保存视觉审核与文档流迁移进度

This commit is contained in:
rain
2026-07-20 17:23:09 +08:00
parent 5a31a75da0
commit 01d246c47b
59 changed files with 3615 additions and 378 deletions
+1 -1
View File
@@ -2,7 +2,7 @@ $ErrorActionPreference = 'Stop'
$root = Split-Path -Parent $PSScriptRoot
$capture = Get-Content -LiteralPath (Join-Path $root 'scripts/capture-chrome-page.js') -Raw -Encoding utf8
foreach ($required in @('prepareFSeriesState', 'f02-validation', 'module-preview-toast', 'waitForPageLoad')) {
foreach ($required in @('prepareFSeriesState', 'f02-validation', 'module-preview-toast', 'waitForPageLoad', "fromSurface: action !== 'from-view'")) {
if ($capture -notmatch [regex]::Escape($required)) { throw "F series capture state missing: $required" }
}
if ($capture -notmatch 'await prepareFSeriesState\(send, action\)') { throw 'Capture runner does not prepare F series states' }
+407
View File
@@ -0,0 +1,407 @@
[
{
"file": "components/AppDialog.vue",
"selector": ".app-dialog-layer",
"reason": "全屏弹窗遮罩"
},
{
"file": "components/AppDialog.vue",
"selector": ".app-dialog__skin",
"reason": "弹窗内部装饰皮肤"
},
{
"file": "components/AppToast.vue",
"selector": ".app-toast",
"reason": "跨页面轻提示"
},
{
"file": "components/PageHeader.vue",
"selector": ".page-header",
"reason": "用户指定的固定顶部导航栏"
},
{
"file": "components/AppTabbar.vue",
"selector": ".app-tabbar",
"reason": "用户指定的固定底部导航栏"
},
{
"file": "components/PageHeader.vue",
"selector": ".header-texture",
"reason": "固定顶部栏内部纯装饰纹理层"
},
{
"file": "components/PageHeader.vue",
"selector": ".header-hall",
"reason": "固定顶部栏内部纯装饰祠堂层"
},
{
"file": "components/PageHeader.vue",
"selector": ".notice-dot",
"reason": "依附通知按钮的局部未读角标"
},
{
"file": "pages/records/r02-person-detail.vue",
"selector": ".person-card-skin",
"reason": "依附卡片容器且不承担排版的装饰框层"
},
{
"file": "components/AppButton.vue",
"selector": ".app-button__skin",
"reason": "依附按钮容器且不承担文字排版的卷轴皮肤"
},
{
"file": "components/GenealogyCard.vue",
"selector": ".row-frame",
"reason": "依附家谱卡片容器的装饰框层"
},
{
"file": "components/GenealogyCard.vue",
"selector": ".surname-seal-frame",
"reason": "依附姓氏印章容器的装饰框层"
},
{
"file": "components/GenealogyPageBackground.vue",
"selector": ".genealogy-page-background",
"reason": "不参与排版且覆盖视口的独立家谱背景层"
},
{
"file": "components/GenealogyPageBackground.vue",
"selector": ".genealogy-page-background__art",
"reason": "独立家谱背景层内部的底部背景画面"
},
{
"file": "components/ModulePageBackground.vue",
"selector": ".module-page-background",
"reason": "不参与排版且覆盖视口的独立模块背景层"
},
{
"file": "components/ModulePageBackground.vue",
"selector": ".module-page-background__image",
"reason": "独立模块背景层内部的底部背景画面"
},
{
"file": "components/ModulePage.vue",
"selector": ".module-lead image",
"reason": "依附模块导语容器且不承担文字排版的分隔装饰"
},
{
"file": "components/ModulePage.vue",
"selector": ".form-row image, .detail-card image, .settings-row image, .list-card > image, .timeline-row > image",
"reason": "依附各内容容器且不承担正文排版的装饰框层"
},
{
"file": "components/ModulePage.vue",
"selector": ".status-card__skin",
"reason": "依附状态卡容器且不承担正文排版的装饰框层"
},
{
"file": "components/tree/TreeMemberForm.vue",
"selector": ".member-form-panel__skin",
"reason": "依附成员表单容器且不承担正文排版的装饰框层"
},
{
"file": "components/tree/TreeMemberForm.vue",
"selector": ".member-field image",
"reason": "依附成员字段容器且不承担表单排版的装饰框层"
},
{
"file": "components/tree/TreeMemberForm.vue",
"selector": ".member-form-action image",
"reason": "依附成员表单按钮且不承担文字排版的卷轴皮肤"
},
{
"file": "pages/records/r01-people-list.vue",
"selector": ".people-search > image",
"reason": "依附人物搜索容器且不承担输入排版的装饰框层"
},
{
"file": "pages/records/r01-people-list.vue",
"selector": ".person-card__skin",
"reason": "依附人物卡片且不承担正文排版的装饰框层"
},
{
"file": "pages/records/r01-people-list.vue",
"selector": ".people-result-empty > image, .people-state-card > image",
"reason": "依附人物状态卡且不承担正文排版的装饰框层"
},
{
"file": "pages/family/f01-family-feed.vue",
"selector": ".feed-shortcuts__skin",
"reason": "依附快捷入口容器且不承担文字排版的卷轴皮肤"
},
{
"file": "pages/family/f01-family-feed.vue",
"selector": ".feed-card__skin, .feed-state-card > image, .feed-action image",
"reason": "依附动态卡片、状态卡和按钮且不承担正文排版的装饰皮肤"
},
{
"file": "pages/family/f02-publish-feed.vue",
"selector": ".publish-panel__skin",
"reason": "依附发布面板且不承担正文排版的装饰框层"
},
{
"file": "pages/family/f02-publish-feed.vue",
"selector": ".publish-field image",
"reason": "依附发布文本域且不承担输入排版的装饰框层"
},
{
"file": "pages/family/f06-article-editor.vue",
"selector": ".editor-control > image",
"reason": "依附文章编辑控件且不承担输入排版的装饰框层"
},
{
"file": "pages/family/f08-album-detail.vue",
"selector": ".album-hero-photo, .album-photo-tile__image",
"reason": "在固定比例照片格内执行裁切的媒体画面层"
},
{
"file": "pages/family/f08-album-detail.vue",
"selector": ".album-photo-tile__caption",
"reason": "依附照片格底部的局部说明叠层"
},
{
"file": "pages/family/f08-album-detail.vue",
"selector": ".album-preview",
"reason": "用户触发的全屏照片预览层"
},
{
"file": "pages/family/f09-media-upload.vue",
"selector": ".media-photo-order, .media-photo-current, .media-photo-status",
"reason": "依附照片缩略图的顺序与状态角标"
},
{
"file": "pages/family/f09-media-upload.vue",
"selector": ".media-photo-remove",
"reason": "依附照片缩略图右上角的删除操作"
},
{
"file": "pages/family/f10-video-list.vue",
"selector": ".video-status-lead image",
"reason": "依附视频状态导语的分隔装饰层"
},
{
"file": "pages/family/f10-video-list.vue",
"selector": ".video-status-card__skin",
"reason": "依附视频状态卡且不承担正文排版的装饰框层"
},
{
"file": "pages/notification/n01-message-center.vue",
"selector": ".notice-card__skin",
"reason": "依附消息卡且不承担正文排版的装饰框层"
},
{
"file": "pages/notification/n01-message-center.vue",
"selector": ".notice-state-card__skin",
"reason": "依附消息状态卡且不承担正文排版的装饰框层"
},
{
"file": "pages/profile/m01-profile-home.vue",
"selector": ".profile-hero__frame",
"reason": "依附个人摘要容器的装饰框层"
},
{
"file": "pages/profile/m01-profile-home.vue",
"selector": ".profile-hero__hall",
"reason": "个人摘要容器内的纯装饰祠堂层"
},
{
"file": "pages/profile/m01-profile-home.vue",
"selector": ".profile-hero__cloud",
"reason": "个人摘要容器内的纯装饰云纹层"
},
{
"file": "pages/profile/m01-profile-home.vue",
"selector": ".profile-scroll-notice__skin",
"reason": "依附提醒入口且不承担文字排版的卷轴皮肤"
},
{
"file": "pages/profile/m01-profile-home.vue",
"selector": ".profile-error__skin",
"reason": "依附个人资料错误卡且不承担正文排版的装饰框层"
},
{
"file": "pages/tree/t01-tree-overview.vue",
"selector": ".lineage-connector",
"reason": "依附世系画布且不承担节点排版的关系线绘制层"
},
{
"file": "pages/tree/t01-tree-overview.vue",
"selector": ".tree-state-card__skin",
"reason": "依附世系状态卡且不承担正文排版的装饰框层"
},
{
"file": "pages/tree/t01-tree-overview.vue",
"selector": ".tree-state-card__action image",
"reason": "依附世系状态按钮且不承担文字排版的卷轴皮肤"
},
{
"file": "pages/tree/t01-tree-overview.vue",
"selector": ".member-sheet",
"reason": "用户选择节点后显示的固定底部详情抽屉"
},
{
"file": "pages/tree/t01-tree-overview.vue",
"selector": ".member-sheet__skin",
"reason": "固定底部详情抽屉内部的装饰框层"
},
{
"file": "pages/auth/a01-entry.vue",
"selector": ".brand-seal",
"reason": "依附登录品牌标题的印章装饰"
},
{
"file": "pages/auth/a01-entry.vue",
"selector": ".button-skin",
"reason": "依附登录按钮且不承担文字排版的卷轴皮肤"
},
{
"file": "pages/auth/a01-entry.vue",
"selector": ".feedback-toast",
"reason": "登录页面跨内容轻提示"
},
{
"file": "pages/auth/a01-entry.vue",
"selector": ".login-tab.active::after",
"reason": "依附激活登录标签的局部下划线指示"
},
{
"file": "pages/auth/a01-entry.vue",
"selector": ".login-tab:first-child::before",
"reason": "登录标签组内部的局部分隔装饰"
},
{
"file": "pages/auth/a01-entry.vue",
"selector": ".page-backdrop",
"reason": "不参与登录内容排版的页面背景层"
},
{
"file": "pages/auth/a01-entry.vue",
"selector": ".verification-layer",
"reason": "用户触发的验证码弹窗遮罩"
},
{
"file": "pages/auth/a01-entry.vue",
"selector": ".verification-dialog__skin",
"reason": "验证码弹窗内部装饰框层"
},
{
"file": "pages/auth/a04-register.vue",
"selector": ".back-button",
"reason": "认证全屏页左上角返回操作"
},
{
"file": "pages/auth/a04-register.vue",
"selector": ".brand-seal",
"reason": "依附注册品牌标题的印章装饰"
},
{
"file": "pages/auth/a04-register.vue",
"selector": ".feedback-toast",
"reason": "注册页面跨内容轻提示"
},
{
"file": "pages/auth/a04-register.vue",
"selector": ".page-backdrop",
"reason": "不参与注册内容排版的页面背景层"
},
{
"file": "pages/auth/a04-register.vue",
"selector": ".register-submit__skin",
"reason": "依附注册按钮且不承担文字排版的卷轴皮肤"
},
{
"file": "pages/auth/a05-reset-password.vue",
"selector": ".back-button",
"reason": "认证全屏页左上角返回操作"
},
{
"file": "pages/auth/a05-reset-password.vue",
"selector": ".brand-seal",
"reason": "依附重置密码品牌标题的印章装饰"
},
{
"file": "pages/auth/a05-reset-password.vue",
"selector": ".feedback-toast",
"reason": "重置密码页面跨内容轻提示"
},
{
"file": "pages/auth/a05-reset-password.vue",
"selector": ".page-backdrop",
"reason": "不参与重置密码内容排版的页面背景层"
},
{
"file": "pages/auth/a05-reset-password.vue",
"selector": ".reset-submit__skin, .success-action__skin",
"reason": "依附重置与成功操作按钮的卷轴皮肤"
},
{
"file": "pages/auth/a05-reset-password.vue",
"selector": ".success-layer",
"reason": "用户触发的重置成功弹窗遮罩"
},
{
"file": "pages/auth/a05-reset-password.vue",
"selector": ".success-dialog__skin",
"reason": "重置成功弹窗内部装饰框层"
},
{
"file": "pages/auth/a06-auth-status.vue",
"selector": ".back-button",
"reason": "认证全屏页左上角返回操作"
},
{
"file": "pages/auth/a06-auth-status.vue",
"selector": ".brand-seal",
"reason": "依附认证状态品牌标题的印章装饰"
},
{
"file": "pages/auth/a06-auth-status.vue",
"selector": ".page-backdrop",
"reason": "不参与认证状态内容排版的页面背景层"
},
{
"file": "pages/auth/a06-auth-status.vue",
"selector": ".status-primary__skin, .recovery-action__skin",
"reason": "依附认证状态操作按钮的卷轴皮肤"
},
{
"file": "pages/auth/a06-auth-status.vue",
"selector": ".recovery-layer",
"reason": "用户触发的账号恢复弹窗遮罩"
},
{
"file": "pages/auth/a06-auth-status.vue",
"selector": ".recovery-dialog__skin",
"reason": "账号恢复弹窗内部装饰框层"
},
{
"file": "pages/genealogy/g03-create-genealogy.vue",
"selector": ".create-flow-panel__skin",
"reason": "依附创建家谱流程面板的装饰框层"
},
{
"file": "pages/genealogy/g03-create-genealogy.vue",
"selector": ".flow-header__skin",
"reason": "依附创建流程标题区的装饰皮肤"
},
{
"file": "pages/genealogy/g03-create-genealogy.vue",
"selector": ".flow-primary-action__skin",
"reason": "依附创建流程主操作的卷轴皮肤"
},
{
"file": "pages/genealogy/g03-create-genealogy.vue",
"selector": ".duplicate-reminder-layer, .flow-success-layer",
"reason": "用户触发的重复提醒与成功弹窗遮罩"
},
{
"file": "pages/genealogy/g03-create-genealogy.vue",
"selector": ".duplicate-reminder__skin, .flow-success-dialog__skin",
"reason": "创建流程弹窗内部装饰框层"
},
{
"file": "pages/genealogy/g03-create-genealogy.vue",
"selector": ".duplicate-reminder__search image, .duplicate-reminder__confirm image, .flow-success-dialog__action image",
"reason": "创建流程弹窗操作按钮的卷轴皮肤"
}
]
+34
View File
@@ -0,0 +1,34 @@
$ErrorActionPreference = 'Stop'
$root = Split-Path $PSScriptRoot -Parent
$allowlistPath = Join-Path $PSScriptRoot 'document-flow-position-allowlist.json'
$allowlist = Get-Content -LiteralPath $allowlistPath -Raw -Encoding UTF8 | ConvertFrom-Json
$allowed = @{}
foreach ($entry in $allowlist) {
$allowed["$($entry.file)::$($entry.selector)"] = $entry.reason
}
$violations = New-Object System.Collections.Generic.List[string]
$files = Get-ChildItem -LiteralPath (Join-Path $root 'pages'), (Join-Path $root 'components') -Recurse -File -Filter '*.vue'
foreach ($file in $files) {
$relative = $file.FullName.Substring($root.Length + 1).Replace('\', '/')
$source = [System.IO.File]::ReadAllText($file.FullName, [System.Text.Encoding]::UTF8)
foreach ($styleMatch in [regex]::Matches($source, '(?s)<style\b[^>]*>(?<css>.*?)</style>')) {
$css = $styleMatch.Groups['css'].Value
foreach ($rule in [regex]::Matches($css, '(?s)(?<selector>[^{}]+)\{(?<body>[^{}]*)\}')) {
$body = $rule.Groups['body'].Value
if ($body -notmatch '(?m)position\s*:\s*(absolute|fixed|sticky)\s*;') { continue }
$selector = (($rule.Groups['selector'].Value -replace '(?s)^.*@media[^\{]*', '') -replace '\s+', ' ').Trim()
$key = "$relative::$selector"
if (-not $allowed.ContainsKey($key)) {
$position = [regex]::Match($body, '(?m)position\s*:\s*(absolute|fixed|sticky)\s*;').Groups[1].Value
$violations.Add("$relative :: $selector :: position:$position")
}
}
}
}
if ($violations.Count -gt 0) {
$violations | Sort-Object | ForEach-Object { Write-Output $_ }
throw "DOCUMENT-FLOW-POSITION-CONTRACT found $($violations.Count) non-overlay positioning rules."
}
Write-Output 'DOCUMENT-FLOW-POSITION-CONTRACT PASS'
+15
View File
@@ -0,0 +1,15 @@
$ErrorActionPreference = 'Stop'
$root = Split-Path $PSScriptRoot -Parent
$page = [System.IO.File]::ReadAllText((Join-Path $root 'pages/family/f01-family-feed.vue'), [System.Text.Encoding]::UTF8)
foreach ($expected in @('box-sizing: border-box','pointer-events: none')) {
if (-not $page.Contains($expected)) { throw "F01 document-flow contract missing: $expected" }
}
foreach ($selector in @('.feed-card__copy','.feed-state-card > view')) {
$escaped = [regex]::Escape($selector)
if ($page -match "(?s)$escaped\s*\{[^}]*position\s*:\s*(absolute|fixed|sticky)\s*;") {
throw "F01 normal content selector uses positioning: $selector"
}
}
Write-Output 'F01-DOCUMENT-FLOW-CONTRACT PASS'
+43
View File
@@ -0,0 +1,43 @@
$ErrorActionPreference = 'Stop'
function Read-Utf8([string]$Path) {
[System.IO.File]::ReadAllText(
(Join-Path (Join-Path $PSScriptRoot '..') $Path),
[System.Text.Encoding]::UTF8
)
}
function Assert-Contains {
param([string]$Content, [string]$Expected, [string]$Message)
if (-not $Content.Contains($Expected)) { throw $Message }
}
function Decode-Utf8Base64([string]$Value) {
[System.Text.Encoding]::UTF8.GetString([Convert]::FromBase64String($Value))
}
$catalog = Read-Utf8 'data/page-catalog.js'
$module = Read-Utf8 'components/ModulePage.vue'
foreach ($expected in @(
'states: {',
"eyebrow: '$(Decode-Utf8Base64 '6LCx5paH5bey5aSx5pWI')'",
"title: '$(Decode-Utf8Base64 '6L+Z56+H6LCx5paH5bey5peg5rOV5p+l55yL')'",
"copy: '$(Decode-Utf8Base64 '5YaF5a655Y+v6IO95bey6KKr5L2c6ICF5Yig6Zmk5oiW5Y+W5raI5YWs5byA77yM6K+36L+U5Zue6LCx5paH5YiX6KGo5p+l55yL5YW25LuW5YaF5a6544CC')'",
"action: '$(Decode-Utf8Base64 '6L+U5Zue6LCx5paH5YiX6KGo')'",
"actionMode: 'back'"
)) {
Assert-Contains $catalog $expected "F05 expired-state catalog contract missing: $expected"
}
foreach ($expected in @(
'page.value.states?.[moduleState.value]',
'@click="handleStateAction"',
'const handleStateAction = () => {',
'stateCopy.value.actionMode === "back"',
'uni.navigateBack()'
)) {
Assert-Contains $module $expected "ModulePage state override contract missing: $expected"
}
Write-Output 'F05-EXPIRED-STATE-CONTRACT PASS'
+58
View File
@@ -0,0 +1,58 @@
$ErrorActionPreference = 'Stop'
function Read-Utf8([string]$Path) {
[System.IO.File]::ReadAllText(
(Join-Path (Join-Path $PSScriptRoot '..') $Path),
[System.Text.Encoding]::UTF8
)
}
function Assert-Contains {
param([string]$Content, [string]$Expected, [string]$Message)
if (-not $Content.Contains($Expected)) { throw $Message }
}
$page = Read-Utf8 'pages/family/f06-article-editor.vue'
$catalog = Read-Utf8 'data/page-catalog.js'
foreach ($expected in @(
'class="article-editor-page"',
'article-editor-state--${editorState}',
'<textarea',
'v-model="form.title"',
'v-model="form.category"',
'v-model="form.content"',
'fieldErrors.title',
'fieldErrors.category',
'fieldErrors.content',
'const editorState = ref("form");',
'const allowedStates = new Set([',
'"draft"',
'"loading"',
'"validation"',
'"saving"',
'"error"',
'"success"',
'if (editorState.value === "saving") return;',
'editorState.value = "saving";',
'? "error" : "success"',
':disabled="editorState === ''saving''"',
'onUnload(() => {',
'ModulePageBackground',
'AppLoading',
'AppButton',
'border-image-source: url("/static/assets/modules/family/transparent/module-content-frame.png");',
'border-image-slice: 72 fill;',
'border-image-width: 64rpx;',
'border-image-repeat: stretch;'
)) {
Assert-Contains $page $expected "F06 dedicated editor contract missing: $expected"
}
foreach ($forbidden in @('<template><ModulePage', 'import ModulePage from', 'page-id="f06"', '@/utils/api.js', 'uni.showToast', 'uni.showModal', 'class="editor-panel__skin"')) {
if ($page.Contains($forbidden)) { throw "F06 retains forbidden dependency: $forbidden" }
}
if ($catalog -match '(?m)^\s*f06\s*:') { throw 'F06 obsolete page-catalog owner must be removed' }
Write-Output 'F06-ARTICLE-EDITOR-CONTRACT PASS'
+30
View File
@@ -0,0 +1,30 @@
$ErrorActionPreference = 'Stop'
$root = Join-Path $PSScriptRoot '..'
$files = @(
'static/assets/modules/family/f08/f08-reunion-hero.png',
'static/assets/modules/family/f08/f08-family-portrait.png',
'static/assets/modules/family/f08/f08-reunion-table.png',
'static/assets/modules/family/f08/f08-ancestral-home.png',
'static/assets/modules/family/f08/f08-ancestral-portrait.png'
)
Add-Type -AssemblyName System.Drawing
foreach ($file in $files) {
$path = Join-Path $root $file
if (-not (Test-Path -LiteralPath $path)) {
throw "Missing F08 album asset: $file"
}
$image = [System.Drawing.Image]::FromFile($path)
try {
if ($image.Width -lt 960 -or $image.Height -lt 720) {
throw "F08 album asset too small: $file $($image.Width)x$($image.Height)"
}
} finally {
$image.Dispose()
}
}
Write-Output 'F08-ALBUM-ASSETS-CONTRACT PASS'
+59
View File
@@ -0,0 +1,59 @@
$ErrorActionPreference = 'Stop'
function Read-Utf8([string]$Path) {
[System.IO.File]::ReadAllText(
(Join-Path (Join-Path $PSScriptRoot '..') $Path),
[System.Text.Encoding]::UTF8
)
}
function Assert-Contains {
param([string]$Content, [string]$Expected, [string]$Message)
if (-not $Content.Contains($Expected)) { throw $Message }
}
$page = Read-Utf8 'pages/family/f08-album-detail.vue'
$catalog = Read-Utf8 'data/page-catalog.js'
foreach ($expected in @(
'class="album-detail-page"',
'album-state--${albumState}',
'class="album-hero-photo"',
'class="album-photo-grid"',
'class="album-preview"',
'class="album-upload-action"',
'const albumState = ref("normal");',
'const photos = [',
'const openPreview = (index) =>',
'const closePreview = () =>',
'const showUploadNotice = () =>',
'query.state === "empty"',
'query.state === "preview"',
'query.state === "expired"',
'class="album-empty-state"',
'class="album-expired-state"',
'class="album-preview__position"',
':message="uploadNoticeMessage"',
'f08-reunion-hero.png',
'f08-family-portrait.png',
'f08-reunion-table.png',
'f08-ancestral-home.png',
'f08-ancestral-portrait.png',
':alt="photo.alt"',
'ModulePageBackground',
'PageHeader',
'AppButton',
'AppToast'
)) {
Assert-Contains $page $expected "F08 contract missing: $expected"
}
foreach ($forbidden in @('<ModulePage page-id="f08"', 'import ModulePage from', 'uni.showToast', 'uni.showModal')) {
if ($page.Contains($forbidden)) { throw "F08 retains forbidden dependency: $forbidden" }
}
if ($catalog -match '(?m)^\s*f08\s*:') {
throw 'F08 obsolete page-catalog owner must be removed'
}
Write-Output 'F08-ALBUM-DETAIL-CONTRACT PASS'
+94
View File
@@ -0,0 +1,94 @@
const assert = require('assert')
const origin = process.argv[2] || 'http://localhost:5173'
const sleep = (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds))
const connect = async () => {
const pages = await (await fetch('http://127.0.0.1:9222/json/list')).json()
const page = pages.find((item) => item.type === 'page' && item.url.startsWith(`${origin}/`))
if (!page) throw new Error(`Chrome debugging has no ${origin} project page`)
const socket = new WebSocket(page.webSocketDebuggerUrl)
await new Promise((resolve, reject) => {
socket.addEventListener('open', resolve, { once: true })
socket.addEventListener('error', reject, { once: true })
})
let id = 0
const pending = new Map()
socket.addEventListener('message', (event) => {
const message = JSON.parse(event.data)
const request = pending.get(message.id)
if (!request) return
pending.delete(message.id)
message.error ? request.reject(new Error(message.error.message)) : request.resolve(message.result)
})
const send = (method, params = {}) => new Promise((resolve, reject) => {
id += 1
pending.set(id, { resolve, reject })
socket.send(JSON.stringify({ id, method, params }))
})
return { socket, send }
}
const valueOf = async (send, expression) => (
await send('Runtime.evaluate', { expression, returnByValue: true })
).result?.value
const waitFor = async (send, expression, message) => {
for (let attempt = 0; attempt < 50; attempt += 1) {
if (await valueOf(send, expression)) return
await sleep(80)
}
throw new Error(message)
}
const run = async () => {
const { socket, send } = await connect()
let auditId = 0
const navigate = async (route, selector) => {
auditId += 1
const url = `${origin}/?f08RuntimeAudit=${auditId}#${route}`
await send('Page.navigate', { url })
await waitFor(send, `location.href === ${JSON.stringify(url)}`, `F08 navigation failed: ${route}`)
await waitFor(send, `Boolean(document.querySelector(${JSON.stringify(selector)}))`, `F08 state missing: ${selector}`)
}
try {
await send('Page.enable')
await send('Runtime.enable')
await navigate('/pages/family/f08-album-detail', '.album-state--normal')
const photoCount = await valueOf(send, "document.querySelectorAll('.album-photo-tile').length")
assert.strictEqual(photoCount, 5, 'F08 normal state must render five photo tiles')
await send('Runtime.evaluate', { expression: "document.querySelector('.album-photo-tile')?.click()" })
await waitFor(send, "Boolean(document.querySelector('.album-preview'))", 'F08 photo click did not open preview')
await send('Runtime.evaluate', { expression: "document.querySelector('.album-preview__close')?.click()" })
await waitFor(send, "!document.querySelector('.album-preview')", 'F08 preview did not close')
await send('Runtime.evaluate', { expression: "document.querySelector('.album-upload-action')?.click()" })
await waitFor(send, "Boolean(document.querySelector('.app-toast'))", 'F08 upload notice did not render')
await navigate('/pages/family/f08-album-detail?state=empty', '.album-state--empty')
assert.strictEqual(await valueOf(send, "document.querySelectorAll('.album-photo-tile').length"), 0, 'F08 empty state must not render photos')
await navigate('/pages/family/f08-album-detail?state=preview', '.album-state--preview')
await waitFor(send, "Boolean(document.querySelector('.album-preview'))", 'F08 direct preview state did not render')
await navigate('/pages/family/f08-album-detail?state=expired', '.album-state--expired')
assert.strictEqual(await valueOf(send, "document.querySelectorAll('.album-photo-tile').length"), 0, 'F08 expired state must not render photos')
process.stdout.write('F08-ALBUM-DETAIL-RUNTIME-SMOKE PASS\n')
} finally {
socket.close()
}
}
run().catch((error) => {
process.stderr.write(`${error.stack || error.message}\n`)
process.exit(1)
})
+70
View File
@@ -0,0 +1,70 @@
$ErrorActionPreference = 'Stop'
function Read-Utf8([string]$Path) {
[System.IO.File]::ReadAllText(
(Join-Path (Join-Path $PSScriptRoot '..') $Path),
[System.Text.Encoding]::UTF8
)
}
function Assert-Contains {
param([string]$Content, [string]$Expected, [string]$Message)
if (-not $Content.Contains($Expected)) { throw $Message }
}
$page = Read-Utf8 'pages/family/f09-media-upload.vue'
$catalog = Read-Utf8 'data/page-catalog.js'
foreach ($expected in @(
'class="media-upload-page"',
'media-upload-state--${uploadState}',
'class="media-album-card"',
'class="media-photo-grid"',
'class="media-add-tile"',
'class="media-batch-field"',
'class="media-photo-field"',
'class="media-photo-editor"',
'class="media-photo-editor__thumb"',
'class="media-photo-editor__position"',
'const MAX_PHOTOS = 9;',
'const selectMockPhotos = () =>',
'const selectPhoto = (index) =>',
'const removePhoto = (index) =>',
'const addMockPhoto = () =>',
'const updateActiveNote = (event) =>',
'const startUpload = () =>',
'const retryFailed = () =>',
'const returnToAlbum = () =>',
'["permission", "selected", "uploading", "error", "success"].includes(query.state)',
'class="media-permission-action"',
'class="media-photo-status media-photo-status--error"',
'class="media-success-title"',
'class="media-progress-copy"',
'class="media-primary-action"',
'class="media-retry-action"',
'f08-reunion-hero.png',
'f08-family-portrait.png',
'f08-reunion-table.png',
'f08-ancestral-home.png',
'f08-ancestral-portrait.png',
'ModulePageBackground',
'PageHeader',
'AppButton'
)) {
Assert-Contains $page $expected "F09 contract missing: $expected"
}
foreach ($forbidden in @(
'<ModulePage page-id="f09"',
'import ModulePage from',
'uni.chooseMedia',
'uni.showModal'
)) {
if ($page.Contains($forbidden)) { throw "F09 retains forbidden dependency: $forbidden" }
}
if ($catalog -match '(?m)^\s*f09\s*:') {
throw 'F09 obsolete page-catalog owner must be removed'
}
Write-Output 'F09-MEDIA-UPLOAD-CONTRACT PASS'
+78
View File
@@ -0,0 +1,78 @@
const assert = require('assert')
const origin = process.argv[2] || 'http://localhost:5173'
const sleep = (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds))
const run = async () => {
const pages = await (await fetch('http://127.0.0.1:9222/json/list')).json()
const page = pages.find((item) => item.type === 'page' && item.url.startsWith(`${origin}/`))
if (!page) throw new Error(`Chrome debugging has no ${origin} project page`)
const socket = new WebSocket(page.webSocketDebuggerUrl)
await new Promise((resolve, reject) => {
socket.addEventListener('open', resolve, { once: true })
socket.addEventListener('error', reject, { once: true })
})
let id = 0
const pending = new Map()
socket.addEventListener('message', (event) => {
const message = JSON.parse(event.data)
const request = pending.get(message.id)
if (!request) return
pending.delete(message.id)
message.error ? request.reject(new Error(message.error.message)) : request.resolve(message.result)
})
const send = (method, params = {}) => new Promise((resolve, reject) => {
id += 1
pending.set(id, { resolve, reject })
socket.send(JSON.stringify({ id, method, params }))
})
const valueOf = async (expression) => (
await send('Runtime.evaluate', { expression, returnByValue: true })
).result?.value
const waitFor = async (expression, message) => {
for (let attempt = 0; attempt < 50; attempt += 1) {
if (await valueOf(expression)) return
await sleep(80)
}
throw new Error(message)
}
try {
await send('Page.enable')
await send('Runtime.enable')
await send('Emulation.setDeviceMetricsOverride', {
width: 412,
height: 915,
deviceScaleFactor: 1,
mobile: true,
screenWidth: 412,
screenHeight: 915,
})
const url = `${origin}/?f09LayoutAudit=1#/pages/family/f09-media-upload?state=selected`
await send('Page.navigate', { url })
await waitFor("Boolean(document.querySelector('.media-upload-state--selected'))", 'F09 selected state missing')
const metrics = await valueOf(`(() => {
const textareas = Array.from(document.querySelectorAll('.media-description-panel textarea'))
const action = document.querySelector('.media-primary-action')
return {
textareaHeights: textareas.map((node) => node.getBoundingClientRect().height),
actionBottom: action.getBoundingClientRect().bottom,
viewportHeight: innerHeight,
}
})()`)
process.stdout.write(`${JSON.stringify(metrics)}\n`)
assert(metrics.textareaHeights.every((height) => height <= 72), 'F09 textarea uses an oversized browser default height')
assert(metrics.actionBottom <= metrics.viewportHeight, 'F09 selected primary action is clipped at 412x915')
process.stdout.write('F09-MEDIA-UPLOAD-LAYOUT-SMOKE PASS\n')
} finally {
try { await send('Emulation.clearDeviceMetricsOverride') } catch (_) {}
socket.close()
}
}
run().catch((error) => {
process.stderr.write(`${error.stack || error.message}\n`)
process.exit(1)
})
+120
View File
@@ -0,0 +1,120 @@
const assert = require('assert')
const origin = process.argv[2] || 'http://localhost:5173'
const sleep = (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds))
const connect = async () => {
const pages = await (await fetch('http://127.0.0.1:9222/json/list')).json()
const page = pages.find((item) => item.type === 'page' && item.url.startsWith(`${origin}/`))
if (!page) throw new Error(`Chrome debugging has no ${origin} project page`)
const socket = new WebSocket(page.webSocketDebuggerUrl)
await new Promise((resolve, reject) => {
socket.addEventListener('open', resolve, { once: true })
socket.addEventListener('error', reject, { once: true })
})
let id = 0
const pending = new Map()
socket.addEventListener('message', (event) => {
const message = JSON.parse(event.data)
const request = pending.get(message.id)
if (!request) return
pending.delete(message.id)
message.error ? request.reject(new Error(message.error.message)) : request.resolve(message.result)
})
const send = (method, params = {}) => new Promise((resolve, reject) => {
id += 1
pending.set(id, { resolve, reject })
socket.send(JSON.stringify({ id, method, params }))
})
return { socket, send }
}
const valueOf = async (send, expression) => (
await send('Runtime.evaluate', { expression, returnByValue: true })
).result?.value
const waitFor = async (send, expression, message) => {
for (let attempt = 0; attempt < 50; attempt += 1) {
if (await valueOf(send, expression)) return
await sleep(80)
}
throw new Error(message)
}
const run = async () => {
const { socket, send } = await connect()
let auditId = 0
const navigate = async (route, selector) => {
auditId += 1
const url = `${origin}/?f09RuntimeAudit=${auditId}#${route}`
await send('Page.navigate', { url })
await waitFor(send, `location.href === ${JSON.stringify(url)}`, `F09 navigation failed: ${route}`)
await waitFor(send, `Boolean(document.querySelector(${JSON.stringify(selector)}))`, `F09 state missing: ${selector}`)
}
try {
await send('Page.enable')
await send('Runtime.enable')
await navigate('/pages/family/f09-media-upload', '.media-upload-state--initial')
assert.strictEqual(await valueOf(send, "document.querySelectorAll('.media-photo-tile').length"), 0)
await send('Runtime.evaluate', { expression: "document.querySelector('.media-primary-action')?.click()" })
await waitFor(send, "Boolean(document.querySelector('.media-upload-state--selected'))", 'F09 did not enter selected state')
assert.strictEqual(await valueOf(send, "document.querySelectorAll('.media-photo-tile').length"), 4)
await send('Runtime.evaluate', { expression: "document.querySelectorAll('.media-photo-tile')[1]?.click()" })
await waitFor(send, "document.querySelectorAll('.media-photo-tile')[1]?.classList.contains('media-photo-tile--active') === true", 'F09 did not switch current photo')
await waitFor(send, "document.querySelector('.media-photo-editor__position')?.textContent.includes('第 2 张 / 共 4 张') === true", 'F09 editor did not identify the second photo')
await waitFor(send, "document.querySelector('.media-photo-editor__thumb img')?.currentSrc.includes('f08-family-portrait.png') === true", 'F09 editor thumbnail did not follow the active photo')
await send('Runtime.evaluate', { expression: `(() => {
const textarea = document.querySelector('.media-photo-field textarea')
textarea.value = '院前合影'
textarea.dispatchEvent(new Event('input', { bubbles: true }))
document.querySelectorAll('.media-photo-tile')[0]?.click()
document.querySelectorAll('.media-photo-tile')[1]?.click()
})()` })
await waitFor(send, "document.querySelector('.media-photo-field textarea')?.value === '院前合影'", 'F09 per-photo note did not remain bound after switching photos')
await send('Runtime.evaluate', { expression: "document.querySelectorAll('.media-photo-remove')[1]?.click()" })
await waitFor(send, "document.querySelectorAll('.media-photo-tile').length === 3", 'F09 did not remove one photo')
await send('Runtime.evaluate', { expression: "document.querySelector('.media-primary-action')?.click()" })
await waitFor(send, "Boolean(document.querySelector('.media-field-error'))", 'F09 batch description validation did not render')
await send('Runtime.evaluate', { expression: `(() => {
const textarea = document.querySelector('.media-batch-field textarea')
textarea.value = '春节团圆照片整理'
textarea.dispatchEvent(new Event('input', { bubbles: true }))
document.querySelector('.media-primary-action')?.click()
})()` })
await waitFor(send, "Boolean(document.querySelector('.media-upload-state--uploading'))", 'F09 did not enter uploading state')
await navigate('/pages/family/f09-media-upload?state=permission', '.media-upload-state--permission')
await send('Runtime.evaluate', { expression: "document.querySelector('.media-permission-action')?.click()" })
await waitFor(send, "Boolean(document.querySelector('.media-upload-state--selected'))", 'F09 permission action did not return to selected state')
await navigate('/pages/family/f09-media-upload?state=error', '.media-upload-state--error')
assert.strictEqual(await valueOf(send, "document.querySelectorAll('.media-photo-status--error').length"), 2)
await send('Runtime.evaluate', { expression: "document.querySelector('.media-retry-action')?.click()" })
await waitFor(send, "Boolean(document.querySelector('.media-upload-state--uploading'))", 'F09 retry did not enter uploading state')
await navigate('/pages/family/f09-media-upload?state=success', '.media-upload-state--success')
await waitFor(send, "document.querySelector('.media-success-title')?.textContent.includes('4 张照片已上传') === true", 'F09 success copy missing')
process.stdout.write('F09-MEDIA-UPLOAD-RUNTIME-SMOKE PASS\n')
} finally {
socket.close()
}
}
run().catch((error) => {
process.stderr.write(`${error.stack || error.message}\n`)
process.exit(1)
})
+35
View File
@@ -0,0 +1,35 @@
$ErrorActionPreference = 'Stop'
function Read-Utf8([string]$Path) {
[System.IO.File]::ReadAllText((Join-Path (Join-Path $PSScriptRoot '..') $Path), [System.Text.Encoding]::UTF8)
}
function Assert-Contains([string]$Content, [string]$Expected, [string]$Message) {
if (-not $Content.Contains($Expected)) { throw $Message }
}
$page = Read-Utf8 'pages/family/f10-video-list.vue'
$catalog = Read-Utf8 'data/page-catalog.js'
foreach ($expected in @(
'class="video-status-page"',
'class="video-status-card"',
'class="video-return-action"',
'const returnToFamily = () =>',
'/pages/family/f01-family-feed',
'ModulePageBackground',
'PageHeader',
'AppButton'
)) {
Assert-Contains $page $expected "F10 contract missing: $expected"
}
foreach ($forbidden in @('<ModulePage page-id="f10"', 'import ModulePage from')) {
if ($page.Contains($forbidden)) { throw "F10 retains forbidden dependency: $forbidden" }
}
if ($catalog -match '(?m)^\s*f10\s*:') {
throw 'F10 obsolete page-catalog owner must be removed'
}
Write-Output 'F10-VIDEO-STATUS-CONTRACT PASS'
+77
View File
@@ -0,0 +1,77 @@
const assert = require('assert')
const origin = process.argv[2] || 'http://localhost:5173'
const sleep = (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds))
const connect = async () => {
const pages = await (await fetch('http://127.0.0.1:9222/json/list')).json()
const page = pages.find((item) => item.type === 'page' && item.url.startsWith(`${origin}/`))
if (!page) throw new Error(`Chrome debugging has no ${origin} project page`)
const socket = new WebSocket(page.webSocketDebuggerUrl)
await new Promise((resolve, reject) => {
socket.addEventListener('open', resolve, { once: true })
socket.addEventListener('error', reject, { once: true })
})
let id = 0
const pending = new Map()
socket.addEventListener('message', (event) => {
const message = JSON.parse(event.data)
const request = pending.get(message.id)
if (!request) return
pending.delete(message.id)
message.error ? request.reject(new Error(message.error.message)) : request.resolve(message.result)
})
const send = (method, params = {}) => new Promise((resolve, reject) => {
id += 1
pending.set(id, { resolve, reject })
socket.send(JSON.stringify({ id, method, params }))
})
return { socket, send }
}
const valueOf = async (send, expression) => (
await send('Runtime.evaluate', { expression, returnByValue: true })
).result?.value
const waitFor = async (send, expression, message) => {
for (let attempt = 0; attempt < 50; attempt += 1) {
if (await valueOf(send, expression)) return
await sleep(80)
}
throw new Error(message)
}
const run = async () => {
const { socket, send } = await connect()
const url = `${origin}/?f10RuntimeAudit=${Date.now()}#/pages/family/f10-video-list`
try {
await send('Page.enable')
await send('Runtime.enable')
await send('Page.navigate', { url })
await waitFor(send, `location.href === ${JSON.stringify(url)}`, 'F10 navigation failed')
await waitFor(send, "Boolean(document.querySelector('.video-status-page'))", 'F10 dedicated state missing: .video-status-page')
assert.strictEqual(
await valueOf(send, "Boolean(document.querySelector('.module-state--success'))"),
false
)
await send('Runtime.evaluate', { expression: "document.querySelector('.video-return-action')?.click()" })
await waitFor(send, "location.hash.startsWith('#/pages/family/f01-family-feed')", 'F10 did not return to F01')
await waitFor(send, "Boolean(document.querySelector('.feed-state--list'))", 'F01 did not render after F10 return')
process.stdout.write('F10-VIDEO-STATUS-RUNTIME-SMOKE PASS\n')
} finally {
socket.close()
}
}
run().catch((error) => {
process.stderr.write(`${error.stack || error.message}\n`)
process.exit(1)
})
@@ -0,0 +1,18 @@
$ErrorActionPreference = 'Stop'
$root = Split-Path $PSScriptRoot -Parent
$publish = [System.IO.File]::ReadAllText((Join-Path $root 'pages/family/f02-publish-feed.vue'), [System.Text.Encoding]::UTF8)
$editor = [System.IO.File]::ReadAllText((Join-Path $root 'pages/family/f06-article-editor.vue'), [System.Text.Encoding]::UTF8)
foreach ($selector in @('.publish-form, .publish-result','.publish-field textarea')) {
$escaped = [regex]::Escape($selector)
if ($publish -match "(?s)$escaped\s*\{[^}]*position\s*:\s*(absolute|fixed|sticky)\s*;") { throw "F02 normal content selector uses positioning: $selector" }
}
foreach ($selector in @('.editor-control input, .editor-control textarea')) {
$escaped = [regex]::Escape($selector)
if ($editor -match "(?s)$escaped\s*\{[^}]*position\s*:\s*(absolute|fixed|sticky)\s*;") { throw "F06 normal content selector uses positioning: $selector" }
}
foreach ($content in @($publish,$editor)) {
if (-not $content.Contains('pointer-events: none')) { throw 'Family editor decorative skins must not intercept form controls' }
}
Write-Output 'FAMILY-EDITORS-DOCUMENT-FLOW-CONTRACT PASS'
@@ -0,0 +1,10 @@
$ErrorActionPreference = 'Stop'
$root = Split-Path $PSScriptRoot -Parent
$header = [System.IO.File]::ReadAllText((Join-Path $root 'components/PageHeader.vue'), [System.Text.Encoding]::UTF8)
$tabbar = [System.IO.File]::ReadAllText((Join-Path $root 'components/AppTabbar.vue'), [System.Text.Encoding]::UTF8)
if ($header -notmatch 'class="page-header-slot"') { throw 'PageHeader must reserve normal-flow space for the fixed header' }
if ($header -notmatch '(?s)\.page-header\s*\{[^}]*position\s*:\s*fixed\s*;[^}]*top\s*:\s*0\s*;[^}]*right\s*:\s*0\s*;[^}]*left\s*:\s*0\s*;') { throw 'PageHeader must be fixed to the top viewport edge' }
if ($tabbar -notmatch '(?s)\.app-tabbar\s*\{[^}]*position\s*:\s*fixed\s*;[^}]*right\s*:\s*0\s*;[^}]*bottom\s*:\s*0\s*;[^}]*left\s*:\s*0\s*;') { throw 'AppTabbar must remain fixed to the bottom viewport edge' }
Write-Output 'FIXED-NAVIGATION-POSITION-CONTRACT PASS'
+10
View File
@@ -0,0 +1,10 @@
$ErrorActionPreference = 'Stop'
$root = Split-Path $PSScriptRoot -Parent
$page = [System.IO.File]::ReadAllText((Join-Path $root 'pages/profile/m01-profile-home.vue'), [System.Text.Encoding]::UTF8)
foreach ($expected in @('grid-template-columns: 42rpx minmax(0, 1fr)','pointer-events: none')) {
if (-not $page.Contains($expected)) { throw "M01 document-flow contract missing: $expected" }
}
if ($page -match '(?s)\.profile-hero__label\s*\{[^}]*position\s*:\s*(absolute|fixed|sticky)\s*;') { throw 'M01 profile hero label must use the hero grid flow' }
Write-Output 'M01-DOCUMENT-FLOW-CONTRACT PASS'
@@ -0,0 +1,22 @@
$ErrorActionPreference = 'Stop'
$root = Split-Path $PSScriptRoot -Parent
$component = [System.IO.File]::ReadAllText((Join-Path $root 'components/ModulePage.vue'), [System.Text.Encoding]::UTF8)
foreach ($expected in @(
'class="form-row"',
'class="settings-row"',
'grid-template-columns: auto minmax(0, 1fr)',
'grid-template-columns: minmax(0, 1fr) auto',
'pointer-events: none'
)) {
if (-not $component.Contains($expected)) { throw "ModulePage document-flow contract missing: $expected" }
}
foreach ($selector in @('.form-row > text','.form-row input','.settings-row > text')) {
$escaped = [regex]::Escape($selector)
if ($component -match "(?s)$escaped\s*\{[^}]*position\s*:\s*(absolute|fixed|sticky)\s*;") {
throw "ModulePage normal content selector uses positioning: $selector"
}
}
Write-Output 'MODULE-PAGE-DOCUMENT-FLOW-CONTRACT PASS'
+2 -2
View File
@@ -27,13 +27,13 @@ const run = async () => {
try {
await send('Page.enable'); await send('Runtime.enable')
await open(send, '/pages/family/f04-article-list', '', '.module-page--list.module-state--ready')
await open(send, '/pages/family/f06-article-editor', '', '.module-page--form.module-state--ready')
await open(send, '/pages/records/r04-gift-editor', '', '.module-page--form.module-state--ready')
await open(send, '/pages/family/f03-feed-detail', '', '.module-page--detail.module-state--ready')
await open(send, '/pages/records/r08-growth-journal', '', '.module-page--timeline.module-state--ready')
await open(send, '/pages/profile/m03-security-settings', '', '.module-page--settings.module-state--ready')
await open(send, '/pages/family/f10-video-list', '', '.module-page--status.module-state--ready')
await open(send, '/pages/family/f04-article-list', '?state=empty', '.module-state--empty')
await open(send, '/pages/family/f06-article-editor', '?state=success', '.module-state--success')
await open(send, '/pages/records/r04-gift-editor', '?state=success', '.module-state--success')
await open(send, '/pages/profile/m03-security-settings', '?state=error', '.module-state--error')
process.stdout.write('MODULE-PAGE-RUNTIME-SMOKE PASS\n')
} finally { socket.close() }
@@ -11,11 +11,14 @@ const routes = [
['/pages/family/f01-family-feed', '.family-page'],
['/pages/family/f02-publish-feed', '.publish-page'],
['/pages/family/f04-article-list', '.module-page--list'],
['/pages/family/f06-article-editor', '.module-page--form'],
['/pages/family/f06-article-editor', '.article-editor-page'],
['/pages/family/f08-album-detail', '.album-detail-page'],
['/pages/family/f09-media-upload?state=selected', '.media-upload-page'],
['/pages/family/f03-feed-detail', '.module-page--detail'],
['/pages/records/r08-growth-journal', '.module-page--timeline'],
['/pages/profile/m03-security-settings', '.module-page--settings'],
['/pages/family/f10-video-list', '.module-page--status'],
['/pages/family/f10-video-list', '.video-status-page'],
['/pages/records/r02-person-detail?personId=1', '.person-detail-page'],
['/pages/notification/n01-message-center', '.notice-page'],
['/pages/profile/m01-profile-home', '.profile-page']
]
@@ -72,7 +75,7 @@ const run = async () => {
const metrics = await valueOf(send, `(() => {
const root = document.querySelector(${JSON.stringify(selector)})
const rect = root.getBoundingClientRect()
const visible = Array.from(document.querySelectorAll('.app-button,.list-card,.detail-card,.form-row,.settings-row,.notice-card,.profile-menu'))
const visible = Array.from(document.querySelectorAll('.app-button,.list-card,.detail-card,.form-row,.editor-control,.album-photo-tile,.album-upload-action,.media-photo-tile,.media-add-tile,.media-batch-field,.media-photo-field,.media-primary-action,.video-return-action,.person-identity-card,.person-archive-card,.person-field,.person-long-field,.person-edit-actions,.settings-row,.notice-card,.profile-menu'))
.filter((node) => node.getClientRects().length)
.map((node) => node.getBoundingClientRect())
return {
+21
View File
@@ -0,0 +1,21 @@
$ErrorActionPreference = 'Stop'
$root = Split-Path $PSScriptRoot -Parent
$page = [System.IO.File]::ReadAllText((Join-Path $root 'pages/records/r01-people-list.vue'), [System.Text.Encoding]::UTF8)
foreach ($expected in @(
'grid-template-columns: minmax(0, 1fr) 126rpx',
'pointer-events: none'
)) {
if (-not $page.Contains($expected)) { throw "R01 document-flow contract missing: $expected" }
}
foreach ($selector in @('.people-search input','.people-search__action','.person-card__copy')) {
$escaped = [regex]::Escape($selector)
if ($page -match "(?s)$escaped\s*\{[^}]*position\s*:\s*(absolute|fixed|sticky)\s*;") {
throw "R01 normal content selector uses positioning: $selector"
}
}
if ($page -notmatch '(?s)\.person-card__copy\s*\{[^}]*position\s*:\s*relative\s*;') { throw 'R01 person-card copy must stay in normal flow above its local skin' }
Write-Output 'R01-DOCUMENT-FLOW-CONTRACT PASS'
+73
View File
@@ -0,0 +1,73 @@
const assert = require('assert')
const origin = process.argv[2] || 'http://localhost:5173'
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
const run = async () => {
const pages = await (await fetch('http://127.0.0.1:9222/json/list')).json()
const page = pages.find((item) => item.type === 'page' && item.url.startsWith(`${origin}/`))
if (!page) throw new Error(`Chrome debugging has no ${origin} project page`)
const socket = new WebSocket(page.webSocketDebuggerUrl)
await new Promise((resolve, reject) => {
socket.addEventListener('open', resolve, { once: true })
socket.addEventListener('error', reject, { once: true })
})
let id = 0
const pending = new Map()
socket.addEventListener('message', (event) => {
const message = JSON.parse(event.data)
const request = pending.get(message.id)
if (!request) return
pending.delete(message.id)
message.error ? request.reject(new Error(message.error.message)) : request.resolve(message.result)
})
const send = (method, params = {}) => new Promise((resolve, reject) => {
id += 1
pending.set(id, { resolve, reject })
socket.send(JSON.stringify({ id, method, params }))
})
const valueOf = async (expression) => (await send('Runtime.evaluate', { expression, returnByValue: true })).result?.value
try {
await send('Page.enable')
await send('Runtime.enable')
await send('Emulation.setDeviceMetricsOverride', { width: 412, height: 915, deviceScaleFactor: 1, mobile: true, screenWidth: 412, screenHeight: 915 })
const url = `${origin}/?r02BackgroundAudit=1#/pages/records/r02-person-detail?personId=1&state=edit`
await send('Page.navigate', { url })
for (let attempt = 0; attempt < 60; attempt += 1) {
if (await valueOf("Boolean(document.querySelector('.person-detail-state--edit'))")) break
await sleep(80)
}
await sleep(500)
const layers = await valueOf(`Array.from(document.querySelectorAll('.person-identity-card,.person-field,.person-long-field')).map((node) => {
const skin = node.querySelector('.person-card-skin')
const image = skin?.querySelector('img')
const cardRect = node.getBoundingClientRect()
const skinRect = skin?.getBoundingClientRect()
return {
className: node.className,
hasSkin: Boolean(skin),
complete: image?.complete,
naturalWidth: image?.naturalWidth,
position: skin ? getComputedStyle(skin).position : '',
widthDelta: skinRect ? Math.abs(cardRect.width - skinRect.width) : 999,
heightDelta: skinRect ? Math.abs(cardRect.height - skinRect.height) : 999
}
})`)
assert(layers.length >= 6, 'R02 edit cards were not rendered')
for (const layer of layers) {
assert.strictEqual(layer.hasSkin, true, `${layer.className} decorative skin is missing`)
assert.strictEqual(layer.complete, true, `${layer.className} decorative skin did not load`)
assert(layer.naturalWidth > 0, `${layer.className} decorative skin has no source pixels`)
assert.strictEqual(layer.position, 'absolute', `${layer.className} decorative skin is not a local absolute layer`)
assert(layer.widthDelta <= 1 && layer.heightDelta <= 1, `${layer.className} decorative skin does not cover its card`)
}
process.stdout.write(`${JSON.stringify(layers)}\nR02-BACKGROUND-RUNTIME-SMOKE PASS\n`)
} finally {
try { await send('Emulation.clearDeviceMetricsOverride') } catch (_) {}
socket.close()
}
}
run().catch((error) => {
process.stderr.write(`${error.stack || error.message}\n`)
process.exit(1)
})
+20
View File
@@ -0,0 +1,20 @@
$ErrorActionPreference = 'Stop'
function Read-Utf8([string]$Path) { [System.IO.File]::ReadAllText((Join-Path (Join-Path $PSScriptRoot '..') $Path), [System.Text.Encoding]::UTF8) }
function Assert-Contains([string]$Content,[string]$Expected) { if (-not $Content.Contains($Expected)) { throw "R02 contract missing: $Expected" } }
$page = Read-Utf8 'pages/records/r02-person-detail.vue'
$catalog = Read-Utf8 'data/page-catalog.js'
foreach ($expected in @(
'class="person-detail-page"','person-detail-state--','class="person-identity-card"',
'class="person-edit-action"','class="person-save-action"','class="person-cancel-action"',
'shortFields','longFields','person-field__label','person-long-field__label','const enterEdit = () =>','const savePerson = () =>',
'const returnToPeople = () =>','r01-person-name-card.png','module-content-frame.png','module-field-frame.png',
'class="person-card-skin"','pointer-events: none','ModulePageBackground','PageHeader','AppButton','AppLoading','AppToast'
)) { Assert-Contains $page $expected }
foreach ($forbidden in @('<ModulePage page-id="r02"','import ModulePage from')) { if ($page.Contains($forbidden)) { throw "R02 retains forbidden dependency: $forbidden" } }
if ($catalog -match '(?m)^\s*r02\s*:') { throw 'R02 obsolete page-catalog owner must be removed' }
if ($page -notmatch '(?s)\.person-card-skin\s*\{[^}]*position\s*:\s*absolute\s*;') { throw 'R02 decorative skin must use a local absolute layer' }
foreach ($selector in @('.person-identity-card__copy','.person-field__label','.person-field input','.person-field-error','.person-long-field__label','.person-long-field textarea','.person-state-action')) {
$escaped = [regex]::Escape($selector)
if ($page -match "(?s)$escaped\s*\{[^}]*position\s*:\s*(absolute|fixed|sticky)\s*;") { throw "R02 normal content selector uses positioning: $selector" }
}
Write-Output 'R02-PERSON-DETAIL-CONTRACT PASS'
+54
View File
@@ -0,0 +1,54 @@
const assert = require('assert')
const origin = process.argv[2] || 'http://localhost:5173'
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
const connect = async () => {
const pages = await (await fetch('http://127.0.0.1:9222/json/list')).json()
const page = pages.find((item) => item.type === 'page' && item.url.startsWith(`${origin}/`))
if (!page) throw new Error(`Chrome debugging has no ${origin} project page`)
const socket = new WebSocket(page.webSocketDebuggerUrl)
await new Promise((resolve, reject) => { socket.addEventListener('open', resolve, { once: true }); socket.addEventListener('error', reject, { once: true }) })
let id = 0
const pending = new Map()
socket.addEventListener('message', (event) => { const message = JSON.parse(event.data); const request = pending.get(message.id); if (!request) return; pending.delete(message.id); message.error ? request.reject(new Error(message.error.message)) : request.resolve(message.result) })
const send = (method, params = {}) => new Promise((resolve, reject) => { id += 1; pending.set(id, { resolve, reject }); socket.send(JSON.stringify({ id, method, params })) })
return { socket, send }
}
const valueOf = async (send, expression) => (await send('Runtime.evaluate', { expression, returnByValue: true })).result?.value
const waitFor = async (send, expression, message) => { for (let attempt = 0; attempt < 60; attempt += 1) { if (await valueOf(send, expression)) return; await sleep(80) } throw new Error(message) }
const run = async () => {
const { socket, send } = await connect()
let auditId = 0
const navigate = async (route, selector) => {
auditId += 1
const url = `${origin}/?r02RuntimeAudit=${auditId}#${route}`
await send('Page.navigate', { url })
await waitFor(send, `location.href === ${JSON.stringify(url)}`, `R02 navigation failed: ${route}`)
await waitFor(send, `Boolean(document.querySelector(${JSON.stringify(selector)}))`, `R02 state missing: ${selector}`)
}
try {
await send('Page.enable'); await send('Runtime.enable')
await navigate('/pages/records/r01-people-list', '.people-state--ready')
await send('Runtime.evaluate', { expression: "document.querySelector('.person-card')?.click()" })
await waitFor(send, "location.hash.startsWith('#/pages/records/r02-person-detail?personId=1')", 'R01 did not open R02')
await waitFor(send, "Boolean(document.querySelector('.person-detail-state--detail'))", 'R02 detail did not render from R01')
assert.strictEqual(await valueOf(send, "document.querySelector('.person-identity-card')?.textContent.includes('汤文正')"), true)
assert.strictEqual(await valueOf(send, "document.querySelector('.person-identity-card')?.textContent.includes('第 18 世')"), true)
await send('Runtime.evaluate', { expression: "document.querySelector('.person-edit-action')?.click()" })
await waitFor(send, "Boolean(document.querySelector('.person-detail-state--edit'))", 'R02 did not enter edit')
await send('Runtime.evaluate', { expression: `(() => { const input=document.querySelector('.person-field input'); input.value=''; input.dispatchEvent(new Event('input',{bubbles:true})); document.querySelector('.person-save-action')?.click() })()` })
await waitFor(send, "Boolean(document.querySelector('.person-field-error'))", 'R02 validation did not render')
for (const [index, value] of ['汤文正', '家谱管理员', '18'].entries()) {
await send('Runtime.evaluate', { expression: `(() => { const input=document.querySelectorAll('.person-field input')[${index}]; const setter=Object.getOwnPropertyDescriptor(HTMLInputElement.prototype,'value').set; setter.call(input,${JSON.stringify(value)}); input.dispatchEvent(new Event('input',{bubbles:true})) })()` })
await waitFor(send, `document.querySelectorAll('.person-field input')[${index}]?.value === ${JSON.stringify(value)}`, `R02 edit field ${index} did not settle`)
await sleep(150)
}
await send('Runtime.evaluate', { expression: "document.querySelector('.person-save-action')?.click()" })
await waitFor(send, "Boolean(document.querySelector('.person-detail-state--detail'))", 'R02 did not return to detail after save')
await waitFor(send, "Boolean(document.querySelector('.app-toast'))", 'R02 save feedback missing')
await navigate('/pages/records/r02-person-detail?personId=1&state=privacy', '.person-detail-state--privacy')
await navigate('/pages/records/r02-person-detail?personId=1&state=expired', '.person-detail-state--expired')
await navigate('/pages/records/r02-person-detail?personId=1&state=error', '.person-detail-state--error')
process.stdout.write('R02-PERSON-DETAIL-RUNTIME-SMOKE PASS\n')
} finally { socket.close() }
}
run().catch((error) => { process.stderr.write(`${error.stack || error.message}\n`); process.exit(1) })
+19 -4
View File
@@ -3,18 +3,29 @@ $root = Split-Path -Parent $PSScriptRoot
$page = Get-Content -LiteralPath (Join-Path $root 'pages/tree/t01-tree-overview.vue') -Raw -Encoding utf8
foreach ($required in @(
'a01-primary-button-v2.png',
'a01-secondary-button-v2.png',
'import AppButton from "@/components/AppButton.vue"',
'<AppButton',
'type="secondary"',
'@click="toMember"',
'@click="toAddRelative"',
't01-member-node-standard.png',
't01-member-node-selected.png',
't01-state-panel.png',
't01-member-drawer.png',
'lineage-connector--root',
'lineage-pan-cue'
'lineage-pan-cue',
'const initialScrollLeft = ref(90)'
)) {
if ($page -notmatch [regex]::Escape($required)) { throw "T01 member action asset missing: $required" }
}
foreach ($forbidden in @(
'a01-primary-button-v2.png',
'a01-secondary-button-v2.png'
)) {
if ($page -match [regex]::Escape($forbidden)) { throw "T01 must consume AppButton instead of legacy action asset: $forbidden" }
}
foreach ($rule in @(
'(?s)\.tree-toolbar__title text:last-child\s*\{[^}]*font-size:\s*22rpx;',
'(?s)\.tree-toolbar__actions\s*\{[^}]*font-size:\s*23rpx;',
@@ -23,7 +34,11 @@ foreach ($rule in @(
'(?s)\.node-years\s*\{[^}]*font-size:\s*20rpx;',
'(?s)\.tree-state-card__copy\s*\{[^}]*font-size:\s*24rpx;',
'(?s)\.sheet-meta\s*\{[^}]*font-size:\s*22rpx;',
'(?s)\.sheet-action text\s*\{[^}]*font-family:\s*''Microsoft YaHei''[^}]*font-size:\s*22rpx;'
'(?s)\.generation-rail\s*\{[^}]*width:\s*190rpx;',
'(?s)\.tree-scroll--lineage\s*\{[^}]*width:\s*calc\(100% - 190rpx\);[^}]*margin-left:\s*190rpx;',
'(?s)\.member-sheet\s*\{[^}]*height:\s*240rpx;',
'(?s)\.sheet-actions\s*\{[^}]*right:\s*0;[^}]*bottom:\s*24rpx;[^}]*left:\s*0;[^}]*justify-content:\s*center;',
'(?s)\.sheet-action\s*\{[^}]*width:\s*250rpx;[^}]*min-height:\s*88rpx;'
)) {
if ($page -notmatch $rule) { throw "T01 readable visual rule missing: $rule" }
}
@@ -0,0 +1,9 @@
$ErrorActionPreference = 'Stop'
$root = Split-Path $PSScriptRoot -Parent
$page = [System.IO.File]::ReadAllText((Join-Path $root 'pages/tree/t01-tree-overview.vue'), [System.Text.Encoding]::UTF8)
foreach ($selector in @('.tree-state-card','.member-sheet__copy','.sheet-actions')) {
$escaped = [regex]::Escape($selector)
if ($page -match "(?s)$escaped\s*\{[^}]*position\s*:\s*(absolute|fixed|sticky)\s*;") { throw "T01 normal sheet/state content uses positioning: $selector" }
}
if (-not $page.Contains('margin-top: auto')) { throw 'T01 fixed member sheet actions must use flex flow' }
Write-Output 'T01-SHEET-STATE-DOCUMENT-FLOW-CONTRACT PASS'
+12 -5
View File
@@ -27,16 +27,20 @@ if ($catalog -match 't02:') { throw 'T02 duplicate catalog entry must be removed
if ($page -match "@/utils/api\.js|\bappApi\b") { throw 'T01 design phase must not connect the API layer' }
foreach ($required in @(
"const treeState = ref('loading')",
'const treeState = ref("loading")',
'tree-state--tree',
'tree-state--landscape',
'tree-state--empty',
'tree-state--error',
'tree-canvas--state',
'lineage-connector',
'generation-band--twelve',
'generation-band--thirteen',
'generation-band--fourteen',
'const generationRows = [',
'v-for="row in generationRows"',
':style="generationRowStyle(row)"',
'class="tree-stage"',
'class="generation-rail"',
'tree-scroll--lineage',
'const generationRowStyle = (row)',
't01-member-node-standard.png',
't01-member-node-selected.png',
't01-state-panel.png',
@@ -51,7 +55,10 @@ foreach ($required in @(
foreach ($forbidden in @(
'g03-create-flow-panel.png',
'g06-search-input-wide.png',
'application-status-card.png'
'application-status-card.png',
'generation-band--twelve',
'generation-band--thirteen',
'generation-band--fourteen'
)) {
if ($page -match [regex]::Escape($forbidden)) { throw "T01 must not keep opaque surface asset: $forbidden" }
}
@@ -0,0 +1,20 @@
$ErrorActionPreference = 'Stop'
$root = Split-Path $PSScriptRoot -Parent
$component = [System.IO.File]::ReadAllText((Join-Path $root 'components/tree/TreeMemberForm.vue'), [System.Text.Encoding]::UTF8)
foreach ($expected in @(
'grid-template-columns: auto minmax(0, 1fr)',
'min-height: min(640px, calc((100vw - 16px) * 1.48))',
'pointer-events: none'
)) {
if (-not $component.Contains($expected)) { throw "TreeMemberForm document-flow contract missing: $expected" }
}
foreach ($selector in @('.member-form, .member-form-result','.member-field > text','.member-field input')) {
$escaped = [regex]::Escape($selector)
if ($component -match "(?s)$escaped\s*\{[^}]*position\s*:\s*(absolute|fixed|sticky)\s*;") {
throw "TreeMemberForm normal content selector uses positioning: $selector"
}
}
Write-Output 'TREE-MEMBER-FORM-DOCUMENT-FLOW-CONTRACT PASS'