Files
gongxue-base/scripts/taro-h5-interaction-smoke.js

2005 lines
106 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { spawn } from 'node:child_process';
import fs from 'node:fs';
import http from 'node:http';
import net from 'node:net';
import os from 'node:os';
import path from 'node:path';
import { setTimeout as delay } from 'node:timers/promises';
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 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'),
};
}
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: '测试学生' }, item: { id: ids.user, name: '测试学生' } };
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() {
const requests = [];
const server = http.createServer(async (req, res) => {
const url = new URL(req.url || '/', 'http://127.0.0.1');
if (req.method === 'OPTIONS') {
jsonResponse(res, 204, {});
return;
}
const body = await requestBody(req);
requests.push({
method: req.method || 'GET',
path: url.pathname,
query: Object.fromEntries(url.searchParams.entries()),
body,
});
try {
jsonResponse(res, 200, mockApiPayload(url.pathname, req.method || 'GET', url.searchParams, body));
} catch (error) {
jsonResponse(res, 500, { code: 'MOCK_API_ERROR', message: error instanceof Error ? error.message : String(error) });
}
});
const port = await listen(server);
return {
baseUrl: `http://127.0.0.1:${port}`,
requests,
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: 'master',
};
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;
}
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 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',
'--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) {
this.wsUrl = wsUrl;
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) {
throw new Error(result.exceptionDetails.text || 'Runtime evaluation failed');
}
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 });
}
close() {
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') {
return {
method: event.method,
text: event.params?.exceptionDetails?.text,
description: event.params?.exceptionDetails?.exception?.description,
};
}
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);
}
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(url)}`, { method: 'PUT' });
if (!response.ok) throw new Error(`Failed to create browser tab: ${response.status}`);
const target = await response.json();
return new CdpPage(target.webSocketDebuggerUrl).connect();
}
async function waitUntil(label, fn, timeoutMs = 10_000) {
const started = Date.now();
let lastValue;
while (Date.now() - started < timeoutMs) {
lastValue = await fn().catch(error => ({ error: error.message }));
if (lastValue) return lastValue;
await delay(200);
}
throw new Error(`Timed out waiting for ${label}. Last value: ${JSON.stringify(lastValue)}`);
}
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) {
await waitUntil(`path "${pathPart}"`, async () => {
const pathValue = await currentPath(page);
return pathValue.includes(pathPart);
}, timeoutMs);
}
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;
for (const type of ['pointerdown', 'mousedown', 'pointerup', 'mouseup', 'click']) {
target.dispatchEvent(new MouseEvent(type, { bubbles: true, cancelable: true, view: window, clientX: x, clientY: y }));
}
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 || ''}`);
if (Number.isFinite(result.x) && Number.isFinite(result.y)) await page.mouseClick(result.x, result.y);
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;
for (const type of ['pointerdown', 'mousedown', 'pointerup', 'mouseup', 'click']) {
target.dispatchEvent(new MouseEvent(type, { bubbles: true, cancelable: true, view: window, clientX: x, clientY: y }));
}
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 || ''}`);
if (Number.isFinite(result.x) && Number.isFinite(result.y)) await page.mouseClick(result.x, result.y);
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;
for (const type of ['pointerdown', 'mousedown', 'pointerup', 'mouseup', 'click']) {
target.dispatchEvent(new MouseEvent(type, { bubbles: true, cancelable: true, view: window, clientX: x, clientY: y }));
}
target.click();
return { ok: true, text: textOf(target).slice(0, 120), x, y };
})()
`);
if (result?.ok) {
if (Number.isFinite(result.x) && Number.isFinite(result.y)) await page.mouseClick(result.x, result.y);
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 elements = Array.from(document.querySelectorAll('input, textarea'))
.filter(el => String(el.getAttribute('placeholder') || '').includes(expected));
const target = elements[occurrence];
if (!target) return { ok: false, body: (document.body?.innerText || '').slice(0, 1200) };
target.scrollIntoView({ block: 'center', inline: 'center' });
const setter = Object.getOwnPropertyDescriptor(Object.getPrototypeOf(target), 'value')?.set;
if (setter) setter.call(target, nextValue);
else target.value = nextValue;
for (const type of ['input', 'change']) {
target.dispatchEvent(new Event(type, { bubbles: true, cancelable: true }));
}
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 elements = Array.from(root.querySelectorAll('input, textarea'))
.filter(el => String(el.getAttribute('placeholder') || '').includes(expected));
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 setter = Object.getOwnPropertyDescriptor(Object.getPrototypeOf(target), 'value')?.set;
if (setter) setter.call(target, nextValue);
else target.value = nextValue;
for (const type of ['input', 'change']) {
target.dispatchEvent(new Event(type, { bubbles: true, cancelable: true }));
}
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 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 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 已触发' });
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 已触发' });
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: '资料预览/下载短签名和水印面板可用' });
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 择校报告列表和推荐结果可渲染' });
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 已触发' });
return checks;
} finally {
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, '后端预览');
await waitForApiRequest(api, '/api/tenant-content/imports/preview/questions', 'POST');
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 fillByPlaceholder(page, '姓名', 'H5 烟测成员', 1);
await fillByPlaceholder(page, '手机号', '13911112222', 1);
await clickText(page, '保存成员');
await waitForApiRequest(api, '/api/tenant-admin/members', 'PUT');
checks.push({ id: 'tenant.settings.brand_role_member', status: 'pass', detail: '主题草稿/发布、角色模板和成员绑定 API 已触发' });
return checks;
} finally {
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 ensureInputValue(page, 'tenantId', ids.tenant, 0);
await ensureInputValue(page, 'starter_yearly', 'starter_yearly');
await clickTextAndConfirm(page, '开通订阅');
await waitForApiRequest(api, '/api/platform-admin/subscriptions', 'POST');
await ensureInputValue(page, 'tenantId', ids.tenant, 1);
await clickTextAndConfirm(page, '生成订阅账单');
await waitForApiRequest(api, '/api/platform-admin/invoices/from-subscription', 'POST');
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 已触发' });
return checks;
} finally {
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();
const staticServers = [];
let browser = null;
try {
for (const portal of portals) staticServers.push(await createStaticServer(portal, api.baseUrl));
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 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,
},
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();