commit c4211ddf7ca1935cee83734b6fedddfc651a4c84 Author: FatttSnake Date: Sun Sep 3 16:05:52 2023 +0800 Init diff --git a/.eslintrc.cjs b/.eslintrc.cjs new file mode 100644 index 0000000..8d0ca95 --- /dev/null +++ b/.eslintrc.cjs @@ -0,0 +1,40 @@ +/* eslint-env node */ + +module.exports = { + root: true, + env: { browser: true, es2020: true }, + extends: [ + 'eslint:recommended', + 'plugin:@typescript-eslint/recommended', + 'plugin:@typescript-eslint/recommended-requiring-type-checking', + 'plugin:react-hooks/recommended', + 'plugin:prettier/recommended', + './.eslintrc-auto-import.json' + ], + parser: '@typescript-eslint/parser', + parserOptions: { + ecmaVersion: 'latest', + sourceType: 'module', + project: './tsconfig*.json', + tsconfigRootDir: __dirname, + }, + plugins: [ + 'react-refresh', + 'prettier'], + rules: { + 'no-cond-assign': 'error', + 'eqeqeq': 'error', + 'indent': ['error', 4, { 'SwitchCase': 1 }], + 'prettier/prettier': [ + 'error', + { + endOfLine: 'auto' + } + ], + 'react-refresh/only-export-components': [ + 'warn', + { allowConstantExport: true } + ], + '@typescript-eslint/no-non-null-assertion': 'off' + } +} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d2b76e2 --- /dev/null +++ b/.gitignore @@ -0,0 +1,28 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? + +# Auto generated +/auto-imports.d.ts +/.eslintrc-auto-import.json diff --git a/.prettierrc.json b/.prettierrc.json new file mode 100644 index 0000000..24f1a24 --- /dev/null +++ b/.prettierrc.json @@ -0,0 +1,8 @@ +{ + "$schema": "https://json.schemastore.org/prettierrc", + "semi": false, + "tabWidth": 4, + "singleQuote": true, + "printWidth": 100, + "trailingComma": "none" +} \ No newline at end of file diff --git a/build/resolvers/antd.ts b/build/resolvers/antd.ts new file mode 100644 index 0000000..469af1b --- /dev/null +++ b/build/resolvers/antd.ts @@ -0,0 +1,421 @@ +export function kebabCase(key: string): string { + const result: string = key.replace(/([A-Z])/g, ' $1').trim() + return result.split(' ').join('-').toLowerCase() +} + +export type Awaitable = T | PromiseLike + +export interface ImportInfo { + as?: string + name?: string + from: string +} + +export type SideEffectsInfo = (ImportInfo | string)[] | ImportInfo | string | undefined + +export interface ComponentInfo extends ImportInfo { + sideEffects?: SideEffectsInfo +} + +export type ComponentResolveResult = Awaitable + +export type ComponentResolverFunction = (name: string) => ComponentResolveResult + +export interface ComponentResolverObject { + type: 'component' | 'directive' + resolve: ComponentResolverFunction +} + +export type ComponentResolver = ComponentResolverFunction | ComponentResolverObject + +interface IMatcher { + pattern: RegExp + styleDir: string +} + +const matchComponents: IMatcher[] = [ + { + pattern: /^Avatar/, + styleDir: 'avatar' + }, + { + pattern: /^AutoComplete/, + styleDir: 'auto-complete' + }, + { + pattern: /^Anchor/, + styleDir: 'anchor' + }, + + { + pattern: /^Badge/, + styleDir: 'badge' + }, + { + pattern: /^Breadcrumb/, + styleDir: 'breadcrumb' + }, + { + pattern: /^Button/, + styleDir: 'button' + }, + { + pattern: /^Checkbox/, + styleDir: 'checkbox' + }, + { + pattern: /^Card/, + styleDir: 'card' + }, + { + pattern: /^Collapse/, + styleDir: 'collapse' + }, + { + pattern: /^Descriptions/, + styleDir: 'descriptions' + }, + { + pattern: /^RangePicker|^WeekPicker|^MonthPicker/, + styleDir: 'date-picker' + }, + { + pattern: /^Dropdown/, + styleDir: 'dropdown' + }, + + { + pattern: /^Form/, + styleDir: 'form' + }, + { + pattern: /^InputNumber/, + styleDir: 'input-number' + }, + + { + pattern: /^Input|^Textarea/, + styleDir: 'input' + }, + { + pattern: /^Statistic/, + styleDir: 'statistic' + }, + { + pattern: /^CheckableTag/, + styleDir: 'tag' + }, + { + pattern: /^TimeRangePicker/, + styleDir: 'time-picker' + }, + { + pattern: /^Layout/, + styleDir: 'layout' + }, + { + pattern: /^Menu|^SubMenu/, + styleDir: 'menu' + }, + + { + pattern: /^Table/, + styleDir: 'table' + }, + { + pattern: /^TimePicker|^TimeRangePicker/, + styleDir: 'time-picker' + }, + { + pattern: /^Radio/, + styleDir: 'radio' + }, + + { + pattern: /^Image/, + styleDir: 'image' + }, + + { + pattern: /^List/, + styleDir: 'list' + }, + + { + pattern: /^Tab/, + styleDir: 'tabs' + }, + { + pattern: /^Mentions/, + styleDir: 'mentions' + }, + + { + pattern: /^Step/, + styleDir: 'steps' + }, + { + pattern: /^Skeleton/, + styleDir: 'skeleton' + }, + + { + pattern: /^Select/, + styleDir: 'select' + }, + { + pattern: /^TreeSelect/, + styleDir: 'tree-select' + }, + { + pattern: /^Tree|^DirectoryTree/, + styleDir: 'tree' + }, + { + pattern: /^Typography/, + styleDir: 'typography' + }, + { + pattern: /^Timeline/, + styleDir: 'timeline' + }, + { + pattern: /^Upload/, + styleDir: 'upload' + } +] + +export interface AntDesignResolverOptions { + /** + * exclude components that do not require automatic import + * + * @default [] + */ + exclude?: string[] + /** + * import style along with components + * + * @default 'css' + */ + importStyle?: boolean | 'css' | 'less' + /** + * resolve `antd' icons + * + * requires package `@ant-design/icons-vue` + * + * @default false + */ + resolveIcons?: boolean + + /** + * @deprecated use `importStyle: 'css'` instead + */ + importCss?: boolean + /** + * @deprecated use `importStyle: 'less'` instead + */ + importLess?: boolean + + /** + * use commonjs build default false + */ + cjs?: boolean + + /** + * rename package + * + * @default 'antd' + */ + packageName?: string +} + +const getStyleDir = (compName: string): string => { + for (const matchComponent of matchComponents) { + if (compName.match(matchComponent.pattern)) { + return matchComponent.styleDir + } + } + return kebabCase(compName) +} + +const getSideEffects = (compName: string, options: AntDesignResolverOptions): SideEffectsInfo => { + const { importStyle = true } = options + + if (!importStyle) { + return + } + + const lib = options.cjs ? 'lib' : 'es' + const packageName = options?.packageName || 'antd' + + const styleDir = getStyleDir(compName) + return `${packageName}/${lib}/${styleDir}/style` +} + +const primitiveNames = [ + 'Affix', + 'Anchor', + 'AnchorLink', + 'AutoComplete', + 'AutoCompleteOptGroup', + 'AutoCompleteOption', + 'Alert', + 'Avatar', + 'AvatarGroup', + 'BackTop', + 'Badge', + 'BadgeRibbon', + 'Breadcrumb', + 'BreadcrumbItem', + 'BreadcrumbSeparator', + 'Button', + 'ButtonGroup', + 'Calendar', + 'Card', + 'CardGrid', + 'CardMeta', + 'Collapse', + 'CollapsePanel', + 'Carousel', + 'Cascader', + 'Checkbox', + 'CheckboxGroup', + 'Col', + 'Comment', + 'ConfigProvider', + 'DatePicker', + 'MonthPicker', + 'WeekPicker', + 'RangePicker', + 'QuarterPicker', + 'Descriptions', + 'DescriptionsItem', + 'Divider', + 'Dropdown', + 'DropdownButton', + 'Drawer', + 'Empty', + 'Form', + 'FormItem', + 'FormItemRest', + 'Grid', + 'Input', + 'InputGroup', + 'InputPassword', + 'InputSearch', + 'Textarea', + 'Image', + 'ImagePreviewGroup', + 'InputNumber', + 'Layout', + 'LayoutHeader', + 'LayoutSider', + 'LayoutFooter', + 'LayoutContent', + 'List', + 'ListItem', + 'ListItemMeta', + 'Menu', + 'MenuDivider', + 'MenuItem', + 'MenuItemGroup', + 'SubMenu', + 'Mentions', + 'MentionsOption', + 'Modal', + 'Statistic', + 'StatisticCountdown', + 'PageHeader', + 'Pagination', + 'Popconfirm', + 'Popover', + 'Progress', + 'Radio', + 'RadioButton', + 'RadioGroup', + 'Rate', + 'Result', + 'Row', + 'Select', + 'SelectOptGroup', + 'SelectOption', + 'Skeleton', + 'SkeletonButton', + 'SkeletonAvatar', + 'SkeletonInput', + 'SkeletonImage', + 'Slider', + 'Space', + 'Spin', + 'Steps', + 'Step', + 'Switch', + 'Table', + 'TableColumn', + 'TableColumnGroup', + 'TableSummary', + 'TableSummaryRow', + 'TableSummaryCell', + 'Transfer', + 'Tree', + 'TreeNode', + 'DirectoryTree', + 'TreeSelect', + 'TreeSelectNode', + 'Tabs', + 'TabPane', + 'Tag', + 'CheckableTag', + 'TimePicker', + 'TimeRangePicker', + 'Timeline', + 'TimelineItem', + 'Tooltip', + 'Typography', + 'TypographyLink', + 'TypographyParagraph', + 'TypographyText', + 'TypographyTitle', + 'Upload', + 'UploadDragger', + 'LocaleProvider' +] + +const prefix = 'Antd' + +let antdNames: Set + +const genAntdNames = (primitiveNames: string[]): void => { + antdNames = new Set(primitiveNames.map((name) => `${prefix}${name}`)) +} + +genAntdNames(primitiveNames) + +const isAntd = (compName: string): boolean => { + return antdNames.has(compName) +} + +export function AntDesignResolver(options: AntDesignResolverOptions = {}): ComponentResolver { + return { + type: 'component', + resolve: (name: string) => { + if (options.resolveIcons && name.match(/(Outlined|Filled|TwoTone)$/)) { + return { + name, + from: '@ant-design/icons' + } + } + + if (isAntd(name) && !options?.exclude?.includes(name)) { + const importName = name.slice(prefix.length) + const { cjs = false, packageName = 'antd' } = options + const path = `${packageName}/${cjs ? 'lib' : 'es'}` + return { + name: importName, + from: path, + sideEffects: getSideEffects(importName, options) + } + } + } + } +} diff --git a/index.html b/index.html new file mode 100644 index 0000000..fdacc4a --- /dev/null +++ b/index.html @@ -0,0 +1,13 @@ + + + + + + + Vite + React + TS + + +
+ + + diff --git a/package.json b/package.json new file mode 100644 index 0000000..2222eb1 --- /dev/null +++ b/package.json @@ -0,0 +1,55 @@ +{ + "name": "fatweb-ui", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "dev-host": "vite --host 0.0.0.0", + "build": "tsc && vite build", + "lint": "eslint src --ext ts,tsx --report-unused-disable-directives --max-warnings 0", + "format": "prettier --write src/", + "preview": "vite preview" + }, + "dependencies": { + "@ant-design/icons": "^5.2.5", + "antd": "^5.8.5", + "axios": "^1.5.0", + "jwt-decode": "^3.1.2", + "localforage": "^1.10.0", + "lodash": "^4.17.21", + "match-sorter": "^6.3.1", + "react": "^18.2.0", + "react-dom": "^18.2.0", + "react-router": "^6.15.0", + "react-router-dom": "^6.15.0", + "sort-by": "^0.0.2" + }, + "devDependencies": { + "@svgr/core": "^8.1.0", + "@svgr/plugin-jsx": "^8.1.0", + "@types/jsdom": "^21.1.2", + "@types/lodash": "^4.14.197", + "@types/node": "^20.5.7", + "@types/react": "^18.2.21", + "@types/react-dom": "^18.2.7", + "@typescript-eslint/eslint-plugin": "^6.5.0", + "@typescript-eslint/parser": "^6.5.0", + "@vitejs/plugin-react": "^4.0.4", + "eslint": "^8.48.0", + "eslint-config-prettier": "^9.0.0", + "eslint-config-standard-with-typescript": "^39.0.0", + "eslint-plugin-import": "^2.28.1", + "eslint-plugin-prettier": "^5.0.0", + "eslint-plugin-promise": "^6.1.1", + "eslint-plugin-react-hooks": "^4.6.0", + "eslint-plugin-react-refresh": "^0.4.3", + "jsdom": "^22.1.0", + "prettier": "^3.0.3", + "stylelint-config-prettier": "^9.0.5", + "typescript": "^5.2.2", + "unplugin-auto-import": "^0.16.6", + "unplugin-icons": "^0.16.6", + "vite": "^4.4.9" + } +} diff --git a/public/vite.svg b/public/vite.svg new file mode 100644 index 0000000..e7b8dfb --- /dev/null +++ b/public/vite.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/App.css b/src/App.css new file mode 100644 index 0000000..b9d355d --- /dev/null +++ b/src/App.css @@ -0,0 +1,42 @@ +#root { + max-width: 1280px; + margin: 0 auto; + padding: 2rem; + text-align: center; +} + +.logo { + height: 6em; + padding: 1.5em; + will-change: filter; + transition: filter 300ms; +} +.logo:hover { + filter: drop-shadow(0 0 2em #646cffaa); +} +.logo.react:hover { + filter: drop-shadow(0 0 2em #61dafbaa); +} + +@keyframes logo-spin { + from { + transform: rotate(0deg); + } + to { + transform: rotate(360deg); + } +} + +@media (prefers-reduced-motion: no-preference) { + a:nth-of-type(2) .logo { + animation: logo-spin infinite 20s linear; + } +} + +.card { + padding: 2em; +} + +.read-the-docs { + color: #888; +} diff --git a/src/App.tsx b/src/App.tsx new file mode 100644 index 0000000..3561770 --- /dev/null +++ b/src/App.tsx @@ -0,0 +1,12 @@ +import React from 'react' +import router from '@/router' + +const App: React.FC = () => { + return ( + <> + + + ) +} + +export default App diff --git a/src/AuthRoute.tsx b/src/AuthRoute.tsx new file mode 100644 index 0000000..788ba52 --- /dev/null +++ b/src/AuthRoute.tsx @@ -0,0 +1,20 @@ +import { getLoginStatus } from '@/utils/auth.ts' + +const AuthRoute = () => { + const match = useMatches()[1] + const handle = match.handle as RouteHandle + const outlet = useOutlet() + const isLogin = getLoginStatus() + + return useMemo(() => { + if (handle?.auth && !isLogin) { + return + } + if (isLogin && match.pathname === '/login') { + return + } + return outlet + }, [handle?.auth, isLogin, match.pathname, outlet]) +} + +export default AuthRoute diff --git a/src/assets/css/base.css b/src/assets/css/base.css new file mode 100644 index 0000000..1426524 --- /dev/null +++ b/src/assets/css/base.css @@ -0,0 +1,63 @@ +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +html { + scroll-behavior: smooth; +} + +em, +i { + font-style: normal +} + +li { + list-style: none +} + +img { + border: 0; + vertical-align: middle +} + +button { + cursor: pointer +} + +a { + color: #666; + text-decoration: none +} + +button, +input { + font-family: Microsoft YaHei, Heiti SC, tahoma, arial, Hiragino Sans GB, "\5B8B\4F53", sans-serif; + border: 0; + outline: none; +} + +body { + -webkit-font-smoothing: antialiased; + background-color: #fff; + font: 12px/1.5 Microsoft YaHei, Heiti SC, tahoma, arial, Hiragino Sans GB, "\5B8B\4F53", sans-serif; + color: #666 +} + +.hide, +.none { + display: none +} + +.clearfix:after { + visibility: hidden; + clear: both; + display: block; + content: "."; + height: 0 +} + +.clearfix { + *zoom: 1 +} \ No newline at end of file diff --git a/src/assets/css/common.css b/src/assets/css/common.css new file mode 100644 index 0000000..1435e89 --- /dev/null +++ b/src/assets/css/common.css @@ -0,0 +1,106 @@ +:root { + --main-color: #00D4FF; + --background-color: #F5F5F5; + --font-main-color: #4D4D4D; + --font-secondary-color: #9E9E9E; +} + +.body { + color: var(--font-main-color); + user-select: none; + min-width: 1800px; + min-height: 400px; +} + +.fill { + height: 100%; + width: 100%; +} + +.fill-with { + width: 100%; +} + +.fill-height { + height: 100%; +} + +.background-white { + background-color: white; +} + +.center-box { + display: flex; + justify-content: center; + align-items: center; +} + +.vertical-center-box { + display: flex; + align-items: center; +} + +.horizontal-center-box { + display: flex; + justify-content: center; +} + +.icon-size-xs { + width: 16px; + height: 16px; +} + +.icon-size-xs > use { + width: 16px; + height: 16px; +} + +.icon-size-sm { + width: 20px; + height: 20px; +} + +.icon-size-sm > use { + width: 20px; + height: 20px; +} + +.icon-size-md { + width: 24px; + height: 24px; +} + +.icon-size-md > use { + width: 24px; + height: 24px; +} + +.icon-size-lg { + width: 32px; + height: 32px; +} + +.icon-size-lg > use { + width: 32px; + height: 32px; +} + +.icon-size-xl { + width: 64px; + height: 64px; +} + +.icon-size-xl > use { + width: 64px; + height: 64px; +} + +.icon-size-menu { + width: 23px; + height: 23px; +} + +.icon-size-menu > use { + width: 23px; + height: 23px; +} diff --git a/src/assets/css/login.css b/src/assets/css/login.css new file mode 100644 index 0000000..fd5fe6e --- /dev/null +++ b/src/assets/css/login.css @@ -0,0 +1,63 @@ +.login-background { + display: flex; + height: 100vh; + width: 100vw; + justify-content: center; + align-items: center; + background-color: #B3E5FC; +} + +.login-box { + display: flex; + width: 800px; + height: 450px; + background-color: #448AFF; +} + +.login-box-left { + display: flex; + justify-content: center; + align-items: center; + height: 100%; + flex: 2; +} + +.login-box-left-text { + font-size: 3rem; + color: white; + font-weight: bold; +} + +.login-box-left-text>div:last-child { + font-size: 1.8rem; + font-weight: normal; +} + +.login-box-right { + position: relative; + height: 100%; + flex: 3; + background-color: white; +} + +.login-from-text { + position: absolute; + top: 60px; + left: 40px; + font-weight: bold; + font-size: 2rem; +} + +.login-from { + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + margin-top: 30px; + width: 100%; + height: 100%; +} + +.login-from-item { + width: 80%; +} \ No newline at end of file diff --git a/src/assets/css/manager.css b/src/assets/css/manager.css new file mode 100644 index 0000000..17c4829 --- /dev/null +++ b/src/assets/css/manager.css @@ -0,0 +1,35 @@ +.top-bar { + display: flex; + justify-content: right; + background-color: #317ece; + padding: 10px 20px; +} + +.top-bar > button:hover { + color: #F5F5F5; + border-color: #F5F5F5; +} + +.search-row { + display: flex; + gap: 20px; + margin: 10px 10px; +} + +.search-row > * { + display: flex; + gap: 5px; + align-items: center; + flex: 1; + font-size: 1rem; +} + +.search-row > *:not(.operation-buttons) > *:last-child { + flex: 1; +} + +.operation-buttons { + display: flex; + justify-content: right; + gap: 10px; +} \ No newline at end of file diff --git a/src/assets/svg/home.svg b/src/assets/svg/home.svg new file mode 100644 index 0000000..689ddce --- /dev/null +++ b/src/assets/svg/home.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/constants/Common.constants.ts b/src/constants/Common.constants.ts new file mode 100644 index 0000000..e58a345 --- /dev/null +++ b/src/constants/Common.constants.ts @@ -0,0 +1,85 @@ +const PRODUCTION_NAME = 'Pinnacle OA' +const TOKEN_NAME = 'JWT_TOKEN' +const COLOR_PRODUCTION = '#00D4FF' +const COLOR_BACKGROUND = '#F5F5F5' +const COLOR_TOP = 'rgba(234,46,13,0.85)' +const COLOR_FONT_MAIN = '#4D4D4D' +const COLOR_FONT_SECONDARY = '#9E9E9E' +const SIZE_ICON_XS = '16px' +const SIZE_ICON_SM = '20px' +const SIZE_ICON_MD = '24px' +const SIZE_ICON_LG = '32px' +const SIZE_ICON_XL = '64px' + +// Response Code +const SYSTEM_OK = 20000 +const LOGIN_SUCCESS = 20010 +const LOGIN_USERNAME_PASSWORD_ERROR = 20011 +const OLD_PASSWORD_NOT_MATCH = 20012 +const LOGOUT_SUCCESS = 20015 +const LOGOUT_FAILED = 20016 +const TOKEN_IS_ILLEGAL = 20017 +const TOKEN_HAS_EXPIRED = 20018 +const TOKEN_RENEW_SUCCESS = 20019 +const DATABASE_SELECT_OK = 20021 +const DATABASE_SAVE_OK = 20022 +const DATABASE_UPDATE_OK = 20023 +const DATABASE_DELETE_OK = 20024 +const DATABASE_SELECT_ERROR = 20031 +const DATABASE_SAVE_ERROR = 20032 +const DATABASE_UPDATE_ERROR = 20033 +const DATABASE_DELETE_ERROR = 20034 +const DATABASE_TIMEOUT_ERROR = 20035 +const DATABASE_CONNECT_ERROR = 20036 +const DATABASE_DATA_TO_LONG = 20037 +const DATABASE_DATA_VALIDATION_FAILED = 20038 +const DATABASE_EXECUTE_ERROR = 20039 + +const UNAUTHORIZED = 30010 +const ACCESS_DENIED = 30030 +const USER_DISABLE = 30031 + +const SYSTEM_ERROR = 50001 +const SYSTEM_TIMEOUT = 50002 + +export { + PRODUCTION_NAME, + TOKEN_NAME, + COLOR_PRODUCTION, + COLOR_BACKGROUND, + COLOR_FONT_MAIN, + COLOR_FONT_SECONDARY, + COLOR_TOP, + SIZE_ICON_XS, + SIZE_ICON_SM, + SIZE_ICON_MD, + SIZE_ICON_LG, + SIZE_ICON_XL, + SYSTEM_OK, + LOGIN_SUCCESS, + LOGIN_USERNAME_PASSWORD_ERROR, + OLD_PASSWORD_NOT_MATCH, + LOGOUT_SUCCESS, + LOGOUT_FAILED, + TOKEN_IS_ILLEGAL, + TOKEN_HAS_EXPIRED, + TOKEN_RENEW_SUCCESS, + DATABASE_SELECT_OK, + DATABASE_SAVE_OK, + DATABASE_UPDATE_OK, + DATABASE_DELETE_OK, + DATABASE_SELECT_ERROR, + DATABASE_SAVE_ERROR, + DATABASE_UPDATE_ERROR, + DATABASE_DELETE_ERROR, + DATABASE_TIMEOUT_ERROR, + DATABASE_CONNECT_ERROR, + DATABASE_DATA_TO_LONG, + DATABASE_DATA_VALIDATION_FAILED, + DATABASE_EXECUTE_ERROR, + UNAUTHORIZED, + ACCESS_DENIED, + USER_DISABLE, + SYSTEM_ERROR, + SYSTEM_TIMEOUT +} diff --git a/src/index.css b/src/index.css new file mode 100644 index 0000000..2c3fac6 --- /dev/null +++ b/src/index.css @@ -0,0 +1,69 @@ +:root { + font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif; + line-height: 1.5; + font-weight: 400; + + color-scheme: light dark; + color: rgba(255, 255, 255, 0.87); + background-color: #242424; + + font-synthesis: none; + text-rendering: optimizeLegibility; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + -webkit-text-size-adjust: 100%; +} + +a { + font-weight: 500; + color: #646cff; + text-decoration: inherit; +} +a:hover { + color: #535bf2; +} + +body { + margin: 0; + display: flex; + place-items: center; + min-width: 320px; + min-height: 100vh; +} + +h1 { + font-size: 3.2em; + line-height: 1.1; +} + +button { + border-radius: 8px; + border: 1px solid transparent; + padding: 0.6em 1.2em; + font-size: 1em; + font-weight: 500; + font-family: inherit; + background-color: #1a1a1a; + cursor: pointer; + transition: border-color 0.25s; +} +button:hover { + border-color: #646cff; +} +button:focus, +button:focus-visible { + outline: 4px auto -webkit-focus-ring-color; +} + +@media (prefers-color-scheme: light) { + :root { + color: #213547; + background-color: #ffffff; + } + a:hover { + color: #747bff; + } + button { + background-color: #f9f9f9; + } +} diff --git a/src/main.tsx b/src/main.tsx new file mode 100644 index 0000000..89528f3 --- /dev/null +++ b/src/main.tsx @@ -0,0 +1,14 @@ +import React from 'react' +import ReactDOM from 'react-dom/client' +import App from './App.tsx' +import '@/assets/css/base.css' +import '@/assets/css/common.css' +import zh_CN from 'antd/locale/zh_CN' + +ReactDOM.createRoot(document.getElementById('root')!).render( + + + + + +) diff --git a/src/pages/Home.tsx b/src/pages/Home.tsx new file mode 100644 index 0000000..28044c9 --- /dev/null +++ b/src/pages/Home.tsx @@ -0,0 +1,11 @@ +import React from 'react' + +const Home: React.FC = () => { + return ( + <> +

