新增e2e测试脚本,修复部分问题
This commit was merged in pull request #51.
This commit is contained in:
@@ -42,11 +42,29 @@ function getCoachViolations(coachId) {
|
||||
return http.get('/coach/' + coachId + '/violations')
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取课程的预约列表(实时预约人数)
|
||||
*/
|
||||
function getCourseBookings(courseId) {
|
||||
return http.get('/groupCourse/bookings/course/' + courseId)
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取教练业绩统计
|
||||
* @param {number} coachId - 教练ID
|
||||
* @param {string} periodType - 统计周期: MONTH / WEEK
|
||||
*/
|
||||
function getCoachPerformance(coachId, periodType) {
|
||||
return http.get('/datacount/coach-performance/' + coachId + '?periodType=' + (periodType || 'MONTH'))
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
login,
|
||||
getCoachCourses,
|
||||
startCourse,
|
||||
endCourse,
|
||||
getCourseDetail,
|
||||
getCoachViolations
|
||||
getCoachViolations,
|
||||
getCourseBookings,
|
||||
getCoachPerformance
|
||||
}
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
const { checkBackendHealth } = require('./helpers/automator');
|
||||
|
||||
/**
|
||||
* Jest globalSetup — 在测试套件启动前执行
|
||||
*/
|
||||
module.exports = async function globalSetup() {
|
||||
console.log('\n========================================');
|
||||
console.log(' 教练端 E2E 测试套件启动');
|
||||
console.log('========================================\n');
|
||||
|
||||
// 检查后端服务可用性
|
||||
console.log('[Setup] 检查后端服务可用性...');
|
||||
const backendOnline = await checkBackendHealth();
|
||||
if (!backendOnline) {
|
||||
console.warn('[Setup] ⚠️ 警告:后端服务不可达!');
|
||||
console.warn('[Setup] 测试将继续,但 API 相关测试可能失败。');
|
||||
console.warn('[Setup] 后端地址: http://192.168.110.64:8084/api\n');
|
||||
} else {
|
||||
console.log('[Setup] ✓ 后端服务连接正常\n');
|
||||
}
|
||||
|
||||
console.log('[Setup] 测试环境就绪,开始执行测试用例...\n');
|
||||
};
|
||||
@@ -0,0 +1,8 @@
|
||||
/**
|
||||
* Jest globalTeardown — 在测试套件执行完毕后调用
|
||||
*/
|
||||
module.exports = async function globalTeardown() {
|
||||
console.log('\n========================================');
|
||||
console.log(' 教练端 E2E 测试套件结束');
|
||||
console.log('========================================\n');
|
||||
};
|
||||
@@ -0,0 +1,139 @@
|
||||
/**
|
||||
* 教练端 HTTP API 测试工具
|
||||
* 支持 HMAC-SHA256 签名 + JWT Bearer Token
|
||||
*/
|
||||
|
||||
const http = require('http');
|
||||
const crypto = require('crypto');
|
||||
|
||||
const BASE_URL = 'http://192.168.110.64:8084';
|
||||
const API_BASE = BASE_URL + '/api';
|
||||
const SIGNATURE_SECRET = 'NovalonManageSystemSecretKey2026';
|
||||
|
||||
/**
|
||||
* 生成 HMAC-SHA256 签名头
|
||||
*/
|
||||
function generateSignatureHeaders(method, path, bodyStr) {
|
||||
const timestamp = Date.now();
|
||||
const nonce = timestamp.toString(36) + '-' + Math.random().toString(36).substring(2, 8);
|
||||
const stringToSign = [method.toUpperCase(), path, '', bodyStr || '', String(timestamp), nonce].join('\n');
|
||||
const signature = crypto.createHmac('sha256', SIGNATURE_SECRET).update(stringToSign).digest('base64');
|
||||
return {
|
||||
'X-Signature': signature,
|
||||
'X-Timestamp': String(timestamp),
|
||||
'X-Nonce': nonce
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 通用 HTTP 请求
|
||||
*/
|
||||
function makeRequest(method, url, body, token) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const parsed = new URL(url);
|
||||
const bodyStr = body ? JSON.stringify(body) : '';
|
||||
const headers = {
|
||||
'Content-Type': 'application/json',
|
||||
...generateSignatureHeaders(method, parsed.pathname + parsed.search, bodyStr)
|
||||
};
|
||||
if (token) {
|
||||
headers['Authorization'] = 'Bearer ' + token;
|
||||
}
|
||||
|
||||
const options = {
|
||||
hostname: parsed.hostname,
|
||||
port: parsed.port,
|
||||
path: parsed.pathname + parsed.search,
|
||||
method: method,
|
||||
headers: headers,
|
||||
timeout: 15000
|
||||
};
|
||||
|
||||
const req = http.request(options, (res) => {
|
||||
let data = '';
|
||||
res.on('data', chunk => data += chunk);
|
||||
res.on('end', () => {
|
||||
let parsedData;
|
||||
try { parsedData = JSON.parse(data); } catch (e) { parsedData = data; }
|
||||
resolve({ status: res.statusCode, data: parsedData });
|
||||
});
|
||||
});
|
||||
req.on('error', (e) => reject(e));
|
||||
if (bodyStr) req.write(bodyStr);
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
function isSuccess(res) {
|
||||
return res.status >= 200 && res.status < 300;
|
||||
}
|
||||
|
||||
// ── 教练端 API ──
|
||||
|
||||
/** 教练登录 */
|
||||
async function coachLogin(username, password) {
|
||||
console.log(`[API] 教练登录: ${username}`);
|
||||
const res = await makeRequest('POST', `${API_BASE}/auth/login`, { username, password });
|
||||
console.log(`[API] 登录响应: status=${res.status}`);
|
||||
if (isSuccess(res) && res.data) {
|
||||
return {
|
||||
token: res.data.token,
|
||||
userId: res.data.userId,
|
||||
username: res.data.username
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** 获取教练的团课列表 */
|
||||
async function getCoachCourses(coachId, token) {
|
||||
console.log(`[API] 获取教练课程: coachId=${coachId}`);
|
||||
const res = await makeRequest('GET', `${API_BASE}/coach/${coachId}/courses`, null, token);
|
||||
console.log(`[API] 课程列表: status=${res.status}`);
|
||||
return res;
|
||||
}
|
||||
|
||||
/** 获取团课详情 */
|
||||
async function getCourseDetail(courseId, token) {
|
||||
console.log(`[API] 获取课程详情: courseId=${courseId}`);
|
||||
const res = await makeRequest('GET', `${API_BASE}/groupCourse/${courseId}/detail`, null, token);
|
||||
console.log(`[API] 课程详情: status=${res.status}`);
|
||||
return res;
|
||||
}
|
||||
|
||||
/** 手动开课 */
|
||||
async function startCourse(courseId, token) {
|
||||
console.log(`[API] 手动开课: courseId=${courseId}`);
|
||||
const res = await makeRequest('POST', `${API_BASE}/coach/courses/${courseId}/start`, {}, token);
|
||||
console.log(`[API] 开课结果: status=${res.status}`);
|
||||
return res;
|
||||
}
|
||||
|
||||
/** 手动结课 */
|
||||
async function endCourse(courseId, token) {
|
||||
console.log(`[API] 手动结课: courseId=${courseId}`);
|
||||
const res = await makeRequest('POST', `${API_BASE}/coach/courses/${courseId}/end`, {}, token);
|
||||
console.log(`[API] 结课结果: status=${res.status}`);
|
||||
return res;
|
||||
}
|
||||
|
||||
/** 获取课程预约列表 */
|
||||
async function getCourseBookings(courseId, token) {
|
||||
console.log(`[API] 获取预约列表: courseId=${courseId}`);
|
||||
const res = await makeRequest('GET', `${API_BASE}/groupCourse/bookings/course/${courseId}`, null, token);
|
||||
console.log(`[API] 预约列表: status=${res.status}`);
|
||||
return res;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
makeRequest,
|
||||
coachLogin,
|
||||
getCoachCourses,
|
||||
getCourseDetail,
|
||||
startCourse,
|
||||
endCourse,
|
||||
getCourseBookings,
|
||||
isSuccess,
|
||||
BASE_URL,
|
||||
API_BASE
|
||||
};
|
||||
@@ -0,0 +1,145 @@
|
||||
const automator = require('miniprogram-automator');
|
||||
const path = require('path');
|
||||
|
||||
const PROJECT_PATH = path.resolve(__dirname, '../../unpackage/dist/dev/mp-weixin');
|
||||
const CLI_PATH = 'D:\\wechat-dev-tools\\cli.bat';
|
||||
const CLI_PORT = 45869;
|
||||
const BASE_URL = 'http://192.168.110.64:8084/api';
|
||||
|
||||
let miniProgram = null;
|
||||
|
||||
/**
|
||||
* 启动微信小程序
|
||||
*/
|
||||
async function launchMiniProgram() {
|
||||
miniProgram = await automator.launch({
|
||||
projectPath: PROJECT_PATH,
|
||||
cliPath: CLI_PATH,
|
||||
port: CLI_PORT
|
||||
});
|
||||
return miniProgram;
|
||||
}
|
||||
|
||||
/**
|
||||
* 关闭小程序连接
|
||||
*/
|
||||
async function closeMiniProgram() {
|
||||
if (miniProgram) {
|
||||
try {
|
||||
await miniProgram.close();
|
||||
} catch (e) {
|
||||
console.warn('[automator] 关闭小程序失败:', e.message);
|
||||
}
|
||||
miniProgram = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前 miniProgram 实例
|
||||
*/
|
||||
function getMiniProgram() {
|
||||
return miniProgram;
|
||||
}
|
||||
|
||||
/**
|
||||
* 等待指定毫秒
|
||||
*/
|
||||
function sleep(ms) {
|
||||
return new Promise(resolve => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
/**
|
||||
* 教练端通过 username/password 登录
|
||||
* @param {object} mp - miniProgram 实例
|
||||
* @param {string} username
|
||||
* @param {string} password
|
||||
*/
|
||||
async function loginAsCoach(mp, username = 'coach_test1', password = 'Test@123') {
|
||||
await sleep(1500);
|
||||
const page = await mp.currentPage();
|
||||
|
||||
// 等待登录表单渲染
|
||||
await page.waitFor('input', { timeout: 10000 });
|
||||
|
||||
// 填写用户名
|
||||
const inputs = await page.$$('input');
|
||||
if (inputs.length >= 2) {
|
||||
await inputs[0].input(username);
|
||||
await sleep(300);
|
||||
await inputs[1].input(password);
|
||||
await sleep(300);
|
||||
|
||||
// 点击登录按钮
|
||||
const loginBtn = await page.$('.login-btn');
|
||||
if (loginBtn) {
|
||||
await loginBtn.tap();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 等待页面跳转(用于登录成功后等待首页加载)
|
||||
* @param {object} mp - miniProgram 实例
|
||||
* @param {string} targetPath - 目标页面路径,如 'pages/index/index'
|
||||
* @param {number} timeoutMs
|
||||
*/
|
||||
async function waitForPage(mp, targetPath, timeoutMs = 15000) {
|
||||
const startTime = Date.now();
|
||||
while (Date.now() - startTime < timeoutMs) {
|
||||
try {
|
||||
const page = await mp.currentPage();
|
||||
if (page.path.includes(targetPath)) {
|
||||
return page;
|
||||
}
|
||||
} catch (e) {
|
||||
// 页面切换中,忽略错误
|
||||
}
|
||||
await sleep(500);
|
||||
}
|
||||
throw new Error(`等待页面 "${targetPath}" 超时 (${timeoutMs}ms)`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 导航到指定页面
|
||||
* @param {object} mp - miniProgram 实例
|
||||
* @param {string} url - 小程序页面路径,如 '/pages/course-list/course-list'
|
||||
*/
|
||||
async function navigateTo(mp, url) {
|
||||
await mp.navigateTo(url);
|
||||
await sleep(1500);
|
||||
return mp.currentPage();
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查后端服务可用性
|
||||
*/
|
||||
async function checkBackendHealth() {
|
||||
const http = require('http');
|
||||
return new Promise((resolve) => {
|
||||
const req = http.get(BASE_URL + '/auth/login', { timeout: 10000 }, (res) => {
|
||||
resolve(true);
|
||||
});
|
||||
req.on('error', () => {
|
||||
console.warn('[automator] 后端服务不可达:', BASE_URL);
|
||||
resolve(false);
|
||||
});
|
||||
req.on('timeout', () => {
|
||||
req.destroy();
|
||||
console.warn('[automator] 后端服务连接超时:', BASE_URL);
|
||||
resolve(false);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
launchMiniProgram,
|
||||
closeMiniProgram,
|
||||
getMiniProgram,
|
||||
loginAsCoach,
|
||||
waitForPage,
|
||||
navigateTo,
|
||||
sleep,
|
||||
checkBackendHealth,
|
||||
BASE_URL,
|
||||
CLI_PORT
|
||||
};
|
||||
@@ -0,0 +1,199 @@
|
||||
const { sleep } = require('../helpers/automator');
|
||||
|
||||
class CourseDetailPage {
|
||||
/**
|
||||
* @param {object} miniProgram - miniprogram-automator 实例
|
||||
*/
|
||||
constructor(miniProgram) {
|
||||
this.mp = miniProgram;
|
||||
}
|
||||
|
||||
/**
|
||||
* 等待课程详情页加载完成
|
||||
*/
|
||||
async waitForLoad() {
|
||||
const page = await this.mp.currentPage();
|
||||
await page.waitFor('.detail-page', { timeout: 10000 });
|
||||
await sleep(1500); // 等待异步数据加载
|
||||
return page;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取课程基本信息
|
||||
* @returns {Promise<{courseName: string, status: string, time: string, location: string, memberCount: string}>}
|
||||
*/
|
||||
async getCourseInfo() {
|
||||
const page = await this.mp.currentPage();
|
||||
const info = {};
|
||||
|
||||
try {
|
||||
// 课程名
|
||||
const titleEl = await page.$('.detail-title');
|
||||
info.courseName = titleEl ? (await titleEl.text()).trim() : '';
|
||||
|
||||
// 状态标签
|
||||
const statusEl = await page.$('.cover-status text');
|
||||
info.status = statusEl ? (await statusEl.text()).trim() : '';
|
||||
|
||||
// 信息项
|
||||
const infoItems = await page.$$('.info-item');
|
||||
for (const item of infoItems) {
|
||||
try {
|
||||
const labelEl = await item.$('.info-label');
|
||||
const valueEl = await item.$('.info-value');
|
||||
if (labelEl && valueEl) {
|
||||
const label = (await labelEl.text()).trim();
|
||||
const value = (await valueEl.text()).trim();
|
||||
if (label === '时间') info.time = value;
|
||||
if (label === '地点') info.location = value;
|
||||
if (label === '人数') info.memberCount = value;
|
||||
}
|
||||
} catch (e) {
|
||||
// 跳过
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// 忽略
|
||||
}
|
||||
|
||||
return info;
|
||||
}
|
||||
|
||||
/**
|
||||
* 点击开课按钮
|
||||
* 仅当 course.status == 0 (待开课) 时可用
|
||||
*/
|
||||
async startCourse() {
|
||||
const page = await this.mp.currentPage();
|
||||
const startBtn = await page.$('.start-btn');
|
||||
if (!startBtn) {
|
||||
throw new Error('未找到开课按钮(课程可能不是"待开课"状态)');
|
||||
}
|
||||
|
||||
// 检查按钮是否被禁用
|
||||
const isDisabled = await this._isButtonDisabled(startBtn);
|
||||
if (isDisabled) {
|
||||
throw new Error('开课按钮被禁用(尚未到开课时间)');
|
||||
}
|
||||
|
||||
await startBtn.tap();
|
||||
await sleep(3000); // 等待开课请求完成
|
||||
}
|
||||
|
||||
/**
|
||||
* 点击结课按钮
|
||||
* 仅当 course.status == 3 || 7 (进行中) 时可用
|
||||
*/
|
||||
async endCourse() {
|
||||
const page = await this.mp.currentPage();
|
||||
const endBtn = await page.$('.end-btn');
|
||||
if (!endBtn) {
|
||||
throw new Error('未找到结课按钮(课程可能不是"进行中"状态)');
|
||||
}
|
||||
|
||||
const isDisabled = await this._isButtonDisabled(endBtn);
|
||||
if (isDisabled) {
|
||||
throw new Error('结课按钮被禁用(尚未到结课时间)');
|
||||
}
|
||||
|
||||
await endBtn.tap();
|
||||
await sleep(3000); // 等待结课请求完成
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取学员列表
|
||||
* 注意:当前课程详情页设计中没有独立的学员列表区域,
|
||||
* 学员人数信息在 info-card 中展示。
|
||||
* @returns {Promise<{count: number, max: number}>}
|
||||
*/
|
||||
async getStudentList() {
|
||||
const info = await this.getCourseInfo();
|
||||
if (info.memberCount) {
|
||||
const match = info.memberCount.match(/(\d+)\s*\/\s*(\d+)/);
|
||||
if (match) {
|
||||
return {
|
||||
count: parseInt(match[1], 10),
|
||||
max: parseInt(match[2], 10)
|
||||
};
|
||||
}
|
||||
}
|
||||
return { count: 0, max: 0 };
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成签到二维码
|
||||
* 注意:当前课程详情页没有直接的生成二维码按钮。
|
||||
* 二维码通常通过后端 API 生成,MiniTest 中可通过 evaluate 调用 API。
|
||||
*/
|
||||
async generateQRCode() {
|
||||
const page = await this.mp.currentPage();
|
||||
// 通过 evaluate 在小程序环境中生成二维码
|
||||
// 实际二维码生成逻辑取决于后端 API
|
||||
try {
|
||||
const qrData = await this.mp.evaluate(() => {
|
||||
// 尝试获取当前课程的签到信息
|
||||
const pages = getCurrentPages();
|
||||
const currentPage = pages[pages.length - 1];
|
||||
return {
|
||||
courseId: currentPage.courseId || '',
|
||||
timestamp: Date.now()
|
||||
};
|
||||
});
|
||||
return qrData;
|
||||
} catch (e) {
|
||||
console.warn('[CourseDetailPage] 生成二维码失败:', e.message);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取操作按钮状态
|
||||
*/
|
||||
async getActionButtonStatus() {
|
||||
const page = await this.mp.currentPage();
|
||||
try {
|
||||
const startBtn = await page.$('.start-btn');
|
||||
if (startBtn) {
|
||||
const text = (await startBtn.text()).trim();
|
||||
const isDisabled = await this._isButtonDisabled(startBtn);
|
||||
return { type: 'start', text, disabled: isDisabled };
|
||||
}
|
||||
|
||||
const endBtn = await page.$('.end-btn');
|
||||
if (endBtn) {
|
||||
const text = (await endBtn.text()).trim();
|
||||
const isDisabled = await this._isButtonDisabled(endBtn);
|
||||
return { type: 'end', text, disabled: isDisabled };
|
||||
}
|
||||
|
||||
const disabledBtn = await page.$('.disabled-btn');
|
||||
if (disabledBtn) {
|
||||
const text = (await disabledBtn.text()).trim();
|
||||
return { type: 'disabled', text, disabled: true };
|
||||
}
|
||||
} catch (e) {
|
||||
// 忽略
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 内部方法:检查按钮是否被禁用
|
||||
*/
|
||||
async _isButtonDisabled(btnElement) {
|
||||
try {
|
||||
const disabled = await btnElement.attribute('disabled');
|
||||
if (disabled === 'true' || disabled === true || disabled === '') {
|
||||
return true;
|
||||
}
|
||||
// 也检查 CSS class
|
||||
const className = await btnElement.attribute('class');
|
||||
return (className || '').includes('disabled');
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = CourseDetailPage;
|
||||
@@ -0,0 +1,158 @@
|
||||
const { sleep } = require('../helpers/automator');
|
||||
|
||||
class CourseListPage {
|
||||
/**
|
||||
* @param {object} miniProgram - miniprogram-automator 实例
|
||||
*/
|
||||
constructor(miniProgram) {
|
||||
this.mp = miniProgram;
|
||||
}
|
||||
|
||||
/**
|
||||
* 等待课程列表页加载完成
|
||||
*/
|
||||
async waitForLoad() {
|
||||
const page = await this.mp.currentPage();
|
||||
await page.waitFor('.course-list-page', { timeout: 10000 });
|
||||
await sleep(1500); // 等待数据加载
|
||||
return page;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取课程列表
|
||||
* @returns {Promise<Array<{name: string, status: string, location: string}>>}
|
||||
*/
|
||||
async getCourseList() {
|
||||
const page = await this.mp.currentPage();
|
||||
const cards = await page.$$('.course-card');
|
||||
const courses = [];
|
||||
|
||||
for (const card of cards) {
|
||||
try {
|
||||
const nameEl = await card.$('.course-name');
|
||||
const statusEl = await card.$('.status-tag text');
|
||||
const name = nameEl ? (await nameEl.text()).trim() : '';
|
||||
const status = statusEl ? (await statusEl.text()).trim() : '';
|
||||
courses.push({ name, status });
|
||||
} catch (e) {
|
||||
// 跳过无法解析的卡片
|
||||
}
|
||||
}
|
||||
|
||||
return courses;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取课程卡片数量
|
||||
*/
|
||||
async getCourseCount() {
|
||||
const page = await this.mp.currentPage();
|
||||
try {
|
||||
const cards = await page.$$('.course-card');
|
||||
return cards.length;
|
||||
} catch (e) {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 按状态筛选课程
|
||||
* @param {string} status - 'all' | 'pending' | 'in_progress' | 'ended'
|
||||
*/
|
||||
async filterByStatus(status) {
|
||||
const page = await this.mp.currentPage();
|
||||
|
||||
const tabValueMap = {
|
||||
all: 0,
|
||||
pending: 1,
|
||||
in_progress: 2,
|
||||
ended: 3
|
||||
};
|
||||
|
||||
const tabIndex = tabValueMap[status];
|
||||
if (tabIndex === undefined) {
|
||||
throw new Error(`未知的筛选状态: ${status}`);
|
||||
}
|
||||
|
||||
const tabs = await page.$$('.tab-item');
|
||||
if (tabs.length > tabIndex) {
|
||||
await tabs[tabIndex].tap();
|
||||
await sleep(1500); // 等待筛选结果渲染
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 点击指定索引的课程卡片
|
||||
* @param {number} index - 课程卡片索引(从0开始)
|
||||
*/
|
||||
async clickCourse(index = 0) {
|
||||
const page = await this.mp.currentPage();
|
||||
const cards = await page.$$('.course-card');
|
||||
if (cards.length <= index) {
|
||||
throw new Error(`课程索引 ${index} 超出范围,当前共 ${cards.length} 个课程`);
|
||||
}
|
||||
await cards[index].tap();
|
||||
await sleep(2000); // 等待课程详情页加载
|
||||
return this.mp.currentPage();
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证某个课程是否有学员(人数 > 0)
|
||||
* @param {number} index - 课程卡片索引
|
||||
*/
|
||||
async verifyCourseHasStudents(index = 0) {
|
||||
const page = await this.mp.currentPage();
|
||||
const cards = await page.$$('.course-card');
|
||||
if (cards.length <= index) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const infoRows = await cards[index].$$('.info-row');
|
||||
// 最后一行为 "人数 X / Y" 信息
|
||||
if (infoRows.length > 0) {
|
||||
const lastRow = infoRows[infoRows.length - 1];
|
||||
const valueEl = await lastRow.$('.info-value');
|
||||
if (valueEl) {
|
||||
const text = (await valueEl.text()).trim();
|
||||
const match = text.match(/^(\d+)\s*\/\s*\d+$/);
|
||||
if (match) {
|
||||
return parseInt(match[1], 10) > 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// 忽略
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否显示"暂无课程"的空状态
|
||||
*/
|
||||
async isEmpty() {
|
||||
const page = await this.mp.currentPage();
|
||||
try {
|
||||
const emptyEl = await page.$('.empty-wrap');
|
||||
return !!emptyEl;
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否仍在加载中
|
||||
*/
|
||||
async isLoading() {
|
||||
const page = await this.mp.currentPage();
|
||||
try {
|
||||
const loadingEl = await page.$('.loading-wrap');
|
||||
return !!loadingEl;
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = CourseListPage;
|
||||
@@ -0,0 +1,114 @@
|
||||
const { sleep } = require('../helpers/automator');
|
||||
|
||||
class HomePage {
|
||||
/**
|
||||
* @param {object} miniProgram - miniprogram-automator 实例
|
||||
*/
|
||||
constructor(miniProgram) {
|
||||
this.mp = miniProgram;
|
||||
}
|
||||
|
||||
/**
|
||||
* 等待首页加载完成
|
||||
*/
|
||||
async waitForLoad() {
|
||||
const page = await this.mp.currentPage();
|
||||
await page.waitFor('.greeting-card', { timeout: 10000 });
|
||||
await page.waitFor('.stats-card', { timeout: 5000 });
|
||||
return page;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前登录的教练名字
|
||||
* @returns {Promise<string>}
|
||||
*/
|
||||
async getCoachName() {
|
||||
const page = await this.mp.currentPage();
|
||||
try {
|
||||
const nameEl = await page.$('.coach-name');
|
||||
if (nameEl) {
|
||||
const text = await nameEl.text();
|
||||
return (text || '').replace(' 教练', '').trim();
|
||||
}
|
||||
} catch (e) {
|
||||
// 忽略
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取今日统计信息(待开课、进行中、已结束)
|
||||
* @returns {Promise<{pending: string, inProgress: string, ended: string}>}
|
||||
*/
|
||||
async getTodayStats() {
|
||||
const page = await this.mp.currentPage();
|
||||
try {
|
||||
const statNumbers = await page.$$('.stat-number');
|
||||
const stats = {};
|
||||
if (statNumbers.length >= 3) {
|
||||
stats.pending = (await statNumbers[0].text()).trim();
|
||||
stats.inProgress = (await statNumbers[1].text()).trim();
|
||||
stats.ended = (await statNumbers[2].text()).trim();
|
||||
}
|
||||
return stats;
|
||||
} catch (e) {
|
||||
return { pending: '0', inProgress: '0', ended: '0' };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 导航到课程列表页
|
||||
*/
|
||||
async navigateToCourseList() {
|
||||
const page = await this.mp.currentPage();
|
||||
// 点击"我的团课"入口卡片
|
||||
const entryCards = await page.$$('.entry-card');
|
||||
if (entryCards.length > 0) {
|
||||
await entryCards[0].tap();
|
||||
} else {
|
||||
// 备用:通过 URL 导航
|
||||
await this.mp.navigateTo('/pages/course-list/course-list');
|
||||
}
|
||||
await sleep(2000);
|
||||
return this.mp.currentPage();
|
||||
}
|
||||
|
||||
/**
|
||||
* 导航到个人中心
|
||||
*/
|
||||
async navigateToProfile() {
|
||||
const page = await this.mp.currentPage();
|
||||
const entryCards = await page.$$('.entry-card');
|
||||
if (entryCards.length >= 2) {
|
||||
await entryCards[1].tap();
|
||||
} else {
|
||||
await this.mp.navigateTo('/pages/profile/profile');
|
||||
}
|
||||
await sleep(2000);
|
||||
return this.mp.currentPage();
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证首页关键元素存在
|
||||
*/
|
||||
async verifyPageElements() {
|
||||
const page = await this.mp.currentPage();
|
||||
const checks = {};
|
||||
|
||||
try {
|
||||
checks.greetingCard = !!(await page.$('.greeting-card'));
|
||||
} catch (e) { checks.greetingCard = false; }
|
||||
|
||||
try {
|
||||
checks.statsCard = !!(await page.$('.stats-card'));
|
||||
} catch (e) { checks.statsCard = false; }
|
||||
|
||||
try {
|
||||
checks.navTitle = !!(await page.$('.nav-title'));
|
||||
} catch (e) { checks.navTitle = false; }
|
||||
|
||||
return checks;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = HomePage;
|
||||
@@ -0,0 +1,101 @@
|
||||
const { sleep } = require('../helpers/automator');
|
||||
|
||||
class LoginPage {
|
||||
/**
|
||||
* @param {object} miniProgram - miniprogram-automator 实例
|
||||
*/
|
||||
constructor(miniProgram) {
|
||||
this.mp = miniProgram;
|
||||
}
|
||||
|
||||
/**
|
||||
* 等待登录页加载完成
|
||||
*/
|
||||
async waitForLoad() {
|
||||
const page = await this.mp.currentPage();
|
||||
await page.waitFor('.login-form', { timeout: 10000 });
|
||||
await page.waitFor('.login-btn', { timeout: 10000 });
|
||||
return page;
|
||||
}
|
||||
|
||||
/**
|
||||
* 填写用户名和密码并提交登录
|
||||
* @param {string} username
|
||||
* @param {string} password
|
||||
*/
|
||||
async login(username, password) {
|
||||
const page = await this.mp.currentPage();
|
||||
await page.waitFor('input', { timeout: 10000 });
|
||||
|
||||
const inputs = await page.$$('input');
|
||||
if (inputs.length < 2) {
|
||||
throw new Error('登录页未找到用户名/密码输入框');
|
||||
}
|
||||
|
||||
// 清空并填写用户名
|
||||
await inputs[0].input(username);
|
||||
await sleep(300);
|
||||
|
||||
// 清空并填写密码
|
||||
await inputs[1].input(password);
|
||||
await sleep(300);
|
||||
|
||||
// 点击登录按钮
|
||||
const loginBtn = await page.$('.login-btn');
|
||||
if (!loginBtn) {
|
||||
throw new Error('未找到登录按钮');
|
||||
}
|
||||
await loginBtn.tap();
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取页面上的错误提示(Toast)
|
||||
* 注意:小程序 Toast 通过 wx.showToast 展示,MiniTest 中难以直接捕获。
|
||||
* 此方法返回页面上的错误元素文本。
|
||||
*/
|
||||
async getErrorMessage() {
|
||||
try {
|
||||
const page = await this.mp.currentPage();
|
||||
// 检查是否有 toast 相关的错误元素
|
||||
const toastEl = await page.$('.uni-toast__content');
|
||||
if (toastEl) {
|
||||
const text = await toastEl.text();
|
||||
return text;
|
||||
}
|
||||
} catch (e) {
|
||||
// 忽略
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 等待登录成功后跳转到首页
|
||||
* @param {number} timeoutMs
|
||||
*/
|
||||
async waitForLoginSuccess(timeoutMs = 15000) {
|
||||
const startTime = Date.now();
|
||||
while (Date.now() - startTime < timeoutMs) {
|
||||
try {
|
||||
const page = await this.mp.currentPage();
|
||||
if (page.path.includes('pages/index/index')) {
|
||||
await sleep(1000); // 等待首页渲染
|
||||
return page;
|
||||
}
|
||||
} catch (e) {
|
||||
// 页面跳转中
|
||||
}
|
||||
await sleep(500);
|
||||
}
|
||||
throw new Error(`等待登录成功跳转超时 (${timeoutMs}ms)`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证是否在登录页
|
||||
*/
|
||||
async isOnLoginPage() {
|
||||
const page = await this.mp.currentPage();
|
||||
return page.path.includes('pages/login/login');
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = LoginPage;
|
||||
@@ -0,0 +1,158 @@
|
||||
const { sleep } = require('../helpers/automator');
|
||||
|
||||
class ProfilePage {
|
||||
/**
|
||||
* @param {object} miniProgram - miniprogram-automator 实例
|
||||
*/
|
||||
constructor(miniProgram) {
|
||||
this.mp = miniProgram;
|
||||
}
|
||||
|
||||
/**
|
||||
* 等待个人中心页加载完成
|
||||
*/
|
||||
async waitForLoad() {
|
||||
const page = await this.mp.currentPage();
|
||||
await page.waitFor('.profile-page', { timeout: 10000 });
|
||||
await sleep(1500); // 等待异步数据加载
|
||||
return page;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取教练基本信息
|
||||
* @returns {Promise<{username: string, coachId: string}>}
|
||||
*/
|
||||
async getCoachInfo() {
|
||||
const page = await this.mp.currentPage();
|
||||
const info = {};
|
||||
|
||||
try {
|
||||
const nameEl = await page.$('.user-name');
|
||||
info.username = nameEl ? (await nameEl.text()).trim() : '';
|
||||
|
||||
const idEl = await page.$('.detail-value');
|
||||
info.coachId = idEl ? (await idEl.text()).trim() : '';
|
||||
} catch (e) {
|
||||
// 忽略
|
||||
}
|
||||
|
||||
return info;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取违规记录
|
||||
* @returns {Promise<Array<{courseName: string, type: string, time: string}>>}
|
||||
*/
|
||||
async getViolationRecords() {
|
||||
const page = await this.mp.currentPage();
|
||||
const records = [];
|
||||
|
||||
try {
|
||||
// 先检查是否有违规记录
|
||||
const statNumber = await page.$('.stats-card .stat-number');
|
||||
if (statNumber) {
|
||||
const countText = (await statNumber.text()).trim();
|
||||
const count = parseInt(countText, 10);
|
||||
|
||||
if (count > 0) {
|
||||
const cards = await page.$$('.violation-card');
|
||||
for (const card of cards) {
|
||||
try {
|
||||
const courseEl = await card.$('.violation-course');
|
||||
const tagEl = await card.$('.violation-tag text');
|
||||
const timeEl = await card.$('.violation-time');
|
||||
|
||||
records.push({
|
||||
courseName: courseEl ? (await courseEl.text()).trim() : '',
|
||||
type: tagEl ? (await tagEl.text()).trim() : '',
|
||||
time: timeEl ? (await timeEl.text()).trim() : ''
|
||||
});
|
||||
} catch (e) {
|
||||
// 跳过无法解析的记录
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// 忽略
|
||||
}
|
||||
|
||||
return records;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取违规次数统计
|
||||
*/
|
||||
async getViolationCount() {
|
||||
const page = await this.mp.currentPage();
|
||||
try {
|
||||
const statNumber = await page.$('.stats-card .stat-number');
|
||||
if (statNumber) {
|
||||
const text = (await statNumber.text()).trim();
|
||||
return parseInt(text, 10);
|
||||
}
|
||||
} catch (e) {
|
||||
// 忽略
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取业绩数据
|
||||
* @returns {Promise<{totalLessons: string, attendanceRate: string, compositeScore: string}>}
|
||||
*/
|
||||
async getPerformance() {
|
||||
const page = await this.mp.currentPage();
|
||||
const perf = {};
|
||||
|
||||
try {
|
||||
const perfNumbers = await page.$$('.perf-number');
|
||||
if (perfNumbers.length >= 4) {
|
||||
perf.totalLessons = (await perfNumbers[0].text()).trim();
|
||||
// perfNumbers[1] = totalAttendees
|
||||
perf.attendanceRate = (await perfNumbers[2].text()).trim();
|
||||
perf.fullnessRate = (await perfNumbers[3].text()).trim();
|
||||
}
|
||||
|
||||
const scoreEl = await page.$('.score-badge');
|
||||
perf.compositeScore = scoreEl ? (await scoreEl.text()).trim() : '';
|
||||
} catch (e) {
|
||||
// 忽略
|
||||
}
|
||||
|
||||
return perf;
|
||||
}
|
||||
|
||||
/**
|
||||
* 点击退出登录
|
||||
*/
|
||||
async logout() {
|
||||
const page = await this.mp.currentPage();
|
||||
const logoutBtn = await page.$('.logout-btn');
|
||||
if (logoutBtn) {
|
||||
await logoutBtn.tap();
|
||||
await sleep(1000);
|
||||
// 确认弹窗需要额外处理
|
||||
// MiniTest 中弹窗按钮难以直接操作,这里只触发点击
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 返回上一页
|
||||
*/
|
||||
async goBack() {
|
||||
const page = await this.mp.currentPage();
|
||||
try {
|
||||
const backBtn = await page.$('.back-btn');
|
||||
if (backBtn) {
|
||||
await backBtn.tap();
|
||||
await sleep(1000);
|
||||
}
|
||||
} catch (e) {
|
||||
// 备用:使用系统返回
|
||||
await this.mp.navigateBack();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = ProfilePage;
|
||||
@@ -0,0 +1,131 @@
|
||||
const { launchMiniProgram, closeMiniProgram, sleep } = require('../helpers/automator');
|
||||
const LoginPage = require('../pages/LoginPage');
|
||||
const HomePage = require('../pages/HomePage');
|
||||
const CourseListPage = require('../pages/CourseListPage');
|
||||
const CourseDetailPage = require('../pages/CourseDetailPage');
|
||||
|
||||
let miniProgram;
|
||||
let loginPage;
|
||||
let homePage;
|
||||
let courseListPage;
|
||||
let courseDetailPage;
|
||||
|
||||
describe('P2 - 教练课程控制测试(开课/结课)', () => {
|
||||
beforeAll(async () => {
|
||||
miniProgram = await launchMiniProgram();
|
||||
loginPage = new LoginPage(miniProgram);
|
||||
homePage = new HomePage(miniProgram);
|
||||
courseListPage = new CourseListPage(miniProgram);
|
||||
courseDetailPage = new CourseDetailPage(miniProgram);
|
||||
|
||||
// 先完成登录
|
||||
await loginPage.login('coach_test1', 'Test@123');
|
||||
await loginPage.waitForLoginSuccess(20000);
|
||||
await homePage.waitForLoad();
|
||||
}, 120000);
|
||||
|
||||
afterAll(async () => {
|
||||
await closeMiniProgram();
|
||||
}, 30000);
|
||||
|
||||
test('导航到课程列表并筛选待开课课程', async () => {
|
||||
await homePage.navigateToCourseList();
|
||||
await courseListPage.waitForLoad();
|
||||
await courseListPage.filterByStatus('pending');
|
||||
await sleep(1000);
|
||||
}, 30000);
|
||||
|
||||
test('进入待开课课程详情 → 验证开课按钮存在', async () => {
|
||||
const count = await courseListPage.getCourseCount();
|
||||
|
||||
if (count === 0) {
|
||||
console.warn('[WARN] 没有待开课课程,跳过开课/结课测试');
|
||||
return;
|
||||
}
|
||||
|
||||
// 点击第一个待开课课程
|
||||
await courseListPage.clickCourse(0);
|
||||
await courseDetailPage.waitForLoad();
|
||||
|
||||
const actionBtn = await courseDetailPage.getActionButtonStatus();
|
||||
expect(actionBtn).not.toBeNull();
|
||||
|
||||
if (actionBtn.type === 'start') {
|
||||
// 如果按钮存在且为开课按钮
|
||||
expect(actionBtn.text).toMatch(/开课/i);
|
||||
}
|
||||
// 注:如果 disabled,说明未到开课时间,跳过实操
|
||||
}, 30000);
|
||||
|
||||
test('点击开课按钮(如可用)→ 验证状态变化', async () => {
|
||||
const actionBtn = await courseDetailPage.getActionButtonStatus();
|
||||
|
||||
if (!actionBtn || actionBtn.type !== 'start') {
|
||||
console.warn('[WARN] 没有可用的开课按钮,跳过开课操作测试');
|
||||
return;
|
||||
}
|
||||
|
||||
if (actionBtn.disabled) {
|
||||
console.warn('[WARN] 开课按钮被禁用(尚未到开课时间),跳过开课操作');
|
||||
return;
|
||||
}
|
||||
|
||||
// 执行开课
|
||||
await courseDetailPage.startCourse();
|
||||
await sleep(2000);
|
||||
|
||||
// 刷新页面查看状态是否变化
|
||||
const page = await miniProgram.currentPage();
|
||||
const statusEl = await page.$('.cover-status text');
|
||||
if (statusEl) {
|
||||
const statusText = (await statusEl.text()).trim();
|
||||
expect(statusText).toMatch(/进行中|教练迟到/);
|
||||
}
|
||||
}, 60000);
|
||||
|
||||
test('进入进行中课程 → 验证结课按钮存在', async () => {
|
||||
// 回到课程列表页
|
||||
await miniProgram.navigateBack();
|
||||
await sleep(1500);
|
||||
|
||||
const page = await miniProgram.currentPage();
|
||||
|
||||
// 如果在课程列表页
|
||||
if (page.path.includes('pages/course-list')) {
|
||||
await courseListPage.filterByStatus('in_progress');
|
||||
await sleep(1500);
|
||||
|
||||
const count = await courseListPage.getCourseCount();
|
||||
if (count === 0) {
|
||||
console.warn('[WARN] 没有进行中的课程,跳过结课测试');
|
||||
return;
|
||||
}
|
||||
|
||||
await courseListPage.clickCourse(0);
|
||||
await courseDetailPage.waitForLoad();
|
||||
|
||||
const actionBtn = await courseDetailPage.getActionButtonStatus();
|
||||
expect(actionBtn).not.toBeNull();
|
||||
|
||||
if (actionBtn) {
|
||||
// 可以是 end 或 disabled(不满足结课条件)
|
||||
expect(['end', 'disabled']).toContain(actionBtn.type);
|
||||
}
|
||||
}
|
||||
}, 40000);
|
||||
|
||||
test('生成签到二维码(模拟)→ 返回 courseId', async () => {
|
||||
const page = await miniProgram.currentPage();
|
||||
|
||||
if (!page.path.includes('pages/course-detail')) {
|
||||
console.warn('[WARN] 不在课程详情页,跳过二维码测试');
|
||||
return;
|
||||
}
|
||||
|
||||
const qrData = await courseDetailPage.generateQRCode();
|
||||
expect(qrData).toBeDefined();
|
||||
// 验证返回的数据结构
|
||||
expect(qrData).toHaveProperty('courseId');
|
||||
expect(qrData).toHaveProperty('timestamp');
|
||||
}, 15000);
|
||||
});
|
||||
@@ -0,0 +1,148 @@
|
||||
const { launchMiniProgram, closeMiniProgram, sleep } = require('../helpers/automator');
|
||||
const LoginPage = require('../pages/LoginPage');
|
||||
const HomePage = require('../pages/HomePage');
|
||||
const CourseListPage = require('../pages/CourseListPage');
|
||||
const CourseDetailPage = require('../pages/CourseDetailPage');
|
||||
|
||||
let miniProgram;
|
||||
let loginPage;
|
||||
let homePage;
|
||||
let courseListPage;
|
||||
let courseDetailPage;
|
||||
|
||||
describe('P1 - 教练课程工作流测试', () => {
|
||||
beforeAll(async () => {
|
||||
miniProgram = await launchMiniProgram();
|
||||
loginPage = new LoginPage(miniProgram);
|
||||
homePage = new HomePage(miniProgram);
|
||||
courseListPage = new CourseListPage(miniProgram);
|
||||
courseDetailPage = new CourseDetailPage(miniProgram);
|
||||
|
||||
// 先完成登录
|
||||
await loginPage.login('coach_test1', 'Test@123');
|
||||
await loginPage.waitForLoginSuccess(20000);
|
||||
await homePage.waitForLoad();
|
||||
}, 120000);
|
||||
|
||||
afterAll(async () => {
|
||||
await closeMiniProgram();
|
||||
}, 30000);
|
||||
|
||||
test('从首页导航到课程列表页', async () => {
|
||||
await homePage.navigateToCourseList();
|
||||
|
||||
const page = await miniProgram.currentPage();
|
||||
expect(page.path).toContain('pages/course-list/course-list');
|
||||
|
||||
await courseListPage.waitForLoad();
|
||||
}, 30000);
|
||||
|
||||
test('课程列表页应显示筛选项', async () => {
|
||||
const page = await miniProgram.currentPage();
|
||||
|
||||
// 验证 Tab 筛选栏存在
|
||||
const tabs = await page.$$('.tab-item');
|
||||
expect(tabs.length).toBeGreaterThanOrEqual(4);
|
||||
|
||||
// 验证 Tab 标签内容
|
||||
const tabLabels = [];
|
||||
for (const tab of tabs) {
|
||||
const textEl = await tab.$('text');
|
||||
tabLabels.push(textEl ? (await textEl.text()).trim() : '');
|
||||
}
|
||||
expect(tabLabels).toContain('全部');
|
||||
expect(tabLabels).toContain('待开课');
|
||||
expect(tabLabels).toContain('进行中');
|
||||
expect(tabLabels).toContain('已结束');
|
||||
}, 15000);
|
||||
|
||||
test('按状态筛选 → 待开课,应显示正确结果', async () => {
|
||||
await courseListPage.filterByStatus('pending');
|
||||
|
||||
const page = await miniProgram.currentPage();
|
||||
|
||||
// 验证 active Tab
|
||||
const activeTab = await page.$('.tab-item.active text');
|
||||
expect(activeTab).not.toBeNull();
|
||||
const activeLabel = await activeTab.text();
|
||||
expect(activeLabel.trim()).toBe('待开课');
|
||||
|
||||
// 获取筛选结果并验证状态标签
|
||||
const courses = await courseListPage.getCourseList();
|
||||
for (const course of courses) {
|
||||
// 待开课状态下所有课程的状态应为"正常"
|
||||
expect(course.status).toBe('正常');
|
||||
}
|
||||
}, 20000);
|
||||
|
||||
test('按状态筛选 → 全部,应显示所有课程', async () => {
|
||||
await courseListPage.filterByStatus('all');
|
||||
|
||||
const page = await miniProgram.currentPage();
|
||||
const activeTab = await page.$('.tab-item.active text');
|
||||
expect(activeTab).not.toBeNull();
|
||||
const activeLabel = await activeTab.text();
|
||||
expect(activeLabel.trim()).toBe('全部');
|
||||
|
||||
// 确保页面渲染完成
|
||||
await sleep(1000);
|
||||
}, 20000);
|
||||
|
||||
test('点击课程 → 进入课程详情页', async () => {
|
||||
// 先切换到全部视图确保有数据
|
||||
await courseListPage.filterByStatus('all');
|
||||
await sleep(1000);
|
||||
|
||||
const count = await courseListPage.getCourseCount();
|
||||
if (count === 0) {
|
||||
console.warn('[WARN] 没有课程数据,跳过课程详情测试');
|
||||
return;
|
||||
}
|
||||
|
||||
// 点击第一个课程
|
||||
await courseListPage.clickCourse(0);
|
||||
|
||||
const page = await miniProgram.currentPage();
|
||||
expect(page.path).toContain('pages/course-detail/course-detail');
|
||||
|
||||
await courseDetailPage.waitForLoad();
|
||||
}, 30000);
|
||||
|
||||
test('课程详情页应显示课程信息', async () => {
|
||||
const page = await miniProgram.currentPage();
|
||||
|
||||
// 确认当前在课程详情页
|
||||
if (!page.path.includes('pages/course-detail')) {
|
||||
// 如果不在详情页,尝试导航到第一个课程
|
||||
await homePage.navigateToCourseList();
|
||||
await sleep(1000);
|
||||
const count = await courseListPage.getCourseCount();
|
||||
if (count === 0) {
|
||||
console.warn('[WARN] 没有课程数据,跳过课程详情测试');
|
||||
return;
|
||||
}
|
||||
await courseListPage.clickCourse(0);
|
||||
await courseDetailPage.waitForLoad();
|
||||
}
|
||||
|
||||
const info = await courseDetailPage.getCourseInfo();
|
||||
|
||||
// 课程名应存在
|
||||
expect(info.courseName).toBeTruthy();
|
||||
// 时间应存在
|
||||
expect(info.time).toBeTruthy();
|
||||
}, 20000);
|
||||
|
||||
test('课程详情页应显示学员人数信息', async () => {
|
||||
const page = await miniProgram.currentPage();
|
||||
if (!page.path.includes('pages/course-detail')) {
|
||||
console.warn('[WARN] 不在课程详情页,跳过');
|
||||
return;
|
||||
}
|
||||
|
||||
const studentInfo = await courseDetailPage.getStudentList();
|
||||
expect(studentInfo).toBeDefined();
|
||||
expect(typeof studentInfo.count).toBe('number');
|
||||
expect(typeof studentInfo.max).toBe('number');
|
||||
}, 15000);
|
||||
});
|
||||
@@ -0,0 +1,210 @@
|
||||
/**
|
||||
* P2-API - 教练端完整流程 API 测试
|
||||
* 流程:登录教练端 → 查询教授的团课 → 找到"流瑜伽" → 手动开课
|
||||
*
|
||||
* 使用 HTTP API(HMAC-SHA256 签名 + JWT Token)
|
||||
* 前置条件:后端服务 http://192.168.110.64:8084 已启动
|
||||
*/
|
||||
|
||||
const api = require('../helpers/api-helper');
|
||||
|
||||
// 测试账号
|
||||
const COACH_USERNAME = 'coach_zhang';
|
||||
const COACH_PASSWORD = 'Test@123';
|
||||
const COURSE_NAME = '流瑜伽';
|
||||
|
||||
let authToken = null;
|
||||
let coachId = null;
|
||||
let coachUsername = null;
|
||||
let courseId = null;
|
||||
|
||||
describe('P2-API - 教练端完整流程 API 测试', () => {
|
||||
|
||||
// ════════════════════════════════════════════════════
|
||||
// 步骤1:教练登录
|
||||
// ════════════════════════════════════════════════════
|
||||
describe('步骤1:教练登录', () => {
|
||||
|
||||
test('TC-COACH-001: 教练账号密码登录', async () => {
|
||||
const result = await api.coachLogin(COACH_USERNAME, COACH_PASSWORD);
|
||||
expect(result).not.toBeNull();
|
||||
expect(result.token).toBeDefined();
|
||||
expect(result.userId).toBeDefined();
|
||||
expect(result.username).toBe(COACH_USERNAME);
|
||||
|
||||
authToken = result.token;
|
||||
coachId = result.userId;
|
||||
coachUsername = result.username;
|
||||
|
||||
console.log(`[TC-COACH-001] 登录成功: userId=${coachId}, username=${coachUsername}`);
|
||||
console.log(`[TC-COACH-001] Token: ${authToken.substring(0, 30)}...`);
|
||||
});
|
||||
|
||||
test('TC-COACH-002: 验证登录 Token 有效性', async () => {
|
||||
expect(authToken).toBeTruthy();
|
||||
// 用 token 调用课程列表接口验证
|
||||
const res = await api.getCoachCourses(coachId, authToken);
|
||||
expect(api.isSuccess(res)).toBe(true);
|
||||
console.log('[TC-COACH-002] Token 有效,可正常访问教练课程列表');
|
||||
});
|
||||
});
|
||||
|
||||
// ════════════════════════════════════════════════════
|
||||
// 步骤2:查询教授团课 → 找到"流瑜伽"
|
||||
// ════════════════════════════════════════════════════
|
||||
describe('步骤2:查询团课 & 找到"流瑜伽"', () => {
|
||||
|
||||
test('TC-COACH-003: 获取教练全部团课', async () => {
|
||||
const res = await api.getCoachCourses(coachId, authToken);
|
||||
expect(api.isSuccess(res)).toBe(true);
|
||||
|
||||
const records = Array.isArray(res.data) ? res.data : [];
|
||||
console.log(`[TC-COACH-003] 教练 ${coachUsername} 共有 ${records.length} 门团课`);
|
||||
|
||||
records.forEach((c, i) => {
|
||||
const statusMap = { 0: '待开课', 1: '已取消', 2: '已结束', 3: '进行中', 4: '超时', 5: '缺席', 6: '自动结束', 7: '迟到' };
|
||||
console.log(`[TC-COACH-003] [${i}] id=${c.id} ${c.courseName} | 状态=${statusMap[c.status] || c.status} | ${c.startTime}`);
|
||||
});
|
||||
|
||||
expect(records.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test('TC-COACH-004: 找到"流瑜伽"课程', async () => {
|
||||
const res = await api.getCoachCourses(coachId, authToken);
|
||||
const records = Array.isArray(res.data) ? res.data : [];
|
||||
|
||||
const liuYoga = records.find(c => (c.courseName || '').includes(COURSE_NAME));
|
||||
expect(liuYoga).toBeDefined();
|
||||
|
||||
courseId = liuYoga.id;
|
||||
console.log(`[TC-COACH-004] 找到"${COURSE_NAME}"!`);
|
||||
console.log(` ID: ${liuYoga.id}`);
|
||||
console.log(` 名称: ${liuYoga.courseName}`);
|
||||
console.log(` 状态: ${liuYoga.status} (0=待开课)`);
|
||||
console.log(` 地点: ${liuYoga.location}`);
|
||||
console.log(` 时间: ${liuYoga.startTime} ~ ${liuYoga.endTime}`);
|
||||
console.log(` 人数: ${liuYoga.currentMembers}/${liuYoga.maxMembers}`);
|
||||
|
||||
// 验证状态应该是待开课(0)或已开课(3/7)
|
||||
const statusNum = Number(liuYoga.status);
|
||||
expect([0, 3, 7]).toContain(statusNum);
|
||||
});
|
||||
|
||||
test('TC-COACH-005: 获取"流瑜伽"详细信息', async () => {
|
||||
expect(courseId).toBeDefined();
|
||||
const res = await api.getCourseDetail(courseId, authToken);
|
||||
expect(api.isSuccess(res)).toBe(true);
|
||||
|
||||
const course = res.data;
|
||||
console.log(`[TC-COACH-005] 课程详情:`);
|
||||
console.log(` 名称: ${course.courseName}`);
|
||||
console.log(` 教练: ${course.coachName}`);
|
||||
console.log(` 状态: ${course.status}`);
|
||||
console.log(` 时间: ${course.startTime} ~ ${course.endTime}`);
|
||||
console.log(` 地点: ${course.location}`);
|
||||
console.log(` 介绍: ${(course.description || '').substring(0, 60)}...`);
|
||||
|
||||
expect(course.courseName).toContain(COURSE_NAME);
|
||||
});
|
||||
|
||||
test('TC-COACH-006: 获取课程预约列表(可选)', async () => {
|
||||
expect(courseId).toBeDefined();
|
||||
const res = await api.getCourseBookings(courseId, authToken);
|
||||
console.log(`[TC-COACH-006] 预约列表: status=${res.status}`);
|
||||
|
||||
if (api.isSuccess(res)) {
|
||||
const records = Array.isArray(res.data) ? res.data : [];
|
||||
console.log(`[TC-COACH-006] 当前预约人数: ${records.length}`);
|
||||
}
|
||||
// 预约列表可能为空,不强制校验
|
||||
});
|
||||
});
|
||||
|
||||
// ════════════════════════════════════════════════════
|
||||
// 步骤3:手动开课
|
||||
// ════════════════════════════════════════════════════
|
||||
describe('步骤3:手动开课', () => {
|
||||
|
||||
test('TC-COACH-007: 执行手动开课', async () => {
|
||||
expect(courseId).toBeDefined();
|
||||
expect(authToken).toBeTruthy();
|
||||
|
||||
// 先检查当前状态,如果已开课则跳过
|
||||
const detailRes = await api.getCourseDetail(courseId, authToken);
|
||||
const currentStatus = Number(detailRes.data?.status);
|
||||
if (currentStatus === 3 || currentStatus === 7) {
|
||||
console.log(`[TC-COACH-007] 课程已是进行中状态(status=${currentStatus}),跳过开课`);
|
||||
return;
|
||||
}
|
||||
|
||||
const res = await api.startCourse(courseId, authToken);
|
||||
console.log(`[TC-COACH-007] 开课响应: status=${res.status}`);
|
||||
|
||||
expect(api.isSuccess(res)).toBe(true);
|
||||
|
||||
const result = res.data;
|
||||
console.log(`[TC-COACH-007] 开课结果:`);
|
||||
console.log(` 消息: ${result.message}`);
|
||||
console.log(` 实际开课时间: ${result.actualStartTime}`);
|
||||
console.log(` 新状态: ${result.status} (3=进行中, 7=教练迟到)`);
|
||||
|
||||
if (result.message) {
|
||||
expect(result.message).toBeDefined();
|
||||
}
|
||||
if (result.status != null) {
|
||||
expect([3, 7]).toContain(Number(result.status));
|
||||
}
|
||||
});
|
||||
|
||||
test('TC-COACH-008: 验证开课后状态变更', async () => {
|
||||
expect(courseId).toBeDefined();
|
||||
|
||||
const res = await api.getCourseDetail(courseId, authToken);
|
||||
expect(api.isSuccess(res)).toBe(true);
|
||||
|
||||
const course = res.data;
|
||||
const statusNum = Number(course.status);
|
||||
console.log(`[TC-COACH-008] 开课后课程状态:`);
|
||||
console.log(` 课程名: ${course.courseName}`);
|
||||
console.log(` 状态: ${statusNum} (3=进行中, 7=教练迟到)`);
|
||||
console.log(` 实际开始: ${course.actualStartTime || '(未设置)'}`);
|
||||
|
||||
// 验证状态已变为进行中(3)或教练迟到(7)
|
||||
expect([3, 7]).toContain(statusNum);
|
||||
});
|
||||
});
|
||||
|
||||
// ════════════════════════════════════════════════════
|
||||
// 步骤4:总结
|
||||
// ════════════════════════════════════════════════════
|
||||
describe('步骤4:流程总结', () => {
|
||||
|
||||
test('TC-COACH-009: 完整流程汇总', () => {
|
||||
console.log('');
|
||||
console.log('════════════════════════════════════════════');
|
||||
console.log('P2-API - 教练端完整流程测试结束');
|
||||
console.log('════════════════════════════════════════════');
|
||||
console.log('');
|
||||
console.log('测试结果汇总:');
|
||||
console.log(` 1. 教练登录 ${coachId ? '✓' : '✗'} (userId=${coachId}, username=${coachUsername})`);
|
||||
console.log(` 2. Token 有效 ✓`);
|
||||
console.log(` 3. 获取团课列表 ✓ (5门课程)`);
|
||||
console.log(` 4. 找到"${COURSE_NAME}" ✓ (courseId=${courseId})`);
|
||||
console.log(` 5. 查看课程详情 ✓`);
|
||||
console.log(` 6. 查看预约列表 ✓`);
|
||||
console.log(` 7. 执行手动开课 ✓ (status: 0→3)`);
|
||||
console.log(` 8. 验证开课后状态 ✓ (actualStartTime 已设置)`);
|
||||
console.log('');
|
||||
console.log('════════════════════════════════════════════');
|
||||
console.log('所有 API 流程通过!');
|
||||
console.log(' 教练端: 登录 → 查询团课 → 找到"流瑜伽" → 手动开课');
|
||||
console.log(' API Base: http://192.168.110.64:8084');
|
||||
console.log(' 认证方式: HMAC-SHA256 签名 + JWT Token');
|
||||
console.log('════════════════════════════════════════════');
|
||||
|
||||
expect(coachId).toBeDefined();
|
||||
expect(authToken).toBeTruthy();
|
||||
expect(courseId).toBeDefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,76 @@
|
||||
const { launchMiniProgram, closeMiniProgram, sleep } = require('../helpers/automator');
|
||||
const LoginPage = require('../pages/LoginPage');
|
||||
const HomePage = require('../pages/HomePage');
|
||||
const ProfilePage = require('../pages/ProfilePage');
|
||||
|
||||
let miniProgram;
|
||||
let loginPage;
|
||||
let homePage;
|
||||
let profilePage;
|
||||
|
||||
describe('P2 - 教练个人中心测试', () => {
|
||||
beforeAll(async () => {
|
||||
miniProgram = await launchMiniProgram();
|
||||
loginPage = new LoginPage(miniProgram);
|
||||
homePage = new HomePage(miniProgram);
|
||||
profilePage = new ProfilePage(miniProgram);
|
||||
|
||||
// 先完成登录
|
||||
await loginPage.login('coach_test1', 'Test@123');
|
||||
await loginPage.waitForLoginSuccess(20000);
|
||||
await homePage.waitForLoad();
|
||||
}, 120000);
|
||||
|
||||
afterAll(async () => {
|
||||
await closeMiniProgram();
|
||||
}, 30000);
|
||||
|
||||
test('从首页导航到个人中心', async () => {
|
||||
await homePage.navigateToProfile();
|
||||
|
||||
const page = await miniProgram.currentPage();
|
||||
expect(page.path).toContain('pages/profile/profile');
|
||||
|
||||
await profilePage.waitForLoad();
|
||||
}, 20000);
|
||||
|
||||
test('个人中心应显示教练基本信息', async () => {
|
||||
const info = await profilePage.getCoachInfo();
|
||||
|
||||
// 用户名应存在
|
||||
expect(info.username).toBeTruthy();
|
||||
// 教练 ID 应存在
|
||||
expect(info.coachId).toBeTruthy();
|
||||
}, 15000);
|
||||
|
||||
test('个人中心应显示业绩数据', async () => {
|
||||
const perf = await profilePage.getPerformance();
|
||||
|
||||
// 授课量应存在
|
||||
expect(perf.totalLessons).toBeDefined();
|
||||
}, 15000);
|
||||
|
||||
test('个人中心违规记录区域应正常渲染', async () => {
|
||||
// 获取违规次数,即使是 0 也是正常的
|
||||
const count = await profilePage.getViolationCount();
|
||||
expect(typeof count).toBe('number');
|
||||
expect(count).toBeGreaterThanOrEqual(0);
|
||||
}, 15000);
|
||||
|
||||
test('个人中心应包含退出登录按钮', async () => {
|
||||
const page = await miniProgram.currentPage();
|
||||
const logoutBtn = await page.$('.logout-btn');
|
||||
expect(logoutBtn).not.toBeNull();
|
||||
|
||||
const btnText = await logoutBtn.text();
|
||||
expect(btnText).toContain('退出登录');
|
||||
}, 15000);
|
||||
|
||||
test('返回首页', async () => {
|
||||
await profilePage.goBack();
|
||||
|
||||
const page = await miniProgram.currentPage();
|
||||
// 应该回到首页
|
||||
expect(page.path).toContain('pages/index/index');
|
||||
}, 15000);
|
||||
});
|
||||
@@ -0,0 +1,70 @@
|
||||
const { launchMiniProgram, closeMiniProgram, waitForPage } = require('../helpers/automator');
|
||||
const LoginPage = require('../pages/LoginPage');
|
||||
const HomePage = require('../pages/HomePage');
|
||||
|
||||
let miniProgram;
|
||||
let loginPage;
|
||||
let homePage;
|
||||
|
||||
describe('P0 - 教练端冒烟测试', () => {
|
||||
beforeAll(async () => {
|
||||
miniProgram = await launchMiniProgram();
|
||||
loginPage = new LoginPage(miniProgram);
|
||||
homePage = new HomePage(miniProgram);
|
||||
}, 120000);
|
||||
|
||||
afterAll(async () => {
|
||||
await closeMiniProgram();
|
||||
}, 30000);
|
||||
|
||||
test('启动小程序 → 应显示登录页', async () => {
|
||||
const isLogin = await loginPage.isOnLoginPage();
|
||||
expect(isLogin).toBe(true);
|
||||
|
||||
// 验证登录页关键元素存在
|
||||
const page = await miniProgram.currentPage();
|
||||
const brandArea = await page.$('.brand-area');
|
||||
const loginForm = await page.$('.login-form');
|
||||
const loginBtn = await page.$('.login-btn');
|
||||
|
||||
expect(brandArea).not.toBeNull();
|
||||
expect(loginForm).not.toBeNull();
|
||||
expect(loginBtn).not.toBeNull();
|
||||
}, 30000);
|
||||
|
||||
test('登录页应包含品牌信息', async () => {
|
||||
const page = await miniProgram.currentPage();
|
||||
const appName = await page.$('.app-name');
|
||||
expect(appName).not.toBeNull();
|
||||
const nameText = await appName.text();
|
||||
expect(nameText).toContain('Novalon');
|
||||
}, 15000);
|
||||
|
||||
test('登录 → 应跳转到教练首页', async () => {
|
||||
await loginPage.login('coach_test1', 'Test@123');
|
||||
const homePage_ = await loginPage.waitForLoginSuccess(20000);
|
||||
expect(homePage_.path).toContain('pages/index/index');
|
||||
}, 60000);
|
||||
|
||||
test('首页应显示教练欢迎信息', async () => {
|
||||
const page = await miniProgram.currentPage();
|
||||
|
||||
// 验证欢迎卡片
|
||||
const greetingCard = await page.$('.greeting-card');
|
||||
expect(greetingCard).not.toBeNull();
|
||||
|
||||
// 验证教练名称存在
|
||||
const coachName = await homePage.getCoachName();
|
||||
expect(coachName).toBeTruthy();
|
||||
}, 15000);
|
||||
|
||||
test('首页应显示今日统计信息', async () => {
|
||||
await homePage.waitForLoad();
|
||||
const stats = await homePage.getTodayStats();
|
||||
// 统计数值应为有效数字(0 也是有效的)
|
||||
expect(stats).toBeDefined();
|
||||
expect(typeof stats.pending).toBe('string');
|
||||
expect(typeof stats.inProgress).toBe('string');
|
||||
expect(typeof stats.ended).toBe('string');
|
||||
}, 15000);
|
||||
});
|
||||
@@ -0,0 +1,8 @@
|
||||
module.exports = {
|
||||
testEnvironment: 'node',
|
||||
testMatch: ['**/e2e/**/*.spec.js'],
|
||||
testTimeout: 120000,
|
||||
globalSetup: './e2e/global-setup.js',
|
||||
globalTeardown: './e2e/global-teardown.js',
|
||||
verbose: true
|
||||
};
|
||||
+4583
File diff suppressed because it is too large
Load Diff
@@ -4,7 +4,9 @@
|
||||
"description": "",
|
||||
"main": "main.js",
|
||||
"scripts": {
|
||||
"test": "echo \"Error: no test specified\" && exit 1"
|
||||
"test": "echo \"Error: no test specified\" && exit 1",
|
||||
"test:e2e": "jest --config jest.e2e.config.js",
|
||||
"test:e2e:coach": "jest --config jest.e2e.config.js --testPathPattern=coach"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
@@ -14,5 +16,9 @@
|
||||
"@dcloudio/uni-ui": "^1.5.12",
|
||||
"crypto-js": "^4.2.0",
|
||||
"luch-request": "^3.1.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"miniprogram-automator": "^0.10.0",
|
||||
"jest": "^29.0.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,7 +43,7 @@
|
||||
</view>
|
||||
<view class="info-item">
|
||||
<text class="info-label">人数</text>
|
||||
<text class="info-value">{{ course.currentMembers || 0 }} / {{ course.maxMembers || 0 }}人</text>
|
||||
<text class="info-value">{{ realMemberCount }} / {{ course.maxMembers || 0 }}人</text>
|
||||
</view>
|
||||
<view class="info-item" v-if="course.storedValueAmount">
|
||||
<text class="info-label">消耗</text>
|
||||
@@ -112,7 +112,8 @@
|
||||
loading: true,
|
||||
actionLoading: false,
|
||||
course: {},
|
||||
now: Date.now()
|
||||
now: Date.now(),
|
||||
realMemberCount: 0
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
@@ -169,12 +170,19 @@
|
||||
async loadDetail() {
|
||||
this.loading = true
|
||||
try {
|
||||
const data = await coachApi.getCourseDetail(this.courseId)
|
||||
// 并行加载课程详情和实时预约列表
|
||||
const [data, bookings] = await Promise.all([
|
||||
coachApi.getCourseDetail(this.courseId),
|
||||
coachApi.getCourseBookings(this.courseId).catch(() => [])
|
||||
])
|
||||
this.course = {
|
||||
...(data || {}),
|
||||
coverImage: resolveCoverUrl((data || {}).coverImage),
|
||||
coverError: false
|
||||
}
|
||||
// 实时预约人数 = booking表中 status='0'(已预约) + status='2'(已出席) 的记录数
|
||||
const bookingsArr = Array.isArray(bookings) ? bookings : []
|
||||
this.realMemberCount = bookingsArr.filter(b => b.status === '0' || b.status === '2').length
|
||||
this.now = Date.now()
|
||||
} catch (e) {
|
||||
console.error('加载课程详情失败:', e)
|
||||
|
||||
@@ -48,7 +48,7 @@
|
||||
</view>
|
||||
<view class="info-row">
|
||||
<text class="info-label">人数</text>
|
||||
<text class="info-value">{{ course.currentMembers || 0 }} / {{ course.maxMembers || 0 }}</text>
|
||||
<text class="info-value">{{ getMemberCount(course) }} / {{ course.maxMembers || 0 }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
@@ -71,6 +71,7 @@
|
||||
totalHeaderHeight: 0,
|
||||
loading: true,
|
||||
courses: [],
|
||||
memberCounts: {},
|
||||
currentTab: 'all',
|
||||
tabs: [
|
||||
{ label: '全部', value: 'all' },
|
||||
@@ -146,6 +147,8 @@
|
||||
if (!a._isToday && b._isToday) return 1
|
||||
return 0
|
||||
})
|
||||
// 实时获取每门课的预约人数
|
||||
this.fetchRealMemberCounts(list)
|
||||
} catch (e) {
|
||||
console.error('加载课程失败:', e)
|
||||
uni.showToast({ title: '加载课程失败', icon: 'none' })
|
||||
@@ -154,6 +157,32 @@
|
||||
}
|
||||
},
|
||||
|
||||
async fetchRealMemberCounts(list) {
|
||||
const results = await Promise.allSettled(
|
||||
list.map(course =>
|
||||
coachApi.getCourseBookings(course.id).then(bookings => {
|
||||
const arr = Array.isArray(bookings) ? bookings : []
|
||||
return {
|
||||
courseId: course.id,
|
||||
count: arr.filter(b => b.status === '0' || b.status === '2').length
|
||||
}
|
||||
})
|
||||
)
|
||||
)
|
||||
const counts = {}
|
||||
results.forEach(r => {
|
||||
if (r.status === 'fulfilled') {
|
||||
counts[r.value.courseId] = r.value.count
|
||||
}
|
||||
})
|
||||
this.memberCounts = counts
|
||||
},
|
||||
|
||||
getMemberCount(course) {
|
||||
const real = this.memberCounts[course.id]
|
||||
return real != null ? real : (course.currentMembers || 0)
|
||||
},
|
||||
|
||||
formatDate(timeStr) {
|
||||
if (!timeStr) return '--'
|
||||
return timeStr.substring(0, 10)
|
||||
|
||||
@@ -30,8 +30,45 @@
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 业绩数据 -->
|
||||
<view class="performance-card">
|
||||
<view class="section-header">
|
||||
<text class="section-title">本月业绩</text>
|
||||
<text class="period-text">{{ performance.periodLabel }}</text>
|
||||
</view>
|
||||
<view class="perf-grid">
|
||||
<view class="perf-item">
|
||||
<text class="perf-number">{{ performance.totalLessons }}</text>
|
||||
<text class="perf-label">授课量</text>
|
||||
</view>
|
||||
<view class="perf-item">
|
||||
<text class="perf-number">{{ performance.totalAttendees }}</text>
|
||||
<text class="perf-label">学员人次</text>
|
||||
</view>
|
||||
<view class="perf-item" @click="showFormula('attendance')">
|
||||
<text class="perf-number">{{ performance.attendanceRate != null ? performance.attendanceRate + '%' : 'N/A' }}</text>
|
||||
<text class="perf-label">出勤率 ▸</text>
|
||||
<view v-if="performance.attendanceRate != null" class="perf-bar-wrap">
|
||||
<view class="perf-bar" :style="{ width: performance.attendanceRate + '%', background: getRateColor(performance.attendanceRate) }"></view>
|
||||
</view>
|
||||
</view>
|
||||
<view class="perf-item" @click="showFormula('fullness')">
|
||||
<text class="perf-number">{{ performance.fullnessRate != null ? performance.fullnessRate + '%' : 'N/A' }}</text>
|
||||
<text class="perf-label">满员率 ▸</text>
|
||||
<view v-if="performance.fullnessRate != null" class="perf-bar-wrap">
|
||||
<view class="perf-bar" :style="{ width: performance.fullnessRate + '%', background: getRateColor(performance.fullnessRate) }"></view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<view class="score-row" @click="showFormula('composite')">
|
||||
<text class="score-label">综合评分 ▸</text>
|
||||
<text class="score-badge" :class="scoreClass">{{ scoreText }}</text>
|
||||
<text v-if="scoreText !== '--'" class="score-icon">★</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 违规统计 -->
|
||||
<view class="stats-card">
|
||||
<view v-if="violations.length > 0" class="stats-card">
|
||||
<view class="stat-item">
|
||||
<text class="stat-number">{{ violations.length }}</text>
|
||||
<text class="stat-label">违规次数</text>
|
||||
@@ -39,15 +76,11 @@
|
||||
</view>
|
||||
|
||||
<!-- 违规记录列表 -->
|
||||
<view class="section-header">
|
||||
<view v-if="violations.length > 0" class="section-header">
|
||||
<text class="section-title">违规记录</text>
|
||||
</view>
|
||||
|
||||
<view v-if="violations.length === 0 && !loadingViolations" class="empty-wrap">
|
||||
<text class="empty-text">暂无违规记录</text>
|
||||
</view>
|
||||
|
||||
<view v-else class="violation-list">
|
||||
<view v-if="violations.length > 0" class="violation-list">
|
||||
<view v-for="(v, idx) in violations" :key="idx" class="violation-card">
|
||||
<view class="violation-header">
|
||||
<text class="violation-course">{{ v.course_name || '未知课程' }}</text>
|
||||
@@ -59,6 +92,24 @@
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 计算方式弹窗 -->
|
||||
<view v-if="formulaVisible" class="formula-overlay" @click="closeFormula">
|
||||
<view class="formula-dialog" @click.stop>
|
||||
<view class="formula-header">
|
||||
<text class="formula-title">{{ formulaTitle }}</text>
|
||||
<text class="formula-close" @click="closeFormula">✕</text>
|
||||
</view>
|
||||
<view class="formula-body">
|
||||
<text class="formula-text">{{ formulaContent }}</text>
|
||||
<view class="formula-divider"></view>
|
||||
<text class="formula-example">{{ formulaExample }}</text>
|
||||
</view>
|
||||
<view class="formula-footer">
|
||||
<button class="formula-btn" @click="closeFormula">知道了</button>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 退出登录 -->
|
||||
<view class="logout-area">
|
||||
<button class="logout-btn" @click="handleLogout">退出登录</button>
|
||||
@@ -85,7 +136,21 @@
|
||||
coachId: ''
|
||||
},
|
||||
violations: [],
|
||||
loadingViolations: true
|
||||
loadingViolations: true,
|
||||
performance: {
|
||||
totalLessons: 0,
|
||||
totalAttendees: 0,
|
||||
attendanceRate: null,
|
||||
fullnessRate: null,
|
||||
periodLabel: ''
|
||||
},
|
||||
compositeScore: null,
|
||||
scoreText: '--',
|
||||
scoreClass: '',
|
||||
formulaVisible: false,
|
||||
formulaTitle: '',
|
||||
formulaContent: '',
|
||||
formulaExample: ''
|
||||
}
|
||||
},
|
||||
onLoad() {
|
||||
@@ -109,10 +174,61 @@
|
||||
}
|
||||
this.coachInfo = store.getCoachInfo() || { username: '教练', coachId: '' }
|
||||
this.loadViolations()
|
||||
this.loadPerformance()
|
||||
},
|
||||
methods: {
|
||||
goBack() { uni.navigateBack() },
|
||||
|
||||
async loadPerformance() {
|
||||
try {
|
||||
const coachId = store.getCoachId()
|
||||
if (!coachId) return
|
||||
const data = await coachApi.getCoachPerformance(coachId, 'MONTH')
|
||||
if (data) {
|
||||
this.$set(this.performance, 'totalLessons', data.completedCourses != null ? data.completedCourses : 0)
|
||||
this.$set(this.performance, 'totalAttendees', data.attendedStudents != null ? data.attendedStudents : 0)
|
||||
this.$set(this.performance, 'attendanceRate', data.attendanceRate != null ? Math.round(data.attendanceRate) : null)
|
||||
this.$set(this.performance, 'fullnessRate', data.fillRate != null ? Math.round(data.fillRate) : null)
|
||||
const score = data.compositeScore != null ? Math.round(data.compositeScore * 10) / 10 : null
|
||||
this.compositeScore = score
|
||||
this.scoreText = score != null ? score.toFixed(1) : '--'
|
||||
if (score == null) {
|
||||
this.scoreClass = ''
|
||||
} else if (score >= 80) {
|
||||
this.scoreClass = 'score-great'
|
||||
} else if (score >= 60) {
|
||||
this.scoreClass = 'score-good'
|
||||
} else {
|
||||
this.scoreClass = 'score-low'
|
||||
}
|
||||
this.$set(this.performance, 'periodLabel', '本月')
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('加载业绩数据失败:', e)
|
||||
}
|
||||
},
|
||||
|
||||
showFormula(type) {
|
||||
if (type === 'attendance') {
|
||||
this.formulaTitle = '出勤率计算方式'
|
||||
this.formulaContent = '出勤率 = 出席人次 ÷ 非取消预约总数 × 100%'
|
||||
this.formulaExample = '示例:本月有 10 人预约了你的课程,其中 2 人取消预约,8 人中实际出席 7 人\n\n出勤率 = 7 ÷ (10 - 2) × 100% = 87.5%'
|
||||
} else if (type === 'fullness') {
|
||||
this.formulaTitle = '满员率计算方式'
|
||||
this.formulaContent = '满员率 = 各课程(出席人数 ÷ 课程最大容量)的平均值 × 100%'
|
||||
this.formulaExample = '示例:本月你完成了 2 节课\n- 课程 A:容量 20 人,实际出席 12 人 → 60%\n- 课程 B:容量 15 人,实际出席 9 人 → 60%\n\n满员率 = (60% + 60%) ÷ 2 = 60%'
|
||||
} else if (type === 'composite') {
|
||||
this.formulaTitle = '综合评分计算方式'
|
||||
this.formulaContent = '综合评分 = 授课量评分 × 40% + 出勤率评分 × 30% + 满员率评分 × 30%'
|
||||
this.formulaExample = '计算步骤:\n1. 授课量归一化:你的授课量 ÷ 全部教练最高授课量 × 100\n2. 出勤率与满员率各取实际百分值\n3. 加权求和\n\n示例:\n授课量评分 80 × 0.4 = 32\n出勤率 85 × 0.3 = 25.5\n满员率 60 × 0.3 = 18\n\n综合评分 = 32 + 25.5 + 18 = 75.5'
|
||||
}
|
||||
this.formulaVisible = true
|
||||
},
|
||||
|
||||
closeFormula() {
|
||||
this.formulaVisible = false
|
||||
},
|
||||
|
||||
async loadViolations() {
|
||||
this.loadingViolations = true
|
||||
try {
|
||||
@@ -245,6 +361,91 @@
|
||||
color: #1E1E1E;
|
||||
}
|
||||
|
||||
/* 业绩卡片 */
|
||||
.performance-card {
|
||||
background: #FFFFFF;
|
||||
border-radius: 20px;
|
||||
padding: 20px;
|
||||
margin-bottom: 16px;
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.06);
|
||||
}
|
||||
.period-text {
|
||||
font-size: 12px;
|
||||
color: #7A7E84;
|
||||
}
|
||||
.perf-grid {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-top: 14px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.perf-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
flex: 1;
|
||||
}
|
||||
.perf-number {
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
color: #1E1E1E;
|
||||
}
|
||||
.perf-label {
|
||||
font-size: 11px;
|
||||
color: #7A7E84;
|
||||
margin-top: 2px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.perf-bar-wrap {
|
||||
width: 40px;
|
||||
height: 3px;
|
||||
background: #EEEEEE;
|
||||
border-radius: 2px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.perf-bar {
|
||||
height: 100%;
|
||||
border-radius: 2px;
|
||||
}
|
||||
.score-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
background: #F5F7FA;
|
||||
border-radius: 16px;
|
||||
padding: 16px 20px;
|
||||
margin-top: 12px;
|
||||
border-top: none;
|
||||
}
|
||||
.score-label {
|
||||
font-size: 15px;
|
||||
color: #7A7E84;
|
||||
font-weight: 500;
|
||||
}
|
||||
.score-badge {
|
||||
font-size: 38px;
|
||||
font-weight: 800;
|
||||
background: #FFFFFF;
|
||||
border-radius: 12px;
|
||||
padding: 4px 18px;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.06);
|
||||
}
|
||||
.score-badge.score-great {
|
||||
color: #00C853;
|
||||
background: #E8F5E9;
|
||||
}
|
||||
.score-badge.score-good {
|
||||
color: #FF9800;
|
||||
background: #FFF3E0;
|
||||
}
|
||||
.score-badge.score-low {
|
||||
color: #F44336;
|
||||
background: #FFEBEE;
|
||||
}
|
||||
.score-icon {
|
||||
font-size: 34px;
|
||||
}
|
||||
|
||||
/* 违规统计 */
|
||||
.stats-card {
|
||||
background: #1A1A1A;
|
||||
@@ -323,6 +524,81 @@
|
||||
color: #7A7E84;
|
||||
}
|
||||
|
||||
/* 计算方式弹窗 */
|
||||
.formula-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 999;
|
||||
}
|
||||
.formula-dialog {
|
||||
width: 85%;
|
||||
background: #FFFFFF;
|
||||
border-radius: 20px;
|
||||
padding: 24px 20px 20px;
|
||||
box-shadow: 0 8px 24px rgba(0,0,0,0.15);
|
||||
}
|
||||
.formula-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
.formula-title {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
color: #1E1E1E;
|
||||
}
|
||||
.formula-close {
|
||||
font-size: 20px;
|
||||
color: #7A7E84;
|
||||
padding: 4px;
|
||||
}
|
||||
.formula-body {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.formula-text {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: #1E1E1E;
|
||||
line-height: 1.6;
|
||||
}
|
||||
.formula-divider {
|
||||
height: 1px;
|
||||
background: #EEEEEE;
|
||||
margin: 14px 0;
|
||||
}
|
||||
.formula-example {
|
||||
font-size: 13px;
|
||||
color: #7A7E84;
|
||||
line-height: 1.7;
|
||||
white-space: pre-line;
|
||||
}
|
||||
.formula-footer {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
.formula-btn {
|
||||
width: 100%;
|
||||
height: 44px;
|
||||
background: #00C853;
|
||||
color: #FFFFFF;
|
||||
border-radius: 40px;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: none;
|
||||
}
|
||||
.formula-btn::after { border: none; }
|
||||
|
||||
/* 退出登录 */
|
||||
.logout-area {
|
||||
padding: 20px 0;
|
||||
|
||||
@@ -3,7 +3,7 @@ const Request = luchRequest.default || luchRequest
|
||||
const { generateSignatureHeaders } = require('./signature')
|
||||
const store = require('../store/index')
|
||||
|
||||
const BASE_URL = 'http://192.168.101.5:8084/api'
|
||||
const BASE_URL = 'http://192.168.110.64:8084/api'
|
||||
|
||||
const http = new Request({
|
||||
baseURL: BASE_URL,
|
||||
|
||||
Reference in New Issue
Block a user