新增e2e测试脚本,修复部分问题
This commit was merged in pull request #51.
This commit is contained in:
@@ -0,0 +1,174 @@
|
||||
-- ============================================
|
||||
-- E2E 测试种子数据
|
||||
-- 描述: Gym 业务模块测试用初始化数据
|
||||
-- 注意: 使用 INSERT ... ON CONFLICT DO NOTHING 确保幂等性
|
||||
-- ID 从 100 开始避免与已有数据冲突
|
||||
-- ============================================
|
||||
|
||||
-- ============================================
|
||||
-- 1. 测试教练(sys_user + user_role)
|
||||
-- 密码均为 Test@123
|
||||
-- bcrypt: $2a$12$nZ1EMUpZQljbnEdIKzH72eHlDJKUmHmHppnTTVth/SlHs5VpSAr8C
|
||||
-- ============================================
|
||||
|
||||
INSERT INTO sys_user (id, username, password, email, phone, nickname, status, create_by, update_by, created_at, updated_at)
|
||||
VALUES
|
||||
(100, 'coach_e2e_01', '$2a$12$nZ1EMUpZQljbnEdIKzH72eHlDJKUmHmHppnTTVth/SlHs5VpSAr8C', 'coach_e2e_01@test.com', '13900139100', 'E2E测试教练-张', 1, 'system', 'system', NOW(), NOW()),
|
||||
(101, 'coach_e2e_02', '$2a$12$nZ1EMUpZQljbnEdIKzH72eHlDJKUmHmHppnTTVth/SlHs5VpSAr8C', 'coach_e2e_02@test.com', '13900139101', 'E2E测试教练-李', 1, 'system', 'system', NOW(), NOW())
|
||||
ON CONFLICT (username) DO UPDATE SET
|
||||
password = EXCLUDED.password,
|
||||
status = EXCLUDED.status;
|
||||
|
||||
-- 分配教练角色(role_id=5)
|
||||
INSERT INTO user_role (user_id, role_id, created_by, created_at)
|
||||
VALUES
|
||||
(100, 5, 'system', NOW()),
|
||||
(101, 5, 'system', NOW())
|
||||
ON CONFLICT (user_id, role_id) DO NOTHING;
|
||||
|
||||
-- ============================================
|
||||
-- 2. 团课类型(group_course_type)
|
||||
-- ============================================
|
||||
|
||||
INSERT INTO group_course_type (id, type_name, base_difficulty, description, category, create_by, update_by, created_at, updated_at)
|
||||
VALUES
|
||||
(100, '瑜伽', 3, '通过呼吸调控与体式练习,提升身体柔韧性与心灵平静', '柔韧与平衡类', 'system', 'system', NOW(), NOW()),
|
||||
(101, '动感单车', 5, '伴随动感音乐进行室内骑行,高效燃脂释放压力', '基础有氧与热身', 'system', 'system', NOW(), NOW()),
|
||||
(102, '搏击操', 6, '融合拳击、踢腿动作的有氧运动,宣泄压力同时高效塑形', '高强度与爆发力', 'system', 'system', NOW(), NOW()),
|
||||
(103, '普拉提', 4, '注重核心肌群控制与脊柱对齐,有效改善体态预防运动损伤', '柔韧与平衡类', 'system', 'system', NOW(), NOW())
|
||||
ON CONFLICT (id) DO UPDATE SET
|
||||
type_name = EXCLUDED.type_name,
|
||||
base_difficulty = EXCLUDED.base_difficulty,
|
||||
description = EXCLUDED.description,
|
||||
category = EXCLUDED.category;
|
||||
|
||||
-- ============================================
|
||||
-- 3. 课程标签(course_label)
|
||||
-- ============================================
|
||||
|
||||
INSERT INTO course_label (id, label_name, color, description, create_by, update_by, created_at, updated_at)
|
||||
VALUES
|
||||
(100, '燃脂', '#ff4d4f', '高效燃烧卡路里,助力减脂', 'system', 'system', NOW(), NOW()),
|
||||
(101, '塑形', '#722ed1', '注重体型塑造与线条雕刻', 'system', 'system', NOW(), NOW()),
|
||||
(102, '减压', '#52c41a', '放松身心,缓解压力', 'system', 'system', NOW(), NOW()),
|
||||
(103, '核心训练', '#1890ff', '强化核心肌群,改善身体稳定性', 'system', 'system', NOW(), NOW()),
|
||||
(104, '初学者友好', '#faad14', '适合健身初学者,动作简单易学', 'system', 'system', NOW(), NOW()),
|
||||
(105, '高阶挑战', '#f5222d', '适合高级学员,强度和技巧要求较高', 'system', 'system', NOW(), NOW())
|
||||
ON CONFLICT (id) DO UPDATE SET
|
||||
label_name = EXCLUDED.label_name,
|
||||
color = EXCLUDED.color,
|
||||
description = EXCLUDED.description;
|
||||
|
||||
-- ============================================
|
||||
-- 4. 类型-标签关联(course_type_label)
|
||||
-- ============================================
|
||||
|
||||
-- 瑜伽: 减压 + 初学者友好 + 核心训练
|
||||
INSERT INTO course_type_label (type_id, label_id, create_by, update_by, created_at, updated_at)
|
||||
SELECT 100, 102, 'system', 'system', NOW(), NOW()
|
||||
WHERE NOT EXISTS (SELECT 1 FROM course_type_label WHERE type_id = 100 AND label_id = 102);
|
||||
|
||||
INSERT INTO course_type_label (type_id, label_id, create_by, update_by, created_at, updated_at)
|
||||
SELECT 100, 104, 'system', 'system', NOW(), NOW()
|
||||
WHERE NOT EXISTS (SELECT 1 FROM course_type_label WHERE type_id = 100 AND label_id = 104);
|
||||
|
||||
INSERT INTO course_type_label (type_id, label_id, create_by, update_by, created_at, updated_at)
|
||||
SELECT 100, 103, 'system', 'system', NOW(), NOW()
|
||||
WHERE NOT EXISTS (SELECT 1 FROM course_type_label WHERE type_id = 100 AND label_id = 103);
|
||||
|
||||
-- 动感单车: 燃脂 + 高阶挑战
|
||||
INSERT INTO course_type_label (type_id, label_id, create_by, update_by, created_at, updated_at)
|
||||
SELECT 101, 100, 'system', 'system', NOW(), NOW()
|
||||
WHERE NOT EXISTS (SELECT 1 FROM course_type_label WHERE type_id = 101 AND label_id = 100);
|
||||
|
||||
INSERT INTO course_type_label (type_id, label_id, create_by, update_by, created_at, updated_at)
|
||||
SELECT 101, 105, 'system', 'system', NOW(), NOW()
|
||||
WHERE NOT EXISTS (SELECT 1 FROM course_type_label WHERE type_id = 101 AND label_id = 105);
|
||||
|
||||
-- 搏击操: 燃脂 + 塑形 + 高阶挑战
|
||||
INSERT INTO course_type_label (type_id, label_id, create_by, update_by, created_at, updated_at)
|
||||
SELECT 102, 100, 'system', 'system', NOW(), NOW()
|
||||
WHERE NOT EXISTS (SELECT 1 FROM course_type_label WHERE type_id = 102 AND label_id = 100);
|
||||
|
||||
INSERT INTO course_type_label (type_id, label_id, create_by, update_by, created_at, updated_at)
|
||||
SELECT 102, 101, 'system', 'system', NOW(), NOW()
|
||||
WHERE NOT EXISTS (SELECT 1 FROM course_type_label WHERE type_id = 102 AND label_id = 101);
|
||||
|
||||
INSERT INTO course_type_label (type_id, label_id, create_by, update_by, created_at, updated_at)
|
||||
SELECT 102, 105, 'system', 'system', NOW(), NOW()
|
||||
WHERE NOT EXISTS (SELECT 1 FROM course_type_label WHERE type_id = 102 AND label_id = 105);
|
||||
|
||||
-- 普拉提: 核心训练 + 塑形 + 初学者友好
|
||||
INSERT INTO course_type_label (type_id, label_id, create_by, update_by, created_at, updated_at)
|
||||
SELECT 103, 103, 'system', 'system', NOW(), NOW()
|
||||
WHERE NOT EXISTS (SELECT 1 FROM course_type_label WHERE type_id = 103 AND label_id = 103);
|
||||
|
||||
INSERT INTO course_type_label (type_id, label_id, create_by, update_by, created_at, updated_at)
|
||||
SELECT 103, 101, 'system', 'system', NOW(), NOW()
|
||||
WHERE NOT EXISTS (SELECT 1 FROM course_type_label WHERE type_id = 103 AND label_id = 101);
|
||||
|
||||
INSERT INTO course_type_label (type_id, label_id, create_by, update_by, created_at, updated_at)
|
||||
SELECT 103, 104, 'system', 'system', NOW(), NOW()
|
||||
WHERE NOT EXISTS (SELECT 1 FROM course_type_label WHERE type_id = 103 AND label_id = 104);
|
||||
|
||||
-- ============================================
|
||||
-- 5. 团课课程(group_course)
|
||||
-- 4节课,未来日期,不同时间段和地点
|
||||
-- ============================================
|
||||
|
||||
-- 晨间瑜伽 | A教室 | 明天 08:00-09:00 | 教练100
|
||||
INSERT INTO group_course (id, course_name, coach_id, course_type, start_time, end_time, max_members, current_members, status, location, cover_image, description, stored_value_amount, create_by, update_by, created_at, updated_at)
|
||||
SELECT 100, '晨间瑜伽', 100, 100, '2026-07-23 08:00:00'::TIMESTAMP, '2026-07-23 09:00:00'::TIMESTAMP, 20, 5, '0', 'A教室', '/static/course/e2e_yoga.jpg', 'E2E测试-晨间瑜伽课程,温和唤醒身体', 88.00, 'system', 'system', NOW(), NOW()
|
||||
WHERE NOT EXISTS (SELECT 1 FROM group_course WHERE id = 100);
|
||||
|
||||
-- 动感燃脂 | 单车房 | 后天 19:00-20:00 | 教练101
|
||||
INSERT INTO group_course (id, course_name, coach_id, course_type, start_time, end_time, max_members, current_members, status, location, cover_image, description, stored_value_amount, create_by, update_by, created_at, updated_at)
|
||||
SELECT 101, '动感燃脂', 101, 101, '2026-07-24 19:00:00'::TIMESTAMP, '2026-07-24 20:00:00'::TIMESTAMP, 25, 10, '0', '单车房', '/static/course/e2e_spin.jpg', 'E2E测试-动感单车燃脂课程', 68.00, 'system', 'system', NOW(), NOW()
|
||||
WHERE NOT EXISTS (SELECT 1 FROM group_course WHERE id = 101);
|
||||
|
||||
-- 有氧搏击 | B教室 | 大后天 10:00-11:00 | 教练100
|
||||
INSERT INTO group_course (id, course_name, coach_id, course_type, start_time, end_time, max_members, current_members, status, location, cover_image, description, stored_value_amount, create_by, update_by, created_at, updated_at)
|
||||
SELECT 102, '有氧搏击', 100, 102, '2026-07-25 10:00:00'::TIMESTAMP, '2026-07-25 11:00:00'::TIMESTAMP, 20, 8, '0', 'B教室', '/static/course/e2e_boxing.jpg', 'E2E测试-有氧搏击操课程', 68.00, 'system', 'system', NOW(), NOW()
|
||||
WHERE NOT EXISTS (SELECT 1 FROM group_course WHERE id = 102);
|
||||
|
||||
-- 普拉提入门 | 瑜伽房 | 4天后 14:00-15:00 | 教练101
|
||||
INSERT INTO group_course (id, course_name, coach_id, course_type, start_time, end_time, max_members, current_members, status, location, cover_image, description, stored_value_amount, create_by, update_by, created_at, updated_at)
|
||||
SELECT 103, '普拉提入门', 101, 103, '2026-07-26 14:00:00'::TIMESTAMP, '2026-07-26 15:00:00'::TIMESTAMP, 15, 3, '0', '瑜伽房', '/static/course/e2e_pilates.jpg', 'E2E测试-普拉提入门课程,适合初学者', 108.00, 'system', 'system', NOW(), NOW()
|
||||
WHERE NOT EXISTS (SELECT 1 FROM group_course WHERE id = 103);
|
||||
|
||||
-- ============================================
|
||||
-- 6. 测试会员(member_user)
|
||||
-- 包含 miniapp_openid 用于微信登录模拟
|
||||
-- ============================================
|
||||
|
||||
INSERT INTO member_user (id, member_no, nickname, phone, gender, miniapp_open_id, created_at, updated_at)
|
||||
VALUES
|
||||
(100, 'MEM-E2E-001', '测试会员一', '13900139200', 1, 'o_test_member_e2e_001', NOW(), NOW()),
|
||||
(101, 'MEM-E2E-002', '测试会员二', '13900139201', 2, 'o_test_member_e2e_002', NOW(), NOW())
|
||||
ON CONFLICT (member_no) DO UPDATE SET
|
||||
nickname = EXCLUDED.nickname,
|
||||
phone = EXCLUDED.phone,
|
||||
gender = EXCLUDED.gender,
|
||||
miniapp_open_id = EXCLUDED.miniapp_open_id;
|
||||
|
||||
-- ============================================
|
||||
-- 7. 签到记录(sign_in_record)
|
||||
-- 为测试会员创建几条签到历史
|
||||
-- ============================================
|
||||
|
||||
INSERT INTO sign_in_record (id, member_id, sign_in_time, sign_in_type, sign_in_status, source, created_at, updated_at)
|
||||
VALUES
|
||||
(100, 100, '2026-07-22 08:30:00'::TIMESTAMP, 'QR_CODE', 'SUCCESS', 'MINI_PROGRAM', NOW(), NOW()),
|
||||
(101, 101, '2026-07-22 09:00:00'::TIMESTAMP, 'QR_CODE', 'SUCCESS', 'MINI_PROGRAM', NOW(), NOW()),
|
||||
(102, 100, '2026-07-22 18:00:00'::TIMESTAMP, 'MANUAL', 'SUCCESS', 'PC_BACKEND', NOW(), NOW())
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
-- ============================================
|
||||
-- 8. 重置序列值,避免后续插入冲突
|
||||
-- ============================================
|
||||
SELECT setval('sys_user_id_seq', GREATEST(COALESCE((SELECT MAX(id) FROM sys_user), 1), 101));
|
||||
SELECT setval('group_course_type_id_seq', GREATEST(COALESCE((SELECT MAX(id) FROM group_course_type), 1), 103));
|
||||
SELECT setval('course_label_id_seq', GREATEST(COALESCE((SELECT MAX(id) FROM course_label), 1), 105));
|
||||
SELECT setval('group_course_id_seq', GREATEST(COALESCE((SELECT MAX(id) FROM group_course), 1), 103));
|
||||
SELECT setval('member_user_id_seq', GREATEST(COALESCE((SELECT MAX(id) FROM member_user), 1), 101));
|
||||
SELECT setval('sign_in_record_id_seq', GREATEST(COALESCE((SELECT MAX(id) FROM sign_in_record), 1), 102));
|
||||
@@ -0,0 +1,270 @@
|
||||
/**
|
||||
* 全流程端到端测试 - 后台管理系统 UI 层
|
||||
*
|
||||
* 使用 Playwright 驱动浏览器操作 Element Plus 后台管理页面:
|
||||
* 1. 利用auth.setup已有的登录态直接进入
|
||||
* 2. 导航到团课管理
|
||||
* 3. 创建新团课(无封面、张教练负责)
|
||||
* 4. 验证创建成功
|
||||
* 5. 保存课程ID供后续流程使用
|
||||
*/
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { GroupCourseManagementPage } from '../pages/GroupCourseManagementPage';
|
||||
import { LoginPage } from '../pages/LoginPage';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
|
||||
// 共享状态文件路径(供后续API测试读取)
|
||||
const STATE_FILE = path.resolve(__dirname, '../../..', 'test-context-ui.json');
|
||||
|
||||
test.describe.serial('全流程 - 后台管理UI', () => {
|
||||
let coursePage: GroupCourseManagementPage;
|
||||
const timestamp = Date.now();
|
||||
const courseName = `UI全流程_${timestamp.toString(36)}`;
|
||||
|
||||
const startDate = new Date(Date.now() + 5 * 60 * 60 * 1000);
|
||||
const endDate = new Date(startDate.getTime() + 60 * 60 * 1000);
|
||||
const pad = (n: number) => String(n).padStart(2, '0');
|
||||
const startTimeStr = `${startDate.getFullYear()}-${pad(startDate.getMonth()+1)}-${pad(startDate.getDate())} ${pad(startDate.getHours())}:${pad(startDate.getMinutes())}:00`;
|
||||
const endTimeStr = `${endDate.getFullYear()}-${pad(endDate.getMonth()+1)}-${pad(endDate.getDate())} ${pad(endDate.getHours())}:${pad(endDate.getMinutes())}:00`;
|
||||
|
||||
function writeState(data: Record<string, unknown>) {
|
||||
try {
|
||||
fs.writeFileSync(STATE_FILE, JSON.stringify(data, null, 2), 'utf-8');
|
||||
console.log('[UI] 状态已写入:', STATE_FILE);
|
||||
} catch (e) {
|
||||
console.error('[UI] 写入状态文件失败:', e);
|
||||
}
|
||||
}
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
coursePage = new GroupCourseManagementPage(page);
|
||||
});
|
||||
|
||||
test('TC-UI-001: 验证已登录状态并导航到仪表盘', async ({ page }) => {
|
||||
await test.step('导航到首页(利用auth.setup的登录态)', async () => {
|
||||
await page.goto('/');
|
||||
await page.waitForLoadState('networkidle');
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
const currentUrl = page.url();
|
||||
console.log('[UI] 当前URL:', currentUrl);
|
||||
|
||||
// 如果跳转到登录页,手动登录
|
||||
if (currentUrl.includes('/login')) {
|
||||
console.log('[UI] 需要手动登录...');
|
||||
const loginPage = new LoginPage(page);
|
||||
await loginPage.usernameInput.fill('admin');
|
||||
await loginPage.passwordInput.fill('Test@123');
|
||||
await loginPage.loginButton.click();
|
||||
await page.waitForURL(/\/(dashboard|\/)$/, { timeout: 30000 });
|
||||
console.log('[UI] 手动登录完成');
|
||||
} else {
|
||||
console.log('[UI] 已处于登录状态');
|
||||
}
|
||||
|
||||
await page.waitForLoadState('networkidle');
|
||||
});
|
||||
|
||||
await test.step('保存admin token到共享状态', async () => {
|
||||
const token = await page.evaluate(() => localStorage.getItem('token') || '');
|
||||
writeState({
|
||||
adminToken: token,
|
||||
courseName: courseName,
|
||||
timestamp: timestamp,
|
||||
baseUrl: 'http://192.168.110.64:8084',
|
||||
});
|
||||
console.log('[UI] Token已保存, 长度:', token.length);
|
||||
});
|
||||
});
|
||||
|
||||
test('TC-UI-002: 导航到团课管理页面', async ({ page }) => {
|
||||
await test.step('进入团课管理', async () => {
|
||||
// 点击侧边栏
|
||||
const sidebarLink = page.locator('.el-menu-item').filter({ hasText: '团课管理' })
|
||||
.or(page.locator('span').filter({ hasText: '团课管理' }));
|
||||
|
||||
const count = await sidebarLink.count();
|
||||
if (count > 0) {
|
||||
await sidebarLink.first().click();
|
||||
} else {
|
||||
// 检查是否有子菜单
|
||||
const subMenu = page.locator('.el-sub-menu__title').filter({ hasText: '课程' })
|
||||
.or(page.locator('.el-sub-menu__title').filter({ hasText: '团课' }));
|
||||
if (await subMenu.count() > 0) {
|
||||
await subMenu.first().click();
|
||||
await page.waitForTimeout(500);
|
||||
const menuItem = page.locator('.el-menu-item').filter({ hasText: '团课管理' });
|
||||
if (await menuItem.count() > 0) {
|
||||
await menuItem.first().click();
|
||||
}
|
||||
}
|
||||
}
|
||||
console.log('[UI] 已点击团课管理导航');
|
||||
});
|
||||
|
||||
await test.step('等待页面加载', async () => {
|
||||
await page.waitForLoadState('networkidle');
|
||||
await page.waitForTimeout(1500);
|
||||
console.log('[UI] 当前URL:', page.url());
|
||||
|
||||
// 截图验证
|
||||
await page.screenshot({ path: `test-results/course-page-${timestamp}.png` });
|
||||
});
|
||||
});
|
||||
|
||||
test('TC-UI-003: 创建团课 - 打开弹窗→填写表单→提交', async ({ page }) => {
|
||||
const dialog = page.locator('.el-dialog');
|
||||
|
||||
await test.step('3a. 确保在团课管理页面', async () => {
|
||||
if (!page.url().includes('course')) {
|
||||
await page.goto('/courses');
|
||||
await page.waitForLoadState('networkidle');
|
||||
await page.waitForTimeout(1000);
|
||||
}
|
||||
});
|
||||
|
||||
await test.step('3b. 点击新增团课按钮打开弹窗', async () => {
|
||||
let clicked = false;
|
||||
const btn1 = page.getByRole('button', { name: '新增团课' });
|
||||
if (await btn1.count() > 0) { await btn1.first().click(); clicked = true; }
|
||||
if (!clicked) {
|
||||
const btn2 = page.locator('button:has-text("新增团课")');
|
||||
if (await btn2.count() > 0) { await btn2.first().click(); clicked = true; }
|
||||
}
|
||||
if (!clicked) {
|
||||
const btn3 = page.locator('button:has-text("新增")').first();
|
||||
if (await btn3.count() > 0) { await btn3.click(); clicked = true; }
|
||||
}
|
||||
if (!clicked) throw new Error('未找到新增团课按钮');
|
||||
|
||||
await page.waitForTimeout(800);
|
||||
const isVisible = await dialog.isVisible().catch(() => false);
|
||||
console.log('[UI] 弹窗可见:', isVisible);
|
||||
await page.screenshot({ path: `test-results/dialog-${timestamp}.png` });
|
||||
});
|
||||
|
||||
await test.step('3c. 填写课程名称', async () => {
|
||||
const nameInput = dialog.locator('.el-form-item').filter({ hasText: '课程名称' }).locator('input');
|
||||
if (await nameInput.count() > 0) {
|
||||
await nameInput.fill(courseName);
|
||||
} else {
|
||||
await dialog.locator('input').first().fill(courseName);
|
||||
}
|
||||
console.log('[UI] 课程名称:', courseName);
|
||||
});
|
||||
|
||||
await test.step('3d. 选择教练 - 张教练', async () => {
|
||||
const coachSelect = dialog.locator('.el-form-item').filter({ hasText: '教练' }).locator('.el-select');
|
||||
if (await coachSelect.count() > 0) {
|
||||
await coachSelect.click();
|
||||
await page.waitForTimeout(600);
|
||||
const options = page.locator('.el-select-dropdown__item').locator('visible=true');
|
||||
const optCount = await options.count();
|
||||
let found = false;
|
||||
for (let i = 0; i < optCount; i++) {
|
||||
const text = await options.nth(i).textContent();
|
||||
if (text && text.includes('张')) {
|
||||
await options.nth(i).click();
|
||||
console.log('[UI] 已选张教练:', text.trim());
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!found && optCount > 0) { await options.first().click(); }
|
||||
await page.waitForTimeout(300);
|
||||
}
|
||||
});
|
||||
|
||||
await test.step('3e. 填写开始/结束时间', async () => {
|
||||
const startInput = dialog.locator('.el-form-item').filter({ hasText: '开始时间' }).locator('input');
|
||||
if (await startInput.count() > 0) {
|
||||
await startInput.click(); await page.waitForTimeout(300);
|
||||
await startInput.fill(startTimeStr);
|
||||
}
|
||||
const endInput = dialog.locator('.el-form-item').filter({ hasText: '结束时间' }).locator('input');
|
||||
if (await endInput.count() > 0) {
|
||||
await endInput.click(); await page.waitForTimeout(300);
|
||||
await endInput.fill(endTimeStr);
|
||||
}
|
||||
console.log('[UI] 时间:', startTimeStr, '~', endTimeStr);
|
||||
});
|
||||
|
||||
await test.step('3f. 填写地点(无封面)', async () => {
|
||||
const locInput = dialog.locator('.el-form-item').filter({ hasText: '地点' }).locator('input');
|
||||
if (await locInput.count() > 0) { await locInput.fill('UI自动化测试场地'); }
|
||||
console.log('[UI] 封面留空 — 无封面');
|
||||
});
|
||||
|
||||
await test.step('3g. 点击确定提交', async () => {
|
||||
const submitBtn = dialog.getByRole('button', { name: '确定' })
|
||||
.or(dialog.locator('button:has-text("确定")'))
|
||||
.or(dialog.locator('.el-button--primary').last());
|
||||
await submitBtn.click();
|
||||
console.log('[UI] 已提交');
|
||||
});
|
||||
|
||||
await test.step('3h. 等待响应并截图', async () => {
|
||||
await page.waitForTimeout(2000);
|
||||
await page.screenshot({ path: `test-results/after-submit-${timestamp}.png` });
|
||||
const successMsg = page.locator('.el-message--success');
|
||||
if (await successMsg.count() > 0) {
|
||||
console.log('[UI] 成功:', await successMsg.first().textContent());
|
||||
}
|
||||
});
|
||||
|
||||
await test.step('3h5. 处理时间冲突警告弹窗(如有)', async () => {
|
||||
// 检查是否有 "时间冲突警告" 弹窗
|
||||
const conflictDialog = page.locator('.el-message-box').filter({ hasText: '时间冲突警告' });
|
||||
const warningDialog = page.locator('.el-overlay-message-box').filter({ hasText: '时间冲突' });
|
||||
|
||||
if ((await conflictDialog.count() > 0) || (await warningDialog.count() > 0)) {
|
||||
console.log('[UI] 检测到时间冲突警告弹窗,尝试关闭...');
|
||||
await page.screenshot({ path: `test-results/time-conflict-dialog-${timestamp}.png` });
|
||||
|
||||
// 点击弹窗中的确定按钮
|
||||
const confirmBtn = page.locator('.el-message-box__btns button').first()
|
||||
.or(page.locator('.el-overlay-message-box button:has-text("确定")'))
|
||||
.or(page.locator('.el-overlay-message-box button:has-text("确认")'))
|
||||
.or(page.locator('.el-message-box button:has-text("确定")'));
|
||||
|
||||
if (await confirmBtn.count() > 0) {
|
||||
await confirmBtn.click();
|
||||
console.log('[UI] 已关闭时间冲突警告弹窗');
|
||||
} else {
|
||||
await page.keyboard.press('Escape');
|
||||
console.log('[UI] 通过Escape关闭弹窗');
|
||||
}
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
// 按 Escape 关闭可能残留的其他遮罩(如 select 下拉、modal 遮罩)
|
||||
await page.keyboard.press('Escape');
|
||||
await page.waitForTimeout(500);
|
||||
console.log('[UI] 已按Escape清除残留遮罩');
|
||||
} else {
|
||||
console.log('[UI] 无时间冲突警告弹窗');
|
||||
// 仍然按 Escape 以防有其他遮罩
|
||||
await page.keyboard.press('Escape');
|
||||
await page.waitForTimeout(300);
|
||||
}
|
||||
});
|
||||
|
||||
await test.step('3i. 搜索验证课程', async () => {
|
||||
const searchInput = page.locator('input[placeholder*="搜索"]').first();
|
||||
if (await searchInput.count() > 0) {
|
||||
await searchInput.fill(courseName);
|
||||
const searchBtn = page.locator('button:has-text("搜索")').first();
|
||||
if (await searchBtn.count() > 0) await searchBtn.click();
|
||||
await page.waitForTimeout(2000);
|
||||
await page.screenshot({ path: `test-results/search-result-${timestamp}.png` });
|
||||
console.log('[UI] 已搜索:', courseName);
|
||||
}
|
||||
});
|
||||
|
||||
console.log('[UI] ====== 后台管理UI测试完成 ======');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { CheckInManagementPage } from '../pages/CheckInManagementPage';
|
||||
import { StatisticsDashboardPage } from '../pages/StatisticsDashboardPage';
|
||||
|
||||
test.describe('签到管理和数据统计验证', () => {
|
||||
let checkInPage: CheckInManagementPage;
|
||||
let statsPage: StatisticsDashboardPage;
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
checkInPage = new CheckInManagementPage(page);
|
||||
statsPage = new StatisticsDashboardPage(page);
|
||||
});
|
||||
|
||||
test('签到记录查看', async ({ page }) => {
|
||||
await test.step('导航到数据统计页面', async () => {
|
||||
await checkInPage.goto();
|
||||
});
|
||||
|
||||
await test.step('验证签到统计区域可见', async () => {
|
||||
await checkInPage.verifySignInDataLoaded();
|
||||
console.log('签到统计区域数据已加载');
|
||||
});
|
||||
|
||||
await test.step('获取签到数据', async () => {
|
||||
const totalSignIns = await checkInPage.getTotalSignIns();
|
||||
const successSignIns = await checkInPage.getSuccessSignIns();
|
||||
console.log(`总签到: ${totalSignIns}, 成功: ${successSignIns}`);
|
||||
});
|
||||
});
|
||||
|
||||
test('数据统计页面数据加载验证', async ({ page }) => {
|
||||
await test.step('导航到数据统计页面', async () => {
|
||||
await statsPage.goto();
|
||||
});
|
||||
|
||||
await test.step('验证统计卡片加载', async () => {
|
||||
await statsPage.verifyStatCardsLoaded();
|
||||
const memberCount = await statsPage.getCardValue(0);
|
||||
const activeMember = await statsPage.getCardValue(1);
|
||||
const bookings = await statsPage.getCardValue(2);
|
||||
const violations = await statsPage.getCardValue(3);
|
||||
console.log(`会员总数: ${memberCount}, 活跃会员: ${activeMember}, 预约总数: ${bookings}, 教练违规: ${violations}`);
|
||||
});
|
||||
|
||||
await test.step('验证详细统计区域加载', async () => {
|
||||
await statsPage.verifySectionCardsLoaded();
|
||||
});
|
||||
|
||||
await test.step('切换统计区间', async () => {
|
||||
await statsPage.switchRange('本周');
|
||||
console.log('已切换到本周统计');
|
||||
});
|
||||
|
||||
await test.step('验证切换区间后卡片仍可见', async () => {
|
||||
await expect(statsPage.statCards.first()).toBeVisible({ timeout: 10000 });
|
||||
console.log('切换区间后数据已刷新');
|
||||
});
|
||||
|
||||
await test.step('切换到教练业绩Tab', async () => {
|
||||
await statsPage.switchTab('教练业绩');
|
||||
const coachTable = page.locator('.el-table');
|
||||
await expect(coachTable).toBeVisible({ timeout: 10000 });
|
||||
console.log('教练业绩Tab数据已加载');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,122 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { CoachManagementPage } from '../pages/CoachManagementPage';
|
||||
|
||||
test.describe.serial('教练管理完整CRUD旅程', () => {
|
||||
let coachPage: CoachManagementPage;
|
||||
const timestamp = Date.now();
|
||||
const coachUsername = `testcoach_${timestamp}`.slice(0, 50); // 确保用户名合法
|
||||
const coachNickname = `测试教练_${timestamp}`;
|
||||
const coachEmail = `coach_${timestamp}@test.com`;
|
||||
const coachPhone = `138${String(timestamp).slice(-8)}`;
|
||||
const updatedNickname = `编辑教练_${timestamp}`;
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
coachPage = new CoachManagementPage(page);
|
||||
});
|
||||
|
||||
test('教练列表显示', async ({ page }) => {
|
||||
await test.step('导航到教练管理页面', async () => {
|
||||
await coachPage.goto();
|
||||
});
|
||||
|
||||
await test.step('验证表格显示', async () => {
|
||||
await expect(coachPage.table).toBeVisible({ timeout: 10000 });
|
||||
});
|
||||
|
||||
await test.step('验证数据加载', async () => {
|
||||
const rowCount = await coachPage.getTableRowCount();
|
||||
console.log(`教练列表包含 ${rowCount} 条记录`);
|
||||
expect(rowCount).toBeGreaterThanOrEqual(0);
|
||||
});
|
||||
});
|
||||
|
||||
test('创建新教练', async ({ page }) => {
|
||||
await test.step('导航到教练管理页面', async () => {
|
||||
await coachPage.goto();
|
||||
});
|
||||
|
||||
await test.step('点击新增教练按钮', async () => {
|
||||
await coachPage.clickAdd();
|
||||
});
|
||||
|
||||
await test.step('填写教练表单', async () => {
|
||||
await coachPage.fillCoachForm({
|
||||
username: coachUsername,
|
||||
password: 'Test@1234',
|
||||
nickname: coachNickname,
|
||||
email: coachEmail,
|
||||
phone: coachPhone,
|
||||
});
|
||||
console.log(`教练表单已填写: ${coachUsername}`);
|
||||
});
|
||||
|
||||
await test.step('提交表单', async () => {
|
||||
await coachPage.submitForm();
|
||||
});
|
||||
|
||||
await test.step('验证创建成功', async () => {
|
||||
await coachPage.waitForDialogClose();
|
||||
console.log(`教练 ${coachUsername} 创建完成`);
|
||||
});
|
||||
});
|
||||
|
||||
test('编辑教练信息', async ({ page }) => {
|
||||
await test.step('导航到教练管理页面', async () => {
|
||||
await coachPage.goto();
|
||||
});
|
||||
|
||||
await test.step('搜索刚创建的教练', async () => {
|
||||
await coachPage.search(coachUsername);
|
||||
});
|
||||
|
||||
await test.step('等待数据加载', async () => {
|
||||
await page.waitForTimeout(500);
|
||||
await expect(coachPage.table).toBeVisible({ timeout: 10000 });
|
||||
});
|
||||
|
||||
await test.step('点击编辑按钮', async () => {
|
||||
const rows = await coachPage.getTableRowCount();
|
||||
if (rows > 0) {
|
||||
await coachPage.clickEditOnFirstRow();
|
||||
console.log('编辑弹窗已打开');
|
||||
} else {
|
||||
console.log('未找到刚创建的教练,跳过编辑测试');
|
||||
test.skip();
|
||||
}
|
||||
});
|
||||
|
||||
await test.step('修改教练昵称和邮箱', async () => {
|
||||
await coachPage.nicknameInput.clear();
|
||||
await coachPage.nicknameInput.fill(updatedNickname);
|
||||
console.log(`教练昵称已修改为: ${updatedNickname}`);
|
||||
});
|
||||
|
||||
await test.step('提交表单', async () => {
|
||||
await coachPage.submitForm();
|
||||
});
|
||||
|
||||
await test.step('验证更新成功', async () => {
|
||||
await coachPage.waitForDialogClose();
|
||||
console.log('教练信息更新完成');
|
||||
});
|
||||
});
|
||||
|
||||
test('搜索验证', async ({ page }) => {
|
||||
await test.step('导航到教练管理页面', async () => {
|
||||
await coachPage.goto();
|
||||
});
|
||||
|
||||
await test.step('搜索编辑后的教练', async () => {
|
||||
await coachPage.search(coachUsername);
|
||||
console.log(`搜索教练: ${coachUsername}`);
|
||||
});
|
||||
|
||||
await test.step('验证搜索结果', async () => {
|
||||
await page.waitForTimeout(500);
|
||||
const rowCount = await coachPage.getTableRowCount();
|
||||
console.log(`搜索结果显示 ${rowCount} 条记录`);
|
||||
// 搜索结果可能为0(如果后端是全量返回+前端过滤),但至少验证页面没有崩溃
|
||||
expect(rowCount).toBeGreaterThanOrEqual(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,152 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { GroupCourseManagementPage } from '../pages/GroupCourseManagementPage';
|
||||
|
||||
test.describe.serial('团课管理完整CRUD旅程', () => {
|
||||
let coursePage: GroupCourseManagementPage;
|
||||
const timestamp = Date.now();
|
||||
const courseName = `自动化测试团课_${timestamp}`;
|
||||
const updatedCourseName = `编辑团课_${timestamp}`;
|
||||
const location = `测试地点_${timestamp}`;
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
coursePage = new GroupCourseManagementPage(page);
|
||||
});
|
||||
|
||||
test('团课列表显示', async ({ page }) => {
|
||||
await test.step('导航到团课管理页面', async () => {
|
||||
await coursePage.goto();
|
||||
});
|
||||
|
||||
await test.step('验证表格显示', async () => {
|
||||
await expect(coursePage.table).toBeVisible({ timeout: 10000 });
|
||||
});
|
||||
|
||||
await test.step('验证数据加载', async () => {
|
||||
const rowCount = await coursePage.getTableRowCount();
|
||||
console.log(`团课列表包含 ${rowCount} 条记录`);
|
||||
expect(rowCount).toBeGreaterThanOrEqual(0);
|
||||
});
|
||||
});
|
||||
|
||||
test('创建新团课', async ({ page }) => {
|
||||
await test.step('导航到团课管理页面', async () => {
|
||||
await coursePage.goto();
|
||||
});
|
||||
|
||||
await test.step('点击新增团课按钮', async () => {
|
||||
await coursePage.clickAdd();
|
||||
});
|
||||
|
||||
await test.step('填写团课表单', async () => {
|
||||
// 获取当前时间后2小时作为开始时间
|
||||
const startDate = new Date(Date.now() + 2 * 60 * 60 * 1000);
|
||||
const startTimeStr = `${startDate.getFullYear()}-${String(startDate.getMonth() + 1).padStart(2, '0')}-${String(startDate.getDate()).padStart(2, '0')} ${String(startDate.getHours()).padStart(2, '0')}:${String(startDate.getMinutes()).padStart(2, '0')}:00`;
|
||||
|
||||
await coursePage.fillCourseForm({
|
||||
courseName: courseName,
|
||||
startTime: startTimeStr,
|
||||
duration: 60,
|
||||
maxMembers: 30,
|
||||
location: location,
|
||||
description: `自动化测试创建的团课_${timestamp}`,
|
||||
});
|
||||
console.log(`团课表单已填写: ${courseName}`);
|
||||
});
|
||||
|
||||
await test.step('提交表单', async () => {
|
||||
await coursePage.submitForm();
|
||||
});
|
||||
|
||||
await test.step('验证创建成功', async () => {
|
||||
await coursePage.waitForDialogClose();
|
||||
console.log(`团课 ${courseName} 创建完成`);
|
||||
});
|
||||
});
|
||||
|
||||
test('编辑团课信息', async ({ page }) => {
|
||||
await test.step('导航到团课管理页面', async () => {
|
||||
await coursePage.goto();
|
||||
});
|
||||
|
||||
await test.step('搜索刚创建的团课', async () => {
|
||||
await coursePage.search(courseName);
|
||||
});
|
||||
|
||||
await test.step('等待数据加载', async () => {
|
||||
await expect(coursePage.table).toBeVisible({ timeout: 10000 });
|
||||
});
|
||||
|
||||
await test.step('点击编辑按钮', async () => {
|
||||
const rows = await coursePage.getTableRowCount();
|
||||
if (rows > 0) {
|
||||
await coursePage.clickEditOnFirstRow();
|
||||
console.log('编辑弹窗已打开');
|
||||
} else {
|
||||
console.log('未找到刚创建的团课,跳过编辑测试');
|
||||
test.skip();
|
||||
}
|
||||
});
|
||||
|
||||
await test.step('修改团课信息', async () => {
|
||||
// 清空并填入新课程名
|
||||
const nameInput = coursePage.page.locator('.el-dialog').getByPlaceholder('请输入课程名称');
|
||||
await nameInput.clear();
|
||||
await nameInput.fill(updatedCourseName);
|
||||
console.log(`团课名称已修改为: ${updatedCourseName}`);
|
||||
});
|
||||
|
||||
await test.step('提交表单', async () => {
|
||||
await coursePage.submitForm();
|
||||
});
|
||||
|
||||
await test.step('验证更新成功', async () => {
|
||||
await coursePage.waitForDialogClose();
|
||||
console.log('团课信息更新完成');
|
||||
});
|
||||
});
|
||||
|
||||
test('删除团课', async ({ page }) => {
|
||||
await test.step('导航到团课管理页面', async () => {
|
||||
await coursePage.goto();
|
||||
});
|
||||
|
||||
await test.step('搜索编辑后的团课', async () => {
|
||||
await coursePage.search(updatedCourseName);
|
||||
});
|
||||
|
||||
await test.step('等待数据加载', async () => {
|
||||
await expect(coursePage.table).toBeVisible({ timeout: 10000 });
|
||||
});
|
||||
|
||||
await test.step('点击删除按钮', async () => {
|
||||
const rows = await coursePage.getTableRowCount();
|
||||
if (rows > 0) {
|
||||
await coursePage.clickDeleteOnFirstRow();
|
||||
console.log('删除确认对话框已打开');
|
||||
} else {
|
||||
// 尝试用原始名称搜索
|
||||
await coursePage.search(courseName);
|
||||
await page.waitForTimeout(500);
|
||||
const retryRows = await coursePage.getTableRowCount();
|
||||
if (retryRows > 0) {
|
||||
await coursePage.clickDeleteOnFirstRow();
|
||||
console.log('删除确认对话框已打开(使用原始名称搜索)');
|
||||
} else {
|
||||
console.log('未找到可删除的团课,跳过删除测试');
|
||||
test.skip();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
await test.step('确认删除', async () => {
|
||||
await coursePage.confirmDelete();
|
||||
console.log('删除确认完成');
|
||||
});
|
||||
|
||||
await test.step('验证删除成功', async () => {
|
||||
const messageBox = page.locator('.el-message-box');
|
||||
await expect(messageBox).not.toBeVisible({ timeout: 5000 });
|
||||
console.log('团课已删除');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,86 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { MemberManagementPage } from '../pages/MemberManagementPage';
|
||||
|
||||
test.describe.serial('会员管理完整CRUD旅程', () => {
|
||||
let memberPage: MemberManagementPage;
|
||||
const timestamp = Date.now();
|
||||
const searchKeyword = `会员_${timestamp}`;
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
memberPage = new MemberManagementPage(page);
|
||||
});
|
||||
|
||||
test('会员列表显示 + 搜索', async ({ page }) => {
|
||||
await test.step('导航到会员管理页面', async () => {
|
||||
await memberPage.goto();
|
||||
});
|
||||
|
||||
await test.step('验证表格显示', async () => {
|
||||
await expect(memberPage.table).toBeVisible({ timeout: 10000 });
|
||||
});
|
||||
|
||||
await test.step('验证数据加载', async () => {
|
||||
const rowCount = await memberPage.getTableRowCount();
|
||||
console.log(`会员列表包含 ${rowCount} 条记录`);
|
||||
expect(rowCount).toBeGreaterThanOrEqual(0);
|
||||
});
|
||||
|
||||
await test.step('搜索会员', async () => {
|
||||
await memberPage.search('测试');
|
||||
console.log('会员搜索完成');
|
||||
});
|
||||
});
|
||||
|
||||
test('编辑会员信息', async ({ page }) => {
|
||||
await test.step('导航到会员管理页面', async () => {
|
||||
await memberPage.goto();
|
||||
});
|
||||
|
||||
await test.step('等待数据加载', async () => {
|
||||
await expect(memberPage.table).toBeVisible({ timeout: 10000 });
|
||||
});
|
||||
|
||||
await test.step('点击第一个会员的编辑按钮', async () => {
|
||||
const rows = await memberPage.getTableRowCount();
|
||||
if (rows > 0) {
|
||||
await memberPage.clickEditOnFirstRow();
|
||||
console.log('编辑弹窗已打开');
|
||||
} else {
|
||||
console.log('当前没有会员记录,跳过编辑测试');
|
||||
test.skip();
|
||||
}
|
||||
});
|
||||
|
||||
await test.step('修改会员信息', async () => {
|
||||
const newNickname = `编辑昵称_${timestamp}`;
|
||||
await memberPage.fillEditForm(newNickname, '女');
|
||||
console.log(`昵称已修改为: ${newNickname}`);
|
||||
});
|
||||
|
||||
await test.step('提交表单', async () => {
|
||||
await memberPage.submitForm();
|
||||
});
|
||||
|
||||
await test.step('验证更新成功', async () => {
|
||||
await memberPage.waitForDialogClose();
|
||||
console.log('会员信息更新完成');
|
||||
});
|
||||
});
|
||||
|
||||
test('搜索验证编辑后的会员', async ({ page }) => {
|
||||
await test.step('导航到会员管理页面', async () => {
|
||||
await memberPage.goto();
|
||||
});
|
||||
|
||||
await test.step('按昵称搜索', async () => {
|
||||
await memberPage.search(`编辑昵称_${timestamp}`);
|
||||
console.log('搜索编辑后的会员');
|
||||
});
|
||||
|
||||
await test.step('验证搜索结果显示', async () => {
|
||||
const rowCount = await memberPage.getTableRowCount();
|
||||
console.log(`搜索结果显示 ${rowCount} 条记录`);
|
||||
expect(rowCount).toBeGreaterThanOrEqual(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,118 @@
|
||||
import { Page, Locator, expect } from '@playwright/test';
|
||||
|
||||
export class CheckInManagementPage {
|
||||
readonly page: Page;
|
||||
readonly table: Locator;
|
||||
readonly searchInput: Locator;
|
||||
readonly searchButton: Locator;
|
||||
readonly datePicker: Locator;
|
||||
|
||||
constructor(page: Page) {
|
||||
this.page = page;
|
||||
this.table = page.locator('.el-table').first();
|
||||
this.searchInput = page.locator('input[placeholder*="搜索"]').or(page.locator('input[name*="keyword"]'));
|
||||
this.searchButton = page.getByRole('button', { name: '搜索' }).or(page.locator('button:has-text("搜索")'));
|
||||
this.datePicker = page.locator('.el-date-editor').or(page.locator('.el-date-picker'));
|
||||
}
|
||||
|
||||
async goto() {
|
||||
try {
|
||||
console.log('导航到数据统计页面(含签到统计)...');
|
||||
await this.page.goto('/statistics');
|
||||
|
||||
await this.page.waitForURL('**/statistics', { timeout: 30000 });
|
||||
await this.page.waitForLoadState('networkidle');
|
||||
|
||||
// statistics page uses stat cards, not tables
|
||||
const statCard = this.page.locator('.stat-card, .el-card, .summary-card');
|
||||
await statCard.first().waitFor({ state: 'visible', timeout: 15000 });
|
||||
|
||||
await expect(this.page).toHaveURL(/.*statistics/);
|
||||
|
||||
console.log('数据统计页面加载完成(签到管理查看)');
|
||||
} catch (error) {
|
||||
await this.page.screenshot({ path: `test-results/checkin-management-error-${Date.now()}.png` });
|
||||
console.error('导航到签到管理页面失败:', error);
|
||||
throw new Error(`导航到签到管理页面失败: ${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
async waitForTableReady() {
|
||||
await this.table.waitFor({ state: 'visible', timeout: 10000 });
|
||||
|
||||
await this.page.waitForFunction(
|
||||
() => {
|
||||
const rows = document.querySelectorAll('.el-table__body-wrapper tbody tr');
|
||||
return rows.length > 0;
|
||||
},
|
||||
{ timeout: 5000 }
|
||||
).catch(() => {
|
||||
console.log('表格没有数据,继续执行');
|
||||
});
|
||||
}
|
||||
|
||||
async searchByDate(date: string) {
|
||||
const dateInput = this.datePicker.locator('input').first();
|
||||
if (await dateInput.count() > 0) {
|
||||
await dateInput.click();
|
||||
await this.page.waitForTimeout(300);
|
||||
await dateInput.fill(date);
|
||||
await this.page.waitForTimeout(300);
|
||||
}
|
||||
|
||||
await this.searchButton.click();
|
||||
await this.page.waitForTimeout(500);
|
||||
}
|
||||
|
||||
async verifyTableHasRecords(): Promise<boolean> {
|
||||
try {
|
||||
await this.page.waitForFunction(
|
||||
() => {
|
||||
const rows = document.querySelectorAll('.el-table__body-wrapper tbody tr');
|
||||
return rows.length > 0;
|
||||
},
|
||||
{ timeout: 5000 }
|
||||
);
|
||||
return true;
|
||||
} catch {
|
||||
console.log('签到表格没有记录');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async getRecordCount(): Promise<number> {
|
||||
return await this.table.locator('tbody tr').count();
|
||||
}
|
||||
|
||||
async containsText(text: string): Promise<boolean> {
|
||||
return await this.table.getByText(text).count() > 0;
|
||||
}
|
||||
|
||||
async verifySignInDataLoaded(): Promise<boolean> {
|
||||
try {
|
||||
const signInSection = this.page.locator('.stat-card, .el-card, .signin-section, .chart-container');
|
||||
await signInSection.first().waitFor({ state: 'visible', timeout: 10000 });
|
||||
console.log('签到数据区域已加载');
|
||||
return true;
|
||||
} catch {
|
||||
console.log('签到数据区域未找到,尝试备选方式');
|
||||
return await this.verifyTableHasRecords();
|
||||
}
|
||||
}
|
||||
|
||||
async getTotalSignIns(): Promise<string> {
|
||||
const statCards = this.page.locator('.stat-card');
|
||||
if (await statCards.count() > 0) {
|
||||
return (await statCards.nth(0).textContent()) || '0';
|
||||
}
|
||||
return 'N/A';
|
||||
}
|
||||
|
||||
async getSuccessSignIns(): Promise<string> {
|
||||
const statCards = this.page.locator('.stat-card');
|
||||
if (await statCards.count() > 1) {
|
||||
return (await statCards.nth(1).textContent()) || '0';
|
||||
}
|
||||
return 'N/A';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
import { Page, Locator, expect } from '@playwright/test';
|
||||
|
||||
export class CoachManagementPage {
|
||||
readonly page: Page;
|
||||
readonly table: Locator;
|
||||
readonly createButton: Locator;
|
||||
readonly searchInput: Locator;
|
||||
readonly searchButton: Locator;
|
||||
readonly successMessage: Locator;
|
||||
readonly nicknameInput: Locator;
|
||||
|
||||
constructor(page: Page) {
|
||||
this.page = page;
|
||||
this.table = page.locator('.el-table').first();
|
||||
this.createButton = page.getByRole('button', { name: '新增教练' }).or(page.locator('button:has-text("新增教练")'));
|
||||
this.searchInput = page.locator('input[placeholder*="搜索教练"]').or(page.locator('input[placeholder*="搜索"]'));
|
||||
this.searchButton = page.getByRole('button', { name: '搜索' }).or(page.locator('button:has-text("搜索")'));
|
||||
this.successMessage = page.locator('.el-message--success').or(page.locator('.success-message'));
|
||||
this.nicknameInput = page.locator('.el-dialog .el-form-item').filter({ hasText: '昵称' }).or(page.locator('.el-dialog .el-form-item').filter({ hasText: '姓名' })).locator('input');
|
||||
}
|
||||
|
||||
async goto() {
|
||||
try {
|
||||
console.log('导航到教练管理页面...');
|
||||
await this.page.goto('/coach');
|
||||
|
||||
await this.page.waitForURL('**/coach', { timeout: 30000 });
|
||||
await this.page.waitForLoadState('networkidle');
|
||||
|
||||
await this.table.waitFor({ state: 'visible', timeout: 15000 });
|
||||
|
||||
await expect(this.page).toHaveURL(/.*coach/);
|
||||
|
||||
console.log('教练管理页面加载完成');
|
||||
} catch (error) {
|
||||
await this.page.screenshot({ path: `test-results/coach-management-error-${Date.now()}.png` });
|
||||
|
||||
console.error('导航到教练管理页面失败:', error);
|
||||
|
||||
throw new Error(`导航到教练管理页面失败: ${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
async waitForTableReady() {
|
||||
await this.table.waitFor({ state: 'visible', timeout: 10000 });
|
||||
|
||||
await this.page.waitForFunction(
|
||||
() => {
|
||||
const rows = document.querySelectorAll('.el-table__body-wrapper tbody tr');
|
||||
return rows.length > 0;
|
||||
},
|
||||
{ timeout: 5000 }
|
||||
).catch(() => {
|
||||
console.log('表格没有数据,继续执行');
|
||||
});
|
||||
}
|
||||
|
||||
async clickCreate() {
|
||||
await this.createButton.click();
|
||||
await this.page.waitForTimeout(500);
|
||||
}
|
||||
|
||||
async fillCoachForm(coachData: {
|
||||
name?: string;
|
||||
nickname?: string;
|
||||
phone?: string;
|
||||
email?: string;
|
||||
username?: string;
|
||||
password?: string;
|
||||
}) {
|
||||
// Normalize property names from spec
|
||||
const name = coachData.name || coachData.nickname || '';
|
||||
const phone = coachData.phone || '';
|
||||
const email = coachData.email || '';
|
||||
const username = coachData.username || '';
|
||||
const password = coachData.password || '';
|
||||
|
||||
const dialog = this.page.locator('.el-dialog');
|
||||
|
||||
// 姓名 / 昵称
|
||||
const nameInput = dialog.locator('.el-form-item').filter({ hasText: '姓名' }).or(dialog.locator('.el-form-item').filter({ hasText: '昵称' })).locator('input');
|
||||
if (await nameInput.count() > 0) {
|
||||
await nameInput.fill(name);
|
||||
} else if (await dialog.locator('input').count() > 0) {
|
||||
await dialog.locator('input').first().fill(name);
|
||||
}
|
||||
|
||||
// 手机号
|
||||
const phoneInput = dialog.locator('.el-form-item').filter({ hasText: '手机' }).locator('input');
|
||||
if (await phoneInput.count() > 0) {
|
||||
await phoneInput.fill(phone);
|
||||
} else if (await dialog.locator('input').count() > 1) {
|
||||
await dialog.locator('input').nth(1).fill(phone);
|
||||
}
|
||||
|
||||
// 邮箱
|
||||
const emailInput = dialog.locator('.el-form-item').filter({ hasText: '邮箱' }).locator('input');
|
||||
if (await emailInput.count() > 0) {
|
||||
await emailInput.fill(email);
|
||||
} else if (await dialog.locator('input').count() > 2) {
|
||||
await dialog.locator('input').nth(2).fill(email);
|
||||
}
|
||||
|
||||
// 用户名
|
||||
const usernameInput = dialog.locator('.el-form-item').filter({ hasText: '用户名' }).locator('input');
|
||||
if (await usernameInput.count() > 0) {
|
||||
await usernameInput.fill(username);
|
||||
} else if (await dialog.locator('input').count() > 3) {
|
||||
await dialog.locator('input').nth(3).fill(username);
|
||||
}
|
||||
|
||||
// 密码
|
||||
const passwordInput = dialog.locator('input[type="password"]');
|
||||
if (await passwordInput.count() > 0) {
|
||||
await passwordInput.fill(password);
|
||||
}
|
||||
}
|
||||
|
||||
async submitForm() {
|
||||
const dialog = this.page.locator('.el-dialog');
|
||||
const submitButton = dialog.getByRole('button', { name: '确定' }).or(dialog.locator('button:has-text("确定")'));
|
||||
|
||||
await submitButton.click();
|
||||
|
||||
await this.page.waitForTimeout(1000);
|
||||
}
|
||||
|
||||
async waitForSuccessMessage(timeout: number = 10000): Promise<boolean> {
|
||||
try {
|
||||
const message = this.page.locator('.el-message--success').or(this.page.locator('.el-message'));
|
||||
await message.waitFor({ state: 'visible', timeout });
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.log('等待成功消息超时,检查是否有错误消息');
|
||||
|
||||
try {
|
||||
const errorMessage = this.page.locator('.el-message--error').or(this.page.locator('.el-message--warning'));
|
||||
if (await errorMessage.count() > 0) {
|
||||
const errorText = await errorMessage.first().textContent();
|
||||
console.log('发现错误消息:', errorText);
|
||||
}
|
||||
} catch (e) {
|
||||
console.log('没有发现错误消息');
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async editCoach(rowNumber: number) {
|
||||
await this.table.locator(`tbody tr:nth-child(${rowNumber})`).getByRole('button', { name: '编辑' }).or(this.page.locator(`tbody tr:nth-child(${rowNumber}) .edit-button`)).click();
|
||||
}
|
||||
|
||||
async deleteCoach(rowNumber: number) {
|
||||
await this.table.locator(`tbody tr:nth-child(${rowNumber})`).getByRole('button', { name: '删除' }).or(this.page.locator(`tbody tr:nth-child(${rowNumber}) .delete-button`)).click();
|
||||
}
|
||||
|
||||
async confirmDelete() {
|
||||
const confirmDialog = this.page.locator('.el-message-box');
|
||||
await confirmDialog.getByRole('button', { name: '确定' }).or(confirmDialog.locator('button:has-text("确定")')).click();
|
||||
}
|
||||
|
||||
async search(keyword: string) {
|
||||
await this.searchInput.fill(keyword);
|
||||
await this.searchButton.click();
|
||||
}
|
||||
|
||||
async isSuccessMessageVisible(): Promise<boolean> {
|
||||
try {
|
||||
return await this.successMessage.isVisible({ timeout: 3000 });
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async clickAdd(): Promise<void> {
|
||||
await this.clickCreate();
|
||||
}
|
||||
|
||||
async waitForDialogClose(): Promise<void> {
|
||||
await this.page.waitForTimeout(1000);
|
||||
await this.page.locator('.el-dialog').waitFor({ state: 'hidden', timeout: 10000 }).catch(() => {
|
||||
console.log('对话框可能已关闭或不存在');
|
||||
});
|
||||
}
|
||||
|
||||
async clickEditOnFirstRow(): Promise<void> {
|
||||
await this.editCoach(1);
|
||||
}
|
||||
|
||||
async getCoachCount(): Promise<number> {
|
||||
return await this.table.locator('tbody tr').count();
|
||||
}
|
||||
|
||||
async getTableRowCount(): Promise<number> {
|
||||
return await this.table.locator('tbody tr').count();
|
||||
}
|
||||
|
||||
async containsText(text: string): Promise<boolean> {
|
||||
return await this.table.getByText(text).count() > 0;
|
||||
}
|
||||
}
|
||||
@@ -124,6 +124,54 @@ export class DashboardPage {
|
||||
await this.page.waitForLoadState('networkidle');
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Gym 业务模块导航方法
|
||||
// ============================================
|
||||
|
||||
async navigateToMemberManagement() {
|
||||
const memberMenu = this.page.locator('.el-sub-menu__title:has-text("会员管理")');
|
||||
await memberMenu.click();
|
||||
await this.page.waitForTimeout(1000);
|
||||
const memberMenuItem = this.page.locator('.el-menu-item:has-text("会员管理")');
|
||||
await memberMenuItem.click();
|
||||
await this.page.waitForURL('**/members', { timeout: 30000 });
|
||||
await this.page.waitForLoadState('networkidle');
|
||||
}
|
||||
|
||||
async navigateToGroupCourseManagement() {
|
||||
const courseMenu = this.page.locator('.el-sub-menu__title:has-text("团课管理")');
|
||||
await courseMenu.click();
|
||||
await this.page.waitForTimeout(1000);
|
||||
const courseMenuItem = this.page.locator('.el-menu-item:has-text("团课管理")');
|
||||
await courseMenuItem.click();
|
||||
await this.page.waitForURL('**/group-courses', { timeout: 30000 });
|
||||
await this.page.waitForLoadState('networkidle');
|
||||
}
|
||||
|
||||
async navigateToCoachManagement() {
|
||||
const coachMenuItem = this.page.locator('.el-menu-item:has-text("教练管理")');
|
||||
await coachMenuItem.click();
|
||||
await this.page.waitForURL('**/coaches', { timeout: 30000 });
|
||||
await this.page.waitForLoadState('networkidle');
|
||||
}
|
||||
|
||||
async navigateToCheckInManagement() {
|
||||
const checkInMenuItem = this.page.locator('.el-menu-item:has-text("签到管理")');
|
||||
await checkInMenuItem.click();
|
||||
await this.page.waitForURL('**/checkin', { timeout: 30000 });
|
||||
await this.page.waitForLoadState('networkidle');
|
||||
}
|
||||
|
||||
async navigateToStatisticsDashboard() {
|
||||
const statsMenu = this.page.locator('.el-sub-menu__title:has-text("数据统计")');
|
||||
await statsMenu.click();
|
||||
await this.page.waitForTimeout(1000);
|
||||
const statsMenuItem = this.page.locator('.el-menu-item:has-text("数据统计看板")');
|
||||
await statsMenuItem.click();
|
||||
await this.page.waitForURL('**/statistics', { timeout: 30000 });
|
||||
await this.page.waitForLoadState('networkidle');
|
||||
}
|
||||
|
||||
async getUsername(): Promise<string | null> {
|
||||
return await this.userInfo.textContent();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,268 @@
|
||||
import { Page, Locator, expect } from '@playwright/test';
|
||||
|
||||
export class GroupCourseManagementPage {
|
||||
readonly page: Page;
|
||||
readonly table: Locator;
|
||||
readonly createButton: Locator;
|
||||
readonly searchInput: Locator;
|
||||
readonly searchButton: Locator;
|
||||
readonly successMessage: Locator;
|
||||
|
||||
constructor(page: Page) {
|
||||
this.page = page;
|
||||
this.table = page.locator('.el-table').first();
|
||||
this.createButton = page.getByRole('button', { name: '新增团课' }).or(page.locator('button:has-text("新增团课")'));
|
||||
this.searchInput = page.locator('input[placeholder*="搜索课程"]').or(page.locator('input[placeholder*="搜索"]'));
|
||||
this.searchButton = page.getByRole('button', { name: '搜索' }).or(page.locator('button:has-text("搜索")'));
|
||||
this.successMessage = page.locator('.el-message--success').or(page.locator('.success-message'));
|
||||
}
|
||||
|
||||
async goto() {
|
||||
try {
|
||||
console.log('导航到团课管理页面...');
|
||||
await this.page.goto('/courses');
|
||||
|
||||
await this.page.waitForURL('**/courses', { timeout: 30000 });
|
||||
await this.page.waitForLoadState('networkidle');
|
||||
|
||||
await this.table.waitFor({ state: 'visible', timeout: 15000 });
|
||||
|
||||
await expect(this.page).toHaveURL(/.*courses/);
|
||||
|
||||
console.log('团课管理页面加载完成');
|
||||
} catch (error) {
|
||||
await this.page.screenshot({ path: `test-results/group-course-management-error-${Date.now()}.png` });
|
||||
|
||||
console.error('导航到团课管理页面失败:', error);
|
||||
|
||||
throw new Error(`导航到团课管理页面失败: ${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
async waitForTableReady() {
|
||||
await this.table.waitFor({ state: 'visible', timeout: 10000 });
|
||||
|
||||
await this.page.waitForFunction(
|
||||
() => {
|
||||
const rows = document.querySelectorAll('.el-table__body-wrapper tbody tr');
|
||||
return rows.length > 0;
|
||||
},
|
||||
{ timeout: 5000 }
|
||||
).catch(() => {
|
||||
console.log('表格没有数据,继续执行');
|
||||
});
|
||||
}
|
||||
|
||||
async clickCreate() {
|
||||
await this.createButton.click();
|
||||
await this.page.waitForTimeout(500);
|
||||
}
|
||||
|
||||
async fillCourseForm(courseData: {
|
||||
name?: string;
|
||||
courseName?: string;
|
||||
typeId?: string;
|
||||
coachId?: string;
|
||||
startTime?: string;
|
||||
endTime?: string;
|
||||
duration?: number;
|
||||
location?: string;
|
||||
maxMembers?: number | string;
|
||||
description?: string;
|
||||
}) {
|
||||
// Normalize property names from spec to POM
|
||||
const name = courseData.name || courseData.courseName || '';
|
||||
const startTime = courseData.startTime || '';
|
||||
const endTime = courseData.endTime || courseData.startTime || '';
|
||||
const location = courseData.location || '';
|
||||
const maxMembers = String(courseData.maxMembers || '30');
|
||||
const typeId = courseData.typeId;
|
||||
const coachId = courseData.coachId;
|
||||
|
||||
const dialog = this.page.locator('.el-dialog');
|
||||
|
||||
// 课程名称
|
||||
const nameInput = dialog.locator('.el-form-item').filter({ hasText: '课程名称' }).locator('input');
|
||||
if (await nameInput.count() > 0) {
|
||||
await nameInput.fill(name);
|
||||
} else {
|
||||
await dialog.locator('input').first().fill(name);
|
||||
}
|
||||
|
||||
// 课程类型(下拉选择)
|
||||
const typeSelect = dialog.locator('.el-form-item').filter({ hasText: '课程类型' }).locator('.el-select');
|
||||
if (await typeSelect.count() > 0) {
|
||||
await typeSelect.click();
|
||||
await this.page.waitForTimeout(500);
|
||||
|
||||
const dropdown = this.page.locator('.el-select-dropdown:not(.is-hidden)').or(this.page.locator('.el-popper:not([style*="display: none"])'));
|
||||
await dropdown.first().waitFor({ state: 'visible', timeout: 5000 }).catch(() => {
|
||||
console.log('课程类型下拉框未显示,使用body层级定位');
|
||||
});
|
||||
const options = this.page.locator('.el-select-dropdown__item').or(this.page.locator('.el-popper .el-select-dropdown__item'));
|
||||
const visibleOptions = options.locator('visible=true');
|
||||
const optionCount = await visibleOptions.count();
|
||||
|
||||
if (optionCount > 0) {
|
||||
if (typeId) {
|
||||
for (let i = 0; i < optionCount; i++) {
|
||||
const optionText = await visibleOptions.nth(i).textContent();
|
||||
if (optionText && optionText.includes(typeId)) {
|
||||
await visibleOptions.nth(i).click();
|
||||
break;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
await visibleOptions.first().click();
|
||||
}
|
||||
}
|
||||
|
||||
await this.page.waitForTimeout(300);
|
||||
}
|
||||
|
||||
// 教练(下拉选择)
|
||||
const coachSelect = dialog.locator('.el-form-item').filter({ hasText: '教练' }).locator('.el-select');
|
||||
if (await coachSelect.count() > 0) {
|
||||
await coachSelect.click();
|
||||
await this.page.waitForTimeout(500);
|
||||
|
||||
const options = this.page.locator('.el-select-dropdown__item').or(this.page.locator('.el-popper .el-select-dropdown__item'));
|
||||
const visibleOptions = options.locator('visible=true');
|
||||
const optionCount = await visibleOptions.count();
|
||||
|
||||
if (optionCount > 0) {
|
||||
if (coachId) {
|
||||
for (let i = 0; i < optionCount; i++) {
|
||||
const optionText = await visibleOptions.nth(i).textContent();
|
||||
if (optionText && optionText.includes(coachId)) {
|
||||
await visibleOptions.nth(i).click();
|
||||
break;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
await visibleOptions.first().click();
|
||||
}
|
||||
}
|
||||
|
||||
await this.page.waitForTimeout(300);
|
||||
}
|
||||
|
||||
// 开始时间(日期时间选择器)
|
||||
const startTimeInput = dialog.locator('.el-form-item').filter({ hasText: '开始时间' }).locator('input');
|
||||
if (await startTimeInput.count() > 0) {
|
||||
await startTimeInput.click();
|
||||
await this.page.waitForTimeout(300);
|
||||
await startTimeInput.fill(startTime);
|
||||
await this.page.waitForTimeout(300);
|
||||
}
|
||||
|
||||
// 结束时间(日期时间选择器)
|
||||
const endTimeInput = dialog.locator('.el-form-item').filter({ hasText: '结束时间' }).locator('input');
|
||||
if (await endTimeInput.count() > 0) {
|
||||
await endTimeInput.click();
|
||||
await this.page.waitForTimeout(300);
|
||||
await endTimeInput.fill(endTime);
|
||||
await this.page.waitForTimeout(300);
|
||||
}
|
||||
|
||||
// 上课地点
|
||||
const locationInput = dialog.locator('.el-form-item').filter({ hasText: '地点' }).or(dialog.locator('.el-form-item').filter({ hasText: '上课地点' })).locator('input');
|
||||
if (await locationInput.count() > 0) {
|
||||
await locationInput.fill(location);
|
||||
}
|
||||
|
||||
// 人数上限
|
||||
const maxMembersInput = dialog.locator('.el-form-item').filter({ hasText: '人数' }).or(dialog.locator('.el-form-item').filter({ hasText: '上限' })).locator('input');
|
||||
if (await maxMembersInput.count() > 0) {
|
||||
await maxMembersInput.fill(maxMembers);
|
||||
}
|
||||
}
|
||||
|
||||
async submitForm() {
|
||||
const dialog = this.page.locator('.el-dialog');
|
||||
const submitButton = dialog.getByRole('button', { name: '确定' }).or(dialog.locator('button:has-text("确定")'));
|
||||
|
||||
await submitButton.click();
|
||||
|
||||
await this.page.waitForTimeout(1000);
|
||||
}
|
||||
|
||||
async waitForSuccessMessage(timeout: number = 10000): Promise<boolean> {
|
||||
try {
|
||||
const message = this.page.locator('.el-message--success').or(this.page.locator('.el-message'));
|
||||
await message.waitFor({ state: 'visible', timeout });
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.log('等待成功消息超时,检查是否有错误消息');
|
||||
|
||||
try {
|
||||
const errorMessage = this.page.locator('.el-message--error').or(this.page.locator('.el-message--warning'));
|
||||
if (await errorMessage.count() > 0) {
|
||||
const errorText = await errorMessage.first().textContent();
|
||||
console.log('发现错误消息:', errorText);
|
||||
}
|
||||
} catch (e) {
|
||||
console.log('没有发现错误消息');
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async editCourse(rowNumber: number) {
|
||||
await this.table.locator(`tbody tr:nth-child(${rowNumber})`).getByRole('button', { name: '编辑' }).or(this.page.locator(`tbody tr:nth-child(${rowNumber}) .edit-button`)).click();
|
||||
}
|
||||
|
||||
async deleteCourse(rowNumber: number) {
|
||||
await this.table.locator(`tbody tr:nth-child(${rowNumber})`).getByRole('button', { name: '删除' }).or(this.page.locator(`tbody tr:nth-child(${rowNumber}) .delete-button`)).click();
|
||||
}
|
||||
|
||||
async confirmDelete() {
|
||||
const confirmDialog = this.page.locator('.el-message-box');
|
||||
await confirmDialog.getByRole('button', { name: '确定' }).or(confirmDialog.locator('button:has-text("确定")')).click();
|
||||
}
|
||||
|
||||
async search(keyword: string) {
|
||||
await this.searchInput.fill(keyword);
|
||||
await this.searchButton.click();
|
||||
}
|
||||
|
||||
async isSuccessMessageVisible(): Promise<boolean> {
|
||||
try {
|
||||
return await this.successMessage.isVisible({ timeout: 3000 });
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async clickAdd(): Promise<void> {
|
||||
await this.clickCreate();
|
||||
}
|
||||
|
||||
async waitForDialogClose(): Promise<void> {
|
||||
await this.page.waitForTimeout(1000);
|
||||
await this.page.locator('.el-dialog').waitFor({ state: 'hidden', timeout: 10000 }).catch(() => {
|
||||
console.log('对话框可能已关闭或不存在');
|
||||
});
|
||||
}
|
||||
|
||||
async clickEditOnFirstRow(): Promise<void> {
|
||||
await this.editCourse(1);
|
||||
}
|
||||
|
||||
async clickDeleteOnFirstRow(): Promise<void> {
|
||||
await this.deleteCourse(1);
|
||||
}
|
||||
|
||||
async getCourseCount(): Promise<number> {
|
||||
return await this.table.locator('tbody tr').count();
|
||||
}
|
||||
|
||||
async getTableRowCount(): Promise<number> {
|
||||
return await this.table.locator('tbody tr').count();
|
||||
}
|
||||
|
||||
async containsText(text: string): Promise<boolean> {
|
||||
return await this.table.getByText(text).count() > 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
import { Page, Locator, expect } from '@playwright/test';
|
||||
|
||||
export class MemberManagementPage {
|
||||
readonly page: Page;
|
||||
readonly table: Locator;
|
||||
readonly createButton: Locator;
|
||||
readonly searchInput: Locator;
|
||||
readonly searchButton: Locator;
|
||||
readonly successMessage: Locator;
|
||||
|
||||
constructor(page: Page) {
|
||||
this.page = page;
|
||||
this.table = page.locator('.el-table').first();
|
||||
this.createButton = page.getByRole('button', { name: '新增会员' }).or(page.locator('button:has-text("新增会员")'));
|
||||
this.searchInput = page.locator('input[placeholder*="搜索会员"]').or(page.locator('input[placeholder*="搜索"]'));
|
||||
this.searchButton = page.getByRole('button', { name: '搜索' }).or(page.locator('button:has-text("搜索")'));
|
||||
this.successMessage = page.locator('.el-message--success').or(page.locator('.success-message'));
|
||||
}
|
||||
|
||||
async goto() {
|
||||
try {
|
||||
console.log('导航到会员管理页面...');
|
||||
await this.page.goto('/members');
|
||||
|
||||
await this.page.waitForLoadState('networkidle');
|
||||
|
||||
await this.table.waitFor({ state: 'visible', timeout: 10000 });
|
||||
|
||||
await expect(this.page).toHaveURL(/.*members/);
|
||||
|
||||
console.log('会员管理页面加载完成');
|
||||
} catch (error) {
|
||||
await this.page.screenshot({ path: `test-results/member-management-error-${Date.now()}.png` });
|
||||
|
||||
console.error('导航到会员管理页面失败:', error);
|
||||
|
||||
throw new Error(`导航到会员管理页面失败: ${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
async waitForTableReady() {
|
||||
await this.table.waitFor({ state: 'visible', timeout: 10000 });
|
||||
|
||||
await this.page.waitForFunction(
|
||||
() => {
|
||||
const rows = document.querySelectorAll('.el-table__body-wrapper tbody tr');
|
||||
return rows.length > 0;
|
||||
},
|
||||
{ timeout: 5000 }
|
||||
).catch(() => {
|
||||
console.log('表格没有数据,继续执行');
|
||||
});
|
||||
}
|
||||
|
||||
async clickCreate() {
|
||||
await this.createButton.click();
|
||||
await this.page.waitForTimeout(500);
|
||||
}
|
||||
|
||||
async fillMemberForm(memberData: {
|
||||
name: string;
|
||||
phone: string;
|
||||
email: string;
|
||||
gender: string;
|
||||
}) {
|
||||
const dialog = this.page.locator('.el-dialog');
|
||||
|
||||
const inputs = dialog.locator('input');
|
||||
|
||||
// 姓名
|
||||
const nameInput = dialog.locator('.el-form-item').filter({ hasText: '姓名' }).locator('input');
|
||||
if (await nameInput.count() > 0) {
|
||||
await nameInput.fill(memberData.name);
|
||||
} else if (await inputs.count() > 0) {
|
||||
await inputs.first().fill(memberData.name);
|
||||
}
|
||||
|
||||
// 手机号
|
||||
const phoneInput = dialog.locator('.el-form-item').filter({ hasText: '手机' }).locator('input');
|
||||
if (await phoneInput.count() > 0) {
|
||||
await phoneInput.fill(memberData.phone);
|
||||
} else if (await inputs.count() > 1) {
|
||||
await inputs.nth(1).fill(memberData.phone);
|
||||
}
|
||||
|
||||
// 邮箱
|
||||
const emailInput = dialog.locator('.el-form-item').filter({ hasText: '邮箱' }).locator('input');
|
||||
if (await emailInput.count() > 0) {
|
||||
await emailInput.fill(memberData.email);
|
||||
} else if (await inputs.count() > 2) {
|
||||
await inputs.nth(2).fill(memberData.email);
|
||||
}
|
||||
|
||||
// 性别(下拉选择)
|
||||
if (memberData.gender) {
|
||||
const genderSelect = dialog.locator('.el-form-item').filter({ hasText: '性别' }).locator('.el-select');
|
||||
if (await genderSelect.count() > 0) {
|
||||
await genderSelect.click();
|
||||
await this.page.waitForTimeout(500);
|
||||
|
||||
const genderText = memberData.gender === '1' || memberData.gender === '男' ? '男' : '女';
|
||||
const dropdown = this.page.locator('.el-select-dropdown');
|
||||
if (await dropdown.count() > 0) {
|
||||
const options = dropdown.locator('.el-select-dropdown__item');
|
||||
const optionCount = await options.count();
|
||||
|
||||
for (let i = 0; i < optionCount; i++) {
|
||||
const optionText = await options.nth(i).textContent();
|
||||
if (optionText && optionText.includes(genderText)) {
|
||||
await options.nth(i).click();
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await this.page.waitForTimeout(300);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async submitForm() {
|
||||
const dialog = this.page.locator('.el-dialog');
|
||||
const submitButton = dialog.getByRole('button', { name: '确定' }).or(dialog.locator('button:has-text("确定")'));
|
||||
|
||||
await submitButton.click();
|
||||
|
||||
await this.page.waitForTimeout(1000);
|
||||
}
|
||||
|
||||
async waitForSuccessMessage(timeout: number = 10000): Promise<boolean> {
|
||||
try {
|
||||
const message = this.page.locator('.el-message--success').or(this.page.locator('.el-message'));
|
||||
await message.waitFor({ state: 'visible', timeout });
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.log('等待成功消息超时,检查是否有错误消息');
|
||||
|
||||
try {
|
||||
const errorMessage = this.page.locator('.el-message--error').or(this.page.locator('.el-message--warning'));
|
||||
if (await errorMessage.count() > 0) {
|
||||
const errorText = await errorMessage.first().textContent();
|
||||
console.log('发现错误消息:', errorText);
|
||||
}
|
||||
} catch (e) {
|
||||
console.log('没有发现错误消息');
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async editMember(rowNumber: number) {
|
||||
await this.table.locator(`tbody tr:nth-child(${rowNumber})`).getByRole('button', { name: '编辑' }).or(this.page.locator(`tbody tr:nth-child(${rowNumber}) .edit-button`)).click();
|
||||
}
|
||||
|
||||
async deleteMember(rowNumber: number) {
|
||||
await this.table.locator(`tbody tr:nth-child(${rowNumber})`).getByRole('button', { name: '删除' }).or(this.page.locator(`tbody tr:nth-child(${rowNumber}) .delete-button`)).click();
|
||||
}
|
||||
|
||||
async confirmDelete() {
|
||||
const confirmDialog = this.page.locator('.el-message-box');
|
||||
await confirmDialog.getByRole('button', { name: '确定' }).or(confirmDialog.locator('button:has-text("确定")')).click();
|
||||
}
|
||||
|
||||
async search(keyword: string) {
|
||||
await this.searchInput.fill(keyword);
|
||||
await this.searchButton.click();
|
||||
}
|
||||
|
||||
async isSuccessMessageVisible(): Promise<boolean> {
|
||||
try {
|
||||
return await this.successMessage.isVisible({ timeout: 3000 });
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async getMemberCount(): Promise<number> {
|
||||
return await this.table.locator('tbody tr').count();
|
||||
}
|
||||
|
||||
async getTableRowCount(): Promise<number> {
|
||||
return await this.table.locator('tbody tr').count();
|
||||
}
|
||||
|
||||
async clickEditOnFirstRow(): Promise<void> {
|
||||
await this.editMember(1);
|
||||
}
|
||||
|
||||
async fillEditForm(newNickname: string, gender: string): Promise<void> {
|
||||
await this.fillMemberForm({ name: newNickname, phone: '', email: '', gender });
|
||||
}
|
||||
|
||||
async clickAdd(): Promise<void> {
|
||||
await this.clickCreate();
|
||||
}
|
||||
|
||||
async waitForDialogClose(): Promise<void> {
|
||||
await this.page.waitForTimeout(1000);
|
||||
await this.page.locator('.el-dialog').waitFor({ state: 'hidden', timeout: 10000 }).catch(() => {
|
||||
console.log('对话框可能已关闭或不存在');
|
||||
});
|
||||
}
|
||||
|
||||
async containsText(text: string): Promise<boolean> {
|
||||
return await this.table.getByText(text).count() > 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
import { Page, Locator, expect } from '@playwright/test';
|
||||
|
||||
export class StatisticsDashboardPage {
|
||||
readonly page: Page;
|
||||
readonly summaryCards: Locator;
|
||||
readonly statCards: Locator;
|
||||
|
||||
constructor(page: Page) {
|
||||
this.page = page;
|
||||
this.summaryCards = page.locator('.el-card').or(page.locator('.statistics-card')).or(page.locator('.summary-card')).or(page.locator('.dashboard-card'));
|
||||
this.statCards = page.locator('.stat-card').or(page.locator('.el-card')).or(page.locator('.summary-card'));
|
||||
}
|
||||
|
||||
async goto() {
|
||||
try {
|
||||
console.log('导航到数据统计页面...');
|
||||
await this.page.goto('/statistics');
|
||||
|
||||
await this.page.waitForLoadState('networkidle');
|
||||
|
||||
await expect(this.page).toHaveURL(/.*statistics/);
|
||||
|
||||
console.log('数据统计页面加载完成');
|
||||
} catch (error) {
|
||||
await this.page.screenshot({ path: `test-results/statistics-dashboard-error-${Date.now()}.png` });
|
||||
|
||||
console.error('导航到数据统计页面失败:', error);
|
||||
|
||||
throw new Error(`导航到数据统计页面失败: ${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
async verifyStatisticsLoaded(): Promise<boolean> {
|
||||
try {
|
||||
await this.summaryCards.first().waitFor({ state: 'visible', timeout: 10000 });
|
||||
|
||||
const cardCount = await this.summaryCards.count();
|
||||
console.log(`数据统计页面加载完成,找到 ${cardCount} 个统计卡片`);
|
||||
|
||||
return cardCount > 0;
|
||||
} catch (error) {
|
||||
console.log('统计卡片未找到,尝试备选选择器');
|
||||
|
||||
const altCards = this.page.locator('.statistics-container').or(this.page.locator('.dashboard-container'));
|
||||
if (await altCards.count() > 0) {
|
||||
await altCards.first().waitFor({ state: 'visible', timeout: 10000 });
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async getSummaryCardCount(): Promise<number> {
|
||||
return await this.summaryCards.count();
|
||||
}
|
||||
|
||||
async getSummaryCardText(index: number): Promise<string | null> {
|
||||
if (await this.summaryCards.count() > index) {
|
||||
return await this.summaryCards.nth(index).textContent();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async verifyStatCardsLoaded(): Promise<boolean> {
|
||||
try {
|
||||
await this.statCards.first().waitFor({ state: 'visible', timeout: 15000 });
|
||||
const cardCount = await this.statCards.count();
|
||||
console.log(`统计卡片加载完成,共 ${cardCount} 个`);
|
||||
return cardCount > 0;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async getCardValue(index: number): Promise<string> {
|
||||
const cards = this.statCards;
|
||||
if (await cards.count() > index) {
|
||||
return (await cards.nth(index).textContent()) || '0';
|
||||
}
|
||||
return '0';
|
||||
}
|
||||
|
||||
async verifySectionCardsLoaded(): Promise<void> {
|
||||
await this.page.waitForTimeout(500);
|
||||
const allCards = this.page.locator('.el-card, .stat-card, .chart-container');
|
||||
if (await allCards.count() > 0) {
|
||||
console.log(`详细统计区域已加载,共 ${await allCards.count()} 个卡片/图表`);
|
||||
}
|
||||
}
|
||||
|
||||
async switchRange(range: string): Promise<void> {
|
||||
const rangeBtn = this.page.locator('.el-radio-button').filter({ hasText: range }).or(this.page.locator('button').filter({ hasText: range }));
|
||||
if (await rangeBtn.count() > 0) {
|
||||
await rangeBtn.first().click();
|
||||
await this.page.waitForTimeout(500);
|
||||
}
|
||||
}
|
||||
|
||||
async switchTab(tabName: string): Promise<void> {
|
||||
const tab = this.page.locator('.el-tabs__item').filter({ hasText: tabName }).or(this.page.locator('.el-tab-pane')).filter({ hasText: tabName }).or(this.page.locator('[role="tab"]').filter({ hasText: tabName }));
|
||||
if (await tab.count() > 0) {
|
||||
await tab.first().click();
|
||||
await this.page.waitForTimeout(800);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
test.describe('Gym模块冒烟测试', () => {
|
||||
test.use({ storageState: { cookies: [], origins: [] } });
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await test.step('导航到登录页面', async () => {
|
||||
await page.goto('/login');
|
||||
await page.waitForLoadState('networkidle');
|
||||
});
|
||||
|
||||
await test.step('输入登录信息', async () => {
|
||||
await page.fill('input[type="text"]', 'admin');
|
||||
await page.fill('input[type="password"]', 'Test@123');
|
||||
});
|
||||
|
||||
await test.step('点击登录按钮并等待跳转', async () => {
|
||||
await page.click('button:has-text("登录")');
|
||||
await page.waitForURL(/.*dashboard/, { timeout: 30000 });
|
||||
await page.waitForLoadState('networkidle');
|
||||
});
|
||||
|
||||
await test.step('验证登录成功', async () => {
|
||||
await expect(page).toHaveURL(/.*dashboard/);
|
||||
console.log('管理员登录成功,进入Dashboard');
|
||||
});
|
||||
});
|
||||
|
||||
test('会员管理页面加载', async ({ page }) => {
|
||||
await test.step('导航到会员管理页面', async () => {
|
||||
await page.goto('/members');
|
||||
await page.waitForURL('**/members', { timeout: 30000 });
|
||||
await page.waitForLoadState('networkidle');
|
||||
});
|
||||
|
||||
await test.step('验证表格可见', async () => {
|
||||
const table = page.locator('.el-table');
|
||||
await expect(table).toBeVisible({ timeout: 15000 });
|
||||
console.log('会员管理页面表格已加载');
|
||||
});
|
||||
|
||||
await test.step('验证搜索框可见', async () => {
|
||||
const searchInput = page.getByPlaceholder('搜索会员编号/昵称/手机号');
|
||||
await expect(searchInput).toBeVisible({ timeout: 5000 });
|
||||
console.log('会员管理页面功能元素正常');
|
||||
});
|
||||
});
|
||||
|
||||
test('团课管理页面加载', async ({ page }) => {
|
||||
await test.step('导航到团课管理页面', async () => {
|
||||
await page.goto('/courses');
|
||||
await page.waitForURL('**/courses', { timeout: 30000 });
|
||||
await page.waitForLoadState('networkidle');
|
||||
});
|
||||
|
||||
await test.step('验证表格可见', async () => {
|
||||
const table = page.locator('.el-table');
|
||||
await expect(table).toBeVisible({ timeout: 15000 });
|
||||
console.log('团课管理页面表格已加载');
|
||||
});
|
||||
|
||||
await test.step('验证新增按钮可见', async () => {
|
||||
const addButton = page.getByRole('button', { name: '新增团课' });
|
||||
await expect(addButton).toBeVisible({ timeout: 5000 });
|
||||
console.log('团课管理页面功能元素正常');
|
||||
});
|
||||
});
|
||||
|
||||
test('教练管理页面加载', async ({ page }) => {
|
||||
await test.step('导航到教练管理页面', async () => {
|
||||
await page.goto('/coach');
|
||||
await page.waitForURL('**/coach', { timeout: 30000 });
|
||||
await page.waitForLoadState('networkidle');
|
||||
});
|
||||
|
||||
await test.step('验证表格可见', async () => {
|
||||
const table = page.locator('.el-table');
|
||||
await expect(table).toBeVisible({ timeout: 15000 });
|
||||
console.log('教练管理页面表格已加载');
|
||||
});
|
||||
|
||||
await test.step('验证新增按钮可见', async () => {
|
||||
const addButton = page.getByRole('button', { name: '新增教练' });
|
||||
await expect(addButton).toBeVisible({ timeout: 5000 });
|
||||
console.log('教练管理页面功能元素正常');
|
||||
});
|
||||
});
|
||||
|
||||
test('数据统计页面加载', async ({ page }) => {
|
||||
await test.step('导航到数据统计页面', async () => {
|
||||
await page.goto('/statistics');
|
||||
await page.waitForURL('**/statistics', { timeout: 30000 });
|
||||
await page.waitForLoadState('networkidle');
|
||||
});
|
||||
|
||||
await test.step('验证统计卡片可见', async () => {
|
||||
const statCards = page.locator('.stat-card');
|
||||
await expect(statCards.first()).toBeVisible({ timeout: 15000 });
|
||||
const cardCount = await statCards.count();
|
||||
expect(cardCount).toBeGreaterThanOrEqual(4);
|
||||
console.log(`数据统计页面加载成功,共 ${cardCount} 个统计卡片`);
|
||||
});
|
||||
});
|
||||
});
|
||||
File diff suppressed because one or more lines are too long
@@ -73,6 +73,21 @@ export interface StatisticsQuery {
|
||||
size?: number
|
||||
}
|
||||
|
||||
// ========== 教练业绩类型 ==========
|
||||
|
||||
export interface CoachPerformanceItem {
|
||||
coachId: number
|
||||
coachName: string
|
||||
avatar: string | null
|
||||
completedCourses: number
|
||||
attendedStudents: number
|
||||
totalBookings: number
|
||||
attendanceRate: number
|
||||
fillRate: number
|
||||
violationCount: number
|
||||
compositeScore: number
|
||||
}
|
||||
|
||||
// ========== API ==========
|
||||
|
||||
export const statisticsApi = {
|
||||
@@ -108,4 +123,14 @@ export const statisticsApi = {
|
||||
responseType: 'blob',
|
||||
})
|
||||
},
|
||||
|
||||
/** 获取教练业绩排行榜 */
|
||||
getCoachRanking(params?: StatisticsQuery) {
|
||||
return request.get<CoachPerformanceItem[]>('/datacount/coach-performance/ranking', { params })
|
||||
},
|
||||
|
||||
/** 获取指定教练业绩详情 */
|
||||
getCoachPerformance(coachId: number, params?: StatisticsQuery) {
|
||||
return request.get<CoachPerformanceItem>(`/datacount/coach-performance/${coachId}`, { params })
|
||||
},
|
||||
}
|
||||
|
||||
@@ -98,7 +98,7 @@
|
||||
<el-table-column prop="location" label="上课地点" width="120" />
|
||||
<el-table-column label="人数" width="90">
|
||||
<template #default="{ row }">
|
||||
{{ row.currentMembers || 0 }}/{{ row.maxMembers || 0 }}
|
||||
{{ realMemberCounts[row.id!] ?? row.currentMembers ?? 0 }}/{{ row.maxMembers || 0 }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="320" fixed="right">
|
||||
@@ -297,7 +297,7 @@
|
||||
<el-descriptions-item label="实际开课时间">{{ formatDateTime(detailRow.actualStartTime) || '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="计划结束时间">{{ formatDateTime(detailRow.endTime) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="实际结课时间">{{ formatDateTime(detailRow.actualEndTime) || '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="人数">{{ detailRow.currentMembers || 0 }} / {{ detailRow.maxMembers || 0 }}</el-descriptions-item>
|
||||
<el-descriptions-item label="人数">{{ realMemberCounts[detailRow.id!] ?? detailRow.currentMembers ?? 0 }} / {{ detailRow.maxMembers || 0 }}</el-descriptions-item>
|
||||
<el-descriptions-item label="储值卡额度">{{ detailRow.storedValueAmount ?? '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="封面图片">
|
||||
<img v-if="detailRow.coverImage && coverCache[detailRow.coverImage]" :src="coverCache[detailRow.coverImage]" class="detail-cover" />
|
||||
@@ -320,7 +320,7 @@
|
||||
import { ref, reactive, watch, onMounted, onUnmounted } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { Search, Plus, WarningFilled, Refresh } from '@element-plus/icons-vue'
|
||||
import { groupCourseApi, groupCourseTypeApi, type GroupCourse, type GroupCourseType } from '@/api/groupCourse.api'
|
||||
import { groupCourseApi, groupCourseTypeApi, groupCourseBookingApi, type GroupCourse, type GroupCourseType } from '@/api/groupCourse.api'
|
||||
import { coachApi, type Coach } from '@/api/coach.api'
|
||||
import request from '@/utils/request'
|
||||
import { formatDateTime } from '@/utils/dateFormat'
|
||||
@@ -331,6 +331,7 @@ const searchKeyword = ref('')
|
||||
const searchStatus = ref('')
|
||||
const courseTypes = ref<GroupCourseType[]>([])
|
||||
const coaches = ref<Coach[]>([])
|
||||
const realMemberCounts = reactive<Record<number, number>>({})
|
||||
|
||||
const statusMap: Record<string, { type: string; label: string }> = {
|
||||
'0': { type: 'success', label: '正常' },
|
||||
@@ -513,6 +514,8 @@ const fetchData = async () => {
|
||||
pagination.total = Number(res.totalElements) || 0
|
||||
// load cover images for all rows
|
||||
res.content.forEach((row: GroupCourse) => loadRowCover(row.coverImage))
|
||||
// 实时获取预约人数
|
||||
fetchRealMemberCounts(res.content)
|
||||
} catch {
|
||||
ElMessage.error('加载数据失败')
|
||||
} finally {
|
||||
@@ -520,6 +523,23 @@ const fetchData = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
const fetchRealMemberCounts = async (courses: GroupCourse[]) => {
|
||||
// 批量获取每门课程的实时预约人数
|
||||
const results = await Promise.allSettled(
|
||||
courses.map(course =>
|
||||
groupCourseBookingApi.getByCourseId(course.id!).then(bookings => ({
|
||||
courseId: course.id!,
|
||||
count: bookings.filter(b => b.status === '0' || b.status === '2').length,
|
||||
}))
|
||||
)
|
||||
)
|
||||
results.forEach(r => {
|
||||
if (r.status === 'fulfilled') {
|
||||
realMemberCounts[r.value.courseId] = r.value.count
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const handleTableChange = () => fetchData()
|
||||
const handleSizeChange = () => {
|
||||
pagination.current = 1
|
||||
|
||||
@@ -28,224 +28,313 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-loading="loading" class="dashboard-content">
|
||||
<!-- ========== 总览统计卡片 ========== -->
|
||||
<el-row :gutter="16" class="stat-cards-row">
|
||||
<el-col :xs="12" :sm="6">
|
||||
<el-card shadow="hover" class="stat-card stat-card--primary">
|
||||
<div class="stat-card__label">会员总数</div>
|
||||
<div class="stat-card__value">{{ summary?.memberStatistics?.totalMembers ?? '-' }}</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
<el-col :xs="12" :sm="6">
|
||||
<el-card shadow="hover" class="stat-card stat-card--success">
|
||||
<div class="stat-card__label">活跃会员</div>
|
||||
<div class="stat-card__value">{{ summary?.memberStatistics?.activeMembers ?? '-' }}</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
<el-col :xs="12" :sm="6">
|
||||
<el-card shadow="hover" class="stat-card stat-card--warning">
|
||||
<div class="stat-card__label">预约总数</div>
|
||||
<div class="stat-card__value">{{ summary?.bookingStatistics?.newBookings ?? '-' }}</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
<el-col :xs="12" :sm="6">
|
||||
<el-card shadow="hover" class="stat-card stat-card--danger">
|
||||
<div class="stat-card__label">教练违规</div>
|
||||
<div class="stat-card__value">{{ summary?.coachStatistics?.totalViolations ?? '-' }}</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-tabs v-model="activeTab" @tab-change="onTabChange">
|
||||
<el-tab-pane label="数据总览" name="overview">
|
||||
<div v-loading="loading" class="dashboard-content">
|
||||
<!-- ========== 总览统计卡片 ========== -->
|
||||
<el-row :gutter="16" class="stat-cards-row">
|
||||
<el-col :xs="12" :sm="6">
|
||||
<el-card shadow="hover" class="stat-card stat-card--primary">
|
||||
<div class="stat-card__label">会员总数</div>
|
||||
<div class="stat-card__value">{{ summary?.memberStatistics?.totalMembers ?? '-' }}</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
<el-col :xs="12" :sm="6">
|
||||
<el-card shadow="hover" class="stat-card stat-card--success">
|
||||
<div class="stat-card__label">活跃会员</div>
|
||||
<div class="stat-card__value">{{ summary?.memberStatistics?.activeMembers ?? '-' }}</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
<el-col :xs="12" :sm="6">
|
||||
<el-card shadow="hover" class="stat-card stat-card--warning">
|
||||
<div class="stat-card__label">预约总数</div>
|
||||
<div class="stat-card__value">{{ summary?.bookingStatistics?.newBookings ?? '-' }}</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
<el-col :xs="12" :sm="6">
|
||||
<el-card shadow="hover" class="stat-card stat-card--danger">
|
||||
<div class="stat-card__label">教练违规</div>
|
||||
<div class="stat-card__value">{{ summary?.coachStatistics?.totalViolations ?? '-' }}</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<!-- ========== 会员统计明细 ========== -->
|
||||
<el-card class="section-card">
|
||||
<template #header>
|
||||
<span class="section-title">会员统计</span>
|
||||
</template>
|
||||
<el-row :gutter="20">
|
||||
<el-col :xs="8" :sm="4">
|
||||
<div class="metric-item">
|
||||
<span class="metric-label">新增会员</span>
|
||||
<span class="metric-value">{{ summary?.memberStatistics?.newMembers ?? '-' }}</span>
|
||||
</div>
|
||||
</el-col>
|
||||
<el-col :xs="8" :sm="4">
|
||||
<div class="metric-item">
|
||||
<span class="metric-label">签到会员</span>
|
||||
<span class="metric-value">{{ summary?.memberStatistics?.signInMembers ?? '-' }}</span>
|
||||
</div>
|
||||
</el-col>
|
||||
<el-col :xs="8" :sm="4">
|
||||
<div class="metric-item">
|
||||
<span class="metric-label">预约会员</span>
|
||||
<span class="metric-value">{{ summary?.memberStatistics?.bookingMembers ?? '-' }}</span>
|
||||
</div>
|
||||
</el-col>
|
||||
<el-col :xs="8" :sm="4">
|
||||
<div class="metric-item">
|
||||
<span class="metric-label">取消预约</span>
|
||||
<span class="metric-value">{{ summary?.memberStatistics?.cancelBookingMembers ?? '-' }}</span>
|
||||
</div>
|
||||
</el-col>
|
||||
<el-col :xs="8" :sm="4">
|
||||
<div class="metric-item">
|
||||
<span class="metric-label">活跃会员</span>
|
||||
<span class="metric-value">{{ summary?.memberStatistics?.activeMembers ?? '-' }}</span>
|
||||
</div>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</el-card>
|
||||
<!-- ========== 会员统计明细 ========== -->
|
||||
<el-card class="section-card">
|
||||
<template #header>
|
||||
<span class="section-title">会员统计</span>
|
||||
</template>
|
||||
<el-row :gutter="20">
|
||||
<el-col :xs="8" :sm="4">
|
||||
<div class="metric-item">
|
||||
<span class="metric-label">新增会员</span>
|
||||
<span class="metric-value">{{ summary?.memberStatistics?.newMembers ?? '-' }}</span>
|
||||
</div>
|
||||
</el-col>
|
||||
<el-col :xs="8" :sm="4">
|
||||
<div class="metric-item">
|
||||
<span class="metric-label">签到会员</span>
|
||||
<span class="metric-value">{{ summary?.memberStatistics?.signInMembers ?? '-' }}</span>
|
||||
</div>
|
||||
</el-col>
|
||||
<el-col :xs="8" :sm="4">
|
||||
<div class="metric-item">
|
||||
<span class="metric-label">预约会员</span>
|
||||
<span class="metric-value">{{ summary?.memberStatistics?.bookingMembers ?? '-' }}</span>
|
||||
</div>
|
||||
</el-col>
|
||||
<el-col :xs="8" :sm="4">
|
||||
<div class="metric-item">
|
||||
<span class="metric-label">取消预约</span>
|
||||
<span class="metric-value">{{ summary?.memberStatistics?.cancelBookingMembers ?? '-' }}</span>
|
||||
</div>
|
||||
</el-col>
|
||||
<el-col :xs="8" :sm="4">
|
||||
<div class="metric-item">
|
||||
<span class="metric-label">活跃会员</span>
|
||||
<span class="metric-value">{{ summary?.memberStatistics?.activeMembers ?? '-' }}</span>
|
||||
</div>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</el-card>
|
||||
|
||||
<!-- ========== 预约统计明细 ========== -->
|
||||
<el-card class="section-card">
|
||||
<template #header>
|
||||
<span class="section-title">预约统计</span>
|
||||
</template>
|
||||
<el-row :gutter="20">
|
||||
<el-col :xs="8" :sm="4">
|
||||
<div class="metric-item">
|
||||
<span class="metric-label">总预约</span>
|
||||
<span class="metric-value">{{ summary?.bookingStatistics?.newBookings ?? '-' }}</span>
|
||||
</div>
|
||||
</el-col>
|
||||
<el-col :xs="8" :sm="4">
|
||||
<div class="metric-item">
|
||||
<span class="metric-label">已签到</span>
|
||||
<span class="metric-value">{{ summary?.bookingStatistics?.attendBookings ?? '-' }}</span>
|
||||
</div>
|
||||
</el-col>
|
||||
<el-col :xs="8" :sm="4">
|
||||
<div class="metric-item">
|
||||
<span class="metric-label">已取消</span>
|
||||
<span class="metric-value">{{ summary?.bookingStatistics?.cancelBookings ?? '-' }}</span>
|
||||
</div>
|
||||
</el-col>
|
||||
<el-col :xs="8" :sm="4">
|
||||
<div class="metric-item">
|
||||
<span class="metric-label">缺席</span>
|
||||
<span class="metric-value">{{ summary?.bookingStatistics?.absentBookings ?? '-' }}</span>
|
||||
</div>
|
||||
</el-col>
|
||||
<el-col :xs="16" :sm="8">
|
||||
<div class="rate-group">
|
||||
<div class="rate-item">
|
||||
<span class="rate-label">出勤率</span>
|
||||
<el-progress
|
||||
:percentage="Number(summary?.bookingStatistics?.attendanceRate ?? 0)"
|
||||
:stroke-width="12"
|
||||
:color="rateColor(summary?.bookingStatistics?.attendanceRate)"
|
||||
/>
|
||||
</div>
|
||||
<div class="rate-item">
|
||||
<span class="rate-label">取消率</span>
|
||||
<el-progress
|
||||
:percentage="Number(summary?.bookingStatistics?.cancelRate ?? 0)"
|
||||
:stroke-width="12"
|
||||
:color="rateColor(summary?.bookingStatistics?.cancelRate, true)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</el-card>
|
||||
<!-- ========== 预约统计明细 ========== -->
|
||||
<el-card class="section-card">
|
||||
<template #header>
|
||||
<span class="section-title">预约统计</span>
|
||||
</template>
|
||||
<el-row :gutter="20">
|
||||
<el-col :xs="8" :sm="4">
|
||||
<div class="metric-item">
|
||||
<span class="metric-label">总预约</span>
|
||||
<span class="metric-value">{{ summary?.bookingStatistics?.newBookings ?? '-' }}</span>
|
||||
</div>
|
||||
</el-col>
|
||||
<el-col :xs="8" :sm="4">
|
||||
<div class="metric-item">
|
||||
<span class="metric-label">已签到</span>
|
||||
<span class="metric-value">{{ summary?.bookingStatistics?.attendBookings ?? '-' }}</span>
|
||||
</div>
|
||||
</el-col>
|
||||
<el-col :xs="8" :sm="4">
|
||||
<div class="metric-item">
|
||||
<span class="metric-label">已取消</span>
|
||||
<span class="metric-value">{{ summary?.bookingStatistics?.cancelBookings ?? '-' }}</span>
|
||||
</div>
|
||||
</el-col>
|
||||
<el-col :xs="8" :sm="4">
|
||||
<div class="metric-item">
|
||||
<span class="metric-label">缺席</span>
|
||||
<span class="metric-value">{{ summary?.bookingStatistics?.absentBookings ?? '-' }}</span>
|
||||
</div>
|
||||
</el-col>
|
||||
<el-col :xs="16" :sm="8">
|
||||
<div class="rate-group">
|
||||
<div class="rate-item">
|
||||
<span class="rate-label">出勤率</span>
|
||||
<el-progress
|
||||
:percentage="Number(summary?.bookingStatistics?.attendanceRate ?? 0)"
|
||||
:stroke-width="12"
|
||||
:color="rateColor(summary?.bookingStatistics?.attendanceRate)"
|
||||
/>
|
||||
</div>
|
||||
<div class="rate-item">
|
||||
<span class="rate-label">取消率</span>
|
||||
<el-progress
|
||||
:percentage="Number(summary?.bookingStatistics?.cancelRate ?? 0)"
|
||||
:stroke-width="12"
|
||||
:color="rateColor(summary?.bookingStatistics?.cancelRate, true)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</el-card>
|
||||
|
||||
<!-- ========== 签到统计明细 ========== -->
|
||||
<el-card class="section-card">
|
||||
<template #header>
|
||||
<span class="section-title">签到统计</span>
|
||||
</template>
|
||||
<el-row :gutter="20">
|
||||
<el-col :xs="8" :sm="4">
|
||||
<div class="metric-item">
|
||||
<span class="metric-label">总签到</span>
|
||||
<span class="metric-value">{{ summary?.signInStatistics?.totalSignIns ?? '-' }}</span>
|
||||
</div>
|
||||
</el-col>
|
||||
<el-col :xs="8" :sm="4">
|
||||
<div class="metric-item">
|
||||
<span class="metric-label">成功</span>
|
||||
<span class="metric-value">{{ summary?.signInStatistics?.successSignIns ?? '-' }}</span>
|
||||
</div>
|
||||
</el-col>
|
||||
<el-col :xs="8" :sm="4">
|
||||
<div class="metric-item">
|
||||
<span class="metric-label">失败</span>
|
||||
<span class="metric-value">{{ summary?.signInStatistics?.failedSignIns ?? '-' }}</span>
|
||||
</div>
|
||||
</el-col>
|
||||
<el-col :xs="24" :sm="12">
|
||||
<div class="rate-group">
|
||||
<div class="rate-item">
|
||||
<span class="rate-label">成功率</span>
|
||||
<el-progress
|
||||
:percentage="Number(summary?.signInStatistics?.successRate ?? 0)"
|
||||
:stroke-width="12"
|
||||
color="#67c23a"
|
||||
/>
|
||||
</div>
|
||||
<div class="signin-type-row">
|
||||
<el-tag type="primary" size="small">扫码: {{ summary?.signInStatistics?.qrCodeSignIns ?? 0 }}</el-tag>
|
||||
<el-tag type="warning" size="small">手动: {{ summary?.signInStatistics?.manualSignIns ?? 0 }}</el-tag>
|
||||
<el-tag type="success" size="small">人脸: {{ summary?.signInStatistics?.faceSignIns ?? 0 }}</el-tag>
|
||||
</div>
|
||||
</div>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</el-card>
|
||||
<!-- ========== 签到统计明细 ========== -->
|
||||
<el-card class="section-card">
|
||||
<template #header>
|
||||
<span class="section-title">签到统计</span>
|
||||
</template>
|
||||
<el-row :gutter="20">
|
||||
<el-col :xs="8" :sm="4">
|
||||
<div class="metric-item">
|
||||
<span class="metric-label">总签到</span>
|
||||
<span class="metric-value">{{ summary?.signInStatistics?.totalSignIns ?? '-' }}</span>
|
||||
</div>
|
||||
</el-col>
|
||||
<el-col :xs="8" :sm="4">
|
||||
<div class="metric-item">
|
||||
<span class="metric-label">成功</span>
|
||||
<span class="metric-value">{{ summary?.signInStatistics?.successSignIns ?? '-' }}</span>
|
||||
</div>
|
||||
</el-col>
|
||||
<el-col :xs="8" :sm="4">
|
||||
<div class="metric-item">
|
||||
<span class="metric-label">失败</span>
|
||||
<span class="metric-value">{{ summary?.signInStatistics?.failedSignIns ?? '-' }}</span>
|
||||
</div>
|
||||
</el-col>
|
||||
<el-col :xs="24" :sm="12">
|
||||
<div class="rate-group">
|
||||
<div class="rate-item">
|
||||
<span class="rate-label">成功率</span>
|
||||
<el-progress
|
||||
:percentage="Number(summary?.signInStatistics?.successRate ?? 0)"
|
||||
:stroke-width="12"
|
||||
color="#67c23a"
|
||||
/>
|
||||
</div>
|
||||
<div class="signin-type-row">
|
||||
<el-tag type="primary" size="small">扫码: {{ summary?.signInStatistics?.qrCodeSignIns ?? 0 }}</el-tag>
|
||||
<el-tag type="warning" size="small">手动: {{ summary?.signInStatistics?.manualSignIns ?? 0 }}</el-tag>
|
||||
<el-tag type="success" size="small">人脸: {{ summary?.signInStatistics?.faceSignIns ?? 0 }}</el-tag>
|
||||
</div>
|
||||
</div>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</el-card>
|
||||
|
||||
<!-- ========== 教练统计明细 ========== -->
|
||||
<el-card class="section-card">
|
||||
<template #header>
|
||||
<span class="section-title">教练统计</span>
|
||||
</template>
|
||||
<el-row :gutter="20">
|
||||
<el-col :xs="8" :sm="4">
|
||||
<div class="metric-item">
|
||||
<span class="metric-label">教练总数</span>
|
||||
<span class="metric-value">{{ summary?.coachStatistics?.totalCoaches ?? '-' }}</span>
|
||||
</div>
|
||||
</el-col>
|
||||
<el-col :xs="8" :sm="4">
|
||||
<div class="metric-item">
|
||||
<span class="metric-label">违规教练</span>
|
||||
<span class="metric-value" :class="{ 'metric-value--danger': (summary?.coachStatistics?.violatedCoaches ?? 0) > 0 }">
|
||||
{{ summary?.coachStatistics?.violatedCoaches ?? '-' }}
|
||||
</span>
|
||||
</div>
|
||||
</el-col>
|
||||
<el-col :xs="8" :sm="4">
|
||||
<div class="metric-item">
|
||||
<span class="metric-label">开课总数</span>
|
||||
<span class="metric-value">{{ summary?.coachStatistics?.totalCourses ?? '-' }}</span>
|
||||
</div>
|
||||
</el-col>
|
||||
<el-col :xs="8" :sm="4">
|
||||
<div class="metric-item">
|
||||
<span class="metric-label">迟到</span>
|
||||
<span class="metric-value" :class="{ 'metric-value--warn': (summary?.coachStatistics?.lateCount ?? 0) > 0 }">
|
||||
{{ summary?.coachStatistics?.lateCount ?? '-' }}
|
||||
</span>
|
||||
</div>
|
||||
</el-col>
|
||||
<el-col :xs="8" :sm="4">
|
||||
<div class="metric-item">
|
||||
<span class="metric-label">缺席</span>
|
||||
<span class="metric-value" :class="{ 'metric-value--danger': (summary?.coachStatistics?.absentCount ?? 0) > 0 }">
|
||||
{{ summary?.coachStatistics?.absentCount ?? '-' }}
|
||||
</span>
|
||||
</div>
|
||||
</el-col>
|
||||
<el-col :xs="8" :sm="4">
|
||||
<div class="metric-item">
|
||||
<span class="metric-label">未手动结课</span>
|
||||
<span class="metric-value" :class="{ 'metric-value--warn': (summary?.coachStatistics?.notManualEndCount ?? 0) > 0 }">
|
||||
{{ summary?.coachStatistics?.notManualEndCount ?? '-' }}
|
||||
</span>
|
||||
</div>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</el-card>
|
||||
</div>
|
||||
<!-- ========== 教练统计明细 ========== -->
|
||||
<el-card class="section-card">
|
||||
<template #header>
|
||||
<span class="section-title">教练统计</span>
|
||||
</template>
|
||||
<el-row :gutter="20">
|
||||
<el-col :xs="8" :sm="4">
|
||||
<div class="metric-item">
|
||||
<span class="metric-label">教练总数</span>
|
||||
<span class="metric-value">{{ summary?.coachStatistics?.totalCoaches ?? '-' }}</span>
|
||||
</div>
|
||||
</el-col>
|
||||
<el-col :xs="8" :sm="4">
|
||||
<div class="metric-item">
|
||||
<span class="metric-label">违规教练</span>
|
||||
<span class="metric-value" :class="{ 'metric-value--danger': (summary?.coachStatistics?.violatedCoaches ?? 0) > 0 }">
|
||||
{{ summary?.coachStatistics?.violatedCoaches ?? '-' }}
|
||||
</span>
|
||||
</div>
|
||||
</el-col>
|
||||
<el-col :xs="8" :sm="4">
|
||||
<div class="metric-item">
|
||||
<span class="metric-label">开课总数</span>
|
||||
<span class="metric-value">{{ summary?.coachStatistics?.totalCourses ?? '-' }}</span>
|
||||
</div>
|
||||
</el-col>
|
||||
<el-col :xs="8" :sm="4">
|
||||
<div class="metric-item">
|
||||
<span class="metric-label">迟到</span>
|
||||
<span class="metric-value" :class="{ 'metric-value--warn': (summary?.coachStatistics?.lateCount ?? 0) > 0 }">
|
||||
{{ summary?.coachStatistics?.lateCount ?? '-' }}
|
||||
</span>
|
||||
</div>
|
||||
</el-col>
|
||||
<el-col :xs="8" :sm="4">
|
||||
<div class="metric-item">
|
||||
<span class="metric-label">缺席</span>
|
||||
<span class="metric-value" :class="{ 'metric-value--danger': (summary?.coachStatistics?.absentCount ?? 0) > 0 }">
|
||||
{{ summary?.coachStatistics?.absentCount ?? '-' }}
|
||||
</span>
|
||||
</div>
|
||||
</el-col>
|
||||
<el-col :xs="8" :sm="4">
|
||||
<div class="metric-item">
|
||||
<span class="metric-label">未手动结课</span>
|
||||
<span class="metric-value" :class="{ 'metric-value--warn': (summary?.coachStatistics?.notManualEndCount ?? 0) > 0 }">
|
||||
{{ summary?.coachStatistics?.notManualEndCount ?? '-' }}
|
||||
</span>
|
||||
</div>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</el-card>
|
||||
</div>
|
||||
</el-tab-pane>
|
||||
|
||||
<!-- ========== 教练业绩 Tab ========== -->
|
||||
<el-tab-pane label="教练业绩" name="coach">
|
||||
<div v-loading="coachLoading" class="dashboard-content">
|
||||
<el-table :data="coachRanking" stripe border style="width: 100%">
|
||||
<el-table-column type="index" label="排名" width="60" />
|
||||
<el-table-column prop="coachName" label="教练" min-width="120">
|
||||
<template #default="{ row }">
|
||||
<div class="coach-name-cell">
|
||||
<el-avatar v-if="row.avatar" :src="row.avatar" :size="28" />
|
||||
<span>{{ row.coachName }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="completedCourses" label="授课量" width="90" sortable />
|
||||
<el-table-column prop="attendedStudents" label="出席人次" width="100" sortable />
|
||||
<el-table-column prop="attendanceRate" label="出勤率" width="90" sortable>
|
||||
<template #default="{ row }">
|
||||
<span :style="{ color: rateColor(row.attendanceRate) }">{{ row.attendanceRate }}%</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="fillRate" label="满员率" width="90" sortable>
|
||||
<template #default="{ row }">
|
||||
<span :style="{ color: rateColor(row.fillRate) }">{{ row.fillRate }}%</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="violationCount" label="违规" width="70" sortable>
|
||||
<template #default="{ row }">
|
||||
<el-tag v-if="row.violationCount > 0" type="danger" size="small">{{ row.violationCount }}</el-tag>
|
||||
<el-tag v-else type="success" size="small">0</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="compositeScore" label="综合评分" width="180" sortable>
|
||||
<template #default="{ row }">
|
||||
<div class="score-cell">
|
||||
<el-progress
|
||||
:percentage="row.compositeScore"
|
||||
:stroke-width="10"
|
||||
:color="scoreProgressColor(row.compositeScore)"
|
||||
/>
|
||||
<span class="score-text">{{ row.compositeScore }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="80" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button type="primary" link size="small" @click="showCoachDetail(row)">
|
||||
详情
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
|
||||
<!-- ========== 教练业绩详情弹窗 ========== -->
|
||||
<el-dialog
|
||||
v-model="coachDetailVisible"
|
||||
:title="`${selectedCoach?.coachName ?? ''} 业绩详情`"
|
||||
width="560px"
|
||||
>
|
||||
<el-descriptions v-if="selectedCoach" :column="2" border>
|
||||
<el-descriptions-item label="授课量">{{ selectedCoach.completedCourses }} 节</el-descriptions-item>
|
||||
<el-descriptions-item label="出席人次">{{ selectedCoach.attendedStudents }} 人次</el-descriptions-item>
|
||||
<el-descriptions-item label="出勤率">
|
||||
<span :style="{ color: rateColor(selectedCoach.attendanceRate) }">{{ selectedCoach.attendanceRate }}%</span>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="满员率">
|
||||
<span :style="{ color: rateColor(selectedCoach.fillRate) }">{{ selectedCoach.fillRate }}%</span>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="违规次数">
|
||||
<el-tag :type="selectedCoach.violationCount > 0 ? 'danger' : 'success'" size="small">
|
||||
{{ selectedCoach.violationCount }}
|
||||
</el-tag>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="综合评分">
|
||||
<el-progress
|
||||
:percentage="selectedCoach.compositeScore"
|
||||
:stroke-width="10"
|
||||
:color="scoreProgressColor(selectedCoach.compositeScore)"
|
||||
style="width: 140px"
|
||||
/>
|
||||
</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -253,7 +342,7 @@
|
||||
import { ref, onMounted, onUnmounted } from 'vue'
|
||||
import { Download } from '@element-plus/icons-vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { statisticsApi, type StatisticsSummary } from '@/api/statistics.api'
|
||||
import { statisticsApi, type StatisticsSummary, type CoachPerformanceItem } from '@/api/statistics.api'
|
||||
|
||||
// ---- 时间区间映射 ----
|
||||
const RANGE_MAP: Record<string, { periodType: string }> = {
|
||||
@@ -270,6 +359,13 @@ const exporting = ref(false)
|
||||
const summary = ref<StatisticsSummary>()
|
||||
const generatedAt = ref('')
|
||||
const selectedRange = ref('today')
|
||||
const activeTab = ref('overview')
|
||||
|
||||
// ---- 教练业绩 ----
|
||||
const coachLoading = ref(false)
|
||||
const coachRanking = ref<CoachPerformanceItem[]>([])
|
||||
const coachDetailVisible = ref(false)
|
||||
const selectedCoach = ref<CoachPerformanceItem | null>(null)
|
||||
|
||||
const rateColor = (rate: number | undefined, inverse = false) => {
|
||||
const val = Number(rate ?? 0)
|
||||
@@ -283,6 +379,12 @@ const rateColor = (rate: number | undefined, inverse = false) => {
|
||||
return '#f56c6c'
|
||||
}
|
||||
|
||||
const scoreProgressColor = (score: number) => {
|
||||
if (score >= 80) return '#67c23a'
|
||||
if (score >= 50) return '#e6a23c'
|
||||
return '#f56c6c'
|
||||
}
|
||||
|
||||
const getQueryParams = () => {
|
||||
const config = RANGE_MAP[selectedRange.value]
|
||||
if (!config) return {}
|
||||
@@ -304,8 +406,37 @@ const fetchSummary = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
const fetchCoachRanking = async () => {
|
||||
coachLoading.value = true
|
||||
try {
|
||||
const data = await statisticsApi.getCoachRanking(getQueryParams())
|
||||
coachRanking.value = data ?? []
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.response?.data?.message || e?.message || '获取教练业绩失败')
|
||||
} finally {
|
||||
coachLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const onRangeChange = () => {
|
||||
fetchSummary()
|
||||
if (activeTab.value === 'coach') {
|
||||
fetchCoachRanking()
|
||||
} else {
|
||||
fetchSummary()
|
||||
}
|
||||
}
|
||||
|
||||
const onTabChange = (name: string | number) => {
|
||||
if (name === 'coach') {
|
||||
fetchCoachRanking()
|
||||
} else {
|
||||
fetchSummary()
|
||||
}
|
||||
}
|
||||
|
||||
const showCoachDetail = (row: CoachPerformanceItem) => {
|
||||
selectedCoach.value = row
|
||||
coachDetailVisible.value = true
|
||||
}
|
||||
|
||||
const handleExport = async () => {
|
||||
@@ -336,7 +467,11 @@ let statsTimer: ReturnType<typeof setInterval> | null = null
|
||||
onMounted(() => {
|
||||
fetchSummary()
|
||||
statsTimer = setInterval(() => {
|
||||
fetchSummary()
|
||||
if (activeTab.value === 'coach') {
|
||||
fetchCoachRanking()
|
||||
} else {
|
||||
fetchSummary()
|
||||
}
|
||||
}, 60_000)
|
||||
})
|
||||
|
||||
@@ -473,4 +608,24 @@ onUnmounted(() => {
|
||||
margin-top: 4px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
/* ---- 教练业绩 ---- */
|
||||
.coach-name-cell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.score-cell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.score-text {
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
color: #303133;
|
||||
min-width: 36px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -139,7 +139,7 @@
|
||||
</el-table-column>
|
||||
<el-table-column label="预约人数" width="100">
|
||||
<template #default="{ row }">
|
||||
{{ row.currentMembers }} / {{ row.maxMembers }}
|
||||
{{ coachCourseMemberCounts[row.id!] ?? row.currentMembers }} / {{ row.maxMembers }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="location" label="上课地点" />
|
||||
@@ -220,7 +220,7 @@
|
||||
</el-table-column>
|
||||
<el-table-column label="预约人数" width="100">
|
||||
<template #default="{ row }">
|
||||
{{ row.currentMembers }} / {{ row.maxMembers }}
|
||||
{{ coachCourseMemberCounts[row.id!] ?? row.currentMembers }} / {{ row.maxMembers }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="location" label="上课地点" />
|
||||
@@ -238,6 +238,7 @@ import { ref, reactive, computed, onMounted } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { Search } from '@element-plus/icons-vue'
|
||||
import { coachApi, type Coach, type CoachCreateRequest, type CoachUpdateRequest, type GroupCourse, type ViolationCountItem, type ViolationRecord } from '@/api/coach.api'
|
||||
import { groupCourseBookingApi } from '@/api/groupCourse.api'
|
||||
import { handleApiError } from '@/utils/errorHandler'
|
||||
import { formatDateTime } from '@/utils/dateFormat'
|
||||
|
||||
@@ -295,6 +296,7 @@ const coursesDialogVisible = ref(false)
|
||||
const coursesLoading = ref(false)
|
||||
const coachCourses = ref<GroupCourse[]>([])
|
||||
const selectedCoachName = ref('')
|
||||
const coachCourseMemberCounts = reactive<Record<number, number>>({})
|
||||
|
||||
// 团课状态映射
|
||||
const courseStatusMap: Record<number, { text: string; tag: string }> = {
|
||||
@@ -443,6 +445,7 @@ const handleViewCourses = async (row: Coach) => {
|
||||
coursesLoading.value = true
|
||||
try {
|
||||
coachCourses.value = await coachApi.getCourses(row.id)
|
||||
await fetchCoachRealMemberCounts(coachCourses.value)
|
||||
} catch (error) {
|
||||
handleApiError(error)
|
||||
} finally {
|
||||
@@ -450,6 +453,22 @@ const handleViewCourses = async (row: Coach) => {
|
||||
}
|
||||
}
|
||||
|
||||
const fetchCoachRealMemberCounts = async (courses: GroupCourse[]) => {
|
||||
const results = await Promise.allSettled(
|
||||
courses.map(course =>
|
||||
groupCourseBookingApi.getByCourseId(course.id!).then(bookings => ({
|
||||
courseId: course.id!,
|
||||
count: bookings.filter(b => b.status === '0' || b.status === '2').length,
|
||||
}))
|
||||
)
|
||||
)
|
||||
results.forEach(r => {
|
||||
if (r.status === 'fulfilled') {
|
||||
coachCourseMemberCounts[r.value.courseId] = r.value.count
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 违规次数标签颜色
|
||||
function getViolationTag(coachId: number) {
|
||||
const count = violationCounts[coachId] ?? 0
|
||||
@@ -485,6 +504,7 @@ const handleViewDetail = async (row: Coach) => {
|
||||
])
|
||||
detailViolations.value = violations
|
||||
detailCourses.value = courses
|
||||
await fetchCoachRealMemberCounts(courses)
|
||||
} catch (error) {
|
||||
handleApiError(error)
|
||||
} finally {
|
||||
|
||||
Reference in New Issue
Block a user