forked from wangziqi/gongxue-base
2438 lines
125 KiB
JavaScript
2438 lines
125 KiB
JavaScript
import { spawn } from 'node:child_process';
|
||
import fs from 'node:fs';
|
||
import http from 'node:http';
|
||
import https from 'node:https';
|
||
import net from 'node:net';
|
||
import os from 'node:os';
|
||
import path from 'node:path';
|
||
import { setTimeout as delay } from 'node:timers/promises';
|
||
import selfsigned from 'selfsigned';
|
||
|
||
const repoRoot = process.cwd();
|
||
const distRoot = path.join(repoRoot, 'apps', 'taro', 'dist');
|
||
const outputDir = process.env.TARO_H5_INTERACTION_OUTPUT_DIR || 'docs/refactor/launch-artifacts';
|
||
const smokeApiHostname = 'api-smoke.gongxue100.com';
|
||
const smokeTls = selfsigned.generate([{ name: 'commonName', value: smokeApiHostname }], {
|
||
days: 1,
|
||
keySize: 2048,
|
||
algorithm: 'sha256',
|
||
extensions: [{
|
||
name: 'subjectAltName',
|
||
altNames: [{ type: 2, value: smokeApiHostname }],
|
||
}],
|
||
});
|
||
|
||
const ids = {
|
||
tenant: '00000000-0000-4000-8000-000000000001',
|
||
user: '00000000-0000-4000-8000-000000000101',
|
||
region: '00000000-0000-4000-8000-000000000301',
|
||
entry: '00000000-0000-4000-8000-000000000401',
|
||
node: '00000000-0000-4000-8000-000000000402',
|
||
collection: '00000000-0000-4000-8000-000000000403',
|
||
blueprint: '00000000-0000-4000-8000-000000000404',
|
||
question: '00000000-0000-4000-8000-000000000501',
|
||
session: '00000000-0000-4000-8000-000000000601',
|
||
plan: '00000000-0000-4000-8000-000000000701',
|
||
order: '00000000-0000-4000-8000-000000000801',
|
||
vocabularyUnit: '00000000-0000-4000-8000-000000000901',
|
||
vocabularyWord: '00000000-0000-4000-8000-000000000902',
|
||
handbookSubject: '00000000-0000-4000-8000-000000001001',
|
||
handbookChapter: '00000000-0000-4000-8000-000000001002',
|
||
handbookEntry: '00000000-0000-4000-8000-000000001003',
|
||
asset: '00000000-0000-4000-8000-000000001101',
|
||
video: '00000000-0000-4000-8000-000000001201',
|
||
school: '00000000-0000-4000-8000-000000001301',
|
||
major: '00000000-0000-4000-8000-000000001302',
|
||
scorelineField: '00000000-0000-4000-8000-000000001303',
|
||
scorelineRecord: '00000000-0000-4000-8000-000000001304',
|
||
notification: '00000000-0000-4000-8000-000000001401',
|
||
aiReport: '00000000-0000-4000-8000-000000001501',
|
||
class: '00000000-0000-4000-8000-000000001601',
|
||
teacher: '00000000-0000-4000-8000-000000001602',
|
||
tenantMember: '00000000-0000-4000-8000-000000001603',
|
||
roleTemplate: '00000000-0000-4000-8000-000000001604',
|
||
importJob: '00000000-0000-4000-8000-000000001701',
|
||
publicBank: '00000000-0000-4000-8000-000000001801',
|
||
publicBankGrant: '00000000-0000-4000-8000-000000001802',
|
||
publicBankAdoption: '00000000-0000-4000-8000-000000001803',
|
||
crmQueue: '00000000-0000-4000-8000-000000001901',
|
||
coupon: '00000000-0000-4000-8000-000000002001',
|
||
pointTask: '00000000-0000-4000-8000-000000002101',
|
||
pointExchange: '00000000-0000-4000-8000-000000002102',
|
||
settlement: '00000000-0000-4000-8000-000000002201',
|
||
proof: '00000000-0000-4000-8000-000000002202',
|
||
platformStaff: '00000000-0000-4000-8000-000000002301',
|
||
invoice: '00000000-0000-4000-8000-000000002401',
|
||
};
|
||
|
||
const portals = [
|
||
{ portal: 'student', dist: 'h5-student', landingPath: '/pages/student/home/index' },
|
||
{ portal: 'tenant-admin', dist: 'h5-tenant-admin', landingPath: '/pages/tenant-admin/workbench/index' },
|
||
{ portal: 'platform-admin', dist: 'h5-platform-admin', landingPath: '/pages/platform-admin/workbench/index' },
|
||
];
|
||
|
||
function parseArgs(argv) {
|
||
return {
|
||
json: argv.includes('--json'),
|
||
keepBrowser: argv.includes('--keep-browser'),
|
||
serveOnly: argv.includes('--serve-only'),
|
||
};
|
||
}
|
||
|
||
function waitForShutdownSignal() {
|
||
return new Promise(resolve => {
|
||
const shutdown = signal => resolve(signal);
|
||
process.once('SIGINT', shutdown);
|
||
process.once('SIGTERM', shutdown);
|
||
});
|
||
}
|
||
|
||
function shanghaiTimestampForFile(date = new Date()) {
|
||
const parts = Object.fromEntries(
|
||
new Intl.DateTimeFormat('en-CA', {
|
||
timeZone: 'Asia/Shanghai',
|
||
year: 'numeric',
|
||
month: '2-digit',
|
||
day: '2-digit',
|
||
hour: '2-digit',
|
||
minute: '2-digit',
|
||
second: '2-digit',
|
||
hour12: false,
|
||
}).formatToParts(date).map(part => [part.type, part.value]),
|
||
);
|
||
return `${parts.year}${parts.month}${parts.day}-${parts.hour}${parts.minute}${parts.second}`;
|
||
}
|
||
|
||
function normalizeSlashes(value) {
|
||
return value.replace(/\\/g, '/');
|
||
}
|
||
|
||
function relative(filePath) {
|
||
return normalizeSlashes(path.relative(repoRoot, filePath));
|
||
}
|
||
|
||
function contentTypeFor(filePath) {
|
||
const ext = path.extname(filePath).toLowerCase();
|
||
if (ext === '.html') return 'text/html; charset=utf-8';
|
||
if (ext === '.js') return 'application/javascript; charset=utf-8';
|
||
if (ext === '.css') return 'text/css; charset=utf-8';
|
||
if (ext === '.json') return 'application/json; charset=utf-8';
|
||
if (ext === '.svg') return 'image/svg+xml';
|
||
if (ext === '.png') return 'image/png';
|
||
if (ext === '.jpg' || ext === '.jpeg') return 'image/jpeg';
|
||
if (ext === '.webp') return 'image/webp';
|
||
if (ext === '.woff2') return 'font/woff2';
|
||
if (ext === '.woff') return 'font/woff';
|
||
return 'application/octet-stream';
|
||
}
|
||
|
||
function jsonResponse(response, statusCode, payload, extraHeaders = {}) {
|
||
response.writeHead(statusCode, {
|
||
'content-type': 'application/json; charset=utf-8',
|
||
'cache-control': 'no-store',
|
||
'access-control-allow-origin': '*',
|
||
'access-control-allow-methods': 'GET,POST,PUT,PATCH,DELETE,OPTIONS',
|
||
'access-control-allow-headers': 'authorization,content-type,x-tenant-id,x-smoke-portal',
|
||
...extraHeaders,
|
||
});
|
||
response.end(`${JSON.stringify(payload)}\n`);
|
||
}
|
||
|
||
function textResponse(response, statusCode, body, headers = {}) {
|
||
response.writeHead(statusCode, headers);
|
||
response.end(body);
|
||
}
|
||
|
||
function requestBody(req) {
|
||
return new Promise(resolve => {
|
||
let raw = '';
|
||
req.on('data', chunk => {
|
||
raw += chunk.toString();
|
||
});
|
||
req.on('end', () => {
|
||
if (!raw.trim()) {
|
||
resolve({});
|
||
return;
|
||
}
|
||
try {
|
||
resolve(JSON.parse(raw));
|
||
} catch {
|
||
resolve({ raw });
|
||
}
|
||
});
|
||
});
|
||
}
|
||
|
||
function baseTenantPayload(query) {
|
||
return {
|
||
item: {
|
||
tenantId: ids.tenant,
|
||
tenantSlug: query.get('tenantCode') || 'master',
|
||
branding: {
|
||
brandName: '工学题库',
|
||
shortName: '工学',
|
||
theme: {
|
||
primaryColor: '#2563eb',
|
||
accentColor: '#16a34a',
|
||
},
|
||
},
|
||
features: {
|
||
enableLeaderboard: false,
|
||
},
|
||
adminFeatures: {
|
||
tenantAdmin: true,
|
||
platformAdmin: true,
|
||
},
|
||
publicConfig: {
|
||
h5InteractionSmoke: true,
|
||
},
|
||
},
|
||
};
|
||
}
|
||
|
||
const question = {
|
||
id: ids.question,
|
||
content: '这是 H5 交互烟测题目:1 + 1 = ?',
|
||
type: 'single_choice',
|
||
typeLabel: '单选题',
|
||
options: ['2', '3', '4', '5'],
|
||
correctOptionIndex: 0,
|
||
correctOptionIndices: [0],
|
||
explanation: '1 + 1 = 2。此题用于验证普通学生刷题、答题和收藏入口。',
|
||
answerText: '2',
|
||
hasVideoExplanation: true,
|
||
};
|
||
|
||
const tenantDashboard = {
|
||
cards: {
|
||
students: { total: 128, newInRange: 12 },
|
||
learning: { answers: 3200, activeStudents: 86, accuracy: 0.78 },
|
||
content: { questions: 74117 },
|
||
activationCodes: { total: 500, used: 180 },
|
||
feedback: { pending: 3 },
|
||
},
|
||
paymentStats: {
|
||
revenueCentsInRange: 529900,
|
||
paidOrdersInRange: 26,
|
||
},
|
||
planSales: [{ planId: ids.plan, name: 'SVIP 年卡', paidOrders: 18, revenueCents: 358200 }],
|
||
recentActivities: [{ activityType: 'student_registered', title: '新增学生注册', occurredAt: new Date().toISOString() }],
|
||
};
|
||
|
||
const tenantStudent = {
|
||
membershipId: 'member-student-smoke',
|
||
userId: ids.user,
|
||
username: 'student_smoke',
|
||
name: '测试学生',
|
||
phone: '13800000000',
|
||
email: 'student@example.test',
|
||
status: 'active',
|
||
regionId: ids.region,
|
||
regionName: '天津',
|
||
selectedSchoolId: ids.school,
|
||
selectedSchoolName: '天津职业大学',
|
||
selectedMajorId: ids.major,
|
||
selectedMajorName: '软件工程',
|
||
questionsAnsweredToday: 6,
|
||
masteredWordsCount: 12,
|
||
classes: [{ classId: ids.class, className: '天津冲刺班' }],
|
||
};
|
||
|
||
const tenantMembers = [
|
||
{
|
||
id: ids.tenantMember,
|
||
membershipId: ids.tenantMember,
|
||
userId: ids.teacher,
|
||
username: 'teacher_smoke',
|
||
name: '测试教师',
|
||
phone: '13900000000',
|
||
email: 'teacher@example.test',
|
||
role: 'teacher',
|
||
primaryRole: 'teacher',
|
||
status: 'active',
|
||
roleTemplateId: ids.roleTemplate,
|
||
roleTemplateName: '教师运营模板',
|
||
permissions: { 'students:read': true, 'students:write': true },
|
||
},
|
||
{
|
||
id: 'member-sales-smoke',
|
||
membershipId: 'member-sales-smoke',
|
||
userId: 'sales-user-smoke',
|
||
username: 'sales_smoke',
|
||
name: '测试销售',
|
||
phone: '13700000000',
|
||
role: 'sales',
|
||
primaryRole: 'sales',
|
||
status: 'active',
|
||
commissionRate: 0.2,
|
||
},
|
||
];
|
||
|
||
const tenantThemeTemplates = [
|
||
{
|
||
code: 'classic',
|
||
name: '经典主题',
|
||
description: '适合默认题库品牌',
|
||
theme: { primaryColor: '#2563eb', accentColor: '#0f766e' },
|
||
},
|
||
{
|
||
code: 'focus',
|
||
name: '专注主题',
|
||
description: '适合学习型机构',
|
||
theme: { primaryColor: '#0f766e', accentColor: '#f59e0b' },
|
||
},
|
||
{
|
||
code: 'contrast',
|
||
name: '高对比主题',
|
||
description: '适合活动页面',
|
||
theme: { primaryColor: '#111827', accentColor: '#dc2626' },
|
||
},
|
||
];
|
||
|
||
const tenantTheme = {
|
||
activeTemplateCode: 'classic',
|
||
activeTemplateName: '经典主题',
|
||
draftTemplateCode: 'focus',
|
||
draftTemplateName: '专注主题',
|
||
status: 'draft',
|
||
theme: { primaryColor: '#2563eb', accentColor: '#0f766e' },
|
||
publicAssets: { logoUrl: '/assets/logo.svg', shareImageUrl: '/assets/share.png', iconSet: 'classic', shareCardStyle: 'clean' },
|
||
};
|
||
|
||
const tenantPermissionCatalog = {
|
||
permissions: [
|
||
{ key: 'students:read', group: 'students', label: '学生查看' },
|
||
{ key: 'students:write', group: 'students', label: '学生编辑' },
|
||
{ key: 'content:write', group: 'content', label: '内容编辑' },
|
||
{ key: 'marketing:write', group: 'marketing', label: '营销编辑' },
|
||
],
|
||
menuGroups: [
|
||
{ key: 'dashboard', group: 'menu', label: '数据看板' },
|
||
{ key: 'students', group: 'menu', label: '学生运营' },
|
||
{ key: 'content', group: 'menu', label: '题库内容' },
|
||
{ key: 'marketing', group: 'menu', label: '营销中心' },
|
||
{ key: 'settings', group: 'menu', label: '租户设置' },
|
||
],
|
||
fieldGroups: [
|
||
{ key: 'student.phone', group: 'student', label: '学生手机号' },
|
||
{ key: 'student.email', group: 'student', label: '学生邮箱' },
|
||
],
|
||
};
|
||
|
||
const importJob = {
|
||
id: ids.importJob,
|
||
importType: 'questions',
|
||
sourceFormat: 'json',
|
||
status: 'previewed',
|
||
sourceName: 'smoke-questions.json',
|
||
totalCount: 1,
|
||
validCount: 1,
|
||
successCount: 0,
|
||
errorCount: 0,
|
||
warningCount: 0,
|
||
insertedCount: 0,
|
||
updatedCount: 0,
|
||
skippedCount: 0,
|
||
executionMode: 'sync',
|
||
attemptCount: 0,
|
||
maxAttempts: 3,
|
||
createdAt: new Date().toISOString(),
|
||
updatedAt: new Date().toISOString(),
|
||
summary: { source: 'h5-smoke' },
|
||
};
|
||
|
||
const publicQuestionBank = {
|
||
grantId: ids.publicBankGrant,
|
||
sourceQuestionBankId: ids.publicBank,
|
||
sourceQuestionBankName: '平台公共题库',
|
||
sourceRegionName: '天津',
|
||
name: '平台公共题库',
|
||
regionName: '天津',
|
||
grantScope: 'plan_code',
|
||
questionCount: 128,
|
||
adoptedId: ids.publicBankAdoption,
|
||
adoptionStatus: 'active',
|
||
syncStatus: 'conflict',
|
||
copiedQuestionCount: 120,
|
||
lastSyncedAt: new Date().toISOString(),
|
||
};
|
||
|
||
const publicBankConflicts = {
|
||
adoptionId: ids.publicBankAdoption,
|
||
syncStatus: 'conflict',
|
||
status: 'conflict',
|
||
lastSyncedAt: new Date().toISOString(),
|
||
conflictCount: 1,
|
||
counts: { conflict: 1 },
|
||
conflicts: [{
|
||
sourceQuestionId: ids.question,
|
||
targetQuestionId: 'target-question-smoke',
|
||
action: 'update_conflict',
|
||
sourceHash: 'platformhash',
|
||
previousSourceHash: 'oldhash',
|
||
targetHash: 'tenanthash',
|
||
}],
|
||
};
|
||
|
||
const platformPermissionCatalog = [
|
||
{ key: '*', group: 'staff', label: '超级权限' },
|
||
{ key: 'platform:staff:write', group: 'staff', label: '员工编辑' },
|
||
{ key: 'platform:staff:status', group: 'staff', label: '员工状态' },
|
||
{ key: 'platform:tenant:write', group: 'tenant', label: '租户编辑' },
|
||
{ key: 'platform:billing:write', group: 'billing', label: '账务编辑' },
|
||
{ key: 'platform:question_bank:write', group: 'question_bank', label: '题库授权' },
|
||
];
|
||
|
||
function mockApiPayload(pathname, method, query, body) {
|
||
if (pathname === '/health') return { ok: true };
|
||
if (pathname === '/api/tenant/resolve') return baseTenantPayload(query);
|
||
if (pathname === '/api/auth/me') {
|
||
return {
|
||
user: {
|
||
id: ids.user,
|
||
name: '测试学生',
|
||
primaryRole: 'platform_admin',
|
||
roles: ['platform_admin', 'tenant_admin'],
|
||
},
|
||
item: {
|
||
id: ids.user,
|
||
name: '测试学生',
|
||
primaryRole: 'platform_admin',
|
||
roles: ['platform_admin', 'tenant_admin'],
|
||
},
|
||
};
|
||
}
|
||
|
||
if (pathname === '/api/catalog/content-entries') {
|
||
return {
|
||
items: [{
|
||
id: ids.entry,
|
||
name: '天津专升本题库',
|
||
entryType: 'question_bank',
|
||
description: '公共课、专业课和院校分类入口',
|
||
regionId: ids.region,
|
||
}],
|
||
};
|
||
}
|
||
if (pathname === '/api/catalog/banners' || pathname === '/api/catalog/announcements') return { items: [] };
|
||
if (pathname === '/api/catalog/regions') return { items: [{ id: ids.region, name: '天津', code: 'tj', isHot: true }] };
|
||
if (pathname === '/api/catalog/content-nodes') {
|
||
return { items: [{ id: ids.node, entryId: ids.entry, name: '文化课', nodeType: 'subject', markerType: '公共课', isLeaf: true }] };
|
||
}
|
||
if (pathname === '/api/catalog/question-collections') {
|
||
return { items: [{ id: ids.collection, entryId: ids.entry, nodeId: ids.node, name: '英语基础章节', collectionType: 'chapter', questionCount: 1 }] };
|
||
}
|
||
if (pathname === '/api/catalog/practice-blueprints') {
|
||
return { items: [{ id: ids.blueprint, entryId: ids.entry, nodeId: ids.node, collectionId: ids.collection, name: '顺序练习', mode: 'sequential', questionLimit: 1 }] };
|
||
}
|
||
if (pathname === '/api/catalog/question-collections/questions' || pathname === '/api/catalog/questions') return { items: [question] };
|
||
if (pathname === '/api/catalog/svip-plans') {
|
||
return { items: [{ id: ids.plan, name: 'SVIP 年卡', priceCents: 19900, days: 365, badge: '推荐', regionId: ids.region, desc: '题库、解析和资料权益' }] };
|
||
}
|
||
|
||
if (pathname === '/api/profile/me') {
|
||
return {
|
||
item: {
|
||
id: ids.user,
|
||
userId: ids.user,
|
||
name: '测试学生',
|
||
phone: '13800000000',
|
||
avatarPreset: 'male',
|
||
score: 120,
|
||
target: { regionId: ids.region, regionName: '天津', schoolId: ids.school, schoolName: '天津职业大学', majorId: ids.major, majorName: '软件工程' },
|
||
membership: { isSvip: true },
|
||
stats: { answers: { totalAnswered: 12 } },
|
||
},
|
||
};
|
||
}
|
||
if (pathname === '/api/profile/score-events') return { items: [] };
|
||
if (pathname === '/api/profile/activity-tasks') return { items: [] };
|
||
if (pathname === '/api/profile/exchange-items') return { items: [] };
|
||
if (pathname === '/api/profile/notifications') {
|
||
return {
|
||
items: [{
|
||
id: ids.notification,
|
||
notificationType: 'badge_granted',
|
||
status: query.get('status') || 'unread',
|
||
severity: 'success',
|
||
title: '入门勋章已发放',
|
||
message: '完成首次练习后,系统已自动发放坚持练习勋章。',
|
||
actionLabel: '查看个人中心',
|
||
actionPath: '/pages/student/profile/index',
|
||
createdAt: new Date().toISOString(),
|
||
}],
|
||
summary: { unread: 1, read: 0, archived: 0, dismissed: 0 },
|
||
};
|
||
}
|
||
if (pathname === '/api/profile/notifications/status') return { item: { updatedCount: body.notificationIds?.length || 1, status: body.status || 'read' } };
|
||
if (pathname === '/api/profile/badges') return { items: [{ id: 'badge-smoke', name: '坚持练习' }] };
|
||
if (pathname === '/api/profile/exam-countdowns') return { items: [] };
|
||
if (pathname === '/api/commerce/entitlements') return { items: [{ type: 'svip', status: 'active' }], summary: { svip: true } };
|
||
if (pathname === '/api/commerce/orders' && method === 'GET') return { items: [] };
|
||
if (pathname === '/api/commerce/orders' && method === 'POST') {
|
||
return { item: { id: ids.order, orderNo: 'SMOKE202607010001', status: 'pending_payment', amountCents: 19900, planId: body.planId || ids.plan } };
|
||
}
|
||
if (pathname === '/api/commerce/payments/create') {
|
||
return { item: { provider: body.provider || 'alipay', paymentParams: { url: 'https://pay.example.test/smoke' } } };
|
||
}
|
||
if (pathname === '/api/commerce/orders/status') return { item: { orderNo: query.get('orderNo') || 'SMOKE202607010001', status: 'pending_payment' } };
|
||
|
||
if (pathname === '/api/learning/practice-sessions' && method === 'POST') {
|
||
return {
|
||
item: {
|
||
id: ids.session,
|
||
mode: body.mode || 'sequential',
|
||
questionIds: [ids.question],
|
||
questionCount: 1,
|
||
durationMinutes: 30,
|
||
accessMode: 'svip',
|
||
consumedFreeQuota: 0,
|
||
},
|
||
};
|
||
}
|
||
if (pathname === '/api/learning/practice-sessions/detail') {
|
||
return {
|
||
item: {
|
||
id: ids.session,
|
||
mode: 'sequential',
|
||
questionIds: [ids.question],
|
||
questionCount: 1,
|
||
status: 'active',
|
||
questions: [question],
|
||
answersByQuestion: {},
|
||
},
|
||
};
|
||
}
|
||
if (pathname === '/api/learning/answers') {
|
||
return {
|
||
item: {
|
||
id: 'answer-smoke',
|
||
questionId: body.questionId || ids.question,
|
||
isCorrect: true,
|
||
selectedOptions: body.selectedOptions || ['0'],
|
||
answeredAt: new Date().toISOString(),
|
||
},
|
||
};
|
||
}
|
||
if (pathname === '/api/learning/favorites/questions' && method === 'POST') return { ok: true, favorite: body.favorite !== false };
|
||
if (pathname === '/api/learning/favorites/questions') return { items: [question] };
|
||
if (pathname === '/api/learning/wrong-questions/review-plan') {
|
||
return {
|
||
items: [{
|
||
questionId: ids.question,
|
||
content: question.content,
|
||
type: question.type,
|
||
typeLabel: question.typeLabel,
|
||
wrongCount: 2,
|
||
lastWrongAt: new Date().toISOString(),
|
||
}],
|
||
nextAction: { mode: 'wrong_review', questionLimit: 30 },
|
||
};
|
||
}
|
||
if (pathname === '/api/learning/wrong-questions/resolve') return { ok: true };
|
||
if (pathname === '/api/learning/wrong-questions') return { items: [question] };
|
||
if (pathname === '/api/learning/stats') {
|
||
return {
|
||
item: {
|
||
windowDays: 30,
|
||
answers: { totalAnswered: 12, correctCount: 10, wrongCount: 2, todayAnswered: 3, accuracy: 0.83 },
|
||
sessions: { totalSessions: 2, activeSessions: 0, finishedSessions: 2 },
|
||
reports: { reportCount: 1, avgAccuracy: 0.83, bestScore: 83 },
|
||
wrongBook: { unresolvedWrong: 2, resolvedWrong: 1, totalWrongBook: 3 },
|
||
favorites: { favoriteQuestions: 1 },
|
||
questionTypes: [],
|
||
},
|
||
};
|
||
}
|
||
if (pathname === '/api/learning/trend') return { items: [{ date: '2026-07-01', answeredCount: 3, correctCount: 2, wrongCount: 1, accuracy: 0.66, sessionCount: 1, reportCount: 1, score: 66, totalScore: 100 }] };
|
||
if (pathname === '/api/learning/practice-sessions/history') return { items: [] };
|
||
|
||
if (pathname === '/api/catalog/vocabulary-units') {
|
||
return { items: [{ id: ids.vocabularyUnit, name: '英语核心词汇', description: '专升本高频词', wordCount: 1, regionId: ids.region }] };
|
||
}
|
||
if (pathname === '/api/catalog/vocabulary-words') {
|
||
return {
|
||
items: [{
|
||
id: ids.vocabularyWord,
|
||
wordId: ids.vocabularyWord,
|
||
unitId: ids.vocabularyUnit,
|
||
word: 'analysis',
|
||
phonetic: "ə'næləsɪs",
|
||
meaning: '分析',
|
||
example: 'Careful analysis helps students improve.',
|
||
exampleTranslation: '细致分析能帮助学生提升。',
|
||
status: 'new',
|
||
}],
|
||
};
|
||
}
|
||
if (pathname === '/api/learning/vocabulary/review-plan') {
|
||
return {
|
||
item: {
|
||
words: [{
|
||
id: ids.vocabularyWord,
|
||
wordId: ids.vocabularyWord,
|
||
unitId: ids.vocabularyUnit,
|
||
word: 'analysis',
|
||
phonetic: "ə'næləsɪs",
|
||
meaning: '分析',
|
||
example: 'Careful analysis helps students improve.',
|
||
exampleTranslation: '细致分析能帮助学生提升。',
|
||
dueLevel: 'new',
|
||
}],
|
||
dueCount: 1,
|
||
newCount: 1,
|
||
totalPlanned: 1,
|
||
},
|
||
};
|
||
}
|
||
if (pathname === '/api/learning/vocabulary/stats') {
|
||
return { item: { totalWords: 1, progressedWords: 0, masteredWords: 0, learningWords: 0, todayReviewed: 0, favoriteWords: 0 } };
|
||
}
|
||
if (pathname === '/api/learning/vocabulary/favorites' && method === 'POST') return { ok: true, favorite: body.favorite !== false };
|
||
if (pathname === '/api/learning/vocabulary/favorites') return { items: [] };
|
||
if (pathname === '/api/learning/vocabulary/review') return { item: { wordId: body.wordId || ids.vocabularyWord, result: body.result || 'known' } };
|
||
|
||
if (pathname === '/api/catalog/handbook-subjects') {
|
||
return { items: [{ id: ids.handbookSubject, name: '高等数学手册', type: 'handbook', description: '章节化知识点' }] };
|
||
}
|
||
if (pathname === '/api/catalog/handbook-chapters') {
|
||
return { items: [{ id: ids.handbookChapter, subjectId: ids.handbookSubject, name: '函数与极限', description: '基础章节' }] };
|
||
}
|
||
if (pathname === '/api/catalog/handbook-entries') {
|
||
return {
|
||
items: [{
|
||
id: ids.handbookEntry,
|
||
chapterId: ids.handbookChapter,
|
||
title: '导数定义',
|
||
summary: '导数描述函数在某一点附近的变化率。',
|
||
content: '设函数 y=f(x),当自变量增量趋近于 0 时,差商的极限称为导数。$f(x)=x^2$ 的导数是 $2x$。',
|
||
}],
|
||
};
|
||
}
|
||
|
||
if (pathname === '/api/catalog/assets') {
|
||
return {
|
||
items: [{
|
||
id: ids.asset,
|
||
title: '天津专升本英语真题 PDF',
|
||
assetType: 'pdf',
|
||
categoryLabel: '真题资料',
|
||
description: '用于验证私有资料短签名、水印和预览入口。',
|
||
fileName: 'tj-english-paper.pdf',
|
||
mimeType: 'application/pdf',
|
||
visibility: 'members',
|
||
previewStatus: 'ready',
|
||
}],
|
||
};
|
||
}
|
||
if (pathname === '/api/catalog/assets/preview') {
|
||
return {
|
||
item: { id: ids.asset, title: '天津专升本英语真题 PDF', assetType: 'pdf', fileName: 'tj-english-paper.pdf', mimeType: 'application/pdf', visibility: 'members' },
|
||
preview: { url: 'https://assets.example.test/tj-english-paper-preview.pdf?token=smoke', expiresInSec: 300, expiresAt: new Date(Date.now() + 300_000).toISOString(), signatureMode: 'signed' },
|
||
watermark: { mode: 'visible_overlay', required: true, text: '工学题库 SMOKE', traceId: 'wm-preview-smoke', position: 'diagonal', opacity: 0.16, repeat: true, renderHint: 'overlay' },
|
||
};
|
||
}
|
||
if (pathname === '/api/catalog/assets/download') {
|
||
return {
|
||
item: { id: ids.asset, title: '天津专升本英语真题 PDF', assetType: 'pdf', fileName: 'tj-english-paper.pdf', mimeType: 'application/pdf', visibility: 'members' },
|
||
download: { url: 'https://assets.example.test/tj-english-paper.pdf?token=smoke', expiresInSec: 300, expiresAt: new Date(Date.now() + 300_000).toISOString(), signatureMode: 'signed' },
|
||
watermark: { mode: 'visible_overlay', required: true, text: '工学题库 SMOKE', traceId: 'wm-download-smoke', position: 'diagonal', opacity: 0.16, repeat: true, renderHint: 'overlay' },
|
||
};
|
||
}
|
||
|
||
if (pathname === '/api/questions/videos') {
|
||
return {
|
||
videos: [{
|
||
id: ids.video,
|
||
questionId: query.get('questionId') || ids.question,
|
||
title: '本题视频解析',
|
||
description: '后端签名播放烟测视频',
|
||
duration: 180,
|
||
accessMode: 'svip',
|
||
playable: true,
|
||
}],
|
||
total: 1,
|
||
};
|
||
}
|
||
if (pathname === '/api/videos/play') {
|
||
return {
|
||
item: { id: body.videoId || ids.video, questionId: body.questionId || ids.question, title: '本题视频解析', duration: 180, accessMode: 'svip' },
|
||
playToken: 'video-play-token-smoke',
|
||
playback: { url: 'https://assets.example.test/video-smoke.mp4?token=smoke', expiresInSec: 300, expiresAt: new Date(Date.now() + 300_000).toISOString(), signatureMode: 'signed' },
|
||
watermark: { mode: 'visible_overlay', required: true, text: '工学题库 SMOKE', traceId: 'wm-video-smoke', position: 'diagonal', opacity: 0.16, repeat: true, renderHint: 'overlay' },
|
||
access: { accessMode: 'svip', consumedQuota: 0 },
|
||
};
|
||
}
|
||
|
||
if (pathname === '/api/scoreline/fields') {
|
||
return { items: [{ id: ids.scorelineField, regionId: ids.region, fieldKey: 'min_score', fieldName: '最低分', fieldType: 'number', unit: '分', isFilter: true, isVisible: true, isTrend: true, sortOrder: 1 }] };
|
||
}
|
||
if (pathname === '/api/scoreline/schools') {
|
||
return { items: [{ id: ids.school, regionId: ids.region, name: '天津职业大学', shortName: '天职大', isHot: true, order: 1 }] };
|
||
}
|
||
if (pathname === '/api/scoreline/majors') {
|
||
return { items: [{ id: ids.major, regionId: ids.region, schoolId: ids.school, name: '软件工程', order: 1, hasRestriction: false }] };
|
||
}
|
||
if (pathname === '/api/scoreline/years') return { items: [2026, 2025, 2024] };
|
||
if (pathname === '/api/scoreline/trend' || pathname === '/api/scoreline/records') {
|
||
return {
|
||
items: [{
|
||
id: ids.scorelineRecord,
|
||
regionId: ids.region,
|
||
schoolId: ids.school,
|
||
majorId: ids.major,
|
||
year: 2025,
|
||
schoolName: '天津职业大学',
|
||
majorName: '软件工程',
|
||
fieldValues: { min_score: 218 },
|
||
}],
|
||
total: 1,
|
||
};
|
||
}
|
||
|
||
if (pathname === '/api/ai/school-recommendations') {
|
||
return {
|
||
items: [{
|
||
id: ids.aiReport,
|
||
tenantId: ids.tenant,
|
||
userId: ids.user,
|
||
regionId: ids.region,
|
||
status: 'generated',
|
||
provider: 'local_rules',
|
||
promptVersion: 'smoke-v1',
|
||
inputPayload: { estimatedScore: 210, riskPreference: 'balanced' },
|
||
contextPayload: {},
|
||
resultPayload: {
|
||
schemaVersion: 'school-recommendation-report-v1',
|
||
summary: '建议优先关注天津职业大学软件工程方向。',
|
||
riskLevel: 'balanced',
|
||
recommendedSchools: [{
|
||
schoolId: ids.school,
|
||
schoolName: '天津职业大学',
|
||
majorId: ids.major,
|
||
majorName: '软件工程',
|
||
latestYear: 2025,
|
||
latestScore: 218,
|
||
averageScore: 214,
|
||
scoreGap: -8,
|
||
riskLevel: 'balanced',
|
||
confidence: 0.76,
|
||
reason: '与预估分接近,适合作为稳中带冲选择。',
|
||
tags: ['天津', '工科'],
|
||
}],
|
||
actionPlan: ['复盘公共课错题', '补齐英语词汇'],
|
||
disclaimers: ['烟测报告仅用于页面联调。'],
|
||
dataCoverage: { scorelines: 1 },
|
||
},
|
||
generatedAt: new Date().toISOString(),
|
||
createdAt: new Date().toISOString(),
|
||
updatedAt: new Date().toISOString(),
|
||
}],
|
||
};
|
||
}
|
||
if (pathname === '/api/ai/school-recommendations/generate') {
|
||
return mockApiPayload('/api/ai/school-recommendations', 'GET', query, body);
|
||
}
|
||
if (pathname === '/api/ai/school-recommendations/export') {
|
||
return { item: { reportId: ids.aiReport, format: query.get('format') || 'markdown', fileName: 'school-report-smoke.md', mimeType: 'text/markdown', contentBase64: 'IyBTbW9rZQ==', contentText: '# Smoke', sha256: 'smoke', sizeBytes: 7 } };
|
||
}
|
||
|
||
if (pathname === '/api/tenant-admin/dashboard') return { item: tenantDashboard };
|
||
if (pathname === '/api/tenant-admin/overview') {
|
||
return { item: { id: ids.tenant, slug: 'master', status: 'active', brandName: '工学题库商户后台', shortName: '工学', billingStatus: 'normal' } };
|
||
}
|
||
if (pathname === '/api/tenant-admin/theme-templates') return { items: tenantThemeTemplates };
|
||
if (pathname === '/api/tenant-admin/theme') return { item: tenantTheme };
|
||
if (pathname === '/api/tenant-admin/theme/preview') return { item: { ...tenantTheme, draftTemplateCode: body.templateCode || 'focus', templateName: '专注主题', status: 'draft' } };
|
||
if (pathname === '/api/tenant-admin/theme/publish') return { item: { ...tenantTheme, activeTemplateCode: body.templateCode || tenantTheme.draftTemplateCode, templateName: '专注主题', status: 'published' } };
|
||
if (pathname === '/api/tenant-admin/permissions') {
|
||
return {
|
||
...tenantPermissionCatalog,
|
||
current: {
|
||
role: 'owner',
|
||
menuPermissions: { dashboard: true, students: true, content: true, marketing: true, commerce: true, settings: true },
|
||
effectivePermissions: { '*': true },
|
||
},
|
||
roleDefaults: { owner: ['*'] },
|
||
};
|
||
}
|
||
if (pathname === '/api/tenant-admin/classes') {
|
||
return { scoped: false, items: [{ id: ids.class, name: '天津冲刺班', code: 'TJ-01', status: 'active', regionName: '天津', studentCount: 32, teacherCount: 2 }] };
|
||
}
|
||
if (pathname === '/api/tenant-admin/students' && method === 'GET') return { scoped: false, items: [tenantStudent] };
|
||
if (pathname === '/api/tenant-admin/students' && method === 'PUT') return { item: { ...tenantStudent, ...body, userId: body.userId || ids.user } };
|
||
if (pathname === '/api/tenant-admin/students/bulk-upsert') return { total: body.students?.length || 1, successCount: body.students?.length || 1, errorCount: 0, items: body.students || [] };
|
||
if (pathname === '/api/tenant-admin/students/status') return { item: { userId: body.userId || ids.user, status: body.status || 'active' } };
|
||
if (pathname === '/api/tenant-admin/students/crm-push') return { requestId: 'crm-push-smoke', total: body.studentUserIds?.length || 1, successCount: body.studentUserIds?.length || 1, errorCount: 0 };
|
||
if (pathname === '/api/tenant-admin/classes/members/bulk-assign') return { total: body.assignments?.length || 1, successCount: body.assignments?.length || 1, errorCount: 0 };
|
||
if (pathname === '/api/tenant-admin/teachers') return { items: [{ userId: ids.teacher, username: 'teacher_smoke', name: '测试教师', phone: '13900000000', status: 'active', classCount: 1 }] };
|
||
if (pathname === '/api/tenant-admin/students/notes' && method === 'GET') return { items: [{ id: 'note-smoke', studentUserId: ids.user, noteType: 'learning', content: '烟测备注', visibility: 'tenant_staff', isPinned: false, createdByName: '测试教师', createdAt: new Date().toISOString() }] };
|
||
if (pathname === '/api/tenant-admin/students/notes' && method === 'PUT') return { item: { id: 'note-smoke-created', ...body, createdAt: new Date().toISOString() } };
|
||
if (pathname === '/api/tenant-admin/students/followups/report') {
|
||
return { item: { summary: { total: 6, openBacklog: 2, overdueBacklog: 1, done: 3, crmPushTasks: 2, completionRate: 0.5, avgCompleteHours: 8 }, byAssignee: [{ assignedToUserId: ids.teacher, assignedToName: '测试教师', total: 4, open: 1, done: 3, overdue: 1 }], overdueItems: [] } };
|
||
}
|
||
if (pathname === '/api/tenant-admin/students/followups' && method === 'GET') {
|
||
return { items: [{ id: 'followup-smoke', studentUserId: ids.user, studentName: '测试学生', title: '学习跟进', followupType: 'learning', priority: 'normal', status: 'open', assignedToUserId: ids.teacher, assignedToName: '测试教师' }] };
|
||
}
|
||
if (pathname === '/api/tenant-admin/students/followups' && method === 'PUT') return { item: { id: body.id || 'followup-created-smoke', ...body } };
|
||
if (pathname === '/api/tenant-admin/students/supervision/preview') {
|
||
return { item: { rules: {}, filters: {}, totalCandidates: 1, candidates: [{ studentUserId: ids.user, studentName: '测试学生', title: '学习风险提醒', description: '近期答题偏少', followupType: 'learning', priority: 'high', riskScore: 86, reasons: [{ code: 'low_activity' }] }] } };
|
||
}
|
||
if (pathname === '/api/tenant-admin/students/supervision/rules' && method === 'GET') return { scoped: false, items: [{ id: 'supervision-rule-smoke', name: '全租户 学习督导', status: 'active', className: null, lastResult: { status: 'ok' } }] };
|
||
if (pathname === '/api/tenant-admin/students/supervision/rules' && method === 'PUT') return { item: { id: 'supervision-rule-created-smoke', ...body, status: body.status || 'active' } };
|
||
if (pathname === '/api/tenant-admin/students/supervision/generate') return { total: body.studentUserIds?.length || 1, successCount: body.studentUserIds?.length || 1, errorCount: 0 };
|
||
if (pathname === '/api/tenant-admin/role-templates' && method === 'GET') {
|
||
return { items: [{ id: ids.roleTemplate, code: 'teacher-ops', name: '教师运营模板', baseRole: 'teacher', status: 'active', sortOrder: 10, permissions: { 'students:read': true }, menuPermissions: { students: true }, modulePermissions: {}, fieldPermissions: {}, dataScope: { mode: 'class' }, isSystem: false }] };
|
||
}
|
||
if (pathname === '/api/tenant-admin/role-templates' && method === 'PUT') return { item: { id: body.id || 'role-template-created-smoke', ...body } };
|
||
if (pathname === '/api/tenant-admin/role-templates/disable') return { item: { id: body.roleTemplateId, status: 'disabled' } };
|
||
if (pathname === '/api/tenant-admin/members' && method === 'GET') return { items: tenantMembers };
|
||
if (pathname === '/api/tenant-admin/members' && method === 'PUT') return { item: { id: body.membershipId || 'member-created-smoke', membershipId: body.membershipId || 'member-created-smoke', userId: body.userId || ids.teacher, ...body } };
|
||
if (pathname === '/api/tenant-admin/members/disable') return { item: { id: body.membershipId, status: 'disabled' } };
|
||
if (pathname === '/api/tenant-admin/domains') return { items: [{ id: 'domain-smoke', host: 'tiku.example.test', domainType: 'custom', status: 'verified', isPrimary: true }] };
|
||
if (pathname === '/api/tenant-admin/payment-accounts') return { items: [{ id: 'payment-smoke', provider: 'wechat_pay', status: 'active', mode: 'tenant_collect' }] };
|
||
if (pathname === '/api/tenant-admin/auth-providers') return { items: [{ id: 'auth-wechat-smoke', provider: 'wechat_miniapp', status: 'active' }] };
|
||
|
||
if (pathname === '/api/tenant-content/content-entries') return { items: [{ id: ids.entry, name: '天津专升本题库', entryType: 'question_bank', visibility: 'tenant', isActive: true, regionName: '天津', status: 'active' }] };
|
||
if (pathname === '/api/tenant-content/imports') return { items: [importJob] };
|
||
if (pathname === '/api/tenant-content/imports/detail') return { item: importJob, issueSummary: {}, itemStatusSummary: { valid: 1 }, recentIssues: [], importPostCheck: { status: 'passed', issues: [] }, worker: { executionMode: 'sync', attemptCount: 0, maxAttempts: 3 } };
|
||
if (pathname === '/api/tenant-content/imports/issues') return { items: [] };
|
||
if (pathname === '/api/tenant-content/imports/templates') {
|
||
const importType = query.get('importType') || 'questions';
|
||
const format = query.get('format') || 'json';
|
||
return { item: { importType, format, fileName: `${importType}-template.${format}`, mimeType: 'application/json', contentBase64: 'W3sidGl0bGUiOiJzbW9rZSJ9XQ==', contentPreview: '[{"title":"smoke","type":"single_choice"}]', fields: [{ field: 'title', label: '题干', required: true, aliases: ['题干', 'title'] }] } };
|
||
}
|
||
if (pathname === '/api/tenant-content/imports/field-mapping') return { item: { importType: query.get('importType') || 'questions', title: '导入字段', fields: [{ field: 'title', label: '题干', required: true, aliases: ['题干', 'title'] }], requiredFields: ['title'] } };
|
||
if (pathname.startsWith('/api/tenant-content/imports/preview/')) return { job: { ...importJob, id: ids.importJob }, items: [{ rowNo: 1, status: 'valid', externalId: 'smoke-question', normalized: { title: 'smoke' }, issues: [] }], issues: [] };
|
||
if (/^\/api\/tenant-content\/imports\/(questions|vocabulary|handbook|scoreline|videos)$/.test(pathname)) return { item: { ...importJob, id: ids.importJob, jobId: ids.importJob, status: 'completed', successCount: 1 }, preview: { job: importJob } };
|
||
if (pathname === '/api/tenant-content/imports/post-check') return { item: { status: 'passed', issues: [], checkedAt: new Date().toISOString() } };
|
||
if (pathname === '/api/tenant-content/public-question-banks') return { items: [publicQuestionBank] };
|
||
if (pathname === '/api/tenant-content/public-question-banks/adopt') return { item: { id: ids.publicBankAdoption, grantId: body.grantId || ids.publicBankGrant, status: 'active' } };
|
||
if (pathname === '/api/tenant-content/public-question-banks/sync') return { item: { id: body.adoptionId || ids.publicBankAdoption, syncStatus: 'conflict' }, sync: publicBankConflicts };
|
||
if (pathname === '/api/tenant-content/public-question-banks/conflicts') return { item: publicBankConflicts };
|
||
if (pathname === '/api/tenant-content/public-question-banks/conflicts/resolve') return { item: { adoptionId: body.adoptionId, sourceQuestionId: body.sourceQuestionId, resolution: body.resolution, status: 'resolved' } };
|
||
if (pathname === '/api/tenant-content/public-question-banks/conflicts/resolve-batch') return { item: { adoptionId: body.adoptionId, resolvedCount: body.sourceQuestionIds?.length || 1, resolution: body.resolution } };
|
||
if (pathname === '/api/tenant-content/notifications') return { items: [{ id: 'content-notification-smoke', notificationType: 'public_bank_conflict', status: 'unread', severity: 'warning', adoptionId: ids.publicBankAdoption, title: '公共题库存在同步冲突', message: '平台公共题库有 1 道题需要处理。', createdAt: new Date().toISOString() }], summary: { unread: 1 } };
|
||
if (pathname === '/api/tenant-content/notifications/status') return { item: { updatedCount: body.notificationIds?.length || 1, status: body.status } };
|
||
|
||
if (pathname === '/api/tenant-admin/code-batches') return { items: [{ id: 'batch-smoke', name: 'SVIP 激活码批次', status: 'active', totalCount: 100, usedCount: 18 }] };
|
||
if (pathname === '/api/tenant-admin/activation-codes') return { items: [{ id: 'code-smoke', code: 'SMOKE2026', status: 'unused' }] };
|
||
if (pathname === '/api/tenant-admin/coupons' && method === 'GET') return { items: [{ id: ids.coupon, code: 'SMOKE80', status: 'active', campaignName: '暑期活动', discountType: 'fixed', discountValue: 8000, usedCount: 3, maxUses: 100, minOrderAmountCents: 10000, perUserLimit: 1 }] };
|
||
if (pathname === '/api/tenant-admin/coupons' && method === 'PUT') return { item: { id: body.id || ids.coupon, ...body, usedCount: 0 } };
|
||
if (pathname === '/api/tenant-admin/coupons/redemptions') return { items: [{ id: 'redemption-smoke', couponId: ids.coupon, couponCode: 'SMOKE80', userId: ids.user, userName: '测试学生', status: 'used', discountAppliedCents: 8000, planName: 'SVIP 年卡', orderNo: 'SMOKE202607010001' }] };
|
||
if (pathname === '/api/tenant-admin/coupons/report') return { item: { claimCount: 12, usedCount: 3, discountCents: 24000, paidAmountCents: 59700, conversionRate: 0.25, byCampaign: [{ campaignName: '暑期活动', claimCount: 12, usedCount: 3, discountCents: 24000 }] } };
|
||
if (pathname === '/api/tenant-admin/point-activity-tasks' && method === 'GET') return { items: [{ id: ids.pointTask, code: 'daily-smoke', title: '每日练习', taskType: 'practice_complete', rewardPoints: 10, claimLimitPerUser: 1, periodType: 'daily', status: 'active', claimCount: 4, claimUserCount: 3 }] };
|
||
if (pathname === '/api/tenant-admin/point-activity-tasks' && method === 'PUT') return { item: { id: body.id || ids.pointTask, ...body, claimCount: 0, claimUserCount: 0 } };
|
||
if (pathname === '/api/tenant-admin/point-activity-claims') return { items: [{ id: 'claim-smoke', taskTitle: '每日练习', rewardPoints: 10, userName: '测试学生', periodKey: '2026-07-01' }] };
|
||
if (pathname === '/api/tenant-admin/point-exchange-items' && method === 'GET') return { items: [{ id: ids.pointExchange, code: 'manual-gift', title: '人工兑换礼品', costPoints: 100, itemType: 'manual', stockRemaining: 20, status: 'active', orderCount: 1 }] };
|
||
if (pathname === '/api/tenant-admin/point-exchange-items' && method === 'PUT') return { item: { id: body.id || ids.pointExchange, ...body, stockRemaining: body.stockTotal || null, orderCount: 0 } };
|
||
if (pathname === '/api/tenant-admin/point-exchange-orders') return { items: [{ id: 'exchange-order-smoke', itemTitle: '人工兑换礼品', status: 'pending_fulfillment', userName: '测试学生', costPoints: 100, createdAt: new Date().toISOString() }] };
|
||
if (pathname === '/api/tenant-admin/points-risk-report') return { item: { summary: { riskScore: 8, suspiciousUserCount: 0, suspiciousEventCount: 0, netPoints: 120 }, suspiciousUsers: [], suspiciousEvents: [], suspiciousTasks: [] } };
|
||
if (pathname === '/api/tenant-admin/feedbacks/report') return { item: { summary: { total: 5, pendingBacklog: 1, handledRate: 0.8, rewardPoints: 20, highPriority: 1, resolved: 2, closed: 1, avgHandleHours: 4, resolutionRate: 0.6 }, recentUnhandled: [], topQuestions: [] } };
|
||
if (pathname === '/api/tenant-admin/user-notifications') return { items: [{ id: 'user-notification-smoke', userId: ids.user, userName: '测试学生', notificationType: 'badge_granted', status: 'unread', title: '勋章发放', message: '坚持练习勋章已发放', createdAt: new Date().toISOString() }], summary: { unread: 1, archived: 0 } };
|
||
if (pathname === '/api/crm/config' && method === 'GET') return { item: { enabled: true, url: 'https://crm.example.test/webhook', secretRef: 'app_private.tenant_secrets:crm:webhook', formName: '刷题题库', examType: '专升本', timeoutSec: 10, delaySec: 60, assignmentMode: 'referrer', assignmentPool: ['sales-user-smoke'], assignmentCursor: 0 } };
|
||
if (pathname === '/api/crm/config' && method === 'PUT') return { item: { id: 'crm-config-smoke', ...body } };
|
||
if (pathname === '/api/crm/queue') return { items: [{ id: ids.crmQueue, status: 'failed', source: 'student.crm_push', attempts: 3, lastHttpCode: 500, lastError: 'smoke failed webhook', targetUrl: 'https://crm.example.test/webhook', provider: 'webhook', createdAt: new Date().toISOString() }] };
|
||
if (pathname === '/api/crm/dead-letters') return { summary: { total: 1, failed: 1, ignored: 0 }, items: [{ id: ids.crmQueue, status: 'failed', source: 'student.crm_push', attempts: 3, lastError: 'smoke failed webhook', deadLetteredAt: new Date().toISOString() }] };
|
||
if (pathname === '/api/crm/queue/logs') return { item: { id: query.get('queueId') || ids.crmQueue }, items: [{ id: 'crm-log-smoke', outcome: 'failed', attempt: 3, httpCode: 500, errorMessage: 'smoke failed webhook', createdAt: new Date().toISOString() }] };
|
||
if (pathname === '/api/crm/queue/action') return { item: { id: body.queueId || ids.crmQueue, status: body.action === 'ignore' ? 'discarded' : 'pending', lastOperatorAction: body.action } };
|
||
if (pathname === '/api/commission/settings' && method === 'GET') return { item: { defaultRate: 0.2, minSettlementCents: 0, settlementCycle: 'monthly' } };
|
||
if (pathname === '/api/commission/settings' && method === 'PUT') return { item: { ...body } };
|
||
if (pathname === '/api/commission/member-rate') return { item: { userId: body.userId, commissionRate: body.commissionRate } };
|
||
if (pathname === '/api/commission/summary') return { item: { sourceCount: 3, grossAmountCents: 59700, commissionAmountCents: 11940, paidUserCount: 3 } };
|
||
if (pathname === '/api/referral/conversion-report') return { item: { summary: { leadCount: 10, convertedLeadCount: 3, conversionRate: 0.3, grossAmountCents: 59700, commissionAmountCents: 11940, crmFailedCount: 1, openFollowupCount: 2, avgFirstPayHours: 24 }, funnel: [{ key: 'lead', label: '客资', count: 10 }], byReferrer: [{ referrerUserId: 'sales-user-smoke', name: '测试销售', role: 'sales', leadCount: 10, convertedLeadCount: 3, conversionRate: 0.3, grossAmountCents: 59700, commissionAmountCents: 11940, crmFailedCount: 1, openFollowupCount: 2, overdueFollowupCount: 1, avgFirstPayHours: 24 }], dailyTrend: [] } };
|
||
if (pathname === '/api/commission/orders') return { items: [{ referrerUserId: 'sales-user-smoke', referrerName: '测试销售', grossAmountCents: 19900, commissionRate: 0.2, commissionAmountCents: 3980, sourceType: 'referral', sourceId: 'source-smoke' }] };
|
||
if (pathname === '/api/commission/settlements' && method === 'GET') return { items: [{ id: ids.settlement, settlementNo: 'SETTLE-SMOKE', referrerUserId: 'sales-user-smoke', referrerName: '测试销售', status: 'pending_review', commissionAmountCents: 3980, periodStart: '2026-07-01', periodEnd: '2026-07-31', sourceCount: 1 }] };
|
||
if (pathname === '/api/commission/settlements/generate') return { item: { id: ids.settlement, ...body, settlementNo: 'SETTLE-SMOKE', commissionAmountCents: 3980 } };
|
||
if (pathname === '/api/commission/settlements/status') return { item: { id: body.settlementId || ids.settlement, status: body.status } };
|
||
if (pathname === '/api/commission/settlements/export') return { item: { filename: 'settlement-smoke.csv', format: 'csv', mimeType: 'text/csv', contentBase64: 'aWQsc3RhdHVzCnNtb2tlLHBhaWQK', rowCount: 1 } };
|
||
if (pathname === '/api/commission/settlements/proofs' && method === 'GET') return { items: [{ id: ids.proof, settlementId: query.get('settlementId') || ids.settlement, title: '烟测凭证', status: 'submitted', externalUrl: 'https://assets.example.test/proof.pdf', amountCents: 3980 }] };
|
||
if (pathname === '/api/commission/settlements/proofs' && method === 'POST') return { item: { id: ids.proof, status: 'submitted', ...body } };
|
||
if (pathname === '/api/commission/settlements/proofs/status') return { item: { id: body.proofId || ids.proof, status: body.status } };
|
||
if (pathname.startsWith('/api/tenant-content/') || pathname.startsWith('/api/tenant-admin/') || pathname.startsWith('/api/crm/') || pathname.startsWith('/api/commission/') || pathname.startsWith('/api/referral/')) {
|
||
return method === 'GET' ? { items: [], item: null, summary: {} } : { ok: true, item: { id: 'tenant-smoke' } };
|
||
}
|
||
|
||
if (pathname === '/api/platform-admin/overview') {
|
||
return { item: { tenants: { total: 6, active: 5 }, billing: { unpaidAmountCents: 360000, paidAmountCents: 1200000 }, subscriptions: { expiringSoon: 1 }, usage: { questions: 74117, students: 3722 } } };
|
||
}
|
||
if (pathname === '/api/platform-admin/permissions') return { item: { primaryRole: 'platform_admin', effective: { '*': true, 'platform:staff:write': true, 'platform:staff:status': true }, catalog: platformPermissionCatalog } };
|
||
if (pathname === '/api/platform-admin/plans') return { items: [{ id: ids.plan, code: 'starter_yearly', name: '基础年费', billingCycle: 'yearly', baseAmountCents: 199900, currency: 'CNY', status: 'active' }] };
|
||
if (pathname === '/api/platform-admin/tenants' && method === 'GET') return { items: [{ id: ids.tenant, slug: 'master', name: '工学题库', legalName: '工学教育科技有限公司', brandName: '工学题库', status: 'active', billingStatus: 'normal', planCode: 'starter_yearly', subscriptionStatus: 'active', subscriptionExpiresAt: '2027-07-01T00:00:00.000Z', openBalanceCents: 199900 }] };
|
||
if (pathname === '/api/platform-admin/tenants' && method === 'POST') return { item: { id: ids.tenant, slug: body.slug || 'smoke-tenant', name: body.name || '烟测租户', brandName: body.brandName || body.name || '烟测租户', status: body.status || 'active', billingStatus: body.billingStatus || 'trial', planCode: body.planCode || 'starter_yearly' } };
|
||
if (pathname === '/api/platform-admin/tenants/detail') return { item: { tenant: { id: ids.tenant, slug: 'master', name: '工学题库', legalName: '工学教育科技有限公司', brandName: '工学题库', status: 'active', billingStatus: 'normal', billingName: '工学教育科技有限公司', contactName: '陈老师', contactPhone: '13800000000', invoiceType: 'normal_vat' }, domains: [{ id: 'domain-smoke', host: 'tiku.example.test', status: 'verified', isPrimary: true }], subscriptions: [{ id: 'subscription-smoke', planCode: 'starter_yearly', status: 'active', amountCents: 199900, startsAt: '2026-07-01', expiresAt: '2027-07-01' }], invoices: [{ id: ids.invoice, tenantId: ids.tenant, invoiceNo: 'INV-SMOKE', status: 'unpaid', totalCents: 199900, paidCents: 0, balanceCents: 199900, dueDate: '2026-07-10' }], usage: [{ id: 'usage-smoke', tenantId: ids.tenant, metricKey: 'students', metricValue: 3722, periodStart: '2026-07-01', periodEnd: '2026-07-31' }] } };
|
||
if (pathname === '/api/platform-admin/tenants/status') return { item: { id: body.tenantId || ids.tenant, status: body.status || 'active', billingStatus: body.billingStatus || 'normal' } };
|
||
if (pathname === '/api/platform-admin/tenants/billing-profile') return { item: { ...body } };
|
||
if (pathname === '/api/platform-admin/audit-logs') return { items: [{ id: 'audit-smoke', tenantId: ids.tenant, action: 'tenant.updated', targetType: 'tenant', targetId: ids.tenant, actorName: '平台管理员', createdAt: new Date().toISOString(), details: { source: 'smoke' } }] };
|
||
if (pathname === '/api/platform-admin/invoices' && method === 'GET') return { items: [{ id: ids.invoice, tenantId: ids.tenant, tenantSlug: 'master', tenantName: '工学题库', invoiceNo: 'INV-SMOKE', invoiceType: 'subscription', status: 'unpaid', currency: 'CNY', totalCents: 199900, paidCents: 0, balanceCents: 199900, dueDate: '2026-07-10' }] };
|
||
if (pathname === '/api/platform-admin/invoices/reminders') return { items: [{ id: 'reminder-smoke', tenantId: ids.tenant, tenantName: '工学题库', invoiceId: ids.invoice, invoiceNo: 'INV-SMOKE', reminderType: 'overdue', channel: 'internal', status: 'pending', reminderLevel: 1, balanceCentsSnapshot: 199900, message: '烟测催缴' }] };
|
||
if (pathname === '/api/platform-admin/usage' && method === 'GET') return { items: [{ id: 'usage-smoke', tenantId: ids.tenant, tenantName: '工学题库', metricKey: 'students', metricValue: 3722, periodStart: '2026-07-01', periodEnd: '2026-07-31' }] };
|
||
if (pathname === '/api/platform-admin/usage' && method === 'POST') return { item: { id: 'usage-created-smoke', ...body } };
|
||
if (pathname === '/api/platform-admin/subscriptions') return { item: { id: 'subscription-created-smoke', ...body } };
|
||
if (pathname === '/api/platform-admin/invoices/from-subscription') return { item: { id: ids.invoice, invoiceNo: 'INV-SMOKE', ...body } };
|
||
if (pathname === '/api/platform-admin/invoices/subscription-candidates') return { items: [{ tenantId: ids.tenant, tenantSlug: 'master', tenantName: '工学题库', subscriptionId: 'subscription-smoke', planCode: 'starter_yearly', planName: '基础年费', status: 'active', amountCents: 199900, expiresAt: '2026-07-20', hasExistingInvoice: false, wouldCreate: true }] };
|
||
if (pathname === '/api/platform-admin/invoices/from-subscriptions-batch') return { item: { dryRun: body.dryRun === true, createdCount: body.dryRun ? 0 : 1, skippedCount: 0, items: [{ tenantId: ids.tenant, subscriptionId: 'subscription-smoke', wouldCreate: true }] } };
|
||
if (pathname === '/api/platform-admin/invoices/usage-overage-candidates') return { items: [{ tenantId: ids.tenant, tenantSlug: 'master', tenantName: '工学题库', planCode: 'starter_yearly', planName: '基础年费', periodStart: query.get('periodStart') || '2026-07-01', periodEnd: query.get('periodEnd') || '2026-07-31', hasExistingInvoice: false, wouldCreate: true, totalCents: 8800, items: [{ description: '超额学生数', quantity: 88, unitAmountCents: 100 }] }] };
|
||
if (pathname === '/api/platform-admin/invoices/from-usage-overage') return { item: { dryRun: body.dryRun === true, createdCount: body.dryRun ? 0 : 1, skippedCount: 0, totalCents: 8800, items: [{ tenantId: ids.tenant, periodStart: body.periodStart, periodEnd: body.periodEnd, totalCents: 8800, wouldCreate: true }] } };
|
||
if (pathname === '/api/platform-admin/invoices/payments/manual-confirm') return { item: { id: 'payment-smoke', invoiceId: body.invoiceId || ids.invoice, amountCents: body.amountCents, status: 'confirmed' } };
|
||
if (pathname === '/api/platform-admin/invoices/process-overdue') return { item: { dryRun: body.dryRun === true, processed: 1, markedOverdue: body.dryRun ? 0 : 1, reminderCreated: body.dryRun ? 0 : 1, skippedReminder: 0, items: [{ id: ids.invoice, wouldMarkOverdue: true, wouldCreateReminder: true }] } };
|
||
if (pathname === '/api/platform-admin/question-banks') return { items: [{ id: ids.publicBank, tenantId: null, name: '平台公共题库', regionId: ids.region, regionName: '天津', sourceScope: 'platform_public', status: 'active', questionCount: 128 }] };
|
||
if (pathname === '/api/platform-admin/question-bank-grants' && method === 'GET') return { items: [{ id: ids.publicBankGrant, sourceQuestionBankId: ids.publicBank, sourceQuestionBankName: '平台公共题库', sourceRegionName: '天津', grantScope: 'all_active_tenants', allowedPlanCodes: ['starter_yearly'], status: 'active' }] };
|
||
if (pathname === '/api/platform-admin/question-bank-grants' && method === 'PUT') return { item: { id: body.id || ids.publicBankGrant, ...body } };
|
||
if (pathname === '/api/platform-admin/staff' && method === 'GET') return { items: [{ id: ids.platformStaff, name: '平台管理员', username: 'platform_admin', email: 'platform@example.test', phone: '13600000000', primaryRole: 'platform_admin', status: 'active', authUserId: 'auth-smoke', platformPermissions: { '*': true }, createdAt: new Date().toISOString() }] };
|
||
if (pathname === '/api/platform-admin/staff' && method === 'PUT') return { item: { id: body.id || ids.platformStaff, ...body, primaryRole: 'platform_admin', platformPermissions: body.platformPermissions || { '*': true } } };
|
||
if (pathname === '/api/platform-admin/staff/status') return { item: { id: body.staffId || ids.platformStaff, status: body.status || 'active' } };
|
||
if (pathname.startsWith('/api/platform-admin/')) return method === 'GET' ? { items: [], item: null } : { ok: true, item: { id: 'platform-smoke' } };
|
||
|
||
return method === 'GET' ? { items: [], item: null } : { ok: true, item: { id: 'smoke' } };
|
||
}
|
||
|
||
async function createMockApiServer(options = {}) {
|
||
const requests = [];
|
||
let authenticated = true;
|
||
const serveOverHttp = options.serveOverHttp === true;
|
||
const requestHandler = async (req, res) => {
|
||
const url = new URL(req.url || '/', serveOverHttp ? 'http://127.0.0.1' : `https://${smokeApiHostname}`);
|
||
if (req.method === 'OPTIONS') {
|
||
jsonResponse(res, 204, {});
|
||
return;
|
||
}
|
||
const body = await requestBody(req);
|
||
const requestRecord = {
|
||
method: req.method || 'GET',
|
||
path: url.pathname,
|
||
query: Object.fromEntries(url.searchParams.entries()),
|
||
body,
|
||
status: 0,
|
||
};
|
||
requests.push(requestRecord);
|
||
try {
|
||
if (url.pathname === '/api/auth/me' && !authenticated) {
|
||
requestRecord.status = 401;
|
||
jsonResponse(res, 401, { error: 'Invalid or expired session', code: 'AUTH_SESSION_INVALID' });
|
||
return;
|
||
}
|
||
requestRecord.status = 200;
|
||
jsonResponse(res, 200, mockApiPayload(url.pathname, req.method || 'GET', url.searchParams, body));
|
||
} catch (error) {
|
||
requestRecord.status = 500;
|
||
jsonResponse(res, 500, { code: 'MOCK_API_ERROR', message: error instanceof Error ? error.message : String(error) });
|
||
}
|
||
};
|
||
const server = serveOverHttp
|
||
? http.createServer(requestHandler)
|
||
: https.createServer({ key: smokeTls.private, cert: smokeTls.cert }, requestHandler);
|
||
const port = await listen(server);
|
||
return {
|
||
baseUrl: serveOverHttp ? `http://127.0.0.1:${port}` : `https://${smokeApiHostname}:${port}`,
|
||
requests,
|
||
setAuthenticated(value) {
|
||
authenticated = Boolean(value);
|
||
},
|
||
close: () => closeServer(server),
|
||
};
|
||
}
|
||
|
||
async function createStaticServer(portal, apiBaseUrl) {
|
||
const distDir = path.join(distRoot, portal.dist);
|
||
const runtimeConfig = {
|
||
portal: portal.portal,
|
||
apiBaseUrl,
|
||
supabaseUrl: 'https://auth.example.test',
|
||
supabasePublishableKey: 'sb_publishable_mock_key_for_h5_interaction_smoke',
|
||
tenantCode: '',
|
||
};
|
||
|
||
assertDistExists(portal);
|
||
const server = http.createServer((req, res) => {
|
||
const url = new URL(req.url || '/', 'http://127.0.0.1');
|
||
const requestPath = decodeURIComponent(url.pathname);
|
||
if (requestPath === '/runtime-config.json') {
|
||
jsonResponse(res, 200, runtimeConfig);
|
||
return;
|
||
}
|
||
if (requestPath === '/favicon.ico') {
|
||
res.writeHead(204, { 'cache-control': 'public, max-age=86400' });
|
||
res.end();
|
||
return;
|
||
}
|
||
|
||
const filePath = resolveStaticPath(distDir, requestPath);
|
||
if (!filePath) {
|
||
textResponse(res, 403, 'forbidden', { 'content-type': 'text/plain; charset=utf-8' });
|
||
return;
|
||
}
|
||
const finalPath = fs.existsSync(filePath) && fs.statSync(filePath).isFile()
|
||
? filePath
|
||
: path.join(distDir, 'index.html');
|
||
if (!fs.existsSync(finalPath)) {
|
||
textResponse(res, 404, 'not found', { 'content-type': 'text/plain; charset=utf-8' });
|
||
return;
|
||
}
|
||
res.writeHead(200, {
|
||
'content-type': contentTypeFor(finalPath),
|
||
'cache-control': finalPath.endsWith('index.html') ? 'no-store' : 'public, max-age=31536000, immutable',
|
||
});
|
||
fs.createReadStream(finalPath).pipe(res);
|
||
});
|
||
const port = await listen(server);
|
||
return {
|
||
portal: portal.portal,
|
||
baseUrl: `http://127.0.0.1:${port}`,
|
||
landingPath: portal.landingPath,
|
||
close: () => closeServer(server),
|
||
};
|
||
}
|
||
|
||
function resolveStaticPath(distDir, requestPath) {
|
||
const cleanPath = requestPath === '/' ? '/index.html' : requestPath;
|
||
const resolved = path.resolve(distDir, `.${cleanPath}`);
|
||
const normalizedRoot = path.resolve(distDir);
|
||
if (resolved !== normalizedRoot && !resolved.startsWith(`${normalizedRoot}${path.sep}`)) return null;
|
||
return resolved;
|
||
}
|
||
|
||
function assertDistExists(portal) {
|
||
const distDir = path.join(distRoot, portal.dist);
|
||
const indexPath = path.join(distDir, 'index.html');
|
||
if (!fs.existsSync(distDir)) throw new Error(`${relative(distDir)} does not exist. Run npm run build:taro:h5 before interaction smoke.`);
|
||
if (!fs.existsSync(indexPath)) throw new Error(`${relative(indexPath)} does not exist. Run npm run build:taro:h5 before interaction smoke.`);
|
||
}
|
||
|
||
function assertServeOnlyPreviewBuild(portal) {
|
||
const distDir = path.join(distRoot, portal.dist);
|
||
const scripts = fs.readdirSync(path.join(distDir, 'js'))
|
||
.filter(fileName => fileName.endsWith('.js'))
|
||
.map(fileName => fs.readFileSync(path.join(distDir, 'js', fileName), 'utf8'));
|
||
if (!scripts.some(source => /["']releaseMode["']\s*:\s*["']preview["']/.test(source))) {
|
||
throw new Error(
|
||
`${relative(distDir)} is not a preview build. Run npm run build:taro:h5:preview before npm run serve:taro:h5:qa; rebuild all three production H5 artifacts after visual QA.`,
|
||
);
|
||
}
|
||
}
|
||
|
||
function listen(server) {
|
||
return new Promise((resolve, reject) => {
|
||
server.once('error', reject);
|
||
server.listen(0, '127.0.0.1', () => resolve(server.address().port));
|
||
});
|
||
}
|
||
|
||
function closeServer(server) {
|
||
return new Promise(resolve => server.close(() => resolve()));
|
||
}
|
||
|
||
function getFreePort() {
|
||
return new Promise((resolve, reject) => {
|
||
const server = net.createServer();
|
||
server.on('error', reject);
|
||
server.listen(0, '127.0.0.1', () => {
|
||
const address = server.address();
|
||
server.close(() => resolve(address.port));
|
||
});
|
||
});
|
||
}
|
||
|
||
function findBrowserExecutable() {
|
||
const candidates = [
|
||
process.env.TARO_H5_SMOKE_BROWSER,
|
||
'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe',
|
||
'C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe',
|
||
'C:\\Program Files\\Microsoft\\Edge\\Application\\msedge.exe',
|
||
'C:\\Program Files (x86)\\Microsoft\\Edge\\Application\\msedge.exe',
|
||
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
|
||
'/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge',
|
||
'/usr/bin/google-chrome',
|
||
'/usr/bin/chromium',
|
||
'/usr/bin/chromium-browser',
|
||
'/usr/bin/microsoft-edge',
|
||
].filter(Boolean);
|
||
return candidates.find(item => fs.existsSync(item));
|
||
}
|
||
|
||
async function waitForDebugEndpoint(port, timeoutMs = 15_000) {
|
||
const started = Date.now();
|
||
while (Date.now() - started < timeoutMs) {
|
||
try {
|
||
const response = await fetch(`http://127.0.0.1:${port}/json/version`);
|
||
if (response.ok) return await response.json();
|
||
} catch {
|
||
// Chrome is still starting.
|
||
}
|
||
await delay(200);
|
||
}
|
||
throw new Error('Browser DevTools endpoint did not become available.');
|
||
}
|
||
|
||
async function startBrowser(options) {
|
||
const executable = findBrowserExecutable();
|
||
if (!executable) {
|
||
throw new Error('Chrome/Edge executable not found. Set TARO_H5_SMOKE_BROWSER to a Chromium-based browser path.');
|
||
}
|
||
const debugPort = await getFreePort();
|
||
const userDataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'taro-h5-interaction-'));
|
||
const args = [
|
||
'--headless=new',
|
||
'--ignore-certificate-errors',
|
||
`--host-resolver-rules=MAP ${smokeApiHostname} 127.0.0.1`,
|
||
'--disable-gpu',
|
||
'--disable-dev-shm-usage',
|
||
'--no-first-run',
|
||
'--no-default-browser-check',
|
||
'--disable-background-networking',
|
||
'--disable-extensions',
|
||
`--remote-debugging-port=${debugPort}`,
|
||
`--user-data-dir=${userDataDir}`,
|
||
'about:blank',
|
||
];
|
||
const child = spawn(executable, args, { stdio: 'ignore', windowsHide: true });
|
||
await waitForDebugEndpoint(debugPort);
|
||
return {
|
||
executable,
|
||
debugPort,
|
||
close: async () => {
|
||
if (!options.keepBrowser && !child.killed) child.kill();
|
||
if (!options.keepBrowser) {
|
||
await delay(300);
|
||
fs.rmSync(userDataDir, { recursive: true, force: true });
|
||
}
|
||
},
|
||
};
|
||
}
|
||
|
||
class CdpPage {
|
||
constructor(wsUrl, debugPort, targetId) {
|
||
this.wsUrl = wsUrl;
|
||
this.debugPort = debugPort;
|
||
this.targetId = targetId;
|
||
this.id = 1;
|
||
this.pending = new Map();
|
||
this.events = [];
|
||
this.ws = new WebSocket(wsUrl);
|
||
}
|
||
|
||
async connect() {
|
||
await new Promise((resolve, reject) => {
|
||
this.ws.addEventListener('open', resolve, { once: true });
|
||
this.ws.addEventListener('error', reject, { once: true });
|
||
this.ws.addEventListener('message', event => {
|
||
const message = JSON.parse(event.data);
|
||
if (message.id && this.pending.has(message.id)) {
|
||
const { resolve: finish, reject: fail } = this.pending.get(message.id);
|
||
this.pending.delete(message.id);
|
||
if (message.error) fail(new Error(`${message.error.message}: ${message.error.data || ''}`));
|
||
else finish(message.result || {});
|
||
} else if (message.method) {
|
||
this.events.push(message);
|
||
}
|
||
});
|
||
});
|
||
await this.send('Runtime.enable');
|
||
await this.send('Page.enable');
|
||
await this.send('Log.enable');
|
||
await this.send('Network.enable');
|
||
await this.send('Page.setInterceptFileChooserDialog', { enabled: false }).catch(() => {});
|
||
return this;
|
||
}
|
||
|
||
send(method, params = {}) {
|
||
const id = this.id++;
|
||
this.ws.send(JSON.stringify({ id, method, params }));
|
||
return new Promise((resolve, reject) => {
|
||
this.pending.set(id, { resolve, reject });
|
||
setTimeout(() => {
|
||
if (this.pending.has(id)) {
|
||
this.pending.delete(id);
|
||
reject(new Error(`CDP command timed out: ${method}`));
|
||
}
|
||
}, 10_000);
|
||
});
|
||
}
|
||
|
||
async evaluate(expression) {
|
||
const result = await this.send('Runtime.evaluate', {
|
||
expression,
|
||
awaitPromise: true,
|
||
returnByValue: true,
|
||
});
|
||
if (result.exceptionDetails) {
|
||
const description = result.exceptionDetails.exception?.description || result.exceptionDetails.text || 'Runtime evaluation failed';
|
||
throw new Error(description);
|
||
}
|
||
return result.result?.value;
|
||
}
|
||
|
||
async navigate(url) {
|
||
await this.send('Page.navigate', { url });
|
||
}
|
||
|
||
async mouseClick(x, y) {
|
||
await this.send('Input.dispatchMouseEvent', { type: 'mouseMoved', x, y, button: 'none' });
|
||
await this.send('Input.dispatchMouseEvent', { type: 'mousePressed', x, y, button: 'left', clickCount: 1 });
|
||
await this.send('Input.dispatchMouseEvent', { type: 'mouseReleased', x, y, button: 'left', clickCount: 1 });
|
||
}
|
||
|
||
async setViewport(width, height) {
|
||
await this.send('Emulation.setDeviceMetricsOverride', {
|
||
width,
|
||
height,
|
||
deviceScaleFactor: 1,
|
||
mobile: false,
|
||
});
|
||
}
|
||
|
||
async close() {
|
||
try {
|
||
if (this.targetId) {
|
||
await fetch(`http://127.0.0.1:${this.debugPort}/json/close/${encodeURIComponent(this.targetId)}`);
|
||
}
|
||
} finally {
|
||
this.ws.close();
|
||
}
|
||
}
|
||
|
||
diagnosticEvents() {
|
||
return this.events
|
||
.filter(event => [
|
||
'Runtime.exceptionThrown',
|
||
'Runtime.consoleAPICalled',
|
||
'Log.entryAdded',
|
||
'Network.loadingFailed',
|
||
'Network.responseReceived',
|
||
].includes(event.method))
|
||
.map(event => {
|
||
if (event.method === 'Runtime.exceptionThrown') {
|
||
const details = event.params?.exceptionDetails || {};
|
||
return {
|
||
method: event.method,
|
||
timestamp: event.params?.timestamp,
|
||
text: details.text,
|
||
description: details.exception?.description,
|
||
url: details.url,
|
||
lineNumber: details.lineNumber,
|
||
columnNumber: details.columnNumber,
|
||
stackTrace: details.stackTrace,
|
||
};
|
||
}
|
||
if (event.method === 'Runtime.consoleAPICalled') {
|
||
return {
|
||
method: event.method,
|
||
type: event.params?.type,
|
||
args: (event.params?.args || []).map(arg => arg.value || arg.description).filter(Boolean).slice(0, 5),
|
||
};
|
||
}
|
||
if (event.method === 'Log.entryAdded') {
|
||
return {
|
||
method: event.method,
|
||
level: event.params?.entry?.level,
|
||
text: event.params?.entry?.text,
|
||
url: event.params?.entry?.url,
|
||
};
|
||
}
|
||
if (event.method === 'Network.loadingFailed') {
|
||
return {
|
||
method: event.method,
|
||
errorText: event.params?.errorText,
|
||
type: event.params?.type,
|
||
};
|
||
}
|
||
const response = event.params?.response || {};
|
||
if (response.status >= 400) {
|
||
return {
|
||
method: event.method,
|
||
status: response.status,
|
||
url: response.url,
|
||
};
|
||
}
|
||
return null;
|
||
})
|
||
.filter(Boolean)
|
||
.slice(-20);
|
||
}
|
||
|
||
runtimeFailures({ allowedHttp = [] } = {}) {
|
||
const allowedResponse = (status, url) => allowedHttp.some(rule => {
|
||
const statusMatches = rule.status === undefined || status === undefined || Number(rule.status) === Number(status);
|
||
const urlMatches = !rule.path || String(url || '').includes(rule.path);
|
||
return statusMatches && urlMatches;
|
||
});
|
||
const failures = [];
|
||
const requestUrls = new Map(
|
||
this.events
|
||
.filter(event => event.method === 'Network.requestWillBeSent')
|
||
.map(event => [event.params?.requestId, event.params?.request?.url]),
|
||
);
|
||
|
||
for (const event of this.events) {
|
||
if (event.method === 'Runtime.exceptionThrown') {
|
||
const details = event.params?.exceptionDetails || {};
|
||
failures.push({
|
||
method: event.method,
|
||
timestamp: event.params?.timestamp,
|
||
text: details.text,
|
||
description: details.exception?.description,
|
||
url: details.url,
|
||
lineNumber: details.lineNumber,
|
||
columnNumber: details.columnNumber,
|
||
stackTrace: details.stackTrace,
|
||
});
|
||
continue;
|
||
}
|
||
if (event.method === 'Runtime.consoleAPICalled' && ['error', 'assert'].includes(event.params?.type)) {
|
||
failures.push({
|
||
method: event.method,
|
||
type: event.params?.type,
|
||
args: (event.params?.args || []).map(arg => arg.value || arg.description).filter(Boolean).slice(0, 8),
|
||
});
|
||
continue;
|
||
}
|
||
if (event.method === 'Log.entryAdded' && event.params?.entry?.level === 'error') {
|
||
const entry = event.params.entry;
|
||
if (!allowedResponse(undefined, entry.url)) {
|
||
failures.push({ method: event.method, level: entry.level, text: entry.text, url: entry.url });
|
||
}
|
||
continue;
|
||
}
|
||
if (event.method === 'Network.loadingFailed') {
|
||
const failure = event.params || {};
|
||
const url = requestUrls.get(failure.requestId) || '';
|
||
if (failure.canceled || failure.errorText === 'net::ERR_ABORTED') continue;
|
||
if (allowedResponse(undefined, url)) continue;
|
||
failures.push({ method: event.method, errorText: failure.errorText, type: failure.type, url });
|
||
continue;
|
||
}
|
||
if (event.method === 'Network.responseReceived') {
|
||
const response = event.params?.response || {};
|
||
if (response.status >= 400 && !allowedResponse(response.status, response.url)) {
|
||
failures.push({ method: event.method, status: response.status, url: response.url });
|
||
}
|
||
}
|
||
}
|
||
return failures.slice(-30);
|
||
}
|
||
|
||
async acceptDialogs() {
|
||
const events = this.events.filter(event => event.method === 'Page.javascriptDialogOpening');
|
||
this.events = this.events.filter(event => event.method !== 'Page.javascriptDialogOpening');
|
||
for (const event of events) {
|
||
await this.send('Page.handleJavaScriptDialog', { accept: true }).catch(() => {});
|
||
}
|
||
}
|
||
}
|
||
|
||
async function newPage(browser, url) {
|
||
const response = await fetch(`http://127.0.0.1:${browser.debugPort}/json/new?${encodeURIComponent('about:blank')}`, { method: 'PUT' });
|
||
if (!response.ok) throw new Error(`Failed to create browser tab: ${response.status}`);
|
||
const target = await response.json();
|
||
const page = await new CdpPage(target.webSocketDebuggerUrl, browser.debugPort, target.id).connect();
|
||
if (url) await page.navigate(url);
|
||
return page;
|
||
}
|
||
|
||
function assertRuntimeHealthy(page, label, options = {}) {
|
||
const failures = page.runtimeFailures(options);
|
||
if (failures.length) {
|
||
throw new Error(`${label} emitted browser runtime errors:\n${JSON.stringify(failures, null, 2)}`);
|
||
}
|
||
}
|
||
|
||
async function waitUntil(label, fn, timeoutMs = 10_000) {
|
||
const started = Date.now();
|
||
let lastValue;
|
||
let lastError = '';
|
||
while (Date.now() - started < timeoutMs) {
|
||
try {
|
||
lastValue = await fn();
|
||
lastError = '';
|
||
} catch (error) {
|
||
lastValue = undefined;
|
||
lastError = error instanceof Error ? error.message : String(error);
|
||
}
|
||
if (lastValue) return lastValue;
|
||
await delay(200);
|
||
}
|
||
const errorDetail = lastError ? ` Last error: ${lastError}` : '';
|
||
throw new Error(`Timed out waiting for ${label}. Last value: ${JSON.stringify(lastValue)}.${errorDetail}`);
|
||
}
|
||
|
||
async function bodyText(page) {
|
||
return page.evaluate('document.body ? document.body.innerText : ""');
|
||
}
|
||
|
||
async function currentPath(page) {
|
||
return page.evaluate('location.pathname + location.search + location.hash');
|
||
}
|
||
|
||
async function waitForText(page, text, timeoutMs = 10_000) {
|
||
try {
|
||
await waitUntil(`text "${text}"`, async () => {
|
||
const textContent = await bodyText(page);
|
||
return textContent.includes(text);
|
||
}, timeoutMs);
|
||
} catch (error) {
|
||
const [pathValue, textContent] = await Promise.all([
|
||
currentPath(page).catch(() => ''),
|
||
bodyText(page).catch(() => ''),
|
||
]);
|
||
throw new Error(`${error.message}\nCurrent path: ${pathValue}\nBody excerpt: ${textContent.slice(0, 1200)}\nBrowser events: ${JSON.stringify(page.diagnosticEvents(), null, 2)}`);
|
||
}
|
||
}
|
||
|
||
async function assertNoText(page, text) {
|
||
const textContent = await bodyText(page);
|
||
if (textContent.includes(text)) throw new Error(`Unexpected text found in page: ${text}`);
|
||
}
|
||
|
||
async function waitForPath(page, pathPart, timeoutMs = 10_000) {
|
||
try {
|
||
await waitUntil(`path "${pathPart}"`, async () => {
|
||
const pathValue = await currentPath(page);
|
||
return pathValue.includes(pathPart);
|
||
}, timeoutMs);
|
||
} catch (error) {
|
||
const [pathValue, textContent] = await Promise.all([
|
||
currentPath(page).catch(() => ''),
|
||
bodyText(page).catch(() => ''),
|
||
]);
|
||
throw new Error(`${error.message}\nCurrent path: ${pathValue}\nBody excerpt: ${textContent.slice(0, 1200)}\nBrowser events: ${JSON.stringify(page.diagnosticEvents(), null, 2)}`);
|
||
}
|
||
}
|
||
|
||
async function clickText(page, text) {
|
||
await page.acceptDialogs();
|
||
const result = await page.evaluate(`
|
||
(() => {
|
||
const expected = ${JSON.stringify(text)};
|
||
const clickableSelector = [
|
||
'button',
|
||
'taro-button-core',
|
||
'a',
|
||
'[role="button"]',
|
||
'[onclick]',
|
||
'.entry-tile',
|
||
'.list-row',
|
||
'.metric',
|
||
'.admin-row',
|
||
'.admin-button',
|
||
'.platform-row',
|
||
'.platform-button',
|
||
'.platform-chip',
|
||
'[class*="button"]'
|
||
].join(',');
|
||
const visible = el => {
|
||
const style = window.getComputedStyle(el);
|
||
const rect = el.getBoundingClientRect();
|
||
return style.visibility !== 'hidden' && style.display !== 'none' && rect.width > 0 && rect.height > 0;
|
||
};
|
||
const normalized = value => String(value || '').replace(/\\s+/g, ' ').trim();
|
||
const textOf = el => normalized(el.innerText || el.textContent || '');
|
||
const byElement = new Map();
|
||
for (const el of Array.from(document.querySelectorAll('*'))) {
|
||
if (!visible(el)) continue;
|
||
const text = textOf(el);
|
||
if (!text.includes(expected)) continue;
|
||
const target = el.matches(clickableSelector) ? el : el.closest(clickableSelector);
|
||
if (!target || !visible(target)) continue;
|
||
const targetText = textOf(target);
|
||
if (!targetText.includes(expected)) continue;
|
||
const exact = targetText === expected ? 0 : 1;
|
||
const tagScore = ['BUTTON', 'TARO-BUTTON-CORE', 'A'].includes(target.tagName) ? 0 : 1;
|
||
const lengthScore = targetText.length;
|
||
const previous = byElement.get(target);
|
||
const next = { el: target, text: targetText, score: exact * 10000 + tagScore * 1000 + lengthScore };
|
||
if (!previous || next.score < previous.score) byElement.set(target, next);
|
||
}
|
||
const candidates = Array.from(byElement.values()).sort((a, b) => a.score - b.score);
|
||
const target = candidates[0]?.el;
|
||
if (!target) return { ok: false, body: (document.body?.innerText || '').slice(0, 1200) };
|
||
target.scrollIntoView({ block: 'center', inline: 'center' });
|
||
const rect = target.getBoundingClientRect();
|
||
const x = rect.left + rect.width / 2;
|
||
const y = rect.top + rect.height / 2;
|
||
target.click();
|
||
return { ok: true, tag: target.tagName, className: target.className, text: textOf(target).slice(0, 120), x, y };
|
||
})()
|
||
`);
|
||
await page.acceptDialogs();
|
||
if (!result?.ok) throw new Error(`Clickable text not found: ${text}\n${result?.body || ''}`);
|
||
await delay(350);
|
||
await page.acceptDialogs();
|
||
return result;
|
||
}
|
||
|
||
async function clickTextInSection(page, sectionTitle, text) {
|
||
await page.acceptDialogs();
|
||
const result = await page.evaluate(`
|
||
(() => {
|
||
const sectionTitle = ${JSON.stringify(sectionTitle)};
|
||
const expected = ${JSON.stringify(text)};
|
||
const clickableSelector = [
|
||
'button',
|
||
'taro-button-core',
|
||
'a',
|
||
'[role="button"]',
|
||
'[onclick]',
|
||
'.admin-row',
|
||
'.admin-button',
|
||
'.platform-row',
|
||
'.platform-button',
|
||
'.platform-chip',
|
||
'[class*="button"]'
|
||
].join(',');
|
||
const visible = el => {
|
||
const style = window.getComputedStyle(el);
|
||
const rect = el.getBoundingClientRect();
|
||
return style.visibility !== 'hidden' && style.display !== 'none' && rect.width > 0 && rect.height > 0;
|
||
};
|
||
const normalized = value => String(value || '').replace(/\\s+/g, ' ').trim();
|
||
const textOf = el => normalized(el.innerText || el.textContent || '');
|
||
const sections = Array.from(document.querySelectorAll('.admin-section,.platform-section,section'))
|
||
.filter(el => visible(el) && textOf(el).includes(sectionTitle));
|
||
const root = sections.sort((a, b) => textOf(a).length - textOf(b).length)[0];
|
||
if (!root) return { ok: false, body: (document.body?.innerText || '').slice(0, 1200) };
|
||
const byElement = new Map();
|
||
for (const el of Array.from(root.querySelectorAll('*'))) {
|
||
if (!visible(el)) continue;
|
||
const nodeText = textOf(el);
|
||
if (!nodeText.includes(expected)) continue;
|
||
const target = el.matches(clickableSelector) ? el : el.closest(clickableSelector);
|
||
if (!target || !visible(target) || !root.contains(target)) continue;
|
||
const targetText = textOf(target);
|
||
if (!targetText.includes(expected)) continue;
|
||
const exact = targetText === expected ? 0 : 1;
|
||
const tagScore = ['BUTTON', 'TARO-BUTTON-CORE', 'A'].includes(target.tagName) ? 0 : 1;
|
||
const lengthScore = targetText.length;
|
||
const previous = byElement.get(target);
|
||
const next = { el: target, text: targetText, score: exact * 10000 + tagScore * 1000 + lengthScore };
|
||
if (!previous || next.score < previous.score) byElement.set(target, next);
|
||
}
|
||
const candidates = Array.from(byElement.values()).sort((a, b) => a.score - b.score);
|
||
const target = candidates[0]?.el;
|
||
if (!target) return { ok: false, body: textOf(root).slice(0, 1200) };
|
||
target.scrollIntoView({ block: 'center', inline: 'center' });
|
||
const rect = target.getBoundingClientRect();
|
||
const x = rect.left + rect.width / 2;
|
||
const y = rect.top + rect.height / 2;
|
||
target.click();
|
||
return { ok: true, tag: target.tagName, className: target.className, text: textOf(target).slice(0, 120), x, y };
|
||
})()
|
||
`);
|
||
await page.acceptDialogs();
|
||
if (!result?.ok) throw new Error(`Clickable text not found in section "${sectionTitle}": ${text}\n${result?.body || ''}`);
|
||
await delay(350);
|
||
await page.acceptDialogs();
|
||
return result;
|
||
}
|
||
|
||
async function clickTextIfPresent(page, text) {
|
||
const exists = await page.evaluate(`
|
||
(() => {
|
||
const expected = ${JSON.stringify(text)};
|
||
const visible = el => {
|
||
const style = window.getComputedStyle(el);
|
||
const rect = el.getBoundingClientRect();
|
||
return style.visibility !== 'hidden' && style.display !== 'none' && rect.width > 0 && rect.height > 0;
|
||
};
|
||
return Array.from(document.querySelectorAll('button,taro-button-core,a,[role="button"],[onclick],[class*="button"]'))
|
||
.some(el => visible(el) && String(el.innerText || el.textContent || '').replace(/\\s+/g, ' ').trim().includes(expected));
|
||
})()
|
||
`);
|
||
if (!exists) return false;
|
||
await clickText(page, text);
|
||
return true;
|
||
}
|
||
|
||
async function clickVisibleTextCandidate(page, texts) {
|
||
const result = await page.evaluate(`
|
||
(() => {
|
||
const expectedTexts = ${JSON.stringify(texts)};
|
||
const clickableSelector = [
|
||
'button',
|
||
'taro-button-core',
|
||
'a',
|
||
'[role="button"]',
|
||
'[onclick]',
|
||
'.platform-chip',
|
||
'[class*="button"]',
|
||
'[class*="btn"]',
|
||
'[class*="model__btn"]',
|
||
'[class*="model__confirm"]'
|
||
].join(',');
|
||
const visible = el => {
|
||
const style = window.getComputedStyle(el);
|
||
const rect = el.getBoundingClientRect();
|
||
return style.visibility !== 'hidden' && style.display !== 'none' && rect.width > 0 && rect.height > 0;
|
||
};
|
||
const normalized = value => String(value || '').replace(/\\s+/g, ' ').trim();
|
||
const textOf = el => normalized(el.innerText || el.textContent || '');
|
||
const modalSelector = [
|
||
'.weui-dialog',
|
||
'.weui-picker',
|
||
'.taro-modal',
|
||
'[role="dialog"]',
|
||
'[aria-modal="true"]',
|
||
'[class*="dialog"]',
|
||
'[class*="modal"]'
|
||
].join(',');
|
||
const activeRoots = Array.from(document.querySelectorAll(modalSelector)).filter(visible);
|
||
if (!activeRoots.length) activeRoots.push(document.body);
|
||
const candidates = [];
|
||
activeRoots.forEach((root, rootIndex) => {
|
||
for (const el of Array.from(root.querySelectorAll('*'))) {
|
||
if (!visible(el)) continue;
|
||
const text = textOf(el);
|
||
if (!expectedTexts.some(expected => text === expected || text.includes(expected))) continue;
|
||
const target = el.matches(clickableSelector) ? el : el.closest(clickableSelector);
|
||
if (!target || !visible(target) || !root.contains(target)) continue;
|
||
const targetText = textOf(target);
|
||
const order = expectedTexts.findIndex(expected => targetText === expected || targetText.includes(expected));
|
||
const exact = expectedTexts.some(expected => targetText === expected) ? 0 : 1;
|
||
candidates.push({ el: target, text: targetText, score: rootIndex * 1000000 + order * 100000 + exact * 10000 + targetText.length });
|
||
}
|
||
});
|
||
candidates.sort((a, b) => a.score - b.score);
|
||
const target = candidates[0]?.el;
|
||
if (!target) return { ok: false, body: (document.body?.innerText || '').slice(0, 1200) };
|
||
target.scrollIntoView({ block: 'center', inline: 'center' });
|
||
const rect = target.getBoundingClientRect();
|
||
const x = rect.left + rect.width / 2;
|
||
const y = rect.top + rect.height / 2;
|
||
target.click();
|
||
return { ok: true, text: textOf(target).slice(0, 120), x, y };
|
||
})()
|
||
`);
|
||
if (result?.ok) {
|
||
await delay(250);
|
||
await page.acceptDialogs();
|
||
}
|
||
return result;
|
||
}
|
||
|
||
async function clickTextAndConfirm(page, text) {
|
||
await clickText(page, text);
|
||
await delay(250);
|
||
await page.acceptDialogs();
|
||
await clickVisibleTextCandidate(page, ['确认', '发布', '停用', '禁用', '恢复', '重试', '忽略', '已打款']);
|
||
await page.acceptDialogs();
|
||
}
|
||
|
||
async function waitForApiRequest(api, path, method) {
|
||
await waitUntil(`${method || ''} ${path} request`, async () => {
|
||
return api.requests.some(item => item.path === path && (!method || item.method === method));
|
||
}, 10_000);
|
||
}
|
||
|
||
async function waitForApiRequestCount(api, path, method, minCount, timeoutMs = 10_000) {
|
||
await waitUntil(`${method || ''} ${path} request count >= ${minCount}`, async () => {
|
||
return api.requests.filter(item => item.path === path && (!method || item.method === method)).length >= minCount;
|
||
}, timeoutMs);
|
||
}
|
||
|
||
function requestCount(api, path, method) {
|
||
return api.requests.filter(item => item.path === path && (!method || item.method === method)).length;
|
||
}
|
||
|
||
async function clickTextAndConfirmForApi(page, api, text, path, method) {
|
||
const before = requestCount(api, path, method);
|
||
await clickText(page, text);
|
||
const confirmTexts = ['确认', '发布', '停用', '禁用', '恢复', '重试', '忽略', '已打款'];
|
||
for (let attempt = 0; attempt < 8; attempt += 1) {
|
||
await page.acceptDialogs();
|
||
const hasModal = await page.evaluate(`
|
||
(() => {
|
||
const visible = el => {
|
||
const style = window.getComputedStyle(el);
|
||
const rect = el.getBoundingClientRect();
|
||
return style.visibility !== 'hidden' && style.display !== 'none' && rect.width > 0 && rect.height > 0;
|
||
};
|
||
const modalSelector = '.weui-dialog,.weui-half-screen-dialog,.taro-modal__content,[role="dialog"],[aria-modal="true"],[class*="dialog"],[class*="modal__content"]';
|
||
return Array.from(document.querySelectorAll(modalSelector)).some(visible);
|
||
})()
|
||
`);
|
||
if (hasModal) await clickVisibleTextCandidate(page, confirmTexts);
|
||
await page.acceptDialogs();
|
||
try {
|
||
await waitForApiRequestCount(api, path, method, before + 1, 1_200);
|
||
return;
|
||
} catch {
|
||
await delay(250);
|
||
}
|
||
}
|
||
try {
|
||
await waitForApiRequestCount(api, path, method, before + 1, 2_000);
|
||
} catch (error) {
|
||
const diagnostics = await page.evaluate(`
|
||
(() => {
|
||
const visible = el => {
|
||
const style = window.getComputedStyle(el);
|
||
const rect = el.getBoundingClientRect();
|
||
return style.visibility !== 'hidden' && style.display !== 'none' && rect.width > 0 && rect.height > 0;
|
||
};
|
||
const normalized = value => String(value || '').replace(/\\s+/g, ' ').trim();
|
||
const modalSelector = '.weui-dialog,.weui-mask,.weui-half-screen-dialog,.taro-modal,[role="dialog"],[aria-modal="true"],[class*="dialog"],[class*="modal"]';
|
||
return {
|
||
body: normalized(document.body?.innerText || '').slice(0, 1500),
|
||
modals: Array.from(document.querySelectorAll(modalSelector))
|
||
.filter(visible)
|
||
.map(el => ({ tag: el.tagName, className: String(el.className || ''), text: normalized(el.innerText || el.textContent || '').slice(0, 600) })),
|
||
buttons: Array.from(document.querySelectorAll('button,taro-button-core,a,[role="button"],[onclick],[class*="button"],[class*="btn"]'))
|
||
.filter(visible)
|
||
.map(el => ({ tag: el.tagName, className: String(el.className || ''), text: normalized(el.innerText || el.textContent || '').slice(0, 120) }))
|
||
.filter(item => item.text.includes('发布') || item.text.includes('确认') || item.text.includes('重试') || item.text.includes('停用') || item.text.includes('禁用') || item.text.includes('已打款'))
|
||
.slice(0, 30)
|
||
};
|
||
})()
|
||
`).catch(error => ({ error: error.message }));
|
||
const recentRequests = api.requests.slice(-20).map(item => `${item.method} ${item.path}`);
|
||
throw new Error(`${error.message}\nConfirm diagnostics: ${JSON.stringify(diagnostics, null, 2)}\nRecent API requests: ${JSON.stringify(recentRequests, null, 2)}`);
|
||
}
|
||
}
|
||
|
||
async function fillByPlaceholder(page, placeholder, value, occurrence = 0) {
|
||
const result = await page.evaluate(`
|
||
(() => {
|
||
const expected = ${JSON.stringify(placeholder)};
|
||
const nextValue = ${JSON.stringify(value)};
|
||
const occurrence = ${Number(occurrence)};
|
||
const visible = el => {
|
||
const style = window.getComputedStyle(el);
|
||
const rect = el.getBoundingClientRect();
|
||
return style.visibility !== 'hidden' && style.display !== 'none' && rect.width > 0 && rect.height > 0;
|
||
};
|
||
const nativeElements = Array.from(document.querySelectorAll('input, textarea'))
|
||
.filter(visible)
|
||
.filter(el => String(el.getAttribute('placeholder') || '').includes(expected));
|
||
const customElements = Array.from(document.querySelectorAll('taro-input-core, taro-textarea-core'))
|
||
.filter(visible)
|
||
.filter(el => String(el.getAttribute('placeholder') || '').includes(expected));
|
||
const elements = nativeElements.length ? nativeElements : customElements;
|
||
const target = elements[occurrence];
|
||
if (!target) return { ok: false, body: (document.body?.innerText || '').slice(0, 1200) };
|
||
target.scrollIntoView({ block: 'center', inline: 'center' });
|
||
const previousValue = target.value;
|
||
const nativePrototype = target instanceof HTMLTextAreaElement ? HTMLTextAreaElement.prototype : target instanceof HTMLInputElement ? HTMLInputElement.prototype : null;
|
||
const setter = Object.getOwnPropertyDescriptor(Object.getPrototypeOf(target), 'value')?.set || (nativePrototype ? Object.getOwnPropertyDescriptor(nativePrototype, 'value')?.set : null);
|
||
if (setter) setter.call(target, nextValue);
|
||
else target.value = nextValue;
|
||
target.setAttribute('value', nextValue);
|
||
if (target._valueTracker) target._valueTracker.setValue(previousValue);
|
||
target.focus();
|
||
const makeInputEvent = type => {
|
||
try {
|
||
return new InputEvent(type, { bubbles: true, cancelable: true, data: nextValue, inputType: 'insertText' });
|
||
} catch {
|
||
return new Event(type, { bubbles: true, cancelable: true });
|
||
}
|
||
};
|
||
for (const event of [
|
||
makeInputEvent('beforeinput'),
|
||
makeInputEvent('input'),
|
||
new CustomEvent('input', { bubbles: true, cancelable: true, detail: { value: nextValue } }),
|
||
new Event('compositionend', { bubbles: true, cancelable: true }),
|
||
new CustomEvent('change', { bubbles: true, cancelable: true, detail: { value: nextValue } }),
|
||
new Event('change', { bubbles: true, cancelable: true }),
|
||
]) {
|
||
target.dispatchEvent(event);
|
||
}
|
||
return { ok: true, placeholder: target.getAttribute('placeholder'), value: target.value };
|
||
})()
|
||
`);
|
||
if (!result?.ok) throw new Error(`Input placeholder not found: ${placeholder}\n${result?.body || ''}`);
|
||
await delay(150);
|
||
return result;
|
||
}
|
||
|
||
async function fillByPlaceholderInSection(page, sectionTitle, placeholder, value, occurrence = 0) {
|
||
const result = await page.evaluate(`
|
||
(() => {
|
||
const sectionTitle = ${JSON.stringify(sectionTitle)};
|
||
const expected = ${JSON.stringify(placeholder)};
|
||
const nextValue = ${JSON.stringify(value)};
|
||
const occurrence = ${Number(occurrence)};
|
||
const visible = el => {
|
||
const style = window.getComputedStyle(el);
|
||
const rect = el.getBoundingClientRect();
|
||
return style.visibility !== 'hidden' && style.display !== 'none' && rect.width > 0 && rect.height > 0;
|
||
};
|
||
const normalized = value => String(value || '').replace(/\\s+/g, ' ').trim();
|
||
const sections = Array.from(document.querySelectorAll('.admin-section,.platform-section,section'))
|
||
.filter(el => visible(el) && normalized(el.innerText || el.textContent || '').includes(sectionTitle));
|
||
const root = sections.sort((a, b) => normalized(a.innerText || a.textContent || '').length - normalized(b.innerText || b.textContent || '').length)[0];
|
||
if (!root) return { ok: false, body: (document.body?.innerText || '').slice(0, 1200) };
|
||
const nativeElements = Array.from(root.querySelectorAll('input, textarea'))
|
||
.filter(visible)
|
||
.filter(el => String(el.getAttribute('placeholder') || '').includes(expected));
|
||
const customElements = Array.from(root.querySelectorAll('taro-input-core, taro-textarea-core'))
|
||
.filter(visible)
|
||
.filter(el => String(el.getAttribute('placeholder') || '').includes(expected));
|
||
const elements = nativeElements.length ? nativeElements : customElements;
|
||
const target = elements[occurrence];
|
||
if (!target) return { ok: false, body: normalized(root.innerText || root.textContent || '').slice(0, 1200) };
|
||
target.scrollIntoView({ block: 'center', inline: 'center' });
|
||
const previousValue = target.value;
|
||
const nativePrototype = target instanceof HTMLTextAreaElement ? HTMLTextAreaElement.prototype : target instanceof HTMLInputElement ? HTMLInputElement.prototype : null;
|
||
const setter = Object.getOwnPropertyDescriptor(Object.getPrototypeOf(target), 'value')?.set || (nativePrototype ? Object.getOwnPropertyDescriptor(nativePrototype, 'value')?.set : null);
|
||
if (setter) setter.call(target, nextValue);
|
||
else target.value = nextValue;
|
||
target.setAttribute('value', nextValue);
|
||
if (target._valueTracker) target._valueTracker.setValue(previousValue);
|
||
target.focus();
|
||
const makeInputEvent = type => {
|
||
try {
|
||
return new InputEvent(type, { bubbles: true, cancelable: true, data: nextValue, inputType: 'insertText' });
|
||
} catch {
|
||
return new Event(type, { bubbles: true, cancelable: true });
|
||
}
|
||
};
|
||
for (const event of [
|
||
makeInputEvent('beforeinput'),
|
||
makeInputEvent('input'),
|
||
new CustomEvent('input', { bubbles: true, cancelable: true, detail: { value: nextValue } }),
|
||
new Event('compositionend', { bubbles: true, cancelable: true }),
|
||
new CustomEvent('change', { bubbles: true, cancelable: true, detail: { value: nextValue } }),
|
||
new Event('change', { bubbles: true, cancelable: true }),
|
||
]) {
|
||
target.dispatchEvent(event);
|
||
}
|
||
return { ok: true, placeholder: target.getAttribute('placeholder'), value: target.value };
|
||
})()
|
||
`);
|
||
if (!result?.ok) throw new Error(`Input placeholder not found in section "${sectionTitle}": ${placeholder}\n${result?.body || ''}`);
|
||
await delay(150);
|
||
return result;
|
||
}
|
||
|
||
async function formDiagnostics(page, placeholder = '') {
|
||
return page.evaluate(`
|
||
(() => {
|
||
const expected = ${JSON.stringify(placeholder)};
|
||
const visible = el => {
|
||
const style = window.getComputedStyle(el);
|
||
const rect = el.getBoundingClientRect();
|
||
return style.visibility !== 'hidden' && style.display !== 'none' && rect.width > 0 && rect.height > 0;
|
||
};
|
||
const normalized = value => String(value || '').replace(/\\s+/g, ' ').trim();
|
||
return {
|
||
path: location.pathname + location.search + location.hash,
|
||
body: normalized(document.body?.innerText || '').slice(0, 1600),
|
||
fields: Array.from(document.querySelectorAll('input, textarea, taro-input-core, taro-textarea-core'))
|
||
.filter(el => !expected || String(el.getAttribute('placeholder') || '').includes(expected))
|
||
.map(el => ({
|
||
tag: el.tagName,
|
||
className: String(el.className || ''),
|
||
placeholder: el.getAttribute('placeholder') || '',
|
||
value: el.value || el.getAttribute('value') || '',
|
||
visible: visible(el),
|
||
})),
|
||
buttons: Array.from(document.querySelectorAll('button,taro-button-core,a,[role="button"],[onclick],[class*="button"],[class*="btn"]'))
|
||
.filter(visible)
|
||
.map(el => ({ tag: el.tagName, className: String(el.className || ''), text: normalized(el.innerText || el.textContent || '').slice(0, 140) }))
|
||
.filter(item => item.text.includes('预览') || item.text.includes('导入') || item.text.includes('题库') || item.text.includes('json'))
|
||
.slice(0, 30),
|
||
};
|
||
})()
|
||
`).catch(error => ({ error: error.message }));
|
||
}
|
||
|
||
async function ensureInputValue(page, placeholder, value, occurrence = 0) {
|
||
const current = await page.evaluate(`
|
||
(() => {
|
||
const expected = ${JSON.stringify(placeholder)};
|
||
const occurrence = ${Number(occurrence)};
|
||
const elements = Array.from(document.querySelectorAll('input, textarea'))
|
||
.filter(el => String(el.getAttribute('placeholder') || '').includes(expected));
|
||
return elements[occurrence]?.value || '';
|
||
})()
|
||
`);
|
||
if (!current) await fillByPlaceholder(page, placeholder, value, occurrence);
|
||
}
|
||
|
||
async function navigateAndExpect(page, baseUrl, path, text, timeoutMs = 10_000) {
|
||
await page.navigate(`${baseUrl}${path}`);
|
||
await waitForPath(page, path.split('?')[0], timeoutMs);
|
||
await waitForText(page, text, timeoutMs);
|
||
}
|
||
|
||
async function runStudentJourney(browser, portal, api) {
|
||
const checks = [];
|
||
const assertStudentRuntimeHealthy = label => assertRuntimeHealthy(page, label, {
|
||
allowedHttp: [
|
||
{ path: 'https://assets.example.test/' },
|
||
{ path: 'https://pay.example.test/' },
|
||
],
|
||
});
|
||
api.setAuthenticated(false);
|
||
const authRequestStart = api.requests.length;
|
||
const loginPage = await newPage(browser, `${portal.baseUrl}/pages/student/login/index`);
|
||
try {
|
||
await waitForText(loginPage, '欢迎回来');
|
||
await waitForText(loginPage, '手机号登录');
|
||
await waitForText(loginPage, '发送验证码');
|
||
await waitUntil('unauthenticated /api/auth/me response', async () => {
|
||
return api.requests
|
||
.slice(authRequestStart)
|
||
.some(item => item.path === '/api/auth/me' && item.method === 'GET' && item.status === 401);
|
||
});
|
||
assertRuntimeHealthy(loginPage, 'Student login flow', {
|
||
allowedHttp: [{ status: 401, path: '/api/auth/me' }],
|
||
});
|
||
checks.push({ id: 'student.login.unauthenticated_401', status: 'pass', detail: '登录页已真实收到 /api/auth/me 401' });
|
||
} finally {
|
||
try {
|
||
await loginPage.close();
|
||
} finally {
|
||
api.setAuthenticated(true);
|
||
}
|
||
}
|
||
const page = await newPage(browser, `${portal.baseUrl}${portal.landingPath}`);
|
||
try {
|
||
await waitForText(page, '今日学习');
|
||
await waitForText(page, '功能导航');
|
||
await assertNoText(page, '排行榜');
|
||
checks.push({ id: 'student.home.rendered', status: 'pass', detail: '首页渲染并未默认展示排行榜' });
|
||
|
||
await clickText(page, '刷题');
|
||
await waitForPath(page, '/pages/student/catalog/index');
|
||
await waitForText(page, '题库练习');
|
||
checks.push({ id: 'student.home.to_catalog', status: 'pass', detail: await currentPath(page) });
|
||
|
||
await clickText(page, '顺序练习');
|
||
await waitForPath(page, '/pages/student/practice/index');
|
||
await waitForText(page, '这是 H5 交互烟测题目');
|
||
checks.push({ id: 'student.catalog.to_practice', status: 'pass', detail: await currentPath(page) });
|
||
|
||
await clickText(page, 'A.');
|
||
await waitForApiRequest(api, '/api/learning/answers', 'POST');
|
||
await waitForText(page, '正确');
|
||
await clickText(page, '收藏');
|
||
await waitForApiRequest(api, '/api/learning/favorites/questions', 'POST');
|
||
checks.push({ id: 'student.practice.answer_favorite', status: 'pass', detail: '答题和收藏 API 已触发' });
|
||
|
||
await page.navigate(`${portal.baseUrl}/pages/student/home/index`);
|
||
await waitForText(page, '个人中心');
|
||
await clickText(page, '个人中心');
|
||
await waitForPath(page, '/pages/student/profile/index');
|
||
await waitForText(page, '会员套餐');
|
||
await clickText(page, '开通会员');
|
||
await waitForPath(page, '/pages/student/checkout/index');
|
||
await waitForText(page, '会员收银台');
|
||
checks.push({ id: 'student.profile.to_checkout', status: 'pass', detail: await currentPath(page) });
|
||
|
||
await clickText(page, '提交订单');
|
||
await waitForApiRequest(api, '/api/commerce/orders', 'POST');
|
||
await waitForApiRequest(api, '/api/commerce/payments/create', 'POST');
|
||
await waitForText(page, '支付参数已生成');
|
||
await waitForText(page, 'SMOKE202607010001');
|
||
await clickText(page, '刷新状态');
|
||
await waitForApiRequest(api, '/api/commerce/orders/status', 'GET');
|
||
checks.push({ id: 'student.checkout.order_payment', status: 'pass', detail: '收银台下单、支付参数生成和状态刷新 API 已触发' });
|
||
assertStudentRuntimeHealthy('Student H5 journey through checkout');
|
||
|
||
await navigateAndExpect(page, portal.baseUrl, '/pages/student/review/index?type=wrong', '错题本');
|
||
await waitForText(page, '开始复习');
|
||
await waitForText(page, '这是 H5 交互烟测题目');
|
||
await clickText(page, '收藏');
|
||
await waitForText(page, '收藏夹');
|
||
await clickText(page, '开始复习');
|
||
await waitForPath(page, '/pages/student/practice/index');
|
||
checks.push({ id: 'student.review.wrong_favorite', status: 'pass', detail: '错题本、收藏夹和后端组卷入口可打开' });
|
||
|
||
await navigateAndExpect(page, portal.baseUrl, '/pages/student/vocabulary/index', '背单词');
|
||
await waitForText(page, '学习概览');
|
||
await waitForText(page, 'analysis');
|
||
await clickText(page, '认识');
|
||
await waitForApiRequest(api, '/api/learning/vocabulary/review', 'POST');
|
||
checks.push({ id: 'student.vocabulary.review', status: 'pass', detail: '背单词计划和复习提交 API 已触发' });
|
||
assertStudentRuntimeHealthy('Student H5 journey through vocabulary');
|
||
|
||
await navigateAndExpect(page, portal.baseUrl, '/pages/student/handbook/index', '知识手册');
|
||
await waitForText(page, '高等数学手册');
|
||
await waitForText(page, '导数定义');
|
||
await waitForText(page, '变化率');
|
||
checks.push({ id: 'student.handbook.rendered', status: 'pass', detail: '手册科目、章节和知识点渲染正常' });
|
||
|
||
await navigateAndExpect(page, portal.baseUrl, '/pages/student/assets/index', '资料下载');
|
||
await waitForText(page, '天津专升本英语真题 PDF');
|
||
await clickText(page, '预览');
|
||
await waitForText(page, '追踪码');
|
||
await waitForApiRequest(api, '/api/catalog/assets/preview', 'GET');
|
||
await clickText(page, '下载');
|
||
await waitForText(page, '确认下载');
|
||
await waitForApiRequest(api, '/api/catalog/assets/download', 'GET');
|
||
checks.push({ id: 'student.assets.signed_watermark', status: 'pass', detail: '资料预览/下载短签名和水印面板可用' });
|
||
assertStudentRuntimeHealthy('Student H5 journey through signed assets');
|
||
|
||
await navigateAndExpect(page, portal.baseUrl, `/pages/student/video/index?questionId=${ids.question}`, '视频解析');
|
||
await waitForText(page, '本题视频解析');
|
||
await clickText(page, '本题视频解析');
|
||
await waitForApiRequest(api, '/api/videos/play', 'POST');
|
||
checks.push({ id: 'student.video.play_auth', status: 'pass', detail: '视频列表和播放授权 API 已触发' });
|
||
|
||
await navigateAndExpect(page, portal.baseUrl, '/pages/student/scoreline/index', '历年分数线');
|
||
await waitForText(page, '天津职业大学');
|
||
await waitForText(page, '最低分');
|
||
checks.push({ id: 'student.scoreline.rendered', status: 'pass', detail: '分数线筛选、趋势和结果渲染正常' });
|
||
|
||
await navigateAndExpect(page, portal.baseUrl, '/pages/student/ai-school/index', 'AI择校推荐');
|
||
await waitForText(page, '推荐结果');
|
||
await waitForText(page, '天津职业大学');
|
||
checks.push({ id: 'student.ai_school.rendered', status: 'pass', detail: 'AI 择校报告列表和推荐结果可渲染' });
|
||
assertStudentRuntimeHealthy('Student H5 journey through scoreline and AI school');
|
||
|
||
await navigateAndExpect(page, portal.baseUrl, '/pages/student/notifications/index', '消息中心');
|
||
await waitForText(page, '入门勋章已发放');
|
||
await clickText(page, '全部已读');
|
||
await waitForApiRequest(api, '/api/profile/notifications/status', 'POST');
|
||
checks.push({ id: 'student.notifications.status', status: 'pass', detail: '消息筛选和批量已读 API 已触发' });
|
||
|
||
assertStudentRuntimeHealthy('Student H5 journey');
|
||
return checks;
|
||
} finally {
|
||
await page.close();
|
||
}
|
||
}
|
||
|
||
async function runTenantJourney(browser, portal, api) {
|
||
const checks = [];
|
||
const page = await newPage(browser, `${portal.baseUrl}${portal.landingPath}`);
|
||
try {
|
||
await waitForText(page, '工学题库商户后台');
|
||
await waitForText(page, '后台模块');
|
||
checks.push({ id: 'tenant.workbench.rendered', status: 'pass', detail: '工作台渲染并展示权限驱动模块' });
|
||
|
||
await clickText(page, '题库内容');
|
||
await waitForPath(page, '/pages/tenant-admin/content/index');
|
||
await waitForText(page, '题库内容');
|
||
checks.push({ id: 'tenant.workbench.to_content', status: 'pass', detail: await currentPath(page) });
|
||
|
||
await fillByPlaceholder(page, '粘贴题目', '[{"title":"smoke","type":"single_choice"}]');
|
||
await clickText(page, '后端预览');
|
||
try {
|
||
await waitForApiRequest(api, '/api/tenant-content/imports/preview/questions', 'POST');
|
||
} catch (error) {
|
||
const diagnostics = await formDiagnostics(page, '粘贴题目');
|
||
const recentRequests = api.requests.slice(-20).map(item => `${item.method} ${item.path}`);
|
||
throw new Error(`${error.message}\nContent import diagnostics: ${JSON.stringify(diagnostics, null, 2)}\nRecent API requests: ${JSON.stringify(recentRequests, null, 2)}`);
|
||
}
|
||
await waitForText(page, '预览第');
|
||
await clickTextAndConfirm(page, '执行导入');
|
||
await waitForApiRequest(api, '/api/tenant-content/imports/questions', 'POST');
|
||
await clickTextAndConfirm(page, '复检任务');
|
||
await waitForApiRequest(api, '/api/tenant-content/imports/post-check', 'POST');
|
||
checks.push({ id: 'tenant.content.import_preview_execute_postcheck', status: 'pass', detail: '内容导入预览、执行和复检 API 已触发' });
|
||
|
||
await clickTextAndConfirm(page, '采纳');
|
||
await waitForApiRequest(api, '/api/tenant-content/public-question-banks/adopt', 'POST');
|
||
await clickTextAndConfirm(page, '同步');
|
||
await waitForApiRequest(api, '/api/tenant-content/public-question-banks/sync', 'POST');
|
||
await waitForText(page, '同步冲突');
|
||
await clickTextAndConfirm(page, '全部保留本地');
|
||
await waitForApiRequest(api, '/api/tenant-content/public-question-banks/conflicts/resolve-batch', 'POST');
|
||
await clickText(page, '标记已读');
|
||
await waitForApiRequest(api, '/api/tenant-content/notifications/status', 'POST');
|
||
checks.push({ id: 'tenant.content.public_bank_adopt_sync_conflict', status: 'pass', detail: '公共题库采纳、同步、冲突处理和通知状态 API 已触发' });
|
||
|
||
await page.navigate(`${portal.baseUrl}${portal.landingPath}`);
|
||
await waitForText(page, '财务运营');
|
||
await clickText(page, '财务运营');
|
||
await waitForPath(page, '/pages/tenant-admin/finance/index');
|
||
await waitForText(page, '财务运营');
|
||
checks.push({ id: 'tenant.workbench.to_finance', status: 'pass', detail: await currentPath(page) });
|
||
|
||
await navigateAndExpect(page, portal.baseUrl, '/pages/tenant-admin/dashboard/index', '数据看板');
|
||
checks.push({ id: 'tenant.workbench.to_dashboard', status: 'pass', detail: await currentPath(page) });
|
||
|
||
await navigateAndExpect(page, portal.baseUrl, '/pages/tenant-admin/students/index', '学生运营');
|
||
await waitForText(page, '测试学生');
|
||
await clickText(page, '详情');
|
||
await fillByPlaceholder(page, '学生备注', 'H5 烟测备注');
|
||
await clickText(page, '保存备注');
|
||
await waitForApiRequest(api, '/api/tenant-admin/students/notes', 'PUT');
|
||
await fillByPlaceholder(page, '标题', 'H5 烟测跟进');
|
||
await clickText(page, '保存跟进');
|
||
await waitForApiRequest(api, '/api/tenant-admin/students/followups', 'PUT');
|
||
await clickText(page, '预览候选');
|
||
await waitForApiRequest(api, '/api/tenant-admin/students/supervision/preview', 'GET');
|
||
await waitForText(page, '近期答题偏少');
|
||
await clickText(page, '生成跟进任务');
|
||
await waitForApiRequest(api, '/api/tenant-admin/students/supervision/generate', 'POST');
|
||
await clickText(page, '保存每日规则');
|
||
await waitForApiRequest(api, '/api/tenant-admin/students/supervision/rules', 'PUT');
|
||
await fillByPlaceholder(page, '跟进标题', 'H5 烟测 CRM 推送');
|
||
await fillByPlaceholder(page, '每行一个学生 userId', ids.user);
|
||
await clickText(page, '入队推送');
|
||
await waitForApiRequest(api, '/api/tenant-admin/students/crm-push', 'POST');
|
||
checks.push({ id: 'tenant.students.operations', status: 'pass', detail: '备注、跟进、督导预览/生成/规则和 CRM 推送 API 已触发' });
|
||
|
||
await navigateAndExpect(page, portal.baseUrl, '/pages/tenant-admin/marketing/index', '营销中心');
|
||
await fillByPlaceholder(page, '任务编码', 'h5-smoke-task');
|
||
await fillByPlaceholder(page, '任务名称', 'H5 烟测积分任务');
|
||
await clickText(page, '保存积分任务');
|
||
await waitForApiRequest(api, '/api/tenant-admin/point-activity-tasks', 'PUT');
|
||
await fillByPlaceholder(page, '兑换编码', 'h5-smoke-gift');
|
||
await fillByPlaceholder(page, '兑换名称', 'H5 烟测兑换商品');
|
||
await clickText(page, '保存兑换商品');
|
||
await waitForApiRequest(api, '/api/tenant-admin/point-exchange-items', 'PUT');
|
||
await fillByPlaceholder(page, '优惠券码', 'SMOKE90');
|
||
await fillByPlaceholder(page, '优惠金额', '9');
|
||
await clickText(page, '保存优惠券');
|
||
await waitForApiRequest(api, '/api/tenant-admin/coupons', 'PUT');
|
||
await clickText(page, '保存 CRM');
|
||
await waitForApiRequest(api, '/api/crm/config', 'PUT');
|
||
await clickText(page, '日志');
|
||
await waitForApiRequest(api, '/api/crm/queue/logs', 'GET');
|
||
await clickTextAndConfirmForApi(page, api, '重试', '/api/crm/queue/action', 'POST');
|
||
await clickText(page, '保存规则');
|
||
await waitForApiRequest(api, '/api/commission/settings', 'PUT');
|
||
await clickTextInSection(page, '成员分佣比例', '测试销售');
|
||
await clickText(page, '保存成员比例');
|
||
await waitForApiRequest(api, '/api/commission/member-rate', 'PUT');
|
||
await clickTextInSection(page, '分佣统计', '测试销售');
|
||
await clickText(page, '生成结算单');
|
||
await waitForApiRequest(api, '/api/commission/settlements/generate', 'POST');
|
||
await clickText(page, '导出 CSV');
|
||
await waitForApiRequest(api, '/api/commission/settlements/export', 'GET');
|
||
checks.push({ id: 'tenant.marketing.operations', status: 'pass', detail: '积分、兑换、优惠券、CRM、分佣规则、成员比例和结算 API 已触发' });
|
||
|
||
await navigateAndExpect(page, portal.baseUrl, '/pages/tenant-admin/settings/index', '租户设置');
|
||
await clickText(page, '保存草稿');
|
||
await waitForApiRequest(api, '/api/tenant-admin/theme/preview', 'POST');
|
||
await waitForText(page, '草稿:专注主题');
|
||
await clickTextAndConfirmForApi(page, api, '发布主题', '/api/tenant-admin/theme/publish', 'POST');
|
||
await clickText(page, '新建模板');
|
||
await fillByPlaceholder(page, '模板名称', 'H5 烟测角色');
|
||
await fillByPlaceholder(page, '模板编码', 'h5-smoke-role');
|
||
await clickText(page, '保存模板');
|
||
await waitForApiRequest(api, '/api/tenant-admin/role-templates', 'PUT');
|
||
await clickText(page, '新建成员');
|
||
await fillByPlaceholderInSection(page, '新建成员权限', '姓名', 'H5 烟测成员');
|
||
await fillByPlaceholderInSection(page, '新建成员权限', '手机号', '13911112222');
|
||
await clickText(page, '保存成员');
|
||
try {
|
||
await waitForApiRequest(api, '/api/tenant-admin/members', 'PUT');
|
||
} catch (error) {
|
||
const diagnostics = await formDiagnostics(page, '姓名');
|
||
const recentRequests = api.requests.slice(-20).map(item => `${item.method} ${item.path}`);
|
||
throw new Error(`${error.message}\nMember diagnostics: ${JSON.stringify(diagnostics, null, 2)}\nRecent API requests: ${JSON.stringify(recentRequests, null, 2)}`);
|
||
}
|
||
checks.push({ id: 'tenant.settings.brand_role_member', status: 'pass', detail: '主题草稿/发布、角色模板和成员绑定 API 已触发' });
|
||
assertRuntimeHealthy(page, 'Tenant admin H5 journey');
|
||
return checks;
|
||
} finally {
|
||
await page.close();
|
||
}
|
||
}
|
||
|
||
async function runPlatformJourney(browser, portal, api) {
|
||
const checks = [];
|
||
const page = await newPage(browser, `${portal.baseUrl}${portal.landingPath}`);
|
||
try {
|
||
await waitForText(page, 'SaaS 平台后台');
|
||
await waitForText(page, '后台模块');
|
||
checks.push({ id: 'platform.workbench.rendered', status: 'pass', detail: '平台工作台渲染' });
|
||
|
||
await clickText(page, '租户管理');
|
||
await waitForPath(page, '/pages/platform-admin/tenants/index');
|
||
await waitForText(page, '租户管理');
|
||
checks.push({ id: 'platform.workbench.to_tenants', status: 'pass', detail: await currentPath(page) });
|
||
|
||
await fillByPlaceholder(page, 'tenant-slug', 'smoke-tenant');
|
||
await fillByPlaceholder(page, '合作商名称', '烟测合作商');
|
||
await clickTextAndConfirmForApi(page, api, '创建租户', '/api/platform-admin/tenants', 'POST');
|
||
await waitForApiRequest(api, '/api/platform-admin/tenants/detail', 'GET');
|
||
await waitForText(page, '租户详情');
|
||
await clickTextInSection(page, '租户列表', '详情');
|
||
await waitForText(page, ids.tenant);
|
||
await clickTextAndConfirmForApi(page, api, '提交状态变更', '/api/platform-admin/tenants/status', 'PATCH');
|
||
await fillByPlaceholder(page, '公司或个人抬头', '烟测合作商有限公司');
|
||
await clickTextAndConfirmForApi(page, api, '保存账务资料', '/api/platform-admin/tenants/billing-profile', 'PUT');
|
||
checks.push({ id: 'platform.tenants.create_status_billing', status: 'pass', detail: '平台租户创建、状态变更和账务资料 API 已触发' });
|
||
|
||
await page.navigate(`${portal.baseUrl}${portal.landingPath}`);
|
||
await waitForText(page, '账务中心');
|
||
await clickText(page, '账务中心');
|
||
await waitForPath(page, '/pages/platform-admin/billing/index');
|
||
await waitForText(page, '账务中心');
|
||
checks.push({ id: 'platform.workbench.to_billing', status: 'pass', detail: await currentPath(page) });
|
||
|
||
await fillByPlaceholderInSection(page, '订阅与账单操作', 'tenantId', ids.tenant, 0);
|
||
await fillByPlaceholderInSection(page, '订阅与账单操作', 'starter_yearly', 'starter_yearly');
|
||
await clickTextAndConfirm(page, '开通订阅');
|
||
await waitForApiRequest(api, '/api/platform-admin/subscriptions', 'POST');
|
||
await fillByPlaceholderInSection(page, '订阅与账单操作', 'tenantId', ids.tenant, 1);
|
||
await clickTextAndConfirm(page, '生成订阅账单');
|
||
try {
|
||
await waitForApiRequest(api, '/api/platform-admin/invoices/from-subscription', 'POST');
|
||
} catch (error) {
|
||
const diagnostics = await formDiagnostics(page, 'tenantId');
|
||
const recentRequests = api.requests.slice(-20).map(item => `${item.method} ${item.path}`);
|
||
throw new Error(`${error.message}\nBilling diagnostics: ${JSON.stringify(diagnostics, null, 2)}\nRecent API requests: ${JSON.stringify(recentRequests, null, 2)}`);
|
||
}
|
||
await fillByPlaceholderInSection(page, '收款与用量', 'tenantId', ids.tenant, 0);
|
||
await fillByPlaceholderInSection(page, '收款与用量', 'invoiceId', ids.invoice);
|
||
await fillByPlaceholderInSection(page, '收款与用量', '元', '1999');
|
||
await clickTextAndConfirmForApi(page, api, '确认收款', '/api/platform-admin/invoices/payments/manual-confirm', 'POST');
|
||
await fillByPlaceholderInSection(page, '收款与用量', 'tenantId', ids.tenant, 1);
|
||
await fillByPlaceholderInSection(page, '收款与用量', '0', '3722');
|
||
await clickTextAndConfirmForApi(page, api, '记录用量', '/api/platform-admin/usage', 'POST');
|
||
await clickText(page, '预览批量');
|
||
await waitForApiRequest(api, '/api/platform-admin/invoices/from-subscriptions-batch', 'POST');
|
||
await clickTextAndConfirmForApi(page, api, '批量生成账单', '/api/platform-admin/invoices/from-subscriptions-batch', 'POST');
|
||
await clickText(page, '预览超额');
|
||
await waitForApiRequest(api, '/api/platform-admin/invoices/from-usage-overage', 'POST');
|
||
await clickTextAndConfirmForApi(page, api, '生成超额账单', '/api/platform-admin/invoices/from-usage-overage', 'POST');
|
||
await clickText(page, '预览逾期');
|
||
await waitForApiRequest(api, '/api/platform-admin/invoices/process-overdue', 'POST');
|
||
await clickTextAndConfirmForApi(page, api, '生成催缴', '/api/platform-admin/invoices/process-overdue', 'POST');
|
||
checks.push({ id: 'platform.billing.operations', status: 'pass', detail: '订阅、账单、收款、用量、批量、超额和催缴 API 已触发' });
|
||
|
||
await navigateAndExpect(page, portal.baseUrl, '/pages/platform-admin/question-banks/index', '公共题库授权');
|
||
checks.push({ id: 'platform.workbench.to_question-banks', status: 'pass', detail: await currentPath(page) });
|
||
await clickText(page, '选择题库');
|
||
await fillByPlaceholder(page, 'starter_yearly,regional_yearly', 'starter_yearly');
|
||
await clickTextAndConfirmForApi(page, api, '保存授权', '/api/platform-admin/question-bank-grants', 'PUT');
|
||
checks.push({ id: 'platform.question_banks.grant', status: 'pass', detail: '公共题库授权保存 API 已触发' });
|
||
|
||
await navigateAndExpect(page, portal.baseUrl, '/pages/platform-admin/staff/index', '平台员工');
|
||
checks.push({ id: 'platform.workbench.to_staff', status: 'pass', detail: await currentPath(page) });
|
||
await clickText(page, '清空表单');
|
||
await fillByPlaceholder(page, 'auth.users.id', 'auth-h5-smoke');
|
||
await fillByPlaceholder(page, 'platform_operator', 'h5_smoke_operator');
|
||
await fillByPlaceholder(page, '员工姓名', 'H5 烟测平台员工');
|
||
await waitForText(page, '员工编辑');
|
||
await clickText(page, '员工编辑');
|
||
await waitForText(page, '当前权限点\n1');
|
||
await clickTextAndConfirmForApi(page, api, '保存员工', '/api/platform-admin/staff', 'PUT');
|
||
await clickTextAndConfirmForApi(page, api, '禁用', '/api/platform-admin/staff/status', 'PATCH');
|
||
checks.push({ id: 'platform.staff.operations', status: 'pass', detail: '平台员工保存和禁用 API 已触发' });
|
||
assertRuntimeHealthy(page, 'Platform admin H5 journey');
|
||
return checks;
|
||
} finally {
|
||
await page.close();
|
||
}
|
||
}
|
||
|
||
async function runCrossPortalRuntimeProbe(browser, staticServers) {
|
||
const student = staticServers.find(item => item.portal === 'student');
|
||
const tenant = staticServers.find(item => item.portal === 'tenant-admin');
|
||
const platform = staticServers.find(item => item.portal === 'platform-admin');
|
||
const page = await newPage(browser, `${student.baseUrl}${student.landingPath}`);
|
||
try {
|
||
await waitForText(page, '今日学习');
|
||
await navigateAndExpect(page, tenant.baseUrl, tenant.landingPath, '工学题库商户后台');
|
||
await page.setViewport(390, 844);
|
||
await navigateAndExpect(page, platform.baseUrl, platform.landingPath, 'SaaS 平台后台');
|
||
assertRuntimeHealthy(page, 'Cross-portal desktop-to-mobile H5 probe');
|
||
return { status: 'pass', portals: ['student', 'tenant-admin', 'platform-admin'], mobileViewport: '390x844' };
|
||
} finally {
|
||
await page.close();
|
||
}
|
||
}
|
||
|
||
async function runTaroInputWatcherRaceProbe(browser, portal) {
|
||
const page = await newPage(browser, `${portal.baseUrl}${portal.landingPath}`);
|
||
try {
|
||
await waitForText(page, 'SaaS 平台后台');
|
||
const result = await page.evaluate(`(async () => {
|
||
await customElements.whenDefined('taro-input-core');
|
||
const element = document.createElement('taro-input-core');
|
||
element.className = 'runtime-input-watcher-race-probe';
|
||
element.value = 'before-mount';
|
||
document.body.appendChild(element);
|
||
if (typeof element.componentOnReady === 'function') await element.componentOnReady();
|
||
await new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)));
|
||
const input = element.querySelector('input');
|
||
const beforeMountValue = input?.value || '';
|
||
element.value = 'after-mount';
|
||
await new Promise(resolve => requestAnimationFrame(resolve));
|
||
const afterMountValue = input?.value || '';
|
||
element.remove();
|
||
return { beforeMountValue, afterMountValue };
|
||
})()`);
|
||
if (result?.beforeMountValue !== 'before-mount' || result?.afterMountValue !== 'after-mount') {
|
||
throw new Error(`Taro Input watcher race probe did not synchronize values: ${JSON.stringify(result)}`);
|
||
}
|
||
assertRuntimeHealthy(page, 'Taro Input watcher pre-mount race probe');
|
||
return { status: 'pass', ...result };
|
||
} finally {
|
||
await page.close();
|
||
}
|
||
}
|
||
|
||
async function runTaroButtonLoadingRaceProbe(browser, portal) {
|
||
const page = await newPage(browser, `${portal.baseUrl}${portal.landingPath}`);
|
||
try {
|
||
await waitForText(page, 'SaaS 平台后台');
|
||
const result = await page.evaluate(`(async () => {
|
||
await customElements.whenDefined('taro-button-core');
|
||
const element = document.createElement('taro-button-core');
|
||
element.className = 'runtime-button-loading-race-probe';
|
||
element.textContent = '运行时按钮探针';
|
||
document.body.appendChild(element);
|
||
if (typeof element.componentOnReady === 'function') await element.componentOnReady();
|
||
await new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)));
|
||
|
||
const loadingNode = element.querySelector('.weui-loading');
|
||
const initialChildCount = element.children.length;
|
||
const initialDisplay = loadingNode ? getComputedStyle(loadingNode).display : '';
|
||
for (let index = 0; index < 200; index += 1) {
|
||
element.loading = index % 2 === 0;
|
||
}
|
||
element.loading = true;
|
||
await new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)));
|
||
const activeNode = element.querySelector('.weui-loading');
|
||
const activeDisplay = activeNode ? getComputedStyle(activeNode).display : '';
|
||
const activeChildCount = element.children.length;
|
||
|
||
element.loading = false;
|
||
await new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)));
|
||
const inactiveNode = element.querySelector('.weui-loading');
|
||
const inactiveDisplay = inactiveNode ? getComputedStyle(inactiveNode).display : '';
|
||
const inactiveChildCount = element.children.length;
|
||
element.remove();
|
||
return {
|
||
loadingNodeStable: Boolean(loadingNode && loadingNode === activeNode && activeNode === inactiveNode),
|
||
childCounts: [initialChildCount, activeChildCount, inactiveChildCount],
|
||
displays: { initial: initialDisplay, active: activeDisplay, inactive: inactiveDisplay },
|
||
};
|
||
})()`);
|
||
if (
|
||
!result?.loadingNodeStable
|
||
|| new Set(result.childCounts || []).size !== 1
|
||
|| result?.displays?.active === 'none'
|
||
|| result?.displays?.inactive !== 'none'
|
||
) {
|
||
throw new Error(`Taro Button loading race probe did not keep a stable loading node: ${JSON.stringify(result)}`);
|
||
}
|
||
assertRuntimeHealthy(page, 'Taro Button loading node race probe');
|
||
return { status: 'pass', toggles: 200, ...result };
|
||
} finally {
|
||
await page.close();
|
||
}
|
||
}
|
||
|
||
function markdownReport(payload) {
|
||
const lines = [
|
||
'# Taro H5 交互烟测报告',
|
||
'',
|
||
`生成时间:${payload.generatedAt}`,
|
||
'',
|
||
`浏览器:${payload.browser.executable}`,
|
||
'',
|
||
`结果:${payload.summary.fail} fail(s),${payload.summary.pass} pass(es)`,
|
||
'',
|
||
'| 检查项 | 状态 | 说明 |',
|
||
'| --- | --- | --- |',
|
||
];
|
||
for (const check of payload.checks) {
|
||
lines.push(`| ${check.id} | ${check.status} | ${String(check.detail || '').replace(/\|/g, '\\|')} |`);
|
||
}
|
||
lines.push('');
|
||
lines.push('说明:该烟测使用本地 mock API 和三套 H5 发布产物,覆盖真实浏览器中的学生、租户后台、平台后台关键入口点击。它不替代真实 Supabase Auth、支付、短信、对象存储和生产 provider 联调。');
|
||
return `${lines.join('\n')}\n`;
|
||
}
|
||
|
||
function writeReport(payload) {
|
||
fs.mkdirSync(outputDir, { recursive: true });
|
||
const stamp = shanghaiTimestampForFile();
|
||
const jsonPath = path.join(outputDir, `taro-h5-interaction-smoke-${stamp}.json`);
|
||
const mdPath = path.join(outputDir, `taro-h5-interaction-smoke-${stamp}.md`);
|
||
fs.writeFileSync(jsonPath, `${JSON.stringify(payload, null, 2)}\n`, 'utf8');
|
||
fs.writeFileSync(mdPath, markdownReport(payload), 'utf8');
|
||
return { jsonPath, mdPath };
|
||
}
|
||
|
||
async function main() {
|
||
const options = parseArgs(process.argv.slice(2));
|
||
const api = await createMockApiServer({ serveOverHttp: options.serveOnly });
|
||
const staticServers = [];
|
||
let browser = null;
|
||
try {
|
||
if (options.serveOnly) portals.forEach(assertServeOnlyPreviewBuild);
|
||
for (const portal of portals) staticServers.push(await createStaticServer(portal, api.baseUrl));
|
||
if (options.serveOnly) {
|
||
const payload = {
|
||
mockApi: api.baseUrl,
|
||
portals: staticServers.map(item => ({
|
||
portal: item.portal,
|
||
url: `${item.baseUrl}${item.landingPath}`,
|
||
})),
|
||
};
|
||
console.log(JSON.stringify(payload, null, 2));
|
||
console.log('[smoke] browser QA servers are ready; press Ctrl+C to stop');
|
||
await waitForShutdownSignal();
|
||
return;
|
||
}
|
||
browser = await startBrowser(options);
|
||
const checks = [];
|
||
checks.push(...await runStudentJourney(browser, staticServers.find(item => item.portal === 'student'), api));
|
||
checks.push(...await runTenantJourney(browser, staticServers.find(item => item.portal === 'tenant-admin'), api));
|
||
checks.push(...await runPlatformJourney(browser, staticServers.find(item => item.portal === 'platform-admin'), api));
|
||
const crossPortal = await runCrossPortalRuntimeProbe(browser, staticServers);
|
||
const inputWatcherRace = await runTaroInputWatcherRaceProbe(
|
||
browser,
|
||
staticServers.find(item => item.portal === 'platform-admin'),
|
||
);
|
||
const buttonLoadingRace = await runTaroButtonLoadingRaceProbe(
|
||
browser,
|
||
staticServers.find(item => item.portal === 'platform-admin'),
|
||
);
|
||
const runtimeHealth = { status: 'pass', crossPortal, inputWatcherRace, buttonLoadingRace };
|
||
|
||
const payload = {
|
||
generatedAt: new Date().toISOString(),
|
||
summary: {
|
||
pass: checks.filter(item => item.status === 'pass').length,
|
||
fail: checks.filter(item => item.status !== 'pass').length,
|
||
},
|
||
browser: {
|
||
executable: browser.executable,
|
||
runtimeHealth,
|
||
},
|
||
staticServers: staticServers.map(item => ({ portal: item.portal, baseUrl: item.baseUrl, landingPath: item.landingPath })),
|
||
mockApi: {
|
||
baseUrl: api.baseUrl,
|
||
requestCount: api.requests.length,
|
||
keyRequests: {
|
||
answers: api.requests.filter(item => item.path === '/api/learning/answers').length,
|
||
favorites: api.requests.filter(item => item.path === '/api/learning/favorites/questions').length,
|
||
ordersCreated: api.requests.filter(item => item.path === '/api/commerce/orders' && item.method === 'POST').length,
|
||
paymentsCreated: api.requests.filter(item => item.path === '/api/commerce/payments/create' && item.method === 'POST').length,
|
||
orderStatus: api.requests.filter(item => item.path === '/api/commerce/orders/status' && item.method === 'GET').length,
|
||
tenantResolve: api.requests.filter(item => item.path === '/api/tenant/resolve').length,
|
||
tenantContentWrites: api.requests.filter(item => item.path.startsWith('/api/tenant-content/') && ['POST', 'PUT', 'PATCH', 'DELETE'].includes(item.method)).length,
|
||
tenantAdminWrites: api.requests.filter(item => item.path.startsWith('/api/tenant-admin/') && ['POST', 'PUT', 'PATCH', 'DELETE'].includes(item.method)).length,
|
||
crmWrites: api.requests.filter(item => item.path.startsWith('/api/crm/') && ['POST', 'PUT', 'PATCH', 'DELETE'].includes(item.method)).length,
|
||
commissionWrites: api.requests.filter(item => item.path.startsWith('/api/commission/') && ['POST', 'PUT', 'PATCH', 'DELETE'].includes(item.method)).length,
|
||
platformAdminWrites: api.requests.filter(item => item.path.startsWith('/api/platform-admin/') && ['POST', 'PUT', 'PATCH', 'DELETE'].includes(item.method)).length,
|
||
},
|
||
},
|
||
checks,
|
||
};
|
||
const files = writeReport(payload);
|
||
payload.artifacts = {
|
||
json: relative(files.jsonPath),
|
||
markdown: relative(files.mdPath),
|
||
};
|
||
|
||
if (options.json) {
|
||
console.log(JSON.stringify(payload, null, 2));
|
||
} else {
|
||
console.log(`Taro H5 interaction smoke: ${payload.summary.fail} fail(s), ${payload.summary.pass} pass(es)`);
|
||
for (const check of checks) console.log(`[${check.status.toUpperCase()}] ${check.id}: ${check.detail}`);
|
||
console.log(`[smoke] wrote ${relative(files.jsonPath)}`);
|
||
console.log(`[smoke] wrote ${relative(files.mdPath)}`);
|
||
}
|
||
if (payload.summary.fail > 0) process.exitCode = 1;
|
||
} catch (error) {
|
||
console.error(error instanceof Error ? error.stack || error.message : String(error));
|
||
process.exitCode = 1;
|
||
} finally {
|
||
if (browser) await browser.close();
|
||
for (const server of staticServers) await server.close();
|
||
await api.close();
|
||
}
|
||
}
|
||
|
||
main();
|