1b4c7d5ba3f59ba4cdab7bddb0ca2c10c712af7d..f56e474c81bb25845b46cf99c85bd313dbfcd3b5
4 天以前 wwf
项目初始化+首页+公告详情页面
f56e47 对比 | 目录
1个文件已修改
56个文件已添加
7631 ■■■■■ 已修改文件
.editorconfig 8 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
.gitattributes 1 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
.gitignore 36 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
.prettierrc.json 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
.vscode/extensions.json 8 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
README.md 5 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
eslint.config.js 65 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
index.html 13 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
package-lock.json 5006 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
package.json 40 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
public/favicon.ico 补丁 | 查看 | 原始文档 | blame | 历史
src/App.vue 20 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/assets/images/home/appraisalPlan.png 补丁 | 查看 | 原始文档 | blame | 历史
src/assets/images/home/banner1.png 补丁 | 查看 | 原始文档 | blame | 历史
src/assets/images/home/certificate.png 补丁 | 查看 | 原始文档 | blame | 历史
src/assets/images/home/examTicket.png 补丁 | 查看 | 原始文档 | blame | 历史
src/assets/images/home/score.png 补丁 | 查看 | 原始文档 | blame | 历史
src/assets/images/qrCode.png 补丁 | 查看 | 原始文档 | blame | 历史
src/assets/images/test.png 补丁 | 查看 | 原始文档 | blame | 历史
src/assets/styles/elementTheme.css 5 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/assets/styles/global.css 276 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/config/property.js 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/config/qxueyou.js 15 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main.js 53 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/router/auth/index.js 8 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/router/error/index.js 8 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/router/index.js 23 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/router/main/home/index.js 9 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/router/main/index.js 16 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/router/main/notice/index.js 14 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/stores/login.js 11 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/stores/optionItems.js 15 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/stores/session.js 11 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/utils/axios.js 195 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/utils/export/excel.js 80 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/utils/hook.js 21 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/utils/ruleGenerator.js 97 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/utils/tool.js 85 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/errorPage/index.vue 160 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/login/components/password.vue 93 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/login/index.vue 201 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/main/components/DataTable.vue 132 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/main/components/DictTag.vue 52 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/main/components/Filtrate.vue 55 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/main/components/MyFooter.vue 74 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/main/components/MyHeader.vue 62 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/main/components/ReturnBtn.vue 17 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/main/components/Upload.vue 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/main/components/UploadBtn.vue 248 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/main/components/contentTitle.vue 33 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/main/components/logout.vue 47 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/main/components/statusTag.vue 31 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/main/home/index.vue 109 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/main/index.vue 48 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/main/notice/detail.vue 86 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/main/notice/list.vue 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
vite.config.js 24 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
.editorconfig
New file
@@ -0,0 +1,8 @@
[*.{js,jsx,mjs,cjs,ts,tsx,mts,cts,vue,css,scss,sass,less,styl}]
charset = utf-8
indent_size = 2
indent_style = space
insert_final_newline = true
trim_trailing_whitespace = true
end_of_line = lf
max_line_length = 100
.gitattributes
New file
@@ -0,0 +1 @@
* text=auto eol=lf
.gitignore
New file
@@ -0,0 +1,36 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
.DS_Store
dist
dist-ssr
coverage
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
*.tsbuildinfo
.eslintcache
# Cypress
/cypress/videos/
/cypress/screenshots/
# Vitest
__screenshots__/
.prettierrc.json
New file
@@ -0,0 +1,6 @@
{
  "$schema": "https://json.schemastore.org/prettierrc",
  "semi": false,
  "singleQuote": true,
  "printWidth": 100
}
.vscode/extensions.json
New file
@@ -0,0 +1,8 @@
{
  "recommendations": [
    "Vue.volar",
    "dbaeumer.vscode-eslint",
    "EditorConfig.EditorConfig",
    "esbenp.prettier-vscode"
  ]
}
README.md
@@ -1,4 +1 @@
## app-web-examination-home
广东省考务系统
# 技能人才评价考务管理系统
eslint.config.js
New file
@@ -0,0 +1,65 @@
import js from '@eslint/js'
import pluginVue from 'eslint-plugin-vue'
import prettier from '@vue/eslint-config-prettier'
export default [
  js.configs.recommended,
  ...pluginVue.configs['flat/essential'],
  prettier,
  {
    files: ['**/*.{js,mjs,jsx,vue}'],
    languageOptions: {
      ecmaVersion: 'latest',
      sourceType: 'module',
      globals: {
        console: 'readonly',
        process: 'readonly',
        Buffer: 'readonly',
        __dirname: 'readonly',
        __filename: 'readonly',
        module: 'readonly',
        require: 'readonly',
        exports: 'readonly',
        global: 'readonly',
        window: 'readonly',
        document: 'readonly',
        navigator: 'readonly',
        location: 'readonly',
        history: 'readonly',
        localStorage: 'readonly',
        sessionStorage: 'readonly',
        setTimeout: 'readonly',
        clearTimeout: 'readonly',
        setInterval: 'readonly',
        clearInterval: 'readonly',
        fetch: 'readonly',
        alert: 'readonly',
        confirm: 'readonly',
        prompt: 'readonly',
      },
    },
    rules: {
      'vue/multi-word-component-names': 'off',
      'no-unused-vars': 'warn',
      'no-console': 'off',
      'no-undef': 'off',
      'prettier/prettier': 'off',
      'vue/html-self-closing': 'off',
      'vue/max-attributes-per-line': 'off',
      'vue/html-indent': 'off',
      'vue/html-closing-bracket-newline': 'off',
      'vue/singleline-html-element-content-newline': 'off',
    },
  },
  {
    ignores: [
      '**/dist/**',
      '**/dist-ssr/**',
      '**/coverage/**',
      '**/node_modules/**',
      '**/*.min.js',
    ],
  },
]
index.html
New file
@@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="">
  <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="/src/main.js"></script>
  </body>
</html>
package-lock.json
New file
Diff too large
package.json
New file
@@ -0,0 +1,40 @@
{
  "name": "app-web-examination-platform",
  "version": "0.0.0",
  "private": true,
  "type": "module",
  "engines": {
    "node": "^20.19.0 || >=22.12.0"
  },
  "scripts": {
    "dev": "vite",
    "build": "vite build",
    "preview": "vite preview",
    "lint": "eslint . --fix",
    "format": "prettier --write src/"
  },
  "dependencies": {
    "@element-plus/icons-vue": "^2.3.2",
    "@iconify/vue": "^5.0.0",
    "axios": "^1.13.2",
    "dayjs": "^1.11.19",
    "element-plus": "^2.11.8",
    "file-saver": "^2.0.5",
    "pinia": "^3.0.3",
    "qs": "^6.14.1",
    "vue": "^3.5.22",
    "vue-router": "^4.6.3",
    "xlsx": "^0.18.5",
    "xlsx-js-style": "^1.2.0"
  },
  "devDependencies": {
    "@vitejs/plugin-vue": "^6.0.1",
    "@vue/eslint-config-prettier": "^10.2.0",
    "eslint": "^9.37.0",
    "eslint-plugin-vue": "~10.5.0",
    "npm-run-all2": "^8.0.4",
    "prettier": "3.6.2",
    "vite": "^7.1.11",
    "vite-plugin-vue-devtools": "^8.0.3"
  }
}
public/favicon.ico
src/App.vue
New file
@@ -0,0 +1,20 @@
<template>
  <div id="app">
    <router-view />
  </div>
</template>
<script setup>
// import { useWindowSize } from '@/utils/hook.js'
// const { width, height } = useWindowSize()
</script>
<style scoped>
#app {
  width: 100vw;
  height: 100vh;
  margin: 0;
  padding: 0;
}
</style>
src/assets/images/home/appraisalPlan.png
src/assets/images/home/banner1.png
src/assets/images/home/certificate.png
src/assets/images/home/examTicket.png
src/assets/images/home/score.png
src/assets/images/qrCode.png
src/assets/images/test.png
src/assets/styles/elementTheme.css
New file
@@ -0,0 +1,5 @@
:root {
  --el-color-primary: #007aff;
  --el-text-color-regular: #333 !important;
  --el-text-color-secondary: #666 !important;
}
src/assets/styles/global.css
New file
@@ -0,0 +1,276 @@
/* ================================
   全局样式重置
   ================================ */
* {
  margin: 0;
  padding: 0;
  box-sizing: border-box;
}
html {
  font-size: 16px;
  line-height: 1.5;
  -webkit-text-size-adjust: 100%;
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
}
body {
  font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
    'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
    sans-serif;
  font-size: 1rem;
  line-height: 1.5;
  color: #333;
  background-color: #fff;
  width: 100%;
  height: 100%;
  overflow-x: hidden;
}
.main-content {
  width: 100%;
  max-width: 1280px;
  padding: 0 40px;
  margin: 0 auto;
}
/* ================================
   根容器样式
   ================================ */
#app {
  width: 100vw;
  height: 100vh;
  position: relative;
  overflow: hidden;
}
/* ================================
   滚动条样式
   ================================ */
::-webkit-scrollbar {
  width: 6px;
  height: 6px;
}
::-webkit-scrollbar-track {
  background: #f1f1f1;
  border-radius: 3px;
}
::-webkit-scrollbar-thumb {
  background: #c1c1c1;
  border-radius: 3px;
}
::-webkit-scrollbar-thumb:hover {
  background: #a8a8a8;
}
/* ================================
   间距工具类
   ================================ */
