Просмотр исходного кода

feat(dashboard-v6): 章节页讨论面板支持「本章」,选中行高亮对应译文

- 讨论面板加「本句 / 本章」切换;本章按经文顺序列出整章讨论,过滤版本 / 类型 / 状态 / 严重程度
- 点一行:正文滚到那句并持续高亮(编辑模式、阅读模式逐句与整段排版);点标题切到本句打开话题
- Discussion 加 onBack,从外部打开的话题能返回列表
- 同步 API 类型

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
visuddhinanda 22 часов назад
Родитель
Сommit
8d58dabfc2

+ 8 - 0
dashboard-v6/src/api/channel.ts

@@ -145,6 +145,14 @@ export async function channelLoader({ params }: LoaderFunctionArgs) {
   return res.data;
 }
 
+/** 按 uid 取一个 channel;取不到返回 undefined(只用来显示名字之类,不弹错) */
+export const fetchChannel = async (
+  channelId: string
+): Promise<IApiResponseChannel["data"] | undefined> => {
+  const res = await get<IApiResponseChannel>(`/api/v2/channel/${channelId}`);
+  return res.ok ? res.data : undefined;
+};
+
 // ─── 获取章节内所有句子 ID ───────────────────────────────────────────────────
 
 /**

+ 38 - 0
dashboard-v6/src/api/discussion.ts

@@ -22,6 +22,10 @@ export type TResType =
   | "task";
 
 type TopicQuery = paths["/v3/discussions"]["get"]["parameters"]["query"];
+/** 按经文范围列话题的过滤条件(channel 在路径上,单独传) */
+export type TTextTopicQuery = NonNullable<
+  paths["/v3/tipitaka-discussions/{channel}"]["get"]["parameters"]["query"]
+>;
 type SummaryQuery =
   paths["/v3/discussions/summary"]["get"]["parameters"]["query"];
 type TopicBody = NonNullable<
@@ -133,6 +137,13 @@ export interface IComment {
   /** 删除了但还挂着回复的话题(墓碑):内容为空,回复照常显示 */
   deleted?: boolean;
   newTpl?: boolean;
+  /** 所在句子的坐标:只有按经文范围列出时有(`/v3/tipitaka-discussions`) */
+  sentence?: {
+    book: number;
+    paragraph: number;
+    wordStart: number;
+    wordEnd: number;
+  };
   createdAt?: string;
   updatedAt?: string;
 }
@@ -164,6 +175,14 @@ export const toIComment = (value: DiscussionData): IComment => ({
   childrenCount: value.reply_count,
   abilities: { ...NO_ABILITIES, ...value.abilities },
   deleted: !!value.deleted_at,
+  sentence: value.sentence
+    ? {
+        book: value.sentence.book ?? 0,
+        paragraph: value.sentence.paragraph ?? 0,
+        wordStart: value.sentence.word_start ?? 0,
+        wordEnd: value.sentence.word_end ?? 0,
+      }
+    : undefined,
   createdAt: value.created_at,
   updatedAt: value.updated_at,
 });
@@ -187,6 +206,25 @@ export const fetchTopics = async (
   };
 };
 
