Forráskód Böngészése

脚手架以及少许界面初版提交

刘洋 9 hónapja
commit
a42341e855
72 módosított fájl, 3625 hozzáadás és 0 törlés
  1. 0 0
      .env
  2. 0 0
      .env.development
  3. 0 0
      .env.production
  4. 7 0
      .gitignore
  5. 18 0
      .npmrc
  6. 45 0
      .vscode/launch.json
  7. 18 0
      README.md
  8. 138 0
      antdvNames.ts
  9. 79 0
      build/build.js
  10. 53 0
      build/builder.json
  11. BIN
      build/icons/icon.ico
  12. 17 0
      build/start.js
  13. 49 0
      build/webpack.config.main.js
  14. 72 0
      package.json
  15. 142 0
      src/main/index.ts
  16. 19 0
      src/main/preload/index.ts
  17. 29 0
      src/render/App.vue
  18. 105 0
      src/render/Layout/index.vue
  19. 12 0
      src/render/ap/exam.ts
  20. 15 0
      src/render/ap/mock/exam.ts
  21. 1 0
      src/render/ap/mock/index.ts
  22. BIN
      src/render/assets/icons/256x256.png
  23. BIN
      src/render/assets/icons/electron.png
  24. BIN
      src/render/assets/icons/icon.icns
  25. BIN
      src/render/assets/icons/icon.ico
  26. BIN
      src/render/assets/imgs/admin_login_icon.png
  27. BIN
      src/render/assets/imgs/bread_prefix_icon.png
  28. BIN
      src/render/assets/imgs/cur_exam_bg.png
  29. BIN
      src/render/assets/imgs/env_check.png
  30. BIN
      src/render/assets/imgs/login_left_bg.png
  31. BIN
      src/render/assets/imgs/maohao.png
  32. BIN
      src/render/assets/imgs/none_data.png
  33. BIN
      src/render/assets/imgs/scan_login_icon.png
  34. 26 0
      src/render/components.d.ts
  35. 101 0
      src/render/components/FooterInfo/index.vue
  36. 392 0
      src/render/components/MyModal/index.vue
  37. 48 0
      src/render/components/register.ts
  38. 13 0
      src/render/index.html
  39. 26 0
      src/render/main.ts
  40. BIN
      src/render/public/favicon.ico
  41. 8 0
      src/render/router/index.ts
  42. 28 0
      src/render/router/routes.ts
  43. 10 0
      src/render/store/index.ts
  44. 27 0
      src/render/store/modules/app/index.ts
  45. 40 0
      src/render/store/modules/user/index.ts
  46. 66 0
      src/render/styles/animation.less
  47. 0 0
      src/render/styles/color.css
  48. 9 0
      src/render/styles/color.less
  49. 18 0
      src/render/styles/index.less
  50. 394 0
      src/render/styles/reset.less
  51. 91 0
      src/render/utils/crypto.ts
  52. 3 0
      src/render/utils/index.ts
  53. 169 0
      src/render/utils/request.ts
  54. 29 0
      src/render/utils/syncServerTime.ts
  55. 33 0
      src/render/utils/time.worker.ts
  56. 231 0
      src/render/utils/tool.ts
  57. 173 0
      src/render/views/CurExam/index.vue
  58. 90 0
      src/render/views/Login/AdminLogin.vue
  59. 44 0
      src/render/views/Login/EnvCheck.vue
  60. 129 0
      src/render/views/Login/IpSet.vue
  61. 112 0
      src/render/views/Login/LoginWays.vue
  62. 104 0
      src/render/views/Login/index.vue
  63. 52 0
      src/render/views/test.vue
  64. 17 0
      src/render/vite-env.d.ts
  65. 30 0
      static/load/index.html
  66. 130 0
      static/load/style.css
  67. 45 0
      tsconfig.json
  68. 14 0
      tsconfig.node.json
  69. 8 0
      types/app.d.ts
  70. 11 0
      types/global.d.ts
  71. 0 0
      uno.config.ts
  72. 85 0
      vite.config.mts

+ 0 - 0
.env


+ 0 - 0
.env.development


+ 0 - 0
.env.production


+ 7 - 0
.gitignore

@@ -0,0 +1,7 @@
+node_modules
+.DS_Store
+dist
+out
+*.local
+*.exe
+package-lock.json

+ 18 - 0
.npmrc

@@ -0,0 +1,18 @@
+shamefully-hoist = true
+auto-install-peers=true
+strict-peer-dependencies=false
+sass_binary_site=https://npmmirror.com/mirrors/node-sass/
+phantomjs_cdnurl=https://npmmirror.com/mirrors/phantomjs/
+
+electron_mirror=https://cdn.npmmirror.com/binaries/electron/
+electron_builder_binaries_mirror=https://npmmirror.com/mirrors/electron-builder-binaries/
+
+registry=https://registry.npmmirror.com
+chromedriver_cdnurl=https://npmmirror.com/mirrors/chromedriver
+profiler_binary_host_mirror=https://npmmirror.com/mirrors/node-inspector
+sharp_binary_host=https://npmmirror.com/mirrors/sharp
+sharp_libvips_binary_host=https://npmmirror.com/mirrors/sharp-libvips
+
+node-options=--max_old_space_size=4096
+
+

+ 45 - 0
.vscode/launch.json

@@ -0,0 +1,45 @@
+// {
+//   // Use IntelliSense to learn about possible attributes.
+//   // Hover to view descriptions of existing attributes.
+//   // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
+//   "version": "0.2.0",
+//   "configurations": [
+//     {
+//       "type": "node",
+//       "request": "launch",
+//       "name": "Launch Program",
+//       "skipFiles": ["<node_internals>/**"],
+//       "program": "${workspaceFolder}\\main.js",
+//       "outFiles": ["${workspaceFolder}/**/*.js"]
+//     }
+//   ]
+// }
+{
+  "version": "0.2.0",
+  "configurations": [
+    {
+      "type": "node",
+      "request": "launch",
+      "name": "Electron: Main",
+      "runtimeExecutable": "${workspaceFolder}/node_modules/.bin/electron",
+      "runtimeArgs": ["--remote-debugging-port=9223", "."],
+      "windows": {
+        "runtimeExecutable": "${workspaceFolder}/node_modules/.bin/electron.cmd"
+      }
+    },
+    {
+      "name": "Electron: Renderer",
+      "type": "chrome",
+      "request": "attach",
+      "port": 9223,
+      "webRoot": "${workspaceFolder}",
+      "timeout": 30000
+    }
+  ],
+  "compounds": [
+    {
+      "name": "Electron: All",
+      "configurations": ["Electron: Main", "Electron: Renderer"]
+    }
+  ]
+}

+ 18 - 0
README.md

@@ -0,0 +1,18 @@
+# 通用扫描管理端
+
+```sh
+
+# install dependency
+npm install
+
+# develop
+npm start
+
+#build
+
+npm run build
+
+#builder
+
+npm run builder
+```

+ 138 - 0
antdvNames.ts

@@ -0,0 +1,138 @@
+export default [
+  "Affix",
+  "Anchor",
+  "AnchorLink",
+  "AutoComplete",
+  "AutoCompleteOptGroup",
+  "AutoCompleteOption",
+  "Alert",
+  "Avatar",
+  "AvatarGroup",
+  "BackTop",
+  "Badge",
+  "BadgeRibbon",
+  "Breadcrumb",
+  "BreadcrumbItem",
+  "BreadcrumbSeparator",
+  "Button",
+  "ButtonGroup",
+  "Calendar",
+  "Card",
+  "CardGrid",
+  "CardMeta",
+  "Collapse",
+  "CollapsePanel",
+  "Carousel",
+  "Cascader",
+  "Checkbox",
+  "CheckboxGroup",
+  "Col",
+  "Comment",
+  "ConfigProvider",
+  "DatePicker",
+  "MonthPicker",
+  "WeekPicker",
+  "RangePicker",
+  "QuarterPicker",
+  "Descriptions",
+  "DescriptionsItem",
+  "Divider",
+  "Dropdown",
+  "DropdownButton",
+  "Drawer",
+  "Empty",
+  "Form",
+  "FormItem",
+  "FormItemRest",
+  "Grid",
+  "Input",
+  "InputGroup",
+  "InputPassword",
+  "InputSearch",
+  "Textarea",
+  "Image",
+  "ImagePreviewGroup",
+  "InputNumber",
+  "Layout",
+  "LayoutHeader",
+  "LayoutSider",
+  "LayoutFooter",
+  "LayoutContent",
+  "List",
+  "ListItem",
+  "ListItemMeta",
+  "Menu",
+  "MenuDivider",
+  "MenuItem",
+  "MenuItemGroup",
+  "SubMenu",
+  "Mentions",
+  "MentionsOption",
+  "Modal",
+  "Statistic",
+  "StatisticCountdown",
+  "PageHeader",
+  "Pagination",
+  "Popconfirm",
+  "Popover",
+  "Progress",
+  "Radio",
+  "RadioButton",
+  "RadioGroup",
+  "Rate",
+  "Result",
+  "Row",
+  "Select",
+  "SelectOptGroup",
+  "SelectOption",
+  "Skeleton",
+  "SkeletonButton",
+  "SkeletonAvatar",
+  "SkeletonInput",
+  "SkeletonImage",
+  "Slider",
+  "Space",
+  "Spin",
+  "Steps",
+  "Step",
+  "Switch",
+  "Table",
+  "TableColumn",
+  "TableColumnGroup",
+  "TableSummary",
+  "TableSummaryRow",
+  "TableSummaryCell",
+  "Transfer",
+  "Tree",
+  "TreeNode",
+  "DirectoryTree",
+  "TreeSelect",
+  "TreeSelectNode",
+  "Tabs",
+  "TabPane",
+  "Tag",
+  "CheckableTag",
+  "TimePicker",
+  "TimeRangePicker",
+  "Timeline",
+  "TimelineItem",
+  "Tooltip",
+  "Typography",
+  "TypographyLink",
+  "TypographyParagraph",
+  "TypographyText",
+  "TypographyTitle",
+  "Upload",
+  "UploadDragger",
+  "LocaleProvider",
+  "FloatButton",
+  "FloatButtonGroup",
+  "Qrcode",
+  "Watermark",
+  "Segmented",
+  "Tour",
+  "SpaceCompact",
+  "StyleProvider",
+  "Flex",
+  "App",
+];

+ 79 - 0
build/build.js

@@ -0,0 +1,79 @@
+const webpack = require("webpack");
+const { resolve } = require("path");
+const ora = require("ora");
+const { spawn } = require("child_process");
+const rm = require("rimraf");
+const chalk = require("chalk");
+const webpackConfig = require("./webpack.config.main.js");
+const electron = require("electron");
+const spinner = ora("building electron...");
+spinner.start();
+let electronProcess = null;
+
+const isDev = process.env.NODE_ENV !== "production";
+
+const startMain = () => {
+  return new Promise((resolve) => {
+    rm("./dist/main.js", (removeErr) => {
+      if (removeErr) {
+        throw removeErr;
+      }
+      webpackConfig.mode = isDev ? "development" : "production";
+      const compiler = webpack(webpackConfig);
+      compiler.watch({}, (err, stats) => {
+        if (err) throw err;
+        spinner.stop();
+        process.stdout.write(
+          stats.toString({
+            colors: true,
+            modules: false,
+            children: false,
+            chunks: false,
+            chunkModules: false,
+          }) + "\n\n"
+        );
+
+        if (stats.hasErrors()) {
+          console.log(chalk.red("Build failed with errors.\n"));
+          process.exit(1);
+        }
+        resolve();
+        console.log(
+          chalk.cyan(
+            `Build complete in ${stats.endTime - stats.startTime}ms.\n`
+          )
+        );
+
+        if (process.env.IS_BUILDER === "builder") process.exit();
+      });
+    });
+  });
+};
+
+const startElectron = () => {
+  if (process.env.IS_BUILDER === "builder") return;
+  var args = ["--inspect=5858", resolve(__dirname, "../dist/main.js")];
+  electronProcess = spawn(electron, args);
+
+  if (isDev) {
+    electronProcess.stdout.on("data", (data) => {
+      console.log(chalk.blue(data.toString()));
+    });
+    electronProcess.stderr.on("data", (data) => {
+      console.log(chalk.blue(data.toString()));
+    });
+  }
+
+  electronProcess.on("close", () => {
+    process.exit();
+  });
+};
+async function init() {
+  try {
+    await startMain();
+    await startElectron();
+  } catch (error) {
+    console.error(error);
+  }
+}
+init();

+ 53 - 0
build/builder.json

@@ -0,0 +1,53 @@
+{
+  "productName": "通用扫描管理端",
+  "appId": "com.electron.qmth.scan-admin",
+  "directories": {
+    "output": "out"
+  },
+  "asar": true,
+  "files": [
+    "**/*",
+    "!src/",
+    "!out/",
+    "!static/",
+    "*.exe",
+    "*.dll"
+  ],
+  "nsis": {
+    "oneClick": false,
+    "allowElevation": true,
+    "allowToChangeInstallationDirectory": true,
+    "installerIcon": "./icons/icon.ico",
+    "uninstallerIcon": "./icons/icon.ico",
+    "installerHeaderIcon": "./icons/icon.ico",
+    "createDesktopShortcut": true,
+    "createStartMenuShortcut": true,
+    "shortcutName": "通用扫描管理端"
+  },
+  "mac": {
+    "icon": "./icons/icon.icns",
+    "artifactName": "${productName}-${os}-${version}-${arch}.${ext}",
+    "darkModeSupport": true,
+    "hardenedRuntime": false
+  },
+  "win": {
+    "icon": "./icons/icon.ico",
+    "artifactName": "${productName}-${os}-${version}-${arch}.${ext}",
+    "target": [
+      {
+        "target": "portable",
+        "arch": [
+          "ia32"
+        ]
+      }
+    ]
+  },
+  "linux": {
+    "icon": "./icons/icon.icns",
+    "artifactName": "${productName}-${os}-${version}-${arch}.${ext}",
+    "target": [
+      "deb"
+    ],
+    "category": "Utility"
+  }
+}

