visuddhinanda 1 bulan lalu
induk
melakukan
9e881a8a99

+ 42 - 36
api-v12/app/Http/Controllers/ParagraphContentController.php

@@ -19,38 +19,17 @@ class ParagraphContentController extends Controller
     /**
      * Display a listing of the resource.
      */
-    public function index()
+    public function index(Request $request, PaliContentService $paliService)
     {
-        //
-    }
-
-    /**
-     * Store a newly created resource in storage.
-     */
-    public function store(Request $request)
-    {
-        //
-    }
+        $data = $request->validate([
+            'book' => 'required|integer',
+            'para' => 'required|integer',
+            'to' => 'integer',
+        ]);
 
-    /**
-     * Display the specified resource.
-     *      * @param  \Illuminate\Http\Request  $request
-     * @param string $id
-     * @return \Illuminate\Http\Response
-     */
-    public function show(Request $request, string $id)
-    {
-        $paliService = app(PaliContentService::class);
-
-        //
-        $sentId = \explode('-', $id);
         $channels = [];
         if ($request->has('channels')) {
-            if (strpos($request->get('channels'), ',') === FALSE) {
-                $_channels = explode('_', $request->get('channels'));
-            } else {
-                $_channels = explode(',', $request->get('channels'));
-            }
+            $_channels = explode(',', str_replace('_', ',', $request->get('channels')));
             foreach ($_channels as $key => $channel) {
                 if (Str::isUuid($channel)) {
                     $channels[] = $channel;
@@ -71,7 +50,8 @@ class ParagraphContentController extends Controller
             $channels[] = $channelId;
         }
 
-        $chapter = PaliText::where('book', $sentId[0])->where('paragraph', $sentId[1])->first();
+        $chapter = PaliText::where('book', $data['book'])
+            ->where('paragraph', $data['para'])->first();
         if (!$chapter) {
             return $this->error("no data");
         }
@@ -80,9 +60,13 @@ class ParagraphContentController extends Controller
 
         $indexChannel = [];
         $indexChannel = $paliService->getChannelIndex($channels);
-        $from = $sentId[1];
-        $to =  $sentId[2] ?? $sentId[1];
-        $record = Sentence::where('book_id', $sentId[0])
+        $from = $data['para'];
+        if (isset($data['to'])) {
+            $to = $data['to'];
+        } else {
+            $to = $data['para'];
+        }
+        $record = Sentence::where('book_id', $data['book'])
             ->whereBetween('paragraph', [$from, $to])
             ->whereIn('channel_uid', $channels)
             ->orderBy('paragraph')
@@ -91,12 +75,34 @@ class ParagraphContentController extends Controller
         if (count($record) === 0) {
             return $this->error("no data");
         }
-        $result = [];
-        $result['content'] = json_encode($paliService->makeContentObj($record, $mode, $indexChannel), JSON_UNESCAPED_UNICODE);
-        $result['content_type'] = 'json';
-        return $this->ok($result);
+        $result = $paliService->makeContentObj($record, $mode, $indexChannel);
+
+        return $this->ok([
+            'items' => $result,
+            'pagination' => [
+                'page' => 1,
+                'pageSize' => $to - $from + 1,
+                'total' => $to - $from + 1
+            ],
+        ]);
+    }
+
+    /**
+     * Store a newly created resource in storage.
+     */
+    public function store(Request $request)
+    {
+        //
     }
 
+    /**
+     * Display the specified resource.
+     *      * @param  \Illuminate\Http\Request  $request
+     * @param string $id
+     * @return \Illuminate\Http\Response
+     */
+    public function show(Request $request, string $id) {}
+
     /**
      * Update the specified resource in storage.
      */

+ 20 - 0
dashboard-v6/src/api/index.ts

@@ -1,2 +1,22 @@
 export const api_url = (path: string) =>
   `${import.meta.env.VITE_API_BASE}${path}`;
+
+/**API 返回结构 */
+interface ApiResponse<T> {
+  ok: boolean;
+  message?: string;
+  data: T;
+}
+/**分页数据结构 */
+interface PaginatedData<T> {
+  items: T[];
+  pagination: Pagination;
+}
+/**分页信息 */
+interface Pagination {
+  page: number; // 当前页
+  pageSize: number; // 每页数量
+  total: number; // 总记录数
+}
+/** 使用 type ArticleListResponse = PaginatedResponse<Article>; */
+export type PaginatedResponse<T> = ApiResponse<PaginatedData<T>>;

+ 27 - 3
dashboard-v6/src/api/pali-text.ts

@@ -1,5 +1,9 @@
 // src/api/pali-text.ts
 import type { MenuProps } from "antd";
+import { get } from "../request";
+import type { ArticleMode, IArticleResponse } from "./article";
+import type { IWidgetSentEditInner } from "../components/sentence/SentEdit";
+import type { PaginatedResponse } from ".";
 
 export interface ITocPathNode {
   key?: string;
@@ -103,9 +107,6 @@ export interface IChapterTocListResponse {
   data: { rows: IChapterToc[]; count: number };
 }
 
-import { get } from "../request";
-import type { IArticleResponse } from "./article";
-
 export interface IFetchPaliBookTocParams {
   /** 二选一:传 series 走系列模式,否则传 book + para */
   series?: string;
@@ -164,3 +165,26 @@ export const fetchNextParaChunk = (
   if (channelId) url += `&channels=${channelId}`;
   return get<IArticleResponse>(url);
 };
+
+export interface IParagraphNode {
+  book: number;
+  para: number;
+  mode?: ArticleMode;
+  channels?: string[];
+  sentenceIds: string[];
+  children?: IWidgetSentEditInner[];
+}
+
+type ParagraphNodeListResponse = PaginatedResponse<IParagraphNode>;
+
+export const fetchParaNodeChunk = (
+  book: number,
+  from: number,
+  to: number,
+  mode: ArticleMode,
+  channelIds?: string | null
+): Promise<ParagraphNodeListResponse> => {
+  let url = `/api/v2/paragraph-content?book=${book}&mode=${mode}&para=${from}&to=${to}`;
+  if (channelIds) url += `&channels=${channelIds}`;
+  return get<ParagraphNodeListResponse>(url);
+};

+ 2 - 2
dashboard-v6/src/components/article/TypePali.tsx

@@ -26,7 +26,7 @@ import ArticleHeader from "./components/ArticleHeader";
 import { TaskBuilderChapterModal } from "../task/TaskBuilderChapterModal";
 import type { TTarget } from "../../types";
 import TocPath from "../tipitaka/TocPath";
-import { ParagraphCtl } from "../template/Paragraph";
+import ParagraphNode from "../tipitaka/ParagraphNode";
 
 export interface ISearchParams {
   key: string;
@@ -196,7 +196,7 @@ const TypePali = ({
         subTitle={articleData?.subtitle}
         summary={articleData?.summary}
         nodes={nodeData.map((item) => {
-          return <ParagraphCtl {...item} />;
+          return <ParagraphNode initData={item} />;
         })}
         loading={loading}
         errorCode={errorCode}

+ 15 - 19
dashboard-v6/src/components/template/Paragraph.tsx

@@ -1,36 +1,34 @@
-import { useMemo, useState } from "react";
+import { useMemo } from "react";
 import { EditOutlined, EyeOutlined } from "@ant-design/icons";
 
 import { useAppSelector } from "../../hooks";
 import { currFocus } from "../../reducers/focus";
 import { ParaHandleCtl } from "./ParaHandle";
-import { SentEditInner, type IWidgetSentEditInner } from "../sentence/SentEdit";
+import { SentEditInner } from "../sentence/SentEdit";
 import { Button } from "antd";
 import type { ArticleMode } from "../../api/article";
 import ParagraphRead from "../tipitaka/components/ParagraphRead";
+import type { IParagraphNode } from "../../api/pali-text";
 
-export interface IParagraphProps {
-  book: number;
-  para: number;
-  mode?: ArticleMode;
-  channels?: string[];
-  sentenceIds: string[];
-  children?: IWidgetSentEditInner[];
+interface IParagraphProps extends IParagraphNode {
+  loading?: boolean;
   onModeChange?: (mode: ArticleMode) => void;
 }
+
 export const ParagraphCtl = ({
   book,
   para,
-  mode = "read",
+  mode,
   channels,
   sentenceIds,
   children,
+  loading,
   onModeChange,
 }: IParagraphProps) => {
-  const [innerMode, setInnerMode] = useState<ArticleMode>("read");
   const focus = useAppSelector(currFocus);
   console.debug("para children", book, para, children?.length);
   console.debug("para children", children);
+
   const isFocus = useMemo(() => {
     if (focus) {
       if (focus.focus?.type === "para") {
@@ -80,28 +78,26 @@ export const ParagraphCtl = ({
           sentences={sentenceIds}
         />
         <div>
-          {innerMode === "edit" && (
+          {mode === "edit" && (
             <Button
+              loading={loading}
               type="link"
               icon={<EyeOutlined />}
               onClick={() => {
                 if (onModeChange) {
                   onModeChange("read");
-                } else {
-                  setInnerMode("read");
                 }
               }}
             />
           )}
-          {innerMode === "read" && (
+          {mode === "read" && (
             <Button
+              loading={loading}
               type="link"
               icon={<EditOutlined />}
               onClick={() => {
                 if (onModeChange) {
                   onModeChange("edit");
-                } else {
-                  setInnerMode("edit");
                 }
               }}
             />
@@ -109,9 +105,9 @@ export const ParagraphCtl = ({
         </div>
       </div>
       <div>
-        {innerMode === "edit" &&
+        {mode === "edit" &&
           children?.map((item) => <SentEditInner {...item} />)}
-        {innerMode === "read" && <ParagraphRead data={children} />}
+        {mode === "read" && <ParagraphRead data={children} />}
       </div>
     </div>
   );

+ 38 - 0
dashboard-v6/src/components/tipitaka/ParagraphNode.tsx

@@ -0,0 +1,38 @@
+import { useState } from "react";
+import { fetchParaNodeChunk, type IParagraphNode } from "../../api/pali-text";
+import { ParagraphCtl } from "../template/Paragraph";
+
+interface IWidget {
+  initData: IParagraphNode;
+}
+const ParagraphNode = ({ initData }: IWidget) => {
+  const [data, setData] = useState<IParagraphNode>();
+  const [loading, setLoading] = useState(false);
+  const currData = data ?? initData;
+  return (
+    <>
+      {currData && (
+        <ParagraphCtl
+          loading={loading}
+          {...currData}
+          onModeChange={async (mode) => {
+            setLoading(true);
+            const newData = await fetchParaNodeChunk(
+              initData.book,
+              initData.para,
+              initData.para,
+              mode,
+              initData.channels?.join(",")
+            );
+            setLoading(false);
+            if (newData.ok && newData.data.items.length > 0) {
+              setData(newData.data.items[0]);
+            }
+          }}
+        />
+      )}
+    </>
+  );
+};
+
+export default ParagraphNode;

+ 102 - 0
dashboard-v6/src/components/tipitaka/hooks/useParaNodeChunk.ts

@@ -0,0 +1,102 @@
+// ─────────────────────────────────────────────
+// useParaNodeChunk.ts
+// ─────────────────────────────────────────────
+/**
+ * 使用范例
+ * const { data, loading, errorCode, errorMessage, refresh } = useParaNodeChunk({
+    book: 1,
+    from: 1,
+    to: 10,
+    mode: "read",
+    channelIds: null,
+  });
+ */
+import { useState, useEffect, useCallback, useRef } from "react";
+
+import { HttpError } from "../../../request";
+import type { ArticleMode } from "../../../api/article";
+import {
+  fetchParaNodeChunk,
+  type IParagraphNode,
+} from "../../../api/pali-text";
+
+interface IParams {
+  book: number;
+  from: number;
+  to: number;
+  mode: ArticleMode;
+  channelIds?: string | null;
+}
+
+interface IUseParaNodeChunkReturn {
+  data: IParagraphNode[] | null;
+  loading: boolean;
+  errorCode: number | null;
+  errorMessage: string | null;
+  refresh: () => void;
+}
+
+export const useParaNodeChunk = (params?: IParams): IUseParaNodeChunkReturn => {
+  const [data, setData] = useState<IParagraphNode[] | 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);
+
+  const paramsKey = JSON.stringify(params);
+
+  const paramsRef = useRef<IParams | undefined>(params);
+
+  useEffect(() => {
+    paramsRef.current = params;
+    // eslint-disable-next-line react-hooks/exhaustive-deps
+  }, [paramsKey]);
+
+  const refresh = useCallback(() => {
+    setTick((t) => t + 1);
+  }, []);
+
+  useEffect(() => {
+    if (!paramsRef.current) return;
+
+    let active = true;
+
+    const fetchData = async () => {
+      const { book, from, to, mode, channelIds } = paramsRef.current!;
+
+      setLoading(true);
+      setErrorCode(null);
+      setErrorMessage(null);
+
+      try {
+        const res = await fetchParaNodeChunk(book, from, to, mode, channelIds);
+
+        if (!active) return;
+
+        setData(res.data.items);
+      } catch (e) {
+        console.error("para node fetch", e);
+
+        if (!active) return;
+
+        if (e instanceof HttpError) {
+          setErrorCode(e.status);
+          setErrorMessage(e.message);
+        } else {
+          setErrorCode(0);
+          setErrorMessage("Network error");
+        }
+      } finally {
+        if (active) setLoading(false);
+      }
+    };
+
+    fetchData();
+
+    return () => {
+      active = false;
+    };
+  }, [paramsKey, tick]);
+
+  return { data, loading, errorCode, errorMessage, refresh };
+};

+ 6 - 0
dashboard-v6/src/hooks/useAuth.ts

@@ -0,0 +1,6 @@
+export function useAuth() {
+  const token = localStorage.getItem("token");
+  return {
+    isAuthenticated: !!token,
+  };
+}

+ 10 - 6
dashboard-v6/src/hooks/useTipitaka.ts

@@ -7,8 +7,12 @@ import type {
   IArticleDataResponse,
   IChapterToc,
 } from "../api/article";
-import { fetchChapter, fetchNextParaChunk, fetchPara } from "../api/pali-text";
-import type { IParagraphProps } from "../components/template/Paragraph";
+import {
+  fetchChapter,
+  fetchNextParaChunk,
+  fetchPara,
+  type IParagraphNode,
+} from "../api/pali-text";
 
 interface IUseTipitakaProps {
   type?: ArticleType;
@@ -23,7 +27,7 @@ interface IUseTipitakaProps {
 interface IUseTipitakaReturn {
   articleData: IArticleDataResponse | undefined;
   articleHtml: string[];
-  nodeData: IParagraphProps[];
+  nodeData: IParagraphNode[];
   toc: IChapterToc[] | undefined;
   loading: boolean;
   errorCode: number | undefined;
@@ -43,7 +47,7 @@ const useTipitaka = ({
 }: IUseTipitakaProps): IUseTipitakaReturn => {
   const [articleData, setArticleData] = useState<IArticleDataResponse>();
   const [articleHtml, setArticleHtml] = useState<string[]>([]);
-  const [nodeData, setNodeData] = useState<IParagraphProps[]>([]);
+  const [nodeData, setNodeData] = useState<IParagraphNode[]>([]);
   const [toc, setToc] = useState<IChapterToc[]>();
   const [loading, setLoading] = useState(false);
   const [errorCode, setErrorCode] = useState<number>();
@@ -138,9 +142,9 @@ const useTipitaka = ({
         });
         setArticleHtml((prev) => [...prev, response.data.content as string]);
         if (response.data.content && response.data.content_type === "json") {
-          const newNodes: IParagraphProps[] = JSON.parse(
+          const newNodes: IParagraphNode[] = JSON.parse(
             response.data.content
-          ) as IParagraphProps[];
+          ) as IParagraphNode[];
           setNodeData((prev) => [...prev, ...newNodes]);
         }
       }

+ 7 - 1
dashboard-v6/src/layouts/dashboard/index.tsx

@@ -1,8 +1,14 @@
-import { Outlet } from "react-router";
+import { Navigate, Outlet } from "react-router";
 
 import Footer from "../Footer";
+import { useAuth } from "../../hooks/useAuth";
 
 const Widget = () => {
+  const { isAuthenticated } = useAuth();
+
+  if (!isAuthenticated) {
+    return <Navigate to="/anonymous/sign-in" replace />;
+  }
   // TODO
   return (
     <div>

+ 7 - 1
dashboard-v6/src/layouts/workspace/index.tsx

@@ -1,5 +1,5 @@
 import { Button, Layout, Space } from "antd";
-import { Outlet } from "react-router";
+import { Navigate, Outlet } from "react-router";
 import { useState } from "react";
 import { MenuFoldOutlined, MenuUnfoldOutlined } from "@ant-design/icons";
 import MainMenu from "../../components/navigation/MainMenu";
@@ -7,10 +7,16 @@ import SignInAvatar from "../../components/auth/SignInAvatar";
 import HeaderBreadcrumb from "../../components/navigation/HeaderBreadcrumb";
 import ThemeSwitch from "../../components/theme/ThemeSwitch";
 import { NetworkStatus } from "../../components/general/NetworkStatus";
+import { useAuth } from "../../hooks/useAuth";
 
 const { Sider, Content } = Layout;
 const Widget = () => {
   const [collapsed, setCollapsed] = useState(false);
+  const { isAuthenticated } = useAuth();
+
+  if (!isAuthenticated) {
+    return <Navigate to="/anonymous/sign-in" replace />;
+  }
 
   return (
     <Layout style={{ minHeight: "100vh" }}>