chenlei 2 years ago
commit
7adf359f5b
98 changed files with 16760 additions and 0 deletions
  1. 23 0
      .gitignore
  2. 3 0
      .vscode/settings.json
  3. 1 0
      README.md
  4. 104 0
      config/env.js
  5. 66 0
      config/getHttpsConfig.js
  6. 29 0
      config/jest/babelTransform.js
  7. 14 0
      config/jest/cssTransform.js
  8. 40 0
      config/jest/fileTransform.js
  9. 134 0
      config/modules.js
  10. 77 0
      config/paths.js
  11. 757 0
      config/webpack.config.js
  12. 9 0
      config/webpack/persistentCache/createEnvironmentHash.js
  13. 124 0
      config/webpackDevServer.config.js
  14. 160 0
      package.json
  15. BIN
      public/favicon.ico
  16. BIN
      public/fonts/SOURCEHANSERIFCN-BOLD.OTF
  17. BIN
      public/fonts/SOURCEHANSERIFCN-REGULAR.OTF
  18. 31 0
      public/index.html
  19. 217 0
      scripts/build.js
  20. 154 0
      scripts/start.js
  21. 52 0
      scripts/test.js
  22. 93 0
      src/App.scss
  23. 30 0
      src/App.tsx
  24. 31 0
      src/api/history.ts
  25. 21 0
      src/api/index.ts
  26. 14 0
      src/api/industrial-meta.ts
  27. 8 0
      src/api/log.ts
  28. 27 0
      src/api/overview.ts
  29. 23 0
      src/api/user.ts
  30. 20 0
      src/api/weapon.ts
  31. BIN
      src/assets/images/logo.png
  32. 8 0
      src/components/DageFileCheckbox/constants.ts
  33. 32 0
      src/components/DageFileCheckbox/index.module.scss
  34. 206 0
      src/components/DageFileCheckbox/index.tsx
  35. 23 0
      src/components/DageFileCheckbox/types.ts
  36. 221 0
      src/components/DageMap/index.tsx
  37. 44 0
      src/components/DageMap/plugins/Geocoder/index.ts
  38. 21 0
      src/components/DageMap/plugins/Geocoder/types.ts
  39. 1 0
      src/components/DageMap/plugins/index.ts
  40. 35 0
      src/components/DageMap/types.ts
  41. 24 0
      src/components/DageMap/utils.ts
  42. 6 0
      src/components/DageUpload/index.scss
  43. 184 0
      src/components/DageUpload/index.tsx
  44. 36 0
      src/components/DageUpload/types.ts
  45. 21 0
      src/components/FormPageFooter/index.scss
  46. 52 0
      src/components/FormPageFooter/index.tsx
  47. 26 0
      src/components/NotFound/index.tsx
  48. 10 0
      src/components/SpinLoding/index.module.scss
  49. 11 0
      src/components/SpinLoding/index.tsx
  50. 5 0
      src/components/index.ts
  51. 9 0
      src/css.d.ts
  52. 7 0
      src/img.d.ts
  53. 19 0
      src/index.tsx
  54. 155 0
      src/pages/History/create-or-edit/index.tsx
  55. 193 0
      src/pages/History/index.tsx
  56. 85 0
      src/pages/IndustrialMeta/create-or-edit/index.tsx
  57. 123 0
      src/pages/IndustrialMeta/index.tsx
  58. 95 0
      src/pages/Layout/components/Header/components/ResetPassword.tsx
  59. 37 0
      src/pages/Layout/components/Header/index.module.scss
  60. 90 0
      src/pages/Layout/components/Header/index.tsx
  61. 36 0
      src/pages/Layout/components/Sider/index.module.scss
  62. 31 0
      src/pages/Layout/components/Sider/index.tsx
  63. 2 0
      src/pages/Layout/components/index.ts
  64. 120 0
      src/pages/Layout/constants.tsx
  65. 3 0
      src/pages/Layout/index.scss
  66. 158 0
      src/pages/Layout/index.tsx
  67. 17 0
      src/pages/Layout/types.ts
  68. 141 0
      src/pages/Log/index.tsx
  69. BIN
      src/pages/Login/images/bg.jpg
  70. BIN
      src/pages/Login/images/icon_account.png
  71. BIN
      src/pages/Login/images/icon_password.png
  72. 109 0
      src/pages/Login/index.scss
  73. 91 0
      src/pages/Login/index.tsx
  74. 181 0
      src/pages/Overview/create-or-edit/index.tsx
  75. 177 0
      src/pages/Overview/index.tsx
  76. 19 0
      src/pages/User/components/UserAdd/index.module.scss
  77. 143 0
      src/pages/User/components/UserAdd/index.tsx
  78. 240 0
      src/pages/User/index.tsx
  79. 254 0
      src/pages/Weapon/create-or-edit/index.tsx
  80. 200 0
      src/pages/Weapon/index.tsx
  81. 20 0
      src/store/index.ts
  82. 22 0
      src/store/reducer/base.ts
  83. 11 0
      src/store/reducer/index.ts
  84. 6 0
      src/theme.scss
  85. 37 0
      src/types/history.ts
  86. 28 0
      src/types/index.ts
  87. 11 0
      src/types/industrial-meta.ts
  88. 7 0
      src/types/log.ts
  89. 32 0
      src/types/overview.ts
  90. 31 0
      src/types/user.ts
  91. 13 0
      src/types/weapon.ts
  92. 118 0
      src/utils/date.ts
  93. 13 0
      src/utils/index.ts
  94. 100 0
      src/utils/pass.ts
  95. 145 0
      src/utils/services.ts
  96. 33 0
      src/utils/storage.ts
  97. 23 0
      tsconfig.json
  98. 10147 0
      yarn.lock

+ 23 - 0
.gitignore

@@ -0,0 +1,23 @@
+# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
+
+# dependencies
+/node_modules
+/.pnp
+.pnp.js
+
+# testing
+/coverage
+
+# production
+/build
+
+# misc
+.DS_Store
+.env.local
+.env.development.local
+.env.test.local
+.env.production.local
+
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*

+ 3 - 0
.vscode/settings.json

@@ -0,0 +1,3 @@
+{
+  "typescript.tsdk": "node_modules\\typescript\\lib"
+}

+ 1 - 0
README.md

@@ -0,0 +1 @@
+### 上海工业博物馆-后台

+ 104 - 0
config/env.js

@@ -0,0 +1,104 @@
+'use strict';
+
+const fs = require('fs');
+const path = require('path');
+const paths = require('./paths');
+
+// Make sure that including paths.js after env.js will read .env variables.
+delete require.cache[require.resolve('./paths')];
+
+const NODE_ENV = process.env.NODE_ENV;
+if (!NODE_ENV) {
+  throw new Error(
+    'The NODE_ENV environment variable is required but was not specified.'
+  );
+}
+
+// https://github.com/bkeepers/dotenv#what-other-env-files-can-i-use
+const dotenvFiles = [
+  `${paths.dotenv}.${NODE_ENV}.local`,
+  // Don't include `.env.local` for `test` environment
+  // since normally you expect tests to produce the same
+  // results for everyone
+  NODE_ENV !== 'test' && `${paths.dotenv}.local`,
+  `${paths.dotenv}.${NODE_ENV}`,
+  paths.dotenv,
+].filter(Boolean);
+
+// Load environment variables from .env* files. Suppress warnings using silent
+// if this file is missing. dotenv will never modify any environment variables
+// that have already been set.  Variable expansion is supported in .env files.
+// https://github.com/motdotla/dotenv
+// https://github.com/motdotla/dotenv-expand
+dotenvFiles.forEach(dotenvFile => {
+  if (fs.existsSync(dotenvFile)) {
+    require('dotenv-expand')(
+      require('dotenv').config({
+        path: dotenvFile,
+      })
+    );
+  }
+});
+
+// We support resolving modules according to `NODE_PATH`.
+// This lets you use absolute paths in imports inside large monorepos:
+// https://github.com/facebook/create-react-app/issues/253.
+// It works similar to `NODE_PATH` in Node itself:
+// https://nodejs.org/api/modules.html#modules_loading_from_the_global_folders
+// Note that unlike in Node, only *relative* paths from `NODE_PATH` are honored.
+// Otherwise, we risk importing Node.js core modules into an app instead of webpack shims.
+// https://github.com/facebook/create-react-app/issues/1023#issuecomment-265344421
+// We also resolve them to make sure all tools using them work consistently.
+const appDirectory = fs.realpathSync(process.cwd());
+process.env.NODE_PATH = (process.env.NODE_PATH || '')
+  .split(path.delimiter)
+  .filter(folder => folder && !path.isAbsolute(folder))
+  .map(folder => path.resolve(appDirectory, folder))
+  .join(path.delimiter);
+
+// Grab NODE_ENV and REACT_APP_* environment variables and prepare them to be
+// injected into the application via DefinePlugin in webpack configuration.
+const REACT_APP = /^REACT_APP_/i;
+
+function getClientEnvironment(publicUrl) {
+  const raw = Object.keys(process.env)
+    .filter(key => REACT_APP.test(key))
+    .reduce(
+      (env, key) => {
+        env[key] = process.env[key];
+        return env;
+      },
+      {
+        // Useful for determining whether we’re running in production mode.
+        // Most importantly, it switches React into the correct mode.
+        NODE_ENV: process.env.NODE_ENV || 'development',
+        // Useful for resolving the correct path to static assets in `public`.
+        // For example, <img src={process.env.PUBLIC_URL + '/img/logo.png'} />.
+        // This should only be used as an escape hatch. Normally you would put
+        // images into the `src` and `import` them in code to get their paths.
+        PUBLIC_URL: publicUrl,
+        // We support configuring the sockjs pathname during development.
+        // These settings let a developer run multiple simultaneous projects.
+        // They are used as the connection `hostname`, `pathname` and `port`
+        // in webpackHotDevClient. They are used as the `sockHost`, `sockPath`
+        // and `sockPort` options in webpack-dev-server.
+        WDS_SOCKET_HOST: process.env.WDS_SOCKET_HOST,
+        WDS_SOCKET_PATH: process.env.WDS_SOCKET_PATH,
+        WDS_SOCKET_PORT: process.env.WDS_SOCKET_PORT,
+        // Whether or not react-refresh is enabled.
+        // It is defined here so it is available in the webpackHotDevClient.
+        FAST_REFRESH: process.env.FAST_REFRESH !== 'false',
+      }
+    );
+  // Stringify all values so we can feed into webpack DefinePlugin
+  const stringified = {
+    'process.env': Object.keys(raw).reduce((env, key) => {
+      env[key] = JSON.stringify(raw[key]);
+      return env;
+    }, {}),
+  };
+
+  return { raw, stringified };
+}
+
+module.exports = getClientEnvironment;

+ 66 - 0
config/getHttpsConfig.js

@@ -0,0 +1,66 @@
+'use strict';
+
+const fs = require('fs');
+const path = require('path');
+const crypto = require('crypto');
+const chalk = require('react-dev-utils/chalk');
+const paths = require('./paths');
+
+// Ensure the certificate and key provided are valid and if not
+// throw an easy to debug error
+function validateKeyAndCerts({ cert, key, keyFile, crtFile }) {
+  let encrypted;
+  try {
+    // publicEncrypt will throw an error with an invalid cert
+    encrypted = crypto.publicEncrypt(cert, Buffer.from('test'));
+  } catch (err) {
+    throw new Error(
+      `The certificate "${chalk.yellow(crtFile)}" is invalid.\n${err.message}`
+    );
+  }
+
+  try {
+    // privateDecrypt will throw an error with an invalid key
+    crypto.privateDecrypt(key, encrypted);
+  } catch (err) {
+    throw new Error(
+      `The certificate key "${chalk.yellow(keyFile)}" is invalid.\n${
+        err.message
+      }`
+    );
+  }
+}
+
+// Read file and throw an error if it doesn't exist
+function readEnvFile(file, type) {
+  if (!fs.existsSync(file)) {
+    throw new Error(
+      `You specified ${chalk.cyan(
+        type
+      )} in your env, but the file "${chalk.yellow(file)}" can't be found.`
+    );
+  }
+  return fs.readFileSync(file);
+}
+
+// Get the https config
+// Return cert files if provided in env, otherwise just true or false
+function getHttpsConfig() {
+  const { SSL_CRT_FILE, SSL_KEY_FILE, HTTPS } = process.env;
+  const isHttps = HTTPS === 'true';
+
+  if (isHttps && SSL_CRT_FILE && SSL_KEY_FILE) {
+    const crtFile = path.resolve(paths.appPath, SSL_CRT_FILE);
+    const keyFile = path.resolve(paths.appPath, SSL_KEY_FILE);
+    const config = {
+      cert: readEnvFile(crtFile, 'SSL_CRT_FILE'),
+      key: readEnvFile(keyFile, 'SSL_KEY_FILE'),
+    };
+
+    validateKeyAndCerts({ ...config, keyFile, crtFile });
+    return config;
+  }
+  return isHttps;
+}
+
+module.exports = getHttpsConfig;

+ 29 - 0
config/jest/babelTransform.js

@@ -0,0 +1,29 @@
+'use strict';
+
+const babelJest = require('babel-jest').default;
+
+const hasJsxRuntime = (() => {
+  if (process.env.DISABLE_NEW_JSX_TRANSFORM === 'true') {
+    return false;
+  }
+
+  try {
+    require.resolve('react/jsx-runtime');
+    return true;
+  } catch (e) {
+    return false;
+  }
+})();
+
+module.exports = babelJest.createTransformer({
+  presets: [
+    [
+      require.resolve('babel-preset-react-app'),
+      {
+        runtime: hasJsxRuntime ? 'automatic' : 'classic',
+      },
+    ],
+  ],
+  babelrc: false,
+  configFile: false,
+});

+ 14 - 0
config/jest/cssTransform.js

@@ -0,0 +1,14 @@
+'use strict';
+
+// This is a custom Jest transformer turning style imports into empty objects.
+// http://facebook.github.io/jest/docs/en/webpack.html
+
+module.exports = {
+  process() {
+    return 'module.exports = {};';
+  },
+  getCacheKey() {
+    // The output is always the same.
+    return 'cssTransform';
+  },
+};

+ 40 - 0
config/jest/fileTransform.js

@@ -0,0 +1,40 @@
+'use strict';
+
+const path = require('path');
+const camelcase = require('camelcase');
+
+// This is a custom Jest transformer turning file imports into filenames.
+// http://facebook.github.io/jest/docs/en/webpack.html
+
+module.exports = {
+  process(src, filename) {
+    const assetFilename = JSON.stringify(path.basename(filename));
+
+    if (filename.match(/\.svg$/)) {
+      // Based on how SVGR generates a component name:
+      // https://github.com/smooth-code/svgr/blob/01b194cf967347d43d4cbe6b434404731b87cf27/packages/core/src/state.js#L6
+      const pascalCaseFilename = camelcase(path.parse(filename).name, {
+        pascalCase: true,
+      });
+      const componentName = `Svg${pascalCaseFilename}`;
+      return `const React = require('react');
+      module.exports = {
+        __esModule: true,
+        default: ${assetFilename},
+        ReactComponent: React.forwardRef(function ${componentName}(props, ref) {
+          return {
+            $$typeof: Symbol.for('react.element'),
+            type: 'svg',
+            ref: ref,
+            key: null,
+            props: Object.assign({}, props, {
+              children: ${assetFilename}
+            })
+          };
+        }),
+      };`;
+    }
+
+    return `module.exports = ${assetFilename};`;
+  },
+};

+ 134 - 0
config/modules.js

@@ -0,0 +1,134 @@
+'use strict';
+
+const fs = require('fs');
+const path = require('path');
+const paths = require('./paths');
+const chalk = require('react-dev-utils/chalk');
+const resolve = require('resolve');
+
+/**
+ * Get additional module paths based on the baseUrl of a compilerOptions object.
+ *
+ * @param {Object} options
+ */
+function getAdditionalModulePaths(options = {}) {
+  const baseUrl = options.baseUrl;
+
+  if (!baseUrl) {
+    return '';
+  }
+
+  const baseUrlResolved = path.resolve(paths.appPath, baseUrl);
+
+  // We don't need to do anything if `baseUrl` is set to `node_modules`. This is
+  // the default behavior.
+  if (path.relative(paths.appNodeModules, baseUrlResolved) === '') {
+    return null;
+  }
+
+  // Allow the user set the `baseUrl` to `appSrc`.
+  if (path.relative(paths.appSrc, baseUrlResolved) === '') {
+    return [paths.appSrc];
+  }
+
+  // If the path is equal to the root directory we ignore it here.
+  // We don't want to allow importing from the root directly as source files are
+  // not transpiled outside of `src`. We do allow importing them with the
+  // absolute path (e.g. `src/Components/Button.js`) but we set that up with
+  // an alias.
+  if (path.relative(paths.appPath, baseUrlResolved) === '') {
+    return null;
+  }
+
+  // Otherwise, throw an error.
+  throw new Error(
+    chalk.red.bold(
+      "Your project's `baseUrl` can only be set to `src` or `node_modules`." +
+        ' Create React App does not support other values at this time.'
+    )
+  );
+}
+
+/**
+ * Get webpack aliases based on the baseUrl of a compilerOptions object.
+ *
+ * @param {*} options
+ */
+function getWebpackAliases(options = {}) {
+  const baseUrl = options.baseUrl;
+
+  if (!baseUrl) {
+    return {};
+  }
+
+  const baseUrlResolved = path.resolve(paths.appPath, baseUrl);
+
+  if (path.relative(paths.appPath, baseUrlResolved) === '') {
+    return {
+      src: paths.appSrc,
+    };
+  }
+}
+
+/**
+ * Get jest aliases based on the baseUrl of a compilerOptions object.
+ *
+ * @param {*} options
+ */
+function getJestAliases(options = {}) {
+  const baseUrl = options.baseUrl;
+
+  if (!baseUrl) {
+    return {};
+  }
+
+  const baseUrlResolved = path.resolve(paths.appPath, baseUrl);
+
+  if (path.relative(paths.appPath, baseUrlResolved) === '') {
+    return {
+      '^src/(.*)$': '<rootDir>/src/$1',
+    };
+  }
+}
+
+function getModules() {
+  // Check if TypeScript is setup
+  const hasTsConfig = fs.existsSync(paths.appTsConfig);
+  const hasJsConfig = fs.existsSync(paths.appJsConfig);
+
+  if (hasTsConfig && hasJsConfig) {
+    throw new Error(
+      'You have both a tsconfig.json and a jsconfig.json. If you are using TypeScript please remove your jsconfig.json file.'
+    );
+  }
+
+  let config;
+
+  // If there's a tsconfig.json we assume it's a
+  // TypeScript project and set up the config
+  // based on tsconfig.json
+  if (hasTsConfig) {
+    const ts = require(resolve.sync('typescript', {
+      basedir: paths.appNodeModules,
+    }));
+    config = ts.readConfigFile(paths.appTsConfig, ts.sys.readFile).config;
+    // Otherwise we'll check if there is jsconfig.json
+    // for non TS projects.
+  } else if (hasJsConfig) {
+    config = require(paths.appJsConfig);
+  }
+
+  config = config || {};
+  const options = config.compilerOptions || {};
+
+  const additionalModulePaths = getAdditionalModulePaths(options);
+
+  return {
+    additionalModulePaths: additionalModulePaths,
+    webpackAliases: getWebpackAliases(options),
+    jestAliases: getJestAliases(options),
+    hasTsConfig,
+  };
+}
+
+module.exports = getModules();

+ 77 - 0
config/paths.js

@@ -0,0 +1,77 @@
+'use strict';
+
+const path = require('path');
+const fs = require('fs');
+const getPublicUrlOrPath = require('react-dev-utils/getPublicUrlOrPath');
+
+// Make sure any symlinks in the project folder are resolved:
+// https://github.com/facebook/create-react-app/issues/637
+const appDirectory = fs.realpathSync(process.cwd());
+const resolveApp = relativePath => path.resolve(appDirectory, relativePath);
+
+// We use `PUBLIC_URL` environment variable or "homepage" field to infer
+// "public path" at which the app is served.
+// webpack needs to know it to put the right <script> hrefs into HTML even in
+// single-page apps that may serve index.html for nested URLs like /todos/42.
+// We can't use a relative path in HTML because we don't want to load something
+// like /todos/42/static/js/bundle.7289d.js. We have to know the root.
+const publicUrlOrPath = getPublicUrlOrPath(
+  process.env.NODE_ENV === 'development',
+  require(resolveApp('package.json')).homepage,
+  process.env.PUBLIC_URL
+);
+
+const buildPath = process.env.BUILD_PATH || 'build';
+
+const moduleFileExtensions = [
+  'web.mjs',
+  'mjs',
+  'web.js',
+  'js',
+  'web.ts',
+  'ts',
+  'web.tsx',
+  'tsx',
+  'json',
+  'web.jsx',
+  'jsx',
+];
+
+// Resolve file paths in the same order as webpack
+const resolveModule = (resolveFn, filePath) => {
+  const extension = moduleFileExtensions.find(extension =>
+    fs.existsSync(resolveFn(`${filePath}.${extension}`))
+  );
+
+  if (extension) {
+    return resolveFn(`${filePath}.${extension}`);
+  }
+
+  return resolveFn(`${filePath}.js`);
+};
+
+// config after eject: we're in ./config/
+module.exports = {
+  dotenv: resolveApp('.env'),
+  appPath: resolveApp('.'),
+  appBuild: resolveApp(buildPath),
+  appPublic: resolveApp('public'),
+  appHtml: resolveApp('public/index.html'),
+  appIndexJs: resolveModule(resolveApp, 'src/index'),
+  appPackageJson: resolveApp('package.json'),
+  appSrc: resolveApp('src'),
+  appTsConfig: resolveApp('tsconfig.json'),
+  appJsConfig: resolveApp('jsconfig.json'),
+  yarnLockFile: resolveApp('yarn.lock'),
+  testsSetup: resolveModule(resolveApp, 'src/setupTests'),
+  proxySetup: resolveApp('src/setupProxy.js'),
+  appNodeModules: resolveApp('node_modules'),
+  appWebpackCache: resolveApp('node_modules/.cache'),
+  appTsBuildInfoFile: resolveApp('node_modules/.cache/tsconfig.tsbuildinfo'),
+  swSrc: resolveModule(resolveApp, 'src/service-worker'),
+  publicUrlOrPath,
+};
+
+
+
+module.exports.moduleFileExtensions = moduleFileExtensions;

+ 757 - 0
config/webpack.config.js