BIN
build/icons/icon.ico


+ 17 - 0
build/start.js

@@ -0,0 +1,17 @@
+const concurrently = require("concurrently");
+concurrently(
+  [
+    { command: "npm run dev:vite", prefixColor: "green", name: "renderer" },
+    { command: "npm run dev:main", prefixColor: "magenta", name: "main" },
+  ],
+  {
+    killOthers: ["failure", "success"],
+  }
+).then(
+  () => {
+    console.log("exit!");
+  },
+  () => {
+    console.log("exit!");
+  }
+);

+ 49 - 0
build/webpack.config.main.js

@@ -0,0 +1,49 @@
+const { resolve } = require("path");
+const CopyWebpackPlugin = require("copy-webpack-plugin");
+
+module.exports = {
+  entry: {
+    main: resolve(__dirname, "../src/main/index.ts"),
+    preload: resolve(__dirname,"../src/main/preload/index.ts")
+  },
+  module: {
+    rules: [
+      {
+        test: /\.(js|jsx|tsx|ts)$/,
+        exclude: /node_modules/,
+        use: "esbuild-loader",
+      },
+      
+      {
+        test: /\.node$/,
+        exclude: /node_modules/,
+        use: "node-loader",
+      },
+    ],
+  },
+  output: {
+    filename: "[name].js",
+    libraryTarget: "commonjs2",
+    path: resolve(__dirname, "../dist"),
+  },
+  plugins: [
+    new CopyWebpackPlugin({
+      patterns: [
+        {
+          from: resolve(__dirname, "../static"),
+          to: resolve(__dirname, "../dist/static"),
+        },
+      ],
+    }),
+  ],
+  resolve: {
+    extensions: [".tsx", ".ts", ".js", ".json", ".node"],
+  },
+  watch: true,
+  watchOptions: {
+    poll: 1000, // 每秒询问多少次
+    aggregateTimeout: 500, //防抖 多少毫秒后再次触发
+    ignored: /node_modules/, //忽略时时监听
+  },
+  target: "electron-main",
+};

+ 72 - 0
package.json

@@ -0,0 +1,72 @@
+{
+  "name": "scan-admin",
+  "version": "2.0.0",
+  "description": "通用扫描管理端",
+  "author": "LiuYang",
+  "scripts": {
+    "start": "npm run dev",
+    "dev": "node build/start.js",
+    "builder": "npm run build:vite --mode production && cross-env-shell IS_BUILDER=builder npm run build:main && electron-builder --config=./build/builder.json",
+    "dev:main": "node build/build.js",
+    "build:main": "node build/build.js",
+    "dev:vite": "vite --mode development",
+    "build:vite": "vue-tsc --noEmit --skipLibCheck && vite build --mode production",
+    "check": "vue-tsc --noEmit --skipLibCheck"
+  },
+  "main": "dist/main.js",
+  "dependencies": {
+    "@ant-design/icons-vue": "^7.0.1",
+    "@qmth/ui": "^1.0.13",
+    "@vueuse/core": "^10.11.0",
+    "axios": "^1.5.0",
+    "core-js": "^3.32.2",
+    "crypto-js": "^4.2.0",
+    "echarts": "^5.5.1",
+    "element-resize-detector": "^1.2.4",
+    "less": "^4.2.0",
+    "lodash-es": "^4.17.21",
+    "mockjs": "^1.1.0",
+    "pinia": "^2.1.6",
+    "pinia-plugin-persistedstate": "^3.2.1",
+    "spark-md5": "^3.0.2",
+    "v3-drag-zoom": "^1.1.20",
+    "vue": "^3.4.32",
+    "vue-echarts": "^7.0.0-beta.0",
+    "vue-router": "^4.2.4"
+  },
+  "devDependencies": {
+    "@babel/core": "^7.11.6",
+    "@babel/plugin-transform-runtime": "^7.11.5",
+    "@babel/preset-env": "^7.11.5",
+    "@babel/preset-react": "^7.10.4",
+    "@types/lodash-es": "^4.17.12",
+    "@types/mockjs": "^1.0.10",
+    "@types/node": "^20.6.1",
+    "@vitejs/plugin-vue": "^4.3.4",
+    "@vitejs/plugin-vue-jsx": "3.0.1",
+    "@vue/compiler-sfc": "^3.3.4",
+    "babel-loader": "^8.1.0",
+    "chalk": "^4.1.0",
+    "concurrently": "^5.3.0",
+    "copy-webpack-plugin": "^6.1.1",
+    "cross-env": "^7.0.2",
+    "electron": "^26.2.1",
+    "electron-builder": "^24.6.4",
+    "electron-devtools-installer": "^3.1.1",
+    "electron-log": "^4.2.4",
+    "esbuild-loader": "^4.0.2",
+    "node-loader": "^1.0.1",
+    "ora": "^5.1.0",
+    "portfinder": "^1.0.28",
+    "rimraf": "^3.0.2",
+    "rollup-plugin-node-polyfills": "^0.2.1",
+    "unocss": "^0.61.5",
+    "unplugin-vue-components": "^0.27.3",
+    "unplugin-vue-setup-extend-plus": "^1.0.1",
+    "vite": "^4.4.9",
+    "wait-on": "^7.0.1",
+    "webpack": "^5.88.2",
+    "webpack-cli": "^5.1.4",
+    "webpack-dev-server": "^4.15.1"
+  }
+}

+ 142 - 0
src/main/index.ts

@@ -0,0 +1,142 @@
+import path from "path";
+import fs from "fs";
+import { app, BrowserWindow, Menu, screen, dialog, ipcMain } from "electron";
+import installExtension, { VUEJS3_DEVTOOLS } from "electron-devtools-installer";
+
+const isDev = process.env.NODE_ENV === "development";
+let win: any = null;
+let loadWin: any = null;
+
+function createWin() {
+  const { width, height } = screen.getPrimaryDisplay().bounds;
+  // Menu.setApplicationMenu(null);
+  // 创建浏览器窗口
+  win = new BrowserWindow({
+    width: 840,
+    // width: width,
+    height: 500,
+    // height: height,
+    frame: false,
+    resizable: false,
+    transparent: true,
+    webPreferences: {
+      preload: path.resolve(__dirname, "preload.js"),
+      nodeIntegration: true,
+    },
+  });
+
+  if (isDev) {
+    win.loadURL(`http://localhost:8090`);
+  } else {
+    win.loadFile(path.resolve(__dirname, "index.html"));
+    //现场环境可能需要配置config.json来获取web页面地址
+    // const filePath = path.join(
+    //   process.env.PORTABLE_EXECUTABLE_DIR as string,
+    //   "config.json"
+    // );
+    // if (!fs.existsSync(filePath)) {
+    //   dialog.showErrorBox("找不到配置文件", "config.json文件不存在!");
+    //   app.quit();
+    // } else {
+    //   fs.readFile(filePath, "utf-8", function (err, data) {
+    //     if (!err) {
+    //       let da: { url?: string } = {};
+    //       try {
+    //         da = JSON.parse(data);
+    //       } catch (error) {
+    //         dialog.showErrorBox(
+    //           "错误",
+    //           "您的config.json文件内容格式可能不正确,请检查!"
+    //         );
+    //         app.quit();
+    //       }
+    //       const url = da.url || "";
+    //       if (!!url) {
+    //         win.loadURL(url);
+    //       }
+    //     } else {
+    //       dialog.showErrorBox("错误", JSON.stringify(err));
+    //       app.quit();
+    //     }
+    //   });
+    // }
+  }
+
+  win.webContents.once("dom-ready", () => {
+    win?.show();
+  });
+  win.webContents.on("before-input-event", (event: any, input: any) => {
+    if (input.key === "F12") {
+      win.webContents.openDevTools();
+    }
+  });
+  // loadWin.destroy();
+
+  win.on("closed", () => {
+    win = null;
+  });
+}
+
+function createLoadWin() {
+  Menu.setApplicationMenu(null);
+  loadWin = new BrowserWindow({
+    width: 840,
+    height: 500,
+    backgroundColor: "#222",
+    frame: false,
+    transparent: true,
+    skipTaskbar: true,
+    resizable: false,
+    webPreferences: { experimentalFeatures: true },
+  });
+
+  loadWin.loadFile(path.resolve(__dirname, "static/load/index.html"));
+
+  loadWin.show();
+
+  setTimeout(async () => {
+    if (isDev) {
+      try {
+        await installExtension(VUEJS3_DEVTOOLS);
+      } catch (e: any) {
+        console.error("Vue Devtools failed to install:", e.toString());
+      }
+    }
+    createWin();
+  }, 2200);
+
+  loadWin.on("closed", () => {
+    loadWin = null;
+  });
+}
+
+// app.isReady()
+//   ? createLoadWin()
+//   : app.on("ready", () => {
+//       createLoadWin();
+//     });
+
+ipcMain.on("change-win-size", (event, args) => {
+  const { width, height } = screen.getPrimaryDisplay().workAreaSize;
+  let w = args === "big" ? width : 840;
+  let h = args === "big" ? height : 500;
+  if (args === "small") {
+    win.setMinimumSize(w, h);
+  }
+  win.setSize(w, h);
+  win.center();
+});
+ipcMain.on("window-min", () => {
+  win.minimize();
+});
+
+app.on("ready", async () => {
+  if (isDev) {
+    try {
+      await installExtension(VUEJS3_DEVTOOLS);
+    } catch (e: any) {
+      console.error("Vue Devtools failed to install:", e.toString());
+    }
+  }
+  createWin();
+});

+ 19 - 0
src/main/preload/index.ts

@@ -0,0 +1,19 @@
+/*
+ * 如果启用了上下文隔离,渲染进程无法使用electron的api,
+ * 可通过contextBridge 导出api给渲染进程使用
+ */
+
+const { contextBridge, ipcRenderer } = require("electron");
+const os = require("os");
+
+contextBridge.exposeInMainWorld("electronApi", {
+  getComputerName: () => {
+    return os.hostname();
+  },
+  changeWinSize: (type: "big" | "small") => {
+    ipcRenderer.send("change-win-size", type);
+  },
+  windowMin: () => {
+    ipcRenderer.send("window-min");
+  },
+});

+ 29 - 0
src/render/App.vue

@@ -0,0 +1,29 @@
+<template>
+  <qm-config-provider>
+    <a-spin
+      :tip="appStore.loadingStatus.tip"
+      :spinning="appStore.loadingStatus.spinning"
+    >
+      <div class="app-in" @dblclick="devReload">
+        <router-view />
+      </div>
+    </a-spin>
+  </qm-config-provider>
+</template>
+
+<script lang="ts" name="App" setup>
+import { useAppStore } from "./store";
+const appStore = useAppStore();
+const devReload = () => {
+  //暗功能:electron环境下,如果dev环境下想刷新页面的话,双击页面闲置区域
+  if (window.electronApi && import.meta.env?.DEV) {
+    location.reload();
+  }
+};
+</script>
+<style lang="less">
+.app-in {
+  width: 100vw;
+  height: 100vh;
+}
+</style>

+ 105 - 0
src/render/Layout/index.vue

@@ -0,0 +1,105 @@
+<template>
+  <div class="layout h-full">
+    <div class="layout-header flex items-center justify-between">
+      <div class="bread-space">
+        <img src="../../assets/imgs/bread_prefix_icon.png" />
+        <div class="my-bread flex items-center">
+          <div v-for="(r, i) in matched" :key="r.path">
+            <span :class="{ 'cur-page': r.name === route.name }">{{
+              r.meta?.title || ""
+            }}</span>
+            <span class="gt" v-if="i < matched.length - 1">&gt;</span>
+          </div>
+        </div>
+      </div>
+      <div class="user-space">
+        <qm-button type="text" :icon="h(UserOutlined)">admin1</qm-button>
+        <qm-button
+          type="text"
+          :icon="h(MinusOutlined)"
+          class="m-l-6px"
+          @click="windowToMin"
+          >最小化</qm-button
+        >
+        <qm-button
+          type="text"
+          :icon="h(LogoutOutlined)"
+          class="m-l-6px"
+          @click="toLogout"
+          >退出账号</qm-button
+        >
+      </div>
+    </div>
+    <div class="radius-wrap">
+      <div class="sub-page-wrap">
+        <router-view />
+      </div>
+    </div>
+    <FooterInfo :height="38" bgColor="#f2f3f5"></FooterInfo>
+  </div>
+</template>
+
+<script setup name="AppLayout" lang="ts">
+import { h, computed } from "vue";
+import { useRoute } from "vue-router";
+import FooterInfo from "@/components/FooterInfo/index.vue";
+import {
+  UserOutlined,
+  MinusOutlined,
+  LogoutOutlined,
+} from "@ant-design/icons-vue";
+import { useUserStore } from "@/store";
+
+const userStore = useUserStore();
+const route = useRoute();
+const matched = computed(() => {
+  return route.matched || [];
+});
+
+const windowToMin = () => {
+  window.electronApi?.windowMin();
+};
+
+const toLogout = () => {
+  userStore.logout();
+};
+</script>
+<style lang="less" scoped>
+.layout {
+  background: #f2f3f5;
+  .layout-header {
+    height: 54px;
+    padding: 0 24px;
+    .bread-space {
+      height: 100%;
+      display: flex;
+      align-items: center;
+      & > img {
+        height: 22px;
+      }
+      .my-bread {
+        margin-left: 10px;
+        color: @text-color3;
+        height: 100%;
+        .cur-page {
+          color: @text-color1;
+        }
+        .gt {
+          margin: 0 4px;
+        }
+      }
+    }
+  }
+  .radius-wrap {
+    border-radius: 12px;
+    overflow: hidden;
+    margin: 0 16px;
+
+    height: calc(100% - 92px);
+    .sub-page-wrap {
+      overflow: auto;
+      height: 100%;
+    }
+  }
+}
+</style>

+ 12 - 0
src/render/ap/exam.ts

@@ -0,0 +1,12 @@
+import { request } from "@/utils/request";
+
+export const getExamList = (data: {
+  enable?: boolean;
+  pageNumber?: number;
+  pageSize?: number;
+}) =>
+  request({
+    url: "/api/admin/exam/list",
+    method: "post",
+    data,
+  });