.m-0 { margin: 0; }
.m-1 { margin: 4px; }
.m-2 { margin: 8px; }
.m-3 { margin: 12px; }
.m-4 { margin: 16px; }
.m-5 { margin: 20px; }
.m-6 { margin: 24px; }
.m-7 { margin: 28px; }
.mx-0 { margin-left: 0; margin-right: 0; }
.mx-1 { margin-left: 4px; margin-right: 4px; }
.mx-2 { margin-left: 8px; margin-right: 8px; }
.mx-3 { margin-left: 12px; margin-right: 12px; }
.mx-4 { margin-left: 16px; margin-right: 16px; }
.mx-5 { margin-left: 20px; margin-right: 20px; }
.mx-6 { margin-left: 24px; margin-right: 24px; }
.mx-7 { margin-left: 28px; margin-right: 28px; }
.my-0 { margin-top: 0; margin-bottom: 0;}
.my-1 { margin-top: 4px; margin-bottom: 4px;}
.my-2 { margin-top: 8px; margin-bottom: 8px;}
.my-3 { margin-top: 12px; margin-bottom: 12px; }
.my-4 { margin-top: 16px; margin-bottom: 16px; }
.my-5 { margin-top: 20px; margin-bottom: 20px; }
.my-6 { margin-top: 24px; margin-bottom: 24px; }
.my-7 { margin-top: 28px; margin-bottom: 28px; }
.mt-0 { margin-top: 0; }
.mt-1 { margin-top: 4px; }
.mt-2 { margin-top: 8px; }
.mt-3 { margin-top: 12px; }
.mt-4 { margin-top: 16px; }
.mt-5 { margin-top: 20px; }
.mt-6 { margin-top: 24px; }
.mt-7 { margin-top: 28px; }
.mb-0 { margin-bottom: 0; }
.mb-1 { margin-bottom: 4px; }
.mb-2 { margin-bottom: 8px; }
.mb-3 { margin-bottom: 12px; }
.mb-4 { margin-bottom: 16px; }
.mb-5 { margin-bottom: 20px; }
.mb-6 { margin-bottom: 24px; }
.mb-7 { margin-bottom: 28px; }
.ml-0 { margin-left: 0; }
.ml-1 { margin-left: 4px; }
.ml-2 { margin-left: 8px; }
.ml-3 { margin-left: 12px; }
.ml-4 { margin-left: 16px; }
.ml-5 { margin-left: 20px; }
.ml-6 { margin-left: 24px; }
.ml-7 { margin-left: 28px; }
.mr-0 { margin-right: 0; }
.mr-1 { margin-right: 4px; }
.mr-2 { margin-right: 8px; }
.mr-3 { margin-right: 12px; }
.mr-4 { margin-right: 16px; }
.mr-5 { margin-right: 20px; }
.mr-6 { margin-right: 24px; }
.mr-7 { margin-right: 28px; }
.p-0 { padding: 0; }
.p-1 { padding: 4px; }
.p-2 { padding: 8px; }
.p-3 { padding: 12px; }
.p-4 { padding: 16px; }
.p-5 { padding: 20px; }
.p-6 { padding: 24px; }
.p-7 { padding: 28px; }
.px-0 { padding-left: 0; padding-right: 0; }
.px-1 { padding-left: 4px; padding-right: 4px; }
.px-2 { padding-left: 8px; padding-right: 8px; }
.px-3 { padding-left: 12px; padding-right: 12px; }
.px-4 { padding-left: 16px; padding-right: 16px; }
.px-5 { padding-left: 20px; padding-right: 20px; }
.px-6 { padding-left: 24px; padding-right: 24px; }
.px-7 { padding-left: 28px; padding-right: 28px; }
.py-0 { padding-top: 0; padding-bottom: 0;}
.py-1 { padding-top: 4px; padding-bottom: 4px;}
.py-2 { padding-top: 8px; padding-bottom: 8px;}
.py-3 { padding-top: 12px; padding-bottom: 12px;}
.py-4 { padding-top: 16px; padding-bottom: 16px;}
.py-5 { padding-top: 20px; padding-bottom: 20px;}
.py-6 { padding-top: 24px; padding-bottom: 24px;}
.py-7 { padding-top: 28px; padding-bottom: 28px;}
.pt-0 { padding-top: 0; }
.pt-1 { padding-top: 4px; }
.pt-2 { padding-top: 8px; }
.pt-3 { padding-top: 12px; }
.pt-4 { padding-top: 16px; }
.pt-5 { padding-top: 20px; }
.pt-6 { padding-top: 24px; }
.pt-7 { padding-top: 28px; }
.pb-0 { padding-bottom: 0; }
.pb-1 { padding-bottom: 4px; }
.pb-2 { padding-bottom: 8px; }
.pb-3 { padding-bottom: 12px; }
.pb-4 { padding-bottom: 16px; }
.pb-5 { padding-bottom: 20px; }
.pb-6 { padding-bottom: 24px; }
.pb-7 { padding-bottom: 28px; }
.pl-0 { padding-left: 0; }
.pl-1 { padding-left: 4px; }
.pl-2 { padding-left: 8px; }
.pl-3 { padding-left: 12px; }
.pl-4 { padding-left: 16px; }
.pl-5 { padding-left: 20px; }
.pl-6 { padding-left: 24px; }
.pl-7 { padding-left: 28px; }
.pr-0 { padding-right: 0; }
.pr-1 { padding-right: 4px; }
.pr-2 { padding-right: 8px; }
.pr-3 { padding-right: 12px; }
.pr-4 { padding-right: 16px; }
.pr-5 { padding-right: 20px; }
.pr-6 { padding-right: 24px; }
.pr-7 { padding-right: 28px; }
/* ================================
   文本工具类
   ================================ */