@@ -0,0 +1,757 @@
+"use strict";
+
+const fs = require("fs");
+const path = require("path");
+const webpack = require("webpack");
+const resolve = require("resolve");
+const HtmlWebpackPlugin = require("html-webpack-plugin");
+const CaseSensitivePathsPlugin = require("case-sensitive-paths-webpack-plugin");
+const InlineChunkHtmlPlugin = require("react-dev-utils/InlineChunkHtmlPlugin");
+const TerserPlugin = require("terser-webpack-plugin");
+const MiniCssExtractPlugin = require("mini-css-extract-plugin");
+const CssMinimizerPlugin = require("css-minimizer-webpack-plugin");
+const { WebpackManifestPlugin } = require("webpack-manifest-plugin");
+const InterpolateHtmlPlugin = require("react-dev-utils/InterpolateHtmlPlugin");
+const WorkboxWebpackPlugin = require("workbox-webpack-plugin");
+const ModuleScopePlugin = require("react-dev-utils/ModuleScopePlugin");
+const getCSSModuleLocalIdent = require("react-dev-utils/getCSSModuleLocalIdent");
+const ESLintPlugin = require("eslint-webpack-plugin");
+const paths = require("./paths");
+const modules = require("./modules");
+const getClientEnvironment = require("./env");
+const ModuleNotFoundPlugin = require("react-dev-utils/ModuleNotFoundPlugin");
+const ForkTsCheckerWebpackPlugin =
+  process.env.TSC_COMPILE_ON_ERROR === "true"
+    ? require("react-dev-utils/ForkTsCheckerWarningWebpackPlugin")
+    : require("react-dev-utils/ForkTsCheckerWebpackPlugin");
+const ReactRefreshWebpackPlugin = require("@pmmmwh/react-refresh-webpack-plugin");
+
+const createEnvironmentHash = require("./webpack/persistentCache/createEnvironmentHash");
+
+// Source maps are resource heavy and can cause out of memory issue for large source files.
+const shouldUseSourceMap = process.env.GENERATE_SOURCEMAP !== "false";
+
+const reactRefreshRuntimeEntry = require.resolve("react-refresh/runtime");
+const reactRefreshWebpackPluginRuntimeEntry = require.resolve(
+  "@pmmmwh/react-refresh-webpack-plugin"
+);
+const babelRuntimeEntry = require.resolve("babel-preset-react-app");
+const babelRuntimeEntryHelpers = require.resolve(
+  "@babel/runtime/helpers/esm/assertThisInitialized",
+  { paths: [babelRuntimeEntry] }
+);
+const babelRuntimeRegenerator = require.resolve("@babel/runtime/regenerator", {
+  paths: [babelRuntimeEntry],
+});
+
+// Some apps do not need the benefits of saving a web request, so not inlining the chunk
+// makes for a smoother build process.
+const shouldInlineRuntimeChunk = process.env.INLINE_RUNTIME_CHUNK !== "false";
+
+const emitErrorsAsWarnings = process.env.ESLINT_NO_DEV_ERRORS === "true";
+const disableESLintPlugin = process.env.DISABLE_ESLINT_PLUGIN === "true";
+
+const imageInlineSizeLimit = parseInt(
+  process.env.IMAGE_INLINE_SIZE_LIMIT || "10000"
+);
+
+// Check if TypeScript is setup
+const useTypeScript = fs.existsSync(paths.appTsConfig);
+
+// Check if Tailwind config exists
+const useTailwind = fs.existsSync(
+  path.join(paths.appPath, "tailwind.config.js")
+);
+
+// Get the path to the uncompiled service worker (if it exists).
+const swSrc = paths.swSrc;
+
+// style files regexes
+const cssRegex = /\.css$/;
+const cssModuleRegex = /\.module\.css$/;
+const sassRegex = /\.(scss|sass)$/;
+const sassModuleRegex = /\.module\.(scss|sass)$/;
+
+const hasJsxRuntime = (() => {
+  if (process.env.DISABLE_NEW_JSX_TRANSFORM === "true") {
+    return false;
+  }
+
+  try {
+    require.resolve("react/jsx-runtime");
+    return true;
+  } catch (e) {
+    return false;
+  }
+})();
+
+// This is the production and development configuration.
+// It is focused on developer experience, fast rebuilds, and a minimal bundle.
+module.exports = function (webpackEnv) {
+  const isEnvDevelopment = webpackEnv === "development";
+  const isEnvProduction = webpackEnv === "production";
+
+  // Variable used for enabling profiling in Production
+  // passed into alias object. Uses a flag if passed into the build command
+  const isEnvProductionProfile =
+    isEnvProduction && process.argv.includes("--profile");
+
+  // We will provide `paths.publicUrlOrPath` to our app
+  // as %PUBLIC_URL% in `index.html` and `process.env.PUBLIC_URL` in JavaScript.
+  // Omit trailing slash as %PUBLIC_URL%/xyz looks better than %PUBLIC_URL%xyz.
+  // Get environment variables to inject into our app.
+  const env = getClientEnvironment(paths.publicUrlOrPath.slice(0, -1));
+
+  const shouldUseReactRefresh = env.raw.FAST_REFRESH;
+
+  // common function to get style loaders
+  const getStyleLoaders = (cssOptions, preProcessor) => {
+    const loaders = [
+      isEnvDevelopment && require.resolve("style-loader"),
+      isEnvProduction && {
+        loader: MiniCssExtractPlugin.loader,
+        // css is located in `static/css`, use '../../' to locate index.html folder
+        // in production `paths.publicUrlOrPath` can be a relative path
+        options: paths.publicUrlOrPath.startsWith(".")
+          ? { publicPath: "../../" }
+          : {},
+      },
+      {
+        loader: require.resolve("css-loader"),
+        options: cssOptions,
+      },
+      {
+        // Options for PostCSS as we reference these options twice
+        // Adds vendor prefixing based on your specified browser support in
+        // package.json
+        loader: require.resolve("postcss-loader"),
+        options: {
+          postcssOptions: {
+            // Necessary for external CSS imports to work
+            // https://github.com/facebook/create-react-app/issues/2677
+            ident: "postcss",
+            config: false,
+            plugins: !useTailwind
+              ? [
+                  "postcss-flexbugs-fixes",
+                  [
+                    "postcss-preset-env",
+                    {
+                      autoprefixer: {
+                        flexbox: "no-2009",
+                      },
+                      stage: 3,
+                    },
+                  ],
+                  // Adds PostCSS Normalize as the reset css with default options,
+                  // so that it honors browserslist config in package.json
+                  // which in turn let's users customize the target behavior as per their needs.
+                  "postcss-normalize",
+                ]
+              : [
+                  "tailwindcss",
+                  "postcss-flexbugs-fixes",
+                  [
+                    "postcss-preset-env",
+                    {
+                      autoprefixer: {
+                        flexbox: "no-2009",
+                      },
+                      stage: 3,
+                    },
+                  ],
+                ],
+          },
+          sourceMap: isEnvProduction ? shouldUseSourceMap : isEnvDevelopment,
+        },
+      },
+    ].filter(Boolean);
+    if (preProcessor) {
+      loaders.push(
+        {
+          loader: require.resolve("resolve-url-loader"),
+          options: {
+            sourceMap: isEnvProduction ? shouldUseSourceMap : isEnvDevelopment,
+            root: paths.appSrc,
+          },
+        },
+        {
+          loader: require.resolve(preProcessor),
+          options: {
+            sourceMap: true,
+          },
+        }
+      );
+    }
+    return loaders;
+  };
+
+  return {
+    target: ["browserslist"],
+    // Webpack noise constrained to errors and warnings
+    stats: "errors-warnings",
+    mode: isEnvProduction ? "production" : isEnvDevelopment && "development",
+    // Stop compilation early in production
+    bail: isEnvProduction,
+    devtool: isEnvProduction
+      ? shouldUseSourceMap
+        ? "source-map"
+        : false
+      : isEnvDevelopment && "cheap-module-source-map",
+    // These are the "entry points" to our application.
+    // This means they will be the "root" imports that are included in JS bundle.
+    entry: paths.appIndexJs,
+    output: {
+      // The build folder.
+      path: paths.appBuild,
+      // Add /* filename */ comments to generated require()s in the output.
+      pathinfo: isEnvDevelopment,
+      // There will be one main bundle, and one file per asynchronous chunk.
+      // In development, it does not produce real files.
+      filename: isEnvProduction
+        ? "static/js/[name].[contenthash:8].js"
+        : isEnvDevelopment && "static/js/bundle.js",
+      // There are also additional JS chunk files if you use code splitting.
+      chunkFilename: isEnvProduction
+        ? "static/js/[name].[contenthash:8].chunk.js"
+        : isEnvDevelopment && "static/js/[name].chunk.js",
+      assetModuleFilename: "static/media/[name].[hash][ext]",
+      // webpack uses `publicPath` to determine where the app is being served from.
+      // It requires a trailing slash, or the file assets will get an incorrect path.
+      // We inferred the "public path" (such as / or /my-project) from homepage.
+      publicPath: paths.publicUrlOrPath,
+      // Point sourcemap entries to original disk location (format as URL on Windows)
+      devtoolModuleFilenameTemplate: isEnvProduction
+        ? (info) =>
+            path
+              .relative(paths.appSrc, info.absoluteResourcePath)
+              .replace(/\\/g, "/")
+        : isEnvDevelopment &&
+          ((info) =>
+            path.resolve(info.absoluteResourcePath).replace(/\\/g, "/")),
+    },
+    cache: {
+      type: "filesystem",
+      version: createEnvironmentHash(env.raw),
+      cacheDirectory: paths.appWebpackCache,
+      store: "pack",
+      buildDependencies: {
+        defaultWebpack: ["webpack/lib/"],
+        config: [__filename],
+        tsconfig: [paths.appTsConfig, paths.appJsConfig].filter((f) =>
+          fs.existsSync(f)
+        ),
+      },
+    },
+    infrastructureLogging: {
+      level: "none",
+    },
+    optimization: {
+      minimize: isEnvProduction,
+      minimizer: [
+        // This is only used in production mode
+        new TerserPlugin({
+          terserOptions: {
+            parse: {
+              // We want terser to parse ecma 8 code. However, we don't want it
+              // to apply any minification steps that turns valid ecma 5 code
+              // into invalid ecma 5 code. This is why the 'compress' and 'output'
+              // sections only apply transformations that are ecma 5 safe
+              // https://github.com/facebook/create-react-app/pull/4234
+              ecma: 8,
+            },
+            compress: {
+              ecma: 5,
+              warnings: false,
+              // Disabled because of an issue with Uglify breaking seemingly valid code:
+              // https://github.com/facebook/create-react-app/issues/2376
+              // Pending further investigation:
+              // https://github.com/mishoo/UglifyJS2/issues/2011
+              comparisons: false,
+              // Disabled because of an issue with Terser breaking valid code:
+              // https://github.com/facebook/create-react-app/issues/5250
+              // Pending further investigation:
+              // https://github.com/terser-js/terser/issues/120
+              inline: 2,
+            },
+            mangle: {
+              safari10: true,
+            },
+            // Added for profiling in devtools
+            keep_classnames: isEnvProductionProfile,
+            keep_fnames: isEnvProductionProfile,
+            output: {
+              ecma: 5,
+              comments: false,
+              // Turned on because emoji and regex is not minified properly using default
+              // https://github.com/facebook/create-react-app/issues/2488
+              ascii_only: true,
+            },
+          },
+        }),
+        // This is only used in production mode
+        new CssMinimizerPlugin(),
+      ],
+    },
+    resolve: {
+      // This allows you to set a fallback for where webpack should look for modules.
+      // We placed these paths second because we want `node_modules` to "win"
+      // if there are any conflicts. This matches Node resolution mechanism.
+      // https://github.com/facebook/create-react-app/issues/253
+      modules: ["node_modules", paths.appNodeModules].concat(
+        modules.additionalModulePaths || []
+      ),
+      // These are the reasonable defaults supported by the Node ecosystem.
+      // We also include JSX as a common component filename extension to support
+      // some tools, although we do not recommend using it, see:
+      // https://github.com/facebook/create-react-app/issues/290
+      // `web` extension prefixes have been added for better support
+      // for React Native Web.
+      extensions: paths.moduleFileExtensions
+        .map((ext) => `.${ext}`)
+        .filter((ext) => useTypeScript || !ext.includes("ts")),
+      alias: {
+        // Support React Native Web
+        // https://www.smashingmagazine.com/2016/08/a-glimpse-into-the-future-with-react-native-for-web/
+        "react-native": "react-native-web",
+        // Allows for better profiling with ReactDevTools
+        ...(isEnvProductionProfile && {
+          "react-dom$": "react-dom/profiling",
+          "scheduler/tracing": "scheduler/tracing-profiling",
+        }),
+        ...(modules.webpackAliases || {}),
+        "@": path.resolve(__dirname, "../src"),
+      },
+      plugins: [
+        // Prevents users from importing files from outside of src/ (or node_modules/).
+        // This often causes confusion because we only process files within src/ with babel.
+        // To fix this, we prevent you from importing files out of src/ -- if you'd like to,
+        // please link the files into your node_modules/ and let module-resolution kick in.
+        // Make sure your source files are compiled, as they will not be processed in any way.
+        new ModuleScopePlugin(paths.appSrc, [
+          paths.appPackageJson,
+          reactRefreshRuntimeEntry,
+          reactRefreshWebpackPluginRuntimeEntry,
+          babelRuntimeEntry,
+          babelRuntimeEntryHelpers,
+          babelRuntimeRegenerator,
+        ]),
+      ],
+    },
+    module: {
+      strictExportPresence: true,
+      rules: [
+        // Handle node_modules packages that contain sourcemaps
+        shouldUseSourceMap && {
+          enforce: "pre",
+          exclude: /@babel(?:\/|\\{1,2})runtime/,
+          test: /\.(js|mjs|jsx|ts|tsx|css)$/,
+          loader: require.resolve("source-map-loader"),
+        },
+        {
+          // "oneOf" will traverse all following loaders until one will
+          // match the requirements. When no loader matches it will fall
+          // back to the "file" loader at the end of the loader list.
+          oneOf: [
+            // TODO: Merge this config once `image/avif` is in the mime-db
+            // https://github.com/jshttp/mime-db
+            {
+              test: [/\.avif$/],
+              type: "asset",
+              mimetype: "image/avif",
+              parser: {
+                dataUrlCondition: {
+                  maxSize: imageInlineSizeLimit,
+                },
+              },
+            },
+            // "url" loader works like "file" loader except that it embeds assets
+            // smaller than specified limit in bytes as data URLs to avoid requests.
+            // A missing `test` is equivalent to a match.
+            {
+              test: [/\.bmp$/, /\.gif$/, /\.jpe?g$/, /\.png$/],
+              type: "asset",
+              parser: {
+                dataUrlCondition: {
+                  maxSize: imageInlineSizeLimit,
+                },
+              },
+            },
+            {
+              test: /\.svg$/,
+              use: [
+                {
+                  loader: require.resolve("@svgr/webpack"),
+                  options: {
+                    prettier: false,
+                    svgo: false,
+                    svgoConfig: {
+                      plugins: [{ removeViewBox: false }],
+                    },
+                    titleProp: true,
+                    ref: true,
+                  },
+                },
+                {
+                  loader: require.resolve("file-loader"),
+                  options: {
+                    name: "static/media/[name].[hash].[ext]",
+                  },
+                },
+              ],
+              issuer: {
+                and: [/\.(ts|tsx|js|jsx|md|mdx)$/],
+              },
+            },
+            // Process application JS with Babel.
+            // The preset includes JSX, Flow, TypeScript, and some ESnext features.
+            {
+              test: /\.(js|mjs|jsx|ts|tsx)$/,
+              include: paths.appSrc,
+              loader: require.resolve("babel-loader"),
+              options: {
+                customize: require.resolve(
+                  "babel-preset-react-app/webpack-overrides"
+                ),
+                presets: [
+                  [
+                    require.resolve("babel-preset-react-app"),
+                    {
+                      runtime: hasJsxRuntime ? "automatic" : "classic",
+                    },
+                  ],
+                ],
+
+                plugins: [
+                  isEnvDevelopment &&
+                    shouldUseReactRefresh &&
+                    require.resolve("react-refresh/babel"),
+                ].filter(Boolean),
+                // This is a feature of `babel-loader` for webpack (not Babel itself).
+                // It enables caching results in ./node_modules/.cache/babel-loader/
+                // directory for faster rebuilds.
+                cacheDirectory: true,
+                // See #6846 for context on why cacheCompression is disabled
+                cacheCompression: false,
+                compact: isEnvProduction,
+              },
+            },
+            // Process any JS outside of the app with Babel.
+            // Unlike the application JS, we only compile the standard ES features.
+            {
+              test: /\.(js|mjs)$/,
+              exclude: /@babel(?:\/|\\{1,2})runtime/,
+              loader: require.resolve("babel-loader"),
+              options: {
+                babelrc: false,
+                configFile: false,
+                compact: false,
+                presets: [
+                  [
+                    require.resolve("babel-preset-react-app/dependencies"),
+                    { helpers: true },
+                  ],
+                ],
+                cacheDirectory: true,
+                // See #6846 for context on why cacheCompression is disabled
+                cacheCompression: false,
+
+                // Babel sourcemaps are needed for debugging into node_modules
+                // code.  Without the options below, debuggers like VSCode
+                // show incorrect code and set breakpoints on the wrong lines.
+                sourceMaps: shouldUseSourceMap,
+                inputSourceMap: shouldUseSourceMap,
+              },
+            },
+            // "postcss" loader applies autoprefixer to our CSS.
+            // "css" loader resolves paths in CSS and adds assets as dependencies.
+            // "style" loader turns CSS into JS modules that inject <style> tags.
+            // In production, we use MiniCSSExtractPlugin to extract that CSS
+            // to a file, but in development "style" loader enables hot editing
+            // of CSS.
+            // By default we support CSS Modules with the extension .module.css
+            {
+              test: cssRegex,
+              exclude: cssModuleRegex,
+              use: getStyleLoaders({
+                importLoaders: 1,
+                sourceMap: isEnvProduction
+                  ? shouldUseSourceMap
+                  : isEnvDevelopment,
+                modules: {
+                  mode: "icss",
+                },
+              }),
+              // Don't consider CSS imports dead code even if the
+              // containing package claims to have no side effects.
+              // Remove this when webpack adds a warning or an error for this.
+              // See https://github.com/webpack/webpack/issues/6571
+              sideEffects: true,
+            },
+            // Adds support for CSS Modules (https://github.com/css-modules/css-modules)
+            // using the extension .module.css
+            {
+              test: cssModuleRegex,
+              use: getStyleLoaders({
+                importLoaders: 1,
+                sourceMap: isEnvProduction
+                  ? shouldUseSourceMap
+                  : isEnvDevelopment,
+                modules: {
+                  mode: "local",
+                  getLocalIdent: getCSSModuleLocalIdent,
+                },
+              }),
+            },
+            // Opt-in support for SASS (using .scss or .sass extensions).
+            // By default we support SASS Modules with the
+            // extensions .module.scss or .module.sass
+            {
+              test: sassRegex,
+              exclude: sassModuleRegex,
+              use: getStyleLoaders(
+                {
+                  importLoaders: 3,
+                  sourceMap: isEnvProduction
+                    ? shouldUseSourceMap
+                    : isEnvDevelopment,
+                  modules: {
+                    mode: "icss",
+                  },
+                },
+                "sass-loader"
+              ),
+              // Don't consider CSS imports dead code even if the
+              // containing package claims to have no side effects.
+              // Remove this when webpack adds a warning or an error for this.
+              // See https://github.com/webpack/webpack/issues/6571
+              sideEffects: true,
+            },
+            // Adds support for CSS Modules, but using SASS
+            // using the extension .module.scss or .module.sass
+            {
+              test: sassModuleRegex,
+              use: getStyleLoaders(
+                {
+                  importLoaders: 3,
+                  sourceMap: isEnvProduction
+                    ? shouldUseSourceMap
+                    : isEnvDevelopment,
+                  modules: {
+                    mode: "local",
+                    getLocalIdent: getCSSModuleLocalIdent,
+                  },
+                },
+                "sass-loader"
+              ),
+            },
+            // "file" loader makes sure those assets get served by WebpackDevServer.
+            // When you `import` an asset, you get its (virtual) filename.
+            // In production, they would get copied to the `build` folder.
+            // This loader doesn't use a "test" so it will catch all modules
+            // that fall through the other loaders.
+            {
+              // Exclude `js` files to keep "css" loader working as it injects
+              // its runtime that would otherwise be processed through "file" loader.
+              // Also exclude `html` and `json` extensions so they get processed
+              // by webpacks internal loaders.
+              exclude: [/^$/, /\.(js|mjs|jsx|ts|tsx)$/, /\.html$/, /\.json$/],
+              type: "asset/resource",
+            },
+            // ** STOP ** Are you adding a new loader?
+            // Make sure to add the new loader(s) before the "file" loader.
+          ],
+        },
+      ].filter(Boolean),
+    },
+    plugins: [
+      // Generates an `index.html` file with the <script> injected.
+      new HtmlWebpackPlugin(
+        Object.assign(
+          {},
+          {
+            inject: true,
+            template: paths.appHtml,
+          },
+          isEnvProduction
+            ? {
+                minify: {
+                  removeComments: true,
+                  collapseWhitespace: true,
+                  removeRedundantAttributes: true,
+                  useShortDoctype: true,
+                  removeEmptyAttributes: true,
+                  removeStyleLinkTypeAttributes: true,
+                  keepClosingSlash: true,
+                  minifyJS: true,
+                  minifyCSS: true,
+                  minifyURLs: true,
+                },
+              }
+            : undefined
+        )
+      ),
+      // Inlines the webpack runtime script. This script is too small to warrant
+      // a network request.
+      // https://github.com/facebook/create-react-app/issues/5358
+      isEnvProduction &&
+        shouldInlineRuntimeChunk &&
+        new InlineChunkHtmlPlugin(HtmlWebpackPlugin, [/runtime-.+[.]js/]),
+      // Makes some environment variables available in index.html.
+      // The public URL is available as %PUBLIC_URL% in index.html, e.g.:
+      // <link rel="icon" href="%PUBLIC_URL%/favicon.ico">
+      // It will be an empty string unless you specify "homepage"
+      // in `package.json`, in which case it will be the pathname of that URL.
+      new InterpolateHtmlPlugin(HtmlWebpackPlugin, env.raw),
+      // This gives some necessary context to module not found errors, such as
+      // the requesting resource.
+      new ModuleNotFoundPlugin(paths.appPath),
+      // Makes some environment variables available to the JS code, for example:
+      // if (process.env.NODE_ENV === 'production') { ... }. See `./env.js`.
+      // It is absolutely essential that NODE_ENV is set to production
+      // during a production build.
+      // Otherwise React will be compiled in the very slow development mode.
+      new webpack.DefinePlugin(env.stringified),
+      // Experimental hot reloading for React .
+      // https://github.com/facebook/react/tree/main/packages/react-refresh
+      isEnvDevelopment &&
+        shouldUseReactRefresh &&
+        new ReactRefreshWebpackPlugin({
+          overlay: false,
+        }),
+      // Watcher doesn't work well if you mistype casing in a path so we use
+      // a plugin that prints an error when you attempt to do this.
+      // See https://github.com/facebook/create-react-app/issues/240
+      isEnvDevelopment && new CaseSensitivePathsPlugin(),
+      isEnvProduction &&
+        new MiniCssExtractPlugin({
+          // Options similar to the same options in webpackOptions.output
+          // both options are optional
+          filename: "static/css/[name].[contenthash:8].css",
+          chunkFilename: "static/css/[name].[contenthash:8].chunk.css",
+        }),
+      // Generate an asset manifest file with the following content:
+      // - "files" key: Mapping of all asset filenames to their corresponding
+      //   output file so that tools can pick it up without having to parse
+      //   `index.html`
+      // - "entrypoints" key: Array of files which are included in `index.html`,
+      //   can be used to reconstruct the HTML if necessary
+      new WebpackManifestPlugin({
+        fileName: "asset-manifest.json",
+        publicPath: paths.publicUrlOrPath,
+        generate: (seed, files, entrypoints) => {
+          const manifestFiles = files.reduce((manifest, file) => {
+            manifest[file.name] = file.path;
+            return manifest;
+          }, seed);
+          const entrypointFiles = entrypoints.main.filter(
+            (fileName) => !fileName.endsWith(".map")
+          );
+
+          return {
+            files: manifestFiles,
+            entrypoints: entrypointFiles,
+          };
+        },
+      }),
+      // Moment.js is an extremely popular library that bundles large locale files
+      // by default due to how webpack interprets its code. This is a practical
+      // solution that requires the user to opt into importing specific locales.
+      // https://github.com/jmblog/how-to-optimize-momentjs-with-webpack
+      // You can remove this if you don't use Moment.js:
+      new webpack.IgnorePlugin({
+        resourceRegExp: /^\.\/locale$/,
+        contextRegExp: /moment$/,
+      }),
+      // Generate a service worker script that will precache, and keep up to date,
+      // the HTML & assets that are part of the webpack build.
+      isEnvProduction &&
+        fs.existsSync(swSrc) &&
+        new WorkboxWebpackPlugin.InjectManifest({
+          swSrc,
+          dontCacheBustURLsMatching: /\.[0-9a-f]{8}\./,
+          exclude: [/\.map$/, /asset-manifest\.json$/, /LICENSE/],
+          // Bump up the default maximum size (2mb) that's precached,
+          // to make lazy-loading failure scenarios less likely.
+          // See https://github.com/cra-template/pwa/issues/13#issuecomment-722667270
+          maximumFileSizeToCacheInBytes: 5 * 1024 * 1024,
+        }),
+      // TypeScript type checking
+      useTypeScript &&
+        new ForkTsCheckerWebpackPlugin({
+          async: isEnvDevelopment,
+          typescript: {
+            typescriptPath: resolve.sync("typescript", {
+              basedir: paths.appNodeModules,
+            }),
+            configOverwrite: {
+              compilerOptions: {
+                sourceMap: isEnvProduction
+                  ? shouldUseSourceMap
+                  : isEnvDevelopment,
+                skipLibCheck: true,
+                inlineSourceMap: false,
+                declarationMap: false,
+                noEmit: true,
+                incremental: true,
+                tsBuildInfoFile: paths.appTsBuildInfoFile,
+              },
+            },
+            context: paths.appPath,
+            diagnosticOptions: {
+              syntactic: true,
+            },
+            mode: "write-references",
+            // profile: true,
+          },
+          issue: {
+            // This one is specifically to match during CI tests,
+            // as micromatch doesn't match
+            // '../cra-template-typescript/template/src/App.tsx'
+            // otherwise.
+            include: [
+              { file: "../**/src/**/*.{ts,tsx}" },
+              { file: "**/src/**/*.{ts,tsx}" },
+            ],
+            exclude: [
+              { file: "**/src/**/__tests__/**" },
+              { file: "**/src/**/?(*.){spec|test}.*" },
+              { file: "**/src/setupProxy.*" },
+              { file: "**/src/setupTests.*" },
+            ],
+          },
+          logger: {
+            infrastructure: "silent",
+          },
+        }),
+      !disableESLintPlugin &&
+        new ESLintPlugin({
+          // Plugin options
+          extensions: ["js", "mjs", "jsx", "ts", "tsx"],
+          formatter: require.resolve("react-dev-utils/eslintFormatter"),
+          eslintPath: require.resolve("eslint"),
+          failOnError: !(isEnvDevelopment && emitErrorsAsWarnings),
+          context: paths.appSrc,
+          cache: true,
+          cacheLocation: path.resolve(
+            paths.appNodeModules,
+            ".cache/.eslintcache"
+          ),
+          // ESLint class options
+          cwd: paths.appPath,
+          resolvePluginsRelativeTo: __dirname,
+          baseConfig: {
+            extends: [require.resolve("eslint-config-react-app/base")],
+            rules: {
+              ...(!hasJsxRuntime && {
+                "react/react-in-jsx-scope": "error",
+              }),
+            },
+          },
+        }),
+    ].filter(Boolean),
+    // Turn off performance processing because we utilize
+    // our own hints via the FileSizeReporter
+    performance: false,
+  };
+};

+ 9 - 0
config/webpack/persistentCache/createEnvironmentHash.js

@@ -0,0 +1,9 @@
+'use strict';
+const { createHash } = require('crypto');
+
+module.exports = env => {
+  const hash = createHash('md5');
+  hash.update(JSON.stringify(env));
+
+  return hash.digest('hex');
+};

+ 124 - 0
config/webpackDevServer.config.js

