From ba71a20d550010b933e17c9c14953332d6c40462 Mon Sep 17 00:00:00 2001 From: Guan <821143943@qq.com> Date: Thu, 20 Feb 2025 06:48:02 +0800 Subject: [PATCH 01/15] feat: enhance level filter --- src/apis/level.ts | 1 - src/components/LevelSelect.tsx | 118 +++++++++++++++++++++++++++++++++ src/components/Operations.tsx | 19 ++---- src/components/Suggest.tsx | 31 ++++++++- 4 files changed, 154 insertions(+), 15 deletions(-) create mode 100644 src/components/LevelSelect.tsx 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/components/LevelSelect.tsx b/src/components/LevelSelect.tsx new file mode 100644 index 00000000..8d5152e5 --- /dev/null +++ b/src/components/LevelSelect.tsx @@ -0,0 +1,118 @@ +import { MenuDivider, MenuItem } from '@blueprintjs/core' + +import clsx from 'clsx' +import Fuse from 'fuse.js' +import { FC, Fragment, useMemo } from 'react' + +import { useLevels } from '../apis/level' +import { createCustomLevel, isHardMode } from '../models/level' +import { Level } from '../models/operation' +import { Suggest } from './Suggest' + +interface LevelSelectProps { + className?: string + value: string + onChange: (level: string) => 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], + ) + const fuseSimilar = useMemo( + () => + new Fuse(levels, { + keys: ['levelId'], + threshold: 0, + }), + [levels], + ) + + // value 可以由用户输入,所以可以是任何值,只有用 stageId 才能匹配到唯一的关卡 + const selectedLevel = useMemo( + () => levels.find((el) => el.stageId === value) ?? null, + [levels, value], + ) + + return ( + + updateQueryOnSelect + items={levels} + itemListPredicate={(query) => { + // 如果 query 和当前关卡完全匹配(也就是唯一对应),就显示同类关卡 + if (selectedLevel && selectedLevel.stageId === query) { + const levelIdPrefix = selectedLevel.levelId + .split('/') + .slice(0, -1) + .join('/') + const similarLevels = fuseSimilar + .search(levelIdPrefix) + .map((el) => el.item) + + if (similarLevels.length > 0) { + const header = createCustomLevel('header') + // catTwo 一般是活动名,有时候是空的 + header.catTwo = selectedLevel.catTwo || '相关关卡' + return [header, ...similarLevels] + } + } + + return query ? fuse.search(query).map((el) => el.item) : levels + }} + onReset={() => onChange('')} + className={clsx(className, selectedLevel && '[&_input]:italic')} + itemRenderer={(item, { handleClick, handleFocus, modifiers }) => + item.name === 'header' ? ( + +
{item.catTwo}
+ +
+ ) : ( + + ) + } + 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/Operations.tsx b/src/components/Operations.tsx index 04baced3..d28683aa 100644 --- a/src/components/Operations.tsx +++ b/src/components/Operations.tsx @@ -17,6 +17,7 @@ import { OperationSetList } from 'components/OperationSetList' import { neoLayoutAtom } from 'store/pref' import { authAtom } from '../store/auth' +import { LevelSelect } from './LevelSelect' import { OperatorFilter } from './OperatorFilter' import { withSuspensable } from './Suspensable' @@ -117,21 +118,15 @@ export const Operations: ComponentType = withSuspensable(() => { } onBlur={() => debouncedSetQueryParams.flush()} /> - - debouncedSetQueryParams((old) => ({ + + setQueryParams((old) => ({ ...old, - levelKeyword: e.target.value.trim(), + levelKeyword: level, })) } - onBlur={() => debouncedSetQueryParams.flush()} /> extends Suggest2Props { debounce?: number // defaults to 100(ms), set to 0 to disable + updateQueryOnSelect?: boolean fieldState?: ControllerFieldState onReset?: () => void } export const Suggest = ({ debounce = 100, + updateQueryOnSelect, fieldState, onReset, items, itemListPredicate, + selectedItem, + inputValueRenderer, inputProps, ...suggest2Props }: SuggestProps) => { + // 禁用掉 focus 自动选中输入框文字的功能 + // https://github.com/palantir/blueprint/blob/b41f668461e63e2c20caf54a3248181fe01161c4/packages/select/src/components/suggest/suggest2.tsx#L229 + const ref = useRef>(null) + if (ref.current && ref.current['selectText'] !== noop) { + ref.current['selectText'] = noop + } + const [query, setQuery] = useState('') const [debouncedQuery, setDebouncedQuery] = useState('') @@ -46,11 +58,20 @@ export const Suggest = ({ } }, [fieldState?.isTouched]) + useEffect(() => { + if (updateQueryOnSelect && selectedItem) { + setQuery(inputValueRenderer(selectedItem)) + } + }, [updateQueryOnSelect, selectedItem, inputValueRenderer]) + return ( + ref={ref} items={filteredItems} query={query} onQueryChange={setQuery} + selectedItem={selectedItem} + inputValueRenderer={inputValueRenderer} inputProps={{ onKeyDown: (event) => { // prevent form submission @@ -60,7 +81,13 @@ export const Suggest = ({ }, rightElement: ( { setQuery('') setDebouncedQuery('') From 4365640e0e291b4c131bba784bac9795fd53ed69 Mon Sep 17 00:00:00 2001 From: Guan <821143943@qq.com> Date: Thu, 20 Feb 2025 15:47:13 +0800 Subject: [PATCH 02/15] fix: missing object properties --- src/components/editor/operator/EditorOperator.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/components/editor/operator/EditorOperator.tsx b/src/components/editor/operator/EditorOperator.tsx index 3fde316e..4d452069 100644 --- a/src/components/editor/operator/EditorOperator.tsx +++ b/src/components/editor/operator/EditorOperator.tsx @@ -26,6 +26,8 @@ const createArbitraryOperator = (name: string): OperatorInfo => ({ alias: '', alt_name: '', subProf: '', + prof: '', + rarity: 0, }) export const EditorOperatorName = ({ From e8ac487a02324df48991d53a17d4dd39b58cc2a9 Mon Sep 17 00:00:00 2001 From: Guan <821143943@qq.com> Date: Fri, 21 Feb 2025 05:45:56 +0800 Subject: [PATCH 03/15] refactor: use exact authorId when searching for operations --- src/apis/operation-set.ts | 15 +++++---------- src/apis/operation.ts | 9 ++++----- src/components/Operations.tsx | 11 ++++++++--- .../operation-set/AddToOperationSet.tsx | 11 +++++++++-- 4 files changed, 26 insertions(+), 20 deletions(-) diff --git a/src/apis/operation-set.ts b/src/apis/operation-set.ts index 59ffaa83..d0d0105e 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 diff --git a/src/apis/operation.ts b/src/apis/operation.ts index d2378b49..32e7a934 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, ] }, @@ -96,8 +96,7 @@ export function useOperations({ } const res = await new OperationApi({ - sendToken: - 'uploaderId' in req && req.uploaderId === 'me' ? 'always' : 'never', + sendToken: 'optional', requireData: true, }).queriesCopilot(req) diff --git a/src/components/Operations.tsx b/src/components/Operations.tsx index d28683aa..a414cffc 100644 --- a/src/components/Operations.tsx +++ b/src/components/Operations.tsx @@ -65,11 +65,11 @@ export const Operations: ComponentType = withSuspensable(() => { className="" icon="user" title="只显示我发布的作品" - active={queryParams.byMyself} + active={!!queryParams.uploaderId} onClick={() => { setQueryParams((old) => ({ ...old, - byMyself: !old.byMyself, + uploaderId: old.uploaderId ? undefined : authState.userId, })) }} > @@ -209,7 +209,12 @@ export const Operations: ComponentType = withSuspensable(() => { revalidateFirstPage={queryParams.orderBy !== 'hot'} /> )} - {listMode === 'operationSet' && } + {listMode === 'operationSet' && ( + + )} ) diff --git a/src/components/operation-set/AddToOperationSet.tsx b/src/components/operation-set/AddToOperationSet.tsx index 4a2a42db..806b0169 100644 --- a/src/components/operation-set/AddToOperationSet.tsx +++ b/src/components/operation-set/AddToOperationSet.tsx @@ -14,6 +14,7 @@ import { useOperationSets, } from 'apis/operation-set' import clsx from 'clsx' +import { useAtomValue } from 'jotai' import { useState } from 'react' import { AppToaster } from 'components/Toaster' @@ -21,6 +22,8 @@ import { OperationSetEditorDialog } from 'components/operation-set/OperationSetE import { formatError } from 'utils/error' import { useNetworkState } from 'utils/useNetworkState' +import { authAtom } from '../../store/auth' + interface AddToOperationSetButtonProps extends ButtonProps { operationId: number } @@ -60,6 +63,8 @@ export function AddToOperationSet({ operationId, onSuccess, }: AddToOperationSetProps) { + const auth = useAtomValue(authAtom) + const { operationSets, isReachingEnd, @@ -67,7 +72,8 @@ export function AddToOperationSet({ setSize, error: listError, } = useOperationSets({ - byMyself: true, + disabled: !auth.userId, + creatorId: auth.userId, }) const { @@ -82,7 +88,8 @@ export function AddToOperationSet({ {} as Record, ) - const error = submitError || listError + const error = + submitError || listError || (!auth.userId ? '未登录' : undefined) const operationSetList = onlyShowAdded ? operationSets?.filter( From a07d6d3e6d1393e958585c1f6835c1d6d5588dec Mon Sep 17 00:00:00 2001 From: Guan <821143943@qq.com> Date: Fri, 21 Feb 2025 06:09:07 +0800 Subject: [PATCH 04/15] feat: add profile page --- src/components/AccountManager.tsx | 13 +++--- src/main.tsx | 6 +++ src/pages/profile.tsx | 71 +++++++++++++++++++++++++++++++ 3 files changed, 82 insertions(+), 8 deletions(-) create mode 100644 src/pages/profile.tsx 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 && ( - - )} - + import('./pages/about').then((m) => ({ default: m.AboutPage }))), ) +const ProfilePageLazy = withSuspensable( + lazy(() => + import('./pages/profile').then((m) => ({ default: m.ProfilePage })), + ), +) ReactDOM.createRoot(document.getElementById('root')!).render( @@ -67,6 +72,7 @@ ReactDOM.createRoot(document.getElementById('root')!).render( } /> } /> } /> + } /> } /> } /> diff --git a/src/pages/profile.tsx b/src/pages/profile.tsx new file mode 100644 index 00000000..29ef43a4 --- /dev/null +++ b/src/pages/profile.tsx @@ -0,0 +1,71 @@ +import { Button, ButtonGroup, Card } from '@blueprintjs/core' + +import { ComponentType, useState } from 'react' +import { useParams } from 'react-router-dom' + +import { withGlobalErrorBoundary } from 'components/GlobalErrorBoundary' +import { OperationList } from 'components/OperationList' +import { OperationSetList } from 'components/OperationSetList' +import { OperationDrawer } from 'components/drawer/OperationDrawer' + +import { CardTitle } from '../components/CardTitle' +import { withSuspensable } from '../components/Suspensable' + +export const _ProfilePage: ComponentType = () => { + const { id } = useParams() + + // edge case? + if (!id) { + throw new Error('ID 无效') + } + + const [listMode, setListMode] = useState<'operation' | 'operationSet'>( + 'operation', + ) + + return ( +
+
+
+ + + + +
+ +
+ {listMode === 'operation' && ( + + )} + {listMode === 'operationSet' && } +
+
+
+
+ + 用户名 + +
+
+ + +
+ ) +} +_ProfilePage.displayName = 'ProfilePage' + +export const ProfilePage = withGlobalErrorBoundary( + withSuspensable(_ProfilePage), +) From 92c168e64bbde476269425b47e7d33b89a6bf68b Mon Sep 17 00:00:00 2001 From: Guan <821143943@qq.com> Date: Fri, 21 Feb 2025 18:49:48 +0800 Subject: [PATCH 05/15] feat: more accurate level suggestions --- src/components/LevelSelect.tsx | 81 +++++++++++++++++++++------------- 1 file changed, 50 insertions(+), 31 deletions(-) diff --git a/src/components/LevelSelect.tsx b/src/components/LevelSelect.tsx index 8d5152e5..590e46d7 100644 --- a/src/components/LevelSelect.tsx +++ b/src/components/LevelSelect.tsx @@ -38,14 +38,6 @@ export const LevelSelect: FC = ({ }), [levels], ) - const fuseSimilar = useMemo( - () => - new Fuse(levels, { - keys: ['levelId'], - threshold: 0, - }), - [levels], - ) // value 可以由用户输入,所以可以是任何值,只有用 stageId 才能匹配到唯一的关卡 const selectedLevel = useMemo( @@ -53,37 +45,64 @@ export const LevelSelect: FC = ({ [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={(query) => { - // 如果 query 和当前关卡完全匹配(也就是唯一对应),就显示同类关卡 - if (selectedLevel && selectedLevel.stageId === query) { - const levelIdPrefix = selectedLevel.levelId - .split('/') - .slice(0, -1) - .join('/') - const similarLevels = fuseSimilar - .search(levelIdPrefix) - .map((el) => el.item) - - if (similarLevels.length > 0) { - const header = createCustomLevel('header') - // catTwo 一般是活动名,有时候是空的 - header.catTwo = selectedLevel.catTwo || '相关关卡' - return [header, ...similarLevels] - } - } - - return query ? fuse.search(query).map((el) => el.item) : levels - }} + itemListPredicate={search} onReset={() => onChange('')} className={clsx(className, selectedLevel && '[&_input]:italic')} itemRenderer={(item, { handleClick, handleFocus, modifiers }) => - item.name === 'header' ? ( + item.stageId === 'header' ? ( -
{item.catTwo}
+
{item.name}
) : ( From 15f62a9ab16344c948b463703af0cf6659c97138 Mon Sep 17 00:00:00 2001 From: Guan <821143943@qq.com> Date: Fri, 21 Feb 2025 20:01:40 +0800 Subject: [PATCH 06/15] feat: display user info in profile page --- src/apis/user.ts | 32 ++++++++++++++++++++++++++++++++ src/components/Suspensable.tsx | 9 ++++++++- src/pages/profile.tsx | 24 ++++++++++++++++-------- src/utils/error.ts | 4 ++++ 4 files changed, 60 insertions(+), 9 deletions(-) create mode 100644 src/apis/user.ts 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/Suspensable.tsx b/src/components/Suspensable.tsx index 420d79c9..1cf84ed1 100644 --- a/src/components/Suspensable.tsx +++ b/src/components/Suspensable.tsx @@ -11,6 +11,7 @@ interface SuspensableProps { pendingTitle?: string fetcher?: () => void + errorFallback?: (params: { error: Error }) => JSX.Element | undefined } export const Suspensable: FCC = ({ @@ -18,6 +19,7 @@ export const Suspensable: FCC = ({ retryDeps = [], pendingTitle = '加载中', fetcher, + errorFallback, }) => { const resetError = useRef<() => void>() @@ -30,13 +32,18 @@ export const Suspensable: FCC = ({ return ( { + const fallback = errorFallback?.({ error }) + if (fallback !== undefined) { + return fallback + } + resetError.current = _resetError return ( { +const _ProfilePage: ComponentType = () => { const { id } = useParams() - - // edge case? if (!id) { + // edge case? throw new Error('ID 无效') } + const { data: userInfo } = useUserInfo({ userId: id, suspense: true }) + const [listMode, setListMode] = useState<'operation' | 'operationSet'>( 'operation', ) @@ -55,7 +58,7 @@ export const _ProfilePage: ComponentType = () => {
- 用户名 + {userInfo?.userName}
@@ -66,6 +69,11 @@ export const _ProfilePage: ComponentType = () => { } _ProfilePage.displayName = 'ProfilePage' -export const ProfilePage = withGlobalErrorBoundary( - withSuspensable(_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 || '网络错误' } From ea80664a291dad6cf2d9a5c3a085740cef3a1648 Mon Sep 17 00:00:00 2001 From: Guan <821143943@qq.com> Date: Fri, 21 Feb 2025 21:26:34 +0800 Subject: [PATCH 07/15] feat: display total amount of operations in profile page --- src/apis/operation-set.ts | 3 + src/apis/operation.ts | 4 +- src/components/OperationList.tsx | 129 +++++++++++++++------------- src/components/OperationSetList.tsx | 18 ++-- src/pages/profile.tsx | 21 +++-- 5 files changed, 103 insertions(+), 72 deletions(-) diff --git a/src/apis/operation-set.ts b/src/apis/operation-set.ts index d0d0105e..c62d8a31 100644 --- a/src/apis/operation-set.ts +++ b/src/apis/operation-set.ts @@ -67,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, @@ -125,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 32e7a934..de3c7143 100644 --- a/src/apis/operation.ts +++ b/src/apis/operation.ts @@ -92,7 +92,7 @@ export function useOperations({ // 如果指定了 id 列表,但是列表为空,就直接返回空数据。不然要是直接传空列表,就相当于没有这个参数, // 会导致后端返回所有数据 if (req.copilotIds?.length === 0) { - return { data: [], hasNext: false } + return { data: [], hasNext: false, total: 0 } } const res = await new OperationApi({ @@ -121,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() ?? [] @@ -134,6 +135,7 @@ export function useOperations({ return { error, operations, + total, setSize, isValidating, isReachingEnd, diff --git a/src/components/OperationList.tsx b/src/components/OperationList.tsx index a9890a8a..1266c2c4 100644 --- a/src/components/OperationList.tsx +++ b/src/components/OperationList.tsx @@ -2,77 +2,84 @@ import { Button, NonIdealState } from '@blueprintjs/core' import { UseOperationsParams, useOperations } from 'apis/operation' import { useAtomValue } from 'jotai' -import { ComponentType, ReactNode } from 'react' +import { ComponentType, ReactNode, useEffect } from 'react' import { neoLayoutAtom } from 'store/pref' import { NeoOperationCard, OperationCard } from './OperationCard' import { withSuspensable } from './Suspensable' -export const OperationList: ComponentType = - 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)} + /> )} - {listMode === 'operationSet' && }
From 72b0d3b5779b8cf8f7b32cb1dc2bf75f8a26fbc0 Mon Sep 17 00:00:00 2001 From: Guan <821143943@qq.com> Date: Fri, 21 Feb 2025 23:27:51 +0800 Subject: [PATCH 08/15] fix: wrap operation card with div instead of anchor This resolves a violation of HTML spec: an anchor element cannot contain interactive content (button, anchor, etc.) https://html.spec.whatwg.org/multipage/dom.html#interactive-content-2 --- src/components/OperationCard.tsx | 10 ++--- src/components/OperationSetCard.tsx | 4 +- src/components/ReLinkDiv.tsx | 69 +++++++++++++++++++++++++++++ 3 files changed, 76 insertions(+), 7 deletions(-) create mode 100644 src/components/ReLinkDiv.tsx diff --git a/src/components/OperationCard.tsx b/src/components/OperationCard.tsx index 7bee455a..bd411b62 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,7 @@ 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 { EDifficulty } from './entity/EDifficulty' import { EDifficultyLevel, NeoELevel } from './entity/ELevel' @@ -21,9 +21,9 @@ export const NeoOperationCard = ({ operation }: { operation: Operation }) => { return ( -
{ elevation={Elevation.TWO} className="relative mb-4 sm:mb-2 last:mb-0" > - +
{/* title */}
@@ -192,7 +192,7 @@ export const OperationCard = ({ operation }: { operation: Operation }) => {
-
+ {/* title */}
@@ -91,7 +91,7 @@ export const OperationSetCard = ({ >
{/* title */} diff --git a/src/components/ReLinkDiv.tsx b/src/components/ReLinkDiv.tsx new file mode 100644 index 00000000..787ffd0a --- /dev/null +++ b/src/components/ReLinkDiv.tsx @@ -0,0 +1,69 @@ +import { isString } from '@sentry/utils' + +import { noop } from 'lodash-es' +import { AnchorHTMLAttributes, HtmlHTMLAttributes } from 'react' +import { + RelativeRoutingType, + To, + useLinkClickHandler, + useSearchParams, +} from 'react-router-dom' + +interface ReLinkProps extends HtmlHTMLAttributes { + search?: Record + target?: AnchorHTMLAttributes['target'] + + to?: To + replace?: boolean + state?: any + preventScrollReset?: boolean + relative?: RelativeRoutingType +} + +// div 版的 ReLink +export function ReLinkDiv({ + className, + search, + to, + replace = false, + state, + target, + onClick, + ...props +}: ReLinkProps) { + const [searchParams] = useSearchParams() + + if (search) { + for (const [key, value] of Object.entries(search)) { + if (value === undefined) { + searchParams.delete(key) + } else { + searchParams.set(key, String(value)) + } + } + } + + to = isString(to) ? to : { ...to, search: searchParams.toString() } + + const handleClick = useLinkClickHandler(to, { + replace, + state, + target, + }) + + return ( +
{ + onClick?.(event) + if (!event.defaultPrevented) { + handleClick(event) + } + }} + onKeyUp={noop} + {...props} + /> + ) +} From 55bdaa54a84193fcc0b27cb752d89e81890ff89f Mon Sep 17 00:00:00 2001 From: Guan <821143943@qq.com> Date: Fri, 21 Feb 2025 23:39:14 +0800 Subject: [PATCH 09/15] feat: clickable user names --- src/components/OperationCard.tsx | 25 +++++++++---------- src/components/OperationSetCard.tsx | 23 ++++++++--------- src/components/ReLink.tsx | 3 +-- src/components/UserName.tsx | 21 ++++++++++++++++ src/components/viewer/OperationSetViewer.tsx | 8 ++++-- src/components/viewer/OperationViewer.tsx | 8 ++++-- src/components/viewer/comment/CommentArea.tsx | 8 ++++-- 7 files changed, 63 insertions(+), 33 deletions(-) create mode 100644 src/components/UserName.tsx diff --git a/src/components/OperationCard.tsx b/src/components/OperationCard.tsx index bd411b62..0b953bbf 100644 --- a/src/components/OperationCard.tsx +++ b/src/components/OperationCard.tsx @@ -13,6 +13,7 @@ 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' @@ -96,15 +97,13 @@ export const NeoOperationCard = ({ operation }: { operation: Operation }) => {
- -
- - {operation.uploader} -
-
+ + + {operation.uploader} +
- + @@ -170,12 +169,12 @@ export const OperationCard = ({ operation }: { operation: Operation }) => { />
- -
- - {operation.uploader} -
-
+
+ + + {operation.uploader} + +
diff --git a/src/components/OperationSetCard.tsx b/src/components/OperationSetCard.tsx index cd85685a..9ee19af9 100644 --- a/src/components/OperationSetCard.tsx +++ b/src/components/OperationSetCard.tsx @@ -8,6 +8,7 @@ import { RelativeTime } from 'components/RelativeTime' import { OperationSetListItem } from 'models/operation-set' import { Paragraphs } from './Paragraphs' +import { UserName } from './UserName' export const NeoOperationSetCard = ({ operationSet, @@ -60,12 +61,10 @@ export const NeoOperationSetCard = ({
- -
- - {operationSet.creator} -
-
+ + + {operationSet.creator} +
@@ -117,12 +116,12 @@ export const OperationSetCard = ({ moment={operationSet.createTime} />
- -
- - {operationSet.creator} -
-
+
+ + + {operationSet.creator} + +
diff --git a/src/components/ReLink.tsx b/src/components/ReLink.tsx index cb0a426e..17b8240e 100644 --- a/src/components/ReLink.tsx +++ b/src/components/ReLink.tsx @@ -1,6 +1,5 @@ import { isString } from '@sentry/utils' -import clsx from 'clsx' import { Link, LinkProps, useSearchParams } from 'react-router-dom' import { SetOptional } from 'type-fest' @@ -25,7 +24,7 @@ export function ReLink({ className, search, ...props }: ReLinkProps) { return ( = ({ + className, + userId, + children, +}) => { + return ( + + {children} + + ) +} diff --git a/src/components/viewer/OperationSetViewer.tsx b/src/components/viewer/OperationSetViewer.tsx index 2c48c960..57d5b025 100644 --- a/src/components/viewer/OperationSetViewer.tsx +++ b/src/components/viewer/OperationSetViewer.tsx @@ -34,6 +34,7 @@ import { authAtom } from 'store/auth' import { wrapErrorMessage } from 'utils/wrapErrorMessage' import { formatError } from '../../utils/error' +import { UserName } from '../UserName' const ManageMenu: FC<{ operationSet: OperationSet @@ -225,9 +226,12 @@ function OperationSetViewerInner({ - + {operationSet.creator} - +
diff --git a/src/components/viewer/OperationViewer.tsx b/src/components/viewer/OperationViewer.tsx index 085fd746..d5c3329e 100644 --- a/src/components/viewer/OperationViewer.tsx +++ b/src/components/viewer/OperationViewer.tsx @@ -45,6 +45,7 @@ import { createCustomLevel, findLevelByStageName } from '../../models/level' import { Level } from '../../models/operation' import { formatError } from '../../utils/error' import { ActionCard } from '../ActionCard' +import { UserName } from '../UserName' import { CommentArea } from './comment/CommentArea' const ManageMenu: FC<{ @@ -351,9 +352,12 @@ function OperationViewerInner({ - + {operation.uploader} - + diff --git a/src/components/viewer/comment/CommentArea.tsx b/src/components/viewer/comment/CommentArea.tsx index a6b8b558..f521353e 100644 --- a/src/components/viewer/comment/CommentArea.tsx +++ b/src/components/viewer/comment/CommentArea.tsx @@ -37,6 +37,7 @@ import { wrapErrorMessage } from '../../../utils/wrapErrorMessage' import { Markdown } from '../../Markdown' import { OutlinedIcon } from '../../OutlinedIcon' import { withSuspensable } from '../../Suspensable' +import { UserName } from '../../UserName' import { CommentForm } from './CommentForm' interface CommentAreaProps { @@ -194,7 +195,10 @@ const SubComment = ({ {fromComment && ( <> - 回复 @{fromComment.uploader} + 回复 + + @{fromComment.uploader} + :  @@ -228,7 +232,7 @@ const CommentHeader = ({ )} >
- {uploader} + {uploader}
{formatRelativeTime(uploadTime)} From 460fcdca9f5c16dbc4efa2271a40fe80177ee5f1 Mon Sep 17 00:00:00 2001 From: Guan <821143943@qq.com> Date: Fri, 21 Feb 2025 23:41:57 +0800 Subject: [PATCH 10/15] refactor: fix type error --- src/components/viewer/comment/CommentArea.tsx | 24 +++++++++++++------ 1 file changed, 17 insertions(+), 7 deletions(-) diff --git a/src/components/viewer/comment/CommentArea.tsx b/src/components/viewer/comment/CommentArea.tsx index f521353e..5cb66e2d 100644 --- a/src/components/viewer/comment/CommentArea.tsx +++ b/src/components/viewer/comment/CommentArea.tsx @@ -20,10 +20,10 @@ import { useComments, } from '../../../apis/comment' import { - MAX_COMMENT_LENGTH, AUTHOR_MAX_COMMENT_LENGTH, CommentInfo, CommentRating, + MAX_COMMENT_LENGTH, MainCommentInfo, SubCommentInfo, isMainComment, @@ -66,9 +66,15 @@ export const CommentArea = withSuspensable(function ViewerComments({ const auth = useAtomValue(authAtom) const operation = useOperation({ id: operationId }).data - const operationOwned = operation && auth.userId && operation.uploaderId === auth.userId + const operationOwned = !!( + operation && + auth.userId && + operation.uploaderId === auth.userId + ) - const maxLength = operationOwned ? AUTHOR_MAX_COMMENT_LENGTH : MAX_COMMENT_LENGTH + const maxLength = operationOwned + ? AUTHOR_MAX_COMMENT_LENGTH + : MAX_COMMENT_LENGTH const [replyTo, setReplyTo] = useState() @@ -112,8 +118,8 @@ export const CommentArea = withSuspensable(function ViewerComments({ sub.fromCommentId === comment.commentId ? undefined : find(comment.subCommentsInfos, { - commentId: sub.fromCommentId, - }) + commentId: sub.fromCommentId, + }) } /> ))} @@ -266,7 +272,9 @@ const CommentActions = ({ const [{ userId }] = useAtom(authAtom) const { operationOwned, replyTo, setReplyTo, reload } = useContext(CommentAreaContext) - const maxLength = operationOwned ? AUTHOR_MAX_COMMENT_LENGTH : MAX_COMMENT_LENGTH + const maxLength = operationOwned + ? AUTHOR_MAX_COMMENT_LENGTH + : MAX_COMMENT_LENGTH const [deleteDialogOpen, setDeleteDialogOpen] = useState(false) const [pending, setPending] = useState(false) @@ -337,7 +345,9 @@ const CommentActions = ({

- {replyTo === comment && } + {replyTo === comment && ( + + )} ) } From 6a9c41b6b5d4a58eb5f3187720aa19415833977a Mon Sep 17 00:00:00 2001 From: Guan <821143943@qq.com> Date: Fri, 21 Feb 2025 23:49:56 +0800 Subject: [PATCH 11/15] fix: use uploaderId to check operation ownership --- src/components/viewer/OperationViewer.tsx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/components/viewer/OperationViewer.tsx b/src/components/viewer/OperationViewer.tsx index d5c3329e..68ea8f13 100644 --- a/src/components/viewer/OperationViewer.tsx +++ b/src/components/viewer/OperationViewer.tsx @@ -186,8 +186,7 @@ export const OperationViewer: ComponentType<{
- {operation.uploader === auth.username && ( - // FIXME: 用户名可以重名,这里会让重名用户都显示管理按钮,需要等后端支持 operation.uploaderId 后再修复 + {operation.uploaderId === auth.userId && ( Date: Sat, 22 Feb 2025 01:38:26 +0800 Subject: [PATCH 12/15] fix: prevent reopening operation drawer after closing it by mouse's back button --- src/components/drawer/OperationDrawer.tsx | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/src/components/drawer/OperationDrawer.tsx b/src/components/drawer/OperationDrawer.tsx index a5a06119..0d660b2f 100644 --- a/src/components/drawer/OperationDrawer.tsx +++ b/src/components/drawer/OperationDrawer.tsx @@ -1,5 +1,6 @@ import { Drawer, DrawerSize } from '@blueprintjs/core' +import { SyntheticEvent } from 'react' import { useSearchParams } from 'react-router-dom' import { OperationSetViewer } from 'components/viewer/OperationSetViewer' @@ -10,14 +11,24 @@ export function OperationDrawer() { const operationId = +(searchParams.get('op') || NaN) || undefined const operationSetId = +(searchParams.get('opset') || NaN) || undefined - const closeOperation = () => { + const closeOperation = (e?: SyntheticEvent) => { + // 如果是通过鼠标点击外面来触发的关闭,那么只在左键点击时关闭,避免用后退键点击时关闭后又立即打开的问题 + // (原因是后退键会先触发关闭,然后触发浏览器的后退操作,使页面回到上一个 URL,导致又触发打开) + if (e?.nativeEvent instanceof MouseEvent && e.nativeEvent.button !== 0) { + return + } + setSearchParams((params) => { params.delete('op') return params }) } - const closeOperationSet = () => { + const closeOperationSet = (e?: SyntheticEvent) => { + if (e?.nativeEvent instanceof MouseEvent && e.nativeEvent.button !== 0) { + return + } + setSearchParams((params) => { params.delete('opset') return params From 0512d8491242ff53f5d7df2fe893f6dacadaa109 Mon Sep 17 00:00:00 2001 From: Guan <821143943@qq.com> Date: Sat, 22 Feb 2025 04:37:39 +0800 Subject: [PATCH 13/15] feat: compact layout of operation viewer --- .../editor/operator/EditorOperator.tsx | 3 +- src/components/viewer/OperationViewer.tsx | 140 ++++++++++-------- 2 files changed, 81 insertions(+), 62 deletions(-) diff --git a/src/components/editor/operator/EditorOperator.tsx b/src/components/editor/operator/EditorOperator.tsx index 4d452069..95d61a3c 100644 --- a/src/components/editor/operator/EditorOperator.tsx +++ b/src/components/editor/operator/EditorOperator.tsx @@ -156,11 +156,12 @@ export const OperatorAvatar = ({ })() const sizingClassName = + size && { small: 'h-5 w-5', medium: 'h-6 w-6', large: 'h-8 w-8', - }[size || 'medium'] || 'h-6 w-6' + }[size] const commonClassName = 'rounded-md object-cover bp4-elevation-1 bg-slate-100' diff --git a/src/components/viewer/OperationViewer.tsx b/src/components/viewer/OperationViewer.tsx index 68ea8f13..7b07efc3 100644 --- a/src/components/viewer/OperationViewer.tsx +++ b/src/components/viewer/OperationViewer.tsx @@ -3,10 +3,11 @@ import { Button, ButtonGroup, Card, + Collapse, Elevation, H3, H4, - H5, + H6, Icon, Menu, MenuItem, @@ -21,6 +22,7 @@ import { useOperation, useRefreshOperations, } from 'apis/operation' +import clsx from 'clsx' import { useAtom } from 'jotai' import { ComponentType, FC, useEffect, useState } from 'react' import { Link } from 'react-router-dom' @@ -248,31 +250,16 @@ const OperatorCard: FC<{ operator: CopilotDocV1.Operator }> = ({ operator }) => { const { name, skill } = operator + const skillStr = [null, '一', '二', '三'][skill ?? 1] ?? '未知' return ( - - -
{name}
-
-
- 技能{skill} -
- +
+ + {name} + {skillStr}技能 +
) } -const EmptyOperator: FC<{ - title?: string - description?: string -}> = ({ title = '暂无干员', description }) => ( - -) - function OperationViewerInner({ levels, operation, @@ -390,56 +377,87 @@ function OperationViewerInner({ ) } function OperationViewerInnerDetails({ operation }: { operation: Operation }) { + const [showOperators, setShowOperators] = useState(true) + const [showActions, setShowActions] = useState(false) + return ( -
-
-

干员与干员组

-
干员
-
+
+

setShowOperators((v) => !v)} + > + 干员与干员组 + + + + +

+ +
+ {!operation.parsedContent.opers?.length && + !operation.parsedContent.groups?.length && ( + + )} {operation.parsedContent.opers?.map((operator) => ( ))} - {!operation.parsedContent.opers?.length && ( - - )}
- -
干员组
-
+
{operation.parsedContent.groups?.map((group) => ( - -
-
{group.name}
- -
- {group.opers - ?.filter(Boolean) - .map((operator) => ( - - ))} - - {group.opers?.filter(Boolean).length === 0 && ( - - )} -
+ +
{group.name}
+
+ {group.opers + ?.filter(Boolean) + .map((operator) => ( + + ))} + + {group.opers?.filter(Boolean).length === 0 && ( + 无干员 + )}
))} - - {!operation.parsedContent.groups?.length && ( - - )}
-
- -
-

动作序列

+ +

setShowActions((v) => !v)} + > + 动作序列 + +

+ {operation.parsedContent.actions?.length ? ( -
+
{operation.parsedContent.actions.map((action, i) => ( ))} @@ -453,7 +471,7 @@ function OperationViewerInnerDetails({ operation }: { operation: Operation }) { layout="horizontal" /> )} -
+
) } From df9a437cb51e4b339d4f1101a735dd2ee3b5731b Mon Sep 17 00:00:00 2001 From: Guan <821143943@qq.com> Date: Sat, 22 Feb 2025 06:25:46 +0800 Subject: [PATCH 14/15] feat: display rarity colors --- .../editor/operator/EditorOperator.tsx | 22 +++++++++++++++++-- src/components/viewer/OperationViewer.tsx | 10 +++++++-- 2 files changed, 28 insertions(+), 4 deletions(-) diff --git a/src/components/editor/operator/EditorOperator.tsx b/src/components/editor/operator/EditorOperator.tsx index 95d61a3c..ba2116a4 100644 --- a/src/components/editor/operator/EditorOperator.tsx +++ b/src/components/editor/operator/EditorOperator.tsx @@ -136,11 +136,13 @@ export const EditorOperatorName = ({ export const OperatorAvatar = ({ id, name, + rarity = 0, size, className, }: { id?: string name?: string + rarity?: number size?: 'small' | 'medium' | 'large' className?: string }) => { @@ -163,11 +165,26 @@ export const OperatorAvatar = ({ large: 'h-8 w-8', }[size] - const commonClassName = 'rounded-md object-cover bp4-elevation-1 bg-slate-100' + const colorClassName = + rarity === 6 + ? ['bg-orange-200', 'ring-orange-300'] + : rarity === 5 + ? ['bg-yellow-100', 'ring-yellow-200'] + : rarity === 4 + ? ['bg-purple-100', 'ring-purple-200'] + : ['bg-slate-100', 'ring-slate-200'] + + const commonClassName = + 'ring-inset ring-2 border-solid rounded-md object-cover' return foundId ? ( {id} = ({ operator }) => { const { name, skill } = operator + const info = OPERATORS.find((o) => o.name === name) const skillStr = [null, '一', '二', '三'][skill ?? 1] ?? '未知' return (
- - {name} + + {name} {skillStr}技能
) From 7d091885842a0ebd38aea71c35ec55ffaca5a16b Mon Sep 17 00:00:00 2001 From: Guan <821143943@qq.com> Date: Sat, 22 Feb 2025 16:06:00 +0800 Subject: [PATCH 15/15] docs: update changelog --- CHANGELOG.md | 7 +++++++ 1 file changed, 7 insertions(+) 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)