Repository: itwanger/paicoding-admin Branch: master Commit: 357d2cced2af Files: 225 Total size: 591.4 KB Directory structure: gitextract_u_5er_hd/ ├── .editorconfig ├── .eslintignore ├── .eslintrc.js ├── .gitattributes ├── .gitignore ├── .husky/ │ ├── commit-msg │ └── pre-commit ├── .prettierignore ├── .prettierrc.js ├── .qoder/ │ └── settings.json ├── .stylelintignore ├── .stylelintrc.js ├── .trae/ │ └── documents/ │ ├── 在文章编辑页集成 Moveable.js 实现图片缩放.md │ └── 文章编辑页:导入 Markdown + 修复 Word 图片清晰度.md ├── .vscode/ │ └── extensions.json ├── AGENTS.md ├── CHANGELOG.md ├── CLAUDE.md ├── LICENSE ├── README.md ├── commitlint.config.js ├── deploy-front.sh ├── index.html ├── launch.sh ├── lint-staged.config.js ├── package.json ├── postcss.config.js ├── src/ │ ├── App.tsx │ ├── api/ │ │ ├── config/ │ │ │ └── servicePort.ts │ │ ├── helper/ │ │ │ ├── axiosCancel.ts │ │ │ └── checkStatus.ts │ │ ├── index.ts │ │ ├── interface/ │ │ │ └── index.ts │ │ └── modules/ │ │ ├── aiConfig.ts │ │ ├── article.ts │ │ ├── author.ts │ │ ├── category.ts │ │ ├── column.ts │ │ ├── comment.ts │ │ ├── common.ts │ │ ├── config.ts │ │ ├── global.ts │ │ ├── login.ts │ │ ├── resume.ts │ │ ├── sensitive.ts │ │ ├── statistics.ts │ │ ├── tag.ts │ │ ├── user.ts │ │ └── wxMenu.ts │ ├── assets/ │ │ ├── fonts/ │ │ │ ├── DIN.otf │ │ │ └── font.less │ │ └── iconfont/ │ │ └── iconfont.less │ ├── components/ │ │ ├── ErrorMessage/ │ │ │ ├── 403.tsx │ │ │ ├── 404.tsx │ │ │ ├── 500.tsx │ │ │ └── index.less │ │ ├── Loading/ │ │ │ ├── index.less │ │ │ └── index.tsx │ │ ├── SwitchDark/ │ │ │ └── index.tsx │ │ ├── common-wrap/ │ │ │ ├── index.scss │ │ │ └── index.tsx │ │ ├── second-sure-modal/ │ │ │ └── index.tsx │ │ └── svgIcon/ │ │ └── index.tsx │ ├── config/ │ │ ├── config.ts │ │ ├── nprogress.ts │ │ └── serviceLoading.tsx │ ├── enums/ │ │ ├── common.ts │ │ └── httpEnum.ts │ ├── hooks/ │ │ └── useTheme.ts │ ├── index.scss │ ├── layouts/ │ │ ├── components/ │ │ │ ├── Footer/ │ │ │ │ ├── index.less │ │ │ │ └── index.tsx │ │ │ ├── Header/ │ │ │ │ ├── components/ │ │ │ │ │ ├── AssemblySize.tsx │ │ │ │ │ ├── AvatarIcon.tsx │ │ │ │ │ ├── BreadcrumbNav.tsx │ │ │ │ │ ├── CollapseIcon.tsx │ │ │ │ │ ├── Fullscreen.tsx │ │ │ │ │ ├── InfoModal.tsx │ │ │ │ │ ├── PasswordModal.tsx │ │ │ │ │ └── Theme.tsx │ │ │ │ ├── index.less │ │ │ │ └── index.tsx │ │ │ ├── Menu/ │ │ │ │ ├── components/ │ │ │ │ │ └── Logo.tsx │ │ │ │ ├── index.css │ │ │ │ ├── index.less │ │ │ │ └── index.tsx │ │ │ └── Tabs/ │ │ │ ├── components/ │ │ │ │ └── MoreButton.tsx │ │ │ ├── index.less │ │ │ └── index.tsx │ │ ├── index.less │ │ └── index.tsx │ ├── main.tsx │ ├── redux/ │ │ ├── index.ts │ │ ├── interface/ │ │ │ └── index.ts │ │ ├── modules/ │ │ │ ├── auth/ │ │ │ │ ├── action.ts │ │ │ │ └── reducer.ts │ │ │ ├── breadcrumb/ │ │ │ │ ├── action.ts │ │ │ │ └── reducer.ts │ │ │ ├── disc/ │ │ │ │ ├── action.ts │ │ │ │ └── reducer.ts │ │ │ ├── global/ │ │ │ │ ├── action.ts │ │ │ │ └── reducer.ts │ │ │ ├── menu/ │ │ │ │ ├── action.ts │ │ │ │ └── reducer.ts │ │ │ └── tabs/ │ │ │ ├── action.ts │ │ │ └── reducer.ts │ │ └── mutation-types.ts │ ├── routers/ │ │ ├── constant.tsx │ │ ├── index.tsx │ │ ├── interface/ │ │ │ └── index.ts │ │ ├── modules/ │ │ │ ├── aiConfig.tsx │ │ │ ├── article.tsx │ │ │ ├── author.tsx │ │ │ ├── category.tsx │ │ │ ├── column.tsx │ │ │ ├── config.tsx │ │ │ ├── error.tsx │ │ │ ├── global.tsx │ │ │ ├── home.tsx │ │ │ ├── resume.tsx │ │ │ ├── sensitive.tsx │ │ │ ├── statistics.tsx │ │ │ ├── tag.tsx │ │ │ └── wxMenu.tsx │ │ ├── route.tsx │ │ └── utils/ │ │ ├── authRouter.tsx │ │ └── lazyLoad.tsx │ ├── styles/ │ │ ├── common.less │ │ ├── reset.less │ │ ├── theme/ │ │ │ ├── theme-dark.less │ │ │ └── theme-default.less │ │ └── var.less │ ├── typings/ │ │ ├── common.ts │ │ ├── global.d.ts │ │ ├── plugins.d.ts │ │ └── window.d.ts │ ├── utils/ │ │ ├── getEnv.ts │ │ ├── is/ │ │ │ └── index.ts │ │ └── util.ts │ ├── views/ │ │ ├── aiConfig/ │ │ │ ├── index.scss │ │ │ └── index.tsx │ │ ├── article/ │ │ │ ├── components/ │ │ │ │ ├── debounceselect/ │ │ │ │ │ ├── index.scss │ │ │ │ │ └── index.tsx │ │ │ │ └── search/ │ │ │ │ ├── index.scss │ │ │ │ └── index.tsx │ │ │ ├── edit/ │ │ │ │ ├── index.scss │ │ │ │ ├── index.tsx │ │ │ │ └── search/ │ │ │ │ ├── index.scss │ │ │ │ └── index.tsx │ │ │ └── list/ │ │ │ ├── index.scss │ │ │ └── index.tsx │ │ ├── author/ │ │ │ ├── loginAudit/ │ │ │ │ ├── index.scss │ │ │ │ └── index.tsx │ │ │ ├── whitelist/ │ │ │ │ ├── index.scss │ │ │ │ └── index.tsx │ │ │ └── zsxqlist/ │ │ │ ├── components/ │ │ │ │ └── search/ │ │ │ │ ├── index.scss │ │ │ │ └── index.tsx │ │ │ ├── index.scss │ │ │ └── index.tsx │ │ ├── category/ │ │ │ ├── components/ │ │ │ │ └── search/ │ │ │ │ ├── index.scss │ │ │ │ └── index.tsx │ │ │ ├── index.css │ │ │ ├── index.scss │ │ │ └── index.tsx │ │ ├── column/ │ │ │ ├── article/ │ │ │ │ ├── components/ │ │ │ │ │ ├── DatePicker.tsx │ │ │ │ │ ├── debounceselect/ │ │ │ │ │ │ ├── DebounceSelect.tsx │ │ │ │ │ │ └── index.scss │ │ │ │ │ ├── search/ │ │ │ │ │ │ ├── index.scss │ │ │ │ │ │ └── index.tsx │ │ │ │ │ └── tableselect/ │ │ │ │ │ └── TableSelect.tsx │ │ │ │ ├── index.scss │ │ │ │ └── index.tsx │ │ │ └── setting/ │ │ │ ├── articlesort/ │ │ │ │ ├── index.scss │ │ │ │ ├── index.tsx │ │ │ │ ├── search.scss │ │ │ │ └── search.tsx │ │ │ ├── components/ │ │ │ │ ├── authorselect/ │ │ │ │ │ ├── index.scss │ │ │ │ │ └── index.tsx │ │ │ │ ├── imgupload/ │ │ │ │ │ └── index.tsx │ │ │ │ └── search/ │ │ │ │ ├── index.scss │ │ │ │ └── index.tsx │ │ │ ├── groups/ │ │ │ │ ├── index.scss │ │ │ │ └── index.tsx │ │ │ ├── index.css │ │ │ ├── index.scss │ │ │ └── index.tsx │ │ ├── comment/ │ │ │ ├── components/ │ │ │ │ └── search/ │ │ │ │ ├── index.scss │ │ │ │ └── index.tsx │ │ │ ├── index.scss │ │ │ └── index.tsx │ │ ├── config/ │ │ │ ├── components/ │ │ │ │ ├── imgupload/ │ │ │ │ │ ├── ImgCropUpload.tsx │ │ │ │ │ └── index.tsx │ │ │ │ └── search/ │ │ │ │ ├── index.scss │ │ │ │ └── index.tsx │ │ │ ├── index.css │ │ │ ├── index.scss │ │ │ └── index.tsx │ │ ├── global/ │ │ │ ├── components/ │ │ │ │ └── search/ │ │ │ │ ├── index.scss │ │ │ │ └── index.tsx │ │ │ ├── index.scss │ │ │ └── index.tsx │ │ ├── home/ │ │ │ ├── index.scss │ │ │ └── index.tsx │ │ ├── login/ │ │ │ ├── components/ │ │ │ │ └── LoginForm.tsx │ │ │ ├── index.less │ │ │ └── index.tsx │ │ ├── resume/ │ │ │ ├── components/ │ │ │ │ └── search/ │ │ │ │ ├── index.scss │ │ │ │ └── index.tsx │ │ │ ├── index.css │ │ │ ├── index.scss │ │ │ └── index.tsx │ │ ├── sensitive/ │ │ │ ├── index.scss │ │ │ └── index.tsx │ │ ├── statistics/ │ │ │ ├── index.scss │ │ │ └── index.tsx │ │ ├── tag/ │ │ │ ├── components/ │ │ │ │ └── search/ │ │ │ │ ├── index.scss │ │ │ │ └── index.tsx │ │ │ ├── index.css │ │ │ ├── index.scss │ │ │ └── index.tsx │ │ └── wxMenu/ │ │ ├── index.scss │ │ └── index.tsx │ └── vite-env.d.ts ├── tsconfig.json └── vite.config.ts ================================================ FILE CONTENTS ================================================ ================================================ FILE: .editorconfig ================================================ # @see: http://editorconfig.org root = true [*] # 表示所有文件适用 charset = utf-8 # 设置文件字符集为 utf-8 end_of_line = lf # 控制换行类型(lf | cr | crlf) insert_final_newline = true # 始终在文件末尾插入一个新行 indent_style = tab # 缩进风格(tab | space) indent_size = 2 # 缩进大小 max_line_length = 130 # 最大行长度 [*.md] # 表示仅 md 文件适用以下规则 max_line_length = off # 关闭最大行长度限制 trim_trailing_whitespace = false # 关闭末尾空格修剪 ================================================ FILE: .eslintignore ================================================ *.sh node_modules *.md *.woff *.ttf .vscode .idea dist /public /docs .husky .local /bin .eslintrc.js .prettierrc.js /src/mock/* ================================================ FILE: .eslintrc.js ================================================ // @see: http://eslint.cn module.exports = { settings: { react: { version: "detect" } }, root: true, env: { browser: true, node: true, es6: true }, /* 指定如何解析语法 */ parser: "@typescript-eslint/parser", /* 优先级低于 parse 的语法解析配置 */ parserOptions: { ecmaVersion: 2020, sourceType: "module", jsxPragma: "React", ecmaFeatures: { jsx: true } }, plugins: ["react", "@typescript-eslint", "react-hooks", "prettier", "simple-import-sort"], /* 继承某些已有的规则 */ extends: [ "eslint:recommended", "plugin:react/recommended", "plugin:@typescript-eslint/recommended", "plugin:react/jsx-runtime", "plugin:react-hooks/recommended", "prettier", "plugin:prettier/recommended" ], /* * "off" 或 0 ==> 关闭规则 * "warn" 或 1 ==> 打开的规则作为警告(不影响代码执行) * "error" 或 2 ==> 规则作为一个错误(代码不能执行,界面报错) */ rules: { // eslint (http://eslint.cn/docs/rules) "no-var": "error", // 要求使用 let 或 const 而不是 var "no-multiple-empty-lines": ["error", { max: 1 }], // 不允许多个空行 "no-use-before-define": "off", // 禁止在 函数/类/变量 定义之前使用它们 "prefer-const": "off", // 此规则旨在标记使用 let 关键字声明但在初始分配后从未重新分配的变量,要求使用 const "no-irregular-whitespace": "off", // 禁止不规则的空白 // typeScript (https://typescript-eslint.io/rules) "@typescript-eslint/no-unused-vars": "off", // 禁止定义未使用的变量 "@typescript-eslint/no-inferrable-types": "off", // 可以轻松推断的显式类型可能会增加不必要的冗长 "@typescript-eslint/no-namespace": "off", // 禁止使用自定义 TypeScript 模块和命名空间。 "@typescript-eslint/no-explicit-any": "off", // 禁止使用 any 类型 "@typescript-eslint/ban-ts-ignore": "off", // 禁止使用 @ts-ignore "@typescript-eslint/ban-types": "off", // 禁止使用特定类型 "@typescript-eslint/explicit-function-return-type": "off", // 不允许对初始化为数字、字符串或布尔值的变量或参数进行显式类型声明 "@typescript-eslint/no-var-requires": "off", // 不允许在 import 语句中使用 require 语句 "@typescript-eslint/no-empty-function": "off", // 禁止空函数 "@typescript-eslint/no-use-before-define": "off", // 禁止在变量定义之前使用它们 "@typescript-eslint/ban-ts-comment": "off", // 禁止 @ts- 使用注释或要求在指令后进行描述 "@typescript-eslint/no-non-null-assertion": "off", // 不允许使用后缀运算符的非空断言(!) "@typescript-eslint/explicit-module-boundary-types": "off", // 要求导出函数和类的公共类方法的显式返回和参数类型 "@typescript-eslint/no-empty-interface": "off", // react (https://github.com/jsx-eslint/eslint-plugin-react) "react-hooks/rules-of-hooks": "off", "react-hooks/exhaustive-deps": "off", "simple-import-sort/imports": "warn", "simple-import-sort/exports": "warn" }, overrides: [ { files: ["*.js", "*.jsx", "*.ts", "*.tsx"], rules: { "simple-import-sort/imports": [ "warn", { groups: [ // Packages `react` related packages come first. ["^react", "^@?\\w"], // Other relative imports. Put same-folder imports and `.` last. ["^(@|components)(/.*|$)", "^\\./(?=.*/)(?!/?$)", "^\\.(?!/?$)", "^\\./?$"], // Style imports. ["^.+\\.?(css)$"] ] } ] } } ] }; ================================================ FILE: .gitattributes ================================================ # Set the default behavior, in case people don't have core.autocrlf set. * text=auto # Explicitly declare text files you want to always be normalized and converted # to native line endings on checkout. *.c text *.h text # Declare files that will always have CRLF line endings on checkout. *.sln text eol=crlf # Denote all files that are truly binary and should not be modified. *.png binary *.jpg binary ================================================ FILE: .gitignore ================================================ # Logs logs *.log npm-debug.log* yarn-debug.log* yarn-error.log* pnpm-debug.log* lerna-debug.log* node_modules dist dist.tar.gz dist-ssr *.local stats.html # Editor directories and files .vscode/* !.vscode/extensions.json !.vscode/settings.json .idea .DS_Store *.suo *.ntvs* *.njsproj *.sln *.sw? .vscode/settings.json dist.zip .env.deploy ================================================ FILE: .husky/commit-msg ================================================ #!/usr/bin/env sh . "$(dirname -- "$0")/_/husky.sh" npx --no-install commitlint --edit $1 ================================================ FILE: .husky/pre-commit ================================================ #!/usr/bin/env sh . "$(dirname -- "$0")/_/husky.sh" npm run lint:lint-staged ================================================ FILE: .prettierignore ================================================ /dist/* .local /node_modules/** **/*.svg **/*.sh /public/* ================================================ FILE: .prettierrc.js ================================================ // @see: https://www.prettier.cn module.exports = { // 超过最大值换行 printWidth: 130, // 缩进字节数 tabWidth: 2, // 使用制表符而不是空格缩进行 useTabs: true, // 结尾不用分号(true有,false没有) semi: true, // 使用单引号(true单双引号,false双引号) singleQuote: false, // 更改引用对象属性的时间 可选值"" quoteProps: "as-needed", // 在对象,数组括号与文字之间加空格 "{ foo: bar }" bracketSpacing: true, // 多行时尽可能打印尾随逗号。(例如,单行数组永远不会出现逗号结尾。) 可选值"",默认none trailingComma: "none", // 在JSX中使用单引号而不是双引号 jsxSingleQuote: false, // (x) => {} 箭头函数参数只有一个时是否要有小括号。avoid:省略括号 ,always:不省略括号 arrowParens: "avoid", // 如果文件顶部已经有一个 doclock,这个选项将新建一行注释,并打上@format标记。 insertPragma: false, // 指定要使用的解析器,不需要写文件开头的 @prettier requirePragma: false, // 默认值。因为使用了一些折行敏感型的渲染器(如GitHub comment)而按照markdown文本样式进行折行 proseWrap: "preserve", // 在html中空格是否是敏感的 "css" - 遵守CSS显示属性的默认值, "strict" - 空格被认为是敏感的 ,"ignore" - 空格被认为是不敏感的 htmlWhitespaceSensitivity: "css", // 换行符使用 lf 结尾是 可选值"" endOfLine: "auto", // 这两个选项可用于格式化以给定字符偏移量(分别包括和不包括)开始和结束的代码 rangeStart: 0, rangeEnd: Infinity, // Vue文件脚本和样式标签缩进 vueIndentScriptAndStyle: false }; ================================================ FILE: .qoder/settings.json ================================================ { "permissions": { "ask": [ "Read(!/Users/itwanger/Documents/GitHub/paicoding-admin/**)", "Edit(!/Users/itwanger/Documents/GitHub/paicoding-admin/**)" ], "allow": [ "Read(/Users/itwanger/Documents/GitHub/paicoding-admin/**)", "Edit(/Users/itwanger/Documents/GitHub/paicoding-admin/**)" ] }, "memoryImport": {}, "monitoring": {} } ================================================ FILE: .stylelintignore ================================================ /dist/* /public/* public/* ================================================ FILE: .stylelintrc.js ================================================ module.exports = { // 引入标准配置文件和scss配置扩展 extends: ["stylelint-config-standard", "stylelint-config-recommended-scss"], rules: { // 引号必须为单引号 "string-quotes": ["single"], // 冒号后要加空格 "declaration-colon-space-after": ["always"], // 冒号前不加空格 "declaration-colon-space-before": ["never"], // 变量后必须添加!default,本地局部变量可以不加 "scss/dollar-variable-default": [true, { ignore: "local" }], // 属性单独成行 "declaration-block-single-line-max-declarations": [1], // 属性和值前不带厂商标记(通过autofixer自动添加,不要自己手工写) "property-no-vendor-prefix": [true], "value-no-vendor-prefix": [true], // 多选择器必须单独成行,逗号结尾 "selector-list-comma-newline-after": ["always"], // 不能有无效的16进制颜色值 "color-no-invalid-hex": [true] }, ignoreFiles: ["src/**/*.tsx", "src/**/*.ts", "src/**/*.jsx", "src/**/*.js"] }; ================================================ FILE: .trae/documents/在文章编辑页集成 Moveable.js 实现图片缩放.md ================================================ ## 实施计划:集成 react-moveable 实现图片缩放 ### 1. 安装依赖 - 安装 `react-moveable` 库,用于提供图片的拖拽和缩放功能。 ### 2. 修改文章编辑页面 ([index.tsx](file:///Users/itwanger/Documents/GitHub/paicoding-admin/src/views/article/edit/index.tsx)) - **引入 Moveable**:导入 `Moveable` 组件。 - **状态管理**: - `target`: 存储当前选中的图片元素。 - `moveableRef`: 引用 Moveable 实例。 - **自定义 ByteMD 插件 (`imageMoveablePlugin`)**: - 在预览区域渲染完成后,为所有 `img` 标签绑定点击事件。 - 点击图片时将其设为 `target`;点击非图片区域时清除 `target`。 - **集成 Moveable 组件**: - 在编辑器预览区域渲染 `Moveable`。 - 配置 `resizable`, `keepRatio`, `snappable` 等属性。 - **实现缩放同步**: - 在 `onResize` 事件中实时更新图片的 DOM 样式。 - 在 `onResizeEnd` 事件中,将缩放后的尺寸同步回 Markdown 源码中,通过将 `![alt](url)` 转换为 `` 来实现持久化。 ### 3. 样式优化 - 调整 Moveable 控制柄的样式,确保在编辑器内清晰可见且不遮挡操作。 ### 4. 验证与测试 - 在编辑页面插入图片,尝试通过 Moveable 进行缩放。 - 确认缩放后的尺寸在保存并重新加载后依然有效。 ================================================ FILE: .trae/documents/文章编辑页:导入 Markdown + 修复 Word 图片清晰度.md ================================================ ## 目标 - 在 `#/article/edit/index` 增加“导入Markdown”功能:读取本地 `.md/.markdown/.txt` 内容并写入编辑器。 - 导入时不上传图片;图片仍走现有“转链”按钮统一处理外链图片。 - 针对“语雀导出的 Markdown”做净化:去掉 ``、空 font、注释、以及图片 URL 周围的干扰字符(如反引号/多余空格)。 ## 方案:导入 Markdown(不触发上传) 1. **新增工具栏按钮** - 在 [search/index.tsx](file:///Users/itwanger/Documents/GitHub/paicoding-admin/src/views/article/edit/search/index.tsx) 增加“导入Markdown”按钮。 - 扩展 props:新增 `handleImportMarkdown`。 2. **实现 `handleImportMarkdown`(核心逻辑)** - 在 [index.tsx](file:///Users/itwanger/Documents/GitHub/paicoding-admin/src/views/article/edit/index.tsx) 新增 `handleImportMarkdown`: - 用隐藏 `input[type=file]` 选择文件,accept:`.md,.markdown,.txt,text/markdown,text/plain`。 - `file.text()` 读取原文(去 BOM、统一换行)。 - 调用 `sanitizeYuqueMarkdown(raw)` 做净化(见下)。 - 若编辑器已有内容:弹 Modal 让用户选“替换/追加/取消”(复用 Word 导入的交互风格)。 - 标题同步:若净化后的 Markdown 首个非空行匹配 `# 标题`,提取为 `shortTitle` 并从正文移除该行。 - 写入编辑器: - 替换:`setContent(md)` + `handleChange({content: md, shortTitle?})` - 追加:`content + '\n\n---\n\n' + md` - 明确不做任何图片上传/转链;用户需要时再点击现有“转链”。 ## 语雀 Markdown 净化规则(sanitizeYuqueMarkdown) - **清理 font 包装**:移除所有 `` 与 `` 标签,但保留其中的文本内容。 - **移除无意义的空 font 段落**:例如仅包含空白/换行的 font 标签残留。 - **移除 HTML 注释块**:``(支持多行)。 - **修复图片 URL 干扰格式**:将语雀导出常见写法 - `![]( `https://...png` )` / `![](`https://...`)` / `![alt]( `url` )` - 统一规范成 `![](https://...)` 或 `![alt](https://...)`(去反引号、去括号内多余空格)。 - **基础规范化**:` ` → `\n`、去零宽字符、连续 3+ 空行压缩为 2 行。 ## 影响范围 - 不改“转链”逻辑:仍由 [index.tsx](file:///Users/itwanger/Documents/GitHub/paicoding-admin/src/views/article/edit/index.tsx) 现有 `handleReplaceImgUrl` 统一处理外链图片。 - 不改 Word 导入图片清晰度方案(按你要求先不处理)。 ## 涉及文件 - [src/views/article/edit/index.tsx](file:///Users/itwanger/Documents/GitHub/paicoding-admin/src/views/article/edit/index.tsx) - [src/views/article/edit/search/index.tsx](file:///Users/itwanger/Documents/GitHub/paicoding-admin/src/views/article/edit/search/index.tsx) ## 验证方式 - 进入 `http://127.0.0.1:3301/#/article/edit/index`: - 导入语雀导出的 `.md`:确认 font/注释被移除、图片语法变为标准 `![](...)`。 - 点击“转链”:确认外链图片正常上传替换,且不会重复上传(沿用现有 30 秒缓存策略)。 - 回归:保存/更新、图片缩放与替换、其它表单项不受影响。 ================================================ FILE: .vscode/extensions.json ================================================ { "recommendations": [ "dsznajder.es7-react-js-snippets", "stylelint.vscode-stylelint", "dbaeumer.vscode-eslint", "editorconfig.editorconfig", "esbenp.prettier-vscode" ] } ================================================ FILE: AGENTS.md ================================================ # AGENTS.md This file provides guidance to Qoder (qoder.com) when working with code in this repository. ## Project Overview paicoding-admin is a React-based admin panel for the 技术派 (PaiCoding) community platform. Built with React 18, TypeScript, Vite 3, Ant Design 5.x, Redux, and React Router v6. ## Build and Development Commands ### Development ```bash npm run dev # Starts dev server at http://127.0.0.1:3301 # Default login credentials: admin/admin ``` ### Build ```bash # Production build npm run build:pro # Development build npm run build:build:dev # Test build npm run build:test ``` ### Preview ```bash npm run preview ``` ### Linting and Code Quality ```bash # ESLint check and auto-fix npm run lint:eslint # Prettier formatting npm run lint:prettier # Stylelint for CSS/Less/SCSS npm run lint:stylelint # Run lint-staged (pre-commit hook) npm run lint:lint-staged ``` ### Git Commits ```bash # Automated commit flow with commitizen npm run commit ``` ## Architecture ### State Management (Redux) - Uses Redux with redux-persist for state persistence - Key modules: `global`, `menu`, `tabs`, `auth`, `breadcrumb`, `disc` - Location: `src/redux/modules/` - Store configuration: `src/redux/index.ts` - Redux DevTools enabled in development - Middleware: redux-thunk, redux-promise ### Routing - React Router v6 with lazy loading support - Route modules auto-loaded from `src/routers/modules/*.tsx` using `import.meta.globEager` - Main router config: `src/routers/index.tsx` - Route definitions: `src/routers/route.tsx` - Supports nested routes, route guards, and multi-tab navigation ### API Layer - Axios-based HTTP client with custom wrapper class `RequestHttp` - Global request/response interceptors - Features: automatic token injection, loading states, error handling, request cancellation - Base URL configured via `VITE_API_URL` environment variable - API modules organized by feature in `src/api/modules/` - Main config: `src/api/index.ts` ### Layout System - Main layout: `src/layouts/index.tsx` - Components: Header, Menu, Tabs, Footer - Uses Ant Design Layout with collapsible sidebar - Responsive design with window resize handling ### Project Structure ``` src/ ├── api/ # API request definitions by module ├── assets/ # Static assets (images, icons, etc.) ├── components/ # Reusable components ├── config/ # Configuration files ├── enums/ # TypeScript enums ├── hooks/ # Custom React hooks ├── layouts/ # Layout components (Header, Menu, Footer, Tabs) ├── redux/ # Redux store and modules ├── routers/ # Route configuration and modules ├── styles/ # Global styles ├── typings/ # TypeScript type definitions ├── utils/ # Utility functions └── views/ # Page components by feature ``` ### Key Views/Features - `statistics/`: Dashboard with ECharts data visualization - `config/`: Platform operation configuration - `article/`: Article management - `column/`: Column configuration - `resume/`: Tutorial/course configuration - `author/`: User/author management - `category/`: Category management - `tag/`: Tag management - `global/`: Global settings - `login/`: Authentication ## Development Notes ### Environment Variables - Development: `.env.development` - Backend at `http://127.0.0.1:8080` - Production: `.env.production` - Test: `.env.test` ### Backend Integration - Backend project: [paicoding](https://github.com/itwanger/paicoding) - Spring Boot-based community platform - Ensure Redis and backend server are running before starting admin panel ### TypeScript Configuration - Strict TypeScript enabled - Path alias `@` configured to `src/` - Config: `tsconfig.json` ### Vite Configuration - Proxy configured for `/admin` and `/api/admin` to `http://127.0.0.1:8080` - Port: 3301 (configurable via `VITE_PORT`) - Gzip compression enabled for production builds - Bundle visualization available with `VITE_REPORT` - SVG icons via vite-plugin-svg-icons - Config: `vite.config.ts` ### Code Standards - ESLint with TypeScript, React, and Prettier integration - Prettier for code formatting - Stylelint for CSS/Less/SCSS - Husky + lint-staged for pre-commit hooks - Commitizen + commitlint for commit message conventions ## Troubleshooting ### Node Modules Issues If `npm install` fails: 1. Upgrade Node.js to 16+ (recommended 18+) 2. Try: `npm install --registry=http://registry.npmmirror.com` 3. If ECONNRESET error: `npm config set registry http://registry.npmjs.org/` 4. Delete `node_modules` and reinstall ### launch.sh (Mac/Linux) Helper script for common tasks: ```bash ./launch.sh install # Install dependencies ./launch.sh server # Start dev server ./launch.sh pro # Build, package, and deploy to server ``` If `$'\r': command not found` error: ```bash sed -i 's/\r//' launch.sh # or dos2unix launch.sh ``` ================================================ FILE: CHANGELOG.md ================================================ # Changelog All notable changes to this project will be documented in this file. See [standard-version](https://github.com/conventional-changelog/standard-version) for commit guidelines. ### 0.0.1 (2022-09-19) ### Features - 🚀 项目初始化(今天的 🧱 格外烫手) ### Bug Fixes - 🧩 密密麻麻 ================================================ FILE: CLAUDE.md ================================================ # CLAUDE.md This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. ## Project Overview paicoding-admin is the admin dashboard for 技术派 (paicoding), a community platform. Built with React 18, TypeScript, Vite 3, Ant Design 5.x, Redux, and ECharts. It provides comprehensive management capabilities for articles, authors, categories, tags, columns, and site configuration. Backend API: https://github.com/itwanger/paicoding (Spring Boot based) ## Development Commands ### Local Development ```bash npm install # OR if install fails (requires Node.js 16+) npm install --registry=http://registry.npmmirror.com npm run dev # Opens http://127.0.0.1:3301 # Default credentials: admin/admin ``` ### Build ```bash # Production build npm run build:pro # Development build npm run build:dev # Test environment build npm run build:test ``` ### Code Quality ```bash # ESLint check and fix npm run lint:eslint # Prettier formatting npm run lint:prettier # Stylelint for styles npm run lint:stylelint # Lint staged files (runs via husky) npm run lint:lint-staged ``` ### Git Commits ```bash # Interactive commit with commitizen npm run commit # This runs: git pull && git add -A && git-cz && git push ``` Commit types follow conventional commits: - `feat`: New feature - `fix`: Bug fix - `docs`: Documentation changes - `style`: Code formatting (not affecting logic) - `refactor`: Code refactoring - `perf`: Performance improvements - `test`: Test changes - `build`: Build system changes - `ci`: CI configuration changes - `chore`: Other changes ### Deployment ```bash # For Mac/Linux users ./launch.sh install # Install dependencies ./launch.sh server # Start dev server ./launch.sh pro # Build, upload to server, and deploy # Manual deployment npm run build:pro # Generates dist/ directory # Upload dist/ to /home/admin/ on server # Configure Nginx: # location ^~ /admin { # alias /home/admin/dist/; # index index.html; # } ``` ## Architecture ### State Management (Redux) Located in `src/redux/modules/`: - **global**: Token, theme, language, assembly size, loading state - **menu**: Menu collapse state, menu list - **tabs**: Multi-tab navigation state - **auth**: Authentication and button permissions - **breadcrumb**: Breadcrumb navigation - **disc**: Discussion/forum state Redux uses `redux-persist` to persist state to localStorage. Uses `redux-thunk` and `redux-promise` middleware. ### Routing System - Routes defined in `src/routers/modules/*.tsx` (modular approach) - Auto-imported via `import.meta.globEager` in `src/routers/index.tsx` - Route modules: article, author, category, column, config, global, home, statistics, tag, resume, error - Lazy loading enabled via `src/routers/utils/lazyLoad.tsx` - Auth protection via HOC in `src/routers/utils/authRouter.tsx` - Multi-tab support with keep-alive using `react-activation` ### API Layer - Axios wrapper in `src/api/index.ts` with global interceptors - Request/response interceptors handle: - Token injection via `x-access-token` header - Loading states via NProgress - Error handling (599 = login expired, redirects to login) - Duplicate request cancellation - API modules in `src/api/modules/`: login, article, author, category, column, config, global, statistics, tag, resume, common - Base URL configured via `.env.*` files: `/api/admin` - Proxy configured in `vite.config.ts` to forward to `http://127.0.0.1:8080/` ### Layout Structure Main layout in `src/layouts/index.tsx`: - **Sider**: Left menu (`LayoutMenu`) - **Header**: Top navigation with breadcrumb, theme toggle, user avatar (`LayoutHeader`) - **Tabs**: Multi-tab navigation (`LayoutTabs`) - **Content**: Main content area (rendered via ``) - **Footer**: Footer component (`LayoutFooter`) Responsive: Auto-collapses menu when window width < 1200px. ### Key Features 1. **Custom Components**: - `DebounceSelect`: Debounced search select (used in article/column forms) - `TableSelect`: Paginated searchable select with table display - `ImgCropUpload`: Image upload with crop functionality (Ant Design) - `DatePicker`: Custom date picker for expireTime fields - `SecondSureModal`: Secondary confirmation modal 2. **Theme System**: - Dark mode support - Gray mode & color-weak mode - Component size switching (small/middle/large) - Managed via `src/hooks/useTheme.ts` 3. **Markdown Editor**: - Uses `@bytemd/react` with plugins: gfm, highlight, math, gemoji, medium-zoom - Theme support via `juejin-markdown-themes` ## File Organization ``` src/ ├── api/ # API modules and Axios configuration ├── assets/ # Static assets (icons, images) ├── components/ # Global reusable components ├── config/ # Configuration (nprogress, service loading) ├── enums/ # Enumerations (HTTP status codes, common enums) ├── hooks/ # Custom React hooks (useTheme, etc.) ├── layouts/ # Layout components (Header, Menu, Tabs, Footer) ├── redux/ # Redux store and modules ├── routers/ # Route configuration (modular) ├── styles/ # Global styles (less files) ├── typings/ # TypeScript type definitions ├── utils/ # Utility functions ├── views/ # Page components (organized by feature) ``` ### Views Structure - `article/`: Article list, edit with markdown editor, search - `author/`: Author whitelist, zsxq list - `category/`: Category management - `column/`: Column settings, article sorting, groups - `config/`: Site configuration with image uploads - `global/`: Global settings - `home/`: Dashboard (default redirect from `/`) - `login/`: Login page - `resume/`: Resume management - `statistics/`: Data statistics with ECharts - `tag/`: Tag management ## Important Notes ### Environment Configuration - Development: `VITE_API_URL = '/api/admin'`, proxy to `http://127.0.0.1:8080` - Backend must be running on port 8080 (paicoding Spring Boot app) - Redis must be running for backend ### Path Alias - `@` maps to `src/` directory (configured in `vite.config.ts` and `tsconfig.json`) ### TypeScript - Strict mode disabled for flexibility - Type definitions in `src/typings/` and `src/api/interface/` ### Styling - Uses Less preprocessor - Global variables in `src/styles/var.less` - Ant Design theme customization supported ### Known Issues - If `npm install` fails with Node.js < 16, upgrade Node.js to 18+ and npm to 9+ - OR download pre-packaged node_modules (mentioned in README) - Windows users: Convert `launch.sh` line endings if needed (`dos2unix launch.sh`) ### Backend Integration - Expects backend at `http://127.0.0.1:8080` - Login endpoint returns token stored in Redux + localStorage - Token sent as `x-access-token` header in all requests - Response format: `{ status: { code, msg }, result: {...} }` - Code 599 = not logged in (redirects to login page) - Code 200 = success ================================================ FILE: LICENSE ================================================ MIT License Copyright (c) 2022 SpicyBoy Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: README.md ================================================ # paicoding-admin 🚀 ## 介绍 📖

