feat: init

This commit is contained in:
zuoge 2025-02-26 10:53:26 +08:00
commit 7bbc69aeda
74 changed files with 17494 additions and 0 deletions

41
.cursorrules Normal file
View File

@ -0,0 +1,41 @@
# cursorrules for UMIJS + React + TypeScript + Ant Design
角色模拟
- 您是UMIJS、React、TypeScript、Ant Design及相关Web开发技术的专家
- 您已经熟练的读懂了文档中指出的各项技术的文档
技术选型
- UMIJS@v4 [官方文档](https://umijs.org/docs/guides/getting-started)
- React@v18 [官方文档](https://react.dev/reference/react)
- TypeScript@v5 [官方文档](https://www.typescriptlang.org/docs)
- Ant Design@v5 [官方文档](https://ant.design/docs/react/introduce-cn)
React基本原则
- 组件命名: PascalCase例如 MyComponent.tsx。
- 代码格式: 使用 Prettier 和 ESLint2 空格缩进JSX 双引号TypeScript 单引号JSX 属性驼峰式。
- 组件结构: 每个组件单独文件,优先使用函数组件和 Hooks拆分组件使用 TypeScript 定义 props 类型。
- 状态管理: 使用 valtio避免直接修改 state。
- 事件处理: 事件处理函数驼峰式命名,使用箭头函数。
- 代码注释: 复杂逻辑添加注释,使用 JSDoc 规范。
- 避免不必要的渲染: 使用 React.memo、useMemo 和 useCallback。
- 懒加载: 使用 React.lazy 和 Suspense。
- 虚拟化长列表: 使用 react-window 或 react-virtualized。
TypeScript基本原则
- 尽可能使用类型注解: 显式地声明变量、函数参数和返回值的类型,可以提高代码的可读性和可维护性,并帮助 TypeScript 编译器捕获潜在的错误。
- 避免使用 any 类型: any 类型会绕过 TypeScript 的类型检查,应该尽量避免使用。如果必须使用 any可以考虑使用更具体的类型例如 unknown 或 Record<string, unknown>。
- 使用类型别名和接口: 使用类型别名和接口来定义复杂的类型,可以提高代码的可读性和可维护性。
- 利用类型推断: TypeScript 编译器可以根据上下文推断出变量的类型,因此不需要在所有地方都显式地声明类型。
- 避免过度使用类型断言: 类型断言会覆盖 TypeScript 的类型推断,应该尽量避免使用。如果必须使用类型断言,请确保你完全理解其含义和潜在风险。
- 使用模块化: 将代码组织成模块,可以提高代码的可读性、可维护性和可复用性。
- 使用命名空间: 对于大型项目,可以使用命名空间来组织代码,避免命名冲突。
- 使用严格模式: 启用 TypeScript 的严格模式选项,可以帮助你编写更安全、更健壮的代码。
- 使用代码格式化工具: 使用 Prettier 等工具统一代码格式,保持代码风格一致。
- 使用代码检查工具: 使用 ESLint 等工具进行代码规范检查,避免常见错误。
- 编写单元测试: 编写单元测试可以确保代码的正确性和稳定性。
Ant Design基本原则
第三方包
项目文件及目录

3
.env Normal file
View File

@ -0,0 +1,3 @@
GUARD_NAME=ADMIN
TOKEN_NAME=ADMIN_TOKEN
DEPLOY_BASE_PATH=/

3
.eslintrc.js Normal file
View File

@ -0,0 +1,3 @@
module.exports = {
extends: require.resolve('@umijs/max/eslint'),
};

13
.gitignore vendored Normal file
View File

@ -0,0 +1,13 @@
/node_modules
/.env.local
/.umirc.local.ts
/config/config.local.ts
/src/.umi
/src/.umi-production
/src/.umi-test
/.umi
/.umi-production
/.umi-test
/dist
/.mfsu
.swc

1
.husky/commit-msg Normal file
View File

@ -0,0 +1 @@
npx --no-install max verify-commit $1

1
.husky/pre-commit Normal file
View File

@ -0,0 +1 @@
npx --no-install lint-staged --quiet

17
.lintstagedrc Normal file
View File

@ -0,0 +1,17 @@
{
"*.{md,json}": [
"prettier --cache --write"
],
"*.{js,jsx}": [
"max lint --fix --eslint-only",
"prettier --cache --write"
],
"*.{css,less}": [
"max lint --fix --stylelint-only",
"prettier --cache --write"
],
"*.ts?(x)": [
"max lint --fix --eslint-only",
"prettier --cache --parser=typescript --write"
]
}

2
.npmrc Normal file
View File

@ -0,0 +1,2 @@
registry=https://registry.npmmirror.com/

3
.prettierignore Normal file
View File

@ -0,0 +1,3 @@
node_modules
.umi
.umi-production

8
.prettierrc Normal file
View File

@ -0,0 +1,8 @@
{
"printWidth": 80,
"singleQuote": true,
"trailingComma": "all",
"proseWrap": "never",
"overrides": [{ "files": ".prettierrc", "options": { "parser": "json" } }],
"plugins": ["prettier-plugin-organize-imports", "prettier-plugin-packagejson"]
}

3
.stylelintrc.js Normal file
View File

@ -0,0 +1,3 @@
module.exports = {
extends: require.resolve('@umijs/max/stylelint'),
};

47
.umirc.ts Normal file
View File

@ -0,0 +1,47 @@
import { defineConfig } from '@umijs/max';
const { DEPLOY_BASE_PATH = '/' } = process.env;
export default defineConfig({
layout: {
title: 'AI 智能助手',
logo: '/logo.png',
},
theme: {
'@primary-color': '#1DA57A',
'root-entry-name': 'variable',
},
proxy: {
'/api/': {
target: 'http://0.0.0.0:8000',
changeOrigin: true,
pathRewrite: { '^': '' },
},
},
define: {
'process.env.GUARD_NAME': process.env.GUARD_NAME,
'process.env.TOKEN_NAME': process.env.TOKEN_NAME,
'process.env.DEPLOY_BASE_PATH': process.env.DEPLOY_BASE_PATH,
},
// 通用的
hash: true,
base: DEPLOY_BASE_PATH,
publicPath: DEPLOY_BASE_PATH,
ignoreMomentLocale: true,
fastRefresh: true,
mako: {},
esbuildMinifyIIFE: true,
conventionRoutes: {
exclude: [/\/components\//, /\/modals\//],
},
deadCode: {},
srcTranspiler: 'swc',
antd: {},
access: {},
model: {},
initialState: {},
request: {
dataField: '',
},
valtio: {},
npmClient: 'pnpm',
});

11
Dockerfile Normal file
View File

@ -0,0 +1,11 @@
# 使用官方的 Caddy 镜像作为基础镜像
FROM caddy:2.9.1-alpine
# 将构建后的 dist 目录复制到 Caddy 的默认网站目录
COPY dist /usr/share/caddy
# 复制自定义的 Caddyfile 到 Caddy 的配置目录
COPY docker/Caddyfile /etc/caddy/Caddyfile
# 暴露 Caddy 的默认端口
EXPOSE 80

3
README.md Normal file
View File

@ -0,0 +1,3 @@
# README
`@umijs/max` 模板项目,更多功能参考 [Umi Max 简介](https://umijs.org/docs/max/introduce)

16
build-and-run.sh Executable file
View File

@ -0,0 +1,16 @@
#!/bin/bash
# 设置默认的镜像名称和版本
IMAGE_NAME="umi-test"
IMAGE_VERSION="main"
# 如果存在容器,则删除
if docker ps -a | grep -q $IMAGE_NAME; then
docker rm -f $IMAGE_NAME
fi
# 构建多平台镜像
docker buildx build --platform linux/arm64 --tag $IMAGE_NAME:$IMAGE_VERSION --load .
# 运行容器
docker run -d --name $IMAGE_NAME --restart=always -p 3000:80 $IMAGE_NAME:$IMAGE_VERSION

62
docker/Caddyfile Normal file
View File

@ -0,0 +1,62 @@
:80 {
root * /usr/share/caddy
file_server {
precompressed br gzip
}
encode {
gzip
}
# 安全头配置保持不变
header /* {
X-Content-Type-Options "nosniff"
X-Frame-Options "DENY"
Referrer-Policy "strict-origin-when-cross-origin"
}
# 缓存策略优化
header {
# 默认动态内容不缓存
Cache-Control "no-cache, must-revalidate"
# 静态资源缓存(改进正则表达式)
@static {
path_regexp \.(?:css|js|png|jpe?g|gif|ico|svg|woff2?|ttf|eot|web[pm]|avif)$
}
header @static Cache-Control "public, max-age=31536000, immutable"
}
# SPA路由处理优化通用方案
@spa {
not file
}
rewrite @spa /index.html
# 日志配置(保持精简)
log {
output stdout
format json {
time_format iso8601
}
format filter {
fields {
request>remote_ip delete
request>remote_port delete
request>proto delete
request>method delete
request>headers delete
resp_headers delete
}
wrap json
}
}
# 错误处理
handle_errors {
@404 {
expression {http.error.status_code} == 404
}
respond @404 "Not Found" 404
}
}

20
mock/userAPI.ts Normal file
View File

@ -0,0 +1,20 @@
const users = [
{ id: 0, name: 'Umi', nickName: 'U', gender: 'MALE' },
{ id: 1, name: 'Fish', nickName: 'B', gender: 'FEMALE' },
];
export default {
'GET /api/v1/queryUserList': (req: any, res: any) => {
res.json({
success: true,
data: { list: users },
errorCode: 0,
});
},
'PUT /api/v1/user/': (req: any, res: any) => {
res.json({
success: true,
errorCode: 0,
});
},
};

35
package.json Normal file
View File

@ -0,0 +1,35 @@
{
"private": true,
"author": "zuoge <zuogeus@gmail.com>",
"scripts": {
"build": "max build",
"dev": "max dev",
"format": "prettier --cache --write .",
"postinstall": "max setup",
"prepare": "husky",
"setup": "max setup",
"start": "npm run dev"
},
"dependencies": {
"@ant-design/icons": "^5.0.1",
"@ant-design/pro-components": "^2.4.4",
"@umijs/max": "^4.4.4",
"ahooks": "^3.8.4",
"antd": "^5.4.0",
"radash": "^12.1.0",
"valtio": "^2.1.2"
},
"devDependencies": {
"@swc/core": "^1.3.67",
"@types/react": "^18.0.33",
"@types/react-dom": "^18.0.11",
"husky": "^9",
"lint-staged": "^13.2.0",
"prettier": "^2.8.7",
"prettier-plugin-organize-imports": "^3.2.2",
"prettier-plugin-packagejson": "^2.4.3",
"swc-plugin-auto-css-modules": "^1.5.0",
"typescript": "^5.0.3",
"vitest": "^3.0.5"
}
}

14619
pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

10
src/access.ts Normal file
View File

@ -0,0 +1,10 @@
export default (initialState: API.UserInfo) => {
// 在这里按照初始化数据定义项目中的权限,统一管理
// 参考文档 https://umijs.org/docs/max/access
const canSeeAdmin = !!(
initialState && initialState.name !== 'dontHaveAccess'
);
return {
canSeeAdmin,
};
};

14
src/app.tsx Normal file
View File

@ -0,0 +1,14 @@
// 运行时配置
import { LayoutConfig, requestConfig, state, stateActions } from './common';
export async function getInitialState(): Promise<{ name: string }> {
await stateActions.me();
return {
name: state.session.user?.username ?? '未登录',
};
}
export const layout = LayoutConfig;
export const request = requestConfig;

0
src/assets/.gitkeep Normal file
View File

BIN
src/assets/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

View File

@ -0,0 +1,63 @@
import { ColorPicker, ColorPickerProps } from 'antd';
type Props = ColorPickerProps & {
value?: string;
onChange?: (value: string) => void;
};
export default function MyColorPicker({ onChange, ...rest }: Props) {
return (
<ColorPicker
allowClear
arrow
showText
format="hex"
defaultValue={null}
presets={[
{
label: 'Recommended',
colors: [
'#000000',
'#000000E0',
'#000000A6',
'#00000073',
'#00000040',
'#00000026',
'#0000001A',
'#00000012',
'#0000000A',
'#00000005',
'#F5222D',
'#FA8C16',
'#FADB14',
'#8BBB11',
'#52C41A',
'#13A8A8',
'#1677FF',
'#2F54EB',
'#722ED1',
'#EB2F96',
'#F5222D4D',
'#FA8C164D',
'#FADB144D',
'#8BBB114D',
'#52C41A4D',
'#13A8A84D',
'#1677FF4D',
'#2F54EB4D',
'#722ED14D',
'#EB2F964D',
],
},
{
label: 'Recent',
colors: [],
},
]}
{...rest}
onChange={(color) => {
onChange?.(color.toHexString());
}}
/>
);
}

View File

@ -0,0 +1,41 @@
import {
AuditOutlined,
BankOutlined,
BarChartOutlined,
BarcodeOutlined,
ClusterOutlined,
ConsoleSqlOutlined,
ControlOutlined,
CreditCardOutlined,
DashboardOutlined,
DatabaseOutlined,
FileWordOutlined,
FormOutlined,
HomeOutlined,
SettingOutlined,
ShopOutlined,
ShoppingCartOutlined,
UserOutlined,
} from '@ant-design/icons';
export type MyIconsType = keyof typeof MyIcons;
export const MyIcons = {
AuditOutlined: <AuditOutlined />,
BankOutlined: <BankOutlined />,
BarcodeOutlined: <BarcodeOutlined />,
BarChartOutlined: <BarChartOutlined />,
ClusterOutlined: <ClusterOutlined />,
ConsoleSqlOutlined: <ConsoleSqlOutlined />,
ControlOutlined: <ControlOutlined />,
CreditCardOutlined: <CreditCardOutlined />,
DashboardOutlined: <DashboardOutlined />,
DatabaseOutlined: <DatabaseOutlined />,
FileWordOutlined: <FileWordOutlined />,
FormOutlined: <FormOutlined />,
HomeOutlined: <HomeOutlined />,
SettingOutlined: <SettingOutlined />,
ShopOutlined: <ShopOutlined />,
ShoppingCartOutlined: <ShoppingCartOutlined />,
UserOutlined: <UserOutlined />,
};

View File

@ -0,0 +1,107 @@
import { Apis } from '@/gen/Apis';
import {
FieldTimeOutlined,
LockOutlined,
UserOutlined,
} from '@ant-design/icons';
import {
LoginFormPage,
ProConfigProvider,
ProFormText,
} from '@ant-design/pro-components';
import { useMemoizedFn, useRequest } from 'ahooks';
import { stateActions } from '../../../libs/valtio/actions';
export default function MyLoginPage() {
const pageReq = useRequest(Apis.Auth.Captcha);
const loginReq = useRequest(stateActions.login, { manual: true });
const handleLogin = useMemoizedFn((values: ApiReqTypes.Auth.Login) => {
loginReq.run({
...values,
captcha_key: pageReq.data?.data?.key ?? '',
});
});
return (
<ProConfigProvider>
<div
style={{
backgroundColor: '#f8f8f8',
height: '100vh',
}}
>
<LoginFormPage<ApiReqTypes.Auth.Login>
loading={loginReq.loading}
title="欢迎使用后台管理系统"
backgroundVideoUrl="https://gw.alipayobjects.com/v/huamei_gcee1x/afts/video/jXRBRK_VAwoAAAAAAAAAAAAAK4eUAQBr"
subTitle="Admin management system"
onFinish={handleLogin}
>
<>
<ProFormText
name="username"
fieldProps={{
size: 'large',
prefix: <UserOutlined className={'prefixIcon'} />,
}}
placeholder={'登录账号'}
rules={[
{
required: true,
message: '请输入登录账号!',
},
]}
/>
<ProFormText.Password
name="password"
fieldProps={{
size: 'large',
prefix: <LockOutlined className={'prefixIcon'} />,
}}
placeholder={'登录密码'}
rules={[
{
required: true,
message: '请输入密码!',
},
]}
/>
<div style={{ display: 'flex' }}>
<ProFormText
name="captcha"
fieldProps={{
size: 'large',
prefix: <FieldTimeOutlined className={'prefixIcon'} />,
}}
placeholder={'验证码'}
rules={[
{
required: true,
message: '请输入验证码!',
},
]}
/>
<div
style={{
height: '40px',
border: '1px solid #eee',
padding: '0 2px',
margin: '0 10px',
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
borderRadius: '3px',
cursor: 'pointer',
}}
onClick={pageReq.run}
>
<img height={32} width={100} src={pageReq.data?.data?.img} />
</div>
</div>
</>
</LoginFormPage>
</div>
</ProConfigProvider>
);
}

View File

@ -0,0 +1,15 @@
import { PlusOutlined } from '@ant-design/icons';
import type { ButtonProps } from 'antd';
import { Button } from 'antd';
interface AddButtonProps extends ButtonProps {
title: string;
}
export default function AddButton({ title, ...props }: AddButtonProps) {
return (
<Button type="primary" icon={<PlusOutlined />} {...props}>
{`添加${title}`}
</Button>
);
}

View File

@ -0,0 +1,32 @@
import { DeleteOutlined, WarningOutlined } from '@ant-design/icons';
import type { ButtonProps } from 'antd';
import { Button, Popconfirm } from 'antd';
interface DeleteButtonProps extends ButtonProps {
title?: string;
onConfirm: () => void;
}
export default function DeleteButton({
title,
onConfirm,
...props
}: DeleteButtonProps) {
return (
<Popconfirm
title={title || '确定要删除吗?'}
icon={<WarningOutlined style={{ color: 'red' }} />}
onConfirm={onConfirm}
>
<Button
size="small"
danger
type="link"
icon={<DeleteOutlined />}
{...props}
>
{title || '删除'}
</Button>
</Popconfirm>
);
}

View File

@ -0,0 +1,15 @@
import { EditOutlined } from '@ant-design/icons';
import type { ButtonProps } from 'antd';
import { Button } from 'antd';
interface EditButtonProps extends ButtonProps {
title?: string;
}
export default function EditButton({ title, ...props }: EditButtonProps) {
return (
<Button size="small" type="link" icon={<EditOutlined />} {...props}>
{title || '编辑'}
</Button>
);
}

View File

@ -0,0 +1,5 @@
import { Spin } from 'antd';
export default function LoadingSpin() {
return <Spin />;
}

View File

@ -0,0 +1,2 @@
export const LOGIN_PATH = process.env.DEPLOY_BASE_PATH + 'login';
export const HOME_PATH = process.env.DEPLOY_BASE_PATH || '/';

View File

@ -0,0 +1 @@

22
src/common/index.ts Normal file
View File

@ -0,0 +1,22 @@
// components
export { default as MyLoginPage } from './components/biz/MyLoginPage';
export { default as AddButton } from './components/buttons/AddButton';
export { default as DeleteButton } from './components/buttons/DeleteButton';
export { default as EditButton } from './components/buttons/EditButton';
export * from './components/MyIcons';
export { default as MyColorPicker } from './components/DataEntry/MyColorPicker';
// constants
export * from './constants';
// utils
export * from './libs/umi/request/downloadFile';
export * from './utils/common';
// libs
export * from './libs/valtio/actions';
export * from './libs/valtio/state';
export * from './libs/umi/layout';
export * from './libs/umi/request';

View File

@ -0,0 +1,29 @@
import { history } from '@umijs/max';
import { Dropdown, MenuProps } from 'antd';
import { stateActions } from '../../valtio/actions';
export default function AvatarDropdown({
children,
}: {
children: React.ReactNode;
}) {
const items: MenuProps['items'] = [
{
key: '1',
label: '修改密码',
onClick: () => {
history.push('/user/password');
},
},
{
key: '2',
danger: true,
label: '退出登录',
onClick: () => {
stateActions.logout();
},
},
];
return <Dropdown menu={{ items }}>{children}</Dropdown>;
}

View File

@ -0,0 +1,37 @@
import { UserOutlined } from '@ant-design/icons';
import { history, RuntimeConfig } from '@umijs/max';
import { Avatar } from 'antd';
import { useMyState } from '../../valtio/state';
import AvatarDropdown from './AvatarDropdown';
import { loopMenu } from './loopMenu';
export const LayoutConfig: RuntimeConfig['layout'] = () => {
const { snap } = useMyState();
return {
layout: 'mix',
colorPrimary: '#1890ff',
siderWidth: 220,
pure: history.location.pathname === process.env.DEPLOY_BASE_PATH + 'login',
actionsRender: () => [],
avatarProps: {
src: (
<Avatar
style={{ backgroundColor: '#87d068' }}
icon={<UserOutlined />}
/>
),
title: snap.session.user?.username,
render: (_, avatarChildren) => {
return <AvatarDropdown>{avatarChildren}</AvatarDropdown>;
},
},
waterMarkProps: {
content: snap.session.user?.username,
},
menu: {
params: snap.session.permissions,
request: async () => loopMenu(snap.session.permissions),
},
};
};

View File

@ -0,0 +1,42 @@
import { MyIcons, MyIconsType } from "@/common/components/MyIcons";
import { sort } from "radash";
type PermissionsType = any[]//ApiRespTypes.Auth.Login['permissions']
// TODO 可以换成umi的route类型
type RouteType = {
path: string | undefined;
name: string;
icon: React.ReactNode;
hideInMenu: boolean;
children: RouteType[];
}
export const loopMenu = (permissions: PermissionsType) => {
// 排序一下
sort(permissions, (f: PermissionsType[0]) => f._lft)
let tree: RouteType[] = [];
let map: Record<number, RouteType> = {};
permissions?.forEach((permission) => {
map[permission.id] = {
path: permission.type === 'Button' ? '' : permission.path,
name: permission.name,
icon: permission.icon && MyIcons[permission.icon as MyIconsType],
hideInMenu: permission.type === 'Button',
children: [],
};
});
permissions?.forEach((permission) => {
let node = map[permission.id];
if (permission.parent_id !== null) {
map[permission.parent_id].children.push(node);
} else {
tree.push(node);
}
});
return tree?.[0]?.children;
};

View File

@ -0,0 +1,24 @@
export const downloadFile = (disposition: string, data: any) => {
const blob = new Blob([data]);
const start = "filename*=utf-8''";
let fileName = '';
let dis = disposition.replace('UTF-8', 'utf-8');
if (dis.includes(start)) {
fileName = dis.substr(dis.indexOf(start) + start.length);
fileName = decodeURI(fileName);
}
if ('download' in document.createElement('a')) {
// 非IE下载
const elink: any = document.createElement('a');
elink.download = fileName;
elink.style.display = 'none';
elink.href = URL.createObjectURL(blob);
document.body.appendChild(elink);
elink.click();
URL.revokeObjectURL(elink.href); // 释放URL 对象
document.body.removeChild(elink);
} else {
// IE10+下载
// navigator.msSaveBlob(blob, fileName);
}
};

View File

@ -0,0 +1,116 @@
import { LOGIN_PATH } from '@/common/constants';
import type { RequestConfig } from '@umijs/max';
import { history } from '@umijs/max';
import { message } from 'antd';
import { state } from '../../valtio/state';
import { downloadFile } from './downloadFile';
export const requestConfig: RequestConfig = {
baseURL: '/api/',
timeout: 1000 * 60,
method: 'POST',
headers: { 'X-Requested-With': 'XMLHttpRequest' },
errorConfig: {
errorThrower: (res) => {
// console.log('errorThrower', res);
// 只要 success === false就会进这里
const { success, data, errorCode, errorMessage, showType } = res;
if (!success) {
const error: any = new Error(errorMessage);
error.name = 'BizError';
error.info = { errorCode, errorMessage, showType, data };
throw error; // 抛出自制的错误
}
},
// 错误接收及处理
errorHandler: (error: any, opts: any) => {
// console.log('errorHandler', error);
// 这里会处理所有的错误
// 如果 opts.skipErrorHandler 为 true则抛出错误
if (opts?.skipErrorHandler) throw error;
// 我们的 errorThrower 抛出的错误。
if (error.name === 'BizError') {
const errorInfo: ResponseStructure | undefined = error.info;
if (errorInfo) {
const { errorMessage, errorCode } = errorInfo;
// 如果错误码为10000并且当前路径不是登录页则跳转到登录页
if (errorCode === 10000) {
if (history.location.pathname !== LOGIN_PATH) {
history.push(LOGIN_PATH);
}
} else {
// 其他情况默认显示错误信息
message.error(errorMessage);
}
// switch (errorInfo.showType) {
// case ErrorShowType.SILENT:
// // do nothing
// break;
// case ErrorShowType.WARN_MESSAGE:
// message.warning(errorMessage);
// break;
// case ErrorShowType.ERROR_MESSAGE:
// message.error(errorMessage);
// break;
// case ErrorShowType.NOTIFICATION:
// notification.open({
// description: errorMessage,
// message: errorCode,
// });
// break;
// case ErrorShowType.REDIRECT:
// // TODO: redirect
// break;
// default:
// message.error(errorMessage);
// }
}
} else if (error.response) {
// Axios 的错误
// 请求成功发出且服务器也响应了状态码,但状态代码超出了 2xx 的范围
message.error(`Response status:${error.response.status}`);
} else if (error.request) {
// 请求已经成功发起,但没有收到响应
// \`error.request\` 在浏览器中是 XMLHttpRequest 的实例,
// 而在node.js中是 http.ClientRequest 的实例
message.error('None response! Please retry.');
} else {
// 发送请求时出了点问题
message.error('Request error, please retry.');
}
},
},
// 请求拦截器
requestInterceptors: [
(url: string, options: any) => {
return {
url: url,
options: {
...options,
headers: {
authorization: 'Bearer ' + state.storage.access_token,
},
},
};
},
],
// 响应拦截器
responseInterceptors: [
(response: any) => {
const { headers } = response;
// 拦截并处理下载文件
if (headers['content-disposition']) {
downloadFile(headers['content-disposition'], response.data);
return false;
}
return response;
},
],
};

View File

@ -0,0 +1,31 @@
import { HOME_PATH, LOGIN_PATH } from '@/common/constants';
import { Apis } from '@/gen/Apis';
import { history } from '@umijs/max';
import { state } from './state';
export const stateActions = {
me: async () => {
Apis.Auth.Me().then(res => {
state.session.user = res.data?.user;
state.session.permissions = res.data?.permissions;
if (history.location.pathname === LOGIN_PATH) {
history.push(HOME_PATH);
}
});
},
login: async (params: ApiReqTypes.Auth.Login) => {
// await sleep(1000);
Apis.Auth.Login(params).then(res => {
state.session.user = res.data?.user;
state.session.permissions = res.data?.permissions;
state.storage.access_token = res.data?.access_token;
history.push(HOME_PATH);
});
},
logout() {
state.session.user = undefined;
state.session.permissions = undefined;
state.storage.access_token = undefined;
history.push(LOGIN_PATH);
},
};

View File

@ -0,0 +1,32 @@
import { proxy, useSnapshot } from 'valtio';
import { proxyWithPersistant } from './utils';
export const state: {
storage: {
access_token: string | undefined;
};
session: {
user?: any;
permissions?: any;
apiKeys: string[];
};
} = proxy({
storage: proxyWithPersistant(
{
access_token: undefined,
},
{
key: process.env.TOKEN_NAME as string,
},
),
session: proxy({
user: undefined,
permissions: undefined,
apiKeys: [],
}),
});
export function useMyState() {
const snap = useSnapshot(state);
return { snap };
}

View File

@ -0,0 +1,15 @@
import { proxy, snapshot, subscribe } from 'valtio';
export function proxyWithPersistant<V>(
val: V,
opts: {
key: string;
},
) {
const local = localStorage.getItem(opts.key);
const state = proxy(local ? JSON.parse(local) : val);
subscribe(state, () => {
localStorage.setItem(opts.key, JSON.stringify(snapshot(state)));
});
return state;
}

26
src/common/types.d.ts vendored Normal file
View File

@ -0,0 +1,26 @@
// 错误处理方案: 错误类型
enum ErrorShowType {
SILENT = 0,
WARN_MESSAGE = 1,
ERROR_MESSAGE = 2,
NOTIFICATION = 3,
REDIRECT = 9,
}
// 与后端约定的响应数据格式
type ResponseStructure<T = undefined> = {
success: boolean;
data?: T;
errorCode?: number;
errorMessage?: string;
errorDetail?: any;
showType?: ErrorShowType;
meta?: ResponseMetaStructure;
};
type ResponseMetaStructure = {
current_page: number;
last_page: number;
per_page: number;
total: number;
};

View File

@ -0,0 +1,13 @@
export function sleep(ms: number): Promise<void> {
return new Promise<void>((resolve) => {
setTimeout(resolve, ms);
});
}
export function wrapTableResp(data: ResponseStructure) {
return {
data: data.data,
success: data.success,
total: data.meta?.total || 0,
};
}

View File

@ -0,0 +1 @@

View File

@ -0,0 +1,34 @@
import { useSysPermissionsDataSource } from '@/hooks/dataSources';
import { ProFormColumnsType } from '@ant-design/pro-components';
/**
*
* @param guardName
* @returns
*/
export const PermissionTreeSelect = (
guardName: string,
): ProFormColumnsType<any, 'treeSelect'> => {
const { data } = useSysPermissionsDataSource({ guard_name: guardName });
return {
key: 'parent_id',
title: '上级菜单',
valueType: 'treeSelect',
request: async () => data || [],
fieldProps: {
rowKey: 'id',
allowClear: true,
autoClearSearchValue: false,
bordered: true,
fieldNames: {
label: 'name',
value: 'id',
},
filterTreeNode: false,
showSearch: false,
treeNodeFilterProp: 'title',
treeDefaultExpandAll: true,
},
};
};

127
src/gen/ApiReqTypes.d.ts vendored Normal file
View File

@ -0,0 +1,127 @@
declare namespace ApiReqTypes {
namespace Select {}
namespace SysRoles {
type List = {
guard_name?: string; // 守护者,[enum:GuardNameEnum]
};
type Store = {
name: string; // 名称
guard_name?: string; // 守护者,[enum:GuardNameEnum]
color?: string;
};
type Update = {
id: number; // ID
name: string; // 名称
guard_name?: string; // 守护者,[enum:GuardNameEnum]
color?: string;
};
type Delete = {
id: number;
};
type GetPermissions = {
id: number; // ID
guard_name?: string; // 守护者,[enum:GuardNameEnum]
};
type SetPermissions = {
id: number; // ID
guard_name?: string; // 守护者,[enum:GuardNameEnum]
permissions_ids: any[];
};
}
namespace Bosses {
type List = {
name1?: string; // 模糊搜索名称1
};
type Store = {
name: string; // 简称
full_name?: string; // 全称
address?: string; // 地址
contact?: string; // 联系人
phone?: string; // 联系电话
};
type Update = {
id: number; // id
name: string; // 简称
full_name?: string; // 全称
address?: string; // 地址
contact?: string; // 联系人
phone?: string; // 联系电话
};
type Delete = {
id: number; // id
};
}
namespace Auth {
type Login = {
username: string; // 用户名
password: string; // 密码
captcha: string; // 验证码
captcha_key: string; // 验证码key
};
type ChangePassword = {
old_password: string; // 老密码
new_password: string; // 新密码
re_new_password: string; // 重复新密码
};
type PreUpload = {
filename: string; // 文件名称
};
}
namespace SysPermissions {
type List = {
parent_id?: number; // 上级ID
guard_name: string; // 守护者,[enum:GuardNameEnum]
};
type Store = {
name: string;
guard_name: string; // 守护者,[enum:GuardNameEnum]
key?: string; // 前端权限识别符
icon?: string; // 图标
type?: string; // 类型,[enum:SysPermissionsTypeEnum]
backend_apis?: any[]; // 后台api
path?: string; // 路由
parent_id?: number;
};
type Update = {
id: number; // ID
name: string;
key?: string;
guard_name: string;
icon?: string; // 图标
type: string; // 类型:SysPermissionsTypeEnum
backend_apis?: any[]; // 后台api
path?: string; // 路由
parent_id?: number;
};
type Delete = {
id: number; // ID
};
type Move = {
id: number; // ID
type: string; // 类型up 升级down 降级
};
}
namespace Admins {
type List = {
username?: string; // 模糊搜索:名称
};
type Store = {
username: string; // 用户名
password: string; // 密码hidden
last_login_at?: Date; // 最后登录时间
last_login_ip?: string; // 最后登录IP
is_locked: boolean; // 是否锁定
};
type Update = {
id: number; // id
username: string; // 用户名
password: string; // 密码hidden
last_login_at?: Date; // 最后登录时间
last_login_ip?: string; // 最后登录IP
is_locked: boolean; // 是否锁定
};
type Delete = {
id: number; // id
};
}
}

59
src/gen/ApiRespTypes.d.ts vendored Normal file
View File

@ -0,0 +1,59 @@
declare namespace ApiRespTypes {
namespace Select {
}
namespace SysRoles {
type List = {
id: number; // ID
name: string; // 名称
guard_name: string; // 守卫名称
created_at: string; // 创建时间
updated_at: string; // 更新时间
color: string; // 颜色
}[];
}
namespace Bosses {
}
namespace Auth {
}
namespace SysPermissions {
type List = {
id: number; // ID
name: string; // 名称
guard_name: string; // 守卫名称
created_at: string; // 创建时间
updated_at: string; // 更新时间
key: string; // 键
icon: string; // 图标
type: string; // 类型
backend_apis: string[]; // 后端API
path: string; // 路径
_lft: number; // 左值
_rgt: number; // 右值
parent_id: number; // 父级ID
children: {
id: number; // ID
name: string; // 名称
guard_name: string; // 守卫名称
created_at: string; // 创建时间
updated_at: string; // 更新时间
key: string; // 键
icon: string; // 图标
type: string; // 类型
backend_apis: string[]; // 后端API
path: string; // 路径
_lft: number; // 左值
_rgt: number; // 右值
parent_id: number; // 父级ID
children: undefined[]; // 子节点
}[]; // 子节点
}[];
}
namespace Admins {
type List = {
id: number; // ID
username: string; // 用户名
created_at: string; // 创建时间
updated_at: string; // 更新时间
}[];
}
}

94
src/gen/Apis.ts Normal file
View File

@ -0,0 +1,94 @@
import { request } from "@umijs/max";
export const Apis = {
Select: {
SysRoles() {
return request('/admin/select/sys_roles', {});
},
},
SysRoles: {
List<TReq = ApiReqTypes.SysRoles.List, TResp = ApiRespTypes.SysRoles.List>(data?: TReq) {
return request<ResponseStructure<TResp>>('/admin/sys_roles/list', { data });
},
Store<TReq = ApiReqTypes.SysRoles.Store>(data: TReq) {
return request('/admin/sys_roles/store', { data });
},
Update<TReq = ApiReqTypes.SysRoles.Update>(data: TReq) {
return request('/admin/sys_roles/update', { data });
},
Delete<TReq = ApiReqTypes.SysRoles.Delete>(data: TReq) {
return request('/admin/sys_roles/delete', { data });
},
GetPermissions<TReq = ApiReqTypes.SysRoles.GetPermissions>(data: TReq) {
return request('/admin/sys_roles/get_permissions', { data });
},
SetPermissions<TReq = ApiReqTypes.SysRoles.SetPermissions>(data: TReq) {
return request('/admin/sys_roles/set_permissions', { data });
},
},
Bosses: {
List<TReq = ApiReqTypes.Bosses.List>(data: TReq) {
return request('/admin/bosses/list', { data });
},
Store<TReq = ApiReqTypes.Bosses.Store>(data: TReq) {
return request('/admin/bosses/store', { data });
},
Update<TReq = ApiReqTypes.Bosses.Update>(data: TReq) {
return request('/admin/bosses/update', { data });
},
Delete<TReq = ApiReqTypes.Bosses.Delete>(data: TReq) {
return request('/admin/bosses/delete', { data });
},
},
Auth: {
Captcha() {
return request('/admin/auth/captcha', {});
},
Login<TReq = ApiReqTypes.Auth.Login>(data: TReq) {
return request('/admin/auth/login', { data });
},
Logout() {
return request('/admin/auth/logout', {});
},
Me() {
return request('/admin/auth/me', {});
},
ChangePassword<TReq = ApiReqTypes.Auth.ChangePassword>(data: TReq) {
return request('/admin/auth/change_password', { data });
},
PreUpload<TReq = ApiReqTypes.Auth.PreUpload>(data: TReq) {
return request('/admin/auth/pre_upload', { data });
},
},
SysPermissions: {
List<TReq = ApiReqTypes.SysPermissions.List, TResp = ApiRespTypes.SysPermissions.List>(data: TReq) {
return request<ResponseStructure<TResp>>('/admin/sys_permissions/list', { data });
},
Store<TReq = ApiReqTypes.SysPermissions.Store>(data: TReq) {
return request('/admin/sys_permissions/store', { data });
},
Update<TReq = ApiReqTypes.SysPermissions.Update>(data: TReq) {
return request('/admin/sys_permissions/update', { data });
},
Delete<TReq = ApiReqTypes.SysPermissions.Delete>(data: TReq) {
return request('/admin/sys_permissions/delete', { data });
},
Move<TReq = ApiReqTypes.SysPermissions.Move>(data: TReq) {
return request('/admin/sys_permissions/move', { data });
},
},
Admins: {
List<TReq = ApiReqTypes.Admins.List>(data: TReq) {
return request('/admin/admins/list', { data });
},
Store<TReq = ApiReqTypes.Admins.Store>(data: TReq) {
return request('/admin/admins/store', { data });
},
Update<TReq = ApiReqTypes.Admins.Update>(data: TReq) {
return request('/admin/admins/update', { data });
},
Delete<TReq = ApiReqTypes.Admins.Delete>(data: TReq) {
return request('/admin/admins/delete', { data });
},
},
};

25
src/gen/Enums.ts Normal file
View File

@ -0,0 +1,25 @@
// 模块类型
export const GuardNameEnum = {
ADMIN: { text: '平台', color: '#007BFF', value: 'ADMIN' },
CUSTOMER: { text: '机构', color: '#FFC107', value: 'CUSTOMER' },
};
// 管理员状态
export const AdminsStatusEnum = {
ENABLED: { text: '启用', color: '#008000', value: 'ENABLED' },
DISABLED: { text: '禁用', color: '#808080', value: 'DISABLED' },
};
// 系统权限类型
export const SysPermissionsTypeEnum = {
DIRECTORY: { text: '目录', color: '#0000FF', value: 'DIRECTORY' },
PAGE: { text: '页面', color: '#FFA500', value: 'PAGE' },
BUTTON: { text: '按钮', color: '#008000', value: 'BUTTON' },
};
// 状态
export const EnableEnum = {
1: { text: '是', color: '#008000', value: 1 },
0: { text: '否', color: '#808080', value: 0 },
};

41
src/hooks/dataSources.ts Normal file
View File

@ -0,0 +1,41 @@
import { Apis } from '@/gen/Apis';
import { useRequest } from 'ahooks';
export enum DataSourceType {
SysPermissions = 'SysPermissions',
SysRoles = 'SysRoles'
}
/**
*
*/
export const useSysPermissionsDataSource = (params: ApiReqTypes.SysPermissions.List) => {
return useRequest(
async () => {
const response = await Apis.SysPermissions.List(params);
return response.data || [];
},
{
// manual: true,
cacheKey: `${DataSourceType.SysPermissions}-${params.guard_name}`,
staleTime: -1, // 10秒内不重新请求
// cacheTime: 5 * 60 * 1000, // 缓存保持5分钟
},
);
};
export const useSysRolesDataSource = () => {
return useRequest(
async () => {
const response = await Apis.SysRoles.List();
return response.data || [];
},
{
// manual: true,
cacheKey: `${DataSourceType.SysRoles}`,
staleTime: -1, // 10秒内不重新请求
// cacheTime: 5 * 60 * 1000, // 缓存保持5分钟
},
);
};

View File

@ -0,0 +1,14 @@
import { describe, expect, it } from 'vitest';
describe('CLI', () => {
it('应该能正确执行命令', () => {
const params = new URLSearchParams({
a: '1',
b: '2',
c: '3',
}).toString();
console.log('params', params);
expect(true).toBe(true);
});
});

109
src/hooks/useParams.ts Normal file
View File

@ -0,0 +1,109 @@
import { useSearchParams } from '@umijs/max';
import { SortOrder } from 'antd/es/table/interface';
import { useEffect, useState } from 'react';
export default function useParams<T>({ defaultParams }: { defaultParams: T }) {
const [ready, setReady] = useState(false);
const [searchParams, setSearchParams] = useSearchParams(); // 浏览器参数
const [apiParams, setApiParams] = useState<T & Record<string, any>>(); // 请求参数
const [antParams, setAntParams] = useState<
T & { current?: number; pageSize?: number } & Record<string, any>
>(); // 表格参数
const antParamsToSearchParams = ({
params,
sort,
filter,
}: {
params: T & { pageSize?: number; current?: number };
sort: Record<string, SortOrder>;
filter: Record<string, (string | number)[] | null>;
}) => {
const { current = 1, pageSize = 20, ...rest } = params;
// 构造 p 参数,只包含非默认值
const pPayload: Record<string, any> = {};
if (current !== 1) {
pPayload.page = current;
}
if (pageSize !== 20) {
pPayload.perPage = pageSize;
}
if (Object.keys(rest).length > 0) {
Object.assign(pPayload, rest);
}
const data: Record<string, string> = {};
if (Object.keys(pPayload).length > 0) {
data.p = JSON.stringify(pPayload);
}
if (sort && Object.keys(sort).length > 0) {
data.s = JSON.stringify(sort);
}
if (filter && Object.keys(filter).length > 0) {
data.f = JSON.stringify(filter);
}
setSearchParams(data);
};
const antParamsToApiParams = ({
params,
sort,
filter,
}: {
params: T & { pageSize?: number; current?: number };
sort: Record<string, SortOrder>;
filter: Record<string, (string | number)[] | null>;
}) => {
const { current = 1, pageSize = 20, ...rest } = params;
const data: Record<string, any> = {};
// 如果 current 不是默认值,则添加 page
if (current !== 1) {
data.page = current;
}
// 如果 pageSize 不是默认值,则添加 perPage
if (pageSize !== 20) {
data.perPage = pageSize;
}
// 如果 rest 不为空,则合并 rest 到 data 中
if (Object.keys(rest).length > 0) {
Object.assign(data, rest);
}
// 仅当 sort 不为空时合并 sort
if (sort && Object.keys(sort).length > 0) {
Object.assign(data, sort);
}
// 仅当 filter 不为空时合并 filter
if (filter && Object.keys(filter).length > 0) {
Object.assign(data, filter);
}
return data;
};
const getApiParams = (params: {
params: T & { pageSize?: number; current?: number };
sort: Record<string, SortOrder>;
filter: Record<string, (string | number)[] | null>;
}) => {
antParamsToSearchParams(params);
const data = antParamsToApiParams(params);
setApiParams(data as T & Record<string, any>);
return data;
};
useEffect(() => {
console.log('searchParams', searchParams, defaultParams);
setReady(true);
}, [searchParams, defaultParams]);
return {
ready,
apiParams,
getApiParams,
};
}

28
src/pages/index.tsx Normal file
View File

@ -0,0 +1,28 @@
import { Apis } from '@/gen/Apis';
import { useRequest } from '@umijs/max';
import { Button } from 'antd';
export default function Index() {
const { data, loading, run } = useRequest(
async () => {
return Apis.Auth.Me()
.then((res) => {
console.log('res', res);
return res;
})
.catch((err) => {
console.log('err', err);
return err;
});
},
{
manual: true,
},
);
return (
<Button onClick={run} loading={loading}>
{data?.data?.user?.username ?? 'Click Me'}
</Button>
);
}

5
src/pages/login.tsx Normal file
View File

@ -0,0 +1,5 @@
import { MyLoginPage } from '@/common';
export default function Login() {
return <MyLoginPage />;
}

View File

@ -0,0 +1,152 @@
import { DeleteButton, wrapTableResp } from '@/common';
import { Apis } from '@/gen/Apis';
import { EnableEnum } from '@/gen/Enums';
import useParams from '@/hooks/useParams';
import {
ActionType,
PageContainer,
ProFormInstance,
ProTable,
} from '@ant-design/pro-components';
import { useMemoizedFn } from 'ahooks';
import { Space } from 'antd';
import { useEffect, useRef } from 'react';
import Create from './modals/Create';
import Update from './modals/Update';
type TableType = ApiRespTypes.Admins.List;
export default function Index() {
const title = '管理员';
const ref = useRef<ActionType>();
const formRef = useRef<ProFormInstance>();
const { ready, getApiParams } = useParams<ApiReqTypes.Admins.List>({
defaultParams: {
username: '123',
},
});
const handleDelete = useMemoizedFn((entity: TableType[0]) => {
Apis.SysRoles.Delete({ id: entity.id }).then(() => {
ref.current?.reload();
});
});
useEffect(() => {
formRef.current?.setFieldsValue({
username: '123',
});
}, [ready]);
return !ready ? (
<></>
) : (
<PageContainer>
<ProTable<TableType[0]>
actionRef={ref}
formRef={formRef}
scroll={{ x: 'max-content', scrollToFirstRowOnChange: true }}
bordered
size="small"
rowKey="id"
options={false}
toolBarRender={(action) => [
<Create
key="Create"
onFinish={() => action?.reload()}
title={title}
/>,
]}
request={async (params, sort, filter) =>
wrapTableResp(
await Apis.Admins.List(
getApiParams({
params,
sort,
filter,
}),
),
)
}
// dataSource={data}
columns={[
{
title: 'ID',
dataIndex: 'id',
hideInSearch: true,
},
{
title: '用户名',
dataIndex: 'username',
},
{
title: '是否锁定',
dataIndex: 'is_locked',
valueEnum: EnableEnum,
},
{
title: '创建时间',
dataIndex: 'created_at',
valueType: 'dateTime',
hideInSearch: true,
},
{
title: '创建时间',
dataIndex: 'created_at',
valueType: 'dateTime',
hideInSearch: true,
},
{
title: '创建时间',
dataIndex: 'created_at',
valueType: 'dateTime',
hideInSearch: true,
},
{
title: '创建时间',
dataIndex: 'created_at',
valueType: 'dateTime',
hideInSearch: true,
},
{
title: '创建时间',
dataIndex: 'created_at',
valueType: 'dateTime',
hideInSearch: true,
},
{
title: '创建时间',
dataIndex: 'created_at',
valueType: 'dateTime',
hideInSearch: true,
},
{
title: '更新时间',
dataIndex: 'updated_at',
valueType: 'dateTime',
hideInSearch: true,
},
{
title: '操作',
fixed: 'right',
width: '150px',
hideInSearch: true,
render: (_1, entity, _2, action) => {
return (
<Space key={entity.id}>
<Update
item={entity}
title={title}
onFinish={() => action?.reload()}
/>
<DeleteButton onConfirm={() => handleDelete(entity)} />
</Space>
);
},
},
]}
/>
</PageContainer>
);
}

View File

@ -0,0 +1,60 @@
import {
ActionType,
ProFormInstance,
ProTable,
} from '@ant-design/pro-components';
import { Button } from 'antd';
import { useRef } from 'react';
const MyComponent = () => {
const formRef = useRef<ProFormInstance>(); // 表单引用
const actionRef = useRef<ActionType>(); // 表格操作引用
// 动态设置搜索表单值的函数
const setSearchValues = () => {
if (formRef.current) {
formRef.current?.setFieldsValue({
name: '新名称',
status: 'active',
});
// 可选:提交表单以触发搜索
formRef.current?.submit();
// 或手动刷新表格
actionRef.current?.reload();
}
};
return (
<>
<Button onClick={setSearchValues}></Button>
<ProTable
formRef={formRef}
actionRef={actionRef}
columns={[
{
title: '名称',
dataIndex: 'name',
key: 'name',
},
{
title: '状态',
dataIndex: 'status',
key: 'status',
valueType: 'select',
valueEnum: {
active: { text: '活跃' },
inactive: { text: '禁用' },
},
},
]}
request={async (params) => {
// 根据表单参数发起请求
console.log('请求参数:', params);
return { data: [], success: true };
}}
/>
</>
);
};
export default MyComponent;

View File

@ -0,0 +1,169 @@
import { DeleteButton } from '@/common';
import { Apis } from '@/gen/Apis';
import { EnableEnum } from '@/gen/Enums';
import {
BetaSchemaForm,
PageContainer,
ProColumns,
ProTable,
} from '@ant-design/pro-components';
import { useSearchParams } from '@umijs/max';
import { useMemoizedFn, useRequest } from 'ahooks';
import { Space } from 'antd';
import { useMemo, useState } from 'react';
import Update from './modals/Update';
type SearchType = ApiReqTypes.Admins.List;
type TableType = ApiRespTypes.Admins.List;
interface TableParams<T> {
s?: T;
p?: {
page?: number;
perPage?: number;
};
o?: {
field: string;
order: 'asc' | 'desc';
};
}
export default function AdminList() {
const [searchParams, setSearchParams] = useSearchParams();
const [params, setParams] = useState<TableParams<SearchType>>({
s: JSON.parse(searchParams.get('s') || '{}'),
p: JSON.parse(searchParams.get('p') || '{}'),
o: JSON.parse(searchParams.get('o') || '{}'),
});
const title = '管理员';
const { data, run } = useRequest(
() =>
Apis.Admins.List({
...params.s,
...params.p,
...params.o,
}),
{
refreshDeps: [params],
},
);
const handleDelete = useMemoizedFn((entity: TableType[0]) => {
Apis.SysRoles.Delete({ id: entity.id }).then(() => {
run();
});
});
const columns = useMemo<ProColumns<TableType[0]>[]>(
() => [
{
dataIndex: 'id',
title: 'ID',
},
{
title: '用户名',
dataIndex: 'username',
},
{
title: '是否锁定',
dataIndex: 'is_locked',
valueEnum: EnableEnum,
},
{
title: '创建时间',
dataIndex: 'created_at',
valueType: 'dateTime',
hideInSearch: true,
},
{
title: '更新时间',
dataIndex: 'updated_at',
valueType: 'dateTime',
hideInSearch: true,
},
{
title: '操作',
fixed: 'right',
width: '150px',
hideInSearch: true,
render: (_1, entity, _2, action) => (
<Space key={entity.id}>
<Update
item={entity}
title={title}
onFinish={() => action?.reload()}
/>
<DeleteButton onConfirm={() => handleDelete(entity)} />
</Space>
),
},
],
[],
);
const handleSearch = (values: SearchType) => {
const newParams = {
...params,
s: values,
};
setParams(newParams);
setSearchParams({
...searchParams,
s: JSON.stringify(values),
});
};
const handlePaginationChange = (page: number, pageSize: number) => {
setParams({
...params,
p: {
page,
perPage: pageSize,
},
});
};
return (
<PageContainer>
<BetaSchemaForm<SearchType>
style={{ backgroundColor: 'white' }}
layoutType="QueryFilter"
initialValues={params.s}
columns={[
{
key: 'username',
title: '用户名',
},
]}
onReset={() => {
setParams({
...params,
s: {},
});
setSearchParams({
...searchParams,
s: JSON.stringify({}),
});
}}
onFinish={handleSearch}
/>
<ProTable<TableType[0]>
scroll={{ x: 'max-content', scrollToFirstRowOnChange: true }}
bordered
size="small"
rowKey="id"
dataSource={data?.data}
search={false}
pagination={{
current: params.p?.page || 1,
total: data?.meta?.total || 100,
pageSize: params.p?.perPage || 10,
onChange: handlePaginationChange,
}}
columns={columns}
/>
</PageContainer>
);
}

View File

@ -0,0 +1,60 @@
import { AddButton } from '@/common';
import { Apis } from '@/gen/Apis';
import { SysPermissionsTypeEnum } from '@/gen/Enums';
import { BetaSchemaForm } from '@ant-design/pro-components';
import { useMemoizedFn } from 'ahooks';
import { message } from 'antd';
import { useState } from 'react';
type FormType = ApiReqTypes.Admins.Store;
export default function Create(props: { title: string; onFinish: () => void }) {
const [loading, setLoading] = useState(false);
const handleFinish = useMemoizedFn(async (values: FormType) => {
setLoading(true);
return Apis.Admins.Store(values)
.then(() => {
message.success('添加成功');
props.onFinish();
return true;
})
.finally(() => {
setLoading(false);
});
});
return (
<BetaSchemaForm<FormType>
title={`添加${props.title}`}
layoutType="ModalForm"
trigger={<AddButton title={props.title} />}
modalProps={{
maskClosable: false,
destroyOnClose: true,
}}
initialValues={{
type: SysPermissionsTypeEnum.PAGE.value,
}}
onFinish={handleFinish}
disabled={loading}
loading={loading}
columns={[
{
key: 'username',
title: '用户名',
},
{
key: 'password',
title: '密码',
valueType: 'password',
},
{
key: 'is_locked',
title: '是否锁定',
valueType: 'switch',
},
]}
/>
);
}

View File

@ -0,0 +1,71 @@
import { EditButton } from '@/common';
import { Apis } from '@/gen/Apis';
import { BetaSchemaForm } from '@ant-design/pro-components';
import { useMemoizedFn } from 'ahooks';
import { Form, message } from 'antd';
import { useState } from 'react';
type TableType = ApiRespTypes.Admins.List;
type FormType = ApiReqTypes.Admins.Update;
export default function Update(props: {
item: TableType[0];
title: string;
onFinish: () => void;
}) {
const [form] = Form.useForm();
const [loading, setLoading] = useState(false);
const handleFinish = useMemoizedFn(async (values: FormType) => {
setLoading(true);
return Apis.Admins.Update({
...values,
id: props.item.id,
})
.then(() => {
message.success('编辑成功');
props.onFinish();
return true;
})
.finally(() => {
setLoading(false);
});
});
return (
<BetaSchemaForm<FormType>
form={form}
title={`编辑${props.title}`}
layoutType="ModalForm"
trigger={<EditButton />}
modalProps={{
maskClosable: false,
destroyOnClose: true,
}}
onOpenChange={(open: any) => {
if (open && props.item) {
form.setFieldsValue(props.item);
}
}}
onFinish={handleFinish}
disabled={loading}
loading={loading}
columns={[
{
key: 'username',
title: '用户名',
},
{
key: 'password',
title: '密码',
valueType: 'password',
},
{
key: 'is_locked',
title: '是否锁定',
valueType: 'switch',
},
]}
/>
);
}

View File

@ -0,0 +1,143 @@
import { DeleteButton } from '@/common';
import { Apis } from '@/gen/Apis';
import { SysPermissionsTypeEnum } from '@/gen/Enums';
import {
DataSourceType,
useSysPermissionsDataSource,
} from '@/hooks/dataSources';
import { DownOutlined, UpOutlined } from '@ant-design/icons';
import { PageContainer, ProTable } from '@ant-design/pro-components';
import { clearCache, useMemoizedFn } from 'ahooks';
import { Button, message, Space, Tag } from 'antd';
import Create from './modals/Create';
import Update from './modals/Update';
type TableType = ApiRespTypes.SysPermissions.List;
export default function Index() {
const title = '权限';
const { data, run } = useSysPermissionsDataSource({
guard_name: process.env.GUARD_NAME || '',
});
const handleCreateFinish = useMemoizedFn(() => {
clearCache(DataSourceType.SysPermissions);
run();
});
const handleUpdateFinish = useMemoizedFn(() => {
clearCache(DataSourceType.SysPermissions);
run();
});
const handleMove = useMemoizedFn(
(entity: TableType[0], type: 'up' | 'down') => {
Apis.SysPermissions.Move({
id: entity.id,
type,
}).then(() => {
clearCache(DataSourceType.SysPermissions);
run();
});
},
);
const handleDelete = useMemoizedFn((entity: TableType[0]) => {
Apis.SysPermissions.Delete({
id: entity.id,
}).then(() => {
message.success('删除成功');
clearCache(DataSourceType.SysPermissions);
run();
});
});
// useEffect(() => {
// run();
// }, []);
return !data ? (
<></>
) : (
<PageContainer>
<ProTable<TableType[0]>
scroll={{ x: 'max-content', scrollToFirstRowOnChange: true }}
bordered
size="small"
rowKey="id"
search={false}
pagination={false}
options={false}
expandable={{
defaultExpandAllRows: true,
}}
toolBarRender={() => [
<Create
key="Create"
guard_name={process.env.GUARD_NAME || ''}
onFinish={handleCreateFinish}
title={title}
/>,
]}
dataSource={data}
columns={[
{
title: '名称',
render: (_, entity) => {
return `${entity.id}_${entity.name}`;
},
},
{ title: 'icon', dataIndex: 'icon' },
{
title: '类型',
dataIndex: 'type',
valueEnum: SysPermissionsTypeEnum,
},
{ title: '链接', dataIndex: 'path' },
{ title: '前端表示', dataIndex: 'key' },
{
title: '后端API',
dataIndex: 'backend_apis',
render: (_, entity) => {
return (
<Space direction="vertical">
{entity.backend_apis?.map((item: string) => (
<Tag key={item}>{item}</Tag>
))}
</Space>
);
},
},
{
title: '操作',
render: (_, entity) => {
return (
<Space key={entity.id}>
<Button
size="small"
icon={<UpOutlined />}
disabled={!entity.parent_id}
onClick={() => handleMove(entity, 'up')}
/>
<Button
size="small"
icon={<DownOutlined />}
disabled={!entity.parent_id}
onClick={() => handleMove(entity, 'down')}
/>
<Update
item={entity}
title={title}
onFinish={handleUpdateFinish}
/>
<DeleteButton onConfirm={() => handleDelete(entity)} />
</Space>
);
},
},
]}
/>
</PageContainer>
);
}

View File

@ -0,0 +1,89 @@
import { AddButton, MyIcons } from '@/common';
import { PermissionTreeSelect } from '@/components/PermissionTreeSelect';
import { Apis } from '@/gen/Apis';
import { SysPermissionsTypeEnum } from '@/gen/Enums';
import { BetaSchemaForm } from '@ant-design/pro-components';
import { useMemoizedFn } from 'ahooks';
import { message, Space } from 'antd';
import { useState } from 'react';
type FormType = ApiReqTypes.SysPermissions.Store;
export default function Create(props: {
title: string;
guard_name: string;
onFinish: () => void;
}) {
const [loading, setLoading] = useState(false);
const handleFinish = useMemoizedFn(async (values: FormType) => {
setLoading(true);
return Apis.SysPermissions.Store({
...values,
guard_name: props.guard_name,
})
.then(() => {
message.success('添加成功');
props.onFinish();
return true;
})
.finally(() => {
setLoading(false);
});
});
return (
<BetaSchemaForm<FormType>
title={`添加${props.title}`}
layoutType="ModalForm"
trigger={<AddButton title={props.title} />}
modalProps={{
maskClosable: false,
destroyOnClose: true,
}}
initialValues={{
type: SysPermissionsTypeEnum.PAGE.value,
}}
onFinish={handleFinish}
disabled={loading}
loading={loading}
columns={[
PermissionTreeSelect(props.guard_name),
{
key: 'name',
title: '名称',
},
{
key: 'key',
title: '前端权限识别符',
},
{
key: 'icon',
title: '图标',
valueType: 'select',
request: async () => {
return Object.entries(MyIcons).map(([key, value]) => ({
label: (
<Space style={{ gap: '5px' }}>
{value}
<span>{key}</span>
</Space>
),
value: key,
}));
},
},
{
key: 'type',
title: '类型',
valueType: 'radioButton',
fieldProps: {
buttonStyle: 'solid',
},
valueEnum: SysPermissionsTypeEnum,
},
{ key: 'path', title: '路由' },
]}
/>
);
}

View File

@ -0,0 +1,95 @@
import { EditButton, MyIcons } from '@/common';
import { PermissionTreeSelect } from '@/components/PermissionTreeSelect';
import { Apis } from '@/gen/Apis';
import { SysPermissionsTypeEnum } from '@/gen/Enums';
import { BetaSchemaForm } from '@ant-design/pro-components';
import { useMemoizedFn } from 'ahooks';
import { Form, message, Space } from 'antd';
import { useState } from 'react';
type TableType = ApiRespTypes.SysPermissions.List;
type FormType = ApiReqTypes.SysPermissions.Update;
export default function Update(props: {
item: TableType[0];
title: string;
onFinish: () => void;
}) {
const [form] = Form.useForm();
const [loading, setLoading] = useState(false);
const handleFinish = useMemoizedFn(async (values: FormType) => {
setLoading(true);
return Apis.SysPermissions.Update({
...values,
guard_name: props.item.guard_name,
id: props.item.id,
})
.then(() => {
message.success('编辑成功');
props.onFinish();
return true;
})
.finally(() => {
setLoading(false);
});
});
return (
<BetaSchemaForm<FormType>
form={form}
title={`编辑${props.title}`}
layoutType="ModalForm"
trigger={<EditButton />}
modalProps={{
maskClosable: false,
destroyOnClose: true,
}}
onOpenChange={(open: any) => {
if (open && props.item) {
form.setFieldsValue(props.item);
}
}}
onFinish={handleFinish}
disabled={loading}
loading={loading}
columns={[
PermissionTreeSelect(props.item.guard_name),
{
key: 'name',
title: '名称',
},
{
key: 'key',
title: '前端权限识别符',
},
{
key: 'icon',
title: '图标',
valueType: 'select',
request: async () => {
return Object.entries(MyIcons).map(([key, value]) => ({
label: (
<Space style={{ gap: '5px' }}>
{value}
<span>{key}</span>
</Space>
),
value: key,
}));
},
},
{
key: 'type',
title: '类型',
valueType: 'radioButton',
fieldProps: {
buttonStyle: 'solid',
},
valueEnum: SysPermissionsTypeEnum,
},
{ key: 'path', title: '路由' },
]}
/>
);
}

View File

@ -0,0 +1,95 @@
import { DeleteButton } from '@/common';
import { Apis } from '@/gen/Apis';
import { GuardNameEnum } from '@/gen/Enums';
import { DataSourceType, useSysRolesDataSource } from '@/hooks/dataSources';
import { PageContainer, ProTable } from '@ant-design/pro-components';
import { clearCache, useMemoizedFn } from 'ahooks';
import { Space, Tag } from 'antd';
import Create from './modals/Create';
import Update from './modals/Update';
type TableType = ApiRespTypes.SysRoles.List;
export default function Index() {
const title = '角色';
const { data, run } = useSysRolesDataSource();
const handleCreateFinish = useMemoizedFn(() => {
clearCache(DataSourceType.SysRoles);
run();
});
const handleUpdateFinish = useMemoizedFn(() => {
clearCache(DataSourceType.SysRoles);
run();
});
const handleDelete = useMemoizedFn((entity: TableType[0]) => {
Apis.SysRoles.Delete({ id: entity.id }).then(() => {
clearCache(DataSourceType.SysRoles);
run();
});
});
return !data ? (
<></>
) : (
<PageContainer>
<ProTable<TableType[0]>
scroll={{ x: 'max-content', scrollToFirstRowOnChange: true }}
bordered
size="small"
rowKey="id"
options={false}
expandable={{
defaultExpandAllRows: true,
}}
toolBarRender={() => [
<Create key="Create" onFinish={handleCreateFinish} title={title} />,
]}
dataSource={data}
columns={[
{
title: 'ID',
dataIndex: 'id',
},
{
title: '权限组',
dataIndex: 'guard_name',
valueEnum: GuardNameEnum,
},
{
title: '名称',
dataIndex: 'name',
},
{
title: '颜色',
dataIndex: 'color',
renderText: (color) => <Tag color={color as string}>{color}</Tag>,
},
{
title: '创建时间',
dataIndex: 'created_at',
valueType: 'dateTime',
},
{
title: '操作',
render: (_, entity) => {
return (
<Space key={entity.id}>
<Update
item={entity}
title={title}
onFinish={handleUpdateFinish}
/>
<DeleteButton onConfirm={() => handleDelete(entity)} />
</Space>
);
},
},
]}
/>
</PageContainer>
);
}

View File

@ -0,0 +1,64 @@
import { AddButton, MyColorPicker } from '@/common';
import { Apis } from '@/gen/Apis';
import { GuardNameEnum, SysPermissionsTypeEnum } from '@/gen/Enums';
import { BetaSchemaForm } from '@ant-design/pro-components';
import { useMemoizedFn } from 'ahooks';
import { message } from 'antd';
import { useState } from 'react';
type FormType = ApiReqTypes.SysRoles.Store;
export default function Create(props: { title: string; onFinish: () => void }) {
const [loading, setLoading] = useState(false);
const handleFinish = useMemoizedFn(async (values: FormType) => {
setLoading(true);
return Apis.SysRoles.Store(values)
.then(() => {
message.success('添加成功');
props.onFinish();
return true;
})
.finally(() => {
setLoading(false);
});
});
return (
<BetaSchemaForm<FormType>
title={`添加${props.title}`}
layoutType="ModalForm"
trigger={<AddButton title={props.title} />}
modalProps={{
maskClosable: false,
destroyOnClose: true,
}}
initialValues={{
type: SysPermissionsTypeEnum.PAGE.value,
}}
onFinish={handleFinish}
disabled={loading}
loading={loading}
columns={[
{
key: 'name',
title: '名称',
},
{
key: 'guard_name',
title: '权限组',
valueType: 'radioButton',
fieldProps: {
buttonStyle: 'solid',
},
valueEnum: GuardNameEnum,
},
{
key: 'color',
title: '颜色',
renderFormItem: () => <MyColorPicker />,
},
]}
/>
);
}

View File

@ -0,0 +1,76 @@
import { EditButton, MyColorPicker } from '@/common';
import { Apis } from '@/gen/Apis';
import { GuardNameEnum } from '@/gen/Enums';
import { BetaSchemaForm } from '@ant-design/pro-components';
import { useMemoizedFn } from 'ahooks';
import { Form, message } from 'antd';
import { useState } from 'react';
type TableType = ApiRespTypes.SysRoles.List;
type FormType = ApiReqTypes.SysRoles.Update;
export default function Update(props: {
item: TableType[0];
title: string;
onFinish: () => void;
}) {
const [form] = Form.useForm();
const [loading, setLoading] = useState(false);
const handleFinish = useMemoizedFn(async (values: FormType) => {
setLoading(true);
return Apis.SysRoles.Update({
...values,
id: props.item.id,
})
.then(() => {
message.success('编辑成功');
props.onFinish();
return true;
})
.finally(() => {
setLoading(false);
});
});
return (
<BetaSchemaForm<FormType>
form={form}
title={`编辑${props.title}`}
layoutType="ModalForm"
trigger={<EditButton />}
modalProps={{
maskClosable: false,
destroyOnClose: true,
}}
onOpenChange={(open: any) => {
if (open && props.item) {
form.setFieldsValue(props.item);
}
}}
onFinish={handleFinish}
disabled={loading}
loading={loading}
columns={[
{
key: 'name',
title: '名称',
},
{
key: 'guard_name',
title: '权限组',
valueType: 'radioButton',
fieldProps: {
buttonStyle: 'solid',
},
valueEnum: GuardNameEnum,
},
{
key: 'color',
title: '颜色',
renderFormItem: () => <MyColorPicker />,
},
]}
/>
);
}

View File

@ -0,0 +1,96 @@
/* eslint-disable */
// 该文件由 OneAPI 自动生成,请勿手动修改!
import { request } from '@umijs/max';
/** 此处后端没有提供注释 GET /api/v1/queryUserList */
export async function queryUserList(
params: {
// query
/** keyword */
keyword?: string;
/** current */
current?: number;
/** pageSize */
pageSize?: number;
},
options?: { [key: string]: any },
) {
return request<API.Result_PageInfo_UserInfo__>('/api/v1/queryUserList', {
method: 'GET',
params: {
...params,
},
...(options || {}),
});
}
/** 此处后端没有提供注释 POST /api/v1/user */
export async function addUser(
body?: API.UserInfoVO,
options?: { [key: string]: any },
) {
return request<API.Result_UserInfo_>('/api/v1/user', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
data: body,
...(options || {}),
});
}
/** 此处后端没有提供注释 GET /api/v1/user/${param0} */
export async function getUserDetail(
params: {
// path
/** userId */
userId?: string;
},
options?: { [key: string]: any },
) {
const { userId: param0 } = params;
return request<API.Result_UserInfo_>(`/api/v1/user/${param0}`, {
method: 'GET',
params: { ...params },
...(options || {}),
});
}
/** 此处后端没有提供注释 PUT /api/v1/user/${param0} */
export async function modifyUser(
params: {
// path
/** userId */
userId?: string;
},
body?: API.UserInfoVO,
options?: { [key: string]: any },
) {
const { userId: param0 } = params;
return request<API.Result_UserInfo_>(`/api/v1/user/${param0}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
params: { ...params },
data: body,
...(options || {}),
});
}
/** 此处后端没有提供注释 DELETE /api/v1/user/${param0} */
export async function deleteUser(
params: {
// path
/** userId */
userId?: string;
},
options?: { [key: string]: any },
) {
const { userId: param0 } = params;
return request<API.Result_string_>(`/api/v1/user/${param0}`, {
method: 'DELETE',
params: { ...params },
...(options || {}),
});
}

View File

@ -0,0 +1,7 @@
/* eslint-disable */
// 该文件由 OneAPI 自动生成,请勿手动修改!
import * as UserController from './UserController';
export default {
UserController,
};

68
src/services/demo/typings.d.ts vendored Normal file
View File

@ -0,0 +1,68 @@
/* eslint-disable */
// 该文件由 OneAPI 自动生成,请勿手动修改!
declare namespace API {
interface PageInfo {
/**
1 */
current?: number;
pageSize?: number;
total?: number;
list?: Array<Record<string, any>>;
}
interface PageInfo_UserInfo_ {
/**
1 */
current?: number;
pageSize?: number;
total?: number;
list?: Array<UserInfo>;
}
interface Result {
success?: boolean;
errorMessage?: string;
data?: Record<string, any>;
}
interface Result_PageInfo_UserInfo__ {
success?: boolean;
errorMessage?: string;
data?: PageInfo_UserInfo_;
}
interface Result_UserInfo_ {
success?: boolean;
errorMessage?: string;
data?: UserInfo;
}
interface Result_string_ {
success?: boolean;
errorMessage?: string;
data?: string;
}
type UserGenderEnum = 'MALE' | 'FEMALE';
interface UserInfo {
id?: string;
name?: string;
/** nick */
nickName?: string;
/** email */
email?: string;
gender?: UserGenderEnum;
}
interface UserInfoVO {
name?: string;
/** nick */
nickName?: string;
/** email */
email?: string;
}
type definitions_0 = null;
}

1
src/types/session.ts Normal file
View File

@ -0,0 +1 @@

4
src/utils/format.ts Normal file
View File

@ -0,0 +1,4 @@
// 示例方法,没有实际意义
export function trim(str: string) {
return str.trim();
}

3
tsconfig.json Normal file
View File

@ -0,0 +1,3 @@
{
"extends": "./src/.umi/tsconfig.json"
}

1
typings.d.ts vendored Normal file
View File

@ -0,0 +1 @@
import '@umijs/max/typings';

103
workflow.md Normal file
View File

@ -0,0 +1,103 @@
# 微信 H5 登录流程
```plantuml
@startuml
|用户|
start
:访问H5页面;
:点击微信登录;
:处理授权请求;
if (是否同意?) then (是)
:等待登录结果;
else (否)
:授权失败;
end
endif
|H5前端|
:加载页面;
:配置wx.config;
if (配置成功?) then (是)
:调用wx.login;
:发送code到后端;
:等待后端响应;
if (登录结果?) then (成功)
:展示用户信息;
else (禁用)
:显示禁用提示;
endif
else (否)
:显示配置失败;
end
endif
|微信客户端|
:处理配置请求;
:返回配置结果;
:获取用户授权;
:生成临时code;
|后端服务器|
:接收code;
:换取access_token;
:获取用户信息;
:查询用户状态;
if (用户状态?) then (正常)
:生成登录态;
else (禁用)
:返回禁用信息;
endif
|数据库|
:查询用户记录;
:返回用户状态;
@enduml
```
## 流程说明
1. 用户泳道:
- 访问 H5 页面
- 点击微信登录按钮
- 处理授权请求
- 等待登录结果
2. H5 前端泳道:
- 加载页面
- 配置 wx.config
- 调用 wx.login 获取 code
- 发送 code 到后端
- 根据响应显示结果
3. 微信客户端泳道:
- 处理配置请求
- 获取用户授权
- 生成临时 code
4. 后端服务器泳道:
- 接收 code
- 换取 access_token
- 获取用户信息
- 查询用户状态
- 生成登录态或返回禁用信息
5. 数据库泳道:
- 查询用户记录
- 返回用户状态
## 注意事项
1. code 的有效期很短,通常只有 5 分钟
2. access_token 需要安全存储在后端,避免泄露
3. 建议后端生成自己的登录态 token 返回给前端
4. 需要处理用户拒绝授权的情况
5. 需要处理各种网络异常情况
6. 建议实现静默登录机制,提升用户体验
7. 用户禁用状态应该定期同步和更新
8. 建议在前端缓存用户禁用状态,避免频繁请求
9. 禁用消息提示应该清晰明确,告知用户如何解除禁用