@@ -0,0 +1,124 @@
+"use strict";
+
+const fs = require("fs");
+const evalSourceMapMiddleware = require("react-dev-utils/evalSourceMapMiddleware");
+const noopServiceWorkerMiddleware = require("react-dev-utils/noopServiceWorkerMiddleware");
+const ignoredFiles = require("react-dev-utils/ignoredFiles");
+const redirectServedPath = require("react-dev-utils/redirectServedPathMiddleware");
+const paths = require("./paths");
+const getHttpsConfig = require("./getHttpsConfig");
+
+const host = process.env.HOST || "0.0.0.0";
+const sockHost = process.env.WDS_SOCKET_HOST;
+const sockPath = process.env.WDS_SOCKET_PATH; // default: '/ws'
+const sockPort = process.env.WDS_SOCKET_PORT;
+
+module.exports = function (proxy, allowedHost) {
+  const disableFirewall =
+    !proxy || process.env.DANGEROUSLY_DISABLE_HOST_CHECK === "true";
+  return {
+    // WebpackDevServer 2.4.3 introduced a security fix that prevents remote
+    // websites from potentially accessing local content through DNS rebinding:
+    // https://github.com/webpack/webpack-dev-server/issues/887
+    // https://medium.com/webpack/webpack-dev-server-middleware-security-issues-1489d950874a
+    // However, it made several existing use cases such as development in cloud
+    // environment or subdomains in development significantly more complicated:
+    // https://github.com/facebook/create-react-app/issues/2271
+    // https://github.com/facebook/create-react-app/issues/2233
+    // While we're investigating better solutions, for now we will take a
+    // compromise. Since our WDS configuration only serves files in the `public`
+    // folder we won't consider accessing them a vulnerability. However, if you
+    // use the `proxy` feature, it gets more dangerous because it can expose
+    // remote code execution vulnerabilities in backends like Django and Rails.
+    // So we will disable the host check normally, but enable it if you have
+    // specified the `proxy` setting. Finally, we let you override it if you
+    // really know what you're doing with a special environment variable.
+    // Note: ["localhost", ".localhost"] will support subdomains - but we might
+    // want to allow setting the allowedHosts manually for more complex setups
+    allowedHosts: disableFirewall ? "all" : [allowedHost],
+    headers: {
+      "Access-Control-Allow-Origin": "*",
+      "Access-Control-Allow-Methods": "*",
+      "Access-Control-Allow-Headers": "*",
+    },
+    // Enable gzip compression of generated files.
+    compress: true,
+    static: {
+      // By default WebpackDevServer serves physical files from current directory
+      // in addition to all the virtual build products that it serves from memory.
+      // This is confusing because those files won’t automatically be available in
+      // production build folder unless we copy them. However, copying the whole
+      // project directory is dangerous because we may expose sensitive files.
+      // Instead, we establish a convention that only files in `public` directory
+      // get served. Our build script will copy `public` into the `build` folder.
+      // In `index.html`, you can get URL of `public` folder with %PUBLIC_URL%:
+      // <link rel="icon" href="%PUBLIC_URL%/favicon.ico">
+      // In JavaScript code, you can access it with `process.env.PUBLIC_URL`.
+      // Note that we only recommend to use `public` folder as an escape hatch
+      // for files like `favicon.ico`, `manifest.json`, and libraries that are
+      // for some reason broken when imported through webpack. If you just want to
+      // use an image, put it in `src` and `import` it from JavaScript instead.
+      directory: paths.appPublic,
+      publicPath: [paths.publicUrlOrPath],
+      // By default files from `contentBase` will not trigger a page reload.
+      watch: {
+        // Reportedly, this avoids CPU overload on some systems.
+        // https://github.com/facebook/create-react-app/issues/293
+        // src/node_modules is not ignored to support absolute imports
+        // https://github.com/facebook/create-react-app/issues/1065
+        ignored: ignoredFiles(paths.appSrc),
+      },
+    },
+    client: {
+      webSocketURL: {
+        // Enable custom sockjs pathname for websocket connection to hot reloading server.
+        // Enable custom sockjs hostname, pathname and port for websocket connection
+        // to hot reloading server.
+        hostname: sockHost,
+        pathname: sockPath,
+        port: sockPort,
+      },
+      overlay: false,
+    },
+    devMiddleware: {
+      // It is important to tell WebpackDevServer to use the same "publicPath" path as
+      // we specified in the webpack config. When homepage is '.', default to serving
+      // from the root.
+      // remove last slash so user can land on `/test` instead of `/test/`
+      publicPath: paths.publicUrlOrPath.slice(0, -1),
+    },
+
+    https: getHttpsConfig(),
+    host,
+    historyApiFallback: {
+      // Paths with dots should still use the history fallback.
+      // See https://github.com/facebook/create-react-app/issues/387.
+      disableDotRule: true,
+      index: paths.publicUrlOrPath,
+    },
+    // `proxy` is run between `before` and `after` `webpack-dev-server` hooks
+    proxy,
+    onBeforeSetupMiddleware(devServer) {
+      // Keep `evalSourceMapMiddleware`
+      // middlewares before `redirectServedPath` otherwise will not have any effect
+      // This lets us fetch source contents from webpack for the error overlay
+      devServer.app.use(evalSourceMapMiddleware(devServer));
+
+      if (fs.existsSync(paths.proxySetup)) {
+        // This registers user provided middleware for proxy reasons
+        require(paths.proxySetup)(devServer.app);
+      }
+    },
+    onAfterSetupMiddleware(devServer) {
+      // Redirect to `PUBLIC_URL` or `homepage` from `package.json` if url not match
+      devServer.app.use(redirectServedPath(paths.publicUrlOrPath));
+
+      // This service worker file is effectively a 'no-op' that will reset any
+      // previous service worker registered for the same host:port combination.
+      // We do this in development to avoid hitting the production cache if
+      // it used the same host and port.
+      // https://github.com/facebook/create-react-app/issues/2272#issuecomment-302832432
+      devServer.app.use(noopServiceWorkerMiddleware(paths.publicUrlOrPath));
+    },
+  };
+};

+ 160 - 0
package.json

@@ -0,0 +1,160 @@
+{
+  "name": "sh-gbg-admin",
+  "description": "上海工业博物馆-后台",
+  "version": "0.1.0",
+  "private": true,
+  "dependencies": {
+    "@amap/amap-react": "^0.1.5",
+    "@babel/core": "^7.16.0",
+    "@pmmmwh/react-refresh-webpack-plugin": "^0.5.3",
+    "@svgr/webpack": "^5.5.0",
+    "@testing-library/jest-dom": "^5.16.5",
+    "@testing-library/react": "^13.4.0",
+    "@testing-library/user-event": "^13.5.0",
+    "@types/jest": "^27.5.2",
+    "@types/node": "^16.18.38",
+    "@types/react": "^18.2.14",
+    "@types/react-dom": "^18.2.6",
+    "antd": "^5.6.4",
+    "axios": "^1.4.0",
+    "babel-jest": "^27.4.2",
+    "babel-loader": "^8.2.3",
+    "babel-plugin-named-asset-import": "^0.3.8",
+    "babel-preset-react-app": "^10.0.1",
+    "bfj": "^7.0.2",
+    "browserslist": "^4.18.1",
+    "camelcase": "^6.2.1",
+    "case-sensitive-paths-webpack-plugin": "^2.4.0",
+    "classnames": "^2.3.2",
+    "css-loader": "^6.5.1",
+    "css-minimizer-webpack-plugin": "^3.2.0",
+    "dotenv": "^10.0.0",
+    "dotenv-expand": "^5.1.0",
+    "eslint": "^8.3.0",
+    "eslint-config-react-app": "^7.0.1",
+    "eslint-webpack-plugin": "^3.1.1",
+    "file-loader": "^6.2.0",
+    "fs-extra": "^10.0.0",
+    "html-webpack-plugin": "^5.5.0",
+    "identity-obj-proxy": "^3.0.0",
+    "jest": "^27.4.3",
+    "jest-resolve": "^27.4.2",
+    "jest-watch-typeahead": "^1.0.0",
+    "js-base64": "^3.7.5",
+    "lodash": "^4.17.21",
+    "mini-css-extract-plugin": "^2.4.5",
+    "postcss": "^8.4.4",
+    "postcss-flexbugs-fixes": "^5.0.2",
+    "postcss-loader": "^6.2.1",
+    "postcss-normalize": "^10.0.1",
+    "postcss-preset-env": "^7.0.1",
+    "prompts": "^2.4.2",
+    "react": "^18.2.0",
+    "react-app-polyfill": "^3.0.0",
+    "react-dev-utils": "^12.0.1",
+    "react-dom": "^18.2.0",
+    "react-redux": "^8.1.1",
+    "react-refresh": "^0.11.0",
+    "react-router-dom": "^6.14.1",
+    "redux": "^4.2.1",
+    "redux-devtools-extension": "^2.13.9",
+    "redux-thunk": "^2.4.2",
+    "resolve": "^1.20.0",
+    "resolve-url-loader": "^4.0.0",
+    "sass-loader": "^12.3.0",
+    "semver": "^7.3.5",
+    "source-map-loader": "^3.0.0",
+    "style-loader": "^3.3.1",
+    "tailwindcss": "^3.0.2",
+    "terser-webpack-plugin": "^5.2.5",
+    "typescript": "^4.9.5",
+    "web-vitals": "^2.1.4",
+    "webpack": "^5.64.4",
+    "webpack-dev-server": "^4.6.0",
+    "webpack-manifest-plugin": "^4.0.2",
+    "workbox-webpack-plugin": "^6.4.1"
+  },
+  "scripts": {
+    "start": "cross-env REACT_APP_BACKEND_URL=https://sit-shgybwg.4dage.com node scripts/start.js",
+    "build": "cross-env REACT_APP_BACKEND_URL=https://sit-shgybwg.4dage.com node scripts/build.js",
+    "test": "node scripts/test.js"
+  },
+  "eslintConfig": {
+    "extends": [
+      "react-app",
+      "react-app/jest"
+    ]
+  },
+  "browserslist": {
+    "production": [
+      ">0.2%",
+      "not dead",
+      "not op_mini all"
+    ],
+    "development": [
+      "last 1 chrome version",
+      "last 1 firefox version",
+      "last 1 safari version"
+    ]
+  },
+  "devDependencies": {
+    "@types/lodash": "^4.14.195",
+    "babel-plugin-import": "^1.13.6",
+    "cross-env": "^7.0.3",
+    "sass": "^1.63.6"
+  },
+  "jest": {
+    "roots": [
+      "<rootDir>/src"
+    ],
+    "collectCoverageFrom": [
+      "src/**/*.{js,jsx,ts,tsx}",
+      "!src/**/*.d.ts"
+    ],
+    "setupFiles": [
+      "react-app-polyfill/jsdom"
+    ],
+    "setupFilesAfterEnv": [],
+    "testMatch": [
+      "<rootDir>/src/**/__tests__/**/*.{js,jsx,ts,tsx}",
+      "<rootDir>/src/**/*.{spec,test}.{js,jsx,ts,tsx}"
+    ],
+    "testEnvironment": "jsdom",
+    "transform": {
+      "^.+\\.(js|jsx|mjs|cjs|ts|tsx)$": "<rootDir>/config/jest/babelTransform.js",
+      "^.+\\.css$": "<rootDir>/config/jest/cssTransform.js",
+      "^(?!.*\\.(js|jsx|mjs|cjs|ts|tsx|css|json)$)": "<rootDir>/config/jest/fileTransform.js"
+    },
+    "transformIgnorePatterns": [
+      "[/\\\\]node_modules[/\\\\].+\\.(js|jsx|mjs|cjs|ts|tsx)$",
+      "^.+\\.module\\.(css|sass|scss)$"
+    ],
+    "modulePaths": [],
+    "moduleNameMapper": {
+      "^react-native$": "react-native-web",
+      "^.+\\.module\\.(css|sass|scss)$": "identity-obj-proxy"
+    },
+    "moduleFileExtensions": [
+      "web.js",
+      "js",
+      "web.ts",
+      "ts",
+      "web.tsx",
+      "tsx",
+      "json",
+      "web.jsx",
+      "jsx",
+      "node"
+    ],
+    "watchPlugins": [
+      "jest-watch-typeahead/filename",
+      "jest-watch-typeahead/testname"
+    ],
+    "resetMocks": true
+  },
+  "babel": {
+    "presets": [
+      "react-app"
+    ]
+  }
+}

BIN
public/favicon.ico


BIN
public/fonts/SOURCEHANSERIFCN-BOLD.OTF


BIN
public/fonts/SOURCEHANSERIFCN-REGULAR.OTF


+ 31 - 0
public/index.html

@@ -0,0 +1,31 @@
+<!DOCTYPE html>
+<html lang="en">
+  <head>
+    <meta content="text/html; charset=utf-8" http-equiv="Content-Type" />
+    <meta
+      content="width=device-width,initial-scale=1,user-scalable=no"
+      name="viewport"
+    />
+    <meta name="apple-mobile-web-app-capable" content="yes" />
+    <meta name="apple-touch-fullscreen" content="yes" />
+    <meta name="format-detection" content="telephone=no,address=no" />
+    <meta name="apple-mobile-web-app-status-bar-style" content="white" />
+    <meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1" />
+    <link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
+    <title>上海市工业博物馆</title>
+  </head>
+  <body>
+    <noscript>You need to enable JavaScript to run this app.</noscript>
+    <div id="root"></div>
+    <!--
+      This HTML file is a template.
+      If you open it directly in the browser, you will see an empty page.
+
+      You can add webfonts, meta tags, or analytics to this file.
+      The build step will place the bundled scripts into the <body> tag.
+
+      To begin the development, run `npm start` or `yarn start`.
+      To create a production bundle, use `npm run build` or `yarn build`.
+    -->
+  </body>
+</html>

+ 217 - 0
scripts/build.js

@@ -0,0 +1,217 @@
+'use strict';
+
+// Do this as the first thing so that any code reading it knows the right env.
+process.env.BABEL_ENV = 'production';
+process.env.NODE_ENV = 'production';
+
+// Makes the script crash on unhandled rejections instead of silently
+// ignoring them. In the future, promise rejections that are not handled will
+// terminate the Node.js process with a non-zero exit code.
+process.on('unhandledRejection', err => {
+  throw err;
+});
+
+// Ensure environment variables are read.
+require('../config/env');
+
+const path = require('path');
+const chalk = require('react-dev-utils/chalk');
+const fs = require('fs-extra');
+const bfj = require('bfj');
+const webpack = require('webpack');
+const configFactory = require('../config/webpack.config');
+const paths = require('../config/paths');
+const checkRequiredFiles = require('react-dev-utils/checkRequiredFiles');
+const formatWebpackMessages = require('react-dev-utils/formatWebpackMessages');
+const printHostingInstructions = require('react-dev-utils/printHostingInstructions');
+const FileSizeReporter = require('react-dev-utils/FileSizeReporter');
+const printBuildError = require('react-dev-utils/printBuildError');
+
+const measureFileSizesBeforeBuild =
+  FileSizeReporter.measureFileSizesBeforeBuild;
+const printFileSizesAfterBuild = FileSizeReporter.printFileSizesAfterBuild;
+const useYarn = fs.existsSync(paths.yarnLockFile);
+
+// These sizes are pretty large. We'll warn for bundles exceeding them.
+const WARN_AFTER_BUNDLE_GZIP_SIZE = 512 * 1024;
+const WARN_AFTER_CHUNK_GZIP_SIZE = 1024 * 1024;
+
+const isInteractive = process.stdout.isTTY;
+
+// Warn and crash if required files are missing
+if (!checkRequiredFiles([paths.appHtml, paths.appIndexJs])) {
+  process.exit(1);
+}
+
+const argv = process.argv.slice(2);
+const writeStatsJson = argv.indexOf('--stats') !== -1;
+
+// Generate configuration
+const config = configFactory('production');
+
+// We require that you explicitly set browsers and do not fall back to
+// browserslist defaults.
+const { checkBrowsers } = require('react-dev-utils/browsersHelper');
+checkBrowsers(paths.appPath, isInteractive)
+  .then(() => {
+    // First, read the current file sizes in build directory.
+    // This lets us display how much they changed later.
+    return measureFileSizesBeforeBuild(paths.appBuild);
+  })
+  .then(previousFileSizes => {
+    // Remove all content but keep the directory so that
+    // if you're in it, you don't end up in Trash
+    fs.emptyDirSync(paths.appBuild);
+    // Merge with the public folder
+    copyPublicFolder();
+    // Start the webpack build
+    return build(previousFileSizes);
+  })
+  .then(
+    ({ stats, previousFileSizes, warnings }) => {
+      if (warnings.length) {
+        console.log(chalk.yellow('Compiled with warnings.\n'));
+        console.log(warnings.join('\n\n'));
+        console.log(
+          '\nSearch for the ' +
+            chalk.underline(chalk.yellow('keywords')) +
+            ' to learn more about each warning.'
+        );
+        console.log(
+          'To ignore, add ' +
+            chalk.cyan('// eslint-disable-next-line') +
+            ' to the line before.\n'
+        );
+      } else {
+        console.log(chalk.green('Compiled successfully.\n'));
+      }
+
+      console.log('File sizes after gzip:\n');
+      printFileSizesAfterBuild(
+        stats,
+        previousFileSizes,
+        paths.appBuild,
+        WARN_AFTER_BUNDLE_GZIP_SIZE,
+        WARN_AFTER_CHUNK_GZIP_SIZE
+      );
+      console.log();
+
+      const appPackage = require(paths.appPackageJson);
+      const publicUrl = paths.publicUrlOrPath;
+      const publicPath = config.output.publicPath;
+      const buildFolder = path.relative(process.cwd(), paths.appBuild);
+      printHostingInstructions(
+        appPackage,
+        publicUrl,
+        publicPath,
+        buildFolder,
+        useYarn
+      );
+    },
+    err => {
+      const tscCompileOnError = process.env.TSC_COMPILE_ON_ERROR === 'true';
+      if (tscCompileOnError) {
+        console.log(
+          chalk.yellow(
+            'Compiled with the following type errors (you may want to check these before deploying your app):\n'
+          )
+        );
+        printBuildError(err);
+      } else {
+        console.log(chalk.red('Failed to compile.\n'));
+        printBuildError(err);
+        process.exit(1);
+      }
+    }
+  )
+  .catch(err => {
+    if (err && err.message) {
+      console.log(err.message);
+    }
+    process.exit(1);
+  });
+
+// Create the production build and print the deployment instructions.
+function build(previousFileSizes) {
+  console.log('Creating an optimized production build...');
+
+  const compiler = webpack(config);
+  return new Promise((resolve, reject) => {
+    compiler.run((err, stats) => {
+      let messages;
+      if (err) {
+        if (!err.message) {
+          return reject(err);
+        }
+
+        let errMessage = err.message;
+
+        // Add additional information for postcss errors
+        if (Object.prototype.hasOwnProperty.call(err, 'postcssNode')) {
+          errMessage +=
+            '\nCompileError: Begins at CSS selector ' +
+            err['postcssNode'].selector;
+        }
+
+        messages = formatWebpackMessages({
+          errors: [errMessage],
+          warnings: [],
+        });
+      } else {
+        messages = formatWebpackMessages(
+          stats.toJson({ all: false, warnings: true, errors: true })
+        );
+      }
+      if (messages.errors.length) {
+        // Only keep the first error. Others are often indicative
+        // of the same problem, but confuse the reader with noise.
+        if (messages.errors.length > 1) {
+          messages.errors.length = 1;
+        }
+        return reject(new Error(messages.errors.join('\n\n')));
+      }
+      if (
+        process.env.CI &&
+        (typeof process.env.CI !== 'string' ||
+          process.env.CI.toLowerCase() !== 'false') &&
+        messages.warnings.length
+      ) {
+        // Ignore sourcemap warnings in CI builds. See #8227 for more info.
+        const filteredWarnings = messages.warnings.filter(
+          w => !/Failed to parse source map/.test(w)
+        );
+        if (filteredWarnings.length) {
+          console.log(
+            chalk.yellow(
+              '\nTreating warnings as errors because process.env.CI = true.\n' +
+                'Most CI servers set it automatically.\n'
+            )
+          );
+          return reject(new Error(filteredWarnings.join('\n\n')));
+        }
+      }
+
+      const resolveArgs = {
+        stats,
+        previousFileSizes,
+        warnings: messages.warnings,
+      };
+
+      if (writeStatsJson) {
+        return bfj
+          .write(paths.appBuild + '/bundle-stats.json', stats.toJson())
+          .then(() => resolve(resolveArgs))
+          .catch(error => reject(new Error(error)));
+      }
+
+      return resolve(resolveArgs);
+    });
+  });
+}
+
+function copyPublicFolder() {
+  fs.copySync(paths.appPublic, paths.appBuild, {
+    dereference: true,
+    filter: file => file !== paths.appHtml,
+  });
+}

+ 154 - 0
scripts/start.js

@@ -0,0 +1,154 @@
+'use strict';
+
+// Do this as the first thing so that any code reading it knows the right env.
+process.env.BABEL_ENV = 'development';
+process.env.NODE_ENV = 'development';
+
+// Makes the script crash on unhandled rejections instead of silently
+// ignoring them. In the future, promise rejections that are not handled will
+// terminate the Node.js process with a non-zero exit code.
+process.on('unhandledRejection', err => {
+  throw err;
+});
+
+// Ensure environment variables are read.
+require('../config/env');
+
+const fs = require('fs');
+const chalk = require('react-dev-utils/chalk');
+const webpack = require('webpack');
+const WebpackDevServer = require('webpack-dev-server');
+const clearConsole = require('react-dev-utils/clearConsole');
+const checkRequiredFiles = require('react-dev-utils/checkRequiredFiles');
+const {
+  choosePort,
+  createCompiler,
+  prepareProxy,
+  prepareUrls,
+} = require('react-dev-utils/WebpackDevServerUtils');
+const openBrowser = require('react-dev-utils/openBrowser');
+const semver = require('semver');
+const paths = require('../config/paths');
+const configFactory = require('../config/webpack.config');
+const createDevServerConfig = require('../config/webpackDevServer.config');
+const getClientEnvironment = require('../config/env');
+const react = require(require.resolve('react', { paths: [paths.appPath] }));
+
+const env = getClientEnvironment(paths.publicUrlOrPath.slice(0, -1));
+const useYarn = fs.existsSync(paths.yarnLockFile);
+const isInteractive = process.stdout.isTTY;
+
+// Warn and crash if required files are missing
+if (!checkRequiredFiles([paths.appHtml, paths.appIndexJs])) {
+  process.exit(1);
+}
+
+// Tools like Cloud9 rely on this.
+const DEFAULT_PORT = parseInt(process.env.PORT, 10) || 3000;
+const HOST = process.env.HOST || '0.0.0.0';
+
+if (process.env.HOST) {
+  console.log(
+    chalk.cyan(
+      `Attempting to bind to HOST environment variable: ${chalk.yellow(
+        chalk.bold(process.env.HOST)
+      )}`
+    )
+  );
+  console.log(
+    `If this was unintentional, check that you haven't mistakenly set it in your shell.`
+  );
+  console.log(
+    `Learn more here: ${chalk.yellow('https://cra.link/advanced-config')}`
+  );
+  console.log();
+}
+
+// We require that you explicitly set browsers and do not fall back to
+// browserslist defaults.
+const { checkBrowsers } = require('react-dev-utils/browsersHelper');
+checkBrowsers(paths.appPath, isInteractive)
+  .then(() => {
+    // We attempt to use the default port but if it is busy, we offer the user to
+    // run on a different port. `choosePort()` Promise resolves to the next free port.
+    return choosePort(HOST, DEFAULT_PORT);
+  })
+  .then(port => {
+    if (port == null) {
+      // We have not found a port.
+      return;
+    }
+
+    const config = configFactory('development');
+    const protocol = process.env.HTTPS === 'true' ? 'https' : 'http';
+    const appName = require(paths.appPackageJson).name;
+
+    const useTypeScript = fs.existsSync(paths.appTsConfig);
+    const urls = prepareUrls(
+      protocol,
+      HOST,
+      port,
+      paths.publicUrlOrPath.slice(0, -1)
+    );
+    // Create a webpack compiler that is configured with custom messages.
+    const compiler = createCompiler({
+      appName,
+      config,
+      urls,
+      useYarn,
+      useTypeScript,
+      webpack,
+    });
+    // Load proxy config
+    const proxySetting = require(paths.appPackageJson).proxy;
+    const proxyConfig = prepareProxy(
+      proxySetting,
+      paths.appPublic,
+      paths.publicUrlOrPath
+    );
+    // Serve webpack assets generated by the compiler over a web server.
+    const serverConfig = {
+      ...createDevServerConfig(proxyConfig, urls.lanUrlForConfig),
+      host: HOST,
+      port,
+    };
+    const devServer = new WebpackDevServer(serverConfig, compiler);
+    // Launch WebpackDevServer.
+    devServer.startCallback(() => {
+      if (isInteractive) {
+        clearConsole();
+      }
+
+      if (env.raw.FAST_REFRESH && semver.lt(react.version, '16.10.0')) {
+        console.log(
+          chalk.yellow(
+            `Fast Refresh requires React 16.10 or higher. You are using React ${react.version}.`
+          )
+        );
+      }
+
+      console.log(chalk.cyan('Starting the development server...\n'));
+      openBrowser(urls.localUrlForBrowser);
+    });
+
+    ['SIGINT', 'SIGTERM'].forEach(function (sig) {
+      process.on(sig, function () {
+        devServer.close();
+        process.exit();
+      });
+    });
+
+    if (process.env.CI !== 'true') {
+      // Gracefully exit when stdin ends
+      process.stdin.on('end', function () {
+        devServer.close();
+        process.exit();
+      });
+    }
+  })
+  .catch(err => {
+    if (err && err.message) {
+      console.log(err.message);
+    }
+    process.exit(1);
+  });

+ 52 - 0
scripts/test.js

@@ -0,0 +1,52 @@
+'use strict';
+
+// Do this as the first thing so that any code reading it knows the right env.
+process.env.BABEL_ENV = 'test';
+process.env.NODE_ENV = 'test';
+process.env.PUBLIC_URL = '';
+
+// Makes the script crash on unhandled rejections instead of silently
+// ignoring them. In the future, promise rejections that are not handled will
+// terminate the Node.js process with a non-zero exit code.
+process.on('unhandledRejection', err => {
+  throw err;
+});
+
+// Ensure environment variables are read.
+require('../config/env');
+
+const jest = require('jest');
+const execSync = require('child_process').execSync;
+let argv = process.argv.slice(2);
+
+function isInGitRepository() {
+  try {
+    execSync('git rev-parse --is-inside-work-tree', { stdio: 'ignore' });
+    return true;
+  } catch (e) {
+    return false;
+  }
+}
+
+function isInMercurialRepository() {
+  try {
+    execSync('hg --cwd . root', { stdio: 'ignore' });
+    return true;
+  } catch (e) {
+    return false;
+  }
+}
+
+// Watch unless on CI or explicitly running all tests
+if (
+  !process.env.CI &&
+  argv.indexOf('--watchAll') === -1 &&
+  argv.indexOf('--watchAll=false') === -1
+) {
+  // https://github.com/facebook/create-react-app/issues/5210
+  const hasSourceControl = isInGitRepository() || isInMercurialRepository();
+  argv.push(hasSourceControl ? '--watch' : '--watchAll');
+}
+
+
+jest.run(argv);

