🎉 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