+ 15 - 0
src/render/ap/mock/exam.ts

@@ -0,0 +1,15 @@
+import Mock from "mockjs";
+Mock.mock("/api/admin/exam/list", "post", {
+  result: [
+    {
+      id: 123,
+      schoolName: "学校名称",
+      name: "考试名称",
+      mode: "COMMON",
+      enable: true,
+      updateTime: 123456,
+    },
+  ],
+  totalCount: 100,
+  pageCount: 10,
+});

+ 1 - 0
src/render/ap/mock/index.ts

@@ -0,0 +1 @@
+import "./exam.ts";

BIN
src/render/assets/icons/256x256.png


BIN
src/render/assets/icons/electron.png


BIN
src/render/assets/icons/icon.icns


BIN
src/render/assets/icons/icon.ico


BIN
src/render/assets/imgs/admin_login_icon.png


BIN
src/render/assets/imgs/bread_prefix_icon.png


BIN
src/render/assets/imgs/cur_exam_bg.png


BIN
src/render/assets/imgs/env_check.png


BIN
src/render/assets/imgs/login_left_bg.png


BIN
src/render/assets/imgs/maohao.png


BIN
src/render/assets/imgs/none_data.png


BIN
src/render/assets/imgs/scan_login_icon.png


+ 26 - 0
src/render/components.d.ts

@@ -0,0 +1,26 @@
+/* eslint-disable */
+// @ts-nocheck
+// Generated by unplugin-vue-components
+// Read more: https://github.com/vuejs/core/pull/3399
+export {}
+
+/* prettier-ignore */
+declare module 'vue' {
+  export interface GlobalComponents {
+    ABreadcrumb: typeof import('@qmth/ui')['Breadcrumb']
+    AButton: typeof import('@qmth/ui')['Button']
+    AInput: typeof import('@qmth/ui')['Input']
+    ARadio: typeof import('@qmth/ui')['Radio']
+    ARadioGroup: typeof import('@qmth/ui')['RadioGroup']
+    ASpin: typeof import('@qmth/ui')['Spin']
+    ATag: typeof import('@qmth/ui')['Tag']
+    QmButton: typeof import('@qmth/ui')['QmButton']
+    QmConfigProvider: typeof import('@qmth/ui')['QmConfigProvider']
+    QmDateRangePicker: typeof import('@qmth/ui')['QmDateRangePicker']
+    QmInput: typeof import('@qmth/ui')['QmInput']
+    QmLowForm: typeof import('@qmth/ui')['QmLowForm']
+    QmModal: typeof import('@qmth/ui')['QmModal']
+    RouterLink: typeof import('vue-router')['RouterLink']
+    RouterView: typeof import('vue-router')['RouterView']
+  }
+}

+ 101 - 0
src/render/components/FooterInfo/index.vue

@@ -0,0 +1,101 @@
+<template>
+  <div
+    class="footer-info flex items-center"
+    :style="{
+      height: height + 'px',
+      backgroundColor: bgColor,
+      borderColor: bgColor !== '#fff' ? 'transparent' : '#e5e5e5',
+    }"
+  >
+    <div class="grid1 h-full">
+      <FundProjectionScreenOutlined class="icon" />
+      <span class="txt">{{ hostname }}</span>
+    </div>
+    <div class="grid2 h-full">
+      <ClockCircleOutlined class="icon" />
+      <span class="txt">{{ timeStr }}</span>
+    </div>
+    <div class="grid3 h-full">
+      <InfoCircleOutlined class="icon" />
+      <span class="txt">v2.0.0</span>
+    </div>
+  </div>
+</template>
+<script name="FooterInfo" lang="ts" setup>
+import { onMounted, onUnmounted, ref } from "vue";
+import { useAppStore } from "@/store";
+import { dateFormat } from "@/utils/tool";
+import TimeInterval from "@/utils/time.worker?worker";
+import {
+  ClockCircleOutlined,
+  FundProjectionScreenOutlined,
+  InfoCircleOutlined,
+} from "@ant-design/icons-vue";
+
+const props = withDefaults(
+  defineProps<{
+    height?: number;
+    bgColor?: string;
+  }>(),
+  {
+    height: 42,
+    bgColor: "#fff",
+  }
+);
+const appStore = useAppStore();
+
+const timeStr = ref("");
+const renderTimeStr = () => {
+  timeStr.value = dateFormat(
+    Date.now() + appStore.timeDiff,
+    "yyyy-MM-dd HH:mm:ss"
+  );
+};
+
+const timeInterval = new TimeInterval();
+timeInterval.onmessage = (e: any) => {
+  if (e.data === "") {
+    renderTimeStr();
+  }
+};
+
+const hostname = ref("");
+onMounted(() => {
+  hostname.value = window.electronApi?.getComputerName?.();
+  timeInterval.postMessage(true);
+});
+onUnmounted(() => {
+  timeInterval.postMessage(false);
+});
+</script>
+<style lang="less" scoped>
+.footer-info {
+  border-top: 1px solid #e5e5e5;
+  padding: 11px 0;
+  .txt {
+    font-size: 14px;
+    margin-left: 4px;
+  }
+  & > div {
+    display: flex;
+    justify-content: center;
+    align-items: center;
+  }
+  .icon {
+    color: @text-color3;
+    font-size: 16px;
+  }
+  .grid2 {
+    min-width: 200px;
+    flex: 1;
+  }
+  .grid1 {
+    flex: 1;
+    border-right: 1px solid #d9d9d9;
+  }
+  .grid3 {
+    flex: 1;
+    border-left: 1px solid #d9d9d9;
+  }
+}
+</style>

+ 392 - 0
src/render/components/MyModal/index.vue

@@ -0,0 +1,392 @@
+<template>
+    <div
+      ref="modalWrapRef"
+      class="draggable-modal"
+      :class="{ fullscreen: fullscreenModel }"
+    >
+      <Modal
+        v-bind="omit(props, ['open', 'onCancel', 'onOk', 'onUpdate:open'])"
+        v-model:open="openModel"
+        :mask-closable="false"
+        :get-container="() => modalWrapRef"
+        :width="innerWidth || width"
+        @ok="emit('ok')"
+        @cancel="emit('cancel')"
+      >
+        <template #title>
+          <slot name="title">{{ $attrs.title || "&nbsp;" }}</slot>
+        </template>
+        <template #closeIcon>
+          <slot name="closeIcon">
+            <Space v-if="showFullScreen" class="ant-modal-operate" @click.stop>
+              <FullscreenOutlined
+                v-if="!fullscreenModel"
+                @click="fullscreenModel = true"
+              />
+              <FullscreenExitOutlined v-else @click="restore" />
+              <CloseOutlined @click="closeModal" />
+            </Space>
+            <span v-else @click.stop>
+              <CloseOutlined @click="closeModal" />
+            </span>
+          </slot>
+        </template>
+        <slot>
+          ① 窗口可以拖动;<br />
+          ② 窗口可以通过八个方向改变大小;<br />
+          ③ 窗口可以最小化、最大化、还原、关闭;<br />
+          ④ 限制窗口最小宽度/高度。
+        </slot>
+        <template v-if="$slots.footer" #footer>
+          <slot name="footer" />
+        </template>
+      </Modal>
+    </div>
+  </template>
+  
+  <script lang="ts" name="QmModal" setup>
+  import { ref, watch, nextTick, onMounted } from "vue";
+  import { modalProps } from "ant-design-vue/es/modal/Modal";
+  import {
+    CloseOutlined,
+    FullscreenOutlined,
+    FullscreenExitOutlined,
+  } from "@ant-design/icons-vue";
+  import { throttle, omit } from "lodash-es";
+  import { Modal, Space } from "ant-design-vue";
+  
+  const props = defineProps({
+    ...modalProps(),
+    fullscreen: {
+      type: Boolean,
+      default: false,
+    },
+    showFullScreen: {
+      type: Boolean,
+      default: false,
+    },
+  });
+  
+  const emit = defineEmits(["update:open", "update:fullscreen", "ok", "cancel"]);
+  
+  const openModel = defineModel<boolean>("open");
+  const fullscreenModel = ref(props.fullscreen);
+  const innerWidth = ref("");
+  
+  const cursorStyle = {
+    top: "n-resize",
+    left: "w-resize",
+    right: "e-resize",
+    bottom: "s-resize",
+    topLeft: "nw-resize",
+    topright: "ne-resize",
+    bottomLeft: "sw-resize",
+    bottomRight: "se-resize",
+    auto: "auto",
+  } as const;
+  
+  // 是否已经初始化过了
+  let inited = false;
+  const modalWrapRef = ref<HTMLDivElement>();
+  
+  const closeModal = () => {
+    openModel.value = false;
+    emit("cancel");
+  };
+  
+  // 居中弹窗
+  const centerModal = async () => {
+    await nextTick();
+    const modalEl =
+      modalWrapRef.value?.querySelector<HTMLDivElement>(".ant-modal");
+  
+    if (modalEl && modalEl.getBoundingClientRect().left < 1) {
+      modalEl.style.left = `${
+        (document.documentElement.clientWidth - modalEl.offsetWidth) / 2
+      }px`;
+    }
+  };
+  
+  const restore = async () => {
+    fullscreenModel.value = false;
+    centerModal();
+  };
+  
+  const registerDragTitle = (
+    dragEl: HTMLDivElement,
+    handleEl: HTMLDivElement
+  ) => {
+    handleEl.style.cursor = "move";
+    handleEl.onmousedown = throttle((e: MouseEvent) => {
+      if (fullscreenModel.value) return;
+      document.body.style.userSelect = "none";
+      const disX = e.clientX - dragEl.getBoundingClientRect().left;
+      const disY = e.clientY - dragEl.getBoundingClientRect().top;
+      const mousemove = (event: MouseEvent) => {
+        if (fullscreenModel.value) return;
+        let iL = event.clientX - disX;
+        let iT = event.clientY - disY;
+        const maxL = document.documentElement.clientWidth - dragEl.offsetWidth;
+        const maxT = document.documentElement.clientHeight - dragEl.offsetHeight;
+  
+        iL <= 0 && (iL = 0);
+        iT <= 0 && (iT = 0);
+        iL >= maxL && (iL = maxL);
+        iT >= maxT && (iT = maxT);
+  
+        dragEl.style.left = `${Math.max(iL, 0)}px`;
+        dragEl.style.top = `${Math.max(iT, 0)}px`;
+      };
+      const mouseup = () => {
+        document.removeEventListener("mousemove", mousemove);
+        document.removeEventListener("mouseup", mouseup);
+        document.body.style.userSelect = "auto";
+      };
+  
+      document.addEventListener("mousemove", mousemove);
+      document.addEventListener("mouseup", mouseup);
+    }, 20);
+  };
+  
+  const initDrag = async () => {
+    await nextTick();
+    const modalWrapRefEl = modalWrapRef.value!;
+    const modalWrapEl =
+      modalWrapRefEl.querySelector<HTMLDivElement>(".ant-modal-wrap");
+    const modalEl = modalWrapRefEl.querySelector<HTMLDivElement>(".ant-modal");
+    if (modalWrapEl && modalEl) {
+      centerModal();
+      const headerEl = modalEl.querySelector<HTMLDivElement>(".ant-modal-header");
+      headerEl && registerDragTitle(modalEl, headerEl);
+  
+      modalWrapEl.onmousemove = throttle((event: MouseEvent) => {
+        if (fullscreenModel.value) return;
+        const left = event.clientX - modalEl.offsetLeft;
+        const top = event.clientY - modalEl.offsetTop;
+        const right = event.clientX - modalEl.offsetWidth - modalEl.offsetLeft;
+        const bottom = event.clientY - modalEl.offsetHeight - modalEl.offsetTop;
+        const isLeft = left <= 0 && left > -8;
+        const isTop = top < 5 && top > -8;
+        const isRight = right >= 0 && right < 8;
+        const isBottom = bottom > -5 && bottom < 8;
+        // 向左
+        if (isLeft && top > 5 && bottom < -5) {
+          modalWrapEl.style.cursor = cursorStyle.left;
+          // 向上
+        } else if (isTop && left > 5 && right < -5) {
+          modalWrapEl.style.cursor = cursorStyle.top;
+          // 向右
+        } else if (isRight && top > 5 && bottom < -5) {
+          modalWrapEl.style.cursor = cursorStyle.right;
+          // 向下
+        } else if (isBottom && left > 5 && right < -5) {
+          modalWrapEl.style.cursor = cursorStyle.bottom;
+          // 左上角
+        } else if (left > -8 && left <= 5 && top <= 5 && top > -8) {
+          modalWrapEl.style.cursor = cursorStyle.topLeft;
+          // 左下角
+        } else if (left > -8 && left <= 5 && bottom <= 5 && bottom > -8) {
+          modalWrapEl.style.cursor = cursorStyle.bottomLeft;
+          // 右上角
+        } else if (right < 8 && right >= -5 && top <= 5 && top > -8) {
+          modalWrapEl.style.cursor = cursorStyle.topright;
+          // 右下角
+        } else if (right < 8 && right >= -5 && bottom <= 5 && bottom > -8) {
+          modalWrapEl.style.cursor = cursorStyle.bottomRight;
+        } else {
+          modalWrapEl.style.cursor = cursorStyle.auto;
+        }
+      }, 20);
+      modalWrapEl.onmousedown = (e: MouseEvent) => {
+        if (fullscreenModel.value) return;
+        const {
+          top: iParentTop,
+          bottom: iParentBottom,
+          left: iParentLeft,
+          right: iParentRight,
+        } = modalEl.getBoundingClientRect();
+  
+        const disX = e.clientX - iParentLeft;
+        const disY = e.clientY - iParentTop;
+        const iParentWidth = modalEl.offsetWidth;
+        const iParentHeight = modalEl.offsetHeight;
+  
+        const cursor = modalWrapEl.style.cursor;
+  
+        const mousemove = throttle((event: MouseEvent) => {
+          if (fullscreenModel.value) return;
+          if (cursor !== cursorStyle.auto) {
+            document.body.style.userSelect = "none";
+          }
+          const mLeft = `${Math.max(0, event.clientX - disX)}px`;
+          const mTop = `${Math.max(0, event.clientY - disY)}px`;
+          const mLeftWidth = `${Math.min(
+            iParentRight,
+            iParentWidth + iParentLeft - event.clientX
+          )}px`;
+          const mRightWidth = `${Math.min(
+            window.innerWidth - iParentLeft,
+            event.clientX - iParentLeft
+          )}px`;
+          const mTopHeight = `${Math.min(
+            iParentBottom,
+            iParentHeight + iParentTop - event.clientY
+          )}px`;
+          const mBottomHeight = `${Math.min(
+            window.innerHeight - iParentTop,
+            event.clientY - iParentTop
+          )}px`;
+  
+          // 向左边拖拽
+          if (cursor === cursorStyle.left) {
+            modalEl.style.left = mLeft;
+            modalEl.style.width = mLeftWidth;
+            // 向上边拖拽
+          } else if (cursor === cursorStyle.top) {
+            modalEl.style.top = mTop;
+            modalEl.style.height = mTopHeight;
+            // 向右边拖拽
+          } else if (cursor === cursorStyle.right) {
+            modalEl.style.width = mRightWidth;
+            // 向下拖拽
+          } else if (cursor === cursorStyle.bottom) {
+            modalEl.style.height = mBottomHeight;
+            // 左上角拖拽
+          } else if (cursor === cursorStyle.topLeft) {
+            modalEl.style.left = mLeft;
+            modalEl.style.top = mTop;
+            modalEl.style.height = mTopHeight;
+            modalEl.style.width = mLeftWidth;
+            // 右上角拖拽
+          } else if (cursor === cursorStyle.topright) {
+            modalEl.style.top = mTop;
+            modalEl.style.width = mRightWidth;
+            modalEl.style.height = mTopHeight;
+            // 左下角拖拽
+          } else if (cursor === cursorStyle.bottomLeft) {
+            modalEl.style.left = mLeft;
+            modalEl.style.width = mLeftWidth;
+            modalEl.style.height = mBottomHeight;
+            // 右下角拖拽
+          } else if (cursor === cursorStyle.bottomRight) {
+            modalEl.style.width = mRightWidth;
+            modalEl.style.height = mBottomHeight;
+          }
+          innerWidth.value = modalEl.style.width;
+        }, 20);
+  
+        const mouseup = () => {
+          document.removeEventListener("mousemove", mousemove);
+          document.removeEventListener("mouseup", mouseup);
+          document.body.style.userSelect = "auto";
+          modalWrapEl.style.cursor = cursorStyle.auto;
+        };
+  
+        document.addEventListener("mousemove", mousemove);
+        document.addEventListener("mouseup", mouseup);
+      };
+    }
+    inited = true;
+  };
+  
+  watch(openModel, async (val) => {
+    if ((val && Object.is(inited, false)) || props.destroyOnClose) {
+      setTimeout(() => {
+        initDrag();
+      });
+    }
+  });
+  onMounted(() => {
+    if ((openModel.value && Object.is(inited, false)) || props.destroyOnClose) {
+      setTimeout(() => {
+        initDrag();
+      });
+    }
+  });
+  </script>
+  
+  <style lang="less">
+  .draggable-modal {
+    &.fullscreen {
+      .ant-modal {
+        inset: 0 !important;
+        width: 100% !important;
+        max-width: 100vw !important;
+        height: 100% !important;
+      }
+  
+      .ant-modal-content {
+        width: 100% !important;
+        height: 100% !important;
+      }
+    }
+  
+    .ant-modal-wrap {
+      overflow-x: hidden;
+    }
+  
+    .ant-modal {
+      position: relative;
+      min-width: 200px;
+      min-height: 200px;
+      margin: 0;
+      padding: 0;
+  
+      .ant-modal-header {
+        user-select: none;
+      }
+  
+      .ant-modal-close {
+        top: 6px;
+        right: 30px;
+        background-color: transparent;
+        cursor: inherit;
+  
+        &:hover,
+        &:focus {
+          color: rgb(0 0 0 / 45%);
+        }
+  
+        .ant-space-item:hover .anticon,
+        .ant-space-item:focus .anticon {
+          color: rgb(0 0 0 / 75%);
+          text-decoration: none;
+        }
+  
+        .ant-modal-close-x {
+          width: 50px;
+          height: 50px;
+          line-height: 44px;
+  
+          .ant-space {
+            width: 100%;
+            height: 100%;
+          }
+        }
+      }
+  
+      .ant-modal-content {
+        /* width: ~'v-bind("props.width")px'; */
+        display: flex;
+        flex-direction: column;
+        width: 100%;
+        min-width: 200px;
+        height: 100%;
+        min-height: 200px;
+        padding-top: 0;
+        overflow: hidden;
+  
+        .ant-modal-header {
+          padding-top: 20px;
+        }
+  
+        .ant-modal-body {
+          flex: auto;
+          height: 100%;
+          overflow: auto;
+        }
+      }
+    }
+  }
+  </style>
+  

