修改布局,修复BUG
Some checks failed
CI / Test (ubuntu-latest) (push) Has been cancelled
CI / Test (windows-latest) (push) Has been cancelled
CI / Lint (ubuntu-latest) (push) Has been cancelled
CI / Lint (windows-latest) (push) Has been cancelled
CI / Check (ubuntu-latest) (push) Has been cancelled
CI / Check (windows-latest) (push) Has been cancelled
CI / CI OK (push) Has been cancelled
CodeQL / Analyze (javascript-typescript) (push) Has been cancelled
Deploy Website on push / Deploy Push Playground Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Docs Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Antd Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Element Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Naive Ftp (push) Has been cancelled
Deploy Website on push / Rerun on failure (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
Some checks failed
CI / Test (ubuntu-latest) (push) Has been cancelled
CI / Test (windows-latest) (push) Has been cancelled
CI / Lint (ubuntu-latest) (push) Has been cancelled
CI / Lint (windows-latest) (push) Has been cancelled
CI / Check (ubuntu-latest) (push) Has been cancelled
CI / Check (windows-latest) (push) Has been cancelled
CI / CI OK (push) Has been cancelled
CodeQL / Analyze (javascript-typescript) (push) Has been cancelled
Deploy Website on push / Deploy Push Playground Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Docs Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Antd Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Element Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Naive Ftp (push) Has been cancelled
Deploy Website on push / Rerun on failure (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
This commit is contained in:
36
API.md
36
API.md
@@ -188,6 +188,42 @@
|
|||||||
- **参数**:
|
- **参数**:
|
||||||
- `file` (file, 必填): 图片文件
|
- `file` (file, 必填): 图片文件
|
||||||
|
|
||||||
|
#### 4.15 获取教室选座列表
|
||||||
|
- **URL**: `GET /api/admin/classroom/getSeatList`
|
||||||
|
- **说明**: 获取教室的选座列表,以座位号排序
|
||||||
|
- **需要登录**: 是
|
||||||
|
- **参数**:
|
||||||
|
- `id` (int, 必填): 教室ID
|
||||||
|
- **返回数据**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 200,
|
||||||
|
"message": "获取成功",
|
||||||
|
"data": [
|
||||||
|
{
|
||||||
|
"seat_number": "1",
|
||||||
|
"is_selected": 1,
|
||||||
|
"student_name": "张三",
|
||||||
|
"student_mobile": "13800138000",
|
||||||
|
"select_time": "2024-03-20 10:00:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"seat_number": "2",
|
||||||
|
"is_selected": 0,
|
||||||
|
"student_name": "",
|
||||||
|
"student_mobile": "",
|
||||||
|
"select_time": ""
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
- **字段说明**:
|
||||||
|
- `seat_number` (string): 座位号
|
||||||
|
- `is_selected` (int): 是否选座(1已选,0未选)
|
||||||
|
- `student_name` (string): 学员姓名(未选座时为空)
|
||||||
|
- `student_mobile` (string): 学员手机号(未选座时为空)
|
||||||
|
- `select_time` (string): 选座时间(未选座时为空)
|
||||||
|
|
||||||
### 5. 预订管理
|
### 5. 预订管理
|
||||||
|
|
||||||
#### 5.1 获取预订列表
|
#### 5.1 获取预订列表
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# 生产环境配置
|
# 生产环境配置
|
||||||
VITE_BASE=/super/
|
VITE_BASE=/super/
|
||||||
VITE_GLOB_API_URL=https://xuanzuo.dhdjy.com
|
VITE_GLOB_API_URL=http://xz.dhdjy.com
|
||||||
VITE_ROUTER_HISTORY=history
|
VITE_ROUTER_HISTORY=history
|
||||||
VITE_COMPRESS=gzip
|
VITE_COMPRESS=gzip
|
||||||
VITE_PWA=false
|
VITE_PWA=false
|
||||||
|
|||||||
@@ -22,10 +22,13 @@ export namespace ClassroomApi {
|
|||||||
export interface ClassroomLayoutCell {
|
export interface ClassroomLayoutCell {
|
||||||
col: number;
|
col: number;
|
||||||
row: number;
|
row: number;
|
||||||
type: 'empty' | 'pillar' | 'aisle' | 'seat' | 'door';
|
type: 'empty' | 'pillar' | 'aisle' | 'seat' | 'door' | 'projector';
|
||||||
number?: string;
|
number?: string;
|
||||||
status?: number;
|
status?: number; // 座位状态:1=可用, 0=禁坐
|
||||||
name?: string;
|
name?: string;
|
||||||
|
colspan?: number; // 合并列数(默认1)
|
||||||
|
rowspan?: number; // 合并行数(默认1)
|
||||||
|
merged?: boolean; // 是否为被合并的单元格(用于标记被主单元格合并的单元格)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 教室座位布局 */
|
/** 教室座位布局 */
|
||||||
@@ -107,6 +110,20 @@ export namespace ClassroomApi {
|
|||||||
export interface CancelBookingAutoParams {
|
export interface CancelBookingAutoParams {
|
||||||
ids: number[];
|
ids: number[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 获取教室选座列表参数 */
|
||||||
|
export interface GetSeatListParams {
|
||||||
|
id: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 座位信息 */
|
||||||
|
export interface SeatInfo {
|
||||||
|
seat_number: string;
|
||||||
|
is_selected: number; // 1已选,0未选
|
||||||
|
student_name: string;
|
||||||
|
student_mobile: string;
|
||||||
|
select_time: string;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -235,3 +252,15 @@ export async function uploadClassroomImageApi(file: File) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取教室选座列表
|
||||||
|
* GET /api/admin/classroom/getSeatList
|
||||||
|
*/
|
||||||
|
export async function getClassroomSeatListApi(params: ClassroomApi.GetSeatListParams) {
|
||||||
|
// 使用 responseReturn: 'body' 获取完整响应体
|
||||||
|
return requestClient.get<any>('/api/admin/classroom/getSeatList', {
|
||||||
|
params,
|
||||||
|
responseReturn: 'body',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,17 @@
|
|||||||
import { requestClient } from '#/api/request';
|
import { requestClient } from '#/api/request';
|
||||||
|
|
||||||
export namespace IndexApi {
|
export namespace IndexApi {
|
||||||
|
/** 统计数据 */
|
||||||
|
export interface Statistics {
|
||||||
|
school_count?: number;
|
||||||
|
classroom_count?: number;
|
||||||
|
class_count?: number;
|
||||||
|
student_count?: number;
|
||||||
|
teacher_count?: number;
|
||||||
|
booking_count?: number;
|
||||||
|
selected_seat_count?: number;
|
||||||
|
}
|
||||||
|
|
||||||
/** 系统信息 */
|
/** 系统信息 */
|
||||||
export interface SystemInfo {
|
export interface SystemInfo {
|
||||||
os?: string;
|
os?: string;
|
||||||
@@ -14,6 +25,7 @@ export namespace IndexApi {
|
|||||||
disk_usage?: string;
|
disk_usage?: string;
|
||||||
runtime_path?: string;
|
runtime_path?: string;
|
||||||
framework_version?: string;
|
framework_version?: string;
|
||||||
|
statistics?: Statistics;
|
||||||
[key: string]: any;
|
[key: string]: any;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -56,10 +56,12 @@ export async function saveSettingApi(data: SettingApi.SaveParams) {
|
|||||||
export async function uploadSettingImageApi(file: File) {
|
export async function uploadSettingImageApi(file: File) {
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append('file', file);
|
formData.append('file', file);
|
||||||
return requestClient.post<string>('/api/admin/setting/upload', formData, {
|
// 使用 responseReturn: 'body' 获取完整响应体
|
||||||
|
return requestClient.post<any>('/api/admin/setting/upload', formData, {
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'multipart/form-data',
|
'Content-Type': 'multipart/form-data',
|
||||||
},
|
},
|
||||||
|
responseReturn: 'body',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -54,7 +54,6 @@ async function loadProfileInfo() {
|
|||||||
try {
|
try {
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
const res = await getProfileInfoApi();
|
const res = await getProfileInfoApi();
|
||||||
console.log('获取个人信息成功:', res);
|
|
||||||
|
|
||||||
// 检查返回的数据
|
// 检查返回的数据
|
||||||
if (!res) {
|
if (!res) {
|
||||||
@@ -80,12 +79,6 @@ async function loadProfileInfo() {
|
|||||||
fileList.value = [];
|
fileList.value = [];
|
||||||
}
|
}
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error('获取个人信息失败:', error);
|
|
||||||
console.error('错误详情:', {
|
|
||||||
message: error?.message,
|
|
||||||
response: error?.response,
|
|
||||||
data: error?.response?.data,
|
|
||||||
});
|
|
||||||
const errorMessage = error?.response?.data?.message || error?.message || '获取个人信息失败';
|
const errorMessage = error?.response?.data?.message || error?.message || '获取个人信息失败';
|
||||||
message.error(errorMessage);
|
message.error(errorMessage);
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -4,25 +4,14 @@ import { $t } from '#/locales';
|
|||||||
|
|
||||||
const routes: RouteRecordRaw[] = [
|
const routes: RouteRecordRaw[] = [
|
||||||
{
|
{
|
||||||
|
name: 'BookingList',
|
||||||
|
path: '/booking/list',
|
||||||
|
component: () => import('#/views/booking/list.vue'),
|
||||||
meta: {
|
meta: {
|
||||||
icon: 'lucide:calendar-check',
|
icon: 'lucide:calendar-check',
|
||||||
order: 2,
|
order: 2,
|
||||||
title: $t('page.booking.title'),
|
title: $t('page.booking.title'),
|
||||||
},
|
},
|
||||||
name: 'Booking',
|
|
||||||
path: '/booking',
|
|
||||||
redirect: '/booking/list',
|
|
||||||
children: [
|
|
||||||
{
|
|
||||||
name: 'BookingList',
|
|
||||||
path: 'list',
|
|
||||||
component: () => import('#/views/booking/list.vue'),
|
|
||||||
meta: {
|
|
||||||
icon: 'lucide:list',
|
|
||||||
title: $t('page.booking.list'),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -4,34 +4,23 @@ import { $t } from '#/locales';
|
|||||||
|
|
||||||
const routes: RouteRecordRaw[] = [
|
const routes: RouteRecordRaw[] = [
|
||||||
{
|
{
|
||||||
|
name: 'ClassList',
|
||||||
|
path: '/class/list',
|
||||||
|
component: () => import('#/views/class/list.vue'),
|
||||||
meta: {
|
meta: {
|
||||||
icon: 'lucide:graduation-cap',
|
icon: 'lucide:graduation-cap',
|
||||||
order: 4,
|
order: 4,
|
||||||
title: $t('page.class.title'),
|
title: $t('page.class.title'),
|
||||||
},
|
},
|
||||||
name: 'Class',
|
},
|
||||||
path: '/class',
|
{
|
||||||
redirect: '/class/list',
|
name: 'ClassDetail',
|
||||||
children: [
|
path: '/class/detail/:id?',
|
||||||
{
|
component: () => import('#/views/class/detail.vue'),
|
||||||
name: 'ClassList',
|
meta: {
|
||||||
path: 'list',
|
hideInMenu: true,
|
||||||
component: () => import('#/views/class/list.vue'),
|
title: $t('page.class.detail'),
|
||||||
meta: {
|
},
|
||||||
icon: 'lucide:list',
|
|
||||||
title: $t('page.class.list'),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'ClassDetail',
|
|
||||||
path: 'detail/:id?',
|
|
||||||
component: () => import('#/views/class/detail.vue'),
|
|
||||||
meta: {
|
|
||||||
hideInMenu: true,
|
|
||||||
title: $t('page.class.detail'),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -4,34 +4,23 @@ import { $t } from '#/locales';
|
|||||||
|
|
||||||
const routes: RouteRecordRaw[] = [
|
const routes: RouteRecordRaw[] = [
|
||||||
{
|
{
|
||||||
|
name: 'ClassroomList',
|
||||||
|
path: '/classroom/list',
|
||||||
|
component: () => import('#/views/classroom/list.vue'),
|
||||||
meta: {
|
meta: {
|
||||||
icon: 'lucide:school',
|
icon: 'lucide:school',
|
||||||
order: 1,
|
order: 1,
|
||||||
title: $t('page.classroom.title'),
|
title: $t('page.classroom.title'),
|
||||||
},
|
},
|
||||||
name: 'Classroom',
|
},
|
||||||
path: '/classroom',
|
{
|
||||||
redirect: '/classroom/list',
|
name: 'ClassroomDetail',
|
||||||
children: [
|
path: '/classroom/detail/:id?',
|
||||||
{
|
component: () => import('#/views/classroom/detail.vue'),
|
||||||
name: 'ClassroomList',
|
meta: {
|
||||||
path: 'list',
|
hideInMenu: true,
|
||||||
component: () => import('#/views/classroom/list.vue'),
|
title: $t('page.classroom.detail'),
|
||||||
meta: {
|
},
|
||||||
icon: 'lucide:list',
|
|
||||||
title: $t('page.classroom.list'),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'ClassroomDetail',
|
|
||||||
path: 'detail/:id?',
|
|
||||||
component: () => import('#/views/classroom/detail.vue'),
|
|
||||||
meta: {
|
|
||||||
hideInMenu: true,
|
|
||||||
title: $t('page.classroom.detail'),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -4,26 +4,15 @@ import { $t } from '#/locales';
|
|||||||
|
|
||||||
const routes: RouteRecordRaw[] = [
|
const routes: RouteRecordRaw[] = [
|
||||||
{
|
{
|
||||||
|
name: 'Analytics',
|
||||||
|
path: '/dashboard/analytics',
|
||||||
|
component: () => import('#/views/dashboard/index.vue'),
|
||||||
meta: {
|
meta: {
|
||||||
icon: 'lucide:layout-dashboard',
|
affixTab: true,
|
||||||
|
icon: 'lucide:area-chart',
|
||||||
order: -1,
|
order: -1,
|
||||||
title: $t('page.dashboard.title'),
|
title: $t('page.dashboard.analytics'),
|
||||||
},
|
},
|
||||||
name: 'Dashboard',
|
|
||||||
path: '/dashboard',
|
|
||||||
redirect: '/dashboard/analytics',
|
|
||||||
children: [
|
|
||||||
{
|
|
||||||
name: 'Analytics',
|
|
||||||
path: 'analytics',
|
|
||||||
component: () => import('#/views/dashboard/index.vue'),
|
|
||||||
meta: {
|
|
||||||
affixTab: true,
|
|
||||||
icon: 'lucide:area-chart',
|
|
||||||
title: $t('page.dashboard.analytics'),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -4,35 +4,24 @@ import { $t } from '#/locales';
|
|||||||
|
|
||||||
const routes: RouteRecordRaw[] = [
|
const routes: RouteRecordRaw[] = [
|
||||||
{
|
{
|
||||||
|
name: 'SchoolList',
|
||||||
|
path: '/school/list',
|
||||||
|
component: () => import('#/views/school/list.vue'),
|
||||||
meta: {
|
meta: {
|
||||||
icon: 'lucide:building-2',
|
icon: 'lucide:building-2',
|
||||||
order: 7,
|
order: 7,
|
||||||
|
authority: ['super_admin'], // 仅超级管理员可见
|
||||||
title: $t('page.school.title'),
|
title: $t('page.school.title'),
|
||||||
roles: ['super_admin'], // 仅超级管理员可见
|
|
||||||
},
|
},
|
||||||
name: 'School',
|
},
|
||||||
path: '/school',
|
{
|
||||||
redirect: '/school/list',
|
name: 'SchoolDetail',
|
||||||
children: [
|
path: '/school/detail/:id?',
|
||||||
{
|
component: () => import('#/views/school/detail.vue'),
|
||||||
name: 'SchoolList',
|
meta: {
|
||||||
path: 'list',
|
hideInMenu: true,
|
||||||
component: () => import('#/views/school/list.vue'),
|
title: $t('page.school.detail'),
|
||||||
meta: {
|
},
|
||||||
icon: 'lucide:list',
|
|
||||||
title: $t('page.school.list'),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'SchoolDetail',
|
|
||||||
path: 'detail/:id?',
|
|
||||||
component: () => import('#/views/school/detail.vue'),
|
|
||||||
meta: {
|
|
||||||
hideInMenu: true,
|
|
||||||
title: $t('page.school.detail'),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -4,26 +4,15 @@ import { $t } from '#/locales';
|
|||||||
|
|
||||||
const routes: RouteRecordRaw[] = [
|
const routes: RouteRecordRaw[] = [
|
||||||
{
|
{
|
||||||
|
name: 'SettingIndex',
|
||||||
|
path: '/setting/index',
|
||||||
|
component: () => import('#/views/setting/index.vue'),
|
||||||
meta: {
|
meta: {
|
||||||
icon: 'lucide:settings',
|
icon: 'lucide:settings',
|
||||||
order: 99,
|
order: 99,
|
||||||
|
authority: ['super_admin'], // 仅超级管理员可见
|
||||||
title: $t('page.setting.title'),
|
title: $t('page.setting.title'),
|
||||||
roles: ['super_admin'], // 仅超级管理员可见
|
|
||||||
},
|
},
|
||||||
name: 'Setting',
|
|
||||||
path: '/setting',
|
|
||||||
redirect: '/setting/index',
|
|
||||||
children: [
|
|
||||||
{
|
|
||||||
name: 'SettingIndex',
|
|
||||||
path: 'index',
|
|
||||||
component: () => import('#/views/setting/index.vue'),
|
|
||||||
meta: {
|
|
||||||
icon: 'lucide:settings',
|
|
||||||
title: $t('page.setting.index'),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -4,34 +4,23 @@ import { $t } from '#/locales';
|
|||||||
|
|
||||||
const routes: RouteRecordRaw[] = [
|
const routes: RouteRecordRaw[] = [
|
||||||
{
|
{
|
||||||
|
name: 'StudentList',
|
||||||
|
path: '/student/list',
|
||||||
|
component: () => import('#/views/student/list.vue'),
|
||||||
meta: {
|
meta: {
|
||||||
icon: 'lucide:users',
|
icon: 'lucide:users',
|
||||||
order: 3,
|
order: 3,
|
||||||
title: $t('page.student.title'),
|
title: $t('page.student.title'),
|
||||||
},
|
},
|
||||||
name: 'Student',
|
},
|
||||||
path: '/student',
|
{
|
||||||
redirect: '/student/list',
|
name: 'StudentDetail',
|
||||||
children: [
|
path: '/student/detail/:id?',
|
||||||
{
|
component: () => import('#/views/student/detail.vue'),
|
||||||
name: 'StudentList',
|
meta: {
|
||||||
path: 'list',
|
hideInMenu: true,
|
||||||
component: () => import('#/views/student/list.vue'),
|
title: $t('page.student.detail'),
|
||||||
meta: {
|
},
|
||||||
icon: 'lucide:list',
|
|
||||||
title: $t('page.student.list'),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'StudentDetail',
|
|
||||||
path: 'detail/:id?',
|
|
||||||
component: () => import('#/views/student/detail.vue'),
|
|
||||||
meta: {
|
|
||||||
hideInMenu: true,
|
|
||||||
title: $t('page.student.detail'),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -4,34 +4,23 @@ import { $t } from '#/locales';
|
|||||||
|
|
||||||
const routes: RouteRecordRaw[] = [
|
const routes: RouteRecordRaw[] = [
|
||||||
{
|
{
|
||||||
|
name: 'TeacherList',
|
||||||
|
path: '/teacher/list',
|
||||||
|
component: () => import('#/views/teacher/list.vue'),
|
||||||
meta: {
|
meta: {
|
||||||
icon: 'lucide:user-check',
|
icon: 'lucide:user-check',
|
||||||
order: 5,
|
order: 5,
|
||||||
title: $t('page.teacher.title'),
|
title: $t('page.teacher.title'),
|
||||||
},
|
},
|
||||||
name: 'Teacher',
|
},
|
||||||
path: '/teacher',
|
{
|
||||||
redirect: '/teacher/list',
|
name: 'TeacherDetail',
|
||||||
children: [
|
path: '/teacher/detail/:id?',
|
||||||
{
|
component: () => import('#/views/teacher/detail.vue'),
|
||||||
name: 'TeacherList',
|
meta: {
|
||||||
path: 'list',
|
hideInMenu: true,
|
||||||
component: () => import('#/views/teacher/list.vue'),
|
title: $t('page.teacher.detail'),
|
||||||
meta: {
|
},
|
||||||
icon: 'lucide:list',
|
|
||||||
title: $t('page.teacher.list'),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'TeacherDetail',
|
|
||||||
path: 'detail/:id?',
|
|
||||||
component: () => import('#/views/teacher/detail.vue'),
|
|
||||||
meta: {
|
|
||||||
hideInMenu: true,
|
|
||||||
title: $t('page.teacher.detail'),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import { resetAllStores, useAccessStore, useUserStore } from '@vben/stores';
|
|||||||
import { notification } from 'ant-design-vue';
|
import { notification } from 'ant-design-vue';
|
||||||
import { defineStore } from 'pinia';
|
import { defineStore } from 'pinia';
|
||||||
|
|
||||||
import { getAccessCodesApi, getUserInfoApi, loginApi, logoutApi } from '#/api';
|
import { getUserInfoApi, loginApi, logoutApi } from '#/api';
|
||||||
import { $t } from '#/locales';
|
import { $t } from '#/locales';
|
||||||
|
|
||||||
export const useAuthStore = defineStore('auth', () => {
|
export const useAuthStore = defineStore('auth', () => {
|
||||||
@@ -44,24 +44,14 @@ export const useAuthStore = defineStore('auth', () => {
|
|||||||
|
|
||||||
// 获取用户信息并存储到 accessStore 中
|
// 获取用户信息并存储到 accessStore 中
|
||||||
try {
|
try {
|
||||||
const [fetchUserInfoResult, accessCodesResult] = await Promise.allSettled([
|
userInfo = await fetchUserInfo();
|
||||||
fetchUserInfo(),
|
|
||||||
getAccessCodesApi().catch(() => []), // 如果接口不存在,返回空数组
|
|
||||||
]);
|
|
||||||
|
|
||||||
userInfo = fetchUserInfoResult.status === 'fulfilled'
|
|
||||||
? fetchUserInfoResult.value
|
|
||||||
: null;
|
|
||||||
|
|
||||||
if (userInfo) {
|
if (userInfo) {
|
||||||
userStore.setUserInfo(userInfo);
|
userStore.setUserInfo(userInfo);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (accessCodesResult.status === 'fulfilled') {
|
// 设置权限码为空数组(不使用权限码接口)
|
||||||
accessStore.setAccessCodes(accessCodesResult.value);
|
accessStore.setAccessCodes([]);
|
||||||
} else {
|
|
||||||
accessStore.setAccessCodes([]);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 重置路由检查状态,让路由守卫重新生成路由
|
// 重置路由检查状态,让路由守卫重新生成路由
|
||||||
accessStore.setIsAccessChecked(false);
|
accessStore.setIsAccessChecked(false);
|
||||||
@@ -132,7 +122,28 @@ export const useAuthStore = defineStore('auth', () => {
|
|||||||
|
|
||||||
async function fetchUserInfo() {
|
async function fetchUserInfo() {
|
||||||
let userInfo: null | UserInfo = null;
|
let userInfo: null | UserInfo = null;
|
||||||
userInfo = await getUserInfoApi();
|
const rawUserInfo = await getUserInfoApi();
|
||||||
|
|
||||||
|
// 将后端的 role 字段转换为 roles 数组
|
||||||
|
if (rawUserInfo) {
|
||||||
|
const role = (rawUserInfo as any).role;
|
||||||
|
if (role) {
|
||||||
|
// 将 role: 'super' 转换为 roles: ['super_admin']
|
||||||
|
// 将 role: 'branch' 转换为 roles: ['branch']
|
||||||
|
const roleMap: Record<string, string> = {
|
||||||
|
super: 'super_admin',
|
||||||
|
branch: 'branch',
|
||||||
|
};
|
||||||
|
const mappedRole = roleMap[role] || role;
|
||||||
|
userInfo = {
|
||||||
|
...rawUserInfo,
|
||||||
|
roles: [mappedRole],
|
||||||
|
} as UserInfo;
|
||||||
|
} else {
|
||||||
|
userInfo = rawUserInfo;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
userStore.setUserInfo(userInfo);
|
userStore.setUserInfo(userInfo);
|
||||||
return userInfo;
|
return userInfo;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -55,8 +55,7 @@ const formSchema = computed((): VbenFormSchema[] => {
|
|||||||
* @param values 登录表单数据
|
* @param values 登录表单数据
|
||||||
*/
|
*/
|
||||||
async function handleLogin(values: Recordable<any>) {
|
async function handleLogin(values: Recordable<any>) {
|
||||||
// eslint-disable-next-line no-console
|
// 处理验证码登录
|
||||||
console.log(values);
|
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -29,8 +29,7 @@ const formSchema = computed((): VbenFormSchema[] => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
function handleSubmit(value: Recordable<any>) {
|
function handleSubmit(value: Recordable<any>) {
|
||||||
// eslint-disable-next-line no-console
|
// 处理重置密码提交
|
||||||
console.log('reset email:', value);
|
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -82,8 +82,7 @@ const formSchema = computed((): VbenFormSchema[] => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
function handleSubmit(value: Recordable<any>) {
|
function handleSubmit(value: Recordable<any>) {
|
||||||
// eslint-disable-next-line no-console
|
// 处理注册提交
|
||||||
console.log('register submit:', value);
|
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ import {
|
|||||||
type SchoolApi,
|
type SchoolApi,
|
||||||
type TeacherApi
|
type TeacherApi
|
||||||
} from '#/api';
|
} from '#/api';
|
||||||
import SeatLayoutEditor from '#/components/classroom/SeatLayoutEditor.vue';
|
|
||||||
|
|
||||||
defineOptions({ name: 'ClassroomDetail' });
|
defineOptions({ name: 'ClassroomDetail' });
|
||||||
|
|
||||||
@@ -37,7 +36,6 @@ const formData = ref<ClassroomApi.SaveParams>({
|
|||||||
description: '',
|
description: '',
|
||||||
status: 1,
|
status: 1,
|
||||||
});
|
});
|
||||||
const layout = ref<ClassroomApi.ClassroomLayout | null>(null);
|
|
||||||
|
|
||||||
const isEdit = computed(() => !!route.params.id);
|
const isEdit = computed(() => !!route.params.id);
|
||||||
const classroomId = computed(() => (isEdit.value ? Number(route.params.id) : 0));
|
const classroomId = computed(() => (isEdit.value ? Number(route.params.id) : 0));
|
||||||
@@ -96,10 +94,6 @@ const fetchDetail = async () => {
|
|||||||
description: data.description || '',
|
description: data.description || '',
|
||||||
status: data.status !== undefined ? data.status : 1,
|
status: data.status !== undefined ? data.status : 1,
|
||||||
};
|
};
|
||||||
// 加载布局数据
|
|
||||||
if (data.layout) {
|
|
||||||
layout.value = data.layout;
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
message.error(res?.message || res?.msg || '获取教室详情失败');
|
message.error(res?.message || res?.msg || '获取教室详情失败');
|
||||||
}
|
}
|
||||||
@@ -147,15 +141,6 @@ const fetchTeacherList = async () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleLayoutSaved = (newLayout: ClassroomApi.ClassroomLayout) => {
|
|
||||||
layout.value = newLayout;
|
|
||||||
// 更新容量(座位数量)
|
|
||||||
const seatCount = newLayout.cells.filter((cell) => cell.type === 'seat').length;
|
|
||||||
if (seatCount > 0) {
|
|
||||||
formData.value.capacity = seatCount;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
fetchSchoolList();
|
fetchSchoolList();
|
||||||
fetchTeacherList();
|
fetchTeacherList();
|
||||||
@@ -275,13 +260,6 @@ onMounted(() => {
|
|||||||
</Form.Item>
|
</Form.Item>
|
||||||
</Form>
|
</Form>
|
||||||
</Tabs.TabPane>
|
</Tabs.TabPane>
|
||||||
<Tabs.TabPane v-if="isEdit" key="layout" tab="座位布局">
|
|
||||||
<SeatLayoutEditor
|
|
||||||
:classroom-id="classroomId"
|
|
||||||
:layout="layout"
|
|
||||||
@saved="handleLayoutSaved"
|
|
||||||
/>
|
|
||||||
</Tabs.TabPane>
|
|
||||||
</Tabs>
|
</Tabs>
|
||||||
</Card>
|
</Card>
|
||||||
</Page>
|
</Page>
|
||||||
|
|||||||
@@ -2,14 +2,17 @@
|
|||||||
import { ref, reactive, onMounted } from 'vue';
|
import { ref, reactive, onMounted } from 'vue';
|
||||||
import { useRouter } from 'vue-router';
|
import { useRouter } from 'vue-router';
|
||||||
import { Page } from '@vben/common-ui';
|
import { Page } from '@vben/common-ui';
|
||||||
import { Button, Card, Table, Space, message, Modal, Input, Select } from 'ant-design-vue';
|
import { Button, Card, Table, Space, message, Modal, Input, Select, Tag } from 'ant-design-vue';
|
||||||
import {
|
import {
|
||||||
getClassroomListApi,
|
getClassroomListApi,
|
||||||
deleteClassroomApi,
|
deleteClassroomApi,
|
||||||
batchDeleteClassroomApi,
|
batchDeleteClassroomApi,
|
||||||
|
getClassroomSeatListApi,
|
||||||
|
getClassroomDetailApi,
|
||||||
type ClassroomApi
|
type ClassroomApi
|
||||||
} from '#/api';
|
} from '#/api';
|
||||||
import { $t } from '#/locales';
|
import SeatLayoutEditor from '#/components/classroom/SeatLayoutEditor.vue';
|
||||||
|
|
||||||
|
|
||||||
defineOptions({ name: 'ClassroomList' });
|
defineOptions({ name: 'ClassroomList' });
|
||||||
|
|
||||||
@@ -30,6 +33,18 @@ const searchForm = reactive({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const selectedRowKeys = ref<number[]>([]);
|
const selectedRowKeys = ref<number[]>([]);
|
||||||
|
const seatListVisible = ref(false);
|
||||||
|
const seatListLoading = ref(false);
|
||||||
|
const seatListData = ref<ClassroomApi.SeatInfo[]>([]);
|
||||||
|
const currentClassroomName = ref('');
|
||||||
|
const seatListPagination = reactive({
|
||||||
|
current: 1,
|
||||||
|
pageSize: 10,
|
||||||
|
});
|
||||||
|
const layoutVisible = ref(false);
|
||||||
|
const layoutLoading = ref(false);
|
||||||
|
const currentClassroomId = ref<number | null>(null);
|
||||||
|
const currentLayout = ref<ClassroomApi.ClassroomLayout | null>(null);
|
||||||
|
|
||||||
const columns = [
|
const columns = [
|
||||||
{
|
{
|
||||||
@@ -88,7 +103,7 @@ const columns = [
|
|||||||
{
|
{
|
||||||
title: '操作',
|
title: '操作',
|
||||||
key: 'action',
|
key: 'action',
|
||||||
width: 200,
|
width: 300,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -178,9 +193,156 @@ const handleBatchDelete = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleTableChange = (pag: any) => {
|
const handleTableChange = (pag: any) => {
|
||||||
pagination.current = pag.current;
|
if (pag) {
|
||||||
pagination.pageSize = pag.pageSize;
|
pagination.current = pag.current || pagination.current;
|
||||||
fetchData();
|
pagination.pageSize = pag.pageSize || pagination.pageSize;
|
||||||
|
fetchData();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 处理行选择变化
|
||||||
|
const handleRowSelectionChange = (keys: (string | number)[]) => {
|
||||||
|
selectedRowKeys.value = keys.map(key => typeof key === 'string' ? Number(key) : key);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 查看使用状态
|
||||||
|
const handleViewStatus = async (record: ClassroomApi.ClassroomInfo) => {
|
||||||
|
if (!record.id) {
|
||||||
|
message.error('教室ID不存在');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
seatListVisible.value = true;
|
||||||
|
currentClassroomName.value = record.name || '';
|
||||||
|
seatListLoading.value = true;
|
||||||
|
seatListData.value = [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await getClassroomSeatListApi({ id: record.id });
|
||||||
|
if (res && (res.code === 0 || res.code === 200)) {
|
||||||
|
seatListData.value = Array.isArray(res.data) ? res.data : [];
|
||||||
|
} else {
|
||||||
|
message.error(res?.message || res?.msg || '获取座位列表失败');
|
||||||
|
seatListData.value = [];
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('获取座位列表失败:', error);
|
||||||
|
message.error(error?.response?.data?.message || error?.message || '获取座位列表失败');
|
||||||
|
seatListData.value = [];
|
||||||
|
} finally {
|
||||||
|
seatListLoading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 关闭使用状态弹窗
|
||||||
|
const handleCloseSeatList = () => {
|
||||||
|
seatListVisible.value = false;
|
||||||
|
seatListData.value = [];
|
||||||
|
currentClassroomName.value = '';
|
||||||
|
seatListPagination.current = 1;
|
||||||
|
seatListPagination.pageSize = 10;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 查看座位布局
|
||||||
|
const handleViewLayout = async (record: ClassroomApi.ClassroomInfo) => {
|
||||||
|
if (!record.id) {
|
||||||
|
message.error('教室ID不存在');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
layoutVisible.value = true;
|
||||||
|
currentClassroomId.value = record.id;
|
||||||
|
currentClassroomName.value = record.name || '';
|
||||||
|
layoutLoading.value = true;
|
||||||
|
currentLayout.value = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await getClassroomDetailApi({ id: record.id });
|
||||||
|
if (res && (res.code === 0 || res.code === 200)) {
|
||||||
|
const data = res.data || res;
|
||||||
|
currentLayout.value = data.layout || null;
|
||||||
|
} else {
|
||||||
|
message.error(res?.message || res?.msg || '获取教室详情失败');
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('获取教室详情失败:', error);
|
||||||
|
message.error(error?.response?.data?.message || error?.message || '获取教室详情失败');
|
||||||
|
} finally {
|
||||||
|
layoutLoading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 关闭座位布局弹窗
|
||||||
|
const handleCloseLayout = () => {
|
||||||
|
layoutVisible.value = false;
|
||||||
|
currentClassroomId.value = null;
|
||||||
|
currentLayout.value = null;
|
||||||
|
currentClassroomName.value = '';
|
||||||
|
};
|
||||||
|
|
||||||
|
// 座位布局保存成功回调
|
||||||
|
const handleLayoutSaved = (layout: ClassroomApi.ClassroomLayout) => {
|
||||||
|
currentLayout.value = layout;
|
||||||
|
message.success('座位布局保存成功');
|
||||||
|
// 可以选择关闭弹窗或保持打开
|
||||||
|
// handleCloseLayout();
|
||||||
|
};
|
||||||
|
|
||||||
|
// 处理座位列表分页变化
|
||||||
|
const handleSeatListTableChange = (pag: any) => {
|
||||||
|
if (pag) {
|
||||||
|
seatListPagination.current = pag.current || seatListPagination.current;
|
||||||
|
seatListPagination.pageSize = pag.pageSize || seatListPagination.pageSize;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 导出座位列表
|
||||||
|
const handleExportSeatList = () => {
|
||||||
|
if (seatListData.value.length === 0) {
|
||||||
|
message.warning('没有数据可导出');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// CSV表头
|
||||||
|
const headers = ['座位号', '状态', '学员姓名', '学员手机号', '选座时间'];
|
||||||
|
|
||||||
|
// 转换数据
|
||||||
|
const csvData = seatListData.value.map((item) => {
|
||||||
|
const status = item.is_selected === 1 ? '已选' : '未选';
|
||||||
|
const studentName = item.student_name || '';
|
||||||
|
const studentMobile = item.student_mobile || '';
|
||||||
|
const selectTime = item.select_time || '';
|
||||||
|
return [
|
||||||
|
item.seat_number || '',
|
||||||
|
status,
|
||||||
|
studentName,
|
||||||
|
studentMobile,
|
||||||
|
selectTime,
|
||||||
|
];
|
||||||
|
});
|
||||||
|
|
||||||
|
// 组合CSV内容
|
||||||
|
const csvContent = [
|
||||||
|
headers.join(','),
|
||||||
|
...csvData.map((row) => row.map((cell) => `"${String(cell).replace(/"/g, '""')}"`).join(',')),
|
||||||
|
].join('\n');
|
||||||
|
|
||||||
|
// 添加BOM以支持中文
|
||||||
|
const BOM = '\uFEFF';
|
||||||
|
const blob = new Blob([BOM + csvContent], { type: 'text/csv;charset=utf-8;' });
|
||||||
|
|
||||||
|
// 创建下载链接
|
||||||
|
const link = document.createElement('a');
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
link.setAttribute('href', url);
|
||||||
|
link.setAttribute('download', `${currentClassroomName.value || '教室'}_座位使用状态_${new Date().toISOString().slice(0, 10)}.csv`);
|
||||||
|
link.style.visibility = 'hidden';
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
document.body.removeChild(link);
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
|
||||||
|
message.success('导出成功');
|
||||||
};
|
};
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
@@ -233,12 +395,11 @@ onMounted(() => {
|
|||||||
total: total,
|
total: total,
|
||||||
showSizeChanger: true,
|
showSizeChanger: true,
|
||||||
showTotal: (total) => `共 ${total} 条`,
|
showTotal: (total) => `共 ${total} 条`,
|
||||||
|
pageSizeOptions: ['10', '20', '50', '100'],
|
||||||
}"
|
}"
|
||||||
:row-selection="{
|
:row-selection="{
|
||||||
selectedRowKeys: selectedRowKeys,
|
selectedRowKeys: selectedRowKeys,
|
||||||
onChange: (keys: number[]) => {
|
onChange: handleRowSelectionChange,
|
||||||
selectedRowKeys = keys;
|
|
||||||
},
|
|
||||||
}"
|
}"
|
||||||
row-key="id"
|
row-key="id"
|
||||||
@change="handleTableChange"
|
@change="handleTableChange"
|
||||||
@@ -247,12 +408,88 @@ onMounted(() => {
|
|||||||
<template v-if="column.key === 'action'">
|
<template v-if="column.key === 'action'">
|
||||||
<Space>
|
<Space>
|
||||||
<Button type="link" size="small" @click="handleEdit(record)">编辑</Button>
|
<Button type="link" size="small" @click="handleEdit(record)">编辑</Button>
|
||||||
|
<Button type="link" size="small" @click="handleViewLayout(record)">座位布局</Button>
|
||||||
|
<Button type="link" size="small" @click="handleViewStatus(record)">使用状态</Button>
|
||||||
<Button type="link" danger size="small" @click="handleDelete(record)">删除</Button>
|
<Button type="link" danger size="small" @click="handleDelete(record)">删除</Button>
|
||||||
</Space>
|
</Space>
|
||||||
</template>
|
</template>
|
||||||
</template>
|
</template>
|
||||||
</Table>
|
</Table>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
<!-- 使用状态弹窗 -->
|
||||||
|
<Modal
|
||||||
|
v-model:open="seatListVisible"
|
||||||
|
:title="`${currentClassroomName} - 使用状态`"
|
||||||
|
width="800px"
|
||||||
|
:footer="null"
|
||||||
|
@cancel="handleCloseSeatList"
|
||||||
|
>
|
||||||
|
<div style="margin-bottom: 16px; text-align: right;">
|
||||||
|
<Button type="primary" @click="handleExportSeatList">
|
||||||
|
导出
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<Table
|
||||||
|
:columns="[
|
||||||
|
{ title: '座位号', dataIndex: 'seat_number', key: 'seat_number', width: 100 },
|
||||||
|
{ title: '状态', key: 'status', width: 100 },
|
||||||
|
{ title: '学员姓名', dataIndex: 'student_name', key: 'student_name' },
|
||||||
|
{ title: '学员手机号', dataIndex: 'student_mobile', key: 'student_mobile' },
|
||||||
|
{ title: '选座时间', dataIndex: 'select_time', key: 'select_time' },
|
||||||
|
]"
|
||||||
|
:data-source="seatListData"
|
||||||
|
:loading="seatListLoading"
|
||||||
|
:pagination="{
|
||||||
|
current: seatListPagination.current,
|
||||||
|
pageSize: seatListPagination.pageSize,
|
||||||
|
total: seatListData.length,
|
||||||
|
showSizeChanger: true,
|
||||||
|
showTotal: (total) => `共 ${total} 条`,
|
||||||
|
pageSizeOptions: ['10', '20', '50', '100'],
|
||||||
|
}"
|
||||||
|
row-key="seat_number"
|
||||||
|
size="small"
|
||||||
|
@change="handleSeatListTableChange"
|
||||||
|
>
|
||||||
|
<template #bodyCell="{ column, record }">
|
||||||
|
<template v-if="column.key === 'status'">
|
||||||
|
<Tag :color="record.is_selected === 1 ? 'red' : 'green'">
|
||||||
|
{{ record.is_selected === 1 ? '已选' : '未选' }}
|
||||||
|
</Tag>
|
||||||
|
</template>
|
||||||
|
<template v-else-if="column.key === 'student_name'">
|
||||||
|
{{ record.student_name || '-' }}
|
||||||
|
</template>
|
||||||
|
<template v-else-if="column.key === 'student_mobile'">
|
||||||
|
{{ record.student_mobile || '-' }}
|
||||||
|
</template>
|
||||||
|
<template v-else-if="column.key === 'select_time'">
|
||||||
|
{{ record.select_time || '-' }}
|
||||||
|
</template>
|
||||||
|
</template>
|
||||||
|
</Table>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
<!-- 座位布局弹窗 -->
|
||||||
|
<Modal
|
||||||
|
v-model:open="layoutVisible"
|
||||||
|
:title="`${currentClassroomName} - 座位布局`"
|
||||||
|
width="90%"
|
||||||
|
:footer="null"
|
||||||
|
:mask-closable="false"
|
||||||
|
@cancel="handleCloseLayout"
|
||||||
|
>
|
||||||
|
<div v-if="layoutLoading" style="text-align: center; padding: 40px;">
|
||||||
|
<span>加载中...</span>
|
||||||
|
</div>
|
||||||
|
<SeatLayoutEditor
|
||||||
|
v-else-if="currentClassroomId"
|
||||||
|
:classroom-id="currentClassroomId"
|
||||||
|
:layout="currentLayout"
|
||||||
|
@saved="handleLayoutSaved"
|
||||||
|
/>
|
||||||
|
</Modal>
|
||||||
</Page>
|
</Page>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|||||||
@@ -17,11 +17,9 @@ const fetchSystemInfo = async () => {
|
|||||||
if (res && (res.code === 0 || res.code === 200)) {
|
if (res && (res.code === 0 || res.code === 200)) {
|
||||||
// 根据实际返回格式,数据在 res.data 中
|
// 根据实际返回格式,数据在 res.data 中
|
||||||
systemInfo.value = res.data || {};
|
systemInfo.value = res.data || {};
|
||||||
} else {
|
|
||||||
console.error('获取系统信息失败:', res?.message || res?.msg);
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('获取系统信息失败:', error);
|
// 静默处理错误
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false;
|
loading.value = false;
|
||||||
}
|
}
|
||||||
@@ -34,45 +32,75 @@ onMounted(() => {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<Page title="系统概览">
|
<Page title="系统概览">
|
||||||
<Card title="系统信息">
|
<div style="display: flex; flex-direction: column; gap: 16px;">
|
||||||
<Spin :spinning="loading">
|
<Card title="系统信息">
|
||||||
<Descriptions :column="2" bordered>
|
<Spin :spinning="loading">
|
||||||
<Descriptions.Item label="操作系统">
|
<Descriptions :column="2" bordered>
|
||||||
{{ systemInfo.os || '-' }}
|
<Descriptions.Item label="操作系统">
|
||||||
</Descriptions.Item>
|
{{ systemInfo.os || '-' }}
|
||||||
<Descriptions.Item label="PHP版本">
|
</Descriptions.Item>
|
||||||
{{ systemInfo.php || '-' }}
|
<Descriptions.Item label="PHP版本">
|
||||||
</Descriptions.Item>
|
{{ systemInfo.php || '-' }}
|
||||||
<Descriptions.Item label="Web服务器">
|
</Descriptions.Item>
|
||||||
{{ systemInfo.server || '-' }}
|
<Descriptions.Item label="Web服务器">
|
||||||
</Descriptions.Item>
|
{{ systemInfo.server || '-' }}
|
||||||
<Descriptions.Item label="MySQL版本">
|
</Descriptions.Item>
|
||||||
{{ systemInfo.mysql || '-' }}
|
<Descriptions.Item label="MySQL版本">
|
||||||
</Descriptions.Item>
|
{{ systemInfo.mysql || '-' }}
|
||||||
<Descriptions.Item label="框架版本">
|
</Descriptions.Item>
|
||||||
{{ systemInfo.framework_version || '-' }}
|
<Descriptions.Item label="框架版本">
|
||||||
</Descriptions.Item>
|
{{ systemInfo.framework_version || '-' }}
|
||||||
<Descriptions.Item label="上传限制">
|
</Descriptions.Item>
|
||||||
{{ systemInfo.upload_max || '-' }}
|
<Descriptions.Item label="上传限制">
|
||||||
</Descriptions.Item>
|
{{ systemInfo.upload_max || '-' }}
|
||||||
<Descriptions.Item label="最大执行时间">
|
</Descriptions.Item>
|
||||||
{{ systemInfo.max_execution_time || '-' }}
|
<Descriptions.Item label="最大执行时间">
|
||||||
</Descriptions.Item>
|
{{ systemInfo.max_execution_time || '-' }}
|
||||||
<Descriptions.Item label="运行目录">
|
</Descriptions.Item>
|
||||||
{{ systemInfo.runtime_path || '-' }}
|
<Descriptions.Item label="运行目录">
|
||||||
</Descriptions.Item>
|
{{ systemInfo.runtime_path || '-' }}
|
||||||
<Descriptions.Item label="磁盘总空间" :span="2">
|
</Descriptions.Item>
|
||||||
{{ systemInfo.disk_total_space || '-' }}
|
<Descriptions.Item label="磁盘总空间" :span="2">
|
||||||
</Descriptions.Item>
|
{{ systemInfo.disk_total_space || '-' }}
|
||||||
<Descriptions.Item label="磁盘可用空间">
|
</Descriptions.Item>
|
||||||
{{ systemInfo.disk_free_space || '-' }}
|
<Descriptions.Item label="磁盘可用空间">
|
||||||
</Descriptions.Item>
|
{{ systemInfo.disk_free_space || '-' }}
|
||||||
<Descriptions.Item label="磁盘使用率">
|
</Descriptions.Item>
|
||||||
{{ systemInfo.disk_usage || '-' }}
|
<Descriptions.Item label="磁盘使用率">
|
||||||
</Descriptions.Item>
|
{{ systemInfo.disk_usage || '-' }}
|
||||||
</Descriptions>
|
</Descriptions.Item>
|
||||||
</Spin>
|
</Descriptions>
|
||||||
</Card>
|
</Spin>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card title="数据统计">
|
||||||
|
<Spin :spinning="loading">
|
||||||
|
<Descriptions :column="2" bordered>
|
||||||
|
<Descriptions.Item label="学校数量">
|
||||||
|
{{ systemInfo.statistics?.school_count ?? '-' }}
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="教室数量">
|
||||||
|
{{ systemInfo.statistics?.classroom_count ?? '-' }}
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="班级数量">
|
||||||
|
{{ systemInfo.statistics?.class_count ?? '-' }}
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="学生数量">
|
||||||
|
{{ systemInfo.statistics?.student_count ?? '-' }}
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="教师数量">
|
||||||
|
{{ systemInfo.statistics?.teacher_count ?? '-' }}
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="预订数量">
|
||||||
|
{{ systemInfo.statistics?.booking_count ?? '-' }}
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="已选座位数量" :span="2">
|
||||||
|
{{ systemInfo.statistics?.selected_seat_count ?? '-' }}
|
||||||
|
</Descriptions.Item>
|
||||||
|
</Descriptions>
|
||||||
|
</Spin>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
</Page>
|
</Page>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { ref, onMounted } from 'vue';
|
import { ref, onMounted } from 'vue';
|
||||||
import { Page } from '@vben/common-ui';
|
import { Page } from '@vben/common-ui';
|
||||||
import { Card, Form, Input, Button, message, Tabs, InputNumber } from 'ant-design-vue';
|
import { Card, Form, Input, Button, message, Tabs, InputNumber, Upload } from 'ant-design-vue';
|
||||||
|
import { useAppConfig } from '@vben/hooks';
|
||||||
import {
|
import {
|
||||||
getSettingApi,
|
getSettingApi,
|
||||||
saveSettingApi,
|
saveSettingApi,
|
||||||
|
uploadSettingImageApi,
|
||||||
type SettingApi
|
type SettingApi
|
||||||
} from '#/api';
|
} from '#/api';
|
||||||
|
|
||||||
@@ -12,7 +14,11 @@ defineOptions({ name: 'SettingIndex' });
|
|||||||
|
|
||||||
const formRef = ref();
|
const formRef = ref();
|
||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
|
const uploading = ref(false);
|
||||||
const activeTab = ref('basic');
|
const activeTab = ref('basic');
|
||||||
|
const { apiURL } = useAppConfig(import.meta.env, import.meta.env.PROD);
|
||||||
|
const fileList = ref<any[]>([]);
|
||||||
|
|
||||||
const formData = ref<Partial<SettingApi.SettingInfo>>({
|
const formData = ref<Partial<SettingApi.SettingInfo>>({
|
||||||
site_name: '',
|
site_name: '',
|
||||||
site_desc: '',
|
site_desc: '',
|
||||||
@@ -33,6 +39,23 @@ const formData = ref<Partial<SettingApi.SettingInfo>>({
|
|||||||
wxapp_original: '',
|
wxapp_original: '',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 获取完整的头像URL
|
||||||
|
const getAvatarUrl = (avatarPath?: string | null): string => {
|
||||||
|
if (!avatarPath) return '';
|
||||||
|
// 如果已经是完整URL,直接返回
|
||||||
|
if (avatarPath.startsWith('http://') || avatarPath.startsWith('https://')) {
|
||||||
|
return avatarPath;
|
||||||
|
}
|
||||||
|
// 拼接完整的URL
|
||||||
|
if (!apiURL) {
|
||||||
|
console.warn('apiURL is not configured');
|
||||||
|
return avatarPath;
|
||||||
|
}
|
||||||
|
const baseUrl = apiURL.replace(/\/$/, '');
|
||||||
|
const path = avatarPath.startsWith('/') ? avatarPath : `/${avatarPath}`;
|
||||||
|
return `${baseUrl}${path}`;
|
||||||
|
};
|
||||||
|
|
||||||
const fetchData = async () => {
|
const fetchData = async () => {
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
try {
|
try {
|
||||||
@@ -61,6 +84,20 @@ const fetchData = async () => {
|
|||||||
wxapp_name: data.wxapp_name || '',
|
wxapp_name: data.wxapp_name || '',
|
||||||
wxapp_original: data.wxapp_original || '',
|
wxapp_original: data.wxapp_original || '',
|
||||||
};
|
};
|
||||||
|
// 初始化头像文件列表
|
||||||
|
if (formData.value.default_avatar) {
|
||||||
|
const avatarUrl = getAvatarUrl(formData.value.default_avatar);
|
||||||
|
fileList.value = [
|
||||||
|
{
|
||||||
|
uid: '-1',
|
||||||
|
name: 'avatar',
|
||||||
|
status: 'done',
|
||||||
|
url: avatarUrl,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
} else {
|
||||||
|
fileList.value = [];
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
message.error(res?.message || res?.msg || '获取系统设置失败');
|
message.error(res?.message || res?.msg || '获取系统设置失败');
|
||||||
}
|
}
|
||||||
@@ -72,6 +109,94 @@ const fetchData = async () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 处理头像上传前
|
||||||
|
const beforeUpload = (file: File) => {
|
||||||
|
const isImage = file.type.startsWith('image/');
|
||||||
|
if (!isImage) {
|
||||||
|
message.error('只能上传图片文件!');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const isLt10M = file.size / 1024 / 1024 < 10;
|
||||||
|
if (!isLt10M) {
|
||||||
|
message.error('图片大小不能超过 10MB!');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 立即上传
|
||||||
|
handleUpload(file);
|
||||||
|
|
||||||
|
return false; // 阻止自动上传
|
||||||
|
};
|
||||||
|
|
||||||
|
// 上传图片
|
||||||
|
const handleUpload = async (file: File) => {
|
||||||
|
try {
|
||||||
|
uploading.value = true;
|
||||||
|
const res = await uploadSettingImageApi(file);
|
||||||
|
|
||||||
|
// 处理响应,获取图片路径
|
||||||
|
// 支持多种响应格式:
|
||||||
|
// 1. { code: 200, data: "path" } 或 { code: 200, data: { src: "path" } }
|
||||||
|
// 2. { code: 200, data: { url: "path" } } 或 { code: 200, data: { path: "path" } }
|
||||||
|
// 3. 直接返回字符串路径
|
||||||
|
// 4. { url: "path" } 或 { path: "path" }
|
||||||
|
let imagePath = '';
|
||||||
|
if (res && (res.code === 0 || res.code === 200)) {
|
||||||
|
// 标准响应格式
|
||||||
|
if (typeof res.data === 'string') {
|
||||||
|
imagePath = res.data;
|
||||||
|
} else if (res.data?.src) {
|
||||||
|
// 优先使用 src 字段
|
||||||
|
imagePath = res.data.src;
|
||||||
|
} else if (res.data?.url || res.data?.path) {
|
||||||
|
imagePath = res.data.url || res.data.path;
|
||||||
|
}
|
||||||
|
} else if (typeof res === 'string') {
|
||||||
|
// 直接返回字符串
|
||||||
|
imagePath = res;
|
||||||
|
} else if (res?.data) {
|
||||||
|
// 嵌套的 data 字段
|
||||||
|
if (typeof res.data === 'string') {
|
||||||
|
imagePath = res.data;
|
||||||
|
} else {
|
||||||
|
imagePath = res.data.src || res.data.url || res.data.path || '';
|
||||||
|
}
|
||||||
|
} else if (res?.src || res?.url || res?.path) {
|
||||||
|
// 直接包含 src、url 或 path
|
||||||
|
imagePath = res.src || res.url || res.path;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (imagePath) {
|
||||||
|
formData.value.default_avatar = imagePath;
|
||||||
|
// 更新文件列表
|
||||||
|
const avatarUrl = getAvatarUrl(imagePath);
|
||||||
|
fileList.value = [
|
||||||
|
{
|
||||||
|
uid: Date.now().toString(),
|
||||||
|
name: file.name,
|
||||||
|
status: 'done',
|
||||||
|
url: avatarUrl,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
message.success('头像上传成功');
|
||||||
|
} else {
|
||||||
|
message.error('上传失败:未获取到图片路径');
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
message.error(error?.response?.data?.message || error?.message || '上传头像失败');
|
||||||
|
} finally {
|
||||||
|
uploading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 处理头像上传变化
|
||||||
|
const handleAvatarChange = (info: any) => {
|
||||||
|
if (info.file.status === 'removed') {
|
||||||
|
fileList.value = [];
|
||||||
|
formData.value.default_avatar = '';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleSubmit = async () => {
|
const handleSubmit = async () => {
|
||||||
try {
|
try {
|
||||||
await formRef.value.validate();
|
await formRef.value.validate();
|
||||||
@@ -111,7 +236,21 @@ onMounted(() => {
|
|||||||
<Input v-model:value="formData.site_icp" placeholder="请输入ICP备案号" />
|
<Input v-model:value="formData.site_icp" placeholder="请输入ICP备案号" />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Form.Item label="默认头像" name="default_avatar">
|
<Form.Item label="默认头像" name="default_avatar">
|
||||||
<Input v-model:value="formData.default_avatar" placeholder="默认头像路径" />
|
<Upload
|
||||||
|
v-model:file-list="fileList"
|
||||||
|
list-type="picture-card"
|
||||||
|
:max-count="1"
|
||||||
|
:before-upload="beforeUpload"
|
||||||
|
:disabled="uploading"
|
||||||
|
@change="handleAvatarChange"
|
||||||
|
accept="image/*"
|
||||||
|
>
|
||||||
|
<div v-if="fileList.length < 1">
|
||||||
|
<div style="margin-top: 8px">
|
||||||
|
<span>上传</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Upload>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
</Tabs.TabPane>
|
</Tabs.TabPane>
|
||||||
<Tabs.TabPane key="upload" tab="上传设置">
|
<Tabs.TabPane key="upload" tab="上传设置">
|
||||||
|
|||||||
@@ -105,28 +105,21 @@ const fetchData = async () => {
|
|||||||
...searchForm,
|
...searchForm,
|
||||||
};
|
};
|
||||||
const res = await getStudentListApi(params);
|
const res = await getStudentListApi(params);
|
||||||
console.log('学生列表API返回:', res); // 调试用
|
|
||||||
// 根据实际返回格式,code 为 0 表示成功
|
// 根据实际返回格式,code 为 0 表示成功
|
||||||
if (res && (res.code === 0 || res.code === 200)) {
|
if (res && (res.code === 0 || res.code === 200)) {
|
||||||
const data = res.data;
|
const data = res.data;
|
||||||
console.log('学生列表数据:', data); // 调试用
|
|
||||||
console.log('学生列表总数:', res.count); // 调试用
|
|
||||||
if (Array.isArray(data)) {
|
if (Array.isArray(data)) {
|
||||||
tableData.value = data;
|
tableData.value = data;
|
||||||
total.value = res.count || data.length;
|
total.value = res.count || data.length;
|
||||||
console.log('设置学生列表,数量:', tableData.value.length, '总数:', total.value); // 调试用
|
|
||||||
} else {
|
} else {
|
||||||
console.warn('学生列表数据不是数组:', data);
|
|
||||||
tableData.value = [];
|
tableData.value = [];
|
||||||
total.value = 0;
|
total.value = 0;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
console.warn('获取学生列表失败,响应码:', res?.code, res);
|
|
||||||
tableData.value = [];
|
tableData.value = [];
|
||||||
total.value = 0;
|
total.value = 0;
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('获取学生列表失败:', error);
|
|
||||||
tableData.value = [];
|
tableData.value = [];
|
||||||
total.value = 0;
|
total.value = 0;
|
||||||
} finally {
|
} finally {
|
||||||
@@ -191,24 +184,18 @@ const fileList = ref<any[]>([]);
|
|||||||
const fetchClassList = async () => {
|
const fetchClassList = async () => {
|
||||||
try {
|
try {
|
||||||
const res = await getClassListApi({ page: 1, limit: 1000 });
|
const res = await getClassListApi({ page: 1, limit: 1000 });
|
||||||
console.log('导入对话框-班级列表API返回:', res); // 调试用
|
|
||||||
if (res && (res.code === 0 || res.code === 200)) {
|
if (res && (res.code === 0 || res.code === 200)) {
|
||||||
// 根据实际返回格式,data 是数组
|
// 根据实际返回格式,data 是数组
|
||||||
const data = res.data;
|
const data = res.data;
|
||||||
console.log('导入对话框-班级列表数据:', data); // 调试用
|
|
||||||
if (Array.isArray(data)) {
|
if (Array.isArray(data)) {
|
||||||
classList.value = data;
|
classList.value = data;
|
||||||
console.log('导入对话框-设置班级列表,数量:', classList.value.length); // 调试用
|
|
||||||
} else {
|
} else {
|
||||||
console.warn('导入对话框-班级列表数据不是数组:', data);
|
|
||||||
classList.value = [];
|
classList.value = [];
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
console.warn('导入对话框-获取班级列表失败:', res);
|
|
||||||
classList.value = [];
|
classList.value = [];
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('导入对话框-获取班级列表失败:', error);
|
|
||||||
classList.value = [];
|
classList.value = [];
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ export default defineConfig(async () => {
|
|||||||
server: {
|
server: {
|
||||||
proxy: {
|
proxy: {
|
||||||
'/api': {
|
'/api': {
|
||||||
target: 'https://xuanzuo.dhdjy.com',
|
target: 'http://xz.dhdjy.com',
|
||||||
changeOrigin: true,
|
changeOrigin: true,
|
||||||
secure: true, // https 接口设为 true
|
secure: true, // https 接口设为 true
|
||||||
ws: true,
|
ws: true,
|
||||||
|
|||||||
142
doc/座位布局性能优化说明.md
Normal file
142
doc/座位布局性能优化说明.md
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
# 座位布局性能优化说明
|
||||||
|
|
||||||
|
## 问题描述
|
||||||
|
|
||||||
|
当座位布局节点数量很大时(例如 50x50 = 2500 个单元格),会出现明显的卡顿现象。主要原因是:
|
||||||
|
|
||||||
|
1. **大量 DOM 节点**:双重循环渲染所有单元格,创建大量 DOM 元素
|
||||||
|
2. **重复计算**:每次渲染时,`getCellClass`、`getCellText`、`shouldRenderCell` 等函数都会被调用,且内部包含循环遍历
|
||||||
|
3. **查找性能**:使用 `Array.find()` 查找单元格,时间复杂度为 O(n)
|
||||||
|
4. **事件处理**:鼠标移动事件触发频繁,没有有效节流
|
||||||
|
|
||||||
|
## 优化方案
|
||||||
|
|
||||||
|
### 1. 使用 Map 缓存单元格查找
|
||||||
|
|
||||||
|
**优化前:**
|
||||||
|
```typescript
|
||||||
|
const getCell = (col: number, row: number) => {
|
||||||
|
return cells.value.find((c) => c.col === col && c.row === row);
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
**优化后:**
|
||||||
|
```typescript
|
||||||
|
// 单元格查找缓存 Map: key = "col,row", value = cell
|
||||||
|
const cellMapCache = ref<Map<string, ClassroomApi.ClassroomLayoutCell>>(new Map());
|
||||||
|
|
||||||
|
const getCell = (col: number, row: number) => {
|
||||||
|
const key = `${col},${row}`;
|
||||||
|
return cellMapCache.value.get(key);
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
**效果:** 查找时间复杂度从 O(n) 降低到 O(1)
|
||||||
|
|
||||||
|
### 2. 缓存合并单元格映射关系
|
||||||
|
|
||||||
|
**优化前:**
|
||||||
|
```typescript
|
||||||
|
// 每次都要遍历所有单元格检查是否被合并
|
||||||
|
for (const otherCell of cells.value) {
|
||||||
|
// ... 检查逻辑
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**优化后:**
|
||||||
|
```typescript
|
||||||
|
// 合并单元格主单元格映射: key = "col,row" (被合并的单元格), value = "col,row" (主单元格)
|
||||||
|
const mergeMasterMapCache = ref<Map<string, string>>(new Map());
|
||||||
|
|
||||||
|
// 使用缓存检查
|
||||||
|
const key = `${col},${row}`;
|
||||||
|
if (mergeMasterMapCache.value.has(key)) {
|
||||||
|
return 'cell cell-merged';
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**效果:** 避免了每次渲染时的 O(n²) 循环嵌套
|
||||||
|
|
||||||
|
### 3. 使用 shallowRef 优化响应式性能
|
||||||
|
|
||||||
|
**优化前:**
|
||||||
|
```typescript
|
||||||
|
const cells = ref<ClassroomApi.ClassroomLayoutCell[]>([]);
|
||||||
|
```
|
||||||
|
|
||||||
|
**优化后:**
|
||||||
|
```typescript
|
||||||
|
const cells = shallowRef<ClassroomApi.ClassroomLayoutCell[]>([]);
|
||||||
|
```
|
||||||
|
|
||||||
|
**效果:** 减少深度响应式监听,提升性能
|
||||||
|
|
||||||
|
### 4. 使用 requestAnimationFrame 优化鼠标移动事件
|
||||||
|
|
||||||
|
**优化前:**
|
||||||
|
```typescript
|
||||||
|
mouseEnterTimer = window.setTimeout(() => {
|
||||||
|
// 填充逻辑
|
||||||
|
}, 16);
|
||||||
|
```
|
||||||
|
|
||||||
|
**优化后:**
|
||||||
|
```typescript
|
||||||
|
rafId = requestAnimationFrame(() => {
|
||||||
|
// 填充逻辑
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
**效果:** 与浏览器渲染周期同步,更流畅
|
||||||
|
|
||||||
|
### 5. 使用 v-memo 指令缓存单元格渲染
|
||||||
|
|
||||||
|
**优化后:**
|
||||||
|
```vue
|
||||||
|
<div
|
||||||
|
v-if="shouldRenderCell(col - 1, row - 1)"
|
||||||
|
v-memo="[getCell(col - 1, row - 1)?.type, getCell(col - 1, row - 1)?.status, ...]"
|
||||||
|
:class="getCellClass(col - 1, row - 1)"
|
||||||
|
...
|
||||||
|
>
|
||||||
|
```
|
||||||
|
|
||||||
|
**效果:** 只有当依赖项变化时才重新渲染,减少不必要的 DOM 更新
|
||||||
|
|
||||||
|
### 6. 统一更新缓存机制
|
||||||
|
|
||||||
|
在所有修改单元格的操作后,统一调用 `updateCellMapCache()` 更新缓存:
|
||||||
|
|
||||||
|
- `initLayout()` - 初始化布局
|
||||||
|
- `loadLayout()` - 加载布局
|
||||||
|
- `addColumnLeft/Right()` - 添加列
|
||||||
|
- `addRowTop/Bottom()` - 添加行
|
||||||
|
- `mergeCells()` - 合并单元格
|
||||||
|
- `unmergeCell()` - 取消合并
|
||||||
|
- `handleUndo()` - 撤销操作
|
||||||
|
- `handleCellMouseUp()` - 鼠标释放
|
||||||
|
|
||||||
|
## 性能提升
|
||||||
|
|
||||||
|
经过优化后,预期性能提升:
|
||||||
|
|
||||||
|
1. **单元格查找**:从 O(n) 降低到 O(1),查找速度提升 100-1000 倍(取决于单元格数量)
|
||||||
|
2. **合并检查**:从 O(n²) 降低到 O(1),检查速度提升 10000 倍以上
|
||||||
|
3. **渲染性能**:使用 v-memo 后,只有变化的单元格才会重新渲染
|
||||||
|
4. **鼠标交互**:使用 requestAnimationFrame 后,交互更流畅,帧率更稳定
|
||||||
|
|
||||||
|
## 注意事项
|
||||||
|
|
||||||
|
1. **缓存一致性**:所有修改单元格的操作都必须调用 `updateCellMapCache()` 保持缓存同步
|
||||||
|
2. **内存占用**:Map 缓存会占用额外内存,但对于性能提升是值得的
|
||||||
|
3. **兼容性**:`v-memo` 是 Vue 3.2+ 的特性,确保项目使用 Vue 3.2 或更高版本
|
||||||
|
|
||||||
|
## 后续优化建议
|
||||||
|
|
||||||
|
如果性能仍然不够理想,可以考虑:
|
||||||
|
|
||||||
|
1. **虚拟滚动**:只渲染可见区域的单元格(对 Grid 布局实现较复杂)
|
||||||
|
2. **Canvas 渲染**:使用 Canvas 替代 DOM 渲染(需要重写交互逻辑)
|
||||||
|
3. **Web Worker**:将复杂计算移到 Web Worker 中
|
||||||
|
4. **分块渲染**:将大布局分成多个块,按需加载
|
||||||
|
|
||||||
@@ -5,13 +5,13 @@ location /super {
|
|||||||
alias /www/wwwroot/你的网站目录/dist; # 请修改为实际的网站目录路径
|
alias /www/wwwroot/你的网站目录/dist; # 请修改为实际的网站目录路径
|
||||||
try_files $uri $uri/ /super/index.html;
|
try_files $uri $uri/ /super/index.html;
|
||||||
index index.html;
|
index index.html;
|
||||||
|
|
||||||
# 静态资源缓存
|
# 静态资源缓存
|
||||||
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
|
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
|
||||||
expires 1y;
|
expires 1y;
|
||||||
add_header Cache-Control "public, immutable";
|
add_header Cache-Control "public, immutable";
|
||||||
}
|
}
|
||||||
|
|
||||||
# Enable CORS (如果需要)
|
# Enable CORS (如果需要)
|
||||||
# add_header 'Access-Control-Allow-Origin' '*';
|
# add_header 'Access-Control-Allow-Origin' '*';
|
||||||
# add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS';
|
# add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS';
|
||||||
|
|||||||
Reference in New Issue
Block a user