🎉 initial commit

This commit is contained in:
2025-09-15 23:55:27 +08:00
commit ea42900d01
82 changed files with 21972 additions and 0 deletions

0
.env Normal file
View File

10
.env.development Normal file
View File

@@ -0,0 +1,10 @@
# 开发环境配置
VITE_NODE_ENV='development'
# 开发环境
VITE_APP_BASE_API='/api'
# 网络请求公用地址
VITE_API_BASE_URL=''

10
.env.production Normal file
View File

@@ -0,0 +1,10 @@
# 开发环境配置
VITE_NODE_ENV='production'
# 开发环境
VITE_APP_BASE_API='/api'
# 网络请求公用地址
VITE_API_BASE_URL=''

24
.gitignore vendored Normal file
View 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
View File

@@ -0,0 +1,3 @@
{
"recommendations": ["Vue.volar"]
}

5
README.md Normal file
View 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
View 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
View 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
View 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

File diff suppressed because it is too large Load Diff

43
package.json Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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`);
};

View File

@@ -0,0 +1 @@

1
src/assets/vue.svg Normal file
View 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

View 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>

View 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>

View 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>

View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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']
}
});

View 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
View 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);
},
}),
});

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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">支持 JPGPNGGIF 格式文件大小不超过 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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
View 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
View 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
View File

@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

25
tsconfig.node.json Normal file
View 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
View 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}`), ""),
},
},
},
};
}
)
;