visuddhinanda 1 miesiąc temu
rodzic
commit
43961f6759

+ 23 - 3
dashboard-v6/src/Router.tsx

@@ -4,7 +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";
+import { anthologyLoader, articleLoader } from "./api/Article";
 
 const RootLayout = lazy(() => import("./layouts/Root"));
 const AnonymousLayout = lazy(() => import("./layouts/anonymous"));
@@ -44,9 +44,15 @@ const WorkspaceAnthologyList = lazy(
 const WorkspaceAnthologyShow = lazy(
   () => import("./pages/workspace/anthology/show")
 );
+const WorkspaceAnthologyEdit = lazy(
+  () => import("./pages/workspace/anthology/edit")
+);
 
 // 文章
 const WorkspaceArticleList = lazy(() => import("./pages/workspace/article"));
+const WorkspaceArticleShow = lazy(
+  () => import("./pages/workspace/article/show")
+);
 
 // ↓ 新增:TestLayout
 const TestLayout = lazy(() => import("./layouts/test"));
@@ -113,12 +119,21 @@ const router = createBrowserRouter(
                 },
                 {
                   path: ":id",
-                  Component: WorkspaceAnthologyShow,
                   loader: anthologyLoader,
                   handle: {
                     crumb: (match: { data: { title: string } }) =>
                       match.data.title,
                   },
+                  children: [
+                    { index: true, Component: WorkspaceAnthologyShow },
+                    {
+                      path: "edit",
+                      handle: {
+                        crumb: "edit",
+                      },
+                      Component: WorkspaceAnthologyEdit,
+                    },
+                  ],
                 },
               ],
             },
@@ -135,7 +150,12 @@ const router = createBrowserRouter(
                 },
                 {
                   path: ":id",
-                  Component: WorkspaceAnthologyShow,
+                  Component: WorkspaceArticleShow,
+                  loader: articleLoader,
+                  handle: {
+                    crumb: (match: { data: { title: string } }) =>
+                      match.data.title,
+                  },
                 },
               ],
             },

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