技术派

🚀🚀🚀 paicoding-admin,技术派管理端,基于 React18、React-Router v6、React-Hooks、Redux、TypeScript、Vite3、Ant-Design 5.x、Hook Admin、ECharts 的一套社区管理系统,够惊艳哦。

## 一、在线预览地址 👀 - Link:[https://paicoding.com/admin](https://paicoding.com/admin) ## 二、Git 仓库地址 (欢迎 Star⭐) - GitHub:[https://github.com/itwanger/paicoding-admin](https://github.com/itwanger/paicoding-admin) - 码云:[https://gitee.com/itwanger/paicoding-admin](https://gitee.com/itwanger/paicoding-admin) ## 三、🔨🔨🔨 项目功能 - 🚀 采用最新技术找开发:React18、React-Router v6、React-Hooks、TypeScript、Vite3 - 🚀 采用 Vite3 作为项目开发、打包工具(配置了 Gzip 打包、跨域代理、打包预览工具……) - 🚀 整个项目集成了 TypeScript (学期来很酷哦 🤣) - 🚀 使用 redux 做状态管理,集成 immer、react-redux、redux-persist 开发 - 🚀 使用 TypeScript 对 Axios 整个二次封装 (全局错误拦截、常用请求封装、全局请求 Loading、取消重复请求……) - 🚀 支持 Antd 组件大小切换、暗黑 && 灰色 && 色弱模式 - 🚀 使用 自定义高阶组件 进行路由权限拦截(403 页面)、页面按钮权限配置 - 🚀 支持 React-Router v6 路由懒加载配置、菜单手风琴模式、无限级菜单、多标签页、面包屑导航 - 🚀 使用 Prettier 统一格式化代码,集成 Eslint、Stylelint 代码校验规范(项目规范配置) - 🚀 使用 husky、lint-staged、commitlint、commitizen、cz-git 规范提交信息(项目规范配置) ## 四、安装使用步骤 📑 ### Clone: ```text # GitHub git clone https://github.com/itwanger/paicoding-admin.git ``` ### Install: ```text npm install cnpm install # npm install 安装失败,请升级 nodejs 到 16 以上,或尝试使用以下命令: npm install --registry=http://registry.npmmirror.com # npm install 如果出现 npm ERR! code ECONNRESET 错误,可尝试执行以下命令后再安装 npm config set registry http://registry.npmjs.org/ ``` ### Run: 将技术派的后端代码和前端代码拉到本地后,先启动 Redis 和服务端端。然后再启动 admin 端,可以通过 VSCode 来进行开发。 ![](https://cdn.tobebetterjavaer.com/stutymore/README-20230605110431.png) ```text npm run dev ``` 会自动在浏览器打开 [http://127.0.0.1:3301](http://127.0.0.1:3301),如下所示。 ![](https://cdn.tobebetterjavaer.com/stutymore/README-20230605110616.png) 本地的用户名和密码均为 admin 和 admin 。 如果遇到 nodejs 环境的问题实在无法启动,可能是一些依赖包的问题,可以尝试删除 node_modules 文件夹,重新安装依赖包。如果仍然无法解决,可以通过以下方式获取我已经打包好的 node_modules 安装包。 异常堆栈: ![](https://cdn.tobebetterjavaer.com/stutymore/README-20231116201935.png) 解决方法 1:升级 nodejs 到 18 以上,升级 npm 到 9 以上,然后重新 install。 ![](https://cdn.tobebetterjavaer.com/stutymore/README-20231116202024.png) 解决方法 2:删除 node_modules 文件夹,在「沉默王二」公众号后台回复「node」下载 node_modules 依赖包。 ![](https://cdn.tobebetterjavaer.com/stutymore/README-20231116202230.png) 然后覆盖你本地的 node_modules 包,然后再执行 `npm run dev` 就可以运行起来了。 ![](https://cdn.tobebetterjavaer.com/stutymore/README-20231116202239.png) ### Build: ```text # 生产环境 npm run build:pro ``` ## 五、项目截图 ### 1、数据统计页(ECharts 真强大): ![](https://cdn.tobebetterjavaer.com/stutymore/README-20230602150500.png) ### 2、运营配置页(Ant 的图片上传组件不错哦): ![](https://cdn.tobebetterjavaer.com/stutymore/README-20230602150909.png) ### 3、文章管理页: ![](https://cdn.tobebetterjavaer.com/stutymore/README-20230602154026.png) ### 4、专栏配置页(自定义下拉框挺好玩的): ![](https://cdn.tobebetterjavaer.com/stutymore/README-20230602154134.png) ![](https://cdn.tobebetterjavaer.com/stutymore/README-20230602154222.png) ### 5、教程配置页(防抖支持搜索的下拉框、自定义支持分页、搜索的下拉框不错哦) ![](https://cdn.tobebetterjavaer.com/stutymore/README-20230602154904.png) ![](https://cdn.tobebetterjavaer.com/stutymore/README-20230602155018.png) ## 六、文件资源目录 📚 ```text pacoding-admin ├─ .vscode # vscode推荐配置 ├─ public # 静态资源文件(忽略打包) ├─ src │ ├─ api # API 接口管理 │ ├─ assets # 静态资源文件 │ ├─ components # 全局组件 │ ├─ config # 全局配置项 │ ├─ enums # 项目枚举 │ ├─ hooks # 常用 Hooks │ ├─ language # 语言国际化 │ ├─ layouts # 框架布局 │ ├─ routers # 路由管理 │ ├─ redux # redux store │ ├─ styles # 全局样式 │ ├─ typings # 全局 ts 声明 │ ├─ utils # 工具库 │ ├─ views # 项目所有页面 │ ├─ App.tsx # 入口页面 │ ├─ main.tsx # 入口文件 │ └─ env.d.ts # vite 声明文件 ├─ .editorconfig # 编辑器配置(格式化) ├─ .env # vite 常用配置 ├─ .env.deploy.example # 部署脚本配置示例 ├─ .env.development # 开发环境配置 ├─ .env.production # 生产环境配置 ├─ .env.test # 测试环境配置 ├─ .eslintignore # 忽略 Eslint 校验 ├─ .eslintrc.js # Eslint 校验配置 ├─ .gitignore # git 提交忽略 ├─ .prettierignore # 忽略 prettier 格式化 ├─ .prettierrc.js # prettier 配置 ├─ .stylelintignore # 忽略 stylelint 格式化 ├─ .stylelintrc.js # stylelint 样式格式化配置 ├─ CHANGELOG.md # 项目更新日志 ├─ commitlint.config.js # git 提交规范配置 ├─ deploy-front.sh # 前端生产环境部署脚本 ├─ index.html # 入口 html ├─ LICENSE # 开源协议文件 ├─ lint-staged.config # lint-staged 配置文件 ├─ package-lock.json # 依赖包包版本锁 ├─ package.json # 依赖包管理 ├─ postcss.config.js # postcss 配置 ├─ README.md # README 介绍 ├─ tsconfig.json # typescript 全局配置 └─ vite.config.ts # vite 配置 ``` ## 七、项目后台接口 🧩 > 依托于技术派项目,一个基于 Spring Boot、MyBatis-Plus、MySQL、Redis、ElasticSearch、MongoDB、Docker、RabbitMQ 等技术栈实现的社区系统,采用主流的互联网技术架构、全新的 UI 设计、支持一键源码部署,拥有完整的文章&教程发布/搜索/评论/统计流程等,代码完全开源,没有任何二次封装,是一个非常适合二次开发/实战的现代化社区项目 👍 。 - 网站首页:[https://paicoding.com/](https://paicoding.com/) - 源码地址:[https://github.com/itwanger/paicoding](https://github.com/itwanger/paicoding) ## 八、生产环境部署 推荐使用根目录下的 `deploy-front.sh` 一键部署脚本。脚本会自动执行 `npm run build:pro`,将 `dist` 压缩为 `dist.zip`,上传到服务器指定目录,删除远端旧的 `dist` 目录并执行 `unzip` 解压。 1、先准备部署配置文件: ```bash cp .env.deploy.example .env.deploy ``` 2、修改 `.env.deploy` 中的部署参数: ```bash DEPLOY_SERVER_HOST=你的服务器IP DEPLOY_SERVER_USER=admin DEPLOY_SERVER_KEY=/Users/yourname/.ssh/id_rsa DEPLOY_TARGET_DIR=/home/admin ``` 其中: - `DEPLOY_SERVER_HOST`:服务器 IP 或域名 - `DEPLOY_SERVER_USER`:SSH 登录用户,默认 `admin` - `DEPLOY_SERVER_KEY`:SSH 私钥路径;如果服务器已经配置免密登录,可以留空 - `DEPLOY_TARGET_DIR`:上传并解压 `dist.zip` 的目标目录,默认 `/home/admin` 3、执行部署脚本: ```bash ./deploy-front.sh ``` 如果当前脚本没有执行权限,可以先执行: ```bash chmod +x deploy-front.sh ``` 4、脚本完成后,服务器上会得到最新的 `/home/admin/dist` 目录。 如果你想手动部署,也可以按下面的流程执行: ```bash npm run build:pro zip -r dist.zip dist scp dist.zip admin@你的服务器IP:/home/admin/dist.zip ssh admin@你的服务器IP cd /home/admin rm -rf dist unzip dist.zip rm -f dist.zip ``` 5、如果采用 Nginx 的话,请在 server 节点下进行 location 配置。 ``` location ^~ /admin { alias /home/admin/dist/; # 根 目 录 index index.html; } ``` ### launch.sh 辅助 shell 脚本,针对 mac/linux 用户而言,提供更好的使用姿势 0. 前提说明 当 launch.sh 执行时,提示 `$‘\r‘: command not found`时,主要原因是 windows 系统编写的 shell 脚本,每行结尾是`\r\n`, 而 linux 的结尾是`\n`,可以通过下面几种方式进行处理 ```bash # case1 sed -i 's/\r//' launch.sh # case2 # sudo apt-get install -y dos2unix sudo yum install -y dos2unix dos2unix launch.sh ``` 1.安装依赖: ```bash ./launch.sh install ``` 2.本地启动: ```bash ./launch.sh server ``` 3.打包上传服务器,并使他生效 ```bash # 下面这个动作,包含以下几步 # 1. 打包 -> 生成 dist 目录, 压缩为 dist.tar.gz 包 # 2. 上传到服务器 # 3. 将之前旧的静态资源备份,然后解压新的上传包 ./launch.sh pro ``` ## 九、友情链接 - [toBeBetterjavaer](https://github.com/itwanger/toBeBetterJavaer) :一份通俗易懂、风趣幽默的 Java 学习指南,内容涵盖 Java 基础、Java 并发编程、Java 虚拟机、Java 企业级开发、Java 面试等核心知识点。学 Java,就认准二哥的 Java 进阶之路 😄 - [paicoding](https://github.com/itwanger/paicoding) :⭐️ 一款好用又强大的开源社区,基于 Spring Boot、MyBatis-Plus、MySQL、Redis、ElasticSearch、MongoDB、Docker、RabbitMQ 等主流技术栈,附详细教程,包括 Java、Spring、MySQL、Redis、微服务&分布式、消息队列等核心知识点。学编程,就上技术派 😁。 ## 十、star 趋势图 [![Star History Chart](https://api.star-history.com/svg?repos=itwanger/paicoding-admin&type=Date)](https://star-history.com/#itwanger/paicoding-admin&Date) ## 十一、许可证 [Apache License 2.0](https://github.com/itwanger/paicoding/blob/main/License) Copyright (c) 2022-2023 技术派(沉默王二、楼仔、一灰、小超) ================================================ FILE: commitlint.config.js ================================================ // @see: https://cz-git.qbenben.com/zh/guide /** @type {import('cz-git').UserConfig} */ module.exports = { ignores: [commit => commit.includes("init")], extends: ["@commitlint/config-conventional"], rules: { // @see: https://commitlint.js.org/#/reference-rules "body-leading-blank": [2, "always"], "footer-leading-blank": [1, "always"], "header-max-length": [2, "always", 108], "subject-empty": [2, "never"], "type-empty": [2, "never"], "subject-case": [0], "type-enum": [ 2, "always", [ "feat", "fix", "docs", "style", "refactor", "perf", "test", "build", "ci", "chore", "revert", "wip", "workflow", "types", "release" ] ] }, prompt: { messages: { type: "Select the type of change that you're committing:", scope: "Denote the SCOPE of this change (optional):", customScope: "Denote the SCOPE of this change:", subject: "Write a SHORT, IMPERATIVE tense description of the change:\n", body: 'Provide a LONGER description of the change (optional). Use "|" to break new line:\n', breaking: 'List any BREAKING CHANGES (optional). Use "|" to break new line:\n', footerPrefixsSelect: "Select the ISSUES type of changeList by this change (optional):", customFooterPrefixs: "Input ISSUES prefix:", footer: "List any ISSUES by this change. E.g.: #31, #34:\n", confirmCommit: "Are you sure you want to proceed with the commit above?" // 中文版 // type: "选择你要提交的类型 :", // scope: "选择一个提交范围(可选):", // customScope: "请输入自定义的提交范围 :", // subject: "填写简短精炼的变更描述 :\n", // body: '填写更加详细的变更描述(可选)。使用 "|" 换行 :\n', // breaking: '列举非兼容性重大的变更(可选)。使用 "|" 换行 :\n', // footerPrefixsSelect: "选择关联issue前缀(可选):", // customFooterPrefixs: "输入自定义issue前缀 :", // footer: "列举关联issue (可选) 例如: #31, #I3244 :\n", // confirmCommit: "是否提交或修改commit ?" }, types: [ { value: "feat", name: "feat: 🚀 A new feature", emoji: "🚀" }, { value: "fix", name: "fix: 🧩 A bug fix", emoji: "🧩" }, { value: "docs", name: "docs: 📚 Documentation only changes", emoji: "📚" }, { value: "style", name: "style: 🎨 Changes that do not affect the meaning of the code", emoji: "🎨" }, { value: "refactor", name: "refactor: ♻️ A code change that neither fixes a bug nor adds a feature", emoji: "♻️" }, { value: "perf", name: "perf: ⚡️ A code change that improves performance", emoji: "⚡️" }, { value: "test", name: "test: ✅ Adding missing tests or correcting existing tests", emoji: "✅" }, { value: "build", name: "build: 📦️ Changes that affect the build system or external dependencies", emoji: "📦️" }, { value: "ci", name: "ci: 🎡 Changes to our CI configuration files and scripts", emoji: "🎡" }, { value: "chore", name: "chore: 🔨 Other changes that don't modify src or test files", emoji: "🔨" }, { value: "revert", name: "revert: ⏪️ Reverts a previous commit", emoji: "⏪️" } // 中文版 // { value: "特性", name: "特性: 🚀 新增功能", emoji: "🚀" }, // { value: "修复", name: "修复: 🧩 修复缺陷", emoji: "🧩" }, // { value: "文档", name: "文档: 📚 文档变更", emoji: "📚" }, // { value: "格式", name: "格式: 🎨 代码格式(不影响功能,例如空格、分号等格式修正)", emoji: "🎨" }, // { value: "重构", name: "重构: ♻️ 代码重构(不包括 bug 修复、功能新增)", emoji: "♻️" }, // { value: "性能", name: "性能: ⚡️ 性能优化", emoji: "⚡️" }, // { value: "测试", name: "测试: ✅ 添加疏漏测试或已有测试改动", emoji: "✅" }, // { value: "构建", name: "构建: 📦️ 构建流程、外部依赖变更(如升级 npm 包、修改 webpack 配置等)", emoji: "📦️" }, // { value: "集成", name: "集成: 🎡 修改 CI 配置、脚本", emoji: "🎡" }, // { value: "回退", name: "回退: ⏪️ 回滚 commit", emoji: "⏪️" }, // { value: "其他", name: "其他: 🔨 对构建过程或辅助工具和库的更改(不影响源文件、测试用例)", emoji: "🔨" } ], useEmoji: true, themeColorCode: "", scopes: [], allowCustomScopes: true, allowEmptyScopes: true, customScopesAlign: "bottom", customScopesAlias: "custom", emptyScopesAlias: "empty", upperCaseSubject: false, allowBreakingChanges: ["feat", "fix"], breaklineNumber: 100, breaklineChar: "|", skipQuestions: [], issuePrefixs: [{ value: "closed", name: "closed: ISSUES has been processed" }], customIssuePrefixsAlign: "top", emptyIssuePrefixsAlias: "skip", customIssuePrefixsAlias: "custom", allowCustomIssuePrefixs: true, allowEmptyIssuePrefixs: true, confirmColorize: true, maxHeaderLength: Infinity, maxSubjectLength: Infinity, minSubjectLength: 0, scopeOverrides: undefined, defaultBody: "", defaultIssues: "", defaultScope: "", defaultSubject: "" } }; ================================================ FILE: deploy-front.sh ================================================ #!/usr/bin/env bash set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" ROOT_DIR="$SCRIPT_DIR" FRONTEND_DIR="${FRONTEND_DIR:-$ROOT_DIR}" DEFAULT_ENV_FILE="$ROOT_DIR/.env.deploy" FALLBACK_ENV_FILE="$ROOT_DIR/.env" ENV_FILE="${ENV_FILE:-$DEFAULT_ENV_FILE}" if [ -f "$ENV_FILE" ]; then set -a # shellcheck disable=SC1090 . "$ENV_FILE" set +a elif [ "$ENV_FILE" = "$DEFAULT_ENV_FILE" ] && [ -f "$FALLBACK_ENV_FILE" ]; then set -a # shellcheck disable=SC1090 . "$FALLBACK_ENV_FILE" set +a fi SERVER_HOST="${DEPLOY_SERVER_HOST:-${SERVER_HOST:-}}" SERVER_USER="${DEPLOY_SERVER_USER:-${SERVER_USER:-admin}}" SERVER_KEY="${DEPLOY_SERVER_KEY:-${SERVER_KEY:-}}" TARGET_DIR="${DEPLOY_TARGET_DIR:-${TARGET_DIR:-/home/admin}}" BUILD_CMD="${DEPLOY_BUILD_CMD:-${BUILD_CMD:-npm run build:pro}}" SKIP_BUILD="${DEPLOY_SKIP_BUILD:-${SKIP_BUILD:-0}}" HEALTHCHECK_URL="${DEPLOY_HEALTHCHECK_URL:-${HEALTHCHECK_URL:-}}" HEALTHCHECK_TIMEOUT="${DEPLOY_HEALTHCHECK_TIMEOUT:-${HEALTHCHECK_TIMEOUT:-15}}" ARTIFACT_NAME="dist.zip" ARTIFACT_PATH="$ROOT_DIR/$ARTIFACT_NAME" REMOTE_ARTIFACT_PATH="$TARGET_DIR/$ARTIFACT_NAME" SSH_OPTS=( -o StrictHostKeyChecking=accept-new ) log() { printf '[deploy] %s\n' "$1" } require_cmd() { if ! command -v "$1" >/dev/null 2>&1; then printf 'Missing required command: %s\n' "$1" >&2 exit 1 fi } require_cmd npm require_cmd zip require_cmd ssh require_cmd scp if [ -n "$HEALTHCHECK_URL" ]; then require_cmd curl fi if [ ! -d "$FRONTEND_DIR" ]; then printf 'Frontend directory not found: %s\n' "$FRONTEND_DIR" >&2 exit 1 fi if [ -z "$SERVER_HOST" ]; then printf 'DEPLOY_SERVER_HOST is required. Set it in %s or export it before running.\n' "$ENV_FILE" >&2 exit 1 fi if [ -n "$SERVER_KEY" ]; then if [ ! -f "$SERVER_KEY" ]; then printf 'SSH key not found: %s\n' "$SERVER_KEY" >&2 exit 1 fi SSH_OPTS=( -i "$SERVER_KEY" "${SSH_OPTS[@]}" ) fi cd "$FRONTEND_DIR" if [ "$SKIP_BUILD" != "1" ]; then log "building frontend with: $BUILD_CMD" eval "$BUILD_CMD" else log "skipping build because SKIP_BUILD=1" fi if [ ! -d "$FRONTEND_DIR/dist" ]; then printf 'Build output not found: %s\n' "$FRONTEND_DIR/dist" >&2 exit 1 fi rm -f "$ARTIFACT_PATH" log "creating artifact: $ARTIFACT_NAME" zip -qry "$ARTIFACT_PATH" dist log "ensuring remote target directory exists" ssh "${SSH_OPTS[@]}" "$SERVER_USER@$SERVER_HOST" "mkdir -p '$TARGET_DIR'" log "uploading artifact to $SERVER_USER@$SERVER_HOST:$TARGET_DIR" scp "${SSH_OPTS[@]}" "$ARTIFACT_PATH" "$SERVER_USER@$SERVER_HOST:$REMOTE_ARTIFACT_PATH" log "replacing remote dist directory" ssh "${SSH_OPTS[@]}" "$SERVER_USER@$SERVER_HOST" " set -e command -v unzip >/dev/null 2>&1 || { echo 'unzip is required on remote host' >&2; exit 1; } cd '$TARGET_DIR' rm -rf dist unzip -oq '$REMOTE_ARTIFACT_PATH' -d '$TARGET_DIR' rm -f '$REMOTE_ARTIFACT_PATH' " log "cleaning up local artifact" rm -f "$ARTIFACT_PATH" log "verifying remote dist/index.html" ssh "${SSH_OPTS[@]}" "$SERVER_USER@$SERVER_HOST" "test -f '$TARGET_DIR/dist/index.html'" if [ -n "$HEALTHCHECK_URL" ]; then log "checking health url: $HEALTHCHECK_URL" curl --fail --silent --show-error --location --max-time "$HEALTHCHECK_TIMEOUT" "$HEALTHCHECK_URL" >/dev/null fi log "deploy finished" ================================================ FILE: index.html ================================================ <%- title %>
================================================ FILE: launch.sh ================================================ #!/usr/bin/env bash # file to upload WEB_PKG="dist.tar.gz" WEB_PKG_BK="dist_bk.tar.gz" TMP_WEB_PKG="tmp.tar.gz" DEPLOY_SCRIPT="launch.sh" START_FUNC_NAME="start" BUILD_FUNC_NAME="build" INSTALL_FUNC_NAME="install" SERVER_FUNC_NAME="server" #env, ssh remote, work dir ENV_PRO="pro" SSH_HOST_PRO=("admin@39.105.208.175") WORK_DIR_PRO="/home/admin/workspace/admin/" ADMIN_WORKSPACE="dist" function build() { echo "---- start to build admin ----" echo "npm run build:pro" npm run build:pro tar -zcvf ${WEB_PKG} dist echo "---------- 静态资源包dist.tar.gz已打包完成 -------------" } function upload() { # upload jar # rename to *.jar.bak scp ${WEB_PKG} $1:$2${TMP_WEB_PKG} ret=$? if [[ ${ret} -ne 0 ]] ; then echo "Failed to scp ${WEB_PKG}" return 1 fi # upload script scp ${DEPLOY_SCRIPT} $1:$2 ret=$? if [[ ${ret} -ne 0 ]] ; then echo 'Failed to scp launch.sh' return 1 fi } function install() { npm install } function server() { npm run dev } function start() { echo "---- 开始部署 ----" cd ${WORK_DIR_PRO} mv ${WEB_PKG} ${WEB_PKG_BK} mv ${ADMIN_WORKSPACE} "${ADMIN_WORKSPACE}_bk" mv ${TMP_WEB_PKG} ${WEB_PKG} tar -zxvf ${WEB_PKG} echo "---- 部署完成 ----" } function deploy() { # package echo "*******Start to package*******" build $1 ret=$? if [[ ${ret} -ne 0 ]] ; then echo 'Failed to compile' exit ${ret} fi if [ "$1" = "${ENV_PRO}" ]; then SSH_HOST=${SSH_HOST_PRO[@]} WORK_DIR=${WORK_DIR_PRO} else echo "Unknown env: $1" exit fi for host in ${SSH_HOST[@]} do # upload jar and launch.sh echo "*******Start to upload:${host} *******" upload ${host} ${WORK_DIR} ret=$? if [[ ${ret} -ne 0 ]] ; then echo 'Failed to upload files' exit ${ret} fi done for host in ${SSH_HOST[@]} do # run echo "*******Start service:${host} *******" ssh ${host} "bash ${WORK_DIR}${DEPLOY_SCRIPT} ${START_FUNC_NAME}" echo "*******Done*******" done } if [ "$1" = "${START_FUNC_NAME}" ]; then start "$@" elif [ "$1" = "${BUILD_FUNC_NAME}" ]; then build $1 elif [ "$1" = "${INSTALL_FUNC_NAME}" ]; then install $1 elif [ "$1" = "${SERVER_FUNC_NAME}" ]; then server $1 elif [ "$1" = "${ENV_PRO}" ]; then deploy $1 else echo "=========== 本地环境安装 & 调试 ==============" echo "安装依赖: ./launch.sh install" echo "本地启动: ./launch.sh server" echo "=========== 上传服务器 & 服务器解压使用 ==============" echo "打包 dist.tar.gz: ./launch.sh build" echo "打包静态资源并上传到服务器 & 解压执行: ./launch.sh pro" echo "服务器上资源启用: ./launch.sh start" fi ================================================ FILE: lint-staged.config.js ================================================ module.exports = { "*.{js,jsx,ts,tsx}": ["eslint --fix", "prettier --write"], "{!(package)*.json,*.code-snippets,.!(browserslist)*rc}": ["prettier --write--parser json"], "package.json": ["prettier --write"], "*.{scss,less,styl}": ["stylelint --fix", "prettier --write"], "*.md": ["prettier --write"] }; ================================================ FILE: package.json ================================================ { "name": "react", "private": true, "version": "0.0.1", "scripts": { "dev": "vite", "serve": "vite", "build:dev": "tsc && vite build --mode development", "build:test": "tsc && vite build --mode test", "build:pro": "vite build --mode production", "preview": "vite preview", "lint:eslint": "eslint --fix --ext .js,.ts,.tsx ./src", "lint:prettier": "prettier --write --loglevel warn \"src/**/*.{js,ts,json,tsx,css,less,scss,html,md}\"", "lint:stylelint": "stylelint --cache --fix \"**/*.{less,postcss,css,scss}\" --cache --cache-location node_modules/.cache/stylelint/", "lint:lint-staged": "lint-staged", "prepare": "husky install", "release": "standard-version", "commit": "git pull && git add -A && git-cz && git push" }, "dependencies": { "@ant-design/icons": "^4.7.0", "@bytemd/plugin-gemoji": "^1.21.0", "@bytemd/plugin-gfm": "^1.21.0", "@bytemd/plugin-highlight": "^1.21.0", "@bytemd/plugin-math": "^1.21.0", "@bytemd/plugin-medium-zoom": "^1.21.0", "@bytemd/react": "^1.21.0", "@dnd-kit/core": "^6.1.0", "@dnd-kit/modifiers": "^7.0.0", "@dnd-kit/sortable": "^8.0.0", "antd": "^5.6.2", "antd-img-crop": "^4.12.2", "axios": "^0.27.2", "dayjs": "^1.11.7", "driver.js": "^0.9.8", "echarts": "^5.3.0", "echarts-liquidfill": "^3.1.0", "github-markdown-css": "^5.4.0", "i18next": "^21.8.10", "immer": "^9.0.15", "js-md5": "^0.7.3", "juejin-markdown-themes": "^1.32.1", "loadash": "^1.0.0", "lodash": "^4.17.21", "mammoth": "^1.11.0", "nprogress": "^0.2.0", "qs": "^6.10.5", "react": "^18.2.0", "react-activation": "^0.11.2", "react-dom": "^18.2.0", "react-highlight-words": "^0.20.0", "react-i18next": "^11.17.3", "react-moveable": "^0.56.0", "react-redux": "^8.0.2", "react-router-dom": "^6.3.0", "react-transition-group": "^4.4.2", "redux": "^4.2.0", "redux-persist": "^6.0.0", "redux-promise": "^0.6.0", "redux-thunk": "^2.4.2", "screenfull": "^6.0.2", "stylelint": "^14.16.1", "stylelint-config-prettier": "^9.0.5", "stylelint-config-recommended-scss": "^5.0.2" }, "devDependencies": { "@commitlint/cli": "^17.0.2", "@commitlint/config-conventional": "^17.0.2", "@types/lodash": "^4.14.194", "@types/node": "^17.0.41", "@types/react": "^18.0.0", "@types/react-dom": "^18.0.0", "@types/react-highlight-words": "^0.16.4", "@types/react-router-dom": "^5.3.3", "@types/redux-promise": "^0.5.29", "@typescript-eslint/eslint-plugin": "^5.27.1", "@typescript-eslint/parser": "^5.27.1", "@vitejs/plugin-react": "^1.3.0", "autoprefixer": "^10.4.7", "commitizen": "^4.2.4", "cz-git": "^1.3.4", "eslint": "^8.17.0", "eslint-config-prettier": "^8.5.0", "eslint-plugin-prettier": "^4.0.0", "eslint-plugin-react": "^7.30.0", "eslint-plugin-react-hooks": "^4.5.0", "eslint-plugin-simple-import-sort": "^8.0.0", "husky": "^8.0.1", "less": "^4.1.3", "lint-staged": "^13.0.2", "postcss": "^8.4.14", "prettier": "^2.6.2", "rollup-plugin-visualizer": "^5.6.0", "sass": "^1.55.0", "standard-version": "^9.5.0", "stylelint-config-recess-order": "^3.0.0", "stylelint-config-standard": "^26.0.0", "stylelint-less": "^1.0.6", "typescript": "^4.6.3", "vite": "^4.3.9", "vite-plugin-compression": "^0.5.1", "vite-plugin-eslint": "^1.6.1", "vite-plugin-html": "^3.2.0", "vite-plugin-style-import": "^2.0.0", "vite-plugin-svg-icons": "^2.0.1" }, "config": { "commitizen": { "path": "node_modules/cz-git" } } } ================================================ FILE: postcss.config.js ================================================ module.exports = { plugins: { autoprefixer: {} } }; ================================================ FILE: src/App.tsx ================================================ import { connect } from "react-redux"; import { HashRouter } from "react-router-dom"; import { ConfigProvider } from "antd"; import zhCN from "antd/lib/locale/zh_CN"; import useTheme from "@/hooks/useTheme"; import Router from "@/routers/index"; import AuthRouter from "@/routers/utils/authRouter"; import "./index.scss"; const App = (props: any) => { const { assemblySize, themeConfig } = props; // 全局使用主题 useTheme(themeConfig); return ( ); }; const mapStateToProps = (state: any) => state.global; const mapDispatchToProps = {}; export default connect(mapStateToProps, mapDispatchToProps)(App); ================================================ FILE: src/api/config/servicePort.ts ================================================ // 后端微服务端口名 export const PORT1 = ""; ================================================ FILE: src/api/helper/axiosCancel.ts ================================================ import axios, { AxiosRequestConfig, Canceler } from "axios"; import qs from "qs"; import { isFunction } from "@/utils/is/index"; // * 声明一个 Map 用于存储每个请求的标识 和 取消函数 let pendingMap = new Map(); // * 序列化参数 export const getPendingUrl = (config: AxiosRequestConfig) => [config.method, config.url, qs.stringify(config.data), qs.stringify(config.params)].join("&"); export class AxiosCanceler { /** * @description: 添加请求 * @param {Object} config */ addPending(config: AxiosRequestConfig) { // * 在请求开始前,对之前的请求做检查取消操作 this.removePending(config); const url = getPendingUrl(config); config.cancelToken = config.cancelToken || new axios.CancelToken(cancel => { if (!pendingMap.has(url)) { // 如果 pending 中不存在当前请求,则添加进去 pendingMap.set(url, cancel); } }); } /** * @description: 移除请求 * @param {Object} config */ removePending(config: AxiosRequestConfig) { const url = getPendingUrl(config); if (pendingMap.has(url)) { // 如果在 pending 中存在当前请求标识,需要取消当前请求,并且移除 const cancel = pendingMap.get(url); cancel && cancel(); pendingMap.delete(url); } } /** * @description: 清空所有pending */ removeAllPending() { pendingMap.forEach(cancel => { cancel && isFunction(cancel) && cancel(); }); pendingMap.clear(); } /** * @description: 重置 */ reset(): void { pendingMap = new Map(); } } ================================================ FILE: src/api/helper/checkStatus.ts ================================================ import { message } from "antd"; /** * @description: 校验网络请求状态码 * @param {Number} status * @return void */ export const checkStatus = (status: number): void => { switch (status) { case 400: message.error("请求失败!请您稍后重试"); break; case 401: message.error("登录失效!请您重新登录"); break; case 403: message.error("当前账号无权限访问!"); break; case 404: message.error("你所访问的资源不存在!"); break; case 405: message.error("请求方式错误!请您稍后重试"); break; case 408: message.error("请求超时!请您稍后重试"); break; case 500: message.error("服务异常!"); break; case 502: message.error("网关错误!"); break; case 503: message.error("服务不可用!"); break; case 504: message.error("网关超时!"); break; default: message.error("请求失败!"); } }; ================================================ FILE: src/api/index.ts ================================================ import { message } from "antd"; import axios, { AxiosError, AxiosInstance, AxiosRequestConfig, AxiosResponse } from "axios"; import { PaiRes, ResultData } from "@/api/interface"; import { LOGIN_URL } from "@/config/config"; import NProgress from "@/config/nprogress"; import { showFullScreenLoading, tryHideFullScreenLoading } from "@/config/serviceLoading"; import { ResultEnum } from "@/enums/httpEnum"; import { store } from "@/redux"; import { setToken } from "@/redux/modules/global/action"; import { AxiosCanceler } from "./helper/axiosCancel"; import { checkStatus } from "./helper/checkStatus"; const axiosCanceler = new AxiosCanceler(); const config = { // 默认地址请求地址,可在 .env 开头文件中修改,在 Axios 中使用 // 实例化的使用用到 baseURL: import.meta.env.VITE_API_URL as string, // 跨域时候允许携带凭证 withCredentials: true }; class RequestHttp { service: AxiosInstance; // 构造方法 public constructor(config: AxiosRequestConfig) { // 实例化axios this.service = axios.create(config); /** * @description 请求拦截器 * 客户端发送请求 -> [请求拦截器] -> 服务器 * token校验(JWT) : 接受服务器返回的token,存储到redux/本地储存当中 */ this.service.interceptors.request.use( (config: AxiosRequestConfig) => { console.log("发起请求"); // 进度条开始 NProgress.start(); // 将当前请求添加到 pending 中 axiosCanceler.addPending(config); // 如果当前请求不需要显示 loading // 在api服务中通过指定的第三个参数: { headers: { noLoading: true } }来控制不显示loading,参见loginApi config.headers!.noLoading || showFullScreenLoading(); // 从 Redux store 中获取 token 并将其添加到请求的 headers 中。这通常用于身份验证,确保后端可以验证用户的身份。 const token: string = store.getState().global.token; console.log("token", token); return { ...config, headers: { ...config.headers, "x-access-token": token } }; }, (error: AxiosError) => { console.log("error", error); return Promise.reject(error); } ); /** * @description 响应拦截器 * 服务器换返回信息 -> [拦截统一处理] -> 客户端JS获取到信息 */ this.service.interceptors.response.use( (response: AxiosResponse) => { console.log("response", response); const { data, config } = response; // 进度条结束 NProgress.done(); // 在请求结束后,移除本次请求(关闭loading) axiosCanceler.removePending(config); tryHideFullScreenLoading(); // 服务器返回的状态码 // 如果响应是文件流(通过 responseType 判断) if (config.responseType === "blob") { // 跳过 dataStatus.code 检查 // TODO(防止下载文件的时候返回数据流,没有code,直接报错) return response; } const dataStatus = data.status; // 登录失效(code == 599) if (dataStatus && dataStatus.code == ResultEnum.NOT_LOGIN) { // 重定向到登录页面 store.dispatch(setToken("")); message.error(dataStatus.msg); window.location.hash = LOGIN_URL; return Promise.reject(data); } // 全局错误信息拦截 if (dataStatus.code && dataStatus.code !== ResultEnum.SUCCESS) { message.error(dataStatus.msg); return Promise.reject(data); } // 成功请求(在页面上除非特殊情况,否则不用处理失败逻辑) return data; }, async (error: AxiosError) => { console.log("error", error); const { response } = error; NProgress.done(); tryHideFullScreenLoading(); // 请求超时单独判断,请求超时没有 response if (error.message.indexOf("timeout") !== -1) message.error("请求超时,请稍后再试"); // 根据响应的错误状态码,做不同的处理 if (response) checkStatus(response.status); // 服务器结果都没有返回(可能服务器错误可能客户端断网) 断网处理:可以跳转到断网页面 if (!window.navigator.onLine) window.location.hash = "/500"; return Promise.reject(error); } ); } // * 常用请求方法封装 down(url: string, config: AxiosRequestConfig = {}): Promise> { console.log("开始执行 get 请求,下载文件", url, config); return this.service.get(url, config); } get(url: string, params?: object, _object = {}): Promise> { return this.service.get(url, { params, ..._object }); } post(url: string, params?: object, _object = {}): Promise> { return this.service.post(url, params, _object); } postForm(url: string, params?: object, _object = {}): Promise> { return this.service.post(url, params, _object); } put(url: string, params?: object, _object = {}): Promise> { return this.service.put(url, params, _object); } delete(url: string, params?: any, _object = {}): Promise> { return this.service.delete(url, { params, ..._object }); } } export default new RequestHttp(config); ================================================ FILE: src/api/interface/index.ts ================================================ // * 请求响应参数(不包含data) export interface Result { code: string; msg: string; } // * 请求响应参数(包含data) export interface ResultData extends Result { data?: T; status?: Status; result?: T; } export interface Status { code: number; msg: string; } export interface PaiRes { status: Status; result?: T; } // * 分页响应参数 export interface ResPage { datalist: T[]; pageNum: number; pageSize: number; total: number; } // * 分页请求参数 export interface ReqPage { pageNum: number; pageSize: number; } // * 登录 export namespace Login { export interface ReqLoginForm { username: string; password: string; } export interface ResLogin { access_token: string; userId: number; // 登录用户名 userName: string; // 用户头像 photo: string; } export interface ResAuthButtons { [propName: string]: any; } } ================================================ FILE: src/api/modules/aiConfig.ts ================================================ import http from "@/api"; import { PORT1 } from "@/api/config/servicePort"; import { Login } from "@/api/interface/index"; export type AISourceValue = | "CHAT_GPT_3_5" | "CHAT_GPT_4" | "PAI_AI" | "XUN_FEI_AI" | "ZHI_PU_AI" | "ZHIPU_CODING" | "ALI_AI" | "DEEP_SEEK" | "DOU_BAO_AI"; export interface GptModelConfig { keys?: string[]; proxy?: boolean; apiHost?: string; timeOut?: number; maxToken?: number; } export interface ChatGptConfig { main?: AISourceValue; gpt35?: GptModelConfig; gpt4?: GptModelConfig; } export interface ZhipuConfig { apiSecretKey?: string; requestIdTemplate?: string; model?: string; } export interface XunFeiConfig { hostUrl?: string; domain?: string; appId?: string; apiKey?: string; apiSecret?: string; apiPassword?: string; } export interface ZhipuCodingConfig { apiKey?: string; apiHost?: string; model?: string; timeout?: number; } export interface DeepSeekConfig { apiKey?: string; apiHost?: string; model?: string; timeout?: number; } export interface DoubaoConfig { apiKey?: string; apiHost?: string; endPoint?: string; } export interface AliConfig { model?: string; } export interface AiConfigAdminDTO { sources?: AISourceValue[]; chatGpt?: ChatGptConfig; zhipu?: ZhipuConfig; zhipuCoding?: ZhipuCodingConfig; xunFei?: XunFeiConfig; deepSeek?: DeepSeekConfig; doubao?: DoubaoConfig; ali?: AliConfig; } export type AiConfigAdminReq = AiConfigAdminDTO; export interface AiConfigTestReq { source: AISourceValue; prompt?: string; } export interface AiConfigTestRes { source?: AISourceValue; success?: boolean; message?: string; answer?: string; costMs?: number; } export const getAiConfigDetailApi = () => { return http.get(`${PORT1}/ai/config/detail`); }; export const saveAiConfigApi = (params: AiConfigAdminReq) => { return http.post(`${PORT1}/ai/config/save`, params); }; export const testAiConfigApi = (params: AiConfigTestReq) => { return http.post(`${PORT1}/ai/config/test`, params); }; ================================================ FILE: src/api/modules/article.ts ================================================ import http from "@/api"; import { PORT1 } from "@/api/config/servicePort"; import { Login } from "@/api/interface/index"; /** * @name 文章模块 */ // 获取列表 export const getArticleListApi = (data: { pageNumber: number; pageSize: number }) => { return http.post(`${PORT1}/article/list`, data); }; // 更新标题操作 export const updateArticleApi = (params: object | undefined) => { return http.post(`${PORT1}/article/update`, params); }; // 保存操作 export const saveArticleApi = (params: object | undefined) => { return http.post(`${PORT1}/article/save`, params); }; // 转链上传图片的操作 export const saveImgApi = (params: string) => { return http.get(`${PORT1}/image/save?img=` + params); }; // 删除操作 export const delArticleApi = (articleId: number) => { return http.get(`${PORT1}/article/delete`, { articleId }); }; // 获取文章 export const getArticleApi = (articleId: number) => { return http.get(`${PORT1}/article/detail`, { articleId }); }; // 置顶/加精操作 export const operateArticleApi = (params: object) => { return http.get(`${PORT1}/article/operate`, params); }; // 上线/下线操作 export const examineArticleApi = (params: object | undefined) => { return http.get(`${PORT1}/article/examine`, params); }; // AI 生成标题和简介 export const generateArticleAiApi = (params: { shortTitle: string; content: string }) => { return http.post(`${PORT1}/article/generate/seo`, params); }; // 生成语义 URL export const generateArticleSlugApi = (params: { title?: string; shortTitle?: string }) => { return http.post(`${PORT1}/article/generate/slug`, params, { headers: { noLoading: true }, timeout: 15000 }); }; ================================================ FILE: src/api/modules/author.ts ================================================ import http from "@/api"; import { PORT1 } from "@/api/config/servicePort"; import { Login } from "@/api/interface/index"; // 添加返回类型 export const getAuthorWhiteListApi = () => { return http.get(`${PORT1}/author/whitelist/get`); }; // 获取知识星球用户白名单 export const getZsxqWhiteListApi = (data: { pageNumber: number; pageSize: number }) => { return http.post(`${PORT1}/zsxq/whitelist`, data); }; // 更新知识星球用户白名单 export const updateZsxqWhiteApi = (params: object | undefined) => { return http.post(`${PORT1}/zsxq/whitelist/save`, params); }; // 审核知识星球用户白名单 export const operateZsxqWhiteApi = (params: object | undefined) => { return http.get(`${PORT1}/zsxq/whitelist/operate`, params); }; // 审核知识星球用户白名单,批量 export const operateBatchZsxqWhiteApi = (params: object | undefined) => { return http.post(`${PORT1}/zsxq/whitelist/batchOperate`, params); }; // 重置操作 export const resetAuthorWhiteApi = (authorId: number) => { return http.get(`${PORT1}/zsxq/whitelist/reset`, { authorId }); }; // 保存操作 export const updateAuthorWhiteApi = (authorId: number) => { return http.get(`${PORT1}/author/whitelist/add`, { authorId }); }; // 删除作者白名单 export const delAuthorWhiteApi = (authorId: number) => { return http.get(`${PORT1}/author/whitelist/remove`, { authorId }); }; ================================================ FILE: src/api/modules/category.ts ================================================ import http from "@/api"; import { PORT1 } from "@/api/config/servicePort"; import { Login } from "@/api/interface/index"; import { IFormType } from "@/views/config"; /** * @name 分类模块 */ // 获取列表 export const getCategoryListApi = (data: { pageNumber: number; pageSize: number }) => { return http.post(`${PORT1}/category/list`, data); }; // 删除操作 export const delCategoryApi = (categoryId: number) => { return http.get(`${PORT1}/category/delete`, { categoryId }); }; // 保存操作 export const updateCategoryApi = (form: IFormType) => { return http.post(`${PORT1}/category/save`, form); }; // 上线/下线操作 export const operateCategoryApi = (params: object | undefined) => { return http.get(`${PORT1}/category/operate`, params); }; ================================================ FILE: src/api/modules/column.ts ================================================ import http from "@/api"; import { PORT1 } from "@/api/config/servicePort"; import { Login } from "@/api/interface/index"; import { IFormType } from "@/views/column/setting"; import { IArticleSortFormType, IGroupFormType } from "@/views/column/setting/articlesort"; import { IMoveType } from "@/views/column/setting/groups"; /** * @name 教程模块 */ // 获取列表 export const getColumnListApi = (data: { pageNumber: number; pageSize: number }) => { return http.post(`${PORT1}/column/list`, data); }; // 添加返回类型 export const getColumnByNameListApi = (key: string) => { return http.get(`${PORT1}/column/query`, { key }); }; // 获取作者列表,参数为作者名称 export const getAuthorListApi = (key: string) => { return http.get(`${PORT1}/user/query`, { key }); }; // 保存操作 export const updateColumnApi = (form: IFormType) => { return http.post(`${PORT1}/column/saveColumn`, form); }; // 删除专栏操作 export const delColumnApi = (columnId: number) => { return http.get(`${PORT1}/column/deleteColumn`, { columnId }); }; // 获取列表 export const getColumnArticleListApi = (data: { columnId: number; pageNumber: number; pageSize: number }) => { return http.post(`${PORT1}/column/listColumnArticle`, data); }; // 根据专栏文章分组的方式,获取文章列表 export const getColumnGroupArticlesApi = (columnId: number) => { return http.get(`${PORT1}/column/listColumnByGroup`, { columnId }); }; // 获取专栏设置的分组列表 export const getColumnGroupListApi = (columnId: number) => { return http.get(`${PORT1}/column/listGroups`, { columnId }); }; // 保存专栏文章分组 export const updateGroupApi = (form: IGroupFormType) => { return http.post(`${PORT1}/column/saveColumnGroup`, form); }; // 删除专栏文章分组 export const deleteGroupApi = (groupId: number) => { return http.get(`${PORT1}/column/deleteColumnGroup`, { groupId }); }; // 保存操作 export const updateColumnArticleApi = (form: IFormType) => { return http.post(`${PORT1}/column/saveColumnArticle`, form); }; // 删除教程操作 export const delColumnArticleApi = (id: number) => { return http.get(`${PORT1}/column/deleteColumnArticle`, { id }); }; // 调整两个教程的顺序 export const sortColumnArticleApi = (activeId: number, overId: number) => { return http.post(`${PORT1}/column/sortColumnArticleApi`, { activeId, overId }); }; // 调整教程的顺序 export const sortColumnArticleByIDApi = (form: IArticleSortFormType) => { return http.post(`${PORT1}/column/sortColumnArticleByIDApi`, form); }; // 拖拽移动教程或者分组 export const moveColumnArticleOrGroup = (form: IMoveType) => { return http.post(`${PORT1}/column/moveColumnArticleOrGroup`, form); }; ================================================ FILE: src/api/modules/comment.ts ================================================ import http from "@/api"; import { PORT1 } from "@/api/config/servicePort"; import { Login } from "@/api/interface"; export interface SearchCommentReq { commentId?: number; articleId?: number; articleTitle?: string; userId?: number; userName?: string; content?: string; commentType?: number; pageNumber: number; pageSize: number; } export interface CommentSaveReq { commentId?: number; articleId: number; parentCommentId?: number; topCommentId?: number; commentContent: string; } export interface CommentAdminDTO { commentId: number; articleId: number; articleTitle?: string; userId: number; userName?: string; userAvatar?: string; commentContent: string; parentCommentId: number; topCommentId: number; parentCommentContent?: string; topCommentContent?: string; commentType: number; replyCount?: number; praiseCount?: number; highlightInfo?: string; createTime?: string; updateTime?: string; } export interface CommentPageDTO { list: CommentAdminDTO[]; pageNum: number; pageSize: number; total: number; pageTotal: number; } export const getCommentListApi = (params: SearchCommentReq) => { return http.post(`${PORT1}/comment/list`, params); }; export const getCommentDetailApi = (commentId: number) => { return http.get(`${PORT1}/comment/detail`, { commentId }); }; export const saveCommentApi = (params: CommentSaveReq) => { return http.post(`${PORT1}/comment/save`, params); }; export const deleteCommentApi = (commentId: number) => { return http.get(`${PORT1}/comment/delete`, { commentId }); }; ================================================ FILE: src/api/modules/common.ts ================================================ import http from "@/api"; import { PORT1 } from "@/api/config/servicePort"; import { Login } from "@/api/interface/index"; import { baseDomain } from "@/utils/util"; /** * @name 分类模块 */ // 获取字典值 export const getDiscListApi = () => { console.log("获取字典,getDiscListApi"); return http.get(`${PORT1}/common/dict`); }; // 上传图片 export const uploadImgApi = (data: FormData) => { // 添加时间戳参数,确保每个请求 URL 不同,避免被 AxiosCanceler 取消 return http.post(`${PORT1}/image/upload?t=${Date.now()}`, data); }; // 文件上传 export const uploadFileUrl = () => { return `${baseDomain}/oss/upload`; }; ================================================ FILE: src/api/modules/config.ts ================================================ import http from "@/api"; import { PORT1 } from "@/api/config/servicePort"; import { Login } from "@/api/interface/index"; import { IFormType } from "@/views/config"; /** * @name 分类模块 */ // 获取列表 export const getConfigListApi = (data: { pageNumber: number; pageSize: number }) => { return http.post(`${PORT1}/config/list`, data); }; // 删除操作 export const delConfigApi = (configId: number) => { return http.get(`${PORT1}/config/delete`, { configId }); }; // 保存操作 export const updateConfigApi = (form: IFormType) => { return http.post(`${PORT1}/config/save`, form); }; // 上线/下线操作 export const operateConfigApi = (params: object | undefined) => { return http.get(`${PORT1}/config/operate`, params); }; // 刷新配置缓存 export const refreshConfigApi = () => { return http.get(`${PORT1}/config/refresh`); }; ================================================ FILE: src/api/modules/global.ts ================================================ import http from "@/api"; import { PORT1 } from "@/api/config/servicePort"; import { Login } from "@/api/interface/index"; import { IFormType } from "@/views/global"; /** * @name 标签模块 */ // 获取列表 export const getGlobalConfigListApi = (data: { pageNumber: number; pageSize: number }) => { return http.post(`${PORT1}/global/config/list`, data); }; // 删除操作 export const delGlobalConfigApi = (id: number) => { return http.get(`${PORT1}/global/config/delete`, { id }); }; // 保存操作 export const updateGlobalConfigApi = (form: IFormType) => { return http.post(`${PORT1}/global/config/save`, form); }; ================================================ FILE: src/api/modules/login.ts ================================================ import qs from "qs"; import http from "@/api"; import { PORT1 } from "@/api/config/servicePort"; import { Login } from "@/api/interface/index"; /** * @name 登录模块 */ // * 用户登录接口 export const loginApi = (params: Login.ReqLoginForm) => { return http.postForm(PORT1 + `/login`, qs.stringify(params)); // post 请求携带 表单 参数 ==> application/x-www-form-urlencoded }; // * 退出登录接口 export const logoutApi = () => { return http.get(PORT1 + `/logout`); }; /** * 查询当前登录的用户信息 */ export const loginUserInfo = () => { return http.get(PORT1 + `/info`); }; // * 获取按钮权限 export const getAuthorButtons = () => { return http.get(PORT1 + `/auth/buttons`); }; // * 获取菜单列表 export const getMenuList = () => { return http.get(PORT1 + `/menu/list`); }; ================================================ FILE: src/api/modules/resume.ts ================================================ import http from "@/api"; import { PORT1 } from "@/api/config/servicePort"; import { Login } from "@/api/interface/index"; import { IFormType } from "@/views/resume"; /** * @name 标签模块 */ // 获取列表 export const getResumeListApi = (data: { pageNumber: number; pageSize: number }) => { return http.post(`${PORT1}/resume/list`, data); }; // 删除操作 export const delResumeApi = (resumeId: number) => { return http.get(`${PORT1}/resume/delete?resumeId=${resumeId}`); }; // 上传 export const replayResumeApi = (form: IFormType) => { return http.post(`${PORT1}/resume/replay`, form); }; // 下载 export const downResumeApi = (resumeId: number) => { return http.get(`${PORT1}/resume/process?resumeId=${resumeId}`); }; ================================================ FILE: src/api/modules/sensitive.ts ================================================ import http from "@/api"; import { PORT1 } from "@/api/config/servicePort"; import { Login } from "@/api/interface/index"; export interface SensitiveWordHitDTO { word: string; hitCount: number; } export interface SensitiveWordConfigDTO { enable: boolean; denyWords: string[]; allowWords: string[]; hitTotal?: number; hitWords?: SensitiveWordHitDTO[]; } export interface SensitiveWordConfigReq { enable: boolean; denyWords: string[]; allowWords: string[]; } export interface SensitiveWordHitPageReq { pageNumber: number; pageSize: number; } export interface SensitiveWordHitPageDTO { list: SensitiveWordHitDTO[]; pageNum: number; pageSize: number; total: number; pageTotal: number; } export const getSensitiveWordDetailApi = () => { return http.get(`${PORT1}/sensitive/detail`); }; export const saveSensitiveWordConfigApi = (params: SensitiveWordConfigReq) => { return http.post(`${PORT1}/sensitive/save`, params); }; export const getSensitiveWordHitListApi = (params: SensitiveWordHitPageReq) => { return http.post(`${PORT1}/sensitive/hit/list`, params); }; export const clearSensitiveWordHitApi = (word: string) => { return http.post(`${PORT1}/sensitive/hit/clear`, { word }); }; ================================================ FILE: src/api/modules/statistics.ts ================================================ import http from "@/api"; import { PORT1 } from "@/api/config/servicePort"; /** * @name 数据统计模块 */ export const getAllApi = () => { return http.get(`${PORT1}/statistics/queryTotal`); }; export const getPvUvApi = (day: number) => { return http.get(`${PORT1}/statistics/pvUvDayList?day=${day}`); }; export const download2ExcelPvUvApi = (day: number) => { return http.down(`${PORT1}/statistics/pvUvDayDownload2Excel?day=${day}`, { responseType: "blob" }); }; ================================================ FILE: src/api/modules/tag.ts ================================================ import http from "@/api"; import { PORT1 } from "@/api/config/servicePort"; import { Login } from "@/api/interface/index"; import { IFormType } from "@/views/tag"; /** * @name 标签模块 */ // 获取列表 export const getTagListApi = (data: any) => { return http.post(`${PORT1}/tag/list`, data); }; // 删除操作 export const delTagApi = (tagId: number) => { return http.get(`${PORT1}/tag/delete`, { tagId }); }; // 保存操作 export const updateTagApi = (form: IFormType) => { return http.post(`${PORT1}/tag/save`, form); }; // 上线/下线操作 export const operateTagApi = (params: object | undefined) => { return http.get(`${PORT1}/tag/operate`, params); }; ================================================ FILE: src/api/modules/user.ts ================================================ import http from "@/api"; import { PORT1 } from "@/api/config/servicePort"; export interface UserLoginAuditItem { id: number; userId?: number; loginName?: string; starNumber?: string; loginType?: number; loginTypeDesc?: string; eventType?: string; eventTypeDesc?: string; deviceId?: string; deviceName?: string; ip?: string; region?: string; sessionHash?: string; riskTag?: string; reason?: string; createTime?: string; } export interface UserSessionItem { id: number; userId?: number; loginName?: string; loginType?: number; loginTypeDesc?: string; sessionHash?: string; deviceId?: string; deviceName?: string; ip?: string; region?: string; sessionState?: string; sessionStateDesc?: string; offlineReason?: string; latestSeenTime?: string; expireTime?: string; offlineTime?: string; createTime?: string; } export interface UserShareRiskItem { userId?: number; loginName?: string; starNumber?: string; kickoutCount?: number; loginSuccessCount?: number; deviceCount?: number; ipCount?: number; lastKickoutTime?: string; lastActiveTime?: string; riskLevel?: string; riskReason?: string; forbidden?: boolean; forbidUntil?: string; forbidReason?: string; } export interface LoginAuditQuery { userId?: number; loginName?: string; starNumber?: string; deviceId?: string; ip?: string; eventType?: string; pageNumber: number; pageSize: number; } export interface UserShareRiskQuery { loginName?: string; recentDays?: number; minKickoutCount?: number; minDeviceCount?: number; minIpCount?: number; pageNumber: number; pageSize: number; } export interface UserSessionQuery { userId?: number; loginName?: string; deviceId?: string; ip?: string; activeOnly?: boolean; pageNumber: number; pageSize: number; } export interface UserForbidReq { userId: number; days?: number; reason?: string; } export interface UserUnforbidReq { userId: number; } export interface PageResult { list: T[]; pageNum: number; pageSize: number; total: number; pageTotal?: number; } export const getLoginAuditListApi = (params: LoginAuditQuery) => { return http.post>(`${PORT1}/user/login-audit`, params); }; export const getUserSessionListApi = (params: UserSessionQuery) => { return http.post>(`${PORT1}/user/session`, params); }; export const getUserShareRiskListApi = (params: UserShareRiskQuery) => { return http.post>(`${PORT1}/user/share-risk`, params); }; export const forbidUserApi = (params: UserForbidReq) => { return http.post(`${PORT1}/user/forbid`, params); }; export const unforbidUserApi = (params: UserUnforbidReq) => { return http.post(`${PORT1}/user/unforbid`, params); }; ================================================ FILE: src/api/modules/wxMenu.ts ================================================ import http from "@/api"; import { PORT1 } from "@/api/config/servicePort"; export interface WxMenuButton { type?: string; name?: string; key?: string; url?: string; appid?: string; pagepath?: string; media_id?: string; article_id?: string; sub_button?: WxMenuButton[]; } export interface WxMenuTree { button?: WxMenuButton[]; } export interface WxMenuReplyArticle { title?: string; description?: string; picUrl?: string; url?: string; } export interface WxMenuReply { replyType?: string; content?: string; articles?: WxMenuReplyArticle[]; } export interface WxMenuKeywordReply { matchType?: string; keywords?: string[]; replyType?: string; reply?: WxMenuReply; enabled?: boolean; priority?: number; title?: string; } export interface WxMenuClickReply { key?: string; title?: string; reply?: WxMenuReply; } export interface WxMenuAiProviderOption { code?: number; value?: string; name?: string; syncSupport?: boolean; } export interface WxMenuConfig { menuJson?: string; comment?: string; subscribeReply?: WxMenuReply; defaultReply?: WxMenuReply; keywordReplies?: WxMenuKeywordReply[]; messageFallbackStrategy?: string; aiPrompt?: string; aiProvider?: string; aiEnable?: boolean; aiProviderOptions?: WxMenuAiProviderOption[]; clickReplies?: WxMenuClickReply[]; } export interface WxMenuDetail { draftConfig?: WxMenuConfig; draftJson?: string; draftComment?: string; draftMenu?: WxMenuTree; draftValid?: boolean; draftErrors?: string[]; draftWarnings?: string[]; subscribeReply?: WxMenuReply; defaultReply?: WxMenuReply; keywordReplies?: WxMenuKeywordReply[]; messageFallbackStrategy?: string; aiPrompt?: string; aiProvider?: string; aiEnable?: boolean; aiProviderOptions?: WxMenuAiProviderOption[]; clickReplies?: WxMenuClickReply[]; remoteJson?: string; remoteMenu?: WxMenuTree; conditionalMenuCount?: number; remoteError?: string; menuJsonTemplate?: string; menuJsonTips?: string[]; replyTips?: string[]; } export interface WxMenuSaveReq { menuJson: string; comment?: string; subscribeReply?: WxMenuReply; defaultReply?: WxMenuReply; keywordReplies?: WxMenuKeywordReply[]; messageFallbackStrategy?: string; aiPrompt?: string; aiProvider?: string; aiEnable?: boolean; clickReplies?: WxMenuClickReply[]; } export interface WxMenuValidateReq { menuJson?: string; subscribeReply?: WxMenuReply; defaultReply?: WxMenuReply; keywordReplies?: WxMenuKeywordReply[]; messageFallbackStrategy?: string; aiPrompt?: string; aiProvider?: string; aiEnable?: boolean; clickReplies?: WxMenuClickReply[]; } export interface WxMenuValidateRes { valid?: boolean; normalizedMenuJson?: string; menuErrors?: string[]; replyErrors?: string[]; errors?: string[]; warnings?: string[]; } export interface WxMenuPreviewMatchReq extends WxMenuSaveReq { eventType?: string; eventKey?: string; content?: string; } export interface WxMenuPreviewMatchRes { matched?: boolean; matchedRuleTitle?: string; matchedRuleType?: string; matchedKeyword?: string; reply?: WxMenuReply; fallbackStrategy?: string; usedFallback?: boolean; } export interface WxMenuPreviewAiReq { content?: string; aiPrompt?: string; aiProvider?: string; aiEnable?: boolean; } export interface WxMenuPreviewAiRes { success?: boolean; replyText?: string; provider?: string; errorMsg?: string; } export interface WxMenuPublishReq { menuJson?: string; } export interface WxMenuPublishRes { success?: boolean; errCode?: number; errMsg?: string; publishedMenuJson?: string; } export const getWxMenuDetailApi = () => { return http.get(`${PORT1}/wx/menu/detail`); }; export const saveWxMenuDraftApi = (params: WxMenuSaveReq) => { return http.post(`${PORT1}/wx/menu/save`, params); }; export const validateWxMenuApi = (params?: WxMenuValidateReq) => { return http.post(`${PORT1}/wx/menu/validate`, params); }; export const previewWxMenuMatchApi = (params?: WxMenuPreviewMatchReq) => { return http.post(`${PORT1}/wx/menu/preview/match`, params); }; export const previewWxMenuAiApi = (params?: WxMenuPreviewAiReq) => { return http.post(`${PORT1}/wx/menu/preview/ai`, params); }; export const publishWxMenuApi = (params?: WxMenuPublishReq) => { return http.post(`${PORT1}/wx/menu/publish`, params); }; export const syncWxMenuApi = () => { return http.post(`${PORT1}/wx/menu/sync`); }; ================================================ FILE: src/assets/fonts/font.less ================================================ @font-face { font-family: YouSheBiaoTiHei; src: url("./YouSheBiaoTiHei.ttf"); } @font-face { font-family: MetroDF; src: url("./MetroDF.ttf"); } @font-face { font-family: DIN; src: url("./DIN.Otf"); } ================================================ FILE: src/assets/iconfont/iconfont.less ================================================ @font-face { font-family: iconfont; src: url("iconfont.ttf?t=1648886414212") format("truetype"); } .iconfont { font-family: iconfont !important; font-size: 16px; -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; font-style: normal; } .icon-zhongyingwen::before { content: "\e605"; } .icon-suoxiao::before { content: "\e641"; } .icon-fangda::before { content: "\e826"; } .icon-contentright::before { content: "\e8c9"; } .icon-zhuti::before { content: "\e62b"; } ================================================ FILE: src/components/ErrorMessage/403.tsx ================================================ import { useNavigate } from "react-router-dom"; import { Button, Result } from "antd"; import { HOME_URL } from "@/config/config"; import "./index.less"; const NotAuth = () => { const navigate = useNavigate(); const goHome = () => { navigate(HOME_URL); }; return ( Back Home } /> ); }; export default NotAuth; ================================================ FILE: src/components/ErrorMessage/404.tsx ================================================ import { useNavigate } from "react-router-dom"; import { Button, Result } from "antd"; import { HOME_URL } from "@/config/config"; import "./index.less"; const NotFound = () => { const navigate = useNavigate(); const goHome = () => { navigate(HOME_URL); }; return ( Back Home } /> ); }; export default NotFound; ================================================ FILE: src/components/ErrorMessage/500.tsx ================================================ import { useNavigate } from "react-router-dom"; import { Button, Result } from "antd"; import { HOME_URL } from "@/config/config"; import "./index.less"; const NotNetwork = () => { const navigate = useNavigate(); const goHome = () => { navigate(HOME_URL); }; return ( Back Home } /> ); }; export default NotNetwork; ================================================ FILE: src/components/ErrorMessage/index.less ================================================ .ant-result { display: flex; flex-direction: column; align-items: center; justify-content: center; height: 100%; .ant-result-image { margin: 0; } } ================================================ FILE: src/components/Loading/index.less ================================================ /* 请求 Loading 样式 */ .request-loading { .ant-spin-text { margin-top: 5px; font-size: 18px; color: #509ff1; } .ant-spin-dot-item { background-color: #509ff1; } } /* 请求 Loading 遮罩层样式 */ #loading { position: fixed; top: 0; right: 0; bottom: 0; left: 0; z-index: 9998; display: flex; align-items: center; justify-content: center; font-size: 20px; background: rgb(0 0 0 / 50%); } ================================================ FILE: src/components/Loading/index.tsx ================================================ import { Spin } from "antd"; import "./index.less"; const Loading = ({ tip = "Loading" }: { tip?: string }) => { return ; }; export default Loading; ================================================ FILE: src/components/SwitchDark/index.tsx ================================================ import { connect } from "react-redux"; import { Switch } from "antd"; import { setThemeConfig } from "@/redux/modules/global/action"; const SwitchDark = (props: any) => { const { setThemeConfig, themeConfig } = props; const onChange = (checked: boolean) => { setThemeConfig({ ...themeConfig, isDark: checked }); }; return ( 🌞} unCheckedChildren={<>🌜} onChange={onChange} /> ); }; const mapStateToProps = (state: any) => state.global; const mapDispatchToProps = { setThemeConfig }; export default connect(mapStateToProps, mapDispatchToProps)(SwitchDark); ================================================ FILE: src/components/common-wrap/index.scss ================================================ .content-wrap { height: 100%; padding: 10px 8px; display: flex; flex-direction: column; box-sizing: border-box; overflow-y: auto; overflow-x: hidden; max-width: 100%; } .content-inter-wrap { flex: 1; background-color: #fff; padding: 20px; border-radius: 6px; box-sizing: border-box; overflow-x: auto; max-width: 100%; } @media (max-width: 768px) { .content-wrap { padding: 8px 4px; } .content-inter-wrap { padding: 16px 12px; overflow-x: hidden; } } @media (max-width: 480px) { .content-wrap { padding: 4px 2px; } .content-inter-wrap { padding: 12px 8px; border-radius: 4px; } } ================================================ FILE: src/components/common-wrap/index.tsx ================================================ import React, { ReactNode } from "react"; import "./index.scss"; interface IProps { children: ReactNode; className?: string; style?: any; } export const ContentWrap = ({ children, className, style }: IProps) => (
{children}
); export const ContentInterWrap = ({ children, className, style }: IProps) => (
{children}
); ================================================ FILE: src/components/second-sure-modal/index.tsx ================================================ import React, { useState } from "react"; import { Button, Modal } from "antd"; const SecondSureModal: React.FC = () => { const [open, setOpen] = useState(false); const [confirmLoading, setConfirmLoading] = useState(false); const [modalText, setModalText] = useState("Content of the modal"); const showModal = () => { setOpen(true); }; const handleOk = () => { setModalText("The modal will be closed after two seconds"); setConfirmLoading(true); setTimeout(() => { setOpen(false); setConfirmLoading(false); }, 2000); }; const handleCancel = () => { console.log("Clicked cancel button"); setOpen(false); }; return ( <>

{modalText}

); }; export default SecondSureModal; ================================================ FILE: src/components/svgIcon/index.tsx ================================================ interface SvgProps { name: string; // 图标的名称 ==> 必传 color?: string; //图标的颜色 ==> 非必传 prefix?: string; // 图标的前缀 ==> 非必传(默认为"icon") iconStyle?: { [key: string]: any }; // 图标的样式 ==> 非必传 } export default function SvgIcon(props: SvgProps) { const { name, prefix = "icon", iconStyle = { width: "100px", height: "100px" } } = props; const symbolId = `#${prefix}-${name}`; return ( ); } ================================================ FILE: src/config/config.ts ================================================ // ? 全局不动配置项 只做导出不做修改 // * 首页地址(默认) export const HOME_URL: string = "/statistics/index"; // * 登录地址 export const LOGIN_URL: string = "/login"; // * Tabs(黑名单地址,不需要添加到 tabs 的路由地址,暂时没用) export const TABS_BLACK_LIST: string[] = ["/403", "/404", "/500", "/layout", "/login", "/dataScreen"]; // * 高德地图key export const MAP_KEY: string = ""; ================================================ FILE: src/config/nprogress.ts ================================================ import NProgress from "nprogress"; import "nprogress/nprogress.css"; NProgress.configure({ easing: "ease", // 动画方式 speed: 500, // 递增进度条的速度 showSpinner: true, // 是否显示加载ico trickleSpeed: 200, // 自动递增间隔 minimum: 0.3 // 初始化时的最小百分比 }); export default NProgress; ================================================ FILE: src/config/serviceLoading.tsx ================================================ import ReactDOM from "react-dom/client"; import Loading from "@/components/Loading"; let needLoadingRequestCount = 0; // * 显示loading export const showFullScreenLoading = () => { if (needLoadingRequestCount === 0) { let dom = document.createElement("div"); dom.setAttribute("id", "loading"); document.body.appendChild(dom); ReactDOM.createRoot(dom).render(); } needLoadingRequestCount++; }; // * 隐藏loading export const tryHideFullScreenLoading = () => { if (needLoadingRequestCount <= 0) return; needLoadingRequestCount--; if (needLoadingRequestCount === 0) { document.body.removeChild(document.getElementById("loading") as HTMLElement); } }; ================================================ FILE: src/enums/common.ts ================================================ export enum UpdateEnum { Save = 0, Edit } export interface IPagination { current: number; pageSize: number; total?: number; } export const initPagination: IPagination = { current: 1, pageSize: 10, total: 0 }; export enum PushStatusEnum { noPublish = "0", Published = "1", Offline = "2" } export const pushStatusInfo = { [PushStatusEnum.noPublish]: "default", [PushStatusEnum.Published]: "success", [PushStatusEnum.Offline]: "error" }; ================================================ FILE: src/enums/httpEnum.ts ================================================ // * 请求枚举配置 /** * @description:请求配置 */ export enum ResultEnum { SUCCESS = 0, ERROR = 500, OVERDUE = 599, TIMEOUT = 10000, NOT_LOGIN = 100403003, TYPE = "success" } /** * @description:请求方法 */ export enum RequestEnum { GET = "GET", POST = "POST", PATCH = "PATCH", PUT = "PUT", DELETE = "DELETE" } /** * @description:常用的contentTyp类型 */ export enum ContentTypeEnum { // json JSON = "application/json;charset=UTF-8", // text TEXT = "text/plain;charset=UTF-8", // form-data 一般配合qs FORM_URLENCODED = "application/x-www-form-urlencoded;charset=UTF-8", // form-data 上传 FORM_DATA = "multipart/form-data;charset=UTF-8" } ================================================ FILE: src/hooks/useTheme.ts ================================================ import { ThemeConfigProp } from "@/redux/interface"; import darkTheme from "@/styles/theme/theme-dark.less"; import defaultTheme from "@/styles/theme/theme-default.less"; /** * @description 全局主题设置 * */ const useTheme = (themeConfig: ThemeConfigProp) => { const { weakOrGray, isDark } = themeConfig; const initTheme = () => { // 灰色和弱色切换 const body = document.documentElement as HTMLElement; if (!weakOrGray) body.setAttribute("style", ""); if (weakOrGray === "weak") body.setAttribute("style", "filter: invert(80%)"); if (weakOrGray === "gray") body.setAttribute("style", "filter: grayscale(1)"); // 切换暗黑模式 let head = document.getElementsByTagName("head")[0]; const getStyle = head.getElementsByTagName("style"); if (getStyle.length > 0) { for (let i = 0, l = getStyle.length; i < l; i++) { if (getStyle[i]?.getAttribute("data-type") === "dark") getStyle[i].remove(); } } let styleDom = document.createElement("style"); styleDom.dataset.type = "dark"; styleDom.innerHTML = isDark ? darkTheme : defaultTheme; head.appendChild(styleDom); }; initTheme(); return { initTheme }; }; export default useTheme; ================================================ FILE: src/index.scss ================================================ .ant-table-tbody > tr > td { padding: 16px !important; } ================================================ FILE: src/layouts/components/Footer/index.less ================================================ .footer { display: flex; align-items: center; justify-content: center; height: 30px; border-top: 1px solid #e4e7ed; a { font-size: 14px; color: #858585; text-decoration: none; letter-spacing: 0.5px; white-space: nowrap; } } ================================================ FILE: src/layouts/components/Footer/index.tsx ================================================ import { connect } from "react-redux"; import { baseDomain } from "@/utils/util"; import "./index.less"; const LayoutFooter = (props: any) => { const { themeConfig } = props; // 定义一个自动获取年份的方法 const getYear = () => { return new Date().getFullYear(); }; return ( <> {!themeConfig.footer && ( )} ); }; const mapStateToProps = (state: any) => state.global; export default connect(mapStateToProps)(LayoutFooter); ================================================ FILE: src/layouts/components/Header/components/AssemblySize.tsx ================================================ import { connect } from "react-redux"; import { Dropdown, Menu } from "antd"; import { setAssemblySize } from "@/redux/modules/global/action"; const AssemblySize = (props: any) => { const { assemblySize, setAssemblySize } = props; // 切换组件大小 const onClick = (e: MenuInfo) => { setAssemblySize(e.key); }; const menu = ( 默认, onClick }, { disabled: assemblySize == "large", key: "large", label: 大型, onClick }, { disabled: assemblySize == "small", key: "small", label: 小型, onClick } ]} /> ); return ( ); }; const mapStateToProps = (state: any) => state.global; const mapDispatchToProps = { setAssemblySize }; export default connect(mapStateToProps, mapDispatchToProps)(AssemblySize); ================================================ FILE: src/layouts/components/Header/components/AvatarIcon.tsx ================================================ /* eslint-disable prettier/prettier */ import { useRef } from "react"; import { connect, useDispatch } from "react-redux"; import { useNavigate } from "react-router-dom"; import { ExclamationCircleOutlined } from "@ant-design/icons"; import { Avatar, Dropdown, MenuProps, message, Modal } from "antd"; import { logoutApi } from "@/api/modules/login"; import loginPng from "@/assets/images/logo_md.png"; import { HOME_URL, LOGIN_URL } from "@/config/config"; import { setToken, setUserInfo } from "@/redux/modules/global/action"; import InfoModal from "./InfoModal"; import PasswordModal from "./PasswordModal"; const AvatarIcon = (props: any) => { const { userInfo, setToken, setUserInfo } = props; console.log("AvatarIcon setToken setUserInfo", setToken, setUserInfo); console.log("AvatarIcon userInfo", userInfo ); const navigate = useNavigate(); interface ModalProps { showModal: (params: Record) => void; } const passRef = useRef(null); const infoRef = useRef(null); // 退出登录 const logout = () => { Modal.confirm({ title: "温馨提示 🧡", icon: , content: "是否确认退出登录?", okText: "确认", cancelText: "取消", onOk: async () => { // 此时需要请求服务器端退出登录接口 const { status, result } = await logoutApi(); if (status && status.code == 0 && result) { // 退出,清除 token,清除用户信息,跳转到登录页 setToken(""); setUserInfo({}); message.success("退出登录成功!"); navigate(LOGIN_URL); } else { message.success("退出登录失败:" + status?.msg); } } }); }; const items: MenuProps["items"] = [ { key: "1", label: 首页, onClick: () => navigate(HOME_URL) }, { key: "2", label: 个人信息, onClick: () => infoRef.current!.showModal({ photo: userInfo.photo, profile: userInfo.profile, role: userInfo.role, userName: userInfo.userName, }) }, { key: "3", label: 修改密码, onClick: () => passRef.current!.showModal({ name: 11 }) }, { type: "divider" }, { key: "4", label: 退出登录, onClick: logout } ]; return ( <> ); }; const mapDispatchToProps = { setToken, setUserInfo }; export default connect(null, mapDispatchToProps)(AvatarIcon); ================================================ FILE: src/layouts/components/Header/components/BreadcrumbNav.tsx ================================================ import { connect } from "react-redux"; import { useLocation } from "react-router-dom"; import { Breadcrumb } from "antd"; import { HOME_URL } from "@/config/config"; const BreadcrumbNav = (props: any) => { const { pathname } = useLocation(); const { themeConfig } = props.global; const breadcrumbList = props.breadcrumb.breadcrumbList[pathname] || []; console.log({ breadcrumbList }); return ( <> {!themeConfig.breadcrumb && ( 首页 {breadcrumbList.map((item: string) => { return {item !== "首页" ? item : null}; })} )} ); }; const mapStateToProps = (state: any) => state; export default connect(mapStateToProps)(BreadcrumbNav); ================================================ FILE: src/layouts/components/Header/components/CollapseIcon.tsx ================================================ import { connect } from "react-redux"; import { MenuFoldOutlined, MenuUnfoldOutlined } from "@ant-design/icons"; import { updateCollapse } from "@/redux/modules/menu/action"; const CollapseIcon = (props: any) => { const { isCollapse, updateCollapse } = props; return (
{ updateCollapse(!isCollapse); }} > {isCollapse ? : }
); }; const mapStateToProps = (state: any) => state.menu; const mapDispatchToProps = { updateCollapse }; export default connect(mapStateToProps, mapDispatchToProps)(CollapseIcon); ================================================ FILE: src/layouts/components/Header/components/Fullscreen.tsx ================================================ import { useEffect, useState } from "react"; import { message } from "antd"; import screenfull from "screenfull"; const Fullscreen = () => { const [fullScreen, setFullScreen] = useState(screenfull.isFullscreen); useEffect(() => { screenfull.on("change", () => { if (screenfull.isFullscreen) setFullScreen(true); else setFullScreen(false); return () => screenfull.off("change", () => {}); }); }, []); const handleFullScreen = () => { if (!screenfull.isEnabled) message.warning("当前您的浏览器不支持全屏 ❌"); screenfull.toggle(); }; return ( ); }; export default Fullscreen; ================================================ FILE: src/layouts/components/Header/components/InfoModal.tsx ================================================ import { Ref, useImperativeHandle, useState } from "react"; import { Avatar, message, Modal } from "antd"; interface Props { innerRef: Ref<{ showModal: (params: any) => void } | undefined>; } const InfoModal = (props: Props) => { const [modalVisible, setModalVisible] = useState(false); const [userInfo, setUserInfo] = useState>({}); // 新增状态来存储用户信息 useImperativeHandle(props.innerRef, () => ({ showModal })); const showModal = (params: Record) => { console.log(params); // 把params 显示到 model 中 setUserInfo(params); setModalVisible(true); }; const handleCancel = () => { setModalVisible(false); }; return (
头像:
用户名: {userInfo.userName}
角色: {userInfo.role}
个人简介: {userInfo.profile}
); }; export default InfoModal; ================================================ FILE: src/layouts/components/Header/components/PasswordModal.tsx ================================================ import { Ref, useImperativeHandle, useState } from "react"; import { message, Modal } from "antd"; interface Props { innerRef: Ref<{ showModal: (params: any) => void }>; } const PasswordModal = (props: Props) => { const [isModalVisible, setIsModalVisible] = useState(false); useImperativeHandle(props.innerRef, () => ({ showModal })); const showModal = (params: { name: number }) => { console.log(params); setIsModalVisible(true); }; const handleOk = () => { setIsModalVisible(false); message.success("修改密码成功 🎉🎉🎉"); }; const handleCancel = () => { setIsModalVisible(false); }; return (

Some Password...

Some Password...

Some Password...

); }; export default PasswordModal; ================================================ FILE: src/layouts/components/Header/components/Theme.tsx ================================================ import { useState } from "react"; import { connect } from "react-redux"; import { FireOutlined, SettingOutlined } from "@ant-design/icons"; import { Divider, Drawer, Switch } from "antd"; import SwitchDark from "@/components/SwitchDark"; import { setThemeConfig } from "@/redux/modules/global/action"; import { updateCollapse } from "@/redux/modules/menu/action"; const Theme = (props: any) => { const [visible, setVisible] = useState(false); const { setThemeConfig, updateCollapse } = props; const { isCollapse } = props.menu; const { themeConfig } = props.global; const { weakOrGray, breadcrumb, tabs, footer } = themeConfig; const setWeakOrGray = (checked: boolean, theme: string) => { if (checked) return setThemeConfig({ ...themeConfig, weakOrGray: theme }); setThemeConfig({ ...themeConfig, weakOrGray: "" }); }; const onChange = (checked: boolean, keyName: string) => { return setThemeConfig({ ...themeConfig, [keyName]: !checked }); }; return ( <> { setVisible(true); }} > { setVisible(false); }} visible={visible} width={320} > {/* 全局主题 */} 全局主题
暗黑模式
灰色模式 { setWeakOrGray(e, "gray"); }} />
色弱模式 { setWeakOrGray(e, "weak"); }} />

