- 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.
168 lines
6.7 KiB
TypeScript
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>
|
|
);
|
|
} |