🎉 initial commit
This commit is contained in:
10
.env.development
Normal file
10
.env.development
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
# 开发环境配置
|
||||||
|
VITE_NODE_ENV='development'
|
||||||
|
|
||||||
|
# 开发环境
|
||||||
|
VITE_APP_BASE_API='/api'
|
||||||
|
|
||||||
|
# 网络请求公用地址
|
||||||
|
VITE_API_BASE_URL=''
|
||||||
|
|
||||||
|
|
||||||
10
.env.production
Normal file
10
.env.production
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
# 开发环境配置
|
||||||
|
VITE_NODE_ENV='production'
|
||||||
|
|
||||||
|
# 开发环境
|
||||||
|
VITE_APP_BASE_API='/api'
|
||||||
|
|
||||||
|
# 网络请求公用地址
|
||||||
|
VITE_API_BASE_URL=''
|
||||||
|
|
||||||
|
|
||||||
24
.gitignore
vendored
Normal file
24
.gitignore
vendored
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
|
||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
dist-ssr
|
||||||
|
*.local
|
||||||
|
|
||||||
|
# Editor directories and files
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/extensions.json
|
||||||
|
.idea
|
||||||
|
.DS_Store
|
||||||
|
*.suo
|
||||||
|
*.ntvs*
|
||||||
|
*.njsproj
|
||||||
|
*.sln
|
||||||
|
*.sw?
|
||||||
3
.vscode/extensions.json
vendored
Normal file
3
.vscode/extensions.json
vendored
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"recommendations": ["Vue.volar"]
|
||||||
|
}
|
||||||
5
README.md
Normal file
5
README.md
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
# Vue 3 + TypeScript + Vite
|
||||||
|
|
||||||
|
This template should help get you started developing with Vue 3 and TypeScript in Vite. The template uses Vue 3 `<script setup>` SFCs, check out the [script setup docs](https://v3.vuejs.org/api/sfc-script-setup.html#sfc-script-setup) to learn more.
|
||||||
|
|
||||||
|
Learn more about the recommended Project Setup and IDE Support in the [Vue Docs TypeScript Guide](https://vuejs.org/guide/typescript/overview.html#project-setup).
|
||||||
109
components.d.ts
vendored
Normal file
109
components.d.ts
vendored
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
/* eslint-disable */
|
||||||
|
// @ts-nocheck
|
||||||
|
// Generated by unplugin-vue-components
|
||||||
|
// Read more: https://github.com/vuejs/core/pull/3399
|
||||||
|
// biome-ignore lint: disable
|
||||||
|
export {}
|
||||||
|
|
||||||
|
/* prettier-ignore */
|
||||||
|
declare module 'vue' {
|
||||||
|
export interface GlobalComponents {
|
||||||
|
AAlert: typeof import('ant-design-vue/es')['Alert']
|
||||||
|
AAvatar: typeof import('ant-design-vue/es')['Avatar']
|
||||||
|
ABadge: typeof import('ant-design-vue/es')['Badge']
|
||||||
|
AButton: typeof import('ant-design-vue/es')['Button']
|
||||||
|
AButtonGroup: typeof import('ant-design-vue/es')['ButtonGroup']
|
||||||
|
ACard: typeof import('ant-design-vue/es')['Card']
|
||||||
|
ACheckbox: typeof import('ant-design-vue/es')['Checkbox']
|
||||||
|
ACheckboxGroup: typeof import('ant-design-vue/es')['CheckboxGroup']
|
||||||
|
ACol: typeof import('ant-design-vue/es')['Col']
|
||||||
|
ACollapse: typeof import('ant-design-vue/es')['Collapse']
|
||||||
|
ACollapsePanel: typeof import('ant-design-vue/es')['CollapsePanel']
|
||||||
|
ADatePicker: typeof import('ant-design-vue/es')['DatePicker']
|
||||||
|
ADescriptions: typeof import('ant-design-vue/es')['Descriptions']
|
||||||
|
ADescriptionsItem: typeof import('ant-design-vue/es')['DescriptionsItem']
|
||||||
|
ADivider: typeof import('ant-design-vue/es')['Divider']
|
||||||
|
AdminHeader: typeof import('./src/components/layout/AdminHeader.vue')['default']
|
||||||
|
AdminLayout: typeof import('./src/components/layout/AdminLayout.vue')['default']
|
||||||
|
AdminSidebar: typeof import('./src/components/layout/AdminSidebar.vue')['default']
|
||||||
|
ADropdown: typeof import('ant-design-vue/es')['Dropdown']
|
||||||
|
AEmpty: typeof import('ant-design-vue/es')['Empty']
|
||||||
|
AForm: typeof import('ant-design-vue/es')['Form']
|
||||||
|
AFormItem: typeof import('ant-design-vue/es')['FormItem']
|
||||||
|
AFormItemRest: typeof import('ant-design-vue/es')['FormItemRest']
|
||||||
|
AImage: typeof import('ant-design-vue/es')['Image']
|
||||||
|
AInput: typeof import('ant-design-vue/es')['Input']
|
||||||
|
AInputNumber: typeof import('ant-design-vue/es')['InputNumber']
|
||||||
|
AInputPassword: typeof import('ant-design-vue/es')['InputPassword']
|
||||||
|
AInputSearch: typeof import('ant-design-vue/es')['InputSearch']
|
||||||
|
ALayout: typeof import('ant-design-vue/es')['Layout']
|
||||||
|
ALayoutContent: typeof import('ant-design-vue/es')['LayoutContent']
|
||||||
|
ALayoutHeader: typeof import('ant-design-vue/es')['LayoutHeader']
|
||||||
|
ALayoutSider: typeof import('ant-design-vue/es')['LayoutSider']
|
||||||
|
AMenu: typeof import('ant-design-vue/es')['Menu']
|
||||||
|
AMenuDivider: typeof import('ant-design-vue/es')['MenuDivider']
|
||||||
|
AMenuItem: typeof import('ant-design-vue/es')['MenuItem']
|
||||||
|
AModal: typeof import('ant-design-vue/es')['Modal']
|
||||||
|
APageHeader: typeof import('ant-design-vue/es')['PageHeader']
|
||||||
|
APopconfirm: typeof import('ant-design-vue/es')['Popconfirm']
|
||||||
|
AProgress: typeof import('ant-design-vue/es')['Progress']
|
||||||
|
ARadio: typeof import('ant-design-vue/es')['Radio']
|
||||||
|
ARadioGroup: typeof import('ant-design-vue/es')['RadioGroup']
|
||||||
|
ARangePicker: typeof import('ant-design-vue/es')['RangePicker']
|
||||||
|
AResult: typeof import('ant-design-vue/es')['Result']
|
||||||
|
ARow: typeof import('ant-design-vue/es')['Row']
|
||||||
|
ASelect: typeof import('ant-design-vue/es')['Select']
|
||||||
|
ASelectOption: typeof import('ant-design-vue/es')['SelectOption']
|
||||||
|
ASpace: typeof import('ant-design-vue/es')['Space']
|
||||||
|
ASpin: typeof import('ant-design-vue/es')['Spin']
|
||||||
|
AStatistic: typeof import('ant-design-vue/es')['Statistic']
|
||||||
|
AStep: typeof import('ant-design-vue/es')['Step']
|
||||||
|
ASteps: typeof import('ant-design-vue/es')['Steps']
|
||||||
|
ASubMenu: typeof import('ant-design-vue/es')['SubMenu']
|
||||||
|
ASwitch: typeof import('ant-design-vue/es')['Switch']
|
||||||
|
ATable: typeof import('ant-design-vue/es')['Table']
|
||||||
|
ATabPane: typeof import('ant-design-vue/es')['TabPane']
|
||||||
|
ATabs: typeof import('ant-design-vue/es')['Tabs']
|
||||||
|
ATag: typeof import('ant-design-vue/es')['Tag']
|
||||||
|
ATextarea: typeof import('ant-design-vue/es')['Textarea']
|
||||||
|
ATypographyText: typeof import('ant-design-vue/es')['TypographyText']
|
||||||
|
AUpload: typeof import('ant-design-vue/es')['Upload']
|
||||||
|
AUploadDragger: typeof import('ant-design-vue/es')['UploadDragger']
|
||||||
|
BannerForm: typeof import('./src/views/banners/components/BannerForm.vue')['default']
|
||||||
|
BannerList: typeof import('./src/views/banners/components/BannerList.vue')['default']
|
||||||
|
BannerPage: typeof import('./src/views/banners/BannerPage.vue')['default']
|
||||||
|
BatchImport: typeof import('./src/views/schools/components/BatchImport.vue')['default']
|
||||||
|
BatchOperations: typeof import('./src/views/questions/components/BatchOperations.vue')['default']
|
||||||
|
ClassBatchImport: typeof import('./src/views/classes/components/ClassBatchImport.vue')['default']
|
||||||
|
ClassForm: typeof import('./src/views/schools/components/ClassForm.vue')['default']
|
||||||
|
ClassFormModal: typeof import('./src/views/classes/components/ClassFormModal.vue')['default']
|
||||||
|
ClassPage: typeof import('./src/views/classes/ClassPage.vue')['default']
|
||||||
|
ClassStudentsModal: typeof import('./src/views/classes/components/ClassStudentsModal.vue')['default']
|
||||||
|
DashboardPage: typeof import('./src/views/dashboard/DashboardPage.vue')['default']
|
||||||
|
GradeForm: typeof import('./src/views/schools/components/GradeForm.vue')['default']
|
||||||
|
GradeFormModal: typeof import('./src/views/grades/components/GradeFormModal.vue')['default']
|
||||||
|
GradePage: typeof import('./src/views/grades/GradePage.vue')['default']
|
||||||
|
HelloWorld: typeof import('./src/components/HelloWorld.vue')['default']
|
||||||
|
ImportExport: typeof import('./src/views/questions/components/ImportExport.vue')['default']
|
||||||
|
LoginPage: typeof import('./src/views/auth/LoginPage.vue')['default']
|
||||||
|
PasswordPage: typeof import('./src/views/profile/PasswordPage.vue')['default']
|
||||||
|
ProfilePage: typeof import('./src/views/profile/ProfilePage.vue')['default']
|
||||||
|
QuestionForm: typeof import('./src/views/questions/components/QuestionForm.vue')['default']
|
||||||
|
QuestionList: typeof import('./src/views/questions/components/QuestionList.vue')['default']
|
||||||
|
QuestionPage: typeof import('./src/views/questions/QuestionPage.vue')['default']
|
||||||
|
RecordDetailModal: typeof import('./src/views/records/components/RecordDetailModal.vue')['default']
|
||||||
|
RecordList: typeof import('./src/views/records/components/RecordList.vue')['default']
|
||||||
|
RecordPage: typeof import('./src/views/records/RecordPage.vue')['default']
|
||||||
|
RichEditor: typeof import('./src/components/RichEditor.vue')['default']
|
||||||
|
RouterLink: typeof import('vue-router')['RouterLink']
|
||||||
|
RouterView: typeof import('vue-router')['RouterView']
|
||||||
|
SchoolForm: typeof import('./src/views/schools/components/SchoolForm.vue')['default']
|
||||||
|
SchoolPage: typeof import('./src/views/schools/SchoolPage.vue')['default']
|
||||||
|
SchoolTree: typeof import('./src/views/schools/components/SchoolTree.vue')['default']
|
||||||
|
StatisticsCards: typeof import('./src/views/records/components/StatisticsCards.vue')['default']
|
||||||
|
StudentFormModal: typeof import('./src/views/classes/components/StudentFormModal.vue')['default']
|
||||||
|
UserDetailModal: typeof import('./src/views/users/components/UserDetailModal.vue')['default']
|
||||||
|
UserList: typeof import('./src/views/users/components/UserList.vue')['default']
|
||||||
|
UserPage: typeof import('./src/views/users/UserPage.vue')['default']
|
||||||
|
}
|
||||||
|
}
|
||||||
59
eslint.config.ts
Normal file
59
eslint.config.ts
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
import js from "@eslint/js";
|
||||||
|
import globals from "globals";
|
||||||
|
import tseslint from "typescript-eslint";
|
||||||
|
import pluginVue from "eslint-plugin-vue";
|
||||||
|
import {defineConfig} from "eslint/config";
|
||||||
|
|
||||||
|
export default defineConfig([
|
||||||
|
{
|
||||||
|
files: ["**/*.{js,mjs,cjs,ts,mts,cts,vue}"],
|
||||||
|
plugins: {js},
|
||||||
|
extends: ["js/recommended"],
|
||||||
|
languageOptions: {globals: {...globals.browser, ...globals.node}}
|
||||||
|
},
|
||||||
|
tseslint.configs.recommended,
|
||||||
|
pluginVue.configs["flat/essential"],
|
||||||
|
{files: ["**/*.vue"], languageOptions: {parserOptions: {parser: tseslint.parser}}},
|
||||||
|
{
|
||||||
|
rules: {
|
||||||
|
semi: "error",
|
||||||
|
"@typescript-eslint/no-explicit-any": "off",
|
||||||
|
"vue/multi-word-component-names": "off",
|
||||||
|
"@typescript-eslint/no-unused-vars": [
|
||||||
|
"error",
|
||||||
|
{
|
||||||
|
"args": "all",
|
||||||
|
"argsIgnorePattern": "^_",
|
||||||
|
"caughtErrors": "all",
|
||||||
|
"caughtErrorsIgnorePattern": "^_",
|
||||||
|
"destructuredArrayIgnorePattern": "^_",
|
||||||
|
"varsIgnorePattern": "^_",
|
||||||
|
"ignoreRestSiblings": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
}, {
|
||||||
|
ignores: [
|
||||||
|
'**/dist',
|
||||||
|
'./src/main.ts',
|
||||||
|
'.vscode',
|
||||||
|
'.idea',
|
||||||
|
'*.sh',
|
||||||
|
'**/node_modules',
|
||||||
|
'*.md',
|
||||||
|
'*.woff',
|
||||||
|
'*.woff',
|
||||||
|
'*.ttf',
|
||||||
|
'yarn.lock',
|
||||||
|
'package-lock.json',
|
||||||
|
'/public',
|
||||||
|
'/docs',
|
||||||
|
'**/output',
|
||||||
|
'.husky',
|
||||||
|
'.local',
|
||||||
|
'/bin',
|
||||||
|
'Dockerfile',
|
||||||
|
'**/bindings/'
|
||||||
|
],
|
||||||
|
}
|
||||||
|
]);
|
||||||
13
index.html
Normal file
13
index.html
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Vite + Vue + TS</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
<script type="module" src="/src/main.ts"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
5267
package-lock.json
generated
Normal file
5267
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
43
package.json
Normal file
43
package.json
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
{
|
||||||
|
"name": "zhuzi-admin",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vue-tsc -b && vite build",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@alova/adapter-axios": "^2.0.16",
|
||||||
|
"@alova/mock": "^2.0.17",
|
||||||
|
"@wangeditor/editor": "^5.1.23",
|
||||||
|
"@wangeditor/editor-for-vue": "^5.1.12",
|
||||||
|
"alova": "^3.3.4",
|
||||||
|
"ant-design-vue": "^4.2.6",
|
||||||
|
"axios": "^1.12.2",
|
||||||
|
"date-fns": "^4.1.0",
|
||||||
|
"localforage": "^1.10.0",
|
||||||
|
"pinia": "^3.0.3",
|
||||||
|
"pinia-plugin-persistedstate": "^4.5.0",
|
||||||
|
"sass": "^1.92.1",
|
||||||
|
"vue": "^3.5.18",
|
||||||
|
"vue-router": "^4.5.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@eslint/js": "^9.35.0",
|
||||||
|
"@types/node": "^24.4.0",
|
||||||
|
"@vitejs/plugin-vue": "^6.0.1",
|
||||||
|
"@vue/tsconfig": "^0.7.0",
|
||||||
|
"eslint": "^9.35.0",
|
||||||
|
"eslint-plugin-vue": "^10.4.0",
|
||||||
|
"globals": "^16.4.0",
|
||||||
|
"jiti": "^2.5.1",
|
||||||
|
"typescript": "~5.8.3",
|
||||||
|
"typescript-eslint": "^8.43.0",
|
||||||
|
"unplugin-vue-components": "^29.0.0",
|
||||||
|
"vite": "^7.1.2",
|
||||||
|
"vue-eslint-parser": "^10.2.0",
|
||||||
|
"vue-tsc": "^3.0.5"
|
||||||
|
}
|
||||||
|
}
|
||||||
1
public/vite.svg
Normal file
1
public/vite.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
|
||||||
|
After Width: | Height: | Size: 1.5 KiB |
78
src/App.vue
Normal file
78
src/App.vue
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
<template>
|
||||||
|
<router-view />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
// 朱子文化管理后台主应用组件
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
/* 全局样式重置 */
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Hiragino Sans GB',
|
||||||
|
'Microsoft YaHei', 'Helvetica Neue', Helvetica, Arial, sans-serif, 'Apple Color Emoji',
|
||||||
|
'Segoe UI Emoji', 'Segoe UI Symbol';
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.5715;
|
||||||
|
color: rgba(0, 0, 0, 0.85);
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
#app {
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Ant Design Vue 样式补充 */
|
||||||
|
.ant-layout {
|
||||||
|
background: #f0f2f5;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 滚动条样式 */
|
||||||
|
::-webkit-scrollbar {
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-track {
|
||||||
|
background: #f1f1f1;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb {
|
||||||
|
background: #c1c1c1;
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: #a8a8a8;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 工具类 */
|
||||||
|
.text-center {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-right {
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mb-0 {
|
||||||
|
margin-bottom: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mb-8 {
|
||||||
|
margin-bottom: 8px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mb-16 {
|
||||||
|
margin-bottom: 16px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mb-24 {
|
||||||
|
margin-bottom: 24px !important;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
64
src/apis/auth.ts
Normal file
64
src/apis/auth.ts
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
import { request } from "@/utils/request";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 登录请求参数
|
||||||
|
*/
|
||||||
|
export interface LoginParams {
|
||||||
|
username: string;
|
||||||
|
password: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 登录响应数据
|
||||||
|
*/
|
||||||
|
export interface LoginResponse {
|
||||||
|
token: string;
|
||||||
|
refreshToken: string;
|
||||||
|
userInfo: {
|
||||||
|
id: string;
|
||||||
|
username: string;
|
||||||
|
nickname: string;
|
||||||
|
avatar?: string;
|
||||||
|
role: 'admin' | 'operator';
|
||||||
|
createTime: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 管理员登录
|
||||||
|
*/
|
||||||
|
export const adminLogin = (params: LoginParams) => {
|
||||||
|
return request.Post<LoginResponse>('/admin/auth/login', params, {
|
||||||
|
meta: {
|
||||||
|
ignoreToken: true,
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取当前用户信息
|
||||||
|
*/
|
||||||
|
export const getCurrentUser = () => {
|
||||||
|
return request.Get<LoginResponse['userInfo']>('/admin/auth/profile');
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 管理员退出登录
|
||||||
|
*/
|
||||||
|
export const adminLogout = () => {
|
||||||
|
return request.Post('/admin/auth/logout');
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 刷新token
|
||||||
|
*/
|
||||||
|
export const refreshAdminToken = () => {
|
||||||
|
return request.Post('/admin/auth/refresh-token', {}, {
|
||||||
|
meta: {
|
||||||
|
authRole: 'refreshToken',
|
||||||
|
ignoreToken: false,
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
116
src/apis/banners.ts
Normal file
116
src/apis/banners.ts
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
import { request } from "@/utils/request";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 轮播图链接类型
|
||||||
|
*/
|
||||||
|
export type BannerLinkType = 'url' | 'article';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 轮播图数据
|
||||||
|
*/
|
||||||
|
export interface Banner {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
image: string;
|
||||||
|
linkType: BannerLinkType;
|
||||||
|
linkUrl?: string;
|
||||||
|
articleContent?: string;
|
||||||
|
sort: number;
|
||||||
|
status: 'enabled' | 'disabled';
|
||||||
|
createTime: string;
|
||||||
|
updateTime: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 轮播图列表查询参数
|
||||||
|
*/
|
||||||
|
export interface BannerQueryParams {
|
||||||
|
page?: number;
|
||||||
|
pageSize?: number;
|
||||||
|
status?: 'enabled' | 'disabled';
|
||||||
|
keyword?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 轮播图列表响应
|
||||||
|
*/
|
||||||
|
export interface BannerListResponse {
|
||||||
|
list: Banner[];
|
||||||
|
total: number;
|
||||||
|
page: number;
|
||||||
|
pageSize: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建轮播图参数
|
||||||
|
*/
|
||||||
|
export interface CreateBannerParams {
|
||||||
|
title: string;
|
||||||
|
image: string;
|
||||||
|
linkType: BannerLinkType;
|
||||||
|
linkUrl?: string;
|
||||||
|
articleContent?: string;
|
||||||
|
sort: number;
|
||||||
|
status: 'enabled' | 'disabled';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取轮播图列表
|
||||||
|
*/
|
||||||
|
export const getBannerList = (params?: BannerQueryParams) => {
|
||||||
|
return request.Get<BannerListResponse>('/admin/banners', {
|
||||||
|
params
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取轮播图详情
|
||||||
|
*/
|
||||||
|
export const getBannerDetail = (id: string) => {
|
||||||
|
return request.Get<Banner>(`/admin/banners/${id}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建轮播图
|
||||||
|
*/
|
||||||
|
export const createBanner = (params: CreateBannerParams) => {
|
||||||
|
return request.Post<{ message: string; id: string }>('/admin/banners', params);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新轮播图
|
||||||
|
*/
|
||||||
|
export const updateBanner = (id: string, params: CreateBannerParams) => {
|
||||||
|
return request.Put<{ message: string }>(`/admin/banners/${id}`, params);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除轮播图
|
||||||
|
*/
|
||||||
|
export const deleteBanner = (id: string) => {
|
||||||
|
return request.Delete<{ message: string }>(`/admin/banners/${id}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新轮播图状态
|
||||||
|
*/
|
||||||
|
export const updateBannerStatus = (id: string, status: 'enabled' | 'disabled') => {
|
||||||
|
return request.Put<{ message: string }>(`/admin/banners/${id}/status`, { status });
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新轮播图排序
|
||||||
|
*/
|
||||||
|
export const updateBannerSort = (id: string, sort: number) => {
|
||||||
|
return request.Put<{ message: string }>(`/admin/banners/${id}/sort`, { sort });
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 上传轮播图图片
|
||||||
|
*/
|
||||||
|
export const uploadBannerImage = (file: File) => {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('image', file);
|
||||||
|
|
||||||
|
return request.Post<{ url: string }>('/admin/upload/banner', formData);
|
||||||
|
};
|
||||||
108
src/apis/classes.ts
Normal file
108
src/apis/classes.ts
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
import { request } from '@/utils/request';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取班级列表
|
||||||
|
*/
|
||||||
|
export const getClasses = (params: {
|
||||||
|
schoolId: string;
|
||||||
|
gradeId: string;
|
||||||
|
page?: number;
|
||||||
|
pageSize?: number;
|
||||||
|
keyword?: string;
|
||||||
|
}) => {
|
||||||
|
return request.Get('/admin/classes', {
|
||||||
|
params
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建班级
|
||||||
|
*/
|
||||||
|
export const createClass = (data: {
|
||||||
|
name: string;
|
||||||
|
code: string;
|
||||||
|
schoolId: string;
|
||||||
|
gradeId: string;
|
||||||
|
teacherName?: string;
|
||||||
|
teacherPhone?: string;
|
||||||
|
classroom?: string;
|
||||||
|
sort?: number;
|
||||||
|
maxStudents?: number;
|
||||||
|
enrollmentYear?: number;
|
||||||
|
description?: string;
|
||||||
|
status?: string;
|
||||||
|
}) => {
|
||||||
|
return request.Post('/admin/classes', data);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新班级
|
||||||
|
*/
|
||||||
|
export const updateClass = (id: string, data: {
|
||||||
|
name?: string;
|
||||||
|
code?: string;
|
||||||
|
teacherName?: string;
|
||||||
|
teacherPhone?: string;
|
||||||
|
classroom?: string;
|
||||||
|
sort?: number;
|
||||||
|
maxStudents?: number;
|
||||||
|
enrollmentYear?: number;
|
||||||
|
description?: string;
|
||||||
|
status?: string;
|
||||||
|
}) => {
|
||||||
|
return request.Put(`/admin/classes/${id}`, data);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除班级
|
||||||
|
*/
|
||||||
|
export const deleteClass = (id: string) => {
|
||||||
|
return request.Delete(`/admin/classes/${id}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 切换班级状态
|
||||||
|
*/
|
||||||
|
export const toggleClassStatus = (id: string) => {
|
||||||
|
return request.Patch(`/admin/classes/${id}/toggle-status`);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取班级详情
|
||||||
|
*/
|
||||||
|
export const getClassDetail = (id: string) => {
|
||||||
|
return request.Get(`/admin/classes/${id}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取班级学生列表
|
||||||
|
*/
|
||||||
|
export const getClassStudents = (classId: string, params?: {
|
||||||
|
page?: number;
|
||||||
|
pageSize?: number;
|
||||||
|
keyword?: string;
|
||||||
|
}) => {
|
||||||
|
return request.Get(`/admin/classes/${classId}/students`, {
|
||||||
|
params
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 批量导入班级
|
||||||
|
*/
|
||||||
|
export const importClasses = (data: {
|
||||||
|
schoolId: string;
|
||||||
|
gradeId: string;
|
||||||
|
file: File;
|
||||||
|
}) => {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('schoolId', data.schoolId);
|
||||||
|
formData.append('gradeId', data.gradeId);
|
||||||
|
formData.append('file', data.file);
|
||||||
|
|
||||||
|
return request.Post('/admin/classes/import', formData, {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'multipart/form-data'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
65
src/apis/grades.ts
Normal file
65
src/apis/grades.ts
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
import { request } from '@/utils/request';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取年级列表
|
||||||
|
*/
|
||||||
|
export const getGrades = (params: {
|
||||||
|
schoolId: string;
|
||||||
|
page?: number;
|
||||||
|
pageSize?: number;
|
||||||
|
keyword?: string;
|
||||||
|
}) => {
|
||||||
|
return request.Get('/admin/grades', {
|
||||||
|
params
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建年级
|
||||||
|
*/
|
||||||
|
export const createGrade = (data: {
|
||||||
|
name: string;
|
||||||
|
code: string;
|
||||||
|
schoolId: string;
|
||||||
|
duration?: number;
|
||||||
|
sort?: number;
|
||||||
|
description?: string;
|
||||||
|
status?: string;
|
||||||
|
}) => {
|
||||||
|
return request.Post('/admin/grades', data);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新年级
|
||||||
|
*/
|
||||||
|
export const updateGrade = (id: string, data: {
|
||||||
|
name?: string;
|
||||||
|
code?: string;
|
||||||
|
duration?: number;
|
||||||
|
sort?: number;
|
||||||
|
description?: string;
|
||||||
|
status?: string;
|
||||||
|
}) => {
|
||||||
|
return request.Put(`/admin/grades/${id}`, data);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除年级
|
||||||
|
*/
|
||||||
|
export const deleteGrade = (id: string) => {
|
||||||
|
return request.Delete(`/admin/grades/${id}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 切换年级状态
|
||||||
|
*/
|
||||||
|
export const toggleGradeStatus = (id: string) => {
|
||||||
|
return request.Patch(`/admin/grades/${id}/toggle-status`);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取年级详情
|
||||||
|
*/
|
||||||
|
export const getGradeDetail = (id: string) => {
|
||||||
|
return request.Get(`/admin/grades/${id}`);
|
||||||
|
};
|
||||||
51
src/apis/login.ts
Normal file
51
src/apis/login.ts
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
import {request} from "@/utils/request";
|
||||||
|
/**
|
||||||
|
* 刷新token post演示接口
|
||||||
|
*/
|
||||||
|
export const refreshToken = () => {
|
||||||
|
return request.Post('/api/auth/token/refresh', {}, {
|
||||||
|
meta: {
|
||||||
|
authRole: 'refreshToken',
|
||||||
|
ignoreToken: false,
|
||||||
|
signature: true
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取Gitee登录链接 get演示接口
|
||||||
|
*/
|
||||||
|
export const getGiteeUrl = () => {
|
||||||
|
return request.Get('/api/oauth/gitee/url',
|
||||||
|
{
|
||||||
|
meta: {
|
||||||
|
ignoreToken: true,
|
||||||
|
},
|
||||||
|
cacheFor: {
|
||||||
|
mode: "restore",
|
||||||
|
expire: 1000 * 60 * 60 * 24
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 使用方式:
|
||||||
|
* 请注意,useRequest只能用于组件内发送请求,在组件外,你可以通过 method 实例直接发送请求,并且 useRequest 的使用需要符合 use hook 使用规则,即只能在函数最外层调用。
|
||||||
|
*
|
||||||
|
* ❌❌❌ 不推荐在在循环、条件判断或者子函数中调用,例如以下在 click 回调中的使用示例,在回调函数中使用时,虽然可以正常发起请求,但 use hook 返回的响应式数据无法在视图中使用,循环和条件判断中使用也是如此。
|
||||||
|
*
|
||||||
|
* // ❌ bad
|
||||||
|
* const handleClick = () => {
|
||||||
|
* const { loading, data } = useRequest(getter);
|
||||||
|
* };
|
||||||
|
*
|
||||||
|
* // -------
|
||||||
|
* // ✅ good
|
||||||
|
* const { loading, data, send } = useRequest(getter, {
|
||||||
|
* immediate: false
|
||||||
|
* });
|
||||||
|
* const handleClick = () => {
|
||||||
|
* send();
|
||||||
|
* };
|
||||||
|
*/
|
||||||
43
src/apis/profile.ts
Normal file
43
src/apis/profile.ts
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
import { request } from '@/utils/request';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取用户资料
|
||||||
|
*/
|
||||||
|
export const getUserProfile = () => {
|
||||||
|
return request.Get('/admin/profile');
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新用户资料
|
||||||
|
*/
|
||||||
|
export const updateUserProfile = (data: {
|
||||||
|
realName?: string;
|
||||||
|
phone?: string;
|
||||||
|
email?: string;
|
||||||
|
gender?: string;
|
||||||
|
birthday?: string;
|
||||||
|
bio?: string;
|
||||||
|
}) => {
|
||||||
|
return request.Put('/admin/profile', data);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 上传头像
|
||||||
|
*/
|
||||||
|
export const uploadAvatar = (formData: FormData) => {
|
||||||
|
return request.Post('/admin/profile/avatar', formData, {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'multipart/form-data'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 修改密码
|
||||||
|
*/
|
||||||
|
export const changePassword = (data: {
|
||||||
|
oldPassword: string;
|
||||||
|
newPassword: string;
|
||||||
|
}) => {
|
||||||
|
return request.Post('/admin/profile/password', data);
|
||||||
|
};
|
||||||
151
src/apis/questions.ts
Normal file
151
src/apis/questions.ts
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
import { request } from "@/utils/request";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 题目类型
|
||||||
|
*/
|
||||||
|
export type QuestionType = 'single' | 'multiple';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 题目选项
|
||||||
|
*/
|
||||||
|
export interface QuestionOption {
|
||||||
|
key: string;
|
||||||
|
value: string;
|
||||||
|
isCorrect?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 题目数据
|
||||||
|
*/
|
||||||
|
export interface Question {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
content: string;
|
||||||
|
image?: string;
|
||||||
|
type: QuestionType;
|
||||||
|
options: QuestionOption[];
|
||||||
|
correctAnswer: string[];
|
||||||
|
score: number;
|
||||||
|
difficulty: 'easy' | 'medium' | 'hard';
|
||||||
|
category: string;
|
||||||
|
tags: string[];
|
||||||
|
createTime: string;
|
||||||
|
updateTime: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 题目列表查询参数
|
||||||
|
*/
|
||||||
|
export interface QuestionQueryParams {
|
||||||
|
page?: number;
|
||||||
|
pageSize?: number;
|
||||||
|
keyword?: string;
|
||||||
|
type?: QuestionType;
|
||||||
|
difficulty?: 'easy' | 'medium' | 'hard';
|
||||||
|
category?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 题目列表响应
|
||||||
|
*/
|
||||||
|
export interface QuestionListResponse {
|
||||||
|
list: Question[];
|
||||||
|
total: number;
|
||||||
|
page: number;
|
||||||
|
pageSize: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建题目参数
|
||||||
|
*/
|
||||||
|
export interface CreateQuestionParams {
|
||||||
|
title: string;
|
||||||
|
content: string;
|
||||||
|
image?: string;
|
||||||
|
type: QuestionType;
|
||||||
|
options: QuestionOption[];
|
||||||
|
correctAnswer: string[];
|
||||||
|
score: number;
|
||||||
|
difficulty: 'easy' | 'medium' | 'hard';
|
||||||
|
category: string;
|
||||||
|
tags: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 批量导入题目参数
|
||||||
|
*/
|
||||||
|
export interface ImportQuestionsParams {
|
||||||
|
file: File;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取题目列表
|
||||||
|
*/
|
||||||
|
export const getQuestionList = (params?: QuestionQueryParams) => {
|
||||||
|
return request.Get<QuestionListResponse>('/admin/questions', {
|
||||||
|
params
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取题目详情
|
||||||
|
*/
|
||||||
|
export const getQuestionDetail = (id: string) => {
|
||||||
|
return request.Get<Question>(`/admin/questions/${id}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建题目
|
||||||
|
*/
|
||||||
|
export const createQuestion = (params: CreateQuestionParams) => {
|
||||||
|
return request.Post<{ message: string; id: string }>('/admin/questions', params);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新题目
|
||||||
|
*/
|
||||||
|
export const updateQuestion = (id: string, params: CreateQuestionParams) => {
|
||||||
|
return request.Put<{ message: string }>(`/admin/questions/${id}`, params);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除题目
|
||||||
|
*/
|
||||||
|
export const deleteQuestion = (id: string) => {
|
||||||
|
return request.Delete<{ message: string }>(`/admin/questions/${id}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 批量删除题目
|
||||||
|
*/
|
||||||
|
export const batchDeleteQuestions = (ids: string[]) => {
|
||||||
|
return request.Delete<{ message: string }>('/admin/questions/batch', {
|
||||||
|
data: { ids }
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 批量导入题目
|
||||||
|
*/
|
||||||
|
export const importQuestions = (file: File) => {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('file', file);
|
||||||
|
|
||||||
|
return request.Post<{ message: string; successCount: number; failCount: number }>('/admin/questions/import', formData);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 导出题目模板
|
||||||
|
*/
|
||||||
|
export const exportQuestionTemplate = () => {
|
||||||
|
return request.Get('/admin/questions/template', {
|
||||||
|
responseType: 'blob'
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取题目分类列表
|
||||||
|
*/
|
||||||
|
export const getQuestionCategories = () => {
|
||||||
|
return request.Get<{ name: string; count: number }[]>('/admin/questions/categories');
|
||||||
|
};
|
||||||
120
src/apis/records.ts
Normal file
120
src/apis/records.ts
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
import { request } from "@/utils/request";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 答题人类型
|
||||||
|
*/
|
||||||
|
export type AnswererType = 'student' | 'parent';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 答题记录数据
|
||||||
|
*/
|
||||||
|
export interface AnswerRecord {
|
||||||
|
id: string;
|
||||||
|
userId: string;
|
||||||
|
studentName: string;
|
||||||
|
parentPhone: string;
|
||||||
|
schoolName: string;
|
||||||
|
gradeName: string;
|
||||||
|
className: string;
|
||||||
|
answererType: AnswererType;
|
||||||
|
totalQuestions: number;
|
||||||
|
correctCount: number;
|
||||||
|
totalScore: number;
|
||||||
|
answerTime: number; // 答题用时(秒)
|
||||||
|
questionDetails: {
|
||||||
|
questionId: string;
|
||||||
|
questionTitle: string;
|
||||||
|
userAnswer: string[];
|
||||||
|
correctAnswer: string[];
|
||||||
|
isCorrect: boolean;
|
||||||
|
score: number;
|
||||||
|
timeSpent: number;
|
||||||
|
}[];
|
||||||
|
createTime: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 答题记录列表查询参数
|
||||||
|
*/
|
||||||
|
export interface RecordQueryParams {
|
||||||
|
page?: number;
|
||||||
|
pageSize?: number;
|
||||||
|
keyword?: string; // 学生姓名关键词
|
||||||
|
schoolName?: string;
|
||||||
|
gradeName?: string;
|
||||||
|
className?: string;
|
||||||
|
answererType?: AnswererType;
|
||||||
|
startTime?: string;
|
||||||
|
endTime?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 答题记录列表响应
|
||||||
|
*/
|
||||||
|
export interface RecordListResponse {
|
||||||
|
list: AnswerRecord[];
|
||||||
|
total: number;
|
||||||
|
page: number;
|
||||||
|
pageSize: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 答题统计数据
|
||||||
|
*/
|
||||||
|
export interface AnswerStatistics {
|
||||||
|
totalRecords: number;
|
||||||
|
totalUsers: number;
|
||||||
|
avgScore: number;
|
||||||
|
avgCorrectRate: number;
|
||||||
|
popularQuestions: {
|
||||||
|
questionId: string;
|
||||||
|
questionTitle: string;
|
||||||
|
answerCount: number;
|
||||||
|
correctRate: number;
|
||||||
|
}[];
|
||||||
|
rankingData: {
|
||||||
|
schoolRanking: { name: string; avgScore: number }[];
|
||||||
|
gradeRanking: { name: string; avgScore: number }[];
|
||||||
|
classRanking: { name: string; avgScore: number }[];
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取答题记录列表
|
||||||
|
*/
|
||||||
|
export const getRecordList = (params?: RecordQueryParams) => {
|
||||||
|
return request.Get<RecordListResponse>('/admin/records', {
|
||||||
|
params
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取答题记录详情
|
||||||
|
*/
|
||||||
|
export const getRecordDetail = (id: string) => {
|
||||||
|
return request.Get<AnswerRecord>(`/admin/records/${id}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取答题统计数据
|
||||||
|
*/
|
||||||
|
export const getAnswerStatistics = (params?: {
|
||||||
|
startTime?: string;
|
||||||
|
endTime?: string;
|
||||||
|
schoolName?: string;
|
||||||
|
gradeName?: string;
|
||||||
|
}) => {
|
||||||
|
return request.Get<AnswerStatistics>('/admin/records/statistics', {
|
||||||
|
params
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 导出答题记录
|
||||||
|
*/
|
||||||
|
export const exportRecords = (params?: RecordQueryParams) => {
|
||||||
|
return request.Get('/admin/records/export', {
|
||||||
|
params,
|
||||||
|
responseType: 'blob'
|
||||||
|
});
|
||||||
|
};
|
||||||
237
src/apis/schools.ts
Normal file
237
src/apis/schools.ts
Normal file
@@ -0,0 +1,237 @@
|
|||||||
|
import { request } from "@/utils/request";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 学校数据
|
||||||
|
*/
|
||||||
|
export interface School {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
address?: string;
|
||||||
|
principal?: string;
|
||||||
|
phone?: string;
|
||||||
|
district: string; // 区县
|
||||||
|
type: 'primary' | 'junior' | 'senior' | 'vocational'; // 学校类型
|
||||||
|
studentCount?: number;
|
||||||
|
grades?: Grade[];
|
||||||
|
createTime: string;
|
||||||
|
updateTime: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 年级数据
|
||||||
|
*/
|
||||||
|
export interface Grade {
|
||||||
|
id: string;
|
||||||
|
schoolId: string;
|
||||||
|
name: string;
|
||||||
|
level: number; // 年级层级:1-6年级,7-9初中,10-12高中
|
||||||
|
classes?: Class[];
|
||||||
|
createTime: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 班级数据
|
||||||
|
*/
|
||||||
|
export interface Class {
|
||||||
|
id: string;
|
||||||
|
schoolId: string;
|
||||||
|
gradeId: string;
|
||||||
|
name: string;
|
||||||
|
teacherName?: string;
|
||||||
|
studentCount?: number;
|
||||||
|
createTime: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 学校列表查询参数
|
||||||
|
*/
|
||||||
|
export interface SchoolQueryParams {
|
||||||
|
page?: number;
|
||||||
|
pageSize?: number;
|
||||||
|
keyword?: string;
|
||||||
|
district?: string;
|
||||||
|
type?: 'primary' | 'junior' | 'senior' | 'vocational';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 学校列表响应
|
||||||
|
*/
|
||||||
|
export interface SchoolListResponse {
|
||||||
|
list: School[];
|
||||||
|
total: number;
|
||||||
|
page: number;
|
||||||
|
pageSize: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建学校参数
|
||||||
|
*/
|
||||||
|
export interface CreateSchoolParams {
|
||||||
|
name: string;
|
||||||
|
address?: string;
|
||||||
|
principal?: string;
|
||||||
|
phone?: string;
|
||||||
|
district: string;
|
||||||
|
type: 'primary' | 'junior' | 'senior' | 'vocational';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建年级参数
|
||||||
|
*/
|
||||||
|
export interface CreateGradeParams {
|
||||||
|
schoolId: string;
|
||||||
|
name: string;
|
||||||
|
level: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建班级参数
|
||||||
|
*/
|
||||||
|
export interface CreateClassParams {
|
||||||
|
schoolId: string;
|
||||||
|
gradeId: string;
|
||||||
|
name: string;
|
||||||
|
teacherName?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 批量导入学校数据参数
|
||||||
|
*/
|
||||||
|
export interface ImportSchoolData {
|
||||||
|
schools: {
|
||||||
|
name: string;
|
||||||
|
district: string;
|
||||||
|
type: 'primary' | 'junior' | 'senior' | 'vocational';
|
||||||
|
grades: {
|
||||||
|
name: string;
|
||||||
|
level: number;
|
||||||
|
classes: {
|
||||||
|
name: string;
|
||||||
|
teacherName?: string;
|
||||||
|
}[];
|
||||||
|
}[];
|
||||||
|
}[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 学校相关接口
|
||||||
|
/**
|
||||||
|
* 获取学校列表
|
||||||
|
*/
|
||||||
|
export const getSchoolList = (params?: SchoolQueryParams) => {
|
||||||
|
return request.Get<SchoolListResponse>('/admin/schools', {
|
||||||
|
params
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取学校详情(包含年级班级)
|
||||||
|
*/
|
||||||
|
export const getSchoolDetail = (id: string) => {
|
||||||
|
return request.Get<School>(`/admin/schools/${id}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建学校
|
||||||
|
*/
|
||||||
|
export const createSchool = (params: CreateSchoolParams) => {
|
||||||
|
return request.Post<{ message: string; id: string }>('/admin/schools', params);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新学校
|
||||||
|
*/
|
||||||
|
export const updateSchool = (id: string, params: CreateSchoolParams) => {
|
||||||
|
return request.Put<{ message: string }>(`/admin/schools/${id}`, params);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除学校
|
||||||
|
*/
|
||||||
|
export const deleteSchool = (id: string) => {
|
||||||
|
return request.Delete<{ message: string }>(`/admin/schools/${id}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 年级相关接口
|
||||||
|
/**
|
||||||
|
* 获取年级列表
|
||||||
|
*/
|
||||||
|
export const getGradeList = (schoolId: string) => {
|
||||||
|
return request.Get<Grade[]>(`/admin/schools/${schoolId}/grades`);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建年级
|
||||||
|
*/
|
||||||
|
export const createGrade = (params: CreateGradeParams) => {
|
||||||
|
return request.Post<{ message: string; id: string }>('/admin/grades', params);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新年级
|
||||||
|
*/
|
||||||
|
export const updateGrade = (id: string, params: CreateGradeParams) => {
|
||||||
|
return request.Put<{ message: string }>(`/admin/grades/${id}`, params);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除年级
|
||||||
|
*/
|
||||||
|
export const deleteGrade = (id: string) => {
|
||||||
|
return request.Delete<{ message: string }>(`/admin/grades/${id}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 班级相关接口
|
||||||
|
/**
|
||||||
|
* 获取班级列表
|
||||||
|
*/
|
||||||
|
export const getClassList = (gradeId: string) => {
|
||||||
|
return request.Get<Class[]>(`/admin/grades/${gradeId}/classes`);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建班级
|
||||||
|
*/
|
||||||
|
export const createClass = (params: CreateClassParams) => {
|
||||||
|
return request.Post<{ message: string; id: string }>('/admin/classes', params);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新班级
|
||||||
|
*/
|
||||||
|
export const updateClass = (id: string, params: CreateClassParams) => {
|
||||||
|
return request.Put<{ message: string }>(`/admin/classes/${id}`, params);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除班级
|
||||||
|
*/
|
||||||
|
export const deleteClass = (id: string) => {
|
||||||
|
return request.Delete<{ message: string }>(`/admin/classes/${id}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 批量操作接口
|
||||||
|
/**
|
||||||
|
* 批量导入学校数据
|
||||||
|
*/
|
||||||
|
export const importSchoolData = (file: File) => {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('file', file);
|
||||||
|
|
||||||
|
return request.Post<{ message: string; successCount: number; failCount: number }>('/admin/schools/import', formData);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 导出学校数据模板
|
||||||
|
*/
|
||||||
|
export const exportSchoolTemplate = () => {
|
||||||
|
return request.Get('/admin/schools/template', {
|
||||||
|
responseType: 'blob'
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取区县列表
|
||||||
|
*/
|
||||||
|
export const getDistrictList = () => {
|
||||||
|
return request.Get<{ name: string; count: number }[]>('/admin/schools/districts');
|
||||||
|
};
|
||||||
85
src/apis/users.ts
Normal file
85
src/apis/users.ts
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
import { request } from "@/utils/request";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 小程序用户数据
|
||||||
|
*/
|
||||||
|
export interface AppUser {
|
||||||
|
id: string;
|
||||||
|
openid: string;
|
||||||
|
phone: string;
|
||||||
|
avatar?: string;
|
||||||
|
nickname?: string;
|
||||||
|
studentId?: string;
|
||||||
|
studentName?: string;
|
||||||
|
schoolId: string;
|
||||||
|
schoolName: string;
|
||||||
|
gradeId: string;
|
||||||
|
gradeName: string;
|
||||||
|
classId: string;
|
||||||
|
className: string;
|
||||||
|
studentSeatNumber?: number;
|
||||||
|
totalScore: number;
|
||||||
|
answerCount: number;
|
||||||
|
lastAnswerTime?: string;
|
||||||
|
createTime: string;
|
||||||
|
updateTime: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 用户列表查询参数
|
||||||
|
*/
|
||||||
|
export interface UserQueryParams {
|
||||||
|
page?: number;
|
||||||
|
pageSize?: number;
|
||||||
|
keyword?: string; // 学生姓名或家长手机号
|
||||||
|
schoolId?: string;
|
||||||
|
gradeId?: string;
|
||||||
|
classId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 用户列表响应
|
||||||
|
*/
|
||||||
|
export interface UserListResponse {
|
||||||
|
list: AppUser[];
|
||||||
|
total: number;
|
||||||
|
page: number;
|
||||||
|
pageSize: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取用户列表
|
||||||
|
*/
|
||||||
|
export const getUserList = (params?: UserQueryParams) => {
|
||||||
|
return request.Get<UserListResponse>('/admin/users', {
|
||||||
|
params
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取用户详情
|
||||||
|
*/
|
||||||
|
export const getUserDetail = (id: string) => {
|
||||||
|
return request.Get<AppUser>(`/admin/users/${id}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解绑家长与学生关系
|
||||||
|
*/
|
||||||
|
export const unbindParentStudent = (userId: string) => {
|
||||||
|
return request.Put<{ message: string }>(`/admin/users/${userId}/unbind`);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 禁用用户
|
||||||
|
*/
|
||||||
|
export const disableUser = (userId: string) => {
|
||||||
|
return request.Put<{ message: string }>(`/admin/users/${userId}/disable`);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 启用用户
|
||||||
|
*/
|
||||||
|
export const enableUser = (userId: string) => {
|
||||||
|
return request.Put<{ message: string }>(`/admin/users/${userId}/enable`);
|
||||||
|
};
|
||||||
1
src/assets/styles/index.scss
Normal file
1
src/assets/styles/index.scss
Normal file
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
1
src/assets/vue.svg
Normal file
1
src/assets/vue.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="37.07" height="36" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 198"><path fill="#41B883" d="M204.8 0H256L128 220.8L0 0h97.92L128 51.2L157.44 0h47.36Z"></path><path fill="#41B883" d="m0 0l128 220.8L256 0h-51.2L128 132.48L50.56 0H0Z"></path><path fill="#35495E" d="M50.56 0L128 133.12L204.8 0h-47.36L128 51.2L97.92 0H50.56Z"></path></svg>
|
||||||
|
After Width: | Height: | Size: 496 B |
334
src/components/RichEditor.vue
Normal file
334
src/components/RichEditor.vue
Normal file
@@ -0,0 +1,334 @@
|
|||||||
|
<template>
|
||||||
|
<div class="rich-editor">
|
||||||
|
<div style="border: 1px solid #ccc">
|
||||||
|
<Toolbar
|
||||||
|
style="border-bottom: 1px solid #ccc"
|
||||||
|
:editor="editorRef"
|
||||||
|
:defaultConfig="toolbarConfig"
|
||||||
|
:mode="mode"
|
||||||
|
/>
|
||||||
|
<Editor
|
||||||
|
:style="`height: ${height}px; overflow-y: hidden;`"
|
||||||
|
v-model="valueHtml"
|
||||||
|
:defaultConfig="editorConfig"
|
||||||
|
:mode="mode"
|
||||||
|
@onCreated="handleCreated"
|
||||||
|
@onChange="handleChange"
|
||||||
|
@onDestroyed="handleDestroyed"
|
||||||
|
@onFocus="handleFocus"
|
||||||
|
@onBlur="handleBlur"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import '@wangeditor/editor/dist/css/style.css' // 引入 css
|
||||||
|
|
||||||
|
import { onBeforeUnmount, ref, shallowRef, watch, computed } from 'vue'
|
||||||
|
import { Editor, Toolbar } from '@wangeditor/editor-for-vue'
|
||||||
|
import type { IDomEditor, IEditorConfig, IToolbarConfig } from '@wangeditor/editor'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
modelValue?: string
|
||||||
|
height?: number
|
||||||
|
placeholder?: string
|
||||||
|
mode?: 'default' | 'simple'
|
||||||
|
disabled?: boolean
|
||||||
|
maxlength?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Emits {
|
||||||
|
(e: 'update:modelValue', value: string): void
|
||||||
|
(e: 'change', value: string): void
|
||||||
|
(e: 'focus'): void
|
||||||
|
(e: 'blur'): void
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
|
modelValue: '',
|
||||||
|
height: 400,
|
||||||
|
placeholder: '请输入内容...',
|
||||||
|
mode: 'default',
|
||||||
|
disabled: false,
|
||||||
|
maxlength: 5000
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits<Emits>()
|
||||||
|
|
||||||
|
// 编辑器实例,必须用 shallowRef
|
||||||
|
const editorRef = shallowRef<IDomEditor>()
|
||||||
|
|
||||||
|
// 内容 HTML
|
||||||
|
const valueHtml = ref(props.modelValue)
|
||||||
|
|
||||||
|
// 工具栏配置
|
||||||
|
const toolbarConfig: Partial<IToolbarConfig> = {
|
||||||
|
toolbarKeys: [
|
||||||
|
// 菜单 key
|
||||||
|
'headerSelect',
|
||||||
|
'blockquote',
|
||||||
|
'|',
|
||||||
|
'bold',
|
||||||
|
'italic',
|
||||||
|
'underline',
|
||||||
|
'through',
|
||||||
|
'code',
|
||||||
|
'sup',
|
||||||
|
'sub',
|
||||||
|
'clearStyle',
|
||||||
|
'|',
|
||||||
|
'color',
|
||||||
|
'bgColor',
|
||||||
|
'|',
|
||||||
|
'fontSize',
|
||||||
|
'fontFamily',
|
||||||
|
'lineHeight',
|
||||||
|
'|',
|
||||||
|
'bulletedList',
|
||||||
|
'numberedList',
|
||||||
|
'todo',
|
||||||
|
{
|
||||||
|
key: 'group-justify',
|
||||||
|
title: '对齐',
|
||||||
|
iconSvg: '<svg viewBox="0 0 1024 1024"><path d="M768 793.6v102.4H51.2v-102.4h716.8z m204.8-230.4v102.4H51.2v-102.4h921.6z m-204.8-230.4v102.4H51.2v-102.4h716.8z m204.8-230.4v102.4H51.2v-102.4h921.6z"></path></svg>',
|
||||||
|
menuKeys: ['justifyLeft', 'justifyRight', 'justifyCenter', 'justifyJustify']
|
||||||
|
},
|
||||||
|
'|',
|
||||||
|
'emotion',
|
||||||
|
'insertLink',
|
||||||
|
'insertTable',
|
||||||
|
'codeBlock',
|
||||||
|
'divider',
|
||||||
|
'|',
|
||||||
|
'undo',
|
||||||
|
'redo',
|
||||||
|
'|',
|
||||||
|
'fullScreen'
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
// 编辑器配置
|
||||||
|
const editorConfig: Partial<IEditorConfig> = computed(() => ({
|
||||||
|
placeholder: props.placeholder,
|
||||||
|
readOnly: props.disabled,
|
||||||
|
maxLength: props.maxlength,
|
||||||
|
// 配置上传图片
|
||||||
|
MENU_CONF: {
|
||||||
|
// 配置上传图片
|
||||||
|
uploadImage: {
|
||||||
|
server: '/api/upload/image',
|
||||||
|
fieldName: 'file',
|
||||||
|
maxFileSize: 2 * 1024 * 1024, // 2M
|
||||||
|
allowedFileTypes: ['image/*'],
|
||||||
|
headers: {
|
||||||
|
// Authorization: 'Bearer ' + getToken()
|
||||||
|
},
|
||||||
|
// 上传错误的回调函数
|
||||||
|
onError(file: File, err: any, res: any) {
|
||||||
|
console.error(`${file.name} 上传出错`, err, res)
|
||||||
|
},
|
||||||
|
// 上传成功的回调函数
|
||||||
|
onSuccess(file: File, res: any) {
|
||||||
|
console.log(`${file.name} 上传成功`, res)
|
||||||
|
},
|
||||||
|
// 上传进度的回调函数
|
||||||
|
onProgress(file: File, progress: number) {
|
||||||
|
console.log(`${file.name} 上传进度 ${progress}%`)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
|
||||||
|
// 监听外部值变化
|
||||||
|
watch(() => props.modelValue, (newValue) => {
|
||||||
|
if (newValue !== valueHtml.value) {
|
||||||
|
valueHtml.value = newValue || ''
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 监听编辑器内容变化
|
||||||
|
watch(valueHtml, (newValue) => {
|
||||||
|
emit('update:modelValue', newValue)
|
||||||
|
})
|
||||||
|
|
||||||
|
// 组件销毁时,也及时销毁编辑器
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
const editor = editorRef.value
|
||||||
|
if (editor == null) return
|
||||||
|
editor.destroy()
|
||||||
|
})
|
||||||
|
|
||||||
|
const handleCreated = (editor: IDomEditor) => {
|
||||||
|
editorRef.value = editor // 记录 editor 实例,重要!
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleChange = (editor: IDomEditor) => {
|
||||||
|
const html = editor.getHtml()
|
||||||
|
valueHtml.value = html
|
||||||
|
emit('change', html)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDestroyed = (editor: IDomEditor) => {
|
||||||
|
console.log('destroyed', editor)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleFocus = (editor: IDomEditor) => {
|
||||||
|
emit('focus')
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleBlur = (editor: IDomEditor) => {
|
||||||
|
emit('blur')
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取编辑器实例(供父组件调用)
|
||||||
|
const getEditor = () => {
|
||||||
|
return editorRef.value
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取文本内容(去除HTML标签)
|
||||||
|
const getText = () => {
|
||||||
|
const editor = editorRef.value
|
||||||
|
if (editor == null) return ''
|
||||||
|
return editor.getText()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取HTML内容
|
||||||
|
const getHtml = () => {
|
||||||
|
const editor = editorRef.value
|
||||||
|
if (editor == null) return ''
|
||||||
|
return editor.getHtml()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 设置HTML内容
|
||||||
|
const setHtml = (html: string) => {
|
||||||
|
const editor = editorRef.value
|
||||||
|
if (editor == null) return
|
||||||
|
editor.setHtml(html)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 插入文本
|
||||||
|
const insertText = (text: string) => {
|
||||||
|
const editor = editorRef.value
|
||||||
|
if (editor == null) return
|
||||||
|
editor.insertText(text)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 暴露方法给父组件
|
||||||
|
defineExpose({
|
||||||
|
getEditor,
|
||||||
|
getText,
|
||||||
|
getHtml,
|
||||||
|
setHtml,
|
||||||
|
insertText
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.rich-editor {
|
||||||
|
:deep(.w-e-toolbar) {
|
||||||
|
background-color: #fafafa;
|
||||||
|
border-color: #d9d9d9;
|
||||||
|
|
||||||
|
.w-e-toolbar-item {
|
||||||
|
&:hover {
|
||||||
|
background-color: #e6f7ff;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.w-e-text-container) {
|
||||||
|
background-color: #fff;
|
||||||
|
border-color: #d9d9d9;
|
||||||
|
|
||||||
|
.w-e-text-placeholder {
|
||||||
|
color: #bfbfbf;
|
||||||
|
}
|
||||||
|
|
||||||
|
.w-e-text {
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.6;
|
||||||
|
color: #333;
|
||||||
|
|
||||||
|
p {
|
||||||
|
margin: 8px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1, h2, h3, h4, h5, h6 {
|
||||||
|
margin: 16px 0 8px 0;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
blockquote {
|
||||||
|
border-left: 4px solid #1890ff;
|
||||||
|
background-color: #f0f9ff;
|
||||||
|
padding: 8px 16px;
|
||||||
|
margin: 16px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
code {
|
||||||
|
background-color: #f5f5f5;
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 3px;
|
||||||
|
font-family: Consolas, Monaco, 'Courier New', monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
pre {
|
||||||
|
background-color: #f5f5f5;
|
||||||
|
padding: 16px;
|
||||||
|
border-radius: 6px;
|
||||||
|
overflow-x: auto;
|
||||||
|
|
||||||
|
code {
|
||||||
|
background: none;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
table {
|
||||||
|
border-collapse: collapse;
|
||||||
|
width: 100%;
|
||||||
|
margin: 16px 0;
|
||||||
|
|
||||||
|
th, td {
|
||||||
|
border: 1px solid #d9d9d9;
|
||||||
|
padding: 8px 12px;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
th {
|
||||||
|
background-color: #fafafa;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
img {
|
||||||
|
max-width: 100%;
|
||||||
|
height: auto;
|
||||||
|
border-radius: 4px;
|
||||||
|
margin: 8px 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 禁用状态样式
|
||||||
|
&.disabled {
|
||||||
|
:deep(.w-e-toolbar) {
|
||||||
|
background-color: #f5f5f5;
|
||||||
|
pointer-events: none;
|
||||||
|
|
||||||
|
.w-e-toolbar-item {
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.w-e-text-container) {
|
||||||
|
background-color: #f5f5f5;
|
||||||
|
|
||||||
|
.w-e-text {
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
177
src/components/layout/AdminHeader.vue
Normal file
177
src/components/layout/AdminHeader.vue
Normal file
@@ -0,0 +1,177 @@
|
|||||||
|
<template>
|
||||||
|
<div class="admin-header">
|
||||||
|
<!-- 左侧:折叠按钮 -->
|
||||||
|
<div class="header-left">
|
||||||
|
<a-button
|
||||||
|
type="text"
|
||||||
|
@click="$emit('toggle')"
|
||||||
|
class="collapse-btn"
|
||||||
|
>
|
||||||
|
<MenuUnfoldOutlined v-if="collapsed" />
|
||||||
|
<MenuFoldOutlined v-else />
|
||||||
|
</a-button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 右侧:用户信息和操作 -->
|
||||||
|
<div class="header-right">
|
||||||
|
<!-- 用户信息下拉菜单 -->
|
||||||
|
<a-dropdown placement="bottomRight">
|
||||||
|
<div class="user-info">
|
||||||
|
<a-avatar :src="userInfo?.avatar" :size="32">
|
||||||
|
<template #icon>
|
||||||
|
<UserOutlined />
|
||||||
|
</template>
|
||||||
|
</a-avatar>
|
||||||
|
<span class="user-name">{{ userInfo?.nickname || '管理员' }}</span>
|
||||||
|
<DownOutlined class="dropdown-icon" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template #overlay>
|
||||||
|
<a-menu @click="handleMenuClick">
|
||||||
|
<a-menu-item key="profile">
|
||||||
|
<UserOutlined />
|
||||||
|
<span>个人信息</span>
|
||||||
|
</a-menu-item>
|
||||||
|
<a-menu-item key="password">
|
||||||
|
<LockOutlined />
|
||||||
|
<span>修改密码</span>
|
||||||
|
</a-menu-item>
|
||||||
|
<a-menu-divider />
|
||||||
|
<a-menu-item key="logout">
|
||||||
|
<LogoutOutlined />
|
||||||
|
<span>退出登录</span>
|
||||||
|
</a-menu-item>
|
||||||
|
</a-menu>
|
||||||
|
</template>
|
||||||
|
</a-dropdown>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue';
|
||||||
|
import { useRouter } from 'vue-router';
|
||||||
|
import { Modal } from 'ant-design-vue';
|
||||||
|
import {
|
||||||
|
MenuFoldOutlined,
|
||||||
|
MenuUnfoldOutlined,
|
||||||
|
UserOutlined,
|
||||||
|
DownOutlined,
|
||||||
|
LockOutlined,
|
||||||
|
LogoutOutlined
|
||||||
|
} from '@ant-design/icons-vue';
|
||||||
|
import { useAuthStore } from '@/stores/auth';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
collapsed: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
defineProps<Props>();
|
||||||
|
|
||||||
|
// 定义事件
|
||||||
|
defineEmits<{
|
||||||
|
toggle: [];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
const authStore = useAuthStore();
|
||||||
|
|
||||||
|
// 用户信息
|
||||||
|
const userInfo = computed(() => authStore.userInfo);
|
||||||
|
|
||||||
|
// 菜单点击处理
|
||||||
|
const handleMenuClick = ({ key }: { key: string }) => {
|
||||||
|
switch (key) {
|
||||||
|
case 'profile':
|
||||||
|
router.push('/admin/profile');
|
||||||
|
break;
|
||||||
|
case 'password':
|
||||||
|
router.push('/admin/password');
|
||||||
|
break;
|
||||||
|
case 'logout':
|
||||||
|
handleLogout();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 退出登录
|
||||||
|
const handleLogout = () => {
|
||||||
|
Modal.confirm({
|
||||||
|
title: '确认退出',
|
||||||
|
content: '您确定要退出登录吗?',
|
||||||
|
okText: '确定',
|
||||||
|
cancelText: '取消',
|
||||||
|
onOk() {
|
||||||
|
authStore.clearAuth();
|
||||||
|
router.push('/login');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.admin-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 0 24px;
|
||||||
|
height: 64px;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-left {
|
||||||
|
.collapse-btn {
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 18px;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: rgba(0, 0, 0, 0.04);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-right {
|
||||||
|
.user-info {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color 0.2s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: rgba(0, 0, 0, 0.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-name {
|
||||||
|
margin: 0 8px;
|
||||||
|
font-size: 14px;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown-icon {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #999;
|
||||||
|
transition: transform 0.2s;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.ant-dropdown-menu) {
|
||||||
|
min-width: 140px;
|
||||||
|
|
||||||
|
.ant-menu-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 8px 16px;
|
||||||
|
|
||||||
|
.anticon {
|
||||||
|
margin-right: 8px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
130
src/components/layout/AdminLayout.vue
Normal file
130
src/components/layout/AdminLayout.vue
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
<template>
|
||||||
|
<div class="admin-layout">
|
||||||
|
<!-- 固定侧边栏 -->
|
||||||
|
<div
|
||||||
|
class="sidebar"
|
||||||
|
:class="{ 'sidebar-collapsed': collapsed }"
|
||||||
|
>
|
||||||
|
<div class="logo">
|
||||||
|
<img src="/vite.svg" alt="朱子管理后台" />
|
||||||
|
<span v-if="!collapsed">朱子管理后台</span>
|
||||||
|
</div>
|
||||||
|
<AdminSidebar :collapsed="collapsed" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 主内容区域 -->
|
||||||
|
<div
|
||||||
|
class="main-content"
|
||||||
|
:class="{ 'main-content-collapsed': collapsed }"
|
||||||
|
>
|
||||||
|
<!-- 顶部导航 -->
|
||||||
|
<div class="layout-header">
|
||||||
|
<AdminHeader :collapsed="collapsed" @toggle="toggleCollapsed" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 内容区域 -->
|
||||||
|
<div class="layout-content">
|
||||||
|
<div class="content-wrapper">
|
||||||
|
<router-view />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue';
|
||||||
|
import AdminSidebar from './AdminSidebar.vue';
|
||||||
|
import AdminHeader from './AdminHeader.vue';
|
||||||
|
|
||||||
|
// 侧边栏折叠状态
|
||||||
|
const collapsed = ref(false);
|
||||||
|
|
||||||
|
// 切换侧边栏折叠状态
|
||||||
|
const toggleCollapsed = () => {
|
||||||
|
collapsed.value = !collapsed.value;
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
// 全局容器
|
||||||
|
.admin-layout {
|
||||||
|
height: 100vh;
|
||||||
|
overflow: hidden;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 固定侧边栏
|
||||||
|
.sidebar {
|
||||||
|
position: fixed;
|
||||||
|
left: 0;
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
width: 240px;
|
||||||
|
background: #001529;
|
||||||
|
z-index: 1000;
|
||||||
|
overflow-y: auto;
|
||||||
|
transition: width 0.2s;
|
||||||
|
|
||||||
|
&.sidebar-collapsed {
|
||||||
|
width: 80px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 主内容区域
|
||||||
|
.main-content {
|
||||||
|
margin-left: 240px;
|
||||||
|
height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
transition: margin-left 0.2s;
|
||||||
|
|
||||||
|
&.main-content-collapsed {
|
||||||
|
margin-left: 80px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Logo区域
|
||||||
|
.logo {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 16px 24px;
|
||||||
|
color: white;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: bold;
|
||||||
|
border-bottom: 1px solid #1a1a1a;
|
||||||
|
|
||||||
|
img {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
margin-right: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
span {
|
||||||
|
white-space: nowrap;
|
||||||
|
transition: opacity 0.2s;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 顶部导航
|
||||||
|
.layout-header {
|
||||||
|
background: #fff;
|
||||||
|
padding: 0;
|
||||||
|
box-shadow: 0 1px 4px rgba(0, 21, 41, 0.08);
|
||||||
|
height: 64px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 内容区域
|
||||||
|
.layout-content {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
background: #f0f2f5;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 内容包装器
|
||||||
|
.content-wrapper {
|
||||||
|
padding: 16px 24px;
|
||||||
|
min-height: 100%;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
191
src/components/layout/AdminSidebar.vue
Normal file
191
src/components/layout/AdminSidebar.vue
Normal file
@@ -0,0 +1,191 @@
|
|||||||
|
<template>
|
||||||
|
<a-menu
|
||||||
|
v-model:selectedKeys="selectedKeys"
|
||||||
|
v-model:openKeys="openKeys"
|
||||||
|
mode="inline"
|
||||||
|
theme="dark"
|
||||||
|
:inline-collapsed="collapsed"
|
||||||
|
@click="handleMenuClick"
|
||||||
|
>
|
||||||
|
<!-- 题库管理 -->
|
||||||
|
<a-menu-item key="questions">
|
||||||
|
<template #icon>
|
||||||
|
<FileTextOutlined />
|
||||||
|
</template>
|
||||||
|
<span>题库管理</span>
|
||||||
|
</a-menu-item>
|
||||||
|
|
||||||
|
<!-- 轮播图管理 -->
|
||||||
|
<a-menu-item key="banners">
|
||||||
|
<template #icon>
|
||||||
|
<PictureOutlined />
|
||||||
|
</template>
|
||||||
|
<span>轮播图管理</span>
|
||||||
|
</a-menu-item>
|
||||||
|
|
||||||
|
<!-- 答题记录 -->
|
||||||
|
<a-menu-item key="records">
|
||||||
|
<template #icon>
|
||||||
|
<BarChartOutlined />
|
||||||
|
</template>
|
||||||
|
<span>答题记录</span>
|
||||||
|
</a-menu-item>
|
||||||
|
|
||||||
|
<!-- 用户管理 -->
|
||||||
|
<a-menu-item key="users">
|
||||||
|
<template #icon>
|
||||||
|
<UserOutlined />
|
||||||
|
</template>
|
||||||
|
<span>用户管理</span>
|
||||||
|
</a-menu-item>
|
||||||
|
|
||||||
|
<!-- 学校管理 -->
|
||||||
|
<a-sub-menu key="school">
|
||||||
|
<template #icon>
|
||||||
|
<BankOutlined />
|
||||||
|
</template>
|
||||||
|
<template #title>学校管理</template>
|
||||||
|
<a-menu-item key="schools">学校列表</a-menu-item>
|
||||||
|
<a-menu-item key="grades">年级管理</a-menu-item>
|
||||||
|
<a-menu-item key="classes">班级管理</a-menu-item>
|
||||||
|
</a-sub-menu>
|
||||||
|
|
||||||
|
<!-- 系统设置 -->
|
||||||
|
<a-sub-menu key="system">
|
||||||
|
<template #icon>
|
||||||
|
<SettingOutlined />
|
||||||
|
</template>
|
||||||
|
<template #title>系统设置</template>
|
||||||
|
<a-menu-item key="profile">个人信息</a-menu-item>
|
||||||
|
<a-menu-item key="password">修改密码</a-menu-item>
|
||||||
|
</a-sub-menu>
|
||||||
|
</a-menu>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, watch } from 'vue';
|
||||||
|
import { useRouter, useRoute } from 'vue-router';
|
||||||
|
import {
|
||||||
|
FileTextOutlined,
|
||||||
|
PictureOutlined,
|
||||||
|
BarChartOutlined,
|
||||||
|
UserOutlined,
|
||||||
|
BankOutlined,
|
||||||
|
SettingOutlined
|
||||||
|
} from '@ant-design/icons-vue';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
collapsed: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<Props>();
|
||||||
|
const router = useRouter();
|
||||||
|
const route = useRoute();
|
||||||
|
|
||||||
|
// 选中的菜单项
|
||||||
|
const selectedKeys = ref<string[]>([]);
|
||||||
|
// 展开的菜单项
|
||||||
|
const openKeys = ref<string[]>(['school']);
|
||||||
|
|
||||||
|
// 根据当前路由设置选中的菜单
|
||||||
|
const updateSelectedKeys = () => {
|
||||||
|
const path = route.path;
|
||||||
|
if (path.includes('/questions')) {
|
||||||
|
selectedKeys.value = ['questions'];
|
||||||
|
} else if (path.includes('/banners')) {
|
||||||
|
selectedKeys.value = ['banners'];
|
||||||
|
} else if (path.includes('/records')) {
|
||||||
|
selectedKeys.value = ['records'];
|
||||||
|
} else if (path.includes('/users')) {
|
||||||
|
selectedKeys.value = ['users'];
|
||||||
|
} else if (path.includes('/schools')) {
|
||||||
|
selectedKeys.value = ['schools'];
|
||||||
|
openKeys.value = ['school'];
|
||||||
|
} else if (path.includes('/grades')) {
|
||||||
|
selectedKeys.value = ['grades'];
|
||||||
|
openKeys.value = ['school'];
|
||||||
|
} else if (path.includes('/classes')) {
|
||||||
|
selectedKeys.value = ['classes'];
|
||||||
|
openKeys.value = ['school'];
|
||||||
|
} else if (path.includes('/profile')) {
|
||||||
|
selectedKeys.value = ['profile'];
|
||||||
|
openKeys.value = ['system'];
|
||||||
|
} else if (path.includes('/password')) {
|
||||||
|
selectedKeys.value = ['password'];
|
||||||
|
openKeys.value = ['system'];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 监听路由变化
|
||||||
|
watch(
|
||||||
|
() => route.path,
|
||||||
|
() => {
|
||||||
|
updateSelectedKeys();
|
||||||
|
},
|
||||||
|
{ immediate: true }
|
||||||
|
);
|
||||||
|
|
||||||
|
// 菜单点击事件
|
||||||
|
const handleMenuClick = ({ key }: { key: string }) => {
|
||||||
|
const routeMap: Record<string, string> = {
|
||||||
|
questions: '/admin/questions',
|
||||||
|
banners: '/admin/banners',
|
||||||
|
records: '/admin/records',
|
||||||
|
users: '/admin/users',
|
||||||
|
schools: '/admin/schools',
|
||||||
|
grades: '/admin/grades',
|
||||||
|
classes: '/admin/classes',
|
||||||
|
profile: '/admin/profile',
|
||||||
|
password: '/admin/password'
|
||||||
|
};
|
||||||
|
|
||||||
|
if (routeMap[key]) {
|
||||||
|
router.push(routeMap[key]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 侧边栏折叠时只保留顶级菜单展开
|
||||||
|
watch(
|
||||||
|
() => props.collapsed,
|
||||||
|
(collapsed) => {
|
||||||
|
if (collapsed) {
|
||||||
|
openKeys.value = [];
|
||||||
|
} else {
|
||||||
|
// 根据当前选中项设置展开状态
|
||||||
|
if (['schools', 'grades', 'classes'].includes(selectedKeys.value[0])) {
|
||||||
|
openKeys.value = ['school'];
|
||||||
|
} else if (['profile', 'password'].includes(selectedKeys.value[0])) {
|
||||||
|
openKeys.value = ['system'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
:deep(.ant-menu-dark) {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.ant-menu-item),
|
||||||
|
:deep(.ant-menu-submenu-title) {
|
||||||
|
height: 48px;
|
||||||
|
line-height: 48px;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0 24px !important;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.06);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.ant-menu-item-selected) {
|
||||||
|
background: #1890ff !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.ant-menu-submenu-selected) {
|
||||||
|
.ant-menu-submenu-title {
|
||||||
|
color: #1890ff;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
13
src/main.ts
Normal file
13
src/main.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import {createApp} from 'vue'
|
||||||
|
import App from './App.vue'
|
||||||
|
|
||||||
|
import router from './router';
|
||||||
|
import piniaPluginPersistedstate from 'pinia-plugin-persistedstate'
|
||||||
|
import {createPinia} from "pinia";
|
||||||
|
|
||||||
|
const pinia = createPinia()
|
||||||
|
pinia.use(piniaPluginPersistedstate)
|
||||||
|
const app = createApp(App);
|
||||||
|
app.use(pinia)
|
||||||
|
app.use(router);
|
||||||
|
app.mount('#app');
|
||||||
128
src/mocks/auth.mock.ts
Normal file
128
src/mocks/auth.mock.ts
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
import { defineMock } from '@alova/mock';
|
||||||
|
|
||||||
|
// 认证相关接口的mock数据
|
||||||
|
export default defineMock({
|
||||||
|
// 管理员登录
|
||||||
|
'[POST]/admin/auth/login': ({ data }) => {
|
||||||
|
const { username, password } = data;
|
||||||
|
|
||||||
|
// 模拟用户数据
|
||||||
|
const mockAdmins = [
|
||||||
|
{
|
||||||
|
id: '1',
|
||||||
|
username: 'admin',
|
||||||
|
password: '123456',
|
||||||
|
nickname: '系统管理员',
|
||||||
|
avatar: 'https://api.dicebear.com/7.x/avataaars/svg?seed=admin',
|
||||||
|
role: 'admin',
|
||||||
|
createTime: '2023-01-01 00:00:00'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '2',
|
||||||
|
username: 'operator',
|
||||||
|
password: '123456',
|
||||||
|
nickname: '运营人员',
|
||||||
|
avatar: 'https://api.dicebear.com/7.x/avataaars/svg?seed=operator',
|
||||||
|
role: 'operator',
|
||||||
|
createTime: '2023-01-01 00:00:00'
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
const admin = mockAdmins.find(user => user.username === username && user.password === password);
|
||||||
|
|
||||||
|
if (!admin) {
|
||||||
|
return {
|
||||||
|
status: 401,
|
||||||
|
body: {
|
||||||
|
code: 401,
|
||||||
|
message: '用户名或密码错误',
|
||||||
|
data: null
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
code: 200,
|
||||||
|
message: 'success',
|
||||||
|
data: {
|
||||||
|
token: `mock_token_${admin.id}_${Date.now()}`,
|
||||||
|
refreshToken: `mock_refresh_token_${admin.id}_${Date.now()}`,
|
||||||
|
userInfo: {
|
||||||
|
id: admin.id,
|
||||||
|
username: admin.username,
|
||||||
|
nickname: admin.nickname,
|
||||||
|
avatar: admin.avatar,
|
||||||
|
role: admin.role,
|
||||||
|
createTime: admin.createTime
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
// 获取当前用户信息
|
||||||
|
'[GET]/admin/auth/profile': ({ headers }) => {
|
||||||
|
const token = headers.authorization?.replace('Bearer ', '');
|
||||||
|
if (!token) {
|
||||||
|
return {
|
||||||
|
status: 401,
|
||||||
|
body: {
|
||||||
|
code: 401,
|
||||||
|
message: '未提供访问令牌',
|
||||||
|
data: null
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 简单的token验证逻辑
|
||||||
|
const tokenParts = token.split('_');
|
||||||
|
if (tokenParts.length < 3) {
|
||||||
|
return {
|
||||||
|
status: 401,
|
||||||
|
body: {
|
||||||
|
code: 401,
|
||||||
|
message: '访问令牌无效',
|
||||||
|
data: null
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const userId = tokenParts[2];
|
||||||
|
const mockAdmins = [
|
||||||
|
{
|
||||||
|
id: '1',
|
||||||
|
username: 'admin',
|
||||||
|
nickname: '系统管理员',
|
||||||
|
avatar: 'https://api.dicebear.com/7.x/avataaars/svg?seed=admin',
|
||||||
|
role: 'admin',
|
||||||
|
createTime: '2023-01-01 00:00:00'
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
const admin = mockAdmins.find(user => user.id === userId);
|
||||||
|
if (!admin) {
|
||||||
|
return {
|
||||||
|
status: 401,
|
||||||
|
body: {
|
||||||
|
code: 401,
|
||||||
|
message: '访问令牌无效',
|
||||||
|
data: null
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
code: 200,
|
||||||
|
message: 'success',
|
||||||
|
data: admin
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
// 退出登录
|
||||||
|
'[POST]/admin/auth/logout': () => {
|
||||||
|
return {
|
||||||
|
code: 200,
|
||||||
|
message: 'success',
|
||||||
|
data: { message: '退出成功' }
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}, true);
|
||||||
246
src/mocks/banners.mock.ts
Normal file
246
src/mocks/banners.mock.ts
Normal file
@@ -0,0 +1,246 @@
|
|||||||
|
import { defineMock } from '@alova/mock';
|
||||||
|
|
||||||
|
// 模拟轮播图数据
|
||||||
|
const mockBanners = [
|
||||||
|
{
|
||||||
|
id: '1',
|
||||||
|
title: '朱子文化传承千年',
|
||||||
|
image: 'https://picsum.photos/800/400?random=1',
|
||||||
|
linkType: 'article',
|
||||||
|
articleContent: `
|
||||||
|
<h1>朱子文化传承千年</h1>
|
||||||
|
<p>朱子文化作为中华优秀传统文化的重要组成部分,传承至今已有千年历史。朱熹(1130-1200),字元晦、仲晦,号晦庵、晦翁,别号紫阳,谥号"文",世称朱文公。</p>
|
||||||
|
<h2>朱子理学的影响</h2>
|
||||||
|
<p>朱子集南宋前儒学思想之大成,构建了"致广大,尽精微,综罗百代"的理学思想体系,影响了中国社会近千年。</p>
|
||||||
|
<h2>教育贡献</h2>
|
||||||
|
<p>朱子在教育方面的贡献同样卓著,提出了许多具有深远影响的教育理念和方法。</p>
|
||||||
|
`,
|
||||||
|
sort: 1,
|
||||||
|
status: 'enabled',
|
||||||
|
createTime: '2024-01-01 09:00:00',
|
||||||
|
updateTime: '2024-01-01 09:00:00'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '2',
|
||||||
|
title: '学习朱子理学精神',
|
||||||
|
image: 'https://picsum.photos/800/400?random=2',
|
||||||
|
linkType: 'url',
|
||||||
|
linkUrl: 'https://www.zhuzi.org',
|
||||||
|
sort: 2,
|
||||||
|
status: 'enabled',
|
||||||
|
createTime: '2024-01-01 09:30:00',
|
||||||
|
updateTime: '2024-01-01 09:30:00'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '3',
|
||||||
|
title: '朱子书院文化',
|
||||||
|
image: 'https://picsum.photos/800/400?random=3',
|
||||||
|
linkType: 'article',
|
||||||
|
articleContent: `
|
||||||
|
<h1>朱子书院文化</h1>
|
||||||
|
<p>朱子创办的书院成为中国古代教育的重要形式,对后世产生了深远的影响。</p>
|
||||||
|
<h2>白鹿洞书院</h2>
|
||||||
|
<p>朱熹重新修复白鹿洞书院,制定了著名的《白鹿洞书院揭示》。</p>
|
||||||
|
`,
|
||||||
|
sort: 3,
|
||||||
|
status: 'disabled',
|
||||||
|
createTime: '2024-01-01 10:00:00',
|
||||||
|
updateTime: '2024-01-01 10:00:00'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '4',
|
||||||
|
title: '朱子家训精神',
|
||||||
|
image: 'https://picsum.photos/800/400?random=4',
|
||||||
|
linkType: 'article',
|
||||||
|
articleContent: `
|
||||||
|
<h1>朱子家训精神</h1>
|
||||||
|
<p>朱子的家训体现了深厚的儒家文化内涵,对家庭教育有重要指导意义。</p>
|
||||||
|
`,
|
||||||
|
sort: 4,
|
||||||
|
status: 'enabled',
|
||||||
|
createTime: '2024-01-01 10:30:00',
|
||||||
|
updateTime: '2024-01-01 10:30:00'
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
// 轮播图管理接口的mock数据
|
||||||
|
export default defineMock({
|
||||||
|
// 获取轮播图列表
|
||||||
|
'[GET]/admin/banners': ({ query }) => {
|
||||||
|
let filteredBanners = [...mockBanners];
|
||||||
|
|
||||||
|
// 状态筛选
|
||||||
|
if (query.status) {
|
||||||
|
filteredBanners = filteredBanners.filter(b => b.status === query.status);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 关键词搜索
|
||||||
|
if (query.keyword) {
|
||||||
|
filteredBanners = filteredBanners.filter(b =>
|
||||||
|
b.title.includes(query.keyword!)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 按排序字段排序
|
||||||
|
filteredBanners.sort((a, b) => a.sort - b.sort);
|
||||||
|
|
||||||
|
// 分页
|
||||||
|
const page = parseInt(query.page) || 1;
|
||||||
|
const pageSize = parseInt(query.pageSize) || 10;
|
||||||
|
const total = filteredBanners.length;
|
||||||
|
const start = (page - 1) * pageSize;
|
||||||
|
const end = start + pageSize;
|
||||||
|
|
||||||
|
return {
|
||||||
|
code: 200,
|
||||||
|
message: 'success',
|
||||||
|
data: {
|
||||||
|
list: filteredBanners.slice(start, end),
|
||||||
|
total,
|
||||||
|
page,
|
||||||
|
pageSize
|
||||||
|
}
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
// 获取轮播图详情
|
||||||
|
'[GET]/admin/banners/{id}': ({ params }) => {
|
||||||
|
const banner = mockBanners.find(b => b.id === params.id);
|
||||||
|
if (!banner) {
|
||||||
|
return {
|
||||||
|
status: 404,
|
||||||
|
body: {
|
||||||
|
code: 404,
|
||||||
|
message: '轮播图不存在',
|
||||||
|
data: null
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
code: 200,
|
||||||
|
message: 'success',
|
||||||
|
data: banner
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
// 创建轮播图
|
||||||
|
'[POST]/admin/banners': ({ data }) => {
|
||||||
|
const newBanner = {
|
||||||
|
...data,
|
||||||
|
id: (mockBanners.length + 1).toString(),
|
||||||
|
createTime: new Date().toLocaleString('zh-CN'),
|
||||||
|
updateTime: new Date().toLocaleString('zh-CN')
|
||||||
|
};
|
||||||
|
|
||||||
|
mockBanners.push(newBanner);
|
||||||
|
|
||||||
|
return {
|
||||||
|
code: 200,
|
||||||
|
message: 'success',
|
||||||
|
data: {
|
||||||
|
message: '创建成功',
|
||||||
|
id: newBanner.id
|
||||||
|
}
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
// 更新轮播图
|
||||||
|
'[PUT]/admin/banners/{id}': ({ params, data }) => {
|
||||||
|
const index = mockBanners.findIndex(b => b.id === params.id);
|
||||||
|
if (index === -1) {
|
||||||
|
return {
|
||||||
|
status: 404,
|
||||||
|
body: {
|
||||||
|
code: 404,
|
||||||
|
message: '轮播图不存在',
|
||||||
|
data: null
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
mockBanners[index] = {
|
||||||
|
...data,
|
||||||
|
id: params.id,
|
||||||
|
createTime: mockBanners[index].createTime,
|
||||||
|
updateTime: new Date().toLocaleString('zh-CN')
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
code: 200,
|
||||||
|
message: 'success',
|
||||||
|
data: { message: '更新成功' }
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
// 删除轮播图
|
||||||
|
'[DELETE]/admin/banners/{id}': ({ params }) => {
|
||||||
|
const index = mockBanners.findIndex(b => b.id === params.id);
|
||||||
|
if (index === -1) {
|
||||||
|
return {
|
||||||
|
status: 404,
|
||||||
|
body: {
|
||||||
|
code: 404,
|
||||||
|
message: '轮播图不存在',
|
||||||
|
data: null
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
mockBanners.splice(index, 1);
|
||||||
|
|
||||||
|
return {
|
||||||
|
code: 200,
|
||||||
|
message: 'success',
|
||||||
|
data: { message: '删除成功' }
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
// 更新轮播图状态
|
||||||
|
'[PUT]/admin/banners/{id}/status': ({ params, data }) => {
|
||||||
|
const banner = mockBanners.find(b => b.id === params.id);
|
||||||
|
if (!banner) {
|
||||||
|
return {
|
||||||
|
status: 404,
|
||||||
|
body: {
|
||||||
|
code: 404,
|
||||||
|
message: '轮播图不存在',
|
||||||
|
data: null
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
banner.status = data.status;
|
||||||
|
banner.updateTime = new Date().toLocaleString('zh-CN');
|
||||||
|
|
||||||
|
return {
|
||||||
|
code: 200,
|
||||||
|
message: 'success',
|
||||||
|
data: { message: '状态更新成功' }
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
// 更新轮播图排序
|
||||||
|
'[PUT]/admin/banners/{id}/sort': ({ params, data }) => {
|
||||||
|
const banner = mockBanners.find(b => b.id === params.id);
|
||||||
|
if (!banner) {
|
||||||
|
return {
|
||||||
|
status: 404,
|
||||||
|
body: {
|
||||||
|
code: 404,
|
||||||
|
message: '轮播图不存在',
|
||||||
|
data: null
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
banner.sort = data.sort;
|
||||||
|
banner.updateTime = new Date().toLocaleString('zh-CN');
|
||||||
|
|
||||||
|
return {
|
||||||
|
code: 200,
|
||||||
|
message: 'success',
|
||||||
|
data: { message: '排序更新成功' }
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}, true);
|
||||||
336
src/mocks/classes.mock.ts
Normal file
336
src/mocks/classes.mock.ts
Normal file
@@ -0,0 +1,336 @@
|
|||||||
|
import { defineMock } from '@alova/mock';
|
||||||
|
|
||||||
|
// Mock数据
|
||||||
|
const mockClasses = [
|
||||||
|
// 朱熹小学一年级班级
|
||||||
|
{
|
||||||
|
id: '1',
|
||||||
|
name: '一年级1班',
|
||||||
|
code: 'CLASS_1_1',
|
||||||
|
schoolId: '1',
|
||||||
|
schoolName: '朱熹小学',
|
||||||
|
gradeId: '1',
|
||||||
|
gradeName: '一年级',
|
||||||
|
teacherName: '张老师',
|
||||||
|
teacherPhone: '13800138001',
|
||||||
|
classroom: '教学楼A101',
|
||||||
|
sort: 1,
|
||||||
|
maxStudents: 40,
|
||||||
|
studentCount: 38,
|
||||||
|
enrollmentYear: 2024,
|
||||||
|
description: '一年级1班,班风优良',
|
||||||
|
status: 'active',
|
||||||
|
createdAt: '2024-09-01 08:00:00',
|
||||||
|
updatedAt: '2024-09-01 08:00:00'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '2',
|
||||||
|
name: '一年级2班',
|
||||||
|
code: 'CLASS_1_2',
|
||||||
|
schoolId: '1',
|
||||||
|
schoolName: '朱熹小学',
|
||||||
|
gradeId: '1',
|
||||||
|
gradeName: '一年级',
|
||||||
|
teacherName: '李老师',
|
||||||
|
teacherPhone: '13800138002',
|
||||||
|
classroom: '教学楼A102',
|
||||||
|
sort: 2,
|
||||||
|
maxStudents: 40,
|
||||||
|
studentCount: 39,
|
||||||
|
enrollmentYear: 2024,
|
||||||
|
description: '一年级2班,活泼可爱',
|
||||||
|
status: 'active',
|
||||||
|
createdAt: '2024-09-01 08:00:00',
|
||||||
|
updatedAt: '2024-09-01 08:00:00'
|
||||||
|
},
|
||||||
|
// 朱熹小学二年级班级
|
||||||
|
{
|
||||||
|
id: '3',
|
||||||
|
name: '二年级1班',
|
||||||
|
code: 'CLASS_2_1',
|
||||||
|
schoolId: '1',
|
||||||
|
schoolName: '朱熹小学',
|
||||||
|
gradeId: '2',
|
||||||
|
gradeName: '二年级',
|
||||||
|
teacherName: '王老师',
|
||||||
|
teacherPhone: '13800138003',
|
||||||
|
classroom: '教学楼A201',
|
||||||
|
sort: 1,
|
||||||
|
maxStudents: 40,
|
||||||
|
studentCount: 37,
|
||||||
|
enrollmentYear: 2023,
|
||||||
|
description: '二年级1班,学习认真',
|
||||||
|
status: 'active',
|
||||||
|
createdAt: '2024-09-01 08:00:00',
|
||||||
|
updatedAt: '2024-09-01 08:00:00'
|
||||||
|
},
|
||||||
|
// 朱熹中学初一班级
|
||||||
|
{
|
||||||
|
id: '4',
|
||||||
|
name: '初一1班',
|
||||||
|
code: 'CLASS_7_1',
|
||||||
|
schoolId: '2',
|
||||||
|
schoolName: '朱熹中学',
|
||||||
|
gradeId: '4',
|
||||||
|
gradeName: '初一',
|
||||||
|
teacherName: '陈老师',
|
||||||
|
teacherPhone: '13800138004',
|
||||||
|
classroom: '教学楼B301',
|
||||||
|
sort: 1,
|
||||||
|
maxStudents: 45,
|
||||||
|
studentCount: 44,
|
||||||
|
enrollmentYear: 2024,
|
||||||
|
description: '初一1班,团结向上',
|
||||||
|
status: 'active',
|
||||||
|
createdAt: '2024-09-01 08:00:00',
|
||||||
|
updatedAt: '2024-09-01 08:00:00'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '5',
|
||||||
|
name: '初一2班',
|
||||||
|
code: 'CLASS_7_2',
|
||||||
|
schoolId: '2',
|
||||||
|
schoolName: '朱熹中学',
|
||||||
|
gradeId: '4',
|
||||||
|
gradeName: '初一',
|
||||||
|
teacherName: '刘老师',
|
||||||
|
teacherPhone: '13800138005',
|
||||||
|
classroom: '教学楼B302',
|
||||||
|
sort: 2,
|
||||||
|
maxStudents: 45,
|
||||||
|
studentCount: 43,
|
||||||
|
enrollmentYear: 2024,
|
||||||
|
description: '初一2班,积极进取',
|
||||||
|
status: 'active',
|
||||||
|
createdAt: '2024-09-01 08:00:00',
|
||||||
|
updatedAt: '2024-09-01 08:00:00'
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
// Mock学生数据
|
||||||
|
const mockStudents = [
|
||||||
|
{
|
||||||
|
id: '1',
|
||||||
|
name: '张小明',
|
||||||
|
studentNumber: '2024001',
|
||||||
|
gender: 'male',
|
||||||
|
phone: '13900139001',
|
||||||
|
parentName: '张大明',
|
||||||
|
parentPhone: '13800138001',
|
||||||
|
classId: '1',
|
||||||
|
status: 'active'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '2',
|
||||||
|
name: '李小红',
|
||||||
|
studentNumber: '2024002',
|
||||||
|
gender: 'female',
|
||||||
|
phone: '13900139002',
|
||||||
|
parentName: '李大红',
|
||||||
|
parentPhone: '13800138002',
|
||||||
|
classId: '1',
|
||||||
|
status: 'active'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '3',
|
||||||
|
name: '王小华',
|
||||||
|
studentNumber: '2024003',
|
||||||
|
gender: 'male',
|
||||||
|
phone: '13900139003',
|
||||||
|
parentName: '王大华',
|
||||||
|
parentPhone: '13800138003',
|
||||||
|
classId: '2',
|
||||||
|
status: 'active'
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
let classIdCounter = 6;
|
||||||
|
let studentIdCounter = 4;
|
||||||
|
|
||||||
|
export default defineMock({
|
||||||
|
// 获取班级列表
|
||||||
|
'/admin/classes': ({ query }) => {
|
||||||
|
const { schoolId, gradeId, page = 1, pageSize = 10, keyword = '' } = query;
|
||||||
|
|
||||||
|
// 根据学校ID和年级ID过滤
|
||||||
|
let filteredClasses = mockClasses.filter(cls =>
|
||||||
|
cls.schoolId === schoolId && cls.gradeId === gradeId
|
||||||
|
);
|
||||||
|
|
||||||
|
// 根据关键字搜索
|
||||||
|
if (keyword) {
|
||||||
|
filteredClasses = filteredClasses.filter(cls =>
|
||||||
|
cls.name.includes(keyword) ||
|
||||||
|
cls.code.includes(keyword) ||
|
||||||
|
cls.teacherName.includes(keyword)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 分页
|
||||||
|
const start = (page - 1) * pageSize;
|
||||||
|
const end = start + pageSize;
|
||||||
|
const list = filteredClasses.slice(start, end);
|
||||||
|
|
||||||
|
return {
|
||||||
|
code: 200,
|
||||||
|
message: 'success',
|
||||||
|
data: {
|
||||||
|
list,
|
||||||
|
page: Number(page),
|
||||||
|
pageSize: Number(pageSize),
|
||||||
|
total: filteredClasses.length
|
||||||
|
}
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
// 创建班级
|
||||||
|
'[POST]/admin/classes': ({ data }) => {
|
||||||
|
const newClass = {
|
||||||
|
id: String(classIdCounter++),
|
||||||
|
...data,
|
||||||
|
schoolName: mockClasses.find(c => c.schoolId === data.schoolId)?.schoolName || '未知学校',
|
||||||
|
gradeName: mockClasses.find(c => c.gradeId === data.gradeId)?.gradeName || '未知年级',
|
||||||
|
studentCount: 0,
|
||||||
|
createdAt: new Date().toLocaleString('zh-CN'),
|
||||||
|
updatedAt: new Date().toLocaleString('zh-CN')
|
||||||
|
};
|
||||||
|
|
||||||
|
mockClasses.push(newClass);
|
||||||
|
|
||||||
|
return {
|
||||||
|
code: 200,
|
||||||
|
message: '创建成功',
|
||||||
|
data: newClass
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
// 更新班级
|
||||||
|
'[PUT]/admin/classes/{id}': ({ params, data }) => {
|
||||||
|
const index = mockClasses.findIndex(cls => cls.id === params.id);
|
||||||
|
if (index === -1) {
|
||||||
|
return {
|
||||||
|
code: 404,
|
||||||
|
message: '班级不存在'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
mockClasses[index] = {
|
||||||
|
...mockClasses[index],
|
||||||
|
...data,
|
||||||
|
updatedAt: new Date().toLocaleString('zh-CN')
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
code: 200,
|
||||||
|
message: '更新成功',
|
||||||
|
data: mockClasses[index]
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
// 删除班级
|
||||||
|
'[DELETE]/admin/classes/{id}': ({ params }) => {
|
||||||
|
const index = mockClasses.findIndex(cls => cls.id === params.id);
|
||||||
|
if (index === -1) {
|
||||||
|
return {
|
||||||
|
code: 404,
|
||||||
|
message: '班级不存在'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
mockClasses.splice(index, 1);
|
||||||
|
|
||||||
|
return {
|
||||||
|
code: 200,
|
||||||
|
message: '删除成功'
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
// 切换班级状态
|
||||||
|
'[PATCH]/admin/classes/{id}/toggle-status': ({ params }) => {
|
||||||
|
const cls = mockClasses.find(c => c.id === params.id);
|
||||||
|
if (!cls) {
|
||||||
|
return {
|
||||||
|
code: 404,
|
||||||
|
message: '班级不存在'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
cls.status = cls.status === 'active' ? 'inactive' : 'active';
|
||||||
|
cls.updatedAt = new Date().toLocaleString('zh-CN');
|
||||||
|
|
||||||
|
return {
|
||||||
|
code: 200,
|
||||||
|
message: '状态切换成功',
|
||||||
|
data: cls
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
// 获取班级详情
|
||||||
|
'/admin/classes/{id}': ({ params }) => {
|
||||||
|
const cls = mockClasses.find(c => c.id === params.id);
|
||||||
|
if (!cls) {
|
||||||
|
return {
|
||||||
|
code: 404,
|
||||||
|
message: '班级不存在'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
code: 200,
|
||||||
|
message: 'success',
|
||||||
|
data: cls
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
// 获取班级学生列表
|
||||||
|
'/admin/classes/{classId}/students': ({ params, query }) => {
|
||||||
|
const { page = 1, pageSize = 10, keyword = '' } = query;
|
||||||
|
|
||||||
|
// 根据班级ID过滤学生
|
||||||
|
let filteredStudents = mockStudents.filter(student => student.classId === params.classId);
|
||||||
|
|
||||||
|
// 根据关键字搜索
|
||||||
|
if (keyword) {
|
||||||
|
filteredStudents = filteredStudents.filter(student =>
|
||||||
|
student.name.includes(keyword) ||
|
||||||
|
student.studentNumber.includes(keyword) ||
|
||||||
|
student.parentName.includes(keyword)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 分页
|
||||||
|
const start = (page - 1) * pageSize;
|
||||||
|
const end = start + pageSize;
|
||||||
|
const list = filteredStudents.slice(start, end);
|
||||||
|
|
||||||
|
return {
|
||||||
|
code: 200,
|
||||||
|
message: 'success',
|
||||||
|
data: {
|
||||||
|
list,
|
||||||
|
page: Number(page),
|
||||||
|
pageSize: Number(pageSize),
|
||||||
|
total: filteredStudents.length
|
||||||
|
}
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
// 批量导入班级
|
||||||
|
'[POST]/admin/classes/import': ({ data }) => {
|
||||||
|
// 模拟导入结果
|
||||||
|
const importResult = {
|
||||||
|
total: 10,
|
||||||
|
success: 9,
|
||||||
|
failed: 1,
|
||||||
|
errors: [
|
||||||
|
{ row: 5, message: '班级代码重复' }
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
code: 200,
|
||||||
|
message: '导入完成',
|
||||||
|
data: importResult
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}, true);
|
||||||
26
src/mocks/common.mock.ts
Normal file
26
src/mocks/common.mock.ts
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import { defineMock } from '@alova/mock';
|
||||||
|
|
||||||
|
// 通用接口的mock数据
|
||||||
|
export default defineMock({
|
||||||
|
// 上传轮播图图片
|
||||||
|
'[POST]/admin/upload/banner': () => {
|
||||||
|
return {
|
||||||
|
code: 200,
|
||||||
|
message: 'success',
|
||||||
|
data: {
|
||||||
|
url: `https://picsum.photos/800/400?random=${Date.now()}`
|
||||||
|
}
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
// 文件上传通用接口
|
||||||
|
'[POST]/admin/upload/file': () => {
|
||||||
|
return {
|
||||||
|
code: 200,
|
||||||
|
message: 'success',
|
||||||
|
data: {
|
||||||
|
url: `https://picsum.photos/400/300?random=${Date.now()}`
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}, true);
|
||||||
214
src/mocks/grades.mock.ts
Normal file
214
src/mocks/grades.mock.ts
Normal file
@@ -0,0 +1,214 @@
|
|||||||
|
import { defineMock } from '@alova/mock';
|
||||||
|
|
||||||
|
// Mock数据
|
||||||
|
const mockGrades = [
|
||||||
|
{
|
||||||
|
id: '1',
|
||||||
|
name: '一年级',
|
||||||
|
code: 'GRADE_1',
|
||||||
|
schoolId: '1',
|
||||||
|
schoolName: '朱熹小学',
|
||||||
|
duration: 1,
|
||||||
|
sort: 1,
|
||||||
|
description: '小学一年级',
|
||||||
|
status: 'active',
|
||||||
|
classCount: 8,
|
||||||
|
studentCount: 320,
|
||||||
|
createdAt: '2023-09-01 08:00:00',
|
||||||
|
updatedAt: '2023-09-01 08:00:00'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '2',
|
||||||
|
name: '二年级',
|
||||||
|
code: 'GRADE_2',
|
||||||
|
schoolId: '1',
|
||||||
|
schoolName: '朱熹小学',
|
||||||
|
duration: 2,
|
||||||
|
sort: 2,
|
||||||
|
description: '小学二年级',
|
||||||
|
status: 'active',
|
||||||
|
classCount: 7,
|
||||||
|
studentCount: 280,
|
||||||
|
createdAt: '2023-09-01 08:00:00',
|
||||||
|
updatedAt: '2023-09-01 08:00:00'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '3',
|
||||||
|
name: '三年级',
|
||||||
|
code: 'GRADE_3',
|
||||||
|
schoolId: '1',
|
||||||
|
schoolName: '朱熹小学',
|
||||||
|
duration: 3,
|
||||||
|
sort: 3,
|
||||||
|
description: '小学三年级',
|
||||||
|
status: 'active',
|
||||||
|
classCount: 6,
|
||||||
|
studentCount: 240,
|
||||||
|
createdAt: '2023-09-01 08:00:00',
|
||||||
|
updatedAt: '2023-09-01 08:00:00'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '4',
|
||||||
|
name: '初一',
|
||||||
|
code: 'GRADE_7',
|
||||||
|
schoolId: '2',
|
||||||
|
schoolName: '朱熹中学',
|
||||||
|
duration: 1,
|
||||||
|
sort: 7,
|
||||||
|
description: '初中一年级',
|
||||||
|
status: 'active',
|
||||||
|
classCount: 10,
|
||||||
|
studentCount: 450,
|
||||||
|
createdAt: '2023-09-01 08:00:00',
|
||||||
|
updatedAt: '2023-09-01 08:00:00'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '5',
|
||||||
|
name: '初二',
|
||||||
|
code: 'GRADE_8',
|
||||||
|
schoolId: '2',
|
||||||
|
schoolName: '朱熹中学',
|
||||||
|
duration: 2,
|
||||||
|
sort: 8,
|
||||||
|
description: '初中二年级',
|
||||||
|
status: 'active',
|
||||||
|
classCount: 9,
|
||||||
|
studentCount: 405,
|
||||||
|
createdAt: '2023-09-01 08:00:00',
|
||||||
|
updatedAt: '2023-09-01 08:00:00'
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
let gradeIdCounter = 6;
|
||||||
|
|
||||||
|
export default defineMock({
|
||||||
|
// 获取年级列表
|
||||||
|
'/admin/grades': ({ query }) => {
|
||||||
|
const { schoolId, page = 1, pageSize = 10, keyword = '' } = query;
|
||||||
|
|
||||||
|
// 根据学校ID过滤
|
||||||
|
let filteredGrades = mockGrades.filter(grade => grade.schoolId === schoolId);
|
||||||
|
|
||||||
|
// 根据关键字搜索
|
||||||
|
if (keyword) {
|
||||||
|
filteredGrades = filteredGrades.filter(grade =>
|
||||||
|
grade.name.includes(keyword) || grade.code.includes(keyword)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 分页
|
||||||
|
const start = (page - 1) * pageSize;
|
||||||
|
const end = start + pageSize;
|
||||||
|
const list = filteredGrades.slice(start, end);
|
||||||
|
|
||||||
|
return {
|
||||||
|
code: 200,
|
||||||
|
message: 'success',
|
||||||
|
data: {
|
||||||
|
list,
|
||||||
|
page: Number(page),
|
||||||
|
pageSize: Number(pageSize),
|
||||||
|
total: filteredGrades.length
|
||||||
|
}
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
// 创建年级
|
||||||
|
'[POST]/admin/grades': ({ data }) => {
|
||||||
|
const newGrade = {
|
||||||
|
id: String(gradeIdCounter++),
|
||||||
|
...data,
|
||||||
|
schoolName: mockGrades.find(g => g.schoolId === data.schoolId)?.schoolName || '未知学校',
|
||||||
|
classCount: 0,
|
||||||
|
studentCount: 0,
|
||||||
|
createdAt: new Date().toLocaleString('zh-CN'),
|
||||||
|
updatedAt: new Date().toLocaleString('zh-CN')
|
||||||
|
};
|
||||||
|
|
||||||
|
mockGrades.push(newGrade);
|
||||||
|
|
||||||
|
return {
|
||||||
|
code: 200,
|
||||||
|
message: '创建成功',
|
||||||
|
data: newGrade
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
// 更新年级
|
||||||
|
'[PUT]/admin/grades/{id}': ({ params, data }) => {
|
||||||
|
const index = mockGrades.findIndex(grade => grade.id === params.id);
|
||||||
|
if (index === -1) {
|
||||||
|
return {
|
||||||
|
code: 404,
|
||||||
|
message: '年级不存在'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
mockGrades[index] = {
|
||||||
|
...mockGrades[index],
|
||||||
|
...data,
|
||||||
|
updatedAt: new Date().toLocaleString('zh-CN')
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
code: 200,
|
||||||
|
message: '更新成功',
|
||||||
|
data: mockGrades[index]
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
// 删除年级
|
||||||
|
'[DELETE]/admin/grades/{id}': ({ params }) => {
|
||||||
|
const index = mockGrades.findIndex(grade => grade.id === params.id);
|
||||||
|
if (index === -1) {
|
||||||
|
return {
|
||||||
|
code: 404,
|
||||||
|
message: '年级不存在'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
mockGrades.splice(index, 1);
|
||||||
|
|
||||||
|
return {
|
||||||
|
code: 200,
|
||||||
|
message: '删除成功'
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
// 切换年级状态
|
||||||
|
'[PATCH]/admin/grades/{id}/toggle-status': ({ params }) => {
|
||||||
|
const grade = mockGrades.find(g => g.id === params.id);
|
||||||
|
if (!grade) {
|
||||||
|
return {
|
||||||
|
code: 404,
|
||||||
|
message: '年级不存在'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
grade.status = grade.status === 'active' ? 'inactive' : 'active';
|
||||||
|
grade.updatedAt = new Date().toLocaleString('zh-CN');
|
||||||
|
|
||||||
|
return {
|
||||||
|
code: 200,
|
||||||
|
message: '状态切换成功',
|
||||||
|
data: grade
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
// 获取年级详情
|
||||||
|
'/admin/grades/{id}': ({ params }) => {
|
||||||
|
const grade = mockGrades.find(g => g.id === params.id);
|
||||||
|
if (!grade) {
|
||||||
|
return {
|
||||||
|
code: 404,
|
||||||
|
message: '年级不存在'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
code: 200,
|
||||||
|
message: 'success',
|
||||||
|
data: grade
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}, true);
|
||||||
42
src/mocks/index.mock.ts
Normal file
42
src/mocks/index.mock.ts
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
/**
|
||||||
|
* Mock数据统一管理 - 使用alova官方@alova/mock
|
||||||
|
*/
|
||||||
|
|
||||||
|
// 导入所有mock模块
|
||||||
|
import authMock from './auth.mock';
|
||||||
|
import questionsMock from './questions.mock';
|
||||||
|
import bannersMock from './banners.mock';
|
||||||
|
import recordsMock from './records.mock';
|
||||||
|
import usersMock from './users.mock';
|
||||||
|
import schoolsMock from './schools.mock';
|
||||||
|
import gradesMock from './grades.mock';
|
||||||
|
import classesMock from './classes.mock';
|
||||||
|
import profileMock from './profile.mock';
|
||||||
|
import commonMock from './common.mock';
|
||||||
|
|
||||||
|
// 导出所有mock定义
|
||||||
|
export const mockGroups = [
|
||||||
|
authMock,
|
||||||
|
questionsMock,
|
||||||
|
bannersMock,
|
||||||
|
recordsMock,
|
||||||
|
usersMock,
|
||||||
|
schoolsMock,
|
||||||
|
gradesMock,
|
||||||
|
classesMock,
|
||||||
|
profileMock,
|
||||||
|
commonMock
|
||||||
|
];
|
||||||
|
|
||||||
|
export {
|
||||||
|
authMock,
|
||||||
|
questionsMock,
|
||||||
|
bannersMock,
|
||||||
|
recordsMock,
|
||||||
|
usersMock,
|
||||||
|
schoolsMock,
|
||||||
|
gradesMock,
|
||||||
|
classesMock,
|
||||||
|
profileMock,
|
||||||
|
commonMock
|
||||||
|
};
|
||||||
104
src/mocks/profile.mock.ts
Normal file
104
src/mocks/profile.mock.ts
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
import { defineMock } from '@alova/mock';
|
||||||
|
|
||||||
|
// Mock用户资料数据
|
||||||
|
let mockProfile = {
|
||||||
|
id: 'admin001',
|
||||||
|
username: 'admin',
|
||||||
|
realName: '管理员',
|
||||||
|
phone: '13800138000',
|
||||||
|
email: 'admin@zhuzi.edu.cn',
|
||||||
|
gender: 'male',
|
||||||
|
birthday: '1985-06-15',
|
||||||
|
bio: '朱子文化管理系统管理员,负责系统维护和用户管理工作。',
|
||||||
|
avatar: 'https://api.dicebear.com/7.x/avataaars/svg?seed=admin',
|
||||||
|
role: 'admin',
|
||||||
|
status: 'active',
|
||||||
|
createdAt: '2023-01-01 08:00:00',
|
||||||
|
lastLoginAt: '2024-12-16 14:30:00',
|
||||||
|
loginCount: 256,
|
||||||
|
updatedAt: '2024-12-16 14:30:00'
|
||||||
|
};
|
||||||
|
|
||||||
|
export default defineMock({
|
||||||
|
// 获取用户资料
|
||||||
|
'/admin/profile': () => {
|
||||||
|
return {
|
||||||
|
code: 200,
|
||||||
|
message: 'success',
|
||||||
|
data: mockProfile
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
// 更新用户资料
|
||||||
|
'[PUT]/admin/profile': ({ data }) => {
|
||||||
|
// 更新用户资料
|
||||||
|
mockProfile = {
|
||||||
|
...mockProfile,
|
||||||
|
...data,
|
||||||
|
updatedAt: new Date().toLocaleString('zh-CN')
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
code: 200,
|
||||||
|
message: '更新成功',
|
||||||
|
data: mockProfile
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
// 上传头像
|
||||||
|
'[POST]/admin/profile/avatar': () => {
|
||||||
|
// 模拟生成头像URL
|
||||||
|
const avatarUrl = `https://api.dicebear.com/7.x/avataaars/svg?seed=${Date.now()}`;
|
||||||
|
|
||||||
|
// 更新用户头像
|
||||||
|
mockProfile.avatar = avatarUrl;
|
||||||
|
mockProfile.updatedAt = new Date().toLocaleString('zh-CN');
|
||||||
|
|
||||||
|
return {
|
||||||
|
code: 200,
|
||||||
|
message: '头像上传成功',
|
||||||
|
data: {
|
||||||
|
url: avatarUrl
|
||||||
|
}
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
// 修改密码
|
||||||
|
'[POST]/admin/profile/password': ({ data }) => {
|
||||||
|
const { oldPassword, newPassword } = data;
|
||||||
|
|
||||||
|
// 模拟验证旧密码(这里简单判断不为空即可)
|
||||||
|
if (!oldPassword) {
|
||||||
|
return {
|
||||||
|
code: 400,
|
||||||
|
message: '当前密码不能为空'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 模拟验证旧密码是否正确(实际项目中应该加密验证)
|
||||||
|
if (oldPassword !== '123456' && oldPassword !== 'admin123') {
|
||||||
|
return {
|
||||||
|
status: 400,
|
||||||
|
statusText: 'Bad Request',
|
||||||
|
body: {
|
||||||
|
code: 400,
|
||||||
|
message: 'Invalid old password'
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 模拟验证新密码强度
|
||||||
|
if (newPassword.length < 8) {
|
||||||
|
return {
|
||||||
|
code: 400,
|
||||||
|
message: '新密码长度至少8位'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 模拟密码修改成功
|
||||||
|
return {
|
||||||
|
code: 200,
|
||||||
|
message: '密码修改成功'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}, true);
|
||||||
297
src/mocks/questions.mock.ts
Normal file
297
src/mocks/questions.mock.ts
Normal file
@@ -0,0 +1,297 @@
|
|||||||
|
import { defineMock } from '@alova/mock';
|
||||||
|
|
||||||
|
// 模拟题目数据
|
||||||
|
const mockQuestions = [
|
||||||
|
{
|
||||||
|
id: '1',
|
||||||
|
title: '朱熹的生卒年份',
|
||||||
|
content: '朱熹(朱子)生于哪一年,卒于哪一年?',
|
||||||
|
type: 'single',
|
||||||
|
options: [
|
||||||
|
{ key: 'A', value: '1130-1200', isCorrect: true },
|
||||||
|
{ key: 'B', value: '1120-1190' },
|
||||||
|
{ key: 'C', value: '1140-1210' },
|
||||||
|
{ key: 'D', value: '1125-1195' }
|
||||||
|
],
|
||||||
|
correctAnswer: ['A'],
|
||||||
|
score: 10,
|
||||||
|
difficulty: 'easy',
|
||||||
|
category: '朱子生平',
|
||||||
|
tags: ['朱熹', '生平', '基础知识'],
|
||||||
|
createTime: '2024-01-01 10:00:00',
|
||||||
|
updateTime: '2024-01-01 10:00:00'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '2',
|
||||||
|
title: '朱子理学的核心思想',
|
||||||
|
content: '朱子理学的核心思想包括哪些?(多选)',
|
||||||
|
type: 'multiple',
|
||||||
|
options: [
|
||||||
|
{ key: 'A', value: '理气论', isCorrect: true },
|
||||||
|
{ key: 'B', value: '心性论', isCorrect: true },
|
||||||
|
{ key: 'C', value: '格物致知', isCorrect: true },
|
||||||
|
{ key: 'D', value: '王道思想' }
|
||||||
|
],
|
||||||
|
correctAnswer: ['A', 'B', 'C'],
|
||||||
|
score: 15,
|
||||||
|
difficulty: 'medium',
|
||||||
|
category: '朱子思想',
|
||||||
|
tags: ['理学', '哲学', '思想'],
|
||||||
|
createTime: '2024-01-01 11:00:00',
|
||||||
|
updateTime: '2024-01-01 11:00:00'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '3',
|
||||||
|
title: '朱子的著作',
|
||||||
|
content: '《四书章句集注》是朱子的代表作品吗?',
|
||||||
|
type: 'single',
|
||||||
|
options: [
|
||||||
|
{ key: 'A', value: '是', isCorrect: true },
|
||||||
|
{ key: 'B', value: '不是' }
|
||||||
|
],
|
||||||
|
correctAnswer: ['A'],
|
||||||
|
score: 8,
|
||||||
|
difficulty: 'easy',
|
||||||
|
category: '朱子著作',
|
||||||
|
tags: ['著作', '四书'],
|
||||||
|
createTime: '2024-01-01 12:00:00',
|
||||||
|
updateTime: '2024-01-01 12:00:00'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '4',
|
||||||
|
title: '朱子的家乡',
|
||||||
|
content: '朱熹的出生地是哪里?',
|
||||||
|
type: 'single',
|
||||||
|
options: [
|
||||||
|
{ key: 'A', value: '江西婺源' },
|
||||||
|
{ key: 'B', value: '福建尤溪', isCorrect: true },
|
||||||
|
{ key: 'C', value: '福建建阳' },
|
||||||
|
{ key: 'D', value: '浙江金华' }
|
||||||
|
],
|
||||||
|
correctAnswer: ['B'],
|
||||||
|
score: 12,
|
||||||
|
difficulty: 'medium',
|
||||||
|
category: '朱子生平',
|
||||||
|
tags: ['家乡', '生平'],
|
||||||
|
createTime: '2024-01-01 13:00:00',
|
||||||
|
updateTime: '2024-01-01 13:00:00'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '5',
|
||||||
|
title: '朱子教育思想',
|
||||||
|
content: '朱子在教育方面提出了哪些重要观点?(多选)',
|
||||||
|
type: 'multiple',
|
||||||
|
options: [
|
||||||
|
{ key: 'A', value: '因材施教', isCorrect: true },
|
||||||
|
{ key: 'B', value: '学思并重', isCorrect: true },
|
||||||
|
{ key: 'C', value: '博学笃行', isCorrect: true },
|
||||||
|
{ key: 'D', value: '死记硬背' }
|
||||||
|
],
|
||||||
|
correctAnswer: ['A', 'B', 'C'],
|
||||||
|
score: 20,
|
||||||
|
difficulty: 'hard',
|
||||||
|
category: '朱子教育',
|
||||||
|
tags: ['教育', '思想', '方法'],
|
||||||
|
createTime: '2024-01-01 14:00:00',
|
||||||
|
updateTime: '2024-01-01 14:00:00'
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
// 题目分类
|
||||||
|
const mockCategories = [
|
||||||
|
{ name: '朱子生平', count: 2 },
|
||||||
|
{ name: '朱子思想', count: 1 },
|
||||||
|
{ name: '朱子著作', count: 1 },
|
||||||
|
{ name: '朱子教育', count: 1 }
|
||||||
|
];
|
||||||
|
|
||||||
|
// 题库管理接口的mock数据
|
||||||
|
export default defineMock({
|
||||||
|
// 获取题目列表
|
||||||
|
'[GET]/admin/questions': ({ query }) => {
|
||||||
|
let filteredQuestions = [...mockQuestions];
|
||||||
|
|
||||||
|
// 关键词搜索
|
||||||
|
if (query.keyword) {
|
||||||
|
filteredQuestions = filteredQuestions.filter(q =>
|
||||||
|
q.title.includes(query.keyword!) ||
|
||||||
|
q.content.includes(query.keyword!) ||
|
||||||
|
q.category.includes(query.keyword!)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 题型筛选
|
||||||
|
if (query.type) {
|
||||||
|
filteredQuestions = filteredQuestions.filter(q => q.type === query.type);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 难度筛选
|
||||||
|
if (query.difficulty) {
|
||||||
|
filteredQuestions = filteredQuestions.filter(q => q.difficulty === query.difficulty);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 分类筛选
|
||||||
|
if (query.category) {
|
||||||
|
filteredQuestions = filteredQuestions.filter(q => q.category === query.category);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 分页
|
||||||
|
const page = parseInt(query.page) || 1;
|
||||||
|
const pageSize = parseInt(query.pageSize) || 10;
|
||||||
|
const total = filteredQuestions.length;
|
||||||
|
const start = (page - 1) * pageSize;
|
||||||
|
const end = start + pageSize;
|
||||||
|
|
||||||
|
return {
|
||||||
|
code: 200,
|
||||||
|
message: 'success',
|
||||||
|
data: {
|
||||||
|
list: filteredQuestions.slice(start, end),
|
||||||
|
total,
|
||||||
|
page,
|
||||||
|
pageSize
|
||||||
|
}
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
// 获取题目详情
|
||||||
|
'[GET]/admin/questions/{id}': ({ params }) => {
|
||||||
|
const question = mockQuestions.find(q => q.id === params.id);
|
||||||
|
if (!question) {
|
||||||
|
return {
|
||||||
|
status: 404,
|
||||||
|
body: {
|
||||||
|
code: 404,
|
||||||
|
message: '题目不存在',
|
||||||
|
data: null
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
code: 200,
|
||||||
|
message: 'success',
|
||||||
|
data: question
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
// 创建题目
|
||||||
|
'[POST]/admin/questions': ({ data }) => {
|
||||||
|
const newQuestion = {
|
||||||
|
...data,
|
||||||
|
id: (mockQuestions.length + 1).toString(),
|
||||||
|
createTime: new Date().toLocaleString('zh-CN'),
|
||||||
|
updateTime: new Date().toLocaleString('zh-CN')
|
||||||
|
};
|
||||||
|
|
||||||
|
mockQuestions.push(newQuestion);
|
||||||
|
|
||||||
|
return {
|
||||||
|
code: 200,
|
||||||
|
message: 'success',
|
||||||
|
data: {
|
||||||
|
message: '创建成功',
|
||||||
|
id: newQuestion.id
|
||||||
|
}
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
// 更新题目
|
||||||
|
'[PUT]/admin/questions/{id}': ({ params, data }) => {
|
||||||
|
const index = mockQuestions.findIndex(q => q.id === params.id);
|
||||||
|
if (index === -1) {
|
||||||
|
return {
|
||||||
|
status: 404,
|
||||||
|
body: {
|
||||||
|
code: 404,
|
||||||
|
message: '题目不存在',
|
||||||
|
data: null
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
mockQuestions[index] = {
|
||||||
|
...data,
|
||||||
|
id: params.id,
|
||||||
|
createTime: mockQuestions[index].createTime,
|
||||||
|
updateTime: new Date().toLocaleString('zh-CN')
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
code: 200,
|
||||||
|
message: 'success',
|
||||||
|
data: { message: '更新成功' }
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
// 删除题目
|
||||||
|
'[DELETE]/admin/questions/{id}': ({ params }) => {
|
||||||
|
const index = mockQuestions.findIndex(q => q.id === params.id);
|
||||||
|
if (index === -1) {
|
||||||
|
return {
|
||||||
|
status: 404,
|
||||||
|
body: {
|
||||||
|
code: 404,
|
||||||
|
message: '题目不存在',
|
||||||
|
data: null
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
mockQuestions.splice(index, 1);
|
||||||
|
|
||||||
|
return {
|
||||||
|
code: 200,
|
||||||
|
message: 'success',
|
||||||
|
data: { message: '删除成功' }
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
// 批量删除题目
|
||||||
|
'[DELETE]/admin/questions/batch': ({ data }) => {
|
||||||
|
let deletedCount = 0;
|
||||||
|
data.ids.forEach(id => {
|
||||||
|
const index = mockQuestions.findIndex(q => q.id === id);
|
||||||
|
if (index !== -1) {
|
||||||
|
mockQuestions.splice(index, 1);
|
||||||
|
deletedCount++;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
code: 200,
|
||||||
|
message: 'success',
|
||||||
|
data: {
|
||||||
|
message: `成功删除${deletedCount}个题目`
|
||||||
|
}
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
// 获取题目分类列表
|
||||||
|
'[GET]/admin/questions/categories': () => {
|
||||||
|
return {
|
||||||
|
code: 200,
|
||||||
|
message: 'success',
|
||||||
|
data: mockCategories
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
// 导出题目模板
|
||||||
|
'[GET]/admin/questions/template': () => {
|
||||||
|
const blob = new Blob(['模板内容'], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' });
|
||||||
|
return blob;
|
||||||
|
},
|
||||||
|
|
||||||
|
// 批量导入题目
|
||||||
|
'[POST]/admin/questions/import': () => {
|
||||||
|
return {
|
||||||
|
code: 200,
|
||||||
|
message: 'success',
|
||||||
|
data: {
|
||||||
|
message: '导入成功',
|
||||||
|
successCount: 5,
|
||||||
|
failCount: 0
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}, true);
|
||||||
421
src/mocks/records.mock.ts
Normal file
421
src/mocks/records.mock.ts
Normal file
@@ -0,0 +1,421 @@
|
|||||||
|
import { defineMock } from '@alova/mock';
|
||||||
|
|
||||||
|
// 模拟答题记录数据
|
||||||
|
const mockAnswerRecords = [
|
||||||
|
{
|
||||||
|
id: '1',
|
||||||
|
userId: '1',
|
||||||
|
studentName: '张小明',
|
||||||
|
parentPhone: '13800138001',
|
||||||
|
schoolName: '建阳区实验小学',
|
||||||
|
gradeName: '一年级',
|
||||||
|
className: '一年级1班',
|
||||||
|
answererType: 'parent',
|
||||||
|
totalQuestions: 5,
|
||||||
|
correctCount: 4,
|
||||||
|
totalScore: 43,
|
||||||
|
answerTime: 120,
|
||||||
|
questionDetails: [
|
||||||
|
{
|
||||||
|
questionId: '1',
|
||||||
|
questionTitle: '朱熹的生卒年份',
|
||||||
|
userAnswer: ['A'],
|
||||||
|
correctAnswer: ['A'],
|
||||||
|
isCorrect: true,
|
||||||
|
score: 10,
|
||||||
|
timeSpent: 25
|
||||||
|
},
|
||||||
|
{
|
||||||
|
questionId: '2',
|
||||||
|
questionTitle: '朱子理学的核心思想',
|
||||||
|
userAnswer: ['A', 'B'],
|
||||||
|
correctAnswer: ['A', 'B', 'C'],
|
||||||
|
isCorrect: false,
|
||||||
|
score: 0,
|
||||||
|
timeSpent: 30
|
||||||
|
},
|
||||||
|
{
|
||||||
|
questionId: '3',
|
||||||
|
questionTitle: '朱子的著作',
|
||||||
|
userAnswer: ['A'],
|
||||||
|
correctAnswer: ['A'],
|
||||||
|
isCorrect: true,
|
||||||
|
score: 8,
|
||||||
|
timeSpent: 20
|
||||||
|
},
|
||||||
|
{
|
||||||
|
questionId: '4',
|
||||||
|
questionTitle: '朱子的家乡',
|
||||||
|
userAnswer: ['B'],
|
||||||
|
correctAnswer: ['B'],
|
||||||
|
isCorrect: true,
|
||||||
|
score: 12,
|
||||||
|
timeSpent: 28
|
||||||
|
},
|
||||||
|
{
|
||||||
|
questionId: '5',
|
||||||
|
questionTitle: '朱子教育思想',
|
||||||
|
userAnswer: ['A', 'B', 'C'],
|
||||||
|
correctAnswer: ['A', 'B', 'C'],
|
||||||
|
isCorrect: true,
|
||||||
|
score: 20,
|
||||||
|
timeSpent: 17
|
||||||
|
}
|
||||||
|
],
|
||||||
|
createTime: '2024-01-15 14:30:00'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '2',
|
||||||
|
userId: '2',
|
||||||
|
studentName: '李小红',
|
||||||
|
parentPhone: '13800138002',
|
||||||
|
schoolName: '建阳区实验小学',
|
||||||
|
gradeName: '一年级',
|
||||||
|
className: '一年级2班',
|
||||||
|
answererType: 'student',
|
||||||
|
totalQuestions: 10,
|
||||||
|
correctCount: 8,
|
||||||
|
totalScore: 76,
|
||||||
|
answerTime: 280,
|
||||||
|
questionDetails: [
|
||||||
|
{
|
||||||
|
questionId: '1',
|
||||||
|
questionTitle: '朱熹的生卒年份',
|
||||||
|
userAnswer: ['A'],
|
||||||
|
correctAnswer: ['A'],
|
||||||
|
isCorrect: true,
|
||||||
|
score: 10,
|
||||||
|
timeSpent: 22
|
||||||
|
},
|
||||||
|
{
|
||||||
|
questionId: '2',
|
||||||
|
questionTitle: '朱子理学的核心思想',
|
||||||
|
userAnswer: ['A', 'B', 'C'],
|
||||||
|
correctAnswer: ['A', 'B', 'C'],
|
||||||
|
isCorrect: true,
|
||||||
|
score: 15,
|
||||||
|
timeSpent: 35
|
||||||
|
}
|
||||||
|
],
|
||||||
|
createTime: '2024-01-14 16:20:00'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '3',
|
||||||
|
userId: '3',
|
||||||
|
studentName: '王小强',
|
||||||
|
parentPhone: '13800138003',
|
||||||
|
schoolName: '建阳区第一中学',
|
||||||
|
gradeName: '七年级',
|
||||||
|
className: '七年级1班',
|
||||||
|
answererType: 'parent',
|
||||||
|
totalQuestions: 5,
|
||||||
|
correctCount: 3,
|
||||||
|
totalScore: 30,
|
||||||
|
answerTime: 145,
|
||||||
|
questionDetails: [
|
||||||
|
{
|
||||||
|
questionId: '1',
|
||||||
|
questionTitle: '朱熹的生卒年份',
|
||||||
|
userAnswer: ['B'],
|
||||||
|
correctAnswer: ['A'],
|
||||||
|
isCorrect: false,
|
||||||
|
score: 0,
|
||||||
|
timeSpent: 30
|
||||||
|
},
|
||||||
|
{
|
||||||
|
questionId: '3',
|
||||||
|
questionTitle: '朱子的著作',
|
||||||
|
userAnswer: ['A'],
|
||||||
|
correctAnswer: ['A'],
|
||||||
|
isCorrect: true,
|
||||||
|
score: 8,
|
||||||
|
timeSpent: 25
|
||||||
|
}
|
||||||
|
],
|
||||||
|
createTime: '2024-01-13 15:45:00'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '4',
|
||||||
|
userId: '4',
|
||||||
|
studentName: '刘小丽',
|
||||||
|
parentPhone: '13800138004',
|
||||||
|
schoolName: '建阳区实验小学',
|
||||||
|
gradeName: '二年级',
|
||||||
|
className: '二年级1班',
|
||||||
|
answererType: 'student',
|
||||||
|
totalQuestions: 10,
|
||||||
|
correctCount: 9,
|
||||||
|
totalScore: 88,
|
||||||
|
answerTime: 250,
|
||||||
|
questionDetails: [
|
||||||
|
{
|
||||||
|
questionId: '1',
|
||||||
|
questionTitle: '朱熹的生卒年份',
|
||||||
|
userAnswer: ['A'],
|
||||||
|
correctAnswer: ['A'],
|
||||||
|
isCorrect: true,
|
||||||
|
score: 10,
|
||||||
|
timeSpent: 20
|
||||||
|
}
|
||||||
|
],
|
||||||
|
createTime: '2024-01-16 11:20:00'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '5',
|
||||||
|
userId: '5',
|
||||||
|
studentName: '陈小华',
|
||||||
|
parentPhone: '13800138005',
|
||||||
|
schoolName: '建阳区实验小学',
|
||||||
|
gradeName: '一年级',
|
||||||
|
className: '一年级1班',
|
||||||
|
answererType: 'parent',
|
||||||
|
totalQuestions: 5,
|
||||||
|
correctCount: 2,
|
||||||
|
totalScore: 18,
|
||||||
|
answerTime: 110,
|
||||||
|
questionDetails: [
|
||||||
|
{
|
||||||
|
questionId: '1',
|
||||||
|
questionTitle: '朱熹的生卒年份',
|
||||||
|
userAnswer: ['C'],
|
||||||
|
correctAnswer: ['A'],
|
||||||
|
isCorrect: false,
|
||||||
|
score: 0,
|
||||||
|
timeSpent: 25
|
||||||
|
},
|
||||||
|
{
|
||||||
|
questionId: '3',
|
||||||
|
questionTitle: '朱子的著作',
|
||||||
|
userAnswer: ['A'],
|
||||||
|
correctAnswer: ['A'],
|
||||||
|
isCorrect: true,
|
||||||
|
score: 8,
|
||||||
|
timeSpent: 20
|
||||||
|
}
|
||||||
|
],
|
||||||
|
createTime: '2024-01-12 09:30:00'
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
// 答题记录管理接口的mock数据
|
||||||
|
export default defineMock({
|
||||||
|
// 获取答题记录列表
|
||||||
|
'[GET]/admin/records': ({ query }) => {
|
||||||
|
let filteredRecords = [...mockAnswerRecords];
|
||||||
|
|
||||||
|
// 关键词搜索(学生姓名)
|
||||||
|
if (query.keyword) {
|
||||||
|
filteredRecords = filteredRecords.filter(r =>
|
||||||
|
r.studentName.includes(query.keyword!) ||
|
||||||
|
r.parentPhone.includes(query.keyword!)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 学校筛选
|
||||||
|
if (query.schoolName) {
|
||||||
|
filteredRecords = filteredRecords.filter(r => r.schoolName.includes(query.schoolName!));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 年级筛选
|
||||||
|
if (query.gradeName) {
|
||||||
|
filteredRecords = filteredRecords.filter(r => r.gradeName.includes(query.gradeName!));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 班级筛选
|
||||||
|
if (query.className) {
|
||||||
|
filteredRecords = filteredRecords.filter(r => r.className.includes(query.className!));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 答题人类型筛选
|
||||||
|
if (query.answererType) {
|
||||||
|
filteredRecords = filteredRecords.filter(r => r.answererType === query.answererType);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 时间范围筛选
|
||||||
|
if (query.startTime) {
|
||||||
|
filteredRecords = filteredRecords.filter(r => r.createTime >= query.startTime!);
|
||||||
|
}
|
||||||
|
if (query.endTime) {
|
||||||
|
filteredRecords = filteredRecords.filter(r => r.createTime <= query.endTime!);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 按创建时间倒序排列
|
||||||
|
filteredRecords.sort((a, b) => new Date(b.createTime).getTime() - new Date(a.createTime).getTime());
|
||||||
|
|
||||||
|
// 分页
|
||||||
|
const page = parseInt(query.page) || 1;
|
||||||
|
const pageSize = parseInt(query.pageSize) || 10;
|
||||||
|
const total = filteredRecords.length;
|
||||||
|
const start = (page - 1) * pageSize;
|
||||||
|
const end = start + pageSize;
|
||||||
|
|
||||||
|
return {
|
||||||
|
code: 200,
|
||||||
|
message: 'success',
|
||||||
|
data: {
|
||||||
|
list: filteredRecords.slice(start, end),
|
||||||
|
total,
|
||||||
|
page,
|
||||||
|
pageSize
|
||||||
|
}
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
// 获取答题记录详情
|
||||||
|
'[GET]/admin/records/{id}': ({ params }) => {
|
||||||
|
const record = mockAnswerRecords.find(r => r.id === params.id);
|
||||||
|
if (!record) {
|
||||||
|
return {
|
||||||
|
status: 404,
|
||||||
|
body: {
|
||||||
|
code: 404,
|
||||||
|
message: '记录不存在',
|
||||||
|
data: null
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
code: 200,
|
||||||
|
message: 'success',
|
||||||
|
data: record
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
// 获取答题统计数据
|
||||||
|
'[GET]/admin/records/statistics': ({ query }) => {
|
||||||
|
let filteredRecords = [...mockAnswerRecords];
|
||||||
|
|
||||||
|
// 时间筛选
|
||||||
|
if (query?.startTime) {
|
||||||
|
filteredRecords = filteredRecords.filter(r => r.createTime >= query.startTime!);
|
||||||
|
}
|
||||||
|
if (query?.endTime) {
|
||||||
|
filteredRecords = filteredRecords.filter(r => r.createTime <= query.endTime!);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 学校筛选
|
||||||
|
if (query?.schoolName) {
|
||||||
|
filteredRecords = filteredRecords.filter(r => r.schoolName.includes(query.schoolName!));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 年级筛选
|
||||||
|
if (query?.gradeName) {
|
||||||
|
filteredRecords = filteredRecords.filter(r => r.gradeName.includes(query.gradeName!));
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalRecords = filteredRecords.length;
|
||||||
|
const totalUsers = new Set(filteredRecords.map(r => r.userId)).size;
|
||||||
|
const avgScore = totalRecords > 0 ?
|
||||||
|
filteredRecords.reduce((sum, r) => sum + r.totalScore, 0) / totalRecords : 0;
|
||||||
|
const avgCorrectRate = totalRecords > 0 ?
|
||||||
|
filteredRecords.reduce((sum, r) => sum + (r.correctCount / r.totalQuestions), 0) / totalRecords : 0;
|
||||||
|
|
||||||
|
// 统计热门题目
|
||||||
|
const questionStats = new Map();
|
||||||
|
filteredRecords.forEach(record => {
|
||||||
|
record.questionDetails.forEach(detail => {
|
||||||
|
if (!questionStats.has(detail.questionId)) {
|
||||||
|
questionStats.set(detail.questionId, {
|
||||||
|
title: detail.questionTitle,
|
||||||
|
answerCount: 0,
|
||||||
|
correctCount: 0
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const stat = questionStats.get(detail.questionId)!;
|
||||||
|
stat.answerCount++;
|
||||||
|
if (detail.isCorrect) {
|
||||||
|
stat.correctCount++;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const popularQuestions = Array.from(questionStats.entries())
|
||||||
|
.map(([questionId, stat]: any) => ({
|
||||||
|
questionId,
|
||||||
|
questionTitle: stat.title,
|
||||||
|
answerCount: stat.answerCount,
|
||||||
|
correctRate: stat.answerCount > 0 ? stat.correctCount / stat.answerCount : 0
|
||||||
|
}))
|
||||||
|
.sort((a, b) => b.answerCount - a.answerCount)
|
||||||
|
.slice(0, 10);
|
||||||
|
|
||||||
|
// 统计排行榜数据
|
||||||
|
const schoolStats = new Map();
|
||||||
|
const gradeStats = new Map();
|
||||||
|
const classStats = new Map();
|
||||||
|
|
||||||
|
filteredRecords.forEach(record => {
|
||||||
|
// 学校统计
|
||||||
|
if (!schoolStats.has(record.schoolName)) {
|
||||||
|
schoolStats.set(record.schoolName, { totalScore: 0, count: 0 });
|
||||||
|
}
|
||||||
|
const schoolStat = schoolStats.get(record.schoolName)!;
|
||||||
|
schoolStat.totalScore += record.totalScore;
|
||||||
|
schoolStat.count++;
|
||||||
|
|
||||||
|
// 年级统计
|
||||||
|
if (!gradeStats.has(record.gradeName)) {
|
||||||
|
gradeStats.set(record.gradeName, { totalScore: 0, count: 0 });
|
||||||
|
}
|
||||||
|
const gradeStat = gradeStats.get(record.gradeName)!;
|
||||||
|
gradeStat.totalScore += record.totalScore;
|
||||||
|
gradeStat.count++;
|
||||||
|
|
||||||
|
// 班级统计
|
||||||
|
if (!classStats.has(record.className)) {
|
||||||
|
classStats.set(record.className, { totalScore: 0, count: 0 });
|
||||||
|
}
|
||||||
|
const classStat = classStats.get(record.className)!;
|
||||||
|
classStat.totalScore += record.totalScore;
|
||||||
|
classStat.count++;
|
||||||
|
});
|
||||||
|
|
||||||
|
const schoolRanking = Array.from(schoolStats.entries())
|
||||||
|
.map(([name, stat]: any) => ({
|
||||||
|
name,
|
||||||
|
avgScore: stat.count > 0 ? stat.totalScore / stat.count : 0
|
||||||
|
}))
|
||||||
|
.sort((a, b) => b.avgScore - a.avgScore)
|
||||||
|
.slice(0, 10);
|
||||||
|
|
||||||
|
const gradeRanking = Array.from(gradeStats.entries())
|
||||||
|
.map(([name, stat]: any) => ({
|
||||||
|
name,
|
||||||
|
avgScore: stat.count > 0 ? stat.totalScore / stat.count : 0
|
||||||
|
}))
|
||||||
|
.sort((a, b) => b.avgScore - a.avgScore)
|
||||||
|
.slice(0, 10);
|
||||||
|
|
||||||
|
const classRanking = Array.from(classStats.entries())
|
||||||
|
.map(([name, stat]: any) => ({
|
||||||
|
name,
|
||||||
|
avgScore: stat.count > 0 ? stat.totalScore / stat.count : 0
|
||||||
|
}))
|
||||||
|
.sort((a, b) => b.avgScore - a.avgScore)
|
||||||
|
.slice(0, 10);
|
||||||
|
|
||||||
|
return {
|
||||||
|
code: 200,
|
||||||
|
message: 'success',
|
||||||
|
data: {
|
||||||
|
totalRecords,
|
||||||
|
totalUsers,
|
||||||
|
avgScore: Math.round(avgScore * 100) / 100,
|
||||||
|
avgCorrectRate: Math.round(avgCorrectRate * 10000) / 100,
|
||||||
|
popularQuestions,
|
||||||
|
rankingData: {
|
||||||
|
schoolRanking,
|
||||||
|
gradeRanking,
|
||||||
|
classRanking
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
// 导出答题记录
|
||||||
|
'[GET]/admin/records/export': () => {
|
||||||
|
const blob = new Blob(['导出内容'], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' });
|
||||||
|
return blob;
|
||||||
|
}
|
||||||
|
}, true);
|
||||||
462
src/mocks/schools.mock.ts
Normal file
462
src/mocks/schools.mock.ts
Normal file
@@ -0,0 +1,462 @@
|
|||||||
|
import { defineMock } from '@alova/mock';
|
||||||
|
|
||||||
|
// 模拟学校数据
|
||||||
|
const mockSchools = [
|
||||||
|
{
|
||||||
|
id: '1',
|
||||||
|
name: '建阳区实验小学',
|
||||||
|
address: '福建省南平市建阳区实验路123号',
|
||||||
|
principal: '张三',
|
||||||
|
phone: '0599-1234567',
|
||||||
|
district: '建阳区',
|
||||||
|
type: 'primary',
|
||||||
|
studentCount: 1200,
|
||||||
|
createTime: '2024-01-01 08:00:00',
|
||||||
|
updateTime: '2024-01-01 08:00:00'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '2',
|
||||||
|
name: '建阳区第一中学',
|
||||||
|
address: '福建省南平市建阳区中山路456号',
|
||||||
|
principal: '李四',
|
||||||
|
phone: '0599-1234568',
|
||||||
|
district: '建阳区',
|
||||||
|
type: 'junior',
|
||||||
|
studentCount: 800,
|
||||||
|
createTime: '2024-01-01 08:00:00',
|
||||||
|
updateTime: '2024-01-01 08:00:00'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '3',
|
||||||
|
name: '建阳区第二小学',
|
||||||
|
address: '福建省南平市建阳区朱熹路789号',
|
||||||
|
principal: '王五',
|
||||||
|
phone: '0599-1234569',
|
||||||
|
district: '建阳区',
|
||||||
|
type: 'primary',
|
||||||
|
studentCount: 900,
|
||||||
|
createTime: '2024-01-01 08:00:00',
|
||||||
|
updateTime: '2024-01-01 08:00:00'
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
// 模拟年级数据
|
||||||
|
const mockGrades = [
|
||||||
|
// 建阳区实验小学
|
||||||
|
{ id: '1', schoolId: '1', name: '一年级', level: 1, createTime: '2024-01-01 08:00:00' },
|
||||||
|
{ id: '2', schoolId: '1', name: '二年级', level: 2, createTime: '2024-01-01 08:00:00' },
|
||||||
|
{ id: '3', schoolId: '1', name: '三年级', level: 3, createTime: '2024-01-01 08:00:00' },
|
||||||
|
{ id: '4', schoolId: '1', name: '四年级', level: 4, createTime: '2024-01-01 08:00:00' },
|
||||||
|
{ id: '5', schoolId: '1', name: '五年级', level: 5, createTime: '2024-01-01 08:00:00' },
|
||||||
|
{ id: '6', schoolId: '1', name: '六年级', level: 6, createTime: '2024-01-01 08:00:00' },
|
||||||
|
|
||||||
|
// 建阳区第一中学
|
||||||
|
{ id: '7', schoolId: '2', name: '七年级', level: 7, createTime: '2024-01-01 08:00:00' },
|
||||||
|
{ id: '8', schoolId: '2', name: '八年级', level: 8, createTime: '2024-01-01 08:00:00' },
|
||||||
|
{ id: '9', schoolId: '2', name: '九年级', level: 9, createTime: '2024-01-01 08:00:00' },
|
||||||
|
|
||||||
|
// 建阳区第二小学
|
||||||
|
{ id: '10', schoolId: '3', name: '一年级', level: 1, createTime: '2024-01-01 08:00:00' },
|
||||||
|
{ id: '11', schoolId: '3', name: '二年级', level: 2, createTime: '2024-01-01 08:00:00' },
|
||||||
|
{ id: '12', schoolId: '3', name: '三年级', level: 3, createTime: '2024-01-01 08:00:00' }
|
||||||
|
];
|
||||||
|
|
||||||
|
// 模拟班级数据
|
||||||
|
const mockClasses = [
|
||||||
|
// 建阳区实验小学一年级
|
||||||
|
{ id: '1', schoolId: '1', gradeId: '1', name: '一年级1班', teacherName: '王老师', studentCount: 30, createTime: '2024-01-01 08:00:00' },
|
||||||
|
{ id: '2', schoolId: '1', gradeId: '1', name: '一年级2班', teacherName: '刘老师', studentCount: 28, createTime: '2024-01-01 08:00:00' },
|
||||||
|
{ id: '3', schoolId: '1', gradeId: '1', name: '一年级3班', teacherName: '李老师', studentCount: 29, createTime: '2024-01-01 08:00:00' },
|
||||||
|
|
||||||
|
// 建阳区实验小学二年级
|
||||||
|
{ id: '4', schoolId: '1', gradeId: '2', name: '二年级1班', teacherName: '张老师', studentCount: 32, createTime: '2024-01-01 08:00:00' },
|
||||||
|
{ id: '5', schoolId: '1', gradeId: '2', name: '二年级2班', teacherName: '赵老师', studentCount: 31, createTime: '2024-01-01 08:00:00' },
|
||||||
|
|
||||||
|
// 建阳区第一中学七年级
|
||||||
|
{ id: '6', schoolId: '2', gradeId: '7', name: '七年级1班', teacherName: '陈老师', studentCount: 40, createTime: '2024-01-01 08:00:00' },
|
||||||
|
{ id: '7', schoolId: '2', gradeId: '7', name: '七年级2班', teacherName: '吴老师', studentCount: 38, createTime: '2024-01-01 08:00:00' },
|
||||||
|
|
||||||
|
// 建阳区第二小学
|
||||||
|
{ id: '8', schoolId: '3', gradeId: '10', name: '一年级1班', teacherName: '周老师', studentCount: 35, createTime: '2024-01-01 08:00:00' }
|
||||||
|
];
|
||||||
|
|
||||||
|
// 区县列表
|
||||||
|
const mockDistricts = [
|
||||||
|
{ name: '建阳区', count: 3 },
|
||||||
|
{ name: '延平区', count: 0 },
|
||||||
|
{ name: '顺昌县', count: 0 },
|
||||||
|
{ name: '浦城县', count: 0 }
|
||||||
|
];
|
||||||
|
|
||||||
|
// 学校管理接口的mock数据
|
||||||
|
export default defineMock({
|
||||||
|
// 获取学校列表
|
||||||
|
'[GET]/admin/schools': ({ query }) => {
|
||||||
|
let filteredSchools = [...mockSchools];
|
||||||
|
|
||||||
|
// 关键词搜索
|
||||||
|
if (query.keyword) {
|
||||||
|
filteredSchools = filteredSchools.filter(s =>
|
||||||
|
s.name.includes(query.keyword!) ||
|
||||||
|
s.address?.includes(query.keyword!) ||
|
||||||
|
s.principal?.includes(query.keyword!)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 区县筛选
|
||||||
|
if (query.district) {
|
||||||
|
filteredSchools = filteredSchools.filter(s => s.district === query.district);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 类型筛选
|
||||||
|
if (query.type) {
|
||||||
|
filteredSchools = filteredSchools.filter(s => s.type === query.type);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 为每个学校添加年级班级信息
|
||||||
|
const schoolsWithGrades = filteredSchools.map(school => {
|
||||||
|
const grades = mockGrades.filter(g => g.schoolId === school.id);
|
||||||
|
const gradesWithClasses = grades.map(grade => ({
|
||||||
|
...grade,
|
||||||
|
classes: mockClasses.filter(c => c.gradeId === grade.id)
|
||||||
|
}));
|
||||||
|
|
||||||
|
return {
|
||||||
|
...school,
|
||||||
|
grades: gradesWithClasses
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// 分页
|
||||||
|
const page = parseInt(query.page) || 1;
|
||||||
|
const pageSize = parseInt(query.pageSize) || 10;
|
||||||
|
const total = schoolsWithGrades.length;
|
||||||
|
const start = (page - 1) * pageSize;
|
||||||
|
const end = start + pageSize;
|
||||||
|
|
||||||
|
return {
|
||||||
|
code: 200,
|
||||||
|
message: 'success',
|
||||||
|
data: {
|
||||||
|
list: schoolsWithGrades.slice(start, end),
|
||||||
|
total,
|
||||||
|
page,
|
||||||
|
pageSize
|
||||||
|
}
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
// 获取学校详情(包含年级班级)
|
||||||
|
'[GET]/admin/schools/{id}': ({ params }) => {
|
||||||
|
const school = mockSchools.find(s => s.id === params.id);
|
||||||
|
if (!school) {
|
||||||
|
return {
|
||||||
|
status: 404,
|
||||||
|
body: {
|
||||||
|
code: 404,
|
||||||
|
message: '学校不存在',
|
||||||
|
data: null
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取该学校的年级
|
||||||
|
const grades = mockGrades.filter(g => g.schoolId === params.id);
|
||||||
|
const gradesWithClasses = grades.map(grade => ({
|
||||||
|
...grade,
|
||||||
|
classes: mockClasses.filter(c => c.gradeId === grade.id)
|
||||||
|
}));
|
||||||
|
|
||||||
|
return {
|
||||||
|
code: 200,
|
||||||
|
message: 'success',
|
||||||
|
data: {
|
||||||
|
...school,
|
||||||
|
grades: gradesWithClasses
|
||||||
|
}
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
// 创建学校
|
||||||
|
'[POST]/admin/schools': ({ data }) => {
|
||||||
|
const newSchool = {
|
||||||
|
...data,
|
||||||
|
id: (mockSchools.length + 1).toString(),
|
||||||
|
createTime: new Date().toLocaleString('zh-CN'),
|
||||||
|
updateTime: new Date().toLocaleString('zh-CN')
|
||||||
|
};
|
||||||
|
|
||||||
|
mockSchools.push(newSchool);
|
||||||
|
|
||||||
|
return {
|
||||||
|
code: 200,
|
||||||
|
message: 'success',
|
||||||
|
data: {
|
||||||
|
message: '创建成功',
|
||||||
|
id: newSchool.id
|
||||||
|
}
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
// 更新学校
|
||||||
|
'[PUT]/admin/schools/{id}': ({ params, data }) => {
|
||||||
|
const index = mockSchools.findIndex(s => s.id === params.id);
|
||||||
|
if (index === -1) {
|
||||||
|
return {
|
||||||
|
status: 404,
|
||||||
|
body: {
|
||||||
|
code: 404,
|
||||||
|
message: '学校不存在',
|
||||||
|
data: null
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
mockSchools[index] = {
|
||||||
|
...data,
|
||||||
|
id: params.id,
|
||||||
|
createTime: mockSchools[index].createTime,
|
||||||
|
updateTime: new Date().toLocaleString('zh-CN')
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
code: 200,
|
||||||
|
message: 'success',
|
||||||
|
data: { message: '更新成功' }
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
// 删除学校
|
||||||
|
'[DELETE]/admin/schools/{id}': ({ params }) => {
|
||||||
|
const schoolIndex = mockSchools.findIndex(s => s.id === params.id);
|
||||||
|
if (schoolIndex === -1) {
|
||||||
|
return {
|
||||||
|
status: 404,
|
||||||
|
body: {
|
||||||
|
code: 404,
|
||||||
|
message: '学校不存在',
|
||||||
|
data: null
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 删除学校
|
||||||
|
mockSchools.splice(schoolIndex, 1);
|
||||||
|
|
||||||
|
// 删除相关年级
|
||||||
|
const gradeIds = mockGrades.filter(g => g.schoolId === params.id).map(g => g.id);
|
||||||
|
for (let i = mockGrades.length - 1; i >= 0; i--) {
|
||||||
|
if (mockGrades[i].schoolId === params.id) {
|
||||||
|
mockGrades.splice(i, 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 删除相关班级
|
||||||
|
for (let i = mockClasses.length - 1; i >= 0; i--) {
|
||||||
|
if (gradeIds.includes(mockClasses[i].gradeId)) {
|
||||||
|
mockClasses.splice(i, 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
code: 200,
|
||||||
|
message: 'success',
|
||||||
|
data: { message: '删除成功' }
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
// 获取年级列表
|
||||||
|
'[GET]/admin/schools/{schoolId}/grades': ({ params }) => {
|
||||||
|
const grades = mockGrades.filter(g => g.schoolId === params.schoolId);
|
||||||
|
return {
|
||||||
|
code: 200,
|
||||||
|
message: 'success',
|
||||||
|
data: grades
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
// 创建年级
|
||||||
|
'[POST]/admin/grades': ({ data }) => {
|
||||||
|
const newGrade = {
|
||||||
|
...data,
|
||||||
|
id: (mockGrades.length + 1).toString(),
|
||||||
|
createTime: new Date().toLocaleString('zh-CN')
|
||||||
|
};
|
||||||
|
|
||||||
|
mockGrades.push(newGrade);
|
||||||
|
|
||||||
|
return {
|
||||||
|
code: 200,
|
||||||
|
message: 'success',
|
||||||
|
data: {
|
||||||
|
message: '创建成功',
|
||||||
|
id: newGrade.id
|
||||||
|
}
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
// 更新年级
|
||||||
|
'[PUT]/admin/grades/{id}': ({ params, data }) => {
|
||||||
|
const index = mockGrades.findIndex(g => g.id === params.id);
|
||||||
|
if (index === -1) {
|
||||||
|
return {
|
||||||
|
status: 404,
|
||||||
|
body: {
|
||||||
|
code: 404,
|
||||||
|
message: '年级不存在',
|
||||||
|
data: null
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
mockGrades[index] = {
|
||||||
|
...data,
|
||||||
|
id: params.id,
|
||||||
|
createTime: mockGrades[index].createTime
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
code: 200,
|
||||||
|
message: 'success',
|
||||||
|
data: { message: '更新成功' }
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
// 删除年级
|
||||||
|
'[DELETE]/admin/grades/{id}': ({ params }) => {
|
||||||
|
const index = mockGrades.findIndex(g => g.id === params.id);
|
||||||
|
if (index === -1) {
|
||||||
|
return {
|
||||||
|
status: 404,
|
||||||
|
body: {
|
||||||
|
code: 404,
|
||||||
|
message: '年级不存在',
|
||||||
|
data: null
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
mockGrades.splice(index, 1);
|
||||||
|
|
||||||
|
// 删除相关班级
|
||||||
|
for (let i = mockClasses.length - 1; i >= 0; i--) {
|
||||||
|
if (mockClasses[i].gradeId === params.id) {
|
||||||
|
mockClasses.splice(i, 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
code: 200,
|
||||||
|
message: 'success',
|
||||||
|
data: { message: '删除成功' }
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
// 获取班级列表
|
||||||
|
'[GET]/admin/grades/{gradeId}/classes': ({ params }) => {
|
||||||
|
const classes = mockClasses.filter(c => c.gradeId === params.gradeId);
|
||||||
|
return {
|
||||||
|
code: 200,
|
||||||
|
message: 'success',
|
||||||
|
data: classes
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
// 创建班级
|
||||||
|
'[POST]/admin/classes': ({ data }) => {
|
||||||
|
const newClass = {
|
||||||
|
...data,
|
||||||
|
id: (mockClasses.length + 1).toString(),
|
||||||
|
createTime: new Date().toLocaleString('zh-CN')
|
||||||
|
};
|
||||||
|
|
||||||
|
mockClasses.push(newClass);
|
||||||
|
|
||||||
|
return {
|
||||||
|
code: 200,
|
||||||
|
message: 'success',
|
||||||
|
data: {
|
||||||
|
message: '创建成功',
|
||||||
|
id: newClass.id
|
||||||
|
}
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
// 更新班级
|
||||||
|
'[PUT]/admin/classes/{id}': ({ params, data }) => {
|
||||||
|
const index = mockClasses.findIndex(c => c.id === params.id);
|
||||||
|
if (index === -1) {
|
||||||
|
return {
|
||||||
|
status: 404,
|
||||||
|
body: {
|
||||||
|
code: 404,
|
||||||
|
message: '班级不存在',
|
||||||
|
data: null
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
mockClasses[index] = {
|
||||||
|
...data,
|
||||||
|
id: params.id,
|
||||||
|
createTime: mockClasses[index].createTime
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
code: 200,
|
||||||
|
message: 'success',
|
||||||
|
data: { message: '更新成功' }
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
// 删除班级
|
||||||
|
'[DELETE]/admin/classes/{id}': ({ params }) => {
|
||||||
|
const index = mockClasses.findIndex(c => c.id === params.id);
|
||||||
|
if (index === -1) {
|
||||||
|
return {
|
||||||
|
status: 404,
|
||||||
|
body: {
|
||||||
|
code: 404,
|
||||||
|
message: '班级不存在',
|
||||||
|
data: null
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
mockClasses.splice(index, 1);
|
||||||
|
|
||||||
|
return {
|
||||||
|
code: 200,
|
||||||
|
message: 'success',
|
||||||
|
data: { message: '删除成功' }
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
// 获取区县列表
|
||||||
|
'[GET]/admin/schools/districts': () => {
|
||||||
|
return {
|
||||||
|
code: 200,
|
||||||
|
message: 'success',
|
||||||
|
data: mockDistricts
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
// 导出学校数据模板
|
||||||
|
'[GET]/admin/schools/template': () => {
|
||||||
|
const blob = new Blob(['模板内容'], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' });
|
||||||
|
return blob;
|
||||||
|
},
|
||||||
|
|
||||||
|
// 批量导入学校数据
|
||||||
|
'[POST]/admin/schools/import': () => {
|
||||||
|
return {
|
||||||
|
code: 200,
|
||||||
|
message: 'success',
|
||||||
|
data: {
|
||||||
|
message: '导入成功',
|
||||||
|
successCount: 3,
|
||||||
|
failCount: 0
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}, true);
|
||||||
266
src/mocks/users.mock.ts
Normal file
266
src/mocks/users.mock.ts
Normal file
@@ -0,0 +1,266 @@
|
|||||||
|
import { defineMock } from '@alova/mock';
|
||||||
|
|
||||||
|
// 模拟小程序用户数据
|
||||||
|
const mockAppUsers = [
|
||||||
|
{
|
||||||
|
id: '1',
|
||||||
|
openid: 'oABC123def456',
|
||||||
|
phone: '13800138001',
|
||||||
|
avatar: 'https://api.dicebear.com/7.x/avataaars/svg?seed=user1',
|
||||||
|
nickname: '张家长',
|
||||||
|
studentId: 'student1',
|
||||||
|
studentName: '张小明',
|
||||||
|
schoolId: '1',
|
||||||
|
schoolName: '建阳区实验小学',
|
||||||
|
gradeId: '1',
|
||||||
|
gradeName: '一年级',
|
||||||
|
classId: '1',
|
||||||
|
className: '一年级1班',
|
||||||
|
studentSeatNumber: 15,
|
||||||
|
totalScore: 85,
|
||||||
|
answerCount: 10,
|
||||||
|
lastAnswerTime: '2024-01-15 14:30:00',
|
||||||
|
createTime: '2024-01-01 10:00:00',
|
||||||
|
updateTime: '2024-01-15 14:30:00'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '2',
|
||||||
|
openid: 'oABC789ghi012',
|
||||||
|
phone: '13800138002',
|
||||||
|
avatar: 'https://api.dicebear.com/7.x/avataaars/svg?seed=user2',
|
||||||
|
nickname: '李家长',
|
||||||
|
studentId: 'student2',
|
||||||
|
studentName: '李小红',
|
||||||
|
schoolId: '1',
|
||||||
|
schoolName: '建阳区实验小学',
|
||||||
|
gradeId: '1',
|
||||||
|
gradeName: '一年级',
|
||||||
|
classId: '2',
|
||||||
|
className: '一年级2班',
|
||||||
|
studentSeatNumber: 8,
|
||||||
|
totalScore: 92,
|
||||||
|
answerCount: 12,
|
||||||
|
lastAnswerTime: '2024-01-14 16:20:00',
|
||||||
|
createTime: '2024-01-01 11:00:00',
|
||||||
|
updateTime: '2024-01-14 16:20:00'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '3',
|
||||||
|
openid: 'oABC345jkl678',
|
||||||
|
phone: '13800138003',
|
||||||
|
avatar: 'https://api.dicebear.com/7.x/avataaars/svg?seed=user3',
|
||||||
|
nickname: '王家长',
|
||||||
|
studentId: 'student3',
|
||||||
|
studentName: '王小强',
|
||||||
|
schoolId: '2',
|
||||||
|
schoolName: '建阳区第一中学',
|
||||||
|
gradeId: '3',
|
||||||
|
gradeName: '七年级',
|
||||||
|
classId: '3',
|
||||||
|
className: '七年级1班',
|
||||||
|
studentSeatNumber: 12,
|
||||||
|
totalScore: 78,
|
||||||
|
answerCount: 8,
|
||||||
|
lastAnswerTime: '2024-01-13 15:45:00',
|
||||||
|
createTime: '2024-01-01 12:00:00',
|
||||||
|
updateTime: '2024-01-13 15:45:00'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '4',
|
||||||
|
openid: 'oABC901mno234',
|
||||||
|
phone: '13800138004',
|
||||||
|
avatar: 'https://api.dicebear.com/7.x/avataaars/svg?seed=user4',
|
||||||
|
nickname: '刘家长',
|
||||||
|
studentId: 'student4',
|
||||||
|
studentName: '刘小丽',
|
||||||
|
schoolId: '1',
|
||||||
|
schoolName: '建阳区实验小学',
|
||||||
|
gradeId: '2',
|
||||||
|
gradeName: '二年级',
|
||||||
|
classId: '4',
|
||||||
|
className: '二年级1班',
|
||||||
|
studentSeatNumber: 5,
|
||||||
|
totalScore: 95,
|
||||||
|
answerCount: 15,
|
||||||
|
lastAnswerTime: '2024-01-16 11:20:00',
|
||||||
|
createTime: '2024-01-01 13:00:00',
|
||||||
|
updateTime: '2024-01-16 11:20:00'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '5',
|
||||||
|
openid: 'oABC567pqr890',
|
||||||
|
phone: '13800138005',
|
||||||
|
avatar: 'https://api.dicebear.com/7.x/avataaars/svg?seed=user5',
|
||||||
|
nickname: '陈家长',
|
||||||
|
studentId: 'student5',
|
||||||
|
studentName: '陈小华',
|
||||||
|
schoolId: '1',
|
||||||
|
schoolName: '建阳区实验小学',
|
||||||
|
gradeId: '1',
|
||||||
|
gradeName: '一年级',
|
||||||
|
classId: '1',
|
||||||
|
className: '一年级1班',
|
||||||
|
studentSeatNumber: 20,
|
||||||
|
totalScore: 67,
|
||||||
|
answerCount: 6,
|
||||||
|
lastAnswerTime: '2024-01-12 09:30:00',
|
||||||
|
createTime: '2024-01-01 14:00:00',
|
||||||
|
updateTime: '2024-01-12 09:30:00'
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
// 用户管理接口的mock数据
|
||||||
|
export default defineMock({
|
||||||
|
// 获取用户列表
|
||||||
|
'[GET]/admin/users': ({ query }) => {
|
||||||
|
let filteredUsers = [...mockAppUsers];
|
||||||
|
|
||||||
|
// 关键词搜索(学生姓名或家长手机号)
|
||||||
|
if (query.keyword) {
|
||||||
|
filteredUsers = filteredUsers.filter(u =>
|
||||||
|
u.studentName?.includes(query.keyword!) ||
|
||||||
|
u.phone.includes(query.keyword!) ||
|
||||||
|
u.nickname?.includes(query.keyword!)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 学校筛选
|
||||||
|
if (query.schoolId) {
|
||||||
|
filteredUsers = filteredUsers.filter(u => u.schoolId === query.schoolId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 年级筛选
|
||||||
|
if (query.gradeId) {
|
||||||
|
filteredUsers = filteredUsers.filter(u => u.gradeId === query.gradeId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 班级筛选
|
||||||
|
if (query.classId) {
|
||||||
|
filteredUsers = filteredUsers.filter(u => u.classId === query.classId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 按创建时间倒序排列
|
||||||
|
filteredUsers.sort((a, b) => new Date(b.createTime).getTime() - new Date(a.createTime).getTime());
|
||||||
|
|
||||||
|
// 分页
|
||||||
|
const page = parseInt(query.page) || 1;
|
||||||
|
const pageSize = parseInt(query.pageSize) || 10;
|
||||||
|
const total = filteredUsers.length;
|
||||||
|
const start = (page - 1) * pageSize;
|
||||||
|
const end = start + pageSize;
|
||||||
|
|
||||||
|
return {
|
||||||
|
code: 200,
|
||||||
|
message: 'success',
|
||||||
|
data: {
|
||||||
|
list: filteredUsers.slice(start, end),
|
||||||
|
total,
|
||||||
|
page,
|
||||||
|
pageSize
|
||||||
|
}
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
// 获取用户详情
|
||||||
|
'[GET]/admin/users/{id}': ({ params }) => {
|
||||||
|
const user = mockAppUsers.find(u => u.id === params.id);
|
||||||
|
if (!user) {
|
||||||
|
return {
|
||||||
|
status: 404,
|
||||||
|
body: {
|
||||||
|
code: 404,
|
||||||
|
message: '用户不存在',
|
||||||
|
data: null
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
code: 200,
|
||||||
|
message: 'success',
|
||||||
|
data: user
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
// 解绑家长与学生关系
|
||||||
|
'[PUT]/admin/users/{id}/unbind': ({ params }) => {
|
||||||
|
const user = mockAppUsers.find(u => u.id === params.id);
|
||||||
|
if (!user) {
|
||||||
|
return {
|
||||||
|
status: 404,
|
||||||
|
body: {
|
||||||
|
code: 404,
|
||||||
|
message: '用户不存在',
|
||||||
|
data: null
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 清空学生信息
|
||||||
|
user.studentId = undefined;
|
||||||
|
user.studentName = undefined;
|
||||||
|
user.schoolId = '';
|
||||||
|
user.schoolName = '';
|
||||||
|
user.gradeId = '';
|
||||||
|
user.gradeName = '';
|
||||||
|
user.classId = '';
|
||||||
|
user.className = '';
|
||||||
|
user.studentSeatNumber = undefined;
|
||||||
|
user.totalScore = 0;
|
||||||
|
user.answerCount = 0;
|
||||||
|
user.lastAnswerTime = undefined;
|
||||||
|
user.updateTime = new Date().toLocaleString('zh-CN');
|
||||||
|
|
||||||
|
return {
|
||||||
|
code: 200,
|
||||||
|
message: 'success',
|
||||||
|
data: { message: '解绑成功' }
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
// 禁用用户
|
||||||
|
'[PUT]/admin/users/{id}/disable': ({ params }) => {
|
||||||
|
const user = mockAppUsers.find(u => u.id === params.id);
|
||||||
|
if (!user) {
|
||||||
|
return {
|
||||||
|
status: 404,
|
||||||
|
body: {
|
||||||
|
code: 404,
|
||||||
|
message: '用户不存在',
|
||||||
|
data: null
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
user.updateTime = new Date().toLocaleString('zh-CN');
|
||||||
|
|
||||||
|
return {
|
||||||
|
code: 200,
|
||||||
|
message: 'success',
|
||||||
|
data: { message: '禁用成功' }
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
// 启用用户
|
||||||
|
'[PUT]/admin/users/{id}/enable': ({ params }) => {
|
||||||
|
const user = mockAppUsers.find(u => u.id === params.id);
|
||||||
|
if (!user) {
|
||||||
|
return {
|
||||||
|
status: 404,
|
||||||
|
body: {
|
||||||
|
code: 404,
|
||||||
|
message: '用户不存在',
|
||||||
|
data: null
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
user.updateTime = new Date().toLocaleString('zh-CN');
|
||||||
|
|
||||||
|
return {
|
||||||
|
code: 200,
|
||||||
|
message: 'success',
|
||||||
|
data: { message: '启用成功' }
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}, true);
|
||||||
151
src/router/index.ts
Normal file
151
src/router/index.ts
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
import { createRouter, createWebHashHistory, type RouteRecordRaw } from 'vue-router';
|
||||||
|
import { useAuthStore } from '@/stores/auth';
|
||||||
|
|
||||||
|
const routes: RouteRecordRaw[] = [
|
||||||
|
{
|
||||||
|
path: '/',
|
||||||
|
redirect: '/admin'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/login',
|
||||||
|
name: 'Login',
|
||||||
|
component: () => import('@/views/auth/LoginPage.vue'),
|
||||||
|
meta: {
|
||||||
|
title: '登录',
|
||||||
|
requiresGuest: true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/admin',
|
||||||
|
component: () => import('@/components/layout/AdminLayout.vue'),
|
||||||
|
meta: {
|
||||||
|
requiresAuth: true
|
||||||
|
},
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
path: '',
|
||||||
|
name: 'Dashboard',
|
||||||
|
component: () => import('@/views/dashboard/DashboardPage.vue'),
|
||||||
|
meta: {
|
||||||
|
title: '仪表盘'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'questions',
|
||||||
|
name: 'Questions',
|
||||||
|
component: () => import('@/views/questions/QuestionPage.vue'),
|
||||||
|
meta: {
|
||||||
|
title: '题库管理'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'banners',
|
||||||
|
name: 'Banners',
|
||||||
|
component: () => import('@/views/banners/BannerPage.vue'),
|
||||||
|
meta: {
|
||||||
|
title: '轮播图管理'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'records',
|
||||||
|
name: 'Records',
|
||||||
|
component: () => import('@/views/records/RecordPage.vue'),
|
||||||
|
meta: {
|
||||||
|
title: '答题记录'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'users',
|
||||||
|
name: 'Users',
|
||||||
|
component: () => import('@/views/users/UserPage.vue'),
|
||||||
|
meta: {
|
||||||
|
title: '用户管理'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'schools',
|
||||||
|
name: 'Schools',
|
||||||
|
component: () => import('@/views/schools/SchoolPage.vue'),
|
||||||
|
meta: {
|
||||||
|
title: '学校管理'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'grades',
|
||||||
|
name: 'Grades',
|
||||||
|
component: () => import('@/views/grades/GradePage.vue'),
|
||||||
|
meta: {
|
||||||
|
title: '年级管理'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'classes',
|
||||||
|
name: 'Classes',
|
||||||
|
component: () => import('@/views/classes/ClassPage.vue'),
|
||||||
|
meta: {
|
||||||
|
title: '班级管理'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'profile',
|
||||||
|
name: 'Profile',
|
||||||
|
component: () => import('@/views/profile/ProfilePage.vue'),
|
||||||
|
meta: {
|
||||||
|
title: '个人信息'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'password',
|
||||||
|
name: 'Password',
|
||||||
|
component: () => import('@/views/profile/PasswordPage.vue'),
|
||||||
|
meta: {
|
||||||
|
title: '修改密码'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/:pathMatch(.*)*',
|
||||||
|
name: 'NotFound',
|
||||||
|
component: () => import('@/views/auth/LoginPage.vue'),
|
||||||
|
meta: {
|
||||||
|
title: '页面未找到'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
const router = createRouter({
|
||||||
|
history: createWebHashHistory(),
|
||||||
|
routes
|
||||||
|
});
|
||||||
|
|
||||||
|
// 路由守卫
|
||||||
|
router.beforeEach((to, _from, next) => {
|
||||||
|
const authStore = useAuthStore();
|
||||||
|
|
||||||
|
|
||||||
|
// 设置页面标题
|
||||||
|
if (to.meta.title) {
|
||||||
|
document.title = `${to.meta.title} - 朱子文化管理后台`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 需要认证的页面
|
||||||
|
if (to.meta.requiresAuth) {
|
||||||
|
if (!authStore.isLoggedIn) {
|
||||||
|
next('/login');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 需要游客身份的页面(如登录页)
|
||||||
|
if (to.meta.requiresGuest) {
|
||||||
|
if (authStore.isLoggedIn) {
|
||||||
|
next('/admin');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
next();
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
71
src/stores/auth.ts
Normal file
71
src/stores/auth.ts
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
import { defineStore } from 'pinia';
|
||||||
|
import { ref, computed } from 'vue';
|
||||||
|
import type { LoginResponse } from '@/apis/auth';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 用户认证状态管理
|
||||||
|
*/
|
||||||
|
export const useAuthStore = defineStore('auth', () => {
|
||||||
|
// 状态
|
||||||
|
const token = ref<string>('');
|
||||||
|
const refreshToken = ref<string>('');
|
||||||
|
const userInfo = ref<LoginResponse['userInfo'] | null>(null);
|
||||||
|
const isLoggedIn = computed(() => !!token.value && !!userInfo.value);
|
||||||
|
|
||||||
|
// 设置登录信息
|
||||||
|
const setAuth = (loginData: LoginResponse) => {
|
||||||
|
token.value = loginData.token;
|
||||||
|
refreshToken.value = loginData.refreshToken;
|
||||||
|
userInfo.value = loginData.userInfo;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 设置用户信息
|
||||||
|
const setUserInfo = (user: LoginResponse['userInfo']) => {
|
||||||
|
userInfo.value = user;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 更新token
|
||||||
|
const updateToken = (newToken: string, newRefreshToken?: string) => {
|
||||||
|
token.value = newToken;
|
||||||
|
if (newRefreshToken) {
|
||||||
|
refreshToken.value = newRefreshToken;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 清除登录信息
|
||||||
|
const clearAuth = () => {
|
||||||
|
token.value = '';
|
||||||
|
refreshToken.value = '';
|
||||||
|
userInfo.value = null;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 检查是否为管理员
|
||||||
|
const isAdmin = computed(() => userInfo.value?.role === 'admin');
|
||||||
|
|
||||||
|
// 检查是否为操作员
|
||||||
|
const isOperator = computed(() => userInfo.value?.role === 'operator');
|
||||||
|
|
||||||
|
return {
|
||||||
|
// 状态
|
||||||
|
token,
|
||||||
|
refreshToken,
|
||||||
|
userInfo,
|
||||||
|
isLoggedIn,
|
||||||
|
|
||||||
|
// 计算属性
|
||||||
|
isAdmin,
|
||||||
|
isOperator,
|
||||||
|
|
||||||
|
// 方法
|
||||||
|
setAuth,
|
||||||
|
setUserInfo,
|
||||||
|
updateToken,
|
||||||
|
clearAuth,
|
||||||
|
};
|
||||||
|
}, {
|
||||||
|
persist: {
|
||||||
|
key: 'zhuzi-admin-auth',
|
||||||
|
storage: localStorage,
|
||||||
|
pick: ['token', 'refreshToken', 'userInfo']
|
||||||
|
}
|
||||||
|
});
|
||||||
17
src/utils/request/adapter/localforageStorageAdapter.ts
Normal file
17
src/utils/request/adapter/localforageStorageAdapter.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import localforage from "localforage";
|
||||||
|
|
||||||
|
|
||||||
|
export const localforageStorageAdapter = {
|
||||||
|
async set(key: string, value: any) {
|
||||||
|
await localforage.setItem(key, value);
|
||||||
|
},
|
||||||
|
async get(key: string) {
|
||||||
|
return await localforage.getItem(key);
|
||||||
|
},
|
||||||
|
async remove(key: any) {
|
||||||
|
await localforage.removeItem(key);
|
||||||
|
},
|
||||||
|
async clear() {
|
||||||
|
await localforage.clear();
|
||||||
|
}
|
||||||
|
};
|
||||||
133
src/utils/request/index.ts
Normal file
133
src/utils/request/index.ts
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
import {createAlova} from 'alova';
|
||||||
|
import VueHook from 'alova/vue';
|
||||||
|
import {localforageStorageAdapter} from "@/utils/request/adapter/localforageStorageAdapter.ts";
|
||||||
|
import {createServerTokenAuthentication} from "alova/client";
|
||||||
|
import type {AxiosResponse, AxiosResponseHeaders} from "axios";
|
||||||
|
import type {AlovaAxiosRequestConfig} from "@alova/adapter-axios";
|
||||||
|
|
||||||
|
import {axiosRequestAdapter} from "@alova/adapter-axios";
|
||||||
|
import {createAlovaMockAdapter} from '@alova/mock';
|
||||||
|
import {mockGroups} from '@/mocks/index.mock';
|
||||||
|
|
||||||
|
|
||||||
|
// 创建axios适配器
|
||||||
|
const httpAdapter = axiosRequestAdapter();
|
||||||
|
|
||||||
|
// 创建mock适配器
|
||||||
|
const mockAdapter = createAlovaMockAdapter<AlovaAxiosRequestConfig, AxiosResponse, AxiosResponseHeaders>(mockGroups, {
|
||||||
|
// 全局控制是否启用mock接口,默认为true
|
||||||
|
enable: import.meta.env.VITE_NODE_ENV === 'development',
|
||||||
|
|
||||||
|
// 非模拟请求适配器,用于未匹配mock接口时发送请求
|
||||||
|
httpAdapter,
|
||||||
|
|
||||||
|
// mock接口响应延迟,单位毫秒
|
||||||
|
delay: 500,
|
||||||
|
|
||||||
|
matchMode: "methodurl",
|
||||||
|
|
||||||
|
// 是否打印mock接口请求信息
|
||||||
|
mockRequestLogger: import.meta.env.DEV,
|
||||||
|
|
||||||
|
// 模拟接口回调,适配axios响应格式
|
||||||
|
onMockResponse: (response, _request, currentMethod) => {
|
||||||
|
if (import.meta.env.DEV) {
|
||||||
|
console.log('🚀 Mock响应:', {
|
||||||
|
url: currentMethod.url,
|
||||||
|
method: currentMethod.type,
|
||||||
|
response: response.body || response
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建AxiosResponse格式的响应
|
||||||
|
const axiosResponse: AxiosResponse = {
|
||||||
|
data: response.body || response,
|
||||||
|
status: response.status || 200,
|
||||||
|
statusText: response.statusText || 'OK',
|
||||||
|
headers: response.responseHeaders || {},
|
||||||
|
config: {} as any,
|
||||||
|
request: {} as any
|
||||||
|
};
|
||||||
|
|
||||||
|
// 转换headers格式以兼容AxiosResponseHeaders
|
||||||
|
const headers: AxiosResponseHeaders = {} as AxiosResponseHeaders;
|
||||||
|
if (response.responseHeaders) {
|
||||||
|
Object.assign(headers, response.responseHeaders);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
response: axiosResponse,
|
||||||
|
headers
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
// 模拟错误回调
|
||||||
|
onMockError: (error, currentMethod) => {
|
||||||
|
console.error('❌ Mock错误:', error, currentMethod?.url);
|
||||||
|
return {
|
||||||
|
name: error.name,
|
||||||
|
message: error.message,
|
||||||
|
stack: error.stack
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const {onAuthRequired, onResponseRefreshToken} = createServerTokenAuthentication<typeof VueHook,
|
||||||
|
typeof mockAdapter>({
|
||||||
|
refreshTokenOnSuccess: {
|
||||||
|
// 在请求前触发,将接收到method参数,并返回boolean表示token是否过期
|
||||||
|
isExpired: (_response: any, _method: any) => {
|
||||||
|
return false
|
||||||
|
},
|
||||||
|
|
||||||
|
// 当token过期时触发,在此函数中触发刷新token
|
||||||
|
handler: async () => {
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export const request = createAlova({
|
||||||
|
timeout: 10000,
|
||||||
|
baseURL: import.meta.env.VITE_APP_BASE_API,
|
||||||
|
statesHook: VueHook,
|
||||||
|
// 使用mock适配器,在生产环境自动切换到http适配器
|
||||||
|
requestAdapter: import.meta.env.VITE_NODE_ENV === 'development' ? mockAdapter : httpAdapter,
|
||||||
|
l2Cache: localforageStorageAdapter,
|
||||||
|
cacheLogger: import.meta.env.VITE_NODE_ENV === 'development',
|
||||||
|
cacheFor: null,
|
||||||
|
// 设置全局的请求拦截器
|
||||||
|
beforeRequest: onAuthRequired(async (method: any) => {
|
||||||
|
// 从localStorage获取token并设置到请求头
|
||||||
|
const token = localStorage.getItem('auth-token');
|
||||||
|
if (token) {
|
||||||
|
method.config.headers.Authorization = `Bearer ${token}`;
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
// 响应拦截器
|
||||||
|
responded: onResponseRefreshToken({
|
||||||
|
onSuccess: async (response: any, _method: any) => {
|
||||||
|
if (response.data instanceof Blob) {
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理标准API响应格式 { code, message, data }
|
||||||
|
if (response.data && typeof response.data === 'object' && 'code' in response.data) {
|
||||||
|
if (response.data.code === 200) {
|
||||||
|
return response.data.data;
|
||||||
|
} else {
|
||||||
|
// 抛出业务错误
|
||||||
|
throw new Error(response.data.message || '请求失败');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
onError:
|
||||||
|
(error: any, _method: any) => {
|
||||||
|
return Promise.reject(error);
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
373
src/views/auth/LoginPage.vue
Normal file
373
src/views/auth/LoginPage.vue
Normal file
@@ -0,0 +1,373 @@
|
|||||||
|
<template>
|
||||||
|
<div class="login-page">
|
||||||
|
<!-- 背景装饰 -->
|
||||||
|
<div class="bg-decoration">
|
||||||
|
<div class="decoration-item decoration-1"></div>
|
||||||
|
<div class="decoration-item decoration-2"></div>
|
||||||
|
<div class="decoration-item decoration-3"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="login-container">
|
||||||
|
<!-- 登录表头 -->
|
||||||
|
<div class="login-header">
|
||||||
|
<div class="logo-wrapper">
|
||||||
|
<div class="logo-bg">
|
||||||
|
<img src="/vite.svg" alt="朱子文化" class="logo" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<h1 class="title">朱子文化管理后台</h1>
|
||||||
|
<p class="subtitle">传承千年文化,弘扬朱子精神</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 登录表单 -->
|
||||||
|
<div class="login-form-wrapper">
|
||||||
|
<a-form
|
||||||
|
:model="loginForm"
|
||||||
|
:rules="loginRules"
|
||||||
|
@finish="handleLogin"
|
||||||
|
layout="vertical"
|
||||||
|
size="large"
|
||||||
|
:colon="false"
|
||||||
|
>
|
||||||
|
<a-form-item name="username">
|
||||||
|
<template #label>
|
||||||
|
<span class="form-label">用户名</span>
|
||||||
|
</template>
|
||||||
|
<a-input
|
||||||
|
v-model:value="loginForm.username"
|
||||||
|
placeholder="请输入用户名"
|
||||||
|
class="form-input"
|
||||||
|
>
|
||||||
|
<template #prefix>
|
||||||
|
<UserOutlined class="input-icon" />
|
||||||
|
</template>
|
||||||
|
</a-input>
|
||||||
|
</a-form-item>
|
||||||
|
|
||||||
|
<a-form-item name="password">
|
||||||
|
<template #label>
|
||||||
|
<span class="form-label">密码</span>
|
||||||
|
</template>
|
||||||
|
<a-input-password
|
||||||
|
v-model:value="loginForm.password"
|
||||||
|
placeholder="请输入密码"
|
||||||
|
class="form-input"
|
||||||
|
>
|
||||||
|
<template #prefix>
|
||||||
|
<LockOutlined class="input-icon" />
|
||||||
|
</template>
|
||||||
|
</a-input-password>
|
||||||
|
</a-form-item>
|
||||||
|
|
||||||
|
<a-form-item class="submit-item">
|
||||||
|
<a-button
|
||||||
|
type="primary"
|
||||||
|
html-type="submit"
|
||||||
|
block
|
||||||
|
:loading="loginLoading"
|
||||||
|
class="login-btn"
|
||||||
|
>
|
||||||
|
立即登录
|
||||||
|
</a-button>
|
||||||
|
</a-form-item>
|
||||||
|
</a-form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { reactive } from 'vue';
|
||||||
|
import { useRouter } from 'vue-router';
|
||||||
|
import { message } from 'ant-design-vue';
|
||||||
|
import { UserOutlined, LockOutlined } from '@ant-design/icons-vue';
|
||||||
|
import { useRequest } from 'alova/client';
|
||||||
|
import { adminLogin } from '@/apis/auth';
|
||||||
|
import type { LoginParams } from '@/apis/auth';
|
||||||
|
import { useAuthStore } from '@/stores/auth';
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
const authStore = useAuthStore();
|
||||||
|
|
||||||
|
// 登录表单
|
||||||
|
const loginForm = reactive<LoginParams>({
|
||||||
|
username: '',
|
||||||
|
password: ''
|
||||||
|
});
|
||||||
|
|
||||||
|
// 登录表单验证规则
|
||||||
|
const loginRules = {
|
||||||
|
username: [
|
||||||
|
{ required: true, message: '请输入用户名', trigger: 'blur' }
|
||||||
|
],
|
||||||
|
password: [
|
||||||
|
{ required: true, message: '请输入密码', trigger: 'blur' }
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
||||||
|
// 登录请求
|
||||||
|
const { loading: loginLoading, send: sendLogin } = useRequest(() => adminLogin(loginForm), {
|
||||||
|
immediate: false
|
||||||
|
});
|
||||||
|
|
||||||
|
// 处理登录
|
||||||
|
const handleLogin = async () => {
|
||||||
|
try {
|
||||||
|
const response = await sendLogin();
|
||||||
|
|
||||||
|
// alova通常会包装响应,我们需要获取data字段
|
||||||
|
const result = response?.data || response;
|
||||||
|
|
||||||
|
if (!result || !result.token) {
|
||||||
|
throw new Error('登录响应数据格式错误');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 保存登录信息
|
||||||
|
authStore.setAuth(result);
|
||||||
|
|
||||||
|
message.success('登录成功!');
|
||||||
|
|
||||||
|
// 等待下一个tick确保状态完全更新
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 100));
|
||||||
|
|
||||||
|
// 跳转到管理后台
|
||||||
|
await router.push('/admin');
|
||||||
|
|
||||||
|
} catch (error: any) {
|
||||||
|
message.error(error.message || '登录失败,请检查用户名和密码');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.login-page {
|
||||||
|
min-height: 100vh;
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 20px;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bg-decoration {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
z-index: 1;
|
||||||
|
|
||||||
|
.decoration-item {
|
||||||
|
position: absolute;
|
||||||
|
background: rgba(255, 255, 255, 0.08);
|
||||||
|
border-radius: 50%;
|
||||||
|
|
||||||
|
&.decoration-1 {
|
||||||
|
width: 300px;
|
||||||
|
height: 300px;
|
||||||
|
top: -150px;
|
||||||
|
right: -150px;
|
||||||
|
animation: float 6s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.decoration-2 {
|
||||||
|
width: 200px;
|
||||||
|
height: 200px;
|
||||||
|
bottom: -100px;
|
||||||
|
left: -100px;
|
||||||
|
animation: float 8s ease-in-out infinite reverse;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.decoration-3 {
|
||||||
|
width: 150px;
|
||||||
|
height: 150px;
|
||||||
|
top: 50%;
|
||||||
|
left: 10%;
|
||||||
|
animation: float 10s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes float {
|
||||||
|
0%, 100% {
|
||||||
|
transform: translateY(0px);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
transform: translateY(-20px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-container {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 420px;
|
||||||
|
background: rgba(255, 255, 255, 0.95);
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
border-radius: 20px;
|
||||||
|
padding: 48px 40px;
|
||||||
|
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.15);
|
||||||
|
position: relative;
|
||||||
|
z-index: 2;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-header {
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 40px;
|
||||||
|
|
||||||
|
.logo-wrapper {
|
||||||
|
margin-bottom: 24px;
|
||||||
|
|
||||||
|
.logo-bg {
|
||||||
|
width: 80px;
|
||||||
|
height: 80px;
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
border-radius: 20px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
margin: 0 auto;
|
||||||
|
box-shadow: 0 8px 20px rgba(102, 126, 234, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
filter: brightness(0) invert(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
font-size: 28px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #333;
|
||||||
|
margin: 0 0 8px 0;
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
-webkit-background-clip: text;
|
||||||
|
-webkit-text-fill-color: transparent;
|
||||||
|
background-clip: text;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subtitle {
|
||||||
|
color: #666;
|
||||||
|
font-size: 15px;
|
||||||
|
margin: 0;
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-form-wrapper {
|
||||||
|
margin-bottom: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-label {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-input {
|
||||||
|
height: 50px !important;
|
||||||
|
border-radius: 12px !important;
|
||||||
|
border: 2px solid #e8e8e8 !important;
|
||||||
|
font-size: 15px;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
background: #fafafa;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
border-color: #667eea !important;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:focus,
|
||||||
|
&.ant-input-focused {
|
||||||
|
border-color: #667eea !important;
|
||||||
|
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1) !important;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-icon {
|
||||||
|
color: #999;
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.ant-form-item) {
|
||||||
|
margin-bottom: 24px;
|
||||||
|
|
||||||
|
.ant-form-item-label {
|
||||||
|
padding-bottom: 8px;
|
||||||
|
|
||||||
|
label {
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.ant-form-item-control-input {
|
||||||
|
min-height: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ant-input-password {
|
||||||
|
height: 50px !important;
|
||||||
|
border-radius: 12px !important;
|
||||||
|
border: 2px solid #e8e8e8 !important;
|
||||||
|
font-size: 15px;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
background: #fafafa;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
border-color: #667eea !important;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:focus-within {
|
||||||
|
border-color: #667eea !important;
|
||||||
|
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1) !important;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.submit-item {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-btn {
|
||||||
|
height: 50px !important;
|
||||||
|
border-radius: 12px !important;
|
||||||
|
font-size: 16px !important;
|
||||||
|
font-weight: 600 !important;
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%) !important;
|
||||||
|
border: none !important;
|
||||||
|
box-shadow: 0 4px 15px rgba(102, 126, 234, 0.4);
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 6px 20px rgba(102, 126, 234, 0.5) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.ant-btn-loading {
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* 响应式设计 */
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.login-container {
|
||||||
|
margin: 20px;
|
||||||
|
padding: 32px 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-header .title {
|
||||||
|
font-size: 24px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
242
src/views/banners/BannerPage.vue
Normal file
242
src/views/banners/BannerPage.vue
Normal file
@@ -0,0 +1,242 @@
|
|||||||
|
<template>
|
||||||
|
<div class="banner-page">
|
||||||
|
<div class="page-header">
|
||||||
|
<h1 class="page-title">轮播图管理</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="page-content">
|
||||||
|
<!-- 搜索和操作栏 -->
|
||||||
|
<div class="toolbar">
|
||||||
|
<div class="search-section">
|
||||||
|
<a-input-search
|
||||||
|
v-model:value="searchKeyword"
|
||||||
|
placeholder="搜索轮播图标题"
|
||||||
|
style="width: 300px"
|
||||||
|
@search="handleSearch"
|
||||||
|
/>
|
||||||
|
<a-select
|
||||||
|
v-model:value="filterStatus"
|
||||||
|
placeholder="状态"
|
||||||
|
style="width: 100px; margin-left: 8px"
|
||||||
|
allow-clear
|
||||||
|
@change="handleSearch"
|
||||||
|
>
|
||||||
|
<a-select-option value="enabled">启用</a-select-option>
|
||||||
|
<a-select-option value="disabled">禁用</a-select-option>
|
||||||
|
</a-select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="action-section">
|
||||||
|
<a-button type="primary" @click="showCreateModal">
|
||||||
|
<PlusOutlined />
|
||||||
|
新增轮播图
|
||||||
|
</a-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 轮播图列表 -->
|
||||||
|
<BannerList
|
||||||
|
:loading="loading"
|
||||||
|
:data-source="banners"
|
||||||
|
:pagination="pagination"
|
||||||
|
@edit="handleEdit"
|
||||||
|
@delete="handleDelete"
|
||||||
|
@status-change="handleStatusChange"
|
||||||
|
@sort-change="handleSortChange"
|
||||||
|
@page-change="handlePageChange"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 新增/编辑轮播图弹窗 -->
|
||||||
|
<BannerForm
|
||||||
|
v-model:visible="formVisible"
|
||||||
|
:form-data="currentBanner"
|
||||||
|
:is-edit="isEditMode"
|
||||||
|
@success="handleFormSuccess"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, reactive, onMounted } from 'vue';
|
||||||
|
import { message, Modal } from 'ant-design-vue';
|
||||||
|
import { PlusOutlined } from '@ant-design/icons-vue';
|
||||||
|
import { useRequest } from 'alova/client';
|
||||||
|
import { getBannerList, deleteBanner, updateBannerStatus, updateBannerSort } from '@/apis/banners';
|
||||||
|
import type { Banner, BannerQueryParams } from '@/apis/banners';
|
||||||
|
|
||||||
|
// 导入子组件
|
||||||
|
import BannerList from './components/BannerList.vue';
|
||||||
|
import BannerForm from './components/BannerForm.vue';
|
||||||
|
|
||||||
|
// 搜索筛选参数
|
||||||
|
const searchKeyword = ref('');
|
||||||
|
const filterStatus = ref<string>();
|
||||||
|
|
||||||
|
// 列表数据
|
||||||
|
const banners = ref<Banner[]>([]);
|
||||||
|
|
||||||
|
// 分页
|
||||||
|
const pagination = reactive({
|
||||||
|
current: 1,
|
||||||
|
pageSize: 10,
|
||||||
|
total: 0,
|
||||||
|
showSizeChanger: true,
|
||||||
|
showQuickJumper: true,
|
||||||
|
showTotal: (total: number) => `共 ${total} 条记录`
|
||||||
|
});
|
||||||
|
|
||||||
|
// 表单相关
|
||||||
|
const formVisible = ref(false);
|
||||||
|
const isEditMode = ref(false);
|
||||||
|
const currentBanner = ref<Partial<Banner>>({});
|
||||||
|
|
||||||
|
// 获取轮播图列表
|
||||||
|
const { loading, send: fetchBanners } = useRequest((params: BannerQueryParams) => getBannerList(params), {
|
||||||
|
immediate: false
|
||||||
|
});
|
||||||
|
|
||||||
|
// 搜索处理
|
||||||
|
const handleSearch = () => {
|
||||||
|
pagination.current = 1;
|
||||||
|
loadBanners();
|
||||||
|
};
|
||||||
|
|
||||||
|
// 加载轮播图列表
|
||||||
|
const loadBanners = async () => {
|
||||||
|
try {
|
||||||
|
const params: BannerQueryParams = {
|
||||||
|
page: pagination.current,
|
||||||
|
pageSize: pagination.pageSize,
|
||||||
|
keyword: searchKeyword.value || undefined,
|
||||||
|
status: filterStatus.value as any
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await fetchBanners(params);
|
||||||
|
banners.value = result.list;
|
||||||
|
pagination.total = result.total;
|
||||||
|
} catch (error: any) {
|
||||||
|
message.error(error.message || '获取轮播图列表失败');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 新增轮播图
|
||||||
|
const showCreateModal = () => {
|
||||||
|
currentBanner.value = {};
|
||||||
|
isEditMode.value = false;
|
||||||
|
formVisible.value = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 编辑轮播图
|
||||||
|
const handleEdit = (record: Banner) => {
|
||||||
|
currentBanner.value = { ...record };
|
||||||
|
isEditMode.value = true;
|
||||||
|
formVisible.value = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 删除轮播图
|
||||||
|
const handleDelete = (record: Banner) => {
|
||||||
|
Modal.confirm({
|
||||||
|
title: '确认删除',
|
||||||
|
content: `确定要删除轮播图"${record.title}"吗?此操作不可恢复。`,
|
||||||
|
okText: '删除',
|
||||||
|
okType: 'danger',
|
||||||
|
cancelText: '取消',
|
||||||
|
async onOk() {
|
||||||
|
try {
|
||||||
|
await deleteBanner(record.id);
|
||||||
|
message.success('删除成功');
|
||||||
|
loadBanners();
|
||||||
|
} catch (error: any) {
|
||||||
|
message.error(error.message || '删除失败');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// 状态变更
|
||||||
|
const handleStatusChange = async (record: Banner, status: 'enabled' | 'disabled') => {
|
||||||
|
try {
|
||||||
|
await updateBannerStatus(record.id, status);
|
||||||
|
message.success(`${status === 'enabled' ? '启用' : '禁用'}成功`);
|
||||||
|
loadBanners();
|
||||||
|
} catch (error: any) {
|
||||||
|
message.error(error.message || '状态更新失败');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 排序变更
|
||||||
|
const handleSortChange = async (record: Banner, sort: number) => {
|
||||||
|
try {
|
||||||
|
await updateBannerSort(record.id, sort);
|
||||||
|
message.success('排序更新成功');
|
||||||
|
loadBanners();
|
||||||
|
} catch (error: any) {
|
||||||
|
message.error(error.message || '排序更新失败');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 表单成功回调
|
||||||
|
const handleFormSuccess = () => {
|
||||||
|
formVisible.value = false;
|
||||||
|
loadBanners();
|
||||||
|
};
|
||||||
|
|
||||||
|
// 分页改变
|
||||||
|
const handlePageChange = (page: number, pageSize: number) => {
|
||||||
|
pagination.current = page;
|
||||||
|
pagination.pageSize = pageSize;
|
||||||
|
loadBanners();
|
||||||
|
};
|
||||||
|
|
||||||
|
// 初始化
|
||||||
|
onMounted(() => {
|
||||||
|
loadBanners();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.banner-page {
|
||||||
|
.page-header {
|
||||||
|
background: #fff;
|
||||||
|
padding: 12px 24px;
|
||||||
|
border-radius: 8px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
|
||||||
|
|
||||||
|
.page-title {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #262626;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
padding: 16px;
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
|
||||||
|
|
||||||
|
.search-section {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-section {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
416
src/views/banners/components/BannerForm.vue
Normal file
416
src/views/banners/components/BannerForm.vue
Normal file
@@ -0,0 +1,416 @@
|
|||||||
|
<template>
|
||||||
|
<a-modal
|
||||||
|
:open="visible"
|
||||||
|
:title="isEdit ? '编辑轮播图' : '新增轮播图'"
|
||||||
|
:width="900"
|
||||||
|
:confirm-loading="loading"
|
||||||
|
@ok="handleSubmit"
|
||||||
|
@cancel="handleCancel"
|
||||||
|
>
|
||||||
|
<a-form
|
||||||
|
ref="formRef"
|
||||||
|
:model="form"
|
||||||
|
:rules="rules"
|
||||||
|
layout="vertical"
|
||||||
|
:label-col="{ span: 24 }"
|
||||||
|
>
|
||||||
|
<a-row :gutter="16">
|
||||||
|
<a-col :span="16">
|
||||||
|
<a-form-item label="轮播图标题" name="title">
|
||||||
|
<a-input
|
||||||
|
v-model:value="form.title"
|
||||||
|
placeholder="请输入轮播图标题"
|
||||||
|
:maxlength="50"
|
||||||
|
show-count
|
||||||
|
/>
|
||||||
|
</a-form-item>
|
||||||
|
</a-col>
|
||||||
|
|
||||||
|
<a-col :span="4">
|
||||||
|
<a-form-item label="排序" name="sort">
|
||||||
|
<a-input-number
|
||||||
|
v-model:value="form.sort"
|
||||||
|
:min="1"
|
||||||
|
:max="999"
|
||||||
|
placeholder="排序"
|
||||||
|
style="width: 100%"
|
||||||
|
/>
|
||||||
|
</a-form-item>
|
||||||
|
</a-col>
|
||||||
|
|
||||||
|
<a-col :span="4">
|
||||||
|
<a-form-item label="状态" name="status">
|
||||||
|
<a-select
|
||||||
|
v-model:value="form.status"
|
||||||
|
placeholder="选择状态"
|
||||||
|
>
|
||||||
|
<a-select-option value="enabled">启用</a-select-option>
|
||||||
|
<a-select-option value="disabled">禁用</a-select-option>
|
||||||
|
</a-select>
|
||||||
|
</a-form-item>
|
||||||
|
</a-col>
|
||||||
|
</a-row>
|
||||||
|
|
||||||
|
<!-- 轮播图图片上传 -->
|
||||||
|
<a-row :gutter="16">
|
||||||
|
<a-col :span="24">
|
||||||
|
<a-form-item label="轮播图图片" name="image">
|
||||||
|
<div class="image-upload-section">
|
||||||
|
<a-upload
|
||||||
|
v-model:file-list="imageFileList"
|
||||||
|
list-type="picture-card"
|
||||||
|
:before-upload="beforeImageUpload"
|
||||||
|
:on-remove="handleImageRemove"
|
||||||
|
accept="image/*"
|
||||||
|
:max-count="1"
|
||||||
|
class="banner-upload"
|
||||||
|
>
|
||||||
|
<div v-if="!form.image" class="upload-placeholder">
|
||||||
|
<PlusOutlined />
|
||||||
|
<div style="margin-top: 8px">上传轮播图</div>
|
||||||
|
<div class="upload-tips">建议尺寸: 800x400</div>
|
||||||
|
</div>
|
||||||
|
</a-upload>
|
||||||
|
|
||||||
|
<!-- 图片预览 -->
|
||||||
|
<div v-if="form.image" class="image-preview">
|
||||||
|
<a-image
|
||||||
|
:src="form.image"
|
||||||
|
:alt="form.title"
|
||||||
|
:width="160"
|
||||||
|
:height="90"
|
||||||
|
:preview="true"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</a-form-item>
|
||||||
|
</a-col>
|
||||||
|
</a-row>
|
||||||
|
|
||||||
|
<!-- 链接类型选择 -->
|
||||||
|
<a-row :gutter="16">
|
||||||
|
<a-col :span="24">
|
||||||
|
<a-form-item label="链接类型" name="linkType">
|
||||||
|
<a-radio-group
|
||||||
|
v-model:value="form.linkType"
|
||||||
|
@change="handleLinkTypeChange"
|
||||||
|
>
|
||||||
|
<a-radio value="url">外部链接</a-radio>
|
||||||
|
<a-radio value="article">文章内容</a-radio>
|
||||||
|
</a-radio-group>
|
||||||
|
</a-form-item>
|
||||||
|
</a-col>
|
||||||
|
</a-row>
|
||||||
|
|
||||||
|
<!-- 外部链接 -->
|
||||||
|
<a-row v-if="form.linkType === 'url'" :gutter="16">
|
||||||
|
<a-col :span="24">
|
||||||
|
<a-form-item label="跳转链接" name="linkUrl">
|
||||||
|
<a-input
|
||||||
|
v-model:value="form.linkUrl"
|
||||||
|
placeholder="请输入完整的URL地址,如:https://www.example.com"
|
||||||
|
:maxlength="200"
|
||||||
|
/>
|
||||||
|
</a-form-item>
|
||||||
|
</a-col>
|
||||||
|
</a-row>
|
||||||
|
|
||||||
|
<!-- 文章内容 -->
|
||||||
|
<a-row v-if="form.linkType === 'article'" :gutter="16">
|
||||||
|
<a-col :span="24">
|
||||||
|
<a-form-item label="文章内容" name="articleContent">
|
||||||
|
<RichEditor
|
||||||
|
v-model="form.articleContent"
|
||||||
|
placeholder="请输入文章内容,支持富文本格式"
|
||||||
|
:height="400"
|
||||||
|
:maxlength="5000"
|
||||||
|
/>
|
||||||
|
</a-form-item>
|
||||||
|
</a-col>
|
||||||
|
</a-row>
|
||||||
|
</a-form>
|
||||||
|
</a-modal>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, reactive, watch, nextTick } from 'vue';
|
||||||
|
import { message } from 'ant-design-vue';
|
||||||
|
import { PlusOutlined } from '@ant-design/icons-vue';
|
||||||
|
import { useRequest } from 'alova/client';
|
||||||
|
import { createBanner, updateBanner, uploadBannerImage } from '@/apis/banners';
|
||||||
|
import type { Banner, CreateBannerParams } from '@/apis/banners';
|
||||||
|
import RichEditor from '@/components/RichEditor.vue';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
visible: boolean;
|
||||||
|
formData: Partial<Banner>;
|
||||||
|
isEdit: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Emits {
|
||||||
|
(e: 'update:visible', visible: boolean): void;
|
||||||
|
(e: 'success'): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<Props>();
|
||||||
|
const emit = defineEmits<Emits>();
|
||||||
|
|
||||||
|
const formRef = ref();
|
||||||
|
const imageFileList = ref<any[]>([]);
|
||||||
|
|
||||||
|
// 表单数据
|
||||||
|
const form = reactive<CreateBannerParams>({
|
||||||
|
title: '',
|
||||||
|
image: '',
|
||||||
|
linkType: 'url',
|
||||||
|
linkUrl: '',
|
||||||
|
articleContent: '',
|
||||||
|
sort: 1,
|
||||||
|
status: 'enabled'
|
||||||
|
});
|
||||||
|
|
||||||
|
// 表单验证规则
|
||||||
|
const rules = {
|
||||||
|
title: [
|
||||||
|
{ required: true, message: '请输入轮播图标题', trigger: 'blur' },
|
||||||
|
{ min: 2, max: 50, message: '标题长度应为2-50个字符', trigger: 'blur' }
|
||||||
|
],
|
||||||
|
image: [
|
||||||
|
{ required: true, message: '请上传轮播图图片', trigger: 'change' }
|
||||||
|
],
|
||||||
|
sort: [
|
||||||
|
{ required: true, message: '请输入排序', trigger: 'blur' },
|
||||||
|
{ type: 'number', min: 1, max: 999, message: '排序应为1-999之间的数字', trigger: 'blur' }
|
||||||
|
],
|
||||||
|
status: [
|
||||||
|
{ required: true, message: '请选择状态', trigger: 'change' }
|
||||||
|
],
|
||||||
|
linkType: [
|
||||||
|
{ required: true, message: '请选择链接类型', trigger: 'change' }
|
||||||
|
],
|
||||||
|
linkUrl: [
|
||||||
|
{
|
||||||
|
validator: (_rule: any, value: string) => {
|
||||||
|
if (form.linkType === 'url') {
|
||||||
|
if (!value) {
|
||||||
|
return Promise.reject(new Error('请输入跳转链接'));
|
||||||
|
}
|
||||||
|
if (!/^https?:\/\/.+/.test(value)) {
|
||||||
|
return Promise.reject(new Error('请输入有效的URL地址'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Promise.resolve();
|
||||||
|
},
|
||||||
|
trigger: 'blur'
|
||||||
|
}
|
||||||
|
],
|
||||||
|
articleContent: [
|
||||||
|
{
|
||||||
|
validator: (_rule: any, value: string) => {
|
||||||
|
if (form.linkType === 'article') {
|
||||||
|
if (!value || value.trim() === '') {
|
||||||
|
return Promise.reject(new Error('请输入文章内容'));
|
||||||
|
}
|
||||||
|
if (value.length < 20) {
|
||||||
|
return Promise.reject(new Error('文章内容至少需要20个字符'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Promise.resolve();
|
||||||
|
},
|
||||||
|
trigger: 'blur'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
||||||
|
// 提交请求
|
||||||
|
const { loading, send: submitForm } = useRequest(
|
||||||
|
() => {
|
||||||
|
const api = props.isEdit && props.formData.id
|
||||||
|
? updateBanner(props.formData.id, form)
|
||||||
|
: createBanner(form);
|
||||||
|
return api;
|
||||||
|
},
|
||||||
|
{ immediate: false }
|
||||||
|
);
|
||||||
|
|
||||||
|
// 监听表单数据变化
|
||||||
|
watch(() => props.formData, (newData) => {
|
||||||
|
if (newData && Object.keys(newData).length > 0) {
|
||||||
|
Object.assign(form, {
|
||||||
|
title: newData.title || '',
|
||||||
|
image: newData.image || '',
|
||||||
|
linkType: newData.linkType || 'url',
|
||||||
|
linkUrl: newData.linkUrl || '',
|
||||||
|
articleContent: newData.articleContent || '',
|
||||||
|
sort: newData.sort || 1,
|
||||||
|
status: newData.status || 'enabled'
|
||||||
|
});
|
||||||
|
|
||||||
|
// 设置图片列表
|
||||||
|
if (newData.image) {
|
||||||
|
imageFileList.value = [{
|
||||||
|
uid: '-1',
|
||||||
|
name: 'banner.jpg',
|
||||||
|
status: 'done',
|
||||||
|
url: newData.image
|
||||||
|
}];
|
||||||
|
} else {
|
||||||
|
imageFileList.value = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, { immediate: true, deep: true });
|
||||||
|
|
||||||
|
// 监听可见性变化
|
||||||
|
watch(() => props.visible, (visible) => {
|
||||||
|
if (!visible) {
|
||||||
|
resetForm();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 链接类型变化处理
|
||||||
|
const handleLinkTypeChange = () => {
|
||||||
|
// 清空对应字段的验证状态
|
||||||
|
nextTick(() => {
|
||||||
|
if (form.linkType === 'url') {
|
||||||
|
formRef.value?.clearValidate(['linkUrl']);
|
||||||
|
form.articleContent = '';
|
||||||
|
} else {
|
||||||
|
formRef.value?.clearValidate(['articleContent']);
|
||||||
|
form.linkUrl = '';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// 图片上传前检查
|
||||||
|
const beforeImageUpload = async (file: File) => {
|
||||||
|
const isImage = file.type.startsWith('image/');
|
||||||
|
if (!isImage) {
|
||||||
|
message.error('只能上传图片文件!');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isLt5M = file.size / 1024 / 1024 < 5;
|
||||||
|
if (!isLt5M) {
|
||||||
|
message.error('图片大小不能超过5MB!');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 模拟上传到服务器
|
||||||
|
const response = await uploadBannerImage(file);
|
||||||
|
form.image = response.url;
|
||||||
|
message.success('图片上传成功');
|
||||||
|
} catch (error: any) {
|
||||||
|
message.error(error.message || '图片上传失败');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false; // 阻止默认上传行为
|
||||||
|
};
|
||||||
|
|
||||||
|
// 移除图片
|
||||||
|
const handleImageRemove = () => {
|
||||||
|
form.image = '';
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
// 重置表单
|
||||||
|
const resetForm = () => {
|
||||||
|
Object.assign(form, {
|
||||||
|
title: '',
|
||||||
|
image: '',
|
||||||
|
linkType: 'url',
|
||||||
|
linkUrl: '',
|
||||||
|
articleContent: '',
|
||||||
|
sort: 1,
|
||||||
|
status: 'enabled'
|
||||||
|
});
|
||||||
|
imageFileList.value = [];
|
||||||
|
formRef.value?.resetFields();
|
||||||
|
};
|
||||||
|
|
||||||
|
// 提交表单
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
try {
|
||||||
|
await formRef.value.validateFields();
|
||||||
|
|
||||||
|
await submitForm();
|
||||||
|
|
||||||
|
message.success(props.isEdit ? '编辑成功' : '创建成功');
|
||||||
|
emit('success');
|
||||||
|
} catch (error: any) {
|
||||||
|
if (error.errorFields) {
|
||||||
|
// 表单验证错误
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
message.error(error.message || (props.isEdit ? '编辑失败' : '创建失败'));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 取消
|
||||||
|
const handleCancel = () => {
|
||||||
|
emit('update:visible', false);
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.image-upload-section {
|
||||||
|
display: flex;
|
||||||
|
gap: 16px;
|
||||||
|
align-items: flex-start;
|
||||||
|
|
||||||
|
.banner-upload {
|
||||||
|
:deep(.ant-upload-select-picture-card) {
|
||||||
|
width: 160px;
|
||||||
|
height: 90px;
|
||||||
|
|
||||||
|
.upload-placeholder {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
height: 100%;
|
||||||
|
|
||||||
|
.upload-tips {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #999;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.ant-upload-list-picture-card .ant-upload-list-item) {
|
||||||
|
width: 160px;
|
||||||
|
height: 90px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-preview {
|
||||||
|
:deep(.ant-image) {
|
||||||
|
border-radius: 6px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* 增强富文本编辑器样式 */
|
||||||
|
:deep(.ant-input) {
|
||||||
|
&:focus,
|
||||||
|
&:hover {
|
||||||
|
border-color: #40a9ff;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 响应式布局 */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.image-upload-section {
|
||||||
|
flex-direction: column;
|
||||||
|
|
||||||
|
.banner-upload {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
444
src/views/banners/components/BannerList.vue
Normal file
444
src/views/banners/components/BannerList.vue
Normal file
@@ -0,0 +1,444 @@
|
|||||||
|
<template>
|
||||||
|
<a-card class="banner-list-card">
|
||||||
|
<a-table
|
||||||
|
:columns="columns"
|
||||||
|
:data-source="dataSource"
|
||||||
|
:loading="loading"
|
||||||
|
:pagination="paginationConfig"
|
||||||
|
row-key="id"
|
||||||
|
@change="handleTableChange"
|
||||||
|
>
|
||||||
|
<!-- 轮播图预览 -->
|
||||||
|
<template #bodyCell="{ column, record }">
|
||||||
|
<template v-if="column.key === 'image'">
|
||||||
|
<div class="banner-preview">
|
||||||
|
<a-image
|
||||||
|
:src="record.image"
|
||||||
|
:alt="record.title"
|
||||||
|
:width="80"
|
||||||
|
:height="45"
|
||||||
|
:preview="{ mask: true }"
|
||||||
|
placeholder
|
||||||
|
fallback="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAMIAAADDCAYAAADQvc6UAAABRWlDQ1BJQ0MgUHJvZmlsZQAAKJFjYGASSSwoyGFhYGDIzSspCnJ3UoiIjFJgf8LAwSDCIMogwMCcmFxc4BgQ4ANUwgCjUcG3awyMIPqyLsis7PPOq3QdDFcvjV3jOD1boQVTPQrgSkktTgbSf4A4LbmgqISBgTEFyFYuLykAsTuAbJEioKOA7DkgdjqEvQHEToKwj4DVhAQ5A9k3gGyB5IxEoBmML4BsnSQk8XQkNtReEOBxcfXxUQg1Mjc0dyHgXNJBSWpFCYh2zi+oLMpMzyhRcASGUqqCZ16yno6CkYGRAQMDKMwhqj/fAIcloxgHQqxAjIHBEugw5sUIsSQpBobtQPdLciLEVJYzMPBHMDBsayhILEqEO4DxG0txmrERhM29nYGBddr//5/DGRjYNRkY/l7////39v///y4Dmn+LgeHANwDrkl1AuO+pmgAAADhlWElmTU0AKgAAAAgAAYdpAAQAAAABAAAAGgAAAAAAAqACAAQAAAABAAAAwqADAAQAAAABAAAAwwAAAAD9b/HnAAAHlklEQVR4Ae3dP3Ik1RnG4W+2C1r/IAtoyYRdOQN4A0hOJPYQsB9H5HFI4XBnYnkJwKuwJNd7AiF3PF+F5uKfhG9n/6gEtqo6c6Z7ut+3n19z4e2uU+8+r2u7n1lCIwAAAAAAAAAAABaEXy5eUjI2NjbAR4CcRQo4CTCKyiLZSAAngDbhiEjgGwEi0C7uVKOiWKPQOJPIzOk/rO0T8B8E3r2EZNJGEFpXIp3+CXYrImuqXXAA4HVOgghcxd3l/cgTnRJgpnvWQ9n8+BX8j4DbBNzNWvKqCwD+z/vF+0HEhb6RjeFf+5y7u6eBvfA58KAAu9Ff74Wl0r1T69/A/9YT4wHxPuDbKuFP9N9vAPgb/vcdCCNuWMsAAAAASUVORK5CYII="
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template v-else-if="column.key === 'title'">
|
||||||
|
<div class="banner-info">
|
||||||
|
<h4>{{ record.title }}</h4>
|
||||||
|
<div class="banner-meta">
|
||||||
|
<a-tag :color="getLinkTypeColor(record.linkType)">
|
||||||
|
{{ getLinkTypeText(record.linkType) }}
|
||||||
|
</a-tag>
|
||||||
|
<span v-if="record.linkType === 'url'" class="link-url">
|
||||||
|
{{ record.linkUrl }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template v-else-if="column.key === 'sort'">
|
||||||
|
<a-input-number
|
||||||
|
:value="record.sort"
|
||||||
|
:min="1"
|
||||||
|
:max="999"
|
||||||
|
size="small"
|
||||||
|
@change="(value: number) => handleSortChange(record, value)"
|
||||||
|
@blur="handleSortBlur"
|
||||||
|
style="width: 80px"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template v-else-if="column.key === 'status'">
|
||||||
|
<a-switch
|
||||||
|
:checked="record.status === 'enabled'"
|
||||||
|
:loading="statusChanging === record.id"
|
||||||
|
@change="(checked: boolean) => handleStatusChange(record, checked)"
|
||||||
|
>
|
||||||
|
<template #checkedChildren>启用</template>
|
||||||
|
<template #unCheckedChildren>禁用</template>
|
||||||
|
</a-switch>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template v-else-if="column.key === 'createTime'">
|
||||||
|
<div class="time-info">
|
||||||
|
{{ formatTime(record.createTime) }}
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template v-else-if="column.key === 'action'">
|
||||||
|
<div class="action-buttons">
|
||||||
|
<a-button type="link" size="small" @click="handlePreview(record)">
|
||||||
|
<EyeOutlined />
|
||||||
|
预览
|
||||||
|
</a-button>
|
||||||
|
<a-button type="link" size="small" @click="$emit('edit', record)">
|
||||||
|
<EditOutlined />
|
||||||
|
编辑
|
||||||
|
</a-button>
|
||||||
|
<a-button type="link" size="small" danger @click="$emit('delete', record)">
|
||||||
|
<DeleteOutlined />
|
||||||
|
删除
|
||||||
|
</a-button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</template>
|
||||||
|
</a-table>
|
||||||
|
|
||||||
|
<!-- 预览弹窗 -->
|
||||||
|
<a-modal
|
||||||
|
v-model:open="previewVisible"
|
||||||
|
:title="`预览: ${previewBanner?.title}`"
|
||||||
|
:width="900"
|
||||||
|
:footer="null"
|
||||||
|
>
|
||||||
|
<div v-if="previewBanner" class="banner-preview-modal">
|
||||||
|
<div class="preview-image">
|
||||||
|
<a-image
|
||||||
|
:src="previewBanner.image"
|
||||||
|
:alt="previewBanner.title"
|
||||||
|
width="100%"
|
||||||
|
style="max-height: 400px; object-fit: cover; border-radius: 8px;"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="preview-info">
|
||||||
|
<h3>{{ previewBanner.title }}</h3>
|
||||||
|
|
||||||
|
<div class="info-row">
|
||||||
|
<strong>链接类型:</strong>
|
||||||
|
<a-tag :color="getLinkTypeColor(previewBanner.linkType)">
|
||||||
|
{{ getLinkTypeText(previewBanner.linkType) }}
|
||||||
|
</a-tag>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="previewBanner.linkType === 'url'" class="info-row">
|
||||||
|
<strong>跳转链接:</strong>
|
||||||
|
<a :href="previewBanner.linkUrl" target="_blank" rel="noopener noreferrer">
|
||||||
|
{{ previewBanner.linkUrl }}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="previewBanner.linkType === 'article'" class="info-row">
|
||||||
|
<strong>文章内容:</strong>
|
||||||
|
<div class="article-content" v-html="previewBanner.articleContent"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="info-row">
|
||||||
|
<strong>排序:</strong>
|
||||||
|
<span>{{ previewBanner.sort }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="info-row">
|
||||||
|
<strong>状态:</strong>
|
||||||
|
<a-tag :color="previewBanner.status === 'enabled' ? 'green' : 'red'">
|
||||||
|
{{ previewBanner.status === 'enabled' ? '启用' : '禁用' }}
|
||||||
|
</a-tag>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</a-modal>
|
||||||
|
</a-card>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed } from 'vue';
|
||||||
|
import { EyeOutlined, EditOutlined, DeleteOutlined } from '@ant-design/icons-vue';
|
||||||
|
import type { Banner } from '@/apis/banners';
|
||||||
|
import type { TableColumnsType } from 'ant-design-vue';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
loading?: boolean;
|
||||||
|
dataSource: Banner[];
|
||||||
|
pagination: {
|
||||||
|
current: number;
|
||||||
|
pageSize: number;
|
||||||
|
total: number;
|
||||||
|
showSizeChanger?: boolean;
|
||||||
|
showQuickJumper?: boolean;
|
||||||
|
showTotal?: (total: number) => string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Emits {
|
||||||
|
(e: 'edit', record: Banner): void;
|
||||||
|
(e: 'delete', record: Banner): void;
|
||||||
|
(e: 'status-change', record: Banner, status: 'enabled' | 'disabled'): void;
|
||||||
|
(e: 'sort-change', record: Banner, sort: number): void;
|
||||||
|
(e: 'page-change', page: number, pageSize: number): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<Props>();
|
||||||
|
const emit = defineEmits<Emits>();
|
||||||
|
|
||||||
|
// 状态变更loading
|
||||||
|
const statusChanging = ref<string>();
|
||||||
|
|
||||||
|
// 预览相关
|
||||||
|
const previewVisible = ref(false);
|
||||||
|
const previewBanner = ref<Banner>();
|
||||||
|
|
||||||
|
// 表格列配置
|
||||||
|
const columns: TableColumnsType = [
|
||||||
|
{
|
||||||
|
title: '轮播图',
|
||||||
|
key: 'image',
|
||||||
|
width: 100,
|
||||||
|
align: 'center'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '标题和链接',
|
||||||
|
key: 'title',
|
||||||
|
width: 300,
|
||||||
|
ellipsis: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '排序',
|
||||||
|
key: 'sort',
|
||||||
|
width: 100,
|
||||||
|
align: 'center'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '状态',
|
||||||
|
key: 'status',
|
||||||
|
width: 100,
|
||||||
|
align: 'center'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '创建时间',
|
||||||
|
key: 'createTime',
|
||||||
|
width: 180,
|
||||||
|
align: 'center'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '操作',
|
||||||
|
key: 'action',
|
||||||
|
width: 180,
|
||||||
|
align: 'center',
|
||||||
|
fixed: 'right'
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
// 分页配置
|
||||||
|
const paginationConfig = computed(() => ({
|
||||||
|
...props.pagination,
|
||||||
|
onChange: (page: number, pageSize: number) => emit('page-change', page, pageSize),
|
||||||
|
onShowSizeChange: (current: number, size: number) => emit('page-change', current, size)
|
||||||
|
}));
|
||||||
|
|
||||||
|
// 表格变化处理
|
||||||
|
const handleTableChange = (pagination: any) => {
|
||||||
|
emit('page-change', pagination.current, pagination.pageSize);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 状态变更处理
|
||||||
|
const handleStatusChange = async (record: Banner, checked: boolean) => {
|
||||||
|
statusChanging.value = record.id;
|
||||||
|
try {
|
||||||
|
const status = checked ? 'enabled' : 'disabled';
|
||||||
|
emit('status-change', record, status);
|
||||||
|
} finally {
|
||||||
|
setTimeout(() => {
|
||||||
|
statusChanging.value = undefined;
|
||||||
|
}, 500);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 排序变更处理
|
||||||
|
const handleSortChange = (record: Banner, value: number | null) => {
|
||||||
|
if (value && value !== record.sort) {
|
||||||
|
emit('sort-change', record, value);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 排序失焦处理
|
||||||
|
const handleSortBlur = () => {
|
||||||
|
// 可以添加一些失焦后的处理逻辑
|
||||||
|
};
|
||||||
|
|
||||||
|
// 预览处理
|
||||||
|
const handlePreview = (record: Banner) => {
|
||||||
|
previewBanner.value = record;
|
||||||
|
previewVisible.value = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 链接类型颜色映射
|
||||||
|
const getLinkTypeColor = (linkType: string) => {
|
||||||
|
const colorMap: Record<string, string> = {
|
||||||
|
url: 'blue',
|
||||||
|
article: 'green'
|
||||||
|
};
|
||||||
|
return colorMap[linkType] || 'default';
|
||||||
|
};
|
||||||
|
|
||||||
|
// 链接类型文本映射
|
||||||
|
const getLinkTypeText = (linkType: string) => {
|
||||||
|
const textMap: Record<string, string> = {
|
||||||
|
url: '外部链接',
|
||||||
|
article: '文章内容'
|
||||||
|
};
|
||||||
|
return textMap[linkType] || linkType;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 时间格式化
|
||||||
|
const formatTime = (time: string) => {
|
||||||
|
return new Date(time).toLocaleString('zh-CN', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: '2-digit',
|
||||||
|
day: '2-digit',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit'
|
||||||
|
});
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.banner-list-card {
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
|
||||||
|
|
||||||
|
:deep(.ant-table) {
|
||||||
|
.ant-table-thead > tr > th {
|
||||||
|
background: #fafafa;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ant-table-tbody > tr > td {
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.banner-preview {
|
||||||
|
:deep(.ant-image) {
|
||||||
|
border-radius: 4px;
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
img {
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.banner-info {
|
||||||
|
h4 {
|
||||||
|
margin: 0 0 8px 0;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #333;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.banner-meta {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
|
||||||
|
.link-url {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #666;
|
||||||
|
max-width: 200px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.time-info {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-buttons {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
justify-content: center;
|
||||||
|
|
||||||
|
.ant-btn-link {
|
||||||
|
padding: 0;
|
||||||
|
height: auto;
|
||||||
|
|
||||||
|
&.ant-btn-dangerous {
|
||||||
|
color: #ff4d4f;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: #ff7875;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 预览弹窗样式
|
||||||
|
.banner-preview-modal {
|
||||||
|
.preview-image {
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-info {
|
||||||
|
.info-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
|
||||||
|
strong {
|
||||||
|
min-width: 80px;
|
||||||
|
margin-right: 12px;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.article-content {
|
||||||
|
flex: 1;
|
||||||
|
padding: 12px;
|
||||||
|
background: #fafafa;
|
||||||
|
border-radius: 6px;
|
||||||
|
max-height: 300px;
|
||||||
|
overflow-y: auto;
|
||||||
|
|
||||||
|
:deep(img) {
|
||||||
|
max-width: 100%;
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(p) {
|
||||||
|
margin-bottom: 8px;
|
||||||
|
|
||||||
|
&:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.banner-list-card {
|
||||||
|
:deep(.ant-table) {
|
||||||
|
.ant-table-thead {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ant-table-tbody > tr > td {
|
||||||
|
display: block;
|
||||||
|
border: none;
|
||||||
|
padding: 8px 16px;
|
||||||
|
|
||||||
|
&:before {
|
||||||
|
content: attr(data-label);
|
||||||
|
font-weight: 600;
|
||||||
|
color: #666;
|
||||||
|
display: inline-block;
|
||||||
|
width: 80px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.ant-table-tbody > tr {
|
||||||
|
border-bottom: 1px solid #f0f0f0;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
438
src/views/classes/ClassPage.vue
Normal file
438
src/views/classes/ClassPage.vue
Normal file
@@ -0,0 +1,438 @@
|
|||||||
|
<template>
|
||||||
|
<div class="class-page">
|
||||||
|
<div class="page-header">
|
||||||
|
<h1 class="page-title">班级管理</h1>
|
||||||
|
</div>
|
||||||
|
<div class="page-content">
|
||||||
|
<div class="toolbar">
|
||||||
|
<div class="search-section">
|
||||||
|
<a-select
|
||||||
|
v-model:value="selectedSchoolId"
|
||||||
|
placeholder="选择学校"
|
||||||
|
style="width: 180px"
|
||||||
|
show-search
|
||||||
|
allow-clear
|
||||||
|
@change="handleSchoolChange"
|
||||||
|
>
|
||||||
|
<a-select-option v-for="school in schools" :key="school.id" :value="school.id">
|
||||||
|
{{ school.name }}
|
||||||
|
</a-select-option>
|
||||||
|
</a-select>
|
||||||
|
<a-select
|
||||||
|
v-model:value="selectedGradeId"
|
||||||
|
placeholder="选择年级"
|
||||||
|
style="width: 150px; margin-left: 8px"
|
||||||
|
allow-clear
|
||||||
|
:disabled="!selectedSchoolId"
|
||||||
|
@change="handleGradeChange"
|
||||||
|
>
|
||||||
|
<a-select-option v-for="grade in grades" :key="grade.id" :value="grade.id">
|
||||||
|
{{ grade.name }}
|
||||||
|
</a-select-option>
|
||||||
|
</a-select>
|
||||||
|
<a-input-search
|
||||||
|
v-model:value="searchKeyword"
|
||||||
|
placeholder="搜索班级名称"
|
||||||
|
style="width: 300px; margin-left: 8px"
|
||||||
|
@search="handleSearch"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="action-section">
|
||||||
|
<a-button @click="handleRefresh">
|
||||||
|
<ReloadOutlined />
|
||||||
|
刷新
|
||||||
|
</a-button>
|
||||||
|
<a-button
|
||||||
|
type="primary"
|
||||||
|
@click="handleAdd"
|
||||||
|
:disabled="!selectedSchoolId || !selectedGradeId"
|
||||||
|
>
|
||||||
|
<PlusOutlined />
|
||||||
|
添加班级
|
||||||
|
</a-button>
|
||||||
|
<a-button @click="showBatchImport" :disabled="!selectedSchoolId || !selectedGradeId">
|
||||||
|
<ImportOutlined />
|
||||||
|
批量导入
|
||||||
|
</a-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="class-list">
|
||||||
|
<a-spin :spinning="loading">
|
||||||
|
<a-table
|
||||||
|
:columns="columns"
|
||||||
|
:data-source="classes"
|
||||||
|
:pagination="pagination"
|
||||||
|
row-key="id"
|
||||||
|
@change="handleTableChange"
|
||||||
|
>
|
||||||
|
<template #bodyCell="{ column, record }">
|
||||||
|
<template v-if="column.key === 'status'">
|
||||||
|
<a-tag :color="record.status === 'active' ? 'green' : 'red'">
|
||||||
|
{{ record.status === 'active' ? '正常' : '禁用' }}
|
||||||
|
</a-tag>
|
||||||
|
</template>
|
||||||
|
<template v-else-if="column.key === 'action'">
|
||||||
|
<a-space>
|
||||||
|
<a-button size="small" @click="handleViewStudents(record)">
|
||||||
|
<TeamOutlined />
|
||||||
|
学生
|
||||||
|
</a-button>
|
||||||
|
<a-button size="small" @click="handleEdit(record)">
|
||||||
|
<EditOutlined />
|
||||||
|
编辑
|
||||||
|
</a-button>
|
||||||
|
<a-button
|
||||||
|
size="small"
|
||||||
|
:type="record.status === 'active' ? 'default' : 'primary'"
|
||||||
|
@click="handleToggleStatus(record)"
|
||||||
|
>
|
||||||
|
{{ record.status === 'active' ? '禁用' : '启用' }}
|
||||||
|
</a-button>
|
||||||
|
<a-popconfirm
|
||||||
|
title="确定删除此班级吗?"
|
||||||
|
@confirm="handleDelete(record)"
|
||||||
|
>
|
||||||
|
<a-button size="small" danger>
|
||||||
|
<DeleteOutlined />
|
||||||
|
删除
|
||||||
|
</a-button>
|
||||||
|
</a-popconfirm>
|
||||||
|
</a-space>
|
||||||
|
</template>
|
||||||
|
</template>
|
||||||
|
</a-table>
|
||||||
|
</a-spin>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 班级表单弹窗 -->
|
||||||
|
<ClassFormModal
|
||||||
|
v-model:open="formVisible"
|
||||||
|
:class-data="currentClass"
|
||||||
|
:school-id="selectedSchoolId"
|
||||||
|
:grade-id="selectedGradeId"
|
||||||
|
@success="handleFormSuccess"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- 学生列表弹窗 -->
|
||||||
|
<ClassStudentsModal
|
||||||
|
v-model:open="studentsVisible"
|
||||||
|
:class-data="currentClass"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- 批量导入弹窗 -->
|
||||||
|
<ClassBatchImport
|
||||||
|
v-model:open="importVisible"
|
||||||
|
:school-id="selectedSchoolId"
|
||||||
|
:grade-id="selectedGradeId"
|
||||||
|
@success="handleImportSuccess"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted, watch } from 'vue';
|
||||||
|
import { message } from 'ant-design-vue';
|
||||||
|
import {
|
||||||
|
ReloadOutlined,
|
||||||
|
PlusOutlined,
|
||||||
|
EditOutlined,
|
||||||
|
DeleteOutlined,
|
||||||
|
TeamOutlined,
|
||||||
|
ImportOutlined
|
||||||
|
} from '@ant-design/icons-vue';
|
||||||
|
import { getClasses, deleteClass, toggleClassStatus } from '@/apis/classes';
|
||||||
|
import { getSchoolList } from '@/apis/schools';
|
||||||
|
import { getGrades } from '@/apis/grades';
|
||||||
|
import ClassFormModal from './components/ClassFormModal.vue';
|
||||||
|
import ClassStudentsModal from './components/ClassStudentsModal.vue';
|
||||||
|
import ClassBatchImport from './components/ClassBatchImport.vue';
|
||||||
|
|
||||||
|
// 数据
|
||||||
|
const loading = ref(false);
|
||||||
|
const selectedSchoolId = ref<string>('');
|
||||||
|
const selectedGradeId = ref<string>('');
|
||||||
|
const searchKeyword = ref('');
|
||||||
|
const classes = ref([]);
|
||||||
|
const schools = ref([]);
|
||||||
|
const grades = ref([]);
|
||||||
|
const formVisible = ref(false);
|
||||||
|
const studentsVisible = ref(false);
|
||||||
|
const importVisible = ref(false);
|
||||||
|
const currentClass = ref(null);
|
||||||
|
|
||||||
|
// 分页配置
|
||||||
|
const pagination = ref({
|
||||||
|
current: 1,
|
||||||
|
pageSize: 10,
|
||||||
|
total: 0,
|
||||||
|
showSizeChanger: true,
|
||||||
|
showQuickJumper: true,
|
||||||
|
showTotal: (total: number) => `共 ${total} 条记录`
|
||||||
|
});
|
||||||
|
|
||||||
|
// 表格列配置
|
||||||
|
const columns = [
|
||||||
|
{
|
||||||
|
title: '班级名称',
|
||||||
|
dataIndex: 'name',
|
||||||
|
key: 'name',
|
||||||
|
width: 150
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '班级代码',
|
||||||
|
dataIndex: 'code',
|
||||||
|
key: 'code',
|
||||||
|
width: 120
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '学校名称',
|
||||||
|
dataIndex: 'schoolName',
|
||||||
|
key: 'schoolName',
|
||||||
|
width: 160
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '年级名称',
|
||||||
|
dataIndex: 'gradeName',
|
||||||
|
key: 'gradeName',
|
||||||
|
width: 120
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '班主任',
|
||||||
|
dataIndex: 'teacherName',
|
||||||
|
key: 'teacherName',
|
||||||
|
width: 120
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '学生数量',
|
||||||
|
dataIndex: 'studentCount',
|
||||||
|
key: 'studentCount',
|
||||||
|
width: 100
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '状态',
|
||||||
|
dataIndex: 'status',
|
||||||
|
key: 'status',
|
||||||
|
width: 80
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '创建时间',
|
||||||
|
dataIndex: 'createdAt',
|
||||||
|
key: 'createdAt',
|
||||||
|
width: 160
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '操作',
|
||||||
|
key: 'action',
|
||||||
|
width: 250,
|
||||||
|
fixed: 'right'
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
// 方法
|
||||||
|
const loadSchools = async () => {
|
||||||
|
try {
|
||||||
|
const result = await getSchoolList({ page: 1, pageSize: 100 });
|
||||||
|
schools.value = result.list || [];
|
||||||
|
} catch (error) {
|
||||||
|
console.error('加载学校列表失败:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadGrades = async () => {
|
||||||
|
if (!selectedSchoolId.value) {
|
||||||
|
grades.value = [];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await getGrades({
|
||||||
|
schoolId: selectedSchoolId.value,
|
||||||
|
page: 1,
|
||||||
|
pageSize: 100
|
||||||
|
});
|
||||||
|
grades.value = result.list || [];
|
||||||
|
} catch (error) {
|
||||||
|
console.error('加载年级列表失败:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadClasses = async () => {
|
||||||
|
if (!selectedSchoolId.value || !selectedGradeId.value) {
|
||||||
|
classes.value = [];
|
||||||
|
pagination.value.total = 0;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
loading.value = true;
|
||||||
|
try {
|
||||||
|
const params = {
|
||||||
|
schoolId: selectedSchoolId.value,
|
||||||
|
gradeId: selectedGradeId.value,
|
||||||
|
page: pagination.value.current,
|
||||||
|
pageSize: pagination.value.pageSize,
|
||||||
|
keyword: searchKeyword.value
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await getClasses(params);
|
||||||
|
classes.value = result.list || [];
|
||||||
|
pagination.value.total = result.total || 0;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('加载班级列表失败:', error);
|
||||||
|
message.error('加载班级列表失败');
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSchoolChange = () => {
|
||||||
|
selectedGradeId.value = '';
|
||||||
|
grades.value = [];
|
||||||
|
classes.value = [];
|
||||||
|
pagination.value.current = 1;
|
||||||
|
pagination.value.total = 0;
|
||||||
|
if (selectedSchoolId.value) {
|
||||||
|
loadGrades();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleGradeChange = () => {
|
||||||
|
pagination.value.current = 1;
|
||||||
|
loadClasses();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSearch = () => {
|
||||||
|
pagination.value.current = 1;
|
||||||
|
loadClasses();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRefresh = () => {
|
||||||
|
loadClasses();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAdd = () => {
|
||||||
|
currentClass.value = null;
|
||||||
|
formVisible.value = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEdit = (classData: any) => {
|
||||||
|
currentClass.value = classData;
|
||||||
|
formVisible.value = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleViewStudents = (classData: any) => {
|
||||||
|
currentClass.value = classData;
|
||||||
|
studentsVisible.value = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async (classData: any) => {
|
||||||
|
try {
|
||||||
|
await deleteClass(classData.id);
|
||||||
|
message.success('删除成功');
|
||||||
|
loadClasses();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('删除失败:', error);
|
||||||
|
message.error('删除失败');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleToggleStatus = async (classData: any) => {
|
||||||
|
try {
|
||||||
|
await toggleClassStatus(classData.id);
|
||||||
|
message.success(`${classData.status === 'active' ? '禁用' : '启用'}成功`);
|
||||||
|
loadClasses();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('状态切换失败:', error);
|
||||||
|
message.error('状态切换失败');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const showBatchImport = () => {
|
||||||
|
importVisible.value = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTableChange = (pag: any) => {
|
||||||
|
pagination.value.current = pag.current;
|
||||||
|
pagination.value.pageSize = pag.pageSize;
|
||||||
|
loadClasses();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFormSuccess = () => {
|
||||||
|
formVisible.value = false;
|
||||||
|
loadClasses();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleImportSuccess = () => {
|
||||||
|
importVisible.value = false;
|
||||||
|
loadClasses();
|
||||||
|
};
|
||||||
|
|
||||||
|
// 监听
|
||||||
|
watch(() => selectedGradeId.value, () => {
|
||||||
|
if (selectedGradeId.value) {
|
||||||
|
pagination.value.current = 1;
|
||||||
|
loadClasses();
|
||||||
|
} else {
|
||||||
|
classes.value = [];
|
||||||
|
pagination.value.total = 0;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 初始化
|
||||||
|
onMounted(() => {
|
||||||
|
loadSchools();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.class-page {
|
||||||
|
.page-header {
|
||||||
|
background: #fff;
|
||||||
|
padding: 12px 24px;
|
||||||
|
border-radius: 8px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
|
||||||
|
|
||||||
|
.page-title {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #262626;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-content {
|
||||||
|
padding: 24px;
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
padding: 16px;
|
||||||
|
background: #fafafa;
|
||||||
|
border-radius: 6px;
|
||||||
|
|
||||||
|
.search-section {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-section {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.class-list {
|
||||||
|
.ant-table {
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
367
src/views/classes/components/ClassBatchImport.vue
Normal file
367
src/views/classes/components/ClassBatchImport.vue
Normal file
@@ -0,0 +1,367 @@
|
|||||||
|
<template>
|
||||||
|
<a-modal
|
||||||
|
:open="open"
|
||||||
|
title="批量导入班级"
|
||||||
|
@ok="handleImport"
|
||||||
|
@cancel="handleCancel"
|
||||||
|
:confirm-loading="loading"
|
||||||
|
width="600px"
|
||||||
|
>
|
||||||
|
<div class="batch-import">
|
||||||
|
<a-steps :current="currentStep" size="small">
|
||||||
|
<a-step title="下载模板" description="下载Excel导入模板" />
|
||||||
|
<a-step title="上传文件" description="上传填写好的Excel文件" />
|
||||||
|
<a-step title="确认导入" description="检查数据并确认导入" />
|
||||||
|
</a-steps>
|
||||||
|
|
||||||
|
<div class="step-content">
|
||||||
|
<!-- 步骤1: 下载模板 -->
|
||||||
|
<div v-if="currentStep === 0" class="step-download">
|
||||||
|
<a-alert
|
||||||
|
message="请先下载导入模板"
|
||||||
|
description="下载Excel模板文件,按照模板格式填写班级信息"
|
||||||
|
type="info"
|
||||||
|
show-icon
|
||||||
|
style="margin-bottom: 16px"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div class="template-info">
|
||||||
|
<h4>模板说明:</h4>
|
||||||
|
<ul>
|
||||||
|
<li>班级名称:必填,如"一年级1班"</li>
|
||||||
|
<li>班级代码:必填,如"CLASS_1_1"</li>
|
||||||
|
<li>班主任姓名:选填</li>
|
||||||
|
<li>班主任电话:选填,格式为11位手机号</li>
|
||||||
|
<li>教室地址:选填</li>
|
||||||
|
<li>最大学生数:选填,默认40人</li>
|
||||||
|
<li>入学年份:选填,格式为YYYY</li>
|
||||||
|
<li>班级描述:选填</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="template-actions">
|
||||||
|
<a-button type="primary" @click="downloadTemplate">
|
||||||
|
<DownloadOutlined />
|
||||||
|
下载导入模板
|
||||||
|
</a-button>
|
||||||
|
<a-button @click="nextStep" style="margin-left: 8px">
|
||||||
|
下一步
|
||||||
|
</a-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 步骤2: 上传文件 -->
|
||||||
|
<div v-if="currentStep === 1" class="step-upload">
|
||||||
|
<a-alert
|
||||||
|
message="上传Excel文件"
|
||||||
|
description="请选择填写好的Excel文件进行上传"
|
||||||
|
type="info"
|
||||||
|
show-icon
|
||||||
|
style="margin-bottom: 16px"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<a-upload-dragger
|
||||||
|
v-model:file-list="fileList"
|
||||||
|
name="file"
|
||||||
|
:multiple="false"
|
||||||
|
accept=".xlsx,.xls"
|
||||||
|
:before-upload="handleBeforeUpload"
|
||||||
|
@change="handleFileChange"
|
||||||
|
>
|
||||||
|
<p class="ant-upload-drag-icon">
|
||||||
|
<InboxOutlined />
|
||||||
|
</p>
|
||||||
|
<p class="ant-upload-text">点击或拖拽文件到此区域上传</p>
|
||||||
|
<p class="ant-upload-hint">
|
||||||
|
支持.xlsx、.xls格式,文件大小不超过10MB
|
||||||
|
</p>
|
||||||
|
</a-upload-dragger>
|
||||||
|
|
||||||
|
<div class="upload-actions">
|
||||||
|
<a-button @click="prevStep">
|
||||||
|
上一步
|
||||||
|
</a-button>
|
||||||
|
<a-button
|
||||||
|
type="primary"
|
||||||
|
@click="nextStep"
|
||||||
|
:disabled="!uploadFile"
|
||||||
|
style="margin-left: 8px"
|
||||||
|
>
|
||||||
|
下一步
|
||||||
|
</a-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 步骤3: 确认导入 -->
|
||||||
|
<div v-if="currentStep === 2" class="step-confirm">
|
||||||
|
<a-alert
|
||||||
|
message="确认导入信息"
|
||||||
|
description="请确认要导入的班级信息"
|
||||||
|
type="warning"
|
||||||
|
show-icon
|
||||||
|
style="margin-bottom: 16px"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div class="import-info">
|
||||||
|
<div class="info-item">
|
||||||
|
<span class="label">学校:</span>
|
||||||
|
<span class="value">{{ schoolName }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="info-item">
|
||||||
|
<span class="label">年级:</span>
|
||||||
|
<span class="value">{{ gradeName }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="info-item">
|
||||||
|
<span class="label">文件名:</span>
|
||||||
|
<span class="value">{{ uploadFile?.name }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="info-item">
|
||||||
|
<span class="label">文件大小:</span>
|
||||||
|
<span class="value">{{ formatFileSize(uploadFile?.size || 0) }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="confirm-actions">
|
||||||
|
<a-button @click="prevStep">
|
||||||
|
上一步
|
||||||
|
</a-button>
|
||||||
|
<a-button
|
||||||
|
type="primary"
|
||||||
|
@click="handleImport"
|
||||||
|
:loading="loading"
|
||||||
|
style="margin-left: 8px"
|
||||||
|
>
|
||||||
|
确认导入
|
||||||
|
</a-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</a-modal>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed } from 'vue';
|
||||||
|
import { message } from 'ant-design-vue';
|
||||||
|
import {
|
||||||
|
DownloadOutlined,
|
||||||
|
InboxOutlined
|
||||||
|
} from '@ant-design/icons-vue';
|
||||||
|
import { importClasses } from '@/apis/classes';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
open: boolean;
|
||||||
|
schoolId: string;
|
||||||
|
gradeId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Emits {
|
||||||
|
(e: 'update:open', value: boolean): void;
|
||||||
|
(e: 'success'): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<Props>();
|
||||||
|
const emit = defineEmits<Emits>();
|
||||||
|
|
||||||
|
// 数据
|
||||||
|
const loading = ref(false);
|
||||||
|
const currentStep = ref(0);
|
||||||
|
const fileList = ref([]);
|
||||||
|
const uploadFile = ref<File | null>(null);
|
||||||
|
|
||||||
|
// 计算属性
|
||||||
|
const schoolName = computed(() => '朱熹小学'); // 这里应该根据schoolId获取学校名称
|
||||||
|
const gradeName = computed(() => '一年级'); // 这里应该根据gradeId获取年级名称
|
||||||
|
|
||||||
|
// 方法
|
||||||
|
const downloadTemplate = () => {
|
||||||
|
// 创建模板数据
|
||||||
|
const templateData = [
|
||||||
|
['班级名称', '班级代码', '班主任姓名', '班主任电话', '教室地址', '最大学生数', '入学年份', '班级描述'],
|
||||||
|
['一年级1班', 'CLASS_1_1', '张老师', '13800138001', '教学楼A101', '40', '2024', '示例班级描述'],
|
||||||
|
['一年级2班', 'CLASS_1_2', '李老师', '13800138002', '教学楼A102', '40', '2024', '示例班级描述']
|
||||||
|
];
|
||||||
|
|
||||||
|
// 创建CSV内容
|
||||||
|
const csvContent = templateData.map(row => row.join(',')).join('\n');
|
||||||
|
const blob = new Blob(['\ufeff' + csvContent], { type: 'text/csv;charset=utf-8;' });
|
||||||
|
|
||||||
|
// 下载文件
|
||||||
|
const link = document.createElement('a');
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
link.setAttribute('href', url);
|
||||||
|
link.setAttribute('download', '班级导入模板.csv');
|
||||||
|
link.style.visibility = 'hidden';
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
document.body.removeChild(link);
|
||||||
|
|
||||||
|
message.success('模板下载成功');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleBeforeUpload = (file: File) => {
|
||||||
|
const isExcel = file.type === 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' ||
|
||||||
|
file.type === 'application/vnd.ms-excel' ||
|
||||||
|
file.name.endsWith('.xlsx') ||
|
||||||
|
file.name.endsWith('.xls');
|
||||||
|
|
||||||
|
if (!isExcel) {
|
||||||
|
message.error('只能上传Excel文件(.xlsx或.xls)');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isLt10M = file.size / 1024 / 1024 < 10;
|
||||||
|
if (!isLt10M) {
|
||||||
|
message.error('文件大小不能超过10MB');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
uploadFile.value = file;
|
||||||
|
return false; // 阻止自动上传
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFileChange = (info: any) => {
|
||||||
|
const { fileList: newFileList } = info;
|
||||||
|
fileList.value = newFileList.slice(-1); // 只保留最新的一个文件
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleImport = async () => {
|
||||||
|
if (!uploadFile.value) {
|
||||||
|
message.error('请先上传文件');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!props.schoolId || !props.gradeId) {
|
||||||
|
message.error('请先选择学校和年级');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
loading.value = true;
|
||||||
|
try {
|
||||||
|
const result: any = await importClasses({
|
||||||
|
schoolId: props.schoolId,
|
||||||
|
gradeId: props.gradeId,
|
||||||
|
file: uploadFile.value
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result.success === result.total) {
|
||||||
|
message.success(`导入成功,共导入 ${result.success} 个班级`);
|
||||||
|
} else {
|
||||||
|
message.warning(`导入完成,成功 ${result.success} 个,失败 ${result.failed} 个`);
|
||||||
|
}
|
||||||
|
|
||||||
|
emit('success');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('批量导入失败:', error);
|
||||||
|
message.error('批量导入失败');
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const nextStep = () => {
|
||||||
|
if (currentStep.value < 2) {
|
||||||
|
currentStep.value++;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const prevStep = () => {
|
||||||
|
if (currentStep.value > 0) {
|
||||||
|
currentStep.value--;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCancel = () => {
|
||||||
|
emit('update:open', false);
|
||||||
|
resetForm();
|
||||||
|
};
|
||||||
|
|
||||||
|
const resetForm = () => {
|
||||||
|
currentStep.value = 0;
|
||||||
|
fileList.value = [];
|
||||||
|
uploadFile.value = null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatFileSize = (size: number) => {
|
||||||
|
if (size < 1024) {
|
||||||
|
return size + ' B';
|
||||||
|
} else if (size < 1024 * 1024) {
|
||||||
|
return (size / 1024).toFixed(2) + ' KB';
|
||||||
|
} else {
|
||||||
|
return (size / (1024 * 1024)).toFixed(2) + ' MB';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.batch-import {
|
||||||
|
.step-content {
|
||||||
|
margin-top: 24px;
|
||||||
|
min-height: 300px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step-download {
|
||||||
|
.template-info {
|
||||||
|
margin: 16px 0;
|
||||||
|
padding: 16px;
|
||||||
|
background: #f6f8fa;
|
||||||
|
border-radius: 6px;
|
||||||
|
|
||||||
|
h4 {
|
||||||
|
margin: 0 0 8px 0;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
ul {
|
||||||
|
margin: 0;
|
||||||
|
padding-left: 20px;
|
||||||
|
|
||||||
|
li {
|
||||||
|
margin-bottom: 4px;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.template-actions {
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.step-upload {
|
||||||
|
.upload-actions {
|
||||||
|
margin-top: 16px;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.step-confirm {
|
||||||
|
.import-info {
|
||||||
|
margin: 16px 0;
|
||||||
|
padding: 16px;
|
||||||
|
background: #f6f8fa;
|
||||||
|
border-radius: 6px;
|
||||||
|
|
||||||
|
.info-item {
|
||||||
|
display: flex;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
|
||||||
|
.label {
|
||||||
|
width: 80px;
|
||||||
|
color: #666;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.value {
|
||||||
|
flex: 1;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.confirm-actions {
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
266
src/views/classes/components/ClassFormModal.vue
Normal file
266
src/views/classes/components/ClassFormModal.vue
Normal file
@@ -0,0 +1,266 @@
|
|||||||
|
<template>
|
||||||
|
<a-modal
|
||||||
|
:open="open"
|
||||||
|
:title="isEdit ? '编辑班级' : '添加班级'"
|
||||||
|
@ok="handleSubmit"
|
||||||
|
@cancel="handleCancel"
|
||||||
|
:confirm-loading="loading"
|
||||||
|
width="700px"
|
||||||
|
>
|
||||||
|
<a-form
|
||||||
|
ref="formRef"
|
||||||
|
:model="formData"
|
||||||
|
:rules="rules"
|
||||||
|
layout="vertical"
|
||||||
|
class="class-form"
|
||||||
|
>
|
||||||
|
<a-row :gutter="16">
|
||||||
|
<a-col :span="12">
|
||||||
|
<a-form-item label="班级名称" name="name">
|
||||||
|
<a-input v-model:value="formData.name" placeholder="请输入班级名称" />
|
||||||
|
</a-form-item>
|
||||||
|
</a-col>
|
||||||
|
<a-col :span="12">
|
||||||
|
<a-form-item label="班级代码" name="code">
|
||||||
|
<a-input v-model:value="formData.code" placeholder="请输入班级代码" />
|
||||||
|
</a-form-item>
|
||||||
|
</a-col>
|
||||||
|
</a-row>
|
||||||
|
|
||||||
|
<a-row :gutter="16">
|
||||||
|
<a-col :span="12">
|
||||||
|
<a-form-item label="班主任姓名" name="teacherName">
|
||||||
|
<a-input v-model:value="formData.teacherName" placeholder="请输入班主任姓名" />
|
||||||
|
</a-form-item>
|
||||||
|
</a-col>
|
||||||
|
<a-col :span="12">
|
||||||
|
<a-form-item label="班主任电话" name="teacherPhone">
|
||||||
|
<a-input v-model:value="formData.teacherPhone" placeholder="请输入班主任电话" />
|
||||||
|
</a-form-item>
|
||||||
|
</a-col>
|
||||||
|
</a-row>
|
||||||
|
|
||||||
|
<a-row :gutter="16">
|
||||||
|
<a-col :span="12">
|
||||||
|
<a-form-item label="教室地址" name="classroom">
|
||||||
|
<a-input v-model:value="formData.classroom" placeholder="请输入教室地址" />
|
||||||
|
</a-form-item>
|
||||||
|
</a-col>
|
||||||
|
<a-col :span="12">
|
||||||
|
<a-form-item label="排序" name="sort">
|
||||||
|
<a-input-number
|
||||||
|
v-model:value="formData.sort"
|
||||||
|
:min="0"
|
||||||
|
placeholder="数字越小排序越前"
|
||||||
|
style="width: 100%"
|
||||||
|
/>
|
||||||
|
</a-form-item>
|
||||||
|
</a-col>
|
||||||
|
</a-row>
|
||||||
|
|
||||||
|
<a-row :gutter="16">
|
||||||
|
<a-col :span="12">
|
||||||
|
<a-form-item label="最大学生数" name="maxStudents">
|
||||||
|
<a-input-number
|
||||||
|
v-model:value="formData.maxStudents"
|
||||||
|
:min="1"
|
||||||
|
:max="100"
|
||||||
|
placeholder="班级最大学生数"
|
||||||
|
style="width: 100%"
|
||||||
|
/>
|
||||||
|
</a-form-item>
|
||||||
|
</a-col>
|
||||||
|
<a-col :span="12">
|
||||||
|
<a-form-item label="入学年份" name="enrollmentYear">
|
||||||
|
<a-date-picker
|
||||||
|
v-model:value="formData.enrollmentYear"
|
||||||
|
picker="year"
|
||||||
|
placeholder="请选择入学年份"
|
||||||
|
style="width: 100%"
|
||||||
|
/>
|
||||||
|
</a-form-item>
|
||||||
|
</a-col>
|
||||||
|
</a-row>
|
||||||
|
|
||||||
|
<a-form-item label="班级描述" name="description">
|
||||||
|
<a-textarea
|
||||||
|
v-model:value="formData.description"
|
||||||
|
placeholder="请输入班级描述"
|
||||||
|
:rows="3"
|
||||||
|
/>
|
||||||
|
</a-form-item>
|
||||||
|
|
||||||
|
<a-form-item label="状态" name="status">
|
||||||
|
<a-radio-group v-model:value="formData.status">
|
||||||
|
<a-radio value="active">正常</a-radio>
|
||||||
|
<a-radio value="inactive">禁用</a-radio>
|
||||||
|
</a-radio-group>
|
||||||
|
</a-form-item>
|
||||||
|
</a-form>
|
||||||
|
</a-modal>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, watch } from 'vue';
|
||||||
|
import { message, type FormInstance } from 'ant-design-vue';
|
||||||
|
import { createClass, updateClass } from '@/apis/classes';
|
||||||
|
import dayjs from 'dayjs';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
open: boolean;
|
||||||
|
classData?: any;
|
||||||
|
schoolId: string;
|
||||||
|
gradeId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Emits {
|
||||||
|
(e: 'update:open', value: boolean): void;
|
||||||
|
(e: 'success'): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<Props>();
|
||||||
|
const emit = defineEmits<Emits>();
|
||||||
|
|
||||||
|
// 数据
|
||||||
|
const loading = ref(false);
|
||||||
|
const formRef = ref<FormInstance>();
|
||||||
|
|
||||||
|
// 表单数据
|
||||||
|
const formData = ref({
|
||||||
|
name: '',
|
||||||
|
code: '',
|
||||||
|
teacherName: '',
|
||||||
|
teacherPhone: '',
|
||||||
|
classroom: '',
|
||||||
|
sort: 0,
|
||||||
|
maxStudents: 50,
|
||||||
|
enrollmentYear: null,
|
||||||
|
description: '',
|
||||||
|
status: 'active'
|
||||||
|
});
|
||||||
|
|
||||||
|
// 计算属性
|
||||||
|
const isEdit = computed(() => !!props.classData);
|
||||||
|
|
||||||
|
// 表单验证规则
|
||||||
|
const rules = {
|
||||||
|
name: [
|
||||||
|
{ required: true, message: '请输入班级名称', trigger: 'blur' },
|
||||||
|
{ min: 2, max: 30, message: '班级名称长度在2-30个字符', trigger: 'blur' }
|
||||||
|
],
|
||||||
|
code: [
|
||||||
|
{ required: true, message: '请输入班级代码', trigger: 'blur' },
|
||||||
|
{ pattern: /^[A-Za-z0-9_-]+$/, message: '班级代码只能包含字母、数字、下划线和中划线', trigger: 'blur' }
|
||||||
|
],
|
||||||
|
teacherName: [
|
||||||
|
{ max: 20, message: '班主任姓名长度不能超过20个字符', trigger: 'blur' }
|
||||||
|
],
|
||||||
|
teacherPhone: [
|
||||||
|
{ pattern: /^1[3-9]\d{9}$/, message: '请输入正确的手机号码', trigger: 'blur' }
|
||||||
|
],
|
||||||
|
classroom: [
|
||||||
|
{ max: 50, message: '教室地址长度不能超过50个字符', trigger: 'blur' }
|
||||||
|
],
|
||||||
|
sort: [
|
||||||
|
{ required: true, message: '请输入排序值', trigger: 'blur' }
|
||||||
|
],
|
||||||
|
maxStudents: [
|
||||||
|
{ required: true, message: '请输入最大学生数', trigger: 'blur' }
|
||||||
|
],
|
||||||
|
status: [
|
||||||
|
{ required: true, message: '请选择状态', trigger: 'change' }
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
||||||
|
// 方法
|
||||||
|
const resetForm = () => {
|
||||||
|
formData.value = {
|
||||||
|
name: '',
|
||||||
|
code: '',
|
||||||
|
teacherName: '',
|
||||||
|
teacherPhone: '',
|
||||||
|
classroom: '',
|
||||||
|
sort: 0,
|
||||||
|
maxStudents: 50,
|
||||||
|
enrollmentYear: null,
|
||||||
|
description: '',
|
||||||
|
status: 'active'
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
try {
|
||||||
|
await formRef.value?.validate();
|
||||||
|
|
||||||
|
if (!props.schoolId || !props.gradeId) {
|
||||||
|
message.error('请先选择学校和年级');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
loading.value = true;
|
||||||
|
|
||||||
|
const submitData = {
|
||||||
|
...formData.value,
|
||||||
|
schoolId: props.schoolId,
|
||||||
|
gradeId: props.gradeId,
|
||||||
|
enrollmentYear: formData.value.enrollmentYear ? formData.value.enrollmentYear.year() : null
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isEdit.value) {
|
||||||
|
await updateClass(props.classData.id, submitData);
|
||||||
|
message.success('更新成功');
|
||||||
|
} else {
|
||||||
|
await createClass(submitData);
|
||||||
|
message.success('创建成功');
|
||||||
|
}
|
||||||
|
|
||||||
|
emit('success');
|
||||||
|
} catch (error) {
|
||||||
|
if (error?.errorFields) {
|
||||||
|
console.log('表单验证失败:', error);
|
||||||
|
} else {
|
||||||
|
console.error('提交失败:', error);
|
||||||
|
message.error(isEdit.value ? '更新失败' : '创建失败');
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCancel = () => {
|
||||||
|
emit('update:open', false);
|
||||||
|
resetForm();
|
||||||
|
};
|
||||||
|
|
||||||
|
// 监听
|
||||||
|
watch(() => props.open, (visible) => {
|
||||||
|
if (visible) {
|
||||||
|
if (isEdit.value && props.classData) {
|
||||||
|
// 编辑模式,填充数据
|
||||||
|
formData.value = {
|
||||||
|
name: props.classData.name,
|
||||||
|
code: props.classData.code,
|
||||||
|
teacherName: props.classData.teacherName || '',
|
||||||
|
teacherPhone: props.classData.teacherPhone || '',
|
||||||
|
classroom: props.classData.classroom || '',
|
||||||
|
sort: props.classData.sort || 0,
|
||||||
|
maxStudents: props.classData.maxStudents || 50,
|
||||||
|
enrollmentYear: props.classData.enrollmentYear ? dayjs().year(props.classData.enrollmentYear) : null,
|
||||||
|
description: props.classData.description || '',
|
||||||
|
status: props.classData.status || 'active'
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
// 新增模式,重置表单
|
||||||
|
resetForm();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.class-form {
|
||||||
|
.ant-form-item {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
287
src/views/classes/components/ClassStudentsModal.vue
Normal file
287
src/views/classes/components/ClassStudentsModal.vue
Normal file
@@ -0,0 +1,287 @@
|
|||||||
|
<template>
|
||||||
|
<a-modal
|
||||||
|
:open="open"
|
||||||
|
:title="`${classData?.name} - 学生列表`"
|
||||||
|
@cancel="handleCancel"
|
||||||
|
width="900px"
|
||||||
|
:footer="null"
|
||||||
|
>
|
||||||
|
<div class="students-modal">
|
||||||
|
<div class="toolbar">
|
||||||
|
<a-input-search
|
||||||
|
v-model:value="searchKeyword"
|
||||||
|
placeholder="搜索学生姓名或学号"
|
||||||
|
style="width: 300px"
|
||||||
|
@search="handleSearch"
|
||||||
|
/>
|
||||||
|
<div class="actions">
|
||||||
|
<a-button @click="handleRefresh">
|
||||||
|
<ReloadOutlined />
|
||||||
|
刷新
|
||||||
|
</a-button>
|
||||||
|
<a-button type="primary" @click="handleAddStudent">
|
||||||
|
<PlusOutlined />
|
||||||
|
添加学生
|
||||||
|
</a-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<a-spin :spinning="loading">
|
||||||
|
<a-table
|
||||||
|
:columns="columns"
|
||||||
|
:data-source="students"
|
||||||
|
:pagination="pagination"
|
||||||
|
row-key="id"
|
||||||
|
size="small"
|
||||||
|
@change="handleTableChange"
|
||||||
|
>
|
||||||
|
<template #bodyCell="{ column, record }">
|
||||||
|
<template v-if="column.key === 'gender'">
|
||||||
|
<a-tag :color="record.gender === 'male' ? 'blue' : 'pink'">
|
||||||
|
{{ record.gender === 'male' ? '男' : '女' }}
|
||||||
|
</a-tag>
|
||||||
|
</template>
|
||||||
|
<template v-else-if="column.key === 'status'">
|
||||||
|
<a-tag :color="record.status === 'active' ? 'green' : 'red'">
|
||||||
|
{{ record.status === 'active' ? '正常' : '禁用' }}
|
||||||
|
</a-tag>
|
||||||
|
</template>
|
||||||
|
<template v-else-if="column.key === 'action'">
|
||||||
|
<a-space>
|
||||||
|
<a-button size="small" @click="handleEditStudent(record)">
|
||||||
|
<EditOutlined />
|
||||||
|
编辑
|
||||||
|
</a-button>
|
||||||
|
<a-button
|
||||||
|
size="small"
|
||||||
|
:type="record.status === 'active' ? 'default' : 'primary'"
|
||||||
|
@click="handleToggleStudentStatus(record)"
|
||||||
|
>
|
||||||
|
{{ record.status === 'active' ? '禁用' : '启用' }}
|
||||||
|
</a-button>
|
||||||
|
<a-popconfirm
|
||||||
|
title="确定将此学生移出班级吗?"
|
||||||
|
@confirm="handleRemoveStudent(record)"
|
||||||
|
>
|
||||||
|
<a-button size="small" danger>
|
||||||
|
<MinusOutlined />
|
||||||
|
移出
|
||||||
|
</a-button>
|
||||||
|
</a-popconfirm>
|
||||||
|
</a-space>
|
||||||
|
</template>
|
||||||
|
</template>
|
||||||
|
</a-table>
|
||||||
|
</a-spin>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 添加/编辑学生弹窗 -->
|
||||||
|
<StudentFormModal
|
||||||
|
v-model:open="studentFormVisible"
|
||||||
|
:student="currentStudent"
|
||||||
|
:class-id="classData?.id"
|
||||||
|
@success="handleStudentFormSuccess"
|
||||||
|
/>
|
||||||
|
</a-modal>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, watch } from 'vue';
|
||||||
|
import { message } from 'ant-design-vue';
|
||||||
|
import {
|
||||||
|
ReloadOutlined,
|
||||||
|
PlusOutlined,
|
||||||
|
EditOutlined,
|
||||||
|
MinusOutlined
|
||||||
|
} from '@ant-design/icons-vue';
|
||||||
|
import { getClassStudents } from '@/apis/classes';
|
||||||
|
import StudentFormModal from './StudentFormModal.vue';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
open: boolean;
|
||||||
|
classData?: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Emits {
|
||||||
|
(e: 'update:open', value: boolean): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<Props>();
|
||||||
|
const emit = defineEmits<Emits>();
|
||||||
|
|
||||||
|
// 数据
|
||||||
|
const loading = ref(false);
|
||||||
|
const searchKeyword = ref('');
|
||||||
|
const students = ref([]);
|
||||||
|
const studentFormVisible = ref(false);
|
||||||
|
const currentStudent = ref(null);
|
||||||
|
|
||||||
|
// 分页配置
|
||||||
|
const pagination = ref({
|
||||||
|
current: 1,
|
||||||
|
pageSize: 10,
|
||||||
|
total: 0,
|
||||||
|
showSizeChanger: true,
|
||||||
|
showQuickJumper: true,
|
||||||
|
showTotal: (total: number) => `共 ${total} 名学生`
|
||||||
|
});
|
||||||
|
|
||||||
|
// 表格列配置
|
||||||
|
const columns = [
|
||||||
|
{
|
||||||
|
title: '学号',
|
||||||
|
dataIndex: 'studentNumber',
|
||||||
|
key: 'studentNumber',
|
||||||
|
width: 120
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '姓名',
|
||||||
|
dataIndex: 'name',
|
||||||
|
key: 'name',
|
||||||
|
width: 100
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '性别',
|
||||||
|
dataIndex: 'gender',
|
||||||
|
key: 'gender',
|
||||||
|
width: 80
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '学生电话',
|
||||||
|
dataIndex: 'phone',
|
||||||
|
key: 'phone',
|
||||||
|
width: 130
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '家长姓名',
|
||||||
|
dataIndex: 'parentName',
|
||||||
|
key: 'parentName',
|
||||||
|
width: 100
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '家长电话',
|
||||||
|
dataIndex: 'parentPhone',
|
||||||
|
key: 'parentPhone',
|
||||||
|
width: 130
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '状态',
|
||||||
|
dataIndex: 'status',
|
||||||
|
key: 'status',
|
||||||
|
width: 80
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '操作',
|
||||||
|
key: 'action',
|
||||||
|
width: 180,
|
||||||
|
fixed: 'right'
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
// 方法
|
||||||
|
const loadStudents = async () => {
|
||||||
|
if (!props.classData?.id) return;
|
||||||
|
|
||||||
|
loading.value = true;
|
||||||
|
try {
|
||||||
|
const params = {
|
||||||
|
page: pagination.value.current,
|
||||||
|
pageSize: pagination.value.pageSize,
|
||||||
|
keyword: searchKeyword.value
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await getClassStudents(props.classData.id, params);
|
||||||
|
students.value = result.list || [];
|
||||||
|
pagination.value.total = result.total || 0;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('加载学生列表失败:', error);
|
||||||
|
message.error('加载学生列表失败');
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSearch = () => {
|
||||||
|
pagination.value.current = 1;
|
||||||
|
loadStudents();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRefresh = () => {
|
||||||
|
loadStudents();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAddStudent = () => {
|
||||||
|
currentStudent.value = null;
|
||||||
|
studentFormVisible.value = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEditStudent = (student: any) => {
|
||||||
|
currentStudent.value = student;
|
||||||
|
studentFormVisible.value = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleToggleStudentStatus = async (student: any) => {
|
||||||
|
try {
|
||||||
|
// 这里应该调用切换学生状态的API
|
||||||
|
message.success(`${student.status === 'active' ? '禁用' : '启用'}成功`);
|
||||||
|
loadStudents();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('状态切换失败:', error);
|
||||||
|
message.error('状态切换失败');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRemoveStudent = async (student: any) => {
|
||||||
|
try {
|
||||||
|
// 这里应该调用移出学生的API
|
||||||
|
message.success('学生移出成功');
|
||||||
|
loadStudents();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('移出学生失败:', error);
|
||||||
|
message.error('移出学生失败');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTableChange = (pag: any) => {
|
||||||
|
pagination.value.current = pag.current;
|
||||||
|
pagination.value.pageSize = pag.pageSize;
|
||||||
|
loadStudents();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleStudentFormSuccess = () => {
|
||||||
|
studentFormVisible.value = false;
|
||||||
|
loadStudents();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCancel = () => {
|
||||||
|
emit('update:open', false);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 监听
|
||||||
|
watch(() => props.open, (visible) => {
|
||||||
|
if (visible && props.classData?.id) {
|
||||||
|
pagination.value.current = 1;
|
||||||
|
searchKeyword.value = '';
|
||||||
|
loadStudents();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.students-modal {
|
||||||
|
.toolbar {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
padding: 12px;
|
||||||
|
background: #fafafa;
|
||||||
|
border-radius: 6px;
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
213
src/views/classes/components/StudentFormModal.vue
Normal file
213
src/views/classes/components/StudentFormModal.vue
Normal file
@@ -0,0 +1,213 @@
|
|||||||
|
<template>
|
||||||
|
<a-modal
|
||||||
|
:open="open"
|
||||||
|
:title="isEdit ? '编辑学生' : '添加学生'"
|
||||||
|
@ok="handleSubmit"
|
||||||
|
@cancel="handleCancel"
|
||||||
|
:confirm-loading="loading"
|
||||||
|
width="600px"
|
||||||
|
>
|
||||||
|
<a-form
|
||||||
|
ref="formRef"
|
||||||
|
:model="formData"
|
||||||
|
:rules="rules"
|
||||||
|
layout="vertical"
|
||||||
|
class="student-form"
|
||||||
|
>
|
||||||
|
<a-row :gutter="16">
|
||||||
|
<a-col :span="12">
|
||||||
|
<a-form-item label="学生姓名" name="name">
|
||||||
|
<a-input v-model:value="formData.name" placeholder="请输入学生姓名" />
|
||||||
|
</a-form-item>
|
||||||
|
</a-col>
|
||||||
|
<a-col :span="12">
|
||||||
|
<a-form-item label="学号" name="studentNumber">
|
||||||
|
<a-input v-model:value="formData.studentNumber" placeholder="请输入学号" />
|
||||||
|
</a-form-item>
|
||||||
|
</a-col>
|
||||||
|
</a-row>
|
||||||
|
|
||||||
|
<a-row :gutter="16">
|
||||||
|
<a-col :span="12">
|
||||||
|
<a-form-item label="性别" name="gender">
|
||||||
|
<a-radio-group v-model:value="formData.gender">
|
||||||
|
<a-radio value="male">男</a-radio>
|
||||||
|
<a-radio value="female">女</a-radio>
|
||||||
|
</a-radio-group>
|
||||||
|
</a-form-item>
|
||||||
|
</a-col>
|
||||||
|
<a-col :span="12">
|
||||||
|
<a-form-item label="学生电话" name="phone">
|
||||||
|
<a-input v-model:value="formData.phone" placeholder="请输入学生电话" />
|
||||||
|
</a-form-item>
|
||||||
|
</a-col>
|
||||||
|
</a-row>
|
||||||
|
|
||||||
|
<a-row :gutter="16">
|
||||||
|
<a-col :span="12">
|
||||||
|
<a-form-item label="家长姓名" name="parentName">
|
||||||
|
<a-input v-model:value="formData.parentName" placeholder="请输入家长姓名" />
|
||||||
|
</a-form-item>
|
||||||
|
</a-col>
|
||||||
|
<a-col :span="12">
|
||||||
|
<a-form-item label="家长电话" name="parentPhone">
|
||||||
|
<a-input v-model:value="formData.parentPhone" placeholder="请输入家长电话" />
|
||||||
|
</a-form-item>
|
||||||
|
</a-col>
|
||||||
|
</a-row>
|
||||||
|
|
||||||
|
<a-form-item label="状态" name="status">
|
||||||
|
<a-radio-group v-model:value="formData.status">
|
||||||
|
<a-radio value="active">正常</a-radio>
|
||||||
|
<a-radio value="inactive">禁用</a-radio>
|
||||||
|
</a-radio-group>
|
||||||
|
</a-form-item>
|
||||||
|
</a-form>
|
||||||
|
</a-modal>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, watch } from 'vue';
|
||||||
|
import { message, type FormInstance } from 'ant-design-vue';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
open: boolean;
|
||||||
|
student?: any;
|
||||||
|
classId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Emits {
|
||||||
|
(e: 'update:open', value: boolean): void;
|
||||||
|
(e: 'success'): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<Props>();
|
||||||
|
const emit = defineEmits<Emits>();
|
||||||
|
|
||||||
|
// 数据
|
||||||
|
const loading = ref(false);
|
||||||
|
const formRef = ref<FormInstance>();
|
||||||
|
|
||||||
|
// 表单数据
|
||||||
|
const formData = ref({
|
||||||
|
name: '',
|
||||||
|
studentNumber: '',
|
||||||
|
gender: 'male',
|
||||||
|
phone: '',
|
||||||
|
parentName: '',
|
||||||
|
parentPhone: '',
|
||||||
|
status: 'active'
|
||||||
|
});
|
||||||
|
|
||||||
|
// 计算属性
|
||||||
|
const isEdit = computed(() => !!props.student);
|
||||||
|
|
||||||
|
// 表单验证规则
|
||||||
|
const rules = {
|
||||||
|
name: [
|
||||||
|
{ required: true, message: '请输入学生姓名', trigger: 'blur' },
|
||||||
|
{ min: 2, max: 20, message: '学生姓名长度在2-20个字符', trigger: 'blur' }
|
||||||
|
],
|
||||||
|
studentNumber: [
|
||||||
|
{ required: true, message: '请输入学号', trigger: 'blur' }
|
||||||
|
],
|
||||||
|
gender: [
|
||||||
|
{ required: true, message: '请选择性别', trigger: 'change' }
|
||||||
|
],
|
||||||
|
phone: [
|
||||||
|
{ pattern: /^1[3-9]\d{9}$/, message: '请输入正确的手机号码', trigger: 'blur' }
|
||||||
|
],
|
||||||
|
parentName: [
|
||||||
|
{ required: true, message: '请输入家长姓名', trigger: 'blur' },
|
||||||
|
{ max: 20, message: '家长姓名长度不能超过20个字符', trigger: 'blur' }
|
||||||
|
],
|
||||||
|
parentPhone: [
|
||||||
|
{ required: true, message: '请输入家长电话', trigger: 'blur' },
|
||||||
|
{ pattern: /^1[3-9]\d{9}$/, message: '请输入正确的手机号码', trigger: 'blur' }
|
||||||
|
],
|
||||||
|
status: [
|
||||||
|
{ required: true, message: '请选择状态', trigger: 'change' }
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
||||||
|
// 方法
|
||||||
|
const resetForm = () => {
|
||||||
|
formData.value = {
|
||||||
|
name: '',
|
||||||
|
studentNumber: '',
|
||||||
|
gender: 'male',
|
||||||
|
phone: '',
|
||||||
|
parentName: '',
|
||||||
|
parentPhone: '',
|
||||||
|
status: 'active'
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
try {
|
||||||
|
await formRef.value?.validate();
|
||||||
|
loading.value = true;
|
||||||
|
|
||||||
|
const submitData = {
|
||||||
|
...formData.value,
|
||||||
|
classId: props.classId
|
||||||
|
};
|
||||||
|
|
||||||
|
// 这里应该调用API接口创建或更新学生
|
||||||
|
// if (isEdit.value) {
|
||||||
|
// await updateStudent(props.student.id, submitData);
|
||||||
|
// } else {
|
||||||
|
// await createStudent(submitData);
|
||||||
|
// }
|
||||||
|
|
||||||
|
// 临时使用submitData避免警告
|
||||||
|
console.log('学生数据:', submitData);
|
||||||
|
|
||||||
|
message.success(isEdit.value ? '更新成功' : '添加成功');
|
||||||
|
emit('success');
|
||||||
|
} catch (error: any) {
|
||||||
|
if (error?.errorFields) {
|
||||||
|
console.log('表单验证失败:', error);
|
||||||
|
} else {
|
||||||
|
console.error('提交失败:', error);
|
||||||
|
message.error(isEdit.value ? '更新失败' : '添加失败');
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCancel = () => {
|
||||||
|
emit('update:open', false);
|
||||||
|
resetForm();
|
||||||
|
};
|
||||||
|
|
||||||
|
// 监听
|
||||||
|
watch(() => props.open, (visible) => {
|
||||||
|
if (visible) {
|
||||||
|
if (isEdit.value && props.student) {
|
||||||
|
// 编辑模式,填充数据
|
||||||
|
formData.value = {
|
||||||
|
name: props.student.name,
|
||||||
|
studentNumber: props.student.studentNumber,
|
||||||
|
gender: props.student.gender || 'male',
|
||||||
|
phone: props.student.phone || '',
|
||||||
|
parentName: props.student.parentName || '',
|
||||||
|
parentPhone: props.student.parentPhone || '',
|
||||||
|
status: props.student.status || 'active'
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
// 新增模式,重置表单
|
||||||
|
resetForm();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.student-form {
|
||||||
|
.ant-form-item {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
106
src/views/dashboard/DashboardPage.vue
Normal file
106
src/views/dashboard/DashboardPage.vue
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
<template>
|
||||||
|
<div class="dashboard-page">
|
||||||
|
<a-page-header title="欢迎使用朱子文化管理后台" />
|
||||||
|
|
||||||
|
<div class="dashboard-content">
|
||||||
|
<a-row :gutter="[24, 24]">
|
||||||
|
<a-col :xs="24" :sm="12" :lg="6">
|
||||||
|
<a-card>
|
||||||
|
<a-statistic
|
||||||
|
title="题目总数"
|
||||||
|
:value="statistics.questionCount"
|
||||||
|
:value-style="{ color: '#3f8600' }"
|
||||||
|
/>
|
||||||
|
</a-card>
|
||||||
|
</a-col>
|
||||||
|
|
||||||
|
<a-col :xs="24" :sm="12" :lg="6">
|
||||||
|
<a-card>
|
||||||
|
<a-statistic
|
||||||
|
title="注册用户"
|
||||||
|
:value="statistics.userCount"
|
||||||
|
:value-style="{ color: '#cf1322' }"
|
||||||
|
/>
|
||||||
|
</a-card>
|
||||||
|
</a-col>
|
||||||
|
|
||||||
|
<a-col :xs="24" :sm="12" :lg="6">
|
||||||
|
<a-card>
|
||||||
|
<a-statistic
|
||||||
|
title="答题记录"
|
||||||
|
:value="statistics.recordCount"
|
||||||
|
:value-style="{ color: '#1890ff' }"
|
||||||
|
/>
|
||||||
|
</a-card>
|
||||||
|
</a-col>
|
||||||
|
|
||||||
|
<a-col :xs="24" :sm="12" :lg="6">
|
||||||
|
<a-card>
|
||||||
|
<a-statistic
|
||||||
|
title="学校数量"
|
||||||
|
:value="statistics.schoolCount"
|
||||||
|
:value-style="{ color: '#722ed1' }"
|
||||||
|
/>
|
||||||
|
</a-card>
|
||||||
|
</a-col>
|
||||||
|
</a-row>
|
||||||
|
|
||||||
|
<a-card title="快速入口" style="margin-top: 24px;">
|
||||||
|
<a-row :gutter="[16, 16]">
|
||||||
|
<a-col :xs="24" :sm="8">
|
||||||
|
<a-button block size="large" @click="$router.push('/admin/questions')">
|
||||||
|
<FileTextOutlined />
|
||||||
|
题库管理
|
||||||
|
</a-button>
|
||||||
|
</a-col>
|
||||||
|
<a-col :xs="24" :sm="8">
|
||||||
|
<a-button block size="large" @click="$router.push('/admin/banners')">
|
||||||
|
<PictureOutlined />
|
||||||
|
轮播图管理
|
||||||
|
</a-button>
|
||||||
|
</a-col>
|
||||||
|
<a-col :xs="24" :sm="8">
|
||||||
|
<a-button block size="large" @click="$router.push('/admin/users')">
|
||||||
|
<UserOutlined />
|
||||||
|
用户管理
|
||||||
|
</a-button>
|
||||||
|
</a-col>
|
||||||
|
</a-row>
|
||||||
|
</a-card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue';
|
||||||
|
import { FileTextOutlined, PictureOutlined, UserOutlined } from '@ant-design/icons-vue';
|
||||||
|
|
||||||
|
// 统计数据
|
||||||
|
const statistics = ref({
|
||||||
|
questionCount: 12,
|
||||||
|
userCount: 156,
|
||||||
|
recordCount: 2341,
|
||||||
|
schoolCount: 8
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.dashboard-page {
|
||||||
|
.dashboard-content {
|
||||||
|
margin-top: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.ant-card) {
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.ant-btn) {
|
||||||
|
height: 48px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
344
src/views/grades/GradePage.vue
Normal file
344
src/views/grades/GradePage.vue
Normal file
@@ -0,0 +1,344 @@
|
|||||||
|
<template>
|
||||||
|
<div class="grade-page">
|
||||||
|
<div class="page-header">
|
||||||
|
<h1 class="page-title">年级管理</h1>
|
||||||
|
</div>
|
||||||
|
<div class="page-content">
|
||||||
|
<div class="toolbar">
|
||||||
|
<div class="search-section">
|
||||||
|
<a-select
|
||||||
|
v-model:value="selectedSchoolId"
|
||||||
|
placeholder="选择学校"
|
||||||
|
style="width: 200px"
|
||||||
|
show-search
|
||||||
|
allow-clear
|
||||||
|
@change="handleSchoolChange"
|
||||||
|
>
|
||||||
|
<a-select-option v-for="school in schools" :key="school.id" :value="school.id">
|
||||||
|
{{ school.name }}
|
||||||
|
</a-select-option>
|
||||||
|
</a-select>
|
||||||
|
<a-input-search
|
||||||
|
v-model:value="searchKeyword"
|
||||||
|
placeholder="搜索年级名称"
|
||||||
|
style="width: 300px; margin-left: 8px"
|
||||||
|
@search="handleSearch"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="action-section">
|
||||||
|
<a-button @click="handleRefresh">
|
||||||
|
<ReloadOutlined />
|
||||||
|
刷新
|
||||||
|
</a-button>
|
||||||
|
<a-button
|
||||||
|
type="primary"
|
||||||
|
@click="handleAdd"
|
||||||
|
:disabled="!selectedSchoolId"
|
||||||
|
>
|
||||||
|
<PlusOutlined />
|
||||||
|
添加年级
|
||||||
|
</a-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grade-list">
|
||||||
|
<a-spin :spinning="loading">
|
||||||
|
<a-table
|
||||||
|
:columns="columns"
|
||||||
|
:data-source="grades"
|
||||||
|
:pagination="pagination"
|
||||||
|
row-key="id"
|
||||||
|
@change="handleTableChange"
|
||||||
|
>
|
||||||
|
<template #bodyCell="{ column, record }">
|
||||||
|
<template v-if="column.key === 'status'">
|
||||||
|
<a-tag :color="record.status === 'active' ? 'green' : 'red'">
|
||||||
|
{{ record.status === 'active' ? '正常' : '禁用' }}
|
||||||
|
</a-tag>
|
||||||
|
</template>
|
||||||
|
<template v-else-if="column.key === 'action'">
|
||||||
|
<a-space>
|
||||||
|
<a-button size="small" @click="handleEdit(record)">
|
||||||
|
<EditOutlined />
|
||||||
|
编辑
|
||||||
|
</a-button>
|
||||||
|
<a-button
|
||||||
|
size="small"
|
||||||
|
:type="record.status === 'active' ? 'default' : 'primary'"
|
||||||
|
@click="handleToggleStatus(record)"
|
||||||
|
>
|
||||||
|
{{ record.status === 'active' ? '禁用' : '启用' }}
|
||||||
|
</a-button>
|
||||||
|
<a-popconfirm
|
||||||
|
title="确定删除此年级吗?"
|
||||||
|
@confirm="handleDelete(record)"
|
||||||
|
>
|
||||||
|
<a-button size="small" danger>
|
||||||
|
<DeleteOutlined />
|
||||||
|
删除
|
||||||
|
</a-button>
|
||||||
|
</a-popconfirm>
|
||||||
|
</a-space>
|
||||||
|
</template>
|
||||||
|
</template>
|
||||||
|
</a-table>
|
||||||
|
</a-spin>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 年级表单弹窗 -->
|
||||||
|
<GradeFormModal
|
||||||
|
v-model:open="formVisible"
|
||||||
|
:grade="currentGrade"
|
||||||
|
:school-id="selectedSchoolId"
|
||||||
|
@success="handleFormSuccess"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted, watch } from 'vue';
|
||||||
|
import { message } from 'ant-design-vue';
|
||||||
|
import {
|
||||||
|
ReloadOutlined,
|
||||||
|
PlusOutlined,
|
||||||
|
EditOutlined,
|
||||||
|
DeleteOutlined
|
||||||
|
} from '@ant-design/icons-vue';
|
||||||
|
import { getGrades, deleteGrade, toggleGradeStatus } from '@/apis/grades';
|
||||||
|
import { getSchoolList } from '@/apis/schools';
|
||||||
|
import GradeFormModal from './components/GradeFormModal.vue';
|
||||||
|
|
||||||
|
// 数据
|
||||||
|
const loading = ref(false);
|
||||||
|
const selectedSchoolId = ref<string>('');
|
||||||
|
const searchKeyword = ref('');
|
||||||
|
const grades = ref([]);
|
||||||
|
const schools = ref([]);
|
||||||
|
const formVisible = ref(false);
|
||||||
|
const currentGrade = ref(null);
|
||||||
|
|
||||||
|
// 分页配置
|
||||||
|
const pagination = ref({
|
||||||
|
current: 1,
|
||||||
|
pageSize: 10,
|
||||||
|
total: 0,
|
||||||
|
showSizeChanger: true,
|
||||||
|
showQuickJumper: true,
|
||||||
|
showTotal: (total: number) => `共 ${total} 条记录`
|
||||||
|
});
|
||||||
|
|
||||||
|
// 表格列配置
|
||||||
|
const columns = [
|
||||||
|
{
|
||||||
|
title: '年级名称',
|
||||||
|
dataIndex: 'name',
|
||||||
|
key: 'name',
|
||||||
|
width: 150
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '年级代码',
|
||||||
|
dataIndex: 'code',
|
||||||
|
key: 'code',
|
||||||
|
width: 120
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '学校名称',
|
||||||
|
dataIndex: 'schoolName',
|
||||||
|
key: 'schoolName',
|
||||||
|
width: 200
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '班级数量',
|
||||||
|
dataIndex: 'classCount',
|
||||||
|
key: 'classCount',
|
||||||
|
width: 100
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '学生数量',
|
||||||
|
dataIndex: 'studentCount',
|
||||||
|
key: 'studentCount',
|
||||||
|
width: 100
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '状态',
|
||||||
|
dataIndex: 'status',
|
||||||
|
key: 'status',
|
||||||
|
width: 80
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '创建时间',
|
||||||
|
dataIndex: 'createdAt',
|
||||||
|
key: 'createdAt',
|
||||||
|
width: 180
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '操作',
|
||||||
|
key: 'action',
|
||||||
|
width: 200,
|
||||||
|
fixed: 'right'
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
// 方法
|
||||||
|
const loadSchools = async () => {
|
||||||
|
try {
|
||||||
|
const result = await getSchoolList({ page: 1, pageSize: 100 });
|
||||||
|
schools.value = result.list || [];
|
||||||
|
} catch (error) {
|
||||||
|
console.error('加载学校列表失败:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadGrades = async () => {
|
||||||
|
if (!selectedSchoolId.value) {
|
||||||
|
grades.value = [];
|
||||||
|
pagination.value.total = 0;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
loading.value = true;
|
||||||
|
try {
|
||||||
|
const params = {
|
||||||
|
schoolId: selectedSchoolId.value,
|
||||||
|
page: pagination.value.current,
|
||||||
|
pageSize: pagination.value.pageSize,
|
||||||
|
keyword: searchKeyword.value
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await getGrades(params);
|
||||||
|
grades.value = result.list || [];
|
||||||
|
pagination.value.total = result.total || 0;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('加载年级列表失败:', error);
|
||||||
|
message.error('加载年级列表失败');
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSchoolChange = () => {
|
||||||
|
pagination.value.current = 1;
|
||||||
|
loadGrades();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSearch = () => {
|
||||||
|
pagination.value.current = 1;
|
||||||
|
loadGrades();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRefresh = () => {
|
||||||
|
loadGrades();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAdd = () => {
|
||||||
|
currentGrade.value = null;
|
||||||
|
formVisible.value = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEdit = (grade: any) => {
|
||||||
|
currentGrade.value = grade;
|
||||||
|
formVisible.value = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async (grade: any) => {
|
||||||
|
try {
|
||||||
|
await deleteGrade(grade.id);
|
||||||
|
message.success('删除成功');
|
||||||
|
loadGrades();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('删除失败:', error);
|
||||||
|
message.error('删除失败');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleToggleStatus = async (grade: any) => {
|
||||||
|
try {
|
||||||
|
await toggleGradeStatus(grade.id);
|
||||||
|
message.success(`${grade.status === 'active' ? '禁用' : '启用'}成功`);
|
||||||
|
loadGrades();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('状态切换失败:', error);
|
||||||
|
message.error('状态切换失败');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTableChange = (pag: any) => {
|
||||||
|
pagination.value.current = pag.current;
|
||||||
|
pagination.value.pageSize = pag.pageSize;
|
||||||
|
loadGrades();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFormSuccess = () => {
|
||||||
|
formVisible.value = false;
|
||||||
|
loadGrades();
|
||||||
|
};
|
||||||
|
|
||||||
|
// 监听
|
||||||
|
watch(() => selectedSchoolId.value, () => {
|
||||||
|
if (selectedSchoolId.value) {
|
||||||
|
pagination.value.current = 1;
|
||||||
|
loadGrades();
|
||||||
|
} else {
|
||||||
|
grades.value = [];
|
||||||
|
pagination.value.total = 0;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 初始化
|
||||||
|
onMounted(() => {
|
||||||
|
loadSchools();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.grade-page {
|
||||||
|
.page-header {
|
||||||
|
background: #fff;
|
||||||
|
padding: 12px 24px;
|
||||||
|
border-radius: 8px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
|
||||||
|
|
||||||
|
.page-title {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #262626;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-content {
|
||||||
|
padding: 24px;
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
padding: 16px;
|
||||||
|
background: #fafafa;
|
||||||
|
border-radius: 6px;
|
||||||
|
|
||||||
|
.search-section {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-section {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.grade-list {
|
||||||
|
.ant-table {
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
206
src/views/grades/components/GradeFormModal.vue
Normal file
206
src/views/grades/components/GradeFormModal.vue
Normal file
@@ -0,0 +1,206 @@
|
|||||||
|
<template>
|
||||||
|
<a-modal
|
||||||
|
:open="open"
|
||||||
|
:title="isEdit ? '编辑年级' : '添加年级'"
|
||||||
|
@ok="handleSubmit"
|
||||||
|
@cancel="handleCancel"
|
||||||
|
:confirm-loading="loading"
|
||||||
|
width="600px"
|
||||||
|
>
|
||||||
|
<a-form
|
||||||
|
ref="formRef"
|
||||||
|
:model="formData"
|
||||||
|
:rules="rules"
|
||||||
|
layout="vertical"
|
||||||
|
class="grade-form"
|
||||||
|
>
|
||||||
|
<a-row :gutter="16">
|
||||||
|
<a-col :span="12">
|
||||||
|
<a-form-item label="年级名称" name="name">
|
||||||
|
<a-input v-model:value="formData.name" placeholder="请输入年级名称" />
|
||||||
|
</a-form-item>
|
||||||
|
</a-col>
|
||||||
|
<a-col :span="12">
|
||||||
|
<a-form-item label="年级代码" name="code">
|
||||||
|
<a-input v-model:value="formData.code" placeholder="请输入年级代码" />
|
||||||
|
</a-form-item>
|
||||||
|
</a-col>
|
||||||
|
</a-row>
|
||||||
|
|
||||||
|
<a-row :gutter="16">
|
||||||
|
<a-col :span="12">
|
||||||
|
<a-form-item label="学制年限" name="duration">
|
||||||
|
<a-input-number
|
||||||
|
v-model:value="formData.duration"
|
||||||
|
:min="1"
|
||||||
|
:max="6"
|
||||||
|
placeholder="学制年限(年)"
|
||||||
|
style="width: 100%"
|
||||||
|
/>
|
||||||
|
</a-form-item>
|
||||||
|
</a-col>
|
||||||
|
<a-col :span="12">
|
||||||
|
<a-form-item label="排序" name="sort">
|
||||||
|
<a-input-number
|
||||||
|
v-model:value="formData.sort"
|
||||||
|
:min="0"
|
||||||
|
placeholder="数字越小排序越前"
|
||||||
|
style="width: 100%"
|
||||||
|
/>
|
||||||
|
</a-form-item>
|
||||||
|
</a-col>
|
||||||
|
</a-row>
|
||||||
|
|
||||||
|
<a-form-item label="年级描述" name="description">
|
||||||
|
<RichEditor
|
||||||
|
v-model="formData.description"
|
||||||
|
placeholder="请输入年级描述,支持富文本格式"
|
||||||
|
:height="200"
|
||||||
|
:maxlength="1000"
|
||||||
|
/>
|
||||||
|
</a-form-item>
|
||||||
|
|
||||||
|
<a-form-item label="状态" name="status">
|
||||||
|
<a-radio-group v-model:value="formData.status">
|
||||||
|
<a-radio value="active">正常</a-radio>
|
||||||
|
<a-radio value="inactive">禁用</a-radio>
|
||||||
|
</a-radio-group>
|
||||||
|
</a-form-item>
|
||||||
|
</a-form>
|
||||||
|
</a-modal>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, watch } from 'vue';
|
||||||
|
import { message, type FormInstance } from 'ant-design-vue';
|
||||||
|
import { createGrade, updateGrade } from '@/apis/grades';
|
||||||
|
import RichEditor from '@/components/RichEditor.vue';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
open: boolean;
|
||||||
|
grade?: any;
|
||||||
|
schoolId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Emits {
|
||||||
|
(e: 'update:open', value: boolean): void;
|
||||||
|
(e: 'success'): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<Props>();
|
||||||
|
const emit = defineEmits<Emits>();
|
||||||
|
|
||||||
|
// 数据
|
||||||
|
const loading = ref(false);
|
||||||
|
const formRef = ref<FormInstance>();
|
||||||
|
|
||||||
|
// 表单数据
|
||||||
|
const formData = ref({
|
||||||
|
name: '',
|
||||||
|
code: '',
|
||||||
|
duration: 3,
|
||||||
|
sort: 0,
|
||||||
|
description: '',
|
||||||
|
status: 'active'
|
||||||
|
});
|
||||||
|
|
||||||
|
// 计算属性
|
||||||
|
const isEdit = computed(() => !!props.grade);
|
||||||
|
|
||||||
|
// 表单验证规则
|
||||||
|
const rules = {
|
||||||
|
name: [
|
||||||
|
{ required: true, message: '请输入年级名称', trigger: 'blur' },
|
||||||
|
{ min: 2, max: 20, message: '年级名称长度在2-20个字符', trigger: 'blur' }
|
||||||
|
],
|
||||||
|
code: [
|
||||||
|
{ required: true, message: '请输入年级代码', trigger: 'blur' },
|
||||||
|
{ pattern: /^[A-Za-z0-9_-]+$/, message: '年级代码只能包含字母、数字、下划线和中划线', trigger: 'blur' }
|
||||||
|
],
|
||||||
|
duration: [
|
||||||
|
{ required: true, message: '请输入学制年限', trigger: 'blur' }
|
||||||
|
],
|
||||||
|
sort: [
|
||||||
|
{ required: true, message: '请输入排序值', trigger: 'blur' }
|
||||||
|
],
|
||||||
|
status: [
|
||||||
|
{ required: true, message: '请选择状态', trigger: 'change' }
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
||||||
|
// 方法
|
||||||
|
const resetForm = () => {
|
||||||
|
formData.value = {
|
||||||
|
name: '',
|
||||||
|
code: '',
|
||||||
|
duration: 3,
|
||||||
|
sort: 0,
|
||||||
|
description: '',
|
||||||
|
status: 'active'
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
try {
|
||||||
|
await formRef.value?.validate();
|
||||||
|
loading.value = true;
|
||||||
|
|
||||||
|
const submitData = {
|
||||||
|
...formData.value,
|
||||||
|
schoolId: props.schoolId
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isEdit.value) {
|
||||||
|
await updateGrade(props.grade.id, submitData);
|
||||||
|
message.success('更新成功');
|
||||||
|
} else {
|
||||||
|
await createGrade(submitData);
|
||||||
|
message.success('创建成功');
|
||||||
|
}
|
||||||
|
|
||||||
|
emit('success');
|
||||||
|
} catch (error) {
|
||||||
|
if (error?.errorFields) {
|
||||||
|
console.log('表单验证失败:', error);
|
||||||
|
} else {
|
||||||
|
console.error('提交失败:', error);
|
||||||
|
message.error(isEdit.value ? '更新失败' : '创建失败');
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCancel = () => {
|
||||||
|
emit('update:open', false);
|
||||||
|
resetForm();
|
||||||
|
};
|
||||||
|
|
||||||
|
// 监听
|
||||||
|
watch(() => props.open, (visible) => {
|
||||||
|
if (visible) {
|
||||||
|
if (isEdit.value && props.grade) {
|
||||||
|
// 编辑模式,填充数据
|
||||||
|
formData.value = {
|
||||||
|
name: props.grade.name,
|
||||||
|
code: props.grade.code,
|
||||||
|
duration: props.grade.duration || 3,
|
||||||
|
sort: props.grade.sort || 0,
|
||||||
|
description: props.grade.description || '',
|
||||||
|
status: props.grade.status || 'active'
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
// 新增模式,重置表单
|
||||||
|
resetForm();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.grade-form {
|
||||||
|
.ant-form-item {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
359
src/views/profile/PasswordPage.vue
Normal file
359
src/views/profile/PasswordPage.vue
Normal file
@@ -0,0 +1,359 @@
|
|||||||
|
<template>
|
||||||
|
<div class="password-page">
|
||||||
|
<div class="page-header">
|
||||||
|
<h1 class="page-title">修改密码</h1>
|
||||||
|
</div>
|
||||||
|
<div class="page-content">
|
||||||
|
<a-row justify="center">
|
||||||
|
<a-col :span="12">
|
||||||
|
<a-card title="密码修改" class="password-card">
|
||||||
|
<a-form
|
||||||
|
ref="formRef"
|
||||||
|
:model="formData"
|
||||||
|
:rules="rules"
|
||||||
|
layout="vertical"
|
||||||
|
@finish="handleChangePassword"
|
||||||
|
>
|
||||||
|
<a-form-item label="当前密码" name="oldPassword">
|
||||||
|
<a-input-password
|
||||||
|
v-model:value="formData.oldPassword"
|
||||||
|
placeholder="请输入当前密码"
|
||||||
|
autocomplete="current-password"
|
||||||
|
>
|
||||||
|
<template #prefix>
|
||||||
|
<LockOutlined class="form-icon" />
|
||||||
|
</template>
|
||||||
|
</a-input-password>
|
||||||
|
</a-form-item>
|
||||||
|
|
||||||
|
<a-form-item label="新密码" name="newPassword">
|
||||||
|
<a-input-password
|
||||||
|
v-model:value="formData.newPassword"
|
||||||
|
placeholder="请输入新密码"
|
||||||
|
autocomplete="new-password"
|
||||||
|
>
|
||||||
|
<template #prefix>
|
||||||
|
<KeyOutlined class="form-icon" />
|
||||||
|
</template>
|
||||||
|
</a-input-password>
|
||||||
|
<div class="password-strength">
|
||||||
|
<div class="strength-bar">
|
||||||
|
<div
|
||||||
|
class="strength-fill"
|
||||||
|
:class="passwordStrengthClass"
|
||||||
|
:style="{ width: passwordStrengthPercent + '%' }"
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
<span class="strength-text">{{ passwordStrengthText }}</span>
|
||||||
|
</div>
|
||||||
|
</a-form-item>
|
||||||
|
|
||||||
|
<a-form-item label="确认新密码" name="confirmPassword">
|
||||||
|
<a-input-password
|
||||||
|
v-model:value="formData.confirmPassword"
|
||||||
|
placeholder="请再次输入新密码"
|
||||||
|
autocomplete="new-password"
|
||||||
|
>
|
||||||
|
<template #prefix>
|
||||||
|
<SafetyOutlined class="form-icon" />
|
||||||
|
</template>
|
||||||
|
</a-input-password>
|
||||||
|
</a-form-item>
|
||||||
|
|
||||||
|
<div class="password-tips">
|
||||||
|
<h4>密码安全建议:</h4>
|
||||||
|
<ul>
|
||||||
|
<li :class="{ 'tip-valid': hasMinLength }">至少8个字符</li>
|
||||||
|
<li :class="{ 'tip-valid': hasUpperCase }">包含大写字母</li>
|
||||||
|
<li :class="{ 'tip-valid': hasLowerCase }">包含小写字母</li>
|
||||||
|
<li :class="{ 'tip-valid': hasNumber }">包含数字</li>
|
||||||
|
<li :class="{ 'tip-valid': hasSpecialChar }">包含特殊字符</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<a-form-item class="submit-section">
|
||||||
|
<a-button
|
||||||
|
type="primary"
|
||||||
|
html-type="submit"
|
||||||
|
:loading="loading"
|
||||||
|
size="large"
|
||||||
|
block
|
||||||
|
>
|
||||||
|
<SaveOutlined />
|
||||||
|
修改密码
|
||||||
|
</a-button>
|
||||||
|
</a-form-item>
|
||||||
|
</a-form>
|
||||||
|
</a-card>
|
||||||
|
</a-col>
|
||||||
|
</a-row>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed } from 'vue';
|
||||||
|
import { message, type FormInstance } from 'ant-design-vue';
|
||||||
|
import {
|
||||||
|
LockOutlined,
|
||||||
|
KeyOutlined,
|
||||||
|
SafetyOutlined,
|
||||||
|
SaveOutlined
|
||||||
|
} from '@ant-design/icons-vue';
|
||||||
|
import { changePassword } from '@/apis/profile';
|
||||||
|
import { useAuthStore } from '@/stores/auth';
|
||||||
|
import { useRouter } from 'vue-router';
|
||||||
|
|
||||||
|
// 数据
|
||||||
|
const loading = ref(false);
|
||||||
|
const formRef = ref<FormInstance>();
|
||||||
|
const authStore = useAuthStore();
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
// 表单数据
|
||||||
|
const formData = ref({
|
||||||
|
oldPassword: '',
|
||||||
|
newPassword: '',
|
||||||
|
confirmPassword: ''
|
||||||
|
});
|
||||||
|
|
||||||
|
// 密码强度检查
|
||||||
|
const hasMinLength = computed(() => formData.value.newPassword.length >= 8);
|
||||||
|
const hasUpperCase = computed(() => /[A-Z]/.test(formData.value.newPassword));
|
||||||
|
const hasLowerCase = computed(() => /[a-z]/.test(formData.value.newPassword));
|
||||||
|
const hasNumber = computed(() => /\d/.test(formData.value.newPassword));
|
||||||
|
const hasSpecialChar = computed(() => /[!@#$%^&*()_+\-=\[\]{};':"\\|,.<>\?]/.test(formData.value.newPassword));
|
||||||
|
|
||||||
|
// 密码强度计算
|
||||||
|
const passwordStrength = computed(() => {
|
||||||
|
let strength = 0;
|
||||||
|
if (hasMinLength.value) strength++;
|
||||||
|
if (hasUpperCase.value) strength++;
|
||||||
|
if (hasLowerCase.value) strength++;
|
||||||
|
if (hasNumber.value) strength++;
|
||||||
|
if (hasSpecialChar.value) strength++;
|
||||||
|
return strength;
|
||||||
|
});
|
||||||
|
|
||||||
|
const passwordStrengthPercent = computed(() => (passwordStrength.value / 5) * 100);
|
||||||
|
|
||||||
|
const passwordStrengthClass = computed(() => {
|
||||||
|
if (passwordStrength.value <= 2) return 'weak';
|
||||||
|
if (passwordStrength.value <= 3) return 'medium';
|
||||||
|
return 'strong';
|
||||||
|
});
|
||||||
|
|
||||||
|
const passwordStrengthText = computed(() => {
|
||||||
|
if (passwordStrength.value <= 2) return '弱';
|
||||||
|
if (passwordStrength.value <= 3) return '中等';
|
||||||
|
return '强';
|
||||||
|
});
|
||||||
|
|
||||||
|
// 表单验证规则
|
||||||
|
const rules = {
|
||||||
|
oldPassword: [
|
||||||
|
{ required: true, message: '请输入当前密码', trigger: 'blur' }
|
||||||
|
],
|
||||||
|
newPassword: [
|
||||||
|
{ required: true, message: '请输入新密码', trigger: 'blur' },
|
||||||
|
{ min: 8, message: '密码至少8个字符', trigger: 'blur' },
|
||||||
|
{
|
||||||
|
validator: (_rule: any, value: string) => {
|
||||||
|
if (!value) return Promise.resolve();
|
||||||
|
|
||||||
|
const checks = [
|
||||||
|
hasMinLength.value,
|
||||||
|
hasUpperCase.value,
|
||||||
|
hasLowerCase.value,
|
||||||
|
hasNumber.value,
|
||||||
|
hasSpecialChar.value
|
||||||
|
];
|
||||||
|
|
||||||
|
const validCount = checks.filter(Boolean).length;
|
||||||
|
if (validCount < 3) {
|
||||||
|
return Promise.reject('密码强度太弱,请包含至少3种字符类型');
|
||||||
|
}
|
||||||
|
|
||||||
|
return Promise.resolve();
|
||||||
|
},
|
||||||
|
trigger: 'blur'
|
||||||
|
}
|
||||||
|
],
|
||||||
|
confirmPassword: [
|
||||||
|
{ required: true, message: '请确认新密码', trigger: 'blur' },
|
||||||
|
{
|
||||||
|
validator: (_rule: any, value: string) => {
|
||||||
|
if (!value) return Promise.resolve();
|
||||||
|
if (value !== formData.value.newPassword) {
|
||||||
|
return Promise.reject('两次输入的密码不一致');
|
||||||
|
}
|
||||||
|
return Promise.resolve();
|
||||||
|
},
|
||||||
|
trigger: 'blur'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
||||||
|
// 方法
|
||||||
|
const handleChangePassword = async () => {
|
||||||
|
try {
|
||||||
|
loading.value = true;
|
||||||
|
|
||||||
|
await changePassword({
|
||||||
|
oldPassword: formData.value.oldPassword,
|
||||||
|
newPassword: formData.value.newPassword
|
||||||
|
});
|
||||||
|
|
||||||
|
message.success('密码修改成功,请重新登录');
|
||||||
|
|
||||||
|
// 清除登录状态,跳转到登录页
|
||||||
|
setTimeout(() => {
|
||||||
|
authStore.logout();
|
||||||
|
router.push('/login');
|
||||||
|
}, 1500);
|
||||||
|
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('修改密码失败:', error);
|
||||||
|
|
||||||
|
if (error.response?.data?.message === 'Invalid old password') {
|
||||||
|
message.error('当前密码错误');
|
||||||
|
} else {
|
||||||
|
message.error('密码修改失败,请稍后重试');
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.password-page {
|
||||||
|
.page-header {
|
||||||
|
background: #fff;
|
||||||
|
padding: 12px 24px;
|
||||||
|
border-radius: 8px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
|
||||||
|
|
||||||
|
.page-title {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #262626;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-content {
|
||||||
|
// 移除多余的 padding 和 margin
|
||||||
|
}
|
||||||
|
|
||||||
|
.password-card {
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||||
|
border-radius: 8px;
|
||||||
|
|
||||||
|
.form-icon {
|
||||||
|
color: #bfbfbf;
|
||||||
|
}
|
||||||
|
|
||||||
|
.password-strength {
|
||||||
|
margin-top: 8px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
|
||||||
|
.strength-bar {
|
||||||
|
flex: 1;
|
||||||
|
height: 4px;
|
||||||
|
background-color: #f5f5f5;
|
||||||
|
border-radius: 2px;
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
.strength-fill {
|
||||||
|
height: 100%;
|
||||||
|
border-radius: 2px;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
|
||||||
|
&.weak {
|
||||||
|
background-color: #ff4d4f;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.medium {
|
||||||
|
background-color: #faad14;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.strong {
|
||||||
|
background-color: #52c41a;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.strength-text {
|
||||||
|
font-size: 12px;
|
||||||
|
min-width: 20px;
|
||||||
|
|
||||||
|
&.weak {
|
||||||
|
color: #ff4d4f;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.medium {
|
||||||
|
color: #faad14;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.strong {
|
||||||
|
color: #52c41a;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.password-tips {
|
||||||
|
background: #f6f8fa;
|
||||||
|
padding: 16px;
|
||||||
|
border-radius: 6px;
|
||||||
|
margin: 16px 0;
|
||||||
|
|
||||||
|
h4 {
|
||||||
|
margin: 0 0 8px 0;
|
||||||
|
color: #666;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
ul {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
list-style: none;
|
||||||
|
|
||||||
|
li {
|
||||||
|
padding: 2px 0;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #999;
|
||||||
|
position: relative;
|
||||||
|
padding-left: 16px;
|
||||||
|
|
||||||
|
&:before {
|
||||||
|
content: '✗';
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
color: #ff4d4f;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.tip-valid {
|
||||||
|
color: #52c41a;
|
||||||
|
|
||||||
|
&:before {
|
||||||
|
content: '✓';
|
||||||
|
color: #52c41a;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.submit-section {
|
||||||
|
margin-top: 32px;
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
362
src/views/profile/ProfilePage.vue
Normal file
362
src/views/profile/ProfilePage.vue
Normal file
@@ -0,0 +1,362 @@
|
|||||||
|
<template>
|
||||||
|
<div class="profile-page">
|
||||||
|
<div class="page-header">
|
||||||
|
<h1 class="page-title">个人信息</h1>
|
||||||
|
</div>
|
||||||
|
<div class="page-content">
|
||||||
|
<a-row :gutter="24">
|
||||||
|
<a-col :span="16">
|
||||||
|
<a-card title="基本信息" class="info-card">
|
||||||
|
<a-form
|
||||||
|
ref="formRef"
|
||||||
|
:model="formData"
|
||||||
|
:rules="rules"
|
||||||
|
layout="vertical"
|
||||||
|
@finish="handleUpdateProfile"
|
||||||
|
>
|
||||||
|
<a-row :gutter="16">
|
||||||
|
<a-col :span="12">
|
||||||
|
<a-form-item label="用户名" name="username">
|
||||||
|
<a-input v-model:value="formData.username" disabled />
|
||||||
|
</a-form-item>
|
||||||
|
</a-col>
|
||||||
|
<a-col :span="12">
|
||||||
|
<a-form-item label="真实姓名" name="realName">
|
||||||
|
<a-input v-model:value="formData.realName" placeholder="请输入真实姓名" />
|
||||||
|
</a-form-item>
|
||||||
|
</a-col>
|
||||||
|
</a-row>
|
||||||
|
|
||||||
|
<a-row :gutter="16">
|
||||||
|
<a-col :span="12">
|
||||||
|
<a-form-item label="手机号码" name="phone">
|
||||||
|
<a-input v-model:value="formData.phone" placeholder="请输入手机号码" />
|
||||||
|
</a-form-item>
|
||||||
|
</a-col>
|
||||||
|
<a-col :span="12">
|
||||||
|
<a-form-item label="邮箱地址" name="email">
|
||||||
|
<a-input v-model:value="formData.email" placeholder="请输入邮箱地址" />
|
||||||
|
</a-form-item>
|
||||||
|
</a-col>
|
||||||
|
</a-row>
|
||||||
|
|
||||||
|
<a-row :gutter="16">
|
||||||
|
<a-col :span="12">
|
||||||
|
<a-form-item label="性别" name="gender">
|
||||||
|
<a-radio-group v-model:value="formData.gender">
|
||||||
|
<a-radio value="male">男</a-radio>
|
||||||
|
<a-radio value="female">女</a-radio>
|
||||||
|
</a-radio-group>
|
||||||
|
</a-form-item>
|
||||||
|
</a-col>
|
||||||
|
<a-col :span="12">
|
||||||
|
<a-form-item label="生日" name="birthday">
|
||||||
|
<a-date-picker
|
||||||
|
v-model:value="formData.birthday"
|
||||||
|
placeholder="请选择生日"
|
||||||
|
style="width: 100%"
|
||||||
|
/>
|
||||||
|
</a-form-item>
|
||||||
|
</a-col>
|
||||||
|
</a-row>
|
||||||
|
|
||||||
|
<a-form-item label="个人简介" name="bio">
|
||||||
|
<a-textarea
|
||||||
|
v-model:value="formData.bio"
|
||||||
|
placeholder="介绍一下自己..."
|
||||||
|
:rows="4"
|
||||||
|
/>
|
||||||
|
</a-form-item>
|
||||||
|
|
||||||
|
<a-form-item>
|
||||||
|
<a-button type="primary" html-type="submit" :loading="loading">
|
||||||
|
<SaveOutlined />
|
||||||
|
保存修改
|
||||||
|
</a-button>
|
||||||
|
</a-form-item>
|
||||||
|
</a-form>
|
||||||
|
</a-card>
|
||||||
|
</a-col>
|
||||||
|
|
||||||
|
<a-col :span="8">
|
||||||
|
<a-card title="头像设置" class="avatar-card">
|
||||||
|
<div class="avatar-section">
|
||||||
|
<a-avatar
|
||||||
|
:size="120"
|
||||||
|
:src="userInfo.avatar"
|
||||||
|
class="user-avatar"
|
||||||
|
>
|
||||||
|
<UserOutlined />
|
||||||
|
</a-avatar>
|
||||||
|
<div class="avatar-actions">
|
||||||
|
<a-upload
|
||||||
|
name="avatar"
|
||||||
|
:show-upload-list="false"
|
||||||
|
:before-upload="handleAvatarUpload"
|
||||||
|
accept=".jpg,.jpeg,.png,.gif"
|
||||||
|
>
|
||||||
|
<a-button type="primary" size="small">
|
||||||
|
<UploadOutlined />
|
||||||
|
更换头像
|
||||||
|
</a-button>
|
||||||
|
</a-upload>
|
||||||
|
<p class="upload-tip">支持 JPG、PNG、GIF 格式,文件大小不超过 2MB</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</a-card>
|
||||||
|
|
||||||
|
<a-card title="账户统计" class="stats-card">
|
||||||
|
<div class="stats-list">
|
||||||
|
<div class="stat-item">
|
||||||
|
<div class="stat-label">注册时间</div>
|
||||||
|
<div class="stat-value">{{ userInfo.createdAt }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-item">
|
||||||
|
<div class="stat-label">最后登录</div>
|
||||||
|
<div class="stat-value">{{ userInfo.lastLoginAt }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-item">
|
||||||
|
<div class="stat-label">登录次数</div>
|
||||||
|
<div class="stat-value">{{ userInfo.loginCount || 0 }} 次</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</a-card>
|
||||||
|
</a-col>
|
||||||
|
</a-row>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted } from 'vue';
|
||||||
|
import { message, type FormInstance } from 'ant-design-vue';
|
||||||
|
import {
|
||||||
|
SaveOutlined,
|
||||||
|
UserOutlined,
|
||||||
|
UploadOutlined
|
||||||
|
} from '@ant-design/icons-vue';
|
||||||
|
import { getUserProfile, updateUserProfile, uploadAvatar } from '@/apis/profile';
|
||||||
|
import { useAuthStore } from '@/stores/auth';
|
||||||
|
import dayjs from 'dayjs';
|
||||||
|
|
||||||
|
// 数据
|
||||||
|
const loading = ref(false);
|
||||||
|
const formRef = ref<FormInstance>();
|
||||||
|
const authStore = useAuthStore();
|
||||||
|
|
||||||
|
// 用户信息
|
||||||
|
const userInfo = ref({
|
||||||
|
id: '',
|
||||||
|
username: '',
|
||||||
|
realName: '',
|
||||||
|
phone: '',
|
||||||
|
email: '',
|
||||||
|
gender: 'male',
|
||||||
|
birthday: null,
|
||||||
|
bio: '',
|
||||||
|
avatar: '',
|
||||||
|
createdAt: '',
|
||||||
|
lastLoginAt: '',
|
||||||
|
loginCount: 0
|
||||||
|
});
|
||||||
|
|
||||||
|
// 表单数据
|
||||||
|
const formData = ref({
|
||||||
|
username: '',
|
||||||
|
realName: '',
|
||||||
|
phone: '',
|
||||||
|
email: '',
|
||||||
|
gender: 'male',
|
||||||
|
birthday: null,
|
||||||
|
bio: ''
|
||||||
|
});
|
||||||
|
|
||||||
|
// 表单验证规则
|
||||||
|
const rules = {
|
||||||
|
realName: [
|
||||||
|
{ required: true, message: '请输入真实姓名', trigger: 'blur' },
|
||||||
|
{ min: 2, max: 20, message: '真实姓名长度在2-20个字符', trigger: 'blur' }
|
||||||
|
],
|
||||||
|
phone: [
|
||||||
|
{ pattern: /^1[3-9]\d{9}$/, message: '请输入正确的手机号码', trigger: 'blur' }
|
||||||
|
],
|
||||||
|
email: [
|
||||||
|
{ type: 'email', message: '请输入正确的邮箱地址', trigger: 'blur' }
|
||||||
|
],
|
||||||
|
gender: [
|
||||||
|
{ required: true, message: '请选择性别', trigger: 'change' }
|
||||||
|
],
|
||||||
|
bio: [
|
||||||
|
{ max: 200, message: '个人简介不能超过200个字符', trigger: 'blur' }
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
||||||
|
// 方法
|
||||||
|
const loadUserProfile = async () => {
|
||||||
|
try {
|
||||||
|
const result = await getUserProfile();
|
||||||
|
userInfo.value = result;
|
||||||
|
|
||||||
|
// 填充表单数据
|
||||||
|
formData.value = {
|
||||||
|
username: result.username,
|
||||||
|
realName: result.realName || '',
|
||||||
|
phone: result.phone || '',
|
||||||
|
email: result.email || '',
|
||||||
|
gender: result.gender || 'male',
|
||||||
|
birthday: result.birthday ? dayjs(result.birthday) : null,
|
||||||
|
bio: result.bio || ''
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error('获取用户信息失败:', error);
|
||||||
|
message.error('获取用户信息失败');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleUpdateProfile = async () => {
|
||||||
|
try {
|
||||||
|
loading.value = true;
|
||||||
|
|
||||||
|
const updateData = {
|
||||||
|
...formData.value,
|
||||||
|
birthday: formData.value.birthday ? formData.value.birthday.format('YYYY-MM-DD') : null
|
||||||
|
};
|
||||||
|
|
||||||
|
await updateUserProfile(updateData);
|
||||||
|
message.success('个人信息更新成功');
|
||||||
|
|
||||||
|
// 重新加载用户信息
|
||||||
|
await loadUserProfile();
|
||||||
|
|
||||||
|
// 更新store中的用户信息
|
||||||
|
authStore.updateUserInfo(userInfo.value);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('更新个人信息失败:', error);
|
||||||
|
message.error('更新个人信息失败');
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAvatarUpload = async (file: File) => {
|
||||||
|
// 检查文件大小 (2MB)
|
||||||
|
if (file.size > 2 * 1024 * 1024) {
|
||||||
|
message.error('文件大小不能超过 2MB');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查文件类型
|
||||||
|
const allowedTypes = ['image/jpeg', 'image/jpg', 'image/png', 'image/gif'];
|
||||||
|
if (!allowedTypes.includes(file.type)) {
|
||||||
|
message.error('只支持 JPG、PNG、GIF 格式的图片');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('avatar', file);
|
||||||
|
|
||||||
|
const result = await uploadAvatar(formData);
|
||||||
|
userInfo.value.avatar = result.url;
|
||||||
|
message.success('头像上传成功');
|
||||||
|
|
||||||
|
// 更新store中的用户信息
|
||||||
|
authStore.updateUserInfo(userInfo.value);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('头像上传失败:', error);
|
||||||
|
message.error('头像上传失败');
|
||||||
|
}
|
||||||
|
|
||||||
|
return false; // 阻止默认上传行为
|
||||||
|
};
|
||||||
|
|
||||||
|
// 初始化
|
||||||
|
onMounted(() => {
|
||||||
|
loadUserProfile();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.profile-page {
|
||||||
|
.page-header {
|
||||||
|
background: #fff;
|
||||||
|
padding: 12px 24px;
|
||||||
|
border-radius: 8px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
|
||||||
|
|
||||||
|
.page-title {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #262626;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-content {
|
||||||
|
// 移除多余的 padding 和 margin
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-card {
|
||||||
|
margin-bottom: 24px;
|
||||||
|
|
||||||
|
.ant-form-item {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar-card {
|
||||||
|
margin-bottom: 24px;
|
||||||
|
|
||||||
|
.avatar-section {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
.user-avatar {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
.upload-tip {
|
||||||
|
margin-top: 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #999;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-card {
|
||||||
|
.stats-list {
|
||||||
|
.stat-item {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 12px 0;
|
||||||
|
border-bottom: 1px solid #f0f0f0;
|
||||||
|
|
||||||
|
&:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-label {
|
||||||
|
color: #666;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-value {
|
||||||
|
font-weight: 500;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
281
src/views/questions/QuestionPage.vue
Normal file
281
src/views/questions/QuestionPage.vue
Normal file
@@ -0,0 +1,281 @@
|
|||||||
|
<template>
|
||||||
|
<div class="question-page">
|
||||||
|
<div class="page-header">
|
||||||
|
<h1 class="page-title">题库管理</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="page-content">
|
||||||
|
<!-- 搜索和操作栏 -->
|
||||||
|
<div class="toolbar">
|
||||||
|
<div class="search-section">
|
||||||
|
<a-input-search
|
||||||
|
v-model:value="searchKeyword"
|
||||||
|
placeholder="搜索题目标题、内容或分类"
|
||||||
|
style="width: 300px"
|
||||||
|
@search="handleSearch"
|
||||||
|
/>
|
||||||
|
<a-select
|
||||||
|
v-model:value="filterType"
|
||||||
|
placeholder="题目类型"
|
||||||
|
style="width: 120px; margin-left: 8px"
|
||||||
|
allow-clear
|
||||||
|
@change="handleSearch"
|
||||||
|
>
|
||||||
|
<a-select-option value="single">单选题</a-select-option>
|
||||||
|
<a-select-option value="multiple">多选题</a-select-option>
|
||||||
|
</a-select>
|
||||||
|
<a-select
|
||||||
|
v-model:value="filterDifficulty"
|
||||||
|
placeholder="难度"
|
||||||
|
style="width: 100px; margin-left: 8px"
|
||||||
|
allow-clear
|
||||||
|
@change="handleSearch"
|
||||||
|
>
|
||||||
|
<a-select-option value="easy">简单</a-select-option>
|
||||||
|
<a-select-option value="medium">中等</a-select-option>
|
||||||
|
<a-select-option value="hard">困难</a-select-option>
|
||||||
|
</a-select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="action-section">
|
||||||
|
<a-button type="primary" @click="showCreateModal">
|
||||||
|
<PlusOutlined />
|
||||||
|
新增题目
|
||||||
|
</a-button>
|
||||||
|
<ImportExport @imported="handleImported" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 批量操作栏 -->
|
||||||
|
<BatchOperations
|
||||||
|
v-if="selectedRows.length > 0"
|
||||||
|
:selected-count="selectedRows.length"
|
||||||
|
@batch-delete="handleBatchDelete"
|
||||||
|
@clear-selection="clearSelection"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- 题目列表 -->
|
||||||
|
<QuestionList
|
||||||
|
:loading="loading"
|
||||||
|
:data-source="questions"
|
||||||
|
:pagination="pagination"
|
||||||
|
:selected-rows="selectedRows"
|
||||||
|
@selection-change="handleSelectionChange"
|
||||||
|
@edit="handleEdit"
|
||||||
|
@delete="handleDelete"
|
||||||
|
@page-change="handlePageChange"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 新增/编辑题目弹窗 -->
|
||||||
|
<QuestionForm
|
||||||
|
v-model:visible="formVisible"
|
||||||
|
:form-data="currentQuestion"
|
||||||
|
:is-edit="isEditMode"
|
||||||
|
@success="handleFormSuccess"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, reactive, onMounted } from 'vue';
|
||||||
|
import { message, Modal } from 'ant-design-vue';
|
||||||
|
import { PlusOutlined } from '@ant-design/icons-vue';
|
||||||
|
import { useRequest } from 'alova/client';
|
||||||
|
import { getQuestionList, deleteQuestion, batchDeleteQuestions } from '@/apis/questions';
|
||||||
|
import type { Question, QuestionQueryParams } from '@/apis/questions';
|
||||||
|
|
||||||
|
// 导入子组件
|
||||||
|
import QuestionList from './components/QuestionList.vue';
|
||||||
|
import QuestionForm from './components/QuestionForm.vue';
|
||||||
|
import BatchOperations from './components/BatchOperations.vue';
|
||||||
|
import ImportExport from './components/ImportExport.vue';
|
||||||
|
|
||||||
|
// 搜索筛选参数
|
||||||
|
const searchKeyword = ref('');
|
||||||
|
const filterType = ref<string>();
|
||||||
|
const filterDifficulty = ref<string>();
|
||||||
|
|
||||||
|
// 列表数据
|
||||||
|
const questions = ref<Question[]>([]);
|
||||||
|
const selectedRows = ref<Question[]>([]);
|
||||||
|
|
||||||
|
// 分页
|
||||||
|
const pagination = reactive({
|
||||||
|
current: 1,
|
||||||
|
pageSize: 10,
|
||||||
|
total: 0,
|
||||||
|
showSizeChanger: true,
|
||||||
|
showQuickJumper: true,
|
||||||
|
showTotal: (total: number) => `共 ${total} 条记录`
|
||||||
|
});
|
||||||
|
|
||||||
|
// 表单相关
|
||||||
|
const formVisible = ref(false);
|
||||||
|
const isEditMode = ref(false);
|
||||||
|
const currentQuestion = ref<Partial<Question>>({});
|
||||||
|
|
||||||
|
// 获取题目列表
|
||||||
|
const { loading, send: fetchQuestions } = useRequest((params: QuestionQueryParams) => getQuestionList(params), {
|
||||||
|
immediate: false
|
||||||
|
});
|
||||||
|
|
||||||
|
// 搜索处理
|
||||||
|
const handleSearch = () => {
|
||||||
|
pagination.current = 1;
|
||||||
|
loadQuestions();
|
||||||
|
};
|
||||||
|
|
||||||
|
// 加载题目列表
|
||||||
|
const loadQuestions = async () => {
|
||||||
|
try {
|
||||||
|
const params: QuestionQueryParams = {
|
||||||
|
page: pagination.current,
|
||||||
|
pageSize: pagination.pageSize,
|
||||||
|
keyword: searchKeyword.value || undefined,
|
||||||
|
type: filterType.value as any,
|
||||||
|
difficulty: filterDifficulty.value as any
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await fetchQuestions(params);
|
||||||
|
questions.value = result.list;
|
||||||
|
pagination.total = result.total;
|
||||||
|
} catch (error: any) {
|
||||||
|
message.error(error.message || '获取题目列表失败');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 选择处理
|
||||||
|
const handleSelectionChange = (rows: Question[]) => {
|
||||||
|
selectedRows.value = rows;
|
||||||
|
};
|
||||||
|
|
||||||
|
const clearSelection = () => {
|
||||||
|
selectedRows.value = [];
|
||||||
|
};
|
||||||
|
|
||||||
|
// 新增题目
|
||||||
|
const showCreateModal = () => {
|
||||||
|
currentQuestion.value = {};
|
||||||
|
isEditMode.value = false;
|
||||||
|
formVisible.value = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 编辑题目
|
||||||
|
const handleEdit = (record: Question) => {
|
||||||
|
currentQuestion.value = { ...record };
|
||||||
|
isEditMode.value = true;
|
||||||
|
formVisible.value = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 删除题目
|
||||||
|
const handleDelete = (record: Question) => {
|
||||||
|
Modal.confirm({
|
||||||
|
title: '确认删除',
|
||||||
|
content: `确定要删除题目"${record.title}"吗?此操作不可恢复。`,
|
||||||
|
okText: '删除',
|
||||||
|
okType: 'danger',
|
||||||
|
cancelText: '取消',
|
||||||
|
async onOk() {
|
||||||
|
try {
|
||||||
|
await deleteQuestion(record.id);
|
||||||
|
message.success('删除成功');
|
||||||
|
loadQuestions();
|
||||||
|
} catch (error: any) {
|
||||||
|
message.error(error.message || '删除失败');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// 批量删除
|
||||||
|
const handleBatchDelete = () => {
|
||||||
|
Modal.confirm({
|
||||||
|
title: '确认批量删除',
|
||||||
|
content: `确定要删除选中的 ${selectedRows.value.length} 个题目吗?此操作不可恢复。`,
|
||||||
|
okText: '删除',
|
||||||
|
okType: 'danger',
|
||||||
|
cancelText: '取消',
|
||||||
|
async onOk() {
|
||||||
|
try {
|
||||||
|
const ids = selectedRows.value.map(item => item.id);
|
||||||
|
await batchDeleteQuestions(ids);
|
||||||
|
message.success(`成功删除 ${ids.length} 个题目`);
|
||||||
|
clearSelection();
|
||||||
|
loadQuestions();
|
||||||
|
} catch (error: any) {
|
||||||
|
message.error(error.message || '批量删除失败');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// 表单成功回调
|
||||||
|
const handleFormSuccess = () => {
|
||||||
|
formVisible.value = false;
|
||||||
|
loadQuestions();
|
||||||
|
};
|
||||||
|
|
||||||
|
// 导入成功回调
|
||||||
|
const handleImported = () => {
|
||||||
|
loadQuestions();
|
||||||
|
};
|
||||||
|
|
||||||
|
// 分页改变
|
||||||
|
const handlePageChange = (page: number, pageSize: number) => {
|
||||||
|
pagination.current = page;
|
||||||
|
pagination.pageSize = pageSize;
|
||||||
|
loadQuestions();
|
||||||
|
};
|
||||||
|
|
||||||
|
// 初始化
|
||||||
|
onMounted(() => {
|
||||||
|
loadQuestions();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.question-page {
|
||||||
|
.page-header {
|
||||||
|
background: #fff;
|
||||||
|
padding: 12px 24px;
|
||||||
|
border-radius: 8px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
|
||||||
|
|
||||||
|
.page-title {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #262626;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
padding: 16px;
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
|
||||||
|
|
||||||
|
.search-section {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-section {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
190
src/views/questions/components/BatchOperations.vue
Normal file
190
src/views/questions/components/BatchOperations.vue
Normal file
@@ -0,0 +1,190 @@
|
|||||||
|
<template>
|
||||||
|
<div class="batch-operations">
|
||||||
|
<a-alert
|
||||||
|
:message="`已选择 ${selectedCount} 个题目`"
|
||||||
|
type="info"
|
||||||
|
show-icon
|
||||||
|
closable
|
||||||
|
@close="$emit('clear-selection')"
|
||||||
|
>
|
||||||
|
<template #action>
|
||||||
|
<div class="batch-actions">
|
||||||
|
<a-button
|
||||||
|
size="small"
|
||||||
|
danger
|
||||||
|
@click="$emit('batch-delete')"
|
||||||
|
:disabled="selectedCount === 0"
|
||||||
|
>
|
||||||
|
<DeleteOutlined />
|
||||||
|
批量删除
|
||||||
|
</a-button>
|
||||||
|
|
||||||
|
<a-button
|
||||||
|
size="small"
|
||||||
|
@click="handleBatchExport"
|
||||||
|
:disabled="selectedCount === 0"
|
||||||
|
>
|
||||||
|
<ExportOutlined />
|
||||||
|
导出选中
|
||||||
|
</a-button>
|
||||||
|
|
||||||
|
<a-dropdown>
|
||||||
|
<a-button size="small" :disabled="selectedCount === 0">
|
||||||
|
<SettingOutlined />
|
||||||
|
批量操作
|
||||||
|
<DownOutlined />
|
||||||
|
</a-button>
|
||||||
|
<template #overlay>
|
||||||
|
<a-menu @click="handleBatchOperation">
|
||||||
|
<a-menu-item key="category">
|
||||||
|
<FolderOutlined />
|
||||||
|
修改分类
|
||||||
|
</a-menu-item>
|
||||||
|
<a-menu-item key="difficulty">
|
||||||
|
<StarOutlined />
|
||||||
|
修改难度
|
||||||
|
</a-menu-item>
|
||||||
|
<a-menu-item key="tags">
|
||||||
|
<TagOutlined />
|
||||||
|
添加标签
|
||||||
|
</a-menu-item>
|
||||||
|
</a-menu>
|
||||||
|
</template>
|
||||||
|
</a-dropdown>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</a-alert>
|
||||||
|
|
||||||
|
<!-- 批量修改弹窗 -->
|
||||||
|
<a-modal
|
||||||
|
v-model:open="batchModalVisible"
|
||||||
|
:title="batchModalTitle"
|
||||||
|
@ok="handleBatchConfirm"
|
||||||
|
@cancel="batchModalVisible = false"
|
||||||
|
>
|
||||||
|
<!-- 修改分类 -->
|
||||||
|
<div v-if="batchType === 'category'">
|
||||||
|
<a-form-item label="新分类">
|
||||||
|
<a-input
|
||||||
|
v-model:value="batchValue"
|
||||||
|
placeholder="请输入新的分类名称"
|
||||||
|
/>
|
||||||
|
</a-form-item>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 修改难度 -->
|
||||||
|
<div v-if="batchType === 'difficulty'">
|
||||||
|
<a-form-item label="新难度">
|
||||||
|
<a-select
|
||||||
|
v-model:value="batchValue"
|
||||||
|
placeholder="请选择新的难度"
|
||||||
|
style="width: 100%"
|
||||||
|
>
|
||||||
|
<a-select-option value="easy">简单</a-select-option>
|
||||||
|
<a-select-option value="medium">中等</a-select-option>
|
||||||
|
<a-select-option value="hard">困难</a-select-option>
|
||||||
|
</a-select>
|
||||||
|
</a-form-item>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 添加标签 -->
|
||||||
|
<div v-if="batchType === 'tags'">
|
||||||
|
<a-form-item label="标签">
|
||||||
|
<a-select
|
||||||
|
v-model:value="batchValue"
|
||||||
|
mode="tags"
|
||||||
|
placeholder="添加标签,按回车确认"
|
||||||
|
style="width: 100%"
|
||||||
|
/>
|
||||||
|
</a-form-item>
|
||||||
|
</div>
|
||||||
|
</a-modal>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue';
|
||||||
|
import { message } from 'ant-design-vue';
|
||||||
|
import {
|
||||||
|
DeleteOutlined,
|
||||||
|
ExportOutlined,
|
||||||
|
SettingOutlined,
|
||||||
|
DownOutlined,
|
||||||
|
FolderOutlined,
|
||||||
|
StarOutlined,
|
||||||
|
TagOutlined
|
||||||
|
} from '@ant-design/icons-vue';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
selectedCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Emits {
|
||||||
|
(e: 'batch-delete'): void;
|
||||||
|
(e: 'clear-selection'): void;
|
||||||
|
(e: 'batch-export'): void;
|
||||||
|
(e: 'batch-update', type: string, value: any): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
defineProps<Props>();
|
||||||
|
const emit = defineEmits<Emits>();
|
||||||
|
|
||||||
|
// 批量操作弹窗
|
||||||
|
const batchModalVisible = ref(false);
|
||||||
|
const batchType = ref('');
|
||||||
|
const batchValue = ref<any>('');
|
||||||
|
const batchModalTitle = ref('');
|
||||||
|
|
||||||
|
// 批量导出
|
||||||
|
const handleBatchExport = () => {
|
||||||
|
emit('batch-export');
|
||||||
|
message.info('导出功能开发中...');
|
||||||
|
};
|
||||||
|
|
||||||
|
// 批量操作
|
||||||
|
const handleBatchOperation = ({ key }: { key: string }) => {
|
||||||
|
batchType.value = key;
|
||||||
|
batchValue.value = key === 'tags' ? [] : '';
|
||||||
|
|
||||||
|
const titleMap: Record<string, string> = {
|
||||||
|
category: '批量修改分类',
|
||||||
|
difficulty: '批量修改难度',
|
||||||
|
tags: '批量添加标签'
|
||||||
|
};
|
||||||
|
|
||||||
|
batchModalTitle.value = titleMap[key] || '批量操作';
|
||||||
|
batchModalVisible.value = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 确认批量操作
|
||||||
|
const handleBatchConfirm = () => {
|
||||||
|
if (!batchValue.value) {
|
||||||
|
message.error('请输入有效的值');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
emit('batch-update', batchType.value, batchValue.value);
|
||||||
|
batchModalVisible.value = false;
|
||||||
|
message.success('批量操作成功');
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.batch-operations {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
|
||||||
|
.batch-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.ant-alert) {
|
||||||
|
border-radius: 8px;
|
||||||
|
|
||||||
|
.ant-alert-message {
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
354
src/views/questions/components/ImportExport.vue
Normal file
354
src/views/questions/components/ImportExport.vue
Normal file
@@ -0,0 +1,354 @@
|
|||||||
|
<template>
|
||||||
|
<a-dropdown placement="bottomRight">
|
||||||
|
<a-button>
|
||||||
|
<CloudDownloadOutlined />
|
||||||
|
导入导出
|
||||||
|
<DownOutlined />
|
||||||
|
</a-button>
|
||||||
|
|
||||||
|
<template #overlay>
|
||||||
|
<a-menu @click="handleMenuClick">
|
||||||
|
<a-menu-item key="import">
|
||||||
|
<CloudUploadOutlined />
|
||||||
|
导入题目
|
||||||
|
</a-menu-item>
|
||||||
|
<a-menu-item key="export">
|
||||||
|
<CloudDownloadOutlined />
|
||||||
|
导出题目
|
||||||
|
</a-menu-item>
|
||||||
|
<a-menu-divider />
|
||||||
|
<a-menu-item key="template">
|
||||||
|
<FileExcelOutlined />
|
||||||
|
下载模板
|
||||||
|
</a-menu-item>
|
||||||
|
</a-menu>
|
||||||
|
</template>
|
||||||
|
</a-dropdown>
|
||||||
|
|
||||||
|
<!-- 导入弹窗 -->
|
||||||
|
<a-modal
|
||||||
|
v-model:open="importVisible"
|
||||||
|
title="导入题目"
|
||||||
|
:confirm-loading="importing"
|
||||||
|
@ok="handleImport"
|
||||||
|
@cancel="handleImportCancel"
|
||||||
|
>
|
||||||
|
<div class="import-section">
|
||||||
|
<a-alert
|
||||||
|
message="导入说明"
|
||||||
|
:description="importTips"
|
||||||
|
type="info"
|
||||||
|
show-icon
|
||||||
|
style="margin-bottom: 16px"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<a-upload-dragger
|
||||||
|
v-model:fileList="importFileList"
|
||||||
|
:before-upload="beforeImportUpload"
|
||||||
|
:on-remove="handleRemove"
|
||||||
|
accept=".xlsx,.xls,.csv"
|
||||||
|
:max-count="1"
|
||||||
|
>
|
||||||
|
<p class="ant-upload-drag-icon">
|
||||||
|
<InboxOutlined />
|
||||||
|
</p>
|
||||||
|
<p class="ant-upload-text">点击或拖拽文件到此区域上传</p>
|
||||||
|
<p class="ant-upload-hint">
|
||||||
|
支持 Excel (.xlsx/.xls) 和 CSV 文件格式
|
||||||
|
</p>
|
||||||
|
</a-upload-dragger>
|
||||||
|
|
||||||
|
<!-- 导入选项 -->
|
||||||
|
<div class="import-options" style="margin-top: 16px">
|
||||||
|
<a-checkbox v-model:checked="importOptions.skipDuplicate">
|
||||||
|
跳过重复题目
|
||||||
|
</a-checkbox>
|
||||||
|
<a-checkbox v-model:checked="importOptions.updateExisting">
|
||||||
|
更新已存在的题目
|
||||||
|
</a-checkbox>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</a-modal>
|
||||||
|
|
||||||
|
<!-- 导入结果弹窗 -->
|
||||||
|
<a-modal
|
||||||
|
v-model:open="resultVisible"
|
||||||
|
title="导入结果"
|
||||||
|
:footer="null"
|
||||||
|
>
|
||||||
|
<div class="import-result">
|
||||||
|
<a-result
|
||||||
|
:status="importResult.success ? 'success' : 'warning'"
|
||||||
|
:title="importResult.success ? '导入成功' : '导入完成'"
|
||||||
|
>
|
||||||
|
<template #extra>
|
||||||
|
<div class="result-stats">
|
||||||
|
<a-statistic
|
||||||
|
title="成功导入"
|
||||||
|
:value="importResult.successCount"
|
||||||
|
:value-style="{ color: '#3f8600' }"
|
||||||
|
/>
|
||||||
|
<a-statistic
|
||||||
|
title="导入失败"
|
||||||
|
:value="importResult.failCount"
|
||||||
|
:value-style="{ color: '#cf1322' }"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="importResult.errors.length > 0" class="error-details">
|
||||||
|
<h4>错误详情:</h4>
|
||||||
|
<ul>
|
||||||
|
<li v-for="(error, index) in importResult.errors" :key="index">
|
||||||
|
{{ error }}
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<a-button type="primary" @click="resultVisible = false">
|
||||||
|
确定
|
||||||
|
</a-button>
|
||||||
|
</template>
|
||||||
|
</a-result>
|
||||||
|
</div>
|
||||||
|
</a-modal>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, reactive } from 'vue';
|
||||||
|
import { message } from 'ant-design-vue';
|
||||||
|
import {
|
||||||
|
CloudDownloadOutlined,
|
||||||
|
CloudUploadOutlined,
|
||||||
|
FileExcelOutlined,
|
||||||
|
DownOutlined,
|
||||||
|
InboxOutlined
|
||||||
|
} from '@ant-design/icons-vue';
|
||||||
|
import { useRequest } from 'alova/client';
|
||||||
|
import { importQuestions, exportQuestionTemplate } from '@/apis/questions';
|
||||||
|
|
||||||
|
interface Emits {
|
||||||
|
(e: 'imported'): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const emit = defineEmits<Emits>();
|
||||||
|
|
||||||
|
// 导入相关状态
|
||||||
|
const importVisible = ref(false);
|
||||||
|
const importFileList = ref<any[]>([]);
|
||||||
|
const importOptions = reactive({
|
||||||
|
skipDuplicate: true,
|
||||||
|
updateExisting: false
|
||||||
|
});
|
||||||
|
|
||||||
|
// 导入结果
|
||||||
|
const resultVisible = ref(false);
|
||||||
|
const importResult = reactive({
|
||||||
|
success: false,
|
||||||
|
successCount: 0,
|
||||||
|
failCount: 0,
|
||||||
|
errors: [] as string[]
|
||||||
|
});
|
||||||
|
|
||||||
|
// 导入说明
|
||||||
|
const importTips = `
|
||||||
|
1. 请下载模板文件,按照模板格式填写题目信息
|
||||||
|
2. 支持 Excel (.xlsx/.xls) 和 CSV 文件格式
|
||||||
|
3. 题目标题和内容为必填项
|
||||||
|
4. 选项至少需要2个,正确答案用A、B、C等字母表示
|
||||||
|
5. 单次最多导入1000个题目
|
||||||
|
`;
|
||||||
|
|
||||||
|
// 导入请求
|
||||||
|
const { loading: importing, send: sendImport } = useRequest(
|
||||||
|
(file: File) => importQuestions(file),
|
||||||
|
{ immediate: false }
|
||||||
|
);
|
||||||
|
|
||||||
|
// 菜单点击处理
|
||||||
|
const handleMenuClick = ({ key }: { key: string }) => {
|
||||||
|
switch (key) {
|
||||||
|
case 'import':
|
||||||
|
showImportModal();
|
||||||
|
break;
|
||||||
|
case 'export':
|
||||||
|
handleExport();
|
||||||
|
break;
|
||||||
|
case 'template':
|
||||||
|
handleDownloadTemplate();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 显示导入弹窗
|
||||||
|
const showImportModal = () => {
|
||||||
|
importVisible.value = true;
|
||||||
|
importFileList.value = [];
|
||||||
|
};
|
||||||
|
|
||||||
|
// 导入前检查
|
||||||
|
const beforeImportUpload = (file: File) => {
|
||||||
|
const isValidType = file.type.includes('sheet') || file.type.includes('csv') ||
|
||||||
|
file.name.endsWith('.xlsx') || file.name.endsWith('.xls') || file.name.endsWith('.csv');
|
||||||
|
|
||||||
|
if (!isValidType) {
|
||||||
|
message.error('只支持 Excel 和 CSV 文件格式!');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isLt10M = file.size / 1024 / 1024 < 10;
|
||||||
|
if (!isLt10M) {
|
||||||
|
message.error('文件大小不能超过10MB!');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false; // 阻止默认上传
|
||||||
|
};
|
||||||
|
|
||||||
|
// 移除文件
|
||||||
|
const handleRemove = () => {
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 执行导入
|
||||||
|
const handleImport = async () => {
|
||||||
|
if (importFileList.value.length === 0) {
|
||||||
|
message.error('请选择要导入的文件');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const file = importFileList.value[0].originFileObj || importFileList.value[0];
|
||||||
|
const result = await sendImport(file);
|
||||||
|
|
||||||
|
// 设置导入结果
|
||||||
|
importResult.success = result.failCount === 0;
|
||||||
|
importResult.successCount = result.successCount;
|
||||||
|
importResult.failCount = result.failCount;
|
||||||
|
importResult.errors = result.errors || [];
|
||||||
|
|
||||||
|
// 显示结果
|
||||||
|
importVisible.value = false;
|
||||||
|
resultVisible.value = true;
|
||||||
|
|
||||||
|
// 通知父组件刷新
|
||||||
|
if (result.successCount > 0) {
|
||||||
|
emit('imported');
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (error: any) {
|
||||||
|
message.error(error.message || '导入失败');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 取消导入
|
||||||
|
const handleImportCancel = () => {
|
||||||
|
importVisible.value = false;
|
||||||
|
importFileList.value = [];
|
||||||
|
};
|
||||||
|
|
||||||
|
// 导出题目
|
||||||
|
const handleExport = () => {
|
||||||
|
message.info('导出功能开发中...');
|
||||||
|
// 这里可以调用导出API
|
||||||
|
// exportQuestions(searchParams);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 下载模板
|
||||||
|
const handleDownloadTemplate = async () => {
|
||||||
|
try {
|
||||||
|
const blob = await exportQuestionTemplate();
|
||||||
|
|
||||||
|
// 创建下载链接
|
||||||
|
const url = window.URL.createObjectURL(blob);
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.style.display = 'none';
|
||||||
|
link.href = url;
|
||||||
|
link.download = '题目导入模板.xlsx';
|
||||||
|
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
document.body.removeChild(link);
|
||||||
|
|
||||||
|
// 释放URL对象
|
||||||
|
window.URL.revokeObjectURL(url);
|
||||||
|
|
||||||
|
message.success('模板下载成功');
|
||||||
|
} catch (error: any) {
|
||||||
|
message.error(error.message || '模板下载失败');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.import-section {
|
||||||
|
.import-options {
|
||||||
|
display: flex;
|
||||||
|
gap: 16px;
|
||||||
|
|
||||||
|
.ant-checkbox-wrapper {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.import-result {
|
||||||
|
.result-stats {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 40px;
|
||||||
|
margin: 20px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-details {
|
||||||
|
text-align: left;
|
||||||
|
margin: 20px 0;
|
||||||
|
|
||||||
|
h4 {
|
||||||
|
color: #cf1322;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
ul {
|
||||||
|
list-style-type: disc;
|
||||||
|
padding-left: 20px;
|
||||||
|
max-height: 200px;
|
||||||
|
overflow-y: auto;
|
||||||
|
|
||||||
|
li {
|
||||||
|
color: #666;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.ant-upload-drag) {
|
||||||
|
background: #fafafa;
|
||||||
|
border: 2px dashed #d9d9d9;
|
||||||
|
border-radius: 8px;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
border-color: #1890ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ant-upload-drag-icon {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
|
||||||
|
.anticon {
|
||||||
|
font-size: 48px;
|
||||||
|
color: #1890ff;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.ant-upload-text {
|
||||||
|
font-size: 16px;
|
||||||
|
color: #333;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ant-upload-hint {
|
||||||
|
font-size: 14px;
|
||||||
|
color: #999;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
487
src/views/questions/components/QuestionForm.vue
Normal file
487
src/views/questions/components/QuestionForm.vue
Normal file
@@ -0,0 +1,487 @@
|
|||||||
|
<template>
|
||||||
|
<a-modal
|
||||||
|
:open="visible"
|
||||||
|
:title="isEdit ? '编辑题目' : '新增题目'"
|
||||||
|
:width="800"
|
||||||
|
:confirm-loading="loading"
|
||||||
|
@ok="handleSubmit"
|
||||||
|
@cancel="handleCancel"
|
||||||
|
>
|
||||||
|
<a-form
|
||||||
|
ref="formRef"
|
||||||
|
:model="form"
|
||||||
|
:rules="rules"
|
||||||
|
layout="vertical"
|
||||||
|
:label-col="{ span: 24 }"
|
||||||
|
>
|
||||||
|
<a-row :gutter="16">
|
||||||
|
<a-col :span="24">
|
||||||
|
<a-form-item label="题目标题" name="title">
|
||||||
|
<a-input
|
||||||
|
v-model:value="form.title"
|
||||||
|
placeholder="请输入题目标题"
|
||||||
|
:maxlength="100"
|
||||||
|
show-count
|
||||||
|
/>
|
||||||
|
</a-form-item>
|
||||||
|
</a-col>
|
||||||
|
</a-row>
|
||||||
|
|
||||||
|
<a-row :gutter="16">
|
||||||
|
<a-col :span="24">
|
||||||
|
<a-form-item label="题目内容" name="content">
|
||||||
|
<RichEditor
|
||||||
|
v-model="form.content"
|
||||||
|
placeholder="请输入题目内容,支持富文本格式"
|
||||||
|
:height="300"
|
||||||
|
:maxlength="2000"
|
||||||
|
/>
|
||||||
|
</a-form-item>
|
||||||
|
</a-col>
|
||||||
|
</a-row>
|
||||||
|
|
||||||
|
<a-row :gutter="16">
|
||||||
|
<a-col :span="12">
|
||||||
|
<a-form-item label="题目类型" name="type">
|
||||||
|
<a-select
|
||||||
|
v-model:value="form.type"
|
||||||
|
placeholder="选择题目类型"
|
||||||
|
@change="handleTypeChange"
|
||||||
|
>
|
||||||
|
<a-select-option value="single">单选题</a-select-option>
|
||||||
|
<a-select-option value="multiple">多选题</a-select-option>
|
||||||
|
</a-select>
|
||||||
|
</a-form-item>
|
||||||
|
</a-col>
|
||||||
|
|
||||||
|
<a-col :span="6">
|
||||||
|
<a-form-item label="难度" name="difficulty">
|
||||||
|
<a-select v-model:value="form.difficulty" placeholder="选择难度">
|
||||||
|
<a-select-option value="easy">简单</a-select-option>
|
||||||
|
<a-select-option value="medium">中等</a-select-option>
|
||||||
|
<a-select-option value="hard">困难</a-select-option>
|
||||||
|
</a-select>
|
||||||
|
</a-form-item>
|
||||||
|
</a-col>
|
||||||
|
|
||||||
|
<a-col :span="6">
|
||||||
|
<a-form-item label="分值" name="score">
|
||||||
|
<a-input-number
|
||||||
|
v-model:value="form.score"
|
||||||
|
:min="1"
|
||||||
|
:max="100"
|
||||||
|
placeholder="分值"
|
||||||
|
style="width: 100%"
|
||||||
|
/>
|
||||||
|
</a-form-item>
|
||||||
|
</a-col>
|
||||||
|
</a-row>
|
||||||
|
|
||||||
|
<a-row :gutter="16">
|
||||||
|
<a-col :span="12">
|
||||||
|
<a-form-item label="题目分类" name="category">
|
||||||
|
<a-input
|
||||||
|
v-model:value="form.category"
|
||||||
|
placeholder="请输入题目分类"
|
||||||
|
:maxlength="50"
|
||||||
|
/>
|
||||||
|
</a-form-item>
|
||||||
|
</a-col>
|
||||||
|
|
||||||
|
<a-col :span="12">
|
||||||
|
<a-form-item label="题目标签" name="tags">
|
||||||
|
<a-select
|
||||||
|
v-model:value="form.tags"
|
||||||
|
mode="tags"
|
||||||
|
placeholder="添加标签,按回车确认"
|
||||||
|
:max-tag-count="5"
|
||||||
|
/>
|
||||||
|
</a-form-item>
|
||||||
|
</a-col>
|
||||||
|
</a-row>
|
||||||
|
|
||||||
|
<!-- 题目图片 -->
|
||||||
|
<a-row :gutter="16">
|
||||||
|
<a-col :span="24">
|
||||||
|
<a-form-item label="题目图片(可选)">
|
||||||
|
<a-upload
|
||||||
|
v-model:file-list="imageFileList"
|
||||||
|
list-type="picture-card"
|
||||||
|
:before-upload="beforeImageUpload"
|
||||||
|
:on-remove="handleImageRemove"
|
||||||
|
accept="image/*"
|
||||||
|
:max-count="1"
|
||||||
|
>
|
||||||
|
<div v-if="!form.image">
|
||||||
|
<PlusOutlined />
|
||||||
|
<div style="margin-top: 8px">上传图片</div>
|
||||||
|
</div>
|
||||||
|
</a-upload>
|
||||||
|
</a-form-item>
|
||||||
|
</a-col>
|
||||||
|
</a-row>
|
||||||
|
|
||||||
|
<!-- 选项设置 -->
|
||||||
|
<a-form-item label="题目选项" name="options">
|
||||||
|
<div class="options-section">
|
||||||
|
<div v-for="(option, index) in form.options" :key="option.key" class="option-item">
|
||||||
|
<a-row :gutter="8" align="middle">
|
||||||
|
<a-col :span="3">
|
||||||
|
<div class="option-label">{{ option.key }}.</div>
|
||||||
|
</a-col>
|
||||||
|
<a-col :span="15">
|
||||||
|
<a-form-item-rest>
|
||||||
|
<a-input
|
||||||
|
v-model:value="option.value"
|
||||||
|
:placeholder="`请输入选项${option.key}内容`"
|
||||||
|
:maxlength="200"
|
||||||
|
/>
|
||||||
|
</a-form-item-rest>
|
||||||
|
</a-col>
|
||||||
|
<a-col :span="4">
|
||||||
|
<a-form-item-rest>
|
||||||
|
<a-checkbox
|
||||||
|
:checked="form.correctAnswer.includes(option.key)"
|
||||||
|
@change="(e: any) => handleAnswerChange(option.key, e.target.checked)"
|
||||||
|
>
|
||||||
|
正确答案
|
||||||
|
</a-checkbox>
|
||||||
|
</a-form-item-rest>
|
||||||
|
</a-col>
|
||||||
|
<a-col :span="2">
|
||||||
|
<a-form-item-rest>
|
||||||
|
<a-button
|
||||||
|
v-if="form.options.length > 2"
|
||||||
|
type="text"
|
||||||
|
danger
|
||||||
|
size="small"
|
||||||
|
@click="removeOption(index)"
|
||||||
|
>
|
||||||
|
<DeleteOutlined />
|
||||||
|
</a-button>
|
||||||
|
</a-form-item-rest>
|
||||||
|
</a-col>
|
||||||
|
</a-row>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<a-form-item-rest>
|
||||||
|
<a-button
|
||||||
|
v-if="form.options.length < 6"
|
||||||
|
type="dashed"
|
||||||
|
block
|
||||||
|
@click="addOption"
|
||||||
|
style="margin-top: 8px"
|
||||||
|
>
|
||||||
|
<PlusOutlined />
|
||||||
|
添加选项
|
||||||
|
</a-button>
|
||||||
|
</a-form-item-rest>
|
||||||
|
</div>
|
||||||
|
</a-form-item>
|
||||||
|
</a-form>
|
||||||
|
</a-modal>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, reactive, watch } from 'vue';
|
||||||
|
import { message } from 'ant-design-vue';
|
||||||
|
import { PlusOutlined, DeleteOutlined } from '@ant-design/icons-vue';
|
||||||
|
import { useRequest } from 'alova/client';
|
||||||
|
import { createQuestion, updateQuestion } from '@/apis/questions';
|
||||||
|
import type { Question, CreateQuestionParams, QuestionOption } from '@/apis/questions';
|
||||||
|
import RichEditor from '@/components/RichEditor.vue';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
visible: boolean;
|
||||||
|
formData: Partial<Question>;
|
||||||
|
isEdit: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Emits {
|
||||||
|
(e: 'update:visible', visible: boolean): void;
|
||||||
|
(e: 'success'): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<Props>();
|
||||||
|
const emit = defineEmits<Emits>();
|
||||||
|
|
||||||
|
const formRef = ref();
|
||||||
|
const imageFileList = ref<any[]>([]);
|
||||||
|
|
||||||
|
// 表单数据
|
||||||
|
const form = reactive<CreateQuestionParams>({
|
||||||
|
title: '',
|
||||||
|
content: '',
|
||||||
|
type: 'single',
|
||||||
|
difficulty: 'easy',
|
||||||
|
score: 10,
|
||||||
|
category: '',
|
||||||
|
tags: [],
|
||||||
|
options: [
|
||||||
|
{ key: 'A', value: '' },
|
||||||
|
{ key: 'B', value: '' }
|
||||||
|
],
|
||||||
|
correctAnswer: [],
|
||||||
|
image: ''
|
||||||
|
});
|
||||||
|
|
||||||
|
// 表单验证规则
|
||||||
|
const rules = {
|
||||||
|
title: [
|
||||||
|
{ required: true, message: '请输入题目标题', trigger: 'blur' },
|
||||||
|
{ min: 5, max: 100, message: '标题长度应为5-100个字符', trigger: 'blur' }
|
||||||
|
],
|
||||||
|
content: [
|
||||||
|
{ required: true, message: '请输入题目内容', trigger: 'blur' },
|
||||||
|
{ min: 10, max: 2000, message: '内容长度应为10-2000个字符', trigger: 'blur' }
|
||||||
|
],
|
||||||
|
type: [
|
||||||
|
{ required: true, message: '请选择题目类型', trigger: 'change' }
|
||||||
|
],
|
||||||
|
difficulty: [
|
||||||
|
{ required: true, message: '请选择难度', trigger: 'change' }
|
||||||
|
],
|
||||||
|
score: [
|
||||||
|
{ required: true, message: '请输入分值', trigger: 'blur' },
|
||||||
|
{ type: 'number', min: 1, max: 100, message: '分值应为1-100之间的数字', trigger: 'blur' }
|
||||||
|
],
|
||||||
|
category: [
|
||||||
|
{ required: true, message: '请输入题目分类', trigger: 'blur' }
|
||||||
|
],
|
||||||
|
options: [
|
||||||
|
{
|
||||||
|
validator: () => {
|
||||||
|
const validOptions = form.options.filter(opt => opt.value.trim());
|
||||||
|
if (validOptions.length < 2) {
|
||||||
|
return Promise.reject(new Error('至少需要2个选项'));
|
||||||
|
}
|
||||||
|
if (form.correctAnswer.length === 0) {
|
||||||
|
return Promise.reject(new Error('请选择正确答案'));
|
||||||
|
}
|
||||||
|
if (form.type === 'single' && form.correctAnswer.length > 1) {
|
||||||
|
return Promise.reject(new Error('单选题只能有一个正确答案'));
|
||||||
|
}
|
||||||
|
return Promise.resolve();
|
||||||
|
},
|
||||||
|
trigger: 'change'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
||||||
|
// 提交请求
|
||||||
|
const { loading, send: submitForm } = useRequest(
|
||||||
|
() => {
|
||||||
|
const api = props.isEdit && props.formData.id
|
||||||
|
? updateQuestion(props.formData.id, form)
|
||||||
|
: createQuestion(form);
|
||||||
|
return api;
|
||||||
|
},
|
||||||
|
{ immediate: false }
|
||||||
|
);
|
||||||
|
|
||||||
|
// 监听表单数据变化
|
||||||
|
watch(() => props.formData, (newData) => {
|
||||||
|
if (newData && Object.keys(newData).length > 0) {
|
||||||
|
Object.assign(form, {
|
||||||
|
title: newData.title || '',
|
||||||
|
content: newData.content || '',
|
||||||
|
type: newData.type || 'single',
|
||||||
|
difficulty: newData.difficulty || 'easy',
|
||||||
|
score: newData.score || 10,
|
||||||
|
category: newData.category || '',
|
||||||
|
tags: newData.tags || [],
|
||||||
|
options: newData.options || [
|
||||||
|
{ key: 'A', value: '' },
|
||||||
|
{ key: 'B', value: '' }
|
||||||
|
],
|
||||||
|
correctAnswer: newData.correctAnswer || [],
|
||||||
|
image: newData.image || ''
|
||||||
|
});
|
||||||
|
|
||||||
|
// 设置图片列表
|
||||||
|
if (newData.image) {
|
||||||
|
imageFileList.value = [{
|
||||||
|
uid: '-1',
|
||||||
|
name: 'image.png',
|
||||||
|
status: 'done',
|
||||||
|
url: newData.image
|
||||||
|
}];
|
||||||
|
} else {
|
||||||
|
imageFileList.value = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, { immediate: true, deep: true });
|
||||||
|
|
||||||
|
// 监听可见性变化
|
||||||
|
watch(() => props.visible, (visible) => {
|
||||||
|
if (!visible) {
|
||||||
|
resetForm();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 题目类型变化处理
|
||||||
|
const handleTypeChange = (value: string) => {
|
||||||
|
if (value === 'single' && form.correctAnswer.length > 1) {
|
||||||
|
// 单选题只保留第一个答案
|
||||||
|
form.correctAnswer = form.correctAnswer.slice(0, 1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 答案选择处理
|
||||||
|
const handleAnswerChange = (optionKey: string, checked: boolean) => {
|
||||||
|
if (checked) {
|
||||||
|
if (form.type === 'single') {
|
||||||
|
// 单选题,替换答案
|
||||||
|
form.correctAnswer = [optionKey];
|
||||||
|
} else {
|
||||||
|
// 多选题,添加答案
|
||||||
|
if (!form.correctAnswer.includes(optionKey)) {
|
||||||
|
form.correctAnswer.push(optionKey);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 移除答案
|
||||||
|
const index = form.correctAnswer.indexOf(optionKey);
|
||||||
|
if (index > -1) {
|
||||||
|
form.correctAnswer.splice(index, 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 添加选项
|
||||||
|
const addOption = () => {
|
||||||
|
const nextKey = String.fromCharCode(65 + form.options.length); // A, B, C...
|
||||||
|
form.options.push({
|
||||||
|
key: nextKey,
|
||||||
|
value: ''
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// 移除选项
|
||||||
|
const removeOption = (index: number) => {
|
||||||
|
const removedKey = form.options[index].key;
|
||||||
|
form.options.splice(index, 1);
|
||||||
|
|
||||||
|
// 移除对应的正确答案
|
||||||
|
const answerIndex = form.correctAnswer.indexOf(removedKey);
|
||||||
|
if (answerIndex > -1) {
|
||||||
|
form.correctAnswer.splice(answerIndex, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 重新排列选项key
|
||||||
|
form.options.forEach((option, i) => {
|
||||||
|
option.key = String.fromCharCode(65 + i);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 更新正确答案的key
|
||||||
|
form.correctAnswer = form.correctAnswer.map(answer => {
|
||||||
|
const oldIndex = answer.charCodeAt(0) - 65;
|
||||||
|
return oldIndex < form.options.length ? String.fromCharCode(65 + oldIndex) : answer;
|
||||||
|
}).filter(answer => {
|
||||||
|
const index = answer.charCodeAt(0) - 65;
|
||||||
|
return index < form.options.length;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// 图片上传前检查
|
||||||
|
const beforeImageUpload = (file: File) => {
|
||||||
|
const isImage = file.type.startsWith('image/');
|
||||||
|
if (!isImage) {
|
||||||
|
message.error('只能上传图片文件!');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isLt2M = file.size / 1024 / 1024 < 2;
|
||||||
|
if (!isLt2M) {
|
||||||
|
message.error('图片大小不能超过2MB!');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 模拟上传,生成临时URL
|
||||||
|
const url = URL.createObjectURL(file);
|
||||||
|
form.image = url;
|
||||||
|
|
||||||
|
return false; // 阻止默认上传行为
|
||||||
|
};
|
||||||
|
|
||||||
|
// 移除图片
|
||||||
|
const handleImageRemove = () => {
|
||||||
|
form.image = '';
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 重置表单
|
||||||
|
const resetForm = () => {
|
||||||
|
Object.assign(form, {
|
||||||
|
title: '',
|
||||||
|
content: '',
|
||||||
|
type: 'single',
|
||||||
|
difficulty: 'easy',
|
||||||
|
score: 10,
|
||||||
|
category: '',
|
||||||
|
tags: [],
|
||||||
|
options: [
|
||||||
|
{ key: 'A', value: '' },
|
||||||
|
{ key: 'B', value: '' }
|
||||||
|
],
|
||||||
|
correctAnswer: [],
|
||||||
|
image: ''
|
||||||
|
});
|
||||||
|
imageFileList.value = [];
|
||||||
|
formRef.value?.resetFields();
|
||||||
|
};
|
||||||
|
|
||||||
|
// 提交表单
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
try {
|
||||||
|
await formRef.value.validateFields();
|
||||||
|
|
||||||
|
// 过滤空选项
|
||||||
|
const validOptions = form.options.filter(opt => opt.value.trim());
|
||||||
|
const formData = {
|
||||||
|
...form,
|
||||||
|
options: validOptions
|
||||||
|
};
|
||||||
|
|
||||||
|
await submitForm();
|
||||||
|
|
||||||
|
message.success(props.isEdit ? '编辑成功' : '创建成功');
|
||||||
|
emit('success');
|
||||||
|
} catch (error: any) {
|
||||||
|
if (error.errorFields) {
|
||||||
|
// 表单验证错误
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
message.error(error.message || (props.isEdit ? '编辑失败' : '创建失败'));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 取消
|
||||||
|
const handleCancel = () => {
|
||||||
|
emit('update:visible', false);
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.options-section {
|
||||||
|
.option-item {
|
||||||
|
margin-bottom: 12px;
|
||||||
|
|
||||||
|
.option-label {
|
||||||
|
font-weight: 600;
|
||||||
|
color: #333;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.ant-upload-list-picture-card .ant-upload-list-item) {
|
||||||
|
width: 80px;
|
||||||
|
height: 80px;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.ant-upload-select-picture-card) {
|
||||||
|
width: 80px;
|
||||||
|
height: 80px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
321
src/views/questions/components/QuestionList.vue
Normal file
321
src/views/questions/components/QuestionList.vue
Normal file
@@ -0,0 +1,321 @@
|
|||||||
|
<template>
|
||||||
|
<a-card class="question-list-card">
|
||||||
|
<a-table
|
||||||
|
:columns="columns"
|
||||||
|
:data-source="dataSource"
|
||||||
|
:loading="loading"
|
||||||
|
:pagination="paginationConfig"
|
||||||
|
:row-selection="rowSelection"
|
||||||
|
:scroll="{ x: 1070, y: 'calc(100vh - 350px)' }"
|
||||||
|
row-key="id"
|
||||||
|
@change="handleTableChange"
|
||||||
|
>
|
||||||
|
<!-- 题目内容 -->
|
||||||
|
<template #bodyCell="{ column, record }">
|
||||||
|
<template v-if="column.key === 'title'">
|
||||||
|
<div class="question-title">
|
||||||
|
<h4>{{ record.title }}</h4>
|
||||||
|
<p class="question-content">{{ record.content }}</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template v-else-if="column.key === 'type'">
|
||||||
|
<a-tag :color="record.type === 'single' ? 'blue' : 'green'">
|
||||||
|
{{ record.type === 'single' ? '单选题' : '多选题' }}
|
||||||
|
</a-tag>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template v-else-if="column.key === 'difficulty'">
|
||||||
|
<a-tag :color="getDifficultyColor(record.difficulty)">
|
||||||
|
{{ getDifficultyText(record.difficulty) }}
|
||||||
|
</a-tag>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template v-else-if="column.key === 'options'">
|
||||||
|
<div class="options-preview">
|
||||||
|
<div v-for="option in record.options.slice(0, 2)" :key="option.key" class="option-item">
|
||||||
|
<strong>{{ option.key }}.</strong> {{ option.value }}
|
||||||
|
</div>
|
||||||
|
<span v-if="record.options.length > 2" class="more-options">
|
||||||
|
...等{{ record.options.length }}个选项
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template v-else-if="column.key === 'correctAnswer'">
|
||||||
|
<a-tag v-for="answer in record.correctAnswer" :key="answer" color="orange">
|
||||||
|
{{ answer }}
|
||||||
|
</a-tag>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template v-else-if="column.key === 'score'">
|
||||||
|
<span class="score-value">{{ record.score }}分</span>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template v-else-if="column.key === 'tags'">
|
||||||
|
<a-tag v-for="tag in record.tags.slice(0, 2)" :key="tag" color="purple">
|
||||||
|
{{ tag }}
|
||||||
|
</a-tag>
|
||||||
|
<span v-if="record.tags.length > 2">...</span>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template v-else-if="column.key === 'action'">
|
||||||
|
<div class="action-buttons">
|
||||||
|
<a-button type="link" size="small" @click="$emit('edit', record)">
|
||||||
|
<EditOutlined />
|
||||||
|
编辑
|
||||||
|
</a-button>
|
||||||
|
<a-button type="link" size="small" danger @click="$emit('delete', record)">
|
||||||
|
<DeleteOutlined />
|
||||||
|
删除
|
||||||
|
</a-button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</template>
|
||||||
|
</a-table>
|
||||||
|
</a-card>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue';
|
||||||
|
import { EditOutlined, DeleteOutlined } from '@ant-design/icons-vue';
|
||||||
|
import type { Question } from '@/apis/questions';
|
||||||
|
import type { TableColumnsType } from 'ant-design-vue';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
loading?: boolean;
|
||||||
|
dataSource: Question[];
|
||||||
|
pagination: {
|
||||||
|
current: number;
|
||||||
|
pageSize: number;
|
||||||
|
total: number;
|
||||||
|
showSizeChanger?: boolean;
|
||||||
|
showQuickJumper?: boolean;
|
||||||
|
showTotal?: (total: number) => string;
|
||||||
|
};
|
||||||
|
selectedRows: Question[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Emits {
|
||||||
|
(e: 'selection-change', rows: Question[]): void;
|
||||||
|
(e: 'edit', record: Question): void;
|
||||||
|
(e: 'delete', record: Question): void;
|
||||||
|
(e: 'page-change', page: number, pageSize: number): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<Props>();
|
||||||
|
const emit = defineEmits<Emits>();
|
||||||
|
|
||||||
|
// 表格列配置
|
||||||
|
const columns: TableColumnsType = [
|
||||||
|
{
|
||||||
|
title: '题目信息',
|
||||||
|
key: 'title',
|
||||||
|
minWidth: 300,
|
||||||
|
ellipsis: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '类型',
|
||||||
|
key: 'type',
|
||||||
|
width: 70,
|
||||||
|
align: 'center'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '难度',
|
||||||
|
key: 'difficulty',
|
||||||
|
width: 70,
|
||||||
|
align: 'center'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '选项',
|
||||||
|
key: 'options',
|
||||||
|
width: 200,
|
||||||
|
ellipsis: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '答案',
|
||||||
|
key: 'correctAnswer',
|
||||||
|
width: 80,
|
||||||
|
align: 'center'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '分值',
|
||||||
|
key: 'score',
|
||||||
|
width: 60,
|
||||||
|
align: 'center'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '分类',
|
||||||
|
dataIndex: 'category',
|
||||||
|
width: 90,
|
||||||
|
ellipsis: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '标签',
|
||||||
|
key: 'tags',
|
||||||
|
width: 100,
|
||||||
|
ellipsis: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '操作',
|
||||||
|
key: 'action',
|
||||||
|
width: 100,
|
||||||
|
align: 'center',
|
||||||
|
fixed: 'right'
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
// 分页配置
|
||||||
|
const paginationConfig = computed(() => ({
|
||||||
|
...props.pagination,
|
||||||
|
onChange: (page: number, pageSize: number) => emit('page-change', page, pageSize),
|
||||||
|
onShowSizeChange: (current: number, size: number) => emit('page-change', current, size)
|
||||||
|
}));
|
||||||
|
|
||||||
|
// 行选择配置
|
||||||
|
const rowSelection = computed(() => ({
|
||||||
|
selectedRowKeys: props.selectedRows.map(row => row.id),
|
||||||
|
onChange: (_selectedRowKeys: string[], selectedRows: Question[]) => {
|
||||||
|
emit('selection-change', selectedRows);
|
||||||
|
},
|
||||||
|
getCheckboxProps: (record: Question) => ({
|
||||||
|
name: record.id
|
||||||
|
})
|
||||||
|
}));
|
||||||
|
|
||||||
|
// 表格变化处理
|
||||||
|
const handleTableChange = (pagination: any) => {
|
||||||
|
emit('page-change', pagination.current, pagination.pageSize);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 难度颜色映射
|
||||||
|
const getDifficultyColor = (difficulty: string) => {
|
||||||
|
const colorMap: Record<string, string> = {
|
||||||
|
easy: 'green',
|
||||||
|
medium: 'orange',
|
||||||
|
hard: 'red'
|
||||||
|
};
|
||||||
|
return colorMap[difficulty] || 'default';
|
||||||
|
};
|
||||||
|
|
||||||
|
// 难度文本映射
|
||||||
|
const getDifficultyText = (difficulty: string) => {
|
||||||
|
const textMap: Record<string, string> = {
|
||||||
|
easy: '简单',
|
||||||
|
medium: '中等',
|
||||||
|
hard: '困难'
|
||||||
|
};
|
||||||
|
return textMap[difficulty] || difficulty;
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.question-list-card {
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
|
||||||
|
|
||||||
|
:deep(.ant-table) {
|
||||||
|
.ant-table-thead > tr > th {
|
||||||
|
background: #fafafa;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ant-table-tbody > tr > td {
|
||||||
|
vertical-align: top;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.question-title {
|
||||||
|
h4 {
|
||||||
|
margin: 0 0 8px 0;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #333;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.question-content {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #666;
|
||||||
|
line-height: 1.4;
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 2;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
line-clamp: 2;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.options-preview {
|
||||||
|
.option-item {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #666;
|
||||||
|
margin-bottom: 2px;
|
||||||
|
|
||||||
|
strong {
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.more-options {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #999;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.score-value {
|
||||||
|
font-weight: 600;
|
||||||
|
color: #1890ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-buttons {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
justify-content: center;
|
||||||
|
|
||||||
|
.ant-btn-link {
|
||||||
|
padding: 0;
|
||||||
|
height: auto;
|
||||||
|
|
||||||
|
&.ant-btn-dangerous {
|
||||||
|
color: #ff4d4f;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: #ff7875;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.question-list-card {
|
||||||
|
:deep(.ant-table) {
|
||||||
|
.ant-table-thead {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ant-table-tbody > tr > td {
|
||||||
|
display: block;
|
||||||
|
border: none;
|
||||||
|
padding: 8px 16px;
|
||||||
|
|
||||||
|
&:before {
|
||||||
|
content: attr(data-label);
|
||||||
|
font-weight: 600;
|
||||||
|
color: #666;
|
||||||
|
display: inline-block;
|
||||||
|
width: 80px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.ant-table-tbody > tr {
|
||||||
|
border-bottom: 1px solid #f0f0f0;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
343
src/views/records/RecordPage.vue
Normal file
343
src/views/records/RecordPage.vue
Normal file
@@ -0,0 +1,343 @@
|
|||||||
|
<template>
|
||||||
|
<div class="record-page">
|
||||||
|
<div class="page-header">
|
||||||
|
<h1 class="page-title">答题记录</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="page-content">
|
||||||
|
<!-- 统计卡片 -->
|
||||||
|
<StatisticsCards v-if="false" :statistics="statistics" :loading="statisticsLoading" />
|
||||||
|
|
||||||
|
<!-- 搜索和筛选栏 -->
|
||||||
|
<div class="toolbar">
|
||||||
|
<div class="search-section">
|
||||||
|
<a-input-search
|
||||||
|
v-model:value="searchKeyword"
|
||||||
|
placeholder="搜索学生姓名或家长手机号"
|
||||||
|
style="width: 250px"
|
||||||
|
@search="handleSearch"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<a-select
|
||||||
|
v-model:value="filterSchool"
|
||||||
|
placeholder="选择学校"
|
||||||
|
style="width: 150px; margin-left: 8px"
|
||||||
|
allow-clear
|
||||||
|
show-search
|
||||||
|
@change="handleSearch"
|
||||||
|
>
|
||||||
|
<a-select-option v-for="school in schoolOptions" :key="school" :value="school">
|
||||||
|
{{ school }}
|
||||||
|
</a-select-option>
|
||||||
|
</a-select>
|
||||||
|
|
||||||
|
<a-select
|
||||||
|
v-model:value="filterGrade"
|
||||||
|
placeholder="年级"
|
||||||
|
style="width: 100px; margin-left: 8px"
|
||||||
|
allow-clear
|
||||||
|
@change="handleSearch"
|
||||||
|
>
|
||||||
|
<a-select-option v-for="grade in gradeOptions" :key="grade" :value="grade">
|
||||||
|
{{ grade }}
|
||||||
|
</a-select-option>
|
||||||
|
</a-select>
|
||||||
|
|
||||||
|
<a-select
|
||||||
|
v-model:value="filterAnswererType"
|
||||||
|
placeholder="答题人类型"
|
||||||
|
style="width: 120px; margin-left: 8px"
|
||||||
|
allow-clear
|
||||||
|
@change="handleSearch"
|
||||||
|
>
|
||||||
|
<a-select-option value="parent">家长答题</a-select-option>
|
||||||
|
<a-select-option value="student">学生答题</a-select-option>
|
||||||
|
</a-select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="action-section">
|
||||||
|
<a-range-picker
|
||||||
|
v-model:value="dateRange"
|
||||||
|
:placeholder="['开始日期', '结束日期']"
|
||||||
|
@change="handleDateChange"
|
||||||
|
style="margin-right: 8px"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<a-button @click="handleExport">
|
||||||
|
<DownloadOutlined />
|
||||||
|
导出记录
|
||||||
|
</a-button>
|
||||||
|
|
||||||
|
<a-button @click="handleRefresh">
|
||||||
|
<ReloadOutlined />
|
||||||
|
刷新
|
||||||
|
</a-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 答题记录列表 -->
|
||||||
|
<RecordList
|
||||||
|
:loading="loading"
|
||||||
|
:data-source="records"
|
||||||
|
:pagination="pagination"
|
||||||
|
@detail="handleViewDetail"
|
||||||
|
@page-change="handlePageChange"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 答题详情弹窗 -->
|
||||||
|
<RecordDetailModal
|
||||||
|
v-model:visible="detailVisible"
|
||||||
|
:record="currentRecord"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, reactive, onMounted, computed } from 'vue';
|
||||||
|
import { message } from 'ant-design-vue';
|
||||||
|
import { DownloadOutlined, ReloadOutlined } from '@ant-design/icons-vue';
|
||||||
|
import { useRequest } from 'alova/client';
|
||||||
|
import { getRecordList, getAnswerStatistics, exportRecords } from '@/apis/records';
|
||||||
|
import type { AnswerRecord, RecordQueryParams } from '@/apis/records';
|
||||||
|
import type { Dayjs } from 'dayjs';
|
||||||
|
|
||||||
|
// 导入子组件
|
||||||
|
import StatisticsCards from './components/StatisticsCards.vue';
|
||||||
|
import RecordList from './components/RecordList.vue';
|
||||||
|
import RecordDetailModal from './components/RecordDetailModal.vue';
|
||||||
|
|
||||||
|
// 搜索筛选参数
|
||||||
|
const searchKeyword = ref('');
|
||||||
|
const filterSchool = ref<string>();
|
||||||
|
const filterGrade = ref<string>();
|
||||||
|
const filterAnswererType = ref<string>();
|
||||||
|
const dateRange = ref<[Dayjs, Dayjs]>();
|
||||||
|
|
||||||
|
// 列表数据
|
||||||
|
const records = ref<AnswerRecord[]>([]);
|
||||||
|
const statistics = ref<any>({});
|
||||||
|
|
||||||
|
// 分页
|
||||||
|
const pagination = reactive({
|
||||||
|
current: 1,
|
||||||
|
pageSize: 10,
|
||||||
|
total: 0,
|
||||||
|
showSizeChanger: true,
|
||||||
|
showQuickJumper: true,
|
||||||
|
showTotal: (total: number) => `共 ${total} 条记录`
|
||||||
|
});
|
||||||
|
|
||||||
|
// 详情弹窗
|
||||||
|
const detailVisible = ref(false);
|
||||||
|
const currentRecord = ref<AnswerRecord>();
|
||||||
|
|
||||||
|
// 筛选选项
|
||||||
|
const schoolOptions = ref<string[]>([]);
|
||||||
|
const gradeOptions = ref<string[]>([]);
|
||||||
|
|
||||||
|
// 获取记录列表
|
||||||
|
const { loading, send: fetchRecords } = useRequest((params: RecordQueryParams) => getRecordList(params), {
|
||||||
|
immediate: false
|
||||||
|
});
|
||||||
|
|
||||||
|
// 获取统计数据
|
||||||
|
const { loading: statisticsLoading, send: fetchStatistics } = useRequest(
|
||||||
|
(params?: any) => getAnswerStatistics(params),
|
||||||
|
{ immediate: false }
|
||||||
|
);
|
||||||
|
|
||||||
|
// 构建查询参数
|
||||||
|
const buildQueryParams = (): RecordQueryParams => {
|
||||||
|
const params: RecordQueryParams = {
|
||||||
|
page: pagination.current,
|
||||||
|
pageSize: pagination.pageSize,
|
||||||
|
keyword: searchKeyword.value || undefined,
|
||||||
|
schoolName: filterSchool.value,
|
||||||
|
gradeName: filterGrade.value,
|
||||||
|
answererType: filterAnswererType.value as any
|
||||||
|
};
|
||||||
|
|
||||||
|
if (dateRange.value) {
|
||||||
|
params.startTime = dateRange.value[0].format('YYYY-MM-DD');
|
||||||
|
params.endTime = dateRange.value[1].format('YYYY-MM-DD');
|
||||||
|
}
|
||||||
|
|
||||||
|
return params;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 搜索处理
|
||||||
|
const handleSearch = () => {
|
||||||
|
pagination.current = 1;
|
||||||
|
loadRecords();
|
||||||
|
};
|
||||||
|
|
||||||
|
// 日期范围变化
|
||||||
|
const handleDateChange = () => {
|
||||||
|
handleSearch();
|
||||||
|
};
|
||||||
|
|
||||||
|
// 加载记录列表
|
||||||
|
const loadRecords = async () => {
|
||||||
|
try {
|
||||||
|
const params = buildQueryParams();
|
||||||
|
const result = await fetchRecords(params);
|
||||||
|
|
||||||
|
records.value = result.list;
|
||||||
|
pagination.total = result.total;
|
||||||
|
|
||||||
|
// 提取筛选选项
|
||||||
|
extractFilterOptions(result.list);
|
||||||
|
} catch (error: any) {
|
||||||
|
message.error(error.message || '获取答题记录失败');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 加载统计数据
|
||||||
|
const loadStatistics = async () => {
|
||||||
|
try {
|
||||||
|
const params = buildQueryParams();
|
||||||
|
delete params.page;
|
||||||
|
delete params.pageSize;
|
||||||
|
|
||||||
|
const result = await fetchStatistics(params);
|
||||||
|
statistics.value = result;
|
||||||
|
} catch (error: any) {
|
||||||
|
message.error(error.message || '获取统计数据失败');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 提取筛选选项
|
||||||
|
const extractFilterOptions = (recordList: AnswerRecord[]) => {
|
||||||
|
const schools = new Set<string>();
|
||||||
|
const grades = new Set<string>();
|
||||||
|
|
||||||
|
recordList.forEach(record => {
|
||||||
|
schools.add(record.schoolName);
|
||||||
|
grades.add(record.gradeName);
|
||||||
|
});
|
||||||
|
|
||||||
|
schoolOptions.value = Array.from(schools).sort();
|
||||||
|
gradeOptions.value = Array.from(grades).sort();
|
||||||
|
};
|
||||||
|
|
||||||
|
// 查看详情
|
||||||
|
const handleViewDetail = (record: AnswerRecord) => {
|
||||||
|
currentRecord.value = record;
|
||||||
|
detailVisible.value = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 导出记录
|
||||||
|
const handleExport = async () => {
|
||||||
|
try {
|
||||||
|
const params = buildQueryParams();
|
||||||
|
delete params.page;
|
||||||
|
delete params.pageSize;
|
||||||
|
|
||||||
|
const blob = await exportRecords(params);
|
||||||
|
|
||||||
|
// 创建下载链接
|
||||||
|
const url = window.URL.createObjectURL(blob);
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.style.display = 'none';
|
||||||
|
link.href = url;
|
||||||
|
link.download = `答题记录_${new Date().toISOString().slice(0, 10)}.xlsx`;
|
||||||
|
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
document.body.removeChild(link);
|
||||||
|
|
||||||
|
// 释放URL对象
|
||||||
|
window.URL.revokeObjectURL(url);
|
||||||
|
|
||||||
|
message.success('导出成功');
|
||||||
|
} catch (error: any) {
|
||||||
|
message.error(error.message || '导出失败');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 刷新数据
|
||||||
|
const handleRefresh = () => {
|
||||||
|
loadRecords();
|
||||||
|
loadStatistics();
|
||||||
|
};
|
||||||
|
|
||||||
|
// 分页改变
|
||||||
|
const handlePageChange = (page: number, pageSize: number) => {
|
||||||
|
pagination.current = page;
|
||||||
|
pagination.pageSize = pageSize;
|
||||||
|
loadRecords();
|
||||||
|
};
|
||||||
|
|
||||||
|
// 初始化
|
||||||
|
onMounted(() => {
|
||||||
|
loadRecords();
|
||||||
|
loadStatistics();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.record-page {
|
||||||
|
.page-header {
|
||||||
|
background: #fff;
|
||||||
|
padding: 12px 24px;
|
||||||
|
border-radius: 8px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
|
||||||
|
|
||||||
|
.page-title {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #262626;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin: 16px 0;
|
||||||
|
padding: 16px;
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
|
||||||
|
|
||||||
|
.search-section {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-section {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 响应式设计 */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.toolbar {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
gap: 16px;
|
||||||
|
|
||||||
|
.search-section {
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-section {
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
468
src/views/records/components/RecordDetailModal.vue
Normal file
468
src/views/records/components/RecordDetailModal.vue
Normal file
@@ -0,0 +1,468 @@
|
|||||||
|
<template>
|
||||||
|
<a-modal
|
||||||
|
:open="visible"
|
||||||
|
:title="`答题详情 - ${record?.studentName || ''}`"
|
||||||
|
:width="900"
|
||||||
|
:footer="null"
|
||||||
|
@cancel="$emit('update:visible', false)"
|
||||||
|
>
|
||||||
|
<div v-if="record" class="record-detail">
|
||||||
|
<!-- 基本信息 -->
|
||||||
|
<div class="basic-info">
|
||||||
|
<a-descriptions :column="2" bordered size="small">
|
||||||
|
<a-descriptions-item label="学生姓名">
|
||||||
|
{{ record.studentName }}
|
||||||
|
</a-descriptions-item>
|
||||||
|
<a-descriptions-item label="家长手机">
|
||||||
|
{{ record.parentPhone }}
|
||||||
|
</a-descriptions-item>
|
||||||
|
<a-descriptions-item label="所属学校">
|
||||||
|
{{ record.schoolName }}
|
||||||
|
</a-descriptions-item>
|
||||||
|
<a-descriptions-item label="年级班级">
|
||||||
|
{{ record.gradeName }} · {{ record.className }}
|
||||||
|
</a-descriptions-item>
|
||||||
|
<a-descriptions-item label="答题人">
|
||||||
|
<a-tag :color="getAnswererTypeColor(record.answererType)">
|
||||||
|
{{ getAnswererTypeText(record.answererType) }}
|
||||||
|
</a-tag>
|
||||||
|
</a-descriptions-item>
|
||||||
|
<a-descriptions-item label="答题时间">
|
||||||
|
{{ formatDateTime(record.createTime) }}
|
||||||
|
</a-descriptions-item>
|
||||||
|
</a-descriptions>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 答题统计 -->
|
||||||
|
<div class="answer-stats">
|
||||||
|
<a-row :gutter="16">
|
||||||
|
<a-col :span="6">
|
||||||
|
<a-statistic
|
||||||
|
title="题目总数"
|
||||||
|
:value="record.totalQuestions"
|
||||||
|
:value-style="{ color: '#1890ff', fontSize: '20px' }"
|
||||||
|
/>
|
||||||
|
</a-col>
|
||||||
|
<a-col :span="6">
|
||||||
|
<a-statistic
|
||||||
|
title="答对题数"
|
||||||
|
:value="record.correctCount"
|
||||||
|
:value-style="{ color: '#52c41a', fontSize: '20px' }"
|
||||||
|
/>
|
||||||
|
</a-col>
|
||||||
|
<a-col :span="6">
|
||||||
|
<a-statistic
|
||||||
|
title="总得分"
|
||||||
|
:value="record.totalScore"
|
||||||
|
:value-style="{ color: '#f5222d', fontSize: '20px' }"
|
||||||
|
suffix="分"
|
||||||
|
/>
|
||||||
|
</a-col>
|
||||||
|
<a-col :span="6">
|
||||||
|
<a-statistic
|
||||||
|
title="答题用时"
|
||||||
|
:value="formatDuration(record.answerTime)"
|
||||||
|
:value-style="{ color: '#722ed1', fontSize: '16px' }"
|
||||||
|
/>
|
||||||
|
</a-col>
|
||||||
|
</a-row>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 题目详情 -->
|
||||||
|
<div class="question-details">
|
||||||
|
<h3>答题详情</h3>
|
||||||
|
|
||||||
|
<div class="question-list">
|
||||||
|
<div
|
||||||
|
v-for="(question, index) in record.questionDetails"
|
||||||
|
:key="question.questionId"
|
||||||
|
class="question-item"
|
||||||
|
>
|
||||||
|
<div class="question-header">
|
||||||
|
<div class="question-number">第{{ index + 1}}题</div>
|
||||||
|
<div class="question-result">
|
||||||
|
<a-tag :color="question.isCorrect ? 'success' : 'error'">
|
||||||
|
{{ question.isCorrect ? '正确' : '错误' }}
|
||||||
|
</a-tag>
|
||||||
|
<span class="question-score">
|
||||||
|
得分: {{ question.score }}分
|
||||||
|
</span>
|
||||||
|
<span class="question-time">
|
||||||
|
用时: {{ question.timeSpent }}秒
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="question-content">
|
||||||
|
<div class="question-title">
|
||||||
|
<strong>{{ question.questionTitle }}</strong>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="answer-section">
|
||||||
|
<div class="user-answer">
|
||||||
|
<span class="label">你的答案:</span>
|
||||||
|
<div class="answer-value">
|
||||||
|
<a-tag
|
||||||
|
v-for="answer in question.userAnswer"
|
||||||
|
:key="answer"
|
||||||
|
:color="question.isCorrect ? 'green' : 'red'"
|
||||||
|
>
|
||||||
|
{{ answer }}
|
||||||
|
</a-tag>
|
||||||
|
<span v-if="question.userAnswer.length === 0" class="no-answer">
|
||||||
|
未作答
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="correct-answer">
|
||||||
|
<span class="label">正确答案:</span>
|
||||||
|
<div class="answer-value">
|
||||||
|
<a-tag
|
||||||
|
v-for="answer in question.correctAnswer"
|
||||||
|
:key="answer"
|
||||||
|
color="blue"
|
||||||
|
>
|
||||||
|
{{ answer }}
|
||||||
|
</a-tag>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 答题分析 -->
|
||||||
|
<div class="answer-analysis">
|
||||||
|
<h3>答题分析</h3>
|
||||||
|
<a-row :gutter="16">
|
||||||
|
<a-col :span="8">
|
||||||
|
<a-card size="small">
|
||||||
|
<a-statistic
|
||||||
|
title="正确率"
|
||||||
|
:value="((record.correctCount / record.totalQuestions) * 100)"
|
||||||
|
:precision="1"
|
||||||
|
suffix="%"
|
||||||
|
:value-style="{
|
||||||
|
color: getCorrectRateColor((record.correctCount / record.totalQuestions) * 100)
|
||||||
|
}"
|
||||||
|
/>
|
||||||
|
</a-card>
|
||||||
|
</a-col>
|
||||||
|
<a-col :span="8">
|
||||||
|
<a-card size="small">
|
||||||
|
<a-statistic
|
||||||
|
title="平均用时"
|
||||||
|
:value="(record.answerTime / record.totalQuestions)"
|
||||||
|
:precision="1"
|
||||||
|
suffix="秒/题"
|
||||||
|
:value-style="{ color: '#1890ff' }"
|
||||||
|
/>
|
||||||
|
</a-card>
|
||||||
|
</a-col>
|
||||||
|
<a-col :span="8">
|
||||||
|
<a-card size="small">
|
||||||
|
<a-statistic
|
||||||
|
title="得分率"
|
||||||
|
:value="getScoreRate()"
|
||||||
|
:precision="1"
|
||||||
|
suffix="%"
|
||||||
|
:value-style="{ color: '#f5222d' }"
|
||||||
|
/>
|
||||||
|
</a-card>
|
||||||
|
</a-col>
|
||||||
|
</a-row>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</a-modal>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
// Vue组件,无需导入computed
|
||||||
|
import type { AnswerRecord } from '@/apis/records';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
visible: boolean;
|
||||||
|
record?: AnswerRecord;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Emits {
|
||||||
|
(e: 'update:visible', visible: boolean): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<Props>();
|
||||||
|
defineEmits<Emits>();
|
||||||
|
|
||||||
|
// 答题人类型颜色映射
|
||||||
|
const getAnswererTypeColor = (type: string) => {
|
||||||
|
const colorMap: Record<string, string> = {
|
||||||
|
parent: 'blue',
|
||||||
|
student: 'green'
|
||||||
|
};
|
||||||
|
return colorMap[type] || 'default';
|
||||||
|
};
|
||||||
|
|
||||||
|
// 答题人类型文本映射
|
||||||
|
const getAnswererTypeText = (type: string) => {
|
||||||
|
const textMap: Record<string, string> = {
|
||||||
|
parent: '家长答题',
|
||||||
|
student: '学生答题'
|
||||||
|
};
|
||||||
|
return textMap[type] || type;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 格式化日期时间
|
||||||
|
const formatDateTime = (dateTime: string) => {
|
||||||
|
return new Date(dateTime).toLocaleString('zh-CN', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: '2-digit',
|
||||||
|
day: '2-digit',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit'
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// 格式化时长(秒转换为分秒)
|
||||||
|
const formatDuration = (seconds: number) => {
|
||||||
|
const minutes = Math.floor(seconds / 60);
|
||||||
|
const remainingSeconds = seconds % 60;
|
||||||
|
|
||||||
|
if (minutes > 0) {
|
||||||
|
return `${minutes}分${remainingSeconds}秒`;
|
||||||
|
} else {
|
||||||
|
return `${remainingSeconds}秒`;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 获取正确率颜色
|
||||||
|
const getCorrectRateColor = (rate: number) => {
|
||||||
|
if (rate >= 80) return '#52c41a';
|
||||||
|
if (rate >= 60) return '#faad14';
|
||||||
|
return '#f5222d';
|
||||||
|
};
|
||||||
|
|
||||||
|
// 计算得分率
|
||||||
|
const getScoreRate = () => {
|
||||||
|
if (!props.record) return 0;
|
||||||
|
|
||||||
|
const totalPossibleScore = props.record.questionDetails.reduce(
|
||||||
|
(sum, question) => sum + (question.isCorrect ? question.score : getQuestionMaxScore(question)),
|
||||||
|
0
|
||||||
|
);
|
||||||
|
|
||||||
|
if (totalPossibleScore === 0) return 0;
|
||||||
|
|
||||||
|
return (props.record.totalScore / totalPossibleScore) * 100;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 获取题目最大可能得分(这里简化处理,假设每题得分就是最大得分)
|
||||||
|
const getQuestionMaxScore = (question: any) => {
|
||||||
|
// 这里可以根据实际业务逻辑计算题目的最大可能得分
|
||||||
|
// 暂时假设正确时的得分就是该题的满分
|
||||||
|
return question.score || 10; // 默认10分
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.record-detail {
|
||||||
|
.basic-info {
|
||||||
|
margin-bottom: 24px;
|
||||||
|
|
||||||
|
:deep(.ant-descriptions) {
|
||||||
|
.ant-descriptions-item-label {
|
||||||
|
background: #fafafa;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.answer-stats {
|
||||||
|
margin-bottom: 32px;
|
||||||
|
padding: 20px;
|
||||||
|
background: #fafafa;
|
||||||
|
border-radius: 8px;
|
||||||
|
|
||||||
|
:deep(.ant-statistic) {
|
||||||
|
text-align: center;
|
||||||
|
|
||||||
|
.ant-statistic-title {
|
||||||
|
color: #666;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.question-details {
|
||||||
|
margin-bottom: 32px;
|
||||||
|
|
||||||
|
h3 {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
color: #333;
|
||||||
|
border-bottom: 2px solid #1890ff;
|
||||||
|
padding-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.question-list {
|
||||||
|
.question-item {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
padding: 16px;
|
||||||
|
border: 1px solid #f0f0f0;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #fff;
|
||||||
|
|
||||||
|
&:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.question-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
|
||||||
|
.question-number {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #1890ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.question-result {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
|
||||||
|
.question-score,
|
||||||
|
.question-time {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.question-content {
|
||||||
|
.question-title {
|
||||||
|
margin-bottom: 12px;
|
||||||
|
font-size: 14px;
|
||||||
|
color: #333;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.answer-section {
|
||||||
|
display: flex;
|
||||||
|
gap: 24px;
|
||||||
|
|
||||||
|
.user-answer,
|
||||||
|
.correct-answer {
|
||||||
|
flex: 1;
|
||||||
|
|
||||||
|
.label {
|
||||||
|
display: inline-block;
|
||||||
|
min-width: 80px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #666;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.answer-value {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 4px;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
.no-answer {
|
||||||
|
color: #999;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.answer-analysis {
|
||||||
|
h3 {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
color: #333;
|
||||||
|
border-bottom: 2px solid #52c41a;
|
||||||
|
padding-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.ant-card) {
|
||||||
|
text-align: center;
|
||||||
|
|
||||||
|
.ant-card-body {
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ant-statistic-title {
|
||||||
|
color: #666;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 响应式设计 */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.record-detail {
|
||||||
|
.answer-stats {
|
||||||
|
:deep(.ant-row) {
|
||||||
|
.ant-col {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
|
||||||
|
&:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.question-details {
|
||||||
|
.question-item {
|
||||||
|
.question-header {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.question-content {
|
||||||
|
.answer-section {
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.answer-analysis {
|
||||||
|
:deep(.ant-row) {
|
||||||
|
.ant-col {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
|
||||||
|
&:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 打印样式 */
|
||||||
|
@media print {
|
||||||
|
.record-detail {
|
||||||
|
.answer-stats {
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid #d9d9d9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.question-details .question-item {
|
||||||
|
break-inside: avoid;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
409
src/views/records/components/RecordList.vue
Normal file
409
src/views/records/components/RecordList.vue
Normal file
@@ -0,0 +1,409 @@
|
|||||||
|
<template>
|
||||||
|
<a-card class="record-list-card">
|
||||||
|
<a-table
|
||||||
|
:columns="columns"
|
||||||
|
:data-source="dataSource"
|
||||||
|
:loading="loading"
|
||||||
|
:pagination="paginationConfig"
|
||||||
|
row-key="id"
|
||||||
|
@change="handleTableChange"
|
||||||
|
>
|
||||||
|
<!-- 学生信息 -->
|
||||||
|
<template #bodyCell="{ column, record }">
|
||||||
|
<template v-if="column.key === 'studentInfo'">
|
||||||
|
<div class="student-info">
|
||||||
|
<div class="student-basic">
|
||||||
|
<h4>{{ record.studentName }}</h4>
|
||||||
|
<div class="student-details">
|
||||||
|
<span class="phone">{{ record.parentPhone }}</span>
|
||||||
|
<a-tag :color="getAnswererTypeColor(record.answererType)" size="small">
|
||||||
|
{{ getAnswererTypeText(record.answererType) }}
|
||||||
|
</a-tag>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="school-info">
|
||||||
|
<div class="school-name">{{ record.schoolName }}</div>
|
||||||
|
<div class="class-info">{{ record.gradeName }} · {{ record.className }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template v-else-if="column.key === 'answerInfo'">
|
||||||
|
<div class="answer-info">
|
||||||
|
<div class="answer-summary">
|
||||||
|
<span class="total-questions">共{{ record.totalQuestions }}题</span>
|
||||||
|
<span class="correct-count">答对{{ record.correctCount }}题</span>
|
||||||
|
</div>
|
||||||
|
<div class="answer-time">
|
||||||
|
用时: {{ formatDuration(record.answerTime) }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template v-else-if="column.key === 'score'">
|
||||||
|
<div class="score-info">
|
||||||
|
<div class="total-score">{{ record.totalScore }}分</div>
|
||||||
|
<div class="accuracy">
|
||||||
|
正确率: {{ ((record.correctCount / record.totalQuestions) * 100).toFixed(1) }}%
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template v-else-if="column.key === 'createTime'">
|
||||||
|
<div class="time-info">
|
||||||
|
<div class="date">{{ formatDate(record.createTime) }}</div>
|
||||||
|
<div class="time">{{ formatTime(record.createTime) }}</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template v-else-if="column.key === 'action'">
|
||||||
|
<div class="action-buttons">
|
||||||
|
<a-button type="link" size="small" @click="$emit('detail', record)">
|
||||||
|
<EyeOutlined />
|
||||||
|
查看详情
|
||||||
|
</a-button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</template>
|
||||||
|
</a-table>
|
||||||
|
</a-card>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue';
|
||||||
|
import { EyeOutlined } from '@ant-design/icons-vue';
|
||||||
|
import type { AnswerRecord } from '@/apis/records';
|
||||||
|
import type { TableColumnsType } from 'ant-design-vue';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
loading?: boolean;
|
||||||
|
dataSource: AnswerRecord[];
|
||||||
|
pagination: {
|
||||||
|
current: number;
|
||||||
|
pageSize: number;
|
||||||
|
total: number;
|
||||||
|
showSizeChanger?: boolean;
|
||||||
|
showQuickJumper?: boolean;
|
||||||
|
showTotal?: (total: number) => string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Emits {
|
||||||
|
(e: 'detail', record: AnswerRecord): void;
|
||||||
|
(e: 'page-change', page: number, pageSize: number): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<Props>();
|
||||||
|
const emit = defineEmits<Emits>();
|
||||||
|
|
||||||
|
// 表格列配置
|
||||||
|
const columns: TableColumnsType = [
|
||||||
|
{
|
||||||
|
title: '学生信息',
|
||||||
|
key: 'studentInfo',
|
||||||
|
width: 280,
|
||||||
|
fixed: 'left'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '答题情况',
|
||||||
|
key: 'answerInfo',
|
||||||
|
width: 150,
|
||||||
|
align: 'center'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '得分情况',
|
||||||
|
key: 'score',
|
||||||
|
width: 120,
|
||||||
|
align: 'center'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '答题时间',
|
||||||
|
key: 'createTime',
|
||||||
|
width: 140,
|
||||||
|
align: 'center'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '操作',
|
||||||
|
key: 'action',
|
||||||
|
width: 100,
|
||||||
|
align: 'center',
|
||||||
|
fixed: 'right'
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
// 分页配置
|
||||||
|
const paginationConfig = computed(() => ({
|
||||||
|
...props.pagination,
|
||||||
|
onChange: (page: number, pageSize: number) => emit('page-change', page, pageSize),
|
||||||
|
onShowSizeChange: (current: number, size: number) => emit('page-change', current, size)
|
||||||
|
}));
|
||||||
|
|
||||||
|
// 表格变化处理
|
||||||
|
const handleTableChange = (pagination: any) => {
|
||||||
|
emit('page-change', pagination.current, pagination.pageSize);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 答题人类型颜色映射
|
||||||
|
const getAnswererTypeColor = (type: string) => {
|
||||||
|
const colorMap: Record<string, string> = {
|
||||||
|
parent: 'blue',
|
||||||
|
student: 'green'
|
||||||
|
};
|
||||||
|
return colorMap[type] || 'default';
|
||||||
|
};
|
||||||
|
|
||||||
|
// 答题人类型文本映射
|
||||||
|
const getAnswererTypeText = (type: string) => {
|
||||||
|
const textMap: Record<string, string> = {
|
||||||
|
parent: '家长答题',
|
||||||
|
student: '学生答题'
|
||||||
|
};
|
||||||
|
return textMap[type] || type;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 格式化时长(秒转换为分秒)
|
||||||
|
const formatDuration = (seconds: number) => {
|
||||||
|
const minutes = Math.floor(seconds / 60);
|
||||||
|
const remainingSeconds = seconds % 60;
|
||||||
|
|
||||||
|
if (minutes > 0) {
|
||||||
|
return `${minutes}分${remainingSeconds}秒`;
|
||||||
|
} else {
|
||||||
|
return `${remainingSeconds}秒`;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 格式化日期
|
||||||
|
const formatDate = (dateTime: string) => {
|
||||||
|
return new Date(dateTime).toLocaleDateString('zh-CN', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: '2-digit',
|
||||||
|
day: '2-digit'
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// 格式化时间
|
||||||
|
const formatTime = (dateTime: string) => {
|
||||||
|
return new Date(dateTime).toLocaleTimeString('zh-CN', {
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit'
|
||||||
|
});
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.record-list-card {
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
|
||||||
|
|
||||||
|
:deep(.ant-table) {
|
||||||
|
.ant-table-thead > tr > th {
|
||||||
|
background: #fafafa;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ant-table-tbody > tr > td {
|
||||||
|
vertical-align: top;
|
||||||
|
padding: 16px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ant-table-tbody > tr {
|
||||||
|
&:hover {
|
||||||
|
background: #f8f9ff;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.student-info {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
|
||||||
|
.student-basic {
|
||||||
|
h4 {
|
||||||
|
margin: 0 0 4px 0;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.student-details {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
|
||||||
|
.phone {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #666;
|
||||||
|
font-family: 'Monaco', 'Menlo', monospace;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.school-info {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #666;
|
||||||
|
|
||||||
|
.school-name {
|
||||||
|
font-weight: 500;
|
||||||
|
margin-bottom: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.class-info {
|
||||||
|
color: #999;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.answer-info {
|
||||||
|
text-align: center;
|
||||||
|
|
||||||
|
.answer-summary {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
|
||||||
|
.total-questions {
|
||||||
|
font-size: 14px;
|
||||||
|
color: #333;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.correct-count {
|
||||||
|
font-size: 13px;
|
||||||
|
color: #52c41a;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.answer-time {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.score-info {
|
||||||
|
text-align: center;
|
||||||
|
|
||||||
|
.total-score {
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #1890ff;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.accuracy {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.time-info {
|
||||||
|
text-align: center;
|
||||||
|
font-size: 12px;
|
||||||
|
|
||||||
|
.date {
|
||||||
|
color: #333;
|
||||||
|
margin-bottom: 2px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.time {
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-buttons {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
|
||||||
|
.ant-btn-link {
|
||||||
|
padding: 0;
|
||||||
|
height: auto;
|
||||||
|
color: #1890ff;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: #40a9ff;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 响应式设计 */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.record-list-card {
|
||||||
|
:deep(.ant-table) {
|
||||||
|
.ant-table-thead {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ant-table-tbody > tr > td {
|
||||||
|
display: block;
|
||||||
|
border: none;
|
||||||
|
padding: 8px 16px;
|
||||||
|
|
||||||
|
&:first-child {
|
||||||
|
padding-top: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:last-child {
|
||||||
|
padding-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:before {
|
||||||
|
content: attr(data-label);
|
||||||
|
font-weight: 600;
|
||||||
|
color: #666;
|
||||||
|
display: inline-block;
|
||||||
|
width: 80px;
|
||||||
|
margin-right: 8px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.ant-table-tbody > tr {
|
||||||
|
border-bottom: 8px solid #f5f5f5;
|
||||||
|
margin-bottom: 0;
|
||||||
|
|
||||||
|
&:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.student-info {
|
||||||
|
.student-basic {
|
||||||
|
.student-details {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.answer-info .answer-summary {
|
||||||
|
flex-direction: row;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 打印样式 */
|
||||||
|
@media print {
|
||||||
|
.action-buttons {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.record-list-card {
|
||||||
|
box-shadow: none;
|
||||||
|
|
||||||
|
:deep(.ant-table) {
|
||||||
|
font-size: 12px;
|
||||||
|
|
||||||
|
.ant-table-tbody > tr > td {
|
||||||
|
padding: 8px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
384
src/views/records/components/StatisticsCards.vue
Normal file
384
src/views/records/components/StatisticsCards.vue
Normal file
@@ -0,0 +1,384 @@
|
|||||||
|
<template>
|
||||||
|
<a-row :gutter="[16, 16]" class="statistics-cards">
|
||||||
|
<a-col :xs="24" :sm="12" :md="6">
|
||||||
|
<a-card :loading="loading" class="stat-card">
|
||||||
|
<a-statistic
|
||||||
|
title="总答题记录"
|
||||||
|
:value="statistics.totalRecords || 0"
|
||||||
|
:value-style="{ color: '#3f8600', fontSize: '24px' }"
|
||||||
|
>
|
||||||
|
<template #prefix>
|
||||||
|
<FileTextOutlined />
|
||||||
|
</template>
|
||||||
|
</a-statistic>
|
||||||
|
</a-card>
|
||||||
|
</a-col>
|
||||||
|
|
||||||
|
<a-col :xs="24" :sm="12" :md="6">
|
||||||
|
<a-card :loading="loading" class="stat-card">
|
||||||
|
<a-statistic
|
||||||
|
title="参与用户数"
|
||||||
|
:value="statistics.totalUsers || 0"
|
||||||
|
:value-style="{ color: '#1890ff', fontSize: '24px' }"
|
||||||
|
>
|
||||||
|
<template #prefix>
|
||||||
|
<UserOutlined />
|
||||||
|
</template>
|
||||||
|
</a-statistic>
|
||||||
|
</a-card>
|
||||||
|
</a-col>
|
||||||
|
|
||||||
|
<a-col :xs="24" :sm="12" :md="6">
|
||||||
|
<a-card :loading="loading" class="stat-card">
|
||||||
|
<a-statistic
|
||||||
|
title="平均得分"
|
||||||
|
:value="statistics.avgScore || 0"
|
||||||
|
:precision="1"
|
||||||
|
:value-style="{ color: '#cf1322', fontSize: '24px' }"
|
||||||
|
suffix="分"
|
||||||
|
>
|
||||||
|
<template #prefix>
|
||||||
|
<TrophyOutlined />
|
||||||
|
</template>
|
||||||
|
</a-statistic>
|
||||||
|
</a-card>
|
||||||
|
</a-col>
|
||||||
|
|
||||||
|
<a-col :xs="24" :sm="12" :md="6">
|
||||||
|
<a-card :loading="loading" class="stat-card">
|
||||||
|
<a-statistic
|
||||||
|
title="平均正确率"
|
||||||
|
:value="statistics.avgCorrectRate || 0"
|
||||||
|
:precision="1"
|
||||||
|
:value-style="{ color: '#722ed1', fontSize: '24px' }"
|
||||||
|
suffix="%"
|
||||||
|
>
|
||||||
|
<template #prefix>
|
||||||
|
<CheckCircleOutlined />
|
||||||
|
</template>
|
||||||
|
</a-statistic>
|
||||||
|
</a-card>
|
||||||
|
</a-col>
|
||||||
|
</a-row>
|
||||||
|
|
||||||
|
<!-- 热门题目和排行榜 -->
|
||||||
|
<a-row :gutter="[16, 16]" style="margin-top: 16px">
|
||||||
|
<!-- 热门题目 -->
|
||||||
|
<a-col :xs="24" :lg="12">
|
||||||
|
<a-card title="热门题目" :loading="loading" class="popular-questions">
|
||||||
|
<div v-if="statistics.popularQuestions?.length > 0" class="question-list">
|
||||||
|
<div
|
||||||
|
v-for="(question, index) in statistics.popularQuestions.slice(0, 5)"
|
||||||
|
:key="question.questionId"
|
||||||
|
class="question-item"
|
||||||
|
>
|
||||||
|
<div class="question-rank">{{ index + 1 }}</div>
|
||||||
|
<div class="question-info">
|
||||||
|
<div class="question-title">{{ question.questionTitle }}</div>
|
||||||
|
<div class="question-stats">
|
||||||
|
<span>答题次数: {{ question.answerCount }}</span>
|
||||||
|
<span class="divider">|</span>
|
||||||
|
<span>正确率: {{ (question.correctRate * 100).toFixed(1) }}%</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<a-empty v-else description="暂无数据" />
|
||||||
|
</a-card>
|
||||||
|
</a-col>
|
||||||
|
|
||||||
|
<!-- 学校排行榜 -->
|
||||||
|
<a-col :xs="24" :lg="12">
|
||||||
|
<a-card :loading="loading" class="ranking-card">
|
||||||
|
<template #title>
|
||||||
|
<a-tabs v-model:activeKey="activeRankingTab" size="small">
|
||||||
|
<a-tab-pane key="school" tab="学校排行" />
|
||||||
|
<a-tab-pane key="grade" tab="年级排行" />
|
||||||
|
<a-tab-pane key="class" tab="班级排行" />
|
||||||
|
</a-tabs>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<div class="ranking-content">
|
||||||
|
<!-- 学校排行 -->
|
||||||
|
<div v-if="activeRankingTab === 'school'" class="ranking-list">
|
||||||
|
<div
|
||||||
|
v-for="(item, index) in statistics.rankingData?.schoolRanking?.slice(0, 5)"
|
||||||
|
:key="item.name"
|
||||||
|
class="ranking-item"
|
||||||
|
>
|
||||||
|
<div class="ranking-position">
|
||||||
|
<a-badge
|
||||||
|
:count="index + 1"
|
||||||
|
:number-style="getRankingStyle(index)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="ranking-name">{{ item.name }}</div>
|
||||||
|
<div class="ranking-score">{{ item.avgScore.toFixed(1) }}分</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 年级排行 -->
|
||||||
|
<div v-if="activeRankingTab === 'grade'" class="ranking-list">
|
||||||
|
<div
|
||||||
|
v-for="(item, index) in statistics.rankingData?.gradeRanking?.slice(0, 5)"
|
||||||
|
:key="item.name"
|
||||||
|
class="ranking-item"
|
||||||
|
>
|
||||||
|
<div class="ranking-position">
|
||||||
|
<a-badge
|
||||||
|
:count="index + 1"
|
||||||
|
:number-style="getRankingStyle(index)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="ranking-name">{{ item.name }}</div>
|
||||||
|
<div class="ranking-score">{{ item.avgScore.toFixed(1) }}分</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 班级排行 -->
|
||||||
|
<div v-if="activeRankingTab === 'class'" class="ranking-list">
|
||||||
|
<div
|
||||||
|
v-for="(item, index) in statistics.rankingData?.classRanking?.slice(0, 5)"
|
||||||
|
:key="item.name"
|
||||||
|
class="ranking-item"
|
||||||
|
>
|
||||||
|
<div class="ranking-position">
|
||||||
|
<a-badge
|
||||||
|
:count="index + 1"
|
||||||
|
:number-style="getRankingStyle(index)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="ranking-name">{{ item.name }}</div>
|
||||||
|
<div class="ranking-score">{{ item.avgScore.toFixed(1) }}分</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<a-empty v-if="!hasRankingData" description="暂无数据" />
|
||||||
|
</div>
|
||||||
|
</a-card>
|
||||||
|
</a-col>
|
||||||
|
</a-row>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed } from 'vue';
|
||||||
|
import {
|
||||||
|
FileTextOutlined,
|
||||||
|
UserOutlined,
|
||||||
|
TrophyOutlined,
|
||||||
|
CheckCircleOutlined
|
||||||
|
} from '@ant-design/icons-vue';
|
||||||
|
import type { AnswerStatistics } from '@/apis/records';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
statistics: AnswerStatistics;
|
||||||
|
loading?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<Props>();
|
||||||
|
|
||||||
|
// 排行榜切换
|
||||||
|
const activeRankingTab = ref('school');
|
||||||
|
|
||||||
|
// 是否有排行榜数据
|
||||||
|
const hasRankingData = computed(() => {
|
||||||
|
const rankingData = props.statistics.rankingData;
|
||||||
|
if (!rankingData) return false;
|
||||||
|
|
||||||
|
switch (activeRankingTab.value) {
|
||||||
|
case 'school':
|
||||||
|
return rankingData.schoolRanking && rankingData.schoolRanking.length > 0;
|
||||||
|
case 'grade':
|
||||||
|
return rankingData.gradeRanking && rankingData.gradeRanking.length > 0;
|
||||||
|
case 'class':
|
||||||
|
return rankingData.classRanking && rankingData.classRanking.length > 0;
|
||||||
|
default:
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 获取排名样式
|
||||||
|
const getRankingStyle = (index: number) => {
|
||||||
|
const colors = ['#f5222d', '#fa8c16', '#fadb14'];
|
||||||
|
return {
|
||||||
|
backgroundColor: colors[index] || '#52c41a',
|
||||||
|
color: '#fff'
|
||||||
|
};
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.statistics-cards {
|
||||||
|
.stat-card {
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||||
|
transform: translateY(-2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.ant-statistic) {
|
||||||
|
.ant-statistic-title {
|
||||||
|
color: #666;
|
||||||
|
font-size: 14px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ant-statistic-content {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
|
||||||
|
.ant-statistic-content-prefix {
|
||||||
|
font-size: 20px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.popular-questions {
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
|
||||||
|
|
||||||
|
.question-list {
|
||||||
|
.question-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
padding: 12px 0;
|
||||||
|
border-bottom: 1px solid #f0f0f0;
|
||||||
|
|
||||||
|
&:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.question-rank {
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #1890ff;
|
||||||
|
color: white;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
margin-right: 12px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.question-info {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
|
||||||
|
.question-title {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #333;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.question-stats {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #666;
|
||||||
|
|
||||||
|
.divider {
|
||||||
|
margin: 0 8px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.ranking-card {
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
|
||||||
|
|
||||||
|
:deep(.ant-card-head) {
|
||||||
|
padding: 16px 24px 0;
|
||||||
|
border-bottom: none;
|
||||||
|
|
||||||
|
.ant-card-head-title {
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ant-tabs {
|
||||||
|
.ant-tabs-tab {
|
||||||
|
padding: 8px 16px;
|
||||||
|
margin: 0;
|
||||||
|
|
||||||
|
&:first-child {
|
||||||
|
margin-left: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.ant-tabs-content-holder {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.ranking-content {
|
||||||
|
min-height: 200px;
|
||||||
|
|
||||||
|
.ranking-list {
|
||||||
|
.ranking-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 12px 0;
|
||||||
|
border-bottom: 1px solid #f0f0f0;
|
||||||
|
|
||||||
|
&:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ranking-position {
|
||||||
|
width: 40px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ranking-name {
|
||||||
|
flex: 1;
|
||||||
|
font-size: 14px;
|
||||||
|
color: #333;
|
||||||
|
margin-left: 12px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ranking-score {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #1890ff;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 响应式设计 */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.statistics-cards {
|
||||||
|
.stat-card {
|
||||||
|
:deep(.ant-statistic-content) {
|
||||||
|
.ant-statistic-content-value {
|
||||||
|
font-size: 20px !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.popular-questions,
|
||||||
|
.ranking-card {
|
||||||
|
:deep(.ant-card-body) {
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
402
src/views/schools/SchoolPage.vue
Normal file
402
src/views/schools/SchoolPage.vue
Normal file
@@ -0,0 +1,402 @@
|
|||||||
|
<template>
|
||||||
|
<div class="school-page">
|
||||||
|
<div class="page-header">
|
||||||
|
<h1 class="page-title">学校管理</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="page-content">
|
||||||
|
<!-- 工具栏 -->
|
||||||
|
<div class="toolbar">
|
||||||
|
<div class="search-section">
|
||||||
|
<a-input-search
|
||||||
|
v-model:value="searchKeyword"
|
||||||
|
placeholder="搜索学校名称"
|
||||||
|
style="width: 300px"
|
||||||
|
@search="handleSearch"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<a-select
|
||||||
|
v-model:value="filterDistrict"
|
||||||
|
placeholder="选择区县"
|
||||||
|
style="width: 150px; margin-left: 8px"
|
||||||
|
allow-clear
|
||||||
|
@change="handleSearch"
|
||||||
|
>
|
||||||
|
<a-select-option v-for="district in districtOptions" :key="district.name" :value="district.name">
|
||||||
|
{{ district.name }} ({{ district.count }})
|
||||||
|
</a-select-option>
|
||||||
|
</a-select>
|
||||||
|
|
||||||
|
<a-select
|
||||||
|
v-model:value="filterType"
|
||||||
|
placeholder="学校类型"
|
||||||
|
style="width: 120px; margin-left: 8px"
|
||||||
|
allow-clear
|
||||||
|
@change="handleSearch"
|
||||||
|
>
|
||||||
|
<a-select-option value="primary">小学</a-select-option>
|
||||||
|
<a-select-option value="junior">初中</a-select-option>
|
||||||
|
<a-select-option value="senior">高中</a-select-option>
|
||||||
|
<a-select-option value="vocational">职校</a-select-option>
|
||||||
|
</a-select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="action-section">
|
||||||
|
<a-button @click="handleRefresh">
|
||||||
|
<ReloadOutlined />
|
||||||
|
刷新
|
||||||
|
</a-button>
|
||||||
|
|
||||||
|
<a-button type="primary" @click="handleAddSchool">
|
||||||
|
<PlusOutlined />
|
||||||
|
添加学校
|
||||||
|
</a-button>
|
||||||
|
|
||||||
|
<a-button @click="showImportModal">
|
||||||
|
<ImportOutlined />
|
||||||
|
批量导入
|
||||||
|
</a-button>
|
||||||
|
|
||||||
|
<a-button @click="handleExportTemplate">
|
||||||
|
<DownloadOutlined />
|
||||||
|
导出模板
|
||||||
|
</a-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 学校树形列表 -->
|
||||||
|
<div class="school-tree-container">
|
||||||
|
<a-spin :spinning="loading">
|
||||||
|
<SchoolTree
|
||||||
|
:data="schools"
|
||||||
|
:loading="loading"
|
||||||
|
@add-grade="handleAddGrade"
|
||||||
|
@add-class="handleAddClass"
|
||||||
|
@edit-school="handleEditSchool"
|
||||||
|
@edit-grade="handleEditGrade"
|
||||||
|
@edit-class="handleEditClass"
|
||||||
|
@delete-school="handleDeleteSchool"
|
||||||
|
@delete-grade="handleDeleteGrade"
|
||||||
|
@delete-class="handleDeleteClass"
|
||||||
|
/>
|
||||||
|
</a-spin>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 学校表单弹窗 -->
|
||||||
|
<SchoolForm
|
||||||
|
v-model:visible="schoolFormVisible"
|
||||||
|
:school="currentSchool"
|
||||||
|
@success="handleFormSuccess"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- 年级表单弹窗 -->
|
||||||
|
<GradeForm
|
||||||
|
v-model:visible="gradeFormVisible"
|
||||||
|
:grade="currentGrade"
|
||||||
|
:school-id="currentSchoolId"
|
||||||
|
@success="handleFormSuccess"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- 班级表单弹窗 -->
|
||||||
|
<ClassForm
|
||||||
|
v-model:visible="classFormVisible"
|
||||||
|
:class-data="currentClass"
|
||||||
|
:school-id="currentSchoolId"
|
||||||
|
:grade-id="currentGradeId"
|
||||||
|
@success="handleFormSuccess"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- 批量导入弹窗 -->
|
||||||
|
<BatchImport
|
||||||
|
v-model:visible="importModalVisible"
|
||||||
|
@success="handleImportSuccess"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, reactive, onMounted } from 'vue';
|
||||||
|
import { message } from 'ant-design-vue';
|
||||||
|
import {
|
||||||
|
ReloadOutlined,
|
||||||
|
PlusOutlined,
|
||||||
|
ImportOutlined,
|
||||||
|
DownloadOutlined
|
||||||
|
} from '@ant-design/icons-vue';
|
||||||
|
import { useRequest } from 'alova/client';
|
||||||
|
import {
|
||||||
|
getSchoolList,
|
||||||
|
deleteSchool,
|
||||||
|
deleteGrade,
|
||||||
|
deleteClass,
|
||||||
|
getDistrictList,
|
||||||
|
exportSchoolTemplate
|
||||||
|
} from '@/apis/schools';
|
||||||
|
import type { School, SchoolQueryParams, Grade, Class } from '@/apis/schools';
|
||||||
|
|
||||||
|
// 导入子组件
|
||||||
|
import SchoolTree from './components/SchoolTree.vue';
|
||||||
|
import SchoolForm from './components/SchoolForm.vue';
|
||||||
|
import GradeForm from './components/GradeForm.vue';
|
||||||
|
import ClassForm from './components/ClassForm.vue';
|
||||||
|
import BatchImport from './components/BatchImport.vue';
|
||||||
|
|
||||||
|
// 搜索筛选参数
|
||||||
|
const searchKeyword = ref('');
|
||||||
|
const filterDistrict = ref<string>();
|
||||||
|
const filterType = ref<'primary' | 'junior' | 'senior' | 'vocational'>();
|
||||||
|
|
||||||
|
// 列表数据
|
||||||
|
const schools = ref<School[]>([]);
|
||||||
|
const districtOptions = ref<{ name: string; count: number }[]>([]);
|
||||||
|
|
||||||
|
// 表单弹窗状态
|
||||||
|
const schoolFormVisible = ref(false);
|
||||||
|
const gradeFormVisible = ref(false);
|
||||||
|
const classFormVisible = ref(false);
|
||||||
|
const importModalVisible = ref(false);
|
||||||
|
|
||||||
|
// 当前编辑的数据
|
||||||
|
const currentSchool = ref<School>();
|
||||||
|
const currentGrade = ref<Grade>();
|
||||||
|
const currentClass = ref<Class>();
|
||||||
|
const currentSchoolId = ref<string>('');
|
||||||
|
const currentGradeId = ref<string>('');
|
||||||
|
|
||||||
|
// 获取学校列表
|
||||||
|
const { loading, send: fetchSchools } = useRequest((params: SchoolQueryParams) => getSchoolList(params), {
|
||||||
|
immediate: false
|
||||||
|
});
|
||||||
|
|
||||||
|
// 获取区县列表
|
||||||
|
const { send: fetchDistricts } = useRequest(() => getDistrictList(), {
|
||||||
|
immediate: false
|
||||||
|
});
|
||||||
|
|
||||||
|
// 构建查询参数
|
||||||
|
const buildQueryParams = (): SchoolQueryParams => {
|
||||||
|
return {
|
||||||
|
keyword: searchKeyword.value || undefined,
|
||||||
|
district: filterDistrict.value,
|
||||||
|
type: filterType.value
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
// 搜索处理
|
||||||
|
const handleSearch = () => {
|
||||||
|
loadSchools();
|
||||||
|
};
|
||||||
|
|
||||||
|
// 加载学校列表
|
||||||
|
const loadSchools = async () => {
|
||||||
|
try {
|
||||||
|
const params = buildQueryParams();
|
||||||
|
const result = await fetchSchools(params);
|
||||||
|
schools.value = result.list;
|
||||||
|
} catch (error: any) {
|
||||||
|
message.error(error.message || '获取学校列表失败');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 加载区县数据
|
||||||
|
const loadDistricts = async () => {
|
||||||
|
try {
|
||||||
|
const result = await fetchDistricts();
|
||||||
|
districtOptions.value = result;
|
||||||
|
} catch (error: any) {
|
||||||
|
message.error(error.message || '获取区县数据失败');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 刷新数据
|
||||||
|
const handleRefresh = () => {
|
||||||
|
loadSchools();
|
||||||
|
};
|
||||||
|
|
||||||
|
// 添加学校
|
||||||
|
const handleAddSchool = () => {
|
||||||
|
currentSchool.value = undefined;
|
||||||
|
schoolFormVisible.value = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 编辑学校
|
||||||
|
const handleEditSchool = (school: School) => {
|
||||||
|
currentSchool.value = school;
|
||||||
|
schoolFormVisible.value = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 删除学校
|
||||||
|
const handleDeleteSchool = async (school: School) => {
|
||||||
|
try {
|
||||||
|
await deleteSchool(school.id);
|
||||||
|
message.success('删除成功');
|
||||||
|
loadSchools();
|
||||||
|
} catch (error: any) {
|
||||||
|
message.error(error.message || '删除失败');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 添加年级
|
||||||
|
const handleAddGrade = (schoolId: string) => {
|
||||||
|
currentGrade.value = undefined;
|
||||||
|
currentSchoolId.value = schoolId;
|
||||||
|
gradeFormVisible.value = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 编辑年级
|
||||||
|
const handleEditGrade = (grade: Grade) => {
|
||||||
|
currentGrade.value = grade;
|
||||||
|
currentSchoolId.value = grade.schoolId;
|
||||||
|
gradeFormVisible.value = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 删除年级
|
||||||
|
const handleDeleteGrade = async (grade: Grade) => {
|
||||||
|
try {
|
||||||
|
await deleteGrade(grade.id);
|
||||||
|
message.success('删除成功');
|
||||||
|
loadSchools();
|
||||||
|
} catch (error: any) {
|
||||||
|
message.error(error.message || '删除失败');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 添加班级
|
||||||
|
const handleAddClass = (schoolId: string, gradeId: string) => {
|
||||||
|
currentClass.value = undefined;
|
||||||
|
currentSchoolId.value = schoolId;
|
||||||
|
currentGradeId.value = gradeId;
|
||||||
|
classFormVisible.value = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 编辑班级
|
||||||
|
const handleEditClass = (cls: Class) => {
|
||||||
|
currentClass.value = cls;
|
||||||
|
currentSchoolId.value = cls.schoolId;
|
||||||
|
currentGradeId.value = cls.gradeId;
|
||||||
|
classFormVisible.value = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 删除班级
|
||||||
|
const handleDeleteClass = async (cls: Class) => {
|
||||||
|
try {
|
||||||
|
await deleteClass(cls.id);
|
||||||
|
message.success('删除成功');
|
||||||
|
loadSchools();
|
||||||
|
} catch (error: any) {
|
||||||
|
message.error(error.message || '删除失败');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 表单提交成功
|
||||||
|
const handleFormSuccess = () => {
|
||||||
|
loadSchools();
|
||||||
|
};
|
||||||
|
|
||||||
|
// 显示导入弹窗
|
||||||
|
const showImportModal = () => {
|
||||||
|
importModalVisible.value = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 导入成功
|
||||||
|
const handleImportSuccess = () => {
|
||||||
|
loadSchools();
|
||||||
|
};
|
||||||
|
|
||||||
|
// 导出模板
|
||||||
|
const handleExportTemplate = async () => {
|
||||||
|
try {
|
||||||
|
const blob = await exportSchoolTemplate();
|
||||||
|
const url = window.URL.createObjectURL(blob);
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.href = url;
|
||||||
|
link.download = '学校数据导入模板.xlsx';
|
||||||
|
link.click();
|
||||||
|
window.URL.revokeObjectURL(url);
|
||||||
|
|
||||||
|
message.success('模板下载成功');
|
||||||
|
} catch (error: any) {
|
||||||
|
message.error(error.message || '模板下载失败');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 初始化
|
||||||
|
onMounted(() => {
|
||||||
|
loadSchools();
|
||||||
|
loadDistricts();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.school-page {
|
||||||
|
.page-header {
|
||||||
|
background: #fff;
|
||||||
|
padding: 12px 24px;
|
||||||
|
border-radius: 8px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
|
||||||
|
|
||||||
|
.page-title {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #262626;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-content {
|
||||||
|
// margin-top: 24px; // 移除多余的margin
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
padding: 16px;
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
|
||||||
|
|
||||||
|
.search-section {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-section {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.school-tree-container {
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
|
||||||
|
padding: 16px;
|
||||||
|
min-height: 500px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 响应式设计 */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.toolbar {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
gap: 16px;
|
||||||
|
|
||||||
|
.search-section {
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-section {
|
||||||
|
justify-content: flex-end;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
571
src/views/schools/components/BatchImport.vue
Normal file
571
src/views/schools/components/BatchImport.vue
Normal file
@@ -0,0 +1,571 @@
|
|||||||
|
<template>
|
||||||
|
<a-modal
|
||||||
|
v-model:open="visible"
|
||||||
|
title="批量导入学校数据"
|
||||||
|
:width="700"
|
||||||
|
:confirm-loading="importing"
|
||||||
|
:ok-button-props="{ disabled: !uploadFile }"
|
||||||
|
@ok="handleImport"
|
||||||
|
@cancel="handleCancel"
|
||||||
|
>
|
||||||
|
<div class="import-content">
|
||||||
|
<!-- 导入说明 -->
|
||||||
|
<a-alert
|
||||||
|
message="导入说明"
|
||||||
|
:description="importDescription"
|
||||||
|
type="info"
|
||||||
|
show-icon
|
||||||
|
:closable="false"
|
||||||
|
style="margin-bottom: 24px;"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- 步骤指引 -->
|
||||||
|
<a-steps :current="currentStep" size="small" style="margin-bottom: 24px;">
|
||||||
|
<a-step title="下载模板" description="下载Excel导入模板" />
|
||||||
|
<a-step title="填写数据" description="按模板格式填写学校数据" />
|
||||||
|
<a-step title="上传文件" description="选择并上传Excel文件" />
|
||||||
|
<a-step title="确认导入" description="检查数据并开始导入" />
|
||||||
|
</a-steps>
|
||||||
|
|
||||||
|
<!-- 模板下载区域 -->
|
||||||
|
<div class="template-section">
|
||||||
|
<h4>
|
||||||
|
<DownloadOutlined style="color: #1890ff; margin-right: 8px;" />
|
||||||
|
第一步:下载导入模板
|
||||||
|
</h4>
|
||||||
|
<p class="section-desc">请先下载标准模板,按照模板格式填写学校数据。</p>
|
||||||
|
|
||||||
|
<a-button
|
||||||
|
type="primary"
|
||||||
|
ghost
|
||||||
|
:loading="downloadLoading"
|
||||||
|
@click="handleDownloadTemplate"
|
||||||
|
style="margin-bottom: 16px;"
|
||||||
|
>
|
||||||
|
<DownloadOutlined />
|
||||||
|
下载Excel模板
|
||||||
|
</a-button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<a-divider />
|
||||||
|
|
||||||
|
<!-- 文件上传区域 -->
|
||||||
|
<div class="upload-section">
|
||||||
|
<h4>
|
||||||
|
<UploadOutlined style="color: #52c41a; margin-right: 8px;" />
|
||||||
|
第二步:上传填写好的Excel文件
|
||||||
|
</h4>
|
||||||
|
<p class="section-desc">支持.xlsx和.xls格式,文件大小不超过10MB。</p>
|
||||||
|
|
||||||
|
<a-upload-dragger
|
||||||
|
v-model:file-list="fileList"
|
||||||
|
:multiple="false"
|
||||||
|
accept=".xlsx,.xls"
|
||||||
|
:before-upload="handleBeforeUpload"
|
||||||
|
:on-remove="handleRemove"
|
||||||
|
:max-count="1"
|
||||||
|
>
|
||||||
|
<p class="ant-upload-drag-icon">
|
||||||
|
<InboxOutlined />
|
||||||
|
</p>
|
||||||
|
<p class="ant-upload-text">点击或拖拽文件到此区域上传</p>
|
||||||
|
<p class="ant-upload-hint">
|
||||||
|
支持Excel格式(.xlsx, .xls),单个文件不超过10MB
|
||||||
|
</p>
|
||||||
|
</a-upload-dragger>
|
||||||
|
|
||||||
|
<!-- 文件信息 -->
|
||||||
|
<div v-if="uploadFile" class="file-info">
|
||||||
|
<a-card size="small">
|
||||||
|
<template #title>
|
||||||
|
<FileExcelOutlined style="color: #52c41a; margin-right: 8px;" />
|
||||||
|
已选择文件
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<a-descriptions :column="2" size="small">
|
||||||
|
<a-descriptions-item label="文件名">
|
||||||
|
{{ uploadFile.name }}
|
||||||
|
</a-descriptions-item>
|
||||||
|
<a-descriptions-item label="文件大小">
|
||||||
|
{{ formatFileSize(uploadFile.size) }}
|
||||||
|
</a-descriptions-item>
|
||||||
|
<a-descriptions-item label="文件类型">
|
||||||
|
{{ uploadFile.type || '未知' }}
|
||||||
|
</a-descriptions-item>
|
||||||
|
<a-descriptions-item label="上传时间">
|
||||||
|
{{ formatDate(new Date()) }}
|
||||||
|
</a-descriptions-item>
|
||||||
|
</a-descriptions>
|
||||||
|
</a-card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<a-divider />
|
||||||
|
|
||||||
|
<!-- 导入选项 -->
|
||||||
|
<div class="options-section">
|
||||||
|
<h4>
|
||||||
|
<SettingOutlined style="color: #fa8c16; margin-right: 8px;" />
|
||||||
|
导入选项
|
||||||
|
</h4>
|
||||||
|
|
||||||
|
<a-checkbox-group v-model:value="importOptions">
|
||||||
|
<a-checkbox value="skipDuplicate">跳过重复数据(根据学校名称和区县判断)</a-checkbox>
|
||||||
|
<a-checkbox value="updateExisting">更新已存在的学校信息</a-checkbox>
|
||||||
|
<a-checkbox value="createGradesClasses">同时创建年级和班级信息</a-checkbox>
|
||||||
|
</a-checkbox-group>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 导入进度 -->
|
||||||
|
<div v-if="importProgress.show" class="progress-section">
|
||||||
|
<a-divider />
|
||||||
|
<h4>导入进度</h4>
|
||||||
|
<a-progress
|
||||||
|
:percent="importProgress.percent"
|
||||||
|
:status="importProgress.status"
|
||||||
|
:stroke-color="importProgress.color"
|
||||||
|
/>
|
||||||
|
<p class="progress-text">{{ importProgress.text }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 导入结果 -->
|
||||||
|
<div v-if="importResult" class="result-section">
|
||||||
|
<a-divider />
|
||||||
|
<h4>导入结果</h4>
|
||||||
|
|
||||||
|
<a-result
|
||||||
|
:status="importResult.success ? 'success' : 'warning'"
|
||||||
|
:title="importResult.title"
|
||||||
|
:sub-title="importResult.subtitle"
|
||||||
|
>
|
||||||
|
<template #extra>
|
||||||
|
<a-row :gutter="16">
|
||||||
|
<a-col :span="8">
|
||||||
|
<a-statistic
|
||||||
|
title="成功导入"
|
||||||
|
:value="importResult.successCount"
|
||||||
|
suffix="条"
|
||||||
|
:value-style="{ color: '#52c41a' }"
|
||||||
|
/>
|
||||||
|
</a-col>
|
||||||
|
<a-col :span="8">
|
||||||
|
<a-statistic
|
||||||
|
title="失败记录"
|
||||||
|
:value="importResult.failCount"
|
||||||
|
suffix="条"
|
||||||
|
:value-style="{ color: '#f5222d' }"
|
||||||
|
/>
|
||||||
|
</a-col>
|
||||||
|
<a-col :span="8">
|
||||||
|
<a-statistic
|
||||||
|
title="总计处理"
|
||||||
|
:value="importResult.totalCount"
|
||||||
|
suffix="条"
|
||||||
|
:value-style="{ color: '#1890ff' }"
|
||||||
|
/>
|
||||||
|
</a-col>
|
||||||
|
</a-row>
|
||||||
|
</template>
|
||||||
|
</a-result>
|
||||||
|
|
||||||
|
<!-- 失败详情 -->
|
||||||
|
<div v-if="importResult.errors?.length" class="error-details">
|
||||||
|
<a-collapse ghost>
|
||||||
|
<a-collapse-panel key="errors" header="查看失败详情">
|
||||||
|
<div class="error-list">
|
||||||
|
<div v-for="(error, index) in importResult.errors" :key="index" class="error-item">
|
||||||
|
<a-tag color="red">行 {{ error.row }}</a-tag>
|
||||||
|
{{ error.message }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</a-collapse-panel>
|
||||||
|
</a-collapse>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</a-modal>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, reactive, computed, watch } from 'vue';
|
||||||
|
import { message } from 'ant-design-vue';
|
||||||
|
import type { UploadProps, UploadFile } from 'ant-design-vue';
|
||||||
|
import {
|
||||||
|
DownloadOutlined,
|
||||||
|
UploadOutlined,
|
||||||
|
InboxOutlined,
|
||||||
|
FileExcelOutlined,
|
||||||
|
SettingOutlined
|
||||||
|
} from '@ant-design/icons-vue';
|
||||||
|
import { useRequest } from 'alova/client';
|
||||||
|
import { exportSchoolTemplate, importSchoolData } from '@/apis/schools';
|
||||||
|
import { format } from 'date-fns';
|
||||||
|
import { zhCN } from 'date-fns/locale';
|
||||||
|
|
||||||
|
// Props定义
|
||||||
|
interface Props {
|
||||||
|
visible: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 事件定义
|
||||||
|
interface Emits {
|
||||||
|
'update:visible': [visible: boolean];
|
||||||
|
'success': [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<Props>();
|
||||||
|
const emit = defineEmits<Emits>();
|
||||||
|
|
||||||
|
// 数据定义
|
||||||
|
const fileList = ref<UploadFile[]>([]);
|
||||||
|
const uploadFile = ref<File>();
|
||||||
|
const currentStep = ref(0);
|
||||||
|
const importOptions = ref<string[]>(['skipDuplicate', 'createGradesClasses']);
|
||||||
|
|
||||||
|
// 导入进度
|
||||||
|
const importProgress = reactive({
|
||||||
|
show: false,
|
||||||
|
percent: 0,
|
||||||
|
status: 'normal' as 'success' | 'exception' | 'normal' | 'active',
|
||||||
|
color: '#1890ff',
|
||||||
|
text: ''
|
||||||
|
});
|
||||||
|
|
||||||
|
// 导入结果
|
||||||
|
interface ImportResult {
|
||||||
|
success: boolean;
|
||||||
|
title: string;
|
||||||
|
subtitle: string;
|
||||||
|
successCount: number;
|
||||||
|
failCount: number;
|
||||||
|
totalCount: number;
|
||||||
|
errors?: Array<{
|
||||||
|
row: number;
|
||||||
|
message: string;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const importResult = ref<ImportResult>();
|
||||||
|
|
||||||
|
// 计算属性
|
||||||
|
const visible = computed({
|
||||||
|
get: () => props.visible,
|
||||||
|
set: (value: boolean) => emit('update:visible', value)
|
||||||
|
});
|
||||||
|
|
||||||
|
// 导入说明文本
|
||||||
|
const importDescription = `
|
||||||
|
1. 请按照标准模板格式填写学校数据,包含学校基本信息、年级和班级信息
|
||||||
|
2. 学校名称和所属区县为必填项,其他信息可选填
|
||||||
|
3. 年级层级请填写数字(1-12),对应小学1-6年级、初中7-9年级、高中10-12年级
|
||||||
|
4. 班级名称请使用标准格式,如"一班"、"二班"等
|
||||||
|
5. 导入前建议先备份现有数据
|
||||||
|
`;
|
||||||
|
|
||||||
|
// 下载模板请求
|
||||||
|
const { loading: downloadLoading, send: sendDownloadTemplate } = useRequest(
|
||||||
|
() => exportSchoolTemplate(), {
|
||||||
|
immediate: false
|
||||||
|
});
|
||||||
|
|
||||||
|
// 导入数据请求
|
||||||
|
const { loading: importing, send: sendImportData } = useRequest(
|
||||||
|
(file: File) => importSchoolData(file), {
|
||||||
|
immediate: false
|
||||||
|
});
|
||||||
|
|
||||||
|
// 监听弹窗显示状态
|
||||||
|
watch(visible, (newVisible) => {
|
||||||
|
if (newVisible) {
|
||||||
|
resetState();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 重置状态
|
||||||
|
const resetState = () => {
|
||||||
|
fileList.value = [];
|
||||||
|
uploadFile.value = undefined;
|
||||||
|
currentStep.value = 0;
|
||||||
|
importProgress.show = false;
|
||||||
|
importProgress.percent = 0;
|
||||||
|
importResult.value = undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 下载模板
|
||||||
|
const handleDownloadTemplate = async () => {
|
||||||
|
try {
|
||||||
|
const blob = await sendDownloadTemplate();
|
||||||
|
const url = window.URL.createObjectURL(blob as Blob);
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.href = url;
|
||||||
|
link.download = '学校数据导入模板.xlsx';
|
||||||
|
link.click();
|
||||||
|
window.URL.revokeObjectURL(url);
|
||||||
|
|
||||||
|
message.success('模板下载成功');
|
||||||
|
currentStep.value = Math.max(currentStep.value, 1);
|
||||||
|
} catch (error: any) {
|
||||||
|
message.error(error.message || '模板下载失败');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 文件上传前处理
|
||||||
|
const handleBeforeUpload: UploadProps['beforeUpload'] = (file) => {
|
||||||
|
// 检查文件类型
|
||||||
|
const isExcel = file.type === 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' ||
|
||||||
|
file.type === 'application/vnd.ms-excel' ||
|
||||||
|
file.name.endsWith('.xlsx') ||
|
||||||
|
file.name.endsWith('.xls');
|
||||||
|
|
||||||
|
if (!isExcel) {
|
||||||
|
message.error('只能上传Excel格式文件(.xlsx, .xls)');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查文件大小(10MB)
|
||||||
|
const isLt10M = file.size / 1024 / 1024 < 10;
|
||||||
|
if (!isLt10M) {
|
||||||
|
message.error('文件大小不能超过10MB');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
uploadFile.value = file;
|
||||||
|
currentStep.value = Math.max(currentStep.value, 2);
|
||||||
|
|
||||||
|
// 阻止默认上传行为
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 移除文件
|
||||||
|
const handleRemove = () => {
|
||||||
|
uploadFile.value = undefined;
|
||||||
|
currentStep.value = Math.max(currentStep.value - 1, 0);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 开始导入
|
||||||
|
const handleImport = async () => {
|
||||||
|
if (!uploadFile.value) {
|
||||||
|
message.error('请先选择要导入的Excel文件');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
currentStep.value = 3;
|
||||||
|
|
||||||
|
// 显示进度
|
||||||
|
importProgress.show = true;
|
||||||
|
importProgress.percent = 0;
|
||||||
|
importProgress.status = 'active';
|
||||||
|
importProgress.text = '正在读取文件...';
|
||||||
|
|
||||||
|
// 模拟进度更新
|
||||||
|
const progressTimer = setInterval(() => {
|
||||||
|
if (importProgress.percent < 90) {
|
||||||
|
importProgress.percent += 10;
|
||||||
|
|
||||||
|
if (importProgress.percent <= 30) {
|
||||||
|
importProgress.text = '正在验证数据格式...';
|
||||||
|
} else if (importProgress.percent <= 60) {
|
||||||
|
importProgress.text = '正在处理学校数据...';
|
||||||
|
} else if (importProgress.percent <= 90) {
|
||||||
|
importProgress.text = '正在创建年级班级...';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, 300);
|
||||||
|
|
||||||
|
const result = await sendImportData(uploadFile.value);
|
||||||
|
|
||||||
|
// 清除进度定时器
|
||||||
|
clearInterval(progressTimer);
|
||||||
|
|
||||||
|
// 完成进度
|
||||||
|
importProgress.percent = 100;
|
||||||
|
importProgress.status = 'success';
|
||||||
|
importProgress.color = '#52c41a';
|
||||||
|
importProgress.text = '导入完成!';
|
||||||
|
|
||||||
|
// 设置结果
|
||||||
|
importResult.value = {
|
||||||
|
success: result.failCount === 0,
|
||||||
|
title: result.failCount === 0 ? '导入成功!' : '导入完成,部分数据失败',
|
||||||
|
subtitle: `成功导入 ${result.successCount} 条记录${result.failCount > 0 ? `,${result.failCount} 条记录失败` : ''}`,
|
||||||
|
successCount: result.successCount,
|
||||||
|
failCount: result.failCount,
|
||||||
|
totalCount: result.successCount + result.failCount,
|
||||||
|
errors: [] // API可能返回详细错误信息
|
||||||
|
};
|
||||||
|
|
||||||
|
if (result.successCount > 0) {
|
||||||
|
emit('success');
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (error: any) {
|
||||||
|
importProgress.status = 'exception';
|
||||||
|
importProgress.color = '#f5222d';
|
||||||
|
importProgress.text = '导入失败!';
|
||||||
|
|
||||||
|
importResult.value = {
|
||||||
|
success: false,
|
||||||
|
title: '导入失败',
|
||||||
|
subtitle: error.message || '文件处理过程中发生错误',
|
||||||
|
successCount: 0,
|
||||||
|
failCount: 0,
|
||||||
|
totalCount: 0
|
||||||
|
};
|
||||||
|
|
||||||
|
message.error(error.message || '导入失败');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 取消操作
|
||||||
|
const handleCancel = () => {
|
||||||
|
visible.value = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 格式化文件大小
|
||||||
|
const formatFileSize = (bytes: number): string => {
|
||||||
|
if (bytes === 0) return '0 B';
|
||||||
|
|
||||||
|
const k = 1024;
|
||||||
|
const sizes = ['B', 'KB', 'MB', 'GB'];
|
||||||
|
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||||
|
|
||||||
|
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
||||||
|
};
|
||||||
|
|
||||||
|
// 格式化日期
|
||||||
|
const formatDate = (date: Date): string => {
|
||||||
|
return format(date, 'yyyy-MM-dd HH:mm:ss', { locale: zhCN });
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.import-content {
|
||||||
|
.section-desc {
|
||||||
|
color: #8c8c8c;
|
||||||
|
font-size: 14px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
h4 {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #262626;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.template-section,
|
||||||
|
.upload-section,
|
||||||
|
.options-section {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-info {
|
||||||
|
margin-top: 16px;
|
||||||
|
|
||||||
|
:deep(.ant-card-head-title) {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.options-section {
|
||||||
|
:deep(.ant-checkbox-group) {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
|
||||||
|
.ant-checkbox-wrapper {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-section {
|
||||||
|
.progress-text {
|
||||||
|
text-align: center;
|
||||||
|
color: #8c8c8c;
|
||||||
|
margin-top: 8px;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-section {
|
||||||
|
:deep(.ant-result-title) {
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.ant-result-subtitle) {
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-details {
|
||||||
|
margin-top: 16px;
|
||||||
|
|
||||||
|
.error-list {
|
||||||
|
max-height: 200px;
|
||||||
|
overflow-y: auto;
|
||||||
|
|
||||||
|
.error-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
padding: 8px;
|
||||||
|
background: #fff2f0;
|
||||||
|
border-radius: 4px;
|
||||||
|
border-left: 3px solid #ff4d4f;
|
||||||
|
|
||||||
|
&:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 上传组件样式 */
|
||||||
|
:deep(.ant-upload-drag) {
|
||||||
|
border: 2px dashed #d9d9d9;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #fafafa;
|
||||||
|
transition: all 0.3s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
border-color: #40a9ff;
|
||||||
|
background: #f0f8ff;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 步骤条样式 */
|
||||||
|
:deep(.ant-steps-item-finish) {
|
||||||
|
.ant-steps-item-icon {
|
||||||
|
background: #52c41a;
|
||||||
|
border-color: #52c41a;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.ant-steps-item-process) {
|
||||||
|
.ant-steps-item-icon {
|
||||||
|
background: #1890ff;
|
||||||
|
border-color: #1890ff;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 响应式设计 */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
:deep(.ant-modal) {
|
||||||
|
margin: 16px;
|
||||||
|
max-width: calc(100vw - 32px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-section {
|
||||||
|
:deep(.ant-col) {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
390
src/views/schools/components/ClassForm.vue
Normal file
390
src/views/schools/components/ClassForm.vue
Normal file
@@ -0,0 +1,390 @@
|
|||||||
|
<template>
|
||||||
|
<a-modal
|
||||||
|
v-model:open="visible"
|
||||||
|
:title="isEdit ? '编辑班级' : '添加班级'"
|
||||||
|
:width="500"
|
||||||
|
:confirm-loading="loading"
|
||||||
|
@ok="handleSubmit"
|
||||||
|
@cancel="handleCancel"
|
||||||
|
>
|
||||||
|
<a-form
|
||||||
|
ref="formRef"
|
||||||
|
:model="formData"
|
||||||
|
:rules="rules"
|
||||||
|
layout="vertical"
|
||||||
|
:label-col="{ span: 24 }"
|
||||||
|
:wrapper-col="{ span: 24 }"
|
||||||
|
>
|
||||||
|
<a-form-item label="班级名称" name="name">
|
||||||
|
<a-input
|
||||||
|
v-model:value="formData.name"
|
||||||
|
placeholder="例如:一班、二班、A班等"
|
||||||
|
:maxlength="20"
|
||||||
|
show-count
|
||||||
|
>
|
||||||
|
<template #prefix>
|
||||||
|
<TeamOutlined style="color: #52c41a;" />
|
||||||
|
</template>
|
||||||
|
</a-input>
|
||||||
|
</a-form-item>
|
||||||
|
|
||||||
|
<a-form-item label="班主任姓名" name="teacherName">
|
||||||
|
<a-input
|
||||||
|
v-model:value="formData.teacherName"
|
||||||
|
placeholder="请输入班主任姓名(可选)"
|
||||||
|
:maxlength="20"
|
||||||
|
>
|
||||||
|
<template #prefix>
|
||||||
|
<UserOutlined style="color: #1890ff;" />
|
||||||
|
</template>
|
||||||
|
</a-input>
|
||||||
|
</a-form-item>
|
||||||
|
</a-form>
|
||||||
|
|
||||||
|
<!-- 班级信息预览 -->
|
||||||
|
<div class="class-preview">
|
||||||
|
<a-divider orientation="left">班级信息预览</a-divider>
|
||||||
|
<a-descriptions :column="1" bordered size="small">
|
||||||
|
<a-descriptions-item label="所属学校">
|
||||||
|
<HomeOutlined style="color: #1890ff; margin-right: 8px;" />
|
||||||
|
{{ getSchoolName() }}
|
||||||
|
</a-descriptions-item>
|
||||||
|
<a-descriptions-item label="所属年级">
|
||||||
|
<BookOutlined style="color: #fa8c16; margin-right: 8px;" />
|
||||||
|
{{ getGradeName() }}
|
||||||
|
</a-descriptions-item>
|
||||||
|
</a-descriptions>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 编辑模式的额外信息 -->
|
||||||
|
<div v-if="isEdit && classData" class="edit-info">
|
||||||
|
<a-divider orientation="left">班级统计</a-divider>
|
||||||
|
<a-row :gutter="16">
|
||||||
|
<a-col :span="12">
|
||||||
|
<a-statistic
|
||||||
|
title="学生人数"
|
||||||
|
:value="classData.studentCount || 0"
|
||||||
|
suffix="人"
|
||||||
|
:value-style="{ color: '#1890ff' }"
|
||||||
|
>
|
||||||
|
<template #prefix>
|
||||||
|
<TeamOutlined />
|
||||||
|
</template>
|
||||||
|
</a-statistic>
|
||||||
|
</a-col>
|
||||||
|
<a-col :span="12">
|
||||||
|
<div class="create-time">
|
||||||
|
<div class="label">创建时间</div>
|
||||||
|
<div class="value">{{ formatDate(classData.createTime) }}</div>
|
||||||
|
</div>
|
||||||
|
</a-col>
|
||||||
|
</a-row>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 创建提示 -->
|
||||||
|
<div v-if="!isEdit" class="create-tip">
|
||||||
|
<a-alert
|
||||||
|
message="创建提示"
|
||||||
|
description="班级创建后,学生可以通过小程序选择该班级进行绑定。"
|
||||||
|
type="info"
|
||||||
|
show-icon
|
||||||
|
:closable="false"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</a-modal>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, reactive, computed, watch } from 'vue';
|
||||||
|
import { message } from 'ant-design-vue';
|
||||||
|
import type { FormInstance, Rule } from 'ant-design-vue/es/form';
|
||||||
|
import {
|
||||||
|
TeamOutlined,
|
||||||
|
UserOutlined,
|
||||||
|
HomeOutlined,
|
||||||
|
BookOutlined
|
||||||
|
} from '@ant-design/icons-vue';
|
||||||
|
import { useRequest } from 'alova/client';
|
||||||
|
import { createClass, updateClass, getSchoolDetail } from '@/apis/schools';
|
||||||
|
import type { Class, CreateClassParams, School } from '@/apis/schools';
|
||||||
|
import { format } from 'date-fns';
|
||||||
|
import { zhCN } from 'date-fns/locale';
|
||||||
|
|
||||||
|
// Props定义
|
||||||
|
interface Props {
|
||||||
|
visible: boolean;
|
||||||
|
class?: Class;
|
||||||
|
schoolId: string;
|
||||||
|
gradeId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 事件定义
|
||||||
|
interface Emits {
|
||||||
|
'update:visible': [visible: boolean];
|
||||||
|
'success': [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<Props>();
|
||||||
|
const emit = defineEmits<Emits>();
|
||||||
|
|
||||||
|
// 表单引用
|
||||||
|
const formRef = ref<FormInstance>();
|
||||||
|
|
||||||
|
// 表单数据
|
||||||
|
const formData = reactive<CreateClassParams>({
|
||||||
|
schoolId: '',
|
||||||
|
gradeId: '',
|
||||||
|
name: '',
|
||||||
|
teacherName: ''
|
||||||
|
});
|
||||||
|
|
||||||
|
// 学校信息
|
||||||
|
const school = ref<School>();
|
||||||
|
|
||||||
|
// 计算属性
|
||||||
|
const visible = computed({
|
||||||
|
get: () => props.visible,
|
||||||
|
set: (value: boolean) => emit('update:visible', value)
|
||||||
|
});
|
||||||
|
|
||||||
|
const isEdit = computed(() => !!props.class);
|
||||||
|
const classData = computed(() => props.class);
|
||||||
|
|
||||||
|
// 表单验证规则
|
||||||
|
const rules: Record<string, Rule[]> = {
|
||||||
|
name: [
|
||||||
|
{ required: true, message: '请输入班级名称', trigger: 'blur' },
|
||||||
|
{ min: 1, max: 20, message: '班级名称长度应在1-20个字符之间', trigger: 'blur' },
|
||||||
|
{
|
||||||
|
pattern: /^[\u4e00-\u9fa5a-zA-Z0-9\s]+$/,
|
||||||
|
message: '班级名称只能包含中文、字母、数字和空格',
|
||||||
|
trigger: 'blur'
|
||||||
|
}
|
||||||
|
],
|
||||||
|
teacherName: [
|
||||||
|
{ max: 20, message: '班主任姓名不能超过20个字符', trigger: 'blur' },
|
||||||
|
{
|
||||||
|
pattern: /^[\u4e00-\u9fa5a-zA-Z\s]*$/,
|
||||||
|
message: '班主任姓名只能包含中文、字母和空格',
|
||||||
|
trigger: 'blur'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
||||||
|
// 创建班级请求
|
||||||
|
const { loading: createLoading, send: sendCreateClass } = useRequest(
|
||||||
|
(params: CreateClassParams) => createClass(params), {
|
||||||
|
immediate: false
|
||||||
|
});
|
||||||
|
|
||||||
|
// 更新班级请求
|
||||||
|
const { loading: updateLoading, send: sendUpdateClass } = useRequest(
|
||||||
|
(id: string, params: CreateClassParams) => updateClass(id, params), {
|
||||||
|
immediate: false
|
||||||
|
});
|
||||||
|
|
||||||
|
// 获取学校详情
|
||||||
|
const { send: fetchSchoolDetail } = useRequest((id: string) => getSchoolDetail(id), {
|
||||||
|
immediate: false
|
||||||
|
});
|
||||||
|
|
||||||
|
const loading = computed(() => createLoading.value || updateLoading.value);
|
||||||
|
|
||||||
|
// 监听props变化,初始化表单数据
|
||||||
|
watch(() => [props.class, props.schoolId, props.gradeId], ([cls, schoolId, gradeId]) => {
|
||||||
|
if (typeof schoolId === 'string') {
|
||||||
|
formData.schoolId = schoolId;
|
||||||
|
}
|
||||||
|
if (typeof gradeId === 'string') {
|
||||||
|
formData.gradeId = gradeId;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cls && typeof cls === 'object' && 'id' in cls) {
|
||||||
|
Object.assign(formData, {
|
||||||
|
schoolId: cls.schoolId,
|
||||||
|
gradeId: cls.gradeId,
|
||||||
|
name: cls.name,
|
||||||
|
teacherName: cls.teacherName || ''
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, { immediate: true });
|
||||||
|
|
||||||
|
// 监听弹窗显示状态
|
||||||
|
watch(visible, async (newVisible) => {
|
||||||
|
if (newVisible) {
|
||||||
|
// 弹窗打开时
|
||||||
|
if (!props.class) {
|
||||||
|
resetForm();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 加载学校信息
|
||||||
|
if (props.schoolId) {
|
||||||
|
try {
|
||||||
|
school.value = await fetchSchoolDetail(props.schoolId);
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('获取学校信息失败:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 弹窗关闭时清理验证状态
|
||||||
|
formRef.value?.clearValidate();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 重置表单
|
||||||
|
const resetForm = () => {
|
||||||
|
Object.assign(formData, {
|
||||||
|
schoolId: props.schoolId,
|
||||||
|
gradeId: props.gradeId,
|
||||||
|
name: '',
|
||||||
|
teacherName: ''
|
||||||
|
});
|
||||||
|
formRef.value?.resetFields();
|
||||||
|
};
|
||||||
|
|
||||||
|
// 获取学校名称
|
||||||
|
const getSchoolName = (): string => {
|
||||||
|
return school.value?.name || '未知学校';
|
||||||
|
};
|
||||||
|
|
||||||
|
// 获取年级名称
|
||||||
|
const getGradeName = (): string => {
|
||||||
|
if (!school.value?.grades) return '未知年级';
|
||||||
|
|
||||||
|
const grade = school.value.grades.find(g => g.id === props.gradeId);
|
||||||
|
return grade?.name || '未知年级';
|
||||||
|
};
|
||||||
|
|
||||||
|
// 提交表单
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
try {
|
||||||
|
await formRef.value?.validate();
|
||||||
|
|
||||||
|
if (isEdit.value && props.class) {
|
||||||
|
// 编辑模式
|
||||||
|
await sendUpdateClass(props.class.id, formData);
|
||||||
|
message.success('班级信息更新成功');
|
||||||
|
} else {
|
||||||
|
// 新建模式
|
||||||
|
await sendCreateClass(formData);
|
||||||
|
message.success('班级创建成功');
|
||||||
|
}
|
||||||
|
|
||||||
|
emit('success');
|
||||||
|
visible.value = false;
|
||||||
|
} catch (error: any) {
|
||||||
|
if (error.errorFields) {
|
||||||
|
// 表单验证错误
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
message.error(error.message || `${isEdit.value ? '更新' : '创建'}失败`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 取消操作
|
||||||
|
const handleCancel = () => {
|
||||||
|
visible.value = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 格式化日期
|
||||||
|
const formatDate = (dateStr?: string) => {
|
||||||
|
if (!dateStr) return '-';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const date = new Date(dateStr);
|
||||||
|
return format(date, 'yyyy-MM-dd HH:mm:ss', { locale: zhCN });
|
||||||
|
} catch (error) {
|
||||||
|
return '-';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.class-preview,
|
||||||
|
.edit-info {
|
||||||
|
margin-top: 16px;
|
||||||
|
padding-top: 16px;
|
||||||
|
border-top: 1px solid #f0f0f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.create-time {
|
||||||
|
.label {
|
||||||
|
color: #8c8c8c;
|
||||||
|
font-size: 12px;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.value {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #262626;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.create-tip {
|
||||||
|
margin-top: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 表单样式优化 */
|
||||||
|
:deep(.ant-form-item) {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.ant-input-affix-wrapper) {
|
||||||
|
.ant-input-prefix {
|
||||||
|
margin-right: 8px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 描述列表样式 */
|
||||||
|
:deep(.ant-descriptions-item-label) {
|
||||||
|
font-weight: 500;
|
||||||
|
background: #fafafa;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.ant-descriptions-item-content) {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 统计数值样式 */
|
||||||
|
:deep(.ant-statistic) {
|
||||||
|
text-align: center;
|
||||||
|
|
||||||
|
.ant-statistic-title {
|
||||||
|
color: #8c8c8c;
|
||||||
|
font-size: 12px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ant-statistic-content {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
.ant-statistic-content-value {
|
||||||
|
margin-right: 4px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ant-statistic-content-suffix {
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 响应式设计 */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
:deep(.ant-modal) {
|
||||||
|
margin: 16px;
|
||||||
|
max-width: calc(100vw - 32px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-info {
|
||||||
|
:deep(.ant-col) {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
374
src/views/schools/components/GradeForm.vue
Normal file
374
src/views/schools/components/GradeForm.vue
Normal file
@@ -0,0 +1,374 @@
|
|||||||
|
<template>
|
||||||
|
<a-modal
|
||||||
|
v-model:open="visible"
|
||||||
|
:title="isEdit ? '编辑年级' : '添加年级'"
|
||||||
|
:width="500"
|
||||||
|
:confirm-loading="loading"
|
||||||
|
@ok="handleSubmit"
|
||||||
|
@cancel="handleCancel"
|
||||||
|
>
|
||||||
|
<a-form
|
||||||
|
ref="formRef"
|
||||||
|
:model="formData"
|
||||||
|
:rules="rules"
|
||||||
|
layout="vertical"
|
||||||
|
:label-col="{ span: 24 }"
|
||||||
|
:wrapper-col="{ span: 24 }"
|
||||||
|
>
|
||||||
|
<a-form-item label="年级名称" name="name">
|
||||||
|
<a-input
|
||||||
|
v-model:value="formData.name"
|
||||||
|
placeholder="例如:一年级、七年级、高一等"
|
||||||
|
:maxlength="20"
|
||||||
|
show-count
|
||||||
|
>
|
||||||
|
<template #prefix>
|
||||||
|
<BookOutlined style="color: #1890ff;" />
|
||||||
|
</template>
|
||||||
|
</a-input>
|
||||||
|
</a-form-item>
|
||||||
|
|
||||||
|
<a-form-item label="年级层级" name="level">
|
||||||
|
<a-select
|
||||||
|
v-model:value="formData.level"
|
||||||
|
placeholder="请选择年级层级"
|
||||||
|
:options="levelOptions"
|
||||||
|
>
|
||||||
|
<template #suffixIcon>
|
||||||
|
<NumberOutlined />
|
||||||
|
</template>
|
||||||
|
</a-select>
|
||||||
|
</a-form-item>
|
||||||
|
|
||||||
|
<!-- 年级说明 -->
|
||||||
|
<a-alert
|
||||||
|
v-if="formData.level"
|
||||||
|
:message="getLevelDescription(formData.level)"
|
||||||
|
type="info"
|
||||||
|
show-icon
|
||||||
|
style="margin-bottom: 16px;"
|
||||||
|
/>
|
||||||
|
</a-form>
|
||||||
|
|
||||||
|
<!-- 预览信息 -->
|
||||||
|
<div v-if="isEdit && grade" class="preview-section">
|
||||||
|
<a-divider orientation="left">年级信息</a-divider>
|
||||||
|
<a-descriptions :column="1" bordered size="small">
|
||||||
|
<a-descriptions-item label="所属学校">
|
||||||
|
<BankOutlined style="color: #1890ff; margin-right: 8px;" />
|
||||||
|
{{ getSchoolName() }}
|
||||||
|
</a-descriptions-item>
|
||||||
|
<a-descriptions-item label="创建时间">
|
||||||
|
{{ formatDate(grade.createTime) }}
|
||||||
|
</a-descriptions-item>
|
||||||
|
<a-descriptions-item label="班级数量">
|
||||||
|
<a-statistic
|
||||||
|
:value="grade.classes?.length || 0"
|
||||||
|
suffix="个"
|
||||||
|
:value-style="{ fontSize: '14px', color: '#52c41a' }"
|
||||||
|
/>
|
||||||
|
</a-descriptions-item>
|
||||||
|
</a-descriptions>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 快速创建班级提示 -->
|
||||||
|
<div v-if="!isEdit" class="quick-tip">
|
||||||
|
<a-alert
|
||||||
|
message="提示"
|
||||||
|
description="年级创建成功后,您可以在学校管理页面为该年级添加班级。"
|
||||||
|
type="success"
|
||||||
|
show-icon
|
||||||
|
:closable="false"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</a-modal>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, reactive, computed, watch } from 'vue';
|
||||||
|
import { message } from 'ant-design-vue';
|
||||||
|
import type { FormInstance, Rule } from 'ant-design-vue/es/form';
|
||||||
|
import {
|
||||||
|
BookOutlined,
|
||||||
|
NumberOutlined,
|
||||||
|
BankOutlined
|
||||||
|
} from '@ant-design/icons-vue';
|
||||||
|
import { useRequest } from 'alova/client';
|
||||||
|
import { createGrade, updateGrade, getSchoolDetail } from '@/apis/schools';
|
||||||
|
import type { Grade, CreateGradeParams, School } from '@/apis/schools';
|
||||||
|
import { format } from 'date-fns';
|
||||||
|
import { zhCN } from 'date-fns/locale';
|
||||||
|
|
||||||
|
// Props定义
|
||||||
|
interface Props {
|
||||||
|
visible: boolean;
|
||||||
|
grade?: Grade;
|
||||||
|
schoolId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 事件定义
|
||||||
|
interface Emits {
|
||||||
|
'update:visible': [visible: boolean];
|
||||||
|
'success': [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<Props>();
|
||||||
|
const emit = defineEmits<Emits>();
|
||||||
|
|
||||||
|
// 表单引用
|
||||||
|
const formRef = ref<FormInstance>();
|
||||||
|
|
||||||
|
// 表单数据
|
||||||
|
const formData = reactive<CreateGradeParams>({
|
||||||
|
schoolId: '',
|
||||||
|
name: '',
|
||||||
|
level: 1
|
||||||
|
});
|
||||||
|
|
||||||
|
// 学校信息
|
||||||
|
const school = ref<School>();
|
||||||
|
|
||||||
|
// 计算属性
|
||||||
|
const visible = computed({
|
||||||
|
get: () => props.visible,
|
||||||
|
set: (value: boolean) => emit('update:visible', value)
|
||||||
|
});
|
||||||
|
|
||||||
|
const isEdit = computed(() => !!props.grade);
|
||||||
|
|
||||||
|
// 年级层级选项
|
||||||
|
const levelOptions = computed(() => {
|
||||||
|
const options = [];
|
||||||
|
|
||||||
|
// 小学阶段
|
||||||
|
for (let i = 1; i <= 6; i++) {
|
||||||
|
options.push({
|
||||||
|
label: `${i}年级(小学)`,
|
||||||
|
value: i,
|
||||||
|
disabled: false
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 初中阶段
|
||||||
|
for (let i = 7; i <= 9; i++) {
|
||||||
|
options.push({
|
||||||
|
label: `${i}年级(初中)`,
|
||||||
|
value: i,
|
||||||
|
disabled: false
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 高中阶段
|
||||||
|
for (let i = 10; i <= 12; i++) {
|
||||||
|
options.push({
|
||||||
|
label: `${i}年级(高中)`,
|
||||||
|
value: i,
|
||||||
|
disabled: false
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return options;
|
||||||
|
});
|
||||||
|
|
||||||
|
// 表单验证规则
|
||||||
|
const rules: Record<string, Rule[]> = {
|
||||||
|
name: [
|
||||||
|
{ required: true, message: '请输入年级名称', trigger: 'blur' },
|
||||||
|
{ min: 2, max: 20, message: '年级名称长度应在2-20个字符之间', trigger: 'blur' }
|
||||||
|
],
|
||||||
|
level: [
|
||||||
|
{ required: true, message: '请选择年级层级', trigger: 'change' },
|
||||||
|
{
|
||||||
|
type: 'number',
|
||||||
|
min: 1,
|
||||||
|
max: 12,
|
||||||
|
message: '年级层级应在1-12之间',
|
||||||
|
trigger: 'change'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
||||||
|
// 创建年级请求
|
||||||
|
const { loading: createLoading, send: sendCreateGrade } = useRequest(
|
||||||
|
(params: CreateGradeParams) => createGrade(params), {
|
||||||
|
immediate: false
|
||||||
|
});
|
||||||
|
|
||||||
|
// 更新年级请求
|
||||||
|
const { loading: updateLoading, send: sendUpdateGrade } = useRequest(
|
||||||
|
(id: string, params: CreateGradeParams) => updateGrade(id, params), {
|
||||||
|
immediate: false
|
||||||
|
});
|
||||||
|
|
||||||
|
// 获取学校详情
|
||||||
|
const { send: fetchSchoolDetail } = useRequest((id: string) => getSchoolDetail(id), {
|
||||||
|
immediate: false
|
||||||
|
});
|
||||||
|
|
||||||
|
const loading = computed(() => createLoading.value || updateLoading.value);
|
||||||
|
|
||||||
|
// 监听props变化,初始化表单数据
|
||||||
|
watch(() => [props.grade, props.schoolId], ([grade, schoolId]) => {
|
||||||
|
formData.schoolId = schoolId;
|
||||||
|
|
||||||
|
if (grade) {
|
||||||
|
Object.assign(formData, {
|
||||||
|
schoolId: grade.schoolId,
|
||||||
|
name: grade.name,
|
||||||
|
level: grade.level
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, { immediate: true });
|
||||||
|
|
||||||
|
// 监听弹窗显示状态
|
||||||
|
watch(visible, async (newVisible) => {
|
||||||
|
if (newVisible) {
|
||||||
|
// 弹窗打开时
|
||||||
|
if (!props.grade) {
|
||||||
|
resetForm();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 加载学校信息
|
||||||
|
if (props.schoolId) {
|
||||||
|
try {
|
||||||
|
school.value = await fetchSchoolDetail(props.schoolId);
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('获取学校信息失败:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 弹窗关闭时清理验证状态
|
||||||
|
formRef.value?.clearValidate();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 重置表单
|
||||||
|
const resetForm = () => {
|
||||||
|
Object.assign(formData, {
|
||||||
|
schoolId: props.schoolId,
|
||||||
|
name: '',
|
||||||
|
level: 1
|
||||||
|
});
|
||||||
|
formRef.value?.resetFields();
|
||||||
|
};
|
||||||
|
|
||||||
|
// 获取层级描述
|
||||||
|
const getLevelDescription = (level: number): string => {
|
||||||
|
if (level >= 1 && level <= 6) {
|
||||||
|
return `小学阶段 - 适用于6-12岁儿童`;
|
||||||
|
} else if (level >= 7 && level <= 9) {
|
||||||
|
return `初中阶段 - 适用于12-15岁青少年`;
|
||||||
|
} else if (level >= 10 && level <= 12) {
|
||||||
|
return `高中阶段 - 适用于15-18岁青少年`;
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
};
|
||||||
|
|
||||||
|
// 获取学校名称
|
||||||
|
const getSchoolName = (): string => {
|
||||||
|
return school.value?.name || '未知学校';
|
||||||
|
};
|
||||||
|
|
||||||
|
// 提交表单
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
try {
|
||||||
|
await formRef.value?.validate();
|
||||||
|
|
||||||
|
if (isEdit.value && props.grade) {
|
||||||
|
// 编辑模式
|
||||||
|
await sendUpdateGrade(props.grade.id, formData);
|
||||||
|
message.success('年级信息更新成功');
|
||||||
|
} else {
|
||||||
|
// 新建模式
|
||||||
|
await sendCreateGrade(formData);
|
||||||
|
message.success('年级创建成功');
|
||||||
|
}
|
||||||
|
|
||||||
|
emit('success');
|
||||||
|
visible.value = false;
|
||||||
|
} catch (error: any) {
|
||||||
|
if (error.errorFields) {
|
||||||
|
// 表单验证错误
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
message.error(error.message || `${isEdit.value ? '更新' : '创建'}失败`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 取消操作
|
||||||
|
const handleCancel = () => {
|
||||||
|
visible.value = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 格式化日期
|
||||||
|
const formatDate = (dateStr?: string) => {
|
||||||
|
if (!dateStr) return '-';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const date = new Date(dateStr);
|
||||||
|
return format(date, 'yyyy-MM-dd HH:mm:ss', { locale: zhCN });
|
||||||
|
} catch (error) {
|
||||||
|
return '-';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.preview-section {
|
||||||
|
margin-top: 16px;
|
||||||
|
padding-top: 16px;
|
||||||
|
border-top: 1px solid #f0f0f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quick-tip {
|
||||||
|
margin-top: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 表单样式优化 */
|
||||||
|
:deep(.ant-form-item) {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.ant-input-affix-wrapper) {
|
||||||
|
.ant-input-prefix {
|
||||||
|
margin-right: 8px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.ant-select) {
|
||||||
|
.ant-select-selector {
|
||||||
|
.ant-select-selection-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 描述列表样式 */
|
||||||
|
:deep(.ant-descriptions-item-label) {
|
||||||
|
font-weight: 500;
|
||||||
|
background: #fafafa;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 统计数值样式 */
|
||||||
|
:deep(.ant-statistic) {
|
||||||
|
.ant-statistic-content {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
.ant-statistic-content-value {
|
||||||
|
margin-right: 4px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 响应式设计 */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
:deep(.ant-modal) {
|
||||||
|
margin: 16px;
|
||||||
|
max-width: calc(100vw - 32px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
347
src/views/schools/components/SchoolForm.vue
Normal file
347
src/views/schools/components/SchoolForm.vue
Normal file
@@ -0,0 +1,347 @@
|
|||||||
|
<template>
|
||||||
|
<a-modal
|
||||||
|
v-model:open="visible"
|
||||||
|
:title="isEdit ? '编辑学校' : '添加学校'"
|
||||||
|
:width="600"
|
||||||
|
:confirm-loading="loading"
|
||||||
|
@ok="handleSubmit"
|
||||||
|
@cancel="handleCancel"
|
||||||
|
>
|
||||||
|
<a-form
|
||||||
|
ref="formRef"
|
||||||
|
:model="formData"
|
||||||
|
:rules="rules"
|
||||||
|
layout="vertical"
|
||||||
|
:label-col="{ span: 24 }"
|
||||||
|
:wrapper-col="{ span: 24 }"
|
||||||
|
>
|
||||||
|
<a-row :gutter="16">
|
||||||
|
<a-col :span="12">
|
||||||
|
<a-form-item label="学校名称" name="name">
|
||||||
|
<a-input
|
||||||
|
v-model:value="formData.name"
|
||||||
|
placeholder="请输入学校名称"
|
||||||
|
:maxlength="50"
|
||||||
|
show-count
|
||||||
|
/>
|
||||||
|
</a-form-item>
|
||||||
|
</a-col>
|
||||||
|
<a-col :span="12">
|
||||||
|
<a-form-item label="学校类型" name="type">
|
||||||
|
<a-select
|
||||||
|
v-model:value="formData.type"
|
||||||
|
placeholder="请选择学校类型"
|
||||||
|
>
|
||||||
|
<a-select-option value="primary">
|
||||||
|
<BookOutlined style="color: #52c41a;" />
|
||||||
|
小学
|
||||||
|
</a-select-option>
|
||||||
|
<a-select-option value="junior">
|
||||||
|
<ReadOutlined style="color: #1890ff;" />
|
||||||
|
初中
|
||||||
|
</a-select-option>
|
||||||
|
<a-select-option value="senior">
|
||||||
|
<TrophyOutlined style="color: #722ed1;" />
|
||||||
|
高中
|
||||||
|
</a-select-option>
|
||||||
|
<a-select-option value="vocational">
|
||||||
|
<ToolOutlined style="color: #fa541c;" />
|
||||||
|
职业学校
|
||||||
|
</a-select-option>
|
||||||
|
</a-select>
|
||||||
|
</a-form-item>
|
||||||
|
</a-col>
|
||||||
|
</a-row>
|
||||||
|
|
||||||
|
<a-row :gutter="16">
|
||||||
|
<a-col :span="12">
|
||||||
|
<a-form-item label="所属区县" name="district">
|
||||||
|
<a-input
|
||||||
|
v-model:value="formData.district"
|
||||||
|
placeholder="请输入所属区县"
|
||||||
|
:maxlength="20"
|
||||||
|
/>
|
||||||
|
</a-form-item>
|
||||||
|
</a-col>
|
||||||
|
<a-col :span="12">
|
||||||
|
<a-form-item label="联系电话" name="phone">
|
||||||
|
<a-input
|
||||||
|
v-model:value="formData.phone"
|
||||||
|
placeholder="请输入联系电话"
|
||||||
|
:maxlength="20"
|
||||||
|
/>
|
||||||
|
</a-form-item>
|
||||||
|
</a-col>
|
||||||
|
</a-row>
|
||||||
|
|
||||||
|
<a-form-item label="学校地址" name="address">
|
||||||
|
<a-input
|
||||||
|
v-model:value="formData.address"
|
||||||
|
placeholder="请输入学校详细地址"
|
||||||
|
:maxlength="100"
|
||||||
|
show-count
|
||||||
|
/>
|
||||||
|
</a-form-item>
|
||||||
|
|
||||||
|
<a-form-item label="校长姓名" name="principal">
|
||||||
|
<a-input
|
||||||
|
v-model:value="formData.principal"
|
||||||
|
placeholder="请输入校长姓名"
|
||||||
|
:maxlength="20"
|
||||||
|
/>
|
||||||
|
</a-form-item>
|
||||||
|
</a-form>
|
||||||
|
|
||||||
|
<!-- 预览信息 -->
|
||||||
|
<div v-if="isEdit" class="preview-section">
|
||||||
|
<a-divider orientation="left">学校信息预览</a-divider>
|
||||||
|
<a-descriptions :column="2" bordered size="small">
|
||||||
|
<a-descriptions-item label="创建时间">
|
||||||
|
{{ formatDate(school?.createTime) }}
|
||||||
|
</a-descriptions-item>
|
||||||
|
<a-descriptions-item label="最后更新">
|
||||||
|
{{ formatDate(school?.updateTime) }}
|
||||||
|
</a-descriptions-item>
|
||||||
|
<a-descriptions-item label="学生总数" :span="2">
|
||||||
|
<a-statistic
|
||||||
|
:value="school?.studentCount || 0"
|
||||||
|
suffix="人"
|
||||||
|
:value-style="{ fontSize: '14px', color: '#1890ff' }"
|
||||||
|
/>
|
||||||
|
</a-descriptions-item>
|
||||||
|
</a-descriptions>
|
||||||
|
</div>
|
||||||
|
</a-modal>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, reactive, computed, watch } from 'vue';
|
||||||
|
import { message } from 'ant-design-vue';
|
||||||
|
import type { FormInstance, Rule } from 'ant-design-vue/es/form';
|
||||||
|
import {
|
||||||
|
BookOutlined,
|
||||||
|
ReadOutlined,
|
||||||
|
TrophyOutlined,
|
||||||
|
ToolOutlined
|
||||||
|
} from '@ant-design/icons-vue';
|
||||||
|
import { useRequest } from 'alova/client';
|
||||||
|
import { createSchool, updateSchool } from '@/apis/schools';
|
||||||
|
import type { School, CreateSchoolParams } from '@/apis/schools';
|
||||||
|
import { format } from 'date-fns';
|
||||||
|
import { zhCN } from 'date-fns/locale';
|
||||||
|
|
||||||
|
// Props定义
|
||||||
|
interface Props {
|
||||||
|
visible: boolean;
|
||||||
|
school?: School;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 事件定义
|
||||||
|
interface Emits {
|
||||||
|
'update:visible': [visible: boolean];
|
||||||
|
'success': [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<Props>();
|
||||||
|
const emit = defineEmits<Emits>();
|
||||||
|
|
||||||
|
// 表单引用
|
||||||
|
const formRef = ref<FormInstance>();
|
||||||
|
|
||||||
|
// 表单数据
|
||||||
|
const formData = reactive<CreateSchoolParams>({
|
||||||
|
name: '',
|
||||||
|
type: 'primary',
|
||||||
|
district: '',
|
||||||
|
address: '',
|
||||||
|
principal: '',
|
||||||
|
phone: ''
|
||||||
|
});
|
||||||
|
|
||||||
|
// 计算属性
|
||||||
|
const visible = computed({
|
||||||
|
get: () => props.visible,
|
||||||
|
set: (value: boolean) => emit('update:visible', value)
|
||||||
|
});
|
||||||
|
|
||||||
|
const isEdit = computed(() => !!props.school);
|
||||||
|
|
||||||
|
// 表单验证规则
|
||||||
|
const rules: Record<string, Rule[]> = {
|
||||||
|
name: [
|
||||||
|
{ required: true, message: '请输入学校名称', trigger: 'blur' },
|
||||||
|
{ min: 2, max: 50, message: '学校名称长度应在2-50个字符之间', trigger: 'blur' }
|
||||||
|
],
|
||||||
|
type: [
|
||||||
|
{ required: true, message: '请选择学校类型', trigger: 'change' }
|
||||||
|
],
|
||||||
|
district: [
|
||||||
|
{ required: true, message: '请输入所属区县', trigger: 'blur' },
|
||||||
|
{ max: 20, message: '区县名称不能超过20个字符', trigger: 'blur' }
|
||||||
|
],
|
||||||
|
phone: [
|
||||||
|
{
|
||||||
|
pattern: /^1[3-9]\d{9}$|^0\d{2,3}-?\d{7,8}$/,
|
||||||
|
message: '请输入正确的手机号或固定电话',
|
||||||
|
trigger: 'blur'
|
||||||
|
}
|
||||||
|
],
|
||||||
|
address: [
|
||||||
|
{ max: 100, message: '地址不能超过100个字符', trigger: 'blur' }
|
||||||
|
],
|
||||||
|
principal: [
|
||||||
|
{ max: 20, message: '校长姓名不能超过20个字符', trigger: 'blur' }
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
||||||
|
// 创建学校请求
|
||||||
|
const { loading: createLoading, send: sendCreateSchool } = useRequest((params: CreateSchoolParams) =>
|
||||||
|
createSchool(params), {
|
||||||
|
immediate: false
|
||||||
|
});
|
||||||
|
|
||||||
|
// 更新学校请求
|
||||||
|
const { loading: updateLoading, send: sendUpdateSchool } = useRequest(
|
||||||
|
(id: string, params: CreateSchoolParams) => updateSchool(id, params), {
|
||||||
|
immediate: false
|
||||||
|
});
|
||||||
|
|
||||||
|
const loading = computed(() => createLoading.value || updateLoading.value);
|
||||||
|
|
||||||
|
// 监听props变化,初始化表单数据
|
||||||
|
watch(() => props.school, (school) => {
|
||||||
|
if (school) {
|
||||||
|
Object.assign(formData, {
|
||||||
|
name: school.name,
|
||||||
|
type: school.type,
|
||||||
|
district: school.district,
|
||||||
|
address: school.address || '',
|
||||||
|
principal: school.principal || '',
|
||||||
|
phone: school.phone || ''
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, { immediate: true });
|
||||||
|
|
||||||
|
// 监听弹窗显示状态
|
||||||
|
watch(visible, (newVisible) => {
|
||||||
|
if (newVisible) {
|
||||||
|
// 弹窗打开时,如果是新建模式则重置表单
|
||||||
|
if (!props.school) {
|
||||||
|
resetForm();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 弹窗关闭时清理验证状态
|
||||||
|
formRef.value?.clearValidate();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 重置表单
|
||||||
|
const resetForm = () => {
|
||||||
|
Object.assign(formData, {
|
||||||
|
name: '',
|
||||||
|
type: 'primary',
|
||||||
|
district: '',
|
||||||
|
address: '',
|
||||||
|
principal: '',
|
||||||
|
phone: ''
|
||||||
|
});
|
||||||
|
formRef.value?.resetFields();
|
||||||
|
};
|
||||||
|
|
||||||
|
// 提交表单
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
try {
|
||||||
|
await formRef.value?.validate();
|
||||||
|
|
||||||
|
if (isEdit.value && props.school) {
|
||||||
|
// 编辑模式
|
||||||
|
await sendUpdateSchool(props.school.id, formData);
|
||||||
|
message.success('学校信息更新成功');
|
||||||
|
} else {
|
||||||
|
// 新建模式
|
||||||
|
await sendCreateSchool(formData);
|
||||||
|
message.success('学校创建成功');
|
||||||
|
}
|
||||||
|
|
||||||
|
emit('success');
|
||||||
|
visible.value = false;
|
||||||
|
} catch (error: any) {
|
||||||
|
if (error.errorFields) {
|
||||||
|
// 表单验证错误
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
message.error(error.message || `${isEdit.value ? '更新' : '创建'}失败`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 取消操作
|
||||||
|
const handleCancel = () => {
|
||||||
|
visible.value = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 格式化日期
|
||||||
|
const formatDate = (dateStr?: string) => {
|
||||||
|
if (!dateStr) return '-';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const date = new Date(dateStr);
|
||||||
|
return format(date, 'yyyy-MM-dd HH:mm:ss', { locale: zhCN });
|
||||||
|
} catch (error) {
|
||||||
|
return '-';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.preview-section {
|
||||||
|
margin-top: 16px;
|
||||||
|
padding-top: 16px;
|
||||||
|
border-top: 1px solid #f0f0f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 表单样式优化 */
|
||||||
|
:deep(.ant-form-item) {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.ant-select-selection-item) {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.ant-input-show-count-suffix) {
|
||||||
|
color: #8c8c8c;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 描述列表样式 */
|
||||||
|
:deep(.ant-descriptions-item-label) {
|
||||||
|
font-weight: 500;
|
||||||
|
background: #fafafa;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 统计数值样式 */
|
||||||
|
:deep(.ant-statistic) {
|
||||||
|
.ant-statistic-content {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
.ant-statistic-content-value {
|
||||||
|
margin-right: 4px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 响应式设计 */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
:deep(.ant-modal) {
|
||||||
|
margin: 16px;
|
||||||
|
max-width: calc(100vw - 32px);
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.ant-col) {
|
||||||
|
span: 24 !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
549
src/views/schools/components/SchoolTree.vue
Normal file
549
src/views/schools/components/SchoolTree.vue
Normal file
@@ -0,0 +1,549 @@
|
|||||||
|
<template>
|
||||||
|
<div class="school-tree">
|
||||||
|
<div v-if="!data.length && !loading" class="empty-state">
|
||||||
|
<a-empty
|
||||||
|
image="https://gw.alipayobjects.com/zos/antfincdn/ZHrcdLPrvN/empty.svg"
|
||||||
|
:image-style="{ height: '100px' }"
|
||||||
|
description="暂无学校数据"
|
||||||
|
>
|
||||||
|
<template #description>
|
||||||
|
<span style="color: #8c8c8c">暂无学校数据,请添加学校或调整筛选条件</span>
|
||||||
|
</template>
|
||||||
|
</a-empty>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="tree-content">
|
||||||
|
<div v-for="school in data" :key="school.id" class="school-item">
|
||||||
|
<!-- 学校卡片 -->
|
||||||
|
<a-card class="school-card" :hoverable="true">
|
||||||
|
<template #title>
|
||||||
|
<div class="school-header">
|
||||||
|
<div class="school-info">
|
||||||
|
<HomeOutlined class="school-icon" />
|
||||||
|
<span class="school-name">{{ school.name }}</span>
|
||||||
|
<a-tag :color="getSchoolTypeColor(school.type)" size="small" class="type-tag">
|
||||||
|
{{ getSchoolTypeText(school.type) }}
|
||||||
|
</a-tag>
|
||||||
|
<a-tag color="geekblue" size="small">{{ school.district }}</a-tag>
|
||||||
|
</div>
|
||||||
|
<div class="school-actions">
|
||||||
|
<a-dropdown :trigger="['click']" placement="bottomRight">
|
||||||
|
<a-button size="small" type="text">
|
||||||
|
<MoreOutlined />
|
||||||
|
</a-button>
|
||||||
|
<template #overlay>
|
||||||
|
<a-menu>
|
||||||
|
<a-menu-item key="add-grade" @click="$emit('addGrade', school.id)">
|
||||||
|
<PlusOutlined />
|
||||||
|
添加年级
|
||||||
|
</a-menu-item>
|
||||||
|
<a-menu-item key="edit" @click="$emit('editSchool', school)">
|
||||||
|
<EditOutlined />
|
||||||
|
编辑学校
|
||||||
|
</a-menu-item>
|
||||||
|
<a-menu-divider />
|
||||||
|
<a-menu-item key="delete" danger @click="handleDeleteSchool(school)">
|
||||||
|
<DeleteOutlined />
|
||||||
|
删除学校
|
||||||
|
</a-menu-item>
|
||||||
|
</a-menu>
|
||||||
|
</template>
|
||||||
|
</a-dropdown>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #extra>
|
||||||
|
<div class="school-stats">
|
||||||
|
<a-statistic
|
||||||
|
title="学生总数"
|
||||||
|
:value="school.studentCount || 0"
|
||||||
|
:value-style="{ fontSize: '16px' }"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- 学校基本信息 -->
|
||||||
|
<div class="school-details">
|
||||||
|
<div v-if="school.address" class="detail-item">
|
||||||
|
<EnvironmentOutlined />
|
||||||
|
<span>{{ school.address }}</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="school.principal" class="detail-item">
|
||||||
|
<UserOutlined />
|
||||||
|
<span>校长:{{ school.principal }}</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="school.phone" class="detail-item">
|
||||||
|
<PhoneOutlined />
|
||||||
|
<span>{{ school.phone }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 年级列表 -->
|
||||||
|
<div v-if="school.grades?.length" class="grades-section">
|
||||||
|
<a-divider orientation="left">年级班级</a-divider>
|
||||||
|
<div class="grades-container">
|
||||||
|
<div v-for="grade in school.grades" :key="grade.id" class="grade-item">
|
||||||
|
<a-card size="small" class="grade-card">
|
||||||
|
<template #title>
|
||||||
|
<div class="grade-header">
|
||||||
|
<div class="grade-info">
|
||||||
|
<BookOutlined class="grade-icon" />
|
||||||
|
<span class="grade-name">{{ grade.name }}</span>
|
||||||
|
<a-tag size="small" color="orange">{{ grade.level }}年级</a-tag>
|
||||||
|
</div>
|
||||||
|
<div class="grade-actions">
|
||||||
|
<a-dropdown :trigger="['click']" placement="bottomRight">
|
||||||
|
<a-button size="small" type="text">
|
||||||
|
<MoreOutlined />
|
||||||
|
</a-button>
|
||||||
|
<template #overlay>
|
||||||
|
<a-menu>
|
||||||
|
<a-menu-item key="add-class" @click="$emit('addClass', school.id, grade.id)">
|
||||||
|
<PlusOutlined />
|
||||||
|
添加班级
|
||||||
|
</a-menu-item>
|
||||||
|
<a-menu-item key="edit" @click="$emit('editGrade', grade)">
|
||||||
|
<EditOutlined />
|
||||||
|
编辑年级
|
||||||
|
</a-menu-item>
|
||||||
|
<a-menu-divider />
|
||||||
|
<a-menu-item key="delete" danger @click="handleDeleteGrade(grade)">
|
||||||
|
<DeleteOutlined />
|
||||||
|
删除年级
|
||||||
|
</a-menu-item>
|
||||||
|
</a-menu>
|
||||||
|
</template>
|
||||||
|
</a-dropdown>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- 班级列表 -->
|
||||||
|
<div v-if="grade.classes?.length" class="classes-container">
|
||||||
|
<div v-for="cls in grade.classes" :key="cls.id" class="class-item">
|
||||||
|
<a-tag
|
||||||
|
:color="getClassColor(cls.id)"
|
||||||
|
class="class-tag"
|
||||||
|
@click="handleClassClick(cls)"
|
||||||
|
>
|
||||||
|
<TeamOutlined />
|
||||||
|
{{ cls.name }}
|
||||||
|
<span v-if="cls.studentCount" class="student-count">
|
||||||
|
({{ cls.studentCount }}人)
|
||||||
|
</span>
|
||||||
|
<span v-if="cls.teacherName" class="teacher-name">
|
||||||
|
- {{ cls.teacherName }}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<a-dropdown :trigger="['click']" @click.stop>
|
||||||
|
<MoreOutlined class="class-more" />
|
||||||
|
<template #overlay>
|
||||||
|
<a-menu>
|
||||||
|
<a-menu-item key="edit" @click="$emit('editClass', cls)">
|
||||||
|
<EditOutlined />
|
||||||
|
编辑班级
|
||||||
|
</a-menu-item>
|
||||||
|
<a-menu-divider />
|
||||||
|
<a-menu-item key="delete" danger @click="handleDeleteClass(cls)">
|
||||||
|
<DeleteOutlined />
|
||||||
|
删除班级
|
||||||
|
</a-menu-item>
|
||||||
|
</a-menu>
|
||||||
|
</template>
|
||||||
|
</a-dropdown>
|
||||||
|
</a-tag>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 无班级提示 -->
|
||||||
|
<div v-else class="no-classes">
|
||||||
|
<a-button
|
||||||
|
type="dashed"
|
||||||
|
size="small"
|
||||||
|
@click="$emit('addClass', school.id, grade.id)"
|
||||||
|
style="width: 100%"
|
||||||
|
>
|
||||||
|
<PlusOutlined />
|
||||||
|
添加班级
|
||||||
|
</a-button>
|
||||||
|
</div>
|
||||||
|
</a-card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 无年级提示 -->
|
||||||
|
<div v-else class="no-grades">
|
||||||
|
<a-empty
|
||||||
|
:image="simpleImage"
|
||||||
|
:image-style="{ height: '40px' }"
|
||||||
|
description="暂无年级"
|
||||||
|
>
|
||||||
|
<a-button type="primary" size="small" @click="$emit('addGrade', school.id)">
|
||||||
|
<PlusOutlined />
|
||||||
|
添加年级
|
||||||
|
</a-button>
|
||||||
|
</a-empty>
|
||||||
|
</div>
|
||||||
|
</a-card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { Modal } from 'ant-design-vue';
|
||||||
|
import {
|
||||||
|
HomeOutlined,
|
||||||
|
BookOutlined,
|
||||||
|
TeamOutlined,
|
||||||
|
MoreOutlined,
|
||||||
|
PlusOutlined,
|
||||||
|
EditOutlined,
|
||||||
|
DeleteOutlined,
|
||||||
|
EnvironmentOutlined,
|
||||||
|
UserOutlined,
|
||||||
|
PhoneOutlined
|
||||||
|
} from '@ant-design/icons-vue';
|
||||||
|
import type { School, Grade, Class } from '@/apis/schools';
|
||||||
|
|
||||||
|
// Props定义
|
||||||
|
interface Props {
|
||||||
|
data: School[];
|
||||||
|
loading?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 事件定义
|
||||||
|
interface Emits {
|
||||||
|
addGrade: [schoolId: string];
|
||||||
|
addClass: [schoolId: string, gradeId: string];
|
||||||
|
editSchool: [school: School];
|
||||||
|
editGrade: [grade: Grade];
|
||||||
|
editClass: [cls: Class];
|
||||||
|
deleteSchool: [school: School];
|
||||||
|
deleteGrade: [grade: Grade];
|
||||||
|
deleteClass: [cls: Class];
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
|
loading: false
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits<Emits>();
|
||||||
|
|
||||||
|
// 简单图标(用于空状态)
|
||||||
|
const simpleImage = 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTI4IiBoZWlnaHQ9IjEyOCIgdmlld0JveD0iMCAwIDEyOCAxMjgiIGZpbGw9Im5vbmUiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+CjxwYXRoIGQ9Ik02NCA5NkM4MC4xNjA0IDk2IDkzIDgzLjE2MDQgOTMgNjdTODAuMTYwNCAzOCA2NCAzOFMzNSA1MC44Mzk2IDM1IDY3UzQ3LjgzOTYgOTYgNjQgOTZaIiBzdHJva2U9IiNEOUQ5RDkiIHN0cm9rZS13aWR0aD0iMiIvPgo8L3N2Zz4K';
|
||||||
|
|
||||||
|
// 获取学校类型颜色
|
||||||
|
const getSchoolTypeColor = (type: string) => {
|
||||||
|
const colorMap: Record<string, string> = {
|
||||||
|
primary: 'green',
|
||||||
|
junior: 'blue',
|
||||||
|
senior: 'purple',
|
||||||
|
vocational: 'orange'
|
||||||
|
};
|
||||||
|
return colorMap[type] || 'default';
|
||||||
|
};
|
||||||
|
|
||||||
|
// 获取学校类型文本
|
||||||
|
const getSchoolTypeText = (type: string) => {
|
||||||
|
const textMap: Record<string, string> = {
|
||||||
|
primary: '小学',
|
||||||
|
junior: '初中',
|
||||||
|
senior: '高中',
|
||||||
|
vocational: '职校'
|
||||||
|
};
|
||||||
|
return textMap[type] || '未知';
|
||||||
|
};
|
||||||
|
|
||||||
|
// 获取班级颜色(基于ID生成固定颜色)
|
||||||
|
const getClassColor = (classId: string) => {
|
||||||
|
const colors = ['blue', 'green', 'orange', 'red', 'purple', 'cyan', 'geekblue', 'magenta'];
|
||||||
|
const hash = classId.split('').reduce((acc, char) => acc + char.charCodeAt(0), 0);
|
||||||
|
return colors[hash % colors.length];
|
||||||
|
};
|
||||||
|
|
||||||
|
// 处理班级点击
|
||||||
|
const handleClassClick = (cls: Class) => {
|
||||||
|
console.log('班级点击:', cls);
|
||||||
|
// 可以在这里添加班级详情查看逻辑
|
||||||
|
};
|
||||||
|
|
||||||
|
// 删除学校确认
|
||||||
|
const handleDeleteSchool = (school: School) => {
|
||||||
|
Modal.confirm({
|
||||||
|
title: '确认删除学校',
|
||||||
|
content: `确定要删除学校"${school.name}"吗?此操作将同时删除该学校下的所有年级和班级,且不可恢复。`,
|
||||||
|
okText: '确定删除',
|
||||||
|
okType: 'danger',
|
||||||
|
cancelText: '取消',
|
||||||
|
onOk() {
|
||||||
|
emit('deleteSchool', school);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// 删除年级确认
|
||||||
|
const handleDeleteGrade = (grade: Grade) => {
|
||||||
|
Modal.confirm({
|
||||||
|
title: '确认删除年级',
|
||||||
|
content: `确定要删除年级"${grade.name}"吗?此操作将同时删除该年级下的所有班级,且不可恢复。`,
|
||||||
|
okText: '确定删除',
|
||||||
|
okType: 'danger',
|
||||||
|
cancelText: '取消',
|
||||||
|
onOk() {
|
||||||
|
emit('deleteGrade', grade);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// 删除班级确认
|
||||||
|
const handleDeleteClass = (cls: Class) => {
|
||||||
|
Modal.confirm({
|
||||||
|
title: '确认删除班级',
|
||||||
|
content: `确定要删除班级"${cls.name}"吗?此操作不可恢复。`,
|
||||||
|
okText: '确定删除',
|
||||||
|
okType: 'danger',
|
||||||
|
cancelText: '取消',
|
||||||
|
onOk() {
|
||||||
|
emit('deleteClass', cls);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.school-tree {
|
||||||
|
.empty-state {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
min-height: 300px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tree-content {
|
||||||
|
.school-item {
|
||||||
|
margin-bottom: 24px;
|
||||||
|
|
||||||
|
&:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.school-card {
|
||||||
|
border: 2px solid #f0f0f0;
|
||||||
|
border-radius: 12px;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
border-color: #1890ff;
|
||||||
|
box-shadow: 0 4px 12px rgba(24, 144, 255, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.ant-card-head) {
|
||||||
|
border-bottom: 1px solid #f0f0f0;
|
||||||
|
background: linear-gradient(135deg, #f6f9fc 0%, #ffffff 100%);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.school-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
width: 100%;
|
||||||
|
|
||||||
|
.school-info {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
flex: 1;
|
||||||
|
|
||||||
|
.school-icon {
|
||||||
|
color: #1890ff;
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.school-name {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #262626;
|
||||||
|
}
|
||||||
|
|
||||||
|
.type-tag {
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.school-actions {
|
||||||
|
margin-left: 16px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.school-details {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
|
||||||
|
.detail-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
color: #8c8c8c;
|
||||||
|
font-size: 14px;
|
||||||
|
|
||||||
|
&:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.grades-section {
|
||||||
|
.grades-container {
|
||||||
|
display: grid;
|
||||||
|
gap: 16px;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.grade-card {
|
||||||
|
border: 1px solid #e8e8e8;
|
||||||
|
border-radius: 8px;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
border-color: #40a9ff;
|
||||||
|
box-shadow: 0 2px 8px rgba(64, 169, 255, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.ant-card-head) {
|
||||||
|
background: #fafafa;
|
||||||
|
border-bottom: 1px solid #e8e8e8;
|
||||||
|
min-height: auto;
|
||||||
|
padding: 8px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.ant-card-body) {
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.grade-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
width: 100%;
|
||||||
|
|
||||||
|
.grade-info {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
flex: 1;
|
||||||
|
|
||||||
|
.grade-icon {
|
||||||
|
color: #fa8c16;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grade-name {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #262626;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.classes-container {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
|
||||||
|
.class-item {
|
||||||
|
.class-tag {
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.3s;
|
||||||
|
padding: 4px 8px;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 12px;
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.student-count {
|
||||||
|
opacity: 0.8;
|
||||||
|
margin-left: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.teacher-name {
|
||||||
|
opacity: 0.7;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.class-more {
|
||||||
|
margin-left: 4px;
|
||||||
|
opacity: 0.6;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.no-classes {
|
||||||
|
text-align: center;
|
||||||
|
padding: 16px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.no-grades {
|
||||||
|
text-align: center;
|
||||||
|
padding: 24px 0;
|
||||||
|
border: 2px dashed #d9d9d9;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #fafafa;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 响应式设计 */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.school-header {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 8px;
|
||||||
|
|
||||||
|
.school-info {
|
||||||
|
width: 100%;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.school-actions {
|
||||||
|
margin-left: 0;
|
||||||
|
align-self: flex-end;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.grades-container {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grade-header {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 4px;
|
||||||
|
|
||||||
|
.grade-actions {
|
||||||
|
align-self: flex-end;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.classes-container {
|
||||||
|
.class-item .class-tag {
|
||||||
|
.teacher-name {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
355
src/views/users/UserPage.vue
Normal file
355
src/views/users/UserPage.vue
Normal file
@@ -0,0 +1,355 @@
|
|||||||
|
<template>
|
||||||
|
<div class="user-page">
|
||||||
|
<div class="page-header">
|
||||||
|
<h1 class="page-title">用户管理</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="page-content">
|
||||||
|
<!-- 搜索和筛选栏 -->
|
||||||
|
<div class="toolbar">
|
||||||
|
<div class="search-section">
|
||||||
|
<a-input-search
|
||||||
|
v-model:value="searchKeyword"
|
||||||
|
placeholder="搜索学生姓名或家长手机号"
|
||||||
|
style="width: 300px"
|
||||||
|
@search="handleSearch"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<a-select
|
||||||
|
v-model:value="filterSchool"
|
||||||
|
placeholder="选择学校"
|
||||||
|
style="width: 150px; margin-left: 8px"
|
||||||
|
allow-clear
|
||||||
|
show-search
|
||||||
|
@change="handleSearch"
|
||||||
|
>
|
||||||
|
<a-select-option v-for="school in schoolOptions" :key="school.id" :value="school.id">
|
||||||
|
{{ school.name }}
|
||||||
|
</a-select-option>
|
||||||
|
</a-select>
|
||||||
|
|
||||||
|
<a-select
|
||||||
|
v-model:value="filterGrade"
|
||||||
|
placeholder="年级"
|
||||||
|
style="width: 100px; margin-left: 8px"
|
||||||
|
allow-clear
|
||||||
|
@change="handleSearch"
|
||||||
|
>
|
||||||
|
<a-select-option v-for="grade in gradeOptions" :key="grade.id" :value="grade.id">
|
||||||
|
{{ grade.name }}
|
||||||
|
</a-select-option>
|
||||||
|
</a-select>
|
||||||
|
|
||||||
|
<a-select
|
||||||
|
v-model:value="filterClass"
|
||||||
|
placeholder="班级"
|
||||||
|
style="width: 120px; margin-left: 8px"
|
||||||
|
allow-clear
|
||||||
|
@change="handleSearch"
|
||||||
|
>
|
||||||
|
<a-select-option v-for="cls in classOptions" :key="cls.id" :value="cls.id">
|
||||||
|
{{ cls.name }}
|
||||||
|
</a-select-option>
|
||||||
|
</a-select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="action-section">
|
||||||
|
<a-button @click="handleRefresh">
|
||||||
|
<ReloadOutlined/>
|
||||||
|
刷新
|
||||||
|
</a-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 用户列表 -->
|
||||||
|
<UserList
|
||||||
|
:loading="loading"
|
||||||
|
:data-source="users"
|
||||||
|
:pagination="pagination"
|
||||||
|
@view-detail="handleViewDetail"
|
||||||
|
@unbind="handleUnbind"
|
||||||
|
@disable="handleDisable"
|
||||||
|
@enable="handleEnable"
|
||||||
|
@page-change="handlePageChange"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 用户详情弹窗 -->
|
||||||
|
<UserDetailModal
|
||||||
|
v-model:visible="detailVisible"
|
||||||
|
:user="currentUser"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import {ref, reactive, onMounted} from 'vue';
|
||||||
|
import {message, Modal} from 'ant-design-vue';
|
||||||
|
import {ReloadOutlined} from '@ant-design/icons-vue';
|
||||||
|
import {useRequest} from 'alova/client';
|
||||||
|
import {getUserList, unbindParentStudent, disableUser, enableUser} from '@/apis/users';
|
||||||
|
import {getSchoolList} from '@/apis/schools';
|
||||||
|
import type {AppUser, UserQueryParams} from '@/apis/users';
|
||||||
|
import type {School} from '@/apis/schools';
|
||||||
|
|
||||||
|
// 导入子组件
|
||||||
|
import UserList from './components/UserList.vue';
|
||||||
|
import UserDetailModal from './components/UserDetailModal.vue';
|
||||||
|
|
||||||
|
// 搜索筛选参数
|
||||||
|
const searchKeyword = ref('');
|
||||||
|
const filterSchool = ref<string>();
|
||||||
|
const filterGrade = ref<string>();
|
||||||
|
const filterClass = ref<string>();
|
||||||
|
|
||||||
|
// 列表数据
|
||||||
|
const users = ref<AppUser[]>([]);
|
||||||
|
|
||||||
|
// 分页
|
||||||
|
const pagination = reactive({
|
||||||
|
current: 1,
|
||||||
|
pageSize: 10,
|
||||||
|
total: 0,
|
||||||
|
showSizeChanger: true,
|
||||||
|
showQuickJumper: true,
|
||||||
|
showTotal: (total: number) => `共 ${total} 条记录`
|
||||||
|
});
|
||||||
|
|
||||||
|
// 详情弹窗
|
||||||
|
const detailVisible = ref(false);
|
||||||
|
const currentUser = ref<AppUser>();
|
||||||
|
|
||||||
|
// 筛选选项
|
||||||
|
const schoolOptions = ref<School[]>([]);
|
||||||
|
const gradeOptions = ref<any[]>([]);
|
||||||
|
const classOptions = ref<any[]>([]);
|
||||||
|
|
||||||
|
// 获取用户列表
|
||||||
|
const {loading, send: fetchUsers} = useRequest((params: UserQueryParams) => getUserList(params), {
|
||||||
|
immediate: false
|
||||||
|
});
|
||||||
|
|
||||||
|
// 获取学校列表(用于筛选)
|
||||||
|
const {send: fetchSchools} = useRequest(() => getSchoolList(), {
|
||||||
|
immediate: false
|
||||||
|
});
|
||||||
|
|
||||||
|
// 构建查询参数
|
||||||
|
const buildQueryParams = (): UserQueryParams => {
|
||||||
|
return {
|
||||||
|
page: pagination.current,
|
||||||
|
pageSize: pagination.pageSize,
|
||||||
|
keyword: searchKeyword.value || undefined,
|
||||||
|
schoolId: filterSchool.value,
|
||||||
|
gradeId: filterGrade.value,
|
||||||
|
classId: filterClass.value
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
// 搜索处理
|
||||||
|
const handleSearch = () => {
|
||||||
|
pagination.current = 1;
|
||||||
|
loadUsers();
|
||||||
|
};
|
||||||
|
|
||||||
|
// 加载用户列表
|
||||||
|
const loadUsers = async () => {
|
||||||
|
try {
|
||||||
|
const params = buildQueryParams();
|
||||||
|
const result = await fetchUsers(params);
|
||||||
|
|
||||||
|
users.value = result.list;
|
||||||
|
pagination.total = result.total;
|
||||||
|
} catch (error: any) {
|
||||||
|
message.error(error.message || '获取用户列表失败');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 加载学校数据
|
||||||
|
const loadSchools = async () => {
|
||||||
|
try {
|
||||||
|
const result = await fetchSchools();
|
||||||
|
schoolOptions.value = result.list;
|
||||||
|
|
||||||
|
// 提取年级和班级选项
|
||||||
|
const grades = new Map();
|
||||||
|
const classes = new Map();
|
||||||
|
|
||||||
|
result.list.forEach(school => {
|
||||||
|
if (school.grades) {
|
||||||
|
school.grades.forEach(grade => {
|
||||||
|
grades.set(grade.id, {
|
||||||
|
id: grade.id,
|
||||||
|
name: grade.name,
|
||||||
|
schoolId: grade.schoolId
|
||||||
|
});
|
||||||
|
|
||||||
|
if (grade.classes) {
|
||||||
|
grade.classes.forEach(cls => {
|
||||||
|
classes.set(cls.id, {
|
||||||
|
id: cls.id,
|
||||||
|
name: cls.name,
|
||||||
|
gradeId: cls.gradeId,
|
||||||
|
schoolId: cls.schoolId
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
gradeOptions.value = Array.from(grades.values());
|
||||||
|
classOptions.value = Array.from(classes.values());
|
||||||
|
|
||||||
|
} catch (error: any) {
|
||||||
|
message.error(error.message || '获取学校数据失败');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 查看详情
|
||||||
|
const handleViewDetail = (user: AppUser) => {
|
||||||
|
currentUser.value = user;
|
||||||
|
detailVisible.value = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 解绑用户
|
||||||
|
const handleUnbind = (user: AppUser) => {
|
||||||
|
Modal.confirm({
|
||||||
|
title: '确认解绑',
|
||||||
|
content: `确定要解绑家长"${user.nickname || user.phone}"与学生"${user.studentName}"的关系吗?`,
|
||||||
|
okText: '确定',
|
||||||
|
okType: 'danger',
|
||||||
|
cancelText: '取消',
|
||||||
|
async onOk() {
|
||||||
|
try {
|
||||||
|
await unbindParentStudent(user.id);
|
||||||
|
message.success('解绑成功');
|
||||||
|
loadUsers();
|
||||||
|
} catch (error: any) {
|
||||||
|
message.error(error.message || '解绑失败');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// 禁用用户
|
||||||
|
const handleDisable = (user: AppUser) => {
|
||||||
|
Modal.confirm({
|
||||||
|
title: '确认禁用',
|
||||||
|
content: `确定要禁用用户"${user.nickname || user.phone}"吗?禁用后该用户将无法正常使用小程序。`,
|
||||||
|
okText: '确定',
|
||||||
|
okType: 'danger',
|
||||||
|
cancelText: '取消',
|
||||||
|
async onOk() {
|
||||||
|
try {
|
||||||
|
await disableUser(user.id);
|
||||||
|
message.success('禁用成功');
|
||||||
|
loadUsers();
|
||||||
|
} catch (error: any) {
|
||||||
|
message.error(error.message || '禁用失败');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// 启用用户
|
||||||
|
const handleEnable = (user: AppUser) => {
|
||||||
|
Modal.confirm({
|
||||||
|
title: '确认启用',
|
||||||
|
content: `确定要启用用户"${user.nickname || user.phone}"吗?`,
|
||||||
|
okText: '确定',
|
||||||
|
cancelText: '取消',
|
||||||
|
async onOk() {
|
||||||
|
try {
|
||||||
|
await enableUser(user.id);
|
||||||
|
message.success('启用成功');
|
||||||
|
loadUsers();
|
||||||
|
} catch (error: any) {
|
||||||
|
message.error(error.message || '启用失败');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// 刷新数据
|
||||||
|
const handleRefresh = () => {
|
||||||
|
loadUsers();
|
||||||
|
};
|
||||||
|
|
||||||
|
// 分页改变
|
||||||
|
const handlePageChange = (page: number, pageSize: number) => {
|
||||||
|
pagination.current = page;
|
||||||
|
pagination.pageSize = pageSize;
|
||||||
|
loadUsers();
|
||||||
|
};
|
||||||
|
|
||||||
|
// 初始化
|
||||||
|
onMounted(() => {
|
||||||
|
loadUsers();
|
||||||
|
loadSchools();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.user-page {
|
||||||
|
.page-header {
|
||||||
|
background: #fff;
|
||||||
|
padding: 12px 24px;
|
||||||
|
border-radius: 8px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
|
||||||
|
|
||||||
|
.page-title {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #262626;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-content {
|
||||||
|
// margin-top: 24px; // 移除多余的margin
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
padding: 16px;
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
|
||||||
|
|
||||||
|
.search-section {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-section {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 响应式设计 */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.toolbar {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
gap: 16px;
|
||||||
|
|
||||||
|
.search-section {
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-section {
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
373
src/views/users/components/UserDetailModal.vue
Normal file
373
src/views/users/components/UserDetailModal.vue
Normal file
@@ -0,0 +1,373 @@
|
|||||||
|
<template>
|
||||||
|
<a-modal
|
||||||
|
:open="visible"
|
||||||
|
title="用户详细信息"
|
||||||
|
:width="800"
|
||||||
|
:footer="null"
|
||||||
|
@cancel="handleCancel"
|
||||||
|
>
|
||||||
|
<div v-if="user" class="user-detail">
|
||||||
|
<!-- 用户基本信息 -->
|
||||||
|
<a-card title="基本信息" class="detail-card">
|
||||||
|
<a-row :gutter="24">
|
||||||
|
<a-col :span="6">
|
||||||
|
<div class="avatar-section">
|
||||||
|
<a-avatar :src="user.avatar" :size="80" class="user-avatar">
|
||||||
|
{{ user.nickname?.[0] || user.phone?.slice(-4) }}
|
||||||
|
</a-avatar>
|
||||||
|
<div class="status-badge">
|
||||||
|
<a-tag :color="getStatusColor(user.status || 'active')">
|
||||||
|
{{ getStatusText(user.status || 'active') }}
|
||||||
|
</a-tag>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</a-col>
|
||||||
|
<a-col :span="18">
|
||||||
|
<a-descriptions :column="2" bordered>
|
||||||
|
<a-descriptions-item label="用户昵称">
|
||||||
|
{{ user.nickname || '未设置' }}
|
||||||
|
</a-descriptions-item>
|
||||||
|
<a-descriptions-item label="手机号码">
|
||||||
|
{{ user.phone }}
|
||||||
|
</a-descriptions-item>
|
||||||
|
<a-descriptions-item label="微信OpenID">
|
||||||
|
<a-typography-text code copyable>{{ user.openid }}</a-typography-text>
|
||||||
|
</a-descriptions-item>
|
||||||
|
<a-descriptions-item label="用户ID">
|
||||||
|
<a-typography-text code copyable>{{ user.id }}</a-typography-text>
|
||||||
|
</a-descriptions-item>
|
||||||
|
<a-descriptions-item label="注册时间">
|
||||||
|
{{ formatDate(user.createTime) }}
|
||||||
|
</a-descriptions-item>
|
||||||
|
<a-descriptions-item label="最后更新">
|
||||||
|
{{ formatDate(user.updateTime) }}
|
||||||
|
</a-descriptions-item>
|
||||||
|
</a-descriptions>
|
||||||
|
</a-col>
|
||||||
|
</a-row>
|
||||||
|
</a-card>
|
||||||
|
|
||||||
|
<!-- 学生信息 -->
|
||||||
|
<a-card title="学生信息" class="detail-card">
|
||||||
|
<div v-if="user.studentName">
|
||||||
|
<a-descriptions :column="2" bordered>
|
||||||
|
<a-descriptions-item label="学生姓名">
|
||||||
|
{{ user.studentName }}
|
||||||
|
</a-descriptions-item>
|
||||||
|
<a-descriptions-item label="学生ID">
|
||||||
|
<a-typography-text code copyable>{{ user.studentId }}</a-typography-text>
|
||||||
|
</a-descriptions-item>
|
||||||
|
<a-descriptions-item label="座位号">
|
||||||
|
{{ user.studentSeatNumber || '未设置' }}
|
||||||
|
</a-descriptions-item>
|
||||||
|
<a-descriptions-item label="绑定状态">
|
||||||
|
<a-tag color="green">已绑定</a-tag>
|
||||||
|
</a-descriptions-item>
|
||||||
|
</a-descriptions>
|
||||||
|
</div>
|
||||||
|
<div v-else class="no-student">
|
||||||
|
<a-empty
|
||||||
|
image="https://gw.alipayobjects.com/zos/antfincdn/ZHrcdLPrvN/empty.svg"
|
||||||
|
:image-style="{ height: '60px' }"
|
||||||
|
description="该用户未绑定学生信息"
|
||||||
|
>
|
||||||
|
<template #description>
|
||||||
|
<span style="color: #8c8c8c">该用户未绑定学生信息</span>
|
||||||
|
</template>
|
||||||
|
</a-empty>
|
||||||
|
</div>
|
||||||
|
</a-card>
|
||||||
|
|
||||||
|
<!-- 学校班级信息 -->
|
||||||
|
<a-card title="学校班级" class="detail-card">
|
||||||
|
<a-descriptions :column="1" bordered>
|
||||||
|
<a-descriptions-item label="学校名称">
|
||||||
|
<div class="school-item">
|
||||||
|
<BankOutlined style="color: #1890ff; margin-right: 8px;" />
|
||||||
|
{{ user.schoolName }}
|
||||||
|
</div>
|
||||||
|
</a-descriptions-item>
|
||||||
|
<a-descriptions-item label="年级班级">
|
||||||
|
<div class="grade-class-item">
|
||||||
|
<TeamOutlined style="color: #52c41a; margin-right: 8px;" />
|
||||||
|
{{ user.gradeName }} {{ user.className }}
|
||||||
|
</div>
|
||||||
|
</a-descriptions-item>
|
||||||
|
<a-descriptions-item label="学校ID">
|
||||||
|
<a-typography-text code copyable>{{ user.schoolId }}</a-typography-text>
|
||||||
|
</a-descriptions-item>
|
||||||
|
</a-descriptions>
|
||||||
|
</a-card>
|
||||||
|
|
||||||
|
<!-- 学习统计 -->
|
||||||
|
<a-card title="学习统计" class="detail-card">
|
||||||
|
<a-row :gutter="16">
|
||||||
|
<a-col :span="8">
|
||||||
|
<a-statistic
|
||||||
|
title="总得分"
|
||||||
|
:value="user.totalScore"
|
||||||
|
:value-style="{ color: '#f5222d' }"
|
||||||
|
suffix="分"
|
||||||
|
>
|
||||||
|
<template #prefix>
|
||||||
|
<TrophyOutlined />
|
||||||
|
</template>
|
||||||
|
</a-statistic>
|
||||||
|
</a-col>
|
||||||
|
<a-col :span="8">
|
||||||
|
<a-statistic
|
||||||
|
title="答题次数"
|
||||||
|
:value="user.answerCount"
|
||||||
|
:value-style="{ color: '#1890ff' }"
|
||||||
|
suffix="次"
|
||||||
|
>
|
||||||
|
<template #prefix>
|
||||||
|
<EditOutlined />
|
||||||
|
</template>
|
||||||
|
</a-statistic>
|
||||||
|
</a-col>
|
||||||
|
<a-col :span="8">
|
||||||
|
<a-statistic
|
||||||
|
title="平均得分"
|
||||||
|
:value="user.answerCount > 0 ? Math.round(user.totalScore / user.answerCount * 100) / 100 : 0"
|
||||||
|
:precision="2"
|
||||||
|
:value-style="{ color: '#52c41a' }"
|
||||||
|
suffix="分"
|
||||||
|
>
|
||||||
|
<template #prefix>
|
||||||
|
<BarChartOutlined />
|
||||||
|
</template>
|
||||||
|
</a-statistic>
|
||||||
|
</a-col>
|
||||||
|
</a-row>
|
||||||
|
|
||||||
|
<a-divider />
|
||||||
|
|
||||||
|
<div class="last-answer-info">
|
||||||
|
<div class="info-label">
|
||||||
|
<ClockCircleOutlined style="margin-right: 8px;" />
|
||||||
|
最后答题时间
|
||||||
|
</div>
|
||||||
|
<div class="info-value">
|
||||||
|
{{ user.lastAnswerTime ? formatDate(user.lastAnswerTime) : '暂无记录' }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</a-card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 加载状态 -->
|
||||||
|
<div v-else class="loading-state">
|
||||||
|
<a-spin size="large">
|
||||||
|
<div class="loading-content">加载用户信息中...</div>
|
||||||
|
</a-spin>
|
||||||
|
</div>
|
||||||
|
</a-modal>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue';
|
||||||
|
import {
|
||||||
|
BankOutlined,
|
||||||
|
TeamOutlined,
|
||||||
|
TrophyOutlined,
|
||||||
|
EditOutlined,
|
||||||
|
BarChartOutlined,
|
||||||
|
ClockCircleOutlined
|
||||||
|
} from '@ant-design/icons-vue';
|
||||||
|
import type { AppUser } from '@/apis/users';
|
||||||
|
import { format } from 'date-fns';
|
||||||
|
import { zhCN } from 'date-fns/locale';
|
||||||
|
|
||||||
|
// Props定义
|
||||||
|
interface Props {
|
||||||
|
visible: boolean;
|
||||||
|
user?: AppUser | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 事件定义
|
||||||
|
interface Emits {
|
||||||
|
'update:visible': [visible: boolean];
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<Props>();
|
||||||
|
const emit = defineEmits<Emits>();
|
||||||
|
|
||||||
|
// 计算属性
|
||||||
|
const visible = computed({
|
||||||
|
get: () => props.visible,
|
||||||
|
set: (value: boolean) => emit('update:visible', value)
|
||||||
|
});
|
||||||
|
|
||||||
|
// 关闭弹窗
|
||||||
|
const handleCancel = () => {
|
||||||
|
visible.value = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 格式化日期
|
||||||
|
const formatDate = (dateStr: string) => {
|
||||||
|
if (!dateStr) return '-';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const date = new Date(dateStr);
|
||||||
|
return format(date, 'yyyy-MM-dd HH:mm:ss', { locale: zhCN });
|
||||||
|
} catch (error) {
|
||||||
|
return '-';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 获取状态颜色
|
||||||
|
const getStatusColor = (status: string) => {
|
||||||
|
const statusMap: Record<string, string> = {
|
||||||
|
active: 'green',
|
||||||
|
disabled: 'red',
|
||||||
|
pending: 'orange'
|
||||||
|
};
|
||||||
|
return statusMap[status] || 'default';
|
||||||
|
};
|
||||||
|
|
||||||
|
// 获取状态文本
|
||||||
|
const getStatusText = (status: string) => {
|
||||||
|
const statusMap: Record<string, string> = {
|
||||||
|
active: '正常',
|
||||||
|
disabled: '已禁用',
|
||||||
|
pending: '待激活'
|
||||||
|
};
|
||||||
|
return statusMap[status] || '未知';
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.user-detail {
|
||||||
|
.detail-card {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
|
||||||
|
&:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.ant-card-head) {
|
||||||
|
background: #fafafa;
|
||||||
|
|
||||||
|
.ant-card-head-title {
|
||||||
|
font-weight: 600;
|
||||||
|
color: #262626;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar-section {
|
||||||
|
text-align: center;
|
||||||
|
|
||||||
|
.user-avatar {
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
color: #fff;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 32px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-badge {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.no-student {
|
||||||
|
padding: 40px 0;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.school-item,
|
||||||
|
.grade-class-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.last-answer-info {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 16px;
|
||||||
|
background: #f9f9f9;
|
||||||
|
border-radius: 6px;
|
||||||
|
border: 1px solid #e8e8e8;
|
||||||
|
|
||||||
|
.info-label {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
color: #8c8c8c;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-value {
|
||||||
|
font-weight: 500;
|
||||||
|
color: #262626;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-state {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
height: 300px;
|
||||||
|
|
||||||
|
.loading-content {
|
||||||
|
margin-top: 16px;
|
||||||
|
color: #8c8c8c;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 描述列表样式优化 */
|
||||||
|
:deep(.ant-descriptions) {
|
||||||
|
.ant-descriptions-item-label {
|
||||||
|
font-weight: 500;
|
||||||
|
background: #fafafa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ant-descriptions-item-content {
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 统计卡片样式 */
|
||||||
|
:deep(.ant-statistic) {
|
||||||
|
.ant-statistic-title {
|
||||||
|
font-size: 14px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ant-statistic-content {
|
||||||
|
.ant-statistic-content-value {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 响应式设计 */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
:deep(.ant-modal) {
|
||||||
|
margin: 16px;
|
||||||
|
max-width: calc(100vw - 32px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-detail {
|
||||||
|
.detail-card {
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.ant-descriptions) {
|
||||||
|
.ant-descriptions-item {
|
||||||
|
padding-bottom: 12px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.last-answer-info {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
363
src/views/users/components/UserList.vue
Normal file
363
src/views/users/components/UserList.vue
Normal file
@@ -0,0 +1,363 @@
|
|||||||
|
<template>
|
||||||
|
<div class="user-list">
|
||||||
|
<a-table
|
||||||
|
:columns="columns"
|
||||||
|
:data-source="dataSource"
|
||||||
|
:loading="loading"
|
||||||
|
:pagination="pagination"
|
||||||
|
row-key="id"
|
||||||
|
@change="handleTableChange"
|
||||||
|
:scroll="{ x: 1200 }"
|
||||||
|
>
|
||||||
|
<template #bodyCell="{ column, record }">
|
||||||
|
<template v-if="column.key === 'user'">
|
||||||
|
<div class="user-info">
|
||||||
|
<a-avatar :src="record.avatar" :size="40" class="user-avatar">
|
||||||
|
{{ record.nickname?.[0] || record.phone?.slice(-4) }}
|
||||||
|
</a-avatar>
|
||||||
|
<div class="user-details">
|
||||||
|
<div class="nickname">{{ record.nickname || '未设置昵称' }}</div>
|
||||||
|
<div class="phone">{{ record.phone }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template v-else-if="column.key === 'student'">
|
||||||
|
<div v-if="record.studentName" class="student-info">
|
||||||
|
<div class="student-name">{{ record.studentName }}</div>
|
||||||
|
<div class="seat-number" v-if="record.studentSeatNumber">
|
||||||
|
座位号: {{ record.studentSeatNumber }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span v-else class="no-student">未绑定学生</span>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template v-else-if="column.key === 'school'">
|
||||||
|
<div class="school-info">
|
||||||
|
<div class="school-name">{{ record.schoolName }}</div>
|
||||||
|
<div class="class-info">
|
||||||
|
{{ record.gradeName }} {{ record.className }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template v-else-if="column.key === 'stats'">
|
||||||
|
<div class="stats-info">
|
||||||
|
<div class="score">总分: <span class="score-value">{{ record.totalScore }}</span></div>
|
||||||
|
<div class="count">答题: {{ record.answerCount }}次</div>
|
||||||
|
<div class="last-time" v-if="record.lastAnswerTime">
|
||||||
|
最后答题: {{ formatTime(record.lastAnswerTime) }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template v-else-if="column.key === 'status'">
|
||||||
|
<a-tag :color="getStatusColor(record.status)">
|
||||||
|
{{ getStatusText(record.status) }}
|
||||||
|
</a-tag>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template v-else-if="column.key === 'action'">
|
||||||
|
<div class="action-buttons">
|
||||||
|
<a-button type="link" size="small" @click="$emit('viewDetail', record)">
|
||||||
|
<EyeOutlined />
|
||||||
|
查看详情
|
||||||
|
</a-button>
|
||||||
|
|
||||||
|
<a-button
|
||||||
|
v-if="record.studentName"
|
||||||
|
type="link"
|
||||||
|
size="small"
|
||||||
|
danger
|
||||||
|
@click="$emit('unbind', record)"
|
||||||
|
>
|
||||||
|
<DisconnectOutlined />
|
||||||
|
解绑
|
||||||
|
</a-button>
|
||||||
|
|
||||||
|
<a-button
|
||||||
|
type="link"
|
||||||
|
size="small"
|
||||||
|
:danger="record.status === 'active'"
|
||||||
|
@click="record.status === 'active' ? $emit('disable', record) : $emit('enable', record)"
|
||||||
|
>
|
||||||
|
<template v-if="record.status === 'active'">
|
||||||
|
<StopOutlined />
|
||||||
|
禁用
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<PlayCircleOutlined />
|
||||||
|
启用
|
||||||
|
</template>
|
||||||
|
</a-button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
</a-table>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue';
|
||||||
|
import {
|
||||||
|
EyeOutlined,
|
||||||
|
DisconnectOutlined,
|
||||||
|
StopOutlined,
|
||||||
|
PlayCircleOutlined
|
||||||
|
} from '@ant-design/icons-vue';
|
||||||
|
import type { AppUser } from '@/apis/users';
|
||||||
|
import type { TableColumnsType, TableProps } from 'ant-design-vue';
|
||||||
|
import { formatDistanceToNow } from 'date-fns';
|
||||||
|
import { zhCN } from 'date-fns/locale';
|
||||||
|
|
||||||
|
// Props定义
|
||||||
|
interface Props {
|
||||||
|
dataSource: AppUser[];
|
||||||
|
loading?: boolean;
|
||||||
|
pagination?: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 事件定义
|
||||||
|
interface Emits {
|
||||||
|
viewDetail: [user: AppUser];
|
||||||
|
unbind: [user: AppUser];
|
||||||
|
disable: [user: AppUser];
|
||||||
|
enable: [user: AppUser];
|
||||||
|
pageChange: [page: number, pageSize: number];
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
|
loading: false
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits<Emits>();
|
||||||
|
|
||||||
|
// 表格列配置
|
||||||
|
const columns = computed<TableColumnsType>(() => [
|
||||||
|
{
|
||||||
|
title: '用户信息',
|
||||||
|
dataIndex: 'user',
|
||||||
|
key: 'user',
|
||||||
|
width: 200
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '学生信息',
|
||||||
|
dataIndex: 'student',
|
||||||
|
key: 'student',
|
||||||
|
width: 150
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '学校班级',
|
||||||
|
dataIndex: 'school',
|
||||||
|
key: 'school',
|
||||||
|
width: 180
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '答题统计',
|
||||||
|
dataIndex: 'stats',
|
||||||
|
key: 'stats',
|
||||||
|
width: 160
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '状态',
|
||||||
|
dataIndex: 'status',
|
||||||
|
key: 'status',
|
||||||
|
width: 100
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '注册时间',
|
||||||
|
dataIndex: 'createTime',
|
||||||
|
key: 'createTime',
|
||||||
|
width: 120,
|
||||||
|
render: (text: string) => formatTime(text)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '操作',
|
||||||
|
key: 'action',
|
||||||
|
width: 200,
|
||||||
|
fixed: 'right'
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
|
||||||
|
// 处理表格变化
|
||||||
|
const handleTableChange: TableProps['onChange'] = (pag) => {
|
||||||
|
if (pag?.current && pag?.pageSize) {
|
||||||
|
emit('pageChange', pag.current, pag.pageSize);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 格式化时间
|
||||||
|
const formatTime = (timeStr: string) => {
|
||||||
|
if (!timeStr) return '-';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const date = new Date(timeStr);
|
||||||
|
return formatDistanceToNow(date, {
|
||||||
|
locale: zhCN,
|
||||||
|
addSuffix: true
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
return '-';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 获取状态颜色
|
||||||
|
const getStatusColor = (status: string) => {
|
||||||
|
const statusMap: Record<string, string> = {
|
||||||
|
active: 'green',
|
||||||
|
disabled: 'red',
|
||||||
|
pending: 'orange'
|
||||||
|
};
|
||||||
|
return statusMap[status] || 'default';
|
||||||
|
};
|
||||||
|
|
||||||
|
// 获取状态文本
|
||||||
|
const getStatusText = (status: string) => {
|
||||||
|
const statusMap: Record<string, string> = {
|
||||||
|
active: '正常',
|
||||||
|
disabled: '已禁用',
|
||||||
|
pending: '待激活'
|
||||||
|
};
|
||||||
|
return statusMap[status] || '未知';
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.user-list {
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
|
||||||
|
|
||||||
|
:deep(.ant-table) {
|
||||||
|
.ant-table-thead > tr > th {
|
||||||
|
background: #fafafa;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-info {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
|
||||||
|
.user-avatar {
|
||||||
|
flex-shrink: 0;
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
color: #fff;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-details {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
|
||||||
|
.nickname {
|
||||||
|
font-weight: 500;
|
||||||
|
color: #262626;
|
||||||
|
margin-bottom: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.phone {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #8c8c8c;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.student-info {
|
||||||
|
.student-name {
|
||||||
|
font-weight: 500;
|
||||||
|
color: #262626;
|
||||||
|
margin-bottom: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.seat-number {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #1890ff;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.no-student {
|
||||||
|
color: #8c8c8c;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.school-info {
|
||||||
|
.school-name {
|
||||||
|
font-weight: 500;
|
||||||
|
color: #262626;
|
||||||
|
margin-bottom: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.class-info {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #8c8c8c;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-info {
|
||||||
|
.score {
|
||||||
|
margin-bottom: 2px;
|
||||||
|
|
||||||
|
.score-value {
|
||||||
|
font-weight: 600;
|
||||||
|
color: #f5222d;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.count {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #1890ff;
|
||||||
|
margin-bottom: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.last-time {
|
||||||
|
font-size: 11px;
|
||||||
|
color: #8c8c8c;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-buttons {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 4px;
|
||||||
|
|
||||||
|
.ant-btn {
|
||||||
|
padding: 0 4px;
|
||||||
|
height: auto;
|
||||||
|
line-height: 1.5;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: rgba(24, 144, 255, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.ant-btn-dangerous:hover {
|
||||||
|
background: rgba(255, 77, 79, 0.08);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 响应式设计 */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.user-info {
|
||||||
|
.user-details .nickname {
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-details .phone {
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-buttons {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
|
||||||
|
.ant-btn {
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
23
src/vite-env.d.ts
vendored
Normal file
23
src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
|
|
||||||
|
|
||||||
|
declare interface ImportMetaEnv {
|
||||||
|
readonly VITE_APP_BASE_API: string;
|
||||||
|
readonly VITE_API_BASE_URL: string;
|
||||||
|
readonly VITE_NODE_ENV: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ImportMeta {
|
||||||
|
readonly env: ImportMetaEnv;
|
||||||
|
}
|
||||||
|
|
||||||
|
declare module '*.vue' {
|
||||||
|
import type {DefineComponent} from 'vue';
|
||||||
|
const component: DefineComponent<object, object, any>;
|
||||||
|
export default component;
|
||||||
|
}
|
||||||
|
|
||||||
|
declare module '*.svg' {
|
||||||
|
const content: any;
|
||||||
|
export default content;
|
||||||
|
}
|
||||||
24
tsconfig.app.json
Normal file
24
tsconfig.app.json
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
{
|
||||||
|
"extends": "@vue/tsconfig/tsconfig.dom.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||||
|
|
||||||
|
/* Linting */
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"erasableSyntaxOnly": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"noUncheckedSideEffectImports": true,
|
||||||
|
"baseUrl": "./",
|
||||||
|
"paths": {
|
||||||
|
"@": [
|
||||||
|
"src"
|
||||||
|
],
|
||||||
|
"@/*": [
|
||||||
|
"src/*"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"]
|
||||||
|
}
|
||||||
7
tsconfig.json
Normal file
7
tsconfig.json
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"files": [],
|
||||||
|
"references": [
|
||||||
|
{ "path": "./tsconfig.app.json" },
|
||||||
|
{ "path": "./tsconfig.node.json" }
|
||||||
|
]
|
||||||
|
}
|
||||||
25
tsconfig.node.json
Normal file
25
tsconfig.node.json
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||||
|
"target": "ES2023",
|
||||||
|
"lib": ["ES2023"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"skipLibCheck": true,
|
||||||
|
|
||||||
|
/* Bundler mode */
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"verbatimModuleSyntax": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"noEmit": true,
|
||||||
|
|
||||||
|
/* Linting */
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"erasableSyntaxOnly": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"noUncheckedSideEffectImports": true
|
||||||
|
},
|
||||||
|
"include": ["vite.config.ts"]
|
||||||
|
}
|
||||||
86
vite.config.ts
Normal file
86
vite.config.ts
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
import {defineConfig, loadEnv} from 'vite';
|
||||||
|
import vue from '@vitejs/plugin-vue';
|
||||||
|
import * as path from 'path';
|
||||||
|
import Components from 'unplugin-vue-components/vite';
|
||||||
|
import {AntDesignVueResolver} from 'unplugin-vue-components/resolvers';
|
||||||
|
|
||||||
|
|
||||||
|
export default defineConfig(({mode}: { mode: string }): object => {
|
||||||
|
const env: Record<string, string> = loadEnv(mode, process.cwd());
|
||||||
|
return {
|
||||||
|
publicDir: 'public',
|
||||||
|
base: '/',
|
||||||
|
resolve: {
|
||||||
|
//设置别名
|
||||||
|
alias: {
|
||||||
|
'@': path.resolve(__dirname, 'src')
|
||||||
|
}
|
||||||
|
},
|
||||||
|
worker: {
|
||||||
|
format: 'es'
|
||||||
|
},
|
||||||
|
plugins: [
|
||||||
|
vue(),
|
||||||
|
Components({
|
||||||
|
dts: true,
|
||||||
|
dirs: ['src/components', 'src/views'],
|
||||||
|
resolvers: [
|
||||||
|
AntDesignVueResolver({
|
||||||
|
importStyle: false,
|
||||||
|
resolveIcons: true
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
css: {
|
||||||
|
preprocessorOptions: {
|
||||||
|
scss: {
|
||||||
|
api: "modern-compiler",
|
||||||
|
javascriptEnabled: true,
|
||||||
|
additionalData: `@use "@/assets/styles/index.scss";`
|
||||||
|
},
|
||||||
|
less: {
|
||||||
|
javascriptEnabled: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
esbuild: {
|
||||||
|
drop: env.VITE_NODE_ENV === 'production' ? ['console', 'debugger'] : [],
|
||||||
|
},
|
||||||
|
build: {
|
||||||
|
outDir: "dist", // 指定输出路径
|
||||||
|
assetsDir: "assets", // 指定生成静态文件目录
|
||||||
|
assetsInlineLimit: "4096", // 小于此阈值的导入或引用资源将内联为 base64 编码
|
||||||
|
cssCodeSplit: true, // 启用 CSS 代码拆分
|
||||||
|
sourcemap: false, // 构建后是否生成 source map 文件
|
||||||
|
minify: "esbuild", // 指定使用哪种混淆器
|
||||||
|
write: true, // 启用将构建后的文件写入磁盘
|
||||||
|
emptyOutDir: true, // 构建时清空该目录
|
||||||
|
brotliSize: true, // 启用 brotli 压缩大小报告
|
||||||
|
chunkSizeWarningLimit: 15000, // chunk 大小警告的限制
|
||||||
|
watch: null, // 设置为 {} 则会启用 rollup 的监听器
|
||||||
|
rollupOptions: { // 自定义底层的 Rollup 打包配置
|
||||||
|
output: {
|
||||||
|
format: 'es',
|
||||||
|
chunkFileNames: 'js/[name]-[hash].js', // 引入文件名的名称
|
||||||
|
entryFileNames: 'js/[name]-[hash].js', // 包的入口文件名称
|
||||||
|
assetFileNames: '[ext]/[name]-[hash].[ext]',// 资源文件像 字体,图片等
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
server: {
|
||||||
|
proxy: env.VITE_NODE_ENV === 'development' ? {} : {
|
||||||
|
[env.VITE_APP_BASE_API]: {
|
||||||
|
//后端接口的baseurl
|
||||||
|
target: env.VITE_API_BASE_URL,
|
||||||
|
//是否允许跨域
|
||||||
|
changeOrigin: true,
|
||||||
|
rewrite: (path: string) => path.replace(RegExp(`^${env.VITE_APP_BASE_API}`), ""),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
)
|
||||||
|
;
|
||||||
Reference in New Issue
Block a user