my-kaiqiuwang/src/page/FavPlayersPage.tsx
kyuuseiryuu 9c9b3735cb feat: migrate user fav system from session aud to Logto sub
- Update database schema to rename `UserFav` to `LogtoUserFav` with clearer field names (`logto_uid`, `kaiqiu_uid`).
- Bump `jose` dependency to v6.2.1 for improved JWT verification.
- Configure `@logto/react` to request the correct resource token for API access.
- Implement token verification on the server side using `jose` and `jwtVerify`.
- Update API routes (`/api/fav`) to extract the user ID from the verified JWT `sub` claim instead of the URL `aud` parameter.
- Refactor frontend components (`FavButton`, `FavePlayersPage`) to use `useAuthHeaders` for fetching auth headers instead of manual token claims extraction.
- Clean up unused migration and DAO functions related to the old `aud`-based logic.
2026-03-16 12:45:12 +09:00

168 lines
6.7 KiB
TypeScript

import { Avatar, Button, Card, Divider, Flex, Image, message as AntdMessage, Popconfirm, Radio, Segmented, Spin, Typography } from "antd";
import { useFavPlayerStore } from "../store/useFavPlayerStore";
import { useNavigate } from "react-router";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useLogto, type IdTokenClaims } from "@logto/react";
import { DeleteOutlined, LoginOutlined, SyncOutlined, UploadOutlined } from "@ant-design/icons";
import { useRequest } from "ahooks";
import type { XCXProfile } from "../types";
import useAutoLogin from "../hooks/useAutoLogin";
import { useAuthHeaders } from "../hooks/useAuthHeaders";
enum SortType {
DEFAULT = '注册时间',
SCORE_UP = '积分升序',
SCORE_DOWN = '积分降序',
}
enum ShowType {
LOCAL = '本地收藏',
ACCOUNT = '账号收藏',
}
export function FavePlayersPage() {
const [message, contextHolder] = AntdMessage.useMessage();
const { favMap, unFav } = useFavPlayerStore(state => state);
const [sortType, setSortType] = useState<SortType>(SortType.DEFAULT);
const [showType, setShowType] = useState(ShowType.LOCAL);
const [claims, setClaims] = useState<IdTokenClaims>();
const headers = useAuthHeaders();
const { login } = useAutoLogin();
const localList = Object.values(favMap);
const favListRequest = useRequest<XCXProfile[], [string]>(async (aud: string) => {
const res = await fetch(`/api/fav`, { headers });
const data = await res.json();
return data;
}, { manual: true, cacheKey: 'favListRequest', cacheTime: 3 * 60 * 1000 });
const list = useMemo(() => {
const l = showType === ShowType.LOCAL ? localList : favListRequest.data?.map(e => ({
...e,
name: e.username,
avatar: e.realpic,
})) || [];
switch (sortType) {
case SortType.DEFAULT: return l;
case SortType.SCORE_UP: return l.sort(({ score: a }, { score: b }) => +a - +b);
case SortType.SCORE_DOWN: return l.sort(({ score: b }, { score: a }) => +a - +b);
default: return localList;
}
}, [favMap, sortType, showType, localList, favListRequest]);
const navigate = useNavigate();
const { isAuthenticated, getIdTokenClaims } = useLogto();
useEffect(() => {
if (!isAuthenticated) return;
const id = setTimeout(async () => {
const claims = await getIdTokenClaims();
setClaims(claims);
favListRequest.runAsync(claims?.aud!);
}, 300);
return () => clearTimeout(id);
}, [isAuthenticated, getIdTokenClaims]);
const handleSyncFav = useCallback(async () => {
if (!isAuthenticated) return;
const claims = await getIdTokenClaims()!;
const aud = claims?.aud;
const jobs = list.map(async u => {
await fetch(`/api/fav/${u.uid}`, { method: 'PUT', headers });
});
message.open({ key: 'sync', content: '同步中...', type: 'loading' });
await Promise.all(jobs);
await favListRequest.runAsync(aud!);
message.open({ key: 'sync', content: '已同步', type: 'success' });
}, [isAuthenticated, list]);
const handleClearLocal = useCallback(() => {
list.forEach(e => unFav(e.uid));
}, []);
return (
<div className="app">
{contextHolder}
<Flex vertical gap={48}>
<Typography.Title></Typography.Title>
<Flex justify="center" align="center" gap={12} style={{ display: isAuthenticated ? '' : 'none' }}>
<Radio.Group
optionType="button"
value={showType}
onChange={e => setShowType(e.target.value)}
options={[
{ label: `${ShowType.LOCAL}(${localList.length})`, value: ShowType.LOCAL},
{ label: `${ShowType.ACCOUNT}(${favListRequest.data?.length ?? 0})`, value: ShowType.ACCOUNT}
]}
/>
<Button
onClick={async () => favListRequest.runAsync(claims?.aud!)}
icon={<SyncOutlined spin={favListRequest.loading} />}
/>
</Flex>
<div style={{ position: 'sticky', zIndex: 1 }}>
<Segmented
value={sortType}
onChange={e => setSortType(e)}
options={[
SortType.DEFAULT,
SortType.SCORE_UP,
SortType.SCORE_DOWN,
]}
/>
</div>
<Spin spinning={showType === ShowType.ACCOUNT && favListRequest.loading}>
{!favListRequest.loading && list.length === 0 ? (
<>
<Divider></Divider>
</>
) : (
<>
<Flex wrap gap={16} align="center" justify="center">
{list.map(e => (
<Card
hoverable
size="small"
key={e.uid}
style={{ width: 240 }}
>
<Flex gap={12}>
{e.avatar?.includes('noavatar')
? <Avatar size={64} shape="square">{e.name}</Avatar>
: <Image width={64} height={64} src={e.avatar} style={{ objectFit: 'cover' }} />}
<Flex
vertical
justify="center"
align="center"
style={{ flex: 1 }}
onClick={() => navigate(`/profile/${e.uid}`)}
>
<h2 style={{ margin: 0 }}>{e.name}</h2>
<Typography.Text type="secondary">{e.realname}</Typography.Text>
</Flex>
</Flex>
</Card>
))}
</Flex>
<Flex vertical gap={12} style={{ marginTop: 12 }}>
{ (showType === ShowType.LOCAL && isAuthenticated && list.length > 0) && (
<Button onClick={handleSyncFav} icon={<UploadOutlined />}></Button>
) }
{ (showType === ShowType.LOCAL && !isAuthenticated && list.length > 0) && (
<Button
onClick={() => login(window.location.pathname)}
icon={<LoginOutlined />}
></Button>
) }
{(showType === ShowType.LOCAL && (
<Popconfirm
title='确定要清空本地收藏吗?该操作不可恢复。'
onConfirm={handleClearLocal}
okText='清空'
cancelText='取消'
okButtonProps={{ danger: true, icon: <DeleteOutlined /> }}
>
<Button danger icon={<DeleteOutlined />}></Button>
</Popconfirm>
))}
</Flex>
</>
)}
</Spin>
</Flex>
</div>
);
}