FatWeb

+ + ) +} + +export default Home diff --git a/src/pages/Login.tsx b/src/pages/Login.tsx new file mode 100644 index 0000000..40f71af --- /dev/null +++ b/src/pages/Login.tsx @@ -0,0 +1,106 @@ +import React from 'react' +import { login } from '@/utils/auth.ts' +import { LOGIN_SUCCESS, LOGIN_USERNAME_PASSWORD_ERROR } from '@/constants/Common.constants.ts' +import { setToken } from '@/utils/common.ts' +import '@/assets/css/login.css' + +const Login: React.FC = () => { + const [messageApi, contextHolder] = message.useMessage() + const navigate = useNavigate() + const [isLoggingIn, setIsLoggingIn] = useState(false) + + const onFinish = (values: LoginForm) => { + setIsLoggingIn(true) + void login(values.username, values.password).then((value) => { + const res = value.data + const { code, data } = res + switch (code) { + case LOGIN_SUCCESS: + setToken(data?.token ?? '') + void messageApi.success('登录成功') + setTimeout(() => { + navigate('/') + }, 1500) + break + case LOGIN_USERNAME_PASSWORD_ERROR: + void messageApi.error( + <> + 用户名密码错误,请重试 + + ) + setIsLoggingIn(false) + break + default: + void messageApi.error( + <> + 服务器出错了 + + ) + setIsLoggingIn(false) + } + }) + } + + return ( + <> + {contextHolder} +
+
+
+
+
欢迎回来
+
Welcome back
+
+
+
+
+ 登 录 +
+ + + } + placeholder={'用户名'} + disabled={isLoggingIn} + /> + + + } + placeholder={'密码'} + disabled={isLoggingIn} + /> + + + + 登    录 + + + +
+
+
+ + ) +} + +export default Login diff --git a/src/router/index.tsx b/src/router/index.tsx new file mode 100644 index 0000000..316eacc --- /dev/null +++ b/src/router/index.tsx @@ -0,0 +1,37 @@ +import React from 'react' + +const routes: RouteObject[] = [ + { + path: '/', + Component: React.lazy(() => import('@/AuthRoute')), + children: [ + { + path: '/login', + id: 'login', + Component: React.lazy(() => import('@/pages/Login')) + }, + { + path: '', + id: 'manager', + Component: React.lazy(() => import('@/pages/Home')), + children: [ + { + id: 'manager-sub', + path: 'sub', + Component: React.lazy(() => import('@/pages/Home')) + } + ], + handle: { + auth: false + } + }, + { + path: '*', + element: + } + ] + } +] + +const router = createBrowserRouter(routes) +export default router diff --git a/src/services/index.tsx b/src/services/index.tsx new file mode 100644 index 0000000..c47f031 --- /dev/null +++ b/src/services/index.tsx @@ -0,0 +1,155 @@ +import axios, { type AxiosError, AxiosInstance, AxiosRequestConfig, AxiosResponse } from 'axios' +import jwtDecode, { JwtPayload } from 'jwt-decode' +import { clearLocalStorage, getToken, setToken } from '@/utils/common' +import { + ACCESS_DENIED, + DATABASE_DATA_TO_LONG, + DATABASE_DATA_VALIDATION_FAILED, + DATABASE_EXECUTE_ERROR, + TOKEN_HAS_EXPIRED, + TOKEN_IS_ILLEGAL, + TOKEN_RENEW_SUCCESS, + UNAUTHORIZED +} from '@/constants/Common.constants' +import { message } from 'antd' + +const service: AxiosInstance = axios.create({ + baseURL: 'http://localhost:8181', + timeout: 10000, + withCredentials: false +}) + +service.defaults.paramsSerializer = (params: Record) => { + return Object.keys(params) + .filter((it) => { + return Object.prototype.hasOwnProperty.call(params, it) + }) + .reduce((pre, curr) => { + return params[curr] !== null + ? (pre !== '' ? pre + '&' : '') + curr + '=' + encodeURIComponent(params[curr]) + : pre + }, '') +} + +service.interceptors.request.use( + async (config) => { + let token = getToken() + if (token !== null) { + const jwt = jwtDecode(token) + if (!jwt.exp) { + return config + } + if ( + jwt.exp * 1000 - new Date().getTime() < 1200000 && + jwt.exp * 1000 - new Date().getTime() > 0 + ) { + await axios + .get('http://localhost:8181/token', { + headers: { token } + }) + .then((value: AxiosResponse<_Response>) => { + const response = value.data + if (response.code === TOKEN_RENEW_SUCCESS) { + setToken(response.data?.token ?? '') + } + }) + } + + token = getToken() + config.headers.set('token', token) + } + return config + }, + async (error) => { + return await Promise.reject(error) + } +) + +service.interceptors.response.use( + (response: AxiosResponse<_Response>) => { + switch (response.data.code) { + case UNAUTHORIZED: + case TOKEN_IS_ILLEGAL: + case TOKEN_HAS_EXPIRED: + clearLocalStorage() + void message.error( + <> + 登录已过期 + + ) + setTimeout(function () { + location.reload() + }, 1500) + throw response?.data + case ACCESS_DENIED: + void message.error( + <> + 暂无权限操作 + + ) + throw response?.data + case DATABASE_DATA_TO_LONG: + void message.error( + <> + 数据过长 + + ) + throw response?.data + case DATABASE_DATA_VALIDATION_FAILED: + void message.error( + <> + 数据验证失败 + + ) + throw response?.data + case DATABASE_EXECUTE_ERROR: + void message.error( + <> + 数据库执行出错 + + ) + throw response?.data + } + return response + }, + async (error: AxiosError) => { + void message.error( + <> + 服务器出错,请稍后重试 + + ) + return await Promise.reject(error?.response?.data) + } +) + +const request = { + async get(url: string, data?: object): Promise>> { + return await request.request('GET', url, { params: data }) + }, + async post(url: string, data?: object): Promise>> { + return await request.request('POST', url, { data }) + }, + async put(url: string, data?: object): Promise>> { + return await request.request('PUT', url, { data }) + }, + async delete(url: string, data?: object): Promise>> { + return await request.request('DELETE', url, { params: data }) + }, + async request( + method = 'GET', + url: string, + data?: AxiosRequestConfig + ): Promise>> { + return await new Promise((resolve, reject) => { + service({ method, url, ...data }) + .then((res) => { + resolve(res as unknown as Promise>>) + }) + .catch((e: Error | AxiosError) => { + reject(e) + }) + }) + } +} + +export default request diff --git a/src/utils/auth.ts b/src/utils/auth.ts new file mode 100644 index 0000000..197427c --- /dev/null +++ b/src/utils/auth.ts @@ -0,0 +1,65 @@ +import { clearLocalStorage, getCaptcha, getLocalStorage, setLocalStorage } from './common' +import { DATABASE_SELECT_OK, TOKEN_NAME } from '@/constants/Common.constants' +import request from '@/services' + +let captcha: Captcha + +export async function login(username: string, password: string) { + return await request.post('/login', { + username, + password + }) +} + +export function logout(): void { + void request.get('/logout').finally(() => { + clearLocalStorage() + }) +} + +export function getLoginStatus(): boolean { + return getLocalStorage(TOKEN_NAME) !== null +} + +export async function getUser(): Promise { + if (getLocalStorage('userInfo') !== null) { + return new Promise((resolve) => { + resolve(JSON.parse(getLocalStorage('userInfo') as string) as User) + }) + } + return requestUser() +} + +export async function requestUser(): Promise { + let user: User | null + + await request.get('/user/info').then((value) => { + const response = value.data + if (response.code === DATABASE_SELECT_OK) { + user = response.data + setLocalStorage('userInfo', JSON.stringify(user)) + } + }) + + return new Promise((resolve, reject) => { + if (user) { + resolve(user) + } + reject(user) + }) +} + +export async function getUsername(): Promise { + const user = await getUser() + + return user.username +} + +export function getCaptchaSrc(): string { + captcha = getCaptcha(300, 150, 4) + return captcha.base64Src +} + +export function verifyCaptcha(value: string): boolean { + return captcha.value.toLowerCase() === value.replace(/\s*/g, '').toLowerCase() +} diff --git a/src/utils/common.ts b/src/utils/common.ts new file mode 100644 index 0000000..96f1784 --- /dev/null +++ b/src/utils/common.ts @@ -0,0 +1,136 @@ +import { TOKEN_NAME } from '@/constants/Common.constants' + +export function getQueryVariable(variable: string): string | null { + const query = window.location.search.substring(1) + const vars = query.split('&') + for (const value of vars) { + const pair = value.split('=') + if (pair[0] === variable) { + return decodeURIComponent(pair[1].replace(/\+/g, ' ')) + } + } + return null +} + +export function setCookie( + name: string, + value: string, + daysToLive: number | null, + path: string | null +): void { + let cookie = name + '=' + encodeURIComponent(value) + + if (typeof daysToLive === 'number') { + cookie = `${cookie}; max-age=${daysToLive * 24 * 60 * 60}` + } + + if (typeof path === 'string') { + cookie += '; path=' + path + } + + document.cookie = cookie +} + +export function setLocalStorage(name: string, value: string): void { + localStorage.setItem(name, value) +} + +export function setToken(token: string): void { + setLocalStorage(TOKEN_NAME, token) +} + +export function getCookie(name: string): string | null { + const cookieArr = document.cookie.split(';') + + for (const cookie of cookieArr) { + const cookiePair = cookie.split('=') + if (cookiePair[0].trim() === name) { + return decodeURIComponent(cookiePair[1]) + } + } + + return null +} + +export function getLocalStorage(name: string): string | null { + return localStorage.getItem(name) +} + +export function getToken(): string | null { + return getLocalStorage(TOKEN_NAME) +} + +export function removeCookie(name: string): void { + document.cookie = name + '=; max-age=0' +} + +export function removeLocalStorage(name: string): void { + localStorage.removeItem(name) +} + +export function removeToken(): void { + removeLocalStorage(TOKEN_NAME) +} + +export function clearLocalStorage(): void { + localStorage.clear() +} + +export function getCaptcha(width: number, high: number, num: number): Captcha { + const CHARTS = '23456789ABCDEFGHJKLMNPRSTUVWXYZabcdefghijklmnpqrstuvwxyz'.split('') + + const canvas = document.createElement('canvas') + const ctx = canvas.getContext('2d') as CanvasRenderingContext2D + + ctx.rect(0, 0, width, high) + ctx.clip() + + ctx.fillStyle = randomColor(200, 250) + ctx.fillRect(0, 0, width, high) + + for (let i = 0.05 * width * high; i > 0; i--) { + ctx.fillStyle = randomColor(0, 256) + ctx.fillRect(randomInt(0, width), randomInt(0, high), 1, 1) + } + + ctx.font = `${high - 4}px Consolas` + ctx.fillStyle = randomColor(160, 200) + let value = '' + for (let i = 0; i < num; i++) { + const x = ((width - 10) / num) * i + 5 + const y = high - 12 + const r = Math.PI * randomFloat(-0.12, 0.12) + const ch = CHARTS[randomInt(0, CHARTS.length)] + value += ch + ctx.translate(x, y) + ctx.rotate(r) + ctx.fillText(ch, 0, 0) + ctx.rotate(-r) + ctx.translate(-x, -y) + } + + const base64Src = canvas.toDataURL('image/jpg') + return { + value, + base64Src + } +} + +function randomInt(start: number, end: number): number { + if (start > end) { + const t = start + start = end + end = t + } + start = Math.ceil(start) + end = Math.floor(end) + return start + Math.floor(Math.random() * (end - start)) +} + +function randomFloat(start: number, end: number): number { + return start + Math.random() * (end - start) +} + +function randomColor(start: number, end: number): string { + return `rgb(${randomInt(start, end)},${randomInt(start, end)},${randomInt(start, end)})` +} diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts new file mode 100644 index 0000000..2bc780f --- /dev/null +++ b/src/vite-env.d.ts @@ -0,0 +1,31 @@ +/// + +type Captcha = { + value: string + base64Src: string +} + +type RouteHandle = { + auth: boolean +} + +type _Response = { + code: number + msg: string + data: T | null +} + +type Token = { + token: string +} + +type User = { + id: number + username: string + enable: number +} + +type LoginForm = { + username: string + password: string +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..a3a6649 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,30 @@ +{ + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "module": "ESNext", + "skipLibCheck": true, + "baseUrl": ".", + "paths": { + "@/*": ["./src/*"] + }, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react-jsx", + + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + + "types": ["unplugin-icons/types/react"] + }, + "include": ["src", "auto-imports.d.ts"], + "references": [{ "path": "./tsconfig.node.json" }] +} diff --git a/tsconfig.node.json b/tsconfig.node.json new file mode 100644 index 0000000..95abca2 --- /dev/null +++ b/tsconfig.node.json @@ -0,0 +1,11 @@ +{ + "compilerOptions": { + "composite": true, + "types": ["node"], + "skipLibCheck": true, + "module": "ESNext", + "moduleResolution": "bundler", + "allowSyntheticDefaultImports": true + }, + "include": ["vite.config.ts", "build/resolvers/*"] +} diff --git a/vite.config.ts b/vite.config.ts new file mode 100644 index 0000000..42e70a7 --- /dev/null +++ b/vite.config.ts @@ -0,0 +1,82 @@ +import { fileURLToPath, URL } from 'node:url' + +import { defineConfig, PluginOption } from 'vite' +import react from '@vitejs/plugin-react' +import Icons from 'unplugin-icons/vite' +import { FileSystemIconLoader } from 'unplugin-icons/loaders' +import IconsResolver from 'unplugin-icons/resolver' +import AutoImport from 'unplugin-auto-import/vite' +import { AntDesignResolver } from './build/resolvers/antd' + +// https://vitejs.dev/config/ +export default defineConfig({ + plugins: [ + react(), + // eslint-disable-next-line @typescript-eslint/no-unsafe-call + AutoImport({ + // targets to transform + include: [ + /\.[tj]sx?$/, // .ts, .tsx, .js, .jsx + /\.md$/ // .md + ], + + // global imports to register + imports: [ + 'react', + 'react-router', + 'react-router-dom', + { + 'react-router': ['useMatches', 'RouterProvider'], + 'react-router-dom': ['createBrowserRouter'], + antd: ['message'] + }, + { + from: 'react-router', + imports: ['RouteObject'], + type: true + } + ], + + // Filepath to generate corresponding .d.ts file. + // Defaults to './auto-imports.d.ts' when `typescript` is installed locally. + // Set `false` to disable. + dts: './auto-imports.d.ts', + + // Custom resolvers, compatible with `unplugin-vue-components` + // see https://github.com/antfu/unplugin-auto-import/pull/23/ + resolvers: [ + IconsResolver({ + prefix: 'icon', + extension: 'jsx', + customCollections: ['fatweb'] + }), + AntDesignResolver({ + resolveIcons: true + }) + ], + + // Generate corresponding .eslintrc-auto-import.json file. + // eslint globals Docs - https://eslint.org/docs/user-guide/configuring/language-options#specifying-globals + eslintrc: { + enabled: true, // Default `false` + filepath: './.eslintrc-auto-import.json', // Default `./.eslintrc-auto-import.json` + globalsPropValue: true // Default `true`, (true | false | 'readonly' | 'readable' | 'writable' | 'writeable') + } + }) as PluginOption, + Icons({ + compiler: 'jsx', + jsx: 'react', + autoInstall: true, + customCollections: { + fatweb: FileSystemIconLoader('src/assets/svg', (svg) => + svg.replace(/^svg /, '