├── .cspell.txt ├── CHANGELOG.md ├── src ├── features │ ├── env │ │ └── index.ts │ ├── users │ │ ├── index.ts │ │ └── api │ │ │ ├── index.ts │ │ │ ├── useUsersQuery.ts │ │ │ └── useProfileQuery.ts │ ├── assets │ │ ├── index.ts │ │ └── utils.ts │ ├── detail │ │ ├── index.ts │ │ └── types.ts │ ├── lang │ │ ├── index.ts │ │ └── utils.ts │ ├── layout │ │ ├── index.ts │ │ └── types.ts │ ├── locales │ │ ├── index.ts │ │ └── utils.ts │ ├── router │ │ ├── index.ts │ │ └── types.ts │ ├── auth │ │ ├── index.ts │ │ └── login-type.ts │ ├── pagination │ │ ├── index.ts │ │ └── usePagination.ts │ ├── dictionaries │ │ ├── api │ │ │ ├── index.ts │ │ │ └── useDictionariesQuery.ts │ │ ├── components │ │ │ ├── index.ts │ │ │ └── ModalContent.tsx │ │ ├── constants │ │ │ ├── index.ts │ │ │ ├── form-initial-value.ts │ │ │ └── detail-fields.tsx │ │ ├── types.ts │ │ ├── hooks │ │ │ ├── index.ts │ │ │ ├── useDictionaryListParams.ts │ │ │ ├── useCrud.ts │ │ │ └── useColumns.tsx │ │ └── index.ts │ ├── icon │ │ ├── index.ts │ │ ├── types.ts │ │ └── icon-set.ts │ ├── modal │ │ ├── modal-type.ts │ │ ├── index.ts │ │ ├── modal-title-map.ts │ │ └── useModal.ts │ └── menu │ │ ├── index.ts │ │ ├── types.ts │ │ ├── get-menu-item.ts │ │ └── get-menu-tree.tsx ├── maps │ ├── index.ts │ └── error-message.ts ├── pages │ ├── login │ │ ├── enum │ │ │ ├── index.ts │ │ │ └── username-login-type.ts │ │ ├── hooks │ │ │ ├── index.ts │ │ │ ├── useHandleLoginResult.ts │ │ │ ├── useRedirect.ts │ │ │ └── useLoginForm.ts │ │ ├── components │ │ │ ├── index.ts │ │ │ ├── Header │ │ │ │ └── index.tsx │ │ │ └── ThirdPartyLogin │ │ │ │ └── index.tsx │ │ ├── types │ │ │ └── index.ts │ │ └── index.tsx │ ├── multi-level-menus │ │ ├── 2-2 │ │ │ └── index.tsx │ │ └── 2-1 │ │ │ ├── 2-1-1 │ │ │ └── index.tsx │ │ │ └── 2-1-2 │ │ │ └── index.tsx │ ├── code-templates │ │ ├── two-col │ │ │ └── index.tsx │ │ ├── table │ │ │ └── index.tsx │ │ └── card │ │ │ └── index.tsx │ ├── error-pages │ │ ├── 403 │ │ │ └── index.tsx │ │ ├── 404 │ │ │ └── index.tsx │ │ ├── 418 │ │ │ └── index.tsx │ │ └── 500 │ │ │ └── index.tsx │ ├── resources │ │ └── locales │ │ │ └── index.tsx │ ├── index.tsx │ ├── signup │ │ └── index.tsx │ └── system │ │ └── dictionaries │ │ └── index.tsx ├── locales │ ├── dictionary │ │ ├── en-US.json │ │ └── zh-CN.json │ ├── user │ │ ├── zh-CN.json │ │ └── en-US.json │ ├── layout │ │ ├── zh-CN.json │ │ └── en-US.json │ ├── auth │ │ ├── zh-CN.json │ │ └── en-US.json │ ├── menu │ │ ├── zh-CN.json │ │ └── en-US.json │ ├── index.ts │ ├── validation │ │ ├── zh-CN.json │ │ └── en-US.json │ └── common │ │ ├── zh-CN.json │ │ └── en-US.json ├── assets │ ├── styles │ │ ├── tailwind.scss │ │ ├── fonts.scss │ │ ├── main.scss │ │ └── custom.scss │ └── images │ │ ├── bit_ocean.png │ │ └── favicon.png ├── constants │ ├── index.ts │ ├── query-client.ts │ ├── env.ts │ ├── metadata.ts │ ├── theme.ts │ ├── page.ts │ └── menu.tsx ├── api │ ├── locale.type.ts │ ├── locale.ts │ ├── auth.type.ts │ ├── axios.enum.ts │ ├── axios.type.ts │ ├── auth.ts │ ├── user.ts │ ├── settings.type.ts │ ├── dictionary.ts │ ├── dictionary.type.ts │ ├── setting.ts │ ├── user.type.ts │ └── axios.ts ├── hooks │ ├── useRemoteTranslation.ts │ ├── useRouteMeta.ts │ ├── useHoverDisplay.ts │ ├── useAuthGuard.ts │ └── useTableFields.ts ├── layouts │ ├── DpBaseLayout │ │ ├── components │ │ │ ├── Content │ │ │ │ └── index.tsx │ │ │ ├── Sidebar │ │ │ │ ├── components │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── Mask │ │ │ │ │ │ └── index.tsx │ │ │ │ │ ├── CollapseButton │ │ │ │ │ │ └── index.tsx │ │ │ │ │ ├── Header │ │ │ │ │ │ └── index.tsx │ │ │ │ │ └── Menu │ │ │ │ │ │ └── index.tsx │ │ │ │ └── index.tsx │ │ │ ├── index.ts │ │ │ ├── Tabs │ │ │ │ └── index.tsx │ │ │ ├── Header │ │ │ │ ├── components │ │ │ │ │ ├── ThemeToggle │ │ │ │ │ │ ├── index.module.scss │ │ │ │ │ │ └── index.tsx │ │ │ │ │ ├── DiscordButton │ │ │ │ │ │ └── index.tsx │ │ │ │ │ ├── DocsButton │ │ │ │ │ │ └── index.tsx │ │ │ │ │ ├── GitHubButton │ │ │ │ │ │ └── index.tsx │ │ │ │ │ ├── MenuVisibilityToggle │ │ │ │ │ │ └── index.tsx │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── FullScreenButton │ │ │ │ │ │ └── index.tsx │ │ │ │ │ ├── LanguageButton │ │ │ │ │ │ └── index.tsx │ │ │ │ │ ├── Breadcrumb │ │ │ │ │ │ └── index.tsx │ │ │ │ │ └── UserAvatar │ │ │ │ │ │ └── index.tsx │ │ │ │ └── index.tsx │ │ │ └── Footer │ │ │ │ └── index.tsx │ │ └── index.tsx │ ├── DpAuthLayout │ │ └── index.tsx │ ├── DpTwoColLayout │ │ └── index.tsx │ └── DpTableLayout │ │ └── index.tsx ├── main.tsx ├── components │ ├── DpGlobalLoading │ │ └── index.tsx │ ├── DpErrorPage │ │ └── index.tsx │ ├── DpHeader │ │ └── index.tsx │ ├── DpIcon │ │ └── index.tsx │ ├── DpTableSearch │ │ └── index.tsx │ ├── DpTableField │ │ └── index.tsx │ ├── DpDevMenuFab │ │ └── index.tsx │ └── DpDetailField │ │ └── index.tsx ├── store │ ├── user.ts │ ├── sidebar.ts │ ├── lang.ts │ └── theme.ts ├── Root.tsx ├── i18n │ └── index.ts ├── App.tsx └── router │ └── index.tsx ├── .dockerignore ├── .prettierrc.json ├── .husky ├── commit-msg └── pre-commit ├── .commitlintrc.json ├── src-tauri ├── build.rs ├── .gitignore ├── icons │ ├── 32x32.png │ ├── icon.icns │ ├── icon.ico │ ├── icon.png │ ├── 128x128.png │ ├── 128x128@2x.png │ ├── StoreLogo.png │ ├── Square30x30Logo.png │ ├── Square44x44Logo.png │ ├── Square71x71Logo.png │ ├── Square89x89Logo.png │ ├── Square107x107Logo.png │ ├── Square142x142Logo.png │ ├── Square150x150Logo.png │ ├── Square284x284Logo.png │ └── Square310x310Logo.png ├── src │ └── main.rs ├── Cargo.toml └── tauri.conf.json ├── .npmrc ├── @types ├── browser.d.ts ├── tanstack-query.d.ts ├── i18next.d.ts ├── env.d.ts └── build.d.ts ├── public └── favicon.ico ├── .prettierignore ├── postcss.config.cjs ├── tsconfig.eslint.json ├── tsconfig.node.json ├── .lintstagedrc.json ├── .eslintrc.json ├── vercel.json ├── docker-compose.yaml ├── Dockerfile ├── .editorconfig ├── .gitattributes ├── scripts ├── deploy-prod.sh └── deploy-staging.sh ├── .renovaterc ├── tailwind.config.cjs ├── tsconfig.json ├── .cspell.json ├── netlify.toml ├── .gitignore ├── .env.example ├── nginx.conf ├── README.zh-CN.md ├── LICENSE ├── CONTRIBUTORS.md ├── index.html ├── README.md ├── .drone.yml ├── package.json └── vite.config.ts /.cspell.txt: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/features/env/index.ts: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | dist 2 | node_modules 3 | -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | "@bit-ocean/prettier-config" 2 | -------------------------------------------------------------------------------- /src/features/users/index.ts: -------------------------------------------------------------------------------- 1 | export * from './api' 2 | -------------------------------------------------------------------------------- /src/maps/index.ts: -------------------------------------------------------------------------------- 1 | export * from './error-message' 2 | -------------------------------------------------------------------------------- /.husky/commit-msg: -------------------------------------------------------------------------------- 1 | npx --no -- commitlint --edit $1 2 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | pnpm lint:check 2 | npx lint-staged 3 | -------------------------------------------------------------------------------- /src/features/assets/index.ts: -------------------------------------------------------------------------------- 1 | export * from './utils' 2 | -------------------------------------------------------------------------------- /src/features/detail/index.ts: -------------------------------------------------------------------------------- 1 | export * from './types' 2 | -------------------------------------------------------------------------------- /src/features/lang/index.ts: -------------------------------------------------------------------------------- 1 | export * from './utils' 2 | -------------------------------------------------------------------------------- /src/features/layout/index.ts: -------------------------------------------------------------------------------- 1 | export * from './types' 2 | -------------------------------------------------------------------------------- /src/features/locales/index.ts: -------------------------------------------------------------------------------- 1 | export * from './utils' 2 | -------------------------------------------------------------------------------- /src/features/router/index.ts: -------------------------------------------------------------------------------- 1 | export * from './types' 2 | -------------------------------------------------------------------------------- /.commitlintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@bit-ocean" 3 | } 4 | -------------------------------------------------------------------------------- /src/features/auth/index.ts: -------------------------------------------------------------------------------- 1 | export * from './login-type' 2 | -------------------------------------------------------------------------------- /src-tauri/build.rs: -------------------------------------------------------------------------------- 1 | fn main() { 2 | tauri_build::build() 3 | } 4 | -------------------------------------------------------------------------------- /src/features/pagination/index.ts: -------------------------------------------------------------------------------- 1 | export * from './usePagination' 2 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | strict-peer-dependencies=false 2 | auto-install-peers=true 3 | -------------------------------------------------------------------------------- /@types/browser.d.ts: -------------------------------------------------------------------------------- 1 | // 扩展 window 对象 2 | declare interface Window {} 3 | -------------------------------------------------------------------------------- /src/features/users/api/index.ts: -------------------------------------------------------------------------------- 1 | export * from './useProfileQuery' 2 | -------------------------------------------------------------------------------- /src/pages/login/enum/index.ts: -------------------------------------------------------------------------------- 1 | export * from './username-login-type' 2 | -------------------------------------------------------------------------------- /src/features/dictionaries/api/index.ts: -------------------------------------------------------------------------------- 1 | export * from './useDictionariesQuery' 2 | -------------------------------------------------------------------------------- /src/features/icon/index.ts: -------------------------------------------------------------------------------- 1 | export * from './icon-set' 2 | export * from './types' 3 | -------------------------------------------------------------------------------- /src/locales/dictionary/en-US.json: -------------------------------------------------------------------------------- 1 | { 2 | "LABEL": "Label", 3 | "CODE": "Code" 4 | } 5 | -------------------------------------------------------------------------------- /src/locales/dictionary/zh-CN.json: -------------------------------------------------------------------------------- 1 | { 2 | "LABEL": "字典名称", 3 | "CODE": "字典编码" 4 | } 5 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dolphin-admin/react-admin/HEAD/public/favicon.ico -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | .husky 2 | pnpm-lock.yaml 3 | public 4 | src-tauri 5 | @types/auto-imports.d.ts 6 | -------------------------------------------------------------------------------- /src/assets/styles/tailwind.scss: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | -------------------------------------------------------------------------------- /src-tauri/.gitignore: -------------------------------------------------------------------------------- 1 | # Generated by Cargo 2 | # will have compiled files and executables 3 | /target/ 4 | -------------------------------------------------------------------------------- /src/features/dictionaries/components/index.ts: -------------------------------------------------------------------------------- 1 | export { default as ModalContent } from './ModalContent' 2 | -------------------------------------------------------------------------------- /src/pages/multi-level-menus/2-2/index.tsx: -------------------------------------------------------------------------------- 1 | export function Component() { 2 | return 3 | } 4 | -------------------------------------------------------------------------------- /src-tauri/icons/32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dolphin-admin/react-admin/HEAD/src-tauri/icons/32x32.png -------------------------------------------------------------------------------- /src-tauri/icons/icon.icns: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dolphin-admin/react-admin/HEAD/src-tauri/icons/icon.icns -------------------------------------------------------------------------------- /src-tauri/icons/icon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dolphin-admin/react-admin/HEAD/src-tauri/icons/icon.ico -------------------------------------------------------------------------------- /src-tauri/icons/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dolphin-admin/react-admin/HEAD/src-tauri/icons/icon.png -------------------------------------------------------------------------------- /src/features/modal/modal-type.ts: -------------------------------------------------------------------------------- 1 | export enum ModalType { 2 | 'CREATE', 3 | 'EDIT', 4 | 'DETAIL' 5 | } 6 | -------------------------------------------------------------------------------- /src/pages/multi-level-menus/2-1/2-1-1/index.tsx: -------------------------------------------------------------------------------- 1 | export function Component() { 2 | return 3 | } 4 | -------------------------------------------------------------------------------- /src/pages/multi-level-menus/2-1/2-1-2/index.tsx: -------------------------------------------------------------------------------- 1 | export function Component() { 2 | return 3 | } 4 | -------------------------------------------------------------------------------- /src-tauri/icons/128x128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dolphin-admin/react-admin/HEAD/src-tauri/icons/128x128.png -------------------------------------------------------------------------------- /postcss.config.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {} 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /src-tauri/icons/128x128@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dolphin-admin/react-admin/HEAD/src-tauri/icons/128x128@2x.png -------------------------------------------------------------------------------- /src-tauri/icons/StoreLogo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dolphin-admin/react-admin/HEAD/src-tauri/icons/StoreLogo.png -------------------------------------------------------------------------------- /src/assets/images/bit_ocean.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dolphin-admin/react-admin/HEAD/src/assets/images/bit_ocean.png -------------------------------------------------------------------------------- /src/assets/images/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dolphin-admin/react-admin/HEAD/src/assets/images/favicon.png -------------------------------------------------------------------------------- /src/features/dictionaries/constants/index.ts: -------------------------------------------------------------------------------- 1 | export * from './detail-fields' 2 | export * from './form-initial-value' 3 | -------------------------------------------------------------------------------- /tsconfig.eslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "include": ["src", "vite.config.ts", "@types"] 4 | } 5 | -------------------------------------------------------------------------------- /src/features/dictionaries/types.ts: -------------------------------------------------------------------------------- 1 | export interface EnableMutationParams { 2 | id: number 3 | enabled: boolean 4 | } 5 | -------------------------------------------------------------------------------- /src/features/icon/types.ts: -------------------------------------------------------------------------------- 1 | import type { iconSet } from './icon-set' 2 | 3 | export type IconType = keyof typeof iconSet 4 | -------------------------------------------------------------------------------- /src/features/menu/index.ts: -------------------------------------------------------------------------------- 1 | export * from './get-menu-item' 2 | export * from './get-menu-tree' 3 | export * from './types' 4 | -------------------------------------------------------------------------------- /src-tauri/icons/Square30x30Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dolphin-admin/react-admin/HEAD/src-tauri/icons/Square30x30Logo.png -------------------------------------------------------------------------------- /src-tauri/icons/Square44x44Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dolphin-admin/react-admin/HEAD/src-tauri/icons/Square44x44Logo.png -------------------------------------------------------------------------------- /src-tauri/icons/Square71x71Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dolphin-admin/react-admin/HEAD/src-tauri/icons/Square71x71Logo.png -------------------------------------------------------------------------------- /src-tauri/icons/Square89x89Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dolphin-admin/react-admin/HEAD/src-tauri/icons/Square89x89Logo.png -------------------------------------------------------------------------------- /src/features/modal/index.ts: -------------------------------------------------------------------------------- 1 | export * from './modal-title-map' 2 | export * from './modal-type' 3 | export * from './useModal' 4 | -------------------------------------------------------------------------------- /src/pages/login/enum/username-login-type.ts: -------------------------------------------------------------------------------- 1 | export enum UserNameLoginType { 2 | 'BASIC', 3 | 'ADMIN', 4 | 'VISITOR' 5 | } 6 | -------------------------------------------------------------------------------- /tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@bit-ocean/tsconfig/vite", 3 | "include": ["vite.config.ts", "@types/env.d.ts"] 4 | } 5 | -------------------------------------------------------------------------------- /src-tauri/icons/Square107x107Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dolphin-admin/react-admin/HEAD/src-tauri/icons/Square107x107Logo.png -------------------------------------------------------------------------------- /src-tauri/icons/Square142x142Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dolphin-admin/react-admin/HEAD/src-tauri/icons/Square142x142Logo.png -------------------------------------------------------------------------------- /src-tauri/icons/Square150x150Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dolphin-admin/react-admin/HEAD/src-tauri/icons/Square150x150Logo.png -------------------------------------------------------------------------------- /src-tauri/icons/Square284x284Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dolphin-admin/react-admin/HEAD/src-tauri/icons/Square284x284Logo.png -------------------------------------------------------------------------------- /src-tauri/icons/Square310x310Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dolphin-admin/react-admin/HEAD/src-tauri/icons/Square310x310Logo.png -------------------------------------------------------------------------------- /src/features/auth/login-type.ts: -------------------------------------------------------------------------------- 1 | // 登录类型 2 | export enum LoginType { 3 | USERNAME = '1', // 用户名登录 4 | EMAIL = '2' // 邮箱登录 5 | } 6 | -------------------------------------------------------------------------------- /src/features/menu/types.ts: -------------------------------------------------------------------------------- 1 | import type { MenuProps } from 'antd' 2 | 3 | export type MenuItem = Required['items'][number] 4 | -------------------------------------------------------------------------------- /src/pages/login/hooks/index.ts: -------------------------------------------------------------------------------- 1 | export * from './useHandleLoginResult' 2 | export * from './useLoginForm' 3 | export * from './useRedirect' 4 | -------------------------------------------------------------------------------- /src/features/assets/utils.ts: -------------------------------------------------------------------------------- 1 | export const getImageFromAssets = (name: string) => 2 | new URL(`../assets/images/${name}`, import.meta.url).href 3 | -------------------------------------------------------------------------------- /src/features/dictionaries/hooks/index.ts: -------------------------------------------------------------------------------- 1 | export * from './useColumns' 2 | export * from './useCrud' 3 | export * from './useDictionaryListParams' 4 | -------------------------------------------------------------------------------- /src/features/dictionaries/index.ts: -------------------------------------------------------------------------------- 1 | export * from './api' 2 | export * from './components' 3 | export * from './hooks' 4 | export * from './types' 5 | -------------------------------------------------------------------------------- /src/locales/user/zh-CN.json: -------------------------------------------------------------------------------- 1 | { 2 | "CONFIRM.PASSWORD": "确认密码", 3 | "NEW.PASSWORD": "新密码", 4 | "PASSWORD": "密码", 5 | "USERNAME": "用户名" 6 | } 7 | -------------------------------------------------------------------------------- /src/pages/login/components/index.ts: -------------------------------------------------------------------------------- 1 | export { default as Header } from './Header' 2 | export { default as ThirdPartyLogin } from './ThirdPartyLogin' 3 | -------------------------------------------------------------------------------- /src/features/detail/types.ts: -------------------------------------------------------------------------------- 1 | import type { DescriptionsItemType } from 'antd/es/descriptions' 2 | 3 | export type DetailItems = DescriptionsItemType[] 4 | -------------------------------------------------------------------------------- /.lintstagedrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "**/*.{js,ts,jsx,tsx}": ["eslint --fix", "prettier --write --ignore-unknown"], 3 | "**/*.{json,md,html,css,scss}": ["prettier --write"] 4 | } 5 | -------------------------------------------------------------------------------- /src/features/layout/types.ts: -------------------------------------------------------------------------------- 1 | import type { ModalProps } from 'antd' 2 | 3 | export interface RenderModal extends ModalProps { 4 | renderContent?: React.ReactNode 5 | } 6 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@bit-ocean/eslint-config/react", 3 | "rules": { 4 | "react/require-default-props": "off", 5 | "import/no-cycle": "off" 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /src/locales/user/en-US.json: -------------------------------------------------------------------------------- 1 | { 2 | "CONFIRM.PASSWORD": "Confirm password", 3 | "NEW.PASSWORD": "New password", 4 | "PASSWORD": "Password", 5 | "USERNAME": "Username" 6 | } 7 | -------------------------------------------------------------------------------- /src/pages/login/types/index.ts: -------------------------------------------------------------------------------- 1 | import type { LoginModel } from '@/api/auth.type' 2 | 3 | export interface LoginFormData extends LoginModel { 4 | rememberPassword: boolean 5 | } 6 | -------------------------------------------------------------------------------- /src/features/dictionaries/constants/form-initial-value.ts: -------------------------------------------------------------------------------- 1 | export const formInitialValue = { 2 | code: '', 3 | label: '', 4 | remark: '', 5 | enabled: true, 6 | sort: 0 7 | } 8 | -------------------------------------------------------------------------------- /src/constants/index.ts: -------------------------------------------------------------------------------- 1 | export * from './env' 2 | export * from './menu' 3 | export * from './metadata' 4 | export * from './page' 5 | export * from './query-client' 6 | export * from './theme' 7 | -------------------------------------------------------------------------------- /src/api/locale.type.ts: -------------------------------------------------------------------------------- 1 | export interface LocaleResource { 2 | /** 3 | * 命名空间 4 | */ 5 | ns: string 6 | /** 7 | * 多语言资源 8 | */ 9 | resources: Record 10 | } 11 | -------------------------------------------------------------------------------- /src/pages/code-templates/two-col/index.tsx: -------------------------------------------------------------------------------- 1 | export function Component() { 2 | return ( 3 | left} 5 | right={
right
} 6 | /> 7 | ) 8 | } 9 | -------------------------------------------------------------------------------- /src/pages/error-pages/403/index.tsx: -------------------------------------------------------------------------------- 1 | export function Component() { 2 | const { t } = useTranslation() 3 | return ( 4 | 8 | ) 9 | } 10 | -------------------------------------------------------------------------------- /src/pages/error-pages/404/index.tsx: -------------------------------------------------------------------------------- 1 | export function Component() { 2 | const { t } = useTranslation() 3 | return ( 4 | 8 | ) 9 | } 10 | -------------------------------------------------------------------------------- /src/pages/error-pages/418/index.tsx: -------------------------------------------------------------------------------- 1 | export function Component() { 2 | const { t } = useTranslation() 3 | return ( 4 | 8 | ) 9 | } 10 | -------------------------------------------------------------------------------- /src/pages/error-pages/500/index.tsx: -------------------------------------------------------------------------------- 1 | export function Component() { 2 | const { t } = useTranslation() 3 | return ( 4 | 8 | ) 9 | } 10 | -------------------------------------------------------------------------------- /@types/tanstack-query.d.ts: -------------------------------------------------------------------------------- 1 | import '@tanstack/react-query' 2 | 3 | import type { AxiosError } from 'axios' 4 | 5 | declare module '@tanstack/react-query' { 6 | interface Register { 7 | defaultError: AxiosError 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /src/hooks/useRemoteTranslation.ts: -------------------------------------------------------------------------------- 1 | export const useRemoteTranslation = () => { 2 | const { t } = useTranslation() 3 | const transformRemoteKey = (key: string = '') => (i18n.exists(key) ? t(key as any) : key) 4 | return { rt: transformRemoteKey } 5 | } 6 | -------------------------------------------------------------------------------- /src/layouts/DpBaseLayout/components/Content/index.tsx: -------------------------------------------------------------------------------- 1 | export default function Content() { 2 | return ( 3 | 4 | 5 | 6 | ) 7 | } 8 | -------------------------------------------------------------------------------- /src/layouts/DpBaseLayout/components/Sidebar/components/index.ts: -------------------------------------------------------------------------------- 1 | export { default as CollapseButton } from './CollapseButton' 2 | export { default as Header } from './Header' 3 | export { default as Mask } from './Mask' 4 | export { default as Menu } from './Menu' 5 | -------------------------------------------------------------------------------- /vercel.json: -------------------------------------------------------------------------------- 1 | { 2 | "rewrites": [ 3 | { 4 | "source": "/base-api/:path*", 5 | "destination": "https://localhost:3000/:path*" 6 | }, 7 | { 8 | "source": "/:path*", 9 | "destination": "/index.html" 10 | } 11 | ] 12 | } 13 | -------------------------------------------------------------------------------- /docker-compose.yaml: -------------------------------------------------------------------------------- 1 | version: '3.8' 2 | services: 3 | web: 4 | container_name: dolphin-admin-react-web 5 | build: 6 | context: . 7 | dockerfile: Dockerfile 8 | ports: 9 | - 5173:5173 10 | env_file: 11 | - .env.development 12 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:lts-slim 2 | 3 | WORKDIR /app 4 | 5 | COPY package.json ./ 6 | COPY pnpm-lock.yaml ./ 7 | COPY .env.development ./ 8 | 9 | RUN npm install -g pnpm 10 | RUN pnpm install 11 | 12 | COPY . . 13 | 14 | EXPOSE 5173 15 | 16 | CMD ["pnpm", "dev"] 17 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | end_of_line = lf 6 | indent_style = space 7 | indent_size = 2 8 | insert_final_newline = true 9 | trim_trailing_whitespace = true 10 | quote_type = single 11 | 12 | [*.md] 13 | trim_trailing_whitespace = false 14 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | # Auto detect text files and perform LF normalization 2 | * text=auto 3 | *.js eol=lf 4 | *.jsx eol=lf 5 | *.ts eol=lf 6 | *.tsx eol=lf 7 | *.vue eol=lf 8 | *.css eol=lf 9 | *.scss eol=lf 10 | *.md eol=lf 11 | *.mdx eol=lf 12 | *.json eol=lf 13 | *.yml eol=lf 14 | -------------------------------------------------------------------------------- /src/layouts/DpBaseLayout/components/index.ts: -------------------------------------------------------------------------------- 1 | export { default as Content } from './Content' 2 | export { default as Footer } from './Footer' 3 | export { default as Header } from './Header' 4 | export { default as Sidebar } from './Sidebar' 5 | export { default as Tabs } from './Tabs' 6 | -------------------------------------------------------------------------------- /scripts/deploy-prod.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -o allexport 4 | source .env.production 5 | set +o allexport 6 | 7 | pnpm i 8 | pnpm build:prod 9 | echo "正在上传静态资源至 $SERVER_IP" 10 | scp -r dist "$SERVER_USER"@"$SERVER_IP":/usr/share/nginx/html/dolphin-admin-react/ 11 | echo "成功部署至 $SERVER_IP" 12 | -------------------------------------------------------------------------------- /scripts/deploy-staging.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -o allexport 4 | source .env.staging 5 | set +o allexport 6 | 7 | pnpm i 8 | pnpm build:staging 9 | echo "正在上传静态资源至 $SERVER_IP" 10 | scp -r dist "$SERVER_USER"@"$SERVER_IP":/usr/share/nginx/html/dolphin-admin-react/ 11 | echo "成功部署至 $SERVER_IP" 12 | -------------------------------------------------------------------------------- /.renovaterc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "config:base" 4 | ], 5 | "labels": [ 6 | "Dependencies" 7 | ], 8 | "semanticCommits": true, 9 | "packageRules": [ 10 | { 11 | "depTypeList": [ 12 | "devDependencies" 13 | ], 14 | "automerge": true 15 | } 16 | ] 17 | } 18 | -------------------------------------------------------------------------------- /src-tauri/src/main.rs: -------------------------------------------------------------------------------- 1 | // Prevents additional console window on Windows in release, DO NOT REMOVE!! 2 | #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] 3 | 4 | fn main() { 5 | tauri::Builder::default() 6 | .run(tauri::generate_context!()) 7 | .expect("error while running tauri application"); 8 | } 9 | -------------------------------------------------------------------------------- /tailwind.config.cjs: -------------------------------------------------------------------------------- 1 | /** @type {import('tailwindcss').Config} */ 2 | module.exports = { 3 | content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'], 4 | theme: { 5 | extend: { 6 | textColor: { 7 | muted: '#999999' 8 | } 9 | } 10 | }, 11 | darkMode: ['class', '[data-theme="dark"]'] 12 | } 13 | -------------------------------------------------------------------------------- /src/features/modal/modal-title-map.ts: -------------------------------------------------------------------------------- 1 | import { ModalType } from './modal-type' 2 | 3 | const t = i18n.getFixedT(null, 'COMMON') 4 | 5 | export const modalTitleMap = new Map string>([ 6 | [ModalType.CREATE, () => t('CREATE')], 7 | [ModalType.EDIT, () => t('EDIT')], 8 | [ModalType.DETAIL, () => t('DETAIL')] 9 | ]) 10 | -------------------------------------------------------------------------------- /src/assets/styles/fonts.scss: -------------------------------------------------------------------------------- 1 | // Google Fonts - Nunito & Noto Sans SC & Noto Color Emoji 2 | @import url('https://fonts.googleapis.com/css2?family=Noto+Color+Emoji&family=Noto+Sans+SC:wght@100;200;300;400;500;600;700;800;900&family=Nunito:ital,wght@0,200;0,300;0,400;0,500;0,600;0,700;0,800;0,900;0,1000;1,200;1,300;1,400;1,500;1,600;1,700;1,800;1,900;1,1000&display=swap'); 3 | -------------------------------------------------------------------------------- /src/features/locales/utils.ts: -------------------------------------------------------------------------------- 1 | import type { LocaleResource } from '@/api/locale.type' 2 | 3 | export const processLocaleResources = (lang: string, localeResources: LocaleResource[]) => { 4 | localeResources.forEach(({ ns, resources }) => 5 | // NOTE: i18n.addResources 不会工作,可能是由于 JSON 键前缀重合导致的 6 | i18n.addResourceBundle(lang, ns, resources, true, true) 7 | ) 8 | } 9 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@bit-ocean/tsconfig/react", 3 | "compilerOptions": { 4 | "types": ["unplugin-icons/types/react"], 5 | "baseUrl": ".", 6 | "paths": { 7 | "@/*": ["src/*"] 8 | } 9 | }, 10 | "include": ["src", "@types"], 11 | "references": [ 12 | { 13 | "path": "./tsconfig.node.json" 14 | } 15 | ] 16 | } 17 | -------------------------------------------------------------------------------- /src/features/dictionaries/hooks/useDictionaryListParams.ts: -------------------------------------------------------------------------------- 1 | export const useDictionaryListParams = () => { 2 | const [listParams, setListParams] = useImmer({ 3 | keywords: '', 4 | code: '', 5 | label: '', 6 | enabled: null, 7 | startTime: null, 8 | endTime: null 9 | }) 10 | 11 | return { 12 | listParams, 13 | setListParams 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /@types/i18next.d.ts: -------------------------------------------------------------------------------- 1 | import 'i18next' 2 | 3 | import type EN_US from '../src/locales' 4 | 5 | declare module 'i18next' { 6 | interface CustomTypeOptions { 7 | defaultNS: 'COMMON' 8 | resources: typeof EN_US 9 | } 10 | 11 | // eslint-disable-next-line @typescript-eslint/naming-convention 12 | interface i18n { 13 | rt: (key: string) => string 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/layouts/DpBaseLayout/components/Tabs/index.tsx: -------------------------------------------------------------------------------- 1 | export default function Tabs() { 2 | const { headerBg } = ATheme.useToken().token.Layout! 3 | 4 | return ( 5 |
9 | ) 10 | } 11 | -------------------------------------------------------------------------------- /src/pages/resources/locales/index.tsx: -------------------------------------------------------------------------------- 1 | export function Component() { 2 | const [searchText, setSearchText] = useState('') 3 | 4 | return ( 5 | {}} 11 | /> 12 | } 13 | /> 14 | ) 15 | } 16 | -------------------------------------------------------------------------------- /src/main.tsx: -------------------------------------------------------------------------------- 1 | import '@/assets/styles/main.scss' 2 | import '@/i18n' 3 | 4 | import React from 'react' 5 | import ReactDOM from 'react-dom/client' 6 | 7 | import App from '@/App' 8 | 9 | BrowserUtils.loadFavicon() 10 | BrowserUtils.disableGestureScale() 11 | 12 | ReactDOM.createRoot(document.getElementById('root')!).render( 13 | 14 | 15 | 16 | ) 17 | -------------------------------------------------------------------------------- /.cspell.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://raw.githubusercontent.com/streetsidesoftware/cspell/main/cspell.schema.json", 3 | "version": "0.2", 4 | "language": "en", 5 | "dictionaries": ["custom-words"], 6 | "dictionaryDefinitions": [ 7 | { 8 | "name": "custom-words", 9 | "path": ".cspell.txt", 10 | "addWords": true 11 | } 12 | ], 13 | "import": ["@bit-ocean/cspell"] 14 | } 15 | -------------------------------------------------------------------------------- /src/layouts/DpBaseLayout/components/Sidebar/components/Mask/index.tsx: -------------------------------------------------------------------------------- 1 | export default function Mask() { 2 | const sidebarStore = useSidebarStore() 3 | return ( 4 |
11 | ) 12 | } 13 | -------------------------------------------------------------------------------- /src/locales/layout/zh-CN.json: -------------------------------------------------------------------------------- 1 | { 2 | "HEADER.CHANGE.PASSWORD": "修改密码", 3 | "HEADER.EXIT.FULL.SCREEN": "退出全屏", 4 | "HEADER.FULL.SCREEN": "全屏", 5 | "HEADER.LANGUAGE": "语言", 6 | "HEADER.LOCK.SCREEN": "锁屏", 7 | "HEADER.LOG.OUT": "退出登录", 8 | "HEADER.NOTIFICATION": "通知", 9 | "HEADER.SWITCH.THEME": "切换主题", 10 | "HEADER.USER.INFO": "个人信息", 11 | "SIDEBAR.HIDE": "隐藏侧边栏", 12 | "SIDEBAR.SHOW": "显示侧边栏" 13 | } 14 | -------------------------------------------------------------------------------- /src/pages/login/hooks/useHandleLoginResult.ts: -------------------------------------------------------------------------------- 1 | import type { Tokens } from '@/api/auth.type' 2 | 3 | export const useHandleLoginResult = () => { 4 | const handleLoginResult = (tokens: Tokens) => { 5 | const { accessToken, refreshToken } = tokens ?? {} 6 | // 保存 token 和用户信息 7 | AuthUtils.setAccessToken(accessToken) 8 | AuthUtils.setRefreshToken(refreshToken) 9 | } 10 | 11 | return { handleLoginResult } 12 | } 13 | -------------------------------------------------------------------------------- /src/layouts/DpAuthLayout/index.tsx: -------------------------------------------------------------------------------- 1 | export default function DpAuthLayout() { 2 | const { isLoading } = useAuthGuard({ skipAuth: true }) 3 | 4 | // 加载用户数据、背景图片时,显示全局 Loading 动画 5 | if (isLoading) { 6 | return 7 | } 8 | 9 | return ( 10 |
11 |
12 | 13 |
14 |
15 | ) 16 | } 17 | -------------------------------------------------------------------------------- /src/locales/auth/zh-CN.json: -------------------------------------------------------------------------------- 1 | { 2 | "ALREADY.HAVE.ACCOUNT": "已有账号?", 3 | "AUTHORIZING": "正在授权...", 4 | "FORGOT.PASSWORD": "忘记密码", 5 | "LOG.OUT.SUCCESS": "登出成功", 6 | "LOGIN": "登录", 7 | "LOGIN.AS.ADMIN": "以管理员登录", 8 | "LOGIN.AS.VISITOR": "以访客登录", 9 | "LOGIN.WITH.GITHUB": "GitHub 登录", 10 | "LOGIN.WITH.GOOGLE": "Google 登录", 11 | "NEED.ACCOUNT": "需要账号?", 12 | "SIGN.UP": "注册", 13 | "THIRD.PARTY.LOGIN": "第三方登录", 14 | "WELCOME.BACK": "欢迎回来!" 15 | } 16 | -------------------------------------------------------------------------------- /netlify.toml: -------------------------------------------------------------------------------- 1 | [build.environment] 2 | NODE_VERSION = "20" 3 | 4 | [build] 5 | publish = "dist" 6 | command = "pnpm build:prod" 7 | 8 | [[redirects]] 9 | from = "/base-api/*" 10 | to = "https://example.com/:splat" 11 | status = 200 12 | force = true 13 | 14 | [[redirects]] 15 | from = "/*" 16 | to = "/index.html" 17 | status = 200 18 | 19 | [[headers]] 20 | for = "/manifest.webmanifest" 21 | [headers.values] 22 | Content-Type = "application/manifest+json" 23 | -------------------------------------------------------------------------------- /src/locales/menu/zh-CN.json: -------------------------------------------------------------------------------- 1 | { 2 | "CODE.TEMPLATES": "代码模版", 3 | "CODE.TEMPLATES.CARD": "卡片", 4 | "CODE.TEMPLATES.TABLE": "表格", 5 | "CODE.TEMPLATES.TWO.COL": "两列", 6 | "HOME": "首页", 7 | "LOGIN": "登录", 8 | "SIGN.UP": "注册", 9 | "ERROR.PAGES": "错误页面", 10 | "MULTI.LEVEL.MENUS": "多级菜单", 11 | "SYSTEM.MANAGEMENT": "系统管理", 12 | "DICTIONARY.MANAGEMENT": "字典管理", 13 | "DICTIONARY.DATA": "字典数据", 14 | "RESOURCES.MANAGEMENT": "资源管理", 15 | "LOCALES.MANAGEMENT": "翻译管理" 16 | } 17 | -------------------------------------------------------------------------------- /src/components/DpGlobalLoading/index.tsx: -------------------------------------------------------------------------------- 1 | export default function DpGlobalLoading() { 2 | const { APP_NAME } = AppMetadata 3 | 4 | return ( 5 |
6 | 7 | {APP_NAME} 8 |
9 | Powered by Bit Ocean 10 |
11 |
12 | ) 13 | } 14 | -------------------------------------------------------------------------------- /src/features/lang/utils.ts: -------------------------------------------------------------------------------- 1 | import { Lang } from '@dolphin-admin/utils' 2 | import type { Locale } from 'antd/lib/locale' 3 | import enUS from 'antd/locale/en_US' 4 | import zhCN from 'antd/locale/zh_CN' 5 | 6 | export const getDefaultLocale = (): Locale => { 7 | const lang = LangUtils.getDefaultLang(Lang['en-US']) 8 | switch (lang) { 9 | case Lang['zh-CN']: 10 | return zhCN 11 | case Lang['en-US']: 12 | return enUS 13 | default: 14 | return zhCN 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/features/modal/useModal.ts: -------------------------------------------------------------------------------- 1 | import { modalTitleMap } from './modal-title-map' 2 | import { ModalType } from './modal-type' 3 | 4 | export const useModal = () => { 5 | const [open, { toggle }] = useToggle(false) 6 | const [modalType, setModalType] = useState(ModalType.CREATE) 7 | 8 | const getModalTitle = () => modalTitleMap.get(modalType)!() 9 | 10 | return { 11 | open, 12 | modalType, 13 | setModalType, 14 | getModalTitle, 15 | toggle 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /@types/env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | interface ImportMetaEnv { 4 | readonly VITE_PORT: string 5 | readonly VITE_APP_BASE_URL: string 6 | readonly VITE_BASE_API_PREFIX: string 7 | readonly VITE_BASE_API_URL: string 8 | readonly VITE_MOCK_API_PREFIX: string 9 | readonly VITE_MOCK_API_URL: string 10 | readonly VITE_GITHUB_CLIENT_ID: string 11 | readonly VITE_GOOGLE_CLIENT_ID: string 12 | } 13 | 14 | interface ImportMeta { 15 | readonly env: ImportMetaEnv 16 | } 17 | -------------------------------------------------------------------------------- /src/locales/layout/en-US.json: -------------------------------------------------------------------------------- 1 | { 2 | "HEADER.CHANGE.PASSWORD": "Change password", 3 | "HEADER.EXIT.FULL.SCREEN": "Exit full screen", 4 | "HEADER.FULL.SCREEN": "Full screen", 5 | "HEADER.LANGUAGE": "Language", 6 | "HEADER.LOCK.SCREEN": "Lock screen", 7 | "HEADER.LOG.OUT": "Logout", 8 | "HEADER.NOTIFICATION": "Notification", 9 | "HEADER.SWITCH.THEME": "Switch theme", 10 | "HEADER.USER.INFO": "User info", 11 | "SIDEBAR.HIDE": "Hide sidebar", 12 | "SIDEBAR.SHOW": "Show sidebar" 13 | } 14 | -------------------------------------------------------------------------------- /src/layouts/DpBaseLayout/components/Header/components/ThemeToggle/index.module.scss: -------------------------------------------------------------------------------- 1 | ::view-transition-old(root), 2 | ::view-transition-new(root) { 3 | animation: none; 4 | mix-blend-mode: normal; 5 | } 6 | 7 | ::view-transition-old(root) { 8 | z-index: 9999; 9 | } 10 | 11 | ::view-transition-new(root) { 12 | z-index: 1; 13 | } 14 | 15 | [data-theme='dark']::view-transition-old(root) { 16 | z-index: 1; 17 | } 18 | 19 | [data-theme='dark']::view-transition-new(root) { 20 | z-index: 9999; 21 | } 22 | -------------------------------------------------------------------------------- /src/layouts/DpBaseLayout/components/Header/components/DiscordButton/index.tsx: -------------------------------------------------------------------------------- 1 | export default function DiscordButton() { 2 | const { DISCORD_URL } = AppMetadata 3 | return ( 4 | 8 | BrowserUtils.openNewWindow(DISCORD_URL)} 14 | /> 15 | 16 | ) 17 | } 18 | -------------------------------------------------------------------------------- /src/pages/login/components/Header/index.tsx: -------------------------------------------------------------------------------- 1 | export default function Header() { 2 | const { t } = useTranslation('AUTH') 3 | return ( 4 |
5 | 6 | {AppMetadata.APP_NAME} 7 | 11 | 12 | 🎉 {t('WELCOME.BACK')} 13 |
14 | ) 15 | } 16 | -------------------------------------------------------------------------------- /src/layouts/DpBaseLayout/components/Header/components/DocsButton/index.tsx: -------------------------------------------------------------------------------- 1 | export default function DocsButton() { 2 | const { DOCS_URL } = AppMetadata 3 | const { t } = useTranslation() 4 | return ( 5 | 9 | BrowserUtils.openNewWindow(DOCS_URL)} 15 | /> 16 | 17 | ) 18 | } 19 | -------------------------------------------------------------------------------- /src/layouts/DpBaseLayout/components/Header/components/GitHubButton/index.tsx: -------------------------------------------------------------------------------- 1 | export default function GitHubButton() { 2 | const { REPO_GITHUB_URL } = AppMetadata 3 | return ( 4 | 9 | BrowserUtils.openNewWindow(REPO_GITHUB_URL)} 15 | /> 16 | 17 | ) 18 | } 19 | -------------------------------------------------------------------------------- /src/api/locale.ts: -------------------------------------------------------------------------------- 1 | import type { LocaleResource } from './locale.type' 2 | 3 | export class LocaleAPI { 4 | private static LOCALE_API_PREFIX = '/locales' 5 | 6 | /** 7 | * 英文国际化资源缓存 key 8 | */ 9 | static EN_US_QUERY_KEY = 'LOCALE.EN.US' 10 | 11 | /** 12 | * 中文国际化资源缓存 key 13 | */ 14 | static ZH_CN_QUERY_KEY = 'LOCALE.ZH.CN' 15 | 16 | /** 17 | * 根据语言获取国际化资源 18 | */ 19 | static getLocaleResources(lang: string) { 20 | return httpRequest.get(`${this.LOCALE_API_PREFIX}/${lang}`) 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Dependencies 2 | node_modules 3 | 4 | # Production 5 | dist 6 | build 7 | .env* 8 | !.env.example 9 | stats.html 10 | public/deploy.txt 11 | 12 | # Logs 13 | logs 14 | *.log 15 | npm-debug.log* 16 | yarn-debug.log* 17 | yarn-error.log* 18 | pnpm-debug.log* 19 | 20 | # Misc 21 | .DS_Store 22 | coverage 23 | *.local 24 | 25 | # Editor 26 | .vscode/* 27 | !.vscode/extensions.json 28 | !.vscode/*.code-snippets 29 | .idea 30 | *.suo 31 | *.ntvs* 32 | *.njsproj 33 | *.sln 34 | *.sw? 35 | *.zip 36 | 37 | # Cache 38 | .cspellcache 39 | .eslintcache 40 | -------------------------------------------------------------------------------- /src/layouts/DpBaseLayout/components/Header/components/MenuVisibilityToggle/index.tsx: -------------------------------------------------------------------------------- 1 | export default function MenuVisibilityToggle() { 2 | const { t } = useTranslation('LAYOUT') 3 | const sidebarStore = useSidebarStore() 4 | return ( 5 | 9 | 15 | 16 | ) 17 | } 18 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | # 启动端口 2 | VITE_PORT=5173 3 | # 启动地址 4 | VITE_APP_BASE_URL=http://localhost:5173 5 | # 接口前缀 6 | VITE_BASE_API_PREFIX=/base-api 7 | # 接口地址(本地运行) 8 | VITE_BASE_API_URL=http://localhost:3000 9 | # 接口地址(Docker 运行,更改为 container 名称) 10 | # VITE_BASE_API_URL=http://dolphin-admin-nest:3000 11 | # Mock 接口前缀 12 | VITE_MOCK_API_PREFIX=/mock-api 13 | # Mock 接口地址 14 | VITE_MOCK_API_URL=https://mock.apifox.cn/ 15 | # GitHub 应用 ID 16 | VITE_GITHUB_CLIENT_ID= 17 | # Google 应用 ID 18 | VITE_GOOGLE_CLIENT_ID= 19 | 20 | # 服务器地址 21 | SERVER_IP= 22 | # 服务器用户名 23 | SERVER_USER= 24 | -------------------------------------------------------------------------------- /src/constants/query-client.ts: -------------------------------------------------------------------------------- 1 | export const STALE = { 2 | MINUTES: { 3 | ONE: 1e3 * 60, 4 | TWO: 1e3 * 60 * 2, 5 | THREE: 1e3 * 60 * 3, 6 | FOUR: 1e3 * 60 * 4, 7 | FIVE: 1e3 * 60 * 5, 8 | TEN: 1e3 * 60 * 10 9 | }, 10 | HOURS: { 11 | HALF: 1e3 * 60 * 30, 12 | ONE: 1e3 * 60 * 60, 13 | TWO: 1e3 * 60 * 60 * 2 14 | }, 15 | INFINITY: Infinity 16 | } 17 | 18 | export const defaultQueryConfig = { 19 | queries: { 20 | staleTime: STALE.MINUTES.FIVE, // 5min 21 | gcTime: STALE.MINUTES.FIVE, // 5min 22 | retry: 1 // 失败重试次数 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/locales/auth/en-US.json: -------------------------------------------------------------------------------- 1 | { 2 | "ALREADY.HAVE.ACCOUNT": "Already have an account?", 3 | "AUTHORIZING": "Authorizing...", 4 | "FORGOT.PASSWORD": "Forgot password", 5 | "LOG.OUT.SUCCESS": "Logout success", 6 | "LOGIN": "Login", 7 | "LOGIN.AS.ADMIN": "Login as Admin", 8 | "LOGIN.AS.VISITOR": "Login as Visitor", 9 | "LOGIN.WITH.GITHUB": "Sign in with GitHub", 10 | "LOGIN.WITH.GOOGLE": "Sign in with Google", 11 | "NEED.ACCOUNT": "Need an account?", 12 | "SIGN.UP": "Sign up", 13 | "THIRD.PARTY.LOGIN": "Third party login", 14 | "WELCOME.BACK": "Welcome back!" 15 | } 16 | -------------------------------------------------------------------------------- /nginx.conf: -------------------------------------------------------------------------------- 1 | server { 2 | server_name example.com; 3 | charset 'utf-8'; 4 | 5 | root /usr/share/nginx/html/dolphin-admin-react-admin/dist; 6 | 7 | location / { 8 | try_files $uri $uri/ /index.html; 9 | } 10 | 11 | location /base-api/ { 12 | proxy_pass https://api.xxx.com/; 13 | } 14 | 15 | location = /favicon.ico { 16 | alias /usr/share/nginx/html/dolphin-admin-react-admin/dist/favicon.ico; 17 | access_log off; 18 | log_not_found off; 19 | expires max; 20 | } 21 | 22 | location = /robots.txt { 23 | access_log off; 24 | log_not_found off; 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/features/dictionaries/constants/detail-fields.tsx: -------------------------------------------------------------------------------- 1 | const t = i18n.getFixedT(null, ['COMMON', 'DICTIONARY']) 2 | 3 | export const detailFields = [ 4 | { key: 'label', label: () => t('DICTIONARY:LABEL'), children: DpDetailField.I18nString }, 5 | { key: 'code', label: () => t('DICTIONARY:CODE'), children: DpDetailField.String }, 6 | { key: 'enabled', label: () => t('ENABLE.OR.NOT'), children: DpDetailField.Boolean }, 7 | { key: 'remark', label: () => t('REMARK'), children: DpDetailField.I18nString }, 8 | { key: 'createdAt', label: () => t('CREATED.AT'), children: DpDetailField.DateString } 9 | ] 10 | -------------------------------------------------------------------------------- /src/locales/menu/en-US.json: -------------------------------------------------------------------------------- 1 | { 2 | "CODE.TEMPLATES": "Code Templates", 3 | "CODE.TEMPLATES.CARD": "Card", 4 | "CODE.TEMPLATES.TABLE": "Table", 5 | "CODE.TEMPLATES.TWO.COL": "Two Col", 6 | "HOME": "Home", 7 | "LOGIN": "Login", 8 | "SIGN.UP": "Signup", 9 | "ERROR.PAGES": "Error Pages", 10 | "MULTI.LEVEL.MENUS": "Multi Level Menu", 11 | "SYSTEM.MANAGEMENT": "System Management", 12 | "DICTIONARY.MANAGEMENT": "Dictionary Management", 13 | "DICTIONARY.DATA": "Dictionary Data", 14 | "RESOURCES.MANAGEMENT": "Resources Management", 15 | "LOCALES.MANAGEMENT": "Locales Management" 16 | } 17 | -------------------------------------------------------------------------------- /src/layouts/DpBaseLayout/components/Header/components/index.ts: -------------------------------------------------------------------------------- 1 | export { default as Breadcrumb } from './Breadcrumb' 2 | export { default as DiscordButton } from './DiscordButton' 3 | export { default as DocsButton } from './DocsButton' 4 | export { default as FullScreenButton } from './FullScreenButton' 5 | export { default as GitHubButton } from './GitHubButton' 6 | export { default as LanguageButton } from './LanguageButton' 7 | export { default as MenuVisibilityToggle } from './MenuVisibilityToggle' 8 | export { default as ThemeToggle } from './ThemeToggle' 9 | export { default as UserAvatar } from './UserAvatar' 10 | -------------------------------------------------------------------------------- /src/locales/index.ts: -------------------------------------------------------------------------------- 1 | import AUTH from './auth/en-US.json' 2 | import COMMON from './common/en-US.json' 3 | import DICTIONARY from './dictionary/en-US.json' 4 | import LAYOUT from './layout/en-US.json' 5 | import MENU from './menu/en-US.json' 6 | import USER from './user/en-US.json' 7 | import VALIDATION from './validation/en-US.json' 8 | 9 | /** 10 | * 用于给 `@types/i18next.d.ts` 提供类型定义 11 | * @see https://www.i18next.com/overview/typescript 12 | */ 13 | const resources = { 14 | COMMON, 15 | AUTH, 16 | VALIDATION, 17 | LAYOUT, 18 | MENU, 19 | USER, 20 | DICTIONARY 21 | } 22 | export default resources 23 | -------------------------------------------------------------------------------- /src/features/router/types.ts: -------------------------------------------------------------------------------- 1 | import type { IndexRouteObject, NonIndexRouteObject } from 'react-router-dom' 2 | 3 | import type { IconType } from '../icon' 4 | 5 | export interface RouteMetadata { 6 | title?: string | (() => string) 7 | hideTitle?: boolean 8 | icon?: IconType 9 | } 10 | 11 | interface CustomIndexRouteObject extends IndexRouteObject { 12 | meta?: RouteMetadata 13 | } 14 | 15 | interface CustomNonIndexRouteObject extends NonIndexRouteObject { 16 | children?: CustomRouteObject[] 17 | meta?: RouteMetadata 18 | } 19 | 20 | export type CustomRouteObject = CustomIndexRouteObject | CustomNonIndexRouteObject 21 | -------------------------------------------------------------------------------- /src/layouts/DpBaseLayout/components/Header/components/FullScreenButton/index.tsx: -------------------------------------------------------------------------------- 1 | export default function FullScreenButton() { 2 | const { t } = useTranslation('LAYOUT') 3 | const [isFullscreen, { toggleFullscreen }] = useFullscreen(document.body) 4 | 5 | return ( 6 | 10 | 17 | 18 | ) 19 | } 20 | -------------------------------------------------------------------------------- /src/layouts/DpBaseLayout/index.tsx: -------------------------------------------------------------------------------- 1 | import { Content, Footer, Header, Sidebar, Tabs } from './components' 2 | 3 | export default function DpBaseLayout() { 4 | const { isLoading } = useAuthGuard() 5 | 6 | if (isLoading) { 7 | return 8 | } 9 | 10 | return ( 11 | // NOTE: 此处 rootClassName 不加 !flex-row 会导致加载布局闪屏 12 | 13 | 14 | 15 |
16 | 17 | 18 |
19 | 20 | 21 | ) 22 | } 23 | -------------------------------------------------------------------------------- /src/pages/login/hooks/useRedirect.ts: -------------------------------------------------------------------------------- 1 | export const useRedirect = () => { 2 | const [searchParams] = useSearchParams() 3 | const navigate = useNavigate() 4 | 5 | const handleRedirect = () => { 6 | // 跳转到登录前的页面 7 | if (searchParams.get('redirect')) { 8 | navigate(searchParams.get('redirect')!, { replace: true }) 9 | } else { 10 | navigate('/', { replace: true }) 11 | } 12 | } 13 | 14 | // 忘记密码 15 | const handleForgotPassword = () => navigate('/forgot-password') 16 | 17 | // 注册 18 | const handleSignup = () => navigate('/signup') 19 | 20 | return { handleRedirect, handleForgotPassword, handleSignup } 21 | } 22 | -------------------------------------------------------------------------------- /src/features/users/api/useUsersQuery.ts: -------------------------------------------------------------------------------- 1 | export const useUsersQuery = () => { 2 | const query = useQuery({ 3 | queryKey: ['Users'], 4 | queryFn: () => 5 | UserAPI.list({ 6 | page: 1, 7 | pageSize: 10 8 | }) 9 | }) 10 | return query 11 | } 12 | 13 | export const useUsersPrefetchQuery = () => { 14 | const queryClient = useQueryClient() 15 | 16 | useEffect(() => {}, []) 17 | 18 | const prefetch = queryClient.prefetchQuery({ 19 | queryKey: ['Users'], 20 | queryFn: () => 21 | UserAPI.list({ 22 | page: 1, 23 | pageSize: 10 24 | }) 25 | }) 26 | 27 | return prefetch 28 | } 29 | -------------------------------------------------------------------------------- /src/locales/validation/zh-CN.json: -------------------------------------------------------------------------------- 1 | { 2 | "Address": "请输入地址", 3 | "BIOGRAPHY": "请输入简介", 4 | "CONFIRM.PASSWORD": "请输入确认密码", 5 | "CONFIRM.PASSWORD.NOT.MATCH": "两次输入的密码不一致", 6 | "DICTIONARY.CODE": "请输入字典编码", 7 | "DICTIONARY.LABEL": "请输入字典名称", 8 | "EMAIL": "请输入邮箱", 9 | "EMAIL.FORMAT": "请输入正确格式的邮箱", 10 | "FILE.NAME": "请输入文件名", 11 | "FIRST.NAME": "请输入名", 12 | "FONT": "请选择字体", 13 | "LABEL": "请输入名称", 14 | "LAST.NAME": "请输入姓", 15 | "NAME": "请输入姓名", 16 | "OLD.PASSWORD": "请输入旧密码", 17 | "PASSWORD": "请输入密码", 18 | "PASSWORD.LENGTH": "密码长度至少为 6 位", 19 | "PHONE.NUMBER": "请输入电话号码", 20 | "PHONE.NUMBER.FORMAT": "请输入正确格式的手机号", 21 | "REMARK": "请输入备注", 22 | "USERNAME": "请输入用户名" 23 | } 24 | -------------------------------------------------------------------------------- /src/constants/env.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * 全局环境变量 3 | */ 4 | export const GlobalEnvConfig = Object.freeze({ 5 | PORT: import.meta.env.VITE_PORT ?? '', 6 | APP_BASE_URL: import.meta.env.VITE_APP_BASE_URL ?? '', 7 | BASE_API_PREFIX: import.meta.env.VITE_BASE_API_PREFIX ?? '', 8 | BASE_API_URL: import.meta.env.VITE_BASE_API_URL ?? '', 9 | MOCK_API_PREFIX: import.meta.env.VITE_MOCK_API_PREFIX ?? '', 10 | MOCK_API_URL: import.meta.env.VITE_MOCK_API_URL ?? '', 11 | GITHUB_CLIENT_ID: import.meta.env.VITE_GITHUB_CLIENT_ID ?? '', 12 | GOOGLE_CLIENT_ID: import.meta.env.VITE_GOOGLE_CLIENT_ID ?? '', 13 | MODE: import.meta.env.MODE, 14 | IS_DEV: import.meta.env.DEV, 15 | IS_PROD: import.meta.env.PROD 16 | }) 17 | -------------------------------------------------------------------------------- /README.zh-CN.md: -------------------------------------------------------------------------------- 1 | # Dolphin Admin React 2 | 3 | [English](./README.md) / 简体中文 4 | 5 | Dolphin Admin React 是一个基于 React + TypeScript + Vite + antd + TailwindCSS 的开源、轻量级、开箱即用、优雅精致、支持国际化的后台管理模板。 6 | 7 | ## 技术栈 8 | 9 | - [x] 基于 [React](https://react.dev/)、[Vite](https://vitejs.dev/) 10 | - [x] [TypeScript](https://www.typescriptlang.org/),当然 11 | 12 | ## 使用 13 | 14 | ### 安装 15 | 16 | ```bash 17 | pnpm i 18 | ``` 19 | 20 | ### 启动 21 | 22 | ```bash 23 | pnpm dev 24 | ``` 25 | 26 | ### 构建 27 | 28 | ```bash 29 | pnpm build 30 | ``` 31 | 32 | ## 部署 33 | 34 | 前往 Vercel 并选择你的 Git 仓库,模板选择 Vite,添加生产环境变量,然后点击部署即可。 35 | 36 | ## 许可证 37 | 38 | [MIT](/LICENSE) License © 2023 [Bruce Song](https://github.com/recallwei) 39 | -------------------------------------------------------------------------------- /src/api/auth.type.ts: -------------------------------------------------------------------------------- 1 | export interface LoginModel { 2 | /** 3 | * 密码 4 | */ 5 | password: string 6 | /** 7 | * 用户名 8 | */ 9 | username: string 10 | } 11 | 12 | export interface SignupModel extends LoginModel { 13 | /** 14 | * 确认密码 15 | */ 16 | confirmPassword: string 17 | } 18 | 19 | export interface Tokens { 20 | /** 21 | * 访问令牌 22 | */ 23 | accessToken: string 24 | /** 25 | * 刷新令牌 26 | */ 27 | refreshToken: string 28 | } 29 | 30 | export interface ChangePasswordModel { 31 | /** 32 | * 旧密码 33 | */ 34 | oldPassword: string 35 | /** 36 | * 新密码 37 | */ 38 | newPassword: string 39 | /** 40 | * 确认密码 41 | */ 42 | confirmPassword: string 43 | } 44 | -------------------------------------------------------------------------------- /src/features/users/api/useProfileQuery.ts: -------------------------------------------------------------------------------- 1 | import { STALE } from '@/constants' 2 | 3 | interface Options { 4 | enabled?: boolean 5 | } 6 | 7 | export const PROFILE_QUERY_KEY = 'profile' 8 | 9 | export const profileQK = () => [PROFILE_QUERY_KEY] 10 | 11 | export const useProfileQuery = (options?: Options) => { 12 | const userStore = useUserStore() 13 | 14 | const query = useQuery({ 15 | queryKey: profileQK(), 16 | queryFn: () => UserAPI.profile(), 17 | enabled: options?.enabled, 18 | staleTime: STALE.HOURS.ONE 19 | }) 20 | 21 | useEffect(() => { 22 | if (query.data) { 23 | const { data: user } = query 24 | userStore.setUser(user ?? {}) 25 | } 26 | }, [query.data]) 27 | 28 | return query 29 | } 30 | -------------------------------------------------------------------------------- /src/hooks/useRouteMeta.ts: -------------------------------------------------------------------------------- 1 | import type { RouteMetadata } from '@/features/router' 2 | import { getRouteMetadata, routes } from '@/router' 3 | 4 | interface RouteMetaAction { 5 | getTitle: () => string | undefined 6 | } 7 | 8 | // 获取当前路由的元数据 9 | export const useRouteMeta = (): RouteMetadata & RouteMetaAction => { 10 | const location = useLocation() 11 | 12 | const routeMeta = useMemo( 13 | () => getRouteMetadata(location.pathname, routes) ?? {}, 14 | [location.pathname] 15 | ) 16 | 17 | /** 18 | * 获取路由标题 19 | * @description `title` 可能函数或字符串,需要处理 20 | */ 21 | const getTitle = () => 22 | typeof routeMeta.title === 'function' ? routeMeta.title() : routeMeta.title 23 | 24 | return { ...routeMeta, getTitle } 25 | } 26 | -------------------------------------------------------------------------------- /src/api/axios.enum.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * 常用响应状态码 3 | * @description 4 | * 1xx: Informational - 请求已接收,继续处理 5 | * 2xx: Success - 请求已成功被服务器接收、理解、并接受 6 | * 3xx: Redirection - 需要后续操作才能完成这一请求 7 | * 4xx: Client Error - 请求含有词法错误或者无法被执行 8 | * 5xx: Server Error - 服务器在处理某个正确请求时发生错误 9 | */ 10 | export enum StatusCode { 11 | SUCCESS = 200, 12 | CREATED = 201, 13 | BAD_REQUEST = 400, 14 | UNAUTHORIZED = 401, 15 | FORBIDDEN = 403, 16 | NOT_FOUND = 404, 17 | METHOD_NOT_ALLOWED = 405, 18 | CONFLICT = 409, 19 | UNPROCESSABLE_ENTITY = 422, 20 | TOO_MANY_REQUESTS = 429, 21 | INTERNAL_SERVER_ERROR = 500, 22 | BAD_GATEWAY = 502, 23 | GATEWAY_TIMEOUT = 504 24 | } 25 | 26 | export enum OrderType { 27 | descend = 'desc', 28 | ascend = 'asc' 29 | } 30 | -------------------------------------------------------------------------------- /src/constants/metadata.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * 站点元数据 3 | * @description 用于配置站点的基本信息 4 | * - `APP_NAME`: 站点名称 5 | * - `VERSION`: 站点版本 6 | * - `FAVICON`: 站点图标 7 | * - `TEAM_NAME`: 团队名称 8 | * - `TEAM_GITHUB_URL`: 团队 GitHub 地址 9 | * - `REPO_GITHUB_URL`: 仓库 GitHub 地址 10 | * - `DOCS_URL`: 文档地址 11 | * - `DISCORD_URL`: Discord 地址 12 | */ 13 | export const AppMetadata = Object.freeze({ 14 | APP_NAME: 'Dolphin Admin React', 15 | VERSION: '0.0.1', 16 | FAVICON_URL: '/favicon.ico', 17 | TEAM_NAME: 'Bit Ocean', 18 | TEAM_GITHUB_URL: 'https://github.com/bit-ocean-studio', 19 | REPO_GITHUB_URL: 'https://github.com/bit-ocean-studio/dolphin-admin-react', 20 | DOCS_URL: 'https://dolphin-admin-docs.bit-ocean.studio', 21 | DISCORD_URL: 'https://discord.gg/NfPAGuz7Em' 22 | }) 23 | -------------------------------------------------------------------------------- /src/hooks/useHoverDisplay.ts: -------------------------------------------------------------------------------- 1 | export const useHoverDisplay = (defaultValue: boolean = false) => { 2 | const [isHovering, setIsHovering] = useState(defaultValue) 3 | const [hoverItem, setHoverItem] = useState(null) 4 | return { 5 | isHovering, 6 | setIsHovering, 7 | hoverItem, 8 | setHoverItem, 9 | onMouseEnter: (data?: T) => { 10 | setHoverItem(data ?? null) 11 | setIsHovering(true) 12 | }, 13 | onMouseLeave: () => { 14 | setHoverItem(null) 15 | setIsHovering(false) 16 | }, 17 | onMouseOver: (data?: T) => { 18 | setHoverItem(data ?? null) 19 | setIsHovering(true) 20 | }, 21 | onMouseOut: () => { 22 | setHoverItem(null) 23 | setIsHovering(false) 24 | } 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/layouts/DpBaseLayout/components/Sidebar/components/CollapseButton/index.tsx: -------------------------------------------------------------------------------- 1 | export default function CollapseButton() { 2 | const sidebarStore = useSidebarStore() 3 | return ( 4 |
5 |
9 | 17 |
18 |
19 | ) 20 | } 21 | -------------------------------------------------------------------------------- /src/maps/error-message.ts: -------------------------------------------------------------------------------- 1 | import { StatusCode } from '@/api/axios.enum' 2 | 3 | /** 4 | * 响应状态码 Map 5 | * @description 用于获取响应状态码对应的错误信息 6 | */ 7 | export const errorMessageMap = new Map([ 8 | [StatusCode.BAD_REQUEST, '400: Bad Request!'], 9 | [StatusCode.UNAUTHORIZED, '401: Unauthorized!'], 10 | [StatusCode.FORBIDDEN, '403: Forbidden!'], 11 | [StatusCode.NOT_FOUND, '404: NotFound!'], 12 | [StatusCode.METHOD_NOT_ALLOWED, '405: Method Not Allowed!'], 13 | [StatusCode.CONFLICT, '409: Conflict!'], 14 | [StatusCode.UNPROCESSABLE_ENTITY, '422: Unprocessable Entity!'], 15 | [StatusCode.TOO_MANY_REQUESTS, '429: Too Many Requests!'], 16 | [StatusCode.INTERNAL_SERVER_ERROR, '500: Internal Server Error!'], 17 | [StatusCode.BAD_GATEWAY, '502: Bad Gateway!'], 18 | [StatusCode.GATEWAY_TIMEOUT, '504: Gateway Timeout!'] 19 | ]) 20 | -------------------------------------------------------------------------------- /src/components/DpErrorPage/index.tsx: -------------------------------------------------------------------------------- 1 | import type { IconType } from '@/features/icon' 2 | 3 | interface Props { 4 | title?: string 5 | iconType?: IconType 6 | iconSize?: number 7 | } 8 | 9 | const DpErrorPage = memo((props: Props) => { 10 | const { t } = useTranslation() 11 | const navigate = useNavigate() 12 | 13 | const handleBack = () => navigate('/') 14 | return ( 15 |
16 | 23 | ) 24 | } 25 | title={props.title} 26 | extra={{t('BACK')}} 27 | /> 28 |
29 | ) 30 | }) 31 | export default DpErrorPage 32 | -------------------------------------------------------------------------------- /src/assets/styles/main.scss: -------------------------------------------------------------------------------- 1 | @use 'tailwind.scss'; 2 | @use 'fonts.scss'; 3 | @use 'custom.scss'; 4 | 5 | *, 6 | *::before, 7 | *::after { 8 | box-sizing: border-box; 9 | margin: 0; 10 | } 11 | 12 | html, 13 | body { 14 | height: 100vh; 15 | overflow: hidden; 16 | user-select: none; 17 | } 18 | 19 | #app { 20 | height: auto; 21 | } 22 | 23 | .dark { 24 | color-scheme: dark; 25 | } 26 | 27 | [data-theme='dark'] { 28 | color: #ffffff; 29 | } 30 | 31 | .global_hide-scrollbar { 32 | scrollbar-width: none; /* Firefox */ 33 | -ms-overflow-style: none; /* Internet Explorer 10+ */ 34 | -webkit-overflow-scrolling: touch; /* iOS (Safari and Chrome) */ 35 | 36 | &::-webkit-scrollbar { 37 | width: 0; 38 | height: 0; 39 | background: transparent; /* Chrome, Safari, and Edge */ 40 | } 41 | 42 | &::-webkit-scrollbar-thumb { 43 | background-color: transparent; 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/store/user.ts: -------------------------------------------------------------------------------- 1 | import { create } from 'zustand' 2 | 3 | import type { User } from '@/api/user.type' 4 | 5 | interface State { 6 | user: Partial 7 | } 8 | 9 | interface Actions { 10 | hasData: () => boolean 11 | setUser: (user: Partial) => void 12 | clearUser: () => void 13 | } 14 | 15 | const initialState: State = { 16 | /** 17 | * 当前登录系统的用户数据 18 | */ 19 | user: {} 20 | } 21 | 22 | export const useUserStore = create()((set, get) => ({ 23 | ...initialState, 24 | 25 | /** 26 | * 判断当前用户是否存在 27 | * @description 判断依据:当前用户数据是否存在 ID 28 | */ 29 | hasData: () => !!get().user.id, 30 | 31 | /** 32 | * 设置当前用户数据,更新方式为“非覆盖式更新” 33 | * @param data 用户数据 34 | */ 35 | setUser: (user: Partial) => set((state) => ({ user: { ...state.user, ...user } })), 36 | 37 | /** 38 | * 清空当前用户数据 39 | */ 40 | clearUser: () => set(() => ({ user: {} })) 41 | })) 42 | -------------------------------------------------------------------------------- /src/layouts/DpBaseLayout/components/Sidebar/index.tsx: -------------------------------------------------------------------------------- 1 | import { CollapseButton, Header, Mask, Menu } from './components' 2 | 3 | export default function Sidebar() { 4 | const sidebarStore = useSidebarStore() 5 | return ( 6 | <> 7 | sidebarStore.setIsCollapse(value)} 15 | width={sidebarStore.isDisplay ? 224 : 0} 16 | collapsedWidth={sidebarStore.isDisplay ? 64 : 0} 17 | trigger={null} 18 | > 19 |
20 | 21 | 22 | 23 | 24 | 25 | ) 26 | } 27 | -------------------------------------------------------------------------------- /src/api/axios.type.ts: -------------------------------------------------------------------------------- 1 | import type { AxiosRequestConfig } from 'axios' 2 | 3 | import type { OrderType } from './axios.enum' 4 | 5 | // 响应数据 6 | export interface R { 7 | /** 8 | * 状态码 9 | */ 10 | msg: string 11 | /** 12 | * 数据 13 | */ 14 | data: T 15 | } 16 | 17 | // 分页数据 18 | export interface Page { 19 | /** 20 | * 数据列表 21 | */ 22 | records: T[] 23 | /** 24 | * 当前页码 25 | */ 26 | page: number 27 | /** 28 | * 每页条数 29 | */ 30 | pageSize: number 31 | /** 32 | * 总条数 33 | */ 34 | total: number 35 | } 36 | 37 | // 排序参数 38 | export interface Sorter { 39 | /** 40 | * 排序字段 41 | */ 42 | key: string 43 | /** 44 | * 排序方式 45 | */ 46 | order: OrderType 47 | } 48 | 49 | // 刷新 token 时,等待请求的任务 50 | export interface PendingTask { 51 | /** 52 | * 请求配置 53 | */ 54 | config?: AxiosRequestConfig 55 | /** 56 | * 请求成功后的回调 57 | */ 58 | resolve: (value: unknown) => void 59 | } 60 | -------------------------------------------------------------------------------- /src-tauri/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "dolphin-admin-react" 3 | version = "0.0.1" 4 | description = "Dolphin Admin React" 5 | authors = ["Bruce Song"] 6 | license = "MIT" 7 | repository = "" 8 | default-run = "dolphin-admin-react" 9 | edition = "2021" 10 | rust-version = "1.60" 11 | 12 | # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html 13 | 14 | [build-dependencies] 15 | tauri-build = { version = "1.5.1", features = [] } 16 | 17 | [dependencies] 18 | serde_json = "1.0" 19 | serde = { version = "1.0", features = ["derive"] } 20 | tauri = { version = "1.5.4", features = ["api-all"] } 21 | 22 | [features] 23 | # this feature is used for production builds or when `devPath` points to the filesystem and the built-in dev server is disabled. 24 | # If you use cargo directly instead of tauri's cli you can use this feature flag to switch between tauri's `dev` and `build` modes. 25 | # DO NOT REMOVE!! 26 | custom-protocol = [ "tauri/custom-protocol" ] 27 | -------------------------------------------------------------------------------- /src/layouts/DpTwoColLayout/index.tsx: -------------------------------------------------------------------------------- 1 | interface Props { 2 | /** 3 | * 左侧内容 4 | */ 5 | left?: React.ReactNode 6 | /** 7 | * 右侧内容 8 | */ 9 | right?: React.ReactNode 10 | /** 11 | * 固定侧宽度 12 | */ 13 | fixWidth?: string | number 14 | /** 15 | * 固定模式 16 | * @description 固定左侧或右侧 17 | */ 18 | fixMode?: 'left' | 'right' 19 | } 20 | 21 | export default function DpTowColLayout(props: Props) { 22 | const { fixWidth = 400, fixMode = 'left', left, right } = props 23 | return ( 24 |
25 | 30 | {left} 31 | 32 | 37 | {right} 38 | 39 |
40 | ) 41 | } 42 | -------------------------------------------------------------------------------- /src/layouts/DpBaseLayout/components/Footer/index.tsx: -------------------------------------------------------------------------------- 1 | import { getImageFromAssets } from '@/features/assets' 2 | 3 | export default function Footer() { 4 | const { APP_NAME, VERSION, TEAM_NAME, TEAM_GITHUB_URL } = AppMetadata 5 | 6 | return ( 7 | 8 | 9 | {APP_NAME} - v{VERSION} 10 | 11 | © 12 | BrowserUtils.openNewWindow(TEAM_GITHUB_URL)} 21 | /> 22 | {TEAM_NAME} 23 | 24 | ) 25 | } 26 | -------------------------------------------------------------------------------- /src/pages/login/components/ThirdPartyLogin/index.tsx: -------------------------------------------------------------------------------- 1 | export default function ThirdPartyLogin() { 2 | const { t } = useTranslation('AUTH') 3 | return ( 4 | <> 5 | {t('THIRD.PARTY.LOGIN')} 6 |
7 | 14 | } 15 | > 16 | {t('LOGIN.WITH.GITHUB')} 17 | 18 | 25 | } 26 | > 27 | {t('LOGIN.WITH.GOOGLE')} 28 | 29 |
30 | 31 | ) 32 | } 33 | -------------------------------------------------------------------------------- /src/api/auth.ts: -------------------------------------------------------------------------------- 1 | import { LoginType } from '@/features/auth' 2 | 3 | import type { LoginModel, SignupModel, Tokens } from './auth.type' 4 | 5 | export class AuthAPI { 6 | private static AUTH_API_PREFIX = '/auth' 7 | 8 | static REFRESH_API_URL = `${this.AUTH_API_PREFIX}/refresh` 9 | 10 | /** 11 | * 登录 12 | */ 13 | static login(data: LoginModel) { 14 | return httpRequest.post( 15 | `${this.AUTH_API_PREFIX}/login`, 16 | { ...data }, 17 | { params: { type: LoginType.USERNAME } } 18 | ) 19 | } 20 | 21 | /** 22 | * 注册 23 | */ 24 | static signup(data: SignupModel) { 25 | return httpRequest.post(`${this.AUTH_API_PREFIX}/signup`, { 26 | ...data 27 | }) 28 | } 29 | 30 | /** 31 | * 刷新令牌 32 | */ 33 | static refresh(token: string) { 34 | return httpRequest.post(this.REFRESH_API_URL, {}, { params: { token } }) 35 | } 36 | 37 | /** 38 | * 登出 39 | */ 40 | static logout() { 41 | return httpRequest.post(`${this.AUTH_API_PREFIX}/logout`) 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/layouts/DpBaseLayout/components/Header/index.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Breadcrumb, 3 | DiscordButton, 4 | DocsButton, 5 | FullScreenButton, 6 | GitHubButton, 7 | LanguageButton, 8 | MenuVisibilityToggle, 9 | ThemeToggle, 10 | UserAvatar 11 | } from './components' 12 | 13 | export default function Header() { 14 | return ( 15 | 22 |
23 | 24 | 25 |
26 | 27 |
28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 |
36 |
37 | ) 38 | } 39 | -------------------------------------------------------------------------------- /src/api/user.ts: -------------------------------------------------------------------------------- 1 | import type { BasePageModel } from '@/constants' 2 | 3 | import type { Page } from './axios.type' 4 | import type { CreateUserModel, User } from './user.type' 5 | 6 | export class UserAPI { 7 | private static API_PREFIX = '/users' 8 | 9 | /** 10 | * 新增用户 11 | */ 12 | static create(data: CreateUserModel) { 13 | return httpRequest.post(this.API_PREFIX, { ...data }) 14 | } 15 | 16 | /** 17 | * 用户列表 18 | */ 19 | static list(params: BasePageModel) { 20 | return httpRequest.get>(this.API_PREFIX, { ...params }) 21 | } 22 | 23 | /** 24 | * 用户信息 25 | */ 26 | static detail(id: number) { 27 | return httpRequest.get(`${this.API_PREFIX}/${id}`) 28 | } 29 | 30 | /** 31 | * 当前用户 32 | * @description 通过当前登录用户的 token 获取用户信息 33 | */ 34 | static profile() { 35 | return httpRequest.get(`${this.API_PREFIX}/profile`) 36 | } 37 | 38 | /** 39 | * 更新用户 40 | */ 41 | static update(id: number, data: User) { 42 | return httpRequest.patch(`${this.API_PREFIX}/${id}`, { ...data }) 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/locales/validation/en-US.json: -------------------------------------------------------------------------------- 1 | { 2 | "ADDRESS": "Please enter your address", 3 | "BIOGRAPHY": "Please enter your biography", 4 | "CONFIRM.PASSWORD": "Please enter confirm password", 5 | "CONFIRM.PASSWORD.NOT.MATCH": "Password not match", 6 | "DICTIONARY.CODE": "Please enter the code", 7 | "DICTIONARY.LABEL": "Please enter the label", 8 | "EMAIL": "Please enter your email", 9 | "EMAIL.FORMAT": "Please enter the correct email format", 10 | "FILE.NAME": "Please enter file name", 11 | "FIRST.NAME": "Please enter your first name", 12 | "FONT": "Please select the font family", 13 | "LABEL": "Please enter the label", 14 | "LAST.NAME": "Please enter your last name", 15 | "NAME": "Please enter name", 16 | "OLD.PASSWORD": "Please enter old password", 17 | "PASSWORD": "Please enter password", 18 | "PASSWORD.LENGTH": "Password length must be greater than 6", 19 | "PHONE.NUMBER": "Please enter a phone number", 20 | "PHONE.NUMBER.FORMAT": "Please enter phone number in the correct format", 21 | "REMARK": "Please enter the remark", 22 | "USERNAME": "Please enter username" 23 | } 24 | -------------------------------------------------------------------------------- /src/store/sidebar.ts: -------------------------------------------------------------------------------- 1 | import { create } from 'zustand' 2 | 3 | interface State { 4 | isCollapse: boolean 5 | isDisplay: boolean 6 | } 7 | 8 | interface Actions { 9 | setIsCollapse: (isCollapse: boolean) => void 10 | toggleCollapse: () => void 11 | setIsDisplay: (isDisplay: boolean) => void 12 | toggleDisplay: () => void 13 | } 14 | 15 | const initialState: State = { 16 | /** 17 | * 是否折叠侧边栏,默认不折叠 18 | */ 19 | isCollapse: false, 20 | 21 | /** 22 | * 是否显示侧边栏,默认显示 23 | */ 24 | isDisplay: !BrowserUtils.isMobile() 25 | } 26 | 27 | export const useSidebarStore = create()((set) => ({ 28 | ...initialState, 29 | /** 30 | * 修改折叠状态 31 | */ 32 | setIsCollapse: (isCollapse) => set(() => ({ isCollapse })), 33 | 34 | /** 35 | * 切换折叠状态 36 | */ 37 | toggleCollapse: () => set((state) => ({ isCollapse: !state.isCollapse })), 38 | 39 | /** 40 | * 修改显示状态 41 | */ 42 | setIsDisplay: (isDisplay) => set(() => ({ isDisplay })), 43 | 44 | /** 45 | * 切换显示状态 46 | */ 47 | toggleDisplay: () => set((state) => ({ isDisplay: !state.isDisplay })) 48 | })) 49 | -------------------------------------------------------------------------------- /src/layouts/DpBaseLayout/components/Header/components/LanguageButton/index.tsx: -------------------------------------------------------------------------------- 1 | import { Lang } from '@dolphin-admin/utils' 2 | 3 | export default function LanguageButton() { 4 | const langStore = useLangStore() 5 | 6 | const [langOptions, setLangOptions] = useImmer([ 7 | { 8 | key: 'zh-CN', 9 | label: '简体中文', 10 | disabled: langStore.lang === Lang['zh-CN'] 11 | }, 12 | { 13 | key: 'en-US', 14 | label: 'English', 15 | disabled: langStore.lang === Lang['en-US'] 16 | } 17 | ]) 18 | 19 | return ( 20 | { 24 | langStore.setLang(key) 25 | setLangOptions((draft) => { 26 | draft.forEach((item) => { 27 | item.disabled = item.key === key 28 | }) 29 | }) 30 | } 31 | }} 32 | placement="bottom" 33 | > 34 | 40 | 41 | ) 42 | } 43 | -------------------------------------------------------------------------------- /src/components/DpHeader/index.tsx: -------------------------------------------------------------------------------- 1 | import type { ReactNode } from 'react' 2 | 3 | interface Props { 4 | /** 5 | * 右侧操作区域 6 | */ 7 | renderRight?: ReactNode 8 | } 9 | 10 | const DpHeader = memo((props: Props) => { 11 | const { getTitle, hideTitle, icon } = useRouteMeta() 12 | const responsive = useResponsive() 13 | 14 | return ( 15 |
16 | {(!hideTitle || props.renderRight) && ( 17 |
23 |
24 | {icon && ( 25 | 29 | )} 30 |
{getTitle()}
31 |
32 |
{props.renderRight && props.renderRight}
33 |
34 | )} 35 |
36 | ) 37 | }) 38 | export default DpHeader 39 | -------------------------------------------------------------------------------- /src/features/menu/get-menu-item.ts: -------------------------------------------------------------------------------- 1 | import type { MenuItem } from './types' 2 | 3 | // 菜单数据缓存 4 | export const menuCacheMap = new Map() 5 | 6 | /** 7 | * 根据当前路由路径递归匹配菜单数据 8 | * @description 先从缓存中获取,如果缓存中没有,则递归匹配,匹配到后缓存结果 9 | */ 10 | export function getMenuItem(key: string, menuTree: MenuItem[]): MenuItem | undefined { 11 | // 优先从缓存中获取 12 | if (menuCacheMap.has(key)) { 13 | return menuCacheMap.get(key) 14 | } 15 | 16 | // 匹配当前菜单数据 17 | const menu = menuTree.find((m) => m?.key === key) 18 | if (menu) { 19 | // 缓存结果 20 | menuCacheMap.set(key, menu) 21 | return menu 22 | } 23 | 24 | // 递归匹配子路由 25 | // eslint-disable-next-line no-restricted-syntax 26 | for (const r of menuTree) { 27 | const { children } = (r as any) ?? {} 28 | if (children) { 29 | const menuItem = getMenuItem(key, children) 30 | if (menuItem) { 31 | // 缓存结果 32 | menuCacheMap.set(key, menuItem) 33 | return menuItem 34 | } 35 | } else { 36 | return undefined 37 | } 38 | } 39 | // 缓存结果 40 | menuCacheMap.set(key, undefined) 41 | return undefined 42 | } 43 | -------------------------------------------------------------------------------- /src/features/pagination/usePagination.ts: -------------------------------------------------------------------------------- 1 | import type { PaginationProps } from 'antd' 2 | 3 | import { DEFAULT_PAGE_SIZE } from '@/constants' 4 | 5 | export const usePagination = () => { 6 | const { t } = useTranslation() 7 | const response = useResponsive() 8 | 9 | const [pageParams, setPageParams] = useImmer({ 10 | page: 1, 11 | pageSize: DEFAULT_PAGE_SIZE 12 | }) 13 | const [total, setTotal] = useState(0) 14 | 15 | const setPagination = (page: number, pageSize: number) => 16 | setPageParams((draft) => { 17 | draft.page = page 18 | draft.pageSize = pageSize 19 | }) 20 | 21 | return { 22 | pageParams, 23 | setPageParams, 24 | total, 25 | setTotal, 26 | pagination: { 27 | total, 28 | current: pageParams.page, 29 | pageSize: pageParams.pageSize, 30 | onChange: setPagination, 31 | size: (response.sm ? 'default' : 'small') as PaginationProps['size'], 32 | rootClassName: '!mb-0', 33 | showSizeChanger: true, 34 | showQuickJumper: true, 35 | showTotal: (totalPage: number) => t('SHOW.TOTAL', { total: totalPage }) 36 | } 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Bruce Song 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/layouts/DpBaseLayout/components/Sidebar/components/Header/index.tsx: -------------------------------------------------------------------------------- 1 | import { getImageFromAssets } from '@/features/assets' 2 | 3 | export default function Header() { 4 | const { APP_NAME } = AppMetadata 5 | 6 | const navigate = useNavigate() 7 | const sidebarStore = useSidebarStore() 8 | return ( 9 |
navigate('/')} 12 | > 13 | 22 | 33 | {APP_NAME} 34 | 35 |
36 | ) 37 | } 38 | -------------------------------------------------------------------------------- /CONTRIBUTORS.md: -------------------------------------------------------------------------------- 1 | 9 | 10 | # Contributors 11 | 12 | Thanks goes to these wonderful people: 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 |