+ 48 - 0
src/render/components/register.ts

@@ -0,0 +1,48 @@
+import { use } from "echarts/core";
+import { CanvasRenderer } from "echarts/renderers";
+import {
+  BarChart,
+  LineChart,
+  PieChart,
+  RadarChart,
+  GaugeChart,
+} from "echarts/charts";
+import {
+  GridComponent,
+  TooltipComponent,
+  LegendComponent,
+  DataZoomComponent,
+  GraphicComponent,
+  TitleComponent,
+  GeoComponent,
+  LegendScrollComponent,
+  ToolboxComponent,
+} from "echarts/components";
+
+import MyModal from "./MyModal/index.vue";
+import FooterInfo from "./FooterInfo/index.vue";
+
+use([
+  CanvasRenderer,
+  BarChart,
+  LineChart,
+  PieChart,
+  RadarChart,
+  GaugeChart,
+  GridComponent,
+  TooltipComponent,
+  LegendComponent,
+  DataZoomComponent,
+  GraphicComponent,
+  TitleComponent,
+  GeoComponent,
+  LegendScrollComponent,
+  ToolboxComponent,
+]);
+
+export default {
+  install(Vue: any) {
+    Vue.component("MyModal", MyModal);
+    Vue.component("FooterInfo", FooterInfo);
+  },
+};

+ 13 - 0
src/render/index.html

@@ -0,0 +1,13 @@
+<!DOCTYPE html>
+<html lang="en">
+  <head>
+    <meta charset="UTF-8" />
+    <link rel="icon" href="/favicon.ico" />
+    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
+    <title>通用扫描管理端</title>
+  </head>
+  <body>
+    <div id="app"></div>
+    <script type="module" src="/main.ts"></script>
+  </body>
+</html>

+ 26 - 0
src/render/main.ts

@@ -0,0 +1,26 @@
+import { createApp } from "vue";
+import App from "./App.vue";
+import router from "./router";
+import piniaStore from "./store";
+import register from "./components/register";
+import V3DragZoom from "v3-drag-zoom";
+import { message, notification, Modal } from "@qmth/ui";
+import "@qmth/ui/lib/style.css";
+import "v3-drag-zoom/dist/style.css";
+import "virtual:uno.css";
+import "./styles/index.less";
+import "./ap/mock/index.ts";
+
+window.$message = message;
+window.$notification = notification;
+window.$info = Modal.info;
+window.$success = Modal.success;
+window.$error = Modal.error;
+window.$warning = Modal.warning;
+window.$confirm = Modal.confirm;
+window.$destroyAll = Modal.destroyAll;
+
+const app = createApp(App);
+
+app.use(router).use(piniaStore).use(register).use(V3DragZoom);
+app.mount("#app");

BIN
src/render/public/favicon.ico


+ 8 - 0
src/render/router/index.ts

@@ -0,0 +1,8 @@
+import { createRouter, createWebHashHistory } from 'vue-router';
+import routes from './routes'
+const router = createRouter({
+  history: createWebHashHistory(),
+  routes,
+});
+
+export default router;

+ 28 - 0
src/render/router/routes.ts

@@ -0,0 +1,28 @@
+import { RouteRecordRaw } from "vue-router";
+import Layout from "@/Layout/index.vue";
+const routes: RouteRecordRaw[] = [
+  {
+    path: "/",
+    name: "Login",
+    component: () => import("@/views/Login/index.vue"),
+  },
+  {
+    path: "/layout",
+    name: "Layout",
+    component: Layout,
+    meta: {
+      title: "首页",
+    },
+    children: [
+      {
+        path: "cur-exam",
+        name: "CurExam",
+        component: () => import("@/views/CurExam/index.vue"),
+        meta: {
+          title: "当前考试信息",
+        },
+      },
+    ],
+  },
+];
+export default routes;

+ 10 - 0
src/render/store/index.ts

@@ -0,0 +1,10 @@
+import { createPinia } from "pinia";
+import { useAppStore } from "./modules/app";
+import { useUserStore } from "./modules/user";
+import piniaPluginPersistedstate from "pinia-plugin-persistedstate";
+
+const pinia = createPinia();
+pinia.use(piniaPluginPersistedstate);
+
+export { useAppStore, useUserStore };
+export default pinia;

+ 27 - 0
src/render/store/modules/app/index.ts

@@ -0,0 +1,27 @@
+import { ref } from "vue";
+import { defineStore } from "pinia";
+
+export const useAppStore = defineStore<"app", any, any, any>("app", {
+  persist: {
+    storage: sessionStorage,
+    paths: ["timeDiff"],
+  },
+  state: () => ({
+    timeDiff: 0,
+    loadingStatus: {
+      spinning: false,
+      tip: "",
+    },
+  }),
+  actions: {
+    setState(data: any) {
+      this.$patch(data);
+    },
+    setLoading(spinning: boolean, tip = "") {
+      this.loadingStatus = {
+        spinning,
+        tip,
+      };
+    },
+  },
+});

+ 40 - 0
src/render/store/modules/user/index.ts

@@ -0,0 +1,40 @@
+import { ref } from "vue";
+import { defineStore } from "pinia";
+import { clearStorage } from "@/utils/tool";
+import router from "@/router";
+
+export const useUserStore = defineStore<"user", any, any, any>("user", {
+  persist: [
+    {
+      storage: sessionStorage,
+      paths: ["userInfo"],
+    },
+    {
+      storage: localStorage,
+      paths: ["curExam"],
+    },
+  ],
+  state: () => ({
+    userInfo: null,
+    curExam: null,
+  }),
+  actions: {
+    setCurExam(exam: Exam) {
+      this.curExam = exam;
+    },
+    setState(data: any) {
+      this.$patch(data);
+    },
+    resetUserInfo() {
+      this.$reset();
+    },
+    async logout() {
+      //todo 退出登录接口
+      // await logout();
+      clearStorage();
+      this.resetUserInfo();
+      router.push({ name: "Login" });
+      window.electronApi?.changeWinSize("small");
+    },
+  },
+});

+ 66 - 0
src/render/styles/animation.less

@@ -0,0 +1,66 @@
+.slide-down-enter-active,
+.slide-down-leave-active,
+.slide-up-enter-active,
+.slide-up-leave-active {
+  will-change: transform;
+  transition: all 0.15s ease;
+}
+// slide-down
+.slide-down-enter-from {
+  opacity: 0;
+  transform: translateY(-10px);
+}
+.slide-down-leave-to {
+  opacity: 0;
+  transform: translateY(10px);
+}
+
+// slide-up
+.slide-up-enter-from {
+  &:extend(.slide-down-leave-to);
+}
+.slide-up-leave-to {
+  &:extend(.slide-down-enter-from);
+}
+
+.zoom-fade-enter-active,
+.zoom-fade-leave-active {
+  transition: transform 0.1s, opacity 0.1s ease-out;
+}
+
+.zoom-fade-enter-from {
+  opacity: 0;
+  transform: scale(0.92);
+}
+
+.zoom-fade-leave-to {
+  opacity: 0;
+  transform: scale(1.06);
+}
+
+.drop-down-enter-active,
+.drop-down-leave-active {
+  transition: all 0.2s ease-out;
+}
+.drop-down-enter-from {
+  height: 0;
+}
+.drop-down-leave-top {
+  height: 100px;
+}
+
+/* fade-slide */
+.fade-slide-leave-active,
+.fade-slide-enter-active {
+  transition: all 0.2s cubic-bezier(0.645, 0.045, 0.355, 1);
+}
+
+.fade-slide-enter-from {
+  transform: translateX(-20px);
+  opacity: 0;
+}
+
+.fade-slide-leave-to {
+  transform: translateX(20px);
+  opacity: 0;
+}

+ 0 - 0
src/render/styles/color.css


+ 9 - 0
src/render/styles/color.less

@@ -0,0 +1,9 @@
+@text-color1: #262626;
+@text-color2: #595959;
+@text-color3: #8c8c8c;
+@border-color1: #e5e5e5;
+
+@brand-color: #165dff;
+@warning-color: #ff7d00;
+@error-color: #f53f3f;
+@success-color: #00b42a;

+ 18 - 0
src/render/styles/index.less

@@ -0,0 +1,18 @@
+@import "animation.less";
+@import "reset.less";
+html,
+body {
+  height: 100%;
+  width: 100%;
+  font-size: 14px;
+  color: @text-color1;
+}
+#app {
+  height: 100%;
+  background-color: #fff;
+  // min-width: 1200px;
+}
+.check-tag {
+  height: 30px;
+  line-height: 28px;
+}

+ 394 - 0
src/render/styles/reset.less