+ 93 - 0
src/App.scss

@@ -0,0 +1,93 @@
+@import "./theme.scss";
+
+:root {
+  --primary-color: #{$primaryColor};
+  --second-color: #{$secondColor};
+  --border-color: #ececec;
+  --index-normal: 1;
+  --index-top: 1000;
+  --index-popper: 2000;
+}
+
+body,
+ol,
+ul,
+h1,
+h2,
+h3,
+h4,
+h5,
+h6,
+p,
+th,
+td,
+dl,
+dd,
+form,
+fieldset,
+legend,
+input,
+textarea,
+select {
+  margin: 0;
+  padding: 0;
+}
+
+ul {
+  list-style: none;
+}
+
+body {
+  font-family: Source Han Sans CN-Regular;
+  font-size: 16px;
+}
+
+.limit-line {
+  display: -webkit-box;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  -webkit-line-clamp: 1;
+  -webkit-box-orient: vertical;
+  word-break: break-all;
+  word-wrap: break-word;
+}
+
+.line-2 {
+  -webkit-line-clamp: 2;
+}
+
+.w220 {
+  width: 220px;
+}
+
+.w450 {
+  width: 450px;
+}
+
+* {
+  box-sizing: border-box;
+}
+
+@font-face {
+  font-family: "Source Han Serif CN-Bold";
+  src: url("~/public/fonts/SOURCEHANSERIFCN-BOLD.OTF") format("opentype");
+  font-display: swap;
+}
+
+@font-face {
+  font-family: "Source Han Sans CN-Regular";
+  src: url("~/public/fonts/SOURCEHANSERIFCN-REGULAR.OTF") format("opentype");
+  font-display: swap;
+}
+
+.ant-btn {
+  box-shadow: none !important;
+}
+
+.ant-input:focus {
+  box-shadow: none;
+}
+
+.c-table {
+  margin-top: 20px;
+}

+ 30 - 0
src/App.tsx

@@ -0,0 +1,30 @@
+import React from "react";
+import { Route, Routes } from "react-router-dom";
+import { ConfigProvider } from "antd";
+import zhCN from "antd/locale/zh_CN";
+import { MemoSpinLoding } from "./components";
+import theme from "./theme.scss";
+import "./App.scss";
+
+const Login = React.lazy(() => import("./pages/Login"));
+const Layout = React.lazy(() => import("./pages/Layout"));
+
+function App() {
+  return (
+    <div className="App">
+      <ConfigProvider
+        locale={zhCN}
+        theme={{ token: { colorPrimary: theme.primaryColor } }}
+      >
+        <React.Suspense fallback={<MemoSpinLoding />}>
+          <Routes>
+            <Route path="/login" Component={Login} />
+            <Route path="/*" Component={Layout} />
+          </Routes>
+        </React.Suspense>
+      </ConfigProvider>
+    </div>
+  );
+}
+
+export default App;

+ 31 - 0
src/api/history.ts

@@ -0,0 +1,31 @@
+import {
+  AddHistoryParams,
+  GetHistoryDetailResponse,
+  GetHistoryListParams,
+  GetHistoryListResponse,
+  HistoryDictItem,
+} from "@/types";
+import service from "@/utils/services";
+
+export const historyApi = {
+  getHistoryDictList: () => {
+    return service.get<HistoryDictItem[]>("/api/cms/history/getDict");
+  },
+  getList(data: GetHistoryListParams) {
+    return service.post<GetHistoryListResponse>(
+      "/api/cms/history/pageList",
+      data
+    );
+  },
+  add: (data: AddHistoryParams) => {
+    return service.post("/api/cms/history/save", data);
+  },
+  del: (id: number) => {
+    return service.get(`/api/cms/history/remove/${id}`);
+  },
+  getDetail: (id: string) => {
+    return service.get<GetHistoryDetailResponse>(
+      `/api/cms/history/detail/${id}`
+    );
+  },
+};

+ 21 - 0
src/api/index.ts

@@ -0,0 +1,21 @@
+import { LoginRequest, LoginResponse, UpdatePwdRequest } from "@/types";
+import service from "@/utils/services";
+
+export const login = (data: LoginRequest) => {
+  return service.post<LoginResponse>("/api/admin/login", data);
+};
+
+export const logoutApi = () => {
+  return service.get("/api/admin/logout");
+};
+
+export const updatePwd = (data: UpdatePwdRequest) => {
+  return service.post("/api/sys/user/updatePwd", data);
+};
+
+export * from "./overview";
+export * from "./history";
+export * from "./log";
+export * from "./user";
+export * from "./industrial-meta";
+export * from "./weapon";

+ 14 - 0
src/api/industrial-meta.ts

@@ -0,0 +1,14 @@
+import { GetIndustrialMetaListParams, IndustrialMetaItemType } from "@/types";
+import service from "@/utils/services";
+
+export const industrialMetaApi = {
+  getList(params: GetIndustrialMetaListParams) {
+    return service.post("/api/cms/meta/pageList", params);
+  },
+  getDetail(id: string) {
+    return service.get<IndustrialMetaItemType>(`/api/cms/meta/detail/${id}`);
+  },
+  edit(params: IndustrialMetaItemType) {
+    return service.post("/api/cms/meta/save", params);
+  },
+};

+ 8 - 0
src/api/log.ts

@@ -0,0 +1,8 @@
+import { GetLogListParams } from "@/types";
+import service from "@/utils/services";
+
+export const logApi = {
+  getList(params: GetLogListParams) {
+    return service.post("/api/sys/log/list", params);
+  },
+};

+ 27 - 0
src/api/overview.ts

@@ -0,0 +1,27 @@
+import {
+  GetOverviewDetailResponse,
+  GetOverviewListParams,
+  GetOverviewListResponse,
+  OverviewParams,
+} from "@/types";
+import service from "@/utils/services";
+
+export const overviewApi = {
+  getList(data: GetOverviewListParams) {
+    return service.post<GetOverviewListResponse>(
+      "/api/cms/company/pageList",
+      data
+    );
+  },
+  getDetail: (id: string) => {
+    return service.get<GetOverviewDetailResponse>(
+      `/api/cms/company/detail/${id}`
+    );
+  },
+  save(params: OverviewParams) {
+    return service.post("/api/cms/company/save", params);
+  },
+  del: (id: number) => {
+    return service.get(`/api/cms/company/remove/${id}`);
+  },
+};

+ 23 - 0
src/api/user.ts

@@ -0,0 +1,23 @@
+import { GetUserListParams, SaveUserType } from "@/types";
+import service from "@/utils/services";
+
+export const userApi = {
+  getList(params: GetUserListParams) {
+    return service.post("/api/sys/user/list", params);
+  },
+  handleType(id: number, isEnabled: number) {
+    return service.get(`/api/sys/user/editStatus/${id}/${isEnabled}`);
+  },
+  del(ids: number) {
+    return service.get(`/api/sys/user/removes/${ids}`);
+  },
+  resetPwd(id: number) {
+    return service.get(`/api/sys/user/resetPass/${id}`);
+  },
+  getDetail(id: number) {
+    return service.get(`/api/sys/user/detail/${id}`);
+  },
+  edit(params: SaveUserType) {
+    return service.post("/api/sys/user/save", params);
+  },
+};

+ 20 - 0
src/api/weapon.ts

@@ -0,0 +1,20 @@
+import { AddWeaponParams, HistoryDictItem } from "@/types";
+import service from "@/utils/services";
+
+export const weaponApi = {
+  getDictList() {
+    return service.get<HistoryDictItem[]>("/api/cms/goods/getDict");
+  },
+  getList(data: any) {
+    return service.post<any>("/api/cms/goods/pageList", data);
+  },
+  add: (data: AddWeaponParams) => {
+    return service.post("/api/cms/goods/save", data);
+  },
+  del: (id: number) => {
+    return service.get(`/api/cms/goods/remove/${id}`);
+  },
+  getDetail: (id: string) => {
+    return service.get<any>(`/api/cms/goods/detail/${id}`);
+  },
+};

BIN
src/assets/images/logo.png


+ 8 - 0
src/components/DageFileCheckbox/constants.ts

@@ -0,0 +1,8 @@
+import { DageUploadType } from "../DageUpload";
+
+export const DAGE_FILE_CHECKBOX_OPTIONS = [
+  { label: "模型", value: DageUploadType.MODEL },
+  { label: "图片", value: DageUploadType.IMG },
+  { label: "音频", value: DageUploadType.AUDIO },
+  { label: "视频", value: DageUploadType.VIDEO },
+];

+ 32 - 0
src/components/DageFileCheckbox/index.module.scss

@@ -0,0 +1,32 @@
+.dageFileContainer {
+  margin-top: 15px;
+}
+
+.dageFileCheckboxGroup {
+  display: flex;
+  align-items: center;
+  height: 32px;
+}
+
+.dageFileItem {
+  display: flex;
+  align-items: flex-start;
+
+  &:not(:first-child) {
+    margin-top: 15px;
+  }
+  > span {
+    margin-right: 5px;
+    line-height: 32px;
+
+    &::before {
+      display: inline-block;
+      margin-inline-end: 4px;
+      color: #ff4d4f;
+      font-size: 14px;
+      font-family: SimSun, sans-serif;
+      line-height: 1;
+      content: "*";
+    }
+  }
+}

+ 206 - 0
src/components/DageFileCheckbox/index.tsx

@@ -0,0 +1,206 @@
+import { Checkbox } from "antd";
+import {
+  forwardRef,
+  useCallback,
+  useEffect,
+  useImperativeHandle,
+  useMemo,
+  useState,
+} from "react";
+import { CheckboxValueType } from "antd/lib/checkbox/Group";
+import style from "./index.module.scss";
+import { DAGE_FILE_CHECKBOX_OPTIONS } from "./constants";
+import { DageFileCheckboxMethods, DageFileCheckboxProps } from "./types";
+import {
+  DageFileResponseType,
+  DageUpload,
+  DageUploadType,
+} from "../DageUpload";
+
+export const DageFileCheckbox = forwardRef<
+  DageFileCheckboxMethods,
+  DageFileCheckboxProps
+>(({ value, action, disabled, onChange, onFileChange }, ref) => {
+  const checkboxVal = useMemo(() => (value ? value.split(",") : []), [value]);
+  const [modelFiles, setModelFiles] = useState<DageFileResponseType[]>([]);
+  const [imgFiles, setImgFiles] = useState<DageFileResponseType[]>([]);
+  const [videoFiles, setVideoFiles] = useState<DageFileResponseType[]>([]);
+  const [audioFiles, setAudioFiles] = useState<DageFileResponseType[]>([]);
+
+  useImperativeHandle(ref, () => ({
+    async validate() {
+      if (hasModel && !modelFiles.length) {
+        throw new Error("模型文件不能为空");
+      } else if (hasImg && !imgFiles.length) {
+        throw new Error("图片文件不能为空");
+      } else if (hasAudio && !videoFiles.length) {
+        throw new Error("视频文件不能为空");
+      } else if (hasVideo && !audioFiles.length) {
+        throw new Error("音频文件不能为空");
+      }
+    },
+    setFileList(list) {
+      const stack: Record<string, DageFileResponseType[]> = {
+        [DageUploadType.MODEL]: [],
+        [DageUploadType.IMG]: [],
+        [DageUploadType.AUDIO]: [],
+        [DageUploadType.VIDEO]: [],
+      };
+
+      list.forEach((item) => {
+        stack[item.dType].push(item);
+      });
+
+      Object.keys(stack).forEach((key) => {
+        const list = stack[key];
+        if (!list.length) return;
+        switch (key) {
+          case DageUploadType.MODEL:
+            setModelFiles(list);
+            break;
+          case DageUploadType.IMG:
+            setImgFiles(list);
+            break;
+          case DageUploadType.AUDIO:
+            setAudioFiles(list);
+            break;
+          case DageUploadType.VIDEO:
+            setVideoFiles(list);
+            break;
+        }
+      });
+    },
+  }));
+
+  const hasModel = useMemo(
+    () => checkboxVal.includes(DageUploadType.MODEL),
+    [checkboxVal]
+  );
+  const hasImg = useMemo(
+    () => checkboxVal.includes(DageUploadType.IMG),
+    [checkboxVal]
+  );
+  const hasAudio = useMemo(
+    () => checkboxVal.includes(DageUploadType.AUDIO),
+    [checkboxVal]
+  );
+  const hasVideo = useMemo(
+    () => checkboxVal.includes(DageUploadType.VIDEO),
+    [checkboxVal]
+  );
+
+  const handleCheckbox = useCallback(
+    (val: CheckboxValueType[]) => {
+      onChange?.(val.join(","));
+    },
+    [onChange]
+  );
+
+  const handleFileChange = useCallback(
+    (
+      method: (v: DageFileResponseType[]) => void,
+      list: DageFileResponseType[]
+    ) => {
+      console.log(list);
+      method(list);
+    },
+    []
+  );
+
+  useEffect(() => {
+    onFileChange?.([
+      ...(hasModel ? modelFiles : []),
+      ...(hasImg ? imgFiles : []),
+      ...(hasVideo ? videoFiles : []),
+      ...(hasAudio ? audioFiles : []),
+    ]);
+  }, [
+    modelFiles,
+    imgFiles,
+    videoFiles,
+    audioFiles,
+    hasModel,
+    hasImg,
+    hasVideo,
+    hasAudio,
+    onFileChange,
+  ]);
+
+  return (
+    <div className={style.dageFileCheckbox}>
+      <Checkbox.Group
+        disabled={disabled}
+        className={style.dageFileCheckboxGroup}
+        value={checkboxVal}
+        options={DAGE_FILE_CHECKBOX_OPTIONS}
+        onChange={handleCheckbox}
+      />
+
+      <div className={style.dageFileContainer}>
+        {hasModel && (
+          <div className={style.dageFileItem}>
+            <span>模型:</span>
+            <DageUpload
+              disabled={disabled}
+              value={modelFiles}
+              action={action}
+              type={DageUploadType.MODEL}
+              maxCount={1}
+              maxSize={500}
+              tips="仅支持4dage格式的模型文件,大小不能超过500M"
+              onChange={handleFileChange.bind(undefined, setModelFiles)}
+            />
+          </div>
+        )}
+        {hasImg && (
+          <div className={style.dageFileItem}>
+            <span>图片:</span>
+            <DageUpload
+              disabled={disabled}
+              value={imgFiles}
+              action={action}
+              type={DageUploadType.IMG}
+              maxCount={9}
+              maxSize={20}
+              tips="支持png、jpg和jpeg格式;最大20M,最多9张"
+              onChange={handleFileChange.bind(undefined, setImgFiles)}
+            />
+          </div>
+        )}
+        {hasAudio && (
+          <div className={style.dageFileItem}>
+            <span>音频:</span>
+            <DageUpload
+              disabled={disabled}
+              value={audioFiles}
+              action={action}
+              type={DageUploadType.AUDIO}
+              maxCount={1}
+              maxSize={10}
+              tips="仅支持MP3格式的音频文件,大小不得超过10MB"
+              onChange={handleFileChange.bind(undefined, setAudioFiles)}
+            />
+          </div>
+        )}
+        {hasVideo && (
+          <div className={style.dageFileItem}>
+            <span>视频:</span>
+            <DageUpload
+              disabled={disabled}
+              value={videoFiles}
+              action={action}
+              type={DageUploadType.VIDEO}
+              maxCount={1}
+              maxSize={500}
+              tips="仅支持MP4格式的视频文件,大小不得超过500MB"
+              onChange={handleFileChange.bind(undefined, setVideoFiles)}
+            />
+          </div>
+        )}
+      </div>
+    </div>
+  );
+});
+
+export * from "./types";
+export * from "./constants";

+ 23 - 0
src/components/DageFileCheckbox/types.ts

@@ -0,0 +1,23 @@
+import { RuleObject } from "antd/es/form";
+import { DageFileResponseType } from "../DageUpload";
+
+export interface DageFileCheckboxMethods {
+  validate(
+    rule: RuleObject,
+    value: any,
+    callback: (error?: string | undefined) => void
+  ): Promise<void>;
+  setFileList(list: DageFileResponseType[]): void;
+}
+
+export interface DageFileCheckboxProps {
+  /**
+   * 选中文件类型
+   * @example model,video,audio
+   */
+  value?: string;
+  action: string;
+  disabled?: boolean;
+  onChange?(v: string): void;
+  onFileChange?(list: DageFileResponseType[]): void;
+}

+ 221 - 0
src/components/DageMap/index.tsx

@@ -0,0 +1,221 @@
+import {
+  forwardRef,
+  useCallback,
+  useImperativeHandle,
+  useMemo,
+  useRef,
+  useState,
+} from "react";
+import { Amap, Marker } from "@amap/amap-react";
+import { AutoComplete, Button, Input, Space } from "antd";
+import axios from "axios";
+import { debounce } from "lodash";
+import {
+  AMapGeocoderGetAddressResult,
+  AMapTipsResponse,
+  DageMapMethods,
+  DageMapPoisType,
+  DageMapProps,
+} from "./types";
+import { DageMapGeocoder } from "./plugins";
+
+export const DageMap = forwardRef<DageMapMethods, DageMapProps>(
+  (
+    { latitude, longitude, disabled, address, city, onChange, onMapComplete },
+    ref
+  ) => {
+    const mapRef = useRef<AMap.Map_2>();
+    const [loaded, setLoaded] = useState(false);
+    const [lng, setLng] = useState<number>(longitude);
+    const [lat, setLat] = useState<number>(latitude);
+    const [options, setOptions] = useState<DageMapPoisType[]>([]);
+    const position = useMemo(
+      () => [longitude, latitude],
+      [longitude, latitude]
+    );
+    const _onChange = useRef(onChange).current;
+
+    useImperativeHandle(ref, () => ({
+      mapRef: mapRef.current,
+    }));
+
+    const handleSearch = useMemo(
+      () =>
+        debounce(async (value: string) => {
+          if (!value) {
+            setOptions([]);
+            return;
+          }
+
+          const { data } = await axios.get<AMapTipsResponse>(
+            `https://restapi.amap.com/v3/assistant/inputtips?key=e98c2a32f09d82868faace9329e58c26&keywords=${value}&city=${city}&citylimit=true`
+          );
+          setOptions(data.tips);
+        }, 500),
+      [city]
+    );
+
+    const handleClick = useCallback(
+      (e: AMap.Map_2, data: any) => {
+        if (disabled) return;
+
+        const { lng, lat } = data.lnglat;
+        setLat(lat);
+        setLng(lng);
+        _onChange?.({
+          lat,
+          lng,
+        });
+      },
+      [_onChange, disabled]
+    );
+
+    /**
+     * 地图初始化完成
+     */
+    const handleComplete = useCallback(() => {
+      setLoaded(true);
+      mapRef.current?.setCenter([longitude, latitude], false, 1000);
+      onMapComplete?.();
+    }, [longitude, latitude, onMapComplete]);
+
+    const handleMarker = useMemo(
+      () =>
+        debounce((e: any) => {
+          const [lng, lat] = e._position;
+          setLat(lat);
+          setLng(lng);
+          _onChange?.({
+            lat,
+            lng,
+          });
+        }, 200),
+      [_onChange]
+    );
+
+    const handlePosition = useCallback(
+      (long = lng, lati = lat) => {
+        if (!long || !lati) return;
+        _onChange?.({
+          lat: lati,
+          lng: long,
+        });
+        mapRef.current?.setCenter([long, lati], false, 1000);
+      },
+      [lng, lat, _onChange]
+    );
+
+    const handleSelect = useCallback(
+      (val: string, opts: DageMapPoisType) => {
+        const [lng, lat] = opts.location.split(",").map((i) => Number(i));
+        setLng(lng);
+        setLat(lat);
+
+        handlePosition(lng, lat);
+
+        _onChange?.({
+          lat,
+          lng,
+        });
+      },
+      [handlePosition, _onChange]
+    );
+
+    const handleAutoCompleteChange = useCallback(
+      (val: string) => {
+        _onChange?.({
+          address: val,
+        });
+      },
+      [_onChange]
+    );
+
+    const handleGeocoderAddress = useCallback(
+      (res: AMapGeocoderGetAddressResult) => {
+        _onChange?.({
+          address: res.regeocode.formattedAddress,
+        });
+      },
+      [_onChange]
+    );
+
+    return (
+      <>
+        <div>
+          <AutoComplete
+            allowClear
+            disabled={disabled}
+            value={address}
+            style={{ width: 450 }}
+            options={options.map((i) => ({
+              ...i,
+              value: i.name,
+            }))}
+            onSearch={handleSearch}
+            onSelect={handleSelect}
+            onChange={handleAutoCompleteChange}
+          >
+            <Input allowClear placeholder="请输入详细描述" />
+          </AutoComplete>
+        </div>
+
+        <Space style={{ margin: "15px 0" }}>
+          <Input
+            readOnly={disabled}
+            value={lng}
+            type="number"
+            placeholder="请输入经度"
+            onChange={(e) => setLng(Number(e.target.value))}
+          />
+          <Input
+            readOnly={disabled}
+            value={lat}
+            type="number"
+            placeholder="请输入纬度"
+            onChange={(e) => setLat(Number(e.target.value))}
+          />
+          {!disabled && (
+            <Button
+              type="primary"
+              onClick={handlePosition.bind(undefined, lng, lat)}
+            >
+              查询
+            </Button>
+          )}
+        </Space>
+
+        <div style={{ width: 650, height: 400 }}>
+          <Amap
+            ref={mapRef}
+            zoom={17}
+            onComplete={handleComplete}
+            onClick={handleClick}
+          >
+            {loaded && position ? (
+              <>
+                <Marker
+                  position={position}
+                  draggable={!disabled}
+                  onDragging={handleMarker}
+                />
+
+                {!disabled && (
+                  <DageMapGeocoder
+                    city={city}
+                    position={position}
+                    onChange={handleGeocoderAddress}
+                  />
+                )}
+              </>
+            ) : (
+              <></>
+            )}
+          </Amap>
+        </div>
+      </>
+    );
+  }
+);
+
+export * from "./types";
+export * from "./utils";

+ 44 - 0
src/components/DageMap/plugins/Geocoder/index.ts

@@ -0,0 +1,44 @@
+import { useAmap, usePlugins } from "@amap/amap-react";
+import { FC, memo, useEffect, useRef } from "react";
+import { AMapGeocoderGetAddressResult } from "../../types";
+
+export interface DageMapGeocoderProps {
+  city?: number | string;
+  position: number[];
+  onChange?: (res: AMapGeocoderGetAddressResult) => void;
+}
+
+export const DageMapGeocoder: FC<DageMapGeocoderProps> = memo(
+  ({ city, position, onChange }) => {
+    const map = useAmap();
+    const AMap = usePlugins(["AMap.Geocoder"]);
+    const geocoder = useRef<any>();
+
+    useEffect(() => {
+      if (!map || !AMap) return;
+      // @ts-ignore
+      geocoder.current = new AMap.Geocoder({
+        city,
+      });
+      map.add(geocoder.current);
+
+      return () => {
+        map.remove(geocoder.current);
+      };
+    }, [map, AMap, city]);
+
+    useEffect(() => {
+      geocoder.current &&
+        geocoder.current.getAddress(
+          position,
+          (status: string, result: AMapGeocoderGetAddressResult) => {
+            if (status === "complete" && result.info === "OK") {
+              onChange?.(result);
+            }
+          }
+        );
+    }, [position, onChange]);
+
+    return null;
+  }
+);

+ 21 - 0
src/components/DageMap/plugins/Geocoder/types.ts

@@ -0,0 +1,21 @@
+export interface AMapGeocoderGetAddressResult {
+  info: string;
+  regeocode: AMapGeocoderGetAddressRegeocode;
+}
+
+export interface AMapGeocoderGetAddressRegeocode {
+  formattedAddress: string;
+  addressComponent: {
+    adcode: string;
+    /** 区 */
+    district: string;
+    /** 市 */
+    province: string;
+    /** 街 */
+    street: string;
+    streetNumber: string;
+    /** 镇 */
+    towncode: string;
+    township: string;
+  };
+}

+ 1 - 0
src/components/DageMap/plugins/index.ts

@@ -0,0 +1 @@
+export * from "./Geocoder";

+ 35 - 0
src/components/DageMap/types.ts

@@ -0,0 +1,35 @@
+export interface DageMapMethods {
+  mapRef: AMap.Map_2 | undefined;
+}
+
+export interface DageMapProps {
+  latitude: number;
+  longitude: number;
+  address: string;
+  disabled?: boolean;
+  /**
+   * 指定进行编码查询的城市,支持传入城市名、adcode 和 citycode
+   */
+  city?: string | number;
+  onChange?(e: DageMapEvent): void;
+  onMapComplete?(): void;
+}
+
+export interface DageMapEvent {
+  lng?: number;
+  lat?: number;
+  address?: string;
+}
+
+export interface DageMapPoisType {
+  adcode: string;
+  address: string;
+  location: string;
+  name: string;
+}
+
+export interface AMapTipsResponse {
+  tips: DageMapPoisType[];
+}
+
+export * from "./plugins/Geocoder/types";

+ 24 - 0
src/components/DageMap/utils.ts

