[
  {
    "path": ".editorconfig",
    "content": "# @see: http://editorconfig.org\n\nroot = true\n\n[*] # 表示所有文件适用\ncharset = utf-8 # 设置文件字符集为 utf-8\nend_of_line = lf # 控制换行类型(lf | cr | crlf)\ninsert_final_newline = true # 始终在文件末尾插入一个新行\nindent_style = tab # 缩进风格（tab | space）\nindent_size = 2 # 缩进大小\nmax_line_length = 130 # 最大行长度\n\n[*.md] # 表示仅 md 文件适用以下规则\nmax_line_length = off # 关闭最大行长度限制\ntrim_trailing_whitespace = false # 关闭末尾空格修剪\n"
  },
  {
    "path": ".eslintignore",
    "content": "*.sh\nnode_modules\n*.md\n*.woff\n*.ttf\n.vscode\n.idea\ndist\n/public\n/docs\n.husky\n.local\n/bin\n.eslintrc.js\n.prettierrc.js\n/src/mock/*\n\n"
  },
  {
    "path": ".eslintrc.js",
    "content": "// @see: http://eslint.cn\n\nmodule.exports = {\n\tsettings: {\n\t\treact: {\n\t\t\tversion: \"detect\"\n\t\t}\n\t},\n\troot: true,\n\tenv: {\n\t\tbrowser: true,\n\t\tnode: true,\n\t\tes6: true\n\t},\n\t/* 指定如何解析语法 */\n\tparser: \"@typescript-eslint/parser\",\n\t/* 优先级低于 parse 的语法解析配置 */\n\tparserOptions: {\n\t\tecmaVersion: 2020,\n\t\tsourceType: \"module\",\n\t\tjsxPragma: \"React\",\n\t\tecmaFeatures: {\n\t\t\tjsx: true\n\t\t}\n\t},\n\tplugins: [\"react\", \"@typescript-eslint\", \"react-hooks\", \"prettier\", \"simple-import-sort\"],\n\t/* 继承某些已有的规则 */\n\textends: [\n\t\t\"eslint:recommended\",\n\t\t\"plugin:react/recommended\",\n\t\t\"plugin:@typescript-eslint/recommended\",\n\t\t\"plugin:react/jsx-runtime\",\n\t\t\"plugin:react-hooks/recommended\",\n\t\t\"prettier\",\n\t\t\"plugin:prettier/recommended\"\n\t],\n\t/*\n\t * \"off\" 或 0    ==>  关闭规则\n\t * \"warn\" 或 1   ==>  打开的规则作为警告（不影响代码执行）\n\t * \"error\" 或 2  ==>  规则作为一个错误（代码不能执行，界面报错）\n\t */\n\trules: {\n\t\t// eslint (http://eslint.cn/docs/rules)\n\t\t\"no-var\": \"error\", // 要求使用 let 或 const 而不是 var\n\t\t\"no-multiple-empty-lines\": [\"error\", { max: 1 }], // 不允许多个空行\n\t\t\"no-use-before-define\": \"off\", // 禁止在 函数/类/变量 定义之前使用它们\n\t\t\"prefer-const\": \"off\", // 此规则旨在标记使用 let 关键字声明但在初始分配后从未重新分配的变量，要求使用 const\n\t\t\"no-irregular-whitespace\": \"off\", // 禁止不规则的空白\n\n\t\t// typeScript (https://typescript-eslint.io/rules)\n\t\t\"@typescript-eslint/no-unused-vars\": \"off\", // 禁止定义未使用的变量\n\t\t\"@typescript-eslint/no-inferrable-types\": \"off\", // 可以轻松推断的显式类型可能会增加不必要的冗长\n\t\t\"@typescript-eslint/no-namespace\": \"off\", // 禁止使用自定义 TypeScript 模块和命名空间。\n\t\t\"@typescript-eslint/no-explicit-any\": \"off\", // 禁止使用 any 类型\n\t\t\"@typescript-eslint/ban-ts-ignore\": \"off\", // 禁止使用 @ts-ignore\n\t\t\"@typescript-eslint/ban-types\": \"off\", // 禁止使用特定类型\n\t\t\"@typescript-eslint/explicit-function-return-type\": \"off\", // 不允许对初始化为数字、字符串或布尔值的变量或参数进行显式类型声明\n\t\t\"@typescript-eslint/no-var-requires\": \"off\", // 不允许在 import 语句中使用 require 语句\n\t\t\"@typescript-eslint/no-empty-function\": \"off\", // 禁止空函数\n\t\t\"@typescript-eslint/no-use-before-define\": \"off\", // 禁止在变量定义之前使用它们\n\t\t\"@typescript-eslint/ban-ts-comment\": \"off\", // 禁止 @ts-<directive> 使用注释或要求在指令后进行描述\n\t\t\"@typescript-eslint/no-non-null-assertion\": \"off\", // 不允许使用后缀运算符的非空断言(!)\n\t\t\"@typescript-eslint/explicit-module-boundary-types\": \"off\", // 要求导出函数和类的公共类方法的显式返回和参数类型\n\t\t\"@typescript-eslint/no-empty-interface\": \"off\",\n\t\t// react (https://github.com/jsx-eslint/eslint-plugin-react)\n\t\t\"react-hooks/rules-of-hooks\": \"off\",\n\t\t\"react-hooks/exhaustive-deps\": \"off\",\n\t\t\"simple-import-sort/imports\": \"warn\",\n\t\t\"simple-import-sort/exports\": \"warn\"\n\t},\n\toverrides: [\n\t\t{\n\t\t\tfiles: [\"*.js\", \"*.jsx\", \"*.ts\", \"*.tsx\"],\n\t\t\trules: {\n\t\t\t\t\"simple-import-sort/imports\": [\n\t\t\t\t\t\"warn\",\n\t\t\t\t\t{\n\t\t\t\t\t\tgroups: [\n\t\t\t\t\t\t\t// Packages `react` related packages come first.\n\t\t\t\t\t\t\t[\"^react\", \"^@?\\\\w\"],\n\t\t\t\t\t\t\t// Other relative imports. Put same-folder imports and `.` last.\n\t\t\t\t\t\t\t[\"^(@|components)(/.*|$)\", \"^\\\\./(?=.*/)(?!/?$)\", \"^\\\\.(?!/?$)\", \"^\\\\./?$\"],\n\t\t\t\t\t\t\t// Style imports.\n\t\t\t\t\t\t\t[\"^.+\\\\.?(css)$\"]\n\t\t\t\t\t\t]\n\t\t\t\t\t}\n\t\t\t\t]\n\t\t\t}\n\t\t}\n\t]\n};\n"
  },
  {
    "path": ".gitattributes",
    "content": "# Set the default behavior, in case people don't have core.autocrlf set.\n* text=auto\n\n# Explicitly declare text files you want to always be normalized and converted\n# to native line endings on checkout.\n*.c text\n*.h text\n\n# Declare files that will always have CRLF line endings on checkout.\n*.sln text eol=crlf\n\n# Denote all files that are truly binary and should not be modified.\n*.png binary\n*.jpg binary\n"
  },
  {
    "path": ".gitignore",
    "content": "# Logs\nlogs\n*.log\nnpm-debug.log*\nyarn-debug.log*\nyarn-error.log*\npnpm-debug.log*\nlerna-debug.log*\n\nnode_modules\ndist\ndist.tar.gz\ndist-ssr\n*.local\nstats.html\n\n\n# Editor directories and files\n.vscode/*\n!.vscode/extensions.json\n!.vscode/settings.json\n.idea\n.DS_Store\n*.suo\n*.ntvs*\n*.njsproj\n*.sln\n*.sw?\n.vscode/settings.json\ndist.zip\n.env.deploy\n"
  },
  {
    "path": ".husky/commit-msg",
    "content": "#!/usr/bin/env sh\n. \"$(dirname -- \"$0\")/_/husky.sh\"\n\nnpx --no-install commitlint --edit $1\n"
  },
  {
    "path": ".husky/pre-commit",
    "content": "#!/usr/bin/env sh\n. \"$(dirname -- \"$0\")/_/husky.sh\"\n\nnpm run lint:lint-staged\n"
  },
  {
    "path": ".prettierignore",
    "content": "/dist/*\n.local\n/node_modules/**\n\n**/*.svg\n**/*.sh\n\n/public/*\n"
  },
  {
    "path": ".prettierrc.js",
    "content": "// @see: https://www.prettier.cn\n\nmodule.exports = {\n\t// 超过最大值换行\n\tprintWidth: 130,\n\t// 缩进字节数\n\ttabWidth: 2,\n\t// 使用制表符而不是空格缩进行\n\tuseTabs: true,\n\t// 结尾不用分号(true有，false没有)\n\tsemi: true,\n\t// 使用单引号(true单双引号，false双引号)\n\tsingleQuote: false,\n\t// 更改引用对象属性的时间 可选值\"<as-needed|consistent|preserve>\"\n\tquoteProps: \"as-needed\",\n\t// 在对象，数组括号与文字之间加空格 \"{ foo: bar }\"\n\tbracketSpacing: true,\n\t// 多行时尽可能打印尾随逗号。（例如，单行数组永远不会出现逗号结尾。） 可选值\"<none|es5|all>\"，默认none\n\ttrailingComma: \"none\",\n\t// 在JSX中使用单引号而不是双引号\n\tjsxSingleQuote: false,\n\t//  (x) => {} 箭头函数参数只有一个时是否要有小括号。avoid：省略括号 ,always：不省略括号\n\tarrowParens: \"avoid\",\n\t// 如果文件顶部已经有一个 doclock，这个选项将新建一行注释，并打上@format标记。\n\tinsertPragma: false,\n\t// 指定要使用的解析器，不需要写文件开头的 @prettier\n\trequirePragma: false,\n\t// 默认值。因为使用了一些折行敏感型的渲染器（如GitHub comment）而按照markdown文本样式进行折行\n\tproseWrap: \"preserve\",\n\t// 在html中空格是否是敏感的 \"css\" - 遵守CSS显示属性的默认值， \"strict\" - 空格被认为是敏感的 ，\"ignore\" - 空格被认为是不敏感的\n\thtmlWhitespaceSensitivity: \"css\",\n\t// 换行符使用 lf 结尾是 可选值\"<auto|lf|crlf|cr>\"\n\tendOfLine: \"auto\",\n\t// 这两个选项可用于格式化以给定字符偏移量（分别包括和不包括）开始和结束的代码\n\trangeStart: 0,\n\trangeEnd: Infinity,\n\t// Vue文件脚本和样式标签缩进\n\tvueIndentScriptAndStyle: false\n};\n"
  },
  {
    "path": ".qoder/settings.json",
    "content": "{\n  \"permissions\": {\n    \"ask\": [\n      \"Read(!/Users/itwanger/Documents/GitHub/paicoding-admin/**)\",\n      \"Edit(!/Users/itwanger/Documents/GitHub/paicoding-admin/**)\"\n    ],\n    \"allow\": [\n      \"Read(/Users/itwanger/Documents/GitHub/paicoding-admin/**)\",\n      \"Edit(/Users/itwanger/Documents/GitHub/paicoding-admin/**)\"\n    ]\n  },\n  \"memoryImport\": {},\n  \"monitoring\": {}\n}"
  },
  {
    "path": ".stylelintignore",
    "content": "/dist/*\n/public/*\npublic/*\n"
  },
  {
    "path": ".stylelintrc.js",
    "content": "module.exports = {\n\t// 引入标准配置文件和scss配置扩展\n\textends: [\"stylelint-config-standard\", \"stylelint-config-recommended-scss\"],\n\trules: {\n\t\t// 引号必须为单引号\n\t\t\"string-quotes\": [\"single\"],\n\t\t// 冒号后要加空格\n\t\t\"declaration-colon-space-after\": [\"always\"],\n\t\t// 冒号前不加空格\n\t\t\"declaration-colon-space-before\": [\"never\"],\n\t\t// 变量后必须添加!default，本地局部变量可以不加\n\t\t\"scss/dollar-variable-default\": [true, { ignore: \"local\" }],\n\t\t// 属性单独成行\n\t\t\"declaration-block-single-line-max-declarations\": [1],\n\t\t// 属性和值前不带厂商标记（通过autofixer自动添加，不要自己手工写）\n\t\t\"property-no-vendor-prefix\": [true],\n\t\t\"value-no-vendor-prefix\": [true],\n\t\t// 多选择器必须单独成行，逗号结尾\n\t\t\"selector-list-comma-newline-after\": [\"always\"],\n\t\t// 不能有无效的16进制颜色值\n\t\t\"color-no-invalid-hex\": [true]\n\t},\n\tignoreFiles: [\"src/**/*.tsx\", \"src/**/*.ts\", \"src/**/*.jsx\", \"src/**/*.js\"]\n};\n"
  },
  {
    "path": ".trae/documents/在文章编辑页集成 Moveable.js 实现图片缩放.md",
    "content": "## 实施计划：集成 react-moveable 实现图片缩放\n\n### 1. 安装依赖\n- 安装 `react-moveable` 库，用于提供图片的拖拽和缩放功能。\n\n### 2. 修改文章编辑页面 ([index.tsx](file:///Users/itwanger/Documents/GitHub/paicoding-admin/src/views/article/edit/index.tsx))\n- **引入 Moveable**：导入 `Moveable` 组件。\n- **状态管理**：\n  - `target`: 存储当前选中的图片元素。\n  - `moveableRef`: 引用 Moveable 实例。\n- **自定义 ByteMD 插件 (`imageMoveablePlugin`)**：\n  - 在预览区域渲染完成后，为所有 `img` 标签绑定点击事件。\n  - 点击图片时将其设为 `target`；点击非图片区域时清除 `target`。\n- **集成 Moveable 组件**：\n  - 在编辑器预览区域渲染 `Moveable`。\n  - 配置 `resizable`, `keepRatio`, `snappable` 等属性。\n- **实现缩放同步**：\n  - 在 `onResize` 事件中实时更新图片的 DOM 样式。\n  - 在 `onResizeEnd` 事件中，将缩放后的尺寸同步回 Markdown 源码中，通过将 `![alt](url)` 转换为 `<img src=\"url\" width=\"xxx\" height=\"xxx\" />` 来实现持久化。\n\n### 3. 样式优化\n- 调整 Moveable 控制柄的样式，确保在编辑器内清晰可见且不遮挡操作。\n\n### 4. 验证与测试\n- 在编辑页面插入图片，尝试通过 Moveable 进行缩放。\n- 确认缩放后的尺寸在保存并重新加载后依然有效。\n"
  },
  {
    "path": ".trae/documents/文章编辑页：导入 Markdown + 修复 Word 图片清晰度.md",
    "content": "## 目标\n- 在 `#/article/edit/index` 增加“导入Markdown”功能：读取本地 `.md/.markdown/.txt` 内容并写入编辑器。\n- 导入时不上传图片；图片仍走现有“转链”按钮统一处理外链图片。\n- 针对“语雀导出的 Markdown”做净化：去掉 `<font ...>`、空 font、注释、以及图片 URL 周围的干扰字符（如反引号/多余空格）。\n\n## 方案：导入 Markdown（不触发上传）\n1. **新增工具栏按钮**\n   - 在 [search/index.tsx](file:///Users/itwanger/Documents/GitHub/paicoding-admin/src/views/article/edit/search/index.tsx) 增加“导入Markdown”按钮。\n   - 扩展 props：新增 `handleImportMarkdown`。\n\n2. **实现 `handleImportMarkdown`（核心逻辑）**\n   - 在 [index.tsx](file:///Users/itwanger/Documents/GitHub/paicoding-admin/src/views/article/edit/index.tsx) 新增 `handleImportMarkdown`：\n     - 用隐藏 `input[type=file]` 选择文件，accept：`.md,.markdown,.txt,text/markdown,text/plain`。\n     - `file.text()` 读取原文（去 BOM、统一换行）。\n     - 调用 `sanitizeYuqueMarkdown(raw)` 做净化（见下）。\n     - 若编辑器已有内容：弹 Modal 让用户选“替换/追加/取消”（复用 Word 导入的交互风格）。\n     - 标题同步：若净化后的 Markdown 首个非空行匹配 `# 标题`，提取为 `shortTitle` 并从正文移除该行。\n     - 写入编辑器：\n       - 替换：`setContent(md)` + `handleChange({content: md, shortTitle?})`\n       - 追加：`content + '\\n\\n---\\n\\n' + md`\n   - 明确不做任何图片上传/转链；用户需要时再点击现有“转链”。\n\n## 语雀 Markdown 净化规则（sanitizeYuqueMarkdown）\n- **清理 font 包装**：移除所有 `<font ...>` 与 `</font>` 标签，但保留其中的文本内容。\n- **移除无意义的空 font 段落**：例如仅包含空白/换行的 font 标签残留。\n- **移除 HTML 注释块**：`<!-- ... -->`（支持多行）。\n- **修复图片 URL 干扰格式**：将语雀导出常见写法\n  - `![]( `https://...png` )` / `![](`https://...`)` / `![alt]( `url` )`\n  - 统一规范成 `![](https://...)` 或 `![alt](https://...)`（去反引号、去括号内多余空格）。\n- **基础规范化**：`\n` → `\\n`、去零宽字符、连续 3+ 空行压缩为 2 行。\n\n## 影响范围\n- 不改“转链”逻辑：仍由 [index.tsx](file:///Users/itwanger/Documents/GitHub/paicoding-admin/src/views/article/edit/index.tsx) 现有 `handleReplaceImgUrl` 统一处理外链图片。\n- 不改 Word 导入图片清晰度方案（按你要求先不处理）。\n\n## 涉及文件\n- [src/views/article/edit/index.tsx](file:///Users/itwanger/Documents/GitHub/paicoding-admin/src/views/article/edit/index.tsx)\n- [src/views/article/edit/search/index.tsx](file:///Users/itwanger/Documents/GitHub/paicoding-admin/src/views/article/edit/search/index.tsx)\n\n## 验证方式\n- 进入 `http://127.0.0.1:3301/#/article/edit/index`：\n  - 导入语雀导出的 `.md`：确认 font/注释被移除、图片语法变为标准 `![](...)`。\n  - 点击“转链”：确认外链图片正常上传替换，且不会重复上传（沿用现有 30 秒缓存策略）。\n  - 回归：保存/更新、图片缩放与替换、其它表单项不受影响。"
  },
  {
    "path": ".vscode/extensions.json",
    "content": "{\n\t\"recommendations\": [\n\t\t\"dsznajder.es7-react-js-snippets\",\n\t\t\"stylelint.vscode-stylelint\",\n\t\t\"dbaeumer.vscode-eslint\",\n\t\t\"editorconfig.editorconfig\",\n\t\t\"esbenp.prettier-vscode\"\n\t]\n}\n"
  },
  {
    "path": "AGENTS.md",
    "content": "# AGENTS.md\n\nThis file provides guidance to Qoder (qoder.com) when working with code in this repository.\n\n## Project Overview\n\npaicoding-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.\n\n## Build and Development Commands\n\n### Development\n```bash\nnpm run dev\n# Starts dev server at http://127.0.0.1:3301\n# Default login credentials: admin/admin\n```\n\n### Build\n```bash\n# Production build\nnpm run build:pro\n\n# Development build\nnpm run build:build:dev\n\n# Test build\nnpm run build:test\n```\n\n### Preview\n```bash\nnpm run preview\n```\n\n### Linting and Code Quality\n```bash\n# ESLint check and auto-fix\nnpm run lint:eslint\n\n# Prettier formatting\nnpm run lint:prettier\n\n# Stylelint for CSS/Less/SCSS\nnpm run lint:stylelint\n\n# Run lint-staged (pre-commit hook)\nnpm run lint:lint-staged\n```\n\n### Git Commits\n```bash\n# Automated commit flow with commitizen\nnpm run commit\n```\n\n## Architecture\n\n### State Management (Redux)\n- Uses Redux with redux-persist for state persistence\n- Key modules: `global`, `menu`, `tabs`, `auth`, `breadcrumb`, `disc`\n- Location: `src/redux/modules/`\n- Store configuration: `src/redux/index.ts`\n- Redux DevTools enabled in development\n- Middleware: redux-thunk, redux-promise\n\n### Routing\n- React Router v6 with lazy loading support\n- Route modules auto-loaded from `src/routers/modules/*.tsx` using `import.meta.globEager`\n- Main router config: `src/routers/index.tsx`\n- Route definitions: `src/routers/route.tsx`\n- Supports nested routes, route guards, and multi-tab navigation\n\n### API Layer\n- Axios-based HTTP client with custom wrapper class `RequestHttp`\n- Global request/response interceptors\n- Features: automatic token injection, loading states, error handling, request cancellation\n- Base URL configured via `VITE_API_URL` environment variable\n- API modules organized by feature in `src/api/modules/`\n- Main config: `src/api/index.ts`\n\n### Layout System\n- Main layout: `src/layouts/index.tsx`\n- Components: Header, Menu, Tabs, Footer\n- Uses Ant Design Layout with collapsible sidebar\n- Responsive design with window resize handling\n\n### Project Structure\n```\nsrc/\n├── api/              # API request definitions by module\n├── assets/           # Static assets (images, icons, etc.)\n├── components/       # Reusable components\n├── config/           # Configuration files\n├── enums/            # TypeScript enums\n├── hooks/            # Custom React hooks\n├── layouts/          # Layout components (Header, Menu, Footer, Tabs)\n├── redux/            # Redux store and modules\n├── routers/          # Route configuration and modules\n├── styles/           # Global styles\n├── typings/          # TypeScript type definitions\n├── utils/            # Utility functions\n└── views/            # Page components by feature\n```\n\n### Key Views/Features\n- `statistics/`: Dashboard with ECharts data visualization\n- `config/`: Platform operation configuration\n- `article/`: Article management\n- `column/`: Column configuration\n- `resume/`: Tutorial/course configuration\n- `author/`: User/author management\n- `category/`: Category management\n- `tag/`: Tag management\n- `global/`: Global settings\n- `login/`: Authentication\n\n## Development Notes\n\n### Environment Variables\n- Development: `.env.development` - Backend at `http://127.0.0.1:8080`\n- Production: `.env.production`\n- Test: `.env.test`\n\n### Backend Integration\n- Backend project: [paicoding](https://github.com/itwanger/paicoding)\n- Spring Boot-based community platform\n- Ensure Redis and backend server are running before starting admin panel\n\n### TypeScript Configuration\n- Strict TypeScript enabled\n- Path alias `@` configured to `src/`\n- Config: `tsconfig.json`\n\n### Vite Configuration\n- Proxy configured for `/admin` and `/api/admin` to `http://127.0.0.1:8080`\n- Port: 3301 (configurable via `VITE_PORT`)\n- Gzip compression enabled for production builds\n- Bundle visualization available with `VITE_REPORT`\n- SVG icons via vite-plugin-svg-icons\n- Config: `vite.config.ts`\n\n### Code Standards\n- ESLint with TypeScript, React, and Prettier integration\n- Prettier for code formatting\n- Stylelint for CSS/Less/SCSS\n- Husky + lint-staged for pre-commit hooks\n- Commitizen + commitlint for commit message conventions\n\n## Troubleshooting\n\n### Node Modules Issues\nIf `npm install` fails:\n1. Upgrade Node.js to 16+ (recommended 18+)\n2. Try: `npm install --registry=http://registry.npmmirror.com`\n3. If ECONNRESET error: `npm config set registry http://registry.npmjs.org/`\n4. Delete `node_modules` and reinstall\n\n### launch.sh (Mac/Linux)\nHelper script for common tasks:\n```bash\n./launch.sh install  # Install dependencies\n./launch.sh server   # Start dev server\n./launch.sh pro      # Build, package, and deploy to server\n```\n\nIf `$'\\r': command not found` error:\n```bash\nsed -i 's/\\r//' launch.sh\n# or\ndos2unix launch.sh\n```\n"
  },
  {
    "path": "CHANGELOG.md",
    "content": "# Changelog\n\nAll notable changes to this project will be documented in this file. See [standard-version](https://github.com/conventional-changelog/standard-version) for commit guidelines.\n\n### 0.0.1 (2022-09-19)\n\n### Features\n\n- 🚀 项目初始化（今天的 🧱 格外烫手）\n\n### Bug Fixes\n\n- 🧩 密密麻麻\n"
  },
  {
    "path": "CLAUDE.md",
    "content": "# CLAUDE.md\n\nThis file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.\n\n## Project Overview\n\npaicoding-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.\n\nBackend API: https://github.com/itwanger/paicoding (Spring Boot based)\n\n## Development Commands\n\n### Local Development\n```bash\nnpm install\n# OR if install fails (requires Node.js 16+)\nnpm install --registry=http://registry.npmmirror.com\n\nnpm run dev\n# Opens http://127.0.0.1:3301\n# Default credentials: admin/admin\n```\n\n### Build\n```bash\n# Production build\nnpm run build:pro\n\n# Development build\nnpm run build:dev\n\n# Test environment build\nnpm run build:test\n```\n\n### Code Quality\n```bash\n# ESLint check and fix\nnpm run lint:eslint\n\n# Prettier formatting\nnpm run lint:prettier\n\n# Stylelint for styles\nnpm run lint:stylelint\n\n# Lint staged files (runs via husky)\nnpm run lint:lint-staged\n```\n\n### Git Commits\n```bash\n# Interactive commit with commitizen\nnpm run commit\n# This runs: git pull && git add -A && git-cz && git push\n```\n\nCommit types follow conventional commits:\n- `feat`: New feature\n- `fix`: Bug fix\n- `docs`: Documentation changes\n- `style`: Code formatting (not affecting logic)\n- `refactor`: Code refactoring\n- `perf`: Performance improvements\n- `test`: Test changes\n- `build`: Build system changes\n- `ci`: CI configuration changes\n- `chore`: Other changes\n\n### Deployment\n```bash\n# For Mac/Linux users\n./launch.sh install  # Install dependencies\n./launch.sh server   # Start dev server\n./launch.sh pro      # Build, upload to server, and deploy\n\n# Manual deployment\nnpm run build:pro    # Generates dist/ directory\n# Upload dist/ to /home/admin/ on server\n# Configure Nginx:\n# location ^~ /admin {\n#     alias /home/admin/dist/;\n#     index index.html;\n# }\n```\n\n## Architecture\n\n### State Management (Redux)\nLocated in `src/redux/modules/`:\n- **global**: Token, theme, language, assembly size, loading state\n- **menu**: Menu collapse state, menu list\n- **tabs**: Multi-tab navigation state\n- **auth**: Authentication and button permissions\n- **breadcrumb**: Breadcrumb navigation\n- **disc**: Discussion/forum state\n\nRedux uses `redux-persist` to persist state to localStorage. Uses `redux-thunk` and `redux-promise` middleware.\n\n### Routing System\n- Routes defined in `src/routers/modules/*.tsx` (modular approach)\n- Auto-imported via `import.meta.globEager` in `src/routers/index.tsx`\n- Route modules: article, author, category, column, config, global, home, statistics, tag, resume, error\n- Lazy loading enabled via `src/routers/utils/lazyLoad.tsx`\n- Auth protection via HOC in `src/routers/utils/authRouter.tsx`\n- Multi-tab support with keep-alive using `react-activation`\n\n### API Layer\n- Axios wrapper in `src/api/index.ts` with global interceptors\n- Request/response interceptors handle:\n  - Token injection via `x-access-token` header\n  - Loading states via NProgress\n  - Error handling (599 = login expired, redirects to login)\n  - Duplicate request cancellation\n- API modules in `src/api/modules/`: login, article, author, category, column, config, global, statistics, tag, resume, common\n- Base URL configured via `.env.*` files: `/api/admin`\n- Proxy configured in `vite.config.ts` to forward to `http://127.0.0.1:8080/`\n\n### Layout Structure\nMain layout in `src/layouts/index.tsx`:\n- **Sider**: Left menu (`LayoutMenu`)\n- **Header**: Top navigation with breadcrumb, theme toggle, user avatar (`LayoutHeader`)\n- **Tabs**: Multi-tab navigation (`LayoutTabs`)\n- **Content**: Main content area (rendered via `<Outlet>`)\n- **Footer**: Footer component (`LayoutFooter`)\n\nResponsive: Auto-collapses menu when window width < 1200px.\n\n### Key Features\n1. **Custom Components**:\n   - `DebounceSelect`: Debounced search select (used in article/column forms)\n   - `TableSelect`: Paginated searchable select with table display\n   - `ImgCropUpload`: Image upload with crop functionality (Ant Design)\n   - `DatePicker`: Custom date picker for expireTime fields\n   - `SecondSureModal`: Secondary confirmation modal\n\n2. **Theme System**:\n   - Dark mode support\n   - Gray mode & color-weak mode\n   - Component size switching (small/middle/large)\n   - Managed via `src/hooks/useTheme.ts`\n\n3. **Markdown Editor**:\n   - Uses `@bytemd/react` with plugins: gfm, highlight, math, gemoji, medium-zoom\n   - Theme support via `juejin-markdown-themes`\n\n## File Organization\n\n```\nsrc/\n├── api/              # API modules and Axios configuration\n├── assets/           # Static assets (icons, images)\n├── components/       # Global reusable components\n├── config/           # Configuration (nprogress, service loading)\n├── enums/            # Enumerations (HTTP status codes, common enums)\n├── hooks/            # Custom React hooks (useTheme, etc.)\n├── layouts/          # Layout components (Header, Menu, Tabs, Footer)\n├── redux/            # Redux store and modules\n├── routers/          # Route configuration (modular)\n├── styles/           # Global styles (less files)\n├── typings/          # TypeScript type definitions\n├── utils/            # Utility functions\n├── views/            # Page components (organized by feature)\n```\n\n### Views Structure\n- `article/`: Article list, edit with markdown editor, search\n- `author/`: Author whitelist, zsxq list\n- `category/`: Category management\n- `column/`: Column settings, article sorting, groups\n- `config/`: Site configuration with image uploads\n- `global/`: Global settings\n- `home/`: Dashboard (default redirect from `/`)\n- `login/`: Login page\n- `resume/`: Resume management\n- `statistics/`: Data statistics with ECharts\n- `tag/`: Tag management\n\n## Important Notes\n\n### Environment Configuration\n- Development: `VITE_API_URL = '/api/admin'`, proxy to `http://127.0.0.1:8080`\n- Backend must be running on port 8080 (paicoding Spring Boot app)\n- Redis must be running for backend\n\n### Path Alias\n- `@` maps to `src/` directory (configured in `vite.config.ts` and `tsconfig.json`)\n\n### TypeScript\n- Strict mode disabled for flexibility\n- Type definitions in `src/typings/` and `src/api/interface/`\n\n### Styling\n- Uses Less preprocessor\n- Global variables in `src/styles/var.less`\n- Ant Design theme customization supported\n\n### Known Issues\n- If `npm install` fails with Node.js < 16, upgrade Node.js to 18+ and npm to 9+\n- OR download pre-packaged node_modules (mentioned in README)\n- Windows users: Convert `launch.sh` line endings if needed (`dos2unix launch.sh`)\n\n### Backend Integration\n- Expects backend at `http://127.0.0.1:8080`\n- Login endpoint returns token stored in Redux + localStorage\n- Token sent as `x-access-token` header in all requests\n- Response format: `{ status: { code, msg }, result: {...} }`\n- Code 599 = not logged in (redirects to login page)\n- Code 200 = success\n"
  },
  {
    "path": "LICENSE",
    "content": "MIT License\n\nCopyright (c) 2022 SpicyBoy\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n"
  },
  {
    "path": "README.md",
    "content": "# paicoding-admin 🚀\n\n## 介绍 📖\n\n<p align=\"center\">\n  <a href=\"https://paicoding.com/\">\n    <img src=\"https://cdn.tobebetterjavaer.com/images/README/1681354262213.png\" alt=\"技术派\" width=\"400\">\n  </a>\n</p>\n🚀🚀🚀 paicoding-admin，技术派管理端，基于 React18、React-Router v6、React-Hooks、Redux、TypeScript、Vite3、Ant-Design 5.x、Hook Admin、ECharts 的一套社区管理系统，够惊艳哦。\n<br><br>\n<p align=\"center\">\n  <a href=\"https://paicoding.com/article/detail/15\"><img src=\"https://img.shields.io/badge/技术派-学习圈子-green.svg?style=for-the-badge\"></a>\n  <a href=\"https://paicoding.com/\" target=\"_blank\"><img src=\"https://img.shields.io/badge/技术派-首页-critical?style=for-the-badge\"></a>\n  <a href=\"https://github.com/itwanger/paicoding-admin\" target=\"_blank\"><img src=\"https://img.shields.io/badge/技术派-管理端-yellow.svg?style=for-the-badge\"></a>\n  <a href=\"https://gitee.com/itwanger/paicoding-admin\" target=\"_blank\"><img src=\"https://img.shields.io/badge/码云-项目地址-blue.svg?style=for-the-badge\"></a>\n</p>\n\n## 一、在线预览地址 👀\n\n- Link：[https://paicoding.com/admin](https://paicoding.com/admin)\n\n## 二、Git 仓库地址 (欢迎 Star⭐)\n\n- GitHub：[https://github.com/itwanger/paicoding-admin](https://github.com/itwanger/paicoding-admin)\n- 码云：[https://gitee.com/itwanger/paicoding-admin](https://gitee.com/itwanger/paicoding-admin)\n\n## 三、🔨🔨🔨 项目功能\n\n- 🚀 采用最新技术找开发：React18、React-Router v6、React-Hooks、TypeScript、Vite3\n- 🚀 采用 Vite3 作为项目开发、打包工具（配置了 Gzip 打包、跨域代理、打包预览工具……）\n- 🚀 整个项目集成了 TypeScript （学期来很酷哦 🤣）\n- 🚀 使用 redux 做状态管理，集成 immer、react-redux、redux-persist 开发\n- 🚀 使用 TypeScript 对 Axios 整个二次封装 （全局错误拦截、常用请求封装、全局请求 Loading、取消重复请求……）\n- 🚀 支持 Antd 组件大小切换、暗黑 && 灰色 && 色弱模式\n- 🚀 使用 自定义高阶组件 进行路由权限拦截（403 页面）、页面按钮权限配置\n- 🚀 支持 React-Router v6 路由懒加载配置、菜单手风琴模式、无限级菜单、多标签页、面包屑导航\n- 🚀 使用 Prettier 统一格式化代码，集成 Eslint、Stylelint 代码校验规范（项目规范配置）\n- 🚀 使用 husky、lint-staged、commitlint、commitizen、cz-git 规范提交信息（项目规范配置）\n\n## 四、安装使用步骤 📑\n\n### Clone：\n\n```text\n# GitHub\ngit clone https://github.com/itwanger/paicoding-admin.git\n```\n\n### Install：\n\n```text\nnpm install\ncnpm install\n\n# npm install 安装失败，请升级 nodejs 到 16 以上，或尝试使用以下命令：\nnpm install --registry=http://registry.npmmirror.com\n\n# npm install 如果出现 npm ERR! code ECONNRESET 错误，可尝试执行以下命令后再安装\nnpm config set registry http://registry.npmjs.org/\n```\n\n### Run：\n\n将技术派的后端代码和前端代码拉到本地后，先启动 Redis 和服务端端。然后再启动 admin 端，可以通过 VSCode 来进行开发。\n\n![](https://cdn.tobebetterjavaer.com/stutymore/README-20230605110431.png)\n\n```text\nnpm run dev\n```\n\n会自动在浏览器打开 [http://127.0.0.1:3301](http://127.0.0.1:3301)，如下所示。\n\n![](https://cdn.tobebetterjavaer.com/stutymore/README-20230605110616.png)\n\n本地的用户名和密码均为 admin 和 admin 。\n\n如果遇到 nodejs 环境的问题实在无法启动，可能是一些依赖包的问题，可以尝试删除 node_modules 文件夹，重新安装依赖包。如果仍然无法解决，可以通过以下方式获取我已经打包好的 node_modules 安装包。\n\n异常堆栈：\n\n![](https://cdn.tobebetterjavaer.com/stutymore/README-20231116201935.png)\n\n解决方法 1：升级 nodejs 到 18 以上，升级 npm 到 9 以上，然后重新 install。\n\n![](https://cdn.tobebetterjavaer.com/stutymore/README-20231116202024.png)\n\n解决方法 2：删除 node_modules 文件夹，在「沉默王二」公众号后台回复「node」下载 node_modules 依赖包。\n\n![](https://cdn.tobebetterjavaer.com/stutymore/README-20231116202230.png)\n\n然后覆盖你本地的 node_modules 包，然后再执行 `npm run dev` 就可以运行起来了。\n\n![](https://cdn.tobebetterjavaer.com/stutymore/README-20231116202239.png)\n\n### Build：\n\n```text\n# 生产环境\nnpm run build:pro\n```\n\n## 五、项目截图\n\n### 1、数据统计页（ECharts 真强大）：\n\n![](https://cdn.tobebetterjavaer.com/stutymore/README-20230602150500.png)\n\n### 2、运营配置页（Ant 的图片上传组件不错哦）：\n\n![](https://cdn.tobebetterjavaer.com/stutymore/README-20230602150909.png)\n\n### 3、文章管理页：\n\n![](https://cdn.tobebetterjavaer.com/stutymore/README-20230602154026.png)\n\n### 4、专栏配置页（自定义下拉框挺好玩的）：\n\n![](https://cdn.tobebetterjavaer.com/stutymore/README-20230602154134.png)\n\n![](https://cdn.tobebetterjavaer.com/stutymore/README-20230602154222.png)\n\n### 5、教程配置页（防抖支持搜索的下拉框、自定义支持分页、搜索的下拉框不错哦）\n\n![](https://cdn.tobebetterjavaer.com/stutymore/README-20230602154904.png)\n\n![](https://cdn.tobebetterjavaer.com/stutymore/README-20230602155018.png)\n\n## 六、文件资源目录 📚\n\n```text\npacoding-admin\n├─ .vscode                # vscode推荐配置\n├─ public                 # 静态资源文件（忽略打包）\n├─ src\n│  ├─ api                 # API 接口管理\n│  ├─ assets              # 静态资源文件\n│  ├─ components          # 全局组件\n│  ├─ config              # 全局配置项\n│  ├─ enums               # 项目枚举\n│  ├─ hooks               # 常用 Hooks\n│  ├─ language            # 语言国际化\n│  ├─ layouts             # 框架布局\n│  ├─ routers             # 路由管理\n│  ├─ redux               # redux store\n│  ├─ styles              # 全局样式\n│  ├─ typings             # 全局 ts 声明\n│  ├─ utils               # 工具库\n│  ├─ views               # 项目所有页面\n│  ├─ App.tsx             # 入口页面\n│  ├─ main.tsx            # 入口文件\n│  └─ env.d.ts            # vite 声明文件\n├─ .editorconfig          # 编辑器配置（格式化）\n├─ .env                   # vite 常用配置\n├─ .env.deploy.example    # 部署脚本配置示例\n├─ .env.development       # 开发环境配置\n├─ .env.production        # 生产环境配置\n├─ .env.test              # 测试环境配置\n├─ .eslintignore          # 忽略 Eslint 校验\n├─ .eslintrc.js           # Eslint 校验配置\n├─ .gitignore             # git 提交忽略\n├─ .prettierignore        # 忽略 prettier 格式化\n├─ .prettierrc.js         # prettier 配置\n├─ .stylelintignore       # 忽略 stylelint 格式化\n├─ .stylelintrc.js        # stylelint 样式格式化配置\n├─ CHANGELOG.md           # 项目更新日志\n├─ commitlint.config.js   # git 提交规范配置\n├─ deploy-front.sh        # 前端生产环境部署脚本\n├─ index.html             # 入口 html\n├─ LICENSE                # 开源协议文件\n├─ lint-staged.config     # lint-staged 配置文件\n├─ package-lock.json      # 依赖包包版本锁\n├─ package.json           # 依赖包管理\n├─ postcss.config.js      # postcss 配置\n├─ README.md              # README 介绍\n├─ tsconfig.json          # typescript 全局配置\n└─ vite.config.ts         # vite 配置\n```\n\n## 七、项目后台接口 🧩\n\n> 依托于技术派项目，一个基于 Spring Boot、MyBatis-Plus、MySQL、Redis、ElasticSearch、MongoDB、Docker、RabbitMQ 等技术栈实现的社区系统，采用主流的互联网技术架构、全新的 UI 设计、支持一键源码部署，拥有完整的文章&教程发布/搜索/评论/统计流程等，代码完全开源，没有任何二次封装，是一个非常适合二次开发/实战的现代化社区项目 👍 。\n\n- 网站首页：[https://paicoding.com/](https://paicoding.com/)\n- 源码地址：[https://github.com/itwanger/paicoding](https://github.com/itwanger/paicoding)\n\n## 八、生产环境部署\n\n推荐使用根目录下的 `deploy-front.sh` 一键部署脚本。脚本会自动执行 `npm run build:pro`，将 `dist` 压缩为 `dist.zip`，上传到服务器指定目录，删除远端旧的 `dist` 目录并执行 `unzip` 解压。\n\n1、先准备部署配置文件：\n\n```bash\ncp .env.deploy.example .env.deploy\n```\n\n2、修改 `.env.deploy` 中的部署参数：\n\n```bash\nDEPLOY_SERVER_HOST=你的服务器IP\nDEPLOY_SERVER_USER=admin\nDEPLOY_SERVER_KEY=/Users/yourname/.ssh/id_rsa\nDEPLOY_TARGET_DIR=/home/admin\n```\n\n其中：\n\n- `DEPLOY_SERVER_HOST`：服务器 IP 或域名\n- `DEPLOY_SERVER_USER`：SSH 登录用户，默认 `admin`\n- `DEPLOY_SERVER_KEY`：SSH 私钥路径；如果服务器已经配置免密登录，可以留空\n- `DEPLOY_TARGET_DIR`：上传并解压 `dist.zip` 的目标目录，默认 `/home/admin`\n\n3、执行部署脚本：\n\n```bash\n./deploy-front.sh\n```\n\n如果当前脚本没有执行权限，可以先执行：\n\n```bash\nchmod +x deploy-front.sh\n```\n\n4、脚本完成后，服务器上会得到最新的 `/home/admin/dist` 目录。\n\n如果你想手动部署，也可以按下面的流程执行：\n\n```bash\nnpm run build:pro\nzip -r dist.zip dist\nscp dist.zip admin@你的服务器IP:/home/admin/dist.zip\nssh admin@你的服务器IP\ncd /home/admin\nrm -rf dist\nunzip dist.zip\nrm -f dist.zip\n```\n\n5、如果采用 Nginx 的话，请在 server 节点下进行 location 配置。\n\n```\nlocation ^~ /admin {\n\talias /home/admin/dist/; # 根 目 录\n\tindex index.html;\n}\n```\n\n### launch.sh\n\n辅助 shell 脚本，针对 mac/linux 用户而言，提供更好的使用姿势\n\n0. 前提说明\n\n当 launch.sh 执行时，提示 `$‘\\r‘: command not found`时，主要原因是 windows 系统编写的 shell 脚本，每行结尾是`\\r\\n`， 而 linux 的结尾是`\\n`，可以通过下面几种方式进行处理\n\n```bash\n# case1\nsed -i 's/\\r//' launch.sh\n\n# case2\n# sudo apt-get install -y dos2unix\nsudo yum install -y dos2unix\ndos2unix launch.sh\n```\n\n1.安装依赖：\n\n```bash\n./launch.sh install\n```\n\n2.本地启动：\n\n```bash\n./launch.sh server\n```\n\n3.打包上传服务器，并使他生效\n\n```bash\n# 下面这个动作，包含以下几步\n# 1. 打包 -> 生成 dist 目录， 压缩为 dist.tar.gz 包\n# 2. 上传到服务器\n# 3. 将之前旧的静态资源备份，然后解压新的上传包\n./launch.sh pro\n```\n\n## 九、友情链接\n\n- [toBeBetterjavaer](https://github.com/itwanger/toBeBetterJavaer) ：一份通俗易懂、风趣幽默的 Java 学习指南，内容涵盖 Java 基础、Java 并发编程、Java 虚拟机、Java 企业级开发、Java 面试等核心知识点。学 Java，就认准二哥的 Java 进阶之路 😄\n- [paicoding](https://github.com/itwanger/paicoding) ：⭐️ 一款好用又强大的开源社区，基于 Spring Boot、MyBatis-Plus、MySQL、Redis、ElasticSearch、MongoDB、Docker、RabbitMQ 等主流技术栈，附详细教程，包括 Java、Spring、MySQL、Redis、微服务&分布式、消息队列等核心知识点。学编程，就上技术派 😁。\n\n## 十、star 趋势图\n\n[![Star History Chart](https://api.star-history.com/svg?repos=itwanger/paicoding-admin&type=Date)](https://star-history.com/#itwanger/paicoding-admin&Date)\n\n## 十一、许可证\n\n[Apache License 2.0](https://github.com/itwanger/paicoding/blob/main/License)\n\nCopyright (c) 2022-2023 技术派（沉默王二、楼仔、一灰、小超）\n"
  },
  {
    "path": "commitlint.config.js",
    "content": "// @see: https://cz-git.qbenben.com/zh/guide\n/** @type {import('cz-git').UserConfig} */\n\nmodule.exports = {\n\tignores: [commit => commit.includes(\"init\")],\n\textends: [\"@commitlint/config-conventional\"],\n\trules: {\n\t\t// @see: https://commitlint.js.org/#/reference-rules\n\t\t\"body-leading-blank\": [2, \"always\"],\n\t\t\"footer-leading-blank\": [1, \"always\"],\n\t\t\"header-max-length\": [2, \"always\", 108],\n\t\t\"subject-empty\": [2, \"never\"],\n\t\t\"type-empty\": [2, \"never\"],\n\t\t\"subject-case\": [0],\n\t\t\"type-enum\": [\n\t\t\t2,\n\t\t\t\"always\",\n\t\t\t[\n\t\t\t\t\"feat\",\n\t\t\t\t\"fix\",\n\t\t\t\t\"docs\",\n\t\t\t\t\"style\",\n\t\t\t\t\"refactor\",\n\t\t\t\t\"perf\",\n\t\t\t\t\"test\",\n\t\t\t\t\"build\",\n\t\t\t\t\"ci\",\n\t\t\t\t\"chore\",\n\t\t\t\t\"revert\",\n\t\t\t\t\"wip\",\n\t\t\t\t\"workflow\",\n\t\t\t\t\"types\",\n\t\t\t\t\"release\"\n\t\t\t]\n\t\t]\n\t},\n\tprompt: {\n\t\tmessages: {\n\t\t\ttype: \"Select the type of change that you're committing:\",\n\t\t\tscope: \"Denote the SCOPE of this change (optional):\",\n\t\t\tcustomScope: \"Denote the SCOPE of this change:\",\n\t\t\tsubject: \"Write a SHORT, IMPERATIVE tense description of the change:\\n\",\n\t\t\tbody: 'Provide a LONGER description of the change (optional). Use \"|\" to break new line:\\n',\n\t\t\tbreaking: 'List any BREAKING CHANGES (optional). Use \"|\" to break new line:\\n',\n\t\t\tfooterPrefixsSelect: \"Select the ISSUES type of changeList by this change (optional):\",\n\t\t\tcustomFooterPrefixs: \"Input ISSUES prefix:\",\n\t\t\tfooter: \"List any ISSUES by this change. E.g.: #31, #34:\\n\",\n\t\t\tconfirmCommit: \"Are you sure you want to proceed with the commit above?\"\n\t\t\t// 中文版\n\t\t\t// type: \"选择你要提交的类型 :\",\n\t\t\t// scope: \"选择一个提交范围（可选）:\",\n\t\t\t// customScope: \"请输入自定义的提交范围 :\",\n\t\t\t// subject: \"填写简短精炼的变更描述 :\\n\",\n\t\t\t// body: '填写更加详细的变更描述（可选）。使用 \"|\" 换行 :\\n',\n\t\t\t// breaking: '列举非兼容性重大的变更（可选）。使用 \"|\" 换行 :\\n',\n\t\t\t// footerPrefixsSelect: \"选择关联issue前缀（可选）:\",\n\t\t\t// customFooterPrefixs: \"输入自定义issue前缀 :\",\n\t\t\t// footer: \"列举关联issue (可选) 例如: #31, #I3244 :\\n\",\n\t\t\t// confirmCommit: \"是否提交或修改commit ?\"\n\t\t},\n\t\ttypes: [\n\t\t\t{\n\t\t\t\tvalue: \"feat\",\n\t\t\t\tname: \"feat:     🚀  A new feature\",\n\t\t\t\temoji: \"🚀\"\n\t\t\t},\n\t\t\t{\n\t\t\t\tvalue: \"fix\",\n\t\t\t\tname: \"fix:      🧩  A bug fix\",\n\t\t\t\temoji: \"🧩\"\n\t\t\t},\n\t\t\t{\n\t\t\t\tvalue: \"docs\",\n\t\t\t\tname: \"docs:     📚  Documentation only changes\",\n\t\t\t\temoji: \"📚\"\n\t\t\t},\n\t\t\t{\n\t\t\t\tvalue: \"style\",\n\t\t\t\tname: \"style:    🎨  Changes that do not affect the meaning of the code\",\n\t\t\t\temoji: \"🎨\"\n\t\t\t},\n\t\t\t{\n\t\t\t\tvalue: \"refactor\",\n\t\t\t\tname: \"refactor: ♻️   A code change that neither fixes a bug nor adds a feature\",\n\t\t\t\temoji: \"♻️\"\n\t\t\t},\n\t\t\t{\n\t\t\t\tvalue: \"perf\",\n\t\t\t\tname: \"perf:     ⚡️  A code change that improves performance\",\n\t\t\t\temoji: \"⚡️\"\n\t\t\t},\n\t\t\t{\n\t\t\t\tvalue: \"test\",\n\t\t\t\tname: \"test:     ✅  Adding missing tests or correcting existing tests\",\n\t\t\t\temoji: \"✅\"\n\t\t\t},\n\t\t\t{\n\t\t\t\tvalue: \"build\",\n\t\t\t\tname: \"build:    📦️   Changes that affect the build system or external dependencies\",\n\t\t\t\temoji: \"📦️\"\n\t\t\t},\n\t\t\t{\n\t\t\t\tvalue: \"ci\",\n\t\t\t\tname: \"ci:       🎡  Changes to our CI configuration files and scripts\",\n\t\t\t\temoji: \"🎡\"\n\t\t\t},\n\t\t\t{\n\t\t\t\tvalue: \"chore\",\n\t\t\t\tname: \"chore:    🔨  Other changes that don't modify src or test files\",\n\t\t\t\temoji: \"🔨\"\n\t\t\t},\n\t\t\t{\n\t\t\t\tvalue: \"revert\",\n\t\t\t\tname: \"revert:   ⏪️  Reverts a previous commit\",\n\t\t\t\temoji: \"⏪️\"\n\t\t\t}\n\t\t\t// 中文版\n\t\t\t// { value: \"特性\", name: \"特性:   🚀  新增功能\", emoji: \"🚀\" },\n\t\t\t// { value: \"修复\", name: \"修复:   🧩  修复缺陷\", emoji: \"🧩\" },\n\t\t\t// { value: \"文档\", name: \"文档:   📚  文档变更\", emoji: \"📚\" },\n\t\t\t// { value: \"格式\", name: \"格式:   🎨  代码格式（不影响功能，例如空格、分号等格式修正）\", emoji: \"🎨\" },\n\t\t\t// { value: \"重构\", name: \"重构:   ♻️  代码重构（不包括 bug 修复、功能新增）\", emoji: \"♻️\" },\n\t\t\t// { value: \"性能\", name: \"性能:   ⚡️  性能优化\", emoji: \"⚡️\" },\n\t\t\t// { value: \"测试\", name: \"测试:   ✅  添加疏漏测试或已有测试改动\", emoji: \"✅\" },\n\t\t\t// { value: \"构建\", name: \"构建:   📦️  构建流程、外部依赖变更（如升级 npm 包、修改 webpack 配置等）\", emoji: \"📦️\" },\n\t\t\t// { value: \"集成\", name: \"集成:   🎡  修改 CI 配置、脚本\", emoji: \"🎡\" },\n\t\t\t// { value: \"回退\", name: \"回退:   ⏪️  回滚 commit\", emoji: \"⏪️\" },\n\t\t\t// { value: \"其他\", name: \"其他:   🔨  对构建过程或辅助工具和库的更改（不影响源文件、测试用例）\", emoji: \"🔨\" }\n\t\t],\n\t\tuseEmoji: true,\n\t\tthemeColorCode: \"\",\n\t\tscopes: [],\n\t\tallowCustomScopes: true,\n\t\tallowEmptyScopes: true,\n\t\tcustomScopesAlign: \"bottom\",\n\t\tcustomScopesAlias: \"custom\",\n\t\temptyScopesAlias: \"empty\",\n\t\tupperCaseSubject: false,\n\t\tallowBreakingChanges: [\"feat\", \"fix\"],\n\t\tbreaklineNumber: 100,\n\t\tbreaklineChar: \"|\",\n\t\tskipQuestions: [],\n\t\tissuePrefixs: [{ value: \"closed\", name: \"closed:   ISSUES has been processed\" }],\n\t\tcustomIssuePrefixsAlign: \"top\",\n\t\temptyIssuePrefixsAlias: \"skip\",\n\t\tcustomIssuePrefixsAlias: \"custom\",\n\t\tallowCustomIssuePrefixs: true,\n\t\tallowEmptyIssuePrefixs: true,\n\t\tconfirmColorize: true,\n\t\tmaxHeaderLength: Infinity,\n\t\tmaxSubjectLength: Infinity,\n\t\tminSubjectLength: 0,\n\t\tscopeOverrides: undefined,\n\t\tdefaultBody: \"\",\n\t\tdefaultIssues: \"\",\n\t\tdefaultScope: \"\",\n\t\tdefaultSubject: \"\"\n\t}\n};\n"
  },
  {
    "path": "deploy-front.sh",
    "content": "#!/usr/bin/env bash\n\nset -euo pipefail\n\nSCRIPT_DIR=\"$(cd \"$(dirname \"${BASH_SOURCE[0]}\")\" && pwd)\"\nROOT_DIR=\"$SCRIPT_DIR\"\nFRONTEND_DIR=\"${FRONTEND_DIR:-$ROOT_DIR}\"\nDEFAULT_ENV_FILE=\"$ROOT_DIR/.env.deploy\"\nFALLBACK_ENV_FILE=\"$ROOT_DIR/.env\"\nENV_FILE=\"${ENV_FILE:-$DEFAULT_ENV_FILE}\"\n\nif [ -f \"$ENV_FILE\" ]; then\n  set -a\n  # shellcheck disable=SC1090\n  . \"$ENV_FILE\"\n  set +a\nelif [ \"$ENV_FILE\" = \"$DEFAULT_ENV_FILE\" ] && [ -f \"$FALLBACK_ENV_FILE\" ]; then\n  set -a\n  # shellcheck disable=SC1090\n  . \"$FALLBACK_ENV_FILE\"\n  set +a\nfi\n\nSERVER_HOST=\"${DEPLOY_SERVER_HOST:-${SERVER_HOST:-}}\"\nSERVER_USER=\"${DEPLOY_SERVER_USER:-${SERVER_USER:-admin}}\"\nSERVER_KEY=\"${DEPLOY_SERVER_KEY:-${SERVER_KEY:-}}\"\nTARGET_DIR=\"${DEPLOY_TARGET_DIR:-${TARGET_DIR:-/home/admin}}\"\nBUILD_CMD=\"${DEPLOY_BUILD_CMD:-${BUILD_CMD:-npm run build:pro}}\"\nSKIP_BUILD=\"${DEPLOY_SKIP_BUILD:-${SKIP_BUILD:-0}}\"\nHEALTHCHECK_URL=\"${DEPLOY_HEALTHCHECK_URL:-${HEALTHCHECK_URL:-}}\"\nHEALTHCHECK_TIMEOUT=\"${DEPLOY_HEALTHCHECK_TIMEOUT:-${HEALTHCHECK_TIMEOUT:-15}}\"\n\nARTIFACT_NAME=\"dist.zip\"\nARTIFACT_PATH=\"$ROOT_DIR/$ARTIFACT_NAME\"\nREMOTE_ARTIFACT_PATH=\"$TARGET_DIR/$ARTIFACT_NAME\"\nSSH_OPTS=(\n  -o StrictHostKeyChecking=accept-new\n)\n\nlog() {\n  printf '[deploy] %s\\n' \"$1\"\n}\n\nrequire_cmd() {\n  if ! command -v \"$1\" >/dev/null 2>&1; then\n    printf 'Missing required command: %s\\n' \"$1\" >&2\n    exit 1\n  fi\n}\n\nrequire_cmd npm\nrequire_cmd zip\nrequire_cmd ssh\nrequire_cmd scp\n\nif [ -n \"$HEALTHCHECK_URL\" ]; then\n  require_cmd curl\nfi\n\nif [ ! -d \"$FRONTEND_DIR\" ]; then\n  printf 'Frontend directory not found: %s\\n' \"$FRONTEND_DIR\" >&2\n  exit 1\nfi\n\nif [ -z \"$SERVER_HOST\" ]; then\n  printf 'DEPLOY_SERVER_HOST is required. Set it in %s or export it before running.\\n' \"$ENV_FILE\" >&2\n  exit 1\nfi\n\nif [ -n \"$SERVER_KEY\" ]; then\n  if [ ! -f \"$SERVER_KEY\" ]; then\n    printf 'SSH key not found: %s\\n' \"$SERVER_KEY\" >&2\n    exit 1\n  fi\n\n  SSH_OPTS=(\n    -i \"$SERVER_KEY\"\n    \"${SSH_OPTS[@]}\"\n  )\nfi\n\ncd \"$FRONTEND_DIR\"\n\nif [ \"$SKIP_BUILD\" != \"1\" ]; then\n  log \"building frontend with: $BUILD_CMD\"\n  eval \"$BUILD_CMD\"\nelse\n  log \"skipping build because SKIP_BUILD=1\"\nfi\n\nif [ ! -d \"$FRONTEND_DIR/dist\" ]; then\n  printf 'Build output not found: %s\\n' \"$FRONTEND_DIR/dist\" >&2\n  exit 1\nfi\n\nrm -f \"$ARTIFACT_PATH\"\n\nlog \"creating artifact: $ARTIFACT_NAME\"\nzip -qry \"$ARTIFACT_PATH\" dist\n\nlog \"ensuring remote target directory exists\"\nssh \"${SSH_OPTS[@]}\" \"$SERVER_USER@$SERVER_HOST\" \"mkdir -p '$TARGET_DIR'\"\n\nlog \"uploading artifact to $SERVER_USER@$SERVER_HOST:$TARGET_DIR\"\nscp \"${SSH_OPTS[@]}\" \"$ARTIFACT_PATH\" \"$SERVER_USER@$SERVER_HOST:$REMOTE_ARTIFACT_PATH\"\n\nlog \"replacing remote dist directory\"\nssh \"${SSH_OPTS[@]}\" \"$SERVER_USER@$SERVER_HOST\" \"\n  set -e\n  command -v unzip >/dev/null 2>&1 || { echo 'unzip is required on remote host' >&2; exit 1; }\n  cd '$TARGET_DIR'\n  rm -rf dist\n  unzip -oq '$REMOTE_ARTIFACT_PATH' -d '$TARGET_DIR'\n  rm -f '$REMOTE_ARTIFACT_PATH'\n\"\n\nlog \"cleaning up local artifact\"\nrm -f \"$ARTIFACT_PATH\"\n\nlog \"verifying remote dist/index.html\"\nssh \"${SSH_OPTS[@]}\" \"$SERVER_USER@$SERVER_HOST\" \"test -f '$TARGET_DIR/dist/index.html'\"\n\nif [ -n \"$HEALTHCHECK_URL\" ]; then\n  log \"checking health url: $HEALTHCHECK_URL\"\n  curl --fail --silent --show-error --location --max-time \"$HEALTHCHECK_TIMEOUT\" \"$HEALTHCHECK_URL\" >/dev/null\nfi\n\nlog \"deploy finished\"\n"
  },
  {
    "path": "index.html",
    "content": "<!DOCTYPE html>\n<html lang=\"en\">\n\t<head>\n\t\t<meta charset=\"UTF-8\" />\n\t\t<link rel=\"icon\" type=\"image/svg+xml\" href=\"./favicon.png\" />\n\t\t<meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\" />\n\t\t<title><%- title %></title>\n\t</head>\n\t<body>\n\t\t<div id=\"root\">\n\t\t\t<style>\n\t\t\t\thtml,\n\t\t\t\tbody,\n\t\t\t\t#root {\n\t\t\t\t\twidth: 100%;\n\t\t\t\t\theight: 100%;\n\t\t\t\t\tpadding: 0;\n\t\t\t\t\tmargin: 0;\n\t\t\t\t\tbackground-color: #ffffff;\n\t\t\t\t}\n\t\t\t\t.first-loading-wrap {\n\t\t\t\t\tdisplay: flex;\n\t\t\t\t\tflex-direction: column;\n\t\t\t\t\talign-items: center;\n\t\t\t\t\tjustify-content: center;\n\t\t\t\t\twidth: 100%;\n\t\t\t\t\theight: 100%;\n\t\t\t\t}\n\t\t\t\t.first-loading-wrap > h1 {\n\t\t\t\t\tfont-size: 128px;\n\t\t\t\t}\n\t\t\t\t.first-loading-wrap .loading-wrap {\n\t\t\t\t\tdisplay: flex;\n\t\t\t\t\talign-items: center;\n\t\t\t\t\tjustify-content: center;\n\t\t\t\t\tpadding: 98px;\n\t\t\t\t}\n\t\t\t\t.dot {\n\t\t\t\t\tposition: relative;\n\t\t\t\t\tbox-sizing: border-box;\n\t\t\t\t\tdisplay: inline-block;\n\t\t\t\t\twidth: 32px;\n\t\t\t\t\theight: 32px;\n\t\t\t\t\tfont-size: 32px;\n\t\t\t\t\ttransform: rotate(45deg);\n\t\t\t\t\tanimation: ant-rotate 1.2s infinite linear;\n\t\t\t\t}\n\t\t\t\t.dot i {\n\t\t\t\t\tposition: absolute;\n\t\t\t\t\tdisplay: block;\n\t\t\t\t\twidth: 14px;\n\t\t\t\t\theight: 14px;\n\t\t\t\t\tbackground-color: #1890ff;\n\t\t\t\t\tborder-radius: 100%;\n\t\t\t\t\topacity: 0.3;\n\t\t\t\t\ttransform: scale(0.75);\n\t\t\t\t\ttransform-origin: 50% 50%;\n\t\t\t\t\tanimation: ant-spin-move 1s infinite linear alternate;\n\t\t\t\t}\n\t\t\t\t.dot i:nth-child(1) {\n\t\t\t\t\ttop: 0;\n\t\t\t\t\tleft: 0;\n\t\t\t\t}\n\t\t\t\t.dot i:nth-child(2) {\n\t\t\t\t\ttop: 0;\n\t\t\t\t\tright: 0;\n\t\t\t\t\tanimation-delay: 0.4s;\n\t\t\t\t}\n\t\t\t\t.dot i:nth-child(3) {\n\t\t\t\t\tright: 0;\n\t\t\t\t\tbottom: 0;\n\t\t\t\t\tanimation-delay: 0.8s;\n\t\t\t\t}\n\t\t\t\t.dot i:nth-child(4) {\n\t\t\t\t\tbottom: 0;\n\t\t\t\t\tleft: 0;\n\t\t\t\t\tanimation-delay: 1.2s;\n\t\t\t\t}\n\t\t\t\t@keyframes ant-rotate {\n\t\t\t\t\tto {\n\t\t\t\t\t\ttransform: rotate(405deg);\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\t@keyframes ant-spin-move {\n\t\t\t\t\tto {\n\t\t\t\t\t\topacity: 1;\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t</style>\n\t\t\t<div class=\"first-loading-wrap\">\n\t\t\t\t<div class=\"loading-wrap\">\n\t\t\t\t\t<span class=\"dot dot-spin\"><i></i><i></i><i></i><i></i></span>\n\t\t\t\t</div>\n\t\t\t</div>\n\t\t</div>\n\t\t<script type=\"module\" src=\"./src/main.tsx\"></script>\n\t</body>\n</html>\n"
  },
  {
    "path": "launch.sh",
    "content": "#!/usr/bin/env bash\n\n# file to upload\nWEB_PKG=\"dist.tar.gz\"\nWEB_PKG_BK=\"dist_bk.tar.gz\"\nTMP_WEB_PKG=\"tmp.tar.gz\"\n\nDEPLOY_SCRIPT=\"launch.sh\"\nSTART_FUNC_NAME=\"start\"\nBUILD_FUNC_NAME=\"build\"\nINSTALL_FUNC_NAME=\"install\"\nSERVER_FUNC_NAME=\"server\"\n\n#env, ssh remote, work dir\nENV_PRO=\"pro\"\nSSH_HOST_PRO=(\"admin@39.105.208.175\")\nWORK_DIR_PRO=\"/home/admin/workspace/admin/\"\nADMIN_WORKSPACE=\"dist\"\n\n\n\nfunction build() {\n    echo \"---- start to build admin ----\"\n    echo \"npm run build:pro\"\n    npm run build:pro\n  \ttar -zcvf ${WEB_PKG} dist\n\n    echo \"---------- 静态资源包dist.tar.gz已打包完成 -------------\"\n}\n\nfunction upload() {\n    # upload jar\n    # rename to *.jar.bak\n    scp ${WEB_PKG} $1:$2${TMP_WEB_PKG}\n    ret=$?\n    if [[ ${ret} -ne 0 ]] ; then\n        echo \"Failed to scp ${WEB_PKG}\"\n        return 1\n    fi\n\n    # upload script\n    scp ${DEPLOY_SCRIPT} $1:$2\n    ret=$?\n    if [[ ${ret} -ne 0 ]] ; then\n        echo 'Failed to scp launch.sh'\n        return 1\n    fi\n}\n\nfunction install() {\n    npm install\n}\n\nfunction server() {\n    npm run dev\n}\n\nfunction start() {\n    echo \"---- 开始部署 ----\"\n\t\tcd ${WORK_DIR_PRO}\n\t\tmv ${WEB_PKG} ${WEB_PKG_BK}\n\t\tmv ${ADMIN_WORKSPACE} \"${ADMIN_WORKSPACE}_bk\"\n\t\tmv ${TMP_WEB_PKG} ${WEB_PKG}\n\t\ttar -zxvf ${WEB_PKG}\n\t\techo \"---- 部署完成 ----\"\n}\n\nfunction deploy() {\n    # package\n    echo \"*******Start to package*******\"\n    build $1\n    ret=$?\n    if [[ ${ret} -ne 0 ]] ; then\n        echo 'Failed to compile'\n        exit ${ret}\n    fi\n\n    if [ \"$1\" = \"${ENV_PRO}\" ]; then\n        SSH_HOST=${SSH_HOST_PRO[@]}\n        WORK_DIR=${WORK_DIR_PRO}\n    else\n        echo \"Unknown env: $1\"\n        exit\n    fi\n\n    for host in ${SSH_HOST[@]}\n    do\n        # upload jar and launch.sh\n        echo \"*******Start to upload:${host} *******\"\n        upload ${host} ${WORK_DIR}\n        ret=$?\n        if [[ ${ret} -ne 0 ]] ; then\n            echo 'Failed to upload files'\n            exit ${ret}\n        fi\n    done\n\n    for host in ${SSH_HOST[@]}\n    do\n        # run\n        echo \"*******Start service:${host} *******\"\n        ssh ${host} \"bash ${WORK_DIR}${DEPLOY_SCRIPT} ${START_FUNC_NAME}\"\n        echo \"*******Done*******\"\n    done\n}\n\nif [ \"$1\" = \"${START_FUNC_NAME}\" ]; then\n    start \"$@\"\nelif [ \"$1\" = \"${BUILD_FUNC_NAME}\" ]; then\n    build $1\nelif [ \"$1\" = \"${INSTALL_FUNC_NAME}\" ]; then\n    install $1\nelif [ \"$1\" = \"${SERVER_FUNC_NAME}\" ]; then\n    server $1\nelif [ \"$1\" = \"${ENV_PRO}\" ]; then\n    deploy $1\nelse\n    echo \"=========== 本地环境安装 & 调试 ==============\"\n    echo \"安装依赖:  ./launch.sh install\"\n    echo \"本地启动:  ./launch.sh server\"\n    echo \"=========== 上传服务器 & 服务器解压使用 ==============\"\n    echo \"打包 dist.tar.gz:  ./launch.sh build\"\n    echo \"打包静态资源并上传到服务器 & 解压执行:  ./launch.sh pro\"\n    echo \"服务器上资源启用: ./launch.sh start\"\nfi\n"
  },
  {
    "path": "lint-staged.config.js",
    "content": "module.exports = {\n\t\"*.{js,jsx,ts,tsx}\": [\"eslint --fix\", \"prettier --write\"],\n\t\"{!(package)*.json,*.code-snippets,.!(browserslist)*rc}\": [\"prettier --write--parser json\"],\n\t\"package.json\": [\"prettier --write\"],\n\t\"*.{scss,less,styl}\": [\"stylelint --fix\", \"prettier --write\"],\n\t\"*.md\": [\"prettier --write\"]\n};\n"
  },
  {
    "path": "package.json",
    "content": "{\n\t\"name\": \"react\",\n\t\"private\": true,\n\t\"version\": \"0.0.1\",\n\t\"scripts\": {\n\t\t\"dev\": \"vite\",\n\t\t\"serve\": \"vite\",\n\t\t\"build:dev\": \"tsc && vite build --mode development\",\n\t\t\"build:test\": \"tsc && vite build --mode test\",\n\t\t\"build:pro\": \"vite build --mode production\",\n\t\t\"preview\": \"vite preview\",\n\t\t\"lint:eslint\": \"eslint --fix --ext .js,.ts,.tsx ./src\",\n\t\t\"lint:prettier\": \"prettier --write --loglevel warn \\\"src/**/*.{js,ts,json,tsx,css,less,scss,html,md}\\\"\",\n\t\t\"lint:stylelint\": \"stylelint --cache --fix \\\"**/*.{less,postcss,css,scss}\\\" --cache --cache-location node_modules/.cache/stylelint/\",\n\t\t\"lint:lint-staged\": \"lint-staged\",\n\t\t\"prepare\": \"husky install\",\n\t\t\"release\": \"standard-version\",\n\t\t\"commit\": \"git pull && git add -A && git-cz && git push\"\n\t},\n\t\"dependencies\": {\n\t\t\"@ant-design/icons\": \"^4.7.0\",\n\t\t\"@bytemd/plugin-gemoji\": \"^1.21.0\",\n\t\t\"@bytemd/plugin-gfm\": \"^1.21.0\",\n\t\t\"@bytemd/plugin-highlight\": \"^1.21.0\",\n\t\t\"@bytemd/plugin-math\": \"^1.21.0\",\n\t\t\"@bytemd/plugin-medium-zoom\": \"^1.21.0\",\n\t\t\"@bytemd/react\": \"^1.21.0\",\n\t\t\"@dnd-kit/core\": \"^6.1.0\",\n\t\t\"@dnd-kit/modifiers\": \"^7.0.0\",\n\t\t\"@dnd-kit/sortable\": \"^8.0.0\",\n\t\t\"antd\": \"^5.6.2\",\n\t\t\"antd-img-crop\": \"^4.12.2\",\n\t\t\"axios\": \"^0.27.2\",\n\t\t\"dayjs\": \"^1.11.7\",\n\t\t\"driver.js\": \"^0.9.8\",\n\t\t\"echarts\": \"^5.3.0\",\n\t\t\"echarts-liquidfill\": \"^3.1.0\",\n\t\t\"github-markdown-css\": \"^5.4.0\",\n\t\t\"i18next\": \"^21.8.10\",\n\t\t\"immer\": \"^9.0.15\",\n\t\t\"js-md5\": \"^0.7.3\",\n\t\t\"juejin-markdown-themes\": \"^1.32.1\",\n\t\t\"loadash\": \"^1.0.0\",\n\t\t\"lodash\": \"^4.17.21\",\n\t\t\"mammoth\": \"^1.11.0\",\n\t\t\"nprogress\": \"^0.2.0\",\n\t\t\"qs\": \"^6.10.5\",\n\t\t\"react\": \"^18.2.0\",\n\t\t\"react-activation\": \"^0.11.2\",\n\t\t\"react-dom\": \"^18.2.0\",\n\t\t\"react-highlight-words\": \"^0.20.0\",\n\t\t\"react-i18next\": \"^11.17.3\",\n\t\t\"react-moveable\": \"^0.56.0\",\n\t\t\"react-redux\": \"^8.0.2\",\n\t\t\"react-router-dom\": \"^6.3.0\",\n\t\t\"react-transition-group\": \"^4.4.2\",\n\t\t\"redux\": \"^4.2.0\",\n\t\t\"redux-persist\": \"^6.0.0\",\n\t\t\"redux-promise\": \"^0.6.0\",\n\t\t\"redux-thunk\": \"^2.4.2\",\n\t\t\"screenfull\": \"^6.0.2\",\n\t\t\"stylelint\": \"^14.16.1\",\n\t\t\"stylelint-config-prettier\": \"^9.0.5\",\n\t\t\"stylelint-config-recommended-scss\": \"^5.0.2\"\n\t},\n\t\"devDependencies\": {\n\t\t\"@commitlint/cli\": \"^17.0.2\",\n\t\t\"@commitlint/config-conventional\": \"^17.0.2\",\n\t\t\"@types/lodash\": \"^4.14.194\",\n\t\t\"@types/node\": \"^17.0.41\",\n\t\t\"@types/react\": \"^18.0.0\",\n\t\t\"@types/react-dom\": \"^18.0.0\",\n\t\t\"@types/react-highlight-words\": \"^0.16.4\",\n\t\t\"@types/react-router-dom\": \"^5.3.3\",\n\t\t\"@types/redux-promise\": \"^0.5.29\",\n\t\t\"@typescript-eslint/eslint-plugin\": \"^5.27.1\",\n\t\t\"@typescript-eslint/parser\": \"^5.27.1\",\n\t\t\"@vitejs/plugin-react\": \"^1.3.0\",\n\t\t\"autoprefixer\": \"^10.4.7\",\n\t\t\"commitizen\": \"^4.2.4\",\n\t\t\"cz-git\": \"^1.3.4\",\n\t\t\"eslint\": \"^8.17.0\",\n\t\t\"eslint-config-prettier\": \"^8.5.0\",\n\t\t\"eslint-plugin-prettier\": \"^4.0.0\",\n\t\t\"eslint-plugin-react\": \"^7.30.0\",\n\t\t\"eslint-plugin-react-hooks\": \"^4.5.0\",\n\t\t\"eslint-plugin-simple-import-sort\": \"^8.0.0\",\n\t\t\"husky\": \"^8.0.1\",\n\t\t\"less\": \"^4.1.3\",\n\t\t\"lint-staged\": \"^13.0.2\",\n\t\t\"postcss\": \"^8.4.14\",\n\t\t\"prettier\": \"^2.6.2\",\n\t\t\"rollup-plugin-visualizer\": \"^5.6.0\",\n\t\t\"sass\": \"^1.55.0\",\n\t\t\"standard-version\": \"^9.5.0\",\n\t\t\"stylelint-config-recess-order\": \"^3.0.0\",\n\t\t\"stylelint-config-standard\": \"^26.0.0\",\n\t\t\"stylelint-less\": \"^1.0.6\",\n\t\t\"typescript\": \"^4.6.3\",\n\t\t\"vite\": \"^4.3.9\",\n\t\t\"vite-plugin-compression\": \"^0.5.1\",\n\t\t\"vite-plugin-eslint\": \"^1.6.1\",\n\t\t\"vite-plugin-html\": \"^3.2.0\",\n\t\t\"vite-plugin-style-import\": \"^2.0.0\",\n\t\t\"vite-plugin-svg-icons\": \"^2.0.1\"\n\t},\n\t\"config\": {\n\t\t\"commitizen\": {\n\t\t\t\"path\": \"node_modules/cz-git\"\n\t\t}\n\t}\n}\n"
  },
  {
    "path": "postcss.config.js",
    "content": "module.exports = {\n\tplugins: {\n\t\tautoprefixer: {}\n\t}\n};\n"
  },
  {
    "path": "src/App.tsx",
    "content": "import { connect } from \"react-redux\";\nimport { HashRouter } from \"react-router-dom\";\nimport { ConfigProvider } from \"antd\";\nimport zhCN from \"antd/lib/locale/zh_CN\";\n\nimport useTheme from \"@/hooks/useTheme\";\nimport Router from \"@/routers/index\";\nimport AuthRouter from \"@/routers/utils/authRouter\";\n\nimport \"./index.scss\";\n\nconst App = (props: any) => {\n\tconst { assemblySize, themeConfig } = props;\n\n\t// 全局使用主题\n\tuseTheme(themeConfig);\n\n\treturn (\n\t\t<HashRouter>\n\t\t\t<ConfigProvider componentSize={assemblySize} locale={zhCN}>\n\t\t\t\t<AuthRouter>\n\t\t\t\t\t<Router />\n\t\t\t\t</AuthRouter>\n\t\t\t</ConfigProvider>\n\t\t</HashRouter>\n\t);\n};\n\nconst mapStateToProps = (state: any) => state.global;\nconst mapDispatchToProps = {};\nexport default connect(mapStateToProps, mapDispatchToProps)(App);\n"
  },
  {
    "path": "src/api/config/servicePort.ts",
    "content": "// 后端微服务端口名\nexport const PORT1 = \"\";\n"
  },
  {
    "path": "src/api/helper/axiosCancel.ts",
    "content": "import axios, { AxiosRequestConfig, Canceler } from \"axios\";\nimport qs from \"qs\";\n\nimport { isFunction } from \"@/utils/is/index\";\n\n// * 声明一个 Map 用于存储每个请求的标识 和 取消函数\nlet pendingMap = new Map<string, Canceler>();\n\n// * 序列化参数\nexport const getPendingUrl = (config: AxiosRequestConfig) =>\n\t[config.method, config.url, qs.stringify(config.data), qs.stringify(config.params)].join(\"&\");\n\nexport class AxiosCanceler {\n\t/**\n\t * @description: 添加请求\n\t * @param {Object} config\n\t */\n\taddPending(config: AxiosRequestConfig) {\n\t\t// * 在请求开始前，对之前的请求做检查取消操作\n\t\tthis.removePending(config);\n\t\tconst url = getPendingUrl(config);\n\t\tconfig.cancelToken =\n\t\t\tconfig.cancelToken ||\n\t\t\tnew axios.CancelToken(cancel => {\n\t\t\t\tif (!pendingMap.has(url)) {\n\t\t\t\t\t// 如果 pending 中不存在当前请求，则添加进去\n\t\t\t\t\tpendingMap.set(url, cancel);\n\t\t\t\t}\n\t\t\t});\n\t}\n\n\t/**\n\t * @description: 移除请求\n\t * @param {Object} config\n\t */\n\tremovePending(config: AxiosRequestConfig) {\n\t\tconst url = getPendingUrl(config);\n\n\t\tif (pendingMap.has(url)) {\n\t\t\t// 如果在 pending 中存在当前请求标识，需要取消当前请求，并且移除\n\t\t\tconst cancel = pendingMap.get(url);\n\t\t\tcancel && cancel();\n\t\t\tpendingMap.delete(url);\n\t\t}\n\t}\n\n\t/**\n\t * @description: 清空所有pending\n\t */\n\tremoveAllPending() {\n\t\tpendingMap.forEach(cancel => {\n\t\t\tcancel && isFunction(cancel) && cancel();\n\t\t});\n\t\tpendingMap.clear();\n\t}\n\n\t/**\n\t * @description: 重置\n\t */\n\treset(): void {\n\t\tpendingMap = new Map<string, Canceler>();\n\t}\n}\n"
  },
  {
    "path": "src/api/helper/checkStatus.ts",
    "content": "import { message } from \"antd\";\n\n/**\n * @description: 校验网络请求状态码\n * @param {Number} status\n * @return void\n */\nexport const checkStatus = (status: number): void => {\n\tswitch (status) {\n\t\tcase 400:\n\t\t\tmessage.error(\"请求失败！请您稍后重试\");\n\t\t\tbreak;\n\t\tcase 401:\n\t\t\tmessage.error(\"登录失效！请您重新登录\");\n\t\t\tbreak;\n\t\tcase 403:\n\t\t\tmessage.error(\"当前账号无权限访问！\");\n\t\t\tbreak;\n\t\tcase 404:\n\t\t\tmessage.error(\"你所访问的资源不存在！\");\n\t\t\tbreak;\n\t\tcase 405:\n\t\t\tmessage.error(\"请求方式错误！请您稍后重试\");\n\t\t\tbreak;\n\t\tcase 408:\n\t\t\tmessage.error(\"请求超时！请您稍后重试\");\n\t\t\tbreak;\n\t\tcase 500:\n\t\t\tmessage.error(\"服务异常！\");\n\t\t\tbreak;\n\t\tcase 502:\n\t\t\tmessage.error(\"网关错误！\");\n\t\t\tbreak;\n\t\tcase 503:\n\t\t\tmessage.error(\"服务不可用！\");\n\t\t\tbreak;\n\t\tcase 504:\n\t\t\tmessage.error(\"网关超时！\");\n\t\t\tbreak;\n\t\tdefault:\n\t\t\tmessage.error(\"请求失败！\");\n\t}\n};\n"
  },
  {
    "path": "src/api/index.ts",
    "content": "import { message } from \"antd\";\nimport axios, { AxiosError, AxiosInstance, AxiosRequestConfig, AxiosResponse } from \"axios\";\n\nimport { PaiRes, ResultData } from \"@/api/interface\";\nimport { LOGIN_URL } from \"@/config/config\";\nimport NProgress from \"@/config/nprogress\";\nimport { showFullScreenLoading, tryHideFullScreenLoading } from \"@/config/serviceLoading\";\nimport { ResultEnum } from \"@/enums/httpEnum\";\nimport { store } from \"@/redux\";\nimport { setToken } from \"@/redux/modules/global/action\";\nimport { AxiosCanceler } from \"./helper/axiosCancel\";\nimport { checkStatus } from \"./helper/checkStatus\";\n\nconst axiosCanceler = new AxiosCanceler();\n\nconst config = {\n\t// 默认地址请求地址，可在 .env 开头文件中修改，在 Axios 中使用\n\t// 实例化的使用用到\n\tbaseURL: import.meta.env.VITE_API_URL as string,\n\t// 跨域时候允许携带凭证\n\twithCredentials: true\n};\n\nclass RequestHttp {\n\tservice: AxiosInstance;\n\n\t// 构造方法\n\tpublic constructor(config: AxiosRequestConfig) {\n\t\t// 实例化axios\n\t\tthis.service = axios.create(config);\n\n\t\t/**\n\t\t * @description 请求拦截器\n\t\t * 客户端发送请求 -> [请求拦截器] -> 服务器\n\t\t * token校验(JWT) : 接受服务器返回的token,存储到redux/本地储存当中\n\t\t */\n\t\tthis.service.interceptors.request.use(\n\t\t\t(config: AxiosRequestConfig) => {\n\t\t\t\tconsole.log(\"发起请求\");\n\t\t\t\t// 进度条开始\n\t\t\t\tNProgress.start();\n\t\t\t\t// 将当前请求添加到 pending 中\n\t\t\t\taxiosCanceler.addPending(config);\n\t\t\t\t// 如果当前请求不需要显示 loading\n\t\t\t\t// 在api服务中通过指定的第三个参数: { headers: { noLoading: true } }来控制不显示loading，参见loginApi\n\t\t\t\tconfig.headers!.noLoading || showFullScreenLoading();\n\t\t\t\t// 从 Redux store 中获取 token 并将其添加到请求的 headers 中。这通常用于身份验证，确保后端可以验证用户的身份。\n\t\t\t\tconst token: string = store.getState().global.token;\n\t\t\t\tconsole.log(\"token\", token);\n\n\t\t\t\treturn { ...config, headers: { ...config.headers, \"x-access-token\": token } };\n\t\t\t},\n\t\t\t(error: AxiosError) => {\n\t\t\t\tconsole.log(\"error\", error);\n\t\t\t\treturn Promise.reject(error);\n\t\t\t}\n\t\t);\n\n\t\t/**\n\t\t * @description 响应拦截器\n\t\t *  服务器换返回信息 -> [拦截统一处理] -> 客户端JS获取到信息\n\t\t */\n\t\tthis.service.interceptors.response.use(\n\t\t\t(response: AxiosResponse) => {\n\t\t\t\tconsole.log(\"response\", response);\n\n\t\t\t\tconst { data, config } = response;\n\t\t\t\t// 进度条结束\n\t\t\t\tNProgress.done();\n\t\t\t\t// 在请求结束后，移除本次请求(关闭loading)\n\t\t\t\taxiosCanceler.removePending(config);\n\t\t\t\ttryHideFullScreenLoading();\n\t\t\t\t// 服务器返回的状态码\n\t\t\t\t// 如果响应是文件流（通过 responseType 判断）\n\t\t\t\tif (config.responseType === \"blob\") {\n\t\t\t\t\t// 跳过 dataStatus.code 检查\n\t\t\t\t\t// TODO（防止下载文件的时候返回数据流，没有code，直接报错）\n\t\t\t\t\treturn response;\n\t\t\t\t}\n\t\t\t\tconst dataStatus = data.status;\n\t\t\t\t// 登录失效（code == 599）\n\t\t\t\tif (dataStatus && dataStatus.code == ResultEnum.NOT_LOGIN) {\n\t\t\t\t\t// 重定向到登录页面\n\t\t\t\t\tstore.dispatch(setToken(\"\"));\n\t\t\t\t\tmessage.error(dataStatus.msg);\n\t\t\t\t\twindow.location.hash = LOGIN_URL;\n\t\t\t\t\treturn Promise.reject(data);\n\t\t\t\t}\n\t\t\t\t// 全局错误信息拦截\n\t\t\t\tif (dataStatus.code && dataStatus.code !== ResultEnum.SUCCESS) {\n\t\t\t\t\tmessage.error(dataStatus.msg);\n\t\t\t\t\treturn Promise.reject(data);\n\t\t\t\t}\n\t\t\t\t// 成功请求（在页面上除非特殊情况，否则不用处理失败逻辑）\n\t\t\t\treturn data;\n\t\t\t},\n\t\t\tasync (error: AxiosError) => {\n\t\t\t\tconsole.log(\"error\", error);\n\t\t\t\tconst { response } = error;\n\t\t\t\tNProgress.done();\n\t\t\t\ttryHideFullScreenLoading();\n\t\t\t\t// 请求超时单独判断，请求超时没有 response\n\t\t\t\tif (error.message.indexOf(\"timeout\") !== -1) message.error(\"请求超时，请稍后再试\");\n\t\t\t\t// 根据响应的错误状态码，做不同的处理\n\t\t\t\tif (response) checkStatus(response.status);\n\t\t\t\t// 服务器结果都没有返回(可能服务器错误可能客户端断网) 断网处理:可以跳转到断网页面\n\t\t\t\tif (!window.navigator.onLine) window.location.hash = \"/500\";\n\t\t\t\treturn Promise.reject(error);\n\t\t\t}\n\t\t);\n\t}\n\n\t// * 常用请求方法封装\n\tdown<T>(url: string, config: AxiosRequestConfig = {}): Promise<AxiosResponse<T>> {\n\t\tconsole.log(\"开始执行 get 请求，下载文件\", url, config);\n\t\treturn this.service.get(url, config);\n\t}\n\tget<T>(url: string, params?: object, _object = {}): Promise<ResultData<T>> {\n\t\treturn this.service.get(url, { params, ..._object });\n\t}\n\tpost<T>(url: string, params?: object, _object = {}): Promise<ResultData<T>> {\n\t\treturn this.service.post(url, params, _object);\n\t}\n\n\tpostForm<T>(url: string, params?: object, _object = {}): Promise<PaiRes<T>> {\n\t\treturn this.service.post(url, params, _object);\n\t}\n\n\tput<T>(url: string, params?: object, _object = {}): Promise<ResultData<T>> {\n\t\treturn this.service.put(url, params, _object);\n\t}\n\tdelete<T>(url: string, params?: any, _object = {}): Promise<ResultData<T>> {\n\t\treturn this.service.delete(url, { params, ..._object });\n\t}\n}\n\nexport default new RequestHttp(config);\n"
  },
  {
    "path": "src/api/interface/index.ts",
    "content": "// * 请求响应参数(不包含data)\nexport interface Result {\n\tcode: string;\n\tmsg: string;\n}\n\n// * 请求响应参数(包含data)\nexport interface ResultData<T = any> extends Result {\n\tdata?: T;\n\tstatus?: Status;\n\tresult?: T;\n}\n\nexport interface Status {\n\tcode: number;\n\tmsg: string;\n}\nexport interface PaiRes<T = any> {\n\tstatus: Status;\n\tresult?: T;\n}\n\n// * 分页响应参数\nexport interface ResPage<T> {\n\tdatalist: T[];\n\tpageNum: number;\n\tpageSize: number;\n\ttotal: number;\n}\n\n// * 分页请求参数\nexport interface ReqPage {\n\tpageNum: number;\n\tpageSize: number;\n}\n\n// * 登录\nexport namespace Login {\n\texport interface ReqLoginForm {\n\t\tusername: string;\n\t\tpassword: string;\n\t}\n\texport interface ResLogin {\n\t\taccess_token: string;\n\t\tuserId: number;\n\t\t// 登录用户名\n\t\tuserName: string;\n\t\t// 用户头像\n\t\tphoto: string;\n\t}\n\texport interface ResAuthButtons {\n\t\t[propName: string]: any;\n\t}\n}\n"
  },
  {
    "path": "src/api/modules/aiConfig.ts",
    "content": "import http from \"@/api\";\nimport { PORT1 } from \"@/api/config/servicePort\";\nimport { Login } from \"@/api/interface/index\";\n\nexport type AISourceValue =\n\t| \"CHAT_GPT_3_5\"\n\t| \"CHAT_GPT_4\"\n\t| \"PAI_AI\"\n\t| \"XUN_FEI_AI\"\n\t| \"ZHI_PU_AI\"\n\t| \"ZHIPU_CODING\"\n\t| \"ALI_AI\"\n\t| \"DEEP_SEEK\"\n\t| \"DOU_BAO_AI\";\n\nexport interface GptModelConfig {\n\tkeys?: string[];\n\tproxy?: boolean;\n\tapiHost?: string;\n\ttimeOut?: number;\n\tmaxToken?: number;\n}\n\nexport interface ChatGptConfig {\n\tmain?: AISourceValue;\n\tgpt35?: GptModelConfig;\n\tgpt4?: GptModelConfig;\n}\n\nexport interface ZhipuConfig {\n\tapiSecretKey?: string;\n\trequestIdTemplate?: string;\n\tmodel?: string;\n}\n\nexport interface XunFeiConfig {\n\thostUrl?: string;\n\tdomain?: string;\n\tappId?: string;\n\tapiKey?: string;\n\tapiSecret?: string;\n\tapiPassword?: string;\n}\n\nexport interface ZhipuCodingConfig {\n\tapiKey?: string;\n\tapiHost?: string;\n\tmodel?: string;\n\ttimeout?: number;\n}\n\nexport interface DeepSeekConfig {\n\tapiKey?: string;\n\tapiHost?: string;\n\tmodel?: string;\n\ttimeout?: number;\n}\n\nexport interface DoubaoConfig {\n\tapiKey?: string;\n\tapiHost?: string;\n\tendPoint?: string;\n}\n\nexport interface AliConfig {\n\tmodel?: string;\n}\n\nexport interface AiConfigAdminDTO {\n\tsources?: AISourceValue[];\n\tchatGpt?: ChatGptConfig;\n\tzhipu?: ZhipuConfig;\n\tzhipuCoding?: ZhipuCodingConfig;\n\txunFei?: XunFeiConfig;\n\tdeepSeek?: DeepSeekConfig;\n\tdoubao?: DoubaoConfig;\n\tali?: AliConfig;\n}\n\nexport type AiConfigAdminReq = AiConfigAdminDTO;\n\nexport interface AiConfigTestReq {\n\tsource: AISourceValue;\n\tprompt?: string;\n}\n\nexport interface AiConfigTestRes {\n\tsource?: AISourceValue;\n\tsuccess?: boolean;\n\tmessage?: string;\n\tanswer?: string;\n\tcostMs?: number;\n}\n\nexport const getAiConfigDetailApi = () => {\n\treturn http.get<AiConfigAdminDTO>(`${PORT1}/ai/config/detail`);\n};\n\nexport const saveAiConfigApi = (params: AiConfigAdminReq) => {\n\treturn http.post<Login.ResAuthButtons>(`${PORT1}/ai/config/save`, params);\n};\n\nexport const testAiConfigApi = (params: AiConfigTestReq) => {\n\treturn http.post<AiConfigTestRes>(`${PORT1}/ai/config/test`, params);\n};\n"
  },
  {
    "path": "src/api/modules/article.ts",
    "content": "import http from \"@/api\";\nimport { PORT1 } from \"@/api/config/servicePort\";\nimport { Login } from \"@/api/interface/index\";\n\n/**\n * @name 文章模块\n */\n\n// 获取列表\nexport const getArticleListApi = (data: { pageNumber: number; pageSize: number }) => {\n\treturn http.post(`${PORT1}/article/list`, data);\n};\n\n// 更新标题操作\nexport const updateArticleApi = (params: object | undefined) => {\n\treturn http.post<Login.ResAuthButtons>(`${PORT1}/article/update`, params);\n};\n\n// 保存操作\nexport const saveArticleApi = (params: object | undefined) => {\n\treturn http.post<Login.ResAuthButtons>(`${PORT1}/article/save`, params);\n};\n\n// 转链上传图片的操作\nexport const saveImgApi = (params: string) => {\n\treturn http.get<Login.ResAuthButtons>(`${PORT1}/image/save?img=` + params);\n};\n\n// 删除操作\nexport const delArticleApi = (articleId: number) => {\n\treturn http.get<Login.ResAuthButtons>(`${PORT1}/article/delete`, { articleId });\n};\n\n// 获取文章\nexport const getArticleApi = (articleId: number) => {\n\treturn http.get<Login.ResAuthButtons>(`${PORT1}/article/detail`, { articleId });\n};\n\n// 置顶/加精操作\nexport const operateArticleApi = (params: object) => {\n\treturn http.get<Login.ResAuthButtons>(`${PORT1}/article/operate`, params);\n};\n\n// 上线/下线操作\nexport const examineArticleApi = (params: object | undefined) => {\n\treturn http.get<Login.ResAuthButtons>(`${PORT1}/article/examine`, params);\n};\n\n// AI 生成标题和简介\nexport const generateArticleAiApi = (params: { shortTitle: string; content: string }) => {\n\treturn http.post<Login.ResAuthButtons>(`${PORT1}/article/generate/seo`, params);\n};\n\n// 生成语义 URL\nexport const generateArticleSlugApi = (params: { title?: string; shortTitle?: string }) => {\n\treturn http.post<Login.ResAuthButtons>(`${PORT1}/article/generate/slug`, params, {\n\t\theaders: { noLoading: true },\n\t\ttimeout: 15000\n\t});\n};\n"
  },
  {
    "path": "src/api/modules/author.ts",
    "content": "import http from \"@/api\";\nimport { PORT1 } from \"@/api/config/servicePort\";\nimport { Login } from \"@/api/interface/index\";\n\n// 添加返回类型\nexport const getAuthorWhiteListApi = () => {\n\treturn http.get(`${PORT1}/author/whitelist/get`);\n};\n\n// 获取知识星球用户白名单\nexport const getZsxqWhiteListApi = (data: { pageNumber: number; pageSize: number }) => {\n\treturn http.post(`${PORT1}/zsxq/whitelist`, data);\n};\n\n// 更新知识星球用户白名单\nexport const updateZsxqWhiteApi = (params: object | undefined) => {\n\treturn http.post<Login.ResAuthButtons>(`${PORT1}/zsxq/whitelist/save`, params);\n};\n\n// 审核知识星球用户白名单\nexport const operateZsxqWhiteApi = (params: object | undefined) => {\n\treturn http.get<Login.ResAuthButtons>(`${PORT1}/zsxq/whitelist/operate`, params);\n};\n\n// 审核知识星球用户白名单，批量\nexport const operateBatchZsxqWhiteApi = (params: object | undefined) => {\n\treturn http.post<Login.ResAuthButtons>(`${PORT1}/zsxq/whitelist/batchOperate`, params);\n};\n\n// 重置操作\nexport const resetAuthorWhiteApi = (authorId: number) => {\n\treturn http.get<Login.ResAuthButtons>(`${PORT1}/zsxq/whitelist/reset`, { authorId });\n};\n\n// 保存操作\nexport const updateAuthorWhiteApi = (authorId: number) => {\n\treturn http.get<Login.ResAuthButtons>(`${PORT1}/author/whitelist/add`, { authorId });\n};\n\n// 删除作者白名单\nexport const delAuthorWhiteApi = (authorId: number) => {\n\treturn http.get<Login.ResAuthButtons>(`${PORT1}/author/whitelist/remove`, { authorId });\n};\n"
  },
  {
    "path": "src/api/modules/category.ts",
    "content": "import http from \"@/api\";\nimport { PORT1 } from \"@/api/config/servicePort\";\nimport { Login } from \"@/api/interface/index\";\nimport { IFormType } from \"@/views/config\";\n\n/**\n * @name 分类模块\n */\n\n// 获取列表\nexport const getCategoryListApi = (data: { pageNumber: number; pageSize: number }) => {\n\treturn http.post(`${PORT1}/category/list`, data);\n};\n\n// 删除操作\nexport const delCategoryApi = (categoryId: number) => {\n\treturn http.get<Login.ResAuthButtons>(`${PORT1}/category/delete`, { categoryId });\n};\n\n// 保存操作\nexport const updateCategoryApi = (form: IFormType) => {\n\treturn http.post<Login.ResAuthButtons>(`${PORT1}/category/save`, form);\n};\n\n// 上线/下线操作\nexport const operateCategoryApi = (params: object | undefined) => {\n\treturn http.get<Login.ResAuthButtons>(`${PORT1}/category/operate`, params);\n};\n"
  },
  {
    "path": "src/api/modules/column.ts",
    "content": "import http from \"@/api\";\nimport { PORT1 } from \"@/api/config/servicePort\";\nimport { Login } from \"@/api/interface/index\";\nimport { IFormType } from \"@/views/column/setting\";\nimport { IArticleSortFormType, IGroupFormType } from \"@/views/column/setting/articlesort\";\nimport { IMoveType } from \"@/views/column/setting/groups\";\n\n/**\n * @name 教程模块\n */\n\n// 获取列表\nexport const getColumnListApi = (data: { pageNumber: number; pageSize: number }) => {\n\treturn http.post(`${PORT1}/column/list`, data);\n};\n\n// 添加返回类型\nexport const getColumnByNameListApi = (key: string) => {\n\treturn http.get(`${PORT1}/column/query`, { key });\n};\n\n// 获取作者列表，参数为作者名称\nexport const getAuthorListApi = (key: string) => {\n\treturn http.get(`${PORT1}/user/query`, { key });\n};\n\n// 保存操作\nexport const updateColumnApi = (form: IFormType) => {\n\treturn http.post<Login.ResAuthButtons>(`${PORT1}/column/saveColumn`, form);\n};\n\n// 删除专栏操作\nexport const delColumnApi = (columnId: number) => {\n\treturn http.get<Login.ResAuthButtons>(`${PORT1}/column/deleteColumn`, { columnId });\n};\n\n// 获取列表\nexport const getColumnArticleListApi = (data: { columnId: number; pageNumber: number; pageSize: number }) => {\n\treturn http.post(`${PORT1}/column/listColumnArticle`, data);\n};\n\n// 根据专栏文章分组的方式，获取文章列表\nexport const getColumnGroupArticlesApi = (columnId: number) => {\n\treturn http.get(`${PORT1}/column/listColumnByGroup`, { columnId });\n};\n\n// 获取专栏设置的分组列表\nexport const getColumnGroupListApi = (columnId: number) => {\n\treturn http.get(`${PORT1}/column/listGroups`, { columnId });\n};\n\n// 保存专栏文章分组\nexport const updateGroupApi = (form: IGroupFormType) => {\n\treturn http.post<Login.ResAuthButtons>(`${PORT1}/column/saveColumnGroup`, form);\n};\n\n// 删除专栏文章分组\nexport const deleteGroupApi = (groupId: number) => {\n\treturn http.get<Login.ResAuthButtons>(`${PORT1}/column/deleteColumnGroup`, { groupId });\n};\n\n// 保存操作\nexport const updateColumnArticleApi = (form: IFormType) => {\n\treturn http.post<Login.ResAuthButtons>(`${PORT1}/column/saveColumnArticle`, form);\n};\n\n// 删除教程操作\nexport const delColumnArticleApi = (id: number) => {\n\treturn http.get<Login.ResAuthButtons>(`${PORT1}/column/deleteColumnArticle`, { id });\n};\n\n// 调整两个教程的顺序\nexport const sortColumnArticleApi = (activeId: number, overId: number) => {\n\treturn http.post<Login.ResAuthButtons>(`${PORT1}/column/sortColumnArticleApi`, { activeId, overId });\n};\n\n// 调整教程的顺序\nexport const sortColumnArticleByIDApi = (form: IArticleSortFormType) => {\n\treturn http.post<Login.ResAuthButtons>(`${PORT1}/column/sortColumnArticleByIDApi`, form);\n};\n\n// 拖拽移动教程或者分组\nexport const moveColumnArticleOrGroup = (form: IMoveType) => {\n\treturn http.post<Login.ResAuthButtons>(`${PORT1}/column/moveColumnArticleOrGroup`, form);\n};\n"
  },
  {
    "path": "src/api/modules/comment.ts",
    "content": "import http from \"@/api\";\nimport { PORT1 } from \"@/api/config/servicePort\";\nimport { Login } from \"@/api/interface\";\n\nexport interface SearchCommentReq {\n\tcommentId?: number;\n\tarticleId?: number;\n\tarticleTitle?: string;\n\tuserId?: number;\n\tuserName?: string;\n\tcontent?: string;\n\tcommentType?: number;\n\tpageNumber: number;\n\tpageSize: number;\n}\n\nexport interface CommentSaveReq {\n\tcommentId?: number;\n\tarticleId: number;\n\tparentCommentId?: number;\n\ttopCommentId?: number;\n\tcommentContent: string;\n}\n\nexport interface CommentAdminDTO {\n\tcommentId: number;\n\tarticleId: number;\n\tarticleTitle?: string;\n\tuserId: number;\n\tuserName?: string;\n\tuserAvatar?: string;\n\tcommentContent: string;\n\tparentCommentId: number;\n\ttopCommentId: number;\n\tparentCommentContent?: string;\n\ttopCommentContent?: string;\n\tcommentType: number;\n\treplyCount?: number;\n\tpraiseCount?: number;\n\thighlightInfo?: string;\n\tcreateTime?: string;\n\tupdateTime?: string;\n}\n\nexport interface CommentPageDTO {\n\tlist: CommentAdminDTO[];\n\tpageNum: number;\n\tpageSize: number;\n\ttotal: number;\n\tpageTotal: number;\n}\n\nexport const getCommentListApi = (params: SearchCommentReq) => {\n\treturn http.post<CommentPageDTO>(`${PORT1}/comment/list`, params);\n};\n\nexport const getCommentDetailApi = (commentId: number) => {\n\treturn http.get<CommentAdminDTO>(`${PORT1}/comment/detail`, { commentId });\n};\n\nexport const saveCommentApi = (params: CommentSaveReq) => {\n\treturn http.post<Login.ResAuthButtons>(`${PORT1}/comment/save`, params);\n};\n\nexport const deleteCommentApi = (commentId: number) => {\n\treturn http.get<Login.ResAuthButtons>(`${PORT1}/comment/delete`, { commentId });\n};\n"
  },
  {
    "path": "src/api/modules/common.ts",
    "content": "import http from \"@/api\";\nimport { PORT1 } from \"@/api/config/servicePort\";\nimport { Login } from \"@/api/interface/index\";\nimport { baseDomain } from \"@/utils/util\";\n\n/**\n * @name 分类模块\n */\n\n// 获取字典值\nexport const getDiscListApi = () => {\n\tconsole.log(\"获取字典，getDiscListApi\");\n\treturn http.get(`${PORT1}/common/dict`);\n};\n\n// 上传图片\nexport const uploadImgApi = (data: FormData) => {\n\t// 添加时间戳参数，确保每个请求 URL 不同，避免被 AxiosCanceler 取消\n\treturn http.post<Login.ResAuthButtons>(`${PORT1}/image/upload?t=${Date.now()}`, data);\n};\n\n// 文件上传\nexport const uploadFileUrl = () => {\n\treturn `${baseDomain}/oss/upload`;\n};\n"
  },
  {
    "path": "src/api/modules/config.ts",
    "content": "import http from \"@/api\";\nimport { PORT1 } from \"@/api/config/servicePort\";\nimport { Login } from \"@/api/interface/index\";\nimport { IFormType } from \"@/views/config\";\n\n/**\n * @name 分类模块\n */\n\n// 获取列表\nexport const getConfigListApi = (data: { pageNumber: number; pageSize: number }) => {\n\treturn http.post(`${PORT1}/config/list`, data);\n};\n\n// 删除操作\nexport const delConfigApi = (configId: number) => {\n\treturn http.get<Login.ResAuthButtons>(`${PORT1}/config/delete`, { configId });\n};\n\n// 保存操作\nexport const updateConfigApi = (form: IFormType) => {\n\treturn http.post<Login.ResAuthButtons>(`${PORT1}/config/save`, form);\n};\n\n// 上线/下线操作\nexport const operateConfigApi = (params: object | undefined) => {\n\treturn http.get<Login.ResAuthButtons>(`${PORT1}/config/operate`, params);\n};\n\n// 刷新配置缓存\nexport const refreshConfigApi = () => {\n\treturn http.get<Login.ResAuthButtons>(`${PORT1}/config/refresh`);\n};\n"
  },
  {
    "path": "src/api/modules/global.ts",
    "content": "import http from \"@/api\";\nimport { PORT1 } from \"@/api/config/servicePort\";\nimport { Login } from \"@/api/interface/index\";\nimport { IFormType } from \"@/views/global\";\n\n/**\n * @name 标签模块\n */\n\n// 获取列表\nexport const getGlobalConfigListApi = (data: { pageNumber: number; pageSize: number }) => {\n\treturn http.post(`${PORT1}/global/config/list`, data);\n};\n\n// 删除操作\nexport const delGlobalConfigApi = (id: number) => {\n\treturn http.get<Login.ResAuthButtons>(`${PORT1}/global/config/delete`, { id });\n};\n\n// 保存操作\nexport const updateGlobalConfigApi = (form: IFormType) => {\n\treturn http.post<Login.ResAuthButtons>(`${PORT1}/global/config/save`, form);\n};\n"
  },
  {
    "path": "src/api/modules/login.ts",
    "content": "import qs from \"qs\";\n\nimport http from \"@/api\";\nimport { PORT1 } from \"@/api/config/servicePort\";\nimport { Login } from \"@/api/interface/index\";\n\n/**\n * @name 登录模块\n */\n// * 用户登录接口\nexport const loginApi = (params: Login.ReqLoginForm) => {\n\treturn http.postForm<Login.ResLogin>(PORT1 + `/login`, qs.stringify(params)); // post 请求携带 表单 参数  ==>  application/x-www-form-urlencoded\n};\n\n// * 退出登录接口\nexport const logoutApi = () => {\n\treturn http.get(PORT1 + `/logout`);\n};\n\n/**\n * 查询当前登录的用户信息\n */\nexport const loginUserInfo = () => {\n\treturn http.get<Login.ResLogin>(PORT1 + `/info`);\n};\n\n// * 获取按钮权限\nexport const getAuthorButtons = () => {\n\treturn http.get<Login.ResAuthButtons>(PORT1 + `/auth/buttons`);\n};\n\n// * 获取菜单列表\nexport const getMenuList = () => {\n\treturn http.get<Menu.MenuOptions[]>(PORT1 + `/menu/list`);\n};\n"
  },
  {
    "path": "src/api/modules/resume.ts",
    "content": "import http from \"@/api\";\nimport { PORT1 } from \"@/api/config/servicePort\";\nimport { Login } from \"@/api/interface/index\";\nimport { IFormType } from \"@/views/resume\";\n\n/**\n * @name 标签模块\n */\n\n// 获取列表\nexport const getResumeListApi = (data: { pageNumber: number; pageSize: number }) => {\n\treturn http.post(`${PORT1}/resume/list`, data);\n};\n\n// 删除操作\nexport const delResumeApi = (resumeId: number) => {\n\treturn http.get<Login.ResAuthButtons>(`${PORT1}/resume/delete?resumeId=${resumeId}`);\n};\n\n// 上传\nexport const replayResumeApi = (form: IFormType) => {\n\treturn http.post<Login.ResAuthButtons>(`${PORT1}/resume/replay`, form);\n};\n\n// 下载\nexport const downResumeApi = (resumeId: number) => {\n\treturn http.get<Login.ResAuthButtons>(`${PORT1}/resume/process?resumeId=${resumeId}`);\n};\n"
  },
  {
    "path": "src/api/modules/sensitive.ts",
    "content": "import http from \"@/api\";\nimport { PORT1 } from \"@/api/config/servicePort\";\nimport { Login } from \"@/api/interface/index\";\n\nexport interface SensitiveWordHitDTO {\n\tword: string;\n\thitCount: number;\n}\n\nexport interface SensitiveWordConfigDTO {\n\tenable: boolean;\n\tdenyWords: string[];\n\tallowWords: string[];\n\thitTotal?: number;\n\thitWords?: SensitiveWordHitDTO[];\n}\n\nexport interface SensitiveWordConfigReq {\n\tenable: boolean;\n\tdenyWords: string[];\n\tallowWords: string[];\n}\n\nexport interface SensitiveWordHitPageReq {\n\tpageNumber: number;\n\tpageSize: number;\n}\n\nexport interface SensitiveWordHitPageDTO {\n\tlist: SensitiveWordHitDTO[];\n\tpageNum: number;\n\tpageSize: number;\n\ttotal: number;\n\tpageTotal: number;\n}\n\nexport const getSensitiveWordDetailApi = () => {\n\treturn http.get<SensitiveWordConfigDTO>(`${PORT1}/sensitive/detail`);\n};\n\nexport const saveSensitiveWordConfigApi = (params: SensitiveWordConfigReq) => {\n\treturn http.post<Login.ResAuthButtons>(`${PORT1}/sensitive/save`, params);\n};\n\nexport const getSensitiveWordHitListApi = (params: SensitiveWordHitPageReq) => {\n\treturn http.post<SensitiveWordHitPageDTO>(`${PORT1}/sensitive/hit/list`, params);\n};\n\nexport const clearSensitiveWordHitApi = (word: string) => {\n\treturn http.post<Login.ResAuthButtons>(`${PORT1}/sensitive/hit/clear`, { word });\n};\n"
  },
  {
    "path": "src/api/modules/statistics.ts",
    "content": "import http from \"@/api\";\nimport { PORT1 } from \"@/api/config/servicePort\";\n\n/**\n * @name 数据统计模块\n */\n\nexport const getAllApi = () => {\n\treturn http.get(`${PORT1}/statistics/queryTotal`);\n};\n\nexport const getPvUvApi = (day: number) => {\n\treturn http.get(`${PORT1}/statistics/pvUvDayList?day=${day}`);\n};\n\nexport const download2ExcelPvUvApi = (day: number) => {\n\treturn http.down(`${PORT1}/statistics/pvUvDayDownload2Excel?day=${day}`, { responseType: \"blob\" });\n};\n"
  },
  {
    "path": "src/api/modules/tag.ts",
    "content": "import http from \"@/api\";\nimport { PORT1 } from \"@/api/config/servicePort\";\nimport { Login } from \"@/api/interface/index\";\nimport { IFormType } from \"@/views/tag\";\n\n/**\n * @name 标签模块\n */\n\n// 获取列表\nexport const getTagListApi = (data: any) => {\n\treturn http.post(`${PORT1}/tag/list`, data);\n};\n\n// 删除操作\nexport const delTagApi = (tagId: number) => {\n\treturn http.get<Login.ResAuthButtons>(`${PORT1}/tag/delete`, { tagId });\n};\n\n// 保存操作\nexport const updateTagApi = (form: IFormType) => {\n\treturn http.post<Login.ResAuthButtons>(`${PORT1}/tag/save`, form);\n};\n\n// 上线/下线操作\nexport const operateTagApi = (params: object | undefined) => {\n\treturn http.get<Login.ResAuthButtons>(`${PORT1}/tag/operate`, params);\n};\n"
  },
  {
    "path": "src/api/modules/user.ts",
    "content": "import http from \"@/api\";\nimport { PORT1 } from \"@/api/config/servicePort\";\n\nexport interface UserLoginAuditItem {\n\tid: number;\n\tuserId?: number;\n\tloginName?: string;\n\tstarNumber?: string;\n\tloginType?: number;\n\tloginTypeDesc?: string;\n\teventType?: string;\n\teventTypeDesc?: string;\n\tdeviceId?: string;\n\tdeviceName?: string;\n\tip?: string;\n\tregion?: string;\n\tsessionHash?: string;\n\triskTag?: string;\n\treason?: string;\n\tcreateTime?: string;\n}\n\nexport interface UserSessionItem {\n\tid: number;\n\tuserId?: number;\n\tloginName?: string;\n\tloginType?: number;\n\tloginTypeDesc?: string;\n\tsessionHash?: string;\n\tdeviceId?: string;\n\tdeviceName?: string;\n\tip?: string;\n\tregion?: string;\n\tsessionState?: string;\n\tsessionStateDesc?: string;\n\tofflineReason?: string;\n\tlatestSeenTime?: string;\n\texpireTime?: string;\n\tofflineTime?: string;\n\tcreateTime?: string;\n}\n\nexport interface UserShareRiskItem {\n\tuserId?: number;\n\tloginName?: string;\n\tstarNumber?: string;\n\tkickoutCount?: number;\n\tloginSuccessCount?: number;\n\tdeviceCount?: number;\n\tipCount?: number;\n\tlastKickoutTime?: string;\n\tlastActiveTime?: string;\n\triskLevel?: string;\n\triskReason?: string;\n\tforbidden?: boolean;\n\tforbidUntil?: string;\n\tforbidReason?: string;\n}\n\nexport interface LoginAuditQuery {\n\tuserId?: number;\n\tloginName?: string;\n\tstarNumber?: string;\n\tdeviceId?: string;\n\tip?: string;\n\teventType?: string;\n\tpageNumber: number;\n\tpageSize: number;\n}\n\nexport interface UserShareRiskQuery {\n\tloginName?: string;\n\trecentDays?: number;\n\tminKickoutCount?: number;\n\tminDeviceCount?: number;\n\tminIpCount?: number;\n\tpageNumber: number;\n\tpageSize: number;\n}\n\nexport interface UserSessionQuery {\n\tuserId?: number;\n\tloginName?: string;\n\tdeviceId?: string;\n\tip?: string;\n\tactiveOnly?: boolean;\n\tpageNumber: number;\n\tpageSize: number;\n}\n\nexport interface UserForbidReq {\n\tuserId: number;\n\tdays?: number;\n\treason?: string;\n}\n\nexport interface UserUnforbidReq {\n\tuserId: number;\n}\n\nexport interface PageResult<T> {\n\tlist: T[];\n\tpageNum: number;\n\tpageSize: number;\n\ttotal: number;\n\tpageTotal?: number;\n}\n\nexport const getLoginAuditListApi = (params: LoginAuditQuery) => {\n\treturn http.post<PageResult<UserLoginAuditItem>>(`${PORT1}/user/login-audit`, params);\n};\n\nexport const getUserSessionListApi = (params: UserSessionQuery) => {\n\treturn http.post<PageResult<UserSessionItem>>(`${PORT1}/user/session`, params);\n};\n\nexport const getUserShareRiskListApi = (params: UserShareRiskQuery) => {\n\treturn http.post<PageResult<UserShareRiskItem>>(`${PORT1}/user/share-risk`, params);\n};\n\nexport const forbidUserApi = (params: UserForbidReq) => {\n\treturn http.post<string>(`${PORT1}/user/forbid`, params);\n};\n\nexport const unforbidUserApi = (params: UserUnforbidReq) => {\n\treturn http.post<string>(`${PORT1}/user/unforbid`, params);\n};\n"
  },
  {
    "path": "src/api/modules/wxMenu.ts",
    "content": "import http from \"@/api\";\nimport { PORT1 } from \"@/api/config/servicePort\";\n\nexport interface WxMenuButton {\n\ttype?: string;\n\tname?: string;\n\tkey?: string;\n\turl?: string;\n\tappid?: string;\n\tpagepath?: string;\n\tmedia_id?: string;\n\tarticle_id?: string;\n\tsub_button?: WxMenuButton[];\n}\n\nexport interface WxMenuTree {\n\tbutton?: WxMenuButton[];\n}\n\nexport interface WxMenuReplyArticle {\n\ttitle?: string;\n\tdescription?: string;\n\tpicUrl?: string;\n\turl?: string;\n}\n\nexport interface WxMenuReply {\n\treplyType?: string;\n\tcontent?: string;\n\tarticles?: WxMenuReplyArticle[];\n}\n\nexport interface WxMenuKeywordReply {\n\tmatchType?: string;\n\tkeywords?: string[];\n\treplyType?: string;\n\treply?: WxMenuReply;\n\tenabled?: boolean;\n\tpriority?: number;\n\ttitle?: string;\n}\n\nexport interface WxMenuClickReply {\n\tkey?: string;\n\ttitle?: string;\n\treply?: WxMenuReply;\n}\n\nexport interface WxMenuAiProviderOption {\n\tcode?: number;\n\tvalue?: string;\n\tname?: string;\n\tsyncSupport?: boolean;\n}\n\nexport interface WxMenuConfig {\n\tmenuJson?: string;\n\tcomment?: string;\n\tsubscribeReply?: WxMenuReply;\n\tdefaultReply?: WxMenuReply;\n\tkeywordReplies?: WxMenuKeywordReply[];\n\tmessageFallbackStrategy?: string;\n\taiPrompt?: string;\n\taiProvider?: string;\n\taiEnable?: boolean;\n\taiProviderOptions?: WxMenuAiProviderOption[];\n\tclickReplies?: WxMenuClickReply[];\n}\n\nexport interface WxMenuDetail {\n\tdraftConfig?: WxMenuConfig;\n\tdraftJson?: string;\n\tdraftComment?: string;\n\tdraftMenu?: WxMenuTree;\n\tdraftValid?: boolean;\n\tdraftErrors?: string[];\n\tdraftWarnings?: string[];\n\tsubscribeReply?: WxMenuReply;\n\tdefaultReply?: WxMenuReply;\n\tkeywordReplies?: WxMenuKeywordReply[];\n\tmessageFallbackStrategy?: string;\n\taiPrompt?: string;\n\taiProvider?: string;\n\taiEnable?: boolean;\n\taiProviderOptions?: WxMenuAiProviderOption[];\n\tclickReplies?: WxMenuClickReply[];\n\tremoteJson?: string;\n\tremoteMenu?: WxMenuTree;\n\tconditionalMenuCount?: number;\n\tremoteError?: string;\n\tmenuJsonTemplate?: string;\n\tmenuJsonTips?: string[];\n\treplyTips?: string[];\n}\n\nexport interface WxMenuSaveReq {\n\tmenuJson: string;\n\tcomment?: string;\n\tsubscribeReply?: WxMenuReply;\n\tdefaultReply?: WxMenuReply;\n\tkeywordReplies?: WxMenuKeywordReply[];\n\tmessageFallbackStrategy?: string;\n\taiPrompt?: string;\n\taiProvider?: string;\n\taiEnable?: boolean;\n\tclickReplies?: WxMenuClickReply[];\n}\n\nexport interface WxMenuValidateReq {\n\tmenuJson?: string;\n\tsubscribeReply?: WxMenuReply;\n\tdefaultReply?: WxMenuReply;\n\tkeywordReplies?: WxMenuKeywordReply[];\n\tmessageFallbackStrategy?: string;\n\taiPrompt?: string;\n\taiProvider?: string;\n\taiEnable?: boolean;\n\tclickReplies?: WxMenuClickReply[];\n}\n\nexport interface WxMenuValidateRes {\n\tvalid?: boolean;\n\tnormalizedMenuJson?: string;\n\tmenuErrors?: string[];\n\treplyErrors?: string[];\n\terrors?: string[];\n\twarnings?: string[];\n}\n\nexport interface WxMenuPreviewMatchReq extends WxMenuSaveReq {\n\teventType?: string;\n\teventKey?: string;\n\tcontent?: string;\n}\n\nexport interface WxMenuPreviewMatchRes {\n\tmatched?: boolean;\n\tmatchedRuleTitle?: string;\n\tmatchedRuleType?: string;\n\tmatchedKeyword?: string;\n\treply?: WxMenuReply;\n\tfallbackStrategy?: string;\n\tusedFallback?: boolean;\n}\n\nexport interface WxMenuPreviewAiReq {\n\tcontent?: string;\n\taiPrompt?: string;\n\taiProvider?: string;\n\taiEnable?: boolean;\n}\n\nexport interface WxMenuPreviewAiRes {\n\tsuccess?: boolean;\n\treplyText?: string;\n\tprovider?: string;\n\terrorMsg?: string;\n}\n\nexport interface WxMenuPublishReq {\n\tmenuJson?: string;\n}\n\nexport interface WxMenuPublishRes {\n\tsuccess?: boolean;\n\terrCode?: number;\n\terrMsg?: string;\n\tpublishedMenuJson?: string;\n}\n\nexport const getWxMenuDetailApi = () => {\n\treturn http.get<WxMenuDetail>(`${PORT1}/wx/menu/detail`);\n};\n\nexport const saveWxMenuDraftApi = (params: WxMenuSaveReq) => {\n\treturn http.post(`${PORT1}/wx/menu/save`, params);\n};\n\nexport const validateWxMenuApi = (params?: WxMenuValidateReq) => {\n\treturn http.post<WxMenuValidateRes>(`${PORT1}/wx/menu/validate`, params);\n};\n\nexport const previewWxMenuMatchApi = (params?: WxMenuPreviewMatchReq) => {\n\treturn http.post<WxMenuPreviewMatchRes>(`${PORT1}/wx/menu/preview/match`, params);\n};\n\nexport const previewWxMenuAiApi = (params?: WxMenuPreviewAiReq) => {\n\treturn http.post<WxMenuPreviewAiRes>(`${PORT1}/wx/menu/preview/ai`, params);\n};\n\nexport const publishWxMenuApi = (params?: WxMenuPublishReq) => {\n\treturn http.post<WxMenuPublishRes>(`${PORT1}/wx/menu/publish`, params);\n};\n\nexport const syncWxMenuApi = () => {\n\treturn http.post<WxMenuDetail>(`${PORT1}/wx/menu/sync`);\n};\n"
  },
  {
    "path": "src/assets/fonts/font.less",
    "content": "@font-face {\n\tfont-family: YouSheBiaoTiHei;\n\tsrc: url(\"./YouSheBiaoTiHei.ttf\");\n}\n@font-face {\n\tfont-family: MetroDF;\n\tsrc: url(\"./MetroDF.ttf\");\n}\n@font-face {\n\tfont-family: DIN;\n\tsrc: url(\"./DIN.Otf\");\n}\n"
  },
  {
    "path": "src/assets/iconfont/iconfont.less",
    "content": "@font-face {\n\tfont-family: iconfont;\n\tsrc: url(\"iconfont.ttf?t=1648886414212\") format(\"truetype\");\n}\n.iconfont {\n\tfont-family: iconfont !important;\n\tfont-size: 16px;\n\t-webkit-font-smoothing: antialiased;\n\t-moz-osx-font-smoothing: grayscale;\n\tfont-style: normal;\n}\n.icon-zhongyingwen::before {\n\tcontent: \"\\e605\";\n}\n.icon-suoxiao::before {\n\tcontent: \"\\e641\";\n}\n.icon-fangda::before {\n\tcontent: \"\\e826\";\n}\n.icon-contentright::before {\n\tcontent: \"\\e8c9\";\n}\n.icon-zhuti::before {\n\tcontent: \"\\e62b\";\n}\n"
  },
  {
    "path": "src/components/ErrorMessage/403.tsx",
    "content": "import { useNavigate } from \"react-router-dom\";\nimport { Button, Result } from \"antd\";\n\nimport { HOME_URL } from \"@/config/config\";\n\nimport \"./index.less\";\n\nconst NotAuth = () => {\n\tconst navigate = useNavigate();\n\tconst goHome = () => {\n\t\tnavigate(HOME_URL);\n\t};\n\treturn (\n\t\t<Result\n\t\t\tstatus=\"403\"\n\t\t\ttitle=\"403\"\n\t\t\tsubTitle=\"Sorry, you are not authorized to access this page.\"\n\t\t\textra={\n\t\t\t\t<Button type=\"primary\" onClick={goHome}>\n\t\t\t\t\tBack Home\n\t\t\t\t</Button>\n\t\t\t}\n\t\t/>\n\t);\n};\n\nexport default NotAuth;\n"
  },
  {
    "path": "src/components/ErrorMessage/404.tsx",
    "content": "import { useNavigate } from \"react-router-dom\";\nimport { Button, Result } from \"antd\";\n\nimport { HOME_URL } from \"@/config/config\";\n\nimport \"./index.less\";\n\nconst NotFound = () => {\n\tconst navigate = useNavigate();\n\tconst goHome = () => {\n\t\tnavigate(HOME_URL);\n\t};\n\treturn (\n\t\t<Result\n\t\t\tstatus=\"404\"\n\t\t\ttitle=\"404\"\n\t\t\tsubTitle=\"Sorry, the page you visited does not exist.\"\n\t\t\textra={\n\t\t\t\t<Button type=\"primary\" onClick={goHome}>\n\t\t\t\t\tBack Home\n\t\t\t\t</Button>\n\t\t\t}\n\t\t/>\n\t);\n};\n\nexport default NotFound;\n"
  },
  {
    "path": "src/components/ErrorMessage/500.tsx",
    "content": "import { useNavigate } from \"react-router-dom\";\nimport { Button, Result } from \"antd\";\n\nimport { HOME_URL } from \"@/config/config\";\n\nimport \"./index.less\";\n\nconst NotNetwork = () => {\n\tconst navigate = useNavigate();\n\tconst goHome = () => {\n\t\tnavigate(HOME_URL);\n\t};\n\treturn (\n\t\t<Result\n\t\t\tstatus=\"500\"\n\t\t\ttitle=\"500\"\n\t\t\tsubTitle=\"Sorry, something went wrong.\"\n\t\t\textra={\n\t\t\t\t<Button type=\"primary\" onClick={goHome}>\n\t\t\t\t\tBack Home\n\t\t\t\t</Button>\n\t\t\t}\n\t\t/>\n\t);\n};\n\nexport default NotNetwork;\n"
  },
  {
    "path": "src/components/ErrorMessage/index.less",
    "content": ".ant-result {\n\tdisplay: flex;\n\tflex-direction: column;\n\talign-items: center;\n\tjustify-content: center;\n\theight: 100%;\n\t.ant-result-image {\n\t\tmargin: 0;\n\t}\n}\n"
  },
  {
    "path": "src/components/Loading/index.less",
    "content": "/* 请求 Loading 样式 */\n.request-loading {\n\t.ant-spin-text {\n\t\tmargin-top: 5px;\n\t\tfont-size: 18px;\n\t\tcolor: #509ff1;\n\t}\n\t.ant-spin-dot-item {\n\t\tbackground-color: #509ff1;\n\t}\n}\n\n/* 请求 Loading 遮罩层样式 */\n#loading {\n\tposition: fixed;\n\ttop: 0;\n\tright: 0;\n\tbottom: 0;\n\tleft: 0;\n\tz-index: 9998;\n\tdisplay: flex;\n\talign-items: center;\n\tjustify-content: center;\n\tfont-size: 20px;\n\tbackground: rgb(0 0 0 / 50%);\n}\n"
  },
  {
    "path": "src/components/Loading/index.tsx",
    "content": "import { Spin } from \"antd\";\n\nimport \"./index.less\";\n\nconst Loading = ({ tip = \"Loading\" }: { tip?: string }) => {\n\treturn <Spin tip={tip} size=\"large\" className=\"request-loading\" />;\n};\n\nexport default Loading;\n"
  },
  {
    "path": "src/components/SwitchDark/index.tsx",
    "content": "import { connect } from \"react-redux\";\nimport { Switch } from \"antd\";\n\nimport { setThemeConfig } from \"@/redux/modules/global/action\";\n\nconst SwitchDark = (props: any) => {\n\tconst { setThemeConfig, themeConfig } = props;\n\tconst onChange = (checked: boolean) => {\n\t\tsetThemeConfig({ ...themeConfig, isDark: checked });\n\t};\n\n\treturn (\n\t\t<Switch\n\t\t\tclassName=\"dark\"\n\t\t\tdefaultChecked={themeConfig.isDark}\n\t\t\tcheckedChildren={<>🌞</>}\n\t\t\tunCheckedChildren={<>🌜</>}\n\t\t\tonChange={onChange}\n\t\t/>\n\t);\n};\n\nconst mapStateToProps = (state: any) => state.global;\nconst mapDispatchToProps = { setThemeConfig };\nexport default connect(mapStateToProps, mapDispatchToProps)(SwitchDark);\n"
  },
  {
    "path": "src/components/common-wrap/index.scss",
    "content": ".content-wrap {\n  height: 100%;\n  padding: 10px 8px;\n  display: flex;\n  flex-direction: column;\n  box-sizing: border-box;\n  overflow-y: auto;\n  overflow-x: hidden;\n  max-width: 100%;\n}\n\n.content-inter-wrap {\n  flex: 1;\n  background-color: #fff;\n  padding: 20px;\n  border-radius: 6px;\n  box-sizing: border-box;\n  overflow-x: auto;\n  max-width: 100%;\n}\n\n@media (max-width: 768px) {\n  .content-wrap {\n    padding: 8px 4px;\n  }\n\n  .content-inter-wrap {\n    padding: 16px 12px;\n    overflow-x: hidden;\n  }\n}\n\n@media (max-width: 480px) {\n  .content-wrap {\n    padding: 4px 2px;\n  }\n\n  .content-inter-wrap {\n    padding: 12px 8px;\n    border-radius: 4px;\n  }\n}\n"
  },
  {
    "path": "src/components/common-wrap/index.tsx",
    "content": "import React, { ReactNode } from \"react\";\n\nimport \"./index.scss\";\n\ninterface IProps {\n\tchildren: ReactNode;\n\tclassName?: string;\n\tstyle?: any;\n}\n\nexport const ContentWrap = ({ children, className, style }: IProps) => (\n\t<div className={`content-wrap ${className || \"\"}`} style={style}>\n\t\t{children}\n\t</div>\n);\n\nexport const ContentInterWrap = ({ children, className, style }: IProps) => (\n\t<div className={`content-inter-wrap ${className || \"\"}`} style={style}>\n\t\t{children}\n\t</div>\n);\n"
  },
  {
    "path": "src/components/second-sure-modal/index.tsx",
    "content": "import React, { useState } from \"react\";\nimport { Button, Modal } from \"antd\";\n\nconst SecondSureModal: React.FC = () => {\n\tconst [open, setOpen] = useState(false);\n\tconst [confirmLoading, setConfirmLoading] = useState(false);\n\tconst [modalText, setModalText] = useState(\"Content of the modal\");\n\n\tconst showModal = () => {\n\t\tsetOpen(true);\n\t};\n\n\tconst handleOk = () => {\n\t\tsetModalText(\"The modal will be closed after two seconds\");\n\t\tsetConfirmLoading(true);\n\t\tsetTimeout(() => {\n\t\t\tsetOpen(false);\n\t\t\tsetConfirmLoading(false);\n\t\t}, 2000);\n\t};\n\n\tconst handleCancel = () => {\n\t\tconsole.log(\"Clicked cancel button\");\n\t\tsetOpen(false);\n\t};\n\n\treturn (\n\t\t<>\n\t\t\t<Modal title=\"Title\" open={open} onOk={handleOk} confirmLoading={confirmLoading} onCancel={handleCancel}>\n\t\t\t\t<p>{modalText}</p>\n\t\t\t</Modal>\n\t\t</>\n\t);\n};\nexport default SecondSureModal;\n"
  },
  {
    "path": "src/components/svgIcon/index.tsx",
    "content": "interface SvgProps {\n\tname: string; // 图标的名称 ==> 必传\n\tcolor?: string; //图标的颜色 ==> 非必传\n\tprefix?: string; // 图标的前缀 ==> 非必传（默认为\"icon\"）\n\ticonStyle?: { [key: string]: any }; // 图标的样式 ==> 非必传\n}\n\nexport default function SvgIcon(props: SvgProps) {\n\tconst { name, prefix = \"icon\", iconStyle = { width: \"100px\", height: \"100px\" } } = props;\n\tconst symbolId = `#${prefix}-${name}`;\n\treturn (\n\t\t<svg aria-hidden=\"true\" style={iconStyle}>\n\t\t\t<use href={symbolId} />\n\t\t</svg>\n\t);\n}\n"
  },
  {
    "path": "src/config/config.ts",
    "content": "// ? 全局不动配置项 只做导出不做修改\n\n// * 首页地址（默认）\nexport const HOME_URL: string = \"/statistics/index\";\n\n// * 登录地址\nexport const LOGIN_URL: string = \"/login\";\n\n// * Tabs（黑名单地址，不需要添加到 tabs 的路由地址，暂时没用）\nexport const TABS_BLACK_LIST: string[] = [\"/403\", \"/404\", \"/500\", \"/layout\", \"/login\", \"/dataScreen\"];\n\n// * 高德地图key\nexport const MAP_KEY: string = \"\";\n"
  },
  {
    "path": "src/config/nprogress.ts",
    "content": "import NProgress from \"nprogress\";\n\nimport \"nprogress/nprogress.css\";\n\nNProgress.configure({\n\teasing: \"ease\", // 动画方式\n\tspeed: 500, // 递增进度条的速度\n\tshowSpinner: true, // 是否显示加载ico\n\ttrickleSpeed: 200, // 自动递增间隔\n\tminimum: 0.3 // 初始化时的最小百分比\n});\n\nexport default NProgress;\n"
  },
  {
    "path": "src/config/serviceLoading.tsx",
    "content": "import ReactDOM from \"react-dom/client\";\n\nimport Loading from \"@/components/Loading\";\n\nlet needLoadingRequestCount = 0;\n\n// * 显示loading\nexport const showFullScreenLoading = () => {\n\tif (needLoadingRequestCount === 0) {\n\t\tlet dom = document.createElement(\"div\");\n\t\tdom.setAttribute(\"id\", \"loading\");\n\t\tdocument.body.appendChild(dom);\n\t\tReactDOM.createRoot(dom).render(<Loading />);\n\t}\n\tneedLoadingRequestCount++;\n};\n\n// * 隐藏loading\nexport const tryHideFullScreenLoading = () => {\n\tif (needLoadingRequestCount <= 0) return;\n\tneedLoadingRequestCount--;\n\tif (needLoadingRequestCount === 0) {\n\t\tdocument.body.removeChild(document.getElementById(\"loading\") as HTMLElement);\n\t}\n};\n"
  },
  {
    "path": "src/enums/common.ts",
    "content": "export enum UpdateEnum {\n\tSave = 0,\n\tEdit\n}\n\nexport interface IPagination {\n\tcurrent: number;\n\tpageSize: number;\n\ttotal?: number;\n}\n\nexport const initPagination: IPagination = {\n\tcurrent: 1,\n\tpageSize: 10,\n\ttotal: 0\n};\n\nexport enum PushStatusEnum {\n\tnoPublish = \"0\",\n\tPublished = \"1\",\n\tOffline = \"2\"\n}\nexport const pushStatusInfo = {\n\t[PushStatusEnum.noPublish]: \"default\",\n\t[PushStatusEnum.Published]: \"success\",\n\t[PushStatusEnum.Offline]: \"error\"\n};\n"
  },
  {
    "path": "src/enums/httpEnum.ts",
    "content": "// * 请求枚举配置\n/**\n * @description：请求配置\n */\nexport enum ResultEnum {\n\tSUCCESS = 0,\n\tERROR = 500,\n\tOVERDUE = 599,\n\tTIMEOUT = 10000,\n\tNOT_LOGIN = 100403003,\n\tTYPE = \"success\"\n}\n\n/**\n * @description：请求方法\n */\nexport enum RequestEnum {\n\tGET = \"GET\",\n\tPOST = \"POST\",\n\tPATCH = \"PATCH\",\n\tPUT = \"PUT\",\n\tDELETE = \"DELETE\"\n}\n\n/**\n * @description：常用的contentTyp类型\n */\nexport enum ContentTypeEnum {\n\t// json\n\tJSON = \"application/json;charset=UTF-8\",\n\t// text\n\tTEXT = \"text/plain;charset=UTF-8\",\n\t// form-data 一般配合qs\n\tFORM_URLENCODED = \"application/x-www-form-urlencoded;charset=UTF-8\",\n\t// form-data 上传\n\tFORM_DATA = \"multipart/form-data;charset=UTF-8\"\n}\n"
  },
  {
    "path": "src/hooks/useTheme.ts",
    "content": "import { ThemeConfigProp } from \"@/redux/interface\";\nimport darkTheme from \"@/styles/theme/theme-dark.less\";\nimport defaultTheme from \"@/styles/theme/theme-default.less\";\n\n/**\n * @description 全局主题设置\n * */\nconst useTheme = (themeConfig: ThemeConfigProp) => {\n\tconst { weakOrGray, isDark } = themeConfig;\n\tconst initTheme = () => {\n\t\t// 灰色和弱色切换\n\t\tconst body = document.documentElement as HTMLElement;\n\t\tif (!weakOrGray) body.setAttribute(\"style\", \"\");\n\t\tif (weakOrGray === \"weak\") body.setAttribute(\"style\", \"filter: invert(80%)\");\n\t\tif (weakOrGray === \"gray\") body.setAttribute(\"style\", \"filter: grayscale(1)\");\n\n\t\t// 切换暗黑模式\n\t\tlet head = document.getElementsByTagName(\"head\")[0];\n\t\tconst getStyle = head.getElementsByTagName(\"style\");\n\t\tif (getStyle.length > 0) {\n\t\t\tfor (let i = 0, l = getStyle.length; i < l; i++) {\n\t\t\t\tif (getStyle[i]?.getAttribute(\"data-type\") === \"dark\") getStyle[i].remove();\n\t\t\t}\n\t\t}\n\t\tlet styleDom = document.createElement(\"style\");\n\t\tstyleDom.dataset.type = \"dark\";\n\t\tstyleDom.innerHTML = isDark ? darkTheme : defaultTheme;\n\t\thead.appendChild(styleDom);\n\t};\n\tinitTheme();\n\n\treturn {\n\t\tinitTheme\n\t};\n};\n\nexport default useTheme;\n"
  },
  {
    "path": "src/index.scss",
    "content": ".ant-table-tbody > tr > td {\n  padding: 16px !important;\n}\n"
  },
  {
    "path": "src/layouts/components/Footer/index.less",
    "content": ".footer {\n\tdisplay: flex;\n\talign-items: center;\n\tjustify-content: center;\n\theight: 30px;\n\tborder-top: 1px solid #e4e7ed;\n\ta {\n\t\tfont-size: 14px;\n\t\tcolor: #858585;\n\t\ttext-decoration: none;\n\t\tletter-spacing: 0.5px;\n\t\twhite-space: nowrap;\n\t}\n}\n"
  },
  {
    "path": "src/layouts/components/Footer/index.tsx",
    "content": "import { connect } from \"react-redux\";\n\nimport { baseDomain } from \"@/utils/util\";\n\nimport \"./index.less\";\n\nconst LayoutFooter = (props: any) => {\n\tconst { themeConfig } = props;\n\n\t// 定义一个自动获取年份的方法\n\tconst getYear = () => {\n\t\treturn new Date().getFullYear();\n\t};\n\n\treturn (\n\t\t<>\n\t\t\t{!themeConfig.footer && (\n\t\t\t\t<div className=\"footer\">\n\t\t\t\t\t<a href={baseDomain} target=\"_blank\" rel=\"noreferrer\">\n\t\t\t\t\t\t{getYear()} © paicoding-admin By 技术派团队.\n\t\t\t\t\t</a>\n\t\t\t\t</div>\n\t\t\t)}\n\t\t</>\n\t);\n};\n\nconst mapStateToProps = (state: any) => state.global;\nexport default connect(mapStateToProps)(LayoutFooter);\n"
  },
  {
    "path": "src/layouts/components/Header/components/AssemblySize.tsx",
    "content": "import { connect } from \"react-redux\";\nimport { Dropdown, Menu } from \"antd\";\n\nimport { setAssemblySize } from \"@/redux/modules/global/action\";\n\nconst AssemblySize = (props: any) => {\n\tconst { assemblySize, setAssemblySize } = props;\n\n\t// 切换组件大小\n\tconst onClick = (e: MenuInfo) => {\n\t\tsetAssemblySize(e.key);\n\t};\n\n\tconst menu = (\n\t\t<Menu\n\t\t\titems={[\n\t\t\t\t{\n\t\t\t\t\tkey: \"middle\",\n\t\t\t\t\tdisabled: assemblySize == \"middle\",\n\t\t\t\t\tlabel: <span>默认</span>,\n\t\t\t\t\tonClick\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tdisabled: assemblySize == \"large\",\n\t\t\t\t\tkey: \"large\",\n\t\t\t\t\tlabel: <span>大型</span>,\n\t\t\t\t\tonClick\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tdisabled: assemblySize == \"small\",\n\t\t\t\t\tkey: \"small\",\n\t\t\t\t\tlabel: <span>小型</span>,\n\t\t\t\t\tonClick\n\t\t\t\t}\n\t\t\t]}\n\t\t/>\n\t);\n\treturn (\n\t\t<Dropdown overlay={menu} placement=\"bottom\" trigger={[\"click\"]} arrow={true}>\n\t\t\t<i className=\"icon-style iconfont icon-contentright\"></i>\n\t\t</Dropdown>\n\t);\n};\n\nconst mapStateToProps = (state: any) => state.global;\nconst mapDispatchToProps = { setAssemblySize };\nexport default connect(mapStateToProps, mapDispatchToProps)(AssemblySize);\n"
  },
  {
    "path": "src/layouts/components/Header/components/AvatarIcon.tsx",
    "content": "/* eslint-disable prettier/prettier */\nimport { useRef } from \"react\";\nimport { connect, useDispatch } from \"react-redux\";\nimport { useNavigate } from \"react-router-dom\";\nimport { ExclamationCircleOutlined } from \"@ant-design/icons\";\nimport { Avatar, Dropdown, MenuProps, message, Modal } from \"antd\";\n\nimport { logoutApi } from \"@/api/modules/login\";\nimport loginPng from \"@/assets/images/logo_md.png\";\nimport { HOME_URL, LOGIN_URL } from \"@/config/config\";\nimport { setToken, setUserInfo } from \"@/redux/modules/global/action\";\nimport InfoModal from \"./InfoModal\";\nimport PasswordModal from \"./PasswordModal\";\n\nconst AvatarIcon = (props: any) => {\n\tconst { userInfo, setToken, setUserInfo } = props;\n\tconsole.log(\"AvatarIcon setToken setUserInfo\", setToken, setUserInfo);\n\tconsole.log(\"AvatarIcon userInfo\", userInfo );\n\n\tconst navigate = useNavigate();\n\n\tinterface ModalProps {\n\t\tshowModal: (params: Record<string, any>) => void;\n\t}\n\n\tconst passRef = useRef<ModalProps>(null);\n\tconst infoRef = useRef<ModalProps>(null);\n\n\t// 退出登录\n\tconst logout = () => {\n\t\tModal.confirm({\n\t\t\ttitle: \"温馨提示 🧡\",\n\t\t\ticon: <ExclamationCircleOutlined />,\n\t\t\tcontent: \"是否确认退出登录？\",\n\t\t\tokText: \"确认\",\n\t\t\tcancelText: \"取消\",\n\t\t\tonOk: async () => {\n\t\t\t\t// 此时需要请求服务器端退出登录接口\n\t\t\t\tconst { status, result } = await logoutApi();\n\t\t\t\tif (status && status.code == 0 && result) {\n\t\t\t\t\t// 退出，清除 token，清除用户信息，跳转到登录页\n\t\t\t\t\tsetToken(\"\");\n\t\t\t\t\tsetUserInfo({});\n\t\t\t\t\tmessage.success(\"退出登录成功！\");\n\t\t\t\t\tnavigate(LOGIN_URL);\n\t\t\t\t} else {\n\t\t\t\t\tmessage.success(\"退出登录失败:\" + status?.msg);\n\t\t\t\t}\n\t\t\t}\n\t\t});\n\t};\n\n\tconst items: MenuProps[\"items\"] = [\n\t\t{\n\t\t\tkey: \"1\",\n\t\t\tlabel: <span className=\"dropdown-item\">首页</span>,\n\t\t\tonClick: () => navigate(HOME_URL)\n\t\t},\n\t\t{\n\t\t\tkey: \"2\",\n\t\t\tlabel: <span className=\"dropdown-item\">个人信息</span>,\n\t\t\tonClick: () => infoRef.current!.showModal({ \n\t\t\t\tphoto: userInfo.photo,\n\t\t\t\tprofile: userInfo.profile,\n\t\t\t\trole: userInfo.role,\n\t\t\t\tuserName: userInfo.userName,\n\t\t\t})\n\t\t},\n\t\t{\n\t\t\tkey: \"3\",\n\t\t\tlabel: <span className=\"dropdown-item\">修改密码</span>,\n\t\t\tonClick: () => passRef.current!.showModal({ name: 11 })\n\t\t},\n\t\t{\n\t\t\ttype: \"divider\"\n\t\t},\n\t\t{\n\t\t\tkey: \"4\",\n\t\t\tlabel: <span className=\"dropdown-item\">退出登录</span>,\n\t\t\tonClick: logout\n\t\t}\n\t];\n\treturn (\n\t\t<>\n\t\t\t<Dropdown menu={{ items }} placement=\"bottom\" arrow trigger={[\"click\"]}>\n\t\t\t\t<Avatar size=\"large\" src={userInfo.photo || loginPng} />\n\t\t\t</Dropdown>\n\t\t\t<InfoModal innerRef={infoRef}></InfoModal>\n\t\t\t<PasswordModal innerRef={passRef}></PasswordModal>\n\t\t</>\n\t);\n};\n\nconst mapDispatchToProps = { setToken, setUserInfo };\nexport default connect(null, mapDispatchToProps)(AvatarIcon);\n"
  },
  {
    "path": "src/layouts/components/Header/components/BreadcrumbNav.tsx",
    "content": "import { connect } from \"react-redux\";\nimport { useLocation } from \"react-router-dom\";\nimport { Breadcrumb } from \"antd\";\n\nimport { HOME_URL } from \"@/config/config\";\n\nconst BreadcrumbNav = (props: any) => {\n\tconst { pathname } = useLocation();\n\tconst { themeConfig } = props.global;\n\tconst breadcrumbList = props.breadcrumb.breadcrumbList[pathname] || [];\n\tconsole.log({ breadcrumbList });\n\n\treturn (\n\t\t<>\n\t\t\t{!themeConfig.breadcrumb && (\n\t\t\t\t<Breadcrumb>\n\t\t\t\t\t<Breadcrumb.Item href={`#${HOME_URL}`}>首页</Breadcrumb.Item>\n\t\t\t\t\t{breadcrumbList.map((item: string) => {\n\t\t\t\t\t\treturn <Breadcrumb.Item key={item}>{item !== \"首页\" ? item : null}</Breadcrumb.Item>;\n\t\t\t\t\t})}\n\t\t\t\t</Breadcrumb>\n\t\t\t)}\n\t\t</>\n\t);\n};\n\nconst mapStateToProps = (state: any) => state;\nexport default connect(mapStateToProps)(BreadcrumbNav);\n"
  },
  {
    "path": "src/layouts/components/Header/components/CollapseIcon.tsx",
    "content": "import { connect } from \"react-redux\";\nimport { MenuFoldOutlined, MenuUnfoldOutlined } from \"@ant-design/icons\";\n\nimport { updateCollapse } from \"@/redux/modules/menu/action\";\n\nconst CollapseIcon = (props: any) => {\n\tconst { isCollapse, updateCollapse } = props;\n\treturn (\n\t\t<div\n\t\t\tclassName=\"collapsed\"\n\t\t\tonClick={() => {\n\t\t\t\tupdateCollapse(!isCollapse);\n\t\t\t}}\n\t\t>\n\t\t\t{isCollapse ? <MenuUnfoldOutlined id=\"isCollapse\" /> : <MenuFoldOutlined id=\"isCollapse\" />}\n\t\t</div>\n\t);\n};\n\nconst mapStateToProps = (state: any) => state.menu;\nconst mapDispatchToProps = { updateCollapse };\nexport default connect(mapStateToProps, mapDispatchToProps)(CollapseIcon);\n"
  },
  {
    "path": "src/layouts/components/Header/components/Fullscreen.tsx",
    "content": "import { useEffect, useState } from \"react\";\nimport { message } from \"antd\";\nimport screenfull from \"screenfull\";\n\nconst Fullscreen = () => {\n\tconst [fullScreen, setFullScreen] = useState<boolean>(screenfull.isFullscreen);\n\n\tuseEffect(() => {\n\t\tscreenfull.on(\"change\", () => {\n\t\t\tif (screenfull.isFullscreen) setFullScreen(true);\n\t\t\telse setFullScreen(false);\n\t\t\treturn () => screenfull.off(\"change\", () => {});\n\t\t});\n\t}, []);\n\n\tconst handleFullScreen = () => {\n\t\tif (!screenfull.isEnabled) message.warning(\"当前您的浏览器不支持全屏 ❌\");\n\t\tscreenfull.toggle();\n\t};\n\treturn (\n\t\t<i className={[\"icon-style iconfont\", fullScreen ? \"icon-suoxiao\" : \"icon-fangda\"].join(\" \")} onClick={handleFullScreen}></i>\n\t);\n};\nexport default Fullscreen;\n"
  },
  {
    "path": "src/layouts/components/Header/components/InfoModal.tsx",
    "content": "import { Ref, useImperativeHandle, useState } from \"react\";\nimport { Avatar, message, Modal } from \"antd\";\n\ninterface Props {\n\tinnerRef: Ref<{ showModal: (params: any) => void } | undefined>;\n}\n\nconst InfoModal = (props: Props) => {\n\tconst [modalVisible, setModalVisible] = useState(false);\n\tconst [userInfo, setUserInfo] = useState<Record<string, any>>({}); // 新增状态来存储用户信息\n\n\tuseImperativeHandle(props.innerRef, () => ({\n\t\tshowModal\n\t}));\n\n\tconst showModal = (params: Record<string, any>) => {\n\t\tconsole.log(params);\n\t\t// 把params 显示到 model 中\n\t\tsetUserInfo(params);\n\t\tsetModalVisible(true);\n\t};\n\n\tconst handleCancel = () => {\n\t\tsetModalVisible(false);\n\t};\n\treturn (\n\t\t<Modal title=\"个人信息\" footer={null} open={modalVisible} onCancel={handleCancel} destroyOnClose={true}>\n\t\t\t<div className=\"info-modal\">\n\t\t\t\t<div className=\"info-modal-item\">\n\t\t\t\t\t<span className=\"info-modal-item-label\">头像：</span>\n\t\t\t\t\t<span className=\"info-modal-item-value\">\n\t\t\t\t\t\t<Avatar src={userInfo.photo} />\n\t\t\t\t\t</span>\n\t\t\t\t</div>\n\t\t\t\t<div className=\"info-modal-item\">\n\t\t\t\t\t<span className=\"info-modal-item-label\">用户名：</span>\n\t\t\t\t\t<span className=\"info-modal-item-value\">{userInfo.userName}</span>\n\t\t\t\t</div>\n\t\t\t\t<div className=\"info-modal-item\">\n\t\t\t\t\t<span className=\"info-modal-item-label\">角色：</span>\n\t\t\t\t\t<span className=\"info-modal-item-value\">{userInfo.role}</span>\n\t\t\t\t</div>\n\t\t\t\t<div className=\"info-modal-item\">\n\t\t\t\t\t<span className=\"info-modal-item-label\">个人简介：</span>\n\t\t\t\t\t<span className=\"info-modal-item-value\">{userInfo.profile}</span>\n\t\t\t\t</div>\n\t\t\t</div>\n\t\t</Modal>\n\t);\n};\nexport default InfoModal;\n"
  },
  {
    "path": "src/layouts/components/Header/components/PasswordModal.tsx",
    "content": "import { Ref, useImperativeHandle, useState } from \"react\";\nimport { message, Modal } from \"antd\";\n\ninterface Props {\n\tinnerRef: Ref<{ showModal: (params: any) => void }>;\n}\n\nconst PasswordModal = (props: Props) => {\n\tconst [isModalVisible, setIsModalVisible] = useState(false);\n\n\tuseImperativeHandle(props.innerRef, () => ({\n\t\tshowModal\n\t}));\n\n\tconst showModal = (params: { name: number }) => {\n\t\tconsole.log(params);\n\t\tsetIsModalVisible(true);\n\t};\n\n\tconst handleOk = () => {\n\t\tsetIsModalVisible(false);\n\t\tmessage.success(\"修改密码成功 🎉🎉🎉\");\n\t};\n\n\tconst handleCancel = () => {\n\t\tsetIsModalVisible(false);\n\t};\n\treturn (\n\t\t<Modal title=\"修改密码\" visible={isModalVisible} onOk={handleOk} onCancel={handleCancel} destroyOnClose={true}>\n\t\t\t<p>Some Password...</p>\n\t\t\t<p>Some Password...</p>\n\t\t\t<p>Some Password...</p>\n\t\t</Modal>\n\t);\n};\nexport default PasswordModal;\n"
  },
  {
    "path": "src/layouts/components/Header/components/Theme.tsx",
    "content": "import { useState } from \"react\";\nimport { connect } from \"react-redux\";\nimport { FireOutlined, SettingOutlined } from \"@ant-design/icons\";\nimport { Divider, Drawer, Switch } from \"antd\";\n\nimport SwitchDark from \"@/components/SwitchDark\";\nimport { setThemeConfig } from \"@/redux/modules/global/action\";\nimport { updateCollapse } from \"@/redux/modules/menu/action\";\n\nconst Theme = (props: any) => {\n\tconst [visible, setVisible] = useState<boolean>(false);\n\tconst { setThemeConfig, updateCollapse } = props;\n\tconst { isCollapse } = props.menu;\n\tconst { themeConfig } = props.global;\n\tconst { weakOrGray, breadcrumb, tabs, footer } = themeConfig;\n\n\tconst setWeakOrGray = (checked: boolean, theme: string) => {\n\t\tif (checked) return setThemeConfig({ ...themeConfig, weakOrGray: theme });\n\t\tsetThemeConfig({ ...themeConfig, weakOrGray: \"\" });\n\t};\n\n\tconst onChange = (checked: boolean, keyName: string) => {\n\t\treturn setThemeConfig({ ...themeConfig, [keyName]: !checked });\n\t};\n\n\treturn (\n\t\t<>\n\t\t\t<i\n\t\t\t\tclassName=\"icon-style iconfont icon-zhuti\"\n\t\t\t\tonClick={() => {\n\t\t\t\t\tsetVisible(true);\n\t\t\t\t}}\n\t\t\t></i>\n\t\t\t<Drawer\n\t\t\t\ttitle=\"布局设置\"\n\t\t\t\tclosable={false}\n\t\t\t\tonClose={() => {\n\t\t\t\t\tsetVisible(false);\n\t\t\t\t}}\n\t\t\t\tvisible={visible}\n\t\t\t\twidth={320}\n\t\t\t>\n\t\t\t\t{/* 全局主题 */}\n\t\t\t\t<Divider className=\"divider\">\n\t\t\t\t\t<FireOutlined />\n\t\t\t\t\t全局主题\n\t\t\t\t</Divider>\n\t\t\t\t<div className=\"theme-item\">\n\t\t\t\t\t<span>暗黑模式</span>\n\t\t\t\t\t<SwitchDark />\n\t\t\t\t</div>\n\t\t\t\t<div className=\"theme-item\">\n\t\t\t\t\t<span>灰色模式</span>\n\t\t\t\t\t<Switch\n\t\t\t\t\t\tchecked={weakOrGray === \"gray\"}\n\t\t\t\t\t\tonChange={e => {\n\t\t\t\t\t\t\tsetWeakOrGray(e, \"gray\");\n\t\t\t\t\t\t}}\n\t\t\t\t\t/>\n\t\t\t\t</div>\n\t\t\t\t<div className=\"theme-item\">\n\t\t\t\t\t<span>色弱模式</span>\n\t\t\t\t\t<Switch\n\t\t\t\t\t\tchecked={weakOrGray === \"weak\"}\n\t\t\t\t\t\tonChange={e => {\n\t\t\t\t\t\t\tsetWeakOrGray(e, \"weak\");\n\t\t\t\t\t\t}}\n\t\t\t\t\t/>\n\t\t\t\t</div>\n\t\t\t\t<br />\n\t\t\t\t{/* 界面设置 */}\n\t\t\t\t<Divider className=\"divider\">\n\t\t\t\t\t<SettingOutlined />\n\t\t\t\t\t界面设置\n\t\t\t\t</Divider>\n\t\t\t\t<div className=\"theme-item\">\n\t\t\t\t\t<span>折叠菜单</span>\n\t\t\t\t\t<Switch\n\t\t\t\t\t\tchecked={isCollapse}\n\t\t\t\t\t\tonChange={e => {\n\t\t\t\t\t\t\tupdateCollapse(e);\n\t\t\t\t\t\t}}\n\t\t\t\t\t/>\n\t\t\t\t</div>\n\t\t\t\t<div className=\"theme-item\">\n\t\t\t\t\t<span>面包屑导航</span>\n\t\t\t\t\t<Switch\n\t\t\t\t\t\tchecked={!breadcrumb}\n\t\t\t\t\t\tonChange={e => {\n\t\t\t\t\t\t\tonChange(e, \"breadcrumb\");\n\t\t\t\t\t\t}}\n\t\t\t\t\t/>\n\t\t\t\t</div>\n\t\t\t\t<div className=\"theme-item\">\n\t\t\t\t\t<span>标签栏</span>\n\t\t\t\t\t<Switch\n\t\t\t\t\t\tchecked={!tabs}\n\t\t\t\t\t\tonChange={e => {\n\t\t\t\t\t\t\tonChange(e, \"tabs\");\n\t\t\t\t\t\t}}\n\t\t\t\t\t/>\n\t\t\t\t</div>\n\t\t\t\t<div className=\"theme-item\">\n\t\t\t\t\t<span>页脚</span>\n\t\t\t\t\t<Switch\n\t\t\t\t\t\tchecked={!footer}\n\t\t\t\t\t\tonChange={e => {\n\t\t\t\t\t\t\tonChange(e, \"footer\");\n\t\t\t\t\t\t}}\n\t\t\t\t\t/>\n\t\t\t\t</div>\n\t\t\t</Drawer>\n\t\t</>\n\t);\n};\n\nconst mapStateToProps = (state: any) => state;\nconst mapDispatchToProps = { setThemeConfig, updateCollapse };\nexport default connect(mapStateToProps, mapDispatchToProps)(Theme);\n"
  },
  {
    "path": "src/layouts/components/Header/index.less",
    "content": ".ant-layout-header {\n\tdisplay: flex;\n\talign-items: center;\n\tjustify-content: space-between;\n\tborder-bottom: 1px solid #f6f6f6;\n\t.header-lf {\n\t\tdisplay: flex;\n\t\talign-items: center;\n\t\t.collapsed {\n\t\t\tmargin-right: 20px;\n\t\t\tfont-size: 18px;\n\t\t\tcursor: pointer;\n\t\t\ttransition: color 0.3s;\n\t\t}\n\t}\n\t.header-ri {\n\t\tdisplay: flex;\n\t\talign-items: center;\n\t\t.icon-style {\n\t\t\tmargin-right: 22px;\n\t\t\tfont-size: 19px;\n\t\t\tline-height: 19px;\n\t\t\tcursor: pointer;\n\t\t}\n\t\t.username {\n\t\t\tmargin: 0 20px 0 0;\n\t\t\tfont-size: 15px;\n\t\t}\n\t\t.ant-avatar {\n\t\t\tcursor: pointer;\n\t\t}\n\t}\n}\n.theme-item {\n\tdisplay: flex;\n\talign-items: center;\n\tjustify-content: space-between;\n\tmargin: 25px 0;\n\tspan {\n\t\tfont-size: 14px;\n\t}\n\t.ant-switch {\n\t\twidth: 46px;\n\t}\n}\n.divider {\n\tmargin: 0 0 22px !important;\n\tfont-size: 15px !important;\n\t.anticon {\n\t\tmargin-right: 10px;\n\t}\n}\n.ant-divider-with-text::before,\n.ant-divider-with-text::after {\n\tborder-top: 1px solid #dcdfe6 !important;\n}\n"
  },
  {
    "path": "src/layouts/components/Header/index.tsx",
    "content": "import { useEffect } from \"react\";\nimport { connect } from \"react-redux\";\nimport { Layout } from \"antd\";\n\nimport { loginUserInfo } from \"@/api/modules/login\";\nimport { getDiscListAction } from \"@/redux/modules/disc/action\";\nimport { setToken, setUserInfo } from \"@/redux/modules/global/action\";\nimport { setTabsList } from \"@/redux/modules/tabs/action\";\nimport AssemblySize from \"./components/AssemblySize\";\nimport AvatarIcon from \"./components/AvatarIcon\";\nimport BreadcrumbNav from \"./components/BreadcrumbNav\";\nimport CollapseIcon from \"./components/CollapseIcon\";\nimport Fullscreen from \"./components/Fullscreen\";\nimport Theme from \"./components/Theme\";\n\nimport \"./index.less\";\n\nconst LayoutHeader = (props: any) => {\n\tconst { setToken, setUserInfo, setTabsList, getDiscListAction } = props;\n\n\t// 尝试从 props 中获取用户信息，如果没有登录行为，则从后端直接获取\n\tlet { userInfo } = props || {};\n\tconsole.log(\"LayoutHeader userInfo\", userInfo);\n\n\tconst { Header } = Layout;\n\n\tuseEffect(() => {\n\t\tlet toCheck = !userInfo || JSON.stringify(userInfo) === \"{}\";\n\t\tconsole.log(\"toCheck\", toCheck);\n\n\t\tif (toCheck) {\n\t\t\tconst fetchUsrInfo = async () => {\n\t\t\t\ttry {\n\t\t\t\t\tconst { status, result } = await loginUserInfo();\n\t\t\t\t\tconsole.log(\"请求用户信息: \", result);\n\n\t\t\t\t\tif (status && status.code == 0 && result && result.userId > 0) {\n\t\t\t\t\t\t// 保存 token 到 Redux 的状态中\n\t\t\t\t\t\tsetToken(String(result.userId));\n\t\t\t\t\t\t// 保存用户登录信息\n\t\t\t\t\t\tsetUserInfo(result);\n\t\t\t\t\t\tsetTabsList([]);\n\t\t\t\t\t\t// 获取字典数据\n\t\t\t\t\t\tgetDiscListAction();\n\t\t\t\t\t}\n\t\t\t\t} catch (e) {\n\t\t\t\t\tconsole.log(\"初始化用户身份异常!\", e);\n\t\t\t\t}\n\t\t\t};\n\t\t\t// 未拿到用户信息时，主动去拿一下\n\t\t\tfetchUsrInfo();\n\t\t}\n\t}, []);\n\n\treturn (\n\t\t<Header>\n\t\t\t<div className=\"header-lf\">\n\t\t\t\t<CollapseIcon />\n\t\t\t\t<BreadcrumbNav />\n\t\t\t</div>\n\t\t\t<div className=\"header-ri\">\n\t\t\t\t<AssemblySize />\n\t\t\t\t<Theme />\n\t\t\t\t<Fullscreen />\n\t\t\t\t<span className=\"username\">{userInfo.userName || \"技术派\"}</span>\n\t\t\t\t<AvatarIcon userInfo={userInfo} />\n\t\t\t</div>\n\t\t</Header>\n\t);\n};\n\nconst mapStateToProps = (state: any) => state.global;\nconst mapDispatchToProps = { setToken, setUserInfo, getDiscListAction, setTabsList };\nexport default connect(mapStateToProps, mapDispatchToProps)(LayoutHeader);\n"
  },
  {
    "path": "src/layouts/components/Menu/components/Logo.tsx",
    "content": "import { connect } from \"react-redux\";\n\nimport logo from \"@/assets/images/logo.svg\";\nimport logoMd from \"@/assets/images/logo_md.png\";\nconst Logo = (props: any) => {\n\tconst { isCollapse } = props;\n\treturn (\n\t\t<div className=\"logo-box\">\n\t\t\t<img src={!isCollapse ? logo : logoMd} alt=\"logo\" className={!isCollapse ? \"logo-img\" : \"logo-img-md\"} />\n\t\t</div>\n\t);\n};\n\nconst mapStateToProps = (state: any) => state.menu;\nexport default connect(mapStateToProps)(Logo);\n"
  },
  {
    "path": "src/layouts/components/Menu/index.css",
    "content": ".menu {\n  display: flex;\n  flex-direction: column;\n  justify-content: space-between;\n  height: 100%;\n  /* 去除菜单 Loading 遮罩层 */\n}\n.menu .logo-box {\n  display: flex;\n  align-items: center;\n  justify-content: center;\n  height: 55px;\n}\n.menu .logo-box .logo-img {\n  width: 100px;\n  margin: 0;\n}\n.menu .logo-box .logo-img-md {\n  width: 30px;\n}\n.menu .logo-box .logo-text {\n  margin: 0 0 0 10px;\n  font-size: 24px;\n  font-weight: bold;\n  color: #dadada;\n  white-space: nowrap;\n}\n.menu .ant-menu-root {\n  flex: 1;\n  overflow-x: hidden;\n  overflow-y: auto;\n}\n.menu .ant-spin-nested-loading,\n.menu .ant-spin-container {\n  display: flex;\n  flex-direction: column;\n  height: 100%;\n}\n.menu .ant-spin-nested-loading .ant-spin,\n.menu .ant-spin-container .ant-spin {\n  max-height: 100% !important;\n}\n.menu .ant-spin-nested-loading .ant-spin-container::after,\n.menu .ant-spin-container .ant-spin-container::after {\n  background: transparent !important;\n}\n.menu .ant-spin-nested-loading .ant-spin-blur,\n.menu .ant-spin-container .ant-spin-blur {\n  overflow: auto !important;\n  clear: none !important;\n  opacity: 1 !important;\n}\n"
  },
  {
    "path": "src/layouts/components/Menu/index.less",
    "content": ".menu {\n\tdisplay: flex;\n\tflex-direction: column;\n\tjustify-content: space-between;\n\theight: 100%;\n\t.logo-box {\n\t\tdisplay: flex;\n\t\talign-items: center;\n\t\tjustify-content: center;\n\t\theight: 55px;\n\t\t.logo-img {\n\t\t\twidth: 100px;\n\t\t\tmargin: 0;\n\t\t}\n\t\t.logo-img-md {\n\t\t\twidth: 30px;\n\t\t}\n\t\t.logo-text {\n\t\t\tmargin: 0 0 0 10px;\n\t\t\tfont-size: 24px;\n\t\t\tfont-weight: bold;\n\t\t\tcolor: #dadada;\n\t\t\twhite-space: nowrap;\n\t\t}\n\t}\n\t.ant-menu-root {\n\t\tflex: 1;\n\t\toverflow-x: hidden;\n\t\toverflow-y: auto;\n\t}\n\n\t/* 去除菜单 Loading 遮罩层 */\n\t.ant-spin-nested-loading,\n\t.ant-spin-container {\n\t\tdisplay: flex;\n\t\tflex-direction: column;\n\t\theight: 100%;\n\t\t.ant-spin {\n\t\t\tmax-height: 100% !important;\n\t\t}\n\t\t.ant-spin-container::after {\n\t\t\tbackground: transparent !important;\n\t\t}\n\t\t.ant-spin-blur {\n\t\t\toverflow: auto !important;\n\t\t\tclear: none !important;\n\t\t\topacity: 1 !important;\n\t\t}\n\t}\n}\n"
  },
  {
    "path": "src/layouts/components/Menu/index.tsx",
    "content": "/**\n * 菜单控制\n */\nimport React, { useEffect, useState } from \"react\";\nimport { connect } from \"react-redux\";\nimport { useLocation, useNavigate } from \"react-router-dom\";\nimport * as Icons from \"@ant-design/icons\";\nimport type { MenuProps } from \"antd\";\nimport { Menu, Spin } from \"antd\";\n\nimport { setAuthRouter } from \"@/redux/modules/auth/action\";\nimport { setBreadcrumbList } from \"@/redux/modules/breadcrumb/action\";\nimport { setMenuList } from \"@/redux/modules/menu/action\";\nimport { currentMenuList } from \"@/routers/route\";\nimport { getOpenKeys, searchRoute } from \"@/utils/util\";\nimport Logo from \"./components/Logo\";\n\nimport \"./index.less\";\n\nconst LayoutMenu = (props: any) => {\n\tconst { pathname } = useLocation();\n\tconst { isCollapse } = props;\n\tconst [selectedKeys, setSelectedKeys] = useState<string[]>([pathname]);\n\tconst [openKeys, setOpenKeys] = useState<string[]>([]);\n\n\t// 刷新页面菜单保持高亮\n\tuseEffect(() => {\n\t\tsetSelectedKeys([pathname]);\n\t\tisCollapse ? null : setOpenKeys(getOpenKeys(pathname));\n\t}, [pathname, isCollapse]);\n\n\t// 设置当前展开的 subMenu\n\tconst onOpenChange = (openKeys: string[]) => {\n\t\tif (openKeys.length === 0 || openKeys.length === 1) return setOpenKeys(openKeys);\n\t\tconst latestOpenKey = openKeys[openKeys.length - 1];\n\t\tif (latestOpenKey.includes(openKeys[0])) return setOpenKeys(openKeys);\n\t\tsetOpenKeys([latestOpenKey]);\n\t};\n\n\t// 定义 menu 类型\n\ttype MenuItem = Required<MenuProps>[\"items\"][number];\n\n\t// 动态渲染 Icon 图标\n\tconst customIcons: { [key: string]: any } = Icons;\n\n\t// 处理后台返回菜单 key 值为 antd 菜单需要的 key 值\n\t// const deepLoopFloat = (menuList: Menu.MenuOptions[], newArr: MenuItem[] = []) => {\n\t// \tmenuList.forEach((item: Menu.MenuOptions) => {\n\t// \t\t// 下面判断代码解释 *** !item?.children?.length   ==>   (!item.children || item.children.length === 0)\n\t// \t\tif (!item?.children?.length) return newArr.push(getItem(item.title, item.path, addIcon(item.icon!)));\n\t// \t\tnewArr.push(getItem(item.title, item.path, addIcon(item.icon!), deepLoopFloat(item.children)));\n\t// \t});\n\t// \treturn newArr;\n\t// };\n\n\t// 获取菜单列表并处理成 antd menu 需要的格式\n\t// const [menuList, setMenuList] = useState<MenuItem[]>([]);\n\tconst [loading, setLoading] = useState(false);\n\t// const getMenuData = async () => {\n\t// \t// setLoading(true);\n\t// \ttry {\n\t// \t\t// const { data } = await getMenuList();\n\t// \t\t// if (!data) return;\n\t// \t\tsetMenuList(deepLoopFloat(currentMenuList));\n\t// \t\t// 存储处理过后的所有面包屑导航栏到 redux 中\n\t// \t\tsetBreadcrumbList(findAllBreadcrumb(currentMenuList));\n\t// \t\t// 把路由菜单处理成一维数组，存储到 redux 中，做菜单权限判断\n\t// \t\tconst dynamicRouter = handleRouter(currentMenuList);\n\t// \t\tsetAuthRouter(dynamicRouter);\n\t// \t\tsetMenuListAction(currentMenuList);\n\t// \t} finally {\n\t// \t\tsetLoading(false);\n\t// \t}\n\t// };\n\t// useEffect(() => {\n\t// \tgetMenuData();\n\t// }, []);\n\n\t// 点击当前菜单跳转页面\n\tconst navigate = useNavigate();\n\tconst clickMenu: MenuProps[\"onClick\"] = ({ key }: { key: string }) => {\n\t\tconst route = searchRoute(key, props.menuList);\n\t\tconsole.log({ route, props });\n\n\t\tif (route.isLink) window.open(route.isLink, \"_blank\");\n\t\tconsole.log({ key });\n\n\t\tnavigate(key);\n\t};\n\n\treturn (\n\t\t<div className=\"menu\">\n\t\t\t<Spin spinning={loading} tip=\"Loading...\">\n\t\t\t\t<Logo></Logo>\n\t\t\t\t<Menu\n\t\t\t\t\ttheme=\"dark\"\n\t\t\t\t\tmode=\"inline\"\n\t\t\t\t\ttriggerSubMenuAction=\"click\"\n\t\t\t\t\topenKeys={openKeys}\n\t\t\t\t\tselectedKeys={selectedKeys}\n\t\t\t\t\t// items={menuList}\n\t\t\t\t\titems={currentMenuList}\n\t\t\t\t\tonClick={clickMenu}\n\t\t\t\t\tonOpenChange={onOpenChange}\n\t\t\t\t></Menu>\n\t\t\t</Spin>\n\t\t</div>\n\t);\n};\n\nconst mapStateToProps = (state: any) => state.menu;\nconst mapDispatchToProps = { setMenuList, setBreadcrumbList, setAuthRouter };\nexport default connect(mapStateToProps, mapDispatchToProps)(LayoutMenu);\n"
  },
  {
    "path": "src/layouts/components/Tabs/components/MoreButton.tsx",
    "content": "import { useTranslation } from \"react-i18next\";\nimport { useLocation, useNavigate } from \"react-router-dom\";\nimport { DownOutlined } from \"@ant-design/icons\";\nimport { Button, Dropdown, Menu } from \"antd\";\n\nimport { HOME_URL } from \"@/config/config\";\n\nconst MoreButton = (props: any) => {\n\tconst { t } = useTranslation();\n\tconst { pathname } = useLocation();\n\tconst navigate = useNavigate();\n\n\t// close multipleTab\n\tconst closeMultipleTab = (tabPath?: string) => {\n\t\tconst handleTabsList = props.tabsList.filter((item: Menu.MenuOptions) => {\n\t\t\treturn item.path === tabPath || item.path === HOME_URL;\n\t\t});\n\t\tprops.setTabsList(handleTabsList);\n\t\ttabPath ?? navigate(HOME_URL);\n\t};\n\n\tconst menu = (\n\t\t<Menu\n\t\t\titems={[\n\t\t\t\t{\n\t\t\t\t\tkey: \"1\",\n\t\t\t\t\tlabel: <span>{t(\"tabs.closeCurrent\")}</span>,\n\t\t\t\t\tonClick: () => props.delTabs(pathname)\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tkey: \"2\",\n\t\t\t\t\tlabel: <span>{t(\"tabs.closeOther\")}</span>,\n\t\t\t\t\tonClick: () => closeMultipleTab(pathname)\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tkey: \"3\",\n\t\t\t\t\tlabel: <span>{t(\"tabs.closeAll\")}</span>,\n\t\t\t\t\tonClick: () => closeMultipleTab()\n\t\t\t\t}\n\t\t\t]}\n\t\t/>\n\t);\n\treturn (\n\t\t<Dropdown overlay={menu} placement=\"bottom\" arrow={{ pointAtCenter: true }} trigger={[\"click\"]}>\n\t\t\t<Button className=\"more-button\" type=\"primary\" size=\"small\">\n\t\t\t\t{t(\"tabs.more\")} <DownOutlined />\n\t\t\t</Button>\n\t\t</Dropdown>\n\t);\n};\nexport default MoreButton;\n"
  },
  {
    "path": "src/layouts/components/Tabs/index.less",
    "content": ".tabs {\n\tposition: relative;\n\tborder-bottom: 1px solid #e4e7ed;\n\t.ant-tabs {\n\t\tpadding: 0 90px 0 13px;\n\t\t.ant-tabs-nav {\n\t\t\tmargin: 0;\n\t\t\t&::before {\n\t\t\t\tborder: none;\n\t\t\t}\n\t\t\t.ant-tabs-ink-bar {\n\t\t\t\tvisibility: visible;\n\t\t\t}\n\t\t\t.ant-tabs-tab-with-remove.ant-tabs-tab-active {\n\t\t\t\t.ant-tabs-tab-remove {\n\t\t\t\t\ttop: 1px;\n\t\t\t\t\tmargin: 7px;\n\t\t\t\t\tcolor: @primary-color !important;\n\t\t\t\t\topacity: 1 !important;\n\t\t\t\t}\n\t\t\t\t.ant-tabs-tab-btn {\n\t\t\t\t\ttransform: translateX(-9px);\n\t\t\t\t}\n\t\t\t}\n\t\t\t.ant-tabs-tab {\n\t\t\t\tpadding: 8px 22px;\n\t\t\t\tcolor: #cccccc;\n\t\t\t\tbackground: none;\n\t\t\t\tborder: none;\n\t\t\t\ttransition: none;\n\t\t\t\t.anticon-home {\n\t\t\t\t\tmargin-right: 7px;\n\t\t\t\t}\n\t\t\t\t.ant-tabs-tab-remove {\n\t\t\t\t\tposition: absolute;\n\t\t\t\t\tright: 0;\n\t\t\t\t\tcolor: #cccccc;\n\t\t\t\t\topacity: 0;\n\t\t\t\t\ttransition: 0.1s ease-in-out;\n\t\t\t\t\t&:hover {\n\t\t\t\t\t\tcolor: @primary-color;\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t\t.ant-tabs-tab.ant-tabs-tab-with-remove {\n\t\t\t\t&:hover {\n\t\t\t\t\t.ant-tabs-tab-remove {\n\t\t\t\t\t\ttop: 1px;\n\t\t\t\t\t\tmargin: 7px;\n\t\t\t\t\t\topacity: 1;\n\t\t\t\t\t\ttransition: 0.1s ease-in-out;\n\t\t\t\t\t}\n\t\t\t\t\t.ant-tabs-tab-btn {\n\t\t\t\t\t\ttransform: translateX(-9px);\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\t.more-button {\n\t\tposition: absolute;\n\t\ttop: 8px;\n\t\tright: 13px;\n\t\tpadding-left: 10px;\n\t\tfont-size: 12px;\n\t}\n}\n\n/* tabs 超出显示的样式 */\n.ant-tabs-dropdown {\n\t.ant-tabs-dropdown-menu-item {\n\t\t.anticon-home {\n\t\t\tmargin-right: 7px;\n\t\t}\n\t}\n}\n\n/* tabs 不受全局组件大小影响 */\n.ant-tabs-small > .ant-tabs-nav .ant-tabs-tab,\n.ant-tabs-large > .ant-tabs-nav .ant-tabs-tab {\n\tpadding: 8px 22px !important;\n\tfont-size: 14px !important;\n}\n"
  },
  {
    "path": "src/layouts/components/Tabs/index.tsx",
    "content": "import { useEffect, useState } from \"react\";\nimport { connect } from \"react-redux\";\nimport { useLocation, useNavigate } from \"react-router-dom\";\nimport { HomeFilled } from \"@ant-design/icons\";\nimport { message, Tabs } from \"antd\";\n\nimport { HOME_URL } from \"@/config/config\";\nimport { setTabsList } from \"@/redux/modules/tabs/action\";\nimport { routerArray } from \"@/routers\";\nimport { searchRoute } from \"@/utils/util\";\nimport MoreButton from \"./components/MoreButton\";\n\nimport \"./index.less\";\n\nconst LayoutTabs = (props: any) => {\n\tconst { tabsList } = props.tabs;\n\tconst { themeConfig } = props.global;\n\tconst { setTabsList } = props;\n\tconst { TabPane } = Tabs;\n\tconst { pathname } = useLocation();\n\tconst navigate = useNavigate();\n\tconst [activeValue, setActiveValue] = useState<string>(pathname);\n\n\tuseEffect(() => {\n\t\taddTabs();\n\t}, [pathname]);\n\n\t// click tabs\n\tconst clickTabs = (path: string) => {\n\t\tnavigate(path);\n\t};\n\n\t// add tabs\n\tconst addTabs = () => {\n\t\tconst route = searchRoute(pathname, routerArray);\n\t\tlet newTabsList = JSON.parse(JSON.stringify(tabsList));\n\t\tif (tabsList.every((item: any) => item.path !== route.path)) {\n\t\t\tnewTabsList.push({ title: route.meta!.title, path: route.path });\n\t\t}\n\t\tsetTabsList(newTabsList);\n\t\tsetActiveValue(pathname);\n\t};\n\n\t// delete tabs\n\tconst delTabs = (tabPath?: string) => {\n\t\tif (tabPath === HOME_URL) return;\n\t\tif (pathname === tabPath) {\n\t\t\ttabsList.forEach((item: Menu.MenuOptions, index: number) => {\n\t\t\t\tif (item.path !== pathname) return;\n\t\t\t\tconst nextTab = tabsList[index + 1] || tabsList[index - 1];\n\t\t\t\tif (!nextTab) return;\n\t\t\t\tnavigate(nextTab.path);\n\t\t\t});\n\t\t}\n\t\tmessage.success(\"你删除了Tabs标签 😆😆😆\");\n\t\tsetTabsList(tabsList.filter((item: Menu.MenuOptions) => item.path !== tabPath));\n\t};\n\n\treturn (\n\t\t<>\n\t\t\t{!themeConfig.tabs && (\n\t\t\t\t<div className=\"tabs\">\n\t\t\t\t\t<Tabs\n\t\t\t\t\t\tanimated\n\t\t\t\t\t\tactiveKey={activeValue}\n\t\t\t\t\t\tonChange={clickTabs}\n\t\t\t\t\t\thideAdd\n\t\t\t\t\t\ttype=\"editable-card\"\n\t\t\t\t\t\tonEdit={path => {\n\t\t\t\t\t\t\tdelTabs(path as string);\n\t\t\t\t\t\t}}\n\t\t\t\t\t>\n\t\t\t\t\t\t{tabsList.map((item: Menu.MenuOptions) => {\n\t\t\t\t\t\t\treturn (\n\t\t\t\t\t\t\t\t<TabPane\n\t\t\t\t\t\t\t\t\tkey={item.path}\n\t\t\t\t\t\t\t\t\ttab={\n\t\t\t\t\t\t\t\t\t\t<span>\n\t\t\t\t\t\t\t\t\t\t\t{item.path == HOME_URL ? <HomeFilled /> : \"\"}\n\t\t\t\t\t\t\t\t\t\t\t{item.title}\n\t\t\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\tclosable={item.path !== HOME_URL}\n\t\t\t\t\t\t\t\t></TabPane>\n\t\t\t\t\t\t\t);\n\t\t\t\t\t\t})}\n\t\t\t\t\t</Tabs>\n\t\t\t\t\t<MoreButton tabsList={tabsList} delTabs={delTabs} setTabsList={setTabsList}></MoreButton>\n\t\t\t\t</div>\n\t\t\t)}\n\t\t</>\n\t);\n};\n\nconst mapStateToProps = (state: any) => state;\nconst mapDispatchToProps = { setTabsList };\nexport default connect(mapStateToProps, mapDispatchToProps)(LayoutTabs);\n"
  },
  {
    "path": "src/layouts/index.less",
    "content": ".container {\n\tdisplay: flex;\n\tmin-width: 950px;\n\theight: 100%;\n\tmax-width: 100%;\n\toverflow-x: hidden;\n\tbox-sizing: border-box;\n\t\n\t.ant-layout-sider {\n\t\tbox-sizing: border-box;\n\t\tflex-shrink: 0;\n\t}\n\t.ant-layout {\n\t\t/* 防止 tabs 超出不收缩 */\n\t\toverflow-x: hidden;\n\t\tmin-width: 0;\n\t\tflex: 1;\n\t\t\n\t\t.ant-layout-content {\n\t\t\tbox-sizing: border-box;\n\t\t\tflex: 1;\n\t\t\tpadding: 10px 12px;\n\t\t\toverflow-x: hidden;\n\t\t\tmax-width: 100%;\n\t\t}\n\n\t\t&-sider {\n\t\t\tbackground: #001529;\n\t\t}\n\t}\n\n\t// 平板和手机端适配\n\t@media (max-width: 768px) {\n\t\tmin-width: 100%;\n\t\t\n\t\t.ant-layout {\n\t\t\twidth: 100%;\n\t\t\tmin-width: 0;\n\t\t}\n\t\t\n\t\t.ant-layout-content {\n\t\t\tpadding: 8px 6px;\n\t\t}\n\t}\n\n\t@media (max-width: 480px) {\n\t\t.ant-layout-content {\n\t\t\tpadding: 4px 2px;\n\t\t}\n\t}\n}\n"
  },
  {
    "path": "src/layouts/index.tsx",
    "content": "import { useEffect } from \"react\";\nimport { connect } from \"react-redux\";\nimport { Outlet } from \"react-router-dom\";\nimport { Layout } from \"antd\";\n\nimport { updateCollapse } from \"@/redux/modules/menu/action\";\nimport LayoutFooter from \"./components/Footer\";\nimport LayoutHeader from \"./components/Header\";\nimport LayoutMenu from \"./components/Menu\";\nimport LayoutTabs from \"./components/Tabs\";\n\nimport \"./index.less\";\n\nconst LayoutIndex = (props: any) => {\n\tconst { Sider, Content } = Layout;\n\tconst { isCollapse, updateCollapse, setAuthButtons } = props;\n\n\t// 监听窗口大小变化\n\tconst listeningWindow = () => {\n\t\twindow.onresize = () => {\n\t\t\treturn (() => {\n\t\t\t\tlet screenWidth = document.body.clientWidth;\n\t\t\t\tif (!isCollapse && screenWidth < 1200) updateCollapse(true);\n\t\t\t\tif (!isCollapse && screenWidth > 1200) updateCollapse(false);\n\t\t\t})();\n\t\t};\n\t};\n\n\tuseEffect(() => {\n\t\tlisteningWindow();\n\t}, []);\n\n\treturn (\n\t\t// 这里不用 Layout 组件原因是切换页面时样式会先错乱然后在正常显示，造成页面闪屏效果\n\t\t<section className=\"container\">\n\t\t\t<Sider trigger={null} collapsed={props.isCollapse} width={220} theme=\"dark\">\n\t\t\t\t<LayoutMenu></LayoutMenu>\n\t\t\t</Sider>\n\t\t\t<Layout>\n\t\t\t\t<LayoutHeader></LayoutHeader>\n\t\t\t\t<LayoutTabs></LayoutTabs>\n\t\t\t\t<Content>\n\t\t\t\t\t<Outlet></Outlet>\n\t\t\t\t</Content>\n\t\t\t\t<LayoutFooter></LayoutFooter>\n\t\t\t</Layout>\n\t\t</section>\n\t);\n};\n\nconst mapStateToProps = (state: any) => state.menu;\nconst mapDispatchToProps = { updateCollapse };\nexport default connect(mapStateToProps, mapDispatchToProps)(LayoutIndex);\n"
  },
  {
    "path": "src/main.tsx",
    "content": "import React from \"react\";\nimport ReactDOM from \"react-dom/client\";\nimport { Provider } from \"react-redux\";\nimport { PersistGate } from \"redux-persist/integration/react\";\n\nimport App from \"@/App\";\nimport { persistor, store } from \"@/redux\";\n\nimport \"@/styles/reset.less\";\nimport \"@/assets/iconfont/iconfont.less\";\nimport \"@/assets/fonts/font.less\";\nimport \"@/styles/common.less\";\nimport \"virtual:svg-icons-register\";\n\nconst root = ReactDOM.createRoot(document.getElementById(\"root\")!);\nroot.render(\n\t<Provider store={store}>\n\t\t<PersistGate loading={null} persistor={persistor}>\n\t\t\t<App />\n\t\t</PersistGate>\n\t</Provider>\n);\n"
  },
  {
    "path": "src/redux/index.ts",
    "content": "/* eslint-disable simple-import-sort/imports */\n/* eslint-disable prettier/prettier */\nimport { Action, combineReducers, compose, legacy_createStore as createStore, Store } from \"redux\";\nimport { ThunkAction, ThunkDispatch } from 'redux-thunk';\nimport { applyMiddleware } from \"redux\";\nimport { persistReducer, persistStore } from \"redux-persist\";\nimport storage from \"redux-persist/lib/storage\";\nimport reduxPromise from \"redux-promise\";\nimport reduxThunk from \"redux-thunk\";\n\nimport auth from \"./modules/auth/reducer\";\nimport breadcrumb from \"./modules/breadcrumb/reducer\";\nimport disc from \"./modules/disc/reducer\";\nimport global from \"./modules/global/reducer\";\nimport menu from \"./modules/menu/reducer\";\nimport tabs from \"./modules/tabs/reducer\";\n\n// 创建reducer(拆分reducer)\nconst reducer = combineReducers({\n\tglobal,\n\tmenu,\n\ttabs,\n\tauth,\n\tbreadcrumb,\n\tdisc\n});\n\nexport type RootState = ReturnType<typeof reducer>;\n// 定义自定义的 thunk action 类型\nexport type AppThunk<ReturnType = void> = ThunkAction<\n  ReturnType,\n  RootState,\n  unknown,\n  Action<string>\n>;\n\n// 定义自定义的 dispatch 类型\nexport type AppDispatch = ThunkDispatch<RootState, unknown, Action<string>>;\n\n// redux 持久化配置\nconst persistConfig = {\n\tkey: \"redux-state\",\n\tstorage: storage\n};\nconst persistReducerConfig = persistReducer(persistConfig, reducer);\n\n// 开启 redux-devtools\nconst composeEnhancers = window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ || compose;\n\n// 使用 redux 中间件\nconst middleWares = applyMiddleware(reduxThunk, reduxPromise);\n\n// 创建 store\nconst store: Store = createStore(persistReducerConfig, composeEnhancers(middleWares));\n\n// 创建持久化 store\nconst persistor = persistStore(store);\n\nexport { persistor, store };\n"
  },
  {
    "path": "src/redux/interface/index.ts",
    "content": "import type { SizeType } from \"antd/lib/config-provider/SizeContext\";\n\nimport { MapItem } from \"@/typings/common\";\n\n/* themeConfigProp */\nexport interface ThemeConfigProp {\n\tprimary: string;\n\tisDark: boolean;\n\tweakOrGray: string;\n\tbreadcrumb: boolean;\n\ttabs: boolean;\n\tfooter: boolean;\n}\n\n/* GlobalState */\nexport interface GlobalState {\n\ttoken: string;\n\tuserInfo: any;\n\tassemblySize: SizeType;\n\tthemeConfig: ThemeConfigProp;\n}\n\n/* MenuState */\nexport interface MenuState {\n\tisCollapse: boolean;\n\tmenuList: Menu.MenuOptions[];\n}\n\n/* TabsState */\nexport interface TabsState {\n\ttabsActive: string;\n\ttabsList: Menu.MenuOptions[];\n}\n\n/* BreadcrumbState */\nexport interface BreadcrumbState {\n\tbreadcrumbList: {\n\t\t[propName: string]: any;\n\t};\n}\n\n/* AuthState */\nexport interface AuthState {\n\tauthButtons: {\n\t\t[propName: string]: any;\n\t};\n\tauthRouter: string[];\n}\n\n/**discState */\nexport interface DiscState {\n\tdisc: MapItem[];\n}\n"
  },
  {
    "path": "src/redux/modules/auth/action.ts",
    "content": "import * as types from \"@/redux/mutation-types\";\n\n// * setAuthButtons\nexport const setAuthButtons = (authButtons: { [propName: string]: any }) => ({\n\ttype: types.SET_AUTH_BUTTONS,\n\tauthButtons\n});\n\n// * setAuthRouter\nexport const setAuthRouter = (authRouter: string[]) => ({\n\ttype: types.SET_AUTH_ROUTER,\n\tauthRouter\n});\n"
  },
  {
    "path": "src/redux/modules/auth/reducer.ts",
    "content": "import produce from \"immer\";\nimport { AnyAction } from \"redux\";\n\nimport { AuthState } from \"@/redux/interface\";\nimport * as types from \"@/redux/mutation-types\";\n\nconst authState: AuthState = {\n\tauthButtons: {},\n\tauthRouter: []\n};\n\n// auth reducer\nconst auth = (state: AuthState = authState, action: AnyAction) =>\n\tproduce(state, draftState => {\n\t\tswitch (action.type) {\n\t\t\tcase types.SET_AUTH_BUTTONS:\n\t\t\t\tdraftState.authButtons = action.authButtons;\n\t\t\t\tbreak;\n\t\t\tcase types.SET_AUTH_ROUTER:\n\t\t\t\tdraftState.authRouter = action.authRouter;\n\t\t\t\tbreak;\n\t\t\tdefault:\n\t\t\t\treturn draftState;\n\t\t}\n\t});\n\nexport default auth;\n"
  },
  {
    "path": "src/redux/modules/breadcrumb/action.ts",
    "content": "import * as types from \"@/redux/mutation-types\";\n\n// * setBreadcrumbList\nexport const setBreadcrumbList = (breadcrumbList: { [propName: string]: any }) => ({\n\ttype: types.SET_BREADCRUMB_LIST,\n\tbreadcrumbList\n});\n"
  },
  {
    "path": "src/redux/modules/breadcrumb/reducer.ts",
    "content": "import produce from \"immer\";\nimport { AnyAction } from \"redux\";\n\nimport { BreadcrumbState } from \"@/redux/interface\";\nimport * as types from \"@/redux/mutation-types\";\n\nconst breadcrumbState: BreadcrumbState = {\n\tbreadcrumbList: {}\n};\n\n// breadcrumb reducer\nconst breadcrumb = (state: BreadcrumbState = breadcrumbState, action: AnyAction) =>\n\tproduce(state, draftState => {\n\t\tswitch (action.type) {\n\t\t\tcase types.SET_BREADCRUMB_LIST:\n\t\t\t\tdraftState.breadcrumbList = action.breadcrumbList;\n\t\t\t\tbreak;\n\t\t\tdefault:\n\t\t\t\treturn draftState;\n\t\t}\n\t});\n\nexport default breadcrumb;\n"
  },
  {
    "path": "src/redux/modules/disc/action.ts",
    "content": "import { toPairs } from \"lodash\";\nimport { Dispatch } from \"redux\";\n\nimport { getDiscListApi } from \"@/api/modules/common\";\nimport * as types from \"@/redux/mutation-types\";\n\n// * setDiscList\n// export const setDiscList = discList => ({\n// \ttype: types.UPDATE_DISC,\n// \tdiscList\n// });\n// * redux-promise《async/await》\nconst dictTransform = (dict = {}, keys = [\"id\", \"title\"]) => {\n\tconsole.log(\"字典 d\", dict);\n\n\treturn toPairs(dict).map(item => {\n\t\treturn {\n\t\t\t[keys[0]]: item[0],\n\t\t\t[keys[1]]: item[1]\n\t\t};\n\t});\n};\n\n// * redux-thunk\n// 获取字典数据\n// 异步 action creator\nexport const getDiscListAction = () => {\n\treturn async (dispatch: Dispatch) => {\n\t\tconst { result } = (await getDiscListApi()) || {};\n\t\tconsole.log(\"获取字典，getDiscListAction\");\n\n\t\tlet dictionaryMap = {};\n\t\tfor (const key in result as object) {\n\t\t\tif (Object.getOwnPropertyDescriptor(result, key)) {\n\t\t\t\t// @ts-ignore\n\t\t\t\tdictionaryMap[key] = result[key];\n\t\t\t\t// @ts-ignore\n\t\t\t\tdictionaryMap[`${key}List`] = dictTransform(result[key], [\"value\", \"label\"]);\n\t\t\t}\n\t\t}\n\n\t\tconsole.log(\"字典\", dictionaryMap);\n\n\t\t// 分发 action 更新 state\n\t\tdispatch({\n\t\t\ttype: types.UPDATE_DISC,\n\t\t\tdiscList: dictionaryMap\n\t\t});\n\t};\n};\n"
  },
  {
    "path": "src/redux/modules/disc/reducer.ts",
    "content": "import produce from \"immer\";\nimport { AnyAction } from \"redux\";\n\nimport { DiscState } from \"@/redux/interface\";\nimport * as types from \"@/redux/mutation-types\";\n\nconst discState: DiscState = {\n\tdisc: []\n};\n\n// disc reducer\nconst disc = (state: DiscState = discState, action: AnyAction) =>\n\tproduce(state, draftState => {\n\t\tswitch (action.type) {\n\t\t\tcase types.UPDATE_DISC:\n\t\t\t\tdraftState.disc = action.discList;\n\t\t\t\tbreak;\n\t\t\tdefault:\n\t\t\t\treturn draftState;\n\t\t}\n\t});\n\nexport default disc;\n"
  },
  {
    "path": "src/redux/modules/global/action.ts",
    "content": "import { ThemeConfigProp } from \"@/redux/interface/index\";\nimport * as types from \"@/redux/mutation-types\";\nimport { MapItem } from \"@/typings/common\";\n\n// * setToken\nexport const setToken = (token: string) => ({\n\ttype: types.SET_TOKEN,\n\ttoken\n});\n\nexport const setUserInfo = (userInfo: MapItem) => {\n\treturn {\n\t\ttype: types.USER_INFO,\n\t\tuserInfo\n\t};\n};\n\n// * setAssemblySize\nexport const setAssemblySize = (assemblySize: string) => ({\n\ttype: types.SET_ASSEMBLY_SIZE,\n\tassemblySize\n});\n\n// * setThemeConfig\nexport const setThemeConfig = (themeConfig: ThemeConfigProp) => ({\n\ttype: types.SET_THEME_CONFIG,\n\tthemeConfig\n});\n"
  },
  {
    "path": "src/redux/modules/global/reducer.ts",
    "content": "import produce from \"immer\";\nimport { AnyAction } from \"redux\";\n\nimport { GlobalState } from \"@/redux/interface\";\nimport * as types from \"@/redux/mutation-types\";\n\nconst globalState: GlobalState = {\n\ttoken: \"\",\n\tuserInfo: {},\n\tassemblySize: \"middle\",\n\tthemeConfig: {\n\t\t// 默认 primary 主题颜色\n\t\tprimary: \"#1890ff\",\n\t\t// 深色模式\n\t\tisDark: false,\n\t\t// 色弱模式(weak) || 灰色模式(gray)\n\t\tweakOrGray: \"\",\n\t\t// 面包屑导航\n\t\tbreadcrumb: true,\n\t\t// 标签页\n\t\ttabs: true,\n\t\t// 页脚\n\t\tfooter: true\n\t}\n};\n\n// global reducer\nconst global = (state: GlobalState = globalState, action: AnyAction) => {\n\tconsole.log({ action });\n\n\treturn produce(state, draftState => {\n\t\tswitch (action.type) {\n\t\t\tcase types.SET_TOKEN:\n\t\t\t\tdraftState.token = action.token;\n\t\t\t\tbreak;\n\t\t\tcase types.USER_INFO:\n\t\t\t\tdraftState.userInfo = action.userInfo;\n\t\t\t\tbreak;\n\t\t\tcase types.SET_ASSEMBLY_SIZE:\n\t\t\t\tdraftState.assemblySize = action.assemblySize;\n\t\t\t\tbreak;\n\t\t\tcase types.SET_THEME_CONFIG:\n\t\t\t\tdraftState.themeConfig = action.themeConfig;\n\t\t\t\tbreak;\n\t\t\tdefault:\n\t\t\t\treturn draftState;\n\t\t}\n\t});\n};\nexport default global;\n"
  },
  {
    "path": "src/redux/modules/menu/action.ts",
    "content": "import { Dispatch } from \"react\";\n\nimport { getMenuList } from \"@/api/modules/login\";\nimport * as types from \"@/redux/mutation-types\";\n\n// * updateCollapse\nexport const updateCollapse = (isCollapse: boolean) => ({\n\ttype: types.UPDATE_COLLAPSE,\n\tisCollapse\n});\n\n// * setMenuList\nexport const setMenuList = (menuList: Menu.MenuOptions[]) => ({\n\ttype: types.SET_MENU_LIST,\n\tmenuList\n});\n\n// ? 下面方法仅为测试使用，不参与任何功能开发\ninterface MenuProps {\n\ttype: string;\n\tmenuList: Menu.MenuOptions[];\n}\n// * redux-thunk\nexport const getMenuListActionThunk = () => {\n\treturn async (dispatch: Dispatch<MenuProps>) => {\n\t\tconst res = await getMenuList();\n\t\tdispatch({\n\t\t\ttype: types.SET_MENU_LIST,\n\t\t\tmenuList: (res.data as Menu.MenuOptions[]) ?? []\n\t\t});\n\t};\n};\n\n// * redux-promise《async/await》\nexport const getMenuListAction = async (): Promise<MenuProps> => {\n\tconst res = await getMenuList();\n\treturn {\n\t\ttype: types.SET_MENU_LIST,\n\t\tmenuList: res.data ? res.data : []\n\t};\n};\n\n// * redux-promise《.then/.catch》\nexport const getMenuListActionPromise = (): Promise<MenuProps> => {\n\treturn getMenuList().then(res => {\n\t\treturn {\n\t\t\ttype: types.SET_MENU_LIST,\n\t\t\tmenuList: res.data ? res.data : []\n\t\t};\n\t});\n};\n"
  },
  {
    "path": "src/redux/modules/menu/reducer.ts",
    "content": "import produce from \"immer\";\nimport { AnyAction } from \"redux\";\n\nimport { MenuState } from \"@/redux/interface\";\nimport * as types from \"@/redux/mutation-types\";\n\nconst menuState: MenuState = {\n\tisCollapse: false,\n\tmenuList: []\n};\n\n// menu reducer\nconst menu = (state: MenuState = menuState, action: AnyAction) =>\n\tproduce(state, draftState => {\n\t\tswitch (action.type) {\n\t\t\tcase types.UPDATE_COLLAPSE:\n\t\t\t\tdraftState.isCollapse = action.isCollapse;\n\t\t\t\tbreak;\n\t\t\tcase types.SET_MENU_LIST:\n\t\t\t\tdraftState.menuList = action.menuList;\n\t\t\t\tbreak;\n\t\t\tdefault:\n\t\t\t\treturn draftState;\n\t\t}\n\t});\n\nexport default menu;\n"
  },
  {
    "path": "src/redux/modules/tabs/action.ts",
    "content": "import * as types from \"@/redux/mutation-types\";\n\n// * setTabsList\nexport const setTabsList = (tabsList: Menu.MenuOptions[]) => ({\n\ttype: types.SET_TABS_LIST,\n\ttabsList\n});\n\n// * setTabsActive\nexport const setTabsActive = (tabsActive: string) => ({\n\ttype: types.SET_TABS_ACTIVE,\n\ttabsActive\n});\n"
  },
  {
    "path": "src/redux/modules/tabs/reducer.ts",
    "content": "import produce from \"immer\";\nimport { AnyAction } from \"redux\";\n\nimport { HOME_URL } from \"@/config/config\";\nimport { TabsState } from \"@/redux/interface\";\nimport * as types from \"@/redux/mutation-types\";\n\nconst tabsState: TabsState = {\n\t// tabsActive 其实没啥用，使用 pathname 就可以了😂\n\ttabsActive: HOME_URL,\n\ttabsList: [{ title: \"首页\", path: HOME_URL }]\n};\n\n// tabs reducer\nconst tabs = (state: TabsState = tabsState, action: AnyAction) =>\n\tproduce(state, draftState => {\n\t\tswitch (action.type) {\n\t\t\tcase types.SET_TABS_LIST:\n\t\t\t\tdraftState.tabsList = action.tabsList;\n\t\t\t\tbreak;\n\t\t\tcase types.SET_TABS_ACTIVE:\n\t\t\t\tdraftState.tabsActive = action.tabsActive;\n\t\t\t\tbreak;\n\t\t\tdefault:\n\t\t\t\treturn draftState;\n\t\t}\n\t});\n\nexport default tabs;\n"
  },
  {
    "path": "src/redux/mutation-types.ts",
    "content": "// 更新 menu 折叠状态\nexport const UPDATE_COLLAPSE = \"UPDATE_ASIDE_COLLAPSE\";\n// 设置 menuList\nexport const SET_MENU_LIST = \"SET_MENU_LIST\";\n// 设置 tabsList\nexport const SET_TABS_LIST = \"SET_TABS_LIST\";\n// 设置 tabsActive\nexport const SET_TABS_ACTIVE = \"SET_TABS_ACTIVE\";\n// 设置 breadcrumb\nexport const SET_BREADCRUMB_LIST = \"SET_BREADCRUMB_LIST\";\n// 设置 authButtons\nexport const SET_AUTH_BUTTONS = \"SET_AUTH_BUTTONS\";\n// 设置 authRouter\nexport const SET_AUTH_ROUTER = \"SET_AUTH_ROUTER\";\n// 设置 token\nexport const SET_TOKEN = \"SET_TOKEN\";\n// 设置 userInfo\nexport const USER_INFO = \"USER_INFO\";\n// 设置 assemblySize\nexport const SET_ASSEMBLY_SIZE = \"SET_ASSEMBLY_SIZE\";\n// 设置 setThemeConfig\nexport const SET_THEME_CONFIG = \"SET_THEME_CONFIG\";\n// 设置字典值\nexport const UPDATE_DISC = \"UPDATE_DISC\";\n"
  },
  {
    "path": "src/routers/constant.tsx",
    "content": "import Layout from \"@/layouts/index\";\n/**\n * @description: default layout\n */\nexport const LayoutIndex = () => <Layout />;\n"
  },
  {
    "path": "src/routers/index.tsx",
    "content": "import { Navigate, useRoutes } from \"react-router-dom\";\n\nimport { RouteObject } from \"@/routers/interface\";\nimport Login from \"@/views/login/index\";\n\n// * 导入所有router\nconst metaRouters = import.meta.globEager(\"./modules/*.tsx\") as Record<string, Record<string, RouteObject[]>>;\nconsole.log(\"metaRouters\", metaRouters);\n\n// * 处理路由\nexport const routerArray: RouteObject[] = [];\n\nObject.keys(metaRouters).forEach(item => {\n\tconsole.log(\"item\", item);\n\tconst router = metaRouters[item];\n\n\tObject.keys(router).forEach((key: any) => {\n\t\tconsole.log(\"key\", key);\n\n\t\trouterArray.push(...router[key]);\n\t});\n});\n\nexport const rootRouter: RouteObject[] = [\n\t{\n\t\tpath: \"/\",\n\t\telement: <Navigate to=\"/login\" />\n\t},\n\t{\n\t\tpath: \"/login\",\n\t\telement: <Login />,\n\t\tmeta: {\n\t\t\ttitle: \"登录页\",\n\t\t\tkey: \"login\"\n\t\t}\n\t},\n\t...routerArray,\n\t{\n\t\tpath: \"*\",\n\t\telement: <Navigate to=\"/404\" />\n\t}\n];\n\nconst Router = () => {\n\tconst routes = useRoutes(rootRouter);\n\tconsole.log(\"routes\", { routes });\n\n\treturn routes;\n};\n\nexport default Router;\n"
  },
  {
    "path": "src/routers/interface/index.ts",
    "content": "export interface MetaProps {\n\tkeepAlive?: boolean;\n\trequiresAuth?: boolean;\n\ttitle: string;\n\tkey?: string;\n}\n\nexport interface RouteObject {\n\tcaseSensitive?: boolean;\n\tchildren?: RouteObject[];\n\telement?: React.ReactNode;\n\tindex?: false;\n\tpath?: string;\n\tmeta?: MetaProps;\n\tisLink?: string;\n}\n"
  },
  {
    "path": "src/routers/modules/aiConfig.tsx",
    "content": "import { LayoutIndex } from \"@/routers/constant\";\nimport { RouteObject } from \"@/routers/interface\";\nimport AiConfigPage from \"@/views/aiConfig\";\n\nconst aiConfigRouter: Array<RouteObject> = [\n\t{\n\t\telement: <LayoutIndex />,\n\t\tchildren: [\n\t\t\t{\n\t\t\t\tpath: \"/ai/config/index\",\n\t\t\t\telement: <AiConfigPage />,\n\t\t\t\tmeta: {\n\t\t\t\t\ttitle: \"AI模型配置\",\n\t\t\t\t\tkey: \"ai-config\"\n\t\t\t\t}\n\t\t\t}\n\t\t]\n\t}\n];\n\nexport default aiConfigRouter;\n"
  },
  {
    "path": "src/routers/modules/article.tsx",
    "content": "import React from \"react\";\n\nimport { LayoutIndex } from \"@/routers/constant\";\nimport { RouteObject } from \"@/routers/interface\";\nimport Article from \"@/views/article/list\";\nimport lazyLoad from \"../utils/lazyLoad\";\n\nconst articleRouter: Array<RouteObject> = [\n\t{\n\t\telement: <LayoutIndex />,\n\t\tchildren: [\n\t\t\t{\n\t\t\t\tpath: \"/article\",\n\t\t\t\tmeta: {\n\t\t\t\t\t// requiresAuth: true,\n\t\t\t\t\ttitle: \"文章\",\n\t\t\t\t\tkey: \"/article\"\n\t\t\t\t},\n\t\t\t\tchildren: [\n\t\t\t\t\t{\n\t\t\t\t\t\tpath: \"/article/list/index\",\n\t\t\t\t\t\telement: lazyLoad(React.lazy(() => import(\"@/views/article/list/index\"))),\n\t\t\t\t\t\tmeta: {\n\t\t\t\t\t\t\ttitle: \"文章列表\",\n\t\t\t\t\t\t\tkey: \"/article/list/index\"\n\t\t\t\t\t\t}\n\t\t\t\t\t},\n\t\t\t\t\t{\n\t\t\t\t\t\tpath: \"/article/edit/index\",\n\t\t\t\t\t\telement: lazyLoad(React.lazy(() => import(\"@/views/article/edit/index\"))),\n\t\t\t\t\t\tmeta: {\n\t\t\t\t\t\t\ttitle: \"文章编辑\",\n\t\t\t\t\t\t\tkey: \"/article/edit/index\"\n\t\t\t\t\t\t}\n\t\t\t\t\t},\n\t\t\t\t\t{\n\t\t\t\t\t\tpath: \"/article/comment/index\",\n\t\t\t\t\t\telement: lazyLoad(React.lazy(() => import(\"@/views/comment/index\"))),\n\t\t\t\t\t\tmeta: {\n\t\t\t\t\t\t\ttitle: \"评论管理\",\n\t\t\t\t\t\t\tkey: \"/article/comment/index\"\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t]\n\t\t\t}\n\t\t]\n\t}\n];\n\nexport default articleRouter;\n"
  },
  {
    "path": "src/routers/modules/author.tsx",
    "content": "import React from \"react\";\n\nimport { LayoutIndex } from \"@/routers/constant\";\nimport { RouteObject } from \"@/routers/interface\";\nimport lazyLoad from \"../utils/lazyLoad\";\n\nconst columnRouter: Array<RouteObject> = [\n\t{\n\t\telement: <LayoutIndex />,\n\t\tchildren: [\n\t\t\t{\n\t\t\t\tpath: \"/author\",\n\t\t\t\tmeta: {\n\t\t\t\t\t// requiresAuth: true,\n\t\t\t\t\ttitle: \"用户管理\",\n\t\t\t\t\tkey: \"/author\"\n\t\t\t\t},\n\t\t\t\tchildren: [\n\t\t\t\t\t{\n\t\t\t\t\t\tpath: \"/author/whitelist/index\",\n\t\t\t\t\t\telement: lazyLoad(React.lazy(() => import(\"@/views/author/whitelist/index\"))),\n\t\t\t\t\t\tmeta: {\n\t\t\t\t\t\t\ttitle: \"作者白名单\",\n\t\t\t\t\t\t\tkey: \"/author/whitelist/index\"\n\t\t\t\t\t\t}\n\t\t\t\t\t},\n\t\t\t\t\t{\n\t\t\t\t\t\tpath: \"/author/zsxqlist/index\",\n\t\t\t\t\t\telement: lazyLoad(React.lazy(() => import(\"@/views/author/zsxqlist/index\"))),\n\t\t\t\t\t\tmeta: {\n\t\t\t\t\t\t\ttitle: \"星球白名单\",\n\t\t\t\t\t\t\tkey: \"/author/zsxqlist/index\"\n\t\t\t\t\t\t}\n\t\t\t\t\t},\n\t\t\t\t\t{\n\t\t\t\t\t\tpath: \"/author/login-audit/index\",\n\t\t\t\t\t\telement: lazyLoad(React.lazy(() => import(\"@/views/author/loginAudit/index\"))),\n\t\t\t\t\t\tmeta: {\n\t\t\t\t\t\t\ttitle: \"登录审计\",\n\t\t\t\t\t\t\tkey: \"/author/login-audit/index\"\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t]\n\t\t\t}\n\t\t]\n\t}\n];\n\nexport default columnRouter;\n"
  },
  {
    "path": "src/routers/modules/category.tsx",
    "content": "import { LayoutIndex } from \"@/routers/constant\";\nimport { RouteObject } from \"@/routers/interface\";\nimport Sort from \"@/views/category\";\n\nconst sortRouter: Array<RouteObject> = [\n\t{\n\t\telement: <LayoutIndex />,\n\t\tchildren: [\n\t\t\t{\n\t\t\t\tpath: \"/category/index\",\n\t\t\t\telement: <Sort />,\n\t\t\t\tmeta: {\n\t\t\t\t\t// requiresAuth: true,\n\t\t\t\t\ttitle: \"分类\",\n\t\t\t\t\tkey: \"sort\"\n\t\t\t\t}\n\t\t\t}\n\t\t]\n\t}\n];\n\nexport default sortRouter;\n"
  },
  {
    "path": "src/routers/modules/column.tsx",
    "content": "import React from \"react\";\n\nimport { LayoutIndex } from \"@/routers/constant\";\nimport { RouteObject } from \"@/routers/interface\";\nimport lazyLoad from \"../utils/lazyLoad\";\n\nconst columnRouter: Array<RouteObject> = [\n\t{\n\t\telement: <LayoutIndex />,\n\t\tchildren: [\n\t\t\t{\n\t\t\t\tpath: \"/column\",\n\t\t\t\tmeta: {\n\t\t\t\t\t// requiresAuth: true,\n\t\t\t\t\ttitle: \"专栏\",\n\t\t\t\t\tkey: \"/column\"\n\t\t\t\t},\n\t\t\t\tchildren: [\n\t\t\t\t\t{\n\t\t\t\t\t\tpath: \"/column/setting/index\",\n\t\t\t\t\t\telement: lazyLoad(React.lazy(() => import(\"@/views/column/setting/index\"))),\n\t\t\t\t\t\tmeta: {\n\t\t\t\t\t\t\ttitle: \"专栏设置\",\n\t\t\t\t\t\t\tkey: \"/column/setting/index\"\n\t\t\t\t\t\t}\n\t\t\t\t\t},\n\t\t\t\t\t{\n\t\t\t\t\t\tpath: \"/column/setting/index/articlesort\",\n\t\t\t\t\t\telement: lazyLoad(React.lazy(() => import(\"@/views/column/setting/articlesort/index\"))),\n\t\t\t\t\t\tmeta: {\n\t\t\t\t\t\t\ttitle: \"教程排序\",\n\t\t\t\t\t\t\tkey: \"/column/setting/index/articlesort\"\n\t\t\t\t\t\t}\n\t\t\t\t\t},\n\t\t\t\t\t{\n\t\t\t\t\t\tpath: \"/column/setting/index/groups\",\n\t\t\t\t\t\telement: lazyLoad(React.lazy(() => import(\"@/views/column/setting/groups/index\"))),\n\t\t\t\t\t\tmeta: {\n\t\t\t\t\t\t\ttitle: \"专栏分组\",\n\t\t\t\t\t\t\tkey: \"/column/setting/index/groups\"\n\t\t\t\t\t\t}\n\t\t\t\t\t},\n\t\t\t\t\t{\n\t\t\t\t\t\tpath: \"/column/article/index\",\n\t\t\t\t\t\telement: lazyLoad(React.lazy(() => import(\"@/views/column/article/index\"))),\n\t\t\t\t\t\tmeta: {\n\t\t\t\t\t\t\ttitle: \"添加教程\",\n\t\t\t\t\t\t\tkey: \"/column/article/index\"\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t]\n\t\t\t}\n\t\t]\n\t}\n];\n\nexport default columnRouter;\n"
  },
  {
    "path": "src/routers/modules/config.tsx",
    "content": "import { LayoutIndex } from \"@/routers/constant\";\nimport { RouteObject } from \"@/routers/interface\";\nimport Banner from \"@/views/config\";\n\nconst configRouter: Array<RouteObject> = [\n\t{\n\t\telement: <LayoutIndex />,\n\t\tchildren: [\n\t\t\t{\n\t\t\t\tpath: \"/config/index\",\n\t\t\t\telement: <Banner />,\n\t\t\t\tmeta: {\n\t\t\t\t\t// requiresAuth: true,\n\t\t\t\t\ttitle: \"配置\",\n\t\t\t\t\tkey: \"config\"\n\t\t\t\t}\n\t\t\t}\n\t\t]\n\t}\n];\n\nexport default configRouter;\n"
  },
  {
    "path": "src/routers/modules/error.tsx",
    "content": "import React from \"react\";\n\nimport { RouteObject } from \"@/routers/interface\";\nimport lazyLoad from \"@/routers/utils/lazyLoad\";\n\n// 错误页面模块\nconst errorRouter: Array<RouteObject> = [\n\t{\n\t\tpath: \"/403\",\n\t\telement: lazyLoad(React.lazy(() => import(\"@/components/ErrorMessage/403\"))),\n\t\tmeta: {\n\t\t\t// requiresAuth: true,\n\t\t\ttitle: \"403页面\",\n\t\t\tkey: \"403\"\n\t\t}\n\t},\n\t{\n\t\tpath: \"/404\",\n\t\telement: lazyLoad(React.lazy(() => import(\"@/components/ErrorMessage/404\"))),\n\t\tmeta: {\n\t\t\t// requiresAuth: false,\n\t\t\ttitle: \"404页面\",\n\t\t\tkey: \"404\"\n\t\t}\n\t},\n\t{\n\t\tpath: \"/500\",\n\t\telement: lazyLoad(React.lazy(() => import(\"@/components/ErrorMessage/500\"))),\n\t\tmeta: {\n\t\t\t// requiresAuth: false,\n\t\t\ttitle: \"500页面\",\n\t\t\tkey: \"500\"\n\t\t}\n\t}\n];\n\nexport default errorRouter;\n"
  },
  {
    "path": "src/routers/modules/global.tsx",
    "content": "import { LayoutIndex } from \"@/routers/constant\";\nimport { RouteObject } from \"@/routers/interface\";\nimport Banner from \"@/views/global\";\n\nconst configRouter: Array<RouteObject> = [\n\t{\n\t\telement: <LayoutIndex />,\n\t\tchildren: [\n\t\t\t{\n\t\t\t\tpath: \"/global/index\",\n\t\t\t\telement: <Banner />,\n\t\t\t\tmeta: {\n\t\t\t\t\t// requiresAuth: true,\n\t\t\t\t\ttitle: \"全局\",\n\t\t\t\t\tkey: \"global\"\n\t\t\t\t}\n\t\t\t}\n\t\t]\n\t}\n];\n\nexport default configRouter;\n"
  },
  {
    "path": "src/routers/modules/home.tsx",
    "content": "import { LayoutIndex } from \"@/routers/constant\";\nimport { RouteObject } from \"@/routers/interface\";\nimport Home from \"@/views/home\";\n\n// 首页模块\nconst homeRouter: Array<RouteObject> = [\n\t{\n\t\telement: <LayoutIndex />,\n\t\tchildren: [\n\t\t\t{\n\t\t\t\tpath: \"/home/index\",\n\t\t\t\telement: <Home />,\n\t\t\t\tmeta: {\n\t\t\t\t\t// requiresAuth: true,\n\t\t\t\t\ttitle: \"首页\",\n\t\t\t\t\tkey: \"home\"\n\t\t\t\t}\n\t\t\t}\n\t\t]\n\t}\n];\n\nexport default homeRouter;\n"
  },
  {
    "path": "src/routers/modules/resume.tsx",
    "content": "import { LayoutIndex } from \"@/routers/constant\";\nimport { RouteObject } from \"@/routers/interface\";\nimport Label from \"@/views/resume\";\n\nconst labelRouter: Array<RouteObject> = [\n\t{\n\t\telement: <LayoutIndex />,\n\t\tchildren: [\n\t\t\t{\n\t\t\t\tpath: \"/resume/index\",\n\t\t\t\telement: <Label />,\n\t\t\t\tmeta: {\n\t\t\t\t\t// requiresAuth: true,\n\t\t\t\t\ttitle: \"简历\",\n\t\t\t\t\tkey: \"resume\"\n\t\t\t\t}\n\t\t\t}\n\t\t]\n\t}\n];\n\nexport default labelRouter;\n"
  },
  {
    "path": "src/routers/modules/sensitive.tsx",
    "content": "import { LayoutIndex } from \"@/routers/constant\";\nimport { RouteObject } from \"@/routers/interface\";\nimport Sensitive from \"@/views/sensitive\";\n\nconst sensitiveRouter: Array<RouteObject> = [\n\t{\n\t\telement: <LayoutIndex />,\n\t\tchildren: [\n\t\t\t{\n\t\t\t\tpath: \"/sensitive/index\",\n\t\t\t\telement: <Sensitive />,\n\t\t\t\tmeta: {\n\t\t\t\t\ttitle: \"敏感词管理\",\n\t\t\t\t\tkey: \"sensitive\"\n\t\t\t\t}\n\t\t\t}\n\t\t]\n\t}\n];\n\nexport default sensitiveRouter;\n"
  },
  {
    "path": "src/routers/modules/statistics.tsx",
    "content": "import { LayoutIndex } from \"@/routers/constant\";\nimport { RouteObject } from \"@/routers/interface\";\nimport Statistics from \"@/views/statistics\";\n\nconst statisticsRouter: Array<RouteObject> = [\n\t{\n\t\telement: <LayoutIndex />,\n\t\tchildren: [\n\t\t\t{\n\t\t\t\tpath: \"/statistics/index\",\n\t\t\t\telement: <Statistics />,\n\t\t\t\tmeta: {\n\t\t\t\t\t// requiresAuth: true,\n\t\t\t\t\ttitle: \"数据统计\",\n\t\t\t\t\tkey: \"statistics\"\n\t\t\t\t}\n\t\t\t}\n\t\t]\n\t}\n];\n\nexport default statisticsRouter;\n"
  },
  {
    "path": "src/routers/modules/tag.tsx",
    "content": "import { LayoutIndex } from \"@/routers/constant\";\nimport { RouteObject } from \"@/routers/interface\";\nimport Label from \"@/views/tag\";\n\nconst labelRouter: Array<RouteObject> = [\n\t{\n\t\telement: <LayoutIndex />,\n\t\tchildren: [\n\t\t\t{\n\t\t\t\tpath: \"/tag/index\",\n\t\t\t\telement: <Label />,\n\t\t\t\tmeta: {\n\t\t\t\t\t// requiresAuth: true,\n\t\t\t\t\ttitle: \"标签\",\n\t\t\t\t\tkey: \"tag\"\n\t\t\t\t}\n\t\t\t}\n\t\t]\n\t}\n];\n\nexport default labelRouter;\n"
  },
  {
    "path": "src/routers/modules/wxMenu.tsx",
    "content": "import { LayoutIndex } from \"@/routers/constant\";\nimport { RouteObject } from \"@/routers/interface\";\nimport WxMenuPage from \"@/views/wxMenu\";\n\nconst wxMenuRouter: Array<RouteObject> = [\n\t{\n\t\telement: <LayoutIndex />,\n\t\tchildren: [\n\t\t\t{\n\t\t\t\tpath: \"/wx/menu/index\",\n\t\t\t\telement: <WxMenuPage />,\n\t\t\t\tmeta: {\n\t\t\t\t\ttitle: \"微信配置\",\n\t\t\t\t\tkey: \"wx-menu\"\n\t\t\t\t}\n\t\t\t}\n\t\t]\n\t}\n];\n\nexport default wxMenuRouter;\n"
  },
  {
    "path": "src/routers/route.tsx",
    "content": "/* eslint-disable prettier/prettier */\nimport {\n\tAlertOutlined,\n\tApiOutlined,\n\tBarsOutlined,\n\tCalendarOutlined,\n\tFileAddOutlined,\n\tFilePptOutlined,\n\tFileTextOutlined,\n\tLineChartOutlined,\n\tMessageOutlined,\n\tReadOutlined,\n\tSettingOutlined,\n\tSmileOutlined,\n\tTagsOutlined,\n\tUserOutlined,\n\tWechatOutlined\n} from \"@ant-design/icons\";\n\nexport const currentMenuList = [\n\t{ key: \"/statistics/index\", icon: <LineChartOutlined />, children: undefined, label: \"数据统计\" },\n\t{ key: \"/config/index\", icon: <CalendarOutlined />, children: undefined, label: \"运营配置\" },\n\t{ key: \"/global/index\", icon: <SettingOutlined />, children: undefined, label: \"全局配置\" },\n\t{ key: \"/sensitive/index\", icon: <AlertOutlined />, children: undefined, label: \"敏感词管理\" },\n\t{ key: \"/ai/config/index\", icon: <ApiOutlined />, children: undefined, label: \"AI模型配置\" },\n\t{ key: \"/wx/menu/index\", icon: <WechatOutlined />, children: undefined, label: \"微信配置\" },\n\t{ key: \"/category/index\", icon: <BarsOutlined />, children: undefined, label: \"分类管理\" },\n\t{ key: \"/tag/index\", icon: <TagsOutlined />, children: undefined, label: \"标签管理\" },\n\t{ key: \"/resume/index\", icon: <FileTextOutlined />, children: undefined, label: \"简历管理\" },\n\t{\n\t\tkey: \"/article\",\n\t\ticon: <ReadOutlined />,\n\t\tchildren: [\n\t\t\t{ key: \"/article/list/index\", icon: <FilePptOutlined />, children: undefined, label: \"文章列表\" },\n\t\t\t{ key: \"/article/edit/index\", icon: <FileAddOutlined />, children: undefined, label: \"文章编辑\" },\n\t\t\t{ key: \"/article/comment/index\", icon: <MessageOutlined />, children: undefined, label: \"评论管理\" }\n\t\t],\n\t\tlabel: \"文章管理\"\n\t},\n\t{\n\t\tkey: \"/author\",\n\t\ticon: <UserOutlined />,\n\t\tchildren: [\n\t\t\t{ key: \"/author/whitelist/index\", icon: <SmileOutlined />, children: undefined, label: \"作者白名单\" },\n\t\t\t{ key: \"/author/zsxqlist/index\", icon: <SmileOutlined />, children: undefined, label: \"星球白名单\" },\n\t\t\t{ key: \"/author/login-audit/index\", icon: <SmileOutlined />, children: undefined, label: \"登录审计\" }\n\t\t],\n\t\tlabel: \"用户管理\"\n\t},\n\t{\n\t\tkey: \"/column\",\n\t\ticon: <ReadOutlined />,\n\t\tchildren: [\n\t\t\t{ key: \"/column/setting/index\", icon: <FilePptOutlined />, children: undefined, label: \"专栏配置\" },\n\t\t\t{ key: \"/column/article/index\", icon: <FileAddOutlined />, children: undefined, label: \"教程配置\" }\n\t\t],\n\t\tlabel: \"教程管理\"\n\t}\n];\n"
  },
  {
    "path": "src/routers/utils/authRouter.tsx",
    "content": "// 路由权限控制\n\nimport { Navigate, useLocation } from \"react-router-dom\";\n\nimport { AxiosCanceler } from \"@/api/helper/axiosCancel\";\nimport { HOME_URL, LOGIN_URL } from \"@/config/config\";\nimport { store } from \"@/redux/index\";\nimport { rootRouter } from \"@/routers/index\";\nimport { searchRoute } from \"@/utils/util\";\n\nconst axiosCanceler = new AxiosCanceler();\n\n/**\n * @description 路由守卫组件\n * */\nconst AuthRouter = (props: { children: JSX.Element }) => {\n\tconst { pathname } = useLocation();\n\tconst route = searchRoute(pathname, rootRouter);\n\tconsole.log({ route });\n\n\t// * 在跳转路由之前，清除所有的请求\n\taxiosCanceler.removeAllPending();\n\tconsole.log({ props });\n\n\t// * 判断当前路由是否需要访问权限(不需要权限直接放行)\n\tif (!route.meta?.requiresAuth) return props.children;\n\n\t// * 判断是否有Token\n\tconst token = store.getState().global.token;\n\tif (!token) return <Navigate to={LOGIN_URL} replace />;\n\n\t// * Dynamic Router(动态路由，根据后端返回的菜单数据生成的一维数组)\n\tconst dynamicRouter = store.getState().auth.authRouter;\n\t// * Static Router(静态路由，必须配置首页地址，否则不能进首页获取菜单、按钮权限等数据)，获取数据的时候会loading，所有配置首页地址也没问题\n\tconst staticRouter = [HOME_URL, \"/403\"];\n\tconst routerList = dynamicRouter.concat(staticRouter);\n\t// * 如果访问的地址没有在路由表中重定向到403页面\n\tif (routerList.indexOf(pathname) == -1) return <Navigate to=\"/403\" />;\n\n\t// * 当前账号有权限返回 Router，正常访问页面\n\treturn props.children;\n};\n\nexport default AuthRouter;\n"
  },
  {
    "path": "src/routers/utils/lazyLoad.tsx",
    "content": "import React, { Suspense } from \"react\";\nimport { Spin } from \"antd\";\n\n/**\n * @description 路由懒加载\n * @param {Element} Comp 需要访问的组件\n * @returns element\n */\nconst lazyLoad = (Comp: React.LazyExoticComponent<any>): React.ReactNode => {\n\treturn (\n\t\t<Suspense\n\t\t\tfallback={\n\t\t\t\t<Spin\n\t\t\t\t\tsize=\"large\"\n\t\t\t\t\tstyle={{\n\t\t\t\t\t\tdisplay: \"flex\",\n\t\t\t\t\t\talignItems: \"center\",\n\t\t\t\t\t\tjustifyContent: \"center\",\n\t\t\t\t\t\theight: \"100%\"\n\t\t\t\t\t}}\n\t\t\t\t/>\n\t\t\t}\n\t\t>\n\t\t\t<Comp />\n\t\t</Suspense>\n\t);\n};\n\nexport default lazyLoad;\n"
  },
  {
    "path": "src/styles/common.less",
    "content": "/* 常用 flex */\n.flx-center {\n\tdisplay: flex;\n\talign-items: center;\n\tjustify-content: center;\n}\n.flx-justify-between {\n\tdisplay: flex;\n\talign-items: center;\n\tjustify-content: space-between;\n}\n.flx-align-center {\n\tdisplay: flex;\n\talign-items: center;\n}\n\n/* 清除浮动 */\n.clearfix::after {\n\tdisplay: block;\n\theight: 0;\n\toverflow: hidden;\n\tclear: both;\n\tcontent: \"\";\n}\n\n/* 文字单行省略号 */\n.sle {\n\toverflow: hidden;\n\ttext-overflow: ellipsis;\n\twhite-space: nowrap;\n}\n\n/* 文字多行省略号 */\n.mle {\n\tdisplay: -webkit-box;\n\toverflow: hidden;\n\t-webkit-box-orient: vertical;\n\t-webkit-line-clamp: 2;\n}\n\n/* 文字多了自動換行 */\n.break-word {\n\tword-break: break-all;\n\tword-wrap: break-word;\n}\n\n/* page switch animation */\n.fade-enter {\n\topacity: 0;\n\ttransform: translateX(-30px);\n}\n.fade-enter-active,\n.fade-exit-active {\n\topacity: 1;\n\ttransition: all 0.2s ease-out;\n\ttransform: translateX(0);\n}\n.fade-exit {\n\topacity: 0;\n\ttransform: translateX(30px);\n}\n\n/* scroll bar */\n::-webkit-scrollbar {\n\twidth: 8px;\n\theight: 8px;\n\tbackground-color: #ffffff;\n}\n::-webkit-scrollbar-thumb {\n\tbackground-color: #dddee0;\n\tborder-radius: 20px;\n\tbox-shadow: inset 0 0 0 #ffffff;\n}\n\n/* card 卡片样式 */\n.card {\n\tbox-sizing: border-box;\n\tpadding: 20px;\n\toverflow-x: hidden;\n\tborder: 1px solid #e4e7ed;\n\tborder-radius: 4px;\n}\n\n/* content-box */\n.content-box {\n\tdisplay: flex;\n\tflex-direction: column;\n\twidth: 100%;\n\theight: 100%;\n\t.text {\n\t\tmargin: 30px 0;\n\t\tfont-size: 23px;\n\t\tfont-weight: 700;\n\t\ttext-align: center;\n\t\ta {\n\t\t\ttext-decoration: underline !important;\n\t\t}\n\t}\n}\n"
  },
  {
    "path": "src/styles/reset.less",
    "content": "/* Reset style sheet */\nhtml,\nbody,\ndiv,\nspan,\napplet,\nobject,\niframe,\nh1,\nh2,\nh3,\nh4,\nh5,\nh6,\np,\nblockquote,\npre,\na,\nabbr,\nacronym,\naddress,\nbig,\ncite,\ncode,\ndel,\ndfn,\nem,\nimg,\nins,\nkbd,\nq,\ns,\nsamp,\nsmall,\nstrike,\nstrong,\nsub,\nsup,\ntt,\nvar,\nb,\nu,\ni,\ncenter,\ndl,\ndt,\ndd,\nol,\nul,\nli,\nfieldset,\nform,\nlabel,\nlegend,\ntable,\ncaption,\ntbody,\ntfoot,\nthead,\ntr,\nth,\ntd,\narticle,\naside,\ncanvas,\ndetails,\nembed,\nfigure,\nfigcaption,\nfooter,\nheader,\nhgroup,\nmenu,\nnav,\noutput,\nruby,\nsection,\nsummary,\ntime,\nmark,\naudio,\nvideo {\n\tpadding: 0;\n\tmargin: 0;\n\tfont: inherit;\n\tfont-size: 100%;\n\tvertical-align: baseline;\n\tborder: 0;\n}\n\n/* HTML5 display-role reset for older browsers */\narticle,\naside,\ndetails,\nfigcaption,\nfigure,\nfooter,\nheader,\nhgroup,\nmenu,\nnav,\nsection {\n\tdisplay: block;\n}\nbody {\n\tpadding: 0;\n\tmargin: 0;\n}\nol,\nul {\n\tlist-style: none;\n}\nblockquote,\nq {\n\tquotes: none;\n}\nblockquote::before,\nblockquote::after,\nq::before,\nq::after {\n\tcontent: \"\";\n\tcontent: none;\n}\ntable {\n\tborder-spacing: 0;\n\tborder-collapse: collapse;\n}\nhtml,\nbody,\n#root {\n\twidth: 100%;\n\theight: 100%;\n}\n"
  },
  {
    "path": "src/styles/theme/theme-dark.less",
    "content": "\n\n/* 自定义 antd 暗黑模式样式 */\n@dark-main-bg-color: #141414;\n@dark-bg-color: #1f1f1f;\n@dark-border-color: #414243;\n@dark-text-color: #d9d9d9;\n@dark-shadow-color: 5px 5px 15px rgb(255 255 255 / 20%);\n@dark-scrollbar-bg-color: #686868;\n\n/* 需要自定义覆盖的样式 */\nbody {\n\tbackground-color: @dark-main-bg-color !important;\n\t// guide\n\t#driver-highlighted-element-stage {\n\t\tbackground-color: #525457 !important;\n\t}\n}\n\n/* login container（先固定样式） */\n.login-container {\n\tbackground-color: @dark-main-bg-color !important;\n\t.login-box {\n\t\tbackground-color: rgb(0 0 0 / 80%) !important;\n\t\t.login-form {\n\t\t\tbackground-color: #000 !important;\n\t\t\tbox-shadow: @dark-shadow-color !important;\n\t\t\t.login-logo {\n\t\t\t\t.logo-text {\n\t\t\t\t\tcolor: @dark-text-color !important;\n\t\t\t\t}\n\t\t\t}\n\t\t\t.login-btn {\n\t\t\t\t.ant-btn-default {\n\t\t\t\t\tcolor: @dark-text-color !important;\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n}\n\n/* container */\n.container {\n\t/* sider */\n\t.ant-layout-sider {\n\t\tborder-right: 1px solid @dark-border-color !important;\n\t\t.ant-menu {\n\t\t\t&::-webkit-scrollbar {\n\t\t\t\tbackground-color: @dark-bg-color !important;\n\t\t\t}\n\t\t\t&::-webkit-scrollbar-thumb {\n\t\t\t\tbackground-color: @dark-scrollbar-bg-color !important;\n\t\t\t}\n\t\t}\n\t\t.logo-box {\n\t\t\tborder-bottom: 1px solid @dark-border-color !important;\n\t\t}\n\t}\n\n\t/* layout */\n\t.ant-layout {\n\t\tbackground-color: @dark-main-bg-color !important;\n\t\t.ant-layout-header,\n\t\t.tabs,\n\t\t.footer,\n\t\t.card {\n\t\t\tbackground-color: @dark-bg-color !important;\n\t\t\tborder-color: @dark-border-color !important;\n\t\t\t.text {\n\t\t\t\tcolor: @dark-text-color !important;\n\t\t\t}\n\t\t}\n\t\t.ant-layout-header {\n\t\t\theight: 55px;\n\t\t\tpadding: 0 40px 0 20px;\n\t\t\t.icon-style,\n\t\t\t.username {\n\t\t\t\tcolor: @dark-text-color !important;\n\t\t\t}\n\t\t}\n\t\t.footer {\n\t\t\ta {\n\t\t\t\tcolor: @dark-text-color !important;\n\t\t\t}\n\t\t}\n\t\t.ant-layout-content {\n\t\t\t&::-webkit-scrollbar {\n\t\t\t\tbackground-color: @dark-main-bg-color !important;\n\t\t\t}\n\t\t\t&::-webkit-scrollbar-thumb {\n\t\t\t\tbackground-color: @dark-scrollbar-bg-color !important;\n\t\t\t}\n\t\t\t.card {\n\t\t\t\t&::-webkit-scrollbar {\n\t\t\t\t\tbackground-color: @dark-bg-color !important;\n\t\t\t\t}\n\t\t\t\t&::-webkit-scrollbar-thumb {\n\t\t\t\t\tbackground-color: @dark-scrollbar-bg-color !important;\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n}\n"
  },
  {
    "path": "src/styles/theme/theme-default.less",
    "content": "\n\n/* 自定义 antd 默认样式 */\n@light-bg-color: #ffffff;\n@light-main-bg-color: #f0f2f5;\n@light-border-color: #e4e7ed;\n@light-border-header-color: #f6f6f6;\n@light-text-color: rgba(0, 0, 0, 0.85);\n@light-shadow-color: 0 0 12px #0000000d;\n@light-scrollbar-bg-color: #dddee0;\n\n/* 需要自定义覆盖的样式 */\nbody {\n\tbackground-color: @light-bg-color !important;\n\t#driver-highlighted-element-stage {\n\t\tbackground-color: #fff !important;\n\t}\n}\n\n/* login container（先固定样式） */\n.login-container {\n\tbackground-color: #eeeeee !important;\n\t.login-box {\n\t\tbackground-color: hsl(0deg 0% 100% / 80%) !important;\n\t\t.login-form {\n\t\t\tbackground-color: #000 !important;\n\t\t\tbox-shadow: 2px 3px 7px rgb(0 0 0 / 20%) !important;\n\t\t\t.login-logo {\n\t\t\t\t.logo-text {\n\t\t\t\t\tcolor: #475768 !important;\n\t\t\t\t}\n\t\t\t}\n\t\t\t.login-btn {\n\t\t\t\t.ant-btn-default {\n\t\t\t\t\tcolor: #606266 !important;\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n}\n\n/* container */\n.container {\n\t/* sider */\n\t.ant-layout-sider {\n\t\tborder-right: 1px solid @light-border-color !important;\n\t\t.ant-menu {\n\t\t\t&::-webkit-scrollbar {\n\t\t\t\tbackground-color: #001529 !important;\n\t\t\t}\n\t\t\t&::-webkit-scrollbar-thumb {\n\t\t\t\tbackground-color: #41444b !important;\n\t\t\t}\n\t\t}\n\t\t.logo-box {\n\t\t\tborder-bottom: 1px solid #010b14 !important;\n\t\t}\n\t}\n\n\t/* layout */\n\t.ant-layout {\n\t\tbackground-color: @light-main-bg-color !important;\n\t\t.tabs,\n\t\t.footer,\n\t\t.card {\n\t\t\tbackground-color: @light-bg-color !important;\n\t\t\tborder-color: @light-border-color !important;\n\t\t}\n\t\t.ant-layout-header {\n\t\t\theight: 55px;\n\t\t\tpadding: 0 40px 0 20px;\n\t\t\tbackground-color: @light-bg-color !important;\n\t\t\tborder-color: @light-border-header-color !important;\n\t\t\t.icon-style,\n\t\t\t.username {\n\t\t\t\tcolor: @light-text-color !important;\n\t\t\t}\n\t\t}\n\t\t.footer {\n\t\t\ta {\n\t\t\t\tcolor: @light-text-color !important;\n\t\t\t}\n\t\t}\n\t\t.card {\n\t\t\tbox-shadow: @light-shadow-color !important;\n\t\t\t.text {\n\t\t\t\tcolor: #585858 !important;\n\t\t\t}\n\t\t}\n\t\t.ant-layout-content {\n\t\t\t&::-webkit-scrollbar {\n\t\t\t\tbackground-color: @light-main-bg-color !important;\n\t\t\t}\n\t\t\t&::-webkit-scrollbar-thumb {\n\t\t\t\tbackground-color: @light-scrollbar-bg-color !important;\n\t\t\t}\n\t\t\t.card {\n\t\t\t\t&::-webkit-scrollbar {\n\t\t\t\t\tbackground-color: @light-bg-color !important;\n\t\t\t\t}\n\t\t\t\t&::-webkit-scrollbar-thumb {\n\t\t\t\t\tbackground-color: @light-scrollbar-bg-color !important;\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n}\n"
  },
  {
    "path": "src/styles/var.less",
    "content": "/* Global definition less */\n@primary-color: #1890ff;\n"
  },
  {
    "path": "src/typings/common.ts",
    "content": "export interface MapItem {\n\t[key: string]: any;\n}\n"
  },
  {
    "path": "src/typings/global.d.ts",
    "content": "// * Menu\ndeclare namespace Menu {\n\tinterface MenuOptions {\n\t\tpath: string;\n\t\ttitle: string;\n\t\ticon?: string;\n\t\tisLink?: string;\n\t\tclose?: boolean;\n\t\tchildren?: MenuOptions[];\n\t}\n}\n\n// * Vite\ndeclare type Recordable<T = any> = Record<string, T>;\n\ndeclare interface ViteEnv {\n\tVITE_API_URL: string;\n\tVITE_DOMAIN: string;\n\tVITE_PORT: number;\n\tVITE_OPEN: boolean;\n\tVITE_GLOB_APP_TITLE: string;\n\tVITE_DROP_CONSOLE: boolean;\n\tVITE_PROXY_URL: string;\n\tVITE_BUILD_GZIP: boolean;\n\tVITE_REPORT: boolean;\n}\n\n// * Dropdown MenuInfo\ndeclare interface MenuInfo {\n\tkey: string;\n\tkeyPath: string[];\n\t/** @deprecated This will not support in future. You should avoid to use this */\n\titem: React.ReactInstance;\n\tdomEvent: React.MouseEvent<HTMLElement> | React.KeyboardEvent<HTMLElement>;\n}\n"
  },
  {
    "path": "src/typings/plugins.d.ts",
    "content": "declare module \"qs\";\ndeclare module \"nprogress\";\ndeclare module \"js-md5\";\ndeclare module \"react-transition-group\";\n"
  },
  {
    "path": "src/typings/window.d.ts",
    "content": "// * global\ndeclare global {\n\tinterface Window {\n\t\t__REDUX_DEVTOOLS_EXTENSION_COMPOSE__: any;\n\t}\n\tinterface Navigator {\n\t\tmsSaveOrOpenBlob: (blob: Blob, fileName: string) => void;\n\t}\n}\nexport {};\n"
  },
  {
    "path": "src/utils/getEnv.ts",
    "content": "import dotenv from \"dotenv\";\nimport fs from \"fs\";\nimport path from \"path\";\n\nexport function isDevFn(mode: string): boolean {\n\treturn mode === \"development\";\n}\n\nexport function isProdFn(mode: string): boolean {\n\treturn mode === \"production\";\n}\n\n/**\n * Whether to generate package preview\n */\nexport function isReportMode(): boolean {\n\treturn process.env.VITE_REPORT === \"true\";\n}\n\n// Read all environment variable configuration files to process.env\nexport function wrapperEnv(envConf: Recordable): ViteEnv {\n\tconst ret: any = {};\n\n\tfor (const envName of Object.keys(envConf)) {\n\t\tlet realName = envConf[envName].replace(/\\\\n/g, \"\\n\");\n\t\trealName = realName === \"true\" ? true : realName === \"false\" ? false : realName;\n\n\t\tif (envName === \"VITE_PORT\") {\n\t\t\trealName = Number(realName);\n\t\t}\n\t\tif (envName === \"VITE_PROXY\") {\n\t\t\ttry {\n\t\t\t\trealName = JSON.parse(realName);\n\t\t\t} catch (error) {\n\t\t\t\tconsole.log(error);\n\t\t\t}\n\t\t}\n\t\tret[envName] = realName;\n\t\tprocess.env[envName] = realName;\n\t}\n\treturn ret;\n}\n\n/**\n * Get the environment variables starting with the specified prefix\n * @param match prefix\n * @param confFiles ext\n */\nexport function getEnvConfig(match = \"VITE_GLOB_\", confFiles = [\".env\", \".env.production\"]) {\n\tlet envConfig = {};\n\tconfFiles.forEach(item => {\n\t\ttry {\n\t\t\tconst env = dotenv.parse(fs.readFileSync(path.resolve(process.cwd(), item)));\n\t\t\tenvConfig = { ...envConfig, ...env };\n\t\t} catch (error) {\n\t\t\tconsole.error(`Error in parsing ${item}`, error);\n\t\t}\n\t});\n\n\tObject.keys(envConfig).forEach(key => {\n\t\tconst reg = new RegExp(`^(${match})`);\n\t\tif (!reg.test(key)) {\n\t\t\tReflect.deleteProperty(envConfig, key);\n\t\t}\n\t});\n\treturn envConfig;\n}\n\n/**\n * Get user root directory\n * @param dir file path\n */\nexport function getRootPath(...dir: string[]) {\n\treturn path.resolve(process.cwd(), ...dir);\n}\n"
  },
  {
    "path": "src/utils/is/index.ts",
    "content": "const toString = Object.prototype.toString;\n\n/**\n * @description: 判断值是否未某个类型\n */\nexport function is(val: unknown, type: string) {\n\treturn toString.call(val) === `[object ${type}]`;\n}\n\n/**\n * @description:  是否为函数\n */\nexport function isFunction<T = Function>(val: unknown): val is T {\n\treturn is(val, \"Function\");\n}\n\n/**\n * @description: 是否已定义\n */\nexport const isDef = <T = unknown>(val?: T): val is T => {\n\treturn typeof val !== \"undefined\";\n};\n\nexport const isUnDef = <T = unknown>(val?: T): val is T => {\n\treturn !isDef(val);\n};\n/**\n * @description: 是否为对象\n */\nexport const isObject = (val: any): val is Record<any, any> => {\n\treturn val !== null && is(val, \"Object\");\n};\n\n/**\n * @description:  是否为时间\n */\nexport function isDate(val: unknown): val is Date {\n\treturn is(val, \"Date\");\n}\n\n/**\n * @description:  是否为数值\n */\nexport function isNumber(val: unknown): val is number {\n\treturn is(val, \"Number\");\n}\n\n/**\n * @description:  是否为AsyncFunction\n */\nexport function isAsyncFunction<T = any>(val: unknown): val is Promise<T> {\n\treturn is(val, \"AsyncFunction\");\n}\n\n/**\n * @description:  是否为promise\n */\nexport function isPromise<T = any>(val: unknown): val is Promise<T> {\n\treturn is(val, \"Promise\") && isObject(val) && isFunction(val.then) && isFunction(val.catch);\n}\n\n/**\n * @description:  是否为字符串\n */\nexport function isString(val: unknown): val is string {\n\treturn is(val, \"String\");\n}\n\n/**\n * @description:  是否为boolean类型\n */\nexport function isBoolean(val: unknown): val is boolean {\n\treturn is(val, \"Boolean\");\n}\n\n/**\n * @description:  是否为数组\n */\nexport function isArray(val: any): val is Array<any> {\n\treturn val && Array.isArray(val);\n}\n\n/**\n * @description: 是否客户端\n */\nexport const isClient = () => {\n\treturn typeof window !== \"undefined\";\n};\n\n/**\n * @description: 是否为浏览器\n */\nexport const isWindow = (val: any): val is Window => {\n\treturn typeof window !== \"undefined\" && is(val, \"Window\");\n};\n\nexport const isElement = (val: unknown): val is Element => {\n\treturn isObject(val) && !!val.tagName;\n};\n\nexport const isServer = typeof window === \"undefined\";\n\n// 是否为图片节点\nexport function isImageDom(o: Element) {\n\treturn o && [\"IMAGE\", \"IMG\"].includes(o.tagName);\n}\n\nexport function isNull(val: unknown): val is null {\n\treturn val === null;\n}\n\nexport function isNullAndUnDef(val: unknown): val is null | undefined {\n\treturn isUnDef(val) && isNull(val);\n}\n\nexport function isNullOrUnDef(val: unknown): val is null | undefined {\n\treturn isUnDef(val) || isNull(val);\n}\n"
  },
  {
    "path": "src/utils/util.ts",
    "content": "import { RouteObject } from \"@/routers/interface\";\n\n/**\n * @description 获取当前域名\n */\nexport const baseDomain = import.meta.env.VITE_DOMAIN;\n\n/**\n * @description 获取localStorage\n * @param {String} key Storage名称\n * @return string\n */\nexport const localGet = (key: string) => {\n\tconst value = window.localStorage.getItem(key);\n\ttry {\n\t\treturn JSON.parse(window.localStorage.getItem(key) as string);\n\t} catch (error) {\n\t\treturn value;\n\t}\n};\n\n/**\n * @description 存储localStorage\n * @param {String} key Storage名称\n * @param {Any} value Storage值\n * @return void\n */\nexport const localSet = (key: string, value: any) => {\n\twindow.localStorage.setItem(key, JSON.stringify(value));\n};\n\n/**\n * @description 清除localStorage\n * @param {String} key Storage名称\n * @return void\n */\nexport const localRemove = (key: string) => {\n\twindow.localStorage.removeItem(key);\n};\n\n/**\n * @description 清除所有localStorage\n * @return void\n */\nexport const localClear = () => {\n\twindow.localStorage.clear();\n};\n\n/**\n * @description 获取需要展开的 subMenu\n * @param {String} path 当前访问地址\n * @returns array\n */\nexport const getOpenKeys = (path: string) => {\n\tlet newStr: string = \"\";\n\tlet newArr: any[] = [];\n\tlet arr = path.split(\"/\").map(i => \"/\" + i);\n\tfor (let i = 1; i < arr.length - 1; i++) {\n\t\tnewStr += arr[i];\n\t\tnewArr.push(newStr);\n\t}\n\treturn newArr;\n};\n\n/**\n * @description 递归查询对应的路由\n * @param {String} path 当前访问地址\n * @param {Array} routes 路由列表\n * @returns array\n */\nexport const searchRoute = (path: string, routes: RouteObject[] = []): RouteObject => {\n\tlet result: RouteObject = {};\n\tfor (let item of routes) {\n\t\tif (item.path === path) return item;\n\t\tif (item.children) {\n\t\t\tconst res = searchRoute(path, item.children);\n\t\t\tif (Object.keys(res).length) result = res;\n\t\t}\n\t}\n\treturn result;\n};\n\n/**\n * @description 递归当前路由的 所有 关联的路由，生成面包屑导航栏\n * @param {String} path 当前访问地址\n * @param {Array} menuList 菜单列表\n * @returns array\n */\nexport const getBreadcrumbList = (path: string, menuList: Menu.MenuOptions[]) => {\n\tlet tempPath: any[] = [];\n\ttry {\n\t\tconst getNodePath = (node: Menu.MenuOptions) => {\n\t\t\ttempPath.push(node);\n\t\t\t// 找到符合条件的节点，通过throw终止掉递归\n\t\t\tif (node.path === path) {\n\t\t\t\tthrow new Error(\"GOT IT!\");\n\t\t\t}\n\t\t\tif (node.children && node.children.length > 0) {\n\t\t\t\tfor (let i = 0; i < node.children.length; i++) {\n\t\t\t\t\tgetNodePath(node.children[i]);\n\t\t\t\t}\n\t\t\t\t// 当前节点的子节点遍历完依旧没找到，则删除路径中的该节点\n\t\t\t\ttempPath.pop();\n\t\t\t} else {\n\t\t\t\t// 找到叶子节点时，删除路径当中的该叶子节点\n\t\t\t\ttempPath.pop();\n\t\t\t}\n\t\t};\n\t\tfor (let i = 0; i < menuList.length; i++) {\n\t\t\tgetNodePath(menuList[i]);\n\t\t}\n\t} catch (e) {\n\t\treturn tempPath.map(item => item.title);\n\t}\n};\n\n/**\n * @description 双重递归 找出所有 面包屑 生成对象存到 redux 中，就不用每次都去递归查找了\n * @param {String} menuList 当前菜单列表\n * @returns object\n */\nexport const findAllBreadcrumb = (menuList: Menu.MenuOptions[]): { [key: string]: any } => {\n\tlet handleBreadcrumbList: any = {};\n\tconst loop = (menuItem: Menu.MenuOptions) => {\n\t\t// 下面判断代码解释 *** !item?.children?.length   ==>   (item.children && item.children.length > 0)\n\t\tif (menuItem?.children?.length) menuItem.children.forEach(item => loop(item));\n\t\telse handleBreadcrumbList[menuItem.path] = getBreadcrumbList(menuItem.path, menuList);\n\t};\n\tmenuList.forEach(item => loop(item));\n\treturn handleBreadcrumbList;\n};\n\n/**\n * @description 使用递归处理路由菜单，生成一维数组，做菜单权限判断\n * @param {Array} menuList 所有菜单列表\n * @param {Array} newArr 菜单的一维数组\n * @return array\n */\nexport function handleRouter(routerList: Menu.MenuOptions[], newArr: string[] = []) {\n\trouterList.forEach((item: Menu.MenuOptions) => {\n\t\ttypeof item === \"object\" && item.path && newArr.push(item.path);\n\t\titem.children && item.children.length && handleRouter(item.children, newArr);\n\t});\n\treturn newArr;\n}\n\n/**\n * @description 判断数据类型\n * @param {Any} val 需要判断类型的数据\n * @return string\n */\nexport const isType = (val: any) => {\n\tif (val === null) return \"null\";\n\tif (typeof val !== \"object\") return typeof val;\n\telse return Object.prototype.toString.call(val).slice(8, -1).toLocaleLowerCase();\n};\n\n/**\n * @description 对象数组深克隆\n * @param {Object} obj 源对象\n * @return object\n */\nexport const deepCopy = <T>(obj: any): T => {\n\tlet newObj: any;\n\ttry {\n\t\tnewObj = obj.push ? [] : {};\n\t} catch (error) {\n\t\tnewObj = {};\n\t}\n\tfor (let attr in obj) {\n\t\tif (typeof obj[attr] === \"object\") {\n\t\t\tnewObj[attr] = deepCopy(obj[attr]);\n\t\t} else {\n\t\t\tnewObj[attr] = obj[attr];\n\t\t}\n\t}\n\treturn newObj;\n};\n\n/**\n * @description 生成随机数\n * @param {Number} min 最小值\n * @param {Number} max 最大值\n * @return number\n */\nexport function randomNum(min: number, max: number): number {\n\tlet num = Math.floor(Math.random() * (min - max) + max);\n\treturn num;\n}\n\nexport const getCompleteUrl = (partialUrl: string | undefined) => {\n\t// 域名，展示图片的时候用\n\tlet completeUrl = partialUrl;\n\n\t// 如果partialUrl为绝对路径，直接返回\n\tif (partialUrl && partialUrl.indexOf(\"http\") === -1 && partialUrl.indexOf(\"https\") === -1) {\n\t\tcompleteUrl = baseDomain + partialUrl;\n\t}\n\treturn completeUrl;\n};\n"
  },
  {
    "path": "src/views/aiConfig/index.scss",
    "content": ".ai-config-page {\n\t&__toolbar {\n\t\tdisplay: flex;\n\t\talign-items: center;\n\t\tjustify-content: space-between;\n\t\tgap: 12px;\n\t\tmargin-bottom: 16px;\n\t}\n\n\t&__toolbar-meta {\n\t\tmax-width: 760px;\n\t}\n\n\t&__toolbar-actions {\n\t\tflex-shrink: 0;\n\t}\n\n\t&__section-grid {\n\t\tdisplay: grid;\n\t\tgrid-template-columns: repeat(2, minmax(0, 1fr));\n\t\tgap: 16px;\n\t\tmargin-top: 16px;\n\t}\n\n\t&__section-card {\n\t\theight: 100%;\n\t}\n\n\t&__sub-card {\n\t\theight: 100%;\n\t\tborder-radius: 10px;\n\t\tbackground: #fafafa;\n\t}\n\n\t&__sub-card-title {\n\t\tfont-size: 15px;\n\t\tfont-weight: 600;\n\t\tcolor: #1f1f1f;\n\t}\n\n\t&__source-group {\n\t\twidth: 100%;\n\t}\n\n\t&__source-option {\n\t\tdisplay: flex;\n\t\talign-items: center;\n\t\tmin-height: 32px;\n\t}\n\n\t&__test-result {\n\t\tpadding-top: 8px;\n\t}\n\n\t&__test-result-text {\n\t\tmargin-top: 8px;\n\t\tmargin-bottom: 0;\n\t\tpadding: 12px;\n\t\tborder-radius: 8px;\n\t\tbackground: #fafafa;\n\t\twhite-space: pre-wrap;\n\t\tword-break: break-word;\n\t}\n\n\t&__number-input,\n\t.ant-input-number {\n\t\twidth: 100%;\n\t}\n}\n\n@media (max-width: 1200px) {\n\t.ai-config-page {\n\t\t&__section-grid {\n\t\t\tgrid-template-columns: 1fr;\n\t\t}\n\t}\n}\n\n@media (max-width: 768px) {\n\t.ai-config-page {\n\t\t&__toolbar {\n\t\t\tflex-direction: column;\n\t\t\talign-items: flex-start;\n\t\t}\n\n\t\t&__toolbar-actions {\n\t\t\twidth: 100%;\n\t\t}\n\t}\n}\n"
  },
  {
    "path": "src/views/aiConfig/index.tsx",
    "content": "import { FC, useEffect, useMemo, useState } from \"react\";\nimport { ReloadOutlined, SaveOutlined } from \"@ant-design/icons\";\nimport {\n\tAlert,\n\tButton,\n\tCard,\n\tCheckbox,\n\tCol,\n\tDivider,\n\tForm,\n\tInput,\n\tInputNumber,\n\tmessage,\n\tModal,\n\tRow,\n\tSpace,\n\tTypography\n} from \"antd\";\n\nimport {\n\tAiConfigAdminDTO,\n\tAiConfigAdminReq,\n\tAISourceValue,\n\tgetAiConfigDetailApi,\n\tsaveAiConfigApi,\n\ttestAiConfigApi\n} from \"@/api/modules/aiConfig\";\nimport { ContentInterWrap, ContentWrap } from \"@/components/common-wrap\";\n\nimport \"./index.scss\";\n\nconst { Paragraph, Text, Title } = Typography;\n\ninterface AiConfigFormValues {\n\tsources: AISourceValue[];\n\tzhipu: {\n\t\tapiSecretKey: string;\n\t\trequestIdTemplate: string;\n\t\tmodel: string;\n\t};\n\tzhipuCoding: {\n\t\tapiKey: string;\n\t\tapiHost: string;\n\t\tmodel: string;\n\t\ttimeout?: number | null;\n\t};\n\txunFei: {\n\t\thostUrl: string;\n\t\tdomain: string;\n\t\tappId: string;\n\t\tapiKey: string;\n\t\tapiSecret: string;\n\t\tapiPassword: string;\n\t};\n\tdeepSeek: {\n\t\tapiKey: string;\n\t\tapiHost: string;\n\t\tmodel: string;\n\t\ttimeout?: number | null;\n\t};\n\tdoubao: {\n\t\tapiKey: string;\n\t\tapiHost: string;\n\t\tendPoint: string;\n\t};\n\tali: {\n\t\tmodel: string;\n\t};\n}\n\nconst AI_SOURCE_OPTIONS: Array<{ label: string; value: AISourceValue }> = [\n\t{ label: \"技术派默认\", value: \"PAI_AI\" },\n\t{ label: \"智谱\", value: \"ZHI_PU_AI\" },\n\t{ label: \"智谱 Coding\", value: \"ZHIPU_CODING\" },\n\t{ label: \"讯飞\", value: \"XUN_FEI_AI\" },\n\t{ label: \"阿里\", value: \"ALI_AI\" },\n\t{ label: \"DeepSeek\", value: \"DEEP_SEEK\" },\n\t{ label: \"豆包\", value: \"DOU_BAO_AI\" }\n];\nconst REMOVED_SOURCE_VALUES: AISourceValue[] = [\"CHAT_GPT_3_5\", \"CHAT_GPT_4\"];\nconst AI_SOURCE_LABEL_MAP = AI_SOURCE_OPTIONS.reduce<Record<AISourceValue, string>>((result, item) => {\n\tresult[item.value] = item.label;\n\treturn result;\n}, {} as Record<AISourceValue, string>);\nconst AI_TEST_PROMPT = \"你正在执行后台 AI 配置连通性测试。请忽略其他上下文，只输出“连接成功”。\";\n\nconst defaultFormValues: AiConfigFormValues = {\n\tsources: [],\n\tzhipu: {\n\t\tapiSecretKey: \"\",\n\t\trequestIdTemplate: \"\",\n\t\tmodel: \"\"\n\t},\n\tzhipuCoding: {\n\t\tapiKey: \"\",\n\t\tapiHost: \"\",\n\t\tmodel: \"GLM-5\",\n\t\ttimeout: undefined\n\t},\n\txunFei: {\n\t\thostUrl: \"\",\n\t\tdomain: \"\",\n\t\tappId: \"\",\n\t\tapiKey: \"\",\n\t\tapiSecret: \"\",\n\t\tapiPassword: \"\"\n\t},\n\tdeepSeek: {\n\t\tapiKey: \"\",\n\t\tapiHost: \"\",\n\t\tmodel: \"deepseek-chat\",\n\t\ttimeout: undefined\n\t},\n\tdoubao: {\n\t\tapiKey: \"\",\n\t\tapiHost: \"\",\n\t\tendPoint: \"\"\n\t},\n\tali: {\n\t\tmodel: \"\"\n\t}\n};\n\nconst normalizeFormValues = (detail?: AiConfigAdminDTO): AiConfigFormValues => ({\n\tsources: (detail?.sources || []).filter(item => !REMOVED_SOURCE_VALUES.includes(item)),\n\tzhipu: {\n\t\tapiSecretKey: detail?.zhipu?.apiSecretKey || \"\",\n\t\trequestIdTemplate: detail?.zhipu?.requestIdTemplate || \"\",\n\t\tmodel: detail?.zhipu?.model || \"\"\n\t},\n\tzhipuCoding: {\n\t\tapiKey: detail?.zhipuCoding?.apiKey || \"\",\n\t\tapiHost: detail?.zhipuCoding?.apiHost || \"\",\n\t\tmodel: detail?.zhipuCoding?.model || \"GLM-5\",\n\t\ttimeout: detail?.zhipuCoding?.timeout\n\t},\n\txunFei: {\n\t\thostUrl: detail?.xunFei?.hostUrl || \"\",\n\t\tdomain: detail?.xunFei?.domain || \"\",\n\t\tappId: detail?.xunFei?.appId || \"\",\n\t\tapiKey: detail?.xunFei?.apiKey || \"\",\n\t\tapiSecret: detail?.xunFei?.apiSecret || \"\",\n\t\tapiPassword: detail?.xunFei?.apiPassword || \"\"\n\t},\n\tdeepSeek: {\n\t\tapiKey: detail?.deepSeek?.apiKey || \"\",\n\t\tapiHost: detail?.deepSeek?.apiHost || \"\",\n\t\tmodel: detail?.deepSeek?.model || \"deepseek-chat\",\n\t\ttimeout: detail?.deepSeek?.timeout\n\t},\n\tdoubao: {\n\t\tapiKey: detail?.doubao?.apiKey || \"\",\n\t\tapiHost: detail?.doubao?.apiHost || \"\",\n\t\tendPoint: detail?.doubao?.endPoint || \"\"\n\t},\n\tali: {\n\t\tmodel: detail?.ali?.model || \"\"\n\t}\n});\n\nconst AiConfigPage: FC = () => {\n\tconst [formRef] = Form.useForm<AiConfigFormValues>();\n\tconst [loading, setLoading] = useState<boolean>(false);\n\tconst [saving, setSaving] = useState<boolean>(false);\n\tconst [testingProvider, setTestingProvider] = useState<AISourceValue | null>(null);\n\n\tconst sourceOptions = useMemo(\n\t\t() =>\n\t\t\tAI_SOURCE_OPTIONS.map(item => ({\n\t\t\t\t...item,\n\t\t\t\tlabel: <span className=\"ai-config-page__source-option\">{item.label}</span>\n\t\t\t})),\n\t\t[]\n\t);\n\n\tconst loadDetail = async () => {\n\t\tsetLoading(true);\n\t\ttry {\n\t\t\tconst { status, result } = await getAiConfigDetailApi();\n\t\t\tif (status?.code === 0) {\n\t\t\t\tformRef.setFieldsValue(normalizeFormValues(result));\n\t\t\t\treturn;\n\t\t\t}\n\t\t\tmessage.error(status?.msg || \"加载 AI 模型配置失败\");\n\t\t} catch (error) {\n\t\t\tmessage.error(\"加载 AI 模型配置失败，请稍后重试\");\n\t\t} finally {\n\t\t\tsetLoading(false);\n\t\t}\n\t};\n\n\tuseEffect(() => {\n\t\tformRef.setFieldsValue(defaultFormValues);\n\t\tloadDetail();\n\t}, []);\n\n\tconst buildPayload = (values: AiConfigFormValues): AiConfigAdminReq => ({\n\t\tsources: (values.sources || []).filter(item => !REMOVED_SOURCE_VALUES.includes(item)),\n\t\tzhipu: {\n\t\t\tapiSecretKey: values.zhipu.apiSecretKey || \"\",\n\t\t\trequestIdTemplate: values.zhipu.requestIdTemplate || \"\",\n\t\t\tmodel: values.zhipu.model || \"\"\n\t\t},\n\t\tzhipuCoding: {\n\t\t\tapiKey: values.zhipuCoding.apiKey || \"\",\n\t\t\tapiHost: values.zhipuCoding.apiHost || \"\",\n\t\t\tmodel: values.zhipuCoding.model || \"\",\n\t\t\ttimeout: values.zhipuCoding.timeout ?? undefined\n\t\t},\n\t\txunFei: {\n\t\t\thostUrl: values.xunFei.hostUrl || \"\",\n\t\t\tdomain: values.xunFei.domain || \"\",\n\t\t\tappId: values.xunFei.appId || \"\",\n\t\t\tapiKey: values.xunFei.apiKey || \"\",\n\t\t\tapiSecret: values.xunFei.apiSecret || \"\",\n\t\t\tapiPassword: values.xunFei.apiPassword || \"\"\n\t\t},\n\t\tdeepSeek: {\n\t\t\tapiKey: values.deepSeek.apiKey || \"\",\n\t\t\tapiHost: values.deepSeek.apiHost || \"\",\n\t\t\tmodel: values.deepSeek.model || \"\",\n\t\t\ttimeout: values.deepSeek.timeout ?? undefined\n\t\t},\n\t\tdoubao: {\n\t\t\tapiKey: values.doubao.apiKey || \"\",\n\t\t\tapiHost: values.doubao.apiHost || \"\",\n\t\t\tendPoint: values.doubao.endPoint || \"\"\n\t\t},\n\t\tali: {\n\t\t\tmodel: values.ali.model || \"\"\n\t\t}\n\t});\n\n\tconst persistConfig = async (showSuccessMessage = true, reloadAfterSave = true) => {\n\t\tconst values = await formRef.validateFields();\n\t\tconst payload = buildPayload(values);\n\t\tconst { status } = await saveAiConfigApi(payload);\n\t\tif (status?.code !== 0) {\n\t\t\tmessage.error(status?.msg || \"AI 模型配置保存失败\");\n\t\t\treturn false;\n\t\t}\n\t\tif (showSuccessMessage) {\n\t\t\tmessage.success(\"AI 模型配置保存成功\");\n\t\t}\n\t\tif (reloadAfterSave) {\n\t\t\tloadDetail();\n\t\t}\n\t\treturn true;\n\t};\n\n\tconst handleSave = async () => {\n\t\tsetSaving(true);\n\t\ttry {\n\t\t\tawait persistConfig(true, true);\n\t\t} catch (error) {\n\t\t\tmessage.error(\"AI 模型配置保存失败，请稍后重试\");\n\t\t} finally {\n\t\t\tsetSaving(false);\n\t\t}\n\t};\n\n\tconst handleTestProvider = async (provider: AISourceValue) => {\n\t\tsetTestingProvider(provider);\n\t\ttry {\n\t\t\tconst saved = await persistConfig(false, false);\n\t\t\tif (!saved) {\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\tconst { status, result } = await testAiConfigApi({\n\t\t\t\tsource: provider,\n\t\t\t\tprompt: AI_TEST_PROMPT\n\t\t\t});\n\t\t\tif (status?.code !== 0) {\n\t\t\t\tmessage.error(status?.msg || `${AI_SOURCE_LABEL_MAP[provider]} 连通测试失败`);\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\tif (result?.success) {\n\t\t\t\tModal.success({\n\t\t\t\t\ttitle: `${AI_SOURCE_LABEL_MAP[provider]} 连通测试成功`,\n\t\t\t\t\tcontent: (\n\t\t\t\t\t\t<div className=\"ai-config-page__test-result\">\n\t\t\t\t\t\t\t<Text type=\"secondary\">模型返回内容</Text>\n\t\t\t\t\t\t\t<Paragraph copyable={{ text: result.answer || \"\" }} className=\"ai-config-page__test-result-text\">\n\t\t\t\t\t\t\t\t{result.answer || result.message || \"连接成功\"}\n\t\t\t\t\t\t\t</Paragraph>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t)\n\t\t\t\t});\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\tModal.error({\n\t\t\t\ttitle: `${AI_SOURCE_LABEL_MAP[provider]} 连通测试失败`,\n\t\t\t\tcontent: result?.message || \"未拿到有效响应，请检查当前配置\"\n\t\t\t});\n\t\t} catch (error) {\n\t\t\tmessage.error(`${AI_SOURCE_LABEL_MAP[provider]} 连通测试失败，请稍后重试`);\n\t\t} finally {\n\t\t\tsetTestingProvider(null);\n\t\t}\n\t};\n\n\tconst renderTestButton = (provider: AISourceValue) => (\n\t\t<Button size=\"small\" loading={testingProvider === provider} onClick={() => handleTestProvider(provider)}>\n\t\t\t保存并测试\n\t\t</Button>\n\t);\n\n\tconst renderTestingHint = (provider: AISourceValue) =>\n\t\ttestingProvider === provider ? <Text type=\"secondary\">正在保存当前配置并执行连通测试...</Text> : null;\n\n\treturn (\n\t\t<div className=\"ai-config-page\">\n\t\t\t<ContentWrap>\n\t\t\t\t<ContentInterWrap>\n\t\t\t\t\t<div className=\"ai-config-page__toolbar\">\n\t\t\t\t\t\t<div className=\"ai-config-page__toolbar-meta\">\n\t\t\t\t\t\t\t<Title level={4} style={{ marginBottom: 8 }}>\n\t\t\t\t\t\t\t\tAI 模型配置\n\t\t\t\t\t\t\t</Title>\n\t\t\t\t\t\t\t<Paragraph type=\"secondary\" style={{ marginBottom: 0 }}>\n\t\t\t\t\t\t\t\t这里单独维护 admin 端使用的 AI 模型配置，会直接读取并保存后端新增的 AI 配置接口，不再和通用全局配置项混在一起。\n\t\t\t\t\t\t\t</Paragraph>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t\t<Space className=\"ai-config-page__toolbar-actions\">\n\t\t\t\t\t\t\t<Button icon={<ReloadOutlined />} onClick={loadDetail} loading={loading}>\n\t\t\t\t\t\t\t\t刷新\n\t\t\t\t\t\t\t</Button>\n\t\t\t\t\t\t\t<Button type=\"primary\" icon={<SaveOutlined />} onClick={handleSave} loading={saving}>\n\t\t\t\t\t\t\t\t保存配置\n\t\t\t\t\t\t\t</Button>\n\t\t\t\t\t\t</Space>\n\t\t\t\t\t</div>\n\n\t\t\t\t\t<Alert\n\t\t\t\t\t\tshowIcon\n\t\t\t\t\t\ttype=\"info\"\n\t\t\t\t\t\tmessage=\"配置说明\"\n\t\t\t\t\t\tdescription=\"当前 admin 端已移除 ChatGPT 配置入口。连通测试会先保存当前页面配置，再调用后端 AI 配置测试接口执行真实探测。\"\n\t\t\t\t\t/>\n\n\t\t\t\t\t<Form form={formRef} layout=\"vertical\" initialValues={defaultFormValues} disabled={loading}>\n\t\t\t\t\t\t<Card className=\"ai-config-page__section-card\" title=\"启用模型源\">\n\t\t\t\t\t\t\t<Form.Item\n\t\t\t\t\t\t\t\tlabel=\"当前启用的模型\"\n\t\t\t\t\t\t\t\tname=\"sources\"\n\t\t\t\t\t\t\t\trules={[{ required: true, type: \"array\", min: 1, message: \"请至少选择一个启用的模型源\" }]}\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t<Checkbox.Group className=\"ai-config-page__source-group\" options={sourceOptions} />\n\t\t\t\t\t\t\t</Form.Item>\n\t\t\t\t\t\t\t<Text type=\"secondary\">这里控制后端可参与路由和降级选择的 AI Source 列表。</Text>\n\t\t\t\t\t\t</Card>\n\n\t\t\t\t\t\t<div className=\"ai-config-page__section-grid\">\n\t\t\t\t\t\t\t<Card className=\"ai-config-page__section-card\" title=\"智谱\" extra={renderTestButton(\"ZHI_PU_AI\")}>\n\t\t\t\t\t\t\t\t<Form.Item label=\"API Secret Key\" name={[\"zhipu\", \"apiSecretKey\"]}>\n\t\t\t\t\t\t\t\t\t<Input.Password allowClear placeholder=\"请输入智谱 API Secret Key\" />\n\t\t\t\t\t\t\t\t</Form.Item>\n\t\t\t\t\t\t\t\t<Form.Item label=\"Request ID 模板\" name={[\"zhipu\", \"requestIdTemplate\"]}>\n\t\t\t\t\t\t\t\t\t<Input allowClear placeholder=\"例如：paicoding-%d\" />\n\t\t\t\t\t\t\t\t</Form.Item>\n\t\t\t\t\t\t\t\t<Form.Item\n\t\t\t\t\t\t\t\t\tlabel=\"模型名\"\n\t\t\t\t\t\t\t\t\tname={[\"zhipu\", \"model\"]}\n\t\t\t\t\t\t\t\t\textra=\"当前后端智谱配置未开放 Base URL，默认走智谱 SDK 内置地址；这里提供官方常用模型，也支持手动输入新编码。\"\n\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t<Input allowClear placeholder=\"请输入智谱模型编码，例如：glm-5\" />\n\t\t\t\t\t\t\t\t</Form.Item>\n\t\t\t\t\t\t\t\t{renderTestingHint(\"ZHI_PU_AI\")}\n\t\t\t\t\t\t\t</Card>\n\n\t\t\t\t\t\t\t<Card className=\"ai-config-page__section-card\" title=\"智谱 Coding\" extra={renderTestButton(\"ZHIPU_CODING\")}>\n\t\t\t\t\t\t\t\t<Form.Item label=\"API Key\" name={[\"zhipuCoding\", \"apiKey\"]}>\n\t\t\t\t\t\t\t\t\t<Input.Password allowClear placeholder=\"请输入智谱 Coding API Key\" />\n\t\t\t\t\t\t\t\t</Form.Item>\n\t\t\t\t\t\t\t\t<Form.Item label=\"API Host\" name={[\"zhipuCoding\", \"apiHost\"]} extra=\"这里可配置智谱 Coding 的 Base URL。\">\n\t\t\t\t\t\t\t\t\t<Input allowClear placeholder=\"例如：https://open.bigmodel.cn/api/coding/paas/v4\" />\n\t\t\t\t\t\t\t\t</Form.Item>\n\t\t\t\t\t\t\t\t<Form.Item label=\"模型名\" name={[\"zhipuCoding\", \"model\"]}>\n\t\t\t\t\t\t\t\t\t<Input allowClear placeholder=\"请输入模型编码，例如：GLM-5\" />\n\t\t\t\t\t\t\t\t</Form.Item>\n\t\t\t\t\t\t\t\t<Form.Item label=\"超时时间\" name={[\"zhipuCoding\", \"timeout\"]}>\n\t\t\t\t\t\t\t\t\t<InputNumber className=\"ai-config-page__number-input\" min={0} precision={0} placeholder=\"单位：毫秒\" />\n\t\t\t\t\t\t\t\t</Form.Item>\n\t\t\t\t\t\t\t\t{renderTestingHint(\"ZHIPU_CODING\")}\n\t\t\t\t\t\t\t</Card>\n\n\t\t\t\t\t\t\t<Card className=\"ai-config-page__section-card\" title=\"讯飞\" extra={renderTestButton(\"XUN_FEI_AI\")}>\n\t\t\t\t\t\t\t\t<Row gutter={[16, 0]}>\n\t\t\t\t\t\t\t\t\t<Col xs={24} md={12}>\n\t\t\t\t\t\t\t\t\t\t<Form.Item label=\"Host URL\" name={[\"xunFei\", \"hostUrl\"]}>\n\t\t\t\t\t\t\t\t\t\t\t<Input allowClear placeholder=\"请输入讯飞 Host URL\" />\n\t\t\t\t\t\t\t\t\t\t</Form.Item>\n\t\t\t\t\t\t\t\t\t</Col>\n\t\t\t\t\t\t\t\t\t<Col xs={24} md={12}>\n\t\t\t\t\t\t\t\t\t\t<Form.Item label=\"Domain\" name={[\"xunFei\", \"domain\"]}>\n\t\t\t\t\t\t\t\t\t\t\t<Input allowClear placeholder=\"例如：generalv3.5\" />\n\t\t\t\t\t\t\t\t\t\t</Form.Item>\n\t\t\t\t\t\t\t\t\t</Col>\n\t\t\t\t\t\t\t\t\t<Col xs={24} md={12}>\n\t\t\t\t\t\t\t\t\t\t<Form.Item label=\"App ID\" name={[\"xunFei\", \"appId\"]}>\n\t\t\t\t\t\t\t\t\t\t\t<Input allowClear placeholder=\"请输入讯飞 App ID\" />\n\t\t\t\t\t\t\t\t\t\t</Form.Item>\n\t\t\t\t\t\t\t\t\t</Col>\n\t\t\t\t\t\t\t\t\t<Col xs={24} md={12}>\n\t\t\t\t\t\t\t\t\t\t<Form.Item label=\"API Key\" name={[\"xunFei\", \"apiKey\"]}>\n\t\t\t\t\t\t\t\t\t\t\t<Input.Password allowClear placeholder=\"请输入讯飞 API Key\" />\n\t\t\t\t\t\t\t\t\t\t</Form.Item>\n\t\t\t\t\t\t\t\t\t</Col>\n\t\t\t\t\t\t\t\t\t<Col xs={24} md={12}>\n\t\t\t\t\t\t\t\t\t\t<Form.Item label=\"API Secret\" name={[\"xunFei\", \"apiSecret\"]}>\n\t\t\t\t\t\t\t\t\t\t\t<Input.Password allowClear placeholder=\"请输入讯飞 API Secret\" />\n\t\t\t\t\t\t\t\t\t\t</Form.Item>\n\t\t\t\t\t\t\t\t\t</Col>\n\t\t\t\t\t\t\t\t\t<Col xs={24} md={12}>\n\t\t\t\t\t\t\t\t\t\t<Form.Item label=\"API Password\" name={[\"xunFei\", \"apiPassword\"]}>\n\t\t\t\t\t\t\t\t\t\t\t<Input.Password allowClear placeholder=\"请输入讯飞 API Password\" />\n\t\t\t\t\t\t\t\t\t\t</Form.Item>\n\t\t\t\t\t\t\t\t\t</Col>\n\t\t\t\t\t\t\t\t</Row>\n\t\t\t\t\t\t\t\t<Text type=\"secondary\">这里的测试会直接调用后端新增的 AI 配置测试接口。</Text>\n\t\t\t\t\t\t\t</Card>\n\n\t\t\t\t\t\t\t<Card className=\"ai-config-page__section-card\" title=\"DeepSeek\" extra={renderTestButton(\"DEEP_SEEK\")}>\n\t\t\t\t\t\t\t\t<Form.Item label=\"API Key\" name={[\"deepSeek\", \"apiKey\"]}>\n\t\t\t\t\t\t\t\t\t<Input.Password allowClear placeholder=\"请输入 DeepSeek API Key\" />\n\t\t\t\t\t\t\t\t</Form.Item>\n\t\t\t\t\t\t\t\t<Form.Item label=\"API Host\" name={[\"deepSeek\", \"apiHost\"]}>\n\t\t\t\t\t\t\t\t\t<Input allowClear placeholder=\"例如：https://api.deepseek.com\" />\n\t\t\t\t\t\t\t\t</Form.Item>\n\t\t\t\t\t\t\t\t<Form.Item label=\"模型名\" name={[\"deepSeek\", \"model\"]}>\n\t\t\t\t\t\t\t\t\t<Input allowClear placeholder=\"请输入 DeepSeek 模型名，例如：deepseek-chat\" />\n\t\t\t\t\t\t\t\t</Form.Item>\n\t\t\t\t\t\t\t\t<Form.Item label=\"超时时间\" name={[\"deepSeek\", \"timeout\"]}>\n\t\t\t\t\t\t\t\t\t<InputNumber className=\"ai-config-page__number-input\" min={0} precision={0} placeholder=\"单位：毫秒\" />\n\t\t\t\t\t\t\t\t</Form.Item>\n\t\t\t\t\t\t\t\t{renderTestingHint(\"DEEP_SEEK\")}\n\t\t\t\t\t\t\t</Card>\n\n\t\t\t\t\t\t\t<Card className=\"ai-config-page__section-card\" title=\"豆包\" extra={renderTestButton(\"DOU_BAO_AI\")}>\n\t\t\t\t\t\t\t\t<Form.Item label=\"API Key\" name={[\"doubao\", \"apiKey\"]}>\n\t\t\t\t\t\t\t\t\t<Input.Password allowClear placeholder=\"请输入豆包 API Key\" />\n\t\t\t\t\t\t\t\t</Form.Item>\n\t\t\t\t\t\t\t\t<Form.Item label=\"API Host\" name={[\"doubao\", \"apiHost\"]}>\n\t\t\t\t\t\t\t\t\t<Input allowClear placeholder=\"请输入豆包 API Host\" />\n\t\t\t\t\t\t\t\t</Form.Item>\n\t\t\t\t\t\t\t\t<Form.Item label=\"End Point\" name={[\"doubao\", \"endPoint\"]}>\n\t\t\t\t\t\t\t\t\t<Input allowClear placeholder=\"请输入豆包 End Point\" />\n\t\t\t\t\t\t\t\t</Form.Item>\n\t\t\t\t\t\t\t\t{renderTestingHint(\"DOU_BAO_AI\")}\n\t\t\t\t\t\t\t</Card>\n\t\t\t\t\t\t</div>\n\n\t\t\t\t\t\t<Divider />\n\n\t\t\t\t\t\t<Card className=\"ai-config-page__section-card\" title=\"阿里\" extra={renderTestButton(\"ALI_AI\")}>\n\t\t\t\t\t\t\t<Form.Item label=\"模型名\" name={[\"ali\", \"model\"]}>\n\t\t\t\t\t\t\t\t<Input allowClear placeholder=\"请输入阿里模型名\" />\n\t\t\t\t\t\t\t</Form.Item>\n\t\t\t\t\t\t\t{renderTestingHint(\"ALI_AI\")}\n\t\t\t\t\t\t</Card>\n\t\t\t\t\t</Form>\n\t\t\t\t</ContentInterWrap>\n\t\t\t</ContentWrap>\n\t\t</div>\n\t);\n};\n\nexport default AiConfigPage;\n"
  },
  {
    "path": "src/views/article/components/debounceselect/index.scss",
    "content": ""
  },
  {
    "path": "src/views/article/components/debounceselect/index.tsx",
    "content": "/* eslint-disable prettier/prettier */\nimport { useCallback, useEffect, useMemo, useRef, useState } from \"react\";\nimport React from \"react\";\nimport { message,Select, SelectProps, Spin } from \"antd\";\nimport { on } from \"events\";\nimport { debounce, set } from \"lodash\";\n\nimport { getTagListApi } from \"@/api/modules/tag\";\nimport { MapItem } from \"@/typings/common\";\n\n// 导入 index.scss 文件\nimport \"./index.scss\";\n\nexport interface DebounceSelectProps<ValueType = any> extends Omit<SelectProps<ValueType | ValueType[]>, \"options\" | \"children\"> {\n\tdebounceTimeout?: number;\n}\n\nexport interface IPagination {\n\tcurrent: number;\n\tpageSize: number;\n\ttotal?: number;\n}\n\nexport const initPagination: IPagination = {\n\tcurrent: 1,\n\tpageSize: 10,\n\ttotal: 0\n};\n\nexport interface IFormType {\n\t// 上下线\n\tstatus: number;\n\t// 标签名\n\ttag: string;\n}\n\nconst defaultInitForm: IFormType = {\n\t// 上下线\n\tstatus: 1,\n\ttag: \"\"\n}\n\n// Usage of DebounceSelect\ninterface TagValue {\n\tkey: string;\n\tlabel: string;\n\tvalue: string;\n}\n\nfunction DebounceSelect<ValueType extends { key?: string; label: React.ReactNode; value: string | number } = any>({\n\tdebounceTimeout = 800,\n\t...props\n}: DebounceSelectProps<ValueType>) {\n\t// 使用useState定义state变量，用于保存选项列表和加载状态\n\tconst [fetching, setFetching] = useState(false);\n\tconst [options, setOptions] = useState<ValueType[]>([]);\n\t// 使用useRef定义ref变量，用于记录请求的次数\n\tconst fetchRef = useRef(0);\n\n\t// 刷新函数\n\tconst [query, setQuery] = useState<number>(0);\n\n\t// 分页\n\tconst [pagination, setPagination] = useState<IPagination>(initPagination);\n\tconst { current, pageSize } = pagination;\n\n\t// 查询表单值\n\tconst [searchForm, setSearchForm] = useState<IFormType>(defaultInitForm);\n\n\t// 检测滚动到底部的逻辑\n\t// @ts-ignore\n\tconst handleScroll = event => {\n\t\tconst { scrollTop, scrollHeight, clientHeight } = event.target;\n\t\t// 距离底部 10px 时触发，增加一点缓冲\n\t\tif (scrollTop + clientHeight >= scrollHeight - 10) {\n\t\t\tif (fetching) return; // 如果正在加载，则不触发\n\t\t\t\n\t\t\t// 检查是否还有更多数据\n\t\t\tif (pagination.total && options.length >= pagination.total) {\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\tsetPagination(prev => ({ ...prev, current: prev.current + 1 }));\n\t\t\tonSure();\n\t\t}\n\t};\n\n\t// 查询表单值改变\n\tconst handleSearchChange = (item: MapItem) => {\n\t\tsetSearchForm({ ...searchForm, ...item });\n\t};\n\n\tconst onSure = useCallback(() => {\n\t\tsetQuery(prev => prev + 1);\n\t}, []);\n\n\tconst debounceFetcher = useMemo(() => {\n\t\tconst loadOptions = debounce(async (value: string) => {\n\t\t\thandleSearchChange({ tag: value });\n\t\t\tsetOptions([]);\n\t\t\tsetPagination(initPagination);\n\t\t\t// 一切准备好后，开始请求数据\n\t\t\tonSure();\n\t\t}, debounceTimeout);\n\t\n\t\treturn loadOptions;\n\t}, [debounceTimeout]);\n\n\tuseEffect(() => {\n\t\tlet isActive = true;\n\t\tconst fetchId = ++fetchRef.current;\n\t\tsetFetching(true);\n\t\n\t\t// 调用 API 并处理 Promise\n\t\tgetTagListApi({\n\t\t\t...searchForm,\n\t\t\tpageNumber: current,\n\t\t\tpageSize\n\t\t}).then(({ status, result }) => {\n\t\t\tif (isActive && fetchId === fetchRef.current) {\n\t\t\t\tconst { code } = status || {};\n\t\t\t\t//@ts-ignore\n\t\t\t\tconst { list, pageNum, pageSize: resPageSize, pageTotal, total } = result || {};\n\t\t\t\tif (code === 0) {\n\t\t\t\t\tif (list && list.length > 0) {\n\t\t\t\t\t\tsetPagination({ current: Number(pageNum), pageSize: resPageSize, total });\n\n\t\t\t\t\t\tconst newList = list.map((item: MapItem) => ({\n\t\t\t\t\t\t\tkey: item?.tagId,\n\t\t\t\t\t\t\tlabel: item?.tag,\n\t\t\t\t\t\t\tvalue: item?.tag\n\t\t\t\t\t\t}));\n\t\t\t\t\t\tsetOptions(prevOptions => {\n\t\t\t\t\t\t\t// 使用 Map 过滤掉重复的 key\n\t\t\t\t\t\t\tconst allOptions = [...prevOptions, ...newList];\n\t\t\t\t\t\t\tconst uniqueOptionsMap = new Map();\n\t\t\t\t\t\t\tallOptions.forEach(opt => {\n\t\t\t\t\t\t\t\tif (opt.key) {\n\t\t\t\t\t\t\t\t\tuniqueOptionsMap.set(opt.key, opt);\n\t\t\t\t\t\t\t\t} else {\n\t\t\t\t\t\t\t\t\t// 如果没有 key，用 value 作为 fallback\n\t\t\t\t\t\t\t\t\tuniqueOptionsMap.set(opt.value, opt);\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t});\n\t\t\t\t\t\t\treturn Array.from(uniqueOptionsMap.values());\n\t\t\t\t\t\t});\n\t\t\t\t\t} else if (current === 1) {\n\t\t\t\t\t\t// 只有在第一页且没有数据时才提示\n\t\t\t\t\t\tconsole.warn('该搜索的值没有标签');\n\t\t\t\t\t}\n\t\t\t\t} else {\n\t\t\t\t\tconsole.error('获取标签列表失败:', status?.msg);\n\t\t\t\t}\n\t\t\t}\n\t\t\tsetFetching(false);\n\t\t}).catch(error => {\n\t\t\tconsole.error('加载数据失败:', error);\n\t\t});\n\t\n\t\treturn () => {\n\t\t\tisActive = false; // 取消当前异步操作\n\t\t};\n\t}, [query]);\t\n\n\tuseEffect(() => {\n\t\t// console.log(\"options 更新:\", options);\n\t}, [options]);\t\n\n\t// 清空\n\tconst handleClear = () => {\n\t\tsetOptions([]);\n\t\tsetPagination(initPagination);\n\t\tsetSearchForm(defaultInitForm);\n\t};\n\n\treturn (\n\t\t<Select\n\t\t\tallowClear\n\t\t\tplaceholder=\"请选择标签\"\n\t\t\t// optionLabelProp：回填到选择框的 Option 的属性值，默认是 Option 的子元素。\n\t\t\t// 比如在子元素需要高亮效果时，此值可以设为 value\n\t\t\toptionLabelProp=\"value\"\n\t\t\t// 是否在输入框聚焦时自动调用搜索方法\n\t\t\tshowSearch={true}\n\t\t\tlabelInValue\n\t\t\tmode=\"multiple\"\n\t\t\tfilterOption={false}\n\t\t\t// 绑定防抖函数到onSearch事件上，触发搜索操作时，会调用防抖函数\n\t\t\tonSearch={debounceFetcher}\n\t\t\tonDropdownVisibleChange={visible => {\n\t\t\t\t// 下拉列表展开时，重新获取选项列表(如果之前没有值的话)\n\t\t\t\tif (visible) {\n\t\t\t\t\thandleClear();\n\t\t\t\t\tonSure();\n\t\t\t\t}\n\t\t\t}}\n\t\t\tonClear={handleClear}\n\t\t\tonPopupScroll={handleScroll}\n\t\t\t// 当加载状态为true时，显示旋转加载图标\n\t\t\tnotFoundContent={fetching ? <Spin size=\"small\" /> : null}\n\t\t\t{...props}\n\t\t\t// 将选项列表传递给Select组件进行展示\n\t\t\toptions={options}\n\t\t/>\n\t);\n}\nexport default DebounceSelect;\n"
  },
  {
    "path": "src/views/article/components/search/index.scss",
    "content": ".article-search {\n  margin-bottom: 16px;\n\n  &__wrap {\n    display: flex;\n    justify-content: space-between;\n  }\n\n  &__search {\n    flex: 1;\n    display: flex;\n    justify-content: space-between;\n\n    &-wrap {\n      display: flex;\n    }\n  }\n\n  &__search-item {\n    margin-right: 28px;\n  }\n\n  &-label {\n    margin-right: 12px;\n  }\n}\n"
  },
  {
    "path": "src/views/article/components/search/index.tsx",
    "content": "/* eslint-disable prettier/prettier */\nimport React, { FC } from \"react\";\nimport { useNavigate } from \"react-router-dom\";\nimport { PlusCircleOutlined, SearchOutlined } from \"@ant-design/icons\";\nimport { Button, Input, Select, Tooltip } from \"antd\";\n\nimport { ContentInterWrap } from \"@/components/common-wrap\";\n\nimport \"./index.scss\";\n\ninterface IProps {\n\thandleSearchChange: (e: object) => void;\n\thandleSearch: () => void;\n\tPushStatusList: Array<{ label: string; value: number }>;\n\tToppingStatusList: Array<{ label: string; value: number }>;\n\tOfficalStatusList: Array<{ label: string; value: number }>;\n\tColumnList: Array<{ label: string; value: number }>;\n}\n\nconst Search: FC<IProps> = ({ handleSearchChange, handleSearch, PushStatusList, ToppingStatusList, OfficalStatusList, ColumnList }) => {\n\tconst navigate = useNavigate();\n\treturn (\n\t\t<div className=\"article-search\">\n\t\t\t{/* 搜索 */}\n\t\t\t<ContentInterWrap className=\"article-search__wrap\">\n\t\t\t\t<div className=\"article-search__search\">\n\t\t\t\t\t<div className=\"article-search__search-item\">\n\t\t\t\t\t\t{/* 增加一个作者的查询条件 */}\n\t\t\t\t\t\t<Input\n\t\t\t\t\t\t\tallowClear\n\t\t\t\t\t\t\tplaceholder=\"请输入作者名\"\n\t\t\t\t\t\t\tstyle={{ width: 142 }}\n\t\t\t\t\t\t\tonChange={e => {\n\t\t\t\t\t\t\t\thandleSearchChange({ userName: e.target.value });\n\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t/>\n\t\t\t\t\t</div>\n\t\t\t\t\t<div className=\"article-search__search-item\">\n\t\t\t\t\t\t<Input\n\t\t\t\t\t\t\tallowClear\n\t\t\t\t\t\t\tplaceholder=\"请输入标题\"\n\t\t\t\t\t\t\tstyle={{ width: 142 }}\n\t\t\t\t\t\t\tonChange={e => handleSearchChange({ title: e.target.value })}\n\t\t\t\t\t\t/>\n\t\t\t\t\t</div>\n\t\t\t\t\t<div className=\"article-search__search-item\">\n\t\t\t\t\t\t<Select\n\t\t\t\t\t\t\t// 可以清空\n\t\t\t\t\t\t\tallowClear\n\t\t\t\t\t\t\t// 默认值\n\t\t\t\t\t\t\tplaceholder=\"选择专栏\"\n\t\t\t\t\t\t\toptions={ColumnList}\n\t\t\t\t\t\t\tstyle={{ width: 142 }}\n\t\t\t\t\t\t\t// 触发搜索\n\t\t\t\t\t\t\tonChange={value => handleSearchChange({ columnId: Number(value || -1) })}\n\t\t\t\t\t\t></Select>\n\t\t\t\t\t</div>\n\t\t\t\t\t<div className=\"article-search__search-item\">\n\t\t\t\t\t\t<Select\n\t\t\t\t\t\t\t// 可以清空\n\t\t\t\t\t\t\tallowClear\n\t\t\t\t\t\t\t// 默认值\n\t\t\t\t\t\t\tplaceholder=\"选择状态\"\n\t\t\t\t\t\t\toptions={PushStatusList}\n\t\t\t\t\t\t\tstyle={{ width: 100 }}\n\t\t\t\t\t\t\t// 触发搜索\n\t\t\t\t\t\t\tonChange={value => handleSearchChange({ status: Number(value || -1) })}\n\t\t\t\t\t\t></Select>\n\t\t\t\t\t</div>\n\t\t\t\t\t<div className=\"article-search__search-item\">\n\t\t\t\t\t\t<Select\n\t\t\t\t\t\t\t// 可以清空\n\t\t\t\t\t\t\tallowClear\n\t\t\t\t\t\t\t// 默认值\n\t\t\t\t\t\t\tplaceholder=\"是否置顶\"\n\t\t\t\t\t\t\toptions={ToppingStatusList}\n\t\t\t\t\t\t\tstyle={{ width: 100 }}\n\t\t\t\t\t\t\t// 触发搜索\n\t\t\t\t\t\t\tonChange={value => handleSearchChange({ toppingStat: Number(value || -1) })}\n\t\t\t\t\t\t></Select>\n\t\t\t\t\t</div>\n\t\t\t\t\t<div className=\"article-search__search-item\">\n\t\t\t\t\t\t<Select\n\t\t\t\t\t\t\t// 可以清空\n\t\t\t\t\t\t\tallowClear\n\t\t\t\t\t\t\t// 默认值\n\t\t\t\t\t\t\tplaceholder=\"是否推荐\"\n\t\t\t\t\t\t\toptions={OfficalStatusList}\n\t\t\t\t\t\t\tstyle={{ width: 100 }}\n\t\t\t\t\t\t\t// 触发搜索\n\t\t\t\t\t\t\tonChange={value => handleSearchChange({ officalStat: Number(value || -1) })}\n\t\t\t\t\t\t></Select>\n\t\t\t\t\t</div>\n\t\t\t\t\t<div className=\"article-search__search-btn\">\n\t\t\t\t\t\t<Tooltip title=\"按条件搜索\">\n\t\t\t\t\t\t\t<Button type=\"primary\" icon={<SearchOutlined />} style={{ marginRight: \"10px\" }} onClick={handleSearch}>\n\t\t\t\t\t\t\t</Button>\n\t\t\t\t\t\t</Tooltip>\n\t\t\t\t\t\t<Tooltip title=\"新增文章\">\n\t\t\t\t\t\t\t<Button type=\"primary\" icon={<PlusCircleOutlined />}  \n\t\t\t\t\t\t\t\tstyle={{ marginRight: \"20px\" }} \n\t\t\t\t\t\t\t\tonClick={() => {navigate(\"/article/edit/index\");}}>\n\t\t\t\t\t\t\t</Button>\n\t\t\t\t\t\t</Tooltip>\n\t\t\t\t\t</div>\n\t\t\t\t</div>\n\t\t\t</ContentInterWrap>\n\t\t</div>\n\t);\n};\nexport default Search;\n"
  },
  {
    "path": "src/views/article/edit/index.scss",
    "content": ".bytemd {\n  height: calc(100vh - 220px);\n}\n\n/* CustomRadioGroup.css */\n.custom-radio-group .ant-radio-button-wrapper {\n  margin-right: 8px; /* 右侧空隙 */\n  width: 100px;\n  display: flex;\n  justify-content: center;\n  align-items: center;\n}\n\n.custom-radio-group {\n  display: flex;\n  flex-wrap: wrap; /* 允许换行 */\n  gap: 10px; /* 行间距 */\n}\n\n/* CustomRadioGroup.css */\n.custom-radio-group .ant-radio-button-wrapper:first-child {\n  border-top-left-radius: 0; /* 左上圆角 */\n  border-bottom-left-radius: 0; /* 左下圆角 */\n}\n\n.custom-radio-group .ant-radio-button-wrapper:last-child {\n  border-top-right-radius: 0; /* 右上圆角 */\n  border-bottom-right-radius: 0; /* 右下圆角 */\n}\n\n.bytemd-preview {\n  /* 解决外部容器出现滚动条的 bug */\n  position: relative;\n\n  strong {\n    font-weight: bold;\n  }\n\n  /* 为斜体添加斜体样式 */\n  em {\n    font-style: italic;\n  }\n\n  ul {\n    list-style-type: disc;\n    padding-left: 20px;\n  }\n\n  p {\n    line-height: 1.5;\n  }\n\n  .img-with-caption {\n    display: inline-block;\n\n    img {\n      display: block;\n      cursor: pointer;\n      transition: outline 0.2s;\n\n      &:hover {\n        outline: 2px solid #1677ff;\n      }\n    }\n\n    .img-caption {\n      display: block;\n      text-align: center;\n      color: #666;\n      padding: 4px 8px;\n      font-size: 12px;\n      margin-top: 4px;\n    }\n  }\n\n  // Moveable handles\n  .moveable-control-box {\n    z-index: 1000 !important;\n  }\n}\n"
  },
  {
    "path": "src/views/article/edit/index.tsx",
    "content": "/* eslint-disable prettier/prettier */\nimport { FC, useCallback, useEffect, useMemo, useRef, useState } from \"react\";\nimport { createPortal } from \"react-dom\";\nimport Moveable, { OnResize, OnResizeEnd } from \"react-moveable\";\nimport { connect } from \"react-redux\";\nimport { useLocation, useNavigate } from \"react-router\";\nimport { ReloadOutlined, SwapOutlined, CopyOutlined } from \"@ant-design/icons\";\nimport gemoji from \"@bytemd/plugin-gemoji\";\nimport gfm from \"@bytemd/plugin-gfm\";\nimport highlight from \"@bytemd/plugin-highlight\";\nimport math from \"@bytemd/plugin-math\";\nimport { Editor } from \"@bytemd/react\";\nimport { Button, Drawer, Form, Input, InputNumber, message, Modal, Radio, Select, Space, UploadFile } from \"antd\";\nimport TextArea from \"antd/es/input/TextArea\";\nimport zhHans from \"bytemd/locales/zh_Hans.json\";\nimport { throttle } from \"lodash\";\nimport mammoth from \"mammoth\";\n\nimport { generateArticleAiApi, getArticleApi, saveArticleApi, saveImgApi } from \"@/api/modules/article\";\nimport { uploadImgApi } from \"@/api/modules/common\";\nimport { getTagListApi } from \"@/api/modules/tag\";\nimport { ContentInterWrap, ContentWrap } from \"@/components/common-wrap\";\nimport { PushStatusEnum, UpdateEnum } from \"@/enums/common\";\nimport { MapItem } from \"@/typings/common\";\nimport { getCompleteUrl, localGet, localRemove, localSet } from \"@/utils/util\";\nimport DebounceSelect from \"@/views/article/components/debounceselect/index\";\nimport ImgUpload from \"@/views/column/setting/components/imgupload\";\nimport Search from \"./search\";\n\nimport \"bytemd/dist/index.css\";\nimport \"./index.scss\";\nimport \"highlight.js/styles/default.css\";\nimport \"juejin-markdown-themes/dist/juejin.css\";\nimport \"katex/dist/katex.css\";\n\n// 自定义插件：为图片添加 alt 标题\nconst imageAltPlugin = () => ({\n\tviewerEffect({ markdownBody }: { markdownBody: HTMLElement }) {\n\t\tconst images = markdownBody.querySelectorAll('img[alt]:not([alt=\"\"])');\n\t\timages.forEach((img: Element) => {\n\t\t\tconst imgElement = img as HTMLImageElement;\n\t\t\tconst alt = imgElement.alt;\n\n\t\t\t// 检查是否已经添加过标题\n\t\t\tconst parent = imgElement.parentElement;\n\t\t\tif (parent && parent.classList.contains(\"img-with-caption\")) {\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\t// 创建包装容器\n\t\t\tconst wrapper = document.createElement(\"span\");\n\t\t\twrapper.className = \"img-with-caption\";\n\n\t\t\t// 创建标题元素\n\t\t\tconst caption = document.createElement(\"span\");\n\t\t\tcaption.className = \"img-caption\";\n\t\t\tcaption.textContent = alt;\n\n\t\t\t// 替换图片\n\t\t\timgElement.parentNode?.insertBefore(wrapper, imgElement);\n\t\t\twrapper.appendChild(imgElement);\n\t\t\twrapper.appendChild(caption);\n\t\t});\n\t}\n});\n\n// 自定义插件：为图片添加可移动和缩放功能\nconst imageMoveablePlugin = (\n\tsetTarget: (el: HTMLElement | null) => void,\n\tonScroll: () => void,\n\tsetContainer: (el: HTMLElement | null) => void,\n\tgetScrollInfo: () => { pos: number; active: boolean; clear: () => void },\n\tsetEditor: (editor: any) => void,\n\tgetImageSizeMap: () => Map<string, { width: number; height: number } | \"reset\">\n) => ({\n\teditorEffect({ editor }: { editor: any }) {\n\t\tsetEditor(editor);\n\t},\n\tviewerEffect({ markdownBody }: { markdownBody: HTMLElement }) {\n\t\tconst scrollContainer = markdownBody.parentElement;\n\n\t\t// 1. 应用缓存的图片尺寸\n\t\tconst sizeMap = getImageSizeMap();\n\t\tconst images = markdownBody.querySelectorAll(\"img\");\n\n\t\tconst resolveUrl = (urlStr: string) => {\n\t\t\tif (!urlStr) return \"\";\n\t\t\ttry {\n\t\t\t\tconst cleanUrl = urlStr.split(/\\s+/)[0];\n\t\t\t\treturn new URL(decodeURIComponent(cleanUrl), window.location.origin).href;\n\t\t\t} catch (e) {\n\t\t\t\treturn urlStr;\n\t\t\t}\n\t\t};\n\n\t\timages.forEach(img => {\n\t\t\tconst src = img.getAttribute(\"src\");\n\t\t\tif (!src) return;\n\t\t\tconst fullUrl = resolveUrl(src);\n\t\t\tconst state = sizeMap.get(fullUrl);\n\t\t\tif (state === \"reset\") {\n\t\t\t\t// 强制清除样式（针对已经在源码中是 <img> 标签的情况）\n\t\t\t\timg.style.width = \"\";\n\t\t\t\timg.style.height = \"\";\n\t\t\t} else if (state) {\n\t\t\t\timg.style.width = `${state.width}px`;\n\t\t\t\timg.style.height = `${state.height}px`;\n\t\t\t}\n\t\t});\n\n\t\t// 2. 检查是否需要恢复滚动位置\n\t\tconst info = getScrollInfo();\n\t\tif (info.active && scrollContainer) {\n\t\t\t// 使用 requestAnimationFrame 确保在浏览器下一帧渲染前恢复位置\n\t\t\t// 这能有效拦截 ByteMD 渲染导致的滚动跳动\n\t\t\trequestAnimationFrame(() => {\n\t\t\t\tscrollContainer.scrollTop = info.pos;\n\t\t\t\tinfo.clear();\n\t\t\t\t// 恢复位置后更新一下控制柄位置\n\t\t\t\tonScroll();\n\t\t\t});\n\t\t}\n\n\t\tconst handleClick = (e: MouseEvent) => {\n\t\t\tconst target = e.target as HTMLElement;\n\t\t\tif (target.tagName === \"IMG\") {\n\t\t\t\te.preventDefault();\n\t\t\t\te.stopPropagation();\n\t\t\t\tsetTarget(target);\n\t\t\t\t// 立即触发一次位置更新，确保控制柄出现\n\t\t\t\tsetTimeout(() => {\n\t\t\t\t\tonScroll();\n\t\t\t\t}, 0);\n\t\t\t} else {\n\t\t\t\t// 点击预览区其他地方取消选中\n\t\t\t\tsetTarget(null);\n\t\t\t}\n\t\t};\n\n\t\tconst handleScroll = () => {\n\t\t\tonScroll();\n\t\t};\n\n\t\t// ByteMD 预览区的滚动容器通常是 markdownBody 的父元素 .bytemd-preview\n\t\tsetContainer(scrollContainer);\n\n\t\tmarkdownBody.addEventListener(\"click\", handleClick);\n\t\tif (scrollContainer) {\n\t\t\tscrollContainer.addEventListener(\"scroll\", handleScroll);\n\t\t}\n\n\t\treturn () => {\n\t\t\tmarkdownBody.removeEventListener(\"click\", handleClick);\n\t\t\tif (scrollContainer) {\n\t\t\t\tscrollContainer.removeEventListener(\"scroll\", handleScroll);\n\t\t\t}\n\t\t};\n\t}\n});\n\n// 自定义 Moveable Able：将复原按钮集成到控制框中\nconst RestoreAble = {\n\tname: \"restoreAble\",\n\talways: true,\n\trender(moveable: any, React: any) {\n\t\tconst { target, resetImageInMarkdown, replaceImageInMarkdown, copyImageToClipboard } = moveable.props;\n\t\t// pos2 是右上角的坐标 [x, y]\n\t\tconst { pos2 } = moveable.state;\n\n\t\treturn (\n\t\t\t<div\n\t\t\t\tkey=\"restore-button\"\n\t\t\t\tstyle={{\n\t\t\t\t\tposition: \"absolute\",\n\t\t\t\t\tleft: `${pos2[0]}px`,\n\t\t\t\t\ttop: `${pos2[1]}px`,\n\t\t\t\t\ttransform: \"translate(-100%, -120%)\", // 右侧对齐图片右上角，且位于上方\n\t\t\t\t\tzIndex: 10,\n\t\t\t\t\tdisplay: \"flex\",\n\t\t\t\t\tgap: \"8px\"\n\t\t\t\t}}\n\t\t\t>\n\t\t\t\t<Button\n\t\t\t\t\tsize=\"small\"\n\t\t\t\t\ttype=\"primary\"\n\t\t\t\t\ticon={<CopyOutlined />}\n\t\t\t\t\tstyle={{\n\t\t\t\t\t\tfontSize: \"12px\",\n\t\t\t\t\t\theight: \"24px\",\n\t\t\t\t\t\tpadding: \"0 8px\",\n\t\t\t\t\t\tboxShadow: \"0 2px 4px rgba(0,0,0,0.2)\",\n\t\t\t\t\t\tborder: \"none\",\n\t\t\t\t\t\tdisplay: \"flex\",\n\t\t\t\t\t\talignItems: \"center\",\n\t\t\t\t\t\tgap: \"4px\",\n\t\t\t\t\t\tbackgroundColor: \"#faad14\" // 使用橙色区分复制功能\n\t\t\t\t\t}}\n\t\t\t\t\tonClick={e => {\n\t\t\t\t\t\te.stopPropagation();\n\t\t\t\t\t\tcopyImageToClipboard(target);\n\t\t\t\t\t}}\n\t\t\t\t>\n\t\t\t\t\t复制\n\t\t\t\t</Button>\n\t\t\t\t<Button\n\t\t\t\t\tsize=\"small\"\n\t\t\t\t\ttype=\"primary\"\n\t\t\t\t\ticon={<SwapOutlined />}\n\t\t\t\t\tstyle={{\n\t\t\t\t\t\tfontSize: \"12px\",\n\t\t\t\t\t\theight: \"24px\",\n\t\t\t\t\t\tpadding: \"0 8px\",\n\t\t\t\t\t\tboxShadow: \"0 2px 4px rgba(0,0,0,0.2)\",\n\t\t\t\t\t\tborder: \"none\",\n\t\t\t\t\t\tdisplay: \"flex\",\n\t\t\t\t\t\talignItems: \"center\",\n\t\t\t\t\t\tgap: \"4px\",\n\t\t\t\t\t\tbackgroundColor: \"#52c41a\" // 使用绿色区分替换功能\n\t\t\t\t\t}}\n\t\t\t\t\tonClick={e => {\n\t\t\t\t\t\te.stopPropagation();\n\t\t\t\t\t\treplaceImageInMarkdown(target);\n\t\t\t\t\t}}\n\t\t\t\t>\n\t\t\t\t\t替换\n\t\t\t\t</Button>\n\t\t\t\t<Button\n\t\t\t\t\tsize=\"small\"\n\t\t\t\t\ttype=\"primary\"\n\t\t\t\t\ticon={<ReloadOutlined />}\n\t\t\t\t\tstyle={{\n\t\t\t\t\t\tfontSize: \"12px\",\n\t\t\t\t\t\theight: \"24px\",\n\t\t\t\t\t\tpadding: \"0 8px\",\n\t\t\t\t\t\tboxShadow: \"0 2px 4px rgba(0,0,0,0.2)\",\n\t\t\t\t\t\tborder: \"none\",\n\t\t\t\t\t\tdisplay: \"flex\",\n\t\t\t\t\t\talignItems: \"center\",\n\t\t\t\t\t\tgap: \"4px\"\n\t\t\t\t\t}}\n\t\t\t\t\tonClick={e => {\n\t\t\t\t\t\te.stopPropagation();\n\t\t\t\t\t\tresetImageInMarkdown(target);\n\t\t\t\t\t}}\n\t\t\t\t>\n\t\t\t\t\t复原\n\t\t\t\t</Button>\n\t\t\t</div>\n\t\t);\n\t}\n} as const;\n\nconst plugins = (\n\tsetTarget: (el: HTMLElement | null) => void,\n\tonScroll: () => void,\n\tsetContainer: (el: HTMLElement | null) => void,\n\tgetScrollInfo: () => { pos: number; active: boolean; clear: () => void },\n\tsetEditor: (editor: any) => void,\n\tgetImageSizeMap: () => Map<string, { width: number; height: number } | \"reset\">\n) => [\n\tgfm(),\n\thighlight(),\n\tgemoji(),\n\tmath(),\n\timageAltPlugin(),\n\timageMoveablePlugin(setTarget, onScroll, setContainer, getScrollInfo, setEditor, getImageSizeMap)\n\t// Add more plugins here\n];\n\ninterface IProps {}\n\ninterface TagValue {\n\ttagId: string;\n\ttag: string;\n}\n\ninterface ImageInfo {\n\timg: string;\n\talt: string;\n\tsrc: string;\n\tindex: number; // 图片在文本中的位置\n}\n\nexport interface IFormType {\n\tarticleId: number; // 文章id\n\tstatus: number; // 文章状态\n\tcontent: string; // 文章内容\n\tcover: string; // 封面\n\ttagIds: number[]; // 标签\n\tshortTitle: string; // 短标题\n\treadType: number; // 阅读类型\n\tpayWay: string; // 付费方式\n\tpayAmount: number; // 付费金额（元）\n\ttitle?: string;\n\tsummary?: string;\n\tcategoryId?: number;\n}\n\nconst defaultInitForm: IFormType = {\n\tarticleId: 0, // 后台默认为 0\n\tstatus: 0,\n\tcontent: \"\",\n\tcover: \"\",\n\ttagIds: [],\n\tshortTitle: \"\",\n\treadType: 0,\n\tpayWay: \"wx_native\",\n\tpayAmount: 0.99\n};\n\nconst ArticleReadTypeList = [\n\t{ label: \"全部可读\", value: 0 },\n\t{ label: \"登录阅读\", value: 1 },\n\t{ label: \"付费阅读\", value: 4 },\n\t{ label: \"星球专享\", value: 3 }\n];\n\nconst PayWayList = [\n\t{ label: \"个人收款码\", value: \"email\" },\n\t{ label: \"统一微信支付\", value: \"wx_native\" }\n];\n\nconst ArticleEdit: FC<IProps> = props => {\n\tconst [formRef] = Form.useForm();\n\tconst selectedReadType = Form.useWatch(\"readType\", formRef) ?? defaultInitForm.readType;\n\tconst selectedPayWay = Form.useWatch(\"payWay\", formRef) ?? defaultInitForm.payWay;\n\n\tconst [form, setForm] = useState<IFormType>(defaultInitForm);\n\n\t// Moveable target\n\tconst [target, setTarget] = useState<HTMLElement | null>(null);\n\tconst [container, setContainer] = useState<HTMLElement | null>(null);\n\tconst moveableRef = useRef<Moveable>(null);\n\tconst editorRef = useRef<any>(null);\n\n\t// 缓存待提交的图片尺寸变更：Map<fullUrl, {width, height} | 'reset'>\n\tconst imageSizeMapRef = useRef<Map<string, { width: number; height: number } | \"reset\">>(new Map());\n\n\t// 记录滚动位置，防止内容更新时预览区跳回顶部\n\tconst scrollPosRef = useRef<number>(0);\n\tconst isUpdatingContentRef = useRef<boolean>(false);\n\n\t// 使用 Ref 存储最新的 update 函数，确保插件闭包能拿到最新逻辑而不触发重绘\n\tconst updateMoveableRef = useRef<() => void>();\n\n\tconst updateMoveable = useCallback(() => {\n\t\tmoveableRef.current?.updateRect();\n\t}, []);\n\n\t// 更新 Ref\n\tuseEffect(() => {\n\t\tupdateMoveableRef.current = updateMoveable;\n\t}, [updateMoveable]);\n\n\t// 封装获取滚动位置的逻辑给插件使用\n\tconst getScrollInfo = useCallback(\n\t\t() => ({\n\t\t\tpos: scrollPosRef.current,\n\t\t\tactive: isUpdatingContentRef.current,\n\t\t\tclear: () => {\n\t\t\t\tisUpdatingContentRef.current = false;\n\t\t\t}\n\t\t}),\n\t\t[]\n\t);\n\n\tconst setEditor = useCallback((editor: any) => {\n\t\teditorRef.current = editor;\n\t}, []);\n\n\tconst getImageSizeMap = useCallback(() => imageSizeMapRef.current, []);\n\n\tconst resolveUrl = useCallback((urlStr: string) => {\n\t\tif (!urlStr) return \"\";\n\t\ttry {\n\t\t\tconst cleanUrl = urlStr.split(/\\s+/)[0];\n\t\t\treturn new URL(decodeURIComponent(cleanUrl), window.location.origin).href;\n\t\t} catch (e) {\n\t\t\treturn urlStr;\n\t\t}\n\t}, []);\n\n\t// 保持 editorPlugins 绝对稳定，防止编辑器重绘导致 target 丢失\n\tconst editorPlugins = useMemo(() => {\n\t\treturn plugins(setTarget, () => updateMoveableRef.current?.(), setContainer, getScrollInfo, setEditor, getImageSizeMap);\n\t}, [getScrollInfo, setEditor, getImageSizeMap]); // 没有任何依赖，只创建一次\n\n\tuseEffect(() => {\n\t\twindow.addEventListener(\"resize\", updateMoveable);\n\t\treturn () => {\n\t\t\twindow.removeEventListener(\"resize\", updateMoveable);\n\t\t};\n\t}, [updateMoveable]);\n\n\t// 当选中图片目标变化时，立即强制更新一次控制柄位置\n\tuseEffect(() => {\n\t\tif (target) {\n\t\t\tconst timer = setTimeout(() => {\n\t\t\t\tupdateMoveable();\n\t\t\t}, 50); // 给予足够的渲染缓冲时间\n\t\t\treturn () => clearTimeout(timer);\n\t\t}\n\t}, [target, updateMoveable]);\n\n\t// 文章内容\n\tconst [content, setContent] = useState<string>(\"\");\n\n\t// 抽屉\n\tconst [isOpenDrawerShow, setIsOpenDrawerShow] = useState<boolean>(false);\n\n\t// 放图片的路径，和上传时间，30s 内防止重复提交\n\tconst lastUploadTimes = useRef<Map<string, number>>(new Map());\n\n\t// 刷新函数\n\tconst [query, setQuery] = useState<number>(0);\n\n\t// 定义一个常量，用于封面\n\tconst coverName = \"建议 180*100，仅在首页信息流中展示\";\n\n\tconst location = useLocation();\n\tconst navigate = useNavigate();\n\t// 取出来 articleId 和 status\n\t// 当前的状态，用于新增还是更新，新增的时候不传递 id，更新的时候传递 id\n\tconst { articleId, status } = location.state || {};\n\n\t// 声明一个 coverList，封面\n\tconst [coverList, setCoverList] = useState<UploadFile[]>([]);\n\n\t// 自动保存 Key\n\tconst draftKey = useMemo(() => {\n\t\treturn articleId ? `ARTICLE_DRAFT_${articleId}` : \"ARTICLE_DRAFT_NEW\";\n\t}, [articleId]);\n\n\t// 自动保存函数 (10s 节流)\n\tconst autoSave = useRef(\n\t\tthrottle(\n\t\t\tdata => {\n\t\t\t\tlocalSet(draftKey, { ...data, timestamp: Date.now() });\n\t\t\t\tconsole.log(\"自动保存草稿成功\", new Date().toLocaleTimeString());\n\t\t\t},\n\t\t\t10000,\n\t\t\t{ leading: false, trailing: true }\n\t\t)\n\t);\n\n\t// 监听变化并触发自动保存\n\tuseEffect(() => {\n\t\tif (content || form.title) {\n\t\t\tconst currentFormValues = formRef.getFieldsValue();\n\t\t\tautoSave.current({\n\t\t\t\t...form,\n\t\t\t\t...currentFormValues,\n\t\t\t\tcontent\n\t\t\t});\n\t\t}\n\t}, [content, form, formRef]);\n\n\t// 新建文章时检查草稿\n\tuseEffect(() => {\n\t\tif (status === UpdateEnum.Edit) return;\n\n\t\tconst draft = localGet(draftKey);\n\t\tif (draft) {\n\t\t\tconst draftTime = new Date(draft.timestamp).toLocaleString();\n\t\t\tModal.confirm({\n\t\t\t\ttitle: \"发现未保存的草稿\",\n\t\t\t\tcontent: `检测到您在 ${draftTime} 有未保存的内容，是否恢复？`,\n\t\t\t\tokText: \"恢复\",\n\t\t\t\tcancelText: \"丢弃\",\n\t\t\t\tonOk: () => {\n\t\t\t\t\tsetContent(draft.content);\n\t\t\t\t\tsetForm(prev => ({ ...prev, ...draft }));\n\t\t\t\t\tformRef.setFieldsValue(draft);\n\t\t\t\t\tif (draft.cover) {\n\t\t\t\t\t\tlet coverUrl = getCompleteUrl(draft.cover);\n\t\t\t\t\t\tsetCoverList([\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\tuid: \"-1\",\n\t\t\t\t\t\t\t\tname: coverName,\n\t\t\t\t\t\t\t\tstatus: \"done\",\n\t\t\t\t\t\t\t\tthumbUrl: coverUrl,\n\t\t\t\t\t\t\t\turl: coverUrl\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t]);\n\t\t\t\t\t}\n\t\t\t\t\tmessage.success(\"已恢复草稿\");\n\t\t\t\t},\n\t\t\t\tonCancel: () => {\n\t\t\t\t\tlocalRemove(draftKey);\n\t\t\t\t}\n\t\t\t});\n\t\t}\n\t}, [draftKey, status, formRef]);\n\n\t//@ts-ignore\n\tconst { CategoryTypeList, CategoryType, PushStatusList } = props || {};\n\n\tconst onSure = useCallback(() => {\n\t\tsetQuery(prev => prev + 1);\n\t}, []);\n\n\tconst handleChange = (item: MapItem) => {\n\t\t// 使用函数式更新，避免多次连续调用时的状态覆盖问题\n\t\tsetForm(prev => ({ ...prev, ...item }));\n\t};\n\n\tconst handleFormRefChange = (item: MapItem) => {\n\t\t// 当自定义组件更新时，对 formRef 也进行更新\n\t\tformRef.setFieldsValue({ ...item });\n\t};\n\n\t// 抽屉关闭\n\tconst handleClose = () => {\n\t\tsetIsOpenDrawerShow(false);\n\t};\n\n\t// 更新 Markdown 中的图片尺寸 (Lazy Update 策略)\n\tconst updateImageInMarkdown = (imgElement: HTMLImageElement, width: number, height: number) => {\n\t\tconst src = imgElement.getAttribute(\"src\");\n\t\tif (!src) return;\n\n\t\t// 辅助函数：归一化路径\n\t\tconst resolveUrl = (urlStr: string) => {\n\t\t\tif (!urlStr) return \"\";\n\t\t\ttry {\n\t\t\t\tconst cleanUrl = urlStr.split(/\\s+/)[0];\n\t\t\t\treturn new URL(decodeURIComponent(cleanUrl), window.location.origin).href;\n\t\t\t} catch (e) {\n\t\t\t\treturn urlStr;\n\t\t\t}\n\t\t};\n\n\t\tconst fullUrl = resolveUrl(src);\n\n\t\t// 1. 记录变更到缓存 Map 中，而不触发 content 更新\n\t\timageSizeMapRef.current.set(fullUrl, { width: Math.round(width), height: Math.round(height) });\n\n\t\t// 2. 直接操作 DOM 样式以获得即时视觉反馈\n\t\timgElement.style.width = `${Math.round(width)}px`;\n\t\timgElement.style.height = `${Math.round(height)}px`;\n\n\t\t// 3. 同步 Moveable 控件位置\n\t\tupdateMoveable();\n\t};\n\n\t// 复原图片尺寸\n\tconst resetImageInMarkdown = (imgElement: HTMLImageElement) => {\n\t\tconst src = imgElement.getAttribute(\"src\");\n\t\tif (!src) return;\n\n\t\tconst fullUrl = resolveUrl(src);\n\n\t\t// 1. 在缓存中标记为 'reset'，而不是直接删除，这样批量更新时能知道要还原\n\t\timageSizeMapRef.current.set(fullUrl, \"reset\");\n\n\t\t// 2. 清除 DOM 样式\n\t\timgElement.style.width = \"\";\n\t\timgElement.style.height = \"\";\n\n\t\t// 3. 隐藏 Moveable\n\t\tsetTarget(null);\n\t\tmessage.success(\"已重置尺寸（保存后同步到源码）\");\n\t};\n\n\t// 替换图片功能 (立即更新策略)\n\tconst replaceImageInMarkdown = (imgElement: HTMLImageElement) => {\n\t\tconst input = document.createElement(\"input\");\n\t\tinput.type = \"file\";\n\t\tinput.accept = \"image/*\";\n\t\tinput.onchange = async e => {\n\t\t\tconst file = (e.target as HTMLInputElement).files?.[0];\n\t\t\tif (!file) return;\n\n\t\t\t// 限制图片大小\n\t\t\tif (file.size > 5 * 1024 * 1024) {\n\t\t\t\tmessage.error(\"图片大小不能超过 5M\");\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\tconst formData = new FormData();\n\t\t\tformData.append(\"image\", file);\n\n\t\t\tconst hide = message.loading(\"正在上传新图片...\", 0);\n\t\t\ttry {\n\t\t\t\t// 发起上传请求\n\t\t\t\tconst response = await uploadImgApi(formData);\n\t\t\t\thide();\n\n\t\t\t\t// 这里的 response 已经是拦截器处理后的 data (ResultData)\n\t\t\t\tconst { status, result } = (response as any) || {};\n\n\t\t\t\tif (status?.code === 0 && result?.imagePath) {\n\t\t\t\t\tconst oldSrc = imgElement.getAttribute(\"src\");\n\t\t\t\t\tconst newSrc = result.imagePath;\n\t\t\t\t\tif (!oldSrc) return;\n\n\t\t\t\t\tconst oldFullUrl = resolveUrl(oldSrc);\n\t\t\t\t\tconst alt = imgElement.getAttribute(\"alt\") || \"\";\n\n\t\t\t\t\t// 记录预览区当前滚动位置（用于预览区同步）\n\t\t\t\t\tif (container) {\n\t\t\t\t\t\tscrollPosRef.current = container.scrollTop;\n\t\t\t\t\t\tisUpdatingContentRef.current = true;\n\t\t\t\t\t}\n\n\t\t\t\t\t// 策略：优先通过编辑器实例直接分发变更，这能保持光标和滚动位置\n\t\t\t\t\tif (editorRef.current) {\n\t\t\t\t\t\ttry {\n\t\t\t\t\t\t\t// 更加鲁棒的内容获取方式\n\t\t\t\t\t\t\tconst state = editorRef.current.state;\n\t\t\t\t\t\t\tconst doc =\n\t\t\t\t\t\t\t\tstate?.doc?.toString() || (typeof editorRef.current.getValue === \"function\" ? editorRef.current.getValue() : \"\");\n\n\t\t\t\t\t\t\tif (doc) {\n\t\t\t\t\t\t\t\t// 1. 尝试匹配 Markdown 语法 ![alt](src)\n\t\t\t\t\t\t\t\tconst mdImgRegex = /!\\[(.*?)\\]\\((.*?)\\)/g;\n\t\t\t\t\t\t\t\tlet match;\n\t\t\t\t\t\t\t\tlet found = false;\n\n\t\t\t\t\t\t\t\twhile ((match = mdImgRegex.exec(doc)) !== null) {\n\t\t\t\t\t\t\t\t\tif (resolveUrl(match[2]) === oldFullUrl) {\n\t\t\t\t\t\t\t\t\t\tconst mdAlt = match[1] || alt;\n\t\t\t\t\t\t\t\t\t\tconst replacement = `![${mdAlt}](${newSrc})`;\n\n\t\t\t\t\t\t\t\t\t\t// 检查是否支持 CM6 的 dispatch\n\t\t\t\t\t\t\t\t\t\tif (typeof editorRef.current.dispatch === \"function\") {\n\t\t\t\t\t\t\t\t\t\t\teditorRef.current.dispatch({\n\t\t\t\t\t\t\t\t\t\t\t\tchanges: { from: match.index, to: match.index + (match[0]?.length || 0), insert: replacement },\n\t\t\t\t\t\t\t\t\t\t\t\tselection: { anchor: match.index },\n\t\t\t\t\t\t\t\t\t\t\t\tscrollIntoView: true\n\t\t\t\t\t\t\t\t\t\t\t});\n\t\t\t\t\t\t\t\t\t\t} else if (typeof editorRef.current.replaceRange === \"function\") {\n\t\t\t\t\t\t\t\t\t\t\t// 兼容 CM5\n\t\t\t\t\t\t\t\t\t\t\tconst posFrom = editorRef.current.posFromIndex(match.index);\n\t\t\t\t\t\t\t\t\t\t\tconst posTo = editorRef.current.posFromIndex(match.index + (match[0]?.length || 0));\n\t\t\t\t\t\t\t\t\t\t\teditorRef.current.replaceRange(replacement, posFrom, posTo);\n\t\t\t\t\t\t\t\t\t\t\teditorRef.current.setCursor(posFrom);\n\t\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\t\tfound = true;\n\t\t\t\t\t\t\t\t\t\tbreak;\n\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t}\n\n\t\t\t\t\t\t\t\t// 2. 如果 Markdown 语法没找到，尝试匹配已有的 HTML <img> 标签\n\t\t\t\t\t\t\t\tif (!found) {\n\t\t\t\t\t\t\t\t\tconst htmlImgRegex = /<img\\s+[^>]*src=[\"'](.*?)[\"'][^>]*>/g;\n\t\t\t\t\t\t\t\t\twhile ((match = htmlImgRegex.exec(doc)) !== null) {\n\t\t\t\t\t\t\t\t\t\tif (resolveUrl(match[1]) === oldFullUrl) {\n\t\t\t\t\t\t\t\t\t\t\tconst altMatch = match[0].match(/alt=[\"'](.*?)[\"']/);\n\t\t\t\t\t\t\t\t\t\t\tconst currentAlt = altMatch ? altMatch[1] : alt;\n\t\t\t\t\t\t\t\t\t\t\tconst replacement = `<img src=\"${newSrc}\" alt=\"${currentAlt}\" />`;\n\n\t\t\t\t\t\t\t\t\t\t\tif (typeof editorRef.current.dispatch === \"function\") {\n\t\t\t\t\t\t\t\t\t\t\t\teditorRef.current.dispatch({\n\t\t\t\t\t\t\t\t\t\t\t\t\tchanges: { from: match.index, to: match.index + (match[0]?.length || 0), insert: replacement },\n\t\t\t\t\t\t\t\t\t\t\t\t\tselection: { anchor: match.index },\n\t\t\t\t\t\t\t\t\t\t\t\t\tscrollIntoView: true\n\t\t\t\t\t\t\t\t\t\t\t\t});\n\t\t\t\t\t\t\t\t\t\t\t} else if (typeof editorRef.current.replaceRange === \"function\") {\n\t\t\t\t\t\t\t\t\t\t\t\tconst posFrom = editorRef.current.posFromIndex(match.index);\n\t\t\t\t\t\t\t\t\t\t\t\tconst posTo = editorRef.current.posFromIndex(match.index + (match[0]?.length || 0));\n\t\t\t\t\t\t\t\t\t\t\t\teditorRef.current.replaceRange(replacement, posFrom, posTo);\n\t\t\t\t\t\t\t\t\t\t\t\teditorRef.current.setCursor(posFrom);\n\t\t\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\t\t\tfound = true;\n\t\t\t\t\t\t\t\t\t\t\tbreak;\n\t\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t}\n\n\t\t\t\t\t\t\t\tif (found) {\n\t\t\t\t\t\t\t\t\t// 清除该图片的尺寸缓存\n\t\t\t\t\t\t\t\t\timageSizeMapRef.current.delete(oldFullUrl);\n\t\t\t\t\t\t\t\t\tmessage.success(\"图片替换成功\");\n\t\t\t\t\t\t\t\t\tsetTarget(null);\n\t\t\t\t\t\t\t\t\treturn;\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t} catch (editorErr) {\n\t\t\t\t\t\t\tconsole.error(\"Editor direct update failed, falling back to setContent:\", editorErr);\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\n\t\t\t\t\t// Fallback: 如果没有编辑器实例或直接更新失败，使用传统的 setContent\n\t\t\t\t\tsetContent(prevContent => {\n\t\t\t\t\t\tconst mdImgRegex = /!\\[(.*?)\\]\\((.*?)\\)/g;\n\t\t\t\t\t\tlet match;\n\t\t\t\t\t\tlet newContent = prevContent;\n\t\t\t\t\t\tlet found = false;\n\n\t\t\t\t\t\twhile ((match = mdImgRegex.exec(prevContent)) !== null) {\n\t\t\t\t\t\t\tif (resolveUrl(match[2]) === oldFullUrl) {\n\t\t\t\t\t\t\t\tconst mdAlt = match[1] || alt;\n\t\t\t\t\t\t\t\tconst replacement = `![${mdAlt}](${newSrc})`;\n\t\t\t\t\t\t\t\tnewContent =\n\t\t\t\t\t\t\t\t\tprevContent.substring(0, match.index) + replacement + prevContent.substring(match.index + match[0].length);\n\t\t\t\t\t\t\t\tfound = true;\n\t\t\t\t\t\t\t\tbreak;\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t}\n\n\t\t\t\t\t\tif (!found) {\n\t\t\t\t\t\t\tconst htmlImgRegex = /<img\\s+[^>]*src=[\"'](.*?)[\"'][^>]*>/g;\n\t\t\t\t\t\t\twhile ((match = htmlImgRegex.exec(prevContent)) !== null) {\n\t\t\t\t\t\t\t\tif (resolveUrl(match[1]) === oldFullUrl) {\n\t\t\t\t\t\t\t\t\tconst altMatch = match[0].match(/alt=[\"'](.*?)[\"']/);\n\t\t\t\t\t\t\t\t\tconst currentAlt = altMatch ? altMatch[1] : alt;\n\t\t\t\t\t\t\t\t\tconst replacement = `<img src=\"${newSrc}\" alt=\"${currentAlt}\" />`;\n\t\t\t\t\t\t\t\t\tnewContent =\n\t\t\t\t\t\t\t\t\t\tprevContent.substring(0, match.index) + replacement + prevContent.substring(match.index + match[0].length);\n\t\t\t\t\t\t\t\t\tfound = true;\n\t\t\t\t\t\t\t\t\tbreak;\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t}\n\n\t\t\t\t\t\tif (newContent !== prevContent) {\n\t\t\t\t\t\t\thandleChange({ content: newContent });\n\t\t\t\t\t\t\timageSizeMapRef.current.delete(oldFullUrl);\n\t\t\t\t\t\t\tmessage.success(\"图片替换成功\");\n\t\t\t\t\t\t\tsetTarget(null);\n\t\t\t\t\t\t}\n\t\t\t\t\t\treturn newContent;\n\t\t\t\t\t});\n\t\t\t\t} else {\n\t\t\t\t\t// status.code !== 0 的情况拦截器已经处理并报错了，这里不需要重复提示\n\t\t\t\t\t// 除非拦截器没报错（虽然不太可能）\n\t\t\t\t\tif (!status?.msg) {\n\t\t\t\t\t\tmessage.error(\"图片上传失败\");\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t} catch (err: any) {\n\t\t\t\thide();\n\t\t\t\tconsole.error(\"Replace image error:\", err);\n\t\t\t\t// 如果拦截器已经报错并拒绝了 Promise，err 会包含 status 对象\n\t\t\t\tif (err?.status?.msg) {\n\t\t\t\t\t// 业务错误，拦截器已提示\n\t\t\t\t} else {\n\t\t\t\t\tmessage.error(\"网络错误或上传超时\");\n\t\t\t\t}\n\t\t\t}\n\t\t};\n\t\tinput.click();\n\t};\n\n\t// 复制图片\n\tconst copyImageToClipboard = async (imgElement: HTMLImageElement) => {\n\t\tconst src = imgElement.getAttribute(\"src\");\n\t\tif (!src) return;\n\t\tconst fullUrl = resolveUrl(src);\n\n\t\tconst hide = message.loading(\"正在处理图片...\", 0);\n\n\t\ttry {\n\t\t\t// 使用 Canvas 方式将图片转换为 PNG Blob\n\t\t\t// 创建一个 Promise，该 Promise 会解析为图片的 Blob 数据\n\t\t\tconst blobPromise = new Promise<Blob>((resolve, reject) => {\n\t\t\t\tconst img = new Image();\n\t\t\t\t// 关键：设置 crossOrigin 为 Anonymous 以请求跨域访问权限\n\t\t\t\timg.crossOrigin = \"Anonymous\";\n\t\t\t\t// 添加时间戳以避开浏览器缓存，确保获取到最新的 CORS 响应头\n\t\t\t\timg.src = `${fullUrl}${fullUrl.includes(\"?\") ? \"&\" : \"?\"}t=${new Date().getTime()}`;\n\n\t\t\t\timg.onload = () => {\n\t\t\t\t\ttry {\n\t\t\t\t\t\tconst canvas = document.createElement(\"canvas\");\n\t\t\t\t\t\tcanvas.width = img.naturalWidth;\n\t\t\t\t\t\tcanvas.height = img.naturalHeight;\n\t\t\t\t\t\tconst ctx = canvas.getContext(\"2d\");\n\t\t\t\t\t\tif (!ctx) {\n\t\t\t\t\t\t\treject(new Error(\"Canvas context failed\"));\n\t\t\t\t\t\t\treturn;\n\t\t\t\t\t\t}\n\t\t\t\t\t\tctx.drawImage(img, 0, 0);\n\t\t\t\t\t\t// 导出为 PNG，这是剪贴板最通用的格式\n\t\t\t\t\t\tcanvas.toBlob(b => {\n\t\t\t\t\t\t\tif (b) resolve(b);\n\t\t\t\t\t\t\telse reject(new Error(\"Blob creation failed\"));\n\t\t\t\t\t\t}, \"image/png\");\n\t\t\t\t\t} catch (e) {\n\t\t\t\t\t\treject(e);\n\t\t\t\t\t}\n\t\t\t\t};\n\n\t\t\t\timg.onerror = () => {\n\t\t\t\t\treject(new Error(\"Image load failed (CORS or Network)\"));\n\t\t\t\t};\n\t\t\t});\n\n\t\t\ttry {\n\t\t\t\t// 尝试 Safari/新版 Chrome 的 Promise 写法\n\t\t\t\t// 直接将 Promise 传递给 ClipboardItem，确保在用户点击事件中立即调用 write\n\t\t\t\t// @ts-ignore\n\t\t\t\tconst item = new ClipboardItem({ \"image/png\": blobPromise });\n\t\t\t\tawait navigator.clipboard.write([item]);\n\t\t\t} catch (e: any) {\n\t\t\t\t// 兼容性降级：如果浏览器不支持 Promise 构造 (旧版 Chrome/Firefox 会报 TypeError)\n\t\t\t\t// 尝试等待 Blob 生成后再写入\n\t\t\t\tconsole.warn(\"ClipboardItem Promise approach failed, falling back to Blob...\", e);\n\t\t\t\tconst blob = await blobPromise;\n\t\t\t\t// @ts-ignore\n\t\t\t\tconst item = new ClipboardItem({ \"image/png\": blob });\n\t\t\t\tawait navigator.clipboard.write([item]);\n\t\t\t}\n\n\t\t\thide();\n\t\t\tmessage.success(\"图片已复制到剪贴板\");\n\t\t} catch (err) {\n\t\t\thide();\n\t\t\tconsole.error(\"Copy image failed:\", err);\n\n\t\t\t// 降级处理：如果因为跨域等原因失败，自动回退到复制链接\n\t\t\ttry {\n\t\t\t\tawait navigator.clipboard.writeText(fullUrl);\n\t\t\t\tmessage.warning({\n\t\t\t\t\tcontent: \"因跨域限制无法复制图片数据，已为您复制图片链接\",\n\t\t\t\t\tduration: 3\n\t\t\t\t});\n\t\t\t} catch (clipboardErr) {\n\t\t\t\t// 如果连链接都复制失败（极少见），再弹窗提示\n\t\t\t\tModal.error({\n\t\t\t\t\ttitle: \"复制图片失败\",\n\t\t\t\t\tcontent: (\n\t\t\t\t\t\t<div>\n\t\t\t\t\t\t\t<p>无法直接复制该图片数据，原因可能是：</p>\n\t\t\t\t\t\t\t<ol>\n\t\t\t\t\t\t\t\t<li>图片服务器未配置 CORS 允许跨域访问（最常见）</li>\n\t\t\t\t\t\t\t\t<li>浏览器安全策略限制</li>\n\t\t\t\t\t\t\t</ol>\n\t\t\t\t\t\t\t<p>建议：尝试手动右键图片选择&quot;复制图片&quot;。</p>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t),\n\t\t\t\t\tokText: \"知道了\"\n\t\t\t\t});\n\t\t\t}\n\t\t}\n\t};\n\n\tconst goBack = () => {\n\t\t// 跳转到文章列表页\n\t\tnavigate(\"/article/list/index\");\n\t};\n\n\t// 重置表单\n\tconst resetFrom = () => {\n\t\tsetForm(defaultInitForm);\n\t\tformRef.resetFields();\n\t\tsetCoverList([]);\n\t};\n\n\t// 保存或者更新\n\tconst handleSaveOrUpdate = async () => {\n\t\t// 1. 将缓存的图片尺寸变更批量应用到 Markdown 内容中\n\t\tconst sizeMap = imageSizeMapRef.current;\n\t\tif (sizeMap.size > 0) {\n\t\t\tlet newContent = content;\n\t\t\tconst resolveUrl = (urlStr: string) => {\n\t\t\t\tif (!urlStr) return \"\";\n\t\t\t\ttry {\n\t\t\t\t\tconst cleanUrl = urlStr.split(/\\s+/)[0];\n\t\t\t\t\treturn new URL(decodeURIComponent(cleanUrl), window.location.origin).href;\n\t\t\t\t} catch (e) {\n\t\t\t\t\treturn urlStr;\n\t\t\t\t}\n\t\t\t};\n\n\t\t\t// 匹配所有图片\n\t\t\tconst mdImgRegex = /!\\[(.*?)\\]\\((.*?)\\)/g;\n\t\t\tconst htmlImgRegex = /<img\\s+[^>]*src=[\"'](.*?)[\"'][^>]*>/g;\n\n\t\t\t// 使用简单的 replace 回调处理批量更新\n\t\t\tnewContent = newContent.replace(mdImgRegex, (match, alt, src) => {\n\t\t\t\tconst fullUrl = resolveUrl(src);\n\t\t\t\tconst state = sizeMap.get(fullUrl);\n\t\t\t\tif (state && typeof state === \"object\") {\n\t\t\t\t\treturn `<img src=\"${src.split(/\\s+/)[0]}\" alt=\"${alt}\" width=\"${state.width}\" height=\"${state.height}\" />`;\n\t\t\t\t}\n\t\t\t\treturn match;\n\t\t\t});\n\n\t\t\tnewContent = newContent.replace(htmlImgRegex, (match, src) => {\n\t\t\t\tconst fullUrl = resolveUrl(src);\n\t\t\t\tconst state = sizeMap.get(fullUrl);\n\n\t\t\t\t// 提取原标签中的 alt 属性\n\t\t\t\tconst altMatch = match.match(/alt=[\"'](.*?)[\"']/);\n\t\t\t\tconst currentAlt = altMatch ? altMatch[1] : \"\";\n\n\t\t\t\tif (state === \"reset\") {\n\t\t\t\t\t// 还原为 Markdown 语法，保留提取到的 alt\n\t\t\t\t\treturn `![${currentAlt}](${src})`;\n\t\t\t\t} else if (state && typeof state === \"object\") {\n\t\t\t\t\t// 替换或添加 width/height 属性，同时保留 alt\n\t\t\t\t\treturn `<img src=\"${src}\" alt=\"${currentAlt}\" width=\"${state.width}\" height=\"${state.height}\" />`;\n\t\t\t\t}\n\t\t\t\treturn match;\n\t\t\t});\n\n\t\t\tif (newContent !== content) {\n\t\t\t\t// 记录滚动位置，防止批量更新时跳动\n\t\t\t\tif (container) {\n\t\t\t\t\tscrollPosRef.current = container.scrollTop;\n\t\t\t\t\tisUpdatingContentRef.current = true;\n\t\t\t\t}\n\n\t\t\t\tsetContent(newContent);\n\t\t\t\thandleChange({ content: newContent });\n\t\t\t\t// 清空缓存\n\t\t\t\tsizeMap.clear();\n\t\t\t}\n\t\t}\n\n\t\t// 2. 弹出抽屉\n\t\tsetIsOpenDrawerShow(true);\n\t};\n\n\t// 判断图片的链接是否已经上传过了\n\tconst canUpload = (url: string) => {\n\t\t// 当前的时间\n\t\tconst now = Date.now();\n\n\t\tconst lastUploadTime = lastUploadTimes.current.get(url);\n\t\t// 如果没有上传过，或者上传时间超过了 30s，就返回 false\n\t\tif (lastUploadTime && now - lastUploadTime < 30000) {\n\t\t\treturn false;\n\t\t}\n\t\t// 更新上传时间\n\t\tlastUploadTimes.current.set(url, now);\n\t\treturn true;\n\t};\n\n\t// 如果是外网的图片链接，转成内网的图片链接\n\tconst uploadImages = async (newVal: string) => {\n\t\t// 正则表达式匹配所有图片\n\t\tconst reg = /!\\[(.*?)\\]\\((.*?)\\)/gm;\n\t\tlet match;\n\n\t\t// 存储需要上传的图片信息及其上传任务\n\t\tinterface UploadTask {\n\t\t\timageInfo: ImageInfo;\n\t\t\tuploadPromise: Promise<any>;\n\t\t}\n\t\tlet uploadTasksWithInfo: UploadTask[] = [];\n\t\tlet successCount = 0;\n\t\tlet failedCount = 0;\n\t\tlet skippedCount = 0;\n\n\t\twhile ((match = reg.exec(newVal)) !== null) {\n\t\t\tconst [img, alt, src] = match;\n\t\t\tconsole.log(\"img, alt, src\", match, img, alt, src);\n\n\t\t\t// 判断是否需要转链:\n\t\t\t// 1. 外链图片 (http/https 开头)\n\t\t\t// 2. 包含 saveError 的失败图片也要重试\n\t\t\tconst isExternalImage = src.length > 0 && src.startsWith(\"http\");\n\t\t\tconst isFailedImage = src.indexOf(\"saveError\") >= 0;\n\n\t\t\tif (isExternalImage && !isFailedImage) {\n\t\t\t\t// 普通外链图片，检查 30 秒防重复\n\t\t\t\tif (!canUpload(src)) {\n\t\t\t\t\tconsole.log(\"30秒内防重复提交，忽略:\", src);\n\t\t\t\t\tskippedCount++;\n\t\t\t\t\tcontinue;\n\t\t\t\t}\n\n\t\t\t\t// 收集图片信息和上传任务，保持一一对应\n\t\t\t\tconst imageInfo: ImageInfo = { img, alt, src, index: match.index };\n\t\t\t\tuploadTasksWithInfo.push({\n\t\t\t\t\timageInfo,\n\t\t\t\t\tuploadPromise: saveImgApi(src)\n\t\t\t\t});\n\t\t\t} else if (isFailedImage) {\n\t\t\t\t// 失败的图片，提取原始 URL 并重试\n\t\t\t\t// URL 格式: https://files.mdnice.com/...?&cause=saveError!\n\t\t\t\tconst originalUrl = src.split(\"?\")[0]; // 去掉 query 参数\n\t\t\t\tconsole.log(\"重试失败的图片:\", originalUrl);\n\n\t\t\t\tconst imageInfo: ImageInfo = { img, alt, src, index: match.index };\n\t\t\t\tuploadTasksWithInfo.push({\n\t\t\t\t\timageInfo,\n\t\t\t\t\tuploadPromise: saveImgApi(originalUrl) // 用原始 URL 重试\n\t\t\t\t});\n\t\t\t}\n\t\t}\n\n\t\t// 如果没有需要上传的图片，直接返回\n\t\tif (uploadTasksWithInfo.length === 0) {\n\t\t\treturn { newContent: newVal, successCount, failedCount, skippedCount };\n\t\t}\n\n\t\t// 同时上传所有图片\n\t\tconst results = await Promise.all(uploadTasksWithInfo.map(task => task.uploadPromise));\n\n\t\t// 按照图片在文本中的位置倒序排序，从后往前替换，避免索引错位\n\t\tconst sortedTasks = [...uploadTasksWithInfo].sort((a, b) => b.imageInfo.index - a.imageInfo.index);\n\n\t\t// 替换所有图片链接\n\t\tlet newContent = newVal;\n\t\tsortedTasks.forEach(task => {\n\t\t\t// 找到对应的 result（需要用原始顺序的索引）\n\t\t\tconst originalIndex = uploadTasksWithInfo.indexOf(task);\n\t\t\tconst result = results[originalIndex];\n\n\t\t\tif (result.status && result.status.code === 0 && result.result && result.result.imagePath) {\n\t\t\t\tconst newImagePath = result.result.imagePath;\n\n\t\t\t\t// 检查返回的路径是否包含 saveError,如果包含说明转链失败\n\t\t\t\tif (newImagePath.indexOf(\"saveError\") >= 0) {\n\t\t\t\t\tconsole.log(\"转链失败(返回 saveError) - 原:\", task.imageInfo.src);\n\t\t\t\t\tconsole.log(\"转链失败(返回 saveError) - 返回:\", newImagePath);\n\t\t\t\t\tfailedCount++;\n\t\t\t\t\t// 不替换内容,保持原样\n\t\t\t\t} else {\n\t\t\t\t\t// 真正转链成功,替换为新路径\n\t\t\t\t\tconst newSrc = `![${task.imageInfo.alt}](${newImagePath})`;\n\t\t\t\t\tconsole.log(\"转链成功 - 原:\", task.imageInfo.src);\n\t\t\t\t\tconsole.log(\"转链成功 - 新:\", newImagePath);\n\t\t\t\t\t// 从后往前替换，避免影响前面的索引\n\t\t\t\t\tnewContent = newContent.replace(task.imageInfo.img, newSrc);\n\t\t\t\t\tsuccessCount++;\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\tconsole.log(\"转链失败(API错误):\", task.imageInfo.src, result);\n\t\t\t\tfailedCount++;\n\t\t\t}\n\t\t});\n\n\t\treturn { newContent, successCount, failedCount, skippedCount };\n\t};\n\n\tconst handleReplaceImgUrl = async () => {\n\t\tconst { content } = form;\n\n\t\t// 检查是否有外链图片或失败的图片需要转换\n\t\tconst hasExternalImages = /!\\[.*?\\]\\(https?:\\/\\/.*?\\)/.test(content);\n\t\tif (!hasExternalImages) {\n\t\t\tmessage.info(\"当前内容中没有外链图片需要转换\");\n\t\t\treturn;\n\t\t}\n\n\t\tconst result = await uploadImages(content);\n\t\tconst { newContent, successCount, failedCount, skippedCount } = result;\n\n\t\t// 更新内容\n\t\tif (newContent !== content) {\n\t\t\tsetContent(newContent);\n\t\t\thandleChange({ content: newContent });\n\t\t}\n\n\t\t// 构建详细的反馈消息\n\t\tconst messages = [];\n\t\tif (successCount > 0) {\n\t\t\tmessages.push(`成功转链 ${successCount} 张图片`);\n\t\t}\n\t\tif (failedCount > 0) {\n\t\t\tmessages.push(`失败 ${failedCount} 张`);\n\t\t}\n\t\tif (skippedCount > 0) {\n\t\t\tmessages.push(`跳过 ${skippedCount} 张(30秒内已转换)`);\n\t\t}\n\n\t\tif (successCount > 0 && failedCount === 0) {\n\t\t\tmessage.success(messages.join(\", \"));\n\t\t} else if (successCount > 0 && failedCount > 0) {\n\t\t\tmessage.warning(messages.join(\", \"));\n\t\t} else if (failedCount > 0) {\n\t\t\tmessage.error(messages.join(\", \"));\n\t\t} else if (skippedCount > 0) {\n\t\t\tmessage.info(\"所有外链图片都在 30 秒内已转换过,请稍后再试\");\n\t\t} else {\n\t\t\tmessage.info(\"没有需要转换的图片\");\n\t\t}\n\t};\n\n\tconst sanitizeYuqueMarkdown = (raw: string) => {\n\t\tconst fixYuqueStrongMarkers = (input: string) => {\n\t\t\tlet inCodeBlock = false;\n\n\t\t\tconst fixLine = (line: string) => {\n\t\t\t\tif (/^\\s*\\*{3,}\\s*$/.test(line)) return line;\n\n\t\t\t\tlet out = line;\n\n\t\t\t\tout = out.replace(/([A-Za-z0-9])\\*\\*([，,。.!?？；;：:])/g, \"$1$2**\");\n\n\t\t\t\tconst openMatch = out.match(/([，,])\\*\\*/);\n\t\t\t\tif (openMatch?.index !== undefined) {\n\t\t\t\t\tconst openIndex = openMatch.index + openMatch[1].length;\n\t\t\t\t\tconst openEnd = openIndex + 2;\n\n\t\t\t\t\tconst minCommaSearchStart = openEnd + 2;\n\t\t\t\t\tconst commaPos = out.indexOf(\"，\", minCommaSearchStart);\n\t\t\t\t\tif (commaPos !== -1) {\n\t\t\t\t\t\tconst closeCandidatePos = out.indexOf(\"**\", commaPos + 1);\n\t\t\t\t\t\tif (closeCandidatePos !== -1) {\n\t\t\t\t\t\t\tconst right = out[closeCandidatePos + 2] ?? \"\";\n\t\t\t\t\t\t\tif (!right || /[\\s\\p{P}\\p{S}\\p{Extended_Pictographic}\\p{Emoji_Presentation}]/u.test(right)) {\n\t\t\t\t\t\t\t\tout = out.slice(0, closeCandidatePos) + out.slice(closeCandidatePos + 2);\n\t\t\t\t\t\t\t\tout = out.slice(0, commaPos) + \"**\" + out.slice(commaPos);\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t}\n\n\t\t\t\tout = out.replace(/\\*\\*([\\p{Extended_Pictographic}\\p{Emoji_Presentation}\\s]+)\\*\\*/gu, \"$1\");\n\t\t\t\tout = out.replace(/\\*{4,}/g, \"**\");\n\n\t\t\t\treturn out;\n\t\t\t};\n\n\t\t\treturn input\n\t\t\t\t.split(\"\\n\")\n\t\t\t\t.map(line => {\n\t\t\t\t\tconst trimmed = line.trimStart();\n\t\t\t\t\tif (trimmed.startsWith(\"```\")) {\n\t\t\t\t\t\tinCodeBlock = !inCodeBlock;\n\t\t\t\t\t\treturn line;\n\t\t\t\t\t}\n\t\t\t\t\tif (inCodeBlock) return line;\n\t\t\t\t\treturn fixLine(line);\n\t\t\t\t})\n\t\t\t\t.join(\"\\n\");\n\t\t};\n\n\t\tlet text = raw || \"\";\n\t\ttext = text.replace(/^\\uFEFF/, \"\");\n\t\ttext = text.replace(/\\r\\n?/g, \"\\n\");\n\t\ttext = text.replace(/[\\u200B-\\u200D\\uFEFF]/g, \"\");\n\t\ttext = text.replace(/<!--[\\s\\S]*?-->/g, \"\");\n\t\ttext = text.replace(/<\\/?font\\b[^>]*>/gi, \"\");\n\t\ttext = fixYuqueStrongMarkers(text);\n\t\ttext = text.replace(/!\\[([^\\]]*)\\]\\(\\s*`?\\s*([^`)\\s]+)\\s*`?\\s*\\)/g, (_m, alt, url) => {\n\t\t\tconst safeAlt = String(alt ?? \"\").trim();\n\t\t\tconst safeUrl = String(url ?? \"\").trim();\n\t\t\treturn `![${safeAlt}](${safeUrl})`;\n\t\t});\n\t\ttext = text.replace(/\\n{3,}/g, \"\\n\\n\");\n\t\treturn text.trim();\n\t};\n\n\t// 解析简单的 YAML 格式 Front Matter\n\tconst parseSimpleYaml = (yamlText: string) => {\n\t\tconst data: any = {};\n\t\tconst lines = yamlText.split(\"\\n\");\n\t\tlet currentKey = \"\";\n\n\t\tfor (let line of lines) {\n\t\t\tconst trimmedLine = line.trim();\n\t\t\tif (!trimmedLine) continue;\n\n\t\t\t// 匹配 key: value\n\t\t\tconst kvMatch = trimmedLine.match(/^(\\w+):\\s*(.*)$/);\n\t\t\tif (kvMatch) {\n\t\t\t\tcurrentKey = kvMatch[1];\n\t\t\t\tconst value = kvMatch[2].trim();\n\t\t\t\t// 如果值为空，可能是列表开始\n\t\t\t\tif (value === \"\" || value === \"-\") {\n\t\t\t\t\tdata[currentKey] = [];\n\t\t\t\t} else {\n\t\t\t\t\tdata[currentKey] = value;\n\t\t\t\t}\n\t\t\t} else if (trimmedLine.startsWith(\"-\") && currentKey) {\n\t\t\t\t// 匹配列表项 - value\n\t\t\t\tconst value = trimmedLine.substring(1).trim();\n\t\t\t\tif (!Array.isArray(data[currentKey])) {\n\t\t\t\t\tdata[currentKey] = [];\n\t\t\t\t}\n\t\t\t\tdata[currentKey].push(value);\n\t\t\t}\n\t\t}\n\t\treturn data;\n\t};\n\n\tconst handleImportMarkdown = () => {\n\t\tconst input = document.createElement(\"input\");\n\t\tinput.type = \"file\";\n\t\tinput.accept = \".md,.markdown,.txt,text/markdown,text/plain\";\n\t\tinput.style.display = \"none\";\n\n\t\tinput.onchange = async (e: Event) => {\n\t\t\tconst target = e.target as HTMLInputElement;\n\t\t\tconst file = target.files?.[0];\n\n\t\t\tif (document.body.contains(input)) {\n\t\t\t\tdocument.body.removeChild(input);\n\t\t\t}\n\n\t\t\tif (!file) return;\n\n\t\t\tconst fileName = file.name || \"\";\n\t\t\tconst isMarkdown = /\\.(md|markdown|txt)$/i.test(fileName);\n\t\t\tif (!isMarkdown) {\n\t\t\t\tmessage.error(\"仅支持 .md/.markdown/.txt 文件\");\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\tconst loadingKey = \"markdown-import-loading\";\n\t\t\tmessage.loading({ content: \"正在导入 Markdown...\", key: loadingKey, duration: 0 });\n\n\t\t\ttry {\n\t\t\t\tconst raw = await file.text();\n\t\t\t\tlet markdown = sanitizeYuqueMarkdown(raw);\n\n\t\t\t\t// 1. 解析 Front Matter\n\t\t\t\tconst fmRegex = /^---\\s*\\n([\\s\\S]*?)\\n\\s*---\\s*(\\n|$)/;\n\t\t\t\tconst fmMatch = markdown.match(fmRegex);\n\t\t\t\tlet fmData: any = null;\n\t\t\t\tif (fmMatch) {\n\t\t\t\t\tconst fmText = fmMatch[1];\n\t\t\t\t\t// 从正文中剔除模板\n\t\t\t\t\tmarkdown = markdown.replace(fmRegex, \"\").trimStart();\n\t\t\t\t\tfmData = parseSimpleYaml(fmText);\n\t\t\t\t\tconsole.log(\"解析到的 Front Matter:\", fmData);\n\t\t\t\t}\n\n\t\t\t\t// 2. 解析 H1 标题\n\t\t\t\tlet articleTitle = \"\";\n\t\t\t\tconst h1Match = markdown.match(/^\\s*#\\s+(.+)\\s*$/m);\n\t\t\t\tif (h1Match) {\n\t\t\t\t\tarticleTitle = h1Match[1].trim();\n\t\t\t\t\tmarkdown = markdown.replace(/^\\s*#\\s+.+\\s*\\n*/m, \"\").trimStart();\n\t\t\t\t}\n\n\t\t\t\t// 3. 准备元数据更新\n\t\t\t\tconst updateData: MapItem = {};\n\t\t\t\tlet foundTags: any[] = [];\n\n\t\t\t\tif (fmData) {\n\t\t\t\t\tif (fmData.title) updateData.title = fmData.title;\n\t\t\t\t\tif (fmData.shortTitle) updateData.shortTitle = fmData.shortTitle;\n\t\t\t\t\tif (fmData.description) updateData.summary = fmData.description;\n\n\t\t\t\t\t// 状态设置为已发布 (Number 格式用于 form 状态)\n\t\t\t\t\tupdateData.status = Number(PushStatusEnum.Published);\n\n\t\t\t\t\t// 分类匹配\n\t\t\t\t\tif (fmData.category) {\n\t\t\t\t\t\tconst catName = Array.isArray(fmData.category) ? fmData.category[0] : fmData.category;\n\t\t\t\t\t\tconst category = CategoryTypeList?.find((item: any) => item.label === catName);\n\t\t\t\t\t\tif (category) {\n\t\t\t\t\t\t\tupdateData.categoryId = category.value;\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\n\t\t\t\t\t// 标签匹配 (限制最多3个)\n\t\t\t\t\tif (fmData.tag) {\n\t\t\t\t\t\tconst tagNames = Array.isArray(fmData.tag) ? fmData.tag : [fmData.tag];\n\t\t\t\t\t\ttry {\n\t\t\t\t\t\t\tconst tagPromises = tagNames\n\t\t\t\t\t\t\t\t.slice(0, 3)\n\t\t\t\t\t\t\t\t.map((name: string) => getTagListApi({ status: 1, tag: name.trim(), pageNumber: 1, pageSize: 1 }));\n\t\t\t\t\t\t\tconst tagResults = await Promise.all(tagPromises);\n\t\t\t\t\t\t\tfoundTags = tagResults.map((res: any) => res.result?.list?.[0]).filter(t => t);\n\t\t\t\t\t\t\tif (foundTags.length > 0) {\n\t\t\t\t\t\t\t\tupdateData.tagIds = foundTags.map(t => t.tagId);\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t} catch (e) {\n\t\t\t\t\t\t\tconsole.warn(\"查找标签失败:\", e);\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t}\n\n\t\t\t\tconst shouldImport = await new Promise<\"append\" | \"replace\" | \"cancel\">(resolve => {\n\t\t\t\t\tif (content && content.trim()) {\n\t\t\t\t\t\tconst modal = Modal.info({\n\t\t\t\t\t\t\ttitle: \"当前编辑器有内容\",\n\t\t\t\t\t\t\tcontent: \"是否替换或追加导入的 Markdown？\",\n\t\t\t\t\t\t\ticon: null,\n\t\t\t\t\t\t\tclosable: true,\n\t\t\t\t\t\t\tokButtonProps: { style: { display: \"none\" } },\n\t\t\t\t\t\t\tfooter: (\n\t\t\t\t\t\t\t\t<div style={{ display: \"flex\", justifyContent: \"flex-end\", gap: \"8px\" }}>\n\t\t\t\t\t\t\t\t\t<Button\n\t\t\t\t\t\t\t\t\t\tonClick={() => {\n\t\t\t\t\t\t\t\t\t\t\tmodal.destroy();\n\t\t\t\t\t\t\t\t\t\t\tresolve(\"cancel\");\n\t\t\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t取消\n\t\t\t\t\t\t\t\t\t</Button>\n\t\t\t\t\t\t\t\t\t<Button\n\t\t\t\t\t\t\t\t\t\tonClick={() => {\n\t\t\t\t\t\t\t\t\t\t\tmodal.destroy();\n\t\t\t\t\t\t\t\t\t\t\tresolve(\"replace\");\n\t\t\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t替换内容\n\t\t\t\t\t\t\t\t\t</Button>\n\t\t\t\t\t\t\t\t\t<Button\n\t\t\t\t\t\t\t\t\t\ttype=\"primary\"\n\t\t\t\t\t\t\t\t\t\tonClick={() => {\n\t\t\t\t\t\t\t\t\t\t\tmodal.destroy();\n\t\t\t\t\t\t\t\t\t\t\tresolve(\"append\");\n\t\t\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t追加到末尾\n\t\t\t\t\t\t\t\t\t</Button>\n\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t)\n\t\t\t\t\t\t});\n\t\t\t\t\t} else {\n\t\t\t\t\t\tresolve(\"replace\");\n\t\t\t\t\t}\n\t\t\t\t});\n\n\t\t\t\tif (shouldImport === \"cancel\") {\n\t\t\t\t\tmessage.info({ content: \"已取消导入\", key: loadingKey });\n\t\t\t\t\treturn;\n\t\t\t\t}\n\n\t\t\t\t// 4. 执行更新\n\t\t\t\tconst finalUpdateData: MapItem = { ...updateData, content: markdown };\n\n\t\t\t\t// 处理标题逻辑：优先使用模板里的，没有则使用 H1\n\t\t\t\tif (!finalUpdateData.shortTitle && articleTitle) {\n\t\t\t\t\tfinalUpdateData.shortTitle = articleTitle;\n\t\t\t\t}\n\n\t\t\t\tif (shouldImport === \"append\") {\n\t\t\t\t\tconst appendedContent = (content ? content.trimEnd() : \"\") + \"\\n\\n---\\n\\n\" + markdown;\n\t\t\t\t\tfinalUpdateData.content = appendedContent;\n\t\t\t\t\tsetContent(appendedContent);\n\t\t\t\t} else {\n\t\t\t\t\tsetContent(markdown);\n\t\t\t\t}\n\n\t\t\t\t// 统一执行状态更新\n\t\t\t\thandleChange(finalUpdateData);\n\n\t\t\t\t// 更新 AntD 表单 UI\n\t\t\t\tconst formValues: any = {\n\t\t\t\t\t...finalUpdateData,\n\t\t\t\t\tstatus: fmData ? String(PushStatusEnum.Published) : undefined\n\t\t\t\t};\n\t\t\t\tif (foundTags.length > 0) {\n\t\t\t\t\tformValues.tagName = foundTags.map(t => ({\n\t\t\t\t\t\tkey: t.tagId,\n\t\t\t\t\t\tlabel: t.tag,\n\t\t\t\t\t\tvalue: t.tag\n\t\t\t\t\t}));\n\t\t\t\t}\n\t\t\t\tformRef.setFieldsValue(formValues);\n\n\t\t\t\tmessage.success({\n\t\t\t\t\tcontent: shouldImport === \"append\" ? \"Markdown 已追加到末尾\" : \"Markdown 已导入\",\n\t\t\t\t\tkey: loadingKey\n\t\t\t\t});\n\t\t\t} catch (error) {\n\t\t\t\tconsole.error(\"导入 Markdown 失败:\", error);\n\t\t\t\tmessage.error({ content: \"导入失败，请确保文件内容正确\", key: loadingKey });\n\t\t\t}\n\t\t};\n\n\t\tdocument.body.appendChild(input);\n\t\tinput.click();\n\t};\n\n\t// 导入 Word 文档\n\tconst handleImportWord = () => {\n\t\tconsole.log(\"=== 开始导入 Word 文档 ===\");\n\n\t\t// 定义已知的代码块 styleId\n\t\tconst codeBlockStyleIds = [\n\t\t\t\"23\",\n\t\t\t\"_Style 23\",\n\t\t\t\"ne-codeblock\",\n\t\t\t\"Code\",\n\t\t\t\"代码\",\n\t\t\t\"Preformatted\",\n\t\t\t\"HTML Preformatted\",\n\t\t\t\"Source Code\",\n\t\t\t\"Plain Text\",\n\t\t\t\"Consolas\",\n\t\t\t\"Courier New\",\n\t\t\t\"Monospaced\"\n\t\t];\n\n\t\t// 定义转换函数\n\t\tfunction transformElement(element: any): any {\n\t\t\t// 处理段落：如果有 styleId 但没有 styleName，尝试修复\n\t\t\tif (element.type === \"paragraph\" && element.styleId && !element.styleName) {\n\t\t\t\tif (codeBlockStyleIds.some(id => element.styleId.includes(id))) {\n\t\t\t\t\telement.styleName = element.styleId; // 用 styleId 作为 styleName\n\t\t\t\t}\n\t\t\t}\n\n\t\t\t// 语雀代码块处理：语雀导出的 docx 中，代码块可能带有特定的 styleId\n\t\t\tif (element.type === \"paragraph\" && element.styleId && element.styleId.includes(\"code\")) {\n\t\t\t\telement.styleName = \"ne-codeblock\";\n\t\t\t}\n\n\t\t\t// 递归处理子元素\n\t\t\tif (element.children) {\n\t\t\t\telement.children = element.children.map(transformElement);\n\t\t\t}\n\n\t\t\treturn element;\n\t\t}\n\n\t\tfunction transformDocument(document: any) {\n\t\t\t// document 是整个文档对象，需要处理它的所有元素\n\t\t\tif (document && document.children) {\n\t\t\t\tdocument.children = document.children.map(transformElement);\n\t\t\t}\n\t\t\treturn document;\n\t\t}\n\n\t\t// 创建文件输入元素\n\t\tconst input = document.createElement(\"input\");\n\t\tinput.type = \"file\";\n\t\tinput.accept = \".docx,application/vnd.openxmlformats-officedocument.wordprocessingml.document\";\n\t\tinput.style.display = \"none\";\n\n\t\tinput.onchange = async (e: Event) => {\n\t\t\tconsole.log(\"change 事件触发\");\n\t\t\tconst target = e.target as HTMLInputElement;\n\t\t\tconst file = target.files?.[0];\n\t\t\tconsole.log(\"选中的文件:\", file);\n\n\t\t\t// 清理 DOM\n\t\t\tif (document.body.contains(input)) {\n\t\t\t\tdocument.body.removeChild(input);\n\t\t\t}\n\n\t\t\tif (!file) {\n\t\t\t\tconsole.log(\"没有选择文件\");\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\tif (!file.name.endsWith(\".docx\")) {\n\t\t\t\tconsole.error(\"文件类型错误:\", file.name);\n\t\t\t\tmessage.error(\"仅支持 .docx 格式的 Word 文档\");\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\ttry {\n\t\t\t\tconsole.log(\"开始转换文件...\");\n\t\t\t\tconst loadingKey = \"word-import-loading\";\n\t\t\t\tmessage.loading({ content: \"正在导入 Word 文档...\", key: loadingKey, duration: 0 });\n\n\t\t\t\tconst arrayBuffer = await file.arrayBuffer();\n\t\t\t\tconsole.log(\"文件读取成功，大小:\", arrayBuffer.byteLength);\n\n\t\t\t\t// 配置 mammoth 选项\n\t\t\t\tconst styleMap = [\n\t\t\t\t\t// 代码块样式\n\t\t\t\t\t\"p[style-name='_Style 23'] => pre.code-block\",\n\t\t\t\t\t\"p[style-name='ne-codeblock'] => pre.code-block\", // 语雀导出的文档\n\t\t\t\t\t\"p[style-name='Code'] => pre.code-block\",\n\t\t\t\t\t\"p[style-name='代码'] => pre.code-block\",\n\t\t\t\t\t\"p[style-name='Preformatted'] => pre.code-block\",\n\t\t\t\t\t\"p[style-name='HTML Preformatted'] => pre.code-block\",\n\t\t\t\t\t\"p[style-name='Preformatted Text'] => pre.code-block\",\n\t\t\t\t\t\"p[style-name='Source Code'] => pre.code-block\",\n\t\t\t\t\t\"p[style-name='Plain Text'] => pre.code-block\",\n\t\t\t\t\t\"p[style-name='Consolas'] => pre.code-block\",\n\t\t\t\t\t\"p[style-name='Courier New'] => pre.code-block\",\n\t\t\t\t\t\"p[style-name='Monospaced'] => pre.code-block\"\n\t\t\t\t].join(\"\\n\");\n\n\t\t\t\tconst result = await (mammoth as any).convertToHtml({\n\t\t\t\t\tarrayBuffer,\n\t\t\t\t\tstyleMap: styleMap,\n\t\t\t\t\ttransformDocument: transformDocument,\n\t\t\t\t\tincludeDefaultStyleMap: true\n\t\t\t\t});\n\t\t\t\tconst html = result.value;\n\t\t\t\tconsole.log(\"HTML 转换成功，长度:\", html.length);\n\n\t\t\t\t// 打印样式警告信息（可以看到文档中有哪些样式）\n\t\t\t\tif (result.messages && result.messages.length > 0) {\n\t\t\t\t\tconsole.warn(\"样式信息和警告:\");\n\t\t\t\t\tresult.messages.forEach((msg: any) => {\n\t\t\t\t\t\tconsole.warn(`- ${msg.type}: ${msg.message}`);\n\t\t\t\t\t});\n\t\t\t\t}\n\n\t\t\t\t// 打印完整的 HTML（移除 base64 图片避免过长）\n\t\t\t\tconst htmlWithoutImages = html.replace(/src=\"data:image[^\"]*\"/g, 'src=\"[base64]\"');\n\t\t\t\tconsole.log(\"=== 完整的 HTML 内容开始 ===\");\n\t\t\t\tconsole.log(htmlWithoutImages);\n\t\t\t\tconsole.log(\"=== 完整的 HTML 内容结束 ===\");\n\n\t\t\t\t// 预处理：将包含多个 br 的段落或符合代码特征的段落转换为代码块\n\t\t\t\tlet processedHtml = html.replace(/<p>([\\s\\S]*?)<\\/p>/gi, (match: string, content: string) => {\n\t\t\t\t\t// 统计 br 标签数量\n\t\t\t\t\tconst brCount = (content.match(/<br\\s*\\/?>/gi) || []).length;\n\n\t\t\t\t\t// 如果有 1 个以上的 br (至少两行)，或者包含明显的代码关键字\n\t\t\t\t\tconst codeKeywords =\n\t\t\t\t\t\t/\\b(FROM|RUN|COPY|WORKDIR|ENTRYPOINT|CMD|ENV|EXPOSE|VOLUME|ARG|import|export|const|let|var|function|class|def|public|private|protected|package|interface|docker|maven|mvn|git|npm|yarn|pip|npm install|yarn add|void|static|return|if|else|for|while|switch|case|break|continue|try|catch|finally|throw|new|this|super|extends|implements|abstract|final|native|synchronized|transient|volatile|strictfp|assert|instanceof|boolean|byte|char|double|float|int|long|short|String|Integer|Long|Double|Float|Boolean|Map|List|Set|HashMap|ArrayList|HashSet|Stream|Optional|Response|Request|Controller|Service|Repository|Component|Autowired|Resource|Value|RequestMapping|GetMapping|PostMapping|PutMapping|DeleteMapping|PathVariable|RequestParam|RequestBody|ResponseBody|Data|AllArgsConstructor|NoArgsConstructor|Builder|Slf4j|SpringBootApplication|Configuration|Bean|Override|System\\.out\\.println|console\\.log|println|def|fn|lambda|async|await|promise|resolve|reject|fetch|axios|api|admin|paicoding|CompletableFuture|supplyAsync|thenAccept|thenApply|thenRun|handle|exceptionally|executor|submit|execute|TtlRunnable|TtlExecutors|log|info|debug|error|warn|trace|logger|RequestContext)\\b/i;\n\n\t\t\t\t\tconst textContent = content.replace(/<[^>]+>/g, \"\").trim(); // 移除所有标签查看纯文本\n\n\t\t\t\t\t// 如果包含 CodeMirror 相关的类名，通常也是代码\n\t\t\t\t\tconst isCodeMirror = content.includes(\"CodeMirror-line\") || content.includes(\"cm-text\");\n\n\t\t\t\t\t// 识别单行代码特征：Lambda 表达式、方法链、分号结尾、或者包含典型的代码符号组合\n\t\t\t\t\tconst isSingleLineCode =\n\t\t\t\t\t\t((textContent.includes(\"->\") || textContent.includes(\"=>\")) && textContent.includes(\"(\")) || // Lambda\n\t\t\t\t\t\t(textContent.includes(\".\") && textContent.includes(\"(\") && textContent.includes(\")\")) || // 方法调用/链\n\t\t\t\t\t\t(textContent.endsWith(\";\") && textContent.length > 5) || // 以分号结尾\n\t\t\t\t\t\t(textContent.includes(\"{\") && textContent.includes(\"}\")) || // 包含大括号\n\t\t\t\t\t\t(textContent.includes(\"(\") && textContent.includes(\")\") && textContent.includes(\"=\")); // 赋值调用\n\n\t\t\t\t\t// 1. 如果有 1 个以上的 br 且匹配关键字\n\t\t\t\t\t// 2. 如果文本内容看起来像代码（例如以特定字符开头或包含特定结构）\n\t\t\t\t\t// 3. 包含 CodeMirror 特征\n\t\t\t\t\t// 4. 符合单行代码特征且包含关键字\n\t\t\t\t\tif (\n\t\t\t\t\t\tisCodeMirror ||\n\t\t\t\t\t\t(brCount >= 1 && codeKeywords.test(textContent)) ||\n\t\t\t\t\t\t(isSingleLineCode && codeKeywords.test(textContent)) ||\n\t\t\t\t\t\t(textContent.length > 5 &&\n\t\t\t\t\t\t\t(textContent.startsWith(\"import \") ||\n\t\t\t\t\t\t\t\ttextContent.startsWith(\"package \") ||\n\t\t\t\t\t\t\t\ttextContent.includes(\"public static void main\") ||\n\t\t\t\t\t\t\t\t(textContent.includes(\"class \") && textContent.includes(\"{\")) ||\n\t\t\t\t\t\t\t\t(textContent.includes(\"function\") && textContent.includes(\")\")) ||\n\t\t\t\t\t\t\t\t(textContent.includes(\"const \") && textContent.includes(\"=\")) ||\n\t\t\t\t\t\t\t\t(textContent.includes(\"let \") && textContent.includes(\"=\"))))\n\t\t\t\t\t) {\n\t\t\t\t\t\tconsole.log(\"检测到潜在代码块，内容预览:\", textContent.substring(0, 100));\n\t\t\t\t\t\treturn '<pre class=\"code-block\">' + content + \"</pre>\";\n\t\t\t\t\t}\n\t\t\t\t\treturn match;\n\t\t\t\t});\n\n\t\t\t\t// 处理已经存在的 pre 标签（可能是 CodeMirror 产生的）\n\t\t\t\tprocessedHtml = processedHtml.replace(\n\t\t\t\t\t/<pre[^>]*class=\"[^\"]*CodeMirror-line[^\"]*\"[^>]*>([\\s\\S]*?)<\\/pre>/gi,\n\t\t\t\t\t(match: string, content: string) => {\n\t\t\t\t\t\treturn '<pre class=\"code-block\">' + content + \"</pre>\";\n\t\t\t\t\t}\n\t\t\t\t);\n\n\t\t\t\t// 合并相邻的代码块\n\t\t\t\tprocessedHtml = processedHtml.replace(/<\\/pre>\\s*<pre class=\"code-block\">/gi, \"<br/>\");\n\n\t\t\t\tconsole.log(\"=== 预处理后的 HTML 开始 ===\");\n\t\t\t\tconst processedWithoutImages = processedHtml.replace(/src=\"data:image[^\"]*\"/g, 'src=\"[base64]\"');\n\t\t\t\tconsole.log(processedWithoutImages);\n\t\t\t\tconsole.log(\"=== 预处理后的 HTML 结束 ===\");\n\n\t\t\t\t// 清理空锚点标签（包括有 id 或 name 属性但内容为空或仅有空白字符的 a 标签）\n\t\t\t\t// 语雀的锚点通常是 <a id=\"xxx\"></a>\n\t\t\t\tconsole.log(\"=== 开始清理语雀锚点 ===\");\n\t\t\t\tprocessedHtml = processedHtml.replace(/<a(?![^>]*\\bhref\\b)[^>]*>([\\s\\S]*?)<\\/a>/gi, (match: string, content: string) => {\n\t\t\t\t\tconst textContent = content.replace(/<[^>]+>/g, \"\").trim();\n\t\t\t\t\tif (textContent === \"\") {\n\t\t\t\t\t\tconsole.log(\"移除空锚点:\", match);\n\t\t\t\t\t\treturn \"\";\n\t\t\t\t\t}\n\t\t\t\t\t// 如果不是空的，则保留内容但移除 a 标签\n\t\t\t\t\tconsole.log(\"移除锚点标签但保留内容:\", match);\n\t\t\t\t\treturn content;\n\t\t\t\t});\n\n\t\t\t\t// 针对语雀特定的锚点格式进一步加强清理\n\t\t\t\tprocessedHtml = processedHtml.replace(/<a\\s+(?:id|name|data-anchor)=\"[^\"]*\"\\s*>\\s*<\\/a>/gi, \"\");\n\t\t\t\tprocessedHtml = processedHtml.replace(/<a\\s+[^>]*\\b(?:id|name|data-anchor)=\"[^\"]*\"[^>]*>\\s*<\\/a>/gi, \"\");\n\n\t\t\t\tconst afterClean = processedHtml.substring(0, 500);\n\t\t\t\tconsole.log(\"清理后（前500字符）:\", afterClean);\n\t\t\t\tconsole.log(\"=== 空锚点清理完成 ===\");\n\n\t\t\t\t// 关闭初始的 loading\n\t\t\t\tmessage.destroy(loadingKey);\n\n\t\t\t\t// 先询问用户是否继续导入\n\t\t\t\tconst shouldImport = await new Promise<\"append\" | \"replace\" | \"cancel\">(resolve => {\n\t\t\t\t\tif (content && content.trim()) {\n\t\t\t\t\t\tconsole.log(\"编辑器有内容，询问用户\");\n\t\t\t\t\t\tconst modal = Modal.info({\n\t\t\t\t\t\t\ttitle: \"当前编辑器有内容\",\n\t\t\t\t\t\t\tcontent: \"Word 文档中包含图片需要上传，是否继续导入？\",\n\t\t\t\t\t\t\ticon: null,\n\t\t\t\t\t\t\tclosable: true,\n\t\t\t\t\t\t\tokButtonProps: { style: { display: \"none\" } },\n\t\t\t\t\t\t\tfooter: (\n\t\t\t\t\t\t\t\t<div style={{ display: \"flex\", justifyContent: \"flex-end\", gap: \"8px\" }}>\n\t\t\t\t\t\t\t\t\t<Button\n\t\t\t\t\t\t\t\t\t\tonClick={() => {\n\t\t\t\t\t\t\t\t\t\t\tconsole.log(\"用户取消导入\");\n\t\t\t\t\t\t\t\t\t\t\tmodal.destroy();\n\t\t\t\t\t\t\t\t\t\t\tresolve(\"cancel\");\n\t\t\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t取消\n\t\t\t\t\t\t\t\t\t</Button>\n\t\t\t\t\t\t\t\t\t<Button\n\t\t\t\t\t\t\t\t\t\tonClick={() => {\n\t\t\t\t\t\t\t\t\t\t\tconsole.log(\"用户选择: 替换\");\n\t\t\t\t\t\t\t\t\t\t\tmodal.destroy();\n\t\t\t\t\t\t\t\t\t\t\tresolve(\"replace\");\n\t\t\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t替换内容\n\t\t\t\t\t\t\t\t\t</Button>\n\t\t\t\t\t\t\t\t\t<Button\n\t\t\t\t\t\t\t\t\t\ttype=\"primary\"\n\t\t\t\t\t\t\t\t\t\tonClick={() => {\n\t\t\t\t\t\t\t\t\t\t\tconsole.log(\"用户选择: 追加\");\n\t\t\t\t\t\t\t\t\t\t\tmodal.destroy();\n\t\t\t\t\t\t\t\t\t\t\tresolve(\"append\");\n\t\t\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t追加到末尾\n\t\t\t\t\t\t\t\t\t</Button>\n\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t)\n\t\t\t\t\t\t});\n\t\t\t\t\t} else {\n\t\t\t\t\t\tresolve(\"replace\");\n\t\t\t\t\t}\n\t\t\t\t});\n\n\t\t\t\tif (shouldImport === \"cancel\") {\n\t\t\t\t\tmessage.info(\"已取消导入\");\n\t\t\t\t\tconsole.log(\"用户取消导入\");\n\t\t\t\t\treturn;\n\t\t\t\t}\n\n\t\t\t\t// 处理图片：提取 base64 图片并上传\n\t\t\t\tconst base64Images = processedHtml.match(/<img src=\"data:image\\/(.*?);base64,(.*?)\"(.*?)>/gi) || [];\n\t\t\t\tconsole.log(`找到 ${base64Images.length} 张 base64 图片`);\n\n\t\t\t\tlet htmlWithUploadedImages = processedHtml;\n\n\t\t\t\tif (base64Images.length > 0) {\n\t\t\t\t\tmessage.loading(`正在上传 ${base64Images.length} 张图片...`, 0);\n\n\t\t\t\t\t// 并发上传图片，最多同时 5 个请求\n\t\t\t\t\tconst concurrency = 5;\n\t\t\t\t\tconst uploadResults: Array<{ original: string; uploaded: string } | null> = [];\n\n\t\t\t\t\t// 分批处理\n\t\t\t\t\tfor (let i = 0; i < base64Images.length; i += concurrency) {\n\t\t\t\t\t\tconst batch = base64Images.slice(i, i + concurrency);\n\t\t\t\t\t\tconsole.log(`上传批次: ${Math.floor(i / concurrency) + 1}, 包含 ${batch.length} 张图片`);\n\n\t\t\t\t\t\t// 并发上传当前批次\n\t\t\t\t\t\tconst batchPromises = batch.map(async (imgTag: string, batchIndex: number) => {\n\t\t\t\t\t\t\tconst index = i + batchIndex;\n\t\t\t\t\t\t\ttry {\n\t\t\t\t\t\t\t\t// 提取 base64 数据和图片类型\n\t\t\t\t\t\t\t\tconst match = imgTag.match(/data:image\\/(.*?);base64,(.*?)\"/);\n\t\t\t\t\t\t\t\tif (!match) return null;\n\n\t\t\t\t\t\t\t\tconst [, imageType, base64Data] = match;\n\t\t\t\t\t\t\t\tconsole.log(`上传图片 ${index + 1}/${base64Images.length}, 类型: ${imageType}`);\n\n\t\t\t\t\t\t\t\t// 将 base64 转换为 Blob\n\t\t\t\t\t\t\t\tconst byteString = atob(base64Data);\n\t\t\t\t\t\t\t\tconst arrayBuffer = new ArrayBuffer(byteString.length);\n\t\t\t\t\t\t\t\tconst uint8Array = new Uint8Array(arrayBuffer);\n\t\t\t\t\t\t\t\tfor (let j = 0; j < byteString.length; j++) {\n\t\t\t\t\t\t\t\t\tuint8Array[j] = byteString.charCodeAt(j);\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\tconst blob = new Blob([arrayBuffer], { type: `image/${imageType}` });\n\n\t\t\t\t\t\t\t\t// 创建 File 对象，增加更多随机性避免文件名和请求冲突\n\t\t\t\t\t\t\t\tconst timestamp = Date.now();\n\t\t\t\t\t\t\t\tconst random = Math.random().toString(36).substring(2, 15);\n\t\t\t\t\t\t\t\tconst fileName = `word-image-${timestamp}-${random}-${index}.${imageType}`;\n\t\t\t\t\t\t\t\tconst file = new File([blob], fileName, {\n\t\t\t\t\t\t\t\t\ttype: `image/${imageType}`\n\t\t\t\t\t\t\t\t});\n\n\t\t\t\t\t\t\t\t// 上传图片，使用更唯一的标识\n\t\t\t\t\t\t\t\tconst formData = new FormData();\n\t\t\t\t\t\t\t\tformData.append(\"image\", file);\n\n\t\t\t\t\t\t\t\tconst response = await uploadImgApi(formData);\n\t\t\t\t\t\t\t\tconst { status, result } = response || {};\n\t\t\t\t\t\t\t\tconst { code } = status || {};\n\t\t\t\t\t\t\t\tconst { imagePath } = result || {};\n\n\t\t\t\t\t\t\t\tif (code === 0 && imagePath) {\n\t\t\t\t\t\t\t\t\tconsole.log(`图片 ${index + 1} 上传成功:`, imagePath);\n\t\t\t\t\t\t\t\t\treturn { original: imgTag, uploaded: imagePath };\n\t\t\t\t\t\t\t\t} else {\n\t\t\t\t\t\t\t\t\tconsole.error(`图片 ${index + 1} 上传失败`);\n\t\t\t\t\t\t\t\t\treturn null;\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t} catch (error) {\n\t\t\t\t\t\t\t\tconsole.error(`图片 ${index + 1} 上传出错:`, error);\n\t\t\t\t\t\t\t\treturn null;\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t});\n\n\t\t\t\t\t\t// 等待当前批次完成\n\t\t\t\t\t\tconst batchResults = await Promise.all(batchPromises);\n\t\t\t\t\t\tuploadResults.push(...batchResults);\n\n\t\t\t\t\t\t// 批次间增加短暂延迟，避免请求过于密集\n\t\t\t\t\t\tif (i + concurrency < base64Images.length) {\n\t\t\t\t\t\t\tawait new Promise(resolve => setTimeout(resolve, 100));\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\n\t\t\t\t\t// 替换 HTML 中的图片\n\t\t\t\t\tlet successCount = 0;\n\t\t\t\t\tuploadResults.forEach(result => {\n\t\t\t\t\t\tif (result) {\n\t\t\t\t\t\t\thtmlWithUploadedImages = htmlWithUploadedImages.replace(result.original, `<img src=\"${result.uploaded}\">`);\n\t\t\t\t\t\t\tsuccessCount++;\n\t\t\t\t\t\t}\n\t\t\t\t\t});\n\n\t\t\t\t\tmessage.destroy();\n\t\t\t\t\tif (successCount > 0) {\n\t\t\t\t\t\tmessage.success(`成功上传 ${successCount} 张图片`);\n\t\t\t\t\t}\n\t\t\t\t\tif (successCount < base64Images.length) {\n\t\t\t\t\t\tmessage.warning(`${base64Images.length - successCount} 张图片上传失败`);\n\t\t\t\t\t}\n\t\t\t\t}\n\n\t\t\t\t// 先清理空的 <a> 标签（锚点）\n\t\t\t\tlet cleanedHtml = htmlWithUploadedImages.replace(\n\t\t\t\t\t/<a(?![^>]*\\bhref\\b)[^>]*>([\\s\\S]*?)<\\/a>/gi,\n\t\t\t\t\t(match: string, content: string) => {\n\t\t\t\t\t\tconst textContent = content.replace(/<[^>]+>/g, \"\").trim();\n\t\t\t\t\t\treturn textContent === \"\" ? \"\" : content;\n\t\t\t\t\t}\n\t\t\t\t);\n\t\t\t\tcleanedHtml = cleanedHtml.replace(/<a\\s+(?:id|name|data-anchor)=\"[^\"]*\"\\s*>\\s*<\\/a>/gi, \"\");\n\n\t\t\t\tlet markdown = cleanedHtml\n\t\t\t\t\t.replace(/<h1>(.*?)<\\/h1>/gi, \"# $1\\n\\n\")\n\t\t\t\t\t.replace(/<h2>(.*?)<\\/h2>/gi, \"## $1\\n\\n\")\n\t\t\t\t\t.replace(/<h3>(.*?)<\\/h3>/gi, \"### $1\\n\\n\")\n\t\t\t\t\t.replace(/<h4>(.*?)<\\/h4>/gi, \"#### $1\\n\\n\")\n\t\t\t\t\t.replace(/<h5>(.*?)<\\/h5>/gi, \"##### $1\\n\\n\")\n\t\t\t\t\t.replace(/<h6>(.*?)<\\/h6>/gi, \"###### $1\\n\\n\")\n\t\t\t\t\t.replace(/<(?:strong|b)>([\\s\\S]*?)([。，！？；：,.!?;:]*)<\\/(?:strong|b)>/gi, \"**$1**$2\")\n\t\t\t\t\t.replace(/<em>(.*?)<\\/em>/gi, \"*$1*\")\n\t\t\t\t\t.replace(/<i>(.*?)<\\/i>/gi, \"*$1*\")\n\t\t\t\t\t.replace(/<li>([\\s\\S]*?)<\\/li>/gi, (match: string, content: string) => {\n\t\t\t\t\t\tconst innerContent = content.replace(/<p>/gi, \"\").replace(/<\\/p>/gi, \"\").trim();\n\t\t\t\t\t\treturn \"- \" + innerContent + \"\\n\";\n\t\t\t\t\t})\n\t\t\t\t\t.replace(/<\\/ul>/gi, \"\\n\\n\")\n\t\t\t\t\t.replace(/<\\/ol>/gi, \"\\n\\n\")\n\t\t\t\t\t.replace(/<ul>/gi, \"\\n\")\n\t\t\t\t\t.replace(/<ol>/gi, \"\\n\")\n\t\t\t\t\t// 处理表格 - 转换为 Markdown 表格格式\n\t\t\t\t\t.replace(/<table>([\\s\\S]*?)<\\/table>/gi, (match: string, tableContent: string) => {\n\t\t\t\t\t\t// 解析表格行\n\t\t\t\t\t\tconst rows = tableContent.match(/<tr>([\\s\\S]*?)<\\/tr>/gi) || [];\n\t\t\t\t\t\tif (rows.length === 0) return match;\n\n\t\t\t\t\t\tlet markdownTable = \"\\n\";\n\n\t\t\t\t\t\trows.forEach((row: string, rowIndex: number) => {\n\t\t\t\t\t\t\t// 提取单元格（支持 td 和 th）\n\t\t\t\t\t\t\tconst cells = row.match(/<t[dh]>([\\s\\S]*?)<\\/t[dh]>/gi) || [];\n\t\t\t\t\t\t\tconst cellContents = cells.map((cell: string) => {\n\t\t\t\t\t\t\t\t// 移除单元格标签，保留内容\n\t\t\t\t\t\t\t\tlet content = cell\n\t\t\t\t\t\t\t\t\t.replace(/<t[dh]>/gi, \"\")\n\t\t\t\t\t\t\t\t\t.replace(/<\\/t[dh]>/gi, \"\")\n\t\t\t\t\t\t\t\t\t.replace(/<p>/gi, \"\")\n\t\t\t\t\t\t\t\t\t.replace(/<\\/p>/gi, \" \")\n\t\t\t\t\t\t\t\t\t.replace(/<a[^>]*>(.*?)<\\/a>/gi, \"$1\")\n\t\t\t\t\t\t\t\t\t.replace(/<strong>(.*?)<\\/strong>/gi, \"**$1**\")\n\t\t\t\t\t\t\t\t\t.replace(/<em>(.*?)<\\/em>/gi, \"*$1*\")\n\t\t\t\t\t\t\t\t\t.replace(/<[^>]+>/g, \"\")\n\t\t\t\t\t\t\t\t\t.trim();\n\t\t\t\t\t\t\t\treturn content || \" \";\n\t\t\t\t\t\t\t});\n\n\t\t\t\t\t\t\t// 构建 Markdown 表格行\n\t\t\t\t\t\t\tmarkdownTable += \"| \" + cellContents.join(\" | \") + \" |\\n\";\n\n\t\t\t\t\t\t\t// 第一行后添加分隔线\n\t\t\t\t\t\t\tif (rowIndex === 0) {\n\t\t\t\t\t\t\t\tmarkdownTable += \"| \" + cellContents.map(() => \"---\").join(\" | \") + \" |\\n\";\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t});\n\n\t\t\t\t\t\treturn markdownTable + \"\\n\";\n\t\t\t\t\t})\n\t\t\t\t\t// 处理代码块 - 匹配我们自定义的 class\n\t\t\t\t\t.replace(/<pre class=\"code-block\">([\\s\\S]*?)<\\/pre>/gi, (match: string, code: string) => {\n\t\t\t\t\t\tconst decoded = code\n\t\t\t\t\t\t\t.replace(/<p>/gi, \"\")\n\t\t\t\t\t\t\t.replace(/<\\/p>/gi, \"\\n\")\n\t\t\t\t\t\t\t.replace(/<br\\s*\\/?>/gi, \"\\n\")\n\t\t\t\t\t\t\t.replace(/<[^>]+>/g, \"\") // 移除代码块内部的所有其他标签（如 CodeMirror 的 span）\n\t\t\t\t\t\t\t.replace(/&ZeroWidthSpace;/g, \"\") // 移除零宽字符\n\t\t\t\t\t\t\t.replace(/&lt;/g, \"<\")\n\t\t\t\t\t\t\t.replace(/&gt;/g, \">\")\n\t\t\t\t\t\t\t.replace(/&amp;/g, \"&\")\n\t\t\t\t\t\t\t.replace(/&quot;/g, '\"')\n\t\t\t\t\t\t\t.replace(/&#39;/g, \"'\")\n\t\t\t\t\t\t\t.trim();\n\n\t\t\t\t\t\t// 检测代码语言\n\t\t\t\t\t\tlet language = \"\";\n\n\t\t\t\t\t\t// Java 代码特征\n\t\t\t\t\t\tif (\n\t\t\t\t\t\t\t/\\b(public|private|protected|class|interface|enum|package|import)\\s+/i.test(decoded) ||\n\t\t\t\t\t\t\t/\\bpublic\\s+static\\s+void\\s+main/i.test(decoded)\n\t\t\t\t\t\t) {\n\t\t\t\t\t\t\tlanguage = \"java\";\n\t\t\t\t\t\t}\n\t\t\t\t\t\t// Dockerfile 特征\n\t\t\t\t\t\telse if (/^FROM\\s+/im.test(decoded) || /^RUN\\s+/im.test(decoded)) {\n\t\t\t\t\t\t\tlanguage = \"dockerfile\";\n\t\t\t\t\t\t}\n\n\t\t\t\t\t\treturn \"```\" + language + \"\\n\" + decoded + \"\\n```\\n\\n\";\n\t\t\t\t\t})\n\t\t\t\t\t// 处理普通的 pre 标签\n\t\t\t\t\t.replace(/<pre[^>]*><code>([\\s\\S]*?)<\\/code><\\/pre>/gi, (match: string, code: string) => {\n\t\t\t\t\t\tconst decoded = code\n\t\t\t\t\t\t\t.replace(/<[^>]+>/g, \"\") // 移除内部标签\n\t\t\t\t\t\t\t.replace(/&ZeroWidthSpace;/g, \"\")\n\t\t\t\t\t\t\t.replace(/&lt;/g, \"<\")\n\t\t\t\t\t\t\t.replace(/&gt;/g, \">\")\n\t\t\t\t\t\t\t.replace(/&amp;/g, \"&\")\n\t\t\t\t\t\t\t.replace(/&quot;/g, '\"')\n\t\t\t\t\t\t\t.replace(/&#39;/g, \"'\");\n\n\t\t\t\t\t\t// 检测代码语言\n\t\t\t\t\t\tlet language = \"\";\n\n\t\t\t\t\t\t// Java 代码特征\n\t\t\t\t\t\tif (\n\t\t\t\t\t\t\t/\\b(public|private|protected|class|interface|enum|package|import)\\s+/i.test(decoded) ||\n\t\t\t\t\t\t\t/\\bpublic\\s+static\\s+void\\s+main/i.test(decoded)\n\t\t\t\t\t\t) {\n\t\t\t\t\t\t\tlanguage = \"java\";\n\t\t\t\t\t\t}\n\t\t\t\t\t\t// Dockerfile 特征\n\t\t\t\t\t\telse if (/^FROM\\s+/im.test(decoded) || /^RUN\\s+/im.test(decoded)) {\n\t\t\t\t\t\t\tlanguage = \"dockerfile\";\n\t\t\t\t\t\t}\n\n\t\t\t\t\t\treturn \"```\" + language + \"\\n\" + decoded + \"\\n```\\n\\n\";\n\t\t\t\t\t})\n\t\t\t\t\t.replace(/<pre[^>]*>([\\s\\S]*?)<\\/pre>/gi, (match: string, code: string) => {\n\t\t\t\t\t\tconst decoded = code\n\t\t\t\t\t\t\t.replace(/<[^>]+>/g, \"\") // 移除内部标签\n\t\t\t\t\t\t\t.replace(/&ZeroWidthSpace;/g, \"\")\n\t\t\t\t\t\t\t.replace(/&lt;/g, \"<\")\n\t\t\t\t\t\t\t.replace(/&gt;/g, \">\")\n\t\t\t\t\t\t\t.replace(/&amp;/g, \"&\")\n\t\t\t\t\t\t\t.replace(/&quot;/g, '\"')\n\t\t\t\t\t\t\t.replace(/&#39;/g, \"'\");\n\n\t\t\t\t\t\t// 检测代码语言\n\t\t\t\t\t\tlet language = \"\";\n\n\t\t\t\t\t\t// Java 代码特征\n\t\t\t\t\t\tif (\n\t\t\t\t\t\t\t/\\b(public|private|protected|class|interface|enum|package|import)\\s+/i.test(decoded) ||\n\t\t\t\t\t\t\t/\\bpublic\\s+static\\s+void\\s+main/i.test(decoded)\n\t\t\t\t\t\t) {\n\t\t\t\t\t\t\tlanguage = \"java\";\n\t\t\t\t\t\t}\n\t\t\t\t\t\t// Dockerfile 特征\n\t\t\t\t\t\telse if (/^FROM\\s+/im.test(decoded) || /^RUN\\s+/im.test(decoded)) {\n\t\t\t\t\t\t\tlanguage = \"dockerfile\";\n\t\t\t\t\t\t}\n\n\t\t\t\t\t\treturn \"```\" + language + \"\\n\" + decoded + \"\\n```\\n\\n\";\n\t\t\t\t\t})\n\t\t\t\t\t// 行内代码\n\t\t\t\t\t.replace(/<code>(.*?)<\\/code>/gi, \"`$1`\")\n\t\t\t\t\t.replace(/<p>(.*?)<\\/p>/gi, \"$1\\n\\n\")\n\t\t\t\t\t.replace(/<a href=\"(.*?)\">(.*?)<\\/a>/gi, \"[$2]($1)\")\n\t\t\t\t\t.replace(/<img src=\"(.*?)\" alt=\"(.*?)\">/gi, \"![$2]($1)\")\n\t\t\t\t\t.replace(/<img src=\"(.*?)\">/gi, \"![]($1)\")\n\t\t\t\t\t.replace(/<br\\s*\\/?>/gi, \"\\n\")\n\t\t\t\t\t.replace(/<[^>]+>/g, \"\");\n\t\t\t\t// 最终全局清洗：保护代码块内部的缩进\n\t\t\t\tlet inCodeBlock = false;\n\t\t\t\tmarkdown = markdown\n\t\t\t\t\t.replace(/&ZeroWidthSpace;/g, \"\") // 全局移除零宽字符\n\t\t\t\t\t.split(\"\\n\")\n\t\t\t\t\t.map((line: string) => {\n\t\t\t\t\t\tconst trimmedLine = line.trim();\n\t\t\t\t\t\t// 检测代码块边界\n\t\t\t\t\t\tif (trimmedLine.startsWith(\"```\")) {\n\t\t\t\t\t\t\tinCodeBlock = !inCodeBlock;\n\t\t\t\t\t\t\treturn trimmedLine;\n\t\t\t\t\t\t}\n\t\t\t\t\t\t// 如果在代码块内部，只清理行尾空格，保留行首缩进\n\t\t\t\t\t\tif (inCodeBlock) {\n\t\t\t\t\t\t\treturn line.trimEnd();\n\t\t\t\t\t\t}\n\t\t\t\t\t\t// 普通行执行全量 trim\n\t\t\t\t\t\treturn trimmedLine;\n\t\t\t\t\t})\n\t\t\t\t\t.join(\"\\n\")\n\t\t\t\t\t.replace(/\\n{3,}/g, \"\\n\\n\")\n\t\t\t\t\t.trim();\n\n\t\t\t\tconsole.log(\"Markdown 转换成功，长度:\", markdown.length);\n\n\t\t\t\t// 提取一级标题并同步到文章标题\n\t\t\t\tlet articleTitle = \"\";\n\t\t\t\tconst h1Match = markdown.match(/^#\\s+(.+)$/m);\n\t\t\t\tif (h1Match) {\n\t\t\t\t\tarticleTitle = h1Match[1].trim();\n\t\t\t\t\tconsole.log(\"检测到一级标题，同步为文章标题:\", articleTitle);\n\t\t\t\t\t// 从正文中移除一级标题及其后的换行\n\t\t\t\t\tmarkdown = markdown.replace(/^#\\s+.+\\n*/m, \"\").trimStart();\n\t\t\t\t}\n\n\t\t\t\tconsole.log(\"=== 完整的 Markdown 内容开始 ===\");\n\t\t\t\tconsole.log(markdown);\n\t\t\t\tconsole.log(\"=== 完整的 Markdown 内容结束 ===\");\n\n\t\t\t\t// 先关闭 loading\n\t\t\t\tmessage.destroy();\n\n\t\t\t\t// 根据用户之前的选择来处理内容\n\t\t\t\tif (shouldImport === \"append\") {\n\t\t\t\t\tconsole.log(\"执行追加操作\");\n\t\t\t\t\tconst finalMarkdown = content + \"\\n\\n---\\n\\n\" + markdown;\n\t\t\t\t\tsetContent(finalMarkdown);\n\t\t\t\t\thandleChange({ content: finalMarkdown });\n\t\t\t\t\tmessage.success(\"Word 文档已追加到末尾\");\n\t\t\t\t} else {\n\t\t\t\t\tconsole.log(\"执行替换操作\");\n\t\t\t\t\tsetContent(markdown);\n\t\t\t\t\t// 如果提取到了标题，同步更新标题\n\t\t\t\t\tif (articleTitle) {\n\t\t\t\t\t\thandleChange({ content: markdown, shortTitle: articleTitle });\n\t\t\t\t\t\tformRef.setFieldsValue({ shortTitle: articleTitle });\n\t\t\t\t\t} else {\n\t\t\t\t\t\thandleChange({ content: markdown });\n\t\t\t\t\t}\n\t\t\t\t\tmessage.success(\"Word 文档已导入\");\n\t\t\t\t}\n\n\t\t\t\tconsole.log(\"=== Word 导入完成 ===\");\n\t\t\t} catch (error) {\n\t\t\t\tconsole.error(\"❌ 导入失败:\", error);\n\t\t\t\tmessage.destroy();\n\t\t\t\tmessage.error(\"导入失败，请确保文件格式正确\");\n\t\t\t}\n\t\t};\n\n\t\t// 必须先添加到 DOM，Chrome 才允许触发\n\t\tdocument.body.appendChild(input);\n\t\tconsole.log(\"input 已添加到 DOM\");\n\t\t// 立即触发 click，必须在同一个事件循环中\n\t\tinput.click();\n\t\tconsole.log(\"click 事件已触发\");\n\t};\n\n\t// AI 初始化文章信息\n\tconst handleAiInit = async () => {\n\t\tconst { shortTitle, content } = form;\n\t\tif (!shortTitle) {\n\t\t\tmessage.warning(\"请先输入或从 Word 导入短标题\");\n\t\t\treturn;\n\t\t}\n\n\t\tconst loadingKey = \"ai-init-loading\";\n\t\tmessage.loading({ content: \"正在 AI 初始化文章信息...\", key: loadingKey, duration: 0 });\n\n\t\ttry {\n\t\t\t// 1. 调用 AI 接口生成标题和简介\n\t\t\tconst { status: aiStatus, result: aiResult } = await generateArticleAiApi({\n\t\t\t\tshortTitle: shortTitle,\n\t\t\t\tcontent: content.substring(0, 400)\n\t\t\t});\n\n\t\t\tif (aiStatus?.code === 0 && aiResult) {\n\t\t\t\tconst { title, description } = aiResult as any;\n\t\t\t\tconsole.log(\"AI 初始化成功:\", { title, description });\n\n\t\t\t\t// 2. 准备回填数据\n\t\t\t\tconst updateData: MapItem = {\n\t\t\t\t\ttitle,\n\t\t\t\t\tsummary: description,\n\t\t\t\t\tstatus: 0\n\t\t\t\t};\n\n\t\t\t\t// 3. 设置分类默认值为“星球专栏”\n\t\t\t\tconst planetCategory = CategoryTypeList?.find((item: any) => item.label === \"星球专栏\");\n\t\t\t\tif (planetCategory) {\n\t\t\t\t\tupdateData.categoryId = planetCategory.value;\n\t\t\t\t\tupdateData.readType = 3;\n\t\t\t\t\tconsole.log(\"自动选择分类:\", planetCategory.label);\n\t\t\t\t}\n\n\t\t\t\t// 4. 设置标签默认值为第一个\n\t\t\t\ttry {\n\t\t\t\t\tconst response = (await getTagListApi({\n\t\t\t\t\t\tstatus: 1,\n\t\t\t\t\t\ttag: \"\",\n\t\t\t\t\t\tpageNumber: 1,\n\t\t\t\t\t\tpageSize: 1\n\t\t\t\t\t})) as any;\n\n\t\t\t\t\tconst { status: tagStatus, result: tagResult } = response;\n\n\t\t\t\t\tif (tagStatus?.code === 0 && tagResult?.list?.length > 0) {\n\t\t\t\t\t\tconst firstTag = tagResult.list[0];\n\t\t\t\t\t\tupdateData.tagIds = [firstTag.tagId];\n\t\t\t\t\t\t// 同步回填到 DebounceSelect（它在 Form 中受控）\n\t\t\t\t\t\tformRef.setFieldsValue({\n\t\t\t\t\t\t\ttagName: [\n\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\tkey: firstTag.tagId,\n\t\t\t\t\t\t\t\t\tlabel: firstTag.tag,\n\t\t\t\t\t\t\t\t\tvalue: firstTag.tag\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t]\n\t\t\t\t\t\t});\n\t\t\t\t\t\tconsole.log(\"自动选择标签:\", firstTag.tag);\n\t\t\t\t\t}\n\t\t\t\t} catch (tagError) {\n\t\t\t\t\tconsole.warn(\"获取默认标签失败:\", tagError);\n\t\t\t\t}\n\n\t\t\t\t// 5. 执行回填\n\t\t\t\thandleChange(updateData);\n\t\t\t\tformRef.setFieldsValue({\n\t\t\t\t\ttitle,\n\t\t\t\t\tsummary: description,\n\t\t\t\t\tstatus: \"0\",\n\t\t\t\t\tcategoryId: updateData.categoryId,\n\t\t\t\t\treadType: updateData.readType ?? defaultInitForm.readType\n\t\t\t\t});\n\n\t\t\t\tmessage.success({ content: \"AI 初始化成功\", key: loadingKey });\n\t\t\t} else {\n\t\t\t\tmessage.error({ content: aiStatus?.msg || \"AI 初始化失败\", key: loadingKey });\n\t\t\t}\n\t\t} catch (error) {\n\t\t\tconsole.error(\"AI 初始化出错:\", error);\n\t\t\tmessage.error({ content: \"网络错误，AI 初始化失败\", key: loadingKey });\n\t\t}\n\t};\n\n\t// 编辑或者新增时提交数据到服务器端\n\tconst handleSubmit = async () => {\n\t\t// 又 from 中获取数据，需要转换格式的数据\n\t\tconst { articleId, cover, content, tagIds, shortTitle } = form;\n\t\tconsole.log(\"handleSubmit 时看看form的值\", form);\n\t\t// content 为空的时候，提示用户\n\t\tif (!content) {\n\t\t\tmessage.error(\"请输入文章内容\");\n\t\t\treturn;\n\t\t}\n\n\t\t// tags 不能超过 3 个\n\t\tif (tagIds.length > 3) {\n\t\t\tmessage.error(\"标签不能超过 3 个\");\n\t\t\treturn;\n\t\t}\n\n\t\tconst values = await formRef.validateFields();\n\t\tconsole.log(\"handleSubmit 时看看form的值 values\", values);\n\n\t\tconst normalizedReadType = Number(values.readType ?? defaultInitForm.readType);\n\t\tconst normalizedPayWay = normalizedReadType === 4 ? values.payWay || defaultInitForm.payWay : undefined;\n\t\tconst normalizedPayAmount =\n\t\t\tnormalizedReadType === 4 && values.payAmount != null ? Math.round(Number(values.payAmount) * 100) : undefined;\n\n\t\t// 新的值传递到后端\n\t\tconst newValues = {\n\t\t\t...values,\n\t\t\tstatus: Number(values.status),\n\t\t\treadType: normalizedReadType,\n\t\t\tpayWay: normalizedPayWay,\n\t\t\tpayAmount: normalizedPayAmount,\n\t\t\tcontent: content,\n\t\t\ttagIds: tagIds,\n\t\t\tshortTitle: shortTitle,\n\t\t\t// 确定的参数\n\t\t\tarticleType: \"BLOG\",\n\t\t\tsource: 2,\n\t\t\t// 草稿还是发布\n\t\t\tactionType: \"post\",\n\t\t\tarticleId: status === UpdateEnum.Save ? UpdateEnum.Save : articleId\n\t\t};\n\t\tconsole.log(\"submit 之前的所有值:\", newValues);\n\n\t\tconst { status: successStatus } = (await saveArticleApi(newValues)) || {};\n\t\tconst { code, msg } = successStatus || {};\n\t\tif (code === 0) {\n\t\t\tmessage.success(\"成功\");\n\t\t\tlocalRemove(draftKey);\n\t\t\t// 返回文章列表页\n\t\t\tgoBack();\n\t\t} else {\n\t\t\tmessage.error(msg || \"失败\");\n\t\t}\n\t};\n\n\t// 数据请求，这是一个钩子，query, current, pageSize, search 有变化的时候就会自动触发\n\tuseEffect(() => {\n\t\tconst getArticle = async () => {\n\t\t\tconsole.log(\"此时是否还有 \", articleId, status);\n\t\t\tconst { status: resultStatus, result } = await getArticleApi(articleId);\n\t\t\tconst { code } = resultStatus || {};\n\t\t\tif (code === 0 && status === UpdateEnum.Edit) {\n\t\t\t\tconsole.log(\"result\", result);\n\n\t\t\t\t// 如果 status 为编辑，就请求数据\n\t\t\t\t// 设置文章内容，编辑器使用\n\t\t\t\tsetContent(result?.content);\n\n\t\t\t\t// 此时不能直接从 form 中取出来，所以我们从 item 中取出来了。\n\t\t\t\tlet coverUrl = getCompleteUrl(result?.cover);\n\t\t\t\t// 需要把 cover 放到 coverList 中，默认显示\n\t\t\t\tsetCoverList([\n\t\t\t\t\t{\n\t\t\t\t\t\tuid: \"-1\",\n\t\t\t\t\t\tname: coverName,\n\t\t\t\t\t\tstatus: \"done\",\n\t\t\t\t\t\tthumbUrl: coverUrl,\n\t\t\t\t\t\turl: coverUrl\n\t\t\t\t\t}\n\t\t\t\t]);\n\t\t\t\t// 填充表单\n\t\t\t\tformRef.setFieldsValue({\n\t\t\t\t\ttitle: result?.title,\n\t\t\t\t\tshortTitle: result?.shortTitle,\n\t\t\t\t\tsummary: result?.summary,\n\t\t\t\t\tcover: coverUrl,\n\t\t\t\t\tstatus: result?.status?.toString(),\n\t\t\t\t\treadType: result?.readType ?? defaultInitForm.readType,\n\t\t\t\t\tpayWay: result?.payWay || defaultInitForm.payWay,\n\t\t\t\t\tpayAmount: result?.payAmount == null ? defaultInitForm.payAmount : Number(result?.payAmount),\n\t\t\t\t\tcategoryId: result?.category?.categoryId,\n\t\t\t\t\ttagName: result?.tags?.map((item: TagValue) => ({\n\t\t\t\t\t\tkey: item?.tagId,\n\t\t\t\t\t\tlabel: item?.tag,\n\t\t\t\t\t\tvalue: item?.tag\n\t\t\t\t\t}))\n\t\t\t\t});\n\n\t\t\t\t// 保存的时候需要\n\t\t\t\thandleChange({\n\t\t\t\t\tcontent: result?.content,\n\t\t\t\t\tarticleId: result?.articleId,\n\t\t\t\t\tshortTitle: result?.shortTitle,\n\t\t\t\t\tstatus: result?.status,\n\t\t\t\t\treadType: result?.readType ?? defaultInitForm.readType,\n\t\t\t\t\tpayWay: result?.payWay || defaultInitForm.payWay,\n\t\t\t\t\tpayAmount: result?.payAmount == null ? defaultInitForm.payAmount : Number(result?.payAmount)\n\t\t\t\t});\n\n\t\t\t\t// 检查草稿\n\t\t\t\tconst draft = localGet(draftKey);\n\t\t\t\tif (draft) {\n\t\t\t\t\tconst draftTime = new Date(draft.timestamp).toLocaleString();\n\t\t\t\t\tModal.confirm({\n\t\t\t\t\t\ttitle: \"发现未保存的草稿\",\n\t\t\t\t\t\tcontent: `检测到您在 ${draftTime} 对此文章有未保存的修改，是否覆盖当前服务器版本？`,\n\t\t\t\t\t\tokText: \"使用草稿\",\n\t\t\t\t\t\tcancelText: \"使用服务器版本\",\n\t\t\t\t\t\tonOk: () => {\n\t\t\t\t\t\t\tsetContent(draft.content);\n\t\t\t\t\t\t\tsetForm(prev => ({ ...prev, ...draft }));\n\t\t\t\t\t\t\tformRef.setFieldsValue(draft);\n\t\t\t\t\t\t\tif (draft.cover) {\n\t\t\t\t\t\t\t\tlet coverUrl = getCompleteUrl(draft.cover);\n\t\t\t\t\t\t\t\tsetCoverList([\n\t\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\tuid: \"-1\",\n\t\t\t\t\t\t\t\t\t\tname: coverName,\n\t\t\t\t\t\t\t\t\t\tstatus: \"done\",\n\t\t\t\t\t\t\t\t\t\tthumbUrl: coverUrl,\n\t\t\t\t\t\t\t\t\t\turl: coverUrl\n\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t]);\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\tmessage.success(\"已加载本地草稿\");\n\t\t\t\t\t\t},\n\t\t\t\t\t\tonCancel: () => {\n\t\t\t\t\t\t\tlocalRemove(draftKey);\n\t\t\t\t\t\t}\n\t\t\t\t\t});\n\t\t\t\t}\n\t\t\t}\n\t\t};\n\n\t\tgetArticle();\n\t}, []);\n\n\t// 标题、分类、标签、封面、简介\n\tconst drawerContent = (\n\t\t<Form name=\"basic\" form={formRef} labelCol={{ span: 4 }} wrapperCol={{ span: 16 }} autoComplete=\"off\">\n\t\t\t<Form.Item label=\"标题\" name=\"title\" rules={[{ required: true, message: \"请输入标题!\" }]}>\n\t\t\t\t<Input\n\t\t\t\t\tallowClear\n\t\t\t\t\tminLength={5}\n\t\t\t\t\tmaxLength={120}\n\t\t\t\t\tonChange={e => {\n\t\t\t\t\t\thandleChange({ title: e.target.value });\n\t\t\t\t\t}}\n\t\t\t\t/>\n\t\t\t</Form.Item>\n\t\t\t<Form.Item label=\"短标题\" name=\"shortTitle\">\n\t\t\t\t<Input\n\t\t\t\t\tallowClear\n\t\t\t\t\tmaxLength={40}\n\t\t\t\t\tonChange={e => {\n\t\t\t\t\t\thandleChange({ shortTitle: e.target.value });\n\t\t\t\t\t}}\n\t\t\t\t/>\n\t\t\t</Form.Item>\n\t\t\t<Form.Item label=\"简介\" name=\"summary\" rules={[{ required: true, message: \"请输入简介!\" }]}>\n\t\t\t\t<TextArea\n\t\t\t\t\tallowClear\n\t\t\t\t\t// 行数\n\t\t\t\t\trows={4}\n\t\t\t\t\tonChange={e => {\n\t\t\t\t\t\thandleChange({ summary: e.target.value });\n\t\t\t\t\t}}\n\t\t\t\t/>\n\t\t\t</Form.Item>\n\t\t\t<Form.Item label=\"封面\" name=\"cover\">\n\t\t\t\t<ImgUpload\n\t\t\t\t\tcoverList={coverList}\n\t\t\t\t\tcoverName={coverName}\n\t\t\t\t\tsetCoverList={setCoverList}\n\t\t\t\t\thandleFormRefChange={handleFormRefChange}\n\t\t\t\t/>\n\t\t\t</Form.Item>\n\t\t\t<Form.Item label=\"状态\" name=\"status\">\n\t\t\t\t<Select\n\t\t\t\t\tplaceholder=\"请选择文章状态\"\n\t\t\t\t\toptions={PushStatusList}\n\t\t\t\t\tonChange={value => {\n\t\t\t\t\t\thandleChange({ status: Number(value) });\n\t\t\t\t\t}}\n\t\t\t\t/>\n\t\t\t</Form.Item>\n\t\t\t<Form.Item\n\t\t\t\tlabel=\"阅读类型\"\n\t\t\t\tname=\"readType\"\n\t\t\t\tinitialValue={defaultInitForm.readType}\n\t\t\t>\n\t\t\t\t<Radio.Group\n\t\t\t\t\tclassName=\"custom-radio-group\"\n\t\t\t\t\toptionType=\"button\"\n\t\t\t\t\tbuttonStyle=\"solid\"\n\t\t\t\t\toptions={ArticleReadTypeList}\n\t\t\t\t\tonChange={e => {\n\t\t\t\t\t\thandleChange({ readType: Number(e.target.value) });\n\t\t\t\t\t}}\n\t\t\t\t/>\n\t\t\t</Form.Item>\n\t\t\t{selectedReadType === 4 && (\n\t\t\t\t<>\n\t\t\t\t\t<Form.Item label=\"支付方式\" name=\"payWay\" initialValue={defaultInitForm.payWay}>\n\t\t\t\t\t\t<Radio.Group\n\t\t\t\t\t\t\toptions={PayWayList}\n\t\t\t\t\t\t\tonChange={e => {\n\t\t\t\t\t\t\t\thandleChange({ payWay: e.target.value });\n\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t/>\n\t\t\t\t\t</Form.Item>\n\t\t\t\t\t<Form.Item\n\t\t\t\t\t\tlabel=\"付费金额\"\n\t\t\t\t\t\tname=\"payAmount\"\n\t\t\t\t\t\tinitialValue={defaultInitForm.payAmount}\n\t\t\t\t\t\trules={\n\t\t\t\t\t\t\tselectedPayWay === \"email\"\n\t\t\t\t\t\t\t\t? []\n\t\t\t\t\t\t\t\t: [\n\t\t\t\t\t\t\t\t\t\t{ required: true, message: \"请输入付费金额\" },\n\t\t\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\t\tvalidator: (_, value) => {\n\t\t\t\t\t\t\t\t\t\t\t\tif (value == null || Number(value) <= 0) {\n\t\t\t\t\t\t\t\t\t\t\t\t\treturn Promise.reject(new Error(\"付费金额必须大于 0\"));\n\t\t\t\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\t\t\t\treturn Promise.resolve();\n\t\t\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t  ]\n\t\t\t\t\t\t}\n\t\t\t\t\t>\n\t\t\t\t\t\t<InputNumber\n\t\t\t\t\t\t\tmin={0.01}\n\t\t\t\t\t\t\tprecision={2}\n\t\t\t\t\t\t\tstyle={{ width: \"100%\" }}\n\t\t\t\t\t\t\taddonAfter=\"元\"\n\t\t\t\t\t\t\tonChange={value => {\n\t\t\t\t\t\t\t\thandleChange({ payAmount: Number(value || 0) });\n\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t/>\n\t\t\t\t\t</Form.Item>\n\t\t\t\t</>\n\t\t\t)}\n\t\t\t<Form.Item name=\"categoryId\" label=\"分类\" rules={[{ required: true, message: \"请选择一个分类\" }]}>\n\t\t\t\t<Radio.Group\n\t\t\t\t\tclassName=\"custom-radio-group\"\n\t\t\t\t\toptionType=\"button\"\n\t\t\t\t\tbuttonStyle=\"solid\"\n\t\t\t\t\toptions={CategoryTypeList}\n\t\t\t\t></Radio.Group>\n\t\t\t</Form.Item>\n\t\t\t<Form.Item label=\"标签\" name=\"tagName\" rules={[{ required: true, message: \"请选择标签!\" }]}>\n\t\t\t\t{/*用下拉框做一个教程的选择 */}\n\t\t\t\t<DebounceSelect\n\t\t\t\t\tonChange={selectedValues => {\n\t\t\t\t\t\tconsole.log(\"选中的值:\", selectedValues);\n\t\t\t\t\t\t// @ts-ignore\n\t\t\t\t\t\tconst keys = selectedValues.map(item => Number(item.key));\n\n\t\t\t\t\t\tconsole.log(\"keysString\", keys);\n\t\t\t\t\t\thandleChange({ tagIds: keys });\n\t\t\t\t\t}}\n\t\t\t\t/>\n\t\t\t</Form.Item>\n\t\t</Form>\n\t);\n\n\treturn (\n\t\t<div className=\"ArticleEdit\">\n\t\t\t<ContentWrap>\n\t\t\t\t<Search\n\t\t\t\t\tstatus={status}\n\t\t\t\t\thandleReplaceImgUrl={handleReplaceImgUrl}\n\t\t\t\t\thandleImportWord={handleImportWord}\n\t\t\t\t\thandleImportMarkdown={handleImportMarkdown}\n\t\t\t\t\thandleSave={handleSaveOrUpdate}\n\t\t\t\t\tgoBack={goBack}\n\t\t\t\t/>\n\t\t\t\t<ContentInterWrap>\n\t\t\t\t\t<div className=\"markdown-body\" style={{ position: \"relative\" }}>\n\t\t\t\t\t\t<Editor\n\t\t\t\t\t\t\tvalue={content}\n\t\t\t\t\t\t\tplugins={editorPlugins}\n\t\t\t\t\t\t\tlocale={zhHans}\n\t\t\t\t\t\t\tuploadImages={files => {\n\t\t\t\t\t\t\t\treturn Promise.all(\n\t\t\t\t\t\t\t\t\tfiles.map(file => {\n\t\t\t\t\t\t\t\t\t\t// 限制图片大小，不超过 5M\n\t\t\t\t\t\t\t\t\t\tif (file.size > 5 * 1024 * 1024) {\n\t\t\t\t\t\t\t\t\t\t\treturn {\n\t\t\t\t\t\t\t\t\t\t\t\turl: \"图片大小不能超过 5M\"\n\t\t\t\t\t\t\t\t\t\t\t};\n\t\t\t\t\t\t\t\t\t\t}\n\n\t\t\t\t\t\t\t\t\t\tconst formData = new FormData();\n\t\t\t\t\t\t\t\t\t\tformData.append(\"image\", file);\n\n\t\t\t\t\t\t\t\t\t\treturn uploadImgApi(formData).then(({ status, result }) => {\n\t\t\t\t\t\t\t\t\t\t\tconst { code, msg } = status || {};\n\t\t\t\t\t\t\t\t\t\t\tconst { imagePath } = result || {};\n\t\t\t\t\t\t\t\t\t\t\tif (code === 0) {\n\t\t\t\t\t\t\t\t\t\t\t\treturn {\n\t\t\t\t\t\t\t\t\t\t\t\t\turl: imagePath\n\t\t\t\t\t\t\t\t\t\t\t\t};\n\t\t\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\t\t\treturn {\n\t\t\t\t\t\t\t\t\t\t\t\turl: msg\n\t\t\t\t\t\t\t\t\t\t\t};\n\t\t\t\t\t\t\t\t\t\t});\n\t\t\t\t\t\t\t\t\t})\n\t\t\t\t\t\t\t\t);\n\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\tonChange={v => {\n\t\t\t\t\t\t\t\t// 右侧的预览更新\n\t\t\t\t\t\t\t\tsetContent(v);\n\t\t\t\t\t\t\t\thandleChange({ content: v });\n\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t/>\n\t\t\t\t\t\t{target &&\n\t\t\t\t\t\t\tcontainer &&\n\t\t\t\t\t\t\tcreatePortal(\n\t\t\t\t\t\t\t\t<Moveable\n\t\t\t\t\t\t\t\t\tref={moveableRef}\n\t\t\t\t\t\t\t\t\ttarget={target}\n\t\t\t\t\t\t\t\t\tcontainer={container}\n\t\t\t\t\t\t\t\t\tdraggable={false}\n\t\t\t\t\t\t\t\t\tresizable={true}\n\t\t\t\t\t\t\t\t\tkeepRatio={true}\n\t\t\t\t\t\t\t\t\tthrottleResize={0}\n\t\t\t\t\t\t\t\t\trenderDirections={[\"nw\", \"ne\", \"sw\", \"se\"]} // 只显示四个角的缩放柄，更符合图片操作\n\t\t\t\t\t\t\t\t\tables={[RestoreAble]}\n\t\t\t\t\t\t\t\t\tprops={{\n\t\t\t\t\t\t\t\t\t\tresetImageInMarkdown: resetImageInMarkdown,\n\t\t\t\t\t\t\t\t\t\treplaceImageInMarkdown: replaceImageInMarkdown,\n\t\t\t\t\t\t\t\t\t\tcopyImageToClipboard: copyImageToClipboard\n\t\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\t\tonResize={({ target, width, height, drag }: OnResize) => {\n\t\t\t\t\t\t\t\t\t\t// 限制最小尺寸为 20px，防止图片消失\n\t\t\t\t\t\t\t\t\t\tconst finalWidth = Math.max(20, width);\n\t\t\t\t\t\t\t\t\t\tconst finalHeight = Math.max(20, height);\n\n\t\t\t\t\t\t\t\t\t\tconst el = target as HTMLElement;\n\t\t\t\t\t\t\t\t\t\tel.style.width = `${finalWidth}px`;\n\t\t\t\t\t\t\t\t\t\tel.style.height = `${finalHeight}px`;\n\n\t\t\t\t\t\t\t\t\t\t// 如果有位移（通常由于中心点偏移引起），也应用到位移上\n\t\t\t\t\t\t\t\t\t\tel.style.transform = drag.transform;\n\t\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\t\tonResizeEnd={({ target }: OnResizeEnd) => {\n\t\t\t\t\t\t\t\t\t\tconst el = target as HTMLElement;\n\t\t\t\t\t\t\t\t\t\tupdateImageInMarkdown(el as HTMLImageElement, el.offsetWidth, el.offsetHeight);\n\t\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\t\torigin={false}\n\t\t\t\t\t\t\t\t\tedge={false} // 设为 false，避免边缘点击冲突\n\t\t\t\t\t\t\t\t/>,\n\t\t\t\t\t\t\t\tcontainer\n\t\t\t\t\t\t\t)}\n\t\t\t\t\t</div>\n\t\t\t\t</ContentInterWrap>\n\t\t\t</ContentWrap>\n\t\t\t{/* 保存或者更新时打开的抽屉 */}\n\t\t\t<Drawer\n\t\t\t\ttitle={status === UpdateEnum.Edit ? \"更新文章\" : \"保存文章\"}\n\t\t\t\tplacement=\"right\"\n\t\t\t\twidth={600}\n\t\t\t\topen={isOpenDrawerShow}\n\t\t\t\tonClose={handleClose}\n\t\t\t\textra={\n\t\t\t\t\t<Space>\n\t\t\t\t\t\t<Button onClick={handleAiInit}>初始</Button>\n\t\t\t\t\t\t<Button onClick={resetFrom}>重置</Button>\n\t\t\t\t\t\t<Button type=\"primary\" onClick={handleSubmit}>\n\t\t\t\t\t\t\t{status === UpdateEnum.Edit ? \"更新文章\" : \"确认保存\"}\n\t\t\t\t\t\t</Button>\n\t\t\t\t\t</Space>\n\t\t\t\t}\n\t\t\t>\n\t\t\t\t{drawerContent}\n\t\t\t</Drawer>\n\t\t</div>\n\t);\n};\n\nconst mapStateToProps = (state: any) => state.disc.disc;\nconst mapDispatchToProps = {};\nexport default connect(mapStateToProps, mapDispatchToProps)(ArticleEdit);\n"
  },
  {
    "path": "src/views/article/edit/search/index.scss",
    "content": ".article-edit-search {\n  margin-bottom: 16px;\n\n  &__wrap {\n    display: flex;\n    justify-content: space-between;\n  }\n\n  &__search {\n    flex: 1;\n    display: flex;\n    justify-content: space-between;\n\n    &-wrap {\n      display: flex;\n    }\n  }\n\n  &__search-item {\n    margin-right: 28px;\n  }\n\n  &-label {\n    margin-right: 12px;\n  }\n}\n"
  },
  {
    "path": "src/views/article/edit/search/index.tsx",
    "content": "/* eslint-disable prettier/prettier */\nimport { FC } from \"react\";\nimport { ArrowLeftOutlined, FileTextOutlined, FileWordOutlined, RetweetOutlined, SaveOutlined } from \"@ant-design/icons\";\nimport { Button } from \"antd\";\n\nimport { ContentInterWrap } from \"@/components/common-wrap\";\nimport { UpdateEnum } from \"@/enums/common\";\n\nimport \"./index.scss\";\n\ninterface IProps {\n\thandleSave: (e: object) => void;\n\thandleReplaceImgUrl: (e: object) => void;\n\thandleImportWord: () => void;\n\thandleImportMarkdown: () => void;\n\tgoBack: () => void;\n\tstatus: number;\n}\n\nconst Search: FC<IProps> = ({ handleSave, goBack, status, handleReplaceImgUrl, handleImportWord, handleImportMarkdown }) => {\n\treturn (\n\t\t<div className=\"article-edit-search\">\n\t\t\t{/* 搜索 */}\n\t\t\t<ContentInterWrap className=\"article-edit-search__wrap\">\n\t\t\t\t<div className=\"article-edit-search__search\">\n\t\t\t\t\t<div className=\"article-edit-search__search-item\">\n\t\t\t\t\t\t<Button onClick={goBack}><ArrowLeftOutlined />返回文章列表</Button>\n\t\t\t\t\t</div>\n\t\t\t\t\t<div className=\"article-edit-search__search-btn\">\n\t\t\t\t\t\t<Button type=\"default\" \n\t\t\t\t\t\t\ticon={<FileWordOutlined />} \n\t\t\t\t\t\t\tstyle={{ marginRight: \"10px\" }} \n\t\t\t\t\t\t\tonClick={handleImportWord}>\n\t\t\t\t\t\t\t导入Word\n\t\t\t\t\t\t</Button>\n\n\t\t\t\t\t\t<Button type=\"default\" \n\t\t\t\t\t\t\ticon={<FileTextOutlined />} \n\t\t\t\t\t\t\tstyle={{ marginRight: \"10px\" }} \n\t\t\t\t\t\t\tonClick={handleImportMarkdown}>\n\t\t\t\t\t\t\t导入Markdown\n\t\t\t\t\t\t</Button>\n\n\t\t\t\t\t\t<Button type=\"primary\" \n\t\t\t\t\t\t\ticon={<RetweetOutlined />} \n\t\t\t\t\t\t\tstyle={{ marginRight: \"10px\" }} \n\t\t\t\t\t\t\tonClick={handleReplaceImgUrl}>\n\t\t\t\t\t\t\t转链\n\t\t\t\t\t\t</Button>\n\n\t\t\t\t\t\t<Button type=\"primary\" icon={<SaveOutlined />} style={{ marginRight: \"25px\" }} onClick={handleSave}>\n\t\t\t\t\t\t\t{status === UpdateEnum.Edit ? \"更新\" : \"保存\"}\n\t\t\t\t\t\t</Button>\n\t\t\t\t\t</div>\n\t\t\t\t</div>\n\t\t\t</ContentInterWrap>\n\t\t</div>\n\t);\n};\nexport default Search;\n"
  },
  {
    "path": "src/views/article/list/index.scss",
    "content": ".cell-text {\n  /* stylelint-disable-next-line value-no-vendor-prefix */\n  display: -webkit-box;\n  -webkit-box-orient: vertical;\n  -webkit-line-clamp: 2;\n  overflow: hidden;\n  text-overflow: ellipsis; /* 在文本溢出时显示省略号 */\n  white-space: normal; /* 确保文本可以换行 */\n  line-height: 1.2em; /* 调整行高，根据需要修改 */\n  max-height: 2.4em; /* 最大高度为行高的两倍 */\n}\n\n.sort {\n  height: 100%;\n}\n"
  },
  {
    "path": "src/views/article/list/index.tsx",
    "content": "/* eslint-disable prettier/prettier */\nimport { FC, useCallback, useEffect, useState } from \"react\";\nimport { connect } from \"react-redux\";\nimport { useNavigate } from \"react-router\";\nimport { DeleteOutlined, EditOutlined, HighlightOutlined } from \"@ant-design/icons\";\nimport { Avatar, Button, Form, Input, message, Modal, Select, Space, Switch, Table, Tooltip } from \"antd\";\nimport type { ColumnsType } from \"antd/es/table\";\nimport dayjs from \"dayjs\";\n\nimport { delArticleApi, generateArticleSlugApi, getArticleListApi, operateArticleApi, updateArticleApi } from \"@/api/modules/article\";\nimport { getColumnListApi } from \"@/api/modules/column\";\nimport { ContentInterWrap, ContentWrap } from \"@/components/common-wrap\";\nimport { initPagination, IPagination, UpdateEnum } from \"@/enums/common\";\nimport { MapItem } from \"@/typings/common\";\nimport { baseDomain } from \"@/utils/util\";\nimport Search from \"../components/search\";\n\nimport \"./index.scss\";\n\ninterface DataType {\n\tarticleId: number;\n\tauthor: number;\n\tauthorName: string;\n\tauthorAvatar: string;\n\ttitle: string;\n\tcover: string;\n\tstatus: number;\n\tofficalStat: number;\n\ttoppingStat: number;\n\tcreamStat: number;\n\turlSlug?: string;\n\tshortTitle?: string;\n}\n\ninterface IProps {}\n\n// 编辑表单接口，定义类型\ninterface IInitForm {\n\tarticleId: number;\n\ttitle: string;\n\tshortTitle: string;\n\turlSlug: string;\n\tstatus: number;\n}\n\n// 查询表单接口，定义类型\ninterface ISearchForm {\n\tuserName: string;\n\ttitle: string;\n\tstatus: number;\n\ttoppingStat: number;\n\tofficalStat: number;\n\tcolumnId: number;\n}\n\n// 编辑表单默认值\nconst defaultInitForm = {\n\tarticleId: -1,\n\ttitle: \"\",\n\tshortTitle: \"\",\n\turlSlug: \"\",\n\tstatus: -1\n};\n\n// 查询表单默认值\nconst defaultSearchForm = {\n\tuserName: \"\",\n\ttitle: \"\",\n\tstatus: -1,\n\ttoppingStat : -1,\n\tofficalStat : -1,\n\tcolumnId: -1\n};\n\nconst Article: FC<IProps> = props => {\n\tconst [formRef] = Form.useForm();\n\t// 编辑表单\n\tconst [form, setForm] = useState<IInitForm>(defaultInitForm);\n\tconst [slugGenerating, setSlugGenerating] = useState<boolean>(false);\n\t// 查询表单\n\tconst [searchForm, setSearchForm] = useState<ISearchForm>(defaultSearchForm);\n\t// 弹窗\n\tconst [isModalOpen, setIsModalOpen] = useState<boolean>(false);\n\t// 列表数据\n\tconst [tableData, setTableData] = useState<DataType[]>([]);\n\t// 表格选中项\n\tconst [selectedRowKeys, setSelectedRowKeys] = useState<Array<string | number>>([]);\n\tconst [batchDeleting, setBatchDeleting] = useState<boolean>(false);\n\t// 专栏列表\n\tconst [columnList, setColumnList] = useState<Array<{ label: string; value: number }>>([]);\n\t// 刷新函数\n\tconst [query, setQuery] = useState<number>(0);\n\n\t// 分页\n\tconst [pagination, setPagination] = useState<IPagination>(initPagination);\n\tconst { current, pageSize } = pagination;\n\n\tconst paginationInfo = {\n\t\tshowSizeChanger: true,\n\t\tshowTotal: (total: number) => `共 ${total || 0} 条`,\n\t\t...pagination,\n\t\tonChange: (current: number, pageSize: number) => {\n\t\t\tsetPagination({ current, pageSize });\n\t\t}\n\t};\n\n\t// 一些配置项\n\t//@ts-ignore\n\tconst { PushStatusList, ToppingStatusList, OfficalStatusList} = props || {};\n\n\tconst { articleId } = form;\n\n\tconst navigate = useNavigate();\n\n\tconst onSure = useCallback(() => {\n\t\tsetQuery(prev => prev + 1);\n\t}, []);\n\n\tconst clearSelection = () => {\n\t\tsetSelectedRowKeys([]);\n\t};\n\t\n\t// 编辑表单值改变\n\tconst handleChange = (item: MapItem) => {\n\t\tsetForm(prev => ({ ...prev, ...item }));\n\t};\n\n\t// 查询表单值改变\n\tconst handleSearchChange = (item: MapItem) => {\n\t\t// 当 status 的值为 -1 时，重新显示\n\t\tsetSearchForm({ ...searchForm, ...item });\n\t\tconsole.log(\"查询条件变化了\",searchForm);\n\t};\n\n\t// 当点击查询按钮的时候触发\n\tconst handleSearch = () => {\n\t\t// 目前是根据文章标题搜索，后面需要加上其他条件\n\t\tconsole.log(\"查询条件\", searchForm);\n\t\tclearSelection();\n\t\t// 查询的时候重置分页\n\t\tsetPagination({ current: 1, pageSize });\n\t\t// 重新请求数据\n\t\tonSure();\n\t};\n\n\tconst deleteArticles = async (articleIds: number[]) => {\n\t\tconst results = [];\n\t\tfor (const id of articleIds) {\n\t\t\ttry {\n\t\t\t\tconst { status } = await delArticleApi(id);\n\t\t\t\tresults.push({\n\t\t\t\t\tarticleId: id,\n\t\t\t\t\tsuccess: status?.code === 0,\n\t\t\t\t\tmsg: status?.msg || \"\"\n\t\t\t\t});\n\t\t\t} catch (error) {\n\t\t\t\tconsole.log(\"删除文章失败\", error);\n\t\t\t\tresults.push({\n\t\t\t\t\tarticleId: id,\n\t\t\t\t\tsuccess: false,\n\t\t\t\t\tmsg: \"\"\n\t\t\t\t});\n\t\t\t}\n\t\t}\n\n\t\tconst failedItems = results.filter(item => !item.success);\n\t\treturn {\n\t\t\tsuccessCount: results.length - failedItems.length,\n\t\t\tfailCount: failedItems.length,\n\t\t\tfailedIds: failedItems.map(item => item.articleId),\n\t\t\tfirstError: failedItems[0]?.msg || \"\"\n\t\t};\n\t};\n\n\t// 删除\n\tconst handleDel = (articleId: number) => {\n\t\tModal.warning({\n\t\t\ttitle: \"确认删除此文章吗\",\n\t\t\tcontent: \"删除此文章后无法恢复，请谨慎操作！\",\n\t\t\tmaskClosable: true,\n\t\t\tclosable: true,\n\t\t\tonOk: async () => {\n\t\t\t\tconst { successCount, firstError } = await deleteArticles([articleId]);\n\t\t\t\tif (successCount === 1) {\n\t\t\t\t\tmessage.success(\"删除成功\");\n\t\t\t\t\tsetSelectedRowKeys(prev => prev.filter(key => Number(key) !== articleId));\n\t\t\t\t\tonSure();\n\t\t\t\t} else {\n\t\t\t\t\tmessage.error(firstError || \"删除失败\");\n\t\t\t\t}\n\t\t\t}\n\t\t});\n\t};\n\n\tconst handleBatchDel = () => {\n\t\tif (!selectedRowKeys.length) {\n\t\t\tmessage.warning(\"请先选择要删除的文章\");\n\t\t\treturn;\n\t\t}\n\n\t\tModal.confirm({\n\t\t\ttitle: \"确认批量删除选中的文章吗\",\n\t\t\tcontent: `本次将删除 ${selectedRowKeys.length} 篇文章，删除后无法恢复，请谨慎操作！`,\n\t\t\tokText: \"确认删除\",\n\t\t\tokButtonProps: { danger: true },\n\t\t\tcancelText: \"取消\",\n\t\t\tonOk: async () => {\n\t\t\t\tsetBatchDeleting(true);\n\t\t\t\ttry {\n\t\t\t\t\tconst articleIds = selectedRowKeys.map(key => Number(key));\n\t\t\t\t\tconst { successCount, failCount, failedIds, firstError } = await deleteArticles(articleIds);\n\t\t\t\t\tif (successCount === articleIds.length) {\n\t\t\t\t\t\tmessage.success(`成功删除 ${successCount} 篇文章`);\n\t\t\t\t\t\tclearSelection();\n\t\t\t\t\t\tonSure();\n\t\t\t\t\t\treturn;\n\t\t\t\t\t}\n\n\t\t\t\t\tif (successCount > 0) {\n\t\t\t\t\t\tsetSelectedRowKeys(failedIds);\n\t\t\t\t\t\tmessage.warning(\n\t\t\t\t\t\t\tfirstError\n\t\t\t\t\t\t\t\t? `已删除 ${successCount} 篇文章，${failCount} 篇删除失败：${firstError}`\n\t\t\t\t\t\t\t\t: `已删除 ${successCount} 篇文章，${failCount} 篇删除失败`\n\t\t\t\t\t\t);\n\t\t\t\t\t\tonSure();\n\t\t\t\t\t\treturn;\n\t\t\t\t\t}\n\n\t\t\t\t\tmessage.error(firstError || \"批量删除失败\");\n\t\t\t\t} finally {\n\t\t\t\t\tsetBatchDeleting(false);\n\t\t\t\t}\n\t\t\t}\n\t\t});\n\t};\n\n\t// 置顶和官方\n\tconst handleOperate = async (articleId: number, operateType: number) => {\n\t\tlet operateDesc = \"\";\n\t\tif (operateType === 4) {\n\t\t\toperateDesc = \"取消置顶\";\n\t\t} else if (operateType === 3) {\n\t\t\toperateDesc = \"置顶\";\n\t\t} else if (operateType === 2) {\n\t\t\toperateDesc = \"取消推荐\";\n\t\t} else if (operateType === 1) {\n\t\t\toperateDesc = \"推荐\";\n\t\t}\n\t\t\n\t\tconst { status } = await operateArticleApi({ articleId, operateType });\n\t\tconst { code, msg } = status || {};\n\t\tif (code === 0) {\n\t\t\tmessage.success(operateDesc + \"操作成功\");\n\t\t\tonSure();\n\t\t} else {\n\t\t\tmessage.error(msg || operateDesc + \"操作失败\");\n\t\t}\n\t};\n\n\t// 改变文章状态的操作\n\tconst handleStatusChange = async (articleId: number, status: number) => {\n\t\t// 将 articleId 和 status 作为参数传递给 updateArticleApi\n\t\tconst newValues = { articleId, status };\n\t\tconst { status: successStatus } = await updateArticleApi(newValues) || {};\n\t\tconst { code, msg } = successStatus || {};\n\t\tif (code === 0) {\n\t\t\tmessage.success(\"状态操作成功\");\n\t\t\tconsole.log(\"code\", code);\n\t\t\tonSure();\n\t\t} else {\n\t\t\tmessage.error(msg || \"状态操作失败\");\n\t\t}\n\t};\n\n\t// 导航到文章编辑页面\n\tconst handleEdit = (articleId: number) => {\n\t\tconsole.log(\"articleId\", articleId);\n\t\tnavigate(\"/article/edit/index\", { state: { \n\t\t\tarticleId,\n\t\t\tstatus: UpdateEnum.Edit\n\t\t}});\n\t};\n\n\tconst handleSubmit = async () => {\n\t\tconst values = await formRef.validateFields();\n\t\n\t\t// 从 form 中取出 status\n\t\tconst { status } = form;\n\t\tconst newValues = {\n\t\t\t...values,\n\t\t\tarticleId,\n\t\t\tstatus,\n\t\t\turlSlug: values.urlSlug?.trim() ?? \"\"\n\t\t};\n\t\tconsole.log(\"编辑 时提交的 newValues:\", newValues);\n\t\t\n\t\tconst { status: successStatus } = (await updateArticleApi(newValues)) || {};\n\t\tconst { code, msg } = successStatus || {};\n\t\tif (code === 0) {\n\t\t\tmessage.success(\"编辑成功\");\n\t\t\tsetIsModalOpen(false);\n\t\t\tonSure();\n\t\t} else {\n\t\t\tmessage.error(msg || \"编辑失败\");\n\t\t}\n\t};\n\n\tconst handleGenerateUrlSlug = async () => {\n\t\tconst values = formRef.getFieldsValue([\"title\", \"shortTitle\"]);\n\t\tconst title = values.title?.trim();\n\t\tconst shortTitle = values.shortTitle?.trim();\n\t\tif (!title && !shortTitle) {\n\t\t\tmessage.warning(\"请先填写标题或教程名\");\n\t\t\treturn;\n\t\t}\n\n\t\tsetSlugGenerating(true);\n\t\ttry {\n\t\t\tconst { result } = await generateArticleSlugApi({ title, shortTitle });\n\t\t\tif (!result) {\n\t\t\t\tmessage.warning(\"没有生成可用的语义 URL\");\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\tformRef.setFieldsValue({ urlSlug: result });\n\t\t\thandleChange({ urlSlug: result });\n\t\t\tmessage.success(\"语义 URL 生成成功\");\n\t\t} catch (error) {\n\t\t\tconsole.log(\"生成语义 URL 失败\", error);\n\t\t} finally {\n\t\t\tsetSlugGenerating(false);\n\t\t}\n\t};\n\n\t// 数据请求，这是一个钩子，query, current, pageSize, search 有变化的时候就会自动触发\n\tuseEffect(() => {\n\t\tconst getSortList = async () => {\n\t\t\tconst { status, result } = await getArticleListApi({ \n\t\t\t\tpageNumber: current, \n\t\t\t\tpageSize,\n\t\t\t\t...searchForm\n\t\t\t});\n\t\t\tconst { code } = status || {};\n\t\t\t//@ts-ignore\n\t\t\tconst { list, pageNum, pageSize: resPageSize, total } = result || {};\n\t\t\tsetPagination({ current: Number(pageNum), pageSize: resPageSize, total });\n\t\t\tif (code === 0) {\n\t\t\t\tconst newList = list.map((item: MapItem) => ({ ...item, key: item?.articleId }));\n\t\t\t\tsetTableData(newList);\n\t\t\t}\n\t\t};\n\t\tgetSortList();\n\t}, [query, current, pageSize]);\n\n\t// 获取专栏列表\n\tuseEffect(() => {\n\t\tconst getColumnList = async () => {\n\t\t\tconst { status, result } = await getColumnListApi({\n\t\t\t\tpageNumber: 1,\n\t\t\t\tpageSize: 100 // 假设专栏不多，直接取 100 个\n\t\t\t});\n\t\t\tconst { code } = status || {};\n\t\t\tif (code === 0) {\n\t\t\t\t//@ts-ignore\n\t\t\t\tconst { list } = result || {};\n\t\t\t\tconst newList = list.map((item: any) => ({\n\t\t\t\t\tlabel: item.column,\n\t\t\t\t\tvalue: item.columnId\n\t\t\t\t}));\n\t\t\t\tsetColumnList(newList);\n\t\t\t}\n\t\t};\n\t\tgetColumnList();\n\t}, []);\n\n\tconst rowSelection = {\n\t\tselectedRowKeys,\n\t\tpreserveSelectedRowKeys: true,\n\t\tonChange: (keys: Array<string | number>) => {\n\t\t\tsetSelectedRowKeys(keys);\n\t\t}\n\t};\n\n\t// 表头设置\n\tconst columns: ColumnsType<DataType> = [\n\t\t{\n\t\t\ttitle: \"标题\",\n\t\t\tdataIndex: \"title\",\n\t\t\tkey: \"title\",\n\t\t\trender(value, item) {\n\t\t\t\tconst { urlSlug, articleId, shortTitle } = item;\n\t\t\t\tconst fullUrl = urlSlug \n\t\t\t\t\t? `${baseDomain}/article/detail/${articleId}/${urlSlug}`\n\t\t\t\t\t: `${baseDomain}/article/detail/${articleId}`;\n\t\t\t\tconst tooltipContent = (\n\t\t\t\t\t<div>\n\t\t\t\t\t\t<div>{fullUrl}</div>\n\t\t\t\t\t\t{shortTitle && <div style={{ marginTop: '4px' }}>教程名: {shortTitle}</div>}\n\t\t\t\t\t</div>\n\t\t\t\t);\n\t\t\t\treturn (\n\t\t\t\t\t<Tooltip title={tooltipContent}>\n\t\t\t\t\t\t<div>\n\t\t\t\t\t\t\t<a \n\t\t\t\t\t\t\t\thref={fullUrl}\n\t\t\t\t\t\t\t\tclassName=\"cell-text\"\n\t\t\t\t\t\t\t\ttarget=\"_blank\" rel=\"noreferrer\">\n\t\t\t\t\t\t\t\t{value}\n\t\t\t\t\t\t\t</a>\n\t\t\t\t\t\t\t{shortTitle && (\n\t\t\t\t\t\t\t\t<div style={{ fontSize: '12px', color: '#1890ff', marginTop: '4px' }}>\n\t\t\t\t\t\t\t\t\t教程: {shortTitle}\n\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t{urlSlug && (\n\t\t\t\t\t\t\t\t<div style={{ fontSize: '12px', color: '#999', marginTop: '4px' }}>\n\t\t\t\t\t\t\t\t\t{urlSlug}\n\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t</div>\n\t\t\t\t\t</Tooltip>\n\t\t\t\t);\n\t\t\t}\n\t\t},\n\t\t{\n\t\t\ttitle: \"修改时间\",\n\t\t\tdataIndex: \"updateTime\",\n\t\t\tkey: \"updateTime\",\n\t\t\twidth: 120,\n\t\t\trender: (value: string) => {\n\t\t\t\tconst time = dayjs(value);\n\t\t\t\treturn <Tooltip title={time.format('YYYY-MM-DD HH:mm:ss')}><span>{time.format('MM-DD HH:mm')}</span></Tooltip>;\n\t\t\t}\n\t\t},\t\n\t\t{\n\t\t\ttitle: \"作者\",\n\t\t\tdataIndex: \"authorName\",\n\t\t\twidth: 110,\n\t\t\tkey: \"authorName\",\n\t\t\trender(value) {\n\t\t\t\treturn <>\n\t\t\t\t\t<Avatar style={{ backgroundColor: '#1890ff', color: '#fff' }} size=\"large\">\n\t\t\t\t\t\t{value?.slice(0, 3) || ''}\n\t\t\t\t\t</Avatar>\n\t\t\t\t</>;\n\t\t\t}\n\t\t},\n\t\t{\n\t\t\ttitle: \"置顶\",\n\t\t\tdataIndex: \"toppingStat\",\n\t\t\tkey: \"toppingStat\",\n\t\t\trender(_, item) {\n\t\t\t\tconst { articleId, toppingStat } = item;\n\t\t\t\t// 返回的是 0 和 1\n\t\t\t\tconst isTopped = toppingStat === 1;\n\t\t\n\t\t\t\tconst topStatus = isTopped ? 4 : 3; // 3-置顶；4-取消置顶\n\t\t\t\treturn <Switch \n\t\t\t\t\tchecked={isTopped} \n\t\t\t\t\tonChange={() => handleOperate(articleId, topStatus)} \n\t\t\t\t\t/>;\n\t\t\t}\n\t\t},\n\t\t{\n\t\t\ttitle: \"推荐\",\n\t\t\tdataIndex: \"officalStat\",\n\t\t\tkey: \"officalStat\",\n\t\t\trender(_, item) {\n\t\t\t\t// 使用 switch 开关\n\t\t\t\tconst { articleId, officalStat } = item;\n\t\t\t\tconst isOffical = officalStat === 1;\n\t\t\t\tconst officalStatus = isOffical ? 2 : 1; // 1-官方推荐；2-取消官方推荐\n\t\t\t\treturn <Switch \n\t\t\t\t\tchecked={isOffical} \n\t\t\t\t\tonChange={() => handleOperate(articleId, officalStatus)} \n\t\t\t\t\t/>;\n\t\t\t}\n\t\t},\n\t\t{\n\t\t\ttitle: \"状态\",\n\t\t\tdataIndex: \"status\",\n\t\t\tkey: \"status\",\n\t\t\trender(_, item) {\n\t\t\t\tconst { articleId, status } = item;\n\t\t\t\treturn <Select \n\t\t\t\t\t\t\t\t// 如果 status 为 1 那么 status 为 warning\n\t\t\t\t\t\t\t\tstatus={status === 1 ? \"\" : \"error\"}\n\t\t\t\t\t\t\t\tvalue={status.toString()} \n\t\t\t\t\t\t\t\toptions={PushStatusList}\n\t\t\t\t\t\t\t\tonChange={(value) => handleStatusChange(articleId, Number(value))}\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t</Select>;\n\t\t\t}\n\t\t},\n\t\t{\n\t\t\ttitle: \"操作\",\n\t\t\tkey: \"key\",\n\t\t\twidth: 150,\n\t\t\trender: (_, item) => {\n\t\t\t\t// 从 item 中取出 articleId\n\t\t\t\tconst { articleId } = item;\n\t\t\t\treturn (\n\t\t\t\t\t<div className=\"operation-btn\">\n\t\t\t\t\t\t<Tooltip title=\"调整标题\">\n\t\t\t\t\t\t\t<Button\n\t\t\t\t\t\t\t\ttype=\"primary\"\n\t\t\t\t\t\t\t\ticon={<EditOutlined />}\n\t\t\t\t\t\t\t\tstyle={{ marginRight: \"10px\" }}\n\t\t\t\t\t\t\t\tonClick={() => {\n\t\t\t\t\t\t\t\t\tsetIsModalOpen(true);\n\t\t\t\t\t\t\t\t\thandleChange({\n\t\t\t\t\t\t\t\t\t\t...item,\n\t\t\t\t\t\t\t\t\t\turlSlug: item.urlSlug || \"\"\n\t\t\t\t\t\t\t\t\t});\n\t\t\t\t\t\t\t\t\tformRef.setFieldsValue({\n\t\t\t\t\t\t\t\t\t\t...item,\n\t\t\t\t\t\t\t\t\t\turlSlug: item.urlSlug || \"\"\n\t\t\t\t\t\t\t\t\t});\n\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t></Button>\n\t\t\t\t\t\t</Tooltip>\n\t\t\t\t\t\t<Tooltip title=\"调整内容\">\n\t\t\t\t\t\t\t<Button\n\t\t\t\t\t\t\t\ttype=\"primary\"\n\t\t\t\t\t\t\t\ticon={<HighlightOutlined />}\n\t\t\t\t\t\t\t\tstyle={{ marginRight: \"10px\" }}\n\t\t\t\t\t\t\t\tonClick={() => {\n\t\t\t\t\t\t\t\t\thandleEdit(articleId);\n\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t></Button>\n\t\t\t\t\t\t</Tooltip>\n\t\t\t\t\t\t<Tooltip title=\"删除\">\n\t\t\t\t\t\t\t<Button \n\t\t\t\t\t\t\t\ttype=\"primary\" \n\t\t\t\t\t\t\t\tdanger \n\t\t\t\t\t\t\t\ticon={<DeleteOutlined />} \n\t\t\t\t\t\t\t\tonClick={() => handleDel(articleId)}>\n\t\t\t\t\t\t\t</Button>\n\t\t\t\t\t\t</Tooltip>\n\t\t\t\t\t</div>\n\t\t\t\t);\n\t\t\t}\n\t\t}\n\t];\n\n\tconst reviseModalContent = (\n\t\t<Form name=\"basic\" form={formRef} labelCol={{ span: 4 }} wrapperCol={{ span: 16 }} autoComplete=\"off\">\n\t\t\t<Form.Item label=\"标题\" name=\"title\" rules={[{ required: false, message: \"请输入标题!\" }]}>\n\t\t\t\t<Input\n\t\t\t\t\tallowClear\n\t\t\t\t\tonChange={e => {\n\t\t\t\t\t\thandleChange({ title: e.target.value });\n\t\t\t\t\t}}\n\t\t\t\t/>\n\t\t\t</Form.Item>\n\t\t\t<Form.Item \n\t\t\t\tlabel=\"教程名\" \n\t\t\t\tname=\"shortTitle\" \n\t\t\t\ttooltip=\"教程的时候使用\"\n\t\t\t\trules={[{ required: false, message: \"请输入短标题!\" }]}\n\t\t\t\t>\n\t\t\t\t<Input\n\t\t\t\t\tallowClear\n\t\t\t\t\tonChange={e => {\n\t\t\t\t\t\thandleChange({ shortTitle: e.target.value });\n\t\t\t\t\t}}\n\t\t\t\t/>\n\t\t\t</Form.Item>\n\t\t\t<Form.Item label=\"语义 URL\" tooltip=\"用于 SEO 友好的文章链接,例如:my-article-title\">\n\t\t\t\t<Space.Compact style={{ width: \"100%\" }}>\n\t\t\t\t\t<Form.Item name=\"urlSlug\" noStyle rules={[{ required: false }]}>\n\t\t\t\t\t\t<Input\n\t\t\t\t\t\t\tallowClear\n\t\t\t\t\t\t\tplaceholder=\"可选,留空则使用默认 ID\"\n\t\t\t\t\t\t\tonChange={e => {\n\t\t\t\t\t\t\t\tconst value = e.target.value;\n\t\t\t\t\t\t\t\thandleChange({ urlSlug: value });\n\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t/>\n\t\t\t\t\t</Form.Item>\n\t\t\t\t\t<Button loading={slugGenerating} onClick={handleGenerateUrlSlug}>\n\t\t\t\t\t\t生成\n\t\t\t\t\t</Button>\n\t\t\t\t</Space.Compact>\n\t\t\t</Form.Item>\n\t\t</Form>\n\t);\n\n\treturn (\n\t\t<div className=\"article\">\n\t\t\t<ContentWrap>\n\t\t\t\t{/* 搜索 */}\n\t\t\t\t<Search\n\t\t\t\t\thandleSearchChange={handleSearchChange}\n\t\t\t\t\thandleSearch={handleSearch}\n\t\t\t\t\tPushStatusList={PushStatusList}\n\t\t\t\t\tToppingStatusList={ToppingStatusList}\n\t\t\t\t\tOfficalStatusList={OfficalStatusList}\n\t\t\t\t\tColumnList={columnList}\n\t\t\t\t/>\n\t\t\t\t{/* 表格 */}\n\t\t\t\t<ContentInterWrap>\n\t\t\t\t\t<div\n\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\tdisplay: \"flex\",\n\t\t\t\t\t\t\tjustifyContent: \"space-between\",\n\t\t\t\t\t\t\talignItems: \"center\",\n\t\t\t\t\t\t\tmarginBottom: \"16px\"\n\t\t\t\t\t\t}}\n\t\t\t\t\t>\n\t\t\t\t\t\t<div>{selectedRowKeys.length ? `已选择 ${selectedRowKeys.length} 篇文章` : \"请选择要批量操作的文章\"}</div>\n\t\t\t\t\t\t<Space>\n\t\t\t\t\t\t\t<Button disabled={!selectedRowKeys.length} onClick={clearSelection}>\n\t\t\t\t\t\t\t\t清空选择\n\t\t\t\t\t\t\t</Button>\n\t\t\t\t\t\t\t<Button danger type=\"primary\" disabled={!selectedRowKeys.length} loading={batchDeleting} onClick={handleBatchDel}>\n\t\t\t\t\t\t\t\t批量删除\n\t\t\t\t\t\t\t</Button>\n\t\t\t\t\t\t</Space>\n\t\t\t\t\t</div>\n\t\t\t\t\t<Table rowSelection={rowSelection} columns={columns} dataSource={tableData} pagination={paginationInfo} />\n\t\t\t\t</ContentInterWrap>\n\t\t\t</ContentWrap>\n\t\t\t{/* 弹窗 */}\n\t\t\t<Modal title=\"修改\" visible={isModalOpen} onCancel={() => setIsModalOpen(false)} onOk={handleSubmit}>\n\t\t\t\t{reviseModalContent}\n\t\t\t</Modal>\n\t\t</div>\n\t);\n};\n\nconst mapStateToProps = (state: any) => state.disc.disc;\nconst mapDispatchToProps = {};\nexport default connect(mapStateToProps, mapDispatchToProps)(Article);\n"
  },
  {
    "path": "src/views/author/loginAudit/index.scss",
    "content": ".login-audit {\n\t&__meta {\n\t\tmargin-bottom: 16px;\n\t}\n\n\t&__card {\n\t\tmargin-bottom: 16px;\n\t\tborder-radius: 16px;\n\t\tbox-shadow: 0 10px 30px rgba(15, 23, 42, 0.05);\n\t}\n\n\t&__filter {\n\t\tmargin-bottom: 16px;\n\t\tpadding: 16px;\n\t\tbackground: linear-gradient(180deg, #f8fbff 0%, #ffffff 100%);\n\t\tborder: 1px solid #eef3fb;\n\t\tborder-radius: 14px;\n\t}\n\n\t&__subtext {\n\t\tfont-size: 12px;\n\t\tcolor: #8c8c8c;\n\t\tword-break: break-all;\n\t}\n\n\t&__multiline {\n\t\twhite-space: normal;\n\t\tword-break: break-word;\n\t\toverflow-wrap: anywhere;\n\t\tline-height: 1.6;\n\t}\n\n\t&__tag {\n\t\tmax-width: 100%;\n\t\theight: auto;\n\t\twhite-space: normal;\n\t\tword-break: break-word;\n\t\tline-height: 1.6;\n\t}\n}\n"
  },
  {
    "path": "src/views/author/loginAudit/index.tsx",
    "content": "import { FC, useCallback, useEffect, useMemo, useRef, useState } from \"react\";\nimport { SearchOutlined } from \"@ant-design/icons\";\nimport { Alert, Button, Card, Col, Form, Input, InputNumber, Modal, Row, Select, Space, Table, Tag, message } from \"antd\";\nimport type { ColumnsType } from \"antd/es/table\";\nimport dayjs from \"dayjs\";\n\nimport {\n\tforbidUserApi,\n\tgetLoginAuditListApi,\n\tgetUserShareRiskListApi,\n\tLoginAuditQuery,\n\tUserLoginAuditItem,\n\tUserShareRiskItem,\n\tunforbidUserApi\n} from \"@/api/modules/user\";\nimport { ContentWrap } from \"@/components/common-wrap\";\nimport { initPagination, IPagination } from \"@/enums/common\";\n\nimport \"./index.scss\";\n\ninterface LoginAuditFilterForm {\n\tstarNumber?: string;\n\tdeviceId?: string;\n\tip?: string;\n\teventType?: string;\n}\n\ninterface ShareRiskFilterForm {\n\tloginName?: string;\n\trecentDays?: number;\n\tminKickoutCount?: number;\n\tminDeviceCount?: number;\n\tminIpCount?: number;\n}\n\nconst defaultLoginAuditFilter: LoginAuditFilterForm = {\n\tstarNumber: \"\",\n\tdeviceId: \"\",\n\tip: \"\",\n\teventType: undefined\n};\n\nconst defaultShareRiskFilter: ShareRiskFilterForm = {\n\tloginName: \"\",\n\trecentDays: 7,\n\tminKickoutCount: 2,\n\tminDeviceCount: 2,\n\tminIpCount: 2\n};\n\nconst loginAuditEventOptions = [\n\t{ value: \"LOGIN_SUCCESS\", label: \"登录成功\" },\n\t{ value: \"LOGIN_FAIL\", label: \"登录失败\" },\n\t{ value: \"LOGOUT\", label: \"主动退出\" },\n\t{ value: \"SESSION_KICKOUT\", label: \"被踢下线\" },\n\t{ value: \"ACCOUNT_FORBID\", label: \"账号禁用\" },\n\t{ value: \"ACCOUNT_UNFORBID\", label: \"解除禁用\" }\n];\n\nconst riskTagTextMap: Record<string, string> = {\n\tNEW_DEVICE: \"新设备登录\",\n\tDEVICE_LIMIT_REPLACED: \"触发设备上限，替换旧会话\"\n};\n\nconst reasonTextMap: Record<string, string> = {\n\tDEVICE_LIMIT_KICKOUT: \"超过设备上限，当前会话被系统挤下线\",\n\tUSER_LOGOUT: \"用户主动退出登录\",\n\tFORCE_KICKOUT: \"管理员或系统强制下线\",\n\tACCOUNT_SUSPENDED: \"账号已被禁用，系统强制下线\"\n};\n\nconst getRiskTagText = (riskTag?: string) => {\n\tif (!riskTag) {\n\t\treturn \"-\";\n\t}\n\treturn riskTagTextMap[riskTag] || riskTag;\n};\n\nconst getReasonText = (reason?: string) => {\n\tif (!reason) {\n\t\treturn \"-\";\n\t}\n\treturn reasonTextMap[reason] || reason;\n};\n\nconst formatDateTime = (value?: string) => {\n\tif (!value) {\n\t\treturn \"-\";\n\t}\n\tconst time = dayjs(value);\n\treturn time.isValid() ? time.format(\"YYYY-MM-DD HH:mm\") : value;\n};\n\nconst LoginAuditPage: FC = () => {\n\tconst [auditFormRef] = Form.useForm<LoginAuditFilterForm>();\n\tconst [shareRiskFormRef] = Form.useForm<ShareRiskFilterForm>();\n\tconst [auditLoading, setAuditLoading] = useState(false);\n\tconst [shareRiskLoading, setShareRiskLoading] = useState(false);\n\tconst [auditFilters, setAuditFilters] = useState<LoginAuditFilterForm>(defaultLoginAuditFilter);\n\tconst [shareRiskFilters, setShareRiskFilters] = useState<ShareRiskFilterForm>(defaultShareRiskFilter);\n\tconst [auditPagination, setAuditPagination] = useState<IPagination>(initPagination);\n\tconst [shareRiskPagination, setShareRiskPagination] = useState<IPagination>({ ...initPagination, pageSize: 5 });\n\tconst [auditList, setAuditList] = useState<UserLoginAuditItem[]>([]);\n\tconst [shareRiskList, setShareRiskList] = useState<UserShareRiskItem[]>([]);\n\tconst [actionUserId, setActionUserId] = useState<number>();\n\tconst auditSectionRef = useRef<HTMLDivElement | null>(null);\n\n\tconst auditSummary = useMemo(() => {\n\t\tconst loginSuccess = auditList.filter(item => item.eventType === \"LOGIN_SUCCESS\").length;\n\t\tconst loginFail = auditList.filter(item => item.eventType === \"LOGIN_FAIL\").length;\n\t\tconst kickout = auditList.filter(item => item.eventType === \"SESSION_KICKOUT\").length;\n\t\treturn { loginSuccess, loginFail, kickout };\n\t}, [auditList]);\n\n\tconst highRiskAccountCount = useMemo(() => shareRiskList.filter(item => item.riskLevel === \"HIGH\").length, [shareRiskList]);\n\n\tconst auditPaginationInfo = {\n\t\tshowSizeChanger: true,\n\t\tshowTotal: (total: number) => `共 ${total || 0} 条`,\n\t\t...auditPagination,\n\t\tonChange: (current: number, pageSize: number) => {\n\t\t\tsetAuditPagination({ current, pageSize });\n\t\t}\n\t};\n\n\tconst shareRiskPaginationInfo = {\n\t\tshowSizeChanger: true,\n\t\tshowTotal: (total: number) => `共 ${total || 0} 个疑似账号`,\n\t\t...shareRiskPagination,\n\t\tonChange: (current: number, pageSize: number) => {\n\t\t\tsetShareRiskPagination({ current, pageSize });\n\t\t}\n\t};\n\n\tconst fetchLoginAudit = useCallback(async () => {\n\t\tsetAuditLoading(true);\n\t\ttry {\n\t\t\tconst params: LoginAuditQuery = {\n\t\t\t\t...auditFilters,\n\t\t\t\tpageNumber: auditPagination.current,\n\t\t\t\tpageSize: auditPagination.pageSize\n\t\t\t};\n\t\t\tconst { status, result } = await getLoginAuditListApi(params);\n\t\t\tif (status?.code !== 0) return;\n\t\t\tconst { list = [], pageNum = auditPagination.current, pageSize = auditPagination.pageSize, total = 0 } = result || {};\n\t\t\tsetAuditList((list as UserLoginAuditItem[]).map(item => ({ ...item, key: item.id })));\n\t\t\tsetAuditPagination(prev => ({ ...prev, current: Number(pageNum), pageSize: Number(pageSize), total: Number(total) }));\n\t\t} finally {\n\t\t\tsetAuditLoading(false);\n\t\t}\n\t}, [auditFilters, auditPagination.current, auditPagination.pageSize]);\n\n\tconst fetchShareRisk = useCallback(async () => {\n\t\tsetShareRiskLoading(true);\n\t\ttry {\n\t\t\tconst params = {\n\t\t\t\t...shareRiskFilters,\n\t\t\t\tpageNumber: shareRiskPagination.current,\n\t\t\t\tpageSize: shareRiskPagination.pageSize\n\t\t\t};\n\t\t\tconst { status, result } = await getUserShareRiskListApi(params);\n\t\t\tif (status?.code !== 0) return;\n\t\t\tconst {\n\t\t\t\tlist = [],\n\t\t\t\tpageNum = shareRiskPagination.current,\n\t\t\t\tpageSize = shareRiskPagination.pageSize,\n\t\t\t\ttotal = 0\n\t\t\t} = result || {};\n\t\t\tsetShareRiskList(\n\t\t\t\t(list as UserShareRiskItem[]).map((item, index) => ({\n\t\t\t\t\t...item,\n\t\t\t\t\tkey: `${item.userId || item.loginName || \"risk\"}-${index}`\n\t\t\t\t}))\n\t\t\t);\n\t\t\tsetShareRiskPagination(prev => ({ ...prev, current: Number(pageNum), pageSize: Number(pageSize), total: Number(total) }));\n\t\t} finally {\n\t\t\tsetShareRiskLoading(false);\n\t\t}\n\t}, [shareRiskFilters, shareRiskPagination.current, shareRiskPagination.pageSize]);\n\n\tuseEffect(() => {\n\t\tvoid fetchLoginAudit();\n\t}, [fetchLoginAudit]);\n\n\tuseEffect(() => {\n\t\tvoid fetchShareRisk();\n\t}, [fetchShareRisk]);\n\n\tconst shareRiskLevelMap: Record<string, { color: string; text: string }> = {\n\t\tHIGH: { color: \"error\", text: \"高风险\" },\n\t\tMEDIUM: { color: \"warning\", text: \"中风险\" },\n\t\tLOW: { color: \"processing\", text: \"低风险\" }\n\t};\n\n\tconst auditColumns: ColumnsType<UserLoginAuditItem> = [\n\t\t{\n\t\t\ttitle: \"时间\",\n\t\t\tdataIndex: \"createTime\",\n\t\t\tkey: \"createTime\",\n\t\t\twidth: 150,\n\t\t\trender: value => <div className=\"login-audit__multiline\">{formatDateTime(value)}</div>\n\t\t},\n\t\t{\n\t\t\ttitle: \"星球编号\",\n\t\t\tdataIndex: \"starNumber\",\n\t\t\tkey: \"starNumber\",\n\t\t\twidth: 120,\n\t\t\trender: (_, item) => (\n\t\t\t\t<div>\n\t\t\t\t\t<div className=\"login-audit__multiline\">{item.starNumber || \"-\"}</div>\n\t\t\t\t\t<div className=\"login-audit__subtext\">登录名: {item.loginName || \"-\"}</div>\n\t\t\t\t</div>\n\t\t\t)\n\t\t},\n\t\t{\n\t\t\ttitle: \"事件\",\n\t\t\tdataIndex: \"eventTypeDesc\",\n\t\t\tkey: \"eventTypeDesc\",\n\t\t\twidth: 80,\n\t\t\trender: (_, item) => {\n\t\t\t\tconst colorMap: Record<string, string> = {\n\t\t\t\t\tLOGIN_SUCCESS: \"success\",\n\t\t\t\t\tLOGIN_FAIL: \"error\",\n\t\t\t\t\tLOGOUT: \"default\",\n\t\t\t\t\tSESSION_KICKOUT: \"warning\",\n\t\t\t\t\tACCOUNT_FORBID: \"error\",\n\t\t\t\t\tACCOUNT_UNFORBID: \"success\"\n\t\t\t\t};\n\t\t\t\treturn <Tag color={colorMap[item.eventType || \"\"] || \"processing\"}>{item.eventTypeDesc || item.eventType || \"-\"}</Tag>;\n\t\t\t}\n\t\t},\n\t\t{\n\t\t\ttitle: \"设备\",\n\t\t\tdataIndex: \"deviceName\",\n\t\t\tkey: \"deviceName\",\n\t\t\twidth: 180,\n\t\t\trender: (_, item) => (\n\t\t\t\t<div>\n\t\t\t\t\t<div>{item.deviceName || \"-\"}</div>\n\t\t\t\t\t<div className=\"login-audit__subtext\">{item.deviceId || \"-\"}</div>\n\t\t\t\t</div>\n\t\t\t)\n\t\t},\n\t\t{\n\t\t\ttitle: \"IP / 地区\",\n\t\t\tdataIndex: \"ip\",\n\t\t\tkey: \"ip\",\n\t\t\twidth: 120,\n\t\t\trender: (_, item) => (\n\t\t\t\t<div>\n\t\t\t\t\t<div className=\"login-audit__multiline\">{item.ip || \"-\"}</div>\n\t\t\t\t\t<div className=\"login-audit__subtext\">{item.region || \"-\"}</div>\n\t\t\t\t</div>\n\t\t\t)\n\t\t},\n\t\t{\n\t\t\ttitle: \"风险标记\",\n\t\t\tdataIndex: \"riskTag\",\n\t\t\tkey: \"riskTag\",\n\t\t\twidth: 150,\n\t\t\trender: value =>\n\t\t\t\tvalue ? (\n\t\t\t\t\t<Tag className=\"login-audit__tag\" color=\"processing\">\n\t\t\t\t\t\t{getRiskTagText(value)}\n\t\t\t\t\t</Tag>\n\t\t\t\t) : (\n\t\t\t\t\t\"-\"\n\t\t\t\t)\n\t\t},\n\t\t{\n\t\t\ttitle: \"原因\",\n\t\t\twidth: 150,\n\t\t\tdataIndex: \"reason\",\n\t\t\tkey: \"reason\",\n\t\t\trender: value => <div className=\"login-audit__multiline\">{getReasonText(value)}</div>\n\t\t}\n\t];\n\n\tconst shareRiskColumns: ColumnsType<UserShareRiskItem> = [\n\t\t{\n\t\t\ttitle: \"星球编号\",\n\t\t\tdataIndex: \"starNumber\",\n\t\t\tkey: \"starNumber\",\n\t\t\twidth: 140,\n\t\t\trender: (_, item) => (\n\t\t\t\t<div>\n\t\t\t\t\t<div className=\"login-audit__multiline\">{item.starNumber || \"-\"}</div>\n\t\t\t\t\t<div className=\"login-audit__subtext\">登录名: {item.loginName || \"-\"}</div>\n\t\t\t\t</div>\n\t\t\t)\n\t\t},\n\t\t{\n\t\t\ttitle: \"被踢次数\",\n\t\t\tdataIndex: \"kickoutCount\",\n\t\t\tkey: \"kickoutCount\",\n\t\t\twidth: 100,\n\t\t\trender: value => value || 0\n\t\t},\n\t\t{\n\t\t\ttitle: \"设备数\",\n\t\t\tdataIndex: \"deviceCount\",\n\t\t\tkey: \"deviceCount\",\n\t\t\twidth: 90,\n\t\t\trender: value => value || 0\n\t\t},\n\t\t{\n\t\t\ttitle: \"IP数\",\n\t\t\tdataIndex: \"ipCount\",\n\t\t\tkey: \"ipCount\",\n\t\t\twidth: 90,\n\t\t\trender: value => value || 0\n\t\t},\n\t\t{\n\t\t\ttitle: \"最后异常 / 禁用截止\",\n\t\t\tdataIndex: \"lastKickoutTime\",\n\t\t\tkey: \"lastKickoutTime\",\n\t\t\twidth: 180,\n\t\t\trender: (_, item) => (\n\t\t\t\t<div>\n\t\t\t\t\t<div className=\"login-audit__multiline\">{formatDateTime(item.lastKickoutTime)}</div>\n\t\t\t\t\t{item.forbidden && item.forbidUntil ? (\n\t\t\t\t\t\t<div className=\"login-audit__subtext\">禁用至: {formatDateTime(item.forbidUntil)}</div>\n\t\t\t\t\t) : null}\n\t\t\t\t</div>\n\t\t\t)\n\t\t},\n\t\t{\n\t\t\ttitle: \"风险等级\",\n\t\t\tdataIndex: \"riskLevel\",\n\t\t\tkey: \"riskLevel\",\n\t\t\twidth: 100,\n\t\t\trender: value => {\n\t\t\t\tconst level = shareRiskLevelMap[value || \"LOW\"] || shareRiskLevelMap.LOW;\n\t\t\t\treturn <Tag color={level.color}>{level.text}</Tag>;\n\t\t\t}\n\t\t},\n\t\t{\n\t\t\ttitle: \"处理状态\",\n\t\t\tkey: \"forbidden\",\n\t\t\twidth: 80,\n\t\t\trender: (_, item) => (item.forbidden ? <Tag color=\"error\">已禁用</Tag> : <Tag color=\"success\">正常</Tag>)\n\t\t},\n\t\t{\n\t\t\ttitle: \"判断依据\",\n\t\t\tdataIndex: \"riskReason\",\n\t\t\tkey: \"riskReason\",\n\t\t\trender: value => <div className=\"login-audit__multiline\">{value || \"-\"}</div>\n\t\t},\n\t\t{\n\t\t\ttitle: \"操作\",\n\t\t\tkey: \"action\",\n\t\t\twidth: 200,\n\t\t\trender: (_, item) => (\n\t\t\t\t<Space wrap size={0}>\n\t\t\t\t\t<Button type=\"link\" onClick={() => handleInspectRisk(item)}>\n\t\t\t\t\t\t查看明细\n\t\t\t\t\t</Button>\n\t\t\t\t\t{item.forbidden ? (\n\t\t\t\t\t\t<Button type=\"link\" onClick={() => handleUnforbid(item)} loading={actionUserId === item.userId}>\n\t\t\t\t\t\t\t解除禁用\n\t\t\t\t\t\t</Button>\n\t\t\t\t\t) : (\n\t\t\t\t\t\t<Button danger type=\"link\" onClick={() => handleForbid(item)} loading={actionUserId === item.userId}>\n\t\t\t\t\t\t\t禁用30天\n\t\t\t\t\t\t</Button>\n\t\t\t\t\t)}\n\t\t\t\t</Space>\n\t\t\t)\n\t\t}\n\t];\n\n\tconst handleAuditSearch = async () => {\n\t\tconst values = await auditFormRef.validateFields();\n\t\tsetAuditPagination(prev => ({ ...prev, current: 1 }));\n\t\tsetAuditFilters({ ...defaultLoginAuditFilter, ...values });\n\t};\n\n\tconst handleAuditReset = () => {\n\t\tauditFormRef.resetFields();\n\t\tsetAuditPagination(prev => ({ ...prev, current: 1 }));\n\t\tsetAuditFilters(defaultLoginAuditFilter);\n\t};\n\n\tconst handleShareRiskSearch = async () => {\n\t\tconst values = await shareRiskFormRef.validateFields();\n\t\tsetShareRiskPagination(prev => ({ ...prev, current: 1 }));\n\t\tsetShareRiskFilters({ ...defaultShareRiskFilter, ...values });\n\t};\n\n\tconst handleShareRiskReset = () => {\n\t\tshareRiskFormRef.resetFields();\n\t\tsetShareRiskPagination(prev => ({ ...prev, current: 1 }));\n\t\tsetShareRiskFilters(defaultShareRiskFilter);\n\t};\n\n\tconst handleInspectRisk = (item: UserShareRiskItem) => {\n\t\tconst starNumber = item.starNumber || \"\";\n\t\tif (!starNumber) {\n\t\t\tmessage.warning(\"当前记录缺少星球编号，无法联动查询\");\n\t\t\treturn;\n\t\t}\n\t\tconst auditValues = {\n\t\t\tstarNumber,\n\t\t\tdeviceId: \"\",\n\t\t\tip: \"\",\n\t\t\teventType: undefined\n\t\t};\n\t\tauditFormRef.setFieldsValue(auditValues);\n\t\tsetAuditPagination(prev => ({ ...prev, current: 1 }));\n\t\tsetAuditFilters(auditValues);\n\t\tmessage.success(`已切换到 ${starNumber || \"当前账号\"} 的登录审计明细`);\n\t\trequestAnimationFrame(() => {\n\t\t\tauditSectionRef.current?.scrollIntoView({ behavior: \"smooth\", block: \"start\" });\n\t\t});\n\t};\n\n\tconst handleForbid = (item: UserShareRiskItem) => {\n\t\tif (!item.userId) {\n\t\t\tmessage.error(\"缺少用户ID，无法禁用\");\n\t\t\treturn;\n\t\t}\n\t\tconst userId = item.userId;\n\t\tModal.confirm({\n\t\t\ttitle: `确认禁用账号 ${item.loginName || item.userId} 30 天？`,\n\t\t\tcontent: \"系统会立即踢下当前会话，并继续保留登录审计记录。\",\n\t\t\tokText: \"确认禁用\",\n\t\t\tokButtonProps: { danger: true },\n\t\t\tcancelText: \"取消\",\n\t\t\tonOk: async () => {\n\t\t\t\tsetActionUserId(userId);\n\t\t\t\ttry {\n\t\t\t\t\tawait forbidUserApi({\n\t\t\t\t\t\tuserId,\n\t\t\t\t\t\tdays: 30,\n\t\t\t\t\t\treason: \"疑似共享账号，管理员禁用30天\"\n\t\t\t\t\t});\n\t\t\t\t\tmessage.success(\"账号已禁用 30 天\");\n\t\t\t\t\tawait Promise.all([fetchShareRisk(), fetchLoginAudit()]);\n\t\t\t\t} catch (error) {\n\t\t\t\t\tconsole.error(error);\n\t\t\t\t} finally {\n\t\t\t\t\tsetActionUserId(undefined);\n\t\t\t\t}\n\t\t\t}\n\t\t});\n\t};\n\n\tconst handleUnforbid = (item: UserShareRiskItem) => {\n\t\tif (!item.userId) {\n\t\t\tmessage.error(\"缺少用户ID，无法解除禁用\");\n\t\t\treturn;\n\t\t}\n\t\tconst userId = item.userId;\n\t\tModal.confirm({\n\t\t\ttitle: `确认解除账号 ${item.loginName || item.userId} 的禁用状态？`,\n\t\t\tokText: \"确认解除\",\n\t\t\tcancelText: \"取消\",\n\t\t\tonOk: async () => {\n\t\t\t\tsetActionUserId(userId);\n\t\t\t\ttry {\n\t\t\t\t\tawait unforbidUserApi({ userId });\n\t\t\t\t\tmessage.success(\"已解除禁用\");\n\t\t\t\t\tawait Promise.all([fetchShareRisk(), fetchLoginAudit()]);\n\t\t\t\t} catch (error) {\n\t\t\t\t\tconsole.error(error);\n\t\t\t\t} finally {\n\t\t\t\t\tsetActionUserId(undefined);\n\t\t\t\t}\n\t\t\t}\n\t\t});\n\t};\n\n\treturn (\n\t\t<div className=\"login-audit\">\n\t\t\t<ContentWrap>\n\t\t\t\t<Row gutter={[16, 16]} className=\"login-audit__meta\">\n\t\t\t\t\t<Col xs={24} md={6}>\n\t\t\t\t\t\t<Alert\n\t\t\t\t\t\t\tshowIcon\n\t\t\t\t\t\t\ttype={shareRiskList.length ? \"warning\" : \"success\"}\n\t\t\t\t\t\t\tmessage={`当前命中疑似共享账号 ${shareRiskPagination.total || 0} 个`}\n\t\t\t\t\t\t/>\n\t\t\t\t\t</Col>\n\t\t\t\t\t<Col xs={24} md={6}>\n\t\t\t\t\t\t<Alert\n\t\t\t\t\t\t\tshowIcon\n\t\t\t\t\t\t\ttype={highRiskAccountCount ? \"error\" : \"info\"}\n\t\t\t\t\t\t\tmessage={`当前页高风险账号 ${highRiskAccountCount} 个`}\n\t\t\t\t\t\t/>\n\t\t\t\t\t</Col>\n\t\t\t\t\t<Col xs={24} md={6}>\n\t\t\t\t\t\t<Alert showIcon type=\"info\" message={`当前页登录成功 ${auditSummary.loginSuccess} 次`} />\n\t\t\t\t\t</Col>\n\t\t\t\t\t<Col xs={24} md={6}>\n\t\t\t\t\t\t<Alert\n\t\t\t\t\t\t\tshowIcon\n\t\t\t\t\t\t\ttype={auditSummary.loginFail ? \"warning\" : \"success\"}\n\t\t\t\t\t\t\tmessage={`当前页登录失败 ${auditSummary.loginFail} 次`}\n\t\t\t\t\t\t/>\n\t\t\t\t\t</Col>\n\t\t\t\t</Row>\n\n\t\t\t\t<Card className=\"login-audit__card\" title=\"疑似共享账号\">\n\t\t\t\t\t<Form form={shareRiskFormRef} layout=\"vertical\" initialValues={defaultShareRiskFilter} className=\"login-audit__filter\">\n\t\t\t\t\t\t<Row gutter={16}>\n\t\t\t\t\t\t\t<Col xs={24} md={6}>\n\t\t\t\t\t\t\t\t<Form.Item label=\"星球编号\" name=\"starNumber\">\n\t\t\t\t\t\t\t\t\t<Input allowClear placeholder=\"输入星球编号查找\" />\n\t\t\t\t\t\t\t\t</Form.Item>\n\t\t\t\t\t\t\t</Col>\n\t\t\t\t\t\t\t<Col xs={24} md={4}>\n\t\t\t\t\t\t\t\t<Form.Item label=\"统计周期\" name=\"recentDays\">\n\t\t\t\t\t\t\t\t\t<Select\n\t\t\t\t\t\t\t\t\t\toptions={[\n\t\t\t\t\t\t\t\t\t\t\t{ value: 3, label: \"近3天\" },\n\t\t\t\t\t\t\t\t\t\t\t{ value: 7, label: \"近7天\" },\n\t\t\t\t\t\t\t\t\t\t\t{ value: 15, label: \"近15天\" },\n\t\t\t\t\t\t\t\t\t\t\t{ value: 30, label: \"近30天\" }\n\t\t\t\t\t\t\t\t\t\t]}\n\t\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t</Form.Item>\n\t\t\t\t\t\t\t</Col>\n\t\t\t\t\t\t\t<Col xs={24} md={4}>\n\t\t\t\t\t\t\t\t<Form.Item label=\"最少被踢次数\" name=\"minKickoutCount\">\n\t\t\t\t\t\t\t\t\t<InputNumber min={1} max={99} style={{ width: \"100%\" }} />\n\t\t\t\t\t\t\t\t</Form.Item>\n\t\t\t\t\t\t\t</Col>\n\t\t\t\t\t\t\t<Col xs={24} md={4}>\n\t\t\t\t\t\t\t\t<Form.Item label=\"最少设备数\" name=\"minDeviceCount\">\n\t\t\t\t\t\t\t\t\t<InputNumber min={1} max={99} style={{ width: \"100%\" }} />\n\t\t\t\t\t\t\t\t</Form.Item>\n\t\t\t\t\t\t\t</Col>\n\t\t\t\t\t\t\t<Col xs={24} md={4}>\n\t\t\t\t\t\t\t\t<Form.Item label=\"最少IP数\" name=\"minIpCount\">\n\t\t\t\t\t\t\t\t\t<InputNumber min={1} max={99} style={{ width: \"100%\" }} />\n\t\t\t\t\t\t\t\t</Form.Item>\n\t\t\t\t\t\t\t</Col>\n\t\t\t\t\t\t</Row>\n\t\t\t\t\t\t<Space wrap>\n\t\t\t\t\t\t\t<Button type=\"primary\" icon={<SearchOutlined />} loading={shareRiskLoading} onClick={handleShareRiskSearch}>\n\t\t\t\t\t\t\t\t查询疑似账号\n\t\t\t\t\t\t\t</Button>\n\t\t\t\t\t\t\t<Button onClick={handleShareRiskReset}>重置</Button>\n\t\t\t\t\t\t</Space>\n\t\t\t\t\t</Form>\n\n\t\t\t\t\t<Table\n\t\t\t\t\t\trowKey={item => `${item.userId || item.loginName || \"risk\"}`}\n\t\t\t\t\t\tsize=\"small\"\n\t\t\t\t\t\tcolumns={shareRiskColumns}\n\t\t\t\t\t\tdataSource={shareRiskList}\n\t\t\t\t\t\tpagination={shareRiskPaginationInfo}\n\t\t\t\t\t\tloading={shareRiskLoading}\n\t\t\t\t\t\tscroll={{ x: 1100 }}\n\t\t\t\t\t/>\n\t\t\t\t</Card>\n\n\t\t\t\t<div ref={auditSectionRef}>\n\t\t\t\t\t<Card className=\"login-audit__card\" title=\"登录轨迹\">\n\t\t\t\t\t\t<Form form={auditFormRef} layout=\"vertical\" initialValues={defaultLoginAuditFilter} className=\"login-audit__filter\">\n\t\t\t\t\t\t\t<Row gutter={16}>\n\t\t\t\t\t\t\t\t<Col xs={24} md={6}>\n\t\t\t\t\t\t\t\t\t<Form.Item label=\"星球编号\" name=\"starNumber\">\n\t\t\t\t\t\t\t\t\t\t<Input allowClear placeholder=\"输入星球编号查找\" />\n\t\t\t\t\t\t\t\t\t</Form.Item>\n\t\t\t\t\t\t\t\t</Col>\n\t\t\t\t\t\t\t\t<Col xs={24} md={6}>\n\t\t\t\t\t\t\t\t\t<Form.Item label=\"设备 ID\" name=\"deviceId\">\n\t\t\t\t\t\t\t\t\t\t<Input allowClear placeholder=\"f-device\" />\n\t\t\t\t\t\t\t\t\t</Form.Item>\n\t\t\t\t\t\t\t\t</Col>\n\t\t\t\t\t\t\t\t<Col xs={24} md={6}>\n\t\t\t\t\t\t\t\t\t<Form.Item label=\"IP\" name=\"ip\">\n\t\t\t\t\t\t\t\t\t\t<Input allowClear placeholder=\"127.0.0.1\" />\n\t\t\t\t\t\t\t\t\t</Form.Item>\n\t\t\t\t\t\t\t\t</Col>\n\t\t\t\t\t\t\t\t<Col xs={24} md={6}>\n\t\t\t\t\t\t\t\t\t<Form.Item label=\"事件类型\" name=\"eventType\">\n\t\t\t\t\t\t\t\t\t\t<Select allowClear placeholder=\"全部事件\" options={loginAuditEventOptions} />\n\t\t\t\t\t\t\t\t\t</Form.Item>\n\t\t\t\t\t\t\t\t</Col>\n\t\t\t\t\t\t\t</Row>\n\t\t\t\t\t\t\t<Space wrap>\n\t\t\t\t\t\t\t\t<Button type=\"primary\" icon={<SearchOutlined />} loading={auditLoading} onClick={handleAuditSearch}>\n\t\t\t\t\t\t\t\t\t查询轨迹\n\t\t\t\t\t\t\t\t</Button>\n\t\t\t\t\t\t\t\t<Button onClick={handleAuditReset}>重置</Button>\n\t\t\t\t\t\t\t</Space>\n\t\t\t\t\t\t</Form>\n\n\t\t\t\t\t\t<Table\n\t\t\t\t\t\t\trowKey=\"id\"\n\t\t\t\t\t\t\tsize=\"small\"\n\t\t\t\t\t\t\tcolumns={auditColumns}\n\t\t\t\t\t\t\tdataSource={auditList}\n\t\t\t\t\t\t\tpagination={auditPaginationInfo}\n\t\t\t\t\t\t\tloading={auditLoading}\n\t\t\t\t\t\t\tscroll={{ x: 1200 }}\n\t\t\t\t\t\t/>\n\t\t\t\t\t</Card>\n\t\t\t\t</div>\n\t\t\t</ContentWrap>\n\t\t</div>\n\t);\n};\n\nexport default LoginAuditPage;\n"
  },
  {
    "path": "src/views/author/whitelist/index.scss",
    "content": ".author-whitelist-search {\n  margin-bottom: 16px;\n\n  &__wrap {\n    display: flex;\n    justify-content: space-between;\n    padding-left: 48px;\n  }\n\n  &__search {\n    flex: 1;\n    display: flex;\n\t\tjustify-content: space-between;\n\t\t&-wrap {\n\t\t\tdisplay: flex;\n\t\t}\n  }\n\n  &__search-item {\n    margin-right: 48px;\n  }\n\n  &-label {\n    margin-right: 12px;\n  }\n}\n"
  },
  {
    "path": "src/views/author/whitelist/index.tsx",
    "content": "/* eslint-disable prettier/prettier */\nimport { FC, useCallback, useEffect, useRef, useState } from \"react\";\nimport Highlighter from 'react-highlight-words';\nimport { connect } from \"react-redux\";\nimport { DeleteOutlined, PlusOutlined, SearchOutlined } from \"@ant-design/icons\";\nimport { Avatar, Button, Drawer, Form, Input, InputRef, message, Modal, Space, Table } from \"antd\";\nimport type { ColumnsType,ColumnType } from \"antd/es/table\";\nimport { FilterConfirmProps } from \"antd/es/table/interface\";\n\nimport { delAuthorWhiteApi, getAuthorWhiteListApi, updateAuthorWhiteApi } from \"@/api/modules/author\";\nimport { ContentInterWrap, ContentWrap } from \"@/components/common-wrap\";\nimport { MapItem } from \"@/typings/common\";\nimport AuthorSelect from \"@/views/column/setting/components/authorselect\";\n\nimport \"./index.scss\";\n\ninterface DataType {\n\tkey: string;\n\tuserId: number;\n\tphoto: string;\n\tuserName: string;\n\tprofile: string;\n}\n\ninterface IProps {}\n\nexport interface IFormType {\n\tauthorId: number;\n\tauthorName: string;\n}\n\nconst defaultInitForm: IFormType = {\n\tauthorId: -1,\n\tauthorName: \"\",\n};\n\nconst AuthorWhiteList: FC<IProps> = props => {\n\tconst [formRef] = Form.useForm();\n\t// form值\n\tconst [form, setForm] = useState<IFormType>(defaultInitForm);\n\t// 抽屉\n\tconst [isDrawerOpen, setIsDrawerOpen] = useState<boolean>(false);\n\t// 列表数据\n\tconst [tableData, setTableData] = useState<DataType[]>([]);\n\t// 刷新函数\n\tconst [query, setQuery] = useState<number>(0);\n\n\tconst { authorName, authorId } = form;\n\n\tconst [searchText, setSearchText] = useState('');\n  const [searchedColumn, setSearchedColumn] = useState('');\n  const searchInput = useRef<InputRef>(null);\n\n\tconst onSure = useCallback(() => {\n\t\tsetQuery(prev => prev + 1);\n\t}, []);\n\n\t// 值改变\n\tconst handleChange = (item: MapItem) => {\n\t\tsetForm({ ...form, ...item });\n\t\tconsole.log(\"handleChange item setForm\", item, form);\n\t\tformRef.setFieldsValue({ ...item });\n\t};\n\t\n\t// 抽屉关闭\n\tconst handleClose = () => {\n\t\tsetIsDrawerOpen(false);\n\t};\n\n\t// 重置表单\n\tconst resetFrom = () => {\n\t\tsetForm(defaultInitForm);\n\t};\n\t// 新增触发\n\tconst handleAdd = () => {\n\t\tresetFrom();\n\t\tsetIsDrawerOpen(true);\n\t};\n\n\t// 当点击查询按钮的时候触发\n\n\ttype DataIndex = keyof DataType;\n\n\tconst handleSearch = (\n    selectedKeys: string[],\n    confirm: (param?: FilterConfirmProps) => void,\n    dataIndex: DataIndex,\n  ) => {\n    confirm();\n    setSearchText(selectedKeys[0]);\n    setSearchedColumn(dataIndex);\n  };\n\n\tconst handleReset = (clearFilters: () => void) => {\n    clearFilters();\n    setSearchText('');\n  };\n\n\t// 删除\n\tconst handleDel = (userId: number) => {\n\t\tModal.warning({\n\t\t\ttitle: \"确认要从白名单中删除该作者吗\",\n\t\t\tcontent: \"请谨慎操作！\",\n\t\t\tmaskClosable: true,\n\t\t\tclosable: true,\n\t\t\tonOk: async () => {\n\t\t\t\tconst { status } = await delAuthorWhiteApi(userId);\n\t\t\t\tconst { code, msg } = status || {};\n\t\n\t\t\t\tif (code === 0) {\n\t\t\t\t\tmessage.success(\"删除成功\");\n\t\t\t\t\tonSure();\n\t\t\t\t} else {\n\t\t\t\t\tmessage.error(msg);\n\t\t\t\t}\n\t\t\t}\n\t\t});\n\t};\n\n\tconst handleSubmit = async () => {\n\t\tconst values = await formRef.validateFields();\n\t\tconsole.log(\"handleSubmit newValues\", values.author);\n\n\t\tconst { status: successStatus } = (await updateAuthorWhiteApi(values.author)) || {};\n\t\tconst { code, msg } = successStatus || {};\n\t\tif (code === 0) {\n\t\t\tsetIsDrawerOpen(false);\n\t\t\tonSure();\n\t\t} else {\n\t\t\tmessage.error(msg);\n\t\t}\n\t\t\n\t};\n\n\t// 数据请求\n\tuseEffect(() => {\n\t\tconst getSortList = async () => {\n\t\t\tconst { status, result } = await getAuthorWhiteListApi();\n\t\t\tconst { code } = status || {};\n\n\t\t\tif (code === 0) {\n\t\t\t\t// @ts-ignore\n\t\t\t\tconst newList = result.map((item: MapItem) => ({ ...item, key: item?.userId }));\n\t\t\t\tsetTableData(newList);\n\t\t\t}\n\t\t};\n\n\t\tgetSortList();\n\t}, [query]);\n\n\tconst getColumnSearchProps = (dataIndex: DataIndex): ColumnType<DataType> => ({\n    filterDropdown: ({ setSelectedKeys, selectedKeys, confirm, clearFilters, close }) => (\n      <div style={{ padding: 8 }} onKeyDown={(e) => e.stopPropagation()}>\n        <Input\n          ref={searchInput}\n          placeholder={`Search ${dataIndex}`}\n          value={selectedKeys[0]}\n          onChange={(e) => setSelectedKeys(e.target.value ? [e.target.value] : [])}\n          onPressEnter={() => handleSearch(selectedKeys as string[], confirm, dataIndex)}\n          style={{ marginBottom: 8, display: 'block' }}\n        />\n        <Space>\n          <Button\n            type=\"primary\"\n            onClick={() => handleSearch(selectedKeys as string[], confirm, dataIndex)}\n            icon={<SearchOutlined />}\n            size=\"small\"\n            style={{ width: 90 }}\n          >\n            查询\n          </Button>\n          <Button\n            onClick={() => clearFilters && handleReset(clearFilters)}\n            size=\"small\"\n            style={{ width: 90 }}\n          >\n            重置\n          </Button>\n          <Button\n            type=\"link\"\n            size=\"small\"\n            onClick={() => {\n              confirm({ closeDropdown: false });\n              setSearchText((selectedKeys as string[])[0]);\n              setSearchedColumn(dataIndex);\n            }}\n          >\n            过滤\n          </Button>\n          <Button\n            type=\"link\"\n            size=\"small\"\n            onClick={() => {\n              close();\n            }}\n          >\n            关闭\n          </Button>\n        </Space>\n      </div>\n    ),\n    filterIcon: (filtered: boolean) => (\n      <SearchOutlined style={{ color: filtered ? '#1677ff' : undefined }} />\n    ),\n    onFilter: (value, record) =>\n      record[dataIndex]\n        .toString()\n        .toLowerCase()\n        .includes((value as string).toLowerCase()),\n    onFilterDropdownOpenChange: (visible) => {\n      if (visible) {\n        setTimeout(() => searchInput.current?.select(), 100);\n      }\n    },\n    render: (text) =>\n      searchedColumn === dataIndex ? (\n        <Highlighter\n          highlightStyle={{ backgroundColor: '#ffc069', padding: 0 }}\n          searchWords={[searchText]}\n          autoEscape\n          textToHighlight={text ? text.toString() : ''}\n        />\n      ) : (\n        text\n      ),\n  });\n\n\t// 表头设置\n\tconst columns: ColumnsType<DataType> = [\n\t\t{\n\t\t\ttitle: \"作者名称\",\n\t\t\tdataIndex: \"userName\",\n\t\t\tkey: \"userName\",\n\t\t\t...getColumnSearchProps('userName'),\n\t\t},\n\t\t{\n\t\t\ttitle: \"作者头像\",\n\t\t\tdataIndex: \"photo\",\n\t\t\tkey: \"photo\",\n\t\t\trender(value) {\n\t\t\t\treturn (\n\t\t\t\t\t<>\n\t\t\t\t\t\t<Avatar src={value} />\n\t\t\t\t\t</>\n\t\t\t\t);\n\t\t\t}\n\t\t},\n\t\t{\n\t\t\ttitle: \"操作\",\n\t\t\tkey: \"key\",\n\t\t\twidth: 210,\n\t\t\trender: (_, item) => {\n\t\t\t\tconst { userId } = item;\n\t\t\t\tconsole.log(\"userId\", userId);\n\t\t\t\treturn (\n\t\t\t\t\t<div className=\"operation-btn\">\n\t\t\t\t\t\t<Button type=\"primary\" danger icon={<DeleteOutlined />} onClick={() => handleDel(userId)}>\n\t\t\t\t\t\t\t删除\n\t\t\t\t\t\t</Button>\n\t\t\t\t\t</div>\n\t\t\t\t);\n\t\t\t}\n\t\t}\n\t];\n\n\t// 编辑表单\n\tconst reviseDrawerContent = (\n\t\t<Form \n\t\t\tname=\"basic\" \n\t\t\tform={formRef} \n\t\t\tlabelCol={{ span: 4 }} \n\t\t\twrapperCol={{ span: 16 }} \n\t\t\tautoComplete=\"off\">\n\t\t\t<Form.Item label=\"作者\" name=\"author\" rules={[{ required: true, message: \"请选择作者!\" }]}>\n\t\t\t\t<AuthorSelect authorName={authorName} handleChange={handleChange} />\n\t\t\t</Form.Item>\n\t\t</Form>\n\t);\n\n\treturn (\n\t\t<div className=\"author-whitelist\">\n\t\t\t<ContentWrap>\n\t\t\t\t{/* 新增 */}\n\t\t\t\t<div className=\"author-whitelist-search\">\n\t\t\t\t\t<ContentInterWrap className=\"author-whitelist-search__wrap\">\n\t\t\t\t\t\t<div className=\"author-whitelist-search__search\">\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t</div>\n\t\t\t\t\t\t<div className=\"author-whitelist-search__search-btn\">\n\t\t\t\t\t\t\t<Button\n\t\t\t\t\t\t\ttype=\"primary\"\n\t\t\t\t\t\t\ticon={<PlusOutlined />}\n\t\t\t\t\t\t\tstyle={{ marginRight: \"20px\" }}\n\t\t\t\t\t\t\tonClick={handleAdd}\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t添加作者\n\t\t\t\t\t\t\t</Button>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t</ContentInterWrap>\n\t\t\t\t</div>\n\t\t\t\t\n\t\t\t\t{/* 表格 */}\n\t\t\t\t<ContentInterWrap>\n\t\t\t\t\t<Table columns={columns} dataSource={tableData} />\n\t\t\t\t</ContentInterWrap>\n\t\t\t</ContentWrap>\n\t\t\t{/* 抽屉 */}\n\t\t\t<Drawer \n\t\t\t\ttitle=\"添加\" \n\t\t\t\topen={isDrawerOpen} \n\t\t\t\tonClose={handleClose}\n\t\t\t\twidth={500}\n\t\t\t\textra={\n          <Space>\n            <Button onClick={handleClose}>取消</Button>\n            <Button type=\"primary\" onClick={handleSubmit}>\n              确定\n            </Button>\n          </Space>\n        }\n\t\t\t\t>\n\t\t\t\t{reviseDrawerContent}\n\t\t\t</Drawer>\n\t\t</div>\n\t);\n};\n\nconst mapStateToProps = (state: any) => state.disc.disc;\nconst mapDispatchToProps = {};\nexport default connect(mapStateToProps, mapDispatchToProps)(AuthorWhiteList);\n"
  },
  {
    "path": "src/views/author/zsxqlist/components/search/index.scss",
    "content": ".zsxq-white-list-search {\n\tmargin-bottom: 16px;\n\n\t&__wrap {\n\t\tdisplay: flex;\n\t\tjustify-content: space-between;\n\t\tpadding-left: 48px;\n\t\tpadding-right: 24px;\n\t\toverflow-x: hidden;\n\t}\n\n\t&__search {\n\t\tflex: 1;\n\t\tdisplay: flex;\n\t\tflex-wrap: wrap;\n\t\tgap: 12px 16px;\n\t\talign-items: center;\n\t\tmin-width: 0;\n\t\t&-wrap {\n\t\t\tdisplay: flex;\n\t\t}\n\t}\n\n\t&__search-item {\n\t\tflex-shrink: 0;\n\t\tmin-width: 0;\n\n\t\t.search-input {\n\t\t\twidth: 142px;\n\t\t}\n\n\t\t.search-select {\n\t\t\twidth: 100px;\n\t\t}\n\t}\n\n\t&__search-btn {\n\t\tdisplay: flex;\n\t\tgap: 12px;\n\t\talign-items: center;\n\t\tflex-shrink: 0;\n\t}\n\n\t&-label {\n\t\tmargin-right: 12px;\n\t}\n\n\t// 移动端适配\n\t@media (max-width: 1200px) {\n\t\t&__wrap {\n\t\t\tpadding-left: 24px;\n\t\t\tpadding-right: 16px;\n\t\t}\n\n\t\t&__search {\n\t\t\tgap: 10px 12px;\n\t\t}\n\n\t\t&__search-item {\n\t\t\t.search-input {\n\t\t\t\twidth: 130px;\n\t\t\t}\n\n\t\t\t.search-select {\n\t\t\t\twidth: 100px;\n\t\t\t}\n\t\t}\n\t}\n\n\t@media (max-width: 768px) {\n\t\t&__wrap {\n\t\t\tpadding-left: 16px;\n\t\t\tpadding-right: 12px;\n\t\t}\n\n\t\t&__search {\n\t\t\tgap: 8px 10px;\n\t\t}\n\n\t\t&__search-item {\n\t\t\tflex: 1 1 auto;\n\t\t\tmin-width: 0;\n\t\t\tmax-width: 100%;\n\n\t\t\t.search-input,\n\t\t\t.search-select,\n\t\t\t.ant-checkbox-wrapper {\n\t\t\t\twidth: 100% !important;\n\t\t\t\tmin-width: 0;\n\t\t\t}\n\t\t}\n\n\t\t&__search-btn {\n\t\t\tflex: 1 1 100%;\n\t\t\tjustify-content: flex-start;\n\t\t}\n\t}\n\n\t// 手机端适配 (≤480px)\n\t@media (max-width: 480px) {\n\t\tmargin-bottom: 12px;\n\n\t\t&__wrap {\n\t\t\tpadding: 12px 8px;\n\t\t}\n\n\t\t&__search {\n\t\t\tgap: 8px;\n\t\t}\n\n\t\t&__search-item {\n\t\t\tflex: 1 1 100%;\n\t\t\tmargin-right: 0;\n\t\t\tmax-width: 100%;\n\t\t\tmin-width: 0;\n\n\t\t\t.search-input,\n\t\t\t.search-select,\n\t\t\t.ant-checkbox-wrapper {\n\t\t\t\twidth: 100% !important;\n\t\t\t\tfont-size: 14px;\n\t\t\t\tbox-sizing: border-box;\n\t\t\t\tmin-width: 0;\n\t\t\t\tmax-width: 100%;\n\t\t\t}\n\n\t\t\t.ant-input {\n\t\t\t\tpadding: 6px 11px;\n\t\t\t}\n\n\t\t\t.ant-checkbox-wrapper {\n\t\t\t\tpadding: 6px 0;\n\t\t\t}\n\t\t}\n\n\t\t&__search-btn {\n\t\t\tflex: 1 1 100%;\n\t\t\tflex-direction: column;\n\t\t\tgap: 8px;\n\t\t\tmax-width: 100%;\n\n\t\t\tbutton {\n\t\t\t\twidth: 100%;\n\t\t\t\tmargin-right: 0 !important;\n\t\t\t}\n\n\t\t\t.ant-radio-group {\n\t\t\t\twidth: 100%;\n\t\t\t\tdisplay: flex;\n\n\t\t\t\t.ant-radio-button-wrapper {\n\t\t\t\t\tflex: 1;\n\t\t\t\t\ttext-align: center;\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n}\n"
  },
  {
    "path": "src/views/author/zsxqlist/components/search/index.tsx",
    "content": "/* eslint-disable prettier/prettier */\nimport React, { FC } from \"react\";\nimport { SearchOutlined } from \"@ant-design/icons\";\nimport { Button, Checkbox, Input, Radio, RadioChangeEvent, Select } from \"antd\";\n\nimport { ContentInterWrap } from \"@/components/common-wrap\";\n\nimport \"./index.scss\";\n\ninterface IProps {\n\thandleSearchChange: (e: object) => void;\n\thandleSearch: () => void;\n\tradioValue: number;\n\thandleBatchStatusChange: (e: RadioChangeEvent) => void;\n\tUserAIStatList: Array<{ label: string; value: number }>;\n}\n\nconst optionsRadioGroup = [\n\t{ label: \"通过\", value: 2 },\n\t{ label: \"拒绝\", value: 3 }\n];\n\nconst Search: FC<IProps> = ({ handleSearchChange, handleSearch, radioValue, handleBatchStatusChange, UserAIStatList }) => {\n\treturn (\n\t\t<div className=\"zsxq-white-list-search\">\n\t\t\t{/* 搜索 */}\n\t\t\t<ContentInterWrap className=\"zsxq-white-list-search__wrap\">\n\t\t\t\t<div className=\"zsxq-white-list-search__search\">\n\t\t\t\t\t<div className=\"zsxq-white-list-search__search-item\">\n\t\t\t\t\t\t{/* 增加一个作者的查询条件 */}\n\t\t\t\t\t\t<Input\n\t\t\t\t\t\t\tallowClear\n\t\t\t\t\t\t\tplaceholder=\"请输入星球编号\"\n\t\t\t\t\t\t\tclassName=\"search-input\"\n\t\t\t\t\t\t\tonChange={e => {\n\t\t\t\t\t\t\t\thandleSearchChange({ starNumber: e.target.value });\n\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t/>\n\t\t\t\t\t</div>\n\t\t\t\t\t<div className=\"zsxq-white-list-search__search-item\">\n\t\t\t\t\t\t<Input\n\t\t\t\t\t\t\tallowClear\n\t\t\t\t\t\t\tplaceholder=\"请输入登录用户名\"\n\t\t\t\t\t\t\tclassName=\"search-input\"\n\t\t\t\t\t\t\tonChange={e => handleSearchChange({ userCode: e.target.value })}\n\t\t\t\t\t\t/>\n\t\t\t\t\t</div>\n\t\t\t\t\t<div className=\"zsxq-white-list-search__search-item\">\n\t\t\t\t\t\t<Input\n\t\t\t\t\t\t\tallowClear\n\t\t\t\t\t\t\tplaceholder=\"请输入用户昵称\"\n\t\t\t\t\t\t\tclassName=\"search-input\"\n\t\t\t\t\t\t\tonChange={e => handleSearchChange({ name: e.target.value })}\n\t\t\t\t\t\t/>\n\t\t\t\t\t</div>\n\t\t\t\t\t<div className=\"zsxq-white-list-search__search-item\">\n\t\t\t\t\t\t<Select\n\t\t\t\t\t\t\t// 可以清空\n\t\t\t\t\t\t\tallowClear\n\t\t\t\t\t\t\t// 默认值\n\t\t\t\t\t\t\tplaceholder=\"选择状态\"\n\t\t\t\t\t\t\toptions={UserAIStatList}\n\t\t\t\t\t\t\tclassName=\"search-select\"\n\t\t\t\t\t\t\t// 触发搜索\n\t\t\t\t\t\t\tonChange={value => handleSearchChange({ state: Number(value || -1) })}\n\t\t\t\t\t\t></Select>\n\t\t\t\t\t</div>\n\t\t\t\t\t<div className=\"zsxq-white-list-search__search-item\">\n\t\t\t\t\t\t<Checkbox\n\t\t\t\t\t\t\tonChange={e => handleSearchChange({ starNumberNotEmpty: e.target.checked })}\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t星球会员\n\t\t\t\t\t\t</Checkbox>\n\t\t\t\t\t</div>\n\t\t\t\t\t<div className=\"zsxq-white-list-search__search-item\">\n\t\t\t\t\t\t<Checkbox\n\t\t\t\t\t\t\tonChange={e => handleSearchChange({ lastLoginWithinWeek: e.target.checked })}\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t一周内登录\n\t\t\t\t\t\t</Checkbox>\n\t\t\t\t\t</div>\n\t\t\t\t\t<div className=\"zsxq-white-list-search__search-btn\">\n\t\t\t\t\t\t<Button type=\"primary\" icon={<SearchOutlined />} style={{ marginRight: \"25px\" }} onClick={handleSearch}>\n\t\t\t\t\t\t\t搜索\n\t\t\t\t\t\t</Button>\n\t\t\t\t\t\t<Radio.Group\n\t\t\t\t\t\t\tvalue={radioValue}\n\t\t\t\t\t\t\tonChange={handleBatchStatusChange}\n\t\t\t\t\t\t\toptions={optionsRadioGroup}\n\t\t\t\t\t\t\toptionType=\"button\"\n\t\t\t\t\t\t\tbuttonStyle=\"solid\"\n\t\t\t\t\t\t/>\n\t\t\t\t\t</div>\n\t\t\t\t</div>\n\t\t\t</ContentInterWrap>\n\t\t</div>\n\t);\n};\nexport default Search;\n"
  },
  {
    "path": "src/views/author/zsxqlist/index.scss",
    "content": "// 移动端卡片列表(默认隐藏)\n.mobile-card-list {\n\tdisplay: none;\n\tmax-width: 100%;\n\toverflow-x: hidden;\n}\n\n// 桌面端表格(默认显示)\n.desktop-table {\n\tdisplay: block;\n\tmax-width: 100%;\n\toverflow-x: auto;\n}\n\n// 平板端适配\n@media (max-width: 768px) {\n\t// 隐藏桌面端表格\n\t.desktop-table {\n\t\tdisplay: none;\n\t}\n\n\t// 显示移动端卡片列表\n\t.mobile-card-list {\n\t\tdisplay: block;\n\t\tpadding: 0;\n\t\tmax-width: 100%;\n\t\toverflow-x: hidden;\n\t\tbox-sizing: border-box;\n\n\t\t.user-card {\n\t\t\tbackground: #fff;\n\t\t\tborder-radius: 8px;\n\t\t\tpadding: 16px;\n\t\t\tmargin-bottom: 12px;\n\t\t\tbox-shadow: 0 1px 4px rgba(0, 0, 0, 0.1);\n\t\t\tmax-width: 100%;\n\t\t\toverflow-x: hidden;\n\t\t\tbox-sizing: border-box;\n\n\t\t\t.card-header {\n\t\t\t\tdisplay: flex;\n\t\t\t\talign-items: center;\n\t\t\t\tgap: 12px;\n\t\t\t\tmargin-bottom: 12px;\n\t\t\t\tpadding-bottom: 12px;\n\t\t\t\tborder-bottom: 1px solid #f0f0f0;\n\t\t\t\tmax-width: 100%;\n\t\t\t\toverflow: hidden;\n\n\t\t\t\t.user-info {\n\t\t\t\t\tflex: 1;\n\t\t\t\t\tmin-width: 0;\n\n\t\t\t\t\t.user-name {\n\t\t\t\t\t\tfont-size: 16px;\n\t\t\t\t\t\tfont-weight: 500;\n\t\t\t\t\t\tcolor: #333;\n\t\t\t\t\t\tmargin-bottom: 4px;\n\t\t\t\t\t\toverflow: hidden;\n\t\t\t\t\t\ttext-overflow: ellipsis;\n\t\t\t\t\t\twhite-space: nowrap;\n\t\t\t\t\t}\n\n\t\t\t\t\t.user-code {\n\t\t\t\t\t\tfont-size: 13px;\n\t\t\t\t\t\tcolor: #1890ff;\n\t\t\t\t\t\ttext-decoration: none;\n\t\t\t\t\t\toverflow: hidden;\n\t\t\t\t\t\ttext-overflow: ellipsis;\n\t\t\t\t\t\twhite-space: nowrap;\n\t\t\t\t\t\tdisplay: block;\n\t\t\t\t\t}\n\t\t\t\t}\n\n\t\t\t\t.user-status {\n\t\t\t\t\tflex-shrink: 0;\n\n\t\t\t\t\t.ant-select {\n\t\t\t\t\t\tmin-width: 100px;\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\n\t\t\t.card-body {\n\t\t\t\tmax-width: 100%;\n\t\t\t\toverflow-x: hidden;\n\n\t\t\t\t.info-row {\n\t\t\t\t\tdisplay: flex;\n\t\t\t\t\talign-items: center;\n\t\t\t\t\tpadding: 8px 0;\n\t\t\t\t\tfont-size: 14px;\n\t\t\t\t\tmax-width: 100%;\n\t\t\t\t\toverflow: hidden;\n\n\t\t\t\t\t.label {\n\t\t\t\t\t\twidth: 90px;\n\t\t\t\t\t\tcolor: #666;\n\t\t\t\t\t\tflex-shrink: 0;\n\t\t\t\t\t}\n\n\t\t\t\t\t.value {\n\t\t\t\t\t\tflex: 1;\n\t\t\t\t\t\tcolor: #333;\n\t\t\t\t\t\tfont-weight: 500;\n\t\t\t\t\t\tmin-width: 0;\n\t\t\t\t\t\toverflow: hidden;\n\t\t\t\t\t\ttext-overflow: ellipsis;\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\n\t\t\t.card-footer {\n\t\t\t\tdisplay: flex;\n\t\t\t\tgap: 12px;\n\t\t\t\tmargin-top: 12px;\n\t\t\t\tpadding-top: 12px;\n\t\t\t\tborder-top: 1px solid #f0f0f0;\n\n\t\t\t\tbutton {\n\t\t\t\t\tflex: 1;\n\t\t\t\t\tmin-width: 0;\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\n\t\t.mobile-pagination {\n\t\t\tbackground: #fff;\n\t\t\tborder-radius: 8px;\n\t\t\tpadding: 16px;\n\t\t\tmargin-top: 12px;\n\t\t\tmax-width: 100%;\n\t\t\tbox-sizing: border-box;\n\n\t\t\t.pagination-info {\n\t\t\t\ttext-align: center;\n\t\t\t\tfont-size: 14px;\n\t\t\t\tcolor: #666;\n\t\t\t\tmargin-bottom: 12px;\n\t\t\t}\n\n\t\t\t.pagination-controls {\n\t\t\t\tdisplay: flex;\n\t\t\t\tjustify-content: center;\n\t\t\t\talign-items: center;\n\t\t\t\tgap: 12px;\n\t\t\t\tmax-width: 100%;\n\n\t\t\t\t.current-page {\n\t\t\t\t\tfont-size: 14px;\n\t\t\t\t\tcolor: #333;\n\t\t\t\t\tmin-width: 60px;\n\t\t\t\t\ttext-align: center;\n\t\t\t\t\twhite-space: nowrap;\n\t\t\t\t}\n\n\t\t\t\tbutton {\n\t\t\t\t\tflex-shrink: 0;\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n}\n\n// 手机端进一步优化\n@media (max-width: 480px) {\n\t.mobile-card-list {\n\t\t.user-card {\n\t\t\tpadding: 12px;\n\t\t\tmargin-bottom: 10px;\n\n\t\t\t.card-header {\n\t\t\t\t.ant-avatar {\n\t\t\t\t\twidth: 40px !important;\n\t\t\t\t\theight: 40px !important;\n\t\t\t\t}\n\n\t\t\t\t.user-info {\n\t\t\t\t\t.user-name {\n\t\t\t\t\t\tfont-size: 15px;\n\t\t\t\t\t}\n\n\t\t\t\t\t.user-code {\n\t\t\t\t\t\tfont-size: 12px;\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\n\t\t\t.card-body {\n\t\t\t\t.info-row {\n\t\t\t\t\tpadding: 6px 0;\n\t\t\t\t\tfont-size: 13px;\n\n\t\t\t\t\t.label {\n\t\t\t\t\t\twidth: 80px;\n\t\t\t\t\t}\n\n\t\t\t\t\t.ant-avatar {\n\t\t\t\t\t\twidth: 36px !important;\n\t\t\t\t\t\theight: 36px !important;\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\n\t\t.mobile-pagination {\n\t\t\tpadding: 12px;\n\n\t\t\t.pagination-info {\n\t\t\t\tfont-size: 13px;\n\t\t\t\tmargin-bottom: 10px;\n\t\t\t}\n\n\t\t\t.pagination-controls {\n\t\t\t\t.current-page {\n\t\t\t\t\tfont-size: 13px;\n\t\t\t\t\tmin-width: 50px;\n\t\t\t\t}\n\n\t\t\t\tbutton {\n\t\t\t\t\tfont-size: 12px;\n\t\t\t\t\tpadding: 4px 12px;\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\n\t// 弹窗适配\n\t.ant-modal {\n\t\tmax-width: 95% !important;\n\t\tmargin: 16px auto !important;\n\t\ttop: 20px !important;\n\n\t\t.ant-modal-content {\n\t\t\t.ant-modal-header {\n\t\t\t\tpadding: 12px 16px;\n\t\t\t}\n\n\t\t\t.ant-modal-body {\n\t\t\t\tpadding: 16px;\n\t\t\t}\n\n\t\t\t.ant-modal-footer {\n\t\t\t\tpadding: 10px 16px;\n\n\t\t\t\tbutton {\n\t\t\t\t\theight: 36px;\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\n\t\t.ant-form-item {\n\t\t\tmargin-bottom: 12px;\n\n\t\t\t.ant-form-item-label {\n\t\t\t\tpadding-bottom: 4px;\n\t\t\t}\n\n\t\t\t.ant-input,\n\t\t\t.ant-picker {\n\t\t\t\tfont-size: 14px;\n\t\t\t}\n\t\t}\n\t}\n}\n"
  },
  {
    "path": "src/views/author/zsxqlist/index.tsx",
    "content": "/* eslint-disable prettier/prettier */\nimport { FC, useCallback, useEffect, useState } from \"react\";\nimport { connect } from \"react-redux\";\nimport { DeleteOutlined, EditOutlined, UndoOutlined } from \"@ant-design/icons\";\nimport { Avatar, Badge, Button, DatePicker, Form, Input, message, Modal, RadioChangeEvent, Select, Table, Tag, Tooltip } from \"antd\";\nimport type { ColumnsType } from \"antd/es/table\";\nimport dayjs from \"dayjs\";\n\nimport { getZsxqWhiteListApi, operateBatchZsxqWhiteApi, operateZsxqWhiteApi, resetAuthorWhiteApi, updateZsxqWhiteApi } from \"@/api/modules/author\";\nimport { ContentInterWrap, ContentWrap } from \"@/components/common-wrap\";\nimport { initPagination, IPagination } from \"@/enums/common\";\nimport { MapItem } from \"@/typings/common\";\nimport { baseDomain } from \"@/utils/util\";\nimport Search from \"./components/search\";\n\nimport \"./index.scss\";\n\ninterface DataType {\n\tid: number;\n\tuserId: number;\n\tname: string;\n\tavatar: string;\n\tuserCode: string;\n\tstarNumber: string;\n\tinviteCode: string;\n\tinviteNum: number;\n\tstate: number;\n\texpireTime: string | null; // 后台返回的是Date类型,前端接收为字符串\n\tlastLoginTime: string | null; // 上次登录时间\n\tloginType: number; // 登录类型\n}\n\ninterface IProps {}\n\n// 查询表单接口，定义类型\ninterface ISearchForm {\n\tstarNumber: string;\n\tname: string;\n\tstate: number;\n\tuserCode: string;\n\tstarNumberNotEmpty?: boolean; // 星球编号不为空\n\tlastLoginWithinWeek?: boolean; // 最近一周有登录\n}\n\n// 编辑表单接口，定义类型\ninterface IInitForm {\n\tid: number;\n\tname: string;\n\tstarNumber: string;\n\tstate: number;\n\texpireTime: string | null; // 表单中也使用字符串格式\n\tuserCode: string;\n}\n\n// 编辑表单默认值\nconst defaultInitForm = {\n\tid: -1,\n\tname: \"\",\n\tstarNumber: \"\",\n\tstate: -1,\n\texpireTime: null,\n\tuserCode: \"\"\n};\n\n// 查询表单默认值\nconst defaultSearchForm = {\n\tstarNumber: \"\",\n\tname: \"\",\n\tuserCode: \"\",\n\tstate: -1,\n\tstarNumberNotEmpty: false,\n\tlastLoginWithinWeek: false\n};\n\nconst Zsxqlist: FC<IProps> = props => {\n\tconst [formRef] = Form.useForm();\n\tconst dateFormat = \"YYYY-MM-DD\";\n\t// 编辑表单\n\tconst [form, setForm] = useState<IInitForm>(defaultInitForm);\n\t// 查询表单\n\tconst [searchForm, setSearchForm] = useState<ISearchForm>(defaultSearchForm);\n\t// 弹窗\n\tconst [isModalOpen, setIsModalOpen] = useState<boolean>(false);\n\t// 列表数据\n\tconst [tableData, setTableData] = useState<DataType[]>([]);\n\t// 刷新函数\n\tconst [query, setQuery] = useState<number>(0);\n\n\tconst [selectedRowKeys, setSelectedRowKeys] = useState<React.Key[]>([]);\n\n\tconst [radioValue, setRadioValue] = useState(-1); // 默认值\n\n\t// 分页\n\tconst [pagination, setPagination] = useState<IPagination>(initPagination);\n\tconst { current, pageSize } = pagination;\n\n\tconst paginationInfo = {\n\t\tshowSizeChanger: true,\n\t\tshowTotal: (total: number) => `共 ${total || 0} 条`,\n\t\t...pagination,\n\t\tonChange: (current: number, pageSize: number) => {\n\t\t\tsetPagination({ current, pageSize });\n\t\t}\n\t};\n\n\t// 一些配置项，从字典里取出来\n\t//@ts-ignore\n\tconst { UserAIStatList, UserAiStrategy, UserAiStrategyList, LoginType, LoginTypeList } = props || {};\n\tconsole.log(\"UserAiStrategyList\", UserAiStrategyList, LoginTypeList);\n\n\tconst chinaOffsetMs = 8 * 60 * 60 * 1000;\n\tconst isDateOnly = (value: string) => /^\\d{4}-\\d{2}-\\d{2}$/.test(value);\n\tconst toChinaDateString = (value: string): string | null => {\n\t\tconst date = new Date(value);\n\t\tif (Number.isNaN(date.getTime())) return null;\n\t\tconst chinaDate = new Date(date.getTime() + chinaOffsetMs);\n\t\treturn chinaDate.toISOString().slice(0, 10);\n\t};\n\tconst normalizeExpireValue = (value: string | null) => {\n\t\tif (!value) return null;\n\t\tif (isDateOnly(value)) return dayjs(value);\n\t\tconst chinaDateString = toChinaDateString(value);\n\t\treturn chinaDateString ? dayjs(chinaDateString) : null;\n\t};\n\n\t// 格式化日期函数，避免时区导致的日期偏移\n\tconst formatDate = (dateString: string | null): string => {\n\t\tif (!dateString) return \"-\";\n\t\tif (typeof dateString === \"string\") {\n\t\t\tif (isDateOnly(dateString)) return dateString;\n\t\t\tif (dateString.includes(\"T\")) {\n\t\t\t\tconst chinaDateString = toChinaDateString(dateString);\n\t\t\t\tif (chinaDateString) return chinaDateString;\n\t\t\t}\n\t\t\tconst match = dateString.match(/^(\\d{4}-\\d{2}-\\d{2})/);\n\t\t\tif (match) return match[1];\n\t\t}\n\t\tconst parsed = dayjs(dateString);\n\t\treturn parsed.isValid() ? parsed.format(\"YYYY-MM-DD\") : \"-\";\n\t};\n\n\tconst colorStrategys = [\"#f50\", \"#2db7f5\", \"#87d068\", \"#108ee9\"];\n\tconst colorLoginTypes = [\"#1890ff\", \"#7265e6\"];\n\t//@ts-ignore\n\tconst colorStrategyMap = UserAiStrategyList.reduce((acc, strategy, index) => {\n    acc[strategy.value] = colorStrategys[index % colorStrategys.length];\n    return acc;\n\t}, {} as { [key: string]: string });\n\t//@ts-ignore\n\tconst colorLoginTypeMap = LoginTypeList.reduce((acc, loginType, index) => {\n    acc[loginType.value] = colorLoginTypes[index % colorLoginTypes.length];\n    return acc;\n\t}, {} as { [key: string]: string });\n\n\tconst { id } = form;\n\n\tconst onSelectChange = (newSelectedRowKeys: React.Key[]) => {\n\t\tconsole.log(\"selectedRowKeys changed: \", newSelectedRowKeys);\n\t\t// 如果新选中的数据和之前的数据不同，则设置单选按钮的选中状态\n\t\tif (newSelectedRowKeys.length !== selectedRowKeys.length) {\n\t\t\tsetRadioValue(-1);\n\t\t} else {\n\t\t\t// 长度相等的时候，判断是否相等\n\t\t\tconst isSame = newSelectedRowKeys.every((item, index) => item === selectedRowKeys[index]);\n\t\t\tif (isSame) {\n\t\t\t\t// 如果相等，则不处理\n\t\t\t} else {\n\t\t\t\tsetRadioValue(-1);\n\t\t\t}\n\t\t}\n\t\tsetSelectedRowKeys(newSelectedRowKeys);\n\t};\n\n\tconst rowSelection = {\n\t\tselectedRowKeys,\n\t\tonChange: onSelectChange\n\t};\n\n\tconst onSure = useCallback(() => {\n\t\tsetQuery(prev => prev + 1);\n\t}, []);\n\n\t// 编辑表单值改变\n\tconst handleChange = (item: MapItem) => {\n\t\tsetForm({ ...form, ...item });\n\t};\n\n\t// 查询表单值改变\n\tconst handleSearchChange = (item: MapItem) => {\n\t\t// 当 status 的值为 -1 时，重新显示\n\t\tsetSearchForm({ ...searchForm, ...item });\n\t\tconsole.log(\"查询条件变化了\", searchForm);\n\t};\n\n\t// 当点击查询按钮的时候触发\n\tconst handleSearch = () => {\n\t\t// 目前是根据文章标题搜索，后面需要加上其他条件\n\t\tconsole.log(\"查询条件\", searchForm);\n\t\t// 查询的时候重置分页\n\t\tsetPagination({ current: 1, pageSize });\n\t\t// 重新请求数据\n\t\tonSure();\n\t};\n\n\t// 改变状态的操作\n\tconst handleStatusChange = async (id: number, status: number) => {\n\t\t// 将 id 和 status 作为参数传递给 operateZsxqWhiteApi\n\t\tconst newValues = { id, status };\n\t\tconst { status: successStatus } = (await operateZsxqWhiteApi(newValues)) || {};\n\t\tconst { code, msg } = successStatus || {};\n\t\tif (code === 0) {\n\t\t\tmessage.success(\"状态操作成功\");\n\t\t\tconsole.log(\"code\", code);\n\t\t\tonSure();\n\t\t} else {\n\t\t\tmessage.error(msg || \"状态操作失败\");\n\t\t}\n\t};\n\n\t// 批量改变状态的操作\n\tconst handleBatchStatusChange = (e: RadioChangeEvent) => {\n\t\tconst { value } = e.target;\n\t\tconsole.log(\"全部通过还是全部拒绝 checked\", value);\n\n\t\tconst newValues = { ids: selectedRowKeys, status: value };\n\t\tconsole.log(\"批量操作的 newValues\", newValues);\n\n\t\t// 判断 ids 是否为空\n\t\tif (selectedRowKeys.length === 0) {\n\t\t\tmessage.error(\"请选择要操作的数据\");\n\t\t\treturn;\n\t\t}\n\n\t\t// 加一个确认对话框\n\t\tModal.confirm({\n\t\t\ttitle: \"确认\",\n\t\t\tcontent: \"确认要批量操作吗？\",\n\t\t\tokText: \"确认\",\n\t\t\tcancelText: \"取消\",\n\t\t\tonOk: async () => {\n\t\t\t\t// 将 ids 和 status 作为参数传递给 operateZsxqWhiteApi\n\t\t\t\tconst { status: successStatus } = (await operateBatchZsxqWhiteApi(newValues)) || {};\n\t\t\t\tconst { code, msg } = successStatus || {};\n\t\t\t\tif (code === 0) {\n\t\t\t\t\tmessage.success(\"批量状态操作成功\");\n\t\t\t\t\tconsole.log(\"code\", code);\n\t\t\t\t\tonSure();\n\t\t\t\t\t// 设置单选按钮的值\n\t\t\t\t\tsetRadioValue(value);\n\t\t\t\t} else {\n\t\t\t\t\tmessage.error(msg || \"批量状态操作失败\");\n\t\t\t\t}\n\t\t\t},\n\t\t\tonCancel: () => {\n\t\t\t\tconsole.log(\"取消\");\n\t\t\t\t// 单选按钮恢复到默认状态\n\t\t\t}\n\t\t});\n\t};\n\n\t// 重置\n\tconst handleReset = (zsxqAId: number) => {\n\t\tModal.warning({\n\t\t\ttitle: \"确认重置此星球用户吗\",\n\t\t\tcontent: \"重置此星球用户后无法恢复，请谨慎操作！\",\n\t\t\tmaskClosable: true,\n\t\t\tclosable: true,\n\t\t\tonOk: async () => {\n\t\t\t\tconst { status } = await resetAuthorWhiteApi(zsxqAId);\n\t\t\t\tconst { code, msg } = status || {};\n\t\t\t\tconsole.log();\n\t\t\t\tif (code === 0) {\n\t\t\t\t\tmessage.success(\"重置成功\");\n\t\t\t\t\tonSure();\n\t\t\t\t} else {\n\t\t\t\t\tmessage.error(msg);\n\t\t\t\t}\n\t\t\t}\n\t\t});\n\t};\n\n\tconst handleSubmit = async () => {\n\t\tconst values = await formRef.validateFields();\n\t\tconst newValues = {\n\t\t\t...values,\n\t\t\tid,\n\t\t\texpireTime: values.expireTime ? dayjs(values.expireTime).format(dateFormat) : null\n\t\t};\n\t\tconsole.log(\"编辑 时提交的 newValues:\", newValues);\n\n\t\tconst { status: successStatus } = (await updateZsxqWhiteApi(newValues)) || {};\n\t\tconst { code, msg } = successStatus || {};\n\t\tif (code === 0) {\n\t\t\tmessage.success(\"编辑成功\");\n\t\t\tsetIsModalOpen(false);\n\t\t\tonSure();\n\t\t} else {\n\t\t\tmessage.error(msg || \"编辑失败\");\n\t\t}\n\t};\n\n\t// 数据请求，这是一个钩子，query, current, pageSize, search 有变化的时候就会自动触发\n\tuseEffect(() => {\n\t\tconst getSortList = async () => {\n\t\t\tconst { status, result } = await getZsxqWhiteListApi({\n\t\t\t\tpageNumber: current,\n\t\t\t\tpageSize,\n\t\t\t\t...searchForm\n\t\t\t});\n\t\t\tconst { code } = status || {};\n\t\t\t//@ts-ignore\n\t\t\tconst { list, pageNum, pageSize: resPageSize, total } = result || {};\n\t\t\tsetPagination({ current: Number(pageNum), pageSize: resPageSize, total });\n\t\t\tif (code === 0) {\n\t\t\t\tconst newList = list.map((item: MapItem) => ({ ...item, key: item?.id }));\n\t\t\t\tsetTableData(newList);\n\t\t\t}\n\t\t};\n\t\tgetSortList();\n\t}, [query, current, pageSize]);\n\n\t// 表头设置\n\tconst columns: ColumnsType<DataType> = [\n\t\t{\n\t\t\ttitle: \"用户登录名\",\n\t\t\tdataIndex: \"userCode\",\n\t\t\tkey: \"userCode\",\n\t\t\twidth: 110,\n\t\t\trender(value, item) {\n\t\t\t\treturn (\n\t\t\t\t\t<a href={`${baseDomain}/user/home?userId=${item?.userId}`} className=\"cell-text\" target=\"_blank\" rel=\"noreferrer\">\n\t\t\t\t\t\t{value}\n\t\t\t\t\t</a>\n\t\t\t\t);\n\t\t\t}\n\t\t},\n\t\t{\n\t\t\ttitle: \"星球编号\",\n\t\t\twidth: 80,\n\t\t\tdataIndex: \"starNumber\",\n\t\t\tkey: \"starNumber\"\n\t\t},\n\t\t{\n\t\t\ttitle: \"用户头像\",\n\t\t\tdataIndex: \"avatar\",\n\t\t\tkey: \"avatar\",\n\t\t\twidth: 80,\n\t\t\trender(value) {\n\t\t\t\treturn (\n\t\t\t\t\t<>\n\t\t\t\t\t\t<Avatar src={value} />\n\t\t\t\t\t</>\n\t\t\t\t);\n\t\t\t}\n\t\t},\n\t\t{\n\t\t\ttitle: \"用户昵称\",\n\t\t\tdataIndex: \"name\",\n\t\t\twidth: 120,\n\t\t\tkey: \"name\",\n\t\t},\n\t\t{\n\t\t\ttitle: \"注册类型\",\n\t\t\tdataIndex: \"loginType\",\n\t\t\tkey: \"loginType\",\n\t\t\twidth: 110,\n\t\t\trender(value) {\n\t\t\t\treturn (\n\t\t\t\t\t<>\n\t\t\t\t\t\t<Avatar style={{ backgroundColor: colorLoginTypeMap[value], color: \"#fff\" }} size={50} gap={1}>\n\t\t\t\t\t\t\t{LoginType[value]?.slice(0, 5) || ''}\n\t\t\t\t\t\t</Avatar>\n\t\t\t\t\t</>\n\t\t\t\t);\n\t\t\t}\n\t\t},\n\t\t{\n\t\t\ttitle: \"过期时间\",\n\t\t\tdataIndex: \"expireTime\",\n\t\t\tkey: \"expireTime\",\n\t\t\twidth: 110,\n\t\t\trender(value) {\n\t\t\t\treturn (\n\t\t\t\t\t<span>{formatDate(value)}</span>\n\t\t\t\t);\n\t\t\t}\n\t\t},\n\t\t{\n\t\t\ttitle: \"邀请人数\",\n\t\t\tdataIndex: \"inviteNum\",\n\t\t\tkey: \"inviteNum\",\n\t\t\twidth: 80,\n\t\t\trender(value) {\n\t\t\t\treturn (\n\t\t\t\t\t<>\n\t\t\t\t\t\t<Badge count={value} showZero color=\"#faad14\" />\n\t\t\t\t\t</>\n\t\t\t\t);\n\t\t\t}\n\t\t},\n\t\t{\n\t\t\ttitle: \"上次登录日期\",\n\t\t\tdataIndex: \"lastLoginTime\",\n\t\t\tkey: \"lastLoginTime\",\n\t\t\twidth: 110,\n\t\t\trender(value) {\n\t\t\t\treturn (\n\t\t\t\t\t<span>{formatDate(value)}</span>\n\t\t\t\t);\n\t\t\t}\n\t\t},\n\t\t{\n\t\t\ttitle: \"状态\",\n\t\t\tdataIndex: \"state\",\n\t\t\tkey: \"state\",\n\t\t\twidth: 130,\n\t\t\trender(_, item) {\n\t\t\t\tconst { id, state } = item;\n\t\t\t\treturn (\n\t\t\t\t\t<Select\n\t\t\t\t\t\t// 宽度\n\t\t\t\t\t\tstyle={{ width: \"100%\" }}\n\t\t\t\t\t\t// 如果 status 为 1 那么 status 为 warning\n\t\t\t\t\t\tstatus={state === 2 ? \"\" : \"error\"}\n\t\t\t\t\t\tvalue={state.toString()}\n\t\t\t\t\t\toptions={UserAIStatList}\n\t\t\t\t\t\tonChange={value => handleStatusChange(id, Number(value))}\n\t\t\t\t\t></Select>\n\t\t\t\t);\n\t\t\t}\n\t\t},\n\t\t{\n\t\t\ttitle: \"操作\",\n\t\t\tkey: \"key\",\n\t\t\twidth: 120,\n\t\t\trender: (_, item) => {\n\t\t\t\t// 从 item 中取出 articleId\n\t\t\t\tconst { id } = item;\n\t\t\t\treturn (\n\t\t\t\t\t<div className=\"operation-btn\">\n\t\t\t\t\t\t<Tooltip title=\"编辑\">\n\t\t\t\t\t\t\t<Button\n\t\t\t\t\t\t\t\ttype=\"primary\"\n\t\t\t\t\t\t\t\ticon={<EditOutlined />}\n\t\t\t\t\t\t\t\tstyle={{ marginRight: \"10px\" }}\n\t\t\t\t\t\t\t\tonClick={() => {\n\t\t\t\t\t\t\t\t\t\tsetIsModalOpen(true);\n\t\t\t\t\t\t\t\t\t\thandleChange({ ...item });\n\t\t\t\t\t\t\t\t\t\tconst formData = {\n\t\t\t\t\t\t\t\t\t\t\t...item,\n\t\t\t\t\t\t\t\t\t\t\texpireTime: normalizeExpireValue(item.expireTime)\n\t\t\t\t\t\t\t\t\t\t};\n\t\t\t\t\t\t\t\t\t\tformRef.setFieldsValue(formData);\n\t\t\t\t\t\t\t\t\tconsole.log(\"formRef item\", formRef.getFieldsValue());\n\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t</Button>\n\t\t\t\t\t\t</Tooltip>\n\t\t\t\t\t\t<Tooltip title=\"重置\">\n\t\t\t\t\t\t\t<Button type=\"primary\" danger icon={<UndoOutlined />} onClick={() => handleReset(id)}>\n\t\t\t\t\t\t\t</Button>\n\t\t\t\t\t\t</Tooltip>\n\t\t\t\t\t</div>\n\t\t\t\t);\n\t\t\t}\n\t\t}\n\t];\n\n\t// 编辑表单\n\tconst reviseModalContent = (\n\t\t<Form name=\"basic\" form={formRef} labelCol={{ span: 4 }} wrapperCol={{ span: 16 }} autoComplete=\"off\">\n\t\t\t<Form.Item label=\"用户昵称\" name=\"name\" rules={[{ required: false, message: \"请输入用户昵称!\" }]}>\n\t\t\t\t<Input\n\t\t\t\t\tallowClear\n\t\t\t\t\tonChange={e => {\n\t\t\t\t\t\thandleChange({ name: e.target.value });\n\t\t\t\t\t}}\n\t\t\t\t/>\n\t\t\t</Form.Item>\n\t\t\t<Form.Item label=\"用户登录名\" name=\"userCode\" rules={[{ required: false, message: \"请输入用户登录名!\" }]}>\n\t\t\t\t<Input\n\t\t\t\t\tallowClear\n\t\t\t\t\tonChange={e => {\n\t\t\t\t\t\thandleChange({ userCode: e.target.value });\n\t\t\t\t\t}}\n\t\t\t\t/>\n\t\t\t</Form.Item>\n\t\t\t<Form.Item label=\"星球编号\" name=\"starNumber\" rules={[{ required: false, message: \"请输入星球编号!\" }]}>\n\t\t\t\t<Input\n\t\t\t\t\tallowClear\n\t\t\t\t\tonChange={e => {\n\t\t\t\t\t\thandleChange({ starNumber: e.target.value });\n\t\t\t\t\t}}\n\t\t\t\t/>\n\t\t\t</Form.Item>\n\t\t\t<Form.Item label=\"过期时间\" name=\"expireTime\" rules={[{ required: false, message: \"请选择过期时间!\" }]}>\n\t\t\t\t<DatePicker\n\t\t\t\t\tstyle={{ width: \"100%\" }}\n\t\t\t\t\tplaceholder=\"选择过期时间\"\n\t\t\t\t\tformat={dateFormat}\n\t\t\t\t\tonChange={(date, dateString) => {\n\t\t\t\t\t\thandleChange({ expireTime: dateString || null });\n\t\t\t\t\t}}\n\t\t\t\t/>\n\t\t\t</Form.Item>\n\t\t</Form>\n\t);\n\n\treturn (\n\t\t<div className=\"article zsxq-list-page\">\n\t\t\t<ContentWrap style={{ overflowX: 'hidden', maxWidth: '100%' }}>\n\t\t\t\t{/* 搜索 */}\n\t\t\t\t<Search\n\t\t\t\t\thandleSearchChange={handleSearchChange}\n\t\t\t\t\thandleSearch={handleSearch}\n\t\t\t\t\tUserAIStatList={UserAIStatList}\n\t\t\t\t\thandleBatchStatusChange={handleBatchStatusChange}\n\t\t\t\t\tradioValue={radioValue}\n\t\t\t\t/>\n\t\t\t\t{/* 表格 - 桌面端 */}\n\t\t\t\t<ContentInterWrap className=\"desktop-table\">\n\t\t\t\t\t<Table rowSelection={rowSelection} columns={columns} dataSource={tableData} pagination={paginationInfo} />\n\t\t\t\t</ContentInterWrap>\n\t\t\t\t{/* 卡片列表 - 移动端 */}\n\t\t\t\t<div className=\"mobile-card-list\">\n\t\t\t\t\t{tableData.map((item) => (\n\t\t\t\t\t\t<div key={item.id} className=\"user-card\">\n\t\t\t\t\t\t\t<div className=\"card-header\">\n\t\t\t\t\t\t\t\t<Avatar src={item.avatar} size={48} />\n\t\t\t\t\t\t\t\t<div className=\"user-info\">\n\t\t\t\t\t\t\t\t\t<div className=\"user-name\">{item.name}</div>\n\t\t\t\t\t\t\t\t\t<a href={`${baseDomain}/user/home?userId=${item?.userId}`} className=\"user-code\" target=\"_blank\" rel=\"noreferrer\">\n\t\t\t\t\t\t\t\t\t\t{item.userCode}\n\t\t\t\t\t\t\t\t\t</a>\n\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t<div className=\"user-status\">\n\t\t\t\t\t\t\t\t\t<Select\n\t\t\t\t\t\t\t\t\t\tsize=\"small\"\n\t\t\t\t\t\t\t\t\t\tstatus={item.state === 2 ? \"\" : \"error\"}\n\t\t\t\t\t\t\t\t\t\tvalue={item.state.toString()}\n\t\t\t\t\t\t\t\t\t\toptions={UserAIStatList}\n\t\t\t\t\t\t\t\t\t\tonChange={value => handleStatusChange(item.id, Number(value))}\n\t\t\t\t\t\t\t\t\t\tstyle={{ width: 100 }}\n\t\t\t\t\t\t\t\t\t></Select>\n\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t<div className=\"card-body\">\n\t\t\t\t\t\t\t\t<div className=\"info-row\">\n\t\t\t\t\t\t\t\t\t<span className=\"label\">星球编号:</span>\n\t\t\t\t\t\t\t\t\t<span className=\"value\">{item.starNumber || '-'}</span>\n\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t<div className=\"info-row\">\n\t\t\t\t\t\t\t\t\t<span className=\"label\">注册类型:</span>\n\t\t\t\t\t\t\t\t\t<span className=\"value\">\n\t\t\t\t\t\t\t\t\t\t<Avatar style={{ backgroundColor: colorLoginTypeMap[item.loginType], color: \"#fff\" }} size={40} gap={1}>\n\t\t\t\t\t\t\t\t\t\t\t{LoginType[item.loginType]?.slice(0, 5) || ''}\n\t\t\t\t\t\t\t\t\t\t</Avatar>\n\t\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t<div className=\"info-row\">\n\t\t\t\t\t\t\t\t\t<span className=\"label\">过期时间:</span>\n\t\t\t\t\t\t\t\t\t<span className=\"value\">{formatDate(item.expireTime)}</span>\n\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t<div className=\"info-row\">\n\t\t\t\t\t\t\t\t\t<span className=\"label\">邀请人数:</span>\n\t\t\t\t\t\t\t\t\t<span className=\"value\">\n\t\t\t\t\t\t\t\t\t\t<Badge count={item.inviteNum} showZero color=\"#faad14\" />\n\t\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t<div className=\"info-row\">\n\t\t\t\t\t\t\t\t\t<span className=\"label\">上次登录:</span>\n\t\t\t\t\t\t\t\t\t<span className=\"value\">{formatDate(item.lastLoginTime)}</span>\n\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t<div className=\"card-footer\">\n\t\t\t\t\t\t\t\t<Button\n\t\t\t\t\t\t\t\t\ttype=\"primary\"\n\t\t\t\t\t\t\t\t\ticon={<EditOutlined />}\n\t\t\t\t\t\t\t\t\tsize=\"small\"\n\t\t\t\t\t\t\t\t\tonClick={() => {\n\t\t\t\t\t\t\t\t\tsetIsModalOpen(true);\n\t\t\t\t\t\t\t\t\thandleChange({ ...item });\n\t\t\t\t\t\t\t\t\tconst formData = {\n\t\t\t\t\t\t\t\t\t\t...item,\n\t\t\t\t\t\t\t\t\t\texpireTime: normalizeExpireValue(item.expireTime)\n\t\t\t\t\t\t\t\t\t};\n\t\t\t\t\t\t\t\t\tformRef.setFieldsValue(formData);\n\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t编辑\n\t\t\t\t\t\t\t\t</Button>\n\t\t\t\t\t\t\t\t<Button type=\"primary\" danger icon={<UndoOutlined />} size=\"small\" onClick={() => handleReset(item.id)}>\n\t\t\t\t\t\t\t\t\t重置\n\t\t\t\t\t\t\t\t</Button>\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t))}\n\t\t\t\t\t{/* 移动端分页 */}\n\t\t\t\t\t{tableData.length > 0 && (\n\t\t\t\t\t\t<div className=\"mobile-pagination\">\n\t\t\t\t\t\t\t<div className=\"pagination-info\">\n\t\t\t\t\t\t\t\t共 {pagination.total || 0} 条\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t<div className=\"pagination-controls\">\n\t\t\t\t\t\t\t\t<Button \n\t\t\t\t\t\t\t\t\tsize=\"small\" \n\t\t\t\t\t\t\t\t\tdisabled={current === 1}\n\t\t\t\t\t\t\t\t\tonClick={() => setPagination({ ...pagination, current: current - 1 })}\n\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t上一页\n\t\t\t\t\t\t\t\t</Button>\n\t\t\t\t\t\t\t\t<span className=\"current-page\">{current} / {Math.ceil((pagination.total || 0) / pageSize)}</span>\n\t\t\t\t\t\t\t\t<Button \n\t\t\t\t\t\t\t\t\tsize=\"small\"\n\t\t\t\t\t\t\t\t\tdisabled={current >= Math.ceil((pagination.total || 0) / pageSize)}\n\t\t\t\t\t\t\t\t\tonClick={() => setPagination({ ...pagination, current: current + 1 })}\n\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t下一页\n\t\t\t\t\t\t\t\t</Button>\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t)}\n\t\t\t\t</div>\n\t\t\t</ContentWrap>\n\t\t\t{/* 弹窗 */}\n\t\t\t<Modal title=\"修改\" visible={isModalOpen} onCancel={() => setIsModalOpen(false)} onOk={handleSubmit}>\n\t\t\t\t{reviseModalContent}\n\t\t\t</Modal>\n\t\t</div>\n\t);\n};\n\nconst mapStateToProps = (state: any) => state.disc.disc;\nconst mapDispatchToProps = {};\nexport default connect(mapStateToProps, mapDispatchToProps)(Zsxqlist);\n"
  },
  {
    "path": "src/views/category/components/search/index.scss",
    "content": ".category-search {\n\tmargin-bottom: 16px;\n\n\t&__wrap {\n\t\tdisplay: flex;\n\t\tjustify-content: space-between;\n\t\tpadding-left: 48px;\n\t}\n\n\t&__search {\n\t\tflex: 1;\n\t\tdisplay: flex;\n\t\tjustify-content: space-between;\n\t\t&-wrap {\n\t\t\tdisplay: flex;\n\t\t}\n\t}\n\n\t&__search-item {\n\t\tmargin-right: 48px;\n\t}\n\n\t&-label {\n\t\tmargin-right: 12px;\n\t}\n}\n"
  },
  {
    "path": "src/views/category/components/search/index.tsx",
    "content": "/* eslint-disable prettier/prettier */\nimport { FC } from \"react\";\nimport { PlusOutlined, SearchOutlined } from \"@ant-design/icons\";\nimport { Button, Input } from \"antd\";\n\nimport { ContentInterWrap } from \"@/components/common-wrap\";\n\nimport \"./index.scss\";\n\ninterface IProps {\n\thandleSearch: (e: object) => void;\n\thandleSearchChange: (e: object) => void;\n\thandleAdd: () => void;\n}\n\nconst Search: FC<IProps> = ({ \n\thandleSearch, \n\thandleSearchChange, \n\thandleAdd \n}) => {\n\treturn (\n\t\t<div className=\"category-search\">\n\t\t\t<ContentInterWrap className=\"category-search__wrap\">\n\t\t\t\t<div className=\"category-search__search\">\n\t\t\t\t\t<div className=\"category-search__search-wrap\">\n\t\t\t\t\t\t<div className=\"category-search__search-item\">\n\t\t\t\t\t\t\t<label className=\"category-search-label\">名称</label>\n\t\t\t\t\t\t\t<Input\n\t\t\t\t\t\t\t\tallowClear\n\t\t\t\t\t\t\t\tstyle={{ width: 252 }}\n\t\t\t\t\t\t\t\tplaceholder=\"请输入分类名称\"\n\t\t\t\t\t\t\t\tonChange={e => handleSearchChange({ category: e.target.value })}\n\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t</div>\n\t\t\t\t\t<div className=\"category-search__search-btn\">\n\t\t\t\t\t\t<Button \n\t\t\t\t\t\t\ttype=\"primary\" \n\t\t\t\t\t\t\ticon={<SearchOutlined />} \n\t\t\t\t\t\t\tstyle={{ marginRight: \"10px\" }} \n\t\t\t\t\t\t\tonClick={handleSearch}>\n\t\t\t\t\t\t\t搜索\n\t\t\t\t\t\t</Button>\n\t\t\t\t\t\t<Button\n\t\t\t\t\t\t\ttype=\"primary\"\n\t\t\t\t\t\t\ticon={<PlusOutlined />}\n\t\t\t\t\t\t\tstyle={{ marginRight: \"20px\" }}\n\t\t\t\t\t\t\tonClick={handleAdd}\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t添加\n\t\t\t\t\t\t</Button>\n\t\t\t\t\t</div>\n\t\t\t\t</div>\n\t\t\t</ContentInterWrap>\n\t\t</div>\n\t);\n};\nexport default Search;\n"
  },
  {
    "path": "src/views/category/index.css",
    "content": ".sort {\n  height: 100%;\n}\n"
  },
  {
    "path": "src/views/category/index.scss",
    "content": "\n"
  },
  {
    "path": "src/views/category/index.tsx",
    "content": "/* eslint-disable prettier/prettier */\nimport { FC, useCallback, useEffect, useState } from \"react\";\nimport { connect } from \"react-redux\";\nimport { DeleteOutlined, EditOutlined } from \"@ant-design/icons\";\nimport { Button, Drawer, Form, Input, InputNumber,message, Modal, Space, Switch,Table } from \"antd\";\nimport type { ColumnsType } from \"antd/es/table\";\n\nimport { delCategoryApi, getCategoryListApi, operateCategoryApi, updateCategoryApi } from \"@/api/modules/category\";\nimport { ContentInterWrap, ContentWrap } from \"@/components/common-wrap\";\nimport { initPagination, IPagination, UpdateEnum } from \"@/enums/common\";\nimport { MapItem } from \"@/typings/common\";\nimport Search from \"./components/search\";\n\nimport \"./index.scss\";\n\ninterface DataType {\n\tcategoryId: number;\n\tkey: string;\n\tname: string;\n}\n\ninterface IProps {}\n\nexport interface IFormType {\n\tcategoryId: number; // 为0时，是保存，非0是更新\n\tcategory: string; // 分类名\n\trank: number; // 排名\n}\n\nconst defaultInitForm: IFormType = {\n\tcategoryId: -1,\n\tcategory: \"\",\n\trank: -1\n};\n\nconst Category: FC<IProps> = props => {\n\tconst [formRef] = Form.useForm();\n\t// form值\n\tconst [form, setForm] = useState<IFormType>(defaultInitForm);\n\t// 查询表单值\n\tconst [searchForm, setSearchForm] = useState<IFormType>(defaultInitForm);\n\t// 抽屉\n\tconst [isDrawerOpen, setIsDrawerOpen] = useState<boolean>(false);\n\t// 列表数据\n\tconst [tableData, setTableData] = useState<DataType[]>([]);\n\t// 刷新函数\n\tconst [query, setQuery] = useState<number>(0);\n\n\t//当前的状态\n\tconst [status, setStatus] = useState<UpdateEnum>(UpdateEnum.Save);\n\n\t// 分页\n\tconst [pagination, setPagination] = useState<IPagination>(initPagination);\n\tconst { current, pageSize } = pagination;\n\n\tconst paginationInfo = {\n\t\t...pagination,\n\t\tshowSizeChanger: true,\n\t\tshowTotal: (total: number) => `共 ${total || 0} 条`,\n\t\tonChange: (current: number, pageSize: number) => {\n\t\t\tsetPagination({ current, pageSize });\n\t\t}\n\t};\n\n\tconst { categoryId } = form;\n\n\tconst onSure = useCallback(() => {\n\t\tsetQuery(prev => prev + 1);\n\t}, []);\n\n\t// 值改变\n\tconst handleChange = (item: MapItem) => {\n\t\tsetForm({ ...form, ...item });\n\t};\n\t// 查询表单值改变\n\tconst handleSearchChange = (item: MapItem) => {\n\t\tsetSearchForm({ ...searchForm, ...item });\n\t};\n\t// 点击搜索按钮时触发搜索\n\tconst handleSearch = () => {\n\t\tsetPagination({ current: 1, pageSize });\n\t\tonSure();\n\t};\n\t// 抽屉关闭\n\tconst handleClose = () => {\n\t\tsetIsDrawerOpen(false);\n\t};\n\n\t// 重置表单\n\tconst resetFrom = () => {\n\t\tsetForm(defaultInitForm);\n\t};\n\t// 新增触发\n\tconst handleAdd = () => {\n\t\tresetFrom();\n\t\tsetStatus(UpdateEnum.Save);\n\t\tsetIsDrawerOpen(true);\n\t};\n\n\t// 删除\n\tconst handleDel = (categoryId: number) => {\n\t\tModal.warning({\n\t\t\ttitle: \"确认删除此分类吗\",\n\t\t\tcontent: \"删除此分类后无法恢复，请谨慎操作！\",\n\t\t\tmaskClosable: true,\n\t\t\tclosable: true,\n\t\t\tonOk: async () => {\n\t\t\t\tconst { status } = await delCategoryApi(categoryId);\n\t\t\t\tconst { code, msg } = status || {};\n\t\n\t\t\t\tif (code === 0) {\n\t\t\t\t\tmessage.success(\"删除成功\");\n\t\t\t\t\tonSure();\n\t\t\t\t} else {\n\t\t\t\t\tmessage.error(msg);\n\t\t\t\t}\n\t\t\t}\n\t\t});\n\t};\n\n\tconst handleSubmit = async () => {\n\t\tconst values = await formRef.validateFields();\n\t\tconst newValues = { \n\t\t\t...values, \n\t\t\tcategoryId: status === UpdateEnum.Save ? UpdateEnum.Save : categoryId \n\t\t};\n\n\t\tconst { status: successStatus } = (await updateCategoryApi(newValues)) || {};\n\t\tconst { code, msg } = successStatus || {};\n\t\tif (code === 0) {\n\t\t\tsetIsDrawerOpen(false);\n\t\t\tsetPagination({ current: 1, pageSize });\n\t\t\tonSure();\n\t\t} else {\n\t\t\tmessage.error(msg);\n\t\t}\n\t\t\n\t};\n\n\t// 上线/下线\n\tconst handleOperate = async (categoryId: number, pushStatus: number) => {\n\t\tconst { status } = await operateCategoryApi({ categoryId, pushStatus });\n\t\tconst { code, msg } = status || {};\n\t\tif (code === 0) {\n\t\t\tmessage.success(\"操作成功\");\n\t\t\tonSure();\n\t\t} else {\n\t\t\tmessage.error(msg);\n\t\t}\n\t};\n\n\t// 数据请求\n\tuseEffect(() => {\n\t\tconst getSortList = async () => {\n\t\t\tconst { status, result } = await getCategoryListApi({ \n\t\t\t\t...searchForm,\n\t\t\t\tpageNumber: current, \n\t\t\t\tpageSize \n\t\t\t});\n\t\t\tconst { code } = status || {};\n\t\t\t//@ts-ignore\n\t\t\tconst { list, pageNum, pageSize: resPageSize, total } = result || {};\n\t\t\tconsole.log(\"result\", result);\n\t\t\tsetPagination({ current: Number(pageNum), pageSize: resPageSize, total });\n\t\t\tif (code === 0) {\n\t\t\t\tconst newList = list.map((item: MapItem) => ({ ...item, key: item?.categoryId }));\n\t\t\t\tsetTableData(newList);\n\t\t\t}\n\t\t};\n\t\tgetSortList();\n\t}, [query, current, pageSize]);\n\n\t// 表头设置\n\tconst columns: ColumnsType<DataType> = [\n\t\t{\n\t\t\ttitle: \"分类名称\",\n\t\t\tdataIndex: \"category\",\n\t\t\tkey: \"category\"\n\t\t},\n\t\t{\n\t\t\ttitle: \"上下线\",\n\t\t\tdataIndex: \"status\",\n\t\t\tkey: \"status\",\n\t\t\trender(status, item) {\n\t\t\t\t// switch 组件\n\t\t\t\treturn (\n\t\t\t\t\t<Switch\n\t\t\t\t\t\tchecked={status === 1}\n\t\t\t\t\t\tonChange={() => {\n\t\t\t\t\t\t\tconst pushStatus = status === 0 ? 1 : 0;\n\t\t\t\t\t\t\thandleOperate(item.categoryId, pushStatus);\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t/>\n\t\t\t\t);\n\t\t\t}\n\t\t},\n\t\t{\n\t\t\ttitle: \"排序\",\n\t\t\tdataIndex: \"rank\",\n\t\t\tkey: \"rank\"\n\t\t},\n\t\t{\n\t\t\ttitle: \"操作\",\n\t\t\tkey: \"key\",\n\t\t\twidth: 210,\n\t\t\trender: (_, item) => {\n\t\t\t\tconst { categoryId } = item;\n\t\t\t\treturn (\n\t\t\t\t\t<div className=\"operation-btn\">\n\t\t\t\t\t\t<Button\n\t\t\t\t\t\t\ttype=\"primary\"\n\t\t\t\t\t\t\ticon={<EditOutlined />}\n\t\t\t\t\t\t\tstyle={{ marginRight: \"10px\" }}\n\t\t\t\t\t\t\tonClick={() => {\n\t\t\t\t\t\t\t\tsetIsDrawerOpen(true);\n\t\t\t\t\t\t\t\tsetStatus(UpdateEnum.Edit);\n\t\t\t\t\t\t\t\thandleChange({ categoryId: categoryId });\n\t\t\t\t\t\t\t\tformRef.setFieldsValue({ ...item });\n\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t编辑\n\t\t\t\t\t\t</Button>\n\t\t\t\t\t\t\n\t\t\t\t\t\t<Button type=\"primary\" danger icon={<DeleteOutlined />} onClick={() => handleDel(categoryId)}>\n\t\t\t\t\t\t\t删除\n\t\t\t\t\t\t</Button>\n\t\t\t\t\t</div>\n\t\t\t\t);\n\t\t\t}\n\t\t}\n\t];\n\n\t// 编辑表单\n\tconst reviseDrawerContent = (\n\t\t<Form \n\t\t\tname=\"basic\" \n\t\t\tform={formRef} \n\t\t\tlabelCol={{ span: 4 }} \n\t\t\twrapperCol={{ span: 16 }} \n\t\t\tautoComplete=\"off\">\n\t\t\t<Form.Item label=\"分类\" name=\"category\" rules={[{ required: true, message: \"请输入分类!\" }]}>\n\t\t\t\t<Input\n\t\t\t\t\tallowClear\n\t\t\t\t\tonChange={e => {\n\t\t\t\t\t\thandleChange({ category: e.target.value });\n\t\t\t\t\t}}\n\t\t\t\t/>\n\t\t\t</Form.Item>\n\t\t\t<Form.Item label=\"排序\" name=\"rank\" rules={[{ required: true, message: \"请输入排序!\" }]}>\n\t\t\t\t<InputNumber\n\t\t\t\t\tmin={0}\n\t\t\t\t\tonChange={value => {\n\t\t\t\t\t\thandleChange({ rank: value });\n\t\t\t\t\t}}\n\t\t\t\t/>\n\t\t\t</Form.Item>\n\t\t</Form>\n\t);\n\n\treturn (\n\t\t<div className=\"category\">\n\t\t\t<ContentWrap>\n\t\t\t\t{/* 搜索 */}\n\t\t\t\t<Search \n\t\t\t\t\thandleSearchChange={handleSearchChange} \n\t\t\t\t\thandleSearch={handleSearch}\n\t\t\t\t\thandleAdd={handleAdd}\n\t\t\t\t/>\n\t\t\t\t{/* 表格 */}\n\t\t\t\t<ContentInterWrap>\n\t\t\t\t\t<Table columns={columns} dataSource={tableData} pagination={paginationInfo} />\n\t\t\t\t</ContentInterWrap>\n\t\t\t</ContentWrap>\n\t\t\t{/* 抽屉 */}\n\t\t\t<Drawer \n\t\t\t\ttitle=\"添加/修改\" \n\t\t\t\topen={isDrawerOpen} \n\t\t\t\tonClose={handleClose}\n\t\t\t\textra={\n          <Space>\n            <Button onClick={handleClose}>取消</Button>\n            <Button type=\"primary\" onClick={handleSubmit}>\n              确定\n            </Button>\n          </Space>\n        }\n\t\t\t\t>\n\t\t\t\t{reviseDrawerContent}\n\t\t\t</Drawer>\n\t\t</div>\n\t);\n};\n\nconst mapStateToProps = (state: any) => state.disc.disc;\nconst mapDispatchToProps = {};\nexport default connect(mapStateToProps, mapDispatchToProps)(Category);\n"
  },
  {
    "path": "src/views/column/article/components/DatePicker.tsx",
    "content": "import { DatePicker } from \"antd\";\nimport type { Dayjs } from \"dayjs\";\nimport dayjsGenerateConfig from \"rc-picker/lib/generate/dayjs\";\n\nconst MyDatePicker = DatePicker.generatePicker<Dayjs>(dayjsGenerateConfig);\n\nexport default MyDatePicker;\n"
  },
  {
    "path": "src/views/column/article/components/debounceselect/DebounceSelect.tsx",
    "content": "import { useMemo, useRef, useState } from \"react\";\nimport { Select, SelectProps, Spin } from \"antd\";\nimport { debounce } from \"lodash\";\n\n// 导入 index.scss 文件\nimport \"./index.scss\";\n\nexport interface DebounceSelectProps<ValueType = any> extends Omit<SelectProps<ValueType | ValueType[]>, \"options\" | \"children\"> {\n\tfetchOptions: (search: string) => Promise<ValueType[]>;\n\tdebounceTimeout?: number;\n}\n\nfunction DebounceSelect<ValueType extends { key?: string; label: React.ReactNode; value: string | number } = any>({\n\tfetchOptions,\n\tdebounceTimeout = 800,\n\t...props\n}: DebounceSelectProps<ValueType>) {\n\t// 使用useState定义state变量，用于保存选项列表和加载状态\n\tconst [fetching, setFetching] = useState(false);\n\tconst [options, setOptions] = useState<ValueType[]>([]);\n\t// 使用useRef定义ref变量，用于记录请求的次数\n\tconst fetchRef = useRef(0);\n\n\t// 使用useMemo定义防抖函数\n\tconst debounceFetcher = useMemo(() => {\n\t\t// 定义异步加载选项的函数\n\t\tconst loadOptions = (value: string) => {\n\t\t\t// 每次请求前递增fetchRef的值，用于区分请求的顺序\n\t\t\tfetchRef.current += 1;\n\t\t\tconst fetchId = fetchRef.current;\n\t\t\t// 清空options和设置加载状态为true\n\t\t\tsetOptions([]);\n\t\t\tsetFetching(true);\n\n\t\t\t// 调用fetchOptions函数获取选项列表\n\t\t\tfetchOptions(value).then(newOptions => {\n\t\t\t\t// 判断当前请求的fetchId是否与最新的fetchRef值一致，保证按请求顺序更新选项\n\t\t\t\tif (fetchId !== fetchRef.current) {\n\t\t\t\t\t// 请求的回调顺序不一致，忽略该请求\n\t\t\t\t\treturn;\n\t\t\t\t}\n\n\t\t\t\t// 设置获取到的选项列表，并设置加载状态为false\n\t\t\t\tsetOptions(newOptions);\n\t\t\t\tsetFetching(false);\n\t\t\t});\n\t\t};\n\n\t\t// 使用lodash的debounce函数创建防抖函数\n\t\treturn debounce(loadOptions, debounceTimeout);\n\t}, [fetchOptions, debounceTimeout]);\n\n\treturn (\n\t\t<Select\n\t\t\tlabelInValue\n\t\t\tfilterOption={false}\n\t\t\t// 绑定防抖函数到onSearch事件上，触发搜索操作时，会调用防抖函数\n\t\t\tonSearch={debounceFetcher}\n\t\t\tonDropdownVisibleChange={visible => {\n\t\t\t\t// 下拉列表展开时，重新获取选项列表\n\t\t\t\tif (visible && options.length === 0) {\n\t\t\t\t\tdebounceFetcher(\"\");\n\t\t\t\t}\n\t\t\t}}\n\t\t\t// 当加载状态为true时，显示旋转加载图标\n\t\t\tnotFoundContent={fetching ? <Spin size=\"small\" /> : null}\n\t\t\t{...props}\n\t\t\t// 将选项列表传递给Select组件进行展示\n\t\t\toptions={options}\n\t\t/>\n\t);\n}\nexport default DebounceSelect;\n"
  },
  {
    "path": "src/views/column/article/components/debounceselect/index.scss",
    "content": ""
  },
  {
    "path": "src/views/column/article/components/search/index.scss",
    "content": ".column-article-search {\n  margin-bottom: 16px;\n\n  &__wrap {\n    display: flex;\n    justify-content: space-between;\n    padding-left: 48px;\n  }\n\n  &__search {\n    flex: 1;\n    display: flex;\n\n    &-wrap {\n      display: flex;\n    }\n  }\n\n  &__search-item {\n    margin-right: 48px;\n  }\n\n  &-label {\n    margin-right: 12px;\n  }\n}\n"
  },
  {
    "path": "src/views/column/article/components/search/index.tsx",
    "content": "/* eslint-disable prettier/prettier */\nimport { FC } from \"react\";\nimport { PlusOutlined, SearchOutlined } from \"@ant-design/icons\";\nimport { Button, Input } from \"antd\";\n\nimport { ContentInterWrap } from \"@/components/common-wrap\";\nimport { UpdateEnum } from \"@/enums/common\";\nimport DebounceSelect from \"../debounceselect/DebounceSelect\";\n\nimport \"./index.scss\";\n\ninterface IProps {\n\thandleSearchChange: (e: object) => void;\n\thandleSearch: () => void;\n\tfetchColumnList: (search: string) => Promise<any[]>;\n\thandleAdd: () => void;\n}\n\nconst Search: FC<IProps> = ({ \n\thandleSearchChange, \n\thandleSearch,\n\tfetchColumnList,\n\thandleAdd\n}) => {\n\treturn (\n\t\t<div className=\"column-article-search\">\n\t\t\t<ContentInterWrap className=\"column-article-search__wrap\">\n\t\t\t\t<div className=\"column-article-search__search\">\n\t\t\t\t\t<div className=\"column-article-search__search-item\">\n\t\t\t\t\t\t<span className=\"column-article-search-label\">专栏</span>\n\t\t\t\t\t\t{/*用下拉框做一个教程的选择 */}\n\t\t\t\t\t\t<DebounceSelect\n\t\t\t\t\t\t\tallowClear\n\t\t\t\t\t\t\tstyle={{ width: 262 }}\n\t\t\t\t\t\t\tfilterOption={false}\n\t\t\t\t\t\t\tplaceholder=\"选择专栏\"\n\t\t\t\t\t\t\t// 回填到选择框的 Option 的属性值，默认是 Option 的子元素。\n\t\t\t\t\t\t\t// 比如在子元素需要高亮效果时，此值可以设为 value\n\t\t\t\t\t\t\toptionLabelProp=\"value\"\n\t\t\t\t\t\t\t// 是否在输入框聚焦时自动调用搜索方法\n\t\t\t\t\t\t\tshowSearch={true}\n\t\t\t\t\t\t\tonChange={(value, option) => {\n\t\t\t\t\t\t\t\tconsole.log(\"教程搜索的值改变\", value, option);\n\t\t\t\t\t\t\t\tif (option) \n\t\t\t\t\t\t\t\t\t//@ts-ignore\n\t\t\t\t\t\t\t\t\thandleSearchChange({ columnId: option.key });\n\t\t\t\t\t\t\t\telse \n\t\t\t\t\t\t\t\t\thandleSearchChange({ columnId: -1 });\n\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\tfetchOptions={fetchColumnList}\n\t\t\t\t\t\t/>\n\t\t\t\t\t</div>\n\t\t\t\t\t<div className=\"column-article-search__search-item\">\n\t\t\t\t\t\t<span className=\"column-article-search-label\">教程标题</span>\n\t\t\t\t\t\t<Input \n\t\t\t\t\t\t\tallowClear\n\t\t\t\t\t\t\tonChange={e => handleSearchChange({ articleTitle: e.target.value })} \n\t\t\t\t\t\t\tstyle={{ width: 202 }} \n\t\t\t\t\t\t\t/>\n\t\t\t\t\t</div>\n\t\t\t\t</div>\n\t\t\t\t<Button\n\t\t\t\t\ttype=\"primary\"\n\t\t\t\t\ticon={<SearchOutlined />}\n\t\t\t\t\tstyle={{ marginRight: \"10px\" }}\n\t\t\t\t\tonClick={handleSearch}\n\t\t\t\t>\n\t\t\t\t\t搜索\n\t\t\t\t</Button>\n\t\t\t\t<Button\n\t\t\t\t\ttype=\"primary\"\n\t\t\t\t\ticon={<PlusOutlined />}\n\t\t\t\t\tstyle={{ marginRight: \"16px\" }}\n\t\t\t\t\tonClick={handleAdd}\n\t\t\t\t>\n\t\t\t\t\t添加\n\t\t\t\t</Button>\n\t\t\t</ContentInterWrap>\n\t\t</div>\n\t);\n};\nexport default Search;\n"
  },
  {
    "path": "src/views/column/article/components/tableselect/TableSelect.tsx",
    "content": "/* eslint-disable prettier/prettier */\nimport React, { FC, useEffect, useState } from \"react\";\nimport { PoweroffOutlined } from \"@ant-design/icons\";\nimport { Avatar,Button, Checkbox, Divider, Input, Select, Table } from \"antd\";\nimport type { ColumnsType } from \"antd/es/table\";\n\nimport { getArticleListApi } from \"@/api/modules/article\";\nimport { initPagination,IPagination } from \"@/enums/common\";\nimport { MapItem } from \"@/typings/common\";\n\nconst { Option } = Select;\n\n// 教程文章的数据类型\ninterface DataType {\n\tarticleId: number;\n\ttitle: string;\n\tauthorName: string;\n\tshortTitle: string;\n}\n\ninterface ValueType {\n\tkey?: string; \n\tlabel: React.ReactNode; \n\tvalue: string | number\n}\n\ninterface IProps {\n\t// 是否打开教程下拉框\n\tisArticleSelectOpen: boolean;\n\t// 什么时候关闭教程下拉框\n\tsetIsArticleSelectOpen: (e: boolean) => void;\n\t// 选中教程后的回调\n\thandleChange: (e: object) => void;\n}\n\n// 查询表单接口，定义类型\ninterface ISearchArticleForm {\n\ttitle: string;\n\tuserName: string;\n\tstatus: number;\n\ttoppingStat: number;\n\tofficalStat: number;\n}\n\n// 查询表单默认值\nconst defaultArticleSearchForm = {\n\ttitle: \"\",\n\tuserName: \"\",\n\tstatus: -1,\n\ttoppingStat : -1,\n\tofficalStat : -1\n};\n\nconst TableSelect: FC<IProps> = ({\n\tisArticleSelectOpen,\n\tsetIsArticleSelectOpen,\n\thandleChange,\n}) => {\n\t// 文章下拉框显示的值\n\tconst [articleSelectValue, setArticleSelectValue] = useState<ValueType>();\n\t// 查询文章表单\n\tconst [searchArticleForm, setSearchArticleForm] = useState<ISearchArticleForm>(defaultArticleSearchForm);\n\t// 文章搜索，目前是根据标题、状态、置顶、推荐搜索\n\tconst [searchArticle, setSearchArticle] = useState<ISearchArticleForm>(defaultArticleSearchForm);\n\t// 文章列表数据\n\tconst [tableArticleData, setTableArticleData] = useState<DataType[]>([]);\n\n\t// 分页\n\tconst [pagination, setPagination] = useState<IPagination>(initPagination);\n\tconst { current, pageSize } = pagination;\n\n\tconst paginationInfo = {\n\t\tshowSizeChanger: true,\n\t\tshowTotal: (total: any) => `共 ${total || 0} 条`,\n\t\t...pagination,\n\t\tonChange: (current: number, pageSize: number) => {\n\t\t\tsetPagination({ current, pageSize });\n\t\t}\n\t};\n\t\n\t// 文章查询表单值改变\n\tconst handleSearchArticleChange = (item: MapItem) => {\n\t\t// 当 status 的值为 -1 时，重新显示\n\t\tsetSearchArticleForm({ ...searchArticleForm, ...item });\n\t\tconsole.log(\"文章查询条件变化了\",searchArticleForm);\n\t};\n\n\t// 当点击文章筛选按钮的时候触发\n\tconst handleArticleSearch = () => {\n\t\t// 目前是根据文章标题搜索，后面需要加上其他条件\n\t\tconsole.log(\"查询条件\", searchArticleForm);\n\t\t// 查询的时候把分页重置为第一页\n\t\tsetPagination({ pageSize, current: 1 });\n\t\tsetSearchArticle(searchArticleForm);\n\t};\n\n\t// 文章数据请求，这是一个钩子，query, current, pageSize, search 有变化的时候就会自动触发\n\tuseEffect(() => {\n\t\tconst getArticleSortList = async () => {\n\t\t\tconsole.log(\"文章查询条件\", pagination);\n\t\t\tconst { status, result } = await getArticleListApi({ \n\t\t\t\tpageNumber: current, \n\t\t\t\tpageSize: pageSize,\n\t\t\t\t...searchArticleForm\n\t\t\t});\n\t\t\tconst { code } = status || {};\n\t\t\t// @ts-ignore\n\t\t\tconst { list, pageNum, pageSize: resPageSize, pageTotal, total } = result || {};\n\t\t\tsetPagination({ current: Number(pageNum), pageSize: resPageSize, total });\n\t\t\tif (code === 0) {\n\t\t\t\tconst newList = list.map((item: MapItem) => ({ ...item, key: item?.categoryId }));\n\t\t\t\tsetTableArticleData(newList);\n\t\t\t}\n\t\t};\n\t\tgetArticleSortList();\n\t}, [current, pageSize, searchArticle]);\n\t\n\t// 表头设置\n\tconst columnsArticle: ColumnsType<DataType> = [\n\t\t{\n\t\t\ttitle: \"教程\",\n\t\t\tdataIndex: \"shortTitle\",\n\t\t\tkey: \"shortTitle\",\n\t\t\trender(value, item) {\n\t\t\t\treturn (\n\t\t\t\t\t<span className=\"cell-text-article\">\n\t\t\t\t\t\t{/* 全部改用 title，shortTitle 在选中的时候带回到输入框 */}\n\t\t\t\t\t\t{item?.title}\n\t\t\t\t\t</span>\n\t\t\t\t);\n\t\t\t}\n\t\t},\n\t\t{\n\t\t\ttitle: \"作者\",\n\t\t\tdataIndex: \"authorName\",\n\t\t\tkey: \"authorName\",\n\t\t\twidth: 60,\n\t\t\trender(value) {\n\t\t\t\treturn <>\n\t\t\t\t\t<Avatar style={{ backgroundColor: '#1890ff', color: '#fff' }}>\n\t\t\t\t\t\t{value?.slice(0, 2) || ''}\n\t\t\t\t\t</Avatar>\n\t\t\t\t</>;\n\t\t\t}\n\t\t},\n\t\t{\n\t\t\ttitle: \"勾选\",\n\t\t\tkey: \"key\",\n\t\t\twidth: 60,\n\t\t\trender: (_, item) => {\n\t\t\t\t{/* 用 checkbox 来负责选中当前行，把选中行的 articleId 带回到下拉框中 */}\n\t\t\t\treturn (\n\t\t\t\t\t\t<Checkbox\n\t\t\t\t\t\t\tchecked={articleSelectValue?.value === item?.articleId}\n\t\t\t\t\t\t\tonChange={(e) => {\n\t\t\t\t\t\t\t\tconst { checked } = e.target;\n\t\t\t\t\t\t\t\tconsole.log(\"选中的状态\", checked);\n\t\t\t\t\t\t\t\tif (checked) {\n\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\tconst { articleId, shortTitle, title } = item;\n\t\t\t\t\t\t\t\t\tconsole.log(\"文章当前的 ID\", articleId, shortTitle, title);\n\n\t\t\t\t\t\t\t\t\t// 选中当前行，把当前行的 articleId 和 title 传给 form 表单\n\t\t\t\t\t\t\t\t\thandleChange({\n\t\t\t\t\t\t\t\t\t\tarticleId: articleId,\n\t\t\t\t\t\t\t\t\t\tshortTitle: shortTitle\n\t\t\t\t\t\t\t\t\t});\n\t\t\n\t\t\t\t\t\t\t\t\t// 把当前行的 articleId 和 title 传给下拉框\n\t\t\t\t\t\t\t\t\tsetArticleSelectValue({value : articleId, label : title});\n\t\t\t\t\t\t\t\t\tconsole.log(\"选中的文章\", articleSelectValue);\n\t\t\t\t\t\t\t\t\t// 关闭下拉框\n\t\t\t\t\t\t\t\t\tsetIsArticleSelectOpen(false);\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t/>\n\t\t\t\t);\n\t\t\t}\n\t\t}\n\t];\n\n\treturn (\n\t\t<Select\n\t\t\tplaceholder=\"请选择教程\"\n\t\t\tlabelInValue={true}\n\t\t\topen={isArticleSelectOpen}\n\t\t\tshowSearch={false}\n\t\t\t// 复选框选中的时候回显\n\t\t\tvalue={articleSelectValue}\n\t\t\t// 下拉框展开时触发，同时上层抽屉关闭时需要关闭\n\t\t\tonDropdownVisibleChange={() => {\n\t\t\t\tconsole.log(\"下拉框展开\");\n\t\t\t\tsetIsArticleSelectOpen(true);\n\t\t\t}}\n\t\t\t// render\n\t\t\tdropdownRender={menu => {\n\t\t\t\treturn (\n\t\t\t\t\t<div>\n\t\t\t\t\t\t<div style={{\n\t\t\t\t\t\t\t\tdisplay: \"flex\",\n\t\t\t\t\t\t\t\tflexWrap: \"nowrap\",\n\t\t\t\t\t\t\t\tpadding: 8\n\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t{/* 增加一个作者的查询条件 */}\n\t\t\t\t\t\t\t<Input\n\t\t\t\t\t\t\t\tplaceholder=\"请输入作者名\"\n\t\t\t\t\t\t\t\tallowClear\n\t\t\t\t\t\t\t\tstyle={{ flex: \"auto\", marginRight: 8 }}\n\t\t\t\t\t\t\t\tonChange={e => {\n\t\t\t\t\t\t\t\t\thandleSearchArticleChange({ userName: e.target.value });\n\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t<Input\n\t\t\t\t\t\t\t\tplaceholder=\"请输入教程名\"\n\t\t\t\t\t\t\t\tallowClear\n\t\t\t\t\t\t\t\tstyle={{ flex: \"auto\" }}\n\t\t\t\t\t\t\t\tonChange={e => {\n\t\t\t\t\t\t\t\t\thandleSearchArticleChange({ title: e.target.value });\n\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t<Button\n\t\t\t\t\t\t\t\ttype=\"primary\"\n\t\t\t\t\t\t\t\tstyle={{ marginLeft: 8 }}\n\t\t\t\t\t\t\t\tonClick={() => {\n\t\t\t\t\t\t\t\t\thandleArticleSearch();\n\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t筛选\n\t\t\t\t\t\t\t</Button>\n\t\t\t\t\t\t\t<Button\n\t\t\t\t\t\t\t\tstyle={{ marginLeft: 8 }}\n\t\t\t\t\t\t\t\ticon={<PoweroffOutlined />}\n\t\t\t\t\t\t\t\tonClick={() => {\n\t\t\t\t\t\t\t\t\tsetIsArticleSelectOpen(false);\n\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t>\t\n\t\t\t\t\t\t\t\t关闭\n\t\t\t\t\t\t\t</Button>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t\t{/* 添加一个Table */}\n\t\t\t\t\t\t<Table \n\t\t\t\t\t\t\tscroll={{ y: 300 }}\n\t\t\t\t\t\t\tcolumns={columnsArticle} \n\t\t\t\t\t\t\tdataSource={tableArticleData} \n\t\t\t\t\t\t\tpagination={paginationInfo} \n\t\t\t\t\t\t\t/>\n\t\t\t\t\t</div>\n\t\t\t\t);\n\t\t\t}}\n\t\t/>\n\t);\n};\n\nexport default TableSelect;\n"
  },
  {
    "path": "src/views/column/article/index.scss",
    "content": "// 增加封面图的样式\n.cover {\n\twidth: 70px;\n\t// height = width * 156 / 110\n\theight: 100px;\n\tbackground-size: cover;\n\tbackground-position: center;\n\tpadding-right: 4px;\n}\n.cell-text {\n  overflow: hidden;\n  text-overflow: ellipsis;\n  display: -webkit-box;\n  -webkit-line-clamp: 2; /* 控制显示的行数 */\n  -webkit-box-orient: vertical;\n}\n\n.sort {\n  height: 100%;\n}\n\n.cell-text-article {\n  overflow: hidden;\n  text-overflow: ellipsis;\n  display: -webkit-box;\n  -webkit-line-clamp: 1; /* 控制显示的行数 */\n  -webkit-box-orient: vertical;\n}\n\n.cover-select {\n\t// 放在下拉框中的封面图\n\twidth: 50px !important;\n\theight: 70px !important;\n\tbackground-size: cover;\n\tbackground-position: center;\n\tmargin-right: 4px;\n}\n\n.ant-table-tbody .ant-table-measure-row {\n\n\ttd {\n\t\tpadding: 0 !important;\n\t}\n}\n"
  },
  {
    "path": "src/views/column/article/index.tsx",
    "content": "/* eslint-disable react/jsx-no-comment-textnodes */\n/* eslint-disable prettier/prettier */\nimport { FC, useCallback, useEffect, useState } from \"react\";\nimport { connect } from \"react-redux\";\nimport { DeleteOutlined, EyeOutlined } from \"@ant-design/icons\";\nimport { Button, Descriptions, Drawer, Form, Image,Input, message, Modal, Space, Table } from \"antd\";\nimport type { ColumnsType } from \"antd/es/table\";\n\nimport { delColumnArticleApi, getColumnArticleListApi, getColumnByNameListApi, updateColumnArticleApi } from \"@/api/modules/column\";\nimport { ContentInterWrap, ContentWrap } from \"@/components/common-wrap\";\nimport { initPagination, IPagination, UpdateEnum } from \"@/enums/common\";\nimport { MapItem } from \"@/typings/common\";\nimport { baseDomain } from \"@/utils/util\";\nimport { getCompleteUrl } from \"@/utils/util\";\nimport DebounceSelect from \"./components/debounceselect/DebounceSelect\";\nimport Search from \"./components/search\";\nimport TableSelect from \"./components/tableselect/TableSelect\";\n\nimport \"./index.scss\";\n\ninterface IProps {}\n\n// 教程文章的数据类型\ninterface DataType {\n\tid: number;\n\tarticleId: string;\n\ttitle: string;\n\tshortTitle: string;\n\tcolumnId: number;\n\tcolumn: string;\n\tsort: number;\n}\n\n// 查询表单接口，定义类型\ninterface ISearchForm {\n\tarticleTitle: string;\n\tcolumnId: number;\n}\n\nexport interface IFormType {\n\tid: number; // 主键id\n\tarticleId: number; // 文章ID\n\ttitle: string; // 文章标题\n\tshortTitle: string; // 文章短标题\n\tcolumnId: number; // 教程ID\n\tcolumn: string; // 教程名\n\tsort: number; // 排序\n}\n\nconst defaultInitForm: IFormType = {\n\tid: -1,\n\tarticleId: -1,\n\ttitle: \"\",\n\tshortTitle: \"\",\n\tcolumnId: -1,\n\tcolumn: \"\",\n\tsort: -1\n};\n\n// 查询表单默认值\nconst defaultSearchForm = {\n\tarticleTitle: \"\",\n\tcolumnId: -1,\n};\n\n// Usage of DebounceSelect\ninterface ColumnValue {\n\tkey: string;\n\tlabel: string;\n\tvalue: string;\n}\n\nconst ColumnArticle: FC<IProps> = props => {\n\n\tconst [formRef] = Form.useForm();\n\t// form值（详情和新增的时候会用到）\n\tconst [form, setForm] = useState<IFormType>(defaultInitForm);\n\n\t// 查询表单\n\tconst [searchForm, setSearchForm] = useState<ISearchForm>(defaultSearchForm);\n\n\t// 修改添加抽屉\n\tconst [isOpenDrawerShow, setIsOpenDrawerShow] = useState<boolean>(false);\n\t// 详情抽屉\n\tconst [isDetailDrawerShow, setIsDetailDrawerShow] = useState<boolean>(false);\n\t// 文章选择下拉框是否打开\n\tconst [isArticleSelectOpen, setIsArticleSelectOpen] = useState<boolean>(false);\n\n\t// 列表数据\n\tconst [tableData, setTableData] = useState<DataType[]>([]);\n\t// 刷新函数\n\tconst [query, setQuery] = useState<number>(0);\n\n\t//当前的状态\n\tconst [status, setStatus] = useState<UpdateEnum>(UpdateEnum.Save);\n\n\t// 分页\n\tconst [pagination, setPagination] = useState<IPagination>(initPagination);\n\tconst { current, pageSize } = pagination;\n\n\t// 详情信息\n\tconst { id, articleId, title, shortTitle, columnId, column, sort } = form;\n\n\tconst detailInfo = [\n\t\t{ label: \"专栏ID\", title: columnId },\n\t\t{ label: \"专栏名\", title: column },\n\t\t{ label: \"文章ID\", title: articleId },\n\t\t{ label: \"文章标题\", title: title },\n\t\t{ label: \"教程ID\", title: id },\n\t\t{ label: \"教程标题\", title: shortTitle },\n\t\t{ label: \"排序\", title: sort }\n\t];\n\n\tconst paginationInfo = {\n\t\tshowSizeChanger: true,\n\t\tshowTotal: (total: any) => `共 ${total || 0} 条`,\n\t\t...pagination,\n\t\tonChange: (current: number, pageSize: number) => {\n\t\t\tsetPagination({ current, pageSize });\n\t\t}\n\t};\n\n\tconst onSure = useCallback(() => {\n\t\tsetQuery(prev => prev + 1);\n\t}, []);\n\n\t// 值改变（新增教程文章时）\n\tconst handleChange = (item: MapItem) => {\n\t\tsetForm({ ...form, ...item });\n\t\tformRef.setFieldsValue({ ...item });\n\t};\n\n\t// 查询表单值改变\n\tconst handleSearchChange = (item: MapItem) => {\n\t\t// 当 status 的值为 -1 时，重新显示\n\t\tsetSearchForm({ ...searchForm, ...item });\n\t\tconsole.log(\"查询条件变化了\",searchForm);\n\t};\n\n\t// 当点击查询按钮的时候触发\n\tconst handleSearch = () => {\n\t\t// 目前是根据文章标题搜索，后面需要加上其他条件\n\t\tconsole.log(\"查询条件\", searchForm);\n\t\tsetPagination({ current: 1, pageSize });\n\t\t// 直接触发刷新\n\t\tonSure();\n\t};\n\n\t// 点击添加的时候触发\n\tconst handleAdd = () => {\n\t\tsetStatus(UpdateEnum.Save);\n\t\tformRef.resetFields();\n\t\tsetIsOpenDrawerShow(true);\n\t};\n\n\t// 关闭抽屉时触发\n\tconst handleCloseDrawer = () => {\n\t\t// 关闭教程的下拉框\n\t\tsetIsArticleSelectOpen(false);\n\t\t// 关闭抽屉\n\t\tsetIsOpenDrawerShow(false);\n\t};\n\n\t// 关闭详情抽屉时触发\n\tconst handleCloseDetailDrawer = () => {\n\t\tsetIsDetailDrawerShow(false);\n\t};\n\t\t\n\t// 删除\n\tconst handleDel = (id: number) => {\n\t\tModal.warning({\n\t\t\ttitle: \"确认删除此专栏的教程吗\",\n\t\t\tcontent: \"删除此专栏的教程后无法恢复，请谨慎操作！\",\n\t\t\tmaskClosable: true,\n\t\t\tclosable: true,\n\t\t\tonOk: async () => {\n\t\t\t\tconst { status } = await delColumnArticleApi(id);\n\t\t\t\tconst { code, msg } = status || {};\n\t\t\t\tif (code === 0) {\n\t\t\t\t\tmessage.success(\"删除成功\");\n\t\t\t\t\tonSure();\n\t\t\t\t} else {\n\t\t\t\t\tmessage.error(msg);\n\t\t\t\t}\n\t\t\t}\n\t\t});\n\t};\n\n\t// 添加教程文章，编辑取消了\n\tconst handleSubmit = async () => {\n\t\tconst values = await formRef.validateFields();\n\t\tconst newValues = {\n\t\t\t...values,\n\t\t\tcolumnId: columnId, \n\t\t};\n\t\tconsole.log(\"提交的值:\", newValues);\n\t\t\n\t\tconst { status: successStatus } = (await updateColumnArticleApi(newValues)) || {};\n\t\tconst { code, msg } = successStatus || {};\n\t\tif (code === 0) {\n\t\t\tsetIsOpenDrawerShow(false);\n\t\t\t// 重置分页\n\t\t\tsetPagination({ current: 1, pageSize });\n\t\t\tonSure();\n\t\t} else {\n\t\t\tmessage.error(msg);\n\t\t}\n\t};\n\n\t// 数据请求\n\tuseEffect(() => {\n\t\tconst getSortList = async () => {\n\t\t\tconst newValues = {\n\t\t\t\t...searchForm,\n\t\t\t\tpageNumber: current, \n\t\t\t\tpageSize,\n\t\t\t};\n\t\t\tconsole.log(\"查询教程列表之前的所有值:\", newValues);\n\t\t\t\n\t\t\tconst { status, result } = await getColumnArticleListApi(newValues);\n\t\t\tconst { code } = status || {};\n\t\t\t// @ts-ignore\n\t\t\tconst { list, pageNum, pageSize: resPageSize, pageTotal, total } = result || {};\n\t\t\tsetPagination({ current: Number(pageNum), pageSize: resPageSize, total });\n\t\t\tif (code === 0) {\n\t\t\t\tconst newList = list.map((item: MapItem) => ({ ...item, key: item?.categoryId }));\n\t\t\t\tsetTableData(newList);\n\t\t\t}\n\t\t};\n\t\tgetSortList();\n\t}, [query, current, pageSize]);\n\n\t// 教程下拉框，可根据教程查询\n\tasync function fetchColumnList(key: string): Promise<ColumnValue[]> {\n\t\tconsole.log('根据教程名查询', key);\n\t\tconst { status, result } = await getColumnByNameListApi(key);\n\t\tconst { code } = status || {};\n\t\t//@ts-ignore\n\t\tconst { items } = result || {};\n\t\tif (code === 0) {\n\t\t\tconst newList = items.map((item: MapItem) => ({\n\t\t\t\tkey: item?.columnId,\n\t\t\t\t// label 这里我想把教程封面也加上\n\t\t\t\tlabel: <div>\n\t\t\t\t\t<Image\n\t\t\t\t\t\tclassName=\"cover-select\"\n\t\t\t\t\t\tsrc={getCompleteUrl(item?.cover)}\n\t\t\t\t\t/>\n\t\t\t\t\t<span>{item?.column}</span>\n\t\t\t\t</div>,\n\t\t\t\tvalue: item?.column\n\t\t\t}));\n\t\t\tconsole.log(\"教程列表\", newList);\n\t\t\treturn newList;\n\t\t}\n\t\t// 没查到数据时，返回空数组\n\t\treturn [];\n\t};\n\n\t// 表头设置\n\tconst columns: ColumnsType<DataType> = [\n\t\t{\n\t\t\ttitle: \"专栏名称\",\n\t\t\tdataIndex: \"column\",\n\t\t\tkey: \"column\",\n\t\t\trender(value, item) {\n\t\t\t\treturn (\n\t\t\t\t\t<a \n\t\t\t\t\t\thref={`${baseDomain}/column/${item?.columnId}/1`}\n\t\t\t\t\t\tclassName=\"cell-text\"\n\t\t\t\t\t\ttarget=\"_blank\" rel=\"noreferrer\">\n\t\t\t\t\t\t{value}\n\t\t\t\t\t</a>\n\t\t\t\t);\n\t\t\t}\n\t\t},\n\t\t{\n\t\t\ttitle: \"教程标题\",\n\t\t\tdataIndex: \"shortTitle\",\n\t\t\tkey: \"shortTitle\",\n\t\t\trender(value, item) {\n\t\t\t\treturn (\n\t\t\t\t\t<a \n\t\t\t\t\t\thref={`${baseDomain}/column/${item?.columnId}/${item?.sort}`}\n\t\t\t\t\t\tclassName=\"cell-text\"\n\t\t\t\t\t\ttarget=\"_blank\" rel=\"noreferrer\">\n\t\t\t\t\t\t{value}\n\t\t\t\t\t</a>\n\t\t\t\t);\n\t\t\t}\n\t\t},\n\t\t{\n\t\t\ttitle: \"排序\",\n\t\t\tdataIndex: \"sort\",\n\t\t\tkey: \"sort\"\n\t\t},\n\t\t{\n\t\t\ttitle: \"操作\",\n\t\t\tkey: \"key\",\n\t\t\twidth: 210,\n\t\t\trender: (_, item) => {\n\t\t\t\t// 删除的时候用\n\t\t\t\tconst { id } = item;\n\t\t\t\treturn (\n\t\t\t\t\t<div className=\"operation-btn\">\n\t\t\t\t\t\t<Button\n\t\t\t\t\t\t\ttype=\"primary\"\n\t\t\t\t\t\t\ticon={<EyeOutlined />}\n\t\t\t\t\t\t\tstyle={{ marginRight: \"10px\" }}\n\t\t\t\t\t\t\tonClick={() => {\n\t\t\t\t\t\t\t\tsetIsDetailDrawerShow(true);\n\t\t\t\t\t\t\t\t// 把所有的值传给 form 表单\n\t\t\t\t\t\t\t\thandleChange({ ...item });\n\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t详情\n\t\t\t\t\t\t</Button>\n\t\t\t\t\t\t<Button \n\t\t\t\t\t\t\ttype=\"primary\" \n\t\t\t\t\t\t\tdanger \n\t\t\t\t\t\t\ticon={<DeleteOutlined />} \n\t\t\t\t\t\t\tonClick={() => handleDel(id)}>\n\t\t\t\t\t\t\t删除\n\t\t\t\t\t\t</Button>\n\t\t\t\t\t</div>\n\t\t\t\t);\n\t\t\t}\n\t\t}\n\t];\n\n\t// 编辑表单\n\tconst reviseDrawerContent = (\n\t\t<Form \n\t\t\tname=\"basic\" \n\t\t\tform={formRef} \n\t\t\tlabelCol={{ span: 4 }} \n\t\t\twrapperCol={{ span: 16 }} \n\t\t\tautoComplete=\"off\">\n\t\t\t<Form.Item \n\t\t\t\tlabel=\"专栏\" \n\t\t\t\tname=\"columnName\" \n\t\t\t\trules={[{ required: true, message: \"请选择专栏!\" }]}>\n\t\t\t\t{/*用下拉框做一个教程的选择 */}\n\t\t\t\t<DebounceSelect\n\t\t\t\t\tallowClear\n\t\t\t\t\tfilterOption={false}\n\t\t\t\t\tplaceholder=\"选择专栏\"\n\t\t\t\t\t// optionLabelProp：回填到选择框的 Option 的属性值，默认是 Option 的子元素。\n\t\t\t\t\t// 比如在子元素需要高亮效果时，此值可以设为 value\n\t\t\t\t\toptionLabelProp=\"value\"\n\t\t\t\t\t// 是否在输入框聚焦时自动调用搜索方法\n\t\t\t\t\tshowSearch={true}\n\t\t\t\t\tonChange={\n\t\t\t\t\t\t(value, option) => {\n\t\t\t\t\t\t\tconsole.log(\"添加教程文章时教程搜索的值改变\", value, option)\n\t\t\t\t\t\tif(option)\n\t\t\t\t\t\t\t//@ts-ignore\n\t\t\t\t\t\t\thandleChange({ columnId: option.key, columnName: option.value})\n\t\t\t\t\t\telse\n\t\t\t\t\t\t\thandleChange({ columnId: -1 })\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t\tfetchOptions={fetchColumnList}\n\t\t\t\t/>\n\t\t\t</Form.Item>\n\n\t\t\t<Form.Item \n\t\t\t\tlabel=\"教程\"\n\t\t\t\tname=\"articleId\"\n\t\t\t\trules={[{ required: true, message: \"请选择教程!\" }]}\n\t\t\t\t>\n\t\t\t\t<TableSelect \n\t\t\t\t\tisArticleSelectOpen={isArticleSelectOpen}\n\t\t\t\t\tsetIsArticleSelectOpen={setIsArticleSelectOpen}\n\t\t\t\t\thandleChange={handleChange}\n\t\t\t\t\t/>\n\t\t\t</Form.Item>\n\n\t\t\t<Form.Item \n\t\t\t\tlabel=\"标题\"\n\t\t\t\tname=\"shortTitle\"\n\t\t\t\trules={[{ required: true, message: \"请输入标题!\" }]}\n\t\t\t\t>\n\t\t\t\t<Input\n\t\t\t\t\tallowClear\n\t\t\t\t\tplaceholder=\"请输入标题\"\n\t\t\t\t\tvalue={shortTitle}\n\t\t\t\t\tonChange={e => handleChange({ shortTitle: e.target.value })}\n\t\t\t\t/>\n\t\t\t</Form.Item>\n\t\t\t\n\t\t</Form>\n\t);\n\n\treturn (\n\t\t<div className=\"ColumnArticle\">\n\t\t\t<ContentWrap>\n\t\t\t\t{/* 搜索 */}\n\t\t\t\t<Search\n\t\t\t\t\thandleSearchChange={handleSearchChange}\n\t\t\t\t\tfetchColumnList={fetchColumnList}\n\t\t\t\t\thandleSearch={handleSearch} \n\t\t\t\t\thandleAdd={handleAdd}\n\t\t\t\t\t/>\n\t\t\t\t{/* 表格 */}\n\t\t\t\t<ContentInterWrap>\n\t\t\t\t\t<Table columns={columns} dataSource={tableData} pagination={paginationInfo} />\n\t\t\t\t</ContentInterWrap>\n\t\t\t</ContentWrap>\n\t\t\t{/* 抽屉 */}\n\t\t\t<Drawer \n\t\t\t\ttitle=\"详情\" \n\t\t\t\tplacement=\"right\" \n\t\t\t\tonClose={handleCloseDetailDrawer} \n\t\t\t\topen={isDetailDrawerShow}>\n\t\t\t\t<Descriptions column={1} labelStyle={{ width: \"100px\" }}>\n\t\t\t\t\t{detailInfo.map(({ label, title }) => (\n\t\t\t\t\t\t<Descriptions.Item label={label} key={label}>\n\t\t\t\t\t\t\t{title !== 0 ? title || \"-\" : 0}\n\t\t\t\t\t\t</Descriptions.Item>\n\t\t\t\t\t))}\n\t\t\t\t</Descriptions>\n\t\t\t</Drawer>\n\t\t\t{/* 把弹窗修改为抽屉 */}\n\t\t\t<Drawer \n\t\t\t\ttitle=\"添加\" \n\t\t\t\tsize=\"large\"\n\t\t\t\tplacement=\"right\"\n\t\t\t\textra={\n          <Space>\n            <Button onClick={handleCloseDrawer}>取消</Button>\n            <Button type=\"primary\" onClick={handleSubmit}>\n              OK\n            </Button>\n          </Space>\n        }\n\t\t\t\tonClose={handleCloseDrawer} \n\t\t\t\topen={isOpenDrawerShow}>\n\t\t\t\t{reviseDrawerContent}\n\t\t\t</Drawer>\n\t\t</div>\n\t);\n};\n\nconst mapStateToProps = (state: any) => state.disc.disc;\nconst mapDispatchToProps = {};\nexport default connect(mapStateToProps, mapDispatchToProps)(ColumnArticle);\n\n"
  },
  {
    "path": "src/views/column/setting/articlesort/index.scss",
    "content": ".rotated-icon {\n\ttransform: rotate(90deg); /* 或任何您需要的角度 */\n}\n\n.group-tree-container {\n\tmargin: 20px;\n\tpadding: 20px;\n\tbackground-color: #fff;\n\tborder-radius: 4px;\n\tbox-shadow: 0 2px 8px rgb(0 0 0 / 10%);\n}\n\n.group-tree {\n\tmargin: 20px;\n\tpadding: 20px;\n\tbackground-color: #fff;\n\tborder-radius: 4px;\n\tbox-shadow: 0 2px 8px rgb(0 0 0 / 10%);\n}\n\n.group-tree .ant-tree-node-content-wrapper {\n\tdisplay: flex;\n\talign-items: center;\n\tjustify-content: space-between;\n}\n\n.group-tree .ant-tree-node-content-wrapper span {\n\tmargin-right: 8px;\n}\n\n// 分组节点样式\n.group-tree .ant-tree-treenode .ant-tree-node-content-wrapper.group-node {\n\tcolor: #1890ff;\n\tfont-weight: 500;\n}\n\n// 文章节点样式\n.group-tree .ant-tree-treenode .ant-tree-node-content-wrapper.article-node {\n\tcolor: #555;\n\tfont-weight: 400;\n\tfont-style: italic;\n}\n\n// 分组节点标题容器\n.group-node-title {\n\tdisplay: flex;\n\talign-items: center;\n\tjustify-content: space-between;\n\twidth: 100%;\n}\n\n// 分组节点按钮容器\n.group-node-buttons {\n\tdisplay: flex;\n\tgap: 4px;\n\topacity: 0;\n\ttransition: opacity 0.2s;\n}\n\n// 鼠标悬停时显示按钮\n.group-tree .ant-tree-treenode:hover .group-node-buttons {\n\topacity: 1;\n}\n\n// 空状态样式\n.group-tree-empty {\n\ttext-align: center;\n\tpadding: 40px 20px;\n\n\tp {\n\t\tmargin-bottom: 20px;\n\t\tcolor: #666;\n\t}\n\n\t.add-group-form {\n\t\tdisplay: flex;\n\t\tgap: 10px;\n\t\tjustify-content: center;\n\t\talign-items: center;\n\n\t\t.ant-input {\n\t\t\twidth: 200px;\n\t\t}\n\n\t\t.ant-btn {\n\t\t\tflex-shrink: 0;\n\t\t}\n\t}\n}\n\n// 树底部添加分组按钮\n.add-group-button {\n\tmargin-top: 20px;\n\twidth: 100%;\n\tdisplay: flex;\n\tjustify-content: center;\n}\n"
  },
  {
    "path": "src/views/column/setting/articlesort/index.tsx",
    "content": "/* eslint-disable react/jsx-no-comment-textnodes */\n/* eslint-disable prettier/prettier */\nimport { FC, useCallback, useEffect, useState } from \"react\";\nimport React from \"react\";\nimport { connect } from \"react-redux\";\nimport { useLocation, useNavigate } from \"react-router-dom\";\nimport { DeleteOutlined, EditOutlined, EyeOutlined, PlusOutlined, SwapOutlined } from \"@ant-design/icons\";\nimport type { DragEndEvent } from \"@dnd-kit/core\";\nimport { DndContext, PointerSensor, useSensor, useSensors } from \"@dnd-kit/core\";\nimport { restrictToVerticalAxis } from \"@dnd-kit/modifiers\";\nimport { SortableContext, useSortable, verticalListSortingStrategy } from \"@dnd-kit/sortable\";\nimport { CSS } from \"@dnd-kit/utilities\";\nimport {\n\tButton,\n\tDescriptions,\n\tDrawer,\n\tForm,\n\tInput,\n\tInputNumber,\n\tmessage,\n\tModal,\n\tSpace,\n\tTable,\n\tTooltip,\n\tTree,\n\tTreeSelect\n} from \"antd\";\nimport type { ColumnsType } from \"antd/es/table\";\nimport type { DataNode } from \"antd/es/tree\";\nimport type { TreeProps } from \"antd/es/tree\";\n\nimport {\n\tdelColumnArticleApi,\n\tdeleteGroupApi,\n\tgetColumnArticleListApi,\n\tgetColumnGroupListApi,\n\tsortColumnArticleApi,\n\tsortColumnArticleByIDApi,\n\tupdateColumnArticleApi,\n\tupdateGroupApi\n} from \"@/api/modules/column\";\nimport { ContentInterWrap, ContentWrap } from \"@/components/common-wrap\";\nimport { initPagination, IPagination, UpdateEnum } from \"@/enums/common\";\nimport { MapItem } from \"@/typings/common\";\nimport { baseDomain } from \"@/utils/util\";\nimport TableSelect from \"@/views/column/article/components/tableselect/TableSelect\";\nimport Search from \"./search\";\n\nimport \"./index.scss\";\n\ninterface IProps {}\n\nexport interface IGroupFormType {\n\tid: number; // 为0时，保存；非0 更新\n\tcolumnId: number; // 专栏 id\n\tparentGroupId: number; // 父分组\n\ttitle: string; // 显示文案\n\tsection: number; // 排序\n}\n\n// 分组数据\ninterface GroupData {\n\tcolumnId: number;\n\tgroupId: number;\n\tparentGroupId: number;\n\ttitle: string;\n\tsection: number;\n\tchildren: GroupData[];\n}\n\n// 教程文章的数据类型\ninterface DataType {\n\tkey: string;\n\tid: number;\n\tarticleId: string;\n\ttitle: string;\n\tshortTitle: string;\n\tcolumnId: number;\n\tcolumn: string;\n\tsort: number;\n\tgroupId: number;\n\tgroupName: string;\n}\n\n// 查询表单接口，定义类型\ninterface ISearchForm {\n\tarticleTitle: string;\n\tcolumnId: number;\n}\n\nexport interface IFormType {\n\tid: number; // 主键id\n\tarticleId: number; // 文章ID\n\ttitle: string; // 文章标题\n\tshortTitle: string; // 文章短标题\n\tcolumnId: number; // 教程ID\n\tcolumn: string; // 教程名\n\tsort: number; // 排序\n\tgroupId: number; // 分组id\n\tgroupName: string; // 分组名\n}\n\nexport interface IArticleSortFormType {\n\tid: number; // 主键id\n\tarticleId: number; // 文章ID\n\tsort: number; // 排序\n}\n\nconst defaulArticleSorttInitForm: IArticleSortFormType = {\n\tid: -1,\n\tarticleId: -1,\n\tsort: -1\n};\n\nconst defaultInitForm: IFormType = {\n\tid: -1,\n\tarticleId: -1,\n\ttitle: \"\",\n\tshortTitle: \"\",\n\tcolumnId: -1,\n\tcolumn: \"\",\n\tsort: -1,\n\tgroupId: 0,\n\tgroupName: \"\"\n};\n\n// 查询表单默认值\nconst defaultSearchForm = {\n\tarticleTitle: \"\",\n\tcolumnId: -1\n};\n\nconst ColumnArticle: FC<IProps> = props => {\n\tconst [formRef] = Form.useForm();\n\t// 调整文章书序的表单\n\tconst [articleSortFormRef] = Form.useForm();\n\t// 调整文章分组的表单\n\tconst [articleGroupFormRef] = Form.useForm();\n\t// form值（详情和新增的时候会用到）\n\tconst [form, setForm] = useState<IFormType>(defaultInitForm);\n\t// form值（调整文章顺序的表单值变化时保存）\n\tconst [articleSortForm, setArticleSortForm] = useState<IArticleSortFormType>(defaulArticleSorttInitForm);\n\t// 查询表单\n\tconst [searchForm, setSearchForm] = useState<ISearchForm>(defaultSearchForm);\n\n\t// 修改添加抽屉\n\tconst [isOpenDrawerShow, setIsOpenDrawerShow] = useState<boolean>(false);\n\t// 详情抽屉\n\tconst [isDetailDrawerShow, setIsDetailDrawerShow] = useState<boolean>(false);\n\t// 调整顺序抽屉\n\tconst [isSortDrawerShow, setIsSortDrawerShow] = useState<boolean>(false);\n\t// 分组管理的抽屉\n\tconst [isGroupDrawerShow, setIsGroupDrawerShow] = useState<boolean>(false);\n\t// 编辑文章抽屉\n\tconst [isEditDrawerOpen, setIsEditDrawerOpen] = useState<boolean>(false);\n\t// 当前编辑文章数据\n\tconst [currentArticle, setCurrentArticle] = useState<DataType | null>(null);\n\t// 文章选择下拉框是否打开\n\tconst [isArticleSelectOpen, setIsArticleSelectOpen] = useState<boolean>(false);\n\n\t// 文章分组下拉框\n\tconst [isArticleGroupSelectOpen, setIsArticleGroupSelectOpen] = useState<boolean>(false);\n\n\t// 列表数据\n\tconst [tableData, setTableData] = useState<DataType[]>([]);\n\t// 分组数据\n\tconst [groupTree, setGroupTree] = useState<GroupData[]>([]);\n\t// 刷新函数\n\tconst [query, setQuery] = useState<number>(0);\n\n\t// 当前的状态\n\tconst [status, setStatus] = useState<UpdateEnum>(UpdateEnum.Save);\n\t// 控制添加子分组弹窗显示\n\tconst [isAddSubGroupModalVisible, setIsAddSubGroupModalVisible] = useState(false);\n\t// 子分组名称\n\tconst [subGroupName, setSubGroupName] = useState(\"\");\n\n\t// 分页\n\tconst [pagination, setPagination] = useState<IPagination>(initPagination);\n\tconst { current, pageSize } = pagination;\n\n\tconst location = useLocation();\n\tconst navigate = useNavigate();\n\tconst { columnId: columnIdParam } = location.state || {};\n\n\t// 拖拽相关 1\n\tinterface RowProps extends React.HTMLAttributes<HTMLTableRowElement> {\n\t\t\"data-row-key\": string;\n\t}\n\n\t// 拖拽相关 2\n\tconst Row = (props: RowProps) => {\n\t\tconst { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({\n\t\t\tid: props[\"data-row-key\"]\n\t\t});\n\n\t\tconst style: React.CSSProperties = {\n\t\t\t...props.style,\n\t\t\ttransform: CSS.Transform.toString(transform && { ...transform, scaleY: 1 }),\n\t\t\ttransition,\n\t\t\tcursor: \"move\",\n\t\t\t...(isDragging ? { position: \"relative\", zIndex: 9999 } : {})\n\t\t};\n\n\t\treturn <tr {...props} ref={setNodeRef} style={style} {...attributes} {...listeners} />;\n\t};\n\n\t// 详情信息\n\tconst { id, articleId, title, shortTitle, columnId, column, sort } = form;\n\n\tconst detailInfo = [\n\t\t{ label: \"专栏ID\", title: columnId },\n\t\t{ label: \"专栏名\", title: column },\n\t\t{ label: \"文章ID\", title: articleId },\n\t\t{ label: \"文章标题\", title: title },\n\t\t{ label: \"教程ID\", title: id },\n\t\t{ label: \"教程标题\", title: shortTitle },\n\t\t{ label: \"排序\", title: sort }\n\t];\n\n\tconst paginationInfo = {\n\t\tshowSizeChanger: true,\n\t\tshowTotal: (total: any) => `共 ${total || 0} 条`,\n\t\t...pagination,\n\t\tonChange: (current: number, pageSize: number) => {\n\t\t\tsetPagination({ current, pageSize });\n\t\t}\n\t};\n\n\t// 拖拽相关 3\n\tconst sensors = useSensors(\n\t\tuseSensor(PointerSensor, {\n\t\t\tactivationConstraint: {\n\t\t\t\t// https://docs.dndkit.com/api-documentation/sensors/pointer#activation-constraints\n\t\t\t\tdistance: 1\n\t\t\t}\n\t\t})\n\t);\n\n\tconst onSure = useCallback(() => {\n\t\tsetQuery(prev => prev + 1);\n\t}, []);\n\n\t// 值改变（新增教程文章时，老的做法，将 formRef 放到了这里，不太好）\n\tconst handleChange = (item: MapItem) => {\n\t\tconsole.log(\"选中的内容: \", item);\n\t\tsetForm({ ...form, ...item });\n\t\tformRef.setFieldsValue({ ...item });\n\t};\n\n\t// 值改变（调整顺序输入框发生变化时）\n\tconst handleArticleSortChange = (item: MapItem) => {\n\t\tsetArticleSortForm({ ...articleSortForm, ...item });\n\t};\n\n\t// 查询表单值改变\n\tconst handleSearchChange = (item: MapItem) => {\n\t\t// 当 status 的值为 -1 时，重新显示\n\t\tsetSearchForm({ ...searchForm, ...item });\n\t\tconsole.log(\"查询条件变化了\", searchForm);\n\t};\n\n\t// 当点击查询按钮的时候触发\n\tconst handleSearch = () => {\n\t\t// 目前是根据文章标题搜索，后面需要加上其他条件\n\t\tconsole.log(\"查询条件\", searchForm);\n\t\tsetPagination({ current: 1, pageSize });\n\t};\n\n\t// 点击添加的时候触发\n\tconst handleAdd = () => {\n\t\tsetStatus(UpdateEnum.Save);\n\t\tformRef.resetFields();\n\t\tsetIsOpenDrawerShow(true);\n\t};\n\n\t// 点击分组管理的时候触发\n\tconst handleGroup = () => {\n\t\tsetIsGroupDrawerShow(true);\n\t};\n\n\tconst goBack = () => {\n\t\tnavigate(-1); // 返回上一个页面\n\t};\n\n\t// 关闭抽屉时触发\n\tconst handleCloseDrawer = () => {\n\t\t// 关闭教程的下拉框\n\t\tsetIsArticleSelectOpen(false);\n\t\t// 关闭抽屉\n\t\tsetIsOpenDrawerShow(false);\n\t\t// 关闭调整顺序的抽屉\n\t\tsetIsSortDrawerShow(false);\n\t\t// 关闭详情抽屉\n\t\tsetIsDetailDrawerShow(false);\n\t\t// 关闭分组抽屉\n\t\tsetIsGroupDrawerShow(false);\n\t};\n\n\t// 删除\n\tconst handleDel = (id: number) => {\n\t\tModal.warning({\n\t\t\ttitle: \"确认删除此专栏的教程吗\",\n\t\t\tcontent: \"删除此专栏的教程后无法恢复，请谨慎操作！\",\n\t\t\tmaskClosable: true,\n\t\t\tclosable: true,\n\t\t\tonOk: async () => {\n\t\t\t\tconst { status } = await delColumnArticleApi(id);\n\t\t\t\tconst { code, msg } = status || {};\n\t\t\t\tif (code === 0) {\n\t\t\t\t\tmessage.success(\"删除成功\");\n\t\t\t\t\tonSure();\n\t\t\t\t} else {\n\t\t\t\t\tmessage.error(msg);\n\t\t\t\t}\n\t\t\t}\n\t\t});\n\t};\n\n\tconst handleUpdateChooseGroup = (item: string | number | null) => {\n\t\tif (currentArticle) {\n\t\t\tcurrentArticle.groupId = Number(item || 0);\n\t\t\tsetCurrentArticle(currentArticle);\n\t\t}\n\t};\n\n\tconst handleUpdateArticleGroup = async () => {\n\t\t// 更新文章分组信息\n\t\tconsole.log(\"准备更新教程信息:\", currentArticle);\n\t\tif (!currentArticle) {\n\t\t\treturn;\n\t\t}\n\n\t\t// 更新文章的分组信息\n\t\tconst values = await articleGroupFormRef.validateFields();\n\t\tconst newValues = {\n\t\t\t...values,\n\t\t\t...currentArticle,\n\t\t\tid: currentArticle?.id,\n\t\t\tgroupId: currentArticle?.groupId\n\t\t};\n\t\tconst { status: successStatus } = (await updateColumnArticleApi(newValues)) || {};\n\t\tconst { code, msg } = successStatus || {};\n\t\tif (code === 0) {\n\t\t\t// 重置分页\n\t\t\tconsole.log(\"重置分页\");\n\t\t\tsetIsOpenDrawerShow(false);\n\t\t\tsetPagination({ current: 1, pageSize });\n\t\t\t// 由于分页没有变化，所以只能是通过 query 来刷新\n\t\t\tonSure();\n\n\t\t\tsetCurrentArticle(null);\n\t\t\tsetIsEditDrawerOpen(false);\n\t\t} else {\n\t\t\tmessage.error(msg);\n\t\t}\n\t};\n\n\t// 添加教程文章，编辑取消了\n\tconst handleSubmit = async () => {\n\t\tconst values = await formRef.validateFields();\n\t\tconst newValues = {\n\t\t\t...values,\n\t\t\tcolumnId: columnIdParam\n\t\t};\n\t\tconsole.log(\"提交的值:\", newValues);\n\n\t\tconst { status: successStatus } = (await updateColumnArticleApi(newValues)) || {};\n\t\tconst { code, msg } = successStatus || {};\n\t\tif (code === 0) {\n\t\t\t// 重置分页\n\t\t\tconsole.log(\"重置分页\");\n\t\t\tsetIsOpenDrawerShow(false);\n\t\t\tsetPagination({ current: 1, pageSize });\n\t\t\t// 由于分页没有变化，所以只能是通过 query 来刷新\n\t\t\tonSure();\n\t\t} else {\n\t\t\tmessage.error(msg);\n\t\t}\n\t};\n\n\t// 调整顺序的 submit\n\tconst handleSortByIDSubmit = async () => {\n\t\tconst values = await articleSortFormRef.validateFields();\n\t\tconst newValues = {\n\t\t\t...values,\n\t\t\tid: articleSortForm.id\n\t\t};\n\t\tconsole.log(\"提交的值:\", newValues);\n\n\t\tconst { status: successStatus } = (await sortColumnArticleByIDApi(newValues)) || {};\n\t\tconst { code, msg } = successStatus || {};\n\t\tif (code === 0) {\n\t\t\tsetIsSortDrawerShow(false);\n\t\t\tonSure();\n\t\t} else {\n\t\t\tmessage.error(msg);\n\t\t}\n\t};\n\n\tconst onDragEnd = async ({ active, over }: DragEndEvent) => {\n\t\tconsole.log(\"active over\", active, over);\n\t\tif (over != null && active.id !== over.id) {\n\t\t\t// 此时，我需要把两个 ID 发送到服务器端等待更新后再在前端调整顺序\n\t\t\tconst { status: successStatus } = (await sortColumnArticleApi(Number(active.id), Number(over.id))) || {};\n\t\t\tconst { code, msg } = successStatus || {};\n\t\t\tif (code === 0) {\n\t\t\t\t// 重新查询一次\n\t\t\t\tonSure();\n\t\t\t} else {\n\t\t\t\tmessage.error(msg);\n\t\t\t}\n\t\t}\n\t};\n\n\tconst getGroupList = async () => {\n\t\tconst { status, result } = await getColumnGroupListApi(columnIdParam);\n\t\tconst { code } = status || {};\n\t\tif (code === 0) {\n\t\t\t// 请求成功的场景\n\t\t\tconst newList = (result as GroupData[]).map((item: GroupData) => ({ ...item, key: item?.groupId }));\n\t\t\tsetGroupTree(newList as unknown as GroupData[]);\n\t\t}\n\t};\n\n\t// 数据请求\n\tuseEffect(() => {\n\t\tconst getSortList = async () => {\n\t\t\tconst newValues = {\n\t\t\t\t...searchForm,\n\t\t\t\tpageNumber: current,\n\t\t\t\tpageSize,\n\t\t\t\tcolumnId: columnIdParam\n\t\t\t};\n\t\t\tconsole.log(\"查询教程列表之前的所有值:\", newValues);\n\n\t\t\tconst { status, result } = await getColumnArticleListApi(newValues);\n\t\t\tconst { code } = status || {};\n\t\t\t// @ts-ignore\n\t\t\tconst { list, pageNum, pageSize: resPageSize, pageTotal, total } = result || {};\n\t\t\tsetPagination({ current: Number(pageNum), pageSize: resPageSize, total });\n\t\t\tconsole.log(\"设置分页后，current 和 pagesize 都没有变化，所以不会重新请求:\", current, pageSize);\n\t\t\tif (code === 0) {\n\t\t\t\tconst newList = list.map((item: MapItem) => ({ ...item, key: item?.id }));\n\t\t\t\tconsole.log(\"教程列表\", newList);\n\t\t\t\tsetTableData(newList);\n\t\t\t}\n\t\t};\n\n\t\tgetSortList();\n\t\tgetGroupList();\n\t}, [query, current, pageSize]);\n\n\t// 表头设置\n\tconst columns: ColumnsType<DataType> = [\n\t\t{\n\t\t\ttitle: \"排序\",\n\t\t\tdataIndex: \"sort\",\n\t\t\tkey: \"sort\"\n\t\t},\n\t\t{\n\t\t\ttitle: \"专栏名称\",\n\t\t\tdataIndex: \"column\",\n\t\t\tkey: \"column\"\n\t\t},\n\t\t{\n\t\t\ttitle: \"文章分组\",\n\t\t\tdataIndex: \"groupId\",\n\t\t\tkey: \"groupId\",\n\t\t\trender(value, item) {\n\t\t\t\treturn (\n\t\t\t\t\t<div style={{ display: \"flex\", alignItems: \"center\" }}>\n\t\t\t\t\t\t<text>\n\t\t\t\t\t\t\t{\" \"}\n\t\t\t\t\t\t\t{item.groupId}-{item.groupName}{\" \"}\n\t\t\t\t\t\t</text>\n\t\t\t\t\t\t<Button\n\t\t\t\t\t\t\ttype=\"link\"\n\t\t\t\t\t\t\tonClick={() => {\n\t\t\t\t\t\t\t\tsetCurrentArticle(item);\n\t\t\t\t\t\t\t\tsetIsEditDrawerOpen(true);\n\t\t\t\t\t\t\t\tarticleGroupFormRef.setFieldsValue({ groupId: item.groupId });\n\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t编辑\n\t\t\t\t\t\t</Button>\n\t\t\t\t\t</div>\n\t\t\t\t);\n\t\t\t}\n\t\t},\n\t\t{\n\t\t\ttitle: \"教程ID\",\n\t\t\tdataIndex: \"articleId\",\n\t\t\tkey: \"articleId\"\n\t\t},\n\t\t{\n\t\t\ttitle: \"教程标题\",\n\t\t\tdataIndex: \"shortTitle\",\n\t\t\tkey: \"shortTitle\",\n\t\t\trender(value, item) {\n\t\t\t\treturn (\n\t\t\t\t\t<a href={`${baseDomain}/column/${item?.columnId}/${item?.sort}`} className=\"cell-text\" target=\"_blank\" rel=\"noreferrer\">\n\t\t\t\t\t\t{value}\n\t\t\t\t\t</a>\n\t\t\t\t);\n\t\t\t}\n\t\t},\n\t\t{\n\t\t\ttitle: \"操作\",\n\t\t\twidth: 150,\n\t\t\trender: (_, item) => {\n\t\t\t\t// 删除的时候用\n\t\t\t\tconst { id, sort } = item;\n\t\t\t\treturn (\n\t\t\t\t\t<div className=\"operation-btn\">\n\t\t\t\t\t\t<Tooltip title=\"详情\">\n\t\t\t\t\t\t\t<Button\n\t\t\t\t\t\t\t\ttype=\"primary\"\n\t\t\t\t\t\t\t\ticon={<EyeOutlined />}\n\t\t\t\t\t\t\t\tstyle={{ marginRight: \"10px\" }}\n\t\t\t\t\t\t\t\tonClick={() => {\n\t\t\t\t\t\t\t\t\tsetIsDetailDrawerShow(true);\n\t\t\t\t\t\t\t\t\t// 把所有的值传给 form 表单\n\t\t\t\t\t\t\t\t\thandleChange({ ...item });\n\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t></Button>\n\t\t\t\t\t\t</Tooltip>\n\t\t\t\t\t\t<Tooltip title=\"调整顺序\">\n\t\t\t\t\t\t\t<Button\n\t\t\t\t\t\t\t\ttype=\"primary\"\n\t\t\t\t\t\t\t\ticon={<SwapOutlined className=\"rotated-icon\" />}\n\t\t\t\t\t\t\t\tstyle={{ marginRight: \"10px\" }}\n\t\t\t\t\t\t\t\tonClick={() => {\n\t\t\t\t\t\t\t\t\tsetIsSortDrawerShow(true);\n\t\t\t\t\t\t\t\t\t// 把 id 和 sort 传给调整顺序的表单\n\t\t\t\t\t\t\t\t\thandleArticleSortChange({ id, sort });\n\t\t\t\t\t\t\t\t\tarticleSortFormRef.setFieldsValue({ sort });\n\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t></Button>\n\t\t\t\t\t\t</Tooltip>\n\t\t\t\t\t\t<Tooltip title=\"删除\">\n\t\t\t\t\t\t\t<Button type=\"primary\" danger icon={<DeleteOutlined />} onClick={() => handleDel(id)}></Button>\n\t\t\t\t\t\t</Tooltip>\n\t\t\t\t\t</div>\n\t\t\t\t);\n\t\t\t}\n\t\t}\n\t];\n\n\t// 调整顺序的表单\n\tconst articleSortContent = (\n\t\t<Form autoComplete=\"off\" form={articleSortFormRef}>\n\t\t\t<Form.Item label=\"设置文章顺序为\" name=\"sort\" rules={[{ required: true, message: \"请输入文章顺序\" }]}>\n\t\t\t\t<InputNumber min={1} size=\"small\" onChange={value => handleArticleSortChange({ sort: value })} />\n\t\t\t</Form.Item>\n\t\t</Form>\n\t);\n\n\t// 递归构建树节点\n\tconst buildTreeNodes = (groups: GroupData[]): DataNode[] => {\n\t\treturn groups.map(group => {\n\t\t\tconst childrenNodes: DataNode[] = [];\n\n\t\t\t// 添加子分组\n\t\t\tif (group.children && group.children.length > 0) {\n\t\t\t\tchildrenNodes.push(...buildTreeNodes(group.children));\n\t\t\t}\n\n\t\t\treturn {\n\t\t\t\tkey: `group-${group.groupId}`,\n\t\t\t\ttitle: (\n\t\t\t\t\t<div className=\"group-node-title\">\n\t\t\t\t\t\t<span>\n\t\t\t\t\t\t\t{\" \"}\n\t\t\t\t\t\t\t{group.groupId} : {group.title}\n\t\t\t\t\t\t</span>\n\t\t\t\t\t\t<div className=\"group-node-buttons\">\n\t\t\t\t\t\t\t<Button\n\t\t\t\t\t\t\t\ttype=\"text\"\n\t\t\t\t\t\t\t\tsize=\"small\"\n\t\t\t\t\t\t\t\ticon={<PlusOutlined />}\n\t\t\t\t\t\t\t\tonClick={e => {\n\t\t\t\t\t\t\t\t\te.stopPropagation();\n\t\t\t\t\t\t\t\t\tsetCurrentGroupId(0);\n\t\t\t\t\t\t\t\t\tsetParentGroupId(group.groupId);\n\t\t\t\t\t\t\t\t\tsetSubGroupName(\"\");\n\t\t\t\t\t\t\t\t\tsetNewGroupName(\"\");\n\t\t\t\t\t\t\t\t\tsetIsAddSubGroupModalVisible(true);\n\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t<Button\n\t\t\t\t\t\t\t\ttype=\"text\"\n\t\t\t\t\t\t\t\tsize=\"small\"\n\t\t\t\t\t\t\t\ticon={<EditOutlined />}\n\t\t\t\t\t\t\t\tonClick={e => {\n\t\t\t\t\t\t\t\t\te.stopPropagation();\n\t\t\t\t\t\t\t\t\tsetCurrentGroupId(group.groupId);\n\t\t\t\t\t\t\t\t\tsetParentGroupId(group.parentGroupId);\n\t\t\t\t\t\t\t\t\tsetSubGroupName(group.title);\n\t\t\t\t\t\t\t\t\tsetNewGroupName(\"\");\n\t\t\t\t\t\t\t\t\tsetIsAddSubGroupModalVisible(true);\n\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t/>\n\n\t\t\t\t\t\t\t<Button\n\t\t\t\t\t\t\t\ttype=\"text\"\n\t\t\t\t\t\t\t\tsize=\"small\"\n\t\t\t\t\t\t\t\ticon={<DeleteOutlined />}\n\t\t\t\t\t\t\t\tonClick={e => {\n\t\t\t\t\t\t\t\t\te.stopPropagation();\n\t\t\t\t\t\t\t\t\thandleDeleteGroup(group.groupId);\n\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t</div>\n\t\t\t\t),\n\t\t\t\ticon: <span className=\"group-icon\">📁</span>,\n\t\t\t\tchildren: childrenNodes.length > 0 ? childrenNodes : undefined,\n\t\t\t\tclassName: \"group-node\"\n\t\t\t};\n\t\t});\n\t};\n\n\tconst treeData = buildTreeNodes(groupTree);\n\n\t// 控制节点展开/收起的状态\n\tconst [expandedKeys, setExpandedKeys] = useState<React.Key[]>([]);\n\tconst [autoExpandParent, setAutoExpandParent] = useState(true);\n\tconst [showAddGroup, setShowAddGroup] = useState(false);\n\tconst [newGroupName, setNewGroupName] = useState(\"\");\n\tconst [currentGroupId, setCurrentGroupId] = useState(0);\n\tconst [parentGroupId, setParentGroupId] = useState(0);\n\n\t// 处理节点展开/收起\n\tconst onExpand = (expandedKeysValue: React.Key[]) => {\n\t\tconsole.log(\"onExpand\", expandedKeysValue);\n\t\tsetExpandedKeys(expandedKeysValue);\n\t\tsetAutoExpandParent(false);\n\t};\n\n\t// 处理节点选择\n\tconst onSelect = (selectedKeysValue: React.Key[], info: any) => {\n\t\tconsole.log(\"selected\", selectedKeysValue, info);\n\t\t// 如果点击的是分组节点，则切换展开/收起状态\n\t\tif (info.node.key.startsWith(\"group-\")) {\n\t\t\tconst key = info.node.key;\n\t\t\tif (expandedKeys.includes(key)) {\n\t\t\t\t// 如果已经展开，则收起\n\t\t\t\tsetExpandedKeys(expandedKeys.filter(k => k !== key));\n\t\t\t} else {\n\t\t\t\t// 如果已经收起，则展开\n\t\t\t\tsetExpandedKeys([...expandedKeys, key]);\n\t\t\t}\n\t\t}\n\t};\n\n\tconst handleDrop: TreeProps[\"onDrop\"] = async info => {\n\t\tconsole.log(\"info\", info);\n\t\tconst dropKey = info.node.key;\n\t\tconst dragKey = info.dragNode.key;\n\t\t// - -1 ：拖拽到目标节点上方\n\t\t// - 0 ：拖拽到目标节点内部（成为子节点）\n\t\t// - 1 ：拖拽到目标节点下方\n\t\tconst dropPosition = info.dropPosition;\n\t\t// - true ：放置在目标节点旁边（同级节点）\n\t\t// - false ：放置在目标节点内部（子节点）\n\t\tconst dropToGap = info.dropToGap;\n\n\t\tconst targetNode = info.node;\n\n\t\t// 解析groupId（从key中提取数字部分）\n\t\tconst getGroupId = (key: string) => parseInt(key.replace(\"group-\", \"\"), 10);\n\t\t// 目标节点\n\t\t\tconst targetGroupId = getGroupId(String(dropKey));\n\t\t\t// 当前节点\n\t\t\tconst sourceGroupId = getGroupId(String(dragKey));\n\n\t\tif (dropToGap) {\n\t\t\t// 和目标节点是同一级\n\t\t} else {\n\t\t\t// 拖到目标节点的内部\n\t\t}\n\n\t\t// try {\n\t\t// \tconst { status } = await updateGroupOrderApi(sortData);\n\t\t// \tif (status?.code === 0) {\n\t\t// \t\tmessage.success('分组顺序已更新');\n\t\t// \t\tgetGroupList(); // 重新获取分组数据以刷新树结构\n\t\t// \t}\n\t\t// } catch (error) {\n\t\t// \tmessage.error('更新分组顺序失败');\n\t\t// \tconsole.error('排序更新错误:', error);\n\t\t// }\n\t\tmessage.error(\"更新分组顺序还没有实现，请等待\");\n\t};\n\n\t// 处理添加分组\n\tconst handleAddGroup = async () => {\n\t\tlet groupName = newGroupName.trim() || subGroupName.trim();\n\t\tif (groupName === \"\") return;\n\n\t\t// 构造分组数据\n\t\tconst groupData = {\n\t\t\tid: currentGroupId, // 新增分组id为0\n\t\t\tcolumnId: columnIdParam, // 使用当前专栏ID\n\t\t\tparentGroupId: parentGroupId, // 默认为顶级分组\n\t\t\ttitle: groupName,\n\t\t\tsection: 0 // 默认排序为0\n\t\t};\n\n\t\ttry {\n\t\t\tconst { status } = await updateGroupApi(groupData);\n\t\t\tconst { code, msg } = status || {};\n\n\t\t\tif (code === 0) {\n\t\t\t\tmessage.success(\"分组添加成功\");\n\t\t\t\tawait getGroupList();\n\t\t\t} else {\n\t\t\t\tmessage.error(msg || \"分组添加失败\");\n\t\t\t}\n\t\t} catch (error) {\n\t\t\tmessage.error(\"分组添加失败\");\n\t\t}\n\n\t\t// 重置状态\n\t\tsetNewGroupName(\"\");\n\t\tsetShowAddGroup(false);\n\t};\n\n\tconst handleDeleteGroup = async (groupId: number) => {\n\t\ttry {\n\t\t\tconst { status } = await deleteGroupApi(groupId);\n\t\t\tconst { code, msg } = status || {};\n\n\t\t\tif (code === 0) {\n\t\t\t\tmessage.success(\"分组删除成功\");\n\t\t\t\tawait getGroupList();\n\t\t\t} else {\n\t\t\t\tmessage.error(msg || \"分组删除失败\");\n\t\t\t}\n\t\t} catch (error) {\n\t\t\tmessage.error(\"分组删除失败\");\n\t\t}\n\t};\n\n\t// 分组抽屉\n\tconst groupDrawerContent = (\n\t\t<div className=\"\">\n\t\t\t{treeData.length > 0 ? (\n\t\t\t\t<>\n\t\t\t\t\t<Tree\n\t\t\t\t\t\tclassName=\"group-tree\"\n\t\t\t\t\t\tshowIcon\n\t\t\t\t\t\tdefaultExpandAll={false}\n\t\t\t\t\t\texpandedKeys={expandedKeys}\n\t\t\t\t\t\tautoExpandParent={autoExpandParent}\n\t\t\t\t\t\tonExpand={onExpand}\n\t\t\t\t\t\tonSelect={onSelect}\n\t\t\t\t\t\ttreeData={treeData}\n\t\t\t\t\t\tdraggable={true}\n\t\t\t\t\t\tonDrop={handleDrop}\n\t\t\t\t\t/>\n\t\t\t\t\t<div className=\"group-tree-empty\">\n\t\t\t\t\t\t<Input placeholder=\"请输入分组名称\" value={newGroupName} onChange={e => setNewGroupName(e.target.value)} />\n\t\t\t\t\t\t<Button\n\t\t\t\t\t\t\tclassName=\"add-group-button\"\n\t\t\t\t\t\t\ttype=\"primary\"\n\t\t\t\t\t\t\tonClick={async e => {\n\t\t\t\t\t\t\t\tsetCurrentGroupId(0);\n\t\t\t\t\t\t\t\tsetParentGroupId(0);\n\t\t\t\t\t\t\t\thandleAddGroup();\n\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t+ 添加顶层分组\n\t\t\t\t\t\t</Button>\n\t\t\t\t\t</div>\n\t\t\t\t</>\n\t\t\t) : (\n\t\t\t\t<div className=\"group-tree-empty\">\n\t\t\t\t\t<p>暂无分组数据</p>\n\t\t\t\t\t<div className=\"add-group-form\">\n\t\t\t\t\t\t<Input placeholder=\"请输入分组名称\" value={newGroupName} onChange={e => setNewGroupName(e.target.value)} />\n\t\t\t\t\t\t<Button\n\t\t\t\t\t\t\ttype=\"primary\"\n\t\t\t\t\t\t\tonClick={async e => {\n\t\t\t\t\t\t\t\tsetCurrentGroupId(0);\n\t\t\t\t\t\t\t\tsetParentGroupId(0);\n\t\t\t\t\t\t\t\thandleAddGroup();\n\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t添加\n\t\t\t\t\t\t</Button>\n\t\t\t\t\t</div>\n\t\t\t\t</div>\n\t\t\t)}\n\t\t</div>\n\t);\n\n\t// 编辑表单\n\tconst reviseDrawerContent = (\n\t\t<Form name=\"basic\" form={formRef} labelCol={{ span: 4 }} wrapperCol={{ span: 16 }} autoComplete=\"off\">\n\t\t\t<Form.Item label=\"分组\" name=\"groupId\" rules={[{ required: true, message: \"请选择教程分组!\" }]}>\n\t\t\t\t<TreeSelect\n\t\t\t\t\tshowSearch\n\t\t\t\t\ttreeNodeFilterProp=\"label\"\n\t\t\t\t\ttreeNodeLabelProp=\"label\"\n\t\t\t\t\tplaceholder=\"请选择教程分组\"\n\t\t\t\t\tonChange={handleChange}\n\t\t\t\t\ttreeData={(() => {\n\t\t\t\t\t\t// 构建树形结构\n\t\t\t\t\t\tconst formatTreeNode = (node: GroupData): any => ({\n\t\t\t\t\t\t\t...node,\n\t\t\t\t\t\t\tkey: node.groupId?.toString() || \"\",\n\t\t\t\t\t\t\tvalue: node.groupId?.toString() || \"\",\n\t\t\t\t\t\t\tlabel: node.title || \"未命名分组\",\n\t\t\t\t\t\t\tchildren: node.children?.map((child: GroupData) => formatTreeNode(child))\n\t\t\t\t\t\t});\n\n\t\t\t\t\t\treturn groupTree.map(group => formatTreeNode(group));\n\t\t\t\t\t})()}\n\t\t\t\t\ttreeDefaultExpandAll={true}\n\t\t\t\t/>\n\t\t\t</Form.Item>\n\n\t\t\t<Form.Item label=\"教程\" name=\"articleId\" rules={[{ required: true, message: \"请选择教程!\" }]}>\n\t\t\t\t<TableSelect\n\t\t\t\t\tisArticleSelectOpen={isArticleSelectOpen}\n\t\t\t\t\tsetIsArticleSelectOpen={setIsArticleSelectOpen}\n\t\t\t\t\thandleChange={handleChange}\n\t\t\t\t/>\n\t\t\t</Form.Item>\n\n\t\t\t<Form.Item label=\"标题\" name=\"shortTitle\" rules={[{ required: true, message: \"请输入标题!\" }]}>\n\t\t\t\t<Input\n\t\t\t\t\tallowClear\n\t\t\t\t\tplaceholder=\"请输入标题\"\n\t\t\t\t\tvalue={shortTitle}\n\t\t\t\t\tonChange={e => handleChange({ shortTitle: e.target.value })}\n\t\t\t\t/>\n\t\t\t</Form.Item>\n\t\t</Form>\n\t);\n\n\treturn (\n\t\t<div className=\"ColumnArticle\">\n\t\t\t<ContentWrap>\n\t\t\t\t{/* 搜索 */}\n\t\t\t\t<Search\n\t\t\t\t\thandleSearchChange={handleSearchChange}\n\t\t\t\t\tgoBack={goBack}\n\t\t\t\t\thandleSearch={handleSearch}\n\t\t\t\t\thandleAdd={handleAdd}\n\t\t\t\t\thandleGroup={handleGroup}\n\t\t\t\t/>\n\t\t\t\t{/* 表格 */}\n\t\t\t\t<ContentInterWrap>\n\t\t\t\t\t<DndContext sensors={sensors} modifiers={[restrictToVerticalAxis]} onDragEnd={onDragEnd}>\n\t\t\t\t\t\t<SortableContext\n\t\t\t\t\t\t\t// rowKey array\n\t\t\t\t\t\t\titems={tableData.map(i => i.key)}\n\t\t\t\t\t\t\tstrategy={verticalListSortingStrategy}\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t<Table\n\t\t\t\t\t\t\t\tcomponents={{\n\t\t\t\t\t\t\t\t\tbody: {\n\t\t\t\t\t\t\t\t\t\trow: Row\n\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\trowKey=\"key\"\n\t\t\t\t\t\t\t\tcolumns={columns}\n\t\t\t\t\t\t\t\tdataSource={tableData}\n\t\t\t\t\t\t\t\tpagination={paginationInfo}\n\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t</SortableContext>\n\t\t\t\t\t</DndContext>\n\t\t\t\t</ContentInterWrap>\n\t\t\t</ContentWrap>\n\t\t\t{/* 抽屉 */}\n\t\t\t<Drawer title=\"详情\" placement=\"right\" onClose={handleCloseDrawer} open={isDetailDrawerShow}>\n\t\t\t\t<Descriptions column={1} labelStyle={{ width: \"100px\" }}>\n\t\t\t\t\t{detailInfo.map(({ label, title }) => (\n\t\t\t\t\t\t<Descriptions.Item label={label} key={label}>\n\t\t\t\t\t\t\t{title !== 0 ? title || \"-\" : 0}\n\t\t\t\t\t\t</Descriptions.Item>\n\t\t\t\t\t))}\n\t\t\t\t</Descriptions>\n\t\t\t</Drawer>\n\t\t\t{/* 调整顺序的抽屉 */}\n\t\t\t<Modal\n\t\t\t\ttitle=\"调整教程顺序\"\n\t\t\t\twidth={280}\n\t\t\t\tstyle={{ left: 200 }}\n\t\t\t\tonOk={handleSortByIDSubmit}\n\t\t\t\tonCancel={handleCloseDrawer}\n\t\t\t\topen={isSortDrawerShow}\n\t\t\t>\n\t\t\t\t{articleSortContent}\n\t\t\t</Modal>\n\t\t\t{/* 把弹窗修改为抽屉 */}\n\t\t\t<Drawer\n\t\t\t\ttitle=\"添加\"\n\t\t\t\tsize=\"large\"\n\t\t\t\tplacement=\"right\"\n\t\t\t\textra={\n\t\t\t\t\t<Space>\n\t\t\t\t\t\t<Button onClick={handleCloseDrawer}>取消</Button>\n\t\t\t\t\t\t<Button type=\"primary\" onClick={handleSubmit}>\n\t\t\t\t\t\t\tOK\n\t\t\t\t\t\t</Button>\n\t\t\t\t\t</Space>\n\t\t\t\t}\n\t\t\t\tonClose={handleCloseDrawer}\n\t\t\t\topen={isOpenDrawerShow}\n\t\t\t>\n\t\t\t\t{reviseDrawerContent}\n\t\t\t</Drawer>\n\t\t\t{/* 分组管理的抽屉 */}\n\t\t\t<Drawer title=\"分组管理\" size=\"large\" placement=\"right\" onClose={handleCloseDrawer} open={isGroupDrawerShow}>\n\t\t\t\t{groupDrawerContent}\n\t\t\t</Drawer>\n\t\t\t{/* 分组编辑弹窗 */}\n\t\t\t<Modal\n\t\t\t\ttitle={currentGroupId ? \"编辑分组\" : \"新增分组\"}\n\t\t\t\twidth={280}\n\t\t\t\tstyle={{ left: 200 }}\n\t\t\t\tonOk={async e => {\n\t\t\t\t\thandleAddGroup();\n\t\t\t\t\tsetIsAddSubGroupModalVisible(false);\n\t\t\t\t}}\n\t\t\t\tonCancel={e => setIsAddSubGroupModalVisible(false)}\n\t\t\t\topen={isAddSubGroupModalVisible}\n\t\t\t>\n\t\t\t\t<Input placeholder=\"请输入分组名称\" value={subGroupName} onChange={e => setSubGroupName(e.target.value)} />\n\t\t\t</Modal>\n\t\t\t{/* 更新教程相关信息 */}\n\t\t\t<Modal\n\t\t\t\ttitle=\"更新教程分组\"\n\t\t\t\twidth={320}\n\t\t\t\tstyle={{ left: 200 }}\n\t\t\t\tonOk={handleUpdateArticleGroup}\n\t\t\t\tonCancel={() => setIsEditDrawerOpen(false)}\n\t\t\t\topen={isEditDrawerOpen}\n\t\t\t>\n\t\t\t\t<Form name=\"basic\" form={articleGroupFormRef} labelCol={{ span: 4 }} wrapperCol={{ span: 16 }} autoComplete=\"off\">\n\t\t\t\t\t<Form.Item label=\"分组\" name=\"groupId\" rules={[{ required: true, message: \"请选择教程分组!\" }]}>\n\t\t\t\t\t\t<TreeSelect\n\t\t\t\t\t\t\tshowSearch\n\t\t\t\t\t\t\ttreeNodeFilterProp=\"label\"\n\t\t\t\t\t\t\ttreeNodeLabelProp=\"label\"\n\t\t\t\t\t\t\tplaceholder=\"请选择教程分组\"\n\t\t\t\t\t\t\tonChange={handleUpdateChooseGroup}\n\t\t\t\t\t\t\ttreeData={(() => {\n\t\t\t\t\t\t\t\t// 构建树形结构\n\t\t\t\t\t\t\t\tconst formatTreeNode = (node: GroupData): any => ({\n\t\t\t\t\t\t\t\t\t...node,\n\t\t\t\t\t\t\t\t\tkey: node.groupId?.toString() || \"\",\n\t\t\t\t\t\t\t\t\tvalue: node.groupId?.toString() || \"\",\n\t\t\t\t\t\t\t\t\tlabel: node.title || \"未命名分组\",\n\t\t\t\t\t\t\t\t\tchildren: node.children?.map((child: GroupData) => formatTreeNode(child))\n\t\t\t\t\t\t\t\t});\n\n\t\t\t\t\t\t\t\treturn groupTree.map(group => formatTreeNode(group));\n\t\t\t\t\t\t\t})()}\n\t\t\t\t\t\t\ttreeDefaultExpandAll={true}\n\t\t\t\t\t\t/>\n\t\t\t\t\t</Form.Item>\n\t\t\t\t</Form>\n\t\t\t</Modal>\n\t\t</div>\n\t);\n};\n\nconst mapStateToProps = (state: any) => state.disc.disc;\nconst mapDispatchToProps = {};\nexport default connect(mapStateToProps, mapDispatchToProps)(ColumnArticle);\n"
  },
  {
    "path": "src/views/column/setting/articlesort/search.scss",
    "content": ".column-article-sort-search {\n  margin-bottom: 16px;\n\n  &__wrap {\n    display: flex;\n    justify-content: space-between;\n  }\n\n  &__search {\n    flex: 1;\n    display: flex;\n\n    &-wrap {\n      display: flex;\n    }\n  }\n\n  &__search-item {\n    margin-right: 48px;\n  }\n\n  &-label {\n    margin-right: 12px;\n  }\n}\n"
  },
  {
    "path": "src/views/column/setting/articlesort/search.tsx",
    "content": "/* eslint-disable prettier/prettier */\nimport { FC } from \"react\";\nimport { ArrowLeftOutlined, PlusOutlined, SearchOutlined } from \"@ant-design/icons\";\nimport { Button, Input } from \"antd\";\n\nimport { ContentInterWrap } from \"@/components/common-wrap\";\n\nimport \"./search.scss\";\n\ninterface IProps {\n\thandleSearchChange: (e: object) => void;\n\thandleSearch: () => void;\n\tgoBack: () => void;\n\thandleAdd: () => void;\n\thandleGroup: () => void;\n}\n\nconst Search: FC<IProps> = ({ handleSearchChange, handleSearch, goBack, handleAdd, handleGroup }) => {\n\treturn (\n\t\t<div className=\"column-article-sort-search\">\n\t\t\t<ContentInterWrap className=\"column-article-sort-search__wrap\">\n\t\t\t\t<div className=\"column-article-sort-search__search\">\n\t\t\t\t\t<div className=\"column-article-sort-search__search-item\">\n\t\t\t\t\t\t<Button onClick={goBack}>\n\t\t\t\t\t\t\t<ArrowLeftOutlined />\n\t\t\t\t\t\t\t返回专栏配置\n\t\t\t\t\t\t</Button>\n\t\t\t\t\t</div>\n\t\t\t\t\t<div className=\"column-article-sort-search__search-item\">\n\t\t\t\t\t\t<span className=\"column-article-sort-search-label\">教程标题</span>\n\t\t\t\t\t\t<Input allowClear onChange={e => handleSearchChange({ articleTitle: e.target.value })} style={{ width: 202 }} />\n\t\t\t\t\t</div>\n\t\t\t\t</div>\n\t\t\t\t<Button type=\"primary\" icon={<SearchOutlined />} style={{ marginRight: \"10px\" }} onClick={handleSearch}>\n\t\t\t\t\t搜索\n\t\t\t\t</Button>\n\t\t\t\t<Button type=\"primary\" icon={<PlusOutlined />} style={{ marginRight: \"10px\" }} onClick={handleGroup}>\n\t\t\t\t\t分组管理\n\t\t\t\t</Button>\n\t\t\t\t<Button type=\"primary\" icon={<PlusOutlined />} style={{ marginRight: \"16px\" }} onClick={handleAdd}>\n\t\t\t\t\t添加\n\t\t\t\t</Button>\n\t\t\t</ContentInterWrap>\n\t\t</div>\n\t);\n};\nexport default Search;\n"
  },
  {
    "path": "src/views/column/setting/components/authorselect/index.scss",
    "content": "// 增加 avatar 的样式\n.avatar {\n\twidth: 30px;\n\theight: 30px;\n\tborder-radius: 50%;\n\tmargin-right: 16px;\n}\n"
  },
  {
    "path": "src/views/column/setting/components/authorselect/index.tsx",
    "content": "/* eslint-disable prettier/prettier */\n/* eslint-disable react/prop-types */\n// 这是一个上传图片的组件，使用的是antd的Upload组件\n\nimport { FC, useEffect, useState } from \"react\";\nimport React from \"react\";\nimport { Avatar, Button, Divider, Input, Select } from \"antd\";\n\nimport { getAuthorListApi } from \"@/api/modules/column\";\nimport { MapItem } from \"@/typings/common\";\n\nimport \"./index.scss\";\n\ninterface IProps {\n\thandleChange: (e: any) => void;\n\thandleFormRefChange?: (e: any) => void;\n\tauthorName:string;\n}\n\nconst AuthorSelect: FC<IProps> = ({ \n\thandleChange,\n\thandleFormRefChange,\n\tauthorName\n}) => {\n\t// 作者列表的查询条件\n\tconst [authorSearchKey, setAuthorSearchKey] = useState<string>(\"\");\n\t// 触发作者列表的搜索，查询条件为作者名\n\tconst [authorSearch, setAuthorSearch] = useState<string>(\"\");\n\t// authorList，从后台返回作者列表\n\tconst [authorList, setAuthorList] = useState<MapItem[]>([]);\n\n\t// 作者列表查询条件变化\n\tconst handleAuthorSearchChange = (value: string) => {\n\t\tconsole.log(\"作者列表查询条件变化\", value);\n\t\tsetAuthorSearchKey(value);\n\t};\n\t\n\t// 获取作者列表\n\tuseEffect(() => {\n\t\tconst getAuthorList = async () => {\n\t\t\tconst { status, result } = await getAuthorListApi(authorSearchKey);\n\t\t\tconst { code } = status || {};\n\t\t\t//@ts-ignore\n\t\t\tconst { items } = result || {};\n\t\t\tif (code === 0) {\n\t\t\t\tconst newList = items.map((item: MapItem) => ({\n\t\t\t\t\tvalue: item.name,\n\t\t\t\t\tlabel: (\n\t\t\t\t\t\t<React.Fragment>\n\t\t\t\t\t\t\t<Avatar src={item.avatar} alt=\"avatar\" className=\"avatar\" />\n\t\t\t\t\t\t\t{item.name}\n\t\t\t\t\t\t</React.Fragment>\n\t\t\t\t\t),\n\t\t\t\t\tkey: item.userId,\n\t\t\t\t}));\n\t\t\t\t\t\t\t\n\t\t\t\tsetAuthorList(newList);\n\t\t\t}\n\t\t};\n\t\tgetAuthorList();\n\t}, [authorSearch]);\n\n\treturn (\n\t\t<Select\n\t\t\t// 使用 select 选择器实现一个可以查找的作者列表供选择\n\t\t\t// value 会从 item 中的 name 中取出来，所以这里不需要设置\n\t\t\tplaceholder=\"请选择作者\"\n\t\t\tsize=\"large\"\n\t\t\tvalue={authorName}\n\t\t\toptions={authorList}\n\t\t\tonChange={(value, option) => {\n\t\t\t\t// 需要把作者 ID 传递给后端\n\t\t\t\tconsole.log(\"选择的作者\", option);\n\t\t\t\tconsole.log(\"选择的作者 value\", value);\n\t\t\t\t// 取出 key\n\t\t\t\t//@ts-ignore\n\t\t\t\tconst { key } = option || {};\n\t\t\t\thandleChange({ author: key, authorName: value });\n\t\t\t\thandleFormRefChange?.({author: key});\n\t\t\t}}\n\t\t\t// render\n\t\t\tdropdownRender={menu => {\n\t\t\t\treturn (\n\t\t\t\t\t<div>\n\t\t\t\t\t\t<div\n\t\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\t\tdisplay: \"flex\",\n\t\t\t\t\t\t\t\tflexWrap: \"nowrap\",\n\t\t\t\t\t\t\t\tpadding: 8\n\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t<Input\n\t\t\t\t\t\t\t\tallowClear\n\t\t\t\t\t\t\t\tplaceholder=\"请输入你想要查找的作者名\"\n\t\t\t\t\t\t\t\tstyle={{ flex: \"auto\" }}\n\t\t\t\t\t\t\t\tonChange={e => {\n\t\t\t\t\t\t\t\t\thandleAuthorSearchChange(e.target.value);\n\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t<Button\n\t\t\t\t\t\t\t\ttype=\"primary\"\n\t\t\t\t\t\t\t\tstyle={{ marginLeft: 8 }}\n\t\t\t\t\t\t\t\t// 触发搜索authorSearch\n\t\t\t\t\t\t\t\tonClick={() => {\n\t\t\t\t\t\t\t\t\tsetAuthorSearch(authorSearchKey);\n\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t筛选\n\t\t\t\t\t\t\t</Button>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t\t{/* 添加一个分割线 */}\n\t\t\t\t\t\t<Divider style={{ margin: \"4px 0\" }} />\n\t\t\t\t\t\t{menu}\n\t\t\t\t\t</div>\n\t\t\t\t);\n\t\t\t}}\n\t\t\t// 下拉框展开时触发\n\t\t\tonDropdownVisibleChange={() => {\n\t\t\t\tconsole.log(\"下拉框展开\");\n\t\t\t}}\n\t\t>\n\t\t</Select>\n\t);\n};\nexport default AuthorSelect;\n"
  },
  {
    "path": "src/views/column/setting/components/imgupload/index.tsx",
    "content": "/* eslint-disable react/prop-types */\n// 这是一个上传图片的组件，使用的是antd的Upload组件\n\nimport { FC } from \"react\";\nimport { UploadOutlined } from \"@ant-design/icons\";\nimport { Button, message, Upload } from \"antd\";\n\nimport { uploadImgApi } from \"@/api/modules/common\";\nimport { getCompleteUrl } from \"@/utils/util\";\n\ninterface IProps {\n\tcoverList: any[];\n\tcoverName: string;\n\tsetCoverList: (e: any[]) => void;\n\thandleFormRefChange: (e: any) => void;\n}\n\nconst ImgUpload: FC<IProps> = ({ coverList, coverName, setCoverList, handleFormRefChange }) => {\n\tconst customCoverUpload = async (options: any) => {\n\t\tconst { onSuccess, onProgress, onError, file } = options;\n\t\tconsole.log(\"上传图片\", options);\n\t\t// 限制图片大小，不超过 5M\n\t\tif (file.size > 5 * 1024 * 1024) {\n\t\t\tonError(\"图片大小不能超过 5M\");\n\t\t\treturn;\n\t\t}\n\n\t\tconst formData = new FormData();\n\t\tformData.append(\"image\", file);\n\n\t\tconst { status, result } = await uploadImgApi(formData);\n\t\tconst { code, msg } = status || {};\n\t\tconst { imagePath } = result || {};\n\t\tconsole.log(\"上传图片\", status, result, code, msg, imagePath);\n\n\t\tif (code === 0) {\n\t\t\tconsole.log(\"上传图片成功，回调 onsuccess\", imagePath);\n\t\t\t// 把 data 的值赋给 form 的 cover，传递给后端\n\t\t\thandleFormRefChange({ cover: imagePath });\n\t\t\tconst coverUrl = getCompleteUrl(imagePath);\n\t\t\t// 更新 coverList\n\t\t\tsetCoverList([\n\t\t\t\t{\n\t\t\t\t\tuid: \"-1\",\n\t\t\t\t\tname: coverName,\n\t\t\t\t\tstatus: \"done\",\n\t\t\t\t\tthumbUrl: coverUrl,\n\t\t\t\t\turl: coverUrl\n\t\t\t\t}\n\t\t\t]);\n\t\t\tonSuccess(imagePath);\n\t\t} else {\n\t\t\tonError(\"上传失败\");\n\t\t}\n\t};\n\n\treturn (\n\t\t<Upload\n\t\t\tcustomRequest={customCoverUpload}\n\t\t\tmultiple={false}\n\t\t\tlistType=\"picture\"\n\t\t\tmaxCount={1}\n\t\t\tfileList={[...coverList]}\n\t\t\taccept=\"image/*\"\n\t\t\tonRemove={() => {\n\t\t\t\tconsole.log(\"删除封面\");\n\t\t\t\t// 删除封面的时候，清空 cover\n\t\t\t\thandleFormRefChange({ cover: \"\" });\n\t\t\t\t// 清空 coverList\n\t\t\t\tsetCoverList([]);\n\t\t\t}}\n\t\t\tonChange={info => {\n\t\t\t\t// clear 的时候记得清空 cover\n\t\t\t\t// submit 的时候要判断 cover 是否为空，空的话提示用户上传\n\t\t\t\tconst { status, name, response } = info.file;\n\t\t\t\tconsole.log(\"上传封面 onchange info\", status, name, response);\n\n\t\t\t\tif (status !== \"uploading\") {\n\t\t\t\t\tconsole.log(\"上传封面 onchange !uploading\");\n\t\t\t\t}\n\t\t\t\tif (status === \"done\") {\n\t\t\t\t\tmessage.success(`${name} 封面上传成功.`);\n\t\t\t\t} else if (status === \"error\") {\n\t\t\t\t\tmessage.error(`封面上传失败，原因：${info.file.error}`);\n\t\t\t\t}\n\t\t\t}}\n\t\t>\n\t\t\t<Button icon={<UploadOutlined />}>Upload</Button>\n\t\t</Upload>\n\t);\n};\nexport default ImgUpload;\n"
  },
  {
    "path": "src/views/column/setting/components/search/index.scss",
    "content": ".column-setting-search {\n  margin-bottom: 16px;\n\n  &__wrap {\n    display: flex;\n    justify-content: space-between;\n    padding-left: 48px;\n  }\n\n  &__search {\n    flex: 1;\n    display: flex;\n\t\tjustify-content: space-between;\n\t\t&-wrap {\n\t\t\tdisplay: flex;\n\t\t}\n  }\n\n  &__search-item {\n    margin-right: 48px;\n  }\n\n  &-label {\n    margin-right: 12px;\n  }\n}\n"
  },
  {
    "path": "src/views/column/setting/components/search/index.tsx",
    "content": "/* eslint-disable prettier/prettier */\nimport React, { FC } from \"react\";\nimport { PlusOutlined, SearchOutlined } from \"@ant-design/icons\";\nimport { Button, Input } from \"antd\";\n\nimport { ContentInterWrap } from \"@/components/common-wrap\";\nimport { UpdateEnum } from \"@/enums/common\";\n\nimport \"./index.scss\";\n\ninterface IProps {\n\thandleSearchChange: (e: object) => void;\n\thandleSearch: () => void;\n\tsetStatus: (e: UpdateEnum) => void;\n\tsetIsOpenDrawerShow: (e: boolean) => void;\n}\n\nconst Search: FC<IProps> = ({ \n\thandleSearchChange, \n\thandleSearch,\n\tsetStatus, \n\tsetIsOpenDrawerShow \n}) => {\n\treturn (\n\t\t<div className=\"column-setting-search\">\n\t\t\t<ContentInterWrap className=\"column-setting-search__wrap\">\n\t\t\t\t<div className=\"column-setting-search__search\">\n\t\t\t\t\t<div className=\"column-setting-search__search-item\">\n\t\t\t\t\t\t<span className=\"column-setting-search-label\">专栏名</span>\n\t\t\t\t\t\t<Input \n\t\t\t\t\t\t\tallowClear \n\t\t\t\t\t\t\tonChange={e => handleSearchChange({ column: e.target.value })} \n\t\t\t\t\t\t\tstyle={{ width: 252 }} />\n\t\t\t\t\t</div>\n\t\t\t\t</div>\n\t\t\t\t<Button\n\t\t\t\t\ttype=\"primary\"\n\t\t\t\t\ticon={<SearchOutlined />}\n\t\t\t\t\tstyle={{ marginRight: \"10px\" }}\n\t\t\t\t\tonClick={e => {\n\t\t\t\t\t\thandleSearch();\n\t\t\t\t\t}}\n\t\t\t\t>\n\t\t\t\t\t搜索\n\t\t\t\t</Button>\n\t\t\t\t<Button\n\t\t\t\t\ttype=\"primary\"\n\t\t\t\t\ticon={<PlusOutlined />}\n\t\t\t\t\tstyle={{ marginRight: \"20px\" }}\n\t\t\t\t\tonClick={() => {\n\t\t\t\t\t\tsetIsOpenDrawerShow(true);\n\t\t\t\t\t\tsetStatus(UpdateEnum.Save);\n\t\t\t\t\t}}\n\t\t\t\t>\n\t\t\t\t\t添加\n\t\t\t\t</Button>\n\t\t\t</ContentInterWrap>\n\t\t</div>\n\t);\n};\nexport default Search;\n"
  },
  {
    "path": "src/views/column/setting/groups/index.scss",
    "content": ".rotated-icon {\n\ttransform: rotate(90deg); /* 或任何您需要的角度 */\n}\n\n.group-tree {\n\tmargin: 20px;\n\tpadding: 20px;\n\tbackground-color: #fff;\n\tborder-radius: 4px;\n\tbox-shadow: 0 2px 8px rgb(0 0 0 / 10%);\n}\n\n.group-tree .ant-tree-node-content-wrapper {\n\tdisplay: flex;\n\talign-items: center;\n}\n\n.group-tree .ant-tree-node-content-wrapper span {\n\tmargin-right: 8px;\n}\n\n// 分组节点样式\n.group-tree .ant-tree-treenode .ant-tree-node-content-wrapper.group-node {\n\tcolor: #1890ff;\n\tfont-weight: 500;\n}\n\n// 文章节点样式\n.group-tree .ant-tree-treenode .ant-tree-node-content-wrapper.article-node {\n\tcolor: #555;\n\tfont-weight: 400;\n\tfont-style: italic;\n}\n\n// 分组节点按钮容器\n.group-node-buttons {\n\tdisplay: flex;\n\tgap: 4px;\n\topacity: 0;\n\ttransition: opacity 0.2s;\n}\n\n// 鼠标悬停时显示按钮\n.group-tree .ant-tree-treenode:hover .group-node-buttons {\n\topacity: 1;\n}\n"
  },
  {
    "path": "src/views/column/setting/groups/index.tsx",
    "content": "/* eslint-disable react/jsx-no-comment-textnodes */\n/* eslint-disable prettier/prettier */\nimport { FC, useCallback, useEffect, useState } from \"react\";\nimport React from \"react\";\nimport { connect } from \"react-redux\";\nimport { useLocation, useNavigate } from \"react-router-dom\";\nimport { DeleteOutlined, EditOutlined, EyeOutlined, ImportOutlined, PlusOutlined, SwapOutlined } from \"@ant-design/icons\";\nimport { Button, Card, Descriptions, Drawer, Form, Input, InputNumber, message, Modal, Space, Table, Tooltip, Tree } from \"antd\";\nimport type { DataNode } from \"antd/es/tree\";\n\nimport {\n\tdelColumnArticleApi,\n\tdeleteGroupApi,\n\tgetColumnGroupArticlesApi,\n\tmoveColumnArticleOrGroup,\n\tsortColumnArticleApi,\n\tsortColumnArticleByIDApi,\n\tupdateColumnArticleApi,\n\tupdateGroupApi\n} from \"@/api/modules/column\";\nimport { ContentInterWrap, ContentWrap } from \"@/components/common-wrap\";\nimport { MapItem } from \"@/typings/common\";\nimport TableSelect from \"@/views/column/article/components/tableselect/TableSelect\";\n\nimport \"./index.scss\";\n\ninterface IProps {}\n\ninterface GroupData {\n\tcolumnId: number;\n\tgroupId: number;\n\tparentGroupId: number;\n\ttitle: string;\n\tsection: number;\n\tchildren: GroupData[];\n\tarticles: DataType[];\n}\n\n// 教程文章的数据类型\ninterface DataType {\n\tkey: string;\n\tid: number;\n\tarticleId: string;\n\ttitle: string;\n\tshortTitle: string;\n\tcolumnId: number;\n\tcolumn: string;\n\tsort: number;\n\tgroupId: number;\n}\n\nexport interface IMoveType {\n\tcolumnId: number;\n\tmoveArticleId?: number;\n\tmoveGroupId?: number;\n\ttargetArticleId?: number;\n\ttargetGroupId?: number;\n\tmovePosition: number;\n}\n\nexport interface IFormType {\n\tid: number; // 主键id\n\tarticleId: number; // 文章ID\n\ttitle: string; // 文章标题\n\tshortTitle: string; // 文章短标题\n\tcolumnId: number; // 教程ID\n\tcolumn: string; // 教程名\n\tsort: number; // 排序\n\tgroupId: number; // 分组id\n\tgroupName: string; // 分组名\n}\n\nconst defaultInitForm: IFormType = {\n\tid: -1,\n\tarticleId: -1,\n\ttitle: \"\",\n\tshortTitle: \"\",\n\tcolumnId: -1,\n\tcolumn: \"\",\n\tsort: -1,\n\tgroupId: 0,\n\tgroupName: \"\"\n};\n\nconst ColumnArticle: FC<IProps> = props => {\n\tconst [formRef] = Form.useForm();\n\t// 分组列表数据\n\tconst [groupTree, setGroupTree] = useState<GroupData[]>([]);\n\n\tconst [currentGroup, setCurrentGroup] = useState<GroupData>();\n\tconst [newGroupName, setNewGroupName] = useState<string>(\"\");\n\tconst [isAddModalVisible, setIsAddModalVisible] = useState<boolean>(false);\n\tconst [isEditModalVisible, setIsEditModalVisible] = useState<boolean>(false);\n\tconst [isImportDrawerVisible, setIsImportDrawerVisible] = useState<boolean>(false);\n\n\t// 刷新函数\n\tconst [query, setQuery] = useState<number>(0);\n\n\tconst location = useLocation();\n\tconst navigate = useNavigate();\n\tconst { columnId: columnIdParam, column: columnParam } = location.state || {};\n\n\tconst fetchTreeData = async () => {\n\t\tconst { status, result } = await getColumnGroupArticlesApi(columnIdParam);\n\t\tconst { code } = status || {};\n\t\t// @ts-ignore\n\t\tif (code === 0) {\n\t\t\tconst newList = (result as GroupData[]).map((item: GroupData) => ({ ...item, key: item?.groupId }));\n\t\t\tsetGroupTree(newList as unknown as GroupData[]);\n\t\t\tconsole.log(\"获取到的groupTree:\", groupTree);\n\t\t}\n\t};\n\n\t// 数据请求\n\tuseEffect(() => {\n\t\tfetchTreeData();\n\t}, []);\n\n\t// 递归构建树节点\n\tconst buildTreeNodes = (groups: GroupData[]): DataNode[] => {\n\t\treturn groups.map(group => {\n\t\t\tconst childrenNodes: DataNode[] = [];\n\n\t\t\t// 优先添加子分组\n\t\t\tif (group.children && group.children.length > 0) {\n\t\t\t\tchildrenNodes.push(...buildTreeNodes(group.children));\n\t\t\t}\n\n\t\t\t// 然后添加文章\n\t\t\tif (group.articles && group.articles.length > 0) {\n\t\t\t\tgroup.articles.forEach(article => {\n\t\t\t\t\tchildrenNodes.push({\n\t\t\t\t\t\tkey: `article-${article.articleId}`,\n\t\t\t\t\t\ttitle: (\n\t\t\t\t\t\t\t<div style={{ display: \"flex\", alignItems: \"center\", justifyContent: \"space-between\", width: \"100%\" }}>\n\t\t\t\t\t\t\t\t<span>\n\t\t\t\t\t\t\t\t\t<span style={{ fontSize: \"0.6rem\", color: \"gray\", fontStyle: \"italic\" }}>{article.articleId}</span>\n\t\t\t\t\t\t\t\t\t<span>{article.shortTitle}</span>\n\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t\t<div className=\"group-node-buttons\">\n\t\t\t\t\t\t\t\t\t<Button\n\t\t\t\t\t\t\t\t\t\ttype=\"text\"\n\t\t\t\t\t\t\t\t\t\tsize=\"small\"\n\t\t\t\t\t\t\t\t\t\tdanger\n\t\t\t\t\t\t\t\t\t\ticon={<DeleteOutlined />}\n\t\t\t\t\t\t\t\t\t\tonClick={e => {\n\t\t\t\t\t\t\t\t\t\t\te.stopPropagation();\n\t\t\t\t\t\t\t\t\t\t\thandleDeleteColumnArticle(article.id);\n\t\t\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t),\n\t\t\t\t\t\ticon: <span className=\"article-icon\">📄</span>,\n\t\t\t\t\t\tclassName: \"article-node\"\n\t\t\t\t\t});\n\t\t\t\t});\n\t\t\t}\n\n\t\t\treturn {\n\t\t\t\tkey: `group-${group.groupId}`,\n\t\t\t\ttitle: (\n\t\t\t\t\t<div style={{ display: \"flex\", alignItems: \"center\", justifyContent: \"space-between\", width: \"100%\" }}>\n\t\t\t\t\t\t<span>\n\t\t\t\t\t\t\t<span style={{ fontSize: \"0.6rem\", color: \"gray\", fontStyle: \"italic\" }}>{group.groupId}</span>\n\t\t\t\t\t\t\t<span>{group.title}</span>\n\t\t\t\t\t\t</span>\n\t\t\t\t\t\t<Space size=\"small\" style={{ marginLeft: \"2rem\" }}>\n\t\t\t\t\t\t\t<div className=\"group-node-buttons\">\n\t\t\t\t\t\t\t\t<Button\n\t\t\t\t\t\t\t\t\ttype=\"link\"\n\t\t\t\t\t\t\t\t\tsize=\"small\"\n\t\t\t\t\t\t\t\t\ticon={<PlusOutlined />}\n\t\t\t\t\t\t\t\t\tonClick={e => {\n\t\t\t\t\t\t\t\t\t\te.stopPropagation();\n\t\t\t\t\t\t\t\t\t\tsetCurrentGroup(group);\n\t\t\t\t\t\t\t\t\t\tsetNewGroupName(\"\");\n\t\t\t\t\t\t\t\t\t\tsetIsAddModalVisible(true);\n\t\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t<Button\n\t\t\t\t\t\t\t\t\ttype=\"link\"\n\t\t\t\t\t\t\t\t\tsize=\"small\"\n\t\t\t\t\t\t\t\t\ticon={<EditOutlined />}\n\t\t\t\t\t\t\t\t\tonClick={e => {\n\t\t\t\t\t\t\t\t\t\te.stopPropagation();\n\t\t\t\t\t\t\t\t\t\tsetCurrentGroup(group);\n\t\t\t\t\t\t\t\t\t\tsetNewGroupName(group.title);\n\t\t\t\t\t\t\t\t\t\tsetIsEditModalVisible(true);\n\t\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t<Button\n\t\t\t\t\t\t\t\t\ttype=\"link\"\n\t\t\t\t\t\t\t\t\tsize=\"small\"\n\t\t\t\t\t\t\t\t\ticon={<ImportOutlined />}\n\t\t\t\t\t\t\t\t\tonClick={e => {\n\t\t\t\t\t\t\t\t\t\te.stopPropagation();\n\t\t\t\t\t\t\t\t\t\tsetCurrentGroup(group);\n\t\t\t\t\t\t\t\t\t\tsetIsImportDrawerVisible(true);\n\t\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\t></Button>\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t</Space>\n\t\t\t\t\t</div>\n\t\t\t\t),\n\t\t\t\ticon: <span className=\"group-icon\">📁</span>,\n\t\t\t\tchildren: childrenNodes.length > 0 ? childrenNodes : undefined,\n\t\t\t\tclassName: \"group-node\"\n\t\t\t};\n\t\t});\n\t};\n\n\tconst treeData = buildTreeNodes(groupTree);\n\n\t// 控制节点展开/收起的状态\n\tconst [expandedKeys, setExpandedKeys] = useState<React.Key[]>([]);\n\tconst [autoExpandParent, setAutoExpandParent] = useState(true);\n\n\t// 处理节点展开/收起\n\tconst onExpand = (expandedKeysValue: React.Key[]) => {\n\t\tconsole.log(\"onExpand\", expandedKeysValue);\n\t\t// 如果你不想节点收起，可以删除下面这行\n\t\tsetExpandedKeys(expandedKeysValue);\n\t\tsetAutoExpandParent(false);\n\t};\n\n\t// 处理节点选择\n\tconst onSelect = (selectedKeysValue: React.Key[], info: any) => {\n\t\tconsole.log(\"selected\", selectedKeysValue, info);\n\t\t// 如果点击的是分组节点，则切换展开/收起状态\n\t\tif (info.node.key.startsWith(\"group-\")) {\n\t\t\tconst key = info.node.key;\n\t\t\tif (expandedKeys.includes(key)) {\n\t\t\t\t// 如果已经展开，则收起\n\t\t\t\tsetExpandedKeys(expandedKeys.filter(k => k !== key));\n\t\t\t} else {\n\t\t\t\t// 如果已经收起，则展开\n\t\t\t\tsetExpandedKeys([...expandedKeys, key]);\n\t\t\t}\n\t\t}\n\t};\n\n\tconst handleSaveNewGroup = async () => {\n\t\t// 添加分组\n\t\tif (!newGroupName.trim()) {\n\t\t\tmessage.warning(\"请输入分组名称\");\n\t\t\treturn;\n\t\t}\n\n\t\t// 构造分组数据\n\t\tconst groupData = {\n\t\t\tid: 0, // 新增分组id为0\n\t\t\tcolumnId: columnIdParam, // 使用当前专栏ID\n\t\t\tparentGroupId: currentGroup?.groupId || 0, // 默认为顶级分组\n\t\t\ttitle: newGroupName.trim(),\n\t\t\tsection: 0 // 默认排序为0\n\t\t};\n\n\t\ttry {\n\t\t\tconst { status } = await updateGroupApi(groupData);\n\t\t\tconst { code, msg } = status || {};\n\n\t\t\tif (code === 0) {\n\t\t\t\tmessage.success(\"分组添加成功\");\n\t\t\t\tawait fetchTreeData();\n\t\t\t\tsetIsAddModalVisible(false);\n\t\t\t} else {\n\t\t\t\tmessage.error(msg || \"分组添加失败\");\n\t\t\t}\n\t\t} catch (error) {\n\t\t\tmessage.error(\"分组添加失败\");\n\t\t}\n\t};\n\tconst handleUpdateGroup = async () => {\n\t\t// 更新分组\n\t\tif (!currentGroup || !newGroupName.trim()) {\n\t\t\tmessage.warning(\"请输入分组名称\");\n\t\t\treturn;\n\t\t}\n\t\t// 构造分组数据\n\t\tconst groupData = {\n\t\t\tid: currentGroup.groupId, // 新增分组id为0\n\t\t\tcolumnId: columnIdParam, // 使用当前专栏ID\n\t\t\tparentGroupId: currentGroup.parentGroupId, // 默认为顶级分组\n\t\t\ttitle: newGroupName.trim(),\n\t\t\tsection: currentGroup.section // 默认排序为0\n\t\t};\n\n\t\ttry {\n\t\t\tconst { status } = await updateGroupApi(groupData);\n\t\t\tconst { code, msg } = status || {};\n\n\t\t\tif (code === 0) {\n\t\t\t\tmessage.success(\"分组添加成功\");\n\t\t\t\tawait fetchTreeData();\n\t\t\t\tsetIsEditModalVisible(false);\n\t\t\t} else {\n\t\t\t\tmessage.error(msg || \"分组添加失败\");\n\t\t\t}\n\t\t} catch (error) {\n\t\t\tmessage.error(\"删除分组失败~\");\n\t\t}\n\t};\n\n\tconst handleDeleteGroup = async () => {\n\t\t// 更新分组\n\t\tif (!currentGroup || !newGroupName.trim()) return;\n\t\ttry {\n\t\t\tconst { status } = await deleteGroupApi(currentGroup.groupId);\n\t\t\tconst { code, msg } = status || {};\n\n\t\t\tif (code === 0) {\n\t\t\t\tmessage.success(\"删除添加成功\");\n\t\t\t\tawait fetchTreeData();\n\t\t\t\tsetIsEditModalVisible(false);\n\t\t\t} else {\n\t\t\t\tmessage.error(msg || \"删除失败\");\n\t\t\t}\n\t\t} catch (error) {\n\t\t\tmessage.error(\"分组添加失败\");\n\t\t}\n\t};\n\n\tconst handleDeleteColumnArticle = async (articleId: number) => {\n\t\t// 删除专栏中的教程\n\t\tModal.warning({\n\t\t\ttitle: \"确认删除此专栏的教程吗\",\n\t\t\tcontent: \"删除此专栏的教程后无法恢复，请谨慎操作！\",\n\t\t\tmaskClosable: true,\n\t\t\tclosable: true,\n\t\t\tonOk: async () => {\n\t\t\t\tconst { status } = await delColumnArticleApi(articleId);\n\t\t\t\tconst { code, msg } = status || {};\n\t\t\t\tif (code === 0) {\n\t\t\t\t\tmessage.success(\"删除成功\");\n\t\t\t\t\tawait fetchTreeData();\n\t\t\t\t} else {\n\t\t\t\t\tmessage.error(msg);\n\t\t\t\t}\n\t\t\t}\n\t\t});\n\t};\n\n\t// 专栏内容进行拖拽，支持文章 拖拽； 分组拖拽\n\t// form值（详情和新增的时候会用到）\n\tconst [moveForm, setMoveForm] = useState<IMoveType>();\n\tconst handleDrop = async (info: any) => {\n\t\tconst { dragNode, node, dropToGap } = info;\n\t\tconsole.log(\"info对象\", info);\n\t\tconsole.log(\"拖拽的对象:\", dragNode);\n\t\tconsole.log(\"目标对象:\", node);\n\n\t\t// -1：移动到和dropKey的平级，并在其上面(即info.dropPosition比dropKey的下标小一个)\n\t\t// 1：移动到和dropKey的平级，并在其下面(info.dropPosition比dropKey的下标大一个)\n\t\t// 0：是移动到dropKey下面作为他的子级(info.dropPosition和dropKey的下标同样大\n\t\tconst dropPos = info.node.pos.split(\"-\");\n\t\tconst dropPosition = info.dropPosition - Number(dropPos[dropPos.length - 1]);\n\t\tconsole.log(\"dropPosition:\", dropPosition);\n\n\t\t// - true ：放置在目标节点旁边（同级节点）\n\t\t// - false ：放置在目标节点内部（子节点）\n\t\tconsole.log(\"dropToGap:\", dropToGap);\n\n\t\tconst articleId = parseInt(dragNode.key.split(\"-\")[1]);\n\t\tconst targetId = parseInt(node.key.split(\"-\")[1]);\n\t\tif (dragNode.key.startsWith(\"article-\")) {\n\t\t\tlet moveForm = {\n\t\t\t\tcolumnId: columnIdParam,\n\t\t\t\tmovePosition: 0,\n\t\t\t\tmoveArticleId: articleId,\n\t\t\t\ttargetArticleId: 0,\n\t\t\t\ttargetGroupId: 0,\n\t\t\t\ttag: \"\"\n\t\t\t};\n\t\t\tif (node.key.startsWith(\"group-\")) {\n\t\t\t\t// 判断是否是文章节点被拖拽到分组节点的前\\后\\里\n\t\t\t\tmoveForm.targetGroupId = targetId;\n\t\t\t\tmoveForm.movePosition = dropPosition;\n\t\t\t\tif (dropPosition == 1) moveForm.tag = \"后\";\n\t\t\t\telse if (dropPosition == 0) moveForm.tag = \"里\";\n\t\t\t\telse moveForm.tag = \"前\";\n\t\t\t} else {\n\t\t\t\t// 目标为文章\n\t\t\t\tmoveForm.targetArticleId = targetId;\n\t\t\t\tif (!dropToGap || dropPosition == 1) {\n\t\t\t\t\t// 移动到目标文章的后面\n\t\t\t\t\tmoveForm.movePosition = 1;\n\t\t\t\t\tmoveForm.tag = \"后\";\n\t\t\t\t} else {\n\t\t\t\t\t// 移动到目标文章的前面\n\t\t\t\t\tmoveForm.movePosition = -1;\n\t\t\t\t\tmoveForm.tag = \"前\";\n\t\t\t\t}\n\t\t\t}\n\t\t\tconsole.log(`移动教程啦： 将 ${articleId} 移动到 ${targetId} ${moveForm.tag}`);\n\t\t\tawait moveData(moveForm);\n\t\t} else {\n\t\t\t// 分组的移动\n\t\t\tlet moveForm = {\n\t\t\t\tcolumnId: columnIdParam,\n\t\t\t\tmovePosition: 0,\n\t\t\t\tmoveGroupId: articleId,\n\t\t\t\ttargetArticleId: 0,\n\t\t\t\ttargetGroupId: targetId,\n\t\t\t\ttag: \"\"\n\t\t\t};\n\t\t\tif (node.key.startsWith(\"group-\")) {\n\t\t\t\t// 往目标分组移动\n\t\t\t\tmoveForm.movePosition = dropPosition;\n\t\t\t\tif (dropPosition == 1) moveForm.tag = \"后\";\n\t\t\t\telse if (dropPosition == 0) moveForm.tag = \"里\";\n\t\t\t\telse moveForm.tag = \"前\";\n\t\t\t} else {\n\t\t\t\t// 往文章边上移动，不支持\n\t\t\t\tmessage.warning(\"请勿将分组移动到教程前后!\");\n\t\t\t\treturn;\n\t\t\t}\n\t\t\tconsole.log(`移动分组啦： 将 ${articleId} 移动到 ${targetId} ${moveForm.tag}`);\n\t\t\tawait moveData(moveForm);\n\t\t}\n\t};\n\tconst moveData = async (moveForm: IMoveType) => {\n\t\tconst { status: successStatus } = (await moveColumnArticleOrGroup(moveForm)) || {};\n\t\tconst { code, msg } = successStatus || {};\n\t\tif (code === 0) {\n\t\t\t// 需要刷新一下列表\n\t\t\tawait fetchTreeData();\n\t\t} else {\n\t\t\tmessage.error(msg);\n\t\t}\n\t};\n\n\t//  ------------------------------------------------------- 下面是在专栏的分组中添加教程 ------------------------------------------\n\t// form值（详情和新增的时候会用到）\n\tconst [form, setForm] = useState<IFormType>(defaultInitForm);\n\t// 文章选择下拉框是否打开\n\tconst [isArticleSelectOpen, setIsArticleSelectOpen] = useState<boolean>(false);\n\t// 详情信息\n\tconst { shortTitle } = form;\n\n\t// 值改变（新增教程文章时，老的做法，将 formRef 放到了这里，不太好）\n\tconst handleChange = (item: MapItem) => {\n\t\tconsole.log(\"选中的内容: \", item);\n\t\tsetForm({ ...form, ...item });\n\t\tformRef.setFieldsValue({ ...item });\n\t};\n\tconst reviseDrawerContent = (\n\t\t<Form name=\"basic\" form={formRef} labelCol={{ span: 4 }} wrapperCol={{ span: 16 }} autoComplete=\"off\">\n\t\t\t<Form.Item label=\"分组\" name=\"groupId\">\n\t\t\t\t{currentGroup?.title}\n\t\t\t</Form.Item>\n\n\t\t\t<Form.Item label=\"教程\" name=\"articleId\" rules={[{ required: true, message: \"请选择教程!\" }]}>\n\t\t\t\t<TableSelect\n\t\t\t\t\tisArticleSelectOpen={isArticleSelectOpen}\n\t\t\t\t\tsetIsArticleSelectOpen={setIsArticleSelectOpen}\n\t\t\t\t\thandleChange={handleChange}\n\t\t\t\t/>\n\t\t\t</Form.Item>\n\n\t\t\t<Form.Item label=\"标题\" name=\"shortTitle\" rules={[{ required: true, message: \"请输入标题!\" }]}>\n\t\t\t\t<Input\n\t\t\t\t\tallowClear\n\t\t\t\t\tplaceholder=\"请输入标题\"\n\t\t\t\t\tvalue={shortTitle}\n\t\t\t\t\tonChange={e => handleChange({ shortTitle: e.target.value })}\n\t\t\t\t/>\n\t\t\t</Form.Item>\n\t\t</Form>\n\t);\n\n\t// 添加教程文章，编辑取消了\n\tconst handleSubmit = async () => {\n\t\tconst values = await formRef.validateFields();\n\t\tconst newValues = {\n\t\t\t...values,\n\t\t\tcolumnId: columnIdParam,\n\t\t\tgroupId: currentGroup?.groupId || 0\n\t\t};\n\t\tconsole.log(\"提交的值:\", newValues);\n\n\t\tconst { status: successStatus } = (await updateColumnArticleApi(newValues)) || {};\n\t\tconst { code, msg } = successStatus || {};\n\t\tif (code === 0) {\n\t\t\tsetIsImportDrawerVisible(false);\n\t\t\tmessage.info(\"教程添加成功\");\n\t\t\t// 需要刷新一下列表\n\t\t\tawait fetchTreeData();\n\t\t} else {\n\t\t\tmessage.error(msg);\n\t\t}\n\t};\n\n\treturn (\n\t\t<div className=\"ColumnArticle\">\n\t\t\t<Card\n\t\t\t\ttitle={\"《\" + columnParam + \"》\"}\n\t\t\t\textra={\n\t\t\t\t\t<Button\n\t\t\t\t\t\tonClick={() => {\n\t\t\t\t\t\t\t// 显示添加分组弹窗\n\t\t\t\t\t\t\tsetCurrentGroup(undefined);\n\t\t\t\t\t\t\tsetIsAddModalVisible(true);\n\t\t\t\t\t\t}}\n\t\t\t\t\t>\n\t\t\t\t\t\t+\n\t\t\t\t\t</Button>\n\t\t\t\t}\n\t\t\t>\n\t\t\t\t<Tree\n\t\t\t\t\tclassName=\"group-tree\"\n\t\t\t\t\tshowIcon\n\t\t\t\t\tdefaultExpandAll={false}\n\t\t\t\t\texpandedKeys={expandedKeys}\n\t\t\t\t\tautoExpandParent={autoExpandParent}\n\t\t\t\t\tonExpand={onExpand}\n\t\t\t\t\tonSelect={onSelect}\n\t\t\t\t\ttreeData={treeData}\n\t\t\t\t\tdraggable={true}\n\t\t\t\t\tonDrop={handleDrop}\n\t\t\t\t/>\n\t\t\t</Card>\n\t\t\t<Modal\n\t\t\t\ttitle=\"添加子目录\"\n\t\t\t\topen={isAddModalVisible}\n\t\t\t\tonCancel={() => setIsAddModalVisible(false)}\n\t\t\t\tfooter={[\n\t\t\t\t\t<Button key=\"cancel\" onClick={() => setIsAddModalVisible(false)}>\n\t\t\t\t\t\t取消\n\t\t\t\t\t</Button>,\n\t\t\t\t\t<Button key=\"save\" type=\"primary\" onClick={handleSaveNewGroup}>\n\t\t\t\t\t\t保存\n\t\t\t\t\t</Button>\n\t\t\t\t]}\n\t\t\t>\n\t\t\t\t<Input placeholder=\"请输入子目录名称\" value={newGroupName} onChange={e => setNewGroupName(e.target.value)} />\n\t\t\t</Modal>\n\t\t\t<Modal\n\t\t\t\ttitle=\"编辑目录\"\n\t\t\t\topen={isEditModalVisible}\n\t\t\t\tonCancel={() => setIsEditModalVisible(false)}\n\t\t\t\tfooter={[\n\t\t\t\t\t<Button key=\"cancel\" onClick={() => setIsEditModalVisible(false)}>\n\t\t\t\t\t\t取消\n\t\t\t\t\t</Button>,\n\t\t\t\t\t<Button key=\"save\" type=\"primary\" onClick={handleUpdateGroup}>\n\t\t\t\t\t\t保存\n\t\t\t\t\t</Button>,\n\t\t\t\t\t<Button key=\"delete\" type=\"primary\" danger onClick={handleDeleteGroup}>\n\t\t\t\t\t\t删除\n\t\t\t\t\t</Button>\n\t\t\t\t]}\n\t\t\t>\n\t\t\t\t<Input placeholder=\"请输入目录名称\" value={newGroupName} onChange={e => setNewGroupName(e.target.value)} />\n\t\t\t</Modal>\n\t\t\t<Drawer\n\t\t\t\ttitle=\"添加\"\n\t\t\t\tsize=\"large\"\n\t\t\t\tplacement=\"right\"\n\t\t\t\textra={\n\t\t\t\t\t<Space>\n\t\t\t\t\t\t<Button onClick={() => setIsImportDrawerVisible(false)}>取消</Button>\n\t\t\t\t\t\t<Button type=\"primary\" onClick={handleSubmit}>\n\t\t\t\t\t\t\tOK\n\t\t\t\t\t\t</Button>\n\t\t\t\t\t</Space>\n\t\t\t\t}\n\t\t\t\tonClose={() => setIsImportDrawerVisible(false)}\n\t\t\t\topen={isImportDrawerVisible}\n\t\t\t>\n\t\t\t\t{reviseDrawerContent}\n\t\t\t</Drawer>\n\t\t</div>\n\t);\n};\n\nconst mapStateToProps = (state: any) => state.disc.disc;\nconst mapDispatchToProps = {};\nexport default connect(mapStateToProps, mapDispatchToProps)(ColumnArticle);\n"
  },
  {
    "path": "src/views/column/setting/index.css",
    "content": ".sort {\n  height: 100%;\n}\n"
  },
  {
    "path": "src/views/column/setting/index.scss",
    "content": ".cell-text {\n  overflow: hidden;\n  text-overflow: ellipsis;\n  display: box;\n  -webkit-line-clamp: 2; /* 控制显示的行数 */\n  -webkit-box-orient: vertical;\n}\n// 增加封面图的样式\n.cover {\n  width: 70px;\n  // height = width * 156 / 110\n  height: 100px;\n  background-size: cover;\n  background-position: center;\n  padding-right: 4px;\n}\n"
  },
  {
    "path": "src/views/column/setting/index.tsx",
    "content": "import { FC, useCallback, useEffect, useState } from \"react\";\nimport { connect } from \"react-redux\";\nimport { DeleteOutlined, EditOutlined, EyeOutlined, SwapOutlined } from \"@ant-design/icons\";\nimport {\n\tAvatar,\n\tButton,\n\tDatePicker,\n\tDescriptions,\n\tDrawer,\n\tForm,\n\tImage,\n\tInput,\n\tInputNumber,\n\tmessage,\n\tModal,\n\tSelect,\n\tSpace,\n\tTable,\n\tTooltip,\n\tUploadFile\n} from \"antd\";\nimport type { ColumnsType } from \"antd/es/table\";\nimport TextArea from \"antd/lib/input/TextArea\";\nimport dayjs, { Dayjs } from \"dayjs\";\n\nimport { delColumnApi, getColumnListApi, updateColumnApi } from \"@/api/modules/column\";\nimport { ContentInterWrap, ContentWrap } from \"@/components/common-wrap\";\nimport { initPagination, IPagination, UpdateEnum } from \"@/enums/common\";\nimport { MapItem } from \"@/typings/common\";\nimport { baseDomain, getCompleteUrl } from \"@/utils/util\";\nimport AuthorSelect from \"./components/authorselect\";\nimport ImgUpload from \"./components/imgupload\";\nimport Search from \"./components/search\";\n\nimport \"dayjs/locale/zh-cn\";\ndayjs.locale(\"zh-cn\");\n\nimport { useNavigate } from \"react-router-dom\";\n\nimport \"./index.scss\";\n\ninterface DataType {\n\tsection: number;\n\tauthor: number;\n\tcolumnId: number;\n\tcolumn: string; // 教程名\n\tstate: number;\n\tfreeEndTime: number;\n\tfreeStartTime: number;\n\ttype: number;\n\tcover: string;\n\tauthorName: string;\n}\n\ninterface IProps {}\n\nexport interface IFormType {\n\tcolumnId: number; // 为0时，是保存，非0是更新\n\tcolumn: string; // 教程名\n\tauthor: number; // 作者ID\n\tintroduction: string; // 简介\n\tcover: string; // 封面 URL\n\ttype: number; // 类型 限时免费 2 登录阅读 1 免费 0\n\tnums: number; // 连载数量\n\tfreeEndTime: number; // 限时免费开始时间\n\tfreeStartTime: number; // 限时免费结束时间\n\tstate: number; // 状态\n\tsection: number; // 排序\n\tauthorAvatar: string; // 作者头像\n\tauthorName: string; // 作者名\n}\n\nconst defaultInitForm: IFormType = {\n\tcolumnId: -1,\n\tcolumn: \"\",\n\tauthor: -1,\n\tintroduction: \"\",\n\tcover: \"\",\n\ttype: -1,\n\tnums: -1,\n\tfreeEndTime: -1,\n\tfreeStartTime: -1,\n\tstate: -1,\n\tsection: -1,\n\tauthorAvatar: \"\",\n\tauthorName: \"\"\n};\n\n// 查询表单接口，定义类型\ninterface ISearchForm {\n\tcolumn: string;\n}\n\n// 查询表单默认值\nconst defaultSearchForm = {\n\tcolumn: \"\"\n};\n\nconst Column: FC<IProps> = props => {\n\tconst dateFormat = \"YYYY/MM/DD\";\n\t// @ts-ignore\n\tconst { ColumnStatus, ColumnStatusList, ColumnType, ColumnTypeList } = props || {};\n\n\t// form值，临时保存一些值\n\tconst [form, setForm] = useState<IFormType>(defaultInitForm);\n\t// 详情的时候，会把信息放到 form 中\n\tconst {\n\t\tcolumnId,\n\t\tcolumn,\n\t\tintroduction,\n\t\tcover,\n\t\tauthorAvatar,\n\t\tauthorName,\n\t\tstate,\n\t\tsection,\n\t\ttype,\n\t\tnums,\n\t\tfreeEndTime,\n\t\tfreeStartTime\n\t} = form;\n\n\tconst detailInfo = [\n\t\t{ label: \"教程名\", title: column },\n\t\t{ label: \"简介\", title: introduction },\n\t\t{ label: \"连载数量\", title: nums },\n\t\t{ label: \"类型\", title: ColumnType[type] },\n\t\t{ label: \"开始时间\", title: dayjs.unix(freeStartTime / 1000).format(dateFormat) },\n\t\t{ label: \"结束时间\", title: dayjs.unix(freeEndTime / 1000).format(dateFormat) },\n\t\t{ label: \"状态\", title: ColumnStatus[state] },\n\t\t{ label: \"排序\", title: section }\n\t].map(({ label, title }) => ({ label, title: title || \"-\" }));\n\n\t// 用户填值的 Form 表单，有些格式可能和后端不一样，需要转换\n\tconst [formRef] = Form.useForm();\n\n\t// 表格查询表单\n\tconst [searchForm, setSearchForm] = useState<ISearchForm>(defaultSearchForm);\n\n\t// 抽屉\n\tconst [isOpenDrawerShow, setIsOpenDrawerShow] = useState<boolean>(false);\n\t// 详情抽屉\n\tconst [isDetailDrawerShow, setIsDetailDrawerShow] = useState<boolean>(false);\n\t// 列表数据\n\tconst [tableData, setTableData] = useState<DataType[]>([]);\n\t// 刷新函数\n\tconst [query, setQuery] = useState<number>(0);\n\n\t//当前的状态，用于新增还是更新，新增的时候不传递 id，更新的时候传递 id\n\tconst [status, setStatus] = useState<UpdateEnum>(UpdateEnum.Save);\n\n\t// 分页\n\tconst [pagination, setPagination] = useState<IPagination>(initPagination);\n\tconst { current, pageSize } = pagination;\n\t// 声明一个 coverList，封面\n\tconst [coverList, setCoverList] = useState<UploadFile[]>([]);\n\n\t// 日期默认值，或者点击编辑时，把 table 中的日期时间赋值给 dateRange\n\tconst [dateRange, setDateRange] = useState<[Dayjs, Dayjs]>([dayjs().add(-7, \"d\"), dayjs()]);\n\n\t// 日期范围组件\n\tconst { RangePicker } = DatePicker;\n\n\tconst navigate = useNavigate();\n\n\tconst rangePresets: {\n\t\tlabel: string;\n\t\tvalue: [Dayjs, Dayjs];\n\t}[] = [\n\t\t{ label: \"最近七天\", value: [dayjs().add(-7, \"d\"), dayjs()] },\n\t\t{ label: \"最近 14 天\", value: [dayjs().add(-14, \"d\"), dayjs()] },\n\t\t{ label: \"最近 30 天\", value: [dayjs().add(-30, \"d\"), dayjs()] },\n\t\t{ label: \"最近 90 天\", value: [dayjs().add(-90, \"d\"), dayjs()] }\n\t];\n\n\tconst paginationInfo = {\n\t\tshowSizeChanger: true,\n\t\tshowTotal: (total: number) => `共 ${total || 0} 条`,\n\t\t...pagination,\n\t\tonChange: (current: number, pageSize: number) => {\n\t\t\tsetPagination({ current, pageSize });\n\t\t}\n\t};\n\n\tconst onSure = useCallback(() => {\n\t\tsetQuery(prev => prev + 1);\n\t}, []);\n\n\t/**\n\t * 在这个 handleChange 函数中，你正在尝试更新 form 状态，\n\t * 并打印更新之前和之后的 form 状态。\n\t * 然而，你可能会发现更新之后打印的 form 状态并没有改变，\n\t * 这是因为 setState 是一个异步操作。\n\t * 当你调用 setForm 函数更新状态后，React 会将这个更新任务放入队列中，\n\t * 然后在未来的某个时间点执行。\n\t * 这意味着在调用 setForm 之后立即打印 form，你将看到的是旧的状态。\n\t *\n\t * @param item\n\t * @returns\n\t */\n\tconst handleChange = (item: MapItem) => {\n\t\t// 把变化的值放到 form 表单中，item 可以是 table 的一行数据（详情、编辑），也可以是单独的表单值发生变化\n\t\tsetForm({ ...form, ...item });\n\t\tconsole.log(\"handleChange 时看看form的值\", item);\n\t};\n\n\tconst handleFormRefChange = (item: MapItem) => {\n\t\t// 当自定义组件更新时，对 formRef 也进行更新\n\t\tconsole.log(\"handleFormRefChange 时看看form的值\", item);\n\t\tformRef.setFieldsValue({ ...item });\n\t};\n\n\t// 查询表单值改变\n\tconst handleSearchChange = (item: MapItem) => {\n\t\t// 当 status 的值为 -1 时，重新显示\n\t\tsetSearchForm({ ...searchForm, ...item });\n\t\tconsole.log(\"查询条件变化了\", searchForm);\n\t};\n\n\t// 当点击查询按钮的时候触发\n\tconst handleSearch = () => {\n\t\t// 目前是根据文章标题搜索，后面需要加上其他条件\n\t\tconsole.log(\"查询条件\", searchForm);\n\t\tonSure();\n\t\t// 查询的时候重置分页\n\t\tsetPagination({ current: 1, pageSize });\n\t};\n\n\t// 抽屉关闭\n\tconst handleClose = () => {\n\t\tsetIsOpenDrawerShow(false);\n\t};\n\n\tconst onRangeChange = (dates: null | (Dayjs | null)[]) => {\n\t\t// 从 dates 中取出 freeStartTime 和 freeEndTime\n\t\t// 日期选择范围框变动的时候，更新 form 中的 freeStartTime 和 freeEndTime\n\t\tlet now = dayjs();\n\t\tlet freeStartTime = now.unix();\n\t\tlet freeEndTime = freeStartTime;\n\n\t\tif (dates) {\n\t\t\t// 从 dates 中取出 freeStartTime 和 freeEndTime\n\t\t\tfreeStartTime = dates[0]?.unix() ?? 0;\n\t\t\tfreeEndTime = dates[1]?.unix() ?? 0;\n\t\t} else {\n\t\t\tconsole.log(\"Clear\");\n\t\t\tfreeStartTime = now.unix();\n\t\t\tfreeEndTime = freeStartTime;\n\t\t}\n\t\tconsole.log(\"freeStartTime\", freeStartTime);\n\t\tconsole.log(\"freeEndTime\", freeEndTime);\n\n\t\t// 更新到 form 中\n\t\tsetForm({ ...form, freeStartTime: freeStartTime * 1000, freeEndTime: freeEndTime * 1000 });\n\t\tsetDateRange([dayjs(freeStartTime * 1000), dayjs(freeEndTime * 1000)]);\n\t};\n\n\t// 重置表单\n\tconst resetFrom = () => {\n\t\tsetForm(defaultInitForm);\n\t\tformRef.resetFields();\n\t\tsetCoverList([]);\n\t};\n\n\t// 删除\n\tconst handleDel = (columnId: number) => {\n\t\tModal.warning({\n\t\t\ttitle: \"确认删除此专栏吗\",\n\t\t\tcontent: \"删除此专栏后无法恢复，请谨慎操作！\",\n\t\t\tmaskClosable: true,\n\t\t\tclosable: true,\n\t\t\tonOk: async () => {\n\t\t\t\tconst { status } = await delColumnApi(columnId);\n\t\t\t\tconst { code, msg } = status || {};\n\t\t\t\tif (code === 0) {\n\t\t\t\t\tmessage.success(\"删除成功\");\n\t\t\t\t\tonSure();\n\t\t\t\t} else {\n\t\t\t\t\tmessage.error(msg);\n\t\t\t\t}\n\t\t\t}\n\t\t});\n\t};\n\n\tconst handleManage = (columnId: number, column: string) => {\n\t\t// 导航到文章排序页面\n\t\tnavigate(\"/column/setting/index/groups\", { state: { columnId, column } });\n\t};\n\n\t// 编辑或者新增时提交数据到服务器端\n\tconst handleSubmit = async () => {\n\t\t// 又从form中获取数据，需要转换格式的数据\n\t\tconst { freeStartTime, freeEndTime, cover, author } = form;\n\t\t// 当 freeStartTime 为 -1 的时候，取当前 dateRange 的值\n\t\tconsole.log(\"handleSubmit 时看看form的值\", form);\n\n\t\t// 从formRef中获取数据，用户填上去可以直接提交的数据\n\t\tconst values = await formRef.validateFields();\n\t\tconsole.log(\"handleSubmit 时看看form的值 values\", values);\n\n\t\t// 新的值传递到后端\n\t\tconst newValues = {\n\t\t\t...values,\n\t\t\tauthor: author,\n\t\t\tcolumnId: status === UpdateEnum.Save ? UpdateEnum.Save : columnId,\n\t\t\tfreeStartTime: freeStartTime,\n\t\t\tfreeEndTime: freeEndTime\n\t\t};\n\t\tconsole.log(\"submit 之前的所有值:\", newValues);\n\n\t\tconst { status: successStatus } = (await updateColumnApi(newValues)) || {};\n\t\tconst { code, msg } = successStatus || {};\n\t\tif (code === 0) {\n\t\t\tsetIsOpenDrawerShow(false);\n\t\t\tsetPagination({ current: 1, pageSize });\n\t\t\tmessage.success(\"提交成功\");\n\t\t\tonSure();\n\t\t} else {\n\t\t\tmessage.error(msg || \"提交失败\");\n\t\t}\n\t};\n\n\t// 列表数据请求\n\tuseEffect(() => {\n\t\tconst getSortList = async () => {\n\t\t\tconst { status, result } = await getColumnListApi({\n\t\t\t\tpageNumber: current,\n\t\t\t\tpageSize,\n\t\t\t\t...searchForm\n\t\t\t});\n\t\t\tconst { code } = status || {};\n\t\t\t//@ts-ignore\n\t\t\tconst { list, pageNum, pageSize: resPageSize, total } = result || {};\n\t\t\tsetPagination({ current: Number(pageNum), pageSize: resPageSize, total });\n\t\t\tif (code === 0) {\n\t\t\t\tconst newList = list.map((item: MapItem) => ({ ...item, key: item?.columnId }));\n\t\t\t\tsetTableData(newList);\n\t\t\t}\n\t\t};\n\t\tgetSortList();\n\t}, [query, current, pageSize]);\n\n\t// 表头设置\n\tconst columns: ColumnsType<DataType> = [\n\t\t{\n\t\t\ttitle: \"封面\",\n\t\t\tdataIndex: \"cover\",\n\t\t\tkey: \"cover\",\n\t\t\twidth: 100,\n\t\t\trender(value) {\n\t\t\t\tconst coverUrl = getCompleteUrl(value);\n\t\t\t\treturn (\n\t\t\t\t\t<div>\n\t\t\t\t\t\t<Image className=\"cover\" src={coverUrl} />\n\t\t\t\t\t</div>\n\t\t\t\t);\n\t\t\t}\n\t\t},\n\t\t{\n\t\t\ttitle: \"专栏名\",\n\t\t\tdataIndex: \"column\",\n\t\t\tkey: \"column\",\n\t\t\trender(value, item) {\n\t\t\t\treturn (\n\t\t\t\t\t<a href={`${baseDomain}/column/${item?.columnId}/1`} className=\"cell-text\" target=\"_blank\" rel=\"noreferrer\">\n\t\t\t\t\t\t{value}\n\t\t\t\t\t</a>\n\t\t\t\t);\n\t\t\t}\n\t\t},\n\n\t\t{\n\t\t\ttitle: \"作者\",\n\t\t\tdataIndex: \"authorName\",\n\t\t\tkey: \"authorName\",\n\t\t\trender(value) {\n\t\t\t\treturn (\n\t\t\t\t\t<>\n\t\t\t\t\t\t<Avatar style={{ backgroundColor: \"#1890ff\", color: \"#fff\" }} size={54}>\n\t\t\t\t\t\t\t{value ? value.slice(0, 4) : \"\"}\n\t\t\t\t\t\t</Avatar>\n\t\t\t\t\t</>\n\t\t\t\t);\n\t\t\t}\n\t\t},\n\t\t{\n\t\t\ttitle: \"类型\",\n\t\t\tdataIndex: \"type\",\n\t\t\tkey: \"type\",\n\t\t\trender(type) {\n\t\t\t\treturn ColumnType[type];\n\t\t\t}\n\t\t},\n\t\t{\n\t\t\ttitle: \"状态\",\n\t\t\tdataIndex: \"state\",\n\t\t\tkey: \"state\",\n\t\t\trender(state) {\n\t\t\t\treturn ColumnStatus[state];\n\t\t\t}\n\t\t},\n\t\t{\n\t\t\ttitle: \"数量\",\n\t\t\tdataIndex: \"nums\",\n\t\t\tkey: \"nums\"\n\t\t},\n\t\t{\n\t\t\ttitle: \"排序\",\n\t\t\tsorter: (a, b) => a.section - b.section,\n\t\t\tdataIndex: \"section\",\n\t\t\tkey: \"section\"\n\t\t},\n\t\t{\n\t\t\ttitle: \"操作\",\n\t\t\tkey: \"key\",\n\t\t\twidth: 200,\n\t\t\trender: (_, item) => {\n\t\t\t\tconst { columnId, column, type, state, cover, freeStartTime, freeEndTime } = item;\n\n\t\t\t\treturn (\n\t\t\t\t\t<div className=\"operation-btn\">\n\t\t\t\t\t\t<Tooltip title=\"查看\">\n\t\t\t\t\t\t\t<Button\n\t\t\t\t\t\t\t\ttype=\"primary\"\n\t\t\t\t\t\t\t\ticon={<EyeOutlined />}\n\t\t\t\t\t\t\t\tstyle={{ marginRight: \"10px\" }}\n\t\t\t\t\t\t\t\tonClick={() => {\n\t\t\t\t\t\t\t\t\thandleChange({\n\t\t\t\t\t\t\t\t\t\t...item\n\t\t\t\t\t\t\t\t\t});\n\t\t\t\t\t\t\t\t\tsetIsDetailDrawerShow(true);\n\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t></Button>\n\t\t\t\t\t\t</Tooltip>\n\t\t\t\t\t\t<Tooltip title=\"调整教程顺序\">\n\t\t\t\t\t\t\t<Button\n\t\t\t\t\t\t\t\ttype=\"primary\"\n\t\t\t\t\t\t\t\ticon={<SwapOutlined />}\n\t\t\t\t\t\t\t\tstyle={{ marginRight: \"10px\" }}\n\t\t\t\t\t\t\t\tonClick={() => {\n\t\t\t\t\t\t\t\t\thandleManage(columnId, column);\n\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t></Button>\n\t\t\t\t\t\t</Tooltip>\n\t\t\t\t\t\t<Tooltip title=\"编辑\">\n\t\t\t\t\t\t\t<Button\n\t\t\t\t\t\t\t\ttype=\"primary\"\n\t\t\t\t\t\t\t\ticon={<EditOutlined />}\n\t\t\t\t\t\t\t\tstyle={{ marginRight: \"10px\" }}\n\t\t\t\t\t\t\t\tonClick={() => {\n\t\t\t\t\t\t\t\t\t// 打开抽屉\n\t\t\t\t\t\t\t\t\tsetIsOpenDrawerShow(true);\n\t\t\t\t\t\t\t\t\t// 设置为更新的状态\n\t\t\t\t\t\t\t\t\tsetStatus(UpdateEnum.Edit);\n\n\t\t\t\t\t\t\t\t\t// 从列表中获取数据，需要转换一下时间格式\n\t\t\t\t\t\t\t\t\t// 此时不能直接从 form 中取出来，所以我们从 item 中取出来了。\n\t\t\t\t\t\t\t\t\tlet coverUrl = getCompleteUrl(cover);\n\t\t\t\t\t\t\t\t\t// 需要把 cover 放到 coverList 中，默认显示\n\t\t\t\t\t\t\t\t\tsetCoverList([\n\t\t\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\t\tuid: \"-1\",\n\t\t\t\t\t\t\t\t\t\t\tname: \"封面图(建议110px*156px)\",\n\t\t\t\t\t\t\t\t\t\t\tstatus: \"done\",\n\t\t\t\t\t\t\t\t\t\t\tthumbUrl: coverUrl,\n\t\t\t\t\t\t\t\t\t\t\turl: coverUrl\n\t\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\t]);\n\n\t\t\t\t\t\t\t\t\tformRef.setFieldsValue({ ...item, type: String(type), state: String(state) });\n\n\t\t\t\t\t\t\t\t\t// 注意把 ID 传过去（更新时需要），还有作者名（显示的时候有用），日期（提交的时候保证有值）\n\t\t\t\t\t\t\t\t\thandleChange({\n\t\t\t\t\t\t\t\t\t\t...item\n\t\t\t\t\t\t\t\t\t});\n\n\t\t\t\t\t\t\t\t\tsetDateRange([dayjs.unix(freeStartTime / 1000), dayjs.unix(freeEndTime / 1000)]);\n\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t></Button>\n\t\t\t\t\t\t</Tooltip>\n\t\t\t\t\t\t<Tooltip title=\"删除\">\n\t\t\t\t\t\t\t<Button type=\"primary\" danger icon={<DeleteOutlined />} onClick={() => handleDel(columnId)}></Button>\n\t\t\t\t\t\t</Tooltip>\n\t\t\t\t\t</div>\n\t\t\t\t);\n\t\t\t}\n\t\t}\n\t];\n\n\t// 编辑表单\n\tconst reviseModalContent = (\n\t\t<Form name=\"basic\" form={formRef} labelCol={{ span: 4 }} wrapperCol={{ span: 16 }} autoComplete=\"off\">\n\t\t\t<Form.Item label=\"专栏名\" name=\"column\" rules={[{ required: true, message: \"请输入专栏名!\" }]}>\n\t\t\t\t<Input\n\t\t\t\t\tallowClear\n\t\t\t\t\tonChange={e => {\n\t\t\t\t\t\thandleChange({ column: e.target.value });\n\t\t\t\t\t}}\n\t\t\t\t/>\n\t\t\t</Form.Item>\n\t\t\t<Form.Item label=\"简介\" name=\"introduction\" rules={[{ required: true, message: \"请输入简介!\" }]}>\n\t\t\t\t<TextArea\n\t\t\t\t\tallowClear\n\t\t\t\t\t// 行数\n\t\t\t\t\trows={3}\n\t\t\t\t\tonChange={e => {\n\t\t\t\t\t\thandleChange({ introduction: e.target.value });\n\t\t\t\t\t}}\n\t\t\t\t/>\n\t\t\t</Form.Item>\n\t\t\t<Form.Item label=\"封面\" name=\"cover\" rules={[{ required: true, message: \"请上传封面!\" }]}>\n\t\t\t\t<ImgUpload\n\t\t\t\t\tcoverList={coverList}\n\t\t\t\t\tcoverName=\"封面图(建议110px*156px)\"\n\t\t\t\t\tsetCoverList={setCoverList}\n\t\t\t\t\thandleFormRefChange={handleFormRefChange}\n\t\t\t\t/>\n\t\t\t</Form.Item>\n\t\t\t<Form.Item label=\"作者\" name=\"author\" rules={[{ required: true, message: \"请选择作者!\" }]}>\n\t\t\t\t<AuthorSelect authorName={authorName} handleChange={handleChange} handleFormRefChange={handleFormRefChange} />\n\t\t\t</Form.Item>\n\n\t\t\t<Form.Item label=\"类型\" name=\"type\" rules={[{ required: true, message: \"请选择类型!\" }]}>\n\t\t\t\t<Select\n\t\t\t\t\tallowClear\n\t\t\t\t\tonChange={value => {\n\t\t\t\t\t\thandleChange({ type: value });\n\t\t\t\t\t}}\n\t\t\t\t\toptions={ColumnTypeList}\n\t\t\t\t/>\n\t\t\t</Form.Item>\n\n\t\t\t<Form.Item label=\"开始结束日期\">\n\t\t\t\t<RangePicker presets={rangePresets} format={dateFormat} value={dateRange} onChange={onRangeChange} />\n\t\t\t</Form.Item>\n\n\t\t\t<Form.Item label=\"状态\" name=\"state\" rules={[{ required: true, message: \"请选择状态!\" }]}>\n\t\t\t\t<Select\n\t\t\t\t\tallowClear\n\t\t\t\t\tonChange={value => {\n\t\t\t\t\t\thandleChange({ state: value });\n\t\t\t\t\t}}\n\t\t\t\t\toptions={ColumnStatusList}\n\t\t\t\t/>\n\t\t\t</Form.Item>\n\n\t\t\t<Form.Item label=\"连载数量\" name=\"nums\" rules={[{ required: true, message: \"请选择连载数量!\" }]}>\n\t\t\t\t<InputNumber\n\t\t\t\t\tonChange={value => {\n\t\t\t\t\t\thandleChange({ nums: value });\n\t\t\t\t\t}}\n\t\t\t\t/>\n\t\t\t</Form.Item>\n\n\t\t\t<Form.Item label=\"排序\" name=\"section\" rules={[{ required: true, message: \"请输入排序\" }]}>\n\t\t\t\t<InputNumber\n\t\t\t\t\tonChange={value => {\n\t\t\t\t\t\thandleChange({ section: value });\n\t\t\t\t\t}}\n\t\t\t\t/>\n\t\t\t</Form.Item>\n\t\t</Form>\n\t);\n\n\treturn (\n\t\t<div className=\"Column\">\n\t\t\t<ContentWrap>\n\t\t\t\t{/* 搜索 */}\n\t\t\t\t<Search\n\t\t\t\t\thandleSearch={handleSearch}\n\t\t\t\t\thandleSearchChange={handleSearchChange}\n\t\t\t\t\tsetStatus={setStatus}\n\t\t\t\t\tsetIsOpenDrawerShow={setIsOpenDrawerShow}\n\t\t\t\t/>\n\t\t\t\t{/* 表格 */}\n\t\t\t\t<ContentInterWrap>\n\t\t\t\t\t<Table columns={columns} dataSource={tableData} pagination={paginationInfo} rowKey=\"columnId\" />\n\t\t\t\t</ContentInterWrap>\n\t\t\t</ContentWrap>\n\n\t\t\t{/* 抽屉 */}\n\t\t\t<Drawer title=\"详情\" placement=\"right\" onClose={() => setIsDetailDrawerShow(false)} open={isDetailDrawerShow}>\n\t\t\t\t<Descriptions column={1} labelStyle={{ width: \"100px\" }}>\n\t\t\t\t\t<Descriptions.Item label=\"头像\">\n\t\t\t\t\t\t<Avatar size={{ xs: 24, sm: 32, md: 40, lg: 64, xl: 80, xxl: 100 }} src={getCompleteUrl(authorAvatar)} />\n\t\t\t\t\t</Descriptions.Item>\n\t\t\t\t\t<Descriptions.Item label=\"教程封面\">\n\t\t\t\t\t\t<Image src={getCompleteUrl(cover)} />\n\t\t\t\t\t</Descriptions.Item>\n\t\t\t\t\t{detailInfo.map(({ label, title }) => (\n\t\t\t\t\t\t<Descriptions.Item label={label} key={label}>\n\t\t\t\t\t\t\t{title !== 0 ? title || \"-\" : 0}\n\t\t\t\t\t\t</Descriptions.Item>\n\t\t\t\t\t))}\n\t\t\t\t</Descriptions>\n\t\t\t</Drawer>\n\n\t\t\t{/* 把弹窗修改为抽屉 */}\n\t\t\t<Drawer\n\t\t\t\ttitle=\"添加/修改\"\n\t\t\t\tplacement=\"right\"\n\t\t\t\tsize=\"large\"\n\t\t\t\textra={\n\t\t\t\t\t<Space>\n\t\t\t\t\t\t<Button onClick={resetFrom}>重置</Button>\n\t\t\t\t\t\t<Button type=\"primary\" onClick={handleSubmit}>\n\t\t\t\t\t\t\tOK\n\t\t\t\t\t\t</Button>\n\t\t\t\t\t</Space>\n\t\t\t\t}\n\t\t\t\tonClose={handleClose}\n\t\t\t\topen={isOpenDrawerShow}\n\t\t\t>\n\t\t\t\t{reviseModalContent}\n\t\t\t</Drawer>\n\t\t</div>\n\t);\n};\n\nconst mapStateToProps = (state: any) => state.disc.disc;\nconst mapDispatchToProps = {};\nexport default connect(mapStateToProps, mapDispatchToProps)(Column);\n"
  },
  {
    "path": "src/views/comment/components/search/index.scss",
    "content": ".comment-search {\n\tmargin-bottom: 16px;\n\n\t&__wrap {\n\t\tdisplay: flex;\n\t\tjustify-content: space-between;\n\t}\n\n\t&__search {\n\t\tflex: 1;\n\t\tdisplay: flex;\n\t\talign-items: center;\n\t\tflex-wrap: wrap;\n\t\tgap: 12px;\n\t}\n\n\t&__search-item {\n\t\tmargin-right: 0;\n\t}\n\n\t&__search-btn {\n\t\tmargin-left: auto;\n\t}\n}\n"
  },
  {
    "path": "src/views/comment/components/search/index.tsx",
    "content": "import { FC } from \"react\";\nimport { PlusCircleOutlined, SearchOutlined } from \"@ant-design/icons\";\nimport { Button, Input, InputNumber, Select, Tooltip } from \"antd\";\n\nimport { ContentInterWrap } from \"@/components/common-wrap\";\n\nimport \"./index.scss\";\n\ninterface IProps {\n\thandleSearchChange: (value: Record<string, any>) => void;\n\thandleSearch: () => void;\n\thandleCreate: () => void;\n}\n\nconst commentTypeOptions = [\n\t{ label: \"全部类型\", value: -1 },\n\t{ label: \"顶级评论\", value: 1 },\n\t{ label: \"回复\", value: 2 }\n];\n\nconst Search: FC<IProps> = ({ handleSearchChange, handleSearch, handleCreate }) => {\n\treturn (\n\t\t<div className=\"comment-search\">\n\t\t\t<ContentInterWrap className=\"comment-search__wrap\">\n\t\t\t\t<div className=\"comment-search__search\">\n\t\t\t\t\t<div className=\"comment-search__search-item\">\n\t\t\t\t\t\t<InputNumber\n\t\t\t\t\t\t\tplaceholder=\"文章ID\"\n\t\t\t\t\t\t\tmin={1}\n\t\t\t\t\t\t\tcontrols={false}\n\t\t\t\t\t\t\tstyle={{ width: 130 }}\n\t\t\t\t\t\t\tonChange={value => handleSearchChange({ articleId: value ? Number(value) : undefined })}\n\t\t\t\t\t\t/>\n\t\t\t\t\t</div>\n\t\t\t\t\t<div className=\"comment-search__search-item\">\n\t\t\t\t\t\t<Input\n\t\t\t\t\t\t\tallowClear\n\t\t\t\t\t\t\tplaceholder=\"文章标题\"\n\t\t\t\t\t\t\tstyle={{ width: 180 }}\n\t\t\t\t\t\t\tonChange={e => handleSearchChange({ articleTitle: e.target.value })}\n\t\t\t\t\t\t/>\n\t\t\t\t\t</div>\n\t\t\t\t\t<div className=\"comment-search__search-item\">\n\t\t\t\t\t\t<Input\n\t\t\t\t\t\t\tallowClear\n\t\t\t\t\t\t\tplaceholder=\"用户名\"\n\t\t\t\t\t\t\tstyle={{ width: 150 }}\n\t\t\t\t\t\t\tonChange={e => handleSearchChange({ userName: e.target.value })}\n\t\t\t\t\t\t/>\n\t\t\t\t\t</div>\n\t\t\t\t\t<div className=\"comment-search__search-item\">\n\t\t\t\t\t\t<Input\n\t\t\t\t\t\t\tallowClear\n\t\t\t\t\t\t\tplaceholder=\"评论内容\"\n\t\t\t\t\t\t\tstyle={{ width: 220 }}\n\t\t\t\t\t\t\tonChange={e => handleSearchChange({ content: e.target.value })}\n\t\t\t\t\t\t/>\n\t\t\t\t\t</div>\n\t\t\t\t\t<div className=\"comment-search__search-item\">\n\t\t\t\t\t\t<Select\n\t\t\t\t\t\t\tallowClear\n\t\t\t\t\t\t\tplaceholder=\"评论类型\"\n\t\t\t\t\t\t\toptions={commentTypeOptions}\n\t\t\t\t\t\t\tstyle={{ width: 130 }}\n\t\t\t\t\t\t\tonChange={value => handleSearchChange({ commentType: Number(value || -1) })}\n\t\t\t\t\t\t/>\n\t\t\t\t\t</div>\n\t\t\t\t\t<div className=\"comment-search__search-btn\">\n\t\t\t\t\t\t<Tooltip title=\"按条件搜索\">\n\t\t\t\t\t\t\t<Button type=\"primary\" icon={<SearchOutlined />} style={{ marginRight: \"10px\" }} onClick={handleSearch} />\n\t\t\t\t\t\t</Tooltip>\n\t\t\t\t\t\t<Tooltip title=\"新增评论\">\n\t\t\t\t\t\t\t<Button type=\"primary\" icon={<PlusCircleOutlined />} onClick={handleCreate} />\n\t\t\t\t\t\t</Tooltip>\n\t\t\t\t\t</div>\n\t\t\t\t</div>\n\t\t\t</ContentInterWrap>\n\t\t</div>\n\t);\n};\n\nexport default Search;\n"
  },
  {
    "path": "src/views/comment/index.scss",
    "content": ".comment-page {\n\t&__table {\n\t\t.cell-link {\n\t\t\tdisplay: inline-block;\n\t\t\tmax-width: 260px;\n\t\t\toverflow: hidden;\n\t\t\ttext-overflow: ellipsis;\n\t\t\twhite-space: nowrap;\n\t\t}\n\n\t\t.cell-content {\n\t\t\tdisplay: -webkit-box;\n\t\t\tmax-width: 320px;\n\t\t\toverflow: hidden;\n\t\t\t-webkit-line-clamp: 2;\n\t\t\t-webkit-box-orient: vertical;\n\t\t\tword-break: break-word;\n\t\t}\n\n\t\t.cell-meta {\n\t\t\tmargin-top: 4px;\n\t\t\tfont-size: 12px;\n\t\t\tcolor: #999;\n\t\t}\n\n\t\t.cell-user {\n\t\t\tdisplay: flex;\n\t\t\talign-items: center;\n\t\t\tgap: 10px;\n\t\t}\n\n\t\t.cell-user-name {\n\t\t\tmax-width: 120px;\n\t\t\toverflow: hidden;\n\t\t\ttext-overflow: ellipsis;\n\t\t\twhite-space: nowrap;\n\t\t}\n\t}\n\n\t&__context {\n\t\tmargin-bottom: 16px;\n\t\tpadding: 12px 14px;\n\t\tbackground: #fafafa;\n\t\tborder: 1px solid #f0f0f0;\n\t\tborder-radius: 8px;\n\t}\n\n\t&__context-title {\n\t\tmargin-bottom: 8px;\n\t\tfont-weight: 600;\n\t\tcolor: #262626;\n\t}\n\n\t&__context-line {\n\t\tmargin-bottom: 6px;\n\t\tcolor: #595959;\n\t\tword-break: break-word;\n\t}\n\n\t&__context-line:last-child {\n\t\tmargin-bottom: 0;\n\t}\n\n\t.operation-btn {\n\t\tdisplay: flex;\n\t\talign-items: center;\n\t\tgap: 8px;\n\t}\n}\n"
  },
  {
    "path": "src/views/comment/index.tsx",
    "content": "import { FC, useCallback, useEffect, useMemo, useState } from \"react\";\nimport { DeleteOutlined, EditOutlined, MessageOutlined } from \"@ant-design/icons\";\nimport { Avatar, Button, Form, Input, InputNumber, message, Modal, Table, Tag, Tooltip } from \"antd\";\nimport type { ColumnsType } from \"antd/es/table\";\nimport dayjs from \"dayjs\";\n\nimport {\n\tCommentAdminDTO,\n\tCommentSaveReq,\n\tdeleteCommentApi,\n\tgetCommentDetailApi,\n\tgetCommentListApi,\n\tsaveCommentApi\n} from \"@/api/modules/comment\";\nimport { ContentInterWrap, ContentWrap } from \"@/components/common-wrap\";\nimport { initPagination, IPagination } from \"@/enums/common\";\nimport { baseDomain } from \"@/utils/util\";\nimport Search from \"./components/search\";\n\nimport \"./index.scss\";\n\ntype ModalMode = \"create\" | \"reply\" | \"edit\";\n\ninterface SearchFormState {\n\tarticleId?: number;\n\tarticleTitle?: string;\n\tuserName?: string;\n\tcontent?: string;\n\tcommentType: number;\n}\n\ninterface FormState {\n\tcommentId?: number;\n\tarticleId?: number;\n\tparentCommentId?: number;\n\ttopCommentId?: number;\n\tcommentContent: string;\n}\n\nconst defaultSearchForm: SearchFormState = {\n\tarticleId: undefined,\n\tarticleTitle: \"\",\n\tuserName: \"\",\n\tcontent: \"\",\n\tcommentType: -1\n};\n\nconst defaultForm: FormState = {\n\tcommentId: undefined,\n\tarticleId: undefined,\n\tparentCommentId: 0,\n\ttopCommentId: 0,\n\tcommentContent: \"\"\n};\n\nconst Comment: FC = () => {\n\tconst [formRef] = Form.useForm<FormState>();\n\tconst [form, setForm] = useState<FormState>(defaultForm);\n\tconst [searchForm, setSearchForm] = useState<SearchFormState>(defaultSearchForm);\n\tconst [tableData, setTableData] = useState<CommentAdminDTO[]>([]);\n\tconst [pagination, setPagination] = useState<IPagination>(initPagination);\n\tconst [query, setQuery] = useState(0);\n\tconst [submitting, setSubmitting] = useState(false);\n\tconst [isModalOpen, setIsModalOpen] = useState(false);\n\tconst [modalMode, setModalMode] = useState<ModalMode>(\"create\");\n\tconst [currentComment, setCurrentComment] = useState<CommentAdminDTO | null>(null);\n\tconst { current, pageSize } = pagination;\n\n\tconst paginationInfo = {\n\t\tshowSizeChanger: true,\n\t\tshowTotal: (total: number) => `共 ${total || 0} 条`,\n\t\t...pagination,\n\t\tonChange: (nextCurrent: number, nextPageSize: number) => {\n\t\t\tsetPagination({ current: nextCurrent, pageSize: nextPageSize });\n\t\t}\n\t};\n\n\tconst refreshList = useCallback(() => {\n\t\tsetQuery(prev => prev + 1);\n\t}, []);\n\n\tconst handleSearchChange = (value: Partial<SearchFormState>) => {\n\t\tsetSearchForm(prev => ({ ...prev, ...value }));\n\t};\n\n\tconst handleSearch = () => {\n\t\tsetPagination(prev => ({ ...prev, current: 1 }));\n\t\trefreshList();\n\t};\n\n\tconst closeModal = () => {\n\t\tsetIsModalOpen(false);\n\t\tsetCurrentComment(null);\n\t\tsetForm(defaultForm);\n\t\tformRef.resetFields();\n\t};\n\n\tconst openCreateModal = () => {\n\t\tsetModalMode(\"create\");\n\t\tsetCurrentComment(null);\n\t\tsetForm(defaultForm);\n\t\tformRef.setFieldsValue(defaultForm);\n\t\tsetIsModalOpen(true);\n\t};\n\n\tconst openReplyModal = async (commentId: number) => {\n\t\tconst { result } = await getCommentDetailApi(commentId);\n\t\tconst nextForm: FormState = {\n\t\t\tcommentId: undefined,\n\t\t\tarticleId: result?.articleId,\n\t\t\tparentCommentId: result?.commentId,\n\t\t\ttopCommentId: result?.commentType === 1 ? result?.commentId : result?.topCommentId,\n\t\t\tcommentContent: \"\"\n\t\t};\n\t\tsetModalMode(\"reply\");\n\t\tsetCurrentComment(result || null);\n\t\tsetForm(nextForm);\n\t\tformRef.setFieldsValue(nextForm);\n\t\tsetIsModalOpen(true);\n\t};\n\n\tconst openEditModal = async (commentId: number) => {\n\t\tconst { result } = await getCommentDetailApi(commentId);\n\t\tconst nextForm: FormState = {\n\t\t\tcommentId: result?.commentId,\n\t\t\tarticleId: result?.articleId,\n\t\t\tparentCommentId: result?.parentCommentId,\n\t\t\ttopCommentId: result?.topCommentId,\n\t\t\tcommentContent: result?.commentContent || \"\"\n\t\t};\n\t\tsetModalMode(\"edit\");\n\t\tsetCurrentComment(result || null);\n\t\tsetForm(nextForm);\n\t\tformRef.setFieldsValue(nextForm);\n\t\tsetIsModalOpen(true);\n\t};\n\n\tconst handleDelete = (commentId: number) => {\n\t\tModal.warning({\n\t\t\ttitle: \"确认删除这条评论吗\",\n\t\t\tcontent: \"删除后无法恢复，顶级评论删除后整条评论串都会在前台隐藏。\",\n\t\t\tmaskClosable: true,\n\t\t\tclosable: true,\n\t\t\tonOk: async () => {\n\t\t\t\tconst { status } = await deleteCommentApi(commentId);\n\t\t\t\tconst { code, msg } = status || {};\n\t\t\t\tif (code === 0) {\n\t\t\t\t\tmessage.success(\"删除成功\");\n\t\t\t\t\trefreshList();\n\t\t\t\t} else {\n\t\t\t\t\tmessage.error(msg || \"删除失败\");\n\t\t\t\t}\n\t\t\t}\n\t\t});\n\t};\n\n\tconst handleSubmit = async () => {\n\t\tconst values = await formRef.validateFields();\n\t\tconst payload: CommentSaveReq = {\n\t\t\tcommentId: modalMode === \"edit\" ? form.commentId : undefined,\n\t\t\tarticleId: Number(values.articleId),\n\t\t\tparentCommentId: Number(values.parentCommentId || 0),\n\t\t\ttopCommentId: Number(values.topCommentId || 0),\n\t\t\tcommentContent: values.commentContent\n\t\t};\n\n\t\tsetSubmitting(true);\n\t\ttry {\n\t\t\tconst { status } = await saveCommentApi(payload);\n\t\t\tconst { code, msg } = status || {};\n\t\t\tif (code === 0) {\n\t\t\t\tmessage.success(modalMode === \"edit\" ? \"评论更新成功\" : \"评论保存成功\");\n\t\t\t\tcloseModal();\n\t\t\t\trefreshList();\n\t\t\t} else {\n\t\t\t\tmessage.error(msg || \"保存失败\");\n\t\t\t}\n\t\t} finally {\n\t\t\tsetSubmitting(false);\n\t\t}\n\t};\n\n\tuseEffect(() => {\n\t\tconst fetchList = async () => {\n\t\t\tconst { result } = await getCommentListApi({\n\t\t\t\tpageNumber: current,\n\t\t\t\tpageSize,\n\t\t\t\t...searchForm\n\t\t\t});\n\t\t\tconst list = result?.list || [];\n\t\t\tconst pageNum = Number(result?.pageNum || current);\n\t\t\tconst resPageSize = Number(result?.pageSize || pageSize);\n\t\t\tconst total = Number(result?.total || 0);\n\t\t\tsetPagination(prev => ({ ...prev, current: pageNum, pageSize: resPageSize, total }));\n\t\t\tsetTableData(list.map(item => ({ ...item, key: item.commentId } as CommentAdminDTO & { key: number })));\n\t\t};\n\t\tvoid fetchList();\n\t}, [query, current, pageSize]);\n\n\tconst modalTitle = useMemo(() => {\n\t\tif (modalMode === \"edit\") return \"编辑评论\";\n\t\tif (modalMode === \"reply\") return \"回复评论\";\n\t\treturn \"新增评论\";\n\t}, [modalMode]);\n\n\tconst modalContext = useMemo(() => {\n\t\tif (!currentComment) {\n\t\t\treturn null;\n\t\t}\n\t\treturn (\n\t\t\t<div className=\"comment-page__context\">\n\t\t\t\t<div className=\"comment-page__context-title\">当前目标</div>\n\t\t\t\t<div className=\"comment-page__context-line\">\n\t\t\t\t\t文章：{currentComment.articleTitle || `文章 ${currentComment.articleId}`}\n\t\t\t\t</div>\n\t\t\t\t<div className=\"comment-page__context-line\">评论ID：{currentComment.commentId}</div>\n\t\t\t\t<div className=\"comment-page__context-line\">原内容：{currentComment.commentContent}</div>\n\t\t\t</div>\n\t\t);\n\t}, [currentComment]);\n\n\tconst columns: ColumnsType<CommentAdminDTO> = [\n\t\t{\n\t\t\ttitle: \"评论ID\",\n\t\t\tdataIndex: \"commentId\",\n\t\t\tkey: \"commentId\",\n\t\t\twidth: 96\n\t\t},\n\t\t{\n\t\t\ttitle: \"文章\",\n\t\t\tdataIndex: \"articleTitle\",\n\t\t\tkey: \"articleTitle\",\n\t\t\twidth: 260,\n\t\t\trender: (_, item) => {\n\t\t\t\tconst commentUrl = `${baseDomain}/article/detail/${item.articleId}#comment-${item.commentId}`;\n\t\t\t\treturn (\n\t\t\t\t\t<div>\n\t\t\t\t\t\t<a href={commentUrl} target=\"_blank\" rel=\"noreferrer\" className=\"cell-link\">\n\t\t\t\t\t\t\t{item.articleTitle || `文章 ${item.articleId}`}\n\t\t\t\t\t\t</a>\n\t\t\t\t\t\t<div className=\"cell-meta\">文章ID：{item.articleId}</div>\n\t\t\t\t\t</div>\n\t\t\t\t);\n\t\t\t}\n\t\t},\n\t\t{\n\t\t\ttitle: \"类型\",\n\t\t\tdataIndex: \"commentType\",\n\t\t\tkey: \"commentType\",\n\t\t\twidth: 96,\n\t\t\trender: value => (value === 1 ? <Tag color=\"blue\">顶级评论</Tag> : <Tag color=\"gold\">回复</Tag>)\n\t\t},\n\t\t{\n\t\t\ttitle: \"内容\",\n\t\t\tdataIndex: \"commentContent\",\n\t\t\tkey: \"commentContent\",\n\t\t\trender: (_, item) => (\n\t\t\t\t<Tooltip title={item.commentContent}>\n\t\t\t\t\t<div>\n\t\t\t\t\t\t<div className=\"cell-content\">{item.commentContent}</div>\n\t\t\t\t\t\t{item.parentCommentId > 0 ? <div className=\"cell-meta\">回复 #{item.parentCommentId}</div> : null}\n\t\t\t\t\t\t{item.parentCommentContent ? <div className=\"cell-meta\">引用：{item.parentCommentContent}</div> : null}\n\t\t\t\t\t</div>\n\t\t\t\t</Tooltip>\n\t\t\t)\n\t\t},\n\t\t{\n\t\t\ttitle: \"作者\",\n\t\t\tdataIndex: \"userName\",\n\t\t\tkey: \"userName\",\n\t\t\twidth: 180,\n\t\t\trender: (_, item) => (\n\t\t\t\t<div className=\"cell-user\">\n\t\t\t\t\t<Avatar src={item.userAvatar}>{item.userName?.slice(0, 2) || \"匿\"}</Avatar>\n\t\t\t\t\t<div>\n\t\t\t\t\t\t<div className=\"cell-user-name\">{item.userName || `用户 ${item.userId}`}</div>\n\t\t\t\t\t\t<div className=\"cell-meta\">UID：{item.userId}</div>\n\t\t\t\t\t</div>\n\t\t\t\t</div>\n\t\t\t)\n\t\t},\n\t\t{\n\t\t\ttitle: \"统计\",\n\t\t\tkey: \"stat\",\n\t\t\twidth: 120,\n\t\t\trender: (_, item) => (\n\t\t\t\t<div>\n\t\t\t\t\t<div>回复：{item.replyCount || 0}</div>\n\t\t\t\t\t<div className=\"cell-meta\">点赞：{item.praiseCount || 0}</div>\n\t\t\t\t</div>\n\t\t\t)\n\t\t},\n\t\t{\n\t\t\ttitle: \"时间\",\n\t\t\tdataIndex: \"createTime\",\n\t\t\tkey: \"createTime\",\n\t\t\twidth: 132,\n\t\t\trender: (_, item) => {\n\t\t\t\tconst createTime = item.createTime ? dayjs(item.createTime) : null;\n\t\t\t\tconst updateTime = item.updateTime ? dayjs(item.updateTime) : null;\n\t\t\t\treturn (\n\t\t\t\t\t<div>\n\t\t\t\t\t\t<div>{createTime ? createTime.format(\"MM-DD HH:mm\") : \"-\"}</div>\n\t\t\t\t\t\t<div className=\"cell-meta\">{updateTime ? `改于 ${updateTime.format(\"MM-DD HH:mm\")}` : \"\"}</div>\n\t\t\t\t\t</div>\n\t\t\t\t);\n\t\t\t}\n\t\t},\n\t\t{\n\t\t\ttitle: \"操作\",\n\t\t\tkey: \"action\",\n\t\t\twidth: 180,\n\t\t\trender: (_, item) => (\n\t\t\t\t<div className=\"operation-btn\">\n\t\t\t\t\t<Tooltip title=\"回复\">\n\t\t\t\t\t\t<Button type=\"primary\" icon={<MessageOutlined />} onClick={() => void openReplyModal(item.commentId)} />\n\t\t\t\t\t</Tooltip>\n\t\t\t\t\t<Tooltip title=\"编辑\">\n\t\t\t\t\t\t<Button type=\"primary\" icon={<EditOutlined />} onClick={() => void openEditModal(item.commentId)} />\n\t\t\t\t\t</Tooltip>\n\t\t\t\t\t<Tooltip title=\"删除\">\n\t\t\t\t\t\t<Button danger type=\"primary\" icon={<DeleteOutlined />} onClick={() => handleDelete(item.commentId)} />\n\t\t\t\t\t</Tooltip>\n\t\t\t\t</div>\n\t\t\t)\n\t\t}\n\t];\n\n\treturn (\n\t\t<div className=\"comment-page\">\n\t\t\t<ContentWrap>\n\t\t\t\t<Search handleSearchChange={handleSearchChange} handleSearch={handleSearch} handleCreate={openCreateModal} />\n\t\t\t\t<ContentInterWrap className=\"comment-page__table\">\n\t\t\t\t\t<Table columns={columns} dataSource={tableData} pagination={paginationInfo} rowKey=\"commentId\" />\n\t\t\t\t</ContentInterWrap>\n\t\t\t</ContentWrap>\n\t\t\t<Modal title={modalTitle} visible={isModalOpen} confirmLoading={submitting} onCancel={closeModal} onOk={handleSubmit}>\n\t\t\t\t{modalContext}\n\t\t\t\t<Form form={formRef} labelCol={{ span: 5 }} wrapperCol={{ span: 18 }} autoComplete=\"off\" initialValues={form}>\n\t\t\t\t\t<Form.Item label=\"文章ID\" name=\"articleId\" rules={[{ required: true, message: \"请输入文章ID\" }]}>\n\t\t\t\t\t\t<InputNumber\n\t\t\t\t\t\t\tmin={1}\n\t\t\t\t\t\t\tcontrols={false}\n\t\t\t\t\t\t\tstyle={{ width: \"100%\" }}\n\t\t\t\t\t\t\tdisabled={modalMode !== \"create\"}\n\t\t\t\t\t\t\tonChange={value => setForm(prev => ({ ...prev, articleId: value ? Number(value) : undefined }))}\n\t\t\t\t\t\t/>\n\t\t\t\t\t</Form.Item>\n\t\t\t\t\t{modalMode !== \"create\" ? (\n\t\t\t\t\t\t<Form.Item label=\"父评论ID\" name=\"parentCommentId\">\n\t\t\t\t\t\t\t<InputNumber controls={false} style={{ width: \"100%\" }} disabled />\n\t\t\t\t\t\t</Form.Item>\n\t\t\t\t\t) : null}\n\t\t\t\t\t{modalMode === \"reply\" ? (\n\t\t\t\t\t\t<Form.Item label=\"顶级评论ID\" name=\"topCommentId\">\n\t\t\t\t\t\t\t<InputNumber controls={false} style={{ width: \"100%\" }} disabled />\n\t\t\t\t\t\t</Form.Item>\n\t\t\t\t\t) : null}\n\t\t\t\t\t<Form.Item\n\t\t\t\t\t\tlabel=\"评论内容\"\n\t\t\t\t\t\tname=\"commentContent\"\n\t\t\t\t\t\trules={[\n\t\t\t\t\t\t\t{ required: true, message: \"请输入评论内容\" },\n\t\t\t\t\t\t\t{ max: 512, message: \"评论内容不能超过 512 个字符\" }\n\t\t\t\t\t\t]}\n\t\t\t\t\t>\n\t\t\t\t\t\t<Input.TextArea\n\t\t\t\t\t\t\trows={6}\n\t\t\t\t\t\t\tshowCount\n\t\t\t\t\t\t\tmaxLength={512}\n\t\t\t\t\t\t\tplaceholder={modalMode === \"reply\" ? \"请输入回复内容\" : \"请输入评论内容\"}\n\t\t\t\t\t\t\tonChange={e => setForm(prev => ({ ...prev, commentContent: e.target.value }))}\n\t\t\t\t\t\t/>\n\t\t\t\t\t</Form.Item>\n\t\t\t\t</Form>\n\t\t\t</Modal>\n\t\t</div>\n\t);\n};\n\nexport default Comment;\n"
  },
  {
    "path": "src/views/config/components/imgupload/ImgCropUpload.tsx",
    "content": "/* eslint-disable prettier/prettier */\nimport React, { useState } from \"react\";\nimport { Upload } from \"antd\";\nimport type { RcFile, UploadFile, UploadProps } from \"antd/es/upload/interface\";\nimport ImgCrop from \"antd-img-crop\";\n\ninterface ImgCropUploadProps {\n\taction: string;\n\tinitialFileList?: UploadFile[];\n\tmaxFiles?: number;\n\tonChange?: (fileList: UploadFile[]) => void;\n\tonPreview?: (file: UploadFile) => void;\n}\n\nconst ImgCropUpload: React.FC<ImgCropUploadProps> = ({ \n\taction, \n\tinitialFileList = [], \n\tmaxFiles = 5, \n\tonChange, onPreview \n}) => {\n\n\tconst [fileList, setFileList] = useState<UploadFile[]>(initialFileList);\n\n\tconst handleChange: UploadProps[\"onChange\"] = ({ fileList: newFileList }) => {\n\t\tsetFileList(newFileList);\n\t\tonChange && onChange(newFileList);\n\t};\n\n\tconst handlePreview = async (file: UploadFile) => {\n\t\tif (onPreview) {\n\t\t\tonPreview(file);\n\t\t} else {\n\t\t\tlet src = file.url as string;\n\t\t\tif (!src) {\n\t\t\t\tsrc = await new Promise(resolve => {\n\t\t\t\t\tconst reader = new FileReader();\n\t\t\t\t\treader.readAsDataURL(file.originFileObj as RcFile);\n\t\t\t\t\treader.onload = () => resolve(reader.result as string);\n\t\t\t\t});\n\t\t\t}\n\t\t\tconst image = new Image();\n\t\t\timage.src = src;\n\t\t\tconst imgWindow = window.open(src);\n\t\t\timgWindow?.document.write(image.outerHTML);\n\t\t}\n\t};\n\n\treturn (\n\t\t<ImgCrop>\n\t\t\t<Upload \n\t\t\t\taction={action} \n\t\t\t\tlistType=\"picture-card\" \n\t\t\t\tfileList={fileList} \n\t\t\t\tonChange={handleChange} \n\t\t\t\tonPreview={handlePreview}>\n\t\t\t\t{fileList.length < maxFiles && \"+ Upload\"}\n\t\t\t</Upload>\n\t\t</ImgCrop>\n\t);\n};\n\nexport default ImgCropUpload;\n"
  },
  {
    "path": "src/views/config/components/imgupload/index.tsx",
    "content": "/* eslint-disable react/prop-types */\n// 这是一个上传图片的组件，使用的是antd的Upload组件\n\nimport { FC } from \"react\";\nimport { UploadOutlined } from \"@ant-design/icons\";\nimport { Button, message, Upload } from \"antd\";\n\nimport { uploadImgApi } from \"@/api/modules/common\";\nimport { getCompleteUrl } from \"@/utils/util\";\n\ninterface IProps {\n\tcoverList: any[];\n\tsetCoverList: (e: any[]) => void;\n\thandleChange: (e: any) => void;\n}\n\nconst ImgUpload: FC<IProps> = ({ coverList, setCoverList, handleChange }) => {\n\tconst customCoverUpload = async (options: any) => {\n\t\tconst { onSuccess, onProgress, onError, file } = options;\n\t\tconsole.log(\"上传图片\", options);\n\t\t// 限制图片大小，不超过 5M\n\t\tif (file.size > 5 * 1024 * 1024) {\n\t\t\tonError(\"图片大小不能超过 5M\");\n\t\t\treturn;\n\t\t}\n\n\t\tconst formData = new FormData();\n\t\tformData.append(\"image\", file);\n\n\t\tconst { status, result } = await uploadImgApi(formData);\n\t\tconst { code, msg } = status || {};\n\t\tconst { imagePath } = result || {};\n\t\tconsole.log(\"上传图片\", status, result, code, msg, imagePath);\n\n\t\tif (code === 0) {\n\t\t\tconsole.log(\"上传图片成功，回调 onsuccess\", imagePath);\n\t\t\tonSuccess(imagePath);\n\t\t\t// 把 data 的值赋给 form 的 bannerUrl，传递给后端\n\t\t\thandleChange({ bannerUrl: imagePath });\n\t\t\tconst coverUrl = getCompleteUrl(imagePath);\n\t\t\tconsole.log(\"上传封面 onchange done\", coverUrl);\n\t\t\t// 更新 coverList\n\t\t\tsetCoverList([\n\t\t\t\t{\n\t\t\t\t\tuid: \"-1\",\n\t\t\t\t\tname: \"封面图(建议70px*100px)\",\n\t\t\t\t\tstatus: \"done\",\n\t\t\t\t\tthumbUrl: coverUrl,\n\t\t\t\t\turl: coverUrl\n\t\t\t\t}\n\t\t\t]);\n\t\t\tconsole.log(\"上传封面 onchange done\", coverList);\n\t\t} else {\n\t\t\tonError(\"上传失败\");\n\t\t}\n\t};\n\n\treturn (\n\t\t<Upload\n\t\t\tcustomRequest={customCoverUpload}\n\t\t\tmultiple={false}\n\t\t\tlistType=\"picture\"\n\t\t\tmaxCount={1}\n\t\t\tfileList={[...coverList]}\n\t\t\taccept=\"image/*\"\n\t\t\tonRemove={() => {\n\t\t\t\tconsole.log(\"删除封面\");\n\t\t\t\t// 删除封面的时候，清空 cover\n\t\t\t\thandleChange({ bannerUrl: \"\" });\n\t\t\t\t// 清空 coverList\n\t\t\t\tsetCoverList([]);\n\t\t\t}}\n\t\t\tonChange={info => {\n\t\t\t\t// clear 的时候记得清空 cover\n\t\t\t\t// submit 的时候要判断 cover 是否为空，空的话提示用户上传\n\t\t\t\tconst { status, name, response } = info.file;\n\t\t\t\tconsole.log(\"上传封面 onchange info\", status, name, response);\n\n\t\t\t\tif (status !== \"uploading\") {\n\t\t\t\t\tconsole.log(\"上传封面 onchange !uploading\");\n\t\t\t\t}\n\t\t\t\tif (status === \"done\") {\n\t\t\t\t\tmessage.success(`${name} 封面上传成功.`);\n\t\t\t\t} else if (status === \"error\") {\n\t\t\t\t\tmessage.error(`封面上传失败，原因：${info.file.error}`);\n\t\t\t\t}\n\t\t\t}}\n\t\t>\n\t\t\t<Button icon={<UploadOutlined />}>Upload</Button>\n\t\t</Upload>\n\t);\n};\nexport default ImgUpload;\n"
  },
  {
    "path": "src/views/config/components/search/index.scss",
    "content": ".config-search {\n\tmargin-bottom: 16px;\n\n\t&__wrap {\n\t\tdisplay: flex;\n\t\tjustify-content: space-between;\n\t\tpadding-left: 48px;\n\t}\n\n\t&__search {\n\t\tflex: 1;\n\t\tdisplay: flex;\n\t\tjustify-content: space-between;\n\t\t&-wrap {\n\t\t\tdisplay: flex;\n\t\t}\n\t}\n\n\t&__search-item {\n\t\tmargin-right: 48px;\n\t}\n\n\t&-label {\n\t\tmargin-right: 12px;\n\t}\n}\n"
  },
  {
    "path": "src/views/config/components/search/index.tsx",
    "content": "/* eslint-disable prettier/prettier */\nimport { FC } from \"react\";\nimport { PlusOutlined, ReloadOutlined, SearchOutlined } from \"@ant-design/icons\";\nimport { Button, Input, Select } from \"antd\";\n\nimport { ContentInterWrap } from \"@/components/common-wrap\";\n\nimport \"./index.scss\";\n\ninterface IProps {\n\tConfigTypeList: any;\n\thandleSearch: (e: object) => void;\n\thandleSearchChange: (e: object) => void;\n\thandleRefresh: () => void;\n\thandleAdd: () => void;\n}\n\nconst Search: FC<IProps> = ({ \n\tConfigTypeList,\n\thandleSearch,\n\thandleSearchChange,\n\thandleRefresh,\n\thandleAdd,\n}) => {\n\treturn (\n\t\t<div className=\"config-search\">\n\t\t\t<ContentInterWrap className=\"config-search__wrap\">\n\t\t\t\t<div className=\"config-search__search \">\n\t\t\t\t\t<div className=\"config-search__search-wrap\">\n\t\t\t\t\t\t<div className=\"config-search__search-item\">\n\t\t\t\t\t\t\t<label className=\"config-search-label\">类型</label>\n\t\t\t\t\t\t\t<Select\n\t\t\t\t\t\t\t\tallowClear\n\t\t\t\t\t\t\t\tstyle={{ width: 152 }}\n\t\t\t\t\t\t\t\tonChange={value => {\n\t\t\t\t\t\t\t\t\tconsole.log(\"查询类型\",value);\n\t\t\t\t\t\t\t\t\thandleSearchChange({ type: Number(value) });\n\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\tplaceholder=\"请选择类型\"\n\t\t\t\t\t\t\t\toptions={ConfigTypeList}\n\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t\t<div className=\"config-search__search-item\">\n\t\t\t\t\t\t\t<label className=\"config-search-label\">名称</label>\n\t\t\t\t\t\t\t<Input \n\t\t\t\t\t\t\t\tallowClear\n\t\t\t\t\t\t\t\tstyle={{ width: 252 }}\n\t\t\t\t\t\t\t\tplaceholder=\"请输入配置名称\"\n\t\t\t\t\t\t\t\tonChange={e => handleSearchChange({ name: e.target.value })} \n\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t</div>\n\t\t\t\t\t<div className=\"config-search__search-btn\">\n\t\t\t\t\t\t<Button \n\t\t\t\t\t\t\ttype=\"default\" \n\t\t\t\t\t\t\ticon={<ReloadOutlined />}\n\t\t\t\t\t\t\tstyle={{ marginRight: \"10px\" }}\n\t\t\t\t\t\t\tonClick={handleRefresh}\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t刷新\n\t\t\t\t\t\t</Button>\n\n\t\t\t\t\t\t<Button \n\t\t\t\t\t\t\ttype=\"primary\" \n\t\t\t\t\t\t\ticon={<SearchOutlined />}\n\t\t\t\t\t\t\tstyle={{ marginRight: \"10px\" }}\n\t\t\t\t\t\t\tonClick={handleSearch}\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t搜索\n\t\t\t\t\t\t</Button>\n\n\t\t\t\t\t\t<Button\n\t\t\t\t\t\t\ttype=\"primary\"\n\t\t\t\t\t\t\ticon={<PlusOutlined />}\n\t\t\t\t\t\t\tstyle={{ marginRight: \"20px\" }}\n\t\t\t\t\t\t\tonClick={handleAdd}\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t添加\n\t\t\t\t\t\t</Button>\n\t\t\t\t\t</div>\n\t\t\t\t</div>\n\t\t\t</ContentInterWrap>\n\t\t</div>\n\t);\n};\nexport default Search;\n"
  },
  {
    "path": "src/views/config/index.css",
    "content": ".sort {\n  height: 100%;\n}\n"
  },
  {
    "path": "src/views/config/index.scss",
    "content": "// 没有边距\n.container {\n\tpadding: 0;\n\tmargin: 0;\n}\n\n"
  },
  {
    "path": "src/views/config/index.tsx",
    "content": "/* eslint-disable react/jsx-no-undef */\n/* eslint-disable prettier/prettier */\nimport { FC, useCallback, useEffect, useState } from \"react\";\nimport { connect } from \"react-redux\";\nimport { DeleteOutlined, EditOutlined, EyeOutlined } from \"@ant-design/icons\";\nimport {\n\tButton,\n\tDescriptions,\n\tDrawer,\n\tForm,\n\tImage,\n\tInput,\n\tInputNumber,\n\tmessage,\n\tModal,\n\tSelect,\n\tSpace,\n\tSwitch,\n\tTable,\n\tTag,\n\tUploadFile\n} from \"antd\";\nimport type { ColumnsType } from \"antd/es/table\";\n\nimport { delConfigApi, getConfigListApi, operateConfigApi, refreshConfigApi, updateConfigApi } from \"@/api/modules/config\";\nimport { ContentInterWrap, ContentWrap } from \"@/components/common-wrap\";\nimport { initPagination, IPagination, UpdateEnum } from \"@/enums/common\";\nimport { MapItem } from \"@/typings/common\";\nimport { getCompleteUrl } from \"@/utils/util\";\nimport ImgUpload from \"./components/imgupload\";\nimport Search from \"./components/search\";\n\nimport \"./index.scss\";\n\nimport \"antd/es/modal/style\";\nimport \"antd/es/slider/style\";\n\ninterface DataType {\n\tbannerUrl: string;\n\tjumpUrl: string;\n\tid: number;\n\tkey: string;\n\tname: string;\n\ttags: string;\n\ttype: number;\n\tstatus: number;\n}\n\ninterface IProps {}\n\n// 编辑新增表单的值类型\nexport interface IFormType {\n\tconfigId: number; // ID\n\ttype: number; // 类型\n\tname: string; // 名称\n\tcontent: string; // 详细描述\n\tbannerUrl: string; // 图片\n\tjumpUrl: string; // 跳转URL\n\trank: number; // 排序\n\ttags: number; // 标签\n}\n\n// 查询表单的值类型\ninterface ISearchFormType {\n\ttype: number; // 类型\n\tname: string; // 名称\n}\n\n// 编辑新增表单默认值\nconst defaultInitForm: IFormType = {\n\tconfigId: -1,\n\ttype: -1,\n\tname: \"\",\n\trank: -1,\n\ttags: -1,\n\tcontent: \"\",\n\tbannerUrl: \"\",\n\tjumpUrl: \"\"\n};\n\n// 查询表单默认值\nconst defaultSearchForm: ISearchFormType = {\n\ttype: -1,\n\tname: \"\"\n};\n\nconst Banner: FC<IProps> = props => {\n\tconst [formRef] = Form.useForm();\n\t// form值\n\tconst [form, setForm] = useState<IFormType>(defaultInitForm);\n\t// 查询表单值\n\tconst [searchForm, setSearchForm] = useState<ISearchFormType>(defaultSearchForm);\n\t// 改成抽屉\n\tconst [isDrawerOpen, setIsDrawerOpen] = useState<boolean>(false);\n\t// 弹窗\n\tconst [isOpenDrawerShow, setIsOpenDrawerShow] = useState<boolean>(false);\n\t// 列表数据\n\tconst [tableData, setTableData] = useState<DataType[]>([]);\n\t// 刷新函数\n\tconst [query, setQuery] = useState<number>(0);\n\n\t//当前的状态\n\tconst [status, setStatus] = useState<UpdateEnum>(UpdateEnum.Save);\n\n\t// 图片\n\tconst [coverList, setCoverList] = useState<UploadFile[]>([]);\n\n\t// 分页\n\tconst [pagination, setPagination] = useState<IPagination>(initPagination);\n\tconst { current, pageSize } = pagination;\n\n\t//@ts-ignore\n\tconst { ConfigType, ConfigTypeList, ArticleTag, ArticleTagList } = props || {};\n\n\tconst { configId, type, name, content, bannerUrl, jumpUrl, rank, tags } = form;\n\n\tconst paginationInfo = {\n\t\tshowSizeChanger: true,\n\t\tshowTotal: (total: number) => `共 ${total || 0} 条`,\n\t\t...pagination,\n\t\tonChange: (current: number, pageSize: number) => {\n\t\t\tsetPagination({ current, pageSize });\n\t\t}\n\t};\n\n\tconst detailInfo = [\n\t\t{ label: \"类型\", title: ConfigType[type] },\n\t\t{ label: \"名称\", title: name },\n\t\t{ label: \"内容\", title: content },\n\t\t{ label: \"排序\", title: rank }\n\t];\n\n\tconst onSure = useCallback(() => {\n\t\tsetQuery(prev => prev + 1);\n\t}, []);\n\n\t// 编辑新增表单值改变\n\tconst handleChange = (item: MapItem) => {\n\t\tconsole.log(\"ConfigTypeList\", ConfigTypeList);\n\t\tsetForm({ ...form, ...item });\n\t};\n\t// 查询表单值改变\n\tconst handleSearchChange = (item: MapItem) => {\n\t\tsetSearchForm({ ...searchForm, ...item });\n\t};\n\n\t// 点击刷新按钮时触发刷新\n\tconst handleRefresh = async () => {\n\t\tconst { status } = await refreshConfigApi();\n\t\tconst { code, msg } = status || {};\n\t\tif (code === 0) {\n\t\t\tmessage.success(\"刷新成功\");\n\t\t} else {\n\t\t\tmessage.error(msg || \"刷新失败\");\n\t\t}\n\t};\n\n\t// 点击搜索按钮时触发搜索\n\tconst handleSearch = () => {\n\t\t// 重置分页\n\t\tsetPagination({ current: 1, pageSize });\n\t\tonSure();\n\t};\n\n\t// 点击取消关闭按钮时触发，关闭抽屉\n\tconst handleClose = () => {\n\t\tsetIsOpenDrawerShow(false);\n\t\tsetIsDrawerOpen(false);\n\t};\n\n\t// 重置表单\n\tconst resetForm = () => {\n\t\tsetForm(defaultInitForm);\n\t\tformRef.resetFields();\n\t};\n\n\t// 新增触发\n\tconst handleAdd = () => {\n\t\tresetForm();\n\t\tsetStatus(UpdateEnum.Save);\n\t\tsetIsDrawerOpen(true);\n\t\t// 图片也清空\n\t\tsetCoverList([]);\n\t};\n\n\t// 删除\n\tconst handleDel = (configId: number) => {\n\t\tModal.warning({\n\t\t\ttitle: \"确认删除此配置吗\",\n\t\t\tcontent: \"删除此配置后无法恢复，请谨慎操作！\",\n\t\t\tmaskClosable: true,\n\t\t\tclosable: true,\n\t\t\tonOk: async () => {\n\t\t\t\tconst { status } = await delConfigApi(configId);\n\t\t\t\tconst { code, msg } = status || {};\n\t\t\t\tif (code === 0) {\n\t\t\t\t\tmessage.success(\"删除成功\");\n\t\t\t\t\t// 触发刷新\n\t\t\t\t\tonSure();\n\t\t\t\t} else {\n\t\t\t\t\tmessage.error(msg);\n\t\t\t\t}\n\t\t\t}\n\t\t});\n\t};\n\n\tconst handleSubmit = async () => {\n\t\tconst values = await formRef.validateFields();\n\t\tconsole.log(\"values\", values);\n\t\t// 从 value 中取出 type 定义为变量 value 并转为 number 类型\n\t\tconst { type } = values;\n\t\t// 转成 number\n\t\tconst typeNumber = Number(type);\n\n\t\t// 如果类型为教程和公告时，详细描述必填\n\t\tif (typeNumber === 4 || typeNumber === 5) {\n\t\t\tif (!values.content) {\n\t\t\t\tmessage.error(\"类型为\" + ConfigType[type] + \"时详细描述不能为空\");\n\t\t\t\treturn;\n\t\t\t}\n\t\t}\n\n\t\tconsole.log(\"bannerUrl\", bannerUrl);\n\n\t\t// 如果类型为教程和 PDF 时，图片不能为空\n\t\tif (typeNumber === 5 || typeNumber === 6) {\n\t\t\tif (!bannerUrl) {\n\t\t\t\tmessage.error(\"类型为\" + ConfigType[type] + \"时图片不能为空\");\n\t\t\t\treturn;\n\t\t\t}\n\t\t}\n\n\t\t// 重写新的值\n\t\tconst newValues = {\n\t\t\t...values,\n\t\t\tbannerUrl: bannerUrl, // 图片要重新赋值，因为 values 中没有\n\t\t\tconfigId: status === UpdateEnum.Save ? UpdateEnum.Save : configId\n\t\t};\n\n\t\tconst { status: successStatus } = (await updateConfigApi(newValues)) || {};\n\t\tconst { code, msg } = successStatus || {};\n\t\tif (code === 0) {\n\t\t\tsetIsDrawerOpen(false);\n\t\t\tsetPagination({ current: 1, pageSize });\n\t\t\tonSure();\n\t\t} else {\n\t\t\tmessage.error(msg);\n\t\t}\n\t};\n\n\t// 上线/下线\n\tconst handleOperate = async (configId: number, pushStatus: number) => {\n\t\tconst { status } = await operateConfigApi({ configId, pushStatus });\n\t\tconst { code, msg } = status || {};\n\t\tif (code === 0) {\n\t\t\tmessage.success(\"操作成功\");\n\t\t\tonSure();\n\t\t} else {\n\t\t\tmessage.error(msg);\n\t\t}\n\t};\n\n\t// 数据请求\n\tuseEffect(() => {\n\t\tconst getConfigList = async () => {\n\t\t\tconsole.log(\"searchForm\", searchForm);\n\t\t\tconst { status, result } = await getConfigListApi({\n\t\t\t\t...searchForm,\n\t\t\t\tpageNumber: current,\n\t\t\t\tpageSize\n\t\t\t});\n\t\t\tconst { code } = status || {};\n\t\t\t//@ts-ignore\n\t\t\tconst { list, pageNum, pageSize: resPageSize, total } = result || {};\n\t\t\tsetPagination({ current: Number(pageNum), pageSize: resPageSize, total });\n\t\t\tif (code === 0) {\n\t\t\t\tconst newList = list.map((item: MapItem) => ({ ...item, key: item.id }));\n\t\t\t\tsetTableData(newList);\n\t\t\t}\n\t\t};\n\t\tgetConfigList();\n\t}, [query, current, pageSize]);\n\n\t// 表头设置\n\tconst columns: ColumnsType<DataType> = [\n\t\t{\n\t\t\ttitle: \"配置名称\",\n\t\t\tdataIndex: \"name\",\n\t\t\tkey: \"name\",\n\t\t\twidth: 400,\n\t\t\trender(name, item) {\n\t\t\t\treturn (\n\t\t\t\t\t<span>\n\t\t\t\t\t\t<Tag color=\"orange\">{ArticleTag[item.tags]}</Tag>\n\t\t\t\t\t\t{name}\n\t\t\t\t\t</span>\n\t\t\t\t);\n\t\t\t}\n\t\t},\n\t\t{\n\t\t\ttitle: \"类型\",\n\t\t\tdataIndex: \"type\",\n\t\t\tkey: \"type\",\n\t\t\trender(type) {\n\t\t\t\treturn ConfigType[type];\n\t\t\t}\n\t\t},\n\t\t{\n\t\t\ttitle: \"上下线\",\n\t\t\tdataIndex: \"status\",\n\t\t\tkey: \"status\",\n\t\t\trender(status, item) {\n\t\t\t\t// switch 组件\n\t\t\t\treturn (\n\t\t\t\t\t<Switch\n\t\t\t\t\t\tchecked={status === 1}\n\t\t\t\t\t\tonChange={() => {\n\t\t\t\t\t\t\tconst pushStatus = status === 0 ? 1 : 0;\n\t\t\t\t\t\t\thandleOperate(item.id, pushStatus);\n\t\t\t\t\t\t}}\n\t\t\t\t\t/>\n\t\t\t\t);\n\t\t\t}\n\t\t},\n\t\t{\n\t\t\ttitle: \"排序\",\n\t\t\tdataIndex: \"rank\",\n\t\t\tkey: \"rank\"\n\t\t},\n\t\t{\n\t\t\ttitle: \"操作\",\n\t\t\tkey: \"key\",\n\t\t\twidth: 300,\n\t\t\trender: (_, item) => {\n\t\t\t\tconst { id, type, status } = item;\n\t\t\t\treturn (\n\t\t\t\t\t<div className=\"operation-btn\">\n\t\t\t\t\t\t<Button\n\t\t\t\t\t\t\ttype=\"primary\"\n\t\t\t\t\t\t\ticon={<EyeOutlined />}\n\t\t\t\t\t\t\tstyle={{ marginRight: \"10px\" }}\n\t\t\t\t\t\t\tonClick={() => {\n\t\t\t\t\t\t\t\tsetIsOpenDrawerShow(true);\n\t\t\t\t\t\t\t\thandleChange({ configId: id, ...item });\n\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t详情\n\t\t\t\t\t\t</Button>\n\t\t\t\t\t\t<Button\n\t\t\t\t\t\t\ttype=\"primary\"\n\t\t\t\t\t\t\ticon={<EditOutlined />}\n\t\t\t\t\t\t\tstyle={{ marginRight: \"10px\" }}\n\t\t\t\t\t\t\tonClick={() => {\n\t\t\t\t\t\t\t\tsetIsDrawerOpen(true);\n\t\t\t\t\t\t\t\tsetStatus(UpdateEnum.Edit);\n\t\t\t\t\t\t\t\tconst { bannerUrl } = item;\n\t\t\t\t\t\t\t\tconsole.log(\"点击编辑 bannerUrl\", bannerUrl);\n\t\t\t\t\t\t\t\thandleChange({ configId: id, bannerUrl: bannerUrl });\n\t\t\t\t\t\t\t\t// 需要设置 image 的值\n\t\t\t\t\t\t\t\tconst coverUrl = getCompleteUrl(bannerUrl);\n\t\t\t\t\t\t\t\t// 更新 coverList\n\t\t\t\t\t\t\t\tsetCoverList([\n\t\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\tuid: \"-1\",\n\t\t\t\t\t\t\t\t\t\tname: \"封面图(建议70px*100px)\",\n\t\t\t\t\t\t\t\t\t\tstatus: \"done\",\n\t\t\t\t\t\t\t\t\t\tthumbUrl: coverUrl,\n\t\t\t\t\t\t\t\t\t\turl: coverUrl\n\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t]);\n\t\t\t\t\t\t\t\tformRef.setFieldsValue({ ...item, type: String(type), status: String(status) });\n\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t编辑\n\t\t\t\t\t\t</Button>\n\t\t\t\t\t\t<Button type=\"primary\" danger icon={<DeleteOutlined />} onClick={() => handleDel(id)}>\n\t\t\t\t\t\t\t删除\n\t\t\t\t\t\t</Button>\n\t\t\t\t\t</div>\n\t\t\t\t);\n\t\t\t}\n\t\t}\n\t];\n\n\t// 编辑表单\n\tconst reviseDrawerContent = (\n\t\t<Form name=\"basic\" form={formRef} labelCol={{ span: 4 }} wrapperCol={{ span: 16 }} autoComplete=\"off\">\n\t\t\t<Form.Item label=\"类型\" name=\"type\" rules={[{ required: true, message: \"请选择类型!\" }]}>\n\t\t\t\t<Select\n\t\t\t\t\tallowClear\n\t\t\t\t\tonChange={value => {\n\t\t\t\t\t\thandleChange({ type: value });\n\t\t\t\t\t}}\n\t\t\t\t\toptions={ConfigTypeList}\n\t\t\t\t/>\n\t\t\t</Form.Item>\n\t\t\t<Form.Item label=\"名称\" name=\"name\" rules={[{ required: true, message: \"请输入名称!\" }]}>\n\t\t\t\t<Input\n\t\t\t\t\tallowClear\n\t\t\t\t\tonChange={e => {\n\t\t\t\t\t\thandleChange({ name: e.target.value });\n\t\t\t\t\t}}\n\t\t\t\t/>\n\t\t\t</Form.Item>\n\t\t\t<Form.Item label=\"详细描述\" name=\"content\" tooltip=\"对公告和教程类型的配置进行一些简短介绍，不要太多字哦\">\n\t\t\t\t<Input.TextArea\n\t\t\t\t\tallowClear\n\t\t\t\t\tmaxLength={120}\n\t\t\t\t\tonChange={e => {\n\t\t\t\t\t\thandleChange({ content: e.target.value });\n\t\t\t\t\t}}\n\t\t\t\t/>\n\t\t\t</Form.Item>\n\t\t\t<Form.Item label=\"图片\" name=\"bannerUrl\" tooltip=\"类型为教程和PDF需要，建议尺寸：70*100\">\n\t\t\t\t<ImgUpload coverList={coverList} setCoverList={setCoverList} handleChange={handleChange} />\n\t\t\t</Form.Item>\n\t\t\t<Form.Item\n\t\t\t\ttooltip=\"可以是内部或者外部链接\"\n\t\t\t\tlabel=\"跳转URL\"\n\t\t\t\tname=\"jumpUrl\"\n\t\t\t\trules={[{ required: true, message: \"请输入跳转URL!\" }]}\n\t\t\t>\n\t\t\t\t<Input.TextArea\n\t\t\t\t\tallowClear\n\t\t\t\t\tonChange={e => {\n\t\t\t\t\t\thandleChange({ jumpUrl: e.target.value });\n\t\t\t\t\t}}\n\t\t\t\t/>\n\t\t\t</Form.Item>\n\n\t\t\t<Form.Item\n\t\t\t\ttooltip=\"在用户端显示的时候会用到\"\n\t\t\t\tlabel=\"标签\"\n\t\t\t\tname=\"tags\"\n\t\t\t\trules={[{ required: false, message: \"请选择标签!\" }]}\n\t\t\t>\n\t\t\t\t<Select\n\t\t\t\t\tallowClear\n\t\t\t\t\tonChange={value => {\n\t\t\t\t\t\thandleChange({ tags: value });\n\t\t\t\t\t}}\n\t\t\t\t\toptions={ArticleTagList}\n\t\t\t\t/>\n\t\t\t</Form.Item>\n\t\t\t<Form.Item label=\"排序\" name=\"rank\" rules={[{ required: true, message: \"请输入排序!\" }]}>\n\t\t\t\t<InputNumber\n\t\t\t\t\tmin={0}\n\t\t\t\t\tonChange={value => {\n\t\t\t\t\t\thandleChange({ rank: value });\n\t\t\t\t\t}}\n\t\t\t\t/>\n\t\t\t</Form.Item>\n\t\t</Form>\n\t);\n\n\treturn (\n\t\t<div>\n\t\t\t<ContentWrap className=\"container\">\n\t\t\t\t{/* 搜索 */}\n\t\t\t\t<Search\n\t\t\t\t\tConfigTypeList={ConfigTypeList}\n\t\t\t\t\thandleSearchChange={handleSearchChange}\n\t\t\t\t\thandleSearch={handleSearch}\n\t\t\t\t\thandleRefresh={handleRefresh}\n\t\t\t\t\thandleAdd={handleAdd}\n\t\t\t\t/>\n\t\t\t\t{/* 表格 */}\n\t\t\t\t<ContentInterWrap>\n\t\t\t\t\t<Table columns={columns} dataSource={tableData} pagination={paginationInfo} />\n\t\t\t\t</ContentInterWrap>\n\t\t\t</ContentWrap>\n\t\t\t{/* 抽屉 */}\n\t\t\t<Drawer title=\"详情\" placement=\"right\" width={500} onClose={handleClose} open={isOpenDrawerShow}>\n\t\t\t\t<Descriptions column={1} labelStyle={{ width: \"100px\" }}>\n\t\t\t\t\t{detailInfo.map(({ label, title }) => (\n\t\t\t\t\t\t<Descriptions.Item label={label} key={label}>\n\t\t\t\t\t\t\t{title || \"-\"}\n\t\t\t\t\t\t</Descriptions.Item>\n\t\t\t\t\t))}\n\t\t\t\t\t{/* 标签显示 */}\n\t\t\t\t\t<Descriptions.Item label=\"标签\" key=\"tags\">\n\t\t\t\t\t\t<Tag color=\"orange\">{ArticleTag[tags]}</Tag>\n\t\t\t\t\t</Descriptions.Item>\n\t\t\t\t\t{/* 链接显示 */}\n\t\t\t\t\t<Descriptions.Item label=\"跳转URL\" key=\"jumpUrl\">\n\t\t\t\t\t\t<Button type=\"link\" style={{ padding: 0, margin: 0 }} target=\"_blank\" href={jumpUrl}>\n\t\t\t\t\t\t\t{jumpUrl}\n\t\t\t\t\t\t</Button>\n\t\t\t\t\t</Descriptions.Item>\n\t\t\t\t\t{/* 图片组件显示 */}\n\t\t\t\t\t<Descriptions.Item label=\"图片\" key=\"bannerUrl\">\n\t\t\t\t\t\t<Image width={100} src={getCompleteUrl(bannerUrl)} />\n\t\t\t\t\t</Descriptions.Item>\n\t\t\t\t</Descriptions>\n\t\t\t</Drawer>\n\t\t\t{/* 弹窗 */}\n\t\t\t<Drawer\n\t\t\t\ttitle=\"添加/修改\"\n\t\t\t\topen={isDrawerOpen}\n\t\t\t\twidth={620}\n\t\t\t\tonClose={handleClose}\n\t\t\t\textra={\n\t\t\t\t\t<Space>\n\t\t\t\t\t\t<Button onClick={handleClose}>取消</Button>\n\t\t\t\t\t\t<Button type=\"primary\" onClick={handleSubmit}>\n\t\t\t\t\t\t\t确定\n\t\t\t\t\t\t</Button>\n\t\t\t\t\t</Space>\n\t\t\t\t}\n\t\t\t>\n\t\t\t\t{reviseDrawerContent}\n\t\t\t</Drawer>\n\t\t</div>\n\t);\n};\n\nconst mapStateToProps = (state: any) => state.disc.disc;\nconst mapDispatchToProps = {};\nexport default connect(mapStateToProps, mapDispatchToProps)(Banner);\n"
  },
  {
    "path": "src/views/global/components/search/index.scss",
    "content": ".global-config-search {\n  margin-bottom: 16px;\n\n  &__wrap {\n    display: flex;\n    justify-content: space-between;\n    padding-left: 48px;\n  }\n\n  &__search {\n    flex: 1;\n    display: flex;\n\t\tjustify-content: space-between;\n\t\t&-wrap {\n\t\t\tdisplay: flex;\n\t\t}\n  }\n\n  &__search-item {\n    margin-right: 48px;\n  }\n\n  &-label {\n    margin-right: 12px;\n  }\n}\n"
  },
  {
    "path": "src/views/global/components/search/index.tsx",
    "content": "/* eslint-disable prettier/prettier */\nimport { FC } from \"react\";\nimport { PlusOutlined, SearchOutlined } from \"@ant-design/icons\";\nimport { Button, Input } from \"antd\";\n\nimport { ContentInterWrap } from \"@/components/common-wrap\";\n\nimport \"./index.scss\";\n\ninterface IProps {\n\thandleSearch: (e: object) => void;\n\thandleSearchChange: (e: object) => void;\n\thandleAdd: () => void;\n}\n\nconst Search: FC<IProps> = ({ handleSearch, handleSearchChange, handleAdd }) => {\n\treturn (\n\t\t<div className=\"global-config-search\">\n\t\t\t<ContentInterWrap className=\"global-config-search__wrap\">\n\t\t\t\t<div className=\"global-config-search__search\">\n\t\t\t\t\t<div className=\"global-config-search__search-wrap\">\n\t\t\t\t\t\t<div className=\"global-config-search__search-item\">\n\t\t\t\t\t\t\t<Input\n\t\t\t\t\t\t\t\tallowClear\n\t\t\t\t\t\t\t\tstyle={{ width: 152 }}\n\t\t\t\t\t\t\t\tplaceholder=\"请输入配置项名称\"\n\t\t\t\t\t\t\t\tonChange={e => handleSearchChange({ keywords: e.target.value })}\n\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t\t<div className=\"global-config-search__search-item\">\n\t\t\t\t\t\t\t<Input\n\t\t\t\t\t\t\t\tallowClear\n\t\t\t\t\t\t\t\tstyle={{ width: 252 }}\n\t\t\t\t\t\t\t\tplaceholder=\"请输入配置项值\"\n\t\t\t\t\t\t\t\tonChange={e => handleSearchChange({ value: e.target.value })}\n\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t\t<div className=\"global-config-search__search-item\">\n\t\t\t\t\t\t\t<Input\n\t\t\t\t\t\t\t\tallowClear\n\t\t\t\t\t\t\t\tstyle={{ width: 152 }}\n\t\t\t\t\t\t\t\tplaceholder=\"请输入备注\"\n\t\t\t\t\t\t\t\tonChange={e => handleSearchChange({ comment: e.target.value })}\n\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t</div>\n\t\t\t\t\t<div className=\"tag-search__search-btn\">\n\t\t\t\t\t\t<Button type=\"primary\" icon={<SearchOutlined />} style={{ marginRight: \"10px\" }} onClick={handleSearch}>\n\t\t\t\t\t\t\t搜索\n\t\t\t\t\t\t</Button>\n\t\t\t\t\t\t<Button type=\"primary\" icon={<PlusOutlined />} style={{ marginRight: \"20px\" }} onClick={handleAdd}>\n\t\t\t\t\t\t\t添加\n\t\t\t\t\t\t</Button>\n\t\t\t\t\t</div>\n\t\t\t\t</div>\n\t\t\t</ContentInterWrap>\n\t\t</div>\n\t);\n};\nexport default Search;\n"
  },
  {
    "path": "src/views/global/index.scss",
    "content": ""
  },
  {
    "path": "src/views/global/index.tsx",
    "content": "/* eslint-disable prettier/prettier */\nimport { FC, useCallback, useEffect, useState } from \"react\";\nimport { connect } from \"react-redux\";\nimport { DeleteOutlined, EditOutlined } from \"@ant-design/icons\";\nimport { Button, Drawer, Form, Input, message, Modal, Space, Switch, Table } from \"antd\";\nimport type { ColumnsType } from \"antd/es/table\";\n\nimport { delGlobalConfigApi, getGlobalConfigListApi, updateGlobalConfigApi } from \"@/api/modules/global\";\nimport { ContentInterWrap, ContentWrap } from \"@/components/common-wrap\";\nimport { initPagination, IPagination, UpdateEnum } from \"@/enums/common\";\nimport { MapItem } from \"@/typings/common\";\nimport Search from \"./components/search\";\n\nimport \"./index.scss\";\n\ninterface DataType {\n\tid: number;\n\tkeywords: string;\n\tvalue: string;\n\tcomment: string;\n}\n\ninterface IProps {}\n\nexport interface IFormType {\n\tid: number; // 为0时，是保存，非0是更新\n\tkeywords: string; // 键名\n\tvalue: string; // 键值\n\tcomment: string; // 备注\n}\n\nconst defaultInitForm: IFormType = {\n\tid: -1,\n\tkeywords: \"\",\n\tvalue: \"\",\n\tcomment: \"\"\n};\n\nconst GlobalConfig: FC<IProps> = props => {\n\tconst [formRef] = Form.useForm();\n\t// form值\n\tconst [form, setForm] = useState<IFormType>(defaultInitForm);\n\t// 查询表单值\n\tconst [searchForm, setSearchForm] = useState<IFormType>(defaultInitForm);\n\t// 抽屉\n\tconst [isDrawerOpen, setIsDrawerOpen] = useState<boolean>(false);\n\t// 列表数据\n\tconst [tableData, setTableData] = useState<DataType[]>([]);\n\t// 刷新函数\n\tconst [query, setQuery] = useState<number>(0);\n\n\t//当前的状态\n\tconst [status, setStatus] = useState<UpdateEnum>(UpdateEnum.Save);\n\n\t// 分页\n\tconst [pagination, setPagination] = useState<IPagination>(initPagination);\n\tconst { current, pageSize } = pagination;\n\n\tconst paginationInfo = {\n\t\tshowSizeChanger: true,\n\t\tshowTotal: (total: number) => `共 ${total || 0} 条`,\n\t\t...pagination,\n\t\tonChange: (current: number, pageSize: number) => {\n\t\t\tsetPagination({ current, pageSize });\n\t\t}\n\t};\n\n\tconst { id } = form;\n\n\tconst onSure = useCallback(() => {\n\t\tsetQuery(prev => prev + 1);\n\t}, []);\n\n\t// 值改变\n\tconst handleChange = (item: MapItem) => {\n\t\tsetForm({ ...form, ...item });\n\t\tconsole.log(form, item);\n\t};\n\t// 查询表单值改变\n\tconst handleSearchChange = (item: MapItem) => {\n\t\tsetSearchForm({ ...searchForm, ...item });\n\t};\n\t// 点击搜索按钮时触发搜索\n\tconst handleSearch = () => {\n\t\tsetPagination({ current: 1, pageSize });\n\t\tonSure();\n\t};\n\t// 抽屉关闭\n\tconst handleClose = () => {\n\t\tsetIsDrawerOpen(false);\n\t};\n\t// 重置表单\n\tconst resetForm = () => {\n\t\tsetForm(defaultInitForm);\n\t};\n\t// 新增触发\n\tconst handleAdd = () => {\n\t\tresetForm();\n\t\tsetStatus(UpdateEnum.Save);\n\t\tsetIsDrawerOpen(true);\n\t};\n\n\t// 删除\n\tconst handleDel = (id: number) => {\n\t\tModal.warning({\n\t\t\ttitle: \"确认删除此全局配置项吗\",\n\t\t\tcontent: \"删除此全局配置项后无法恢复，请谨慎操作！\",\n\t\t\tmaskClosable: true,\n\t\t\tclosable: true,\n\t\t\tonOk: async () => {\n\t\t\t\tconst { status } = await delGlobalConfigApi(id);\n\t\t\t\tconst { code, msg } = status || {};\n\t\t\t\tconsole.log();\n\t\t\t\tif (code === 0) {\n\t\t\t\t\tmessage.success(\"删除成功\");\n\t\t\t\t\tonSure();\n\t\t\t\t} else {\n\t\t\t\t\tmessage.error(msg);\n\t\t\t\t}\n\t\t\t}\n\t\t});\n\t};\n\n\tconst handleSubmit = async () => {\n\t\tconst values = await formRef.validateFields();\n\t\tconst newValues = {\n\t\t\t...values,\n\t\t\tid: status === UpdateEnum.Save ? UpdateEnum.Save : id\n\t\t};\n\t\tconsole.log(\"提交的时候\", newValues);\n\t\tconst { status: successStatus } = (await updateGlobalConfigApi(newValues)) || {};\n\t\tconst { code, msg } = successStatus || {};\n\t\tif (code === 0) {\n\t\t\tsetIsDrawerOpen(false);\n\t\t\tsetPagination({ current: 1, pageSize });\n\t\t\tonSure();\n\t\t} else {\n\t\t\tmessage.error(msg);\n\t\t}\n\t};\n\n\t// 数据请求\n\tuseEffect(() => {\n\t\tconst getSortList = async () => {\n\t\t\tconsole.log(\"searchForm\", searchForm);\n\t\t\tconst { status, result } = await getGlobalConfigListApi({\n\t\t\t\t...searchForm,\n\t\t\t\tpageNumber: current,\n\t\t\t\tpageSize\n\t\t\t});\n\t\t\tconst { code } = status || {};\n\t\t\t//@ts-ignore\n\t\t\tconst { list, pageNum, pageSize: resPageSize, pageTotal, total } = result || {};\n\t\t\tsetPagination({ current: Number(pageNum), pageSize: resPageSize, total });\n\t\t\tif (code === 0) {\n\t\t\t\tconst newList = list.map((item: MapItem) => ({ ...item, key: item?.id }));\n\t\t\t\tsetTableData(newList);\n\t\t\t}\n\t\t};\n\t\tgetSortList();\n\t}, [query, current, pageSize]);\n\n\t// 表头设置\n\tconst columns: ColumnsType<DataType> = [\n\t\t{\n\t\t\ttitle: \"配置项名\",\n\t\t\tdataIndex: \"keywords\",\n\t\t\tkey: \"keywords\"\n\t\t},\n\t\t{\n\t\t\ttitle: \"配置项值\",\n\t\t\tdataIndex: \"value\",\n\t\t\twidth: 400,\n\t\t\tkey: \"value\",\n\t\t\trender: (text) => (\n\t\t\t\t<div style={{\n\t\t\t\t\twordWrap: 'break-word',\n\t\t\t\t\twordBreak: 'break-all',\n\t\t\t\t\tmaxHeight: '4rem', // 3行的高度，可以根据字体大小调整\n\t\t\t\t\toverflow: 'hidden',\n\t\t\t\t\ttextOverflow: 'ellipsis',\n\t\t\t\t\tdisplay: '-webkit-box',\n\t\t\t\t\tWebkitLineClamp: 3, // 限制显示3行\n\t\t\t\t\tWebkitBoxOrient: 'vertical',\n\t\t\t\t}}>\n\t\t\t\t\t{text}\n\t\t\t\t</div>\n\t\t\t)\n\t\t},\t\t\n\t\t{\n\t\t\ttitle: \"备注\",\n\t\t\tdataIndex: \"comment\",\n\t\t\tkey: \"comment\"\n\t\t},\n\t\t{\n\t\t\ttitle: \"操作\",\n\t\t\tkey: \"key\",\n\t\t\twidth: 210,\n\t\t\trender: (_, item) => {\n\t\t\t\treturn (\n\t\t\t\t\t<div className=\"operation-btn\">\n\t\t\t\t\t\t<Button\n\t\t\t\t\t\t\ttype=\"primary\"\n\t\t\t\t\t\t\ticon={<EditOutlined />}\n\t\t\t\t\t\t\tstyle={{ marginRight: \"10px\" }}\n\t\t\t\t\t\t\tonClick={() => {\n\t\t\t\t\t\t\t\tsetIsDrawerOpen(true);\n\t\t\t\t\t\t\t\tsetStatus(UpdateEnum.Edit);\n\t\t\t\t\t\t\t\thandleChange({ ...item });\n\t\t\t\t\t\t\t\tformRef.setFieldsValue({ ...item });\n\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t编辑\n\t\t\t\t\t\t</Button>\n\n\t\t\t\t\t\t<Button type=\"primary\" danger icon={<DeleteOutlined />} onClick={() => handleDel(item.id)}>\n\t\t\t\t\t\t\t删除\n\t\t\t\t\t\t</Button>\n\t\t\t\t\t</div>\n\t\t\t\t);\n\t\t\t}\n\t\t}\n\t];\n\n\t// 编辑表单\n\tconst reviseDrawerContent = (\n\t\t<Form name=\"basic\" form={formRef} labelCol={{ span: 4 }} wrapperCol={{ span: 16 }} autoComplete=\"off\">\n\t\t\t<Form.Item label=\"配置项名\" name=\"keywords\" rules={[{ required: true, message: \"请输入配置项名称!\" }]}>\n\t\t\t\t<Input\n\t\t\t\t\tallowClear\n\t\t\t\t\tonChange={e => {\n\t\t\t\t\t\thandleChange({ keywords: e.target.value });\n\t\t\t\t\t}}\n\t\t\t\t/>\n\t\t\t</Form.Item>\n\t\t\t<Form.Item label=\"配置项值\" name=\"value\" rules={[{ required: true, message: \"请输入配置项值!\" }]}>\n\t\t\t\t<Input.TextArea\n\t\t\t\t\tallowClear\n\t\t\t\t\tonChange={e => {\n\t\t\t\t\t\thandleChange({ value: e.target.value });\n\t\t\t\t\t}}\n\t\t\t\t/>\n\t\t\t</Form.Item>\n\t\t\t<Form.Item label=\"备注\" name=\"comment\" rules={[{ required: true, message: \"请输入备注!\" }]}>\n\t\t\t\t<Input\n\t\t\t\t\tallowClear\n\t\t\t\t\tonChange={e => {\n\t\t\t\t\t\thandleChange({ comment: e.target.value });\n\t\t\t\t\t}}\n\t\t\t\t/>\n\t\t\t</Form.Item>\n\t\t</Form>\n\t);\n\n\treturn (\n\t\t<div className=\"banner\">\n\t\t\t<ContentWrap>\n\t\t\t\t{/* 搜索 */}\n\t\t\t\t<Search handleSearchChange={handleSearchChange} handleSearch={handleSearch} handleAdd={handleAdd} />\n\t\t\t\t{/* 表格 */}\n\t\t\t\t<ContentInterWrap>\n\t\t\t\t\t<Table columns={columns} dataSource={tableData} pagination={paginationInfo} />\n\t\t\t\t</ContentInterWrap>\n\t\t\t</ContentWrap>\n\t\t\t{/* 抽屉 */}\n\t\t\t<Drawer\n\t\t\t\ttitle=\"添加/修改\"\n\t\t\t\topen={isDrawerOpen}\n\t\t\t\tsize=\"large\"\n\t\t\t\tonClose={handleClose}\n\t\t\t\textra={\n\t\t\t\t\t<Space>\n\t\t\t\t\t\t<Button onClick={handleClose}>取消</Button>\n\t\t\t\t\t\t<Button type=\"primary\" onClick={handleSubmit}>\n\t\t\t\t\t\t\t确定\n\t\t\t\t\t\t</Button>\n\t\t\t\t\t</Space>\n\t\t\t\t}\n\t\t\t>\n\t\t\t\t{reviseDrawerContent}\n\t\t\t</Drawer>\n\t\t</div>\n\t);\n};\n\nconst mapStateToProps = (state: any) => state.disc.disc;\nconst mapDispatchToProps = {};\nexport default connect(mapStateToProps, mapDispatchToProps)(GlobalConfig);\n"
  },
  {
    "path": "src/views/home/index.scss",
    "content": ".home {\n  display: flex;\n  align-items: center;\n  justify-content: center;\n  height: 100%;\n\n  img {\n    width: 70%;\n  }\n}\n"
  },
  {
    "path": "src/views/home/index.tsx",
    "content": "import welcome from \"@/assets/images/welcome01.png\";\n\nimport \"./index.scss\";\n\nconst Home = () => {\n\treturn (\n\t\t<div className=\"home card\">\n\t\t\t<img src={welcome} alt=\"welcome\" />\n\t\t</div>\n\t);\n};\n\nexport default Home;\n"
  },
  {
    "path": "src/views/login/components/LoginForm.tsx",
    "content": "/* eslint-disable prettier/prettier */\nimport { useState } from \"react\";\nimport { connect, useDispatch } from \"react-redux\";\nimport { useNavigate } from \"react-router-dom\";\nimport { CloseCircleOutlined, LockOutlined, UserOutlined } from \"@ant-design/icons\";\nimport { Button, Form, Input, message } from \"antd\";\n\nimport { Login } from \"@/api/interface\";\nimport { loginApi } from \"@/api/modules/login\";\nimport { HOME_URL } from \"@/config/config\";\nimport { AppDispatch, store } from \"@/redux\";\nimport { getDiscListAction } from \"@/redux/modules/disc/action\";\nimport { setToken, setUserInfo } from \"@/redux/modules/global/action\";\nimport { setTabsList } from \"@/redux/modules/tabs/action\";\n\nconst LoginForm = (props: any) => {\n\tconst { setToken, setTabsList, setUserInfo } = props;\n\n\tconst navigate = useNavigate();\n\tconst [form] = Form.useForm();\n\tconst [loading, setLoading] = useState<boolean>(false);\n\n\tconst dispatch: AppDispatch = useDispatch();\n\n\t// 登录\n\tconst onFinish = async (loginForm: Login.ReqLoginForm) => {\n\t\ttry {\n\t\t\t// 开启 loading\n\t\t\tsetLoading(true);\n\t\t\t// 发送登录请求\n\t\t\tconst { status, result } = await loginApi(loginForm);\n\t\t\tif (status && status.code == 0 && result && result.userId > 0) {\n\t\t\t\tmessage.success(\"登录成功\");\n\n\t\t\t\t// 用户登录信息\n\t\t\t\tsetUserInfo(result);\n\t\t\t\t// 使用 dispatch 来调用 setToken action，将 token 保存到 Redux 的状态中\n\t\t\t\tstore.dispatch(setToken(result.userId));\n\t\t\t\t// tab 清空，可以采用 tab 的方式打开页面\n\t\t\t\tsetTabsList([]);\n\t\t\t\t// 强制 getDiscListAction 获取字典数据先执行\n\t\t\t\t// 否则 naviage 跳转到首页后，字典数据还没有获取到，会导致页面渲染不出来\n\t\t\t\tawait dispatch(getDiscListAction());\n\n\t\t\t\t// 跳转到首页\n\t\t\t\tnavigate(HOME_URL);\n\t\t\t} else {\n\t\t\t\tmessage.success(\"登录失败:\" + status?.msg);\n\t\t\t}\n\t\t} finally {\n\t\t\tsetLoading(false);\n\t\t}\n\t};\n\n\tconst onFinishFailed = (errorInfo: any) => {\n\t\tconsole.log(\"Failed:\", errorInfo);\n\t};\n\n\treturn (\n\t\t<Form\n\t\t\tform={form}\n\t\t\tname=\"basic\"\n\t\t\tlabelCol={{ span: 5 }}\n\t\t\tinitialValues={{ remember: true }}\n\t\t\tonFinish={onFinish}\n\t\t\tonFinishFailed={onFinishFailed}\n\t\t\tsize=\"large\"\n\t\t\tautoComplete=\"off\"\n\t\t>\n\t\t\t<Form.Item name=\"username\" rules={[{ required: true, message: \"请输入用户名\" }]}>\n\t\t\t\t<Input placeholder=\"用户名（admin）\" prefix={<UserOutlined />} />\n\t\t\t</Form.Item>\n\t\t\t<Form.Item name=\"password\" rules={[{ required: true, message: \"请输入密码\" }]}>\n\t\t\t\t<Input.Password autoComplete=\"new-password\" placeholder=\"密码（微信搜 沉默王二 回复 002）\" prefix={<LockOutlined />} />\n\t\t\t</Form.Item>\n\t\t\t<Form.Item className=\"login-btn\">\n\t\t\t\t<Button\n\t\t\t\t\tonClick={() => {\n\t\t\t\t\t\tform.resetFields();\n\t\t\t\t\t}}\n\t\t\t\t\ticon={<CloseCircleOutlined />}\n\t\t\t\t>\n\t\t\t\t\t重置\n\t\t\t\t</Button>\n\t\t\t\t<Button type=\"primary\" htmlType=\"submit\" loading={loading} icon={<UserOutlined />}>\n\t\t\t\t\t登录\n\t\t\t\t</Button>\n\t\t\t</Form.Item>\n\t\t</Form>\n\t);\n};\n\nconst mapDispatchToProps = { setToken, setTabsList, setUserInfo, getDiscListAction };\nexport default connect(null, mapDispatchToProps)(LoginForm);\n"
  },
  {
    "path": "src/views/login/index.less",
    "content": ".login-container {\n\tposition: relative;\n\tdisplay: flex;\n\talign-items: center;\n\tjustify-content: center;\n\tmin-width: 550px;\n\theight: 100%;\n\tmin-height: 500px;\n\tbackground-image: url(\"@/assets/images/login_bg.svg\");\n\tbackground-position: 50%;\n\tbackground-size: 100% 100%;\n\tbackground-size: cover;\n\t.dark {\n\t\tposition: absolute;\n\t\ttop: 5%;\n\t\tright: 3.2%;\n\t}\n\t.login-box {\n\t\tbox-sizing: border-box;\n\t\tdisplay: flex;\n\t\talign-items: center;\n\t\tjustify-content: space-around;\n\t\twidth: 96%;\n\t\theight: 94%;\n\t\tpadding: 0 4% 0 20px;\n\t\toverflow: hidden;\n\t\tborder-radius: 10px;\n\t\t.login-left {\n\t\t\twidth: 750px;\n\t\t\timg {\n\t\t\t\twidth: 100%;\n\t\t\t\theight: 100%;\n\t\t\t}\n\t\t}\n\t\t.login-form {\n\t\t\tpadding: 40px 45px 25px;\n\t\t\tborder-radius: 10px;\n\t\t\t.login-logo {\n\t\t\t\tdisplay: flex;\n\t\t\t\talign-items: center;\n\t\t\t\tjustify-content: center;\n\t\t\t\tmargin-bottom: 40px;\n\t\t\t\t.login-icon {\n\t\t\t\t\twidth: 200px;\n\t\t\t\t}\n\t\t\t\t.logo-text {\n\t\t\t\t\tpadding-left: 25px;\n\t\t\t\t\tfont-size: 48px;\n\t\t\t\t\tfont-weight: bold;\n\t\t\t\t\twhite-space: nowrap;\n\t\t\t\t}\n\t\t\t}\n\t\t\t.ant-form-item {\n\t\t\t\theight: 75px;\n\t\t\t\tmargin-bottom: 0;\n\t\t\t\t.ant-input-prefix {\n\t\t\t\t\tmargin-right: 10px;\n\t\t\t\t}\n\t\t\t\t.ant-input-affix-wrapper-lg {\n\t\t\t\t\tpadding: 8.3px 11px;\n\t\t\t\t}\n\t\t\t\t.ant-input-affix-wrapper,\n\t\t\t\t.ant-input-lg {\n\t\t\t\t\tfont-size: 14px;\n\t\t\t\t}\n\t\t\t\t.ant-input-affix-wrapper {\n\t\t\t\t\tcolor: #bfbfbf;\n\t\t\t\t}\n\t\t\t}\n\t\t\t.login-btn {\n\t\t\t\twidth: 100%;\n\t\t\t\tmargin-top: 10px;\n\t\t\t\twhite-space: nowrap;\n\t\t\t\t.ant-form-item-control-input-content {\n\t\t\t\t\tmin-width: 400px;\n\t\t\t\t\tdisplay: flex;\n\t\t\t\t\tjustify-content: space-between;\n\t\t\t\t\t.ant-btn {\n\t\t\t\t\t\twidth: 180px;\n\t\t\t\t\t\tspan {\n\t\t\t\t\t\t\tfont-size: 14px;\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n}\n"
  },
  {
    "path": "src/views/login/index.tsx",
    "content": "import loginLeft from \"@/assets/images/login_left.png\";\nimport logo from \"@/assets/images/logo.svg\";\nimport SwitchDark from \"@/components/SwitchDark\";\nimport LoginForm from \"./components/LoginForm\";\n\nimport \"./index.less\";\n\nconst Login = () => {\n\treturn (\n\t\t<div className=\"login-container\">\n\t\t\t<SwitchDark />\n\t\t\t<div className=\"login-box\">\n\t\t\t\t<div className=\"login-left\">\n\t\t\t\t\t<img src={loginLeft} alt=\"login\" />\n\t\t\t\t</div>\n\t\t\t\t<div className=\"login-form\">\n\t\t\t\t\t<div className=\"login-logo\">\n\t\t\t\t\t\t<img className=\"login-icon\" src={logo} alt=\"logo\" />\n\t\t\t\t\t</div>\n\t\t\t\t\t<LoginForm />\n\t\t\t\t</div>\n\t\t\t</div>\n\t\t</div>\n\t);\n};\n\nexport default Login;\n"
  },
  {
    "path": "src/views/resume/components/search/index.scss",
    "content": ".tag-search {\n\tmargin-bottom: 16px;\n\n\t&__wrap {\n\t\tdisplay: flex;\n\t\tjustify-content: space-between;\n\t\tpadding-left: 48px;\n\t}\n\n\t&__search {\n\t\tflex: 1;\n\t\tdisplay: flex;\n\t\tjustify-content: space-between;\n\n\t\t&-wrap {\n\t\t\tdisplay: flex;\n\t\t}\n\t}\n\n\t&__search-item {\n\t\tmargin-right: 48px;\n\t}\n\n\t&-label {\n\t\tmargin-right: 12px;\n\t}\n}\n"
  },
  {
    "path": "src/views/resume/components/search/index.tsx",
    "content": "/* eslint-disable prettier/prettier */\nimport { FC } from \"react\";\nimport { PlusOutlined, SearchOutlined } from \"@ant-design/icons\";\nimport { Button, Input, Select } from \"antd\";\n\nimport { ContentInterWrap } from \"@/components/common-wrap\";\n\nimport \"./index.scss\";\n\ninterface IProps {\n\thandleSearch: (e: object) => void;\n\thandleSearchChange: (e: object) => void;\n}\n\nconst Search: FC<IProps> = ({ handleSearch, handleSearchChange }) => {\n\treturn (\n\t\t<div className=\"tag-search\">\n\t\t\t<ContentInterWrap className=\"tag-search__wrap\">\n\t\t\t\t<div className=\"tag-search__search\">\n\t\t\t\t\t<div className=\"tag-search__search-wrap\">\n\t\t\t\t\t\t<div className=\"tag-search__search-item\">\n\t\t\t\t\t\t\t<label className=\"tag-search-label\">用户</label>\n\t\t\t\t\t\t\t<Input\n\t\t\t\t\t\t\t\tallowClear\n\t\t\t\t\t\t\t\tstyle={{ width: 252 }}\n\t\t\t\t\t\t\t\tplaceholder=\"请输入用户名\"\n\t\t\t\t\t\t\t\tonChange={e => handleSearchChange({ uname: e.target.value })}\n\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t\t<div className=\"config-search__search-item\">\n\t\t\t\t\t\t\t<label className=\"config-search-label\">状态</label>\n\t\t\t\t\t\t\t<Select\n\t\t\t\t\t\t\t\tallowClear\n\t\t\t\t\t\t\t\tstyle={{ width: 152 }}\n\t\t\t\t\t\t\t\tdefaultValue={\"0\"}\n\t\t\t\t\t\t\t\tonChange={value => {\n\t\t\t\t\t\t\t\t\tconsole.log(\"查询类型\", value);\n\t\t\t\t\t\t\t\t\thandleSearchChange({ type: Number(value) });\n\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\tplaceholder=\"请选择类型\"\n\t\t\t\t\t\t\t\toptions={[\n\t\t\t\t\t\t\t\t\t{ value: \"0\", label: \"未处理\" },\n\t\t\t\t\t\t\t\t\t{ value: \"1\", label: \"处理中\" },\n\t\t\t\t\t\t\t\t\t{ value: \"2\", label: \"已回复\" }\n\t\t\t\t\t\t\t\t]}\n\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t</div>\n\t\t\t\t\t<div className=\"tag-search__search-btn\">\n\t\t\t\t\t\t<Button type=\"primary\" icon={<SearchOutlined />} style={{ marginRight: \"10px\" }} onClick={handleSearch}>\n\t\t\t\t\t\t\t搜索\n\t\t\t\t\t\t</Button>\n\t\t\t\t\t</div>\n\t\t\t\t</div>\n\t\t\t</ContentInterWrap>\n\t\t</div>\n\t);\n};\nexport default Search;\n"
  },
  {
    "path": "src/views/resume/index.css",
    "content": ".sort {\n  height: 100%;\n}\n"
  },
  {
    "path": "src/views/resume/index.scss",
    "content": "// 没有边距\n.container {\n\tpadding: 0;\n\tmargin: 0;\n}\n"
  },
  {
    "path": "src/views/resume/index.tsx",
    "content": "/* eslint-disable prettier/prettier */\nimport { FC, useCallback, useEffect, useRef, useState } from \"react\";\nimport { connect } from \"react-redux\";\nimport { DeleteOutlined, DownloadOutlined, EditOutlined, InboxOutlined, UploadOutlined } from \"@ant-design/icons\";\nimport { Button, Drawer, Form, Input, message, Modal, Space, Table, Tag, Upload } from \"antd\";\nimport type { ColumnsType } from \"antd/es/table\";\nimport dayjs from \"dayjs\";\n\nimport { uploadFileUrl } from \"@/api/modules/common\";\nimport { delResumeApi, downResumeApi, getResumeListApi, replayResumeApi } from \"@/api/modules/resume\";\nimport { ContentInterWrap, ContentWrap } from \"@/components/common-wrap\";\nimport { initPagination, IPagination, UpdateEnum } from \"@/enums/common\";\nimport { MapItem } from \"@/typings/common\";\nimport { baseDomain } from \"@/utils/util\";\nimport Search from \"./components/search\";\n\nimport \"./index.scss\";\n\ninterface DataType {\n\tresumeId: number;\n\tresumeName: string;\n\tresumeUrl: string;\n\treplayUrl: string;\n\tuname: string;\n\tuid: number;\n\ttag: string;\n\tstatus: number;\n\ttype: string;\n\ttypeVal: number;\n}\n\nconst { Dragger } = Upload;\n\ninterface IProps {}\n\nexport interface IFormType {\n\tuserId: number; // 为0时，是保存，非0是更新\n\tuname: string; // 用户名\n\ttype: number; // 类型\n}\n\nconst defaultInitForm: IFormType = {\n\tuserId: -1,\n\tuname: \"\",\n\ttype: 0\n};\n\nconst Resume: FC<IProps> = props => {\n\tconst [formRef] = Form.useForm();\n\t// form值\n\tconst [form, setForm] = useState<IFormType>(defaultInitForm);\n\t// 查询表单值\n\tconst [searchForm, setSearchForm] = useState<IFormType>(defaultInitForm);\n\t// 抽屉\n\tconst [isDrawerOpen, setIsDrawerOpen] = useState<boolean>(false);\n\t// 列表数据\n\tconst [tableData, setTableData] = useState<DataType[]>([]);\n\t// 刷新函数\n\tconst [query, setQuery] = useState<number>(0);\n\n\t//当前的状态\n\tconst [status, setStatus] = useState<UpdateEnum>(UpdateEnum.Save);\n\n\tconst [currentResume, setCurrentResume] = useState<any>();\n\tconst [replayUrl, setReplayUrl] = useState<string>();\n\n\t// 分页\n\tconst [pagination, setPagination] = useState<IPagination>(initPagination);\n\tconst { current, pageSize, total } = pagination;\n\tconst paginationInfo = {\n\t\t...pagination,\n\t\tshowSizeChanger: true,\n\t\tshowTotal: (total: number) => `共 ${total || 0} 条`,\n\t\tonChange: (current: number, pageSize: number) => {\n\t\t\tsetPagination({ current, pageSize });\n\t\t}\n\t};\n\n\tconst { userId, uname, type } = form;\n\n\tconst onSure = useCallback(() => {\n\t\tsetQuery(prev => prev + 1);\n\t}, []);\n\n\t// 下载简历附件\n\tconst handleDownload = async (item: MapItem) => {\n\t\tlet { resumeUrl, resumeName, resumeId, uname } = item;\n\t\tif (resumeUrl) {\n\t\t\tif (!resumeUrl.startsWith(\"http\")) {\n\t\t\t\tresumeUrl = `${baseDomain}${resumeUrl}`;\n\t\t\t}\n\t\t\tfetch(resumeUrl).then(res =>\n\t\t\t\tres.blob().then(async blob => {\n\t\t\t\t\tlet a = document.createElement(\"a\");\n\t\t\t\t\tlet url = window.URL.createObjectURL(blob);\n\t\t\t\t\tlet filename = res.headers.get(\"Content-Disposition\");\n\t\t\t\t\tfilename = `${uname}-${resumeName}`; //提 取文件名\n\t\t\t\t\tconsole.log(\"下载文件并准备重命名\", filename);\n\t\t\t\t\t\ta.href = url;\n\t\t\t\t\t\ta.download = filename; //给下载下来的文件起个名字\n\t\t\t\t\t\ta.click();\n\t\t\t\t\t\twindow.URL.revokeObjectURL(url);\n\n\t\t\t\t\t\tawait updateResumeStatus(item);\n\t\t\t\t\t})\n\t\t\t\t);\n\t\t}\n\t};\n\t// 下载附件之后，将状态更新为处理中\n\tconst updateResumeStatus = async (item: MapItem) => {\n\t\tif (item.typeVal == 0) {\n\t\t\t// 将状态更新为处理中\n\t\t\tconst { status } = await downResumeApi(item.resumeId);\n\t\t\tconst { code, msg } = status || {};\n\t\t\tconsole.log();\n\t\t\tif (code === 0) {\n\t\t\t\tmessage.success(\"已下载\");\n\t\t\t\tonSure();\n\t\t\t} else {\n\t\t\t\tmessage.error(msg);\n\t\t\t}\n\t\t}\n\t};\n\t// 值改变\n\tconst handleChange = (item: MapItem) => {\n\t\tsetForm({ ...form, ...item });\n\t};\n\t// 查询表单值改变\n\tconst handleSearchChange = (item: MapItem) => {\n\t\tconsole.log(\"表达查询值发生变更!\");\n\t\tsetSearchForm({ ...searchForm, ...item });\n\t};\n\t// 点击搜索按钮时触发搜索\n\tconst handleSearch = () => {\n\t\tconsole.log(\"执行搜索\");\n\t\t// setPagination(initPagination);\n\t\tonSure();\n\t};\n\t// 显示抽屉\n\tconst handleShowDrawer = (item: any) => {\n\t\tsetCurrentResume(item);\n\t\tsetIsDrawerOpen(true);\n\t\tsetReplayUrl(\"\");\n\t};\n\t// 抽屉关闭\n\tconst handleClose = () => {\n\t\tsetIsDrawerOpen(false);\n\t};\n\t// 删除\n\tconst handleDel = (resumeId: number) => {\n\t\tconsole.log(\"删除的简历id -> \", resumeId);\n\t\tModal.warning({\n\t\t\ttitle: \"确认删除此简历么\",\n\t\t\tcontent: \"删除后无法恢复，请谨慎操作！\",\n\t\t\tmaskClosable: true,\n\t\t\tclosable: true,\n\t\t\tonOk: async () => {\n\t\t\t\tconst { status } = await delResumeApi(resumeId);\n\t\t\t\tconst { code, msg } = status || {};\n\t\t\t\tconsole.log();\n\t\t\t\tif (code === 0) {\n\t\t\t\t\tmessage.success(\"删除成功\");\n\t\t\t\t\tonSure();\n\t\t\t\t} else {\n\t\t\t\t\tmessage.error(msg);\n\t\t\t\t}\n\t\t\t}\n\t\t});\n\t};\n\n\tconst handleSubmit = async () => {\n\t\tconst values = await formRef.validateFields();\n\t\tconst newValues = {\n\t\t\t...values,\n\t\t\tresumeId: currentResume.resumeId\n\t\t};\n\t\tif (replayUrl) {\n\t\t\tnewValues[\"replayUrl\"] = replayUrl;\n\t\t}\n\t\tconsole.log(\"提交回复的内容: \", newValues);\n\t\tconst { status: successStatus } = (await replayResumeApi(newValues)) || {};\n\t\tconst { code, msg } = successStatus || {};\n\t\tif (code === 0) {\n\t\t\tsetIsDrawerOpen(false);\n\t\t\tsetPagination({ current: 1, pageSize });\n\t\t\tonSure();\n\t\t} else {\n\t\t\tmessage.error(msg);\n\t\t}\n\t};\n\n\t// 数据请求\n\tuseEffect(() => {\n\t\tconst getSortList = async () => {\n\t\t\tconst { status, result } = await getResumeListApi({\n\t\t\t\t...searchForm,\n\t\t\t\tpageNumber: current,\n\t\t\t\tpageSize\n\t\t\t});\n\n\t\t\tconst { code } = status || {};\n\t\t\t//@ts-ignore\n\t\t\tconst { list, pageNum, pageSize: resPageSize, pageTotal, total } = result || {};\n\t\t\tif (total != pagination.total || Number(pageNum) != pagination.current || resPageSize != pagination.pageSize) {\n\t\t\t\tconsole.log(\"刷新分页信息!\", pagination, total, pageNum, resPageSize);\n\t\t\t\tsetPagination({ current: Number(pageNum), pageSize: resPageSize, total });\n\t\t\t}\n\t\t\tif (code === 0) {\n\t\t\t\tconst newList = list.map((item: MapItem) => ({\n\t\t\t\t\tkey: item.resume.resumeId,\n\t\t\t\t\tresumeId: item.resume.resumeId,\n\t\t\t\t\tuname: item.user.name,\n\t\t\t\t\tuavatar: item.user.avatar,\n\t\t\t\t\temail: item.resume.replayEmail,\n\t\t\t\t\tmark: item.resume.mark,\n\t\t\t\t\tresumeName: item.resume.resumeName,\n\t\t\t\t\tresumeUrl: item.resume.resumeUrl,\n\t\t\t\t\treplay: item.resume.replay,\n\t\t\t\t\treplayUrl: item.resume.replayUrl,\n\t\t\t\t\ttypeVal: item.resume.type,\n\t\t\t\t\ttype: item.resume.type == 0 ? \"未处理\" : item.resume.type == 1 ? \"处理中\" : \"已回复\",\n\t\t\t\t\tcreateTime: item.resume.createTime,\n\t\t\t\t\tupdateTime: item.resume.updateTime\n\t\t\t\t}));\n\t\t\t\tconsole.log(\"请求的简历列表信息:\", newList);\n\t\t\t\tsetTableData(newList);\n\t\t\t}\n\t\t};\n\t\tgetSortList();\n\t\tconsole.trace(\"请求调用栈! query\", query, current, pageSize);\n\t}, [query, current, pageSize]);\n\n\t// 表头设置\n\tconst columns: ColumnsType<DataType> = [\n\t\t{\n\t\t\ttitle: \"用户\",\n\t\t\tdataIndex: \"uname\",\n\t\t\tkey: \"uname\",\n\t\t\tfixed: \"left\"\n\t\t},\n\t\t{\n\t\t\ttitle: \"邮箱\",\n\t\t\tdataIndex: \"email\",\n\t\t\tkey: \"email\",\n\t\t\tfixed: \"left\",\n\t\t\trender: (value: string) => {\n\t\t\t\treturn <a href={`mailto:${value}`}>{value}</a>;\n\t\t\t}\n\t\t},\n\t\t{\n\t\t\ttitle: \"提交时间\",\n\t\t\tdataIndex: \"createTime\",\n\t\t\tkey: \"createTime\",\n\t\t\trender: (value: number) => {\n\t\t\t\tconst time = dayjs.unix(value / 1000);\n\t\t\t\treturn <span>{time.format(\"YY-MM-DD HH:mm\")}</span>;\n\t\t\t}\n\t\t},\n\t\t{\n\t\t\ttitle: \"状态\",\n\t\t\tdataIndex: \"type\",\n\t\t\tkey: \"type\",\n\t\t\trender: (_, item) => {\n\t\t\t\tconst { type, typeVal } = item;\n\t\t\t\tconst color = typeVal == 0 ? \"cyan\" : typeVal == 1 ? \"red\" : \"blue\";\n\t\t\t\treturn <Tag color={color}>{type}</Tag>;\n\t\t\t}\n\t\t},\n\t\t{\n\t\t\ttitle: \"说明\",\n\t\t\tdataIndex: \"mark\",\n\t\t\tkey: \"mark\"\n\t\t},\n\t\t{\n\t\t\ttitle: \"上传简历\",\n\t\t\tdataIndex: \"resumeName\",\n\t\t\tkey: \"resumeName\",\n\t\t\trender: (_, item) => {\n\t\t\t\tconst { resumeName, resumeUrl } = item;\n\t\t\t\treturn (\n\t\t\t\t\t<a href={`${resumeUrl}`} className=\"cell-text\" target=\"_blank\" rel=\"noreferrer\">\n\t\t\t\t\t\t{resumeName}\n\t\t\t\t\t</a>\n\t\t\t\t);\n\t\t\t}\n\t\t},\n\t\t{\n\t\t\ttitle: \"回复\",\n\t\t\tdataIndex: \"replay\",\n\t\t\tkey: \"replay\"\n\t\t},\n\t\t{\n\t\t\ttitle: \"修改后简历\",\n\t\t\tdataIndex: \"replayUrl\",\n\t\t\tkey: \"replayUrl\",\n\t\t\trender: (_, item) => {\n\t\t\t\tconst { resumeName, replayUrl } = item;\n\t\t\t\tif (replayUrl) {\n\t\t\t\t\treturn (\n\t\t\t\t\t\t<a href={`${replayUrl}`} className=\"cell-text\" target=\"_blank\" rel=\"noreferrer\">\n\t\t\t\t\t\t\t（改）{resumeName}\n\t\t\t\t\t\t</a>\n\t\t\t\t\t);\n\t\t\t\t} else {\n\t\t\t\t\treturn <div />;\n\t\t\t\t}\n\t\t\t}\n\t\t},\n\n\t\t{\n\t\t\ttitle: \"操作\",\n\t\t\tkey: \"key\",\n\t\t\tfixed: \"right\",\n\t\t\trender: (_, item) => {\n\t\t\t\tconst { resumeId } = item;\n\t\t\t\treturn (\n\t\t\t\t\t<div className=\"operation-btn\">\n\t\t\t\t\t\t<Button\n\t\t\t\t\t\t\tsize=\"small\"\n\t\t\t\t\t\t\ticon={<DownloadOutlined />}\n\t\t\t\t\t\t\tstyle={{ marginRight: \"10px\" }}\n\t\t\t\t\t\t\tonClick={() => {\n\t\t\t\t\t\t\t\thandleDownload({ ...item });\n\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t></Button>\n\t\t\t\t\t\t<Button\n\t\t\t\t\t\t\ttype=\"primary\"\n\t\t\t\t\t\t\tsize=\"small\"\n\t\t\t\t\t\t\ticon={<EditOutlined />}\n\t\t\t\t\t\t\tstyle={{ marginRight: \"10px\" }}\n\t\t\t\t\t\t\tonClick={() => {\n\t\t\t\t\t\t\t\thandleShowDrawer(item);\n\t\t\t\t\t\t\t\tsetStatus(UpdateEnum.Edit);\n\t\t\t\t\t\t\t\thandleChange({ ...item });\n\t\t\t\t\t\t\t\tformRef.setFieldsValue({ ...item, upload: [] });\n\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t></Button>\n\n\t\t\t\t\t\t<Button type=\"primary\" size=\"small\" danger icon={<DeleteOutlined />} onClick={() => handleDel(resumeId)}></Button>\n\t\t\t\t\t</div>\n\t\t\t\t);\n\t\t\t}\n\t\t}\n\t];\n\n\tconst normFile = (info: any) => {\n\t\tconsole.log(\"Upload event:\", info);\n\t\tif (Array.isArray(info)) {\n\t\t\treturn info;\n\t\t}\n\t\tconsole.log(\"返回结果:\", info);\n\t\tconst { status } = info.file;\n\t\tif (status !== \"uploading\") {\n\t\t\tconsole.log(info.file, info.fileList);\n\t\t}\n\t\tif (status === \"done\") {\n\t\t\tlet res = info.file.response.result;\n\t\t\tsetReplayUrl(res.ossPath);\n\t\t\tmessage.success(`${info.file.name} 上传成功.`);\n\t\t} else if (status === \"error\") {\n\t\t\tmessage.error(`${info.file.name} 上传失败.`);\n\t\t}\n\t\treturn info?.fileList;\n\t};\n\n\t// 编辑表单\n\tconst reviseDrawerContent = (\n\t\t<div>\n\t\t\t<Form name=\"basic\" form={formRef} autoComplete=\"off\">\n\t\t\t\t{currentResume?.replayUrl ? (\n\t\t\t\t\t<Form.Item label=\"回复附件\" name=\"replayUrl\">\n\t\t\t\t\t\t<a href={currentResume?.replayUrl}>{currentResume?.resumeName}</a>\n\t\t\t\t\t</Form.Item>\n\t\t\t\t) : (\n\t\t\t\t\t<Form.Item name=\"upload\" label=\"上传简历\" valuePropName=\"fileList\" getValueFromEvent={normFile} extra=\"\">\n\t\t\t\t\t\t<Upload name=\"file\" action={uploadFileUrl()}>\n\t\t\t\t\t\t\t<Button icon={<UploadOutlined />}>选择doc/docx/pdf格式简历</Button>\n\t\t\t\t\t\t</Upload>\n\t\t\t\t\t</Form.Item>\n\t\t\t\t)}\n\t\t\t\t<Form.Item label=\"回复内容\" name=\"replay\" rules={[{ required: true, message: \"请输入回复内容!\" }]}>\n\t\t\t\t\t<Input.TextArea rows={12} />\n\t\t\t\t</Form.Item>\n\t\t\t</Form>\n\t\t</div>\n\t);\n\n\treturn (\n\t\t<div className=\"banner\">\n\t\t\t<ContentWrap>\n\t\t\t\t{/* 搜索 */}\n\t\t\t\t<Search handleSearchChange={handleSearchChange} handleSearch={handleSearch} />\n\t\t\t\t{/* 表格 */}\n\t\t\t\t<ContentInterWrap>\n\t\t\t\t\t<Table columns={columns} dataSource={tableData} pagination={paginationInfo} scroll={{ x: 1300 }} bordered />\n\t\t\t\t</ContentInterWrap>\n\t\t\t</ContentWrap>\n\t\t\t{/* 抽屉 */}\n\t\t\t<Drawer\n\t\t\t\ttitle={`回复【${currentResume?.uname}】的简历 - ${currentResume?.resumeName}`}\n\t\t\t\topen={isDrawerOpen}\n\t\t\t\tsize=\"large\"\n\t\t\t\tonClose={handleClose}\n\t\t\t\textra={\n\t\t\t\t\t<Space>\n\t\t\t\t\t\t<Button onClick={handleClose}>取消</Button>\n\t\t\t\t\t\t<Button type=\"primary\" onClick={handleSubmit}>\n\t\t\t\t\t\t\t确定\n\t\t\t\t\t\t</Button>\n\t\t\t\t\t</Space>\n\t\t\t\t}\n\t\t\t>\n\t\t\t\t{reviseDrawerContent}\n\t\t\t</Drawer>\n\t\t</div>\n\t);\n};\n\nconst mapStateToProps = (state: any) => state.disc.disc;\nconst mapDispatchToProps = {};\nexport default connect(mapStateToProps, mapDispatchToProps)(Resume);\n"
  },
  {
    "path": "src/views/sensitive/index.scss",
    "content": ".sensitive-page {\n  &__wrap {\n    height: auto;\n    min-height: 100%;\n    overflow-y: visible;\n  }\n\n  &__panel {\n    display: flex;\n    flex-direction: column;\n    gap: 16px;\n  }\n\n  &__content {\n    display: flex;\n    flex-direction: column;\n    gap: 16px;\n  }\n\n  &__form-grid {\n    display: grid;\n    grid-template-columns: repeat(2, minmax(0, 1fr));\n    gap: 16px;\n    align-items: start;\n  }\n\n  &__hero {\n    --hero-glow-left: radial-gradient(circle at top left, rgb(255 215 64 / 16%), transparent 34%);\n    --hero-glow-right: radial-gradient(circle at right center, rgb(24 144 255 / 12%), transparent 30%);\n    --hero-base: linear-gradient(135deg, #fffaf0 0%, #fff 55%, #f6fbff 100%);\n\n    display: flex;\n    align-items: flex-start;\n    justify-content: space-between;\n    gap: 16px;\n    padding: 24px;\n    border: 1px solid #f0f0f0;\n    border-radius: 16px;\n    background: var(--hero-glow-left), var(--hero-glow-right), var(--hero-base);\n  }\n\n  &__eyebrow {\n    margin-bottom: 8px;\n    color: #d48806;\n    font-size: 12px;\n    font-weight: 600;\n    letter-spacing: 0.16em;\n    text-transform: uppercase;\n  }\n\n  &__title {\n    margin: 0;\n    color: #1f1f1f;\n    font-size: 28px;\n    line-height: 1.2;\n  }\n\n  &__desc {\n    max-width: 720px;\n    margin: 12px 0 0;\n    color: #595959;\n    font-size: 14px;\n    line-height: 1.8;\n  }\n\n  &__stats {\n    display: grid;\n    grid-template-columns: repeat(4, minmax(0, 1fr));\n    gap: 12px;\n  }\n\n  &__stat-card {\n    display: flex;\n    flex-direction: column;\n    gap: 10px;\n    min-height: 100px;\n    padding: 18px 20px;\n    border: 1px solid #f0f0f0;\n    border-radius: 14px;\n    background: linear-gradient(180deg, #fff 0%, #fafafa 100%);\n    box-shadow: 0 10px 30px rgb(31 35 41 / 4%);\n  }\n\n  &__stat-card span {\n    color: #8c8c8c;\n    font-size: 13px;\n  }\n\n  &__stat-card strong {\n    color: #262626;\n    font-size: 28px;\n    line-height: 1;\n  }\n\n  &__card {\n    border-radius: 14px;\n    box-shadow: 0 10px 30px rgb(31 35 41 / 4%);\n  }\n\n  &__table-card {\n    overflow: hidden;\n  }\n\n  &__table-card .ant-card-body {\n    overflow-x: auto;\n  }\n\n  &__table-actions {\n    display: flex;\n    align-items: center;\n    gap: 12px;\n    flex-wrap: nowrap;\n    white-space: nowrap;\n  }\n\n  &__action-btn {\n    padding-inline: 0;\n  }\n\n  &__hit-count {\n    color: #cf1322;\n    font-weight: 600;\n  }\n}\n\n@media (max-width: 1200px) {\n  .sensitive-page {\n    &__stats {\n      grid-template-columns: repeat(2, minmax(0, 1fr));\n    }\n  }\n}\n\n@media (max-width: 768px) {\n  .sensitive-page {\n    &__hero {\n      padding: 18px;\n      border-radius: 12px;\n    }\n\n    &__title {\n      font-size: 24px;\n    }\n\n    &__stats {\n      grid-template-columns: 1fr;\n    }\n\n    &__form-grid {\n      grid-template-columns: 1fr;\n    }\n\n    &__stat-card {\n      min-height: auto;\n    }\n  }\n}\n"
  },
  {
    "path": "src/views/sensitive/index.tsx",
    "content": "import { FC, useCallback, useEffect, useMemo, useState } from \"react\";\nimport { connect } from \"react-redux\";\nimport { CheckOutlined, DeleteOutlined, ReloadOutlined, SaveOutlined } from \"@ant-design/icons\";\nimport { Alert, Button, Card, Empty, Form, Input, message, Modal, Space, Switch, Table, Tag } from \"antd\";\nimport type { ColumnsType } from \"antd/es/table\";\n\nimport {\n\tclearSensitiveWordHitApi,\n\tgetSensitiveWordDetailApi,\n\tgetSensitiveWordHitListApi,\n\tsaveSensitiveWordConfigApi,\n\tSensitiveWordConfigDTO,\n\tSensitiveWordHitDTO\n} from \"@/api/modules/sensitive\";\nimport { ContentInterWrap, ContentWrap } from \"@/components/common-wrap\";\nimport { initPagination, IPagination } from \"@/enums/common\";\n\nimport \"./index.scss\";\n\nconst { TextArea } = Input;\n\ninterface IProps {}\n\ninterface SensitiveWordFormValues {\n\tenable: boolean;\n\tdenyWordsText: string;\n\tallowWordsText: string;\n}\n\nconst defaultDetail: SensitiveWordConfigDTO = {\n\tenable: false,\n\tdenyWords: [],\n\tallowWords: [],\n\thitTotal: 0,\n\thitWords: []\n};\n\nconst defaultFormValues: SensitiveWordFormValues = {\n\tenable: false,\n\tdenyWordsText: \"\",\n\tallowWordsText: \"\"\n};\n\nconst joinWordText = (words: string[] = []) => words.join(\"\\n\");\n\nconst splitWordText = (value?: string) =>\n\tArray.from(\n\t\tnew Set(\n\t\t\t(value || \"\")\n\t\t\t\t.split(/[\\n,，]/)\n\t\t\t\t.map(item => item.trim())\n\t\t\t\t.filter(Boolean)\n\t\t)\n\t);\n\nconst Sensitive: FC<IProps> = () => {\n\tconst [formRef] = Form.useForm<SensitiveWordFormValues>();\n\tconst allowWordsText = Form.useWatch(\"allowWordsText\", formRef);\n\tconst [detail, setDetail] = useState<SensitiveWordConfigDTO>(defaultDetail);\n\tconst [hitWords, setHitWords] = useState<SensitiveWordHitDTO[]>([]);\n\tconst [selectedHitWords, setSelectedHitWords] = useState<string[]>([]);\n\tconst [loading, setLoading] = useState(false);\n\tconst [hitLoading, setHitLoading] = useState(false);\n\tconst [saving, setSaving] = useState(false);\n\tconst [addingWord, setAddingWord] = useState<string>(\"\");\n\tconst [clearingWord, setClearingWord] = useState<string>(\"\");\n\tconst [batchAdding, setBatchAdding] = useState(false);\n\tconst [batchClearing, setBatchClearing] = useState(false);\n\tconst [hitPagination, setHitPagination] = useState<IPagination>(initPagination);\n\tconst allowWordSet = useMemo(() => new Set(splitWordText(allowWordsText)), [allowWordsText]);\n\tconst { current: hitCurrent, pageSize: hitPageSize, total: hitTotal = 0 } = hitPagination;\n\n\tconst hitPaginationInfo = {\n\t\tshowSizeChanger: true,\n\t\tshowTotal: (total: number) => `共 ${total || 0} 条`,\n\t\t...hitPagination,\n\t\tonChange: (current: number, pageSize: number) => {\n\t\t\tsetHitPagination(prev => ({ ...prev, current, pageSize }));\n\t\t}\n\t};\n\n\tconst syncForm = useCallback(\n\t\t(config: SensitiveWordConfigDTO) => {\n\t\t\tformRef.setFieldsValue({\n\t\t\t\tenable: Boolean(config.enable),\n\t\t\t\tdenyWordsText: joinWordText(config.denyWords),\n\t\t\t\tallowWordsText: joinWordText(config.allowWords)\n\t\t\t});\n\t\t},\n\t\t[formRef]\n\t);\n\n\tconst fetchDetail = useCallback(async () => {\n\t\tsetLoading(true);\n\t\ttry {\n\t\t\tconst { result } = await getSensitiveWordDetailApi();\n\t\t\tconst nextDetail: SensitiveWordConfigDTO = {\n\t\t\t\tenable: Boolean(result?.enable),\n\t\t\t\tdenyWords: result?.denyWords || [],\n\t\t\t\tallowWords: result?.allowWords || [],\n\t\t\t\thitTotal: Number(result?.hitTotal || 0),\n\t\t\t\thitWords: []\n\t\t\t};\n\t\t\tsetDetail(nextDetail);\n\t\t\tsyncForm(nextDetail);\n\t\t} finally {\n\t\t\tsetLoading(false);\n\t\t}\n\t}, [syncForm]);\n\n\tconst fetchHitWords = useCallback(async (pageNumber: number, pageSize: number) => {\n\t\tsetHitLoading(true);\n\t\ttry {\n\t\t\tconst { result } = await getSensitiveWordHitListApi({ pageNumber, pageSize });\n\t\t\tconst list = result?.list || [];\n\t\t\tconst resPageNum = Number(result?.pageNum || pageNumber);\n\t\t\tconst resPageSize = Number(result?.pageSize || pageSize);\n\t\t\tconst total = Number(result?.total || 0);\n\t\t\tconst pageTotal = Number(result?.pageTotal || 0);\n\n\t\t\tif (total > 0 && pageTotal > 0 && pageNumber > pageTotal) {\n\t\t\t\tsetHitPagination(prev => ({ ...prev, current: pageTotal, pageSize: resPageSize, total }));\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\tsetHitWords(list);\n\t\t\tsetHitPagination(prev => ({ ...prev, current: resPageNum, pageSize: resPageSize, total }));\n\t\t} finally {\n\t\t\tsetHitLoading(false);\n\t\t}\n\t}, []);\n\n\tuseEffect(() => {\n\t\tvoid fetchDetail();\n\t}, [fetchDetail]);\n\n\tuseEffect(() => {\n\t\tvoid fetchHitWords(hitCurrent, hitPageSize);\n\t}, [fetchHitWords, hitCurrent, hitPageSize]);\n\n\tuseEffect(() => {\n\t\tsetSelectedHitWords(prev => prev.filter(word => hitWords.some(item => item.word === word)));\n\t}, [hitWords]);\n\n\tconst getConfigPayload = async () => {\n\t\tconst values = await formRef.validateFields();\n\t\treturn {\n\t\t\tenable: Boolean(values.enable),\n\t\t\tdenyWords: splitWordText(values.denyWordsText),\n\t\t\tallowWords: splitWordText(values.allowWordsText)\n\t\t};\n\t};\n\n\tconst refreshPageData = async () => {\n\t\tawait fetchDetail();\n\t\tawait fetchHitWords(hitCurrent, hitPageSize);\n\t};\n\n\tconst clearHitWords = async (words: string[]) => {\n\t\tawait Promise.all(words.map(word => clearSensitiveWordHitApi(word)));\n\t};\n\n\tconst handleSave = async () => {\n\t\tconst payload = await getConfigPayload();\n\t\tsetSaving(true);\n\t\ttry {\n\t\t\tawait saveSensitiveWordConfigApi(payload);\n\t\t\tmessage.success(\"敏感词配置保存成功\");\n\t\t\tawait refreshPageData();\n\t\t} finally {\n\t\t\tsetSaving(false);\n\t\t}\n\t};\n\n\tconst handleAddToAllowList = async (word: string) => {\n\t\tconst currentWord = word.trim();\n\t\tif (!currentWord) {\n\t\t\treturn;\n\t\t}\n\n\t\tconst payload = await getConfigPayload();\n\t\tif (payload.allowWords.includes(currentWord)) {\n\t\t\tmessage.info(\"该词已经在白名单中了\");\n\t\t\treturn;\n\t\t}\n\n\t\tsetAddingWord(currentWord);\n\t\ttry {\n\t\t\tawait saveSensitiveWordConfigApi({\n\t\t\t\t...payload,\n\t\t\t\tallowWords: [...payload.allowWords, currentWord]\n\t\t\t});\n\t\t\tawait clearSensitiveWordHitApi(currentWord);\n\t\t\tmessage.success(\"已加入白名单\");\n\t\t\tawait refreshPageData();\n\t\t} finally {\n\t\t\tsetAddingWord(\"\");\n\t\t}\n\t};\n\n\tconst handleClearHitWord = (word: string) => {\n\t\tModal.warning({\n\t\t\ttitle: `确认清理“${word}”的命中统计吗`,\n\t\t\tcontent: \"清理后会移除当前词的命中记录，请谨慎操作。\",\n\t\t\tmaskClosable: true,\n\t\t\tclosable: true,\n\t\t\tonOk: async () => {\n\t\t\t\tsetClearingWord(word);\n\t\t\t\ttry {\n\t\t\t\t\tawait clearHitWords([word]);\n\t\t\t\t\tmessage.success(\"命中统计已清理\");\n\t\t\t\t\tawait refreshPageData();\n\t\t\t\t} finally {\n\t\t\t\t\tsetClearingWord(\"\");\n\t\t\t\t}\n\t\t\t}\n\t\t});\n\t};\n\n\tconst handleBatchAddToAllowList = () => {\n\t\tif (!selectedHitWords.length) {\n\t\t\tmessage.info(\"请先选择命中词\");\n\t\t\treturn;\n\t\t}\n\n\t\tModal.warning({\n\t\t\ttitle: `确认将选中的 ${selectedHitWords.length} 个命中词加入白名单吗`,\n\t\t\tcontent: \"加入后会一并清理这些词的命中统计，请谨慎操作。\",\n\t\t\tmaskClosable: true,\n\t\t\tclosable: true,\n\t\t\tonOk: async () => {\n\t\t\t\tconst payload = await getConfigPayload();\n\t\t\t\tconst newWords = selectedHitWords.filter(word => !payload.allowWords.includes(word));\n\t\t\t\tif (!newWords.length) {\n\t\t\t\t\tmessage.info(\"所选词都已经在白名单中了\");\n\t\t\t\t\treturn;\n\t\t\t\t}\n\n\t\t\t\tsetBatchAdding(true);\n\t\t\t\ttry {\n\t\t\t\t\tawait saveSensitiveWordConfigApi({\n\t\t\t\t\t\t...payload,\n\t\t\t\t\t\tallowWords: [...payload.allowWords, ...newWords]\n\t\t\t\t\t});\n\t\t\t\t\tawait clearHitWords(newWords);\n\t\t\t\t\tsetSelectedHitWords([]);\n\t\t\t\t\tmessage.success(`已批量加入白名单 ${newWords.length} 项`);\n\t\t\t\t\tawait refreshPageData();\n\t\t\t\t} finally {\n\t\t\t\t\tsetBatchAdding(false);\n\t\t\t\t}\n\t\t\t}\n\t\t});\n\t};\n\n\tconst handleBatchClearHitWords = () => {\n\t\tif (!selectedHitWords.length) {\n\t\t\tmessage.info(\"请先选择命中词\");\n\t\t\treturn;\n\t\t}\n\n\t\tModal.warning({\n\t\t\ttitle: `确认清理选中的 ${selectedHitWords.length} 条命中统计吗`,\n\t\t\tcontent: \"清理后会移除这些词的命中记录，请谨慎操作。\",\n\t\t\tmaskClosable: true,\n\t\t\tclosable: true,\n\t\t\tonOk: async () => {\n\t\t\t\tsetBatchClearing(true);\n\t\t\t\ttry {\n\t\t\t\t\tawait clearHitWords(selectedHitWords);\n\t\t\t\t\tsetSelectedHitWords([]);\n\t\t\t\t\tmessage.success(`已清理 ${selectedHitWords.length} 条命中统计`);\n\t\t\t\t\tawait refreshPageData();\n\t\t\t\t} finally {\n\t\t\t\t\tsetBatchClearing(false);\n\t\t\t\t}\n\t\t\t}\n\t\t});\n\t};\n\n\tconst hitRowSelection = {\n\t\tselectedRowKeys: selectedHitWords,\n\t\tonChange: (selectedRowKeys: Array<string | number>) => {\n\t\t\tsetSelectedHitWords(selectedRowKeys.map(key => String(key)));\n\t\t}\n\t};\n\n\tconst columns: ColumnsType<SensitiveWordHitDTO> = [\n\t\t{\n\t\t\ttitle: \"命中词\",\n\t\t\tdataIndex: \"word\",\n\t\t\tkey: \"word\",\n\t\t\trender: word => <Tag color=\"red\">{word}</Tag>\n\t\t},\n\t\t{\n\t\t\ttitle: \"命中次数\",\n\t\t\tdataIndex: \"hitCount\",\n\t\t\tkey: \"hitCount\",\n\t\t\twidth: 120,\n\t\t\trender: hitCount => <span className=\"sensitive-page__hit-count\">{hitCount || 0}</span>\n\t\t},\n\t\t{\n\t\t\ttitle: \"操作\",\n\t\t\tkey: \"action\",\n\t\t\twidth: 200,\n\t\t\trender: (_, item) => (\n\t\t\t\t<div className=\"sensitive-page__table-actions\">\n\t\t\t\t\t<Button\n\t\t\t\t\t\ttype=\"link\"\n\t\t\t\t\t\tclassName=\"sensitive-page__action-btn\"\n\t\t\t\t\t\ticon={<CheckOutlined />}\n\t\t\t\t\t\tdisabled={allowWordSet.has(item.word)}\n\t\t\t\t\t\tloading={addingWord === item.word}\n\t\t\t\t\t\tonClick={() => handleAddToAllowList(item.word)}\n\t\t\t\t\t>\n\t\t\t\t\t\t{allowWordSet.has(item.word) ? \"已在白名单\" : \"加入白名单\"}\n\t\t\t\t\t</Button>\n\t\t\t\t\t<Button\n\t\t\t\t\t\ttype=\"link\"\n\t\t\t\t\t\tclassName=\"sensitive-page__action-btn\"\n\t\t\t\t\t\tdanger\n\t\t\t\t\t\ticon={<DeleteOutlined />}\n\t\t\t\t\t\tloading={clearingWord === item.word}\n\t\t\t\t\t\tonClick={() => handleClearHitWord(item.word)}\n\t\t\t\t\t>\n\t\t\t\t\t\t清理\n\t\t\t\t\t</Button>\n\t\t\t\t</div>\n\t\t\t)\n\t\t}\n\t];\n\n\treturn (\n\t\t<div className=\"banner sensitive-page\">\n\t\t\t<ContentWrap className=\"sensitive-page__wrap\" style={{ height: \"auto\", minHeight: \"100%\", overflowY: \"visible\" }}>\n\t\t\t\t<ContentInterWrap className=\"sensitive-page__panel\">\n\t\t\t\t\t<div className=\"sensitive-page__hero\">\n\t\t\t\t\t\t<div>\n\t\t\t\t\t\t\t<div className=\"sensitive-page__eyebrow\">Content Safety</div>\n\t\t\t\t\t\t\t<h2 className=\"sensitive-page__title\">敏感词管理</h2>\n\t\t\t\t\t\t\t<p className=\"sensitive-page__desc\">\n\t\t\t\t\t\t\t\t统一维护敏感词开关、拦截词库、白名单，以及线上命中统计。词条支持换行、英文逗号、中文逗号分隔。\n\t\t\t\t\t\t\t</p>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t\t<Space wrap>\n\t\t\t\t\t\t\t<Button icon={<ReloadOutlined />} loading={loading || hitLoading} onClick={refreshPageData}>\n\t\t\t\t\t\t\t\t刷新\n\t\t\t\t\t\t\t</Button>\n\t\t\t\t\t\t\t<Button type=\"primary\" icon={<SaveOutlined />} loading={saving} onClick={handleSave}>\n\t\t\t\t\t\t\t\t保存配置\n\t\t\t\t\t\t\t</Button>\n\t\t\t\t\t\t</Space>\n\t\t\t\t\t</div>\n\n\t\t\t\t\t<div className=\"sensitive-page__stats\">\n\t\t\t\t\t\t<div className=\"sensitive-page__stat-card\">\n\t\t\t\t\t\t\t<span>当前状态</span>\n\t\t\t\t\t\t\t<strong>{detail.enable ? \"已启用\" : \"已关闭\"}</strong>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t\t<div className=\"sensitive-page__stat-card\">\n\t\t\t\t\t\t\t<span>敏感词数量</span>\n\t\t\t\t\t\t\t<strong>{detail.denyWords.length}</strong>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t\t<div className=\"sensitive-page__stat-card\">\n\t\t\t\t\t\t\t<span>白名单数量</span>\n\t\t\t\t\t\t\t<strong>{detail.allowWords.length}</strong>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t\t<div className=\"sensitive-page__stat-card\">\n\t\t\t\t\t\t\t<span>命中词条</span>\n\t\t\t\t\t\t\t<strong>{detail.hitTotal ?? hitTotal}</strong>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t</div>\n\n\t\t\t\t\t<Alert\n\t\t\t\t\t\tshowIcon\n\t\t\t\t\t\ttype={detail.enable ? \"success\" : \"warning\"}\n\t\t\t\t\t\tmessage={detail.enable ? \"敏感词检测已开启，命中词会参与替换与统计。\" : \"敏感词检测当前关闭，保存后可立即启用。\"}\n\t\t\t\t\t/>\n\n\t\t\t\t\t<div className=\"sensitive-page__content\">\n\t\t\t\t\t\t<Card title=\"规则配置\" bordered={false} className=\"sensitive-page__card\">\n\t\t\t\t\t\t\t<Form form={formRef} layout=\"vertical\" initialValues={defaultFormValues}>\n\t\t\t\t\t\t\t\t<Form.Item label=\"启用敏感词检测\" name=\"enable\" valuePropName=\"checked\">\n\t\t\t\t\t\t\t\t\t<Switch checkedChildren=\"开启\" unCheckedChildren=\"关闭\" />\n\t\t\t\t\t\t\t\t</Form.Item>\n\t\t\t\t\t\t\t\t<div className=\"sensitive-page__form-grid\">\n\t\t\t\t\t\t\t\t\t<Form.Item label=\"敏感词名单\" name=\"denyWordsText\" extra=\"保存时会自动去重并清理空白词条。\">\n\t\t\t\t\t\t\t\t\t\t<TextArea rows={10} showCount placeholder={\"广告\\n引流\\n兼职\"} />\n\t\t\t\t\t\t\t\t\t</Form.Item>\n\t\t\t\t\t\t\t\t\t<Form.Item label=\"白名单\" name=\"allowWordsText\" extra=\"命中后需要放行的词可加入白名单。\">\n\t\t\t\t\t\t\t\t\t\t<TextArea rows={10} showCount placeholder={\"技术派\\n开源项目\"} />\n\t\t\t\t\t\t\t\t\t</Form.Item>\n\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t</Form>\n\t\t\t\t\t\t</Card>\n\n\t\t\t\t\t\t<Card\n\t\t\t\t\t\t\ttitle=\"命中统计\"\n\t\t\t\t\t\t\tbordered={false}\n\t\t\t\t\t\t\tclassName=\"sensitive-page__card sensitive-page__table-card\"\n\t\t\t\t\t\t\textra={\n\t\t\t\t\t\t\t\t<Space size=\"small\" wrap>\n\t\t\t\t\t\t\t\t\t<Button\n\t\t\t\t\t\t\t\t\t\ticon={<CheckOutlined />}\n\t\t\t\t\t\t\t\t\t\tdisabled={!selectedHitWords.length}\n\t\t\t\t\t\t\t\t\t\tloading={batchAdding}\n\t\t\t\t\t\t\t\t\t\tonClick={handleBatchAddToAllowList}\n\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t批量加入白名单\n\t\t\t\t\t\t\t\t\t</Button>\n\t\t\t\t\t\t\t\t\t<Button\n\t\t\t\t\t\t\t\t\t\tdanger\n\t\t\t\t\t\t\t\t\t\ticon={<DeleteOutlined />}\n\t\t\t\t\t\t\t\t\t\tdisabled={!selectedHitWords.length}\n\t\t\t\t\t\t\t\t\t\tloading={batchClearing}\n\t\t\t\t\t\t\t\t\t\tonClick={handleBatchClearHitWords}\n\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t批量清理\n\t\t\t\t\t\t\t\t\t</Button>\n\t\t\t\t\t\t\t\t\t<Tag color={selectedHitWords.length ? \"processing\" : hitTotal ? \"processing\" : \"default\"}>\n\t\t\t\t\t\t\t\t\t\t{selectedHitWords.length ? `已选 ${selectedHitWords.length} 项` : hitTotal ? `共 ${hitTotal} 项` : \"暂无命中\"}\n\t\t\t\t\t\t\t\t\t</Tag>\n\t\t\t\t\t\t\t\t</Space>\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t{hitTotal ? (\n\t\t\t\t\t\t\t\t<Table\n\t\t\t\t\t\t\t\t\trowKey=\"word\"\n\t\t\t\t\t\t\t\t\tsize=\"small\"\n\t\t\t\t\t\t\t\t\trowSelection={hitRowSelection}\n\t\t\t\t\t\t\t\t\tcolumns={columns}\n\t\t\t\t\t\t\t\t\tdataSource={hitWords}\n\t\t\t\t\t\t\t\t\tpagination={hitPaginationInfo}\n\t\t\t\t\t\t\t\t\tloading={hitLoading || batchAdding || batchClearing}\n\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t) : (\n\t\t\t\t\t\t\t\t<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description=\"暂无命中统计\" />\n\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t</Card>\n\t\t\t\t\t</div>\n\t\t\t\t</ContentInterWrap>\n\t\t\t</ContentWrap>\n\t\t</div>\n\t);\n};\n\nconst mapStateToProps = (state: any) => state.disc.disc;\nconst mapDispatchToProps = {};\nexport default connect(mapStateToProps, mapDispatchToProps)(Sensitive);\n"
  },
  {
    "path": "src/views/statistics/index.scss",
    "content": "@font-face {\n  font-family: 'iconfont';  /* Project id 4057336 */\n  src: url('//at.alicdn.com/t/c/font_4057336_1x0j6564ndk.woff2?t=1685012234915') format('woff2'),\n       url('//at.alicdn.com/t/c/font_4057336_1x0j6564ndk.woff?t=1685012234915') format('woff'),\n       url('//at.alicdn.com/t/c/font_4057336_1x0j6564ndk.ttf?t=1685012234915') format('truetype');\n}\n\n.iconfont {\n  font-family: 'iconfont' !important;\n  font-size: 16px;\n  font-style: normal;\n  -webkit-font-smoothing: antialiased;\n  -moz-osx-font-smoothing: grayscale;\n}\n\n.statistics {\n\t.content {\n\t\tbackground-color: #fff!important;\n\t}\n\t.content-wrap {\n\t\toverflow-x: hidden;\n\n\t\t.top-content {\n\t\t\tdisplay: flex;\n\t\t\tjustify-content: space-between;\n\t\t\tmargin-top: 10px;\n\t\t\tflex: 1 1 0%;\n\n\t\t\t.item-left {\n\t\t\t\tbox-sizing: border-box;\n\t\t\t\twidth: 22%;\n\t\t\t\tcolor: rgb(255, 255, 255);\n\t\t\t\tpadding: 40px 0px 100px 30px;\n\t\t\t\toverflow: hidden;\n\t\t\t\tbackground: url(@/assets/images/book-bg.png) 0% 0% / 100% 100%;\n\t\t\t\tborder-radius: 20px;\n\n\t\t\t\t.left-title {\n\t\t\t\t\tfont-family: \"PingFang SC\";\n\t\t\t\t\tfont-size: 20px;\n\t\t\t\t\tfont-weight: 500;\n\t\t\t\t}\n\n\t\t\t\t.img-box {\n\t\t\t\t\tdisplay: flex;\n\t\t\t\t\talign-items: center;\n\t\t\t\t\tjustify-content: center;\n\t\t\t\t\twidth: 60px;\n\t\t\t\t\theight: 60px;\n\t\t\t\t\tbox-shadow: rgba(0, 0, 0, 0.14) 0px 10px 20px;\n\t\t\t\t\tmargin: 20px 0px 10px;\n\t\t\t\t\tbackground: rgb(255, 255, 255);\n\t\t\t\t\tborder-radius: 20px;\n\n\t\t\t\t\timg {\n\t\t\t\t\t\twidth: 50px;\n\t\t\t\t\t\theight: 55px;\n\t\t\t\t\t}\n\t\t\t\t}\n\n\t\t\t\t.left-number {\n\t\t\t\t\tfont-family: DIN;\n\t\t\t\t\tfont-size: 42px;\n\t\t\t\t\tfont-weight: 500;\n\t\t\t\t\toverflow: hidden;\n\t\t\t\t}\n\t\t\t}\n\n\t\t\t.item-center {\n\t\t\t\tdisplay: flex;\n\t\t\t\tflex-wrap: wrap;\n\t\t\t\talign-content: space-between;\n\t\t\t\tjustify-content: space-between;\n\t\t\t\tmin-width: 250px;\n\t\t\t\tflex: 1 1 0%;\n\t\t\t\tpadding: 0px 50px;\n\n\t\t\t\t.traffic-box {\n\t\t\t\t\tbox-sizing: border-box;\n\t\t\t\t\tdisplay: flex;\n\t\t\t\t\tflex-direction: column;\n\t\t\t\t\twidth: 47%;\n\t\t\t\t\theight: 48%;\n\t\t\t\t\tpadding: 15px;\n\t\t\t\t\tborder-radius: 15px;\n\t\t\t\t\t.traffic-img {\n\t\t\t\t\t\tdisplay: flex;\n\t\t\t\t\t\talign-items: center;\n\t\t\t\t\t\tjustify-content: center;\n\t\t\t\t\t\twidth: 50px;\n\t\t\t\t\t\theight: 50px;\n\t\t\t\t\t\tmargin-bottom: 10px;\n\t\t\t\t\t\tbackground-color: #ffffff;\n\t\t\t\t\t\tborder-radius: 19px;\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\timg {\n\t\t\t\t\twidth: 33px;\n\t\t\t\t\theight: 33px;\n\t\t\t\t}\n\n\t\t\t\t.item-value {\n\t\t\t\t\tmargin-bottom: 4px;\n\t\t\t\t\tfont-family: DIN;\n\t\t\t\t\tfont-size: 28px;\n\t\t\t\t\tfont-weight: bold;\n\t\t\t\t\tcolor: #1a1a37;\n\t\t\t\t}\n\t\t\t\t.traffic-name {\n\t\t\t\t\toverflow: hidden;\n\t\t\t\t\tfont-family: DIN;\n\t\t\t\t\tfont-size: 15px;\n\t\t\t\t\tfont-weight: 400;\n\t\t\t\t\tcolor: #1a1a37;\n\t\t\t\t\twhite-space: nowrap;\n\t\t\t\t}\n\t\t\t\t.gitee-traffic {\n\t\t\t\t\tbackground: url(\"@/assets/images/1-bg.png\");\n\t\t\t\t\tbackground-color: #e8faea;\n\t\t\t\t\tbackground-size: 100% 100%;\n\t\t\t\t}\n\t\t\t\t.gitHub-traffic {\n\t\t\t\t\tbackground: url(\"@/assets/images/2-bg.png\");\n\t\t\t\t\tbackground-color: #e7e1fb;\n\t\t\t\t\tbackground-size: 100% 100%;\n\t\t\t\t}\n\t\t\t\t.today-traffic {\n\t\t\t\t\tbackground: url(\"@/assets/images/3-bg.png\");\n\t\t\t\t\tbackground-color: #f4e4d4;\n\t\t\t\t\tbackground-size: 100% 100%;\n\t\t\t\t}\n\t\t\t\t.yesterday-traffic {\n\t\t\t\t\tbackground: url(\"@/assets/images/4-bg.png\");\n\t\t\t\t\tbackground-color: #d5e2f1;\n\t\t\t\t\tbackground-size: 100% 100%;\n\t\t\t\t}\n\t\t\t}\n\n\t\t\t.item-right {\n\t\t\t\tbox-sizing: border-box;\n\t\t\t\tdisplay: flex;\n\t\t\t\tflex-direction: column;\n\t\t\t\twidth: 40%;\n\t\t\t\theight: 330px;\n\t\t\t\tborder-width: 1px;\n\t\t\t\tborder-style: solid;\n\t\t\t\tborder-color: rgb(229, 231, 235);\n\t\t\t\tborder-image: initial;\n\t\t\t\tborder-radius: 16px;\n\t\t\t}\n\t\t}\n\t}\n\n\t&-pv__wrap {\n\t\tdisplay: flex;\n\t\tflex-direction: column;\n\t\talign-items: flex-end;\n\t\tborder: 1px solid #ccc;\n\t\tbackground-color: #fff;\n\t\tpadding: 10px;\n\t\tborder-radius: 8px\n\t}\n\n\t&-pv {\n\t\twidth: 100%;\n\t\t// height 随着屏幕大小变化\n\t\theight: calc(100vh - 510px);\n\t\tdisplay: flex;\n\t\tjustify-content: center;\n\t}\n\n\t&-pie {\n\t\twidth: 100%;\n\t\theight: 100%;\n\t\tpadding: 20px;\n\t}\n\n\t&-all__wrap {\n\t\tdisplay: flex;\n\t\tmargin-bottom: 20px;\n\t}\n\n\t&-all__item {\n\t\tflex: 1;\n\t\theight: 200px;\n\t\tborder-radius: 8px;\n\t\tdisplay: flex;\n\t\talign-items: center;\n\t\tjustify-content: center;\n\t\tmargin-right: 20px;\n\t\t&:last-child {\n\t\t\tmargin-right: 0;\n\t\t}\n\t}\n\n\t&-all__item-box {\n\t\tdisplay: flex;\n\t\tflex-direction: column;\n\t\talign-items: center;\n\t}\n\n\t&-all__item-title {\n\t\tfont-size: 24px;\n\t\tfont-weight: 500;\n\t}\n\t&-all__item-value {\n\t\tfont-size: 20px;\n\t\tcolor: #fff;\n\t}\n}\n"
  },
  {
    "path": "src/views/statistics/index.tsx",
    "content": "/* eslint-disable prettier/prettier */\nimport { FC, useCallback, useEffect, useRef, useState } from \"react\";\nimport { Select, Switch } from \"antd\";\nimport * as echarts from \"echarts\";\n\nimport { download2ExcelPvUvApi, getAllApi, getPvUvApi } from \"@/api/modules/statistics\";\nimport pvCountImg from \"@/assets/images/fangwenliang.png\";\nimport articleCountImg from \"@/assets/images/wenzhangzongshu.png\";\nimport userCountImg from \"@/assets/images/yonghu.png\";\nimport zhuanlanImg from \"@/assets/images/zhuanlan.png\";\nimport zhuceImg from \"@/assets/images/zhuce.png\";\nimport { ContentWrap } from \"@/components/common-wrap\";\nimport { MapItem } from \"@/typings/common\";\n\nimport \"./index.scss\";\n\ninterface IProps {}\n\nconst Statistics: FC<IProps> = props => {\n\tconst chartRef = useRef<HTMLDivElement>(null);\n\tconst myChartRef = useRef<echarts.ECharts>();\n\n\tconst pieChartRef = useRef<HTMLDivElement>(null);\n\tconst myPieChartRef = useRef<echarts.ECharts>();\n\n\tconst [pvUvDay, setPvUvDay] = useState<string>(\"7\");\n\tconst [pvUvInfo, setPvUvInfo] = useState<MapItem[]>([]);\n\tconst [allInfo, setAllInfo] = useState<MapItem[]>([]);\n\tconst [isDarkTheme, setIsDarkTheme] = useState(false);\n\n\tconst pvUvDate = pvUvInfo.map(({ date }) => date);\n\tconst pvDateCount = pvUvInfo.map(({ pvCount }) => pvCount);\n\tconst uvDateCount = pvUvInfo.map(({ uvCount }) => uvCount);\n\n\t// @ts-ignore\n\tconst { pvCount, userCount, starPayCount, articleCount,tutorialCount,collectCount,likeCount,readCount,commentCount } = allInfo;\n\n\tconst dayLimitList = [\n\t\t{ value: \"7\", label: \"7天\" },\n\t\t{ value: \"30\", label: \"30天\" },\n\t\t{ value: \"90\", label: \"90天\" },\n\t\t{ value: \"180\", label: \"180天\" }\n\t];\n\n\tconst resizeChart = useCallback(() => {\n\t\tmyChartRef.current?.resize();\n\t\tmyPieChartRef.current?.resize();\n\t}, []);\n\n\tconst exportToExcel = async () => {\n\t\tconst day = dayLimitList.find(item => item.value === pvUvDay)?.value;\n\t\tif (!day) return;\n\n\t\tconst response = await download2ExcelPvUvApi(Number(day));\n\t\tconst contentDisposition = response.headers[\"content-disposition\"];\n\t\tlet fileName = \"paicoding.xlsx\";\n\n\t\tif (contentDisposition) {\n\t\t\tconst matches = contentDisposition.match(/filename\\*?=utf-8''([^;]+)/i);\n\t\t\tif (matches && matches.length === 2) {\n\t\t\t\tfileName = decodeURIComponent(matches[1]);\n\t\t\t}\n\t\t}\n\n\t\tconst blob = new Blob([response.data as BlobPart], {\n\t\t\ttype: response.headers[\"content-type\"]\n\t\t});\n\t\tconst url = window.URL.createObjectURL(blob);\n\t\tconst link = document.createElement(\"a\");\n\t\tlink.href = url;\n\t\tlink.download = fileName;\n\t\tlink.click();\n\t\twindow.URL.revokeObjectURL(url);\n\t};\n\n\tconst getAllInfo = async () => {\n\t\tconst { status, result } = await getAllApi();\n\t\tif (status && status.code === 0) {\n\t\t\tsetAllInfo(result as MapItem[]);\n\t\t}\n\t};\n\n\tuseEffect(() => {\n\t\tgetAllInfo();\n\t}, []);\n\n\tuseEffect(() => {\n\t\tconst getPvUv = async () => {\n\t\t\tconst { status, result } = await getPvUvApi(Number(pvUvDay));\n\t\t\tif (status && status.code === 0) {\n\t\t\t\tsetPvUvInfo((result as any[]).reverse());\n\t\t\t}\n\t\t};\n\t\tgetPvUv();\n\t}, [pvUvDay]);\n\n\tuseEffect(() => {\n\t\tconst getPieRef = () => {\n\t\t\tif (pieChartRef.current && echarts.getInstanceByDom(pieChartRef.current)) {\n\t\t\t\techarts.dispose(pieChartRef.current);\n\t\t\t}\n\n\t\t\tlet myPieChart = echarts.init(pieChartRef.current as HTMLElement, isDarkTheme ? \"dark\" : \"light\");\n\t\t\tlet option = {\n\t\t\t\ttitle: {\n\t\t\t\t\ttext: \"数据统计\",\n\t\t\t\t\tleft: \"center\"\n\t\t\t\t},\n\t\t\t\ttooltip: {\n\t\t\t\t\ttrigger: \"item\"\n\t\t\t\t},\n\t\t\t\tlegend: {\n\t\t\t\t\torient: \"vertical\",\n\t\t\t\t\tleft: \"left\",\n\t\t\t\t\tbottom: 0\n\t\t\t\t},\n\t\t\t\tseries: [\n\t\t\t\t\t{\n\t\t\t\t\t\tname: \"数据统计\",\n\t\t\t\t\t\ttype: \"pie\",\n\t\t\t\t\t\tradius: [\"60%\"],\n\t\t\t\t\t\temphasis: {\n\t\t\t\t\t\t\titemStyle: {\n\t\t\t\t\t\t\t\tshadowBlur: 10,\n\t\t\t\t\t\t\t\tshadowOffsetX: 0,\n\t\t\t\t\t\t\t\tshadowColor: \"rgba(0, 0, 0, 0.5)\"\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t},\n\t\t\t\t\t\tdata: [\n\t\t\t\t\t\t\t{ value: collectCount, name: \"收藏总数\" },\n\t\t\t\t\t\t\t{ value: likeCount, name: \"点赞总数\" },\n\t\t\t\t\t\t\t{ value: readCount, name: \"阅读总数\" },\n\t\t\t\t\t\t\t{ value: commentCount, name: \"评论总数\" }\n\t\t\t\t\t\t]\n\t\t\t\t\t}\n\t\t\t\t]\n\t\t\t};\n\t\t\tmyPieChartRef.current = myPieChart;\n\t\t\toption && myPieChart.setOption(option);\n\t\t\twindow.addEventListener(\"resize\", resizeChart);\n\t\t};\n\t\tgetPieRef();\n\t\treturn () => {\n\t\t\twindow.removeEventListener(\"resize\", resizeChart);\n\t\t};\n\t});\n\n\tuseEffect(() => {\n\t\tconst getPvUvRef = () => {\n\t\t\tif (chartRef.current && echarts.getInstanceByDom(chartRef.current)) {\n\t\t\t\techarts.dispose(chartRef.current);\n\t\t\t}\n\t\t\tlet myChart = echarts.init(chartRef.current as HTMLElement, isDarkTheme ? \"dark\" : \"light\");\n\n\t\t\tlet option = {\n\t\t\t\ttitle: {\n\t\t\t\t\ttext: \"PV UV数据\",\n\t\t\t\t\ttop: 0\n\t\t\t\t},\n\t\t\t\ttooltip: {\n\t\t\t\t\ttrigger: \"axis\"\n\t\t\t\t},\n\t\t\t\tlegend: {\n\t\t\t\t\tdata: [\"PV\", \"UV\"]\n\t\t\t\t},\n\t\t\t\tgrid: {\n\t\t\t\t\tleft: \"3%\",\n\t\t\t\t\tright: \"3%\",\n\t\t\t\t\tbottom: \"3%\",\n\t\t\t\t\tcontainLabel: true\n\t\t\t\t},\n\t\t\t\ttoolbox: {\n\t\t\t\t\tshow: true,\n\t\t\t\t\tmagicType: {\n\t\t\t\t\t\ttype: [\"line\", \"bar\"]\n\t\t\t\t\t},\n\t\t\t\t\tfeature: {\n\t\t\t\t\t\tmyDownloadExcel: {\n\t\t\t\t\t\t\tshow: true,\n\t\t\t\t\t\t\ttitle: \"下载 Excel\",\n\t\t\t\t\t\t\ticon: \"path://M19 3H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm-1 14h-3v-3h-2v3H8v-3H6v3H5v-2h3v-2H5V5h14v9h-2v3z\",\n\t\t\t\t\t\t\tonclick: exportToExcel\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t},\n\t\t\t\txAxis: {\n\t\t\t\t\ttype: \"category\",\n\t\t\t\t\tdata: pvUvDate\n\t\t\t\t},\n\t\t\t\tyAxis: {\n\t\t\t\t\ttype: \"value\"\n\t\t\t\t},\n\t\t\t\tseries: [\n\t\t\t\t\t{\n\t\t\t\t\t\tname: \"PV\",\n\t\t\t\t\t\tdata: pvDateCount,\n\t\t\t\t\t\ttype: \"line\",\n\t\t\t\t\t\tsmooth: true,\n\t\t\t\t\t\tlabel: {\n\t\t\t\t\t\t\tshow: true,\n\t\t\t\t\t\t\tposition: \"top\",\n\t\t\t\t\t\t\ttextStyle: {\n\t\t\t\t\t\t\t\tfontSize: 20\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t}\n\t\t\t\t\t},\n\t\t\t\t\t{\n\t\t\t\t\t\tname: \"UV\",\n\t\t\t\t\t\tdata: uvDateCount,\n\t\t\t\t\t\ttype: \"line\",\n\t\t\t\t\t\tsmooth: true\n\t\t\t\t\t}\n\t\t\t\t]\n\t\t\t};\n\n\t\t\tmyChartRef.current = myChart;\n\t\t\toption && myChart.setOption(option);\n\t\t\twindow.addEventListener(\"resize\", resizeChart);\n\t\t};\n\t\tgetPvUvRef();\n\t\treturn () => {\n\t\t\twindow.removeEventListener(\"resize\", resizeChart);\n\t\t};\n\t}, [pvUvDate, pvDateCount, isDarkTheme]);\n\n\treturn (\n\t\t<div className=\"statistics\">\n\t\t\t<ContentWrap className=\"content\">\n\t\t\t\t<div className=\"statistics-all__wrap top-content\">\n\t\t\t\t\t<div className=\"item-left sle\">\n\t\t\t\t\t\t<span className=\"left-title\">访问总数</span>\n\t\t\t\t\t\t<div className=\"img-box\">\n\t\t\t\t\t\t\t<img src={pvCountImg} />\n\t\t\t\t\t\t</div>\n\t\t\t\t\t\t<span className=\"left-number\">{pvCount}</span>\n\t\t\t\t\t</div>\n\t\t\t\t\t<div className=\"item-center\">\n\t\t\t\t\t\t<div className=\"gitee-traffic traffic-box\">\n\t\t\t\t\t\t\t<div className=\"traffic-img\">\n\t\t\t\t\t\t\t\t<img src={userCountImg} />\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t<span className=\"item-value\">{starPayCount}</span>\n\t\t\t\t\t\t\t<span className=\"traffic-name sle\">星球用户</span>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t\t<div className=\"gitHub-traffic traffic-box\">\n\t\t\t\t\t\t\t<div className=\"traffic-img\">\n\t\t\t\t\t\t\t\t<img src={zhuceImg} />\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t<span className=\"item-value\">{userCount}</span>\n\t\t\t\t\t\t\t<span className=\"traffic-name sle\">用户总数</span>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t\t<div className=\"today-traffic traffic-box\">\n\t\t\t\t\t\t\t<div className=\"traffic-img\">\n\t\t\t\t\t\t\t\t<img src={articleCountImg} />\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t<span className=\"item-value\">{articleCount}</span>\n\t\t\t\t\t\t\t<span className=\"traffic-name sle\">文章总数</span>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t\t<div className=\"yesterday-traffic traffic-box\">\n\t\t\t\t\t\t\t<div className=\"traffic-img\">\n\t\t\t\t\t\t\t\t<img src={zhuanlanImg} />\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t<span className=\"item-value\">{tutorialCount}</span>\n\t\t\t\t\t\t\t<span className=\"traffic-name sle\">专栏总数</span>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t</div>\n\t\t\t\t\t<div className=\"item-right\">\n\t\t\t\t\t\t<div className=\"statistics-pie\" ref={pieChartRef}></div>\n\t\t\t\t\t</div>\n\t\t\t\t</div>\n\t\t\t\t<div className=\"statistics-pv__wrap\">\n\t\t\t\t\t<div className=\"statistics-setting\">\n\t\t\t\t\t\t<Switch\n\t\t\t\t\t\t\tstyle={{ marginRight: \"20px\" }}\n\t\t\t\t\t\t\tonChange={checked => setIsDarkTheme(checked)}\n\t\t\t\t\t\t\tcheckedChildren=\"深色\"\n\t\t\t\t\t\t\tunCheckedChildren=\"浅色\"\n\t\t\t\t\t\t/>\n\n\t\t\t\t\t\t<Select style={{ width: \"100px\" }} value={pvUvDay} onChange={value => setPvUvDay(value)} options={dayLimitList} />\n\t\t\t\t\t</div>\n\t\t\t\t\t<div className=\"statistics-pv\" ref={chartRef}></div>\n\t\t\t\t</div>\n\t\t\t</ContentWrap>\n\t\t</div>\n\t);\n};\n\nexport default Statistics;\n"
  },
  {
    "path": "src/views/tag/components/search/index.scss",
    "content": ".tag-search {\n  margin-bottom: 16px;\n\n  &__wrap {\n    display: flex;\n    justify-content: space-between;\n    padding-left: 48px;\n  }\n\n  &__search {\n    flex: 1;\n    display: flex;\n\t\tjustify-content: space-between;\n\t\t&-wrap {\n\t\t\tdisplay: flex;\n\t\t}\n  }\n\n  &__search-item {\n    margin-right: 48px;\n  }\n\n  &-label {\n    margin-right: 12px;\n  }\n}\n"
  },
  {
    "path": "src/views/tag/components/search/index.tsx",
    "content": "/* eslint-disable prettier/prettier */\nimport { FC } from \"react\";\nimport { PlusOutlined, SearchOutlined } from \"@ant-design/icons\";\nimport { Button, Input, Select } from \"antd\";\n\nimport { ContentInterWrap } from \"@/components/common-wrap\";\n\nimport \"./index.scss\";\n\ninterface IProps {\n\thandleSearch: (e: object) => void;\n\thandleSearchChange: (e: object) => void;\n\thandleAdd: () => void;\n}\n\nconst Search: FC<IProps> = ({ \n\thandleSearch, \n\thandleSearchChange, \n\thandleAdd \n}) => {\n\treturn (\n\t\t<div className=\"tag-search\">\n\t\t\t<ContentInterWrap className=\"tag-search__wrap\">\n\t\t\t\t<div className=\"tag-search__search\">\n\t\t\t\t\t<div className=\"tag-search__search-wrap\">\n\t\t\t\t\t\t<div className=\"tag-search__search-item\">\n\t\t\t\t\t\t\t<label className=\"tag-search-label\">名称</label>\n\t\t\t\t\t\t\t<Input\n\t\t\t\t\t\t\t\tallowClear\n\t\t\t\t\t\t\t\tstyle={{ width: 252 }}\n\t\t\t\t\t\t\t\tplaceholder=\"请输入标签名称\"\n\t\t\t\t\t\t\t\tonChange={e => handleSearchChange({ tag: e.target.value })}\n\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t\t<div className=\"tag-search__search-item\">\n\t\t\t\t\t\t\t<label className=\"tag-search-label\">状态</label>\n\t\t\t\t\t\t\t<Select\n\t\t\t\t\t\t\t\tallowClear\n\t\t\t\t\t\t\t\tstyle={{ width: 120 }}\n\t\t\t\t\t\t\t\tplaceholder=\"请选择\"\n\t\t\t\t\t\t\t\tonChange={value => handleSearchChange({ status: value })}\n\t\t\t\t\t\t\t\toptions={[\n\t\t\t\t\t\t\t\t\t{ value: 1, label: '上线' },\n\t\t\t\t\t\t\t\t\t{ value: 0, label: '下线' },\n\t\t\t\t\t\t\t\t]}\n\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t</div>\n\t\t\t\t\t<div className=\"tag-search__search-btn\">\n\t\t\t\t\t\t<Button\n\t\t\t\t\t\t\ttype=\"primary\"\n\t\t\t\t\t\t\ticon={<SearchOutlined />}\n\t\t\t\t\t\t\tstyle={{ marginRight: \"10px\" }}\n\t\t\t\t\t\t\tonClick={handleSearch}\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t搜索\n\t\t\t\t\t\t</Button>\n\t\t\t\t\t\t<Button\n\t\t\t\t\t\t\ttype=\"primary\"\n\t\t\t\t\t\t\ticon={<PlusOutlined />}\n\t\t\t\t\t\t\tstyle={{ marginRight: \"20px\" }}\n\t\t\t\t\t\t\tonClick={handleAdd}\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t添加\n\t\t\t\t\t\t</Button>\n\t\t\t\t\t</div>\n\t\t\t\t</div>\n\t\t\t</ContentInterWrap>\n\t\t</div>\n\t);\n};\nexport default Search;\n"
  },
  {
    "path": "src/views/tag/index.css",
    "content": ".sort {\n  height: 100%;\n}\n"
  },
  {
    "path": "src/views/tag/index.scss",
    "content": "\n"
  },
  {
    "path": "src/views/tag/index.tsx",
    "content": "/* eslint-disable prettier/prettier */\nimport { FC, useCallback, useEffect, useState } from \"react\";\nimport { connect } from \"react-redux\";\nimport { DeleteOutlined, EditOutlined } from \"@ant-design/icons\";\nimport { Button, Drawer, Form, Input, message, Modal, Space, Switch, Table } from \"antd\";\nimport type { ColumnsType } from \"antd/es/table\";\n\nimport { delTagApi, getTagListApi, operateTagApi, updateTagApi } from \"@/api/modules/tag\";\nimport { ContentInterWrap, ContentWrap } from \"@/components/common-wrap\";\nimport { initPagination, IPagination, UpdateEnum } from \"@/enums/common\";\nimport { MapItem } from \"@/typings/common\";\nimport Search from \"./components/search\";\n\nimport \"./index.scss\";\n\ninterface DataType {\n\ttagId: number;\n\ttag: string;\n\tstatus: number;\n}\n\ninterface IProps {}\n\nexport interface IFormType {\n\ttagId: number; // 为0时，是保存，非0是更新\n\ttag: string; // 标签名\n\tstatus?: number; // 状态：1-上线，0-下线\n}\n\nconst defaultInitForm: IFormType = {\n\ttagId: -1,\n\ttag: \"\",\n\tstatus: undefined\n};\n\nconst Tag: FC<IProps> = props => {\n\tconst [formRef] = Form.useForm();\n\t// form值\n\tconst [form, setForm] = useState<IFormType>(defaultInitForm);\n\t// 查询表单值\n\tconst [searchForm, setSearchForm] = useState<IFormType>(defaultInitForm);\n\t// 抽屉\n\tconst [isDrawerOpen, setIsDrawerOpen] = useState<boolean>(false);\n\t// 列表数据\n\tconst [tableData, setTableData] = useState<DataType[]>([]);\n\t// 刷新函数\n\tconst [query, setQuery] = useState<number>(0);\n\n\t//当前的状态\n\tconst [status, setStatus] = useState<UpdateEnum>(UpdateEnum.Save);\n\n\t// 分页\n\tconst [pagination, setPagination] = useState<IPagination>(initPagination);\n\tconst { current, pageSize } = pagination;\n\n\tconst paginationInfo = {\n\t\tshowSizeChanger: true,\n\t\tshowTotal: (total: number) => `共 ${total || 0} 条`,\n\t\t...pagination,\n\t\tonChange: (current: number, pageSize: number) => {\n\t\t\tsetPagination({ current, pageSize });\n\t\t}\n\t};\n\n\tconst { tagId } = form;\n\n\tconst onSure = useCallback(() => {\n\t\tsetQuery(prev => prev + 1);\n\t}, []);\n\n\t// 值改变\n\tconst handleChange = (item: MapItem) => {\n\t\tsetForm({ ...form, ...item });\n\t};\n\t// 查询表单值改变\n\tconst handleSearchChange = (item: MapItem) => {\n\t\tsetSearchForm({ ...searchForm, ...item });\n\t};\n\t// 点击搜索按钮时触发搜索\n\tconst handleSearch = () => {\n\t\tsetPagination(initPagination);\n\t\tonSure();\n\t};\n\t// 抽屉关闭\n\tconst handleClose = () => {\n\t\tsetIsDrawerOpen(false);\n\t};\n\t// 重置表单\n\tconst resetForm = () => {\n\t\tsetForm(defaultInitForm);\n\t};\n\t// 新增触发\n\tconst handleAdd = () => {\n\t\tresetForm();\n\t\tsetStatus(UpdateEnum.Save);\n\t\tsetIsDrawerOpen(true);\n\t};\n\n\t// 删除\n\tconst handleDel = (tagId: number) => {\n\t\tModal.warning({\n\t\t\ttitle: \"确认删除此标签吗\",\n\t\t\tcontent: \"删除此标签后无法恢复，请谨慎操作！\",\n\t\t\tmaskClosable: true,\n\t\t\tclosable: true,\n\t\t\tonOk: async () => {\n\t\t\t\tconst { status } = await delTagApi(tagId);\n\t\t\t\tconst { code, msg } = status || {};\n\t\t\t\tconsole.log();\n\t\t\t\tif (code === 0) {\n\t\t\t\t\tmessage.success(\"删除成功\");\n\t\t\t\t\tonSure();\n\t\t\t\t} else {\n\t\t\t\t\tmessage.error(msg);\n\t\t\t\t}\n\t\t\t}\n\t\t});\n\t};\n\n\tconst handleSubmit = async () => {\n\t\tconst values = await formRef.validateFields();\n\t\tconst newValues = {\n\t\t\t...values,\n\t\t\ttagId: status === UpdateEnum.Save ? UpdateEnum.Save : tagId\n\t\t};\n\t\tconst { status: successStatus } = (await updateTagApi(newValues)) || {};\n\t\tconst { code, msg } = successStatus || {};\n\t\tif (code === 0) {\n\t\t\tsetIsDrawerOpen(false);\n\t\t\tsetPagination({ current: 1, pageSize });\n\t\t\tonSure();\n\t\t} else {\n\t\t\tmessage.error(msg);\n\t\t}\n\t};\n\n\t// 上线/下线\n\tconst handleOperate = async (tagId: number, pushStatus: number) => {\n\t\tconst { status } = await operateTagApi({ tagId, pushStatus });\n\t\tconst { code, msg } = status || {};\n\t\tconsole.log();\n\t\tif (code === 0) {\n\t\t\tmessage.success(\"操作成功\");\n\t\t\tonSure();\n\t\t} else {\n\t\t\tmessage.error(msg);\n\t\t}\n\t};\n\n\t// 数据请求\n\tuseEffect(() => {\n\t\tconst getSortList = async () => {\n\t\t\tconst { status, result } = await getTagListApi({\n\t\t\t\t...searchForm,\n\t\t\t\tpageNumber: current,\n\t\t\t\tpageSize\n\t\t\t});\n\t\t\tconst { code } = status || {};\n\t\t\t//@ts-ignore\n\t\t\tconst { list, pageNum, pageSize: resPageSize, pageTotal, total } = result || {};\n\t\t\tsetPagination({ current: Number(pageNum), pageSize: resPageSize, total });\n\t\t\tif (code === 0) {\n\t\t\t\tconst newList = list.map((item: MapItem) => ({ ...item, key: item?.tagId }));\n\t\t\t\tsetTableData(newList);\n\t\t\t}\n\t\t};\n\t\tgetSortList();\n\t}, [query, current, pageSize]);\n\n\t// 表头设置\n\tconst columns: ColumnsType<DataType> = [\n\t\t{\n\t\t\ttitle: \"标签\",\n\t\t\tdataIndex: \"tag\",\n\t\t\tkey: \"tag\"\n\t\t},\n\t\t{\n\t\t\ttitle: \"上下线\",\n\t\t\tdataIndex: \"status\",\n\t\t\tkey: \"status\",\n\t\t\trender(status, item) {\n\t\t\t\treturn (\n\t\t\t\t\t<Switch\n\t\t\t\t\t\tchecked={status === 1}\n\t\t\t\t\t\tonChange={() => {\n\t\t\t\t\t\t\tconst pushStatus = status === 0 ? 1 : 0;\n\t\t\t\t\t\t\thandleOperate(item.tagId, pushStatus);\n\t\t\t\t\t\t}}\n\t\t\t\t\t/>\n\t\t\t\t);\n\t\t\t}\n\t\t},\n\t\t{\n\t\t\ttitle: \"操作\",\n\t\t\tkey: \"key\",\n\t\t\twidth: 210,\n\t\t\trender: (_, item) => {\n\t\t\t\tconst { tagId } = item;\n\t\t\t\treturn (\n\t\t\t\t\t<div className=\"operation-btn\">\n\t\t\t\t\t\t<Button\n\t\t\t\t\t\t\ttype=\"primary\"\n\t\t\t\t\t\t\ticon={<EditOutlined />}\n\t\t\t\t\t\t\tstyle={{ marginRight: \"10px\" }}\n\t\t\t\t\t\t\tonClick={() => {\n\t\t\t\t\t\t\t\tsetIsDrawerOpen(true);\n\t\t\t\t\t\t\t\tsetStatus(UpdateEnum.Edit);\n\t\t\t\t\t\t\t\thandleChange({ ...item });\n\t\t\t\t\t\t\t\tformRef.setFieldsValue({ ...item });\n\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t编辑\n\t\t\t\t\t\t</Button>\n\n\t\t\t\t\t\t<Button type=\"primary\" danger icon={<DeleteOutlined />} onClick={() => handleDel(tagId)}>\n\t\t\t\t\t\t\t删除\n\t\t\t\t\t\t</Button>\n\t\t\t\t\t</div>\n\t\t\t\t);\n\t\t\t}\n\t\t}\n\t];\n\n\t// 编辑表单\n\tconst reviseDrawerContent = (\n\t\t<Form name=\"basic\" form={formRef} labelCol={{ span: 4 }} wrapperCol={{ span: 16 }} autoComplete=\"off\">\n\t\t\t<Form.Item label=\"标签\" name=\"tag\" rules={[{ required: true, message: \"请输入名称!\" }]}>\n\t\t\t\t<Input\n\t\t\t\t\tallowClear\n\t\t\t\t\tonChange={e => {\n\t\t\t\t\t\thandleChange({ tag: e.target.value });\n\t\t\t\t\t}}\n\t\t\t\t/>\n\t\t\t</Form.Item>\n\t\t</Form>\n\t);\n\n\treturn (\n\t\t<div className=\"banner\">\n\t\t\t<ContentWrap>\n\t\t\t\t{/* 搜索 */}\n\t\t\t\t<Search handleSearchChange={handleSearchChange} handleSearch={handleSearch} handleAdd={handleAdd} />\n\t\t\t\t{/* 表格 */}\n\t\t\t\t<ContentInterWrap>\n\t\t\t\t\t<Table columns={columns} dataSource={tableData} pagination={paginationInfo} />\n\t\t\t\t</ContentInterWrap>\n\t\t\t</ContentWrap>\n\t\t\t{/* 抽屉 */}\n\t\t\t<Drawer\n\t\t\t\ttitle=\"添加/修改\"\n\t\t\t\topen={isDrawerOpen}\n\t\t\t\tonClose={handleClose}\n\t\t\t\textra={\n\t\t\t\t\t<Space>\n\t\t\t\t\t\t<Button onClick={handleClose}>取消</Button>\n\t\t\t\t\t\t<Button type=\"primary\" onClick={handleSubmit}>\n\t\t\t\t\t\t\t确定\n\t\t\t\t\t\t</Button>\n\t\t\t\t\t</Space>\n\t\t\t\t}\n\t\t\t>\n\t\t\t\t{reviseDrawerContent}\n\t\t\t</Drawer>\n\t\t</div>\n\t);\n};\n\nconst mapStateToProps = (state: any) => state.disc.disc;\nconst mapDispatchToProps = {};\nexport default connect(mapStateToProps, mapDispatchToProps)(Tag);\n"
  },
  {
    "path": "src/views/wxMenu/index.scss",
    "content": ".wx-menu-page {\n\t--wx-page-bg: transparent;\n\t--wx-panel-bg: #fff;\n\t--wx-panel-subtle: #fafbfc;\n\t--wx-border: #e5e6eb;\n\t--wx-border-strong: #d8dce3;\n\t--wx-text: #1f2329;\n\t--wx-text-secondary: #4e5969;\n\t--wx-text-tertiary: #86909c;\n\t--wx-primary: #1890ff;\n\t--wx-primary-soft: rgb(24 144 255 / 8%);\n\t--wx-success-soft: rgb(82 196 26 / 8%);\n\t--wx-radius-lg: 12px;\n\t--wx-radius-md: 8px;\n\n\t&__wrap {\n\t\tbackground: var(--wx-page-bg);\n\t\tpadding-bottom: 16px;\n\t}\n\n\t&__layout {\n\t\tdisplay: flex;\n\t\tflex-direction: column;\n\t\tgap: 16px;\n\t\tmin-width: 0;\n\t}\n\n\t&__hero,\n\t&__action-bar,\n\t&__panel,\n\t&__rule-card,\n\t&__subcard {\n\t\tbackground: var(--wx-panel-bg) !important;\n\t\tborder: 1px solid var(--wx-border) !important;\n\t\tborder-radius: var(--wx-radius-lg) !important;\n\t\tbox-shadow: none !important;\n\t}\n\n\t&__hero {\n\t\tdisplay: grid;\n\t\tgrid-template-columns: minmax(0, 1.15fr) minmax(320px, 0.95fr);\n\t\tgap: 16px;\n\t\tpadding: 20px 24px;\n\t}\n\n\t&__hero-copy,\n\t&__hero-metrics,\n\t&__menu-layout,\n\t&__sidebar,\n\t&__editor-shell,\n\t&__single-layout,\n\t&__rules-layout,\n\t&__aside-stack,\n\t&__fallback-grid,\n\t&__rule-grid,\n\t&__rules-list,\n\t&__section-copy {\n\t\tmin-width: 0;\n\t}\n\n\t&__eyebrow {\n\t\tmargin: 0 0 8px;\n\t\tcolor: var(--wx-primary);\n\t\tfont-size: 13px;\n\t\tfont-weight: 500;\n\t\tline-height: 1.4;\n\t}\n\n\t&__title {\n\t\tmargin: 0;\n\t\tcolor: var(--wx-text);\n\t\tfont-size: 26px;\n\t\tfont-weight: 600;\n\t\tline-height: 1.4;\n\t}\n\n\t&__subtitle {\n\t\tmargin: 8px 0 0;\n\t\tmax-width: 760px;\n\t\tcolor: var(--wx-text-secondary);\n\t\tfont-size: 14px;\n\t\tline-height: 1.75;\n\t}\n\n\t&__hero-metrics {\n\t\tdisplay: grid;\n\t\tgrid-template-columns: repeat(2, minmax(0, 1fr));\n\t\tgap: 12px;\n\t}\n\n\t&__metric-card {\n\t\tdisplay: flex;\n\t\tflex-direction: column;\n\t\tgap: 6px;\n\t\tmin-width: 0;\n\t\tpadding: 14px 16px;\n\t\tbackground: var(--wx-panel-subtle);\n\t\tborder: 1px solid var(--wx-border);\n\t\tborder-radius: var(--wx-radius-md);\n\t}\n\n\t&__metric-label {\n\t\tcolor: var(--wx-text-tertiary);\n\t\tfont-size: 12px;\n\t\tline-height: 1.4;\n\t}\n\n\t&__metric-value {\n\t\tcolor: var(--wx-text);\n\t\tfont-size: 18px;\n\t\tfont-weight: 600;\n\t\tline-height: 1.5;\n\t\toverflow-wrap: anywhere;\n\t}\n\n\t&__action-bar {\n\t\t.ant-card-body {\n\t\t\tdisplay: flex;\n\t\t\tflex-wrap: wrap;\n\t\t\talign-items: center;\n\t\t\tjustify-content: space-between;\n\t\t\tgap: 12px;\n\t\t}\n\t}\n\n\t&__action-copy {\n\t\tdisplay: flex;\n\t\tflex-direction: column;\n\t\tgap: 4px;\n\t\tmin-width: 0;\n\t}\n\n\t&__module {\n\t\tdisplay: flex;\n\t\tflex-direction: column;\n\t\tgap: 12px;\n\t}\n\n\t&__section-head {\n\t\tdisplay: flex;\n\t\talign-items: flex-start;\n\t\tgap: 10px;\n\t}\n\n\t&__section-badge {\n\t\tflex-shrink: 0;\n\t\tmin-width: 66px;\n\t\tpadding: 2px 8px;\n\t\tborder-radius: 999px;\n\t\tbackground: var(--wx-primary-soft);\n\t\tcolor: var(--wx-primary);\n\t\tfont-size: 12px;\n\t\tline-height: 24px;\n\t\ttext-align: center;\n\t}\n\n\t&__section-title {\n\t\tmargin: 0;\n\t\tcolor: var(--wx-text);\n\t\tfont-size: 20px;\n\t\tfont-weight: 600;\n\t\tline-height: 1.5;\n\t}\n\n\t&__section-desc {\n\t\tmargin: 4px 0 0;\n\t\tcolor: var(--wx-text-secondary);\n\t\tfont-size: 13px;\n\t\tline-height: 1.7;\n\t}\n\n\t&__menu-layout {\n\t\tdisplay: grid;\n\t\tgrid-template-columns: minmax(0, 1.45fr) minmax(320px, 0.92fr);\n\t\tgap: 16px;\n\t\talign-items: stretch;\n\t}\n\n\t&__single-layout {\n\t\tdisplay: grid;\n\t\tgrid-template-columns: minmax(0, 1.3fr) minmax(280px, 0.7fr);\n\t\tgap: 16px;\n\t\talign-items: start;\n\t}\n\n\t&__rules-layout {\n\t\tdisplay: grid;\n\t\tgrid-template-columns: minmax(0, 1.35fr) minmax(280px, 0.75fr);\n\t\tgap: 16px;\n\t\talign-items: start;\n\t}\n\n\t&__fallback-grid {\n\t\tdisplay: grid;\n\t\tgrid-template-columns: repeat(3, minmax(0, 1fr));\n\t\tgap: 16px;\n\t\talign-items: start;\n\t}\n\n\t&__runtime-grid {\n\t\tdisplay: grid;\n\t\tgrid-template-columns: repeat(2, minmax(0, 1fr));\n\t\tgap: 16px;\n\t\talign-items: start;\n\t}\n\n\t&__sidebar,\n\t&__aside-stack {\n\t\tdisplay: flex;\n\t\tflex-direction: column;\n\t\tgap: 12px;\n\t}\n\n\t&__panel {\n\t\toverflow: hidden;\n\t}\n\n\t&__panel--editor {\n\t\theight: 100%;\n\n\t\t.ant-card-body {\n\t\t\tdisplay: flex;\n\t\t\theight: 100%;\n\t\t}\n\t}\n\n\t&__panel--aside {\n\t\tbackground: var(--wx-panel-subtle) !important;\n\t}\n\n\t&__subcard,\n\t&__rule-card {\n\t\tborder-radius: var(--wx-radius-md) !important;\n\t}\n\n\t&__editor-shell {\n\t\tdisplay: flex;\n\t\tflex: 1;\n\t\tflex-direction: column;\n\t\tgap: 16px;\n\t\tmin-height: 620px;\n\t}\n\n\t&__editor-field {\n\t\tdisplay: flex;\n\t\tflex: 1;\n\t\tflex-direction: column;\n\t\tmin-height: 0;\n\t}\n\n\t&__field,\n\t&__field-head {\n\t\tdisplay: flex;\n\t\tflex-direction: column;\n\t\tgap: 8px;\n\t\tmin-width: 0;\n\t}\n\n\t&__field--full {\n\t\tgrid-column: 1 / -1;\n\t}\n\n\t&__field-head {\n\t\tmargin-bottom: 8px;\n\t}\n\n\t&__comment {\n\t\tmargin-top: 2px;\n\t}\n\n\t&__editor {\n\t\tflex: 1;\n\t\tmin-height: 420px;\n\t\tfont-family: Monaco, Menlo, Consolas, \"Courier New\", monospace;\n\t}\n\n\t&__tag-list {\n\t\tdisplay: flex;\n\t\tflex-wrap: wrap;\n\t\tgap: 8px;\n\t}\n\n\t&__reply-empty {\n\t\tdisplay: flex;\n\t\tflex-direction: column;\n\t\tgap: 12px;\n\t}\n\n\t&__reply-actions {\n\t\tmargin-bottom: 4px;\n\t}\n\n\t&__reply-articles {\n\t\tdisplay: flex;\n\t\tflex-direction: column;\n\t\tgap: 12px;\n\t}\n\n\t&__preview-phone {\n\t\toverflow: hidden;\n\t\tborder: 1px solid var(--wx-border);\n\t\tborder-radius: 18px;\n\t\tbackground: #f5f7fa;\n\t}\n\n\t&__preview-phone-head {\n\t\tpadding: 10px 14px;\n\t\tborder-bottom: 1px solid var(--wx-border);\n\t\tbackground: #fff;\n\t\tcolor: var(--wx-text);\n\t\tfont-size: 13px;\n\t\tfont-weight: 500;\n\t}\n\n\t&__preview-phone-body {\n\t\tdisplay: flex;\n\t\tflex-direction: column;\n\t\tgap: 12px;\n\t\tmin-height: 180px;\n\t\tpadding: 14px;\n\t\tbackground: linear-gradient(180deg, #f7f8fa 0%, #eef2f6 100%);\n\t}\n\n\t&__preview-phone-body--menu {\n\t\tmin-height: 120px;\n\t\tjustify-content: flex-end;\n\t}\n\n\t&__preview-bubble {\n\t\tmax-width: 90%;\n\t\tpadding: 10px 12px;\n\t\tborder-radius: 12px;\n\t\tbackground: #fff;\n\t\tbox-shadow: 0 1px 2px rgb(0 0 0 / 4%);\n\t}\n\n\t&__preview-bubble--user {\n\t\talign-self: flex-end;\n\t\tbackground: #d9f7be;\n\t}\n\n\t&__preview-bubble--bot {\n\t\talign-self: flex-start;\n\t}\n\n\t&__preview-bubble-label {\n\t\tmargin-bottom: 6px;\n\t\tcolor: var(--wx-text-tertiary);\n\t\tfont-size: 12px;\n\t\tline-height: 1.4;\n\t}\n\n\t&__preview-bubble-content,\n\t&__preview-rich-text {\n\t\tcolor: var(--wx-text);\n\t\tfont-size: 13px;\n\t\tline-height: 1.8;\n\t}\n\n\t&__preview-rich-text a,\n\t&__preview-news-copy a {\n\t\tcolor: var(--wx-primary);\n\t}\n\n\t&__preview-empty {\n\t\tcolor: var(--wx-text-tertiary);\n\t\tfont-size: 13px;\n\t\tline-height: 1.7;\n\t}\n\n\t&__preview-news-list {\n\t\tdisplay: flex;\n\t\tflex-direction: column;\n\t\tgap: 10px;\n\t}\n\n\t&__preview-news-card {\n\t\tdisplay: grid;\n\t\tgrid-template-columns: 72px minmax(0, 1fr);\n\t\tgap: 10px;\n\t\tpadding: 10px;\n\t\tborder: 1px solid var(--wx-border);\n\t\tborder-radius: var(--wx-radius-md);\n\t\tbackground: #fff;\n\t}\n\n\t&__preview-news-cover {\n\t\tdisplay: flex;\n\t\talign-items: center;\n\t\tjustify-content: center;\n\t\toverflow: hidden;\n\t\tborder-radius: 8px;\n\t\tbackground: var(--wx-panel-subtle);\n\t\tcolor: var(--wx-text-tertiary);\n\t\tfont-size: 12px;\n\t\ttext-align: center;\n\t}\n\n\t&__preview-news-cover img {\n\t\twidth: 100%;\n\t\theight: 100%;\n\t\tobject-fit: cover;\n\t}\n\n\t&__preview-news-copy {\n\t\tdisplay: flex;\n\t\tflex-direction: column;\n\t\tgap: 6px;\n\t\tmin-width: 0;\n\t\tcolor: var(--wx-text-secondary);\n\t\tfont-size: 12px;\n\t\tline-height: 1.6;\n\t}\n\n\t&__preview-submenus {\n\t\tdisplay: flex;\n\t\tflex-direction: column;\n\t\tgap: 10px;\n\t\tpadding: 0 14px 12px;\n\t\tbackground: linear-gradient(180deg, #f7f8fa 0%, #eef2f6 100%);\n\t}\n\n\t&__preview-submenu-card {\n\t\tpadding: 10px 12px;\n\t\tborder: 1px solid var(--wx-border);\n\t\tborder-radius: var(--wx-radius-md);\n\t\tbackground: #fff;\n\t}\n\n\t&__preview-submenu-list {\n\t\tdisplay: flex;\n\t\tflex-wrap: wrap;\n\t\tgap: 8px;\n\t\tmargin-top: 8px;\n\t}\n\n\t&__preview-submenu-list span {\n\t\tpadding: 4px 8px;\n\t\tborder-radius: 999px;\n\t\tbackground: var(--wx-primary-soft);\n\t\tcolor: var(--wx-primary);\n\t\tfont-size: 12px;\n\t\tline-height: 1.4;\n\t}\n\n\t&__preview-menu-bar {\n\t\tdisplay: grid;\n\t\tgrid-template-columns: repeat(3, minmax(0, 1fr));\n\t\tgap: 1px;\n\t\tborder-top: 1px solid var(--wx-border);\n\t\tbackground: var(--wx-border);\n\t}\n\n\t&__preview-menu-item {\n\t\tdisplay: flex;\n\t\tflex-direction: column;\n\t\talign-items: center;\n\t\tjustify-content: center;\n\t\tgap: 4px;\n\t\tmin-height: 54px;\n\t\tpadding: 8px;\n\t\tbackground: #fff;\n\t\tcolor: var(--wx-text);\n\t\tfont-size: 12px;\n\t\ttext-align: center;\n\t}\n\n\t&__preview-menu-item small {\n\t\tcolor: var(--wx-text-tertiary);\n\t\tfont-size: 11px;\n\t}\n\n\t&__preview-result {\n\t\tdisplay: flex;\n\t\tflex-direction: column;\n\t\tgap: 12px;\n\t}\n\n\t&__preview-meta {\n\t\tdisplay: grid;\n\t\tgrid-template-columns: repeat(2, minmax(0, 1fr));\n\t\tgap: 10px;\n\t}\n\n\t&__preview-meta-item {\n\t\tdisplay: flex;\n\t\tflex-direction: column;\n\t\tgap: 4px;\n\t\tpadding: 10px 12px;\n\t\tborder: 1px solid var(--wx-border);\n\t\tborder-radius: var(--wx-radius-md);\n\t\tbackground: var(--wx-panel-subtle);\n\t\tmin-width: 0;\n\t}\n\n\t&__preview-meta-item span {\n\t\tcolor: var(--wx-text-tertiary);\n\t\tfont-size: 12px;\n\t\tline-height: 1.4;\n\t}\n\n\t&__preview-meta-item strong {\n\t\tcolor: var(--wx-text);\n\t\tfont-size: 13px;\n\t\tline-height: 1.6;\n\t\toverflow-wrap: anywhere;\n\t}\n\n\t&__preview-hints {\n\t\tdisplay: flex;\n\t\tflex-direction: column;\n\t\tgap: 8px;\n\t}\n\n\t&__preview-hint {\n\t\tdisplay: flex;\n\t\tflex-direction: column;\n\t\tgap: 2px;\n\t\tmin-width: 0;\n\t}\n\n\t&__rule-actions {\n\t\tdisplay: flex;\n\t\tflex-wrap: wrap;\n\t\talign-items: center;\n\t\tjustify-content: space-between;\n\t\tgap: 12px;\n\t\tmargin-bottom: 16px;\n\t}\n\n\t&__rules-list {\n\t\tdisplay: flex;\n\t\tflex-direction: column;\n\t\tgap: 12px;\n\t}\n\n\t&__rule-grid {\n\t\tdisplay: grid;\n\t\tgrid-template-columns: repeat(2, minmax(0, 1fr));\n\t\tgap: 14px 16px;\n\t}\n\n\t&__rule-label {\n\t\tpadding: 10px 12px;\n\t\tborder-radius: var(--wx-radius-md);\n\t\tbackground: var(--wx-primary-soft);\n\t\tcolor: var(--wx-primary);\n\t\tline-height: 1.6;\n\t}\n\n\t&__rule-cheats,\n\t&__strategy-notes {\n\t\tdisplay: flex;\n\t\tflex-direction: column;\n\t\tgap: 10px;\n\t}\n\n\t&__cheat-item,\n\t&__strategy-item {\n\t\tdisplay: flex;\n\t\tflex-direction: column;\n\t\tgap: 6px;\n\t\tpadding: 12px;\n\t\tborder: 1px solid var(--wx-border);\n\t\tborder-radius: var(--wx-radius-md);\n\t\tbackground: #fff;\n\t\tcolor: var(--wx-text-secondary);\n\t\tline-height: 1.6;\n\t}\n\n\t&__strategy-item--active {\n\t\tborder-color: rgb(24 144 255 / 28%);\n\t\tbackground: var(--wx-primary-soft);\n\t\tcolor: var(--wx-primary);\n\t}\n\n\t&__switch-row {\n\t\tdisplay: flex;\n\t\talign-items: flex-start;\n\t\tjustify-content: space-between;\n\t\tgap: 12px;\n\t}\n\n\t&__empty-block {\n\t\tpadding: 28px 20px;\n\t\tborder: 1px dashed var(--wx-border-strong);\n\t\tborder-radius: var(--wx-radius-md);\n\t\tbackground: var(--wx-panel-subtle);\n\t}\n\n\t&__empty-title {\n\t\tmargin-bottom: 6px !important;\n\t\tcolor: var(--wx-text);\n\t\tfont-size: 15px;\n\t\tfont-weight: 600;\n\t}\n\n\t&__tree-node {\n\t\tdisplay: flex;\n\t\tflex-wrap: wrap;\n\t\talign-items: center;\n\t\tgap: 8px;\n\t\tpadding-right: 12px;\n\t}\n\n\t&__tree-name {\n\t\tfont-weight: 500;\n\t}\n\n\t&__tree-meta {\n\t\tword-break: break-all;\n\t}\n\n\t.ant-card-head {\n\t\tborder-bottom-color: var(--wx-border);\n\t}\n\n\t.ant-card-head-title {\n\t\tcolor: var(--wx-text);\n\t\tfont-weight: 600;\n\t}\n\n\t.ant-card,\n\t.ant-card-body,\n\t.ant-alert,\n\t.ant-list,\n\t.ant-list-item,\n\t.ant-tree,\n\t.ant-space,\n\t.ant-space-item,\n\t.ant-select,\n\t.ant-select-selector,\n\t.ant-input-number {\n\t\tmin-width: 0;\n\t}\n\n\t.ant-list-item,\n\t.ant-alert-description,\n\t.ant-tree-node-content-wrapper,\n\t.ant-input,\n\t.ant-input-number-input,\n\t.ant-typography,\n\ttextarea {\n\t\toverflow-wrap: anywhere;\n\t\tword-break: break-word;\n\t}\n\n\t@media (max-width: 1280px) {\n\t\t&__hero,\n\t\t&__menu-layout,\n\t\t&__single-layout,\n\t\t&__rules-layout,\n\t\t&__fallback-grid,\n\t\t&__runtime-grid {\n\t\t\tgrid-template-columns: 1fr;\n\t\t}\n\t}\n\n\t@media (max-width: 900px) {\n\t\t&__hero {\n\t\t\tpadding: 16px;\n\t\t}\n\n\t\t&__hero-metrics,\n\t\t&__rule-grid,\n\t\t&__preview-meta {\n\t\t\tgrid-template-columns: 1fr;\n\t\t}\n\n\t\t&__action-bar .ant-card-body,\n\t\t&__rule-actions,\n\t\t&__switch-row {\n\t\t\talign-items: flex-start;\n\t\t}\n\t}\n\n\t@media (max-width: 640px) {\n\t\t&__section-head {\n\t\t\tflex-direction: column;\n\t\t\tgap: 6px;\n\t\t}\n\n\t\t&__section-badge {\n\t\t\tmin-width: auto;\n\t\t\twidth: fit-content;\n\t\t}\n\n\t\t&__hero-metrics {\n\t\t\tgrid-template-columns: 1fr;\n\t\t}\n\t}\n}\n"
  },
  {
    "path": "src/views/wxMenu/index.tsx",
    "content": "import { FC, ReactNode, useEffect, useRef, useState } from \"react\";\nimport {\n\tAlert,\n\tButton,\n\tCard,\n\tInput,\n\tInputNumber,\n\tList,\n\tmessage,\n\tModal,\n\tSelect,\n\tSpace,\n\tSpin,\n\tSwitch,\n\tTag,\n\tTree,\n\tTypography\n} from \"antd\";\nimport type { DataNode } from \"antd/es/tree\";\n\nimport { AISourceValue, getAiConfigDetailApi } from \"@/api/modules/aiConfig\";\nimport {\n\tgetWxMenuDetailApi,\n\tpreviewWxMenuAiApi,\n\tpreviewWxMenuMatchApi,\n\tpublishWxMenuApi,\n\tsaveWxMenuDraftApi,\n\tsyncWxMenuApi,\n\tvalidateWxMenuApi,\n\tWxMenuButton,\n\tWxMenuAiProviderOption,\n\tWxMenuClickReply,\n\tWxMenuDetail,\n\tWxMenuKeywordReply,\n\tWxMenuPreviewAiRes,\n\tWxMenuPreviewMatchRes,\n\tWxMenuPublishRes,\n\tWxMenuReply,\n\tWxMenuReplyArticle,\n\tWxMenuTree,\n\tWxMenuValidateRes\n} from \"@/api/modules/wxMenu\";\nimport { ContentWrap } from \"@/components/common-wrap\";\n\nimport \"./index.scss\";\n\nconst { Paragraph, Text } = Typography;\nconst { TextArea } = Input;\n\nconst DEFAULT_DRAFT_COMMENT = \"微信自定义菜单草稿\";\nconst DEFAULT_FALLBACK_STRATEGY = \"fixed_reply\";\nconst LOCAL_DRAFT_CACHE_KEY = \"paicoding-admin:wx-menu:draft-cache\";\n\nconst REPLY_TYPE_OPTIONS = [\n\t{ label: \"文本回复\", value: \"text\" },\n\t{ label: \"图文回复\", value: \"news\" }\n];\n\nconst MATCH_TYPE_OPTIONS = [\n\t{ label: \"菜单 click key 精确匹配\", value: \"event_key_exact\" },\n\t{ label: \"消息内容精确匹配\", value: \"content_exact\" },\n\t{ label: \"消息内容包含匹配\", value: \"content_contains\" }\n];\n\nconst FALLBACK_STRATEGY_OPTIONS = [\n\t{ label: \"不处理\", value: \"none\" },\n\t{ label: \"固定回复\", value: \"fixed_reply\" },\n\t{ label: \"AI 回复\", value: \"ai_reply\" }\n];\n\nconst PREVIEW_EVENT_OPTIONS = [\n\t{ label: \"用户发消息\", value: \"TEXT\" },\n\t{ label: \"点击菜单 click\", value: \"CLICK\" },\n\t{ label: \"关注公众号\", value: \"subscribe\" }\n];\n\nconst AI_PROVIDER_CATALOG: Array<{\n\tlabel: string;\n\tvalue: AISourceValue;\n\tsyncPreviewSupported?: boolean;\n}> = [\n\t{ label: \"技术派默认 AI\", value: \"PAI_AI\" },\n\t{ label: \"智谱 AI\", value: \"ZHI_PU_AI\" },\n\t{ label: \"智谱 Coding\", value: \"ZHIPU_CODING\" },\n\t{ label: \"讯飞 AI\", value: \"XUN_FEI_AI\", syncPreviewSupported: false },\n\t{ label: \"阿里 AI\", value: \"ALI_AI\" },\n\t{ label: \"DeepSeek\", value: \"DEEP_SEEK\" },\n\t{ label: \"豆包 AI\", value: \"DOU_BAO_AI\" }\n];\n\ninterface AiProviderOptionViewModel {\n\tlabel: string;\n\tvalue: string;\n}\n\nconst DEFAULT_MENU_TEMPLATE = JSON.stringify(\n\t{\n\t\tbutton: [\n\t\t\t{\n\t\t\t\ttype: \"view\",\n\t\t\t\tname: \"首页\",\n\t\t\t\turl: \"https://paicoding.com\"\n\t\t\t},\n\t\t\t{\n\t\t\t\tname: \"内容精选\",\n\t\t\t\tsub_button: [\n\t\t\t\t\t{\n\t\t\t\t\t\ttype: \"view\",\n\t\t\t\t\t\tname: \"最新文章\",\n\t\t\t\t\t\turl: \"https://paicoding.com/article/list\"\n\t\t\t\t\t},\n\t\t\t\t\t{\n\t\t\t\t\t\ttype: \"click\",\n\t\t\t\t\t\tname: \"联系我们\",\n\t\t\t\t\t\tkey: \"CONTACT_US\"\n\t\t\t\t\t}\n\t\t\t\t]\n\t\t\t}\n\t\t]\n\t},\n\tnull,\n\t2\n);\n\nconst SUPPORTED_TYPES = [\n\t\"view\",\n\t\"miniprogram\",\n\t\"click\",\n\t\"scancode_push\",\n\t\"scancode_waitmsg\",\n\t\"pic_sysphoto\",\n\t\"pic_photo_or_album\",\n\t\"pic_weixin\",\n\t\"location_select\",\n\t\"media_id\",\n\t\"view_limited\",\n\t\"article_id\",\n\t\"article_view_limited\"\n];\n\nconst MATCH_TYPE_LABEL_MAP: Record<string, string> = {\n\tevent_key_exact: \"菜单 click key 精确匹配\",\n\tcontent_exact: \"消息内容精确匹配\",\n\tcontent_contains: \"消息内容包含匹配\"\n};\n\nconst MATCH_TYPE_HINT_MAP: Record<string, string> = {\n\tevent_key_exact: \"用于 click 菜单事件。关键字里一行一个 event key，例如 CONTACT_US。\",\n\tcontent_exact: \"只有用户发来的内容和关键字完全一致时才会命中，适合口令类指令。\",\n\tcontent_contains: \"只要消息里包含关键字就会命中，适合模糊词、品牌词和功能词。\"\n};\n\nconst FALLBACK_STRATEGY_DESC_MAP: Record<string, string> = {\n\tnone: \"未命中关键词后不走后台配置回复。\",\n\tfixed_reply: \"未命中关键词后，优先使用默认兜底回复。\",\n\tai_reply: \"未命中关键词后尝试交给大模型回复，再按系统逻辑兜底。\"\n};\n\nconst createEmptyArticle = (): WxMenuReplyArticle => ({\n\ttitle: \"\",\n\tdescription: \"\",\n\tpicUrl: \"\",\n\turl: \"\"\n});\n\nconst createReply = (replyType: \"text\" | \"news\" = \"text\"): WxMenuReply =>\n\treplyType === \"news\"\n\t\t? {\n\t\t\t\treplyType,\n\t\t\t\tarticles: [createEmptyArticle()]\n\t\t  }\n\t\t: {\n\t\t\t\treplyType,\n\t\t\t\tcontent: \"\"\n\t\t  };\n\nconst createKeywordReply = (matchType = \"content_contains\"): WxMenuKeywordReply => ({\n\tmatchType,\n\tkeywords: [],\n\treplyType: \"text\",\n\treply: createReply(\"text\"),\n\tenabled: true,\n\tpriority: 100,\n\ttitle: \"\"\n});\n\nconst createSubscribeReplyTemplate = (): WxMenuReply => ({\n\treplyType: \"text\",\n\tcontent: [\n\t\t\"🎉 你好，欢迎关注技术派！\",\n\t\t\"\",\n\t\t\"这里可以了解二哥编程星球的实战项目、学习路线和源码获取方式。\",\n\t\t\"\",\n\t\t\"🚀 核心项目入口：\",\n\t\t`• <a href=\"https://paicoding.com/column/13/1\">PaiFlow：企业级 AI Agent 工作流编排平台</a>`,\n\t\t`• <a href=\"https://paicoding.com/column/10/1\">派聪明 RAG：企业级知识库项目（Java 版）</a>`,\n\t\t`• <a href=\"https://paicoding.com\">技术派：Spring Boot + React 社区项目</a>`,\n\t\t`• <a href=\"https://paicoding.com/article/detail/2602100040108033\">项目源码和教程最新获取方法</a>`,\n\t\t\"\",\n\t\t\"💬 你也可以直接回复：\",\n\t\t\"【派聪明】了解 RAG 项目\",\n\t\t\"【PaiFlow】了解工作流编排项目\",\n\t\t\"【技术派】了解社区项目\",\n\t\t\"【源码】查看项目源码获取方式\"\n\t].join(\"\\n\")\n});\n\nconst createDefaultReplyTemplate = (): WxMenuReply => ({\n\treplyType: \"text\",\n\tcontent: [\n\t\t\"🙂 我这边主要提供二哥编程星球的项目、教程和源码获取说明。\",\n\t\t\"\",\n\t\t\"当前重点方向包括：PaiFlow 工作流编排、派聪明 RAG、技术派、pmhub、PaiAgent 和 mydb。\",\n\t\t`📚 项目总览：<a href=\"https://paicoding.com/article/detail/2602100040108033\">点击查看最新获取方法</a>`,\n\t\t\"\",\n\t\t\"你可以继续回复：\",\n\t\t\"【派聪明】\",\n\t\t\"【PaiFlow】\",\n\t\t\"【技术派】\",\n\t\t\"【源码】\",\n\t\t\"【星球】\",\n\t\t\"\",\n\t\t\"如果你想了解某个项目，我可以继续给你做针对性介绍。\"\n\t].join(\"\\n\")\n});\n\nconst createAiPromptTemplate = () =>\n\t[\n\t\t\"你是“技术派 / 二哥编程星球”的微信公众号助手，负责回答用户关于项目、教程、源码获取方式和学习路线的问题。\",\n\t\t\"\",\n\t\t\"你已知的重点项目信息如下：\",\n\t\t\"1. PaiFlow：企业级 AI Agent 工作流编排平台，强调可视化编排、模型节点和工具节点组合。\",\n\t\t\"2. PaiAgent：Vibe Coding 风格项目，是 PaiFlow 的前身，适合作为入门项目。\",\n\t\t\"3. 派聪明 RAG：企业级 RAG 知识库项目，包含 Java 版和 Go 版。\",\n\t\t\"4. 技术派：Spring Boot + React 的前后端分离社区项目，覆盖 Web 开发核心能力。\",\n\t\t\"5. pmhub：微服务企业级项目，适合进阶分布式和架构能力。\",\n\t\t\"6. mydb：适合做轮子和数据库基础能力训练。\",\n\t\t\"7. 校招派、编程喵、面试指南、LeetCode 等内容属于补充学习资料。\",\n\t\t\"\",\n\t\t\"回答要求：\",\n\t\t\"1. 优先回答项目定位、适合人群、亮点、学习顺序和源码获取方式。\",\n\t\t\"2. 语气简洁、可信、像一个答疑助手，不要太营销。\",\n\t\t\"3. 当用户提到“派聪明”“PaiFlow”“技术派”“pmhub”等关键词时，优先介绍对应项目。\",\n\t\t\"4. 当用户询问源码获取时，说明需要先加入星球，再按项目说明申请 GitCode 权限；如需人工审核，提醒补充 GitCode 昵称和星球编号。\",\n\t\t\"5. 不确定的信息不要编造，可以建议用户继续回复“派聪明”“PaiFlow”“技术派”“源码”获取更具体说明。\",\n\t\t\"6. 每次回复尽量控制在 3 到 6 句，先给结论，再给下一步引导。\"\n\t].join(\"\\n\");\n\nconst cloneData = <T,>(value: T): T => {\n\tif (value == null) return value;\n\treturn JSON.parse(JSON.stringify(value)) as T;\n};\n\ninterface LocalDraftCache {\n\teditorValue: string;\n\tdraftComment: string;\n\tsubscribeReply?: WxMenuReply;\n\tdefaultReply?: WxMenuReply;\n\tkeywordReplies?: WxMenuKeywordReply[];\n\tmessageFallbackStrategy?: string;\n\taiPrompt?: string;\n\taiProvider?: string;\n\taiEnable?: boolean;\n}\n\nconst readLocalDraftCache = (): LocalDraftCache | null => {\n\ttry {\n\t\tconst cache = window.localStorage.getItem(LOCAL_DRAFT_CACHE_KEY);\n\t\treturn cache ? (JSON.parse(cache) as LocalDraftCache) : null;\n\t} catch (error) {\n\t\treturn null;\n\t}\n};\n\nconst writeLocalDraftCache = (cache: LocalDraftCache) => {\n\ttry {\n\t\twindow.localStorage.setItem(LOCAL_DRAFT_CACHE_KEY, JSON.stringify(cache));\n\t} catch (error) {\n\t\tconsole.warn(\"wx menu draft cache save failed\", error);\n\t}\n};\n\nconst clearLocalDraftCache = () => {\n\ttry {\n\t\twindow.localStorage.removeItem(LOCAL_DRAFT_CACHE_KEY);\n\t} catch (error) {\n\t\tconsole.warn(\"wx menu draft cache clear failed\", error);\n\t}\n};\n\nconst formatMenuJson = (menuJson?: string) => {\n\tif (!menuJson) return DEFAULT_MENU_TEMPLATE;\n\ttry {\n\t\treturn JSON.stringify(JSON.parse(menuJson), null, 2);\n\t} catch (error) {\n\t\treturn menuJson;\n\t}\n};\n\nconst parseMenuJson = (menuJson?: string) => {\n\tif (!menuJson) return null;\n\ttry {\n\t\treturn JSON.parse(menuJson) as WxMenuTree;\n\t} catch (error) {\n\t\treturn null;\n\t}\n};\n\nconst getButtonMeta = (button: WxMenuButton) => {\n\tif (button.sub_button?.length) return `包含 ${button.sub_button.length} 个二级菜单`;\n\tif (button.type === \"view\") return button.url || \"未填写 url\";\n\tif (button.type === \"miniprogram\") return `${button.appid || \"未填写 appid\"} / ${button.pagepath || \"未填写 pagepath\"}`;\n\tif (button.key) return button.key;\n\tif (button.media_id) return button.media_id;\n\tif (button.article_id) return button.article_id;\n\treturn \"未配置额外参数\";\n};\n\nconst normalizeText = (value?: string) => value?.trim();\n\nconst normalizeReply = (reply?: WxMenuReply) => {\n\tif (!reply) return undefined;\n\n\tconst replyType = normalizeText(reply.replyType);\n\tif (!replyType) return undefined;\n\n\tif (replyType === \"news\") {\n\t\treturn {\n\t\t\treplyType,\n\t\t\tarticles: (reply.articles || []).map(item => ({\n\t\t\t\ttitle: normalizeText(item.title),\n\t\t\t\tdescription: normalizeText(item.description),\n\t\t\t\tpicUrl: normalizeText(item.picUrl),\n\t\t\t\turl: normalizeText(item.url)\n\t\t\t}))\n\t\t};\n\t}\n\n\treturn {\n\t\treplyType,\n\t\tcontent: normalizeText(reply.content)\n\t};\n};\n\nconst normalizeKeywordReplies = (keywordReplies: WxMenuKeywordReply[]) =>\n\tkeywordReplies.map(item => {\n\t\tconst normalizedReply = normalizeReply(item.reply);\n\t\treturn {\n\t\t\tmatchType: normalizeText(item.matchType),\n\t\t\tkeywords: (item.keywords || []).map(keyword => keyword.trim()).filter(Boolean),\n\t\t\treplyType: normalizeText(item.replyType) || normalizedReply?.replyType,\n\t\t\treply: normalizedReply,\n\t\t\tenabled: item.enabled !== false,\n\t\t\tpriority: typeof item.priority === \"number\" ? item.priority : undefined,\n\t\t\ttitle: normalizeText(item.title)\n\t\t};\n\t});\n\nconst legacyClickRepliesToKeywordReplies = (clickReplies?: WxMenuClickReply[]) =>\n\t(clickReplies || []).map(item => ({\n\t\tmatchType: \"event_key_exact\",\n\t\tkeywords: item?.key ? [item.key] : [],\n\t\treplyType: item?.reply?.replyType,\n\t\treply: item?.reply,\n\t\tenabled: true,\n\t\tpriority: 100,\n\t\ttitle: item?.title\n\t}));\n\nconst buildTreeData = (buttons?: WxMenuButton[], parentKey = \"button\"): DataNode[] =>\n\t(buttons || []).map((button, index) => {\n\t\tconst key = `${parentKey}-${index}`;\n\t\treturn {\n\t\t\tkey,\n\t\t\ttitle: (\n\t\t\t\t<div className=\"wx-menu-page__tree-node\">\n\t\t\t\t\t<span className=\"wx-menu-page__tree-name\">{button.name || `未命名菜单 ${index + 1}`}</span>\n\t\t\t\t\t{button.type ? <Tag color=\"green\">{button.type}</Tag> : <Tag color=\"gold\">目录</Tag>}\n\t\t\t\t\t<Text type=\"secondary\" className=\"wx-menu-page__tree-meta\">\n\t\t\t\t\t\t{getButtonMeta(button)}\n\t\t\t\t\t</Text>\n\t\t\t\t</div>\n\t\t\t),\n\t\t\tchildren: button.sub_button?.length ? buildTreeData(button.sub_button, key) : undefined\n\t\t};\n\t});\n\nconst parseKeywordsInput = (value: string) =>\n\tvalue\n\t\t.split(/[\\n,，]/)\n\t\t.map(item => item.trim())\n\t\t.filter(Boolean);\n\nconst formatKeywordsInput = (keywords?: string[]) => (keywords || []).join(\"\\n\");\n\nconst getMatchTypeLabel = (matchType?: string) => MATCH_TYPE_LABEL_MAP[matchType || \"\"] || \"未选择匹配方式\";\n\nconst getFallbackStrategyLabel = (strategy?: string) =>\n\tFALLBACK_STRATEGY_OPTIONS.find(item => item.value === strategy)?.label || \"固定回复\";\n\nconst getPreviewEventLabel = (eventType?: string) =>\n\tPREVIEW_EVENT_OPTIONS.find(item => item.value === eventType)?.label || \"用户发消息\";\n\nconst getPreviewTriggerLabel = (eventType?: string) => {\n\tif (eventType === \"CLICK\") return \"点击菜单\";\n\tif (eventType === \"subscribe\") return \"用户动作\";\n\treturn \"用户消息\";\n};\n\nconst getPreviewTriggerValue = (eventType?: string, content?: string, eventKey?: string) => {\n\tif (eventType === \"CLICK\") return `点击菜单：${eventKey || \"未填写 event key\"}`;\n\tif (eventType === \"subscribe\") return \"关注公众号\";\n\treturn content || \"未填写试跑内容\";\n};\n\nconst getAiProviderLabel = (provider?: string) =>\n\tAI_PROVIDER_CATALOG.find(item => item.value === provider)?.label || provider || \"未填写 Provider\";\n\nconst isKnownAiProvider = (provider?: string) => AI_PROVIDER_CATALOG.some(item => item.value === provider);\n\nconst isSyncPreviewSupportedProvider = (provider?: string) =>\n\tAI_PROVIDER_CATALOG.find(item => item.value === provider)?.syncPreviewSupported !== false;\n\nconst getAiPreviewErrorText = (result?: WxMenuPreviewAiRes) => {\n\tif (!result || result.success) return \"\";\n\tif (result.errorMsg) return result.errorMsg;\n\tif (result.provider) return `Provider ${result.provider} 调用失败，但后端没有返回具体错误详情，请查看服务端日志。`;\n\treturn \"后端没有返回具体失败原因。\";\n};\n\nconst findMatchedKeywordLocally = (rule: WxMenuKeywordReply, eventType?: string, content?: string, eventKey?: string) => {\n\tconst keywords = (rule.keywords || []).map(item => item.trim()).filter(Boolean);\n\tif (!keywords.length) return null;\n\n\tif (rule.matchType === \"event_key_exact\") {\n\t\tif (eventType !== \"CLICK\" || !eventKey) return null;\n\t\treturn keywords.find(keyword => keyword === eventKey) || null;\n\t}\n\n\tconst normalizedContent = content?.trim();\n\tif (!normalizedContent) return null;\n\n\tif (rule.matchType === \"content_exact\") {\n\t\treturn keywords.find(keyword => keyword === normalizedContent) || null;\n\t}\n\n\tif (rule.matchType === \"content_contains\") {\n\t\treturn keywords.find(keyword => normalizedContent.includes(keyword)) || null;\n\t}\n\n\treturn null;\n};\n\nconst renderRichTextPreview = (content?: string) => {\n\tif (!content) return null;\n\n\tconst anchorPattern = /<a\\s+href=\"([^\"]+)\"[^>]*>(.*?)<\\/a>/gi;\n\treturn content.split(\"\\n\").map((line, lineIndex) => {\n\t\tconst nodes: ReactNode[] = [];\n\t\tlet lastIndex = 0;\n\t\tlet match: RegExpExecArray | null;\n\n\t\tanchorPattern.lastIndex = 0;\n\t\twhile ((match = anchorPattern.exec(line))) {\n\t\t\tconst [fullText, href, label] = match;\n\t\t\tconst startIndex = match.index;\n\t\t\tif (startIndex > lastIndex) {\n\t\t\t\tnodes.push(line.slice(lastIndex, startIndex));\n\t\t\t}\n\t\t\tnodes.push(\n\t\t\t\t<a key={`${lineIndex}-${startIndex}`} href={href} target=\"_blank\" rel=\"noreferrer\">\n\t\t\t\t\t{label}\n\t\t\t\t</a>\n\t\t\t);\n\t\t\tlastIndex = startIndex + fullText.length;\n\t\t}\n\n\t\tif (lastIndex < line.length) {\n\t\t\tnodes.push(line.slice(lastIndex));\n\t\t}\n\n\t\treturn (\n\t\t\t<span key={`line-${lineIndex}`}>\n\t\t\t\t{nodes.length ? nodes : \"\\u00A0\"}\n\t\t\t\t{lineIndex < content.split(\"\\n\").length - 1 ? <br /> : null}\n\t\t\t</span>\n\t\t);\n\t});\n};\n\ninterface SectionTitleProps {\n\tindex: string;\n\ttitle: string;\n\tdescription: string;\n}\n\nconst SectionTitle: FC<SectionTitleProps> = ({ index, title, description }) => (\n\t<div className=\"wx-menu-page__section-head\">\n\t\t<div className=\"wx-menu-page__section-badge\">模块 {index}</div>\n\t\t<div className=\"wx-menu-page__section-copy\">\n\t\t\t<h2 className=\"wx-menu-page__section-title\">{title}</h2>\n\t\t\t<p className=\"wx-menu-page__section-desc\">{description}</p>\n\t\t</div>\n\t</div>\n);\n\ninterface WechatReplyPreviewProps {\n\ttitle: string;\n\treply?: WxMenuReply;\n\ttriggerLabel?: string;\n\ttriggerValue?: string;\n\temptyText: string;\n}\n\nconst WechatReplyPreview: FC<WechatReplyPreviewProps> = ({ title, reply, triggerLabel, triggerValue, emptyText }) => (\n\t<div className=\"wx-menu-page__preview-phone\">\n\t\t<div className=\"wx-menu-page__preview-phone-head\">{title}</div>\n\t\t<div className=\"wx-menu-page__preview-phone-body\">\n\t\t\t{triggerValue ? (\n\t\t\t\t<div className=\"wx-menu-page__preview-bubble wx-menu-page__preview-bubble--user\">\n\t\t\t\t\t<div className=\"wx-menu-page__preview-bubble-label\">{triggerLabel || \"用户触发\"}</div>\n\t\t\t\t\t<div className=\"wx-menu-page__preview-bubble-content\">{triggerValue}</div>\n\t\t\t\t</div>\n\t\t\t) : null}\n\n\t\t\t<div className=\"wx-menu-page__preview-bubble wx-menu-page__preview-bubble--bot\">\n\t\t\t\t<div className=\"wx-menu-page__preview-bubble-label\">公众号回复</div>\n\t\t\t\t{reply?.replyType === \"news\" && reply.articles?.length ? (\n\t\t\t\t\t<div className=\"wx-menu-page__preview-news-list\">\n\t\t\t\t\t\t{reply.articles.map((article, index) => (\n\t\t\t\t\t\t\t<div key={`preview-article-${index}`} className=\"wx-menu-page__preview-news-card\">\n\t\t\t\t\t\t\t\t<div className=\"wx-menu-page__preview-news-cover\">\n\t\t\t\t\t\t\t\t\t{article.picUrl ? <img src={article.picUrl} alt={article.title || \"cover\"} /> : <span>图文封面</span>}\n\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t<div className=\"wx-menu-page__preview-news-copy\">\n\t\t\t\t\t\t\t\t\t<strong>{article.title || `图文 ${index + 1}`}</strong>\n\t\t\t\t\t\t\t\t\t{article.description ? <span>{article.description}</span> : null}\n\t\t\t\t\t\t\t\t\t{article.url ? (\n\t\t\t\t\t\t\t\t\t\t<a href={article.url} target=\"_blank\" rel=\"noreferrer\">\n\t\t\t\t\t\t\t\t\t\t\t打开链接\n\t\t\t\t\t\t\t\t\t\t</a>\n\t\t\t\t\t\t\t\t\t) : null}\n\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t))}\n\t\t\t\t\t</div>\n\t\t\t\t) : reply?.content ? (\n\t\t\t\t\t<div className=\"wx-menu-page__preview-rich-text\">{renderRichTextPreview(reply.content)}</div>\n\t\t\t\t) : (\n\t\t\t\t\t<div className=\"wx-menu-page__preview-empty\">{emptyText}</div>\n\t\t\t\t)}\n\t\t\t</div>\n\t\t</div>\n\t</div>\n);\n\ninterface WechatMenuPreviewProps {\n\tbuttons?: WxMenuButton[];\n}\n\nconst WechatMenuPreview: FC<WechatMenuPreviewProps> = ({ buttons }) => {\n\tconst topButtons = buttons || [];\n\n\treturn (\n\t\t<div className=\"wx-menu-page__preview-phone\">\n\t\t\t<div className=\"wx-menu-page__preview-phone-head\">微信菜单预览</div>\n\t\t\t<div className=\"wx-menu-page__preview-phone-body wx-menu-page__preview-phone-body--menu\">\n\t\t\t\t<div className=\"wx-menu-page__preview-empty\">\n\t\t\t\t\t<Text type=\"secondary\">会话底部菜单将显示在这里，点击后可展开二级菜单。</Text>\n\t\t\t\t</div>\n\t\t\t</div>\n\n\t\t\t{topButtons.some(button => button.sub_button?.length) ? (\n\t\t\t\t<div className=\"wx-menu-page__preview-submenus\">\n\t\t\t\t\t{topButtons.map((button, index) =>\n\t\t\t\t\t\tbutton.sub_button?.length ? (\n\t\t\t\t\t\t\t<div key={`submenu-${index}`} className=\"wx-menu-page__preview-submenu-card\">\n\t\t\t\t\t\t\t\t<strong>{button.name || `菜单 ${index + 1}`}</strong>\n\t\t\t\t\t\t\t\t<div className=\"wx-menu-page__preview-submenu-list\">\n\t\t\t\t\t\t\t\t\t{button.sub_button.map((subButton, subIndex) => (\n\t\t\t\t\t\t\t\t\t\t<span key={`submenu-item-${index}-${subIndex}`}>{subButton.name || `子菜单 ${subIndex + 1}`}</span>\n\t\t\t\t\t\t\t\t\t))}\n\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t) : null\n\t\t\t\t\t)}\n\t\t\t\t</div>\n\t\t\t) : null}\n\n\t\t\t<div className=\"wx-menu-page__preview-menu-bar\">\n\t\t\t\t{topButtons.length ? (\n\t\t\t\t\ttopButtons.map((button, index) => (\n\t\t\t\t\t\t<div key={`menu-${index}`} className=\"wx-menu-page__preview-menu-item\">\n\t\t\t\t\t\t\t<span>{button.name || `菜单 ${index + 1}`}</span>\n\t\t\t\t\t\t\t{button.sub_button?.length ? <small>展开</small> : null}\n\t\t\t\t\t\t</div>\n\t\t\t\t\t))\n\t\t\t\t) : (\n\t\t\t\t\t<div className=\"wx-menu-page__preview-empty\">暂无菜单内容</div>\n\t\t\t\t)}\n\t\t\t</div>\n\t\t</div>\n\t);\n};\n\ninterface MatchPreviewMetaProps {\n\tresult?: WxMenuPreviewMatchRes;\n}\n\nconst MatchPreviewMeta: FC<MatchPreviewMetaProps> = ({ result }) => {\n\tif (!result) {\n\t\treturn <Text type=\"secondary\">还没有执行真实命中预览。</Text>;\n\t}\n\n\tconst metaItems = [\n\t\t{\n\t\t\tlabel: \"命中状态\",\n\t\t\tvalue: result.matched ? \"已命中\" : \"未命中\"\n\t\t},\n\t\t{\n\t\t\tlabel: \"命中规则\",\n\t\t\tvalue: result.matchedRuleTitle || \"未返回\"\n\t\t},\n\t\t{\n\t\t\tlabel: \"规则类型\",\n\t\t\tvalue: result.matchedRuleType || \"未返回\"\n\t\t},\n\t\t{\n\t\t\tlabel: \"命中关键字\",\n\t\t\tvalue: result.matchedKeyword || \"未返回\"\n\t\t},\n\t\t{\n\t\t\tlabel: \"兜底策略\",\n\t\t\tvalue: result.fallbackStrategy || \"未返回\"\n\t\t},\n\t\t{\n\t\t\tlabel: \"是否兜底\",\n\t\t\tvalue: result.usedFallback ? \"是\" : \"否\"\n\t\t}\n\t];\n\n\treturn (\n\t\t<div className=\"wx-menu-page__preview-meta\">\n\t\t\t{metaItems.map(item => (\n\t\t\t\t<div key={item.label} className=\"wx-menu-page__preview-meta-item\">\n\t\t\t\t\t<span>{item.label}</span>\n\t\t\t\t\t<strong>{item.value}</strong>\n\t\t\t\t</div>\n\t\t\t))}\n\t\t</div>\n\t);\n};\n\ninterface ReplyEditorProps {\n\tvalue?: WxMenuReply;\n\tonChange: (reply?: WxMenuReply) => void;\n\temptyText: string;\n}\n\nconst ReplyEditor: FC<ReplyEditorProps> = ({ value, onChange, emptyText }) => {\n\tconst replyType = value?.replyType || \"text\";\n\tconst articles = value?.articles || [];\n\n\tconst handleReplyTypeChange = (nextType: \"text\" | \"news\") => {\n\t\tconst nextReply = cloneData(value) || createReply(nextType);\n\t\tnextReply.replyType = nextType;\n\t\tif (nextType === \"news\") {\n\t\t\tnextReply.articles = nextReply.articles?.length ? nextReply.articles : [createEmptyArticle()];\n\t\t\tdelete nextReply.content;\n\t\t} else {\n\t\t\tnextReply.content = nextReply.content || \"\";\n\t\t\tdelete nextReply.articles;\n\t\t}\n\t\tonChange(nextReply);\n\t};\n\n\tconst updateArticle = (index: number, patch: Partial<WxMenuReplyArticle>) => {\n\t\tconst nextReply = cloneData(value) || createReply(\"news\");\n\t\tconst nextArticles = nextReply.articles?.length ? [...nextReply.articles] : [createEmptyArticle()];\n\t\tnextArticles[index] = { ...nextArticles[index], ...patch };\n\t\tnextReply.articles = nextArticles;\n\t\tonChange(nextReply);\n\t};\n\n\tconst addArticle = () => {\n\t\tconst nextReply = cloneData(value) || createReply(\"news\");\n\t\tnextReply.replyType = \"news\";\n\t\tnextReply.articles = [...(nextReply.articles || []), createEmptyArticle()];\n\t\tonChange(nextReply);\n\t};\n\n\tconst removeArticle = (index: number) => {\n\t\tconst nextReply = cloneData(value) || createReply(\"news\");\n\t\tnextReply.articles = (nextReply.articles || []).filter((_, itemIndex) => itemIndex !== index);\n\t\tonChange(nextReply);\n\t};\n\n\tif (!value) {\n\t\treturn (\n\t\t\t<div className=\"wx-menu-page__reply-empty\">\n\t\t\t\t<Paragraph type=\"secondary\">{emptyText}</Paragraph>\n\t\t\t\t<Space wrap>\n\t\t\t\t\t<Button onClick={() => onChange(createReply(\"text\"))}>添加文本回复</Button>\n\t\t\t\t\t<Button onClick={() => onChange(createReply(\"news\"))}>添加图文回复</Button>\n\t\t\t\t</Space>\n\t\t\t</div>\n\t\t);\n\t}\n\n\treturn (\n\t\t<Space direction=\"vertical\" size={12} style={{ display: \"flex\" }}>\n\t\t\t<Space wrap className=\"wx-menu-page__reply-actions\">\n\t\t\t\t<Select\n\t\t\t\t\tvalue={replyType}\n\t\t\t\t\toptions={REPLY_TYPE_OPTIONS}\n\t\t\t\t\tstyle={{ width: 160 }}\n\t\t\t\t\tonChange={nextType => handleReplyTypeChange(nextType as \"text\" | \"news\")}\n\t\t\t\t/>\n\t\t\t\t<Button onClick={() => onChange(undefined)}>清空回复</Button>\n\t\t\t</Space>\n\n\t\t\t{replyType === \"news\" ? (\n\t\t\t\t<div className=\"wx-menu-page__reply-articles\">\n\t\t\t\t\t{articles.map((article, index) => (\n\t\t\t\t\t\t<Card\n\t\t\t\t\t\t\tsize=\"small\"\n\t\t\t\t\t\t\tkey={`article-${index}`}\n\t\t\t\t\t\t\ttitle={`图文 ${index + 1}`}\n\t\t\t\t\t\t\tclassName=\"wx-menu-page__subcard\"\n\t\t\t\t\t\t\textra={\n\t\t\t\t\t\t\t\t<Button danger size=\"small\" onClick={() => removeArticle(index)} disabled={articles.length === 1}>\n\t\t\t\t\t\t\t\t\t删除\n\t\t\t\t\t\t\t\t</Button>\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t<Space direction=\"vertical\" size={10} style={{ display: \"flex\" }}>\n\t\t\t\t\t\t\t\t<Input placeholder=\"标题\" value={article.title} onChange={e => updateArticle(index, { title: e.target.value })} />\n\t\t\t\t\t\t\t\t<TextArea\n\t\t\t\t\t\t\t\t\tautoSize={{ minRows: 2, maxRows: 4 }}\n\t\t\t\t\t\t\t\t\tplaceholder=\"描述，可选\"\n\t\t\t\t\t\t\t\t\tvalue={article.description}\n\t\t\t\t\t\t\t\t\tonChange={e => updateArticle(index, { description: e.target.value })}\n\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t<Input\n\t\t\t\t\t\t\t\t\tplaceholder=\"封面图 URL，可选\"\n\t\t\t\t\t\t\t\t\tvalue={article.picUrl}\n\t\t\t\t\t\t\t\t\tonChange={e => updateArticle(index, { picUrl: e.target.value })}\n\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t<Input\n\t\t\t\t\t\t\t\t\tplaceholder=\"跳转链接 URL\"\n\t\t\t\t\t\t\t\t\tvalue={article.url}\n\t\t\t\t\t\t\t\t\tonChange={e => updateArticle(index, { url: e.target.value })}\n\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t</Space>\n\t\t\t\t\t\t</Card>\n\t\t\t\t\t))}\n\t\t\t\t\t<Button onClick={addArticle}>新增图文</Button>\n\t\t\t\t</div>\n\t\t\t) : (\n\t\t\t\t<TextArea\n\t\t\t\t\tautoSize={{ minRows: 5, maxRows: 8 }}\n\t\t\t\t\tplaceholder=\"请输入回复内容\"\n\t\t\t\t\tvalue={value.content}\n\t\t\t\t\tonChange={e => onChange({ ...value, replyType: \"text\", content: e.target.value })}\n\t\t\t\t/>\n\t\t\t)}\n\t\t</Space>\n\t);\n};\n\ninterface KeywordRuleCardProps {\n\tindex: number;\n\tvalue: WxMenuKeywordReply;\n\tonChange: (patch: Partial<WxMenuKeywordReply>) => void;\n\tonReplyChange: (reply?: WxMenuReply) => void;\n\tonRemove: () => void;\n}\n\nconst KeywordRuleCard: FC<KeywordRuleCardProps> = ({ index, value, onChange, onReplyChange, onRemove }) => {\n\tconst matchType = value.matchType || \"content_contains\";\n\tconst previewTriggerValue =\n\t\tvalue.matchType === \"event_key_exact\"\n\t\t\t? `点击菜单：${value.keywords?.[0] || \"未设置 event key\"}`\n\t\t\t: value.keywords?.[0] || \"未设置关键词\";\n\n\treturn (\n\t\t<Card\n\t\t\tsize=\"small\"\n\t\t\tclassName=\"wx-menu-page__rule-card\"\n\t\t\ttitle={value.title || `规则 ${index + 1}`}\n\t\t\textra={\n\t\t\t\t<Space size={12} wrap>\n\t\t\t\t\t<Text type=\"secondary\">启用</Text>\n\t\t\t\t\t<Switch checked={value.enabled !== false} onChange={checked => onChange({ enabled: checked })} />\n\t\t\t\t\t<Button danger size=\"small\" onClick={onRemove}>\n\t\t\t\t\t\t删除\n\t\t\t\t\t</Button>\n\t\t\t\t</Space>\n\t\t\t}\n\t\t>\n\t\t\t<div className=\"wx-menu-page__rule-grid\">\n\t\t\t\t<div className=\"wx-menu-page__field\">\n\t\t\t\t\t<Text strong>规则名称</Text>\n\t\t\t\t\t<Input placeholder=\"例如 派聪明邀请码\" value={value.title} onChange={e => onChange({ title: e.target.value })} />\n\t\t\t\t</div>\n\t\t\t\t<div className=\"wx-menu-page__field\">\n\t\t\t\t\t<Text strong>优先级</Text>\n\t\t\t\t\t<InputNumber\n\t\t\t\t\t\tmin={1}\n\t\t\t\t\t\tstyle={{ width: \"100%\" }}\n\t\t\t\t\t\tplaceholder=\"数字越小越先命中\"\n\t\t\t\t\t\tvalue={value.priority}\n\t\t\t\t\t\tonChange={nextValue => onChange({ priority: typeof nextValue === \"number\" ? nextValue : undefined })}\n\t\t\t\t\t/>\n\t\t\t\t</div>\n\t\t\t\t<div className=\"wx-menu-page__field\">\n\t\t\t\t\t<Text strong>匹配方式</Text>\n\t\t\t\t\t<Select value={matchType} options={MATCH_TYPE_OPTIONS} onChange={nextValue => onChange({ matchType: nextValue })} />\n\t\t\t\t</div>\n\t\t\t\t<div className=\"wx-menu-page__field\">\n\t\t\t\t\t<Text strong>当前规则</Text>\n\t\t\t\t\t<div className=\"wx-menu-page__rule-label\">{getMatchTypeLabel(matchType)}</div>\n\t\t\t\t</div>\n\t\t\t\t<div className=\"wx-menu-page__field wx-menu-page__field--full\">\n\t\t\t\t\t<Text strong>关键词 / event key</Text>\n\t\t\t\t\t<TextArea\n\t\t\t\t\t\tautoSize={{ minRows: 3, maxRows: 5 }}\n\t\t\t\t\t\tplaceholder={\n\t\t\t\t\t\t\tmatchType === \"event_key_exact\"\n\t\t\t\t\t\t\t\t? \"一行一个 event key，例如 CONTACT_US\"\n\t\t\t\t\t\t\t\t: matchType === \"content_exact\"\n\t\t\t\t\t\t\t\t? \"一行一个完整关键词，例如 聪明\"\n\t\t\t\t\t\t\t\t: \"一行一个包含词，例如 派聪明\"\n\t\t\t\t\t\t}\n\t\t\t\t\t\tvalue={formatKeywordsInput(value.keywords)}\n\t\t\t\t\t\tonChange={e => onChange({ keywords: parseKeywordsInput(e.target.value) })}\n\t\t\t\t\t/>\n\t\t\t\t\t<Text type=\"secondary\">{MATCH_TYPE_HINT_MAP[matchType]}</Text>\n\t\t\t\t</div>\n\t\t\t\t<div className=\"wx-menu-page__field wx-menu-page__field--full\">\n\t\t\t\t\t<Text strong>命中后回复</Text>\n\t\t\t\t\t<ReplyEditor\n\t\t\t\t\t\tvalue={value.reply}\n\t\t\t\t\t\tonChange={reply => onReplyChange(reply ? { ...reply, replyType: reply.replyType || value.replyType } : reply)}\n\t\t\t\t\t\temptyText=\"命中这条规则后需要返回的回复内容。\"\n\t\t\t\t\t/>\n\t\t\t\t</div>\n\t\t\t\t<div className=\"wx-menu-page__field wx-menu-page__field--full\">\n\t\t\t\t\t<Text strong>规则预览</Text>\n\t\t\t\t\t<WechatReplyPreview\n\t\t\t\t\t\ttitle={value.title || `规则 ${index + 1}`}\n\t\t\t\t\t\treply={value.reply}\n\t\t\t\t\t\ttriggerLabel={getMatchTypeLabel(matchType)}\n\t\t\t\t\t\ttriggerValue={previewTriggerValue}\n\t\t\t\t\t\temptyText=\"这条规则还没有配置回复内容。\"\n\t\t\t\t\t/>\n\t\t\t\t</div>\n\t\t\t</div>\n\t\t</Card>\n\t);\n};\n\nconst WxMenuPage: FC = () => {\n\tconst [loading, setLoading] = useState(false);\n\tconst [actionLoading, setActionLoading] = useState<string>(\"\");\n\tconst [detail, setDetail] = useState<WxMenuDetail>();\n\tconst [validateResult, setValidateResult] = useState<WxMenuValidateRes>();\n\tconst [editorValue, setEditorValue] = useState(\"\");\n\tconst [draftComment, setDraftComment] = useState(DEFAULT_DRAFT_COMMENT);\n\tconst [subscribeReply, setSubscribeReply] = useState<WxMenuReply>();\n\tconst [defaultReply, setDefaultReply] = useState<WxMenuReply>();\n\tconst [keywordReplies, setKeywordReplies] = useState<WxMenuKeywordReply[]>([]);\n\tconst [messageFallbackStrategy, setMessageFallbackStrategy] = useState(DEFAULT_FALLBACK_STRATEGY);\n\tconst [aiPrompt, setAiPrompt] = useState(\"\");\n\tconst [aiProvider, setAiProvider] = useState(\"\");\n\tconst [aiEnable, setAiEnable] = useState(false);\n\tconst [availableAiProviderOptions, setAvailableAiProviderOptions] = useState<AiProviderOptionViewModel[]>([]);\n\tconst [previewEventType, setPreviewEventType] = useState(\"TEXT\");\n\tconst [previewContent, setPreviewContent] = useState(\"\");\n\tconst [previewEventKey, setPreviewEventKey] = useState(\"\");\n\tconst [matchPreviewResult, setMatchPreviewResult] = useState<WxMenuPreviewMatchRes>();\n\tconst [aiPreviewContent, setAiPreviewContent] = useState(\"\");\n\tconst [aiPreviewResult, setAiPreviewResult] = useState<WxMenuPreviewAiRes>();\n\tconst hasInitializedRef = useRef(false);\n\tconst hasRestoredCacheRef = useRef(false);\n\n\tconst loadDetail = async (nextEditorValue?: string) => {\n\t\tsetLoading(true);\n\t\ttry {\n\t\t\tconst [{ result }, aiConfigResponse] = await Promise.all([getWxMenuDetailApi(), getAiConfigDetailApi()]);\n\t\t\tif (!result) return;\n\t\t\tconst aiConfigResult = aiConfigResponse?.result;\n\n\t\t\tconst serverEditorValue = formatMenuJson(\n\t\t\t\tnextEditorValue || result.draftJson || result.remoteJson || result.menuJsonTemplate || DEFAULT_MENU_TEMPLATE\n\t\t\t);\n\t\t\tconst serverDraftComment = result.draftComment || DEFAULT_DRAFT_COMMENT;\n\t\t\tconst serverSubscribeReply = cloneData(result.subscribeReply || result.draftConfig?.subscribeReply);\n\t\t\tconst serverDefaultReply = cloneData(result.defaultReply || result.draftConfig?.defaultReply);\n\t\t\tconst serverKeywordReplies = cloneData(\n\t\t\t\tresult.keywordReplies ||\n\t\t\t\t\tresult.draftConfig?.keywordReplies ||\n\t\t\t\t\tlegacyClickRepliesToKeywordReplies(result.clickReplies || result.draftConfig?.clickReplies)\n\t\t\t);\n\t\t\tconst serverFallbackStrategy =\n\t\t\t\tresult.messageFallbackStrategy || result.draftConfig?.messageFallbackStrategy || DEFAULT_FALLBACK_STRATEGY;\n\t\t\tconst serverAiPrompt = result.aiPrompt || result.draftConfig?.aiPrompt || \"\";\n\t\t\tconst serverAiProvider = result.aiProvider || result.draftConfig?.aiProvider || \"\";\n\t\t\tconst serverAiEnable = Boolean(result.aiEnable ?? result.draftConfig?.aiEnable);\n\t\t\tconst localCache = readLocalDraftCache();\n\t\t\tconst detailProviderOptions = (result.aiProviderOptions || [])\n\t\t\t\t.filter((item: WxMenuAiProviderOption) => item?.value && item.syncSupport !== false)\n\t\t\t\t.map((item: WxMenuAiProviderOption) => ({\n\t\t\t\t\tlabel: item?.name || item?.value || \"\",\n\t\t\t\t\tvalue: item?.value || \"\"\n\t\t\t\t}))\n\t\t\t\t.filter((item: AiProviderOptionViewModel) => item.value);\n\t\t\tconst syncEnabledSources = ((aiConfigResult?.sources || []) as AISourceValue[]).filter(source =>\n\t\t\t\tisSyncPreviewSupportedProvider(source)\n\t\t\t);\n\t\t\tconst fallbackProviderOptions = AI_PROVIDER_CATALOG.filter(item => syncEnabledSources.includes(item.value)).map(item => ({\n\t\t\t\tlabel: item.label,\n\t\t\t\tvalue: item.value\n\t\t\t}));\n\t\t\tconst syncedProviderOptions = detailProviderOptions.length ? detailProviderOptions : fallbackProviderOptions;\n\t\t\tconst syncedProviderValues = syncedProviderOptions.map(item => item.value);\n\t\t\tconst autoSyncedAiProvider =\n\t\t\t\tlocalCache?.aiProvider || serverAiProvider || (syncedProviderValues.length === 1 ? syncedProviderValues[0] : \"\");\n\n\t\t\tsetDetail(result);\n\t\t\tsetAvailableAiProviderOptions(syncedProviderOptions);\n\t\t\tsetDraftComment(localCache?.draftComment ?? serverDraftComment);\n\t\t\tsetSubscribeReply(localCache?.subscribeReply ?? serverSubscribeReply);\n\t\t\tsetDefaultReply(localCache?.defaultReply ?? serverDefaultReply);\n\t\t\tsetKeywordReplies(localCache?.keywordReplies ?? serverKeywordReplies ?? []);\n\t\t\tsetMessageFallbackStrategy(localCache?.messageFallbackStrategy ?? serverFallbackStrategy);\n\t\t\tsetAiPrompt(localCache?.aiPrompt ?? serverAiPrompt);\n\t\t\tsetAiProvider(autoSyncedAiProvider);\n\t\t\tsetAiEnable(localCache?.aiEnable ?? serverAiEnable);\n\t\t\tsetValidateResult({\n\t\t\t\tvalid: result.draftValid,\n\t\t\t\tnormalizedMenuJson: result.draftJson,\n\t\t\t\tmenuErrors: [],\n\t\t\t\treplyErrors: [],\n\t\t\t\terrors: result.draftErrors || [],\n\t\t\t\twarnings: result.draftWarnings || []\n\t\t\t});\n\t\t\tsetEditorValue(localCache?.editorValue ?? serverEditorValue);\n\n\t\t\tif (localCache && !hasRestoredCacheRef.current) {\n\t\t\t\thasRestoredCacheRef.current = true;\n\t\t\t\tmessage.info(\"已恢复本地未保存的微信菜单编辑内容\");\n\t\t\t}\n\n\t\t\thasInitializedRef.current = true;\n\t\t} finally {\n\t\t\tsetLoading(false);\n\t\t}\n\t};\n\n\tuseEffect(() => {\n\t\tloadDetail();\n\t}, []);\n\n\tuseEffect(() => {\n\t\tif (!hasInitializedRef.current) return;\n\n\t\twriteLocalDraftCache({\n\t\t\teditorValue,\n\t\t\tdraftComment,\n\t\t\tsubscribeReply,\n\t\t\tdefaultReply,\n\t\t\tkeywordReplies,\n\t\t\tmessageFallbackStrategy,\n\t\t\taiPrompt,\n\t\t\taiProvider,\n\t\t\taiEnable\n\t\t});\n\t}, [\n\t\teditorValue,\n\t\tdraftComment,\n\t\tsubscribeReply,\n\t\tdefaultReply,\n\t\tkeywordReplies,\n\t\tmessageFallbackStrategy,\n\t\taiPrompt,\n\t\taiProvider,\n\t\taiEnable\n\t]);\n\n\tconst runAction = async (actionKey: string, action: () => Promise<void>) => {\n\t\tsetActionLoading(actionKey);\n\t\ttry {\n\t\t\tawait action();\n\t\t} finally {\n\t\t\tsetActionLoading(\"\");\n\t\t}\n\t};\n\n\tconst buildPayload = () => ({\n\t\tmenuJson: editorValue,\n\t\tcomment: draftComment,\n\t\tsubscribeReply: normalizeReply(subscribeReply),\n\t\tdefaultReply: normalizeReply(defaultReply),\n\t\tkeywordReplies: normalizeKeywordReplies(keywordReplies),\n\t\tmessageFallbackStrategy,\n\t\taiPrompt: normalizeText(aiPrompt),\n\t\taiProvider: normalizeText(aiProvider),\n\t\taiEnable\n\t});\n\n\tconst handleValidate = async () => {\n\t\tawait runAction(\"validate\", async () => {\n\t\t\tconst payload = buildPayload();\n\t\t\tconst { result } = await validateWxMenuApi(payload);\n\t\t\tif (!result) return;\n\n\t\t\tsetValidateResult(result);\n\t\t\tif (result.valid && result.normalizedMenuJson) {\n\t\t\t\tsetEditorValue(formatMenuJson(result.normalizedMenuJson));\n\t\t\t\tmessage.success(\"整套微信菜单配置校验通过\");\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\tmessage.warning(`校验未通过，共 ${result.errors?.length || 0} 项问题`);\n\t\t});\n\t};\n\n\tconst handleSaveDraft = async () => {\n\t\tawait runAction(\"save\", async () => {\n\t\t\tconst { status } = await saveWxMenuDraftApi(buildPayload());\n\t\t\tif (status?.code === 0) {\n\t\t\t\tclearLocalDraftCache();\n\t\t\t\thasRestoredCacheRef.current = false;\n\t\t\t\tmessage.success(\"草稿已保存\");\n\t\t\t\tawait loadDetail();\n\t\t\t}\n\t\t});\n\t};\n\n\tconst handlePublish = () => {\n\t\tModal.confirm({\n\t\t\ttitle: \"确认发布当前菜单吗？\",\n\t\t\tcontent: \"这次发布只会同步菜单结构到微信公众号；关注后回复、关键词回复和普通消息兜底在保存草稿后就会立即生效。\",\n\t\t\tokText: \"确认发布\",\n\t\t\tcancelText: \"取消\",\n\t\t\tonOk: async () => {\n\t\t\t\tawait runAction(\"publish\", async () => {\n\t\t\t\t\tconst { result } = await publishWxMenuApi({ menuJson: editorValue });\n\t\t\t\t\tconst publishResult = (result || {}) as WxMenuPublishRes;\n\t\t\t\t\tif (publishResult.success) {\n\t\t\t\t\t\tmessage.success(\"微信菜单发布成功\");\n\t\t\t\t\t\tawait loadDetail(publishResult.publishedMenuJson || editorValue);\n\t\t\t\t\t}\n\t\t\t\t});\n\t\t\t}\n\t\t});\n\t};\n\n\tconst handleSyncRemote = () => {\n\t\tModal.confirm({\n\t\t\ttitle: \"同步线上菜单到草稿？\",\n\t\t\tcontent: \"这会把当前公众号线上菜单写回草稿区，其它回复配置会继续沿用你当前编辑的内容。\",\n\t\t\tokText: \"同步\",\n\t\t\tcancelText: \"取消\",\n\t\t\tonOk: async () => {\n\t\t\t\tawait runAction(\"sync\", async () => {\n\t\t\t\t\tconst { result } = await syncWxMenuApi();\n\t\t\t\t\tclearLocalDraftCache();\n\t\t\t\t\thasRestoredCacheRef.current = false;\n\t\t\t\t\tmessage.success(\"线上菜单已同步到草稿\");\n\t\t\t\t\tawait loadDetail(result?.draftJson || result?.draftConfig?.menuJson);\n\t\t\t\t});\n\t\t\t}\n\t\t});\n\t};\n\n\tconst handleFormat = () => {\n\t\tconst parsedMenu = parseMenuJson(editorValue);\n\t\tif (!parsedMenu) {\n\t\t\tmessage.warning(\"当前 JSON 不是合法格式，暂时无法格式化\");\n\t\t\treturn;\n\t\t}\n\n\t\tsetEditorValue(JSON.stringify(parsedMenu, null, 2));\n\t};\n\n\tconst handlePreviewMatch = async () => {\n\t\tconst normalizedContent = normalizeText(previewContent);\n\t\tconst normalizedEventKey = normalizeText(previewEventKey);\n\n\t\tif (previewEventType === \"TEXT\" && !normalizedContent) {\n\t\t\tmessage.warning(\"请输入一条用户消息，再执行真实命中预览\");\n\t\t\treturn;\n\t\t}\n\n\t\tif (previewEventType === \"CLICK\" && !normalizedEventKey) {\n\t\t\tmessage.warning(\"请输入 event key，再执行 click 命中预览\");\n\t\t\treturn;\n\t\t}\n\n\t\tawait runAction(\"previewMatch\", async () => {\n\t\t\tconst { result } = await previewWxMenuMatchApi({\n\t\t\t\t...buildPayload(),\n\t\t\t\teventType: previewEventType,\n\t\t\t\teventKey: previewEventType === \"CLICK\" ? normalizedEventKey : undefined,\n\t\t\t\tcontent: previewEventType === \"TEXT\" ? normalizedContent : undefined\n\t\t\t});\n\n\t\t\tsetMatchPreviewResult(result);\n\t\t\tmessage.success(\"真实命中预览已更新\");\n\t\t});\n\t};\n\n\tconst handlePreviewAi = async () => {\n\t\tconst normalizedContent = normalizeText(aiPreviewContent);\n\t\tif (!normalizedContent) {\n\t\t\tmessage.warning(\"请输入一条消息，再试跑 AI 回复\");\n\t\t\treturn;\n\t\t}\n\n\t\tawait runAction(\"previewAi\", async () => {\n\t\t\tconst { result } = await previewWxMenuAiApi({\n\t\t\t\tcontent: normalizedContent,\n\t\t\t\taiPrompt: normalizeText(aiPrompt),\n\t\t\t\taiProvider: normalizeText(aiProvider),\n\t\t\t\taiEnable\n\t\t\t});\n\n\t\t\tsetAiPreviewResult(result);\n\t\t\tif (result?.success) {\n\t\t\t\tmessage.success(\"AI 回复预览已更新\");\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\tmessage.warning(getAiPreviewErrorText(result) || \"AI 回复预览未成功，请检查配置\");\n\t\t});\n\t};\n\n\tconst updateKeywordReply = (index: number, patch: Partial<WxMenuKeywordReply>) => {\n\t\tsetKeywordReplies(prev => prev.map((item, itemIndex) => (itemIndex === index ? { ...item, ...patch } : item)));\n\t};\n\n\tconst updateKeywordReplyReply = (index: number, reply?: WxMenuReply) => {\n\t\tsetKeywordReplies(prev =>\n\t\t\tprev.map((item, itemIndex) =>\n\t\t\t\titemIndex === index ? { ...item, reply, replyType: reply?.replyType || item.replyType || \"text\" } : item\n\t\t\t)\n\t\t);\n\t};\n\n\tconst removeKeywordReply = (index: number) => {\n\t\tsetKeywordReplies(prev => prev.filter((_, itemIndex) => itemIndex !== index));\n\t};\n\n\tconst editorPreview = parseMenuJson(editorValue);\n\tconst editorTreeData = buildTreeData(editorPreview?.button);\n\tconst remoteTreeData = buildTreeData(detail?.remoteMenu?.button);\n\tconst templateValue = detail?.menuJsonTemplate || DEFAULT_MENU_TEMPLATE;\n\tconst draftStatusText =\n\t\tdetail?.draftValid === true ? \"草稿已通过\" : detail?.draftValid === false ? \"草稿待修正\" : \"尚未保存草稿\";\n\tconst remoteStatusText = detail?.remoteError ? \"远端拉取异常\" : remoteTreeData.length ? \"远端菜单已连接\" : \"远端菜单为空\";\n\tconst warningCount = validateResult?.warnings?.length || 0;\n\tconst topMenuCount = editorPreview?.button?.length || 0;\n\tconst enabledRuleCount = keywordReplies.filter(item => item.enabled !== false).length;\n\tconst eventRuleCount = keywordReplies.filter(item => item.matchType === \"event_key_exact\").length;\n\tconst contentRuleCount = keywordReplies.filter(item => item.matchType !== \"event_key_exact\").length;\n\tconst fallbackStrategyLabel = getFallbackStrategyLabel(messageFallbackStrategy);\n\tconst previewTriggerLabel = getPreviewTriggerLabel(previewEventType);\n\tconst previewTriggerValue = getPreviewTriggerValue(previewEventType, previewContent, previewEventKey);\n\tconst isAiProviderEnabled = aiProvider ? availableAiProviderOptions.some(item => item.value === aiProvider) : false;\n\tconst aiPreviewReply =\n\t\taiPreviewResult?.replyText && aiPreviewResult.success\n\t\t\t? {\n\t\t\t\t\treplyType: \"text\",\n\t\t\t\t\tcontent: aiPreviewResult.replyText\n\t\t\t  }\n\t\t\t: undefined;\n\tconst matchDisabledRules = keywordReplies\n\t\t.map(rule => ({\n\t\t\trule,\n\t\t\tmatchedKeyword: findMatchedKeywordLocally(rule, previewEventType, previewContent, previewEventKey)\n\t\t}))\n\t\t.filter(item => item.rule.enabled === false && item.matchedKeyword);\n\tconst aiProviderOptions = availableAiProviderOptions.length\n\t\t? aiProvider && !availableAiProviderOptions.some(item => item.value === aiProvider)\n\t\t\t? [\n\t\t\t\t\t...availableAiProviderOptions,\n\t\t\t\t\t{\n\t\t\t\t\t\tlabel: `${getAiProviderLabel(aiProvider)}（当前草稿值，未在 AI 模型配置中启用）`,\n\t\t\t\t\t\tvalue: aiProvider\n\t\t\t\t\t}\n\t\t\t  ]\n\t\t\t: availableAiProviderOptions\n\t\t: aiProvider\n\t\t? [\n\t\t\t\t{\n\t\t\t\t\tlabel: `${getAiProviderLabel(aiProvider)}（当前草稿值，未在 AI 模型配置中启用）`,\n\t\t\t\t\tvalue: aiProvider\n\t\t\t\t}\n\t\t  ]\n\t\t: [];\n\n\treturn (\n\t\t<div className=\"wx-menu-page\">\n\t\t\t<ContentWrap className=\"wx-menu-page__wrap\">\n\t\t\t\t<Spin spinning={loading}>\n\t\t\t\t\t<div className=\"wx-menu-page__layout\">\n\t\t\t\t\t\t<section className=\"wx-menu-page__hero\">\n\t\t\t\t\t\t\t<div className=\"wx-menu-page__hero-copy\">\n\t\t\t\t\t\t\t\t<p className=\"wx-menu-page__eyebrow\">微信配置中心</p>\n\t\t\t\t\t\t\t\t<h1 className=\"wx-menu-page__title\">微信公众号配置</h1>\n\t\t\t\t\t\t\t\t<p className=\"wx-menu-page__subtitle\">\n\t\t\t\t\t\t\t\t\t菜单、关注后回复、关键词规则和普通消息兜底分开维护。页面只保留后台需要的结构和信息，不再额外做一套独立视觉。\n\t\t\t\t\t\t\t\t</p>\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t<div className=\"wx-menu-page__hero-metrics\">\n\t\t\t\t\t\t\t\t<div className=\"wx-menu-page__metric-card\">\n\t\t\t\t\t\t\t\t\t<span className=\"wx-menu-page__metric-label\">菜单配置</span>\n\t\t\t\t\t\t\t\t\t<strong className=\"wx-menu-page__metric-value\">{topMenuCount} 个一级菜单</strong>\n\t\t\t\t\t\t\t\t\t<Text type=\"secondary\">{draftStatusText}</Text>\n\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t<div className=\"wx-menu-page__metric-card\">\n\t\t\t\t\t\t\t\t\t<span className=\"wx-menu-page__metric-label\">关键词规则</span>\n\t\t\t\t\t\t\t\t\t<strong className=\"wx-menu-page__metric-value\">{enabledRuleCount} 条已启用规则</strong>\n\t\t\t\t\t\t\t\t\t<Text type=\"secondary\">\n\t\t\t\t\t\t\t\t\t\t{eventRuleCount} 条事件规则 / {contentRuleCount} 条消息规则\n\t\t\t\t\t\t\t\t\t</Text>\n\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t<div className=\"wx-menu-page__metric-card\">\n\t\t\t\t\t\t\t\t\t<span className=\"wx-menu-page__metric-label\">普通消息兜底</span>\n\t\t\t\t\t\t\t\t\t<strong className=\"wx-menu-page__metric-value\">{fallbackStrategyLabel}</strong>\n\t\t\t\t\t\t\t\t\t<Text type=\"secondary\">{aiEnable ? getAiProviderLabel(aiProvider) : \"AI 未开启\"}</Text>\n\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t<div className=\"wx-menu-page__metric-card\">\n\t\t\t\t\t\t\t\t\t<span className=\"wx-menu-page__metric-label\">线上状态</span>\n\t\t\t\t\t\t\t\t\t<strong className=\"wx-menu-page__metric-value\">{remoteStatusText}</strong>\n\t\t\t\t\t\t\t\t\t<Text type=\"secondary\">{warningCount} 条校验提示</Text>\n\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t</section>\n\n\t\t\t\t\t\t<Card bordered={false} className=\"wx-menu-page__action-bar\">\n\t\t\t\t\t\t\t<div className=\"wx-menu-page__action-copy\">\n\t\t\t\t\t\t\t\t<Text strong>保存节奏</Text>\n\t\t\t\t\t\t\t\t<Text type=\"secondary\">保存草稿会立即生效回复配置；发布菜单只同步微信公众号菜单结构。</Text>\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t<Space wrap>\n\t\t\t\t\t\t\t\t<Button onClick={() => loadDetail()} loading={loading}>\n\t\t\t\t\t\t\t\t\t刷新详情\n\t\t\t\t\t\t\t\t</Button>\n\t\t\t\t\t\t\t\t<Button onClick={() => setEditorValue(formatMenuJson(templateValue))}>填充模板</Button>\n\t\t\t\t\t\t\t\t<Button onClick={handleFormat}>格式化 JSON</Button>\n\t\t\t\t\t\t\t\t<Button type=\"primary\" onClick={handleValidate} loading={actionLoading === \"validate\"}>\n\t\t\t\t\t\t\t\t\t校验整套配置\n\t\t\t\t\t\t\t\t</Button>\n\t\t\t\t\t\t\t\t<Button onClick={handleSaveDraft} loading={actionLoading === \"save\"}>\n\t\t\t\t\t\t\t\t\t保存草稿\n\t\t\t\t\t\t\t\t</Button>\n\t\t\t\t\t\t\t\t<Button onClick={handleSyncRemote} loading={actionLoading === \"sync\"}>\n\t\t\t\t\t\t\t\t\t同步线上到草稿\n\t\t\t\t\t\t\t\t</Button>\n\t\t\t\t\t\t\t\t<Button danger type=\"primary\" onClick={handlePublish} loading={actionLoading === \"publish\"}>\n\t\t\t\t\t\t\t\t\t发布菜单到微信\n\t\t\t\t\t\t\t\t</Button>\n\t\t\t\t\t\t\t</Space>\n\t\t\t\t\t\t</Card>\n\n\t\t\t\t\t\t<section className=\"wx-menu-page__module\">\n\t\t\t\t\t\t\t<SectionTitle\n\t\t\t\t\t\t\t\tindex=\"01\"\n\t\t\t\t\t\t\t\ttitle=\"菜单配置\"\n\t\t\t\t\t\t\t\tdescription=\"这一块只处理微信官方菜单 JSON。保存草稿后菜单结构会持久化，点击发布后才会真正同步到公众号。\"\n\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t<div className=\"wx-menu-page__menu-layout\">\n\t\t\t\t\t\t\t\t<Card bordered={false} className=\"wx-menu-page__panel wx-menu-page__panel--editor\" title=\"菜单工作台\">\n\t\t\t\t\t\t\t\t\t<div className=\"wx-menu-page__editor-shell\">\n\t\t\t\t\t\t\t\t\t\t<div className=\"wx-menu-page__field\">\n\t\t\t\t\t\t\t\t\t\t\t<Text strong>草稿备注</Text>\n\t\t\t\t\t\t\t\t\t\t\t<Input\n\t\t\t\t\t\t\t\t\t\t\t\tclassName=\"wx-menu-page__comment\"\n\t\t\t\t\t\t\t\t\t\t\t\tplaceholder=\"用于记录这份草稿的说明\"\n\t\t\t\t\t\t\t\t\t\t\t\tvalue={draftComment}\n\t\t\t\t\t\t\t\t\t\t\t\tonChange={e => setDraftComment(e.target.value)}\n\t\t\t\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t\t<div className=\"wx-menu-page__editor-field\">\n\t\t\t\t\t\t\t\t\t\t\t<div className=\"wx-menu-page__field-head\">\n\t\t\t\t\t\t\t\t\t\t\t\t<Text strong>菜单 JSON</Text>\n\t\t\t\t\t\t\t\t\t\t\t\t<Text type=\"secondary\">一级菜单、二级菜单和跳转规则都在这里维护。</Text>\n\t\t\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t\t\t<TextArea\n\t\t\t\t\t\t\t\t\t\t\t\tclassName=\"wx-menu-page__editor\"\n\t\t\t\t\t\t\t\t\t\t\t\tvalue={editorValue}\n\t\t\t\t\t\t\t\t\t\t\t\tonChange={e => setEditorValue(e.target.value)}\n\t\t\t\t\t\t\t\t\t\t\t\tplaceholder=\"请输入微信菜单 JSON\"\n\t\t\t\t\t\t\t\t\t\t\t\tstyle={{ height: \"100%\", resize: \"none\" }}\n\t\t\t\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t</Card>\n\n\t\t\t\t\t\t\t\t<div className=\"wx-menu-page__sidebar\">\n\t\t\t\t\t\t\t\t\t<Card bordered={false} className=\"wx-menu-page__panel\" title=\"当前编辑预览\">\n\t\t\t\t\t\t\t\t\t\t{editorTreeData.length ? (\n\t\t\t\t\t\t\t\t\t\t\t<Tree defaultExpandAll treeData={editorTreeData} />\n\t\t\t\t\t\t\t\t\t\t) : (\n\t\t\t\t\t\t\t\t\t\t\t<Alert type=\"warning\" showIcon message=\"当前编辑区 JSON 还不能解析为菜单树\" />\n\t\t\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t\t</Card>\n\n\t\t\t\t\t\t\t\t\t<Card bordered={false} className=\"wx-menu-page__panel\" title=\"微信菜单效果预览\">\n\t\t\t\t\t\t\t\t\t\t<WechatMenuPreview buttons={editorPreview?.button} />\n\t\t\t\t\t\t\t\t\t</Card>\n\n\t\t\t\t\t\t\t\t\t<Card bordered={false} className=\"wx-menu-page__panel\" title=\"支持的 type\">\n\t\t\t\t\t\t\t\t\t\t<div className=\"wx-menu-page__tag-list\">\n\t\t\t\t\t\t\t\t\t\t\t{SUPPORTED_TYPES.map(type => (\n\t\t\t\t\t\t\t\t\t\t\t\t<Tag key={type}>{type}</Tag>\n\t\t\t\t\t\t\t\t\t\t\t))}\n\t\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t</Card>\n\n\t\t\t\t\t\t\t\t\t<Card bordered={false} className=\"wx-menu-page__panel\" title=\"最新校验结果\">\n\t\t\t\t\t\t\t\t\t\t<Space direction=\"vertical\" size={12} style={{ display: \"flex\" }}>\n\t\t\t\t\t\t\t\t\t\t\t{validateResult?.valid === true && <Alert type=\"success\" showIcon message=\"当前整套配置校验通过\" />}\n\t\t\t\t\t\t\t\t\t\t\t{validateResult?.valid === undefined && <Text type=\"secondary\">还没有执行过校验。</Text>}\n\t\t\t\t\t\t\t\t\t\t\t{validateResult?.menuErrors?.length ? (\n\t\t\t\t\t\t\t\t\t\t\t\t<Alert\n\t\t\t\t\t\t\t\t\t\t\t\t\ttype=\"error\"\n\t\t\t\t\t\t\t\t\t\t\t\t\tshowIcon\n\t\t\t\t\t\t\t\t\t\t\t\t\tmessage=\"菜单结构问题\"\n\t\t\t\t\t\t\t\t\t\t\t\t\tdescription={\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t<List\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tsize=\"small\"\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tdataSource={validateResult.menuErrors}\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\trenderItem={item => <List.Item>{item}</List.Item>}\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t\t\t\t) : null}\n\t\t\t\t\t\t\t\t\t\t\t{validateResult?.errors?.length &&\n\t\t\t\t\t\t\t\t\t\t\t!validateResult?.menuErrors?.length &&\n\t\t\t\t\t\t\t\t\t\t\t!validateResult?.replyErrors?.length ? (\n\t\t\t\t\t\t\t\t\t\t\t\t<Alert\n\t\t\t\t\t\t\t\t\t\t\t\t\ttype=\"error\"\n\t\t\t\t\t\t\t\t\t\t\t\t\tshowIcon\n\t\t\t\t\t\t\t\t\t\t\t\t\tmessage=\"草稿配置问题\"\n\t\t\t\t\t\t\t\t\t\t\t\t\tdescription={\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t<List\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tsize=\"small\"\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tdataSource={validateResult.errors}\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\trenderItem={item => <List.Item>{item}</List.Item>}\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t\t\t\t) : null}\n\t\t\t\t\t\t\t\t\t\t\t{validateResult?.replyErrors?.length ? (\n\t\t\t\t\t\t\t\t\t\t\t\t<Alert\n\t\t\t\t\t\t\t\t\t\t\t\t\ttype=\"error\"\n\t\t\t\t\t\t\t\t\t\t\t\t\tshowIcon\n\t\t\t\t\t\t\t\t\t\t\t\t\tmessage=\"回复配置问题\"\n\t\t\t\t\t\t\t\t\t\t\t\t\tdescription={\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t<List\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tsize=\"small\"\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tdataSource={validateResult.replyErrors}\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\trenderItem={item => <List.Item>{item}</List.Item>}\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t\t\t\t) : null}\n\t\t\t\t\t\t\t\t\t\t\t{validateResult?.warnings?.length ? (\n\t\t\t\t\t\t\t\t\t\t\t\t<Alert\n\t\t\t\t\t\t\t\t\t\t\t\t\ttype=\"warning\"\n\t\t\t\t\t\t\t\t\t\t\t\t\tshowIcon\n\t\t\t\t\t\t\t\t\t\t\t\t\tmessage=\"校验提示\"\n\t\t\t\t\t\t\t\t\t\t\t\t\tdescription={\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t<List\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tsize=\"small\"\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tdataSource={validateResult.warnings}\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\trenderItem={item => <List.Item>{item}</List.Item>}\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t\t\t\t) : null}\n\t\t\t\t\t\t\t\t\t\t</Space>\n\t\t\t\t\t\t\t\t\t</Card>\n\n\t\t\t\t\t\t\t\t\t<Card bordered={false} className=\"wx-menu-page__panel\" title=\"菜单 JSON 规则\">\n\t\t\t\t\t\t\t\t\t\t<List\n\t\t\t\t\t\t\t\t\t\t\tsize=\"small\"\n\t\t\t\t\t\t\t\t\t\t\tdataSource={detail?.menuJsonTips || []}\n\t\t\t\t\t\t\t\t\t\t\trenderItem={item => <List.Item>{item}</List.Item>}\n\t\t\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t\t</Card>\n\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t</section>\n\n\t\t\t\t\t\t<section className=\"wx-menu-page__module\">\n\t\t\t\t\t\t\t<SectionTitle\n\t\t\t\t\t\t\t\tindex=\"02\"\n\t\t\t\t\t\t\t\ttitle=\"被关注回复\"\n\t\t\t\t\t\t\t\tdescription=\"用户刚关注公众号时触发。适合放欢迎语、导航说明或首次触达文案。\"\n\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t<div className=\"wx-menu-page__single-layout\">\n\t\t\t\t\t\t\t\t<Card\n\t\t\t\t\t\t\t\t\tbordered={false}\n\t\t\t\t\t\t\t\t\tclassName=\"wx-menu-page__panel\"\n\t\t\t\t\t\t\t\t\ttitle=\"关注后回复\"\n\t\t\t\t\t\t\t\t\textra={\n\t\t\t\t\t\t\t\t\t\t<Button size=\"small\" onClick={() => setSubscribeReply(createSubscribeReplyTemplate())}>\n\t\t\t\t\t\t\t\t\t\t\t填充项目介绍模板\n\t\t\t\t\t\t\t\t\t\t</Button>\n\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t<ReplyEditor\n\t\t\t\t\t\t\t\t\t\tvalue={subscribeReply}\n\t\t\t\t\t\t\t\t\t\tonChange={setSubscribeReply}\n\t\t\t\t\t\t\t\t\t\temptyText=\"当前没有配置关注后回复，用户关注后不会收到后台定义的被动消息。\"\n\t\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t</Card>\n\n\t\t\t\t\t\t\t\t<Card bordered={false} className=\"wx-menu-page__panel wx-menu-page__panel--aside\" title=\"关注后回复预览\">\n\t\t\t\t\t\t\t\t\t<WechatReplyPreview\n\t\t\t\t\t\t\t\t\t\ttitle=\"新用户关注\"\n\t\t\t\t\t\t\t\t\t\treply={subscribeReply}\n\t\t\t\t\t\t\t\t\t\ttriggerLabel=\"用户动作\"\n\t\t\t\t\t\t\t\t\t\ttriggerValue=\"关注公众号\"\n\t\t\t\t\t\t\t\t\t\temptyText=\"当前还没有配置关注后回复。\"\n\t\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t</Card>\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t</section>\n\n\t\t\t\t\t\t<section className=\"wx-menu-page__module\">\n\t\t\t\t\t\t\t<SectionTitle\n\t\t\t\t\t\t\t\tindex=\"03\"\n\t\t\t\t\t\t\t\ttitle=\"关键词回复\"\n\t\t\t\t\t\t\t\tdescription=\"这里统一管理 click 事件规则和用户消息关键词规则。优先级越小越先命中，命中后立即返回对应回复。\"\n\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t<div className=\"wx-menu-page__rules-layout\">\n\t\t\t\t\t\t\t\t<Card bordered={false} className=\"wx-menu-page__panel\" title=\"规则列表\">\n\t\t\t\t\t\t\t\t\t<div className=\"wx-menu-page__rule-actions\">\n\t\t\t\t\t\t\t\t\t\t<Space wrap>\n\t\t\t\t\t\t\t\t\t\t\t<Button onClick={() => setKeywordReplies(prev => [...prev, createKeywordReply(\"event_key_exact\")])}>\n\t\t\t\t\t\t\t\t\t\t\t\t新增 click 事件规则\n\t\t\t\t\t\t\t\t\t\t\t</Button>\n\t\t\t\t\t\t\t\t\t\t\t<Button onClick={() => setKeywordReplies(prev => [...prev, createKeywordReply(\"content_exact\")])}>\n\t\t\t\t\t\t\t\t\t\t\t\t新增精确关键词\n\t\t\t\t\t\t\t\t\t\t\t</Button>\n\t\t\t\t\t\t\t\t\t\t\t<Button\n\t\t\t\t\t\t\t\t\t\t\t\ttype=\"primary\"\n\t\t\t\t\t\t\t\t\t\t\t\tonClick={() => setKeywordReplies(prev => [...prev, createKeywordReply(\"content_contains\")])}\n\t\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t\t新增包含关键词\n\t\t\t\t\t\t\t\t\t\t\t</Button>\n\t\t\t\t\t\t\t\t\t\t</Space>\n\t\t\t\t\t\t\t\t\t\t<Text type=\"secondary\">比如发送“聪明”返回邀请码，或者让 CONTACT_US 这种 click key 命中专属回复。</Text>\n\t\t\t\t\t\t\t\t\t</div>\n\n\t\t\t\t\t\t\t\t\t{keywordReplies.length ? (\n\t\t\t\t\t\t\t\t\t\t<div className=\"wx-menu-page__rules-list\">\n\t\t\t\t\t\t\t\t\t\t\t{keywordReplies.map((item, index) => (\n\t\t\t\t\t\t\t\t\t\t\t\t<KeywordRuleCard\n\t\t\t\t\t\t\t\t\t\t\t\t\tkey={`keyword-rule-${index}`}\n\t\t\t\t\t\t\t\t\t\t\t\t\tindex={index}\n\t\t\t\t\t\t\t\t\t\t\t\t\tvalue={item}\n\t\t\t\t\t\t\t\t\t\t\t\t\tonChange={patch => updateKeywordReply(index, patch)}\n\t\t\t\t\t\t\t\t\t\t\t\t\tonReplyChange={reply => updateKeywordReplyReply(index, reply)}\n\t\t\t\t\t\t\t\t\t\t\t\t\tonRemove={() => removeKeywordReply(index)}\n\t\t\t\t\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t\t\t\t))}\n\t\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t) : (\n\t\t\t\t\t\t\t\t\t\t<div className=\"wx-menu-page__empty-block\">\n\t\t\t\t\t\t\t\t\t\t\t<Paragraph className=\"wx-menu-page__empty-title\">还没有配置关键词回复</Paragraph>\n\t\t\t\t\t\t\t\t\t\t\t<Paragraph type=\"secondary\">\n\t\t\t\t\t\t\t\t\t\t\t\t可以先加一条 `content_exact` 规则，把 “聪明” 这类口令指向邀请码；剩余消息再交给普通消息兜底处理。\n\t\t\t\t\t\t\t\t\t\t\t</Paragraph>\n\t\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t</Card>\n\n\t\t\t\t\t\t\t\t<div className=\"wx-menu-page__aside-stack\">\n\t\t\t\t\t\t\t\t\t<Card bordered={false} className=\"wx-menu-page__panel wx-menu-page__panel--aside\" title=\"回复配置提示\">\n\t\t\t\t\t\t\t\t\t\t<List size=\"small\" dataSource={detail?.replyTips || []} renderItem={item => <List.Item>{item}</List.Item>} />\n\t\t\t\t\t\t\t\t\t</Card>\n\t\t\t\t\t\t\t\t\t<Card bordered={false} className=\"wx-menu-page__panel wx-menu-page__panel--aside\" title=\"规则速记\">\n\t\t\t\t\t\t\t\t\t\t<div className=\"wx-menu-page__rule-cheats\">\n\t\t\t\t\t\t\t\t\t\t\t<div className=\"wx-menu-page__cheat-item\">\n\t\t\t\t\t\t\t\t\t\t\t\t<Tag color=\"green\">event_key_exact</Tag>\n\t\t\t\t\t\t\t\t\t\t\t\t<Text type=\"secondary\">给 click 菜单事件用，关键字写 event key。</Text>\n\t\t\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t\t\t<div className=\"wx-menu-page__cheat-item\">\n\t\t\t\t\t\t\t\t\t\t\t\t<Tag color=\"blue\">content_exact</Tag>\n\t\t\t\t\t\t\t\t\t\t\t\t<Text type=\"secondary\">适合口令类消息，比如“聪明”。</Text>\n\t\t\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t\t\t<div className=\"wx-menu-page__cheat-item\">\n\t\t\t\t\t\t\t\t\t\t\t\t<Tag color=\"gold\">content_contains</Tag>\n\t\t\t\t\t\t\t\t\t\t\t\t<Text type=\"secondary\">适合模糊关键词和自然语言触发。</Text>\n\t\t\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t</Card>\n\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t</section>\n\n\t\t\t\t\t\t<section className=\"wx-menu-page__module\">\n\t\t\t\t\t\t\t<SectionTitle\n\t\t\t\t\t\t\t\tindex=\"04\"\n\t\t\t\t\t\t\t\ttitle=\"普通消息兜底\"\n\t\t\t\t\t\t\t\tdescription=\"当用户消息没有命中任何关键词规则时，按这里的策略继续处理，可以不回复、固定回复，或者交给 AI。\"\n\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t<div className=\"wx-menu-page__fallback-grid\">\n\t\t\t\t\t\t\t\t<Card bordered={false} className=\"wx-menu-page__panel\" title=\"兜底策略\">\n\t\t\t\t\t\t\t\t\t<Space direction=\"vertical\" size={16} style={{ display: \"flex\" }}>\n\t\t\t\t\t\t\t\t\t\t<div className=\"wx-menu-page__field\">\n\t\t\t\t\t\t\t\t\t\t\t<Text strong>messageFallbackStrategy</Text>\n\t\t\t\t\t\t\t\t\t\t\t<Select\n\t\t\t\t\t\t\t\t\t\t\t\tvalue={messageFallbackStrategy}\n\t\t\t\t\t\t\t\t\t\t\t\toptions={FALLBACK_STRATEGY_OPTIONS}\n\t\t\t\t\t\t\t\t\t\t\t\tonChange={value => setMessageFallbackStrategy(value)}\n\t\t\t\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t\t<Alert showIcon type=\"info\" message={FALLBACK_STRATEGY_DESC_MAP[messageFallbackStrategy]} />\n\t\t\t\t\t\t\t\t\t\t<div className=\"wx-menu-page__strategy-notes\">\n\t\t\t\t\t\t\t\t\t\t\t{FALLBACK_STRATEGY_OPTIONS.map(item => (\n\t\t\t\t\t\t\t\t\t\t\t\t<div\n\t\t\t\t\t\t\t\t\t\t\t\t\tkey={item.value}\n\t\t\t\t\t\t\t\t\t\t\t\t\tclassName={`wx-menu-page__strategy-item ${\n\t\t\t\t\t\t\t\t\t\t\t\t\t\titem.value === messageFallbackStrategy ? \"wx-menu-page__strategy-item--active\" : \"\"\n\t\t\t\t\t\t\t\t\t\t\t\t\t}`}\n\t\t\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t\t\t<strong>{item.label}</strong>\n\t\t\t\t\t\t\t\t\t\t\t\t\t<span>{FALLBACK_STRATEGY_DESC_MAP[item.value]}</span>\n\t\t\t\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t\t\t))}\n\t\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t</Space>\n\t\t\t\t\t\t\t\t</Card>\n\n\t\t\t\t\t\t\t\t<Card\n\t\t\t\t\t\t\t\t\tbordered={false}\n\t\t\t\t\t\t\t\t\tclassName=\"wx-menu-page__panel\"\n\t\t\t\t\t\t\t\t\ttitle=\"AI 回复设置\"\n\t\t\t\t\t\t\t\t\textra={\n\t\t\t\t\t\t\t\t\t\t<Button size=\"small\" onClick={() => setAiPrompt(createAiPromptTemplate())}>\n\t\t\t\t\t\t\t\t\t\t\t填充助手模板\n\t\t\t\t\t\t\t\t\t\t</Button>\n\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t<Space direction=\"vertical\" size={16} style={{ display: \"flex\" }}>\n\t\t\t\t\t\t\t\t\t\t<div className=\"wx-menu-page__switch-row\">\n\t\t\t\t\t\t\t\t\t\t\t<div>\n\t\t\t\t\t\t\t\t\t\t\t\t<Text strong>启用 AI</Text>\n\t\t\t\t\t\t\t\t\t\t\t\t<Paragraph type=\"secondary\">只有兜底策略选了 AI 回复，这里的配置才会参与生效。</Paragraph>\n\t\t\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t\t\t<Switch checked={aiEnable} onChange={setAiEnable} />\n\t\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t\t<div className=\"wx-menu-page__field\">\n\t\t\t\t\t\t\t\t\t\t\t<Text strong>AI Provider</Text>\n\t\t\t\t\t\t\t\t\t\t\t<Select\n\t\t\t\t\t\t\t\t\t\t\t\tshowSearch\n\t\t\t\t\t\t\t\t\t\t\t\tallowClear\n\t\t\t\t\t\t\t\t\t\t\t\tplaceholder={\n\t\t\t\t\t\t\t\t\t\t\t\t\tavailableAiProviderOptions.length\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t? \"请选择 AI 模型配置中已启用的 Provider\"\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t: \"请先到 AI 模型配置里启用 Provider\"\n\t\t\t\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\t\t\t\tvalue={aiProvider || undefined}\n\t\t\t\t\t\t\t\t\t\t\t\toptions={aiProviderOptions}\n\t\t\t\t\t\t\t\t\t\t\t\tonChange={value => setAiProvider(value || \"\")}\n\t\t\t\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t\t\t\t<Text type=\"secondary\">这里已经同步 AI 模型配置里当前启用的来源，只展示可用于同步预览的 Provider。</Text>\n\t\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t\t<div className=\"wx-menu-page__field\">\n\t\t\t\t\t\t\t\t\t\t\t<Text strong>AI Prompt</Text>\n\t\t\t\t\t\t\t\t\t\t\t<TextArea\n\t\t\t\t\t\t\t\t\t\t\t\tautoSize={{ minRows: 6, maxRows: 10 }}\n\t\t\t\t\t\t\t\t\t\t\t\tplaceholder=\"给大模型的系统提示词，例如：根据用户消息回答公众号相关问题，保持简洁，无法确定时引导查看菜单。\"\n\t\t\t\t\t\t\t\t\t\t\t\tvalue={aiPrompt}\n\t\t\t\t\t\t\t\t\t\t\t\tonChange={e => setAiPrompt(e.target.value)}\n\t\t\t\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t</Space>\n\t\t\t\t\t\t\t\t</Card>\n\n\t\t\t\t\t\t\t\t<Card\n\t\t\t\t\t\t\t\t\tbordered={false}\n\t\t\t\t\t\t\t\t\tclassName=\"wx-menu-page__panel\"\n\t\t\t\t\t\t\t\t\ttitle=\"默认兜底回复\"\n\t\t\t\t\t\t\t\t\textra={\n\t\t\t\t\t\t\t\t\t\t<Button size=\"small\" onClick={() => setDefaultReply(createDefaultReplyTemplate())}>\n\t\t\t\t\t\t\t\t\t\t\t填充项目介绍模板\n\t\t\t\t\t\t\t\t\t\t</Button>\n\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t<ReplyEditor\n\t\t\t\t\t\t\t\t\t\tvalue={defaultReply}\n\t\t\t\t\t\t\t\t\t\tonChange={setDefaultReply}\n\t\t\t\t\t\t\t\t\t\temptyText=\"当前没有配置默认回复。固定兜底模式下，未命中的消息会走系统默认文案。\"\n\t\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t</Card>\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t</section>\n\n\t\t\t\t\t\t<section className=\"wx-menu-page__module\">\n\t\t\t\t\t\t\t<SectionTitle\n\t\t\t\t\t\t\t\tindex=\"05\"\n\t\t\t\t\t\t\t\ttitle=\"真实预览\"\n\t\t\t\t\t\t\t\tdescription=\"这里会调用后端新增的预览接口，直接用当前编辑中的配置试跑命中结果和 AI 回复，不需要先保存草稿。\"\n\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t<div className=\"wx-menu-page__runtime-grid\">\n\t\t\t\t\t\t\t\t<Card bordered={false} className=\"wx-menu-page__panel\" title=\"真实命中预览\">\n\t\t\t\t\t\t\t\t\t<Space direction=\"vertical\" size={16} style={{ display: \"flex\" }}>\n\t\t\t\t\t\t\t\t\t\t<div className=\"wx-menu-page__field\">\n\t\t\t\t\t\t\t\t\t\t\t<Text strong>触发方式</Text>\n\t\t\t\t\t\t\t\t\t\t\t<Select value={previewEventType} options={PREVIEW_EVENT_OPTIONS} onChange={setPreviewEventType} />\n\t\t\t\t\t\t\t\t\t\t</div>\n\n\t\t\t\t\t\t\t\t\t\t{previewEventType === \"TEXT\" ? (\n\t\t\t\t\t\t\t\t\t\t\t<div className=\"wx-menu-page__field\">\n\t\t\t\t\t\t\t\t\t\t\t\t<Text strong>用户消息</Text>\n\t\t\t\t\t\t\t\t\t\t\t\t<TextArea\n\t\t\t\t\t\t\t\t\t\t\t\t\tautoSize={{ minRows: 3, maxRows: 5 }}\n\t\t\t\t\t\t\t\t\t\t\t\t\tplaceholder=\"例如：聪明\"\n\t\t\t\t\t\t\t\t\t\t\t\t\tvalue={previewContent}\n\t\t\t\t\t\t\t\t\t\t\t\t\tonChange={e => setPreviewContent(e.target.value)}\n\t\t\t\t\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t\t) : null}\n\n\t\t\t\t\t\t\t\t\t\t{previewEventType === \"CLICK\" ? (\n\t\t\t\t\t\t\t\t\t\t\t<div className=\"wx-menu-page__field\">\n\t\t\t\t\t\t\t\t\t\t\t\t<Text strong>event key</Text>\n\t\t\t\t\t\t\t\t\t\t\t\t<Input\n\t\t\t\t\t\t\t\t\t\t\t\t\tplaceholder=\"例如：CONTACT_US\"\n\t\t\t\t\t\t\t\t\t\t\t\t\tvalue={previewEventKey}\n\t\t\t\t\t\t\t\t\t\t\t\t\tonChange={e => setPreviewEventKey(e.target.value)}\n\t\t\t\t\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t\t) : null}\n\n\t\t\t\t\t\t\t\t\t\t<Alert\n\t\t\t\t\t\t\t\t\t\t\tshowIcon\n\t\t\t\t\t\t\t\t\t\t\ttype=\"info\"\n\t\t\t\t\t\t\t\t\t\t\tmessage={`当前会按「${getPreviewEventLabel(\n\t\t\t\t\t\t\t\t\t\t\t\tpreviewEventType\n\t\t\t\t\t\t\t\t\t\t\t)}」方式，把页面上正在编辑的配置送到后端真实试跑。`}\n\t\t\t\t\t\t\t\t\t\t/>\n\n\t\t\t\t\t\t\t\t\t\t{matchDisabledRules.length ? (\n\t\t\t\t\t\t\t\t\t\t\t<Alert\n\t\t\t\t\t\t\t\t\t\t\t\tshowIcon\n\t\t\t\t\t\t\t\t\t\t\t\ttype=\"warning\"\n\t\t\t\t\t\t\t\t\t\t\t\tmessage=\"当前有本可命中的规则处于关闭状态\"\n\t\t\t\t\t\t\t\t\t\t\t\tdescription={\n\t\t\t\t\t\t\t\t\t\t\t\t\t<div className=\"wx-menu-page__preview-hints\">\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t{matchDisabledRules.map((item, index) => (\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t<div key={`${item.rule.title || \"rule\"}-${index}`} className=\"wx-menu-page__preview-hint\">\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t<strong>{item.rule.title || `规则 ${index + 1}`}</strong>\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t<span>\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t匹配方式：{getMatchTypeLabel(item.rule.matchType)}，命中词：{item.matchedKeyword}\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t))}\n\t\t\t\t\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t\t\t) : null}\n\n\t\t\t\t\t\t\t\t\t\t<Button type=\"primary\" onClick={handlePreviewMatch} loading={actionLoading === \"previewMatch\"}>\n\t\t\t\t\t\t\t\t\t\t\t试跑命中结果\n\t\t\t\t\t\t\t\t\t\t</Button>\n\n\t\t\t\t\t\t\t\t\t\t<div className=\"wx-menu-page__preview-result\">\n\t\t\t\t\t\t\t\t\t\t\t<MatchPreviewMeta result={matchPreviewResult} />\n\t\t\t\t\t\t\t\t\t\t\t<WechatReplyPreview\n\t\t\t\t\t\t\t\t\t\t\t\ttitle=\"命中结果\"\n\t\t\t\t\t\t\t\t\t\t\t\treply={matchPreviewResult?.reply}\n\t\t\t\t\t\t\t\t\t\t\t\ttriggerLabel={previewTriggerLabel}\n\t\t\t\t\t\t\t\t\t\t\t\ttriggerValue={previewTriggerValue}\n\t\t\t\t\t\t\t\t\t\t\t\temptyText=\"这次试跑没有返回回复内容。\"\n\t\t\t\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t</Space>\n\t\t\t\t\t\t\t\t</Card>\n\n\t\t\t\t\t\t\t\t<Card bordered={false} className=\"wx-menu-page__panel\" title=\"AI 回复预览\">\n\t\t\t\t\t\t\t\t\t<Space direction=\"vertical\" size={16} style={{ display: \"flex\" }}>\n\t\t\t\t\t\t\t\t\t\t<div className=\"wx-menu-page__field\">\n\t\t\t\t\t\t\t\t\t\t\t<Text strong>用户消息</Text>\n\t\t\t\t\t\t\t\t\t\t\t<TextArea\n\t\t\t\t\t\t\t\t\t\t\t\tautoSize={{ minRows: 4, maxRows: 6 }}\n\t\t\t\t\t\t\t\t\t\t\t\tplaceholder=\"例如：我想了解派聪明 RAG 这个项目\"\n\t\t\t\t\t\t\t\t\t\t\t\tvalue={aiPreviewContent}\n\t\t\t\t\t\t\t\t\t\t\t\tonChange={e => setAiPreviewContent(e.target.value)}\n\t\t\t\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t\t\t</div>\n\n\t\t\t\t\t\t\t\t\t\t<Alert\n\t\t\t\t\t\t\t\t\t\t\tshowIcon\n\t\t\t\t\t\t\t\t\t\t\ttype={aiEnable ? \"success\" : \"warning\"}\n\t\t\t\t\t\t\t\t\t\t\tmessage={\n\t\t\t\t\t\t\t\t\t\t\t\taiEnable\n\t\t\t\t\t\t\t\t\t\t\t\t\t? `当前 AI 预览会使用 Provider：${getAiProviderLabel(aiProvider)}`\n\t\t\t\t\t\t\t\t\t\t\t\t\t: \"当前 AI 开关还没开启，后端预览大概率会直接返回失败原因。\"\n\t\t\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\t\t/>\n\n\t\t\t\t\t\t\t\t\t\t{!availableAiProviderOptions.length ? (\n\t\t\t\t\t\t\t\t\t\t\t<Alert\n\t\t\t\t\t\t\t\t\t\t\t\tshowIcon\n\t\t\t\t\t\t\t\t\t\t\t\ttype=\"warning\"\n\t\t\t\t\t\t\t\t\t\t\t\tmessage=\"还没有可用的 AI Provider\"\n\t\t\t\t\t\t\t\t\t\t\t\tdescription=\"请先到 AI 模型配置页启用至少一个支持同步预览的模型来源，再回来选择。\"\n\t\t\t\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t\t\t) : null}\n\n\t\t\t\t\t\t\t\t\t\t{aiProvider && !isKnownAiProvider(aiProvider) ? (\n\t\t\t\t\t\t\t\t\t\t\t<Alert\n\t\t\t\t\t\t\t\t\t\t\t\tshowIcon\n\t\t\t\t\t\t\t\t\t\t\t\ttype=\"warning\"\n\t\t\t\t\t\t\t\t\t\t\t\tmessage=\"当前 Provider 不在后端可识别列表里\"\n\t\t\t\t\t\t\t\t\t\t\t\tdescription=\"建议改用下拉中的固定值，例如 PAI_AI、ZHI_PU_AI、ALI_AI、DEEP_SEEK、DOU_BAO_AI。\"\n\t\t\t\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t\t\t) : null}\n\n\t\t\t\t\t\t\t\t\t\t{aiProvider && isKnownAiProvider(aiProvider) && !isAiProviderEnabled ? (\n\t\t\t\t\t\t\t\t\t\t\t<Alert\n\t\t\t\t\t\t\t\t\t\t\t\tshowIcon\n\t\t\t\t\t\t\t\t\t\t\t\ttype=\"warning\"\n\t\t\t\t\t\t\t\t\t\t\t\tmessage=\"当前 Provider 未在 AI 模型配置中启用\"\n\t\t\t\t\t\t\t\t\t\t\t\tdescription=\"这份微信配置草稿里保存了一个旧值，建议改成当前已启用的 Provider，避免预览和线上运行不一致。\"\n\t\t\t\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t\t\t) : null}\n\n\t\t\t\t\t\t\t\t\t\t<Button type=\"primary\" onClick={handlePreviewAi} loading={actionLoading === \"previewAi\"}>\n\t\t\t\t\t\t\t\t\t\t\t试跑 AI 回复\n\t\t\t\t\t\t\t\t\t\t</Button>\n\n\t\t\t\t\t\t\t\t\t\t{aiPreviewResult && !aiPreviewResult.success ? (\n\t\t\t\t\t\t\t\t\t\t\t<Alert showIcon type=\"error\" message=\"AI 预览失败\" description={getAiPreviewErrorText(aiPreviewResult)} />\n\t\t\t\t\t\t\t\t\t\t) : null}\n\n\t\t\t\t\t\t\t\t\t\t{aiPreviewResult?.provider ? (\n\t\t\t\t\t\t\t\t\t\t\t<div className=\"wx-menu-page__preview-meta\">\n\t\t\t\t\t\t\t\t\t\t\t\t<div className=\"wx-menu-page__preview-meta-item\">\n\t\t\t\t\t\t\t\t\t\t\t\t\t<span>AI Provider</span>\n\t\t\t\t\t\t\t\t\t\t\t\t\t<strong>{aiPreviewResult.provider}</strong>\n\t\t\t\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t\t) : null}\n\n\t\t\t\t\t\t\t\t\t\t<WechatReplyPreview\n\t\t\t\t\t\t\t\t\t\t\ttitle=\"AI 回复结果\"\n\t\t\t\t\t\t\t\t\t\t\treply={aiPreviewReply}\n\t\t\t\t\t\t\t\t\t\t\ttriggerLabel=\"用户消息\"\n\t\t\t\t\t\t\t\t\t\t\ttriggerValue={aiPreviewContent || \"未填写试跑内容\"}\n\t\t\t\t\t\t\t\t\t\t\temptyText=\"执行 AI 预览后，这里会展示后端返回的回复内容。\"\n\t\t\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t\t</Space>\n\t\t\t\t\t\t\t\t</Card>\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t</section>\n\t\t\t\t\t</div>\n\t\t\t\t</Spin>\n\t\t\t</ContentWrap>\n\t\t</div>\n\t);\n};\n\nexport default WxMenuPage;\n"
  },
  {
    "path": "src/vite-env.d.ts",
    "content": "/// <reference types=\"vite/client\" />\n"
  },
  {
    "path": "tsconfig.json",
    "content": "{\n\t\"compilerOptions\": {\n\t\t\"target\": \"ESNext\",\n\t\t\"useDefineForClassFields\": true,\n\t\t\"lib\": [\"DOM\", \"DOM.Iterable\", \"ESNext\"],\n\t\t\"allowJs\": false,\n\t\t\"skipLibCheck\": true,\n\t\t\"esModuleInterop\": false,\n\t\t\"allowSyntheticDefaultImports\": true,\n\n\t\t/* Strict Type-Checking Options */\n\t\t\"strict\": true /* Enable all strict type-checking options. */,\n\t\t// \"noImplicitAny\": true,                 /* Raise error on expressions and declarations with an implied 'any' type. */\n\t\t// \"strictNullChecks\": true,              /* Enable strict null checks. */\n\t\t// \"strictFunctionTypes\": true,           /* Enable strict checking of function types. */\n\t\t// \"strictBindCallApply\": true,           /* Enable strict 'bind', 'call', and 'apply' methods on functions. */\n\t\t// \"strictPropertyInitialization\": true,  /* Enable strict checking of property initialization in classes. */\n\t\t// \"noImplicitThis\": true,                /* Raise error on 'this' expressions with an implied 'any' type. */\n\t\t// \"alwaysStrict\": true,                  /* Parse in strict mode and emit \"use strict\" for each source file. */\n\n\t\t\"forceConsistentCasingInFileNames\": true,\n\t\t\"module\": \"ESNext\",\n\t\t\"moduleResolution\": \"Node\",\n\t\t\"resolveJsonModule\": true,\n\t\t\"isolatedModules\": true,\n\t\t\"noEmit\": true,\n\t\t\"jsx\": \"react-jsx\",\n\t\t// 解析非相对模块名的基准目录\n\t\t\"baseUrl\": \"./\",\n\t\t// 模块名到基于 baseUrl的路径映射的列表。\n\t\t\"paths\": {\n\t\t\t\"@\": [\"src\"],\n\t\t\t\"@/*\": [\"src/*\"]\n\t\t}\n\t},\n\t\"include\": [\"src/**/*.ts\", \"src/**/*.d.ts\", \"src/**/*.tsx\", \"vite.config.ts\"],\n\t\"exclude\": [\"node_modules\", \"dist\", \"**/*.js\"]\n}\n"
  },
  {
    "path": "vite.config.ts",
    "content": "import { execSync } from \"child_process\";\nimport * as http from \"http\";\nimport * as https from \"https\";\nimport react from \"@vitejs/plugin-react\";\nimport { resolve } from \"path\";\nimport { visualizer } from \"rollup-plugin-visualizer\";\nimport { ConfigEnv, defineConfig, loadEnv, PluginOption, UserConfig } from \"vite\";\nimport viteCompression from \"vite-plugin-compression\";\nimport eslintPlugin from \"vite-plugin-eslint\";\nimport { createHtmlPlugin } from \"vite-plugin-html\";\nimport { createStyleImportPlugin } from \"vite-plugin-style-import\";\nimport { createSvgIconsPlugin } from \"vite-plugin-svg-icons\";\n\nimport { wrapperEnv } from \"./src/utils/getEnv\";\n\nconst DEFAULT_BACKEND_ORIGIN = \"http://127.0.0.1:8080\";\nconst BACKEND_PROBE_PATHS = [\"/api/admin/probe\", \"/api/admin/isLogined\"];\nconst BACKEND_PROBE_SIGNATURE = \"paicoding-port-for-admin\";\nconst BACKEND_PROBE_TIMEOUT = 1200;\nconst WELL_KNOWN_BACKEND_PORTS = [8080, 9201, 8081, 8082, 8083, 8084, 8090, 8888, 9999];\n\nfunction normalizeLocalOrigin(value?: string) {\n\tif (!value) return \"\";\n\n\ttry {\n\t\tconst url = new URL(value);\n\t\tif (![\"127.0.0.1\", \"localhost\", \"0.0.0.0\"].includes(url.hostname)) return \"\";\n\n\t\tconst hostname = url.hostname === \"0.0.0.0\" ? \"127.0.0.1\" : url.hostname;\n\t\treturn `${url.protocol}//${hostname}${url.port ? `:${url.port}` : \"\"}`;\n\t} catch {\n\t\treturn \"\";\n\t}\n}\n\nfunction getListeningPorts() {\n\ttry {\n\t\tconst output = execSync(\"lsof -nP -iTCP -sTCP:LISTEN\", {\n\t\t\tencoding: \"utf8\",\n\t\t\tstdio: [\"ignore\", \"pipe\", \"ignore\"]\n\t\t});\n\t\tconst javaPorts: number[] = [];\n\t\tconst otherPorts: number[] = [];\n\n\t\toutput\n\t\t\t.split(\"\\n\")\n\t\t\t.slice(1)\n\t\t\t.forEach(line => {\n\t\t\t\tconst trimmedLine = line.trim();\n\t\t\t\tif (!trimmedLine) return;\n\n\t\t\t\tconst matchedPort = trimmedLine.match(/:(\\d+)\\s+\\(LISTEN\\)$/);\n\t\t\t\tif (!matchedPort) return;\n\n\t\t\t\tconst port = Number(matchedPort[1]);\n\t\t\t\tif (!Number.isInteger(port)) return;\n\n\t\t\t\tconst command = trimmedLine.split(/\\s+/)[0]?.toLowerCase();\n\t\t\t\tif (command === \"java\") {\n\t\t\t\t\tjavaPorts.push(port);\n\t\t\t\t\treturn;\n\t\t\t\t}\n\n\t\t\t\totherPorts.push(port);\n\t\t\t});\n\n\t\treturn [...new Set([...javaPorts, ...otherPorts])];\n\t} catch {\n\t\treturn [];\n\t}\n}\n\nfunction parseProbeResponse(body: string, fallbackOrigin: string) {\n\ttry {\n\t\tconst payload = JSON.parse(body);\n\t\tif (\n\t\t\tpayload?.status?.code === 0 &&\n\t\t\tpayload?.result?.signature === BACKEND_PROBE_SIGNATURE &&\n\t\t\tpayload?.result?.app === \"paicoding\" &&\n\t\t\tpayload?.result?.module === \"admin\"\n\t\t) {\n\t\t\treturn normalizeLocalOrigin(payload?.result?.host) || fallbackOrigin;\n\t\t}\n\n\t\tif (payload?.status?.code === 0 && typeof payload?.result === \"boolean\") {\n\t\t\treturn fallbackOrigin;\n\t\t}\n\t} catch {\n\t\treturn \"\";\n\t}\n\n\treturn \"\";\n}\n\nfunction requestProbe(url: URL, fallbackOrigin: string): Promise<string> {\n\tconst requestClient = url.protocol === \"https:\" ? https : http;\n\n\treturn new Promise(resolve => {\n\t\tlet settled = false;\n\t\tconst finish = (matchedOrigin: string) => {\n\t\t\tif (settled) return;\n\t\t\tsettled = true;\n\t\t\tresolve(matchedOrigin);\n\t\t};\n\n\t\tconst req = requestClient.request(\n\t\t\turl,\n\t\t\t{\n\t\t\t\tmethod: \"GET\",\n\t\t\t\ttimeout: BACKEND_PROBE_TIMEOUT,\n\t\t\t\theaders: {\n\t\t\t\t\tAccept: \"application/json\"\n\t\t\t\t}\n\t\t\t},\n\t\t\tres => {\n\t\t\t\tlet body = \"\";\n\n\t\t\t\tres.setEncoding(\"utf8\");\n\t\t\t\tres.on(\"data\", chunk => {\n\t\t\t\t\tif (body.length < 4096) {\n\t\t\t\t\t\tbody += chunk;\n\t\t\t\t\t}\n\t\t\t\t});\n\t\t\t\tres.on(\"end\", () => {\n\t\t\t\t\tfinish(parseProbeResponse(body, fallbackOrigin));\n\t\t\t\t});\n\t\t\t}\n\t\t);\n\n\t\treq.on(\"timeout\", () => {\n\t\t\treq.destroy();\n\t\t\tfinish(\"\");\n\t\t});\n\t\treq.on(\"error\", () => finish(\"\"));\n\t\treq.end();\n\t});\n}\n\nasync function probeBackendOrigin(origin: string) {\n\tfor (const probePath of BACKEND_PROBE_PATHS) {\n\t\tconst matchedOrigin = await requestProbe(new URL(probePath, origin), origin);\n\t\tif (matchedOrigin) {\n\t\t\treturn matchedOrigin;\n\t\t}\n\t}\n\n\treturn \"\";\n}\n\nasync function resolveBackendOrigin(configuredDomain: string) {\n\tconst configuredOrigin = normalizeLocalOrigin(configuredDomain);\n\tconst candidateOrigins = new Set<string>();\n\n\tif (configuredOrigin) {\n\t\tcandidateOrigins.add(configuredOrigin);\n\t}\n\n\tgetListeningPorts().forEach(port => {\n\t\tcandidateOrigins.add(`http://127.0.0.1:${port}`);\n\t});\n\tWELL_KNOWN_BACKEND_PORTS.forEach(port => {\n\t\tcandidateOrigins.add(`http://127.0.0.1:${port}`);\n\t});\n\tcandidateOrigins.add(DEFAULT_BACKEND_ORIGIN);\n\n\tfor (const origin of candidateOrigins) {\n\t\tconst matchedOrigin = await probeBackendOrigin(origin);\n\t\tif (matchedOrigin) {\n\t\t\treturn matchedOrigin;\n\t\t}\n\t}\n\n\treturn configuredOrigin || DEFAULT_BACKEND_ORIGIN;\n}\n\n// @see: https://vitejs.dev/config/\n/** @type {import('vite').UserConfig} */\nexport default defineConfig(async (mode: ConfigEnv): Promise<UserConfig> => {\n\tconst env = loadEnv(mode.mode, process.cwd());\n\tconst viteEnv = wrapperEnv(env);\n\tconst shouldAutoDiscoverBackend = [\"development\", \"test\"].includes(mode.mode) && viteEnv.VITE_API_URL.startsWith(\"/\");\n\tconst configuredDomain = viteEnv.VITE_DOMAIN;\n\tconst backendOrigin = shouldAutoDiscoverBackend ? await resolveBackendOrigin(configuredDomain) : configuredDomain;\n\n\tif (shouldAutoDiscoverBackend) {\n\t\tviteEnv.VITE_DOMAIN = backendOrigin;\n\t\tconsole.info(`[vite] PaiCoding backend target: ${backendOrigin}`);\n\t}\n\tconst plugins: PluginOption[] = [\n\t\treact(),\n\t\tcreateHtmlPlugin({\n\t\t\tinject: {\n\t\t\t\tdata: {\n\t\t\t\t\ttitle: viteEnv.VITE_GLOB_APP_TITLE\n\t\t\t\t}\n\t\t\t}\n\t\t}),\n\t\tcreateSvgIconsPlugin({\n\t\t\ticonDirs: [resolve(process.cwd(), \"src/assets/icons\")],\n\t\t\tsymbolId: \"icon-[dir]-[name]\"\n\t\t}),\n\t\tcreateStyleImportPlugin({\n\t\t\tlibs: [\n\t\t\t\t{\n\t\t\t\t\tlibraryName: \"antd\",\n\t\t\t\t\tesModule: true,\n\t\t\t\t\tresolveStyle: (name: any) => {\n\t\t\t\t\t\treturn `antd/es/${name}/style/index`;\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t]\n\t\t}),\n\t\teslintPlugin()\n\t];\n\n\tif (viteEnv.VITE_REPORT) {\n\t\tplugins.push(visualizer() as unknown as PluginOption);\n\t}\n\n\tif (viteEnv.VITE_BUILD_GZIP) {\n\t\tplugins.push(\n\t\t\tviteCompression({\n\t\t\t\tverbose: true,\n\t\t\t\tdisable: false,\n\t\t\t\tthreshold: 10240,\n\t\t\t\talgorithm: \"gzip\",\n\t\t\t\text: \".gz\"\n\t\t\t}) as unknown as PluginOption\n\t\t);\n\t}\n\n\treturn {\n\t\tdefine: shouldAutoDiscoverBackend\n\t\t\t? {\n\t\t\t\t\t\"import.meta.env.VITE_DOMAIN\": JSON.stringify(backendOrigin)\n\t\t\t  }\n\t\t\t: undefined,\n\t\t// base: \"/\",\n\t\t// alias config\n\t\tresolve: {\n\t\t\talias: {\n\t\t\t\t\"@\": resolve(__dirname, \"./src\")\n\t\t\t}\n\t\t},\n\t\t// global css\n\t\tcss: {\n\t\t\tpreprocessorOptions: {\n\t\t\t\tless: {\n\t\t\t\t\tjavascriptEnabled: true,\n\t\t\t\t\tadditionalData: `@import \"@/styles/var.less\";`\n\t\t\t\t}\n\t\t\t}\n\t\t},\n\t\t// server config\n\t\tserver: {\n\t\t\thost: \"127.0.0.1\", // 服务器主机名，如果允许外部访问，可设置为\"0.0.0.0\"\n\t\t\tport: viteEnv.VITE_PORT,\n\t\t\topen: viteEnv.VITE_OPEN,\n\t\t\tcors: true,\n\t\t\t// https: false,\n\t\t\t// 代理跨域（mock 不需要配置，这里只是个示例）\n\t\t\tproxy: {\n\t\t\t\t\"/admin\": {\n\t\t\t\t\ttarget: backendOrigin,\n\t\t\t\t\tchangeOrigin: true,\n\t\t\t\t\trewrite: path => path.replace(/^\\/api/, \"\")\n\t\t\t\t},\n\t\t\t\t\"/api/admin\": {\n\t\t\t\t\ttarget: backendOrigin,\n\t\t\t\t\tchangeOrigin: true\n\t\t\t\t}\n\t\t\t}\n\t\t},\n\t\tplugins,\n\t\tesbuild: {\n\t\t\tpure: viteEnv.VITE_DROP_CONSOLE ? [\"console.log\", \"debugger\"] : []\n\t\t},\n\t\tbase: \"./\",\n\t\t// build configure\n\t\tbuild: {\n\t\t\toutDir: \"dist\",\n\t\t\t// esbuild 打包更快，但是不能去除 console.log，去除 console 使用 terser 模式\n\t\t\tminify: \"esbuild\",\n\t\t\t// minify: \"terser\",\n\t\t\t// terserOptions: {\n\t\t\t// \tcompress: {\n\t\t\t// \t\tdrop_console: viteEnv.VITE_DROP_CONSOLE,\n\t\t\t// \t\tdrop_debugger: true\n\t\t\t// \t}\n\t\t\t// },\n\t\t\trollupOptions: {\n\t\t\t\toutput: {\n\t\t\t\t\t// Static resource classification and packaging\n\t\t\t\t\tchunkFileNames: \"assets/js/[name]-[hash].js\",\n\t\t\t\t\tentryFileNames: \"assets/js/[name]-[hash].js\",\n\t\t\t\t\tassetFileNames: \"assets/[name]-[hash].[ext]\"\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t};\n});\n"
  }
]