Compare commits

3 Commits
main ... dev

Author SHA1 Message Date
3c261c4f64 docs: 补充项目目录结构与技术栈文档 2026-05-28 11:42:47 +08:00
34e52ab1d0 feat: 新增知识卡片页面及路由支持
- 添加知识卡片 API 接口与 Mock 数据
- 实现知识卡片列表展示与筛选功能
- 实现知识卡片详情抽屉
- 支持路由嵌套配置与菜单展开
2026-05-27 19:46:05 +08:00
9575e5898f feat: 添加 msw 支持 API 模拟 2026-05-26 14:55:21 +08:00
21 changed files with 1698 additions and 27 deletions

View File

@@ -46,6 +46,28 @@ go test -run TestName ./...
## 架构
### 目录结构
```
smartserve-client/
├── main.go # Wails 应用入口
├── app.go # App 结构体,暴露给前端的 Go 方法
├── go.mod / go.sum
├── wails.json # Wails 项目配置
└── frontend/
└── src/
├── App.tsx # 根组件antd 配置、401 处理器)
├── main.tsx # React 入口MSW 启动)
├── api/ # HTTP 请求函数user、knowledgeCards
├── pages/ # 页面组件(小驼峰命名)
├── layouts/ # 布局组件(大驼峰命名)
├── router/ # 路由配置与实例
├── store/ # Zustand 状态管理
├── mock/ # MSW 模拟 API开发专用
├── types/ # TypeScript 全局类型
└── utils/ # 工具函数request.ts HTTP 客户端)
```
### Go ↔ 前端桥接Wails 绑定)
-`main.go``Bind: []interface{}{app}` 中注册的结构体方法会自动暴露给前端。
@@ -61,6 +83,7 @@ go test -run TestName ./...
| `app.go` | `App` 结构体,包含暴露给前端的方法;新增 Go→JS 方法在此添加 |
| `frontend/src/App.tsx` | 根 React 组件 |
| `frontend/src/main.tsx` | React 入口点 |
| `frontend/src/utils/request.ts` | HTTP 客户端Token 注入、401 拦截、超时) |
| `wails.json` | 项目配置(前端构建命令、输出文件名、作者信息) |
### 前端技术栈
@@ -68,6 +91,8 @@ go test -run TestName ./...
- **React 19** + **TypeScript**(严格模式)
- **Ant Design 6**`antd@6.x` + `@ant-design/icons@6.x`)用于 UI 组件——**编写页面时优先使用 antd 组件**
- **react-router-dom v7** 用于路由管理
- **Zustand v5** 用于状态管理
- **MSW v2** 用于开发模式 API 模拟
- **Vite 8** 用于打包
- **pnpm** 作为包管理器(不要使用 npm/yarn
@@ -93,9 +118,31 @@ go test -run TestName ./...
|------|------|
| `frontend/src/router/routes.tsx` | 路由配置数据,导出 `RouteConfig` 接口和 `routes` 数组;菜单组件从这里取数据 |
| `frontend/src/router/index.tsx` | 基于 `routes` 创建并导出 `router` 实例(`createBrowserRouter` |
| `frontend/src/router/AuthGuard.tsx` | 路由守卫(`AuthGuard` 要求登录,`GuestGuard` 要求未登录) |
`RouteConfig` 包含 `path``label``icon``element``hideInMenu``children` 字段,菜单组件过滤 `hideInMenu: true` 的条目即可渲染菜单。
页面访问流程:
```
未登录访问任意页 → AuthGuard 重定向至 /login
登录成功 → GuestGuard 重定向至 /
```
### 状态管理Zustand
| Store | 文件 | 内容 |
|-------|------|------|
| `useUserStore` | `store/user.ts` | 用户信息、登录/登出、Token持久化至 `localStorage` key: `access_token` |
| `useAppStore` | `store/app.ts` | 侧边栏折叠状态 |
401 响应由 `utils/request.ts` 全局拦截,调用 `App.tsx` 注册的 `setUnauthorizedHandler`,自动执行 `logout()` 并跳转 `/login`
### API 层
- 请求函数位于 `frontend/src/api/`,调用 `utils/request.ts` 封装的 HTTP 客户端。
- 统一响应格式(`types/global.d.ts``API.Response<T>``code``msg``ok``data``time` 字段。
- 开发模式下,`mock/handlers/` 中的 MSW 处理器拦截请求并返回模拟数据;生产构建不包含 MSW。
### 添加新的 Go 方法
1.`app.go``App` 结构体中添加方法(必须是导出/公开的)。

View File

@@ -1 +1,2 @@
VITE_API_BASE_URL=http://localhost:8080
VITE_API_BASE_URL=
VITE_MOCK=true

View File

@@ -1 +1,2 @@
VITE_API_BASE_URL=https://api.example.com
VITE_MOCK=false

5
frontend/.gitignore vendored
View File

@@ -7,6 +7,11 @@ yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
# Env
.env
.env.*
!.env.example
node_modules
dist
dist-ssr

View File

@@ -27,8 +27,14 @@
"eslint-plugin-react-hooks": "^7.1.1",
"eslint-plugin-react-refresh": "^0.5.2",
"globals": "^17.6.0",
"msw": "^2.14.6",
"typescript": "~6.0.2",
"typescript-eslint": "^8.59.2",
"vite": "^8.0.12"
},
"msw": {
"workerDirectory": [
"public"
]
}
}

View File

@@ -1 +1 @@
1723f0aff1690fce405ab7beb6570d82
2f1b47a26399ec7c847d08cf81f5dc77

389
frontend/pnpm-lock.yaml generated
View File

@@ -54,6 +54,9 @@ importers:
globals:
specifier: ^17.6.0
version: 17.6.0
msw:
specifier: ^2.14.6
version: 2.14.6(@types/node@24.12.4)(typescript@6.0.3)
typescript:
specifier: ~6.0.2
version: 6.0.3
@@ -246,6 +249,41 @@ packages:
resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==}
engines: {node: '>=18.18'}
'@inquirer/ansi@2.0.5':
resolution: {integrity: sha512-doc2sWgJpbFQ64UflSVd17ibMGDuxO1yKgOgLMwavzESnXjFWJqUeG8saYosqKpHp4kWiM5x1nXvEjbpx90gzw==}
engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'}
'@inquirer/confirm@6.0.13':
resolution: {integrity: sha512-wkGPC7yJ5WJk1DJ5SX7fzk+gfj4BM8cf5dDDi71B/551xHrdsZVRJOC0WyikXd0pEsb/9cLniuE4atbsMqmFkw==}
engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'}
peerDependencies:
'@types/node': '>=18'
peerDependenciesMeta:
'@types/node':
optional: true
'@inquirer/core@11.1.10':
resolution: {integrity: sha512-a4Q5BXHQAHa9eO202sTaFCHFYVB3x5fauDuThEAdZ9gfn76pSxiKU7wWcEH0N1O0XmQvNfQNU6QXpiRxmYQx+A==}
engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'}
peerDependencies:
'@types/node': '>=18'
peerDependenciesMeta:
'@types/node':
optional: true
'@inquirer/figures@2.0.5':
resolution: {integrity: sha512-NsSs4kzfm12lNetHwAn3GEuH317IzpwrMCbOuMIVytpjnJ90YYHNwdRgYGuKmVxwuIqSgqk3M5qqQt1cDk0tGQ==}
engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'}
'@inquirer/type@4.0.5':
resolution: {integrity: sha512-aetVUNeKNc/VriqXlw1NRSW0zhMBB0W4bNbWRJgzRl/3d0QNDQFfk0GO5SDdtjMZVg6o8ZKEiadd7SCCzoOn5Q==}
engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'}
peerDependencies:
'@types/node': '>=18'
peerDependenciesMeta:
'@types/node':
optional: true
'@jridgewell/gen-mapping@0.3.13':
resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==}
@@ -262,12 +300,28 @@ packages:
'@jridgewell/trace-mapping@0.3.31':
resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==}
'@mswjs/interceptors@0.41.9':
resolution: {integrity: sha512-VVPPgHyQ6ShqnrmDWuxjmUIsO9gWyOZFmuOfLd9LfBGQJwZfy0gvv9pbHSJuoFNIYC7ZDX9aoFwowjcdSC4E8w==}
engines: {node: '>=18'}
'@napi-rs/wasm-runtime@1.1.4':
resolution: {integrity: sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==}
peerDependencies:
'@emnapi/core': ^1.7.1
'@emnapi/runtime': ^1.7.1
'@open-draft/deferred-promise@2.2.0':
resolution: {integrity: sha512-CecwLWx3rhxVQF6V4bAgPS5t+So2sTbPgAzafKkVizyi7tlwpcFpdFqq+wqF2OwNBmqFuu6tOyouTuxgpMfzmA==}
'@open-draft/deferred-promise@3.0.0':
resolution: {integrity: sha512-XW375UK8/9SqUVNVa6M0yEy8+iTi4QN5VZ7aZuRFQmy76LRwI9wy5F4YIBU6T+eTe2/DNDo8tqu8RHlwLHM6RA==}
'@open-draft/logger@0.3.0':
resolution: {integrity: sha512-X2g45fzhxH238HKO4xbSr7+wBS8Fvw6ixhTDuvLd5mqh6bJJCFAPwU9mPDxbcrRtfxv4u5IHCEH77BmxvXmmxQ==}
'@open-draft/until@2.1.0':
resolution: {integrity: sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg==}
'@oxc-project/types@0.130.0':
resolution: {integrity: sha512-ibD2usx9JRu7f5pu2tMKMI4cpA4NgXJQoYRP4pQ7Pxmn1l6k/53qWtQWZayhYy3X4QZkt90Ot+mJEaeXouio6Q==}
@@ -669,6 +723,12 @@ packages:
'@types/react@19.2.14':
resolution: {integrity: sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==}
'@types/set-cookie-parser@2.4.10':
resolution: {integrity: sha512-GGmQVGpQWUe5qglJozEjZV/5dyxbOOZ0LHe/lqyWssB88Y4svNfst0uqBVscdDeIKl5Jy5+aPSvy7mI9tYRguw==}
'@types/statuses@2.0.6':
resolution: {integrity: sha512-xMAgYwceFhRA2zY+XbEA7mxYbA093wdiW8Vu6gZPGWy9cmOyU9XesH1tNcEWsKFd5Vzrqx5T3D38PWx1FIIXkA==}
'@typescript-eslint/eslint-plugin@8.59.4':
resolution: {integrity: sha512-PegsU+XfyJJNjd4+u/k6f9yTyp0lEXXiPopUNobZcIAUJFGICFLN+sP0Rb3JehVmiij1Ph0dFGYqODoRo/2+6A==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
@@ -754,6 +814,14 @@ packages:
ajv@6.15.0:
resolution: {integrity: sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==}
ansi-regex@5.0.1:
resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==}
engines: {node: '>=8'}
ansi-styles@4.3.0:
resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==}
engines: {node: '>=8'}
antd@6.4.3:
resolution: {integrity: sha512-6H2avkxCGfxcF67r3J2mwm9Ck50el1pks/73vfM1wDsPL/tPtj5vHuauMgJFnrqmq7CH3g8aoZ0VBQbt+jpAsw==}
peerDependencies:
@@ -781,10 +849,25 @@ packages:
caniuse-lite@1.0.30001793:
resolution: {integrity: sha512-iwSsYWaCOoh26cV8NwNRViHlrfUvYsHDfRVcbtmw0Kg6PJIZZXwMkj1442FYLBGkeUf1juAsU3DTfxW579mrPA==}
cli-width@4.1.0:
resolution: {integrity: sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==}
engines: {node: '>= 12'}
cliui@8.0.1:
resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==}
engines: {node: '>=12'}
clsx@2.1.1:
resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==}
engines: {node: '>=6'}
color-convert@2.0.1:
resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==}
engines: {node: '>=7.0.0'}
color-name@1.1.4:
resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==}
compute-scroll-into-view@3.1.1:
resolution: {integrity: sha512-VRhuHOLoKYOy4UbilLbUzbYg93XLjv2PncJC50EuTWPA3gaja1UjBsUP/D/9/juV3vQFr6XBEzn9KCAHdUvOHw==}
@@ -824,6 +907,9 @@ packages:
electron-to-chromium@1.5.359:
resolution: {integrity: sha512-8lPELWuYZIWk7NDvCNthtmMw/7Q5Wu25NpM4djFMHBmk8DubPAtL4YTOp7ou0e7HyJtwkVlWv8XMLURnrtgJQw==}
emoji-regex@8.0.0:
resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==}
escalade@3.2.0:
resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==}
engines: {node: '>=6'}
@@ -894,6 +980,15 @@ packages:
fast-levenshtein@2.0.6:
resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==}
fast-string-truncated-width@3.0.3:
resolution: {integrity: sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g==}
fast-string-width@3.0.2:
resolution: {integrity: sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg==}
fast-wrap-ansi@0.2.0:
resolution: {integrity: sha512-rLV8JHxTyhVmFYhBJuMujcrHqOT2cnO5Zxj37qROj23CP39GXubJRBUFF0z8KFK77Uc0SukZUf7JZhsVEQ6n8w==}
fdir@6.5.0:
resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==}
engines: {node: '>=12.0.0'}
@@ -927,6 +1022,10 @@ packages:
resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==}
engines: {node: '>=6.9.0'}
get-caller-file@2.0.5:
resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==}
engines: {node: 6.* || 8.* || >= 10.*}
glob-parent@6.0.2:
resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==}
engines: {node: '>=10.13.0'}
@@ -935,6 +1034,13 @@ packages:
resolution: {integrity: sha512-sepffkT8stwnIYbsMBpoCHJuJM5l98FUF2AnE07hfvE0m/qp3R586hw4jF4uadbhvg1ooIdzuu7CsfD2jzCaNA==}
engines: {node: '>=18'}
graphql@16.14.0:
resolution: {integrity: sha512-BBvQ/406p+4CZbTpCbVPSxfzrZrbnuWSP1ELYgyS6B+hNeKzgrdB4JczCa5VZUBQrDa9hUngm0KnexY6pJRN5Q==}
engines: {node: ^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0}
headers-polyfill@5.0.1:
resolution: {integrity: sha512-1TJ6Fih/b8h5TIcv+1+Hw0PDQWJTKDKzFZzcKOiW1wJza3XoAQlkCuXLbymPYB8+ZQyw8mHvdw560e8zVFIWyA==}
hermes-estree@0.25.1:
resolution: {integrity: sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==}
@@ -957,6 +1063,10 @@ packages:
resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==}
engines: {node: '>=0.10.0'}
is-fullwidth-code-point@3.0.0:
resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==}
engines: {node: '>=8'}
is-glob@4.0.3:
resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==}
engines: {node: '>=0.10.0'}
@@ -964,6 +1074,9 @@ packages:
is-mobile@5.0.0:
resolution: {integrity: sha512-Tz/yndySvLAEXh+Uk8liFCxOwVH6YutuR74utvOcu7I9Di+DwM0mtdPVZNaVvvBUM2OXxne/NhOs1zAO7riusQ==}
is-node-process@1.2.0:
resolution: {integrity: sha512-Vg4o6/fqPxIjtxgUH5QLJhwZ7gW5diGCVlXpuUfELC62CuxM1iHcRe51f2W1FDy04Ai4KJkagKjx3XaqyfRKXw==}
isexe@2.0.0:
resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==}
@@ -1087,6 +1200,20 @@ packages:
ms@2.1.3:
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
msw@2.14.6:
resolution: {integrity: sha512-ALe+N10S72cyx94cMcy3Zs4HhXCj35sgeAL4c+WTvKi0zWnbd8/h0lcFqv0mb2P+aSgAdD7p9HzvA0DiUPxsyg==}
engines: {node: '>=18'}
hasBin: true
peerDependencies:
typescript: '>= 4.8.x'
peerDependenciesMeta:
typescript:
optional: true
mute-stream@3.0.0:
resolution: {integrity: sha512-dkEJPVvun4FryqBmZ5KhDo0K9iDXAwn08tMLDinNdRBNPcYEDiWYysLcc6k3mjTMlbP9KyylvRpd4wFtwrT9rw==}
engines: {node: ^20.17.0 || >=22.9.0}
nanoid@3.3.12:
resolution: {integrity: sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==}
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
@@ -1102,6 +1229,9 @@ packages:
resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==}
engines: {node: '>= 0.8.0'}
outvariant@1.4.3:
resolution: {integrity: sha512-+Sl2UErvtsoajRDKCE5/dBz4DIvHXQQnAxtQTF04OJxY0+DyZXSo5P5Bb7XYWOh81syohlYL24hbDwxedPUJCA==}
p-limit@3.1.0:
resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==}
engines: {node: '>=10'}
@@ -1118,6 +1248,9 @@ packages:
resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==}
engines: {node: '>=8'}
path-to-regexp@6.3.0:
resolution: {integrity: sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==}
picocolors@1.1.1:
resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
@@ -1166,6 +1299,13 @@ packages:
resolution: {integrity: sha512-sfWGGfavi0xr8Pg0sVsyHMAOziVYKgPLNrS7ig+ivMNb3wbCBw3KxtflsGBAwD3gYQlE/AEZsTLgToRrSCjb0Q==}
engines: {node: '>=0.10.0'}
require-directory@2.1.1:
resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==}
engines: {node: '>=0.10.0'}
rettime@0.11.11:
resolution: {integrity: sha512-ILJRqVWBCTlg9r42fFgwVZx1gnFAcQF8mRoMkbgQfIrjEDf9nbBFDFx00oloOa+Q869FUtaYDXZvEfnecQSCoQ==}
rolldown@1.0.1:
resolution: {integrity: sha512-X0KQHljNnEkWNqqiz9zJrGunh1B0HgOxLXvnFpCOcadzcy5qohZ3tqMEUg00vncoRovXuK3ZqCT9KnnKzoInFQ==}
engines: {node: ^20.19.0 || >=22.12.0}
@@ -1189,6 +1329,9 @@ packages:
set-cookie-parser@2.7.2:
resolution: {integrity: sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==}
set-cookie-parser@3.1.0:
resolution: {integrity: sha512-kjnC1DXBHcxaOaOXBHBeRtltsDG2nUiUni+jP92M9gYdW12rsmx92UsfpH7o5tDRs7I1ZZPSQJQGv3UaRfCiuw==}
shebang-command@2.0.0:
resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==}
engines: {node: '>=8'}
@@ -1197,16 +1340,39 @@ packages:
resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==}
engines: {node: '>=8'}
signal-exit@4.1.0:
resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==}
engines: {node: '>=14'}
source-map-js@1.2.1:
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
engines: {node: '>=0.10.0'}
statuses@2.0.2:
resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==}
engines: {node: '>= 0.8'}
strict-event-emitter@0.5.1:
resolution: {integrity: sha512-vMgjE/GGEPEFnhFub6pa4FmJBRBVOLpIII2hvCZ8Kzb7K0hlHo7mQv6xYrBvCL2LtAIBwFUK8wvuJgTVSQ5MFQ==}
string-convert@0.2.1:
resolution: {integrity: sha512-u/1tdPl4yQnPBjnVrmdLo9gtuLvELKsAoRapekWggdiQNvvvum+jYF329d84NAa660KQw7pB2n36KrIKVoXa3A==}
string-width@4.2.3:
resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==}
engines: {node: '>=8'}
strip-ansi@6.0.1:
resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==}
engines: {node: '>=8'}
stylis@4.4.0:
resolution: {integrity: sha512-5Z9ZpRzfuH6l/UAvCPAPUo3665Nk2wLaZU3x+TLHKVzIz33+sbJqbtrYoC3KD4/uVOr2Zp+L0LySezP9OHV9yA==}
tagged-tag@1.0.0:
resolution: {integrity: sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==}
engines: {node: '>=20'}
throttle-debounce@5.0.2:
resolution: {integrity: sha512-B71/4oyj61iNH0KeCamLuE2rmKuTO5byTOSVwECM5FA7TiAiAW+UqTKZ9ERueC4qvgSttUhdmq1mXC3kJqGX7A==}
engines: {node: '>=12.22'}
@@ -1215,6 +1381,17 @@ packages:
resolution: {integrity: sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==}
engines: {node: '>=12.0.0'}
tldts-core@7.0.30:
resolution: {integrity: sha512-uiHN8PIB1VmWyS98eZYja4xzlYqeFZVjb4OuYlJQnZAuJhMw4PbKQOKgHKhBdJR3FE/t5mUQ1Kd80++B+qhD1Q==}
tldts@7.0.30:
resolution: {integrity: sha512-ELrFxuqsDdHUwoh0XxDbxuLD3Wnz49Z57IFvTtvWy1hJdcMZjXLIuonjilCiWHlT2GbE4Wlv1wKVTzDFnXH1aw==}
hasBin: true
tough-cookie@6.0.1:
resolution: {integrity: sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw==}
engines: {node: '>=16'}
ts-api-utils@2.5.0:
resolution: {integrity: sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==}
engines: {node: '>=18.12'}
@@ -1228,6 +1405,10 @@ packages:
resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==}
engines: {node: '>= 0.8.0'}
type-fest@5.6.0:
resolution: {integrity: sha512-8ZiHFm91orbSAe2PSAiSVBVko18pbhbiB3U9GglSzF/zCGkR+rxpHx6sEMCUm4kxY4LjDIUGgCfUMtwfZfjfUA==}
engines: {node: '>=20'}
typescript-eslint@8.59.4:
resolution: {integrity: sha512-Rw6+44QNFaXtgHSjPy+Kw8hrJniMYzR85E9yLmOLcfZ91/rz+JXQbDTCmc6ccxMPY6K6PgAq26f0JCBfR7LIPQ==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
@@ -1243,6 +1424,9 @@ packages:
undici-types@7.16.0:
resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==}
until-async@3.0.2:
resolution: {integrity: sha512-IiSk4HlzAMqTUseHHe3VhIGyuFmN90zMTpD3Z3y8jeQbzLIq500MVM7Jq2vUAnTKAFPJrqwkzr6PoTcPhGcOiw==}
update-browserslist-db@1.2.3:
resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==}
hasBin: true
@@ -1304,9 +1488,25 @@ packages:
resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==}
engines: {node: '>=0.10.0'}
wrap-ansi@7.0.0:
resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==}
engines: {node: '>=10'}
y18n@5.0.8:
resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==}
engines: {node: '>=10'}
yallist@3.1.1:
resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==}
yargs-parser@21.1.1:
resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==}
engines: {node: '>=12'}
yargs@17.7.2:
resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==}
engines: {node: '>=12'}
yocto-queue@0.1.0:
resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==}
engines: {node: '>=10'}
@@ -1558,6 +1758,33 @@ snapshots:
'@humanwhocodes/retry@0.4.3': {}
'@inquirer/ansi@2.0.5': {}
'@inquirer/confirm@6.0.13(@types/node@24.12.4)':
dependencies:
'@inquirer/core': 11.1.10(@types/node@24.12.4)
'@inquirer/type': 4.0.5(@types/node@24.12.4)
optionalDependencies:
'@types/node': 24.12.4
'@inquirer/core@11.1.10(@types/node@24.12.4)':
dependencies:
'@inquirer/ansi': 2.0.5
'@inquirer/figures': 2.0.5
'@inquirer/type': 4.0.5(@types/node@24.12.4)
cli-width: 4.1.0
fast-wrap-ansi: 0.2.0
mute-stream: 3.0.0
signal-exit: 4.1.0
optionalDependencies:
'@types/node': 24.12.4
'@inquirer/figures@2.0.5': {}
'@inquirer/type@4.0.5(@types/node@24.12.4)':
optionalDependencies:
'@types/node': 24.12.4
'@jridgewell/gen-mapping@0.3.13':
dependencies:
'@jridgewell/sourcemap-codec': 1.5.5
@@ -1577,6 +1804,15 @@ snapshots:
'@jridgewell/resolve-uri': 3.1.2
'@jridgewell/sourcemap-codec': 1.5.5
'@mswjs/interceptors@0.41.9':
dependencies:
'@open-draft/deferred-promise': 2.2.0
'@open-draft/logger': 0.3.0
'@open-draft/until': 2.1.0
is-node-process: 1.2.0
outvariant: 1.4.3
strict-event-emitter: 0.5.1
'@napi-rs/wasm-runtime@1.1.4(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)':
dependencies:
'@emnapi/core': 1.10.0
@@ -1584,6 +1820,17 @@ snapshots:
'@tybys/wasm-util': 0.10.2
optional: true
'@open-draft/deferred-promise@2.2.0': {}
'@open-draft/deferred-promise@3.0.0': {}
'@open-draft/logger@0.3.0':
dependencies:
is-node-process: 1.2.0
outvariant: 1.4.3
'@open-draft/until@2.1.0': {}
'@oxc-project/types@0.130.0': {}
'@rc-component/async-validator@5.1.0':
@@ -1997,6 +2244,12 @@ snapshots:
dependencies:
csstype: 3.2.3
'@types/set-cookie-parser@2.4.10':
dependencies:
'@types/node': 24.12.4
'@types/statuses@2.0.6': {}
'@typescript-eslint/eslint-plugin@8.59.4(@typescript-eslint/parser@8.59.4(eslint@10.4.0)(typescript@6.0.3))(eslint@10.4.0)(typescript@6.0.3)':
dependencies:
'@eslint-community/regexpp': 4.12.2
@@ -2106,6 +2359,12 @@ snapshots:
json-schema-traverse: 0.4.1
uri-js: 4.4.1
ansi-regex@5.0.1: {}
ansi-styles@4.3.0:
dependencies:
color-convert: 2.0.1
antd@6.4.3(react-dom@19.2.6(react@19.2.6))(react@19.2.6):
dependencies:
'@ant-design/colors': 8.0.1
@@ -2180,8 +2439,22 @@ snapshots:
caniuse-lite@1.0.30001793: {}
cli-width@4.1.0: {}
cliui@8.0.1:
dependencies:
string-width: 4.2.3
strip-ansi: 6.0.1
wrap-ansi: 7.0.0
clsx@2.1.1: {}
color-convert@2.0.1:
dependencies:
color-name: 1.1.4
color-name@1.1.4: {}
compute-scroll-into-view@3.1.1: {}
convert-source-map@2.0.0: {}
@@ -2208,6 +2481,8 @@ snapshots:
electron-to-chromium@1.5.359: {}
emoji-regex@8.0.0: {}
escalade@3.2.0: {}
escape-string-regexp@4.0.0: {}
@@ -2297,6 +2572,16 @@ snapshots:
fast-levenshtein@2.0.6: {}
fast-string-truncated-width@3.0.3: {}
fast-string-width@3.0.2:
dependencies:
fast-string-truncated-width: 3.0.3
fast-wrap-ansi@0.2.0:
dependencies:
fast-string-width: 3.0.2
fdir@6.5.0(picomatch@4.0.4):
optionalDependencies:
picomatch: 4.0.4
@@ -2322,12 +2607,21 @@ snapshots:
gensync@1.0.0-beta.2: {}
get-caller-file@2.0.5: {}
glob-parent@6.0.2:
dependencies:
is-glob: 4.0.3
globals@17.6.0: {}
graphql@16.14.0: {}
headers-polyfill@5.0.1:
dependencies:
'@types/set-cookie-parser': 2.4.10
set-cookie-parser: 3.1.0
hermes-estree@0.25.1: {}
hermes-parser@0.25.1:
@@ -2342,12 +2636,16 @@ snapshots:
is-extglob@2.1.1: {}
is-fullwidth-code-point@3.0.0: {}
is-glob@4.0.3:
dependencies:
is-extglob: 2.1.1
is-mobile@5.0.0: {}
is-node-process@1.2.0: {}
isexe@2.0.0: {}
js-tokens@4.0.0: {}
@@ -2438,6 +2736,33 @@ snapshots:
ms@2.1.3: {}
msw@2.14.6(@types/node@24.12.4)(typescript@6.0.3):
dependencies:
'@inquirer/confirm': 6.0.13(@types/node@24.12.4)
'@mswjs/interceptors': 0.41.9
'@open-draft/deferred-promise': 3.0.0
'@types/statuses': 2.0.6
cookie: 1.1.1
graphql: 16.14.0
headers-polyfill: 5.0.1
is-node-process: 1.2.0
outvariant: 1.4.3
path-to-regexp: 6.3.0
picocolors: 1.1.1
rettime: 0.11.11
statuses: 2.0.2
strict-event-emitter: 0.5.1
tough-cookie: 6.0.1
type-fest: 5.6.0
until-async: 3.0.2
yargs: 17.7.2
optionalDependencies:
typescript: 6.0.3
transitivePeerDependencies:
- '@types/node'
mute-stream@3.0.0: {}
nanoid@3.3.12: {}
natural-compare@1.4.0: {}
@@ -2453,6 +2778,8 @@ snapshots:
type-check: 0.4.0
word-wrap: 1.2.5
outvariant@1.4.3: {}
p-limit@3.1.0:
dependencies:
yocto-queue: 0.1.0
@@ -2465,6 +2792,8 @@ snapshots:
path-key@3.1.1: {}
path-to-regexp@6.3.0: {}
picocolors@1.1.1: {}
picomatch@4.0.4: {}
@@ -2502,6 +2831,10 @@ snapshots:
react@19.2.6: {}
require-directory@2.1.1: {}
rettime@0.11.11: {}
rolldown@1.0.1:
dependencies:
'@oxc-project/types': 0.130.0
@@ -2535,18 +2868,38 @@ snapshots:
set-cookie-parser@2.7.2: {}
set-cookie-parser@3.1.0: {}
shebang-command@2.0.0:
dependencies:
shebang-regex: 3.0.0
shebang-regex@3.0.0: {}
signal-exit@4.1.0: {}
source-map-js@1.2.1: {}
statuses@2.0.2: {}
strict-event-emitter@0.5.1: {}
string-convert@0.2.1: {}
string-width@4.2.3:
dependencies:
emoji-regex: 8.0.0
is-fullwidth-code-point: 3.0.0
strip-ansi: 6.0.1
strip-ansi@6.0.1:
dependencies:
ansi-regex: 5.0.1
stylis@4.4.0: {}
tagged-tag@1.0.0: {}
throttle-debounce@5.0.2: {}
tinyglobby@0.2.16:
@@ -2554,6 +2907,16 @@ snapshots:
fdir: 6.5.0(picomatch@4.0.4)
picomatch: 4.0.4
tldts-core@7.0.30: {}
tldts@7.0.30:
dependencies:
tldts-core: 7.0.30
tough-cookie@6.0.1:
dependencies:
tldts: 7.0.30
ts-api-utils@2.5.0(typescript@6.0.3):
dependencies:
typescript: 6.0.3
@@ -2565,6 +2928,10 @@ snapshots:
dependencies:
prelude-ls: 1.2.1
type-fest@5.6.0:
dependencies:
tagged-tag: 1.0.0
typescript-eslint@8.59.4(eslint@10.4.0)(typescript@6.0.3):
dependencies:
'@typescript-eslint/eslint-plugin': 8.59.4(@typescript-eslint/parser@8.59.4(eslint@10.4.0)(typescript@6.0.3))(eslint@10.4.0)(typescript@6.0.3)
@@ -2580,6 +2947,8 @@ snapshots:
undici-types@7.16.0: {}
until-async@3.0.2: {}
update-browserslist-db@1.2.3(browserslist@4.28.2):
dependencies:
browserslist: 4.28.2
@@ -2607,8 +2976,28 @@ snapshots:
word-wrap@1.2.5: {}
wrap-ansi@7.0.0:
dependencies:
ansi-styles: 4.3.0
string-width: 4.2.3
strip-ansi: 6.0.1
y18n@5.0.8: {}
yallist@3.1.1: {}
yargs-parser@21.1.1: {}
yargs@17.7.2:
dependencies:
cliui: 8.0.1
escalade: 3.2.0
get-caller-file: 2.0.5
require-directory: 2.1.1
string-width: 4.2.3
y18n: 5.0.8
yargs-parser: 21.1.1
yocto-queue@0.1.0: {}
zod-validation-error@4.0.2(zod@4.4.3):

