diff --git a/src/app/api/popi/character/list/__tests__/route.test.ts b/src/app/api/popi/character/list/__tests__/route.test.ts index 8ce98b49..437c84fe 100644 --- a/src/app/api/popi/character/list/__tests__/route.test.ts +++ b/src/app/api/popi/character/list/__tests__/route.test.ts @@ -52,6 +52,22 @@ describe("/api/popi/character/list route", () => { }); }); + it.each([ + ["system", "https://popi.example/api_client/anime/character/list?page=1&pageSize=20&type=1&keyword="], + ["public", "https://popi.example/api_client/anime/character/listUserPublicCharacter?page=1&pageSize=20&type=1&keyword="], + ["mine", "https://popi.example/api_client/anime/character/list?page=1&pageSize=20&type=1&keyword=&uploadCharacter=true"], + ["liked", "https://popi.example/api_client/anime/character/listUserLikeCharacter?page=1&pageSize=20&type=1&keyword="], + ])("routes %s character requests to the expected upstream endpoint", async (kind, expectedUrl) => { + const fetchMock = vi.fn().mockResolvedValue( + new Response(JSON.stringify({ status: "0000", message: "ok", data: {} }), { status: 200 }) + ); + global.fetch = fetchMock; + + await GET(createRequest(`http://localhost/api/popi/character/list?kind=${kind}&page=1&pageSize=20`)); + + expect(String(fetchMock.mock.calls[0][0])).toBe(expectedUrl); + }); + it("overrides client-provided type", async () => { const fetchMock = vi.fn().mockResolvedValue( new Response(JSON.stringify({ status: "0000", message: "ok", data: {} }), { status: 200 }) @@ -64,4 +80,15 @@ describe("/api/popi/character/list route", () => { "https://popi.example/api_client/anime/character/list?keyword=a&type=1" ); }); + + it("rejects invalid character list kind", async () => { + const fetchMock = vi.fn(); + global.fetch = fetchMock; + + const response = await GET(createRequest("http://localhost/api/popi/character/list?kind=unknown")); + + await expect(response.json()).resolves.toEqual({ success: false, error: "Invalid character list kind." }); + expect(response.status).toBe(400); + expect(fetchMock).not.toHaveBeenCalled(); + }); }); diff --git a/src/app/api/popi/character/list/route.ts b/src/app/api/popi/character/list/route.ts index b06caf25..e02f6af7 100644 --- a/src/app/api/popi/character/list/route.ts +++ b/src/app/api/popi/character/list/route.ts @@ -3,8 +3,19 @@ import { requireLogin } from "@/app/api/_auth"; import { ApiError } from "@/app/api/_errors"; const POPI_CHARACTER_LIST_PATH = "/api_client/anime/character/list"; +const POPI_PUBLIC_CHARACTER_LIST_PATH = "/api_client/anime/character/listUserPublicCharacter"; +const POPI_LIKED_CHARACTER_LIST_PATH = "/api_client/anime/character/listUserLikeCharacter"; const POPI_CHARACTER_TYPE = "1"; +const CHARACTER_LIST_ENDPOINTS = { + system: POPI_CHARACTER_LIST_PATH, + public: POPI_PUBLIC_CHARACTER_LIST_PATH, + mine: POPI_CHARACTER_LIST_PATH, + liked: POPI_LIKED_CHARACTER_LIST_PATH, +} as const; + +type CharacterListKind = keyof typeof CHARACTER_LIST_ENDPOINTS; + type PopiserverResponse = { status?: string; message?: string; @@ -22,14 +33,24 @@ function getPopiBaseUrl(request: NextRequest): string { export async function GET(request: NextRequest) { try { const login = requireLogin(request); - const upstreamUrl = new URL(getPopiBaseUrl(request) + POPI_CHARACTER_LIST_PATH); + const rawKind = request.nextUrl.searchParams.get("kind") || "system"; + if (!isCharacterListKind(rawKind)) { + throw ApiError.badRequest("Invalid character list kind."); + } + + const upstreamUrl = new URL(getPopiBaseUrl(request) + CHARACTER_LIST_ENDPOINTS[rawKind]); request.nextUrl.searchParams.forEach((value, key) => { - upstreamUrl.searchParams.set(key, value); + if (key !== "kind") { + upstreamUrl.searchParams.set(key, value); + } }); upstreamUrl.searchParams.set("type", POPI_CHARACTER_TYPE); if (!upstreamUrl.searchParams.has("keyword")) { upstreamUrl.searchParams.set("keyword", ""); } + if (rawKind === "mine") { + upstreamUrl.searchParams.set("uploadCharacter", "true"); + } const upstream = await fetch(upstreamUrl, { method: "GET", @@ -63,3 +84,7 @@ export async function GET(request: NextRequest) { ); } } + +function isCharacterListKind(kind: string): kind is CharacterListKind { + return kind in CHARACTER_LIST_ENDPOINTS; +} diff --git a/src/components/CharacterPickerModal.tsx b/src/components/CharacterPickerModal.tsx index a105a30c..65c8af7e 100644 --- a/src/components/CharacterPickerModal.tsx +++ b/src/components/CharacterPickerModal.tsx @@ -2,7 +2,7 @@ import { useCallback, useEffect, useMemo, useState } from "react"; import { createPortal } from "react-dom"; -import { Pagination } from "antd"; +import { Pagination, Tabs } from "antd"; import { authFetch } from "@/utils/authFetch"; import { useI18n } from "@/i18n"; @@ -40,12 +40,22 @@ interface CharacterListResponse { const PAGE_SIZE = 20; +type CharacterTabKey = "system" | "public" | "mine" | "liked"; + +const CHARACTER_TABS: { key: CharacterTabKey; labelKey: "characterPicker.system" | "characterPicker.public" | "characterPicker.mine" | "characterPicker.liked" }[] = [ + { key: "system", labelKey: "characterPicker.system" }, + { key: "public", labelKey: "characterPicker.public" }, + { key: "mine", labelKey: "characterPicker.mine" }, + { key: "liked", labelKey: "characterPicker.liked" }, +]; + function getCharacterTitle(character: CharacterItem): string { return character.title?.trim() || `#${character.id}`; } export function CharacterPickerModal({ onClose, onSelect }: CharacterPickerModalProps) { const { t } = useI18n(); + const [activeTab, setActiveTab] = useState("system"); const [page, setPage] = useState(1); const [items, setItems] = useState([]); const [total, setTotal] = useState(0); @@ -57,6 +67,7 @@ export function CharacterPickerModal({ onClose, onSelect }: CharacterPickerModal setError(null); try { const params = new URLSearchParams({ + kind: activeTab, keyword: "", page: String(page), pageSize: String(PAGE_SIZE), @@ -78,7 +89,7 @@ export function CharacterPickerModal({ onClose, onSelect }: CharacterPickerModal } finally { if (!signal.aborted) setIsLoading(false); } - }, [page, t]); + }, [activeTab, page, t]); useEffect(() => { const controller = new AbortController(); @@ -164,6 +175,11 @@ export function CharacterPickerModal({ onClose, onSelect }: CharacterPickerModal ); }, [error, isLoading, items, loadCharacters, onSelect, t]); + const tabItems = useMemo( + () => CHARACTER_TABS.map((tab) => ({ key: tab.key, label: t(tab.labelKey) })), + [t] + ); + if (typeof document === "undefined") return null; return createPortal( @@ -192,6 +208,18 @@ export function CharacterPickerModal({ onClose, onSelect }: CharacterPickerModal + { + setActiveTab(key as CharacterTabKey); + setPage(1); + setItems([]); + setTotal(0); + }} + /> + {content}
diff --git a/src/i18n/index.tsx b/src/i18n/index.tsx index d685869b..33dedae1 100644 --- a/src/i18n/index.tsx +++ b/src/i18n/index.tsx @@ -390,6 +390,10 @@ const en = { "characterPicker.loadFailed": "Failed to load characters", "characterPicker.retry": "Retry", "characterPicker.character": "Character", + "characterPicker.system": "System", + "characterPicker.public": "Public", + "characterPicker.mine": "Mine", + "characterPicker.liked": "Liked", "imageInput.replace": "Replace", "imageInput.crop": "Crop", "imageInput.draw": "Draw", @@ -1361,6 +1365,10 @@ const zhCN: Dictionary = { "characterPicker.loadFailed": "角色加载失败", "characterPicker.retry": "重试", "characterPicker.character": "角色", + "characterPicker.system": "系统角色", + "characterPicker.public": "公开角色", + "characterPicker.mine": "我的角色", + "characterPicker.liked": "喜欢的角色", "imageInput.replace": "替换", "imageInput.crop": "裁剪", "imageInput.draw": "绘制", @@ -2162,6 +2170,10 @@ const zhTW: Dictionary = { "characterPicker.loadFailed": "角色載入失敗", "characterPicker.retry": "重試", "characterPicker.character": "角色", + "characterPicker.system": "系統角色", + "characterPicker.public": "公開角色", + "characterPicker.mine": "我的角色", + "characterPicker.liked": "喜歡的角色", "imageInput.replace": "替換", "imageInput.crop": "裁剪", "imageInput.draw": "繪製", @@ -2824,6 +2836,10 @@ const ja: Dictionary = { "characterPicker.loadFailed": "キャラクターの読み込みに失敗しました", "characterPicker.retry": "再試行", "characterPicker.character": "キャラクター", + "characterPicker.system": "システム", + "characterPicker.public": "公開", + "characterPicker.mine": "自分のキャラクター", + "characterPicker.liked": "お気に入り", "imageInput.replace": "置換", "imageInput.crop": "切り抜き", "imageInput.draw": "描画",