@@ -0,0 +1,394 @@
+/*
+1. Prevent padding and border from affecting element width. (https://github.com/mozdevs/cssremedy/issues/4)
+2. Allow adding a border to an element by just adding a border-width. (https://github.com/tailwindcss/tailwindcss/pull/116)
+*/
+*,
+::before,
+::after {
+  box-sizing: border-box; /* 1 */
+  border-width: 0; /* 2 */
+  border-style: solid; /* 2 */
+  border-color: currentColor; /* 2 */
+}
+
+/*
+1. Use a consistent sensible line-height in all browsers.
+2. Prevent adjustments of font size after orientation changes in iOS.
+3. Use a more readable tab size.
+4. Use the user's configured `sans` font-family by default.
+*/
+
+html {
+  //line-height: 1.5; /* 1 */
+  line-height: 1;
+  -webkit-text-size-adjust: 100%; /* 2 */
+  -moz-tab-size: 4; /* 3 */
+  tab-size: 4; /* 3 */
+  font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont,
+    'Segoe UI', Roboto, 'Helvetica Neue', Arial, 'Noto Sans', sans-serif,
+    'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji'; /* 4 */
+}
+html,
+body,
+#app {
+  height: 100%;
+}
+
+/*
+1. Remove the margin in all browsers.
+2. Inherit line-height from `html` so users can set them as a class directly on the `html` element.
+*/
+
+body {
+  margin: 0 !important; /* 1 */
+  //line-height: inherit; /* 2 */
+  line-height: 1 !important;
+}
+
+/*
+1. Add the correct height in Firefox.
+2. Correct the inheritance of border color in Firefox. (https://bugzilla.mozilla.org/show_bug.cgi?id=190655)
+3. Ensure horizontal rules are visible by default.
+*/
+
+hr {
+  height: 0; /* 1 */
+  color: inherit; /* 2 */
+  border-top-width: 1px; /* 3 */
+}
+
+/*
+Add the correct text decoration in Chrome, Edge, and Safari.
+*/
+
+abbr:where([title]) {
+  text-decoration: underline dotted;
+}
+
+/*
+Remove the default font size and weight for headings.
+*/
+
+//h1,
+//h2,
+//h3,
+//h4,
+//h5,
+//h6 {
+//  font-size: inherit;
+//  font-weight: inherit;
+//}
+
+/*
+Reset links to optimize for opt-in styling instead of opt-out.
+*/
+
+a {
+  color: inherit;
+  text-decoration: inherit;
+}
+
+/*
+Add the correct font weight in Edge and Safari.
+*/
+
+b,
+strong {
+  font-weight: bolder;
+}
+
+/*
+1. Use the user's configured `mono` font family by default.
+2. Correct the odd `em` font sizing in all browsers.
+*/
+
+code,
+kbd,
+samp,
+pre {
+  font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas,
+    'Liberation Mono', 'Courier New', monospace; /* 1 */
+  font-size: 1em; /* 2 */
+}
+
+/*
+Add the correct font size in all browsers.
+*/
+
+small {
+  font-size: 80%;
+}
+
+/*
+Prevent `sub` and `sup` elements from affecting the line height in all browsers.
+*/
+
+sub,
+sup {
+  font-size: 75%;
+  line-height: 0;
+  position: relative;
+  vertical-align: baseline;
+}
+
+sub {
+  bottom: -0.25em;
+}
+
+sup {
+  top: -0.5em;
+}
+
+/*
+1. Remove text indentation from table contents in Chrome and Safari. (https://bugs.chromium.org/p/chromium/issues/detail?id=999088, https://bugs.webkit.org/show_bug.cgi?id=201297)
+2. Correct table border color inheritance in all Chrome and Safari. (https://bugs.chromium.org/p/chromium/issues/detail?id=935729, https://bugs.webkit.org/show_bug.cgi?id=195016)
+3. Remove gaps between table borders by default.
+*/
+
+table {
+  text-indent: 0; /* 1 */
+  border-color: inherit; /* 2 */
+  border-collapse: collapse; /* 3 */
+}
+
+/*
+1. Change the font styles in all browsers.
+2. Remove the margin in Firefox and Safari.
+3. Remove default padding in all browsers.
+*/
+
+button,
+input,
+optgroup,
+select,
+textarea {
+  font-family: inherit; /* 1 */
+  font-size: 100%; /* 1 */
+  line-height: inherit; /* 1 */
+  color: inherit; /* 1 */
+  margin: 0; /* 2 */
+  padding: 0; /* 3 */
+}
+
+/*
+Remove the inheritance of text transform in Edge and Firefox.
+*/
+
+button,
+select {
+  text-transform: none;
+}
+
+/*
+1. Correct the inability to style clickable types in iOS and Safari.
+2. Remove default button styles.
+*/
+
+button,
+[type='button'],
+[type='reset'],
+[type='submit'] {
+  -webkit-appearance: button; /* 1 */
+  /* background-color: transparent; 2 */
+  background-image: none; /* 2 */
+}
+
+/*
+Use the modern Firefox focus style for all focusable elements.
+*/
+
+:-moz-focusring {
+  outline: auto;
+}
+
+/*
+Remove the additional `:invalid` styles in Firefox. (https://github.com/mozilla/gecko-dev/blob/2f9eacd9d3d995c937b4251a5557d95d494c9be1/layout/style/res/forms.css#L728-L737)
+*/
+
+:-moz-ui-invalid {
+  box-shadow: none;
+}
+
+/*
+Add the correct vertical alignment in Chrome and Firefox.
+*/
+
+progress {
+  vertical-align: baseline;
+}
+
+/*
+Correct the cursor style of increment and decrement buttons in Safari.
+*/
+
+::-webkit-inner-spin-button,
+::-webkit-outer-spin-button {
+  height: auto;
+}
+
+/*
+1. Correct the odd appearance in Chrome and Safari.
+2. Correct the outline style in Safari.
+*/
+
+[type='search'] {
+  -webkit-appearance: textfield; /* 1 */
+  outline-offset: -2px; /* 2 */
+}
+
+/*
+Remove the inner padding in Chrome and Safari on macOS.
+*/
+
+::-webkit-search-decoration {
+  -webkit-appearance: none;
+}
+
+/*
+1. Correct the inability to style clickable types in iOS and Safari.
+2. Change font properties to `inherit` in Safari.
+*/
+
+::-webkit-file-upload-button {
+  -webkit-appearance: button; /* 1 */
+  font: inherit; /* 2 */
+}
+
+/*
+Add the correct display in Chrome and Safari.
+*/
+
+summary {
+  display: list-item;
+}
+
+/*
+Removes the default spacing and border for appropriate elements.
+*/
+
+blockquote,
+dl,
+dd,
+h1,
+h2,
+h3,
+h4,
+h5,
+h6,
+hr,
+figure,
+p,
+pre {
+  margin: 0;
+}
+
+fieldset {
+  margin: 0;
+  padding: 0;
+}
+
+legend {
+  padding: 0;
+}
+
+ol,
+ul,
+menu {
+  list-style: none;
+  margin: 0;
+  padding: 0;
+}
+
+/*
+Prevent resizing textareas horizontally by default.
+*/
+
+textarea {
+  resize: vertical;
+}
+
+/*
+1. Reset the default placeholder opacity in Firefox. (https://github.com/tailwindlabs/tailwindcss/issues/3300)
+2. Set the default placeholder color to the user's configured gray 400 color.
+*/
+
+input::placeholder,
+textarea::placeholder {
+  opacity: 1; /* 1 */
+  //color: #9ca3af; /* 2 */
+}
+
+/*
+Set the default cursor for buttons.
+*/
+
+button,
+[role='button'] {
+  cursor: pointer;
+}
+
+/*
+Make sure disabled buttons don't get the pointer cursor.
+*/
+:disabled {
+  cursor: default;
+}
+
+/*
+1. Make replaced elements `display: block` by default. (https://github.com/mozdevs/cssremedy/issues/14)
+2. Add `vertical-align: middle` to align replaced elements more sensibly by default. (https://github.com/jensimmons/cssremedy/issues/14#issuecomment-634934210)
+   This can trigger a poorly considered lint error in some tools but is included by design.
+*/
+
+img,
+svg,
+video,
+canvas,
+audio,
+iframe,
+embed,
+object {
+  //display: block; /* 1 */
+  //vertical-align: middle; /* 2 */
+}
+
+/*
+Constrain images and videos to the parent width and preserve their intrinsic aspect ratio. (https://github.com/mozdevs/cssremedy/issues/14)
+*/
+
+img,
+video {
+  max-width: 100%;
+  max-height: 100%;
+  height: auto;
+}
+
+/*
+Ensure the default browser behavior of the `hidden` attribute.
+*/
+
+[hidden] {
+  display: none;
+}
+
+/*---滚动条默认显示样式--*/
+::-webkit-scrollbar-thumb {
+  background-color: #bbb;
+  border-radius: 3px;
+}
+/*---鼠标点击滚动条显示样式--*/
+::-webkit-scrollbar-thumb:hover {
+  background-color: #aaa;
+  border-radius: 3px;
+}
+/*---滚动条大小--*/
+::-webkit-scrollbar {
+  width: 6px;
+  height: 6px;
+}
+
+/*---滚动框背景样式--*/
+::-webkit-scrollbar-track-piece {
+  background-color: rgba(0, 0, 0, 0);
+  border-radius: 0;
+}

+ 91 - 0
src/render/utils/crypto.ts

@@ -0,0 +1,91 @@
+// const CryptoJS = require('crypto-js');
+import Base64 from "crypto-js/enc-base64";
+import Utf8 from "crypto-js/enc-utf8";
+import AES from "crypto-js/aes";
+import SHA1 from "crypto-js/sha1";
+import MD5 from "crypto-js/md5";
+import SparkMD5 from "spark-md5";
+
+export const getBase64 = (content: string) => {
+  const words = Utf8.parse(content);
+  const base64Str = Base64.stringify(words);
+
+  return base64Str;
+};
+
+export const getAES = (content: string) => {
+  const KEY = "1234567890123456";
+  const IV = "1234567890123456";
+
+  var key = Utf8.parse(KEY);
+  var iv = Utf8.parse(IV);
+  var encrypted = AES.encrypt(content, key, { iv: iv });
+  return encrypted.toString();
+};
+
+/**
+ * 获取authorisation
+ * @param {Object} infos 相关信息
+ * @param {String} type 类别:secret、token两种
+ */
+export const getAuthorization = (infos: any, type: "secret" | "token") => {
+  // {type} {invoker}:base64(sha1(method&uri&timestamp&{secret}))
+  if (type === "secret") {
+    // accessKey | method&uri&timestamp&accessSecret
+    const str = `${infos.method.toLowerCase()}&${infos.uri}&${
+      infos.timestamp
+    }&${infos.accessSecret}`;
+    const sign = Base64.stringify(SHA1(str));
+    return `Secret ${infos.accessKey}:${sign}`;
+  } else if (type === "token") {
+    // userId | method&uri&timestamp&token
+    const str = `${infos.method.toLowerCase()}&${infos.uri}&${
+      infos.timestamp
+    }&${infos.token}`;
+    const sign = Base64.stringify(SHA1(str));
+    return `Token ${infos.sessionId}:${sign}`;
+  }
+};
+
+/**
+ *
+ * @param {any} str 字符串
+ */
+export const getMD5 = (content: string) => {
+  return MD5(content);
+};
+
+// export const getFileMD5 = (file) => {
+//   return new Promise((resolve, reject) => {
+//     const reader = new FileReader();
+//     reader.onloadend = function () {
+//       const arrayBuffer = reader.result;
+//       resolve(MD5(arrayBuffer).toString());
+//     };
+//     reader.onerror = function (err) {
+//       reject(err);
+//     };
+//     reader.readAsArrayBuffer(file);
+//   });
+// };
+
+export const getFileMD5 = (dataFile: File) => {
+  return new Promise((rs, rj) => {
+    var fileReader = new FileReader();
+    var spark = new SparkMD5(); //创建md5对象(基于SparkMD5)
+    if (dataFile.size > 1024 * 1024 * 10) {
+      var data1 = dataFile.slice(0, 1024 * 1024 * 10); //将文件进行分块 file.slice(start,length)
+      fileReader.readAsBinaryString(data1); //将文件读取为二进制码
+    } else {
+      fileReader.readAsBinaryString(dataFile);
+    }
+    fileReader.onload = function (e: any) {
+      spark.appendBinary(e.target.result);
+      var md5 = spark.end();
+      rs(md5);
+    };
+    fileReader.onerror = function (err) {
+      rj(err);
+    };
+  });
+};

+ 3 - 0
src/render/utils/index.ts

@@ -0,0 +1,3 @@
+export const closeApp = () => {
+  window.close();
+};

+ 169 - 0
src/render/utils/request.ts