+/**
+ * 一段经文(整章 / 段落区间 / 整本书)里挂在句子上的话题,按经文顺序,每条带 sentence 坐标。
+ * channel 必填:同一段在不同译文里是不同的句子。
+ */
+export const fetchTextTopics = async (
+  channel: string,
+  query: TTextTopicQuery
+): Promise<{ items: IComment[]; total: number }> => {
+  const body = unwrap(
+    await api.GET("/v3/tipitaka-discussions/{channel}", {
+      params: { path: { channel }, query },
+    })
+  );
+  return {
+    items: (body.data ?? []).map(toIComment),
+    total: body.meta?.total ?? 0,
+  };
+};
+
 /** 某个对象上的话题概况:各状态的数量、当前用户能发起的类型 */
 export const fetchTopicSummary = async (
   query: SummaryQuery

+ 222 - 1
dashboard-v6/src/api/schema.d.ts

@@ -7728,7 +7728,8 @@ export interface paths {
          * @description 只列话题(不含回复),删除了但还有回复的话题以墓碑出现(deleted_at 有值、内容为空)。
          *     未登录看不到不公开的话题(discussion);basic 角色在自己无权管理的对象上只看得到
          *     自己发起的不公开话题。各状态的数量与「能发起哪些类型」见 `GET /v3/discussions/summary`。
-         *     不传 type 时只列评论线程(discussion / qa / help / issue),批注要显式 type=note / commentary。
+         *     不传 type 时只列评论线程(discussion / qa / help / issue / glossary),批注要显式 type=note / commentary。
+         *     要一段经文(一章、一段段落区间)里的全部话题,用 `GET /v3/tipitaka-discussions/{channel}`。
          *
          *     实现:`V3\DiscussionController@index`
          */
@@ -8276,6 +8277,33 @@ export interface paths {
         patch?: never;
         trace?: never;
     };
+    "/v3/tipitaka-discussions/{channel}": {
+        parameters: {
+            query?: never;
+            header?: never;
+            path?: never;
+            cookie?: never;
+        };
+        /**
+         * 列出一段经文里的话题
+         * @description 只列挂在该 channel 句子上的话题(不含回复),按经文顺序(书、段、句)排列,同一句里按时间;
+         *     每条带 `sentence`(所在句子的坐标)。删除了但还有回复的话题以墓碑出现。
+         *     不传 type 时只列评论线程(discussion / qa / help / issue / glossary),批注要显式
+         *     type=note / commentary。可见性同 `GET /v3/discussions`:未登录看不到 discussion;
+         *     basic 角色在自己不能编辑的 channel 上只看得到自己发起的 discussion。
+         *     channel 不存在或范围内没有讨论都返回空列表;chapter 不存在 404。
+         *
+         *     实现:`V3\TipitakaDiscussionController@__invoke`
+         */
+        get: operations["get_api_v3_tipitaka_discussions_channel_"];
+        put?: never;
+        post?: never;
+        delete?: never;
+        options?: never;
+        head?: never;
+        patch?: never;
+        trace?: never;
+    };
     "/v3/tipitaka-reading/{channel}": {
         parameters: {
             query?: never;
@@ -34542,6 +34570,13 @@ export interface operations {
                             quote_suffix?: string | null;
                             /** @description 仅在部分查询口径下返回。 */
                             reply_count?: number;
+                            /** @description 仅在部分查询口径下返回。 */
+                            sentence?: {
+                                book?: number;
+                                paragraph?: number;
+                                word_start?: number;
+                                word_end?: number;
+                            };
                             editor?: {
                                 id?: string;
                                 nickName?: string;
@@ -34660,6 +34695,13 @@ export interface operations {
                             quote_suffix?: string | null;
                             /** @description 仅在部分查询口径下返回。 */
                             reply_count?: number;
+                            /** @description 仅在部分查询口径下返回。 */
+                            sentence?: {
+                                book?: number;
+                                paragraph?: number;
+                                word_start?: number;
+                                word_end?: number;
+                            };
                             editor?: {
                                 id?: string;
                                 nickName?: string;
@@ -34721,6 +34763,13 @@ export interface operations {
                             quote_suffix?: string | null;
                             /** @description 仅在部分查询口径下返回。 */
                             reply_count?: number;
+                            /** @description 仅在部分查询口径下返回。 */
+                            sentence?: {
+                                book?: number;
+                                paragraph?: number;
+                                word_start?: number;
+                                word_end?: number;
+                            };
                             editor?: {
                                 id?: string;
                                 nickName?: string;
@@ -34887,6 +34936,13 @@ export interface operations {
                             quote_suffix?: string | null;
                             /** @description 仅在部分查询口径下返回。 */
                             reply_count?: number;
+                            /** @description 仅在部分查询口径下返回。 */
+                            sentence?: {
+                                book?: number;
+                                paragraph?: number;
+                                word_start?: number;
+                                word_end?: number;
+                            };
                             editor?: {
                                 id?: string;
                                 nickName?: string;
@@ -34963,6 +35019,13 @@ export interface operations {
                             quote_suffix?: string | null;
                             /** @description 仅在部分查询口径下返回。 */
                             reply_count?: number;
+                            /** @description 仅在部分查询口径下返回。 */
+                            sentence?: {
+                                book?: number;
+                                paragraph?: number;
+                                word_start?: number;
+                                word_end?: number;
+                            };
                             editor?: {
                                 id?: string;
                                 nickName?: string;
@@ -35083,6 +35146,13 @@ export interface operations {
                             quote_suffix?: string | null;
                             /** @description 仅在部分查询口径下返回。 */
                             reply_count?: number;
+                            /** @description 仅在部分查询口径下返回。 */
+                            sentence?: {
+                                book?: number;
+                                paragraph?: number;
+                                word_start?: number;
+                                word_end?: number;
+                            };
                             editor?: {
                                 id?: string;
                                 nickName?: string;
@@ -35174,6 +35244,13 @@ export interface operations {
                             quote_suffix?: string | null;
                             /** @description 仅在部分查询口径下返回。 */
                             reply_count?: number;
+                            /** @description 仅在部分查询口径下返回。 */
+                            sentence?: {
+                                book?: number;
+                                paragraph?: number;
+                                word_start?: number;
+                                word_end?: number;
+                            };
                             editor?: {
                                 id?: string;
                                 nickName?: string;
@@ -35263,6 +35340,13 @@ export interface operations {
                             quote_suffix?: string | null;
                             /** @description 仅在部分查询口径下返回。 */
                             reply_count?: number;
+                            /** @description 仅在部分查询口径下返回。 */
+                            sentence?: {
+                                book?: number;
+                                paragraph?: number;
+                                word_start?: number;
+                                word_end?: number;
+                            };
                             editor?: {
                                 id?: string;
                                 nickName?: string;
@@ -35324,6 +35408,13 @@ export interface operations {
                             quote_suffix?: string | null;
                             /** @description 仅在部分查询口径下返回。 */
                             reply_count?: number;
+                            /** @description 仅在部分查询口径下返回。 */
+                            sentence?: {
+                                book?: number;
+                                paragraph?: number;
+                                word_start?: number;
+                                word_end?: number;
+                            };
                             editor?: {
                                 id?: string;
                                 nickName?: string;
@@ -36313,6 +36404,136 @@ export interface operations {
             };
         };
     };
+    get_api_v3_tipitaka_discussions_channel_: {
+        parameters: {
+            query?: {
+                /** @description 每页数量,最大 200 */
+                per_page?: number;
+                /**
+                 * @description 书号;与 chapter / para / to 一起用时必填,不传则是整个 channel
+                 * @example 169
+                 */
+                book?: number;
+                /**
+                 * @description 章节起始段落号,服务端按章节长度展开成段落区间;不能与 para / to 同用
+                 * @example 893
+                 */
+                chapter?: number;
+                /**
+                 * @description 起始段落号
+                 * @example 895
+                 */
+                para?: number;
+                /**
+                 * @description 结束段落号(含),缺省同 para
+                 * @example 896
+                 */
+                to?: number;
+                /**
+                 * @description 类型,逗号分隔多选;不传则只列评论线程
+                 * @example issue,glossary
+                 */
+                type?: string;
+                /** @description 话题状态,不传则两种都要 */
+                status?: "active" | "close";
+                /**
+                 * @description 严重程度,逗号分隔多选
+                 * @example error,warning
+                 */
+                severity?: string;
+                /** @description 页码 */
+                page?: number;
+            };
+            header?: never;
+            path: {
+                /** @description 句子所在 channel 的 uuid */
+                channel: string;
+            };
+            cookie?: never;
+        };
+        requestBody?: never;
+        responses: {
+            /** @description 成功 */
+            200: {
+                headers: {
+                    [name: string]: unknown;
+                };
+                content: {
+                    "application/json": {
+                        data?: {
+                            id?: string;
+                            target_type?: string;
+                            target_id?: string;
+                            parent_id?: string | null;
+                            type?: string;
+                            severity?: string | null;
+                            status?: string;
+                            title?: string | null;
+                            content?: string | null;
+                            content_type?: string;
+                            /** @description 仅在部分查询口径下返回。 */
+                            html?: string;
+                            /** @description 仅在部分查询口径下返回。 */
+                            summary?: string;
+                            tpl_id?: string | null;
+                            pos_start?: number | null;
+                            pos_end?: number | null;
+                            quote_exact?: string | null;
+                            quote_prefix?: string | null;
+                            quote_suffix?: string | null;
+                            /** @description 仅在部分查询口径下返回。 */
+                            reply_count?: number;
+                            /** @description 仅在部分查询口径下返回。 */
+                            sentence?: {
+                                book?: number;
+                                paragraph?: number;
+                                word_start?: number;
+                                word_end?: number;
+                            };
+                            editor?: {
+                                id?: string;
+                                nickName?: string;
+                                userName?: string;
+                                realName?: string;
+                                sn?: number;
+                                avatar?: string;
+                                roles?: string[];
+                            } | null;
+                            co_editor?: {
+                                id?: string;
+                                nickName?: string;
+                                userName?: string;
+                                realName?: string;
+                                sn?: number;
+                                avatar?: string;
+                                roles?: string[];
+                            } | null;
+                            abilities?: {
+                                update?: boolean;
+                                delete?: boolean;
+                                close?: boolean;
+                                reply?: boolean;
+                            };
+                            created_at?: string;
+                            updated_at?: string;
+                            deleted_at?: string | null;
+                        }[];
+                        meta?: components["schemas"]["PaginationMeta"];
+                    };
+                };
+            };
+            /** @description chapter 不是本书存在的段落 */
+            404: {
+                headers: {
+                    [name: string]: unknown;
+                };
+                content: {
+                    "application/problem+json": components["schemas"]["ProblemDetails"];
+                };
+            };
+            422: components["responses"]["ProblemValidation"];
+        };
+    };
     get_api_v3_tipitaka_reading_channel_: {
         parameters: {
             query?: {

+ 241 - 0
dashboard-v6/src/components/discussion/ChapterDiscussionList.tsx

@@ -0,0 +1,241 @@
+import { useEffect, useState } from "react";
+import { Button, Segmented, Select, Space, Tag, Typography, theme } from "antd";
+import { ProList } from "@ant-design/pro-components";
+
+import {
+  fetchTextTopics,
+  SEVERITY_OPTIONS,
+  THREAD_TYPES,
+  type IComment,
+  type TDiscussionStatus,
+  type TDiscussionType,
+  type TTextTopicQuery,
+} from "../../api/discussion";
+import { fetchChannel } from "../../api/channel";
+import DiscussionAuthors from "./DiscussionAuthors";
+import { SeverityTag } from "./SeverityTag";
+import TimeShow from "../general/TimeShow";
+
+const { Text } = Typography;
+
+/** 可选的类型:评论线程 + 批注(批注不在默认里,要显式选) */
+const TYPE_OPTIONS: TDiscussionType[] = [...THREAD_TYPES, "note", "commentary"];
+
+/** 选了这些类型时才出现 severity 过滤:只有它们有 severity */
+const SEVERITY_VALUES = [
+  ...new Set(Object.values(SEVERITY_OPTIONS).flat()),
+] as string[];
+
+type TStatusFilter = TDiscussionStatus | "all";
+
+interface IWidget {
+  book: number;
+  /** 章节起始段落号 */
+  chapter: number;
+  /** 页面打开的版本(URL 里 channel=a_b 拆开);讨论挂在句子上,句子属于某个版本 */
+  channels: string[];
+  /** 点某条话题:调用方切到那一句并打开话题 */
+  onOpen?: (topic: IComment) => void;
+  /** 点某一行:选中它(再点一次取消,传 undefined),调用方让正文滚到那一句并高亮 */
+  onSelect?: (topic?: IComment) => void;
+}
+
+/**
+ * 一章里的全部讨论(`GET /v3/tipitaka-discussions/{channel}?book=&chapter=`),按经文顺序。
+ *
+ * 过滤:版本(多版本时选一个,必选)、类型(多选,不选只列话题)、状态、严重程度。
+ * 点一行:选中并让正文滚到那句、高亮;点标题:打开话题。
+ * 话题详情与回复不在这里做:点开交给调用方切到「本句」视图,复用单句讨论那一套。
+ */
+const ChapterDiscussionList = ({
+  book,
+  chapter,
+  channels,
+  onOpen,
+  onSelect,
+}: IWidget) => {
+  const { token } = theme.useToken();
+  const [selectedId, setSelectedId] = useState<string>();
+  const [channel, setChannel] = useState<string | undefined>(channels[0]);
+  const [channelNames, setChannelNames] = useState<Record<string, string>>({});
+  const [types, setTypes] = useState<TDiscussionType[]>([]);
+  const [status, setStatus] = useState<TStatusFilter>("active");
+  const [severity, setSeverity] = useState<string[]>([]);
+
+  // URL 里的版本变了:当前选中的不在里面就换成第一个
+  const [prevChannels, setPrevChannels] = useState(channels.join("_"));
+  if (prevChannels !== channels.join("_")) {
+    setPrevChannels(channels.join("_"));
+    if (!channel || !channels.includes(channel)) {
+      setChannel(channels[0]);
+    }
+  }
+
+  // 多版本时下拉框要显示名字
+  useEffect(() => {
+    if (channels.length < 2) return;
+    let alive = true;
+    Promise.all(
+      channels.map(async (id) => [id, (await fetchChannel(id))?.name ?? id])
+    ).then((pairs) => {
+      if (alive) setChannelNames(Object.fromEntries(pairs));
+    });
+    return () => {
+      alive = false;
+    };
+  }, [channels]);
+
+  const showSeverity =
+    types.length === 0 ||
+    types.some((t) => (SEVERITY_OPTIONS[t] ?? []).length > 0);
+
+  if (!channel) {
+    return (
+      <Typography.Paragraph type="secondary">
+        先在「版本」里选一个译文版本,再看本章的讨论。
+      </Typography.Paragraph>
+    );
+  }
+
+  return (
+    <ProList<IComment>
+      rowKey="id"
+      onRow={(entity) => ({
+        onClick: () => {
+          const next = entity.id === selectedId ? undefined : entity;
+          setSelectedId(next?.id);
+          onSelect?.(next);
+        },
+        style: {
+          cursor: "pointer",
+          backgroundColor:
+            entity.id === selectedId ? token.controlItemBgActive : undefined,
+        },
+      })}
+      params={{ channel, book, chapter, types, status, severity }}
+      request={async (params = {}) => {
+        const query: TTextTopicQuery = {
+          book,
+          chapter,
+          page: params.current ?? 1,
+          per_page: params.pageSize ?? 20,
+        };
+        if (types.length > 0) query.type = types.join(",");
+        if (status !== "all") query.status = status;
+        if (showSeverity && severity.length > 0) {
+          query.severity = severity.join(",");
+        }
+        const res = await fetchTextTopics(channel, query);
+        return { data: res.items, total: res.total, success: true };
+      }}
+      headerTitle={
+        <Space wrap size={[8, 8]}>
+          {channels.length > 1 ? (
+            <Select
+              size="small"
+              style={{ minWidth: 140 }}
+              value={channel}
+              onChange={setChannel}
+              options={channels.map((id) => ({
+                value: id,
+                label: channelNames[id] ?? id.slice(0, 8),
+              }))}
+            />
+          ) : undefined}
+          <Select<TDiscussionType[]>
+            size="small"
+            mode="multiple"
+            allowClear
+            style={{ minWidth: 160 }}
+            placeholder="类型:全部话题"
+            value={types}
+            onChange={setTypes}
+            options={TYPE_OPTIONS.map((value) => ({ value, label: value }))}
+          />
+          <Segmented<TStatusFilter>
+            size="small"
+            value={status}
+            onChange={setStatus}
+            options={[
+              { value: "active", label: "active" },
+              { value: "close", label: "close" },
+              { value: "all", label: "全部" },
+            ]}
+          />
+          {showSeverity ? (
+            <Select<string[]>
+              size="small"
+              mode="multiple"
+              allowClear
+              style={{ minWidth: 120 }}
+              placeholder="严重程度"
+              value={severity}
+              onChange={setSeverity}
+              options={SEVERITY_VALUES.map((value) => ({ value, label: value }))}
+            />
+          ) : undefined}
+        </Space>
+      }
+      metas={{
+        avatar: {
+          render: (_dom, entity) => (
+            <DiscussionAuthors user={entity.user} coEditor={entity.coEditor} />
+          ),
+        },
+        title: {
+          render: (_dom, entity) => (
+            <div>
+              {entity.deleted ? (
+                <Text type="secondary" italic>
+                  (已删除)
+                </Text>
+              ) : (
+                <Button
+                  size="small"
+                  type="link"
+                  onClick={(e) => {
+                    e.stopPropagation(); // 打开话题,不是选中行
+                    onOpen?.(entity);
+                  }}
+                >
+                  {entity.title}
+                </Button>
+              )}
+              {entity.type !== "discussion" ? <Tag>{entity.type}</Tag> : undefined}
+              <SeverityTag severity={entity.severity} />
+              {status === "all" && entity.status === "close" ? (
+                <Tag>close</Tag>
+              ) : undefined}
+            </div>
+          ),
+        },
+        description: {
+          render: (_dom, entity) => (
+            <Space size={4} wrap>
+              {entity.sentence ? (
+                <Text type="secondary">
+                  §{entity.sentence.paragraph} · {entity.sentence.wordStart}–
+                  {entity.sentence.wordEnd}
+                </Text>
+              ) : undefined}
+              {entity.childrenCount ? (
+                <Text type="secondary">{entity.childrenCount} 条回复</Text>
+              ) : undefined}
+              <TimeShow
+                type="secondary"
+                showIcon={false}
+                createdAt={entity.createdAt}
+                updatedAt={entity.updatedAt}
+              />
+            </Space>
+          ),
+        },
+      }}
+      pagination={{ pageSize: 20, showSizeChanger: true, size: "small" }}
+      search={false}
+      bordered
+    />
+  );
+};
+
+export default ChapterDiscussionList;

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

@@ -23,6 +23,8 @@ interface IWidget {
   /** 只列这种类型的话题;不传则列出全部类型 */
   type?: TDiscussionType;
   onTopicReady?: (value: IComment) => void;
+  /** 从话题详情点返回:由外部给的 showTopicId 打开的话题,要外部清掉它才回得到列表 */
+  onBack?: () => void;
 }
 
 const Discussion = ({
@@ -32,6 +34,7 @@ const Discussion = ({
   focus,
   type,
   onTopicReady,
+  onBack,
 }: IWidget) => {
   // 用户手动点击触发的状态
   const [manualTopicId, setManualTopicId] = useState<string>();
@@ -75,7 +78,10 @@ const Discussion = ({
             <Button
               shape="circle"
               icon={<ArrowLeftOutlined />}
-              onClick={() => setManualOpen(false)}
+              onClick={() => {
+                setManualOpen(false);
+                onBack?.();
+              }}
             />
             <Text strong style={{ fontSize: 16 }}>
               {topic ? topic.title : topicTitle}

+ 115 - 33
dashboard-v6/src/components/discussion/DiscussionBox.tsx

@@ -4,55 +4,137 @@ import { useAppSelector } from "../../hooks";
 import {
   type IShowDiscussion,
   message,
+  setHighlight,
   show,
   showAnchor,
 } from "../../reducers/discussion";
-import { Button } from "antd";
+import { Button, Segmented } from "antd";
 import store from "../../store";
 import Discussion from "./Discussion";
+import ChapterDiscussionList from "./ChapterDiscussionList";
 import type { IComment } from "../../api/discussion";
+import type { ArticleType } from "../../api/article";
 
-const DiscussionBoxWidget = () => {
+type TScope = "sentence" | "chapter";
+
+interface IWidget {
+  /** 右侧面板所在页面的文章;章节页(chapter,id 为 book-para)才有「本章」视图 */
+  articleId?: string;
+  articleType?: ArticleType;
+  /** 页面打开的版本,多个用 "_" 拼接 */
+  channelId?: string | null;
+}
+
+const DiscussionBoxWidget = ({ articleId, articleType, channelId }: IWidget) => {
   const [topicId, setTopicId] = useState<string>();
   const [currTopic, setCurrTopic] = useState<IComment>();
 
   const discussionMessage = useAppSelector(message);
 
-  useEffect(() => {
-    if (discussionMessage) {
-      if (discussionMessage.topic) {
-        setTopicId(discussionMessage.topic);
-      }
+  // 章节页:book-para
+  const [book, chapter] =
+    articleType === "chapter" && articleId
+      ? articleId.split("-").map((item) => parseInt(item))
+      : [NaN, NaN];
+  const hasChapter = Number.isInteger(book) && Number.isInteger(chapter);
+  const channels = channelId ? channelId.split("_").filter(Boolean) : [];
+
+  // 章节页默认看本章;点了某一句的讨论(或从本章列表点开一条)就切到本句
+  const [scope, setScope] = useState<TScope>(
+    hasChapter && !discussionMessage?.resId ? "chapter" : "sentence"
+  );
+  // 消息变了:切到本句、打开消息里点名的话题(React「prop 变化时调整 state」模式)
+  const [prevMessage, setPrevMessage] = useState(discussionMessage);
+  if (prevMessage !== discussionMessage) {
+    setPrevMessage(discussionMessage);
+    if (discussionMessage?.resId) {
+      setScope("sentence");
     }
-  }, [discussionMessage]);
+    if (discussionMessage?.topic) {
+      setTopicId(discussionMessage.topic);
+    }
+  }
+
+  // 「本章」列表选中的高亮只在本章视图里有效:切走或面板关掉就清掉
+  useEffect(() => {
+    if (scope !== "chapter") return;
+    return () => {
+      store.dispatch(setHighlight(undefined));
+    };
+  }, [scope]);
+
+  /** 让正文滚到这一句(SentCell 监听 anchor) */
+  const locate = (resId?: string) => {
+    if (!resId) return;
+    store.dispatch(showAnchor({ type: "discussion", resId, resType: "sentence" }));
+  };
 
   return (
     <>
-      <Button
-        type="link"
-        onClick={() => {
-          const anchorInfo: IShowDiscussion = {
-            type: "discussion",
-            resId: discussionMessage?.resId
-              ? discussionMessage?.resId
-              : currTopic?.resId,
-            resType: discussionMessage?.resType,
-          };
-          store.dispatch(show(anchorInfo));
-          store.dispatch(showAnchor(anchorInfo));
-        }}
-      >
-        显示译文
-      </Button>
-      <Discussion
-        resId={discussionMessage?.resId}
-        resType={discussionMessage?.resType}
-        focus={discussionMessage?.comment}
-        showTopicId={topicId}
-        onTopicReady={(value: IComment) => {
-          setCurrTopic(value);
-        }}
-      />
+      {hasChapter ? (
+        <Segmented<TScope>
+          block
+          style={{ marginBottom: 8 }}
+          value={scope}
+          onChange={setScope}
+          options={[
+            { value: "sentence", label: "本句" },
+            { value: "chapter", label: "本章" },
+          ]}
+        />
+      ) : undefined}
+      {scope === "chapter" && hasChapter ? (
+        <ChapterDiscussionList
+          book={book}
+          chapter={chapter}
+          channels={channels}
+          onSelect={(topic) => {
+            store.dispatch(setHighlight(topic?.resId));
+            locate(topic?.resId);
+          }}
+          onOpen={(topic) => {
+            if (!topic.resId) return;
+            // 切到这一句并打开话题:复用单句讨论的详情、回复、编辑
+            const info: IShowDiscussion = {
+              type: "discussion",
+              resId: topic.resId,
+              resType: "sentence",
+              topic: topic.id,
+            };
+            store.dispatch(show(info));
+            locate(topic.resId);
+          }}
+        />
+      ) : (
+        <>
+          <Button
+            type="link"
+            onClick={() => {
+              const anchorInfo: IShowDiscussion = {
+                type: "discussion",
+                resId: discussionMessage?.resId
+                  ? discussionMessage?.resId
+                  : currTopic?.resId,
+                resType: discussionMessage?.resType,
+              };
+              store.dispatch(show(anchorInfo));
+              store.dispatch(showAnchor(anchorInfo));
+            }}
+          >
+            显示译文
+          </Button>
+          <Discussion
+            resId={discussionMessage?.resId}
+            resType={discussionMessage?.resType}
+            focus={discussionMessage?.comment}
+            showTopicId={topicId}
+            onTopicReady={(value: IComment) => {
+              setCurrTopic(value);
+            }}
+            onBack={() => setTopicId(undefined)}
+          />
+        </>
+      )}
     </>
   );
 };

+ 7 - 1
dashboard-v6/src/components/editor/Editor.tsx

@@ -142,7 +142,13 @@ export default function Editor({
       key: "discussion",
       icon: <CommentOutlined />,
       label: "讨论",
-      content: <DiscussionBox />,
+      content: (
+        <DiscussionBox
+          articleId={articleId}
+          articleType={articleType}
+          channelId={channelId}
+        />
+      ),
     },
     {
       key: "suggestion",

+ 7 - 5
dashboard-v6/src/components/sentence/SentCell.tsx

@@ -15,7 +15,7 @@ import { accept, doneSent, done, sentence } from "../../reducers/accept-pr";
 import SentWbwEdit from "./SentWbwEdit";
 import { getEnding } from "../../reducers/nissaya-ending-vocabulary";
 
-import { anchor, message } from "../../reducers/discussion";
+import { anchor, highlight, message } from "../../reducers/discussion";
 import TextDiff from "../general/TextDiff";
 
 import type { IDeleteResponse } from "../../api/article";
@@ -88,6 +88,7 @@ const SentCellWidget = ({
   const [prOpen, setPrOpen] = useState(false);
   const discussionMessage = useAppSelector(message);
   const anchorInfo = useAppSelector(anchor);
+  const highlightId = useAppSelector(highlight);
   const [copyOpen, setCopyOpen] = useState<boolean>(false);
 
   // sentData 由 useMemo 派生:优先级 overrideSentData > value > initValue
@@ -100,15 +101,16 @@ const SentCellWidget = ({
 
   const bgColor = useMemo(() => {
     if (
-      discussionMessage &&
-      discussionMessage.resId &&
-      discussionMessage.resId === initValue?.id
+      (discussionMessage &&
+        discussionMessage.resId &&
+        discussionMessage.resId === initValue?.id) ||
+      (highlightId && highlightId === initValue?.id)
     ) {
       return "#1890ff33";
     } else {
       return undefined;
     }
-  }, [discussionMessage, initValue?.id]);
+  }, [discussionMessage, highlightId, initValue?.id]);
 
   useEffect(() => {
     if (anchorInfo && anchorInfo?.resId === initValue?.id) {

+ 18 - 1
dashboard-v6/src/components/tipitaka/components/ParagraphReadPara.tsx

@@ -4,6 +4,7 @@ import MdOrigin from "../../sentence/components/MdOrigin";
 import MdTranslation from "../../sentence/components/MdTranslation";
 import type { IWidgetSentEditInner } from "../../sentence/SentEdit";
 import CommentaryPad from "./CommentaryPad";
+import { SENT_HIGHLIGHT_BG, useSentHighlight } from "./useSentHighlight";
 
 interface IWidget {
   data?: IWidgetSentEditInner[];
@@ -12,6 +13,7 @@ const ParagraphReadPara = ({ data }: IWidget) => {
   const direction = useSetting("setting.layout.direction");
   const layoutCommentary = useSetting("setting.layout.commentary");
   console.debug("direction", direction);
+  const { rootRef, isHighlighted } = useSentHighlight();
 
   const channels: string[] = [];
   data?.forEach((value) => {
@@ -44,6 +46,7 @@ const ParagraphReadPara = ({ data }: IWidget) => {
         </div>
         {/**译文区 */}
         <div
+          ref={rootRef}
           className="sent_read"
           style={{ display: "block", flex: 5, padding: 4 }}
         >
@@ -53,7 +56,21 @@ const ParagraphReadPara = ({ data }: IWidget) => {
                 {data?.map((item) => {
                   return item.translation?.map((trans, id) => {
                     if (trans.channel.id === channel) {
-                      return <MdTranslation text={trans.html} key={id} />;
+                      // 整段排版里句子是行内的:套一层 span 承载定位与高亮
+                      return (
+                        <span
+                          key={id}
+                          data-sent-uid={trans.id}
+                          style={{
+                            transition: "background-color 0.3s",
+                            backgroundColor: isHighlighted(trans.id)
+                              ? SENT_HIGHLIGHT_BG
+                              : undefined,
+                          }}
+                        >
+                          <MdTranslation text={trans.html} />
+                        </span>
+                      );
                     }
                   });
                 })}

+ 15 - 3
dashboard-v6/src/components/tipitaka/components/ParagraphReadSent.tsx

@@ -4,6 +4,7 @@ import MdOrigin from "../../sentence/components/MdOrigin";
 import MdTranslation from "../../sentence/components/MdTranslation";
 import type { IWidgetSentEditInner } from "../../sentence/SentEdit";
 import CommentaryPad from "./CommentaryPad";
+import { SENT_HIGHLIGHT_BG, useSentHighlight } from "./useSentHighlight";
 
 interface IWidget {
   data?: IWidgetSentEditInner[];
@@ -13,6 +14,8 @@ const ParagraphReadSent = ({ data }: IWidget) => {
   const layoutCommentary = useSetting("setting.layout.commentary");
   console.debug("direction", direction);
 
+  const { rootRef, isHighlighted } = useSentHighlight();
+
   const channels: string[] = [];
   data?.forEach((value) => {
     value.translation?.forEach((trans) => {
@@ -23,7 +26,7 @@ const ParagraphReadSent = ({ data }: IWidget) => {
   });
 
   return (
-    <div>
+    <div ref={rootRef}>
       {data?.map((item) => {
         return (
           <Flex vertical={layoutCommentary === "column"}>
@@ -45,8 +48,17 @@ const ParagraphReadSent = ({ data }: IWidget) => {
               >
                 {item.translation?.map((tran, id) => {
                   return (
-                    <div>
-                      <MdTranslation text={tran.html} key={id} />
+                    <div
+                      key={id}
+                      data-sent-uid={tran.id}
+                      style={{
+                        transition: "background-color 0.3s",
+                        backgroundColor: isHighlighted(tran.id)
+                          ? SENT_HIGHLIGHT_BG
+                          : undefined,
+                      }}
+                    >
+                      <MdTranslation text={tran.html} />
                     </div>
                   );
                 })}

+ 31 - 0
dashboard-v6/src/components/tipitaka/components/useSentHighlight.ts

@@ -0,0 +1,31 @@
+import { useEffect, useRef } from "react";
+import { useAppSelector } from "../../../hooks";
+import { anchor, highlight, message } from "../../../reducers/discussion";
+
+export const SENT_HIGHLIGHT_BG = "#1890ff33";
+
+/**
+ * 阅读模式的译文跟着讨论面板走(编辑模式由 SentCell 负责,口径相同):
+ * - 定位:anchor 指向容器里的某句译文(`[data-sent-uid]`)时滚到那句
+ * - 高亮:正在看讨论的那句、「本章」列表选中的那句
+ */
+export const useSentHighlight = <T extends HTMLElement = HTMLDivElement>() => {
+  const anchorInfo = useAppSelector(anchor);
+  const discussionMessage = useAppSelector(message);
+  const highlightId = useAppSelector(highlight);
+  const rootRef = useRef<T>(null);
+
+  useEffect(() => {
+    if (!anchorInfo?.resId) return;
+    rootRef.current
+      ?.querySelector<HTMLElement>(
+        `[data-sent-uid="${CSS.escape(anchorInfo.resId)}"]`
+      )
+      ?.scrollIntoView({ behavior: "smooth", block: "center", inline: "nearest" });
+  }, [anchorInfo]);
+
+  const isHighlighted = (id?: string) =>
+    !!id && (id === discussionMessage?.resId || id === highlightId);
+
+  return { rootRef, isHighlighted };
+};

+ 8 - 1
dashboard-v6/src/reducers/discussion.ts

@@ -19,6 +19,8 @@ export interface IShowDiscussion {
 interface IState {
   message?: IShowDiscussion;
   anchor?: IShowDiscussion;
+  /** 讨论面板「本章」列表选中的那一句(句子 id),正文持续高亮它 */
+  highlight?: string;
 }
 
 const initialState: IState = {};
@@ -33,13 +35,18 @@ export const slice = createSlice({
     showAnchor: (state, action: PayloadAction<IShowDiscussion>) => {
       state.anchor = action.payload;
     },
+    setHighlight: (state, action: PayloadAction<string | undefined>) => {
+      state.highlight = action.payload;
+    },
   },
 });
 
-export const { show, showAnchor } = slice.actions;
+export const { show, showAnchor, setHighlight } = slice.actions;
 
 export const message = (state: RootState): IShowDiscussion | undefined =>
   state.discussion.message;
 export const anchor = (state: RootState): IShowDiscussion | undefined =>
   state.discussion.anchor;
+export const highlight = (state: RootState): string | undefined =>
+  state.discussion.highlight;
 export default slice.reducer;