diff --git a/CHANGELOG.md b/CHANGELOG.md index fa79bd28..60de3e51 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,10 @@ +## 2025-02-22 + +- 添加了关卡筛选器的联想输入功能 [@guansss](https://github.com/guansss) +- 添加了用户个人页面 [@guansss](https://github.com/guansss) +- 修复了在作业详情里无法正常使用鼠标回退键的问题 [@guansss](https://github.com/guansss) +- 优化了作业列表和作业详情界面 [@guansss](https://github.com/guansss) + ## 2025-02-17 - 修复了编辑作业时无法修改干员名称的问题 [@Gemini2035](https://github.com/Gemini2035) diff --git a/src/apis/level.ts b/src/apis/level.ts index bedc39fb..3f8b1ac6 100644 --- a/src/apis/level.ts +++ b/src/apis/level.ts @@ -28,7 +28,6 @@ export const useLevels = ({ suspense }: { suspense?: boolean } = {}) => { } if (stageIds.has(level.stageId)) { - console.warn('Duplicate level removed:', level.stageId, level.name) return false } diff --git a/src/apis/operation-set.ts b/src/apis/operation-set.ts index 59ffaa83..c62d8a31 100644 --- a/src/apis/operation-set.ts +++ b/src/apis/operation-set.ts @@ -1,4 +1,3 @@ -import { useAtomValue } from 'jotai' import { noop } from 'lodash-es' import { CopilotSetPageRes, @@ -9,7 +8,6 @@ import { import useSWR from 'swr' import useSWRInfinite from 'swr/infinite' -import { authAtom } from 'store/auth' import { OperationSetApi } from 'utils/maa-copilot-client' import { useSWRRefresh } from 'utils/swr' @@ -19,7 +17,7 @@ export type OrderBy = 'views' | 'hot' | 'id' export interface UseOperationSetsParams { keyword?: string - byMyself?: boolean + creatorId?: string disabled?: boolean suspense?: boolean @@ -27,12 +25,10 @@ export interface UseOperationSetsParams { export function useOperationSets({ keyword, - byMyself, + creatorId, disabled, suspense, }: UseOperationSetsParams) { - const { userId } = useAtomValue(authAtom) - const { data: pages, error, @@ -53,14 +49,13 @@ export function useOperationSets({ limit: 50, page: pageIndex + 1, keyword, - creatorId: byMyself ? userId : undefined, + creatorId, } satisfies CopilotSetQuery, - byMyself, ] }, - async ([, req, byMyself]) => { + async ([, req]) => { const res = await new OperationSetApi({ - sendToken: byMyself ? 'always' : 'optional', // 如果有 token 会用来获取自己的作业集 + sendToken: 'optional', // 如果有 token 即可获取到私有的作业集 requireData: true, }).querySets({ copilotSetQuery: req }) return res.data @@ -72,10 +67,12 @@ export function useOperationSets({ ) const isReachingEnd = !!pages?.some((page) => !page.hasNext) + const total = pages?.[0]?.total ?? 0 const operationSets = pages?.map((page) => page.data).flat() return { operationSets, + total, error, setSize, isValidating, @@ -130,6 +127,7 @@ export function useOperationSetSearch({ if (id) { return { operationSets: [operationSet], + total: operationSet ? 1 : 0, isReachingEnd: true, setSize: noop, diff --git a/src/apis/operation.ts b/src/apis/operation.ts index d2378b49..de3c7143 100644 --- a/src/apis/operation.ts +++ b/src/apis/operation.ts @@ -19,7 +19,7 @@ export interface UseOperationsParams { levelKeyword?: string operator?: string operationIds?: number[] - byMyself?: boolean + uploaderId?: string disabled?: boolean suspense?: boolean @@ -34,7 +34,7 @@ export function useOperations({ levelKeyword, operator, operationIds, - byMyself, + uploaderId, disabled, suspense, revalidateFirstPage, @@ -84,7 +84,7 @@ export function useOperations({ orderBy, desc: descending, copilotIds: operationIds, - uploaderId: byMyself ? 'me' : undefined, + uploaderId, } satisfies QueriesCopilotRequest, ] }, @@ -92,12 +92,11 @@ export function useOperations({ // 如果指定了 id 列表,但是列表为空,就直接返回空数据。不然要是直接传空列表,就相当于没有这个参数, // 会导致后端返回所有数据 if (req.copilotIds?.length === 0) { - return { data: [], hasNext: false } + return { data: [], hasNext: false, total: 0 } } const res = await new OperationApi({ - sendToken: - 'uploaderId' in req && req.uploaderId === 'me' ? 'always' : 'never', + sendToken: 'optional', requireData: true, }).queriesCopilot(req) @@ -122,6 +121,7 @@ export function useOperations({ ) const isReachingEnd = !!pages?.some((page) => !page.hasNext) + const total = pages?.[0]?.total ?? 0 const _operations = pages?.map((page) => page.data).flat() ?? [] @@ -135,6 +135,7 @@ export function useOperations({ return { error, operations, + total, setSize, isValidating, isReachingEnd, diff --git a/src/apis/user.ts b/src/apis/user.ts new file mode 100644 index 00000000..958beecb --- /dev/null +++ b/src/apis/user.ts @@ -0,0 +1,32 @@ +import useSWR from 'swr' + +import { NotFoundError } from '../utils/error' +import { UserApi } from '../utils/maa-copilot-client' + +export function useUserInfo({ + userId, + suspense, +}: { + userId?: string + suspense?: boolean +}) { + return useSWR( + userId ? ['user', userId] : null, + async ([, userId]) => { + const res = await new UserApi({ + sendToken: 'never', + requireData: true, + }).getUserInfo({ + userId, + }) + + // FIXME: 严谨一点!!! + if (res.data.userName === '未知用户:(') { + throw new NotFoundError() + } + + return res.data + }, + { suspense }, + ) +} diff --git a/src/components/AccountManager.tsx b/src/components/AccountManager.tsx index f09edaf8..1da39bb1 100644 --- a/src/components/AccountManager.tsx +++ b/src/components/AccountManager.tsx @@ -73,14 +73,11 @@ const AccountMenu: FC = () => { /> )} - {isSM && ( - - )} - + void +} + +export const LevelSelect: FC = ({ + className, + value, + onChange, +}) => { + const { data } = useLevels() + const levels = useMemo( + () => + data + // to simplify the list, we only show levels in normal mode + .filter((level) => !isHardMode(level.stageId)) + .sort((a, b) => a.levelId.localeCompare(b.levelId)), + [data], + ) + + const fuse = useMemo( + () => + new Fuse(levels, { + keys: ['name', 'catTwo', 'catThree', 'stageId'], + threshold: 0.3, + }), + [levels], + ) + + // value 可以由用户输入,所以可以是任何值,只有用 stageId 才能匹配到唯一的关卡 + const selectedLevel = useMemo( + () => levels.find((el) => el.stageId === value) ?? null, + [levels, value], + ) + + const search = (query: string) => { + // 如果 query 和当前关卡完全匹配(也就是唯一对应),就显示同类关卡 + if (selectedLevel && selectedLevel.stageId === query) { + let similarLevels: Level[] + let headerName: string + + if (selectedLevel.catOne === '剿灭作战') { + headerName = selectedLevel.catOne + similarLevels = levels.filter( + (el) => el.catOne === selectedLevel.catOne, + ) + } else if ( + selectedLevel.stageId.includes('rune') || + selectedLevel.stageId.includes('crisis') + ) { + // 危机合约分类非常混乱,直接全塞到一起 + headerName = '危机合约' + similarLevels = levels.filter( + (el) => el.stageId.includes('rune') || el.stageId.includes('crisis'), + ) + } else if (selectedLevel.catTwo) { + headerName = selectedLevel.catTwo + similarLevels = levels.filter( + (el) => el.catTwo === selectedLevel.catTwo, + ) + } else { + // catTwo 为空的时候用 levelId 来分类 + headerName = '相关关卡' + const levelIdPrefix = selectedLevel.levelId + .split('/') + .slice(0, -1) + .join('/') + similarLevels = levelIdPrefix + ? levels.filter((el) => el.levelId.startsWith(levelIdPrefix)) + : [] + } + + if (similarLevels.length > 1) { + const header = createCustomLevel(headerName) + header.stageId = 'header' + return [header, ...similarLevels] + } + } + + return query ? fuse.search(query).map((el) => el.item) : levels + } + + return ( + + updateQueryOnSelect + items={levels} + itemListPredicate={search} + onReset={() => onChange('')} + className={clsx(className, selectedLevel && '[&_input]:italic')} + itemRenderer={(item, { handleClick, handleFocus, modifiers }) => + item.stageId === 'header' ? ( + +
{item.name}
+ +
+ ) : ( + + ) + } + selectedItem={selectedLevel} + onItemSelect={(level) => onChange(level.stageId)} + inputValueRenderer={(item) => item.stageId} + noResults={} + inputProps={{ + placeholder: '关卡名、关卡类型、关卡编号', + leftIcon: 'area-of-interest', + large: true, + size: 64, + onBlur: (e) => { + // 失焦时直接把 query 提交上去,用于处理关卡未匹配的情况 + if (value !== e.target.value) { + onChange(e.target.value) + } + }, + }} + /> + ) +} diff --git a/src/components/OperationCard.tsx b/src/components/OperationCard.tsx index 7bee455a..0b953bbf 100644 --- a/src/components/OperationCard.tsx +++ b/src/components/OperationCard.tsx @@ -4,7 +4,6 @@ import { Tooltip2 } from '@blueprintjs/popover2' import clsx from 'clsx' import { copyShortCode, handleDownloadJSON } from 'services/operation' -import { ReLink } from 'components/ReLink' import { RelativeTime } from 'components/RelativeTime' import { AddToOperationSetButton } from 'components/operation-set/AddToOperationSet' import { OperationRating } from 'components/viewer/OperationRating' @@ -13,6 +12,8 @@ import { OpDifficulty, Operation } from 'models/operation' import { useLevels } from '../apis/level' import { createCustomLevel, findLevelByStageName } from '../models/level' import { Paragraphs } from './Paragraphs' +import { ReLinkDiv } from './ReLinkDiv' +import { UserName } from './UserName' import { EDifficulty } from './entity/EDifficulty' import { EDifficultyLevel, NeoELevel } from './entity/ELevel' @@ -21,9 +22,9 @@ export const NeoOperationCard = ({ operation }: { operation: Operation }) => { return ( -
{
- -
- - {operation.uploader} -
-
+ + + {operation.uploader} +
-
+
@@ -120,7 +119,7 @@ export const OperationCard = ({ operation }: { operation: Operation }) => { elevation={Elevation.TWO} className="relative mb-4 sm:mb-2 last:mb-0" > - +
{/* title */}
@@ -170,12 +169,12 @@ export const OperationCard = ({ operation }: { operation: Operation }) => { />
- -
- - {operation.uploader} -
-
+
+ + + {operation.uploader} + +
@@ -192,7 +191,7 @@ export const OperationCard = ({ operation }: { operation: Operation }) => {
-
+ = - withSuspensable( - (props) => { - const neoLayout = useAtomValue(neoLayoutAtom) +interface OperationListProps extends UseOperationsParams { + onUpdate?: (params: { total: number }) => void +} - const { operations, setSize, isValidating, isReachingEnd } = - useOperations({ - ...props, - suspense: true, - }) +export const OperationList: ComponentType = withSuspensable( + ({ onUpdate, ...params }) => { + const neoLayout = useAtomValue(neoLayoutAtom) - // make TS happy: we got Suspense out there - if (!operations) throw new Error('unreachable') + const { operations, total, setSize, isValidating, isReachingEnd } = + useOperations({ + ...params, + suspense: true, + }) - const items: ReactNode = neoLayout ? ( -
- {operations.map((operation) => ( - - ))} -
- ) : ( - operations.map((operation) => ( - - )) - ) + // make TS happy: we got Suspense out there + if (!operations) throw new Error('unreachable') - return ( - <> - {items} + useEffect(() => { + onUpdate?.({ total }) + }, [total, onUpdate]) - {isReachingEnd && operations.length === 0 && ( - - )} + const items: ReactNode = neoLayout ? ( +
+ {operations.map((operation) => ( + + ))} +
+ ) : ( + operations.map((operation) => ( + + )) + ) - {isReachingEnd && operations.length !== 0 && ( -
- 已经到底了哦 (゚▽゚)/ -
- )} + return ( + <> + {items} - {!isReachingEnd && ( - + + + + +
+ {listMode === 'operation' && ( + setOperationCount(total)} + /> + )} + {listMode === 'operationSet' && ( + setOperationSetCount(total)} + /> + )} +
+ +
+
+ + {userInfo?.userName} + +
+
+ + + + ) +} +_ProfilePage.displayName = 'ProfilePage' + +export const ProfilePage = withSuspensable(_ProfilePage, { + errorFallback: ({ error }) => { + if (error instanceof NotFoundError) { + return + } + return undefined + }, +}) diff --git a/src/utils/error.ts b/src/utils/error.ts index 3ef5a280..30146de0 100644 --- a/src/utils/error.ts +++ b/src/utils/error.ts @@ -27,6 +27,10 @@ export class InvalidTokenError extends Error { message = this.message || '登录失效,请重新登录' } +export class NotFoundError extends Error { + message = this.message || '资源不存在' +} + export class NetworkError extends Error { message = this.message || '网络错误' }