@@ -0,0 +1,169 @@
+import axios from "axios";
+import { download } from "@/utils/tool";
+import { get, isEmpty } from "lodash-es";
+import qs from "qs";
+// import { h } from 'vue';
+import { getAuthorization, getMD5 } from "./crypto";
+import router from "@/router";
+import { initSyncTime, fetchTime } from "./syncServerTime";
+import { local } from "@/utils/tool";
+import { useAppStore } from "@/store";
+import { message } from "@qmth/ui";
+
+function getDeviceId() {
+  let deviceId = local.get("deviceId");
+  if (deviceId) return deviceId;
+
+  if (!deviceId) {
+    deviceId = getMD5(Math.random() + "-" + Date.now());
+    local.set("deviceId", deviceId);
+    return deviceId;
+  }
+}
+
+function setAuth(config: any) {
+  let userSession = sessionStorage.getItem("user");
+  if (userSession && !config.noAuth) {
+    let user = JSON.parse(userSession).user?.userInfo;
+    if (!user) {
+      return;
+    }
+    const timestamp = fetchTime();
+    const authorization = getAuthorization(
+      {
+        method: config.method,
+        uri: config.url.split("?")[0].trim(),
+        timestamp,
+        sessionId: user.sessionId,
+        token: user.accessToken,
+      },
+      "token"
+    );
+    config.headers["Authorization"] = authorization;
+    config.headers["time"] = timestamp;
+  }
+}
+
+function createService() {
+  // 创建一个 axios 实例
+  const service = axios.create();
+
+  // HTTP request 拦截器
+  service.interceptors.request.use(
+    (config: any) => {
+      console.log("request config", config);
+      setAuth(config);
+      const appStore = useAppStore();
+      if (config.loading) {
+        appStore.setLoading(true);
+      }
+      if (config.download) {
+        config.responseType ??= "blob";
+      }
+      return config;
+    },
+    (error) => {
+      // 失败
+      return Promise.reject(error);
+    }
+  );
+  const rejectWhiteList: string[] = [];
+  // HTTP response 拦截器
+  service.interceptors.response.use(
+    (response: any) => {
+      initSyncTime(new Date(response.headers.date).getTime());
+      const appStore = useAppStore();
+      if (response.config.loading) {
+        appStore.setLoading(false);
+      }
+      // 以下代码看后端是否有统一在接口里增加外层code的规范
+      if (response.data?.code && response.data?.code !== 200) {
+        message.error(response.data?.message);
+      }
+      if (response.config.download && response.config.responseType === "blob") {
+        download(response);
+      }
+      return response.data;
+    },
+    (error) => {
+      if (error.response) {
+        initSyncTime(new Date(error.response.headers.date).getTime());
+      }
+      const appStore = useAppStore();
+      if (error.config?.loading) {
+        appStore.setLoading(true);
+      }
+      const err = (text: string) => {
+        !rejectWhiteList.includes(error.config?.url) &&
+          message.error(
+            error.response && error.response.data && error.response.data.message
+              ? error.response.data.message
+              : text
+          );
+      };
+      if (error.response) {
+        switch (error.response.status) {
+          case 404:
+            err(`服务器资源不存在`);
+            break;
+          case 500:
+            err(`服务器内部错误`);
+            break;
+          case 401:
+            message.error("登录状态已过期");
+            router.replace({ name: "Login" });
+            break;
+          case 403:
+            err(`没有权限访问该资源`);
+            break;
+          default:
+            err(JSON.stringify(error?.response || "未知错误"));
+        }
+      } else {
+        err("请求超时,服务器无响应!");
+      }
+      return Promise.reject(
+        error.response && error.response.data ? error.response.data : null
+      );
+    }
+  );
+  return service;
+}
+
+/**
+ * @description 创建请求方法
+ * @param {Object} service axios 实例
+ */
+function createRequest(service: any) {
+  return function (config: any) {
+    const env = process.env;
+    let headers = {
+      platform: "WEB",
+      deviceId: getDeviceId(),
+      ...(config.headers || {}),
+      "Content-Type": get(
+        config,
+        "headers.Content-Type",
+        "application/json;charset=UTF-8"
+      ),
+    };
+    const configDefault = {
+      method: "post",
+      timeout: 120000,
+      // baseURL: env.VITE_APP_PROXY_URL ? "" : env.VITE_APP_BASE_URL,
+      baseURL: env.VITE_APP_PROXY_URL ? "" : local.get("baseUrl") || "/",
+      data: {},
+    };
+    const option = Object.assign(configDefault, config, { headers: headers });
+
+    return service(option);
+  };
+}
+
+export function paramsSerializer(params: any) {
+  return qs.stringify(params);
+}
+
+// 用于真实网络请求的实例和请求方法
+export const service = createService();
+export const request = createRequest(service);

+ 29 - 0
src/render/utils/syncServerTime.ts

@@ -0,0 +1,29 @@
+let initLocalTime: number = 0;
+let initServerTime: number = 0;
+
+function getStorgeTime() {
+  let st: string = localStorage.getItem("st") || "";
+  const unvalidVals = ["Infinity", "NaN", "null", "undefined"];
+  if (unvalidVals.includes(st + "")) {
+    return [Date.now(), Date.now()];
+  } else {
+    const [s, t]: any = st.split("_");
+    return [s * 1, t * 1];
+  }
+}
+
+const [serverTime, localTime] = getStorgeTime();
+initSyncTime(serverTime, localTime);
+
+function initSyncTime(serverTime: number, localTime = Date.now()) {
+  initLocalTime = localTime;
+  initServerTime = serverTime;
+  localStorage.setItem("st", `${initServerTime}_${initLocalTime}`);
+}
+
+function fetchTime() {
+  console.log("time diff", initServerTime - initLocalTime);
+  return Date.now() + (initServerTime - initLocalTime || 0);
+}
+
+export { initSyncTime, fetchTime };

+ 33 - 0
src/render/utils/time.worker.ts

@@ -0,0 +1,33 @@
+class TimeInterval {
+  timer: any = null;
+  send() {
+    postMessage("");
+  }
+  isActive = false;
+
+  pause() {
+    if (this.timer) {
+      clearInterval(this.timer);
+      this.timer = null;
+    }
+    this.isActive = false;
+    postMessage(false);
+  }
+  resume() {
+    const func = this.send;
+    this.timer = setInterval(func, 1000);
+    this.isActive = true;
+    postMessage(true);
+  }
+}
+
+const onlineInterval = new TimeInterval();
+
+addEventListener("message", (e: any) => {
+  const data = e.data as boolean;
+  if (data) {
+    onlineInterval.resume();
+  } else {
+    onlineInterval.pause();
+  }
+});

+ 231 - 0
src/render/utils/tool.ts

@@ -0,0 +1,231 @@
+const storagePrefix = "cet_";
+/**
+ * LocalStorage
+ */
+export const local = {
+  set(table: string, settings: any) {
+    const _set = JSON.stringify(settings);
+    return localStorage.setItem(storagePrefix + table, _set);
+  },
+  get(table: string) {
+    let data: any = localStorage.getItem(storagePrefix + table);
+    try {
+      data = JSON.parse(data);
+    } catch (err) {
+      return null;
+    }
+    return data;
+  },
+  remove(table: string) {
+    return localStorage.removeItem(storagePrefix + table);
+  },
+  clear() {
+    return localStorage.clear();
+  },
+};
+
+/**
+ * SessionStorage
+ */
+export const session = {
+  set(table: string, settings: any) {
+    const _set = JSON.stringify(settings);
+    return sessionStorage.setItem(storagePrefix + table, _set);
+  },
+  get(table: string) {
+    let data: any = sessionStorage.getItem(storagePrefix + table);
+    try {
+      data = JSON.parse(data);
+    } catch (err) {
+      return null;
+    }
+    return data;
+  },
+  remove(table: string) {
+    return sessionStorage.removeItem(storagePrefix + table);
+  },
+  clear() {
+    return sessionStorage.clear();
+  },
+};
+
+export const clearStorage = () => {
+  localStorage.clear();
+  sessionStorage.clear();
+};
+
+export const generateId = function () {
+  return Math.floor(
+    Math.random() * 100000 + Math.random() * 20000 + Math.random() * 5000
+  );
+};
+
+/* 日期格式化 */
+export const dateFormat = (
+  date: any,
+  fmt = "yyyy/MM/dd HH:mm:ss",
+  isDefault = "-"
+) => {
+  if (!date) {
+    return "-";
+  }
+  if (date.toString().length === 10) {
+    date *= 1000;
+  }
+  date = new Date(date);
+
+  if (date.valueOf() < 1) {
+    return isDefault;
+  }
+  const o: any = {
+    "M+": date.getMonth() + 1, // 月份
+    "d+": date.getDate(), // 日
+    "H+": date.getHours(), // 小时
+    "m+": date.getMinutes(), // 分
+    "s+": date.getSeconds(), // 秒
+    "q+": Math.floor((date.getMonth() + 3) / 3), // 季度
+    S: date.getMilliseconds(), // 毫秒
+  };
+  if (/(y+)/.test(fmt)) {
+    fmt = fmt.replace(
+      RegExp.$1,
+      `${date.getFullYear()}`.substr(4 - RegExp.$1.length)
+    );
+  }
+  for (const k in o) {
+    if (new RegExp(`(${k})`).test(fmt)) {
+      fmt = fmt.replace(
+        RegExp.$1,
+        RegExp.$1.length === 1 ? o[k] : `00${o[k]}`.substr(`${o[k]}`.length)
+      );
+    }
+  }
+  return fmt;
+};
+
+export const extractFileName = (str: string) => {
+  if (/filename=([^;\s]*)/gi.test(str)) {
+    return decodeURIComponent(RegExp.$1);
+  }
+  return "下载文件";
+};
+
+export const download = (res: any, downName = "") => {
+  const aLink = document.createElement("a");
+  let fileName = downName;
+  let blob = res; // 第三方请求返回blob对象
+
+  // 通过后端接口返回
+  if (res.headers && res.data) {
+    blob = new Blob([res.data], {
+      type: res.headers["content-type"].replace(";charset=utf8", ""),
+    });
+    if (!downName) {
+      fileName = extractFileName(res.headers?.["content-disposition"]);
+    }
+  }
+
+  aLink.href = URL.createObjectURL(blob);
+  // 设置下载文件名称
+  aLink.setAttribute("download", fileName);
+  document.body.appendChild(aLink);
+  aLink.click();
+  document.body.removeChild(aLink);
+  URL.revokeObjectURL(aLink.href);
+};
+
+/**
+ * 下载url
+ * @param {String} url 文件下载地址
+ * @param {String}} filename 文件名
+ */
+export function downloadByUrl(url: string, filename: string) {
+  const tempLink = document.createElement("a");
+  tempLink.style.display = "none";
+  tempLink.href = url;
+  const fileName =
+    filename || url.split("/").pop()?.split("?")[0] || "未命名文件";
+  tempLink.setAttribute("download", fileName);
+  if (tempLink.download === "undefined") {
+    tempLink.setAttribute("target", "_blank");
+  }
+  document.body.appendChild(tempLink);
+  tempLink.click();
+  document.body.removeChild(tempLink);
+  window.URL.revokeObjectURL(url);
+}
+
+export function urlToBlob(url: string, cb: Function) {
+  const xhr = new XMLHttpRequest();
+  xhr.open("GET", url, true);
+  xhr.responseType = "blob";
+  xhr.onload = function () {
+    if (xhr.status == 200) {
+      cb(URL.createObjectURL(xhr.response));
+    }
+  };
+  xhr.send();
+}
+
+export function saveAs(blob: Blob, filename: string) {
+  if ((window as any).navigator.msSaveOrOpenBlob) {
+    (navigator as any).msSaveBlob(blob, filename);
+  } else {
+    var link: any = document.createElement("a");
+    var body = document.querySelector("body") as HTMLBodyElement;
+    link.href = blob;
+    link.download = filename;
+    link.style.display = "none";
+    body.appendChild(link);
+    link.click();
+    body.removeChild(link);
+    window.URL.revokeObjectURL(link.href);
+  }
+}
+
+export function downloadByCrossUrl(url: string, filename: string) {
+  urlToBlob(url, (blob: Blob) => {
+    saveAs(blob, filename);
+  });
+}
+
+export const getRequestParams = (url: string) => {
+  const theRequest: any = new Object();
+  if (url.indexOf("?") != -1) {
+    const params = url.split("?")[1].split("&");
+    for (let i = 0; i < params.length; i++) {
+      const param = params[i].split("=");
+      theRequest[param[0]] = decodeURIComponent(param[1]);
+    }
+  }
+  return theRequest;
+};
+
+/**
+ * 字典数据转成option list
+ * @param {Object} data 字典数据
+ * @returns list
+ */
+export const dictToOptions = (data: any) => {
+  return Object.keys(data).map((k) => {
+    const kstr = typeof k === "number" ? k : k + "";
+    return { value: kstr, label: data[k] };
+  });
+};
+
+/**
+ * 获取随机code,默认获取16位
+ * @param {Number} len 推荐8的倍数
+ *
+ */
+export function randomCode(len = 16) {
+  if (len <= 0) return;
+  let steps = Math.ceil(len / 8);
+  let stepNums = [];
+  for (let i = 0; i < steps; i++) {
+    let ranNum = Math.random().toString(32).slice(-8);
+    stepNums.push(ranNum);
+  }
+
+  return stepNums.join("");
+}

+ 173 - 0
src/render/views/CurExam/index.vue

