10 Commits

54 changed files with 4005 additions and 93 deletions

View File

@@ -7,3 +7,65 @@
页面表格内单元样式细化
仪表盘填充真实数据
## 目录结构
```
public/ # 静态资源(图片、字体等)
src/
├── actions/ # 服务端 Action表单提交、数据操作等
├── app/ # App Router 核心目录
│ ├── (root)/ # 根路由组(无路径前缀)
│ ├── login/ # /login 路由页面
│ ├── favicon.ico # 网站图标
│ ├── globals.css # 全局样式
│ └── layout.tsx # 根布局组件
├── components/ # 可复用 UI 组件
├── hooks/ # 自定义 React Hooks
├── lib/ # 工具函数、第三方库封装
├── models/ # 数据模型、类型定义、Schema
└── proxy.ts # 代理配置API 转发等)
.env # 环境变量(本地开发)
.env.example # 环境变量示例模板
.gitignore # Git 忽略文件
.npmrc # npm 配置
biome.json # Biome 代码格式化/检查配置
bun.lock # Bun 包管理器锁文件
components.json # shadcn/ui 组件库配置
Dockerfile # Docker 容器化配置
next-env.d.ts # Next.js 类型声明
next.config.ts # Next.js 配置文件
package.json # 项目依赖与脚本
postcss.config.mjs # PostCSS 配置
README.md # 项目说明文档
tsconfig.json # TypeScript 配置
```
## 搭建开发环境
1. 下载本地项目:`git clone https://43.226.58.254:53000/lanhu/admin`
2. 安装依赖包:`bun install`
3. 创建环境变量文件 .env复制 .env.example 中的内容到 .env并根据实际情况修改
4. 启动项目终端运行 `bun run dev`
## 构建项目 & 版本管理
1. 在 package.json 文件中修改版本号
2. 构建并上传镜像, 终端运行 `./publish.ps1 <版本号>`
3. 终端执行成功后在 `https://43.226.58.254:53000/lanhu/admin` 发布最新版本
## 技术栈
| 类别 | 场景/库名 | 推荐方案/用途 |
| :--- | :--- | :--- |
| **核心框架** | Next.js | 服务框架 (React 全栈框架) |
| **UI / 样式体系** | Radix UI | 无样式基础 UI 组件原语 |
| | Tailwind CSS | CSS 框架 (原子化 CSS) |
| | lucide-react | 图标库 |
| **表单与数据验证** | React Hook Form | 表单状态管理及验证 |
| | Zod | 数据验证与类型推断 |
| **数据管理与通信** | Zustand | 全局状态管理库 |
| | TanStack Query | 服务端状态管理 (数据请求、缓存) |
| | TanStack Table | 无头 UI 表格库 |
| **工具库** | date-fns | 日期时间处理库 |
生产环境的项目部署通过单独的部署脚本进行管理,前端开发上线只需要构建以及发布版本,无需考虑部署问题。

View File

