Browse Source

角色更新

feature/handle
TianYun 1 month ago
parent
commit
f080f6a80e
  1. 27
      src/app/api/popi/character/list/__tests__/route.test.ts
  2. 29
      src/app/api/popi/character/list/route.ts
  3. 32
      src/components/CharacterPickerModal.tsx
  4. 16
      src/i18n/index.tsx

27
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();
});
});

29
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<T> = {
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;
}

32
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<CharacterTabKey>("system");
const [page, setPage] = useState(1);
const [items, setItems] = useState<CharacterItem[]>([]);
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
</button>
</div>
<Tabs
activeKey={activeTab}
items={tabItems}
size="small"
onChange={(key) => {
setActiveTab(key as CharacterTabKey);
setPage(1);
setItems([]);
setTotal(0);
}}
/>
{content}
<div className="mt-4 flex justify-end">

16
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": "描画",

Loading…
Cancel
Save