Bruce

Upwards-rwr
21 | 22 | 23 | 24 | 25 | This list is auto-generated using `pnpm generate:contributors`. It shows the top 100 contributors with > 3 contributions. 26 | -------------------------------------------------------------------------------- /src/hooks/useAuthGuard.ts: -------------------------------------------------------------------------------- 1 | import { useProfileQuery } from '@/features/users' 2 | 3 | interface AuthGuardProps { 4 | /** 5 | * 是否跳过认证 6 | * @default false 7 | */ 8 | skipAuth?: boolean 9 | } 10 | 11 | export const useAuthGuard = (props?: AuthGuardProps) => { 12 | const { skipAuth = false } = props ?? {} 13 | 14 | const navigate = useNavigate() 15 | const userStore = useUserStore() 16 | 17 | const [isLoading, setIsLoading] = useState(true) 18 | 19 | const profileQuery = useProfileQuery({ enabled: AuthUtils.isAuthenticated() }) 20 | 21 | useAsyncEffect(async () => { 22 | // 如果已经登录,直接跳转到首页,否则清除用户信息 23 | if (!AuthUtils.isAuthenticated()) { 24 | // 清除用户信息并跳转到登录页 25 | userStore.clearUser() 26 | if (!skipAuth) { 27 | navigate(`/login?redirect=${window.location.pathname}`, { 28 | replace: true 29 | }) 30 | } 31 | setIsLoading(false) 32 | return 33 | } 34 | 35 | // 如果跳过认证,直接跳转到首页 36 | if (skipAuth) { 37 | navigate('/', { replace: true }) 38 | } 39 | }, []) 40 | 41 | useEffect(() => { 42 | if (profileQuery.isSuccess) { 43 | setIsLoading(false) 44 | } 45 | }, [profileQuery.isSuccess]) 46 | 47 | return { isLoading } 48 | } 49 | -------------------------------------------------------------------------------- /src/components/DpIcon/index.tsx: -------------------------------------------------------------------------------- 1 | import type { IconComponentProps } from '@ant-design/icons/lib/components/Icon' 2 | 3 | import { iconSet } from '@/features/icon' 4 | import { type IconType } from '@/features/icon' 5 | 6 | type Props = IconComponentProps & 7 | React.RefAttributes & { 8 | type: IconType 9 | style?: React.CSSProperties 10 | color?: string 11 | size?: number 12 | depth?: number 13 | } 14 | 15 | const getOpacityByDepth = (depth: number) => { 16 | switch (depth) { 17 | case 1: 18 | return 0.82 19 | case 2: 20 | return 0.72 21 | case 3: 22 | return 0.38 23 | case 4: 24 | return 0.24 25 | case 5: 26 | return 0.18 27 | default: 28 | return 1 29 | } 30 | } 31 | 32 | const DpIcon = memo((props: Props) => { 33 | const { type, style, color, size, depth, ...rest } = props 34 | const IconComponent = iconSet[type] 35 | 36 | if (!IconComponent) { 37 | return null 38 | } 39 | 40 | return ( 41 | 51 | ) 52 | }) 53 | export default DpIcon 54 | -------------------------------------------------------------------------------- /src/layouts/DpTableLayout/index.tsx: -------------------------------------------------------------------------------- 1 | import type { RenderModal } from '@/features/layout' 2 | 3 | interface Props { 4 | /** 5 | * 顶部操作区域 6 | */ 7 | renderOperate?: React.ReactNode 8 | /** 9 | * 头部区域 10 | */ 11 | renderHeader?: React.ReactNode 12 | /** 13 | * 表格区域 14 | */ 15 | renderTable?: React.ReactNode 16 | /** 17 | * 模态框区域 18 | */ 19 | renderModal?: RenderModal 20 | } 21 | 22 | export default function DpTableLayout(props: Props) { 23 | const { renderOperate, renderHeader, renderTable, renderModal } = props 24 | const { renderContent, ...modalProps } = renderModal ?? {} 25 | const { t } = useTranslation() 26 | 27 | return ( 28 | <> 29 | 30 | 34 | {renderHeader && {renderHeader}} 35 |
{renderTable}
36 |
37 | {renderModal && ( 38 | 43 | {renderContent} 44 | 45 | )} 46 | 47 | ) 48 | } 49 | -------------------------------------------------------------------------------- /src/assets/styles/custom.scss: -------------------------------------------------------------------------------- 1 | // NOTE: 此处用于定制化全局样式 2 | // 修复 Message 文字字体、不对齐的问题 3 | .ant-message-custom-content { 4 | display: flex; 5 | font-family: 6 | Nunito, 7 | Noto Sans SC, 8 | Noto Color Emoji, 9 | system-ui, 10 | -apple-system, 11 | Roboto, 12 | Helvetica Neue, 13 | Arial, 14 | sans-serif; 15 | } 16 | 17 | // 修改 Card 的阴影,仅修改非暗黑主题 18 | [data-theme]:not([data-theme='dark']) .ant-card-hoverable { 19 | &:hover { 20 | box-shadow: 21 | 0 1px 2px -2px rgba(0, 0, 0, 0.08), 22 | 0 3px 6px 0 rgba(0, 0, 0, 0.06), 23 | 0 5px 12px 4px rgba(0, 0, 0, 0.04); 24 | } 25 | } 26 | 27 | // 移除 Table 分页器的下边距 28 | .ant-table-wrapper { 29 | .ant-table-pagination.ant-pagination { 30 | margin-bottom: 0; 31 | margin-top: 12px; 32 | } 33 | } 34 | 35 | // 移除 Sidebar 颜色的过渡动画 36 | .ant-layout .ant-layout-sider { 37 | transition: 38 | all 0.2s, 39 | border 0s, 40 | color 0s, 41 | background 0s !important; 42 | } 43 | 44 | // 移除 Breadcrumb 的下拉箭头 45 | .ant-breadcrumb { 46 | .anticon-down { 47 | display: none; 48 | } 49 | } 50 | 51 | .ant-table { 52 | min-height: calc(100vh - 358px); 53 | } 54 | // 表格容器添加最小高度,避免内容过少时,表格高度不满一屏 55 | @media (min-width: 640px) { 56 | .ant-table { 57 | min-height: calc(100vh - 372px); 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /src/components/DpTableSearch/index.tsx: -------------------------------------------------------------------------------- 1 | import type { ChangeEvent } from 'react' 2 | 3 | interface Props { 4 | searchText?: string 5 | setSearchText?: (value: string) => void 6 | loading?: boolean 7 | handleSearch?: () => void 8 | } 9 | 10 | const DpTableSearch = memo((props: Props) => { 11 | const { t } = useTranslation() 12 | 13 | const handleSearchTextChange = (e: ChangeEvent) => { 14 | if (props.setSearchText) { 15 | props.setSearchText(e.target.value) 16 | } 17 | } 18 | 19 | const handleSearch = () => { 20 | if (props.handleSearch) { 21 | props.handleSearch() 22 | } 23 | } 24 | return ( 25 | 29 | 30 | 38 | 39 | : } 43 | onClick={handleSearch} 44 | /> 45 | 46 | ) 47 | }) 48 | export default DpTableSearch 49 | -------------------------------------------------------------------------------- /src/components/DpTableField/index.tsx: -------------------------------------------------------------------------------- 1 | import { isNil } from 'lodash-es' 2 | 3 | const { rt } = i18n 4 | const t = i18n.getFixedT(null, 'COMMON') 5 | 6 | const handleCopy = (str: string) => { 7 | BrowserUtils.setClipBoardText(str) 8 | AMessage.success(t('COPY.SUCCESS')) 9 | } 10 | 11 | const I18nString = (value: string) => rt(value) 12 | 13 | function DateString(value: any) { 14 | let label 15 | let fullLabel 16 | if (!isNil(value)) { 17 | fullLabel = TimeUtils.formatTime(value) 18 | label = TimeUtils.isCurrentYear(value) 19 | ? TimeUtils.formatTime(value, 'MM-DD HH:mm:ss') 20 | : fullLabel 21 | } 22 | return ( 23 | 27 | {label} 28 | 29 | ) 30 | } 31 | 32 | function CopyableTagString(value: string) { 33 | return ( 34 | handleCopy(value)} 42 | > 43 | {value} 44 | 45 | ) 46 | } 47 | 48 | function Boolean(value: any) { 49 | return value && 50 | } 51 | 52 | const DpTableField = { 53 | I18nString, 54 | DateString, 55 | CopyableTagString, 56 | Boolean 57 | } 58 | export default DpTableField 59 | -------------------------------------------------------------------------------- /src/Root.tsx: -------------------------------------------------------------------------------- 1 | import nprogress from 'nprogress' 2 | import { useNavigation } from 'react-router-dom' 3 | 4 | import { getRouteMetadata, routes } from './router' 5 | 6 | nprogress.configure({ showSpinner: false }) 7 | 8 | const { APP_NAME } = AppMetadata 9 | 10 | /** 11 | * 生成页面标题 12 | * @description 13 | * - 如果传参,结果为 `当前页面标题 | 应用名称` 14 | * - 默认为 `应用名称` 15 | */ 16 | const getDocumentTitle = (title?: string) => (title ? `${title} | ${APP_NAME}` : APP_NAME) 17 | 18 | /** 19 | * 路由根组件 20 | * @description 为什么要使用这个组件? 21 | * - 使用 React Router 的 RouterProvider 时,无法在 App 组件,即全局路由,使用 React Router 的 Hook。 22 | * - 但是,我们需要在全局路由中监听路由状态,以便在路由切换时,显示进度条。 23 | * - 这个组件目前仅用于处理 NProgress 进度条,如果有其他全局路由状态需要处理,可以在这里处理。 24 | * @see {@link https://reactrouter.com/en/main/upgrading/v6-data} 25 | */ 26 | export default function Root() { 27 | const navigation = useNavigation() 28 | const location = useLocation() 29 | 30 | // 监听路由变化,显示进度条 31 | useEffect(() => { 32 | if (navigation.state === 'loading') { 33 | nprogress.start() 34 | } else { 35 | nprogress.done() 36 | } 37 | }, [navigation.state]) 38 | 39 | // 监听路由变化,动态修改页面标题 40 | useEffect(() => { 41 | const { title } = getRouteMetadata(location.pathname, routes) ?? {} 42 | document.title = getDocumentTitle(typeof title === 'function' ? title() : title) 43 | }, [location.pathname]) 44 | 45 | return 46 | } 47 | -------------------------------------------------------------------------------- /src/components/DpDevMenuFab/index.tsx: -------------------------------------------------------------------------------- 1 | import ThemeSwitchIcon from '~icons/mdi/theme-light-dark' 2 | 3 | /** 4 | * 悬浮按钮形式的开发菜单 5 | * @description 6 | * - 仅在开发环境下显示 7 | * - 用于页面切换主题、语言,方便调试 8 | */ 9 | export default function DpDevMenuFab() { 10 | const { IS_DEV } = GlobalEnvConfig 11 | 12 | const themeStore = useThemeStore() 13 | const langStore = useLangStore() 14 | 15 | const [isOpen, setIsOpen] = useState(false) 16 | 17 | // 切换语言 18 | const handleChangeLanguage = () => { 19 | langStore.setLang(langStore.lang === 'zh-CN' ? 'en-US' : 'zh-CN') 20 | setIsOpen(false) 21 | } 22 | 23 | // 切换主题 24 | const handleToggleTheme = () => { 25 | themeStore.toggleTheme() 26 | setIsOpen(false) 27 | } 28 | 29 | // 仅在开发环境下显示 30 | if (IS_DEV) { 31 | return ( 32 | } 37 | open={isOpen} 38 | onClick={() => setIsOpen(!isOpen)} 39 | > 40 | } 42 | onClick={handleChangeLanguage} 43 | /> 44 | } 46 | onClick={handleToggleTheme} 47 | /> 48 | 49 | ) 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/pages/index.tsx: -------------------------------------------------------------------------------- 1 | import { getImageFromAssets } from '@/features/assets' 2 | import { commitMessage, committer, committerDate, github } from '~build/git' 3 | import now from '~build/time' 4 | 5 | export function Component() { 6 | const { APP_NAME, TEAM_NAME } = AppMetadata 7 | 8 | return ( 9 |
10 |
11 |
{APP_NAME}
12 |
13 | 22 | {TEAM_NAME} 23 |
24 |
25 | GitHub 地址:{github} 26 | 上次部署时间:{TimeUtils.formatTime(now)} 27 | 上次提交作者:{committer} 28 | 上次提交信息:{commitMessage} 29 | 上次提交日期:{TimeUtils.formatTime(committerDate)} 30 |
31 |
32 |
33 | ) 34 | } 35 | -------------------------------------------------------------------------------- /src/api/settings.type.ts: -------------------------------------------------------------------------------- 1 | export interface Setting { 2 | /** 3 | * 创建时间 4 | */ 5 | createdAt: Date 6 | /** 7 | * 创建人 8 | */ 9 | createdBy: number | null 10 | /** 11 | * 是否启用 12 | */ 13 | enabled: boolean 14 | /** 15 | * ID 16 | */ 17 | id: number 18 | /** 19 | * 键 20 | */ 21 | key: string 22 | /** 23 | * 名称 24 | */ 25 | label: string 26 | /** 27 | * 备注 28 | */ 29 | remark?: string 30 | /** 31 | * 排序 32 | */ 33 | sort?: number 34 | /** 35 | * 更新时间 36 | */ 37 | updatedAt: Date 38 | /** 39 | * 更新人 40 | */ 41 | updatedBy: number | null 42 | /** 43 | * 值 44 | */ 45 | value: string 46 | } 47 | 48 | export interface CreateSettingModel { 49 | /** 50 | * 是否启用 51 | */ 52 | enabled: boolean 53 | /** 54 | * 键 55 | */ 56 | key: string 57 | /** 58 | * 名称 59 | */ 60 | label: string 61 | /** 62 | * 备注 63 | */ 64 | remark?: string 65 | /** 66 | * 值 67 | */ 68 | value: string 69 | } 70 | 71 | export interface UpdateSettingModel extends CreateSettingModel {} 72 | 73 | export interface PatchSettingModel { 74 | /** 75 | * 是否启用 76 | */ 77 | enabled?: boolean 78 | /** 79 | * 键 80 | */ 81 | key?: string 82 | /** 83 | * 名称 84 | */ 85 | label?: string 86 | /** 87 | * 备注 88 | */ 89 | remark?: string 90 | /** 91 | * 值 92 | */ 93 | value?: string 94 | } 95 | -------------------------------------------------------------------------------- /src/api/dictionary.ts: -------------------------------------------------------------------------------- 1 | import type { BasePageModel } from '@/constants' 2 | 3 | import type { Page } from './axios.type' 4 | import type { 5 | CreateDictionaryModel, 6 | Dictionary, 7 | PatchDictionaryModel, 8 | UpdateDictionaryModel 9 | } from './dictionary.type' 10 | 11 | export class DictionaryAPI { 12 | private static API_PREFIX = '/dictionaries' 13 | 14 | /** 15 | * 新增字典 16 | */ 17 | static create(data: CreateDictionaryModel) { 18 | return httpRequest.post(this.API_PREFIX, { ...data }) 19 | } 20 | 21 | /** 22 | * 字典列表 23 | */ 24 | static list(params: BasePageModel, signal?: AbortSignal) { 25 | return httpRequest.get>(this.API_PREFIX, { ...params }, { signal }) 26 | } 27 | 28 | /** 29 | * 字典详情 30 | */ 31 | static detail(id: number) { 32 | return httpRequest.get(`${this.API_PREFIX}/${id}`) 33 | } 34 | 35 | /** 36 | * 更新字典 37 | */ 38 | static update(id: number, data: UpdateDictionaryModel) { 39 | return httpRequest.put(`${this.API_PREFIX}/${id}`, { ...data }) 40 | } 41 | 42 | /** 43 | * 部分更新 44 | */ 45 | static patch(id: number, data: PatchDictionaryModel) { 46 | return httpRequest.patch(`${this.API_PREFIX}/${id}`, { ...data }) 47 | } 48 | 49 | /** 50 | * 删除字典 51 | */ 52 | static delete(id: number) { 53 | return httpRequest.delete(`${this.API_PREFIX}/${id}`) 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /src/features/dictionaries/api/useDictionariesQuery.ts: -------------------------------------------------------------------------------- 1 | import { DictionaryPageModel } from '@/api/dictionary.type' 2 | 3 | interface Options { 4 | pageParams: DictionaryPageModel 5 | prefetch?: boolean 6 | } 7 | 8 | export const DICTIONARIES_QUERY_KEY = 'dictionaries' 9 | 10 | export const dictionariesQK = (pageParams?: DictionaryPageModel) => { 11 | if (!pageParams) { 12 | return [DICTIONARIES_QUERY_KEY] 13 | } 14 | return [DICTIONARIES_QUERY_KEY, pageParams] 15 | } 16 | 17 | export const useDictionariesQuery = (options: Options) => { 18 | const queryClient = useQueryClient() 19 | 20 | const query = useQuery({ 21 | queryKey: dictionariesQK(options.pageParams), 22 | queryFn: ({ signal }) => 23 | DictionaryAPI.list(new DictionaryPageModel(options.pageParams), signal), 24 | placeholderData: keepPreviousData 25 | }) 26 | 27 | useEffect(() => { 28 | if (options.prefetch && query.data) { 29 | const { page, pageSize, total } = query.data 30 | if (page * pageSize < total) { 31 | const params = { 32 | ...options.pageParams, 33 | page: options.pageParams.page + 1 34 | } 35 | queryClient.prefetchQuery({ 36 | queryKey: dictionariesQK(params), 37 | queryFn: ({ signal }) => DictionaryAPI.list(params, signal) 38 | }) 39 | } 40 | } 41 | }, [options.prefetch, options.pageParams, query.data, queryClient]) 42 | 43 | return query 44 | } 45 | -------------------------------------------------------------------------------- /src/hooks/useTableFields.ts: -------------------------------------------------------------------------------- 1 | import type { ColumnsType, ColumnType } from 'antd/es/table' 2 | 3 | export const useTableFields = () => { 4 | const response = useResponsive() 5 | const { t } = useTranslation() 6 | 7 | const baseTableFields = { 8 | id: { 9 | title: 'ID', 10 | dataIndex: 'id', 11 | key: 'id', 12 | fixed: response.sm && 'left', 13 | align: 'center', 14 | width: 100 15 | }, 16 | enabled: { 17 | title: t('IS.ENABLED'), 18 | dataIndex: 'enabled', 19 | key: 'enabled', 20 | width: 100, 21 | align: 'center', 22 | render: DpTableField.Boolean 23 | }, 24 | remark: { 25 | title: t('REMARK'), 26 | dataIndex: 'remark', 27 | key: 'remark', 28 | ellipsis: { showTitle: true }, 29 | render: DpTableField.I18nString 30 | }, 31 | createdAt: { 32 | title: t('CREATED.AT'), 33 | dataIndex: 'createdAt', 34 | key: 'createdAt', 35 | width: 200, 36 | align: 'center', 37 | render: DpTableField.DateString 38 | } 39 | } 40 | 41 | const getTableField = (key: keyof typeof baseTableFields): ColumnType => 42 | baseTableFields[key] as ColumnType 43 | 44 | const getTableFields = (...keys: (keyof typeof baseTableFields)[]): ColumnsType => 45 | keys.map((key) => baseTableFields[key]) as ColumnsType 46 | 47 | return { 48 | getTableField, 49 | getTableFields 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/i18n/index.ts: -------------------------------------------------------------------------------- 1 | import { Lang } from '@dolphin-admin/utils' 2 | import i18n from 'i18next' 3 | import { initReactI18next } from 'react-i18next' 4 | 5 | const DEFAULT_NS = 'COMMON' 6 | 7 | i18n.use(initReactI18next).init({ 8 | lng: LangUtils.getDefaultLang(Lang['en-US']), // 默认语言 9 | fallbackLng: Lang['en-US'], // 未匹配到语言时的默认语言 10 | defaultNS: DEFAULT_NS, // 默认命名空间 11 | ns: [], // 动态加载命名空间 12 | resources: {}, // 动态加载资源文件,初始化为空 13 | interpolation: { 14 | escapeValue: false 15 | } 16 | }) 17 | // 添加远程资源翻译方法 18 | i18n.rt = (key: string = '') => (i18n.exists(key) ? i18n.t(key as any) : key) 19 | 20 | // i18n 实例声明后,读取 /locales 下的资源文件 21 | dynamicLoadTrans().forEach((transItem) => i18n.addResourceBundle(...transItem)) 22 | 23 | export default i18n 24 | 25 | /** 26 | * 动态加载 i18n 资源文件 27 | * @description 读取 /locales 下的全部 JSON 文件 28 | * - 转化成 i18n 资源数组,格式如 [语言 key, 命名空间 key, 资源文件内容] 29 | * - 通过 import.meta.glob 实现 30 | * @see {@link https://vitejs.dev/guide/features.html#glob-import} 31 | */ 32 | function dynamicLoadTrans() { 33 | return Object.entries( 34 | import.meta.glob>('../locales/**/*.json', { 35 | import: 'default', 36 | eager: true 37 | }) 38 | ).map<[string, string, Record]>(([path, resource]) => [ 39 | path.match(/([^/]+)\.json$/)![1], // 语言 key 40 | path.split('/')[2].replaceAll('-', '_').toUpperCase(), // 命名空间 key 41 | resource // 资源文件内容 42 | ]) 43 | } 44 | -------------------------------------------------------------------------------- /src/api/dictionary.type.ts: -------------------------------------------------------------------------------- 1 | import type { Nullable } from '@dolphin-admin/utils' 2 | 3 | import { BasePageModel } from '@/constants' 4 | 5 | export interface Dictionary { 6 | /** 7 | * 字典编码 8 | */ 9 | code: string 10 | /** 11 | * 创建时间 12 | */ 13 | createdAt: Date 14 | /** 15 | * 创建人 16 | */ 17 | createdBy: number | null 18 | /** 19 | * 是否启用 20 | */ 21 | enabled: boolean 22 | /** 23 | * ID 24 | */ 25 | id: number 26 | /** 27 | * 名称 28 | */ 29 | label: string 30 | /** 31 | * 备注 32 | */ 33 | remark?: string 34 | /** 35 | * 排序 36 | */ 37 | sort?: number 38 | /** 39 | * 更新时间 40 | */ 41 | updatedAt: Date 42 | /** 43 | * 更新人 44 | */ 45 | updatedBy: number | null 46 | } 47 | 48 | export class DictionaryPageModel extends BasePageModel { 49 | code?: string 50 | 51 | label?: string 52 | 53 | enabled?: Nullable 54 | 55 | constructor(dictionaryPageModel: DictionaryPageModel) { 56 | const { code, label, enabled, ...basePageModel } = dictionaryPageModel ?? {} 57 | super(basePageModel) 58 | this.code = code 59 | this.label = label 60 | this.enabled = enabled 61 | } 62 | } 63 | 64 | export type CreateDictionaryModel = Pick< 65 | Dictionary, 66 | 'code' | 'enabled' | 'label' | 'remark' | 'sort' 67 | > 68 | 69 | export type UpdateDictionaryModel = CreateDictionaryModel 70 | 71 | export type PatchDictionaryModel = Partial 72 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 9 | 13 | 17 | 21 | 26 | Dolphin Admin React 27 | 28 | 29 |
30 | 34 | 46 | 47 | 48 | -------------------------------------------------------------------------------- /src/layouts/DpBaseLayout/components/Sidebar/components/Menu/index.tsx: -------------------------------------------------------------------------------- 1 | import { getMenuTree } from '@/constants' 2 | import type { MenuItem } from '@/features/menu' 3 | 4 | export default function Menu() { 5 | const { siderBg } = ATheme.useToken().token.Layout! 6 | const navigate = useNavigate() 7 | const location = useLocation() 8 | 9 | // 选中项 10 | const [selectedKeys, setSelectedKeys] = useState([]) 11 | // 展开项 12 | const [openKeys, setOpenKeys] = useState([]) 13 | 14 | // 根据路由地址,设置菜单的选中项和展开项 15 | useEffect(() => { 16 | setSelectedKeys([location.pathname]) 17 | setOpenKeys((value) => 18 | location.pathname 19 | .split('/') 20 | .filter((i) => i) 21 | .reduce((acc, cur) => { 22 | const key = `${acc}/${cur}` 23 | return [...acc, key] 24 | }, []) 25 | .concat(value) 26 | ) 27 | }, [location.pathname]) 28 | 29 | // 点击菜单项,跳转到对应的路由 30 | const handleClickMenuItem = (menuInfo: MenuItem) => { 31 | if (menuInfo?.key && typeof menuInfo.key === 'string') { 32 | navigate(menuInfo.key) 33 | } 34 | } 35 | 36 | return ( 37 | 47 | ) 48 | } 49 | -------------------------------------------------------------------------------- /src/api/setting.ts: -------------------------------------------------------------------------------- 1 | import type { BasePageModel } from '@/constants' 2 | 3 | import type { Page } from './axios.type' 4 | import type { 5 | CreateSettingModel, 6 | PatchSettingModel, 7 | Setting, 8 | UpdateSettingModel 9 | } from './settings.type' 10 | 11 | export class SettingAPI { 12 | private static API_PREFIX = '/settings' 13 | 14 | /** 15 | * 设置列表缓存 key 16 | */ 17 | static LIST_QUERY_KEY = 'SETTING.LIST' 18 | 19 | /** 20 | * 设置详情缓存 key 21 | */ 22 | static DETAIL_QUERY_KEY = 'SETTING.DETAIL' 23 | 24 | /** 25 | * 新增设置 26 | */ 27 | static create(data: CreateSettingModel) { 28 | return httpRequest.get(this.API_PREFIX, { ...data }) 29 | } 30 | 31 | /** 32 | * 设置列表 33 | */ 34 | static list(params: BasePageModel) { 35 | return httpRequest.get>(this.API_PREFIX, { 36 | ...params 37 | }) 38 | } 39 | 40 | /** 41 | * 设置详情 42 | */ 43 | static detail(id: number) { 44 | return httpRequest.get(`${this.API_PREFIX}/${id}`) 45 | } 46 | 47 | /** 48 | * 更新设置 49 | */ 50 | static update(id: number, data: UpdateSettingModel) { 51 | return httpRequest.get(`${this.API_PREFIX}/${id}`, { ...data }) 52 | } 53 | 54 | /** 55 | * 修改设置 56 | */ 57 | static patch(id: number, data: PatchSettingModel) { 58 | return httpRequest.patch(`${this.API_PREFIX}/${id}`, { ...data }) 59 | } 60 | 61 | /** 62 | * 删除设置 63 | */ 64 | static delete(id: number) { 65 | return httpRequest.delete(`${this.API_PREFIX}/${id}`) 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /src/constants/theme.ts: -------------------------------------------------------------------------------- 1 | import type { ThemeConfig } from 'antd' 2 | import type { AliasToken } from 'antd/es/theme/interface' 3 | 4 | // 消息配置 5 | export const messageConfig = { 6 | maxCount: 3, 7 | duration: 1.5 8 | } 9 | 10 | // 主题:基础配置 11 | const themeBaseToken: Partial = { 12 | fontFamily: 13 | 'Nunito, Noto Sans SC, Noto Color Emoji, system-ui, -apple-system, Roboto, Helvetica Neue, Arial, sans-serif' 14 | } 15 | 16 | // 主题:组件配置 17 | const themeBaseComponents = { 18 | Card: { 19 | paddingLG: 16 20 | } 21 | } 22 | 23 | // 亮色主题预设 24 | export const lightThemeConfigPresets: ThemeConfig = { 25 | algorithm: ATheme.defaultAlgorithm, 26 | token: { 27 | ...themeBaseToken, 28 | colorPrimary: '#1875ff', 29 | colorInfo: '#1875ff', 30 | colorBgBase: '#ffffff', 31 | colorTextBase: '#000000' 32 | }, 33 | components: { 34 | ...themeBaseComponents, 35 | Layout: { 36 | bodyBg: '#ffffff', 37 | footerBg: '#ffffff', 38 | headerBg: '#ffffff', 39 | siderBg: '#ffffff' 40 | } 41 | } 42 | } 43 | 44 | // 暗色主题预设 45 | export const darkThemeConfigPresets: ThemeConfig = { 46 | algorithm: ATheme.darkAlgorithm, 47 | token: { 48 | ...themeBaseToken, 49 | colorPrimary: '#1875ff', 50 | colorPrimaryBg: '#333333', 51 | colorInfo: '#1875ff', 52 | colorBgBase: '#111111', 53 | colorTextBase: '#ffffff' 54 | }, 55 | components: { 56 | ...themeBaseComponents, 57 | Layout: { 58 | bodyBg: '36393f', 59 | footerBg: '#36393f', 60 | headerBg: '#36393f', 61 | siderBg: '#36393f' 62 | } 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /src/constants/page.ts: -------------------------------------------------------------------------------- 1 | import type { Nullable } from '@dolphin-admin/utils' 2 | 3 | import type { Sorter } from '@/api/axios.type' 4 | 5 | export const DEFAULT_PAGE_SIZE = 10 6 | 7 | /** 8 | * 分页模型 9 | * @description 用于分页查询的基本参数 10 | */ 11 | export class BasePageModel { 12 | /** 13 | * 当前页码 14 | */ 15 | page: number 16 | 17 | /** 18 | * 每页条数 19 | */ 20 | pageSize: number 21 | 22 | /** 23 | * 搜索文本 24 | */ 25 | keywords?: string 26 | 27 | /** 28 | * 开始日期 29 | */ 30 | startTime?: Nullable 31 | 32 | /** 33 | * 结束日期 34 | */ 35 | endTime?: Nullable 36 | 37 | /** 38 | * 排序关键字 39 | * @description 以逗号分隔 40 | */ 41 | sort?: string 42 | 43 | /** 44 | * 排序顺序 45 | * @description 以逗号分隔 46 | */ 47 | order?: string 48 | 49 | /** 50 | * 排序器 51 | */ 52 | sorters?: Sorter[] 53 | 54 | constructor(basePageModel?: BasePageModel) { 55 | const { page, pageSize, keywords, startTime, endTime, sorters } = basePageModel ?? {} 56 | this.page = page ?? 1 57 | this.pageSize = pageSize ?? DEFAULT_PAGE_SIZE 58 | if (keywords) { 59 | this.keywords = keywords 60 | } 61 | if (startTime) { 62 | this.startTime = startTime 63 | } 64 | if (endTime) { 65 | this.endTime = endTime 66 | } 67 | if (sorters && Array.isArray(sorters) && sorters.length > 0) { 68 | const sorterKeys = sorters.map((sorter) => sorter.key) 69 | this.sort = sorterKeys.join() 70 | const sorterOrders = sorters.map((sorter) => sorter.order) 71 | this.order = sorterOrders.join() 72 | } 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /src/locales/common/zh-CN.json: -------------------------------------------------------------------------------- 1 | { 2 | "401": "认证失败", 3 | "403": "无访问权限", 4 | "404": "页面未找到", 5 | "418": "我是个杯具", 6 | "500": "服务器错误", 7 | "ACTIONS": "操作", 8 | "ALL": "全部", 9 | "ALREADY.READ": "已读", 10 | "APPLY": "应用", 11 | "BACK": "返回", 12 | "CANCEL": "取消", 13 | "CLEAR": "清除", 14 | "CONFIRM": "确认", 15 | "CONTINUE": "继续", 16 | "COPY": "复制", 17 | "CREATE": "新建", 18 | "CREATED.AT": "创建时间", 19 | "DELETE": "删除", 20 | "DETAIL": "详情", 21 | "DISABLE": "禁用", 22 | "DOCS": "文档", 23 | "DOWNLOAD": "下载", 24 | "EDIT": "编辑", 25 | "ENABLE": "启用", 26 | "ENABLE.OR.NOT": "是否启用", 27 | "EXPORT": "导出", 28 | "FONT": "字体", 29 | "GO.TO": "前往", 30 | "IMPORT": "导入", 31 | "IS.ENABLED": "是否启用", 32 | "IS.LOADING.DATA": "正在加载数据...", 33 | "IS.OR.NOT": "是否", 34 | "KEY": "键", 35 | "KEYWORDS.SEARCH": "关键字搜索", 36 | "LABEL": "标签", 37 | "LOAD.DATA.ERROR": "加载数据失败", 38 | "N": "否", 39 | "NAME": "名称", 40 | "NETWORK.ERROR": "网络错误", 41 | "NOTIFICATION": "通知", 42 | "OPERATION": "操作", 43 | "OPERATION.CONFIRMATION": "确认执行此操作?", 44 | "PAUSE": "暂停", 45 | "PRINT": "打印", 46 | "REFRESH": "刷新", 47 | "REMARK": "备注", 48 | "REQUEST.GEOLOCATION": "为了您的使用体验,可能需要您提供位置信息。", 49 | "RESET": "重置", 50 | "SAVE": "保存", 51 | "SEARCH": "搜索", 52 | "SETTINGS": "设置", 53 | "SHOW.TOTAL": "共计 {{total}}", 54 | "SORT": "排序", 55 | "START": "开始", 56 | "STATUS": "状态", 57 | "STOP": "停止", 58 | "TYPE": "类型", 59 | "UNKNOWN.ERROR": "未知错误", 60 | "UPLOAD": "上传", 61 | "VALUE": "值", 62 | "VERIFY": "验证", 63 | "VERIFY.OR.NOT": "是否验证", 64 | "VIEW": "查看", 65 | "Y": "是", 66 | "COPY.SUCCESS": "复制成功" 67 | } 68 | -------------------------------------------------------------------------------- /@types/build.d.ts: -------------------------------------------------------------------------------- 1 | declare module '~build/time' { 2 | const now: string 3 | export default now 4 | } 5 | 6 | declare module '~build/git' { 7 | const github: string 8 | /** The current branch */ 9 | const branch: string 10 | /** SHA of the current commit */ 11 | const sha: string 12 | /** The first 10 chars of the current SHA */ 13 | const abbreviatedSha: string 14 | /** The tag for the current SHA (or `null` if no tag exists) */ 15 | const tag: string | null 16 | /** Tag for the closest tagged ancestor (or `null` if no ancestor is tagged) */ 17 | const lastTag: string | null 18 | /** The committer of the current SHA */ 19 | const committer: string 20 | /** The commit date of the current SHA */ 21 | const committerDate: string 22 | /** The author for the current SHA */ 23 | const author: string 24 | /** The authored date for the current SHA */ 25 | const authorDate: string 26 | /** The commit message for the current SHA */ 27 | const commitMessage: string 28 | /** 29 | * The root directory for the Git repo or submodule. 30 | * If in a worktree, this is the directory containing the original copy, not the worktree. 31 | */ 32 | const root: string 33 | /** 34 | * The directory containing Git metadata for this repo or submodule. 35 | * If in a worktree, this is the primary Git directory for the repo, not the worktree-specific one. 36 | */ 37 | const commonGitDir: string 38 | /** 39 | * If in a worktree, the directory containing Git metadata specific to this worktree. 40 | * Otherwise, this is the same as `commonGitDir`. 41 | */ 42 | const worktreeGitDir: string 43 | } 44 | -------------------------------------------------------------------------------- /src/components/DpDetailField/index.tsx: -------------------------------------------------------------------------------- 1 | import { isNil } from 'lodash-es' 2 | 3 | function String({ value }: { value?: string }) { 4 | return ( 5 | 9 | {value} 10 | 11 | ) 12 | } 13 | 14 | function I18nString({ value }: { value?: string }) { 15 | const { rt } = useRemoteTranslation() 16 | return ( 17 | 21 | {rt(value)} 22 | 23 | ) 24 | } 25 | 26 | function DateString({ value }: { value?: string }) { 27 | let label 28 | let fullLabel 29 | if (!isNil(value)) { 30 | fullLabel = TimeUtils.formatTime(value) 31 | label = TimeUtils.isCurrentYear(value) 32 | ? TimeUtils.formatTime(value, 'MM-DD HH:mm:ss') 33 | : fullLabel 34 | } 35 | return ( 36 | 40 | 44 | {label} 45 | 46 | 47 | ) 48 | } 49 | 50 | function Boolean({ value }: { value?: boolean }) { 51 | return ( 52 | 56 | {value ? : } 57 | 58 | ) 59 | } 60 | 61 | const DpDetailField = { 62 | String, 63 | I18nString, 64 | DateString, 65 | Boolean 66 | } 67 | export default DpDetailField 68 | -------------------------------------------------------------------------------- /src/api/user.type.ts: -------------------------------------------------------------------------------- 1 | export interface User { 2 | /** 3 | * 地址 4 | */ 5 | address?: string 6 | /** 7 | * 头像 8 | */ 9 | avatarUrl?: string 10 | /** 11 | * 个人简介 12 | */ 13 | biography?: string 14 | /** 15 | * 出生日期 16 | */ 17 | birthDate: Date 18 | /** 19 | * 城市 20 | */ 21 | city?: string 22 | /** 23 | * 国家 24 | */ 25 | country?: string 26 | /** 27 | * 创建时间 28 | */ 29 | createdAt: Date 30 | /** 31 | * 创建人 32 | */ 33 | createdBy: number | null 34 | /** 35 | * 邮箱 36 | */ 37 | email?: string 38 | /** 39 | * 是否启用 40 | */ 41 | enabled: boolean 42 | /** 43 | * 名 44 | */ 45 | firstName?: string 46 | /** 47 | * 性别 48 | */ 49 | gender?: string 50 | /** 51 | * 全名 52 | */ 53 | fullName: string 54 | /** 55 | * ID 56 | */ 57 | id: number 58 | /** 59 | * 姓 60 | */ 61 | lastName?: string 62 | /** 63 | * 中间名 64 | */ 65 | middleName?: string 66 | /** 67 | * 昵称 68 | */ 69 | nickName?: string 70 | /** 71 | * 手机号 72 | */ 73 | phoneNumber?: string 74 | /** 75 | * 个人主页 76 | */ 77 | profile?: string 78 | /** 79 | * 省份 80 | */ 81 | province?: string 82 | /** 83 | * 更新时间 84 | */ 85 | updatedAt: Date 86 | /** 87 | * 更新人 88 | */ 89 | updatedBy: number | null 90 | /** 91 | * 用户名 92 | */ 93 | username: string 94 | /** 95 | * 个人网站 96 | */ 97 | website?: string 98 | } 99 | 100 | export interface CreateUserModel { 101 | /** 102 | * 用户名 103 | */ 104 | username: string 105 | /** 106 | * 密码 107 | */ 108 | password: string 109 | } 110 | -------------------------------------------------------------------------------- /src/store/lang.ts: -------------------------------------------------------------------------------- 1 | import { Lang } from '@dolphin-admin/utils' 2 | import type { Locale } from 'antd/lib/locale' 3 | import enUS from 'antd/locale/en_US' 4 | import zhCN from 'antd/locale/zh_CN' 5 | import { create } from 'zustand' 6 | import { subscribeWithSelector } from 'zustand/middleware' 7 | 8 | import { getDefaultLocale } from '@/features/lang' 9 | import { processLocaleResources } from '@/features/locales' 10 | 11 | interface State { 12 | lang: string 13 | locale: Locale 14 | } 15 | 16 | interface Actions { 17 | setLang: (lang: string) => void 18 | setLocale: (locale: Locale) => void 19 | } 20 | 21 | const initialState: State = { 22 | lang: LangUtils.getDefaultLang(Lang['en-US']), 23 | 24 | /** 25 | * antd 国际化配置 26 | */ 27 | locale: getDefaultLocale() 28 | } 29 | 30 | export const useLangStore = create()( 31 | subscribeWithSelector((set) => ({ 32 | ...initialState, 33 | /** 34 | * 设置语言 35 | * @param lang 选择的语言 36 | */ 37 | setLang: (lang: string) => set({ lang }), 38 | setLocale: (locale: Locale) => set({ locale }) 39 | })) 40 | ) 41 | 42 | /** 43 | * 监听语言改变 44 | * @description 改变全局状态时,自动更新 i18n 实例 和 antd 组件语言 45 | */ 46 | useLangStore.subscribe( 47 | (state) => state.lang, 48 | async (lang) => { 49 | i18n.changeLanguage(lang) 50 | LangUtils.setLang(lang) 51 | LangUtils.setHtmlLang(lang) 52 | // TODO: Use Tanstack Query 53 | processLocaleResources(lang, await LocaleAPI.getLocaleResources(lang)) 54 | switch (lang) { 55 | case Lang['zh-CN']: 56 | useLangStore.setState({ locale: zhCN }) 57 | break 58 | case Lang['en-US']: 59 | useLangStore.setState({ locale: enUS }) 60 | break 61 | default: 62 | break 63 | } 64 | }, 65 | { 66 | fireImmediately: true 67 | } 68 | ) 69 | -------------------------------------------------------------------------------- /src/layouts/DpBaseLayout/components/Header/components/ThemeToggle/index.tsx: -------------------------------------------------------------------------------- 1 | import './index.module.scss' 2 | 3 | import type { MouseEvent } from 'react' 4 | 5 | export default function ThemeToggle() { 6 | const { t } = useTranslation('LAYOUT') 7 | const themeStore = useThemeStore() 8 | 9 | const isAppearanceTransition = () => 10 | typeof document.startViewTransition !== 'undefined' && 11 | !window.matchMedia('(prefers-reduced-motion: reduce)').matches 12 | 13 | const handleToggleTheme = async (event: MouseEvent) => { 14 | if (!isAppearanceTransition()) { 15 | themeStore.toggleTheme() 16 | return 17 | } 18 | const { clientX: x, clientY: y } = event 19 | const endRadius = Math.hypot( 20 | Math.max(x, window.innerWidth - x), 21 | Math.max(y, window.innerHeight - y) 22 | ) 23 | const transition = document.startViewTransition(() => themeStore.toggleTheme()) 24 | await transition.ready 25 | const isDarkTheme = themeStore.isDarkTheme() 26 | const clipPath = [`circle(0px at ${x}px ${y}px)`, `circle(${endRadius}px at ${x}px ${y}px)`] 27 | document.documentElement.animate( 28 | { 29 | clipPath: isDarkTheme ? clipPath : [...clipPath].reverse() 30 | }, 31 | { 32 | duration: 500, 33 | easing: 'ease-in-out', 34 | pseudoElement: isDarkTheme ? '::view-transition-new(root)' : '::view-transition-old(root)' 35 | } 36 | ) 37 | } 38 | 39 | return ( 40 | 44 | 51 | 52 | ) 53 | } 54 | -------------------------------------------------------------------------------- /src/App.tsx: -------------------------------------------------------------------------------- 1 | import 'nprogress/nprogress.css' 2 | 3 | import { px2remTransformer, StyleProvider } from '@ant-design/cssinjs' 4 | import { HappyProvider } from '@ant-design/happy-work-theme' 5 | import { QueryClient, QueryClientProvider } from '@tanstack/react-query' 6 | import { ReactQueryDevtools } from '@tanstack/react-query-devtools' 7 | import { RouterProvider } from 'react-router-dom' 8 | 9 | import router from '@/router' 10 | 11 | import { defaultQueryConfig, messageConfig } from './constants' 12 | 13 | /** 14 | * rem 适配 15 | * @see https://ant-design.antgroup.com/docs/react/compatible-style#rem-adaptation 16 | */ 17 | const px2rem = px2remTransformer({ 18 | rootValue: 16, 19 | mediaQuery: true 20 | }) 21 | 22 | // 静态方法的全局配置 23 | AMessage.config(messageConfig) 24 | 25 | export default function App() { 26 | const themeStore = useThemeStore() 27 | const langStore = useLangStore() 28 | 29 | const [queryClient] = useState(() => new QueryClient({ defaultOptions: defaultQueryConfig })) 30 | 31 | return ( 32 | 33 | {/** 34 | * antd 样式兼容 35 | * @see https://ant-design.antgroup.com/docs/react/compatible-style-cn 36 | */} 37 | 43 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | ) 58 | } 59 | -------------------------------------------------------------------------------- /src/locales/common/en-US.json: -------------------------------------------------------------------------------- 1 | { 2 | "401": "Unauthorized", 3 | "403": "Forbidden", 4 | "404": "Not Found", 5 | "418": "I'm A Teapot", 6 | "500": "Internal Server Error", 7 | "ACTIONS": "Actions", 8 | "ALL": "All", 9 | "ALREADY.READ": "Have read", 10 | "APPLY": "Apply", 11 | "BACK": "Back", 12 | "CANCEL": "Cancel", 13 | "CLEAR": "Clear", 14 | "CONFIRM": "Confirm", 15 | "CONTINUE": "Continue", 16 | "COPY": "Copy", 17 | "CREATE": "Create", 18 | "CREATED.AT": "Creation time", 19 | "DELETE": "Delete", 20 | "DETAIL": "Detail", 21 | "DISABLE": "Disable", 22 | "DOCS": "Docs", 23 | "DOWNLOAD": "Download", 24 | "EDIT": "Edit", 25 | "ENABLE": "Enable", 26 | "ENABLE.OR.NOT": "Enable or not", 27 | "EXPORT": "Export", 28 | "FONT": "Font Family", 29 | "GO.TO": "Go to", 30 | "IMPORT": "Import", 31 | "IS.ENABLED": "Enabled", 32 | "IS.LOADING.DATA": "Loading data ...", 33 | "IS.OR.NOT": "Is or not", 34 | "KEY": "Key", 35 | "KEYWORDS.SEARCH": "Keyword search", 36 | "LABEL": "Label", 37 | "LOAD.DATA.ERROR": "Loading data error", 38 | "N": "No", 39 | "NAME": "Name", 40 | "NETWORK.ERROR": "Network Error", 41 | "NOTIFICATION": "Notification", 42 | "OPERATION": "Operation", 43 | "OPERATION.CONFIRMATION": "Are you sure you want to do this action?", 44 | "PAUSE": "Pause", 45 | "PRINT": "Print", 46 | "REFRESH": "Refresh", 47 | "REMARK": "Remark", 48 | "REQUEST.GEOLOCATION": "For your experience, we need your location information.", 49 | "RESET": "Reset", 50 | "SAVE": "Save", 51 | "SEARCH": "Search", 52 | "SETTINGS": "Settings", 53 | "SHOW.TOTAL": "Total {{total}}", 54 | "SORT": "Sort", 55 | "START": "Start", 56 | "STATUS": "Status", 57 | "STOP": "Stop", 58 | "TYPE": "Type", 59 | "UNKNOWN.ERROR": "Unknown Error", 60 | "UPLOAD": "Upload", 61 | "VALUE": "Value", 62 | "VERIFY": "Verify", 63 | "VERIFY.OR.NOT": "Verify or not", 64 | "VIEW": "View", 65 | "Y": "Yes", 66 | "COPY.SUCCESS": "Copy success" 67 | } 68 | -------------------------------------------------------------------------------- /src/pages/login/hooks/useLoginForm.ts: -------------------------------------------------------------------------------- 1 | import { UserNameLoginType } from '../enum' 2 | import type { LoginFormData } from '../types' 3 | 4 | export const useLoginForm = () => { 5 | const [form] = AForm.useForm() 6 | 7 | useEffect(() => { 8 | // 从 localStorage 中获取记住的账号密码 9 | const localStorageData = AuthUtils.getRememberedAccount() 10 | if (localStorageData) { 11 | try { 12 | const data = JSON.parse(localStorageData) as LoginFormData 13 | form.setFieldsValue(data) 14 | } catch { 15 | // 16 | } 17 | } 18 | }, [form]) 19 | 20 | // 清空密码 21 | const clearPassword = () => form.setFieldValue('password', '') 22 | 23 | // 设置管理员账号密码 24 | const setAdminAccount = () => 25 | form.setFieldsValue({ 26 | username: AuthUtils.DEFAULT_ADMIN_USERNAME, 27 | password: AuthUtils.DEFAULT_ADMIN_PASSWORD 28 | }) 29 | 30 | // 设置访客账号密码 31 | const setVisitorAccount = () => 32 | form.setFieldsValue({ 33 | username: AuthUtils.DEFAULT_VISITOR_USERNAME, 34 | password: AuthUtils.DEFAULT_VISITOR_PASSWORD 35 | }) 36 | 37 | // 处理不同登录方式 38 | const handleAutoComplete = (type: UserNameLoginType) => { 39 | switch (type) { 40 | // 管理员登录 41 | case UserNameLoginType.ADMIN: 42 | setAdminAccount() 43 | break 44 | // 访客登录 45 | case UserNameLoginType.VISITOR: 46 | setVisitorAccount() 47 | break 48 | // 普通登录 49 | case UserNameLoginType.BASIC: 50 | default: 51 | break 52 | } 53 | } 54 | 55 | // 处理记住密码 56 | const handleRememberPassword = () => { 57 | const formData = form.getFieldsValue() 58 | if (formData.rememberPassword) { 59 | AuthUtils.setRememberedAccount(JSON.stringify(formData)) 60 | } else { 61 | AuthUtils.clearRememberedAccount() 62 | } 63 | } 64 | 65 | return { 66 | loginForm: form, 67 | clearPassword, 68 | handleAutoComplete, 69 | handleRememberPassword 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /src/layouts/DpBaseLayout/components/Header/components/Breadcrumb/index.tsx: -------------------------------------------------------------------------------- 1 | import type { BreadcrumbItemType, ItemType } from 'antd/es/breadcrumb/Breadcrumb' 2 | 3 | import { getMenuItem, getMenuTree, menuCacheMap } from '@/constants' 4 | 5 | export default function BaseBreadcrumb() { 6 | const { i18n } = useTranslation() 7 | const location = useLocation() 8 | const navigate = useNavigate() 9 | 10 | const [breadcrumbItems, setBreadcrumbItems] = useImmer([]) 11 | 12 | useEffect(() => menuCacheMap.clear(), [i18n.language]) 13 | 14 | useEffect(() => { 15 | const pathSnippets = location.pathname.split('/').filter((i) => i) 16 | const items: BreadcrumbItemType[] = [] 17 | const menuTree = getMenuTree() 18 | pathSnippets.reduce((acc, cur) => { 19 | const key = `${acc}/${cur}` 20 | const menuItem = getMenuItem(key, menuTree) 21 | const siblingMenuList = !acc ? menuTree : (getMenuItem(acc, menuTree) as any)?.children 22 | if (menuItem) { 23 | const { label } = menuItem as any 24 | items.push({ 25 | key: acc, 26 | title: label, 27 | dropdownProps: { 28 | arrow: { 29 | pointAtCenter: true 30 | } 31 | }, 32 | ...(Array.isArray(siblingMenuList) && 33 | siblingMenuList.length > 1 && { 34 | menu: { 35 | items: siblingMenuList.map((item: any) => ({ 36 | key: item.key, 37 | label: item.label, 38 | children: item.children 39 | })), 40 | onClick: ({ key: menuKey }) => navigate(menuKey) 41 | } 42 | }) 43 | }) 44 | } 45 | return key 46 | }, '') 47 | setBreadcrumbItems(items) 48 | }, [location.pathname, i18n.language, setBreadcrumbItems, navigate]) 49 | 50 | return ( 51 | /
} 56 | /> 57 | ) 58 | } 59 | -------------------------------------------------------------------------------- /src/layouts/DpBaseLayout/components/Header/components/UserAvatar/index.tsx: -------------------------------------------------------------------------------- 1 | enum UserAction { 2 | 'USER.INFO' = '1', 3 | 'CHANGE.PASSWORD' = '2', 4 | 'QUIT' = '3' 5 | } 6 | 7 | export default function UserAvatar() { 8 | const { t } = useTranslation(['LAYOUT', 'AUTH']) 9 | const { message } = AApp.useApp() 10 | const userStore = useUserStore() 11 | const navigate = useNavigate() 12 | 13 | const logoutMutation = useMutation({ 14 | mutationFn: () => AuthAPI.logout(), 15 | onSuccess: () => { 16 | userStore.clearUser() 17 | AuthUtils.clearAccessToken() 18 | AuthUtils.clearRefreshToken() 19 | navigate('/login', { replace: true }) 20 | message.success(t('AUTH:LOG.OUT.SUCCESS')) 21 | } 22 | }) 23 | 24 | const menuItems = [ 25 | { 26 | key: UserAction['USER.INFO'], 27 | label: t('HEADER.USER.INFO') 28 | }, 29 | { 30 | key: UserAction['CHANGE.PASSWORD'], 31 | label: t('HEADER.CHANGE.PASSWORD') 32 | }, 33 | { 34 | key: UserAction.QUIT, 35 | label: t('HEADER.LOG.OUT') 36 | } 37 | ] 38 | 39 | // 点击菜单 40 | const handleClickMenu = ({ key }: { key: string }) => { 41 | switch (key) { 42 | case UserAction['USER.INFO']: 43 | navigate('/user-info') 44 | break 45 | case UserAction['CHANGE.PASSWORD']: 46 | navigate('/change-password') 47 | break 48 | case UserAction.QUIT: 49 | logoutMutation.mutate() 50 | break 51 | default: 52 | break 53 | } 54 | } 55 | 56 | if (!userStore.hasData()) { 57 | return null 58 | } 59 | 60 | return ( 61 | 67 | {userStore.user.avatarUrl ? ( 68 | 73 | ) : ( 74 | 80 | )} 81 | 82 | ) 83 | } 84 | -------------------------------------------------------------------------------- /src-tauri/tauri.conf.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "../node_modules/@tauri-apps/cli/schema.json", 3 | "build": { 4 | "beforeBuildCommand": "pnpm build", 5 | "beforeDevCommand": "pnpm dev", 6 | "devPath": "http://localhost:4061", 7 | "distDir": "../dist" 8 | }, 9 | "package": { 10 | "productName": "Dolphin Admin React", 11 | "version": "0.0.1" 12 | }, 13 | "tauri": { 14 | "allowlist": { 15 | "all": true, 16 | "dialog": { 17 | "all": true, 18 | "open": true, 19 | "save": true 20 | }, 21 | "notification": { 22 | "all": true 23 | } 24 | }, 25 | "bundle": { 26 | "active": true, 27 | "category": "DeveloperTool", 28 | "copyright": "", 29 | "deb": { 30 | "depends": [] 31 | }, 32 | "externalBin": [], 33 | "icon": [ 34 | "icons/32x32.png", 35 | "icons/128x128.png", 36 | "icons/128x128@2x.png", 37 | "icons/icon.icns", 38 | "icons/icon.ico" 39 | ], 40 | "identifier": "dolphin.admin.react", 41 | "longDescription": "", 42 | "macOS": { 43 | "entitlements": null, 44 | "exceptionDomain": "", 45 | "frameworks": [], 46 | "providerShortName": null, 47 | "signingIdentity": null 48 | }, 49 | "resources": [], 50 | "shortDescription": "", 51 | "targets": "all", 52 | "windows": { 53 | "certificateThumbprint": null, 54 | "digestAlgorithm": "sha256", 55 | "timestampUrl": "", 56 | "webviewInstallMode": { 57 | "type": "embedBootstrapper" 58 | }, 59 | "wix": { 60 | "language": [ 61 | "en-US", 62 | "zh-CN" 63 | ] 64 | } 65 | } 66 | }, 67 | "security": { 68 | "csp": null 69 | }, 70 | "updater": { 71 | "active": false 72 | }, 73 | "windows": [ 74 | { 75 | "fullscreen": false, 76 | "resizable": true, 77 | "title": "Dolphin Admin React", 78 | "center": true, 79 | "width": 1300, 80 | "height": 800, 81 | "minWidth": 1300, 82 | "minHeight": 800 83 | } 84 | ] 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Dolphin Admin React 2 | 3 | English / [简体中文](./README.zh-CN.md) 4 | 5 | Dolphin Admin React is an open source, lightweight, out-of-the-box, elegant and exquisite, internationalized 6 | backend management template based on React + TypeScript + Vite + antd + TailwindCSS. 7 | 8 | ## Tech Stack 9 | 10 | - [x] Based on [React](https://react.dev/), [Vite](https://vitejs.dev/) 11 | - [x] [TypeScript](https://www.typescriptlang.org/), of course 12 | - [x] [Ant Design](https://ant.design/) as UI framework 13 | - [x] [TailwindCSS](https://tailwindcss.com/), a utility-first CSS framework 14 | - [x] [pnpm](https://pnpm.io/) as package manager 15 | - [x] [Sass](https://sass-lang.com/) as CSS preprocessor 16 | - [x] [React Router](https://reactrouter.com/) for routing management 17 | - [x] [Zustand](https://zustand-demo.pmnd.rs/) for global state management 18 | - [x] [Axios](https://axios-http.com/) for request, and highly encapsulated 19 | - [x] [Tanstack Query](https://tanstack.com/) for request state management 20 | - [x] [react-i18next](https://react.i18next.com/) for internationalization 21 | - [ ] [React Hook Form](https://www.react-hook-form.com/), [yup](https://github.com/jquense/yup) for form validation 22 | - [x] [iconify/json](https://iconify.design/) and [unplugin-icons](https://github.com/antfu/unplugin-icons) 23 | for icon management, you can use [icones](https://icones.js.org/) to use it quickly 24 | - [x] [unplugin-auto-import](https://github.com/antfu/unplugin-auto-import) and 25 | [unplugin-vue-components](https://github.com/antfu/unplugin-vue-components) for automatic import 26 | of components, hooks, and utility classes, freeing your hands 27 | - [x] [ESLint](https://eslint.org/) for code checking 28 | - [x] [Prettier](https://prettier.io/) for code formatting 29 | - [x] [CSpell](https://cspell.org/) for code spelling checking 30 | - [x] [Husky](https://typicode.github.io/husky/), [lint-staged](https://github.com/okonet/lint-staged), 31 | [commitlint](https://commitlint.js.org/#/), [cz-git](https://cz-git.qbb.sh/) for Git commit management 32 | - [x] Support absolute path import, use `@/*` 33 | - [x] Deploy on [Vercel](https://vercel.com/), zero configuration 34 | 35 | ## Usage 36 | 37 | ### Install 38 | 39 | ```bash 40 | pnpm i 41 | ``` 42 | 43 | ### Start 44 | 45 | ```bash 46 | pnpm dev 47 | ``` 48 | 49 | ### Build 50 | 51 | ```bash 52 | pnpm build 53 | ``` 54 | 55 | ## Deploy 56 | 57 | Go to Vercel and select your Git repository, choose Vite as template, add production environment variables, and click deploy. 58 | 59 | ## License 60 | 61 | [MIT](/LICENSE) License © 2023 [Bruce Song](https://github.com/recallwei) 62 | -------------------------------------------------------------------------------- /.drone.yml: -------------------------------------------------------------------------------- 1 | kind: pipeline 2 | type: docker 3 | name: deploy staging 4 | 5 | platform: 6 | os: linux 7 | arch: amd64 8 | 9 | trigger: 10 | branch: 11 | exclude: 12 | - main 13 | event: 14 | - pull_request 15 | - push 16 | 17 | steps: 18 | - name: build staging 19 | image: node:20-alpine 20 | commands: 21 | - npm i -g pnpm 22 | - pnpm i 23 | - pnpm build:staging 24 | 25 | - name: deploy staging 26 | image: appleboy/drone-scp 27 | settings: 28 | host: 29 | from_secret: SSH_HOST_STAGING 30 | username: 31 | from_secret: SSH_USERNAME 32 | password: 33 | from_secret: SSH_PASSWORD 34 | port: 22 35 | target: /usr/share/nginx/html/dolphin-admin-react/dist 36 | source: 37 | - ./dist 38 | rm_target: true 39 | strip_components: 1 40 | debug: true 41 | depends_on: 42 | - build staging 43 | --- 44 | kind: pipeline 45 | type: docker 46 | name: deploy production 47 | 48 | platform: 49 | os: linux 50 | arch: amd64 51 | 52 | trigger: 53 | branch: 54 | - main 55 | event: 56 | - push 57 | 58 | steps: 59 | - name: build staging 60 | image: node:20-alpine 61 | commands: 62 | - npm i -g pnpm 63 | - pnpm i 64 | - pnpm build:staing 65 | 66 | - name: deploy staging 67 | image: appleboy/drone-scp 68 | settings: 69 | host: 70 | from_secret: SSH_HOST_STAGING 71 | username: 72 | from_secret: SSH_USERNAME 73 | password: 74 | from_secret: SSH_PASSWORD 75 | port: 22 76 | target: /usr/share/nginx/html/dolphin-admin-react/dist 77 | source: 78 | - ./dist 79 | rm_target: true 80 | strip_components: 1 81 | debug: true 82 | depends_on: 83 | - build staging 84 | 85 | - name: build production 86 | image: node:20-alpine 87 | commands: 88 | - npm i -g pnpm 89 | - pnpm i 90 | - pnpm build:prod 91 | depends_on: 92 | - deploy staging 93 | 94 | - name: deploy production 95 | image: appleboy/drone-scp 96 | settings: 97 | host: 98 | from_secret: SSH_HOST_PRODUCTION 99 | username: 100 | from_secret: SSH_USERNAME 101 | password: 102 | from_secret: SSH_PASSWORD 103 | port: 22 104 | target: /usr/share/nginx/html/dolphin-admin-react/dist 105 | source: 106 | - ./dist 107 | rm_target: true 108 | strip_components: 1 109 | debug: true 110 | depends_on: 111 | - build production 112 | -------------------------------------------------------------------------------- /src/features/dictionaries/components/ModalContent.tsx: -------------------------------------------------------------------------------- 1 | import { type FormInstance } from 'antd' 2 | 3 | import type { Dictionary } from '@/api/dictionary.type' 4 | import type { DetailItems } from '@/features/detail' 5 | import { ModalType } from '@/features/modal' 6 | 7 | import { formInitialValue } from '../constants' 8 | 9 | interface Props { 10 | modalType: ModalType 11 | detailItems?: DetailItems 12 | crudForm: FormInstance 13 | isDetailLoading: boolean 14 | isFormSubmitting: boolean 15 | handleSubmit: (values: Dictionary) => void 16 | } 17 | 18 | const ModalContent = memo((props: Props) => { 19 | const { t } = useTranslation(['DICTIONARY', 'COMMON', 'VALIDATION']) 20 | 21 | if (props.modalType === ModalType.DETAIL) { 22 | return ( 23 | 30 | ) 31 | } 32 | 33 | return ( 34 | 35 | 44 | 49 | 53 | 54 | 59 | 63 | 64 | 68 | 72 | 73 | 77 | 82 | 83 | 84 | 85 | ) 86 | }) 87 | export default ModalContent 88 | -------------------------------------------------------------------------------- /src/store/theme.ts: -------------------------------------------------------------------------------- 1 | import { Theme } from '@dolphin-admin/utils' 2 | import type { ThemeConfig } from 'antd' 3 | import { create } from 'zustand' 4 | import { subscribeWithSelector } from 'zustand/middleware' 5 | 6 | import { darkThemeConfigPresets, lightThemeConfigPresets } from '@/constants' 7 | 8 | interface State { 9 | theme: Theme 10 | enableHappyWorkTheme: boolean 11 | lightThemeConfig: ThemeConfig 12 | darkThemeConfig: ThemeConfig 13 | } 14 | 15 | interface Actions { 16 | isLightTheme: () => boolean 17 | isDarkTheme: () => boolean 18 | toggleTheme: () => void 19 | changeTheme: (theme: Theme) => void 20 | setHappyWorkTheme: (enable: boolean) => void 21 | toggleHappyWorkTheme: () => void 22 | } 23 | 24 | const initialState: State = { 25 | /** 26 | * 全局亮色主题配置项 27 | */ 28 | lightThemeConfig: lightThemeConfigPresets, 29 | 30 | /** 31 | * 全局暗色主题配置项 32 | */ 33 | darkThemeConfig: darkThemeConfigPresets, 34 | 35 | /** 36 | * 主题模式 37 | * @description 38 | * 可选值:`light` | `dark` 39 | */ 40 | theme: ThemeUtils.getDefaultTheme(), 41 | 42 | /** 43 | * 是否开启 antd 快乐工作主题,默认开启 44 | */ 45 | enableHappyWorkTheme: true 46 | } 47 | 48 | export const useThemeStore = create()( 49 | subscribeWithSelector((set, get) => ({ 50 | ...initialState, 51 | 52 | /** 53 | * 是否为亮色主题 54 | */ 55 | isLightTheme: () => get().theme === Theme.LIGHT, 56 | 57 | /** 58 | * 是否为暗色主题 59 | */ 60 | isDarkTheme: () => get().theme === Theme.DARK, 61 | 62 | /** 63 | * 修改主题模式 64 | * @description 65 | * - 切换主题模式时,会自动添加或移除 document 上 `dark` 类名 66 | * - 将主题模式存储到 localStorage 中,以便下次打开页面时读取 67 | */ 68 | changeTheme: (theme: Theme) => { 69 | set({ theme }) 70 | ThemeUtils.changeTheme(theme) 71 | }, 72 | 73 | /** 74 | * 切换主题模式 75 | */ 76 | toggleTheme: () => { 77 | set(() => ({ theme: get().isLightTheme() ? Theme.DARK : Theme.LIGHT })) 78 | ThemeUtils.changeTheme(get().theme) 79 | }, 80 | 81 | /** 82 | * 启用/禁用快乐工作主题 83 | */ 84 | setHappyWorkTheme: (enable: boolean) => set({ enableHappyWorkTheme: enable }), 85 | /** 86 | * 切换快乐工作主题 87 | */ 88 | toggleHappyWorkTheme: () => 89 | set((state) => ({ enableHappyWorkTheme: !state.enableHappyWorkTheme })) 90 | })) 91 | ) 92 | 93 | /** 94 | * 监听主题改变 95 | * @description 96 | * - 切换主题模式时,会自动添加或移除 document 上 `dark` 类名 97 | * - 将主题模式存储到 localStorage 中,以便下次打开页面时读取 98 | */ 99 | useThemeStore.subscribe( 100 | (state) => state.theme, 101 | (theme) => ThemeUtils.changeTheme(theme), 102 | { 103 | fireImmediately: true 104 | } 105 | ) 106 | -------------------------------------------------------------------------------- /src/features/dictionaries/hooks/useCrud.ts: -------------------------------------------------------------------------------- 1 | import type { Dictionary } from '@/api/dictionary.type' 2 | import type { DetailItems } from '@/features/detail' 3 | 4 | import { detailFields } from '../constants' 5 | import type { EnableMutationParams } from '../types' 6 | 7 | export const useCrud = () => { 8 | const queryClient = useQueryClient() 9 | 10 | const [crudForm] = AForm.useForm() 11 | 12 | const [currentId, setCurrentId] = useState() 13 | 14 | const detailQuery = useQuery({ 15 | queryKey: ['DICTIONARY.DETAIL', currentId], 16 | queryFn: ({ queryKey }) => DictionaryAPI.detail(queryKey[1] as number), 17 | enabled: Boolean(currentId) 18 | }) 19 | 20 | const createMutation = useMutation({ 21 | mutationFn: (params: Dictionary) => DictionaryAPI.create(params), 22 | onSuccess: () => refetchList() 23 | }) 24 | 25 | const updateMutation = useMutation({ 26 | mutationFn: (params: Dictionary) => DictionaryAPI.update(currentId!, params), 27 | onSuccess: () => refetchList() 28 | }) 29 | 30 | const patchMutation = useMutation({ 31 | mutationFn: ({ id, enabled }: EnableMutationParams) => DictionaryAPI.patch(id, { enabled }), 32 | onSuccess: () => refetchList() 33 | }) 34 | 35 | const deleteMutation = useMutation({ 36 | mutationFn: (id: number) => DictionaryAPI.delete(id), 37 | onSuccess: () => refetchList() 38 | }) 39 | 40 | // 刷新列表 41 | function refetchList() { 42 | queryClient.invalidateQueries({ 43 | queryKey: ['DICTIONARY.LIST'] 44 | }) 45 | } 46 | 47 | // 切换启用/禁用状态 48 | const toggleEnabled = async (params: EnableMutationParams) => { 49 | await patchMutation.mutateAsync(params) 50 | } 51 | 52 | // 删除 53 | const handleDelete = async (id: number) => { 54 | await deleteMutation.mutateAsync(id) 55 | } 56 | 57 | // 创建提交 58 | const handleCreateSubmit = async (values: Dictionary) => { 59 | await createMutation.mutateAsync(values) 60 | } 61 | 62 | // 编辑提交 63 | const handleEditSubmit = async (values: Dictionary) => { 64 | await updateMutation.mutateAsync(values) 65 | } 66 | 67 | // 格式化详情页数据 68 | const formatDetailItems = (): DetailItems => 69 | detailFields.map((i) => ({ 70 | ...i, 71 | label: i.label(), 72 | children: i.children({ value: detailQuery.data?.[i.key as keyof Dictionary] as any }) 73 | })) 74 | 75 | return { 76 | crudForm, 77 | detail: detailQuery.data, 78 | detailItems: formatDetailItems(), 79 | isDetailLoading: detailQuery.isFetching, 80 | isDeleteLoading: deleteMutation.isPending, 81 | isPatchLoading: patchMutation.isPending, 82 | isFormSubmitting: createMutation.isPending || updateMutation.isPending, 83 | setCurrentId, 84 | toggleEnabled, 85 | handleDelete, 86 | handleCreateSubmit, 87 | handleEditSubmit 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /src/features/dictionaries/hooks/useColumns.tsx: -------------------------------------------------------------------------------- 1 | import type { ColumnsType } from 'antd/es/table' 2 | 3 | import type { Dictionary } from '@/api/dictionary.type' 4 | 5 | import type { EnableMutationParams } from '../types' 6 | 7 | interface Props { 8 | toggleEnabled: (params: EnableMutationParams) => void 9 | handleDelete: (id: number) => void 10 | toggleEditModal: (id: number) => void 11 | toggleDetailModal: (id: number) => void 12 | isDeleteLoading: boolean 13 | isPatchLoading: boolean 14 | } 15 | 16 | export const useColumns = (props: Props): ColumnsType => { 17 | const { t } = useTranslation(['COMMON', 'DICTIONARY']) 18 | const response = useResponsive() 19 | const { getTableField, getTableFields } = useTableFields() 20 | 21 | return [ 22 | getTableField('id'), 23 | { 24 | title: t('DICTIONARY:LABEL'), 25 | dataIndex: 'label', 26 | key: 'label', 27 | fixed: response.sm && 'left', 28 | width: 150, 29 | render: DpTableField.I18nString 30 | }, 31 | { 32 | title: t('DICTIONARY:CODE'), 33 | dataIndex: 'code', 34 | key: 'code', 35 | width: 150, 36 | align: 'center', 37 | render: DpTableField.CopyableTagString 38 | }, 39 | ...getTableFields('enabled', 'remark', 'createdAt'), 40 | { 41 | title: t('ACTIONS'), 42 | align: 'center', 43 | fixed: response.sm && 'right', 44 | width: 250, 45 | render: (_, record) => ( 46 | 47 | props.toggleDetailModal(record.id)} 50 | > 51 | {t('VIEW')} 52 | 53 | props.toggleEditModal(record.id)} 56 | > 57 | {t('EDIT')} 58 | 59 | props.toggleEnabled({ ...record })} 66 | > 67 | 71 | {record.enabled ? t('ENABLE') : t('DISABLE')} 72 | 73 | 74 | props.handleDelete(record.id)} 81 | > 82 | 86 | {t('DELETE')} 87 | 88 | 89 | 90 | ) 91 | } 92 | ] 93 | } 94 | -------------------------------------------------------------------------------- /src/features/icon/icon-set.ts: -------------------------------------------------------------------------------- 1 | import GitHub from '~icons/carbon/logo-github' 2 | import TwoCol from '~icons/carbon/open-panel-left' 3 | import Settings from '~icons/heroicons/cog-6-tooth' 4 | import Check from '~icons/ic/round-check' 5 | import Lang from '~icons/ion/language-outline' 6 | import ChevronDoubleLeft from '~icons/line-md/chevron-small-double-left' 7 | import Discord from '~icons/line-md/discord' 8 | import Docs from '~icons/line-md/document-list' 9 | import GitHubDynamic from '~icons/line-md/github-loop' 10 | import Loading from '~icons/line-md/loading-twotone-loop' 11 | import HideMenu from '~icons/line-md/menu-fold-left' 12 | import ShowMenu from '~icons/line-md/menu-fold-right' 13 | import Sun from '~icons/line-md/moon-alt-to-sunny-outline-loop-transition' 14 | import Moon from '~icons/line-md/sunny-filled-loop-to-moon-alt-filled-loop-transition' 15 | import Google from '~icons/logos/google-icon' 16 | import React from '~icons/logos/react' 17 | import Account from '~icons/material-symbols/account-circle' 18 | import Add from '~icons/material-symbols/add-rounded' 19 | import Table from '~icons/material-symbols/backup-table-rounded' 20 | import Close from '~icons/material-symbols/close-rounded' 21 | import Code from '~icons/material-symbols/code-rounded' 22 | import Copy from '~icons/material-symbols/content-copy-outline-rounded' 23 | import Error from '~icons/material-symbols/error-outline' 24 | import FullscreenExit from '~icons/material-symbols/fullscreen-exit-rounded' 25 | import Fullscreen from '~icons/material-symbols/fullscreen-rounded' 26 | import Key from '~icons/material-symbols/key-vertical-outline-rounded' 27 | import Books from '~icons/material-symbols/library-books-outline-rounded' 28 | import Lock from '~icons/material-symbols/lock-outline' 29 | import Dictionary from '~icons/material-symbols/menu-book-outline-rounded' 30 | import Menu from '~icons/material-symbols/menu-rounded' 31 | import Package from '~icons/material-symbols/package-2-outline' 32 | import Refresh from '~icons/material-symbols/refresh-rounded' 33 | import Tools from '~icons/material-symbols/tools-wrench-outline' 34 | import NotFound from '~icons/streamline-emojis/confounded-face' 35 | import InternalServerError from '~icons/streamline-emojis/face-with-head-bandage' 36 | import Unauthorized from '~icons/streamline-emojis/shushing-face' 37 | import IAmATeapot from '~icons/streamline-emojis/smiling-face-with-sunglasses' 38 | 39 | export const iconSet = { 40 | '403': Unauthorized, 41 | '404': NotFound, 42 | '418': IAmATeapot, 43 | '500': InternalServerError, 44 | GitHub, 45 | Google, 46 | Discord, 47 | React, 48 | Loading, 49 | Lang, 50 | Refresh, 51 | Settings, 52 | Tools, 53 | Menu, 54 | Key, 55 | Account, 56 | Lock, 57 | Error, 58 | Code, 59 | Table, 60 | Dictionary, 61 | Books, 62 | TwoCol, 63 | Check, 64 | Close, 65 | Docs, 66 | Fullscreen, 67 | Sun, 68 | Moon, 69 | Add, 70 | Copy, 71 | Package, 72 | 'Hide:Menu': HideMenu, 73 | 'Show:Menu': ShowMenu, 74 | 'Fullscreen:Exit': FullscreenExit, 75 | 'Chevron:Double:Left': ChevronDoubleLeft, 76 | 'GitHub:Dynamic': GitHubDynamic 77 | } 78 | -------------------------------------------------------------------------------- /src/pages/signup/index.tsx: -------------------------------------------------------------------------------- 1 | interface SignupData { 2 | username: string 3 | password: string 4 | confirmPassword: string 5 | } 6 | 7 | export function Component() { 8 | const { t } = useTranslation(['AUTH', 'USER', 'VALIDATION']) 9 | const navigate = useNavigate() 10 | const [form] = AForm.useForm() 11 | 12 | const signupMutation = useMutation({ 13 | mutationFn: (data: SignupData) => AuthAPI.signup(data), 14 | onSuccess: async (data) => { 15 | const { accessToken, refreshToken } = data ?? {} 16 | AuthUtils.setAccessToken(accessToken) 17 | AuthUtils.setRefreshToken(refreshToken) 18 | navigate('/', { replace: true }) 19 | }, 20 | onError: () => form.setFieldsValue({ password: '', confirmPassword: '' }) 21 | }) 22 | 23 | // 注册 24 | const handleSignup = (values: SignupData) => signupMutation.mutate(values) 25 | 26 | // 跳转到登录页面 27 | const handleLogin = () => navigate('/login') 28 | 29 | return ( 30 |
31 |
{t('SIGN.UP')}
32 | 44 | 49 | 53 | 54 | 55 | 63 | 67 | 68 | 69 | ({ 75 | validator(_, value) { 76 | if (!value || getFieldValue('password') === value) { 77 | return Promise.resolve() 78 | } 79 | return Promise.reject(new Error(t('VALIDATION:CONFIRM.PASSWORD.NOT.MATCH'))) 80 | } 81 | }) 82 | ]} 83 | rootClassName="!mb-4" 84 | > 85 | 89 | 90 | 91 | 92 | 98 | {t('SIGN.UP')} 99 | 100 | 101 | 102 |
103 | {t('ALREADY.HAVE.ACCOUNT')} 104 | 105 | 110 | 111 | {t('LOGIN')} 112 | 113 | 114 | 115 |
116 |
117 |
118 | ) 119 | } 120 | -------------------------------------------------------------------------------- /src/pages/system/dictionaries/index.tsx: -------------------------------------------------------------------------------- 1 | import type { Dictionary } from '@/api/dictionary.type' 2 | import { 3 | ModalContent, 4 | useColumns, 5 | useCrud, 6 | useDictionariesQuery, 7 | useDictionaryListParams 8 | } from '@/features/dictionaries' 9 | import { ModalType, useModal } from '@/features/modal' 10 | import { usePagination } from '@/features/pagination' 11 | 12 | export function Component() { 13 | const { t } = useTranslation(['COMMON', 'DICTIONARY', 'VALIDATION']) 14 | const queryClient = useQueryClient() 15 | const [searchText, setSearchText] = useState('') 16 | 17 | const { pageParams, pagination, setTotal } = usePagination() 18 | const { open, modalType, setModalType, getModalTitle, toggle } = useModal() 19 | const { listParams } = useDictionaryListParams() 20 | const { 21 | data: listData, 22 | isRefetching, 23 | isFetching 24 | } = useDictionariesQuery({ 25 | pageParams: { ...pageParams, ...listParams }, 26 | prefetch: true 27 | }) 28 | const { 29 | detail, 30 | detailItems, 31 | crudForm, 32 | isDetailLoading, 33 | isDeleteLoading, 34 | isPatchLoading, 35 | isFormSubmitting, 36 | setCurrentId, 37 | toggleEnabled, 38 | handleDelete, 39 | handleCreateSubmit, 40 | handleEditSubmit 41 | } = useCrud() 42 | 43 | const columns = useColumns({ 44 | toggleEnabled, 45 | handleDelete, 46 | toggleEditModal, 47 | toggleDetailModal, 48 | isDeleteLoading, 49 | isPatchLoading 50 | }) 51 | 52 | useEffect(() => { 53 | if (listData) { 54 | setTotal(listData.total) 55 | } 56 | }, [listData, setTotal]) 57 | 58 | useEffect(() => { 59 | if (modalType === ModalType.EDIT) { 60 | crudForm.setFieldsValue({ ...detail }) 61 | } 62 | }, [detail]) 63 | 64 | // 新增 65 | function toggleCreateModal() { 66 | crudForm.resetFields() 67 | setModalType(ModalType.CREATE) 68 | toggle() 69 | } 70 | 71 | // 编辑 72 | async function toggleEditModal(id: number) { 73 | queryClient.cancelQueries({ queryKey: [id] }) 74 | setCurrentId(id) 75 | setModalType(ModalType.EDIT) 76 | toggle() 77 | } 78 | 79 | // 详情 80 | function toggleDetailModal(id: number) { 81 | setCurrentId(id) 82 | setModalType(ModalType.DETAIL) 83 | toggle() 84 | } 85 | 86 | // 提交表单 87 | async function handleSubmit(values: Dictionary) { 88 | if (modalType === ModalType.CREATE) { 89 | await handleCreateSubmit(values) 90 | } else if (modalType === ModalType.EDIT) { 91 | await handleEditSubmit(values) 92 | } 93 | toggle() 94 | } 95 | 96 | return ( 97 | 100 | {t('EXPORT')} 101 | toggleCreateModal()} 104 | > 105 | {t('CREATE')} 106 | 107 | 108 | } 109 | renderHeader={ 110 | {}} 115 | /> 116 | } 117 | renderTable={ 118 | 119 | rowKey={(record) => record.id} 120 | columns={columns} 121 | dataSource={listData?.records} 122 | scroll={{ 123 | scrollToFirstRowOnChange: true, 124 | x: 1500 125 | }} 126 | loading={isFetching} 127 | pagination={pagination} 128 | /> 129 | } 130 | renderModal={{ 131 | open, 132 | title: getModalTitle(), 133 | onOk: crudForm.submit, 134 | onCancel: toggle, 135 | confirmLoading: isFormSubmitting, 136 | renderContent: ( 137 | handleSubmit(values)} 144 | /> 145 | ) 146 | }} 147 | /> 148 | ) 149 | } 150 | -------------------------------------------------------------------------------- /src/features/menu/get-menu-tree.tsx: -------------------------------------------------------------------------------- 1 | import type { MenuItem } from './types' 2 | 3 | const t = i18n.getFixedT(null, 'MENU') 4 | 5 | export const getMenuTree = (): MenuItem[] => [ 6 | { 7 | label: t('SYSTEM.MANAGEMENT'), 8 | key: '/system', 9 | icon: ( 10 | 14 | ), 15 | children: [ 16 | { 17 | label: t('DICTIONARY.MANAGEMENT'), 18 | key: '/system/dictionaries', 19 | icon: ( 20 | 24 | ) 25 | } 26 | ] 27 | }, 28 | { 29 | label: t('RESOURCES.MANAGEMENT'), 30 | key: '/resources', 31 | icon: ( 32 | 36 | ), 37 | children: [ 38 | { 39 | label: t('LOCALES.MANAGEMENT'), 40 | key: '/resources/locales', 41 | icon: ( 42 | 46 | ) 47 | } 48 | ] 49 | }, 50 | { 51 | label: t('CODE.TEMPLATES'), 52 | key: '/code-templates', 53 | icon: ( 54 | 58 | ), 59 | children: [ 60 | { 61 | label: t('CODE.TEMPLATES.TABLE'), 62 | key: '/code-templates/table', 63 | icon: ( 64 | 68 | ) 69 | }, 70 | { 71 | label: t('CODE.TEMPLATES.CARD'), 72 | key: '/code-templates/card', 73 | icon: ( 74 | 78 | ) 79 | }, 80 | { 81 | label: t('CODE.TEMPLATES.TWO.COL'), 82 | key: '/code-templates/two-col', 83 | icon: ( 84 | 88 | ) 89 | } 90 | ] 91 | }, 92 | { 93 | label: t('MULTI.LEVEL.MENUS'), 94 | key: '/multi-level-menus', 95 | icon: ( 96 | 100 | ), 101 | children: [ 102 | { 103 | label: '2-1', 104 | key: '/multi-level-menus/2-1', 105 | icon: ( 106 | 110 | ), 111 | children: [ 112 | { 113 | label: '2-1-1', 114 | key: '/multi-level-menus/2-1/2-1-1', 115 | icon: ( 116 | 120 | ) 121 | }, 122 | { 123 | label: '2-1-2', 124 | key: '/multi-level-menus/2-1/2-1-2', 125 | icon: ( 126 | 130 | ) 131 | } 132 | ] 133 | }, 134 | { 135 | label: '2-2', 136 | key: '/multi-level-menus/2-2', 137 | icon: ( 138 | 142 | ) 143 | } 144 | ] 145 | }, 146 | { 147 | label: t('ERROR.PAGES'), 148 | key: '/error-pages', 149 | icon: ( 150 | 154 | ), 155 | children: [ 156 | { 157 | label: '403', 158 | key: '/error-pages/403', 159 | icon: ( 160 | 164 | ) 165 | }, 166 | { 167 | label: '404', 168 | key: '/error-pages/404', 169 | icon: ( 170 | 174 | ) 175 | }, 176 | { 177 | label: '418', 178 | key: '/error-pages/418', 179 | icon: ( 180 | 184 | ) 185 | }, 186 | { 187 | label: '500', 188 | key: '/error-pages/500', 189 | icon: ( 190 | 194 | ) 195 | } 196 | ] 197 | } 198 | ] 199 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "dolphin-admin-react", 3 | "description": "🐬 Dolphin Admin React is an open source, lightweight, out-of-the-box, elegant and exquisite, internationalized backend panel template based on React + TypeScript + Vite + antd + TailwindCSS.", 4 | "author": "Bruce Song (https://github.com/recallwei/)", 5 | "repository": { 6 | "type": "git", 7 | "url": "https://github.com/bit-ocean-studio/dolphin-admin-react" 8 | }, 9 | "bugs": { 10 | "url": "https://github.com/bit-ocean-studio/dolphin-admin-react/issues" 11 | }, 12 | "type": "module", 13 | "scripts": { 14 | "dev": "vite", 15 | "build:staging": "pnpm type:check && NODE_ENV=staging vite build --mode staging", 16 | "build:prod": "pnpm type:check && NODE_ENV=production vite build --mode production", 17 | "preview": "vite preview", 18 | "clean": "rimraf dist", 19 | "desktop:dev": "tauri dev", 20 | "desktop:build:staging": "NODE_ENV=staging tauri build --mode staging", 21 | "desktop:build:prod": "NODE_ENV=production tauri build --mode production", 22 | "deploy:staging": "sh scripts/deploy-staging.sh", 23 | "deploy:prod": "sh scripts/deploy-prod.sh", 24 | "lint:check": "pnpm type:check && pnpm cspell:check && pnpm eslint:check && pnpm prettier:check", 25 | "type:check": "tsc --pretty --noEmit --composite false", 26 | "cspell:check": "cspell --no-progress --show-suggestions --show-context --cache **", 27 | "eslint:check": "eslint . --color --cache", 28 | "eslint:fix": "eslint . --color --cache --fix", 29 | "prettier:check": "prettier --check --ignore-unknown .", 30 | "prettier:fix": "prettier --write --ignore-unknown .", 31 | "cz": "git-cz", 32 | "changelog": "conventional-changelog -p angular -i CHANGELOG.md -s && git add CHANGELOG.md", 33 | "prepare": "husky" 34 | }, 35 | "dependencies": { 36 | "@ant-design/cssinjs": "1.19.1", 37 | "@ant-design/happy-work-theme": "^1.0.0", 38 | "@ant-design/icons": "^5.3.6", 39 | "@dolphin-admin/utils": "^0.0.23", 40 | "@tanstack/react-query": "^5.29.2", 41 | "@tanstack/react-query-devtools": "^5.29.2", 42 | "@tauri-apps/api": "^1.5.3", 43 | "ahooks": "^3.7.11", 44 | "antd": "^5.16.2", 45 | "axios": "^1.6.8", 46 | "clsx": "^2.1.0", 47 | "dayjs": "^1.11.10", 48 | "echarts": "^5.5.0", 49 | "i18next": "^23.11.2", 50 | "immer": "^10.0.4", 51 | "lodash-es": "^4.17.21", 52 | "nprogress": "^0.2.0", 53 | "qrcode": "^1.5.3", 54 | "react": "^18.2.0", 55 | "react-dom": "^18.2.0", 56 | "react-i18next": "^14.1.0", 57 | "react-router-dom": "^6.22.3", 58 | "socket.io-client": "^4.7.5", 59 | "use-immer": "^0.9.0", 60 | "xlsx": "https://cdn.sheetjs.com/xlsx-0.20.0/xlsx-0.20.0.tgz", 61 | "zustand": "^4.5.2" 62 | }, 63 | "devDependencies": { 64 | "@bit-ocean/commitlint-config": "^0.0.17", 65 | "@bit-ocean/cspell": "^0.0.17", 66 | "@bit-ocean/eslint-config": "^0.0.17", 67 | "@bit-ocean/prettier-config": "^0.0.17", 68 | "@bit-ocean/tsconfig": "^0.0.17", 69 | "@brucesong/eslint-config-react": "^1.0.22", 70 | "@commitlint/cli": "^19.2.2", 71 | "@commitlint/config-conventional": "^19.2.2", 72 | "@dolphin-admin/auto-import": "^0.0.12", 73 | "@dolphin-admin/bootstrap-animation": "^0.0.6", 74 | "@iconify/json": "^2.2.201", 75 | "@iconify/react": "^4.1.1", 76 | "@svgr/core": "^8.1.0", 77 | "@svgr/plugin-jsx": "^8.1.0", 78 | "@tauri-apps/cli": "^1.5.11", 79 | "@types/lodash-es": "^4.17.12", 80 | "@types/nprogress": "^0.2.3", 81 | "@types/qrcode": "^1.5.5", 82 | "@types/react": "^18.2.79", 83 | "@types/react-dom": "^18.2.25", 84 | "@vitejs/plugin-react-swc": "^3.6.0", 85 | "autoprefixer": "^10.4.19", 86 | "commitizen": "^4.3.0", 87 | "cspell": "^8.7.0", 88 | "cz-git": "^1.9.1", 89 | "eslint": "^8.57.0", 90 | "husky": "^9.0.11", 91 | "lint-staged": "^15.2.2", 92 | "postcss": "^8.4.38", 93 | "prettier": "^3.2.5", 94 | "rimraf": "^5.0.5", 95 | "rollup-plugin-visualizer": "^5.12.0", 96 | "sass": "^1.75.0", 97 | "tailwindcss": "^3.4.3", 98 | "typescript": "^5.4.5", 99 | "unplugin-auto-import": "^0.17.5", 100 | "unplugin-auto-import-ahooks": "^0.0.2", 101 | "unplugin-auto-import-antd": "^0.0.1", 102 | "unplugin-icons": "^0.18.5", 103 | "unplugin-info": "^1.1.0", 104 | "vite": "^5.2.9", 105 | "vite-plugin-compression": "^0.5.1" 106 | }, 107 | "config": { 108 | "commitizen": { 109 | "path": "node_modules/cz-git" 110 | } 111 | }, 112 | "private": true, 113 | "license": "MIT" 114 | } 115 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import { fileURLToPath, URL } from 'node:url' 2 | 3 | import { dolphinAdminPresets } from '@dolphin-admin/auto-import' 4 | import { BootstrapAnimation } from '@dolphin-admin/bootstrap-animation' 5 | import ReactSWC from '@vitejs/plugin-react-swc' 6 | import { visualizer } from 'rollup-plugin-visualizer' 7 | import AutoImport from 'unplugin-auto-import/vite' 8 | import AhooksResolver from 'unplugin-auto-import-ahooks' 9 | import AntdResolver from 'unplugin-auto-import-antd' 10 | import Icons from 'unplugin-icons/vite' 11 | import Info from 'unplugin-info/vite' 12 | import type { ProxyOptions } from 'vite' 13 | import { defineConfig, loadEnv } from 'vite' 14 | import ViteCompression from 'vite-plugin-compression' 15 | 16 | export default defineConfig(({ mode }) => { 17 | const env = loadEnv(mode, process.cwd()) as ImportMetaEnv 18 | const { 19 | VITE_PORT, 20 | VITE_BASE_API_PREFIX, 21 | VITE_BASE_API_URL, 22 | VITE_MOCK_API_PREFIX, 23 | VITE_MOCK_API_URL 24 | } = env 25 | 26 | const port = Number.parseInt(VITE_PORT, 10) || 5173 27 | const proxy: Record = { 28 | [VITE_BASE_API_PREFIX]: { 29 | target: VITE_BASE_API_URL, 30 | changeOrigin: true, 31 | rewrite: (path: string) => path.replace(VITE_BASE_API_PREFIX, '') 32 | }, 33 | [VITE_MOCK_API_PREFIX]: { 34 | target: VITE_MOCK_API_URL, 35 | changeOrigin: true, 36 | rewrite: (path: string) => path.replace(VITE_MOCK_API_PREFIX, '') 37 | }, 38 | '/socket.io': { 39 | target: VITE_BASE_API_URL, 40 | ws: true, 41 | changeOrigin: true 42 | } 43 | } 44 | 45 | return { 46 | base: '/', 47 | plugins: [ 48 | ReactSWC(), 49 | AutoImport({ 50 | dts: '@types/auto-imports.d.ts', 51 | include: [ 52 | /\.[tj]sx?$/, // .ts, .tsx, .js, .jsx 53 | /\.md$/ // .md 54 | ], 55 | imports: [ 56 | 'react', 57 | 'react-router-dom', 58 | 'react-i18next', 59 | { 60 | from: '@tanstack/react-query', 61 | imports: ['useQueryClient', 'useQuery', 'useQueries', 'useMutation', 'keepPreviousData'] 62 | }, 63 | { from: 'clsx', imports: [['default', 'clsx']] }, 64 | { from: 'react', imports: ['Suspense'] }, 65 | { from: 'use-immer', imports: ['useImmer'] }, 66 | { from: '@iconify/react', imports: ['Icon'] }, 67 | { from: '@ant-design/icons', imports: [['default', 'AIcon']] }, 68 | { from: '@/constants', imports: ['AppMetadata', 'GlobalEnvConfig', 'BasePageModel'] }, 69 | { from: '@/i18n', imports: [['default', 'i18n']] }, 70 | ...dolphinAdminPresets 71 | ], 72 | resolvers: [AntdResolver({ prefix: 'A' }), AhooksResolver()], 73 | dirs: [ 74 | 'src/api/**', 75 | 'src/components/**', 76 | 'src/hooks/**', 77 | 'src/layouts/*/index.tsx', 78 | 'src/providers/**', 79 | 'src/store/**' 80 | ] 81 | }), 82 | Icons({ 83 | autoInstall: true, 84 | compiler: 'jsx', 85 | jsx: 'react' 86 | }), 87 | ViteCompression({ 88 | verbose: true, // 是否在控制台中输出压缩结果 89 | disable: true, 90 | threshold: 10240, // 体积过小时不压缩 91 | algorithm: 'gzip', // 压缩算法 92 | ext: '.gz', 93 | deleteOriginFile: true // 源文件压缩后是否删除 94 | }), 95 | visualizer({ open: false, gzipSize: true }), 96 | BootstrapAnimation(), 97 | Info() 98 | ], 99 | resolve: { 100 | alias: { 101 | '@': fileURLToPath(new URL('./src', import.meta.url)) 102 | }, 103 | extensions: ['.mjs', '.js', '.ts', '.jsx', '.tsx', '.json'] 104 | }, 105 | esbuild: { 106 | drop: mode === 'production' ? ['console', 'debugger'] : [] 107 | }, 108 | server: { 109 | host: true, 110 | port, 111 | strictPort: true, 112 | open: false, 113 | proxy 114 | }, 115 | preview: { 116 | host: true, 117 | port, 118 | strictPort: true, 119 | open: false, 120 | proxy 121 | }, 122 | /** 123 | * Tauri 相关配置 124 | * @see https://tauri.app/zh-cn/v1/guides/getting-started/setup/vite 125 | */ 126 | clearScreen: false, 127 | envPrefix: [ 128 | 'VITE_', 129 | 'TAURI_PLATFORM', 130 | 'TAURI_ARCH', 131 | 'TAURI_FAMILY', 132 | 'TAURI_PLATFORM_VERSION', 133 | 'TAURI_PLATFORM_TYPE', 134 | 'TAURI_DEBUG' 135 | ], 136 | build: { 137 | rollupOptions: { 138 | output: { 139 | manualChunks: { 140 | axios: ['axios'], 141 | antd: ['antd'], 142 | 'lodash-es': ['lodash-es'] 143 | } 144 | } 145 | }, 146 | // Tauri 在 Windows 上使用 Chromium,在 macOS 和 Linux 上使用 WebKit 147 | target: process.env.TAURI_PLATFORM === 'windows' ? 'chrome105' : 'esnext', 148 | // 调试构建时禁用压缩 149 | minify: !process.env.TAURI_DEBUG ? 'esbuild' : false, 150 | // 为调试构建生成源代码映射 (sourcemap) 151 | sourcemap: !!process.env.TAURI_DEBUG 152 | } 153 | } 154 | }) 155 | -------------------------------------------------------------------------------- /src/router/index.tsx: -------------------------------------------------------------------------------- 1 | import { createBrowserRouter } from 'react-router-dom' 2 | 3 | import type { CustomRouteObject, RouteMetadata } from '@/features/router' 4 | 5 | import Root from '../Root' 6 | 7 | const t = i18n.getFixedT(null, 'MENU') 8 | 9 | export const routes: CustomRouteObject[] = [ 10 | { 11 | path: '/', 12 | Component: Root, 13 | children: [ 14 | { 15 | path: '/', 16 | Component: DpBaseLayout, 17 | children: [ 18 | { 19 | index: true, 20 | lazy: () => import('@/pages'), 21 | meta: { title: () => t('HOME') } 22 | }, 23 | { 24 | path: '/system/dictionaries', 25 | lazy: () => import('@/pages/system/dictionaries'), 26 | meta: { title: () => t('DICTIONARY.MANAGEMENT'), icon: 'Dictionary' } 27 | }, 28 | { 29 | path: '/resources/locales', 30 | lazy: () => import('@/pages/resources/locales'), 31 | meta: { title: () => t('LOCALES.MANAGEMENT'), icon: 'Lang' } 32 | }, 33 | { 34 | path: '/code-templates/table', 35 | lazy: () => import('@/pages/code-templates/table'), 36 | meta: { title: () => t('CODE.TEMPLATES.TABLE'), icon: 'Table' } 37 | }, 38 | { 39 | path: '/code-templates/card', 40 | lazy: () => import('@/pages/code-templates/card'), 41 | meta: { title: () => t('CODE.TEMPLATES.CARD'), icon: 'Books' } 42 | }, 43 | { 44 | path: '/code-templates/two-col', 45 | lazy: () => import('@/pages/code-templates/two-col'), 46 | meta: { title: () => t('CODE.TEMPLATES.TWO.COL') } 47 | }, 48 | { 49 | path: '/multi-level-menus/2-1/2-1-1', 50 | lazy: () => import('@/pages/multi-level-menus/2-1/2-1-1'), 51 | meta: { title: '2-1-1', icon: 'Menu' } 52 | }, 53 | { 54 | path: '/multi-level-menus/2-1/2-1-2', 55 | lazy: () => import('@/pages/multi-level-menus/2-1/2-1-2'), 56 | meta: { title: '2-1-2', icon: 'Menu' } 57 | }, 58 | { 59 | path: '/multi-level-menus/2-2', 60 | lazy: () => import('@/pages/multi-level-menus/2-2'), 61 | meta: { title: '2-2', icon: 'Menu' } 62 | }, 63 | { 64 | path: '/error-pages/403', 65 | lazy: () => import('@/pages/error-pages/403'), 66 | meta: { title: '403', icon: '403' } 67 | }, 68 | { 69 | path: '/error-pages/404', 70 | lazy: () => import('@/pages/error-pages/404'), 71 | meta: { title: '404', icon: '404' } 72 | }, 73 | { 74 | path: '/error-pages/418', 75 | lazy: () => import('@/pages/error-pages/418'), 76 | meta: { title: '418', icon: '418' } 77 | }, 78 | { 79 | path: '/error-pages/500', 80 | lazy: () => import('@/pages/error-pages/500'), 81 | meta: { title: '500', icon: '500' } 82 | }, 83 | { 84 | path: '*', 85 | lazy: () => import('@/pages/error-pages/404'), 86 | meta: { title: '404', icon: '404' } 87 | } 88 | ] 89 | }, 90 | { 91 | path: '/', 92 | Component: DpAuthLayout, 93 | children: [ 94 | { 95 | path: '/login', 96 | lazy: () => import('@/pages/login'), 97 | meta: { title: () => t('LOGIN') } 98 | }, 99 | { 100 | path: '/signup', 101 | lazy: () => import('@/pages/signup'), 102 | meta: { title: () => t('SIGN.UP') } 103 | } 104 | ] 105 | } 106 | ] 107 | } 108 | ] 109 | 110 | /** 111 | * @see {@link https://github.com/remix-run/react-router/discussions/9915} 112 | */ 113 | const router: ReturnType = createBrowserRouter(routes) 114 | 115 | // 路由元数据缓存 116 | export const routeMetadataCacheMap = new Map() 117 | 118 | /** 119 | * 递归匹配获取当前路由的元数据 120 | * @description 先从缓存中获取,如果缓存中没有,则递归匹配,匹配到后缓存结果 121 | */ 122 | export function getRouteMetadata( 123 | path: string, 124 | routeList: CustomRouteObject[] 125 | ): RouteMetadata | undefined { 126 | // 优先从缓存中获取 127 | if (routeMetadataCacheMap.has(path)) { 128 | return routeMetadataCacheMap.get(path) 129 | } 130 | 131 | // 匹配当前路由 132 | const route = routeList.find((r) => r.path === path) 133 | if (route) { 134 | // 缓存结果 135 | routeMetadataCacheMap.set(path, route.meta) 136 | return route.meta 137 | } 138 | 139 | // 递归匹配子路由 140 | // eslint-disable-next-line no-restricted-syntax 141 | for (const r of routeList) { 142 | if (r.children) { 143 | const meta = getRouteMetadata(path, r.children) 144 | if (meta) { 145 | // 缓存结果 146 | routeMetadataCacheMap.set(path, meta) 147 | return meta 148 | } 149 | } 150 | } 151 | // 缓存结果 152 | routeMetadataCacheMap.set(path, undefined) 153 | return undefined 154 | } 155 | 156 | export default router 157 | -------------------------------------------------------------------------------- /src/pages/login/index.tsx: -------------------------------------------------------------------------------- 1 | import type { LoginModel, Tokens } from '@/api/auth.type' 2 | 3 | import { Header, ThirdPartyLogin } from './components' 4 | import { UserNameLoginType } from './enum' 5 | import { useHandleLoginResult, useLoginForm, useRedirect } from './hooks' 6 | 7 | export function Component() { 8 | const { t } = useTranslation(['AUTH', 'VALIDATION', 'USER']) 9 | const { handleLoginResult } = useHandleLoginResult() 10 | const { handleRedirect, handleForgotPassword, handleSignup } = useRedirect() 11 | const { loginForm, clearPassword, handleAutoComplete, handleRememberPassword } = useLoginForm() 12 | 13 | // 登录请求 14 | const loginMutation = useMutation({ 15 | mutationFn: (data: LoginModel) => AuthAPI.login(data), 16 | onSuccess: onLoginSuccess, 17 | onError: clearPassword 18 | }) 19 | 20 | // 登录 21 | const handleLogin = (type: UserNameLoginType) => { 22 | handleAutoComplete(type) 23 | loginForm.submit() 24 | } 25 | 26 | // 登录成功 27 | function onLoginSuccess(data: Tokens) { 28 | // 处理登录结果 29 | handleLoginResult(data) 30 | // 记住密码写入 localStorage 31 | handleRememberPassword() 32 | // 处理重定向 33 | handleRedirect() 34 | } 35 | 36 | return ( 37 |
38 |
39 | 40 | loginMutation.mutate(values)} 44 | autoComplete="off" 45 | disabled={loginMutation.isPending} 46 | > 47 | 52 | 58 | } 59 | placeholder={t('USER:USERNAME')} 60 | autoComplete="username" 61 | allowClear 62 | /> 63 | 64 | 69 | 75 | } 76 | placeholder={t('USER:PASSWORD')} 77 | autoComplete="current-password" 78 | /> 79 | 80 | 81 |
82 | 87 | {t('USER:CONFIRM.PASSWORD')} 88 | 89 | 90 | 91 | 92 | 97 | 98 | {t('FORGOT.PASSWORD')} 99 | 100 | 101 | 102 | 103 |
104 | 105 | 106 |
107 | handleLogin(UserNameLoginType.BASIC)} 112 | > 113 | {t('LOGIN')} 114 | 115 | 116 | 117 | 118 |
119 | handleLogin(UserNameLoginType.ADMIN)} 124 | > 125 | {t('LOGIN.AS.ADMIN')} 126 | 127 | handleLogin(UserNameLoginType.VISITOR)} 132 | > 133 | {t('LOGIN.AS.VISITOR')} 134 | 135 |
136 |
137 |
138 | 139 |
140 | {t('NEED.ACCOUNT')} 141 | 142 | 147 | 148 | {t('SIGN.UP')} 149 | 150 | 151 | 152 |
153 | 154 | 155 |
156 |
157 | ) 158 | } 159 | -------------------------------------------------------------------------------- /src/constants/menu.tsx: -------------------------------------------------------------------------------- 1 | import type { MenuItem } from '@/features/menu' 2 | 3 | const t = i18n.getFixedT(null, 'MENU') 4 | 5 | export const getMenuTree = (): MenuItem[] => [ 6 | { 7 | label: t('SYSTEM.MANAGEMENT'), 8 | key: '/system', 9 | icon: ( 10 | 14 | ), 15 | children: [ 16 | { 17 | label: t('DICTIONARY.MANAGEMENT'), 18 | key: '/system/dictionaries', 19 | icon: ( 20 | 24 | ) 25 | } 26 | ] 27 | }, 28 | { 29 | label: t('RESOURCES.MANAGEMENT'), 30 | key: '/resources', 31 | icon: ( 32 | 36 | ), 37 | children: [ 38 | { 39 | label: t('LOCALES.MANAGEMENT'), 40 | key: '/resources/locales', 41 | icon: ( 42 | 46 | ) 47 | } 48 | ] 49 | }, 50 | { 51 | label: t('CODE.TEMPLATES'), 52 | key: '/code-templates', 53 | icon: ( 54 | 58 | ), 59 | children: [ 60 | { 61 | label: t('CODE.TEMPLATES.TABLE'), 62 | key: '/code-templates/table', 63 | icon: ( 64 | 68 | ) 69 | }, 70 | { 71 | label: t('CODE.TEMPLATES.CARD'), 72 | key: '/code-templates/card', 73 | icon: ( 74 | 78 | ) 79 | }, 80 | { 81 | label: t('CODE.TEMPLATES.TWO.COL'), 82 | key: '/code-templates/two-col', 83 | icon: ( 84 | 88 | ) 89 | } 90 | ] 91 | }, 92 | { 93 | label: t('MULTI.LEVEL.MENUS'), 94 | key: '/multi-level-menus', 95 | icon: ( 96 | 100 | ), 101 | children: [ 102 | { 103 | label: '2-1', 104 | key: '/multi-level-menus/2-1', 105 | icon: ( 106 | 110 | ), 111 | children: [ 112 | { 113 | label: '2-1-1', 114 | key: '/multi-level-menus/2-1/2-1-1', 115 | icon: ( 116 | 120 | ) 121 | }, 122 | { 123 | label: '2-1-2', 124 | key: '/multi-level-menus/2-1/2-1-2', 125 | icon: ( 126 | 130 | ) 131 | } 132 | ] 133 | }, 134 | { 135 | label: '2-2', 136 | key: '/multi-level-menus/2-2', 137 | icon: ( 138 | 142 | ) 143 | } 144 | ] 145 | }, 146 | { 147 | label: t('ERROR.PAGES'), 148 | key: '/error-pages', 149 | icon: ( 150 | 154 | ), 155 | children: [ 156 | { 157 | label: '403', 158 | key: '/error-pages/403', 159 | icon: ( 160 | 164 | ) 165 | }, 166 | { 167 | label: '404', 168 | key: '/error-pages/404', 169 | icon: ( 170 | 174 | ) 175 | }, 176 | { 177 | label: '418', 178 | key: '/error-pages/418', 179 | icon: ( 180 | 184 | ) 185 | }, 186 | { 187 | label: '500', 188 | key: '/error-pages/500', 189 | icon: ( 190 | 194 | ) 195 | } 196 | ] 197 | } 198 | ] 199 | 200 | // 菜单数据缓存 201 | export const menuCacheMap = new Map() 202 | 203 | /** 204 | * 根据当前路由路径递归匹配菜单数据 205 | * @description 先从缓存中获取,如果缓存中没有,则递归匹配,匹配到后缓存结果 206 | */ 207 | export function getMenuItem(key: string, menuTree: MenuItem[]): MenuItem | undefined { 208 | // 优先从缓存中获取 209 | if (menuCacheMap.has(key)) { 210 | return menuCacheMap.get(key) 211 | } 212 | 213 | // 匹配当前菜单数据 214 | const menu = menuTree.find((m) => m?.key === key) 215 | if (menu) { 216 | // 缓存结果 217 | menuCacheMap.set(key, menu) 218 | return menu 219 | } 220 | 221 | // 递归匹配子路由 222 | // eslint-disable-next-line no-restricted-syntax 223 | for (const r of menuTree) { 224 | const { children } = (r as any) ?? {} 225 | if (children) { 226 | const menuItem = getMenuItem(key, children) 227 | if (menuItem) { 228 | // 缓存结果 229 | menuCacheMap.set(key, menuItem) 230 | return menuItem 231 | } 232 | } else { 233 | return undefined 234 | } 235 | } 236 | // 缓存结果 237 | menuCacheMap.set(key, undefined) 238 | return undefined 239 | } 240 | -------------------------------------------------------------------------------- /src/pages/code-templates/table/index.tsx: -------------------------------------------------------------------------------- 1 | import type { ColumnsType } from 'antd/es/table' 2 | 3 | import type { Setting } from '@/api/settings.type' 4 | 5 | export function Component() { 6 | const { t } = useTranslation('COMMON') 7 | const response = useResponsive() 8 | const queryClient = useQueryClient() 9 | const [searchText, setSearchText] = useState('') 10 | const searchRef = useRef('') 11 | 12 | const [pagination, setPagination] = useImmer({ 13 | current: 1, 14 | pageSize: 10, 15 | total: 0 16 | }) 17 | 18 | const templateQuery = useQuery({ 19 | queryKey: [ 20 | SettingAPI.LIST_QUERY_KEY, 21 | pagination.current, 22 | pagination.pageSize, 23 | searchRef.current 24 | ], 25 | queryFn: () => 26 | SettingAPI.list( 27 | new BasePageModel({ 28 | pageSize: pagination.pageSize, 29 | page: pagination.current, 30 | keywords: searchRef.current 31 | }) 32 | ), 33 | placeholderData: keepPreviousData 34 | }) 35 | 36 | const enableMutation = useMutation({ 37 | mutationFn: (id: number) => SettingAPI.patch(id, { enabled: true }), 38 | onSuccess: () => { 39 | queryClient.invalidateQueries({ 40 | queryKey: [SettingAPI.LIST_QUERY_KEY] 41 | }) 42 | } 43 | }) 44 | 45 | const disableMutation = useMutation({ 46 | mutationFn: (id: number) => SettingAPI.patch(id, { enabled: false }), 47 | onSuccess: () => { 48 | queryClient.invalidateQueries({ 49 | queryKey: [SettingAPI.LIST_QUERY_KEY] 50 | }) 51 | } 52 | }) 53 | 54 | const deleteMutation = useMutation({ 55 | mutationFn: (id: number) => SettingAPI.delete(id), 56 | onSuccess: () => { 57 | queryClient.invalidateQueries({ 58 | queryKey: [SettingAPI.LIST_QUERY_KEY] 59 | }) 60 | } 61 | }) 62 | 63 | useEffect(() => { 64 | if (templateQuery.data?.total) { 65 | setPagination((draft) => { 66 | draft.total = templateQuery.data.total 67 | }) 68 | } 69 | }, [templateQuery.data?.total, setPagination]) 70 | 71 | const columns: ColumnsType = [ 72 | { title: 'ID', dataIndex: 'id', key: 'id', fixed: 'left', align: 'center', width: 60 }, 73 | { title: 'Label', dataIndex: 'label', key: 'label', fixed: 'left', width: 200 }, 74 | { title: 'Key', dataIndex: 'key', key: 'key', width: 200 }, 75 | { title: 'Value', dataIndex: 'value', key: 'value', width: 200 }, 76 | { 77 | title: 'Enabled', 78 | dataIndex: 'enabled', 79 | key: 'enabled', 80 | width: 100, 81 | align: 'center', 82 | render: (value) => value && 83 | }, 84 | { title: 'Sort', dataIndex: 'sort', key: 'sort', width: 100, align: 'center' }, 85 | { title: 'Remark', dataIndex: 'remark', key: 'remark', ellipsis: { showTitle: true } }, 86 | { 87 | title: 'Action', 88 | align: 'center', 89 | fixed: 'right', 90 | width: 250, 91 | render: (_, record) => ( 92 | 93 | 编辑 94 | {record.enabled ? ( 95 | handleDisable(record.id)} 104 | > 105 | 109 | 禁用 110 | 111 | 112 | ) : ( 113 | handleEnable(record.id)} 122 | > 123 | 启用 124 | 125 | )} 126 | handleDelete(record.id)} 135 | > 136 | 140 | 删除 141 | 142 | 143 | 144 | ) 145 | } 146 | ] 147 | 148 | // 启用 149 | async function handleEnable(id: number) { 150 | await enableMutation.mutateAsync(id) 151 | } 152 | 153 | // 禁用 154 | async function handleDisable(id: number) { 155 | await disableMutation.mutateAsync(id) 156 | } 157 | 158 | // 删除 159 | async function handleDelete(id: number) { 160 | await deleteMutation.mutateAsync(id) 161 | } 162 | 163 | return ( 164 | 新增} 166 | renderHeader={ 167 | { 172 | searchRef.current = searchText 173 | }} 174 | /> 175 | } 176 | renderTable={ 177 | 178 | columns={columns} 179 | dataSource={templateQuery.data?.records} 180 | scroll={{ 181 | scrollToFirstRowOnChange: true, 182 | x: 1500 183 | }} 184 | loading={templateQuery.isFetching} 185 | pagination={{ 186 | ...pagination, 187 | onChange: (page, pageSize) => { 188 | setPagination((draft) => { 189 | draft.current = page 190 | draft.pageSize = pageSize 191 | }) 192 | }, 193 | rootClassName: '!mb-0', 194 | size: response.sm ? 'default' : 'small', 195 | showSizeChanger: true, 196 | showQuickJumper: true, 197 | showTotal: (total) => t('SHOW.TOTAL', { total }) 198 | }} 199 | /> 200 | } 201 | /> 202 | ) 203 | } 204 | -------------------------------------------------------------------------------- /src/api/axios.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | AxiosError, 3 | AxiosInstance, 4 | AxiosRequestConfig, 5 | InternalAxiosRequestConfig 6 | } from 'axios' 7 | import axios from 'axios' 8 | import { createSearchParams } from 'react-router-dom' 9 | 10 | import type { BasePageModel } from '@/constants' 11 | import { errorMessageMap } from '@/maps' 12 | import router from '@/router' 13 | 14 | import { StatusCode } from './axios.enum' 15 | import type { PendingTask, R } from './axios.type' 16 | 17 | const t = i18n.getFixedT(null, 'COMMON') 18 | 19 | class HttpRequest { 20 | /** 21 | * Axios 实例 22 | */ 23 | instance: AxiosInstance 24 | 25 | /** 26 | * 刷新令牌的标识 27 | * @description `true` 表示正在刷新令牌 28 | */ 29 | isRefreshing = false 30 | 31 | /** 32 | * 等待请求队列 33 | */ 34 | pendingQueue: PendingTask[] = [] 35 | 36 | // Axios 配置 37 | private readonly config: AxiosRequestConfig = { 38 | baseURL: GlobalEnvConfig.BASE_API_PREFIX, 39 | timeout: 30000, 40 | withCredentials: true, 41 | headers: { 42 | 'Content-Type': 'application/json;charset=utf-8' 43 | } 44 | } 45 | 46 | public constructor() { 47 | this.instance = axios.create(this.config) 48 | this.initInterceptors() 49 | } 50 | 51 | initInterceptors() { 52 | this.instance.interceptors.request.use( 53 | (req: InternalAxiosRequestConfig) => { 54 | // 设置 token 55 | const { url } = req 56 | // 如果是 Base API 接口请求,添加 token 57 | if (AuthUtils.isAuthenticated() && url?.startsWith(GlobalEnvConfig.BASE_API_PREFIX)) { 58 | req.headers.Authorization = AuthUtils.getAuthorization() 59 | } 60 | // 设置语言 61 | req.headers['Accept-Language'] = LangUtils.getDefaultLang() 62 | return req 63 | }, 64 | (err: AxiosError) => Promise.reject(err) 65 | ) 66 | 67 | this.instance.interceptors.response.use( 68 | (res) => { 69 | const { data, msg } = res.data ?? {} 70 | if (msg) { 71 | AMessage.success(msg) 72 | } 73 | return data 74 | }, 75 | async (err: AxiosError) => { 76 | const { response, config } = err 77 | const { data, status } = response ?? {} 78 | const { msg } = data ?? {} 79 | // 处理取消的请求 80 | if (axios.isCancel(err)) { 81 | return Promise.reject(err) 82 | } 83 | // 当前接口是否是刷新令牌接口 84 | const isRefreshTokenAPI = config?.url?.includes(AuthAPI.REFRESH_API_URL) 85 | /** 86 | * 处理刷新令牌接口的认证失败 87 | * @description 88 | * - 刷新标识置为 false 89 | * - 清除 token 90 | * - 清空请求队列 91 | */ 92 | if (isRefreshTokenAPI) { 93 | this.isRefreshing = false 94 | this.pendingQueue = [] 95 | return Promise.reject(data) 96 | } 97 | // 如果正在刷新令牌,将当前失败的请求加入待请求队列 98 | if (this.isRefreshing) { 99 | return new Promise((resolve) => { 100 | this.pendingQueue.push({ config, resolve }) 101 | }) 102 | } 103 | /** 104 | * 处理响应状态码 105 | * @description 根据响应状态码进行相应的处理 106 | * - 401 未授权,刷新 token 或认证失败并跳转到登录页 107 | * - 403 禁止访问,提示用户无权限访问 108 | * - 404 未找到,跳转到 404 页面 109 | * - 500 服务器错误,跳转到 500 页面 110 | * - 其他状态码,提示错误信息 111 | */ 112 | const errorMessage = msg ?? errorMessageMap.get(status as number) ?? t('UNKNOWN.ERROR') 113 | const currentRefreshToken = AuthUtils.getRefreshToken() 114 | switch (status) { 115 | case StatusCode.UNAUTHORIZED: 116 | // 存在刷新令牌,认证令牌过期时,需要通过刷新令牌获取新的认证令牌 117 | if (currentRefreshToken) { 118 | this.isRefreshing = true 119 | try { 120 | const { refreshToken, accessToken } = await AuthAPI.refresh(currentRefreshToken) 121 | AuthUtils.setAccessToken(accessToken) 122 | AuthUtils.setRefreshToken(refreshToken) 123 | this.isRefreshing = false 124 | if (config) { 125 | // 重新发起上次失败的请求 126 | const res = await this.request({ 127 | ...config, 128 | headers: { ...config.headers, Authorization: AuthUtils.getAuthorization() } 129 | }) 130 | // 刷新了认证令牌后,将待请求队列的请求重新发起 131 | if (this.pendingQueue.length > 0) { 132 | this.pendingQueue.forEach((task) => task.resolve(this.request(task.config!))) 133 | this.pendingQueue = [] 134 | } 135 | return res 136 | } 137 | } catch { 138 | // 处理刷新令牌认证失败的情况 139 | } 140 | } 141 | // 处理认证失败 142 | this.handleUnauthorized() 143 | AMessage.error(errorMessage) 144 | break 145 | case StatusCode.FORBIDDEN: 146 | AMessage.error(errorMessage) 147 | router.navigate('/403', { replace: true }) 148 | break 149 | case StatusCode.INTERNAL_SERVER_ERROR: 150 | case StatusCode.BAD_GATEWAY: 151 | case StatusCode.GATEWAY_TIMEOUT: 152 | AMessage.error(errorMessage) 153 | router.navigate('/500', { replace: true }) 154 | break 155 | default: 156 | AMessage.error(errorMessage) 157 | break 158 | } 159 | // 网络错误,跳转到 404 页面 160 | if (!window.navigator.onLine) { 161 | router.navigate('/404', { replace: true }) 162 | AMessage.error(t('NETWORK.ERROR')) 163 | } 164 | return Promise.reject(data) 165 | } 166 | ) 167 | } 168 | 169 | /** 170 | * 处理认证失败 171 | * @description 172 | * - 清除 token 173 | * - 跳转到登录页 174 | */ 175 | handleUnauthorized() { 176 | AuthUtils.clearAccessToken() 177 | AuthUtils.clearRefreshToken() 178 | // 如果非登录页面,需要重定向到登录页,且需要带上 redirect 参数 179 | const { pathname } = router.state.location 180 | const search = 181 | pathname === '/login' ? '' : `?${createSearchParams({ redirect: pathname }).toString()}` 182 | router.navigate({ pathname: '/login', search }, { replace: true }) 183 | } 184 | 185 | /** 186 | * 通用请求 187 | * @param config 请求配置 188 | */ 189 | request(config: AxiosRequestConfig): Promise { 190 | return this.instance.request(config) 191 | } 192 | 193 | /** 194 | * GET 请求 195 | * @param url 请求地址 196 | * @param params 请求参数 197 | * @param config 请求配置 198 | */ 199 | get( 200 | url: string, 201 | params?: Record | BasePageModel, 202 | config?: AxiosRequestConfig 203 | ): Promise { 204 | return this.instance.get(url, { params, ...config }) 205 | } 206 | 207 | /** 208 | * POST 请求 209 | * @param url 请求地址 210 | * @param data 请求数据 211 | * @param config 请求配置 212 | */ 213 | post(url: string, data?: Record, config?: AxiosRequestConfig): Promise { 214 | return this.instance.post(url, data, config) 215 | } 216 | 217 | /** 218 | * PUT 请求 219 | * @param url 请求地址 220 | * @param data 请求数据 221 | * @param config 请求配置 222 | */ 223 | put(url: string, data?: Record, config?: AxiosRequestConfig): Promise { 224 | return this.instance.put(url, data, config) 225 | } 226 | 227 | /** 228 | * DELETE 请求 229 | * @param url 请求地址 230 | * @param params 请求参数 231 | * @param config 请求配置 232 | */ 233 | delete( 234 | url: string, 235 | params?: Record, 236 | config?: AxiosRequestConfig 237 | ): Promise { 238 | return this.instance.delete(url, { params, ...config }) 239 | } 240 | 241 | /** 242 | * PATCH 请求 243 | * @param url 请求地址 244 | * @param data 请求数据 245 | * @param config 请求配置 246 | */ 247 | patch(url: string, data?: Record, config?: AxiosRequestConfig): Promise { 248 | return this.instance.patch(url, data, config) 249 | } 250 | } 251 | 252 | export const httpRequest = new HttpRequest() 253 | -------------------------------------------------------------------------------- /src/pages/code-templates/card/index.tsx: -------------------------------------------------------------------------------- 1 | import ValueIcon from '~icons/carbon/character-upper-case' 2 | import RemarkIcon from '~icons/mdi/comment-multiple-outline' 3 | import DeleteIcon from '~icons/mdi/delete-forever-outline' 4 | import EnableIcon from '~icons/mdi/lock-open-outline' 5 | import DisableIcon from '~icons/mdi/lock-outline' 6 | import EditIcon from '~icons/mdi/pencil' 7 | import KeyIcon from '~icons/solar/key-outline' 8 | 9 | export function Component() { 10 | const { t } = useTranslation() 11 | const response = useResponsive() 12 | const queryClient = useQueryClient() 13 | const { message } = AApp.useApp() 14 | const hoverDisplay = useHoverDisplay() 15 | 16 | const [pagination, setPagination] = useImmer({ 17 | current: 1, 18 | pageSize: 10, 19 | total: 0 20 | }) 21 | 22 | const templateQuery = useQuery({ 23 | queryKey: [SettingAPI.LIST_QUERY_KEY, pagination.pageSize, pagination.current], 24 | queryFn: () => 25 | SettingAPI.list( 26 | new BasePageModel({ 27 | pageSize: pagination.pageSize, 28 | page: pagination.current 29 | }) 30 | ), 31 | placeholderData: keepPreviousData 32 | }) 33 | 34 | const toggleEnableMutation = useMutation({ 35 | mutationFn: ({ id, enabled }: { id: number; enabled: boolean }) => 36 | SettingAPI.patch(id, { enabled }), 37 | onMutate: async ({ id, enabled }) => { 38 | await queryClient.cancelQueries({ 39 | queryKey: [SettingAPI.LIST_QUERY_KEY, pagination.pageSize, pagination.current] 40 | }) 41 | const previous = await queryClient.getQueryData([ 42 | SettingAPI.LIST_QUERY_KEY, 43 | pagination.pageSize, 44 | pagination.current 45 | ]) 46 | await queryClient.setQueryData( 47 | [SettingAPI.LIST_QUERY_KEY, pagination.pageSize, pagination.current], 48 | (old: any) => { 49 | const records = [...old.data.records] 50 | const index = records.findIndex((item: any) => item.id === id) 51 | records[index].enabled = enabled 52 | return { 53 | ...old, 54 | records 55 | } 56 | } 57 | ) 58 | return { previous } 59 | }, 60 | onError: (err, newTodo, context) => { 61 | queryClient.setQueryData( 62 | [SettingAPI.LIST_QUERY_KEY, pagination.pageSize, pagination.current], 63 | context?.previous 64 | ) 65 | message.success('操作失败') 66 | }, 67 | // onSettled: () => { 68 | // queryClient.invalidateQueries({ 69 | // queryKey: [SettingAPI.LIST_QUERY_KEY, pagination.pageSize, pagination.current] 70 | // }) 71 | // }, 72 | onSuccess: () => { 73 | message.success('操作成功') 74 | } 75 | }) 76 | 77 | const deleteMutation = useMutation({ 78 | mutationFn: (id: number) => SettingAPI.delete(id), 79 | onSuccess: () => 80 | queryClient.invalidateQueries({ 81 | queryKey: [SettingAPI.LIST_QUERY_KEY] 82 | }) 83 | }) 84 | 85 | useEffect(() => { 86 | const { records, total } = templateQuery.data ?? {} 87 | if (records && total) { 88 | setPagination((draft) => { 89 | draft.total = total 90 | }) 91 | } 92 | }, [templateQuery, setPagination]) 93 | 94 | // 启用、禁用 95 | async function toggleEnable(id: number, enabled: boolean) { 96 | await toggleEnableMutation.mutateAsync({ id, enabled }) 97 | } 98 | 99 | // 删除 100 | async function handleDelete(id: number) { 101 | await deleteMutation.mutateAsync(id) 102 | } 103 | 104 | return ( 105 | 新增} 107 | renderHeader={} 108 | renderTable={ 109 |
110 |
111 | {(templateQuery.data?.records ?? []).map((item) => ( 112 | hoverDisplay.onMouseEnter(item.id)} 117 | onMouseLeave={hoverDisplay.onMouseLeave} 118 | onMouseOver={() => hoverDisplay.onMouseOver(item.id)} 119 | onMouseOut={hoverDisplay.onMouseOut} 120 | > 121 | 125 | 126 | 127 | {item.id} 128 | {item.label} 129 | 130 | 139 | 144 | 149 | 150 | 155 | toggleEnable(item.id, !item.enabled)} 157 | className="cursor-pointer text-lg text-muted hover:text-blue-400" 158 | component={item.enabled ? EnableIcon : DisableIcon} 159 | /> 160 | 161 | 166 | handleDelete(item.id)} 168 | className="cursor-pointer text-xl text-muted hover:text-red-400" 169 | component={DeleteIcon} 170 | /> 171 | 172 | 173 | 174 | 175 | 176 | 180 | {item.key} 181 | 182 | 183 | 184 | 188 | {item.value} 189 | 190 | 191 | 192 | 196 | {item.remark} 197 | 198 | 199 | 200 | ))} 201 |
202 | { 206 | setPagination((draft) => { 207 | draft.current = page 208 | draft.pageSize = pageSize 209 | }) 210 | }} 211 | size={response.sm ? 'default' : 'small'} 212 | showSizeChanger 213 | showQuickJumper 214 | showTotal={(total) => t('SHOW.TOTAL', { total })} 215 | /> 216 |
217 | } 218 | /> 219 | ) 220 | } 221 | --------------------------------------------------------------------------------