Răsfoiți Sursa

feat(dashboard-v6): 讨论切到 v3;补回 v4 漏搬的讨论入口;角标与标签拆开

- 讨论组件全部改调 /v3/discussions(openapi-fetch),按 abilities 显示菜单;严重程度、墓碑、
  co_editor「(与 X)」显示
- 新页面 /discussion/topic/:id(复制链接的去处)、/discussion/show/:type/:id(对象的全部讨论),
  均在 workspace 外、不要求登录;话题页上方显示挂靠内容
- 补回 v6 搬运时漏掉的:文章底部问答区 InteractiveArea(改用 v3 summary)、术语页讨论
  (DiscussionMock 换回 Discussion)、逐词讨论图标(原被注释掉)
- 讨论角标改走 v3 batch-count:DiscussionButton 自己登记对象,DiscussionBadgeLoader 攒批请求;
  修掉角标恒为 0(空数组长度盖掉了 initCount);标签拆到 tag-map reducer + TagsLoader,仍走 v2
- 删除 DiscussionList、DiscussionCount、DiscussionMock、discussion-count reducer、api/Comment.ts、
  countChange;IAnswerCount 四份并成一份
- schema.d.ts 按新规格重新生成

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
visuddhinanda 4 zile în urmă
părinte
comite
29bea45a9b
42 a modificat fișierele cu 2343 adăugiri și 1243 ștergeri
  1. 6 0
      dashboard-v6/src/Router.tsx
  2. 0 105
      dashboard-v6/src/api/Comment.ts
  3. 241 8
      dashboard-v6/src/api/discussion.ts
  4. 1175 35
      dashboard-v6/src/api/schema.d.ts
  5. 16 0
      dashboard-v6/src/api/tag.ts
  6. 4 4
      dashboard-v6/src/components/article/ArticleReader.tsx
  7. 13 16
      dashboard-v6/src/components/discussion/Discussion.tsx
  8. 1 21
      dashboard-v6/src/components/discussion/DiscussionAnchor.tsx
  9. 49 0
      dashboard-v6/src/components/discussion/DiscussionBadgeLoader.tsx
  10. 0 6
      dashboard-v6/src/components/discussion/DiscussionBox.tsx
  11. 29 59
      dashboard-v6/src/components/discussion/DiscussionButton.tsx
  12. 0 47
      dashboard-v6/src/components/discussion/DiscussionCount.tsx
  13. 150 164
      dashboard-v6/src/components/discussion/DiscussionCreate.tsx
  14. 1 5
      dashboard-v6/src/components/discussion/DiscussionDrawer.tsx
  15. 13 40
      dashboard-v6/src/components/discussion/DiscussionEdit.tsx
  16. 0 60
      dashboard-v6/src/components/discussion/DiscussionList.tsx
  17. 92 121
      dashboard-v6/src/components/discussion/DiscussionListCard.tsx
  18. 0 39
      dashboard-v6/src/components/discussion/DiscussionMock.tsx
  19. 59 73
      dashboard-v6/src/components/discussion/DiscussionShow.tsx
  20. 12 1
      dashboard-v6/src/components/discussion/DiscussionTopic.tsx
  21. 13 43
      dashboard-v6/src/components/discussion/DiscussionTopicChildren.tsx
  22. 12 41
      dashboard-v6/src/components/discussion/DiscussionTopicInfo.tsx
  23. 61 90
      dashboard-v6/src/components/discussion/InteractiveArea.tsx
  24. 1 1
      dashboard-v6/src/components/discussion/QaBox.tsx
  25. 31 63
      dashboard-v6/src/components/discussion/QaList.tsx
  26. 16 0
      dashboard-v6/src/components/discussion/SeverityTag.tsx
  27. 31 32
      dashboard-v6/src/components/discussion/hooks/useDiscussion.tsx
  28. 10 35
      dashboard-v6/src/components/discussion/utils.ts
  29. 1 1
      dashboard-v6/src/components/tag/TagsArea.tsx
  30. 46 0
      dashboard-v6/src/components/tag/TagsLoader.tsx
  31. 1 1
      dashboard-v6/src/components/tag/TagsOnItem.tsx
  32. 5 5
      dashboard-v6/src/components/task/TaskLog.tsx
  33. 1 1
      dashboard-v6/src/components/term/TermItem.tsx
  34. 19 34
      dashboard-v6/src/components/wbw/WbwPaliDiscussionIcon.tsx
  35. 6 0
      dashboard-v6/src/layouts/Root.tsx
  36. 33 0
      dashboard-v6/src/pages/discussion/show.tsx
  37. 86 0
      dashboard-v6/src/pages/discussion/topic.tsx
  38. 57 0
      dashboard-v6/src/reducers/discussion-badge.ts
  39. 0 78
      dashboard-v6/src/reducers/discussion-count.ts
  40. 1 12
      dashboard-v6/src/reducers/discussion.ts
  41. 47 0
      dashboard-v6/src/reducers/tag-map.ts
  42. 4 2
      dashboard-v6/src/store.ts

+ 6 - 0
dashboard-v6/src/Router.tsx

@@ -32,6 +32,8 @@ const UsersSignUpInvite = lazy(() => import("./pages/users/sign-up-invite"));
 const UsersForgotPassword = lazy(() => import("./pages/users/forgot-password"));
 const UsersResetPassword = lazy(() => import("./pages/users/reset-password"));
 const DashboardIndex = lazy(() => import("./pages/dashboard/index"));
+const DiscussionTopic = lazy(() => import("./pages/discussion/topic"));
+const DiscussionShow = lazy(() => import("./pages/discussion/show"));
 
 const Home = lazy(() => import("./pages/home"));
 const WorkspaceHome = lazy(() => import("./pages/workspace/home"));
@@ -76,6 +78,10 @@ const router = createBrowserRouter(
             { path: "reset-password/:token", Component: UsersResetPassword },
           ],
         },
