新增e2e测试脚本,修复部分问题

This commit was merged in pull request #51.
This commit is contained in:
2026-07-22 20:00:13 +08:00
parent 53d1ce6fb2
commit 4c07ec5455
105 changed files with 21700 additions and 318 deletions
@@ -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;
}
}
+48
View File
@@ -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);
}
}
}
+104
View File
@@ -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
+25
View File
@@ -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 {