diff --git a/AGENTS.md b/AGENTS.md index c878589..6b64f68 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -25,19 +25,36 @@ src/ ``` pages/page-app/ ├── components/ # 模块专属组件 -├── store/ # 模块状态管理 -└── module/ # 模块功能函数 +├── hooks/ # 模块 React Query hooks(API 查询封装) +├── modules/ # 模块功能函数(UI 组件、工具函数等) +└── store/ # 模块状态管理(Zustand) ``` +### hooks/ 文件夹说明 + +每个模块的 `hooks/` 文件夹用于封装与该模块相关的 React Query hooks: + +- **use-api-query.ts**: 使用 `@tanstack/react-query` 的 `useQuery` 封装 API 调用 + - 定义 `queryKeys` 常量用于缓存标识 + - 封装 `useQuery` hooks 用于数据获取(GET 请求) + - 封装 `useMutation` hooks 用于数据修改(POST/PUT/DELETE 请求) + - 支持预取(prefetch)和无限滚动(infinite query) +- **index.ts**: 导出模块所有 hooks,便于统一导入使用 + ### 状态和数据获取 +- **@tanstack/react-query** 用于数据获取、缓存和状态管理 + - 在模块的 `hooks/` 文件夹中封装 API 调用 + - QueryClient 实例位于 `src/modules/query.ts` + - 在 `src/routes/__root.tsx` 中通过 `QueryClientProvider` 提供 - **Zustand** 用于全局状态管理 -- **@kevisual/query** 用于数据获取(QueryClient 实例位于 `src/modules/query.ts`) +- **@kevisual/query** 用于底层 API 请求封装 - **React Hook Form** 用于表单管理 ## 核心依赖 - **@base-ui/react**: Headless UI 基础组件 +- **@tanstack/react-query**: 数据获取、缓存和状态管理(配合 hooks/ 使用) - **@tanstack/react-router**: 基于 TanStack Router 插件的文件路由 - **class-variance-authority**: 基于变体的样式系统 - **clsx + tailwind-merge**: 通过 `cn()` 提供 className 工具函数 diff --git a/package.json b/package.json index 9ecbd15..64e9588 100644 --- a/package.json +++ b/package.json @@ -13,53 +13,55 @@ "dist" ], "dependencies": { - "@base-ui/react": "^1.2.0", - "@kevisual/router": "0.1.1", - "@tanstack/react-router": "^1.166.7", + "@base-ui/react": "^1.3.0", + "@kevisual/router": "0.1.6", + "@tanstack/react-query": "^5.91.0", + "@tanstack/react-router": "^1.167.4", "@tanstack/react-table": "^8.21.3", "@uiw/react-codemirror": "^4.25.8", "@uiw/react-md-editor": "^4.0.11", - "antd": "^6.3.2", + "antd": "^6.3.3", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.1.1", - "dayjs": "^1.11.19", + "dayjs": "^1.11.20", "eruda": "^3.4.3", "es-toolkit": "^1.45.1", "fuse.js": "^7.1.0", "idb-keyval": "^6.2.2", "lucide-react": "^0.577.0", - "nanoid": "^5.1.6", + "nanoid": "^5.1.7", "next-themes": "^0.4.6", "react": "19.2.4", "react-dom": "19.2.4", "react-hook-form": "^7.71.2", - "react-resizable-panels": "^4.7.2", + "react-resizable-panels": "^4.7.3", "sonner": "^2.0.7", "valtio": "^2.3.1", "zod": "^4.3.6", - "zustand": "^5.0.11" + "zustand": "^5.0.12" }, "devDependencies": { "@kevisual/ai": "^0.0.28", - "@kevisual/api": "^0.0.62", + "@kevisual/api": "^0.0.64", "@kevisual/context": "^0.0.8", "@kevisual/js-filter": "^0.0.6", - "@kevisual/kv-login": "^0.1.17", - "@kevisual/query": "^0.0.53", + "@kevisual/kv-login": "^0.1.18", + "@kevisual/query": "^0.0.54", "@kevisual/types": "^0.0.12", - "@tailwindcss/vite": "^4.2.1", - "@tanstack/react-router-devtools": "^1.166.7", - "@tanstack/router-plugin": "^1.166.7", - "@types/node": "^25.4.0", + "@tailwindcss/vite": "^4.2.2", + "@tanstack/react-router-devtools": "^1.166.9", + "@tanstack/router-plugin": "^1.166.13", + "@types/node": "^25.5.0", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", - "@vitejs/plugin-react": "^5.1.4", + "@vitejs/plugin-react": "^6.0.1", "dotenv": "^17.3.1", "tailwind-merge": "^3.5.0", - "tailwindcss": "^4.2.1", + "tailwindcss": "^4.2.2", "tw-animate-css": "^1.4.0", "typescript": "^5.9.3", - "vite": "v8.0.0-beta.16" + "vite": "v8.0.0", + "vite-plugin-pwa": "^1.2.0" } } diff --git a/public/auth.json b/public/auth.json new file mode 100644 index 0000000..acf0250 --- /dev/null +++ b/public/auth.json @@ -0,0 +1,44 @@ +{ + "metadata": { + "name": "kevisual", + "share": "public" + }, + "registry": "https://kevisual.cn/root/ai/kevisual/frontend/vite-react-template", + "clone": { + ".": { + "enabled": true + } + }, + "syncd": [ + { + "files": [ + "**/*" + ], + "registry": "" + } + ], + "scripts": { + "auth": "ev sync clone -l -i https://kevisual.cn/root/ai/kevisual/frontend/vite-react-template/public/auth.json" + }, + "sync": { + "AGENTS.md": "https://kevisual.cn/root/ai/kevisual/frontend/vite-react-template/AGENTS.md", + "vite.config.ts": "https://kevisual.cn/root/ai/kevisual/frontend/vite-react-template/vite.config.ts", + "src/main.tsx": "https://kevisual.cn/root/ai/kevisual/frontend/vite-react-template/src/main.tsx", + "public/auth.json": "https://kevisual.cn/root/ai/kevisual/frontend/vite-react-template/public/auth.json", + "src/agents/index.ts": "https://kevisual.cn/root/ai/kevisual/frontend/vite-react-template/src/agents/index.ts", + "src/modules/basename.ts": "https://kevisual.cn/root/ai/kevisual/frontend/vite-react-template/src/modules/basename.ts", + "src/modules/query.ts": "https://kevisual.cn/root/ai/kevisual/frontend/vite-react-template/src/modules/query.ts", + "src/routes/demo.tsx": "https://kevisual.cn/root/ai/kevisual/frontend/vite-react-template/src/routes/demo.tsx", + "src/routes/index.tsx": "https://kevisual.cn/root/ai/kevisual/frontend/vite-react-template/src/routes/index.tsx", + "src/routes/login.tsx": "https://kevisual.cn/root/ai/kevisual/frontend/vite-react-template/src/routes/login.tsx", + "src/styles/theme.css": "https://kevisual.cn/root/ai/kevisual/frontend/vite-react-template/src/styles/theme.css", + "src/pages/auth/index.tsx": "https://kevisual.cn/root/ai/kevisual/frontend/vite-react-template/src/pages/auth/index.tsx", + "src/pages/auth/page.tsx": "https://kevisual.cn/root/ai/kevisual/frontend/vite-react-template/src/pages/auth/page.tsx", + "src/pages/auth/store.ts": "https://kevisual.cn/root/ai/kevisual/frontend/vite-react-template/src/pages/auth/store.ts", + "src/pages/demo/page.tsx": "https://kevisual.cn/root/ai/kevisual/frontend/vite-react-template/src/pages/demo/page.tsx", + "src/pages/auth/hooks/index.ts": "https://kevisual.cn/root/ai/kevisual/frontend/vite-react-template/src/pages/auth/hooks/index.ts", + "src/pages/auth/hooks/use-api-query.ts": "https://kevisual.cn/root/ai/kevisual/frontend/vite-react-template/src/pages/auth/hooks/use-api-query.ts", + "src/pages/auth/modules/BaseHeader.tsx": "https://kevisual.cn/root/ai/kevisual/frontend/vite-react-template/src/pages/auth/modules/BaseHeader.tsx", + "src/pages/demo/store/index.ts": "https://kevisual.cn/root/ai/kevisual/frontend/vite-react-template/src/pages/demo/store/index.ts" + } +} \ No newline at end of file diff --git a/src/modules/basename.ts b/src/modules/basename.ts index 0516610..4792606 100644 --- a/src/modules/basename.ts +++ b/src/modules/basename.ts @@ -19,4 +19,14 @@ export const getDynamicBasename = (): string => { } // 默认使用构建时的 basename return basename +} + +export const openLink = (path: string, target: string = '_self') => { + if (path.startsWith('http://') || path.startsWith('https://')) { + window.open(path, target); + return; + } + const url = new URL(path, window.location.origin); + url.pathname = wrapBasename(url.pathname); + window.open(url.toString(), target); } \ No newline at end of file diff --git a/src/modules/query.ts b/src/modules/query.ts index bc14351..b45efe0 100644 --- a/src/modules/query.ts +++ b/src/modules/query.ts @@ -1,20 +1,18 @@ -import { Query } from '@kevisual/query'; +import { Query, DataOpts } from '@kevisual/query'; import { QueryLoginBrowser } from '@kevisual/api/query-login' import { useContextKey } from '@kevisual/context'; -export const query = useContextKey('query', () => { - return new Query({ - url: '/api/router', - }); -}); +import { QueryClient } from '@tanstack/react-query'; -export const queryClient = useContextKey('queryClient', () => { - return new Query({ - url: '/client/router', - }); -}); +export const query = useContextKey('query', new Query({ + url: '/api/router', +})); -export const queryLogin = useContextKey('queryLogin', () => { - return new QueryLoginBrowser({ - query: query - }); -}); \ No newline at end of file +export const queryClient = useContextKey('queryClient', new Query({ + url: '/client/router', +})); + +export const queryLogin = useContextKey('queryLogin', new QueryLoginBrowser({ + query: query +})); + +export const stackQueryClient = useContextKey('stackQueryClient', new QueryClient()); \ No newline at end of file diff --git a/src/pages/auth/hooks/index.ts b/src/pages/auth/hooks/index.ts new file mode 100644 index 0000000..660cd77 --- /dev/null +++ b/src/pages/auth/hooks/index.ts @@ -0,0 +1 @@ +export * from './use-api-query'; diff --git a/src/pages/auth/hooks/use-api-query.ts b/src/pages/auth/hooks/use-api-query.ts new file mode 100644 index 0000000..44302c2 --- /dev/null +++ b/src/pages/auth/hooks/use-api-query.ts @@ -0,0 +1,55 @@ +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { queryLogin } from '@/modules/query'; +import { toast } from 'sonner'; +import type { UserInfo } from '../store'; + +export const authQueryKeys = { + me: ['auth', 'me'] as const, + token: ['auth', 'token'] as const, +} as const; + +export const useMe = () => { + return useQuery({ + queryKey: authQueryKeys.me, + queryFn: async () => { + const res = await queryLogin.getMe(); + if (res.code === 200) { + return res.data; + } + throw new Error(res.message || 'Failed to fetch user info'); + }, + staleTime: 1000 * 60 * 5, // 5 minutes + }); +}; + +export const useSwitchOrg = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async (username?: string) => { + const res = await queryLogin.switchUser(username || ''); + if (res.code === 200) { + return res.data; + } + throw new Error(res.message || 'Switch failed'); + }, + onSuccess: () => { + toast.success('切换成功'); + queryClient.invalidateQueries({ queryKey: authQueryKeys.me }); + setTimeout(() => { + window.location.reload(); + }, 1000); + }, + onError: (error) => { + toast.error(error.message || '请求失败'); + }, + }); +}; + +export const useGetToken = () => { + return useQuery({ + queryKey: authQueryKeys.token, + queryFn: () => queryLogin.getToken(), + staleTime: Infinity, + }); +}; diff --git a/src/pages/auth/index.tsx b/src/pages/auth/index.tsx index ac827c7..abc6ff5 100644 --- a/src/pages/auth/index.tsx +++ b/src/pages/auth/index.tsx @@ -6,7 +6,6 @@ export { BaseHeader } from './modules/BaseHeader' import { useMemo } from 'react'; import { useLocation, useNavigate } from '@tanstack/react-router'; - type Props = { children?: React.ReactNode, mustLogin?: boolean, diff --git a/src/pages/auth/modules/BaseHeader.tsx b/src/pages/auth/modules/BaseHeader.tsx index 551ad39..3136210 100644 --- a/src/pages/auth/modules/BaseHeader.tsx +++ b/src/pages/auth/modules/BaseHeader.tsx @@ -83,7 +83,6 @@ export const BaseHeader = (props: { main?: React.ComponentType | null }) => { {meInfo} -
) } diff --git a/src/pages/auth/store.ts b/src/pages/auth/store.ts index c6015c1..b5c1d09 100644 --- a/src/pages/auth/store.ts +++ b/src/pages/auth/store.ts @@ -1,8 +1,9 @@ -import { queryLogin } from '@/modules/query'; +import { queryLogin, stackQueryClient } from '@/modules/query'; import { create } from 'zustand'; import { toast } from 'sonner'; -type UserInfo = { +import { authQueryKeys } from './hooks'; +export type UserInfo = { id?: string; username?: string; nickname?: string | null; @@ -37,6 +38,9 @@ export type LayoutStore = { setLinks: (links: HeaderLink[]) => void; showBaseHeader: boolean; setShowBaseHeader: (showBaseHeader: boolean) => void; + serverData: Record | null; + setServerData: (data: Record) => void; + initConvex: () => Promise; }; type HeaderLink = { title?: string; @@ -56,19 +60,25 @@ export const useLayoutStore = create((set, get) => ({ setMe: (me) => set({ me }), clearMe: () => { set({ me: undefined, isAdmin: false }); - window.location.href = '/root/login/?redirect=' + encodeURIComponent(window.location.href); }, getMe: async () => { - const res = await queryLogin.getMe(); - if (res.code === 200) { - set({ me: res.data }); - set({ isAdmin: res.data.orgs?.includes?.('admin') || false }); - } + const data = await stackQueryClient.fetchQuery({ + queryKey: authQueryKeys.me, + queryFn: async () => { + const res = await queryLogin.getMe(); + if (res.code === 200) { + return res.data; + } + throw new Error(res.message || 'Failed to fetch user info'); + }, + }); + set({ me: data, isAdmin: data?.orgs?.includes?.('admin') || false }); }, switchOrg: async (username?: string) => { const res = await queryLogin.switchUser(username || ''); if (res.code === 200) { toast.success('切换成功'); + stackQueryClient.invalidateQueries({ queryKey: authQueryKeys.me }); setTimeout(() => { window.location.reload(); }, 1000); @@ -79,20 +89,32 @@ export const useLayoutStore = create((set, get) => ({ isAdmin: false, setIsAdmin: (isAdmin) => set({ isAdmin }), init: async () => { - const token = await queryLogin.getToken(); + await queryLogin.init(); + const token = await queryLogin.checkLocalToken(); if (token) { - set({ me: {} }) - const me = await queryLogin.getMe(); - // const user = await queryLogin.checkLocalUser() as UserInfo; - const user = me.code === 200 ? me.data : undefined; - if (user) { - set({ me: user }); - set({ isAdmin: user.orgs?.includes?.('admin') || false }); - } else { + set({ me: {} }); + try { + // const data = await stackQueryClient.fetchQuery({ + // queryKey: authQueryKeys.me, + // }) as UserInfo; + const userInfo = await queryLogin.checkLocalUser(); + if (userInfo) { + set({ me: userInfo as UserInfo, isAdmin: userInfo.orgs?.includes?.('admin') || false }); + } else { + set({ me: undefined, isAdmin: false }); + } + } catch { set({ me: undefined, isAdmin: false }); } } + // 获取服务端数据 + // @ts-ignore + const sererData = window.__SERVER_DATA__; + if (sererData) { + set({ serverData: sererData }); + } }, + initConvex: async () => { }, openLinkList: ['/login'], setOpenLinkList: (openLinkList) => set({ openLinkList }), loginPageConfig: { @@ -107,4 +129,6 @@ export const useLayoutStore = create((set, get) => ({ setLinks: (links) => set({ links }), showBaseHeader: true, setShowBaseHeader: (showBaseHeader) => set({ showBaseHeader }), + serverData: null, + setServerData: (data) => set({ serverData: data }), })); diff --git a/src/pages/demo/page.tsx b/src/pages/demo/page.tsx new file mode 100644 index 0000000..33e13e5 --- /dev/null +++ b/src/pages/demo/page.tsx @@ -0,0 +1,8 @@ +import { useDemoStore } from './store/index' +export const App = () => { + const demoStore = useDemoStore() + console.log('demo', demoStore.formData) + return
App
+} + +export default App; \ No newline at end of file diff --git a/src/pages/demo/store/index.ts b/src/pages/demo/store/index.ts new file mode 100644 index 0000000..8c66c73 --- /dev/null +++ b/src/pages/demo/store/index.ts @@ -0,0 +1,95 @@ +import { create } from 'zustand'; +import { query } from '@/modules/query'; +import { toast } from 'sonner'; + +interface Data { + id: string; + [key: string]: any; +} + +type State = { + formData: Record; + setFormData: (data: Record) => void; + showEdit: boolean; + setShowEdit: (showEdit: boolean) => void; + loading: boolean; + setLoading: (loading: boolean) => void; + list: Data[]; + getItem: (id: string) => Promise; + getList: () => Promise; + updateData: (data: Data) => Promise; + deleteData: (id: string) => Promise; +} + +export const useDemoStore = create((set, get) => { + return { + formData: {}, + setFormData: (data) => set({ formData: data }), + showEdit: false, + setShowEdit: (showEdit) => set({ showEdit }), + loading: false, + setLoading: (loading) => set({ loading }), + list: [], + getItem: async (id) => { + const { setLoading } = get(); + setLoading(true); + try { + const res = await query.post({ + path: 'demo', + key: 'item', + data: { id } + }) + if (res.code === 200) { + return res; + } else { + toast.error(res.message || '请求失败'); + } + } finally { + setLoading(false); + } + }, + getList: async () => { + const { setLoading } = get(); + setLoading(true); + try { + const res = await query.post({ + path: 'demo', + key: 'list' + }); + if (res.code === 200) { + const list = res.data?.list || [] + set({ list }); + } else { + toast.error(res.message || '请求失败'); + } + return res; + } finally { + setLoading(false); + } + }, + updateData: async (data) => { + const res = await query.post({ + path: 'demo', + key: 'update', + data + }) + if (res.code === 200) { + get().getList() + } else { + toast.error(res.message || '请求失败'); + } + }, + deleteData: async (id) => { + const res = await query.post({ + path: 'demo', + key: 'delete', + data: { id } + }) + if (res.code === 200) { + get().getList() + } else { + toast.error(res.message || '请求失败'); + } + } + } +}) \ No newline at end of file diff --git a/src/routeTree.gen.ts b/src/routeTree.gen.ts index e00082d..a0d5d7e 100644 --- a/src/routeTree.gen.ts +++ b/src/routeTree.gen.ts @@ -11,6 +11,7 @@ import { Route as rootRouteImport } from './routes/__root' import { Route as ViewRouteImport } from './routes/view' import { Route as LoginRouteImport } from './routes/login' +import { Route as DemoRouteImport } from './routes/demo' import { Route as ConsoleRouteImport } from './routes/console' import { Route as IdRouteImport } from './routes/$id' import { Route as IndexRouteImport } from './routes/index' @@ -25,6 +26,11 @@ const LoginRoute = LoginRouteImport.update({ path: '/login', getParentRoute: () => rootRouteImport, } as any) +const DemoRoute = DemoRouteImport.update({ + id: '/demo', + path: '/demo', + getParentRoute: () => rootRouteImport, +} as any) const ConsoleRoute = ConsoleRouteImport.update({ id: '/console', path: '/console', @@ -45,6 +51,7 @@ export interface FileRoutesByFullPath { '/': typeof IndexRoute '/$id': typeof IdRoute '/console': typeof ConsoleRoute + '/demo': typeof DemoRoute '/login': typeof LoginRoute '/view': typeof ViewRoute } @@ -52,6 +59,7 @@ export interface FileRoutesByTo { '/': typeof IndexRoute '/$id': typeof IdRoute '/console': typeof ConsoleRoute + '/demo': typeof DemoRoute '/login': typeof LoginRoute '/view': typeof ViewRoute } @@ -60,21 +68,23 @@ export interface FileRoutesById { '/': typeof IndexRoute '/$id': typeof IdRoute '/console': typeof ConsoleRoute + '/demo': typeof DemoRoute '/login': typeof LoginRoute '/view': typeof ViewRoute } export interface FileRouteTypes { fileRoutesByFullPath: FileRoutesByFullPath - fullPaths: '/' | '/$id' | '/console' | '/login' | '/view' + fullPaths: '/' | '/$id' | '/console' | '/demo' | '/login' | '/view' fileRoutesByTo: FileRoutesByTo - to: '/' | '/$id' | '/console' | '/login' | '/view' - id: '__root__' | '/' | '/$id' | '/console' | '/login' | '/view' + to: '/' | '/$id' | '/console' | '/demo' | '/login' | '/view' + id: '__root__' | '/' | '/$id' | '/console' | '/demo' | '/login' | '/view' fileRoutesById: FileRoutesById } export interface RootRouteChildren { IndexRoute: typeof IndexRoute IdRoute: typeof IdRoute ConsoleRoute: typeof ConsoleRoute + DemoRoute: typeof DemoRoute LoginRoute: typeof LoginRoute ViewRoute: typeof ViewRoute } @@ -95,6 +105,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof LoginRouteImport parentRoute: typeof rootRouteImport } + '/demo': { + id: '/demo' + path: '/demo' + fullPath: '/demo' + preLoaderRoute: typeof DemoRouteImport + parentRoute: typeof rootRouteImport + } '/console': { id: '/console' path: '/console' @@ -123,6 +140,7 @@ const rootRouteChildren: RootRouteChildren = { IndexRoute: IndexRoute, IdRoute: IdRoute, ConsoleRoute: ConsoleRoute, + DemoRoute: DemoRoute, LoginRoute: LoginRoute, ViewRoute: ViewRoute, } diff --git a/src/routes/demo.tsx b/src/routes/demo.tsx new file mode 100644 index 0000000..773433d --- /dev/null +++ b/src/routes/demo.tsx @@ -0,0 +1,9 @@ +import { createFileRoute } from '@tanstack/react-router' +import App from '@/pages/demo/page' +export const Route = createFileRoute('/demo')({ + component: RouteComponent, +}) + +function RouteComponent() { + return +} diff --git a/vite.config.ts b/vite.config.ts index 813a859..45c0e9c 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -4,11 +4,14 @@ import path from 'path'; import pkgs from './package.json'; import tailwindcss from '@tailwindcss/vite'; import { tanstackRouter } from '@tanstack/router-plugin/vite' -import 'dotenv/config' -const isDev = process.env.NODE_ENV === 'development'; -const basename = isDev ? '/' : pkgs?.basename || '/'; -let target = process.env.VITE_API_URL || 'http://localhost:51515'; +import dotenv from 'dotenv'; +import { VitePWA } from 'vite-plugin-pwa'; +const env = dotenv.config().parsed || {}; +const isDev = env.NODE_ENV === 'development' || process.env.NODE_ENV === 'development'; +const basename = isDev ? '/' : pkgs?.basename || '/'; + +let target = env.VITE_API_URL || process.env.API_URL || 'http://localhost:51515'; const apiProxy = { target: target, changeOrigin: true, ws: true, rewriteWsOrigin: true, secure: false, cookieDomainRewrite: 'localhost' }; let proxy = { '/root/': apiProxy, @@ -26,7 +29,10 @@ export default defineConfig({ autoCodeSplitting: true, }), react(), - tailwindcss() + tailwindcss(), + VitePWA({ + injectRegister: 'auto', + }), ], resolve: { alias: {