.text-center { text-align: center; }
.text-left { text-align: left; }
.text-right { text-align: right; }
.text-xs { font-size: 12px; }
.text-sm { font-size: 13px; }
.text-base { font-size: 14px; }
.text-lg { font-size: 16px; }
.text-xl { font-size: 18px; }
.text-2xl { font-size: 20px; }
.text-3xl { font-size: 22px; }
.font-normal { font-weight: 400; }
.font-medium { font-weight: 500; }
.font-semibold { font-weight: 600; }
.font-bold { font-weight: 700; }
.text-primary { color: #007AFF; }
.text-success { color: #67c23a; }
.text-warning { color: #e6a23c; }
.text-danger { color: #f56c6c; }
.text-info { color: #909399; }
.text-white { color: #fff; }
.text-note { color: #333 }
.text-black { color: #000; }
.text-red {color: #FF0000 }
.flex-1 {
  flex: 1;
}
/* .el-button:hover,
.el-button:focus{
  outline: none !important;
}
*/
.cursor-p {
  cursor: pointer;;
}
.el-tooltip__trigger:focus-visible {
  outline: unset !important;
}
.el-tabs__header {
  margin: 0px;
}
/* el-tabs ui */
/* .el-tabs__header.is-top {
  height: 50px;
} */
.el-tabs__active-bar {
  /* margin-bottom: -3px !important; */
  /* margin-top: -3px !important; */
}
/* el-card ui */
.el-card__header {
  padding: 6px 12px;
}
.el-card__body {
  padding: 0px;
  padding-bottom: 12px;
}
.el-upload__tip {
  display: inline;
  margin-left: 8px;
}
.el-card__body {
  padding-bottom: 0;
}
src/config/property.js
New file
@@ -0,0 +1,3 @@
export default {
}
src/config/qxueyou.js
New file
@@ -0,0 +1,15 @@
let baseDomain = `${location.protocol}//${location.host}`
let qxyRes = 'https://res.qxueyou.com/'
let serverContext = '/admin-api'
let htmlContext = '/examination/user'
export default {
  domain: baseDomain,
  qxyRes: qxyRes,
  baseUrl: serverContext,
  htmlRoot: baseDomain + htmlContext,
  serverRoot: baseDomain + serverContext,
  upload: `${serverContext}/base/file/upload`,
  ACCESS_TOKEN_KEY: 'accessToken',
  REFRESH_TOKEN_KEY: 'refreshToken'
}
src/main.js
New file
@@ -0,0 +1,53 @@
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import ElementPlus from 'element-plus'
import 'element-plus/dist/index.css'
import './assets/styles/global.css'
import './assets/styles/elementTheme.css'
import { ruleGenerator } from '@/utils/ruleGenerator'
import { getImageUrl, getDictData, getOccupationName, getJobName } from '@/utils/tool'
import * as ElementPlusIconsVue from '@element-plus/icons-vue'
import zhCn from 'element-plus/es/locale/lang/zh-cn'
import dayjs from 'dayjs'
import 'dayjs/locale/zh-cn'
import App from './App.vue'
import router from './router'
import axios from '@/utils/axios.js'
import { Icon } from '@iconify/vue'
import DataTable from '@/views/main/components/DataTable.vue'
import Filtrate from '@/views/main/components/Filtrate.vue'
import ReturnBtn from '@/views/main/components/ReturnBtn.vue'
import DictTag from '@/views/main/components/DictTag.vue'
import UploadBtn from '@/views/main/components/UploadBtn.vue'
import property from '@/config/property.js'
import qxueyou from '@/config/qxueyou.js'
const app = createApp(App)
for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
  app.component(key, component)
}
app.config.globalProperties.$rules = ruleGenerator
app.config.globalProperties.$property = property
app.config.globalProperties.$qxueyou = qxueyou
app.config.globalProperties.$axios = axios
app.config.globalProperties.$dayjs = dayjs
app.config.globalProperties.$getImageUrl = getImageUrl
app.config.globalProperties.$getDictData = getDictData
app.config.globalProperties.$getOccupationName = getOccupationName
app.config.globalProperties.$getJobName = getJobName
app.component('DataTable', DataTable);
app.component('Filtrate', Filtrate);
app.component('DictTag', DictTag);
app.component('ReturnBtn', ReturnBtn);
app.component('Icon', Icon)
app.component('UploadBtn', UploadBtn)
app.use(ElementPlus, {
  locale: zhCn
})
app.use(createPinia())
app.use(router)
app.mount('#app')
src/router/auth/index.js
New file
@@ -0,0 +1,8 @@
const router = [
  {
    path: '/auth/login',
    name: '登录',
    component: () => import('@/views/login/index.vue'),
  },
]
export default router
src/router/error/index.js
New file
@@ -0,0 +1,8 @@
const router = [
  {
    path: '/error/:code',
    name: '错误页面',
    component: () => import('@/views/errorPage/index.vue'),
  },
]
export default router
src/router/index.js
New file
@@ -0,0 +1,23 @@
import { createRouter, createWebHistory } from 'vue-router'
import authPage from '@/router/auth/index.js'
import errorPage from '@/router/error/index.js'
import mainPage from '@/router/main/index.js'
const router = createRouter({
  history: createWebHistory(import.meta.env.BASE_URL),
  routes: [...mainPage, ...authPage, ...errorPage],
})
router.beforeEach((to, from, next) => {
  if (!to.matched.length) {
    if (to.path === '/') {
      next({ path: '/main/home' })
    } else {
      next({ path: '/error/404', query: { errorUrl: to.path } })
    }
  } else {
    next()
  }
})
export default router
src/router/main/home/index.js
New file
@@ -0,0 +1,9 @@
const home = [
  {
    path: 'home',
    name: '首页',
    component: () => import('@/views/main/home/index.vue'),
  },
]
export default home
src/router/main/index.js
New file
@@ -0,0 +1,16 @@
import home from '@/router/main/home/index.js'
import notice from '@/router/main/notice/index.js'
const mainRouter = [
  ...home,
  ...notice
]
const router = [
  {
    path: '/main',
    name: '用户端',
    component: () => import('@/views/main/index.vue'),
    children: mainRouter,
  },
]
export default router
src/router/main/notice/index.js
New file
@@ -0,0 +1,14 @@
const notice = [
  {
    path: 'noticeList',
    name: '公告列表',
    component: () => import('@/views/main/notice/list.vue'),
  },
  {
    path: 'noticeDetail',
    name: '公告详情',
    component: () => import('@/views/main/notice/detail.vue'),
  },
]
export default notice
src/stores/login.js
New file
@@ -0,0 +1,11 @@
import { ref } from 'vue'
import { defineStore } from 'pinia'
export const useLoginStore = defineStore('login', () => {
  const loginDialogVisible = ref(false)
  function setLoginDialogVisible(visible) {
    loginDialogVisible.value = visible
  }
  return { loginDialogVisible, setLoginDialogVisible }
})
src/stores/optionItems.js
New file
@@ -0,0 +1,15 @@
import { ref } from 'vue'
import { defineStore } from 'pinia'
export const useOptionItemsStore = defineStore('optionItems', () => {
  const cityItems = ref([])
  const occupationItems = ref([])
  function setCityItems(data) {
    cityItems.value = data
  }
  function setOccupationItems(data) {
    occupationItems.value = data
  }
  return { cityItems, setCityItems, occupationItems, setOccupationItems }
})
src/stores/session.js
New file
@@ -0,0 +1,11 @@
import { ref } from 'vue'
import { defineStore } from 'pinia'
export const useSessionStore = defineStore('session', () => {
  const userInfo = ref({})
  function setUserInfo(data) {
    userInfo.value = data
  }
  return { userInfo, setUserInfo }
})
src/utils/axios.js
New file
@@ -0,0 +1,195 @@
import axios from 'axios'
import $qxueyou from '@/config/qxueyou.js'
import { getUUID } from "@/utils/tool.js";
let createAxios = axios.create({
  baseURL: $qxueyou.serverRoot,
  timeout: 30000,
  headers: {
    "Content-Type": `application/json;charset=utf-8`
  }
})
createAxios.all = axios.all
createAxios.spread = axios.spread
// Token 工具函数
const ACCESS_TOKEN_KEY = $qxueyou.ACCESS_TOKEN_KEY;
const REFRESH_TOKEN_KEY = $qxueyou.REFRESH_TOKEN_KEY;
export const tokenUtils = {
  // 获取 Access Token
  getAccessToken() {
    return localStorage.getItem(ACCESS_TOKEN_KEY);
  },
  // 获取 Refresh Token
  getRefreshToken() {
    return localStorage.getItem(REFRESH_TOKEN_KEY);
  },
  // 设置 Token
  setTokens(accessToken, refreshToken) {
    if (accessToken) {
      localStorage.setItem(ACCESS_TOKEN_KEY, accessToken);
    }
    if (refreshToken) {
      localStorage.setItem(REFRESH_TOKEN_KEY, refreshToken);
    }
  },
  // 清除 Token
  clearTokens() {
    localStorage.removeItem(ACCESS_TOKEN_KEY);
    localStorage.removeItem(REFRESH_TOKEN_KEY);
  },
  // 检查 Token 是否存在
  hasTokens() {
    return !!(this.getAccessToken() && this.getRefreshToken());
  },
}
let isRefreshing = false;
// 刷新 token 期间的请求队列
let refreshQueue = [];
// 刷新 Access Token
export async function refreshAccessToken() {
  const currentRefreshToken = tokenUtils.getRefreshToken();
  if (!currentRefreshToken) {
    throw new Error("No refresh token available");
  }
  try {
    const response = await createAxios.post(
      `/system/auth/refresh-token?refreshToken=${currentRefreshToken}`
    );
    if (response.data && response.data.code === 0) {
      const { accessToken, refreshToken: newRefreshToken } = response.data.data;
      tokenUtils.setTokens(accessToken, newRefreshToken);
      return accessToken;
    } else {
      throw new Error("Token refresh failed");
    }
  } catch (error) {
    console.error('Token refresh error:', error);
    throw error;
  }
}
let strTrim = function(data) {
  let newData = {...data}
  Object.keys(newData).forEach((key) => {
    if (typeof newData[key] === 'string') {
      newData[key] = newData[key].trim()
    }
  })
  return newData
}
//网络请求监听
createAxios.interceptors.request.use(
  function(config) {
    // 刷新 token 的请求不需要添加 Authorization header
    if (!config.url.includes('/auth/refresh-token')) {
      config.headers = {
        ...config.headers,
        Authorization: localStorage.getItem('accessToken')
      }
    }
    config.flag = getUUID().toString().slice(-4)
    if (config.data) {
      console.log(`data-${config.flag} `, config.data)
      // config.data = JSON.stringify(config.data)
    }
    if (config.params) {
      console.log(`params-${config.flag} `, config.params)
      // config.params = strTrim(config.params)
    }
    return config
  },
  function(error) {
    return Promise.reject(error)
  }
)
createAxios.interceptors.response.use(
  async response => {
    const resultCode = response.data.code;
    const originalRequest = response.config;
    if (response.data && [500, 400].includes(resultCode)) {
      console.log(response)
      return response
    }
    if (response.data && resultCode == '401') {
      if (!originalRequest._retry && tokenUtils.getRefreshToken) {
        originalRequest._retry = true;
        if (isRefreshing) {
          // 如果正在刷新,将请求加入队列
          return new Promise((resolve, reject) => {
            refreshQueue.push({ resolve, reject, config: originalRequest });
          });
        }
        isRefreshing = true;
        try {
          const newAccessToken = await refreshAccessToken();
          // 处理队列中的请求
          refreshQueue.forEach(({ resolve, config }) => {
            config.headers.Authorization = newAccessToken;
            resolve(createAxios(config));
          });
          refreshQueue = [];
          // 重新发送原始请求
          originalRequest.headers.Authorization = newAccessToken;
          return createAxios(originalRequest)
        } catch (refreshError) {
          console.log(refreshError)
          // 刷新失败,处理队列中的请求
          refreshQueue.forEach(({ reject }) => {
            reject(refreshError);
          });
          refreshQueue = [];
          tokenUtils.clearTokens();
          // $store.commit("session/loginFlag", false);
          // $store.commit("session/userInfo", {});
          // 可以在这里触发登录页面跳转
        } finally {
          isRefreshing = false;
        }
      } else {
        tokenUtils.clearTokens();
        // $store.commit("session/loginFlag", false);
        // $store.commit("session/userInfo", {});
      }
    }
    console.log(`url-${response.config.flag} `, response.config.url, response.data)
    return response
  },
  error => {
    // store.commit('network/changeNetworkState', false)
    if (!error.response) return Promise.reject(error)
    // store.commit('network/status', error.response.status)
    if (401 === error.response.status) {
      // 刷新 token 的请求返回 401 时,直接返回错误,不要抛出新的错误
      if (error.config && error.config.url && error.config.url.includes('/auth/refresh-token')) {
        return Promise.reject(error)
      }
      // store.commit('session/clearUser')
      // store.commit('endea/clearShareK')
      // store.commit('network/logout')
      throw new Error('登录失效')
    }
    return Promise.reject(error)
  }
)
export default createAxios
src/utils/export/excel.js
New file
@@ -0,0 +1,80 @@
import * as XLSX from 'xlsx-js-style'; // 另一种常见的导入方式
export const exportToExcel = (headers, list, fileName) => {
  // 定义自定义表头和数据映射
  const headerData = headers.filter(item => !item.noExport).map(item => item.label);
  const bodyData = list.map(item => {
    let itemList = []
    headers.forEach(header => {
      if (header.noExport) return
      itemList.push(item[header.value])
    })
    return itemList
  });
  // 将表头和数据体组合成一个二维数组
  const allData = [headerData, ...bodyData];
  // 使用 aoa_to_sheet 方法,它接受二维数组
  const ws = XLSX.utils.aoa_to_sheet(allData);
  const headerStyle = {
    font: { bold: true, sz: 12 },
    alignment: { horizontal: 'center', vertical: 'center' },
  }
  // 数据单元格样式:居中
  const cellStyle = {
    alignment: { horizontal: 'center', vertical: 'center' }
  }
  // 3. 计算并设置列宽(核心:最大宽度限制下的自适应)
  const maxWidth = 30 // 设置最大列宽(单位:字符数)
  const colWidths = []
  // 遍历每一列计算最大宽度
  for (let col = 0; col < allData[0].length; col++) {
    let maxLen = 10 // 默认最小宽度
    for (let row = 0; row < allData.length; row++) {
      const value = allData[row][col]
      if (value != null) {
        // 计算单元格内容长度(中文按2个字符算)
        let len = 0
        const str = value.toString()
        for (let i = 0; i < str.length; i++) {
          const charCode = str.charCodeAt(i)
          len += (charCode > 255) ? 2 : 1 // 中文字符算2个宽度
        }
        if (len > maxLen) maxLen = len
      }
    }
    // 应用最大宽度限制
    colWidths.push({ wch: Math.min(maxLen + 2, maxWidth) })
  }
  ws['!cols'] = colWidths
  // 4. 应用样式到所有单元格
  const range = XLSX.utils.decode_range(ws['!ref'])
  for (let R = range.s.r; R <= range.e.r; R++) {
    for (let C = range.s.c; C <= range.e.c; C++) {
      const cellAddress = { c: C, r: R }
      const cellRef = XLSX.utils.encode_cell(cellAddress)
      if (!ws[cellRef]) ws[cellRef] = {} // 确保单元格存在
      if (!ws[cellRef].s) ws[cellRef].s = {} // 确保样式对象存在
      // 第一行(表头)应用headerStyle,其他行应用cellStyle
      ws[cellRef].s = R === 0 ?
        { ...ws[cellRef].s, ...headerStyle } :
        { ...ws[cellRef].s, ...cellStyle }
    }
  }
  // ... 后续创建工作簿和导出的步骤同上
  const workbook = XLSX.utils.book_new();
  XLSX.utils.book_append_sheet(workbook, ws, 'Sheet1');
  // 3. 生成Excel文件并保存
  XLSX.writeFile(workbook, `${fileName || '导出数据'}.xlsx`); // 使用xlsx库的writeFile方法
};
src/utils/hook.js
New file
@@ -0,0 +1,21 @@
import { ref, onMounted, onUnmounted } from 'vue'
export function useWindowSize() {
  const width = ref(window.innerWidth)
  const height = ref(window.innerHeight)
  const handleResize = () => {
    width.value = window.innerWidth
    height.value = window.innerHeight
  }
  onMounted(() => {
    window.addEventListener('resize', handleResize)
  })
  onUnmounted(() => {
    window.removeEventListener('resize', handleResize)
  })
  return { width, height }
}
src/utils/ruleGenerator.js
New file
@@ -0,0 +1,97 @@
//表单校验规则
export const ruleGenerator = {
  // 必填字段
  required(message = '此字段为必填项') {
    return { required: true, message, trigger: 'blur' }
  },
  // 长度限制
  length(min, max, fieldName = '') {
    const message = fieldName
      ? `${fieldName}长度应在${min}到${max}个字符之间`
      : `长度应在${min}到${max}个字符之间`
    return {
      validator: (rule, value, callback) => {
        if (!value || (value.length >= min && value.length <= max)) {
          callback()
        } else {
          callback(new Error(message))
        }
      },
      trigger: 'blur'
    }
  },
  // 手机号验证
  phone() {
    return {
      validator: (rule, value, callback) => {
        const reg = /^1[3-9]\d{9}$/
        if (!value || reg.test(value)) {
          callback()
        } else {
          callback(new Error('请输入正确的手机号码'))
        }
      },
      trigger: ['blur', 'change']
    }
  },
  //验证码验证
  code() {
    return {
      validator: (rule, value, callback) => {
        const reg = /^\d{4}$/
        if (!value || reg.test(value)) {
          callback()
        } else {
          callback(new Error('请输入正确的验证码'))
        }
      },
      trigger: ['blur', 'change']
    }
  },
  password() {
    return {
      validator: (rule, value, callback) => {
        const reg = /^\d{6}$/
        if (!value || reg.test(value)) {
          callback()
        } else {
          callback(new Error('密码格式为6个字符'))
        }
      },
      trigger: ['blur', 'change']
    }
  },
  // 多选框验证
  checkbox(message) {
    return {
      validator: (rule, value, callback) => {
        if (!value) {
          callback(new Error(message));
        } else {
          callback();
        }
      },
      trigger: 'change'
    }
  },
  // 邮箱验证
  email() {
    return {
      validator: (rule, value, callback) => {
        const reg = /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/
        if (!value || reg.test(value)) {
          callback()
        } else {
          callback(new Error('请输入正确的邮箱地址'))
        }
      },
      trigger: 'blur'
    }
  }
}
src/utils/tool.js
New file
@@ -0,0 +1,85 @@
import { useOptionItemsStore } from '@/stores/optionItems.js';
/**
 * 获取 assets/images 目录下的图片URL
 * @param {string} imageName - 图片文件名(包含扩展名)
 * @returns {string} 图片的完整URL
 */
export const getImageUrl = (imageName) => {
  try {
    return new URL('../assets/images/' + imageName, import.meta.url).href;
  } catch (error) {
    console.warn(`Failed to load image: ${imageName}`, error);
    return "";
  }
};
export const getUUID = () => {
  let s = []
  let num10 = 0x10
  let num3 = 0x3
  let num8 = 0x8
  let hexDigits = '0123456789abcdef'
  for (let i = 0; i < 36; i++) {
    s[i] = hexDigits.substr(Math.floor(Math.random() * num10), 1)
  }
  s[14] = '4'
  s[19] = hexDigits.substr((s[19] & num3) | num8, 1)
  s[8] = s[13] = s[18] = s[23] = ''
  return s.join('')
}
export const getDictData = (key) => {
  let list = JSON.parse(localStorage.getItem('dictData')) || []
  return list.filter(ele => ele.dictType == key)
}
/**
 * 防抖函数
 * @param {Function} func - 需要防抖的函数
 * @param {number} delay - 延迟时间(毫秒),默认 300ms
 * @param {boolean} immediate - 是否立即执行,默认 false
 * @returns {Function} 防抖后的函数
 */
export const debounce = (func, delay = 300, immediate = false) => {
  let timer = null
  let isImmediateExecuted = false
  return function (...args) {
    const context = this
    if (timer) {
      clearTimeout(timer)
      timer = null
    }
    if (immediate && !isImmediateExecuted) {
      func.apply(context, args)
      isImmediateExecuted = true
    }
    timer = setTimeout(() => {
      if (!immediate) {
        func.apply(context, args)
      }
      isImmediateExecuted = false
      timer = null
    }, delay)
  }
}
export const getOccupationName = (code) => {
  const { occupationItems } = useOptionItemsStore()
  const obj = occupationItems.find(ele => ele.code == code)
  return obj?.name || ''
}
export const getJobName = (occupationCode, jobCode) => {
  const { occupationItems } = useOptionItemsStore()
  const occupation = occupationItems.find(ele => ele.code == occupationCode)
  if (!occupation) return ''
  const job = occupation.jobs?.find(ele => ele.jobCode == jobCode)
  if (!job) return ''
  return job.jobName || ''
}
src/views/errorPage/index.vue
New file
@@ -0,0 +1,160 @@
<template>
  <div class="error-page">
    <div class="error-container">
      <div class="error-content">
        <div class="error-code">404</div>
        <div class="error-message">页面未找到</div>
        <div class="error-description">抱歉,您访问的页面不存在或已被移除</div>
        <div class="error-actions">
          <el-button @click="goBack" size="large">
            <Icon icon="icon-park-solid:return" width="16" height="16" class="mr-1" />
            返回首页
          </el-button>
        </div>
      </div>
      <div class="error-illustration">
        <Icon icon="icon-park-solid:file-search" width="200" height="200" />
      </div>
    </div>
  </div>
</template>
<script>
export default {
  methods: {
    goHome() {
      this.$router.push('/')
    },
    goBack() {
      if (window.history.length > 1) {
        this.$router.go(-1)
      } else {
        this.$router.push('/')
      }
    }
  }
}
</script>
<style scoped>
.error-page {
  height: 100vh;
  background: #ffffff;
  display: flex;
  align-items: center;
  justify-content: center;
  font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
}
.error-container {
  display: flex;
  align-items: center;
  gap: 60px;
  max-width: 1000px;
  padding: 40px;
}
.error-content {
  flex: 1;
  text-align: left;
}
.error-code {
  font-size: 120px;
  font-weight: bold;
  color: #667eea;
  line-height: 1;
  margin-bottom: 20px;
}
.error-message {
  font-size: 32px;
  font-weight: 600;
  color: #333333;
  margin-bottom: 16px;
}
.error-description {
  font-size: 18px;
  color: #666666;
  margin-bottom: 40px;
  line-height: 1.6;
}
.error-actions {
  display: flex;
  gap: 16px;
  flex-wrap: wrap;
}
.error-illustration {
  flex: 1;
  display: flex;
  justify-content: center;
  align-items: center;
}
.error-illustration .iconify {
  color: #667eea;
  animation: float 3s ease-in-out infinite;
}
@keyframes float {
  0%, 100% {
    transform: translateY(0px);
  }
  50% {
    transform: translateY(-20px);
  }
}
/* 响应式设计 */
@media (max-width: 768px) {
  .error-container {
    flex-direction: column;
    text-align: center;
    gap: 40px;
    padding: 20px;
  }
  .error-code {
    font-size: 80px;
  }
  .error-message {
    font-size: 24px;
  }
  .error-description {
    font-size: 16px;
  }
  .error-actions {
    justify-content: center;
  }
  .error-illustration .iconify {
    width: 120px !important;
    height: 120px !important;
  }
}
@media (max-width: 480px) {
  .error-code {
    font-size: 60px;
  }
  .error-message {
    font-size: 20px;
  }
  .error-actions {
    flex-direction: column;
    align-items: center;
  }
  .error-actions .el-button {
    width: 200px;
  }
}
</style>
src/views/login/components/password.vue
New file
@@ -0,0 +1,93 @@
<template>
  <div class="login-form-content">
    <p>账号密码登录</p>
    <el-form ref="accountForm" :model="form" :rules="rules" style="width: 100%;">
      <el-form-item prop="username">
        <el-input
          v-model="form.username"
          placeholder="请输入账号"
          style="width: 100%" size="large"
        />
      </el-form-item>
      <el-form-item prop="password">
        <el-input
          v-model="form.password"
          placeholder="请输入密码"
          style="width: 100%" size="large"
          type="password"
          show-password
        />
      </el-form-item>
    </el-form>
    <el-button type="primary" style="width: 100%" size="large" @click="login()">登 录</el-button>
    <el-row align="middle">
      <el-text class="text-primary cursor-p" @click="resetDialog()">忘记密码</el-text>
      <el-divider style="height: 20px;margin:0 14px" direction="vertical"></el-divider>
      <el-text class="text-primary cursor-p" @click="changeLoginType('mobile')">手机号验证码登录</el-text>
      <el-divider style="height: 20px;margin:0 14px" direction="vertical"></el-divider>
      <el-text @click="changeLoginType('qrCode')" class="text-primary cursor-p">微信扫码登录</el-text>
    </el-row>
  </div>
</template>
<script>
import { tokenUtils } from '@/utils/axios.js';
export default {
  data() {
    return {
      form: {
        username: this.$route.query.target=='tenant' ? '13030003000' : 'admin',
        password: this.$route.query.target=='tenant' ? '123456' : 'admin123',
      },
      rules: {
        username: [ this.$rules.required('账号不能为空') ],
        password: [ this.$rules.required('密码不能为空') ]
      }
    }
  },
  methods: {
    changeLoginType(type) {
      this.$emit('changeLoginType', type)
    },
    resetDialog() {
    },
    async login() {
      const validate = await this.$refs.accountForm.validate()
      if (validate) {
        const data = {
          ...this.form
        }
        this.$axios.post('/system/auth/login', data).then(res => {
          if (res.data.code == 0 && res.data.data) {
            const resData = res.data.data
            tokenUtils.setTokens(resData.accessToken, resData.refreshToken)
            this.$message.success('登录成功')
            this.$router.push('/console')
          } else {
            this.$message.error(res.data.msg || '登录失败')
          }
        })
      }
    }
  }
}
</script>
<style>
.login-form-content {
  width: 100%;
  height: 350px;
  padding: 20px;
  display: flex;
  flex-direction: column;
  justify-content: space-around;
  align-items: center;
}
.login-form-content p{
  font-size: 22px;
  font-weight: bold;
  color: black;
  letter-spacing: 10px;
}
</style>
src/views/login/index.vue
New file
@@ -0,0 +1,201 @@
<template>
  <el-dialog
    v-model="loginDialogVisible"
    width="500"
    align-center
    :close-on-click-modal="false"
    :close-on-press-escape="false"
  >
    <div class="px-4">
      <el-row justify="center">
        <el-text class="text-2xl font-bold">{{ title }}</el-text>
      </el-row>
      <el-form v-if="loginType == 'mobile'" ref="accountForm" :model="form" :rules="rules" class="pt-6 pb-3">
        <el-form-item prop="mobile">
          <el-input v-model="form.mobile" placeholder="请输入手机号" />
        </el-form-item>
        <el-form-item prop="code" class="mb-2">
          <el-input v-model="form.code" placeholder="请输入验证码">
            <template #append>
              <el-row style="width: 70px;justify-content: center;">
                <el-button
                  v-if="countdown == 180"
                  style="color: var(--el-color-primary);"
                  class="cursor-p"
                  :loading="sendCodeLoading"
                  @click="sendCode()"
                >
                  获取验证码
                </el-button>
                <el-text v-else>{{ countdown }}s</el-text>
              </el-row>
            </template>
          </el-input>
        </el-form-item>
        <el-form-item prop="agreement" style="height: 30px;">
          <el-checkbox v-model="form.agreement" label="同意xxx服务协议" size="large" />
        </el-form-item>
        <el-button class="mt-1" @click="submitLogin()" type="primary" size="large" style="width: 100%;">
          <el-text class="text-lg text-white">登录</el-text>
        </el-button>
      </el-form>
      <el-row v-else-if="loginType == 'qrCode'" justify="center" class="mt-7">
        <el-image style="width: 200px;" :src="$getImageUrl('/qrCode.png')"></el-image>
      </el-row>
      <el-form v-else-if="loginType == 'register'" ref="registerForm" :model="form" :rules="rules" class="pt-6 pb-3">
        <el-form-item prop="name" class="mb-2">
          <el-input v-model="form.name" placeholder="请输入姓名" />
        </el-form-item>
        <el-form-item prop="sex" style="height: 20px;">
          <el-radio-group v-model="form.sex">
            <el-radio :value="1">男</el-radio>
            <el-radio :value="0">女</el-radio>
          </el-radio-group>
        </el-form-item>
        <el-form-item prop="idCard">
          <el-input v-model="form.idCard" placeholder="请输入身份证号" />
        </el-form-item>
        <el-form-item prop="mobile">
          <el-input v-model="form.mobile" placeholder="请输入手机号" />
        </el-form-item>
        <el-form-item prop="code" class="mb-2">
          <el-input v-model="form.code" placeholder="请输入验证码">
            <template #append>
              <el-row style="width: 70px;justify-content: center;">
                <el-button
                  v-if="countdown == 180"
                  style="color: var(--el-color-primary);"
                  class="cursor-p"
                  :loading="sendCodeLoading"
                  @click="sendCode()"
                >
                  获取验证码
                </el-button>
                <el-text v-else>{{ countdown }}s</el-text>
              </el-row>
            </template>
          </el-input>
        </el-form-item>
        <el-form-item prop="agreement" style="height: 30px;">
          <el-checkbox v-model="form.agreement" label="同意xxx服务协议" size="large" />
        </el-form-item>
        <el-button class="mt-1" @click="submitRegister()" type="primary" size="large" style="width: 100%;">
          <el-text class="text-lg text-white">注册</el-text>
        </el-button>
      </el-form>
      <el-row class="mt-7" justify="center">
        <el-button v-if="loginType!='register'" text type="primary" @click="changeLoginType('register')">注册账号</el-button>
        <el-divider v-if="loginType!='register'" direction="vertical" class="m-0 mt-1 mx-4" style="height: 24px !important" />
        <el-button v-if="loginType!='qrCode'" text type="primary" @click="changeLoginType('qrCode')">二维码登录</el-button>
        <el-divider v-if="loginType=='register'" direction="vertical" class="m-0 mt-1 mx-4" style="height: 24px !important" />
        <el-button v-if="loginType!='mobile'" text type="primary" @click="changeLoginType('mobile')">手机号登录</el-button>
      </el-row>
    </div>
  </el-dialog>
</template>
<script>
import { useLoginStore } from '@/stores/login.js'
import { storeToRefs } from 'pinia';
export default {
  components: {},
  setup() {
    const { loginDialogVisible } = storeToRefs(useLoginStore())
    return { loginDialogVisible }
  },
  data() {
    return {
      loginType: 'mobile', // mobile、qrCode
      form: {
        name: '',
        sex: 1,
        idCard: '',
        mobile: '13537719675',
        code: '',
        agreement: false
      },
      rules: {
        name: [ this.$rules.required('请输入姓名') ],
        idCard: [ this.$rules.required('请输入身份证号') ],
        mobile: [ this.$rules.required('请输入手机号'), this.$rules.phone() ],
        code: [ this.$rules.required('请填写验证码'), this.$rules.code() ],
        agreement: [ this.$rules.checkbox('请阅读并勾选同意协议') ]
      },
      countdown: 180,
      countdownInterval: null,
      sendCodeLoading: false,
    }
  },
  computed: {
    title() {
      let obj = {
        register: '注 册',
        mobile: '手 机 号登 录',
        qrCode: '微 信 扫 码 登 录'
      }
      return obj[this.loginType]
    }
  },
  created() {
  },
  unmounted() {
    this.clearCountdownInterval()
  },
  watch: {
  },
  methods: {
    async sendCode() {
      try {
        await this.$refs.accountForm.validateField('mobile')
        this.startCountdownInterval()
        this.$message.success('已发送验证码,请注意查收')
      } catch (error) {
        this.$message.error('请输入手机号码')
      }
    },
    startCountdownInterval() {
      this.clearCountdownInterval()
      this.countdown--
      this.countdownInterval = setInterval(() => {
        if (this.countdown > 0) {
          this.countdown--
        } else {
          this.countdown = 180
          this.clearCountdownInterval()
        }
      }, 1000)
    },
    clearCountdownInterval() {
      clearInterval(this.countdownInterval)
      this.countdownInterval = null
    },
    async submitLogin() {
      try {
        await this.$refs.accountForm.validate()
      } catch (error) {
        console.log()
      }
    },
    async submitRegister() {
      try {
        await this.$refs.registerForm.validate()
      } catch (error) {
        console.log()
      }
    },
    changeLoginType(type) {
      this.loginType = type
    }
  }
}
</script>
<style scoped>
</style>
src/views/main/components/DataTable.vue
New file
@@ -0,0 +1,132 @@
<template>
  <div class="mt-4 mb-2">
    <el-table
      :data="showList"
      table-layout="auto"
      stripe
      :empty-text="emptyText"
      style="width: 100%"
    >
      <el-table-column v-if="selectionFlag" type="selection" width="55" />
      <el-table-column
        v-for="(item,index) in headers" :key="index"
        :prop="item.value"
        :label="item.label"
        :width="item.width"
        :align="item.align || 'center'"
        :sortable="item.sortable"
      >
        <template v-if="$slots[item.value]" #default="scope">
          <slot :name="item.value" v-bind="scope"></slot>
        </template>
      </el-table-column>
    </el-table>
    <el-row class="mt-4" align="middle" justify="end" v-if="paginator">
      <el-pagination
        v-model:current-page="page"
        v-model:page-size="size"
        :page-sizes="pageSize"
        background
        :layout="layout"
        :total="totalCount || list.length"
        @size-change="handleSizeChange"
        @current-change="handleCurrentChange"
      />
    </el-row>
  </div>
</template>
<script>
export default {
  data() {
    return {
      pageSize: [12, 24, 36, 48],
      page: 1,
      size: 12,
      showList: [],
    }
  },
  props: {
    filter: {
      type: Object,
      default: () => { return { pageNo: 1, pageSize: 12 } }
    },
    headers: {
      type: Array,
      default: () => []
    },
    list: {
      type: Array,
      default: () => []
    },
    totalCount: {
      type: Number,
      default: 0
    },
    layout: {
      type: String,
      default: 'total, sizes, prev, pager, next, jumper'
    },
    selectionFlag: {
      type: Boolean,
      default: false
    },
    emptyText: {
      type: String,
      default: '暂无数据'
    },
    paginator: {
      type: Boolean,
      default: true
    }
  },
  watch: {
    list: {
      handler() {
        this.updateShowList()
      },
      immediate: true
    },
    'filter.page': function(val) {
      this.page = val
    },
    'filter.size': function(val) {
      this.size = val
    }
  },
  methods: {
    handleSizeChange() {
      this.$emit('update:filter', {
        ...this.filter,
        size: this.size
      })
      this.updateShowList()
    },
    handleCurrentChange() {
      this.$emit('update:filter', {
        ...this.filter,
        page: this.page,
      })
      this.updateShowList()
    },
    updateShowList() {
      if (this.paginator) {
        this.showList = this.list.slice((this.page - 1) * this.size, this.page * this.size)
      } else {
        this.showList = this.list
      }
    }
  }
}
</script>
<style>
.el-table th.el-table__cell {
  padding: 12px 0;
  background-color: #fafafa;
}
</style>
src/views/main/components/DictTag.vue
New file
@@ -0,0 +1,52 @@
<template>
  <el-tag
    :round="round"
    :style="cssClass"
  >
    <el-text :style="{ color: cssClass.color }">
      {{ text }}
    </el-text>
  </el-tag>
</template>
<script>
export default {
  data() {
    return {
      list: []
    }
  },
  props: {
    valueKey: [String, Number],
    dictType: String,
    round: {
      type: Boolean,
      default: true
    }
  },
  created() {
    this.initList()
  },
  computed: {
    dictObj: function() {
      return this.list.find(ele => ele.value == this.valueKey) || {}
    },
    text: function() {
      return this.dictObj?.label || ''
    },
    cssClass: function() {
      let str = ''
      try {
        str = JSON.parse(this.dictObj?.cssClass)
      } catch(error) {
        console.log()
      }
      return str
    }
  },
  methods: {
    initList() {
      this.list = this.$getDictData(this.dictType)
    }
  }
}
</script>
src/views/main/components/Filtrate.vue
New file
@@ -0,0 +1,55 @@
<template>
  <el-row style="width: 100%;flex-wrap: nowrap;">
    <div style="flex-grow: 0;">
      <el-tooltip content="刷新列表数据" placement="top-start">
        <el-button type="text" style="width: 30px;" :loading="loadingFlag" @click.stop="handlerRefresh()" class="mr-2">
          <Icon v-show="!loadingFlag" icon="material-symbols:refresh-rounded" width="26" height="26" style="color: #4d4d4d" />
          <template #loading>
            <Icon icon="line-md:loading-loop" width="30" height="30" style="color: black" />
          </template>
        </el-button>
      </el-tooltip>
    </div>
    <slot name="default"></slot>
  </el-row>
</template>
<script>
import { debounce } from '@/utils/tool.js'
export default {
  data() {
    return {
      loading: false
    }
  },
  props: {
    loadingFlag: {
      type: Boolean,
      default: false
    },
    refresh: {
      type: Function,
      default: () => {}
    },
    filter: {
      type: Object,
      default: () => {
        return {}
      }
    }
  },
  watch: {
    filter: {
      handler: debounce(function() {
        this.handlerRefresh()
      }, 1000),
      deep: true
    }
  },
  methods: {
    handlerRefresh() {
      this.refresh()
    }
  }
}
</script>
src/views/main/components/MyFooter.vue
New file
@@ -0,0 +1,74 @@
<template>
  <div class="custom-footer">
    <el-row justify="center">
      <el-select
        v-for="(item,index) in selectList"
        v-model="item.value"
        :placeholder="item.placeholder"
        :key="`select${index}`"
        style="width: 288px;font-size: 16px;"
        class="mx-2 mt-6"
      >
        <el-option
          v-for="item in item.options"
          :key="item.value"
          :label="item.label"
          :value="item.value"
        />
      </el-select>
    </el-row>
    <el-row>
      <el-text class="text-white font-medium text-default">
        ©2025 深圳市企鹅网络科技有限公司 粤ICP备15026064号   粤公网安备 44030502007327 号
      </el-text>
    </el-row>
  </div>
</template>
<script>
export default {
  data() {
    return {
      selectList: [
        {
          options: [],
          placeholder: "上级政府网站",
          value: ""
        },
        {
          options: [],
          placeholder: "各省人社部门网站",
          value: ""
        },
        {
          options: [],
          placeholder: "各地市人社部门网站",
          value: ""
        },
        {
          options: [],
          placeholder: "业务网站",
          value: ""
        },
      ]
    }
  }
}
</script>
<style scoped>
.custom-footer {
  width: 100%;
  min-height: 185px !important;
  display: flex;
  padding: 20px;
  flex-direction: column;
  justify-content: space-between;
  align-items: center;
  background-color: #007AFF;
  margin-top: 30px;
  /* background-image: url('@/assets/images/test.png'); */
}
:deep(.el-select__wrapper) {
  min-height: 44px;
}
</style>
src/views/main/components/MyHeader.vue
New file
@@ -0,0 +1,62 @@
<template>
  <div class="custom-header">
    <el-row class="content" justify="space-between">
      <el-text class="text-white text-2xl font-bold">广东省技能人才评价考务管理平台</el-text>
      <el-dropdown v-if="!userInfo.id" placement="bottom">
        <el-row  align="middle">
          <Icon icon="fa:user-circle" width="22" height="22" class="mr-2"  style="color: #fff" />
          <el-text class="text-white cursor-p text-lg font-bold">黄婷婷</el-text>
          <Icon icon="flowbite:caret-down-solid" width="22" height="22" class="ml-2"  style="color: #fff" />
        </el-row>
        <template #dropdown>
          <el-dropdown-menu>
            <el-dropdown-item>个人中心</el-dropdown-item>
            <el-dropdown-item>退出登录</el-dropdown-item>
          </el-dropdown-menu>
        </template>
      </el-dropdown>
      <el-button  v-else color="#007AFF">
        <el-text class="text-white cursor-p text-lg font-bold" @click="login">登 录</el-text>
      </el-button>
    </el-row>
  </div>
</template>
<script>
import { useSessionStore } from '@/stores/session.js'
import { useLoginStore } from '@/stores/login.js'
import { storeToRefs } from 'pinia';
export default {
  setup() {
    const { loginDialogVisible } = storeToRefs(useLoginStore())
    const { userInfo } = storeToRefs(useSessionStore())
    return { loginDialogVisible, userInfo }
  },
  data() {
    return {
    }
  },
  methods: {
    login() {
      this.loginDialogVisible = true
    }
  }
}
</script>
<style>
.custom-header {
  width: 100%;
  align-content: center;
  background-color: var(--el-color-primary);
  height: 60px;
}
.content {
  width: 100%;
  max-width: 1280px;
  margin: 0 auto;
  padding: 0 40px;
}
</style>
src/views/main/components/ReturnBtn.vue
New file
@@ -0,0 +1,17 @@
<template>
  <el-button text class="pl-0 pr-2 my-1" @click="goBack">
    <el-row align="middle">
      <Icon icon="mingcute:home-6-line" width="16" height="16" style="color: var(--el-color-primary)" />
      <el-text class="ml-1" style="color: var(--el-color-primary);">返回首页</el-text>
    </el-row>
  </el-button>
</template>
<script>
export default {
  methods: {
    goBack() {
      this.$router.replace('/main/home')
    }
  }
}
</script>
src/views/main/components/Upload.vue
New file
@@ -0,0 +1,3 @@
<template>
  <div></div>
</template>
src/views/main/components/UploadBtn.vue
New file
@@ -0,0 +1,248 @@
<template>
  <el-upload
    ref="upload"
    class="my-3" action="#"
    v-model:file-list="list"
    :accept="acceptType"
    :list-type="listType"
    :http-request="uploadRequest"
    :multiple="true"
    :limit="limitFileCount"
    :on-preview="handlePreview"
    :on-remove="handleRemove"
    :before-remove="beforeRemove"
    :on-exceed="handleExceed"
    :disabled="disabled"
    :class="{ hideUpload: hideUpload }"
  >
    <template #default>
      <template v-if="listType=='picture-card'">
        <el-icon v-if="!disabled"><Plus /></el-icon>
        <el-text v-if="disabled">暂未上传</el-text>
      </template>
      <template v-else>
        <el-row>
          <el-button type="primary">
            <el-row align="middle">
              <Icon icon="material-symbols:upload-rounded" width="20" height="20"  style="color: #fff" />
              <el-text class="text-white ml-1">{{ againUploadFlag ? '重新上传' : '立即上传' }}</el-text>
            </el-row>
          </el-button>
          <el-text style="color: #666666" class="ml-4">
            {{ tip }}
          </el-text>
        </el-row>
      </template>
    </template>
    <template #file="{ file, index }">
      <template v-if="listType=='picture-card'">
        <el-image
          ref="previewImg"
          :src="file.url"
          :initial-index="initialPreviewImgIndex"
          :preview-src-list="filterPreviewImgList"
        >
        </el-image>
        <span class="el-upload-list__item-actions">
          <span @click="previewImage(index)">
            <el-icon><zoom-in /></el-icon>
          </span>
          <!-- <span @click="replaceUpload(index)">
            <el-icon><upload /></el-icon>
          </span> -->
          <span @click="deleteFileItem(index)">
            <el-icon><delete /></el-icon>
          </span>
        </span>
      </template>
      <template v-else>
        <div class="file-box">
          <Icon
            @click="deleteFileItem(index)"
            class="cursor-p mr-3"
            icon="eva:close-fill"
            width="18"
            height="18"
            style="color: #666666"
          />
          <Icon class="mr-1" icon="uis:paperclip" width="18" height="18"  style="color:#007AFF" />
          <span>{{ file.name }}</span>
        </div>
      </template>
    </template>
  </el-upload>
  <el-dialog v-model="imgPreviewDialogFlag">
    <img w-full :src="previewUrl" alt="Preview Image" />
  </el-dialog>
</template>
<script>
const pdf = 'application/pdf'
const xls = 'application/vnd.ms-excel,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
const doc = 'application/msword,application/vnd.openxmlformats-officedocument.wordprocessingml.document'
const image = 'image/*'
const jpg = 'image/jpeg'
const png = 'image/png'
import { genFileId } from 'element-plus'
export default {
  data() {
    return {
      list: [],
      imgPreviewDialogFlag: false,
      previewUrl: '',
      initialPreviewImgIndex: 0
    }
  },
  props: {
    listType: {
      type: String,
      default: 'text'
    },
    accept: {
      type: Array,
      default: () => {
        return ['pdf', 'xls', 'doc', 'image', 'jpg', 'png']
      }
    },
    limitFileCount: {
      type: Number,
      default: 1
    },
    modelValue: {
      type: Array,
      default: () => {
        return []
      }
    },
    disabled: {
      type: Boolean,
      default: false
    }
  },
  watch: {
    modelValue: {
      handler: function() {
        this.list = this.modelValue
      },
      deep: true
    },
    list: {
      handler: function(val) {
        this.$emit('update:modelValue', val)
      },
      deep: true
    }
  },
  computed: {
    acceptType() {
      let obj = { pdf, xls, doc, image, jpg, png }
      return this.accept.map(ele => obj[ele]).join(',')
    },
    tip() {
      let obj = {
        pdf: 'PDF',
        xls: 'EXCEL',
        doc: 'WORD',
        image: '图片',
        jpg: 'JPEG/JPG',
        png: 'PNG'
      }
      let tip = this.accept.map(ele => obj[ele]).join('、')
      return `支持${tip}类型文件`
    },
    againUploadFlag() {
      return this.limitFileCount == 1  && this.list.length == 1
    },
    filterPreviewImgList() {
      let list = this.list.map(ele => ele.url )
      return list
    },
    hideUpload() {
      return this.list.length >= this.limitFileCount
    }
  },
  methods: {
    uploadRequest(UploadRequestOptions) {
      const data = {
        file: UploadRequestOptions.file,
        directory: ''
      }
      this.$axios.post('/infra/file/upload', data, {
        headers: { 'Content-Type': "multipart/form-data" }
      }).then(res => {
        let index = this.list.findIndex(ele => ele.uid == data.file.uid)
        if (res.data.code == 0) {
          let index = this.list.findIndex(ele => ele.uid == data.file.uid)
          if (index != -1) {
            this.list[index].uploadStatus = 'success'
            this.list[index].url = res.data.data
          }
          this.$message.success('上传成功')
        } else {
          if (index != -1) {
            this.list[index].uploadStatus = 'fail'
          }
          this.$message.error(res.data.msg || '上传失败')
        }
      })
    },
    deleteFileItem(index) {
      this.list.splice(index, 1)
    },
    previewImage(index) {
      this.initialPreviewImgIndex = index
      this.$refs.previewImg?.showPreview()
    },
    replaceUpload() {
    },
    handleRemove() {},
    beforeRemove() {},
    handlePreview() {},
    handleExceed(file) {
      if (this.againUploadFlag) {
        this.$refs.upload?.clearFiles()
        let newFile = file[0]
        newFile.uid = genFileId()
        this.$refs.upload?.handleStart(newFile)
        this.$refs.upload?.submit()
      } else {
        this.$message.error(`最多支持上传 ${this.limitFileCount} 个文件`)
      }
    }
  }
}
</script>
<style scoped>
.hideUpload :deep(.el-upload--picture-card) {
  display: none !important;
}
.file-box {
  display: flex;
  color: #007AFF;
  align-content: center;
  background-color: #ECF5FF;
  padding: 8px;
  font-size: 15px;
  line-height: 16px;
}
.el-upload-list__item-actions {
  position: absolute;
  top: 0;
  left: 0;
  width: 100%;
  height: 100%;
  background-color: rgba(0, 0, 0, 0.5); /* 半透明黑色蒙版 */
  display: flex;
  justify-content: center;
  align-items: center;
  opacity: 0; /* 默认透明 */
  transition: opacity .3s; /* 添加过渡动画效果 */
}
/* 鼠标悬停时显示蒙版 */
.el-upload-list__item:hover .el-upload-list__item-actions {
  opacity: 1;
}
</style>
src/views/main/components/contentTitle.vue
New file
@@ -0,0 +1,33 @@
<template>
  <el-row>
    <el-col>
      <div class="area">{{ title }}</div>
    </el-col>
  </el-row>
</template>
<script>
export default {
  data() {
    return {
    }
  },
  props: {
    title: {
      type: String,
      default: '基本信息',
    }
  }
}
</script>
<style scoped>
.area {
  color: '#000';
  background-color: #f7f7f7;
  padding: 14px;
  font-size: 16px;
  font-weight: bold;
  margin-top: 14px;
  margin-bottom: 14px;
}
</style>
src/views/main/components/logout.vue
New file
@@ -0,0 +1,47 @@
<template>
  <el-dialog
    v-model="dialogFlag"
    title="提示"
    width="500"
    align-center
  >
    <span>是否退出登录?</span>
    <template #footer>
      <div class="dialog-footer">
        <el-button @click="dialogFlag = false">取消</el-button>
        <el-button type="primary" @click="confirm()">
          确定
        </el-button>
      </div>
    </template>
  </el-dialog>
</template>
<script>
export default {
  data() {
    return {
      dialogFlag: false
    }
  },
  props: {
    modelValue: {
      type: Boolean,
      default: false
    }
  },
  watch: {
    modelValue(val) {
      this.dialogFlag = val
    },
    dialogFlag(val) {
      this.$emit('update:modelValue', val)
    }
  },
  methods: {
    confirm() {
      this.dialogFlag = false
      this.$router.push('/auth/login')
    }
  }
}
</script>
src/views/main/components/statusTag.vue
New file
@@ -0,0 +1,31 @@
<template>
  <template v-if="$property[propertyKey]">
    <el-tag
      :round="round"
      :style="{ borderColor: $property[propertyKey].borderColor[statusKey]}"
      :color="$property[propertyKey].bgColor[statusKey]"
    >
      <el-text :style="{ color: $property[propertyKey].textColor[statusKey] }">
        {{ $property[propertyKey].text[statusKey] }}
      </el-text>
    </el-tag>
  </template>
  <el-text v-else>
    {{ statusKey }}
  </el-text>
</template>
<script>
export default {
  data() {
    return {}
  },
  props: {
    statusKey: String,
    propertyKey: String,
    round: {
      type: Boolean,
      default: true
    }
  },
}
</script>
src/views/main/home/index.vue
New file
@@ -0,0 +1,109 @@
<template>
  <div>
    <el-image
      :src="$getImageUrl('/home/banner1.png')"
      style="width: 100%;max-height: 430px;"
    >
    </el-image>
    <div class="main-content">
      <el-row justify="space-between">
        <el-card
          v-for="(item,index) in operationList"
          :key="`operation${index}`"
          shadow="hover"
          style="max-width: 270px;"
          class="cursor-p my-4"
        >
          <el-image :src="$getImageUrl(`/home/${item.value}.png`)">
          </el-image>
        </el-card>
      </el-row>
      <el-row justify="space-between" class="py-2" style="border-bottom: 2px solid var(--el-color-primary);">
        <el-text class="text-xl font-bold">
          <span style="color: var(--el-color-primary);">通知</span>
          <span>公告</span>
        </el-text>
        <el-button text type="primary">查看全部>></el-button>
      </el-row>
      <el-card
        v-for="(notice,index) in noticeList"
        :key="`notice${index}`"
        class="mt-2 p-4 py-3"
        shadow="never"
      >
        <el-row justify="space-between" align="middle">
          <div>
            <el-row><el-text class="text-lg text-black font-medium">{{ notice.title }}</el-text></el-row>
            <el-row class="mt-2">
              <el-text style="margin-right: 40px;">发布时间:{{ notice.publishTime }}</el-text>
              <el-text>所属地区:{{ notice.area }}</el-text>
            </el-row>
          </div>
          <div>
            <el-button text type="primary">点击查看详情>></el-button>
          </div>
        </el-row>
      </el-card>
      <el-row class="mt-5" v-if="noticeList.length == 0" justify="center">
        <el-text>暂无公告~</el-text>
      </el-row>
    </div>
  </div>
</template>
<script>
export default {
  data() {
    return {
      operationList: [
        { name: "评价计划", value: 'appraisalPlan' },
        { name: "准考证查询", value: 'examTicket' },
        { name: "成绩查询", value: 'score' },
        { name: "证书查询", value: 'certificate' },
      ],
      noticeList: []
    }
  },
  created() {
    this.getNoticeList()
  },
  methods: {
    getNoticeList() {
      setTimeout(() => {
        this.noticeList =
        [
          {
            title: "关于公布2024年广东省产教评技能生态链链主培育单位入选名单的通知",
            publishTime: '2024-07-12 14:24:33',
            area: '广东省',
          },
          {
            title: "关于公布2024年广东省产教评技能生态链链主培育单位入选名单的通知",
            publishTime: '2024-07-12 14:24:33',
            area: '广东省',
          },
          {
            title: "关于公布2024年广东省产教评技能生态链链主培育单位入选名单的通知",
            publishTime: '2024-07-12 14:24:33',
            area: '广东省',
          },
          {
            title: "关于公布2024年广东省产教评技能生态链链主培育单位入选名单的通知",
            publishTime: '2024-07-12 14:24:33',
            area: '广东省',
          },
          {
            title: "关于公布2024年广东省产教评技能生态链链主培育单位入选名单的通知",
            publishTime: '2024-07-12 14:24:33',
            area: '广东省',
          },
        ]
      }, 400)
    }
  }
}
</script>
src/views/main/index.vue
New file
@@ -0,0 +1,48 @@
<template>
  <el-container direction="vertical" style="height: 100vh;">
    <el-header class="p-0">
      <MyHeader></MyHeader>
    </el-header>
    <el-main :style="{height: mainHeight}" class="custom-main p-0" >
      <div>
        <router-view></router-view>
      </div>
      <MyFooter></MyFooter>
    </el-main>
    <LoginDialog />
  </el-container>
</template>
<script>
import { useWindowSize } from '@/utils/hook.js'
import MyHeader from '@/views/main/components/MyHeader.vue'
import MyFooter from '@/views/main/components/MyFooter.vue'
import LoginDialog from '@/views/login/index.vue'
export default {
  components: {
    MyHeader,
    MyFooter,
    LoginDialog
  },
  setup() {
    const { height } = useWindowSize()
    return { height }
  },
  computed: {
    mainHeight: function() {
      return `${this.height - 60}px`
    }
  }
}
</script>
<style scoped>
.custom-main {
  display: flex;
  flex-direction: column;
  justify-content: space-between;
  -ms-overflow-style: none;  /* IE 和 Edge */
  scrollbar-width: none;     /* Firefox */
}
</style>
src/views/main/notice/detail.vue
New file
@@ -0,0 +1,86 @@
<template>
  <div class="main-content">
    <ReturnBtn></ReturnBtn>
    <el-card shadow="never" class="p-6">
      <!-- 标题 -->
      <el-row justify="center">
        <el-text class="text-3xl font-bold">{{detail.title}}</el-text>
      </el-row>
      <!-- 发表时间、地区、操作栏 -->
      <el-row class="mt-4" justify="space-between" align="middle">
        <div>
          <el-row>
            <el-text class="mr-4 text-note">发布时间:{{ detail.publishTime }}</el-text>
            <el-text class="text-note">所属地区:{{ detail.area }}</el-text>
          </el-row>
        </div>
        <div>
          <el-row>
            <el-text>字体:</el-text>
            <el-text
              v-for="(item,index) in setFontList"
              :key="`setFont${index}`"
              @click="fontSize = item.fontSize"
              class="cursor-p mx-1"
              :style="{ color: fontSize==item.fontSize ? `var(--el-color-primary)` : '' }"
            >
              {{ item.label }}
            </el-text>
          </el-row>
        </div>
      </el-row>
      <el-divider class="my-3" border-style="dashed"></el-divider>
      <!-- 内容 -->
      <div v-html="detail.content" :style="{ fontSize: `${fontSize}px` }"></div>
    </el-card>
  </div>
</template>
<script>
export default {
  data() {
    return {
      setFontList: [
        { label: '[大]', fontSize: 22 },
        { label: '[中]', fontSize: 18 },
        { label: '[小]', fontSize: 14 },
      ],
      fontSize: 18,
      detail: {
        title: '关于 [院校名称] [XXXX] 年第 [XX] 批次 [工种 / 项目名称] 职业技能等级认定(院校统考)成绩的公示',
        publishTime: '2026-01-28 14:24:33',
        area: '广东省',
        content: `
          <article>
            <h3>关于人工智能发展的思考</h3>
            <p class="meta">作者:AI研究员 | 发布日期:2024年1月</p>
            <p>近年来,<strong>人工智能(AI)</strong>技术取得了突破性进展,特别是在深度学习、自然语言处理和计算机视觉领域。</p>
            <blockquote>
              “AI不会取代人类,但会使用AI的人将取代不使用AI的人。”
            </blockquote>
            <p>主要技术突破包括:</p>
            <ul>
              <li>大规模预训练模型(如GPT系列)</li>
              <li>多模态理解与生成</li>
              <li>强化学习在实际问题中的应用</li>
            </ul>
            <p>未来发展趋势:</p>
            <ol>
              <li>更加高效和轻量化的模型</li>
              <li>AI与具体行业的深度融合</li>
              <li>对AI伦理和安全性的更多关注</li>
            </ol>
            <p>了解更多信息,请访问<a href="#" onclick="alert('示例链接')">我们的专题页面</a>。</p>
          </article>
        `
      }
    }
  }
}
</script>
src/views/main/notice/list.vue
New file
@@ -0,0 +1,3 @@
<template>
  <div class="main-content">列表</div>
</template>
vite.config.js
New file
@@ -0,0 +1,24 @@
import { fileURLToPath, URL } from 'node:url'
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import vueDevTools from 'vite-plugin-vue-devtools'
// https://vite.dev/config/
export default defineConfig({
  base: '/examination/user',
  plugins: [vue(), vueDevTools()],
  resolve: {
    alias: {
      '@': fileURLToPath(new URL('./src', import.meta.url)),
    },
  },
  server: {
    proxy: {
      '/admin-api': {
        target: 'http://101.43.143.75:48180', // dev
        changeOrigin: true,
      },
    },
  },
})