@@ -0,0 +1,24 @@
+import { config as AmapReactConfig } from "@amap/amap-react";
+
+AmapReactConfig.version = "2.0"; // 默认2.0,这里可以不修改
+AmapReactConfig.key = "8de83e1728d5368f8452eee95f35585d";
+
+// @ts-ignore
+window._AMapSecurityConfig = {
+  serviceHost: "https://sit-shgybwg.4dage.com/_AMapService",
+};
+
+/**
+ * 修改高德地图 key
+ * @param key JS-API key
+ * @param serviceHost 安全密钥地址
+ */
+export const setAmapJSKey = (key: string, serviceHost?: string) => {
+  AmapReactConfig.key = key;
+  if (serviceHost) {
+    // @ts-ignore
+    window._AMapSecurityConfig = {
+      serviceHost,
+    };
+  }
+};

+ 6 - 0
src/components/DageUpload/index.scss

@@ -0,0 +1,6 @@
+.dage-upload {
+  &__tips {
+    color: #999999;
+    font-size: 12px;
+  }
+}

+ 184 - 0
src/components/DageUpload/index.tsx

@@ -0,0 +1,184 @@
+import { Button, Modal, Upload, message } from "antd";
+import { PlusOutlined } from "@ant-design/icons";
+import { FC, useMemo, useRef, useState } from "react";
+import { RcFile, UploadFile, UploadProps } from "antd/es/upload";
+import { getTokenInfo } from "@/utils";
+import { DageUploadProps, DageUploadType } from "./types";
+import { UploadOutlined } from "@ant-design/icons";
+import "./index.scss";
+
+const getBase64 = (file: RcFile): Promise<string> =>
+  new Promise((resolve, reject) => {
+    const reader = new FileReader();
+    reader.readAsDataURL(file);
+    reader.onload = () => resolve(reader.result as string);
+    reader.onerror = (error) => reject(error);
+  });
+
+export const DageUpload: FC<DageUploadProps> = ({
+  action,
+  value,
+  type = DageUploadType.IMG,
+  maxCount = 9,
+  maxSize = 5,
+  tips,
+  disabled,
+  onUploaded,
+  onUploading,
+  onChange,
+}) => {
+  const [previewOpen, setPreviewOpen] = useState(false);
+  const [previewImage, setPreviewImage] = useState("");
+  const [previewTitle, setPreviewTitle] = useState("");
+  const [uploading, setUploading] = useState(false);
+  const uploadListType = useMemo(() => {
+    switch (type) {
+      case DageUploadType.IMG:
+        return "picture-card";
+      default:
+        return "text";
+    }
+  }, [type]);
+  const isPictureCard = uploadListType === "picture-card";
+  const uploadingFileNum = useRef(0);
+
+  const beforeUpload = (file: RcFile) => {
+    let pass = false;
+    let passFileType = false;
+
+    // 校验文件类型
+    switch (type) {
+      case DageUploadType.IMG:
+        passFileType = ["image/jpeg", "image/png", "image/gif"].includes(
+          file.type
+        );
+        if (!passFileType) {
+          message.error("只支持png、jpg、gif和jpeg格式!");
+        }
+        break;
+      case DageUploadType.MODEL:
+        passFileType = file.name.indexOf(".4dage") > -1;
+        if (!passFileType) {
+          message.error("只支持4dage格式的模型文件!");
+        }
+        break;
+      case DageUploadType.VIDEO:
+        passFileType = ["video/mp4"].includes(file.type);
+        if (!passFileType) {
+          message.error("只支持mp4格式!");
+        }
+        break;
+      case DageUploadType.AUDIO:
+        passFileType = ["audio/mpeg"].includes(file.type);
+        if (!passFileType) {
+          message.error("只支持mp3格式!");
+        }
+        break;
+    }
+
+    // 校验文件大小
+    const isLtM = file.size / 1024 / 1024 < maxSize;
+    if (!isLtM) {
+      message.error(`最大支持 ${maxSize}M!`);
+    }
+
+    uploadingFileNum.current += 1;
+    pass = passFileType && isLtM;
+
+    return pass ? pass : Upload.LIST_IGNORE;
+  };
+
+  const handleChange: UploadProps["onChange"] = ({
+    fileList: newFileList,
+    file,
+  }) => {
+    if (file.status === "uploading") {
+      setUploading(true);
+      onUploading?.();
+    }
+    if (file.status === "done") {
+      uploadingFileNum.current -= 1;
+
+      if (uploading && !uploadingFileNum.current) {
+        setUploading(false);
+        onUploaded?.();
+      }
+    }
+    onChange?.(
+      newFileList.map((i) => ({
+        ...i,
+        dType: type,
+      }))
+    );
+  };
+
+  const handleCancel = () => setPreviewOpen(false);
+
+  const handlePreview = async (file: UploadFile) => {
+    if (!isPictureCard) return;
+
+    if (!file.url && !file.preview) {
+      file.preview = await getBase64(file.originFileObj as RcFile);
+    }
+
+    setPreviewImage(file.url || (file.preview as string));
+    setPreviewOpen(true);
+    setPreviewTitle(
+      file.name || file.url!.substring(file.url!.lastIndexOf("/") + 1)
+    );
+  };
+
+  return (
+    <div className="dage-upload">
+      <Upload
+        disabled={disabled}
+        headers={{
+          token: getTokenInfo().token,
+        }}
+        fileList={value}
+        withCredentials
+        action={process.env.REACT_APP_BACKEND_URL + action}
+        name="file"
+        listType={uploadListType}
+        data={{
+          type,
+        }}
+        maxCount={maxCount}
+        showUploadList={{
+          showDownloadIcon: true,
+        }}
+        multiple={maxCount > 1}
+        onPreview={handlePreview}
+        beforeUpload={beforeUpload}
+        onChange={handleChange}
+      >
+        {!disabled &&
+          (isPictureCard ? (
+            (!value || value.length < maxCount) && <PlusOutlined />
+          ) : (
+            <Button icon={<UploadOutlined />}>上传</Button>
+          ))}
+      </Upload>
+
+      {!!tips && (
+        <p
+          style={{ marginTop: isPictureCard ? 0 : "8px" }}
+          className="dage-upload__tips"
+        >
+          {tips}
+        </p>
+      )}
+
+      <Modal
+        open={previewOpen}
+        title={previewTitle}
+        footer={null}
+        onCancel={handleCancel}
+      >
+        <img alt="example" style={{ width: "100%" }} src={previewImage} />
+      </Modal>
+    </div>
+  );
+};
+
+export * from "./types";

+ 36 - 0
src/components/DageUpload/types.ts

@@ -0,0 +1,36 @@
+import { UploadFile } from "antd";
+
+export enum DageUploadType {
+  /** 缩略图 */
+  THUMB = "thumb",
+  /** 模型 */
+  MODEL = "model",
+  /** 视频 */
+  VIDEO = "video",
+  /** 图片 */
+  IMG = "img",
+  /** 音频 */
+  AUDIO = "audio",
+  /** 文档 */
+  DOC = "doc",
+}
+
+export interface DageFileResponseType extends UploadFile {
+  dType: DageUploadType;
+}
+
+export interface DageUploadProps {
+  action: string;
+  value?: DageFileResponseType[];
+  maxCount?: number;
+  /**
+   * 最大尺寸(M),默认 5M
+   */
+  maxSize?: number;
+  type?: DageUploadType;
+  tips?: string;
+  disabled?: boolean;
+  onUploading?(): void;
+  onUploaded?(): void;
+  onChange?(list: DageFileResponseType[]): void;
+}

+ 21 - 0
src/components/FormPageFooter/index.scss

@@ -0,0 +1,21 @@
+.form-page-footer {
+  $height: 60px;
+  height: calc($height);
+
+  &-container {
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    position: fixed;
+    left: 220px;
+    right: 0;
+    bottom: 0;
+    height: $height;
+    background: white;
+    border-top: 1px solid var(--border-color);
+
+    > *:not(:first-child) {
+      margin-left: 20px;
+    }
+  }
+}

+ 52 - 0
src/components/FormPageFooter/index.tsx

@@ -0,0 +1,52 @@
+import { Button, message } from "antd";
+import "./index.scss";
+import { FC, memo, useCallback, useState } from "react";
+
+export interface FormPageFooterProps {
+  disabled?: boolean;
+  showSubmit?: boolean;
+  onSubmit?(): void;
+  onCancel?(): void;
+}
+
+export const FormPageFooter: FC<FormPageFooterProps> = memo(
+  ({ onSubmit, onCancel, disabled, showSubmit = true }) => {
+    const [loading, setLoading] = useState(false);
+
+    const handleClick = useCallback(async () => {
+      setLoading(true);
+      try {
+        await onSubmit?.();
+
+        message.open({
+          type: "success",
+          content: "操作成功",
+        });
+      } finally {
+        setLoading(false);
+      }
+    }, [onSubmit]);
+
+    const handleCancel = useCallback(() => {
+      onCancel?.();
+    }, [onCancel]);
+
+    return (
+      <div className="form-page-footer">
+        <div className="form-page-footer-container">
+          {showSubmit && (
+            <Button
+              disabled={disabled}
+              loading={loading}
+              type="primary"
+              onClick={handleClick}
+            >
+              提交
+            </Button>
+          )}
+          <Button onClick={handleCancel}>取消</Button>
+        </div>
+      </div>
+    );
+  }
+);

+ 26 - 0
src/components/NotFound/index.tsx

@@ -0,0 +1,26 @@
+import { Result } from "antd";
+import { useEffect, useRef } from "react";
+
+export default function NotFound() {
+  const timeRef = useRef(-1);
+
+  useEffect(() => {
+    timeRef.current = window.setTimeout(() => {
+      const dom: HTMLDivElement = document.querySelector(".noFindPage")!;
+      dom.style.opacity = "1";
+    }, 300);
+    return () => {
+      clearTimeout(timeRef.current);
+    };
+  }, []);
+
+  return (
+    <div className="noFindPage">
+      <Result
+        status="404"
+        title="404"
+        subTitle="页面找不到或没有权限,请联系管理员!"
+      />
+    </div>
+  );
+}

+ 10 - 0
src/components/SpinLoding/index.module.scss

@@ -0,0 +1,10 @@
+.SpinLoding {
+  position: absolute;
+  z-index: 9999;
+  width: 100%;
+  height: 100%;
+  background-color: #fff;
+  display: flex;
+  justify-content: center;
+  align-items: center;
+}

+ 11 - 0
src/components/SpinLoding/index.tsx

@@ -0,0 +1,11 @@
+import styles from "./index.module.scss";
+import { Spin } from "antd";
+import React from "react";
+function SpinLoding() {
+  return (
+    <div className={styles.SpinLoding}>
+      <Spin size="large" />
+    </div>
+  );
+}
+export const MemoSpinLoding = React.memo(SpinLoding);

+ 5 - 0
src/components/index.ts

@@ -0,0 +1,5 @@
+export * from "./SpinLoding";
+export * from "./DageUpload";
+export * from "./DageMap";
+export * from "./DageFileCheckbox";
+export * from "./FormPageFooter";

+ 9 - 0
src/css.d.ts

@@ -0,0 +1,9 @@
+declare module "*.module.scss" {
+  const classes: { readonly [key: string]: string };
+  export default classes;
+}
+
+declare module "*.scss" {
+  const classes: { readonly [key: string]: string };
+  export default classes;
+}

+ 7 - 0
src/img.d.ts

@@ -0,0 +1,7 @@
+declare module "*.svg";
+declare module "*.png";
+declare module "*.jpg";
+declare module "*.jpeg";
+declare module "*.gif";
+declare module "*.bmp";
+declare module "*.tiff";

+ 19 - 0
src/index.tsx

@@ -0,0 +1,19 @@
+import React from "react";
+import ReactDOM from "react-dom/client";
+import { BrowserRouter } from "react-router-dom";
+import { Provider } from "react-redux";
+import store from "./store/index";
+import App from "./App";
+
+const root = ReactDOM.createRoot(
+  document.getElementById("root") as HTMLElement
+);
+root.render(
+  <React.StrictMode>
+    <BrowserRouter>
+      <Provider store={store}>
+        <App />
+      </Provider>
+    </BrowserRouter>
+  </React.StrictMode>
+);

+ 155 - 0
src/pages/History/create-or-edit/index.tsx

@@ -0,0 +1,155 @@
+import { historyApi } from "@/api";
+import { DageUpload, FormPageFooter, MemoSpinLoding } from "@/components";
+import { HistoryDictItem } from "@/types";
+import { Form, FormInstance, Input, Select } from "antd";
+import { useCallback, useEffect, useRef, useState } from "react";
+import { useLocation, useNavigate, useParams } from "react-router-dom";
+
+export default function HistoryCreateOrEdit() {
+  const navigate = useNavigate();
+  const params = useParams();
+  const location = useLocation();
+  const readonly = useRef(location.pathname.indexOf("view") > -1);
+  const formRef = useRef<FormInstance>(null);
+  const [loading, setLoading] = useState(false);
+  const [btnDisabled, setBtnDisabled] = useState(false);
+  const [dictList, setDictList] = useState<HistoryDictItem[]>([]);
+
+  const getDetail = useCallback(async () => {
+    setLoading(true);
+    try {
+      const {
+        data: { entity, file },
+      } = await historyApi.getDetail(params.id as string);
+      formRef.current?.setFieldsValue({
+        ...entity,
+        fileIds: file.map((i) => ({
+          uid: i.id + "",
+          url: `${process.env.REACT_APP_BACKEND_URL}${i.filePath}`,
+          name: i.fileName,
+        })),
+      });
+    } finally {
+      setLoading(false);
+    }
+  }, [params.id]);
+
+  const getHistoryDictList = useCallback(async () => {
+    const { data } = await historyApi.getHistoryDictList();
+    setDictList(data);
+  }, []);
+
+  const handleCancel = useCallback(() => {
+    navigate(-1);
+  }, [navigate]);
+
+  const handleSubmit = useCallback(async () => {
+    if (!(await formRef.current?.validateFields())) return;
+
+    const { fileIds, ...rest } = formRef.current?.getFieldsValue();
+
+    if (params.id) {
+      rest.id = params.id;
+    }
+
+    await historyApi.add({
+      ...rest,
+      fileIds: fileIds
+        .map((i: any) => (!!i.response ? i.response.data.id : i.uid))
+        .join(),
+    });
+
+    handleCancel();
+  }, [handleCancel, params.id]);
+
+  useEffect(() => {
+    getHistoryDictList();
+  }, [getHistoryDictList]);
+
+  useEffect(() => {
+    !!params.id && getDetail();
+  }, [getDetail, params.id]);
+
+  return (
+    <div style={{ position: "relative" }}>
+      {loading && <MemoSpinLoding />}
+      <Form ref={formRef} labelCol={{ span: 3 }}>
+        <Form.Item
+          label="姓名"
+          name="name"
+          rules={[{ required: true, message: "请输入" }]}
+        >
+          <Input
+            className="w450"
+            placeholder="请输入内容"
+            maxLength={20}
+            showCount
+            readOnly={readonly.current}
+          />
+        </Form.Item>
+        <Form.Item
+          label="所属阶段"
+          name="stage"
+          rules={[{ required: true, message: "请选择" }]}
+        >
+          <Select
+            style={{ width: 200 }}
+            placeholder="请选择"
+            options={dictList.map((i) => ({
+              value: i.name,
+              label: i.name,
+            }))}
+            disabled={readonly.current}
+          />
+        </Form.Item>
+        <Form.Item label="企业" name="companyName">
+          <Input
+            className="w450"
+            placeholder="请输入内容"
+            maxLength={30}
+            showCount
+            readOnly={readonly.current}
+          />
+        </Form.Item>
+        <Form.Item label="简介" name="description">
+          <Input.TextArea
+            className="w450"
+            placeholder="请输入内容"
+            maxLength={200}
+            rows={8}
+            showCount
+            readOnly={readonly.current}
+          />
+        </Form.Item>
+        <Form.Item label="故事" name="story">
+          <Input.TextArea
+            className="w450"
+            placeholder="请输入内容"
+            maxLength={200}
+            rows={8}
+            showCount
+            readOnly={readonly.current}
+          />
+        </Form.Item>
+        <Form.Item label="图片" name="fileIds">
+          <DageUpload
+            disabled={readonly.current}
+            tips="支持png、jpg和jpeg格式;最多9张,最大5M"
+            action="/api/cms/history/upload"
+            onUploading={() => setBtnDisabled(true)}
+            onUploaded={() => setBtnDisabled(false)}
+          />
+        </Form.Item>
+      </Form>
+
+      {!loading && (
+        <FormPageFooter
+          showSubmit={!readonly.current}
+          disabled={btnDisabled}
+          onSubmit={handleSubmit}
+          onCancel={handleCancel}
+        />
+      )}
+    </div>
+  );
+}

+ 193 - 0
src/pages/History/index.tsx

@@ -0,0 +1,193 @@
+import { historyApi } from "@/api";
+import { GetHistoryListParams, HistoryDictItem, HistoryItem } from "@/types";
+import {
+  Button,
+  Form,
+  FormInstance,
+  Input,
+  Popconfirm,
+  Select,
+  Table,
+} from "antd";
+import { debounce } from "lodash";
+import { useCallback, useEffect, useMemo, useRef, useState } from "react";
+import { useNavigate } from "react-router-dom";
+
+const DEFAULT_PARAMS: GetHistoryListParams = {
+  pageNum: 1,
+  pageSize: 20,
+};
+
+export default function History() {
+  const navigate = useNavigate();
+  const formRef = useRef<FormInstance>(null);
+  const [list, setList] = useState<HistoryItem[]>([]);
+  const [dictList, setDictList] = useState<HistoryDictItem[]>([]);
+  const [total, setTotal] = useState(0);
+  const [loading, setLoading] = useState(false);
+  const [params, setParams] = useState<GetHistoryListParams>({
+    ...DEFAULT_PARAMS,
+  });
+
+  const getHistoryDictList = async () => {
+    const { data } = await historyApi.getHistoryDictList();
+    setDictList(data);
+  };
+
+  useEffect(() => {
+    getHistoryDictList();
+  }, []);
+
+  const getList = useCallback(async () => {
+    setLoading(true);
+    try {
+      const { data } = await historyApi.getList(params);
+      setList(data.records);
+      setTotal(data.total);
+    } finally {
+      setLoading(false);
+    }
+  }, [params]);
+
+  useEffect(() => {
+    getList();
+  }, [getList, params]);
+
+  const handleDel = useCallback(
+    async (id: number) => {
+      await historyApi.del(id);
+      getList();
+    },
+    [getList]
+  );
+
+  const handleAdd = useCallback(() => {
+    navigate("/history/create");
+  }, [navigate]);
+
+  const debounceSearch = useMemo(
+    () =>
+      debounce((changedVal: unknown, vals: GetHistoryListParams) => {
+        setParams({ ...params, ...vals });
+      }, 500),
+    [params]
+  );
+
+  const paginationChange = useCallback(
+    () => (pageNum: number, pageSize: number) => {
+      setParams({ ...params, pageNum, pageSize });
+    },
+    [params]
+  );
+
+  const handleReset = useCallback(() => {
+    setParams({ ...DEFAULT_PARAMS });
+    formRef.current?.resetFields();
+  }, [formRef]);
+
+  const COLUMNS = useMemo(() => {
+    return [
+      {
+        title: "姓名",
+        dataIndex: "name",
+      },
+      {
+        title: "所属阶段",
+        dataIndex: "stage",
+      },
+      {
+        title: "品牌",
+        render: (item: HistoryItem) =>
+          item.companyName ? item.companyName : "(空)",
+      },
+      {
+        title: "简介",
+        width: 400,
+        ellipsis: true,
+        render: (item: HistoryItem) =>
+          item.description ? item.description : "(空)",
+      },
+      {
+        title: "操作",
+        width: 230,
+        render: (item: HistoryItem) => (
+          <>
+            <Button
+              type="text"
+              onClick={() => navigate(`/history/view/${item.id}`)}
+            >
+              查看
+            </Button>
+            <Button
+              type="text"
+              onClick={() => navigate(`/history/edit/${item.id}`)}
+            >
+              编辑
+            </Button>
+            <Popconfirm
+              title="提示"
+              description="确认是否删除?"
+              onConfirm={handleDel.bind(undefined, item.id)}
+            >
+              <Button type="text" danger>
+                删除
+              </Button>
+            </Popconfirm>
+          </>
+        ),
+      },
+    ];
+  }, [handleDel, navigate]);
+
+  return (
+    <div className="history">
+      <Form ref={formRef} layout="inline" onValuesChange={debounceSearch}>
+        <Form.Item label="姓名" name="searchKey">
+          <Input
+            className="w220"
+            placeholder="请输入关键字"
+            maxLength={30}
+            showCount
+            allowClear
+          />
+        </Form.Item>
+        <Form.Item label="所属阶段" name="stage">
+          <Select
+            style={{ width: 220 }}
+            placeholder="请选择"
+            allowClear
+            options={dictList.map((i) => ({
+              value: i.name,
+              label: i.name,
+            }))}
+          />
+        </Form.Item>
+        <Form.Item>
+          <Button type="primary" onClick={handleAdd}>
+            新增
+          </Button>
+        </Form.Item>
+        <Form.Item>
+          <Button onClick={handleReset}>重置</Button>
+        </Form.Item>
+      </Form>
+
+      <Table
+        loading={loading}
+        className="c-table"
+        dataSource={list}
+        columns={COLUMNS}
+        rowKey="id"
+        pagination={{
+          showQuickJumper: true,
+          position: ["bottomCenter"],
+          showSizeChanger: true,
+          current: params.pageNum,
+          pageSize: params.pageSize,
+          total,
+          onChange: paginationChange(),
+        }}
+      />
+    </div>
+  );
+}

+ 85 - 0
src/pages/IndustrialMeta/create-or-edit/index.tsx

@@ -0,0 +1,85 @@
+import { industrialMetaApi } from "@/api";
+import { FormPageFooter, MemoSpinLoding } from "@/components";
+import { Form, FormInstance, Input } from "antd";
+import { useCallback, useEffect, useRef, useState } from "react";
+import { useLocation, useNavigate, useParams } from "react-router-dom";
+
+export default function HistoryCreateOrEdit() {
+  const navigate = useNavigate();
+  const params = useParams();
+  const location = useLocation();
+  const readonly = useRef(location.pathname.indexOf("view") > -1);
+  const formRef = useRef<FormInstance>(null);
+  const [loading, setLoading] = useState(false);
+
+  const getDetail = useCallback(async () => {
+    setLoading(true);
+    try {
+      const { data } = await industrialMetaApi.getDetail(params.id as string);
+      formRef.current?.setFieldsValue(data);
+    } finally {
+      setLoading(false);
+    }
+  }, [params.id]);
+
+  const handleCancel = useCallback(() => {
+    navigate(-1);
+  }, [navigate]);
+
+  const handleSubmit = useCallback(async () => {
+    if (!(await formRef.current?.validateFields())) return;
+
+    const value = formRef.current?.getFieldsValue();
+
+    if (params.id) {
+      value.id = params.id;
+    }
+
+    await industrialMetaApi.edit(value);
+
+    handleCancel();
+  }, [handleCancel, params.id]);
+
+  useEffect(() => {
+    !!params.id && getDetail();
+  }, [getDetail, params.id]);
+
+  return (
+    <div style={{ position: "relative" }}>
+      {loading && <MemoSpinLoding />}
+      <Form ref={formRef} labelCol={{ span: 3 }}>
+        <Form.Item
+          label="名称"
+          name="name"
+          rules={[{ required: true, message: "请输入" }]}
+        >
+          <Input
+            className="w450"
+            placeholder="请输入内容"
+            maxLength={20}
+            showCount
+            readOnly={readonly.current}
+          />
+        </Form.Item>
+        <Form.Item label="简介" name="description">
+          <Input.TextArea
+            className="w450"
+            placeholder="请输入内容"
+            maxLength={200}
+            rows={12}
+            showCount
+            readOnly={readonly.current}
+          />
+        </Form.Item>
+      </Form>
+
+      {!loading && (
+        <FormPageFooter
+          showSubmit={!readonly.current}
+          onSubmit={handleSubmit}
+          onCancel={handleCancel}
+        />
+      )}
+    </div>
+  );
+}

+ 123 - 0
src/pages/IndustrialMeta/index.tsx

@@ -0,0 +1,123 @@
+import { industrialMetaApi } from "@/api";
+import { GetHistoryListParams, HistoryItem } from "@/types";
+import { Button, Form, FormInstance, Input, Table } from "antd";
+import { debounce } from "lodash";
+import { useCallback, useEffect, useMemo, useRef, useState } from "react";
+import { useNavigate } from "react-router-dom";
+
+const DEFAULT_PARAMS: GetHistoryListParams = {
+  pageNum: 1,
+  pageSize: 20,
+};
+
+export default function IndustrialMeta() {
+  const navigate = useNavigate();
+  const formRef = useRef<FormInstance>(null);
+  const [list, setList] = useState<HistoryItem[]>([]);
+  const [total, setTotal] = useState(0);
+  const [loading, setLoading] = useState(false);
+  const [params, setParams] = useState<GetHistoryListParams>({
+    ...DEFAULT_PARAMS,
+  });
+
+  const getList = useCallback(async () => {
+    setLoading(true);
+    try {
+      const { data } = await industrialMetaApi.getList(params);
+      setList(data.records);
+      setTotal(data.total);
+    } finally {
+      setLoading(false);
+    }
+  }, [params]);
+
+  useEffect(() => {
+    getList();
+  }, [getList, params]);
+
+  const debounceSearch = useMemo(
+    () =>
+      debounce((changedVal: unknown, vals: GetHistoryListParams) => {
+        setParams({ ...params, ...vals });
+      }, 500),
+    [params]
+  );
+
+  const paginationChange = useCallback(
+    () => (pageNum: number, pageSize: number) => {
+      setParams({ ...params, pageNum, pageSize });
+    },
+    [params]
+  );
+
+  const handleReset = useCallback(() => {
+    setParams({ ...DEFAULT_PARAMS });
+    formRef.current?.resetFields();
+  }, [formRef]);
+
+  const COLUMNS = useMemo(() => {
+    return [
+      {
+        title: "名称",
+        dataIndex: "name",
+      },
+      {
+        title: "简介",
+        width: 400,
+        ellipsis: true,
+        render: (item: HistoryItem) =>
+          item.description ? item.description : "(空)",
+      },
+      {
+        title: "操作",
+        width: 230,
+        render: (item: HistoryItem) => (
+          <>
+            <Button
+              type="text"
+              onClick={() => navigate(`/industrial-meta/edit/${item.id}`)}
+            >
+              编辑
+            </Button>
+          </>
+        ),
+      },
+    ];
+  }, [navigate]);
+
+  return (
+    <div className="industrial-meta">
+      <Form ref={formRef} layout="inline" onValuesChange={debounceSearch}>
+        <Form.Item label="名称" name="searchKey">
+          <Input
+            className="w220"
+            placeholder="请输入关键字"
+            maxLength={30}
+            showCount
+            allowClear
+          />
+        </Form.Item>
+        <Form.Item>
+          <Button onClick={handleReset}>重置</Button>
+        </Form.Item>
+      </Form>
+
+      <Table
+        loading={loading}
+        className="c-table"
+        dataSource={list}
+        columns={COLUMNS}
+        rowKey="id"
+        pagination={{
+          showQuickJumper: true,
+          position: ["bottomCenter"],
+          showSizeChanger: true,
+          current: params.pageNum,
+          pageSize: params.pageSize,
+          total,
+          onChange: paginationChange(),
+        }}
+      />
+    </div>
+  );
+}