View File

@@ -0,0 +1,349 @@
/* eslint-disable */
/* tslint:disable */
/**
* Mock Service Worker.
* @see https://github.com/mswjs/msw
* - Please do NOT modify this file.
*/
const PACKAGE_VERSION = '2.14.6'
const INTEGRITY_CHECKSUM = '4db4a41e972cec1b64cc569c66952d82'
const IS_MOCKED_RESPONSE = Symbol('isMockedResponse')
const activeClientIds = new Set()
addEventListener('install', function () {
self.skipWaiting()
})
addEventListener('activate', function (event) {
event.waitUntil(self.clients.claim())
})
addEventListener('message', async function (event) {
const clientId = Reflect.get(event.source || {}, 'id')
if (!clientId || !self.clients) {
return
}
const client = await self.clients.get(clientId)
if (!client) {
return
}
const allClients = await self.clients.matchAll({
type: 'window',
})
switch (event.data) {
case 'KEEPALIVE_REQUEST': {
sendToClient(client, {
type: 'KEEPALIVE_RESPONSE',
})
break
}
case 'INTEGRITY_CHECK_REQUEST': {
sendToClient(client, {
type: 'INTEGRITY_CHECK_RESPONSE',
payload: {
packageVersion: PACKAGE_VERSION,
checksum: INTEGRITY_CHECKSUM,
},
})
break
}
case 'MOCK_ACTIVATE': {
activeClientIds.add(clientId)
sendToClient(client, {
type: 'MOCKING_ENABLED',
payload: {
client: {
id: client.id,
frameType: client.frameType,
},
},
})
break
}
case 'CLIENT_CLOSED': {
activeClientIds.delete(clientId)
const remainingClients = allClients.filter((client) => {
return client.id !== clientId
})
// Unregister itself when there are no more clients
if (remainingClients.length === 0) {
self.registration.unregister()
}
break
}
}
})
addEventListener('fetch', function (event) {
const requestInterceptedAt = Date.now()
// Bypass navigation requests.
if (event.request.mode === 'navigate') {
return
}
// Opening the DevTools triggers the "only-if-cached" request
// that cannot be handled by the worker. Bypass such requests.
if (
event.request.cache === 'only-if-cached' &&
event.request.mode !== 'same-origin'
) {
return
}
// Bypass all requests when there are no active clients.
// Prevents the self-unregistered worked from handling requests
// after it's been terminated (still remains active until the next reload).
if (activeClientIds.size === 0) {
return
}
const requestId = crypto.randomUUID()
event.respondWith(handleRequest(event, requestId, requestInterceptedAt))
})
/**
* @param {FetchEvent} event
* @param {string} requestId
* @param {number} requestInterceptedAt
*/
async function handleRequest(event, requestId, requestInterceptedAt) {
const client = await resolveMainClient(event)
const requestCloneForEvents = event.request.clone()
const response = await getResponse(
event,
client,
requestId,
requestInterceptedAt,
)
// Send back the response clone for the "response:*" life-cycle events.
// Ensure MSW is active and ready to handle the message, otherwise
// this message will pend indefinitely.
if (client && activeClientIds.has(client.id)) {
const serializedRequest = await serializeRequest(requestCloneForEvents)
// Clone the response so both the client and the library could consume it.
const responseClone = response.clone()
sendToClient(
client,
{
type: 'RESPONSE',
payload: {
isMockedResponse: IS_MOCKED_RESPONSE in response,
request: {
id: requestId,
...serializedRequest,
},
response: {
type: responseClone.type,
status: responseClone.status,
statusText: responseClone.statusText,
headers: Object.fromEntries(responseClone.headers.entries()),
body: responseClone.body,
},
},
},
responseClone.body ? [serializedRequest.body, responseClone.body] : [],
)
}
return response
}
/**
* Resolve the main client for the given event.
* Client that issues a request doesn't necessarily equal the client
* that registered the worker. It's with the latter the worker should
* communicate with during the response resolving phase.
* @param {FetchEvent} event
* @returns {Promise<Client | undefined>}
*/
async function resolveMainClient(event) {
const client = await self.clients.get(event.clientId)
if (activeClientIds.has(event.clientId)) {
return client
}
if (client?.frameType === 'top-level') {
return client
}
const allClients = await self.clients.matchAll({
type: 'window',
})
return allClients
.filter((client) => {
// Get only those clients that are currently visible.
return client.visibilityState === 'visible'
})
.find((client) => {
// Find the client ID that's recorded in the
// set of clients that have registered the worker.
return activeClientIds.has(client.id)
})
}
/**
* @param {FetchEvent} event
* @param {Client | undefined} client
* @param {string} requestId
* @param {number} requestInterceptedAt
* @returns {Promise<Response>}
*/
async function getResponse(event, client, requestId, requestInterceptedAt) {
// Clone the request because it might've been already used
// (i.e. its body has been read and sent to the client).
const requestClone = event.request.clone()
function passthrough() {
// Cast the request headers to a new Headers instance
// so the headers can be manipulated with.
const headers = new Headers(requestClone.headers)
// Remove the "accept" header value that marked this request as passthrough.
// This prevents request alteration and also keeps it compliant with the
// user-defined CORS policies.
const acceptHeader = headers.get('accept')
if (acceptHeader) {
const values = acceptHeader.split(',').map((value) => value.trim())
const filteredValues = values.filter(
(value) => value !== 'msw/passthrough',
)
if (filteredValues.length > 0) {
headers.set('accept', filteredValues.join(', '))
} else {
headers.delete('accept')
}
}
return fetch(requestClone, { headers })
}
// Bypass mocking when the client is not active.
if (!client) {
return passthrough()
}
// Bypass initial page load requests (i.e. static assets).
// The absence of the immediate/parent client in the map of the active clients
// means that MSW hasn't dispatched the "MOCK_ACTIVATE" event yet
// and is not ready to handle requests.
if (!activeClientIds.has(client.id)) {
return passthrough()
}
// Notify the client that a request has been intercepted.
const serializedRequest = await serializeRequest(event.request)
const clientMessage = await sendToClient(
client,
{
type: 'REQUEST',
payload: {
id: requestId,
interceptedAt: requestInterceptedAt,
...serializedRequest,
},
},
[serializedRequest.body],
)
switch (clientMessage.type) {
case 'MOCK_RESPONSE': {
return respondWithMock(clientMessage.data)
}
case 'PASSTHROUGH': {
return passthrough()
}
}
return passthrough()
}
/**
* @param {Client} client
* @param {any} message
* @param {Array<Transferable>} transferrables
* @returns {Promise<any>}
*/
function sendToClient(client, message, transferrables = []) {
return new Promise((resolve, reject) => {
const channel = new MessageChannel()
channel.port1.onmessage = (event) => {
if (event.data && event.data.error) {
return reject(event.data.error)
}
resolve(event.data)
}
client.postMessage(message, [
channel.port2,
...transferrables.filter(Boolean),
])
})
}
/**
* @param {Response} response
* @returns {Response}
*/
function respondWithMock(response) {
// Setting response status code to 0 is a no-op.
// However, when responding with a "Response.error()", the produced Response
// instance will have status code set to 0. Since it's not possible to create
// a Response instance with status code 0, handle that use-case separately.
if (response.status === 0) {
return Response.error()
}
const mockedResponse = new Response(response.body, response)
Reflect.defineProperty(mockedResponse, IS_MOCKED_RESPONSE, {
value: true,
enumerable: true,
})
return mockedResponse
}
/**
* @param {Request} request
*/
async function serializeRequest(request) {
return {
url: request.url,
mode: request.mode,
method: request.method,
headers: Object.fromEntries(request.headers.entries()),
cache: request.cache,
credentials: request.credentials,
destination: request.destination,
integrity: request.integrity,
redirect: request.redirect,
referrer: request.referrer,
referrerPolicy: request.referrerPolicy,
body: await request.arrayBuffer(),
keepalive: request.keepalive,
}
}

