commit
6177c7cd10
@ -0,0 +1,13 @@ |
|||||||
|
root = true |
||||||
|
|
||||||
|
[*] |
||||||
|
charset = utf-8 |
||||||
|
end_of_line = lf |
||||||
|
trim_trailing_whitespace = true |
||||||
|
insert_final_newline = true |
||||||
|
indent_style = space |
||||||
|
indent_size = 2 |
||||||
|
tab_width = 2 |
||||||
|
|
||||||
|
[*md] |
||||||
|
trim_trailing_whitespace = false |
@ -0,0 +1,2 @@ |
|||||||
|
node_modules |
||||||
|
dist |
@ -0,0 +1,13 @@ |
|||||||
|
module.exports = { |
||||||
|
root: true, |
||||||
|
env: { |
||||||
|
browser: true, |
||||||
|
es2020: true, |
||||||
|
}, |
||||||
|
extends: [ |
||||||
|
'eslint:recommended', |
||||||
|
'plugin:@typescript-eslint/recommended', |
||||||
|
], |
||||||
|
ignorePatterns: ['dist', '.eslintrc.cjs'], |
||||||
|
parser: '@typescript-eslint/parser', |
||||||
|
} |
@ -0,0 +1,9 @@ |
|||||||
|
dist |
||||||
|
.DS_Store |
||||||
|
node_modules |
||||||
|
TODOs.md |
||||||
|
*.log |
||||||
|
*.local |
||||||
|
.idea |
||||||
|
!.vscode/extensions.json |
||||||
|
.vscode |
@ -0,0 +1,11 @@ |
|||||||
|
# 设置源 为淘宝源 |
||||||
|
# registry =https://registry.npm.taobao.org |
||||||
|
|
||||||
|
# 默认情况下,PNPM创建了一个半模型,依赖关系可以访问未删除的依赖项, |
||||||
|
# 但在node_modules之外的模块不会。 使用此布局,生态系统中的大多数包都没有问题。 |
||||||
|
# 但是,如果某些工具仅在Node_Modules的根目录中工作时,您可以将其设置为true以为您提升。 |
||||||
|
# shamefully-hoist=true |
||||||
|
|
||||||
|
# 结合根目录的 package.json 中的 engines 字段, |
||||||
|
# 我们可以指定运行的 node 版本和 pnpm 版本。 |
||||||
|
engine-strict=true |
@ -0,0 +1,23 @@ |
|||||||
|
module.exports = { |
||||||
|
root: true, |
||||||
|
env: { browser: true, es2020: true }, |
||||||
|
extends: [ |
||||||
|
'eslint:recommended', |
||||||
|
'plugin:@typescript-eslint/recommended', |
||||||
|
'plugin:react-hooks/recommended', |
||||||
|
], |
||||||
|
ignorePatterns: ['dist', '.eslintrc.cjs'], |
||||||
|
parser: '@typescript-eslint/parser', |
||||||
|
plugins: ['react-refresh'], |
||||||
|
rules: { |
||||||
|
// 'react-refresh/only-export-components': [ |
||||||
|
// 'warn', |
||||||
|
// { allowConstantExport: true }, |
||||||
|
// ], |
||||||
|
"react-hooks/exhaustive-deps": [ |
||||||
|
"warn", { |
||||||
|
"additionalHooks": "useRecoilCallback" |
||||||
|
} |
||||||
|
], |
||||||
|
}, |
||||||
|
} |
@ -0,0 +1,27 @@ |
|||||||
|
# React + TypeScript + Vite |
||||||
|
|
||||||
|
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules. |
||||||
|
|
||||||
|
Currently, two official plugins are available: |
||||||
|
|
||||||
|
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/README.md) uses [Babel](https://babeljs.io/) for Fast Refresh |
||||||
|
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh |
||||||
|
|
||||||
|
## Expanding the ESLint configuration |
||||||
|
|
||||||
|
If you are developing a production application, we recommend updating the configuration to enable type aware lint rules: |
||||||
|
|
||||||
|
- Configure the top-level `parserOptions` property like this: |
||||||
|
|
||||||
|
```js |
||||||
|
parserOptions: { |
||||||
|
ecmaVersion: 'latest', |
||||||
|
sourceType: 'module', |
||||||
|
project: ['./tsconfig.json', './tsconfig.node.json'], |
||||||
|
tsconfigRootDir: __dirname, |
||||||
|
}, |
||||||
|
``` |
||||||
|
|
||||||
|
- Replace `plugin:@typescript-eslint/recommended` to `plugin:@typescript-eslint/recommended-type-checked` or `plugin:@typescript-eslint/strict-type-checked` |
||||||
|
- Optionally add `plugin:@typescript-eslint/stylistic-type-checked` |
||||||
|
- Install [eslint-plugin-react](https://github.com/jsx-eslint/eslint-plugin-react) and add `plugin:react/recommended` & `plugin:react/jsx-runtime` to the `extends` list |
@ -0,0 +1,13 @@ |
|||||||
|
<!doctype html> |
||||||
|
<html lang="zh-Hans"> |
||||||
|
<head> |
||||||
|
<meta charset="UTF-8" /> |
||||||
|
<link rel="icon" type="image/svg+xml" href="/vite.svg" /> |
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> |
||||||
|
<title>后台脚手架</title> |
||||||
|
</head> |
||||||
|
<body> |
||||||
|
<div id="root"></div> |
||||||
|
<script type="module" src="/src/main.tsx"></script> |
||||||
|
</body> |
||||||
|
</html> |
@ -0,0 +1,41 @@ |
|||||||
|
{ |
||||||
|
"name": "@doad/example", |
||||||
|
"private": true, |
||||||
|
"version": "0.0.1", |
||||||
|
"type": "module", |
||||||
|
"license": "MIT", |
||||||
|
"description": "a Vite4 + Typescript + React + Antd + Less + Eslint + Prettier template", |
||||||
|
"scripts": { |
||||||
|
"dev": "vite", |
||||||
|
"build": "tsc && vite build", |
||||||
|
"preview": "vite preview" |
||||||
|
}, |
||||||
|
"engines": { |
||||||
|
"node": ">=14", |
||||||
|
"pnpm": ">=7" |
||||||
|
}, |
||||||
|
"dependencies": { |
||||||
|
"@ant-design/icons": "^5.1.4", |
||||||
|
"antd": "^5.7.2", |
||||||
|
"dayjs": "^1.11.9", |
||||||
|
"default-passive-events": "^2.0.0", |
||||||
|
"react": "^18.2.0", |
||||||
|
"react-dom": "^18.2.0", |
||||||
|
"react-router-dom": "^6.14.2", |
||||||
|
"recoil": "^0.7.7", |
||||||
|
"@doad/doad": "workspace:*" |
||||||
|
}, |
||||||
|
"devDependencies": { |
||||||
|
"@types/node": "^20.4.4", |
||||||
|
"@types/react": "^18.2.15", |
||||||
|
"@types/react-dom": "^18.2.7", |
||||||
|
"@typescript-eslint/eslint-plugin": "^6.0.0", |
||||||
|
"@typescript-eslint/parser": "^6.0.0", |
||||||
|
"@vitejs/plugin-react-swc": "^3.3.2", |
||||||
|
"eslint": "^8.45.0", |
||||||
|
"eslint-plugin-react-hooks": "^4.6.0", |
||||||
|
"eslint-plugin-react-refresh": "^0.4.3", |
||||||
|
"typescript": "^5.0.2", |
||||||
|
"vite": "^4.4.5" |
||||||
|
} |
||||||
|
} |
@ -0,0 +1,15 @@ |
|||||||
|
import {FC} from "react"; |
||||||
|
import {DoadRoot} from "@doad/doad"; |
||||||
|
import {Outlet} from "react-router-dom"; |
||||||
|
|
||||||
|
const AdminLayout: FC = () => { |
||||||
|
return ( |
||||||
|
<DoadRoot search> |
||||||
|
<Outlet/> |
||||||
|
</DoadRoot> |
||||||
|
) |
||||||
|
} |
||||||
|
|
||||||
|
AdminLayout.displayName = 'AdminLayout' |
||||||
|
|
||||||
|
export default AdminLayout |
@ -0,0 +1,50 @@ |
|||||||
|
import {ExceptionStatusType} from "antd/es/result"; |
||||||
|
import {FC} from "react"; |
||||||
|
import {useNavigate, useRouteError} from "react-router-dom"; |
||||||
|
import {Button, Card, Layout, Result} from "antd"; |
||||||
|
import {DoadGeneralLayout, DoadHead, doadHeaderStyle, doadLayoutStyles} from "@doad/doad"; |
||||||
|
|
||||||
|
const errors: Record<ExceptionStatusType, string> = { |
||||||
|
403: '您没有权限访问当前页面', |
||||||
|
404: '您访问的页面不存在', |
||||||
|
500: '程序出错了', |
||||||
|
} |
||||||
|
|
||||||
|
export interface RouteError { |
||||||
|
status?: ExceptionStatusType |
||||||
|
statusText?: string |
||||||
|
message?: string |
||||||
|
} |
||||||
|
|
||||||
|
const ErrorPage: FC = () => { |
||||||
|
const error = useRouteError() as RouteError; |
||||||
|
const navigate = useNavigate() |
||||||
|
|
||||||
|
return ( |
||||||
|
<Layout style={{backgroundColor: 'transparent'}}> |
||||||
|
<Layout.Header style={doadHeaderStyle}> |
||||||
|
<DoadHead search /> |
||||||
|
</Layout.Header> |
||||||
|
<Layout.Content> |
||||||
|
<DoadGeneralLayout> |
||||||
|
<Card style={{...doadLayoutStyles, textAlign: 'center'}}> |
||||||
|
<Result |
||||||
|
status={error.status ?? 500} |
||||||
|
subTitle={errors[error.status!] ?? (error.statusText || error.message)} |
||||||
|
extra={ |
||||||
|
<> |
||||||
|
<Button onClick={() => navigate(-1)}>上一页</Button> |
||||||
|
<Button type="primary" onClick={() => navigate('/')}>返回首页</Button> |
||||||
|
</> |
||||||
|
} |
||||||
|
/> |
||||||
|
</Card> |
||||||
|
</DoadGeneralLayout> |
||||||
|
</Layout.Content> |
||||||
|
</Layout> |
||||||
|
) |
||||||
|
} |
||||||
|
|
||||||
|
ErrorPage.displayName = 'ErrorPage' |
||||||
|
|
||||||
|
export default ErrorPage |
@ -0,0 +1,24 @@ |
|||||||
|
html, |
||||||
|
body { |
||||||
|
width: 100%; |
||||||
|
min-height: 100vh; |
||||||
|
} |
||||||
|
|
||||||
|
html { |
||||||
|
box-sizing: border-box; |
||||||
|
-webkit-tap-highlight-color: rgba(0, 0, 0, 0); |
||||||
|
} |
||||||
|
|
||||||
|
body { |
||||||
|
margin: 0; |
||||||
|
min-width: 1200px; |
||||||
|
/*background: #f8f9fb;*/ |
||||||
|
/*background: #f5f8ff;*/ |
||||||
|
background: #f1f1f1; |
||||||
|
} |
||||||
|
|
||||||
|
*, |
||||||
|
*::before, |
||||||
|
*::after { |
||||||
|
box-sizing: inherit; |
||||||
|
} |
@ -0,0 +1,120 @@ |
|||||||
|
import { FC, ReactNode, StrictMode, useEffect } from 'react' |
||||||
|
import ReactDOM from 'react-dom/client' |
||||||
|
import dayjs from 'dayjs'; |
||||||
|
import { RecoilRoot } from 'recoil'; |
||||||
|
import { RouterProvider } from 'react-router-dom'; |
||||||
|
import router from './routes/test.tsx'; |
||||||
|
import { |
||||||
|
AppstoreFilled, |
||||||
|
CodeFilled, |
||||||
|
CompassFilled, |
||||||
|
FireFilled, |
||||||
|
HomeFilled, |
||||||
|
NotificationFilled, |
||||||
|
PieChartFilled, |
||||||
|
SettingFilled, |
||||||
|
TrophyFilled |
||||||
|
} from "@ant-design/icons"; |
||||||
|
import { App } from 'antd' |
||||||
|
|
||||||
|
import {DoadMenuItem, useDoadMenuItemsSetter} from "@doad/doad"; |
||||||
|
|
||||||
|
// 关于 Added non-passive event listener to a scroll-blocking ‘wheel’ event. Consider marking event handler as 'passive'
|
||||||
|
// to make the page more responsive. 的新解决方案
|
||||||
|
import 'default-passive-events' |
||||||
|
import 'dayjs/locale/zh-cn'; |
||||||
|
import './index.css' |
||||||
|
|
||||||
|
dayjs.locale('zh-cn'); |
||||||
|
|
||||||
|
let itemKey = 0 |
||||||
|
|
||||||
|
function getItem( |
||||||
|
title: ReactNode, |
||||||
|
icon?: ReactNode, |
||||||
|
children?: DoadMenuItem[], |
||||||
|
path?: string |
||||||
|
): DoadMenuItem { |
||||||
|
let handle: DoadMenuItem['handle'] |
||||||
|
if (path == null) { |
||||||
|
handle = (_, navigate) => navigate("/abc") |
||||||
|
} |
||||||
|
return { |
||||||
|
key: `${++itemKey}`, |
||||||
|
title, |
||||||
|
icon, |
||||||
|
path, |
||||||
|
handle, |
||||||
|
children, |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
const items: DoadMenuItem[] = [ |
||||||
|
getItem("首页", <HomeFilled/>, [ |
||||||
|
getItem('工作台', null, [], "/about"), |
||||||
|
]), |
||||||
|
getItem('管理', <CompassFilled/>, [ |
||||||
|
getItem('版本管理'), |
||||||
|
getItem('成员管理'), |
||||||
|
getItem('用户反馈'), |
||||||
|
getItem('付费管理'), |
||||||
|
]), |
||||||
|
getItem("统计", <PieChartFilled/>), |
||||||
|
getItem('功能', <AppstoreFilled/>, [ |
||||||
|
getItem('附近的小程序'), |
||||||
|
getItem('微信搜一搜'), |
||||||
|
getItem('微信支付'), |
||||||
|
getItem('购物订单'), |
||||||
|
getItem('交易保障'), |
||||||
|
getItem('体验评价'), |
||||||
|
getItem('购物订单'), |
||||||
|
getItem('物流服务'), |
||||||
|
getItem('硬件设备'), |
||||||
|
getItem('客服'), |
||||||
|
getItem('订阅消息'), |
||||||
|
getItem('页面内容接入'), |
||||||
|
getItem('小程序插件'), |
||||||
|
getItem('交易组件'), |
||||||
|
getItem('实验工具'), |
||||||
|
]), |
||||||
|
|
||||||
|
getItem('开发', <CodeFilled/>, [ |
||||||
|
getItem('开发管理'), |
||||||
|
getItem('开发工具'), |
||||||
|
getItem('云服务'), |
||||||
|
]), |
||||||
|
|
||||||
|
getItem('成长', <TrophyFilled/>, [ |
||||||
|
getItem('小程序评测'), |
||||||
|
getItem('违规记录'), |
||||||
|
]), |
||||||
|
|
||||||
|
getItem('推广', <NotificationFilled/>, [ |
||||||
|
getItem('流量主'), |
||||||
|
getItem('广告主'), |
||||||
|
]), |
||||||
|
|
||||||
|
getItem('第三方服务', <FireFilled/>, [ |
||||||
|
getItem('服务'), |
||||||
|
]), |
||||||
|
|
||||||
|
getItem('设置', <SettingFilled/>), |
||||||
|
]; |
||||||
|
|
||||||
|
const Application: FC = () => { |
||||||
|
const setMenus = useDoadMenuItemsSetter() |
||||||
|
useEffect(() => setMenus(items)) |
||||||
|
return ( |
||||||
|
<App> |
||||||
|
<RouterProvider router={router} /> |
||||||
|
</App> |
||||||
|
) |
||||||
|
} |
||||||
|
|
||||||
|
ReactDOM.createRoot(document.getElementById('root')!).render( |
||||||
|
<StrictMode> |
||||||
|
<RecoilRoot> |
||||||
|
<Application /> |
||||||
|
</RecoilRoot> |
||||||
|
</StrictMode>, |
||||||
|
) |
@ -0,0 +1,50 @@ |
|||||||
|
import {Card} from "antd"; |
||||||
|
import {createBrowserRouter} from "react-router-dom"; |
||||||
|
import {DoadLazy, DoadTabbedLayout, DoadTabbedLayoutProps} from "@doad/doad"; |
||||||
|
import AdminLayout from "@/components/layouts/AdminLayout"; |
||||||
|
import ErrorPage from "@/components/layouts/ErrorPage"; |
||||||
|
|
||||||
|
const getTabItem = (label: string, key: string) => { |
||||||
|
return { |
||||||
|
key, |
||||||
|
label, |
||||||
|
render: () => <DoadLazy>{() => { |
||||||
|
return new Promise(resolve => { |
||||||
|
setTimeout(() => { |
||||||
|
resolve({ |
||||||
|
default: () => <Card>{label}</Card> |
||||||
|
}) |
||||||
|
}, 2000) |
||||||
|
}) |
||||||
|
}}</DoadLazy>, |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
const tabsItems: DoadTabbedLayoutProps['items'] = [ |
||||||
|
getTabItem('功能异常', '1'), |
||||||
|
getTabItem('产品建议', '2'), |
||||||
|
getTabItem('监控告警', '3'), |
||||||
|
getTabItem('垃圾箱', '4'), |
||||||
|
]; |
||||||
|
|
||||||
|
const router = createBrowserRouter([ |
||||||
|
{ |
||||||
|
path: '/', |
||||||
|
element: <AdminLayout/>, |
||||||
|
errorElement: <ErrorPage/>, |
||||||
|
children: [ |
||||||
|
{ |
||||||
|
path: "about", |
||||||
|
element: ( |
||||||
|
<DoadTabbedLayout |
||||||
|
title="用户反馈" |
||||||
|
items={tabsItems} |
||||||
|
activeKey="1" |
||||||
|
/> |
||||||
|
) |
||||||
|
}, |
||||||
|
] |
||||||
|
} |
||||||
|
]) |
||||||
|
|
||||||
|
export default router |
@ -0,0 +1,50 @@ |
|||||||
|
declare namespace API { |
||||||
|
/** 请求参数结构 */ |
||||||
|
type Request< |
||||||
|
Query = Record<string, unknown> | string, |
||||||
|
Body = unknown, |
||||||
|
Files = Record<string, Blob | File | string> |
||||||
|
> = { |
||||||
|
/** 请求的目标地址 */ |
||||||
|
url: string |
||||||
|
/** HTTP 请求方法,我们可以按照下面列举的方式理解其用法和意义: |
||||||
|
* |
||||||
|
* - GET(SELECT):从服务器取出资源(一项或多项)。 |
||||||
|
* - POST(CREATE):在服务器新建一个资源。 |
||||||
|
* - PUT(UPDATE):在服务器更新资源(客户端提供改变后的完整资源)。 |
||||||
|
* - PATCH(UPDATE):在服务器更新资源(客户端提供改变的属性)。 |
||||||
|
* - DELETE(DELETE):从服务器删除资源。 */ |
||||||
|
method?: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE' |
||||||
|
/** 搜索参数,对应 URL 的 QueryString 部分,用于对返回结构的过滤, |
||||||
|
* 比如分页参数、排序字段、排序方式及其它。*/ |
||||||
|
query?: Query |
||||||
|
/** 请求体数据 */ |
||||||
|
body?: Body |
||||||
|
/** 上传的文件 */ |
||||||
|
files?: Files |
||||||
|
} |
||||||
|
|
||||||
|
/** 响应数据结构 */ |
||||||
|
type Response<T = unknown> = { |
||||||
|
/** 业务上的请求是否成功 */ |
||||||
|
success: boolean |
||||||
|
/** 业务约定的错误码 */ |
||||||
|
code: string |
||||||
|
/** 业务上的错误信息 */ |
||||||
|
message?: string |
||||||
|
/** 业务数据 */ |
||||||
|
data?: T |
||||||
|
} |
||||||
|
|
||||||
|
/** 分页数据结构 */ |
||||||
|
type Pagination<T = unknown> = { |
||||||
|
/** 数据总数 */ |
||||||
|
total: number |
||||||
|
/** 当前页码 */ |
||||||
|
page: number |
||||||
|
/** 页面数据容量 */ |
||||||
|
per_page: number |
||||||
|
/** 数据列表 */ |
||||||
|
list: T[] |
||||||
|
} |
||||||
|
} |
@ -0,0 +1 @@ |
|||||||
|
/// <reference types="vite/client" />
|
@ -0,0 +1,29 @@ |
|||||||
|
{ |
||||||
|
"compilerOptions": { |
||||||
|
"target": "ES2020", |
||||||
|
"useDefineForClassFields": true, |
||||||
|
"lib": ["ES2020", "DOM", "DOM.Iterable"], |
||||||
|
"module": "ESNext", |
||||||
|
"skipLibCheck": true, |
||||||
|
|
||||||
|
/* Bundler mode */ |
||||||
|
"moduleResolution": "bundler", |
||||||
|
"allowImportingTsExtensions": true, |
||||||
|
"resolveJsonModule": true, |
||||||
|
"isolatedModules": true, |
||||||
|
"noEmit": true, |
||||||
|
"jsx": "react-jsx", |
||||||
|
"baseUrl": ".", |
||||||
|
"paths": { |
||||||
|
"@/*": ["src/*"] |
||||||
|
}, |
||||||
|
|
||||||
|
/* Linting */ |
||||||
|
"strict": true, |
||||||
|
"noUnusedLocals": true, |
||||||
|
"noUnusedParameters": true, |
||||||
|
"noFallthroughCasesInSwitch": true |
||||||
|
}, |
||||||
|
"include": ["src"], |
||||||
|
"references": [{ "path": "./tsconfig.node.json" }] |
||||||
|
} |
@ -0,0 +1,10 @@ |
|||||||
|
{ |
||||||
|
"compilerOptions": { |
||||||
|
"composite": true, |
||||||
|
"skipLibCheck": true, |
||||||
|
"module": "ESNext", |
||||||
|
"moduleResolution": "bundler", |
||||||
|
"allowSyntheticDefaultImports": true |
||||||
|
}, |
||||||
|
"include": ["vite.config.ts"] |
||||||
|
} |
@ -0,0 +1,13 @@ |
|||||||
|
import { resolve } from 'node:path' |
||||||
|
import { defineConfig } from 'vite' |
||||||
|
import react from '@vitejs/plugin-react-swc' |
||||||
|
|
||||||
|
// https://vitejs.dev/config/
|
||||||
|
export default defineConfig({ |
||||||
|
plugins: [react()], |
||||||
|
resolve: { |
||||||
|
alias: [ |
||||||
|
{find: '@', replacement: resolve(__dirname, "src")} |
||||||
|
] |
||||||
|
} |
||||||
|
}) |
@ -0,0 +1,34 @@ |
|||||||
|
{ |
||||||
|
"private": true, |
||||||
|
"version": "1.0.0", |
||||||
|
"packageManager": "pnpm@8.6.2", |
||||||
|
"type": "module", |
||||||
|
"scripts": { |
||||||
|
"check": "tsc --incremental --noEmit", |
||||||
|
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0" |
||||||
|
}, |
||||||
|
"engines": { |
||||||
|
"node": ">=16.11.0" |
||||||
|
}, |
||||||
|
"simple-git-hooks": { |
||||||
|
"pre-commit": "pnpm lint-staged && pnpm check", |
||||||
|
"commit-msg": "node scripts/verifyCommit.js" |
||||||
|
}, |
||||||
|
"lint-staged": { |
||||||
|
"*.{json,ts?(x)}": [ |
||||||
|
"eslint --fix" |
||||||
|
] |
||||||
|
}, |
||||||
|
"devDependencies": { |
||||||
|
"@types/node": "^20.4.4", |
||||||
|
"@typescript-eslint/eslint-plugin": "^6.0.0", |
||||||
|
"@typescript-eslint/parser": "^6.0.0", |
||||||
|
"chalk": "^5.3.0", |
||||||
|
"eslint": "^8.45.0", |
||||||
|
"lint-staged": "^13.2.3", |
||||||
|
"simple-git-hooks": "^2.9.0", |
||||||
|
"typescript": "^5.0.2", |
||||||
|
"vite": "^4.4.5", |
||||||
|
"vitest": "^0.33.0" |
||||||
|
} |
||||||
|
} |
@ -0,0 +1,22 @@ |
|||||||
|
module.exports = { |
||||||
|
env: { browser: true, es2020: true }, |
||||||
|
extends: [ |
||||||
|
'eslint:recommended', |
||||||
|
'plugin:@typescript-eslint/recommended', |
||||||
|
'plugin:react-hooks/recommended', |
||||||
|
], |
||||||
|
ignorePatterns: ['dist', '.eslintrc.cjs'], |
||||||
|
parser: '@typescript-eslint/parser', |
||||||
|
plugins: ['react-refresh'], |
||||||
|
rules: { |
||||||
|
'react-refresh/only-export-components': [ |
||||||
|
'warn', |
||||||
|
{ allowConstantExport: true }, |
||||||
|
], |
||||||
|
"react-hooks/exhaustive-deps": [ |
||||||
|
"warn", { |
||||||
|
"additionalHooks": "useRecoilCallback" |
||||||
|
} |
||||||
|
], |
||||||
|
}, |
||||||
|
} |
@ -0,0 +1,41 @@ |
|||||||
|
{ |
||||||
|
"name": "@doad/doad", |
||||||
|
"version": "0.0.1", |
||||||
|
"type": "module", |
||||||
|
"license": "MIT", |
||||||
|
"description": "Dashboard of ant design", |
||||||
|
"main": "src/index.ts", |
||||||
|
"scripts": { |
||||||
|
"dev": "vite", |
||||||
|
"build": "tsc && vite build", |
||||||
|
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0", |
||||||
|
"preview": "vite preview" |
||||||
|
}, |
||||||
|
"engines": { |
||||||
|
"node": ">=14", |
||||||
|
"pnpm": ">=7" |
||||||
|
}, |
||||||
|
"dependencies": { |
||||||
|
"@ant-design/icons": "^5.1.4", |
||||||
|
"antd": "^5.7.2", |
||||||
|
"dayjs": "^1.11.9", |
||||||
|
"default-passive-events": "^2.0.0", |
||||||
|
"react": "^18.2.0", |
||||||
|
"react-dom": "^18.2.0", |
||||||
|
"react-router-dom": "^6.14.2", |
||||||
|
"recoil": "^0.7.7" |
||||||
|
}, |
||||||
|
"devDependencies": { |
||||||
|
"@types/node": "^20.4.4", |
||||||
|
"@types/react": "^18.2.15", |
||||||
|
"@types/react-dom": "^18.2.7", |
||||||
|
"@typescript-eslint/eslint-plugin": "^6.0.0", |
||||||
|
"@typescript-eslint/parser": "^6.0.0", |
||||||
|
"@vitejs/plugin-react-swc": "^3.3.2", |
||||||
|
"eslint": "^8.45.0", |
||||||
|
"eslint-plugin-react-hooks": "^4.6.0", |
||||||
|
"eslint-plugin-react-refresh": "^0.4.3", |
||||||
|
"typescript": "^5.0.2", |
||||||
|
"vite": "^4.4.5" |
||||||
|
} |
||||||
|
} |
@ -0,0 +1,123 @@ |
|||||||
|
import {ComponentClass, FunctionComponent} from "react"; |
||||||
|
import {useDoadPermissionValue} from "../state"; |
||||||
|
import {getDisplayName, toArray} from "../utils"; |
||||||
|
import {Link} from "react-router-dom"; |
||||||
|
import { |
||||||
|
Alert, |
||||||
|
AutoComplete, |
||||||
|
Avatar, |
||||||
|
Badge, |
||||||
|
Button, |
||||||
|
Calendar, |
||||||
|
Card, |
||||||
|
Carousel, |
||||||
|
Cascader, |
||||||
|
Checkbox, |
||||||
|
Collapse, |
||||||
|
ColorPicker, |
||||||
|
DatePicker, |
||||||
|
Descriptions, |
||||||
|
Drawer, |
||||||
|
Dropdown, |
||||||
|
FloatButton, |
||||||
|
Form, |
||||||
|
Image, |
||||||
|
Input, |
||||||
|
InputNumber, |
||||||
|
List, |
||||||
|
Mentions, |
||||||
|
Modal, |
||||||
|
Popconfirm, |
||||||
|
Popover, |
||||||
|
Progress, |
||||||
|
QRCode, |
||||||
|
Radio, |
||||||
|
Rate, |
||||||
|
Segmented, |
||||||
|
Select, |
||||||
|
Slider, |
||||||
|
Statistic, |
||||||
|
Switch, |
||||||
|
Table, |
||||||
|
Tabs, |
||||||
|
Tag, |
||||||
|
Timeline, |
||||||
|
TimePicker, |
||||||
|
Tooltip, |
||||||
|
Tour, |
||||||
|
Transfer, |
||||||
|
Tree, |
||||||
|
TreeSelect, |
||||||
|
Upload |
||||||
|
} from "antd"; |
||||||
|
|
||||||
|
export type AuthorityComponent<P> = FunctionComponent<P & { |
||||||
|
permissions?: Array<string | number> | string | number |
||||||
|
}> |
||||||
|
|
||||||
|
export function withAuthority<P>(BaseComponent: FunctionComponent<P> | ComponentClass<P> | string): AuthorityComponent<P> { |
||||||
|
return function (props) { |
||||||
|
const permissions = useDoadPermissionValue() |
||||||
|
const allows = toArray(props.permissions) |
||||||
|
if (allows == null || permissions.some(x => allows.includes(x.key))) { |
||||||
|
const attributes = {...props} |
||||||
|
delete attributes.permissions |
||||||
|
return <BaseComponent {...attributes} /> |
||||||
|
} |
||||||
|
return null |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
function wrap<P>(BaseComponent: FunctionComponent<P> | ComponentClass<P> | string): AuthorityComponent<P> { |
||||||
|
const Comp = withAuthority<P>(BaseComponent) |
||||||
|
Comp.displayName = `Doad${getDisplayName(BaseComponent)}` |
||||||
|
return Comp |
||||||
|
} |
||||||
|
|
||||||
|
export const DoadLink = wrap(Link) |
||||||
|
export const DoadButton = wrap(Button) |
||||||
|
export const DoadDropdown = wrap(Dropdown) |
||||||
|
export const DoadAutoComplete = wrap(AutoComplete) |
||||||
|
export const DoadCascader = wrap(Cascader) |
||||||
|
export const DoadCheckbox = wrap(Checkbox) |
||||||
|
export const DoadColorPicker = wrap(ColorPicker) |
||||||
|
export const DoadDatePicker = wrap(DatePicker) |
||||||
|
export const DoadForm = wrap(Form) |
||||||
|
export const DoadInput = wrap(Input) |
||||||
|
export const DoadInputNumber = wrap(InputNumber) |
||||||
|
export const DoadMentions = wrap(Mentions) |
||||||
|
export const DoadRadio = wrap(Radio) |
||||||
|
export const DoadRate = wrap(Rate) |
||||||
|
export const DoadSelect = wrap(Select) |
||||||
|
export const DoadSlider = wrap(Slider) |
||||||
|
export const DoadSwitch = wrap(Switch) |
||||||
|
export const DoadTimePicker = wrap(TimePicker) |
||||||
|
export const DoadTransfer = wrap(Transfer) |
||||||
|
export const DoadTreeSelect = wrap(TreeSelect) |
||||||
|
export const DoadUpload = wrap(Upload) |
||||||
|
export const DoadAvatar = wrap(Avatar) |
||||||
|
export const DoadBadge = wrap(Badge) |
||||||
|
export const DoadCalendar = wrap(Calendar) |
||||||
|
export const DoadCard = wrap(Card) |
||||||
|
export const DoadCarousel = wrap(Carousel) |
||||||
|
export const DoadCollapse = wrap(Collapse) |
||||||
|
export const DoadDescriptions = wrap(Descriptions) |
||||||
|
export const DoadImage = wrap(Image) |
||||||
|
export const DoadList = wrap(List) |
||||||
|
export const DoadPopover = wrap(Popover) |
||||||
|
export const DoadQRCode = wrap(QRCode) |
||||||
|
export const DoadSegmented = wrap(Segmented) |
||||||
|
export const DoadStatistic = wrap(Statistic) |
||||||
|
export const DoadTable = wrap(Table) |
||||||
|
export const DoadTabs = wrap(Tabs) |
||||||
|
export const DoadTag = wrap(Tag) |
||||||
|
export const DoadTimeline = wrap(Timeline) |
||||||
|
export const DoadTooltip = wrap(Tooltip) |
||||||
|
export const DoadTour = wrap(Tour) |
||||||
|
export const DoadTree = wrap(Tree) |
||||||
|
export const DoadAlert = wrap(Alert) |
||||||
|
export const DoadDrawer = wrap(Drawer) |
||||||
|
export const DoadModal = wrap(Modal) |
||||||
|
export const DoadPopconfirm = wrap(Popconfirm) |
||||||
|
export const DoadProgress = wrap(Progress) |
||||||
|
export const DoadFloatButton = wrap(FloatButton) |
@ -0,0 +1,39 @@ |
|||||||
|
import {FC, ReactNode} from "react"; |
||||||
|
import {DoadSearch, DoadSearchProps} from "./search"; |
||||||
|
import {Avatar, Col, Row} from "antd"; |
||||||
|
import {BellOutlined} from "@ant-design/icons"; |
||||||
|
import {DoadLogo} from './logo' |
||||||
|
|
||||||
|
export interface DoadHeadProps { |
||||||
|
search?: DoadSearchProps | boolean |
||||||
|
logo?: ReactNode |
||||||
|
} |
||||||
|
|
||||||
|
export const DoadHead: FC<DoadHeadProps> = (props) => { |
||||||
|
const searchProps = props.search === true ? {} : props.search |
||||||
|
return ( |
||||||
|
<Row wrap={false} align='middle'> |
||||||
|
<Col flex="187px" style={{ |
||||||
|
alignItems: 'center', |
||||||
|
width: '100%', |
||||||
|
justifyContent: 'start', |
||||||
|
display: 'flex', |
||||||
|
gap: '12px', |
||||||
|
fontSize: '24px', |
||||||
|
}}> |
||||||
|
<DoadLogo element={props.logo}/> |
||||||
|
</Col> |
||||||
|
<Col flex="1px" style={{borderInlineEnd: '1px solid rgba(5, 5, 5, 0.06)', height: '22px'}}/> |
||||||
|
<Col flex="none"> |
||||||
|
{searchProps && <DoadSearch {...searchProps} />} |
||||||
|
</Col> |
||||||
|
<Col flex="auto"/> |
||||||
|
<Col flex="none" style={{display: "flex", alignItems: 'center', gap: '40px'}}> |
||||||
|
<BellOutlined style={{fontSize: '22px', opacity: '0.65'}}/> |
||||||
|
<Avatar src="https://wx.qlogo.cn/mmhead/Q3auHgzwzM7nCvm721MTlkPGQuLWEYYYkV6ibpDI9zcSlkBIbib2Emyw/0"/> |
||||||
|
</Col> |
||||||
|
</Row> |
||||||
|
) |
||||||
|
} |
||||||
|
|
||||||
|
DoadHead.displayName = 'DoadHead' |
@ -0,0 +1,7 @@ |
|||||||
|
.acsRLTLine .ant-tabs-nav::before { |
||||||
|
border-bottom-color: #e4e8eb; |
||||||
|
} |
||||||
|
|
||||||
|
.acsRLTLine .ant-tabs-nav .ant-tabs-ink-bar { |
||||||
|
height: 3px; |
||||||
|
} |
@ -0,0 +1,87 @@ |
|||||||
|
import {CSSProperties, FC, ReactNode, useState} from "react"; |
||||||
|
import {Tabs} from "antd"; |
||||||
|
import {DoadTitle} from './title' |
||||||
|
import {mergeStyles} from "../utils"; |
||||||
|
import './layout.css' |
||||||
|
|
||||||
|
export const doadLayoutStyles: CSSProperties = { |
||||||
|
maxWidth: '1280px', |
||||||
|
width: '85%', |
||||||
|
margin: '40px auto', |
||||||
|
} |
||||||
|
|
||||||
|
export interface DoadGeneralLayoutProps { |
||||||
|
title?: ReactNode |
||||||
|
tailing?: ReactNode |
||||||
|
style?: CSSProperties |
||||||
|
children?: ReactNode |
||||||
|
} |
||||||
|
|
||||||
|
export const DoadGeneralLayout: FC<DoadGeneralLayoutProps> = (props) => { |
||||||
|
return ( |
||||||
|
<div style={mergeStyles(doadLayoutStyles, props.style)}> |
||||||
|
<DoadTitle title={props.title} tailing={props.tailing} /> |
||||||
|
{props.children} |
||||||
|
</div> |
||||||
|
) |
||||||
|
} |
||||||
|
|
||||||
|
DoadGeneralLayout.displayName = 'DoadGeneralLayout' |
||||||
|
|
||||||
|
export interface DoadTabbedLayoutTab { |
||||||
|
key: string |
||||||
|
label: string |
||||||
|
disabled?: boolean |
||||||
|
render?: (ready: boolean) => ReactNode |
||||||
|
} |
||||||
|
|
||||||
|
export interface DoadTabbedLayoutProps { |
||||||
|
title?: ReactNode |
||||||
|
titleTailing?: ReactNode |
||||||
|
subtitle?: ReactNode |
||||||
|
style?: CSSProperties |
||||||
|
activeKey?: string, |
||||||
|
lazy?: boolean |
||||||
|
animated?: boolean |
||||||
|
items: DoadTabbedLayoutTab[] |
||||||
|
} |
||||||
|
|
||||||
|
export const DoadTabbedLayout: FC<DoadTabbedLayoutProps> = (props) => { |
||||||
|
const activeKey = props.activeKey ?? props.items[0]?.key ?? '' |
||||||
|
const [rendered, setRendered] = useState<string[]>([activeKey]) |
||||||
|
const tabs = props.items.map((tab) => { |
||||||
|
return { |
||||||
|
key: tab.key, |
||||||
|
label: <span style={{fontSize: '16px'}}>{tab.label}</span>, |
||||||
|
disabled: tab.disabled, |
||||||
|
children: tab.render?.(props.lazy ? rendered.includes(tab.key) : true), |
||||||
|
} |
||||||
|
}) |
||||||
|
const handleTabChange = (activeKey: string) => { |
||||||
|
if (props.lazy && !rendered.includes(activeKey)) { |
||||||
|
setRendered((keys) => [...keys, activeKey]) |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
// fixme 为什么会被渲染2次
|
||||||
|
|
||||||
|
return ( |
||||||
|
<DoadGeneralLayout |
||||||
|
title={props.title} |
||||||
|
tailing={props.titleTailing} |
||||||
|
style={props.style} |
||||||
|
> |
||||||
|
{props.subtitle} |
||||||
|
<Tabs |
||||||
|
defaultActiveKey={props.activeKey} |
||||||
|
className="acsRLTLine" |
||||||
|
items={tabs} |
||||||
|
size={'small'} |
||||||
|
animated={props.animated} |
||||||
|
onChange={handleTabChange} |
||||||
|
/> |
||||||
|
</DoadGeneralLayout> |
||||||
|
) |
||||||
|
} |
||||||
|
|
||||||
|
DoadTabbedLayout.displayName = 'DoadTabbedLayout' |
@ -0,0 +1,29 @@ |
|||||||
|
import {FC, ReactNode, Suspense, createElement, ComponentType, lazy} from "react"; |
||||||
|
import {Space, Spin} from "antd"; |
||||||
|
|
||||||
|
export type LazyFactory<T> = () => Promise<{ default: T }> |
||||||
|
|
||||||
|
export interface DoadLazyProps { |
||||||
|
children?: LazyFactory<ComponentType<unknown>> |
||||||
|
loading?: ReactNode |
||||||
|
} |
||||||
|
|
||||||
|
const defaultLoading = ( |
||||||
|
<Space align="center" style={{lineHeight: 1, padding: '24px 0'}}> |
||||||
|
<Spin size="small"/> |
||||||
|
<span>加载中...</span> |
||||||
|
</Space> |
||||||
|
) |
||||||
|
|
||||||
|
export const DoadLazy: FC<DoadLazyProps> = (props) => { |
||||||
|
if (props.children == null) { |
||||||
|
return null |
||||||
|
} |
||||||
|
return ( |
||||||
|
<Suspense fallback={props.loading ?? defaultLoading}> |
||||||
|
{createElement(lazy(props.children))} |
||||||
|
</Suspense> |
||||||
|
) |
||||||
|
} |
||||||
|
|
||||||
|
DoadLazy.displayName = 'DoadLazy' |
@ -0,0 +1,36 @@ |
|||||||
|
import {CSSProperties, FC, ReactNode} from 'react'; |
||||||
|
import {mergeStyles} from '../utils'; |
||||||
|
|
||||||
|
export const doadLogoStyle: CSSProperties = { |
||||||
|
display: 'flex', |
||||||
|
justifyContent: 'center', |
||||||
|
alignItems: 'center', |
||||||
|
flexWrap: 'nowrap', |
||||||
|
} |
||||||
|
|
||||||
|
export const defaultLogoElement = ( |
||||||
|
<div style={{display: 'flex', alignItems: 'center', gap: '8px'}}> |
||||||
|
<img |
||||||
|
src="https://gw.alipayobjects.com/zos/rmsportal/KDpgvguMpGfqaHPjicRK.svg" |
||||||
|
height="32" |
||||||
|
width="32" |
||||||
|
alt="logo" |
||||||
|
/> |
||||||
|
<span>Ant Design</span> |
||||||
|
</div> |
||||||
|
) |
||||||
|
|
||||||
|
export interface DoadLogoProps { |
||||||
|
style?: CSSProperties |
||||||
|
element?: ReactNode |
||||||
|
} |
||||||
|
|
||||||
|
export const DoadLogo: FC<DoadLogoProps> = (props) => { |
||||||
|
return ( |
||||||
|
<div style={mergeStyles(doadLogoStyle, props.style)}> |
||||||
|
{props.element ?? defaultLogoElement} |
||||||
|
</div> |
||||||
|
) |
||||||
|
} |
||||||
|
|
||||||
|
DoadLogo.displayName = 'DoadLogo' |
@ -0,0 +1,114 @@ |
|||||||
|
import {FC} from "react"; |
||||||
|
import {useNavigate} from "react-router-dom"; |
||||||
|
import {Menu, MenuProps, Space} from "antd"; |
||||||
|
import { useDoadActiveMenuValue, useDoadMenuItemsValue } from "../state"; |
||||||
|
import {DoadMenuItem} from "../types"; |
||||||
|
|
||||||
|
export const doadMenuWidth = 228 |
||||||
|
|
||||||
|
type MenuItem = Required<MenuProps>['items'][number]; |
||||||
|
|
||||||
|
function getMenuItem(items: DoadMenuItem[], key: string): DoadMenuItem | null { |
||||||
|
for (const item of items) { |
||||||
|
if (item.key === key) { |
||||||
|
return item |
||||||
|
} |
||||||
|
if (item.children != null) { |
||||||
|
const sub = getMenuItem(item.children, key) |
||||||
|
if (sub != null) { |
||||||
|
return sub |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
return null |
||||||
|
} |
||||||
|
|
||||||
|
function toRawMenuItems(items: DoadMenuItem[], deep = 0): MenuItem[] { |
||||||
|
return items.reduce((list, item, i) => { |
||||||
|
let label = item.title |
||||||
|
if (deep === 0 && item.icon != null) { |
||||||
|
label = ( |
||||||
|
<Space style={{color: 'rgba(0,0,0,.88)', fontSize: '18px', width: '100%'}} size='middle'> |
||||||
|
<span style={{opacity: 0.6}}>{item.icon}</span> |
||||||
|
<span>{item.title}</span> |
||||||
|
</Space> |
||||||
|
) |
||||||
|
} |
||||||
|
let type: 'group' | undefined |
||||||
|
if (deep == 0) { |
||||||
|
type = 'group' |
||||||
|
} |
||||||
|
let children: MenuItem[] | undefined |
||||||
|
if (item.children?.length) { |
||||||
|
children = toRawMenuItems(item.children, deep + 1) |
||||||
|
} else if (deep == 0) { |
||||||
|
children = toRawMenuItems([item], deep + 1) |
||||||
|
} |
||||||
|
if (deep == 0 && i !== items.length - 1) { |
||||||
|
children ??= [] |
||||||
|
children.push( { |
||||||
|
type: "divider", |
||||||
|
style: { |
||||||
|
width: '43%', |
||||||
|
margin: '12px 0 12px 50px', |
||||||
|
} |
||||||
|
}) |
||||||
|
} |
||||||
|
|
||||||
|
list.push({ |
||||||
|
key: item.key, |
||||||
|
label, |
||||||
|
type, |
||||||
|
children, |
||||||
|
style: { |
||||||
|
padding: type ? "0 18px" : "0 18px 0 46px", |
||||||
|
textAlign: "left", |
||||||
|
} |
||||||
|
}) |
||||||
|
|
||||||
|
return list |
||||||
|
}, [] as MenuItem[]) |
||||||
|
} |
||||||
|
|
||||||
|
export interface DoadMenuProps { |
||||||
|
onClick?: (item: DoadMenuItem, handled: boolean) => void |
||||||
|
} |
||||||
|
|
||||||
|
export const DoadMenu: FC<DoadMenuProps> = (props) => { |
||||||
|
const menuItems = useDoadMenuItemsValue() |
||||||
|
const activeMenuKey = useDoadActiveMenuValue() |
||||||
|
const navigate = useNavigate(); |
||||||
|
|
||||||
|
const handleClick: MenuProps['onClick'] = (e) => { |
||||||
|
const item = getMenuItem(menuItems, e.key) |
||||||
|
let handled = true |
||||||
|
if (item?.handle != null) { |
||||||
|
item.handle(item, navigate) |
||||||
|
} else if (item?.path != null) { |
||||||
|
navigate(item.path) |
||||||
|
} else { |
||||||
|
handled = false |
||||||
|
} |
||||||
|
if (item != null) { |
||||||
|
props.onClick?.(item, handled) |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
return ( |
||||||
|
<Menu |
||||||
|
selectedKeys={[activeMenuKey].filter(Boolean) as string[]} |
||||||
|
style={{ |
||||||
|
width: doadMenuWidth, |
||||||
|
margin: '40px 0', |
||||||
|
userSelect: 'none', |
||||||
|
backgroundColor: 'transparent', |
||||||
|
}} |
||||||
|
mode="inline" |
||||||
|
inlineIndent={0} |
||||||
|
items={toRawMenuItems(menuItems)} |
||||||
|
onClick={handleClick} |
||||||
|
/> |
||||||
|
) |
||||||
|
} |
||||||
|
|
||||||
|
DoadMenu.displayName = 'DoadMenu' |
@ -0,0 +1,48 @@ |
|||||||
|
import {CSSProperties, FC, ReactNode} from "react"; |
||||||
|
import {Layout} from "antd"; |
||||||
|
import {DoadSearchProps} from "./search"; |
||||||
|
import {DoadMenu, DoadMenuProps, doadMenuWidth} from "./menu"; |
||||||
|
import {DoadHead} from "./head"; |
||||||
|
|
||||||
|
export const doadHeaderStyle: CSSProperties = { |
||||||
|
position: 'sticky', |
||||||
|
top: 0, |
||||||
|
zIndex: 3, |
||||||
|
textAlign: 'center', |
||||||
|
height: 64, |
||||||
|
paddingInline: 40, |
||||||
|
lineHeight: '64px', |
||||||
|
backgroundColor: '#ffffff', |
||||||
|
boxShadow: '0 1px 2px rgba(150, 150, 150, 0.3)', |
||||||
|
} |
||||||
|
|
||||||
|
export interface DoadRootProps { |
||||||
|
logo?: ReactNode |
||||||
|
footer?: ReactNode |
||||||
|
children?: ReactNode |
||||||
|
search?: DoadSearchProps | boolean |
||||||
|
onMenuClick?: DoadMenuProps['onClick'] |
||||||
|
} |
||||||
|
|
||||||
|
export const DoadRoot: FC<DoadRootProps> = (props) => { |
||||||
|
return ( |
||||||
|
<Layout style={{backgroundColor: 'transparent'}}> |
||||||
|
<Layout.Header style={{...doadHeaderStyle}}> |
||||||
|
<DoadHead search={props.search} logo={props.logo}/> |
||||||
|
</Layout.Header> |
||||||
|
<Layout style={{backgroundColor: 'transparent'}}> |
||||||
|
<Layout.Sider style={{backgroundColor: 'transparent'}} width={doadMenuWidth}> |
||||||
|
<DoadMenu onClick={props.onMenuClick}/> |
||||||
|
</Layout.Sider> |
||||||
|
<Layout.Content> |
||||||
|
{props.children} |
||||||
|
</Layout.Content> |
||||||
|
</Layout> |
||||||
|
{props.footer && <Layout.Footer> |
||||||
|
{props.footer} |
||||||
|
</Layout.Footer>} |
||||||
|
</Layout> |
||||||
|
) |
||||||
|
} |
||||||
|
|
||||||
|
DoadRoot.displayName = 'DoadRoot' |
@ -0,0 +1,36 @@ |
|||||||
|
import {CSSProperties, FC, ReactNode} from "react"; |
||||||
|
import {Input} from "antd"; |
||||||
|
import {SearchOutlined} from "@ant-design/icons"; |
||||||
|
import {mergeStyles} from "../utils"; |
||||||
|
|
||||||
|
export const doadSearchShortcutStyle: CSSProperties = { |
||||||
|
color: '#ced4d9', |
||||||
|
backgroundColor: 'rgba(150, 150, 150, 0.06)', |
||||||
|
border: '1px solid rgba(100, 100, 100, 0.2)', |
||||||
|
borderRadius: '4px', |
||||||
|
padding: '4px 8px', |
||||||
|
fontSize: '12px', |
||||||
|
lineHeight: 1, |
||||||
|
} |
||||||
|
|
||||||
|
export interface DoadSearchProps { |
||||||
|
placeholder?: string |
||||||
|
shortcut?: ReactNode |
||||||
|
bordered?: boolean |
||||||
|
style?: CSSProperties |
||||||
|
shortcutStyle?: CSSProperties |
||||||
|
} |
||||||
|
|
||||||
|
export const DoadSearch: FC<DoadSearchProps> = (props) => { |
||||||
|
return ( |
||||||
|
<Input |
||||||
|
style={props.style} |
||||||
|
prefix={<SearchOutlined/>} |
||||||
|
suffix={props.shortcut ?? <span style={mergeStyles(doadSearchShortcutStyle, props.shortcutStyle)}>Ctrl+K</span>} |
||||||
|
placeholder={props.placeholder ?? '请输入关键字搜索'} |
||||||
|
bordered={props.bordered ?? false} |
||||||
|
/> |
||||||
|
) |
||||||
|
} |
||||||
|
|
||||||
|
DoadSearch.displayName = 'DoadSearch' |
@ -0,0 +1,47 @@ |
|||||||
|
import {FC, ReactNode} from "react"; |
||||||
|
import {Col, Row} from "antd"; |
||||||
|
|
||||||
|
export interface DoadTitleProps { |
||||||
|
title?: ReactNode |
||||||
|
tailing?: ReactNode |
||||||
|
} |
||||||
|
|
||||||
|
export const DoadTitle: FC<DoadTitleProps> = (props) => { |
||||||
|
if (props.title == null) { |
||||||
|
return null |
||||||
|
} |
||||||
|
if (props.tailing == null) { |
||||||
|
return ( |
||||||
|
<h2 style={{ |
||||||
|
fontSize: '26px', |
||||||
|
lineHeight: '36px', |
||||||
|
marginBottom: '12px', |
||||||
|
fontWeight: 400, |
||||||
|
fontStyle: 'normal', |
||||||
|
}}>{props.title}</h2> |
||||||
|
) |
||||||
|
} |
||||||
|
return ( |
||||||
|
<Row |
||||||
|
style={{marginBottom: '12px'}} |
||||||
|
justify="space-between" |
||||||
|
align="middle" |
||||||
|
gutter={36} |
||||||
|
wrap={false} |
||||||
|
> |
||||||
|
<Col flex='none'> |
||||||
|
<h2 style={{ |
||||||
|
fontSize: '26px', |
||||||
|
lineHeight: '36px', |
||||||
|
fontWeight: 400, |
||||||
|
fontStyle: 'normal', |
||||||
|
}}>{props.title}</h2> |
||||||
|
</Col> |
||||||
|
<Col flex='none'> |
||||||
|
{props.tailing} |
||||||
|
</Col> |
||||||
|
</Row> |
||||||
|
) |
||||||
|
} |
||||||
|
|
||||||
|
DoadTitle.displayName = 'DoadTitle' |
@ -0,0 +1,10 @@ |
|||||||
|
export * from './components/access' |
||||||
|
export * from './components/head' |
||||||
|
export * from './components/layout' |
||||||
|
export * from './components/lazy' |
||||||
|
export * from './components/logo' |
||||||
|
export * from './components/menu' |
||||||
|
export * from './components/root' |
||||||
|
export * from './components/search' |
||||||
|
export * from './state' |
||||||
|
export * from './types' |
@ -0,0 +1,59 @@ |
|||||||
|
import { |
||||||
|
DefaultValue, |
||||||
|
atom, |
||||||
|
selector, |
||||||
|
useRecoilState, |
||||||
|
useRecoilValue, |
||||||
|
useSetRecoilState |
||||||
|
} from "recoil"; |
||||||
|
import {DoadProfile} from "./types"; |
||||||
|
|
||||||
|
export const radProfile = atom<DoadProfile>({ |
||||||
|
key: 'acsProfile', |
||||||
|
default: { |
||||||
|
menuItems: [], |
||||||
|
routes: [], |
||||||
|
permissions: [], |
||||||
|
} |
||||||
|
}) |
||||||
|
|
||||||
|
function createSelector<K extends keyof DoadProfile>( |
||||||
|
key: string, |
||||||
|
field: K, |
||||||
|
defValue?: () => DoadProfile[K], |
||||||
|
) { |
||||||
|
return selector<DoadProfile[K]>({ |
||||||
|
key, |
||||||
|
get({get}) { |
||||||
|
return get(radProfile)[field] |
||||||
|
}, |
||||||
|
set({set}, newValue) { |
||||||
|
set(radProfile, v => ({ |
||||||
|
...v, |
||||||
|
[field]: newValue instanceof DefaultValue ? defValue?.() : newValue, |
||||||
|
})) |
||||||
|
}, |
||||||
|
}) |
||||||
|
} |
||||||
|
|
||||||
|
const activeMenuKeyState = createSelector('doadActiveMenuKeyState', 'activeMenuKey') |
||||||
|
const menuItemsState = createSelector('doadMenuItemsState', 'menuItems', () => []) |
||||||
|
const userState = createSelector('doadUserState', 'user') |
||||||
|
const permissionsState = createSelector('doadPermissionsState', 'permissions', () => []) |
||||||
|
const routesState = createSelector('doadRoutesState', 'routes', () => []) |
||||||
|
|
||||||
|
export const useDoadActiveMenuState = () => useRecoilState(activeMenuKeyState) |
||||||
|
export const useDoadActiveMenuSetter = () => useSetRecoilState(activeMenuKeyState) |
||||||
|
export const useDoadActiveMenuValue = () => useRecoilValue(activeMenuKeyState) |
||||||
|
export const useDoadMenuItemsState = () => useRecoilState(menuItemsState) |
||||||
|
export const useDoadMenuItemsSetter = () => useSetRecoilState(menuItemsState) |
||||||
|
export const useDoadMenuItemsValue = () => useRecoilValue(menuItemsState) |
||||||
|
export const useDoadUserState = () => useRecoilState(userState) |
||||||
|
export const useDoadUserSetter = () => useSetRecoilState(userState) |
||||||
|
export const useDoadUserValue = () => useRecoilValue(userState) |
||||||
|
export const useDoadPermissionState = () => useRecoilState(permissionsState) |
||||||
|
export const useDoadPermissionSetter = () => useSetRecoilState(permissionsState) |
||||||
|
export const useDoadPermissionValue = () => useRecoilValue(permissionsState) |
||||||
|
export const useDoadRoutesState = () => useRecoilState(routesState) |
||||||
|
export const useDoadRoutesSetter = () => useSetRecoilState(routesState) |
||||||
|
export const useDoadRoutesValue = () => useRecoilValue(routesState) |
@ -0,0 +1,57 @@ |
|||||||
|
/** 布局菜单选项 */ |
||||||
|
export interface DoadMenuItem { |
||||||
|
/** 菜单唯一键 */ |
||||||
|
key: string |
||||||
|
/** 菜单标题 */ |
||||||
|
title: import('react').ReactNode |
||||||
|
/** 菜单图标 */ |
||||||
|
icon?: import('react').ReactNode |
||||||
|
/** 菜单跳转路径 */ |
||||||
|
path?: string |
||||||
|
/** 子菜单 */ |
||||||
|
children?: DoadMenuItem[] |
||||||
|
/** 自定义点击函数,优先级高于 {@link DoadMenuItem.path} */ |
||||||
|
handle?: (item: DoadMenuItem, navigate: import('react-router-dom').NavigateFunction) => void |
||||||
|
/** 其它自定义数据 */ |
||||||
|
[key: string]: unknown |
||||||
|
} |
||||||
|
|
||||||
|
/** 登录用户信息 */ |
||||||
|
export interface DoadUser { |
||||||
|
/** 用户昵称 */ |
||||||
|
nickname: string |
||||||
|
/** 用户头像 */ |
||||||
|
avatar?: string |
||||||
|
/** 其它自定义数据 */ |
||||||
|
[key: string]: unknown |
||||||
|
} |
||||||
|
|
||||||
|
/** 权限 */ |
||||||
|
export interface DoadPermission { |
||||||
|
/** 权限唯一键 */ |
||||||
|
key: string | number |
||||||
|
/** 权限名称 */ |
||||||
|
title?: string |
||||||
|
/** 其它自定义数据 */ |
||||||
|
[key: string]: unknown |
||||||
|
} |
||||||
|
|
||||||
|
|
||||||
|
/** 当前用户环境 */ |
||||||
|
export interface DoadProfile { |
||||||
|
/** 登录的用户信息 */ |
||||||
|
user?: DoadUser |
||||||
|
/** 有效的菜单 */ |
||||||
|
menuItems: DoadMenuItem[] |
||||||
|
/** 有效的路由 */ |
||||||
|
routes: string[] |
||||||
|
/** 被允许的权限 */ |
||||||
|
permissions: DoadPermission[] |
||||||
|
/** 当前菜单键 */ |
||||||
|
activeMenuKey?: string |
||||||
|
} |
||||||
|
|
||||||
|
export type DoadLayoutStatus = |
||||||
|
| 'loading' |
||||||
|
| 'success' |
||||||
|
| 'error' |
@ -0,0 +1,12 @@ |
|||||||
|
import {ComponentType} from "react"; |
||||||
|
|
||||||
|
export function toArray<T>(t: T | T[]): T[] | undefined { |
||||||
|
if (t == null) return undefined |
||||||
|
return Array.isArray(t) ? t : [t] |
||||||
|
} |
||||||
|
|
||||||
|
export function getDisplayName(Component: unknown) { |
||||||
|
return (Component as ComponentType).displayName |
||||||
|
|| (Component as ComponentType).name |
||||||
|
|| 'Component' |
||||||
|
} |
@ -0,0 +1,11 @@ |
|||||||
|
import {CSSProperties} from "react"; |
||||||
|
|
||||||
|
export const mergeStyles = (style: CSSProperties, ...others: Array<CSSProperties | undefined>): CSSProperties => { |
||||||
|
let merged = {...style} |
||||||
|
for (const other of others) { |
||||||
|
if (other != null) { |
||||||
|
merged = {...merged, ...other} |
||||||
|
} |
||||||
|
} |
||||||
|
return merged |
||||||
|
} |
@ -0,0 +1,2 @@ |
|||||||
|
export * from './array' |
||||||
|
export * from './css' |
@ -0,0 +1,26 @@ |
|||||||
|
{ |
||||||
|
"compilerOptions": { |
||||||
|
"target": "ES2020", |
||||||
|
"useDefineForClassFields": true, |
||||||
|
"lib": ["ES2020", "DOM", "DOM.Iterable"], |
||||||
|
"module": "ESNext", |
||||||
|
"skipLibCheck": true, |
||||||
|
|
||||||
|
/* Bundler mode */ |
||||||
|
"moduleResolution": "bundler", |
||||||
|
"allowImportingTsExtensions": true, |
||||||
|
"resolveJsonModule": true, |
||||||
|
"isolatedModules": true, |
||||||
|
"noEmit": true, |
||||||
|
"jsx": "react-jsx", |
||||||
|
"baseUrl": ".", |
||||||
|
|
||||||
|
/* Linting */ |
||||||
|
"strict": true, |
||||||
|
"noUnusedLocals": true, |
||||||
|
"noUnusedParameters": true, |
||||||
|
"noFallthroughCasesInSwitch": true |
||||||
|
}, |
||||||
|
"include": ["src"], |
||||||
|
"references": [{ "path": "./tsconfig.node.json" }] |
||||||
|
} |
@ -0,0 +1,10 @@ |
|||||||
|
{ |
||||||
|
"compilerOptions": { |
||||||
|
"composite": true, |
||||||
|
"skipLibCheck": true, |
||||||
|
"module": "ESNext", |
||||||
|
"moduleResolution": "bundler", |
||||||
|
"allowSyntheticDefaultImports": true |
||||||
|
}, |
||||||
|
"include": ["vite.config.ts"] |
||||||
|
} |
@ -0,0 +1,7 @@ |
|||||||
|
{ |
||||||
|
"name": "@doad/mock", |
||||||
|
"version": "1.0.0", |
||||||
|
"dependencies": { |
||||||
|
"path-to-regexp": "^6.2.1" |
||||||
|
} |
||||||
|
} |
@ -0,0 +1,28 @@ |
|||||||
|
import {DefineOptions, MockMethod} from "./types"; |
||||||
|
import {setMockData} from "./utils"; |
||||||
|
|
||||||
|
export function defineMock(opts: DefineOptions) { |
||||||
|
if (!(import.meta as any).env?.VITE_USE_MOCK) { |
||||||
|
return |
||||||
|
} |
||||||
|
let items: MockMethod | Array<MockMethod> | undefined |
||||||
|
if (typeof opts === 'function') { |
||||||
|
items = opts() |
||||||
|
} else if (Array.isArray(opts)) { |
||||||
|
items = opts |
||||||
|
} else if ('setup' in opts) { |
||||||
|
if (opts.mock || opts.mock == null) { |
||||||
|
items = opts.setup() |
||||||
|
} |
||||||
|
} else { |
||||||
|
items = opts |
||||||
|
} |
||||||
|
if (!items) { |
||||||
|
return |
||||||
|
} |
||||||
|
if (Array.isArray(items)) { |
||||||
|
items.forEach(setMockData) |
||||||
|
} else { |
||||||
|
setMockData(items) |
||||||
|
} |
||||||
|
} |
@ -0,0 +1,99 @@ |
|||||||
|
// import {Connect} from "vite";
|
||||||
|
// import IncomingMessage = Connect.IncomingMessage;
|
||||||
|
// import {ServerResponse} from "http";
|
||||||
|
// import TypedArray = NodeJS.TypedArray;
|
||||||
|
//
|
||||||
|
// export type MockMethod =
|
||||||
|
// | 'get'
|
||||||
|
// | 'post'
|
||||||
|
// | 'put'
|
||||||
|
// | 'delete'
|
||||||
|
// | 'patch'
|
||||||
|
// | 'head'
|
||||||
|
// | 'options'
|
||||||
|
//
|
||||||
|
// export interface MockContext {
|
||||||
|
// params: Record<string, any> | null
|
||||||
|
// query: URLSearchParams
|
||||||
|
// body: () => Promise<Record<string, any> | null>
|
||||||
|
// headers?: Record<string, string>
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// export interface ServerMockContext extends MockContext {
|
||||||
|
// request: IncomingMessage
|
||||||
|
// response: ServerResponse
|
||||||
|
// json: () => unknown
|
||||||
|
// text: () => string
|
||||||
|
// buffer: () => TypedArray
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// export interface MockItem<T = unknown> {
|
||||||
|
// url: string
|
||||||
|
// method?: MockMethod
|
||||||
|
// timeout?: number
|
||||||
|
// statusCode?: number
|
||||||
|
// response?: (ctx: MockContext) => Promise<T> | T
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// export type DefineFunction = () => MockItem | MockItem[]
|
||||||
|
//
|
||||||
|
// export interface DefineMockOptions {
|
||||||
|
// mock?: boolean,
|
||||||
|
// setup: DefineFunction
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// export type DefineOptions =
|
||||||
|
// | DefineMockOptions
|
||||||
|
// | DefineFunction
|
||||||
|
// | MockItem
|
||||||
|
// | MockItem[]
|
||||||
|
|
||||||
|
import { IncomingMessage, ServerResponse as RawServerResponse } from 'http' |
||||||
|
|
||||||
|
export type MethodType = 'get' | 'post' | 'put' | 'delete' | 'patch' |
||||||
|
|
||||||
|
export interface ServerResponse extends RawServerResponse { |
||||||
|
text(): Promise<string> |
||||||
|
json(): Promise<unknown> |
||||||
|
buffer(): Promise<Uint8Array> |
||||||
|
} |
||||||
|
|
||||||
|
export interface MockContext { |
||||||
|
/** 发起请求的路径 */ |
||||||
|
url: string |
||||||
|
/** 发起请求的参数 */ |
||||||
|
method: string |
||||||
|
/** 路径上面解析到的参数 */ |
||||||
|
params: Record<string, any> | null |
||||||
|
/** 解析到的 QueryString 参数 */ |
||||||
|
query: URLSearchParams |
||||||
|
/** 提交的报头 */ |
||||||
|
headers?: Record<string, string> |
||||||
|
/** 提交的内容 */ |
||||||
|
body: () => Promise<Record<string, any> | string | null> |
||||||
|
/** 请求结构,利用服务器端 mock 时有效 */ |
||||||
|
request?: IncomingMessage |
||||||
|
/** 响应结构,利用服务器端 mock 时有效 */ |
||||||
|
response: ServerResponse |
||||||
|
} |
||||||
|
|
||||||
|
export interface MockMethod { |
||||||
|
url: string |
||||||
|
method?: MethodType | MethodType[] |
||||||
|
timeout?: number |
||||||
|
statusCode?: number |
||||||
|
response: (ctx: MockContext) => unknown |
||||||
|
} |
||||||
|
|
||||||
|
export type DefineFunction = () => MockMethod | Array<MockMethod> |
||||||
|
|
||||||
|
export interface DefineMockOptions { |
||||||
|
mock?: boolean, |
||||||
|
setup: DefineFunction |
||||||
|
} |
||||||
|
|
||||||
|
export type DefineOptions = |
||||||
|
| DefineMockOptions |
||||||
|
| DefineFunction |
||||||
|
| MockMethod |
||||||
|
| Array<MockMethod> |
@ -0,0 +1,28 @@ |
|||||||
|
import { match, pathToRegexp } from 'path-to-regexp' |
||||||
|
import {MockContext, MockMethod} from "./types"; |
||||||
|
|
||||||
|
interface MockItemNormalized<T = unknown> { |
||||||
|
url: string |
||||||
|
method: string |
||||||
|
regex: RegExp |
||||||
|
match: (path: string) => Record<string, unknown> | null |
||||||
|
timeout?: number |
||||||
|
statusCode?: number |
||||||
|
response?: (ctx: MockContext) => T |
||||||
|
} |
||||||
|
|
||||||
|
const mockItems: MockItemNormalized[] = [] |
||||||
|
|
||||||
|
export function setMockData(data: MockMethod) { |
||||||
|
mockItems.push({ |
||||||
|
...data, |
||||||
|
method: ((data.method ?? 'get') as string).toUpperCase(), |
||||||
|
regex: pathToRegexp(data.url), |
||||||
|
match(path: string): Record<string, unknown> | null { |
||||||
|
const urlMatch = match(data.url, { decode: decodeURIComponent }) |
||||||
|
const res = urlMatch(path) |
||||||
|
if (!res) return null |
||||||
|
return res.params as Record<string, unknown> |
||||||
|
}, |
||||||
|
}) |
||||||
|
} |
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,3 @@ |
|||||||
|
packages: |
||||||
|
- 'packages/*' |
||||||
|
- 'example' |
@ -0,0 +1,27 @@ |
|||||||
|
// @ts-check
|
||||||
|
import chalk from 'chalk' |
||||||
|
import {readFileSync} from 'node:fs' |
||||||
|
import path from 'node:path' |
||||||
|
|
||||||
|
const msgPath = path.resolve('.git/COMMIT_EDITMSG') |
||||||
|
const msg = readFileSync(msgPath, 'utf-8').trim() |
||||||
|
const commitRE = |
||||||
|
/^(revert: )?(feat|fix|docs|dx|style|refactor|perf|test|workflow|build|ci|chore|types|wip|release)(\(.+\))?: .{1,50}/ |
||||||
|
|
||||||
|
if (!commitRE.test(msg)) { |
||||||
|
console.log() |
||||||
|
console.error( |
||||||
|
` ${chalk.bgRed.white(' ERROR ')} ${chalk.red( |
||||||
|
`invalid commit message format.` |
||||||
|
)}\n\n` +
|
||||||
|
chalk.red( |
||||||
|
` Proper commit message format is required for automated changelog generation. Examples:\n\n` |
||||||
|
) + |
||||||
|
` ${chalk.green(`feat(compiler): add 'comments' option`)}\n` + |
||||||
|
` ${chalk.green( |
||||||
|
`fix(v-model): handle events on blur (close #28)` |
||||||
|
)}\n\n` +
|
||||||
|
chalk.red(` See .github/commit-convention.md for more details.\n`) |
||||||
|
) |
||||||
|
process.exit(1) |
||||||
|
} |
@ -0,0 +1,27 @@ |
|||||||
|
{ |
||||||
|
"compilerOptions": { |
||||||
|
"target": "ES2020", |
||||||
|
"useDefineForClassFields": true, |
||||||
|
"lib": ["ES2020", "DOM", "DOM.Iterable"], |
||||||
|
"module": "ESNext", |
||||||
|
"skipLibCheck": true, |
||||||
|
|
||||||
|
/* Bundler mode */ |
||||||
|
"moduleResolution": "bundler", |
||||||
|
"allowImportingTsExtensions": true, |
||||||
|
"resolveJsonModule": true, |
||||||
|
"isolatedModules": true, |
||||||
|
"noEmit": true, |
||||||
|
"jsx": "react-jsx", |
||||||
|
"rootDir": ".", |
||||||
|
/* Linting */ |
||||||
|
"strict": true, |
||||||
|
"strictNullChecks": true, |
||||||
|
"noUnusedLocals": true, |
||||||
|
"noUnusedParameters": true, |
||||||
|
"noFallthroughCasesInSwitch": true |
||||||
|
}, |
||||||
|
"include": ["src"], |
||||||
|
"exclude": ["node_modules"], |
||||||
|
"references": [{ "path": "./tsconfig.node.json" }] |
||||||
|
} |
@ -0,0 +1,10 @@ |
|||||||
|
{ |
||||||
|
"compilerOptions": { |
||||||
|
"composite": true, |
||||||
|
"skipLibCheck": true, |
||||||
|
"module": "ESNext", |
||||||
|
"moduleResolution": "bundler", |
||||||
|
"allowSyntheticDefaultImports": true |
||||||
|
}, |
||||||
|
"include": ["vite.config.ts"] |
||||||
|
} |
@ -0,0 +1,12 @@ |
|||||||
|
import {defineConfig} from "vitest/config"; |
||||||
|
|
||||||
|
export default defineConfig({ |
||||||
|
define: { |
||||||
|
__DEV__: true, |
||||||
|
__VERSION__: '"test"', |
||||||
|
}, |
||||||
|
test: { |
||||||
|
globals: true, |
||||||
|
threads: !process.env.GITHUB_ACTIONS, |
||||||
|
} |
||||||
|
}) |
Loading…
Reference in new issue