{/* 界面设置 */} 界面设置
折叠菜单 { updateCollapse(e); }} />
面包屑导航 { onChange(e, "breadcrumb"); }} />
标签栏 { onChange(e, "tabs"); }} />
页脚 { onChange(e, "footer"); }} />
); }; const mapStateToProps = (state: any) => state; const mapDispatchToProps = { setThemeConfig, updateCollapse }; export default connect(mapStateToProps, mapDispatchToProps)(Theme); ================================================ FILE: src/layouts/components/Header/index.less ================================================ .ant-layout-header { display: flex; align-items: center; justify-content: space-between; border-bottom: 1px solid #f6f6f6; .header-lf { display: flex; align-items: center; .collapsed { margin-right: 20px; font-size: 18px; cursor: pointer; transition: color 0.3s; } } .header-ri { display: flex; align-items: center; .icon-style { margin-right: 22px; font-size: 19px; line-height: 19px; cursor: pointer; } .username { margin: 0 20px 0 0; font-size: 15px; } .ant-avatar { cursor: pointer; } } } .theme-item { display: flex; align-items: center; justify-content: space-between; margin: 25px 0; span { font-size: 14px; } .ant-switch { width: 46px; } } .divider { margin: 0 0 22px !important; font-size: 15px !important; .anticon { margin-right: 10px; } } .ant-divider-with-text::before, .ant-divider-with-text::after { border-top: 1px solid #dcdfe6 !important; } ================================================ FILE: src/layouts/components/Header/index.tsx ================================================ import { useEffect } from "react"; import { connect } from "react-redux"; import { Layout } from "antd"; import { loginUserInfo } from "@/api/modules/login"; import { getDiscListAction } from "@/redux/modules/disc/action"; import { setToken, setUserInfo } from "@/redux/modules/global/action"; import { setTabsList } from "@/redux/modules/tabs/action"; import AssemblySize from "./components/AssemblySize"; import AvatarIcon from "./components/AvatarIcon"; import BreadcrumbNav from "./components/BreadcrumbNav"; import CollapseIcon from "./components/CollapseIcon"; import Fullscreen from "./components/Fullscreen"; import Theme from "./components/Theme"; import "./index.less"; const LayoutHeader = (props: any) => { const { setToken, setUserInfo, setTabsList, getDiscListAction } = props; // 尝试从 props 中获取用户信息,如果没有登录行为,则从后端直接获取 let { userInfo } = props || {}; console.log("LayoutHeader userInfo", userInfo); const { Header } = Layout; useEffect(() => { let toCheck = !userInfo || JSON.stringify(userInfo) === "{}"; console.log("toCheck", toCheck); if (toCheck) { const fetchUsrInfo = async () => { try { const { status, result } = await loginUserInfo(); console.log("请求用户信息: ", result); if (status && status.code == 0 && result && result.userId > 0) { // 保存 token 到 Redux 的状态中 setToken(String(result.userId)); // 保存用户登录信息 setUserInfo(result); setTabsList([]); // 获取字典数据 getDiscListAction(); } } catch (e) { console.log("初始化用户身份异常!", e); } }; // 未拿到用户信息时,主动去拿一下 fetchUsrInfo(); } }, []); return (
{userInfo.userName || "技术派"}
); }; const mapStateToProps = (state: any) => state.global; const mapDispatchToProps = { setToken, setUserInfo, getDiscListAction, setTabsList }; export default connect(mapStateToProps, mapDispatchToProps)(LayoutHeader); ================================================ FILE: src/layouts/components/Menu/components/Logo.tsx ================================================ import { connect } from "react-redux"; import logo from "@/assets/images/logo.svg"; import logoMd from "@/assets/images/logo_md.png"; const Logo = (props: any) => { const { isCollapse } = props; return (
logo
); }; const mapStateToProps = (state: any) => state.menu; export default connect(mapStateToProps)(Logo); ================================================ FILE: src/layouts/components/Menu/index.css ================================================ .menu { display: flex; flex-direction: column; justify-content: space-between; height: 100%; /* 去除菜单 Loading 遮罩层 */ } .menu .logo-box { display: flex; align-items: center; justify-content: center; height: 55px; } .menu .logo-box .logo-img { width: 100px; margin: 0; } .menu .logo-box .logo-img-md { width: 30px; } .menu .logo-box .logo-text { margin: 0 0 0 10px; font-size: 24px; font-weight: bold; color: #dadada; white-space: nowrap; } .menu .ant-menu-root { flex: 1; overflow-x: hidden; overflow-y: auto; } .menu .ant-spin-nested-loading, .menu .ant-spin-container { display: flex; flex-direction: column; height: 100%; } .menu .ant-spin-nested-loading .ant-spin, .menu .ant-spin-container .ant-spin { max-height: 100% !important; } .menu .ant-spin-nested-loading .ant-spin-container::after, .menu .ant-spin-container .ant-spin-container::after { background: transparent !important; } .menu .ant-spin-nested-loading .ant-spin-blur, .menu .ant-spin-container .ant-spin-blur { overflow: auto !important; clear: none !important; opacity: 1 !important; } ================================================ FILE: src/layouts/components/Menu/index.less ================================================ .menu { display: flex; flex-direction: column; justify-content: space-between; height: 100%; .logo-box { display: flex; align-items: center; justify-content: center; height: 55px; .logo-img { width: 100px; margin: 0; } .logo-img-md { width: 30px; } .logo-text { margin: 0 0 0 10px; font-size: 24px; font-weight: bold; color: #dadada; white-space: nowrap; } } .ant-menu-root { flex: 1; overflow-x: hidden; overflow-y: auto; } /* 去除菜单 Loading 遮罩层 */ .ant-spin-nested-loading, .ant-spin-container { display: flex; flex-direction: column; height: 100%; .ant-spin { max-height: 100% !important; } .ant-spin-container::after { background: transparent !important; } .ant-spin-blur { overflow: auto !important; clear: none !important; opacity: 1 !important; } } } ================================================ FILE: src/layouts/components/Menu/index.tsx ================================================ /** * 菜单控制 */ import React, { useEffect, useState } from "react"; import { connect } from "react-redux"; import { useLocation, useNavigate } from "react-router-dom"; import * as Icons from "@ant-design/icons"; import type { MenuProps } from "antd"; import { Menu, Spin } from "antd"; import { setAuthRouter } from "@/redux/modules/auth/action"; import { setBreadcrumbList } from "@/redux/modules/breadcrumb/action"; import { setMenuList } from "@/redux/modules/menu/action"; import { currentMenuList } from "@/routers/route"; import { getOpenKeys, searchRoute } from "@/utils/util"; import Logo from "./components/Logo"; import "./index.less"; const LayoutMenu = (props: any) => { const { pathname } = useLocation(); const { isCollapse } = props; const [selectedKeys, setSelectedKeys] = useState([pathname]); const [openKeys, setOpenKeys] = useState([]); // 刷新页面菜单保持高亮 useEffect(() => { setSelectedKeys([pathname]); isCollapse ? null : setOpenKeys(getOpenKeys(pathname)); }, [pathname, isCollapse]); // 设置当前展开的 subMenu const onOpenChange = (openKeys: string[]) => { if (openKeys.length === 0 || openKeys.length === 1) return setOpenKeys(openKeys); const latestOpenKey = openKeys[openKeys.length - 1]; if (latestOpenKey.includes(openKeys[0])) return setOpenKeys(openKeys); setOpenKeys([latestOpenKey]); }; // 定义 menu 类型 type MenuItem = Required["items"][number]; // 动态渲染 Icon 图标 const customIcons: { [key: string]: any } = Icons; // 处理后台返回菜单 key 值为 antd 菜单需要的 key 值 // const deepLoopFloat = (menuList: Menu.MenuOptions[], newArr: MenuItem[] = []) => { // menuList.forEach((item: Menu.MenuOptions) => { // // 下面判断代码解释 *** !item?.children?.length ==> (!item.children || item.children.length === 0) // if (!item?.children?.length) return newArr.push(getItem(item.title, item.path, addIcon(item.icon!))); // newArr.push(getItem(item.title, item.path, addIcon(item.icon!), deepLoopFloat(item.children))); // }); // return newArr; // }; // 获取菜单列表并处理成 antd menu 需要的格式 // const [menuList, setMenuList] = useState([]); const [loading, setLoading] = useState(false); // const getMenuData = async () => { // // setLoading(true); // try { // // const { data } = await getMenuList(); // // if (!data) return; // setMenuList(deepLoopFloat(currentMenuList)); // // 存储处理过后的所有面包屑导航栏到 redux 中 // setBreadcrumbList(findAllBreadcrumb(currentMenuList)); // // 把路由菜单处理成一维数组,存储到 redux 中,做菜单权限判断 // const dynamicRouter = handleRouter(currentMenuList); // setAuthRouter(dynamicRouter); // setMenuListAction(currentMenuList); // } finally { // setLoading(false); // } // }; // useEffect(() => { // getMenuData(); // }, []); // 点击当前菜单跳转页面 const navigate = useNavigate(); const clickMenu: MenuProps["onClick"] = ({ key }: { key: string }) => { const route = searchRoute(key, props.menuList); console.log({ route, props }); if (route.isLink) window.open(route.isLink, "_blank"); console.log({ key }); navigate(key); }; return (
); }; const mapStateToProps = (state: any) => state.menu; const mapDispatchToProps = { setMenuList, setBreadcrumbList, setAuthRouter }; export default connect(mapStateToProps, mapDispatchToProps)(LayoutMenu); ================================================ FILE: src/layouts/components/Tabs/components/MoreButton.tsx ================================================ import { useTranslation } from "react-i18next"; import { useLocation, useNavigate } from "react-router-dom"; import { DownOutlined } from "@ant-design/icons"; import { Button, Dropdown, Menu } from "antd"; import { HOME_URL } from "@/config/config"; const MoreButton = (props: any) => { const { t } = useTranslation(); const { pathname } = useLocation(); const navigate = useNavigate(); // close multipleTab const closeMultipleTab = (tabPath?: string) => { const handleTabsList = props.tabsList.filter((item: Menu.MenuOptions) => { return item.path === tabPath || item.path === HOME_URL; }); props.setTabsList(handleTabsList); tabPath ?? navigate(HOME_URL); }; const menu = ( {t("tabs.closeCurrent")}, onClick: () => props.delTabs(pathname) }, { key: "2", label: {t("tabs.closeOther")}, onClick: () => closeMultipleTab(pathname) }, { key: "3", label: {t("tabs.closeAll")}, onClick: () => closeMultipleTab() } ]} /> ); return ( ); }; export default MoreButton; ================================================ FILE: src/layouts/components/Tabs/index.less ================================================ .tabs { position: relative; border-bottom: 1px solid #e4e7ed; .ant-tabs { padding: 0 90px 0 13px; .ant-tabs-nav { margin: 0; &::before { border: none; } .ant-tabs-ink-bar { visibility: visible; } .ant-tabs-tab-with-remove.ant-tabs-tab-active { .ant-tabs-tab-remove { top: 1px; margin: 7px; color: @primary-color !important; opacity: 1 !important; } .ant-tabs-tab-btn { transform: translateX(-9px); } } .ant-tabs-tab { padding: 8px 22px; color: #cccccc; background: none; border: none; transition: none; .anticon-home { margin-right: 7px; } .ant-tabs-tab-remove { position: absolute; right: 0; color: #cccccc; opacity: 0; transition: 0.1s ease-in-out; &:hover { color: @primary-color; } } } .ant-tabs-tab.ant-tabs-tab-with-remove { &:hover { .ant-tabs-tab-remove { top: 1px; margin: 7px; opacity: 1; transition: 0.1s ease-in-out; } .ant-tabs-tab-btn { transform: translateX(-9px); } } } } } .more-button { position: absolute; top: 8px; right: 13px; padding-left: 10px; font-size: 12px; } } /* tabs 超出显示的样式 */ .ant-tabs-dropdown { .ant-tabs-dropdown-menu-item { .anticon-home { margin-right: 7px; } } } /* tabs 不受全局组件大小影响 */ .ant-tabs-small > .ant-tabs-nav .ant-tabs-tab, .ant-tabs-large > .ant-tabs-nav .ant-tabs-tab { padding: 8px 22px !important; font-size: 14px !important; } ================================================ FILE: src/layouts/components/Tabs/index.tsx ================================================ import { useEffect, useState } from "react"; import { connect } from "react-redux"; import { useLocation, useNavigate } from "react-router-dom"; import { HomeFilled } from "@ant-design/icons"; import { message, Tabs } from "antd"; import { HOME_URL } from "@/config/config"; import { setTabsList } from "@/redux/modules/tabs/action"; import { routerArray } from "@/routers"; import { searchRoute } from "@/utils/util"; import MoreButton from "./components/MoreButton"; import "./index.less"; const LayoutTabs = (props: any) => { const { tabsList } = props.tabs; const { themeConfig } = props.global; const { setTabsList } = props; const { TabPane } = Tabs; const { pathname } = useLocation(); const navigate = useNavigate(); const [activeValue, setActiveValue] = useState(pathname); useEffect(() => { addTabs(); }, [pathname]); // click tabs const clickTabs = (path: string) => { navigate(path); }; // add tabs const addTabs = () => { const route = searchRoute(pathname, routerArray); let newTabsList = JSON.parse(JSON.stringify(tabsList)); if (tabsList.every((item: any) => item.path !== route.path)) { newTabsList.push({ title: route.meta!.title, path: route.path }); } setTabsList(newTabsList); setActiveValue(pathname); }; // delete tabs const delTabs = (tabPath?: string) => { if (tabPath === HOME_URL) return; if (pathname === tabPath) { tabsList.forEach((item: Menu.MenuOptions, index: number) => { if (item.path !== pathname) return; const nextTab = tabsList[index + 1] || tabsList[index - 1]; if (!nextTab) return; navigate(nextTab.path); }); } message.success("你删除了Tabs标签 😆😆😆"); setTabsList(tabsList.filter((item: Menu.MenuOptions) => item.path !== tabPath)); }; return ( <> {!themeConfig.tabs && (
{ delTabs(path as string); }} > {tabsList.map((item: Menu.MenuOptions) => { return ( {item.path == HOME_URL ? : ""} {item.title} } closable={item.path !== HOME_URL} > ); })}
)} ); }; const mapStateToProps = (state: any) => state; const mapDispatchToProps = { setTabsList }; export default connect(mapStateToProps, mapDispatchToProps)(LayoutTabs); ================================================ FILE: src/layouts/index.less ================================================ .container { display: flex; min-width: 950px; height: 100%; max-width: 100%; overflow-x: hidden; box-sizing: border-box; .ant-layout-sider { box-sizing: border-box; flex-shrink: 0; } .ant-layout { /* 防止 tabs 超出不收缩 */ overflow-x: hidden; min-width: 0; flex: 1; .ant-layout-content { box-sizing: border-box; flex: 1; padding: 10px 12px; overflow-x: hidden; max-width: 100%; } &-sider { background: #001529; } } // 平板和手机端适配 @media (max-width: 768px) { min-width: 100%; .ant-layout { width: 100%; min-width: 0; } .ant-layout-content { padding: 8px 6px; } } @media (max-width: 480px) { .ant-layout-content { padding: 4px 2px; } } } ================================================ FILE: src/layouts/index.tsx ================================================ import { useEffect } from "react"; import { connect } from "react-redux"; import { Outlet } from "react-router-dom"; import { Layout } from "antd"; import { updateCollapse } from "@/redux/modules/menu/action"; import LayoutFooter from "./components/Footer"; import LayoutHeader from "./components/Header"; import LayoutMenu from "./components/Menu"; import LayoutTabs from "./components/Tabs"; import "./index.less"; const LayoutIndex = (props: any) => { const { Sider, Content } = Layout; const { isCollapse, updateCollapse, setAuthButtons } = props; // 监听窗口大小变化 const listeningWindow = () => { window.onresize = () => { return (() => { let screenWidth = document.body.clientWidth; if (!isCollapse && screenWidth < 1200) updateCollapse(true); if (!isCollapse && screenWidth > 1200) updateCollapse(false); })(); }; }; useEffect(() => { listeningWindow(); }, []); return ( // 这里不用 Layout 组件原因是切换页面时样式会先错乱然后在正常显示,造成页面闪屏效果
); }; const mapStateToProps = (state: any) => state.menu; const mapDispatchToProps = { updateCollapse }; export default connect(mapStateToProps, mapDispatchToProps)(LayoutIndex); ================================================ FILE: src/main.tsx ================================================ import React from "react"; import ReactDOM from "react-dom/client"; import { Provider } from "react-redux"; import { PersistGate } from "redux-persist/integration/react"; import App from "@/App"; import { persistor, store } from "@/redux"; import "@/styles/reset.less"; import "@/assets/iconfont/iconfont.less"; import "@/assets/fonts/font.less"; import "@/styles/common.less"; import "virtual:svg-icons-register"; const root = ReactDOM.createRoot(document.getElementById("root")!); root.render( ); ================================================ FILE: src/redux/index.ts ================================================ /* eslint-disable simple-import-sort/imports */ /* eslint-disable prettier/prettier */ import { Action, combineReducers, compose, legacy_createStore as createStore, Store } from "redux"; import { ThunkAction, ThunkDispatch } from 'redux-thunk'; import { applyMiddleware } from "redux"; import { persistReducer, persistStore } from "redux-persist"; import storage from "redux-persist/lib/storage"; import reduxPromise from "redux-promise"; import reduxThunk from "redux-thunk"; import auth from "./modules/auth/reducer"; import breadcrumb from "./modules/breadcrumb/reducer"; import disc from "./modules/disc/reducer"; import global from "./modules/global/reducer"; import menu from "./modules/menu/reducer"; import tabs from "./modules/tabs/reducer"; // 创建reducer(拆分reducer) const reducer = combineReducers({ global, menu, tabs, auth, breadcrumb, disc }); export type RootState = ReturnType; // 定义自定义的 thunk action 类型 export type AppThunk = ThunkAction< ReturnType, RootState, unknown, Action >; // 定义自定义的 dispatch 类型 export type AppDispatch = ThunkDispatch>; // redux 持久化配置 const persistConfig = { key: "redux-state", storage: storage }; const persistReducerConfig = persistReducer(persistConfig, reducer); // 开启 redux-devtools const composeEnhancers = window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ || compose; // 使用 redux 中间件 const middleWares = applyMiddleware(reduxThunk, reduxPromise); // 创建 store const store: Store = createStore(persistReducerConfig, composeEnhancers(middleWares)); // 创建持久化 store const persistor = persistStore(store); export { persistor, store }; ================================================ FILE: src/redux/interface/index.ts ================================================ import type { SizeType } from "antd/lib/config-provider/SizeContext"; import { MapItem } from "@/typings/common"; /* themeConfigProp */ export interface ThemeConfigProp { primary: string; isDark: boolean; weakOrGray: string; breadcrumb: boolean; tabs: boolean; footer: boolean; } /* GlobalState */ export interface GlobalState { token: string; userInfo: any; assemblySize: SizeType; themeConfig: ThemeConfigProp; } /* MenuState */ export interface MenuState { isCollapse: boolean; menuList: Menu.MenuOptions[]; } /* TabsState */ export interface TabsState { tabsActive: string; tabsList: Menu.MenuOptions[]; } /* BreadcrumbState */ export interface BreadcrumbState { breadcrumbList: { [propName: string]: any; }; } /* AuthState */ export interface AuthState { authButtons: { [propName: string]: any; }; authRouter: string[]; } /**discState */ export interface DiscState { disc: MapItem[]; } ================================================ FILE: src/redux/modules/auth/action.ts ================================================ import * as types from "@/redux/mutation-types"; // * setAuthButtons export const setAuthButtons = (authButtons: { [propName: string]: any }) => ({ type: types.SET_AUTH_BUTTONS, authButtons }); // * setAuthRouter export const setAuthRouter = (authRouter: string[]) => ({ type: types.SET_AUTH_ROUTER, authRouter }); ================================================ FILE: src/redux/modules/auth/reducer.ts ================================================ import produce from "immer"; import { AnyAction } from "redux"; import { AuthState } from "@/redux/interface"; import * as types from "@/redux/mutation-types"; const authState: AuthState = { authButtons: {}, authRouter: [] }; // auth reducer const auth = (state: AuthState = authState, action: AnyAction) => produce(state, draftState => { switch (action.type) { case types.SET_AUTH_BUTTONS: draftState.authButtons = action.authButtons; break; case types.SET_AUTH_ROUTER: draftState.authRouter = action.authRouter; break; default: return draftState; } }); export default auth; ================================================ FILE: src/redux/modules/breadcrumb/action.ts ================================================ import * as types from "@/redux/mutation-types"; // * setBreadcrumbList export const setBreadcrumbList = (breadcrumbList: { [propName: string]: any }) => ({ type: types.SET_BREADCRUMB_LIST, breadcrumbList }); ================================================ FILE: src/redux/modules/breadcrumb/reducer.ts ================================================ import produce from "immer"; import { AnyAction } from "redux"; import { BreadcrumbState } from "@/redux/interface"; import * as types from "@/redux/mutation-types"; const breadcrumbState: BreadcrumbState = { breadcrumbList: {} }; // breadcrumb reducer const breadcrumb = (state: BreadcrumbState = breadcrumbState, action: AnyAction) => produce(state, draftState => { switch (action.type) { case types.SET_BREADCRUMB_LIST: draftState.breadcrumbList = action.breadcrumbList; break; default: return draftState; } }); export default breadcrumb; ================================================ FILE: src/redux/modules/disc/action.ts ================================================ import { toPairs } from "lodash"; import { Dispatch } from "redux"; import { getDiscListApi } from "@/api/modules/common"; import * as types from "@/redux/mutation-types"; // * setDiscList // export const setDiscList = discList => ({ // type: types.UPDATE_DISC, // discList // }); // * redux-promise《async/await》 const dictTransform = (dict = {}, keys = ["id", "title"]) => { console.log("字典 d", dict); return toPairs(dict).map(item => { return { [keys[0]]: item[0], [keys[1]]: item[1] }; }); }; // * redux-thunk // 获取字典数据 // 异步 action creator export const getDiscListAction = () => { return async (dispatch: Dispatch) => { const { result } = (await getDiscListApi()) || {}; console.log("获取字典,getDiscListAction"); let dictionaryMap = {}; for (const key in result as object) { if (Object.getOwnPropertyDescriptor(result, key)) { // @ts-ignore dictionaryMap[key] = result[key]; // @ts-ignore dictionaryMap[`${key}List`] = dictTransform(result[key], ["value", "label"]); } } console.log("字典", dictionaryMap); // 分发 action 更新 state dispatch({ type: types.UPDATE_DISC, discList: dictionaryMap }); }; }; ================================================ FILE: src/redux/modules/disc/reducer.ts ================================================ import produce from "immer"; import { AnyAction } from "redux"; import { DiscState } from "@/redux/interface"; import * as types from "@/redux/mutation-types"; const discState: DiscState = { disc: [] }; // disc reducer const disc = (state: DiscState = discState, action: AnyAction) => produce(state, draftState => { switch (action.type) { case types.UPDATE_DISC: draftState.disc = action.discList; break; default: return draftState; } }); export default disc; ================================================ FILE: src/redux/modules/global/action.ts ================================================ import { ThemeConfigProp } from "@/redux/interface/index"; import * as types from "@/redux/mutation-types"; import { MapItem } from "@/typings/common"; // * setToken export const setToken = (token: string) => ({ type: types.SET_TOKEN, token }); export const setUserInfo = (userInfo: MapItem) => { return { type: types.USER_INFO, userInfo }; }; // * setAssemblySize export const setAssemblySize = (assemblySize: string) => ({ type: types.SET_ASSEMBLY_SIZE, assemblySize }); // * setThemeConfig export const setThemeConfig = (themeConfig: ThemeConfigProp) => ({ type: types.SET_THEME_CONFIG, themeConfig }); ================================================ FILE: src/redux/modules/global/reducer.ts ================================================ import produce from "immer"; import { AnyAction } from "redux"; import { GlobalState } from "@/redux/interface"; import * as types from "@/redux/mutation-types"; const globalState: GlobalState = { token: "", userInfo: {}, assemblySize: "middle", themeConfig: { // 默认 primary 主题颜色 primary: "#1890ff", // 深色模式 isDark: false, // 色弱模式(weak) || 灰色模式(gray) weakOrGray: "", // 面包屑导航 breadcrumb: true, // 标签页 tabs: true, // 页脚 footer: true } }; // global reducer const global = (state: GlobalState = globalState, action: AnyAction) => { console.log({ action }); return produce(state, draftState => { switch (action.type) { case types.SET_TOKEN: draftState.token = action.token; break; case types.USER_INFO: draftState.userInfo = action.userInfo; break; case types.SET_ASSEMBLY_SIZE: draftState.assemblySize = action.assemblySize; break; case types.SET_THEME_CONFIG: draftState.themeConfig = action.themeConfig; break; default: return draftState; } }); }; export default global; ================================================ FILE: src/redux/modules/menu/action.ts ================================================ import { Dispatch } from "react"; import { getMenuList } from "@/api/modules/login"; import * as types from "@/redux/mutation-types"; // * updateCollapse export const updateCollapse = (isCollapse: boolean) => ({ type: types.UPDATE_COLLAPSE, isCollapse }); // * setMenuList export const setMenuList = (menuList: Menu.MenuOptions[]) => ({ type: types.SET_MENU_LIST, menuList }); // ? 下面方法仅为测试使用,不参与任何功能开发 interface MenuProps { type: string; menuList: Menu.MenuOptions[]; } // * redux-thunk export const getMenuListActionThunk = () => { return async (dispatch: Dispatch) => { const res = await getMenuList(); dispatch({ type: types.SET_MENU_LIST, menuList: (res.data as Menu.MenuOptions[]) ?? [] }); }; }; // * redux-promise《async/await》 export const getMenuListAction = async (): Promise => { const res = await getMenuList(); return { type: types.SET_MENU_LIST, menuList: res.data ? res.data : [] }; }; // * redux-promise《.then/.catch》 export const getMenuListActionPromise = (): Promise => { return getMenuList().then(res => { return { type: types.SET_MENU_LIST, menuList: res.data ? res.data : [] }; }); }; ================================================ FILE: src/redux/modules/menu/reducer.ts ================================================ import produce from "immer"; import { AnyAction } from "redux"; import { MenuState } from "@/redux/interface"; import * as types from "@/redux/mutation-types"; const menuState: MenuState = { isCollapse: false, menuList: [] }; // menu reducer const menu = (state: MenuState = menuState, action: AnyAction) => produce(state, draftState => { switch (action.type) { case types.UPDATE_COLLAPSE: draftState.isCollapse = action.isCollapse; break; case types.SET_MENU_LIST: draftState.menuList = action.menuList; break; default: return draftState; } }); export default menu; ================================================ FILE: src/redux/modules/tabs/action.ts ================================================ import * as types from "@/redux/mutation-types"; // * setTabsList export const setTabsList = (tabsList: Menu.MenuOptions[]) => ({ type: types.SET_TABS_LIST, tabsList }); // * setTabsActive export const setTabsActive = (tabsActive: string) => ({ type: types.SET_TABS_ACTIVE, tabsActive }); ================================================ FILE: src/redux/modules/tabs/reducer.ts ================================================ import produce from "immer"; import { AnyAction } from "redux"; import { HOME_URL } from "@/config/config"; import { TabsState } from "@/redux/interface"; import * as types from "@/redux/mutation-types"; const tabsState: TabsState = { // tabsActive 其实没啥用,使用 pathname 就可以了😂 tabsActive: HOME_URL, tabsList: [{ title: "首页", path: HOME_URL }] }; // tabs reducer const tabs = (state: TabsState = tabsState, action: AnyAction) => produce(state, draftState => { switch (action.type) { case types.SET_TABS_LIST: draftState.tabsList = action.tabsList; break; case types.SET_TABS_ACTIVE: draftState.tabsActive = action.tabsActive; break; default: return draftState; } }); export default tabs; ================================================ FILE: src/redux/mutation-types.ts ================================================ // 更新 menu 折叠状态 export const UPDATE_COLLAPSE = "UPDATE_ASIDE_COLLAPSE"; // 设置 menuList export const SET_MENU_LIST = "SET_MENU_LIST"; // 设置 tabsList export const SET_TABS_LIST = "SET_TABS_LIST"; // 设置 tabsActive export const SET_TABS_ACTIVE = "SET_TABS_ACTIVE"; // 设置 breadcrumb export const SET_BREADCRUMB_LIST = "SET_BREADCRUMB_LIST"; // 设置 authButtons export const SET_AUTH_BUTTONS = "SET_AUTH_BUTTONS"; // 设置 authRouter export const SET_AUTH_ROUTER = "SET_AUTH_ROUTER"; // 设置 token export const SET_TOKEN = "SET_TOKEN"; // 设置 userInfo export const USER_INFO = "USER_INFO"; // 设置 assemblySize export const SET_ASSEMBLY_SIZE = "SET_ASSEMBLY_SIZE"; // 设置 setThemeConfig export const SET_THEME_CONFIG = "SET_THEME_CONFIG"; // 设置字典值 export const UPDATE_DISC = "UPDATE_DISC"; ================================================ FILE: src/routers/constant.tsx ================================================ import Layout from "@/layouts/index"; /** * @description: default layout */ export const LayoutIndex = () => ; ================================================ FILE: src/routers/index.tsx ================================================ import { Navigate, useRoutes } from "react-router-dom"; import { RouteObject } from "@/routers/interface"; import Login from "@/views/login/index"; // * 导入所有router const metaRouters = import.meta.globEager("./modules/*.tsx") as Record>; console.log("metaRouters", metaRouters); // * 处理路由 export const routerArray: RouteObject[] = []; Object.keys(metaRouters).forEach(item => { console.log("item", item); const router = metaRouters[item]; Object.keys(router).forEach((key: any) => { console.log("key", key); routerArray.push(...router[key]); }); }); export const rootRouter: RouteObject[] = [ { path: "/", element: }, { path: "/login", element: , meta: { title: "登录页", key: "login" } }, ...routerArray, { path: "*", element: } ]; const Router = () => { const routes = useRoutes(rootRouter); console.log("routes", { routes }); return routes; }; export default Router; ================================================ FILE: src/routers/interface/index.ts ================================================ export interface MetaProps { keepAlive?: boolean; requiresAuth?: boolean; title: string; key?: string; } export interface RouteObject { caseSensitive?: boolean; children?: RouteObject[]; element?: React.ReactNode; index?: false; path?: string; meta?: MetaProps; isLink?: string; } ================================================ FILE: src/routers/modules/aiConfig.tsx ================================================ import { LayoutIndex } from "@/routers/constant"; import { RouteObject } from "@/routers/interface"; import AiConfigPage from "@/views/aiConfig"; const aiConfigRouter: Array = [ { element: , children: [ { path: "/ai/config/index", element: , meta: { title: "AI模型配置", key: "ai-config" } } ] } ]; export default aiConfigRouter; ================================================ FILE: src/routers/modules/article.tsx ================================================ import React from "react"; import { LayoutIndex } from "@/routers/constant"; import { RouteObject } from "@/routers/interface"; import Article from "@/views/article/list"; import lazyLoad from "../utils/lazyLoad"; const articleRouter: Array = [ { element: , children: [ { path: "/article", meta: { // requiresAuth: true, title: "文章", key: "/article" }, children: [ { path: "/article/list/index", element: lazyLoad(React.lazy(() => import("@/views/article/list/index"))), meta: { title: "文章列表", key: "/article/list/index" } }, { path: "/article/edit/index", element: lazyLoad(React.lazy(() => import("@/views/article/edit/index"))), meta: { title: "文章编辑", key: "/article/edit/index" } }, { path: "/article/comment/index", element: lazyLoad(React.lazy(() => import("@/views/comment/index"))), meta: { title: "评论管理", key: "/article/comment/index" } } ] } ] } ]; export default articleRouter; ================================================ FILE: src/routers/modules/author.tsx ================================================ import React from "react"; import { LayoutIndex } from "@/routers/constant"; import { RouteObject } from "@/routers/interface"; import lazyLoad from "../utils/lazyLoad"; const columnRouter: Array = [ { element: , children: [ { path: "/author", meta: { // requiresAuth: true, title: "用户管理", key: "/author" }, children: [ { path: "/author/whitelist/index", element: lazyLoad(React.lazy(() => import("@/views/author/whitelist/index"))), meta: { title: "作者白名单", key: "/author/whitelist/index" } }, { path: "/author/zsxqlist/index", element: lazyLoad(React.lazy(() => import("@/views/author/zsxqlist/index"))), meta: { title: "星球白名单", key: "/author/zsxqlist/index" } }, { path: "/author/login-audit/index", element: lazyLoad(React.lazy(() => import("@/views/author/loginAudit/index"))), meta: { title: "登录审计", key: "/author/login-audit/index" } } ] } ] } ]; export default columnRouter; ================================================ FILE: src/routers/modules/category.tsx ================================================ import { LayoutIndex } from "@/routers/constant"; import { RouteObject } from "@/routers/interface"; import Sort from "@/views/category"; const sortRouter: Array = [ { element: , children: [ { path: "/category/index", element: , meta: { // requiresAuth: true, title: "分类", key: "sort" } } ] } ]; export default sortRouter; ================================================ FILE: src/routers/modules/column.tsx ================================================ import React from "react"; import { LayoutIndex } from "@/routers/constant"; import { RouteObject } from "@/routers/interface"; import lazyLoad from "../utils/lazyLoad"; const columnRouter: Array = [ { element: , children: [ { path: "/column", meta: { // requiresAuth: true, title: "专栏", key: "/column" }, children: [ { path: "/column/setting/index", element: lazyLoad(React.lazy(() => import("@/views/column/setting/index"))), meta: { title: "专栏设置", key: "/column/setting/index" } }, { path: "/column/setting/index/articlesort", element: lazyLoad(React.lazy(() => import("@/views/column/setting/articlesort/index"))), meta: { title: "教程排序", key: "/column/setting/index/articlesort" } }, { path: "/column/setting/index/groups", element: lazyLoad(React.lazy(() => import("@/views/column/setting/groups/index"))), meta: { title: "专栏分组", key: "/column/setting/index/groups" } }, { path: "/column/article/index", element: lazyLoad(React.lazy(() => import("@/views/column/article/index"))), meta: { title: "添加教程", key: "/column/article/index" } } ] } ] } ]; export default columnRouter; ================================================ FILE: src/routers/modules/config.tsx ================================================ import { LayoutIndex } from "@/routers/constant"; import { RouteObject } from "@/routers/interface"; import Banner from "@/views/config"; const configRouter: Array = [ { element: , children: [ { path: "/config/index", element: , meta: { // requiresAuth: true, title: "配置", key: "config" } } ] } ]; export default configRouter; ================================================ FILE: src/routers/modules/error.tsx ================================================ import React from "react"; import { RouteObject } from "@/routers/interface"; import lazyLoad from "@/routers/utils/lazyLoad"; // 错误页面模块 const errorRouter: Array = [ { path: "/403", element: lazyLoad(React.lazy(() => import("@/components/ErrorMessage/403"))), meta: { // requiresAuth: true, title: "403页面", key: "403" } }, { path: "/404", element: lazyLoad(React.lazy(() => import("@/components/ErrorMessage/404"))), meta: { // requiresAuth: false, title: "404页面", key: "404" } }, { path: "/500", element: lazyLoad(React.lazy(() => import("@/components/ErrorMessage/500"))), meta: { // requiresAuth: false, title: "500页面", key: "500" } } ]; export default errorRouter; ================================================ FILE: src/routers/modules/global.tsx ================================================ import { LayoutIndex } from "@/routers/constant"; import { RouteObject } from "@/routers/interface"; import Banner from "@/views/global"; const configRouter: Array = [ { element: , children: [ { path: "/global/index", element: , meta: { // requiresAuth: true, title: "全局", key: "global" } } ] } ]; export default configRouter; ================================================ FILE: src/routers/modules/home.tsx ================================================ import { LayoutIndex } from "@/routers/constant"; import { RouteObject } from "@/routers/interface"; import Home from "@/views/home"; // 首页模块 const homeRouter: Array = [ { element: , children: [ { path: "/home/index", element: , meta: { // requiresAuth: true, title: "首页", key: "home" } } ] } ]; export default homeRouter; ================================================ FILE: src/routers/modules/resume.tsx ================================================ import { LayoutIndex } from "@/routers/constant"; import { RouteObject } from "@/routers/interface"; import Label from "@/views/resume"; const labelRouter: Array = [ { element: , children: [ { path: "/resume/index", element: