├── .dockerignore ├── .env.local ├── .gitignore ├── .idea ├── .gitignore ├── fastgpt-admin.iml └── modules.xml ├── Dockerfile ├── LICENSE ├── Makefile ├── README.md ├── docker-compose-prod.yml ├── docker-compose-test.yml ├── docker-compose.yml ├── index.html ├── package.json ├── packages └── tushan │ ├── LICENSE │ ├── README.md │ ├── chart.ts │ ├── client │ ├── api │ │ ├── auth │ │ │ ├── const.ts │ │ │ ├── createAuthHTTPClient.ts │ │ │ ├── createAuthProvider.ts │ │ │ ├── index.ts │ │ │ ├── useCheckAuth.ts │ │ │ ├── useLogin.ts │ │ │ ├── useLogout.ts │ │ │ └── usePermissions.ts │ │ ├── consts.ts │ │ ├── defaultDataProvider.ts │ │ ├── defaultExporter.ts │ │ ├── defaultQueryClient.ts │ │ ├── http │ │ │ ├── error.ts │ │ │ ├── index.ts │ │ │ ├── jsonServerProvider.ts │ │ │ ├── request.ts │ │ │ └── utils.ts │ │ ├── index.ts │ │ ├── types.ts │ │ ├── useCreate.ts │ │ ├── useDelete.ts │ │ ├── useDeleteMany.ts │ │ ├── useGetList.ts │ │ ├── useGetMany.ts │ │ ├── useGetOne.ts │ │ ├── useRefreshList.ts │ │ └── useUpdate.ts │ ├── chart.ts │ ├── components │ │ ├── ArcoDesignProvider.tsx │ │ ├── BuiltinRoutes.tsx │ │ ├── ButtonWithConfirm.tsx │ │ ├── Category.tsx │ │ ├── CustomRoute.tsx │ │ ├── FieldTitle.tsx │ │ ├── LoadingView.tsx │ │ ├── Resource.tsx │ │ ├── SubmitButton.tsx │ │ ├── Tushan.tsx │ │ ├── defaults │ │ │ ├── Dashboard.tsx │ │ │ └── LoginPage.tsx │ │ ├── detail │ │ │ ├── DetailForm.tsx │ │ │ └── index.ts │ │ ├── edit │ │ │ ├── EditForm.tsx │ │ │ └── index.ts │ │ ├── fields │ │ │ ├── avatar.tsx │ │ │ ├── boolean.tsx │ │ │ ├── datetime.tsx │ │ │ ├── email.tsx │ │ │ ├── factory.ts │ │ │ ├── image.tsx │ │ │ ├── index.ts │ │ │ ├── json.tsx │ │ │ ├── number.tsx │ │ │ ├── password.tsx │ │ │ ├── reference.tsx │ │ │ ├── select.tsx │ │ │ ├── text.tsx │ │ │ ├── textarea.tsx │ │ │ ├── types.ts │ │ │ └── url.tsx │ │ ├── index.tsx │ │ ├── layout │ │ │ ├── Breadcrumb.tsx │ │ │ ├── Navbar.tsx │ │ │ ├── Sidebar.tsx │ │ │ └── index.tsx │ │ └── list │ │ │ ├── ListFilter.tsx │ │ │ ├── ListTable.tsx │ │ │ ├── ListTableDrawer.tsx │ │ │ ├── actions │ │ │ ├── BatchDeleteAction.tsx │ │ │ ├── DeleteAction.tsx │ │ │ ├── ExportAction.tsx │ │ │ └── RefreshAction.tsx │ │ │ ├── components │ │ │ └── ListTableRow.tsx │ │ │ ├── context.ts │ │ │ ├── index.ts │ │ │ ├── useColumns.tsx │ │ │ └── useListTableDrawer.tsx │ ├── context │ │ ├── record.tsx │ │ ├── resource.tsx │ │ ├── tushan.tsx │ │ └── viewtype.tsx │ ├── hooks │ │ ├── useAsync.ts │ │ ├── useAsyncFn.ts │ │ ├── useAsyncRefresh.ts │ │ ├── useAsyncRequest.ts │ │ ├── useConfigureAdminRouterFromChildren.ts │ │ ├── useDataReady.ts │ │ ├── useDebounce.ts │ │ ├── useDelay.ts │ │ ├── useEditValue.ts │ │ ├── useEvent.ts │ │ ├── useForceUpdate.ts │ │ ├── useInitI18N.ts │ │ ├── useIsMounted.ts │ │ ├── useMountedState.ts │ │ ├── useObjectState.ts │ │ ├── useSafeState.ts │ │ ├── useSendRequest.ts │ │ ├── useUpdateRef.ts │ │ ├── useUrlState.ts │ │ └── useWatch.ts │ ├── i18n │ │ ├── default.ts │ │ ├── index.ts │ │ └── resources │ │ │ ├── en.ts │ │ │ └── zh.ts │ ├── icon.ts │ ├── index.ts │ ├── store │ │ ├── menu.ts │ │ └── user.ts │ ├── types.ts │ └── utils │ │ ├── common.ts │ │ ├── context.ts │ │ ├── createSelector.ts │ │ ├── event.ts │ │ ├── tree.ts │ │ └── validator │ │ ├── email.ts │ │ ├── index.ts │ │ ├── mobile.ts │ │ ├── strong-password.ts │ │ ├── types.ts │ │ └── url.ts │ ├── icon.ts │ ├── index.ts │ ├── package.json │ ├── pnpm-lock.yaml │ └── tsconfig.json ├── pnpm-lock.yaml ├── pnpm-workspace.yaml ├── public └── logo.ico ├── server.js ├── service ├── constant │ ├── constant.js │ └── permisson.js ├── middleware │ └── common.js ├── route │ ├── app.js │ ├── dashboard.js │ ├── kb.js │ ├── system.js │ └── user.js ├── schema │ ├── appSchema.js │ ├── datasetSchema.js │ ├── index.js │ ├── paySchema.js │ ├── systemSchema.js │ ├── teamMemberSchema.js │ ├── teamSchema.js │ └── userSchema.js └── utils │ ├── index.js │ └── winston.js ├── src ├── App.tsx ├── Dashboard.tsx ├── auth.ts ├── fields.ts ├── main.tsx └── vite-env.d.ts ├── tsconfig.json ├── tsconfig.node.json └── vite.config.ts /.dockerignore: -------------------------------------------------------------------------------- 1 | Dockerfile 2 | .dockerignore 3 | node_modules 4 | npm-debug.log 5 | README.md 6 | .git 7 | 8 | .yalc/ 9 | yalc.lock 10 | testApi/ 11 | node_modules -------------------------------------------------------------------------------- /.env.local: -------------------------------------------------------------------------------- 1 | MONGODB_URI=mongodb://myusername:mypassword@127.0.0.1:27017/fastgpt?authSource=admin&directConnection=true 2 | MONGODB_NAME=fastgpt 3 | ADMIN_USER=root 4 | ADMIN_PASS=1234 5 | ADMIN_SECRET=any 6 | PARENT_URL=http://localhost:3000 # FastGpt服务的地址 7 | PARENT_ROOT_KEY=root_key # FastGpt 的rootkey 8 | VITE_PUBLIC_SERVER_URL=http://localhost:3001 # 和server.js一致 9 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | 3 | /logs 4 | -------------------------------------------------------------------------------- /.idea/.gitignore: -------------------------------------------------------------------------------- 1 | # 默认忽略的文件 2 | /shelf/ 3 | /workspace.xml 4 | # 基于编辑器的 HTTP 客户端请求 5 | /httpRequests/ 6 | -------------------------------------------------------------------------------- /.idea/fastgpt-admin.iml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /.idea/modules.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # 使用 node 官方的 Alpine 基础镜像 2 | FROM node:18.17-alpine AS builder 3 | 4 | # 设置 npm 和 pnpm 的镜像源 5 | RUN npm config set registry https://registry.npmmirror.com/ && \ 6 | apk add --no-cache libc6-compat && \ 7 | npm install -g pnpm && \ 8 | pnpm config set registry https://registry.npmmirror.com/ 9 | 10 | # 设置工作目录 11 | WORKDIR /app 12 | 13 | # 设置环境变量 14 | ENV NEXT_TELEMETRY_DISABLED=1 \ 15 | VITE_PUBLIC_SERVER_URL="" 16 | 17 | # 复制项目文件到容器 18 | COPY . . 19 | 20 | # 安装依赖并构建项目 21 | RUN pnpm install && \ 22 | pnpm build 23 | 24 | # 生产阶段 25 | FROM node:18.17-alpine AS runner 26 | 27 | WORKDIR /app 28 | 29 | # 设置环境变量 30 | ENV NODE_ENV=production \ 31 | NEXT_TELEMETRY_DISABLED=1 \ 32 | PORT=3001 33 | 34 | # 安装运行时依赖 35 | RUN apk --no-cache add curl ca-certificates && \ 36 | update-ca-certificates 37 | 38 | # 复制构建阶段生成的文件到生产镜像 39 | COPY --from=builder /app/server.js ./server.js 40 | COPY --from=builder /app/service ./service 41 | COPY --from=builder /app/dist ./dist 42 | COPY --from=builder /app/package.json ./package.json 43 | COPY --from=builder /app/pnpm-lock.yaml ./pnpm-lock.yaml 44 | 45 | # 安装生产依赖 46 | RUN npm install -g pnpm && \ 47 | pnpm config set registry https://registry.npmmirror.com/ && \ 48 | pnpm install --prod && \ 49 | npm remove -g pnpm 50 | 51 | # 设置容器监听端口 52 | EXPOSE 3001 53 | 54 | # 不切换用户,使用 root 用户运行 55 | # USER nextjs 56 | 57 | # 设置容器启动命令 58 | CMD ["node", "server.js"] 59 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Sumingcheng 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | VERSION = v1.2.0 2 | IMAGE_NAME = fastgpt-admin 3 | 4 | # 构建镜像 5 | build: 6 | docker build -t $(IMAGE_NAME):$(VERSION) . 7 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # fastgpt-admin 2 | ## 项目 3 | 4 | 使用[项目做的前端](https://github.com/stakeswky/fastgpt-admin),重构了后台的接口和用户管理部分,可以实现简单的用户创建和管理,本地部署的版本是fastgpt4.8.3,可以进行用户的增删改查,暂时没时间开发团队部分的内容 5 | 6 | ## 本地开发 7 | 8 | 1. 修改 `.env.local`里面的环境变量,连接到本地部署的 mongodb 数据库 9 | 2. `pnpm i` 10 | 3. `pnpm dev` 11 | 4. 打开 `http://localhost:5173/` 访问前端页面 12 | 5. 后端接口运行在http://localhost:3001/ 13 | 14 | ## 部署 15 | 16 | 1. Docker 构建镜像 17 | 1. 运行 make build 18 | 2. 运行 `docker-compose up -d` 19 | 20 | 21 | 2. 部署时候提前修改`docker-compose环境变量 22 | 23 | ``` 24 | MONGODB_URI: "mongodb://myusername:mypassword@127.0.0.1:27017/fastgpt?authSource=admin&directConnection=true" 25 | MONGODB_NAME: "fastgpt" 26 | ADMIN_USER: "root" 27 | ADMIN_PASS: "1234" 28 | ADMIN_SECRET: "fastgpt" 29 | PARENT_URL: "http://127.0.0.1:3000/" # FastGpt服务的地址 30 | PARENT_ROOT_KEY: "root_key" # FastGpt的rootkey 31 | VITE_PUBLIC_SERVER_URL: "http://127.0.0.1:30003" # 和server.js一致 32 | ``` 33 | 34 | ## 贡献 35 | 36 | 欢迎贡献代码,平时比较忙可能没时间更新新内容 37 | -------------------------------------------------------------------------------- /docker-compose-prod.yml: -------------------------------------------------------------------------------- 1 | services: 2 | fastgpt-admin: 3 | image: fastgpt-admin:v1.2.0 4 | build: 5 | context: . 6 | dockerfile: Dockerfile 7 | container_name: fastgpt-admin 8 | ports: 9 | - "30003:3001" 10 | restart: unless-stopped 11 | environment: 12 | MONGODB_URI: "mongodb://myusername:mypassword@172.22.220.89:27017/fastgpt?authSource=admin&directConnection=true" 13 | MONGODB_NAME: "fastgpt" 14 | ADMIN_USER: "root" 15 | ADMIN_PASS: "1234" 16 | ADMIN_SECRET: "fastgpt" 17 | PARENT_URL: "http://172.22.220.89:3000/" # FastGpt服务的地址 18 | PARENT_ROOT_KEY: "root_key" # FastGpt的rootkey 19 | VITE_PUBLIC_SERVER_URL: "http://172.22.220.89:30003" # 和server.js一致 20 | volumes: 21 | - logs:/logs 22 | 23 | volumes: 24 | logs: 25 | 26 | networks: 27 | default: 28 | driver: bridge 29 | -------------------------------------------------------------------------------- /docker-compose-test.yml: -------------------------------------------------------------------------------- 1 | services: 2 | fastgpt-admin: 3 | image: fastgpt-admin:v1.2.0 4 | build: 5 | context: . 6 | dockerfile: Dockerfile 7 | container_name: fastgpt-admin 8 | ports: 9 | - "30003:3001" 10 | restart: unless-stopped 11 | environment: 12 | MONGODB_URI: "mongodb://myusername:mypassword@127.0.0.1:27017/fastgpt?authSource=admin&directConnection=true" 13 | MONGODB_NAME: "fastgpt" 14 | ADMIN_USER: "root" 15 | ADMIN_PASS: "modelspace@123" 16 | ADMIN_SECRET: "fastgpt" 17 | PARENT_URL: "http://127.0.0.1:3000/" # FastGpt服务的地址 18 | PARENT_ROOT_KEY: "root_key" # FastGpt的rootkey 19 | VITE_PUBLIC_SERVER_URL: "http://127.0.0.1:30003" # 和server.js一致 20 | volumes: 21 | - logs:/logs 22 | 23 | volumes: 24 | logs: 25 | 26 | networks: 27 | default: 28 | driver: bridge 29 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | fastgpt-admin: 3 | image: fastgpt-admin:v1.2.0 4 | build: 5 | context: . 6 | dockerfile: Dockerfile 7 | container_name: fastgpt-admin 8 | ports: 9 | - "30003:3001" 10 | restart: unless-stopped 11 | environment: 12 | MONGODB_URI: "mongodb://myusername:mypassword@127.0.0.1:27017/fastgpt?authSource=admin&directConnection=true" 13 | MONGODB_NAME: "fastgpt" 14 | ADMIN_USER: "root" 15 | ADMIN_PASS: "1234" 16 | ADMIN_SECRET: "fastgpt" 17 | PARENT_URL: "http://127.0.0.1:3000/" # FastGpt服务的地址 18 | PARENT_ROOT_KEY: "root_key" # FastGpt的rootkey 19 | VITE_PUBLIC_SERVER_URL: "http://127.0.0.1:30003" # 和server.js一致 20 | volumes: 21 | - logs:/logs 22 | 23 | volumes: 24 | logs: 25 | 26 | networks: 27 | default: 28 | driver: bridge 29 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | FastGPT-Admin 8 | 9 | 10 |
11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "kbgpt-deafult", 3 | "private": true, 4 | "version": "0.0.0", 5 | "type": "module", 6 | "author": "anonymous", 7 | "scripts": { 8 | "dev": "concurrently \"vite\" \"npm run start:api\"", 9 | "build": "tsc && vite build", 10 | "preview": "vite preview", 11 | "start:api": "nodemon server.js" 12 | }, 13 | "dependencies": { 14 | "tushan": "workspace:*", 15 | "@arco-design/web-react": "^2.49.1", 16 | "concurrently": "^8.1.0", 17 | "cors": "^2.8.5", 18 | "dayjs": "^1.11.8", 19 | "dotenv": "^16.1.4", 20 | "express": "^4.18.2", 21 | "jsonwebtoken": "^9.0.0", 22 | "mongoose": "^7.2.2", 23 | "nodemon": "^2.0.22", 24 | "react": "^18.2.0", 25 | "react-admin": "^4.11.0", 26 | "react-dom": "^18.2.0", 27 | "react-i18next": "^12.3.1", 28 | "winston": "^3.13.0", 29 | "winston-daily-rotate-file": "^5.0.0" 30 | }, 31 | "devDependencies": { 32 | "@types/jsonexport": "^3.0.2", 33 | "@types/lodash-es": "^4.17.7", 34 | "@types/node": "^20.2.5", 35 | "@types/react": "^18.0.28", 36 | "@types/react-dom": "^18.0.11", 37 | "@types/react-helmet": "^6.1.6", 38 | "@types/styled-components": "^5.1.26", 39 | "@vitejs/plugin-react": "^3.1.0", 40 | "typescript": "^4.9.2", 41 | "vite": "^4.2.1" 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /packages/tushan/LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 MsgByte 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /packages/tushan/README.md: -------------------------------------------------------------------------------- 1 | UI Design provided by [arco-design](https://arco.design/) 2 | 3 | Inspired by [react-admin](https://github.com/marmelab/react-admin), but modify lots, and interface is fully compatible, you can directly use his ecology. 4 | 5 | For more document, please visit [https://tushan.msgbyte.com/](https://tushan.msgbyte.com/) 6 | 7 | ## Preview 8 | 9 | ![](../../website/static/img/preview/1.png) 10 | ![](../../website/static/img/preview/2.png) 11 | ![](../../website/static/img/preview/3.png) 12 | ![](../../website/static/img/preview/4.png) 13 | -------------------------------------------------------------------------------- /packages/tushan/chart.ts: -------------------------------------------------------------------------------- 1 | export * from './client/chart'; 2 | -------------------------------------------------------------------------------- /packages/tushan/client/api/auth/const.ts: -------------------------------------------------------------------------------- 1 | export const defaultAuthParams = { 2 | loginUrl: '/login', 3 | afterLoginUrl: '/', 4 | }; 5 | 6 | export const defaultAuthStorageKey = 'tushan:auth'; 7 | -------------------------------------------------------------------------------- /packages/tushan/client/api/auth/createAuthHTTPClient.ts: -------------------------------------------------------------------------------- 1 | import { fetchJSON } from '../http'; 2 | import type { Options } from '../http/request'; 3 | import { defaultAuthStorageKey } from './const'; 4 | 5 | export function createAuthHttpClient( 6 | authStorageKey: string = defaultAuthStorageKey 7 | ) { 8 | const httpClient: typeof fetchJSON = (url: string, options: Options = {}) => { 9 | try { 10 | if (!options.headers) { 11 | options.headers = new Headers({ Accept: 'application/json' }); 12 | } 13 | const { token } = JSON.parse( 14 | window.localStorage.getItem(authStorageKey) ?? '{}' 15 | ); 16 | (options.headers as Headers).set('Authorization', `Bearer ${token}`); 17 | 18 | return fetchJSON(url, options); 19 | } catch (err) { 20 | return Promise.reject(); 21 | } 22 | }; 23 | 24 | return httpClient; 25 | } 26 | -------------------------------------------------------------------------------- /packages/tushan/client/api/auth/createAuthProvider.ts: -------------------------------------------------------------------------------- 1 | import type { AuthProvider } from '../types'; 2 | import { defaultAuthStorageKey } from './const'; 3 | 4 | interface CreateAuthProviderOptions { 5 | /** 6 | * key which store in localStorage 7 | * 8 | * @default "tushan:auth" 9 | */ 10 | authStorageKey?: string; 11 | 12 | /** 13 | * Login url 14 | * 15 | * @example "/api/login" 16 | */ 17 | loginUrl: string; 18 | } 19 | 20 | export function createAuthProvider( 21 | options: CreateAuthProviderOptions 22 | ): AuthProvider { 23 | const { authStorageKey = defaultAuthStorageKey, loginUrl } = options; 24 | 25 | const authProvider: AuthProvider = { 26 | login: async ({ username, password }) => { 27 | const request = new Request(loginUrl, { 28 | method: 'POST', 29 | body: JSON.stringify({ username, password }), 30 | headers: new Headers({ 'Content-Type': 'application/json' }), 31 | }); 32 | 33 | try { 34 | const response = await fetch(request); 35 | const auth = await response.json(); 36 | localStorage.setItem(authStorageKey, JSON.stringify(auth)); 37 | } catch { 38 | throw new Error('Login Failed'); 39 | } 40 | }, 41 | logout: () => { 42 | localStorage.removeItem(authStorageKey); 43 | return Promise.resolve(); 44 | }, 45 | checkAuth: () => { 46 | const auth = localStorage.getItem(authStorageKey); 47 | if (auth) { 48 | try { 49 | const obj = JSON.parse(auth); 50 | if (obj.expiredAt && Date.now() < obj.expiredAt) { 51 | return Promise.resolve(); 52 | } 53 | } catch (err) {} 54 | } 55 | 56 | return Promise.reject(); 57 | }, 58 | checkError: (error) => { 59 | const status = error.status; 60 | if (status === 401 || status === 403) { 61 | localStorage.removeItem(authStorageKey); 62 | return Promise.reject(); 63 | } 64 | 65 | // other error code (404, 500, etc): no need to log out 66 | return Promise.resolve(); 67 | }, 68 | getIdentity: () => { 69 | const { username } = JSON.parse( 70 | localStorage.getItem(authStorageKey) ?? '{}' 71 | ); 72 | if (!username) { 73 | return Promise.reject(); 74 | } 75 | 76 | return Promise.resolve({ 77 | id: username, 78 | fullName: username, 79 | }); 80 | }, 81 | getPermissions: () => Promise.resolve(''), 82 | }; 83 | 84 | return authProvider; 85 | } 86 | -------------------------------------------------------------------------------- /packages/tushan/client/api/auth/index.ts: -------------------------------------------------------------------------------- 1 | export * from './useCheckAuth'; 2 | export * from './const'; 3 | export * from './createAuthProvider'; 4 | export * from './createAuthHTTPClient'; 5 | export * from './useLogin'; 6 | export * from './useLogout'; 7 | export * from './usePermissions'; 8 | -------------------------------------------------------------------------------- /packages/tushan/client/api/auth/useCheckAuth.ts: -------------------------------------------------------------------------------- 1 | import { useLogout } from './useLogout'; 2 | import { useTushanContext } from '../../context/tushan'; 3 | import { Message } from '@arco-design/web-react'; 4 | import { defaultAuthParams } from './const'; 5 | import { useUserStore } from '../../store/user'; 6 | import { useEvent } from '../../hooks/useEvent'; 7 | 8 | export const useCheckAuth = (): CheckAuth => { 9 | const { authProvider } = useTushanContext(); 10 | const logout = useLogout(); 11 | const loginUrl = defaultAuthParams.loginUrl; 12 | 13 | const checkAuth = useEvent( 14 | (params: any = {}, logoutOnFailure = true, redirectTo = loginUrl) => 15 | authProvider! 16 | .checkAuth(params) 17 | .then(() => { 18 | authProvider!.getIdentity?.().then((userIdentity) => { 19 | useUserStore.setState({ 20 | userIdentity, 21 | isLogin: true, 22 | }); 23 | }); 24 | }) 25 | .catch((error) => { 26 | if (logoutOnFailure) { 27 | logout( 28 | {}, 29 | error && error.redirectTo != null ? error.redirectTo : redirectTo 30 | ); 31 | 32 | // Message.error('Please login to continue'); 33 | } 34 | 35 | throw error; 36 | }) 37 | ); 38 | 39 | return authProvider ? checkAuth : checkAuthWithoutAuthProvider; 40 | }; 41 | 42 | const checkAuthWithoutAuthProvider = () => Promise.resolve(); 43 | 44 | /** 45 | * Check if the current user is authenticated by calling authProvider.checkAuth(). 46 | * Logs the user out on failure. 47 | * 48 | * @param {Object} params The parameters to pass to the authProvider 49 | * @param {boolean} logoutOnFailure Whether the user should be logged out if the authProvider fails to authenticate them. True by default. 50 | * @param {string} redirectTo The login form url. Defaults to '/login' 51 | * @param {boolean} disableNotification Avoid showing a notification after the user is logged out. false by default. 52 | * 53 | * @return {Promise} Resolved to the authProvider response if the user passes the check, or rejected with an error otherwise 54 | */ 55 | export type CheckAuth = ( 56 | params?: any, 57 | logoutOnFailure?: boolean, 58 | redirectTo?: string 59 | ) => Promise; 60 | -------------------------------------------------------------------------------- /packages/tushan/client/api/auth/useLogin.ts: -------------------------------------------------------------------------------- 1 | import { Message } from '@arco-design/web-react'; 2 | import { useCallback } from 'react'; 3 | import { useLocation, useNavigate } from 'react-router-dom'; 4 | import { useTushanContext } from '../../context/tushan'; 5 | import { useUserStore } from '../../store/user'; 6 | import { createSelector } from '../../utils/createSelector'; 7 | import { defaultAuthParams } from './const'; 8 | 9 | const useLogin = (): Login => { 10 | const { authProvider } = useTushanContext(); 11 | const location = useLocation(); 12 | const locationState = location.state as any; 13 | const navigate = useNavigate(); 14 | const nextPathName = locationState && locationState.nextPathname; 15 | const nextSearch = locationState && locationState.nextSearch; 16 | const afterLoginUrl = defaultAuthParams.afterLoginUrl; 17 | const { setIsLogin } = useUserStore(createSelector('setIsLogin')); 18 | 19 | const login = useCallback( 20 | (params: any = {}, pathName?: string) => 21 | authProvider!.login(params).then((ret: any) => { 22 | console.log("Login response:", ret); 23 | Message.clear(); 24 | if (ret && ret.hasOwnProperty('redirectTo')) { 25 | console.log("Redirecting to:", ret.redirectTo); 26 | if (ret) { 27 | navigate(ret.redirectTo); 28 | } 29 | } else { 30 | const redirectUrl = pathName 31 | ? pathName 32 | : nextPathName + nextSearch || afterLoginUrl; 33 | console.log("Default redirecting to:", redirectUrl); 34 | navigate(redirectUrl); 35 | } 36 | 37 | setIsLogin(true); 38 | return ret; 39 | }), 40 | [authProvider, navigate, nextPathName, nextSearch, afterLoginUrl] 41 | ); 42 | 43 | const loginWithoutProvider = useCallback(() => { 44 | Message.clear(); 45 | navigate(afterLoginUrl); 46 | return Promise.resolve(); 47 | }, [navigate, afterLoginUrl]); 48 | 49 | return authProvider ? login : loginWithoutProvider; 50 | }; 51 | 52 | /** 53 | * Log a user in by calling the authProvider.login() method 54 | * 55 | * @param {Object} params Login parameters to pass to the authProvider. May contain username/email, password, etc 56 | * @param {string} pathName The path to redirect to after login. By default, redirects to the home page, or to the last page visited after disconnection. 57 | * 58 | * @return {Promise} The authProvider response 59 | */ 60 | type Login = (params: any, pathName?: string) => Promise; 61 | 62 | export default useLogin; 63 | -------------------------------------------------------------------------------- /packages/tushan/client/api/auth/useLogout.ts: -------------------------------------------------------------------------------- 1 | import { useCallback, useEffect, useRef } from 'react'; 2 | import { useLocation, useNavigate, Path } from 'react-router-dom'; 3 | import { useQueryClient } from '@tanstack/react-query'; 4 | import { useTushanContext } from '../../context/tushan'; 5 | import { defaultAuthParams } from './const'; 6 | import { useUserStore } from '../../store/user'; 7 | import { createSelector } from '../../utils/createSelector'; 8 | 9 | export const useLogout = (): Logout => { 10 | const { authProvider } = useTushanContext(); 11 | const queryClient = useQueryClient(); 12 | // const resetStore = useResetStore(); 13 | const navigate = useNavigate(); 14 | // useNavigate forces rerenders on every navigation, even if we don't use the result 15 | // see https://github.com/remix-run/react-router/issues/7634 16 | // so we use a ref to bail out of rerenders when we don't need to 17 | const navigateRef = useRef(navigate); 18 | const location = useLocation(); 19 | const locationRef = useRef(location); 20 | const loginUrl = defaultAuthParams.loginUrl; 21 | const { setIsLogin } = useUserStore(createSelector('setIsLogin')); 22 | 23 | /* 24 | * We need the current location to pass in the router state 25 | * so that the login hook knows where to redirect to as next route after login. 26 | * 27 | * But if we used the location from useLocation as a dependency of the logout 28 | * function, it would be rebuilt each time the user changes location. 29 | * Consequently, that would force a rerender of all components using this hook 30 | * upon navigation (CoreAdminRouter for example). 31 | * 32 | * To avoid that, we store the location in a ref. 33 | */ 34 | useEffect(() => { 35 | locationRef.current = location; 36 | navigateRef.current = navigate; 37 | }, [location, navigate]); 38 | 39 | const logout: Logout = useCallback( 40 | ( 41 | params = {}, 42 | redirectTo = loginUrl, 43 | redirectToCurrentLocationAfterLogin = true 44 | ) => 45 | authProvider!.logout(params).then((redirectToFromProvider) => { 46 | if (redirectToFromProvider === false || redirectTo === false) { 47 | queryClient.clear(); 48 | // do not redirect 49 | return; 50 | } 51 | 52 | const finalRedirectTo = redirectToFromProvider || redirectTo; 53 | 54 | if (finalRedirectTo?.startsWith('http')) { 55 | // absolute link (e.g. https://my.oidc.server/login) 56 | queryClient.clear(); 57 | window.location.href = finalRedirectTo; 58 | return finalRedirectTo; 59 | } 60 | 61 | // redirectTo is an internal location that may contain a query string, e.g. '/login?foo=bar' 62 | // we must split it to pass a structured location to navigate() 63 | const redirectToParts = finalRedirectTo.split('?'); 64 | const newLocation: Partial = { 65 | pathname: redirectToParts[0], 66 | }; 67 | let newLocationOptions = {}; 68 | 69 | if ( 70 | redirectToCurrentLocationAfterLogin && 71 | locationRef.current && 72 | locationRef.current.pathname 73 | ) { 74 | newLocationOptions = { 75 | state: { 76 | nextPathname: locationRef.current.pathname, 77 | nextSearch: locationRef.current.search, 78 | }, 79 | }; 80 | } 81 | if (redirectToParts[1]) { 82 | newLocation.search = redirectToParts[1]; 83 | } 84 | navigateRef.current(newLocation, newLocationOptions); 85 | queryClient.clear(); 86 | setIsLogin(false); 87 | 88 | return redirectToFromProvider; 89 | }), 90 | [authProvider, loginUrl, queryClient] 91 | ); 92 | 93 | const logoutWithoutProvider = useCallback(() => { 94 | navigate( 95 | { 96 | pathname: loginUrl, 97 | }, 98 | { 99 | state: { 100 | nextPathname: location && location.pathname, 101 | }, 102 | } 103 | ); 104 | queryClient.clear(); 105 | return Promise.resolve(); 106 | }, [location, navigate, loginUrl, queryClient]); 107 | 108 | return authProvider ? logout : logoutWithoutProvider; 109 | }; 110 | 111 | /** 112 | * Log the current user out by calling the authProvider.logout() method, 113 | * and redirect them to the login screen. 114 | * 115 | * @param {Object} params The parameters to pass to the authProvider 116 | * @param {string} redirectTo The path name to redirect the user to (optional, defaults to login) 117 | * @param {boolean} redirectToCurrentLocationAfterLogin Whether the button shall record the current location to redirect to it after login. true by default. 118 | * 119 | * @return {Promise} The authProvider response 120 | */ 121 | type Logout = ( 122 | params?: any, 123 | redirectTo?: string | false, 124 | redirectToCurrentLocationAfterLogin?: boolean 125 | ) => Promise; 126 | -------------------------------------------------------------------------------- /packages/tushan/client/api/auth/usePermissions.ts: -------------------------------------------------------------------------------- 1 | import { useQuery, UseQueryOptions } from '@tanstack/react-query'; 2 | import { useMemo } from 'react'; 3 | import { useTushanContext } from '../../context/tushan'; 4 | import { useDataReady } from '../../hooks/useDataReady'; 5 | import { useUserStore } from '../../store/user'; 6 | import { createSelector } from '../../utils/createSelector'; 7 | 8 | export function usePermissions( 9 | params = {}, 10 | queryParams: UseQueryOptions = { 11 | staleTime: 5 * 60 * 1000, 12 | } 13 | ) { 14 | const { authProvider } = useTushanContext(); 15 | const { isLogin } = useUserStore(createSelector('isLogin')); 16 | 17 | const result = useQuery( 18 | ['auth', 'getPermissions', JSON.stringify(params)], 19 | async () => { 20 | if (authProvider) { 21 | try { 22 | const permission = await authProvider.getPermissions(params); 23 | return permission; 24 | } catch (err) { 25 | return null; 26 | } 27 | } 28 | 29 | return null; 30 | }, 31 | queryParams 32 | ); 33 | 34 | useDataReady( 35 | () => isLogin === true, 36 | () => { 37 | if (result.data === null) { 38 | // retry when loaded 39 | result.refetch(); 40 | } 41 | } 42 | ); 43 | 44 | return useMemo( 45 | () => ({ 46 | permissions: result.data, 47 | isReady: isLogin && !result.isLoading, 48 | error: result.error, 49 | refetch: result.refetch, 50 | }), 51 | [result] 52 | ); 53 | } 54 | -------------------------------------------------------------------------------- /packages/tushan/client/api/consts.ts: -------------------------------------------------------------------------------- 1 | import type { PaginationPayload, SortPayload } from './types'; 2 | 3 | export const defaultSort: SortPayload = { field: 'id', order: 'DESC' }; 4 | 5 | export const defaultFilter: Record = {}; 6 | 7 | export const defaultPagination: PaginationPayload = { 8 | page: 1, 9 | perPage: 20, 10 | }; 11 | -------------------------------------------------------------------------------- /packages/tushan/client/api/defaultDataProvider.ts: -------------------------------------------------------------------------------- 1 | import type { DataProvider } from './types'; 2 | 3 | export const defaultDataProvider: DataProvider = { 4 | create: () => Promise.resolve({ data: null } as any), // avoids adding a context in tests 5 | delete: () => Promise.resolve({ data: null } as any), // avoids adding a context in tests 6 | deleteMany: () => Promise.resolve({ data: [] }), // avoids adding a context in tests 7 | getList: () => Promise.resolve({ data: [], total: 0 }), // avoids adding a context in tests 8 | getMany: () => Promise.resolve({ data: [] }), // avoids adding a context in tests 9 | getManyReference: () => Promise.resolve({ data: [], total: 0 }), // avoids adding a context in tests 10 | getOne: () => Promise.resolve({ data: null } as any), // avoids adding a context in tests 11 | update: () => Promise.resolve({ data: null } as any), // avoids adding a context in tests 12 | updateMany: () => Promise.resolve({ data: [] }), // avoids adding a context in tests 13 | }; 14 | -------------------------------------------------------------------------------- /packages/tushan/client/api/defaultExporter.ts: -------------------------------------------------------------------------------- 1 | import type { Exporter } from './types'; 2 | import jsonExport from 'jsonexport/dist'; 3 | import { Message } from '@arco-design/web-react'; 4 | 5 | export const defaultExporter: Exporter = (data, _, __, resource) => { 6 | jsonExport(data, (err: Error, csv: string) => { 7 | if (err) { 8 | Message.error(String(err)); 9 | return; 10 | } 11 | 12 | downloadCSV(csv, resource); 13 | }); 14 | }; 15 | 16 | function downloadCSV(csv: string, filename: string): void { 17 | const fakeLink = document.createElement('a'); 18 | fakeLink.style.display = 'none'; 19 | document.body.appendChild(fakeLink); 20 | const blob = new Blob([csv], { type: 'text/csv;charset=utf-8' }); 21 | // @ts-ignore 22 | if (window.navigator && window.navigator.msSaveOrOpenBlob) { 23 | // Manage IE11+ & Edge 24 | // @ts-ignore 25 | window.navigator.msSaveOrOpenBlob(blob, `${filename}.csv`); 26 | } else { 27 | fakeLink.setAttribute('href', URL.createObjectURL(blob)); 28 | fakeLink.setAttribute('download', `${filename}.csv`); 29 | fakeLink.click(); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /packages/tushan/client/api/defaultQueryClient.ts: -------------------------------------------------------------------------------- 1 | import { QueryClient } from '@tanstack/react-query'; 2 | 3 | export const defaultQueryClient = new QueryClient(); 4 | -------------------------------------------------------------------------------- /packages/tushan/client/api/http/error.ts: -------------------------------------------------------------------------------- 1 | export class HttpError extends Error { 2 | constructor( 3 | public readonly message: string, 4 | public readonly status: number, 5 | public readonly body = null 6 | ) { 7 | super(message); 8 | Object.setPrototypeOf(this, HttpError.prototype); 9 | this.name = this.constructor.name; 10 | 11 | if (typeof Error.captureStackTrace === 'function') { 12 | Error.captureStackTrace(this, this.constructor); 13 | } else { 14 | this.stack = new Error(message).stack; 15 | } 16 | this.stack = new Error().stack; 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /packages/tushan/client/api/http/index.ts: -------------------------------------------------------------------------------- 1 | export * from './jsonServerProvider'; 2 | export * from './error'; 3 | export { fetchJSON } from './request'; 4 | export type { HTTPClient } from './request'; 5 | -------------------------------------------------------------------------------- /packages/tushan/client/api/http/jsonServerProvider.ts: -------------------------------------------------------------------------------- 1 | import type { DataProvider } from '../types'; 2 | import { fetchJSON, HTTPClient } from './request'; 3 | import qs from 'qs'; 4 | import { flattenObject } from './utils'; 5 | 6 | /** 7 | * Fork from `ra-data-json-server` because we dont need `ra-core` 8 | */ 9 | 10 | /** 11 | * Maps tushan queries to a json-server powered REST API 12 | * 13 | * @see https://github.com/typicode/json-server 14 | * 15 | * @example 16 | * 17 | * getList => GET http://my.api.url/posts?_sort=title&_order=ASC&_start=0&_end=24 18 | * getOne => GET http://my.api.url/posts/123 19 | * getManyReference => GET http://my.api.url/posts?author_id=345 20 | * getMany => GET http://my.api.url/posts?id=123&id=456&id=789 21 | * create => POST http://my.api.url/posts/123 22 | * update => PUT http://my.api.url/posts/123 23 | * updateMany => PUT http://my.api.url/posts/123, PUT http://my.api.url/posts/456, PUT http://my.api.url/posts/789 24 | * delete => DELETE http://my.api.url/posts/123 25 | * 26 | * @example 27 | * 28 | * import * as React from "react"; 29 | * import { Tushan, jsonServerProvider } from '@tushan'; 30 | * 31 | * const App = () => ( 32 | * 33 | * ... 34 | * 35 | * ); 36 | * 37 | * export default App; 38 | */ 39 | export function jsonServerProvider( 40 | apiUrl: string, 41 | httpClient: HTTPClient = fetchJSON 42 | ): DataProvider { 43 | return { 44 | getList: (resource, params) => { 45 | const { page, perPage } = params.pagination; 46 | const { field, order } = params.sort; 47 | const query = { 48 | ...flattenObject(params.filter), 49 | _sort: field, 50 | _order: order, 51 | _start: (page - 1) * perPage, 52 | _end: page * perPage, 53 | }; 54 | const url = `${apiUrl}/${resource}?${qs.stringify(query)}`; 55 | 56 | return httpClient(url).then(({ headers, json }) => { 57 | if (!headers.has('x-total-count')) { 58 | throw new Error( 59 | 'The X-Total-Count header is missing in the HTTP Response. The jsonServer Data Provider expects responses for lists of resources to contain this header with the total number of results to build the pagination. If you are using CORS, did you declare X-Total-Count in the Access-Control-Expose-Headers header?' 60 | ); 61 | } 62 | 63 | return { 64 | data: json, 65 | total: parseInt( 66 | headers.get('x-total-count')?.split('/').pop() || '0', 67 | 10 68 | ), 69 | }; 70 | }); 71 | }, 72 | 73 | getOne: (resource, params) => 74 | httpClient(`${apiUrl}/${resource}/${params.id}`).then(({ json }) => ({ 75 | data: json, 76 | })), 77 | 78 | getMany: (resource, params) => { 79 | const query = { 80 | id: params.ids, 81 | }; 82 | const url = `${apiUrl}/${resource}?${qs.stringify(query)}`; 83 | return httpClient(url).then(({ json }) => ({ data: json })); 84 | }, 85 | 86 | getManyReference: (resource, params) => { 87 | const { page, perPage } = params.pagination; 88 | const { field, order } = params.sort; 89 | const query = { 90 | ...flattenObject(params.filter), 91 | [params.target]: params.id, 92 | _sort: field, 93 | _order: order, 94 | _start: (page - 1) * perPage, 95 | _end: page * perPage, 96 | }; 97 | const url = `${apiUrl}/${resource}?${qs.stringify(query)}`; 98 | 99 | return httpClient(url).then(({ headers, json }) => { 100 | if (!headers.has('x-total-count')) { 101 | throw new Error( 102 | 'The X-Total-Count header is missing in the HTTP Response. The jsonServer Data Provider expects responses for lists of resources to contain this header with the total number of results to build the pagination. If you are using CORS, did you declare X-Total-Count in the Access-Control-Expose-Headers header?' 103 | ); 104 | } 105 | return { 106 | data: json, 107 | total: parseInt( 108 | headers.get('x-total-count')?.split('/').pop() || '0', 109 | 10 110 | ), 111 | }; 112 | }); 113 | }, 114 | 115 | update: (resource, params) => 116 | httpClient(`${apiUrl}/${resource}/${params.id}`, { 117 | method: 'PUT', 118 | body: JSON.stringify(params.data), 119 | }).then(({ json }) => ({ data: json })), 120 | 121 | // json-server doesn't handle filters on UPDATE route, so we fallback to calling UPDATE n times instead 122 | updateMany: (resource, params) => 123 | Promise.all( 124 | params.ids.map((id) => 125 | httpClient(`${apiUrl}/${resource}/${id}`, { 126 | method: 'PUT', 127 | body: JSON.stringify(params.data), 128 | }) 129 | ) 130 | ).then((responses) => ({ data: responses.map(({ json }) => json.id) })), 131 | 132 | create: (resource, params) => 133 | httpClient(`${apiUrl}/${resource}`, { 134 | method: 'POST', 135 | body: JSON.stringify(params.data), 136 | }).then(({ json }) => ({ 137 | data: { ...params.data, id: json.id }, 138 | })), 139 | 140 | delete: (resource, params) => 141 | httpClient(`${apiUrl}/${resource}/${params.id}`, { 142 | method: 'DELETE', 143 | }).then(({ json }) => ({ data: json })), 144 | 145 | // json-server doesn't handle filters on DELETE route, so we fallback to calling DELETE n times instead 146 | deleteMany: (resource, params) => 147 | Promise.all( 148 | params.ids.map((id) => 149 | httpClient(`${apiUrl}/${resource}/${id}`, { 150 | method: 'DELETE', 151 | }) 152 | ) 153 | ).then((responses) => ({ data: responses.map(({ json }) => json.id) })), 154 | }; 155 | } 156 | -------------------------------------------------------------------------------- /packages/tushan/client/api/http/request.ts: -------------------------------------------------------------------------------- 1 | import { HttpError } from './error'; 2 | 3 | export interface Options extends RequestInit { 4 | user?: { 5 | authenticated?: boolean; 6 | token?: string; 7 | }; 8 | } 9 | 10 | export function createHeadersFromOptions(options: Options): Headers { 11 | const requestHeaders = (options.headers || 12 | new Headers({ 13 | Accept: 'application/json', 14 | })) as Headers; 15 | if ( 16 | !requestHeaders.has('Content-Type') && 17 | !(options && (!options.method || options.method === 'GET')) && 18 | !(options && options.body && options.body instanceof FormData) 19 | ) { 20 | requestHeaders.set('Content-Type', 'application/json'); 21 | } 22 | 23 | if (options.user && options.user.authenticated && options.user.token) { 24 | requestHeaders.set('Authorization', options.user.token); 25 | } 26 | 27 | return requestHeaders; 28 | } 29 | 30 | export async function fetchJSON(url: string, options: Options = {}) { 31 | const requestHeaders = createHeadersFromOptions(options); 32 | 33 | const response = await fetch(url, { ...options, headers: requestHeaders }); 34 | const text = await response.text(); 35 | 36 | const { status, statusText, headers, body } = { 37 | status: response.status, 38 | statusText: response.statusText, 39 | headers: response.headers, 40 | body: text, 41 | }; 42 | 43 | let json; 44 | try { 45 | json = JSON.parse(body); 46 | } catch (e) {} 47 | 48 | if (status < 200 || status >= 300) { 49 | return Promise.reject( 50 | new HttpError((json && json.message) || statusText, status, json) 51 | ); 52 | } 53 | return await Promise.resolve({ status, headers, body, json }); 54 | } 55 | 56 | export type HTTPClient = typeof fetchJSON; 57 | -------------------------------------------------------------------------------- /packages/tushan/client/api/http/utils.ts: -------------------------------------------------------------------------------- 1 | function isValidObject(value: any): value is object { 2 | if (!value) { 3 | return false; 4 | } 5 | 6 | const isArray = Array.isArray(value); 7 | const isBuffer = typeof Buffer !== 'undefined' && Buffer.isBuffer(value); 8 | const isObject = Object.prototype.toString.call(value) === '[object Object]'; 9 | const hasKeys = !!Object.keys(value).length; 10 | 11 | return !isArray && !isBuffer && isObject && hasKeys; 12 | } 13 | 14 | /** 15 | * {"foo": {"bar": 1}} => {"foo.bar": 1} 16 | */ 17 | export function flattenObject(value: any, path: string[] = []): any { 18 | if (isValidObject(value)) { 19 | return Object.assign( 20 | {}, 21 | ...Object.keys(value).map((key) => 22 | flattenObject((value as any)[key], path.concat([key])) 23 | ) 24 | ); 25 | } else { 26 | return path.length 27 | ? { 28 | [path.join('.')]: value, 29 | } 30 | : value; 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /packages/tushan/client/api/index.ts: -------------------------------------------------------------------------------- 1 | export * from './types'; 2 | export * from './defaultDataProvider'; 3 | export * from './defaultQueryClient'; 4 | export * from './auth'; 5 | export * from './http'; 6 | export * from './useGetList'; 7 | export * from './useGetOne'; 8 | export * from './useGetMany'; 9 | export * from './useCreate'; 10 | export * from './useUpdate'; 11 | export * from './useDelete'; 12 | export * from './useDeleteMany'; 13 | export * from './useRefreshList'; 14 | -------------------------------------------------------------------------------- /packages/tushan/client/api/types.ts: -------------------------------------------------------------------------------- 1 | export type Identifier = string | number; 2 | 3 | export interface BasicRecord { 4 | id: Identifier; 5 | [key: string]: any; 6 | } 7 | 8 | export interface SortPayload { 9 | field: string; 10 | order: string; 11 | } 12 | export interface FilterPayload { 13 | [k: string]: any; 14 | } 15 | 16 | export interface PaginationPayload { 17 | page: number; 18 | perPage: number; 19 | } 20 | 21 | export type DataProvider = { 22 | getList: ( 23 | resource: ResourceType, 24 | params: GetListParams 25 | ) => Promise>; 26 | 27 | getOne: ( 28 | resource: ResourceType, 29 | params: GetOneParams 30 | ) => Promise>; 31 | 32 | getMany: ( 33 | resource: ResourceType, 34 | params: GetManyParams 35 | ) => Promise>; 36 | 37 | getManyReference: ( 38 | resource: ResourceType, 39 | params: GetManyReferenceParams 40 | ) => Promise>; 41 | 42 | update: ( 43 | resource: ResourceType, 44 | params: UpdateParams 45 | ) => Promise>; 46 | 47 | updateMany: ( 48 | resource: ResourceType, 49 | params: UpdateManyParams 50 | ) => Promise>; 51 | 52 | create: ( 53 | resource: ResourceType, 54 | params: CreateParams 55 | ) => Promise>; 56 | 57 | delete: ( 58 | resource: ResourceType, 59 | params: DeleteParams 60 | ) => Promise>; 61 | 62 | deleteMany: ( 63 | resource: ResourceType, 64 | params: DeleteManyParams 65 | ) => Promise>; 66 | 67 | [key: string]: any; 68 | }; 69 | 70 | export interface GetListParams { 71 | pagination: PaginationPayload; 72 | sort: SortPayload; 73 | filter: Record; 74 | meta?: any; 75 | } 76 | 77 | export interface GetListResult { 78 | data: RecordType[]; 79 | total?: number; 80 | pageInfo?: { 81 | hasNextPage?: boolean; 82 | hasPreviousPage?: boolean; 83 | }; 84 | } 85 | 86 | export interface SortPayload { 87 | field: string; 88 | order: string; 89 | } 90 | 91 | export interface GetOneParams { 92 | id: RecordType['id']; 93 | meta?: any; 94 | } 95 | 96 | export interface GetOneResult { 97 | data: RecordType; 98 | } 99 | 100 | export interface GetManyParams { 101 | ids: Identifier[]; 102 | meta?: any; 103 | } 104 | 105 | export interface GetManyResult { 106 | data: RecordType[]; 107 | } 108 | 109 | export interface GetManyReferenceParams { 110 | target: string; 111 | id: Identifier; 112 | pagination: PaginationPayload; 113 | sort: SortPayload; 114 | filter: any; 115 | meta?: any; 116 | } 117 | 118 | export interface GetManyReferenceResult { 119 | data: RecordType[]; 120 | total?: number; 121 | pageInfo?: { 122 | hasNextPage?: boolean; 123 | hasPreviousPage?: boolean; 124 | }; 125 | } 126 | 127 | export interface UpdateParams { 128 | id: Identifier; 129 | data: Partial; 130 | previousData: T; 131 | meta?: any; 132 | } 133 | 134 | export interface UpdateResult { 135 | data: RecordType; 136 | } 137 | 138 | export interface UpdateManyParams { 139 | ids: Identifier[]; 140 | data: T; 141 | meta?: any; 142 | } 143 | 144 | export interface UpdateManyResult { 145 | data?: RecordType['id'][]; 146 | } 147 | 148 | export interface CreateParams { 149 | data: T; 150 | meta?: any; 151 | } 152 | 153 | export interface CreateResult { 154 | data: RecordType; 155 | } 156 | 157 | export interface DeleteParams { 158 | id: RecordType['id']; 159 | previousData?: RecordType; 160 | meta?: any; 161 | } 162 | 163 | export interface DeleteResult { 164 | data: RecordType; 165 | } 166 | 167 | export interface DeleteManyParams { 168 | ids: RecordType['id'][]; 169 | meta?: any; 170 | } 171 | 172 | export interface DeleteManyResult { 173 | data?: RecordType['id'][]; 174 | } 175 | 176 | /** 177 | * authProvider types 178 | */ 179 | export interface AuthProvider { 180 | login: ( 181 | params: any 182 | ) => Promise<{ redirectTo?: string | boolean } | void | any>; 183 | logout: (params: any) => Promise; 184 | checkAuth: (params: any) => Promise; 185 | checkError: (error: any) => Promise; 186 | getIdentity?: () => Promise; 187 | getPermissions: (params: any) => Promise; 188 | handleCallback?: () => Promise; 189 | } 190 | 191 | export interface UserIdentity { 192 | id: Identifier; 193 | fullName?: string; 194 | avatar?: string; 195 | [key: string]: any; 196 | } 197 | 198 | export type AuthRedirectResult = { 199 | redirectTo?: string | false; 200 | logoutOnFailure?: boolean; 201 | }; 202 | 203 | export type Exporter = ( 204 | data: any, 205 | fetchRelatedRecords: ( 206 | data: any, 207 | field: string, 208 | resource: string 209 | ) => Promise, 210 | dataProvider: DataProvider, 211 | resource: string 212 | ) => void | Promise; 213 | 214 | export interface GetInfiniteListResult 215 | extends GetListResult { 216 | pageParam?: number; 217 | } 218 | -------------------------------------------------------------------------------- /packages/tushan/client/api/useCreate.ts: -------------------------------------------------------------------------------- 1 | import { 2 | useMutation, 3 | UseMutationOptions, 4 | UseMutationResult, 5 | useQueryClient, 6 | MutateOptions, 7 | } from '@tanstack/react-query'; 8 | import type { BasicRecord, CreateParams } from './types'; 9 | import { useDataProvider } from '../context/tushan'; 10 | import { useEvent } from '../hooks/useEvent'; 11 | 12 | /** 13 | * Get a callback to call the dataProvider.create() method, the result and the loading state. 14 | * 15 | * @param {Object} options Options object to pass to the queryClient. 16 | * May include side effects to be executed upon success or failure, e.g. { onSuccess: () => { refresh(); } } 17 | * 18 | * @typedef Params 19 | * @prop params.data The record to create, e.g. { title: 'hello, world' } 20 | * 21 | * @returns The current mutation state. Destructure as [create, { data, error, isLoading }]. 22 | * 23 | * The return value updates according to the request state: 24 | * 25 | * - initial: [create, { isLoading: false, isIdle: true }] 26 | * - start: [create, { isLoading: true }] 27 | * - success: [create, { data: [data from response], isLoading: false, isSuccess: true }] 28 | * - error: [create, { error: [error from response], isLoading: false, isError: true }] 29 | * 30 | * The create() function must be called with a resource and a parameter object: create(resource, { data, meta }, options) 31 | * 32 | * This hook uses react-query useMutation under the hood. 33 | * This means the state object contains mutate, isIdle, reset and other react-query methods. 34 | * 35 | * @see https://tanstack.com/query/latest/docs/react/reference/useMutation 36 | * 37 | * @example // set params when calling the create callback 38 | * 39 | * import { useCreate, useRecordContext } from 'react-admin'; 40 | * 41 | * const LikeButton = () => { 42 | * const record = useRecordContext(); 43 | * const like = { postId: record.id }; 44 | * const [create, { isLoading, error }] = useCreate(); 45 | * const handleClick = () => { 46 | * create('likes', { data: like }) 47 | * } 48 | * if (error) { return

ERROR

; } 49 | * return ; 50 | * }; 51 | * 52 | * @example // set params when calling the hook 53 | * 54 | * import { useCreate, useRecordContext } from 'react-admin'; 55 | * 56 | * const LikeButton = () => { 57 | * const record = useRecordContext(); 58 | * const like = { postId: record.id }; 59 | * const [create, { isLoading, error }] = useCreate('likes', { data: like }); 60 | * if (error) { return