+ 95 - 0
src/pages/Layout/components/Header/components/ResetPassword.tsx

@@ -0,0 +1,95 @@
+import { updatePwd } from "@/api";
+import { logout } from "@/utils";
+import encodeStr from "@/utils/pass";
+import { Form, FormInstance, Input, Modal, ModalProps, message } from "antd";
+import { Base64 } from "js-base64";
+import { FC, useRef, useState } from "react";
+
+export interface ResetPasswordProps extends Pick<ModalProps, "onCancel"> {
+  visible: boolean;
+}
+
+export const ResetPassword: FC<ResetPasswordProps> = ({ visible, ...rest }) => {
+  // 拿到新密码的输入框的值
+  const oldPasswordValue = useRef("");
+  const formRef = useRef<FormInstance>(null);
+  const [confirmLoading, setConfirmLoading] = useState(false);
+
+  const checkPassWord = (rule: any, value = "") => {
+    if (value !== oldPasswordValue.current)
+      return Promise.reject("新密码不一致!");
+    else return Promise.resolve(value);
+  };
+
+  const onFinish = async () => {
+    setConfirmLoading(true);
+    try {
+      if (!(await formRef.current?.validateFields())) return;
+
+      const value = formRef.current?.getFieldsValue();
+      const obj = {
+        oldPassword: encodeStr(Base64.encode(value.oldPassword)),
+        newPassword: encodeStr(Base64.encode(value.newPassword)),
+      };
+      await updatePwd(obj);
+
+      message.open({
+        type: "success",
+        content: "修改成功!",
+      });
+      logout();
+    } finally {
+      setConfirmLoading(false);
+    }
+  };
+
+  return (
+    <Modal
+      open={visible}
+      title="修改密码"
+      {...rest}
+      maskClosable={false}
+      destroyOnClose={true}
+      confirmLoading={confirmLoading}
+      onOk={onFinish}
+    >
+      <Form
+        ref={formRef}
+        labelCol={{ span: 5 }}
+        wrapperCol={{ span: 16 }}
+        onFinish={onFinish}
+        autoComplete="off"
+      >
+        <Form.Item
+          label="旧密码"
+          name="oldPassword"
+          rules={[{ required: true, message: "不能为空!" }]}
+        >
+          <Input.Password maxLength={15} />
+        </Form.Item>
+
+        <Form.Item
+          label="新密码"
+          name="newPassword"
+          rules={[
+            { required: true, message: "不能为空!" },
+            { min: 6, max: 15, message: "密码长度为6-15个字符!" },
+          ]}
+        >
+          <Input.Password
+            maxLength={15}
+            onChange={(e) => (oldPasswordValue.current = e.target.value)}
+          />
+        </Form.Item>
+
+        <Form.Item
+          label="确定新密码"
+          name="checkPass"
+          rules={[{ validator: checkPassWord }]}
+        >
+          <Input.Password maxLength={15} />
+        </Form.Item>
+      </Form>
+    </Modal>
+  );
+};

+ 37 - 0
src/pages/Layout/components/Header/index.module.scss

@@ -0,0 +1,37 @@
+.header {
+  position: sticky;
+  top: 0;
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  padding: 0 40px;
+  height: 60px;
+  line-height: 60px;
+  background-color: #fff;
+  border-bottom: 1px solid var(--border-color);
+  z-index: var(--index-top);
+}
+
+.user {
+  display: flex;
+  align-items: center;
+  font-size: 16px;
+  cursor: pointer;
+
+  span {
+    margin-left: 10px;
+  }
+}
+
+.userPopover {
+  li {
+    height: 50px;
+    line-height: 50px;
+    text-align: center;
+    cursor: pointer;
+
+    &:hover {
+      color: var(--second-color);
+    }
+  }
+}

+ 90 - 0
src/pages/Layout/components/Header/index.tsx

@@ -0,0 +1,90 @@
+import { FC, useCallback, useMemo, useState } from "react";
+import style from "./index.module.scss";
+import { App, Avatar, Breadcrumb, Button, Popover } from "antd";
+import { Header } from "antd/es/layout/layout";
+import { RouteType } from "../../types";
+import { useSelector } from "react-redux";
+import { RootState } from "@/store";
+import { ResetPassword } from "./components/ResetPassword";
+import { logout } from "@/utils";
+
+export interface LayoutHeaderProps {
+  menu: RouteType | null;
+}
+
+export const LayoutHeader: FC<LayoutHeaderProps> = ({ menu }) => {
+  const { modal } = App.useApp();
+  const [resetPwdModalVisible, setResetPwdModalVisible] = useState(false);
+  const { userInfo } = useSelector<RootState, RootState["base"]>(
+    (state) => state.base
+  );
+
+  const breadcrumb = useMemo(() => {
+    const stack: {
+      title: string;
+      href?: string;
+    }[] = [];
+
+    if (!!menu) {
+      if (!!menu.parent) {
+        stack.push({
+          title: menu.parent.label,
+          href: menu.parent.key,
+        });
+      }
+      stack.push({
+        title: menu.label,
+      });
+    }
+
+    return stack;
+  }, [menu]);
+
+  const handleLogout = useCallback(() => {
+    modal.confirm({
+      title: "提示",
+      content: "确定退出吗?",
+      async onOk() {
+        await logout();
+      },
+    });
+  }, [modal]);
+
+  return (
+    <Header className={style.header}>
+      <Breadcrumb items={breadcrumb} />
+
+      <Popover
+        placement="bottom"
+        content={
+          <ul className={style.userPopover}>
+            <li>
+              <Button type="text" onClick={() => setResetPwdModalVisible(true)}>
+                修改密码
+              </Button>
+            </li>
+            <li>
+              <Button type="text" onClick={handleLogout}>
+                退出登录
+              </Button>
+            </li>
+          </ul>
+        }
+      >
+        <div className={style.user}>
+          <Avatar
+            size={40}
+            src={userInfo?.user.thumb}
+            alt={userInfo?.user.nickName}
+          />
+          <span>{userInfo?.user.nickName}</span>
+        </div>
+      </Popover>
+
+      <ResetPassword
+        visible={resetPwdModalVisible}
+        onCancel={() => setResetPwdModalVisible(false)}
+      />
+    </Header>
+  );
+};

+ 36 - 0
src/pages/Layout/components/Sider/index.module.scss

@@ -0,0 +1,36 @@
+.logo {
+  padding: 20px 20px 0;
+
+  img {
+    width: 100%;
+  }
+}
+
+:global {
+  .layout-menu {
+    color: #ffffff;
+    font-size: 16px;
+    background: var(--theme-color);
+
+    .ant-menu-item {
+      margin-inline: 10px;
+      margin-block: 20px;
+      display: flex;
+      align-items: center;
+      width: calc(100% - 20px);
+      height: 50px;
+
+      &-selected {
+        color: #ffffff !important;
+        background: var(--second-color) !important;
+      }
+      &-active {
+        color: #ffffff !important;
+        padding-inline: 32px !important;
+      }
+      &-icon {
+        font-size: 20px !important;
+      }
+    }
+  }
+}

+ 31 - 0
src/pages/Layout/components/Sider/index.tsx

@@ -0,0 +1,31 @@
+import { Menu, MenuProps } from "antd";
+import { FC } from "react";
+import LogoImage from "@/assets/images/logo.png";
+import style from "./index.module.scss";
+import { RouteType } from "../../types";
+import Sider from "antd/es/layout/Sider";
+
+export interface LayoutSiderProps extends MenuProps {
+  items: RouteType[];
+}
+
+export const LayoutSider: FC<LayoutSiderProps> = (props) => {
+  return (
+    <Sider
+      width={220}
+      style={{
+        position: "fixed",
+        top: 0,
+        left: 0,
+        bottom: 0,
+        background: "var(--primary-color)",
+      }}
+    >
+      <div className={style.logo}>
+        <img draggable="false" alt="logo" src={LogoImage} />
+      </div>
+
+      <Menu className="layout-menu" mode="inline" {...props} />
+    </Sider>
+  );
+};

+ 2 - 0
src/pages/Layout/components/index.ts

@@ -0,0 +1,2 @@
+export * from "./Header";
+export * from "./Sider";

+ 120 - 0
src/pages/Layout/constants.tsx

@@ -0,0 +1,120 @@
+import {
+  PieChartOutlined,
+  UserOutlined,
+  SettingOutlined,
+  ShareAltOutlined,
+  GlobalOutlined,
+  DeploymentUnitOutlined,
+} from "@ant-design/icons";
+import React from "react";
+import { RouteType } from "./types";
+
+export const DEFAULT_MENU: RouteType[] = [
+  {
+    key: "/weapon",
+    label: "国之重器",
+    icon: <GlobalOutlined />,
+    component: React.lazy(() => import("../Weapon")),
+    child: [
+      {
+        key: "/create",
+        hide: true,
+        label: "新增",
+        component: React.lazy(() => import("../Weapon/create-or-edit")),
+      },
+      {
+        key: "/view/:id",
+        hide: true,
+        label: "查看",
+        component: React.lazy(() => import("../Weapon/create-or-edit")),
+      },
+      {
+        key: "/edit/:id",
+        hide: true,
+        label: "编辑",
+        component: React.lazy(() => import("../Weapon/create-or-edit")),
+      },
+    ],
+  },
+  {
+    key: "/overview",
+    label: "概况总览",
+    icon: <PieChartOutlined />,
+    component: React.lazy(() => import("../Overview")),
+    child: [
+      {
+        key: "/create",
+        hide: true,
+        label: "新增",
+        component: React.lazy(() => import("../Overview/create-or-edit")),
+      },
+      {
+        key: "/view/:id",
+        hide: true,
+        label: "查看",
+        component: React.lazy(() => import("../Overview/create-or-edit")),
+      },
+      {
+        key: "/edit/:id",
+        hide: true,
+        label: "编辑",
+        component: React.lazy(() => import("../Overview/create-or-edit")),
+      },
+    ],
+  },
+  {
+    key: "/history",
+    label: "历史回顾",
+    icon: <DeploymentUnitOutlined />,
+    component: React.lazy(() => import("../History")),
+    child: [
+      {
+        key: "/create",
+        hide: true,
+        label: "新增",
+        component: React.lazy(() => import("../History/create-or-edit")),
+      },
+      {
+        key: "/view/:id",
+        hide: true,
+        label: "查看",
+        component: React.lazy(() => import("../History/create-or-edit")),
+      },
+      {
+        key: "/edit/:id",
+        hide: true,
+        label: "编辑",
+        component: React.lazy(() => import("../History/create-or-edit")),
+      },
+    ],
+  },
+  {
+    key: "/industrial-meta",
+    label: "工业元宇宙",
+    icon: <ShareAltOutlined />,
+    component: React.lazy(() => import("../IndustrialMeta")),
+    child: [
+      {
+        key: "/edit/:id",
+        hide: true,
+        label: "编辑",
+        component: React.lazy(() => import("../IndustrialMeta/create-or-edit")),
+      },
+    ],
+  },
+];
+
+export const DEFAULT_ADMIN_MENU: RouteType[] = [
+  {
+    key: "/user",
+    label: "用户管理",
+    icon: <UserOutlined />,
+    component: React.lazy(() => import("../User")),
+  },
+  {
+    key: "/log",
+    label: "操作日志",
+    icon: <SettingOutlined />,
+    component: React.lazy(() => import("../Log")),
+  },
+];

+ 3 - 0
src/pages/Layout/index.scss

@@ -0,0 +1,3 @@
+.layout {
+  min-height: 100vh;
+}

+ 158 - 0
src/pages/Layout/index.tsx

@@ -0,0 +1,158 @@
+import React, {
+  useMemo,
+  useEffect,
+  useState,
+  useCallback,
+  Suspense,
+} from "react";
+import { App, Layout, message } from "antd";
+import { useSelector } from "react-redux";
+import {
+  Route,
+  Routes,
+  useNavigate,
+  useLocation,
+  Navigate,
+} from "react-router-dom";
+import { Content } from "antd/es/layout/layout";
+import { hasToken, getTokenInfo } from "@/utils";
+import store from "@/store";
+import { LayoutSider, LayoutHeader } from "./components";
+import { RootState } from "@/store";
+import { RouteType } from "./types";
+import { DEFAULT_ADMIN_MENU, DEFAULT_MENU } from "./constants";
+import "./index.scss";
+import { MemoSpinLoding } from "@/components";
+import { isNaN } from "lodash";
+
+const NotFound = React.lazy(() => import("@/components/NotFound"));
+
+export default function CustomLayout() {
+  const navigate = useNavigate();
+  const location = useLocation();
+  const [activeMenuKey, setActiveMenuKey] = useState("/");
+  const baseStore = useSelector<RootState, RootState["base"]>(
+    (state) => state.base
+  );
+  const menuList = useMemo<RouteType[]>(() => {
+    const list = baseStore.userInfo?.user.isAdmin
+      ? [...DEFAULT_MENU, ...DEFAULT_ADMIN_MENU]
+      : [...DEFAULT_MENU];
+
+    function deep(v: RouteType[], parent?: RouteType) {
+      const stack: RouteType[] = [];
+      v.forEach((item) => {
+        const { child = [], ...rest } = item;
+
+        if (!!parent) {
+          rest.key = parent.key + rest.key;
+          rest.parent = {
+            key: parent.key,
+            label: parent.label,
+          };
+        }
+
+        stack.push(rest);
+
+        if (!!child.length) {
+          stack.push(...deep(child, item));
+        }
+      });
+      return stack;
+    }
+
+    return deep(list);
+  }, [baseStore.userInfo]);
+  const [curMenu, setCurMenu] = useState<null | RouteType>(null);
+
+  useEffect(() => {
+    if (!hasToken()) {
+      message.open({
+        type: "warning",
+        content: "登录失效!",
+        duration: 4,
+      });
+
+      navigate("/login", {
+        replace: true,
+      });
+    } else {
+      store.dispatch({ type: "setUserInfo", payload: getTokenInfo() });
+    }
+  }, [navigate]);
+
+  /**
+   * 初始化菜单选中状态
+   */
+  const getActiveMenu = useCallback(() => {
+    const split = location.pathname.split("/").slice(1);
+    const pMenu = "/" + split[0];
+    if (split.length > 1) {
+      const pathname = split
+        .map((i) => (!isNaN(Number(i)) ? ":id" : i))
+        .join("/");
+      setCurMenu(menuList.find((i) => i.key === `/${pathname}`) as RouteType);
+    } else {
+      setCurMenu(menuList.find((i) => i.key === pMenu) as RouteType);
+    }
+    setActiveMenuKey(pMenu);
+  }, [location, menuList]);
+
+  useEffect(() => {
+    getActiveMenu();
+  }, [getActiveMenu]);
+
+  const handleMenu = useCallback(
+    (item: Partial<RouteType>) => {
+      const path = item?.key as string;
+
+      setActiveMenuKey(path);
+      navigate(path);
+    },
+    [setActiveMenuKey, navigate]
+  );
+
+  return (
+    <App>
+      <Layout hasSider className="layout">
+        {/* 菜单 */}
+        <LayoutSider
+          selectedKeys={[activeMenuKey]}
+          items={menuList.filter((i) => !i.hide)}
+          onClick={handleMenu}
+        />
+
+        <Layout style={{ marginLeft: 220 }}>
+          {/* 头部 */}
+          <LayoutHeader menu={curMenu} />
+
+          {/* 主体 */}
+          <Content
+            style={{
+              margin: "15px",
+              overflow: "initial",
+              position: "relative",
+              background: "#ffffff",
+              padding: 20,
+              borderRadius: 4,
+            }}
+          >
+            <Suspense fallback={<MemoSpinLoding />}>
+              <Routes>
+                <Route path="/" element={<Navigate to="/weapon" />} />
+                {menuList.map((menu) => (
+                  <Route
+                    key={menu.key}
+                    path={menu.key}
+                    Component={menu.component}
+                  />
+                ))}
+                <Route path="*" Component={NotFound} />
+              </Routes>
+            </Suspense>
+          </Content>
+        </Layout>
+      </Layout>
+    </App>
+  );
+}

+ 17 - 0
src/pages/Layout/types.ts

@@ -0,0 +1,17 @@
+import { FC, ReactNode } from "react";
+
+export interface RouteType {
+  key: string;
+  label: string;
+  component: FC;
+  /**
+   * 是否在侧边栏隐藏
+   */
+  hide?: boolean;
+  icon?: ReactNode;
+  child?: RouteType[];
+  parent?: {
+    key: string;
+    label: string;
+  };
+}

+ 141 - 0
src/pages/Log/index.tsx

@@ -0,0 +1,141 @@
+import { logApi } from "@/api";
+import { GetLogListParams, HistoryItem } from "@/types";
+import { formatDate } from "@/utils/date";
+import { Button, DatePicker, Form, FormInstance, Input, Table } from "antd";
+import { debounce } from "lodash";
+import { useCallback, useEffect, useMemo, useRef, useState } from "react";
+
+const DEFAULT_PARAMS: GetLogListParams = {
+  pageNum: 1,
+  pageSize: 20,
+  startTime: "",
+  endTime: "",
+};
+
+const { RangePicker } = DatePicker;
+
+export default function IndustrialMeta() {
+  const formRef = useRef<FormInstance>(null);
+  const [list, setList] = useState<HistoryItem[]>([]);
+  const [total, setTotal] = useState(0);
+  const [loading, setLoading] = useState(false);
+  const [params, setParams] = useState<GetLogListParams>({
+    ...DEFAULT_PARAMS,
+  });
+
+  const getList = useCallback(async () => {
+    setLoading(true);
+    try {
+      const { data } = await logApi.getList(params);
+      setList(data.records);
+      setTotal(data.total);
+    } finally {
+      setLoading(false);
+    }
+  }, [params]);
+
+  useEffect(() => {
+    getList();
+  }, [getList, params]);
+
+  const debounceSearch = useMemo(
+    () =>
+      debounce(
+        (changedVal: unknown, vals: GetLogListParams & { date: string[] }) => {
+          const { date, ...rest } = vals;
+
+          let startTime = "";
+          let endTime = "";
+          if (date && date[0] && date[1]) {
+            startTime = formatDate(date[0]) + " 00:00:00";
+            endTime = formatDate(date[1]) + " 23:59:59";
+          }
+
+          setParams({ ...params, ...rest, startTime, endTime });
+        },
+        500
+      ),
+    [params]
+  );
+
+  const paginationChange = useCallback(
+    () => (pageNum: number, pageSize: number) => {
+      setParams({ ...params, pageNum, pageSize });
+    },
+    [params]
+  );
+
+  const handleReset = useCallback(() => {
+    setParams({ ...DEFAULT_PARAMS });
+    formRef.current?.resetFields();
+  }, [formRef]);
+
+  const COLUMNS = useMemo(() => {
+    return [
+      {
+        title: "序号",
+        render: (text: any, record: any, index: any) =>
+          index + 1 + (params.pageNum - 1) * params.pageSize,
+      },
+      {
+        title: "操作者",
+        dataIndex: "userName",
+      },
+      {
+        title: "操作日期",
+        dataIndex: "createTime",
+      },
+      {
+        title: "IP记录",
+        dataIndex: "ip",
+      },
+      {
+        title: "操作模块",
+        dataIndex: "type",
+      },
+      {
+        title: "操作事件",
+        dataIndex: "description",
+      },
+    ];
+  }, [params]);
+
+  return (
+    <div className="log">
+      <Form ref={formRef} layout="inline" onValuesChange={debounceSearch}>
+        <Form.Item label="账号" name="searchKey">
+          <Input
+            className="w220"
+            placeholder="请输入关键字"
+            maxLength={30}
+            showCount
+            allowClear
+          />
+        </Form.Item>
+        <Form.Item label="操作日期" name="date">
+          <RangePicker />
+        </Form.Item>
+        <Form.Item>
+          <Button onClick={handleReset}>重置</Button>
+        </Form.Item>
+      </Form>
+
+      <Table
+        loading={loading}
+        className="c-table"
+        dataSource={list}
+        columns={COLUMNS}
+        rowKey="id"
+        pagination={{
+          showQuickJumper: true,
+          position: ["bottomCenter"],
+          showSizeChanger: true,
+          current: params.pageNum,
+          pageSize: params.pageSize,
+          total,
+          onChange: paginationChange(),
+        }}
+      />
+    </div>
+  );
+}

BIN
src/pages/Login/images/bg.jpg


BIN
src/pages/Login/images/icon_account.png


BIN
src/pages/Login/images/icon_password.png


+ 109 - 0
src/pages/Login/index.scss

@@ -0,0 +1,109 @@
+.login {
+  width: 100vw;
+  height: 100vh;
+  overflow: hidden;
+  background-image: url("./images/bg.jpg");
+  background-size: cover;
+
+  &::before {
+    content: "";
+    position: absolute;
+    top: 0;
+    left: 0;
+    width: 100%;
+    height: 100%;
+    background-color: rgba(0, 0, 0, 0.4);
+    z-index: 1;
+  }
+  .icon-account,
+  .icon-password {
+    margin-right: 24px;
+    width: 26px;
+  }
+
+  &-logo {
+    display: flex;
+    flex-direction: column;
+    position: absolute;
+    top: 17.5vh;
+    left: 15vw;
+    z-index: 1;
+
+    &__txt {
+      display: flex;
+      justify-content: space-between;
+      margin-top: 23px;
+      font-size: 24px;
+      color: #ffffff;
+      opacity: 0.6;
+    }
+  }
+
+  &-form {
+    position: absolute;
+    top: 52%;
+    right: 12vw;
+    width: 550px;
+    height: 646px;
+    padding: 66px 75px;
+    background: rgba(176, 161, 121, 0.2);
+    box-shadow: inset 0px 4px 4px 0px rgba(255, 255, 255, 0.25);
+    border-radius: 3px;
+    transform: translateY(-50%);
+    z-index: 2;
+
+    &__title {
+      margin-bottom: 20px;
+      font-size: 40px;
+      font-family: Source Han Serif CN-Bold;
+      font-weight: bold;
+      color: white;
+    }
+
+    &__input {
+      $inputHeight: 85px;
+      display: flex;
+      flex-direction: column;
+
+      .ant-input {
+        width: 100%;
+        height: $inputHeight;
+        color: #ffffff;
+        background-color: transparent;
+
+        &::-webkit-input-placeholder {
+          color: #ffffff;
+          opacity: 0.6;
+        }
+      }
+      .ant-input-affix-wrapper {
+        padding: 0 11px;
+        width: 100%;
+        height: $inputHeight;
+        border: none;
+        color: #ffffff;
+        font-size: 24px;
+        border-bottom: 1px solid #ffffff;
+        border-radius: 0;
+        background-color: transparent;
+      }
+      .ant-input-password-icon {
+        color: #ffffff !important;
+        opacity: 0.6;
+      }
+    }
+
+    &__btn {
+      margin-top: 85px;
+
+      .ant-btn {
+        font-size: 24px;
+        width: 100%;
+        height: 66px;
+        border-radius: 3px;
+        border: 0;
+        background: var(--second-color);
+      }
+    }
+  }
+}

+ 91 - 0
src/pages/Login/index.tsx

@@ -0,0 +1,91 @@
+import { useState } from "react";
+import { Button, Form, Input } from "antd";
+import { Base64 } from "js-base64";
+import { useNavigate } from "react-router-dom";
+import encodeStr from "@/utils/pass";
+import { LoginRequest } from "@/types";
+import { login } from "@/api";
+import { setTokenInfo } from "@/utils";
+import IconAccount from "./images/icon_account.png";
+import IconPassword from "./images/icon_password.png";
+import LogoImage from "../../assets/images/logo.png";
+import "./index.scss";
+
+export default function Login() {
+  const [loading, setLoading] = useState(false);
+  const navigate = useNavigate();
+
+  const handleLogin = async (vals: LoginRequest) => {
+    setLoading(true);
+
+    const obj = {
+      userName: vals.userName,
+      passWord: encodeStr(Base64.encode(vals.passWord as string)),
+    };
+
+    try {
+      const { data } = await login(obj);
+
+      // 用户信息存到本地
+      setTokenInfo(data);
+      navigate("/weapon", {
+        replace: true,
+      });
+    } finally {
+      setLoading(false);
+    }
+  };
+
+  return (
+    <div className="login">
+      <div className="login-logo">
+        <img alt="logo" src={LogoImage} />
+        <div className="login-logo__txt">
+          {"上海工业博物馆后台界面".split("").map((t) => (
+            <span key={t}>{t}</span>
+          ))}
+        </div>
+      </div>
+
+      <div className="login-form">
+        <div className="login-form__title">登 录</div>
+
+        <Form className="login-form__input" onFinish={handleLogin}>
+          <Form.Item
+            name="userName"
+            rules={[{ required: true, message: "请输入用户名!" }]}
+          >
+            <Input
+              prefix={
+                <img className="icon-account" src={IconAccount} alt="账号" />
+              }
+              placeholder="请输入用户名"
+              maxLength={15}
+              bordered={false}
+            />
+          </Form.Item>
+          <Form.Item
+            name="passWord"
+            rules={[{ required: true, message: "请输入密码!" }]}
+          >
+            <Input.Password
+              prefix={
+                <img className="icon-password" src={IconPassword} alt="密码" />
+              }
+              placeholder="请输入用户密码"
+              maxLength={15}
+              bordered={false}
+            />
+          </Form.Item>
+
+          {/* 登录按钮 */}
+          <div className="login-form__btn">
+            <Button htmlType="submit" loading={loading}>
+              登 录
+            </Button>
+          </div>
+        </Form>
+      </div>
+    </div>
+  );
+}

