| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574 |
- //src/api/article.ts
- import type { IStudio, IStudioApiResponse, IUser, TRole } from "./Auth";
- import type { IChannel } from "./channel";
- import { delete_, get, post, put } from "../request";
- import type { ITocPathNode } from "./pali-text";
- import type { LoaderFunctionArgs } from "react-router";
- import type { ListNodeData } from "../components/article/components/EditableTree";
- export type TContentType = "text" | "markdown" | "html" | "json";
- export type ArticleMode = "read" | "edit" | "wbw" | "auto";
- export type ArticleType =
- | "anthology"
- | "article"
- | "series"
- | "chapter"
- | "para"
- | "cs-para"
- | "sent"
- | "sim"
- | "page"
- | "textbook"
- | "sent-original"
- | "sent-commentary"
- | "sent-nissaya"
- | "sent-translation"
- | "term"
- | "task";
- /**
- * 每种article type 对应的路由参数
- * article/id?anthology=id&channel=id1,id2&mode=ArticleMode
- * chapter/book-para?channel=id1,id2&mode=ArticleMode
- * para/book?par=para1,para2&channel=id1,id2&mode=ArticleMode
- * cs-para/book-para?channel=id1,id2&mode=ArticleMode
- * sent/id?channel=id1,id2&mode=ArticleMode
- * sim/id?channel=id1,id2&mode=ArticleMode
- * textbook/articleId?course=id&mode=ArticleMode
- * exercise/articleId?course=id&exercise=id&username=name&mode=ArticleMode
- * exercise-list/articleId?course=id&exercise=id&mode=ArticleMode
- * sent-original/id
- */
- export interface IAnthologyData {
- id: string;
- title: string;
- subTitle: string;
- summary: string;
- articles: ListNodeData[];
- studio: IStudio;
- created_at: string;
- updated_at: string;
- }
- export interface IArticleListApiResponse {
- article: string;
- title: string;
- level: string;
- children: number;
- }
- export interface IAnthologyDataRequest {
- title: string;
- subtitle: string;
- summary?: string;
- article_list?: IArticleListApiResponse[];
- lang: string;
- status: number;
- default_channel?: string | null;
- }
- export interface IAnthologyDataResponse {
- uid: string;
- title: string;
- subtitle: string;
- summary: string;
- article_list: IArticleListApiResponse[];
- studio: IStudio;
- default_channel?: IChannel;
- lang: string;
- status: number;
- childrenNumber: number;
- created_at: string;
- updated_at: string;
- }
- export interface IAnthologyResponse {
- ok: boolean;
- message: string;
- data: IAnthologyDataResponse;
- }
- export interface IAnthologyListResponse {
- ok: boolean;
- message: string;
- data: {
- rows: IAnthologyDataResponse[];
- count: number;
- };
- }
- export interface IAnthologyStudioListApiResponse {
- ok: boolean;
- message: string;
- data: {
- count: number;
- rows: IAnthologyStudioListDataApiResponse[];
- };
- }
- export interface IAnthologyStudioListDataApiResponse {
- count: number;
- studio: IStudioApiResponse;
- }
- export interface IArticleDataRequest {
- uid: string;
- title: string;
- subtitle: string;
- summary?: string | null;
- content?: string;
- content_type?: string;
- status: number;
- lang: string;
- to_tpl?: boolean;
- anthology_id?: string;
- }
- export interface IChapterToc {
- key?: string;
- book: number;
- paragraph: number;
- level: number;
- pali_title: string /**巴利文标题 */;
- title?: string /**译文文标题 */;
- progress?: number[];
- }
- export interface IArticleDataResponse {
- uid: string;
- title: string;
- title_text?: string;
- subtitle: string;
- summary: string | null;
- _summary?: string;
- content?: string;
- content_type?: TContentType;
- toc?: IChapterToc[];
- html?: string;
- path?: ITocPathNode[];
- status: number;
- lang: string;
- anthology_count?: number;
- anthology_first?: { uid: string; title: string };
- role?: TRole;
- studio?: IStudio;
- editor?: IUser;
- created_at: string;
- updated_at: string;
- from?: number;
- to?: number;
- mode?: string;
- paraId?: string;
- parent_uid?: string;
- channels?: string;
- }
- export interface IArticleResponse {
- ok: boolean;
- message: string;
- data: IArticleDataResponse;
- }
- export interface IArticleListResponse {
- ok: boolean;
- message: string;
- data: {
- rows: IArticleDataResponse[];
- count: number;
- };
- }
- export interface IArticleCreateRequest {
- title: string;
- lang: string;
- studio: string;
- anthologyId?: string;
- parentId?: string;
- status?: number;
- }
- export interface IAnthologyCreateRequest {
- title: string;
- lang: string;
- studio: string;
- }
- export interface IArticleMapRequest {
- id?: string;
- collect_id?: string;
- collection?: { id: string; title: string };
- article_id?: string;
- level: number;
- title: string;
- title_text?: string;
- editor?: IUser;
- children?: number;
- status?: number;
- deleted_at?: string | null;
- created_at?: string;
- updated_at?: string;
- }
- export interface IArticleMapListResponse {
- ok: boolean;
- message: string;
- data: {
- rows: IArticleMapRequest[];
- count: number;
- };
- }
- export interface IArticleMapAddRequest {
- anthology_id: string;
- article_id: string[];
- operation: string;
- }
- export interface IArticleMapUpdateRequest {
- data: IArticleMapRequest[];
- operation: string;
- }
- export interface IArticleMapAddResponse {
- ok: boolean;
- message: string;
- data: number;
- }
- export interface IDeleteResponse {
- ok: boolean;
- message: string;
- data: number;
- }
- export interface IArticleNavResponse {
- ok: boolean;
- data: IArticleNavData;
- message: string;
- }
- export interface IArticleNavData {
- curr?: IArticleMapRequest;
- prev?: IArticleMapRequest;
- next?: IArticleMapRequest;
- }
- export interface IPageNavResponse {
- ok: boolean;
- data: IPageNavData;
- message: string;
- }
- export interface IPageNavData {
- curr: IPageNavItem;
- prev: IPageNavItem;
- next: IPageNavItem;
- }
- export interface IPageNavItem {
- id: number;
- type: string;
- volume: number;
- page: number;
- book: number;
- paragraph: number;
- wid: number;
- pcd_book_id: number;
- created_at: string;
- updated_at: string;
- }
- export interface ICSParaNavResponse {
- ok: boolean;
- data: ICSParaNavData;
- message: string;
- }
- export interface ICSParaNavData {
- curr: ICSParaNavItem;
- prev?: ICSParaNavItem;
- next?: ICSParaNavItem;
- end: number;
- }
- export interface ICSParaNavItem {
- book: number;
- start: number;
- content: string;
- }
- export interface IArticleFtsListResponse {
- ok: boolean;
- message: string;
- data: {
- rows: IArticleDataResponse[];
- page: { size: number; current: number; total: number };
- };
- }
- // ─────────────────────────────────────────────
- // Query param types
- // ─────────────────────────────────────────────
- export interface IFetchArticleParams {
- /** 频道 ID 列表,后端用 `_` 分隔;anthology 有 default_channel 时可不传 */
- channelIds?: string[];
- /** 文集 UUID,影响 path / toc 生成和 channel 回退逻辑 */
- anthologyId?: string | null;
- /** 课程 ID,影响 channel 选择(答案频道 / 用户作业频道) */
- courseId?: string;
- /** 读写模式,后端默认 read */
- mode?: ArticleMode;
- /** 渲染格式,后端默认 react */
- format?: "react" | "text" | "markdown" | "html";
- /** 是否显示原文,后端默认 true */
- origin?: boolean;
- /** 是否显示段落编号,后端默认 false */
- paragraph?: boolean;
- }
- // ─────────────────────────────────────────────
- // Article CRUD
- // ─────────────────────────────────────────────
- /**
- * 将 IFetchArticleParams 序列化为 query string(不含 ? 前缀)
- *
- * 与后端默认值一致的参数不附加,保持 URL 简洁:
- * mode 默认 read
- * format 默认 react
- * origin 默认 true
- * paragraph 默认 false
- */
- const buildArticleQuery = (params: IFetchArticleParams): string => {
- const { channelIds, anthologyId, courseId, mode, format, origin, paragraph } =
- params;
- const parts: string[] = [];
- if (mode && mode !== "read") parts.push(`mode=${mode}`);
- if (format && format !== "react") parts.push(`format=${format}`);
- if (origin === false) parts.push(`origin=false`);
- if (paragraph === true) parts.push(`paragraph=true`);
- if (channelIds && channelIds.length > 0)
- parts.push(`channel=${channelIds.join("_")}`);
- if (anthologyId) parts.push(`anthology=${anthologyId}`);
- if (courseId) parts.push(`course=${courseId}`);
- return parts.join("&");
- };
- /**
- * 获取单篇文章
- *
- * 合并了原 fetchArticle / fetchArticleOriginText / fetchParentArticle,
- * 通过 params 区分场景:
- *
- * ```ts
- * // 普通阅读(无参)
- * fetchArticle(id)
- *
- * // 取父节点(原 fetchParentArticle)
- * fetchArticle(parentId)
- *
- * // 带文集上下文,返回 path / toc
- * fetchArticle(id, { anthologyId })
- *
- * // 带频道
- * fetchArticle(id, { channelIds: ['ch1', 'ch2'] })
- *
- * // 取原文纯文本(原 fetchArticleOriginText)
- * fetchArticle(id, { format: 'text' }) // origin 后端默认 true,无需显式传
- *
- * // 编辑模式
- * fetchArticle(id, { mode: 'edit', anthologyId })
- *
- * // 课程场景
- * fetchArticle(id, { courseId, channelIds })
- * ```
- *
- * GET /v2/article/:articleId?[mode=]&[format=]&[origin=]&[paragraph=]
- * &[channel=]&[anthology=]&[course=]
- */
- export const fetchArticle = (
- articleId: string,
- params: IFetchArticleParams = {}
- ): Promise<IArticleResponse> => {
- const query = buildArticleQuery(params);
- const url = `/api/v2/article/${articleId}${query ? `?${query}` : ""}`;
- return get<IArticleResponse>(url);
- };
- /**
- * 创建文章
- *
- * POST /v2/article
- */
- export const createArticle = (
- data: IArticleCreateRequest
- ): Promise<IArticleResponse> => {
- return post<IArticleCreateRequest, IArticleResponse>(`/api/v2/article`, data);
- };
- /**
- * 更新文章
- *
- * PUT /v2/article/:articleId
- */
- export const updateArticle = (
- articleId: string,
- data: IArticleDataRequest
- ): Promise<IArticleResponse> => {
- return put<IArticleDataRequest, IArticleResponse>(
- `/api/v2/article/${articleId}`,
- data
- );
- };
- /**
- * 删除文章
- *
- * DELETE /v2/article/:id
- */
- export const deleteArticle = (id: string): Promise<IDeleteResponse> => {
- return delete_<IDeleteResponse>(`/api/v2/article/${id}`);
- };
- // ─────────────────────────────────────────────
- // Article list(Studio 管理视图)
- // ─────────────────────────────────────────────
- import type { SortOrder } from "antd/es/table/interface";
- /** 排序字段,对应后端 order 参数 */
- export type TArticleSortField = "updated_at" | "created_at" | "title";
- /** view=template:按 studio_name 获取模板文章 */
- interface IListArticleTemplateParams {
- view: "template";
- studioName: string;
- }
- /** view=studio 时 anthology 的过滤选项
- * - 不传 不按文集过滤
- * - 'all' 全部(含已归集和未归集)
- * - 'none' 未归入任何我的文集的文章
- * - UUID string 指定文集内的文章
- */
- type TAnthologyFilter = "all" | "none" | string;
- /** view=studio:当前用户 studio 下的文章(支持协作、文集过滤、分页搜索) */
- interface IListArticleStudioParams {
- view: "studio";
- /** studio 名称,对应后端 name 参数 */
- studioName: string;
- /** 'my'(默认)= 自己的文章;'collab' = 协作文章 */
- view2?: "my" | "collab";
- /** 文集过滤,不传则不过滤 */
- anthology?: TAnthologyFilter;
- }
- /** view=public:公开文章列表 */
- interface IListArticlePublicParams {
- view: "public";
- }
- /** 所有 view 共享的分页 / 搜索 / 排序参数 */
- interface IListArticleCommonParams {
- current?: number;
- pageSize?: number;
- keyword?: string;
- /** subtitle 精确匹配 */
- subtitle?: string;
- /** 是否同时返回 content 字段,对应后端 content=true */
- withContent?: boolean;
- /** 排序字段 */
- orderBy?: TArticleSortField;
- /** 排序方向,对应 antd SortOrder */
- sortOrder?: SortOrder;
- }
- export type IListArticleParams = IListArticleCommonParams &
- (
- | IListArticleTemplateParams
- | IListArticleStudioParams
- | IListArticlePublicParams
- );
- /**
- * 获取文章列表
- *
- * GET /v2/article?view=template|studio|public
- * &[studio_name=|name=]
- * &[view2=my|collab]
- * &[anthology=all|none|<uuid>]
- * &[search=]&[subtitle=]&[content=true]
- * &limit=&offset=
- * &[order=]&[dir=]
- */
- export const fetchArticleList = (
- params: IListArticleParams
- ): Promise<IArticleListResponse> => {
- const {
- current = 1,
- pageSize = 20,
- keyword,
- subtitle,
- withContent,
- orderBy,
- sortOrder,
- } = params;
- const offset = (current - 1) * pageSize;
- const parts: string[] = [`view=${params.view}`];
- // view 专属参数
- if (params.view === "template") {
- parts.push(`studio_name=${params.studioName}`);
- } else if (params.view === "studio") {
- parts.push(`name=${params.studioName}`);
- if (params.view2 && params.view2 !== "my")
- parts.push(`view2=${params.view2}`);
- if (params.anthology !== undefined)
- parts.push(`anthology=${params.anthology}`);
- }
- // 公共参数
- parts.push(`limit=${pageSize}`, `offset=${offset}`);
- if (keyword) parts.push(`search=${keyword}`);
- if (subtitle) parts.push(`subtitle=${subtitle}`);
- if (withContent) parts.push(`content=true`);
- // 排序:SortOrder → 后端 order/dir 参数
- if (orderBy && sortOrder) {
- const dir = sortOrder === "ascend" ? "asc" : "desc";
- parts.push(`order=${orderBy}`, `dir=${dir}`);
- }
- const url = `/v2/article?${parts.join("&")}`;
- return get<IArticleListResponse>(url);
- };
- // src/api/Article.ts 新增部分
- export const fetchAnthology = (id: string): Promise<IAnthologyResponse> => {
- return get<IAnthologyResponse>(`/api/v2/anthology/${id}`);
- };
- export async function anthologyLoader({ params }: LoaderFunctionArgs) {
- const id = params.anthologyId;
- if (!id) {
- throw new Response("Missing anthologyId", { status: 400 });
- }
- const res = await fetchAnthology(id);
- if (!res.ok) {
- throw new Response("anthology not found", { status: 404 });
- }
- return res.data;
- }
- export async function articleLoader({ params }: LoaderFunctionArgs) {
- const id = params.articleId;
- if (!id) {
- throw new Response("Missing articleId", { status: 400 });
- }
- const res = await fetchArticle(id);
- if (!res.ok) {
- throw new Response("article not found", { status: 404 });
- }
- return res.data;
- }
|