feat: complete TanStack dashboard acceptance

This commit is contained in:
2026-09-19 12:09:01 +08:00
parent 45a29ea89d
commit 8d35318c7e
103 changed files with 6061 additions and 1065 deletions
+7 -4
View File
@@ -1,7 +1,10 @@
// 401 = 未认证:抹去凭证并引导到登录页。开发代理下 401 由后端直接返回。
// 401 = 未认证:抹去凭证并通过 TanStack Router 监听的 history 事件回到登录页。
const unauthorized = () => {
localStorage.removeItem("creatorhub.auth");
if (!location.hash.startsWith("#/login")) location.hash = "#/login";
if (typeof window !== "undefined" && window.location.pathname !== "/login") {
window.history.replaceState({}, "", "/login");
window.dispatchEvent(new PopStateEvent("popstate"));
}
return Object.assign(new Error("认证已过期,请重新登录"), { status: 401 });
};
@@ -74,11 +77,11 @@ const withID = (record, fallback) => ({
});
export const dataProvider = {
// Refine v5 契约:所有方法收单个参数对象(getList 收 {resource, pagination, filters, meta})。
// 页面数据层契约:所有方法收单个参数对象(getList 收 {resource, pagination, filters, meta})。
async getList({ resource, pagination = {}, filters = [], meta = {} }) {
const path = resourcePaths[resource];
if (!path) return unsupported(resource, "getList");
// filters 为 [{field, value, operator}];meta 是 Refine 透传的查询上下文。
// filters 为 [{field, value, operator}];meta 是页面层透传的查询上下文。
const valueOf = (field) => {
const direct = meta?.[field];
if (direct !== undefined) return direct;
+1 -1
View File
@@ -47,7 +47,7 @@ describe("dataProvider", () => {
dataProvider.getList({ resource: "browsers" }),
).rejects.toMatchObject({ status: 401 });
expect(localStorage.getItem("creatorhub.auth")).toBeNull();
expect(location.hash).toBe("#/login");
expect(location.pathname).toBe("/login");
});
it.each([
+51
View File
@@ -0,0 +1,51 @@
import { createContext, createElement, useContext } from 'react'
import { useQuery } from '@tanstack/react-query'
import { dataProvider as defaultDataProvider } from '../api/dataProvider'
export const DataProviderContext = createContext(defaultDataProvider)
export function DataProviderProvider({ dataProvider, children }) {
return createElement(DataProviderContext.Provider, { value: dataProvider }, children)
}
const emptyList = { data: [], total: 0 }
function listKey({ resource, pagination, filters, meta }) {
return ['creatorhub', 'list', resource, { pagination, filters, meta }]
}
export function useList({ resource, pagination, filters, meta, queryOptions = {} }) {
const provider = useContext(DataProviderContext)
const query = useQuery({
queryKey: listKey({ resource, pagination, filters, meta }),
queryFn: () => provider.getList({ resource, pagination, filters, meta }),
...queryOptions,
})
return {
result: query.data ?? emptyList,
query,
error: query.error,
isPending: query.isPending,
}
}
export function useOne({ resource, id, queryOptions = {} }) {
const provider = useContext(DataProviderContext)
const query = useQuery({
queryKey: ['creatorhub', 'one', resource, id],
queryFn: () => provider.getOne({ resource, id }),
enabled: Boolean(id) && queryOptions.enabled !== false,
...queryOptions,
})
return {
result: query.data?.data,
query,
error: query.error,
isPending: query.isPending,
}
}
export function useDataProvider() {
const provider = useContext(DataProviderContext)
return () => provider
}
+69
View File
@@ -0,0 +1,69 @@
import { createContext, useContext } from 'react'
import {
Link as TanStackLink,
Outlet as TanStackOutlet,
useLocation as useTanStackLocation,
useNavigate as useTanStackNavigate,
useParams as useTanStackParams,
} from '@tanstack/react-router'
export const TestRouterContext = createContext(null)
export function Link({ to, ...props }) {
const testRouter = useContext(TestRouterContext)
if (testRouter) {
return <a href={typeof to === 'string' ? to : '#'} data-router-link="true" {...props} onClick={(event) => {
props.onClick?.(event)
if (event.defaultPrevented || event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return
event.preventDefault()
testRouter.navigate({ to })
}} />
}
return <TanStackLink to={to} data-router-link="true" {...props} />
}
export function Outlet() {
return <TanStackOutlet />
}
export function useNavigate() {
const testRouter = useContext(TestRouterContext)
if (testRouter) return (to, options = {}) => testRouter.navigate(typeof to === 'string' ? { to, ...options } : to)
const navigate = useTanStackNavigate()
return (to, options = {}) => {
if (typeof to === 'string') return navigate({ to, ...options })
return navigate(to)
}
}
export function useLocation() {
const testRouter = useContext(TestRouterContext)
if (testRouter) return testRouter.location
const location = useTanStackLocation()
return {
...location,
pathname: location.pathname,
search: location.searchStr,
}
}
export const RouterParamsContext = createContext(null)
export function useParams() {
return useContext(RouterParamsContext) ?? useTanStackParams({ strict: false })
}
export function useSearchParams() {
const location = useLocation()
const navigate = useNavigate()
const searchParams = new URLSearchParams(location.searchStr || '')
const setSearchParams = (next, options = {}) => {
const values = next instanceof URLSearchParams ? Object.fromEntries(next) : next
return navigate({
to: location.pathname,
search: values,
...options,
})
}
return [searchParams, setSearchParams]
}