+ 181 - 0
src/pages/Overview/create-or-edit/index.tsx

@@ -0,0 +1,181 @@
+import { DatePicker, Form, FormInstance, Input } from "antd";
+import dayjs from "dayjs";
+import { RangePickerProps } from "antd/es/date-picker";
+import {
+  DageMap,
+  DageUpload,
+  FormPageFooter,
+  MemoSpinLoding,
+  DageMapEvent,
+  DageMapMethods,
+} from "@/components";
+import { useCallback, useEffect, useRef, useState } from "react";
+import { useLocation, useNavigate, useParams } from "react-router-dom";
+import { overviewApi } from "@/api";
+import { formatDate } from "@/utils/date";
+
+const disabledDate: RangePickerProps["disabledDate"] = (current) => {
+  return current && current > dayjs().endOf("day");
+};
+
+export default function OverviewCreateOrEdit() {
+  const navigate = useNavigate();
+  const params = useParams();
+  const location = useLocation();
+  const formRef = useRef<FormInstance>(null);
+  const dageMapRef = useRef<DageMapMethods>(null);
+  const readonly = useRef(location.pathname.split("/")[2].indexOf("view") > -1);
+  const [btnDisabled, setBtnDisabled] = useState(false);
+  const [loading, setLoading] = useState(false);
+
+  const [position, setPosition] = useState([121.473581, 31.230536]);
+  const [address, setAddress] = useState("上海市人民政府");
+
+  const getDetail = useCallback(async () => {
+    setLoading(true);
+    try {
+      const {
+        data: {
+          entity: { createDay, longitude, latitude, address, ...rest },
+          file,
+        },
+      } = await overviewApi.getDetail(params.id as string);
+      formRef.current?.setFieldsValue({
+        ...rest,
+        address,
+        createDay: dayjs(createDay),
+        fileIds: file.map((i) => ({
+          uid: i.id + "",
+          url: `${process.env.REACT_APP_BACKEND_URL}${i.filePath}`,
+          name: i.fileName,
+        })),
+      });
+      setPosition([longitude, latitude]);
+      setAddress(address);
+
+      dageMapRef.current?.mapRef?.setCenter([longitude, latitude]);
+    } finally {
+      setLoading(false);
+    }
+  }, [params.id]);
+
+  const handleCancel = useCallback(() => {
+    navigate(-1);
+  }, [navigate]);
+
+  const handleSubmit = useCallback(async () => {
+    if (!(await formRef.current?.validateFields())) return;
+
+    const { createDay, fileIds, ...rest } = formRef.current?.getFieldsValue();
+
+    if (params.id) {
+      rest.id = params.id;
+    }
+
+    await overviewApi.save({
+      ...rest,
+      createDay: formatDate(createDay),
+      latitude: position[1],
+      longitude: position[0],
+      address,
+      fileIds: fileIds
+        .map((i: any) => (!!i.response ? i.response.data.id : i.uid))
+        .join(),
+    });
+
+    handleCancel();
+  }, [handleCancel, params.id, position, address]);
+
+  const handleMapChange = useCallback((data: DageMapEvent) => {
+    if (data.lng && data.lat) {
+      setPosition([data.lng, data.lat]);
+    }
+    setAddress(data.address || "");
+  }, []);
+
+  useEffect(() => {
+    !!params.id && getDetail();
+  }, [getDetail, params.id]);
+
+  return (
+    <div className="overview-edit">
+      {loading && <MemoSpinLoding />}
+      <Form ref={formRef} initialValues={{ address }} labelCol={{ span: 3 }}>
+        <Form.Item
+          label="企业名称"
+          name="name"
+          rules={[{ required: true, message: "请输入" }]}
+        >
+          <Input
+            readOnly={readonly.current}
+            className="w450"
+            placeholder="请输入内容"
+            maxLength={30}
+            showCount
+          />
+        </Form.Item>
+        <Form.Item label="简介" name="description">
+          <Input.TextArea
+            readOnly={readonly.current}
+            className="w450"
+            placeholder="请输入内容"
+            maxLength={200}
+            showCount
+          />
+        </Form.Item>
+        <Form.Item label="图片" name="fileIds">
+          <DageUpload
+            disabled={readonly.current}
+            tips="支持png、jpg和jpeg格式;最多9张,最大5M"
+            action="/api/cms/history/upload"
+            onUploading={() => setBtnDisabled(true)}
+            onUploaded={() => setBtnDisabled(false)}
+          />
+        </Form.Item>
+        <Form.Item label="企业类型" name="type">
+          <Input
+            readOnly={readonly.current}
+            className="w450"
+            placeholder="请输入内容"
+            maxLength={30}
+            showCount
+          />
+        </Form.Item>
+        <Form.Item
+          label="成立日期"
+          name="createDay"
+          rules={[{ required: true, message: "请选择" }]}
+        >
+          <DatePicker
+            disabled={readonly.current}
+            className="w450"
+            format="YYYY-MM-DD"
+            disabledDate={disabledDate}
+          />
+        </Form.Item>
+        <Form.Item
+          label="地址"
+          name="address"
+          rules={[{ required: true, message: "请选择" }]}
+        >
+          <DageMap
+            disabled={readonly.current}
+            ref={dageMapRef}
+            longitude={position[0]}
+            latitude={position[1]}
+            address={address}
+            city={310000}
+            onChange={handleMapChange}
+          />
+        </Form.Item>
+      </Form>
+
+      <FormPageFooter
+        showSubmit={!readonly.current}
+        disabled={btnDisabled}
+        onSubmit={handleSubmit}
+        onCancel={handleCancel}
+      />
+    </div>
+  );
+}

+ 177 - 0
src/pages/Overview/index.tsx

@@ -0,0 +1,177 @@
+import { overviewApi } from "@/api";
+import { GetOverviewListParams, OverviewParams } from "@/types";
+import { formatDate } from "@/utils/date";
+import {
+  Button,
+  DatePicker,
+  Form,
+  FormInstance,
+  Input,
+  Popconfirm,
+  Table,
+} from "antd";
+import { debounce } from "lodash";
+import { useCallback, useEffect, useMemo, useRef, useState } from "react";
+import { useNavigate } from "react-router-dom";
+
+const { RangePicker } = DatePicker;
+const DEFAULT_PARAMS: GetOverviewListParams = {
+  startTime: "",
+  endTime: "",
+  searchKey: "",
+  pageNum: 1,
+  pageSize: 20,
+};
+
+export default function Overview() {
+  const navigate = useNavigate();
+  const [list, setList] = useState<OverviewParams[]>([]);
+  const [total, setTotal] = useState(0);
+  const formRef = useRef<FormInstance>(null);
+  const [params, setParams] = useState<GetOverviewListParams>({
+    ...DEFAULT_PARAMS,
+  });
+
+  const getOverviewList = useCallback(async () => {
+    const { data } = await overviewApi.getList(params);
+    setList(data.records);
+    setTotal(data.total);
+  }, [params]);
+
+  const handleDel = useCallback(
+    async (id: number) => {
+      await overviewApi.del(id);
+      getOverviewList();
+    },
+    [getOverviewList]
+  );
+
+  const COLUMNS = useMemo(() => {
+    return [
+      {
+        title: "企业名称",
+        dataIndex: "name",
+      },
+      {
+        title: "成立日期",
+        dataIndex: "createDay",
+      },
+      {
+        title: "简介",
+        render: (item: OverviewParams) =>
+          item.description ? item.description : "(空)",
+      },
+      {
+        title: "操作",
+        width: 230,
+        render(item: OverviewParams) {
+          return (
+            <>
+              <>
+                <Button
+                  type="text"
+                  onClick={() => navigate(`/overview/view/${item.id}`)}
+                >
+                  查看
+                </Button>
+                <Button
+                  type="text"
+                  onClick={() => navigate(`/overview/edit/${item.id}`)}
+                >
+                  编辑
+                </Button>
+                <Popconfirm
+                  title="提示"
+                  description="确认是否删除?"
+                  onConfirm={handleDel.bind(undefined, item.id)}
+                >
+                  <Button type="text" danger>
+                    删除
+                  </Button>
+                </Popconfirm>
+              </>
+            </>
+          );
+        },
+      },
+    ];
+  }, [handleDel, navigate]);
+
+  useEffect(() => {
+    getOverviewList();
+  }, [getOverviewList]);
+
+  const handleAdd = useCallback(() => {
+    navigate("/overview/create");
+  }, [navigate]);
+
+  const debounceSearch = useMemo(
+    () =>
+      debounce(
+        (
+          changedVal: OverviewParams,
+          vals: OverviewParams & { date: string[] }
+        ) => {
+          const { date, ...rest } = vals;
+
+          let startTime = "";
+          let endTime = "";
+          if (date && date[0] && date[1]) {
+            startTime = formatDate(date[0]) + " 00:00:00";
+            endTime = formatDate(date[1]) + " 23:59:59";
+          }
+
+          setParams({ ...params, ...rest, startTime, endTime });
+        },
+        500
+      ),
+    [params]
+  );
+
+  const handleReset = useCallback(() => {
+    setParams({ ...DEFAULT_PARAMS });
+    formRef.current?.resetFields();
+  }, [formRef]);
+
+  return (
+    <div className="overview">
+      <Form layout="inline" ref={formRef} onValuesChange={debounceSearch}>
+        <Form.Item label="企业名称:" name="searchKey">
+          <Input
+            className="w220"
+            placeholder="请输入关键字"
+            maxLength={30}
+            allowClear
+            showCount
+          />
+        </Form.Item>
+        <Form.Item label="成立日期:" name="date">
+          <RangePicker />
+        </Form.Item>
+        <Form.Item>
+          <Button type="primary" onClick={handleAdd}>
+            新增
+          </Button>
+        </Form.Item>
+        <Form.Item>
+          <Button onClick={handleReset}>重置</Button>
+        </Form.Item>
+      </Form>
+
+      <Table
+        className="c-table"
+        dataSource={list}
+        columns={COLUMNS}
+        rowKey="id"
+        pagination={{
+          showQuickJumper: true,
+          position: ["bottomCenter"],
+          showSizeChanger: true,
+          current: params.pageNum,
+          pageSize: params.pageSize,
+          total,
+        }}
+      />
+    </div>
+  );
+}

+ 19 - 0
src/pages/User/components/UserAdd/index.module.scss

@@ -0,0 +1,19 @@
+.userAdd {
+  :global {
+    .ant-modal-close {
+      display: none;
+    }
+
+    .userAddMain {
+      border-top: 1px solid #999999;
+      padding-top: 15px;
+      width: 100%;
+
+      .passTit {
+        color: #ff4d4f;
+        font-size: 14px;
+        padding-left: 98px;
+      }
+    }
+  }
+}

+ 143 - 0
src/pages/User/components/UserAdd/index.tsx

@@ -0,0 +1,143 @@
+import { SaveUserType } from "@/types";
+import {
+  Button,
+  Form,
+  FormInstance,
+  Input,
+  Modal,
+  Popconfirm,
+  message,
+} from "antd";
+import React, { useCallback, useEffect, useRef } from "react";
+import styles from "./index.module.scss";
+import { userApi } from "@/api";
+
+type Props = {
+  id: any;
+  closePage: () => void;
+  upTableList: () => void;
+  addTableList: () => void;
+};
+
+function UserAdd({ id, closePage, upTableList, addTableList }: Props) {
+  // 设置表单初始数据(区分编辑和新增)
+  const FormBoxRef = useRef<FormInstance>(null);
+
+  const getInfoInAPIFu = useCallback(async (id: number) => {
+    const res = await userApi.getDetail(id);
+    FormBoxRef.current?.setFieldsValue(res.data);
+    console.log("是编辑,在这里发请求拿数据", res);
+  }, []);
+
+  // 没有通过校验
+  const onFinishFailed = useCallback(() => {
+    // return MessageFu.warning("有表单不符号规则!");
+  }, []);
+
+  useEffect(() => {
+    if (id) getInfoInAPIFu(id);
+    else {
+      FormBoxRef.current?.setFieldsValue({});
+    }
+  }, [getInfoInAPIFu, id]);
+
+  // 通过校验点击确定
+  const onFinish = useCallback(
+    async (values: any) => {
+      const obj: SaveUserType = {
+        ...values,
+        id: id ? id : null,
+      };
+
+      const res: any = await userApi.edit(obj);
+
+      if (res.code === 0) {
+        message.success(id ? "编辑成功!" : "新增成功!");
+        if (id) upTableList();
+        else addTableList();
+
+        closePage();
+      }
+      console.log("通过校验,点击确定");
+    },
+    [addTableList, closePage, id, upTableList]
+  );
+
+  return (
+    <Modal
+      wrapClassName={styles.userAdd}
+      destroyOnClose
+      open={true}
+      title={id ? "编辑用户" : "新增用户"}
+      footer={
+        [] // 设置footer为空,去掉 取消 确定默认按钮
+      }
+    >
+      <div className="userAddMain">
+        <Form
+          ref={FormBoxRef}
+          name="basic"
+          labelCol={{ span: 5 }}
+          onFinish={onFinish}
+          onFinishFailed={onFinishFailed}
+          autoComplete="off"
+        >
+          <Form.Item
+            label="账号名"
+            name="userName"
+            rules={[{ required: true, message: "请输入账号名!" }]}
+            getValueFromEvent={(e) => e.target.value.replace(/\s+/g, "")}
+          >
+            <Input
+              disabled={id}
+              maxLength={15}
+              showCount
+              placeholder="请输入内容"
+            />
+          </Form.Item>
+
+          <Form.Item
+            label="用户昵称"
+            name="nickName"
+            rules={[{ required: true, message: "请输入用户昵称!" }]}
+            getValueFromEvent={(e) => e.target.value.replace(/\s+/g, "")}
+          >
+            <Input maxLength={8} showCount placeholder="请输入内容" />
+          </Form.Item>
+
+          <Form.Item
+            label="真实姓名"
+            name="realName"
+            rules={[{ required: true, message: "请输入真实姓名!" }]}
+            getValueFromEvent={(e) => e.target.value.replace(/\s+/g, "")}
+          >
+            <Input maxLength={8} showCount placeholder="请输入内容" />
+          </Form.Item>
+
+          {id ? null : <div className="passTit">* 默认密码 123456</div>}
+
+          {/* 确定和取消按钮 */}
+          <br />
+          <Form.Item wrapperCol={{ offset: 9, span: 16 }}>
+            <Button type="primary" htmlType="submit">
+              提交
+            </Button>
+            &emsp;
+            <Popconfirm
+              title="放弃编辑后,信息将不会保存!"
+              okText="放弃"
+              cancelText="取消"
+              onConfirm={closePage}
+            >
+              <Button>取消</Button>
+            </Popconfirm>
+          </Form.Item>
+        </Form>
+      </div>
+    </Modal>
+  );
+}
+
+const MemoUserAdd = React.memo(UserAdd);
+
+export default MemoUserAdd;

+ 240 - 0
src/pages/User/index.tsx

@@ -0,0 +1,240 @@
+import { userApi } from "@/api";
+import { GetLogListParams, HistoryItem, UserTableListType } from "@/types";
+import {
+  Button,
+  Form,
+  FormInstance,
+  Input,
+  Popconfirm,
+  Switch,
+  Table,
+  message,
+} from "antd";
+import { debounce } from "lodash";
+import { useCallback, useEffect, useMemo, useRef, useState } from "react";
+import UserAdd from "./components/UserAdd";
+
+const DEFAULT_PARAMS: GetLogListParams = {
+  pageNum: 1,
+  pageSize: 20,
+  startTime: "",
+  endTime: "",
+};
+
+export default function IndustrialMeta() {
+  const formRef = useRef<FormInstance>(null);
+  const [list, setList] = useState<HistoryItem[]>([]);
+  const [total, setTotal] = useState(0);
+  const [loading, setLoading] = useState(false);
+  const [editPageShow, setEditPageShow] = useState(false);
+  const editId = useRef(0);
+  const [params, setParams] = useState<GetLogListParams>({
+    ...DEFAULT_PARAMS,
+  });
+
+  const getList = useCallback(async () => {
+    setLoading(true);
+    try {
+      const { data } = await userApi.getList(params);
+      setList(data.records);
+      setTotal(data.total);
+    } finally {
+      setLoading(false);
+    }
+  }, [params]);
+
+  useEffect(() => {
+    getList();
+  }, [getList, params]);
+
+  const debounceSearch = useMemo(
+    () =>
+      debounce((changedVal: unknown, vals: GetLogListParams) => {
+        setParams({ ...params, ...vals });
+      }, 500),
+    [params]
+  );
+
+  // 切换表格中的启用停用状态
+  const isEnabledClickFu = useCallback(
+    async (val: boolean, id: number) => {
+      const isDisable = val ? 1 : 0;
+      await userApi.handleType(id, isDisable);
+      getList();
+    },
+    [getList]
+  );
+
+  const paginationChange = useCallback(
+    () => (pageNum: number, pageSize: number) => {
+      setParams({ ...params, pageNum, pageSize });
+    },
+    [params]
+  );
+
+  const handleReset = useCallback(() => {
+    setParams({ ...DEFAULT_PARAMS });
+    formRef.current?.resetFields();
+  }, [formRef]);
+
+  // 点击删除
+  const delTableFu = useCallback(
+    async (id: number) => {
+      await userApi.del(id);
+      message.open({
+        type: "success",
+        content: "操作成功",
+      });
+      getList();
+    },
+    [getList]
+  );
+
+  // 点击重置密码
+  const resetPassFu = useCallback(async (id: number) => {
+    await userApi.resetPwd(id);
+    message.open({
+      type: "success",
+      content: "操作成功",
+    });
+  }, []);
+
+  const openEditPageFu = useCallback(
+    (id: number) => {
+      if (id === 0 && total >= 50)
+        return message.open({
+          type: "warning",
+          content: "最多支持50个用户!",
+        });
+
+      editId.current = id;
+      setEditPageShow(true);
+    },
+    [total]
+  );
+
+  const COLUMNS = useMemo(() => {
+    return [
+      {
+        title: "账号名",
+        dataIndex: "userName",
+      },
+      {
+        title: "用户昵称",
+        dataIndex: "nickName",
+      },
+      {
+        title: "真实姓名",
+        dataIndex: "realName",
+      },
+      {
+        title: "注册时间",
+        dataIndex: "createTime",
+      },
+
+      {
+        title: "启用状态",
+        render: (item: UserTableListType) => (
+          <Switch
+            disabled={item.isAdmin === 1}
+            checkedChildren="启用"
+            unCheckedChildren="停用"
+            checked={item.isEnabled === 1}
+            onChange={(val) => isEnabledClickFu(val, item.id)}
+          />
+        ),
+      },
+
+      {
+        title: "操作",
+        render: (item: UserTableListType) => {
+          return item.isAdmin === 1 ? (
+            "-"
+          ) : (
+            <>
+              <Popconfirm
+                title="密码重制后为123456,是否重置?"
+                okText="重置"
+                cancelText="取消"
+                onConfirm={() => resetPassFu(item.id)}
+              >
+                <Button size="small" type="text">
+                  重置密码
+                </Button>
+              </Popconfirm>
+
+              <Button
+                size="small"
+                type="text"
+                onClick={() => openEditPageFu(item.id)}
+              >
+                编辑
+              </Button>
+              <Popconfirm
+                title="删除后无法恢复,是否删除?"
+                okText="删除"
+                cancelText="取消"
+                onConfirm={() => delTableFu(item.id)}
+              >
+                <Button size="small" type="text" danger>
+                  删除
+                </Button>
+              </Popconfirm>
+            </>
+          );
+        },
+      },
+    ];
+  }, [delTableFu, isEnabledClickFu, resetPassFu, openEditPageFu]);
+
+  return (
+    <div className="user">
+      <Form ref={formRef} layout="inline" onValuesChange={debounceSearch}>
+        <Form.Item label="账号" name="searchKey">
+          <Input
+            className="w220"
+            placeholder="请输入关键字"
+            maxLength={30}
+            showCount
+            allowClear
+          />
+        </Form.Item>
+        <Form.Item>
+          <Button type="primary" onClick={openEditPageFu.bind(undefined, 0)}>
+            新增
+          </Button>
+        </Form.Item>
+        <Form.Item>
+          <Button onClick={handleReset}>重置</Button>
+        </Form.Item>
+      </Form>
+
+      <Table
+        loading={loading}
+        className="c-table"
+        dataSource={list}
+        columns={COLUMNS}
+        rowKey="id"
+        pagination={{
+          showQuickJumper: true,
+          position: ["bottomCenter"],
+          showSizeChanger: true,
+          current: params.pageNum,
+          pageSize: params.pageSize,
+          total,
+          onChange: paginationChange(),
+        }}
+      />
+
+      {/* 点击新增或者编辑 */}
+      {editPageShow ? (
+        <UserAdd
+          id={editId.current}
+          closePage={() => setEditPageShow(false)}
+          upTableList={getList}
+          addTableList={handleReset}
+        />
+      ) : null}
+    </div>
+  );
+}

+ 254 - 0
src/pages/Weapon/create-or-edit/index.tsx

@@ -0,0 +1,254 @@
+import { weaponApi } from "@/api";
+import {
+  DageFileCheckbox,
+  DageFileCheckboxMethods,
+  DageFileResponseType,
+  DageUpload,
+  FormPageFooter,
+  MemoSpinLoding,
+} from "@/components";
+import { HistoryDictItem } from "@/types";
+import { Form, FormInstance, Input, Select } from "antd";
+import { useCallback, useEffect, useRef, useState } from "react";
+import { useLocation, useNavigate, useParams } from "react-router-dom";
+
+export default function HistoryCreateOrEdit() {
+  const navigate = useNavigate();
+  const params = useParams();
+  const location = useLocation();
+  const readonly = useRef(location.pathname.indexOf("view") > -1);
+  const formRef = useRef<FormInstance>(null);
+  const dageFileCheckboxRef = useRef<DageFileCheckboxMethods>(null);
+  const [loading, setLoading] = useState(false);
+  const [btnDisabled, setBtnDisabled] = useState(false);
+  const [dictList, setDictList] = useState<HistoryDictItem[]>([]);
+  const [fileList, setFileList] = useState<DageFileResponseType[]>([]);
+
+  const getDetail = useCallback(async () => {
+    setLoading(true);
+    try {
+      const {
+        data: {
+          entity: { thumb, ...rest },
+          file,
+        },
+      } = await weaponApi.getDetail(params.id as string);
+      if (thumb) {
+        rest.thumb = [
+          {
+            uid: thumb,
+            url: `${process.env.REACT_APP_BACKEND_URL}${thumb}`,
+            name: thumb,
+            status: "done",
+          },
+        ];
+      }
+      formRef.current?.setFieldsValue({
+        ...rest,
+      });
+      dageFileCheckboxRef.current?.setFileList(
+        file.map((i: any) => ({
+          uid: i.id + "",
+          url: `${process.env.REACT_APP_BACKEND_URL}${i.filePath}`,
+          thumbUrl: `${process.env.REACT_APP_BACKEND_URL}${i.filePath}`,
+          name: i.fileName,
+          dType: i.type,
+          status: "done",
+        }))
+      );
+    } finally {
+      setLoading(false);
+    }
+  }, [params.id]);
+
+  const getDictList = useCallback(async () => {
+    const { data } = await weaponApi.getDictList();
+    setDictList(data);
+  }, []);
+
+  const handleCancel = useCallback(() => {
+    navigate(-1);
+  }, [navigate]);
+
+  const handleSubmit = useCallback(async () => {
+    if (!(await formRef.current?.validateFields())) return;
+
+    const { thumb, ...rest } = formRef.current?.getFieldsValue();
+
+    if (params.id) {
+      rest.id = params.id;
+    }
+
+    await weaponApi.add({
+      ...rest,
+      thumb: thumb
+        .map((i: any) => (!!i.response ? i.response.data.filePath : i.uid))
+        .join(),
+      fileIds: fileList
+        .map((i) => (!!i.response ? i.response.data.id : i.uid))
+        .join(),
+    });
+
+    handleCancel();
+  }, [handleCancel, params.id, fileList]);
+
+  const handleFileChange = useCallback((list: DageFileResponseType[]) => {
+    setFileList(list);
+  }, []);
+
+  useEffect(() => {
+    getDictList();
+  }, [getDictList]);
+
+  useEffect(() => {
+    !!params.id && getDetail();
+  }, [getDetail, params.id]);
+
+  return (
+    <div style={{ position: "relative" }}>
+      {loading && <MemoSpinLoding />}
+      <Form ref={formRef} labelCol={{ span: 3 }}>
+        <Form.Item
+          label="名称"
+          name="name"
+          rules={[{ required: true, message: "请输入" }]}
+        >
+          <Input
+            className="w450"
+            placeholder="请输入内容"
+            maxLength={20}
+            showCount
+            readOnly={readonly.current}
+          />
+        </Form.Item>
+        <Form.Item
+          label="所属阶段"
+          name="stage"
+          rules={[{ required: true, message: "请选择" }]}
+        >
+          <Select
+            style={{ width: 200 }}
+            placeholder="请选择"
+            options={dictList.map((i) => ({
+              value: i.name,
+              label: i.name,
+            }))}
+            disabled={readonly.current}
+          />
+        </Form.Item>
+        <Form.Item label="行业" name="vocation">
+          <Input
+            className="w450"
+            placeholder="请输入内容"
+            maxLength={30}
+            showCount
+            readOnly={readonly.current}
+          />
+        </Form.Item>
+        <Form.Item label="类型" name="type">
+          <Input
+            className="w450"
+            placeholder="请输入内容"
+            maxLength={30}
+            showCount
+            readOnly={readonly.current}
+          />
+        </Form.Item>
+        <Form.Item label="生产厂家" name="company">
+          <Input
+            className="w450"
+            placeholder="请输入内容"
+            maxLength={30}
+            showCount
+            readOnly={readonly.current}
+          />
+        </Form.Item>
+        <Form.Item label="年份" name="year">
+          <Input
+            className="w450"
+            placeholder="请输入内容"
+            maxLength={30}
+            showCount
+            readOnly={readonly.current}
+          />
+        </Form.Item>
+        <Form.Item label="尺寸" name="size">
+          <Input
+            className="w450"
+            placeholder="请输入内容"
+            maxLength={30}
+            showCount
+            readOnly={readonly.current}
+          />
+        </Form.Item>
+        <Form.Item label="重量" name="weight">
+          <Input
+            className="w450"
+            placeholder="请输入内容"
+            maxLength={30}
+            showCount
+            readOnly={readonly.current}
+          />
+        </Form.Item>
+        <Form.Item label="简介" name="description">
+          <Input.TextArea
+            className="w450"
+            placeholder="请输入内容"
+            maxLength={200}
+            rows={8}
+            showCount
+            readOnly={readonly.current}
+          />
+        </Form.Item>
+        <Form.Item label="选送理由" name="reason">
+          <Input.TextArea
+            className="w450"
+            placeholder="请输入内容"
+            maxLength={200}
+            rows={4}
+            showCount
+            readOnly={readonly.current}
+          />
+        </Form.Item>
+        <Form.Item
+          label="封面图"
+          name="thumb"
+          rules={[{ required: true, message: "请输入" }]}
+        >
+          <DageUpload
+            maxCount={1}
+            disabled={readonly.current}
+            tips="支持png、jpg和jpeg格式;最大20M,最多1张"
+            action="/api/cms/goods/upload"
+            onUploading={() => setBtnDisabled(true)}
+            onUploaded={() => setBtnDisabled(false)}
+          />
+        </Form.Item>
+        <Form.Item
+          label="文件类型"
+          name="fileTypes"
+          rules={[
+            { required: true, message: "请选择" },
+            { validator: dageFileCheckboxRef.current?.validate! },
+          ]}
+        >
+          <DageFileCheckbox
+            ref={dageFileCheckboxRef}
+            disabled={readonly.current}
+            action="/api/cms/goods/upload"
+            onFileChange={handleFileChange}
+          />
+        </Form.Item>
+      </Form>
+
+      {!loading && (
+        <FormPageFooter
+          showSubmit={!readonly.current}
+          disabled={btnDisabled}
+          onSubmit={handleSubmit}
+          onCancel={handleCancel}
+        />
+      )}
+    </div>
+  );
+}

