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: '测试学生', primaryRole: 'platform_admin', roles: ['platform_admin', 'tenant_admin'], }, item: { id: ids.user, name: '测试学生', primaryRole: 'platform_admin', roles: ['platform_admin', 'tenant_admin'], }, }; } if (pathname === '/api/catalog/content-entries') { return { items: [{ id: ids.entry, name: '天津专升本题库', entryType: 'question_bank', description: '公共课、专业课和院校分类入口', regionId: ids.region, }], }; } if (pathname === '/api/catalog/banners' || pathname === '/api/catalog/announcements') return { items: [] }; if (pathname === '/api/catalog/regions') return { items: [{ id: ids.region, name: '天津', code: 'tj', isHot: true }] }; if (pathname === '/api/catalog/content-nodes') { return { items: [{ id: ids.node, entryId: ids.entry, name: '文化课', nodeType: 'subject', markerType: '公共课', isLeaf: true }] }; } if (pathname === '/api/catalog/question-collections') { return { items: [{ id: ids.collection, entryId: ids.entry, nodeId: ids.node, name: '英语基础章节', collectionType: 'chapter', questionCount: 1 }] }; } if (pathname === '/api/catalog/practice-blueprints') { return { items: [{ id: ids.blueprint, entryId: ids.entry, nodeId: ids.node, collectionId: ids.collection, name: '顺序练习', mode: 'sequential', questionLimit: 1 }] }; } if (pathname === '/api/catalog/question-collections/questions' || pathname === '/api/catalog/questions') return { items: [question] }; if (pathname === '/api/catalog/svip-plans') { return { items: [{ id: ids.plan, name: 'SVIP 年卡', priceCents: 19900, days: 365, badge: '推荐', regionId: ids.region, desc: '题库、解析和资料权益' }] }; } if (pathname === '/api/profile/me') { return { item: { id: ids.user, userId: ids.user, name: '测试学生', phone: '13800000000', avatarPreset: 'male', score: 120, target: { regionId: ids.region, regionName: '天津', schoolId: ids.school, schoolName: '天津职业大学', majorId: ids.major, majorName: '软件工程' }, membership: { isSvip: true }, stats: { answers: { totalAnswered: 12 } }, }, }; } if (pathname === '/api/profile/score-events') return { items: [] }; if (pathname === '/api/profile/activity-tasks') return { items: [] }; if (pathname === '/api/profile/exchange-items') return { items: [] }; if (pathname === '/api/profile/notifications') { return { items: [{ id: ids.notification, notificationType: 'badge_granted', status: query.get('status') || 'unread', severity: 'success', title: '入门勋章已发放', message: '完成首次练习后,系统已自动发放坚持练习勋章。', actionLabel: '查看个人中心', actionPath: '/pages/student/profile/index', createdAt: new Date().toISOString(), }], summary: { unread: 1, read: 0, archived: 0, dismissed: 0 }, }; } if (pathname === '/api/profile/notifications/status') return { item: { updatedCount: body.notificationIds?.length || 1, status: body.status || 'read' } }; if (pathname === '/api/profile/badges') return { items: [{ id: 'badge-smoke', name: '坚持练习' }] }; if (pathname === '/api/profile/exam-countdowns') return { items: [] }; if (pathname === '/api/commerce/entitlements') return { items: [{ type: 'svip', status: 'active' }], summary: { svip: true } }; if (pathname === '/api/commerce/orders' && method === 'GET') return { items: [] }; if (pathname === '/api/commerce/orders' && method === 'POST') { return { item: { id: ids.order, orderNo: 'SMOKE202607010001', status: 'pending_payment', amountCents: 19900, planId: body.planId || ids.plan } }; } if (pathname === '/api/commerce/payments/create') { return { item: { provider: body.provider || 'alipay', paymentParams: { url: 'https://pay.example.test/smoke' } } }; } if (pathname === '/api/commerce/orders/status') return { item: { orderNo: query.get('orderNo') || 'SMOKE202607010001', status: 'pending_payment' } }; if (pathname === '/api/learning/practice-sessions' && method === 'POST') { return { item: { id: ids.session, mode: body.mode || 'sequential', questionIds: [ids.question], questionCount: 1, durationMinutes: 30, accessMode: 'svip', consumedFreeQuota: 0, }, }; } if (pathname === '/api/learning/practice-sessions/detail') { return { item: { id: ids.session, mode: 'sequential', questionIds: [ids.question], questionCount: 1, status: 'active', questions: [question], answersByQuestion: {}, }, }; } if (pathname === '/api/learning/answers') { return { item: { id: 'answer-smoke', questionId: body.questionId || ids.question, isCorrect: true, selectedOptions: body.selectedOptions || ['0'], answeredAt: new Date().toISOString(), }, }; } if (pathname === '/api/learning/favorites/questions' && method === 'POST') return { ok: true, favorite: body.favorite !== false }; if (pathname === '/api/learning/favorites/questions') return { items: [question] }; if (pathname === '/api/learning/wrong-questions/review-plan') { return { items: [{ questionId: ids.question, content: question.content, type: question.type, typeLabel: question.typeLabel, wrongCount: 2, lastWrongAt: new Date().toISOString(), }], nextAction: { mode: 'wrong_review', questionLimit: 30 }, }; } if (pathname === '/api/learning/wrong-questions/resolve') return { ok: true }; if (pathname === '/api/learning/wrong-questions') return { items: [question] }; if (pathname === '/api/learning/stats') { return { item: { windowDays: 30, answers: { totalAnswered: 12, correctCount: 10, wrongCount: 2, todayAnswered: 3, accuracy: 0.83 }, sessions: { totalSessions: 2, activeSessions: 0, finishedSessions: 2 }, reports: { reportCount: 1, avgAccuracy: 0.83, bestScore: 83 }, wrongBook: { unresolvedWrong: 2, resolvedWrong: 1, totalWrongBook: 3 }, favorites: { favoriteQuestions: 1 }, questionTypes: [], }, }; } if (pathname === '/api/learning/trend') return { items: [{ date: '2026-07-01', answeredCount: 3, correctCount: 2, wrongCount: 1, accuracy: 0.66, sessionCount: 1, reportCount: 1, score: 66, totalScore: 100 }] }; if (pathname === '/api/learning/practice-sessions/history') return { items: [] }; if (pathname === '/api/catalog/vocabulary-units') { return { items: [{ id: ids.vocabularyUnit, name: '英语核心词汇', description: '专升本高频词', wordCount: 1, regionId: ids.region }] }; } if (pathname === '/api/catalog/vocabulary-words') { return { items: [{ id: ids.vocabularyWord, wordId: ids.vocabularyWord, unitId: ids.vocabularyUnit, word: 'analysis', phonetic: "ə'næləsɪs", meaning: '分析', example: 'Careful analysis helps students improve.', exampleTranslation: '细致分析能帮助学生提升。', status: 'new', }], }; } if (pathname === '/api/learning/vocabulary/review-plan') { return { item: { words: [{ id: ids.vocabularyWord, wordId: ids.vocabularyWord, unitId: ids.vocabularyUnit, word: 'analysis', phonetic: "ə'næləsɪs", meaning: '分析', example: 'Careful analysis helps students improve.', exampleTranslation: '细致分析能帮助学生提升。', dueLevel: 'new', }], dueCount: 1, newCount: 1, totalPlanned: 1, }, }; } if (pathname === '/api/learning/vocabulary/stats') { return { item: { totalWords: 1, progressedWords: 0, masteredWords: 0, learningWords: 0, todayReviewed: 0, favoriteWords: 0 } }; } if (pathname === '/api/learning/vocabulary/favorites' && method === 'POST') return { ok: true, favorite: body.favorite !== false }; if (pathname === '/api/learning/vocabulary/favorites') return { items: [] }; if (pathname === '/api/learning/vocabulary/review') return { item: { wordId: body.wordId || ids.vocabularyWord, result: body.result || 'known' } }; if (pathname === '/api/catalog/handbook-subjects') { return { items: [{ id: ids.handbookSubject, name: '高等数学手册', type: 'handbook', description: '章节化知识点' }] }; } if (pathname === '/api/catalog/handbook-chapters') { return { items: [{ id: ids.handbookChapter, subjectId: ids.handbookSubject, name: '函数与极限', description: '基础章节' }] }; } if (pathname === '/api/catalog/handbook-entries') { return { items: [{ id: ids.handbookEntry, chapterId: ids.handbookChapter, title: '导数定义', summary: '导数描述函数在某一点附近的变化率。', content: '设函数 y=f(x),当自变量增量趋近于 0 时,差商的极限称为导数。$f(x)=x^2$ 的导数是 $2x$。', }], }; } if (pathname === '/api/catalog/assets') { return { items: [{ id: ids.asset, title: '天津专升本英语真题 PDF', assetType: 'pdf', categoryLabel: '真题资料', description: '用于验证私有资料短签名、水印和预览入口。', fileName: 'tj-english-paper.pdf', mimeType: 'application/pdf', visibility: 'members', previewStatus: 'ready', }], }; } if (pathname === '/api/catalog/assets/preview') { return { item: { id: ids.asset, title: '天津专升本英语真题 PDF', assetType: 'pdf', fileName: 'tj-english-paper.pdf', mimeType: 'application/pdf', visibility: 'members' }, preview: { url: 'https://assets.example.test/tj-english-paper-preview.pdf?token=smoke', expiresInSec: 300, expiresAt: new Date(Date.now() + 300_000).toISOString(), signatureMode: 'signed' }, watermark: { mode: 'visible_overlay', required: true, text: '工学题库 SMOKE', traceId: 'wm-preview-smoke', position: 'diagonal', opacity: 0.16, repeat: true, renderHint: 'overlay' }, }; } if (pathname === '/api/catalog/assets/download') { return { item: { id: ids.asset, title: '天津专升本英语真题 PDF', assetType: 'pdf', fileName: 'tj-english-paper.pdf', mimeType: 'application/pdf', visibility: 'members' }, download: { url: 'https://assets.example.test/tj-english-paper.pdf?token=smoke', expiresInSec: 300, expiresAt: new Date(Date.now() + 300_000).toISOString(), signatureMode: 'signed' }, watermark: { mode: 'visible_overlay', required: true, text: '工学题库 SMOKE', traceId: 'wm-download-smoke', position: 'diagonal', opacity: 0.16, repeat: true, renderHint: 'overlay' }, }; } if (pathname === '/api/questions/videos') { return { videos: [{ id: ids.video, questionId: query.get('questionId') || ids.question, title: '本题视频解析', description: '后端签名播放烟测视频', duration: 180, accessMode: 'svip', playable: true, }], total: 1, }; } if (pathname === '/api/videos/play') { return { item: { id: body.videoId || ids.video, questionId: body.questionId || ids.question, title: '本题视频解析', duration: 180, accessMode: 'svip' }, playToken: 'video-play-token-smoke', playback: { url: 'https://assets.example.test/video-smoke.mp4?token=smoke', expiresInSec: 300, expiresAt: new Date(Date.now() + 300_000).toISOString(), signatureMode: 'signed' }, watermark: { mode: 'visible_overlay', required: true, text: '工学题库 SMOKE', traceId: 'wm-video-smoke', position: 'diagonal', opacity: 0.16, repeat: true, renderHint: 'overlay' }, access: { accessMode: 'svip', consumedQuota: 0 }, }; } if (pathname === '/api/scoreline/fields') { return { items: [{ id: ids.scorelineField, regionId: ids.region, fieldKey: 'min_score', fieldName: '最低分', fieldType: 'number', unit: '分', isFilter: true, isVisible: true, isTrend: true, sortOrder: 1 }] }; } if (pathname === '/api/scoreline/schools') { return { items: [{ id: ids.school, regionId: ids.region, name: '天津职业大学', shortName: '天职大', isHot: true, order: 1 }] }; } if (pathname === '/api/scoreline/majors') { return { items: [{ id: ids.major, regionId: ids.region, schoolId: ids.school, name: '软件工程', order: 1, hasRestriction: false }] }; } if (pathname === '/api/scoreline/years') return { items: [2026, 2025, 2024] }; if (pathname === '/api/scoreline/trend' || pathname === '/api/scoreline/records') { return { items: [{ id: ids.scorelineRecord, regionId: ids.region, schoolId: ids.school, majorId: ids.major, year: 2025, schoolName: '天津职业大学', majorName: '软件工程', fieldValues: { min_score: 218 }, }], total: 1, }; } if (pathname === '/api/ai/school-recommendations') { return { items: [{ id: ids.aiReport, tenantId: ids.tenant, userId: ids.user, regionId: ids.region, status: 'generated', provider: 'local_rules', promptVersion: 'smoke-v1', inputPayload: { estimatedScore: 210, riskPreference: 'balanced' }, contextPayload: {}, resultPayload: { schemaVersion: 'school-recommendation-report-v1', summary: '建议优先关注天津职业大学软件工程方向。', riskLevel: 'balanced', recommendedSchools: [{ schoolId: ids.school, schoolName: '天津职业大学', majorId: ids.major, majorName: '软件工程', latestYear: 2025, latestScore: 218, averageScore: 214, scoreGap: -8, riskLevel: 'balanced', confidence: 0.76, reason: '与预估分接近,适合作为稳中带冲选择。', tags: ['天津', '工科'], }], actionPlan: ['复盘公共课错题', '补齐英语词汇'], disclaimers: ['烟测报告仅用于页面联调。'], dataCoverage: { scorelines: 1 }, }, generatedAt: new Date().toISOString(), createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), }], }; } if (pathname === '/api/ai/school-recommendations/generate') { return mockApiPayload('/api/ai/school-recommendations', 'GET', query, body); } if (pathname === '/api/ai/school-recommendations/export') { return { item: { reportId: ids.aiReport, format: query.get('format') || 'markdown', fileName: 'school-report-smoke.md', mimeType: 'text/markdown', contentBase64: 'IyBTbW9rZQ==', contentText: '# Smoke', sha256: 'smoke', sizeBytes: 7 } }; } if (pathname === '/api/tenant-admin/dashboard') return { item: tenantDashboard }; if (pathname === '/api/tenant-admin/overview') { return { item: { id: ids.tenant, slug: 'master', status: 'active', brandName: '工学题库商户后台', shortName: '工学', billingStatus: 'normal' } }; } if (pathname === '/api/tenant-admin/theme-templates') return { items: tenantThemeTemplates }; if (pathname === '/api/tenant-admin/theme') return { item: tenantTheme }; if (pathname === '/api/tenant-admin/theme/preview') return { item: { ...tenantTheme, draftTemplateCode: body.templateCode || 'focus', templateName: '专注主题', status: 'draft' } }; if (pathname === '/api/tenant-admin/theme/publish') return { item: { ...tenantTheme, activeTemplateCode: body.templateCode || tenantTheme.draftTemplateCode, templateName: '专注主题', status: 'published' } }; if (pathname === '/api/tenant-admin/permissions') { return { ...tenantPermissionCatalog, current: { role: 'owner', menuPermissions: { dashboard: true, students: true, content: true, marketing: true, commerce: true, settings: true }, effectivePermissions: { '*': true }, }, roleDefaults: { owner: ['*'] }, }; } if (pathname === '/api/tenant-admin/classes') { return { scoped: false, items: [{ id: ids.class, name: '天津冲刺班', code: 'TJ-01', status: 'active', regionName: '天津', studentCount: 32, teacherCount: 2 }] }; } if (pathname === '/api/tenant-admin/students' && method === 'GET') return { scoped: false, items: [tenantStudent] }; if (pathname === '/api/tenant-admin/students' && method === 'PUT') return { item: { ...tenantStudent, ...body, userId: body.userId || ids.user } }; if (pathname === '/api/tenant-admin/students/bulk-upsert') return { total: body.students?.length || 1, successCount: body.students?.length || 1, errorCount: 0, items: body.students || [] }; if (pathname === '/api/tenant-admin/students/status') return { item: { userId: body.userId || ids.user, status: body.status || 'active' } }; if (pathname === '/api/tenant-admin/students/crm-push') return { requestId: 'crm-push-smoke', total: body.studentUserIds?.length || 1, successCount: body.studentUserIds?.length || 1, errorCount: 0 }; if (pathname === '/api/tenant-admin/classes/members/bulk-assign') return { total: body.assignments?.length || 1, successCount: body.assignments?.length || 1, errorCount: 0 }; if (pathname === '/api/tenant-admin/teachers') return { items: [{ userId: ids.teacher, username: 'teacher_smoke', name: '测试教师', phone: '13900000000', status: 'active', classCount: 1 }] }; if (pathname === '/api/tenant-admin/students/notes' && method === 'GET') return { items: [{ id: 'note-smoke', studentUserId: ids.user, noteType: 'learning', content: '烟测备注', visibility: 'tenant_staff', isPinned: false, createdByName: '测试教师', createdAt: new Date().toISOString() }] }; if (pathname === '/api/tenant-admin/students/notes' && method === 'PUT') return { item: { id: 'note-smoke-created', ...body, createdAt: new Date().toISOString() } }; if (pathname === '/api/tenant-admin/students/followups/report') { return { item: { summary: { total: 6, openBacklog: 2, overdueBacklog: 1, done: 3, crmPushTasks: 2, completionRate: 0.5, avgCompleteHours: 8 }, byAssignee: [{ assignedToUserId: ids.teacher, assignedToName: '测试教师', total: 4, open: 1, done: 3, overdue: 1 }], overdueItems: [] } }; } if (pathname === '/api/tenant-admin/students/followups' && method === 'GET') { return { items: [{ id: 'followup-smoke', studentUserId: ids.user, studentName: '测试学生', title: '学习跟进', followupType: 'learning', priority: 'normal', status: 'open', assignedToUserId: ids.teacher, assignedToName: '测试教师' }] }; } if (pathname === '/api/tenant-admin/students/followups' && method === 'PUT') return { item: { id: body.id || 'followup-created-smoke', ...body } }; if (pathname === '/api/tenant-admin/students/supervision/preview') { return { item: { rules: {}, filters: {}, totalCandidates: 1, candidates: [{ studentUserId: ids.user, studentName: '测试学生', title: '学习风险提醒', description: '近期答题偏少', followupType: 'learning', priority: 'high', riskScore: 86, reasons: [{ code: 'low_activity' }] }] } }; } if (pathname === '/api/tenant-admin/students/supervision/rules' && method === 'GET') return { scoped: false, items: [{ id: 'supervision-rule-smoke', name: '全租户 学习督导', status: 'active', className: null, lastResult: { status: 'ok' } }] }; if (pathname === '/api/tenant-admin/students/supervision/rules' && method === 'PUT') return { item: { id: 'supervision-rule-created-smoke', ...body, status: body.status || 'active' } }; if (pathname === '/api/tenant-admin/students/supervision/generate') return { total: body.studentUserIds?.length || 1, successCount: body.studentUserIds?.length || 1, errorCount: 0 }; if (pathname === '/api/tenant-admin/role-templates' && method === 'GET') { return { items: [{ id: ids.roleTemplate, code: 'teacher-ops', name: '教师运营模板', baseRole: 'teacher', status: 'active', sortOrder: 10, permissions: { 'students:read': true }, menuPermissions: { students: true }, modulePermissions: {}, fieldPermissions: {}, dataScope: { mode: 'class' }, isSystem: false }] }; } if (pathname === '/api/tenant-admin/role-templates' && method === 'PUT') return { item: { id: body.id || 'role-template-created-smoke', ...body } }; if (pathname === '/api/tenant-admin/role-templates/disable') return { item: { id: body.roleTemplateId, status: 'disabled' } }; if (pathname === '/api/tenant-admin/members' && method === 'GET') return { items: tenantMembers }; if (pathname === '/api/tenant-admin/members' && method === 'PUT') return { item: { id: body.membershipId || 'member-created-smoke', membershipId: body.membershipId || 'member-created-smoke', userId: body.userId || ids.teacher, ...body } }; if (pathname === '/api/tenant-admin/members/disable') return { item: { id: body.membershipId, status: 'disabled' } }; if (pathname === '/api/tenant-admin/domains') return { items: [{ id: 'domain-smoke', host: 'tiku.example.test', domainType: 'custom', status: 'verified', isPrimary: true }] }; if (pathname === '/api/tenant-admin/payment-accounts') return { items: [{ id: 'payment-smoke', provider: 'wechat_pay', status: 'active', mode: 'tenant_collect' }] }; if (pathname === '/api/tenant-admin/auth-providers') return { items: [{ id: 'auth-wechat-smoke', provider: 'wechat_miniapp', status: 'active' }] }; if (pathname === '/api/tenant-content/content-entries') return { items: [{ id: ids.entry, name: '天津专升本题库', entryType: 'question_bank', visibility: 'tenant', isActive: true, regionName: '天津', status: 'active' }] }; if (pathname === '/api/tenant-content/imports') return { items: [importJob] }; if (pathname === '/api/tenant-content/imports/detail') return { item: importJob, issueSummary: {}, itemStatusSummary: { valid: 1 }, recentIssues: [], importPostCheck: { status: 'passed', issues: [] }, worker: { executionMode: 'sync', attemptCount: 0, maxAttempts: 3 } }; if (pathname === '/api/tenant-content/imports/issues') return { items: [] }; if (pathname === '/api/tenant-content/imports/templates') { const importType = query.get('importType') || 'questions'; const format = query.get('format') || 'json'; return { item: { importType, format, fileName: `${importType}-template.${format}`, mimeType: 'application/json', contentBase64: 'W3sidGl0bGUiOiJzbW9rZSJ9XQ==', contentPreview: '[{"title":"smoke","type":"single_choice"}]', fields: [{ field: 'title', label: '题干', required: true, aliases: ['题干', 'title'] }] } }; } if (pathname === '/api/tenant-content/imports/field-mapping') return { item: { importType: query.get('importType') || 'questions', title: '导入字段', fields: [{ field: 'title', label: '题干', required: true, aliases: ['题干', 'title'] }], requiredFields: ['title'] } }; if (pathname.startsWith('/api/tenant-content/imports/preview/')) return { job: { ...importJob, id: ids.importJob }, items: [{ rowNo: 1, status: 'valid', externalId: 'smoke-question', normalized: { title: 'smoke' }, issues: [] }], issues: [] }; if (/^\/api\/tenant-content\/imports\/(questions|vocabulary|handbook|scoreline|videos)$/.test(pathname)) return { item: { ...importJob, id: ids.importJob, jobId: ids.importJob, status: 'completed', successCount: 1 }, preview: { job: importJob } }; if (pathname === '/api/tenant-content/imports/post-check') return { item: { status: 'passed', issues: [], checkedAt: new Date().toISOString() } }; if (pathname === '/api/tenant-content/public-question-banks') return { items: [publicQuestionBank] }; if (pathname === '/api/tenant-content/public-question-banks/adopt') return { item: { id: ids.publicBankAdoption, grantId: body.grantId || ids.publicBankGrant, status: 'active' } }; if (pathname === '/api/tenant-content/public-question-banks/sync') return { item: { id: body.adoptionId || ids.publicBankAdoption, syncStatus: 'conflict' }, sync: publicBankConflicts }; if (pathname === '/api/tenant-content/public-question-banks/conflicts') return { item: publicBankConflicts }; if (pathname === '/api/tenant-content/public-question-banks/conflicts/resolve') return { item: { adoptionId: body.adoptionId, sourceQuestionId: body.sourceQuestionId, resolution: body.resolution, status: 'resolved' } }; if (pathname === '/api/tenant-content/public-question-banks/conflicts/resolve-batch') return { item: { adoptionId: body.adoptionId, resolvedCount: body.sourceQuestionIds?.length || 1, resolution: body.resolution } }; if (pathname === '/api/tenant-content/notifications') return { items: [{ id: 'content-notification-smoke', notificationType: 'public_bank_conflict', status: 'unread', severity: 'warning', adoptionId: ids.publicBankAdoption, title: '公共题库存在同步冲突', message: '平台公共题库有 1 道题需要处理。', createdAt: new Date().toISOString() }], summary: { unread: 1 } }; if (pathname === '/api/tenant-content/notifications/status') return { item: { updatedCount: body.notificationIds?.length || 1, status: body.status } }; if (pathname === '/api/tenant-admin/code-batches') return { items: [{ id: 'batch-smoke', name: 'SVIP 激活码批次', status: 'active', totalCount: 100, usedCount: 18 }] }; if (pathname === '/api/tenant-admin/activation-codes') return { items: [{ id: 'code-smoke', code: 'SMOKE2026', status: 'unused' }] }; if (pathname === '/api/tenant-admin/coupons' && method === 'GET') return { items: [{ id: ids.coupon, code: 'SMOKE80', status: 'active', campaignName: '暑期活动', discountType: 'fixed', discountValue: 8000, usedCount: 3, maxUses: 100, minOrderAmountCents: 10000, perUserLimit: 1 }] }; if (pathname === '/api/tenant-admin/coupons' && method === 'PUT') return { item: { id: body.id || ids.coupon, ...body, usedCount: 0 } }; if (pathname === '/api/tenant-admin/coupons/redemptions') return { items: [{ id: 'redemption-smoke', couponId: ids.coupon, couponCode: 'SMOKE80', userId: ids.user, userName: '测试学生', status: 'used', discountAppliedCents: 8000, planName: 'SVIP 年卡', orderNo: 'SMOKE202607010001' }] }; if (pathname === '/api/tenant-admin/coupons/report') return { item: { claimCount: 12, usedCount: 3, discountCents: 24000, paidAmountCents: 59700, conversionRate: 0.25, byCampaign: [{ campaignName: '暑期活动', claimCount: 12, usedCount: 3, discountCents: 24000 }] } }; if (pathname === '/api/tenant-admin/point-activity-tasks' && method === 'GET') return { items: [{ id: ids.pointTask, code: 'daily-smoke', title: '每日练习', taskType: 'practice_complete', rewardPoints: 10, claimLimitPerUser: 1, periodType: 'daily', status: 'active', claimCount: 4, claimUserCount: 3 }] }; if (pathname === '/api/tenant-admin/point-activity-tasks' && method === 'PUT') return { item: { id: body.id || ids.pointTask, ...body, claimCount: 0, claimUserCount: 0 } }; if (pathname === '/api/tenant-admin/point-activity-claims') return { items: [{ id: 'claim-smoke', taskTitle: '每日练习', rewardPoints: 10, userName: '测试学生', periodKey: '2026-07-01' }] }; if (pathname === '/api/tenant-admin/point-exchange-items' && method === 'GET') return { items: [{ id: ids.pointExchange, code: 'manual-gift', title: '人工兑换礼品', costPoints: 100, itemType: 'manual', stockRemaining: 20, status: 'active', orderCount: 1 }] }; if (pathname === '/api/tenant-admin/point-exchange-items' && method === 'PUT') return { item: { id: body.id || ids.pointExchange, ...body, stockRemaining: body.stockTotal || null, orderCount: 0 } }; if (pathname === '/api/tenant-admin/point-exchange-orders') return { items: [{ id: 'exchange-order-smoke', itemTitle: '人工兑换礼品', status: 'pending_fulfillment', userName: '测试学生', costPoints: 100, createdAt: new Date().toISOString() }] }; if (pathname === '/api/tenant-admin/points-risk-report') return { item: { summary: { riskScore: 8, suspiciousUserCount: 0, suspiciousEventCount: 0, netPoints: 120 }, suspiciousUsers: [], suspiciousEvents: [], suspiciousTasks: [] } }; if (pathname === '/api/tenant-admin/feedbacks/report') return { item: { summary: { total: 5, pendingBacklog: 1, handledRate: 0.8, rewardPoints: 20, highPriority: 1, resolved: 2, closed: 1, avgHandleHours: 4, resolutionRate: 0.6 }, recentUnhandled: [], topQuestions: [] } }; if (pathname === '/api/tenant-admin/user-notifications') return { items: [{ id: 'user-notification-smoke', userId: ids.user, userName: '测试学生', notificationType: 'badge_granted', status: 'unread', title: '勋章发放', message: '坚持练习勋章已发放', createdAt: new Date().toISOString() }], summary: { unread: 1, archived: 0 } }; if (pathname === '/api/crm/config' && method === 'GET') return { item: { enabled: true, url: 'https://crm.example.test/webhook', secretRef: 'app_private.tenant_secrets:crm:webhook', formName: '刷题题库', examType: '专升本', timeoutSec: 10, delaySec: 60, assignmentMode: 'referrer', assignmentPool: ['sales-user-smoke'], assignmentCursor: 0 } }; if (pathname === '/api/crm/config' && method === 'PUT') return { item: { id: 'crm-config-smoke', ...body } }; if (pathname === '/api/crm/queue') return { items: [{ id: ids.crmQueue, status: 'failed', source: 'student.crm_push', attempts: 3, lastHttpCode: 500, lastError: 'smoke failed webhook', targetUrl: 'https://crm.example.test/webhook', provider: 'webhook', createdAt: new Date().toISOString() }] }; if (pathname === '/api/crm/dead-letters') return { summary: { total: 1, failed: 1, ignored: 0 }, items: [{ id: ids.crmQueue, status: 'failed', source: 'student.crm_push', attempts: 3, lastError: 'smoke failed webhook', deadLetteredAt: new Date().toISOString() }] }; if (pathname === '/api/crm/queue/logs') return { item: { id: query.get('queueId') || ids.crmQueue }, items: [{ id: 'crm-log-smoke', outcome: 'failed', attempt: 3, httpCode: 500, errorMessage: 'smoke failed webhook', createdAt: new Date().toISOString() }] }; if (pathname === '/api/crm/queue/action') return { item: { id: body.queueId || ids.crmQueue, status: body.action === 'ignore' ? 'discarded' : 'pending', lastOperatorAction: body.action } }; if (pathname === '/api/commission/settings' && method === 'GET') return { item: { defaultRate: 0.2, minSettlementCents: 0, settlementCycle: 'monthly' } }; if (pathname === '/api/commission/settings' && method === 'PUT') return { item: { ...body } }; if (pathname === '/api/commission/member-rate') return { item: { userId: body.userId, commissionRate: body.commissionRate } }; if (pathname === '/api/commission/summary') return { item: { sourceCount: 3, grossAmountCents: 59700, commissionAmountCents: 11940, paidUserCount: 3 } }; if (pathname === '/api/referral/conversion-report') return { item: { summary: { leadCount: 10, convertedLeadCount: 3, conversionRate: 0.3, grossAmountCents: 59700, commissionAmountCents: 11940, crmFailedCount: 1, openFollowupCount: 2, avgFirstPayHours: 24 }, funnel: [{ key: 'lead', label: '客资', count: 10 }], byReferrer: [{ referrerUserId: 'sales-user-smoke', name: '测试销售', role: 'sales', leadCount: 10, convertedLeadCount: 3, conversionRate: 0.3, grossAmountCents: 59700, commissionAmountCents: 11940, crmFailedCount: 1, openFollowupCount: 2, overdueFollowupCount: 1, avgFirstPayHours: 24 }], dailyTrend: [] } }; if (pathname === '/api/commission/orders') return { items: [{ referrerUserId: 'sales-user-smoke', referrerName: '测试销售', grossAmountCents: 19900, commissionRate: 0.2, commissionAmountCents: 3980, sourceType: 'referral', sourceId: 'source-smoke' }] }; if (pathname === '/api/commission/settlements' && method === 'GET') return { items: [{ id: ids.settlement, settlementNo: 'SETTLE-SMOKE', referrerUserId: 'sales-user-smoke', referrerName: '测试销售', status: 'pending_review', commissionAmountCents: 3980, periodStart: '2026-07-01', periodEnd: '2026-07-31', sourceCount: 1 }] }; if (pathname === '/api/commission/settlements/generate') return { item: { id: ids.settlement, ...body, settlementNo: 'SETTLE-SMOKE', commissionAmountCents: 3980 } }; if (pathname === '/api/commission/settlements/status') return { item: { id: body.settlementId || ids.settlement, status: body.status } }; if (pathname === '/api/commission/settlements/export') return { item: { filename: 'settlement-smoke.csv', format: 'csv', mimeType: 'text/csv', contentBase64: 'aWQsc3RhdHVzCnNtb2tlLHBhaWQK', rowCount: 1 } }; if (pathname === '/api/commission/settlements/proofs' && method === 'GET') return { items: [{ id: ids.proof, settlementId: query.get('settlementId') || ids.settlement, title: '烟测凭证', status: 'submitted', externalUrl: 'https://assets.example.test/proof.pdf', amountCents: 3980 }] }; if (pathname === '/api/commission/settlements/proofs' && method === 'POST') return { item: { id: ids.proof, status: 'submitted', ...body } }; if (pathname === '/api/commission/settlements/proofs/status') return { item: { id: body.proofId || ids.proof, status: body.status } }; if (pathname.startsWith('/api/tenant-content/') || pathname.startsWith('/api/tenant-admin/') || pathname.startsWith('/api/crm/') || pathname.startsWith('/api/commission/') || pathname.startsWith('/api/referral/')) { return method === 'GET' ? { items: [], item: null, summary: {} } : { ok: true, item: { id: 'tenant-smoke' } }; } if (pathname === '/api/platform-admin/overview') { return { item: { tenants: { total: 6, active: 5 }, billing: { unpaidAmountCents: 360000, paidAmountCents: 1200000 }, subscriptions: { expiringSoon: 1 }, usage: { questions: 74117, students: 3722 } } }; } if (pathname === '/api/platform-admin/permissions') return { item: { primaryRole: 'platform_admin', effective: { '*': true, 'platform:staff:write': true, 'platform:staff:status': true }, catalog: platformPermissionCatalog } }; if (pathname === '/api/platform-admin/plans') return { items: [{ id: ids.plan, code: 'starter_yearly', name: '基础年费', billingCycle: 'yearly', baseAmountCents: 199900, currency: 'CNY', status: 'active' }] }; if (pathname === '/api/platform-admin/tenants' && method === 'GET') return { items: [{ id: ids.tenant, slug: 'master', name: '工学题库', legalName: '工学教育科技有限公司', brandName: '工学题库', status: 'active', billingStatus: 'normal', planCode: 'starter_yearly', subscriptionStatus: 'active', subscriptionExpiresAt: '2027-07-01T00:00:00.000Z', openBalanceCents: 199900 }] }; if (pathname === '/api/platform-admin/tenants' && method === 'POST') return { item: { id: ids.tenant, slug: body.slug || 'smoke-tenant', name: body.name || '烟测租户', brandName: body.brandName || body.name || '烟测租户', status: body.status || 'active', billingStatus: body.billingStatus || 'trial', planCode: body.planCode || 'starter_yearly' } }; if (pathname === '/api/platform-admin/tenants/detail') return { item: { tenant: { id: ids.tenant, slug: 'master', name: '工学题库', legalName: '工学教育科技有限公司', brandName: '工学题库', status: 'active', billingStatus: 'normal', billingName: '工学教育科技有限公司', contactName: '陈老师', contactPhone: '13800000000', invoiceType: 'normal_vat' }, domains: [{ id: 'domain-smoke', host: 'tiku.example.test', status: 'verified', isPrimary: true }], subscriptions: [{ id: 'subscription-smoke', planCode: 'starter_yearly', status: 'active', amountCents: 199900, startsAt: '2026-07-01', expiresAt: '2027-07-01' }], invoices: [{ id: ids.invoice, tenantId: ids.tenant, invoiceNo: 'INV-SMOKE', status: 'unpaid', totalCents: 199900, paidCents: 0, balanceCents: 199900, dueDate: '2026-07-10' }], usage: [{ id: 'usage-smoke', tenantId: ids.tenant, metricKey: 'students', metricValue: 3722, periodStart: '2026-07-01', periodEnd: '2026-07-31' }] } }; if (pathname === '/api/platform-admin/tenants/status') return { item: { id: body.tenantId || ids.tenant, status: body.status || 'active', billingStatus: body.billingStatus || 'normal' } }; if (pathname === '/api/platform-admin/tenants/billing-profile') return { item: { ...body } }; if (pathname === '/api/platform-admin/audit-logs') return { items: [{ id: 'audit-smoke', tenantId: ids.tenant, action: 'tenant.updated', targetType: 'tenant', targetId: ids.tenant, actorName: '平台管理员', createdAt: new Date().toISOString(), details: { source: 'smoke' } }] }; if (pathname === '/api/platform-admin/invoices' && method === 'GET') return { items: [{ id: ids.invoice, tenantId: ids.tenant, tenantSlug: 'master', tenantName: '工学题库', invoiceNo: 'INV-SMOKE', invoiceType: 'subscription', status: 'unpaid', currency: 'CNY', totalCents: 199900, paidCents: 0, balanceCents: 199900, dueDate: '2026-07-10' }] }; if (pathname === '/api/platform-admin/invoices/reminders') return { items: [{ id: 'reminder-smoke', tenantId: ids.tenant, tenantName: '工学题库', invoiceId: ids.invoice, invoiceNo: 'INV-SMOKE', reminderType: 'overdue', channel: 'internal', status: 'pending', reminderLevel: 1, balanceCentsSnapshot: 199900, message: '烟测催缴' }] }; if (pathname === '/api/platform-admin/usage' && method === 'GET') return { items: [{ id: 'usage-smoke', tenantId: ids.tenant, tenantName: '工学题库', metricKey: 'students', metricValue: 3722, periodStart: '2026-07-01', periodEnd: '2026-07-31' }] }; if (pathname === '/api/platform-admin/usage' && method === 'POST') return { item: { id: 'usage-created-smoke', ...body } }; if (pathname === '/api/platform-admin/subscriptions') return { item: { id: 'subscription-created-smoke', ...body } }; if (pathname === '/api/platform-admin/invoices/from-subscription') return { item: { id: ids.invoice, invoiceNo: 'INV-SMOKE', ...body } }; if (pathname === '/api/platform-admin/invoices/subscription-candidates') return { items: [{ tenantId: ids.tenant, tenantSlug: 'master', tenantName: '工学题库', subscriptionId: 'subscription-smoke', planCode: 'starter_yearly', planName: '基础年费', status: 'active', amountCents: 199900, expiresAt: '2026-07-20', hasExistingInvoice: false, wouldCreate: true }] }; if (pathname === '/api/platform-admin/invoices/from-subscriptions-batch') return { item: { dryRun: body.dryRun === true, createdCount: body.dryRun ? 0 : 1, skippedCount: 0, items: [{ tenantId: ids.tenant, subscriptionId: 'subscription-smoke', wouldCreate: true }] } }; if (pathname === '/api/platform-admin/invoices/usage-overage-candidates') return { items: [{ tenantId: ids.tenant, tenantSlug: 'master', tenantName: '工学题库', planCode: 'starter_yearly', planName: '基础年费', periodStart: query.get('periodStart') || '2026-07-01', periodEnd: query.get('periodEnd') || '2026-07-31', hasExistingInvoice: false, wouldCreate: true, totalCents: 8800, items: [{ description: '超额学生数', quantity: 88, unitAmountCents: 100 }] }] }; if (pathname === '/api/platform-admin/invoices/from-usage-overage') return { item: { dryRun: body.dryRun === true, createdCount: body.dryRun ? 0 : 1, skippedCount: 0, totalCents: 8800, items: [{ tenantId: ids.tenant, periodStart: body.periodStart, periodEnd: body.periodEnd, totalCents: 8800, wouldCreate: true }] } }; if (pathname === '/api/platform-admin/invoices/payments/manual-confirm') return { item: { id: 'payment-smoke', invoiceId: body.invoiceId || ids.invoice, amountCents: body.amountCents, status: 'confirmed' } }; if (pathname === '/api/platform-admin/invoices/process-overdue') return { item: { dryRun: body.dryRun === true, processed: 1, markedOverdue: body.dryRun ? 0 : 1, reminderCreated: body.dryRun ? 0 : 1, skippedReminder: 0, items: [{ id: ids.invoice, wouldMarkOverdue: true, wouldCreateReminder: true }] } }; if (pathname === '/api/platform-admin/question-banks') return { items: [{ id: ids.publicBank, tenantId: null, name: '平台公共题库', regionId: ids.region, regionName: '天津', sourceScope: 'platform_public', status: 'active', questionCount: 128 }] }; if (pathname === '/api/platform-admin/question-bank-grants' && method === 'GET') return { items: [{ id: ids.publicBankGrant, sourceQuestionBankId: ids.publicBank, sourceQuestionBankName: '平台公共题库', sourceRegionName: '天津', grantScope: 'all_active_tenants', allowedPlanCodes: ['starter_yearly'], status: 'active' }] }; if (pathname === '/api/platform-admin/question-bank-grants' && method === 'PUT') return { item: { id: body.id || ids.publicBankGrant, ...body } }; if (pathname === '/api/platform-admin/staff' && method === 'GET') return { items: [{ id: ids.platformStaff, name: '平台管理员', username: 'platform_admin', email: 'platform@example.test', phone: '13600000000', primaryRole: 'platform_admin', status: 'active', authUserId: 'auth-smoke', platformPermissions: { '*': true }, createdAt: new Date().toISOString() }] }; if (pathname === '/api/platform-admin/staff' && method === 'PUT') return { item: { id: body.id || ids.platformStaff, ...body, primaryRole: 'platform_admin', platformPermissions: body.platformPermissions || { '*': true } } }; if (pathname === '/api/platform-admin/staff/status') return { item: { id: body.staffId || ids.platformStaff, status: body.status || 'active' } }; if (pathname.startsWith('/api/platform-admin/')) return method === 'GET' ? { items: [], item: null } : { ok: true, item: { id: 'platform-smoke' } }; return method === 'GET' ? { items: [], item: null } : { ok: true, item: { id: 'smoke' } }; } async function createMockApiServer() { 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) { const description = result.exceptionDetails.exception?.description || result.exceptionDetails.text || 'Runtime evaluation failed'; throw new Error(description); } return result.result?.value; } async navigate(url) { await this.send('Page.navigate', { url }); } async mouseClick(x, y) { await this.send('Input.dispatchMouseEvent', { type: 'mouseMoved', x, y, button: 'none' }); await this.send('Input.dispatchMouseEvent', { type: 'mousePressed', x, y, button: 'left', clickCount: 1 }); await this.send('Input.dispatchMouseEvent', { type: 'mouseReleased', x, y, button: 'left', clickCount: 1 }); } 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).catch(() => {}); 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).catch(() => {}); 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).catch(() => {}); await delay(250); await page.acceptDialogs(); } return result; } async function clickTextAndConfirm(page, text) { await clickText(page, text); await delay(250); await page.acceptDialogs(); await clickVisibleTextCandidate(page, ['确认', '发布', '停用', '禁用', '恢复', '重试', '忽略', '已打款']); await page.acceptDialogs(); } async function waitForApiRequest(api, path, method) { await waitUntil(`${method || ''} ${path} request`, async () => { return api.requests.some(item => item.path === path && (!method || item.method === method)); }, 10_000); } async function waitForApiRequestCount(api, path, method, minCount, timeoutMs = 10_000) { await waitUntil(`${method || ''} ${path} request count >= ${minCount}`, async () => { return api.requests.filter(item => item.path === path && (!method || item.method === method)).length >= minCount; }, timeoutMs); } function requestCount(api, path, method) { return api.requests.filter(item => item.path === path && (!method || item.method === method)).length; } async function clickTextAndConfirmForApi(page, api, text, path, method) { const before = requestCount(api, path, method); await clickText(page, text); const confirmTexts = ['确认', '发布', '停用', '禁用', '恢复', '重试', '忽略', '已打款']; for (let attempt = 0; attempt < 8; attempt += 1) { await page.acceptDialogs(); const hasModal = await page.evaluate(` (() => { const visible = el => { const style = window.getComputedStyle(el); const rect = el.getBoundingClientRect(); return style.visibility !== 'hidden' && style.display !== 'none' && rect.width > 0 && rect.height > 0; }; const modalSelector = '.weui-dialog,.weui-half-screen-dialog,.taro-modal__content,[role="dialog"],[aria-modal="true"],[class*="dialog"],[class*="modal__content"]'; return Array.from(document.querySelectorAll(modalSelector)).some(visible); })() `); if (hasModal) await clickVisibleTextCandidate(page, confirmTexts); await page.acceptDialogs(); try { await waitForApiRequestCount(api, path, method, before + 1, 1_200); return; } catch { await delay(250); } } try { await waitForApiRequestCount(api, path, method, before + 1, 2_000); } catch (error) { const diagnostics = await page.evaluate(` (() => { const visible = el => { const style = window.getComputedStyle(el); const rect = el.getBoundingClientRect(); return style.visibility !== 'hidden' && style.display !== 'none' && rect.width > 0 && rect.height > 0; }; const normalized = value => String(value || '').replace(/\\s+/g, ' ').trim(); const modalSelector = '.weui-dialog,.weui-mask,.weui-half-screen-dialog,.taro-modal,[role="dialog"],[aria-modal="true"],[class*="dialog"],[class*="modal"]'; return { body: normalized(document.body?.innerText || '').slice(0, 1500), modals: Array.from(document.querySelectorAll(modalSelector)) .filter(visible) .map(el => ({ tag: el.tagName, className: String(el.className || ''), text: normalized(el.innerText || el.textContent || '').slice(0, 600) })), buttons: Array.from(document.querySelectorAll('button,taro-button-core,a,[role="button"],[onclick],[class*="button"],[class*="btn"]')) .filter(visible) .map(el => ({ tag: el.tagName, className: String(el.className || ''), text: normalized(el.innerText || el.textContent || '').slice(0, 120) })) .filter(item => item.text.includes('发布') || item.text.includes('确认') || item.text.includes('重试') || item.text.includes('停用') || item.text.includes('禁用') || item.text.includes('已打款')) .slice(0, 30) }; })() `).catch(error => ({ error: error.message })); const recentRequests = api.requests.slice(-20).map(item => `${item.method} ${item.path}`); throw new Error(`${error.message}\nConfirm diagnostics: ${JSON.stringify(diagnostics, null, 2)}\nRecent API requests: ${JSON.stringify(recentRequests, null, 2)}`); } } async function fillByPlaceholder(page, placeholder, value, occurrence = 0) { const result = await page.evaluate(` (() => { const expected = ${JSON.stringify(placeholder)}; const nextValue = ${JSON.stringify(value)}; const occurrence = ${Number(occurrence)}; const visible = el => { const style = window.getComputedStyle(el); const rect = el.getBoundingClientRect(); return style.visibility !== 'hidden' && style.display !== 'none' && rect.width > 0 && rect.height > 0; }; const nativeElements = Array.from(document.querySelectorAll('input, textarea')) .filter(visible) .filter(el => String(el.getAttribute('placeholder') || '').includes(expected)); const customElements = Array.from(document.querySelectorAll('taro-input-core, taro-textarea-core')) .filter(visible) .filter(el => String(el.getAttribute('placeholder') || '').includes(expected)); const elements = nativeElements.length ? nativeElements : customElements; const target = elements[occurrence]; if (!target) return { ok: false, body: (document.body?.innerText || '').slice(0, 1200) }; target.scrollIntoView({ block: 'center', inline: 'center' }); const previousValue = target.value; const nativePrototype = target instanceof HTMLTextAreaElement ? HTMLTextAreaElement.prototype : target instanceof HTMLInputElement ? HTMLInputElement.prototype : null; const setter = Object.getOwnPropertyDescriptor(Object.getPrototypeOf(target), 'value')?.set || (nativePrototype ? Object.getOwnPropertyDescriptor(nativePrototype, 'value')?.set : null); if (setter) setter.call(target, nextValue); else target.value = nextValue; target.setAttribute('value', nextValue); if (target._valueTracker) target._valueTracker.setValue(previousValue); target.focus(); const makeInputEvent = type => { try { return new InputEvent(type, { bubbles: true, cancelable: true, data: nextValue, inputType: 'insertText' }); } catch { return new Event(type, { bubbles: true, cancelable: true }); } }; for (const event of [ makeInputEvent('beforeinput'), makeInputEvent('input'), new CustomEvent('input', { bubbles: true, cancelable: true, detail: { value: nextValue } }), new Event('compositionend', { bubbles: true, cancelable: true }), new CustomEvent('change', { bubbles: true, cancelable: true, detail: { value: nextValue } }), new Event('change', { bubbles: true, cancelable: true }), ]) { target.dispatchEvent(event); } return { ok: true, placeholder: target.getAttribute('placeholder'), value: target.value }; })() `); if (!result?.ok) throw new Error(`Input placeholder not found: ${placeholder}\n${result?.body || ''}`); await delay(150); return result; } async function fillByPlaceholderInSection(page, sectionTitle, placeholder, value, occurrence = 0) { const result = await page.evaluate(` (() => { const sectionTitle = ${JSON.stringify(sectionTitle)}; const expected = ${JSON.stringify(placeholder)}; const nextValue = ${JSON.stringify(value)}; const occurrence = ${Number(occurrence)}; const visible = el => { const style = window.getComputedStyle(el); const rect = el.getBoundingClientRect(); return style.visibility !== 'hidden' && style.display !== 'none' && rect.width > 0 && rect.height > 0; }; const normalized = value => String(value || '').replace(/\\s+/g, ' ').trim(); const sections = Array.from(document.querySelectorAll('.admin-section,.platform-section,section')) .filter(el => visible(el) && normalized(el.innerText || el.textContent || '').includes(sectionTitle)); const root = sections.sort((a, b) => normalized(a.innerText || a.textContent || '').length - normalized(b.innerText || b.textContent || '').length)[0]; if (!root) return { ok: false, body: (document.body?.innerText || '').slice(0, 1200) }; const nativeElements = Array.from(root.querySelectorAll('input, textarea')) .filter(visible) .filter(el => String(el.getAttribute('placeholder') || '').includes(expected)); const customElements = Array.from(root.querySelectorAll('taro-input-core, taro-textarea-core')) .filter(visible) .filter(el => String(el.getAttribute('placeholder') || '').includes(expected)); const elements = nativeElements.length ? nativeElements : customElements; const target = elements[occurrence]; if (!target) return { ok: false, body: normalized(root.innerText || root.textContent || '').slice(0, 1200) }; target.scrollIntoView({ block: 'center', inline: 'center' }); const previousValue = target.value; const nativePrototype = target instanceof HTMLTextAreaElement ? HTMLTextAreaElement.prototype : target instanceof HTMLInputElement ? HTMLInputElement.prototype : null; const setter = Object.getOwnPropertyDescriptor(Object.getPrototypeOf(target), 'value')?.set || (nativePrototype ? Object.getOwnPropertyDescriptor(nativePrototype, 'value')?.set : null); if (setter) setter.call(target, nextValue); else target.value = nextValue; target.setAttribute('value', nextValue); if (target._valueTracker) target._valueTracker.setValue(previousValue); target.focus(); const makeInputEvent = type => { try { return new InputEvent(type, { bubbles: true, cancelable: true, data: nextValue, inputType: 'insertText' }); } catch { return new Event(type, { bubbles: true, cancelable: true }); } }; for (const event of [ makeInputEvent('beforeinput'), makeInputEvent('input'), new CustomEvent('input', { bubbles: true, cancelable: true, detail: { value: nextValue } }), new Event('compositionend', { bubbles: true, cancelable: true }), new CustomEvent('change', { bubbles: true, cancelable: true, detail: { value: nextValue } }), new Event('change', { bubbles: true, cancelable: true }), ]) { target.dispatchEvent(event); } return { ok: true, placeholder: target.getAttribute('placeholder'), value: target.value }; })() `); if (!result?.ok) throw new Error(`Input placeholder not found in section "${sectionTitle}": ${placeholder}\n${result?.body || ''}`); await delay(150); return result; } async function formDiagnostics(page, placeholder = '') { return page.evaluate(` (() => { const expected = ${JSON.stringify(placeholder)}; const visible = el => { const style = window.getComputedStyle(el); const rect = el.getBoundingClientRect(); return style.visibility !== 'hidden' && style.display !== 'none' && rect.width > 0 && rect.height > 0; }; const normalized = value => String(value || '').replace(/\\s+/g, ' ').trim(); return { path: location.pathname + location.search + location.hash, body: normalized(document.body?.innerText || '').slice(0, 1600), fields: Array.from(document.querySelectorAll('input, textarea, taro-input-core, taro-textarea-core')) .filter(el => !expected || String(el.getAttribute('placeholder') || '').includes(expected)) .map(el => ({ tag: el.tagName, className: String(el.className || ''), placeholder: el.getAttribute('placeholder') || '', value: el.value || el.getAttribute('value') || '', visible: visible(el), })), buttons: Array.from(document.querySelectorAll('button,taro-button-core,a,[role="button"],[onclick],[class*="button"],[class*="btn"]')) .filter(visible) .map(el => ({ tag: el.tagName, className: String(el.className || ''), text: normalized(el.innerText || el.textContent || '').slice(0, 140) })) .filter(item => item.text.includes('预览') || item.text.includes('导入') || item.text.includes('题库') || item.text.includes('json')) .slice(0, 30), }; })() `).catch(error => ({ error: error.message })); } async function ensureInputValue(page, placeholder, value, occurrence = 0) { const current = await page.evaluate(` (() => { const expected = ${JSON.stringify(placeholder)}; const occurrence = ${Number(occurrence)}; const elements = Array.from(document.querySelectorAll('input, textarea')) .filter(el => String(el.getAttribute('placeholder') || '').includes(expected)); return elements[occurrence]?.value || ''; })() `); if (!current) await fillByPlaceholder(page, placeholder, value, occurrence); } async function navigateAndExpect(page, baseUrl, path, text, timeoutMs = 10_000) { await page.navigate(`${baseUrl}${path}`); await waitForPath(page, path.split('?')[0], timeoutMs); await waitForText(page, text, timeoutMs); } async function runStudentJourney(browser, portal, api) { const checks = []; const 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, '后端预览'); try { await waitForApiRequest(api, '/api/tenant-content/imports/preview/questions', 'POST'); } catch (error) { const diagnostics = await formDiagnostics(page, '粘贴题目'); const recentRequests = api.requests.slice(-20).map(item => `${item.method} ${item.path}`); throw new Error(`${error.message}\nContent import diagnostics: ${JSON.stringify(diagnostics, null, 2)}\nRecent API requests: ${JSON.stringify(recentRequests, null, 2)}`); } await waitForText(page, '预览第'); await clickTextAndConfirm(page, '执行导入'); await waitForApiRequest(api, '/api/tenant-content/imports/questions', 'POST'); await clickTextAndConfirm(page, '复检任务'); await waitForApiRequest(api, '/api/tenant-content/imports/post-check', 'POST'); checks.push({ id: 'tenant.content.import_preview_execute_postcheck', status: 'pass', detail: '内容导入预览、执行和复检 API 已触发' }); await clickTextAndConfirm(page, '采纳'); await waitForApiRequest(api, '/api/tenant-content/public-question-banks/adopt', 'POST'); await clickTextAndConfirm(page, '同步'); await waitForApiRequest(api, '/api/tenant-content/public-question-banks/sync', 'POST'); await waitForText(page, '同步冲突'); await clickTextAndConfirm(page, '全部保留本地'); await waitForApiRequest(api, '/api/tenant-content/public-question-banks/conflicts/resolve-batch', 'POST'); await clickText(page, '标记已读'); await waitForApiRequest(api, '/api/tenant-content/notifications/status', 'POST'); checks.push({ id: 'tenant.content.public_bank_adopt_sync_conflict', status: 'pass', detail: '公共题库采纳、同步、冲突处理和通知状态 API 已触发' }); await page.navigate(`${portal.baseUrl}${portal.landingPath}`); await waitForText(page, '财务运营'); await clickText(page, '财务运营'); await waitForPath(page, '/pages/tenant-admin/finance/index'); await waitForText(page, '财务运营'); checks.push({ id: 'tenant.workbench.to_finance', status: 'pass', detail: await currentPath(page) }); await navigateAndExpect(page, portal.baseUrl, '/pages/tenant-admin/dashboard/index', '数据看板'); checks.push({ id: 'tenant.workbench.to_dashboard', status: 'pass', detail: await currentPath(page) }); await navigateAndExpect(page, portal.baseUrl, '/pages/tenant-admin/students/index', '学生运营'); await waitForText(page, '测试学生'); await clickText(page, '详情'); await fillByPlaceholder(page, '学生备注', 'H5 烟测备注'); await clickText(page, '保存备注'); await waitForApiRequest(api, '/api/tenant-admin/students/notes', 'PUT'); await fillByPlaceholder(page, '标题', 'H5 烟测跟进'); await clickText(page, '保存跟进'); await waitForApiRequest(api, '/api/tenant-admin/students/followups', 'PUT'); await clickText(page, '预览候选'); await waitForApiRequest(api, '/api/tenant-admin/students/supervision/preview', 'GET'); await waitForText(page, '近期答题偏少'); await clickText(page, '生成跟进任务'); await waitForApiRequest(api, '/api/tenant-admin/students/supervision/generate', 'POST'); await clickText(page, '保存每日规则'); await waitForApiRequest(api, '/api/tenant-admin/students/supervision/rules', 'PUT'); await fillByPlaceholder(page, '跟进标题', 'H5 烟测 CRM 推送'); await fillByPlaceholder(page, '每行一个学生 userId', ids.user); await clickText(page, '入队推送'); await waitForApiRequest(api, '/api/tenant-admin/students/crm-push', 'POST'); checks.push({ id: 'tenant.students.operations', status: 'pass', detail: '备注、跟进、督导预览/生成/规则和 CRM 推送 API 已触发' }); await navigateAndExpect(page, portal.baseUrl, '/pages/tenant-admin/marketing/index', '营销中心'); await fillByPlaceholder(page, '任务编码', 'h5-smoke-task'); await fillByPlaceholder(page, '任务名称', 'H5 烟测积分任务'); await clickText(page, '保存积分任务'); await waitForApiRequest(api, '/api/tenant-admin/point-activity-tasks', 'PUT'); await fillByPlaceholder(page, '兑换编码', 'h5-smoke-gift'); await fillByPlaceholder(page, '兑换名称', 'H5 烟测兑换商品'); await clickText(page, '保存兑换商品'); await waitForApiRequest(api, '/api/tenant-admin/point-exchange-items', 'PUT'); await fillByPlaceholder(page, '优惠券码', 'SMOKE90'); await fillByPlaceholder(page, '优惠金额', '9'); await clickText(page, '保存优惠券'); await waitForApiRequest(api, '/api/tenant-admin/coupons', 'PUT'); await clickText(page, '保存 CRM'); await waitForApiRequest(api, '/api/crm/config', 'PUT'); await clickText(page, '日志'); await waitForApiRequest(api, '/api/crm/queue/logs', 'GET'); await clickTextAndConfirmForApi(page, api, '重试', '/api/crm/queue/action', 'POST'); await clickText(page, '保存规则'); await waitForApiRequest(api, '/api/commission/settings', 'PUT'); await clickTextInSection(page, '成员分佣比例', '测试销售'); await clickText(page, '保存成员比例'); await waitForApiRequest(api, '/api/commission/member-rate', 'PUT'); await clickTextInSection(page, '分佣统计', '测试销售'); await clickText(page, '生成结算单'); await waitForApiRequest(api, '/api/commission/settlements/generate', 'POST'); await clickText(page, '导出 CSV'); await waitForApiRequest(api, '/api/commission/settlements/export', 'GET'); checks.push({ id: 'tenant.marketing.operations', status: 'pass', detail: '积分、兑换、优惠券、CRM、分佣规则、成员比例和结算 API 已触发' }); await navigateAndExpect(page, portal.baseUrl, '/pages/tenant-admin/settings/index', '租户设置'); await clickText(page, '保存草稿'); await waitForApiRequest(api, '/api/tenant-admin/theme/preview', 'POST'); await waitForText(page, '草稿:专注主题'); await clickTextAndConfirmForApi(page, api, '发布主题', '/api/tenant-admin/theme/publish', 'POST'); await clickText(page, '新建模板'); await fillByPlaceholder(page, '模板名称', 'H5 烟测角色'); await fillByPlaceholder(page, '模板编码', 'h5-smoke-role'); await clickText(page, '保存模板'); await waitForApiRequest(api, '/api/tenant-admin/role-templates', 'PUT'); await clickText(page, '新建成员'); await fillByPlaceholderInSection(page, '新建成员权限', '姓名', 'H5 烟测成员'); await fillByPlaceholderInSection(page, '新建成员权限', '手机号', '13911112222'); await clickText(page, '保存成员'); try { await waitForApiRequest(api, '/api/tenant-admin/members', 'PUT'); } catch (error) { const diagnostics = await formDiagnostics(page, '姓名'); const recentRequests = api.requests.slice(-20).map(item => `${item.method} ${item.path}`); throw new Error(`${error.message}\nMember diagnostics: ${JSON.stringify(diagnostics, null, 2)}\nRecent API requests: ${JSON.stringify(recentRequests, null, 2)}`); } checks.push({ id: 'tenant.settings.brand_role_member', status: 'pass', detail: '主题草稿/发布、角色模板和成员绑定 API 已触发' }); 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 fillByPlaceholderInSection(page, '订阅与账单操作', 'tenantId', ids.tenant, 0); await fillByPlaceholderInSection(page, '订阅与账单操作', 'starter_yearly', 'starter_yearly'); await clickTextAndConfirm(page, '开通订阅'); await waitForApiRequest(api, '/api/platform-admin/subscriptions', 'POST'); await fillByPlaceholderInSection(page, '订阅与账单操作', 'tenantId', ids.tenant, 1); await clickTextAndConfirm(page, '生成订阅账单'); try { await waitForApiRequest(api, '/api/platform-admin/invoices/from-subscription', 'POST'); } catch (error) { const diagnostics = await formDiagnostics(page, 'tenantId'); const recentRequests = api.requests.slice(-20).map(item => `${item.method} ${item.path}`); throw new Error(`${error.message}\nBilling diagnostics: ${JSON.stringify(diagnostics, null, 2)}\nRecent API requests: ${JSON.stringify(recentRequests, null, 2)}`); } await fillByPlaceholderInSection(page, '收款与用量', 'tenantId', ids.tenant, 0); await fillByPlaceholderInSection(page, '收款与用量', 'invoiceId', ids.invoice); await fillByPlaceholderInSection(page, '收款与用量', '元', '1999'); await clickTextAndConfirmForApi(page, api, '确认收款', '/api/platform-admin/invoices/payments/manual-confirm', 'POST'); await fillByPlaceholderInSection(page, '收款与用量', 'tenantId', ids.tenant, 1); await fillByPlaceholderInSection(page, '收款与用量', '0', '3722'); await clickTextAndConfirmForApi(page, api, '记录用量', '/api/platform-admin/usage', 'POST'); await clickText(page, '预览批量'); await waitForApiRequest(api, '/api/platform-admin/invoices/from-subscriptions-batch', 'POST'); await clickTextAndConfirmForApi(page, api, '批量生成账单', '/api/platform-admin/invoices/from-subscriptions-batch', 'POST'); await clickText(page, '预览超额'); await waitForApiRequest(api, '/api/platform-admin/invoices/from-usage-overage', 'POST'); await clickTextAndConfirmForApi(page, api, '生成超额账单', '/api/platform-admin/invoices/from-usage-overage', 'POST'); await clickText(page, '预览逾期'); await waitForApiRequest(api, '/api/platform-admin/invoices/process-overdue', 'POST'); await clickTextAndConfirmForApi(page, api, '生成催缴', '/api/platform-admin/invoices/process-overdue', 'POST'); checks.push({ id: 'platform.billing.operations', status: 'pass', detail: '订阅、账单、收款、用量、批量、超额和催缴 API 已触发' }); await navigateAndExpect(page, portal.baseUrl, '/pages/platform-admin/question-banks/index', '公共题库授权'); checks.push({ id: 'platform.workbench.to_question-banks', status: 'pass', detail: await currentPath(page) }); await clickText(page, '选择题库'); await fillByPlaceholder(page, 'starter_yearly,regional_yearly', 'starter_yearly'); await clickTextAndConfirmForApi(page, api, '保存授权', '/api/platform-admin/question-bank-grants', 'PUT'); checks.push({ id: 'platform.question_banks.grant', status: 'pass', detail: '公共题库授权保存 API 已触发' }); await navigateAndExpect(page, portal.baseUrl, '/pages/platform-admin/staff/index', '平台员工'); checks.push({ id: 'platform.workbench.to_staff', status: 'pass', detail: await currentPath(page) }); await clickText(page, '清空表单'); await fillByPlaceholder(page, 'auth.users.id', 'auth-h5-smoke'); await fillByPlaceholder(page, 'platform_operator', 'h5_smoke_operator'); await fillByPlaceholder(page, '员工姓名', 'H5 烟测平台员工'); await waitForText(page, '员工编辑'); await clickText(page, '员工编辑'); await waitForText(page, '当前权限点\n1'); await clickTextAndConfirmForApi(page, api, '保存员工', '/api/platform-admin/staff', 'PUT'); await clickTextAndConfirmForApi(page, api, '禁用', '/api/platform-admin/staff/status', 'PATCH'); checks.push({ id: 'platform.staff.operations', status: 'pass', detail: '平台员工保存和禁用 API 已触发' }); 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();