article.ts 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627
  1. //src/api/article.ts
  2. import type { IStudio, IStudioApiResponse, IUser, TRole } from "./Auth";
  3. import type { IChannel } from "./channel";
  4. import { delete_, get, post, put } from "../request";
  5. import { bookName } from "./bookName";
  6. import type { ITocPathNode } from "./pali-text";
  7. import type { LoaderFunctionArgs } from "react-router";
  8. import type { ListNodeData } from "../components/article/components/EditableTree";
  9. export type TContentType = "text" | "markdown" | "html" | "json";
  10. export type ArticleMode = "read" | "edit" | "wbw" | "auto";
  11. export type ArticleType =
  12. | "anthology"
  13. | "article"
  14. | "series"
  15. | "chapter"
  16. | "para"
  17. | "cs-para"
  18. | "sent"
  19. | "sim"
  20. | "page"
  21. | "textbook"
  22. | "sent-original"
  23. | "sent-commentary"
  24. | "sent-nissaya"
  25. | "sent-translation"
  26. | "term"
  27. | "task";
  28. /**
  29. * 每种article type 对应的路由参数
  30. * article/id?anthology=id&channel=id1,id2&mode=ArticleMode
  31. * chapter/book-para?channel=id1,id2&mode=ArticleMode
  32. * para/book?par=para1,para2&channel=id1,id2&mode=ArticleMode
  33. * cs-para/book-para?channel=id1,id2&mode=ArticleMode
  34. * sent/id?channel=id1,id2&mode=ArticleMode
  35. * sim/id?channel=id1,id2&mode=ArticleMode
  36. * textbook/articleId?course=id&mode=ArticleMode
  37. * exercise/articleId?course=id&exercise=id&username=name&mode=ArticleMode
  38. * exercise-list/articleId?course=id&exercise=id&mode=ArticleMode
  39. * sent-original/id
  40. */
  41. export interface IAnthologyData {
  42. id: string;
  43. title: string;
  44. subTitle: string;
  45. summary: string;
  46. articles: ListNodeData[];
  47. studio: IStudio;
  48. created_at: string;
  49. updated_at: string;
  50. }
  51. export interface IArticleListApiResponse {
  52. article: string;
  53. title: string;
  54. level: string;
  55. children: number;
  56. }
  57. export interface IAnthologyDataRequest {
  58. title: string;
  59. subtitle: string;
  60. summary?: string;
  61. article_list?: IArticleListApiResponse[];
  62. lang: string;
  63. status: number;
  64. default_channel?: string | null;
  65. }
  66. export interface IAnthologyDataResponse {
  67. uid: string;
  68. title: string;
  69. subtitle: string;
  70. summary: string;
  71. article_list: IArticleListApiResponse[];
  72. studio: IStudio;
  73. default_channel?: IChannel;
  74. lang: string;
  75. status: number;
  76. childrenNumber: number;
  77. created_at: string;
  78. updated_at: string;
  79. }
  80. export interface IAnthologyResponse {
  81. ok: boolean;
  82. message: string;
  83. data: IAnthologyDataResponse;
  84. }
  85. export interface IAnthologyListResponse {
  86. ok: boolean;
  87. message: string;
  88. data: {
  89. rows: IAnthologyDataResponse[];
  90. count: number;
  91. };
  92. }
  93. export interface IAnthologyStudioListApiResponse {
  94. ok: boolean;
  95. message: string;
  96. data: {
  97. count: number;
  98. rows: IAnthologyStudioListDataApiResponse[];
  99. };
  100. }
  101. export interface IAnthologyStudioListDataApiResponse {
  102. count: number;
  103. studio: IStudioApiResponse;
  104. }
  105. export interface IArticleDataRequest {
  106. uid: string;
  107. title: string;
  108. subtitle: string;
  109. summary?: string | null;
  110. content?: string;
  111. content_type?: string;
  112. status: number;
  113. lang: string;
  114. to_tpl?: boolean;
  115. anthology_id?: string;
  116. }
  117. export interface IChapterToc {
  118. key?: string;
  119. book: number;
  120. paragraph: number;
  121. level: number;
  122. pali_title: string /**巴利文标题 */;
  123. title?: string /**译文文标题 */;
  124. progress?: number[];
  125. }
  126. export interface IArticleDataResponse {
  127. uid: string;
  128. title: string;
  129. title_text?: string;
  130. subtitle: string;
  131. summary: string | null;
  132. _summary?: string;
  133. content?: string;
  134. content_type?: TContentType;
  135. toc?: IChapterToc[];
  136. html?: string;
  137. path?: ITocPathNode[];
  138. status: number;
  139. lang: string;
  140. anthology_count?: number;
  141. anthology_first?: { uid: string; title: string };
  142. role?: TRole;
  143. studio?: IStudio;
  144. editor?: IUser;
  145. created_at: string;
  146. updated_at: string;
  147. from?: number;
  148. to?: number;
  149. mode?: string;
  150. paraId?: string;
  151. parent_uid?: string;
  152. channels?: string;
  153. }
  154. export interface IArticleResponse {
  155. ok: boolean;
  156. message: string;
  157. data: IArticleDataResponse;
  158. }
  159. export interface IArticleListResponse {
  160. ok: boolean;
  161. message: string;
  162. data: {
  163. rows: IArticleDataResponse[];
  164. count: number;
  165. };
  166. }
  167. export interface IArticleCreateRequest {
  168. title: string;
  169. lang: string;
  170. studio: string;
  171. anthologyId?: string;
  172. parentId?: string;
  173. status?: number;
  174. }
  175. export interface IAnthologyCreateRequest {
  176. title: string;
  177. lang: string;
  178. studio: string;
  179. }
  180. export interface IArticleMapRequest {
  181. id?: string;
  182. collect_id?: string;
  183. collection?: { id: string; title: string };
  184. article_id?: string;
  185. level: number;
  186. title: string;
  187. title_text?: string;
  188. editor?: IUser;
  189. children?: number;
  190. status?: number;
  191. deleted_at?: string | null;
  192. created_at?: string;
  193. updated_at?: string;
  194. }
  195. export interface IArticleMapListResponse {
  196. ok: boolean;
  197. message: string;
  198. data: {
  199. rows: IArticleMapRequest[];
  200. count: number;
  201. };
  202. }
  203. export interface IArticleMapAddRequest {
  204. anthology_id: string;
  205. article_id: string[];
  206. operation: string;
  207. }
  208. export interface IArticleMapUpdateRequest {
  209. data: IArticleMapRequest[];
  210. operation: string;
  211. }
  212. export interface IArticleMapAddResponse {
  213. ok: boolean;
  214. message: string;
  215. data: number;
  216. }
  217. export interface IDeleteResponse {
  218. ok: boolean;
  219. message: string;
  220. data: number;
  221. }
  222. export interface IArticleNavResponse {
  223. ok: boolean;
  224. data: IArticleNavData;
  225. message: string;
  226. }
  227. export interface IArticleNavData {
  228. curr?: IArticleMapRequest;
  229. prev?: IArticleMapRequest;
  230. next?: IArticleMapRequest;
  231. }
  232. export interface IPageNavResponse {
  233. ok: boolean;
  234. data: IPageNavData;
  235. message: string;
  236. }
  237. export interface IPageNavData {
  238. curr: IPageNavItem;
  239. prev: IPageNavItem;
  240. next: IPageNavItem;
  241. }
  242. export interface IPageNavItem {
  243. id: number;
  244. type: string;
  245. volume: number;
  246. page: number;
  247. book: number;
  248. paragraph: number;
  249. wid: number;
  250. pcd_book_id: number;
  251. created_at: string;
  252. updated_at: string;
  253. }
  254. export interface ICSParaNavResponse {
  255. ok: boolean;
  256. data: ICSParaNavData;
  257. message: string;
  258. }
  259. export interface ICSParaNavData {
  260. curr: ICSParaNavItem;
  261. prev?: ICSParaNavItem;
  262. next?: ICSParaNavItem;
  263. end: number;
  264. }
  265. export interface ICSParaNavItem {
  266. book: number;
  267. start: number;
  268. content: string;
  269. }
  270. export interface IArticleFtsListResponse {
  271. ok: boolean;
  272. message: string;
  273. data: {
  274. rows: IArticleDataResponse[];
  275. page: { size: number; current: number; total: number };
  276. };
  277. }
  278. // ─────────────────────────────────────────────
  279. // Query param types
  280. // ─────────────────────────────────────────────
  281. export interface IFetchArticleParams {
  282. /** 频道 ID 列表,后端用 `_` 分隔;anthology 有 default_channel 时可不传 */
  283. channelIds?: string[];
  284. /** 文集 UUID,影响 path / toc 生成和 channel 回退逻辑 */
  285. anthologyId?: string | null;
  286. /** 课程 ID,影响 channel 选择(答案频道 / 用户作业频道) */
  287. courseId?: string;
  288. /** 读写模式,后端默认 read */
  289. mode?: ArticleMode;
  290. /** 渲染格式,后端默认 react */
  291. format?: "react" | "text" | "markdown" | "html";
  292. /** 是否显示原文,后端默认 true */
  293. origin?: boolean;
  294. /** 是否显示段落编号,后端默认 false */
  295. paragraph?: boolean;
  296. }
  297. // ─────────────────────────────────────────────
  298. // Article CRUD
  299. // ─────────────────────────────────────────────
  300. /**
  301. * 将 IFetchArticleParams 序列化为 query string(不含 ? 前缀)
  302. *
  303. * 与后端默认值一致的参数不附加,保持 URL 简洁:
  304. * mode 默认 read
  305. * format 默认 react
  306. * origin 默认 true
  307. * paragraph 默认 false
  308. */
  309. const buildArticleQuery = (params: IFetchArticleParams): string => {
  310. const { channelIds, anthologyId, courseId, mode, format, origin, paragraph } =
  311. params;
  312. const parts: string[] = [];
  313. if (mode && mode !== "read") parts.push(`mode=${mode}`);
  314. if (format && format !== "react") parts.push(`format=${format}`);
  315. if (origin === false) parts.push(`origin=false`);
  316. if (paragraph === true) parts.push(`paragraph=true`);
  317. if (channelIds && channelIds.length > 0)
  318. parts.push(`channel=${channelIds.join("_")}`);
  319. if (anthologyId) parts.push(`anthology=${anthologyId}`);
  320. if (courseId) parts.push(`course=${courseId}`);
  321. return parts.join("&");
  322. };
  323. /**
  324. * 获取单篇文章
  325. *
  326. * 合并了原 fetchArticle / fetchArticleOriginText / fetchParentArticle,
  327. * 通过 params 区分场景:
  328. *
  329. * ```ts
  330. * // 普通阅读(无参)
  331. * fetchArticle(id)
  332. *
  333. * // 取父节点(原 fetchParentArticle)
  334. * fetchArticle(parentId)
  335. *
  336. * // 带文集上下文,返回 path / toc
  337. * fetchArticle(id, { anthologyId })
  338. *
  339. * // 带频道
  340. * fetchArticle(id, { channelIds: ['ch1', 'ch2'] })
  341. *
  342. * // 取原文纯文本(原 fetchArticleOriginText)
  343. * fetchArticle(id, { format: 'text' }) // origin 后端默认 true,无需显式传
  344. *
  345. * // 编辑模式
  346. * fetchArticle(id, { mode: 'edit', anthologyId })
  347. *
  348. * // 课程场景
  349. * fetchArticle(id, { courseId, channelIds })
  350. * ```
  351. *
  352. * GET /v2/article/:articleId?[mode=]&[format=]&[origin=]&[paragraph=]
  353. * &[channel=]&[anthology=]&[course=]
  354. */
  355. export const fetchArticle = (
  356. articleId: string,
  357. params: IFetchArticleParams = {}
  358. ): Promise<IArticleResponse> => {
  359. const query = buildArticleQuery(params);
  360. const url = `/api/v2/article/${articleId}${query ? `?${query}` : ""}`;
  361. return get<IArticleResponse>(url);
  362. };
  363. /**
  364. * 创建文章
  365. *
  366. * POST /v2/article
  367. */
  368. export const createArticle = (
  369. data: IArticleCreateRequest
  370. ): Promise<IArticleResponse> => {
  371. return post<IArticleCreateRequest, IArticleResponse>(`/api/v2/article`, data);
  372. };
  373. /**
  374. * 更新文章
  375. *
  376. * PUT /v2/article/:articleId
  377. */
  378. export const updateArticle = (
  379. articleId: string,
  380. data: IArticleDataRequest
  381. ): Promise<IArticleResponse> => {
  382. console.debug("updateArticle", articleId);
  383. return put<IArticleDataRequest, IArticleResponse>(
  384. `/api/v2/article/${articleId}`,
  385. data
  386. );
  387. };
  388. /**
  389. * 删除文章
  390. *
  391. * DELETE /v2/article/:id
  392. */
  393. export const deleteArticle = (id: string): Promise<IDeleteResponse> => {
  394. return delete_<IDeleteResponse>(`/api/v2/article/${id}`);
  395. };
  396. // ─────────────────────────────────────────────
  397. // Article list(Studio 管理视图)
  398. // ─────────────────────────────────────────────
  399. import type { SortOrder } from "antd/es/table/interface";
  400. /** 排序字段,对应后端 order 参数 */
  401. export type TArticleSortField = "updated_at" | "created_at" | "title";
  402. /** view=template:按 studio_name 获取模板文章 */
  403. interface IListArticleTemplateParams {
  404. view: "template";
  405. studioName: string;
  406. }
  407. /** view=studio 时 anthology 的过滤选项
  408. * - 不传 不按文集过滤
  409. * - 'all' 全部(含已归集和未归集)
  410. * - 'none' 未归入任何我的文集的文章
  411. * - UUID string 指定文集内的文章
  412. */
  413. type TAnthologyFilter = "all" | "none" | string;
  414. /** view=studio:当前用户 studio 下的文章(支持协作、文集过滤、分页搜索) */
  415. interface IListArticleStudioParams {
  416. view: "studio";
  417. /** studio 名称,对应后端 name 参数 */
  418. studioName: string;
  419. /** 'my'(默认)= 自己的文章;'collab' = 协作文章 */
  420. view2?: "my" | "collab";
  421. /** 文集过滤,不传则不过滤 */
  422. anthology?: TAnthologyFilter;
  423. }
  424. /** view=public:公开文章列表 */
  425. interface IListArticlePublicParams {
  426. view: "public";
  427. }
  428. /** 所有 view 共享的分页 / 搜索 / 排序参数 */
  429. interface IListArticleCommonParams {
  430. current?: number;
  431. pageSize?: number;
  432. keyword?: string;
  433. /** subtitle 精确匹配 */
  434. subtitle?: string;
  435. /** 是否同时返回 content 字段,对应后端 content=true */
  436. withContent?: boolean;
  437. /** 排序字段 */
  438. orderBy?: TArticleSortField;
  439. /** 排序方向,对应 antd SortOrder */
  440. sortOrder?: SortOrder;
  441. }
  442. export type IListArticleParams = IListArticleCommonParams &
  443. (
  444. | IListArticleTemplateParams
  445. | IListArticleStudioParams
  446. | IListArticlePublicParams
  447. );
  448. /**
  449. * 获取文章列表
  450. *
  451. * GET /v2/article?view=template|studio|public
  452. * &[studio_name=|name=]
  453. * &[view2=my|collab]
  454. * &[anthology=all|none|<uuid>]
  455. * &[search=]&[subtitle=]&[content=true]
  456. * &limit=&offset=
  457. * &[order=]&[dir=]
  458. */
  459. export const fetchArticleList = (
  460. params: IListArticleParams
  461. ): Promise<IArticleListResponse> => {
  462. const {
  463. current = 1,
  464. pageSize = 20,
  465. keyword,
  466. subtitle,
  467. withContent,
  468. orderBy,
  469. sortOrder,
  470. } = params;
  471. const offset = (current - 1) * pageSize;
  472. const parts: string[] = [`view=${params.view}`];
  473. // view 专属参数
  474. if (params.view === "template") {
  475. parts.push(`studio_name=${params.studioName}`);
  476. } else if (params.view === "studio") {
  477. parts.push(`name=${params.studioName}`);
  478. if (params.view2 && params.view2 !== "my")
  479. parts.push(`view2=${params.view2}`);
  480. if (params.anthology !== undefined)
  481. parts.push(`anthology=${params.anthology}`);
  482. }
  483. // 公共参数
  484. parts.push(`limit=${pageSize}`, `offset=${offset}`);
  485. if (keyword) parts.push(`search=${keyword}`);
  486. if (subtitle) parts.push(`subtitle=${subtitle}`);
  487. if (withContent) parts.push(`content=true`);
  488. // 排序:SortOrder → 后端 order/dir 参数
  489. if (orderBy && sortOrder) {
  490. const dir = sortOrder === "ascend" ? "asc" : "desc";
  491. parts.push(`order=${orderBy}`, `dir=${dir}`);
  492. }
  493. const url = `/api/v2/article?${parts.join("&")}`;
  494. return get<IArticleListResponse>(url);
  495. };
  496. // src/api/Article.ts 新增部分
  497. export const fetchAnthology = (id: string): Promise<IAnthologyResponse> => {
  498. return get<IAnthologyResponse>(`/api/v2/anthology/${id}`);
  499. };
  500. export async function anthologyLoader({ params }: LoaderFunctionArgs) {
  501. const id = params.anthologyId;
  502. if (!id) {
  503. throw new Response("Missing anthologyId", { status: 400 });
  504. }
  505. const res = await fetchAnthology(id);
  506. if (!res.ok) {
  507. throw new Response("anthology not found", { status: 404 });
  508. }
  509. return res.data;
  510. }
  511. export async function articleLoader({ params }: LoaderFunctionArgs) {
  512. const id = params.articleId;
  513. if (!id) {
  514. throw new Response("Missing articleId", { status: 400 });
  515. }
  516. const res = await fetchArticle(id);
  517. if (!res.ok) {
  518. throw new Response("article not found", { status: 404 });
  519. }
  520. return res.data;
  521. }
  522. /**
  523. * 页码导航
  524. *
  525. * pageId 形如 `M-dīghanikāya-2-10`(页码类型_书名_卷号_页码),
  526. * 内部通过 bookName 表把书名 term 反查为 book id 后请求
  527. * GET /api/v2/nav-page/{TYPE}-{booksId}-{volume}-{page}
  528. */
  529. export const fetchPageNav = (pageId: string): Promise<IPageNavResponse> => {
  530. const pageParam = pageId.split("_");
  531. if (pageParam.length < 4) {
  532. return Promise.reject(new Error(`invalid page id: ${pageId}`));
  533. }
  534. const booksId = bookName
  535. .filter((value) => value.term === pageParam[1])
  536. .map((item) => item.id)
  537. .join("_");
  538. const url = `/api/v2/nav-page/${pageParam[0].toUpperCase()}-${booksId}-${
  539. pageParam[2]
  540. }-${pageParam[3]}`;
  541. return get<IPageNavResponse>(url);
  542. };
  543. /**
  544. * cs-para 导航
  545. *
  546. * csParaId 形如 `book_para_page`,例如 `169_3_64`。
  547. * GET /api/v2/nav-cs-para/{csParaId}
  548. */
  549. export const fetchCSParaNav = (
  550. csParaId: string
  551. ): Promise<ICSParaNavResponse> => {
  552. return get<ICSParaNavResponse>(`/api/v2/nav-cs-para/${csParaId}`);
  553. };
  554. export async function csParaLoader({ params }: LoaderFunctionArgs) {
  555. const id = params.id;
  556. if (!id) {
  557. throw new Response("Missing cs-para id", { status: 400 });
  558. }
  559. const res = await fetchCSParaNav(id);
  560. if (!res.ok) {
  561. throw new Response("cs-para not found", { status: 404 });
  562. }
  563. // title 使用 `book_para_page` 中的 page(第三段),例如 `169_3_64` -> `64`
  564. return { title: id.split("_")[2] ?? id };
  565. }