article.ts 14 KB

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