ERROR

; } 61 | * return ; 62 | * }; 63 | * 64 | * @example // TypeScript 65 | * const [create, { data }] = useCreate('products', { data: product }); 66 | * \-- data is Product 67 | */ 68 | export const useCreate = < 69 | RecordType extends BasicRecord = any, 70 | MutationError = unknown 71 | >( 72 | options: UseCreateOptions = {} 73 | ): UseCreateResult => { 74 | const dataProvider = useDataProvider(); 75 | const queryClient = useQueryClient(); 76 | 77 | const mutation = useMutation< 78 | RecordType, 79 | MutationError, 80 | UseCreateMutateParams 81 | >( 82 | ({ resource, data, meta }) => 83 | dataProvider 84 | .create(resource, { 85 | data, 86 | meta, 87 | }) 88 | .then(({ data }) => data), 89 | { 90 | ...options, 91 | onSuccess: ( 92 | data: RecordType, 93 | variables: UseCreateMutateParams, 94 | context: unknown 95 | ) => { 96 | const { resource } = variables; 97 | queryClient.setQueryData( 98 | [resource, 'getOne', { id: String(data.id) }], 99 | data 100 | ); 101 | 102 | if (options.onSuccess) { 103 | options.onSuccess(data, variables, context); 104 | } 105 | }, 106 | } 107 | ); 108 | 109 | const create = ( 110 | resource: string, 111 | params: CreateParams, 112 | createOptions: MutateOptions< 113 | RecordType, 114 | MutationError, 115 | UseCreateMutateParams, 116 | unknown 117 | > = {} 118 | ) => { 119 | return mutation.mutateAsync({ resource, ...params }, createOptions); 120 | }; 121 | 122 | return [useEvent(create), mutation]; 123 | }; 124 | 125 | export interface UseCreateMutateParams { 126 | resource: string; 127 | data: Partial; 128 | meta?: any; 129 | } 130 | 131 | export type UseCreateOptions< 132 | RecordType extends BasicRecord = any, 133 | MutationError = unknown 134 | > = UseMutationOptions< 135 | RecordType, 136 | MutationError, 137 | UseCreateMutateParams 138 | > & { returnPromise?: boolean }; 139 | 140 | export type UseCreateResult< 141 | RecordType extends BasicRecord = any, 142 | TReturnPromise extends boolean = boolean, 143 | MutationError = unknown 144 | > = [ 145 | ( 146 | resource: string, 147 | params: CreateParams, 148 | options?: MutateOptions< 149 | RecordType, 150 | MutationError, 151 | UseCreateMutateParams, 152 | unknown 153 | > & { returnPromise?: TReturnPromise } 154 | ) => Promise, 155 | UseMutationResult< 156 | RecordType, 157 | MutationError, 158 | UseCreateMutateParams, 159 | unknown 160 | > 161 | ]; 162 | -------------------------------------------------------------------------------- /packages/tushan/client/api/useGetList.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useMemo } from 'react'; 2 | import { 3 | useQuery, 4 | UseQueryOptions, 5 | UseQueryResult, 6 | useQueryClient, 7 | } from '@tanstack/react-query'; 8 | import type { BasicRecord, GetListParams, GetListResult } from './types'; 9 | import { useDataProvider } from '../context/tushan'; 10 | import { defaultFilter, defaultSort } from './consts'; 11 | import { useEvent } from '../hooks/useEvent'; 12 | import { sharedEvent } from '../utils/event'; 13 | 14 | /** 15 | * Call the dataProvider.getList() method and return the resolved result 16 | * as well as the loading state. 17 | * 18 | * The return value updates according to the request state: 19 | * 20 | * - start: { isLoading: true, refetch } 21 | * - success: { data: [data from store], total: [total from response], isLoading: false, refetch } 22 | * - error: { error: [error from response], isLoading: false, refetch } 23 | * 24 | * This hook will return the cached result when called a second time 25 | * with the same parameters, until the response arrives. 26 | * 27 | * @param {string} resource The resource name, e.g. 'posts' 28 | * @param {Params} params The getList parameters { pagination, sort, filter, meta } 29 | * @param {Object} options Options object to pass to the queryClient. 30 | * May include side effects to be executed upon success or failure, e.g. { onSuccess: () => { refresh(); } } 31 | * 32 | * @typedef Params 33 | * @prop params.pagination The request pagination { page, perPage }, e.g. { page: 1, perPage: 10 } 34 | * @prop params.sort The request sort { field, order }, e.g. { field: 'id', order: 'DESC' } 35 | * @prop params.filter The request filters, e.g. { title: 'hello, world' } 36 | * @prop params.meta Optional meta parameters 37 | * 38 | * @returns The current request state. Destructure as { data, total, error, isLoading, refetch }. 39 | * 40 | * @example 41 | * 42 | * import { useGetList } from '@tushan'; 43 | * 44 | * const LatestNews = () => { 45 | * const { data, total, isLoading, error } = useGetList( 46 | * 'posts', 47 | * { pagination: { page: 1, perPage: 10 }, sort: { field: 'published_at', order: 'DESC' } } 48 | * ); 49 | * if (isLoading) { return ; } 50 | * if (error) { return