@@ -0,0 +1,173 @@
+<template>
+  <div class="cur-exam h-full">
+    <div class="operate-box flex items-center justify-between">
+      <div class="lf h-full flex items-center" v-if="curExam">
+        <span class="no">{{ curExam?.id }}</span>
+        <qm-button type="primary" ghost size="small"
+          >{{ curExam?.mode }}模式</qm-button
+        >
+        <div class="exam-name">{{ curExam?.name }}</div>
+      </div>
+      <div v-else></div>
+      <div class="rt h-full flex items-center">
+        <qm-button :icon="h(SwapOutlined)" @click="showExamListModal = true"
+          >切换考试</qm-button
+        >
+        <qm-button class="ml-10px" :icon="h(EditOutlined)">编辑</qm-button>
+        <qm-button class="ml-10px" :icon="h(PlusCircleOutlined)" type="primary"
+          >新建</qm-button
+        >
+      </div>
+    </div>
+    <div class="page-main">
+      <div v-if="!curExam" class="flex justify-center items-center h-full">
+        <div class="none-center text-center">
+          <img src="../../assets/imgs/none_data.png" />
+          <p>当前没有考试,请新建考试</p>
+          <qm-button :icon="h(PlusCircleOutlined)" type="primary"
+            >新建</qm-button
+          >
+        </div>
+      </div>
+      <div v-else class="cur-exam-box h-full"></div>
+    </div>
+
+    <my-modal
+      v-model:open="showExamListModal"
+      v-if="showExamListModal"
+      title="切换考试"
+      @ok="chooseExamHandler"
+    >
+      <a-radio-group v-model:value="choosedExamId" class="exam-modal-body">
+        <a-radio
+          :style="radioStyle"
+          v-for="item in examList"
+          :key="item.id"
+          :value="item.id"
+        >
+          <div class="flex-1 flex items-center justify-between">
+            <span class="sub1">{{ item?.id }}</span>
+            <span class="sub2">{{ item?.name }}</span>
+            <span class="sub3">{{ item?.mode }}</span>
+          </div>
+        </a-radio>
+      </a-radio-group>
+    </my-modal>
+  </div>
+</template>
+<script name="CurExam" lang="ts" setup>
+import { onMounted, ref, computed, h, watch, reactive } from "vue";
+import { getExamList } from "@/ap/exam";
+import { useUserStore } from "@/store";
+import {
+  SwapOutlined,
+  EditOutlined,
+  PlusCircleOutlined,
+} from "@ant-design/icons-vue";
+
+const radioStyle = reactive({
+  display: "flex",
+  alignItems: "center",
+  justifyContent: "between",
+});
+
+const userStore = useUserStore();
+const curExam = computed(() => {
+  return userStore.curExam;
+});
+const choosedExamId = ref();
+watch(curExam, (exam: Exam) => {
+  choosedExamId.value = exam.id;
+});
+const examList = ref<Exam[]>([]);
+const showExamListModal = ref(false);
+const _getExamList = () => {
+  getExamList({ enable: true, pageNumber: 1, pageSize: 10000 }).then(
+    (res: any) => {
+      if (res?.result?.length) {
+        console.log("考试列表:", res.result);
+        examList.value = res.result || [];
+        if (showExamListModal.value) {
+          choosedExamId.value = res.result[0]?.id;
+        }
+      } else {
+        examList.value = [];
+      }
+    }
+  );
+};
+
+const chooseExamHandler = () => {
+  let exam = examList.value.find((item: any) => item.id == choosedExamId.value);
+  !!exam && userStore.setCurExam(exam);
+  showExamListModal.value = false;
+};
+
+onMounted(() => {
+  _getExamList();
+});
+watch(showExamListModal, (val: boolean) => {
+  if (val) {
+    _getExamList();
+  }
+});
+</script>
+<style lang="less" scoped>
+.cur-exam {
+  background-color: #fff;
+  .none-center {
+    & > img {
+      height: 70px;
+      margin-bottom: 6px;
+    }
+    & > p {
+      color: @text-color3;
+      margin-bottom: 16px;
+    }
+  }
+  .exam-modal-body {
+    width: 100%;
+    :deep(.ant-radio-wrapper > span:last-child) {
+      width: 100%;
+      display: flex;
+    }
+    .sub1,
+    .sub2 {
+      color: @text-color1;
+    }
+    .sub3 {
+      color: @text-color3;
+    }
+  }
+  .page-main {
+    height: calc(100% - 54px);
+    .cur-exam-box {
+      background: url(../../assets/imgs/cur_exam_bg.png) 0 0 no-repeat;
+      background-size: 100% 100%;
+    }
+  }
+  .operate-box {
+    height: 54px;
+    padding: 0 16px;
+    .lf {
+      .no {
+        width: 70px;
+        height: 24px;
+        background: linear-gradient(135deg, #fdd62d 0%, #faad14 100%);
+        border-radius: 4px;
+        border: 1px solid #ffc53d;
+        color: #fff;
+        margin-right: 6px;
+      }
+      .exam-name {
+        color: @text-color1;
+        font-size: 16px;
+        font-weight: bold;
+        margin-left: 6px;
+      }
+    }
+    .rt {
+    }
+  }
+}
+</style>

+ 90 - 0
src/render/views/Login/AdminLogin.vue

@@ -0,0 +1,90 @@
+<template>
+  <div class="admin-login">
+    <div class="title">管理员登录</div>
+    <div class="tip">输入账号和密码</div>
+    <qm-low-form
+      :fields="fields"
+      :params="params"
+      :labelWidth="1"
+    ></qm-low-form>
+    <div class="text-center">
+      <qm-button type="link" @click="emit('toIndex', 2)"
+        >返回选择登录身份</qm-button
+      >
+    </div>
+    <qm-button
+      :icon="h(GlobalOutlined)"
+      class="reset-btn"
+      @click="emit('toIndex', 0)"
+    >
+      连接设置</qm-button
+    >
+  </div>
+</template>
+<script name="AdminLogin" lang="ts" setup>
+import { reactive, ref, h } from "vue";
+import { useRouter } from "vue-router";
+import { GlobalOutlined } from "@ant-design/icons-vue";
+const emit = defineEmits(["toIndex"]);
+const params = reactive({ a: "12" });
+const router = useRouter();
+const fields = ref([
+  {
+    prop: "a",
+    colSpan: 24,
+    attrs: {
+      placeholder: "输入你的账号",
+      allowClear: true,
+      size: "large",
+    },
+  },
+  {
+    prop: "b",
+    type: "password",
+    colSpan: 24,
+    attrs: {
+      placeholder: "输入你的密码",
+      size: "large",
+    },
+  },
+  {
+    type: "button",
+    colSpan: 24,
+    text: "登录",
+    attrs: {
+      block: true,
+      size: "large",
+      onClick: () => {
+        //todo模拟登录请求...
+        router.push({ name: "CurExam" });
+        window.electronApi.changeWinSize("big");
+      },
+    },
+  },
+]);
+</script>
+<style lang="less" scoped>
+.admin-login {
+  padding: 88px 80px 0 80px;
+  position: relative;
+  :deep(.reset-btn) {
+    position: absolute;
+    top: 25px;
+    right: 65px;
+    z-index: 1;
+  }
+  .title {
+    color: @text-color1;
+    font-weight: bold;
+    line-height: 32px;
+    font-size: 24px;
+  }
+  .tip {
+    color: @text-color3;
+    line-height: 26px;
+    font-size: 18px;
+    margin-top: 4px;
+    margin-bottom: 28px;
+  }
+}
+</style>

+ 44 - 0
src/render/views/Login/EnvCheck.vue

@@ -0,0 +1,44 @@
+<template>
+  <div class="env-check h-full">
+    <img src="../../assets/imgs/env_check.png" />
+    <div class="txt">
+      <p class="title">正在检测</p>
+      <p class="desc">环境检测中,请稍等...</p>
+    </div>
+  </div>
+</template>
+<script name="EnvCheck" lang="ts" setup>
+import { onMounted, ref } from "vue";
+
+const emit = defineEmits(["mounted"]);
+onMounted(() => {
+  emit("mounted");
+});
+</script>
+<style lang="less" scoped>
+.env-check {
+  padding: 50px;
+  text-align: center;
+  position: relative;
+  img {
+    height: 100%;
+    max-width: 100%;
+  }
+  .txt {
+    position: absolute;
+    bottom: 75px;
+    width: 100%;
+    left: 0;
+    .title {
+      font-size: 24px;
+      line-height: 36px;
+      font-weight: bold;
+    }
+    .desc {
+      color: @text-color3;
+      font-size: 18px;
+      line-height: 26px;
+    }
+  }
+}
+</style>

+ 129 - 0
src/render/views/Login/IpSet.vue

@@ -0,0 +1,129 @@
+<template>
+  <div class="ip-set h-full">
+    <div class="flex flex-col h-full justify-between">
+      <div class="level1">
+        <div class="title">设置</div>
+        <div class="tip">输入服务器IP地址</div>
+        <div class="flex items-center">
+          <a-input v-model:value="ipData.ip1" class="ip-num" />
+          <span class="dian">●</span>
+          <a-input v-model:value="ipData.ip2" class="ip-num" />
+          <span class="dian">●</span>
+          <a-input v-model:value="ipData.ip3" class="ip-num" />
+          <span class="dian">●</span>
+          <a-input v-model:value="ipData.ip4" class="ip-num" />
+          <img class="maohao" src="../../assets/imgs/maohao.png" />
+          <a-input v-model:value="ipData.port" class="ip-num" />
+        </div>
+        <div class="pt-20px flex items-center">
+          <!-- <a-tag color="success" class="tag check-tag" v-if="showCheckResult">
+            <template #icon>
+              <check-circle-filled />
+            </template>
+            验证通过
+          </a-tag> -->
+          <a-tag color="error" class="tag check-tag" v-if="showCheckResult">
+            <template #icon>
+              <close-circle-filled />
+            </template>
+            验证失败,重新输入
+          </a-tag>
+        </div>
+      </div>
+      <div class="level2">
+        <div class="text-right">
+          <qm-button type="primary" @click="nextStep"
+            >下一步 <template #icon> <SwapRightOutlined /> </template
+          ></qm-button>
+        </div>
+        <footer-info></footer-info>
+      </div>
+    </div>
+  </div>
+</template>
+<script name="IpSet" lang="ts" setup>
+import { ref, computed, reactive } from "vue";
+import {
+  CheckCircleFilled,
+  CloseCircleFilled,
+  SwapRightOutlined,
+} from "@ant-design/icons-vue";
+import FooterInfo from "@/components/FooterInfo";
+import { local } from "@/utils/tool";
+
+const emit = defineEmits(["next"]);
+const ipData = reactive({
+  ip1: "",
+  ip2: "",
+  ip3: "",
+  ip4: "",
+  port: "",
+});
+const checkClicked = ref(false);
+
+const IPV4Single = () => {
+  const ipStr = `${ipData.ip1}.${ipData.ip2}.${ipData.ip3}.${ipData.ip4}:${ipData.port}`;
+  const IPV4 =
+    /^((25[0-5]|2[0-4]\d|[01]?\d\d?)\.){3}(25[0-5]|2[0-4]\d|[01]?\d\d?):\d{1,5}$/;
+  return IPV4.test(ipStr);
+};
+
+const showCheckResult = computed(() => {
+  return checkClicked.value && !IPV4Single();
+});
+
+const nextStep = () => {
+  checkClicked.value = true;
+  if (IPV4Single()) {
+    local.set(
+      "baseUrl",
+      `http://${ipData.ip1}.${ipData.ip2}.${ipData.ip3}.${ipData.ip4}:${ipData.port}`
+    );
+    emit("next");
+  }
+};
+</script>
+<style lang="less" scoped>
+.ip-set {
+  padding-top: 120px;
+  .level2 {
+    .text-right {
+      padding-right: 24px;
+      padding-bottom: 24px;
+    }
+  }
+  .level1 {
+    padding: 0 24px;
+    .tag {
+      margin-left: 10px;
+    }
+    .ip-num {
+      height: 60px;
+      font-size: 20px;
+      text-align: center;
+      background: #f3f4f6;
+    }
+    .title {
+      color: @text-color1;
+      font-weight: bold;
+      line-height: 32px;
+      font-size: 24px;
+    }
+    .tip {
+      color: @text-color3;
+      line-height: 26px;
+      font-size: 18px;
+      margin-top: 4px;
+      margin-bottom: 24px;
+    }
+    .maohao {
+      height: 20px;
+      margin-left: 3px;
+      margin-right: 3px;
+    }
+    .dian {
+      padding: 0 4px;
+    }
+  }
+}
+</style>

+ 112 - 0
src/render/views/Login/LoginWays.vue

@@ -0,0 +1,112 @@
+<template>
+  <div class="login-ways h-full">
+    <div class="flex flex-col h-full justify-between">
+      <div class="level1">
+        <div class="title">登录</div>
+        <div class="tip">选择登录身份</div>
+        <div class="flex items-center justify-between">
+          <div
+            class="card"
+            @click="activeName = 'scan'"
+            :class="{ active: activeName === 'scan' }"
+          >
+            <img src="../../assets/imgs/scan_login_icon.png" />
+            <p class="way-name">扫描员登录</p>
+            <p class="way-desc">Scanner login</p>
+          </div>
+          <div
+            class="card"
+            @click="activeName = 'admin'"
+            :class="{ active: activeName === 'admin' }"
+          >
+            <img src="../../assets/imgs/admin_login_icon.png" />
+            <p class="way-name">管理员登录</p>
+            <p class="way-desc">Administrator login</p>
+          </div>
+        </div>
+      </div>
+      <div class="level2">
+        <div class="text-right">
+          <qm-button type="primary" @click="nextStep"
+            >下一步 <template #icon> <SwapRightOutlined /> </template
+          ></qm-button>
+        </div>
+        <footer-info></footer-info>
+      </div>
+    </div>
+  </div>
+</template>
+<script name="LoginWays" lang="ts" setup>
+import { ref } from "vue";
+import {
+  CheckCircleFilled,
+  CloseCircleFilled,
+  SwapRightOutlined,
+} from "@ant-design/icons-vue";
+import FooterInfo from "@/components/FooterInfo";
+
+const emit = defineEmits(["next"]);
+const activeName = ref("scan");
+
+const nextStep = () => {
+  if (activeName.value === "scan") {
+    //todo,拉起本地扫描端exe程序,隐藏本browserWindow
+  } else {
+    emit("next");
+  }
+};
+</script>
+<style lang="less" scoped>
+.login-ways {
+  padding-top: 120px;
+  .level2 {
+    .text-right {
+      padding-right: 24px;
+      padding-bottom: 24px;
+    }
+  }
+  .level1 {
+    padding: 0 24px;
+    .card {
+      border-radius: 6px;
+      border: 1px solid @border-color1;
+      height: 140px;
+      width: calc(50% - 12px);
+      text-align: center;
+      cursor: pointer;
+      transition: all 0.3s;
+      &.active {
+        background: #f3f4f6;
+      }
+      img {
+        height: 35px;
+        margin-top: 26px;
+      }
+      .way-name {
+        font-size: 16px;
+        font-weight: bold;
+        color: #14161a;
+        margin-top: 12px;
+      }
+      .way-desc {
+        font-size: 12px;
+        color: @text-color3;
+        margin-top: 10px;
+      }
+    }
+    .title {
+      color: @text-color1;
+      font-weight: bold;
+      line-height: 32px;
+      font-size: 24px;
+    }
+    .tip {
+      color: @text-color3;
+      line-height: 26px;
+      font-size: 18px;
+      margin-top: 4px;
+      margin-bottom: 24px;
+    }
+  }
+}
+</style>

+ 104 - 0
src/render/views/Login/index.vue

@@ -0,0 +1,104 @@
+<template>
+  <div class="login h-full flex justify-center items-center">
+    <div class="login-base flex">
+      <span class="close" @click="willClose">&times;</span>
+      <div class="left-bg-box"></div>
+      <div class="right-box h-full">
+        <IpSet v-if="curStepIndex == 0" @next="toNext"></IpSet>
+
+        <EnvCheck
+          v-if="curStepIndex == 1"
+          @mounted="checkEnvHandler"
+        ></EnvCheck>
+        <LoginWays v-if="curStepIndex == 2" @next="toNext"> </LoginWays>
+        <AdminLogin v-if="curStepIndex == 3" @to-index="toIndex"> </AdminLogin>
+      </div>
+    </div>
+  </div>
+</template>
+<script name="Login" lang="ts" setup>
+import { ref, onMounted } from "vue";
+import { closeApp } from "@/utils";
+import EnvCheck from "./EnvCheck.vue";
+import IpSet from "./IpSet.vue";
+import LoginWays from "./LoginWays.vue";
+import AdminLogin from "./AdminLogin.vue";
+import { local } from "@/utils/tool";
+const curStepIndex = ref(0);
+const toNext = () => {
+  curStepIndex.value++;
+};
+const toIndex = (i: number) => {
+  curStepIndex.value = i;
+};
+onMounted(() => {
+  if (local.get("baseUrl")) {
+    curStepIndex.value++;
+  }
+});
+const envCheckLoading = ref(false);
+const checkEnvHandler = () => {
+  //todo 环境检测,发起请求
+  envCheckLoading.value = true;
+  //先定时器模拟请求
+  setTimeout(() => {
+    envCheckLoading.value = false;
+    curStepIndex.value++;
+
+    //todo如果请求异常,则清除本地local里的baseUrl
+    //...
+  }, 2000);
+};
+const willClose = () => {
+  if (curStepIndex.value == 1 && envCheckLoading.value) {
+    window.$confirm({
+      title: "系统提示",
+      content: "需要重新设置服务器IP吗?",
+      onCancel: () => {
+        closeApp();
+      },
+      onOk: () => {
+        curStepIndex.value = 0;
+        local.remove("baseUrl");
+      },
+    });
+  } else {
+    closeApp();
+  }
+};
+</script>
+<style lang="less" scoped>
+.login {
+  height: 100%;
+  .close {
+    position: absolute;
+    z-index: 10;
+    right: 28px;
+    top: 26px;
+    color: #666;
+    cursor: pointer;
+    font-size: 24px;
+    transition: all 0.3s;
+    &:hover {
+      font-weight: bold;
+      color: #333;
+    }
+  }
+  .login-base {
+    width: 100%;
+    height: 100%;
+    border-radius: 12px;
+    background: #fff;
+    position: relative;
+    .right-box {
+      width: calc(100% - 360px);
+    }
+    .left-bg-box {
+      width: 360px;
+      height: 100%;
+      background: url(../../assets//imgs/login_left_bg.png) 0 0 no-repeat;
+      background-size: 100% 100%;
+    }
+  }
+}
+</style>

+ 52 - 0
src/render/views/test.vue

@@ -0,0 +1,52 @@
+<template>
+  <div class="login h-full">
+    <!-- <qm-button type="primary" @click="changeColor('#000')"
+      >切换主题色</qm-button
+    >
+    <QmButton type="primary" @click="login">登录</QmButton>
+    <QmButton type="primary" @click="visible = true">打开弹框</QmButton>
+    <div>
+      <a-input v-model:value="ccc" />
+    </div>
+    <QmLowForm :fields="fields" :params="params" :labelWidth="100"></QmLowForm>
+    <MyModal v-model:open="visible"></MyModal> -->
+    <!-- http://192.168.10.224:9000/sheet/1/409/1500409-1.jpg -->
+    <div class="img-box">
+      <v3-drag-zoom-container style="height: 100%">
+        <img src="http://192.168.10.224:9000/sheet/1/409/1500409-1.jpg" />
+      </v3-drag-zoom-container>
+    </div>
+  </div>
+</template>
+<script lang="ts" name="Login" setup>
+import { useRouter } from "vue-router";
+import { useThemeColor } from "@qmth/ui";
+import { reactive, ref } from "vue";
+import MyModal from "@/components/MyModal";
+const ccc = ref("ccc");
+const visible = ref(false);
+const { publishPrimaryColor } = useThemeColor();
+const changeColor = (color: string) => {
+  publishPrimaryColor(color);
+};
+const router = useRouter();
+const login = () => {
+  router.push({ name: "CreateExam" });
+};
+const params = reactive({ a: "12" });
+
+const fields = ref([
+  {
+    prop: "a",
+    label: "输入框",
+    colSpan: 8,
+  },
+]);
+</script>
+<style lang="less" scoped>
+.img-box {
+  height: 500px;
+  width: 500px;
+  background-color: #ddd;
+}
+</style>

+ 17 - 0
src/render/vite-env.d.ts

@@ -0,0 +1,17 @@
+/// <reference types="vite/client" />
+/// <reference types="@qmth/ui/global" />
+
+interface ImportMetaEnv {
+  readonly VITE_APP_PROXY_URL: any;
+  // more env variables...
+}
+
+interface ImportMeta {
+  readonly env: ImportMetaEnv;
+}
+
+declare module "*.vue" {
+  import type { DefineComponent } from "vue";
+  const component: DefineComponent<{}, {}, any>;
+  export default component;
+}

+ 30 - 0
static/load/index.html

@@ -0,0 +1,30 @@
+<!DOCTYPE html>
+<html>
+  <head>
+    <meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
+
+    <title>loading</title>
+    <link rel="stylesheet" type="text/css" href="style.css" />
+    <style>
+      a {
+        background: #13a3a5;
+        padding: 5px;
+        margin: 10px;
+        display: block;
+        font-weight: 100;
+        cursor: pointer;
+        font-size: 1.5em;
+        float: left;
+        text-decoration: none;
+        font-size: 18px;
+        color: white;
+      }
+    </style>
+  </head>
+  <body>
+    <div class="loader">
+      <div class="cap"></div>
+      <div class="line">通用扫描v2.0.0</div>
+    </div>
+  </body>
+</html>

+ 130 - 0
static/load/style.css

@@ -0,0 +1,130 @@
+
+*{
+	margin:0em;
+	padding:0em;
+}
+
+body { 
+	-webkit-tap-highlight-color: rgba(0, 0, 0, 0);
+	/* background: #403833; */
+	background:#2f3241;
+	font-family: 'Open Sans';
+	font-weight: 100;
+	color:#f2f2f2;
+	font-size:100%;
+	margin:0em;
+	padding:0em;
+}
+
+.loader {
+	position: absolute;
+	top: 50%;
+	left: 50%;
+	-webkit-transform: translate(-50%, -50%);
+	-moz-transform: translate(-50%, -50%);
+	-mos-transform: translate(-50%, -50%);
+	-o-transform: translate(-50%, -50%);
+	transform: translate(-50%, -50%);
+	text-align:center;
+	-webkit-touch-callout: none;
+    -webkit-user-select: none;
+    -khtml-user-select: none;
+    -moz-user-select: none;
+    -ms-user-select: none;
+    user-select: none;
+    cursor:default;
+}
+
+.line{
+	white-space: nowrap;
+	border-bottom:5px solid #f2f2f2;
+	overflow:hidden;
+	width:100%;
+	-webkit-animation: line 2s ease-in-out infinite;
+	-moz-animation: line 2s ease-in-out infinite;
+	-mos-animation: line 2s ease-in-out infinite;
+	-o-animation: line 2s ease-in-out infinite;
+	animation: line 2s ease-in-out infinite;
+	color:#f2f2f2;
+	font-size:4em;
+	text-shadow: 0 1px 0 #ccc, 
+				 0 2px 0 #c9c9c9,
+				 0 3px 0 #bbb,
+				 0 4px 0 #b9b9b9,
+				 0 5px 0 #aaa,
+				 0 6px 1px rgba(0,0,0,.1),
+				 0 0 5px rgba(0,0,0,.1),
+				 0 1px 3px rgba(0,0,0,.1),
+				 0 3px 5px rgba(0,0,0,.1),
+				 0 5px 10px rgba(0,0,0,.1),
+				 0 10px 10px rgba(0,0,0,.1),
+				 0 20px 20px rgba(0,0,0,.15);
+	box-shadow: inset 0px -5px 10px -7px rgba(0,0,0,0.75), inset 0px 5px 10px -7px rgba(0,0,0,0.75);
+}
+
+.cap{
+	position:absolute;
+	left:-1px;
+	height:100%;
+	border-bottom:5px solid #403833;
+	-webkit-animation: cap 2s ease-in-out infinite;
+	-moz-animation: cap 2s ease-in-out infinite;
+	-mos-animation: cap 2s ease-in-out infinite;
+	-o-animation: cap 2s ease-in-out infinite;
+	animation: cap 2s ease-in-out infinite;
+	/* background:#403833; */
+	background:#2f3241;
+
+}
+
+
+@-webkit-keyframes line{
+	0% {width:0%;}
+	50%,100% {width:100%;}
+}
+
+@-moz-keyframes line{
+	0% {width:0%;}
+	50%,100% {width:100%;}
+}
+
+@-mos-keyframes line{
+	0% {width:0%;}
+	50%,100% {width:100%;}
+}
+
+@-o-keyframes line{
+	0% {width:0%;}
+	50%,100% {width:100%;}
+}
+
+@keyframes line{
+	0% {width:0%;}
+	50%,100% {width:100%;}
+}
+
+
+@-webkit-keyframes cap{
+	0%,50% {width:0%;}
+	100% {width:100%;}
+}
+
+@-moz-keyframes cap{
+	0%,50% {width:0%;}
+	100% {width:100%;}
+}
+
+@-mos-keyframes cap{
+	0%,50% {width:0%;}
+	100% {width:100%;}
+}
+
+@-o-keyframes cap{
+	0%,50% {width:0%;}
+	100% {width:100%;}
+}
+
+@keyframes cap{
+	0%,50% {width:0%;}
+	100% {width:100%;}
+}

+ 45 - 0
tsconfig.json

@@ -0,0 +1,45 @@
+{
+  "compilerOptions": {
+    "target": "ESNext",
+    "useDefineForClassFields": true,
+    "module": "ESNext",
+    "moduleResolution": "Node",
+    "strict": true,
+    "jsx": "preserve",
+    "resolveJsonModule": true,
+    "isolatedModules": true,
+    "esModuleInterop": true,
+    "lib": [
+      "ESNext",
+      "DOM"
+    ],
+    "skipLibCheck": true,
+    "noEmit": true,
+    "baseUrl": ".",
+    "paths": {
+      "@/*": [
+        "src/render/*"
+      ],
+      "#/*": [
+        "types/*"
+      ]
+    },
+  },
+  "include": [
+    "src/**/*.ts",
+    "src/**/*.d.ts",
+    "src/**/*.tsx",
+    "src/**/*.vue",
+    "types",
+  ],
+  "exclude": [
+    "node_modules",
+    "dist",
+    "**/*.js"
+  ],
+  "references": [
+    {
+      "path": "./tsconfig.node.json"
+    }
+  ]
+}

+ 14 - 0
tsconfig.node.json

@@ -0,0 +1,14 @@
+{
+  "compilerOptions": {
+    "composite": true,
+    "module": "ESNext",
+    "moduleResolution": "Node",
+    "resolveJsonModule": true,
+    "allowSyntheticDefaultImports": true
+  },
+  "include": [
+    "vite.config.mts",
+    "package.json",
+    "electron"
+  ]
+}

+ 8 - 0
types/app.d.ts

@@ -0,0 +1,8 @@
+interface Exam {
+  enable: boolean;
+  id: number;
+  mode: string;
+  name: string;
+  schoolName: string;
+  updateTime: number;
+}

+ 11 - 0
types/global.d.ts

@@ -0,0 +1,11 @@
+declare interface Window {
+  $message: any;
+  $notification: any;
+  $info: any;
+  $success: any;
+  $error: any;
+  $warning: any;
+  $confirm: any;
+  $destroyAll: any;
+  electronApi: any;
+}

+ 0 - 0
uno.config.ts


+ 85 - 0
vite.config.mts

@@ -0,0 +1,85 @@
+import { resolve } from "path";
+import { defineConfig, loadEnv } from "vite";
+import Components from "unplugin-vue-components/vite";
+import { AntDesignVueResolver } from "unplugin-vue-components/resolvers";
+import vue from "@vitejs/plugin-vue";
+import vueSetupExtend from "unplugin-vue-setup-extend-plus/vite";
+import vueJsx from "@vitejs/plugin-vue-jsx";
+import UnoCSS from "unocss/vite";
+import antdvNames from "./antdvNames";
+const root = resolve(__dirname, "src/render");
+const outDir = resolve(__dirname, "dist/");
+
+export default defineConfig((configEnv) => {
+  const viteEnv = loadEnv(configEnv.mode, process.cwd());
+  const processEnvValues = {
+    "process.env": Object.entries(viteEnv).reduce((prev, [key, val]) => {
+      return {
+        ...prev,
+        [key]: val,
+      };
+    }, {}),
+  };
+  return {
+    root,
+    base: "./",
+    // publicDir: outDir,
+    build: {
+      outDir,
+    },
+    resolve: {
+      alias: {
+        "@": root,
+        qs: "rollup-plugin-node-polyfills/polyfills/qs",
+      },
+      extensions: [".mjs", ".js", ".ts", ".jsx", ".tsx", ".json", ".vue"],
+    },
+    css: {
+      preprocessorOptions: {
+        less: {
+          javascriptEnabled: true,
+          additionalData: `@import "${resolve(root, "styles/color.less")}";`,
+        },
+      },
+    },
+    define: { ...processEnvValues },
+    plugins: [
+      vue(),
+      vueJsx(),
+      vueSetupExtend(),
+      UnoCSS(),
+      Components({
+        resolvers: [
+          (componentName) => {
+            if (componentName?.startsWith("Qm"))
+              return { name: componentName.slice(0), from: "@qmth/ui" };
+          },
+          // AntDesignVueResolver({
+          //   importStyle: false,
+          // }),
+
+          (componentName) => {
+            if (componentName?.startsWith("A")) {
+              if (antdvNames.includes(componentName.slice(1))) {
+                return {
+                  name: componentName.slice(1),
+                  from: "@qmth/ui",
+                };
+              }
+            }
+          },
+        ],
+      }),
+    ],
+    server: {
+      host: "127.0.0.1",
+      port: 8090,
+      proxy: {
+        "/api": {
+          target: viteEnv.VITE_APP_PROXY_URL,
+          changeOrigin: true,
+        },
+      },
+    },
+  };
+});