@@ -301,7 +301,7 @@ export interface IFetchArticleParams {
   /** 频道 ID 列表,后端用 `_` 分隔;anthology 有 default_channel 时可不传 */
   channelIds?: string[];
   /** 文集 UUID,影响 path / toc 生成和 channel 回退逻辑 */
-  anthologyId?: string;
+  anthologyId?: string | null;
   /** 课程 ID,影响 channel 选择(答案频道 / 用户作业频道) */
   courseId?: string;
   /** 读写模式,后端默认 read */
@@ -589,3 +589,19 @@ export async function anthologyLoader({ params }: LoaderFunctionArgs) {
 
   return res.data;
 }
+
+export async function articleLoader({ params }: LoaderFunctionArgs) {
+  const id = params.id;
+
+  if (!id) {
+    throw new Response("Missing channelId", { status: 400 });
+  }
+
+  const res = await fetchArticle(id);
+
+  if (!res.ok) {
+    throw new Response("Channel not found", { status: 404 });
+  }
+
+  return res.data;
+}

+ 1 - 14
dashboard-v6/src/components/anthology/AnthologyTocEdit.tsx

@@ -1,6 +1,5 @@
 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";
@@ -17,12 +16,7 @@ interface Props {
   onArticleClick?: (anthologyId: string, id: string, target: string) => void;
 }
 
-const AnthologyTocEdit = ({
-  id,
-  channels,
-  editorStudioName,
-  onArticleClick,
-}: Props) => {
+const AnthologyTocEdit = ({ id, editorStudioName }: Props) => {
   const { data, loading, errorCode } = useAnthology(id);
   const intl = useIntl();
 
@@ -38,13 +32,6 @@ const AnthologyTocEdit = ({
             {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}

+ 9 - 2
dashboard-v6/src/components/anthology/hooks/useAnthology.tsx

@@ -23,6 +23,7 @@ import {
   fetchAnthology,
   type IAnthologyDataResponse,
 } from "../../../api/Article";
+import { HttpError } from "../../../request";
 
 interface UseAnthologyResult {
   data: IAnthologyDataResponse | null;
@@ -58,9 +59,15 @@ export const useAnthology = (id?: string): UseAnthologyResult => {
         }
 
         setData(res.data);
-      } catch (err) {
+      } catch (e) {
+        console.error("anthology fetch", e);
+
         if (active) {
-          setErrorCode(err as number);
+          if (e instanceof HttpError) {
+            setErrorCode(e.status); // 422 / 429 / 500 / 502 …
+          } else {
+            setErrorCode(0); // 用 0 表示网络层错误
+          }
         }
       } finally {
         if (active) setLoading(false);

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

@@ -1,126 +0,0 @@
-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;

+ 10 - 12
dashboard-v6/src/components/article/ArticleDrawer.tsx

@@ -7,6 +7,7 @@ import type {
   ArticleType,
   IArticleDataResponse,
 } from "../../api/Article";
+import TypeArticle from "./TypeArticle";
 const { Text } = Typography;
 
 interface IWidget {
@@ -25,12 +26,10 @@ interface IWidget {
   onArticleEdit?: (value: IArticleDataResponse) => void;
 }
 
-const ArticleDrawerWidget = ({
+const ArticleDrawer = ({
   trigger,
   title,
   type,
-  book,
-  para,
   channelId,
   articleId,
   mode,
@@ -43,9 +42,11 @@ const ArticleDrawerWidget = ({
   useEffect(() => {
     setOpenDrawer(open);
   }, [open]);
+
   useEffect(() => {
     setDrawerTitle(title);
   }, [title]);
+
   const showDrawer = () => {
     setOpenDrawer(true);
   };
@@ -61,11 +62,9 @@ const ArticleDrawerWidget = ({
   };
 
   const getUrl = (openMode?: string): string => {
-    let url = `/article/${type}/${articleId}?mode=`;
+    let url = `/workspace/${type}/${articleId}?mode=`;
     url += openMode ? openMode : mode ? mode : "read";
     url += channelId ? `&channel=${channelId}` : "";
-    url += book ? `&book=${book}` : "";
-    url += para ? `&par=${para}` : "";
     return url;
   };
 
@@ -95,18 +94,17 @@ const ArticleDrawerWidget = ({
         extra={
           <Space>
             <Button>
-              <Link to={getUrl()}>在单页面中打开</Link>
-            </Button>
-            <Button>
-              <Link to={getUrl("edit")}>翻译模式</Link>
+              <Link to={getUrl()} target="_blank">
+                在新标签页打开
+              </Link>
             </Button>
           </Space>
         }
       >
-        <>mock</>
+        <TypeArticle articleId={articleId} />
       </Drawer>
     </>
   );
 };
 
-export default ArticleDrawerWidget;
+export default ArticleDrawer;

+ 4 - 4
dashboard-v6/src/components/article/ArticleEdit.tsx

@@ -57,7 +57,7 @@ interface IWidget {
   onSubmit?: (data: IArticleDataResponse) => void;
 }
 
-const ArticleEditWidget = ({
+const ArticleEdit = ({
   studioName,
   articleId,
   anthologyId,
@@ -149,7 +149,7 @@ const ArticleEditWidget = ({
             to_tpl: values.to_tpl,
             anthology_id: anthologyId,
           };
-          const url = `/v2/article/${articleId}`;
+          const url = `/api/v2/article/${articleId}`;
           console.info("save url", url, request);
           put<IArticleDataRequest, IArticleResponse>(url, request)
             .then((res) => {
@@ -172,7 +172,7 @@ const ArticleEditWidget = ({
             });
         }}
         request={async () => {
-          const url = `/v2/article/${articleId}`;
+          const url = `/api/v2/article/${articleId}`;
           console.info("url", url);
           const res = await get<IArticleResponse>(url);
           console.log("article", res);
@@ -300,4 +300,4 @@ const ArticleEditWidget = ({
   );
 };
 
-export default ArticleEditWidget;
+export default ArticleEdit;

+ 6 - 7
dashboard-v6/src/components/article/ArticleEditTools.tsx

@@ -3,11 +3,10 @@ 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";
+import TplBuilder from "../tpl-builder/TplBuilder";
+import AddToAnthology from "../anthology/AddToAnthology";
+import { EResType } from "../share/utils";
 
 interface IWidget {
   studioName?: string;
@@ -22,7 +21,7 @@ const ArticleEditToolsWidget = ({
   const intl = useIntl();
   return (
     <Space>
-      <Builder trigger={<Button type="link">{"<t>"}</Button>} />
+      <TplBuilder trigger={<Button type="link">{"<t>"}</Button>} />
       {articleId ? (
         <AddToAnthology
           trigger={<Button type="link">加入文集</Button>}
@@ -46,9 +45,9 @@ const ArticleEditToolsWidget = ({
       <Link to={`/article/article/${articleId}`} target="_blank">
         {intl.formatMessage({ id: "buttons.open.in.tab" })}
       </Link>
-      <ArticleTplModal
+      <TplBuilder
         title={title}
-        type="article"
+        tpl="article"
         articleId={articleId}
         trigger={<Button type="link">获取模版</Button>}
       />

+ 17 - 1
dashboard-v6/src/components/article/ArticleList.tsx

@@ -40,6 +40,7 @@ import { EResType } from "../share/utils";
 import TplBuilder from "../tpl-builder/TplBuilder";
 import AddToAnthology from "../anthology/AddToAnthology";
 import StatusBadge from "../general/StatusBadge";
+import ArticleDrawer from "./ArticleDrawer";
 
 const { Text } = Typography;
 
@@ -95,6 +96,8 @@ const ArticleList = ({
   onPageChange,
 }: IWidget) => {
   const intl = useIntl(); //i18n
+  const [openDrawer, setOpenOpenDrawer] = useState(false);
+  const [currArticleId, setCurrArticleId] = useState<string>();
   const [openCreate, setOpenCreate] = useState(false);
   const [anthologyId, setAnthologyId] = useState<string>();
   const [myNumber, setMyNumber] = useState<number>(0);
@@ -234,8 +237,11 @@ const ArticleList = ({
                       onClick={(
                         event: React.MouseEvent<HTMLElement, MouseEvent>
                       ) => {
-                        if (typeof onSelect !== "undefined") {
+                        if (onSelect) {
                           onSelect(row.id, row.title, event);
+                        } else {
+                          setOpenOpenDrawer(true);
+                          setCurrArticleId(row.id);
                         }
                       }}
                     >
@@ -589,6 +595,16 @@ const ArticleList = ({
         open={transferOpen}
         onOpenChange={(visible: boolean) => setTransferOpen(visible)}
       />
+
+      <ArticleDrawer
+        articleId={currArticleId}
+        type="article"
+        open={openDrawer}
+        onClose={() => {
+          setCurrArticleId(undefined);
+          setOpenOpenDrawer(false);
+        }}
+      />
     </>
   );
 };

+ 9 - 70
dashboard-v6/src/components/article/ArticleReader.tsx

@@ -1,13 +1,7 @@
-import { useEffect, useState } from "react";
+import type React from "react";
 import { Divider, Space, Tag } from "antd";
 
-import { get } from "../../request";
-import type {
-  ArticleMode,
-  ArticleType,
-  IArticleNavData,
-  IArticleNavResponse,
-} from "../../api/Article";
+import type { ArticleMode, ArticleType } from "../../api/Article";
 
 import "./article.css";
 
@@ -20,7 +14,7 @@ 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 ArticleNavigation from "./components/ArticleNavigation";
 import TocPath from "../tipitaka/TocPath";
 import { useArticle } from "./hooks/useArticle";
 
@@ -41,6 +35,7 @@ interface IWidget {
   ) => void;
   onEdit?: () => void;
 }
+
 const ArticleReader = ({
   articleId,
   channelId,
@@ -53,12 +48,12 @@ const ArticleReader = ({
   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("_") : [],
+    anthologyId: anthologyId,
   });
 
   const articleData = data;
@@ -72,21 +67,6 @@ const ArticleReader = ({
     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 = {
@@ -98,20 +78,6 @@ const ArticleReader = ({
 
   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 ? (
@@ -201,38 +167,11 @@ const ArticleReader = ({
             }}
           />
           <Divider />
-          <NavigateButton
-            prevTitle={nav?.prev?.title}
-            nextTitle={nav?.next?.title}
-            topOfChapter={topOfChapter}
-            endOfChapter={endOfChapter}
+          <ArticleNavigation
+            articleId={articleId}
+            anthologyId={anthologyId}
             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");
-                  }
-                }
-              }
-            }}
+            onArticleChange={onArticleChange}
           />
           {hideInteractive ? <></> : <></>}
         </>

+ 6 - 3
dashboard-v6/src/components/article/TypeArticle.tsx

@@ -1,4 +1,4 @@
-import { useState } from "react";
+import React, { useState } from "react";
 import { Modal } from "antd";
 import { ExclamationCircleOutlined } from "@ant-design/icons";
 import type {
@@ -21,6 +21,7 @@ interface IWidget {
   hideInteractive?: boolean;
   hideTitle?: boolean;
   isSubWindow?: boolean;
+  headerExtra?: React.ReactNode;
   onArticleChange?: (type: ArticleType, id: string, target?: TTarget) => void;
   onArticleEdit?: (value: IArticleDataResponse) => void;
   onLoad?: (data: IArticleDataResponse) => void;
@@ -29,12 +30,13 @@ interface IWidget {
     e: React.MouseEvent<HTMLElement, MouseEvent>
   ) => void;
 }
-const TypeArticleWidget = ({
+const TypeArticle = ({
   channelId,
   parentChannels,
   articleId,
   anthologyId,
   mode = "read",
+  headerExtra,
   active = false,
   hideInteractive = false,
   hideTitle = false,
@@ -46,6 +48,7 @@ const TypeArticleWidget = ({
   const [edit, setEdit] = useState(false);
   return (
     <div>
+      {headerExtra}
       {edit ? (
         <ArticleEdit
           anthologyId={anthologyId ? anthologyId : undefined}
@@ -97,4 +100,4 @@ const TypeArticleWidget = ({
   );
 };
 
-export default TypeArticleWidget;
+export default TypeArticle;

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

@@ -0,0 +1,91 @@
+import { useEffect, useState } from "react";
+import { get } from "../../../request";
+import type {
+  ArticleType,
+  IArticleNavData,
+  IArticleNavResponse,
+} from "../../../api/Article";
+import type { TTarget } from "../../../types";
+import NavigateButton from "./NavigateButton";
+import type { ITocPathNode } from "../../../api/pali-text";
+
+interface IArticleNavigationProps {
+  articleId?: string;
+  anthologyId?: string | null;
+  path?: ITocPathNode[];
+  onArticleChange?: (type: ArticleType, id: string, target?: TTarget) => void;
+}
+
+const ArticleNavigation = ({
+  articleId,
+  anthologyId,
+  path,
+  onArticleChange,
+}: IArticleNavigationProps) => {
+  const [nav, setNav] = useState<IArticleNavData>();
+
+  useEffect(() => {
+    const url = `/api/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 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 (
+    <NavigateButton
+      prevTitle={nav?.prev?.title}
+      nextTitle={nav?.next?.title}
+      topOfChapter={topOfChapter}
+      endOfChapter={endOfChapter}
+      path={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 = 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");
+            }
+          }
+        }
+      }}
+    />
+  );
+};
+
+export default ArticleNavigation;

+ 9 - 0
dashboard-v6/src/components/article/components/EditableTree.tsx

@@ -168,6 +168,15 @@ const EditableTreeWidget = ({
     tocGetTreeData(initValue ?? [])
   );
 
+  const [prevInitValue, setPrevInitValue] = useState<
+    ListNodeData[] | undefined
+  >(undefined);
+
+  if (!isControlled && initValue !== undefined && initValue !== prevInitValue) {
+    setPrevInitValue(initValue);
+    setInternalGData(tocGetTreeData(initValue));
+  }
+
   // 用 state 存上一次的 prop 值,用于在 render 阶段对比变化
   // 这是 React 官方文档推荐的派生 state 模式
   // https://react.dev/reference/react/useState#storing-information-from-previous-renders

+ 9 - 2
dashboard-v6/src/components/article/hooks/useArticle.ts

@@ -39,6 +39,7 @@ import type {
   IArticleDataResponse,
   IFetchArticleParams,
 } from "../../../api/Article";
+import { HttpError } from "../../../request";
 
 interface IUseArticleReturn {
   data: IArticleDataResponse | null;
@@ -90,9 +91,15 @@ export const useArticle = (
 
         setData(res.data);
       } catch (e) {
+        console.error("article fetch", e);
         if (!active) return;
-        setErrorCode(e as number);
-        setErrorMessage("Unknown error");
+        if (e instanceof HttpError) {
+          setErrorCode(e.status); // 422 / 429 / 500 / 502 …
+          setErrorMessage(e.message);
+        } else {
+          setErrorCode(0); // 用 0 表示网络层错误
+          setErrorMessage("Network error");
+        }
       } finally {
         if (active) setLoading(false);
       }

+ 0 - 61
dashboard-v6/src/components/general/NetStatus.tsx

@@ -1,61 +0,0 @@
-import { Button } from "antd";
-import { CloudOutlined } from "@ant-design/icons";
-import { useEffect } from "react";
-import { useAppSelector } from "../../hooks";
-import { netStatus } from "../../reducers/net-status";
-
-interface IWidget {
-  style?: React.CSSProperties;
-}
-const NetStatusWidget = ({ style }: IWidget) => {
-  const mNetStatus = useAppSelector(netStatus);
-
-  useEffect(() => {
-    // 监听网络连接状态变化
-    const onOnline = () => console.info("网络连接已恢复");
-    const onOffline = () => console.info("网络连接已中断");
-
-    window.addEventListener("online", onOnline);
-    window.addEventListener("offline", onOffline);
-
-    return () => {
-      window.removeEventListener("online", onOnline);
-      window.removeEventListener("offline", onOffline);
-    };
-  }, []);
-
-  let loading = false;
-  console.log("net status", mNetStatus);
-  switch (mNetStatus?.status) {
-    case "loading":
-      loading = true;
-      break;
-    case "success":
-      loading = false;
-      break;
-    case "fail":
-      loading = false;
-      break;
-    default:
-      break;
-  }
-  let label = "online";
-  if (mNetStatus?.message) {
-    label = mNetStatus?.message;
-  }
-
-  return (
-    <>
-      <Button
-        style={style}
-        type="text"
-        loading={loading}
-        icon={<CloudOutlined />}
-      >
-        {label}
-      </Button>
-    </>
-  );
-};
-
-export default NetStatusWidget;

+ 1 - 3
dashboard-v6/src/pages/workspace/anthology/show.tsx

@@ -18,9 +18,7 @@ const Widget = () => {
         id={id}
         onArticleClick={(anthologyId, articleId, target) => {
           console.log("click", target);
-          navigate(
-            `/workspace/article/${articleId}?anthology=${anthologyId}&channel=${articleId}`
-          );
+          navigate(`/workspace/article/${articleId}?anthology=${anthologyId}`);
         }}
       />
     </>

+ 1 - 5
dashboard-v6/src/pages/workspace/article/index.tsx

@@ -1,12 +1,11 @@
 import { useAppSelector } from "../../../hooks";
 import { currentUser } from "../../../reducers/current-user";
-import { useNavigate, useSearchParams } from "react-router";
+import { 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();
 
@@ -43,9 +42,6 @@ const Widget = () => {
         onTabChange={handleTabChange}
         onPageChange={handlePageChange}
         studioName={studioName}
-        onSelect={(id) => {
-          navigate(`/workspace/article/${id}`);
-        }}
       />
     </>
   );

+ 39 - 0
dashboard-v6/src/pages/workspace/article/show.tsx

@@ -0,0 +1,39 @@
+import { useNavigate, useParams, useSearchParams } from "react-router";
+import TypeArticle from "../../../components/article/TypeArticle";
+import SplitLayout from "../../../components/general/SplitLayout";
+import type { ArticleMode } from "../../../api/Article";
+
+const Widget = () => {
+  const { id } = useParams();
+  const [searchParams, setSearchParams] = useSearchParams();
+  const navigate = useNavigate();
+  const mode = searchParams.get("mode") ?? "read";
+  const channelId = searchParams.get("channel");
+  const anthology = searchParams.get("anthology");
+
+  return (
+    <SplitLayout key="mode-a" sidebarTitle="table of content" sidebar={<></>}>
+      {({ expandButton }) => (
+        <TypeArticle
+          articleId={id}
+          mode={mode as ArticleMode}
+          anthologyId={anthology}
+          channelId={channelId}
+          headerExtra={expandButton}
+          onAnthologySelect={(id) => {
+            setSearchParams((prev) => {
+              const next = new URLSearchParams(prev);
+              next.set("anthology", id);
+              return next;
+            });
+          }}
+          onArticleChange={(type, id) => {
+            navigate(`/workspace/${type}/${id}`);
+          }}
+        />
+      )}
+    </SplitLayout>
+  );
+};
+
+export default Widget;

+ 23 - 0
dashboard-v6/src/request.ts

@@ -2,6 +2,16 @@ import { GraphQLError } from "graphql";
 
 import { get as get_token } from "./reducers/session";
 
+export class HttpError extends Error {
+  constructor(
+    public status: number,
+    message?: string
+  ) {
+    super(message ?? `HTTP ${status}`);
+    this.name = "HttpError";
+  }
+}
+
 export const upload = () => {
   return {
     Authorization: `Bearer ${get_token()}`,
@@ -22,6 +32,19 @@ export const options = (method: string): RequestInit => {
 
 export const get = async <R>(path: string): Promise<R> => {
   const response = await fetch(path, options("GET"));
+
+  if (!response.ok) {
+    // 尝试读取后端返回的错误信息
+    let message: string | undefined;
+    try {
+      const body = await response.json();
+      message = body?.message ?? body?.error;
+    } catch {
+      // 忽略,body 可能不是 JSON
+    }
+    throw new HttpError(response.status, message);
+  }
+
   const res: R = await response.json();
   return res;
 };