ERROR

; } 51 | * return
    {data.map(item => 52 | *
  • {item.title}
  • 53 | * )}
; 54 | * }; 55 | */ 56 | export const useGetList = ( 57 | resource: string, 58 | params: Partial = {}, 59 | options?: UseQueryOptions, Error> 60 | ): UseGetListHookValue => { 61 | const { 62 | pagination = { page: 1, perPage: 20 }, 63 | sort = defaultSort, 64 | filter = defaultFilter, 65 | meta, 66 | } = params; 67 | const dataProvider = useDataProvider(); 68 | const queryClient = useQueryClient(); 69 | 70 | const result = useQuery< 71 | GetListResult, 72 | Error, 73 | GetListResult 74 | >( 75 | [resource, 'getList', { pagination, sort, filter, meta }], 76 | () => 77 | dataProvider 78 | .getList(resource, { 79 | pagination, 80 | sort, 81 | filter, 82 | meta, 83 | }) 84 | .then(({ data, total, pageInfo }) => ({ 85 | data, 86 | total, 87 | pageInfo, 88 | })), 89 | { 90 | ...options, 91 | onSuccess: (value) => { 92 | const { data } = value; 93 | // optimistically populate the getOne cache 94 | data.forEach((record) => { 95 | queryClient.setQueryData( 96 | [resource, 'getOne', { id: String(record.id), meta }], 97 | (oldRecord) => oldRecord ?? record 98 | ); 99 | }); 100 | // execute call-time onSuccess if provided 101 | if (options?.onSuccess) { 102 | options.onSuccess(value); 103 | } 104 | }, 105 | } 106 | ); 107 | 108 | const handleRefresh = useEvent(() => { 109 | result.refetch(); 110 | }); 111 | 112 | useEffect(() => { 113 | const fn = (_resource: string) => { 114 | if (_resource === resource) { 115 | handleRefresh(); 116 | } 117 | }; 118 | sharedEvent.on('refreshList', fn); 119 | 120 | return () => { 121 | sharedEvent.off('refreshList', fn); 122 | }; 123 | }, [resource]); 124 | 125 | return useMemo( 126 | () => 127 | result.data 128 | ? { 129 | ...result, 130 | data: result.data?.data, 131 | total: result.data?.total, 132 | pageInfo: result.data?.pageInfo, 133 | } 134 | : result, 135 | [result] 136 | ) as UseQueryResult & { 137 | total?: number; 138 | pageInfo?: { 139 | hasNextPage?: boolean; 140 | hasPreviousPage?: boolean; 141 | }; 142 | }; 143 | }; 144 | 145 | export type UseGetListHookValue = 146 | UseQueryResult & { 147 | total?: number; 148 | pageInfo?: { 149 | hasNextPage?: boolean; 150 | hasPreviousPage?: boolean; 151 | }; 152 | }; 153 | -------------------------------------------------------------------------------- /packages/tushan/client/api/useGetMany.ts: -------------------------------------------------------------------------------- 1 | import { 2 | useQueryClient, 3 | UseQueryOptions, 4 | useQuery, 5 | UseQueryResult, 6 | hashQueryKey, 7 | } from '@tanstack/react-query'; 8 | import { useDataProvider } from '../context/tushan'; 9 | import type { BasicRecord, GetManyParams } from './types'; 10 | 11 | export const useGetMany = ( 12 | resource: string, 13 | params: Partial = {}, 14 | options?: UseQueryOptions 15 | ): UseGetManyHookValue => { 16 | const { ids, meta } = params; 17 | const dataProvider = useDataProvider(); 18 | const queryClient = useQueryClient(); 19 | const queryCache = queryClient.getQueryCache(); 20 | 21 | return useQuery( 22 | [ 23 | resource, 24 | 'getMany', 25 | { 26 | ids: !ids || ids.length === 0 ? [] : ids.map((id) => String(id)), 27 | meta, 28 | }, 29 | ], 30 | () => { 31 | if (!ids || ids.length === 0) { 32 | // no need to call the dataProvider 33 | return Promise.resolve([]); 34 | } 35 | return dataProvider 36 | .getMany(resource, { ids, meta }) 37 | .then(({ data }) => data); 38 | }, 39 | { 40 | placeholderData: () => { 41 | const records = 42 | !ids || ids.length === 0 43 | ? [] 44 | : ids.map((id) => { 45 | const queryHash = hashQueryKey([ 46 | resource, 47 | 'getOne', 48 | { id: String(id), meta }, 49 | ]); 50 | return queryCache.get(queryHash)?.state?.data; 51 | }); 52 | if (records.some((record) => record === undefined)) { 53 | return undefined; 54 | } else { 55 | return records as RecordType[]; 56 | } 57 | }, 58 | onSuccess: (data) => { 59 | // optimistically populate the getOne cache 60 | data.forEach((record) => { 61 | queryClient.setQueryData( 62 | [resource, 'getOne', { id: String(record.id), meta }], 63 | (oldRecord) => oldRecord ?? record 64 | ); 65 | }); 66 | }, 67 | retry: false, 68 | ...options, 69 | } 70 | ); 71 | }; 72 | 73 | export type UseGetManyHookValue = 74 | UseQueryResult; 75 | -------------------------------------------------------------------------------- /packages/tushan/client/api/useGetOne.ts: -------------------------------------------------------------------------------- 1 | import { 2 | useQuery, 3 | UseQueryOptions, 4 | UseQueryResult, 5 | } from '@tanstack/react-query'; 6 | import { useDataProvider } from '../context/tushan'; 7 | import type { GetOneParams, BasicRecord } from './types'; 8 | 9 | export function useGetOne( 10 | resource: string, 11 | { id, meta }: GetOneParams, 12 | options?: UseQueryOptions 13 | ): UseGetOneHookValue { 14 | const dataProvider = useDataProvider(); 15 | return useQuery( 16 | // Sometimes the id comes as a string (e.g. when read from the URL in a Show view). 17 | // Sometimes the id comes as a number (e.g. when read from a Record in useGetList response). 18 | // As the react-query cache is type-sensitive, we always stringify the identifier to get a match 19 | [resource, 'getOne', { id: String(id), meta }], 20 | () => 21 | dataProvider 22 | .getOne(resource, { id, meta }) 23 | .then(({ data }) => data), 24 | options 25 | ); 26 | } 27 | 28 | export type UseGetOneHookValue = 29 | UseQueryResult; 30 | -------------------------------------------------------------------------------- /packages/tushan/client/api/useRefreshList.ts: -------------------------------------------------------------------------------- 1 | import { useEvent } from '../hooks/useEvent'; 2 | import { sharedEvent } from '../utils/event'; 3 | 4 | export function useRefreshList(resource: string) { 5 | const refresh = useEvent(() => { 6 | sharedEvent.emit('refreshList', resource); 7 | }); 8 | 9 | return refresh; 10 | } 11 | -------------------------------------------------------------------------------- /packages/tushan/client/chart.ts: -------------------------------------------------------------------------------- 1 | export * from 'recharts'; 2 | -------------------------------------------------------------------------------- /packages/tushan/client/components/ArcoDesignProvider.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import enUS from '@arco-design/web-react/es/locale/en-US'; 3 | import { ConfigProvider } from '@arco-design/web-react'; 4 | import { useTranslation } from 'react-i18next'; 5 | import { useAsync } from '../hooks/useAsync'; 6 | import type { Locale } from '@arco-design/web-react/es/locale/interface'; 7 | 8 | export const ArcoDesignProvider: React.FC = React.memo( 9 | (props) => { 10 | const { i18n } = useTranslation(); 11 | const language = i18n.language; 12 | 13 | const { value: locale } = useAsync(async (): Promise => { 14 | if (language === 'zh-CN' || language === 'zh' || language === 'zh-Hans') { 15 | return import('@arco-design/web-react/es/locale/zh-CN').then( 16 | (m) => m.default 17 | ); 18 | } else if (language === 'zh-TW') { 19 | return import('@arco-design/web-react/es/locale/zh-TW').then( 20 | (m) => m.default 21 | ); 22 | } else if (language === 'zh-HK') { 23 | return import('@arco-design/web-react/es/locale/zh-HK').then( 24 | (m) => m.default 25 | ); 26 | } else if (language === 'ja-JP' || language === 'ja') { 27 | return import('@arco-design/web-react/es/locale/ja-JP').then( 28 | (m) => m.default 29 | ); 30 | } 31 | 32 | return enUS; 33 | }, [language]); 34 | 35 | return ( 36 | {props.children} 37 | ); 38 | } 39 | ); 40 | ArcoDesignProvider.displayName = 'ArcoDesignProvider'; 41 | -------------------------------------------------------------------------------- /packages/tushan/client/components/BuiltinRoutes.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useState } from 'react'; 2 | import { Navigate, Route, Routes } from 'react-router-dom'; 3 | import { defaultAuthParams, useCheckAuth } from '../api/auth'; 4 | import { useTushanContext } from '../context/tushan'; 5 | import { useConfigureAdminRouterFromChildren } from '../hooks/useConfigureAdminRouterFromChildren'; 6 | import { useDelay } from '../hooks/useDelay'; 7 | import { useUserStore } from '../store/user'; 8 | import type { TushanChildren } from '../types'; 9 | import { createSelector } from '../utils/createSelector'; 10 | import { Dashboard } from './defaults/Dashboard'; 11 | import { LoginPage } from './defaults/LoginPage'; 12 | import { BasicLayout } from './layout'; 13 | import { LoadingView } from './LoadingView'; 14 | 15 | export interface BuiltinRoutesProps { 16 | children: TushanChildren; 17 | } 18 | 19 | export const BuiltinRoutes: React.FC = React.memo( 20 | (props) => { 21 | const { 22 | customRoutesWithLayout, 23 | customRoutesWithoutLayout, 24 | resources, 25 | components, 26 | } = useConfigureAdminRouterFromChildren(props.children); 27 | const { 28 | dashboard = , 29 | authProvider, 30 | layout = , 31 | loginPage = , 32 | } = useTushanContext(); 33 | const requireAuth = Boolean(authProvider); 34 | const [canRender, setCanRender] = useState(!requireAuth); 35 | const oneSecondHasPassed = useDelay(1000); 36 | const { isLogin } = useUserStore(createSelector('isLogin')); 37 | 38 | const checkAuth = useCheckAuth(); 39 | 40 | useEffect(() => { 41 | if (requireAuth) { 42 | checkAuth() 43 | .then(() => { 44 | setCanRender(true); 45 | }) 46 | .catch(() => {}); 47 | } 48 | }, [checkAuth, requireAuth, isLogin]); 49 | 50 | return ( 51 | <> 52 | 53 | {customRoutesWithoutLayout} 54 | 55 | 56 | 57 | {canRender ? ( 58 | 59 | 63 | 64 | {dashboard && ( 65 | 66 | )} 67 | 68 | {customRoutesWithLayout.map((item) => ( 69 | 74 | ))} 75 | 76 | {resources.map((resource) => ( 77 | 82 | ))} 83 | 84 | 94 | } 95 | /> 96 | 97 | 404} /> 98 | 99 | 100 | } 101 | /> 102 | 103 | ) : oneSecondHasPassed ? ( 104 | } /> 105 | ) : ( 106 | 107 | )} 108 | 109 | 110 | {components} 111 | 112 | ); 113 | } 114 | ); 115 | BuiltinRoutes.displayName = 'BuiltinRoutes'; 116 | -------------------------------------------------------------------------------- /packages/tushan/client/components/ButtonWithConfirm.tsx: -------------------------------------------------------------------------------- 1 | import { Button, ButtonProps, Modal } from '@arco-design/web-react'; 2 | import React, { useState } from 'react'; 3 | import { useTranslation } from '../i18n'; 4 | 5 | interface Props extends ButtonProps { 6 | confirmTitle?: string; 7 | confirmContent?: string; 8 | onConfirm?: () => void | Promise; 9 | } 10 | export const ButtonWithConfirm: React.FC = React.memo((props) => { 11 | const { t } = useTranslation(); 12 | 13 | const { 14 | confirmTitle = t('tushan.common.confirmTitle'), 15 | confirmContent = t('tushan.common.confirmContent'), 16 | onConfirm, 17 | ...buttonProps 18 | } = props; 19 | const [loading, setLoading] = useState(false); 20 | const [visible, setVisible] = React.useState(false); 21 | 22 | return ( 23 | <> 24 | 103 | 104 | 105 | ); 106 | }); 107 | LoginForm.displayName = 'LoginForm'; 108 | -------------------------------------------------------------------------------- /packages/tushan/client/components/detail/DetailForm.tsx: -------------------------------------------------------------------------------- 1 | import { Form } from '@arco-design/web-react'; 2 | import React, { useMemo } from 'react'; 3 | import type { BasicRecord } from '../../api/types'; 4 | import { RecordContextProvider } from '../../context/record'; 5 | import { ViewTypeContextProvider } from '../../context/viewtype'; 6 | import type { FieldHandler } from '../fields/factory'; 7 | 8 | export interface DetailFormProps { 9 | record: BasicRecord; 10 | fields: FieldHandler[]; 11 | } 12 | export const DetailForm: React.FC = React.memo((props) => { 13 | const items = useMemo(() => { 14 | return props.fields 15 | .map((handler) => handler('detail')) 16 | .filter((item) => !item.hidden); 17 | }, [props.fields]); 18 | 19 | return ( 20 | 21 | 22 |
23 | {items.map((item) => ( 24 | 25 | {item.render(props.record[item.source])} 26 | 27 | ))} 28 |
29 |
30 |
31 | ); 32 | }); 33 | DetailForm.displayName = 'DetailForm'; 34 | -------------------------------------------------------------------------------- /packages/tushan/client/components/detail/index.ts: -------------------------------------------------------------------------------- 1 | export * from './DetailForm'; 2 | -------------------------------------------------------------------------------- /packages/tushan/client/components/edit/EditForm.tsx: -------------------------------------------------------------------------------- 1 | import { Button, Form, Message, Space } from '@arco-design/web-react'; 2 | import React, { useMemo } from 'react'; 3 | import { useTranslation } from 'react-i18next'; 4 | import { BasicRecord, useRefreshList } from '../../api'; 5 | import { useCreate } from '../../api/useCreate'; 6 | import { useUpdate } from '../../api/useUpdate'; 7 | import { useResourceContext } from '../../context/resource'; 8 | import { ViewTypeContextProvider } from '../../context/viewtype'; 9 | import { useSendRequest } from '../../hooks/useSendRequest'; 10 | import type { FieldHandler } from '../fields/factory'; 11 | import { SubmitButton } from '../SubmitButton'; 12 | 13 | export interface EditFormProps { 14 | fields: FieldHandler[]; 15 | record: BasicRecord | null; // edit or create 16 | onSuccess?: (values: BasicRecord) => void; 17 | onCancel?: () => void; 18 | } 19 | export const EditForm: React.FC = React.memo((props) => { 20 | const isCreate = props.record === null; 21 | const [form] = Form.useForm(); 22 | const [create] = useCreate(); 23 | const [updateOne] = useUpdate(); 24 | const resource = useResourceContext(); 25 | const { t } = useTranslation(); 26 | const refresh = useRefreshList(resource); 27 | 28 | const items = useMemo(() => { 29 | return props.fields 30 | .map((handler) => (isCreate ? handler('create') : handler('edit'))) 31 | .filter((item) => !item.hidden); 32 | }, [props.fields, isCreate]); 33 | const defaultValues = useMemo(() => { 34 | const v = props.record ?? ({} as BasicRecord); 35 | if (isCreate) { 36 | items.forEach((item) => { 37 | if ( 38 | typeof v[item.source] === 'undefined' && 39 | typeof item.default !== 'undefined' 40 | ) { 41 | v[item.source] = item.default; 42 | } 43 | }); 44 | } 45 | 46 | return v; 47 | }, [props.record, isCreate, items]); 48 | 49 | const handleSubmit = useSendRequest(async () => { 50 | try { 51 | const values = form.getFieldsValue(); 52 | 53 | await form.validate(); 54 | 55 | if (isCreate) { 56 | await create(resource, { 57 | data: { ...values }, 58 | }); 59 | refresh(); // refresh list after call create in list drawer 60 | } else { 61 | if (!defaultValues.id) { 62 | Message.error('Cannot update record, not found id'); 63 | return; 64 | } 65 | await updateOne(resource, { 66 | id: defaultValues.id, 67 | data: { ...values }, 68 | }); 69 | } 70 | 71 | props.onSuccess?.(values as BasicRecord); 72 | Message.success(t('tushan.common.operateSuccess') ?? ''); 73 | } catch (err) { 74 | Message.error(t('tushan.common.operateFailed') + ':' + String(err)); 75 | } 76 | }); 77 | 78 | return ( 79 | 80 |
86 | {items.map((item, i) => { 87 | if (item.source === 'id') { 88 | // Dont render id field 89 | return null; 90 | } 91 | 92 | return ( 93 | 99 | {(formData, form) => 100 | item.render(formData[item.source], (val) => { 101 | form.setFieldValue(item.source, val); 102 | }) 103 | } 104 | 105 | ); 106 | })} 107 | 108 | 109 | 110 | 111 | {isCreate ? t('tushan.edit.create') : t('tushan.edit.save')} 112 | 113 | 116 | 117 | 118 |
119 |
120 | ); 121 | }); 122 | EditForm.displayName = 'EditForm'; 123 | -------------------------------------------------------------------------------- /packages/tushan/client/components/edit/index.ts: -------------------------------------------------------------------------------- 1 | export * from './EditForm'; 2 | -------------------------------------------------------------------------------- /packages/tushan/client/components/fields/avatar.tsx: -------------------------------------------------------------------------------- 1 | import { Avatar } from '@arco-design/web-react'; 2 | import React from 'react'; 3 | import { isValidUrl } from '../../utils/common'; 4 | import { createFieldFactory } from './factory'; 5 | import { TextFieldEdit } from './text'; 6 | import type { FieldDetailComponent } from './types'; 7 | 8 | export const AvatarFieldDetail: FieldDetailComponent = React.memo( 9 | (props) => { 10 | return ( 11 | 12 | {isValidUrl(props.value) ? ( 13 | avatar 14 | ) : ( 15 | {props.value} 16 | )} 17 | 18 | ); 19 | } 20 | ); 21 | AvatarFieldDetail.displayName = 'AvatarFieldDetail'; 22 | 23 | export const createAvatarField = createFieldFactory({ 24 | detail: AvatarFieldDetail, 25 | edit: TextFieldEdit, 26 | }); 27 | -------------------------------------------------------------------------------- /packages/tushan/client/components/fields/boolean.tsx: -------------------------------------------------------------------------------- 1 | import { Switch } from '@arco-design/web-react'; 2 | import { IconCheck, IconClose } from '@arco-design/web-react/icon'; 3 | import React from 'react'; 4 | import { createFieldFactory } from './factory'; 5 | import type { FieldDetailComponent, FieldEditComponent } from './types'; 6 | 7 | function isTrusty(input: any): boolean { 8 | return input === '1' || input === 'true' || input === 1 || input === true; 9 | } 10 | 11 | export const BooleanFieldDetail: FieldDetailComponent = React.memo( 12 | (props) => { 13 | return {isTrusty(props.value) ? : }; 14 | } 15 | ); 16 | BooleanFieldDetail.displayName = 'BooleanFieldDetail'; 17 | 18 | export const BooleanFieldEdit: FieldEditComponent = React.memo( 19 | (props) => { 20 | return ( 21 |
22 | props.onChange(val)} 25 | /> 26 |
27 | ); 28 | } 29 | ); 30 | BooleanFieldEdit.displayName = 'BooleanFieldEdit'; 31 | 32 | export const createBooleanField = createFieldFactory({ 33 | detail: BooleanFieldDetail, 34 | edit: BooleanFieldEdit, 35 | }); 36 | -------------------------------------------------------------------------------- /packages/tushan/client/components/fields/datetime.tsx: -------------------------------------------------------------------------------- 1 | import { DatePicker, Input, TimePicker } from '@arco-design/web-react'; 2 | import React from 'react'; 3 | import { createFieldFactory } from './factory'; 4 | import type { FieldDetailComponent, FieldEditComponent } from './types'; 5 | 6 | export type DateTimeFieldValueType = string | number; 7 | 8 | export interface DateTimeFieldOptions { 9 | format: 'iso' | 'unix'; 10 | } 11 | 12 | export const DateTimeFieldDetail: FieldDetailComponent< 13 | DateTimeFieldValueType, 14 | DateTimeFieldOptions 15 | > = React.memo((props) => { 16 | return {new Date(props.value).toLocaleString()}; 17 | }); 18 | DateTimeFieldDetail.displayName = 'DateTimeFieldDetail'; 19 | 20 | export const DateTimeFieldEdit: FieldEditComponent< 21 | DateTimeFieldValueType, 22 | DateTimeFieldOptions 23 | > = React.memo((props) => { 24 | return ( 25 | { 29 | const format = props.options.format ?? 'iso'; 30 | 31 | if (format === 'iso') { 32 | props.onChange(date.toISOString()); 33 | } else if (format === 'unix') { 34 | props.onChange(date.unix()); 35 | } 36 | }} 37 | showTime={true} 38 | /> 39 | ); 40 | }); 41 | DateTimeFieldEdit.displayName = 'DateTimeFieldEdit'; 42 | 43 | export const createDateTimeField = createFieldFactory({ 44 | detail: DateTimeFieldDetail, 45 | edit: DateTimeFieldEdit, 46 | }); 47 | -------------------------------------------------------------------------------- /packages/tushan/client/components/fields/email.tsx: -------------------------------------------------------------------------------- 1 | import { Link } from '@arco-design/web-react'; 2 | import { IconEmail } from '@arco-design/web-react/icon'; 3 | import React from 'react'; 4 | import { createFieldFactory } from './factory'; 5 | import { TextFieldEdit } from './text'; 6 | import type { FieldDetailComponent } from './types'; 7 | 8 | export const EmailFieldDetail: FieldDetailComponent = React.memo( 9 | (props) => { 10 | return ( 11 | }> 12 | {props.value} 13 | 14 | ); 15 | } 16 | ); 17 | EmailFieldDetail.displayName = 'EmailFieldDetail'; 18 | 19 | export const createEmailField = createFieldFactory({ 20 | detail: EmailFieldDetail, 21 | edit: TextFieldEdit, 22 | }); 23 | -------------------------------------------------------------------------------- /packages/tushan/client/components/fields/factory.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | BasicFieldOptions, 3 | FieldDetailComponent, 4 | FieldEditComponent, 5 | } from './types'; 6 | import type { ViewType } from '../../context/viewtype'; 7 | import type { RulesProps, TableColumnProps } from '@arco-design/web-react'; 8 | import { createElement, ReactElement } from 'react'; 9 | import { FieldTitle } from '../FieldTitle'; 10 | 11 | export interface CreateFieldFactoryConfig { 12 | detail: FieldDetailComponent; 13 | edit: FieldEditComponent; 14 | } 15 | 16 | export type ListFieldItem = { 17 | hidden: boolean; 18 | columnProps: TableColumnProps; 19 | }; 20 | 21 | export interface EditFieldItem { 22 | source: string; 23 | title: string; 24 | hidden: boolean; 25 | default?: T; 26 | rules: RulesProps[]; 27 | render: (value: T, onChange: (val: T) => void) => ReactElement; 28 | } 29 | 30 | export interface DetailFieldItem { 31 | source: string; 32 | title: string; 33 | hidden: boolean; 34 | render: (value: T) => ReactElement; 35 | } 36 | 37 | export type FieldHandler = ( 38 | viewType: T 39 | ) => T extends 'list' 40 | ? ListFieldItem 41 | : T extends 'edit' | 'create' 42 | ? EditFieldItem 43 | : T extends 'detail' 44 | ? DetailFieldItem 45 | : never; 46 | 47 | export function createFieldFactory( 48 | config: CreateFieldFactoryConfig 49 | ) { 50 | return ( 51 | source: string, 52 | options?: BasicFieldOptions & CustomOptions 53 | ): FieldHandler => 54 | (viewType) => { 55 | if (viewType === 'list') { 56 | return { 57 | hidden: options?.list?.hidden ?? false, 58 | columnProps: { 59 | dataIndex: source, 60 | sorter: options?.list?.sort ?? false, 61 | sortDirections: ['ascend', 'descend'], 62 | title: options?.label ?? createElement(FieldTitle, { source }), 63 | width: options?.list?.width, 64 | ellipsis: options?.list?.ellipsis, 65 | render: (val) => { 66 | return createElement(config.detail, { 67 | value: options?.preRenderTransform 68 | ? options?.preRenderTransform(val) 69 | : val, 70 | options: options ?? {}, 71 | }); 72 | }, 73 | }, 74 | } as ListFieldItem; 75 | } else if (viewType === 'edit' || viewType === 'create') { 76 | let editOptions = options?.edit ?? {}; 77 | 78 | if (viewType === 'create') { 79 | editOptions = { 80 | ...editOptions, 81 | ...options?.create, 82 | }; 83 | } 84 | 85 | return { 86 | source, 87 | title: options?.label ?? createElement(FieldTitle, { source }), 88 | hidden: editOptions.hidden ?? false, 89 | default: editOptions.default, 90 | rules: editOptions.rules ?? [], 91 | render: (value, onChange) => { 92 | return createElement(config.edit, { 93 | value, 94 | onChange, 95 | options: options ?? {}, 96 | }); 97 | }, 98 | } as EditFieldItem; 99 | } else if (viewType === 'detail') { 100 | return { 101 | source, 102 | title: options?.label ?? createElement(FieldTitle, { source }), 103 | hidden: options?.detail?.hidden ?? false, 104 | render: (val) => { 105 | return createElement(config.detail, { 106 | value: options?.preRenderTransform 107 | ? options?.preRenderTransform(val) 108 | : val, 109 | options: options ?? {}, 110 | }); 111 | }, 112 | } as DetailFieldItem; 113 | } 114 | 115 | return null as any; 116 | }; 117 | } 118 | -------------------------------------------------------------------------------- /packages/tushan/client/components/fields/image.tsx: -------------------------------------------------------------------------------- 1 | import { Image } from '@arco-design/web-react'; 2 | import React from 'react'; 3 | import { createFieldFactory } from './factory'; 4 | import { TextFieldEdit } from './text'; 5 | import type { FieldDetailComponent } from './types'; 6 | 7 | export interface ImageFieldOptions { 8 | width?: number; 9 | height?: number; 10 | } 11 | 12 | export const ImageFieldDetail: FieldDetailComponent = 13 | React.memo((props) => { 14 | const { height = 80, width = undefined } = props.options; 15 | 16 | return ; 17 | }); 18 | ImageFieldDetail.displayName = 'ImageFieldDetail'; 19 | 20 | export const createImageField = createFieldFactory({ 21 | detail: ImageFieldDetail, 22 | edit: TextFieldEdit, 23 | }); 24 | -------------------------------------------------------------------------------- /packages/tushan/client/components/fields/index.ts: -------------------------------------------------------------------------------- 1 | export * from './types'; 2 | export * from './factory'; 3 | export * from './text'; 4 | export * from './textarea'; 5 | export * from './url'; 6 | export * from './email'; 7 | export * from './avatar'; 8 | export * from './image'; 9 | export * from './boolean'; 10 | export * from './json'; 11 | export * from './datetime'; 12 | export * from './number'; 13 | export * from './password'; 14 | export * from './reference'; 15 | export * from './select'; 16 | -------------------------------------------------------------------------------- /packages/tushan/client/components/fields/json.tsx: -------------------------------------------------------------------------------- 1 | import { Button, Popover } from '@arco-design/web-react'; 2 | import React from 'react'; 3 | import { createFieldFactory } from './factory'; 4 | import type { FieldDetailComponent, FieldEditComponent } from './types'; 5 | import ReactJson, { ReactJsonViewProps } from 'react-json-view'; 6 | import { useViewTypeContext } from '../../context/viewtype'; 7 | 8 | const defaultJSONViewProps: Partial = { 9 | name: false, 10 | displayDataTypes: false, 11 | iconStyle: 'square', 12 | }; 13 | 14 | function getObjectSize(obj: any): number { 15 | if (!obj) { 16 | return 0; 17 | } 18 | 19 | if (Array.isArray(obj)) { 20 | return obj.length; 21 | } else if (typeof obj === 'object') { 22 | return Object.keys(obj).length; 23 | } else { 24 | return String(obj).length; 25 | } 26 | } 27 | 28 | export const JSONFieldDetail: FieldDetailComponent = React.memo( 29 | (props) => { 30 | const viewType = useViewTypeContext(); 31 | 32 | if (viewType === 'list') { 33 | return ( 34 | } 37 | > 38 | 41 | 42 | ); 43 | } else { 44 | return ; 45 | } 46 | } 47 | ); 48 | JSONFieldDetail.displayName = 'JSONFieldDetail'; 49 | 50 | export const JSONFieldEdit: FieldEditComponent = React.memo((props) => { 51 | return ( 52 | props.onChange(edit.updated_src)} 56 | /> 57 | ); 58 | }); 59 | JSONFieldEdit.displayName = 'JSONFieldEdit'; 60 | 61 | export const createJSONField = createFieldFactory({ 62 | detail: JSONFieldDetail, 63 | edit: JSONFieldEdit, 64 | }); 65 | -------------------------------------------------------------------------------- /packages/tushan/client/components/fields/number.tsx: -------------------------------------------------------------------------------- 1 | import { InputNumber } from '@arco-design/web-react'; 2 | import React from 'react'; 3 | import { createFieldFactory } from './factory'; 4 | import { TextFieldDetail } from './text'; 5 | import type { FieldEditComponent } from './types'; 6 | 7 | export const NumberFieldEdit: FieldEditComponent = React.memo( 8 | (props) => { 9 | return ( 10 | props.onChange(val)} 14 | /> 15 | ); 16 | } 17 | ); 18 | NumberFieldEdit.displayName = 'NumberFieldEdit'; 19 | 20 | export const createNumberField = createFieldFactory({ 21 | detail: TextFieldDetail, 22 | edit: NumberFieldEdit, 23 | }); 24 | -------------------------------------------------------------------------------- /packages/tushan/client/components/fields/password.tsx: -------------------------------------------------------------------------------- 1 | import { Input } from '@arco-design/web-react'; 2 | import React from 'react'; 3 | import { normalizeText } from '../../utils/common'; 4 | import { createFieldFactory } from './factory'; 5 | import { TextFieldDetail } from './text'; 6 | import type { FieldEditComponent } from './types'; 7 | 8 | export const PasswordFieldEdit: FieldEditComponent = React.memo( 9 | (props) => { 10 | return ( 11 | props.onChange(val)} 15 | /> 16 | ); 17 | } 18 | ); 19 | PasswordFieldEdit.displayName = 'PasswordFieldEdit'; 20 | 21 | export const createPasswordField = createFieldFactory({ 22 | detail: TextFieldDetail, 23 | edit: PasswordFieldEdit, 24 | }); 25 | -------------------------------------------------------------------------------- /packages/tushan/client/components/fields/reference.tsx: -------------------------------------------------------------------------------- 1 | import { Select } from '@arco-design/web-react'; 2 | import { get } from 'lodash-es'; 3 | import React from 'react'; 4 | import { Identifier, useGetList } from '../../api'; 5 | import { useGetOne } from '../../api/useGetOne'; 6 | import { useDebounce } from '../../hooks/useDebounce'; 7 | import { useObjectState } from '../../hooks/useObjectState'; 8 | import { createFieldFactory } from './factory'; 9 | import type { FieldDetailComponent, FieldEditComponent } from './types'; 10 | 11 | export interface ReferenceFieldOptions { 12 | reference: string; 13 | /** 14 | * Source Field for display 15 | */ 16 | displayField: string; 17 | /** 18 | * Field which define search filter in edit. 19 | * 20 | * @default "q" 21 | */ 22 | searchField?: string; 23 | 24 | /** 25 | * @default false 26 | */ 27 | allowClear?: boolean; 28 | } 29 | 30 | export const ReferenceFieldDetail: FieldDetailComponent< 31 | Identifier, 32 | ReferenceFieldOptions 33 | > = React.memo((props) => { 34 | const options = props.options; 35 | const reference = options.reference ?? ''; 36 | const displayField = options.displayField ?? ''; 37 | const id = props.value; 38 | 39 | const { data } = useGetOne(reference, { 40 | id, 41 | }); 42 | 43 | return {get(data, displayField)}; 44 | }); 45 | ReferenceFieldDetail.displayName = 'ReferenceFieldDetail'; 46 | 47 | export const ReferenceFieldEdit: FieldEditComponent< 48 | Identifier, 49 | ReferenceFieldOptions 50 | > = React.memo((props) => { 51 | const reference = props.options.reference ?? ''; 52 | const displayField = props.options.displayField ?? ''; 53 | const searchField = props.options.searchField ?? 'q'; 54 | const allowClear = props.options.allowClear ?? false; 55 | const [filterValues, setFilterValues] = useObjectState({}); 56 | const lazyFilter = useDebounce(filterValues, { wait: 500 }); 57 | 58 | const { data = [] } = useGetList(reference, { 59 | pagination: { 60 | page: 1, 61 | perPage: 10, 62 | }, 63 | filter: lazyFilter, 64 | }); 65 | 66 | return ( 67 | 81 | ); 82 | }); 83 | ReferenceFieldEdit.displayName = 'ReferenceFieldEdit'; 84 | 85 | export const createReferenceField = createFieldFactory({ 86 | detail: ReferenceFieldDetail, 87 | edit: ReferenceFieldEdit, 88 | }); 89 | -------------------------------------------------------------------------------- /packages/tushan/client/components/fields/select.tsx: -------------------------------------------------------------------------------- 1 | import { Select, Tag } from '@arco-design/web-react'; 2 | import React from 'react'; 3 | import { createFieldFactory } from './factory'; 4 | import type { FieldDetailComponent, FieldEditComponent } from './types'; 5 | 6 | export type SelectFieldOptionValueType = string | number; 7 | 8 | export interface SelectFieldOptionItem { 9 | value: SelectFieldOptionValueType; 10 | 11 | label?: string; 12 | 13 | /** 14 | * Use in detail mode 15 | */ 16 | color?: string; 17 | /** 18 | * Use in detail mode 19 | */ 20 | icon?: string; 21 | } 22 | 23 | export interface SelectFieldOptions { 24 | items: SelectFieldOptionItem[]; 25 | } 26 | 27 | export const SelectFieldDetail: FieldDetailComponent< 28 | SelectFieldOptionValueType, 29 | SelectFieldOptions 30 | > = React.memo((props) => { 31 | const items = props.options.items ?? []; 32 | 33 | const selectedOption = items.find((item) => item.value === props.value); 34 | 35 | if (selectedOption) { 36 | return ( 37 | 38 | {selectedOption.label ?? selectedOption.value} 39 | 40 | ); 41 | } else { 42 | return {props.value}; 43 | } 44 | }); 45 | SelectFieldDetail.displayName = 'SelectFieldDetail'; 46 | 47 | export const SelectFieldEdit: FieldEditComponent< 48 | SelectFieldOptionValueType, 49 | SelectFieldOptions 50 | > = React.memo((props) => { 51 | const items = props.options.items ?? []; 52 | 53 | return ( 54 | 65 | ); 66 | }); 67 | SelectFieldEdit.displayName = 'SelectFieldEdit'; 68 | 69 | /** 70 | * @example 71 | * createSelectField('Class', { 72 | * items: [ 73 | * { 74 | * value: 'A', 75 | * label: 'Class A', 76 | * color: 'red', 77 | * }, 78 | * { 79 | * value: 'B', 80 | * label: 'Class B', 81 | * color: 'green', 82 | * }, 83 | * ], 84 | * }), 85 | */ 86 | export const createSelectField = createFieldFactory({ 87 | detail: SelectFieldDetail, 88 | edit: SelectFieldEdit, 89 | }); 90 | -------------------------------------------------------------------------------- /packages/tushan/client/components/fields/text.tsx: -------------------------------------------------------------------------------- 1 | import { Input } from '@arco-design/web-react'; 2 | import React from 'react'; 3 | import { normalizeText } from '../../utils/common'; 4 | import { createFieldFactory } from './factory'; 5 | import type { FieldDetailComponent, FieldEditComponent } from './types'; 6 | 7 | export const TextFieldDetail: FieldDetailComponent = React.memo( 8 | (props) => { 9 | return {normalizeText(props.value)}; 10 | } 11 | ); 12 | TextFieldDetail.displayName = 'TextFieldDetail'; 13 | 14 | export const TextFieldEdit: FieldEditComponent = React.memo((props) => { 15 | return ( 16 | props.onChange(val)} 20 | /> 21 | ); 22 | }); 23 | TextFieldEdit.displayName = 'TextFieldEdit'; 24 | 25 | export const createTextField = createFieldFactory({ 26 | detail: TextFieldDetail, 27 | edit: TextFieldEdit, 28 | }); 29 | -------------------------------------------------------------------------------- /packages/tushan/client/components/fields/textarea.tsx: -------------------------------------------------------------------------------- 1 | import { Input, Typography } from '@arco-design/web-react'; 2 | import React from 'react'; 3 | import { useViewTypeContext } from '../../context/viewtype'; 4 | import { normalizeText } from '../../utils/common'; 5 | import { createFieldFactory } from './factory'; 6 | import type { FieldDetailComponent, FieldEditComponent } from './types'; 7 | 8 | export const TextAreaFieldDetail: FieldDetailComponent = React.memo( 9 | (props) => { 10 | const viewType = useViewTypeContext(); 11 | const text = normalizeText(props.value); 12 | 13 | if (viewType === 'list') { 14 | return ( 15 | 30 | {text} 31 | 32 | ); 33 | } 34 | 35 | return ( 36 | 39 | {text} 40 | 41 | ); 42 | } 43 | ); 44 | TextAreaFieldDetail.displayName = 'TextAreaFieldDetail'; 45 | 46 | export const TextAreaFieldEdit: FieldEditComponent = React.memo( 47 | (props) => { 48 | return ( 49 | props.onChange(val)} 53 | /> 54 | ); 55 | } 56 | ); 57 | TextAreaFieldEdit.displayName = 'TextAreaFieldEdit'; 58 | 59 | export const createTextAreaField = createFieldFactory({ 60 | detail: TextAreaFieldDetail, 61 | edit: TextAreaFieldEdit, 62 | }); 63 | -------------------------------------------------------------------------------- /packages/tushan/client/components/fields/types.ts: -------------------------------------------------------------------------------- 1 | import type { RulesProps } from '@arco-design/web-react'; 2 | import type { ComponentType } from 'react'; 3 | 4 | export interface BasicFieldEditOptions { 5 | placeholder?: string; 6 | hidden?: boolean; 7 | rules?: RulesProps[]; 8 | /** 9 | * default value on create. 10 | */ 11 | default?: any; 12 | } 13 | 14 | export interface BasicFieldOptions { 15 | label?: string; 16 | 17 | /** 18 | * preprocess data before transfer to render component 19 | */ 20 | preRenderTransform?: (value: unknown) => unknown; 21 | 22 | list?: { 23 | /** 24 | * whether allow to sort, work in list table 25 | */ 26 | sort?: boolean; 27 | width?: string | number; 28 | hidden?: boolean; 29 | /** 30 | * If the cell content exceeds the length, whether it is automatically omitted and displays .... After setting this property, the table-layout of the table will automatically become fixed. 31 | */ 32 | ellipsis?: boolean; 33 | }; 34 | 35 | edit?: BasicFieldEditOptions; 36 | 37 | /** 38 | * Options which will be override edit 39 | * Useful if you wanna edit and create has any different. 40 | * 41 | * If you dont need different, you can ignore it. 42 | */ 43 | create?: BasicFieldEditOptions; 44 | 45 | detail?: { 46 | hidden?: boolean; 47 | }; 48 | } 49 | 50 | // Detail 51 | export interface FieldDetailComponentProps< 52 | T, 53 | Options extends BasicFieldOptions 54 | > { 55 | value: T; 56 | options: Partial; 57 | } 58 | export type FieldDetailComponent = ComponentType< 59 | FieldDetailComponentProps 60 | >; 61 | 62 | export interface FieldEditComponentProps { 63 | value: T; 64 | onChange: (val: T) => void; 65 | options: Partial; 66 | } 67 | export type FieldEditComponent = ComponentType< 68 | FieldEditComponentProps 69 | >; 70 | -------------------------------------------------------------------------------- /packages/tushan/client/components/fields/url.tsx: -------------------------------------------------------------------------------- 1 | import { Link } from '@arco-design/web-react'; 2 | import React from 'react'; 3 | import { createFieldFactory } from './factory'; 4 | import { TextFieldEdit } from './text'; 5 | import type { FieldDetailComponent } from './types'; 6 | 7 | export const LinkFieldDetail: FieldDetailComponent = React.memo( 8 | (props) => { 9 | return ( 10 | 11 | {props.value} 12 | 13 | ); 14 | } 15 | ); 16 | LinkFieldDetail.displayName = 'LinkFieldDetail'; 17 | 18 | export const createUrlField = createFieldFactory({ 19 | detail: LinkFieldDetail, 20 | edit: TextFieldEdit, 21 | }); 22 | -------------------------------------------------------------------------------- /packages/tushan/client/components/index.tsx: -------------------------------------------------------------------------------- 1 | export { Tushan } from './Tushan'; 2 | export * from './list'; 3 | export * from './fields'; 4 | export * from './Resource'; 5 | export * from './CustomRoute'; 6 | export * from './Category'; 7 | export * from './LoadingView'; 8 | export * from './SubmitButton'; 9 | -------------------------------------------------------------------------------- /packages/tushan/client/components/layout/Breadcrumb.tsx: -------------------------------------------------------------------------------- 1 | import { Breadcrumb } from '@arco-design/web-react'; 2 | import { IconHome } from '@arco-design/web-react/icon'; 3 | import React from 'react'; 4 | import { NavLink } from 'react-router-dom'; 5 | import { useLocation } from 'react-router'; 6 | import { useMemo } from 'react'; 7 | import { useMenuStore } from '../../store/menu'; 8 | import { useTranslation } from 'react-i18next'; 9 | import { startCase } from 'lodash-es'; 10 | import { useTushanContext } from '../../context/tushan'; 11 | 12 | export const TushanBreadcrumb: React.FC = React.memo(() => { 13 | const location = useLocation(); 14 | const { t } = useTranslation(); 15 | const { dashboard } = useTushanContext(); 16 | 17 | const title = useMemo(() => { 18 | const menu = useMenuStore 19 | .getState() 20 | .findMenuBySelector((menu) => 21 | location.pathname.startsWith(`/${menu.key}`) 22 | ); 23 | 24 | if (!menu) { 25 | return ''; 26 | } 27 | 28 | return ( 29 | menu.label ?? 30 | t(`resources.${menu.key}.name`, { 31 | defaultValue: startCase(menu.key), 32 | }) 33 | ); 34 | }, [location.pathname, t]); 35 | 36 | return ( 37 | 38 | 39 | {dashboard !== false ? ( 40 | 41 | 42 | 43 | {t('tushan.breadcrumb.home')} 44 | 45 | ) : ( 46 |
47 | 48 | 49 | {t('tushan.breadcrumb.home')} 50 |
51 | )} 52 |
53 | 54 | {title && {title}} 55 |
56 | ); 57 | }); 58 | TushanBreadcrumb.displayName = 'TushanBreadcrumb'; 59 | -------------------------------------------------------------------------------- /packages/tushan/client/components/layout/Navbar.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Avatar, Button, Space } from '@arco-design/web-react'; 3 | import { IconLanguage, IconUser } from '@arco-design/web-react/icon'; 4 | import { Dropdown } from '@arco-design/web-react'; 5 | import { Menu } from '@arco-design/web-react'; 6 | import styled from 'styled-components'; 7 | import { useLogout } from '../../api/auth'; 8 | import { useTranslation } from '../../i18n'; 9 | import { useTushanContext } from '../../context/tushan'; 10 | 11 | const Root = styled.div` 12 | display: flex; 13 | justify-content: space-between; 14 | align-items: center; 15 | height: 4rem; 16 | 17 | .title { 18 | padding-left: 0.75rem; 19 | padding-right: 0.75rem; 20 | font-size: 1.125rem; 21 | line-height: 1.75rem; 22 | } 23 | 24 | .actions { 25 | padding-left: 0.75rem; 26 | padding-right: 0.75rem; 27 | display: flex; 28 | 29 | .avatar { 30 | cursor: pointer; 31 | } 32 | } 33 | `; 34 | 35 | export const Navbar: React.FC = React.memo(() => { 36 | const logout = useLogout(); 37 | const { i18n: i18nConfig, header } = useTushanContext(); 38 | const { t, i18n, ready } = useTranslation(); 39 | 40 | return ( 41 | 42 |
{header ?? t('tushan.navbar.title')}
43 | 44 | 45 | {i18nConfig && ready && i18nConfig.languages.length >= 2 && ( 46 | 49 | {i18nConfig.languages.map((item) => ( 50 | i18n.changeLanguage(item.key)} 53 | > 54 | {item.label} 55 | 56 | ))} 57 | 58 | } 59 | position="br" 60 | trigger={'click'} 61 | > 62 |
63 |
65 |
66 | )} 67 | 68 | 71 | logout()}> 72 | {t('tushan.navbar.logout')} 73 | 74 | 75 | } 76 | position="br" 77 | trigger={'click'} 78 | > 79 |
80 | 81 | 82 | 83 |
84 |
85 |
86 |
87 | ); 88 | }); 89 | Navbar.displayName = 'Navbar'; 90 | -------------------------------------------------------------------------------- /packages/tushan/client/components/layout/Sidebar.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Menu } from '@arco-design/web-react'; 3 | import { IconApps, IconHome } from '@arco-design/web-react/icon'; 4 | import { useLocation, useNavigate } from 'react-router'; 5 | import { TushanMenu, useMenuStore } from '../../store/menu'; 6 | import { createSelector } from '../../utils/createSelector'; 7 | import styled from 'styled-components'; 8 | import { useTranslation } from 'react-i18next'; 9 | import { startCase } from 'lodash-es'; 10 | import { useTushanContext } from '../../context/tushan'; 11 | import type { TFunction } from 'i18next'; 12 | 13 | const MenuItem = Menu.Item; 14 | const SubMenu = Menu.SubMenu; 15 | 16 | const Root = styled(Menu)` 17 | width: 100%; 18 | `; 19 | 20 | export const Sidebar: React.FC = React.memo(() => { 21 | const navigate = useNavigate(); 22 | const location = useLocation(); 23 | const { menus } = useMenuStore(createSelector('menus')); 24 | const { t } = useTranslation(); 25 | const { dashboard } = useTushanContext(); 26 | 27 | return ( 28 | navigate(path)} 32 | > 33 | {dashboard !== false && ( 34 | 35 | 36 | 37 | {t('tushan.dashboard.name')} 38 | 39 | )} 40 | 41 | {renderMenu({ 42 | menus, 43 | t, 44 | })} 45 | 46 | ); 47 | }); 48 | Sidebar.displayName = 'Sidebar'; 49 | 50 | /** 51 | * A function which generate tree struct menu 52 | * 53 | * Cannot use hooks because of `Menu - SubMenu - MenuItem` should be closest 54 | */ 55 | function renderMenu({ menus, t }: { menus: TushanMenu[]; t: TFunction }) { 56 | return (menus ?? []).map((item) => { 57 | const path = item.path ?? item.key; 58 | 59 | const hasChildren = item.children && item.children.length > 0; 60 | 61 | if (hasChildren) { 62 | return ( 63 | 67 | {item.icon ? item.icon : } 68 | 69 | {item.label ?? 70 | t(`category.${item.key}`, { 71 | defaultValue: startCase(item.key), 72 | })} 73 | 74 | } 75 | > 76 | {renderMenu({ 77 | menus: item.children ?? [], 78 | t, 79 | })} 80 | 81 | ); 82 | } else { 83 | return ( 84 | 85 | {item.icon ? item.icon : } 86 | 87 | {item.label ?? 88 | t(`resources.${item.key}.name`, { 89 | defaultValue: startCase(item.key), 90 | })} 91 | 92 | ); 93 | } 94 | }); 95 | } 96 | -------------------------------------------------------------------------------- /packages/tushan/client/components/layout/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | import { Layout } from '@arco-design/web-react'; 3 | import { Navbar } from './Navbar'; 4 | import { Sidebar } from './Sidebar'; 5 | import { TushanBreadcrumb } from './Breadcrumb'; 6 | import { Outlet } from 'react-router'; 7 | import styled from 'styled-components'; 8 | import { useTranslation } from '../../i18n'; 9 | import { useTushanContext } from '../../context/tushan'; 10 | 11 | const Sider = Layout.Sider; 12 | const Header = Layout.Header; 13 | const Footer = Layout.Footer; 14 | const Content = Layout.Content; 15 | 16 | const Root = styled(Layout)` 17 | height: 100vh; 18 | width: 100%; 19 | overflow-y: auto; 20 | min-height: 100%; 21 | 22 | .header { 23 | position: fixed; 24 | width: 100%; 25 | top: 0; 26 | left: 0; 27 | z-index: 50; 28 | border-bottom-width: 1px; 29 | border-color: var(--color-border); 30 | background-color: var(--color-bg-1); 31 | } 32 | 33 | .sider { 34 | position: fixed; 35 | left: 0; 36 | top: 0; 37 | height: 100%; 38 | } 39 | 40 | .content { 41 | background-color: var(--color-fill-2); 42 | padding: 1rem; 43 | transition-property: all; 44 | transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); 45 | transition-duration: 150ms; 46 | 47 | .body { 48 | flex: 1; 49 | } 50 | 51 | .footer { 52 | text-align: center; 53 | color: rgb(107 114 128); 54 | font-size: 0.75rem; 55 | line-height: 1rem; 56 | margin-top: 1rem; 57 | } 58 | } 59 | `; 60 | 61 | export const BasicLayout: React.FC = React.memo((props) => { 62 | const [collapsed, setCollapsed] = useState(false); 63 | const navbarHeight = 64; 64 | const menuWidth = collapsed ? 48 : 220; 65 | const { t } = useTranslation(); 66 | const { footer } = useTushanContext(); 67 | 68 | return ( 69 | 70 |
71 | 72 |
73 | 74 | 75 | setCollapsed(collapse)} 81 | breakpoint="lg" 82 | className="sider" 83 | > 84 | 85 | 86 | 90 | 91 | 92 | 93 | 94 |
95 | {footer ?? t('tushan.footer.title')} 96 |
97 |
98 |
99 |
100 | ); 101 | }); 102 | BasicLayout.displayName = 'BasicLayout'; 103 | -------------------------------------------------------------------------------- /packages/tushan/client/components/list/ListFilter.tsx: -------------------------------------------------------------------------------- 1 | import { Grid } from '@arco-design/web-react'; 2 | import React from 'react'; 3 | import { useControlledObjectState } from '../../hooks/useObjectState'; 4 | import type { FieldHandler } from '../fields'; 5 | 6 | interface ListFilterProps { 7 | fields: FieldHandler[]; 8 | filterValues: Record; 9 | onChangeFilter: (values: Record) => void; 10 | } 11 | export const ListFilter: React.FC = React.memo((props) => { 12 | const [filterValues, setFilterValues] = useControlledObjectState< 13 | Record 14 | >(props.filterValues, props.onChangeFilter); 15 | 16 | return ( 17 |
18 | 19 | {props.fields.map((fieldHandler, i) => { 20 | const c = fieldHandler('edit'); 21 | 22 | return ( 23 | 24 |
{c.title}
25 |
26 | {c.render(filterValues[c.source], (val) => 27 | setFilterValues({ [c.source]: castFilterValue(val) }) 28 | )} 29 |
30 |
31 | ); 32 | })} 33 |
34 |
35 | ); 36 | }); 37 | ListFilter.displayName = 'ListFilter'; 38 | 39 | function castFilterValue(input: any) { 40 | if ( 41 | input === '' || 42 | input === null || 43 | input === undefined || 44 | (Array.isArray(input) && input.length === 0) 45 | ) { 46 | return undefined; 47 | } 48 | 49 | return input; 50 | } 51 | -------------------------------------------------------------------------------- /packages/tushan/client/components/list/ListTableDrawer.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Drawer } from '@arco-design/web-react'; 3 | import type { FieldHandler } from '../fields'; 4 | import { EditForm } from '../edit/EditForm'; 5 | import type { ViewType } from '../../context/viewtype'; 6 | import { useEvent } from '../../hooks/useEvent'; 7 | import { DetailForm } from '../detail/DetailForm'; 8 | import type { BasicRecord } from '../../api/types'; 9 | import { useTranslation } from 'react-i18next'; 10 | 11 | export interface ListTableDrawerProps { 12 | visible: boolean; 13 | onChangeVisible: (visible: boolean) => void; 14 | fields: FieldHandler[]; 15 | record: BasicRecord | null; 16 | viewType: ViewType; 17 | width?: number; 18 | } 19 | export const ListTableDrawer: React.FC = React.memo( 20 | (props) => { 21 | const { t } = useTranslation(); 22 | const hide = useEvent(() => { 23 | props.onChangeVisible(false); 24 | }); 25 | 26 | return ( 27 | 43 | {props.viewType === 'edit' ? ( 44 | 50 | ) : ( 51 | 55 | )} 56 | 57 | ); 58 | } 59 | ); 60 | ListTableDrawer.displayName = 'ListTableDrawer'; 61 | -------------------------------------------------------------------------------- /packages/tushan/client/components/list/actions/BatchDeleteAction.tsx: -------------------------------------------------------------------------------- 1 | import { Button, Message, Popconfirm, Tooltip } from '@arco-design/web-react'; 2 | import { IconDelete } from '@arco-design/web-react/icon'; 3 | import React from 'react'; 4 | import { useTranslation } from 'react-i18next'; 5 | import { useDeleteMany } from '../../../api'; 6 | import { useResourceContext } from '../../../context/resource'; 7 | import { useBatchSelectedIdsContext } from '../context'; 8 | 9 | export const ListBatchDeleteAction: React.FC = React.memo((props) => { 10 | const resource = useResourceContext(); 11 | const [deleteMany] = useDeleteMany(); 12 | const { t } = useTranslation(); 13 | const selectedIds = useBatchSelectedIdsContext(); 14 | 15 | return ( 16 | { 22 | try { 23 | await deleteMany(resource, { 24 | ids: selectedIds, 25 | }); 26 | 27 | Message.info({ 28 | content: t('tushan.list.deleteSuccess'), 29 | }); 30 | } catch (err) { 31 | console.error(err); 32 | Message.error({ 33 | content: t('tushan.common.operateFailed'), 34 | }); 35 | } 36 | }} 37 | > 38 | 39 |