$ErrorActionPreference = 'Stop' $root = Split-Path -Parent $PSScriptRoot $contracts = [ordered]@{ 'pages/notification/n01-message-center.vue' = @('listNotificationFixtures', 'notices', 'markAllRead', 'openNotice', 'openPage(', '"N02",', '"G10",') 'pages/notification/n02-message-detail.vue' = @('findNotificationFixture', 'noticeDetail', 'markAsRead', 'openNoticeTarget', 'targetType', 'targetParams', 'notice-state--expired', 'notice-state--ready') 'pages/profile/m01-profile-home.vue' = @('menuItems', 'routeKey', 'openPage(', '"M02"', '"N01"') 'pages/profile/m02-edit-profile.vue' = @('profileForm', 'chooseAvatar', 'validateProfile', 'saveProfile', 'profile-state--saving') 'pages/profile/m03-security-settings.vue' = @('securityItems', 'openSecurityItem', 'device-state--limited', 'checkSecurity', 'routeKey: "M04"', 'routeKey: "M05"') 'pages/profile/m04-change-password.vue' = @('passwordForm', 'validatePassword', 'togglePassword', 'savePassword', 'password-state--saving') 'pages/profile/m05-change-phone.vue' = @('phoneForm', 'sendCode', 'savePhone', 'phone-state--saving') 'pages/profile/m06-help-center.vue' = @('helpCategories', 'filteredQuestions', 'toggleQuestion', 'contactSupport', 'openPage("M07", {}, "M06")') 'pages/profile/m07-feedback.vue' = @('feedbackForm', 'feedbackTypes', 'validateFeedback', 'submitFeedback', 'feedback-state--submitting') 'pages/profile/m08-promotion.vue' = @('inviteState', 'openInviteExplanation', 'share-state--unavailable', 'explanationVisible') 'pages/profile/m09-vip-orders.vue' = @('serviceBenefits', 'orderState', 'order-state--unavailable', 'openServiceNotice') 'pages/profile/m10-about-settings.vue' = @('agreementItems', 'openAgreement', 'confirmLogout', 'appVersion', 'logoutVisible') } foreach ($entry in $contracts.GetEnumerator()) { $source = Get-Content -LiteralPath (Join-Path $root $entry.Key) -Raw -Encoding UTF8 if ($source -match ')|import\s+ModulePage\s+from') { throw "$($entry.Key) must not consume ModulePage" } foreach ($forbidden in @('uni.showToast', 'uni.showModal', 'uni.showLoading', 'uni.showActionSheet', 'uni.navigateTo', 'uni.navigateBack', 'uni.redirectTo', 'uni.reLaunch', 'getCurrentPages(', '/pages/')) { if ($source.Contains($forbidden)) { throw "$($entry.Key) contains forbidden token: $forbidden" } } if ($entry.Key -ne 'pages/profile/m07-feedback.vue' -and $source.Contains('@/utils/api.js')) { throw "$($entry.Key) must not consume the API before its independent interface batch" } foreach ($required in @('ModulePageBackground', 'PageHeader', 'AppButton') + $entry.Value) { if (-not $source.Contains($required)) { throw "$($entry.Key) missing business token: $required" } } if ($source -notmatch 'min-height:\s*100vh') { throw "$($entry.Key) must use natural page height" } } $sources = @{} foreach ($path in $contracts.Keys) { $sources[$path] = Get-Content -LiteralPath (Join-Path $root $path) -Raw -Encoding UTF8 } $n01 = $sources['pages/notification/n01-message-center.vue'] $n02 = $sources['pages/notification/n02-message-detail.vue'] $m02 = $sources['pages/profile/m02-edit-profile.vue'] $m03 = $sources['pages/profile/m03-security-settings.vue'] $m08 = $sources['pages/profile/m08-promotion.vue'] $m10 = $sources['pages/profile/m10-about-settings.vue'] if ($n01 -notmatch '(?s)openPage\(\s*"N02",\s*\{ id: String\(item\.id\) \},\s*"N01",?\s*\)') { throw 'N01 must open N02 with only its registered lexical notice identity' } if ($n01 -notmatch '(?s)genealogyId\.value\s*\?\s*openPage\(\s*"G10",\s*\{ genealogyId: genealogyId\.value \},\s*"N01",?\s*\)') { throw 'N01 review entry must fail closed without genealogyId and use the gateway' } foreach ($source in @($n01, $n02)) { foreach ($forbiddenTarget in @('detailId', 'target: "/pages/', 'targetUrl', 'returnUrl', 'const noticeDetails')) { if ($source.Contains($forbiddenTarget)) { throw "Notification pages retain a duplicate identity, owner, or raw target: $forbiddenTarget" } } } if ($n02 -match 'noticeDetail\.value\.target(?![A-Za-z0-9_$])') { throw 'N02 retains an executable target field' } if ($n02.Contains('noticeDetail.read') -or -not $n02.Contains('noticeDetail.unread')) { throw 'N02 must consume the notification owner unread field instead of creating a duplicate read field' } if ($n02 -notmatch '(?s)const selectedNotice = findNotificationFixture\(noticeId\.value\);.*?if \(.*?!selectedNotice.*?\) \{.*?noticeState\.value = "expired";.*?return;') { throw 'N02 unknown notice identity must fail closed instead of reusing a default notice' } foreach ($required in @( 'getGenealogyFixtureAccess', 'targetError', 'navigateNoticeTarget(', 'noticeDetail.value.targetType', 'noticeDetail.value.targetParams' )) { if (-not $n02.Contains($required)) { throw "N02 safe target flow missing: $required" } } foreach ($formPath in @( 'pages/profile/m02-edit-profile.vue', 'pages/profile/m04-change-password.vue', 'pages/profile/m05-change-phone.vue', 'pages/profile/m07-feedback.vue' )) { $source = $sources[$formPath] foreach ($required in @( 'custom-back', '@back="requestBack"', 'createDiscardConfirmation', 'const isDirty = computed(', 'runBackGuard({', 'dirty: isDirty.value', 'submitting:', 'onBackPress((event) => handleBackPress(event, requestBack));', 'discardConfirmation.dispose();', ':close-on-mask="false"' )) { if (-not $source.Contains($required)) { throw "$formPath back-guard contract missing: $required" } } } foreach ($previewContract in @( @{ Path = 'pages/profile/m02-edit-profile.vue'; Retired = @('个人资料已保存') }, @{ Path = 'pages/profile/m04-change-password.vue'; Retired = @('密码已修改', 'passwordForm.current = ""', 'passwordForm.next = ""', 'passwordForm.confirm = ""') }, @{ Path = 'pages/profile/m05-change-phone.vue'; Retired = @('演示验证码已发送', '绑定手机号已更新', 'phoneForm.currentPhone =') } )) { $source = $sources[$previewContract.Path] if (-not $source.Contains('尚未提交服务器')) { throw "$($previewContract.Path) must identify local validation as unsaved" } foreach ($retired in $previewContract.Retired) { if ($source.Contains($retired)) { throw "$($previewContract.Path) retains fake server success: $retired" } } } foreach ($dialogPath in @( 'pages/profile/m08-promotion.vue', 'pages/profile/m09-vip-orders.vue', 'pages/profile/m10-about-settings.vue' )) { $source = $sources[$dialogPath] foreach ($required in @( 'custom-back', '@back="requestBack"', 'runBackGuard({', 'transientOpen:', 'onBackPress((event) => handleBackPress(event, requestBack));', ':close-on-mask="false"' )) { if (-not $source.Contains($required)) { throw "$dialogPath transient back-guard contract missing: $required" } } } foreach ($retiredInviteCopy in @('仍需管理员审核才能加入', '家人提交加入申请', '管理员核实并审核', 'inviteCode', 'setClipboardData', 'posterVisible', 'generatePoster')) { if ($m08.Contains($retiredInviteCopy)) { throw "M08 retains retired invitation review semantics: $retiredInviteCopy" } } foreach ($directInviteCopy in @('邀请码校验成功后直接加入', '不会生成入谱审核记录')) { if (-not $m08.Contains($directInviteCopy)) { throw "M08 direct-join contract missing: $directInviteCopy" } } foreach ($unavailableInviteCopy in @('当前没有可用邀请码', '尚未接入邀请码校验')) { if (-not $m08.Contains($unavailableInviteCopy)) { throw "M08 unavailable boundary missing: $unavailableInviteCopy" } } if (-not $m03.Contains('currentUser.phone') -or $m03.Contains('139****6421')) { throw 'M03 must consume the unique current-user phone instead of a second hard-coded identity' } if (-not $m02.Contains('currentUser.name') -or $m02.Contains('nickName: "汤文远"')) { throw 'M02 must initialize editable identity from the unique current-user owner' } $m09 = $sources['pages/profile/m09-vip-orders.vue'] foreach ($forbiddenOrderPreview in @('query.state', '演示订单', '¥0.00', 'order-state--ready')) { if ($m09.Contains($forbiddenOrderPreview)) { throw "M09 must not fabricate an order before API integration: $forbiddenOrderPreview" } } if ($m10 -notmatch 'import \{ session \} from "@/utils/session\.js";') { throw 'M10 must consume the unique session owner' } if ($m10 -notmatch '(?s)const confirmLogout = \(\) => \{\s*session\.clear\(\);\s*logoutVisible\.value = false;\s*return goRoot\("A01"\);\s*\};') { throw 'M10 logout order must be session.clear -> close dialog -> goRoot(A01)' } if (([regex]::Matches($m10, 'session\.clear\(\)')).Count -ne 1) { throw 'M10 cancellation or back paths must never clear the session' } if ($m10 -notmatch 'import manifest from "@/manifest\.json";' -or $m10.Contains('const appVersion = "1.0.0"')) { throw 'M10 must display the version from manifest.json instead of duplicating release metadata' } Write-Output 'NM-PAGE-BUSINESS-CONTRACT PASS'