View File

@@ -0,0 +1,38 @@
import { get } from '@/utils/request'
export type KnowledgeCardType = 'global' | 'product' | 'chat'
export type KnowledgeCardAuditStatus = 'pending' | 'approved'
export interface RelatedProduct {
id: number
name: string
}
export interface RelatedChat {
id: number
name: string
}
export interface KnowledgeCard {
id: number
title: string
content: string
type: KnowledgeCardType
auditStatus: KnowledgeCardAuditStatus
author: string
updatedAt: string
coverColor: string
tags: string[]
relatedProducts: RelatedProduct[]
relatedChats: RelatedChat[]
}
export interface GetKnowledgeCardsParams {
type?: KnowledgeCardType
auditStatus?: KnowledgeCardAuditStatus
}
/** 获取知识卡片列表 */
export function getKnowledgeCards(params?: GetKnowledgeCardsParams) {
return get<KnowledgeCard[]>('/knowledge/cards', params as Record<string, string>)
}

View File

@@ -7,7 +7,7 @@ interface UserInfo {
/** 手机号码 */
phone: string
/** 昵称 */
nickname: string
nickName: string
avatar?: string
}
@@ -18,7 +18,6 @@ interface LoginParams {
interface LoginResult {
token: string
user: UserInfo
}
// ---- API 函数 ----

View File

@@ -1,13 +1,25 @@
import { Layout, Menu, Button, Popconfirm, Avatar, theme } from 'antd'
import { MenuFoldOutlined, MenuUnfoldOutlined, LogoutOutlined, UserOutlined } from '@ant-design/icons'
import { useNavigate, useLocation, Outlet } from 'react-router-dom'
import { routes } from '@/router/routes'
import { routes, type RouteConfig } from '@/router/routes'
import { useAppStore } from '@/store/app'
import { useUserStore } from '@/store/user'
import type { MenuProps } from 'antd'
const { Sider, Header, Content } = Layout
// 递归扁平化所有路由,用于查找当前路径对应的标题
function flatRouteLabel(list: RouteConfig[], pathname: string): string {
for (const r of list) {
if (r.path === pathname) return r.label
if (r.children) {
const found = flatRouteLabel(r.children, pathname)
if (found) return found
}
}
return ''
}
const SIDER_WIDTH = 220
const SIDER_COLLAPSED_WIDTH = 64
const HEADER_HEIGHT = 64
@@ -102,6 +114,7 @@ function BasicLayout() {
<Menu
mode="inline"
selectedKeys={[location.pathname]}
defaultOpenKeys={routes.filter(r => r.children).map(r => r.path)}
items={menuItems}
onClick={handleMenuClick}
style={{ borderInlineEnd: 'none', paddingTop: 8 }}
@@ -143,7 +156,7 @@ function BasicLayout() {
{/* 当前页面标题 */}
<span style={{ fontSize: 16, fontWeight: 600, color: token.colorText }}>
{routes.find(r => r.path === location.pathname)?.label ?? ''}
{flatRouteLabel(routes, location.pathname)}
</span>
{/* 右侧操作区 */}
@@ -156,7 +169,7 @@ function BasicLayout() {
style={{ background: token.colorPrimary }}
/>
<span style={{ color: token.colorText, fontSize: 14 }}>
{userInfo?.nickname ?? ''}
{userInfo?.nickName ?? ''}
</span>
<Popconfirm

View File

@@ -3,8 +3,21 @@ import { createRoot } from 'react-dom/client'
import './index.css'
import App from '@/App.tsx'
async function bootstrap() {
// 仅开发环境启动 MSW生产构建时此分支被 tree-shaking 移除
if (import.meta.env.VITE_MOCK === 'true') {
const { worker } = await import('@/mock/browser')
await worker.start({
// 未匹配的请求透传到真实网络,不打印警告
onUnhandledRequest: 'bypass',
})
}
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
)
}
bootstrap()

View File

@@ -0,0 +1,5 @@
import { setupWorker } from 'msw/browser'
import { handlers } from './handlers'
/** MSW browser worker 实例,仅在开发环境使用 */
export const worker = setupWorker(...handlers)

View File

@@ -0,0 +1,8 @@
import { userHandlers } from './handlers/user'
import { knowledgeCardsHandlers } from './handlers/knowledgeCards'
/** 所有 mock handlers按模块聚合 */
export const handlers = [
...userHandlers,
...knowledgeCardsHandlers,
]

View File

@@ -0,0 +1,158 @@
import { http, HttpResponse, delay } from 'msw'
import type { KnowledgeCard } from '@/api/knowledgeCards'
const mockCards: KnowledgeCard[] = [
{
id: 1,
title: '如何处理客户退款申请',
content:
'当客户提出退款申请时首先需要核实订单信息确认是否在退款政策范围内。若符合条件按照标准流程发起退款并在24小时内完成审核。对于特殊情况需上报主管审批。',
type: 'global',
auditStatus: 'approved',
author: '张三',
updatedAt: '2025-05-20',
coverColor: '#e6f4ff',
tags: ['退款', '售后', '客服流程'],
relatedProducts: [{ id: 101, name: '夏季连衣裙' }, { id: 102, name: '运动鞋' }],
relatedChats: [{ id: 201, name: '退款标准话术' }],
},
{
id: 2,
title: '新品发布常见问题解答',
content:
'本文档汇总了新品发布期间客户最常咨询的问题,包括发货时间、库存查询、预售规则等,客服可直接引用回复客户。',
type: 'product',
auditStatus: 'pending',
author: '李四',
updatedAt: '2025-05-22',
coverColor: '#fff7e6',
tags: ['新品', 'FAQ', '预售'],
relatedProducts: [{ id: 103, name: '2025夏季新款T恤' }],
relatedChats: [{ id: 202, name: '新品咨询话术' }, { id: 203, name: '预售说明' }],
},
{
id: 3,
title: '节假日活动话术模板',
content:
'双十一、春节、618等大促节点的标准欢迎语、活动介绍话术以及常见异议处理方式。使用时请根据当前活动情况替换活动名称和折扣信息。',
type: 'chat',
auditStatus: 'approved',
author: '王五',
updatedAt: '2025-05-18',
coverColor: '#f6ffed',
tags: ['大促', '话术', '双十一', '618'],
relatedProducts: [],
relatedChats: [{ id: 204, name: '双十一欢迎语' }, { id: 205, name: '618活动说明' }],
},
{
id: 4,
title: '商品质量问题处理规范',
content:
'针对客户反馈商品质量问题的完整处理流程:收集问题描述与凭证、判断责任归属、协商解决方案(补寄/退款/换货)、记录归档。',
type: 'product',
auditStatus: 'approved',
author: '赵六',
updatedAt: '2025-05-15',
coverColor: '#fff0f6',
tags: ['质量问题', '换货', '售后'],
relatedProducts: [{ id: 104, name: '皮质背包' }, { id: 105, name: '陶瓷餐具套装' }],
relatedChats: [{ id: 206, name: '质量投诉处理话术' }],
},
{
id: 5,
title: '客户服务礼貌用语规范',
content:
'统一客服沟通风格,包括:开场白、道歉用语、感谢用语、告别语等,禁止使用的不当用语列表,以及如何在情绪激动的客户面前保持专业态度。',
type: 'chat',
auditStatus: 'pending',
author: '孙七',
updatedAt: '2025-05-23',
coverColor: '#e6f4ff',
tags: ['礼貌用语', '规范', '沟通技巧'],
relatedProducts: [],
relatedChats: [{ id: 207, name: '通用开场白' }, { id: 208, name: '道歉话术' }],
},
{
id: 6,
title: '快递异常处理指南',
content:
'物流延误、快递丢失、破损到货等场景的处理方式。包括如何查询快递信息、与快递公司协商、赔偿标准,以及如何安抚客户情绪。',
type: 'global',
auditStatus: 'approved',
author: '周八',
updatedAt: '2025-05-10',
coverColor: '#f9f0ff',
tags: ['物流', '快递异常', '赔偿'],
relatedProducts: [],
relatedChats: [{ id: 209, name: '物流异常安抚话术' }],
},
{
id: 7,
title: '会员积分兑换规则说明',
content:
'平台会员积分的获取方式(消费、签到、邀请好友)、兑换比例、有效期及使用限制。常见积分问题的排查和处理方式。',
type: 'global',
auditStatus: 'pending',
author: '吴九',
updatedAt: '2025-05-21',
coverColor: '#fff7e6',
tags: ['会员', '积分', '兑换'],
relatedProducts: [],
relatedChats: [{ id: 210, name: '积分查询话术' }],
},
{
id: 8,
title: '爆款商品卖点话术',
content:
'本季度热销TOP10商品的核心卖点提炼、差异化优势描述和场景化推荐话术帮助客服快速向客户介绍商品价值并促成购买决策。',
type: 'product',
auditStatus: 'approved',
author: '郑十',
updatedAt: '2025-05-17',
coverColor: '#f6ffed',
tags: ['爆款', '卖点', '推荐话术'],
relatedProducts: [
{ id: 106, name: '无线蓝牙耳机' },
{ id: 107, name: '智能手表' },
{ id: 108, name: '颈部按摩仪' },
],
relatedChats: [{ id: 211, name: '爆款推荐话术' }, { id: 212, name: '商品对比话术' }],
},
{
id: 9,
title: '投诉升级处理流程',
content:
'当客户投诉进入升级状态(如威胁投诉平台、媒体曝光)时的处置预案:评估风险等级、上报节点、授权范围,以及事后复盘和改进记录要求。',
type: 'global',
auditStatus: 'pending',
author: '张三',
updatedAt: '2025-05-24',
coverColor: '#fff0f6',
tags: ['投诉', '升级处理', '风险'],
relatedProducts: [],
relatedChats: [{ id: 213, name: '升级投诉安抚话术' }],
},
]
export const knowledgeCardsHandlers = [
http.get('/knowledge/cards', async ({ request }) => {
await delay(300)
const url = new URL(request.url)
const type = url.searchParams.get('type')
const auditStatus = url.searchParams.get('auditStatus')
const filtered = mockCards.filter(card => {
const typeMatch = !type || card.type === type
const auditMatch = !auditStatus || card.auditStatus === auditStatus
return typeMatch && auditMatch
})
return HttpResponse.json({
code: '0',
msg: 'ok',
time: Date.now(),
ok: true,
data: filtered,
})
}),
]

View File

@@ -0,0 +1,51 @@
import { http, HttpResponse, delay } from 'msw'
// ---- 私有 mock 数据 ----
const mockUser = {
id: 1,
phone: '13800138000',
nickName: '测试用户',
avatar: '',
}
const MOCK_TOKEN = 'mock-token-dev-only'
// ---- handlers ----
export const userHandlers = [
// 登录
http.post('/auth/login', async ({ request }) => {
await delay(300)
const { phone, password } = await request.json() as { phone: string; password: string }
if (phone === mockUser.phone && password === '123456') {
return HttpResponse.json({
code: '0',
msg: 'ok',
time: Date.now(),
ok: true,
data: { token: MOCK_TOKEN },
})
}
return HttpResponse.json({
code: '401',
msg: '手机号或密码错误',
time: Date.now(),
ok: false,
})
}),
// 获取用户信息
http.get('/user/info', async () => {
await delay(200)
return HttpResponse.json({
code: '0',
msg: 'ok',
time: Date.now(),
ok: true,
data: mockUser,
})
}),
]

View File

@@ -0,0 +1,563 @@
import { useState, useEffect } from 'react'
import {
Card,
Tag,
Typography,
Space,
Segmented,
Flex,
Avatar,
theme,
Drawer,
Divider,
Spin,
Tooltip,
message,
} from 'antd'
import {
CheckCircleOutlined,
ClockCircleOutlined,
GlobalOutlined,
ShoppingOutlined,
MessageOutlined,
CalendarOutlined,
UserOutlined,
CopyOutlined,
ShoppingCartOutlined,
CommentOutlined,
} from '@ant-design/icons'
import {
getKnowledgeCards,
type KnowledgeCard,
type KnowledgeCardType,
type KnowledgeCardAuditStatus,
} from '@/api/knowledgeCards'
const { Text, Paragraph, Title } = Typography
type AuditFilter = 'all' | KnowledgeCardAuditStatus
type TypeFilter = 'all' | KnowledgeCardType
const typeConfig = {
global: { label: '全域知识', icon: <GlobalOutlined />, color: 'blue' },
product: { label: '商品知识', icon: <ShoppingOutlined />, color: 'orange' },
chat: { label: '聊天知识', icon: <MessageOutlined />, color: 'green' },
}
const auditConfig = {
pending: { label: '未审核', icon: <ClockCircleOutlined />, color: 'warning' as const },
approved: { label: '已审核', icon: <CheckCircleOutlined />, color: 'success' as const },
}
// 卡片固定尺寸
const CARD_WIDTH = 280
const CARD_HEIGHT = 220
function KnowledgeCardItem({
card,
onClick,
}: {
card: KnowledgeCard
onClick: (card: KnowledgeCard) => void
}) {
const { token } = theme.useToken()
const type = typeConfig[card.type]
const audit = auditConfig[card.auditStatus]
return (
<Card
hoverable
onClick={() => onClick(card)}
style={{
borderRadius: token.borderRadiusLG,
overflow: 'hidden',
width: CARD_WIDTH,
height: CARD_HEIGHT,
cursor: 'pointer',
flexShrink: 0,
}}
styles={{ body: { padding: 0, height: '100%' } }}
>
{/* 顶部色块装饰 */}
<div
style={{
height: 6,
background:
card.type === 'global'
? token.colorPrimary
: card.type === 'product'
? token.colorWarning
: token.colorSuccess,
flexShrink: 0,
}}
/>
<div
style={{
padding: token.paddingMD,
height: `calc(100% - 6px)`,
display: 'flex',
flexDirection: 'column',
overflow: 'hidden',
boxSizing: 'border-box',
}}
>
{/* 标题最多1行超出省略 */}
<Text
strong
ellipsis
style={{
fontSize: token.fontSizeLG,
display: 'block',
marginBottom: token.marginXS,
color: token.colorText,
flexShrink: 0,
}}
>
{card.title}
</Text>
{/* 正文:固定行数,超出省略,不可展开 */}
<div
style={{
flex: 1,
overflow: 'hidden',
marginBottom: token.marginSM,
}}
>
<Paragraph
type="secondary"
ellipsis={{ rows: 3, expandable: false }}
style={{
marginBottom: 0,
fontSize: token.fontSize,
}}
>
{card.content}
</Paragraph>
</div>
{/* 底部作者信息 */}
<Flex align="center" justify="space-between" style={{ flexShrink: 0, marginBottom: 6 }}>
<Space size={4}>
<Avatar
size={20}
style={{
backgroundColor: token.colorPrimary,
fontSize: 11,
}}
>
{card.author[0]}
</Avatar>
<Text type="secondary" style={{ fontSize: token.fontSizeSM }}>
{card.author} · {card.updatedAt}
</Text>
</Space>
</Flex>
{/* 标签行 */}
<Flex gap={6} style={{ flexShrink: 0 }} wrap>
<Tag
icon={audit.icon}
color={audit.color}
style={{ margin: 0, borderRadius: token.borderRadiusSM }}
>
{audit.label}
</Tag>
<Tag
icon={type.icon}
color={type.color}
style={{ margin: 0, borderRadius: token.borderRadiusSM }}
>
{type.label}
</Tag>
</Flex>
</div>
</Card>
)
}
function KnowledgeCardDetail({
card,
open,
onClose,
}: {
card: KnowledgeCard | null
open: boolean
onClose: () => void
}) {
const { token } = theme.useToken()
const [messageApi, contextHolder] = message.useMessage()
if (!card) return null
const type = typeConfig[card.type]
const audit = auditConfig[card.auditStatus]
const topBarColor =
card.type === 'global'
? token.colorPrimary
: card.type === 'product'
? token.colorWarning
: token.colorSuccess
const handleCopyId = () => {
navigator.clipboard.writeText(String(card.id)).then(() => {
messageApi.success('ID 已复制')
})
}
const sidebarBg = token.colorFillQuaternary
return (
<Drawer
title={null}
placement="right"
width={760}
open={open}
onClose={onClose}
styles={{
header: { display: 'none' },
body: { padding: 0, display: 'flex', flexDirection: 'column' },
}}
>
{contextHolder}
{/* 顶部色块 */}
<div style={{ height: 6, background: topBarColor, flexShrink: 0 }} />
{/* 左右两栏 */}
<div style={{ display: 'flex', flex: 1, overflow: 'hidden' }}>
{/* 左侧:主内容 */}
<div
style={{
flex: 1,
overflow: 'auto',
padding: token.paddingLG,
borderRight: `1px solid ${token.colorBorderSecondary}`,
}}
>
{/* 标题区 */}
<Title level={4} style={{ marginTop: 0, marginBottom: token.marginSM }}>
{card.title}
</Title>
{/* 状态标签 */}
<Flex gap={8} style={{ marginBottom: token.marginMD }}>
<Tag
icon={audit.icon}
color={audit.color}
style={{ borderRadius: token.borderRadiusSM }}
>
{audit.label}
</Tag>
<Tag
icon={type.icon}
color={type.color}
style={{ borderRadius: token.borderRadiusSM }}
>
{type.label}
</Tag>
</Flex>
<Divider style={{ marginTop: 0, marginBottom: token.marginMD }} />
{/* 元信息 */}
<Flex gap={token.marginLG} style={{ marginBottom: token.marginLG }}>
<Space size={6}>
<UserOutlined style={{ color: token.colorTextSecondary }} />
<Text type="secondary" style={{ fontSize: token.fontSizeSM }}>
{card.author}
</Text>
</Space>
<Space size={6}>
<CalendarOutlined style={{ color: token.colorTextSecondary }} />
<Text type="secondary" style={{ fontSize: token.fontSizeSM }}>
{card.updatedAt}
</Text>
</Space>
</Flex>
{/* 正文完整内容 */}
<Paragraph
style={{
fontSize: token.fontSize,
lineHeight: 1.8,
color: token.colorText,
whiteSpace: 'pre-wrap',
}}
>
{card.content}
</Paragraph>
</div>
{/* 右侧:属性面板 */}
<div
style={{
width: 220,
flexShrink: 0,
overflow: 'auto',
background: sidebarBg,
display: 'flex',
flexDirection: 'column',
gap: 0,
}}
>
{/* 基础信息 */}
<div style={{ padding: `${token.paddingMD}px ${token.paddingMD}px ${token.paddingSM}px` }}>
<Text
type="secondary"
style={{
fontSize: token.fontSizeSM,
fontWeight: 600,
textTransform: 'uppercase',
letterSpacing: '0.05em',
display: 'block',
marginBottom: token.marginSM,
}}
>
</Text>
{/* ID 可复制 */}
<div style={{ marginBottom: token.marginSM }}>
<Text type="secondary" style={{ fontSize: token.fontSizeSM, display: 'block', marginBottom: 4 }}>
ID
</Text>
<Flex
align="center"
gap={6}
style={{
background: token.colorBgContainer,
border: `1px solid ${token.colorBorder}`,
borderRadius: token.borderRadiusSM,
padding: `4px ${token.paddingSM}px`,
cursor: 'pointer',
}}
onClick={handleCopyId}
>
<Text style={{ fontSize: token.fontSizeSM, flex: 1, fontFamily: 'monospace' }}>
{card.id}
</Text>
<Tooltip title="复制 ID">
<CopyOutlined style={{ fontSize: 12, color: token.colorTextSecondary }} />
</Tooltip>
</Flex>
</div>
{/* 多选标签 */}
<div>
<Text type="secondary" style={{ fontSize: token.fontSizeSM, display: 'block', marginBottom: 4 }}>
</Text>
{card.tags.length > 0 ? (
<Flex gap={4} wrap>
{card.tags.map(tag => (
<Tag
key={tag}
style={{
margin: 0,
borderRadius: token.borderRadiusSM,
fontSize: token.fontSizeSM,
}}
>
{tag}
</Tag>
))}
</Flex>
) : (
<Text type="secondary" style={{ fontSize: token.fontSizeSM }}>
</Text>
)}
</div>
</div>
<Divider style={{ margin: `${token.marginXS}px 0` }} />
{/* 扩展信息 */}
<div style={{ padding: `${token.paddingSM}px ${token.paddingMD}px ${token.paddingMD}px` }}>
<Text
type="secondary"
style={{
fontSize: token.fontSizeSM,
fontWeight: 600,
textTransform: 'uppercase',
letterSpacing: '0.05em',
display: 'block',
marginBottom: token.marginSM,
}}
>
</Text>
{/* 关联商品 */}
<div style={{ marginBottom: token.marginMD }}>
<Space size={4} style={{ marginBottom: 6 }}>
<ShoppingCartOutlined style={{ fontSize: token.fontSizeSM, color: token.colorTextSecondary }} />
<Text type="secondary" style={{ fontSize: token.fontSizeSM }}>
</Text>
</Space>
{card.relatedProducts.length > 0 ? (
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
{card.relatedProducts.map(p => (
<div
key={p.id}
style={{
background: token.colorBgContainer,
border: `1px solid ${token.colorBorder}`,
borderRadius: token.borderRadiusSM,
padding: `4px ${token.paddingSM}px`,
}}
>
<Text style={{ fontSize: token.fontSizeSM }} ellipsis>
{p.name}
</Text>
</div>
))}
</div>
) : (
<Text type="secondary" style={{ fontSize: token.fontSizeSM }}>
</Text>
)}
</div>
{/* 关联聊天 */}
<div>
<Space size={4} style={{ marginBottom: 6 }}>
<CommentOutlined style={{ fontSize: token.fontSizeSM, color: token.colorTextSecondary }} />
<Text type="secondary" style={{ fontSize: token.fontSizeSM }}>
</Text>
</Space>
{card.relatedChats.length > 0 ? (
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
{card.relatedChats.map(c => (
<div
key={c.id}
style={{
background: token.colorBgContainer,
border: `1px solid ${token.colorBorder}`,
borderRadius: token.borderRadiusSM,
padding: `4px ${token.paddingSM}px`,
}}
>
<Text style={{ fontSize: token.fontSizeSM }} ellipsis>
{c.name}
</Text>
</div>
))}
</div>
) : (
<Text type="secondary" style={{ fontSize: token.fontSizeSM }}>
</Text>
)}
</div>
</div>
</div>
</div>
</Drawer>
)
}
function KnowledgeCards() {
const { token } = theme.useToken()
const [auditFilter, setAuditFilter] = useState<AuditFilter>('all')
const [typeFilter, setTypeFilter] = useState<TypeFilter>('all')
const [cards, setCards] = useState<KnowledgeCard[]>([])
const [loading, setLoading] = useState(false)
const [selectedCard, setSelectedCard] = useState<KnowledgeCard | null>(null)
const [drawerOpen, setDrawerOpen] = useState(false)
useEffect(() => {
const fetchCards = async () => {
setLoading(true)
const params: Record<string, string> = {}
if (auditFilter !== 'all') params.auditStatus = auditFilter
if (typeFilter !== 'all') params.type = typeFilter
const res = await getKnowledgeCards(
params as Parameters<typeof getKnowledgeCards>[0],
)
if (res.ok && res.data) {
setCards(res.data)
}
setLoading(false)
}
fetchCards()
}, [auditFilter, typeFilter])
const handleCardClick = (card: KnowledgeCard) => {
setSelectedCard(card)
setDrawerOpen(true)
}
const handleDrawerClose = () => {
setDrawerOpen(false)
}
return (
<div style={{ padding: token.paddingLG }}>
{/* 筛选栏 */}
<Flex gap={token.marginMD} wrap style={{ marginBottom: token.marginLG }}>
<Segmented
value={auditFilter}
onChange={v => setAuditFilter(v as AuditFilter)}
options={[
{ label: '全部', value: 'all' },
{ label: '未审核', value: 'pending' },
{ label: '已审核', value: 'approved' },
]}
/>
<Segmented
value={typeFilter}
onChange={v => setTypeFilter(v as TypeFilter)}
options={[
{ label: '全部', value: 'all' },
{ label: '全域知识', value: 'global' },
{ label: '商品知识', value: 'product' },
{ label: '聊天知识', value: 'chat' },
]}
/>
</Flex>
{/* 固定大小卡片网格 */}
<Spin spinning={loading}>
{cards.length > 0 ? (
<div
style={{
display: 'flex',
flexWrap: 'wrap',
gap: token.marginMD,
}}
>
{cards.map(card => (
<KnowledgeCardItem key={card.id} card={card} onClick={handleCardClick} />
))}
</div>
) : (
!loading && (
<Flex
justify="center"
align="center"
style={{ height: 300, color: token.colorTextQuaternary, fontSize: token.fontSizeLG }}
>
</Flex>
)
)}
</Spin>
{/* 详情抽屉 */}
<KnowledgeCardDetail
card={selectedCard}
open={drawerOpen}
onClose={handleDrawerClose}
/>
</div>
)
}
export default KnowledgeCards

View File

@@ -1,17 +1,24 @@
import type { ReactNode } from 'react'
import { createBrowserRouter } from 'react-router-dom'
import BasicLayout from '@/layouts/BasicLayout'
import LoginPage from '@/pages/login'
import NotFound from '@/pages/notFound'
import { AuthGuard, GuestGuard } from './AuthGuard'
import { routes } from './routes'
import { routes, type RouteConfig } from './routes'
const layoutChildren = routes
.filter(r => r.path !== '*')
.map(({ path, element }) => ({
path,
element,
...(path === '/' ? { index: true, path: undefined } : {}),
}))
// 递归展开所有路由(包含子路由),扁平化注册到 layoutChildren
function flattenRoutes(list: RouteConfig[]): { path: string; element: ReactNode }[] {
return list.flatMap(r => {
if (r.path === '*') return []
const self = r.path === '/'
? [{ path: r.path, element: r.element, index: true }]
: [{ path: r.path, element: r.element }]
const children = r.children ? flattenRoutes(r.children) : []
return [...self, ...children] as { path: string; element: ReactNode }[]
})
}
const layoutChildren = flattenRoutes(routes)
const router = createBrowserRouter([
{

View File

@@ -1,7 +1,8 @@
import type { ReactNode } from 'react'
import { HomeOutlined } from '@ant-design/icons'
import { HomeOutlined, BookOutlined, FileTextOutlined } from '@ant-design/icons'
import Home from '@/pages/home'
import NotFound from '@/pages/notFound'
import KnowledgeCards from '@/pages/knowledgeBase/cards'
export interface RouteConfig {
/** 路由路径 */
@@ -25,6 +26,20 @@ export const routes: RouteConfig[] = [
icon: <HomeOutlined />,
element: <Home />,
},
{
path: '/knowledge-base',
label: '知识库',
icon: <BookOutlined />,
element: <></>,
children: [
{
path: '/knowledge-base/cards',
label: '知识卡片',
icon: <FileTextOutlined />,
element: <KnowledgeCards />,
},
],
},
{
path: '*',
label: '页面不存在',

View File

@@ -9,7 +9,7 @@ interface UserInfo {
/** 手机号码 */
phone: string
/** 昵称 */
nickname: string
nickName: string
avatar?: string
}
@@ -47,7 +47,12 @@ export const useUserStore = create<UserState>(set => ({
const res = await loginApi(params)
if (res.ok && res.data) {
setToken(res.data.token)
set({ isLoggedIn: true, userInfo: res.data.user })
set({ isLoggedIn: true })
// 登录成功后立即拉取用户信息
const infoRes = await getUserInfo()
if (infoRes.ok && infoRes.data) {
set({ userInfo: infoRes.data })
}
return true
}
return res.msg || '登录失败'

View File

@@ -59,9 +59,7 @@ async function request<T>(
const authHeaders: Record<string, string> = {}
if (!skipAuth) {
const token = getToken()
if (token) {
authHeaders['Authorization'] = `Bearer ${token}`
}
if (token) authHeaders['Authorization'] = `Bearer ${token}`
}
try {