压缩历史至 95081aff

This commit is contained in:
ryan
2026-06-08 20:34:27 +08:00
commit 8a782525de
435 changed files with 71146 additions and 0 deletions
+42
View File
@@ -0,0 +1,42 @@
import * as React from 'react';
interface CommonControlledStateProps<T> {
value?: T;
defaultValue?: T;
}
/**
* 受控状态 Hook
* 用于处理受控和非受控组件的状态管理
*
* @example
* ```tsx
* const [value, setValue] = useControlledState({ value: propValue, onChange: console.log })
* ```
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export function useControlledState<T, Rest extends any[] = []>(
props: CommonControlledStateProps<T> & {
onChange?: (value: T, ...args: Rest) => void;
},
): readonly [T, (next: T, ...args: Rest) => void] {
const { value, defaultValue, onChange } = props;
const [state, setInternalState] = React.useState<T>(
value !== undefined ? value : (defaultValue as T),
);
React.useEffect(() => {
if (value !== undefined) setInternalState(value);
}, [value]);
const setState = React.useCallback(
(next: T, ...args: Rest) => {
setInternalState(next);
onChange?.(next, ...args);
},
[onChange],
);
return [state, setState] as const;
}
+81
View File
@@ -0,0 +1,81 @@
"use client"
import * as React from "react"
import type { DisputeWithOrder, DisputeStatus } from "@/lib/services"
import { useUser } from "@/contexts/user-context"
import { toast } from "sonner"
const DISPUTE_PAGE_SIZE = 20
/**
* 争议数据获取 Hook
* 用于获取争议数据
*
* @param fetchFn 获取争议数据的函数
* @returns 争议数据
*/
interface UseDisputeDataProps {
fetchFn: (params: { page: number; page_size: number; status: DisputeStatus }) => Promise<{ total: number; disputes: DisputeWithOrder[] }>
}
export const useDisputeData = ({ fetchFn }: UseDisputeDataProps) => {
const { user } = useUser()
const [disputes, setDisputes] = React.useState<{ count: number; list: DisputeWithOrder[] }>({ count: 0, list: [] })
const [loading, setLoading] = React.useState<boolean>(true)
const fetchFnRef = React.useRef(fetchFn)
React.useEffect(() => {
fetchFnRef.current = fetchFn
}, [fetchFn])
const fetchData = React.useCallback(async () => {
if (!user) return
try {
setLoading(true)
const result = await fetchFnRef.current({
page: 1,
page_size: DISPUTE_PAGE_SIZE,
status: 'disputing'
}).catch(() => ({ total: 0, disputes: [] }))
setDisputes({
count: result.total,
list: result.disputes
})
} catch (err) {
toast.error('获取争议数据失败', {
description: err instanceof Error ? err.message : '获取争议数据失败'
})
} finally {
setLoading(false)
}
}, [user])
React.useEffect(() => {
fetchData()
}, [fetchData])
const handleRefresh = React.useCallback(async () => {
setLoading(true)
try {
const result = await fetchFnRef.current({
page: 1,
page_size: DISPUTE_PAGE_SIZE,
status: 'disputing'
})
setDisputes({
count: result.total,
list: result.disputes
})
} catch (err) {
toast.error('刷新争议数据失败', {
description: err instanceof Error ? err.message : '刷新争议数据失败'
})
} finally {
setLoading(false)
}
}, [])
return { disputes, loading, handleRefresh, refetchData: fetchData }
}
+33
View File
@@ -0,0 +1,33 @@
import * as React from 'react';
import { useInView, type UseInViewOptions } from 'motion/react';
interface UseIsInViewOptions {
inView?: boolean;
inViewOnce?: boolean;
inViewMargin?: UseInViewOptions['margin'];
}
/**
* 检测元素是否在视图中
*
* @example
* ```tsx
* const { ref, isInView } = useIsInView({ inViewOnce: true })
* ```
*/
function useIsInView<T extends HTMLElement = HTMLElement>(
ref: React.Ref<T>,
options: UseIsInViewOptions = {},
) {
const { inView, inViewOnce = false, inViewMargin = '0px' } = options;
const localRef = React.useRef<T>(null);
React.useImperativeHandle(ref, () => localRef.current as T);
const inViewResult = useInView(localRef, {
once: inViewOnce,
margin: inViewMargin,
});
const isInView = !inView || inViewResult;
return { ref: localRef, isInView };
}
export { useIsInView, type UseIsInViewOptions };
+127
View File
@@ -0,0 +1,127 @@
"use client";
import * as React from "react";
import { LeaderboardService } from "@/lib/services/leaderboard";
import type {
LeaderboardListResponse,
LeaderboardListRequest,
LeaderboardEntry,
UserRankResponse,
} from "@/lib/services/leaderboard";
export const useLeaderboard = () => {
const [data, setData] = React.useState<LeaderboardListResponse | null>(null);
const [allItems, setAllItems] = React.useState<LeaderboardEntry[]>([]);
const [myRank, setMyRank] = React.useState<UserRankResponse | null>(null);
const [loading, setLoading] = React.useState(true);
const [loadingMore, setLoadingMore] = React.useState(false);
const [myRankLoading, setMyRankLoading] = React.useState(true);
const [error, setError] = React.useState<Error | null>(null);
const currentPageRef = React.useRef(1);
const fetchData = React.useCallback(
async (
queryParams: LeaderboardListRequest,
append = false,
ignore?: { current: boolean },
) => {
if (append) {
setLoadingMore(true);
} else {
setLoading(true);
setAllItems([]);
currentPageRef.current = 1;
}
setError(null);
try {
const response = await LeaderboardService.getList(queryParams);
if (ignore?.current) return;
setData(response);
if (append) {
setAllItems((prev) => [...prev, ...response.items]);
} else {
setAllItems(response.items);
}
currentPageRef.current = response.page;
} catch (err) {
if (ignore?.current) return;
if (err instanceof Error) {
setError(err);
}
} finally {
if (!ignore?.current) {
setLoading(false);
setLoadingMore(false);
}
}
},
[],
);
const fetchMyRank = React.useCallback(
async (ignore?: { current: boolean }) => {
setMyRankLoading(true);
try {
const response = await LeaderboardService.getMyRank();
if (!ignore?.current) {
setMyRank(response);
}
} catch {
if (!ignore?.current) {
setMyRank(null);
}
} finally {
if (!ignore?.current) {
setMyRankLoading(false);
}
}
},
[],
);
React.useEffect(() => {
const ignore = { current: false };
fetchData({ page: 1, page_size: 50 }, false, ignore);
fetchMyRank(ignore);
return () => {
ignore.current = true;
};
}, [fetchData, fetchMyRank]);
const loadNextPage = React.useCallback(() => {
if (
data &&
currentPageRef.current * data.page_size < data.total &&
!loadingMore
) {
const nextPage = currentPageRef.current + 1;
fetchData({ page: nextPage, page_size: data.page_size }, true);
}
}, [data, loadingMore, fetchData]);
const refresh = React.useCallback(() => {
fetchData({ page: 1, page_size: 50 }, false);
fetchMyRank();
}, [fetchData, fetchMyRank]);
const hasMore = data
? currentPageRef.current * data.page_size < data.total
: false;
return {
data,
items: allItems,
myRank,
loading,
loadingMore,
myRankLoading,
error,
hasMore,
loadNextPage,
refresh,
};
};
+19
View File
@@ -0,0 +1,19 @@
import * as React from "react"
const MOBILE_BREAKPOINT = 768
export function useIsMobile() {
const [isMobile, setIsMobile] = React.useState<boolean | undefined>(undefined)
React.useEffect(() => {
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`)
const onChange = () => {
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
}
mql.addEventListener("change", onChange)
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
return () => mql.removeEventListener("change", onChange)
}, [])
return !!isMobile
}
+69
View File
@@ -0,0 +1,69 @@
import { useState, useEffect } from 'react'
import { ConfigService, PublicConfigResponse } from '@/lib/services'
/* 全局缓存 */
let cachedConfig: PublicConfigResponse | null = null
let cachePromise: Promise<PublicConfigResponse> | null = null
let cacheTime = 0
const CACHE_DURATION = 5 * 60 * 1000
/**
* 获取公共配置(带缓存)
*
* @example
* ```tsx
* const { config, loading, error } = usePublicConfig()
* ```
*/
export function usePublicConfig() {
const [config, setConfig] = useState<PublicConfigResponse | null>(cachedConfig)
const [loading, setLoading] = useState(!cachedConfig)
const [error, setError] = useState<Error | null>(null)
useEffect(() => {
let mounted = true
const fetchConfig = async () => {
const now = Date.now()
if (cachedConfig && (now - cacheTime) < CACHE_DURATION) {
if (mounted) {
setConfig(cachedConfig)
setLoading(false)
}
return
}
if (!cachePromise) {
cachePromise = ConfigService.getPublicConfig()
}
try {
const data = await cachePromise
if (mounted) {
cachedConfig = data
cacheTime = now
setConfig(data)
setError(null)
}
} catch (err) {
if (mounted) {
setError(err instanceof Error ? err : new Error('获取配置失败'))
}
} finally {
cachePromise = null
if (mounted) {
setLoading(false)
}
}
}
fetchConfig()
return () => {
mounted = false
}
}, [])
return { config, loading, error }
}