+ 200 - 0
src/pages/Weapon/index.tsx

@@ -0,0 +1,200 @@
+import { weaponApi } from "@/api";
+import { GetHistoryListParams, HistoryDictItem, HistoryItem } from "@/types";
+import {
+  Button,
+  Form,
+  FormInstance,
+  Input,
+  Popconfirm,
+  Select,
+  Table,
+} from "antd";
+import { debounce } from "lodash";
+import { useCallback, useEffect, useMemo, useRef, useState } from "react";
+import { useNavigate } from "react-router-dom";
+
+const DEFAULT_PARAMS: GetHistoryListParams = {
+  pageNum: 1,
+  pageSize: 20,
+};
+
+export default function History() {
+  const navigate = useNavigate();
+  const formRef = useRef<FormInstance>(null);
+  const [list, setList] = useState<HistoryItem[]>([]);
+  const [dictList, setDictList] = useState<HistoryDictItem[]>([]);
+  const [total, setTotal] = useState(0);
+  const [loading, setLoading] = useState(false);
+  const [params, setParams] = useState<GetHistoryListParams>({
+    ...DEFAULT_PARAMS,
+  });
+
+  const getDictList = async () => {
+    const { data } = await weaponApi.getDictList();
+    setDictList(data);
+  };
+
+  useEffect(() => {
+    getDictList();
+  }, []);
+
+  const getList = useCallback(async () => {
+    setLoading(true);
+    try {
+      const { data } = await weaponApi.getList(params);
+      setList(data.records);
+      setTotal(data.total);
+    } finally {
+      setLoading(false);
+    }
+  }, [params]);
+
+  useEffect(() => {
+    getList();
+  }, [getList, params]);
+
+  const handleDel = useCallback(
+    async (id: number) => {
+      await weaponApi.del(id);
+      getList();
+    },
+    [getList]
+  );
+
+  const handleAdd = useCallback(() => {
+    navigate("/weapon/create");
+  }, [navigate]);
+
+  const debounceSearch = useMemo(
+    () =>
+      debounce((changedVal: unknown, vals: GetHistoryListParams) => {
+        setParams({ ...params, ...vals });
+      }, 500),
+    [params]
+  );
+
+  const paginationChange = useCallback(
+    () => (pageNum: number, pageSize: number) => {
+      setParams({ ...params, pageNum, pageSize });
+    },
+    [params]
+  );
+
+  const handleReset = useCallback(() => {
+    setParams({ ...DEFAULT_PARAMS });
+    formRef.current?.resetFields();
+  }, [formRef]);
+
+  const COLUMNS = useMemo(() => {
+    return [
+      {
+        title: "名称",
+        dataIndex: "name",
+      },
+      {
+        title: "所属阶段",
+        dataIndex: "stage",
+      },
+      {
+        title: "行业",
+        dataIndex: "vocation",
+      },
+      {
+        title: "类型",
+        dataIndex: "type",
+      },
+      {
+        title: "生产厂家",
+        dataIndex: "company",
+      },
+      {
+        title: "简介",
+        width: 400,
+        ellipsis: true,
+        render: (item: HistoryItem) =>
+          item.description ? item.description : "(空)",
+      },
+      {
+        title: "操作",
+        width: 230,
+        render: (item: HistoryItem) => (
+          <>
+            <Button
+              type="text"
+              onClick={() => navigate(`/weapon/view/${item.id}`)}
+            >
+              查看
+            </Button>
+            <Button
+              type="text"
+              onClick={() => navigate(`/weapon/edit/${item.id}`)}
+            >
+              编辑
+            </Button>
+            <Popconfirm
+              title="提示"
+              description="确认是否删除?"
+              onConfirm={handleDel.bind(undefined, item.id)}
+            >
+              <Button type="text" danger>
+                删除
+              </Button>
+            </Popconfirm>
+          </>
+        ),
+      },
+    ];
+  }, [handleDel, navigate]);
+
+  return (
+    <div className="weapon">
+      <Form ref={formRef} layout="inline" onValuesChange={debounceSearch}>
+        <Form.Item label="名称" name="searchKey">
+          <Input
+            className="w220"
+            placeholder="请输入关键字"
+            maxLength={30}
+            showCount
+            allowClear
+          />
+        </Form.Item>
+        <Form.Item label="所属阶段" name="stage">
+          <Select
+            style={{ width: 220 }}
+            placeholder="请选择"
+            allowClear
+            options={dictList.map((i) => ({
+              value: i.name,
+              label: i.name,
+            }))}
+          />
+        </Form.Item>
+        <Form.Item>
+          <Button type="primary" onClick={handleAdd}>
+            新增
+          </Button>
+        </Form.Item>
+        <Form.Item>
+          <Button onClick={handleReset}>重置</Button>
+        </Form.Item>
+      </Form>
+
+      <Table
+        loading={loading}
+        className="c-table"
+        dataSource={list}
+        columns={COLUMNS}
+        rowKey="id"
+        pagination={{
+          showQuickJumper: true,
+          position: ["bottomCenter"],
+          showSizeChanger: true,
+          current: params.pageNum,
+          pageSize: params.pageSize,
+          total,
+          onChange: paginationChange(),
+        }}
+      />
+    </div>
+  );
+}

+ 20 - 0
src/store/index.ts

@@ -0,0 +1,20 @@
+// 导入 redux
+import { applyMiddleware, legacy_createStore as createStore } from 'redux'
+// 导入自己封装的  rootReducer 
+import rootReducer from './reducer'
+// 导入调试工具和 异步的 redux(用来发送异步请求)
+// 调试工具需要下载谷歌 扩展程序 我用的是 Redux DevTools 3.0.17
+import { composeWithDevTools } from 'redux-devtools-extension'
+import thunk from 'redux-thunk'
+
+// 创建仓库实例
+const store = createStore(rootReducer, composeWithDevTools(applyMiddleware(thunk)))
+
+// 声明 RootState,在使用仓库的时候用来使用
+export type RootState = ReturnType<typeof store.getState>
+
+// 声明 AppDispatch,在异步请求的时候来使用
+export type AppDispatch = typeof store.dispatch
+
+// 导出仓库实例
+export default store

+ 22 - 0
src/store/reducer/base.ts

@@ -0,0 +1,22 @@
+import { LoginResponse } from "@/types";
+
+// 初始化状态
+const initState = {
+  userInfo: null as null | LoginResponse,
+};
+
+// 定义 action 类型
+type BaseActionType = {
+  type: "setUserInfo";
+  payload: LoginResponse;
+};
+
+// 频道 reducer
+export default function baseReducer(state = initState, action: BaseActionType) {
+  switch (action.type) {
+    case "setUserInfo":
+      return { ...state, userInfo: action.payload };
+    default:
+      return state;
+  }
+}

+ 11 - 0
src/store/reducer/index.ts

@@ -0,0 +1,11 @@
+// 导入合并reducer的依赖
+import { combineReducers } from "redux";
+import base from "./base";
+
+// 合并 reducer
+const rootReducer = combineReducers({
+  base,
+});
+
+// 默认导出
+export default rootReducer;

+ 6 - 0
src/theme.scss

@@ -0,0 +1,6 @@
+$primaryColor: #121620;
+$secondColor: #b0a179;
+
+:export {
+  primaryColor: $primaryColor;
+}

+ 37 - 0
src/types/history.ts

@@ -0,0 +1,37 @@
+export interface GetHistoryListParams {
+  pageNum: number;
+  pageSize: number;
+  searchKey?: string;
+  stage?: boolean;
+}
+export interface GetHistoryListResponse {
+  total: number;
+  records: HistoryItem[];
+}
+export interface HistoryItem extends Required<AddHistoryParams> {
+  id: number;
+}
+
+export interface HistoryDictItem {
+  name: string;
+  id: number;
+  display: number;
+}
+
+export interface AddHistoryParams {
+  name: string;
+  stage: number;
+  companyName?: string;
+  description?: string;
+  story?: string;
+  fileIds?: string;
+}
+
+export interface GetHistoryDetailResponse {
+  entity: HistoryItem;
+  file: {
+    id: number;
+    filePath: string;
+    fileName: string;
+  }[];
+}

+ 28 - 0
src/types/index.ts

@@ -0,0 +1,28 @@
+export interface LoginRequest {
+  userName: string;
+  passWord: string | string[];
+}
+export interface LoginResponse {
+  token: string;
+  user: {
+    id: number;
+    nickName: string;
+    realName: string;
+    phone: string;
+    thumb: string;
+    isAdmin: boolean;
+    isEnabled: boolean;
+  };
+}
+
+export interface UpdatePwdRequest {
+  newPassword: string | string[];
+  oldPassword: string | string[];
+}
+
+export * from "./overview";
+export * from "./history";
+export * from "./log";
+export * from "./industrial-meta";
+export * from "./user";
+export * from "./weapon";

+ 11 - 0
src/types/industrial-meta.ts

@@ -0,0 +1,11 @@
+export interface GetIndustrialMetaListParams {
+  pageNum: number;
+  pageSize: number;
+  searchKey?: string;
+}
+
+export interface IndustrialMetaItemType {
+  id: number;
+  name: string;
+  description: string;
+}

+ 7 - 0
src/types/log.ts

@@ -0,0 +1,7 @@
+export interface GetLogListParams {
+  startTime?: string;
+  endTime?: string;
+  searchKey?: string;
+  pageSize: number;
+  pageNum: number;
+}

+ 32 - 0
src/types/overview.ts

@@ -0,0 +1,32 @@
+export interface GetOverviewListParams {
+  endTime?: string;
+  startTime?: string;
+  pageNum: number;
+  pageSize: number;
+  searchKey?: string;
+}
+
+export interface OverviewParams {
+  id: number;
+  address: string;
+  name: string;
+  type: string;
+  description: string;
+  createDay: string;
+  latitude: number;
+  longitude: number;
+}
+
+export interface GetOverviewListResponse {
+  records: OverviewParams[];
+  total: number;
+}
+
+export interface GetOverviewDetailResponse {
+  entity: OverviewParams;
+  file: {
+    id: number;
+    filePath: string;
+    fileName: string;
+  }[];
+}

+ 31 - 0
src/types/user.ts

@@ -0,0 +1,31 @@
+export type UserTableListType = {
+  createTime: string;
+  creatorId: null;
+  creatorName: string;
+  id: number;
+  isAdmin: number;
+  isEnabled: number;
+  nickName: string;
+  phone: string;
+  realName: string;
+  roleId: null;
+  roleName: string;
+  sex: string;
+  thumb: string;
+  updateTime: string;
+  userName: string;
+};
+
+export interface GetUserListParams {
+  pageNum: number;
+  searchKey?: string;
+  pageSize: number;
+}
+
+export type SaveUserType = {
+  id: number | null;
+  userName: string;
+  nickName: string;
+  roleId: number;
+  realName: string;
+};

+ 13 - 0
src/types/weapon.ts

@@ -0,0 +1,13 @@
+export interface AddWeaponParams {
+  name: string;
+  company: string;
+  reason: string;
+  size: string;
+  stage: string;
+  type: string;
+  year: string;
+  weight: string;
+  vocation: string;
+  thumb: string;
+  fileTypes: string;
+}

+ 118 - 0
src/utils/date.ts

@@ -0,0 +1,118 @@
+/**
+ * 日期和时间相关
+ *
+ * 更复杂的日期操作建议直接使用 dayjs https://dayjs.gitee.io/docs/zh-CN/installation/installation
+ */
+import dayjs from "dayjs";
+import customParseFormat from "dayjs/plugin/customParseFormat";
+
+dayjs.extend(customParseFormat);
+
+export { dayjs };
+
+/**
+ * @group 日期格式
+ * @groupOrder 4
+ */
+export const DATE_FORMAT = "YYYY-MM-DD";
+
+/**
+ * @group 日期格式
+ * @groupOrder 4
+ */
+export const DATE_TIME_FORMAT = "YYYY-MM-DD HH:mm:ss";
+
+export const SECOND = 1000; // 毫秒
+export const MINUTE = 60 * SECOND;
+export const HOUR = 60 * MINUTE;
+export const DAY = 24 * HOUR;
+
+/**
+ * 日期解析
+ *
+ * @group 日期
+ * @groupOrder 5
+ *
+ * @remarks
+ * 如果传入数字,我们假定为`毫秒` <br/>
+ *` parseDate`会先尝试常见的日期格式(例如 YYYY/MM/DD HH:mm:ss、YYYY-MM-DD HH:mm:ss、YYYY-MM-DD),如果`parse`失败就会使用`format`
+ *
+ * @param dateString - 要转换成日期的数字和字符串.
+ * @param format - 用于指定日期的格式。如果你的日期格式不是常见的格式,可以显式指定
+ * @link https://dayjs.gitee.io/docs/zh-CN/parse/string-format
+ *
+ * @example
+ * 例子:
+ * ```typescript
+ * parseDate(3600)
+ * // => 1970-01-01T00:00:03.600Z
+ *
+ * parseDate('2012/12/10 12:12:12')
+ * // => 2012-02-10T04:12:12.000Z
+ * ```
+ * @public
+ */
+export function parseDate(
+  dateString: string | number | Date,
+  format?: string | string[]
+): Date | null {
+  if (typeof dateString === "number") {
+    return new Date(dateString);
+  } else if (dateString instanceof Date) {
+    return dateString;
+  }
+
+  const day = dayjs(dateString, format);
+
+  if (day.isValid()) {
+    return day.toDate();
+  } else {
+    if (!format) {
+      // 兼容 ios
+      dateString = dateString.replace(/-/g, "/");
+      dateString = dateString.replace(/T/g, " ");
+    }
+
+    const retry = dayjs(dateString);
+
+    if (retry.isValid()) {
+      return retry.toDate();
+    }
+
+    return null;
+  }
+}
+
+/**
+ * 格式化日期
+ *
+ * @group 日期
+ * @groupOrder 5
+ *
+ * @remarks
+ * 如果传入数字,我们假定为`毫秒`
+ *
+ * @param date - 日期.
+ * @param format - 详见 {@link https://dayjs.gitee.io/docs/zh-CN/parse/string-format}.
+ *
+ * @example
+ * 例子:
+ * ```
+ * const day = new Date('2012/12/12 12:12:12');
+ *
+ * const target = formatDate(day);
+ * // => target = "2012-02-10"
+ *
+ * const target2 = formatDate(day, 'YYYY-MM-DD HH:mm:ss');
+ * // => target2 = "2012-02-10 12:12:12"
+ * ```
+ * @public
+ */
+export function formatDate(
+  date: string | number | Date,
+  format: string = DATE_FORMAT,
+  parseFormat?: string | string[]
+): string {
+  const parsed = parseDate(date, parseFormat);
+  return dayjs(parsed).format(format);
+}

+ 13 - 0
src/utils/index.ts

@@ -0,0 +1,13 @@
+import { removeTokenInfo } from "./storage";
+import { logoutApi } from "@/api";
+
+export * from "./storage";
+
+export const logout = async () => {
+  await logoutApi();
+
+  removeTokenInfo();
+  globalThis.location.href = "/login";
+};
+
+export const isDevelopment = process.env.NODE_ENV === "development";

+ 100 - 0
src/utils/pass.ts

@@ -0,0 +1,100 @@
+function randomWord(randomFlag: boolean, min: number, max: number = 15) {
+  let str = "";
+  let range = min;
+  const arr = [
+    "0",
+    "1",
+    "2",
+    "3",
+    "4",
+    "5",
+    "6",
+    "7",
+    "8",
+    "9",
+    "a",
+    "b",
+    "c",
+    "d",
+    "e",
+    "f",
+    "g",
+    "h",
+    "i",
+    "j",
+    "k",
+    "l",
+    "m",
+    "n",
+    "o",
+    "p",
+    "q",
+    "r",
+    "s",
+    "t",
+    "u",
+    "v",
+    "w",
+    "x",
+    "y",
+    "z",
+    "A",
+    "B",
+    "C",
+    "D",
+    "E",
+    "F",
+    "G",
+    "H",
+    "I",
+    "J",
+    "K",
+    "L",
+    "M",
+    "N",
+    "O",
+    "P",
+    "Q",
+    "R",
+    "S",
+    "T",
+    "U",
+    "V",
+    "W",
+    "X",
+    "Y",
+    "Z",
+  ];
+  // 随机产生
+  if (randomFlag) {
+    range = Math.round(Math.random() * (max - min)) + min;
+  }
+  for (var i = 0; i < range; i++) {
+    const pos = Math.round(Math.random() * (arr.length - 1));
+    str += arr[pos];
+  }
+  return str;
+}
+
+const encodeStr = (str: string, strv = "") => {
+  const NUM = 2;
+  const front = randomWord(false, 8);
+  const middle = randomWord(false, 8);
+  const end = randomWord(false, 8);
+
+  const str1 = str.substring(0, NUM);
+  const str2 = str.substring(NUM);
+
+  if (strv) {
+    const strv1 = strv.substring(0, NUM);
+    const strv2 = strv.substring(NUM);
+    return [
+      front + str2 + middle + str1 + end,
+      front + strv2 + middle + strv1 + end,
+    ];
+  }
+
+  return front + str2 + middle + str1 + end;
+};
+
+export default encodeStr;

+ 145 - 0
src/utils/services.ts

@@ -0,0 +1,145 @@
+import { message } from "antd";
+import { NoticeType } from "antd/es/message/interface";
+import axios, {
+  type AxiosInstance,
+  type InternalAxiosRequestConfig,
+  type AxiosResponse,
+} from "axios";
+import isNumber from "lodash/isNumber";
+import { getTokenInfo } from "./storage";
+
+export enum ResponseStatusCode {
+  SUCCESS = 0,
+  TOKEN_INVALID = 5001,
+  TOKEN_INVALID2 = 5002,
+}
+
+interface Config<D = any> extends InternalAxiosRequestConfig<D> {
+  /**
+   * 隐藏错误提醒
+   */
+  hidden?: boolean;
+}
+
+export interface DageResponse<T = any, D = any> extends AxiosResponse<T, D> {
+  code: number;
+  msg: string;
+  success: boolean;
+}
+
+interface Service extends AxiosInstance {
+  get<T = any, R = DageResponse<T>, D = any>(
+    url: string,
+    config?: Config<D>
+  ): Promise<R>;
+  post<T = any, R = DageResponse<T>, D = any>(
+    url: string,
+    data?: D,
+    config?: Config<D>
+  ): Promise<R>;
+}
+
+const service: Service = axios.create({
+  baseURL: process.env.REACT_APP_BACKEND_URL,
+  timeout: 60000,
+  headers: {
+    "Cache-Control": "no-cache",
+    "Content-Type": "application/json;charset=UTF-8",
+    "X-Requested-With": "XMLHttpRequest",
+  },
+});
+
+/**
+ * 服务端接口empty字符串跟null返回的结果不同,过滤掉empty字符串
+ * @param params
+ * @param emptyString 是否过滤空字符串
+ */
+function filterEmptyKey(params: any, emptyString = false) {
+  if (Array.isArray(params) || params == null) {
+    return params;
+  }
+
+  Object.keys(params).forEach((key) => {
+    if (params[key] === null || (emptyString && params[key] === "")) {
+      delete params[key];
+    }
+  });
+}
+
+const showMessage = (msg: string, type: NoticeType = "error") => {
+  message.open({
+    type,
+    content: msg,
+    duration: 4,
+  });
+};
+
+service.interceptors.request.use((config: Config) => {
+  const { token } = getTokenInfo();
+  if (token) config.headers.token = token;
+
+  if (config.method === "post") {
+    const params = {
+      ...config.data,
+    };
+    filterEmptyKey(params); // 过滤空字符串
+    config.data = params;
+  } else if (config.method === "get") {
+    config.params = {
+      _t: new Date().getTime() / 1000,
+      ...config.params,
+    };
+    filterEmptyKey(config.params, true);
+  }
+  return config;
+});
+
+service.interceptors.response.use(
+  (res) => {
+    const { data, config }: { config: Config; data: DageResponse } = res;
+
+    // data 有可能直接返回数据
+    if (isNumber(data.code)) {
+      if (
+        [
+          ResponseStatusCode.TOKEN_INVALID,
+          ResponseStatusCode.TOKEN_INVALID2,
+        ].includes(data.code)
+      ) {
+        const msg = "登录失效!";
+        showMessage(msg);
+        globalThis.location.href = "/login";
+
+        return Promise.reject({
+          code: data.code,
+          msg,
+        });
+      } else if (data.code !== ResponseStatusCode.SUCCESS) {
+        const msg = data.msg || "加载失败";
+        const code = data.code || -1000;
+
+        // 未手动配置 隐藏 消息提示时,公共提醒错误
+        if (!config.hidden) {
+          setTimeout(() => {
+            showMessage(msg);
+          }, 0);
+        }
+
+        return Promise.reject({
+          code,
+          msg,
+        });
+      }
+    }
+
+    return data || {};
+  },
+  (error) => {
+    showMessage(error.message);
+    return new Promise((res, rej) => {
+      rej(error);
+    });
+  }
+);
+
+export default service as Required<Service>;

+ 33 - 0
src/utils/storage.ts

@@ -0,0 +1,33 @@
+// ------------------------------------token的本地存储------------------------------------
+
+// 用户 Token 的本地缓存键名,自己定义
+const TOKEN_KEY = "SH_GBG_USER_INFO";
+
+/**
+ * 从本地缓存中获取 Token 信息
+ */
+export const getTokenInfo = (): any => {
+  return JSON.parse(localStorage.getItem(TOKEN_KEY) || "{}");
+};
+
+/**
+ * 将 Token 信息存入缓存
+ * @param {Object} tokenInfo 从后端获取到的 Token 信息
+ */
+export const setTokenInfo = (tokenInfo: any): void => {
+  localStorage.setItem(TOKEN_KEY, JSON.stringify(tokenInfo));
+};
+
+/**
+ * 删除本地缓存中的 Token 信息
+ */
+export const removeTokenInfo = (): void => {
+  localStorage.removeItem(TOKEN_KEY);
+};
+
+/**
+ * 判断本地缓存中是否存在 Token 信息
+ */
+export const hasToken = (): boolean => {
+  return Boolean(getTokenInfo().token);
+};

+ 23 - 0
tsconfig.json

@@ -0,0 +1,23 @@
+{
+  "compilerOptions": {
+    "target": "es5",
+    "lib": ["dom", "dom.iterable", "esnext"],
+    "allowJs": true,
+    "skipLibCheck": true,
+    "esModuleInterop": true,
+    "allowSyntheticDefaultImports": true,
+    "strict": true,
+    "forceConsistentCasingInFileNames": true,
+    "noFallthroughCasesInSwitch": true,
+    "module": "esnext",
+    "moduleResolution": "node",
+    "resolveJsonModule": true,
+    "isolatedModules": true,
+    "noEmit": true,
+    "jsx": "react-jsx",
+    "paths": {
+      "@/*": ["./src/*"]
+    }
+  },
+  "include": ["src"]
+}

File diff suppressed because it is too large
+ 10147 - 0
yarn.lock