+        // 「复制链接」拷出的话题地址,匿名可读(可见性由后端判定)
+        { path: "discussion/topic/:topicId", Component: DiscussionTopic },
+        // 某个对象上的全部讨论(讨论抽屉的「新窗口打开」)
+        { path: "discussion/show/:type/:id", Component: DiscussionShow },
         {
           path: "dashboard",
           Component: DashboardLayout,

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

@@ -1,105 +0,0 @@
-// src/api/Comment.ts
-
-import type { IUser } from "./Auth";
-import type { TContentType } from "./article";
-import type { TDiscussionType, TResType } from "./discussion";
-import type { ITagMapData } from "./tag";
-import { get } from "../request";
-
-export interface ICommentRequest {
-  id?: string;
-  res_id?: string;
-  res_type?: string;
-  type?: TDiscussionType;
-  title?: string;
-  content?: string;
-  content_type?: TContentType;
-  parent?: string;
-  topicId?: string;
-  tpl_id?: string;
-  status?: "active" | "close";
-  editor?: IUser;
-  created_at?: string;
-  updated_at?: string;
-}
-
-export interface ICommentApiData {
-  id: string;
-  res_id: string;
-  res_type: TResType;
-  type: TDiscussionType;
-  title?: string;
-  content?: string;
-  content_type?: TContentType;
-  html?: string;
-  summary?: string;
-  parent?: string;
-  tpl_id?: string;
-  status?: "active" | "close";
-  children_count?: number;
-  editor: IUser;
-  created_at?: string;
-  updated_at?: string;
-}
-
-export interface ICommentResponse {
-  ok: boolean;
-  message: string;
-  data: ICommentApiData;
-}
-
-export interface ICommentListResponse {
-  ok: boolean;
-  message: string;
-  data: {
-    rows: ICommentApiData[];
-    count: number;
-    active: number;
-    close: number;
-    can_create: boolean;
-    can_reply: boolean;
-  };
-}
-export interface ICommentAnchorResponse {
-  ok: boolean;
-  message: string;
-  data: string;
-}
-
-export interface IDiscussionCountRequest {
-  course_id?: string | null;
-  sentences: string[][];
-}
-
-export interface IDiscussionCountWbw {
-  book_id: number;
-  paragraph: number;
-  wid: number;
-}
-export interface IDiscussionCountData {
-  id: string;
-  res_id: string;
-  type: string;
-  editor_uid: string;
-  wbw?: IDiscussionCountWbw;
-}
-export interface IDiscussionCountResponse {
-  ok: boolean;
-  message: string;
-  data: { discussions: IDiscussionCountData[]; tags: ITagMapData[] };
-}
-
-export interface IFetchCommentListParams {
-  limit?: number;
-  offset?: number;
-  status?: "active" | "close";
-}
-
-export async function fetchCommentList(
-  taskId: string,
-  params: IFetchCommentListParams = {}
-): Promise<ICommentListResponse> {
-  const { limit = 5, offset = 0, status = "active" } = params;
-  const url = `/api/v2/discussion?type=discussion&res_type=task&view=question&id=${taskId}&limit=${limit}&offset=${offset}&status=${status}`;
-  return get<ICommentListResponse>(url);
-}

+ 241 - 8
dashboard-v6/src/api/discussion.ts

@@ -1,5 +1,17 @@
+// src/api/discussion.ts
+//
+// 评论线程(话题 + 一层回复),走 v3:/v3/discussions。
+// 请求与响应的类型全部取自 schema.d.ts(由 OpenAPI 生成),不要手写字段。
+
 import type { IUser } from "./Auth";
+import { api, unwrap, unwrapQuiet } from "./client";
+import { ApiError } from "./error";
+import type { paths } from "./schema";
 
+/**
+ * 通用的资源类型:transfer、webhook、右侧面板等也在用,比讨论能挂的对象多。
+ * 讨论能挂的对象见 {@link TDiscussionTarget}。
+ */
 export type TResType =
   | "article"
   | "channel"
@@ -8,24 +20,245 @@ export type TResType =
   | "wbw"
   | "term"
   | "task";
-export type TDiscussionType = "qa" | "discussion" | "help" | "comment";
 
+type TopicQuery = paths["/v3/discussions"]["get"]["parameters"]["query"];
+type SummaryQuery =
+  paths["/v3/discussions/summary"]["get"]["parameters"]["query"];
+type TopicBody = NonNullable<
+  paths["/v3/discussions"]["post"]["requestBody"]
+>["content"]["application/json"];
+type PatchBody = NonNullable<
+  paths["/v3/discussions/{discussion}"]["patch"]["requestBody"]
+>["content"]["application/json"];
+type DiscussionData = NonNullable<
+  paths["/v3/discussions/{discussion}"]["get"]["responses"][200]["content"]["application/json"]["data"]
+>;
+type BatchCountBody = NonNullable<
+  paths["/v3/discussions/batch-count"]["post"]["requestBody"]
+>["content"]["application/json"];
+/** 讨论角标的对象:句子或逐词 */
+export type TDiscussionBadgeTarget = BatchCountBody["targets"][number];
+/** 一个对象的讨论角标:未关闭的评论线程数与其中我发起的 */
+export type TDiscussionBadge = NonNullable<
+  paths["/v3/discussions/batch-count"]["post"]["responses"][200]["content"]["application/json"]["data"]
+>[number];
+export type TDiscussionSummary = NonNullable<
+  paths["/v3/discussions/summary"]["get"]["responses"][200]["content"]["application/json"]["data"]
+>;
+
+/** 讨论能挂的对象类型 */
+export type TDiscussionTarget = TopicQuery["target_type"];
+/** 类型:评论线程(THREAD_TYPES)+ 批注(note 边注、commentary 义注 / 复注对应) */
+export type TDiscussionType = NonNullable<TopicQuery["type"]>;
+
+/**
+ * 评论线程的类型(与后端 Discussion::THREAD_TYPES 一致)。讨论面板只处理这几种;
+ * 批注注入阅读页、没有回复与开关,不在讨论面板里新建。
+ */
+export const THREAD_TYPES: readonly TDiscussionType[] = [
+  "discussion",
+  "qa",
+  "help",
+  "issue",
+];
+/** 严重程度(几种话题类型的并集,按类型拆分见 SEVERITY_OPTIONS) */
+export type TSeverity = NonNullable<TopicBody["severity"]>;
+/** 话题状态 */
+export type TDiscussionStatus = NonNullable<TopicQuery["status"]>;
+
+/**
+ * 各话题类型可选的 severity(与后端 Discussion::SEVERITIES 一致,不在表里的类型不收)。
+ * 规格里 severity 是几种类型的并集,按类型拆分只能写在这里。
+ */
+export const SEVERITY_OPTIONS: Partial<Record<TDiscussionType, TSeverity[]>> = {
+  issue: ["error", "warning", "info"],
+  help: ["urgent", "normal"],
+};
+
+/** 当前用户对一条记录能做的动作,由后端按权限算好 */
+export interface IDiscussionAbilities {
+  update: boolean;
+  delete: boolean;
+  close: boolean;
+  reply: boolean;
+}
+
+export const NO_ABILITIES: IDiscussionAbilities = {
+  update: false,
+  delete: false,
+  close: false,
+  reply: false,
+};
+
+const DISCUSSION_TARGETS: readonly TDiscussionTarget[] = [
+  "sentence",
+  "wbw",
+  "article",
+  "term",
+  "task",
+];
+
+/** 这个资源类型能不能挂讨论(chapter、channel 不能) */
+export const asDiscussionTarget = (
+  resType?: TResType
+): TDiscussionTarget | undefined =>
+  DISCUSSION_TARGETS.find((value) => value === resType);
+
+/** 某个话题的回复数变化:新建回复后从话题视图传回列表,更新列表上的计数 */
+export interface IAnswerCount {
+  id: string;
+  count: number;
+}
+
+/** 界面用的一条评论:话题(parent 为空)或回复 */
 export interface IComment {
-  id?: string; //id未提供为新建
+  id?: string; //id未提供为尚未创建的模板话题
   resId?: string;
-  resType?: TResType;
+  resType?: TDiscussionTarget;
   type: TDiscussionType;
-  tplId?: string;
+  severity?: string | null;
+  tplId?: string | null;
   user: IUser;
+  /** AI 凭人类授权代写时的模型;此时 user 是授权人 */
+  coEditor?: IUser;
   parent?: string | null;
-  title?: string;
-  content?: string;
+  title?: string | null;
+  content?: string | null;
   html?: string;
   summary?: string;
-  status?: "active" | "close";
-  children?: IComment[];
+  status?: TDiscussionStatus;
   childrenCount?: number;
+  abilities: IDiscussionAbilities;
+  /** 删除了但还挂着回复的话题(墓碑):内容为空,回复照常显示 */
+  deleted?: boolean;
   newTpl?: boolean;
   createdAt?: string;
   updatedAt?: string;
 }
+
+/** 响应里的操作者摘要(editor / co_editor)→ 界面用的 IUser */
+const toUser = (actor: DiscussionData["editor"]): IUser => ({
+  id: actor?.id ?? "",
+  nickName: actor?.nickName ?? "",
+  userName: actor?.userName ?? "",
+  avatar: actor?.avatar,
+  roles: actor?.roles,
+});
+
+export const toIComment = (value: DiscussionData): IComment => ({
+  id: value.id,
+  resId: value.target_id,
+  resType: asDiscussionTarget(value.target_type as TResType),
+  type: value.type as TDiscussionType,
+  severity: value.severity,
+  tplId: value.tpl_id,
+  user: toUser(value.editor),
+  coEditor: value.co_editor ? toUser(value.co_editor) : undefined,
+  parent: value.parent_id,
+  title: value.title,
+  content: value.content,
+  html: value.html,
+  summary: value.summary,
+  status: value.status as TDiscussionStatus,
+  childrenCount: value.reply_count,
+  abilities: { ...NO_ABILITIES, ...value.abilities },
+  deleted: !!value.deleted_at,
+  createdAt: value.created_at,
+  updatedAt: value.updated_at,
+});
+
+/** 单个资源的响应里 data 必然存在;缺了说明后端契约坏了,当作失败处理 */
+const one = (body: { data?: DiscussionData }): IComment => {
+  if (!body.data) {
+    throw new ApiError(500, "empty discussion response");
+  }
+  return toIComment(body.data);
+};
+
+/** 某个对象上的话题列表 */
+export const fetchTopics = async (
+  query: TopicQuery
+): Promise<{ items: IComment[]; total: number }> => {
+  const body = unwrap(await api.GET("/v3/discussions", { params: { query } }));
+  return {
+    items: (body.data ?? []).map(toIComment),
+    total: body.meta?.total ?? 0,
+  };
+};
+
+/** 某个对象上的话题概况:各状态的数量、当前用户能发起的类型 */
+export const fetchTopicSummary = async (
+  query: SummaryQuery
+): Promise<TDiscussionSummary> =>
+  unwrap(await api.GET("/v3/discussions/summary", { params: { query } }))
+    .data ?? {};
+
+export const fetchDiscussion = async (id: string): Promise<IComment> =>
+  one(
+    unwrap(
+      await api.GET("/v3/discussions/{discussion}", {
+        params: { path: { discussion: id } },
+      })
+    )
+  );
+
+/** 话题的回复,按时间正序 */
+export const fetchReplies = async (
+  topicId: string,
+  perPage = 200
+): Promise<IComment[]> =>
+  (
+    unwrap(
+      await api.GET("/v3/discussions/{discussion}/replies", {
+        params: { path: { discussion: topicId }, query: { per_page: perPage } },
+      })
+    ).data ?? []
+  ).map(toIComment);
+
+export const createTopic = async (body: TopicBody): Promise<IComment> =>
+  one(unwrap(await api.POST("/v3/discussions", { body })));
+
+export const createReply = async (
+  topicId: string,
+  content: string
+): Promise<IComment> =>
+  one(
+    unwrap(
+      await api.POST("/v3/discussions/{discussion}/replies", {
+        params: { path: { discussion: topicId } },
+        body: { content },
+      })
+    )
+  );
+
+/** 局部更新:只发要改的字段 */
+export const updateDiscussion = async (
+  id: string,
+  body: PatchBody
+): Promise<IComment> =>
+  one(
+    unwrap(
+      await api.PATCH("/v3/discussions/{discussion}", {
+        params: { path: { discussion: id } },
+        body,
+      })
+    )
+  );
+
+/** 软删除,返回删除后的资源 */
+export const deleteDiscussion = async (id: string): Promise<IComment> =>
+  one(
+    unwrap(
+      await api.DELETE("/v3/discussions/{discussion}", {
+        params: { path: { discussion: id } },
+      })
+    )
+  );
+
+/** 一批对象的讨论角标,按请求顺序返回(没有讨论的为 0)。一次最多 500 个,调用方自己分批 */
+export const fetchDiscussionBadges = async (
+  targets: TDiscussionBadgeTarget[]
+): Promise<TDiscussionBadge[]> =>
+  unwrapQuiet(
+    await api.POST("/v3/discussions/batch-count", { body: { targets } })
+  ).data ?? [];

+ 1175 - 35
dashboard-v6/src/api/schema.d.ts

@@ -2510,25 +2510,20 @@ export interface paths {
         };
         /**
          * 列出讨论 / 批注
-         * @description 按 view 指定的口径返回 discussion 列表,支持状态过滤、分页与排序。
-         *     权限:未登录用户请求 type=discussion 时直接返回空结果;basic 角色用户在自己无编辑权限的
-         *     channel 上只能看到自己发表的 discussion;view=topic-by-user 必须登录,否则返回 403。
-         *     返回 rows、count(当前口径总数)、active/close(顶级节点的活跃与关闭数)、
-         *     can_create/can_reply(当前用户能否发起与回复,按 type 与 res_type 计算)。
-         *
-         *     实现:`DiscussionController@index`
+         * @deprecated
+         * @description 已被 v3 取代,保留仅为 dashboard-v4 兼容,待 v4 下线后删除:
+         *     - `view=question` / `view=res_id` / `view=question-by-topic` → `GET /v3/discussions?target_type=&target_id=`
+         *     - `view=answer` → `GET /v3/discussions/{discussion}/replies`
+         *     - 响应里的 `active` / `close` / `can_create` → `GET /v3/discussions/summary`
+         *     - `view=topic-by-user`、`view=all` 与课程参数(`course` / `show_student`)在 v3 暂无对应端点
          */
         get: operations["get_api_v2_discussion"];
         put?: never;
         /**
          * 发表讨论 / 批注或回复
-         * @description 必须登录,未登录返回 401。传了 parent 即为回复:res_id 与 res_type 继承自父节点,
-         *     无需再传,父节点不存在返回 no record,保存后父节点 children_count 自增;
-         *     不传 parent 为顶级节点,此时 res_id、res_type、title 均为必填。
-         *     锚点字段(pos_start/pos_end/quote_exact/quote_prefix/quote_suffix)两种情况都可选。
-         *     默认会向消息队列 discussion 推送通知,可用 notification=false 关闭。
-         *
-         *     实现:`DiscussionController@store`
+         * @deprecated
+         * @description 已被 v3 取代,保留仅为 dashboard-v4 兼容,待 v4 下线后删除:
+         *     不带 `parent` → `POST /v3/discussions`;带 `parent` → `POST /v3/discussions/{discussion}/replies`。
          */
         post: operations["post_api_v2_discussion"];
         delete?: never;
@@ -2645,42 +2640,30 @@ export interface paths {
         };
         /**
          * 查询单条讨论
-         * @description 按路径参数做路由模型绑定并直接返回,不做登录与权限校验。
-         *
-         *     实现:`DiscussionController@show`
+         * @deprecated
+         * @description 已被 `GET /v3/discussions/{discussion}` 取代。保留仅为 dashboard-v4 兼容,待 v4 下线后删除。
          */
         get: operations["get_api_v2_discussion_discussion_"];
         /**
          * 修改讨论 / 批注
-         * @description 必须登录,未登录返回 403。作者本人可改;非作者则要求是资源所属 channel 的可编辑者
-         *     (res_type=sentence 取 sentence.channel_uid,res_type=wbw 经 wbw block 取 channel_uid),
-         *     两者都不满足返回 403。
-         *     title、content、status 按请求覆盖,未提交时分别回落为 null、null、active;
-         *     type 与五个锚点字段为增量更新,只改请求里出现的字段,未提交的原样保留。
-         *
-         *     实现:`DiscussionController@update`
+         * @deprecated
+         * @description 已被 `PATCH /v3/discussions/{discussion}`(局部更新)取代。保留仅为 dashboard-v4 兼容,待 v4 下线后删除。
          */
         put: operations["put_api_v2_discussion_discussion_"];
         post?: never;
         /**
          * 删除讨论 / 批注
-         * @description 必须登录,未登录返回 401。目前只允许作者本人删除,其他人返回 403。
-         *     不会级联删除子回复,也不会回退父节点的 children_count。
-         *
-         *     实现:`DiscussionController@destroy`
+         * @deprecated
+         * @description 已被 `DELETE /v3/discussions/{discussion}` 取代。2026-10-15 起两边都是软删除。
+         *     保留仅为 dashboard-v4 兼容,待 v4 下线后删除。
          */
         delete: operations["delete_api_v2_discussion_discussion_"];
         options?: never;
         head?: never;
         /**
          * 修改讨论 / 批注
-         * @description 必须登录,未登录返回 403。作者本人可改;非作者则要求是资源所属 channel 的可编辑者
-         *     (res_type=sentence 取 sentence.channel_uid,res_type=wbw 经 wbw block 取 channel_uid),
-         *     两者都不满足返回 403。
-         *     title、content、status 按请求覆盖,未提交时分别回落为 null、null、active;
-         *     type 与五个锚点字段为增量更新,只改请求里出现的字段,未提交的原样保留。
-         *
-         *     实现:`DiscussionController@update`
+         * @deprecated
+         * @description 已被 `PATCH /v3/discussions/{discussion}`(局部更新)取代。保留仅为 dashboard-v4 兼容,待 v4 下线后删除。
          */
         patch: operations["patch_api_v2_discussion_discussion_"];
         trace?: never;
@@ -7707,6 +7690,190 @@ export interface paths {
         patch: operations["patch_api_v2_webhook_webhook_"];
         trace?: never;
     };
+    "/v3/agent-grants": {
+        parameters: {
+            query?: never;
+            header?: never;
+            path?: never;
+            cookie?: never;
+        };
+        get?: never;
+        put?: never;
+        /**
+         * 签发 agent 写入授权
+         * @description 必须用人类的登录 token 调用(模型 token 403);模型必须归当前用户所有,当前用户
+         *     必须能编辑该 channel。授权 7 天有效,只能由指定的模型使用;撤销该模型的 token
+         *     (token_version 自增)时一并失效。
+         *     模型写入时用自己的模型 token 登录,并带请求头 `X-Agent-Grant: <token>`;
+         *     写入的记录 editor 为授权人、co_editor 为模型,权限按授权人判断。
+         *
+         *     实现:`V3\AgentGrantController@store`
+         */
+        post: operations["post_api_v3_agent_grants"];
+        delete?: never;
+        options?: never;
+        head?: never;
+        patch?: never;
+        trace?: never;
+    };
+    "/v3/discussions": {
+        parameters: {
+            query?: never;
+            header?: never;
+            path?: never;
+            cookie?: never;
+        };
+        /**
+         * 列出某个对象上的话题
+         * @description 只列话题(不含回复),删除了但还有回复的话题以墓碑出现(deleted_at 有值、内容为空)。
+         *     未登录看不到协作类话题(discussion / issue);basic 角色在自己无权管理的对象上只看得到
+         *     自己发起的协作类话题。各状态的数量与「能发起哪些类型」见 `GET /v3/discussions/summary`。
+         *     不传 type 时只列评论线程(discussion / qa / help / issue),批注要显式 type=note / commentary。
+         *
+         *     实现:`V3\DiscussionController@index`
+         */
+        get: operations["get_api_v3_discussions"];
+        put?: never;
+        /**
+         * 发起话题或新建批注
+         * @description discussion / issue 登录即可发;qa / help 只能发在文章上,且只有文章的编辑者能发。
+         *     severity 的取值随 type:issue 为 error / warning / info,help 为 urgent / normal,
+         *     其它类型不收。默认会通知对象的作者(站内信 + webhook),`notify=false` 关闭。
+         *     批注(note / commentary)只能加在句子上,且只有该句所在 channel 的编辑者能加;
+         *     标题可省、正文必填,commentary 的正文必须是义注句子模板 `{{book-para-start-end}}`
+         *     (可多句并列);可带锚点 pos_* / quote_*(评论线程传了 422);不发通知。
+         *
+         *     实现:`V3\DiscussionController@store`
+         */
+        post: operations["post_api_v3_discussions"];
+        delete?: never;
+        options?: never;
+        head?: never;
+        patch?: never;
+        trace?: never;
+    };
+    "/v3/discussions/batch-count": {
+        parameters: {
+            query?: never;
+            header?: never;
+            path?: never;
+            cookie?: never;
+        };
+        get?: never;
+        put?: never;
+        /**
+         * 批量取讨论角标
+         * @description 每个对象返回未关闭的评论线程数(total)与其中当前用户发起的数目(mine,未登录为 0),
+         *     按请求顺序、一个不少(没有讨论的为 0)。只数评论线程(discussion / qa / help / issue),
+         *     批注与已删除的不算;可见性与列表相同:未登录不数协作类话题,basic 角色在无权管理的
+         *     对象上只数自己发起的协作类话题。最多 500 个对象。
+         *     与 v2 的区别:入参是对象 uid 而不是句子坐标;只给计数不给明细;v2 不分类型、不管可见性。
+         *
+         *     实现:`V3\DiscussionBatchCountController@__invoke`
+         */
+        post: operations["post_api_v3_discussions_batch_count"];
+        delete?: never;
+        options?: never;
+        head?: never;
+        patch?: never;
+        trace?: never;
+    };
+    "/v3/discussions/summary": {
+        parameters: {
+            query?: never;
+            header?: never;
+            path?: never;
+            cookie?: never;
+        };
+        /**
+         * 话题概况
+         * @description 各状态的话题数(只数没删除的、当前用户看得见的),以及当前用户能在这个对象上
+         *     发起哪些类型的话题(未登录为空数组)。
+         *
+         *     实现:`V3\DiscussionSummaryController@__invoke`
+         */
+        get: operations["get_api_v3_discussions_summary"];
+        put?: never;
+        post?: never;
+        delete?: never;
+        options?: never;
+        head?: never;
+        patch?: never;
+        trace?: never;
+    };
+    "/v3/discussions/{discussion}": {
+        parameters: {
+            query?: never;
+            header?: never;
+            path?: never;
+            cookie?: never;
+        };
+        /**
+         * 获取单条话题或回复
+         * @description 删除了但还有回复的话题返回墓碑;删除了的回复、删除了且没有回复的话题返回 404。
+         *     当前用户看不见的话题同样 404。
+         *
+         *     实现:`V3\DiscussionController@show`
+         */
+        get: operations["get_api_v3_discussions_discussion_"];
+        put?: never;
+        post?: never;
+        /**
+         * 删除话题或回复(软删除)
+         * @description 作者本人或挂靠对象的管理者(句子 / 逐词所在 channel、文章的编辑者)能删;
+         *     v2 只允许作者。删话题不动回复:有回复的话题以墓碑形式留在列表里。
+         *     返回删除后的资源(200),不是 204——软删的资源并没有消失。
+         *
+         *     实现:`V3\DiscussionController@destroy`
+         */
+        delete: operations["delete_api_v3_discussions_discussion_"];
+        options?: never;
+        head?: never;
+        /**
+         * 修改话题或回复(局部更新)
+         * @description 只改传了的字段。title / status / type / severity 只对话题有效,回复传了 422。
+         *     作者或对象的管理者(句子、逐词所在 channel 的编辑者,文章的编辑者)可改;
+         *     改 type 时回复跟着改,新类型不收原来的 severity 时 severity 清空。
+         *     批注:没有 status,type 只能在 note / commentary 之间改(不能与评论线程互转),
+         *     正文不能清空,可改锚点。
+         *     改了内容(标题、正文、锚点)时 co_editor 跟着这次写入走:人亲自改则清空,AI 代改则换成该模型;
+         *     只改 status / type / severity 不动 co_editor。
+         *
+         *     实现:`V3\DiscussionController@update`
+         */
+        patch: operations["patch_api_v3_discussions_discussion_"];
+        trace?: never;
+    };
+    "/v3/discussions/{discussion}/replies": {
+        parameters: {
+            query?: never;
+            header?: never;
+            path?: never;
+            cookie?: never;
+        };
+        /**
+         * 列出话题的回复
+         * @description 按时间正序。删除了的话题(墓碑)下的回复照样可读。
+         *
+         *     实现:`V3\DiscussionReplyController@index`
+         */
+        get: operations["get_api_v3_discussions_discussion_replies"];
+        put?: never;
+        /**
+         * 回复话题
+         * @description 类型与挂靠对象继承话题。已关闭的话题不能回复(409),对一条回复再回复 422。
+         *     AI 代写同 `POST /v3/discussions`(`X-Agent-Grant` 请求头)。
+         *     文章上的 qa 只有文章的编辑者能回答。默认发通知,`notify=false` 关闭。
+         *
+         *     实现:`V3\DiscussionReplyController@store`
+         */
+        post: operations["post_api_v3_discussions_discussion_replies"];
+        delete?: never;
+        options?: never;
+        head?: never;
+        patch?: never;
+        trace?: never;
+    };
     "/v3/email-certifications": {
         parameters: {
             query?: never;
@@ -34233,6 +34400,979 @@ export interface operations {
             422: components["responses"]["ValidationError"];
         };
     };
+    post_api_v3_agent_grants: {
+        parameters: {
+            query?: never;
+            header?: never;
+            path?: never;
+            cookie?: never;
+        };
+        requestBody: {
+            content: {
+                "application/json": {
+                    /** @description 被授权的 AI 模型 uid */
+                    ai_model: string;
+                    /**
+                     * @description 授权范围的类型
+                     * @enum {string}
+                     */
+                    target_type: "channel";
+                    /** @description channel 的 uid */
+                    target_id: string;
+                    /**
+                     * @description 限定 book,0 或不传表示不限
+                     * @default 0
+                     */
+                    book?: number;
+                };
+            };
+        };
+        responses: {
+            /** @description 成功 */
+            200: {
+                headers: {
+                    [name: string]: unknown;
+                };
+                content: {
+                    "application/json": {
+                        data?: {
+                            token?: string;
+                            grantor?: string;
+                            ai_model?: string;
+                            target_type?: string;
+                            target_id?: string;
+                            book?: number;
+                            expires_at?: string;
+                        };
+                    };
+                };
+            };
+            /** @description 签发成功 */
+            201: {
+                headers: {
+                    [name: string]: unknown;
+                };
+                content: {
+                    "application/json": {
+                        data?: {
+                            token?: string;
+                            grantor?: string;
+                            ai_model?: string;
+                            target_type?: string;
+                            target_id?: string;
+                            book?: number;
+                            expires_at?: string;
+                        };
+                    };
+                };
+            };
+            401: components["responses"]["ProblemUnauthorized"];
+            /** @description 用模型 token 调用、模型不归当前用户,或当前用户不能编辑该 channel */
+            403: {
+                headers: {
+                    [name: string]: unknown;
+                };
+                content: {
+                    "application/problem+json": components["schemas"]["ProblemDetails"];
+                };
+            };
+            422: components["responses"]["ProblemValidation"];
+        };
+    };
+    get_api_v3_discussions: {
+        parameters: {
+            query: {
+                /** @description 每页数量,最大 200 */
+                per_page?: number;
+                /** @description 挂靠对象的类型 */
+                target_type: "sentence" | "wbw" | "article" | "term" | "task";
+                /** @description 挂靠对象的 uuid */
+                target_id: string;
+                /** @description 类型,不传则只列评论线程 */
+                type?: "discussion" | "qa" | "help" | "issue" | "note" | "commentary";
+                /** @description 话题状态,不传则两种都要 */
+                status?: "active" | "close";
+                /**
+                 * @description 严重程度,逗号分隔多选
+                 * @example error,warning
+                 */
+                severity?: string;
+                /** @description 排序,前缀 - 表示倒序 */
+                sort?: "created_at" | "-created_at" | "updated_at" | "-updated_at";
+                /** @description 页码 */
+                page?: number;
+            };
+            header?: never;
+            path?: never;
+            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;
+                            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"];
+                    };
+                };
+            };
+            422: components["responses"]["ProblemValidation"];
+        };
+    };
+    post_api_v3_discussions: {
+        parameters: {
+            query?: never;
+            header?: never;
+            path?: never;
+            cookie?: never;
+        };
+        requestBody: {
+            content: {
+                "application/json": {
+                    /**
+                     * @description 挂靠对象的类型
+                     * @enum {string}
+                     */
+                    target_type: "sentence" | "wbw" | "article" | "term" | "task";
+                    /** @description 挂靠对象的 uuid */
+                    target_id: string;
+                    /**
+                     * @description 类型
+                     * @default discussion
+                     * @enum {string}
+                     */
+                    type?: "discussion" | "qa" | "help" | "issue" | "note" | "commentary";
+                    /** @description 标题,最长 256 字符;评论线程必填,批注可省 */
+                    title?: string;
+                    /** @description 正文(markdown);批注必填 */
+                    content?: string;
+                    /**
+                     * @description 严重程度
+                     * @enum {string}
+                     */
+                    severity?: "error" | "warning" | "info" | "urgent" | "normal";
+                    /** @description 批注锚点在句子文本中的起始偏移(只限批注) */
+                    pos_start?: number;
+                    /** @description 批注锚点的结束偏移,批注插在这里(只限批注) */
+                    pos_end?: number;
+                    /** @description 锚定的原文片段(只限批注) */
+                    quote_exact?: string;
+                    /** @description 锚定片段前的上下文,用于重新定位(只限批注) */
+                    quote_prefix?: string;
+                    /** @description 锚定片段后的上下文,用于重新定位(只限批注) */
+                    quote_suffix?: string;
+                    /** @description 话题模板(文章)的 uuid */
+                    tpl_id?: string;
+                    /**
+                     * @description 是否发通知(批注不发)
+                     * @default true
+                     */
+                    notify?: boolean;
+                };
+            };
+        };
+        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;
+                            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;
+                        };
+                    };
+                };
+            };
+            /** @description 创建成功 */
+            201: {
+                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;
+                            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;
+                        };
+                    };
+                };
+            };
+            401: components["responses"]["ProblemUnauthorized"];
+            /** @description 当前用户不能在这个对象上发起这种类型的话题 */
+            403: {
+                headers: {
+                    [name: string]: unknown;
+                };
+                content: {
+                    "application/problem+json": components["schemas"]["ProblemDetails"];
+                };
+            };
+            422: components["responses"]["ProblemValidation"];
+        };
+    };
+    post_api_v3_discussions_batch_count: {
+        parameters: {
+            query?: never;
+            header?: never;
+            path?: never;
+            cookie?: never;
+        };
+        requestBody: {
+            content: {
+                "application/json": {
+                    /** @description 讨论对象列表,每项 {type, id},最多 500 个 */
+                    targets: {
+                        /**
+                         * @description 对象类型
+                         * @enum {string}
+                         */
+                        type: "sentence" | "wbw";
+                        /** @description 对象 uid */
+                        id: string;
+                    }[];
+                };
+            };
+        };
+        responses: {
+            /** @description 成功 */
+            200: {
+                headers: {
+                    [name: string]: unknown;
+                };
+                content: {
+                    "application/json": {
+                        data?: {
+                            target_type?: string;
+                            target_id?: string;
+                            total?: number;
+                            mine?: number;
+                        }[];
+                        meta?: components["schemas"]["PaginationMeta"];
+                    };
+                };
+            };
+            422: components["responses"]["ProblemValidation"];
+        };
+    };
+    get_api_v3_discussions_summary: {
+        parameters: {
+            query: {
+                /** @description 挂靠对象的类型 */
+                target_type: "sentence" | "wbw" | "article" | "term" | "task";
+                /** @description 挂靠对象的 uuid */
+                target_id: string;
+                /** @description 只数这种类型,不传则只数评论线程 */
+                type?: "discussion" | "qa" | "help" | "issue" | "note" | "commentary";
+            };
+            header?: never;
+            path?: never;
+            cookie?: never;
+        };
+        requestBody?: never;
+        responses: {
+            /** @description 成功 */
+            200: {
+                headers: {
+                    [name: string]: unknown;
+                };
+                content: {
+                    "application/json": {
+                        data?: {
+                            status_counts?: {
+                                active?: number;
+                                close?: number;
+                            };
+                            creatable_types?: string[];
+                        };
+                    };
+                };
+            };
+            422: components["responses"]["ProblemValidation"];
+        };
+    };
+    get_api_v3_discussions_discussion_: {
+        parameters: {
+            query?: never;
+            header?: never;
+            path: {
+                /** @description 话题或回复的 uuid */
+                discussion: 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;
+                            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;
+                        };
+                    };
+                };
+            };
+            422: components["responses"]["ProblemValidation"];
+        };
+    };
+    delete_api_v3_discussions_discussion_: {
+        parameters: {
+            query?: never;
+            header?: never;
+            path: {
+                /** @description 话题或回复的 uuid */
+                discussion: 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;
+                            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;
+                        };
+                    };
+                };
+            };
+            401: components["responses"]["ProblemUnauthorized"];
+            /** @description 既不是作者,也不是挂靠对象的管理者 */
+            403: {
+                headers: {
+                    [name: string]: unknown;
+                };
+                content: {
+                    "application/problem+json": components["schemas"]["ProblemDetails"];
+                };
+            };
+            422: components["responses"]["ProblemValidation"];
+        };
+    };
+    patch_api_v3_discussions_discussion_: {
+        parameters: {
+            query?: never;
+            header?: never;
+            path: {
+                /** @description 话题或回复的 uuid */
+                discussion: string;
+            };
+            cookie?: never;
+        };
+        requestBody?: {
+            content: {
+                "application/json": {
+                    /** @description 标题(只限话题) */
+                    title?: string;
+                    /** @description 正文(markdown) */
+                    content?: string;
+                    /**
+                     * @description 状态(只限话题)
+                     * @enum {string}
+                     */
+                    status?: "active" | "close";
+                    /**
+                     * @description 类型(只限话题;评论线程与批注不能互转)
+                     * @enum {string}
+                     */
+                    type?: "discussion" | "qa" | "help" | "issue" | "note" | "commentary";
+                    /**
+                     * @description 严重程度(只限话题),null 表示清空
+                     * @enum {string}
+                     */
+                    severity?: "error" | "warning" | "info" | "urgent" | "normal";
+                    /** @description 锚点起始偏移(只限批注) */
+                    pos_start?: number;
+                    /** @description 锚点结束偏移(只限批注) */
+                    pos_end?: number;
+                    /** @description 锚定的原文片段(只限批注) */
+                    quote_exact?: string;
+                    /** @description 锚定片段前的上下文(只限批注) */
+                    quote_prefix?: string;
+                    /** @description 锚定片段后的上下文(只限批注) */
+                    quote_suffix?: string;
+                };
+            };
+        };
+        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;
+                            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;
+                        };
+                    };
+                };
+            };
+            401: components["responses"]["ProblemUnauthorized"];
+            /** @description 没有修改权限 */
+            403: {
+                headers: {
+                    [name: string]: unknown;
+                };
+                content: {
+                    "application/problem+json": components["schemas"]["ProblemDetails"];
+                };
+            };
+            422: components["responses"]["ProblemValidation"];
+        };
+    };
+    get_api_v3_discussions_discussion_replies: {
+        parameters: {
+            query?: {
+                /** @description 每页数量,最大 200 */
+                per_page?: number;
+                /** @description 页码 */
+                page?: number;
+            };
+            header?: never;
+            path: {
+                /** @description 话题的 uuid */
+                discussion: 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;
+                            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"];
+                    };
+                };
+            };
+            422: components["responses"]["ProblemValidation"];
+        };
+    };
+    post_api_v3_discussions_discussion_replies: {
+        parameters: {
+            query?: never;
+            header?: never;
+            path: {
+                /** @description 话题的 uuid */
+                discussion: string;
+            };
+            cookie?: never;
+        };
+        requestBody: {
+            content: {
+                "application/json": {
+                    /** @description 正文(markdown) */
+                    content: string;
+                    /**
+                     * @description 是否发通知
+                     * @default true
+                     */
+                    notify?: boolean;
+                };
+            };
+        };
+        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;
+                            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;
+                        };
+                    };
+                };
+            };
+            /** @description 回复成功 */
+            201: {
+                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;
+                            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;
+                        };
+                    };
+                };
+            };
+            401: components["responses"]["ProblemUnauthorized"];
+            /** @description 当前用户不能回复这个话题 */
+            403: {
+                headers: {
+                    [name: string]: unknown;
+                };
+                content: {
+                    "application/problem+json": components["schemas"]["ProblemDetails"];
+                };
+            };
+            /** @description 话题已关闭 */
+            409: {
+                headers: {
+                    [name: string]: unknown;
+                };
+                content: {
+                    "application/problem+json": components["schemas"]["ProblemDetails"];
+                };
+            };
+            422: components["responses"]["ProblemValidation"];
+        };
+    };
     post_api_v3_email_certifications: {
         parameters: {
             query?: never;

+ 16 - 0
dashboard-v6/src/api/tag.ts

@@ -103,3 +103,19 @@ export async function tagLoader({ params }: LoaderFunctionArgs) {
 
   return res.data;
 }
+
+/**
+ * 页面上一批句子的标签:`POST /v2/discussion-count` 的请求与响应(只用到 tags)。
+ *
+ * FIXME: 这个 v2 端点同时返回讨论明细(discussions),讨论角标已改走 v3 batch-count,
+ * 这里只读 tags。标签迁 v3 时换成标签自己的批量端点,这两个类型随之删掉。
+ */
+export interface ISentenceTagsRequest {
+  /** 句子坐标 [book, paragraph, word_start, word_end] */
+  sentences: string[][];
+}
+export interface ISentenceTagsResponse {
+  ok: boolean;
+  message: string;
+  data: { tags: ITagMapData[] };
+}

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

@@ -18,6 +18,7 @@ import ArticleNavigation from "./components/ArticleNavigation";
 import TocPath from "../tipitaka/TocPath";
 import { useArticle } from "./hooks/useArticle";
 import ArticleHeader from "./components/ArticleHeader";
+import InteractiveArea from "../discussion/InteractiveArea";
 
 interface IWidget {
   articleId?: string;
@@ -187,14 +188,13 @@ const ArticleReader = ({
             path={articleData?.path}
             onArticleChange={onArticleChange}
           />
-          {hideInteractive ? <></> : <></>}
+          {hideInteractive ? undefined : (
+            <InteractiveArea resType="article" resId={articleId} />
+          )}
         </>
       )}
     </div>
   );
-  /**
-   * <InteractiveArea resType={"article"} resId={articleId} />
-   */
 };
 
 export default ArticleReader;

+ 13 - 16
dashboard-v6/src/components/discussion/Discussion.tsx

@@ -3,26 +3,25 @@ import { ArrowLeftOutlined } from "@ant-design/icons";
 
 import DiscussionTopic from "./DiscussionTopic";
 import DiscussionListCard from "./DiscussionListCard";
+import { refreshDiscussionBadge } from "./utils";
 
-import { countChange } from "../../reducers/discussion";
 import { Button, Space, Typography } from "antd";
-import store from "../../store";
-import type { IComment, TDiscussionType, TResType } from "../../api/discussion";
+import type {
+  IAnswerCount,
+  IComment,
+  TDiscussionType,
+  TResType,
+} from "../../api/discussion";
 
 const { Text } = Typography;
 
-export interface IAnswerCount {
-  id: string;
-  count: number;
-}
-
 interface IWidget {
   resId?: string;
   resType?: TResType;
   showTopicId?: string;
   focus?: string;
+  /** 只列这种类型的话题;不传则列出全部类型 */
   type?: TDiscussionType;
-  showStudent?: boolean;
   onTopicReady?: (value: IComment) => void;
 }
 
@@ -30,9 +29,8 @@ const Discussion = ({
   resId,
   resType,
   showTopicId,
-  showStudent = false,
   focus,
-  type = "discussion",
+  type,
   onTopicReady,
 }: IWidget) => {
   // 用户手动点击触发的状态
@@ -95,7 +93,7 @@ const Discussion = ({
               }
             }}
             onTopicReady={(value: IComment) => {
-              setTopicTitle(value.title);
+              setTopicTitle(value.title ?? undefined);
               if (typeof onTopicReady !== "undefined") {
                 onTopicReady(value);
               }
@@ -109,16 +107,15 @@ const Discussion = ({
           resId={resId}
           resType={resType}
           type={type}
-          showStudent={showStudent}
           onSelect={(
             _e: React.MouseEvent<HTMLSpanElement, MouseEvent>,
             comment: IComment
           ) => showChildrenDrawer(comment)}
-          onReply={(comment: IComment) => showChildrenDrawer(comment)}
           onReady={() => {}}
           changedAnswerCount={answerCount}
-          onItemCountChange={(count: number) => {
-            store.dispatch(countChange({ count, resId, resType }));
+          onItemCountChange={() => {
+            // 话题数变了(新建 / 删除),角标按服务端口径重取
+            refreshDiscussionBadge(resType, resId);
           }}
         />
       )}

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

@@ -2,7 +2,6 @@ import { Skeleton } from "antd";
 import { useEffect, useState } from "react";
 import { get } from "../../request";
 import type { IArticleResponse } from "../../api/article";
-import type { ICommentAnchorResponse } from "../../api/Comment";
 
 import AnchorCard from "./AnchorCard";
 
@@ -20,32 +19,13 @@ export interface IAnchor {
 interface IWidget {
   resId?: string;
   resType?: TResType;
-  topicId?: string;
   onLoad?: Function;
 }
-const DiscussionAnchorWidget = ({
-  resId,
-  resType,
-  topicId,
-  onLoad,
-}: IWidget) => {
+const DiscussionAnchorWidget = ({ resId, resType, onLoad }: IWidget) => {
   const [title, setTitle] = useState<React.ReactNode>();
   const [content, setContent] = useState<string>();
   const [loading, setLoading] = useState(false);
 
-  useEffect(() => {
-    if (typeof topicId === "string") {
-      const url = `/api/v2/discussion-anchor/${topicId}`;
-      console.info("api request", url);
-      get<ICommentAnchorResponse>(url).then((json) => {
-        console.debug("api response", json);
-        if (json.ok) {
-          setContent(json.data);
-        }
-      });
-    }
-  }, [topicId]);
-
   useEffect(() => {
     let url: string;
     switch (resType) {

+ 49 - 0
dashboard-v6/src/components/discussion/DiscussionBadgeLoader.tsx

@@ -0,0 +1,49 @@
+import { useEffect, useRef } from "react";
+
+import { useAppSelector } from "../../hooks";
+import { badgeTargets, setCounts } from "../../reducers/discussion-badge";
+import {
+  fetchDiscussionBadges,
+  type TDiscussionBadgeTarget,
+} from "../../api/discussion";
+import store from "../../store";
+
+/** 角标是边渲染边登记的,等登记告一段落再一次性请求 */
+const DEBOUNCE_MS = 300;
+/** 与后端 BatchCountDiscussionRequest::MAX_TARGETS 一致 */
+const BATCH_SIZE = 500;
+
+/**
+ * 讨论角标的数据源:把各角标登记的对象(句子 / 逐词)攒一批,调
+ * `POST /v3/discussions/batch-count`。每个对象只取一次;写入讨论后的刷新走
+ * utils.refreshDiscussionBadge。全站只挂一份(layouts/Root.tsx)。
+ */
+const DiscussionBadgeLoader = () => {
+  const targets = useAppSelector(badgeTargets);
+  // 已经发出过请求的对象,不重复取
+  const requested = useRef(new Set<string>());
+
+  useEffect(() => {
+    const pending = targets.filter((key) => !requested.current.has(key));
+    if (pending.length === 0) {
+      return;
+    }
+    const timer = setTimeout(() => {
+      pending.forEach((key) => requested.current.add(key));
+      const items = pending.map((key) => {
+        const [type, id] = key.split("/");
+        return { type, id } as TDiscussionBadgeTarget;
+      });
+      for (let i = 0; i < items.length; i += BATCH_SIZE) {
+        fetchDiscussionBadges(items.slice(i, i + BATCH_SIZE)).then((list) =>
+          store.dispatch(setCounts(list))
+        );
+      }
+    }, DEBOUNCE_MS);
+    return () => clearTimeout(timer);
+  }, [targets]);
+
+  return <></>;
+};
+
+export default DiscussionBadgeLoader;

+ 0 - 6
dashboard-v6/src/components/discussion/DiscussionBox.tsx

@@ -12,11 +12,6 @@ import store from "../../store";
 import Discussion from "./Discussion";
 import type { IComment } from "../../api/discussion";
 
-export interface IAnswerCount {
-  id: string;
-  count: number;
-}
-
 const DiscussionBoxWidget = () => {
   const [topicId, setTopicId] = useState<string>();
   const [currTopic, setCurrTopic] = useState<IComment>();
@@ -53,7 +48,6 @@ const DiscussionBoxWidget = () => {
         resId={discussionMessage?.resId}
         resType={discussionMessage?.resType}
         focus={discussionMessage?.comment}
-        showStudent={discussionMessage?.withStudent}
         showTopicId={topicId}
         onTopicReady={(value: IComment) => {
           setCurrTopic(value);

+ 29 - 59
dashboard-v6/src/components/discussion/DiscussionButton.tsx

@@ -1,27 +1,31 @@
+import { useEffect } from "react";
 import { Space, Tooltip } from "antd";
 
-import { count } from "../../reducers/discussion";
 import { CommentFillIcon, CommentOutlinedIcon } from "../../assets/icon";
 import { useAppSelector } from "../../hooks";
-import { currentUser } from "../../reducers/current-user";
-import { discussionList } from "../../reducers/discussion-count";
-import type {
-  IDiscussionCountData,
-  IDiscussionCountWbw,
-} from "../../api/Comment";
-import { useMemo } from "react";
+import { badgeCounts, register } from "../../reducers/discussion-badge";
 import type { TResType } from "../../api/discussion";
+import store from "../../store";
 import { openDiscussion } from "./utils";
 
 interface IWidget {
+  /** 角标数据到达之前先显示的数(句子数据里自带的 suggestionCount.discussion) */
   initCount?: number;
   resId?: string;
   resType?: TResType;
   hideCount?: boolean;
   hideInZero?: boolean;
+  /** 只数我发起的 */
   onlyMe?: boolean;
-  wbw?: IDiscussionCountWbw;
 }
+
+/**
+ * 句子 / 逐词上的讨论角标。挂载时登记自己的对象,由 DiscussionBadgeLoader 批量取数
+ * (`POST /v3/discussions/batch-count`:未关闭的评论线程数与其中我发起的)。
+ *
+ * TODO: 课程迁 v3 之后补回 v2 课程模式的「学生的讨论」(老师在答案 channel 上看到学员
+ * 在同一个词上的讨论,v2 靠 wbw 坐标匹配)。现在只数这个对象本身上的。
+ */
 const DiscussionButton = ({
   initCount = 0,
   resId,
@@ -29,60 +33,26 @@ const DiscussionButton = ({
   hideCount = false,
   hideInZero = false,
   onlyMe = false,
-  wbw,
 }: IWidget) => {
-  const user = useAppSelector(currentUser);
-  const discussions = useAppSelector(discussionList);
-  const discussionCount = useAppSelector(count);
+  const counts = useAppSelector(badgeCounts);
 
-  const CommentCount = useMemo(() => {
-    if (
-      discussionCount?.resType === "sentence" &&
-      discussionCount.resId === resId
-    ) {
-      return discussionCount.count;
-    } else {
-      return initCount;
+  useEffect(() => {
+    if (resId && (resType === "sentence" || resType === "wbw")) {
+      store.dispatch(register({ type: resType, id: resId }));
     }
-  }, [discussionCount, resId, initCount]);
-
-  const all = discussions?.filter((value) => value.res_id === resId);
-  const my = all?.filter((value) => value.editor_uid === user?.id);
-
-  let withStudent: IDiscussionCountData[] | undefined;
-  if (wbw) {
-    withStudent = discussions?.filter(
-      (value) =>
-        value.wbw?.book_id === wbw?.book_id &&
-        value.wbw?.paragraph === wbw?.paragraph &&
-        value.wbw?.wid.toString() === wbw?.wid.toString()
-    );
-  }
-
-  //console.debug("DiscussionButton", discussions, wbw, withStudent);
+  }, [resId, resType]);
 
-  let currCount = CommentCount;
-  if (onlyMe) {
-    if (my) {
-      currCount = my.length;
-    } else {
-      currCount = 0;
-    }
-  } else {
-    if (all) {
-      currCount = all.length;
-    } else {
-      currCount = 0;
-    }
-    if (withStudent) {
-      currCount += withStudent.length;
-    }
-  }
+  const badge = resId ? counts[`${resType}/${resId}`] : undefined;
+  // 角标数据到了就以它为准;没到之前,全部数用句子自带的初始值,「只看我的」先算 0
+  const currCount = badge
+    ? onlyMe
+      ? badge.mine
+      : badge.total
+    : onlyMe
+      ? 0
+      : initCount;
 
-  let myCount = false;
-  if (my && my.length > 0) {
-    myCount = true;
-  }
+  const myCount = (badge?.mine ?? 0) > 0;
 
   return hideInZero && currCount === 0 ? (
     <></>
@@ -96,7 +66,7 @@ const DiscussionButton = ({
         }}
         onClick={() => {
           if (resId) {
-            openDiscussion(resId, resType, wbw ? true : false);
+            openDiscussion(resId, resType, false);
           }
         }}
       >

+ 0 - 47
dashboard-v6/src/components/discussion/DiscussionCount.tsx

@@ -1,47 +0,0 @@
-import { useEffect } from "react";
-import { useAppSelector } from "../../hooks";
-import { discussions, tags } from "../../reducers/discussion-count";
-import { sentenceList } from "../../reducers/sentence";
-import type {
-  IDiscussionCountRequest,
-  IDiscussionCountResponse,
-} from "../../api/Comment";
-import { post } from "../../request";
-import store from "../../store";
-
-interface IWidget {
-  courseId?: string | null;
-}
-
-const DiscussionCount = ({ courseId }: IWidget) => {
-  const sentences = useAppSelector(sentenceList);
-
-  console.debug("sentences", sentences);
-
-  useEffect(() => {
-    const sentId: string[] = sentences
-      .filter((value) => typeof value.id != "undefined")
-      .map((item) => item.id);
-    if (sentId.length === 0) {
-      return;
-    }
-    const url = "/api/v2/discussion-count";
-    const data: IDiscussionCountRequest = {
-      course_id: courseId ?? undefined,
-      sentences: sentId.map((item) => item.split("-")),
-    };
-    console.info("discussion-count api request", url, data);
-    post<IDiscussionCountRequest, IDiscussionCountResponse>(url, data).then(
-      (json) => {
-        console.debug("discussion-count api response", json);
-        if (json.ok) {
-          store.dispatch(discussions(json.data.discussions));
-          store.dispatch(tags(json.data.tags));
-        }
-      }
-    );
-  }, [courseId, sentences]);
-  return <></>;
-};
-
-export default DiscussionCount;

+ 150 - 164
dashboard-v6/src/components/discussion/DiscussionCreate.tsx

@@ -1,213 +1,199 @@
 import { useIntl } from "react-intl";
-import { App, Form } from "antd";
+import { Form } from "antd";
 import {
   ProForm,
   type ProFormInstance,
+  ProFormSelect,
   ProFormText,
-  ProFormTextArea,
 } from "@ant-design/pro-components";
 
-import { post } from "../../request";
-import type { ICommentRequest, ICommentResponse } from "../../api/Comment";
 import { useAppSelector } from "../../hooks";
 import { currentUser as _currentUser } from "../../reducers/current-user";
 import { useEffect, useRef, useState } from "react";
 import MDEditor from "@uiw/react-md-editor";
-import type { IComment, TDiscussionType } from "../../api/discussion";
-import type { TContentType } from "../../api/article";
-import { discussionCountUpgrade, toIComment } from "./utils";
+import {
+  asDiscussionTarget,
+  createReply,
+  createTopic,
+  SEVERITY_OPTIONS,
+  type IComment,
+  type TDiscussionType,
+  type TResType,
+  type TSeverity,
+} from "../../api/discussion";
+import { refreshDiscussionBadge } from "./utils";
+
+interface IFormValues {
+  title?: string;
+  content?: string;
+  type?: TDiscussionType;
+  severity?: TSeverity;
+}
 
 interface IWidget {
   resId?: string;
-  resType?: string; //TODO change
+  resType?: TResType;
+  /** 传了就是回复这个话题 */
   parent?: string;
-  topicId?: string;
+  /** 新话题的默认类型 */
   type?: TDiscussionType;
+  /** 当前用户能发起的话题类型(来自 /v3/discussions/summary),多于一种时给出选择 */
+  creatableTypes?: TDiscussionType[];
+  /** 尚未创建的模板话题:先建话题,再回复 */
   topic?: IComment;
-  contentType?: TContentType;
   onCreated?: (value: IComment) => void;
   onTopicCreated?: (value: IComment) => void;
 }
 const DiscussionCreateWidget = ({
   resId,
   resType,
-  contentType = "html",
   parent,
-  topicId,
   topic,
-  type = "discussion",
+  type,
+  creatableTypes = [],
   onCreated,
   onTopicCreated,
 }: IWidget) => {
   const intl = useIntl();
-  const { message } = App.useApp();
-  const formRef = useRef<ProFormInstance | undefined>(undefined);
+  const formRef = useRef<ProFormInstance<IFormValues> | undefined>(undefined);
   const _currUser = useAppSelector(_currentUser);
   const [currParent, setCurrParent] = useState(parent);
+  const [currType, setCurrType] = useState<TDiscussionType>(
+    type ?? creatableTypes[0] ?? "discussion"
+  );
 
   useEffect(() => {
     setCurrParent(parent);
   }, [parent]);
 
+  const target = asDiscussionTarget(resType);
+  const isReply =
+    typeof currParent !== "undefined" || typeof topic?.tplId === "string";
+
   if (typeof _currUser === "undefined") {
     return <></>;
-  } else {
-    return (
+  }
+  return (
+    <div>
+      <div>{_currUser?.nickName}:</div>
       <div>
-        <div>{_currUser?.nickName}:</div>
-        <div>
-          <ProForm<IComment>
-            formRef={formRef}
-            autoFocusFirstInput={false}
-            onFinish={async (values) => {
-              //新建
-              console.log("create", resId, resType, currParent, topic);
-              console.log("value", values);
-              let newParent: string | undefined;
-              if (typeof currParent === "undefined") {
-                if (typeof topic !== "undefined" && topic.tplId) {
-                  /**
-                   * 在模版下跟帖
-                   * 先建立模版topic,再建立跟帖
-                   */
-                  const topicData: ICommentRequest = {
-                    res_id: resId,
-                    res_type: resType,
-                    title: topic.title,
-                    tpl_id: topic.tplId,
-                    content: topic.content,
-                    content_type: "markdown",
-                    type: topic.type,
-                  };
-                  const url = `/api/v2/discussion`;
-                  console.log("create topic api request", url, topicData);
-                  const newTopic = await post<
-                    ICommentRequest,
-                    ICommentResponse
-                  >(url, topicData);
-                  if (newTopic.ok) {
-                    discussionCountUpgrade(resId);
-                    setCurrParent(newTopic.data.id);
-                    newParent = newTopic.data.id;
-                    if (typeof onTopicCreated !== "undefined") {
-                      onTopicCreated(toIComment(newTopic.data));
-                    }
-                  } else {
-                    console.error("no parent id");
-                    return;
-                  }
+        <ProForm<IFormValues>
+          formRef={formRef}
+          autoFocusFirstInput={false}
+          initialValues={{ type: currType }}
+          onFinish={async (values) => {
+            if (isReply) {
+              let topicId = currParent;
+              if (typeof topicId === "undefined") {
+                // 在模板话题下跟帖:先建立模板话题,再回复
+                if (!topic?.tplId || !resId || !target) {
+                  return;
                 }
-              }
-              const url = `/api/v2/discussion`;
-              const data: ICommentRequest = {
-                res_id: resId,
-                res_type: resType,
-                parent: newParent ? newParent : currParent,
-                topicId: topicId,
-                title: values.title,
-                content: values.content,
-                content_type: contentType,
-                type: topic ? topic.type : type,
-              };
-              console.info("api request", url, data);
-              post<ICommentRequest, ICommentResponse>(url, data)
-                .then((json) => {
-                  console.debug("new discussion api response", json);
-                  if (json.ok) {
-                    formRef.current?.resetFields();
-                    discussionCountUpgrade(resId);
-                    if (typeof onCreated !== "undefined") {
-                      onCreated(toIComment(json.data));
-                    }
-                  } else {
-                    message.error(json.message);
-                  }
-                })
-                .catch((e) => {
-                  message.error(e.message);
+                const newTopic = await createTopic({
+                  target_type: target,
+                  target_id: resId,
+                  type: topic.type,
+                  title: topic.title ?? "",
+                  content: topic.content ?? undefined,
+                  tpl_id: topic.tplId,
                 });
-            }}
-            params={{}}
-          >
+                topicId = newTopic.id;
+                setCurrParent(topicId);
+                onTopicCreated?.(newTopic);
+              }
+              if (!topicId) {
+                return;
+              }
+              const reply = await createReply(topicId, values.content ?? "");
+              formRef.current?.resetFields();
+              refreshDiscussionBadge(target, resId);
+              onCreated?.(reply);
+              return;
+            }
+
+            if (!resId || !target) {
+              return;
+            }
+            const newType = values.type ?? currType;
+            const created = await createTopic({
+              target_type: target,
+              target_id: resId,
+              type: newType,
+              title: values.title ?? "",
+              content: values.content,
+              severity: SEVERITY_OPTIONS[newType] ? values.severity : undefined,
+            });
+            formRef.current?.resetFields();
+            refreshDiscussionBadge(target, resId);
+            onCreated?.(created);
+          }}
+          params={{}}
+        >
+          {!isReply && creatableTypes.length > 1 && typeof type === "undefined" ? (
             <ProForm.Group>
-              <ProFormText
-                name="title"
-                width={"lg"}
-                hidden={
-                  typeof currParent !== "undefined" ||
-                  typeof topic?.tplId !== "undefined"
-                }
-                label={intl.formatMessage({ id: "forms.fields.title.label" })}
-                tooltip="最长为 24 位"
-                placeholder={intl.formatMessage({
-                  id: "forms.message.question.required",
-                })}
-                rules={[
-                  { required: currParent || topic?.tplId ? false : true },
-                ]}
+              <ProFormSelect
+                name="type"
+                width="sm"
+                allowClear={false}
+                label={intl.formatMessage({ id: "forms.fields.type.label" })}
+                options={creatableTypes.map((value) => ({
+                  value,
+                  label: value,
+                }))}
+                fieldProps={{
+                  onChange: (value: TDiscussionType) => setCurrType(value),
+                }}
               />
-            </ProForm.Group>
-            <ProForm.Group>
-              {contentType === "text" ? (
-                <ProFormTextArea
-                  name="content"
-                  label={intl.formatMessage({
-                    id: "forms.fields.content.label",
-                  })}
-                  placeholder={intl.formatMessage({
-                    id: "forms.fields.content.placeholder",
-                  })}
+              {SEVERITY_OPTIONS[currType] ? (
+                <ProFormSelect
+                  name="severity"
+                  width="sm"
+                  label="severity"
+                  options={SEVERITY_OPTIONS[currType]?.map((value) => ({
+                    value,
+                    label: value,
+                  }))}
                 />
-              ) : contentType === "html" ? (
-                <Form.Item
-                  name="content"
-                  label={intl.formatMessage({
-                    id: "forms.fields.content.label",
-                  })}
-                  tooltip="可以直接粘贴屏幕截图"
-                ></Form.Item>
-              ) : contentType === "markdown" ? (
-                <Form.Item
-                  name="content"
-                  rules={[
-                    {
-                      required:
-                        typeof currParent !== "undefined" ||
-                        typeof topic?.tplId !== "undefined",
-                    },
-                  ]}
-                  label={
-                    typeof currParent === "undefined" &&
-                    typeof topic?.tplId === "undefined"
-                      ? intl.formatMessage({
-                          id: "forms.message.question.description.option",
-                        })
-                      : intl.formatMessage({
-                          id: "forms.fields.replay.label",
-                        })
-                  }
-                >
-                  <MDEditor
-                    textareaProps={{
-                      placeholder:
-                        "问题的详细描述" +
-                        (typeof currParent !== "undefined" &&
-                        typeof topic?.tplId !== "undefined"
-                          ? ""
-                          : "(选填)"),
-                      maxLength: 10000,
-                    }}
-                  />
-                </Form.Item>
-              ) : (
-                <></>
-              )}
+              ) : undefined}
             </ProForm.Group>
-          </ProForm>
-        </div>
+          ) : undefined}
+          <ProForm.Group>
+            <ProFormText
+              name="title"
+              width={"lg"}
+              hidden={isReply}
+              label={intl.formatMessage({ id: "forms.fields.title.label" })}
+              tooltip="最长为 24 位"
+              placeholder={intl.formatMessage({
+                id: "forms.message.question.required",
+              })}
+              rules={[{ required: !isReply }]}
+            />
+          </ProForm.Group>
+          <ProForm.Group>
+            <Form.Item
+              name="content"
+              rules={[{ required: isReply }]}
+              label={
+                isReply
+                  ? intl.formatMessage({ id: "forms.fields.replay.label" })
+                  : intl.formatMessage({
+                      id: "forms.message.question.description.option",
+                    })
+              }
+            >
+              <MDEditor
+                textareaProps={{
+                  placeholder: "问题的详细描述" + (isReply ? "" : "(选填)"),
+                  maxLength: 10000,
+                }}
+              />
+            </Form.Item>
+          </ProForm.Group>
+        </ProForm>
       </div>
-    );
-  }
+    </div>
+  );
 };
 
 export default DiscussionCreateWidget;

+ 1 - 5
dashboard-v6/src/components/discussion/DiscussionDrawer.tsx

@@ -7,10 +7,6 @@ import { useIntl } from "react-intl";
 import Discussion from "./Discussion";
 import type { TResType } from "../../api/discussion";
 
-export interface IAnswerCount {
-  id: string;
-  count: number;
-}
 interface IWidget {
   trigger?: JSX.Element;
   open?: boolean;
@@ -89,7 +85,7 @@ const DiscussionDrawerWidget = ({
         open={openDrawer}
         maskClosable={false}
       >
-        <Discussion resId={resId} resType={resType} showStudent={false} />
+        <Discussion resId={resId} resType={resType} />
       </Drawer>
     </>
   );

+ 13 - 40
dashboard-v6/src/components/discussion/DiscussionEdit.tsx

@@ -1,12 +1,10 @@
 import { useIntl } from "react-intl";
-import { App, Button, Card, Col, Form, Row, Space } from "antd";
+import { Button, Card, Col, Form, Row, Space } from "antd";
 import { ProForm, ProFormText } from "@ant-design/pro-components";
 import { CloseOutlined } from "@ant-design/icons";
 
-import { put } from "../../request";
-import type { ICommentRequest, ICommentResponse } from "../../api/Comment";
 import MDEditor from "@uiw/react-md-editor";
-import type { IComment } from "../../api/discussion";
+import { updateDiscussion, type IComment } from "../../api/discussion";
 
 interface IWidget {
   data: IComment;
@@ -15,7 +13,6 @@ interface IWidget {
 }
 const DiscussionEditWidget = ({ data, onUpdated, onClose }: IWidget) => {
   const intl = useIntl();
-  const { message } = App.useApp();
 
   return (
     <Card
@@ -47,41 +44,17 @@ const DiscussionEditWidget = ({ data, onUpdated, onClose }: IWidget) => {
           },
         }}
         onFinish={async (values) => {
-          const url = `/api/v2/discussion/${data.id}`;
-          const newData: ICommentRequest = {
-            title: values.title,
-            content: values.content,
-          };
-          console.info("DiscussionEdit api request", url, newData);
-          put<ICommentRequest, ICommentResponse>(url, newData)
-            .then((json) => {
-              console.debug("DiscussionEdit api response", json);
-              if (json.ok) {
-                console.log(intl.formatMessage({ id: "flashes.success" }));
-                if (typeof onUpdated !== "undefined") {
-                  const newData: IComment = {
-                    id: json.data.id, //id未提供为新建
-                    resId: json.data.res_id,
-                    resType: json.data.res_type,
-                    user: json.data.editor,
-                    parent: json.data.parent,
-                    title: json.data.title,
-                    content: json.data.content,
-                    status: json.data.status,
-                    childrenCount: json.data.children_count,
-                    createdAt: json.data.created_at,
-                    updatedAt: json.data.updated_at,
-                    type: json.data.type,
-                  };
-                  onUpdated(newData);
-                }
-              } else {
-                message.error(json.message);
-              }
-            })
-            .catch((e) => {
-              message.error(e.message);
-            });
+          if (!data.id) {
+            return;
+          }
+          // 局部更新:回复没有标题,只发正文;话题发标题与正文,状态不动
+          const updated = await updateDiscussion(
+            data.id,
+            data.parent
+              ? { content: values.content ?? "" }
+              : { title: values.title ?? "", content: values.content ?? "" }
+          );
+          onUpdated?.(updated);
         }}
         params={{}}
         request={async () => {

+ 0 - 60
dashboard-v6/src/components/discussion/DiscussionList.tsx

@@ -1,60 +0,0 @@
-import { List } from "antd";
-
-import DiscussionItem from "./DiscussionItem";
-import type { IComment } from "../../api/discussion";
-
-interface IWidget {
-  data: IComment[];
-  onSelect?: (
-    e: React.MouseEvent<HTMLSpanElement, MouseEvent>,
-    data: IComment
-  ) => void;
-  onDelete?: (id: string) => void;
-  onClose?: (item: IComment) => void;
-}
-const DiscussionListWidget = ({
-  data,
-  onSelect,
-  onDelete,
-  onClose,
-}: IWidget) => {
-  return (
-    <List
-      pagination={{
-        onChange: (page) => {
-          console.log(page);
-        },
-        pageSize: 10,
-      }}
-      itemLayout="horizontal"
-      dataSource={data}
-      renderItem={(item) => (
-        <List.Item>
-          <DiscussionItem
-            data={item}
-            onSelect={(
-              e: React.MouseEvent<HTMLSpanElement, MouseEvent>,
-              data: IComment
-            ) => {
-              if (typeof onSelect !== "undefined") {
-                onSelect(e, data);
-              }
-            }}
-            onDelete={() => {
-              if (item.id) {
-                onDelete?.(item.id);
-              }
-            }}
-            onClose={() => {
-              if (typeof onClose !== "undefined") {
-                onClose(item);
-              }
-            }}
-          />
-        </List.Item>
-      )}
-    />
-  );
-};
-
-export default DiscussionListWidget;

+ 92 - 121
dashboard-v6/src/components/discussion/DiscussionListCard.tsx

@@ -1,11 +1,9 @@
 import { useEffect, useRef, useState } from "react";
-import { Button, Space, Typography } from "antd";
+import { Button, Space, Tag, Typography } from "antd";
 import { LinkOutlined } from "@ant-design/icons";
 
 import { get } from "../../request";
-import type { ICommentListResponse } from "../../api/Comment";
 
-import type { IAnswerCount } from "./DiscussionDrawer";
 import { type ActionType, ProList } from "@ant-design/pro-components";
 import DiscussionCreate from "./DiscussionCreate";
 import User from "../auth/User";
@@ -14,53 +12,54 @@ import { useAppSelector } from "../../hooks";
 import { currentUser as _currentUser } from "../../reducers/current-user";
 import { CommentOutlinedIcon, TemplateOutlinedIcon } from "../../assets/icon";
 
-import { courseInfo } from "../../reducers/current-course";
-import { courseUser } from "../../reducers/course-user";
 import TimeShow from "../general/TimeShow";
-import type { IComment, TDiscussionType, TResType } from "../../api/discussion";
+import {
+  asDiscussionTarget,
+  fetchTopicSummary,
+  fetchTopics,
+  NO_ABILITIES,
+  THREAD_TYPES,
+  type IAnswerCount,
+  type IComment,
+  type TDiscussionStatus,
+  type TDiscussionType,
+  type TResType,
+} from "../../api/discussion";
 import type { ISentenceResponse } from "../../api/sentence";
 import StatusBadge from "../general/StatusBadge";
+import { SeverityTag } from "./SeverityTag";
 
-const { Paragraph } = Typography;
+const { Paragraph, Text } = Typography;
 
 interface IWidget {
   resId?: string;
   resType?: TResType;
-  topicId?: string;
-  userId?: string;
   changedAnswerCount?: IAnswerCount;
+  /** 只列这种类型的话题;不传则列出全部类型 */
   type?: TDiscussionType;
   pageSize?: number;
-  showStudent?: boolean; //在课程中是否显示学生discussions
   onSelect?: Function;
   onItemCountChange?: Function;
-  onReply?: Function;
   onReady?: Function;
 }
 const DiscussionListCardWidget = ({
   resId,
   resType,
-  topicId,
-  userId,
-  showStudent = false,
   onSelect,
   changedAnswerCount,
-  type = "discussion",
+  type,
   pageSize = 10,
   onItemCountChange,
   onReady,
 }: IWidget) => {
   const ref = useRef<ActionType | null>(null);
-  const [activeKey, setActiveKey] = useState<React.Key | undefined>("active");
+  const [activeKey, setActiveKey] = useState<TDiscussionStatus>("active");
   const [activeNumber, setActiveNumber] = useState<number>(0);
   const [closeNumber, setCloseNumber] = useState<number>(0);
-  const [count, setCount] = useState<number>(0);
-  const [canCreate, setCanCreate] = useState(false);
-
-  const course = useAppSelector(courseInfo);
-  const myCourse = useAppSelector(courseUser);
+  const [creatableTypes, setCreatableTypes] = useState<TDiscussionType[]>([]);
 
   const user = useAppSelector(_currentUser);
+  const target = asDiscussionTarget(resType);
 
   useEffect(() => {
     ref.current?.reload();
@@ -71,11 +70,7 @@ const DiscussionListCardWidget = ({
     ref.current?.reload();
   }, [changedAnswerCount]);
 
-  if (
-    typeof resId === "undefined" &&
-    typeof topicId === "undefined" &&
-    typeof userId === "undefined"
-  ) {
+  if (typeof resId === "undefined" || typeof target === "undefined") {
     return (
       <Typography.Paragraph>
         该资源尚未创建,不能发表讨论。
@@ -85,7 +80,7 @@ const DiscussionListCardWidget = ({
   return (
     <>
       <ProList<IComment>
-        rowKey="id"
+        rowKey={(row) => row.id ?? `tpl-${row.tplId}`}
         actionRef={ref}
         metas={{
           avatar: {
@@ -103,21 +98,31 @@ const DiscussionListCardWidget = ({
                 <>
                   <div>
                     {entity.resId !== resId ? <LinkOutlined /> : <></>}
-                    <Button
-                      key={index}
-                      size="small"
-                      type="link"
-                      icon={
-                        entity.newTpl ? <TemplateOutlinedIcon /> : undefined
-                      }
-                      onClick={(event) => {
-                        if (typeof onSelect !== "undefined") {
-                          onSelect(event, entity);
+                    {entity.deleted ? (
+                      <Text type="secondary" italic>
+                        (已删除)
+                      </Text>
+                    ) : (
+                      <Button
+                        key={index}
+                        size="small"
+                        type="link"
+                        icon={
+                          entity.newTpl ? <TemplateOutlinedIcon /> : undefined
                         }
-                      }}
-                    >
-                      {entity.title}
-                    </Button>
+                        onClick={(event) => {
+                          if (typeof onSelect !== "undefined") {
+                            onSelect(event, entity);
+                          }
+                        }}
+                      >
+                        {entity.title}
+                      </Button>
+                    )}
+                    {entity.type !== "discussion" ? (
+                      <Tag>{entity.type}</Tag>
+                    ) : undefined}
+                    <SeverityTag severity={entity.severity} />
                   </div>
                   <div>
                     <TimeShow
@@ -135,7 +140,7 @@ const DiscussionListCardWidget = ({
             dataIndex: "content",
             search: false,
             render(_dom, entity, index, _action, _schema) {
-              const content = entity.summary ?? entity.content;
+              const content = entity.summary ?? entity.content ?? undefined;
               return (
                 <div key={index}>
                   <Paragraph
@@ -143,9 +148,6 @@ const DiscussionListCardWidget = ({
                     ellipsis={{
                       rows: 2,
                       expandable: true,
-                      onEllipsis: (ellipsis) => {
-                        console.log("Ellipsis changed:", ellipsis);
-                      },
                     }}
                     title={content}
                   >
@@ -158,7 +160,11 @@ const DiscussionListCardWidget = ({
           actions: {
             render: (_text, row, index, _action) => [
               row.childrenCount ? (
-                <Space key={index}>
+                <Space
+                  key={index}
+                  style={{ cursor: "pointer" }}
+                  onClick={(event) => onSelect?.(event, row)}
+                >
                   <CommentOutlinedIcon key={"icon"} />
                   <span key={"count"}>{row.childrenCount}</span>
                 </Space>
@@ -169,89 +175,55 @@ const DiscussionListCardWidget = ({
           },
         }}
         request={async (params = {}, _sorter, _filter) => {
-          let url: string = `/api/v2/discussion?type=${type}&res_type=${resType}&`;
-          if (typeof topicId !== "undefined") {
-            url += `view=question-by-topic&id=${topicId}`;
-          } else if (typeof resId !== "undefined") {
-            url += `view=question&id=${resId}`;
-          } else if (typeof userId !== "undefined") {
-            url += `view=topic-by-user`;
-          } else {
-            return {
-              total: 0,
-              succcess: false,
-            };
-          }
-          const offset =
-            ((params.current ? params.current : 1) - 1) *
-            (params.pageSize ? params.pageSize : pageSize);
-          url += `&limit=${params.pageSize}&offset=${offset}`;
-          url += params.keyword ? "&search=" + params.keyword : "";
-          url += activeKey ? "&status=" + activeKey : "";
-
-          if (myCourse && course) {
-            if (myCourse.role !== "student") {
-              url += `&course=${course.courseId}`;
-            }
-          }
-          if (showStudent) {
-            url += `&show_student=true`;
-          }
-          console.info("DiscussionListCard api request", url);
-          const res = await get<ICommentListResponse>(url);
-          console.info("DiscussionListCard api response", res);
-          setCount(res.data.active);
-          setCanCreate(res.data.can_create);
-          const items: IComment[] = res.data.rows.map((item, _id) => {
-            return {
-              id: item.id,
-              resId: item.res_id,
-              resType: item.res_type,
-              type: item.type,
-              user: item.editor,
-              title: item.title,
-              parent: item.parent,
-              tplId: item.tpl_id,
-              content: item.content,
-              summary: item.summary,
-              status: item.status,
-              childrenCount: item.children_count,
-              createdAt: item.created_at,
-              updatedAt: item.updated_at,
-            };
-          });
+          const [res, summary] = await Promise.all([
+            fetchTopics({
+              target_type: target,
+              target_id: resId,
+              type,
+              status: activeKey,
+              page: params.current ?? 1,
+              per_page: params.pageSize ?? pageSize,
+            }),
+            fetchTopicSummary({
+              target_type: target,
+              target_id: resId,
+              type,
+            }),
+          ]);
+          // summary 也会列出批注类型(note / commentary),讨论面板只新建评论线程
+          setCreatableTypes(
+            THREAD_TYPES.filter((value) =>
+              summary.creatable_types?.includes(value)
+            )
+          );
+          setActiveNumber(summary.status_counts?.active ?? 0);
+          setCloseNumber(summary.status_counts?.close ?? 0);
 
           let topicTpl: IComment[] = [];
           if (
             activeKey !== "close" &&
+            target === "sentence" &&
             user?.roles?.includes("basic") === false
           ) {
             //获取channel模版
-            let studioName: string | undefined;
-            switch (resType) {
-              case "sentence": {
-                const url = `/api/v2/sentence/${resId}`;
-                console.info("api request", url);
-                const sentInfo = await get<ISentenceResponse>(url);
-                console.info("api response", sentInfo);
-                studioName = sentInfo.data.studio.realName;
-                break;
-              }
-            }
+            const url = `/api/v2/sentence/${resId}`;
+            console.info("api request", url);
+            const sentInfo = await get<ISentenceResponse>(url);
+            const studioName = sentInfo.data.studio.realName;
             const urlTpl = `/api/v2/article?view=template&studio_name=${studioName}&subtitle=_template_discussion_topic_&content=true`;
             const resTpl = await get<IArticleListResponse>(urlTpl);
             if (resTpl.ok) {
-              console.log("resTpl.data.rows", resTpl.data.rows);
               topicTpl = resTpl.data.rows
                 .filter(
                   (value) =>
-                    items.findIndex((old) => old.tplId === value.uid) === -1
+                    res.items.findIndex((old) => old.tplId === value.uid) ===
+                    -1
                 )
                 .map((item) => {
                   return {
                     tplId: item.uid,
                     resId: resId,
-                    resType: resType,
+                    resType: target,
                     type: "discussion",
                     user: item.editor
                       ? item.editor
@@ -263,6 +235,7 @@ const DiscussionListCardWidget = ({
                     summary: item.summary ? item.summary : item._summary,
                     status: "active",
                     childrenCount: 0,
+                    abilities: NO_ABILITIES,
                     newTpl: true,
                     createdAt: item.created_at,
                     updatedAt: item.updated_at,
@@ -271,15 +244,13 @@ const DiscussionListCardWidget = ({
             }
           }
 
-          setActiveNumber(res.data.active);
-          setCloseNumber(res.data.close);
           if (typeof onReady !== "undefined") {
             onReady();
           }
           return {
-            total: res.data.count,
-            succcess: true,
-            data: [...topicTpl, ...items],
+            total: res.total,
+            success: true,
+            data: [...topicTpl, ...res.items],
           };
         }}
         bordered
@@ -322,22 +293,22 @@ const DiscussionListCardWidget = ({
               },
             ],
             onChange(key) {
-              setActiveKey(key);
+              setActiveKey(key as TDiscussionStatus);
               ref.current?.reload();
             },
           },
         }}
       />
 
-      {canCreate && resId && resType ? (
+      {creatableTypes.length > 0 ? (
         <DiscussionCreate
-          contentType="markdown"
           resId={resId}
-          resType={resType}
+          resType={target}
           type={type}
+          creatableTypes={creatableTypes}
           onCreated={() => {
             if (typeof onItemCountChange !== "undefined") {
-              onItemCountChange(count + 1);
+              onItemCountChange(activeNumber + 1);
             }
             ref.current?.reload();
           }}

+ 0 - 39
dashboard-v6/src/components/discussion/DiscussionMock.tsx

@@ -1,39 +0,0 @@
-import type { IComment, TDiscussionType, TResType } from "../../api/discussion";
-
-export interface IAnswerCount {
-  id: string;
-  count: number;
-}
-
-interface IWidget {
-  resId?: string;
-  resType?: TResType;
-  showTopicId?: string;
-  focus?: string;
-  type?: TDiscussionType;
-  showStudent?: boolean;
-  onTopicReady?: (value: IComment) => void;
-}
-
-const DiscussionWidget = ({
-  resId,
-  resType,
-  showTopicId,
-  showStudent = false,
-  focus,
-  type = "discussion",
-}: IWidget) => {
-  console.debug(
-    "discussion mock",
-    resId,
-    resType,
-    showTopicId,
-    showStudent,
-    focus,
-    type
-  );
-
-  return <>discussion mock</>;
-};
-
-export default DiscussionWidget;

+ 59 - 73
dashboard-v6/src/components/discussion/DiscussionShow.tsx

@@ -23,14 +23,17 @@ import type { MenuProps } from "antd";
 
 import TimeShow from "../general/TimeShow";
 import Marked from "../general/Marked";
-import { delete_, put } from "../../request";
-import type { IDeleteResponse } from "../../api/article";
 import { fullUrl } from "../../utils";
-import type { ICommentRequest, ICommentResponse } from "../../api/Comment";
 import { useState } from "react";
-import type { IComment, TDiscussionType } from "../../api/discussion";
-import { discussionCountUpgrade } from "./utils";
+import {
+  deleteDiscussion,
+  updateDiscussion,
+  type IComment,
+  type TDiscussionType,
+} from "../../api/discussion";
+import { refreshDiscussionBadge } from "./utils";
 import MdView from "../general/MdView";
+import { SeverityTag } from "./SeverityTag";
 
 const { Text } = Typography;
 
@@ -77,83 +80,47 @@ const DiscussionShowWidget = ({
       cancelText: intl.formatMessage({
         id: "buttons.no",
       }),
-      onOk() {
-        const url = `/api/v2/discussion/${id}`;
-        console.info("Discussion delete api request", url);
-        return delete_<IDeleteResponse>(url)
-          .then((json) => {
-            console.debug("api response", json);
-            if (json.ok) {
-              message.success("删除成功");
-              discussionCountUpgrade(resId);
-              if (typeof onDelete !== "undefined") {
-                onDelete(id);
-              }
-            } else {
-              message.error(json.message);
-            }
-          })
-          .catch((e) => console.log("Oops errors!", e));
+      onOk: async () => {
+        await deleteDiscussion(id);
+        message.success("删除成功");
+        refreshDiscussionBadge(data.resType, resId);
+        onDelete?.(id);
       },
     });
   };
 
-  const close = (value: boolean) => {
-    const url = `/api/v2/discussion/${data.id}`;
-    const newData: ICommentRequest = {
-      title: data.title,
-      content: data.content,
+  const close = async (value: boolean) => {
+    if (!data.id) {
+      return;
+    }
+    const updated = await updateDiscussion(data.id, {
       status: value ? "close" : "active",
-    };
-    console.info("api request", url, newData);
-    put<ICommentRequest, ICommentResponse>(url, newData).then((json) => {
-      console.log(json);
-      if (json.ok) {
-        setClosed(json.data.status);
-        discussionCountUpgrade(data.resId);
-        if (typeof onClose !== "undefined") {
-          onClose(value);
-        }
-      } else {
-        message.error(json.message);
-      }
     });
+    setClosed(updated.status);
+    refreshDiscussionBadge(data.resType, data.resId);
+    onClose?.(value);
   };
 
-  const convert = (newType: TDiscussionType) => {
-    const url = `/api/v2/discussion/${data.id}`;
-    const newData: ICommentRequest = {
-      title: data.title,
-      content: data.content,
-      status: data.status,
-      type: newType,
-    };
-    console.info("api response", url, newData);
-    put<ICommentRequest, ICommentResponse>(url, newData).then((json) => {
-      console.info("api response", json);
-      if (json.ok) {
-        notification.info({ message: "转换成功" });
-        if (typeof onConvert !== "undefined") {
-          onConvert(newType);
-        }
-      }
-    });
+  const convert = async (newType: TDiscussionType) => {
+    if (!data.id) {
+      return;
+    }
+    await updateDiscussion(data.id, { type: newType });
+    notification.info({ message: "转换成功" });
+    onConvert?.(newType);
   };
 
   const onClick: MenuProps["onClick"] = (e) => {
     console.log("click ", e);
     switch (e.key) {
       case "copy-link": {
-        let url = `/discussion/topic/`;
-        if (data.id) {
-          if (data.parent) {
-            url += `${data.parent}#${data.id}`;
-          } else {
-            url += data.id;
-          }
-        } else {
-          url += `${data.tplId}?tpl=true&resId=${data.resId}&resType=${data.resType}`;
+        if (!data.id) {
+          break;
         }
+        // 回复用 #锚点 定位,话题页据此滚动高亮
+        const url = data.parent
+          ? `/discussion/topic/${data.parent}#${data.id}`
+          : `/discussion/topic/${data.id}`;
 
         navigator.clipboard.writeText(fullUrl(url)).then(() => {
           message.success("链接地址已经拷贝到剪贴板");
@@ -187,6 +154,9 @@ const DiscussionShowWidget = ({
       case "convert_discussion":
         convert("discussion");
         break;
+      case "convert_issue":
+        convert("issue");
+        break;
       case "delete":
         if (data.id && data.resId) {
           showDeleteConfirm(data.id, data.resId, data.title ?? "");
@@ -196,7 +166,6 @@ const DiscussionShowWidget = ({
         break;
     }
   };
-  console.log("children", data.childrenCount);
   const items: MenuProps["items"] = [
     {
       key: "copy-link",
@@ -204,6 +173,7 @@ const DiscussionShowWidget = ({
         id: "buttons.copy.link",
       }),
       icon: <LinkOutlined />,
+      disabled: !data.id,
     },
     {
       key: "copy-tpl",
@@ -222,6 +192,7 @@ const DiscussionShowWidget = ({
         id: "buttons.edit",
       }),
       icon: <EditOutlined />,
+      disabled: !data.abilities.update,
     },
     {
       key: "close",
@@ -229,7 +200,7 @@ const DiscussionShowWidget = ({
         id: "buttons.close",
       }),
       icon: <CloseOutlined />,
-      disabled: closed === "close",
+      disabled: !data.abilities.close || closed === "close",
     },
     {
       key: "reopen",
@@ -237,7 +208,7 @@ const DiscussionShowWidget = ({
         id: "buttons.open",
       }),
       icon: <CheckOutlined />,
-      disabled: closed === "active",
+      disabled: !data.abilities.close || closed === "active",
     },
     {
       type: "divider",
@@ -248,7 +219,7 @@ const DiscussionShowWidget = ({
         id: "buttons.convert",
       }),
       icon: <SyncOutlined />,
-      disabled: data.parent ? true : false,
+      disabled: data.parent || !data.abilities.update ? true : false,
       children: [
         { key: "convert_qa", label: "qa", disabled: data.type === "qa" },
         { key: "convert_help", label: "help", disabled: data.type === "help" },
@@ -257,6 +228,7 @@ const DiscussionShowWidget = ({
           label: "discussion",
           disabled: data.type === "discussion",
         },
+        { key: "convert_issue", label: "issue", disabled: data.type === "issue" },
       ],
     },
     {
@@ -269,7 +241,8 @@ const DiscussionShowWidget = ({
       }),
       icon: <DeleteOutlined />,
       danger: true,
-      disabled: data.childrenCount && data.childrenCount > 0 ? true : false,
+      // 软删除:话题有回复也能删,删掉的话题留作墓碑,回复照常显示
+      disabled: !data.abilities.delete,
     },
   ];
 
@@ -291,12 +264,16 @@ const DiscussionShowWidget = ({
         ) : undefined}
         <Text type="secondary" style={{ fontSize: "80%" }}>
           <Space>
+            <SeverityTag severity={data.severity} />
             {!data.parent && closed === "close" ? (
               <Tag style={{ backgroundColor: "#8250df", color: "white" }}>
                 {"closed"}
               </Tag>
             ) : undefined}
             {data.user.nickName}
+            {data.coEditor ? (
+              <Text type="secondary">(与 {data.coEditor.nickName})</Text>
+            ) : undefined}
             <TimeShow
               type="secondary"
               updatedAt={data.updatedAt}
@@ -338,6 +315,15 @@ const DiscussionShowWidget = ({
       </Space>
     );
   };
+  if (data.deleted) {
+    return (
+      <Card size="small" style={{ width: "100%" }}>
+        <Text type="secondary" italic>
+          (已删除)
+        </Text>
+      </Card>
+    );
+  }
   return (
     <Card
       size="small"
@@ -349,7 +335,7 @@ const DiscussionShowWidget = ({
         {data.html ? (
           <MdView html={data.html} />
         ) : (
-          <Marked text={data.content} />
+          <Marked text={data.content ?? undefined} />
         )}
       </div>
       {data.type === "qa" && data.parent ? (

+ 12 - 1
dashboard-v6/src/components/discussion/DiscussionTopic.tsx

@@ -2,7 +2,12 @@ import { useEffect, useState } from "react";
 
 import DiscussionTopicInfo from "./DiscussionTopicInfo";
 import DiscussionTopicChildren from "./DiscussionTopicChildren";
-import type { IComment, TDiscussionType, TResType } from "../../api/discussion";
+import {
+  fetchDiscussion,
+  type IComment,
+  type TDiscussionType,
+  type TResType,
+} from "../../api/discussion";
 
 interface IWidget {
   resType?: TResType;
@@ -48,6 +53,12 @@ const DiscussionTopicWidget = ({
           onTopicReady?.(value);
         }}
         onDelete={onTopicDelete}
+        onClose={(value: IComment) => {
+          // 关闭 / 重新打开会改变能否回复(abilities.reply),重新取一次话题,回复框跟着变
+          if (value.id) {
+            fetchDiscussion(value.id).then(setCurrTopic);
+          }
+        }}
         onConvert={onConvert}
       />
       <DiscussionTopicChildren

+ 13 - 43
dashboard-v6/src/components/discussion/DiscussionTopicChildren.tsx

@@ -1,13 +1,15 @@
-import { App, List, Skeleton } from "antd";
+import { List, Skeleton } from "antd";
 
 import { useEffect, useState } from "react";
-import { useIntl } from "react-intl";
 
 import { get } from "../../request";
-import type { ICommentListResponse } from "../../api/Comment";
 
 import DiscussionCreate from "./DiscussionCreate";
-import type { IComment, TResType } from "../../api/discussion";
+import {
+  fetchReplies,
+  type IComment,
+  type TResType,
+} from "../../api/discussion";
 import type {
   ISentHistoryData,
   ISentHistoryListResponse,
@@ -43,8 +45,6 @@ const DiscussionTopicChildrenWidget = ({
   onItemCountChange,
   onTopicCreate,
 }: IWidget) => {
-  const intl = useIntl();
-  const { message } = App.useApp();
   const [data, setData] = useState<IComment[]>([]);
   const [loading, setLoading] = useState(false);
   const [history, setHistory] = useState<ISentHistoryData[]>([]);
@@ -154,40 +154,10 @@ const DiscussionTopicChildrenWidget = ({
       return;
     }
     setLoading(true);
-    const url = `/api/v2/discussion?view=answer&id=${topicId}`;
-    console.info("api request", url);
-    get<ICommentListResponse>(url)
-      .then((json) => {
-        console.debug("api response", json);
-        if (json.ok) {
-          const discussions: IComment[] = json.data.rows.map((item) => {
-            return {
-              id: item.id,
-              resId: item.res_id,
-              resType: item.res_type,
-              type: item.type,
-              user: item.editor,
-              parent: item.parent,
-              title: item.title,
-              content: item.content,
-              status: item.status,
-              childrenCount: item.children_count,
-              createdAt: item.created_at,
-              updatedAt: item.updated_at,
-            };
-          });
-          setData(discussions);
-        } else {
-          message.error(json.message);
-        }
-      })
-      .finally(() => {
-        setLoading(false);
-      })
-      .catch((e) => {
-        message.error(e.message);
-      });
-  }, [intl, message, topicId]);
+    fetchReplies(topicId)
+      .then(setData)
+      .finally(() => setLoading(false));
+  }, [topicId]);
   return (
     <div>
       {loading ? (
@@ -232,15 +202,15 @@ const DiscussionTopicChildrenWidget = ({
           }}
         />
       )}
-      {hideReply ? (
+      {hideReply ||
+      // 话题关闭、删除或没有回复权限时不给回复框;尚未创建的模板话题由回复框负责创建
+      (topic && !topic.newTpl && !topic.abilities.reply) ? (
         <></>
       ) : (
         <DiscussionCreate
           resId={resId}
           resType={resType}
-          contentType="markdown"
           parent={topicId}
-          topicId={topicId}
           topic={topic}
           onCreated={(value: IComment) => {
             const newData = JSON.parse(JSON.stringify(value));

+ 12 - 41
dashboard-v6/src/components/discussion/DiscussionTopicInfo.tsx

@@ -1,11 +1,12 @@
-import { App } from "antd";
 import { useEffect, useState } from "react";
 
-import { get } from "../../request";
-import type { ICommentResponse } from "../../api/Comment";
 import DiscussionItem from "./DiscussionItem";
 
-import type { IComment, TDiscussionType } from "../../api/discussion";
+import {
+  fetchDiscussion,
+  type IComment,
+  type TDiscussionType,
+} from "../../api/discussion";
 
 interface IWidget {
   topicId?: string;
@@ -27,7 +28,6 @@ const DiscussionTopicInfoWidget = ({
   onClose,
   onConvert,
 }: IWidget) => {
-  const { message } = App.useApp();
   const [data, setData] = useState<IComment | undefined>(topic);
   useEffect(() => {
     setData(topic);
@@ -42,44 +42,15 @@ const DiscussionTopicInfoWidget = ({
   }, [childrenCount]);
 
   useEffect(() => {
-    if (typeof topicId === "undefined") {
+    // 调用方已经带着这条话题来了(如话题页),不再重复取
+    if (typeof topicId === "undefined" || topic?.id === topicId) {
       return;
     }
-    const url = `/api/v2/discussion/${topicId}`;
-    console.info("discussion api request", url);
-    get<ICommentResponse>(url)
-      .then((json) => {
-        console.debug("api response", json);
-        if (json.ok) {
-          const item = json.data;
-          const discussion: IComment = {
-            id: item.id,
-            resId: item.res_id,
-            resType: item.res_type,
-            type: item.type,
-            parent: item.parent,
-            user: item.editor,
-            title: item.title,
-            content: item.content,
-            html: item.html,
-            status: item.status,
-            childrenCount: item.children_count,
-            createdAt: item.created_at,
-            updatedAt: item.updated_at,
-          };
-          setData(discussion);
-          if (typeof onReady !== "undefined") {
-            console.log("discussion on ready");
-            onReady(discussion);
-          }
-        } else {
-          message.error(json.message);
-        }
-      })
-      .catch((e) => {
-        message.error(e.message);
-      });
-  }, [message, topicId]);
+    fetchDiscussion(topicId).then((discussion) => {
+      setData(discussion);
+      onReady?.(discussion);
+    });
+  }, [topicId]);
 
   return (
     <div>

+ 61 - 90
dashboard-v6/src/components/discussion/InteractiveArea.tsx

@@ -2,110 +2,81 @@ import { useEffect, useState } from "react";
 import { Tabs } from "antd";
 
 import Discussion from "./Discussion";
-import { get } from "../../request";
 import QaBox from "./QaBox";
-import type { TResType } from "../../api/discussion";
-
-interface IInteractive {
-  ok: boolean;
-  data: ITypeData;
-  message: string;
-}
-
-interface ITypeData {
-  qa: IPower;
-  help: IPower;
-  discussion: IPower;
-}
-
-interface IPower {
-  can_create: boolean;
-  can_reply: boolean;
-  count: number;
-}
+import {
+  asDiscussionTarget,
+  fetchTopicSummary,
+  type TDiscussionType,
+  type TResType,
+} from "../../api/discussion";
 
 interface IWidget {
   resId?: string;
   resType?: TResType;
 }
-const InteractiveAreaWidget = ({ resId, resType }: IWidget) => {
-  const [showQa, setShowQa] = useState(false);
 
-  const [showHelp, setShowHelp] = useState(false);
-  const [showDiscussion, setShowDiscussion] = useState(false);
+/** 文章底部的三个页签,按这个顺序排 */
+const TABS: { type: TDiscussionType; label: string }[] = [
+  { type: "qa", label: "问答" },
+  { type: "help", label: "求助" },
+  { type: "discussion", label: "讨论" },
+];
 
-  useEffect(() => {
-    get<IInteractive>(`/api/v2/interactive/${resId}?res_type=${resType}`).then(
-      (json) => {
-        if (json.ok) {
-          console.debug("interactive", json);
-          if (json.data.qa.can_create || json.data.qa.can_reply) {
-            setShowQa(true);
-          } else if (json.data.qa.count > 0) {
-            setShowQa(true);
-          } else {
-            setShowQa(false);
-          }
+/**
+ * 文章底部的互动区:问答 / 求助 / 讨论三个页签。
+ *
+ * 某个页签出现的条件:当前用户能在这里发起这种话题,或者已经有这种话题。
+ * 取代 v2 `GET /v2/interactive/{id}` 的 can_create / can_reply / count,改用每种类型
+ * 各一次 `GET /v3/discussions/summary`(creatable_types + status_counts)。
+ */
+const InteractiveAreaWidget = ({ resId, resType }: IWidget) => {
+  // 记下是哪个对象的结果,换文章时旧结果自然失效,不必在 effect 里先清状态
+  const [visible, setVisible] = useState<{
+    key: string;
+    types: TDiscussionType[];
+  }>();
+  const target = asDiscussionTarget(resType);
+  const key = `${target}/${resId}`;
 
-          if (json.data.help.can_create) {
-            setShowHelp(true);
-          } else if (json.data.help.can_reply) {
-            if (json.data.help.count > 0) {
-              setShowHelp(true);
-            }
-          } else {
-            setShowHelp(false);
-          }
+  useEffect(() => {
+    if (!resId || !target) {
+      return;
+    }
+    Promise.all(
+      TABS.map(({ type }) =>
+        fetchTopicSummary({ target_type: target, target_id: resId, type })
+      )
+    ).then((summaries) => {
+      const types = TABS.filter(({ type }, i) => {
+        const counts = summaries[i].status_counts;
+        const total = (counts?.active ?? 0) + (counts?.close ?? 0);
+        return summaries[i].creatable_types?.includes(type) || total > 0;
+      }).map(({ type }) => type);
+      setVisible({ key: `${target}/${resId}`, types });
+    });
+  }, [resId, target]);
 
-          if (
-            json.data.discussion.can_create ||
-            json.data.discussion.can_reply ||
-            json.data.discussion.count > 0
-          ) {
-            setShowDiscussion(true);
-          } else {
-            setShowDiscussion(false);
-          }
-        }
-      }
-    );
-  }, [resId, resType]);
+  const types = visible?.key === key ? visible.types : [];
+  if (types.length === 0) {
+    return <></>;
+  }
 
-  return showQa || showHelp || showDiscussion ? (
+  return (
     <Tabs
       size="small"
-      items={[
-        {
-          label: `问答`,
-          key: "qa",
-          children: <QaBox resId={resId} resType={resType} />,
-        },
-        {
-          label: `求助`,
-          key: "help",
-          children: <Discussion resId={resId} resType={resType} type="help" />,
-        },
-        {
-          label: `讨论`,
-          key: "discussion",
-          children: (
-            <Discussion resId={resId} resType={resType} type="discussion" />
-          ),
-        },
-      ].filter((value) => {
-        if (value.key === "qa") {
-          return showQa;
-        } else if (value.key === "help") {
-          return showHelp;
-        } else if (value.key === "discussion") {
-          return showDiscussion;
-        } else {
-          return false;
-        }
-      })}
+      items={TABS.filter(({ type }) => types.includes(type)).map(
+        ({ type, label }) => ({
+          label,
+          key: type,
+          children:
+            type === "qa" ? (
+              <QaBox resId={resId} resType={resType} />
+            ) : (
+              <Discussion resId={resId} resType={resType} type={type} />
+            ),
+        })
+      )}
     />
-  ) : (
-    <></>
   );
 };
 

+ 1 - 1
dashboard-v6/src/components/discussion/QaBox.tsx

@@ -72,7 +72,7 @@ const DiscussionWidget = ({
             focus={focus}
             hideTitle
             onTopicReady={(value: IComment) => {
-              setTopicTitle(value.title);
+              setTopicTitle(value.title ?? undefined);
               if (typeof onTopicReady !== "undefined") {
                 onTopicReady(value);
               }

+ 31 - 63
dashboard-v6/src/components/discussion/QaList.tsx

@@ -1,9 +1,12 @@
 import { useEffect, useState } from "react";
 
-import { get } from "../../request";
-import type { ICommentListResponse } from "../../api/Comment";
 import DiscussionItem from "./DiscussionItem";
-import type { IComment, TResType } from "../../api/discussion";
+import {
+  asDiscussionTarget,
+  fetchTopics,
+  type IComment,
+  type TResType,
+} from "../../api/discussion";
 
 interface IWidget {
   resId?: string;
@@ -13,75 +16,40 @@ interface IWidget {
     value: IComment
   ) => void;
 }
+/**
+ * 某个对象上的问答(qa)话题,最早的在前。点开话题再看回答(由 QaBox 负责)。
+ */
 const QaListWidget = ({ resId, resType, onSelect }: IWidget) => {
   const [data, setData] = useState<IComment[]>();
 
   useEffect(() => {
-    if (!resType || !resType) {
+    const target = asDiscussionTarget(resType);
+    if (!resId || !target) {
       return;
     }
-    let url: string = `/api/v2/discussion?res_type=${resType}&view=res_id&id=${resId}`;
-    url += "&dir=asc&type=qa&status=active,close";
-    console.info("api request", url);
-    get<ICommentListResponse>(url).then((json) => {
-      if (json.ok) {
-        console.debug("discussion api response", json);
-        const items: IComment[] = json.data.rows.map((item) => {
-          return {
-            id: item.id,
-            resId: item.res_id,
-            resType: item.res_type,
-            type: item.type,
-            user: item.editor,
-            title: item.title,
-            parent: item.parent,
-            tplId: item.tpl_id,
-            content: item.content,
-            summary: item.summary,
-            status: item.status,
-            childrenCount: item.children_count,
-            createdAt: item.created_at,
-            updatedAt: item.updated_at,
-          };
-        });
+    fetchTopics({
+      target_type: target,
+      target_id: resId,
+      type: "qa",
+      sort: "created_at",
+      per_page: 200,
+    }).then((res) => setData(res.items));
+  }, [resId, resType]);
 
-        setData(items);
-      }
-    });
-  }, []);
   return (
     <>
-      {data
-        ?.filter((value) => !value.parent)
-        .map((question, index) => {
-          return (
-            <div key={`div_${index}`}>
-              <DiscussionItem
-                key={index}
-                data={question}
-                onSelect={(
-                  e: React.MouseEvent<HTMLSpanElement, MouseEvent>,
-                  value: IComment
-                ) => {
-                  onSelect?.(e, value);
-                }}
-              />
-              <div
-                style={{
-                  marginLeft: 16,
-                  borderLeft: "2px solid gray",
-                  padding: 4,
-                }}
-              >
-                {data
-                  ?.filter((value) => value.parent === question.id)
-                  .map((item, id) => {
-                    return <DiscussionItem key={id} data={item} />;
-                  })}
-              </div>
-            </div>
-          );
-        })}
+      {data?.map((question) => (
+        <DiscussionItem
+          key={question.id}
+          data={question}
+          onSelect={(
+            e: React.MouseEvent<HTMLSpanElement, MouseEvent>,
+            value: IComment
+          ) => {
+            onSelect?.(e, value);
+          }}
+        />
+      ))}
     </>
   );
 };

+ 16 - 0
dashboard-v6/src/components/discussion/SeverityTag.tsx

@@ -0,0 +1,16 @@
+import { Tag } from "antd";
+
+const COLORS: Record<string, string> = {
+  error: "red",
+  urgent: "red",
+  warning: "orange",
+  info: "blue",
+  normal: "default",
+};
+
+interface IWidget {
+  severity?: string | null;
+}
+
+export const SeverityTag = ({ severity }: IWidget) =>
+  severity ? <Tag color={COLORS[severity] ?? "default"}>{severity}</Tag> : null;

+ 31 - 32
dashboard-v6/src/components/discussion/hooks/useDiscussion.tsx

@@ -1,59 +1,58 @@
-import { useState, useEffect, useCallback, useRef } from "react";
+import { useState, useEffect, useCallback } from "react";
+
 import {
-  fetchCommentList,
-  type ICommentListResponse,
-  type IFetchCommentListParams,
-} from "../../../api/Comment";
+  fetchTopics,
+  type IComment,
+  type TDiscussionStatus,
+} from "../../../api/discussion";
+
+interface IUseTaskLogParams {
+  perPage?: number;
+  status?: TDiscussionStatus;
+}
 
 interface IUseTaskLogReturn {
-  data: ICommentListResponse["data"] | null;
+  data: { items: IComment[]; total: number } | null;
   loading: boolean;
   refresh: () => void;
 }
 
+/**
+ * 任务上的讨论(含 v2 TaskStatusController 写进来的状态变更日志),最新的在前。
+ */
 export const useDiscussion = (
   taskId?: string,
-  params: IFetchCommentListParams = {}
+  { perPage = 5, status = "active" }: IUseTaskLogParams = {}
 ): IUseTaskLogReturn => {
-  const [data, setData] = useState<ICommentListResponse["data"] | null>(null);
+  const [data, setData] = useState<IUseTaskLogReturn["data"]>(null);
   const [loading, setLoading] = useState(false);
   const [tick, setTick] = useState(0);
 
-  const paramsKey = JSON.stringify(params);
-  const paramsRef = useRef<IFetchCommentListParams>(params);
-  useEffect(() => {
-    paramsRef.current = params;
-    // eslint-disable-next-line react-hooks/exhaustive-deps
-  }, [paramsKey]);
-
   const refresh = useCallback(() => setTick((t) => t + 1), []);
 
   useEffect(() => {
     if (!taskId) return;
 
     let active = true;
-
-    const fetchData = async () => {
-      setLoading(true);
-      try {
-        const res = await fetchCommentList(taskId, paramsRef.current);
-        if (!active) return;
-        if (res.ok) {
-          setData(res.data);
-        }
-      } catch (e) {
-        console.error("tasklog fetch", e);
-      } finally {
+    setLoading(true);
+    fetchTopics({
+      target_type: "task",
+      target_id: taskId,
+      type: "discussion",
+      status,
+      per_page: perPage,
+    })
+      .then((res) => {
+        if (active) setData(res);
+      })
+      .finally(() => {
         if (active) setLoading(false);
-      }
-    };
-
-    fetchData();
+      });
 
     return () => {
       active = false;
     };
-  }, [taskId, paramsKey, tick]);
+  }, [taskId, perPage, status, tick]);
 
   return { data, loading, refresh };
 };

+ 10 - 35
dashboard-v6/src/components/discussion/utils.ts

@@ -1,12 +1,7 @@
-import type {
-  ICommentApiData,
-  IDiscussionCountResponse,
-} from "../../api/Comment";
-import type { IComment, TResType } from "../../api/discussion";
+import { fetchDiscussionBadges, type TResType } from "../../api/discussion";
 import { show, type IShowDiscussion } from "../../reducers/discussion";
-import { upgrade } from "../../reducers/discussion-count";
+import { setCounts } from "../../reducers/discussion-badge";
 import { openPanel } from "../../reducers/right-panel";
-import { get } from "../../request";
 import store from "../../store";
 
 export const openDiscussion = (
@@ -25,34 +20,14 @@ export const openDiscussion = (
   store.dispatch(openPanel("discussion"));
 };
 
-export const discussionCountUpgrade = (resId?: string) => {
-  if (typeof resId === "undefined") {
+/**
+ * 写入讨论之后重取某个对象的讨论角标(只有句子 / 逐词有角标,其它对象直接返回)。
+ */
+export const refreshDiscussionBadge = (resType?: TResType, resId?: string) => {
+  if (!resId || (resType !== "sentence" && resType !== "wbw")) {
     return;
   }
-  const url = `/api/v2/discussion-count/${resId}`;
-  console.info("discussion-count api request", url);
-  get<IDiscussionCountResponse>(url).then((json) => {
-    console.debug("discussion-count api response", json);
-    if (json.ok) {
-      store.dispatch(upgrade({ resId: resId, data: json.data.discussions }));
-    } else {
-      console.error(json.message);
-    }
-  });
-};
-
-export const toIComment = (value: ICommentApiData): IComment => {
-  return {
-    id: value.id,
-    resId: value.res_id,
-    resType: value.res_type,
-    type: value.type,
-    user: value.editor,
-    title: value.title,
-    parent: value.parent,
-    tplId: value.tpl_id,
-    content: value.content,
-    createdAt: value.created_at,
-    updatedAt: value.updated_at,
-  };
+  fetchDiscussionBadges([{ type: resType, id: resId }]).then((list) =>
+    store.dispatch(setCounts(list))
+  );
 };

+ 1 - 1
dashboard-v6/src/components/tag/TagsArea.tsx

@@ -2,7 +2,7 @@ import { Badge, Popover, Tag } from "antd";
 import type { ITagMapData } from "../../api/tag";
 
 import { useAppSelector } from "../../hooks";
-import { tagList } from "../../reducers/discussion-count";
+import { tagList } from "../../reducers/tag-map";
 
 import TagSelectButton from "./TagSelectButton";
 import { numToHex } from "../../utils";

+ 46 - 0
dashboard-v6/src/components/tag/TagsLoader.tsx

@@ -0,0 +1,46 @@
+import { useEffect } from "react";
+
+import { useAppSelector } from "../../hooks";
+import { sentenceList } from "../../reducers/sentence";
+import { tags } from "../../reducers/tag-map";
+import type {
+  ISentenceTagsRequest,
+  ISentenceTagsResponse,
+} from "../../api/tag";
+import { post } from "../../request";
+import store from "../../store";
+
+/** 句子是边渲染边登记的,等登记告一段落再一次性请求 */
+const DEBOUNCE_MS = 300;
+
+/**
+ * 页面上已渲染句子的标签(逐词上的 TagsArea 从 tag-map reducer 读)。全站只挂一份(layouts/Root.tsx)。
+ *
+ * FIXME: 仍走 v2 `POST /v2/discussion-count`,只读响应里的 tags(讨论角标已拆到
+ * DiscussionBadgeLoader,走 v3)。标签迁 v3 时换掉。
+ */
+const TagsLoader = () => {
+  const sentences = useAppSelector(sentenceList);
+
+  useEffect(() => {
+    const ids = sentences.map((item) => item.id).filter(Boolean);
+    if (ids.length === 0) {
+      return;
+    }
+    const timer = setTimeout(() => {
+      post<ISentenceTagsRequest, ISentenceTagsResponse>(
+        "/api/v2/discussion-count",
+        { sentences: ids.map((item) => item.split("-")) }
+      ).then((json) => {
+        if (json.ok) {
+          store.dispatch(tags(json.data.tags));
+        }
+      });
+    }, DEBOUNCE_MS);
+    return () => clearTimeout(timer);
+  }, [sentences]);
+
+  return <></>;
+};
+
+export default TagsLoader;

+ 1 - 1
dashboard-v6/src/components/tag/TagsOnItem.tsx

@@ -24,7 +24,7 @@ import { useRef, useState } from "react";
 import TagsList from "./TagList";
 import type { IDeleteResponse } from "../../api/article";
 import store from "../../store";
-import { tagsUpgrade } from "../../reducers/discussion-count";
+import { tagsUpgrade } from "../../reducers/tag-map";
 
 interface IWidget {
   studioName?: string;

+ 5 - 5
dashboard-v6/src/components/task/TaskLog.tsx

@@ -11,7 +11,7 @@ interface IWidget {
   onMore?: () => void;
 }
 
-function findKeywordInTitle(title?: string): string | undefined {
+function findKeywordInTitle(title?: string | null): string | undefined {
   if (!title) return undefined;
   for (const keyword of StatusButtons) {
     if (title.includes(keyword)) return keyword;
@@ -26,26 +26,26 @@ const TaskLog = ({ taskId, onMore }: IWidget) => {
     <>
       <Timeline>
         {loading && <Skeleton paragraph={{ rows: 1 }} active avatar />}
-        {logData?.rows.map((item, id) => {
+        {logData?.items.map((item, id) => {
           const status = findKeywordInTitle(item.title);
           return (
             <Timeline.Item
               key={id}
               color={TaskStatusColor(status as TTaskStatus)}
-              icon={<User {...item.editor} showName={false} />}
+              icon={<User {...item.user} showName={false} />}
             >
               <div>
                 <TimeShow
                   showLabel={false}
                   showIcon={false}
-                  createdAt={item.created_at}
+                  createdAt={item.createdAt}
                 />
               </div>
               <div>{item.title}</div>
             </Timeline.Item>
           );
         })}
-        {logData && logData.count > 5 && (
+        {logData && logData.total > 5 && (
           <Timeline.Item>
             <Button type="link" onClick={onMore}>
               更多

+ 1 - 1
dashboard-v6/src/components/term/TermItem.tsx

@@ -16,7 +16,7 @@ import { useAppSelector } from "../../hooks";
 import { click, clickedTerm } from "../../reducers/term-click";
 import store from "../../store";
 import "../article/article.css";
-import Discussion from "../discussion/DiscussionMock";
+import Discussion from "../discussion/Discussion";
 import { useIntl } from "react-intl";
 import User from "../auth/User";
 import MdView from "../general/MdView";

+ 19 - 34
dashboard-v6/src/components/wbw/WbwPaliDiscussionIcon.tsx

@@ -1,9 +1,7 @@
 import { useAppSelector } from "../../hooks";
 import { courseUser } from "../../reducers/course-user";
-import { courseInfo } from "../../reducers/current-course";
 import { currentUser } from "../../reducers/current-user";
-import type { IDiscussionCountWbw } from "../../api/Comment";
-//import DiscussionButton from "../../discussion/DiscussionButton";
+import DiscussionButton from "../discussion/DiscussionButton";
 
 import type { IStudio } from "../../api/Auth";
 import type { IWbw } from "../../types/wbw";
@@ -11,39 +9,29 @@ import type { IWbw } from "../../types/wbw";
 interface IWidget {
   data: IWbw;
   studio?: IStudio;
+  /** 老师视角按 channel 匹配学员讨论时要用,见下面的 TODO */
   channelId?: string;
 }
-const WbwPaliDiscussionIcon = ({ data, studio, channelId }: IWidget) => {
+
+/**
+ * 逐词上的讨论图标(有讨论才显示)。
+ *
+ * 课程里的学员:看自己的逐词 channel 时显示全部讨论,看别人的只显示自己发起的。
+ *
+ * TODO: 课程迁 v3 之后补回老师视角的「学生的讨论」:老师在课程答案 channel 上,
+ * 同时看到学员们在同一个词上的讨论(v4 传 wbw 坐标 {book_id, paragraph, wid},由 v2
+ * discussion-count 的 course_id 分支按坐标匹配)。现在只数这个词本身上的。
+ * v6 搬运时这个图标整个被注释掉了(「TODO reload」),2026-10-15 恢复。
+ */
+const WbwPaliDiscussionIcon = ({ data, studio }: IWidget) => {
   const userInCourse = useAppSelector(courseUser);
   const currUser = useAppSelector(currentUser);
-  const course = useAppSelector(courseInfo);
 
-  let wbw: IDiscussionCountWbw | undefined;
-  let onlyMe = false;
-  if (userInCourse) {
-    if (userInCourse.role === "student") {
-      if (studio?.id === currUser?.id) {
-        //我自己的wbw channel 显示全部
-        onlyMe = false;
-      } else {
-        //其他channel 只显示自己的
-        onlyMe = true;
-      }
-    } else {
-      if (course) {
-        if (course.channelId === channelId) {
-          wbw = {
-            book_id: data.book,
-            paragraph: data.para,
-            wid: data.sn[0],
-          };
-        }
-      }
-    }
-  }
-  console.debug("wbw discussion", wbw, onlyMe);
-  /** TODO reload
- *  return (
+  // 学员看别人的逐词 channel:只显示自己发起的
+  const onlyMe =
+    userInCourse?.role === "student" && studio?.id !== currUser?.id;
+
+  return (
     <DiscussionButton
       initCount={data.hasComment ? 1 : 0}
       hideCount
@@ -51,11 +39,8 @@ const WbwPaliDiscussionIcon = ({ data, studio, channelId }: IWidget) => {
       onlyMe={onlyMe}
       resId={data.uid}
       resType="wbw"
-      wbw={wbw}
     />
   );
- */
-  return <></>;
 };
 
 export default WbwPaliDiscussionIcon;

+ 6 - 0
dashboard-v6/src/layouts/Root.tsx

@@ -1,10 +1,16 @@
 import { Outlet } from "react-router";
 
+import DiscussionBadgeLoader from "../components/discussion/DiscussionBadgeLoader";
+import TagsLoader from "../components/tag/TagsLoader";
+
 const Widget = () => {
   // TODO
 
   return (
     <div>
+      {/* 全站各一份:讨论角标(v3)与句子标签(仍走 v2),攒批取数 */}
+      <DiscussionBadgeLoader />
+      <TagsLoader />
       <Outlet />
     </div>
   );

+ 33 - 0
dashboard-v6/src/pages/discussion/show.tsx

@@ -0,0 +1,33 @@
+import { useParams } from "react-router";
+import { Divider, Result } from "antd";
+
+import Discussion from "../../components/discussion/Discussion";
+import DiscussionAnchor from "../../components/discussion/DiscussionAnchor";
+import { asDiscussionTarget, type TResType } from "../../api/discussion";
+
+/**
+ * 某个对象上的全部讨论:`/discussion/show/:type/:id`。
+ *
+ * 上面是讨论挂靠的内容(句子 / 文章),下面是话题列表,点开在原地看话题与回复
+ * (模板话题也在原地新建)。讨论抽屉右上角的「新窗口打开」链到这里。
+ * 与话题页一样放在 workspace 之外、不要求登录,能看到什么由后端的可见性规则决定。
+ */
+const Widget = () => {
+  const { type, id } = useParams();
+  const target = asDiscussionTarget(type as TResType);
+
+  if (!target || !id) {
+    return <Result status="404" title="不支持的讨论对象" />;
+  }
+
+  return (
+    <div style={{ maxWidth: 960, margin: "0 auto", padding: 16 }}>
+      <title>discussion</title>
+      <DiscussionAnchor resId={id} resType={target} />
+      <Divider />
+      <Discussion resId={id} resType={target} />
+    </div>
+  );
+};
+
+export default Widget;

+ 86 - 0
dashboard-v6/src/pages/discussion/topic.tsx

@@ -0,0 +1,86 @@
+import { useEffect, useState } from "react";
+import { Link, useLocation, useNavigate, useParams } from "react-router";
+import { Button, Card, Result, Space, Spin, Tag } from "antd";
+import { ArrowLeftOutlined } from "@ant-design/icons";
+
+import DiscussionTopic from "../../components/discussion/DiscussionTopic";
+import DiscussionAnchor from "../../components/discussion/DiscussionAnchor";
+import { fetchDiscussion, type IComment } from "../../api/discussion";
+
+/**
+ * 单个话题页:`/discussion/topic/:topicId`,回复用 `#<回复 id>` 定位。
+ *
+ * 「复制链接」拷出来的就是这个地址。放在 workspace 之外、不要求登录:
+ * qa / help 对匿名公开,能不能看由后端的可见性规则决定(看不到时是 404)。
+ */
+const Widget = () => {
+  const { topicId } = useParams();
+  const { hash } = useLocation();
+  const navigate = useNavigate();
+  // 记下取的是哪条,路由换了话题时旧结果自然失效,不必在 effect 里先清状态
+  const [result, setResult] = useState<{
+    id: string;
+    topic?: IComment;
+    failed?: boolean;
+  }>();
+
+  useEffect(() => {
+    if (!topicId) {
+      return;
+    }
+    fetchDiscussion(topicId)
+      .then((topic) => setResult({ id: topicId, topic }))
+      .catch(() => setResult({ id: topicId, failed: true }));
+  }, [topicId]);
+
+  const current = result?.id === topicId ? result : undefined;
+  const topic = current?.topic;
+
+  const back = (
+    <Button icon={<ArrowLeftOutlined />} onClick={() => navigate("/")}>
+      首页
+    </Button>
+  );
+
+  let body;
+  if (current?.failed) {
+    body = <Result status="404" title="话题不存在或无权查看" extra={back} />;
+  } else if (!topic) {
+    body = <Spin style={{ display: "block", margin: 48 }} />;
+  } else {
+    body = (
+      <>
+        {/* 讨论挂靠的内容:句子 / 文章 */}
+        <DiscussionAnchor resId={topic.resId} resType={topic.resType} />
+        <Card
+          style={{ marginTop: 16 }}
+          title={
+            <Space>
+              <Tag>{topic.type}</Tag>
+              <Link to={`/discussion/show/${topic.resType}/${topic.resId}`}>
+                这里的全部讨论
+              </Link>
+            </Space>
+          }
+        >
+          <DiscussionTopic
+            topicId={topic.id}
+            topic={topic}
+            resType={topic.resType}
+            focus={hash ? hash.slice(1) : undefined}
+            onTopicDelete={() => navigate("/")}
+          />
+        </Card>
+      </>
+    );
+  }
+
+  return (
+    <div style={{ maxWidth: 960, margin: "0 auto", padding: 16 }}>
+      <title>{topic?.title ?? "discussion"}</title>
+      {body}
+    </div>
+  );
+};
+
+export default Widget;

+ 57 - 0
dashboard-v6/src/reducers/discussion-badge.ts

@@ -0,0 +1,57 @@
+/**
+ * 讨论角标:句子 / 逐词上未关闭的评论线程数,以及其中我发起的。
+ *
+ * 角标组件(DiscussionButton)挂载时登记自己的对象,DiscussionBadgeLoader 把登记的
+ * 对象攒一批,调 `POST /v3/discussions/batch-count` 填回来。
+ */
+import { createSlice, type PayloadAction } from "@reduxjs/toolkit";
+
+import type { RootState } from "../store";
+import type { TDiscussionBadge, TDiscussionBadgeTarget } from "../api/discussion";
+
+export interface IBadgeCount {
+  total: number;
+  mine: number;
+}
+
+interface IState {
+  /** 登记过的对象,键为 `type/id` */
+  targets: string[];
+  counts: Record<string, IBadgeCount>;
+}
+
+const initialState: IState = { targets: [], counts: {} };
+
+export const badgeKey = (target: TDiscussionBadgeTarget): string =>
+  `${target.type}/${target.id}`;
+
+export const slice = createSlice({
+  name: "discussion-badge",
+  initialState,
+  reducers: {
+    register: (state, action: PayloadAction<TDiscussionBadgeTarget>) => {
+      const key = badgeKey(action.payload);
+      if (!state.targets.includes(key)) {
+        state.targets.push(key);
+      }
+    },
+    setCounts: (state, action: PayloadAction<TDiscussionBadge[]>) => {
+      for (const item of action.payload) {
+        state.counts[`${item.target_type}/${item.target_id}`] = {
+          total: item.total ?? 0,
+          mine: item.mine ?? 0,
+        };
+      }
+    },
+  },
+});
+
+export const { register, setCounts } = slice.actions;
+
+export const badgeTargets = (state: RootState): string[] =>
+  state.discussionBadge.targets;
+
+export const badgeCounts = (state: RootState): Record<string, IBadgeCount> =>
+  state.discussionBadge.counts;
+
+export default slice.reducer;

+ 0 - 78
dashboard-v6/src/reducers/discussion-count.ts

@@ -1,78 +0,0 @@
-/**
- *
- */
-import { createSlice, type PayloadAction } from "@reduxjs/toolkit";
-
-import type { RootState } from "../store";
-import type { IDiscussionCountData } from "../api/Comment";
-import type { ITagMapData } from "../api/tag";
-
-export interface IUpgrade {
-  resId: string;
-  data?: IDiscussionCountData[];
-  tags?: ITagMapData[];
-}
-
-interface IState {
-  list: IDiscussionCountData[];
-  tags?: ITagMapData[];
-}
-
-const initialState: IState = { list: [] };
-
-export const slice = createSlice({
-  name: "discussion-count",
-  initialState,
-  reducers: {
-    discussions: (state, action: PayloadAction<IDiscussionCountData[]>) => {
-      console.debug("discussion-count publish", action.payload);
-      state.list = action.payload;
-    },
-    tags: (state, action: PayloadAction<ITagMapData[]>) => {
-      console.debug("discussion-count publish", action.payload);
-      state.tags = action.payload;
-    },
-    upgrade: (state, action: PayloadAction<IUpgrade>) => {
-      console.debug("discussion-count publish", action.payload);
-      const old = state.list.filter(
-        (value) => value.res_id !== action.payload.resId
-      );
-
-      if (old) {
-        if (action.payload.data) {
-          state.list = [...old, ...action.payload.data];
-        }
-      } else {
-        if (action.payload.data) {
-          state.list = [...action.payload.data];
-        }
-      }
-    },
-    tagsUpgrade: (state, action: PayloadAction<IUpgrade>) => {
-      console.debug("discussion-count publish", action.payload);
-      const old = state.tags?.filter(
-        (value) => value.anchor_id !== action.payload.resId
-      );
-      if (old) {
-        if (action.payload.tags) {
-          state.tags = [...old, ...action.payload.tags];
-        }
-      } else {
-        if (action.payload.tags) {
-          state.tags = [...action.payload.tags];
-        }
-      }
-    },
-  },
-});
-
-export const { discussions, tags, upgrade, tagsUpgrade } = slice.actions;
-
-export const discussionList = (
-  state: RootState
-): IDiscussionCountData[] | undefined => state.discussionCount.list;
-
-export const tagList = (state: RootState): ITagMapData[] | undefined =>
-  state.discussionCount.tags;
-
-export default slice.reducer;

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

@@ -16,14 +16,8 @@ export interface IShowDiscussion {
   sent?: ISentence;
   withStudent?: boolean;
 }
-export interface ICount {
-  count: number;
-  resType?: TResType;
-  resId?: string;
-}
 interface IState {
   message?: IShowDiscussion;
-  count?: ICount;
   anchor?: IShowDiscussion;
 }
 
@@ -36,21 +30,16 @@ export const slice = createSlice({
     show: (state, action: PayloadAction<IShowDiscussion>) => {
       state.message = action.payload;
     },
-    countChange: (state, action: PayloadAction<ICount>) => {
-      state.count = action.payload;
-    },
     showAnchor: (state, action: PayloadAction<IShowDiscussion>) => {
       state.anchor = action.payload;
     },
   },
 });
 
-export const { show, countChange, showAnchor } = slice.actions;
+export const { show, showAnchor } = slice.actions;
 
 export const message = (state: RootState): IShowDiscussion | undefined =>
   state.discussion.message;
-export const count = (state: RootState): ICount | undefined =>
-  state.discussion.count;
 export const anchor = (state: RootState): IShowDiscussion | undefined =>
   state.discussion.anchor;
 export default slice.reducer;

+ 47 - 0
dashboard-v6/src/reducers/tag-map.ts

@@ -0,0 +1,47 @@
+/**
+ * 句子 / 逐词上的标签(tag_maps),由 TagsLoader 批量取、TagsOnItem 改完后局部更新。
+ *
+ * 原先和讨论角标一起放在 discussion-count reducer 里(v2 的同一个接口一起返回),
+ * 讨论角标迁 v3 时拆开了。
+ */
+import { createSlice, type PayloadAction } from "@reduxjs/toolkit";
+
+import type { RootState } from "../store";
+import type { ITagMapData } from "../api/tag";
+
+export interface ITagsUpgrade {
+  resId: string;
+  tags?: ITagMapData[];
+}
+
+interface IState {
+  tags?: ITagMapData[];
+}
+
+const initialState: IState = {};
+
+export const slice = createSlice({
+  name: "tag-map",
+  initialState,
+  reducers: {
+    tags: (state, action: PayloadAction<ITagMapData[]>) => {
+      state.tags = action.payload;
+    },
+    tagsUpgrade: (state, action: PayloadAction<ITagsUpgrade>) => {
+      if (!action.payload.tags) {
+        return;
+      }
+      const others = (state.tags ?? []).filter(
+        (value) => value.anchor_id !== action.payload.resId
+      );
+      state.tags = [...others, ...action.payload.tags];
+    },
+  },
+});
+
+export const { tags, tagsUpgrade } = slice.actions;
+
+export const tagList = (state: RootState): ITagMapData[] | undefined =>
+  state.tagMap.tags;
+
+export default slice.reducer;

+ 4 - 2
dashboard-v6/src/store.ts

@@ -30,7 +30,8 @@ import focusReducer from "./reducers/focus";
 import prLoadReducer from "./reducers/pr-load";
 import termClickReducer from "./reducers/term-click";
 import cartModeReducer from "./reducers/cart-mode";
-import discussionCountReducer from "./reducers/discussion-count";
+import discussionBadgeReducer from "./reducers/discussion-badge";
+import tagMapReducer from "./reducers/tag-map";
 
 const store = configureStore({
   reducer: {
@@ -64,7 +65,8 @@ const store = configureStore({
     prLoad: prLoadReducer,
     termClick: termClickReducer,
     cartMode: cartModeReducer,
-    discussionCount: discussionCountReducer,
+    discussionBadge: discussionBadgeReducer,
+    tagMap: tagMapReducer,
   },
 });