@@ -18,9 +18,11 @@
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"date-fns": "^4.1.0",
"jotai": "^2.19.0",
"lucide-react": "^0.562.0",
"next": "^16.0.10",
"next-themes": "^0.4.6",
"radix-ui": "^1.4.3",
"react": "^19.2.1",
"react-dom": "^19.2.1",
"react-hook-form": "^7.68.0",
@@ -164,28 +166,62 @@
"@radix-ui/primitive": ["@radix-ui/primitive@1.1.3", "https://registry.npmmirror.com/@radix-ui/primitive/-/primitive-1.1.3.tgz", {}, "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg=="],
"@radix-ui/react-accessible-icon": ["@radix-ui/react-accessible-icon@1.1.7", "https://registry.npmmirror.com/@radix-ui/react-accessible-icon/-/react-accessible-icon-1.1.7.tgz", { "dependencies": { "@radix-ui/react-visually-hidden": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-XM+E4WXl0OqUJFovy6GjmxxFyx9opfCAIUku4dlKRd5YEPqt4kALOkQOp0Of6reHuUkJuiPBEc5k0o4z4lTC8A=="],
"@radix-ui/react-accordion": ["@radix-ui/react-accordion@1.2.12", "https://registry.npmmirror.com/@radix-ui/react-accordion/-/react-accordion-1.2.12.tgz", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collapsible": "1.1.12", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-T4nygeh9YE9dLRPhAHSeOZi7HBXo+0kYIPJXayZfvWOWA0+n3dESrZbjfDPUABkUNym6Hd+f2IR113To8D2GPA=="],
"@radix-ui/react-alert-dialog": ["@radix-ui/react-alert-dialog@1.1.15", "https://registry.npmmirror.com/@radix-ui/react-alert-dialog/-/react-alert-dialog-1.1.15.tgz", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-dialog": "1.1.15", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-oTVLkEw5GpdRe29BqJ0LSDFWI3qu0vR1M0mUkOQWDIUnY/QIkLpgDMWuKxP94c2NAC2LGcgVhG1ImF3jkZ5wXw=="],
"@radix-ui/react-arrow": ["@radix-ui/react-arrow@1.1.7", "https://registry.npmmirror.com/@radix-ui/react-arrow/-/react-arrow-1.1.7.tgz", { "dependencies": { "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w=="],
"@radix-ui/react-aspect-ratio": ["@radix-ui/react-aspect-ratio@1.1.7", "https://registry.npmmirror.com/@radix-ui/react-aspect-ratio/-/react-aspect-ratio-1.1.7.tgz", { "dependencies": { "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Yq6lvO9HQyPwev1onK1daHCHqXVLzPhSVjmsNjCa2Zcxy2f7uJD2itDtxknv6FzAKCwD1qQkeVDmX/cev13n/g=="],
"@radix-ui/react-avatar": ["@radix-ui/react-avatar@1.1.10", "https://registry.npmmirror.com/@radix-ui/react-avatar/-/react-avatar-1.1.10.tgz", { "dependencies": { "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-is-hydrated": "0.1.0", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-V8piFfWapM5OmNCXTzVQY+E1rDa53zY+MQ4Y7356v4fFz6vqCyUtIz2rUD44ZEdwg78/jKmMJHj07+C/Z/rcog=="],
"@radix-ui/react-checkbox": ["@radix-ui/react-checkbox@1.3.3", "https://registry.npmmirror.com/@radix-ui/react-checkbox/-/react-checkbox-1.3.3.tgz", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-previous": "1.1.1", "@radix-ui/react-use-size": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-wBbpv+NQftHDdG86Qc0pIyXk5IR3tM8Vd0nWLKDcX8nNn4nXFOFwsKuqw2okA/1D/mpaAkmuyndrPJTYDNZtFw=="],
"@radix-ui/react-collapsible": ["@radix-ui/react-collapsible@1.1.12", "https://registry.npmmirror.com/@radix-ui/react-collapsible/-/react-collapsible-1.1.12.tgz", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Uu+mSh4agx2ib1uIGPP4/CKNULyajb3p92LsVXmH2EHVMTfZWpll88XJ0j4W0z3f8NK1eYl1+Mf/szHPmcHzyA=="],
"@radix-ui/react-collection": ["@radix-ui/react-collection@1.1.7", "https://registry.npmmirror.com/@radix-ui/react-collection/-/react-collection-1.1.7.tgz", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw=="],
"@radix-ui/react-compose-refs": ["@radix-ui/react-compose-refs@1.1.2", "https://registry.npmmirror.com/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg=="],
"@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "https://registry.npmmirror.com/@radix-ui/react-context/-/react-context-1.1.2.tgz", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="],
"@radix-ui/react-context-menu": ["@radix-ui/react-context-menu@2.2.16", "https://registry.npmmirror.com/@radix-ui/react-context-menu/-/react-context-menu-2.2.16.tgz", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-menu": "2.1.16", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-O8morBEW+HsVG28gYDZPTrT9UUovQUlJue5YO836tiTJhuIWBm/zQHc7j388sHWtdH/xUZurK9olD2+pcqx5ww=="],
"@radix-ui/react-dialog": ["@radix-ui/react-dialog@1.1.15", "https://registry.npmmirror.com/@radix-ui/react-dialog/-/react-dialog-1.1.15.tgz", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-focus-guards": "1.1.3", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-controllable-state": "1.2.2", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-TCglVRtzlffRNxRMEyR36DGBLJpeusFcgMVD9PZEzAKnUs1lKCgX5u9BmC2Yg+LL9MgZDugFFs1Vl+Jp4t/PGw=="],
"@radix-ui/react-direction": ["@radix-ui/react-direction@1.1.1", "https://registry.npmmirror.com/@radix-ui/react-direction/-/react-direction-1.1.1.tgz", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw=="],
"@radix-ui/react-dismissable-layer": ["@radix-ui/react-dismissable-layer@1.1.11", "https://registry.npmmirror.com/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.11.tgz", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-escape-keydown": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg=="],
"@radix-ui/react-dropdown-menu": ["@radix-ui/react-dropdown-menu@2.1.16", "https://registry.npmmirror.com/@radix-ui/react-dropdown-menu/-/react-dropdown-menu-2.1.16.tgz", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-menu": "2.1.16", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-1PLGQEynI/3OX/ftV54COn+3Sud/Mn8vALg2rWnBLnRaGtJDduNW/22XjlGgPdpcIbiQxjKtb7BkcjP00nqfJw=="],
"@radix-ui/react-focus-guards": ["@radix-ui/react-focus-guards@1.1.3", "https://registry.npmmirror.com/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.3.tgz", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-0rFg/Rj2Q62NCm62jZw0QX7a3sz6QCQU0LpZdNrJX8byRGaGVTqbrW9jAoIAHyMQqsNpeZ81YgSizOt5WXq0Pw=="],
"@radix-ui/react-focus-scope": ["@radix-ui/react-focus-scope@1.1.7", "https://registry.npmmirror.com/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.7.tgz", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-t2ODlkXBQyn7jkl6TNaw/MtVEVvIGelJDCG41Okq/KwUsJBwQ4XVZsHAVUkK4mBv3ewiAS3PGuUWuY2BoK4ZUw=="],
"@radix-ui/react-form": ["@radix-ui/react-form@0.1.8", "https://registry.npmmirror.com/@radix-ui/react-form/-/react-form-0.1.8.tgz", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-label": "2.1.7", "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-QM70k4Zwjttifr5a4sZFts9fn8FzHYvQ5PiB19O2HsYibaHSVt9fH9rzB0XZo/YcM+b7t/p7lYCT/F5eOeF5yQ=="],
"@radix-ui/react-hover-card": ["@radix-ui/react-hover-card@1.1.15", "https://registry.npmmirror.com/@radix-ui/react-hover-card/-/react-hover-card-1.1.15.tgz", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-popper": "1.2.8", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-qgTkjNT1CfKMoP0rcasmlH2r1DAiYicWsDsufxl940sT2wHNEWWv6FMWIQXWhVdmC1d/HYfbhQx60KYyAtKxjg=="],
"@radix-ui/react-id": ["@radix-ui/react-id@1.1.1", "https://registry.npmmirror.com/@radix-ui/react-id/-/react-id-1.1.1.tgz", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg=="],
"@radix-ui/react-label": ["@radix-ui/react-label@2.1.8", "https://registry.npmmirror.com/@radix-ui/react-label/-/react-label-2.1.8.tgz", { "dependencies": { "@radix-ui/react-primitive": "2.1.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-FmXs37I6hSBVDlO4y764TNz1rLgKwjJMQ0EGte6F3Cb3f4bIuHB/iLa/8I9VKkmOy+gNHq8rql3j686ACVV21A=="],
"@radix-ui/react-menu": ["@radix-ui/react-menu@2.1.16", "https://registry.npmmirror.com/@radix-ui/react-menu/-/react-menu-2.1.16.tgz", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-focus-guards": "1.1.3", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-popper": "1.2.8", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-roving-focus": "1.1.11", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-callback-ref": "1.1.1", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-72F2T+PLlphrqLcAotYPp0uJMr5SjP5SL01wfEspJbru5Zs5vQaSHb4VB3ZMJPimgHHCHG7gMOeOB9H3Hdmtxg=="],
"@radix-ui/react-menubar": ["@radix-ui/react-menubar@1.1.16", "https://registry.npmmirror.com/@radix-ui/react-menubar/-/react-menubar-1.1.16.tgz", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-menu": "2.1.16", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-roving-focus": "1.1.11", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-EB1FktTz5xRRi2Er974AUQZWg2yVBb1yjip38/lgwtCVRd3a+maUoGHN/xs9Yv8SY8QwbSEb+YrxGadVWbEutA=="],
"@radix-ui/react-navigation-menu": ["@radix-ui/react-navigation-menu@1.2.14", "https://registry.npmmirror.com/@radix-ui/react-navigation-menu/-/react-navigation-menu-1.2.14.tgz", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-layout-effect": "1.1.1", "@radix-ui/react-use-previous": "1.1.1", "@radix-ui/react-visually-hidden": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-YB9mTFQvCOAQMHU+C/jVl96WmuWeltyUEpRJJky51huhds5W2FQr1J8D/16sQlf0ozxkPK8uF3niQMdUwZPv5w=="],
"@radix-ui/react-one-time-password-field": ["@radix-ui/react-one-time-password-field@0.1.8", "https://registry.npmmirror.com/@radix-ui/react-one-time-password-field/-/react-one-time-password-field-0.1.8.tgz", { "dependencies": { "@radix-ui/number": "1.1.1", "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-roving-focus": "1.1.11", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-effect-event": "0.0.2", "@radix-ui/react-use-is-hydrated": "0.1.0", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-ycS4rbwURavDPVjCb5iS3aG4lURFDILi6sKI/WITUMZ13gMmn/xGjpLoqBAalhJaDk8I3UbCM5GzKHrnzwHbvg=="],
"@radix-ui/react-password-toggle-field": ["@radix-ui/react-password-toggle-field@0.1.3", "https://registry.npmmirror.com/@radix-ui/react-password-toggle-field/-/react-password-toggle-field-0.1.3.tgz", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-effect-event": "0.0.2", "@radix-ui/react-use-is-hydrated": "0.1.0" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-/UuCrDBWravcaMix4TdT+qlNdVwOM1Nck9kWx/vafXsdfj1ChfhOdfi3cy9SGBpWgTXwYCuboT/oYpJy3clqfw=="],
"@radix-ui/react-popover": ["@radix-ui/react-popover@1.1.15", "https://registry.npmmirror.com/@radix-ui/react-popover/-/react-popover-1.1.15.tgz", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-focus-guards": "1.1.3", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-popper": "1.2.8", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-controllable-state": "1.2.2", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-kr0X2+6Yy/vJzLYJUPCZEc8SfQcf+1COFoAqauJm74umQhta9M7lNJHP7QQS3vkvcGLQUbWpMzwrXYwrYztHKA=="],
"@radix-ui/react-popper": ["@radix-ui/react-popper@1.2.8", "https://registry.npmmirror.com/@radix-ui/react-popper/-/react-popper-1.2.8.tgz", { "dependencies": { "@floating-ui/react-dom": "^2.0.0", "@radix-ui/react-arrow": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-layout-effect": "1.1.1", "@radix-ui/react-use-rect": "1.1.1", "@radix-ui/react-use-size": "1.1.1", "@radix-ui/rect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw=="],
"@radix-ui/react-portal": ["@radix-ui/react-portal@1.1.9", "https://registry.npmmirror.com/@radix-ui/react-portal/-/react-portal-1.1.9.tgz", { "dependencies": { "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ=="],
@@ -194,6 +230,10 @@
"@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "https://registry.npmmirror.com/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="],
"@radix-ui/react-progress": ["@radix-ui/react-progress@1.1.7", "https://registry.npmmirror.com/@radix-ui/react-progress/-/react-progress-1.1.7.tgz", { "dependencies": { "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-vPdg/tF6YC/ynuBIJlk1mm7Le0VgW6ub6J2UWnTQ7/D23KXcPI1qy+0vBkgKgd38RCMJavBXpB83HPNFMTb0Fg=="],
"@radix-ui/react-radio-group": ["@radix-ui/react-radio-group@1.3.8", "https://registry.npmmirror.com/@radix-ui/react-radio-group/-/react-radio-group-1.3.8.tgz", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-roving-focus": "1.1.11", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-previous": "1.1.1", "@radix-ui/react-use-size": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-VBKYIYImA5zsxACdisNQ3BjCBfmbGH3kQlnFVqlWU4tXwjy7cGX8ta80BcrO+WJXIn5iBylEH3K6ZTlee//lgQ=="],
"@radix-ui/react-roving-focus": ["@radix-ui/react-roving-focus@1.1.11", "https://registry.npmmirror.com/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.11.tgz", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-7A6S9jSgm/S+7MdtNDSb+IU859vQqJ/QAtcYQcfFC6W8RS4IxIZDldLR0xqCFZ6DCyrQLjLPsxtTNch5jVA4lA=="],
"@radix-ui/react-scroll-area": ["@radix-ui/react-scroll-area@1.2.10", "https://registry.npmmirror.com/@radix-ui/react-scroll-area/-/react-scroll-area-1.2.10.tgz", { "dependencies": { "@radix-ui/number": "1.1.1", "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-tAXIa1g3sM5CGpVT0uIbUx/U3Gs5N8T52IICuCtObaos1S8fzsrPXG5WObkQN3S6NVl6wKgPhAIiBGbWnvc97A=="],
@@ -202,10 +242,22 @@
"@radix-ui/react-separator": ["@radix-ui/react-separator@1.1.8", "https://registry.npmmirror.com/@radix-ui/react-separator/-/react-separator-1.1.8.tgz", { "dependencies": { "@radix-ui/react-primitive": "2.1.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-sDvqVY4itsKwwSMEe0jtKgfTh+72Sy3gPmQpjqcQneqQ4PFmr/1I0YA+2/puilhggCe2gJcx5EBAYFkWkdpa5g=="],
"@radix-ui/react-slider": ["@radix-ui/react-slider@1.3.6", "https://registry.npmmirror.com/@radix-ui/react-slider/-/react-slider-1.3.6.tgz", { "dependencies": { "@radix-ui/number": "1.1.1", "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-layout-effect": "1.1.1", "@radix-ui/react-use-previous": "1.1.1", "@radix-ui/react-use-size": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-JPYb1GuM1bxfjMRlNLE+BcmBC8onfCi60Blk7OBqi2MLTFdS+8401U4uFjnwkOr49BLmXxLC6JHkvAsx5OJvHw=="],
"@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.4", "https://registry.npmmirror.com/@radix-ui/react-slot/-/react-slot-1.2.4.tgz", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA=="],
"@radix-ui/react-switch": ["@radix-ui/react-switch@1.2.6", "https://registry.npmmirror.com/@radix-ui/react-switch/-/react-switch-1.2.6.tgz", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-previous": "1.1.1", "@radix-ui/react-use-size": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-bByzr1+ep1zk4VubeEVViV592vu2lHE2BZY5OnzehZqOOgogN80+mNtCqPkhn2gklJqOpxWgPoYTSnhBCqpOXQ=="],
"@radix-ui/react-tabs": ["@radix-ui/react-tabs@1.1.13", "https://registry.npmmirror.com/@radix-ui/react-tabs/-/react-tabs-1.1.13.tgz", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-roving-focus": "1.1.11", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-7xdcatg7/U+7+Udyoj2zodtI9H/IIopqo+YOIcZOq1nJwXWBZ9p8xiu5llXlekDbZkca79a/fozEYQXIA4sW6A=="],
"@radix-ui/react-toast": ["@radix-ui/react-toast@1.2.15", "https://registry.npmmirror.com/@radix-ui/react-toast/-/react-toast-1.2.15.tgz", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-layout-effect": "1.1.1", "@radix-ui/react-visually-hidden": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-3OSz3TacUWy4WtOXV38DggwxoqJK4+eDkNMl5Z/MJZaoUPaP4/9lf81xXMe1I2ReTAptverZUpbPY4wWwWyL5g=="],
"@radix-ui/react-toggle": ["@radix-ui/react-toggle@1.1.10", "https://registry.npmmirror.com/@radix-ui/react-toggle/-/react-toggle-1.1.10.tgz", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-lS1odchhFTeZv3xwHH31YPObmJn8gOg7Lq12inrr0+BH/l3Tsq32VfjqH1oh80ARM3mlkfMic15n0kg4sD1poQ=="],
"@radix-ui/react-toggle-group": ["@radix-ui/react-toggle-group@1.1.11", "https://registry.npmmirror.com/@radix-ui/react-toggle-group/-/react-toggle-group-1.1.11.tgz", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-roving-focus": "1.1.11", "@radix-ui/react-toggle": "1.1.10", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-5umnS0T8JQzQT6HbPyO7Hh9dgd82NmS36DQr+X/YJ9ctFNCiiQd6IJAYYZ33LUwm8M+taCz5t2ui29fHZc4Y6Q=="],
"@radix-ui/react-toolbar": ["@radix-ui/react-toolbar@1.1.11", "https://registry.npmmirror.com/@radix-ui/react-toolbar/-/react-toolbar-1.1.11.tgz", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-roving-focus": "1.1.11", "@radix-ui/react-separator": "1.1.7", "@radix-ui/react-toggle-group": "1.1.11" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-4ol06/1bLoFu1nwUqzdD4Y5RZ9oDdKeiHIsntug54Hcr1pgaHiPqHFEaXI1IFP/EsOfROQZ8Mig9VTIRza6Tjg=="],
"@radix-ui/react-tooltip": ["@radix-ui/react-tooltip@1.2.8", "https://registry.npmmirror.com/@radix-ui/react-tooltip/-/react-tooltip-1.2.8.tgz", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-popper": "1.2.8", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-visually-hidden": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-tY7sVt1yL9ozIxvmbtN5qtmH2krXcBCfjEiCgKGLqunJHvgvZG2Pcl2oQ3kbcZARb1BGEHdkLzcYGO8ynVlieg=="],
"@radix-ui/react-use-callback-ref": ["@radix-ui/react-use-callback-ref@1.1.1", "https://registry.npmmirror.com/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.1.tgz", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg=="],
@@ -216,6 +268,8 @@
"@radix-ui/react-use-escape-keydown": ["@radix-ui/react-use-escape-keydown@1.1.1", "https://registry.npmmirror.com/@radix-ui/react-use-escape-keydown/-/react-use-escape-keydown-1.1.1.tgz", { "dependencies": { "@radix-ui/react-use-callback-ref": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Il0+boE7w/XebUHyBjroE+DbByORGR9KKmITzbR7MyQ4akpORYP/ZmbhAr0DG7RmmBqoOnZdy2QlvajJ2QA59g=="],
"@radix-ui/react-use-is-hydrated": ["@radix-ui/react-use-is-hydrated@0.1.0", "https://registry.npmmirror.com/@radix-ui/react-use-is-hydrated/-/react-use-is-hydrated-0.1.0.tgz", { "dependencies": { "use-sync-external-store": "^1.5.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-U+UORVEq+cTnRIaostJv9AGdV3G6Y+zbVd+12e18jQ5A3c0xL03IhnHuiU4UV69wolOQp5GfR58NW/EgdQhwOA=="],
"@radix-ui/react-use-layout-effect": ["@radix-ui/react-use-layout-effect@1.1.1", "https://registry.npmmirror.com/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.1.tgz", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ=="],
"@radix-ui/react-use-previous": ["@radix-ui/react-use-previous@1.1.1", "https://registry.npmmirror.com/@radix-ui/react-use-previous/-/react-use-previous-1.1.1.tgz", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-2dHfToCj/pzca2Ck724OZ5L0EVrr3eHRNsG/b3xQJLA2hZpVCS99bLAX+hm1IHXDEnzU6by5z/5MIY794/a8NQ=="],
@@ -306,6 +360,8 @@
"jiti": ["jiti@2.6.1", "https://registry.npmmirror.com/jiti/-/jiti-2.6.1.tgz", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="],
"jotai": ["jotai@2.19.0", "https://registry.npmmirror.com/jotai/-/jotai-2.19.0.tgz", { "peerDependencies": { "@babel/core": ">=7.0.0", "@babel/template": ">=7.0.0", "@types/react": ">=17.0.0", "react": ">=17.0.0" }, "optionalPeers": ["@babel/core", "@babel/template", "@types/react", "react"] }, "sha512-r2wwxEXP1F2JteDLZEOPoIpAHhV89paKsN5GWVYndPNMMP/uVZDcC+fNj0A8NjKgaPWzdyO8Vp8YcYKe0uCEqQ=="],
"lightningcss": ["lightningcss@1.30.2", "https://registry.npmmirror.com/lightningcss/-/lightningcss-1.30.2.tgz", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.30.2", "lightningcss-darwin-arm64": "1.30.2", "lightningcss-darwin-x64": "1.30.2", "lightningcss-freebsd-x64": "1.30.2", "lightningcss-linux-arm-gnueabihf": "1.30.2", "lightningcss-linux-arm64-gnu": "1.30.2", "lightningcss-linux-arm64-musl": "1.30.2", "lightningcss-linux-x64-gnu": "1.30.2", "lightningcss-linux-x64-musl": "1.30.2", "lightningcss-win32-arm64-msvc": "1.30.2", "lightningcss-win32-x64-msvc": "1.30.2" } }, "sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ=="],
"lightningcss-android-arm64": ["lightningcss-android-arm64@1.30.2", "https://registry.npmmirror.com/lightningcss-android-arm64/-/lightningcss-android-arm64-1.30.2.tgz", { "os": "android", "cpu": "arm64" }, "sha512-BH9sEdOCahSgmkVhBLeU7Hc9DWeZ1Eb6wNS6Da8igvUwAe0sqROHddIlvU06q3WyXVEOYDZ6ykBZQnjTbmo4+A=="],
@@ -344,6 +400,8 @@
"postcss": ["postcss@8.5.3", "https://registry.npmmirror.com/postcss/-/postcss-8.5.3.tgz", { "dependencies": { "nanoid": "3.3.9", "picocolors": "1.1.1", "source-map-js": "1.2.1" } }, "sha512-dle9A3yYxlBSrt8Fu+IpjGT8SY8hN0mlaA6GY8t0P5PjIOZemULz/E2Bnm/2dcUOena75OTNkHI76uZBNUUq3A=="],
"radix-ui": ["radix-ui@1.4.3", "https://registry.npmmirror.com/radix-ui/-/radix-ui-1.4.3.tgz", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-accessible-icon": "1.1.7", "@radix-ui/react-accordion": "1.2.12", "@radix-ui/react-alert-dialog": "1.1.15", "@radix-ui/react-arrow": "1.1.7", "@radix-ui/react-aspect-ratio": "1.1.7", "@radix-ui/react-avatar": "1.1.10", "@radix-ui/react-checkbox": "1.3.3", "@radix-ui/react-collapsible": "1.1.12", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-context-menu": "2.2.16", "@radix-ui/react-dialog": "1.1.15", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-dropdown-menu": "2.1.16", "@radix-ui/react-focus-guards": "1.1.3", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-form": "0.1.8", "@radix-ui/react-hover-card": "1.1.15", "@radix-ui/react-label": "2.1.7", "@radix-ui/react-menu": "2.1.16", "@radix-ui/react-menubar": "1.1.16", "@radix-ui/react-navigation-menu": "1.2.14", "@radix-ui/react-one-time-password-field": "0.1.8", "@radix-ui/react-password-toggle-field": "0.1.3", "@radix-ui/react-popover": "1.1.15", "@radix-ui/react-popper": "1.2.8", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-progress": "1.1.7", "@radix-ui/react-radio-group": "1.3.8", "@radix-ui/react-roving-focus": "1.1.11", "@radix-ui/react-scroll-area": "1.2.10", "@radix-ui/react-select": "2.2.6", "@radix-ui/react-separator": "1.1.7", "@radix-ui/react-slider": "1.3.6", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-switch": "1.2.6", "@radix-ui/react-tabs": "1.1.13", "@radix-ui/react-toast": "1.2.15", "@radix-ui/react-toggle": "1.1.10", "@radix-ui/react-toggle-group": "1.1.11", "@radix-ui/react-toolbar": "1.1.11", "@radix-ui/react-tooltip": "1.2.8", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-effect-event": "0.0.2", "@radix-ui/react-use-escape-keydown": "1.1.1", "@radix-ui/react-use-is-hydrated": "0.1.0", "@radix-ui/react-use-layout-effect": "1.1.1", "@radix-ui/react-use-size": "1.1.1", "@radix-ui/react-visually-hidden": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-aWizCQiyeAenIdUbqEpXgRA1ya65P13NKn/W8rWkcN0OPkRDxdBVLWnIEDsS2RpwCK2nobI7oMUSmexzTDyAmA=="],
"react": ["react@19.2.3", "https://registry.npmmirror.com/react/-/react-19.2.3.tgz", {}, "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA=="],
"react-dom": ["react-dom@19.2.3", "https://registry.npmmirror.com/react-dom/-/react-dom-19.2.3.tgz", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.3" } }, "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg=="],
@@ -388,20 +446,34 @@
"use-sidecar": ["use-sidecar@1.1.3", "https://registry.npmmirror.com/use-sidecar/-/use-sidecar-1.1.3.tgz", { "dependencies": { "detect-node-es": "^1.1.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ=="],
"use-sync-external-store": ["use-sync-external-store@1.6.0", "https://registry.npmmirror.com/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w=="],
"zod": ["zod@3.25.76", "https://registry.npmmirror.com/zod/-/zod-3.25.76.tgz", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
"zustand": ["zustand@5.0.9", "https://registry.npmmirror.com/zustand/-/zustand-5.0.9.tgz", { "peerDependencies": { "@types/react": ">=18.0.0", "immer": ">=9.0.6", "react": ">=18.0.0", "use-sync-external-store": ">=1.2.0" }, "optionalPeers": ["@types/react", "immer", "react", "use-sync-external-store"] }, "sha512-ALBtUj0AfjJt3uNRQoL1tL2tMvj6Gp/6e39dnfT6uzpelGru8v1tPOGBzayOWbPJvujM8JojDk3E1LxeFisBNg=="],
"@radix-ui/react-alert-dialog/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "https://registry.npmmirror.com/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
"@radix-ui/react-collection/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "https://registry.npmmirror.com/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
"@radix-ui/react-dialog/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "https://registry.npmmirror.com/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
"@radix-ui/react-form/@radix-ui/react-label": ["@radix-ui/react-label@2.1.7", "https://registry.npmmirror.com/@radix-ui/react-label/-/react-label-2.1.7.tgz", { "dependencies": { "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-YT1GqPSL8kJn20djelMX7/cTRp/Y9w5IZHvfxQTVHrOqa2yMl7i/UfMqKRU5V7mEyKTrUVgJXhNQPVCG8PBLoQ=="],
"@radix-ui/react-label/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.4", "https://registry.npmmirror.com/@radix-ui/react-primitive/-/react-primitive-2.1.4.tgz", { "dependencies": { "@radix-ui/react-slot": "1.2.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg=="],
"@radix-ui/react-menu/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "https://registry.npmmirror.com/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
"@radix-ui/react-popover/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "https://registry.npmmirror.com/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
"@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "https://registry.npmmirror.com/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
"@radix-ui/react-select/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "https://registry.npmmirror.com/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
"@radix-ui/react-separator/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.4", "https://registry.npmmirror.com/@radix-ui/react-primitive/-/react-primitive-2.1.4.tgz", { "dependencies": { "@radix-ui/react-slot": "1.2.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg=="],
"@radix-ui/react-toolbar/@radix-ui/react-separator": ["@radix-ui/react-separator@1.1.7", "https://registry.npmmirror.com/@radix-ui/react-separator/-/react-separator-1.1.7.tgz", { "dependencies": { "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-0HEb8R9E8A+jZjvmFCy/J4xhbXy3TV+9XSnGJ3KvTtjlIUy/YQ/p6UYZvi7YbeoeXdyU9+Y3scizK6hkY37baA=="],
"@radix-ui/react-tooltip/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "https://registry.npmmirror.com/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
"@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.7.1", "https://registry.npmmirror.com/@emnapi/core/-/core-1.7.1.tgz", { "dependencies": { "@emnapi/wasi-threads": "1.1.0", "tslib": "^2.4.0" }, "bundled": true }, "sha512-o1uhUASyo921r2XtHYOHy7gdkGLge8ghBEQHMWmyJFoXlpU58kIrhhN3w26lpQb6dspetweapMn2CSNwQ8I4wg=="],
@@ -417,5 +489,11 @@
"@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "https://registry.npmmirror.com/tslib/-/tslib-2.8.1.tgz", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
"next/postcss": ["postcss@8.4.31", "https://registry.npmmirror.com/postcss/-/postcss-8.4.31.tgz", { "dependencies": { "nanoid": "3.3.9", "picocolors": "1.1.1", "source-map-js": "1.2.1" } }, "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ=="],
"radix-ui/@radix-ui/react-label": ["@radix-ui/react-label@2.1.7", "https://registry.npmmirror.com/@radix-ui/react-label/-/react-label-2.1.7.tgz", { "dependencies": { "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-YT1GqPSL8kJn20djelMX7/cTRp/Y9w5IZHvfxQTVHrOqa2yMl7i/UfMqKRU5V7mEyKTrUVgJXhNQPVCG8PBLoQ=="],
"radix-ui/@radix-ui/react-separator": ["@radix-ui/react-separator@1.1.7", "https://registry.npmmirror.com/@radix-ui/react-separator/-/react-separator-1.1.7.tgz", { "dependencies": { "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-0HEb8R9E8A+jZjvmFCy/J4xhbXy3TV+9XSnGJ3KvTtjlIUy/YQ/p6UYZvi7YbeoeXdyU9+Y3scizK6hkY37baA=="],
"radix-ui/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "https://registry.npmmirror.com/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
}
}

View File

@@ -21,9 +21,11 @@
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"date-fns": "^4.1.0",
"jotai": "^2.19.0",
"lucide-react": "^0.562.0",
"next": "^16.0.10",
"next-themes": "^0.4.6",
"radix-ui": "^1.4.3",
"react": "^19.2.1",
"react-dom": "^19.2.1",
"react-hook-form": "^7.68.0",

16
publish.ps1 Normal file
View File

@@ -0,0 +1,16 @@
if (-not $args) {
Write-Error "需要指定版本号"
exit 1
}
$confrim = Read-Host "构建版本为 [admin:$($args[0])],是否继续?(y/n)"
if ($confrim -ne "y") {
Write-Host "已取消构建"
exit 0
}
docker build -t 43.226.58.254:53000/lanhu/admin:latest .
docker build -t 43.226.58.254:53000/lanhu/admin:$($args[0]) .
docker push 43.226.58.254:53000/lanhu/admin:latest
docker push 43.226.58.254:53000/lanhu/admin:$($args[0])

37
src/actions/admin.ts Normal file
View File

@@ -0,0 +1,37 @@
"use server"
import type { PageRecord } from "@/lib/api"
import type { Admin } from "@/models/admin"
import { callByUser } from "./base"
export async function getPageAdmin(params: { page: number; size: number }) {
return callByUser<PageRecord<Admin>>("/api/admin/admin/page", params)
}
export async function createAdmin(data: {
username: string
password: string
name?: string
phone?: string
email?: string
status?: number
roles?: number[]
}) {
return callByUser<Admin>("/api/admin/admin/create", data)
}
export async function updateAdmin(data: {
id: number
password?: string
name?: string
phone?: string
email?: string
status?: number
roles?: number[]
}) {
return callByUser<Admin>("/api/admin/admin/update", data)
}
export async function deleteAdmin(id: number) {
return callByUser<Admin>("/api/admin/admin/remove", { id })
}

View File

@@ -1,7 +1,7 @@
"use server"
import { cookies } from "next/headers"
import type { ApiResponse } from "@/lib/api"
import type { User } from "@/models/user"
import type { Admin } from "@/models/admin"
import { callByDevice, callByUser } from "./base"
export type TokenResp = {
@@ -16,7 +16,7 @@ export async function login(params: {
username: string
password: string
remember: boolean
}): Promise<ApiResponse> {
}): Promise<ApiResponse<string[]>> {
const resp = await callByDevice<TokenResp>("/api/auth/token", {
grant_type: "password",
login_type: "password",
@@ -43,7 +43,7 @@ export async function login(params: {
return {
success: true,
data: undefined,
data: data.scope?.split(" ") || [],
}
}
@@ -61,16 +61,8 @@ export async function logout() {
}
// 删除 cookies
cookieStore.set("admin/auth_token", "", {
httpOnly: true,
sameSite: "strict",
maxAge: -1,
})
cookieStore.set("admin/auth_refresh", "", {
httpOnly: true,
sameSite: "strict",
maxAge: -1,
})
cookieStore.delete("admin/auth_token")
cookieStore.delete("admin/auth_refresh")
return {
success: true,
@@ -79,7 +71,7 @@ export async function logout() {
}
export async function getProfile() {
return await callByUser<User>("/api/auth/introspect")
return await callByUser<Admin & { scopes: string[] }>("/api/auth/introspect")
}
export async function refreshAuth() {
@@ -128,5 +120,6 @@ export async function refreshAuth() {
return {
access_token: nextAccessToken,
refresh_token: nextRefreshToken,
scopes: data.scope?.split(" ") || [],
}
}

19
src/actions/permission.ts Normal file
View File

@@ -0,0 +1,19 @@
"use server"
import type { PageRecord } from "@/lib/api"
import type { Permission } from "@/models/permission"
import { callByUser } from "./base"
export async function getPagePermission(params: {
page: number
size: number
}) {
return callByUser<PageRecord<Permission>>(
"/api/admin/permission/page",
params,
)
}
export async function getAllPermissions() {
return callByUser<Permission[]>("/api/admin/permission/list", {})
}

67
src/actions/product.ts Normal file
View File

@@ -0,0 +1,67 @@
"use server"
import type { PageRecord } from "@/lib/api"
import type { Product } from "@/models/product"
import type { ProductSku } from "@/models/product_sku"
import { callByUser } from "./base"
export async function getAllProduct() {
return callByUser<Product[]>("/api/admin/product/all")
}
export async function getPageProductSku(params: {
page: number
size: number
product_id?: number
}) {
return callByUser<PageRecord<ProductSku>>(
"/api/admin/product/sku/page",
params,
)
}
export async function createProductSku(data: {
product_id: number
code: string
name: string
price: string
discount_id?: number
}) {
return callByUser<ProductSku>("/api/admin/product/sku/create", {
product_id: data.product_id,
code: data.code,
name: data.name,
price: data.price,
discount_id: data.discount_id,
})
}
export async function updateProductSku(data: {
id: number
code?: string
name?: string
price?: string
discount_id?: number | null
}) {
return callByUser<ProductSku>("/api/admin/product/sku/update", {
id: data.id,
code: data.code,
name: data.name,
price: data.price,
discount_id: data.discount_id,
})
}
export async function deleteProductSku(id: number) {
return callByUser<ProductSku>("/api/admin/product/sku/remove", { id })
}
export async function batchUpdateProductSkuDiscount(data: {
product_id: number
discount_id: number | null
}) {
return callByUser<void>("/api/admin/product/sku/update/discount/batch", {
product_id: data.product_id,
discount_id: data.discount_id,
})
}

View File

@@ -0,0 +1,47 @@
"use server"
import type { PageRecord } from "@/lib/api"
import type { ProductDiscount } from "@/models/product_discount"
import { callByUser } from "./base"
export async function getAllProductDiscount() {
return callByUser<ProductDiscount[]>("/api/admin/product/discount/all")
}
export async function getPageProductDiscount(params: {
page: number
size: number
}) {
return callByUser<PageRecord<ProductDiscount>>(
"/api/admin/product/discount/page",
params,
)
}
export async function createProductDiscount(data: {
name: string
discount: string
}) {
return callByUser<ProductDiscount>("/api/admin/product/discount/create", {
name: data.name,
discount: Number(data.discount),
})
}
export async function updateProductDiscount(data: {
id: number
name?: string
discount?: string
}) {
return callByUser<ProductDiscount>("/api/admin/product/discount/update", {
id: data.id,
name: data.name,
discount: data.discount ? Number(data.discount) : undefined,
})
}
export async function deleteProductDiscount(id: number) {
return callByUser<ProductDiscount>("/api/admin/product/discount/remove", {
id,
})
}

38
src/actions/role.ts Normal file
View File

@@ -0,0 +1,38 @@
"use server"
import type { PageRecord } from "@/lib/api"
import type { Role } from "@/models/role"
import { callByUser } from "./base"
export async function getAllRoles() {
return callByUser<Role[]>("/api/admin/admin-role/list", {})
}
export async function getPageRole(params: { page: number; size: number }) {
return callByUser<PageRecord<Role>>("/api/admin/admin-role/page", params)
}
export async function createRole(data: {
name: string
description?: string
active?: boolean
sort?: number
permissions?: number[]
}) {
return callByUser<Role>("/api/admin/admin-role/create", data)
}
export async function updateRole(data: {
id: number
name?: string
description?: string
active?: boolean
sort?: number
permissions?: number[]
}) {
return callByUser<Role>("/api/admin/admin-role/update", data)
}
export async function deleteRole(id: number) {
return callByUser<Role>("/api/admin/admin-role/remove", { id })
}

View File

@@ -0,0 +1,159 @@
import { useCallback, useEffect, useState } from "react"
import { toast } from "sonner"
import { updateAdmin } from "@/actions/admin"
import { getAllRoles } from "@/actions/role"
import { Button } from "@/components/ui/button"
import { Checkbox } from "@/components/ui/checkbox"
import {
Dialog,
DialogClose,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog"
import type { Admin } from "@/models/admin"
import type { Role } from "@/models/role"
export function AssignRoles(props: { admin: Admin; onSuccess?: () => void }) {
const [open, setOpen] = useState(false)
const [loading, setLoading] = useState(false)
const [submitting, setSubmitting] = useState(false)
const [roles, setRoles] = useState<Role[]>([])
const [selected, setSelected] = useState<Set<number>>(new Set())
const fetchRoles = useCallback(async () => {
setLoading(true)
try {
const resp = await getAllRoles()
if (resp.success) {
setRoles(resp.data ?? [])
} else {
toast.error(resp.message ?? "获取角色列表失败")
}
} catch (error) {
const message = error instanceof Error ? error.message : error
toast.error(`接口请求错误: ${message}`)
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
if (open) {
fetchRoles()
}
}, [open, fetchRoles])
const handleToggle = (id: number, checked: boolean) => {
setSelected(prev => {
const next = new Set(prev)
if (checked) {
next.add(id)
} else {
next.delete(id)
}
return next
})
}
const handleSubmit = async () => {
setSubmitting(true)
try {
const resp = await updateAdmin({
id: props.admin.id,
roles: Array.from(selected),
})
if (resp.success) {
toast.success("角色分配成功")
props.onSuccess?.()
setOpen(false)
} else {
toast.error(resp.message ?? "角色分配失败")
}
} catch (error) {
const message = error instanceof Error ? error.message : error
toast.error(`接口请求错误: ${message}`)
} finally {
setSubmitting(false)
}
}
const handleOpenChange = (value: boolean) => {
if (value) {
const existingIds = new Set((props.admin.roles ?? []).map(r => r.id))
setSelected(existingIds)
} else {
setSelected(new Set())
setRoles([])
}
setOpen(value)
}
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogTrigger asChild>
<Button size="sm" variant="secondary">
</Button>
</DialogTrigger>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle> · {props.admin.username}</DialogTitle>
</DialogHeader>
<div className="max-h-[60vh] overflow-y-auto pr-1">
{loading ? (
<div className="py-8 text-center text-sm text-muted-foreground">
...
</div>
) : roles.length === 0 ? (
<div className="py-8 text-center text-sm text-muted-foreground">
</div>
) : (
<div className="flex flex-col gap-2">
{roles.map(role => {
const checkboxId = `assign-role-${role.id}`
const isChecked = selected.has(role.id)
return (
<div key={role.id} className="flex items-center gap-2 py-0.5">
<Checkbox
id={checkboxId}
checked={isChecked}
onCheckedChange={checked =>
handleToggle(role.id, checked === true)
}
/>
<label
htmlFor={checkboxId}
className="flex items-center gap-1.5 cursor-pointer select-none text-sm"
>
{role.name}
{role.description && (
<span className="text-xs text-muted-foreground">
{role.description}
</span>
)}
</label>
</div>
)
})}
</div>
)}
</div>
<DialogFooter>
<DialogClose asChild>
<Button variant="ghost"></Button>
</DialogClose>
<Button onClick={handleSubmit} disabled={submitting || loading}>
{submitting ? "保存中..." : "保存"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}

View File

@@ -0,0 +1,237 @@
import { zodResolver } from "@hookform/resolvers/zod"
import { useState } from "react"
import { Controller, useForm } from "react-hook-form"
import { toast } from "sonner"
import z from "zod"
import { createAdmin } from "@/actions/admin"
import { Button } from "@/components/ui/button"
import {
Dialog,
DialogClose,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog"
import {
Field,
FieldError,
FieldGroup,
FieldLabel,
} from "@/components/ui/field"
import { Input } from "@/components/ui/input"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import { AdminStatus } from "@/models/admin"
const schema = z.object({
username: z.string().min(1, "请输入用户名"),
password: z.string().min(6, "密码至少 6 位"),
name: z.string().optional(),
phone: z.string().optional(),
email: z.string().email("请输入有效的邮箱地址").optional().or(z.literal("")),
status: z.nativeEnum(AdminStatus),
})
type FormValues = z.infer<typeof schema>
export function CreateAdmin(props: { onSuccess?: () => void }) {
const [open, setOpen] = useState(false)
const form = useForm<FormValues>({
resolver: zodResolver(schema),
defaultValues: {
username: "",
password: "",
name: "",
phone: "",
email: "",
status: AdminStatus.Enabled,
},
})
const onSubmit = async (data: FormValues) => {
try {
const resp = await createAdmin({
username: data.username,
password: data.password,
name: data.name || undefined,
phone: data.phone || undefined,
email: data.email || undefined,
status: data.status,
})
if (resp.success) {
form.reset()
toast.success("管理员创建成功")
props.onSuccess?.()
setOpen(false)
} else {
toast.error(resp.message)
}
} catch (error) {
const message = error instanceof Error ? error.message : error
toast.error(`接口请求错误: ${message}`)
}
}
const handleOpenChange = (value: boolean) => {
if (!value) {
form.reset()
}
setOpen(value)
}
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogTrigger asChild>
<Button></Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle></DialogTitle>
</DialogHeader>
<form id="admin-create" onSubmit={form.handleSubmit(onSubmit)}>
<FieldGroup>
<Controller
control={form.control}
name="username"
render={({ field, fieldState }) => (
<Field>
<FieldLabel htmlFor="admin-create-username">
</FieldLabel>
<Input
id="admin-create-username"
autoComplete="off"
{...field}
aria-invalid={fieldState.invalid}
/>
{fieldState.invalid && (
<FieldError errors={[fieldState.error]} />
)}
</Field>
)}
/>
<Controller
control={form.control}
name="password"
render={({ field, fieldState }) => (
<Field>
<FieldLabel htmlFor="admin-create-password"></FieldLabel>
<Input
id="admin-create-password"
type="password"
autoComplete="new-password"
{...field}
aria-invalid={fieldState.invalid}
/>
{fieldState.invalid && (
<FieldError errors={[fieldState.error]} />
)}
</Field>
)}
/>
<Controller
control={form.control}
name="name"
render={({ field, fieldState }) => (
<Field>
<FieldLabel htmlFor="admin-create-name"></FieldLabel>
<Input
id="admin-create-name"
{...field}
aria-invalid={fieldState.invalid}
/>
{fieldState.invalid && (
<FieldError errors={[fieldState.error]} />
)}
</Field>
)}
/>
<Controller
control={form.control}
name="phone"
render={({ field, fieldState }) => (
<Field>
<FieldLabel htmlFor="admin-create-phone"></FieldLabel>
<Input
id="admin-create-phone"
{...field}
aria-invalid={fieldState.invalid}
/>
{fieldState.invalid && (
<FieldError errors={[fieldState.error]} />
)}
</Field>
)}
/>
<Controller
control={form.control}
name="email"
render={({ field, fieldState }) => (
<Field>
<FieldLabel htmlFor="admin-create-email"></FieldLabel>
<Input
id="admin-create-email"
{...field}
aria-invalid={fieldState.invalid}
/>
{fieldState.invalid && (
<FieldError errors={[fieldState.error]} />
)}
</Field>
)}
/>
<Controller
control={form.control}
name="status"
render={({ field }) => (
<Field>
<FieldLabel></FieldLabel>
<Select
value={String(field.value)}
onValueChange={value => field.onChange(Number(value))}
>
<SelectTrigger className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value={String(AdminStatus.Enabled)}>
</SelectItem>
<SelectItem value={String(AdminStatus.Disabled)}>
</SelectItem>
</SelectContent>
</Select>
</Field>
)}
/>
</FieldGroup>
</form>
<DialogFooter>
<DialogClose asChild>
<Button variant="ghost"></Button>
</DialogClose>
<Button type="submit" form="admin-create">
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}

View File

@@ -0,0 +1,244 @@
"use client"
import { Suspense, useState } from "react"
import { toast } from "sonner"
import { deleteAdmin, getPageAdmin, updateAdmin } from "@/actions/admin"
import { DataTable, useDataTable } from "@/components/data-table"
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import {
HoverCard,
HoverCardContent,
HoverCardTrigger,
} from "@/components/ui/hover-card"
import { type Admin, AdminStatus } from "@/models/admin"
import type { Role } from "@/models/role"
import { AssignRoles } from "./assign-roles"
import { CreateAdmin } from "./create"
import { UpdateAdmin } from "./update"
export default function AdminPage() {
const table = useDataTable((page, size) => getPageAdmin({ page, size }))
return (
<div className="flex flex-col gap-3">
{/* 操作栏 */}
<div className="flex justify-between items-stretch">
<div className="flex gap-3">
<CreateAdmin onSuccess={table.refresh} />
</div>
</div>
{/* 数据表 */}
<Suspense>
<DataTable
{...table}
columns={[
{ header: "用户名", accessorKey: "username" },
{
header: "姓名",
accessorFn: row => row.name ?? "-",
},
{
header: "手机号",
accessorFn: row => row.phone ?? "-",
},
{
header: "邮箱",
accessorFn: row => row.email ?? "-",
},
{
header: "状态",
cell: ({ row }) => (
<Badge
variant={
row.original.status === AdminStatus.Enabled
? "default"
: "secondary"
}
>
{row.original.status === AdminStatus.Enabled
? "启用"
: "禁用"}
</Badge>
),
},
{
header: "角色",
cell: ({ row }) => <RolesCell roles={row.original.roles ?? []} />,
},
{
header: "操作",
cell: ({ row }) => (
<div className="flex gap-2">
<UpdateAdmin admin={row.original} onSuccess={table.refresh} />
<AssignRoles admin={row.original} onSuccess={table.refresh} />
<ToggleStatusButton
admin={row.original}
onSuccess={table.refresh}
/>
<DeleteButton
admin={row.original}
onSuccess={table.refresh}
/>
</div>
),
},
]}
/>
</Suspense>
</div>
)
}
function RolesCell({ roles }: { roles: Role[] }) {
if (!roles || roles.length === 0) {
return <span className="text-muted-foreground text-xs"></span>
}
const preview = roles.slice(0, 3)
const rest = roles.length - preview.length
return (
<HoverCard>
<HoverCardTrigger asChild>
<div className="flex flex-wrap gap-1 cursor-default max-w-52">
{preview.map(r => (
<Badge key={r.id} variant="secondary">
{r.name}
</Badge>
))}
{rest > 0 && <Badge variant="outline">+{rest}</Badge>}
</div>
</HoverCardTrigger>
<HoverCardContent className="w-64" align="start">
<p className="text-xs font-medium text-muted-foreground mb-2">
{roles.length}
</p>
<div className="flex flex-wrap gap-1.5">
{roles.map(r => (
<Badge key={r.id} variant="secondary">
{r.name}
</Badge>
))}
</div>
</HoverCardContent>
</HoverCard>
)
}
function ToggleStatusButton({
admin,
onSuccess,
}: {
admin: Admin
onSuccess?: () => void
}) {
const [loading, setLoading] = useState(false)
const isEnabled = admin.status === AdminStatus.Enabled
const handleConfirm = async () => {
setLoading(true)
try {
const resp = await updateAdmin({
id: admin.id,
status: isEnabled ? AdminStatus.Disabled : AdminStatus.Enabled,
})
if (resp.success) {
toast.success(isEnabled ? "已禁用" : "已启用")
onSuccess?.()
} else {
toast.error(resp.message ?? "操作失败")
}
} catch (error) {
const message = error instanceof Error ? error.message : error
toast.error(`接口请求错误: ${message}`)
} finally {
setLoading(false)
}
}
return (
<AlertDialog>
<AlertDialogTrigger asChild>
<Button size="sm" variant="secondary" disabled={loading}>
{isEnabled ? "禁用" : "启用"}
</Button>
</AlertDialogTrigger>
<AlertDialogContent size="sm">
<AlertDialogHeader>
<AlertDialogTitle>{isEnabled ? "禁用" : "启用"}</AlertDialogTitle>
<AlertDialogDescription>
{isEnabled ? "禁用" : "启用"}{admin.username}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel>
<AlertDialogAction onClick={handleConfirm}></AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)
}
function DeleteButton({
admin,
onSuccess,
}: {
admin: Admin
onSuccess?: () => void
}) {
const [loading, setLoading] = useState(false)
const handleConfirm = async () => {
setLoading(true)
try {
const resp = await deleteAdmin(admin.id)
if (resp.success) {
toast.success("删除成功")
onSuccess?.()
} else {
toast.error(resp.message ?? "删除失败")
}
} catch (error) {
const message = error instanceof Error ? error.message : error
toast.error(`接口请求错误: ${message}`)
} finally {
setLoading(false)
}
}
return (
<AlertDialog>
<AlertDialogTrigger asChild>
<Button size="sm" variant="destructive" disabled={loading}>
</Button>
</AlertDialogTrigger>
<AlertDialogContent size="sm">
<AlertDialogHeader>
<AlertDialogTitle></AlertDialogTitle>
<AlertDialogDescription>
{admin.username}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel>
<AlertDialogAction variant="destructive" onClick={handleConfirm}>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)
}

View File

@@ -0,0 +1,229 @@
import { zodResolver } from "@hookform/resolvers/zod"
import { useState } from "react"
import { Controller, useForm } from "react-hook-form"
import { toast } from "sonner"
import z from "zod"
import { updateAdmin } from "@/actions/admin"
import { Button } from "@/components/ui/button"
import {
Dialog,
DialogClose,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog"
import {
Field,
FieldError,
FieldGroup,
FieldLabel,
} from "@/components/ui/field"
import { Input } from "@/components/ui/input"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import { type Admin, AdminStatus } from "@/models/admin"
const schema = z.object({
password: z.string().min(6, "密码至少6位").optional().or(z.literal("")),
name: z.string().optional(),
phone: z.string().optional(),
email: z.string().email("请输入有效的邮箱地址").optional().or(z.literal("")),
status: z.nativeEnum(AdminStatus),
})
type FormValues = z.infer<typeof schema>
export function UpdateAdmin(props: { admin: Admin; onSuccess?: () => void }) {
const [open, setOpen] = useState(false)
const form = useForm<FormValues>({
resolver: zodResolver(schema),
defaultValues: {
password: "",
name: props.admin.name ?? "",
phone: props.admin.phone ?? "",
email: props.admin.email ?? "",
status: props.admin.status,
},
})
const onSubmit = async (data: FormValues) => {
try {
const resp = await updateAdmin({
id: props.admin.id,
password: data.password || undefined,
name: data.name || undefined,
phone: data.phone || undefined,
email: data.email || undefined,
status: data.status,
})
if (resp.success) {
toast.success("管理员修改成功")
props.onSuccess?.()
setOpen(false)
} else {
toast.error(resp.message)
}
} catch (error) {
const message = error instanceof Error ? error.message : error
toast.error(`接口请求错误: ${message}`)
}
}
const handleOpenChange = (value: boolean) => {
if (value) {
form.reset({
password: "",
name: props.admin.name ?? "",
phone: props.admin.phone ?? "",
email: props.admin.email ?? "",
status: props.admin.status,
})
}
setOpen(value)
}
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogTrigger asChild>
<Button size="sm" variant="secondary">
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle></DialogTitle>
</DialogHeader>
<form id="admin-update" onSubmit={form.handleSubmit(onSubmit)}>
<FieldGroup>
{/* 用户名只读,不可修改 */}
<Field>
<FieldLabel></FieldLabel>
<Input value={props.admin.username} disabled />
</Field>
<Controller
control={form.control}
name="password"
render={({ field, fieldState }) => (
<Field>
<FieldLabel htmlFor="admin-update-password">
</FieldLabel>
<Input
id="admin-update-password"
type="password"
placeholder="不修改请留空"
{...field}
aria-invalid={fieldState.invalid}
/>
{fieldState.invalid && (
<FieldError errors={[fieldState.error]} />
)}
</Field>
)}
/>
<Controller
control={form.control}
name="name"
render={({ field, fieldState }) => (
<Field>
<FieldLabel htmlFor="admin-update-name"></FieldLabel>
<Input
id="admin-update-name"
{...field}
aria-invalid={fieldState.invalid}
/>
{fieldState.invalid && (
<FieldError errors={[fieldState.error]} />
)}
</Field>
)}
/>
<Controller
control={form.control}
name="phone"
render={({ field, fieldState }) => (
<Field>
<FieldLabel htmlFor="admin-update-phone"></FieldLabel>
<Input
id="admin-update-phone"
{...field}
aria-invalid={fieldState.invalid}
/>
{fieldState.invalid && (
<FieldError errors={[fieldState.error]} />
)}
</Field>
)}
/>
<Controller
control={form.control}
name="email"
render={({ field, fieldState }) => (
<Field>
<FieldLabel htmlFor="admin-update-email"></FieldLabel>
<Input
id="admin-update-email"
{...field}
aria-invalid={fieldState.invalid}
/>
{fieldState.invalid && (
<FieldError errors={[fieldState.error]} />
)}
</Field>
)}
/>
<Controller
control={form.control}
name="status"
render={({ field }) => (
<Field>
<FieldLabel></FieldLabel>
<Select
value={String(field.value)}
onValueChange={value => field.onChange(Number(value))}
>
<SelectTrigger className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value={String(AdminStatus.Enabled)}>
</SelectItem>
<SelectItem value={String(AdminStatus.Disabled)}>
</SelectItem>
</SelectContent>
</Select>
</Field>
)}
/>
</FieldGroup>
</form>
<DialogFooter>
<DialogClose asChild>
<Button variant="ghost"></Button>
</DialogClose>
<Button type="submit" form="admin-update">
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}

View File

@@ -14,13 +14,12 @@ import Image from "next/image"
import Link from "next/link"
import { usePathname, useRouter } from "next/navigation"
import { useEffect, useRef, useState } from "react"
import { getProfile, logout } from "@/actions/auth"
import { logout } from "@/actions/auth"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import type { User } from "@/models/user"
import type { Admin } from "@/models/admin"
export default function Appbar() {
const [currentUser, setCurrentUser] = useState<User>()
export default function Appbar(props: { admin: Admin }) {
const router = useRouter()
const [showDropdown, setShowDropdown] = useState(false)
const [showNotifications, setShowNotifications] = useState(false)
@@ -122,28 +121,6 @@ export default function Appbar() {
}
}
useEffect(() => {
async function fetchUserProfile() {
try {
const resp = await getProfile()
console.log(resp, "resp")
if (resp.success) {
setCurrentUser(resp.data)
} else {
console.error("获取用户信息失败:", resp.message)
if (resp.status === 401) {
router.replace("/login")
}
}
} catch (error) {
console.error("获取用户信息时出错:", error)
}
}
fetchUserProfile()
}, [router])
return (
<header className="bg-white h-16 border-b border-gray-200 flex items-center justify-between px-6">
{/* 面包屑导航 */}
@@ -259,10 +236,10 @@ export default function Appbar() {
aria-label="用户菜单"
>
<div className="h-8 w-8 rounded-full bg-blue-100 text-blue-800 flex items-center justify-center overflow-hidden border-2 border-white shadow-sm">
{currentUser ? (
currentUser.avatar ? (
{props.admin ? (
props.admin.avatar ? (
<Image
src={currentUser.avatar}
src={props.admin.avatar}
alt="用户头像"
width={32}
height={32}
@@ -271,8 +248,8 @@ export default function Appbar() {
const target = e.target as HTMLImageElement
target.style.display = "none"
const parent = target.parentElement
if (parent && currentUser?.name) {
parent.textContent = currentUser.name
if (parent && props.admin?.name) {
parent.textContent = props.admin.name
.charAt(0)
.toUpperCase()
}
@@ -281,7 +258,7 @@ export default function Appbar() {
) : (
// 如果没有头像,直接显示用户名首字母
<span className="text-sm font-semibold">
{currentUser.name.charAt(0).toUpperCase()}
{props.admin.name?.charAt(0).toUpperCase()}
</span>
)
) : (
@@ -290,12 +267,14 @@ export default function Appbar() {
)}
</div>
<div className="hidden md:block text-left">
{currentUser && (
{props.admin && (
<div>
<p className="text-sm font-medium text-gray-800">
{currentUser.name}
{props.admin.name}
</p>
<p className="text-xs text-gray-500">
{props.admin.username}
</p>
<p className="text-xs text-gray-500">{currentUser.username}</p>
</div>
)}
</div>
@@ -305,13 +284,13 @@ export default function Appbar() {
{/* 用户下拉内容 */}
{showDropdown && (
<div className="absolute right-0 mt-2 w-56 bg-white rounded-lg shadow-lg py-2 z-20 border border-gray-200">
{currentUser && (
{props.admin && (
<div className="px-4 py-2 border-b border-gray-100 md:hidden">
<p className="font-medium text-gray-800">
{currentUser.name}
{props.admin.name}
</p>
<p className="text-xs text-gray-500">{currentUser.name}</p>
<p className="text-xs text-gray-500">{props.admin.name}</p>
</div>
)}

View File

@@ -0,0 +1,122 @@
import { zodResolver } from "@hookform/resolvers/zod"
import { useState } from "react"
import { Controller, useForm } from "react-hook-form"
import { toast } from "sonner"
import z from "zod"
import { createProductDiscount } from "@/actions/product_discount"
import { Button } from "@/components/ui/button"
import {
Dialog,
DialogClose,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog"
import {
Field,
FieldError,
FieldGroup,
FieldLabel,
} from "@/components/ui/field"
import { Input } from "@/components/ui/input"
const schema = z.object({
name: z.string().min(1, "请输入折扣名称"),
discount: z.string().min(1, "请输入折扣代码"),
})
export function CreateDiscount(props: { onSuccess?: () => void }) {
const [open, setOpen] = useState(false)
const form = useForm({
resolver: zodResolver(schema),
defaultValues: {
name: "",
discount: "",
},
})
const onSubmit = async (data: z.infer<typeof schema>) => {
try {
const resp = await createProductDiscount(data)
if (resp.success) {
form.reset()
toast.success("折扣创建成功")
props.onSuccess?.()
setOpen(false)
} else {
toast.error(resp.message)
}
} catch (error) {
const message = error instanceof Error ? error.message : error
toast.error(`接口请求错误: ${message}`)
}
}
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button></Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle></DialogTitle>
</DialogHeader>
<form id="discount-create" onSubmit={form.handleSubmit(onSubmit)}>
<FieldGroup>
<Controller
control={form.control}
name="name"
render={({ field, fieldState }) => (
<Field>
<FieldLabel htmlFor="discount-create-name"></FieldLabel>
<Input
id="discount-create-name"
{...field}
aria-invalid={fieldState.invalid}
/>
{fieldState.invalid && (
<FieldError errors={[fieldState.error]} />
)}
</Field>
)}
/>
<Controller
control={form.control}
name="discount"
render={({ field, fieldState }) => (
<Field>
<FieldLabel htmlFor="discount-create-discount">
</FieldLabel>
<Input
id="discount-create-discount"
{...field}
aria-invalid={fieldState.invalid}
/>
{fieldState.invalid && (
<FieldError errors={[fieldState.error]} />
)}
</Field>
)}
/>
</FieldGroup>
</form>
<DialogFooter>
<DialogClose asChild>
<Button variant="ghost"></Button>
</DialogClose>
<Button type="submit" form="discount-create">
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}

View File

@@ -0,0 +1,131 @@
"use client"
import { format } from "date-fns"
import { Suspense, useState } from "react"
import { toast } from "sonner"
import {
deleteProductDiscount,
getPageProductDiscount,
} from "@/actions/product_discount"
import { DataTable, useDataTable } from "@/components/data-table"
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog"
import { Button } from "@/components/ui/button"
import type { ProductDiscount } from "@/models/product_discount"
import { CreateDiscount } from "./create"
import { UpdateDiscount } from "./update"
export default function DiscountPage() {
const table = useDataTable((page, size) =>
getPageProductDiscount({ page, size }),
)
return (
<div className="flex flex-col gap-3">
{/* 操作栏 */}
<div className="flex justify-between items-stretch">
<div className="flex gap-3">
<CreateDiscount onSuccess={table.refresh} />
</div>
</div>
{/* 数据表 */}
<Suspense>
<DataTable<ProductDiscount>
{...table}
columns={[
{ header: "名称", accessorKey: "name" },
{ header: "折扣", accessorKey: "discount" },
{
header: "创建时间",
accessorKey: "created_at",
cell: ({ row }) =>
format(new Date(row.original.created_at), "yyyy-MM-dd HH:mm"),
},
{
header: "更新时间",
accessorKey: "updated_at",
cell: ({ row }) =>
format(new Date(row.original.updated_at), "yyyy-MM-dd HH:mm"),
},
{
header: "操作",
cell: ({ row }) => (
<div className="flex gap-2">
<UpdateDiscount
discount={row.original}
onSuccess={table.refresh}
/>
<DeleteButton
discount={row.original}
onSuccess={table.refresh}
/>
</div>
),
},
]}
/>
</Suspense>
</div>
)
}
function DeleteButton({
discount,
onSuccess,
}: {
discount: ProductDiscount
onSuccess?: () => void
}) {
const [loading, setLoading] = useState(false)
const handleConfirm = async () => {
setLoading(true)
try {
const resp = await deleteProductDiscount(discount.id)
if (resp.success) {
toast.success("删除成功")
onSuccess?.()
} else {
toast.error(resp.message ?? "删除失败")
}
} catch (error) {
const message = error instanceof Error ? error.message : error
toast.error(`接口请求错误: ${message}`)
} finally {
setLoading(false)
}
}
return (
<AlertDialog>
<AlertDialogTrigger asChild>
<Button size="sm" variant="destructive" disabled={loading}>
</Button>
</AlertDialogTrigger>
<AlertDialogContent size="sm">
<AlertDialogHeader>
<AlertDialogTitle></AlertDialogTitle>
<AlertDialogDescription>
{discount.name}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel>
<AlertDialogAction variant="destructive" onClick={handleConfirm}>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)
}

View File

@@ -0,0 +1,140 @@
import { zodResolver } from "@hookform/resolvers/zod"
import { useState } from "react"
import { Controller, useForm } from "react-hook-form"
import { toast } from "sonner"
import z from "zod"
import { updateProductDiscount } from "@/actions/product_discount"
import { Button } from "@/components/ui/button"
import {
Dialog,
DialogClose,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog"
import {
Field,
FieldError,
FieldGroup,
FieldLabel,
} from "@/components/ui/field"
import { Input } from "@/components/ui/input"
import type { ProductDiscount } from "@/models/product_discount"
const schema = z.object({
name: z.string().min(1, "请输入折扣名称"),
discount: z.string().min(1, "请输入折扣代码"),
})
export function UpdateDiscount(props: {
discount: ProductDiscount
onSuccess?: () => void
}) {
const [open, setOpen] = useState(false)
const form = useForm({
resolver: zodResolver(schema),
defaultValues: {
name: props.discount.name,
discount: String(props.discount.discount),
},
})
const onSubmit = async (data: z.infer<typeof schema>) => {
try {
const resp = await updateProductDiscount({
id: props.discount.id,
...data,
})
if (resp.success) {
toast.success("折扣修改成功")
props.onSuccess?.()
setOpen(false)
} else {
toast.error(resp.message)
}
} catch (error) {
const message = error instanceof Error ? error.message : error
toast.error(`接口请求错误: ${message}`)
}
}
const handleOpenChange = (value: boolean) => {
if (value) {
form.reset({
name: props.discount.name,
discount: String(props.discount.discount),
})
}
setOpen(value)
}
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogTrigger asChild>
<Button size="sm" variant="secondary">
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle></DialogTitle>
</DialogHeader>
<form id="discount-update" onSubmit={form.handleSubmit(onSubmit)}>
<FieldGroup>
<Controller
control={form.control}
name="name"
render={({ field, fieldState }) => (
<Field>
<FieldLabel htmlFor="discount-update-name"></FieldLabel>
<Input
id="discount-update-name"
{...field}
aria-invalid={fieldState.invalid}
/>
{fieldState.invalid && (
<FieldError errors={[fieldState.error]} />
)}
</Field>
)}
/>
<Controller
control={form.control}
name="discount"
render={({ field, fieldState }) => (
<Field>
<FieldLabel htmlFor="discount-update-discount">
</FieldLabel>
<Input
id="discount-update-discount"
{...field}
aria-invalid={fieldState.invalid}
/>
{fieldState.invalid && (
<FieldError errors={[fieldState.error]} />
)}
</Field>
)}
/>
</FieldGroup>
</form>
<DialogFooter>
<DialogClose asChild>
<Button variant="ghost"></Button>
</DialogClose>
<Button type="submit" form="discount-update">
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}

View File

@@ -1,26 +1,38 @@
import {ReactNode} from 'react'
import Appbar from '@/app/(root)/appbar'
import Navigation from '@/app/(root)/navigation'
import { type ReactNode, Suspense } from "react"
import { getProfile } from "@/actions/auth"
import Appbar from "@/app/(root)/appbar"
import Navigation from "@/app/(root)/navigation"
import SetScopes from "./scopes"
export type RootLayoutProps = {
children: ReactNode
}
export default function RootLayout({children}: RootLayoutProps) {
export default async function RootLayout({ children }: RootLayoutProps) {
return (
<Suspense>
<Layout>{children}</Layout>
</Suspense>
)
}
async function Layout(props: { children: ReactNode }) {
const profile = await getProfile()
if (!profile.success) throw new Error("页面渲染失败:无法获取账号信息")
return (
<div className="flex h-screen bg-gray-100">
<SetScopes admin={profile.data} />
{/* 侧边栏 */}
<Navigation/>
<Navigation />
{/* 主内容区 */}
<div className="flex-1 flex flex-col overflow-hidden">
{/* 顶部导航栏 */}
<Appbar/>
<Appbar admin={profile.data} />
{/* 内容区域 */}
<main className="flex-1 overflow-auto p-6">
{children}
</main>
<main className="flex-1 overflow-auto p-6">{props.children}</main>
</div>
</div>
)

View File

@@ -7,16 +7,15 @@ import {
ClipboardList,
Code,
ComputerIcon,
Database,
DollarSign,
FileText,
Globe,
Home,
KeyRound,
type LucideIcon,
Package,
Server,
Settings,
Shield,
ShoppingBag,
SquarePercent,
SquarePercentIcon,
Users,
} from "lucide-react"
import Link from "next/link"
@@ -196,6 +195,12 @@ export default function Navigation() {
{/* 运营 */}
<NavGroup title="运营">
<NavItem href="/product" icon={ShoppingBag} label="产品管理" />
<NavItem
href="/discount"
icon={SquarePercent}
label="折扣管理"
/>
<NavItem href="/resources" icon={Package} label="套餐管理" />
<NavItem href="/batch" icon={ClipboardList} label="使用记录" />
<NavItem href="/channel" icon={Code} label="IP管理" />
@@ -206,7 +211,9 @@ export default function Navigation() {
{/* 系统 */}
<NavGroup title="系统">
{/*<NavItem href="/settings" icon={Settings} label="系统设置" />*/}
<NavItem href="/security" icon={Shield} label="管理员" />
<NavItem href="/admin" icon={Shield} label="管理员" />
<NavItem href="/roles" icon={KeyRound} label="角色列表" />
<NavItem href="/permissions" icon={Shield} label="权限列表" />
{/*<NavItem href="/logs" icon={FileText} label="系统日志" />*/}
</NavGroup>
</nav>

View File

@@ -0,0 +1,136 @@
"use client"
import {
flexRender,
getCoreRowModel,
getExpandedRowModel,
type Row,
useReactTable,
} from "@tanstack/react-table"
import { Suspense, useCallback, useEffect, useState } from "react"
import { toast } from "sonner"
import { getAllPermissions } from "@/actions/permission"
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table"
import { cn } from "@/lib/utils"
type Node = {
id: number
name: string
description: string
children: Node[]
}
export default function PermissionsPage() {
return (
<Suspense>
<PermissionTable />
</Suspense>
)
}
function PermissionTable() {
const [data, setData] = useState<Node[]>([])
const table = useReactTable({
getCoreRowModel: getCoreRowModel(),
getExpandedRowModel: getExpandedRowModel(),
data,
columns: [
{ header: "编码", accessorKey: "name" },
{ header: "描述", accessorKey: "description" },
],
getSubRows: row => row.children,
})
const refresh = useCallback(async () => {
try {
const resp = await getAllPermissions()
if (!resp.success) {
throw new Error(resp.message)
}
const map = new Map<number, Node>()
resp.data.forEach(permission => {
map.set(permission.id, {
id: permission.id,
name: permission.name,
description: permission.description,
children: [],
})
})
const roots: Node[] = []
resp.data.forEach(permission => {
const node = map.get(permission.id)
if (!node) {
throw new Error(`找不到权限节点: ${permission.name}`)
}
if (!permission.parent_id) {
roots.push(node)
return
}
const parent = map.get(permission.parent_id)
if (!parent) {
throw new Error(`找不到父权限节点: ${permission.name}`)
}
parent.children.push(node)
})
setData(roots)
console.log(roots)
} catch (e) {
toast.error(e instanceof Error ? e.message : "获取权限列表失败")
}
}, [])
useEffect(() => {
refresh()
}, [refresh])
return (
<div className="bg-background rounded-lg">
<Table>
<TableHeader>
<TableRow className="h-10">
<TableHead></TableHead>
<TableHead></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{table.getRowModel().rows.map(row => (
<PermisionRow key={row.id} row={row} />
))}
</TableBody>
</Table>
</div>
)
}
function PermisionRow(props: { row: Row<Node> }) {
const row = props.row
return (
<>
<TableRow key={row.id} className="h-10">
<TableCell className="flex">
<div style={{ width: row.depth * 16 }}></div>
<span className={cn(row.subRows.length ? "font-bold" : "text-sm")}>
{row.original.name}
</span>
</TableCell>
<TableCell>{row.original.description}</TableCell>
</TableRow>
{row.subRows.map(subRow => (
<PermisionRow key={subRow.id} row={subRow} />
))}
</>
)
}

View File

@@ -0,0 +1,144 @@
import { zodResolver } from "@hookform/resolvers/zod"
import { useEffect, useState } from "react"
import { Controller, useForm } from "react-hook-form"
import { toast } from "sonner"
import z from "zod"
import { batchUpdateProductSkuDiscount } from "@/actions/product"
import { getAllProductDiscount } from "@/actions/product_discount"
import { Button } from "@/components/ui/button"
import {
Dialog,
DialogClose,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog"
import {
Field,
FieldError,
FieldGroup,
FieldLabel,
} from "@/components/ui/field"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import type { ProductDiscount } from "@/models/product_discount"
const schema = z.object({
discount_id: z.string().min(1, "请选择折扣"),
})
export function BatchUpdateDiscount(props: {
productId: number
onSuccess?: () => void
}) {
const [open, setOpen] = useState(false)
const [discounts, setDiscounts] = useState<ProductDiscount[]>([])
const form = useForm({
resolver: zodResolver(schema),
defaultValues: {
discount_id: "",
},
})
useEffect(() => {
if (open) {
getAllProductDiscount().then(resp => {
if (resp.success) {
setDiscounts(resp.data)
}
})
}
}, [open])
const onSubmit = async (data: z.infer<typeof schema>) => {
try {
const resp = await batchUpdateProductSkuDiscount({
product_id: props.productId,
discount_id:
data.discount_id === "none" ? null : Number(data.discount_id),
})
if (resp.success) {
toast.success("批量配置折扣成功")
props.onSuccess?.()
setOpen(false)
} else {
toast.error(resp.message ?? "操作失败")
}
} catch (error) {
const message = error instanceof Error ? error.message : error
toast.error(`接口请求错误: ${message}`)
}
}
const handleOpenChange = (value: boolean) => {
if (!value) {
form.reset()
}
setOpen(value)
}
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogTrigger asChild>
<Button variant="outline" disabled={!props.productId}>
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle></DialogTitle>
</DialogHeader>
<form id="sku-batch-discount" onSubmit={form.handleSubmit(onSubmit)}>
<FieldGroup>
<Controller
control={form.control}
name="discount_id"
render={({ field, fieldState }) => (
<Field>
<FieldLabel></FieldLabel>
<Select value={field.value} onValueChange={field.onChange}>
<SelectTrigger
className="w-full"
aria-invalid={fieldState.invalid}
>
<SelectValue placeholder="请选择要应用的折扣" />
</SelectTrigger>
<SelectContent>
{discounts.map(d => (
<SelectItem key={d.id} value={String(d.id)}>
{d.name}{d.discount}%
</SelectItem>
))}
</SelectContent>
</Select>
{fieldState.invalid && (
<FieldError errors={[fieldState.error]} />
)}
</Field>
)}
/>
</FieldGroup>
</form>
<DialogFooter>
<DialogClose asChild>
<Button variant="ghost"></Button>
</DialogClose>
<Button type="submit" form="sku-batch-discount">
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}

View File

@@ -0,0 +1,220 @@
import { zodResolver } from "@hookform/resolvers/zod"
import { useEffect, useState } from "react"
import { Controller, useForm } from "react-hook-form"
import { toast } from "sonner"
import z from "zod"
import { createProductSku } from "@/actions/product"
import { getAllProductDiscount } from "@/actions/product_discount"
import { Button } from "@/components/ui/button"
import {
Dialog,
DialogClose,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog"
import {
Field,
FieldError,
FieldGroup,
FieldLabel,
} from "@/components/ui/field"
import { Input } from "@/components/ui/input"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import type { ProductDiscount } from "@/models/product_discount"
const schema = z.object({
code: z.string().min(1, "请输入套餐编码"),
name: z.string().min(1, "请输入套餐名称"),
price: z
.string()
.min(1, "请输入单价")
.refine(
v => !Number.isNaN(Number(v)) && Number(v) > 0,
"请输入有效的正数单价",
),
discount_id: z.string().optional(),
})
export function CreateProductSku(props: {
productId: number
onSuccess?: () => void
}) {
const [open, setOpen] = useState(false)
const [discounts, setDiscounts] = useState<ProductDiscount[]>([])
const form = useForm({
resolver: zodResolver(schema),
defaultValues: {
code: "",
name: "",
price: "",
discount_id: "",
},
})
useEffect(() => {
if (open) {
getAllProductDiscount()
.then(resp => {
if (resp.success) {
setDiscounts(resp.data)
}
})
.catch(e => toast.error(e.message))
}
}, [open])
const onSubmit = async (data: z.infer<typeof schema>) => {
try {
const resp = await createProductSku({
product_id: props.productId,
code: data.code,
name: data.name,
price: data.price,
discount_id:
data.discount_id && data.discount_id !== ""
? Number(data.discount_id)
: undefined,
})
if (resp.success) {
form.reset()
toast.success("套餐创建成功")
props.onSuccess?.()
setOpen(false)
} else {
toast.error(resp.message)
}
} catch (error) {
const message = error instanceof Error ? error.message : error
toast.error(`接口请求错误: ${message}`)
}
}
const handleOpenChange = (value: boolean) => {
if (!value) {
form.reset()
}
setOpen(value)
}
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogTrigger asChild>
<Button disabled={!props.productId}></Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle></DialogTitle>
</DialogHeader>
<form id="sku-create" onSubmit={form.handleSubmit(onSubmit)}>
<FieldGroup>
<Controller
control={form.control}
name="code"
render={({ field, fieldState }) => (
<Field>
<FieldLabel htmlFor="sku-create-code"></FieldLabel>
<Input
id="sku-create-code"
placeholder="请输入套餐编码"
{...field}
aria-invalid={fieldState.invalid}
/>
{fieldState.invalid && (
<FieldError errors={[fieldState.error]} />
)}
</Field>
)}
/>
<Controller
control={form.control}
name="name"
render={({ field, fieldState }) => (
<Field>
<FieldLabel htmlFor="sku-create-name"></FieldLabel>
<Input
id="sku-create-name"
placeholder="请输入套餐名称"
{...field}
aria-invalid={fieldState.invalid}
/>
{fieldState.invalid && (
<FieldError errors={[fieldState.error]} />
)}
</Field>
)}
/>
<Controller
control={form.control}
name="price"
render={({ field, fieldState }) => (
<Field>
<FieldLabel htmlFor="sku-create-price"></FieldLabel>
<Input
id="sku-create-price"
placeholder="请输入单价"
{...field}
aria-invalid={fieldState.invalid}
/>
{fieldState.invalid && (
<FieldError errors={[fieldState.error]} />
)}
</Field>
)}
/>
<Controller
control={form.control}
name="discount_id"
render={({ field, fieldState }) => (
<Field>
<FieldLabel></FieldLabel>
<Select value={field.value} onValueChange={field.onChange}>
<SelectTrigger
className="w-full"
aria-invalid={fieldState.invalid}
>
<SelectValue placeholder="选择折扣" />
</SelectTrigger>
<SelectContent>
{discounts.map(d => (
<SelectItem key={d.id} value={String(d.id)}>
{d.name}{d.discount}%
</SelectItem>
))}
</SelectContent>
</Select>
{fieldState.invalid && (
<FieldError errors={[fieldState.error]} />
)}
</Field>
)}
/>
</FieldGroup>
</form>
<DialogFooter>
<DialogClose asChild>
<Button variant="ghost"></Button>
</DialogClose>
<Button type="submit" form="sku-create">
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}

View File

@@ -0,0 +1,204 @@
"use client"
import { format } from "date-fns"
import { Suspense, useCallback, useEffect, useMemo, useState } from "react"
import { toast } from "sonner"
import {
deleteProductSku,
getAllProduct,
getPageProductSku,
} from "@/actions/product"
import { DataTable, useDataTable } from "@/components/data-table"
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { cn } from "@/lib/utils"
import type { Product } from "@/models/product"
import type { ProductSku } from "@/models/product_sku"
import { BatchUpdateDiscount } from "./batch-discount"
import { CreateProductSku } from "./create"
import { UpdateProductSku } from "./update"
export default function ProductPage() {
const [selected, setSelected] = useState<number | undefined>(undefined)
return (
<div className="size-full flex gap-6 items-stretch">
<Products selected={selected} onSelect={setSelected} />
<ProductSkus selected={selected} />
</div>
)
}
function Products(props: {
selected?: number
onSelect?: (id: number) => void
}) {
const [list, setList] = useState<Product[]>([])
const refresh = useCallback(async () => {
const resp = await getAllProduct()
if (resp.success) {
setList(resp.data)
}
}, [])
const selected = useMemo(() => {
return list.find(item => item.id === props.selected)
}, [list, props.selected])
useEffect(() => {
refresh()
}, [refresh])
return (
<section className="flex-none basis-64 bg-background rounded-lg">
<header className="pl-3 pr-1 h-10 border-b flex items-center justify-between">
<h3 className="text-sm"></h3>
</header>
<ul className="flex flex-col gap-1 py-1">
{list.map(item => (
<li key={item.id} className="px-1">
<Button
variant="ghost"
className={cn(
"size-full box-border p-2 rounded-md flex justify-between items-center select-none",
selected?.id === item.id && "bg-primary/20",
)}
onClick={() => props.onSelect?.(item.id)}
>
<div>
<p>{item.name}</p>
<p className="text-sm text-gray-500">{item.description}</p>
</div>
<Badge className="bg-green-600/60"></Badge>
</Button>
</li>
))}
</ul>
</section>
)
}
function ProductSkus(props: { selected?: number }) {
const action = useCallback(
(page: number, size: number) => {
return getPageProductSku({ page, size, product_id: props.selected })
},
[props.selected],
)
const table = useDataTable(action)
return (
<div className="flex-auto overflow-hidden flex flex-col items-stretch gap-3">
<div className="flex gap-3">
<CreateProductSku
productId={props.selected ?? 0}
onSuccess={table.refresh}
/>
<BatchUpdateDiscount
productId={props.selected ?? 0}
onSuccess={table.refresh}
/>
</div>
<Suspense>
<DataTable<ProductSku>
classNames={{
root: "overflow-auto",
}}
{...table}
columns={[
{ header: "套餐编码", accessorKey: "code" },
{ header: "套餐名称", accessorKey: "name" },
{ header: "单价", accessorKey: "price" },
{ header: "折扣", accessorFn: row => row.discount?.name ?? "—" },
{
header: "最终价格",
accessorFn: row =>
row.discount
? (Number(row.price) * Number(row.discount.discount)) / 100
: Number(row.price),
},
{
header: "创建时间",
accessorFn: row => format(row.created_at, "yyyy-MM-dd HH:mm"),
},
{
header: "更新时间",
accessorFn: row => format(row.updated_at, "yyyy-MM-dd HH:mm"),
},
{
header: "操作",
cell: ({ row }) => (
<div className="flex gap-1">
<UpdateProductSku
sku={row.original}
onSuccess={table.refresh}
/>
<DeleteButton sku={row.original} onSuccess={table.refresh} />
</div>
),
},
]}
/>
</Suspense>
</div>
)
}
function DeleteButton(props: { sku: ProductSku; onSuccess?: () => void }) {
const [loading, setLoading] = useState(false)
const handleConfirm = async () => {
setLoading(true)
try {
const resp = await deleteProductSku(props.sku.id)
if (resp.success) {
toast.success("删除成功")
props.onSuccess?.()
} else {
toast.error(resp.message ?? "删除失败")
}
} catch (error) {
const message = error instanceof Error ? error.message : error
toast.error(`接口请求错误: ${message}`)
} finally {
setLoading(false)
}
}
return (
<AlertDialog>
<AlertDialogTrigger asChild>
<Button size="sm" variant="destructive" disabled={loading}>
</Button>
</AlertDialogTrigger>
<AlertDialogContent size="sm">
<AlertDialogHeader>
<AlertDialogTitle></AlertDialogTitle>
<AlertDialogDescription>
{props.sku.name}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel>
<AlertDialogAction variant="destructive" onClick={handleConfirm}>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)
}

View File

@@ -0,0 +1,225 @@
import { zodResolver } from "@hookform/resolvers/zod"
import { useEffect, useState } from "react"
import { Controller, useForm } from "react-hook-form"
import { toast } from "sonner"
import z from "zod"
import { updateProductSku } from "@/actions/product"
import { getAllProductDiscount } from "@/actions/product_discount"
import { Button } from "@/components/ui/button"
import {
Dialog,
DialogClose,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog"
import {
Field,
FieldError,
FieldGroup,
FieldLabel,
} from "@/components/ui/field"
import { Input } from "@/components/ui/input"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import type { ProductDiscount } from "@/models/product_discount"
import type { ProductSku } from "@/models/product_sku"
const schema = z.object({
code: z.string().min(1, "请输入套餐编码"),
name: z.string().min(1, "请输入套餐名称"),
price: z
.string()
.min(1, "请输入单价")
.refine(
v => !Number.isNaN(Number(v)) && Number(v) > 0,
"请输入有效的正数单价",
),
discount_id: z.string().optional(),
})
export function UpdateProductSku(props: {
sku: ProductSku
onSuccess?: () => void
}) {
const [open, setOpen] = useState(false)
const [discounts, setDiscounts] = useState<ProductDiscount[]>([])
const form = useForm({
resolver: zodResolver(schema),
defaultValues: {
code: props.sku.code,
name: props.sku.name,
price: props.sku.price,
discount_id: props.sku.discount ? String(props.sku.discount.id) : "",
},
})
useEffect(() => {
if (open) {
getAllProductDiscount().then(resp => {
if (resp.success) {
setDiscounts(resp.data)
}
})
}
}, [open])
const onSubmit = async (data: z.infer<typeof schema>) => {
try {
const resp = await updateProductSku({
id: props.sku.id,
code: data.code,
name: data.name,
price: data.price,
discount_id:
data.discount_id && data.discount_id !== ""
? Number(data.discount_id)
: null,
})
if (resp.success) {
toast.success("套餐修改成功")
props.onSuccess?.()
setOpen(false)
} else {
toast.error(resp.message)
}
} catch (error) {
const message = error instanceof Error ? error.message : error
toast.error(`接口请求错误: ${message}`)
}
}
const handleOpenChange = (value: boolean) => {
if (value) {
form.reset({
code: props.sku.code,
name: props.sku.name,
price: props.sku.price,
discount_id: props.sku.discount ? String(props.sku.discount.id) : "",
})
}
setOpen(value)
}
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogTrigger asChild>
<Button size="sm" variant="secondary">
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle></DialogTitle>
</DialogHeader>
<form id="sku-update" onSubmit={form.handleSubmit(onSubmit)}>
<FieldGroup>
<Controller
control={form.control}
name="code"
render={({ field, fieldState }) => (
<Field>
<FieldLabel htmlFor="sku-update-code"></FieldLabel>
<Input
id="sku-update-code"
placeholder="请输入套餐编码"
{...field}
aria-invalid={fieldState.invalid}
/>
{fieldState.invalid && (
<FieldError errors={[fieldState.error]} />
)}
</Field>
)}
/>
<Controller
control={form.control}
name="name"
render={({ field, fieldState }) => (
<Field>
<FieldLabel htmlFor="sku-update-name"></FieldLabel>
<Input
id="sku-update-name"
placeholder="请输入套餐名称"
{...field}
aria-invalid={fieldState.invalid}
/>
{fieldState.invalid && (
<FieldError errors={[fieldState.error]} />
)}
</Field>
)}
/>
<Controller
control={form.control}
name="price"
render={({ field, fieldState }) => (
<Field>
<FieldLabel htmlFor="sku-update-price"></FieldLabel>
<Input
id="sku-update-price"
placeholder="请输入单价"
{...field}
aria-invalid={fieldState.invalid}
/>
{fieldState.invalid && (
<FieldError errors={[fieldState.error]} />
)}
</Field>
)}
/>
<Controller
control={form.control}
name="discount_id"
render={({ field, fieldState }) => (
<Field>
<FieldLabel></FieldLabel>
<Select value={field.value} onValueChange={field.onChange}>
<SelectTrigger
className="w-full"
aria-invalid={fieldState.invalid}
>
<SelectValue placeholder="选择折扣" />
</SelectTrigger>
<SelectContent>
{discounts.map(d => (
<SelectItem key={d.id} value={String(d.id)}>
{d.name}{d.discount}%
</SelectItem>
))}
</SelectContent>
</Select>
{fieldState.invalid && (
<FieldError errors={[fieldState.error]} />
)}
</Field>
)}
/>
</FieldGroup>
</form>
<DialogFooter>
<DialogClose asChild>
<Button variant="ghost"></Button>
</DialogClose>
<Button type="submit" form="sku-update">
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}

View File

@@ -0,0 +1,213 @@
import { useCallback, useEffect, useState } from "react"
import { toast } from "sonner"
import { getAllPermissions } from "@/actions/permission"
import { updateRole } from "@/actions/role"
import { Button } from "@/components/ui/button"
import { Checkbox } from "@/components/ui/checkbox"
import {
Dialog,
DialogClose,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog"
import { cn } from "@/lib/utils"
import type { Role } from "@/models/role"
type TreeNode = {
id: number
name: string
description: string
children: TreeNode[]
}
function PermissionRow({
node,
depth,
selected,
onToggle,
}: {
node: TreeNode
depth: number
selected: Set<number>
onToggle: (id: number, checked: boolean) => void
}) {
const hasChildren = node.children.length > 0
return (
<>
<div className={cn("flex items-center gap-2 h-8", hasChildren && "mt-2")}>
<div style={{ width: depth * 16 }} />
<Checkbox
id={`perm-${node.id}`}
checked={selected.has(node.id)}
onCheckedChange={checked => onToggle(node.id, checked === true)}
/>
<label
htmlFor={`perm-${node.id}`}
className={cn(
"cursor-pointer select-none text-sm size-full flex items-center",
hasChildren && "font-bold",
)}
>
{node.description}
</label>
</div>
{node.children.map(child => (
<PermissionRow
key={child.id}
node={child}
depth={depth + 1}
selected={selected}
onToggle={onToggle}
/>
))}
</>
)
}
export function AssignPermissions(props: {
role: Role
onSuccess?: () => void
}) {
const [open, setOpen] = useState(false)
const [loading, setLoading] = useState(false)
const [submitting, setSubmitting] = useState(false)
const [nodes, setNodes] = useState<TreeNode[]>([])
const [selected, setSelected] = useState<Set<number>>(new Set())
const fetchPermissions = useCallback(async () => {
setLoading(true)
try {
const resp = await getAllPermissions()
if (!resp.success) throw new Error(resp.message)
const data = resp.data ?? []
const map = new Map<number, TreeNode>()
data.forEach(p => {
map.set(p.id, {
id: p.id,
name: p.name,
description: p.description,
children: [],
})
})
const roots: TreeNode[] = []
data.forEach(p => {
const node = map.get(p.id)
if (!node) return
if (!p.parent_id) {
roots.push(node)
} else {
map.get(p.parent_id)?.children.push(node)
}
})
setNodes(roots)
} catch (error) {
toast.error(error instanceof Error ? error.message : "获取权限列表失败")
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
if (open) fetchPermissions()
}, [open, fetchPermissions])
const handleToggle = useCallback((id: number, checked: boolean) => {
setSelected(prev => {
const next = new Set(prev)
if (checked) {
next.add(id)
} else {
next.delete(id)
}
return next
})
}, [])
const handleSubmit = async () => {
setSubmitting(true)
try {
const resp = await updateRole({
id: props.role.id,
permissions: Array.from(selected),
})
if (resp.success) {
toast.success("权限分配成功")
props.onSuccess?.()
setOpen(false)
} else {
toast.error(resp.message ?? "权限分配失败")
}
} catch (error) {
toast.error(error instanceof Error ? error.message : "接口请求错误")
} finally {
setSubmitting(false)
}
}
const handleOpenChange = (value: boolean) => {
if (value) {
setSelected(new Set((props.role.permissions ?? []).map(p => p.id)))
} else {
setSelected(new Set())
setNodes([])
}
setOpen(value)
}
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogTrigger asChild>
<Button size="sm" variant="secondary">
</Button>
</DialogTrigger>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle> · {props.role.name}</DialogTitle>
</DialogHeader>
<div className="max-h-[60vh] overflow-y-auto pr-1">
{loading ? (
<div className="py-8 text-center text-sm text-muted-foreground">
...
</div>
) : nodes.length === 0 ? (
<div className="py-8 text-center text-sm text-muted-foreground">
</div>
) : (
<div className="flex flex-col">
{nodes.map(node => (
<PermissionRow
key={node.id}
node={node}
depth={0}
selected={selected}
onToggle={handleToggle}
/>
))}
</div>
)}
</div>
<DialogFooter>
<DialogClose asChild>
<Button variant="ghost"></Button>
</DialogClose>
<Button onClick={handleSubmit} disabled={submitting || loading}>
{submitting ? "保存中..." : "保存"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}

View File

@@ -0,0 +1,123 @@
import { zodResolver } from "@hookform/resolvers/zod"
import { useState } from "react"
import { Controller, useForm } from "react-hook-form"
import { toast } from "sonner"
import z from "zod"
import { createRole } from "@/actions/role"
import { Button } from "@/components/ui/button"
import {
Dialog,
DialogClose,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog"
import {
Field,
FieldError,
FieldGroup,
FieldLabel,
} from "@/components/ui/field"
import { Input } from "@/components/ui/input"
import { Textarea } from "@/components/ui/textarea"
const schema = z.object({
name: z.string().min(1, "请输入角色名称"),
description: z.string().optional(),
})
export function CreateRole(props: { onSuccess?: () => void }) {
const form = useForm({
resolver: zodResolver(schema),
defaultValues: {
name: "",
description: "",
},
})
const onSubmit = async (data: z.infer<typeof schema>) => {
try {
const resp = await createRole(data)
if (resp.success) {
form.reset()
toast.success("角色创建成功")
props.onSuccess?.()
setOpen(false)
} else {
toast.error(resp.message)
}
} catch (error) {
const message = error instanceof Error ? error.message : error
toast.error(`接口请求错误: ${message}`)
}
}
const [open, setOpen] = useState(false)
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button></Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle></DialogTitle>
</DialogHeader>
<form id="role-create" onSubmit={form.handleSubmit(onSubmit)}>
<FieldGroup>
<Controller
control={form.control}
name="name"
render={({ field, fieldState }) => (
<Field>
<FieldLabel htmlFor="role-create-name"></FieldLabel>
<Input
id="role-create-name"
{...field}
aria-invalid={fieldState.invalid}
/>
{fieldState.invalid && (
<FieldError errors={[fieldState.error]} />
)}
</Field>
)}
/>
<Controller
control={form.control}
name="description"
render={({ field, fieldState }) => (
<Field>
<FieldLabel htmlFor="role-create-description">
</FieldLabel>
<Textarea
id="role-create-description"
{...field}
aria-invalid={fieldState.invalid}
/>
{fieldState.invalid && (
<FieldError errors={[fieldState.error]} />
)}
</Field>
)}
/>
</FieldGroup>
</form>
<DialogFooter>
<DialogClose asChild>
<Button variant="ghost"></Button>
</DialogClose>
<Button type="submit" form="role-create">
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}

View File

@@ -0,0 +1,221 @@
"use client"
import { Suspense, useState } from "react"
import { toast } from "sonner"
import { deleteRole, getPageRole, updateRole } from "@/actions/role"
import { DataTable, useDataTable } from "@/components/data-table"
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import {
HoverCard,
HoverCardContent,
HoverCardTrigger,
} from "@/components/ui/hover-card"
import type { Permission } from "@/models/permission"
import type { Role } from "@/models/role"
import { AssignPermissions } from "./assign-permissions"
import { CreateRole } from "./create"
import { UpdateRole } from "./update"
export default function RolesPage() {
const table = useDataTable((page, size) => getPageRole({ page, size }))
return (
<div className="flex flex-col gap-3">
{/* 操作栏 */}
<div className="flex justify-between items-stretch">
<div className="flex gap-3">
<CreateRole onSuccess={table.refresh} />
</div>
</div>
{/* 数据表 */}
<Suspense>
<DataTable
{...table}
columns={[
{ header: "名称", accessorKey: "name" },
{ header: "描述", accessorKey: "description" },
{
header: "状态",
accessorFn: row => (row.active ? "启用" : "停用"),
},
{
header: "权限",
cell: ({ row }) => (
<PermissionsCell permissions={row.original.permissions ?? []} />
),
},
{
header: "操作",
cell: ({ row }) => (
<div className="flex gap-2">
<UpdateRole role={row.original} onSuccess={table.refresh} />
<AssignPermissions
role={row.original}
onSuccess={table.refresh}
/>
<ToggleActiveButton
role={row.original}
onSuccess={table.refresh}
/>
<DeleteButton role={row.original} onSuccess={table.refresh} />
</div>
),
},
]}
/>
</Suspense>
</div>
)
}
function PermissionsCell({ permissions }: { permissions: Permission[] }) {
if (!permissions || permissions.length === 0) {
return <span className="text-muted-foreground text-xs"></span>
}
const preview = permissions.slice(0, 3)
const rest = permissions.length - preview.length
return (
<HoverCard>
<HoverCardTrigger asChild>
<div className="flex flex-wrap gap-1 cursor-default max-w-52">
{preview.map(p => (
<Badge key={p.id} variant="secondary">
{p.description}
</Badge>
))}
{rest > 0 && <Badge variant="outline">+{rest}</Badge>}
</div>
</HoverCardTrigger>
<HoverCardContent className="w-72" align="start">
<p className="text-xs font-medium text-muted-foreground mb-2">
{permissions.length}
</p>
<div className="flex flex-wrap gap-1.5">
{permissions.map(p => (
<Badge key={p.id} variant="secondary">
{p.description}
</Badge>
))}
</div>
</HoverCardContent>
</HoverCard>
)
}
function ToggleActiveButton({
role,
onSuccess,
}: {
role: Role
onSuccess?: () => void
}) {
const [loading, setLoading] = useState(false)
const handleConfirm = async () => {
setLoading(true)
try {
const resp = await updateRole({ id: role.id, active: !role.active })
if (resp.success) {
toast.success(role.active ? "已停用" : "已启用")
onSuccess?.()
} else {
toast.error(resp.message ?? "操作失败")
}
} catch (error) {
const message = error instanceof Error ? error.message : error
toast.error(`接口请求错误: ${message}`)
} finally {
setLoading(false)
}
}
return (
<AlertDialog>
<AlertDialogTrigger asChild>
<Button size="sm" variant="secondary" disabled={loading}>
{role.active ? "停用" : "启用"}
</Button>
</AlertDialogTrigger>
<AlertDialogContent size="sm">
<AlertDialogHeader>
<AlertDialogTitle>
{role.active ? "停用" : "启用"}
</AlertDialogTitle>
<AlertDialogDescription>
{role.active ? "停用" : "启用"}{role.name}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel>
<AlertDialogAction onClick={handleConfirm}></AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)
}
function DeleteButton({
role,
onSuccess,
}: {
role: Role
onSuccess?: () => void
}) {
const [loading, setLoading] = useState(false)
const handleConfirm = async () => {
setLoading(true)
try {
const resp = await deleteRole(role.id)
if (resp.success) {
toast.success("删除成功")
onSuccess?.()
} else {
toast.error(resp.message ?? "删除失败")
}
} catch (error) {
const message = error instanceof Error ? error.message : error
toast.error(`接口请求错误: ${message}`)
} finally {
setLoading(false)
}
}
return (
<AlertDialog>
<AlertDialogTrigger asChild>
<Button size="sm" variant="destructive" disabled={loading}>
</Button>
</AlertDialogTrigger>
<AlertDialogContent size="sm">
<AlertDialogHeader>
<AlertDialogTitle></AlertDialogTitle>
<AlertDialogDescription>
{role.name}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel>
<AlertDialogAction variant="destructive" onClick={handleConfirm}>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)
}

View File

@@ -0,0 +1,135 @@
import { zodResolver } from "@hookform/resolvers/zod"
import { useState } from "react"
import { Controller, useForm } from "react-hook-form"
import { toast } from "sonner"
import z from "zod"
import { updateRole } from "@/actions/role"
import { Button } from "@/components/ui/button"
import {
Dialog,
DialogClose,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog"
import {
Field,
FieldError,
FieldGroup,
FieldLabel,
} from "@/components/ui/field"
import { Input } from "@/components/ui/input"
import { Textarea } from "@/components/ui/textarea"
import type { Role } from "@/models/role"
const schema = z.object({
name: z.string().min(1, "请输入角色名称"),
description: z.string().optional(),
})
export function UpdateRole(props: { role: Role; onSuccess?: () => void }) {
const [open, setOpen] = useState(false)
const form = useForm({
resolver: zodResolver(schema),
defaultValues: {
name: props.role.name,
description: props.role.description ?? "",
},
})
const onSubmit = async (data: z.infer<typeof schema>) => {
try {
const resp = await updateRole({ id: props.role.id, ...data })
if (resp.success) {
toast.success("角色修改成功")
props.onSuccess?.()
setOpen(false)
} else {
toast.error(resp.message)
}
} catch (error) {
const message = error instanceof Error ? error.message : error
toast.error(`接口请求错误: ${message}`)
}
}
const handleOpenChange = (value: boolean) => {
if (value) {
form.reset({
name: props.role.name,
description: props.role.description ?? "",
})
}
setOpen(value)
}
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogTrigger asChild>
<Button size="sm" variant="secondary">
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle></DialogTitle>
</DialogHeader>
<form id="role-update" onSubmit={form.handleSubmit(onSubmit)}>
<FieldGroup>
<Controller
control={form.control}
name="name"
render={({ field, fieldState }) => (
<Field>
<FieldLabel htmlFor="role-update-name"></FieldLabel>
<Input
id="role-update-name"
{...field}
aria-invalid={fieldState.invalid}
/>
{fieldState.invalid && (
<FieldError errors={[fieldState.error]} />
)}
</Field>
)}
/>
<Controller
control={form.control}
name="description"
render={({ field, fieldState }) => (
<Field>
<FieldLabel htmlFor="role-update-description">
</FieldLabel>
<Textarea
id="role-update-description"
{...field}
aria-invalid={fieldState.invalid}
/>
{fieldState.invalid && (
<FieldError errors={[fieldState.error]} />
)}
</Field>
)}
/>
</FieldGroup>
</form>
<DialogFooter>
<DialogClose asChild>
<Button variant="ghost"></Button>
</DialogClose>
<Button type="submit" form="role-update">
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}

14
src/app/(root)/scopes.tsx Normal file
View File

@@ -0,0 +1,14 @@
"use client"
import { useSetAtom } from "jotai"
import { scopesAtom } from "@/lib/stores/scopes"
import type { Admin } from "@/models/admin"
export default function SetScopes(props: {
admin: Admin & { scopes: string[] }
}) {
const setScopes = useSetAtom(scopesAtom)
console.log("用户权限", props.admin.scopes)
setScopes(props.admin.scopes)
return null
}

View File

@@ -1,5 +0,0 @@
"use client"
export default function SecurityPage() {
return <div>~</div>
}

View File

@@ -90,6 +90,8 @@ export default function UserPage() {
format(new Date(row.original.created_at), "yyyy-MM-dd HH:mm"),
},
{
id: "action",
meta: { pin: "right" },
header: "操作",
cell: ctx => (
<Button

View File

@@ -36,7 +36,6 @@ export default function LoginPage() {
})
const router = useRouter()
const onSubmit = async (data: Schema) => {
try {
const resp = await login(data)
@@ -46,6 +45,7 @@ export default function LoginPage() {
// 登录成功后跳转到首页
router.push("/")
router.refresh()
} catch (e) {
toast.error("登录失败", {
description: e instanceof Error ? e.message : "未知错误",

View File

@@ -0,0 +1,13 @@
import { useAtomValue } from "jotai"
import type { ReactNode } from "react"
import { scopesAtom } from "@/lib/stores/scopes"
export function Auth(props: { scope: string; children: ReactNode }) {
const scopes = useAtomValue(scopesAtom)
if (!scopes.length) return props.children
const hasScope = scopes.some(s => props.scope.startsWith(s))
if (!hasScope) return null
return props.children
}

View File

@@ -1,10 +1,13 @@
import {
type Column,
type ColumnDef,
flexRender,
getCoreRowModel,
getExpandedRowModel,
useReactTable,
} from "@tanstack/react-table"
import { Loader } from "lucide-react"
import { type CSSProperties, useCallback, useMemo } from "react"
import { Pagination, type PaginationProps } from "@/components/ui/pagination"
import {
TableBody,
@@ -22,6 +25,7 @@ export type DataTableProps<T> = {
columns: ColumnDef<T>[]
pagination: PaginationProps
classNames?: {
root?: string
headRow?: string
dataRow?: string
}
@@ -43,10 +47,48 @@ export function DataTable<T extends Record<string, unknown>>(
},
columnFilters: [],
},
initialState: {
columnPinning: {
left: props.columns
.map(column =>
column.meta?.pin === "left"
? column.id || column.accessorKey
: undefined,
)
.filter(Boolean),
right: props.columns
.map(column =>
column.meta?.pin === "right"
? column.id || column.accessorKey
: undefined,
)
.filter(Boolean),
},
},
})
const pinStyle = (column: Column<T>) => {
const pinned = column.getIsPinned()
if (!pinned) return {}
return {
position: pinned ? ("sticky" as const) : undefined,
backgroundColor: "white",
zIndex: 1,
...{
left: {
left: column.getStart(pinned),
boxShadow: "inset 1px 0 var(--border)",
},
right: {
right: column.getAfter(pinned),
boxShadow: "inset 1px 0 var(--border)",
},
}[pinned],
} as CSSProperties
}
return (
<div className="flex flex-col gap-3">
<div className={cn("flex flex-col gap-3", props.classNames?.root)}>
{/* 数据表 */}
<div className="rounded-md relative bg-card">
<TableRoot>
@@ -54,7 +96,11 @@ export function DataTable<T extends Record<string, unknown>>(
{table.getHeaderGroups().map(group => (
<TableRow key={group.id}>
{group.headers.map(header => (
<TableHead key={header.id} colSpan={header.colSpan}>
<TableHead
key={header.id}
colSpan={header.colSpan}
style={pinStyle(header.column)}
>
{header.isPlaceholder
? null
: flexRender(
@@ -93,7 +139,7 @@ export function DataTable<T extends Record<string, unknown>>(
className={cn("h-14", props.classNames?.dataRow)}
>
{row.getVisibleCells().map(cell => (
<TableCell key={cell.id}>
<TableCell key={cell.id} style={pinStyle(cell.column)}>
{flexRender(
cell.column.columnDef.cell,
cell.getContext(),

View File

@@ -15,10 +15,10 @@ export function useDataTable<T>(
const [total, setTotal] = useState(0)
const refresh = useCallback(
async (page: number, size: number) => {
async (_page?: number, _size?: number) => {
setStatus("load")
try {
const resp = await fetch(page, size)
const resp = await fetch(_page ?? page, _size ?? size)
if (!resp.success) {
throw new Error("获取数据失败")
}
@@ -34,7 +34,7 @@ export function useDataTable<T>(
setStatus("fail")
}
},
[fetch, setStatus],
[fetch, page, size, setStatus],
)
const onPageChange = (page: number) => {

5
src/components/page.tsx Normal file
View File

@@ -0,0 +1,5 @@
import type { ReactNode } from "react"
export function Page(props: { children: ReactNode }) {
return <div className="flex flex-col">{props.children}</div>
}

View File

@@ -0,0 +1,196 @@
"use client"
import * as React from "react"
import { AlertDialog as AlertDialogPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
function AlertDialog({
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Root>) {
return <AlertDialogPrimitive.Root data-slot="alert-dialog" {...props} />
}
function AlertDialogTrigger({
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Trigger>) {
return (
<AlertDialogPrimitive.Trigger data-slot="alert-dialog-trigger" {...props} />
)
}
function AlertDialogPortal({
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Portal>) {
return (
<AlertDialogPrimitive.Portal data-slot="alert-dialog-portal" {...props} />
)
}
function AlertDialogOverlay({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Overlay>) {
return (
<AlertDialogPrimitive.Overlay
data-slot="alert-dialog-overlay"
className={cn(
"fixed inset-0 z-50 bg-black/50 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:animate-in data-[state=open]:fade-in-0",
className
)}
{...props}
/>
)
}
function AlertDialogContent({
className,
size = "default",
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Content> & {
size?: "default" | "sm"
}) {
return (
<AlertDialogPortal>
<AlertDialogOverlay />
<AlertDialogPrimitive.Content
data-slot="alert-dialog-content"
data-size={size}
className={cn(
"group/alert-dialog-content fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border bg-background p-6 shadow-lg duration-200 data-[size=sm]:max-w-xs data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95 data-[size=default]:sm:max-w-lg",
className
)}
{...props}
/>
</AlertDialogPortal>
)
}
function AlertDialogHeader({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-dialog-header"
className={cn(
"grid grid-rows-[auto_1fr] place-items-center gap-1.5 text-center has-data-[slot=alert-dialog-media]:grid-rows-[auto_auto_1fr] has-data-[slot=alert-dialog-media]:gap-x-6 sm:group-data-[size=default]/alert-dialog-content:place-items-start sm:group-data-[size=default]/alert-dialog-content:text-left sm:group-data-[size=default]/alert-dialog-content:has-data-[slot=alert-dialog-media]:grid-rows-[auto_1fr]",
className
)}
{...props}
/>
)
}
function AlertDialogFooter({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-dialog-footer"
className={cn(
"flex flex-col-reverse gap-2 group-data-[size=sm]/alert-dialog-content:grid group-data-[size=sm]/alert-dialog-content:grid-cols-2 sm:flex-row sm:justify-end",
className
)}
{...props}
/>
)
}
function AlertDialogTitle({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Title>) {
return (
<AlertDialogPrimitive.Title
data-slot="alert-dialog-title"
className={cn(
"text-lg font-semibold sm:group-data-[size=default]/alert-dialog-content:group-has-data-[slot=alert-dialog-media]/alert-dialog-content:col-start-2",
className
)}
{...props}
/>
)
}
function AlertDialogDescription({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Description>) {
return (
<AlertDialogPrimitive.Description
data-slot="alert-dialog-description"
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
)
}
function AlertDialogMedia({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-dialog-media"
className={cn(
"mb-2 inline-flex size-16 items-center justify-center rounded-md bg-muted sm:group-data-[size=default]/alert-dialog-content:row-span-2 *:[svg:not([class*='size-'])]:size-8",
className
)}
{...props}
/>
)
}
function AlertDialogAction({
className,
variant = "default",
size = "default",
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Action> &
Pick<React.ComponentProps<typeof Button>, "variant" | "size">) {
return (
<Button variant={variant} size={size} asChild>
<AlertDialogPrimitive.Action
data-slot="alert-dialog-action"
className={cn(className)}
{...props}
/>
</Button>
)
}
function AlertDialogCancel({
className,
variant = "outline",
size = "default",
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Cancel> &
Pick<React.ComponentProps<typeof Button>, "variant" | "size">) {
return (
<Button variant={variant} size={size} asChild>
<AlertDialogPrimitive.Cancel
data-slot="alert-dialog-cancel"
className={cn(className)}
{...props}
/>
</Button>
)
}
export {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogMedia,
AlertDialogOverlay,
AlertDialogPortal,
AlertDialogTitle,
AlertDialogTrigger,
}

View File

@@ -1,6 +1,6 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import type * as React from "react"
import { cn } from "@/lib/utils"
@@ -13,7 +13,7 @@ const buttonVariants = cva(
destructive:
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
"border bg-background hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost:
@@ -33,7 +33,7 @@ const buttonVariants = cva(
variant: "default",
size: "default",
},
}
},
)
function Button({

View File

@@ -0,0 +1,158 @@
"use client"
import * as React from "react"
import { XIcon } from "lucide-react"
import { Dialog as DialogPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
function Dialog({
...props
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
return <DialogPrimitive.Root data-slot="dialog" {...props} />
}
function DialogTrigger({
...props
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
}
function DialogPortal({
...props
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
}
function DialogClose({
...props
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
}
function DialogOverlay({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
return (
<DialogPrimitive.Overlay
data-slot="dialog-overlay"
className={cn(
"fixed inset-0 z-50 bg-black/50 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:animate-in data-[state=open]:fade-in-0",
className
)}
{...props}
/>
)
}
function DialogContent({
className,
children,
showCloseButton = true,
...props
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
showCloseButton?: boolean
}) {
return (
<DialogPortal data-slot="dialog-portal">
<DialogOverlay />
<DialogPrimitive.Content
data-slot="dialog-content"
className={cn(
"fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border bg-background p-6 shadow-lg duration-200 outline-none data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95 sm:max-w-lg",
className
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close
data-slot="dialog-close"
className="absolute top-4 right-4 rounded-xs opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:ring-2 focus:ring-ring focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
>
<XIcon />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Content>
</DialogPortal>
)
}
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-header"
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
{...props}
/>
)
}
function DialogFooter({
className,
showCloseButton = false,
children,
...props
}: React.ComponentProps<"div"> & {
showCloseButton?: boolean
}) {
return (
<div
data-slot="dialog-footer"
className={cn(
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
className
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close asChild>
<Button variant="outline">Close</Button>
</DialogPrimitive.Close>
)}
</div>
)
}
function DialogTitle({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
return (
<DialogPrimitive.Title
data-slot="dialog-title"
className={cn("text-lg leading-none font-semibold", className)}
{...props}
/>
)
}
function DialogDescription({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
return (
<DialogPrimitive.Description
data-slot="dialog-description"
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
)
}
export {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger,
}

View File

@@ -0,0 +1,44 @@
"use client"
import * as React from "react"
import { HoverCard as HoverCardPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function HoverCard({
...props
}: React.ComponentProps<typeof HoverCardPrimitive.Root>) {
return <HoverCardPrimitive.Root data-slot="hover-card" {...props} />
}
function HoverCardTrigger({
...props
}: React.ComponentProps<typeof HoverCardPrimitive.Trigger>) {
return (
<HoverCardPrimitive.Trigger data-slot="hover-card-trigger" {...props} />
)
}
function HoverCardContent({
className,
align = "center",
sideOffset = 4,
...props
}: React.ComponentProps<typeof HoverCardPrimitive.Content>) {
return (
<HoverCardPrimitive.Portal data-slot="hover-card-portal">
<HoverCardPrimitive.Content
data-slot="hover-card-content"
align={align}
sideOffset={sideOffset}
className={cn(
"z-50 w-64 origin-(--radix-hover-card-content-transform-origin) rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-hidden 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 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95",
className
)}
{...props}
/>
</HoverCardPrimitive.Portal>
)
}
export { HoverCard, HoverCardTrigger, HoverCardContent }

View File

@@ -0,0 +1,18 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
return (
<textarea
data-slot="textarea"
className={cn(
"flex field-sizing-content min-h-16 w-full rounded-md border border-input bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:aria-invalid:ring-destructive/40",
className
)}
{...props}
/>
)
}
export { Textarea }

View File

@@ -2,6 +2,7 @@ import {
type Dispatch,
type SetStateAction,
useCallback,
useEffect,
useState,
} from "react"
import { toast } from "sonner"
@@ -49,3 +50,24 @@ export function useFetch<TArgs extends unknown[], TResult>(
],
)
}
type Action = <P extends unknown[], R>(...args: P) => Promise<R>
export function useAction(action: Action) {
const [status, setStatus] = useStatus()
const func = useCallback(
async (...args: Parameters<Action>) => {
try {
setStatus("load")
await action(...args)
setStatus("done")
} catch (e) {
setStatus("fail")
throw e
}
},
[action, setStatus],
)
return [func, status]
}

65
src/lib/scopes.ts Normal file
View File

@@ -0,0 +1,65 @@
// 权限
export const ScopePermission = "permission";
export const ScopePermissionRead = "permission:read"; // 读取权限列表
export const ScopePermissionWrite = "permission:write"; // 写入权限
// 管理员角色
export const ScopeAdminRole = "admin_role";
export const ScopeAdminRoleRead = "admin_role:read"; // 读取管理员角色列表
export const ScopeAdminRoleWrite = "admin_role:write"; // 写入管理员角色
// 管理员
export const ScopeAdmin = "admin";
export const ScopeAdminRead = "admin:read"; // 读取管理员列表
export const ScopeAdminWrite = "admin:write"; // 写入管理员
// 产品
export const ScopeProduct = "product";
export const ScopeProductRead = "product:read"; // 读取产品列表
export const ScopeProductWrite = "product:write"; // 写入产品
// 产品套餐
export const ScopeProductSku = "product_sku";
export const ScopeProductSkuRead = "product_sku:read"; // 读取产品套餐列表
export const ScopeProductSkuWrite = "product_sku:write"; // 写入产品套餐
// 折扣
export const ScopeDiscount = "discount";
export const ScopeDiscountRead = "discount:read"; // 读取折扣列表
export const ScopeDiscountWrite = "discount:write"; // 写入折扣
// 用户套餐
export const ScopeResource = "resource";
export const ScopeResourceRead = "resource:read"; // 读取用户套餐列表
export const ScopeResourceWrite = "resource:write"; // 写入用户套餐
// 用户
export const ScopeUser = "user";
export const ScopeUserRead = "user:read"; // 读取用户列表
export const ScopeUserWrite = "user:write"; // 写入用户
export const ScopeUserWriteBalance = "user:write:balance"; // 写入用户余额
// 优惠券
export const ScopeCoupon = "coupon";
export const ScopeCouponRead = "coupon:read"; // 读取优惠券列表
export const ScopeCouponWrite = "coupon:write"; // 写入优惠券
// 批次
export const ScopeBatch = "batch";
export const ScopeBatchRead = "batch:read"; // 读取批次列表
export const ScopeBatchWrite = "batch:write"; // 写入批次
// IP
export const ScopeChannel = "channel";
export const ScopeChannelRead = "channel:read"; // 读取 IP 列表
export const ScopeChannelWrite = "channel:write"; // 写入 IP
// 交易
export const ScopeTrade = "trade";
export const ScopeTradeRead = "trade:read"; // 读取交易列表
export const ScopeTradeWrite = "trade:write"; // 写入交易
// 账单
export const ScopeBill = "bill";
export const ScopeBillRead = "bill:read"; // 读取账单列表
export const ScopeBillWrite = "bill:write"; // 写入账单

5
src/lib/stores/scopes.ts Normal file
View File

@@ -0,0 +1,5 @@
import { atom } from "jotai"
const scopesAtom = atom<string[]>([])
export { scopesAtom }

28
src/models/admin.ts Normal file
View File

@@ -0,0 +1,28 @@
import type { Role } from "./role"
// 管理员状态枚举
export enum AdminStatus {
Disabled = 0, // 禁用
Enabled = 1, // 正常
}
// 管理员
export type Admin = {
id: number
createdAt: Date
updatedAt: Date
username: string
password: string
name?: string
avatar?: string
phone?: string
email?: string
status: AdminStatus
lastLogin?: Date
lastLoginIp?: string
lastLoginUa?: string
roles: Role[]
}

5
src/models/base/model.ts Normal file
View File

@@ -0,0 +1,5 @@
export type Model = {
id: number
created_at: Date
updated_at: Date
}

12
src/models/permission.ts Normal file
View File

@@ -0,0 +1,12 @@
export type Permission = {
id: number
created_at: Date
updated_at: Date
expired_at: Date
parent_id?: number
name: string
description: string
parent: number
children: Permission[]
}

12
src/models/product.ts Normal file
View File

@@ -0,0 +1,12 @@
import type { Model } from "./base/model"
import type { ProductSku } from "./product_sku"
export type Product = Model & {
code: string
name: string
description?: string
sort: number
status: number
skus?: ProductSku[]
}

View File

@@ -0,0 +1,6 @@
import type { Model } from "./base/model"
export type ProductDiscount = Model & {
name: string
discount: number
}

13
src/models/product_sku.ts Normal file
View File

@@ -0,0 +1,13 @@
import type { Model } from "./base/model"
import type { Product } from "./product"
import type { ProductDiscount } from "./product_discount"
export type ProductSku = Model & {
product_id: number
code: string
name: string
price: string
product?: Product
discount?: ProductDiscount
}

14
src/models/role.ts Normal file
View File

@@ -0,0 +1,14 @@
import type { Permission } from "./permission"
export type Role = {
id: number
created_at: Date
updated_at: Date
name: string
description: string
active: boolean
sort: number
permissions: Permission[]
}

View File

@@ -1,3 +1,5 @@
import type { Admin } from "./admin"
export type User = {
id: number
admin_id?: number
@@ -21,7 +23,3 @@ export type User = {
created_at: Date
updated_at: Date
}
export type Admin = {
name: string
}

View File

@@ -1,3 +1,4 @@
import { useSetAtom } from "jotai"
import { type NextRequest, NextResponse, type ProxyConfig } from "next/server"
import { refreshAuth } from "@/actions/auth"
@@ -32,15 +33,15 @@ export async function proxy(request: NextRequest) {
}
// 验证访问令牌
const hasToken = !!request.cookies.get("admin/auth_token")
const hasToken = request.cookies.has("admin/auth_token")
// const isToAdmin = request.nextUrl.pathname.startsWith("/admin")
const protectedPaths = ["/", "/admin"]
const isProtectedPath = protectedPaths.some(
path =>
request.nextUrl.pathname === path ||
request.nextUrl.pathname.startsWith(`${path}/`),
const ignoredPaths = ["/login"]
const ignored = ignoredPaths.some(path =>
request.nextUrl.pathname.startsWith(path),
)
if (!hasToken && isProtectedPath) {
console.log("hasToken", hasToken, "ignored", ignored)
if (!hasToken && !ignored) {
return NextResponse.redirect(
`${request.nextUrl.origin}/login?redirect=${request.nextUrl.pathname}`,
)