Compare commits
48 Commits
aadf2acebe
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c8955e684f | ||
|
|
9d4b3f013a | ||
|
|
02925f69f4 | ||
|
|
bfcb5b6004 | ||
| 1c86787219 | |||
| 942c67357d | |||
| ef9d98c090 | |||
| 75ea380570 | |||
| a5c3b6e4e0 | |||
| b0bec742ca | |||
| a316009234 | |||
| 91abdac399 | |||
| 700f8177fe | |||
| 37914d21f0 | |||
| ac51f1dc50 | |||
| 5a07ba8a2d | |||
| 5fe3e7e1dc | |||
| 66245c149b | |||
| 1a6c8524d3 | |||
| ec089206f1 | |||
| 66defedf4c | |||
| dc95e1ec60 | |||
| d0e47e2d87 | |||
| 2d90ace92e | |||
| ac958a2a68 | |||
| cf3fe611eb | |||
| bdac089a85 | |||
| 8aad3bcb9b | |||
| 0be8c66754 | |||
| 341e2331a0 | |||
| fcd914b3c2 | |||
| c1df9eb5d4 | |||
| 3630144fb1 | |||
| 26f8d24b89 | |||
| ce38fd6dad | |||
| 8349e1ca4c | |||
| 605d833841 | |||
| acf18e42bd | |||
| 2cb404846e | |||
| b2a7b66f21 | |||
| abe6c8b71c | |||
| 9ea7ee18a9 | |||
| 5d7068f55e | |||
| 1ffb3cabaa | |||
| 89777fb2b0 | |||
| 76d991cfec | |||
| 0b9a9ea89c | |||
| 9c1d625ad2 |
20
.cnb.yml
20
.cnb.yml
@@ -4,8 +4,7 @@ include:
|
|||||||
|
|
||||||
.common_env: &common_env
|
.common_env: &common_env
|
||||||
env:
|
env:
|
||||||
TO_REPO: template/vite-react-template
|
USERNAME: root
|
||||||
TO_URL: git.xiongxiao.me
|
|
||||||
imports:
|
imports:
|
||||||
- https://cnb.cool/kevisual/env/-/blob/main/.env.development
|
- https://cnb.cool/kevisual/env/-/blob/main/.env.development
|
||||||
|
|
||||||
@@ -16,21 +15,6 @@ $:
|
|||||||
services:
|
services:
|
||||||
- vscode
|
- vscode
|
||||||
- docker
|
- docker
|
||||||
|
env: !reference [.common_env, env]
|
||||||
imports: !reference [.common_env, imports]
|
imports: !reference [.common_env, imports]
|
||||||
stages: !reference [.dev_template, stages]
|
stages: !reference [.dev_template, stages]
|
||||||
|
|
||||||
.common_sync_to_gitea: &common_sync_to_gitea
|
|
||||||
- <<: *common_env
|
|
||||||
services: !reference [.common_sync_to_gitea_template, services]
|
|
||||||
stages: !reference [.common_sync_to_gitea_template, stages]
|
|
||||||
|
|
||||||
.common_sync_from_gitea: &common_sync_from_gitea
|
|
||||||
- <<: *common_env
|
|
||||||
services: !reference [.common_sync_from_gitea_template, services]
|
|
||||||
stages: !reference [.common_sync_from_gitea_template, stages]
|
|
||||||
|
|
||||||
main:
|
|
||||||
api_trigger_sync_to_gitea:
|
|
||||||
- <<: *common_sync_to_gitea
|
|
||||||
api_trigger_sync_from_gitea:
|
|
||||||
- <<: *common_sync_from_gitea
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
## 本地环境
|
|
||||||
|
|
||||||
# VITE_API_URL = "http://localhost:8000"
|
|
||||||
### 开发环境
|
|
||||||
VITE_API_URL = "https://kevisual.xiongxiao.me"
|
|
||||||
### 生产环境
|
|
||||||
# VITE_API_URL = "https://kevisual.cn"
|
|
||||||
24
.gitignore
vendored
24
.gitignore
vendored
@@ -1,37 +1,19 @@
|
|||||||
# Logs
|
# Logs
|
||||||
logs
|
logs
|
||||||
*.log
|
*.log
|
||||||
.env
|
|
||||||
npm-debug.log*
|
|
||||||
yarn-debug.log*
|
|
||||||
yarn-error.log*
|
|
||||||
pnpm-debug.log*
|
|
||||||
lerna-debug.log*
|
|
||||||
|
|
||||||
node_modules
|
node_modules
|
||||||
dist
|
dist
|
||||||
dist-ssr
|
pack-dist
|
||||||
*.local
|
|
||||||
|
|
||||||
# Editor directories and files
|
|
||||||
.vscode/*
|
|
||||||
!.vscode/extensions.json
|
|
||||||
.idea
|
|
||||||
.DS_Store
|
.DS_Store
|
||||||
*.suo
|
|
||||||
*.ntvs*
|
|
||||||
*.njsproj
|
|
||||||
*.sln
|
|
||||||
*.sw?
|
|
||||||
|
|
||||||
tsconfig.app.tsbuildinfo
|
|
||||||
tsconfig.node.tsbuildinfo
|
|
||||||
|
|
||||||
.turbo
|
.turbo
|
||||||
|
|
||||||
.pnpm-store
|
.pnpm-store
|
||||||
|
|
||||||
.tanstack
|
.tanstack
|
||||||
.env
|
.env*
|
||||||
|
|
||||||
!.env.example
|
!.env.example
|
||||||
|
.pnpm-lock.yaml
|
||||||
3
.npmrc
3
.npmrc
@@ -1,3 +0,0 @@
|
|||||||
//npm.xiongxiao.me/:_authToken=${ME_NPM_TOKEN}
|
|
||||||
//registry.npmjs.org/:_authToken=${NPM_TOKEN}
|
|
||||||
ignore-workspace-root-check=true
|
|
||||||
30
AGENTS.md
30
AGENTS.md
@@ -25,19 +25,36 @@ src/
|
|||||||
```
|
```
|
||||||
pages/page-app/
|
pages/page-app/
|
||||||
├── components/ # 模块专属组件
|
├── components/ # 模块专属组件
|
||||||
├── store/ # 模块状态管理
|
├── hooks/ # 模块 React Query hooks(API 查询封装)
|
||||||
└── module/ # 模块功能函数
|
├── 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** 用于全局状态管理
|
- **Zustand** 用于全局状态管理
|
||||||
- **@kevisual/query** 用于数据获取(QueryClient 实例位于 `src/modules/query.ts`)
|
- **@kevisual/query** 用于底层 API 请求封装
|
||||||
- **React Hook Form** 用于表单管理
|
- **React Hook Form** 用于表单管理
|
||||||
|
|
||||||
## 核心依赖
|
## 核心依赖
|
||||||
|
|
||||||
- **@base-ui/react**: Headless UI 基础组件
|
- **@base-ui/react**: Headless UI 基础组件
|
||||||
|
- **@tanstack/react-query**: 数据获取、缓存和状态管理(配合 hooks/ 使用)
|
||||||
- **@tanstack/react-router**: 基于 TanStack Router 插件的文件路由
|
- **@tanstack/react-router**: 基于 TanStack Router 插件的文件路由
|
||||||
- **class-variance-authority**: 基于变体的样式系统
|
- **class-variance-authority**: 基于变体的样式系统
|
||||||
- **clsx + tailwind-merge**: 通过 `cn()` 提供 className 工具函数
|
- **clsx + tailwind-merge**: 通过 `cn()` 提供 className 工具函数
|
||||||
@@ -46,3 +63,10 @@ pages/page-app/
|
|||||||
- **sonner**: Toast 通知
|
- **sonner**: Toast 通知
|
||||||
- **zustand**: 状态管理
|
- **zustand**: 状态管理
|
||||||
- **tailwindcss v4**: 使用 @tailwindcss/vite 插件进行样式处理
|
- **tailwindcss v4**: 使用 @tailwindcss/vite 插件进行样式处理
|
||||||
|
|
||||||
|
## 主题系统
|
||||||
|
|
||||||
|
- **主题配色**: 采用黑白配色方案,提供简洁优雅的视觉体验
|
||||||
|
- **主题模式**: 支持 light(浅色)和 dark(深色)模式切换
|
||||||
|
- **主题实现**: 使用 `next-themes` 进行主题管理
|
||||||
|
- **CSS 变量**: 主题相关的 CSS 变量定义在 `src/styles/theme.css` 中
|
||||||
12
README.md
12
README.md
@@ -1 +1,13 @@
|
|||||||
# vite-react-template
|
# vite-react-template
|
||||||
|
|
||||||
|
## download template
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ev sync clone -i https://kevisual.cn/root/ai/kevisual/frontend/vite-react-template
|
||||||
|
```
|
||||||
|
|
||||||
|
## clone auth update
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ev sync clone -l -i https://kevisual.cn/root/ai/kevisual/frontend/vite-react-template/public/auth.json
|
||||||
|
```
|
||||||
21
kevisual.json
Normal file
21
kevisual.json
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"metadata": {
|
||||||
|
"name": "kevisual",
|
||||||
|
"share": "public"
|
||||||
|
},
|
||||||
|
"registry": "https://kevisual.cn/root/ai/kevisual/frontend/vite-react-template",
|
||||||
|
"clone": {
|
||||||
|
".": {
|
||||||
|
"enabled": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"syncd": [
|
||||||
|
{
|
||||||
|
"files": [
|
||||||
|
"**/*"
|
||||||
|
],
|
||||||
|
"registry": ""
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"sync": {}
|
||||||
|
}
|
||||||
58
package.json
58
package.json
@@ -3,12 +3,12 @@
|
|||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.0.1",
|
"version": "0.0.1",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"basename": "/",
|
"basename": "/root/vite-react",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"ui": "pnpm dlx shadcn@latest add ",
|
"ui": "bunx shadcn@latest add ",
|
||||||
"pub": "envision deploy ./dist -k vite-react -v 0.0.1 -y y -u"
|
"pub": "envision deploy ./dist -k vite-react -v 0.0.1 -y y -u"
|
||||||
},
|
},
|
||||||
"files": [
|
"files": [
|
||||||
@@ -17,44 +17,50 @@
|
|||||||
"author": "abearxiong <xiongxiao@xiongxiao.me>",
|
"author": "abearxiong <xiongxiao@xiongxiao.me>",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@base-ui/react": "^1.1.0",
|
"@base-ui/react": "^1.3.0",
|
||||||
"@kevisual/api": "^0.0.47",
|
"@kevisual/api": "^0.0.65",
|
||||||
"@kevisual/context": "^0.0.4",
|
"@kevisual/context": "^0.0.8",
|
||||||
"@kevisual/router": "0.0.70",
|
"@kevisual/router": "0.1.6",
|
||||||
"@tanstack/react-router": "^1.158.4",
|
"@tanstack/react-query": "^5.91.3",
|
||||||
|
"@tanstack/react-router": "^1.168.1",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"cmdk": "^1.1.1",
|
"cmdk": "^1.1.1",
|
||||||
"dayjs": "^1.11.19",
|
"convex": "^1.34.0",
|
||||||
"es-toolkit": "^1.44.0",
|
"dayjs": "^1.11.20",
|
||||||
"lucide-react": "^0.563.0",
|
"es-toolkit": "^1.45.1",
|
||||||
"nanoid": "^5.1.6",
|
"fuse.js": "^7.1.0",
|
||||||
|
"lucide-react": "^0.577.0",
|
||||||
|
"nanoid": "^5.1.7",
|
||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
"react": "^19.2.4",
|
"react": "^19.2.4",
|
||||||
"react-dom": "^19.2.4",
|
"react-dom": "^19.2.4",
|
||||||
"react-hook-form": "^7.71.1",
|
"react-hook-form": "^7.71.2",
|
||||||
"sonner": "^2.0.7",
|
"sonner": "^2.0.7",
|
||||||
"zustand": "^5.0.11"
|
"zustand": "^5.0.12"
|
||||||
},
|
},
|
||||||
"publishConfig": {
|
"publishConfig": {
|
||||||
"access": "public"
|
"access": "public"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@kevisual/query": "0.0.39",
|
"@kevisual/ai": "0.0.28",
|
||||||
|
"@kevisual/kv-login": "^0.1.18",
|
||||||
|
"@kevisual/query": "0.0.55",
|
||||||
"@kevisual/types": "^0.0.12",
|
"@kevisual/types": "^0.0.12",
|
||||||
"@tailwindcss/vite": "^4.1.18",
|
"@kevisual/vite-html-plugin": "^0.0.1",
|
||||||
"@tanstack/react-router-devtools": "^1.158.4",
|
"@tailwindcss/vite": "^4.2.2",
|
||||||
"@tanstack/router-plugin": "^1.158.4",
|
"@tanstack/react-router-devtools": "^1.166.10",
|
||||||
"@types/node": "^25.2.2",
|
"@tanstack/router-plugin": "^1.167.2",
|
||||||
"@types/react": "^19.2.13",
|
"@types/node": "^25.5.0",
|
||||||
|
"@types/react": "^19.2.14",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
"@vitejs/plugin-react": "^5.1.3",
|
"@vitejs/plugin-react": "^6.0.1",
|
||||||
"dotenv": "^17.2.4",
|
"dotenv": "^17.3.1",
|
||||||
"tailwind-merge": "^3.4.0",
|
"tailwind-merge": "^3.5.0",
|
||||||
"tailwindcss": "^4.1.18",
|
"tailwindcss": "^4.2.2",
|
||||||
"tw-animate-css": "^1.4.0",
|
"tw-animate-css": "^1.4.0",
|
||||||
"typescript": "^5.9.3",
|
"typescript": "^5.9.3",
|
||||||
"vite": "v8.0.0-beta.13"
|
"vite": "v8.0.1",
|
||||||
},
|
"vite-plugin-pwa": "^1.2.0"
|
||||||
"packageManager": "pnpm@10.29.1"
|
}
|
||||||
}
|
}
|
||||||
4222
pnpm-lock.yaml
generated
4222
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
67
public/auth.json
Normal file
67
public/auth.json
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
{
|
||||||
|
"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",
|
||||||
|
"tsconfig.json": "https://kevisual.cn/root/ai/kevisual/frontend/vite-react-template/tsconfig.json",
|
||||||
|
"vite.config.ts": "https://kevisual.cn/root/ai/kevisual/frontend/vite-react-template/vite.config.ts",
|
||||||
|
"public/auth.json": "https://kevisual.cn/root/ai/kevisual/frontend/vite-react-template/public/auth.json",
|
||||||
|
"public/demo.html": "https://kevisual.cn/root/ai/kevisual/frontend/vite-react-template/public/demo.html",
|
||||||
|
"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/components/a/PWAUpdate.tsx": "https://kevisual.cn/root/ai/kevisual/frontend/vite-react-template/src/components/a/PWAUpdate.tsx",
|
||||||
|
"src/components/a/Sidebar.tsx": "https://kevisual.cn/root/ai/kevisual/frontend/vite-react-template/src/components/a/Sidebar.tsx",
|
||||||
|
"src/components/ui/badge.tsx": "https://kevisual.cn/root/ai/kevisual/frontend/vite-react-template/src/components/ui/badge.tsx",
|
||||||
|
"src/components/ui/breadcrumb.tsx": "https://kevisual.cn/root/ai/kevisual/frontend/vite-react-template/src/components/ui/breadcrumb.tsx",
|
||||||
|
"src/components/ui/button.tsx": "https://kevisual.cn/root/ai/kevisual/frontend/vite-react-template/src/components/ui/button.tsx",
|
||||||
|
"src/components/ui/card.tsx": "https://kevisual.cn/root/ai/kevisual/frontend/vite-react-template/src/components/ui/card.tsx",
|
||||||
|
"src/components/ui/checkbox.tsx": "https://kevisual.cn/root/ai/kevisual/frontend/vite-react-template/src/components/ui/checkbox.tsx",
|
||||||
|
"src/components/ui/command.tsx": "https://kevisual.cn/root/ai/kevisual/frontend/vite-react-template/src/components/ui/command.tsx",
|
||||||
|
"src/components/ui/dialog.tsx": "https://kevisual.cn/root/ai/kevisual/frontend/vite-react-template/src/components/ui/dialog.tsx",
|
||||||
|
"src/components/ui/dropdown-menu.tsx": "https://kevisual.cn/root/ai/kevisual/frontend/vite-react-template/src/components/ui/dropdown-menu.tsx",
|
||||||
|
"src/components/ui/input-group.tsx": "https://kevisual.cn/root/ai/kevisual/frontend/vite-react-template/src/components/ui/input-group.tsx",
|
||||||
|
"src/components/ui/input.tsx": "https://kevisual.cn/root/ai/kevisual/frontend/vite-react-template/src/components/ui/input.tsx",
|
||||||
|
"src/components/ui/kbd.tsx": "https://kevisual.cn/root/ai/kevisual/frontend/vite-react-template/src/components/ui/kbd.tsx",
|
||||||
|
"src/components/ui/label.tsx": "https://kevisual.cn/root/ai/kevisual/frontend/vite-react-template/src/components/ui/label.tsx",
|
||||||
|
"src/components/ui/menubar.tsx": "https://kevisual.cn/root/ai/kevisual/frontend/vite-react-template/src/components/ui/menubar.tsx",
|
||||||
|
"src/components/ui/popover.tsx": "https://kevisual.cn/root/ai/kevisual/frontend/vite-react-template/src/components/ui/popover.tsx",
|
||||||
|
"src/components/ui/select.tsx": "https://kevisual.cn/root/ai/kevisual/frontend/vite-react-template/src/components/ui/select.tsx",
|
||||||
|
"src/components/ui/sheet.tsx": "https://kevisual.cn/root/ai/kevisual/frontend/vite-react-template/src/components/ui/sheet.tsx",
|
||||||
|
"src/components/ui/skeleton.tsx": "https://kevisual.cn/root/ai/kevisual/frontend/vite-react-template/src/components/ui/skeleton.tsx",
|
||||||
|
"src/components/ui/sonner.tsx": "https://kevisual.cn/root/ai/kevisual/frontend/vite-react-template/src/components/ui/sonner.tsx",
|
||||||
|
"src/components/ui/tabs.tsx": "https://kevisual.cn/root/ai/kevisual/frontend/vite-react-template/src/components/ui/tabs.tsx",
|
||||||
|
"src/components/ui/textarea.tsx": "https://kevisual.cn/root/ai/kevisual/frontend/vite-react-template/src/components/ui/textarea.tsx",
|
||||||
|
"src/components/ui/tooltip.tsx": "https://kevisual.cn/root/ai/kevisual/frontend/vite-react-template/src/components/ui/tooltip.tsx",
|
||||||
|
"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/agents/app.ts": "https://kevisual.cn/root/ai/kevisual/frontend/vite-react-template/src/agents/app.ts",
|
||||||
|
"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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,3 +1,3 @@
|
|||||||
import { QueryRouterServer } from "@kevisual/router/browser"
|
import { QueryRouterServer } from "@kevisual/router/browser"
|
||||||
import { useContextKey } from '@kevisual/context'
|
import { useContextKey } from '@kevisual/context'
|
||||||
export const app = useContextKey('router', new QueryRouterServer())
|
export const app = useContextKey('app', new QueryRouterServer())
|
||||||
60
src/components/a/PWAUpdate.tsx
Normal file
60
src/components/a/PWAUpdate.tsx
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { useRegisterSW } from 'virtual:pwa-register/react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardFooter,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from '@/components/ui/card';
|
||||||
|
|
||||||
|
function PWAUpdate() {
|
||||||
|
const {
|
||||||
|
needRefresh: [needRefresh, setNeedRefresh],
|
||||||
|
updateServiceWorker,
|
||||||
|
} = useRegisterSW({
|
||||||
|
onNeedRefresh() {
|
||||||
|
setNeedRefresh(true);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
|
const handleUpdate = async () => {
|
||||||
|
setIsLoading(true);
|
||||||
|
await updateServiceWorker(true);
|
||||||
|
setIsLoading(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDismiss = () => {
|
||||||
|
setNeedRefresh(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!needRefresh) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed bottom-4 right-4 z-50">
|
||||||
|
<Card className="w-80 shadow-lg">
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<CardTitle>发现新版本</CardTitle>
|
||||||
|
<CardDescription>有新版本可用,点击立即更新</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="pt-0" />
|
||||||
|
<CardFooter className="gap-2">
|
||||||
|
<Button variant="outline" size="sm" onClick={handleDismiss}>
|
||||||
|
稍后
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" onClick={handleUpdate} disabled={isLoading}>
|
||||||
|
{isLoading ? '更新中...' : '立即更新'}
|
||||||
|
</Button>
|
||||||
|
</CardFooter>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default PWAUpdate;
|
||||||
281
src/components/a/Sidebar.tsx
Normal file
281
src/components/a/Sidebar.tsx
Normal file
@@ -0,0 +1,281 @@
|
|||||||
|
'use client'
|
||||||
|
import { useNavigate, useLocation } from '@tanstack/react-router'
|
||||||
|
import { useState } from 'react'
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogFooter,
|
||||||
|
} from '@/components/ui/dialog'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
import { ChevronLeft, ChevronRight, ChevronDown } from 'lucide-react'
|
||||||
|
import { Resizable } from 're-resizable'
|
||||||
|
|
||||||
|
export interface NavItem {
|
||||||
|
title: string
|
||||||
|
path: string
|
||||||
|
icon?: React.ReactNode
|
||||||
|
isDeveloping?: boolean
|
||||||
|
badge?: string
|
||||||
|
hidden?: boolean
|
||||||
|
children?: NavItem[]
|
||||||
|
external?: boolean
|
||||||
|
onClick?: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SidebarProps {
|
||||||
|
items: NavItem[]
|
||||||
|
className?: string
|
||||||
|
children?: React.ReactNode
|
||||||
|
logo?: React.ReactNode
|
||||||
|
title?: React.ReactNode
|
||||||
|
defaultCollapsed?: boolean
|
||||||
|
defaultWidth?: number
|
||||||
|
minWidth?: number
|
||||||
|
maxWidth?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Sidebar({
|
||||||
|
items,
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
logo,
|
||||||
|
title,
|
||||||
|
defaultCollapsed = false,
|
||||||
|
defaultWidth = 208,
|
||||||
|
minWidth = 120,
|
||||||
|
maxWidth = 400,
|
||||||
|
}: SidebarProps) {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const location = useLocation()
|
||||||
|
const currentPath = location.pathname
|
||||||
|
|
||||||
|
const [collapsed, setCollapsed] = useState(defaultCollapsed)
|
||||||
|
const [sidebarWidth, setSidebarWidth] = useState(defaultWidth)
|
||||||
|
const [expandedGroups, setExpandedGroups] = useState<Set<string>>(new Set())
|
||||||
|
const [developingDialog, setDevelopingDialog] = useState<{ open: boolean; title: string }>({
|
||||||
|
open: false,
|
||||||
|
title: '',
|
||||||
|
})
|
||||||
|
|
||||||
|
const toggleGroup = (path: string) => {
|
||||||
|
const newExpanded = new Set(expandedGroups)
|
||||||
|
if (newExpanded.has(path)) {
|
||||||
|
newExpanded.delete(path)
|
||||||
|
} else {
|
||||||
|
newExpanded.add(path)
|
||||||
|
}
|
||||||
|
setExpandedGroups(newExpanded)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleNavClick = (item: NavItem) => {
|
||||||
|
// 优先执行 onClick 回调
|
||||||
|
if (item.onClick) {
|
||||||
|
item.onClick()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (item.isDeveloping) {
|
||||||
|
setDevelopingDialog({ open: true, title: item.title })
|
||||||
|
} else if (item.external && item.path.startsWith('http')) {
|
||||||
|
window.open(item.path, '_blank')
|
||||||
|
} else if (item.path.startsWith('/')) {
|
||||||
|
navigate({ to: item.path })
|
||||||
|
} else {
|
||||||
|
navigate({ href: item.path })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 判断当前路径是否激活(以导航路径开头)
|
||||||
|
const isActive = (path: string) => {
|
||||||
|
if (path === '/') {
|
||||||
|
return currentPath === '/'
|
||||||
|
}
|
||||||
|
return currentPath.startsWith(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 渲染导航项
|
||||||
|
const renderNavItem = (item: NavItem, isChild = false) => {
|
||||||
|
if (item.hidden) return null
|
||||||
|
|
||||||
|
const hasChildren = item.children && item.children.length > 0
|
||||||
|
const isExpanded = expandedGroups.has(item.path)
|
||||||
|
const active = isActive(item.path)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<li key={item.path} className='list-none'>
|
||||||
|
{hasChildren ? (
|
||||||
|
// 父菜单项(可展开)
|
||||||
|
<div>
|
||||||
|
<button
|
||||||
|
onClick={() => toggleGroup(item.path)}
|
||||||
|
className={cn(
|
||||||
|
'w-full flex items-center gap-3 px-3 py-2 text-sm rounded-lg transition-colors cursor-pointer',
|
||||||
|
active
|
||||||
|
? 'bg-gray-200 text-gray-900 font-medium'
|
||||||
|
: 'text-gray-600 hover:bg-gray-100 hover:text-gray-900',
|
||||||
|
collapsed && 'justify-center px-2'
|
||||||
|
)}
|
||||||
|
title={collapsed ? item.title : undefined}
|
||||||
|
>
|
||||||
|
{item.icon && <span className="flex-shrink-0">{item.icon}</span>}
|
||||||
|
{!collapsed && (
|
||||||
|
<>
|
||||||
|
<span className="flex-1 text-left">{item.title}</span>
|
||||||
|
<ChevronDown className={cn('w-4 h-4 transition-transform', isExpanded && 'rotate-180')} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
{/* 子菜单 */}
|
||||||
|
{!collapsed && isExpanded && item.children && (
|
||||||
|
<ul className="ml-6 mt-1 space-y-1 list-none">
|
||||||
|
{item.children.map(child => renderNavItem(child, true))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
// 普通菜单项
|
||||||
|
<button
|
||||||
|
onClick={() => handleNavClick(item)}
|
||||||
|
className={cn(
|
||||||
|
'w-full flex items-center gap-3 px-3 py-2 text-sm rounded-lg transition-colors cursor-pointer',
|
||||||
|
active
|
||||||
|
? 'bg-gray-200 text-gray-900 font-medium'
|
||||||
|
: 'text-gray-600 hover:bg-gray-100 hover:text-gray-900',
|
||||||
|
item.isDeveloping && 'opacity-60',
|
||||||
|
isChild && 'py-1.5',
|
||||||
|
collapsed && 'justify-center px-2'
|
||||||
|
)}
|
||||||
|
title={collapsed ? item.title : undefined}
|
||||||
|
>
|
||||||
|
{item.icon && <span className="flex-shrink-0">{item.icon}</span>}
|
||||||
|
{!collapsed && (
|
||||||
|
<>
|
||||||
|
<span className="flex-1 text-left">{item.title}</span>
|
||||||
|
{item.isDeveloping && (
|
||||||
|
<span className="text-xs bg-gray-200 text-gray-600 px-1.5 py-0.5 rounded">
|
||||||
|
dev
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{item.badge && !item.isDeveloping && (
|
||||||
|
<span className="text-xs bg-gray-200 text-gray-700 px-1.5 py-0.5 rounded">
|
||||||
|
{item.badge}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className={cn('flex h-full', className)}>
|
||||||
|
{/* 侧边栏 */}
|
||||||
|
{!collapsed ? (
|
||||||
|
<Resizable
|
||||||
|
defaultSize={{ width: sidebarWidth, height: '100%' }}
|
||||||
|
minWidth={minWidth}
|
||||||
|
maxWidth={maxWidth}
|
||||||
|
onResizeStop={(_e, _direction, ref, _d) => {
|
||||||
|
setSidebarWidth(ref.offsetWidth)
|
||||||
|
}}
|
||||||
|
enable={{
|
||||||
|
right: true,
|
||||||
|
}}
|
||||||
|
handleComponent={{
|
||||||
|
right: <div className="w-1 h-full cursor-col-resize hover:bg-blue-400 transition-colors" />,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<aside
|
||||||
|
className={cn(
|
||||||
|
'border-r bg-white flex-shrink-0 flex flex-col'
|
||||||
|
)}
|
||||||
|
style={{ width: sidebarWidth }}
|
||||||
|
>
|
||||||
|
{/* Logo 区域 */}
|
||||||
|
<div className={cn(
|
||||||
|
'h-12 flex items-center border-b px-3'
|
||||||
|
)}>
|
||||||
|
{logo && (
|
||||||
|
<div className="flex-shrink-0 flex items-center gap-2">{logo}</div>
|
||||||
|
)}
|
||||||
|
{title && (
|
||||||
|
<span className="text-sm font-medium text-gray-900 truncate ml-1">{title}</span>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={() => setCollapsed(true)}
|
||||||
|
className="ml-auto flex-shrink-0 p-1 rounded-lg hover:bg-gray-100 transition-colors cursor-pointer"
|
||||||
|
title="收起"
|
||||||
|
>
|
||||||
|
<ChevronLeft className="w-4 h-4 text-gray-500" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 导航区域 */}
|
||||||
|
<nav className="flex-1 p-2 overflow-y-auto">
|
||||||
|
<ul className="space-y-1 list-none">
|
||||||
|
{items.map((item) => renderNavItem(item))}
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
</aside>
|
||||||
|
</Resizable>
|
||||||
|
) : (
|
||||||
|
// 收起状态
|
||||||
|
<aside className="w-14 border-r bg-white flex-shrink-0 flex flex-col">
|
||||||
|
{/* Logo 区域 */}
|
||||||
|
<div className="h-12 flex items-center justify-center border-b px-2">
|
||||||
|
<div className="group relative flex-shrink-0">
|
||||||
|
<button
|
||||||
|
onClick={() => setCollapsed(false)}
|
||||||
|
className="p-1 rounded-lg hover:bg-gray-100 transition-colors cursor-pointer"
|
||||||
|
title="展开"
|
||||||
|
>
|
||||||
|
<span className="group-hover:hidden">{logo}</span>
|
||||||
|
<ChevronRight className="w-5 h-5 hidden group-hover:block text-gray-600" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 导航区域 */}
|
||||||
|
<nav className="flex-1 p-2 overflow-y-auto">
|
||||||
|
<ul className="space-y-1 list-none">
|
||||||
|
{items.map((item) => renderNavItem(item))}
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
</aside>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 主内容区域 */}
|
||||||
|
<main className="flex-1 overflow-auto h-full bg-gray-50">
|
||||||
|
{children}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 开发中弹窗 */}
|
||||||
|
<Dialog
|
||||||
|
open={developingDialog.open}
|
||||||
|
onOpenChange={(open) => setDevelopingDialog({ open, title: '' })}
|
||||||
|
>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{developingDialog.title} - 开发中</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
该功能正在紧张开发中,敬请期待!暂时无法访问。
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button onClick={() => setDevelopingDialog({ open: false, title: '' })}>
|
||||||
|
知道了
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,5 +1,3 @@
|
|||||||
"use client"
|
|
||||||
|
|
||||||
import * as React from "react"
|
import * as React from "react"
|
||||||
import { Menu as MenuPrimitive } from "@base-ui/react/menu"
|
import { Menu as MenuPrimitive } from "@base-ui/react/menu"
|
||||||
|
|
||||||
@@ -132,7 +130,7 @@ function DropdownMenuSubContent({
|
|||||||
return (
|
return (
|
||||||
<DropdownMenuContent
|
<DropdownMenuContent
|
||||||
data-slot="dropdown-menu-sub-content"
|
data-slot="dropdown-menu-sub-content"
|
||||||
className={cn("data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 ring-foreground/10 bg-popover text-popover-foreground min-w-[96px] rounded-md p-1 shadow-lg ring-1 duration-100 w-auto", className)}
|
className={cn("data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 ring-foreground/10 bg-popover text-popover-foreground min-w-[96px] rounded-lg p-1 shadow-lg ring-1 duration-100 w-auto", className)}
|
||||||
align={align}
|
align={align}
|
||||||
alignOffset={alignOffset}
|
alignOffset={alignOffset}
|
||||||
side={side}
|
side={side}
|
||||||
|
|||||||
26
src/components/ui/kbd.tsx
Normal file
26
src/components/ui/kbd.tsx
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Kbd({ className, ...props }: React.ComponentProps<"kbd">) {
|
||||||
|
return (
|
||||||
|
<kbd
|
||||||
|
data-slot="kbd"
|
||||||
|
className={cn(
|
||||||
|
"bg-muted text-muted-foreground in-data-[slot=tooltip-content]:bg-background/20 in-data-[slot=tooltip-content]:text-background dark:in-data-[slot=tooltip-content]:bg-background/10 h-5 w-fit min-w-5 gap-1 rounded-sm px-1 font-sans text-xs font-medium [&_svg:not([class*='size-'])]:size-3 pointer-events-none inline-flex items-center justify-center select-none",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function KbdGroup({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<kbd
|
||||||
|
data-slot="kbd-group"
|
||||||
|
className={cn("gap-1 inline-flex items-center", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Kbd, KbdGroup }
|
||||||
265
src/components/ui/menubar.tsx
Normal file
265
src/components/ui/menubar.tsx
Normal file
@@ -0,0 +1,265 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import { Menu as MenuPrimitive } from "@base-ui/react/menu"
|
||||||
|
import { Menubar as MenubarPrimitive } from "@base-ui/react/menubar"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuGroup,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuLabel,
|
||||||
|
DropdownMenuPortal,
|
||||||
|
DropdownMenuRadioGroup,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuShortcut,
|
||||||
|
DropdownMenuSub,
|
||||||
|
DropdownMenuSubContent,
|
||||||
|
DropdownMenuSubTrigger,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from "@/components/ui/dropdown-menu"
|
||||||
|
import { CheckIcon } from "lucide-react"
|
||||||
|
|
||||||
|
function Menubar({ className, ...props }: MenubarPrimitive.Props) {
|
||||||
|
return (
|
||||||
|
<MenubarPrimitive
|
||||||
|
data-slot="menubar"
|
||||||
|
className={cn("bg-background h-8 gap-0.5 rounded-lg border p-[3px] flex items-center", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function MenubarMenu({ ...props }: React.ComponentProps<typeof DropdownMenu>) {
|
||||||
|
return <DropdownMenu data-slot="menubar-menu" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function MenubarGroup({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuGroup>) {
|
||||||
|
return <DropdownMenuGroup data-slot="menubar-group" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function MenubarPortal({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPortal>) {
|
||||||
|
return <DropdownMenuPortal data-slot="menubar-portal" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function MenubarTrigger({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuTrigger>) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuTrigger
|
||||||
|
data-slot="menubar-trigger"
|
||||||
|
className={cn(
|
||||||
|
"hover:bg-muted aria-expanded:bg-muted rounded-sm px-1.5 py-[2px] text-sm font-medium flex items-center outline-hidden select-none",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function MenubarContent({
|
||||||
|
className,
|
||||||
|
align = "start",
|
||||||
|
alignOffset = -4,
|
||||||
|
sideOffset = 8,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuContent>) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuContent
|
||||||
|
data-slot="menubar-content"
|
||||||
|
align={align}
|
||||||
|
alignOffset={alignOffset}
|
||||||
|
sideOffset={sideOffset}
|
||||||
|
className={cn("bg-popover text-popover-foreground data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 ring-foreground/10 min-w-36 rounded-lg p-1 shadow-md ring-1 duration-100 data-[side=inline-start]:slide-in-from-right-2 data-[side=inline-end]:slide-in-from-left-2", className )}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function MenubarItem({
|
||||||
|
className,
|
||||||
|
inset,
|
||||||
|
variant = "default",
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuItem>) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuItem
|
||||||
|
data-slot="menubar-item"
|
||||||
|
data-inset={inset}
|
||||||
|
data-variant={variant}
|
||||||
|
className={cn("focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:text-destructive! not-data-[variant=destructive]:focus:**:text-accent-foreground gap-1.5 rounded-md px-1.5 py-1 text-sm data-disabled:opacity-50 data-inset:pl-7 [&_svg:not([class*='size-'])]:size-4 group/menubar-item", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function MenubarCheckboxItem({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
checked,
|
||||||
|
inset,
|
||||||
|
...props
|
||||||
|
}: MenuPrimitive.CheckboxItem.Props & {
|
||||||
|
inset?: boolean
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<MenuPrimitive.CheckboxItem
|
||||||
|
data-slot="menubar-checkbox-item"
|
||||||
|
data-inset={inset}
|
||||||
|
className={cn(
|
||||||
|
"focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground gap-1.5 rounded-md py-1 pr-1.5 pl-7 text-sm data-inset:pl-7 relative flex cursor-default items-center outline-hidden select-none data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
checked={checked}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span className="left-1.5 size-4 [&_svg:not([class*='size-'])]:size-4 pointer-events-none absolute flex items-center justify-center">
|
||||||
|
<MenuPrimitive.CheckboxItemIndicator>
|
||||||
|
<CheckIcon
|
||||||
|
/>
|
||||||
|
</MenuPrimitive.CheckboxItemIndicator>
|
||||||
|
</span>
|
||||||
|
{children}
|
||||||
|
</MenuPrimitive.CheckboxItem>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function MenubarRadioGroup({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuRadioGroup>) {
|
||||||
|
return <DropdownMenuRadioGroup data-slot="menubar-radio-group" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function MenubarRadioItem({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
inset,
|
||||||
|
...props
|
||||||
|
}: MenuPrimitive.RadioItem.Props & {
|
||||||
|
inset?: boolean
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<MenuPrimitive.RadioItem
|
||||||
|
data-slot="menubar-radio-item"
|
||||||
|
data-inset={inset}
|
||||||
|
className={cn(
|
||||||
|
"focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground gap-1.5 rounded-md py-1 pr-1.5 pl-7 text-sm data-disabled:opacity-50 data-inset:pl-7 [&_svg:not([class*='size-'])]:size-4 relative flex cursor-default items-center outline-hidden select-none data-disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span className="left-1.5 size-4 [&_svg:not([class*='size-'])]:size-4 pointer-events-none absolute flex items-center justify-center">
|
||||||
|
<MenuPrimitive.RadioItemIndicator>
|
||||||
|
<CheckIcon
|
||||||
|
/>
|
||||||
|
</MenuPrimitive.RadioItemIndicator>
|
||||||
|
</span>
|
||||||
|
{children}
|
||||||
|
</MenuPrimitive.RadioItem>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function MenubarLabel({
|
||||||
|
className,
|
||||||
|
inset,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuLabel> & {
|
||||||
|
inset?: boolean
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuLabel
|
||||||
|
data-slot="menubar-label"
|
||||||
|
data-inset={inset}
|
||||||
|
className={cn("px-1.5 py-1 text-sm font-medium data-inset:pl-7", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function MenubarSeparator({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuSeparator>) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuSeparator
|
||||||
|
data-slot="menubar-separator"
|
||||||
|
className={cn("bg-border -mx-1 my-1 h-px", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function MenubarShortcut({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuShortcut>) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuShortcut
|
||||||
|
data-slot="menubar-shortcut"
|
||||||
|
className={cn("text-muted-foreground group-focus/menubar-item:text-accent-foreground text-xs tracking-widest ml-auto", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function MenubarSub({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuSub>) {
|
||||||
|
return <DropdownMenuSub data-slot="menubar-sub" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function MenubarSubTrigger({
|
||||||
|
className,
|
||||||
|
inset,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuSubTrigger> & {
|
||||||
|
inset?: boolean
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuSubTrigger
|
||||||
|
data-slot="menubar-sub-trigger"
|
||||||
|
data-inset={inset}
|
||||||
|
className={cn("focus:bg-accent focus:text-accent-foreground data-open:bg-accent data-open:text-accent-foreground gap-1.5 rounded-md px-1.5 py-1 text-sm data-inset:pl-7 [&_svg:not([class*='size-'])]:size-4", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function MenubarSubContent({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuSubContent>) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuSubContent
|
||||||
|
data-slot="menubar-sub-content"
|
||||||
|
className={cn("bg-popover text-popover-foreground data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 ring-foreground/10 min-w-32 rounded-lg p-1 shadow-lg ring-1 duration-100", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Menubar,
|
||||||
|
MenubarPortal,
|
||||||
|
MenubarMenu,
|
||||||
|
MenubarTrigger,
|
||||||
|
MenubarContent,
|
||||||
|
MenubarGroup,
|
||||||
|
MenubarSeparator,
|
||||||
|
MenubarLabel,
|
||||||
|
MenubarItem,
|
||||||
|
MenubarShortcut,
|
||||||
|
MenubarCheckboxItem,
|
||||||
|
MenubarRadioGroup,
|
||||||
|
MenubarRadioItem,
|
||||||
|
MenubarSub,
|
||||||
|
MenubarSubTrigger,
|
||||||
|
MenubarSubContent,
|
||||||
|
}
|
||||||
88
src/components/ui/popover.tsx
Normal file
88
src/components/ui/popover.tsx
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import { Popover as PopoverPrimitive } from "@base-ui/react/popover"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Popover({ ...props }: PopoverPrimitive.Root.Props) {
|
||||||
|
return <PopoverPrimitive.Root data-slot="popover" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function PopoverTrigger({ ...props }: PopoverPrimitive.Trigger.Props) {
|
||||||
|
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function PopoverContent({
|
||||||
|
className,
|
||||||
|
align = "center",
|
||||||
|
alignOffset = 0,
|
||||||
|
side = "bottom",
|
||||||
|
sideOffset = 4,
|
||||||
|
...props
|
||||||
|
}: PopoverPrimitive.Popup.Props &
|
||||||
|
Pick<
|
||||||
|
PopoverPrimitive.Positioner.Props,
|
||||||
|
"align" | "alignOffset" | "side" | "sideOffset"
|
||||||
|
>) {
|
||||||
|
return (
|
||||||
|
<PopoverPrimitive.Portal>
|
||||||
|
<PopoverPrimitive.Positioner
|
||||||
|
align={align}
|
||||||
|
alignOffset={alignOffset}
|
||||||
|
side={side}
|
||||||
|
sideOffset={sideOffset}
|
||||||
|
className="isolate z-50"
|
||||||
|
>
|
||||||
|
<PopoverPrimitive.Popup
|
||||||
|
data-slot="popover-content"
|
||||||
|
className={cn(
|
||||||
|
"bg-popover text-popover-foreground data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 ring-foreground/10 flex flex-col gap-2.5 rounded-lg p-2.5 text-sm shadow-md ring-1 duration-100 data-[side=inline-start]:slide-in-from-right-2 data-[side=inline-end]:slide-in-from-left-2 z-50 w-72 origin-(--transform-origin) outline-hidden",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</PopoverPrimitive.Positioner>
|
||||||
|
</PopoverPrimitive.Portal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function PopoverHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="popover-header"
|
||||||
|
className={cn("flex flex-col gap-0.5 text-sm", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function PopoverTitle({ className, ...props }: PopoverPrimitive.Title.Props) {
|
||||||
|
return (
|
||||||
|
<PopoverPrimitive.Title
|
||||||
|
data-slot="popover-title"
|
||||||
|
className={cn("font-medium", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function PopoverDescription({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: PopoverPrimitive.Description.Props) {
|
||||||
|
return (
|
||||||
|
<PopoverPrimitive.Description
|
||||||
|
data-slot="popover-description"
|
||||||
|
className={cn("text-muted-foreground", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Popover,
|
||||||
|
PopoverContent,
|
||||||
|
PopoverDescription,
|
||||||
|
PopoverHeader,
|
||||||
|
PopoverTitle,
|
||||||
|
PopoverTrigger,
|
||||||
|
}
|
||||||
13
src/components/ui/skeleton.tsx
Normal file
13
src/components/ui/skeleton.tsx
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="skeleton"
|
||||||
|
className={cn("animate-pulse rounded-md bg-muted", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Skeleton }
|
||||||
80
src/components/ui/tabs.tsx
Normal file
80
src/components/ui/tabs.tsx
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
import { Tabs as TabsPrimitive } from "@base-ui/react/tabs"
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Tabs({
|
||||||
|
className,
|
||||||
|
orientation = "horizontal",
|
||||||
|
...props
|
||||||
|
}: TabsPrimitive.Root.Props) {
|
||||||
|
return (
|
||||||
|
<TabsPrimitive.Root
|
||||||
|
data-slot="tabs"
|
||||||
|
data-orientation={orientation}
|
||||||
|
className={cn(
|
||||||
|
"gap-2 group/tabs flex data-horizontal:flex-col",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const tabsListVariants = cva(
|
||||||
|
"rounded-lg p-[3px] group-data-horizontal/tabs:h-8 data-[variant=line]:rounded-none group/tabs-list text-muted-foreground inline-flex w-fit items-center justify-center group-data-vertical/tabs:h-fit group-data-vertical/tabs:flex-col",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: "bg-muted",
|
||||||
|
line: "gap-1 bg-transparent",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
function TabsList({
|
||||||
|
className,
|
||||||
|
variant = "default",
|
||||||
|
...props
|
||||||
|
}: TabsPrimitive.List.Props & VariantProps<typeof tabsListVariants>) {
|
||||||
|
return (
|
||||||
|
<TabsPrimitive.List
|
||||||
|
data-slot="tabs-list"
|
||||||
|
data-variant={variant}
|
||||||
|
className={cn(tabsListVariants({ variant }), className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TabsTrigger({ className, ...props }: TabsPrimitive.Tab.Props) {
|
||||||
|
return (
|
||||||
|
<TabsPrimitive.Tab
|
||||||
|
data-slot="tabs-trigger"
|
||||||
|
className={cn(
|
||||||
|
"gap-1.5 rounded-md border border-transparent px-1.5 py-0.5 text-sm font-medium group-data-[variant=default]/tabs-list:data-active:shadow-sm group-data-[variant=line]/tabs-list:data-active:shadow-none [&_svg:not([class*='size-'])]:size-4 focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring text-foreground/60 hover:text-foreground dark:text-muted-foreground dark:hover:text-foreground relative inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center whitespace-nowrap transition-all group-data-vertical/tabs:w-full group-data-vertical/tabs:justify-start focus-visible:ring-[3px] focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0",
|
||||||
|
"group-data-[variant=line]/tabs-list:bg-transparent group-data-[variant=line]/tabs-list:data-active:bg-transparent dark:group-data-[variant=line]/tabs-list:data-active:border-transparent dark:group-data-[variant=line]/tabs-list:data-active:bg-transparent",
|
||||||
|
"data-active:bg-background dark:data-active:text-foreground dark:data-active:border-input dark:data-active:bg-input/30 data-active:text-foreground",
|
||||||
|
"after:bg-foreground after:absolute after:opacity-0 after:transition-opacity group-data-horizontal/tabs:after:inset-x-0 group-data-horizontal/tabs:after:bottom-[-5px] group-data-horizontal/tabs:after:h-0.5 group-data-vertical/tabs:after:inset-y-0 group-data-vertical/tabs:after:-right-1 group-data-vertical/tabs:after:w-0.5 group-data-[variant=line]/tabs-list:data-active:after:opacity-100",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TabsContent({ className, ...props }: TabsPrimitive.Panel.Props) {
|
||||||
|
return (
|
||||||
|
<TabsPrimitive.Panel
|
||||||
|
data-slot="tabs-content"
|
||||||
|
className={cn("text-sm flex-1 outline-none", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Tabs, TabsList, TabsTrigger, TabsContent, tabsListVariants }
|
||||||
14
src/main.tsx
14
src/main.tsx
@@ -2,12 +2,13 @@ import ReactDOM from 'react-dom/client'
|
|||||||
import { RouterProvider, createRouter } from '@tanstack/react-router'
|
import { RouterProvider, createRouter } from '@tanstack/react-router'
|
||||||
import { routeTree } from './routeTree.gen'
|
import { routeTree } from './routeTree.gen'
|
||||||
import './index.css'
|
import './index.css'
|
||||||
import { basename } from './modules/basename'
|
import { getDynamicBasename } from './modules/basename'
|
||||||
|
import './agents/index.ts';
|
||||||
|
import PWAUpdate from './components/a/PWAUpdate.tsx';
|
||||||
// Set up a Router instance
|
// Set up a Router instance
|
||||||
const router = createRouter({
|
const router = createRouter({
|
||||||
routeTree,
|
routeTree,
|
||||||
basepath: basename,
|
basepath: getDynamicBasename(),
|
||||||
defaultPreload: 'intent',
|
defaultPreload: 'intent',
|
||||||
scrollRestoration: true,
|
scrollRestoration: true,
|
||||||
})
|
})
|
||||||
@@ -23,5 +24,10 @@ const rootElement = document.getElementById('root')!
|
|||||||
|
|
||||||
if (!rootElement.innerHTML) {
|
if (!rootElement.innerHTML) {
|
||||||
const root = ReactDOM.createRoot(rootElement)
|
const root = ReactDOM.createRoot(rootElement)
|
||||||
root.render(<RouterProvider router={router} />)
|
root.render(
|
||||||
|
<>
|
||||||
|
<RouterProvider router={router} />
|
||||||
|
<PWAUpdate />
|
||||||
|
</>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
@@ -9,3 +9,24 @@ export const wrapBasename = (path: string) => {
|
|||||||
return path;
|
return path;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 动态计算 basename,根据当前 URL 路径
|
||||||
|
export const getDynamicBasename = (): string => {
|
||||||
|
const path = window.location.pathname
|
||||||
|
const [user, key, id] = path.split('/').filter(Boolean)
|
||||||
|
if (key === 'v1' && id) {
|
||||||
|
return `/${user}/v1/${id}`
|
||||||
|
}
|
||||||
|
// 默认使用构建时的 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);
|
||||||
|
}
|
||||||
@@ -1,3 +1,18 @@
|
|||||||
import { QueryClient } from '@kevisual/query';
|
import { Query, DataOpts } from '@kevisual/query';
|
||||||
|
import { QueryLoginBrowser } from '@kevisual/api/query-login'
|
||||||
|
import { useContextKey } from '@kevisual/context';
|
||||||
|
import { QueryClient } from '@tanstack/react-query';
|
||||||
|
|
||||||
export const query = new QueryClient({});
|
export const query = useContextKey('query', new Query({
|
||||||
|
url: '/api/router',
|
||||||
|
}));
|
||||||
|
|
||||||
|
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());
|
||||||
1
src/pages/auth/hooks/index.ts
Normal file
1
src/pages/auth/hooks/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export * from './use-api-query';
|
||||||
55
src/pages/auth/hooks/use-api-query.ts
Normal file
55
src/pages/auth/hooks/use-api-query.ts
Normal file
@@ -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,
|
||||||
|
});
|
||||||
|
};
|
||||||
57
src/pages/auth/index.tsx
Normal file
57
src/pages/auth/index.tsx
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
import { useEffect } from "react"
|
||||||
|
import { useLayoutStore } from "./store"
|
||||||
|
import { useShallow } from "zustand/shallow"
|
||||||
|
import { LogIn, LockKeyhole } from "lucide-react"
|
||||||
|
export { BaseHeader } from './modules/BaseHeader'
|
||||||
|
import { useMemo } from 'react';
|
||||||
|
import { useLocation, useNavigate } from '@tanstack/react-router';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
children?: React.ReactNode,
|
||||||
|
mustLogin?: boolean,
|
||||||
|
}
|
||||||
|
export const AuthProvider = ({ children, mustLogin }: Props) => {
|
||||||
|
const store = useLayoutStore(useShallow(state => ({
|
||||||
|
init: state.init,
|
||||||
|
me: state.me,
|
||||||
|
openLinkList: state.openLinkList,
|
||||||
|
})));
|
||||||
|
useEffect(() => {
|
||||||
|
store.init()
|
||||||
|
}, [])
|
||||||
|
const location = useLocation()
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const isOpen = useMemo(() => {
|
||||||
|
return store.openLinkList.some(item => location.pathname.startsWith(item))
|
||||||
|
}, [location.pathname])
|
||||||
|
const loginUrl = '/root/login/?redirect=' + encodeURIComponent(window.location.href);
|
||||||
|
if (mustLogin && !store.me && !isOpen) {
|
||||||
|
return (
|
||||||
|
<div className="w-full h-full min-h-screen flex items-center justify-center bg-background">
|
||||||
|
<div className="flex flex-col items-center gap-6 p-10 rounded-2xl border border-border bg-card shadow-lg max-w-sm w-full mx-4">
|
||||||
|
<div className="flex items-center justify-center w-16 h-16 rounded-full bg-muted">
|
||||||
|
<LockKeyhole className="w-8 h-8 text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col items-center gap-2 text-center">
|
||||||
|
<h2 className="text-xl font-semibold text-foreground">需要登录</h2>
|
||||||
|
<p className="text-sm text-muted-foreground">请先登录以继续访问此页面</p>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className="inline-flex items-center justify-center gap-2 w-full px-6 py-2.5 rounded-lg bg-foreground text-background text-sm font-medium transition-opacity hover:opacity-80 active:opacity-70"
|
||||||
|
onClick={() => {
|
||||||
|
// window.open(loginUrl, '_blank');
|
||||||
|
navigate({ to: '/login' });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<LogIn className="w-4 h-4" />
|
||||||
|
立即登录
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return <>
|
||||||
|
{children}
|
||||||
|
</>
|
||||||
|
}
|
||||||
92
src/pages/auth/modules/BaseHeader.tsx
Normal file
92
src/pages/auth/modules/BaseHeader.tsx
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
import { Home, User, LogIn, LogOut } from 'lucide-react';
|
||||||
|
import { Link, useNavigate } from '@tanstack/react-router'
|
||||||
|
import { useLayoutStore } from '../store';
|
||||||
|
import { useShallow } from 'zustand/shallow';
|
||||||
|
import { useMemo } from 'react';
|
||||||
|
|
||||||
|
export const BaseHeader = (props: { main?: React.ComponentType | null }) => {
|
||||||
|
const store = useLayoutStore(useShallow(state => ({
|
||||||
|
me: state.me,
|
||||||
|
clearMe: state.clearMe,
|
||||||
|
links: state.links,
|
||||||
|
showBaseHeader: state.showBaseHeader,
|
||||||
|
})));
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const meInfo = useMemo(() => {
|
||||||
|
if (!store.me) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={() => navigate({ to: '/login' })}
|
||||||
|
className="flex items-center gap-2 px-3 py-1.5 text-sm text-gray-600 hover:text-gray-900 hover:bg-gray-100 rounded-lg transition-colors cursor-pointer"
|
||||||
|
>
|
||||||
|
<LogIn className="w-4 h-4" />
|
||||||
|
<span>去登录</span>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{store.me.avatar && (
|
||||||
|
<img
|
||||||
|
src={store.me.avatar}
|
||||||
|
alt="Avatar"
|
||||||
|
className="w-8 h-8 rounded-full object-cover"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{!store.me.avatar && (
|
||||||
|
<div className="w-8 h-8 rounded-full bg-gray-200 flex items-center justify-center">
|
||||||
|
<User className="w-4 h-4 text-gray-500" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<span className="font-medium text-gray-700">{store.me?.username}</span>
|
||||||
|
<button
|
||||||
|
onClick={() => store.clearMe?.()}
|
||||||
|
className="flex items-center gap-1 px-2 py-1 text-sm text-gray-500 hover:text-red-600 hover:bg-red-50 rounded-lg transition-colors cursor-pointer"
|
||||||
|
title="退出登录"
|
||||||
|
>
|
||||||
|
<LogOut className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}, [store.me, store.clearMe])
|
||||||
|
if (!store.showBaseHeader) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="flex gap-2 text-lg w-full h-12 items-center justify-between bg-gray-200">
|
||||||
|
<div className='px-2 flex items-center gap-1'>
|
||||||
|
{
|
||||||
|
store.links.map(link => (
|
||||||
|
<div key={link.key || link.title}
|
||||||
|
className="cursor-pointer flex items-center justify-center gap-1 p-2 text-sm text-gray-600 hover:text-gray-900 hover:bg-gray-100 rounded-lg transition-colors"
|
||||||
|
onClick={() => {
|
||||||
|
if (!link.href) return;
|
||||||
|
if (link.href.startsWith('http') || link.isRoot) {
|
||||||
|
window.open(link.href, '_blank');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
navigate({
|
||||||
|
to: link.href
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{link.key === 'home' && <Home className="w-4 h-4" />}
|
||||||
|
{link.icon && <>{link.icon}</>}
|
||||||
|
{!link.icon && link.title}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
))
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<div className='mr-4'>
|
||||||
|
{meInfo}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const LayoutMain = () => {
|
||||||
|
return <BaseHeader />
|
||||||
|
}
|
||||||
81
src/pages/auth/page.tsx
Normal file
81
src/pages/auth/page.tsx
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
import { useContextKey } from '@kevisual/context';
|
||||||
|
import '@kevisual/kv-login';
|
||||||
|
import { checkPluginLogin } from '@kevisual/kv-login'
|
||||||
|
import { useEffect } from 'react';
|
||||||
|
import { useLayoutStore } from './store';
|
||||||
|
import { useShallow } from 'zustand/shallow';
|
||||||
|
import { useNavigate } from '@tanstack/react-router';
|
||||||
|
|
||||||
|
export const LoginComponent = ({ onLoginSuccess }: { onLoginSuccess: () => void }) => {
|
||||||
|
useEffect(() => {
|
||||||
|
// 监听登录成功事件
|
||||||
|
const handleLoginSuccess = () => {
|
||||||
|
console.log('监听到登录成功事件,关闭弹窗');
|
||||||
|
onLoginSuccess();
|
||||||
|
};
|
||||||
|
const loginEmitter = useContextKey('login-emitter')
|
||||||
|
console.log('KvLogin Types:', loginEmitter);
|
||||||
|
|
||||||
|
loginEmitter.on('login-success', handleLoginSuccess);
|
||||||
|
|
||||||
|
// 清理监听器
|
||||||
|
return () => {
|
||||||
|
loginEmitter.off('login-success', handleLoginSuccess);
|
||||||
|
};
|
||||||
|
}, [onLoginSuccess]);
|
||||||
|
|
||||||
|
// @ts-ignore
|
||||||
|
return (<kv-login></kv-login>)
|
||||||
|
}
|
||||||
|
export const App = () => {
|
||||||
|
const store = useLayoutStore(useShallow((state) => ({
|
||||||
|
init: state.init,
|
||||||
|
loginPageConfig: state.loginPageConfig,
|
||||||
|
})));
|
||||||
|
useEffect(() => {
|
||||||
|
checkPluginLogin();
|
||||||
|
}, []);
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const handleLoginSuccess = async () => {
|
||||||
|
await store.init()
|
||||||
|
navigate({ to: '/' })
|
||||||
|
};
|
||||||
|
const { title, subtitle, footer } = store.loginPageConfig;
|
||||||
|
return (
|
||||||
|
<div className='w-full h-full relative overflow-hidden bg-gradient-to-br from-slate-900 via-purple-900 to-slate-900'>
|
||||||
|
{/* 背景装饰 - 圆形光晕 */}
|
||||||
|
<div className='absolute top-1/4 -left-32 w-96 h-96 bg-purple-500/30 rounded-full blur-3xl'></div>
|
||||||
|
<div className='absolute bottom-1/4 -right-32 w-96 h-96 bg-blue-500/30 rounded-full blur-3xl'></div>
|
||||||
|
<div className='absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-[600px] h-[600px] bg-indigo-500/20 rounded-full blur-3xl'></div>
|
||||||
|
|
||||||
|
{/* 背景装饰 - 网格图案 */}
|
||||||
|
<div className='absolute inset-0 opacity-[0.03] bg-[linear-gradient(rgba(255,255,255,0.1)_1px,transparent_1px),linear-gradient(90deg,rgba(255,255,255,0.1)_1px,transparent_1px)] bg-[size:50px_50px]'></div>
|
||||||
|
|
||||||
|
{/* 顶部装饰文字 */}
|
||||||
|
<div className='absolute top-10 left-0 right-0 text-center'>
|
||||||
|
<h1 className='text-4xl font-bold text-white/90 tracking-wider'>{title}</h1>
|
||||||
|
<p className='mt-2 text-white/50 text-sm tracking-widest'>{subtitle}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 登录卡片容器 */}
|
||||||
|
<div className='w-full h-full flex items-center justify-center p-8'>
|
||||||
|
<div className='relative'>
|
||||||
|
{/* 卡片外圈光效 */}
|
||||||
|
<div className='absolute -inset-1 bg-gradient-to-r from-purple-500 via-blue-500 to-indigo-500 rounded-2xl blur opacity-30'></div>
|
||||||
|
|
||||||
|
{/* 登录组件容器 */}
|
||||||
|
<div className='relative bg-slate-900/80 backdrop-blur-xl rounded-2xl border border-white/10 shadow-2xl overflow-hidden'>
|
||||||
|
<LoginComponent onLoginSuccess={handleLoginSuccess} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 底部装饰 */}
|
||||||
|
<div className='absolute bottom-6 left-0 right-0 text-center'>
|
||||||
|
<p className='text-white/30 text-xs'>{footer}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default App;
|
||||||
131
src/pages/auth/store.ts
Normal file
131
src/pages/auth/store.ts
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
|
||||||
|
import { queryLogin, stackQueryClient } from '@/modules/query';
|
||||||
|
import { create } from 'zustand';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import { authQueryKeys } from './hooks';
|
||||||
|
export type UserInfo = {
|
||||||
|
id?: string;
|
||||||
|
username?: string;
|
||||||
|
nickname?: string | null;
|
||||||
|
needChangePassword?: boolean;
|
||||||
|
description?: string | null;
|
||||||
|
type?: 'user' | 'org';
|
||||||
|
orgs?: string[];
|
||||||
|
avatar?: string;
|
||||||
|
};
|
||||||
|
export type LayoutStore = {
|
||||||
|
open: boolean;
|
||||||
|
setOpen: (open: boolean) => void;
|
||||||
|
openUser: boolean;
|
||||||
|
setOpenUser: (openUser: boolean) => void;
|
||||||
|
me?: UserInfo;
|
||||||
|
setMe: (me: UserInfo) => void;
|
||||||
|
clearMe: () => void;
|
||||||
|
getMe: () => Promise<void>;
|
||||||
|
switchOrg: (username?: string) => Promise<void>;
|
||||||
|
isAdmin: boolean;
|
||||||
|
setIsAdmin: (isAdmin: boolean) => void
|
||||||
|
init: () => Promise<void>;
|
||||||
|
openLinkList: string[];
|
||||||
|
setOpenLinkList: (openLinkList: string[]) => void;
|
||||||
|
loginPageConfig: {
|
||||||
|
title: string;
|
||||||
|
subtitle: string;
|
||||||
|
footer: string;
|
||||||
|
};
|
||||||
|
setLoginPageConfig: (config: Partial<LayoutStore['loginPageConfig']>) => void;
|
||||||
|
links: HeaderLink[];
|
||||||
|
setLinks: (links: HeaderLink[]) => void;
|
||||||
|
showBaseHeader: boolean;
|
||||||
|
setShowBaseHeader: (showBaseHeader: boolean) => void;
|
||||||
|
serverData: Record<string, any> | null;
|
||||||
|
setServerData: (data: Record<string, any>) => void;
|
||||||
|
initConvex: () => Promise<void>;
|
||||||
|
};
|
||||||
|
type HeaderLink = {
|
||||||
|
title?: string;
|
||||||
|
href: string;
|
||||||
|
description?: string;
|
||||||
|
icon?: React.ReactNode;
|
||||||
|
key?: string;
|
||||||
|
isRoot?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useLayoutStore = create<LayoutStore>((set, get) => ({
|
||||||
|
open: false,
|
||||||
|
setOpen: (open) => set({ open }),
|
||||||
|
openUser: false,
|
||||||
|
setOpenUser: (openUser) => set({ openUser }),
|
||||||
|
me: undefined,
|
||||||
|
setMe: (me) => set({ me }),
|
||||||
|
clearMe: () => {
|
||||||
|
set({ me: undefined, isAdmin: false });
|
||||||
|
},
|
||||||
|
getMe: async () => {
|
||||||
|
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);
|
||||||
|
} else {
|
||||||
|
toast.error(res.message || '请求失败');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
isAdmin: false,
|
||||||
|
setIsAdmin: (isAdmin) => set({ isAdmin }),
|
||||||
|
init: async () => {
|
||||||
|
await queryLogin.init();
|
||||||
|
const token = await queryLogin.checkTokenValid();
|
||||||
|
if (token) {
|
||||||
|
set({ me: {} });
|
||||||
|
try {
|
||||||
|
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: {
|
||||||
|
title: '可视化管理平台',
|
||||||
|
subtitle: '让工具和智能化触手可及',
|
||||||
|
footer: '欢迎使用可视化管理平台 · 连接您的工具',
|
||||||
|
},
|
||||||
|
setLoginPageConfig: (config) => set((state) => ({
|
||||||
|
loginPageConfig: { ...state.loginPageConfig, ...config },
|
||||||
|
})),
|
||||||
|
links: [{ title: '', href: '/', key: 'home' }],
|
||||||
|
setLinks: (links) => set({ links }),
|
||||||
|
showBaseHeader: true,
|
||||||
|
setShowBaseHeader: (showBaseHeader) => set({ showBaseHeader }),
|
||||||
|
serverData: null,
|
||||||
|
setServerData: (data) => set({ serverData: data }),
|
||||||
|
}));
|
||||||
@@ -9,9 +9,15 @@
|
|||||||
// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
|
// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
|
||||||
|
|
||||||
import { Route as rootRouteImport } from './routes/__root'
|
import { Route as rootRouteImport } from './routes/__root'
|
||||||
|
import { Route as LoginRouteImport } from './routes/login'
|
||||||
import { Route as DemoRouteImport } from './routes/demo'
|
import { Route as DemoRouteImport } from './routes/demo'
|
||||||
import { Route as IndexRouteImport } from './routes/index'
|
import { Route as IndexRouteImport } from './routes/index'
|
||||||
|
|
||||||
|
const LoginRoute = LoginRouteImport.update({
|
||||||
|
id: '/login',
|
||||||
|
path: '/login',
|
||||||
|
getParentRoute: () => rootRouteImport,
|
||||||
|
} as any)
|
||||||
const DemoRoute = DemoRouteImport.update({
|
const DemoRoute = DemoRouteImport.update({
|
||||||
id: '/demo',
|
id: '/demo',
|
||||||
path: '/demo',
|
path: '/demo',
|
||||||
@@ -26,31 +32,42 @@ const IndexRoute = IndexRouteImport.update({
|
|||||||
export interface FileRoutesByFullPath {
|
export interface FileRoutesByFullPath {
|
||||||
'/': typeof IndexRoute
|
'/': typeof IndexRoute
|
||||||
'/demo': typeof DemoRoute
|
'/demo': typeof DemoRoute
|
||||||
|
'/login': typeof LoginRoute
|
||||||
}
|
}
|
||||||
export interface FileRoutesByTo {
|
export interface FileRoutesByTo {
|
||||||
'/': typeof IndexRoute
|
'/': typeof IndexRoute
|
||||||
'/demo': typeof DemoRoute
|
'/demo': typeof DemoRoute
|
||||||
|
'/login': typeof LoginRoute
|
||||||
}
|
}
|
||||||
export interface FileRoutesById {
|
export interface FileRoutesById {
|
||||||
__root__: typeof rootRouteImport
|
__root__: typeof rootRouteImport
|
||||||
'/': typeof IndexRoute
|
'/': typeof IndexRoute
|
||||||
'/demo': typeof DemoRoute
|
'/demo': typeof DemoRoute
|
||||||
|
'/login': typeof LoginRoute
|
||||||
}
|
}
|
||||||
export interface FileRouteTypes {
|
export interface FileRouteTypes {
|
||||||
fileRoutesByFullPath: FileRoutesByFullPath
|
fileRoutesByFullPath: FileRoutesByFullPath
|
||||||
fullPaths: '/' | '/demo'
|
fullPaths: '/' | '/demo' | '/login'
|
||||||
fileRoutesByTo: FileRoutesByTo
|
fileRoutesByTo: FileRoutesByTo
|
||||||
to: '/' | '/demo'
|
to: '/' | '/demo' | '/login'
|
||||||
id: '__root__' | '/' | '/demo'
|
id: '__root__' | '/' | '/demo' | '/login'
|
||||||
fileRoutesById: FileRoutesById
|
fileRoutesById: FileRoutesById
|
||||||
}
|
}
|
||||||
export interface RootRouteChildren {
|
export interface RootRouteChildren {
|
||||||
IndexRoute: typeof IndexRoute
|
IndexRoute: typeof IndexRoute
|
||||||
DemoRoute: typeof DemoRoute
|
DemoRoute: typeof DemoRoute
|
||||||
|
LoginRoute: typeof LoginRoute
|
||||||
}
|
}
|
||||||
|
|
||||||
declare module '@tanstack/react-router' {
|
declare module '@tanstack/react-router' {
|
||||||
interface FileRoutesByPath {
|
interface FileRoutesByPath {
|
||||||
|
'/login': {
|
||||||
|
id: '/login'
|
||||||
|
path: '/login'
|
||||||
|
fullPath: '/login'
|
||||||
|
preLoaderRoute: typeof LoginRouteImport
|
||||||
|
parentRoute: typeof rootRouteImport
|
||||||
|
}
|
||||||
'/demo': {
|
'/demo': {
|
||||||
id: '/demo'
|
id: '/demo'
|
||||||
path: '/demo'
|
path: '/demo'
|
||||||
@@ -71,6 +88,7 @@ declare module '@tanstack/react-router' {
|
|||||||
const rootRouteChildren: RootRouteChildren = {
|
const rootRouteChildren: RootRouteChildren = {
|
||||||
IndexRoute: IndexRoute,
|
IndexRoute: IndexRoute,
|
||||||
DemoRoute: DemoRoute,
|
DemoRoute: DemoRoute,
|
||||||
|
LoginRoute: LoginRoute,
|
||||||
}
|
}
|
||||||
export const routeTree = rootRouteImport
|
export const routeTree = rootRouteImport
|
||||||
._addFileChildren(rootRouteChildren)
|
._addFileChildren(rootRouteChildren)
|
||||||
|
|||||||
@@ -1,29 +1,40 @@
|
|||||||
import { Link, Outlet, createRootRoute } from '@tanstack/react-router'
|
import { LayoutMain } from '@/pages/auth/modules/BaseHeader';
|
||||||
|
import { Outlet, createRootRoute } from '@tanstack/react-router'
|
||||||
import { TanStackRouterDevtools } from '@tanstack/react-router-devtools'
|
import { TanStackRouterDevtools } from '@tanstack/react-router-devtools'
|
||||||
import { Toaster } from "@/components/ui/sonner"
|
import { Toaster } from '@/components/ui/sonner'
|
||||||
|
import { AuthProvider } from '@/pages/auth'
|
||||||
|
import { TooltipProvider } from '@/components/ui/tooltip'
|
||||||
|
import { useLayoutStore } from '@/pages/auth/store';
|
||||||
|
import { useShallow } from 'zustand/shallow';
|
||||||
|
import { stackQueryClient } from '@/modules/query'
|
||||||
|
import { QueryClientProvider } from '@tanstack/react-query'
|
||||||
|
import clsx from 'clsx';
|
||||||
export const Route = createRootRoute({
|
export const Route = createRootRoute({
|
||||||
component: RootComponent,
|
component: RootComponent,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
function RootComponent() {
|
function RootComponent() {
|
||||||
|
const store = useLayoutStore(useShallow(state => ({
|
||||||
|
showBaseHeader: state.showBaseHeader,
|
||||||
|
})));
|
||||||
return (
|
return (
|
||||||
<>
|
<QueryClientProvider client={stackQueryClient}>
|
||||||
<div className="p-2 flex gap-2 text-lg">
|
<div className='h-full overflow-hidden'>
|
||||||
<Link
|
<LayoutMain />
|
||||||
to="/"
|
<AuthProvider mustLogin={true}>
|
||||||
activeProps={{
|
<TooltipProvider>
|
||||||
className: 'font-bold',
|
<main className={clsx('overflow-auto scrollbar', {
|
||||||
}}
|
'h-[calc(100%-3rem)]': store.showBaseHeader,
|
||||||
activeOptions={{ exact: true }}
|
'h-full': !store.showBaseHeader,
|
||||||
>
|
})}>
|
||||||
Home
|
<Outlet />
|
||||||
</Link>
|
</main>
|
||||||
|
</TooltipProvider>
|
||||||
|
</AuthProvider>
|
||||||
|
<TanStackRouterDevtools position="bottom-right" />
|
||||||
|
<Toaster />
|
||||||
</div>
|
</div>
|
||||||
<hr />
|
</QueryClientProvider>
|
||||||
<Outlet />
|
|
||||||
<TanStackRouterDevtools position="bottom-right" />
|
|
||||||
<Toaster />
|
|
||||||
</>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
9
src/routes/login.tsx
Normal file
9
src/routes/login.tsx
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import { createFileRoute } from '@tanstack/react-router'
|
||||||
|
import App from '@/pages/auth/page'
|
||||||
|
export const Route = createFileRoute('/login')({
|
||||||
|
component: RouteComponent,
|
||||||
|
})
|
||||||
|
|
||||||
|
function RouteComponent() {
|
||||||
|
return <App />
|
||||||
|
}
|
||||||
@@ -4,11 +4,14 @@ import path from 'path';
|
|||||||
import pkgs from './package.json';
|
import pkgs from './package.json';
|
||||||
import tailwindcss from '@tailwindcss/vite';
|
import tailwindcss from '@tailwindcss/vite';
|
||||||
import { tanstackRouter } from '@tanstack/router-plugin/vite'
|
import { tanstackRouter } from '@tanstack/router-plugin/vite'
|
||||||
|
import dotenv from 'dotenv';
|
||||||
|
import { VitePWA } from 'vite-plugin-pwa';
|
||||||
|
|
||||||
const isDev = process.env.NODE_ENV === 'development';
|
const env = dotenv.config().parsed || {};
|
||||||
|
const isDev = env.NODE_ENV === 'development' || process.env.NODE_ENV === 'development';
|
||||||
const basename = isDev ? '/' : pkgs?.basename || '/';
|
const basename = isDev ? '/' : pkgs?.basename || '/';
|
||||||
|
|
||||||
let target = process.env.VITE_API_URL || 'http://localhost:51515';
|
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' };
|
const apiProxy = { target: target, changeOrigin: true, ws: true, rewriteWsOrigin: true, secure: false, cookieDomainRewrite: 'localhost' };
|
||||||
let proxy = {
|
let proxy = {
|
||||||
'/root/': apiProxy,
|
'/root/': apiProxy,
|
||||||
@@ -26,7 +29,43 @@ export default defineConfig({
|
|||||||
autoCodeSplitting: true,
|
autoCodeSplitting: true,
|
||||||
}),
|
}),
|
||||||
react(),
|
react(),
|
||||||
tailwindcss()
|
tailwindcss(),
|
||||||
|
VitePWA({
|
||||||
|
injectRegister: 'auto',
|
||||||
|
registerType: 'autoUpdate',
|
||||||
|
// Workbox 缓存策略配置
|
||||||
|
workbox: {
|
||||||
|
// API 请求使用网络优先策略,确保获取最新数据
|
||||||
|
runtimeCaching: [
|
||||||
|
{
|
||||||
|
urlPattern: /^https?.*\/api\/.*/,
|
||||||
|
handler: 'NetworkFirst',
|
||||||
|
options: {
|
||||||
|
cacheName: 'api-cache',
|
||||||
|
expiration: {
|
||||||
|
maxEntries: 50,
|
||||||
|
maxAgeSeconds: 60 * 60 * 24, // 24小时
|
||||||
|
},
|
||||||
|
cacheableResponse: {
|
||||||
|
statuses: [0, 200],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
// 静态资源使用缓存优先,但设置较短过期时间
|
||||||
|
{
|
||||||
|
urlPattern: /^https?.*\.(js|css|woff2?|png|jpg|jpeg|svg|gif|ico)/,
|
||||||
|
handler: 'StaleWhileRevalidate',
|
||||||
|
options: {
|
||||||
|
cacheName: 'static-resources',
|
||||||
|
expiration: {
|
||||||
|
maxEntries: 100,
|
||||||
|
maxAgeSeconds: 60 * 60 * 24 * 7, // 7天
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}),
|
||||||
],
|
],
|
||||||
resolve: {
|
resolve: {
|
||||||
alias: {
|
alias: {
|
||||||
|
|||||||
Reference in New Issue
Block a user