接口开始5%
@@ -58,6 +58,4 @@ sitemap.xml
|
||||
/unpackage/
|
||||
/.vite/
|
||||
/design-pipeline/generated/
|
||||
/docs/design/screens/runtime/
|
||||
/docs/superpowers/specs/design/
|
||||
/tmp-g01-icon-audit.png
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
# SDD Progress
|
||||
|
||||
Plan: docs/superpowers/plans/2026-07-21-page-header-safe-area-g03.md
|
||||
Branch: fix/page-header-safe-area
|
||||
Base: 779a442
|
||||
|
||||
Task 1: complete (commits 779a442..2f22df6, review clean)
|
||||
Task 2: complete (commits 2f22df6..a0d19f4, review clean after strict-regex fix)
|
||||
Task 3: complete (verification only at a0d19f4, review clean; 8 contracts + 2 H5 smokes pass)
|
||||
@@ -1,143 +0,0 @@
|
||||
# Review package: 779a442..2f22df6
|
||||
|
||||
## Commits
|
||||
2f22df6 fix: adapt shared page header to safe area
|
||||
|
||||
## Files changed
|
||||
components/PageHeader.vue | 12 ++++++++++--
|
||||
tests/shared-component-document-flow-contract.ps1 | 4 ++++
|
||||
tests/shared-interaction-accessibility-contract.ps1 | 10 ++++++++++
|
||||
3 files changed, 24 insertions(+), 2 deletions(-)
|
||||
|
||||
## Diff
|
||||
diff --git a/components/PageHeader.vue b/components/PageHeader.vue
|
||||
index 5ef28dd..17ab7ef 100644
|
||||
--- a/components/PageHeader.vue
|
||||
+++ b/components/PageHeader.vue
|
||||
@@ -67,52 +67,60 @@
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
const props = defineProps({
|
||||
title: { type: String, required: true },
|
||||
action: { type: String, default: "" },
|
||||
root: { type: Boolean, default: false },
|
||||
unreadCount: { type: Number, default: 0 },
|
||||
fallbackUrl: { type: String, default: "/pages/genealogy/g01-my-genealogies" },
|
||||
+ customBack: { type: Boolean, default: false },
|
||||
});
|
||||
|
||||
+const emit = defineEmits(["brand", "notice", "action", "back"]);
|
||||
+
|
||||
const goBack = () => {
|
||||
+ if (props.customBack) {
|
||||
+ emit("back");
|
||||
+ return;
|
||||
+ }
|
||||
const stack = getCurrentPages();
|
||||
if (stack.length > 1) {
|
||||
uni.navigateBack();
|
||||
return;
|
||||
}
|
||||
uni.reLaunch({ url: props.fallbackUrl });
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.page-header-slot {
|
||||
- height: 104rpx;
|
||||
+ height: calc(104rpx + var(--status-bar-height, 0px));
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.page-header-slot--root {
|
||||
height: calc(124rpx + var(--status-bar-height, 0px));
|
||||
}
|
||||
|
||||
.page-header {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
right: 0;
|
||||
left: 0;
|
||||
z-index: 30;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
- height: 104rpx;
|
||||
+ height: calc(104rpx + var(--status-bar-height, 0px));
|
||||
padding: 0 24rpx;
|
||||
+ padding-top: var(--status-bar-height, 0px);
|
||||
box-sizing: border-box;
|
||||
background: $brand-red;
|
||||
color: #fff9ed;
|
||||
}
|
||||
|
||||
.page-header--root {
|
||||
height: calc(124rpx + var(--status-bar-height, 0px));
|
||||
padding-top: var(--status-bar-height, 0px);
|
||||
background-color: #b52e22;
|
||||
overflow: hidden;
|
||||
diff --git a/tests/shared-component-document-flow-contract.ps1 b/tests/shared-component-document-flow-contract.ps1
|
||||
index e7b88ae..71e6b5f 100644
|
||||
--- a/tests/shared-component-document-flow-contract.ps1
|
||||
+++ b/tests/shared-component-document-flow-contract.ps1
|
||||
@@ -28,20 +28,24 @@ if ($header -match '(?s)\.page-header--root \.header-side,\s*\.page-header--root
|
||||
throw 'PageHeader root content must not use positioning'
|
||||
}
|
||||
Assert-Match $header '(?s)\.header-icon-button\s*\{[^}]*display:\s*grid;[^}]*place-items:\s*center;' 'PageHeader icon button must use a grid overlay'
|
||||
if ($header -match '(?s)\.header-icon-button\s*\{[^}]*position\s*:') {
|
||||
throw 'PageHeader icon button must not be a positioning context'
|
||||
}
|
||||
Assert-Match $header '(?s)\.header-logo,\s*\.header-notice-icon,\s*\.notice-dot\s*\{[^}]*grid-area:\s*1 / 1;' 'PageHeader icon and notice dot must share one grid cell'
|
||||
if ($header -match '(?s)\.notice-dot\s*\{[^}]*position\s*:') {
|
||||
throw 'PageHeader notice dot must use grid alignment instead of positioning'
|
||||
}
|
||||
+Assert-Match $header '(?s)\.page-header-slot\s*\{[^}]*height:\s*calc\(104rpx \+ var\(--status-bar-height, 0px\)\);' 'PageHeader secondary slot must reserve status bar plus 104rpx content height'
|
||||
+Assert-Match $header '(?s)\.page-header\s*\{[^}]*height:\s*calc\(104rpx \+ var\(--status-bar-height, 0px\)\);[^}]*padding-top:\s*var\(--status-bar-height, 0px\);' 'PageHeader secondary header must place its 104rpx content below the status bar'
|
||||
+Assert-Match $header '(?s)\.page-header-slot--root\s*\{[^}]*height:\s*calc\(124rpx \+ var\(--status-bar-height, 0px\)\);' 'PageHeader root slot height must remain unchanged'
|
||||
+Assert-Match $header '(?s)\.page-header--root\s*\{[^}]*height:\s*calc\(124rpx \+ var\(--status-bar-height, 0px\)\);' 'PageHeader root height must remain unchanged'
|
||||
|
||||
if ($card -match '(?im)(?<![-\w])position\s*:') {
|
||||
throw 'GenealogyCard must use grid instead of positioning'
|
||||
}
|
||||
Assert-Match $card '(?s)\.genealogy-card\s*\{[^}]*display:\s*grid;[^}]*grid-template-columns:\s*72rpx minmax\(0, 1fr\);[^}]*column-gap:\s*22rpx;' 'GenealogyCard must own the two-column document-flow grid'
|
||||
Assert-Match $card '(?s)\.row-frame\s*\{[^}]*grid-area:\s*1 / 1 / 2 / -1;[^}]*z-index:\s*1;[^}]*width:\s*calc\(100% \+ var\(--card-padding-x\) \+ var\(--card-padding-x\)\);[^}]*height:\s*calc\(100% \+ var\(--card-padding-y\) \+ var\(--card-padding-y\)\);[^}]*margin:\s*calc\(-1 \* var\(--card-padding-y\)\) calc\(-1 \* var\(--card-padding-x\)\);' 'GenealogyCard row frame must span the padded card grid above the fixed page background'
|
||||
Assert-Match $card '(?s)\.surname-seal\s*\{[^}]*display:\s*grid;[^}]*grid-row:\s*1;[^}]*z-index:\s*2;[^}]*place-items:\s*center;' 'GenealogyCard surname seal must share the frame row and stay above it'
|
||||
Assert-Match $card '(?s)\.card-main\s*\{[^}]*grid-row:\s*1;[^}]*z-index:\s*2;' 'GenealogyCard content must share the frame row and stay above it'
|
||||
Assert-Match $card '(?s)\.surname-seal-frame,\s*\.surname-seal-copy\s*\{[^}]*grid-area:\s*1 / 1;' 'GenealogyCard seal frame and copy must share one grid cell'
|
||||
if ($card -match '(?s)\.card-name\s*\{[^}]*(?:overflow:\s*hidden|text-overflow:\s*ellipsis|white-space:\s*nowrap)') {
|
||||
diff --git a/tests/shared-interaction-accessibility-contract.ps1 b/tests/shared-interaction-accessibility-contract.ps1
|
||||
index 84ad70c..1059467 100644
|
||||
--- a/tests/shared-interaction-accessibility-contract.ps1
|
||||
+++ b/tests/shared-interaction-accessibility-contract.ps1
|
||||
@@ -10,20 +10,30 @@ if ($button -match '<view\s+[^>]*class="app-button"') { throw 'AppButton must us
|
||||
|
||||
$header = Read-Utf8 'components/PageHeader.vue'
|
||||
foreach ($pattern in @(
|
||||
'(?s)<button[^>]+class="header-back"[^>]+aria-label=',
|
||||
'(?s)<button[^>]+class="header-icon-button header-notice"[^>]+aria-label=',
|
||||
'(?s)<button[^>]+class="header-icon-button"[^>]+aria-label='
|
||||
)) {
|
||||
if ($header -notmatch $pattern) { throw "PageHeader accessibility contract missing: $pattern" }
|
||||
}
|
||||
if (-not $header.Contains('fallbackUrl')) { throw 'PageHeader must expose a deep-link fallback route' }
|
||||
+foreach ($token in @(
|
||||
+ 'customBack: { type: Boolean, default: false }',
|
||||
+ 'const emit = defineEmits(["brand", "notice", "action", "back"]);',
|
||||
+ 'if (props.customBack) {',
|
||||
+ 'emit("back");',
|
||||
+ 'uni.navigateBack();',
|
||||
+ 'uni.reLaunch({ url: props.fallbackUrl });'
|
||||
+)) {
|
||||
+ if (-not $header.Contains($token)) { throw "PageHeader back contract missing: $token" }
|
||||
+}
|
||||
|
||||
$dialog = Read-Utf8 'components/AppDialog.vue'
|
||||
foreach ($token in @('role="dialog"', 'aria-modal="true"', 'tabindex="-1"', '@keydown.esc.stop="cancel"', 'max-height: calc(100vh - 80rpx)', 'overflow-y: auto')) {
|
||||
if (-not $dialog.Contains($token)) { throw "AppDialog accessibility contract missing: $token" }
|
||||
}
|
||||
|
||||
$toast = Read-Utf8 'components/AppToast.vue'
|
||||
foreach ($token in @('v-show="visible"', 'role="status"', 'aria-live="polite"', 'aria-atomic="true"')) {
|
||||
if (-not $toast.Contains($token)) { throw "AppToast accessibility contract missing: $token" }
|
||||
}
|
||||
@@ -1,229 +0,0 @@
|
||||
# Review package: 2f22df6..9362c47
|
||||
|
||||
## Commits
|
||||
9362c47 refactor: reuse shared header on G03
|
||||
|
||||
## Files changed
|
||||
pages/genealogy/g03-create-genealogy.vue | 62 ++++----------------------------
|
||||
tests/g03-create-flow-contract.ps1 | 9 +++--
|
||||
tests/g03-document-flow-contract.ps1 | 4 ++-
|
||||
tests/g03-visual-states-contract.ps1 | 4 +--
|
||||
4 files changed, 17 insertions(+), 62 deletions(-)
|
||||
|
||||
## Diff
|
||||
diff --git a/pages/genealogy/g03-create-genealogy.vue b/pages/genealogy/g03-create-genealogy.vue
|
||||
index 2bc757d..f37cadc 100644
|
||||
--- a/pages/genealogy/g03-create-genealogy.vue
|
||||
+++ b/pages/genealogy/g03-create-genealogy.vue
|
||||
@@ -1,30 +1,20 @@
|
||||
<!-- 页面编号:G-03;用途:创建家谱与录入首代人物的同路由两步流程。 -->
|
||||
<template>
|
||||
<view class="flow-page">
|
||||
<GenealogyPageBackground />
|
||||
|
||||
- <view class="flow-header">
|
||||
- <view class="flow-header__content">
|
||||
- <view class="flow-header__back" @click="goBack">
|
||||
- <image
|
||||
- class="flow-header__back-icon"
|
||||
- src="/static/assets/foundation/transparent/chevron-right.png"
|
||||
- mode="aspectFit"
|
||||
- />
|
||||
- </view>
|
||||
- <text class="flow-header__title">{{
|
||||
- isAncestorStep ? "录入首代人物" : "创建家谱"
|
||||
- }}</text>
|
||||
- <view class="flow-header__side" />
|
||||
- </view>
|
||||
- </view>
|
||||
+ <PageHeader
|
||||
+ :title="isAncestorStep ? '录入首代人物' : '创建家谱'"
|
||||
+ custom-back
|
||||
+ @back="goBack"
|
||||
+ />
|
||||
|
||||
<view class="flow-content">
|
||||
<view class="create-flow-panel">
|
||||
<view v-if="!isAncestorStep" class="create-flow-panel__content">
|
||||
<view class="flow-step-label"><text>第一步 · 立谱信息</text></view>
|
||||
<text class="flow-heading">为家族立一部可传承的谱</text>
|
||||
<text class="flow-note"
|
||||
>家谱建立后可继续完善,名称与访问规则由创建者维护。</text
|
||||
>
|
||||
|
||||
@@ -241,20 +231,21 @@
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, onMounted, onUnmounted, reactive, ref } from "vue";
|
||||
import { onLoad, onShow } from "@dcloudio/uni-app";
|
||||
import GenealogyPageBackground from "@/components/GenealogyPageBackground.vue";
|
||||
+import PageHeader from "@/components/PageHeader.vue";
|
||||
|
||||
const currentStep = ref("create");
|
||||
const genealogyId = ref("");
|
||||
const isSubmitting = ref(false);
|
||||
const createState = ref("form");
|
||||
const ancestorState = ref("form");
|
||||
const duplicateReminderVisible = ref(false);
|
||||
const fieldErrors = reactive({
|
||||
surname: "",
|
||||
name: "",
|
||||
@@ -416,61 +407,20 @@ const enterOverview = () =>
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.flow-page {
|
||||
display: flex;
|
||||
min-height: 100vh;
|
||||
flex-direction: column;
|
||||
background: #f9f6ef;
|
||||
}
|
||||
|
||||
-.flow-header {
|
||||
- z-index: 3;
|
||||
- height: 112rpx;
|
||||
- overflow: hidden;
|
||||
- background: url("/static/assets/foundation/opaque/root-header-cinnabar.jpg")
|
||||
- center / 100% 100% no-repeat;
|
||||
-}
|
||||
-
|
||||
-.flow-header__content {
|
||||
- z-index: 1;
|
||||
- display: flex;
|
||||
- height: 100%;
|
||||
- align-items: center;
|
||||
- justify-content: space-between;
|
||||
- padding: 0 26rpx;
|
||||
- box-sizing: border-box;
|
||||
-}
|
||||
-
|
||||
-.flow-header__back,
|
||||
-.flow-header__side {
|
||||
- display: flex;
|
||||
- width: 72rpx;
|
||||
- align-items: center;
|
||||
-}
|
||||
-
|
||||
-.flow-header__back-icon {
|
||||
- width: 34rpx;
|
||||
- height: 34rpx;
|
||||
- transform: rotate(180deg);
|
||||
-}
|
||||
-
|
||||
-.flow-header__title {
|
||||
- flex: 1;
|
||||
- color: #ffe4a7;
|
||||
- font-family: "STKaiti", "KaiTi", serif;
|
||||
- font-size: 39rpx;
|
||||
- font-weight: 700;
|
||||
- letter-spacing: 4rpx;
|
||||
- text-align: center;
|
||||
-}
|
||||
-
|
||||
.flow-content {
|
||||
z-index: 2;
|
||||
padding: 24rpx 32rpx 48rpx;
|
||||
}
|
||||
|
||||
.create-flow-panel {
|
||||
min-height: 1000rpx;
|
||||
background: url("/static/assets/modules/genealogy/transparent/g01-empty-panel-frame.png")
|
||||
center / 100% 100% no-repeat;
|
||||
}
|
||||
diff --git a/tests/g03-create-flow-contract.ps1 b/tests/g03-create-flow-contract.ps1
|
||||
index 4accfd6..89923c2 100644
|
||||
--- a/tests/g03-create-flow-contract.ps1
|
||||
+++ b/tests/g03-create-flow-contract.ps1
|
||||
@@ -30,29 +30,32 @@ foreach ($required in @(
|
||||
'window.addEventListener("hashchange", syncFlowFromRoute)',
|
||||
'step=ancestor&genealogyId=',
|
||||
'const createState = ref("form");',
|
||||
'const ancestorState = ref("form");',
|
||||
'const fieldErrors = reactive({',
|
||||
'const duplicateReminderVisible = ref(false);',
|
||||
'class="duplicate-reminder-layer"',
|
||||
'if (isSubmitting.value) return;',
|
||||
'/pages/genealogy/g05-genealogy-overview?genealogyId=',
|
||||
'g01-empty-panel-frame.png',
|
||||
- 'root-header-cinnabar.jpg',
|
||||
'a01-scroll-primary-v3.png',
|
||||
'create-flow-panel',
|
||||
- 'flow-primary-action'
|
||||
+ 'flow-primary-action',
|
||||
+ 'import PageHeader from "@/components/PageHeader.vue";',
|
||||
+ '<PageHeader',
|
||||
+ 'custom-back',
|
||||
+ '@back="goBack"'
|
||||
)) {
|
||||
Assert-Contains -Content $g03 -Expected $required -Message "Missing G03 flow contract: $required"
|
||||
}
|
||||
|
||||
-foreach ($forbidden in @("from '@/utils/api.js'", 'appApi.', 'uni.showToast', 'uni.showModal', 'uni.showLoading', 'uni.showActionSheet', '/pages/tree/t01-tree-overview?genealogyId=')) {
|
||||
+foreach ($forbidden in @("from '@/utils/api.js'", 'appApi.', 'uni.showToast', 'uni.showModal', 'uni.showLoading', 'uni.showActionSheet', '/pages/tree/t01-tree-overview?genealogyId=', 'class="flow-header"', 'flow-header__back', 'root-header-cinnabar.jpg')) {
|
||||
if ($g03 -match [regex]::Escape($forbidden)) { throw "G03 retains forbidden implementation: $forbidden" }
|
||||
}
|
||||
|
||||
foreach ($className in @('create-flow-panel', 'flow-primary-action')) {
|
||||
Assert-NoCssSurface -Content $g03 -ClassName $className
|
||||
}
|
||||
|
||||
if (-not (Test-Path -LiteralPath $panelPath)) { throw 'G03 requires the accepted genealogy paper panel bitmap' }
|
||||
Add-Type -AssemblyName System.Drawing
|
||||
$panel = [System.Drawing.Bitmap]::FromFile($panelPath)
|
||||
diff --git a/tests/g03-document-flow-contract.ps1 b/tests/g03-document-flow-contract.ps1
|
||||
index 0249541..654dfa7 100644
|
||||
--- a/tests/g03-document-flow-contract.ps1
|
||||
+++ b/tests/g03-document-flow-contract.ps1
|
||||
@@ -1,15 +1,17 @@
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$root = Split-Path -Parent $PSScriptRoot
|
||||
$page = Get-Content -LiteralPath (Join-Path $root 'pages/genealogy/g03-create-genealogy.vue') -Raw -Encoding utf8
|
||||
$style = [regex]::Match($page, '(?s)<style[^>]*>(.*?)</style>').Groups[1].Value
|
||||
$positions = [regex]::Matches([regex]::Replace($style, '(?s)/\*.*?\*/', ''), '(?m)\bposition\s*:\s*([^;]+);')
|
||||
if ($positions.Count -ne 1 -or $positions[0].Groups[1].Value.Trim() -ne 'fixed') {
|
||||
throw "G03 must retain only its combined fixed dialog layer rule; found $($positions.Count) position declarations"
|
||||
}
|
||||
foreach ($required in @(
|
||||
- 'background: url("/static/assets/foundation/opaque/root-header-cinnabar.jpg")',
|
||||
'background: url("/static/assets/modules/genealogy/transparent/g01-empty-panel-frame.png")',
|
||||
'background: url("/static/assets/modules/auth/transparent/a01-scroll-dialog-v3.png")',
|
||||
'background: url("/static/assets/foundation/transparent/a01-scroll-primary-v3.png")'
|
||||
)) { if ($page -notmatch [regex]::Escape($required)) { throw "G03 is missing real asset background: $required" } }
|
||||
+foreach ($forbidden in @('flow-header', 'root-header-cinnabar.jpg')) {
|
||||
+ if ($page -match [regex]::Escape($forbidden)) { throw "G03 must not retain private header token: $forbidden" }
|
||||
+}
|
||||
Write-Output 'G03-DOCUMENT-FLOW-CONTRACT PASS'
|
||||
diff --git a/tests/g03-visual-states-contract.ps1 b/tests/g03-visual-states-contract.ps1
|
||||
index 5342acb..cfb7911 100644
|
||||
--- a/tests/g03-visual-states-contract.ps1
|
||||
+++ b/tests/g03-visual-states-contract.ps1
|
||||
@@ -2,22 +2,22 @@ $ErrorActionPreference = 'Stop'
|
||||
|
||||
$root = Split-Path -Parent $PSScriptRoot
|
||||
$g03 = Get-Content -LiteralPath (Join-Path $root 'pages/genealogy/g03-create-genealogy.vue') -Raw -Encoding utf8
|
||||
|
||||
function Assert-Match {
|
||||
param([string]$Content, [string]$Pattern, [string]$Message)
|
||||
if ($Content -notmatch $Pattern) { throw $Message }
|
||||
}
|
||||
|
||||
if ($g03 -match '<text>返回</text>') { throw 'G03 header must use only the approved image back arrow' }
|
||||
-Assert-Match -Content $g03 -Pattern '(?s)\.flow-header__back,\s*\.flow-header__side\s*\{[^}]*width:\s*72rpx;' -Message 'G03 header side slots must preserve centered title after removing back text'
|
||||
-Assert-Match -Content $g03 -Pattern '(?s)\.flow-header__back-icon\s*\{[^}]*width:\s*34rpx;[^}]*height:\s*34rpx;' -Message 'G03 back arrow must use the approved visible size'
|
||||
+Assert-Match -Content $g03 -Pattern '(?s)<PageHeader\s+:title="isAncestorStep \? .\u5F55\u5165\u9996\u4EE3\u4EBA\u7269. : .\u521B\u5EFA\u5BB6\u8C31."\s+custom-back\s+@back="goBack"\s*/>' -Message 'G03 must use PageHeader with its dynamic title and custom back handler'
|
||||
+if ($g03 -match 'flow-header|flow-header__back|flow-header__title') { throw 'G03 must not retain a private header implementation' }
|
||||
Assert-Match -Content $g03 -Pattern '(?s)\.flow-note,\s*\.flow-rule__note\s*\{[^}]*font-size:\s*25rpx;' -Message 'G03 guidance copy must use the approved readable size'
|
||||
Assert-Match -Content $g03 -Pattern '(?s)\.flow-rule__option\s*\{[^}]*font-size:\s*23rpx;' -Message 'G03 visibility options must use the approved readable size'
|
||||
Assert-Match -Content $g03 -Pattern '(?s)\.field-error\s*\{[^}]*font-size:\s*24rpx;[^}]*line-height:\s*34rpx;' -Message 'G03 field errors must use the approved readable size'
|
||||
Assert-Match -Content $g03 -Pattern '(?s)\.flow-error\s*\{[^}]*font-size:\s*25rpx;[^}]*line-height:\s*36rpx;' -Message 'G03 submit errors must use the approved readable size'
|
||||
|
||||
foreach ($asset in @('a01-scroll-dialog-v3.png', 'a01-scroll-primary-v3.png', 'a01-scroll-secondary-v3.png')) {
|
||||
if ($g03 -notmatch [regex]::Escape($asset)) { throw "G03 must retain approved shared asset: $asset" }
|
||||
}
|
||||
|
||||
Write-Output 'G03-VISUAL-STATES-CONTRACT PASS'
|
||||
@@ -1,230 +0,0 @@
|
||||
# Review package: 2f22df6..a0d19f4
|
||||
|
||||
## Commits
|
||||
a0d19f4 test: tighten G03 shared header contract
|
||||
9362c47 refactor: reuse shared header on G03
|
||||
|
||||
## Files changed
|
||||
pages/genealogy/g03-create-genealogy.vue | 62 ++++----------------------------
|
||||
tests/g03-create-flow-contract.ps1 | 9 +++--
|
||||
tests/g03-document-flow-contract.ps1 | 4 ++-
|
||||
tests/g03-visual-states-contract.ps1 | 4 +--
|
||||
4 files changed, 17 insertions(+), 62 deletions(-)
|
||||
|
||||
## Diff
|
||||
diff --git a/pages/genealogy/g03-create-genealogy.vue b/pages/genealogy/g03-create-genealogy.vue
|
||||
index 2bc757d..f37cadc 100644
|
||||
--- a/pages/genealogy/g03-create-genealogy.vue
|
||||
+++ b/pages/genealogy/g03-create-genealogy.vue
|
||||
@@ -1,30 +1,20 @@
|
||||
<!-- 页面编号:G-03;用途:创建家谱与录入首代人物的同路由两步流程。 -->
|
||||
<template>
|
||||
<view class="flow-page">
|
||||
<GenealogyPageBackground />
|
||||
|
||||
- <view class="flow-header">
|
||||
- <view class="flow-header__content">
|
||||
- <view class="flow-header__back" @click="goBack">
|
||||
- <image
|
||||
- class="flow-header__back-icon"
|
||||
- src="/static/assets/foundation/transparent/chevron-right.png"
|
||||
- mode="aspectFit"
|
||||
- />
|
||||
- </view>
|
||||
- <text class="flow-header__title">{{
|
||||
- isAncestorStep ? "录入首代人物" : "创建家谱"
|
||||
- }}</text>
|
||||
- <view class="flow-header__side" />
|
||||
- </view>
|
||||
- </view>
|
||||
+ <PageHeader
|
||||
+ :title="isAncestorStep ? '录入首代人物' : '创建家谱'"
|
||||
+ custom-back
|
||||
+ @back="goBack"
|
||||
+ />
|
||||
|
||||
<view class="flow-content">
|
||||
<view class="create-flow-panel">
|
||||
<view v-if="!isAncestorStep" class="create-flow-panel__content">
|
||||
<view class="flow-step-label"><text>第一步 · 立谱信息</text></view>
|
||||
<text class="flow-heading">为家族立一部可传承的谱</text>
|
||||
<text class="flow-note"
|
||||
>家谱建立后可继续完善,名称与访问规则由创建者维护。</text
|
||||
>
|
||||
|
||||
@@ -241,20 +231,21 @@
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, onMounted, onUnmounted, reactive, ref } from "vue";
|
||||
import { onLoad, onShow } from "@dcloudio/uni-app";
|
||||
import GenealogyPageBackground from "@/components/GenealogyPageBackground.vue";
|
||||
+import PageHeader from "@/components/PageHeader.vue";
|
||||
|
||||
const currentStep = ref("create");
|
||||
const genealogyId = ref("");
|
||||
const isSubmitting = ref(false);
|
||||
const createState = ref("form");
|
||||
const ancestorState = ref("form");
|
||||
const duplicateReminderVisible = ref(false);
|
||||
const fieldErrors = reactive({
|
||||
surname: "",
|
||||
name: "",
|
||||
@@ -416,61 +407,20 @@ const enterOverview = () =>
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.flow-page {
|
||||
display: flex;
|
||||
min-height: 100vh;
|
||||
flex-direction: column;
|
||||
background: #f9f6ef;
|
||||
}
|
||||
|
||||
-.flow-header {
|
||||
- z-index: 3;
|
||||
- height: 112rpx;
|
||||
- overflow: hidden;
|
||||
- background: url("/static/assets/foundation/opaque/root-header-cinnabar.jpg")
|
||||
- center / 100% 100% no-repeat;
|
||||
-}
|
||||
-
|
||||
-.flow-header__content {
|
||||
- z-index: 1;
|
||||
- display: flex;
|
||||
- height: 100%;
|
||||
- align-items: center;
|
||||
- justify-content: space-between;
|
||||
- padding: 0 26rpx;
|
||||
- box-sizing: border-box;
|
||||
-}
|
||||
-
|
||||
-.flow-header__back,
|
||||
-.flow-header__side {
|
||||
- display: flex;
|
||||
- width: 72rpx;
|
||||
- align-items: center;
|
||||
-}
|
||||
-
|
||||
-.flow-header__back-icon {
|
||||
- width: 34rpx;
|
||||
- height: 34rpx;
|
||||
- transform: rotate(180deg);
|
||||
-}
|
||||
-
|
||||
-.flow-header__title {
|
||||
- flex: 1;
|
||||
- color: #ffe4a7;
|
||||
- font-family: "STKaiti", "KaiTi", serif;
|
||||
- font-size: 39rpx;
|
||||
- font-weight: 700;
|
||||
- letter-spacing: 4rpx;
|
||||
- text-align: center;
|
||||
-}
|
||||
-
|
||||
.flow-content {
|
||||
z-index: 2;
|
||||
padding: 24rpx 32rpx 48rpx;
|
||||
}
|
||||
|
||||
.create-flow-panel {
|
||||
min-height: 1000rpx;
|
||||
background: url("/static/assets/modules/genealogy/transparent/g01-empty-panel-frame.png")
|
||||
center / 100% 100% no-repeat;
|
||||
}
|
||||
diff --git a/tests/g03-create-flow-contract.ps1 b/tests/g03-create-flow-contract.ps1
|
||||
index 4accfd6..89923c2 100644
|
||||
--- a/tests/g03-create-flow-contract.ps1
|
||||
+++ b/tests/g03-create-flow-contract.ps1
|
||||
@@ -30,29 +30,32 @@ foreach ($required in @(
|
||||
'window.addEventListener("hashchange", syncFlowFromRoute)',
|
||||
'step=ancestor&genealogyId=',
|
||||
'const createState = ref("form");',
|
||||
'const ancestorState = ref("form");',
|
||||
'const fieldErrors = reactive({',
|
||||
'const duplicateReminderVisible = ref(false);',
|
||||
'class="duplicate-reminder-layer"',
|
||||
'if (isSubmitting.value) return;',
|
||||
'/pages/genealogy/g05-genealogy-overview?genealogyId=',
|
||||
'g01-empty-panel-frame.png',
|
||||
- 'root-header-cinnabar.jpg',
|
||||
'a01-scroll-primary-v3.png',
|
||||
'create-flow-panel',
|
||||
- 'flow-primary-action'
|
||||
+ 'flow-primary-action',
|
||||
+ 'import PageHeader from "@/components/PageHeader.vue";',
|
||||
+ '<PageHeader',
|
||||
+ 'custom-back',
|
||||
+ '@back="goBack"'
|
||||
)) {
|
||||
Assert-Contains -Content $g03 -Expected $required -Message "Missing G03 flow contract: $required"
|
||||
}
|
||||
|
||||
-foreach ($forbidden in @("from '@/utils/api.js'", 'appApi.', 'uni.showToast', 'uni.showModal', 'uni.showLoading', 'uni.showActionSheet', '/pages/tree/t01-tree-overview?genealogyId=')) {
|
||||
+foreach ($forbidden in @("from '@/utils/api.js'", 'appApi.', 'uni.showToast', 'uni.showModal', 'uni.showLoading', 'uni.showActionSheet', '/pages/tree/t01-tree-overview?genealogyId=', 'class="flow-header"', 'flow-header__back', 'root-header-cinnabar.jpg')) {
|
||||
if ($g03 -match [regex]::Escape($forbidden)) { throw "G03 retains forbidden implementation: $forbidden" }
|
||||
}
|
||||
|
||||
foreach ($className in @('create-flow-panel', 'flow-primary-action')) {
|
||||
Assert-NoCssSurface -Content $g03 -ClassName $className
|
||||
}
|
||||
|
||||
if (-not (Test-Path -LiteralPath $panelPath)) { throw 'G03 requires the accepted genealogy paper panel bitmap' }
|
||||
Add-Type -AssemblyName System.Drawing
|
||||
$panel = [System.Drawing.Bitmap]::FromFile($panelPath)
|
||||
diff --git a/tests/g03-document-flow-contract.ps1 b/tests/g03-document-flow-contract.ps1
|
||||
index 0249541..654dfa7 100644
|
||||
--- a/tests/g03-document-flow-contract.ps1
|
||||
+++ b/tests/g03-document-flow-contract.ps1
|
||||
@@ -1,15 +1,17 @@
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$root = Split-Path -Parent $PSScriptRoot
|
||||
$page = Get-Content -LiteralPath (Join-Path $root 'pages/genealogy/g03-create-genealogy.vue') -Raw -Encoding utf8
|
||||
$style = [regex]::Match($page, '(?s)<style[^>]*>(.*?)</style>').Groups[1].Value
|
||||
$positions = [regex]::Matches([regex]::Replace($style, '(?s)/\*.*?\*/', ''), '(?m)\bposition\s*:\s*([^;]+);')
|
||||
if ($positions.Count -ne 1 -or $positions[0].Groups[1].Value.Trim() -ne 'fixed') {
|
||||
throw "G03 must retain only its combined fixed dialog layer rule; found $($positions.Count) position declarations"
|
||||
}
|
||||
foreach ($required in @(
|
||||
- 'background: url("/static/assets/foundation/opaque/root-header-cinnabar.jpg")',
|
||||
'background: url("/static/assets/modules/genealogy/transparent/g01-empty-panel-frame.png")',
|
||||
'background: url("/static/assets/modules/auth/transparent/a01-scroll-dialog-v3.png")',
|
||||
'background: url("/static/assets/foundation/transparent/a01-scroll-primary-v3.png")'
|
||||
)) { if ($page -notmatch [regex]::Escape($required)) { throw "G03 is missing real asset background: $required" } }
|
||||
+foreach ($forbidden in @('flow-header', 'root-header-cinnabar.jpg')) {
|
||||
+ if ($page -match [regex]::Escape($forbidden)) { throw "G03 must not retain private header token: $forbidden" }
|
||||
+}
|
||||
Write-Output 'G03-DOCUMENT-FLOW-CONTRACT PASS'
|
||||
diff --git a/tests/g03-visual-states-contract.ps1 b/tests/g03-visual-states-contract.ps1
|
||||
index 5342acb..d539bc5 100644
|
||||
--- a/tests/g03-visual-states-contract.ps1
|
||||
+++ b/tests/g03-visual-states-contract.ps1
|
||||
@@ -2,22 +2,22 @@ $ErrorActionPreference = 'Stop'
|
||||
|
||||
$root = Split-Path -Parent $PSScriptRoot
|
||||
$g03 = Get-Content -LiteralPath (Join-Path $root 'pages/genealogy/g03-create-genealogy.vue') -Raw -Encoding utf8
|
||||
|
||||
function Assert-Match {
|
||||
param([string]$Content, [string]$Pattern, [string]$Message)
|
||||
if ($Content -notmatch $Pattern) { throw $Message }
|
||||
}
|
||||
|
||||
if ($g03 -match '<text>返回</text>') { throw 'G03 header must use only the approved image back arrow' }
|
||||
-Assert-Match -Content $g03 -Pattern '(?s)\.flow-header__back,\s*\.flow-header__side\s*\{[^}]*width:\s*72rpx;' -Message 'G03 header side slots must preserve centered title after removing back text'
|
||||
-Assert-Match -Content $g03 -Pattern '(?s)\.flow-header__back-icon\s*\{[^}]*width:\s*34rpx;[^}]*height:\s*34rpx;' -Message 'G03 back arrow must use the approved visible size'
|
||||
+Assert-Match -Content $g03 -Pattern '(?s)<PageHeader\s+:title="isAncestorStep \? ''\u5F55\u5165\u9996\u4EE3\u4EBA\u7269'' : ''\u521B\u5EFA\u5BB6\u8C31''"\s+custom-back\s+@back="goBack"\s*/>' -Message 'G03 must use PageHeader with its dynamic title and custom back handler'
|
||||
+if ($g03 -match 'flow-header|flow-header__back|flow-header__title') { throw 'G03 must not retain a private header implementation' }
|
||||
Assert-Match -Content $g03 -Pattern '(?s)\.flow-note,\s*\.flow-rule__note\s*\{[^}]*font-size:\s*25rpx;' -Message 'G03 guidance copy must use the approved readable size'
|
||||
Assert-Match -Content $g03 -Pattern '(?s)\.flow-rule__option\s*\{[^}]*font-size:\s*23rpx;' -Message 'G03 visibility options must use the approved readable size'
|
||||
Assert-Match -Content $g03 -Pattern '(?s)\.field-error\s*\{[^}]*font-size:\s*24rpx;[^}]*line-height:\s*34rpx;' -Message 'G03 field errors must use the approved readable size'
|
||||
Assert-Match -Content $g03 -Pattern '(?s)\.flow-error\s*\{[^}]*font-size:\s*25rpx;[^}]*line-height:\s*36rpx;' -Message 'G03 submit errors must use the approved readable size'
|
||||
|
||||
foreach ($asset in @('a01-scroll-dialog-v3.png', 'a01-scroll-primary-v3.png', 'a01-scroll-secondary-v3.png')) {
|
||||
if ($g03 -notmatch [regex]::Escape($asset)) { throw "G03 must retain approved shared asset: $asset" }
|
||||
}
|
||||
|
||||
Write-Output 'G03-VISUAL-STATES-CONTRACT PASS'
|
||||
@@ -1,7 +0,0 @@
|
||||
# Review package: a0d19f4..a0d19f4
|
||||
|
||||
## Commits
|
||||
|
||||
## Files changed
|
||||
|
||||
## Diff
|
||||
@@ -1,116 +0,0 @@
|
||||
### Task 1: 为公共 PageHeader 建立安全区与返回契约
|
||||
|
||||
**Files:**
|
||||
- Modify: `tests/shared-component-document-flow-contract.ps1`
|
||||
- Modify: `tests/shared-interaction-accessibility-contract.ps1`
|
||||
- Modify: `components/PageHeader.vue`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: uni-app 提供的 `var(--status-bar-height, 0px)`、`getCurrentPages()`、`uni.navigateBack()`、`uni.reLaunch()`。
|
||||
- Produces: `customBack: Boolean = false` 属性和 `back` 事件;普通页头及占位槽均使用安全区总高度。
|
||||
|
||||
- [ ] **Step 1: 写入普通页头安全区的失败契约**
|
||||
|
||||
在 `tests/shared-component-document-flow-contract.ps1` 的 PageHeader 断言中加入:
|
||||
|
||||
```powershell
|
||||
Assert-Match $header '(?s)\.page-header-slot\s*\{[^}]*height:\s*calc\(104rpx \+ var\(--status-bar-height, 0px\)\);' 'PageHeader secondary slot must reserve status bar plus 104rpx content height'
|
||||
Assert-Match $header '(?s)\.page-header\s*\{[^}]*height:\s*calc\(104rpx \+ var\(--status-bar-height, 0px\)\);[^}]*padding-top:\s*var\(--status-bar-height, 0px\);' 'PageHeader secondary header must place its 104rpx content below the status bar'
|
||||
Assert-Match $header '(?s)\.page-header-slot--root\s*\{[^}]*height:\s*calc\(124rpx \+ var\(--status-bar-height, 0px\)\);' 'PageHeader root slot height must remain unchanged'
|
||||
Assert-Match $header '(?s)\.page-header--root\s*\{[^}]*height:\s*calc\(124rpx \+ var\(--status-bar-height, 0px\)\);' 'PageHeader root height must remain unchanged'
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 写入自定义返回的失败契约**
|
||||
|
||||
在 `tests/shared-interaction-accessibility-contract.ps1` 的 PageHeader 检查中加入:
|
||||
|
||||
```powershell
|
||||
foreach ($token in @(
|
||||
'customBack: { type: Boolean, default: false }',
|
||||
'const emit = defineEmits(["brand", "notice", "action", "back"]);',
|
||||
'if (props.customBack) {',
|
||||
'emit("back");',
|
||||
'uni.navigateBack();',
|
||||
'uni.reLaunch({ url: props.fallbackUrl });'
|
||||
)) {
|
||||
if (-not $header.Contains($token)) { throw "PageHeader back contract missing: $token" }
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 运行聚焦契约并确认 RED**
|
||||
|
||||
Run:
|
||||
|
||||
```powershell
|
||||
& powershell -ExecutionPolicy Bypass -File tests/shared-component-document-flow-contract.ps1
|
||||
& powershell -ExecutionPolicy Bypass -File tests/shared-interaction-accessibility-contract.ps1
|
||||
```
|
||||
|
||||
Expected: 两项均 FAIL,分别指出普通页头仍为固定 `104rpx`、`customBack` 合同不存在。
|
||||
|
||||
- [ ] **Step 4: 实现最小公共组件改动**
|
||||
|
||||
在 `components/PageHeader.vue` 中补充属性和事件:
|
||||
|
||||
```js
|
||||
const props = defineProps({
|
||||
title: { type: String, required: true },
|
||||
action: { type: String, default: "" },
|
||||
root: { type: Boolean, default: false },
|
||||
unreadCount: { type: Number, default: 0 },
|
||||
fallbackUrl: { type: String, default: "/pages/genealogy/g01-my-genealogies" },
|
||||
customBack: { type: Boolean, default: false },
|
||||
});
|
||||
|
||||
const emit = defineEmits(["brand", "notice", "action", "back"]);
|
||||
|
||||
const goBack = () => {
|
||||
if (props.customBack) {
|
||||
emit("back");
|
||||
return;
|
||||
}
|
||||
const stack = getCurrentPages();
|
||||
if (stack.length > 1) {
|
||||
uni.navigateBack();
|
||||
return;
|
||||
}
|
||||
uni.reLaunch({ url: props.fallbackUrl });
|
||||
};
|
||||
```
|
||||
|
||||
把普通变体的尺寸改为:
|
||||
|
||||
```scss
|
||||
.page-header-slot {
|
||||
height: calc(104rpx + var(--status-bar-height, 0px));
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
height: calc(104rpx + var(--status-bar-height, 0px));
|
||||
padding: 0 24rpx;
|
||||
padding-top: var(--status-bar-height, 0px);
|
||||
}
|
||||
```
|
||||
|
||||
保留 `.page-header-slot--root` 与 `.page-header--root` 的现有 `124rpx` 规则不变。
|
||||
|
||||
- [ ] **Step 5: 运行聚焦契约并确认 GREEN**
|
||||
|
||||
Run:
|
||||
|
||||
```powershell
|
||||
& powershell -ExecutionPolicy Bypass -File tests/shared-component-document-flow-contract.ps1
|
||||
& powershell -ExecutionPolicy Bypass -File tests/shared-interaction-accessibility-contract.ps1
|
||||
git diff --check
|
||||
```
|
||||
|
||||
Expected: 两项输出 `PASS`,`git diff --check` 退出码为 0。
|
||||
|
||||
- [ ] **Step 6: 提交公共组件改动**
|
||||
|
||||
```powershell
|
||||
git add components/PageHeader.vue tests/shared-component-document-flow-contract.ps1 tests/shared-interaction-accessibility-contract.ps1
|
||||
git commit -m "fix: adapt shared page header to safe area"
|
||||
```
|
||||
|
||||
@@ -1,56 +0,0 @@
|
||||
# Task 1 Report
|
||||
|
||||
Status: DONE
|
||||
|
||||
## Changed files
|
||||
|
||||
- `components/PageHeader.vue`
|
||||
- `tests/shared-component-document-flow-contract.ps1`
|
||||
- `tests/shared-interaction-accessibility-contract.ps1`
|
||||
|
||||
## RED
|
||||
|
||||
Command:
|
||||
|
||||
```powershell
|
||||
& powershell -ExecutionPolicy Bypass -File tests/shared-component-document-flow-contract.ps1
|
||||
& powershell -ExecutionPolicy Bypass -File tests/shared-interaction-accessibility-contract.ps1
|
||||
```
|
||||
|
||||
Failure evidence:
|
||||
|
||||
- `PageHeader secondary slot must reserve status bar plus 104rpx content height`
|
||||
- `PageHeader back contract missing: customBack: { type: Boolean, default: false }`
|
||||
|
||||
Both contract scripts exited with code `1`, for the expected missing behavior.
|
||||
|
||||
## GREEN
|
||||
|
||||
Command:
|
||||
|
||||
```powershell
|
||||
& powershell -ExecutionPolicy Bypass -File tests/shared-component-document-flow-contract.ps1
|
||||
& powershell -ExecutionPolicy Bypass -File tests/shared-interaction-accessibility-contract.ps1
|
||||
git diff --check
|
||||
```
|
||||
|
||||
Result summary:
|
||||
|
||||
- `SHARED-COMPONENT-DOCUMENT-FLOW-CONTRACT PASS`
|
||||
- `SHARED-INTERACTION-ACCESSIBILITY-CONTRACT PASS`
|
||||
- `git diff --check` exited `0`
|
||||
|
||||
## Commit
|
||||
|
||||
`2f22df6 fix: adapt shared page header to safe area`
|
||||
|
||||
## Self-review
|
||||
|
||||
- Normal PageHeader slot and header use `calc(104rpx + var(--status-bar-height, 0px))`; the header applies status-bar top padding.
|
||||
- Root slot and header retain their `calc(124rpx + var(--status-bar-height, 0px))` rules.
|
||||
- `customBack` emits `back` and returns before navigation; normal navigation retains stack-aware back and fallback relaunch behavior.
|
||||
- The added contract tests cover the required exact public interface and layout declarations.
|
||||
|
||||
## Concerns
|
||||
|
||||
None. Git emitted existing line-ending conversion warnings during checks and commit, but all required checks passed.
|
||||
@@ -1,98 +0,0 @@
|
||||
### Task 2: 用公共 PageHeader 替换 G03 自建页头
|
||||
|
||||
**Files:**
|
||||
- Modify: `tests/g03-visual-states-contract.ps1`
|
||||
- Modify: `tests/g03-create-flow-contract.ps1`
|
||||
- Modify: `tests/g03-document-flow-contract.ps1`
|
||||
- Modify: `pages/genealogy/g03-create-genealogy.vue`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: Task 1 的 `<PageHeader :title="String" custom-back @back="Function" />`。
|
||||
- Produces: G03 动态标题 `isAncestorStep ? "录入首代人物" : "创建家谱"`,以及现有 `goBack()` 对第一、第二步的业务分流。
|
||||
|
||||
- [ ] **Step 1: 将 G03 契约改成公共页头要求**
|
||||
|
||||
在 `tests/g03-visual-states-contract.ps1` 删除 `.flow-header__back`、`.flow-header__side` 和 `.flow-header__back-icon` 尺寸断言,改为:
|
||||
|
||||
```powershell
|
||||
Assert-Match -Content $g03 -Pattern '(?s)<PageHeader\s+:title="isAncestorStep \? .录入首代人物. : .创建家谱."\s+custom-back\s+@back="goBack"\s*/>' -Message 'G03 must use PageHeader with its dynamic title and custom back handler'
|
||||
if ($g03 -match 'flow-header|flow-header__back|flow-header__title') { throw 'G03 must not retain a private header implementation' }
|
||||
```
|
||||
|
||||
在 `tests/g03-create-flow-contract.ps1` 的 required 项中加入:
|
||||
|
||||
```powershell
|
||||
'import PageHeader from "@/components/PageHeader.vue";',
|
||||
'<PageHeader',
|
||||
'custom-back',
|
||||
'@back="goBack"'
|
||||
```
|
||||
|
||||
并从 required 项删除 `'root-header-cinnabar.jpg'`;在 forbidden 项加入:
|
||||
|
||||
```powershell
|
||||
'class="flow-header"',
|
||||
'flow-header__back',
|
||||
'root-header-cinnabar.jpg'
|
||||
```
|
||||
|
||||
在 `tests/g03-document-flow-contract.ps1` 删除页头背景的 required 项,并加入:
|
||||
|
||||
```powershell
|
||||
foreach ($forbidden in @('flow-header', 'root-header-cinnabar.jpg')) {
|
||||
if ($page -match [regex]::Escape($forbidden)) { throw "G03 must not retain private header token: $forbidden" }
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 运行 G03 聚焦契约并确认 RED**
|
||||
|
||||
Run:
|
||||
|
||||
```powershell
|
||||
& powershell -ExecutionPolicy Bypass -File tests/g03-visual-states-contract.ps1
|
||||
& powershell -ExecutionPolicy Bypass -File tests/g03-create-flow-contract.ps1
|
||||
& powershell -ExecutionPolicy Bypass -File tests/g03-document-flow-contract.ps1
|
||||
```
|
||||
|
||||
Expected: 契约因 G03 仍含私有 `flow-header` 且尚未导入 `PageHeader` 而 FAIL。
|
||||
|
||||
- [ ] **Step 3: 替换 G03 页头结构并删除私有样式**
|
||||
|
||||
在 `pages/genealogy/g03-create-genealogy.vue` 中把整个 `.flow-header` 模板替换为:
|
||||
|
||||
```vue
|
||||
<PageHeader
|
||||
:title="isAncestorStep ? '录入首代人物' : '创建家谱'"
|
||||
custom-back
|
||||
@back="goBack"
|
||||
/>
|
||||
```
|
||||
|
||||
在 `<script setup>` 中加入:
|
||||
|
||||
```js
|
||||
import PageHeader from "@/components/PageHeader.vue";
|
||||
```
|
||||
|
||||
完整删除 `.flow-header`、`.flow-header__content`、`.flow-header__back`、`.flow-header__side`、`.flow-header__back-icon`、`.flow-header__title` 六组样式。保留现有 `goBack()`:第二步 `redirectTo` 第一页,第一步 `navigateBack`。
|
||||
|
||||
- [ ] **Step 4: 运行 G03 聚焦契约并确认 GREEN**
|
||||
|
||||
Run:
|
||||
|
||||
```powershell
|
||||
& powershell -ExecutionPolicy Bypass -File tests/g03-visual-states-contract.ps1
|
||||
& powershell -ExecutionPolicy Bypass -File tests/g03-create-flow-contract.ps1
|
||||
& powershell -ExecutionPolicy Bypass -File tests/g03-document-flow-contract.ps1
|
||||
git diff --check
|
||||
```
|
||||
|
||||
Expected: 三项输出 `PASS`,`git diff --check` 退出码为 0。
|
||||
|
||||
- [ ] **Step 5: 提交 G03 接入改动**
|
||||
|
||||
```powershell
|
||||
git add pages/genealogy/g03-create-genealogy.vue tests/g03-visual-states-contract.ps1 tests/g03-create-flow-contract.ps1 tests/g03-document-flow-contract.ps1
|
||||
git commit -m "refactor: reuse shared header on G03"
|
||||
```
|
||||
|
||||
@@ -1,77 +0,0 @@
|
||||
# Task 2 Report: Reuse shared PageHeader on G03
|
||||
|
||||
## Status
|
||||
|
||||
Complete. Commit: `9362c47` (`refactor: reuse shared header on G03`).
|
||||
|
||||
## Changed files
|
||||
|
||||
- `pages/genealogy/g03-create-genealogy.vue`
|
||||
- `tests/g03-visual-states-contract.ps1`
|
||||
- `tests/g03-create-flow-contract.ps1`
|
||||
- `tests/g03-document-flow-contract.ps1`
|
||||
|
||||
## RED
|
||||
|
||||
Command:
|
||||
|
||||
```powershell
|
||||
& powershell -ExecutionPolicy Bypass -File tests/g03-visual-states-contract.ps1
|
||||
& powershell -ExecutionPolicy Bypass -File tests/g03-create-flow-contract.ps1
|
||||
& powershell -ExecutionPolicy Bypass -File tests/g03-document-flow-contract.ps1
|
||||
```
|
||||
|
||||
Exit code: `1`.
|
||||
|
||||
Failure evidence:
|
||||
|
||||
- `G03 must use PageHeader with its dynamic title and custom back handler`
|
||||
- `Missing G03 flow contract: import PageHeader from "@/components/PageHeader.vue";`
|
||||
- `G03 must not retain private header token: flow-header`
|
||||
|
||||
## GREEN
|
||||
|
||||
Command:
|
||||
|
||||
```powershell
|
||||
& powershell -ExecutionPolicy Bypass -File tests/g03-visual-states-contract.ps1
|
||||
& powershell -ExecutionPolicy Bypass -File tests/g03-create-flow-contract.ps1
|
||||
& powershell -ExecutionPolicy Bypass -File tests/g03-document-flow-contract.ps1
|
||||
git diff --check
|
||||
```
|
||||
|
||||
Result: exit code `0`; all three focused contracts emitted `PASS`, and `git diff --check` exited cleanly. Git emitted only LF-to-CRLF informational warnings for the four changed files.
|
||||
|
||||
## Self-review
|
||||
|
||||
- Replaced only the private G03 header with `PageHeader`, using the required dynamic title, `custom-back`, and existing `goBack` handler.
|
||||
- Added the single shared-header import.
|
||||
- Removed all six specified private header style groups and the obsolete header asset reference.
|
||||
- Preserved G03 forms, dialogs, actions, backgrounds, data, and the existing two-step `goBack()` branching unchanged.
|
||||
- Updated contracts to require the shared header and reject private header tokens.
|
||||
|
||||
## Concerns
|
||||
|
||||
- The known App-Plus `step=ancestor` route-parameter issue was deliberately not modified, as required.
|
||||
- No remaining task-specific concern.
|
||||
|
||||
## Follow-up: strict dynamic-title quotes
|
||||
|
||||
Fixed `tests/g03-visual-states-contract.ps1` so its `PageHeader` title regex requires the Vue expression's internal single quotes. In the PowerShell single-quoted pattern, each required single quote is written as `''`.
|
||||
|
||||
Regression commands:
|
||||
|
||||
```powershell
|
||||
# RED: old pattern incorrectly matched a candidate with double-quoted internal titles.
|
||||
$ErrorActionPreference = 'Stop'; $pattern = '(?s)<PageHeader\s+:title="isAncestorStep \? .\u5F55\u5165\u9996\u4EE3\u4EBA\u7269. : .\u521B\u5EFA\u5BB6\u8C31."\s+custom-back\s+@back="goBack"\s*/>'; $title1 = -join [char[]]@(0x5F55, 0x5165, 0x9996, 0x4EE3, 0x4EBA, 0x7269); $title2 = -join [char[]]@(0x521B, 0x5EFA, 0x5BB6, 0x8C31); $invalid = '<PageHeader :title="isAncestorStep ? "' + $title1 + '" : "' + $title2 + '"" custom-back @back="goBack" />'; if ($invalid -match $pattern) { throw 'Dynamic-title regex incorrectly matches double-quoted internal titles' }
|
||||
|
||||
# GREEN and focused verification
|
||||
& powershell -ExecutionPolicy Bypass -File tests/g03-visual-states-contract.ps1
|
||||
& powershell -ExecutionPolicy Bypass -File tests/g03-create-flow-contract.ps1
|
||||
& powershell -ExecutionPolicy Bypass -File tests/g03-document-flow-contract.ps1
|
||||
git diff --check
|
||||
```
|
||||
|
||||
Output summary: the RED probe failed with `Dynamic-title regex incorrectly matches double-quoted internal titles`; after the fix, the strict-quote probe and all three G03 contracts emitted `PASS`, and `git diff --check` exited `0`.
|
||||
|
||||
Commit: `a0d19f4` (`test: tighten G03 shared header contract`).
|
||||
@@ -1,54 +0,0 @@
|
||||
### Task 3: 自动回归公共页头消费者与响应式边界
|
||||
|
||||
**Files:**
|
||||
- Verify: `components/PageHeader.vue`
|
||||
- Verify: `pages/genealogy/g03-create-genealogy.vue`
|
||||
- Verify: `pages/genealogy/g06-search-genealogies.vue`
|
||||
- Verify: `pages/genealogy/g01-my-genealogies.vue`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: Task 1 与 Task 2 的公共页头合同。
|
||||
- Produces: 公共组件、G03、G06 与 G01 无静态合同回退的验证记录。
|
||||
|
||||
- [ ] **Step 1: 运行全部聚焦合同**
|
||||
|
||||
Run:
|
||||
|
||||
```powershell
|
||||
$tests = @(
|
||||
'tests/shared-component-document-flow-contract.ps1',
|
||||
'tests/shared-interaction-accessibility-contract.ps1',
|
||||
'tests/g03-visual-states-contract.ps1',
|
||||
'tests/g03-create-flow-contract.ps1',
|
||||
'tests/g03-document-flow-contract.ps1',
|
||||
'tests/g06-search-flow-contract.ps1',
|
||||
'tests/g06-document-flow-contract.ps1',
|
||||
'tests/g01-visual-contract.ps1'
|
||||
)
|
||||
foreach ($test in $tests) { & powershell -ExecutionPolicy Bypass -File $test }
|
||||
```
|
||||
|
||||
Expected: 八项均输出各自 `PASS`,PowerShell 退出码为 0。
|
||||
|
||||
- [ ] **Step 2: 运行现有 H5 逻辑和宽度辅助检查**
|
||||
|
||||
在本地 H5 服务已运行时执行:
|
||||
|
||||
```powershell
|
||||
node tests/g03-create-flow-runtime-smoke.js http://localhost:5173
|
||||
node tests/g06-search-flow-runtime-smoke.js http://localhost:5173
|
||||
```
|
||||
|
||||
Expected: 分别输出 `G03-CREATE-FLOW-RUNTIME-SMOKE PASS`、`G06-SEARCH-FLOW-RUNTIME-SMOKE PASS`;320、360、412px 视口无横向溢出。若本地服务端口不同,只替换 URL,不改变测试内容。
|
||||
|
||||
- [ ] **Step 3: 检查最终差异**
|
||||
|
||||
Run:
|
||||
|
||||
```powershell
|
||||
git diff --check
|
||||
git status --short
|
||||
```
|
||||
|
||||
Expected: `git diff --check` 退出码为 0;工作区只允许存在本计划明确列出的验证证据或尚未提交的计划文件。
|
||||
|
||||
@@ -1,66 +0,0 @@
|
||||
# Task 3 Verification Report
|
||||
|
||||
## Status
|
||||
|
||||
PASS. All eight focused static contracts and both available H5 runtime smoke checks passed. No source or test files were modified by this task.
|
||||
|
||||
## Focused contract checks
|
||||
|
||||
Command executed:
|
||||
|
||||
```powershell
|
||||
$tests = @(
|
||||
'tests/shared-component-document-flow-contract.ps1',
|
||||
'tests/shared-interaction-accessibility-contract.ps1',
|
||||
'tests/g03-visual-states-contract.ps1',
|
||||
'tests/g03-create-flow-contract.ps1',
|
||||
'tests/g03-document-flow-contract.ps1',
|
||||
'tests/g06-search-flow-contract.ps1',
|
||||
'tests/g06-document-flow-contract.ps1',
|
||||
'tests/g01-visual-contract.ps1'
|
||||
)
|
||||
foreach ($test in $tests) { & powershell -ExecutionPolicy Bypass -File $test }
|
||||
```
|
||||
|
||||
| Test | Exit code | Output summary |
|
||||
| --- | ---: | --- |
|
||||
| `tests/shared-component-document-flow-contract.ps1` | 0 | `SHARED-COMPONENT-DOCUMENT-FLOW-CONTRACT PASS` |
|
||||
| `tests/shared-interaction-accessibility-contract.ps1` | 0 | `SHARED-INTERACTION-ACCESSIBILITY-CONTRACT PASS` |
|
||||
| `tests/g03-visual-states-contract.ps1` | 0 | `G03-VISUAL-STATES-CONTRACT PASS` |
|
||||
| `tests/g03-create-flow-contract.ps1` | 0 | `G03-CREATE-FLOW-CONTRACT PASS` |
|
||||
| `tests/g03-document-flow-contract.ps1` | 0 | `G03-DOCUMENT-FLOW-CONTRACT PASS` |
|
||||
| `tests/g06-search-flow-contract.ps1` | 0 | `G06-SEARCH-FLOW-CONTRACT PASS` |
|
||||
| `tests/g06-document-flow-contract.ps1` | 0 | `G06-DOCUMENT-FLOW-CONTRACT PASS` |
|
||||
| `tests/g01-visual-contract.ps1` | 0 | `PASS G-01 visual contract` |
|
||||
|
||||
Aggregate command exit code: `0`.
|
||||
|
||||
## H5 runtime smoke checks
|
||||
|
||||
Availability probe command:
|
||||
|
||||
```powershell
|
||||
Invoke-WebRequest -UseBasicParsing -Uri 'http://localhost:5173' -TimeoutSec 5
|
||||
```
|
||||
|
||||
Result: HTTP `200`; therefore an existing local H5 service was available at `http://localhost:5173`. No service was started or installed. (`Get-NetTCPConnection -LocalPort 5173` did not list a listener, but the HTTP probe and both browser-driven smoke tests succeeded.)
|
||||
|
||||
| Command | Exit code | Output summary |
|
||||
| --- | ---: | --- |
|
||||
| `node tests/g03-create-flow-runtime-smoke.js http://localhost:5173` | 0 | `G03-CREATE-FLOW-RUNTIME-SMOKE PASS` |
|
||||
| `node tests/g06-search-flow-runtime-smoke.js http://localhost:5173` | 0 | `G06-SEARCH-FLOW-RUNTIME-SMOKE PASS` |
|
||||
|
||||
## Final worktree checks
|
||||
|
||||
| Command | Exit code | Output summary |
|
||||
| --- | ---: | --- |
|
||||
| `git diff --check` | 0 | No output; no whitespace errors. |
|
||||
| `git status --short` | 0 | `?? .superpowers/` only. |
|
||||
|
||||
`git status --short` also emitted warnings that `C:\\Users\\Administrator/.config/git/ignore` could not be accessed due to permission denial; this did not affect its exit code or the reported worktree status.
|
||||
|
||||
## Worktree classification and concerns
|
||||
|
||||
The only untracked tree is `.superpowers/`, containing task briefs, earlier task reports/review diffs, and this Task 3 report. It is verification scratch evidence rather than source or test changes. No source-code worktree changes were reported.
|
||||
|
||||
Concern: the port-listener probe did not enumerate `5173` despite a successful HTTP `200`; runtime results confirm the H5 target was nevertheless reachable and exercised successfully.
|
||||
@@ -1,46 +0,0 @@
|
||||
### Task 4: MuMu 原生视觉验收门
|
||||
|
||||
**Files:**
|
||||
- Create: `tmp/G03-shared-header-mumu.png`
|
||||
- Create: `tmp/G06-shared-header-mumu.png`
|
||||
- Create: `tmp/G01-root-header-regression-mumu.png`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: HBuilderX 已同步到 MuMu 的最新 Android 包与设备 `emulator-5554`。
|
||||
- Produces: G03、G06、G01 三张原生截图及用户逐页结论。
|
||||
|
||||
- [ ] **Step 1: 确认设备尺寸与密度**
|
||||
|
||||
Run:
|
||||
|
||||
```powershell
|
||||
$adb = 'C:\Users\Administrator\Desktop\HBuilderX\plugins\launcher-tools\tools\adbs\adb.exe'
|
||||
& $adb devices
|
||||
& $adb -s emulator-5554 shell wm size
|
||||
& $adb -s emulator-5554 shell wm density
|
||||
```
|
||||
|
||||
Expected: `emulator-5554` 为 `device`,并记录当前 MuMu 的物理尺寸与密度。
|
||||
|
||||
- [ ] **Step 2: 捕获 G03、G06、G01 原生截图**
|
||||
|
||||
每次人工导航到指定页面后执行同一组命令,仅替换文件名:
|
||||
|
||||
```powershell
|
||||
& $adb -s emulator-5554 shell screencap -p /sdcard/G03-shared-header-mumu.png
|
||||
& $adb -s emulator-5554 pull /sdcard/G03-shared-header-mumu.png tmp/G03-shared-header-mumu.png
|
||||
```
|
||||
|
||||
G06 使用 `G06-shared-header-mumu.png`,G01 使用 `G01-root-header-regression-mumu.png`。
|
||||
|
||||
- [ ] **Step 3: 按页面验收并向用户展示**
|
||||
|
||||
检查:
|
||||
|
||||
```text
|
||||
G03:返回按钮和“创建家谱”完整位于系统状态栏下方,正文紧随占位槽,无裁切或横向溢出。
|
||||
G06:公共二级页头获得相同安全区,正文没有被推入页头或遮挡。
|
||||
G01:根页头高度、logo、标题和操作区与已放行状态一致。
|
||||
```
|
||||
|
||||
只有用户明确确认三页后,才能把本次公共页头改动报告为视觉通过。G03 原生第二步仍因既有路由参数问题单独记为未通过,不得用本次页头结论覆盖。
|
||||
@@ -1,41 +0,0 @@
|
||||
{
|
||||
"schemaVersion": 2,
|
||||
"page": "A01",
|
||||
"runtime": {
|
||||
"url": "http://localhost:5173/#/pages/auth/a01-entry",
|
||||
"chromePort": 9222
|
||||
},
|
||||
"assets": [
|
||||
{
|
||||
"id": "a01-primary-button-v2",
|
||||
"assetClass": "fixed-bitmap",
|
||||
"source": "static/assets/foundation/opaque/a01-primary-button.png",
|
||||
"output": "static/assets/foundation/transparent/a01-primary-button-v2.png",
|
||||
"logicalSlot": { "widthRpx": 622, "heightRpx": 92 },
|
||||
"outputPixels": { "width": 1866, "height": 276 },
|
||||
"render": { "scalePolicy": "uniform-only", "uniMode": "aspectFit", "allowDistortion": false },
|
||||
"alpha": { "required": true, "transparentOuterPadding": 12, "cornerMaxAlpha": 0 },
|
||||
"edge": { "forbidChromaResidue": true, "forbidLightFringe": true, "premultipliedAlphaCheck": true },
|
||||
"quality": { "maxBytes": 1000000, "symmetry": "horizontal", "colorSpace": "sRGB" },
|
||||
"processing": { "trim": 8, "capWidth": 180 },
|
||||
"runtime": { "selector": ".login-submit", "imageSelector": ".button-skin img" },
|
||||
"consumers": ["pages/auth/a01-entry.vue"]
|
||||
},
|
||||
{
|
||||
"id": "a01-secondary-button-v2",
|
||||
"assetClass": "fixed-bitmap",
|
||||
"source": "static/assets/foundation/opaque/a01-secondary-button.png",
|
||||
"output": "static/assets/foundation/transparent/a01-secondary-button-v2.png",
|
||||
"logicalSlot": { "widthRpx": 622, "heightRpx": 100 },
|
||||
"outputPixels": { "width": 1866, "height": 300 },
|
||||
"render": { "scalePolicy": "uniform-only", "uniMode": "aspectFit", "allowDistortion": false },
|
||||
"alpha": { "required": true, "transparentOuterPadding": 12, "cornerMaxAlpha": 0 },
|
||||
"edge": { "forbidChromaResidue": true, "forbidLightFringe": true, "premultipliedAlphaCheck": true },
|
||||
"quality": { "maxBytes": 1200000, "symmetry": "horizontal", "colorSpace": "sRGB" },
|
||||
"processing": { "trim": 12, "capWidth": 200 },
|
||||
"runtime": { "selector": ".wechat-login", "imageSelector": ".button-skin img" },
|
||||
"consumers": ["pages/auth/a01-entry.vue"]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,70 +0,0 @@
|
||||
{
|
||||
"schemaVersion": 2,
|
||||
"page": "A01",
|
||||
"runtime": {
|
||||
"url": "http://localhost:5173/#/pages/auth/a01-entry",
|
||||
"chromePort": 9222
|
||||
},
|
||||
"assets": [
|
||||
{
|
||||
"id": "a01-scroll-primary-v3",
|
||||
"assetClass": "fixed-bitmap",
|
||||
"source": "docs/design/assets/a01-vnext/masters/a01-scroll-primary-master-v3.png",
|
||||
"output": "static/assets/foundation/transparent/a01-scroll-primary-v3.png",
|
||||
"logicalSlot": { "widthRpx": 622, "heightRpx": 92 },
|
||||
"outputPixels": { "width": 1866, "height": 276 },
|
||||
"render": { "scalePolicy": "uniform-only", "uniMode": "aspectFit", "allowDistortion": false },
|
||||
"alpha": { "required": true, "transparentOuterPadding": 6, "cornerMaxAlpha": 0 },
|
||||
"edge": { "forbidChromaResidue": true, "forbidLightFringe": true, "premultipliedAlphaCheck": true },
|
||||
"quality": { "maxBytes": 1800000, "symmetry": "horizontal", "colorSpace": "sRGB" },
|
||||
"processing": { "trim": 1, "capWidth": 380, "keyColor": "#00FF00", "keyTolerance": 96 },
|
||||
"runtime": { "selector": ".login-submit", "imageSelector": ".button-skin img" },
|
||||
"consumers": ["pages/auth/a01-entry.vue"]
|
||||
},
|
||||
{
|
||||
"id": "a01-scroll-secondary-v3",
|
||||
"assetClass": "fixed-bitmap",
|
||||
"source": "docs/design/assets/a01-vnext/masters/a01-scroll-secondary-master-v3.png",
|
||||
"output": "static/assets/foundation/transparent/a01-scroll-secondary-v3.png",
|
||||
"logicalSlot": { "widthRpx": 622, "heightRpx": 100 },
|
||||
"outputPixels": { "width": 1866, "height": 300 },
|
||||
"render": { "scalePolicy": "uniform-only", "uniMode": "aspectFit", "allowDistortion": false },
|
||||
"alpha": { "required": true, "transparentOuterPadding": 6, "cornerMaxAlpha": 0 },
|
||||
"edge": { "forbidChromaResidue": true, "forbidLightFringe": true, "premultipliedAlphaCheck": true },
|
||||
"quality": { "maxBytes": 1800000, "symmetry": "horizontal", "colorSpace": "sRGB" },
|
||||
"processing": { "trim": 1, "capWidth": 380, "keyColor": "#00FF00", "keyTolerance": 96 },
|
||||
"runtime": { "selector": ".wechat-login", "imageSelector": ".button-skin img" },
|
||||
"consumers": ["pages/auth/a01-entry.vue"]
|
||||
},
|
||||
{
|
||||
"id": "a01-scroll-toast-v3",
|
||||
"assetClass": "nine-slice",
|
||||
"source": "docs/design/assets/a01-vnext/masters/a01-scroll-toast-master-v3.png",
|
||||
"output": "static/assets/foundation/transparent/a01-scroll-toast-v3.png",
|
||||
"logicalSlot": { "widthRpx": 590, "heightRpx": 82 },
|
||||
"outputPixels": { "width": 1770, "height": 246 },
|
||||
"render": { "scalePolicy": "nine-slice", "uniMode": "aspectFit", "allowDistortion": false },
|
||||
"alpha": { "required": true, "transparentOuterPadding": 6, "cornerMaxAlpha": 0 },
|
||||
"edge": { "forbidChromaResidue": true, "forbidLightFringe": true, "premultipliedAlphaCheck": true },
|
||||
"quality": { "maxBytes": 1600000, "symmetry": "horizontal", "colorSpace": "sRGB" },
|
||||
"processing": { "trim": 1, "capWidth": 340, "keyColor": "#00FF00", "keyTolerance": 96 },
|
||||
"runtime": { "selector": ".feedback-toast", "imageSelector": ".feedback-toast__skin img" },
|
||||
"consumers": ["pages/auth/a01-entry.vue"]
|
||||
},
|
||||
{
|
||||
"id": "a01-scroll-dialog-v3",
|
||||
"assetClass": "fixed-bitmap",
|
||||
"source": "docs/design/assets/a01-vnext/masters/a01-scroll-dialog-master-v3-r2.png",
|
||||
"output": "static/assets/modules/auth/transparent/a01-scroll-dialog-v3.png",
|
||||
"logicalSlot": { "widthRpx": 620, "heightRpx": 520 },
|
||||
"outputPixels": { "width": 1860, "height": 1560 },
|
||||
"render": { "scalePolicy": "uniform-only", "uniMode": "aspectFit", "allowDistortion": false },
|
||||
"alpha": { "required": true, "transparentOuterPadding": 6, "cornerMaxAlpha": 0 },
|
||||
"edge": { "forbidChromaResidue": true, "forbidLightFringe": true, "premultipliedAlphaCheck": true },
|
||||
"quality": { "maxBytes": 3000000, "symmetry": "horizontal", "colorSpace": "sRGB" },
|
||||
"processing": { "trim": 1, "capWidth": 260, "keyColor": "#00FF00", "keyTolerance": 96, "paletteColors": 128, "indexedPng": true },
|
||||
"runtime": { "selector": ".verification-dialog", "imageSelector": ".verification-dialog__skin img" },
|
||||
"consumers": ["pages/auth/a01-entry.vue"]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,119 +0,0 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"canvas": {
|
||||
"width": 412,
|
||||
"height": 915
|
||||
},
|
||||
"shared": {
|
||||
"brandSeal": "static/assets/foundation/transparent/brand-seal.png",
|
||||
"lock": "static/assets/modules/auth/transparent/a01-icon-lock-v1.png",
|
||||
"wechat": "static/assets/foundation/transparent/auth-wechat.png",
|
||||
"agreementUnchecked": "static/assets/modules/auth/transparent/a02-agreement-unchecked.png"
|
||||
},
|
||||
"states": [
|
||||
{ "name": "password-hidden", "mode": "password", "passwordVisible": false },
|
||||
{ "name": "password-visible", "mode": "password", "passwordVisible": true },
|
||||
{ "name": "sms-default", "mode": "sms", "countdown": 0 },
|
||||
{ "name": "sms-countdown", "mode": "sms", "countdown": 56 }
|
||||
],
|
||||
"assets": [
|
||||
{
|
||||
"id": "header",
|
||||
"source": "static/assets/modules/auth/opaque/a01-vnext-header-v1.png",
|
||||
"output": "static/assets/modules/auth/opaque/a01-vnext-header-v1.png",
|
||||
"width": 824,
|
||||
"height": 340,
|
||||
"alpha": false,
|
||||
"maxBytes": 1200000
|
||||
},
|
||||
{
|
||||
"id": "scroll",
|
||||
"source": "static/assets/modules/auth/transparent/a01-vnext-scroll-v1.png",
|
||||
"output": "static/assets/modules/auth/transparent/a01-vnext-scroll-v1.png",
|
||||
"width": 784,
|
||||
"height": 1544,
|
||||
"alpha": true,
|
||||
"maxBytes": 4500000
|
||||
},
|
||||
{
|
||||
"id": "titleOrnament",
|
||||
"source": "static/assets/modules/auth/transparent/a01-vnext-title-ornament-v1.png",
|
||||
"output": "static/assets/modules/auth/transparent/a01-vnext-title-ornament-v1.png",
|
||||
"width": 376,
|
||||
"height": 130,
|
||||
"alpha": true,
|
||||
"maxBytes": 350000
|
||||
},
|
||||
{
|
||||
"id": "divider",
|
||||
"source": "static/assets/modules/auth/transparent/a01-vnext-divider-v1.png",
|
||||
"output": "static/assets/modules/auth/transparent/a01-vnext-divider-v1.png",
|
||||
"width": 540,
|
||||
"height": 90,
|
||||
"alpha": true,
|
||||
"maxBytes": 350000
|
||||
},
|
||||
{
|
||||
"id": "primaryButton",
|
||||
"source": "static/assets/modules/auth/transparent/a01-vnext-primary-button-v1.png",
|
||||
"output": "static/assets/modules/auth/transparent/a01-vnext-primary-button-v1.png",
|
||||
"width": 584,
|
||||
"height": 130,
|
||||
"alpha": true,
|
||||
"maxBytes": 350000
|
||||
},
|
||||
{
|
||||
"id": "secondaryButton",
|
||||
"source": "static/assets/modules/auth/transparent/a01-vnext-secondary-button-v1.png",
|
||||
"output": "static/assets/modules/auth/transparent/a01-vnext-secondary-button-v1.png",
|
||||
"width": 584,
|
||||
"height": 120,
|
||||
"alpha": true,
|
||||
"maxBytes": 350000
|
||||
},
|
||||
{
|
||||
"id": "phone",
|
||||
"source": "static/assets/modules/auth/transparent/a01-vnext-phone-v1.png",
|
||||
"output": "static/assets/modules/auth/transparent/a01-vnext-phone-v1.png",
|
||||
"width": 120,
|
||||
"height": 120,
|
||||
"alpha": true,
|
||||
"maxBytes": 120000
|
||||
},
|
||||
{
|
||||
"id": "eyeOpen",
|
||||
"source": "static/assets/modules/auth/transparent/a01-vnext-eye-open-v1.png",
|
||||
"output": "static/assets/modules/auth/transparent/a01-vnext-eye-open-v1.png",
|
||||
"width": 168,
|
||||
"height": 128,
|
||||
"alpha": true,
|
||||
"maxBytes": 150000
|
||||
},
|
||||
{
|
||||
"id": "eyeClosedPupil",
|
||||
"source": "static/assets/modules/auth/transparent/a01-vnext-eye-closed-pupil-v1.png",
|
||||
"output": "static/assets/modules/auth/transparent/a01-vnext-eye-closed-pupil-v1.png",
|
||||
"width": 168,
|
||||
"height": 140,
|
||||
"alpha": true,
|
||||
"maxBytes": 160000
|
||||
},
|
||||
{
|
||||
"id": "smsThreeDots",
|
||||
"source": "static/assets/modules/auth/transparent/a01-vnext-sms-three-dots-v1.png",
|
||||
"output": "static/assets/modules/auth/transparent/a01-vnext-sms-three-dots-v1.png",
|
||||
"width": 120,
|
||||
"height": 120,
|
||||
"alpha": true,
|
||||
"maxBytes": 120000
|
||||
}
|
||||
],
|
||||
"previews": [
|
||||
{ "state": "password-hidden", "output": "design-pipeline/generated/a01/A01-password-412x915.png", "width": 412, "height": 915 },
|
||||
{ "state": "sms-default", "output": "design-pipeline/generated/a01/A01-sms-412x915.png", "width": 412, "height": 915 },
|
||||
{ "state": "contact", "output": "design-pipeline/generated/a01/A01-password-sms-contact-824x915.png", "width": 824, "height": 915 },
|
||||
{ "state": "password-hidden", "output": "design-pipeline/generated/a01/A01-password-320x568.png", "width": 320, "height": 568 },
|
||||
{ "state": "password-hidden", "output": "design-pipeline/generated/a01/A01-password-360x640.png", "width": 360, "height": 640 },
|
||||
{ "state": "password-hidden", "output": "design-pipeline/generated/a01/A01-password-360x800.png", "width": 360, "height": 800 }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"kind": "runtime-asset-inventory",
|
||||
"scope": "application-direct",
|
||||
"imports": [],
|
||||
"assets": [
|
||||
{ "id": "app-foundation-opaque-root-header-cinnabar", "output": "static/assets/foundation/opaque/root-header-cinnabar.jpg", "width": 720, "height": 184, "alpha": false, "bytes": 9356, "sha256": "9561b1e4d11d3cdd005b902c6d989aadc48bd614bec5ddbd65a32699983b4b37", "provenance": "committed-binary", "rebuildable": false },
|
||||
{ "id": "app-foundation-transparent-auth-title-cloud", "output": "static/assets/foundation/transparent/auth-title-cloud.png", "width": 200, "height": 120, "alpha": true, "bytes": 15833, "sha256": "c6dc6e367198e815b28b8f88c4012b850f2a21195d8ca4ecaf27c0204eba64f2", "provenance": "committed-binary", "rebuildable": false },
|
||||
{ "id": "app-foundation-transparent-meta-admin", "output": "static/assets/foundation/transparent/meta-admin.png", "width": 96, "height": 96, "alpha": true, "bytes": 4705, "sha256": "d2363e3a4f990dcf5925f0b67ac51b8d3d0fe79d4e7d753ddd8df5b412a15517", "provenance": "committed-binary", "rebuildable": false },
|
||||
{ "id": "app-foundation-transparent-meta-location", "output": "static/assets/foundation/transparent/meta-location.png", "width": 96, "height": 96, "alpha": true, "bytes": 3634, "sha256": "ed1285d91df018a6e7628cca944428e1582fb10ddeaf75ba7743481340adc12c", "provenance": "committed-binary", "rebuildable": false },
|
||||
{ "id": "app-foundation-transparent-meta-member", "output": "static/assets/foundation/transparent/meta-member.png", "width": 96, "height": 96, "alpha": true, "bytes": 8931, "sha256": "c89824e8e19210dd746f56019a48c9a833abe4a210b222758a56c3d25dec9d0c", "provenance": "committed-binary", "rebuildable": false },
|
||||
{ "id": "app-foundation-transparent-notice", "output": "static/assets/foundation/transparent/notice.png", "width": 96, "height": 96, "alpha": true, "bytes": 8775, "sha256": "a75ce2368afa661edb4b394d0a58bc514248fa994f9e732d1d6833caa79353e1", "provenance": "committed-binary", "rebuildable": false },
|
||||
{ "id": "app-foundation-transparent-root-header-hall", "output": "static/assets/foundation/transparent/root-header-hall.png", "width": 750, "height": 300, "alpha": true, "bytes": 159144, "sha256": "a4b55282fb42f0200636e7d5a66cd1f237bea01521c312fa99a3b7278cf8ff49", "provenance": "committed-binary", "rebuildable": false },
|
||||
{ "id": "app-foundation-transparent-tab-family-active", "output": "static/assets/foundation/transparent/tab-family-active.png", "width": 96, "height": 96, "alpha": true, "bytes": 2620, "sha256": "007f03d04927fbf6638fff027335f45db1331adb36979bcbca0d577bb28046f5", "provenance": "committed-binary", "rebuildable": false },
|
||||
{ "id": "app-foundation-transparent-tab-family", "output": "static/assets/foundation/transparent/tab-family.png", "width": 96, "height": 96, "alpha": true, "bytes": 2621, "sha256": "c7e0083e4ca045b084dab6e77cbece8a26f490dec628e386689d68a57d03387a", "provenance": "committed-binary", "rebuildable": false },
|
||||
{ "id": "app-foundation-transparent-tab-genealogy-active", "output": "static/assets/foundation/transparent/tab-genealogy-active.png", "width": 96, "height": 96, "alpha": true, "bytes": 3142, "sha256": "99ad57c8868eb618f62601882f7748edd5b87c81cf1d0fe5bf2324113930a88c", "provenance": "committed-binary", "rebuildable": false },
|
||||
{ "id": "app-foundation-transparent-tab-genealogy", "output": "static/assets/foundation/transparent/tab-genealogy.png", "width": 96, "height": 96, "alpha": true, "bytes": 3141, "sha256": "88d5653db7521b846ef81736355e9ccbbb59daf6dfe92c9c2748bef6456af646", "provenance": "committed-binary", "rebuildable": false },
|
||||
{ "id": "app-foundation-transparent-tab-profile-active", "output": "static/assets/foundation/transparent/tab-profile-active.png", "width": 96, "height": 96, "alpha": true, "bytes": 1973, "sha256": "5ff15db3b4cc64934e4ae9602e604345501c1f86160da10d30d9c0a80ef95d36", "provenance": "committed-binary", "rebuildable": false },
|
||||
{ "id": "app-foundation-transparent-tab-profile", "output": "static/assets/foundation/transparent/tab-profile.png", "width": 96, "height": 96, "alpha": true, "bytes": 1973, "sha256": "45fc22b367807bdc465e6e17230a10276a4d7e453e27b7cbc064af7fdc255470", "provenance": "committed-binary", "rebuildable": false },
|
||||
{ "id": "app-modules-family-f08-f08-ancestral-home", "output": "static/assets/modules/family/f08/f08-ancestral-home.png", "width": 1448, "height": 1086, "alpha": true, "bytes": 3370777, "sha256": "b9339cac4fc6e2fe466ae64944140d8b332e019e10985fe900e06c06668391f1", "provenance": "committed-binary", "rebuildable": false },
|
||||
{ "id": "app-modules-family-f08-f08-ancestral-portrait", "output": "static/assets/modules/family/f08/f08-ancestral-portrait.png", "width": 1448, "height": 1086, "alpha": true, "bytes": 3227971, "sha256": "4b541960557d0caab15084348f1ecde5a9f93f7f791e1a7e881deb7a74e8496e", "provenance": "committed-binary", "rebuildable": false },
|
||||
{ "id": "app-modules-family-f08-f08-family-portrait", "output": "static/assets/modules/family/f08/f08-family-portrait.png", "width": 1448, "height": 1086, "alpha": true, "bytes": 4129091, "sha256": "dd221049eb552c098dfe8177e9ac8d4185b9865d7390f745ca91742b700bc68b", "provenance": "committed-binary", "rebuildable": false },
|
||||
{ "id": "app-modules-family-f08-f08-reunion-hero", "output": "static/assets/modules/family/f08/f08-reunion-hero.png", "width": 1672, "height": 940, "alpha": true, "bytes": 4110597, "sha256": "5b296d9254580b3565539813daacf74edaa5471495a3b30b06b512a5b2153dc9", "provenance": "committed-binary", "rebuildable": false },
|
||||
{ "id": "app-modules-family-f08-f08-reunion-table", "output": "static/assets/modules/family/f08/f08-reunion-table.png", "width": 1448, "height": 1086, "alpha": true, "bytes": 3765758, "sha256": "b241176cd3080e7b038b6b84e7cd3cb30f423c175df8cf2a78dc0cff1709e6eb", "provenance": "committed-binary", "rebuildable": false },
|
||||
{ "id": "app-modules-family-transparent-f01-family-letter-card", "output": "static/assets/modules/family/transparent/f01-family-letter-card.png", "width": 2003, "height": 581, "alpha": true, "bytes": 1513491, "sha256": "a3686f99e32cdc255c0fc130294aab1974392d66bc8a7991daeb8f67849015ee", "provenance": "committed-binary", "rebuildable": false },
|
||||
{ "id": "app-modules-family-transparent-module-content-frame", "output": "static/assets/modules/family/transparent/module-content-frame.png", "width": 2003, "height": 581, "alpha": true, "bytes": 1513491, "sha256": "a3686f99e32cdc255c0fc130294aab1974392d66bc8a7991daeb8f67849015ee", "provenance": "committed-binary", "rebuildable": false },
|
||||
{ "id": "app-modules-family-transparent-module-field-frame", "output": "static/assets/modules/family/transparent/module-field-frame.png", "width": 2132, "height": 443, "alpha": true, "bytes": 1065770, "sha256": "bdc65f33e1dbe05ae75562519b0080e2e5cdbe2dba720a6ac49b56b99be75ceb", "provenance": "committed-binary", "rebuildable": false },
|
||||
{ "id": "app-modules-genealogy-opaque-g05-overview-surface", "output": "static/assets/modules/genealogy/opaque/g05-overview-surface.png", "width": 1122, "height": 1506, "alpha": false, "bytes": 2537499, "sha256": "2d908f53c7edb637c7ced7500822bd33d354410ad8e671871d1da3a4cbd0f4db", "provenance": "committed-binary", "rebuildable": false },
|
||||
{ "id": "app-modules-genealogy-opaque-g06-search-button", "output": "static/assets/modules/genealogy/opaque/g06-search-button.png", "width": 300, "height": 132, "alpha": false, "bytes": 74664, "sha256": "bf0abc0a036c07322dd834fe5a9797413afd453b69d2ff8a895f3999fb8846cd", "provenance": "committed-binary", "rebuildable": false },
|
||||
{ "id": "app-modules-genealogy-opaque-g06-search-input-wide", "output": "static/assets/modules/genealogy/opaque/g06-search-input-wide.png", "width": 1120, "height": 248, "alpha": false, "bytes": 319364, "sha256": "2a575eeb5582c8458cbdd04cd328afbbecbdaabf4db46e8f2133bfe2614faa80", "provenance": "committed-binary", "rebuildable": false },
|
||||
{ "id": "app-modules-genealogy-transparent-add", "output": "static/assets/modules/genealogy/transparent/add.png", "width": 96, "height": 96, "alpha": true, "bytes": 5226, "sha256": "671846ee23f8df701e2e1dfc34e3520dfe9f719f106efce56e30d78b84a47a0b", "provenance": "committed-binary", "rebuildable": false },
|
||||
{ "id": "app-modules-genealogy-transparent-create-cloud", "output": "static/assets/modules/genealogy/transparent/create-cloud.png", "width": 192, "height": 96, "alpha": true, "bytes": 10538, "sha256": "be01c1322181d91264641879162f6371d21ffda1feb971f5f31a791f32d02206", "provenance": "committed-binary", "rebuildable": false },
|
||||
{ "id": "app-modules-genealogy-transparent-current-seal-frame", "output": "static/assets/modules/genealogy/transparent/current-seal-frame.png", "width": 144, "height": 208, "alpha": true, "bytes": 1214, "sha256": "6d9aa61d766eec077cd91fbd5b71079990e67bcdc213d631a1185714dfb09328", "provenance": "committed-binary", "rebuildable": false },
|
||||
{ "id": "app-modules-genealogy-transparent-current-slip-frame", "output": "static/assets/modules/genealogy/transparent/current-slip-frame.png", "width": 720, "height": 272, "alpha": true, "bytes": 18568, "sha256": "47e519768830062a2363bd4bd10fb9ffc82c6a94638d3400a1e4c943a0ca6e66", "provenance": "committed-binary", "rebuildable": false },
|
||||
{ "id": "app-modules-genealogy-transparent-g-form-field-frame", "output": "static/assets/modules/genealogy/transparent/g-form-field-frame.png", "width": 2132, "height": 443, "alpha": true, "bytes": 1065770, "sha256": "bdc65f33e1dbe05ae75562519b0080e2e5cdbe2dba720a6ac49b56b99be75ceb", "provenance": "committed-binary", "rebuildable": false },
|
||||
{ "id": "app-modules-genealogy-transparent-g01-add-sheet-background-v3", "output": "static/assets/modules/genealogy/transparent/g01-add-sheet-background-v3.png", "width": 1536, "height": 1024, "alpha": true, "bytes": 1423794, "sha256": "181b9d9c95f0e8cce284dc6640adb4fa6c4fa9691306052c9e33558abf930e85", "provenance": "committed-binary", "rebuildable": false },
|
||||
{ "id": "app-modules-genealogy-transparent-g01-dialog-close", "output": "static/assets/modules/genealogy/transparent/g01-dialog-close.png", "width": 1254, "height": 1254, "alpha": true, "bytes": 105163, "sha256": "909fe4badccbfad1ddcfe4a21292ba73fecde32efb914f1c7fcb717f32f9db3f", "provenance": "committed-binary", "rebuildable": false },
|
||||
{ "id": "app-modules-genealogy-transparent-list-slip-frame", "output": "static/assets/modules/genealogy/transparent/list-slip-frame.png", "width": 720, "height": 144, "alpha": true, "bytes": 2992, "sha256": "1e39ed72c000e569340049833b1afca9e6aaeb077618a91e0c0f4f2758113005", "provenance": "committed-binary", "rebuildable": false },
|
||||
{ "id": "app-modules-genealogy-transparent-row-seal-frame", "output": "static/assets/modules/genealogy/transparent/row-seal-frame.png", "width": 112, "height": 160, "alpha": true, "bytes": 875, "sha256": "f2f2b0260fe54d738a70168f28d3960622d4b3e57c4ef95a032716008de6626b", "provenance": "committed-binary", "rebuildable": false },
|
||||
{ "id": "app-modules-genealogy-transparent-section-divider", "output": "static/assets/modules/genealogy/transparent/section-divider.png", "width": 720, "height": 30, "alpha": true, "bytes": 6645, "sha256": "925241a6cd2bd9897edad60e53eb255033603bef1028afbc189e87a29fddc6cd", "provenance": "committed-binary", "rebuildable": false },
|
||||
{ "id": "app-modules-genealogy-transparent-shortcut-application", "output": "static/assets/modules/genealogy/transparent/shortcut-application.png", "width": 96, "height": 96, "alpha": true, "bytes": 8925, "sha256": "3842aec0357ee1013ddc230fe1030894ad5eb74101aa696bd8f986ad0c1bdd93", "provenance": "committed-binary", "rebuildable": false },
|
||||
{ "id": "app-modules-genealogy-transparent-shortcut-generation-poem", "output": "static/assets/modules/genealogy/transparent/shortcut-generation-poem.png", "width": 96, "height": 96, "alpha": true, "bytes": 8169, "sha256": "688e593fa88925ea00acf37570852a2dca9e1a505ba3b8107e0ddd3d146a6f4e", "provenance": "committed-binary", "rebuildable": false },
|
||||
{ "id": "app-modules-genealogy-transparent-shortcut-members", "output": "static/assets/modules/genealogy/transparent/shortcut-members.png", "width": 96, "height": 96, "alpha": true, "bytes": 8931, "sha256": "c89824e8e19210dd746f56019a48c9a833abe4a210b222758a56c3d25dec9d0c", "provenance": "committed-binary", "rebuildable": false },
|
||||
{ "id": "app-modules-genealogy-transparent-shortcut-tree", "output": "static/assets/modules/genealogy/transparent/shortcut-tree.png", "width": 96, "height": 96, "alpha": true, "bytes": 6636, "sha256": "cafe12a3fa800ca79d6c559baa533d7e26124ec8ac0072482ddb89ac2167422d", "provenance": "committed-binary", "rebuildable": false },
|
||||
{ "id": "app-modules-notification-transparent-module-content-frame", "output": "static/assets/modules/notification/transparent/module-content-frame.png", "width": 2139, "height": 675, "alpha": true, "bytes": 254885, "sha256": "01cf572ed3f20d99e10a21fee6834dced385e71d6b14693b04c0cb49560a6fd3", "provenance": "committed-binary", "rebuildable": false },
|
||||
{ "id": "app-modules-notification-transparent-module-field-frame", "output": "static/assets/modules/notification/transparent/module-field-frame.png", "width": 2132, "height": 443, "alpha": true, "bytes": 1065770, "sha256": "bdc65f33e1dbe05ae75562519b0080e2e5cdbe2dba720a6ac49b56b99be75ceb", "provenance": "committed-binary", "rebuildable": false },
|
||||
{ "id": "app-modules-notification-transparent-n01-notice-card", "output": "static/assets/modules/notification/transparent/n01-notice-card.png", "width": 2139, "height": 675, "alpha": true, "bytes": 254885, "sha256": "01cf572ed3f20d99e10a21fee6834dced385e71d6b14693b04c0cb49560a6fd3", "provenance": "committed-binary", "rebuildable": false },
|
||||
{ "id": "app-modules-profile-transparent-m01-profile-summary-card", "output": "static/assets/modules/profile/transparent/m01-profile-summary-card.png", "width": 2139, "height": 675, "alpha": true, "bytes": 254885, "sha256": "01cf572ed3f20d99e10a21fee6834dced385e71d6b14693b04c0cb49560a6fd3", "provenance": "committed-binary", "rebuildable": false },
|
||||
{ "id": "app-modules-profile-transparent-module-content-frame", "output": "static/assets/modules/profile/transparent/module-content-frame.png", "width": 2139, "height": 675, "alpha": true, "bytes": 254885, "sha256": "01cf572ed3f20d99e10a21fee6834dced385e71d6b14693b04c0cb49560a6fd3", "provenance": "committed-binary", "rebuildable": false },
|
||||
{ "id": "app-modules-profile-transparent-module-field-frame", "output": "static/assets/modules/profile/transparent/module-field-frame.png", "width": 2132, "height": 443, "alpha": true, "bytes": 1065770, "sha256": "bdc65f33e1dbe05ae75562519b0080e2e5cdbe2dba720a6ac49b56b99be75ceb", "provenance": "committed-binary", "rebuildable": false },
|
||||
{ "id": "app-modules-records-transparent-module-content-frame", "output": "static/assets/modules/records/transparent/module-content-frame.png", "width": 2139, "height": 675, "alpha": true, "bytes": 254885, "sha256": "01cf572ed3f20d99e10a21fee6834dced385e71d6b14693b04c0cb49560a6fd3", "provenance": "committed-binary", "rebuildable": false },
|
||||
{ "id": "app-modules-records-transparent-module-field-frame", "output": "static/assets/modules/records/transparent/module-field-frame.png", "width": 2132, "height": 443, "alpha": true, "bytes": 1065770, "sha256": "bdc65f33e1dbe05ae75562519b0080e2e5cdbe2dba720a6ac49b56b99be75ceb", "provenance": "committed-binary", "rebuildable": false },
|
||||
{ "id": "app-modules-records-transparent-r01-person-name-card", "output": "static/assets/modules/records/transparent/r01-person-name-card.png", "width": 2139, "height": 675, "alpha": true, "bytes": 254885, "sha256": "01cf572ed3f20d99e10a21fee6834dced385e71d6b14693b04c0cb49560a6fd3", "provenance": "committed-binary", "rebuildable": false },
|
||||
{ "id": "app-modules-tree-transparent-t01-member-drawer", "output": "static/assets/modules/tree/transparent/t01-member-drawer.png", "width": 2139, "height": 675, "alpha": true, "bytes": 254885, "sha256": "01cf572ed3f20d99e10a21fee6834dced385e71d6b14693b04c0cb49560a6fd3", "provenance": "committed-binary", "rebuildable": false },
|
||||
{ "id": "app-modules-tree-transparent-t01-member-node-selected", "output": "static/assets/modules/tree/transparent/t01-member-node-selected.png", "width": 720, "height": 272, "alpha": true, "bytes": 18568, "sha256": "47e519768830062a2363bd4bd10fb9ffc82c6a94638d3400a1e4c943a0ca6e66", "provenance": "committed-binary", "rebuildable": false },
|
||||
{ "id": "app-modules-tree-transparent-t01-member-node-standard", "output": "static/assets/modules/tree/transparent/t01-member-node-standard.png", "width": 720, "height": 272, "alpha": true, "bytes": 18568, "sha256": "47e519768830062a2363bd4bd10fb9ffc82c6a94638d3400a1e4c943a0ca6e66", "provenance": "committed-binary", "rebuildable": false },
|
||||
{ "id": "app-modules-tree-transparent-t01-state-panel", "output": "static/assets/modules/tree/transparent/t01-state-panel.png", "width": 2139, "height": 675, "alpha": true, "bytes": 254885, "sha256": "01cf572ed3f20d99e10a21fee6834dced385e71d6b14693b04c0cb49560a6fd3", "provenance": "committed-binary", "rebuildable": false },
|
||||
{ "id": "app-modules-tree-transparent-t07-search-input-frame", "output": "static/assets/modules/tree/transparent/t07-search-input-frame.png", "width": 2132, "height": 443, "alpha": true, "bytes": 1065770, "sha256": "bdc65f33e1dbe05ae75562519b0080e2e5cdbe2dba720a6ac49b56b99be75ceb", "provenance": "committed-binary", "rebuildable": false }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"kind": "runtime-asset-inventory",
|
||||
"scope": "auth",
|
||||
"imports": [],
|
||||
"assets": [
|
||||
{ "id": "auth-page-paper", "output": "static/assets/foundation/opaque/auth-page-paper.jpg", "width": 750, "height": 1334, "alpha": false, "bytes": 60355, "sha256": "3eea1eefa815c306f0f4a95ce79878ca53f8f64a7c9b44144c99ca683a38e7b8", "provenance": "committed-binary", "rebuildable": false },
|
||||
{ "id": "auth-divider-knot", "output": "static/assets/foundation/transparent/auth-divider-knot.png", "width": 160, "height": 96, "alpha": true, "bytes": 6751, "sha256": "d45dc052c8eb2ba214c7d4d5846f5b53f01686e00e671bfe0582ed7d9d4a2419", "provenance": "committed-binary", "rebuildable": false },
|
||||
{ "id": "auth-login-outline", "output": "static/assets/foundation/transparent/auth-login-outline.png", "width": 96, "height": 96, "alpha": true, "bytes": 1973, "sha256": "45fc22b367807bdc465e6e17230a10276a4d7e453e27b7cbc064af7fdc255470", "provenance": "committed-binary", "rebuildable": false },
|
||||
{ "id": "auth-wechat", "output": "static/assets/foundation/transparent/auth-wechat.png", "width": 96, "height": 96, "alpha": true, "bytes": 2215, "sha256": "5c69addac53afcd34064e48fffc19ef56a5d105c92f6130cd6045dd1591221d1", "provenance": "committed-binary", "rebuildable": false },
|
||||
{ "id": "brand-seal", "output": "static/assets/foundation/transparent/brand-seal.png", "width": 240, "height": 288, "alpha": true, "bytes": 101898, "sha256": "865e15edd6a1b50aa298ccdff5babf0f3140906026ce76d3a64631027820a7ae", "provenance": "committed-binary", "rebuildable": false },
|
||||
{ "id": "chevron-right", "output": "static/assets/foundation/transparent/chevron-right.png", "width": 96, "height": 96, "alpha": true, "bytes": 2106, "sha256": "82f4996e118832108dba4aa33d320131fee300970679f6ce8cf4b09bda62b700", "provenance": "committed-binary", "rebuildable": false },
|
||||
{ "id": "auth-backdrop-v1", "output": "static/assets/modules/auth/opaque/a01-red-hall-ink-backdrop-v1.png", "width": 824, "height": 1830, "alpha": false, "bytes": 669577, "sha256": "478d0f56d669ac1597cfc2a1082740e73550f07d655286762f3306410c19eb11", "provenance": "committed-binary", "rebuildable": false },
|
||||
{ "id": "auth-header-v1", "output": "static/assets/modules/auth/opaque/a01-vnext-header-v1.png", "width": 824, "height": 340, "alpha": false, "bytes": 504001, "sha256": "6ce03f4962e8bdd0272ed00a82efded21becdf350a1fb0880c595d7d9c15200d", "provenance": "committed-binary", "rebuildable": false },
|
||||
{ "id": "auth-eye-closed-v2", "output": "static/assets/modules/auth/transparent/a01-icon-eye-closed-pupil-v2.png", "width": 96, "height": 96, "alpha": true, "bytes": 3906, "sha256": "b9304de963cacaa5ecd133a9835174f0c77761d9f264afec900460c9aa983b4f", "provenance": "committed-binary", "rebuildable": false },
|
||||
{ "id": "auth-eye-open-v1", "output": "static/assets/modules/auth/transparent/a01-icon-eye-open-v1.png", "width": 96, "height": 96, "alpha": true, "bytes": 2868, "sha256": "aa6a78b3f0ecb2d47963b9aa04c98db1b2981497f52f58ff8c602cff80fab21a", "provenance": "committed-binary", "rebuildable": false },
|
||||
{ "id": "auth-lock-v1", "output": "static/assets/modules/auth/transparent/a01-icon-lock-v1.png", "width": 96, "height": 96, "alpha": true, "bytes": 2649, "sha256": "c1b13de86ffee9a9f2e7534d58ddc3bf155d5341eeddbd5998ef3747d57fdea8", "provenance": "committed-binary", "rebuildable": false },
|
||||
{ "id": "auth-phone-v1", "output": "static/assets/modules/auth/transparent/a01-icon-phone-v1.png", "width": 96, "height": 96, "alpha": true, "bytes": 925, "sha256": "5110cfb1cc84b00145506b3cda3fc35860400d16404e606428902dfe0fa7ec2e", "provenance": "committed-binary", "rebuildable": false },
|
||||
{ "id": "auth-sms-code-v2", "output": "static/assets/modules/auth/transparent/a01-icon-sms-code-v2.png", "width": 1254, "height": 1254, "alpha": true, "bytes": 114061, "sha256": "e932900fa8ab0edcfeed0de0d52c637890e28f58f867aabfeacbb18df060a556", "provenance": "committed-binary", "rebuildable": false },
|
||||
{ "id": "auth-divider-v1", "output": "static/assets/modules/auth/transparent/a01-vnext-divider-v1.png", "width": 540, "height": 90, "alpha": true, "bytes": 6278, "sha256": "10a3281723abfadcc2cec7b8e4437fe50ecdc36d885d54ebce11f46c46ed4af4", "provenance": "committed-binary", "rebuildable": false },
|
||||
{ "id": "agreement-checked", "output": "static/assets/modules/auth/transparent/a02-agreement-checked.png", "width": 96, "height": 96, "alpha": true, "bytes": 10602, "sha256": "825fcb945c81330429abc1d3ec1d0c20df86a9634416e244896e452cb08644f4", "provenance": "committed-binary", "rebuildable": false },
|
||||
{ "id": "agreement-unchecked", "output": "static/assets/modules/auth/transparent/a02-agreement-unchecked.png", "width": 96, "height": 96, "alpha": true, "bytes": 6014, "sha256": "d9843d1c3644272f6eeff9fb44e763c857f409f404551a5e1f032ddf1b07b69f", "provenance": "committed-binary", "rebuildable": false }
|
||||
]
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"page": "G01",
|
||||
"status": "long-flagship-selected-for-genealogy-module",
|
||||
"selectedCandidateId": "g01-list-background-long-flagship",
|
||||
"runtimeOutput": "static/assets/modules/genealogy/opaque/genealogy-page-background-long.png",
|
||||
"generatedDate": "2026-07-16",
|
||||
"referenceAssets": [
|
||||
"static/assets/foundation/transparent/footer-mountain-bamboo.png",
|
||||
"tmp/g01-audit-02-application-gap-focused-412x915.png"
|
||||
],
|
||||
"determinism": {
|
||||
"modelRegeneration": "direction-only-not-pixel-identical",
|
||||
"savedMasterProcessing": "pixel-identical-with-locked-dependencies"
|
||||
},
|
||||
"chromaKey": {
|
||||
"color": "#00ff00",
|
||||
"dominanceStart": 30,
|
||||
"dominanceEnd": 220,
|
||||
"despillAllowance": 10
|
||||
},
|
||||
"candidates": [
|
||||
{
|
||||
"id": "g01-list-background-a",
|
||||
"title": "上山下亭纵向框景",
|
||||
"master": "docs/design/assets/g01-background/masters/g01-list-background-direction-a-chroma-master.png",
|
||||
"generatedOutput": "design-pipeline/generated/g01-background/g01-list-background-direction-a.png",
|
||||
"processingMode": "chroma-key",
|
||||
"sourcePixels": { "width": 1024, "height": 1536 },
|
||||
"prompt": "1024x1536 纵向 G01 列表背景独立资产;暖灰淡金中国水墨,远山从上方约 10% 开始,左中下亭台,右侧竹影贯穿,中央低对比度供列表文字阅读;无文字、无 UI、无卡片、无红色;非画面区域必须为纯 #00ff00 绿幕,画面本身不得使用绿色。"
|
||||
},
|
||||
{
|
||||
"id": "g01-list-background-b",
|
||||
"title": "上下山水环抱框景",
|
||||
"master": "docs/design/assets/g01-background/masters/g01-list-background-direction-b-chroma-master.png",
|
||||
"generatedOutput": "design-pipeline/generated/g01-background/g01-list-background-direction-b.png",
|
||||
"processingMode": "chroma-key",
|
||||
"sourcePixels": { "width": 1024, "height": 1536 },
|
||||
"prompt": "1024x1536 纵向 G01 列表背景独立资产;上方连续远山与雾带、左中亭台、右侧淡金竹影、下方第二层山水,三个纵向区段都有内容但中央保持安静;无文字、无 UI、无卡片、无红色;非画面区域必须为纯 #00ff00 绿幕,画面本身不得使用绿色。"
|
||||
},
|
||||
{
|
||||
"id": "g01-list-background-c",
|
||||
"title": "云竹亭台连续宣纸景",
|
||||
"master": "docs/design/assets/g01-background/masters/g01-list-background-direction-c-paper-master.png",
|
||||
"generatedOutput": "design-pipeline/generated/g01-background/g01-list-background-direction-c.png",
|
||||
"processingMode": "opaque-paper",
|
||||
"sourcePixels": { "width": 1024, "height": 1536 },
|
||||
"prompt": "1024x1536 纵向 G01 列表背景独立资产;暖宣纸底,顶部 5% 即出现云气、枝叶和淡金竹影,左侧约 40% 高度放置亭台,山水从中部连续延伸到底部,中央低对比度;无文字、无 UI、无卡片、无标志、无红色。"
|
||||
},
|
||||
{
|
||||
"id": "g01-list-background-long-flagship",
|
||||
"title": "旗舰长屏连续云竹亭台宣纸景",
|
||||
"imageGenSource": "docs/design/assets/g01-background/masters/genealogy-page-background-long-flagship-imagegen-source.png",
|
||||
"master": "docs/design/assets/g01-background/masters/genealogy-page-background-long-flagship-master.png",
|
||||
"generatedOutput": "design-pipeline/generated/g01-background/genealogy-page-background-long-flagship.png",
|
||||
"processingMode": "opaque-paper-resize",
|
||||
"sourcePixels": { "width": 1536, "height": 3840 },
|
||||
"outputPixels": { "width": 1440, "height": 3600 },
|
||||
"prompt": "1536x3840 纵向 G 类型共享连续长背景;延续 C 方向的暖灰宣纸、淡金竹影和灰褐水墨,顶部疏竹淡云作为短屏安全裁切区,中段低对比留给页面内容,下半部连续布置亭台、湖面、远山和岸边竹影;无文字、无 Logo、无 UI、无卡片、无按钮、无红色元素、无横向接缝、无重复图案。"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"schemaVersion": 3,
|
||||
"kind": "asset-build-manifest",
|
||||
"family": "g01-state-frame-v3",
|
||||
"assets": [
|
||||
{
|
||||
"id": "g01-empty-panel-frame",
|
||||
"source": "docs/design/assets/g01-state/masters/g01-empty-panel-master.png",
|
||||
"output": "static/assets/modules/genealogy/transparent/g01-empty-panel-frame.png",
|
||||
"sourcePixels": { "width": 1122, "height": 1402 },
|
||||
"outputPixels": { "width": 1122, "height": 1402 },
|
||||
"alpha": { "required": true, "transparentOuterPadding": 0, "cornerMaxAlpha": 0 },
|
||||
"edge": { "forbidChromaResidue": false, "forbidLightFringe": false, "premultipliedAlphaCheck": true },
|
||||
"quality": { "maxBytes": 2200000, "colorSpace": "sRGB" },
|
||||
"processing": {
|
||||
"mode": "warm-gold-frame-extract",
|
||||
"borderBand": 110,
|
||||
"redGreenMin": 15,
|
||||
"greenBlueMin": 12,
|
||||
"redBlueMin": 35,
|
||||
"redMaxExclusive": 245,
|
||||
"blueMaxExclusive": 180,
|
||||
"alphaOffset": 25,
|
||||
"alphaScale": 6,
|
||||
"outputMode": "RGBA"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
{
|
||||
"version": 1,
|
||||
"width": 1440,
|
||||
"height": 3600,
|
||||
"backgrounds": [
|
||||
{
|
||||
"module": "tree",
|
||||
"source": "docs/design/assets/module-backgrounds/masters/tree-page-background-imagegen-source.png",
|
||||
"output": "static/assets/modules/tree/opaque/tree-page-background-long.png"
|
||||
},
|
||||
{
|
||||
"module": "family",
|
||||
"source": "docs/design/assets/module-backgrounds/masters/family-page-background-imagegen-source.png",
|
||||
"output": "static/assets/modules/family/opaque/family-page-background-long.png"
|
||||
},
|
||||
{
|
||||
"module": "records",
|
||||
"source": "docs/design/assets/module-backgrounds/masters/records-page-background-imagegen-source.png",
|
||||
"output": "static/assets/modules/records/opaque/records-page-background-long.png"
|
||||
},
|
||||
{
|
||||
"module": "notification",
|
||||
"source": "docs/design/assets/module-backgrounds/masters/notification-page-background-imagegen-source.png",
|
||||
"output": "static/assets/modules/notification/opaque/notification-page-background-long.png"
|
||||
},
|
||||
{
|
||||
"module": "profile",
|
||||
"source": "docs/design/assets/module-backgrounds/masters/profile-page-background-imagegen-source.png",
|
||||
"output": "static/assets/modules/profile/opaque/profile-page-background-long.png"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
{
|
||||
"schemaVersion": 3,
|
||||
"kind": "asset-build-manifest",
|
||||
"family": "page-backgrounds-v3",
|
||||
"assets": [
|
||||
{
|
||||
"id": "genealogy-page-background-long",
|
||||
"source": "docs/design/assets/g01-background/masters/genealogy-page-background-long-flagship-master.png",
|
||||
"output": "static/assets/modules/genealogy/opaque/genealogy-page-background-long.png",
|
||||
"sourcePixels": { "width": 1536, "height": 3840 },
|
||||
"outputPixels": { "width": 1440, "height": 3600 },
|
||||
"quality": { "maxBytes": 7000000, "colorSpace": "sRGB" },
|
||||
"processing": { "mode": "opaque-resize", "resample": "lanczos", "outputMode": "RGB" }
|
||||
},
|
||||
{
|
||||
"id": "tree-page-background-long",
|
||||
"source": "docs/design/assets/module-backgrounds/masters/tree-page-background-imagegen-source.png",
|
||||
"output": "static/assets/modules/tree/opaque/tree-page-background-long.png",
|
||||
"sourcePixels": { "width": 793, "height": 1983 },
|
||||
"outputPixels": { "width": 1440, "height": 3600 },
|
||||
"quality": { "maxBytes": 7000000, "colorSpace": "sRGB" },
|
||||
"processing": { "mode": "opaque-cover-crop", "resample": "lanczos", "anchor": "center", "outputMode": "RGB" }
|
||||
},
|
||||
{
|
||||
"id": "family-page-background-long",
|
||||
"source": "docs/design/assets/module-backgrounds/masters/family-page-background-imagegen-source.png",
|
||||
"output": "static/assets/modules/family/opaque/family-page-background-long.png",
|
||||
"sourcePixels": { "width": 793, "height": 1983 },
|
||||
"outputPixels": { "width": 1440, "height": 3600 },
|
||||
"quality": { "maxBytes": 7000000, "colorSpace": "sRGB" },
|
||||
"processing": { "mode": "opaque-cover-crop", "resample": "lanczos", "anchor": "center", "outputMode": "RGB" }
|
||||
},
|
||||
{
|
||||
"id": "records-page-background-long",
|
||||
"source": "docs/design/assets/module-backgrounds/masters/records-page-background-imagegen-source.png",
|
||||
"output": "static/assets/modules/records/opaque/records-page-background-long.png",
|
||||
"sourcePixels": { "width": 793, "height": 1983 },
|
||||
"outputPixels": { "width": 1440, "height": 3600 },
|
||||
"quality": { "maxBytes": 7000000, "colorSpace": "sRGB" },
|
||||
"processing": { "mode": "opaque-cover-crop", "resample": "lanczos", "anchor": "center", "outputMode": "RGB" }
|
||||
},
|
||||
{
|
||||
"id": "notification-page-background-long",
|
||||
"source": "docs/design/assets/module-backgrounds/masters/notification-page-background-imagegen-source.png",
|
||||
"output": "static/assets/modules/notification/opaque/notification-page-background-long.png",
|
||||
"sourcePixels": { "width": 793, "height": 1983 },
|
||||
"outputPixels": { "width": 1440, "height": 3600 },
|
||||
"quality": { "maxBytes": 7000000, "colorSpace": "sRGB" },
|
||||
"processing": { "mode": "opaque-cover-crop", "resample": "lanczos", "anchor": "center", "outputMode": "RGB" }
|
||||
},
|
||||
{
|
||||
"id": "profile-page-background-long",
|
||||
"source": "docs/design/assets/module-backgrounds/masters/profile-page-background-imagegen-source.png",
|
||||
"output": "static/assets/modules/profile/opaque/profile-page-background-long.png",
|
||||
"sourcePixels": { "width": 793, "height": 1983 },
|
||||
"outputPixels": { "width": 1440, "height": 3600 },
|
||||
"quality": { "maxBytes": 7000000, "colorSpace": "sRGB" },
|
||||
"processing": { "mode": "opaque-cover-crop", "resample": "lanczos", "anchor": "center", "outputMode": "RGB" }
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"kind": "runtime-asset-inventory",
|
||||
"scope": "schema-v3",
|
||||
"imports": [
|
||||
"design-pipeline/manifests/auth-runtime-assets.json",
|
||||
"design-pipeline/manifests/application-runtime-assets.json",
|
||||
"design-pipeline/manifests/shared-scroll-skins-v3.json",
|
||||
"design-pipeline/manifests/page-backgrounds-v3.json",
|
||||
"design-pipeline/manifests/g01-state-frame-v3.json"
|
||||
],
|
||||
"assets": []
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
{
|
||||
"schemaVersion": 3,
|
||||
"kind": "asset-build-manifest",
|
||||
"family": "shared-scroll-skins-v3",
|
||||
"assets": [
|
||||
{
|
||||
"id": "shared-scroll-primary-v3",
|
||||
"source": "docs/design/assets/a01-vnext/masters/a01-scroll-primary-master-v3.png",
|
||||
"output": "static/assets/foundation/transparent/a01-scroll-primary-v3.png",
|
||||
"sourcePixels": { "width": 2172, "height": 724 },
|
||||
"outputPixels": { "width": 1866, "height": 276 },
|
||||
"alpha": { "required": true, "transparentOuterPadding": 6, "cornerMaxAlpha": 0 },
|
||||
"edge": { "forbidChromaResidue": true, "forbidLightFringe": true, "premultipliedAlphaCheck": true },
|
||||
"quality": { "maxBytes": 1800000, "colorSpace": "sRGB" },
|
||||
"processing": { "mode": "chroma-stretch", "capWidth": 380, "keyColor": "#00FF00", "keyTolerance": 96 }
|
||||
},
|
||||
{
|
||||
"id": "shared-scroll-secondary-v3",
|
||||
"source": "docs/design/assets/a01-vnext/masters/a01-scroll-secondary-master-v3.png",
|
||||
"output": "static/assets/foundation/transparent/a01-scroll-secondary-v3.png",
|
||||
"sourcePixels": { "width": 2172, "height": 724 },
|
||||
"outputPixels": { "width": 1866, "height": 300 },
|
||||
"alpha": { "required": true, "transparentOuterPadding": 6, "cornerMaxAlpha": 0 },
|
||||
"edge": { "forbidChromaResidue": true, "forbidLightFringe": true, "premultipliedAlphaCheck": true },
|
||||
"quality": { "maxBytes": 1800000, "colorSpace": "sRGB" },
|
||||
"processing": { "mode": "chroma-stretch", "capWidth": 380, "keyColor": "#00FF00", "keyTolerance": 96 }
|
||||
},
|
||||
{
|
||||
"id": "shared-scroll-toast-v3",
|
||||
"source": "docs/design/assets/a01-vnext/masters/a01-scroll-toast-master-v3.png",
|
||||
"output": "static/assets/foundation/transparent/a01-scroll-toast-v3.png",
|
||||
"sourcePixels": { "width": 2172, "height": 724 },
|
||||
"outputPixels": { "width": 1770, "height": 246 },
|
||||
"alpha": { "required": true, "transparentOuterPadding": 6, "cornerMaxAlpha": 0 },
|
||||
"edge": { "forbidChromaResidue": true, "forbidLightFringe": true, "premultipliedAlphaCheck": true },
|
||||
"quality": { "maxBytes": 1600000, "colorSpace": "sRGB" },
|
||||
"processing": { "mode": "chroma-stretch", "capWidth": 340, "keyColor": "#00FF00", "keyTolerance": 96 }
|
||||
},
|
||||
{
|
||||
"id": "shared-scroll-dialog-v3",
|
||||
"source": "docs/design/assets/a01-vnext/masters/a01-scroll-dialog-master-v3-r2.png",
|
||||
"output": "static/assets/modules/auth/transparent/a01-scroll-dialog-v3.png",
|
||||
"sourcePixels": { "width": 1370, "height": 1148 },
|
||||
"outputPixels": { "width": 1860, "height": 1560 },
|
||||
"alpha": { "required": true, "transparentOuterPadding": 6, "cornerMaxAlpha": 0 },
|
||||
"edge": { "forbidChromaResidue": true, "forbidLightFringe": true, "premultipliedAlphaCheck": true },
|
||||
"quality": { "maxBytes": 3000000, "colorSpace": "sRGB" },
|
||||
"processing": { "mode": "chroma-stretch", "capWidth": 260, "keyColor": "#00FF00", "keyTolerance": 96, "paletteColors": 128, "indexedPng": true }
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -4,605 +4,7 @@
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "jiapu-design-pipeline",
|
||||
"dependencies": {
|
||||
"sharp": "0.34.5"
|
||||
}
|
||||
},
|
||||
"node_modules/@emnapi/runtime": {
|
||||
"version": "1.11.2",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.2.tgz",
|
||||
"integrity": "sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/colour": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz",
|
||||
"integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-darwin-arm64": {
|
||||
"version": "0.34.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz",
|
||||
"integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-darwin-arm64": "1.2.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-darwin-x64": {
|
||||
"version": "0.34.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz",
|
||||
"integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-darwin-x64": "1.2.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-darwin-arm64": {
|
||||
"version": "1.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz",
|
||||
"integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-darwin-x64": {
|
||||
"version": "1.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz",
|
||||
"integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-linux-arm": {
|
||||
"version": "1.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz",
|
||||
"integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-linux-arm64": {
|
||||
"version": "1.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz",
|
||||
"integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-linux-ppc64": {
|
||||
"version": "1.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz",
|
||||
"integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-linux-riscv64": {
|
||||
"version": "1.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz",
|
||||
"integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-linux-s390x": {
|
||||
"version": "1.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz",
|
||||
"integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-linux-x64": {
|
||||
"version": "1.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz",
|
||||
"integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-linuxmusl-arm64": {
|
||||
"version": "1.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz",
|
||||
"integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-linuxmusl-x64": {
|
||||
"version": "1.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz",
|
||||
"integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-linux-arm": {
|
||||
"version": "0.34.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz",
|
||||
"integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-linux-arm": "1.2.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-linux-arm64": {
|
||||
"version": "0.34.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz",
|
||||
"integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-linux-arm64": "1.2.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-linux-ppc64": {
|
||||
"version": "0.34.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz",
|
||||
"integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-linux-ppc64": "1.2.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-linux-riscv64": {
|
||||
"version": "0.34.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz",
|
||||
"integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-linux-riscv64": "1.2.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-linux-s390x": {
|
||||
"version": "0.34.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz",
|
||||
"integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-linux-s390x": "1.2.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-linux-x64": {
|
||||
"version": "0.34.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz",
|
||||
"integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-linux-x64": "1.2.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-linuxmusl-arm64": {
|
||||
"version": "0.34.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz",
|
||||
"integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-linuxmusl-arm64": "1.2.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-linuxmusl-x64": {
|
||||
"version": "0.34.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz",
|
||||
"integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-linuxmusl-x64": "1.2.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-wasm32": {
|
||||
"version": "0.34.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz",
|
||||
"integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==",
|
||||
"cpu": [
|
||||
"wasm32"
|
||||
],
|
||||
"license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@emnapi/runtime": "^1.7.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-win32-arm64": {
|
||||
"version": "0.34.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz",
|
||||
"integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "Apache-2.0 AND LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-win32-ia32": {
|
||||
"version": "0.34.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz",
|
||||
"integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"license": "Apache-2.0 AND LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-win32-x64": {
|
||||
"version": "0.34.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz",
|
||||
"integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "Apache-2.0 AND LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/detect-libc": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
|
||||
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/semver": {
|
||||
"version": "7.8.5",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
|
||||
"integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
|
||||
"license": "ISC",
|
||||
"bin": {
|
||||
"semver": "bin/semver.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/sharp": {
|
||||
"version": "0.34.5",
|
||||
"resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz",
|
||||
"integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==",
|
||||
"hasInstallScript": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@img/colour": "^1.0.0",
|
||||
"detect-libc": "^2.1.2",
|
||||
"semver": "^7.7.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-darwin-arm64": "0.34.5",
|
||||
"@img/sharp-darwin-x64": "0.34.5",
|
||||
"@img/sharp-libvips-darwin-arm64": "1.2.4",
|
||||
"@img/sharp-libvips-darwin-x64": "1.2.4",
|
||||
"@img/sharp-libvips-linux-arm": "1.2.4",
|
||||
"@img/sharp-libvips-linux-arm64": "1.2.4",
|
||||
"@img/sharp-libvips-linux-ppc64": "1.2.4",
|
||||
"@img/sharp-libvips-linux-riscv64": "1.2.4",
|
||||
"@img/sharp-libvips-linux-s390x": "1.2.4",
|
||||
"@img/sharp-libvips-linux-x64": "1.2.4",
|
||||
"@img/sharp-libvips-linuxmusl-arm64": "1.2.4",
|
||||
"@img/sharp-libvips-linuxmusl-x64": "1.2.4",
|
||||
"@img/sharp-linux-arm": "0.34.5",
|
||||
"@img/sharp-linux-arm64": "0.34.5",
|
||||
"@img/sharp-linux-ppc64": "0.34.5",
|
||||
"@img/sharp-linux-riscv64": "0.34.5",
|
||||
"@img/sharp-linux-s390x": "0.34.5",
|
||||
"@img/sharp-linux-x64": "0.34.5",
|
||||
"@img/sharp-linuxmusl-arm64": "0.34.5",
|
||||
"@img/sharp-linuxmusl-x64": "0.34.5",
|
||||
"@img/sharp-wasm32": "0.34.5",
|
||||
"@img/sharp-win32-arm64": "0.34.5",
|
||||
"@img/sharp-win32-ia32": "0.34.5",
|
||||
"@img/sharp-win32-x64": "0.34.5"
|
||||
}
|
||||
},
|
||||
"node_modules/tslib": {
|
||||
"version": "2.8.1",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||
"license": "0BSD",
|
||||
"optional": true
|
||||
"name": "jiapu-design-pipeline"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,18 +3,16 @@
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build:a01": "node scripts/build-a01.mjs",
|
||||
"test:v2": "node --test tests/*.test.mjs",
|
||||
"validate:a01-buttons": "node scripts/validate-manifest-v2.mjs manifests/a01-buttons-v2.json",
|
||||
"validate:a01-scroll-skins": "node scripts/validate-manifest-v2.mjs manifests/a01-scroll-skins-v3.json",
|
||||
"rebuild:a01-buttons": "node scripts/rebuild-a01-buttons.mjs",
|
||||
"verify:assets": "node scripts/verify-assets.mjs",
|
||||
"build:a01-scroll-skins": "node scripts/build-a01-scroll-skins.mjs",
|
||||
"verify:a01-scroll-skins": "node scripts/verify-a01-scroll-skins.mjs",
|
||||
"build:g01-background-candidates": "node scripts/build-g01-backgrounds.mjs",
|
||||
"build:g01-empty-frame": "node scripts/build-g01-empty-frame.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"sharp": "0.34.5"
|
||||
"test": "npm run test:node && npm run test:python",
|
||||
"test:node": "node --test tests/*.test.mjs",
|
||||
"test:python": "node scripts/run-python-tests.mjs",
|
||||
"validate:shared-scroll-skins": "node scripts/validate-asset-build-manifest.mjs design-pipeline/manifests/shared-scroll-skins-v3.json",
|
||||
"validate:page-backgrounds": "node scripts/validate-asset-build-manifest.mjs design-pipeline/manifests/page-backgrounds-v3.json",
|
||||
"validate:g01-state-frame": "node scripts/validate-asset-build-manifest.mjs design-pipeline/manifests/g01-state-frame-v3.json",
|
||||
"validate:runtime-assets": "node scripts/validate-runtime-asset-inventory.mjs design-pipeline/manifests/runtime-assets.json",
|
||||
"build:shared-scroll-skins": "node scripts/build-shared-scroll-skins.mjs",
|
||||
"verify:shared-scroll-skins": "node scripts/verify-shared-scroll-skins.mjs",
|
||||
"build:page-backgrounds": "node scripts/build-raster-assets.mjs design-pipeline/manifests/page-backgrounds-v3.json",
|
||||
"build:g01-state-frame": "node scripts/build-raster-assets.mjs design-pipeline/manifests/g01-state-frame-v3.json"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,196 @@
|
||||
import path from 'node:path'
|
||||
|
||||
const assertOnlyFields = (value, fields, label) => {
|
||||
for (const field of Object.keys(value)) {
|
||||
if (!fields.has(field)) throw new Error(`${label} contains unknown field: ${field}`)
|
||||
}
|
||||
}
|
||||
|
||||
const requireObject = (value, label) => {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
throw new Error(`${label} must be an object`)
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
const requireString = (value, label) => {
|
||||
if (typeof value !== 'string' || value.trim() === '') {
|
||||
throw new Error(`${label} must be a non-empty string`)
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
const requirePositiveInteger = (value, label) => {
|
||||
if (!Number.isInteger(value) || value <= 0) throw new Error(`${label} must be a positive integer`)
|
||||
return value
|
||||
}
|
||||
|
||||
const requireIntegerInRange = (value, minimum, maximum, label) => {
|
||||
if (!Number.isInteger(value) || value < minimum || value > maximum) {
|
||||
throw new Error(`${label} must be an integer from ${minimum} to ${maximum}`)
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
const requireBoolean = (value, label) => {
|
||||
if (typeof value !== 'boolean') throw new Error(`${label} must be boolean`)
|
||||
return value
|
||||
}
|
||||
|
||||
const requireExact = (value, expected, label) => {
|
||||
if (value !== expected) throw new Error(`${label} must be ${expected}`)
|
||||
return value
|
||||
}
|
||||
|
||||
const resolveInsideWorkspace = (workspace, relativePath, label) => {
|
||||
requireString(relativePath, label)
|
||||
const absolutePath = path.resolve(workspace, relativePath)
|
||||
const relative = path.relative(workspace, absolutePath)
|
||||
if (relative.startsWith('..') || path.isAbsolute(relative)) {
|
||||
throw new Error(`${label} escapes workspace: ${relativePath}`)
|
||||
}
|
||||
return absolutePath
|
||||
}
|
||||
|
||||
const validatePixels = (value, label) => {
|
||||
const pixels = requireObject(value, label)
|
||||
assertOnlyFields(pixels, new Set(['width', 'height']), label)
|
||||
requirePositiveInteger(pixels.width, `${label}.width`)
|
||||
requirePositiveInteger(pixels.height, `${label}.height`)
|
||||
}
|
||||
|
||||
const validateAlphaAndEdge = (asset, id) => {
|
||||
const alpha = requireObject(asset.alpha, `${id}.alpha`)
|
||||
assertOnlyFields(alpha, new Set(['required', 'transparentOuterPadding', 'cornerMaxAlpha']), `${id}.alpha`)
|
||||
requireExact(alpha.required, true, `${id}.alpha.required`)
|
||||
requireIntegerInRange(alpha.transparentOuterPadding, 0, 4096, `${id}.alpha.transparentOuterPadding`)
|
||||
requireIntegerInRange(alpha.cornerMaxAlpha, 0, 255, `${id}.alpha.cornerMaxAlpha`)
|
||||
|
||||
const edge = requireObject(asset.edge, `${id}.edge`)
|
||||
assertOnlyFields(
|
||||
edge,
|
||||
new Set(['forbidChromaResidue', 'forbidLightFringe', 'premultipliedAlphaCheck']),
|
||||
`${id}.edge`,
|
||||
)
|
||||
requireBoolean(edge.forbidChromaResidue, `${id}.edge.forbidChromaResidue`)
|
||||
requireBoolean(edge.forbidLightFringe, `${id}.edge.forbidLightFringe`)
|
||||
requireBoolean(edge.premultipliedAlphaCheck, `${id}.edge.premultipliedAlphaCheck`)
|
||||
}
|
||||
|
||||
const validateProcessing = (processing, id) => {
|
||||
requireObject(processing, `${id}.processing`)
|
||||
const mode = requireString(processing.mode, `${id}.processing.mode`)
|
||||
|
||||
if (mode === 'chroma-stretch') {
|
||||
assertOnlyFields(
|
||||
processing,
|
||||
new Set(['mode', 'capWidth', 'keyColor', 'keyTolerance', 'paletteColors', 'indexedPng']),
|
||||
`${id}.processing`,
|
||||
)
|
||||
requirePositiveInteger(processing.capWidth, `${id}.processing.capWidth`)
|
||||
if (!/^#[A-Fa-f0-9]{6}$/.test(processing.keyColor)) {
|
||||
throw new Error(`${id}.processing.keyColor must be a six-digit RGB color`)
|
||||
}
|
||||
requireIntegerInRange(processing.keyTolerance, 0, 441, `${id}.processing.keyTolerance`)
|
||||
if (processing.paletteColors !== undefined) {
|
||||
requireIntegerInRange(processing.paletteColors, 2, 256, `${id}.processing.paletteColors`)
|
||||
}
|
||||
if (processing.indexedPng !== undefined) {
|
||||
requireBoolean(processing.indexedPng, `${id}.processing.indexedPng`)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
if (mode === 'opaque-resize') {
|
||||
assertOnlyFields(processing, new Set(['mode', 'resample', 'outputMode']), `${id}.processing`)
|
||||
requireExact(processing.resample, 'lanczos', `${id}.processing.resample`)
|
||||
requireExact(processing.outputMode, 'RGB', `${id}.processing.outputMode`)
|
||||
return false
|
||||
}
|
||||
|
||||
if (mode === 'opaque-cover-crop') {
|
||||
assertOnlyFields(processing, new Set(['mode', 'resample', 'anchor', 'outputMode']), `${id}.processing`)
|
||||
requireExact(processing.resample, 'lanczos', `${id}.processing.resample`)
|
||||
requireExact(processing.anchor, 'center', `${id}.processing.anchor`)
|
||||
requireExact(processing.outputMode, 'RGB', `${id}.processing.outputMode`)
|
||||
return false
|
||||
}
|
||||
|
||||
if (mode === 'warm-gold-frame-extract') {
|
||||
assertOnlyFields(
|
||||
processing,
|
||||
new Set([
|
||||
'mode',
|
||||
'borderBand',
|
||||
'redGreenMin',
|
||||
'greenBlueMin',
|
||||
'redBlueMin',
|
||||
'redMaxExclusive',
|
||||
'blueMaxExclusive',
|
||||
'alphaOffset',
|
||||
'alphaScale',
|
||||
'outputMode',
|
||||
]),
|
||||
`${id}.processing`,
|
||||
)
|
||||
requirePositiveInteger(processing.borderBand, `${id}.processing.borderBand`)
|
||||
for (const field of ['redGreenMin', 'greenBlueMin', 'redBlueMin', 'alphaOffset']) {
|
||||
requireIntegerInRange(processing[field], 0, 255, `${id}.processing.${field}`)
|
||||
}
|
||||
for (const field of ['redMaxExclusive', 'blueMaxExclusive']) {
|
||||
requireIntegerInRange(processing[field], 1, 256, `${id}.processing.${field}`)
|
||||
}
|
||||
requirePositiveInteger(processing.alphaScale, `${id}.processing.alphaScale`)
|
||||
requireExact(processing.outputMode, 'RGBA', `${id}.processing.outputMode`)
|
||||
return true
|
||||
}
|
||||
|
||||
throw new Error(`${id} has unsupported processing.mode: ${mode}`)
|
||||
}
|
||||
|
||||
export const validateAssetBuildManifest = (manifest, workspace) => {
|
||||
requireObject(manifest, 'manifest')
|
||||
assertOnlyFields(manifest, new Set(['schemaVersion', 'kind', 'family', 'assets']), 'manifest')
|
||||
if (manifest.schemaVersion !== 3) throw new Error('schemaVersion must be 3')
|
||||
if (manifest.kind !== 'asset-build-manifest') throw new Error('kind must be asset-build-manifest')
|
||||
requireString(manifest.family, 'family')
|
||||
if (!Array.isArray(manifest.assets) || manifest.assets.length === 0) {
|
||||
throw new Error('assets must be a non-empty array')
|
||||
}
|
||||
|
||||
const ids = new Set()
|
||||
const outputs = new Set()
|
||||
for (const asset of manifest.assets) {
|
||||
requireObject(asset, 'asset')
|
||||
const processing = requireObject(asset.processing, 'asset.processing')
|
||||
const mode = requireString(processing.mode, 'asset.processing.mode')
|
||||
const transparent = ['chroma-stretch', 'warm-gold-frame-extract'].includes(mode)
|
||||
const fields = new Set(['id', 'source', 'output', 'sourcePixels', 'outputPixels', 'quality', 'processing'])
|
||||
if (transparent) {
|
||||
fields.add('alpha')
|
||||
fields.add('edge')
|
||||
}
|
||||
assertOnlyFields(asset, fields, 'asset')
|
||||
|
||||
const id = requireString(asset.id, 'asset.id')
|
||||
if (ids.has(id)) throw new Error(`duplicate asset id: ${id}`)
|
||||
ids.add(id)
|
||||
|
||||
resolveInsideWorkspace(workspace, asset.source, `${id}.source`)
|
||||
resolveInsideWorkspace(workspace, asset.output, `${id}.output`)
|
||||
if (outputs.has(asset.output)) throw new Error(`duplicate output: ${asset.output}`)
|
||||
outputs.add(asset.output)
|
||||
|
||||
validatePixels(asset.sourcePixels, `${id}.sourcePixels`)
|
||||
validatePixels(asset.outputPixels, `${id}.outputPixels`)
|
||||
|
||||
const processingNeedsAlpha = validateProcessing(processing, id)
|
||||
if (processingNeedsAlpha) validateAlphaAndEdge(asset, id)
|
||||
|
||||
const quality = requireObject(asset.quality, `${id}.quality`)
|
||||
assertOnlyFields(quality, new Set(['maxBytes', 'colorSpace']), `${id}.quality`)
|
||||
requirePositiveInteger(quality.maxBytes, `${id}.quality.maxBytes`)
|
||||
requireExact(quality.colorSpace, 'sRGB', `${id}.quality.colorSpace`)
|
||||
}
|
||||
return manifest
|
||||
}
|
||||
@@ -15,9 +15,14 @@ def _outer_ring_pixels(image: Image.Image, thickness: int):
|
||||
yield image.getpixel((x, y))
|
||||
|
||||
|
||||
def analyze_asset(path: Path, spec: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Analyze one PNG against its manifest v2 specification."""
|
||||
path = Path(path)
|
||||
def analyze_asset(path: Path, spec: dict[str, Any], workspace: Path) -> dict[str, Any]:
|
||||
"""按 schema v3 物理规格分析单张 PNG,并只输出工作区相对路径。"""
|
||||
workspace = Path(workspace).resolve()
|
||||
path = Path(path).resolve()
|
||||
try:
|
||||
report_path = path.relative_to(workspace).as_posix()
|
||||
except ValueError as error:
|
||||
raise ValueError(f"asset path escapes workspace: {path}") from error
|
||||
errors: list[str] = []
|
||||
warnings: list[str] = []
|
||||
metrics: dict[str, int] = {}
|
||||
@@ -40,14 +45,34 @@ def analyze_asset(path: Path, spec: dict[str, Any]) -> dict[str, Any]:
|
||||
errors.append(f"alpha-required asset has no alpha channel or transparency table, got {mode}")
|
||||
|
||||
padding = int(alpha.get("transparentOuterPadding", 0))
|
||||
max_alpha = int(alpha.get("cornerMaxAlpha", 0))
|
||||
opaque_padding = sum(1 for pixel in _outer_ring_pixels(image, padding) if pixel[3] > max_alpha)
|
||||
max_alpha = int(alpha.get("cornerMaxAlpha", 255))
|
||||
corners = (
|
||||
image.getpixel((0, 0))[3],
|
||||
image.getpixel((width - 1, 0))[3],
|
||||
image.getpixel((0, height - 1))[3],
|
||||
image.getpixel((width - 1, height - 1))[3],
|
||||
)
|
||||
corner_violations = sum(1 for value in corners if value > max_alpha)
|
||||
metrics["cornerAlphaViolations"] = corner_violations
|
||||
if corner_violations:
|
||||
errors.append(f"corners contain {corner_violations} pixels above alpha {max_alpha}")
|
||||
|
||||
# 不透明长背景没有边缘/透明度扫描需求;避免无意义地把每张 1440×3600 图
|
||||
# 展开成数百万个 Python 元组。透明资产仍完整执行原有像素级质量合同。
|
||||
opaque_padding = 0
|
||||
if padding > 0:
|
||||
opaque_padding = sum(1 for pixel in _outer_ring_pixels(image, padding) if pixel[3] > max_alpha)
|
||||
metrics["outerPaddingViolations"] = opaque_padding
|
||||
if opaque_padding:
|
||||
errors.append(f"outer padding contains {opaque_padding} pixels above alpha {max_alpha}")
|
||||
|
||||
edge = spec.get("edge", {})
|
||||
pixels = list(image.get_flattened_data())
|
||||
needs_edge_pixels = any(edge.get(field) for field in (
|
||||
"forbidChromaResidue",
|
||||
"forbidLightFringe",
|
||||
"premultipliedAlphaCheck",
|
||||
))
|
||||
pixels = list(image.get_flattened_data()) if needs_edge_pixels else []
|
||||
|
||||
chroma_residue = sum(
|
||||
1
|
||||
@@ -83,11 +108,15 @@ def analyze_asset(path: Path, spec: dict[str, Any]) -> dict[str, Any]:
|
||||
|
||||
expected_color_space = spec.get("quality", {}).get("colorSpace")
|
||||
if expected_color_space == "sRGB" and not ("srgb" in info or "icc_profile" in info):
|
||||
warnings.append("PNG does not declare an sRGB chunk or ICC profile")
|
||||
errors.append("PNG does not declare an sRGB chunk or ICC profile")
|
||||
|
||||
expected_mode = spec.get("processing", {}).get("outputMode")
|
||||
if expected_mode and mode != expected_mode:
|
||||
errors.append(f"output mode expected {expected_mode}, got {mode}")
|
||||
|
||||
return {
|
||||
"id": spec.get("id", path.stem),
|
||||
"path": str(path),
|
||||
"path": report_path,
|
||||
"width": width,
|
||||
"height": height,
|
||||
"mode": mode,
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { loadAndValidateManifest } from './manifest-v2.mjs'
|
||||
|
||||
const scriptDirectory = path.dirname(fileURLToPath(import.meta.url))
|
||||
const pipelineDirectory = path.resolve(scriptDirectory, '..')
|
||||
const workspace = path.resolve(pipelineDirectory, '..')
|
||||
const manifestPath = path.join(pipelineDirectory, 'manifests', 'a01-scroll-skins-v3.json')
|
||||
const reportPath = path.join(pipelineDirectory, 'generated', 'a01-scroll-skins-v3', 'quality-report.json')
|
||||
const localPython = path.join(pipelineDirectory, '.venv', 'Scripts', 'python.exe')
|
||||
const python = process.env.PYTHON || (fs.existsSync(localPython) ? localPython : 'python')
|
||||
|
||||
await loadAndValidateManifest(manifestPath, workspace)
|
||||
|
||||
function run(script, args) {
|
||||
const result = spawnSync(python, [path.join(scriptDirectory, script), ...args], {
|
||||
cwd: workspace,
|
||||
encoding: 'utf8',
|
||||
stdio: 'inherit',
|
||||
})
|
||||
if (result.error) throw new Error(`无法启动 Python(${python}):${result.error.message}`)
|
||||
if (result.status !== 0) process.exit(result.status ?? 1)
|
||||
}
|
||||
|
||||
run('build_scroll_skins.py', [manifestPath, '--workspace', workspace])
|
||||
run('verify-assets.py', [manifestPath, '--workspace', workspace, '--report', reportPath])
|
||||
@@ -1,159 +0,0 @@
|
||||
import { createHash } from 'node:crypto'
|
||||
import { mkdir, readFile, stat, writeFile } from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import sharp from 'sharp'
|
||||
|
||||
const scriptDirectory = path.dirname(fileURLToPath(import.meta.url))
|
||||
const workspace = path.resolve(scriptDirectory, '..', '..')
|
||||
const manifestPath = path.join(workspace, 'design-pipeline', 'manifests', 'a01.json')
|
||||
const manifest = JSON.parse(await readFile(manifestPath, 'utf8'))
|
||||
const generatedDirectory = path.join(workspace, 'design-pipeline', 'generated', 'a01')
|
||||
|
||||
const resolveWorkspacePath = (relativePath) => {
|
||||
const absolutePath = path.resolve(workspace, relativePath)
|
||||
const relative = path.relative(workspace, absolutePath)
|
||||
if (relative.startsWith('..') || path.isAbsolute(relative)) {
|
||||
throw new Error(`路径越出工作区:${relativePath}`)
|
||||
}
|
||||
return absolutePath
|
||||
}
|
||||
|
||||
const sha256 = (buffer) => createHash('sha256').update(buffer).digest('hex')
|
||||
|
||||
const writeVersionedFile = async (outputPath, buffer) => {
|
||||
await mkdir(path.dirname(outputPath), { recursive: true })
|
||||
try {
|
||||
const current = await readFile(outputPath)
|
||||
if (sha256(current) !== sha256(buffer)) {
|
||||
throw new Error(`拒绝覆盖内容不同的版本化输出:${path.relative(workspace, outputPath)}`)
|
||||
}
|
||||
return
|
||||
} catch (error) {
|
||||
if (error.code !== 'ENOENT') throw error
|
||||
}
|
||||
await writeFile(outputPath, buffer)
|
||||
}
|
||||
|
||||
const runtimeAssets = new Map()
|
||||
const reportAssets = []
|
||||
|
||||
for (const asset of manifest.assets) {
|
||||
const sourcePath = resolveWorkspacePath(asset.source)
|
||||
const outputPath = resolveWorkspacePath(asset.output)
|
||||
await stat(sourcePath)
|
||||
const pipeline = sharp(sourcePath).resize(asset.width, asset.height, { fit: 'fill' })
|
||||
if (!asset.alpha) pipeline.flatten({ background: '#f4eadc' })
|
||||
const buffer = await pipeline.png({ compressionLevel: 9, adaptiveFiltering: true, palette: false }).toBuffer()
|
||||
await writeVersionedFile(outputPath, buffer)
|
||||
runtimeAssets.set(asset.id, buffer)
|
||||
const metadata = await sharp(buffer).metadata()
|
||||
reportAssets.push({
|
||||
id: asset.id,
|
||||
output: asset.output,
|
||||
width: metadata.width,
|
||||
height: metadata.height,
|
||||
bytes: buffer.length,
|
||||
sha256: sha256(buffer)
|
||||
})
|
||||
}
|
||||
|
||||
const loadShared = async (key) => readFile(resolveWorkspacePath(manifest.shared[key]))
|
||||
const shared = {
|
||||
brandSeal: await loadShared('brandSeal'),
|
||||
lock: await loadShared('lock'),
|
||||
wechat: await loadShared('wechat'),
|
||||
agreementUnchecked: await loadShared('agreementUnchecked')
|
||||
}
|
||||
|
||||
const svgLayer = (mode) => Buffer.from(`
|
||||
<svg width="412" height="915" xmlns="http://www.w3.org/2000/svg">
|
||||
<style>
|
||||
.k { font-family: KaiTi, STKaiti, serif; }
|
||||
.red { fill: #9f1717; }
|
||||
.gold { fill: #a97936; }
|
||||
.body { fill: #51443b; }
|
||||
.muted { fill: #9a8e84; }
|
||||
</style>
|
||||
<text x="206" y="321" text-anchor="middle" class="k red" font-size="36">登录家谱</text>
|
||||
<text x="130" y="389" text-anchor="middle" class="k ${mode === 'password' ? 'red' : 'gold'}" font-size="20">密码登录</text>
|
||||
<text x="282" y="389" text-anchor="middle" class="k ${mode === 'sms' ? 'red' : 'gold'}" font-size="20">验证码登录</text>
|
||||
<line x1="61" y1="404" x2="351" y2="404" stroke="#d1a965" stroke-width="1"/>
|
||||
<line x1="${mode === 'password' ? 99 : 251}" y1="401" x2="${mode === 'password' ? 161 : 313}" y2="401" stroke="#bd151b" stroke-width="4"/>
|
||||
<text x="112" y="449" class="k muted" font-size="18">手机号</text>
|
||||
<line x1="61" y1="481" x2="351" y2="481" stroke="#d1a965" stroke-width="1"/>
|
||||
<text x="112" y="559" class="k muted" font-size="18">${mode === 'password' ? '密码' : '验证码'}</text>
|
||||
${mode === 'password'
|
||||
? '<text x="349" y="590" text-anchor="end" class="k red" font-size="15">忘记密码</text>'
|
||||
: '<text x="349" y="559" text-anchor="end" class="k red" font-size="15">获取验证码</text>'}
|
||||
<line x1="61" y1="576" x2="351" y2="576" stroke="#d1a965" stroke-width="1"/>
|
||||
<text x="206" y="646" text-anchor="middle" class="k" fill="#fffaf2" font-size="26">登录</text>
|
||||
<line x1="62" y1="692" x2="151" y2="692" stroke="#d1a965"/>
|
||||
<line x1="261" y1="692" x2="350" y2="692" stroke="#d1a965"/>
|
||||
<text x="206" y="699" text-anchor="middle" class="k gold" font-size="15">其他登录方式</text>
|
||||
<text x="219" y="761" text-anchor="middle" class="k body" font-size="20">微信登录</text>
|
||||
<text x="206" y="812" text-anchor="middle" class="k body" font-size="14">还没有账号? <tspan class="red">注册账号</tspan></text>
|
||||
<text x="93" y="851" class="k body" font-size="11">我已阅读并同意《用户协议》与《隐私政策》</text>
|
||||
</svg>`)
|
||||
|
||||
const sized = async (input, width, height) => sharp(input).resize(width, height, { fit: 'fill' }).png().toBuffer()
|
||||
|
||||
const buildBasePreview = async (mode) => {
|
||||
const stateIcon = mode === 'password' ? shared.lock : runtimeAssets.get('smsThreeDots')
|
||||
const rightIcon = mode === 'password' ? runtimeAssets.get('eyeClosedPupil') : null
|
||||
const layers = [
|
||||
{ input: await sized(runtimeAssets.get('header'), 412, 170), left: 0, top: 0 },
|
||||
{ input: await sized(runtimeAssets.get('scroll'), 392, 772), left: 10, top: 143 },
|
||||
{ input: await sized(shared.brandSeal, 72, 86), left: 170, top: 35 },
|
||||
{ input: await sized(runtimeAssets.get('titleOrnament'), 126, 44), left: 143, top: 235 },
|
||||
{ input: await sized(runtimeAssets.get('divider'), 180, 30), left: 116, top: 330 },
|
||||
{ input: await sized(runtimeAssets.get('phone'), 40, 40), left: 68, top: 420 },
|
||||
{ input: await sized(stateIcon, 40, 40), left: 68, top: 522 },
|
||||
{ input: await sized(runtimeAssets.get('primaryButton'), 292, 65), left: 60, top: 608 },
|
||||
{ input: await sized(runtimeAssets.get('secondaryButton'), 292, 60), left: 60, top: 718 },
|
||||
{ input: await sized(shared.wechat, 36, 36), left: 126, top: 730 },
|
||||
{ input: await sized(shared.agreementUnchecked, 24, 24), left: 62, top: 831 },
|
||||
{ input: svgLayer(mode), left: 0, top: 0 }
|
||||
]
|
||||
if (rightIcon) layers.splice(7, 0, { input: await sized(rightIcon, 42, 35), left: 312, top: 525 })
|
||||
return sharp({ create: { width: 412, height: 915, channels: 4, background: '#f4eadc' } })
|
||||
.composite(layers)
|
||||
.png({ compressionLevel: 9, adaptiveFiltering: true })
|
||||
.toBuffer()
|
||||
}
|
||||
|
||||
await mkdir(generatedDirectory, { recursive: true })
|
||||
const passwordPreview = await buildBasePreview('password')
|
||||
const smsPreview = await buildBasePreview('sms')
|
||||
|
||||
const writeGenerated = async (relativePath, buffer) => {
|
||||
const outputPath = resolveWorkspacePath(relativePath)
|
||||
await mkdir(path.dirname(outputPath), { recursive: true })
|
||||
await writeFile(outputPath, buffer)
|
||||
}
|
||||
|
||||
await writeGenerated('design-pipeline/generated/a01/A01-password-412x915.png', passwordPreview)
|
||||
await writeGenerated('design-pipeline/generated/a01/A01-sms-412x915.png', smsPreview)
|
||||
const contact = await sharp({ create: { width: 824, height: 915, channels: 4, background: '#ffffff' } })
|
||||
.composite([{ input: passwordPreview, left: 0, top: 0 }, { input: smsPreview, left: 412, top: 0 }])
|
||||
.png({ compressionLevel: 9, adaptiveFiltering: true })
|
||||
.toBuffer()
|
||||
await writeGenerated('design-pipeline/generated/a01/A01-password-sms-contact-824x915.png', contact)
|
||||
|
||||
for (const preview of manifest.previews.filter((item) => item.width !== 412 && item.width !== 824)) {
|
||||
const compact = await sharp(passwordPreview)
|
||||
.resize(preview.width, preview.height, { fit: 'fill' })
|
||||
.png({ compressionLevel: 9, adaptiveFiltering: true })
|
||||
.toBuffer()
|
||||
await writeGenerated(preview.output, compact)
|
||||
}
|
||||
|
||||
const buildReport = {
|
||||
schemaVersion: 1,
|
||||
manifest: path.relative(workspace, manifestPath).replaceAll('\\', '/'),
|
||||
assets: reportAssets,
|
||||
generatedAt: new Date().toISOString()
|
||||
}
|
||||
await writeGenerated('design-pipeline/generated/a01/build-report.json', Buffer.from(`${JSON.stringify(buildReport, null, 2)}\n`))
|
||||
|
||||
console.log('A01-CODE-PIPELINE BUILD PASS')
|
||||
@@ -1,33 +0,0 @@
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const scriptDirectory = path.dirname(fileURLToPath(import.meta.url))
|
||||
const pipelineDirectory = path.resolve(scriptDirectory, '..')
|
||||
const workspace = path.resolve(pipelineDirectory, '..')
|
||||
const manifestPath = path.join(pipelineDirectory, 'manifests', 'g01-background-candidates.json')
|
||||
const reportPath = path.join(pipelineDirectory, 'generated', 'g01-background', 'build-report.json')
|
||||
const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'))
|
||||
|
||||
if (manifest.schemaVersion !== 1 || manifest.page !== 'G01' || manifest.candidates?.length !== 4) {
|
||||
throw new Error('G01 background manifest must declare exactly four schema v1 candidates')
|
||||
}
|
||||
|
||||
for (const candidate of manifest.candidates) {
|
||||
const source = path.resolve(workspace, candidate.master)
|
||||
const relative = path.relative(workspace, source)
|
||||
if (relative.startsWith('..') || path.isAbsolute(relative)) throw new Error(`${candidate.id} master escapes workspace`)
|
||||
if (!fs.existsSync(source)) throw new Error(`${candidate.id} master is missing: ${candidate.master}`)
|
||||
}
|
||||
|
||||
const localPython = path.join(pipelineDirectory, '.venv', 'Scripts', 'python.exe')
|
||||
const python = process.env.PYTHON || (fs.existsSync(localPython) ? localPython : 'python')
|
||||
const result = spawnSync(
|
||||
python,
|
||||
[path.join(scriptDirectory, 'build_g01_backgrounds.py'), manifestPath, '--workspace', workspace, '--report', reportPath],
|
||||
{ cwd: workspace, encoding: 'utf8', stdio: 'inherit' }
|
||||
)
|
||||
|
||||
if (result.error) throw new Error(`无法启动 Python(${python}):${result.error.message}`)
|
||||
if (result.status !== 0) process.exit(result.status ?? 1)
|
||||
@@ -1,51 +0,0 @@
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import sharp from 'sharp'
|
||||
|
||||
const scriptDirectory = path.dirname(fileURLToPath(import.meta.url))
|
||||
const projectRoot = path.resolve(scriptDirectory, '..', '..')
|
||||
const sourcePath = path.join(projectRoot, 'static', 'assets', 'modules', 'genealogy', 'opaque', 'g01-empty-panel.png')
|
||||
const outputPath = path.join(projectRoot, 'static', 'assets', 'modules', 'genealogy', 'transparent', 'g01-empty-panel-frame.png')
|
||||
const borderBand = 110
|
||||
|
||||
const { data, info } = await sharp(sourcePath)
|
||||
.ensureAlpha()
|
||||
.raw()
|
||||
.toBuffer({ resolveWithObject: true })
|
||||
|
||||
for (let y = 0; y < info.height; y += 1) {
|
||||
for (let x = 0; x < info.width; x += 1) {
|
||||
const offset = (y * info.width + x) * info.channels
|
||||
const red = data[offset]
|
||||
const green = data[offset + 1]
|
||||
const blue = data[offset + 2]
|
||||
const alpha = data[offset + 3]
|
||||
const distanceToEdge = Math.min(x, y, info.width - 1 - x, info.height - 1 - y)
|
||||
const isWarmGold = red > green
|
||||
&& green > blue
|
||||
&& red - green >= 15
|
||||
&& green - blue >= 12
|
||||
&& red - blue >= 35
|
||||
&& red < 245
|
||||
&& blue < 180
|
||||
|
||||
if (distanceToEdge >= borderBand || !isWarmGold) {
|
||||
data[offset + 3] = 0
|
||||
continue
|
||||
}
|
||||
|
||||
const edgeAlpha = Math.max(0, Math.min(255, (red - blue - 25) * 6))
|
||||
data[offset + 3] = Math.min(alpha, edgeAlpha)
|
||||
}
|
||||
}
|
||||
|
||||
await sharp(data, {
|
||||
raw: {
|
||||
width: info.width,
|
||||
height: info.height,
|
||||
channels: info.channels
|
||||
}
|
||||
}).png().toFile(outputPath)
|
||||
|
||||
process.stdout.write(`BUILT ${path.relative(projectRoot, outputPath)}\n`)
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
import { validateAssetBuildManifest } from './asset-build-manifest.mjs'
|
||||
import { resolvePythonExecutable, runPythonCommand } from './python-runtime.mjs'
|
||||
|
||||
const scriptDirectory = path.dirname(fileURLToPath(import.meta.url))
|
||||
const pipelineDirectory = path.resolve(scriptDirectory, '..')
|
||||
const workspace = path.resolve(pipelineDirectory, '..')
|
||||
const argument = process.argv[2]
|
||||
if (!argument) throw new Error('用法:node build-raster-assets.mjs <仓库相对清单路径>')
|
||||
|
||||
// 清单参数本身也必须留在工作区;source/output 的边界由 schema validator 逐项负责。
|
||||
const manifestPath = path.resolve(workspace, argument)
|
||||
const relativeManifest = path.relative(workspace, manifestPath)
|
||||
if (relativeManifest.startsWith('..') || path.isAbsolute(relativeManifest)) {
|
||||
throw new Error(`构建清单越出工作区:${argument}`)
|
||||
}
|
||||
|
||||
const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'))
|
||||
validateAssetBuildManifest(manifest, workspace)
|
||||
const supportedModes = new Set(['opaque-resize', 'opaque-cover-crop', 'warm-gold-frame-extract'])
|
||||
for (const asset of manifest.assets) {
|
||||
if (!supportedModes.has(asset.processing.mode)) {
|
||||
throw new Error(`通用 raster 构建器不支持模式:${asset.processing.mode}`)
|
||||
}
|
||||
}
|
||||
|
||||
const python = resolvePythonExecutable({ pipelineDirectory })
|
||||
const reportPath = path.join(pipelineDirectory, 'generated', manifest.family, 'quality-report.json')
|
||||
|
||||
// Node 只编排严格 schema、锁定的 Python 入口和质量报告;像素算法只存在于 Python。
|
||||
runPythonCommand({
|
||||
executable: python,
|
||||
args: [path.join(scriptDirectory, 'build_raster_assets.py'), manifestPath, '--workspace', workspace],
|
||||
cwd: workspace,
|
||||
})
|
||||
runPythonCommand({
|
||||
executable: python,
|
||||
args: [path.join(scriptDirectory, 'verify-assets.py'), manifestPath, '--workspace', workspace, '--report', reportPath],
|
||||
cwd: workspace,
|
||||
})
|
||||
@@ -0,0 +1,28 @@
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
import { validateAssetBuildManifest } from './asset-build-manifest.mjs'
|
||||
import { resolvePythonExecutable, runPythonCommand } from './python-runtime.mjs'
|
||||
|
||||
const scriptDirectory = path.dirname(fileURLToPath(import.meta.url))
|
||||
const pipelineDirectory = path.resolve(scriptDirectory, '..')
|
||||
const workspace = path.resolve(pipelineDirectory, '..')
|
||||
const manifestPath = path.join(pipelineDirectory, 'manifests', 'shared-scroll-skins-v3.json')
|
||||
const reportPath = path.join(pipelineDirectory, 'generated', 'shared-scroll-skins-v3', 'quality-report.json')
|
||||
const python = resolvePythonExecutable({ pipelineDirectory })
|
||||
const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'))
|
||||
|
||||
validateAssetBuildManifest(manifest, workspace)
|
||||
|
||||
// Node 只负责编排锁定的清单和 Python 工具;像素处理与质量分析各自只有一个实现。
|
||||
function run(script, args) {
|
||||
runPythonCommand({
|
||||
executable: python,
|
||||
args: [path.join(scriptDirectory, script), ...args],
|
||||
cwd: workspace,
|
||||
})
|
||||
}
|
||||
|
||||
run('build_scroll_skins.py', [manifestPath, '--workspace', workspace])
|
||||
run('verify-assets.py', [manifestPath, '--workspace', workspace, '--report', reportPath])
|
||||
@@ -1,128 +0,0 @@
|
||||
"""Deterministically build G01 background candidates from saved ImageGen masters."""
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from PIL import Image, PngImagePlugin
|
||||
|
||||
|
||||
def process_chroma_image(source: Image.Image, config: dict[str, Any]) -> Image.Image:
|
||||
"""Convert a green-screen master to clean RGBA with edge despill."""
|
||||
start = int(config["dominanceStart"])
|
||||
end = int(config["dominanceEnd"])
|
||||
allowance = int(config["despillAllowance"])
|
||||
if end <= start:
|
||||
raise ValueError("dominanceEnd must be greater than dominanceStart")
|
||||
|
||||
output = Image.new("RGBA", source.size, (0, 0, 0, 0))
|
||||
converted = []
|
||||
for red, green, blue, source_alpha in source.convert("RGBA").get_flattened_data():
|
||||
dominance = green - max(red, blue)
|
||||
if green >= 100 and dominance > start:
|
||||
removal = min(1.0, (dominance - start) / (end - start))
|
||||
alpha = round(source_alpha * (1.0 - removal))
|
||||
if alpha <= 2:
|
||||
converted.append((0, 0, 0, 0))
|
||||
continue
|
||||
green = min(green, max(red, blue) + allowance)
|
||||
converted.append((red, green, blue, alpha))
|
||||
else:
|
||||
converted.append((red, green, blue, source_alpha))
|
||||
output.putdata(converted)
|
||||
return output
|
||||
|
||||
|
||||
def process_opaque_image(source: Image.Image, output_size: tuple[int, int] | None = None) -> Image.Image:
|
||||
"""Normalize a paper-backed master to deterministic opaque RGBA."""
|
||||
output = source.convert("RGBA")
|
||||
if output_size is not None and output.size != output_size:
|
||||
output = output.resize(output_size, Image.Resampling.LANCZOS)
|
||||
output.putalpha(255)
|
||||
return output
|
||||
|
||||
|
||||
def save_png(image: Image.Image, path: Path) -> None:
|
||||
"""Save a deterministic PNG with an explicit standard-sRGB chunk."""
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
png_info = PngImagePlugin.PngInfo()
|
||||
png_info.add(b"sRGB", b"\x00")
|
||||
image.save(path, format="PNG", optimize=True, compress_level=9, pnginfo=png_info)
|
||||
|
||||
|
||||
def _resolve_inside(workspace: Path, relative_path: str) -> Path:
|
||||
absolute = (workspace / relative_path).resolve()
|
||||
try:
|
||||
absolute.relative_to(workspace.resolve())
|
||||
except ValueError as error:
|
||||
raise ValueError(f"path escapes workspace: {relative_path}") from error
|
||||
return absolute
|
||||
|
||||
|
||||
def _sha256(path: Path) -> str:
|
||||
digest = hashlib.sha256()
|
||||
with path.open("rb") as stream:
|
||||
for chunk in iter(lambda: stream.read(1024 * 1024), b""):
|
||||
digest.update(chunk)
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def build(manifest_path: Path, workspace: Path, report_path: Path) -> list[dict[str, Any]]:
|
||||
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
|
||||
chroma = manifest["chromaKey"]
|
||||
reports = []
|
||||
|
||||
for candidate in manifest["candidates"]:
|
||||
source_path = _resolve_inside(workspace, candidate["master"])
|
||||
output_path = _resolve_inside(workspace, candidate["generatedOutput"])
|
||||
with Image.open(source_path) as opened:
|
||||
expected = candidate["sourcePixels"]
|
||||
if opened.size != (expected["width"], expected["height"]):
|
||||
raise ValueError(
|
||||
f"{candidate['id']} expected {expected['width']}x{expected['height']}, got {opened.width}x{opened.height}"
|
||||
)
|
||||
if candidate["processingMode"] == "chroma-key":
|
||||
output = process_chroma_image(opened, chroma)
|
||||
elif candidate["processingMode"] in {"opaque-paper", "opaque-paper-resize"}:
|
||||
output_size = None
|
||||
if candidate["processingMode"] == "opaque-paper-resize":
|
||||
target = candidate["outputPixels"]
|
||||
output_size = (target["width"], target["height"])
|
||||
output = process_opaque_image(opened, output_size)
|
||||
else:
|
||||
raise ValueError(f"unsupported processingMode: {candidate['processingMode']}")
|
||||
|
||||
save_png(output, output_path)
|
||||
reports.append(
|
||||
{
|
||||
"id": candidate["id"],
|
||||
"mode": candidate["processingMode"],
|
||||
"source": candidate["master"],
|
||||
"output": candidate["generatedOutput"],
|
||||
"width": output.width,
|
||||
"height": output.height,
|
||||
"bytes": output_path.stat().st_size,
|
||||
"sha256": _sha256(output_path),
|
||||
}
|
||||
)
|
||||
|
||||
report_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
report_path.write_text(json.dumps(reports, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
return reports
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("manifest", type=Path)
|
||||
parser.add_argument("--workspace", type=Path, required=True)
|
||||
parser.add_argument("--report", type=Path, required=True)
|
||||
args = parser.parse_args()
|
||||
reports = build(args.manifest, args.workspace.resolve(), args.report)
|
||||
for report in reports:
|
||||
print(f"G01-BACKGROUND-BUILD PASS {report['id']} {report['width']}x{report['height']} {report['sha256']}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,61 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from PIL import Image
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
MANIFEST = ROOT / "design-pipeline/manifests/module-page-backgrounds.json"
|
||||
|
||||
|
||||
def sha256(path: Path) -> str:
|
||||
digest = hashlib.sha256()
|
||||
with path.open("rb") as stream:
|
||||
for chunk in iter(lambda: stream.read(1024 * 1024), b""):
|
||||
digest.update(chunk)
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def normalize(source: Path, output: Path, width: int, height: int) -> None:
|
||||
with Image.open(source) as image:
|
||||
image = image.convert("RGB")
|
||||
scale = max(width / image.width, height / image.height)
|
||||
resized = image.resize(
|
||||
(round(image.width * scale), round(image.height * scale)),
|
||||
Image.Resampling.LANCZOS,
|
||||
)
|
||||
left = max(0, (resized.width - width) // 2)
|
||||
top = max(0, (resized.height - height) // 2)
|
||||
normalized = resized.crop((left, top, left + width, top + height))
|
||||
output.parent.mkdir(parents=True, exist_ok=True)
|
||||
normalized.save(output, format="PNG", optimize=True)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
manifest = json.loads(MANIFEST.read_text(encoding="utf-8"))
|
||||
width = int(manifest["width"])
|
||||
height = int(manifest["height"])
|
||||
report = []
|
||||
for item in manifest["backgrounds"]:
|
||||
source = ROOT / item["source"]
|
||||
output = ROOT / item["output"]
|
||||
if not source.is_file():
|
||||
raise FileNotFoundError(source)
|
||||
normalize(source, output, width, height)
|
||||
report.append(
|
||||
{
|
||||
"module": item["module"],
|
||||
"source": item["source"],
|
||||
"output": item["output"],
|
||||
"size": [width, height],
|
||||
"sha256": sha256(output),
|
||||
}
|
||||
)
|
||||
print(json.dumps(report, ensure_ascii=False, indent=2))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,132 @@
|
||||
"""按 schema v3 清单确定性生成不透明长背景与 G01 暖金边框。"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from PIL import Image, PngImagePlugin
|
||||
|
||||
|
||||
def _resolve_inside(workspace: Path, relative_path: str) -> Path:
|
||||
"""解析仓库相对路径,并在任何读写发生前拒绝目录逃逸。"""
|
||||
absolute = (workspace / relative_path).resolve()
|
||||
try:
|
||||
absolute.relative_to(workspace.resolve())
|
||||
except ValueError as error:
|
||||
raise ValueError(f"path escapes workspace: {relative_path}") from error
|
||||
return absolute
|
||||
|
||||
|
||||
def build_opaque_resize(source: Image.Image, output_size: tuple[int, int]) -> Image.Image:
|
||||
"""把同宽高比母版按 LANCZOS 直接缩放为无透明通道的 RGB 正式图。"""
|
||||
image = source.convert("RGB")
|
||||
if image.size != output_size:
|
||||
image = image.resize(output_size, Image.Resampling.LANCZOS)
|
||||
return image
|
||||
|
||||
|
||||
def build_opaque_cover_crop(source: Image.Image, output_size: tuple[int, int]) -> Image.Image:
|
||||
"""等比 cover 后从中心裁切;算法与旧五模块构建器的可见像素完全一致。"""
|
||||
width, height = output_size
|
||||
image = source.convert("RGB")
|
||||
scale = max(width / image.width, height / image.height)
|
||||
resized = image.resize(
|
||||
(round(image.width * scale), round(image.height * scale)),
|
||||
Image.Resampling.LANCZOS,
|
||||
)
|
||||
left = max(0, (resized.width - width) // 2)
|
||||
top = max(0, (resized.height - height) // 2)
|
||||
return resized.crop((left, top, left + width, top + height))
|
||||
|
||||
|
||||
def extract_warm_gold_frame(source: Image.Image, config: dict[str, Any]) -> Image.Image:
|
||||
"""复刻旧 Sharp 暖金边框阈值,同时清零全透明像素的隐藏 RGB。"""
|
||||
image = source.convert("RGBA")
|
||||
width, height = image.size
|
||||
border_band = int(config["borderBand"])
|
||||
output = Image.new("RGBA", image.size, (0, 0, 0, 0))
|
||||
result: list[tuple[int, int, int, int]] = []
|
||||
|
||||
for index, (red, green, blue, source_alpha) in enumerate(image.get_flattened_data()):
|
||||
x = index % width
|
||||
y = index // width
|
||||
distance_to_edge = min(x, y, width - 1 - x, height - 1 - y)
|
||||
warm_gold = (
|
||||
red > green > blue
|
||||
and red - green >= int(config["redGreenMin"])
|
||||
and green - blue >= int(config["greenBlueMin"])
|
||||
and red - blue >= int(config["redBlueMin"])
|
||||
and red < int(config["redMaxExclusive"])
|
||||
and blue < int(config["blueMaxExclusive"])
|
||||
)
|
||||
if distance_to_edge >= border_band or not warm_gold:
|
||||
result.append((0, 0, 0, 0))
|
||||
continue
|
||||
|
||||
edge_alpha = max(
|
||||
0,
|
||||
min(
|
||||
255,
|
||||
(red - blue - int(config["alphaOffset"])) * int(config["alphaScale"]),
|
||||
),
|
||||
)
|
||||
alpha = min(source_alpha, edge_alpha)
|
||||
result.append((red, green, blue, alpha) if alpha else (0, 0, 0, 0))
|
||||
|
||||
output.putdata(result)
|
||||
return output
|
||||
|
||||
|
||||
def save_png(image: Image.Image, path: Path) -> None:
|
||||
"""以固定压缩参数和标准 sRGB chunk 保存,保证同输入得到同字节。"""
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
png_info = PngImagePlugin.PngInfo()
|
||||
png_info.add(b"sRGB", b"\x00")
|
||||
image.save(path, format="PNG", optimize=True, compress_level=9, pnginfo=png_info)
|
||||
|
||||
|
||||
def build_manifest(manifest_path: Path, workspace: Path) -> None:
|
||||
"""执行已经由 Node 严格校验的清单,并再次核对真实母版像素尺寸。"""
|
||||
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
|
||||
for asset in manifest["assets"]:
|
||||
source_path = _resolve_inside(workspace, asset["source"])
|
||||
output_path = _resolve_inside(workspace, asset["output"])
|
||||
expected_source = asset["sourcePixels"]
|
||||
output_pixels = asset["outputPixels"]
|
||||
output_size = (int(output_pixels["width"]), int(output_pixels["height"]))
|
||||
|
||||
with Image.open(source_path) as source:
|
||||
expected_size = (int(expected_source["width"]), int(expected_source["height"]))
|
||||
if source.size != expected_size:
|
||||
raise ValueError(
|
||||
f"{asset['id']} source expected {expected_size[0]}x{expected_size[1]}, "
|
||||
f"got {source.width}x{source.height}"
|
||||
)
|
||||
|
||||
mode = asset["processing"]["mode"]
|
||||
if mode == "opaque-resize":
|
||||
output = build_opaque_resize(source, output_size)
|
||||
elif mode == "opaque-cover-crop":
|
||||
output = build_opaque_cover_crop(source, output_size)
|
||||
elif mode == "warm-gold-frame-extract":
|
||||
output = extract_warm_gold_frame(source, asset["processing"])
|
||||
else:
|
||||
raise ValueError(f"unsupported raster processing mode: {mode}")
|
||||
|
||||
if output.size != output_size:
|
||||
raise ValueError(f"{asset['id']} produced unexpected output size: {output.size}")
|
||||
save_png(output, output_path)
|
||||
print(f"BUILT {asset['id']} -> {asset['output']}")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("manifest", type=Path)
|
||||
parser.add_argument("--workspace", type=Path, required=True)
|
||||
args = parser.parse_args()
|
||||
build_manifest(args.manifest.resolve(), args.workspace.resolve())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Build exact A01 scroll-skin outputs from chroma-backed visual masters."""
|
||||
"""从锁定的色键母版确定性生成项目共享卷轴资产。"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
@@ -127,7 +127,16 @@ def build_asset(asset: dict, workspace: Path) -> None:
|
||||
source_path = workspace / asset["source"]
|
||||
output_path = workspace / asset["output"]
|
||||
|
||||
if processing["mode"] != "chroma-stretch":
|
||||
raise ValueError(f"unsupported scroll processing mode: {processing['mode']}")
|
||||
|
||||
with Image.open(source_path) as source:
|
||||
expected = asset["sourcePixels"]
|
||||
if source.size != (expected["width"], expected["height"]):
|
||||
raise ValueError(
|
||||
f"{asset['id']} source expected {expected['width']}x{expected['height']}, "
|
||||
f"got {source.width}x{source.height}"
|
||||
)
|
||||
cleaned = remove_chroma_background(
|
||||
source,
|
||||
parse_hex_color(processing["keyColor"]),
|
||||
|
||||
@@ -1,121 +0,0 @@
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
|
||||
const allowedAssetClasses = new Set([
|
||||
'code-native',
|
||||
'fixed-bitmap',
|
||||
'transparent-overlay',
|
||||
'nine-slice',
|
||||
'tile-texture'
|
||||
])
|
||||
|
||||
const requireObject = (value, label) => {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
throw new Error(`${label} must be an object`)
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
const requireString = (value, label) => {
|
||||
if (typeof value !== 'string' || value.trim() === '') throw new Error(`${label} must be a non-empty string`)
|
||||
return value
|
||||
}
|
||||
|
||||
const requirePositiveNumber = (value, label) => {
|
||||
if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) {
|
||||
throw new Error(`${label} must be a positive number`)
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
const resolveWorkspacePath = (workspace, relativePath, label) => {
|
||||
requireString(relativePath, label)
|
||||
const absolutePath = path.resolve(workspace, relativePath)
|
||||
const relative = path.relative(workspace, absolutePath)
|
||||
if (relative.startsWith('..') || path.isAbsolute(relative)) {
|
||||
throw new Error(`${label} escapes workspace: ${relativePath}`)
|
||||
}
|
||||
return absolutePath
|
||||
}
|
||||
|
||||
const requireBoolean = (value, label) => {
|
||||
if (typeof value !== 'boolean') throw new Error(`${label} must be boolean`)
|
||||
return value
|
||||
}
|
||||
|
||||
export const validateManifest = (manifest, workspace) => {
|
||||
requireObject(manifest, 'manifest')
|
||||
if (manifest.schemaVersion !== 2) throw new Error('schemaVersion must be 2')
|
||||
requireString(manifest.page, 'page')
|
||||
requireObject(manifest.runtime, 'runtime')
|
||||
requireString(manifest.runtime.url, 'runtime.url')
|
||||
requirePositiveNumber(manifest.runtime.chromePort, 'runtime.chromePort')
|
||||
if (!Array.isArray(manifest.assets) || manifest.assets.length === 0) throw new Error('assets must be a non-empty array')
|
||||
|
||||
const ids = new Set()
|
||||
for (const asset of manifest.assets) {
|
||||
requireObject(asset, 'asset')
|
||||
const id = requireString(asset.id, 'asset.id')
|
||||
if (ids.has(id)) throw new Error(`duplicate asset id: ${id}`)
|
||||
ids.add(id)
|
||||
|
||||
if (!allowedAssetClasses.has(asset.assetClass)) throw new Error(`unsupported assetClass: ${asset.assetClass}`)
|
||||
resolveWorkspacePath(workspace, asset.source, `${id}.source`)
|
||||
resolveWorkspacePath(workspace, asset.output, `${id}.output`)
|
||||
|
||||
const logicalSlot = requireObject(asset.logicalSlot, `${id}.logicalSlot`)
|
||||
const outputPixels = requireObject(asset.outputPixels, `${id}.outputPixels`)
|
||||
const widthRpx = requirePositiveNumber(logicalSlot.widthRpx, `${id}.logicalSlot.widthRpx`)
|
||||
const heightRpx = requirePositiveNumber(logicalSlot.heightRpx, `${id}.logicalSlot.heightRpx`)
|
||||
const width = requirePositiveNumber(outputPixels.width, `${id}.outputPixels.width`)
|
||||
const height = requirePositiveNumber(outputPixels.height, `${id}.outputPixels.height`)
|
||||
|
||||
const render = requireObject(asset.render, `${id}.render`)
|
||||
requireString(render.scalePolicy, `${id}.render.scalePolicy`)
|
||||
requireString(render.uniMode, `${id}.render.uniMode`)
|
||||
requireBoolean(render.allowDistortion, `${id}.render.allowDistortion`)
|
||||
if (render.scalePolicy === 'uniform-only' && render.uniMode === 'scaleToFill') {
|
||||
throw new Error(`${id} uniform-only asset cannot use scaleToFill`)
|
||||
}
|
||||
const ratioDrift = Math.abs((width / height) / (widthRpx / heightRpx) - 1)
|
||||
if (asset.assetClass === 'fixed-bitmap' && ratioDrift > 0.005) {
|
||||
throw new Error(`${id} ratio drift exceeds 0.5%`)
|
||||
}
|
||||
|
||||
const alpha = requireObject(asset.alpha, `${id}.alpha`)
|
||||
requireBoolean(alpha.required, `${id}.alpha.required`)
|
||||
requirePositiveNumber(alpha.transparentOuterPadding, `${id}.alpha.transparentOuterPadding`)
|
||||
if (!Number.isInteger(alpha.cornerMaxAlpha) || alpha.cornerMaxAlpha < 0 || alpha.cornerMaxAlpha > 255) {
|
||||
throw new Error(`${id}.alpha.cornerMaxAlpha must be an integer from 0 to 255`)
|
||||
}
|
||||
|
||||
const edge = requireObject(asset.edge, `${id}.edge`)
|
||||
requireBoolean(edge.forbidChromaResidue, `${id}.edge.forbidChromaResidue`)
|
||||
requireBoolean(edge.forbidLightFringe, `${id}.edge.forbidLightFringe`)
|
||||
requireBoolean(edge.premultipliedAlphaCheck, `${id}.edge.premultipliedAlphaCheck`)
|
||||
|
||||
const quality = requireObject(asset.quality, `${id}.quality`)
|
||||
requirePositiveNumber(quality.maxBytes, `${id}.quality.maxBytes`)
|
||||
requireString(quality.colorSpace, `${id}.quality.colorSpace`)
|
||||
|
||||
const processing = requireObject(asset.processing, `${id}.processing`)
|
||||
requirePositiveNumber(processing.trim, `${id}.processing.trim`)
|
||||
requirePositiveNumber(processing.capWidth, `${id}.processing.capWidth`)
|
||||
|
||||
const runtime = requireObject(asset.runtime, `${id}.runtime`)
|
||||
requireString(runtime.selector, `${id}.runtime.selector`)
|
||||
requireString(runtime.imageSelector, `${id}.runtime.imageSelector`)
|
||||
|
||||
if (!Array.isArray(asset.consumers) || asset.consumers.length === 0) {
|
||||
throw new Error(`${id}.consumers must be a non-empty array`)
|
||||
}
|
||||
for (const consumer of asset.consumers) resolveWorkspacePath(workspace, consumer, `${id}.consumer`)
|
||||
}
|
||||
return manifest
|
||||
}
|
||||
|
||||
export const loadAndValidateManifest = async (filePath, workspace) => {
|
||||
const manifest = JSON.parse(await readFile(filePath, 'utf8'))
|
||||
return validateManifest(manifest, workspace)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { spawnSync } from 'node:child_process'
|
||||
|
||||
function canRunPython(executable) {
|
||||
const result = spawnSync(executable, ['--version'], {
|
||||
encoding: 'utf8',
|
||||
stdio: 'ignore',
|
||||
})
|
||||
return !result.error && result.status === 0
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析设计构建管线唯一可用的 Python 入口。
|
||||
*
|
||||
* 顺序必须保持稳定:显式配置 > 项目虚拟环境 > Windows Python Manager >
|
||||
* 系统命令。这样既尊重调用方选择,也不会把 WindowsApps 的零字节执行别名
|
||||
* 误判为真实解释器。每个候选都必须实际执行 `--version`,文件存在本身不算可用。
|
||||
*/
|
||||
export function resolvePythonExecutable({
|
||||
pipelineDirectory,
|
||||
environment = process.env,
|
||||
platform = process.platform,
|
||||
pathExists = fs.existsSync,
|
||||
canRun = canRunPython,
|
||||
} = {}) {
|
||||
if (!pipelineDirectory) throw new Error('解析 Python 入口时缺少 design-pipeline 目录')
|
||||
|
||||
const configured = environment.PYTHON?.trim()
|
||||
if (configured) {
|
||||
if (canRun(configured)) return configured
|
||||
throw new Error(`PYTHON 指定的解释器不可用:${configured}`)
|
||||
}
|
||||
|
||||
const pathApi = platform === 'win32' ? path.win32 : path.posix
|
||||
const candidates = []
|
||||
const virtualEnvironment = platform === 'win32'
|
||||
? pathApi.join(pipelineDirectory, '.venv', 'Scripts', 'python.exe')
|
||||
: pathApi.join(pipelineDirectory, '.venv', 'bin', 'python')
|
||||
if (pathExists(virtualEnvironment)) candidates.push(virtualEnvironment)
|
||||
|
||||
if (platform === 'win32' && environment.LOCALAPPDATA) {
|
||||
const managerPython = pathApi.join(environment.LOCALAPPDATA, 'Python', 'bin', 'python.exe')
|
||||
if (pathExists(managerPython)) candidates.push(managerPython)
|
||||
}
|
||||
|
||||
candidates.push(platform === 'win32' ? 'python' : 'python3', 'python')
|
||||
for (const candidate of new Set(candidates)) {
|
||||
if (canRun(candidate)) return candidate
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
'未找到可用的 Python。请设置 PYTHON 为真实解释器路径,或在 design-pipeline/.venv 中安装项目虚拟环境。',
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 通过统一入口执行 Python,保证任何构建和测试都不会向源码目录写入 `.pyc`。
|
||||
* 调用方只提供业务参数;`-B`、进程错误和退出码转换由这里集中负责。
|
||||
*/
|
||||
export function runPythonCommand({ executable, args, cwd, spawn = spawnSync } = {}) {
|
||||
if (typeof executable !== 'string' || executable.trim() === '') throw new Error('执行 Python 时缺少解释器')
|
||||
if (!Array.isArray(args)) throw new Error('执行 Python 时 args 必须为数组')
|
||||
if (typeof cwd !== 'string' || cwd.trim() === '') throw new Error('执行 Python 时缺少工作目录')
|
||||
|
||||
const result = spawn(executable, ['-B', ...args], {
|
||||
cwd,
|
||||
encoding: 'utf8',
|
||||
stdio: 'inherit',
|
||||
})
|
||||
if (result.error) throw new Error(`无法启动 Python(${executable}):${result.error.message}`)
|
||||
if (result.status !== 0) throw new Error(`Python 命令执行失败,退出码:${result.status ?? 'unknown'}`)
|
||||
return result
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { loadAndValidateManifest } from './manifest-v2.mjs'
|
||||
|
||||
const scriptDirectory = path.dirname(fileURLToPath(import.meta.url))
|
||||
const pipelineDirectory = path.resolve(scriptDirectory, '..')
|
||||
const workspace = path.resolve(pipelineDirectory, '..')
|
||||
const manifestPath = path.join(pipelineDirectory, 'manifests', 'a01-buttons-v2.json')
|
||||
const reportPath = path.join(pipelineDirectory, 'generated', 'a01-buttons-v2', 'quality-report.json')
|
||||
|
||||
await loadAndValidateManifest(manifestPath, workspace)
|
||||
|
||||
const localPython = path.join(pipelineDirectory, '.venv', 'Scripts', 'python.exe')
|
||||
const python = process.env.PYTHON || (fs.existsSync(localPython) ? localPython : 'python')
|
||||
|
||||
function runPython(script, args) {
|
||||
const result = spawnSync(python, [path.join(scriptDirectory, script), ...args], {
|
||||
cwd: workspace,
|
||||
encoding: 'utf8',
|
||||
stdio: 'inherit',
|
||||
})
|
||||
if (result.error) {
|
||||
throw new Error(`无法启动 Python(${python}):${result.error.message}`)
|
||||
}
|
||||
if (result.status !== 0) process.exit(result.status ?? 1)
|
||||
}
|
||||
|
||||
runPython('rebuild_a01_buttons.py', [manifestPath, '--workspace', workspace])
|
||||
runPython('verify-assets.py', [manifestPath, '--workspace', workspace, '--report', reportPath])
|
||||
@@ -1,89 +0,0 @@
|
||||
"""Rebuild A01 button skins with deterministic horizontal three-slice scaling."""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from PIL import Image, PngImagePlugin
|
||||
|
||||
|
||||
def rebuild_button(
|
||||
source_path: Path,
|
||||
output_path: Path,
|
||||
*,
|
||||
output_size: tuple[int, int],
|
||||
trim: int,
|
||||
padding: int,
|
||||
cap_width: int,
|
||||
) -> None:
|
||||
"""Trim contaminated edges, preserve both caps, and stretch only the center."""
|
||||
with Image.open(source_path) as opened:
|
||||
source = opened.convert("RGBA")
|
||||
|
||||
if trim < 0 or trim * 2 >= min(source.size):
|
||||
raise ValueError(f"invalid trim {trim} for source size {source.size}")
|
||||
if trim:
|
||||
source = source.crop((trim, trim, source.width - trim, source.height - trim))
|
||||
|
||||
if cap_width <= 0 or cap_width * 2 >= source.width:
|
||||
raise ValueError(f"invalid capWidth {cap_width} for cropped width {source.width}")
|
||||
|
||||
output_width, output_height = output_size
|
||||
inner_width = output_width - padding * 2
|
||||
inner_height = output_height - padding * 2
|
||||
if inner_width <= 0 or inner_height <= 0:
|
||||
raise ValueError(f"padding {padding} leaves no drawable area in {output_size}")
|
||||
|
||||
target_cap_width = max(1, round(cap_width * inner_height / source.height))
|
||||
if target_cap_width * 2 >= inner_width:
|
||||
raise ValueError("scaled caps leave no room for the center slice")
|
||||
|
||||
left = source.crop((0, 0, cap_width, source.height))
|
||||
center = source.crop((cap_width, 0, source.width - cap_width, source.height))
|
||||
right = source.crop((source.width - cap_width, 0, source.width, source.height))
|
||||
|
||||
resampling = Image.Resampling.LANCZOS
|
||||
left = left.resize((target_cap_width, inner_height), resampling)
|
||||
center = center.resize((inner_width - target_cap_width * 2, inner_height), resampling)
|
||||
right = right.resize((target_cap_width, inner_height), resampling)
|
||||
|
||||
output = Image.new("RGBA", output_size, (0, 0, 0, 0))
|
||||
output.alpha_composite(left, (padding, padding))
|
||||
output.alpha_composite(center, (padding + target_cap_width, padding))
|
||||
output.alpha_composite(right, (output_width - padding - target_cap_width, padding))
|
||||
|
||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
png_info = PngImagePlugin.PngInfo()
|
||||
png_info.add(b"sRGB", b"\x00")
|
||||
output.save(output_path, format="PNG", optimize=True, pnginfo=png_info)
|
||||
|
||||
|
||||
def rebuild_manifest(manifest_path: Path, workspace_root: Path) -> None:
|
||||
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
|
||||
for asset in manifest["assets"]:
|
||||
processing = asset["processing"]
|
||||
alpha = asset["alpha"]
|
||||
pixels = asset["outputPixels"]
|
||||
source = workspace_root / asset["source"]
|
||||
output = workspace_root / asset["output"]
|
||||
rebuild_button(
|
||||
source,
|
||||
output,
|
||||
output_size=(pixels["width"], pixels["height"]),
|
||||
trim=processing["trim"],
|
||||
padding=alpha["transparentOuterPadding"],
|
||||
cap_width=processing["capWidth"],
|
||||
)
|
||||
print(f"REBUILT {asset['id']} -> {asset['output']}")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("manifest", type=Path)
|
||||
parser.add_argument("--workspace", type=Path, required=True)
|
||||
args = parser.parse_args()
|
||||
rebuild_manifest(args.manifest.resolve(), args.workspace.resolve())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,14 @@
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
import { resolvePythonExecutable, runPythonCommand } from './python-runtime.mjs'
|
||||
|
||||
const scriptDirectory = path.dirname(fileURLToPath(import.meta.url))
|
||||
const pipelineDirectory = path.resolve(scriptDirectory, '..')
|
||||
const python = resolvePythonExecutable({ pipelineDirectory })
|
||||
|
||||
runPythonCommand({
|
||||
executable: python,
|
||||
args: ['-m', 'unittest', 'discover', '-s', 'tests', '-p', 'test_*.py'],
|
||||
cwd: pipelineDirectory,
|
||||
})
|
||||
@@ -0,0 +1,153 @@
|
||||
import { readFile, readdir } from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
|
||||
import { validateAssetBuildManifest } from './asset-build-manifest.mjs'
|
||||
|
||||
const formalManifestKinds = new Set(['asset-build-manifest', 'runtime-asset-inventory'])
|
||||
const assertOnlyFields = (value, fields, label) => {
|
||||
for (const field of Object.keys(value)) {
|
||||
if (!fields.has(field)) throw new Error(`${label} contains unknown field: ${field}`)
|
||||
}
|
||||
}
|
||||
|
||||
const resolveInsideWorkspace = (workspace, candidate, label) => {
|
||||
if (typeof candidate !== 'string' || candidate.trim() === '') {
|
||||
throw new Error(`${label} must be a non-empty string`)
|
||||
}
|
||||
const absolutePath = path.resolve(workspace, candidate)
|
||||
const relative = path.relative(workspace, absolutePath)
|
||||
if (relative.startsWith('..') || path.isAbsolute(relative)) {
|
||||
throw new Error(`${label} escapes workspace: ${candidate}`)
|
||||
}
|
||||
return absolutePath
|
||||
}
|
||||
|
||||
const readManifest = async (manifestPath) => {
|
||||
try {
|
||||
return JSON.parse(await readFile(manifestPath, 'utf8'))
|
||||
} catch (error) {
|
||||
if (error.code === 'ENOENT') throw new Error(`missing manifest: ${manifestPath}`)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
const validateDirectAsset = (asset, workspace) => {
|
||||
if (!asset || typeof asset !== 'object' || Array.isArray(asset)) throw new Error('runtime asset must be an object')
|
||||
assertOnlyFields(
|
||||
asset,
|
||||
new Set(['id', 'output', 'width', 'height', 'alpha', 'bytes', 'sha256', 'provenance', 'rebuildable']),
|
||||
'runtime asset',
|
||||
)
|
||||
for (const key of ['id', 'output']) {
|
||||
if (typeof asset[key] !== 'string' || asset[key].trim() === '') {
|
||||
throw new Error(`runtime asset ${key} must be a non-empty string`)
|
||||
}
|
||||
}
|
||||
resolveInsideWorkspace(workspace, asset.output, `${asset.id}.output`)
|
||||
for (const key of ['width', 'height', 'bytes']) {
|
||||
if (!Number.isInteger(asset[key]) || asset[key] <= 0) throw new Error(`${asset.id}.${key} must be a positive integer`)
|
||||
}
|
||||
if (typeof asset.alpha !== 'boolean') throw new Error(`${asset.id}.alpha must be boolean`)
|
||||
if (asset.provenance !== 'committed-binary') {
|
||||
throw new Error(`${asset.id}.provenance must be committed-binary`)
|
||||
}
|
||||
if (asset.rebuildable !== false) throw new Error(`${asset.id}.rebuildable must be false`)
|
||||
if (!/^[a-f0-9]{64}$/.test(asset.sha256)) throw new Error(`${asset.id}.sha256 must be lowercase SHA256`)
|
||||
return asset
|
||||
}
|
||||
|
||||
export const expandRuntimeAssetInventory = async (rootManifestPath, workspace) => {
|
||||
const workspacePath = path.resolve(workspace)
|
||||
const loaded = new Set()
|
||||
const stack = []
|
||||
const ids = new Map()
|
||||
const outputs = new Map()
|
||||
const manifests = []
|
||||
|
||||
const addAsset = (asset, owner) => {
|
||||
if (ids.has(asset.id)) {
|
||||
throw new Error(`duplicate asset id: ${asset.id} (${ids.get(asset.id)}, ${owner})`)
|
||||
}
|
||||
if (outputs.has(asset.output)) {
|
||||
throw new Error(`duplicate output: ${asset.output} (${outputs.get(asset.output).owner}, ${owner})`)
|
||||
}
|
||||
ids.set(asset.id, owner)
|
||||
outputs.set(asset.output, { ...asset, owner })
|
||||
}
|
||||
|
||||
// 递归展开只处理机器清单之间的依赖;页面消费者始终从源码扫描得到,避免双写。
|
||||
const visit = async (manifestPath) => {
|
||||
const absolutePath = resolveInsideWorkspace(workspacePath, manifestPath, 'manifest')
|
||||
if (stack.includes(absolutePath)) {
|
||||
throw new Error(`import cycle: ${[...stack, absolutePath].join(' -> ')}`)
|
||||
}
|
||||
if (loaded.has(absolutePath)) throw new Error(`duplicate import: ${absolutePath}`)
|
||||
|
||||
stack.push(absolutePath)
|
||||
loaded.add(absolutePath)
|
||||
manifests.push(absolutePath)
|
||||
const manifest = await readManifest(absolutePath)
|
||||
|
||||
if (manifest.kind === 'asset-build-manifest') {
|
||||
validateAssetBuildManifest(manifest, workspacePath)
|
||||
for (const asset of manifest.assets) {
|
||||
addAsset(
|
||||
{
|
||||
id: asset.id,
|
||||
output: asset.output,
|
||||
width: asset.outputPixels.width,
|
||||
height: asset.outputPixels.height,
|
||||
// 不透明构建模式依法不声明 alpha;只有透明模式存在该对象,避免把“字段缺失”误判为清单损坏。
|
||||
alpha: asset.alpha?.required ?? false,
|
||||
maxBytes: asset.quality.maxBytes,
|
||||
provenance: 'generated-from-manifest',
|
||||
rebuildable: true,
|
||||
},
|
||||
absolutePath,
|
||||
)
|
||||
}
|
||||
} else if (manifest.kind === 'runtime-asset-inventory') {
|
||||
assertOnlyFields(manifest, new Set(['schemaVersion', 'kind', 'scope', 'imports', 'assets']), 'runtime manifest')
|
||||
if (manifest.schemaVersion !== 1) throw new Error('runtime inventory schemaVersion must be 1')
|
||||
if (typeof manifest.scope !== 'string' || manifest.scope.trim() === '') throw new Error('runtime inventory scope is required')
|
||||
if (!Array.isArray(manifest.imports) || !Array.isArray(manifest.assets)) {
|
||||
throw new Error('runtime inventory imports and assets must be arrays')
|
||||
}
|
||||
for (const asset of manifest.assets) addAsset(validateDirectAsset(asset, workspacePath), absolutePath)
|
||||
for (const imported of manifest.imports) await visit(imported)
|
||||
} else {
|
||||
throw new Error(`unsupported manifest kind: ${manifest.kind}`)
|
||||
}
|
||||
|
||||
stack.pop()
|
||||
}
|
||||
|
||||
await visit(path.relative(workspacePath, path.resolve(rootManifestPath)))
|
||||
return { manifests, assets: [...outputs.values()] }
|
||||
}
|
||||
|
||||
export const validateRuntimeAssetRegistry = async (rootManifestPath, workspace, manifestsDirectory) => {
|
||||
const workspacePath = path.resolve(workspace)
|
||||
const directory = resolveInsideWorkspace(workspacePath, manifestsDirectory, 'manifests directory')
|
||||
const rootManifest = await readManifest(path.resolve(rootManifestPath))
|
||||
if (rootManifest.kind !== 'runtime-asset-inventory' || rootManifest.scope !== 'schema-v3') {
|
||||
throw new Error('registry root scope must be schema-v3')
|
||||
}
|
||||
const inventory = await expandRuntimeAssetInventory(rootManifestPath, workspacePath)
|
||||
const registered = new Set(inventory.manifests.map((manifest) => path.resolve(manifest)))
|
||||
|
||||
// 顶层注册表必须覆盖目录内每一份正式 owner。这样新增清单若没有接入全局图会立即失败,
|
||||
// output 与 id 的唯一性也就不再局限于某个业务域的 imports 闭包。
|
||||
for (const entry of await readdir(directory, { withFileTypes: true })) {
|
||||
if (!entry.isFile() || path.extname(entry.name).toLowerCase() !== '.json') continue
|
||||
const manifestPath = path.join(directory, entry.name)
|
||||
const manifest = await readManifest(manifestPath)
|
||||
if (formalManifestKinds.has(manifest.kind)) {
|
||||
if (!registered.has(path.resolve(manifestPath))) throw new Error(`unregistered manifest: ${manifestPath}`)
|
||||
continue
|
||||
}
|
||||
|
||||
throw new Error(`undeclared legacy manifest: ${manifestPath}`)
|
||||
}
|
||||
return inventory
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
import { validateAssetBuildManifest } from './asset-build-manifest.mjs'
|
||||
|
||||
const scriptDirectory = path.dirname(fileURLToPath(import.meta.url))
|
||||
const workspace = path.resolve(scriptDirectory, '..', '..')
|
||||
const manifestArgument = process.argv[2]
|
||||
if (!manifestArgument) throw new Error('Usage: node validate-asset-build-manifest.mjs <workspace-relative-manifest>')
|
||||
|
||||
const manifestPath = path.resolve(workspace, manifestArgument)
|
||||
const manifest = JSON.parse(await readFile(manifestPath, 'utf8'))
|
||||
validateAssetBuildManifest(manifest, workspace)
|
||||
process.stdout.write(`ASSET-BUILD-MANIFEST PASS ${path.relative(workspace, manifestPath).replaceAll('\\', '/')}\n`)
|
||||
@@ -1,15 +0,0 @@
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { loadAndValidateManifest } from './manifest-v2.mjs'
|
||||
|
||||
const scriptDirectory = path.dirname(fileURLToPath(import.meta.url))
|
||||
const pipelineDirectory = path.resolve(scriptDirectory, '..')
|
||||
const workspace = path.resolve(pipelineDirectory, '..')
|
||||
const manifestArgument = process.argv[2]
|
||||
|
||||
if (!manifestArgument) throw new Error('Usage: node validate-manifest-v2.mjs <manifest>')
|
||||
|
||||
const manifestPath = path.resolve(pipelineDirectory, manifestArgument)
|
||||
await loadAndValidateManifest(manifestPath, workspace)
|
||||
process.stdout.write(`MANIFEST-V2 PASS ${path.relative(workspace, manifestPath).replaceAll('\\', '/')}\n`)
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
import { validateRuntimeAssetRegistry } from './runtime-asset-inventory.mjs'
|
||||
|
||||
const scriptDirectory = path.dirname(fileURLToPath(import.meta.url))
|
||||
const workspace = path.resolve(scriptDirectory, '..', '..')
|
||||
const manifestArgument = process.argv[2]
|
||||
if (!manifestArgument) throw new Error('Usage: node validate-runtime-asset-inventory.mjs <workspace-relative-manifest>')
|
||||
|
||||
const inventory = await validateRuntimeAssetRegistry(
|
||||
path.resolve(workspace, manifestArgument),
|
||||
workspace,
|
||||
path.join(workspace, 'design-pipeline', 'manifests'),
|
||||
)
|
||||
process.stdout.write(`${JSON.stringify(inventory)}\n`)
|
||||
@@ -1,22 +0,0 @@
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { loadAndValidateManifest } from './manifest-v2.mjs'
|
||||
|
||||
const scriptDirectory = path.dirname(fileURLToPath(import.meta.url))
|
||||
const pipelineDirectory = path.resolve(scriptDirectory, '..')
|
||||
const workspace = path.resolve(pipelineDirectory, '..')
|
||||
const manifestPath = path.join(pipelineDirectory, 'manifests', 'a01-scroll-skins-v3.json')
|
||||
const reportPath = path.join(pipelineDirectory, 'generated', 'a01-scroll-skins-v3', 'quality-report.json')
|
||||
const localPython = path.join(pipelineDirectory, '.venv', 'Scripts', 'python.exe')
|
||||
const python = process.env.PYTHON || (fs.existsSync(localPython) ? localPython : 'python')
|
||||
|
||||
await loadAndValidateManifest(manifestPath, workspace)
|
||||
const result = spawnSync(
|
||||
python,
|
||||
[path.join(scriptDirectory, 'verify-assets.py'), manifestPath, '--workspace', workspace, '--report', reportPath],
|
||||
{ cwd: workspace, encoding: 'utf8', stdio: 'inherit' }
|
||||
)
|
||||
if (result.error) throw new Error(`无法启动 Python(${python}):${result.error.message}`)
|
||||
if (result.status !== 0) process.exit(result.status ?? 1)
|
||||
@@ -1,24 +0,0 @@
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { loadAndValidateManifest } from './manifest-v2.mjs'
|
||||
|
||||
const scriptDirectory = path.dirname(fileURLToPath(import.meta.url))
|
||||
const pipelineDirectory = path.resolve(scriptDirectory, '..')
|
||||
const workspace = path.resolve(pipelineDirectory, '..')
|
||||
const manifestPath = path.join(pipelineDirectory, 'manifests', 'a01-buttons-v2.json')
|
||||
const reportPath = path.join(pipelineDirectory, 'generated', 'a01-buttons-v2', 'quality-report.json')
|
||||
|
||||
await loadAndValidateManifest(manifestPath, workspace)
|
||||
|
||||
const localPython = path.join(pipelineDirectory, '.venv', 'Scripts', 'python.exe')
|
||||
const python = process.env.PYTHON || (fs.existsSync(localPython) ? localPython : 'python')
|
||||
const result = spawnSync(
|
||||
python,
|
||||
[path.join(scriptDirectory, 'verify-assets.py'), manifestPath, '--workspace', workspace, '--report', reportPath],
|
||||
{ cwd: workspace, encoding: 'utf8', stdio: 'inherit' }
|
||||
)
|
||||
|
||||
if (result.error) throw new Error(`无法启动 Python(${python}):${result.error.message}`)
|
||||
if (result.status !== 0) process.exit(result.status ?? 1)
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Verify generated assets declared in a manifest v2 file."""
|
||||
"""校验 schema v3 生成型资产,并写出不含机器绝对路径的确定性报告。"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
@@ -23,13 +23,13 @@ def main() -> None:
|
||||
if not output.is_file():
|
||||
report = {
|
||||
"id": asset["id"],
|
||||
"path": str(output),
|
||||
"path": Path(asset["output"]).as_posix(),
|
||||
"errors": ["output file is missing"],
|
||||
"warnings": [],
|
||||
"metrics": {},
|
||||
}
|
||||
else:
|
||||
report = analyze_asset(output, asset)
|
||||
report = analyze_asset(output, asset, args.workspace)
|
||||
reports.append(report)
|
||||
if report["errors"]:
|
||||
failed = True
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
import { validateAssetBuildManifest } from './asset-build-manifest.mjs'
|
||||
import { resolvePythonExecutable, runPythonCommand } from './python-runtime.mjs'
|
||||
|
||||
const scriptDirectory = path.dirname(fileURLToPath(import.meta.url))
|
||||
const pipelineDirectory = path.resolve(scriptDirectory, '..')
|
||||
const workspace = path.resolve(pipelineDirectory, '..')
|
||||
const manifestPath = path.join(pipelineDirectory, 'manifests', 'shared-scroll-skins-v3.json')
|
||||
const reportPath = path.join(pipelineDirectory, 'generated', 'shared-scroll-skins-v3', 'quality-report.json')
|
||||
const python = resolvePythonExecutable({ pipelineDirectory })
|
||||
const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'))
|
||||
|
||||
validateAssetBuildManifest(manifest, workspace)
|
||||
runPythonCommand({
|
||||
executable: python,
|
||||
args: [path.join(scriptDirectory, 'verify-assets.py'), manifestPath, '--workspace', workspace, '--report', reportPath],
|
||||
cwd: workspace,
|
||||
})
|
||||
@@ -0,0 +1,131 @@
|
||||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
import { validateAssetBuildManifest } from '../scripts/asset-build-manifest.mjs'
|
||||
|
||||
const testDirectory = path.dirname(fileURLToPath(import.meta.url))
|
||||
const workspace = path.resolve(testDirectory, '..', '..')
|
||||
|
||||
const baseAsset = () => ({
|
||||
id: 'test-asset',
|
||||
source: 'docs/design/assets/source.png',
|
||||
output: 'static/assets/output.png',
|
||||
sourcePixels: { width: 1536, height: 3840 },
|
||||
outputPixels: { width: 1440, height: 3600 },
|
||||
quality: { maxBytes: 7000000, colorSpace: 'sRGB' },
|
||||
})
|
||||
|
||||
// schema v3 以 processing.mode 为严格判别字段。每个分支只允许自身真正消费的
|
||||
// 参数,避免给不透明背景伪造透明边、色键或切片字段来“凑齐”旧结构。
|
||||
const chromaAsset = () => ({
|
||||
...baseAsset(),
|
||||
alpha: { required: true, transparentOuterPadding: 6, cornerMaxAlpha: 0 },
|
||||
edge: { forbidChromaResidue: true, forbidLightFringe: true, premultipliedAlphaCheck: true },
|
||||
processing: {
|
||||
mode: 'chroma-stretch',
|
||||
capWidth: 380,
|
||||
keyColor: '#00FF00',
|
||||
keyTolerance: 96,
|
||||
},
|
||||
})
|
||||
|
||||
const opaqueResizeAsset = () => ({
|
||||
...baseAsset(),
|
||||
processing: { mode: 'opaque-resize', resample: 'lanczos', outputMode: 'RGB' },
|
||||
})
|
||||
|
||||
const opaqueCoverAsset = () => ({
|
||||
...baseAsset(),
|
||||
processing: {
|
||||
mode: 'opaque-cover-crop',
|
||||
resample: 'lanczos',
|
||||
anchor: 'center',
|
||||
outputMode: 'RGB',
|
||||
},
|
||||
})
|
||||
|
||||
const warmFrameAsset = () => ({
|
||||
...baseAsset(),
|
||||
alpha: { required: true, transparentOuterPadding: 0, cornerMaxAlpha: 0 },
|
||||
edge: { forbidChromaResidue: false, forbidLightFringe: false, premultipliedAlphaCheck: true },
|
||||
processing: {
|
||||
mode: 'warm-gold-frame-extract',
|
||||
borderBand: 110,
|
||||
redGreenMin: 15,
|
||||
greenBlueMin: 12,
|
||||
redBlueMin: 35,
|
||||
redMaxExclusive: 245,
|
||||
blueMaxExclusive: 180,
|
||||
alphaOffset: 25,
|
||||
alphaScale: 6,
|
||||
outputMode: 'RGBA',
|
||||
},
|
||||
})
|
||||
|
||||
const manifestWith = (asset) => ({
|
||||
schemaVersion: 3,
|
||||
kind: 'asset-build-manifest',
|
||||
family: 'test-family-v3',
|
||||
assets: [asset],
|
||||
})
|
||||
|
||||
test('接受四种职责严格分离的 schema v3 处理模式', () => {
|
||||
for (const factory of [chromaAsset, opaqueResizeAsset, opaqueCoverAsset, warmFrameAsset]) {
|
||||
const manifest = manifestWith(factory())
|
||||
assert.equal(validateAssetBuildManifest(manifest, workspace), manifest)
|
||||
}
|
||||
})
|
||||
|
||||
test('拒绝没有 processing.mode 的旧 schema v3 形状', () => {
|
||||
const asset = chromaAsset()
|
||||
delete asset.processing.mode
|
||||
assert.throws(() => validateAssetBuildManifest(manifestWith(asset), workspace), /processing\.mode/i)
|
||||
})
|
||||
|
||||
test('不透明分支拒绝透明度、边缘和其他模式的参数', () => {
|
||||
const withAlpha = opaqueResizeAsset()
|
||||
withAlpha.alpha = chromaAsset().alpha
|
||||
assert.throws(() => validateAssetBuildManifest(manifestWith(withAlpha), workspace), /unknown field/i)
|
||||
|
||||
const withAnchor = opaqueResizeAsset()
|
||||
withAnchor.processing.anchor = 'center'
|
||||
assert.throws(() => validateAssetBuildManifest(manifestWith(withAnchor), workspace), /unknown field/i)
|
||||
|
||||
const withColorKey = opaqueCoverAsset()
|
||||
withColorKey.processing.keyColor = '#00FF00'
|
||||
assert.throws(() => validateAssetBuildManifest(manifestWith(withColorKey), workspace), /unknown field/i)
|
||||
})
|
||||
|
||||
test('透明分支拒绝缺失 alpha/edge 及不属于自身的处理字段', () => {
|
||||
const missingAlpha = chromaAsset()
|
||||
delete missingAlpha.alpha
|
||||
assert.throws(() => validateAssetBuildManifest(manifestWith(missingAlpha), workspace), /alpha/i)
|
||||
|
||||
const missingEdge = warmFrameAsset()
|
||||
delete missingEdge.edge
|
||||
assert.throws(() => validateAssetBuildManifest(manifestWith(missingEdge), workspace), /edge/i)
|
||||
|
||||
const wrongProcessing = warmFrameAsset()
|
||||
wrongProcessing.processing.capWidth = 380
|
||||
assert.throws(() => validateAssetBuildManifest(manifestWith(wrongProcessing), workspace), /unknown field/i)
|
||||
})
|
||||
|
||||
test('拒绝重复输出、越界路径、未知字段和未知模式', () => {
|
||||
const duplicate = manifestWith(chromaAsset())
|
||||
duplicate.assets.push({ ...chromaAsset(), id: 'duplicate-output' })
|
||||
assert.throws(() => validateAssetBuildManifest(duplicate, workspace), /duplicate output/i)
|
||||
|
||||
const escaped = manifestWith(opaqueResizeAsset())
|
||||
escaped.assets[0].output = '../outside.png'
|
||||
assert.throws(() => validateAssetBuildManifest(escaped, workspace), /escapes workspace/i)
|
||||
|
||||
const unknownField = manifestWith(opaqueResizeAsset())
|
||||
unknownField.assets[0].logicalSlot = 'page'
|
||||
assert.throws(() => validateAssetBuildManifest(unknownField, workspace), /unknown field/i)
|
||||
|
||||
const unknownMode = manifestWith(opaqueResizeAsset())
|
||||
unknownMode.assets[0].processing.mode = 'future-magic'
|
||||
assert.throws(() => validateAssetBuildManifest(unknownMode, workspace), /unsupported processing\.mode/i)
|
||||
})
|
||||
@@ -1,47 +0,0 @@
|
||||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const testDirectory = path.dirname(fileURLToPath(import.meta.url))
|
||||
const workspace = path.resolve(testDirectory, '..', '..')
|
||||
const manifestPath = path.join(workspace, 'design-pipeline', 'manifests', 'g01-background-candidates.json')
|
||||
const wrapperPath = path.join(workspace, 'design-pipeline', 'scripts', 'build-g01-backgrounds.mjs')
|
||||
|
||||
test('G01 background candidate manifest preserves four portable masters and selects the approved long background', async () => {
|
||||
const manifest = JSON.parse(await readFile(manifestPath, 'utf8'))
|
||||
assert.equal(manifest.schemaVersion, 1)
|
||||
assert.equal(manifest.page, 'G01')
|
||||
assert.equal(manifest.status, 'long-flagship-selected-for-genealogy-module')
|
||||
assert.equal(manifest.selectedCandidateId, 'g01-list-background-long-flagship')
|
||||
assert.equal(manifest.runtimeOutput, 'static/assets/modules/genealogy/opaque/genealogy-page-background-long.png')
|
||||
assert.equal(manifest.candidates.length, 4)
|
||||
|
||||
const ids = new Set()
|
||||
for (const candidate of manifest.candidates) {
|
||||
assert.ok(!ids.has(candidate.id), `duplicate candidate id: ${candidate.id}`)
|
||||
ids.add(candidate.id)
|
||||
assert.match(candidate.prompt, /\d+x\d+/)
|
||||
assert.ok(candidate.sourcePixels.width > 0 && candidate.sourcePixels.height > 0)
|
||||
assert.ok(['chroma-key', 'opaque-paper', 'opaque-paper-resize'].includes(candidate.processingMode))
|
||||
|
||||
for (const key of ['master', 'generatedOutput']) {
|
||||
const absolute = path.resolve(workspace, candidate[key])
|
||||
const relative = path.relative(workspace, absolute)
|
||||
assert.ok(!relative.startsWith('..') && !path.isAbsolute(relative), `${key} escapes workspace`)
|
||||
}
|
||||
}
|
||||
|
||||
const selected = manifest.candidates.find(({ id }) => id === manifest.selectedCandidateId)
|
||||
assert.deepEqual(selected.sourcePixels, { width: 1536, height: 3840 })
|
||||
assert.deepEqual(selected.outputPixels, { width: 1440, height: 3600 })
|
||||
assert.equal(selected.processingMode, 'opaque-paper-resize')
|
||||
})
|
||||
|
||||
test('G01 Node wrapper delegates pixel decoding to locked Pillow without Sharp', async () => {
|
||||
const wrapper = await readFile(wrapperPath, 'utf8')
|
||||
assert.doesNotMatch(wrapper, /from ['"]sharp['"]/)
|
||||
assert.match(wrapper, /build_g01_backgrounds\.py/)
|
||||
assert.match(wrapper, /candidates\?\.length !== 4/)
|
||||
})
|
||||
@@ -1,72 +0,0 @@
|
||||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { validateManifest } from '../scripts/manifest-v2.mjs'
|
||||
|
||||
const testDirectory = path.dirname(fileURLToPath(import.meta.url))
|
||||
const workspace = path.resolve(testDirectory, '..', '..')
|
||||
|
||||
const validAsset = () => ({
|
||||
id: 'a01-primary-button-v2',
|
||||
assetClass: 'fixed-bitmap',
|
||||
source: 'static/assets/foundation/opaque/a01-primary-button.png',
|
||||
output: 'static/assets/foundation/transparent/a01-primary-button-v2.png',
|
||||
logicalSlot: { widthRpx: 622, heightRpx: 92 },
|
||||
outputPixels: { width: 1866, height: 276 },
|
||||
render: { scalePolicy: 'uniform-only', uniMode: 'aspectFit', allowDistortion: false },
|
||||
alpha: { required: true, transparentOuterPadding: 12, cornerMaxAlpha: 0 },
|
||||
edge: { forbidChromaResidue: true, forbidLightFringe: true, premultipliedAlphaCheck: true },
|
||||
quality: { maxBytes: 500000, symmetry: 'horizontal', colorSpace: 'sRGB' },
|
||||
processing: { trim: 8, capWidth: 180 },
|
||||
runtime: { selector: '.login-submit', imageSelector: '.button-skin img' },
|
||||
consumers: ['pages/auth/a01-entry.vue']
|
||||
})
|
||||
|
||||
const validManifest = () => ({
|
||||
schemaVersion: 2,
|
||||
page: 'A01',
|
||||
runtime: { url: 'http://localhost:5173/#/pages/auth/a01-entry', chromePort: 9222 },
|
||||
assets: [validAsset()]
|
||||
})
|
||||
|
||||
test('accepts a complete manifest v2', () => {
|
||||
const manifest = validManifest()
|
||||
assert.equal(validateManifest(manifest, workspace), manifest)
|
||||
})
|
||||
|
||||
test('rejects duplicate asset ids', () => {
|
||||
const manifest = validManifest()
|
||||
manifest.assets.push(validAsset())
|
||||
assert.throws(() => validateManifest(manifest, workspace), /duplicate asset id/i)
|
||||
})
|
||||
|
||||
test('rejects paths that escape the workspace', () => {
|
||||
const manifest = validManifest()
|
||||
manifest.assets[0].output = '../outside.png'
|
||||
assert.throws(() => validateManifest(manifest, workspace), /escapes workspace/i)
|
||||
})
|
||||
|
||||
test('rejects a fixed bitmap whose output ratio differs from its slot', () => {
|
||||
const manifest = validManifest()
|
||||
manifest.assets[0].outputPixels.height = 300
|
||||
assert.throws(() => validateManifest(manifest, workspace), /ratio drift/i)
|
||||
})
|
||||
|
||||
test('rejects scaleToFill for uniform-only assets', () => {
|
||||
const manifest = validManifest()
|
||||
manifest.assets[0].render.uniMode = 'scaleToFill'
|
||||
assert.throws(() => validateManifest(manifest, workspace), /scaleToFill/i)
|
||||
})
|
||||
|
||||
test('rejects unknown asset classes', () => {
|
||||
const manifest = validManifest()
|
||||
manifest.assets[0].assetClass = 'mystery'
|
||||
assert.throws(() => validateManifest(manifest, workspace), /assetClass/i)
|
||||
})
|
||||
|
||||
test('rejects assets without consumers', () => {
|
||||
const manifest = validManifest()
|
||||
manifest.assets[0].consumers = []
|
||||
assert.throws(() => validateManifest(manifest, workspace), /consumers/i)
|
||||
})
|
||||
@@ -0,0 +1,68 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import path from 'node:path'
|
||||
import test from 'node:test'
|
||||
|
||||
import { resolvePythonExecutable, runPythonCommand } from '../scripts/python-runtime.mjs'
|
||||
|
||||
test('显式 PYTHON 配置优先于所有自动发现路径', () => {
|
||||
const selected = resolvePythonExecutable({
|
||||
pipelineDirectory: 'C:\\repo\\design-pipeline',
|
||||
environment: {
|
||||
PYTHON: 'D:\\tools\\python.exe',
|
||||
LOCALAPPDATA: 'C:\\Users\\tester\\AppData\\Local',
|
||||
},
|
||||
platform: 'win32',
|
||||
pathExists: () => true,
|
||||
canRun: candidate => candidate === 'D:\\tools\\python.exe',
|
||||
})
|
||||
|
||||
assert.equal(selected, 'D:\\tools\\python.exe')
|
||||
})
|
||||
|
||||
test('Windows 环境在虚拟环境缺失时选择 Python Manager 的真实入口', () => {
|
||||
const managerPython = path.win32.join(
|
||||
'C:\\Users\\tester\\AppData\\Local',
|
||||
'Python',
|
||||
'bin',
|
||||
'python.exe',
|
||||
)
|
||||
const selected = resolvePythonExecutable({
|
||||
pipelineDirectory: 'C:\\repo\\design-pipeline',
|
||||
environment: { LOCALAPPDATA: 'C:\\Users\\tester\\AppData\\Local' },
|
||||
platform: 'win32',
|
||||
pathExists: candidate => candidate === managerPython,
|
||||
canRun: candidate => candidate === managerPython,
|
||||
})
|
||||
|
||||
assert.equal(selected, managerPython)
|
||||
})
|
||||
|
||||
test('所有候选解释器均不可用时给出可执行的中文修复指引', () => {
|
||||
assert.throws(
|
||||
() => resolvePythonExecutable({
|
||||
pipelineDirectory: 'C:\\repo\\design-pipeline',
|
||||
environment: {},
|
||||
platform: 'win32',
|
||||
pathExists: () => false,
|
||||
canRun: () => false,
|
||||
}),
|
||||
/未找到可用的 Python.*PYTHON.*\.venv/s,
|
||||
)
|
||||
})
|
||||
|
||||
test('统一执行器始终把禁止字节码缓存参数放在首位', () => {
|
||||
let invocation
|
||||
runPythonCommand({
|
||||
executable: 'python-test',
|
||||
args: ['script.py', '--flag'],
|
||||
cwd: 'C:\\repo',
|
||||
spawn: (command, args, options) => {
|
||||
invocation = { command, args, options }
|
||||
return { status: 0 }
|
||||
},
|
||||
})
|
||||
|
||||
assert.equal(invocation.command, 'python-test')
|
||||
assert.deepEqual(invocation.args, ['-B', 'script.py', '--flag'])
|
||||
assert.equal(invocation.options.cwd, 'C:\\repo')
|
||||
})
|
||||
@@ -0,0 +1,65 @@
|
||||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
import { validateAssetBuildManifest } from '../scripts/asset-build-manifest.mjs'
|
||||
|
||||
const testDirectory = path.dirname(fileURLToPath(import.meta.url))
|
||||
const pipelineDirectory = path.resolve(testDirectory, '..')
|
||||
const workspace = path.resolve(pipelineDirectory, '..')
|
||||
|
||||
const readManifest = async (name) => JSON.parse(await readFile(
|
||||
path.join(pipelineDirectory, 'manifests', name),
|
||||
'utf8',
|
||||
))
|
||||
|
||||
test('长页面背景只由一份清单拥有六张正式输出', async () => {
|
||||
const manifest = await readManifest('page-backgrounds-v3.json')
|
||||
validateAssetBuildManifest(manifest, workspace)
|
||||
|
||||
assert.equal(manifest.family, 'page-backgrounds-v3')
|
||||
assert.deepEqual(
|
||||
manifest.assets.map(({ id }) => id),
|
||||
[
|
||||
'genealogy-page-background-long',
|
||||
'tree-page-background-long',
|
||||
'family-page-background-long',
|
||||
'records-page-background-long',
|
||||
'notification-page-background-long',
|
||||
'profile-page-background-long',
|
||||
],
|
||||
)
|
||||
assert.equal(manifest.assets[0].processing.mode, 'opaque-resize')
|
||||
for (const asset of manifest.assets.slice(1)) {
|
||||
assert.equal(asset.processing.mode, 'opaque-cover-crop')
|
||||
}
|
||||
})
|
||||
|
||||
test('G01 空态边框拥有独立的暖金边框提取清单', async () => {
|
||||
const manifest = await readManifest('g01-state-frame-v3.json')
|
||||
validateAssetBuildManifest(manifest, workspace)
|
||||
|
||||
assert.equal(manifest.family, 'g01-state-frame-v3')
|
||||
assert.equal(manifest.assets.length, 1)
|
||||
assert.equal(manifest.assets[0].id, 'g01-empty-panel-frame')
|
||||
assert.equal(manifest.assets[0].processing.mode, 'warm-gold-frame-extract')
|
||||
assert.equal(manifest.assets[0].processing.borderBand, 110)
|
||||
})
|
||||
|
||||
test('新构建入口不再保留候选构建和 Sharp 专用命令', async () => {
|
||||
const packageJson = JSON.parse(await readFile(path.join(pipelineDirectory, 'package.json'), 'utf8'))
|
||||
assert.equal(
|
||||
packageJson.scripts['build:page-backgrounds'],
|
||||
'node scripts/build-raster-assets.mjs design-pipeline/manifests/page-backgrounds-v3.json',
|
||||
)
|
||||
assert.equal(
|
||||
packageJson.scripts['build:g01-state-frame'],
|
||||
'node scripts/build-raster-assets.mjs design-pipeline/manifests/g01-state-frame-v3.json',
|
||||
)
|
||||
assert.equal(packageJson.scripts['build:g01-background-candidates'], undefined)
|
||||
assert.equal(packageJson.scripts['build:module-page-backgrounds'], undefined)
|
||||
assert.equal(packageJson.scripts['build:g01-empty-frame'], undefined)
|
||||
assert.equal(packageJson.dependencies?.sharp, undefined)
|
||||
})
|
||||
@@ -0,0 +1,142 @@
|
||||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises'
|
||||
import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
|
||||
import { expandRuntimeAssetInventory } from '../scripts/runtime-asset-inventory.mjs'
|
||||
|
||||
const createWorkspace = async () => {
|
||||
const workspace = await mkdtemp(path.join(os.tmpdir(), 'jiapu-runtime-assets-'))
|
||||
await mkdir(path.join(workspace, 'design-pipeline', 'manifests'), { recursive: true })
|
||||
return workspace
|
||||
}
|
||||
|
||||
const writeManifest = async (workspace, name, value) => {
|
||||
const filePath = path.join(workspace, 'design-pipeline', 'manifests', name)
|
||||
await writeFile(filePath, `${JSON.stringify(value, null, 2)}\n`, 'utf8')
|
||||
return filePath
|
||||
}
|
||||
|
||||
const directAsset = (id, output) => ({
|
||||
id,
|
||||
output,
|
||||
width: 96,
|
||||
height: 96,
|
||||
alpha: true,
|
||||
bytes: 100,
|
||||
sha256: 'a'.repeat(64),
|
||||
provenance: 'committed-binary',
|
||||
rebuildable: false,
|
||||
})
|
||||
|
||||
const runtimeManifest = (imports = [], assets = []) => ({
|
||||
schemaVersion: 1,
|
||||
kind: 'runtime-asset-inventory',
|
||||
scope: 'auth',
|
||||
imports,
|
||||
assets,
|
||||
})
|
||||
|
||||
test('展开直接资产与单一导入且不复制物理规格', async (t) => {
|
||||
const workspace = await createWorkspace()
|
||||
t.after(() => rm(workspace, { recursive: true, force: true }))
|
||||
const imported = await writeManifest(
|
||||
workspace,
|
||||
'shared.json',
|
||||
runtimeManifest([], [directAsset('shared', 'static/assets/shared.png')]),
|
||||
)
|
||||
const root = await writeManifest(
|
||||
workspace,
|
||||
'auth.json',
|
||||
runtimeManifest(['design-pipeline/manifests/shared.json'], [directAsset('auth', 'static/assets/auth.png')]),
|
||||
)
|
||||
|
||||
const result = await expandRuntimeAssetInventory(root, workspace)
|
||||
assert.deepEqual(result.assets.map(({ id }) => id).sort(), ['auth', 'shared'])
|
||||
assert(result.manifests.includes(imported))
|
||||
})
|
||||
|
||||
test('拒绝缺失的导入文件', async (t) => {
|
||||
const workspace = await createWorkspace()
|
||||
t.after(() => rm(workspace, { recursive: true, force: true }))
|
||||
const root = await writeManifest(workspace, 'auth.json', runtimeManifest(['design-pipeline/manifests/missing.json']))
|
||||
await assert.rejects(() => expandRuntimeAssetInventory(root, workspace), /missing manifest/i)
|
||||
})
|
||||
|
||||
test('拒绝循环导入', async (t) => {
|
||||
const workspace = await createWorkspace()
|
||||
t.after(() => rm(workspace, { recursive: true, force: true }))
|
||||
const first = await writeManifest(workspace, 'first.json', runtimeManifest(['design-pipeline/manifests/second.json']))
|
||||
await writeManifest(workspace, 'second.json', runtimeManifest(['design-pipeline/manifests/first.json']))
|
||||
await assert.rejects(() => expandRuntimeAssetInventory(first, workspace), /import cycle/i)
|
||||
})
|
||||
|
||||
test('拒绝同一清单被重复导入', async (t) => {
|
||||
const workspace = await createWorkspace()
|
||||
t.after(() => rm(workspace, { recursive: true, force: true }))
|
||||
await writeManifest(workspace, 'shared.json', runtimeManifest([], [directAsset('shared', 'static/assets/shared.png')]))
|
||||
const root = await writeManifest(
|
||||
workspace,
|
||||
'auth.json',
|
||||
runtimeManifest([
|
||||
'design-pipeline/manifests/shared.json',
|
||||
'design-pipeline/manifests/shared.json',
|
||||
]),
|
||||
)
|
||||
await assert.rejects(() => expandRuntimeAssetInventory(root, workspace), /duplicate import/i)
|
||||
})
|
||||
|
||||
test('拒绝不同清单拥有同一正式输出', async (t) => {
|
||||
const workspace = await createWorkspace()
|
||||
t.after(() => rm(workspace, { recursive: true, force: true }))
|
||||
await writeManifest(workspace, 'shared.json', runtimeManifest([], [directAsset('shared', 'static/assets/same.png')]))
|
||||
const root = await writeManifest(
|
||||
workspace,
|
||||
'auth.json',
|
||||
runtimeManifest(
|
||||
['design-pipeline/manifests/shared.json'],
|
||||
[directAsset('auth', 'static/assets/same.png')],
|
||||
),
|
||||
)
|
||||
await assert.rejects(() => expandRuntimeAssetInventory(root, workspace), /duplicate output/i)
|
||||
})
|
||||
|
||||
test('拒绝展开图中的重复资产 id', async (t) => {
|
||||
const workspace = await createWorkspace()
|
||||
t.after(() => rm(workspace, { recursive: true, force: true }))
|
||||
await writeManifest(workspace, 'shared.json', runtimeManifest([], [directAsset('same-id', 'static/assets/shared.png')]))
|
||||
const root = await writeManifest(
|
||||
workspace,
|
||||
'auth.json',
|
||||
runtimeManifest(
|
||||
['design-pipeline/manifests/shared.json'],
|
||||
[directAsset('same-id', 'static/assets/auth.png')],
|
||||
),
|
||||
)
|
||||
await assert.rejects(() => expandRuntimeAssetInventory(root, workspace), /duplicate asset id/i)
|
||||
})
|
||||
|
||||
test('直接资产只接受 committed-binary 且拒绝未知字段', async (t) => {
|
||||
const workspace = await createWorkspace()
|
||||
t.after(() => rm(workspace, { recursive: true, force: true }))
|
||||
|
||||
const wrongProvenance = directAsset('wrong-provenance', 'static/assets/wrong.png')
|
||||
wrongProvenance.provenance = 'manual-copy'
|
||||
const wrongRoot = await writeManifest(workspace, 'wrong.json', runtimeManifest([], [wrongProvenance]))
|
||||
await assert.rejects(() => expandRuntimeAssetInventory(wrongRoot, workspace), /committed-binary/i)
|
||||
|
||||
const unknownField = directAsset('unknown-field', 'static/assets/unknown.png')
|
||||
unknownField.runtimeSelector = '.page'
|
||||
const unknownRoot = await writeManifest(workspace, 'unknown.json', runtimeManifest([], [unknownField]))
|
||||
await assert.rejects(() => expandRuntimeAssetInventory(unknownRoot, workspace), /unknown field/i)
|
||||
})
|
||||
|
||||
test('运行时清单本身拒绝未知字段', async (t) => {
|
||||
const workspace = await createWorkspace()
|
||||
t.after(() => rm(workspace, { recursive: true, force: true }))
|
||||
const manifest = runtimeManifest()
|
||||
manifest.consumerList = []
|
||||
const root = await writeManifest(workspace, 'auth.json', manifest)
|
||||
await assert.rejects(() => expandRuntimeAssetInventory(root, workspace), /unknown field/i)
|
||||
})
|
||||
@@ -0,0 +1,94 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises'
|
||||
import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
import test from 'node:test'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
import { validateRuntimeAssetRegistry } from '../scripts/runtime-asset-inventory.mjs'
|
||||
|
||||
const runtimeManifest = (scope, imports = []) => ({
|
||||
schemaVersion: 1,
|
||||
kind: 'runtime-asset-inventory',
|
||||
scope,
|
||||
imports,
|
||||
assets: [],
|
||||
})
|
||||
|
||||
const testDirectory = path.dirname(fileURLToPath(import.meta.url))
|
||||
const pipelineDirectory = path.resolve(testDirectory, '..')
|
||||
const workspaceDirectory = path.resolve(pipelineDirectory, '..')
|
||||
const realManifestsDirectory = path.join(pipelineDirectory, 'manifests')
|
||||
|
||||
test('顶层注册表拒绝未进入导入闭包的正式 owner', async (t) => {
|
||||
const workspace = await mkdtemp(path.join(os.tmpdir(), 'jiapu-runtime-registry-'))
|
||||
t.after(() => rm(workspace, { recursive: true, force: true }))
|
||||
const manifestsDirectory = path.join(workspace, 'design-pipeline', 'manifests')
|
||||
await mkdir(manifestsDirectory, { recursive: true })
|
||||
const write = (name, value) => writeFile(path.join(manifestsDirectory, name), `${JSON.stringify(value)}\n`, 'utf8')
|
||||
|
||||
await write('runtime-assets.json', runtimeManifest('schema-v3'))
|
||||
await write('orphan.json', runtimeManifest('orphan'))
|
||||
|
||||
await assert.rejects(
|
||||
() => validateRuntimeAssetRegistry(
|
||||
path.join(manifestsDirectory, 'runtime-assets.json'),
|
||||
workspace,
|
||||
manifestsDirectory,
|
||||
),
|
||||
/unregistered manifest/i,
|
||||
)
|
||||
})
|
||||
|
||||
test('真实 schema v3 注册表覆盖直接资产与三类正式生成 owner', async () => {
|
||||
const registry = JSON.parse(await readFile(path.join(realManifestsDirectory, 'runtime-assets.json'), 'utf8'))
|
||||
const auth = JSON.parse(await readFile(path.join(realManifestsDirectory, 'auth-runtime-assets.json'), 'utf8'))
|
||||
|
||||
assert.equal(registry.scope, 'schema-v3')
|
||||
assert.deepEqual(registry.imports, [
|
||||
'design-pipeline/manifests/auth-runtime-assets.json',
|
||||
'design-pipeline/manifests/application-runtime-assets.json',
|
||||
'design-pipeline/manifests/shared-scroll-skins-v3.json',
|
||||
'design-pipeline/manifests/page-backgrounds-v3.json',
|
||||
'design-pipeline/manifests/g01-state-frame-v3.json',
|
||||
])
|
||||
assert.deepEqual(auth.imports, [])
|
||||
await validateRuntimeAssetRegistry(
|
||||
path.join(realManifestsDirectory, 'runtime-assets.json'),
|
||||
workspaceDirectory,
|
||||
realManifestsDirectory,
|
||||
)
|
||||
})
|
||||
|
||||
test('注册表根 scope 不是 schema-v3 时拒绝验证', async (t) => {
|
||||
const workspace = await mkdtemp(path.join(os.tmpdir(), 'jiapu-runtime-registry-scope-'))
|
||||
t.after(() => rm(workspace, { recursive: true, force: true }))
|
||||
const manifestsDirectory = path.join(workspace, 'design-pipeline', 'manifests')
|
||||
await mkdir(manifestsDirectory, { recursive: true })
|
||||
const root = path.join(manifestsDirectory, 'runtime-assets.json')
|
||||
await writeFile(root, `${JSON.stringify(runtimeManifest('auth'))}\n`, 'utf8')
|
||||
|
||||
await assert.rejects(
|
||||
() => validateRuntimeAssetRegistry(root, workspace, manifestsDirectory),
|
||||
/root scope must be schema-v3/i,
|
||||
)
|
||||
})
|
||||
|
||||
test('未声明的旧格式或未知 kind 清单不能被静默跳过', async (t) => {
|
||||
const workspace = await mkdtemp(path.join(os.tmpdir(), 'jiapu-runtime-registry-unknown-'))
|
||||
t.after(() => rm(workspace, { recursive: true, force: true }))
|
||||
const manifestsDirectory = path.join(workspace, 'design-pipeline', 'manifests')
|
||||
await mkdir(manifestsDirectory, { recursive: true })
|
||||
const root = path.join(manifestsDirectory, 'runtime-assets.json')
|
||||
await writeFile(root, `${JSON.stringify(runtimeManifest('schema-v3'))}\n`, 'utf8')
|
||||
await writeFile(
|
||||
path.join(manifestsDirectory, 'unknown.json'),
|
||||
`${JSON.stringify({ schemaVersion: 1, kind: 'legacy-owner', outputs: [] })}\n`,
|
||||
'utf8',
|
||||
)
|
||||
|
||||
await assert.rejects(
|
||||
() => validateRuntimeAssetRegistry(root, workspace, manifestsDirectory),
|
||||
/undeclared legacy manifest/i,
|
||||
)
|
||||
})
|
||||
@@ -1,30 +0,0 @@
|
||||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { validateManifest } from '../scripts/manifest-v2.mjs'
|
||||
|
||||
const testDirectory = path.dirname(fileURLToPath(import.meta.url))
|
||||
const pipelineDirectory = path.resolve(testDirectory, '..')
|
||||
const workspace = path.resolve(pipelineDirectory, '..')
|
||||
const manifestPath = path.join(pipelineDirectory, 'manifests', 'a01-scroll-skins-v3.json')
|
||||
|
||||
test('declares the approved A01 scroll-skin family', async () => {
|
||||
const manifest = JSON.parse(await readFile(manifestPath, 'utf8'))
|
||||
validateManifest(manifest, workspace)
|
||||
|
||||
assert.equal(manifest.assets.length, 4)
|
||||
assert.deepEqual(
|
||||
manifest.assets.map(({ id }) => id),
|
||||
['a01-scroll-primary-v3', 'a01-scroll-secondary-v3', 'a01-scroll-toast-v3', 'a01-scroll-dialog-v3']
|
||||
)
|
||||
assert.deepEqual(
|
||||
manifest.assets.map(({ outputPixels }) => [outputPixels.width, outputPixels.height]),
|
||||
[[1866, 276], [1866, 300], [1770, 246], [1860, 1560]]
|
||||
)
|
||||
assert.equal(manifest.assets[2].assetClass, 'nine-slice')
|
||||
assert.equal(manifest.assets[2].render.scalePolicy, 'nine-slice')
|
||||
assert.equal(manifest.assets[3].render.uniMode, 'aspectFit')
|
||||
assert(manifest.assets.every(({ consumers }) => consumers.includes('pages/auth/a01-entry.vue')))
|
||||
})
|
||||
@@ -0,0 +1,31 @@
|
||||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
import { validateAssetBuildManifest } from '../scripts/asset-build-manifest.mjs'
|
||||
|
||||
const testDirectory = path.dirname(fileURLToPath(import.meta.url))
|
||||
const pipelineDirectory = path.resolve(testDirectory, '..')
|
||||
const workspace = path.resolve(pipelineDirectory, '..')
|
||||
const manifestPath = path.join(pipelineDirectory, 'manifests', 'shared-scroll-skins-v3.json')
|
||||
|
||||
test('共享卷轴清单只维护四张正式生成资产', async () => {
|
||||
const manifest = JSON.parse(await readFile(manifestPath, 'utf8'))
|
||||
validateAssetBuildManifest(manifest, workspace)
|
||||
|
||||
assert.equal(manifest.kind, 'asset-build-manifest')
|
||||
assert.equal(manifest.family, 'shared-scroll-skins-v3')
|
||||
assert.deepEqual(
|
||||
manifest.assets.map(({ id }) => id),
|
||||
['shared-scroll-primary-v3', 'shared-scroll-secondary-v3', 'shared-scroll-toast-v3', 'shared-scroll-dialog-v3'],
|
||||
)
|
||||
for (const asset of manifest.assets) {
|
||||
assert.deepEqual(
|
||||
Object.keys(asset).sort(),
|
||||
['alpha', 'edge', 'id', 'output', 'outputPixels', 'processing', 'quality', 'source', 'sourcePixels'],
|
||||
)
|
||||
assert.equal(asset.processing.mode, 'chroma-stretch')
|
||||
}
|
||||
})
|
||||
@@ -3,7 +3,7 @@ import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from PIL import Image
|
||||
from PIL import Image, PngImagePlugin
|
||||
|
||||
SCRIPTS = Path(__file__).resolve().parents[1] / "scripts"
|
||||
sys.path.insert(0, str(SCRIPTS))
|
||||
@@ -37,19 +37,23 @@ class AssetQualityTests(unittest.TestCase):
|
||||
image.putpixel((x, y), (150, 20, 12, 255))
|
||||
return image
|
||||
|
||||
def save(self, image, name="asset.png"):
|
||||
def save(self, image, name="asset.png", include_srgb=True):
|
||||
path = self.root / name
|
||||
image.save(path, format="PNG")
|
||||
png_info = PngImagePlugin.PngInfo()
|
||||
if include_srgb:
|
||||
png_info.add(b"sRGB", b"\x00")
|
||||
image.save(path, format="PNG", pnginfo=png_info)
|
||||
return path
|
||||
|
||||
def error_text(self, report):
|
||||
return " ".join(report["errors"])
|
||||
|
||||
def test_accepts_clean_rgba_asset(self):
|
||||
report = analyze_asset(self.save(self.clean_image()), self.spec())
|
||||
report = analyze_asset(self.save(self.clean_image()), self.spec(), self.root)
|
||||
self.assertEqual([], report["errors"])
|
||||
self.assertEqual(32, report["width"])
|
||||
self.assertEqual(16, report["height"])
|
||||
self.assertEqual("asset.png", report["path"])
|
||||
|
||||
def test_accepts_indexed_png_with_real_transparency(self):
|
||||
indexed = self.clean_image().quantize(
|
||||
@@ -57,44 +61,49 @@ class AssetQualityTests(unittest.TestCase):
|
||||
method=Image.Quantize.FASTOCTREE,
|
||||
dither=Image.Dither.NONE,
|
||||
)
|
||||
report = analyze_asset(self.save(indexed), self.spec())
|
||||
report = analyze_asset(self.save(indexed), self.spec(), self.root)
|
||||
self.assertEqual([], report["errors"])
|
||||
self.assertEqual("P", report["mode"])
|
||||
|
||||
def test_rejects_wrong_dimensions(self):
|
||||
report = analyze_asset(self.save(Image.new("RGBA", (31, 16), (0, 0, 0, 0))), self.spec())
|
||||
report = analyze_asset(self.save(Image.new("RGBA", (31, 16), (0, 0, 0, 0))), self.spec(), self.root)
|
||||
self.assertIn("dimensions", self.error_text(report))
|
||||
|
||||
def test_rejects_opaque_outer_padding(self):
|
||||
image = self.clean_image()
|
||||
image.putpixel((0, 0), (120, 30, 20, 255))
|
||||
report = analyze_asset(self.save(image), self.spec())
|
||||
report = analyze_asset(self.save(image), self.spec(), self.root)
|
||||
self.assertIn("outer padding", self.error_text(report))
|
||||
|
||||
def test_rejects_visible_green_residue(self):
|
||||
image = self.clean_image()
|
||||
image.putpixel((16, 8), (0, 255, 0, 255))
|
||||
report = analyze_asset(self.save(image), self.spec())
|
||||
report = analyze_asset(self.save(image), self.spec(), self.root)
|
||||
self.assertIn("chroma residue", self.error_text(report))
|
||||
|
||||
def test_rejects_light_partially_transparent_fringe(self):
|
||||
image = self.clean_image()
|
||||
image.putpixel((2, 8), (250, 250, 250, 128))
|
||||
report = analyze_asset(self.save(image), self.spec())
|
||||
report = analyze_asset(self.save(image), self.spec(), self.root)
|
||||
self.assertIn("light fringe", self.error_text(report))
|
||||
|
||||
def test_rejects_hidden_rgb_in_fully_transparent_pixels(self):
|
||||
image = self.clean_image()
|
||||
image.putpixel((0, 0), (255, 255, 255, 0))
|
||||
report = analyze_asset(self.save(image), self.spec())
|
||||
report = analyze_asset(self.save(image), self.spec(), self.root)
|
||||
self.assertIn("transparent RGB", self.error_text(report))
|
||||
|
||||
def test_rejects_file_over_max_bytes(self):
|
||||
spec = self.spec()
|
||||
spec["quality"]["maxBytes"] = 8
|
||||
report = analyze_asset(self.save(self.clean_image()), spec)
|
||||
report = analyze_asset(self.save(self.clean_image()), spec, self.root)
|
||||
self.assertIn("maxBytes", self.error_text(report))
|
||||
|
||||
def test_rejects_missing_declared_srgb_metadata(self):
|
||||
path = self.save(self.clean_image(), include_srgb=False)
|
||||
report = analyze_asset(path, self.spec(), self.root)
|
||||
self.assertIn("sRGB", self.error_text(report))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -1,71 +0,0 @@
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from PIL import Image
|
||||
|
||||
SCRIPTS = Path(__file__).resolve().parents[1] / "scripts"
|
||||
sys.path.insert(0, str(SCRIPTS))
|
||||
|
||||
from build_g01_backgrounds import process_chroma_image, process_opaque_image, save_png
|
||||
|
||||
|
||||
class G01BackgroundBuilderTests(unittest.TestCase):
|
||||
def config(self):
|
||||
return {
|
||||
"dominanceStart": 30,
|
||||
"dominanceEnd": 220,
|
||||
"despillAllowance": 10,
|
||||
}
|
||||
|
||||
def test_pure_green_becomes_clean_transparency(self):
|
||||
source = Image.new("RGB", (2, 1), (0, 255, 0))
|
||||
output = process_chroma_image(source, self.config())
|
||||
self.assertEqual((0, 0, 0, 0), output.getpixel((0, 0)))
|
||||
|
||||
def test_warm_artwork_remains_opaque(self):
|
||||
source = Image.new("RGB", (1, 1), (210, 190, 150))
|
||||
output = process_chroma_image(source, self.config())
|
||||
self.assertEqual((210, 190, 150, 255), output.getpixel((0, 0)))
|
||||
|
||||
def test_antialiased_green_edge_is_translucent_and_despilled(self):
|
||||
source = Image.new("RGB", (1, 1), (100, 200, 90))
|
||||
output = process_chroma_image(source, self.config())
|
||||
red, green, blue, alpha = output.getpixel((0, 0))
|
||||
self.assertGreater(alpha, 0)
|
||||
self.assertLess(alpha, 255)
|
||||
self.assertLessEqual(green, max(red, blue) + 10)
|
||||
|
||||
def test_opaque_master_is_normalized_to_rgba_without_transparency(self):
|
||||
source = Image.new("RGB", (1, 1), (240, 235, 220))
|
||||
output = process_opaque_image(source)
|
||||
self.assertEqual("RGBA", output.mode)
|
||||
self.assertEqual((240, 235, 220, 255), output.getpixel((0, 0)))
|
||||
|
||||
def test_opaque_master_can_be_resized_to_locked_runtime_dimensions(self):
|
||||
source = Image.new("RGB", (2, 3), (240, 235, 220))
|
||||
output = process_opaque_image(source, (4, 6))
|
||||
self.assertEqual((4, 6), output.size)
|
||||
self.assertEqual("RGBA", output.mode)
|
||||
self.assertEqual((240, 235, 220, 255), output.getpixel((3, 5)))
|
||||
|
||||
def test_saved_png_declares_srgb_color_profile(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
path = Path(directory) / "asset.png"
|
||||
save_png(Image.new("RGBA", (1, 1), (240, 235, 220, 255)), path)
|
||||
with Image.open(path) as opened:
|
||||
self.assertIn("srgb", opened.info)
|
||||
|
||||
def test_saved_png_bytes_are_deterministic(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
first = Path(directory) / "first.png"
|
||||
second = Path(directory) / "second.png"
|
||||
image = Image.new("RGBA", (2, 2), (240, 235, 220, 255))
|
||||
save_png(image, first)
|
||||
save_png(image, second)
|
||||
self.assertEqual(first.read_bytes(), second.read_bytes())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,85 @@
|
||||
import hashlib
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from PIL import Image
|
||||
|
||||
SCRIPTS = Path(__file__).resolve().parents[1] / "scripts"
|
||||
sys.path.insert(0, str(SCRIPTS))
|
||||
|
||||
from build_raster_assets import (
|
||||
build_opaque_cover_crop,
|
||||
build_opaque_resize,
|
||||
extract_warm_gold_frame,
|
||||
save_png,
|
||||
)
|
||||
|
||||
|
||||
class RasterAssetBuilderTests(unittest.TestCase):
|
||||
"""锁定迁移后的两类背景处理与 G01 暖金边框提取语义。"""
|
||||
|
||||
def test_opaque_resize_uses_locked_output_size_and_rgb(self):
|
||||
source = Image.new("RGBA", (2, 3), (120, 80, 40, 128))
|
||||
output = build_opaque_resize(source, (4, 6))
|
||||
self.assertEqual((4, 6), output.size)
|
||||
self.assertEqual("RGB", output.mode)
|
||||
|
||||
def test_cover_crop_centers_the_scaled_source(self):
|
||||
source = Image.new("RGB", (2, 1), (255, 0, 0))
|
||||
source.putpixel((1, 0), (0, 0, 255))
|
||||
output = build_opaque_cover_crop(source, (2, 2))
|
||||
self.assertEqual((2, 2), output.size)
|
||||
self.assertEqual("RGB", output.mode)
|
||||
self.assertNotEqual(output.getpixel((0, 0)), output.getpixel((1, 0)))
|
||||
|
||||
def test_warm_gold_frame_keeps_only_the_edge_band(self):
|
||||
source = Image.new("RGBA", (5, 5), (180, 140, 80, 255))
|
||||
config = {
|
||||
"borderBand": 1,
|
||||
"redGreenMin": 15,
|
||||
"greenBlueMin": 12,
|
||||
"redBlueMin": 35,
|
||||
"redMaxExclusive": 245,
|
||||
"blueMaxExclusive": 180,
|
||||
"alphaOffset": 25,
|
||||
"alphaScale": 6,
|
||||
}
|
||||
output = extract_warm_gold_frame(source, config)
|
||||
self.assertGreater(output.getpixel((0, 2))[3], 0)
|
||||
self.assertEqual((0, 0, 0, 0), output.getpixel((2, 2)))
|
||||
|
||||
def test_warm_gold_frame_rejects_non_gold_and_clears_hidden_rgb(self):
|
||||
source = Image.new("RGBA", (1, 1), (100, 150, 100, 255))
|
||||
config = {
|
||||
"borderBand": 1,
|
||||
"redGreenMin": 15,
|
||||
"greenBlueMin": 12,
|
||||
"redBlueMin": 35,
|
||||
"redMaxExclusive": 245,
|
||||
"blueMaxExclusive": 180,
|
||||
"alphaOffset": 25,
|
||||
"alphaScale": 6,
|
||||
}
|
||||
output = extract_warm_gold_frame(source, config)
|
||||
self.assertEqual((0, 0, 0, 0), output.getpixel((0, 0)))
|
||||
|
||||
def test_png_save_is_srgb_and_byte_deterministic(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
first = Path(directory) / "first.png"
|
||||
second = Path(directory) / "second.png"
|
||||
image = Image.new("RGB", (4, 4), (230, 220, 200))
|
||||
save_png(image, first)
|
||||
save_png(image, second)
|
||||
self.assertEqual(first.read_bytes(), second.read_bytes())
|
||||
self.assertEqual(
|
||||
hashlib.sha256(first.read_bytes()).hexdigest(),
|
||||
hashlib.sha256(second.read_bytes()).hexdigest(),
|
||||
)
|
||||
with Image.open(first) as opened:
|
||||
self.assertIn("srgb", opened.info)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,3 +1,5 @@
|
||||
import hashlib
|
||||
import json
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
@@ -8,7 +10,13 @@ from PIL import Image
|
||||
SCRIPTS = Path(__file__).resolve().parents[1] / "scripts"
|
||||
sys.path.insert(0, str(SCRIPTS))
|
||||
|
||||
from build_scroll_skins import build_asset, remove_chroma_background, sanitize_output_edges, stretch_safe_center
|
||||
from asset_quality import analyze_asset
|
||||
from build_scroll_skins import (
|
||||
build_asset,
|
||||
remove_chroma_background,
|
||||
sanitize_output_edges,
|
||||
stretch_safe_center,
|
||||
)
|
||||
|
||||
|
||||
class BuildScrollSkinsTests(unittest.TestCase):
|
||||
@@ -20,9 +28,7 @@ class BuildScrollSkinsTests(unittest.TestCase):
|
||||
def test_chroma_background_becomes_transparent_black(self):
|
||||
image = Image.new("RGB", (4, 2), (0, 255, 0))
|
||||
image.putpixel((1, 0), (180, 30, 20))
|
||||
|
||||
cleaned = remove_chroma_background(image, (0, 255, 0), tolerance=80)
|
||||
|
||||
self.assertEqual((0, 0, 0, 0), cleaned.getpixel((0, 0)))
|
||||
self.assertEqual((180, 30, 20, 255), cleaned.getpixel((1, 0)))
|
||||
|
||||
@@ -33,13 +39,7 @@ class BuildScrollSkinsTests(unittest.TestCase):
|
||||
image.putpixel((x, y), (220, 170, 40, 255))
|
||||
image.putpixel((29 - x, y), (220, 170, 40, 255))
|
||||
|
||||
output = stretch_safe_center(
|
||||
image,
|
||||
output_size=(60, 20),
|
||||
cap_width=5,
|
||||
padding=2,
|
||||
)
|
||||
|
||||
output = stretch_safe_center(image, output_size=(60, 20), cap_width=5, padding=2)
|
||||
self.assertEqual((60, 20), output.size)
|
||||
self.assertEqual((0, 0, 0, 0), output.getpixel((0, 0)))
|
||||
self.assertEqual((220, 170, 40, 255), output.getpixel((3, 10)))
|
||||
@@ -50,28 +50,28 @@ class BuildScrollSkinsTests(unittest.TestCase):
|
||||
image = Image.new("RGBA", (3, 1), (248, 240, 220, 255))
|
||||
image.putpixel((0, 0), (0, 255, 0, 128))
|
||||
image.putpixel((1, 0), (250, 250, 250, 96))
|
||||
|
||||
cleaned = sanitize_output_edges(image)
|
||||
|
||||
self.assertEqual((0, 0, 0, 0), cleaned.getpixel((0, 0)))
|
||||
self.assertEqual((0, 0, 0, 0), cleaned.getpixel((1, 0)))
|
||||
self.assertEqual((248, 240, 220, 255), cleaned.getpixel((2, 0)))
|
||||
|
||||
def test_build_asset_honors_declared_palette_size(self):
|
||||
source = Image.new("RGB", (32, 16), (0, 255, 0))
|
||||
for y in range(1, 15):
|
||||
for x in range(1, 31):
|
||||
source.putpixel((x, y), (120 + (x % 30) * 4, 20 + y, 10))
|
||||
source_path = self.root / "source.png"
|
||||
output_path = self.root / "output.png"
|
||||
source.save(source_path)
|
||||
asset = {
|
||||
"id": "palette-test",
|
||||
def asset(self, asset_id):
|
||||
"""返回与现行判别式 schema 一致的最小透明卷轴测试资产。"""
|
||||
return {
|
||||
"id": asset_id,
|
||||
"source": "source.png",
|
||||
"output": "output.png",
|
||||
"sourcePixels": {"width": 32, "height": 16},
|
||||
"outputPixels": {"width": 60, "height": 30},
|
||||
"alpha": {"transparentOuterPadding": 1},
|
||||
"alpha": {"required": True, "transparentOuterPadding": 1, "cornerMaxAlpha": 0},
|
||||
"edge": {
|
||||
"forbidChromaResidue": True,
|
||||
"forbidLightFringe": True,
|
||||
"premultipliedAlphaCheck": True,
|
||||
},
|
||||
"quality": {"maxBytes": 10000, "colorSpace": "sRGB"},
|
||||
"processing": {
|
||||
"mode": "chroma-stretch",
|
||||
"capWidth": 4,
|
||||
"keyColor": "#00FF00",
|
||||
"keyTolerance": 80,
|
||||
@@ -79,14 +79,37 @@ class BuildScrollSkinsTests(unittest.TestCase):
|
||||
},
|
||||
}
|
||||
|
||||
build_asset(asset, self.root)
|
||||
def save_test_source(self):
|
||||
source = Image.new("RGB", (32, 16), (0, 255, 0))
|
||||
for y in range(1, 15):
|
||||
for x in range(1, 31):
|
||||
source.putpixel((x, y), (120 + (x % 30) * 4, 20 + y, 10))
|
||||
source.save(self.root / "source.png")
|
||||
|
||||
with Image.open(output_path) as output:
|
||||
def test_build_asset_honors_declared_palette_size(self):
|
||||
self.save_test_source()
|
||||
build_asset(self.asset("palette-test"), self.root)
|
||||
with Image.open(self.root / "output.png") as output:
|
||||
visible_colors = {
|
||||
pixel[:3] for pixel in output.convert("RGBA").get_flattened_data() if pixel[3] > 0
|
||||
}
|
||||
self.assertLessEqual(len(visible_colors), 8)
|
||||
|
||||
def test_same_input_produces_identical_bytes_and_report_twice(self):
|
||||
self.save_test_source()
|
||||
asset = self.asset("deterministic-test")
|
||||
snapshots = []
|
||||
for _ in range(2):
|
||||
build_asset(asset, self.root)
|
||||
output = self.root / "output.png"
|
||||
snapshots.append(
|
||||
(
|
||||
hashlib.sha256(output.read_bytes()).hexdigest(),
|
||||
json.dumps(analyze_asset(output, asset, self.root), ensure_ascii=False, sort_keys=True),
|
||||
)
|
||||
)
|
||||
self.assertEqual(snapshots[0], snapshots[1])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -1,49 +0,0 @@
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from PIL import Image, ImageDraw
|
||||
|
||||
SCRIPTS = Path(__file__).resolve().parents[1] / "scripts"
|
||||
sys.path.insert(0, str(SCRIPTS))
|
||||
|
||||
from rebuild_a01_buttons import rebuild_button
|
||||
|
||||
|
||||
class RebuildA01ButtonTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.directory = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self.directory.cleanup)
|
||||
self.root = Path(self.directory.name)
|
||||
|
||||
def make_source(self):
|
||||
image = Image.new("RGBA", (60, 30), (0, 255, 0, 255))
|
||||
draw = ImageDraw.Draw(image)
|
||||
draw.rectangle((4, 4, 55, 25), fill=(150, 20, 12, 255))
|
||||
draw.rectangle((4, 4, 13, 25), fill=(210, 160, 40, 255))
|
||||
draw.rectangle((46, 4, 55, 25), fill=(210, 160, 40, 255))
|
||||
return image
|
||||
|
||||
def test_rebuilds_three_slice_into_clean_transparent_canvas(self):
|
||||
source = self.root / "source.png"
|
||||
output = self.root / "output.png"
|
||||
self.make_source().save(source)
|
||||
|
||||
rebuild_button(source, output, output_size=(180, 60), trim=4, padding=6, cap_width=10)
|
||||
|
||||
with Image.open(output) as image:
|
||||
self.assertEqual("RGBA", image.mode)
|
||||
self.assertEqual((180, 60), image.size)
|
||||
self.assertEqual((0, 0, 0, 0), image.getpixel((0, 0)))
|
||||
self.assertEqual((0, 0, 0, 0), image.getpixel((179, 59)))
|
||||
visible_pixels = [pixel for pixel in image.get_flattened_data() if pixel[3] > 8]
|
||||
self.assertFalse(
|
||||
any(green > 120 and green - max(red, blue) > 80 for red, green, blue, green_alpha in visible_pixels)
|
||||
)
|
||||
self.assertGreater(image.getpixel((7, 30))[0], image.getpixel((90, 30))[0])
|
||||
self.assertGreater(image.getpixel((172, 30))[0], image.getpixel((90, 30))[0])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,34 +0,0 @@
|
||||
# 家谱 App 项目计划
|
||||
|
||||
## 已确定的产品规则
|
||||
|
||||
- 端:uni-app 用户端,面向 Android 与 iOS 的 HBuilderX 打包。
|
||||
- 导航:家谱、家族、我的;通知入口只做提醒与跳转。
|
||||
- 主流程:管理员创建家谱并录入首代关系,族人申请加入后由管理员审核。
|
||||
- 访问规则:家谱默认仅成员可见;管理员可开放为可搜索、可申请加入。
|
||||
- 视觉:家祠卷轴——朱砂红、宣纸暖白、黛青、鎏金细线、竖式人物牌位。
|
||||
- 素材:自制图标均为透明背景 PNG;必要时采用可商用在线 PNG 图标并统一风格。
|
||||
- 接口:`APP.openapi.yaml` 是 App 端接口契约;地址、`clientid`、`tenantId` 与测试账号通过本地配置注入。
|
||||
|
||||
## 进度
|
||||
|
||||
- [x] 阅读思维导图、现有界面稿、参考 uni-app 工程与 OpenAPI 文档。
|
||||
- [x] 确定三 Tab 信息架构、核心创建流程、加入审核规则和视觉方向。
|
||||
- [x] 创建 uni-app 工程骨架、设计令牌与透明 PNG 图标资产。
|
||||
- [x] 完成登录、我的家谱、创建家谱、家谱总览、世系树、成员档案和申请审核的首版界面与本地交互闭环。
|
||||
- [x] 实现登录、我的家谱、创建家谱与公开家谱申请加入。
|
||||
- [x] 修复 Vue 3 工程入口:补齐根目录 `index.html`,并加入配置与入口自检。
|
||||
- [x] 完成当前已实现页面的 HBuilderX Web 编译、全量语言诊断与缓存排查;本地 H5 发行构建仍受 HBuilderX 登录状态限制。
|
||||
- [x] 参照完整 uni-app 工程修复 Sass 注入:令牌直接置于 `uni.scss`,重启 Vite 后登录页样式请求已验证为 200。
|
||||
- [x] 完成显式 Mock/Remote 模式、密码登录链路、token 保存、接口 `data` 解包与当前家谱 ID 上下文。
|
||||
- [x] 完成创建家谱后录入首代,并按当前家谱加载详情、世系树、成员档案、申请审核与通知已读。
|
||||
- [x] 完成家族动态发布、谱文/相册/祭祀/族务内容入口,以及动态个人资料读取。
|
||||
- [ ] 实现家谱总览、世系树、成员资料和亲属关系编辑。
|
||||
- [ ] 实现家族圈、谱文、相册、祭祀、贺礼、成长记录、备忘和功德。
|
||||
- [ ] 实现通知、个人中心、帮助、反馈、VIP 与接口配置。
|
||||
- [ ] 在 HBuilderX 验证运行、Android/iOS 打包配置和关键流程。
|
||||
|
||||
## 当前接口限制
|
||||
|
||||
- 已支持:认证、家谱、成员、字辈、世系树、家族圈、文章、相册、祭祀、族务记录、通知、反馈与 VIP。
|
||||
- 暂不实现为可用功能:邀请码、视频与多管理员分级权限;接口文档尚未提供对应端点。
|
||||
@@ -1,28 +0,0 @@
|
||||
# 参考 uni-app 工程结构核对
|
||||
|
||||
参考工程:`C:\Users\Rain\Desktop\软件\JOB\3dyjsapp`
|
||||
|
||||
## 已确认并采纳
|
||||
|
||||
- 参考工程是完整的 HBuilderX uni-app 工程,根目录使用 `App.vue`、`main.js`、`pages.json`、`manifest.json`、`index.html` 与 `uni.scss`。
|
||||
- 参考工程的 `manifest.json` 已声明 Vue 3;`main.js` 同时保留 Vue 2 与 Vue 3 的条件入口。家谱项目固定使用 Vue 3,因此保留精简的 Vue 3 入口即可。
|
||||
- 参考工程的 `uni.scss` 直接定义 Sass 变量,而不是在其中使用相对路径导入。家谱项目已按此方式调整:`uni.scss` 是唯一的设计令牌来源。
|
||||
- 参考工程把页面清单集中在 `pages.json`,家谱项目已用自动审计校验每条路由都有对应页面文件。
|
||||
|
||||
## 本项目的对应实现
|
||||
|
||||
- `uni.scss`:朱砂红、宣纸暖白、墨色、鎏金等 Sass 令牌。
|
||||
- `styles/global.scss`:只由 `App.vue` 从根目录引入的全局通用样式。
|
||||
- `tests/compile-audit.ps1`:校验路由、入口、Sass 引用与模块引用。
|
||||
- `tests/uni-scss-injection.ps1`:防止在 `uni.scss` 中再次写入会被页面按错误目录解析的相对 Sass 导入。
|
||||
|
||||
## 不照搬的部分
|
||||
|
||||
- 彩票业务页面、接口、数据模型与大量业务组件。
|
||||
- 参考工程中的第三方 UI 模块、OAuth 配置和过宽设备权限;家谱 App 按实际功能逐项添加,避免无关权限与凭据进入工程。
|
||||
|
||||
## 后续仍需补齐
|
||||
|
||||
- 家谱 App 的 Android/iOS 应用图标尺寸与启动图资源。
|
||||
- 登录后的真实接口配置与测试账号联调。
|
||||
- HBuilderX 登录后的本地 H5 发行构建及 Android/iOS 云打包验证。
|
||||
@@ -1,45 +0,0 @@
|
||||
# 全活动页面业务所有权审计(2026-07-20)
|
||||
|
||||
## 结论
|
||||
|
||||
52 条活动路由已经完成业务所有权收敛。活动页面不再引用 `ModulePage.vue` 或 `page-catalog.js`;共享层只保留按钮、弹层、Toast、加载、页头、背景和视觉卡片等原语。每个路由自行维护本页的模拟数据、状态、校验、动作及去向,仍未连接接口。
|
||||
|
||||
`ModulePage.vue`、`page-catalog.js` 和 `TreeMemberForm.vue` 按“不删除既有源码”的约束保留为历史文件,但活动路由不再消费这些通用业务母版。
|
||||
|
||||
## 五个方案与选择
|
||||
|
||||
1. 继续使用单一通用业务母版:改动最少,但页面语义、状态和真实入口继续失真,不采用。
|
||||
2. 扩大 `page-catalog.js` 配置:能增加字段,却会把校验、状态机和导航也塞入配置,不采用。
|
||||
3. 每个模块保留一个大型业务母版:比全局母版稍好,但 F、R、N、M 内部页面仍然职责不同,不采用。
|
||||
4. 建立无头 schema 渲染器:扩展性高,但会引入当前项目不需要的抽象和维护成本,不采用。
|
||||
5. 共享视觉原语、路由拥有业务内容:既保持统一国风视觉,又允许列表、详情、编辑器、时间线、消息、安全和服务页表达真实任务,采用。
|
||||
|
||||
## 本轮收敛范围
|
||||
|
||||
- F:动态详情、谱文列表/详情/编辑、相册列表/详情/上传、视频状态及 F01 八个真实入口形成闭环。
|
||||
- R:人物、贺礼、礼仪、成长、人生事、备忘、功德分别拥有专属数据结构、字段、状态和动作。
|
||||
- N/M:消息卡进入消息详情;资料、安全、密码、手机号、帮助、反馈、推广、订单和关于均为独立页面。
|
||||
- G:家谱上下文、成员身份、加入申请来源、驳回/撤回再申请、设置草稿和字辈容量由路由参数与数据决定。
|
||||
- T:T01 根据 `generation`/`parentId` 计算树布局;T03—T08 按人物、关系及权限状态工作,不再用演示标签切状态。
|
||||
- 共享组件:按钮改为原生可聚焦按钮;弹层补齐对话框语义、焦点和 Escape;Toast/Loading 补齐 live region 与减少动态效果;页头返回支持深链兜底。
|
||||
|
||||
## 数据与布局边界
|
||||
|
||||
- 普通列表和卡片由数据循环与自然高度撑开,不依赖固定条数或绝对坐标。
|
||||
- T01 的布局尺寸从成员关系计算,测试覆盖 10 世代、每代 12 人的 120 节点压力数据。
|
||||
- 容量合同覆盖 50 条列表、50 条弹层、30 项媒体、三倍长文本和 1.3 倍字号;103 个保留风险均精确到文件、选择器和风险类型。
|
||||
- 定位合同扫描所有 `position` 值(包括 `relative`);必要定位精确白名单之外无违规,也无失效白名单。
|
||||
|
||||
## 验证证据
|
||||
|
||||
- 52 条活动路由及 2 个共享组件 Vite 转换:54/54。
|
||||
- PowerShell 合同:119 项中 118 项通过;唯一失败为既有基础资产候选式命名。A01 预览已从可复现管线重新生成,G06 一致性合同已改读长期 handoff 证据,不再依赖已清理的 runtime 目录。
|
||||
- 浏览器运行时 smoke:排除会写回已清理长期目录的旧 T07 baseline 后,26/26 通过。
|
||||
- 52 个活动页面均在 360×800 捕获当前图到 `%TEMP%`,按 A/G/T、F、R、N/M 生成联系表,并与现有同模块长期候选图复核;未发现横向溢出、明显裁切、错误复用母版或不可达主动作。
|
||||
- 四档视口:320×568、360×640、360×800、412×915;覆盖模块响应式、数据压力和主要业务状态。
|
||||
|
||||
## 明确边界
|
||||
|
||||
- 本轮结论是 H5 本地模拟数据阶段的页面实现与内部审核完成,不代表接口、持久化、上传权限、微信能力、Android/HBuilderX、软键盘或真机性能完成。
|
||||
- F01、T01、A05 及大量业务页发生可见变化;自动审计不能替用户维持或新增 `[x]`。这些页面保持“待用户复核”,只有用户再次明确说“通过”才更新视觉冻结状态。
|
||||
- `foundation-asset-audit` 仍未通过,未放宽规则或删除实际资产。`repository-handoff-size-contract` 通过补齐可再生成目录的忽略规则后已真实通过(35 个长期截图、19.51MB、52 个索引链接),没有删除长期资料或放宽阈值。
|
||||
@@ -1,58 +0,0 @@
|
||||
# 全项目数据驱动布局审计(2026-07-20)
|
||||
|
||||
## 审计基线
|
||||
|
||||
- 权威基线:`main` / `01d246c 保存视觉审核与文档流迁移进度`。
|
||||
- 范围:`pages/`、`components/`、活动路由与封存 A06;当前只验证 H5,不接接口,不宣称 Android 完成。
|
||||
- 首次容量合同发现 279 项待解释风险。修正“固定网格容量”规则后为 276 项:普通 `repeat(2/3/4, ...)` 是响应式列数,不等于数据容量上限;只有 12 列及以上的固定重复网格才按固定容量画布拦截。
|
||||
- 当前截图证据:G01 切换家谱弹层在 412×915、6 条数据下可以自然增高并滚动,作为弹层容量参考;这不代表其他页面已经通过。
|
||||
|
||||
## 统一五方案决策
|
||||
|
||||
每个确认问题均评估以下五种方案:
|
||||
|
||||
1. 扩大固定尺寸:改动小,但只推迟溢出,淘汰。
|
||||
2. 增加响应式断点:只解决设备宽高差异,不能解决未知数据量;仅作补充。
|
||||
3. `max-height` 加内部滚动:适合弹层、独立列表和结构化画布,不用于普通内容卡片。
|
||||
4. 正常文档流自然撑高:适合普通页面、列表卡、表单、状态说明和长文案,作为默认最优方案。
|
||||
5. 由数据计算布局边界:适合世系树、可变列媒体和结构化关系画布。
|
||||
|
||||
## 已确认问题与选择
|
||||
|
||||
| 类别 | 已确认风险 | 五方案结论 | 最优方案 |
|
||||
| --- | --- | --- | --- |
|
||||
| 共享 `GenealogyCard` | 谱名省略、地点/人数/角色/更新时间不换行 | 1 不能覆盖未知长度;2 只能缓解窄屏;3 不应让普通卡片内部滚动;4 可保持完整数据并自然撑高;5 无结构坐标需求 | 4 |
|
||||
| T07 成员目录 | 成员卡固定 192/196rpx,姓名和元信息强制单行省略 | 1 仍有上限;2 仍依赖样本;3 破坏页面主滚动;4 与普通列表语义一致;5 无结构坐标需求 | 4 |
|
||||
| G06 搜索结果 | 结果卡虽有 `min-height`,仍用 `overflow: hidden` 裁切增长内容 | 1 无法解决裁切;2 无法覆盖长字段;3 普通结果卡不应独立滚动;4 删除裁切后由内容撑高;5 无结构坐标需求 | 4 |
|
||||
| G05 家谱概览 | 内容区使用百分比/固定 grid 行,长标题与详情可能互相挤压 | 1/2 只能推迟;3 不适合普通概览正文;4 可让各段按内容增长;5 当前无坐标关系 | 4 |
|
||||
| T01 世系树 | 固定 900rpx 画布、180×180 网格和固定世代轨道隐含容量上限 | 1/2 仍有容量上限;3 只能承担视口滚动;4 无法表达节点坐标与连线;5 可由世代数、单代节点数和间距推导画布 | 5,外层配合方案 3 |
|
||||
|
||||
## 保留边界
|
||||
|
||||
下列固定视觉边界不会因合同扫描被机械删除:图标尺寸、媒体缩略图比例与必要裁切、固定导航、真实弹窗/遮罩/Toast、装饰背景槽位。它们必须进入精确白名单并说明理由;普通正文、按钮文案、表单、列表卡和状态说明不得借白名单隐藏容量问题。
|
||||
|
||||
## 再审核要求
|
||||
|
||||
修复后必须重新运行全局容量合同、聚焦合同和压力数据 smoke,并在 320×568、360×640、360×800、412×915 下检查横向溢出、文本裁切、末项可达和滚动所有权。凡产生可见变化的既有 `[x]` 页面,只能作为新的视觉候选,必须由用户再次明确说“通过”后才能维持或新增 `[x]`。
|
||||
|
||||
## 实施与二次审核结果
|
||||
|
||||
- 容量合同从初始 279 项、规则纠正后的 276 项,收敛为 0 个未解释风险和 0 个失效白名单。最终 103 个白名单键均精确到文件、选择器和风险类型,只保留图标/印章/装饰分隔、固定导航、媒体缩略图裁切、G01/F01/T01 明确内部滚动壳及 T01 结构化节点边界。
|
||||
- 共享 `GenealogyCard`、T07、G06、F01、N01、M01、R01/R02、表单控件、状态卡与 G08—G12/T03/T08 的普通内容改为 `min-height`、换行和自然撑高;根容器不再用 `overflow-x: hidden` 掩盖横向问题。
|
||||
- G08/G11/G12 文本域使用 `auto-height`;G09 状态与操作分为独立数据行。二次截图发现的 G08/G11 原生 textarea 固有高度和 G09 状态操作重叠均已再次修正并复核。
|
||||
- T01 的画布宽高、行列数、世代轨、节点和关系线全部由成员数据计算。外层纵向滚动与内层横向滚动分工明确;120 节点(10 世代×每代 12 人)压力通过。
|
||||
- G01 弹层已验证 2/6/12/50 条:少量内容自然增长,达到安全上限后仅列表区滚动,50 条末项可达。
|
||||
- `data-driven-layout-runtime-smoke.js` 已验证:T07 50 条、G09 50 条、F09 30 项、3 倍长中文与 1.3 倍字号压力、四档视口、末项可达、横向溢出和表单自动增高。
|
||||
- 二次视觉对比覆盖 G05、T01、G08—G12、T03、T07、T08;对照长期 handoff 图检查了边框、装饰资产、间距和内容区。当前结果只能作为新的 H5 视觉候选,不能代替用户重新确认。
|
||||
|
||||
## 验证边界
|
||||
|
||||
- 相关静态合同(排除已知独立失败/缺失生成物项)全通过;21 个既有核心 runtime smoke、G01 50 条弹层压力和新增数据容量 smoke 通过。
|
||||
- `foundation-asset-audit` 与 `repository-handoff-size-contract` 的既有失败未通过删资料或放宽阈值处理;G06 runtime 截图一致性脚本因已授权清理 `docs/design/screens/runtime/` 不计入通过项。
|
||||
- 未运行会恢复长期目录外短期截图且含旧行为断言的 `t07-module-baseline-runtime-smoke.js`;T03—T08 核心流程由独立 smoke 通过。
|
||||
- 没有新的 Android/HBuilderX、系统字体缩放或真实接口证据;A06 仍为封存静态检查。
|
||||
|
||||
## 用户复核结论
|
||||
|
||||
- 用户已明确说“通过”,确认 G05、T01、G08—G12、T03、T07、T08 的本轮数据驱动布局迁移候选。
|
||||
- T01、T07 原有 `[x]` 可以维持;其他页面的本次确认只覆盖迁移后的 H5 可见效果,不替代真实入口、适用状态和整页流程验收。
|
||||
@@ -1,56 +0,0 @@
|
||||
# A01、G01 与 A02 视觉审视
|
||||
|
||||
> 日期:2026-07-13
|
||||
> 方法:通过用户授权的 Chrome 调试端口捕获真实 360 × 800 页面画面,并逐张检查。
|
||||
> 范围:只审视 A01、G01 两张已验收基准页与 A02 当前登录页;本记录不是用户最终验收。
|
||||
|
||||
## 1. 截图证据
|
||||
|
||||
| 页面 | 状态 | 截图 |
|
||||
| --- | --- | --- |
|
||||
| A01 启动/登录引导 | 用户已验收的认证基准 | `screens/runtime/2026-07-13/A01-360x800.png` |
|
||||
| G01 我的家谱 | 用户已验收的家谱基准 | `screens/runtime/2026-07-13/G01-360x800.png` |
|
||||
| A02 账号登录 | 当前实现,未验收 | `screens/runtime/2026-07-13/A02-before-360x800.png` |
|
||||
|
||||
## 2. A01、G01 共同的可继承规则
|
||||
|
||||
1. **完整资产承担外观。** 朱砂页头、纸纹、主/次按钮、题签框、谱印框、云纹和分区线都有明确的图片资产;文字与交互位于图片之上。
|
||||
2. **一屏只有一个视觉主角。** A01 是“家谱”标题与两枚操作按钮;G01 是当前家谱题签。其余山水、竹影和金线只托住主角。
|
||||
3. **信息层级有节奏。** 大号楷体标题、常规正文、弱化元信息分明;每一段间保留足够纸面,不把内容挤成普通管理表单。
|
||||
4. **朱砂只表达关键动作或当前状态。** A01 的登录、G01 的当前谱印与选中 Tab 使用朱砂;古金负责边框、图标和分隔。
|
||||
5. **同一类元素使用同一套成品。** A01 两枚按钮保持一致比例;G01 的当前题签、列表题签、谱印、快捷图标和底部导航形成连续的组件语言。
|
||||
6. **背景不抢任务。** 纸纹先提供可读底色,淡墨山水只落在空白和底部,不能穿过输入区或元信息。
|
||||
|
||||
## 3. A02 当前状态
|
||||
|
||||
### 可保留项
|
||||
|
||||
- 已有 A01 同源的纸纹、祠堂页头、谱印、淡墨背景和朱砂按钮皮肤。
|
||||
- 账号密码/手机验证码双 Tab、真实 input、忘记密码入口、协议判断与 A05 跳转仍应保留。
|
||||
- 360 × 800 下页面没有横向裁切,主按钮可见。
|
||||
|
||||
### 必须解决的可见问题
|
||||
|
||||
1. **登录面板是普通的细金线矩形。** 它没有完整纸面、双线、角纹或题签结构,和 A01 的完整按钮、G01 的完整谱签不属于同一视觉系统。
|
||||
2. **卡片内容过小且过密。** 标题、双 Tab、标签、占位文本、忘记密码与协议提示都落在一张矮卡内,用户进入后感受到的是“填写控件”,不是“进入家谱”。
|
||||
3. **按钮比例被压扁。** 当前主按钮使用约 4.49:1 的 A01 皮肤,却放入更扁的容器,边角和纹样失去端正的卷轴比例。
|
||||
4. **标题装饰没有成为层级。** 单侧云纹和细小返回符号像零散点缀,无法支撑“登录家谱”作为当前任务标题。
|
||||
5. **底部留白失衡。** 表单卡结束后到山水背景之间有大块无任务留白,而面板内部反而拥挤;需要把留白放在卡片外、把可读空间还给表单。
|
||||
|
||||
## 4. A02 设计决策
|
||||
|
||||
- 继续使用“家祠卷轴 · 庄重留白”,不另起现代渐变、玻璃卡片或圆角后台表单画风。
|
||||
- 认证头部与 A01 使用同一祠堂高度和谱印位置;头部以下先给清楚的“登录家谱”题签标题,再给完整不透明的认证面板资产。
|
||||
- 认证面板只承载 Tab、输入、忘记密码与协议说明;面板自身纸纹、金边和角纹由一张完整不透明 PNG 提供。
|
||||
- 主按钮只能按完整皮肤原比例显示;空间不足时缩小宽度或增加高度,不能纵向压缩图片。
|
||||
- 淡墨山水放到卡片结束后的留白与底部,作为收束,不穿过输入行。
|
||||
|
||||
## 5. 可见性与无障碍风险
|
||||
|
||||
- A02 的弱提示和协议文字在 360 × 800 下偏小,后续实现要保证正文至少 24rpx,主要点击区至少约 44dp。
|
||||
- 截图不能证明键盘焦点、读屏标签、错误提示朗读和真实 Android 字体回退;实施后仍需在 HBuilderX/Android 复核。
|
||||
- 本次只验证了正常态,没有验证输入错误、验证码倒计时或网络失败态;这些状态保持现有功能边界,不在本轮扩展接口。
|
||||
|
||||
## 6. 下一步
|
||||
|
||||
先为 A02 建立完整认证面板和正确比例按钮的资产合同,再做 A02 四尺寸前后对比;用户确认 A02 后,才将它作为 A03–A05 的认证样板。
|
||||
@@ -1,73 +0,0 @@
|
||||
# 页面与状态合并审计
|
||||
|
||||
> 审计日期:2026-07-14
|
||||
> 结论状态:G02→G01、G04→G03 已获用户授权并完成实施;其余四项仍是待实施建议。
|
||||
|
||||
## 1. 审计范围与证据
|
||||
|
||||
- 审计时 `pages.json` 实际登记 58 条路由;用户确认后已依序删除 G02、G04、G07 独立路由,当前由 `tests/full-page-visual-contract.ps1` 约束为 **55 条路由**。
|
||||
- G02 原先替换的是 `pages/genealogy/g02-empty-genealogies` 通用壳;该路由与 G01 的空态重复,现已收敛为 G01 的 `?state=empty`,不再进入独立页面验收。
|
||||
- 已在本轮重新捕获并审视 A01、A02、A04、A05、A06、G01 的 360×800 真实运行图:`screens/runtime/2026-07-14/route-audit/accepted-route-audit-360x800.png`。
|
||||
- G01 源码在 `hasGenealogies` 的 `v-else` 分支拥有“新建家谱/搜索并申请加入”两条动作;现已由 `forceEmptyState` 统一所有权。默认态前后对比为 `screens/runtime/2026-07-14/G01-default-before-after-360x800.png`,空状态为 `G01-empty-after-360x800.png` 与 `G01-empty-after-412x915.png`,均已获用户审美确认。
|
||||
- 其余 40 个仍渲染 `ModulePage` 的路由尚无独立视觉实现;其合并判断依据目前是规划中的任务语义、页面中文用途注释与目录状态,不将通用壳截图误当成最终交互。
|
||||
|
||||
## 2. 判断标准
|
||||
|
||||
保留独立路由,当用户进入的是不同的主任务、需要独立返回历史、可被深链/通知直接打开,或需要承载未保存的完整表单。
|
||||
|
||||
合并为同页状态,当差异仅是加载/空/失败/权限、查询前后、同一表单的步骤,或同一详情内的媒体预览与上传结果。状态由页面数据或查询参数驱动,不另建占位路由。
|
||||
|
||||
## 3. 已验收页面复核
|
||||
|
||||
| 已验收路由 | 结论 | 理由 |
|
||||
| --- | --- | --- |
|
||||
| A01 启动/登录引导 | 保留独立页 | 品牌入口、微信登录、注册入口与协议确认,和登录表单不是同一步。 |
|
||||
| A02 账号登录 | 保留独立页;现有双 Tab 正确 | 账号密码/短信验证码已经在同页状态切换,无需拆为 A03。 |
|
||||
| A04 注册账号 | 保留独立页 | 需要完整新用户表单、协议确认与返回链。 |
|
||||
| A05 重置密码 | 保留独立页 | 手机验证、验证码与两次密码确认是独立安全任务。 |
|
||||
| A06 登录状态 | 保留一个路由;现有六状态正确 | `normal/failed/restricted/register-pending/wechat-*` 已在同页状态配置中,未为每种错误新增页面。 |
|
||||
| G01 我的家谱 | 保留根页;合并 G02 | 已具备列表、加载和空态分支,G02 不应是第十二个家谱路由。 |
|
||||
|
||||
结论:已验收页面中没有需要为了减少页面而合并的认证页;A02 与 A06 已经是正确的“同页多状态”实现。唯一明确重复的是 G02→G01。
|
||||
|
||||
## 4. 全量路由建议
|
||||
|
||||
| 模块 | 现有路由/单元 | 保留为独立任务 | 合并为同页状态 | 路由数变化 |
|
||||
| --- | ---: | --- | --- | ---: |
|
||||
| 认证 | 5 | A01、A02、A04、A05、A06 | A02 Tab;A06 六状态均保留在各自页面内部 | 5 → 5 |
|
||||
| 家谱 | 9 | G01、G03、G05、G06、G08、G09、G10、G11、G12 | 已完成 G07→G06 搜索结果/无结果收敛 | 9 → 9 |
|
||||
| 世系树 | 8 | T01、T03、T04、T05、T06、T07 | T02→T01 的横屏/空/加载失败状态;T08→T03 的已故/隐私/无权状态 | 8 → 6 |
|
||||
| 家族内容 | 10 | F01、F02、F03、F04、F05、F06、F07、F08、F10 | F09→F08 的图片预览/上传/失败状态;F03 评论/删除确认和 F10 服务未开放也只作本页状态 | 10 → 9 |
|
||||
| 人物与礼仪 | 11 | R01--R11 | R02 编辑、R04 新增/详情/删除确认、R07 新建/编辑均已应作为各自路由内部状态 | 11 → 11 |
|
||||
| 消息 | 2 | N01、N02 | N01 空态、N02 已读均为内部状态 | 2 → 2 |
|
||||
| 个人中心 | 10 | M01--M10 | M01 登录态、M03 安全态、M09 空订单均为内部状态;M04/M05 保持独立,尤其 M05 符合用户已确认的“安全设置按钮跳转绑定手机号页面” | 10 → 10 |
|
||||
| **总计** | **55** | | **尚余 3 个重复路由合并** | **55 → 52** |
|
||||
|
||||
## 5. 建议的 52 条路由主表
|
||||
|
||||
### 合并清单(高置信)
|
||||
|
||||
1. 已删除 `G07` 路由;G06 同页管理初始搜索、结果列表与无结果,点选后进入 G08。G06 截图与自动验证已完成,等待用户审美确认。
|
||||
2. 删除 `T02` 路由;T01 同页管理树正常态、小屏横屏提示、无成员、加载与失败。
|
||||
3. 删除 `T08` 路由;T03 同页管理正常成员、已故、隐私隐藏、无权限。T07 仅保留目录和搜索结果。
|
||||
4. 删除 `F09` 路由;F08 同页管理照片墙、预览、选择上传、上传中、失败重试。
|
||||
|
||||
### 暂不合并的边界
|
||||
|
||||
- 不把 A01/A02、A04/A05、M04/M05 合成万能认证/安全页:这些是不同主任务,且 M05 的独立跳转是用户已确认的产品要求。
|
||||
- 不把 G08/G09 合并:一次申请表单与“我的全部申请记录”是不同任务和信息密度。
|
||||
- 不把 T04/T06、F02/F06、R03/R05/R08--R11 合并:对象、数据结构与返回历史不同;可复用表单组件,但不应强行共享一个路由。
|
||||
- F10 是否并入 F01 的媒体 Tab 不足以从当前规划判断,暂保留 F10 路由;待家族首页信息架构定稿后再评估,不计入 52 的高置信精简。
|
||||
|
||||
## 6. 已完成的 G02、G04、G07 收敛
|
||||
|
||||
G02 不进入独立视觉验收。用户已授权实施后,独立页面、G02 契约/冒烟测试、专属截图与“等待验收”文档已删除;最终 ImageGen 面板已改名为 `g01-empty-panel.png`,归属 G01 `state=empty`,现已获用户确认。G04 也已删除;其字段、校验与真实保存行为归属 G03 `step=ancestor`,并已获用户继续授权通过。G07 也已删除;公开检索的初始、结果和无结果均归属 G06,结果点选只进入 G08。`tests/g01-empty-state-contract.ps1`、`tests/g03-create-flow-contract.ps1`、`tests/g06-search-flow-contract.ps1` 与对应 Chrome 冒烟测试保护三条收敛链路;G01 默认态前后对比和 G06 前后对比证明已验收状态没有可见回退,G06 本身仍待用户审美确认。
|
||||
|
||||
## 7. 推荐执行顺序(G06 三态等待审美确认)
|
||||
|
||||
1. G02→G01、G04→G03、G07→G06 已完成,路由总数已从 58 变为 55;G01 两种状态与 G03 双步骤已获用户确认,G06 三态等待审美确认。
|
||||
2. 用户已授权连续推进;下一项实施 T02→T01,路由数变为 54。
|
||||
3. 在世系树完成 T01、T03 的独立设计时同步吸收 T02、T08,不先为状态壳单独生成资产。
|
||||
4. 在相册 F08 设计时同步吸收 F09;其余模块按“先任务页、后状态”推进。
|
||||
|
||||
除已获用户确认的 G02→G01 外,未经用户确认,本审计不改动其余路由、已验收页面或现有未提交内容。
|
||||
@@ -1,81 +0,0 @@
|
||||
# A-01 启动 / 登录引导设计记录
|
||||
|
||||
> 状态:A-01 文字注册入口方案、四尺寸静态验收、本地 UI 与 412 × 915 运行时视觉基准已完成并获用户验收;A-02 双标签本地 UI 已完成并获用户验收。A-03 已删除;协议页去向、A-04 至 A-06 的设计、认证接口与 Android 全尺寸回归验收仍未完成,D2 继续进行中。
|
||||
>
|
||||
> 本页设计事实的唯一归档位置为本文件。旧的 `2026-07-13-a01-login-guide-design.md` 与 `2026-07-13-a01-login-guide.md` 仅保留为失效历史,不可作为实现或推进下一页的依据。
|
||||
|
||||
## 1. 已归档的美术锚点
|
||||
|
||||
- v1–v3 早期整页效果稿已于 2026-07-17 上传前清理;历史入口结构保留在本记录与项目外完整归档中,不再作为当前方案依据。
|
||||
- 当前可恢复美术源:[`assets/a01-vnext/source/A01-layered-source-v1.psd`](assets/a01-vnext/source/A01-layered-source-v1.psd) 与 [`a01-psd-manifest.json`](assets/a01-vnext/source/a01-psd-manifest.json)。
|
||||
- 当前运行时视觉基准:[`screens/A01-启动登录引导-栅格验收-412x915.png`](screens/A01-启动登录引导-栅格验收-412x915.png),用于 A-01 协议未勾选状态的同尺寸像素比对;`pages/auth/entry.vue` 必须使用真实页面结构还原,不得将整张效果图作为页面内容。
|
||||
- 用途:保留本轮已确认的“家祠卷轴”欢迎页美术方向,不作为可直接还原的代码布局图,也不作为功能图标资产。
|
||||
- 画面方向:朱砂家祠红头、暖宣纸主体、古金细线与云纹、墨褐标题、两侧淡墨建筑/竹影与底部山水。登录欢迎页后续必须使用独立的红头/宣纸背景组合,不复用 G01 的列表页大山水底图。
|
||||
|
||||
## 2. 已确认的页面结构与交互
|
||||
|
||||
- 标题区保留“家谱 / 为家族留存可传承的记忆”,使用美术锚点中的朱砂家祠红头、暖宣纸、古金云纹、墨褐文字与淡墨建筑/竹影层次。
|
||||
- 操作区只保留两个大按钮和一个文字入口:朱砂主按钮“登录”、古金描边按钮“微信登录”,以及其下方的“还没有账号?注册账号”。“注册账号”仅为朱砂红可点击文字,不再作为独立大按钮。
|
||||
- 入口顺序:登录为主入口;微信登录为次入口;注册账号为辅助文字入口。
|
||||
- 已勾选协议后,“登录”前往 `A-02`,且 `A-02` 内固定使用“账号密码登录 / 手机验证码登录”双标签切换,默认显示账号密码登录。微信授权取消、失败与受限状态以后归 `A-06`;当前入口继续提示“微信登录功能准备中”,不新增真实授权跳转。
|
||||
- 注册账号入口仍归 `A-04`;忘记密码仍归 `A-05`。这两页完成设计前,当前入口只提示页面准备中,不伪造跳转。
|
||||
- 协议必须为用户主动勾选:初始未勾选,可分别打开《家谱用户协议》和《隐私政策》。不得使用“登录即表示同意”或任何默认同意文案;未勾选时登录操作应明确提示用户先确认协议。
|
||||
|
||||
## 3. 固定布局与状态边界
|
||||
|
||||
- 页面采用纵向滚动;`320 × 568` 下两个按钮、注册文字和协议行可以滚动,但任一按钮文字、注册文字、协议行和底部内容不得被系统导航区遮挡。
|
||||
- A-01 的登录与微信功能图标均需采用独立本地透明 PNG;微信标志只使用官方或许可来源的原色透明 PNG,不自行重绘或改色。账号密码与手机号验证码的表单图标归 A-02 设计时确定。
|
||||
- A-01 只设计欢迎、入口与协议未勾选提示。账号密码与手机号验证码表单均归 `A-02`,微信授权取消、失败与受限结果归 `A-06`,注册归 `A-04`,忘记密码归 `A-05`。绑定手机号以后归个人中心安全设置。
|
||||
- A-01 正常态必须展示两个按钮、注册文字入口和未勾选协议;点击任一需要授权的入口但未勾选协议时,仅提示“请先阅读并同意相关协议”,不视为用户已同意。
|
||||
- 页面根容器宽度必须为 100%,不可写死为 `750rpx` 或使用整张效果图。固定设计画布仍以 `750rpx` 为测量基准,运行时由真实 DOM 按当前屏宽响应式还原。
|
||||
#### 412 × 915 运行时校准表
|
||||
|
||||
| 元素 | 412 × 915 像素基准 | 750rpx 实现基准 |
|
||||
| --- | --- | --- |
|
||||
| 朱砂家祠头图 | 高 139px | 高 253rpx |
|
||||
| 红头谱印 | 比头图顶边低约 38px | `top: 70rpx` |
|
||||
| 标题区起点 | 红头后约 82px | `padding-top: 150rpx` |
|
||||
| 标题两侧云纹 | 紧贴标题两侧,不扩张标题组宽度 | 独立透明素材 `auth-title-cloud-v1.png`,`64rpx × 40rpx`,标题左右各 12rpx |
|
||||
| 标题分隔饰线 | 宽约 234px,中心为对称小结 | 宽 426rpx;中结使用独立透明素材 `auth-divider-knot-v1.png`,上、下间距独立 |
|
||||
| 两个按钮 | x=60px,宽约 291px,高约 65px | `margin: 95rpx 110rpx 0`,高 118rpx |
|
||||
| 两按钮间距 | 约 28px | `margin-top: 51rpx` |
|
||||
| 按钮内框与内容组 | 内框距外边约 3px,四角有卷草角花;图标与文字整体偏左 | `inset: 6rpx`;四角使用同一张 `auth-button-corner-v1.png` 镜像;内容组 `translateX(-20rpx)` |
|
||||
| 注册入口 | 微信按钮后约 56px;无左右短线 | `margin-top: 98rpx` |
|
||||
| 协议行 | 注册入口后约 77px;复选框与全部文案同一水平线 | `margin-top: 141rpx`,字号与行高均为 20rpx / 30rpx,复选框 30rpx,`align-items: center`、`white-space: nowrap`,加安全区 |
|
||||
|
||||
- 上表是 A-01 运行时布局数值的唯一所有者。320 × 568 时页面允许纵向滚动,根容器不得以 `overflow: hidden` 截断协议行或系统安全区;底部内边距包含 `env(safe-area-inset-bottom)`。
|
||||
- 仅作静态设计验证,不代表 uni-app 运行时验收;页面实现后仍须在 Android 320 × 568、360 × 640、360 × 800、412 × 915 上重新验收。
|
||||
|
||||
| 状态 | 触发 | A-01 可见结果 | 去向 |
|
||||
| --- | --- | --- | --- |
|
||||
| 初始未勾选 | 首次进入 | 两个按钮、注册文字与空复选框 | 留在 A-01 |
|
||||
| 未同意协议 | 点击登录、微信登录或注册账号 | 提示“请先阅读并同意相关协议”,不跳转、不改变勾选状态 | 留在 A-01 |
|
||||
| 登录已确认协议 | 点击“登录” | 结束 A-01 引导 | A-02,默认账号密码登录标签 |
|
||||
| 微信已确认协议 | 点击“微信登录” | 提示“微信登录功能准备中” | 留在 A-01;真实授权取消、失败与受限状态以后归 A-06 |
|
||||
| 注册已确认协议 | 点击“注册账号”文字 | 提示“注册页面准备中” | 留在 A-01,等待 A-04 设计完成 |
|
||||
|
||||
### A-01 资产清单
|
||||
|
||||
| 资产 | 用途 | 来源或生成提示 | 尺寸与验收 |
|
||||
| --- | --- | --- | --- |
|
||||
| `docs/design/assets/a01-vnext/source/A01-layered-source-v1.psd` | A-01 可恢复视觉源 | 当前分层 PSD 与清单 | 页面以真实 DOM 和导出位图还原,不把整页设计图作为页面内容 |
|
||||
| `static/assets/backgrounds/auth-ancestral-header-v1.png`、`auth-rice-paper-v1.jpg`、`auth-ink-scenery-v1.png` | A-01 分层页面背景 | 2026-07-13 内置图像生成 | 当前 A-01 使用;不得复用 G-01 背景 |
|
||||
| `static/assets/icons/auth/login-outline-v1.png`、`static/assets/icons/auth/wechat-licensed-v1.png` | A-01 两个按钮图标 | 登录图标复用项目既有资产;微信图标来源与许可见旧记录 | 当前 A-01 使用;微信图标免费使用须在应用“关于/设置”及应用商店描述中保留 Icons8 署名链接 |
|
||||
| `static/assets/foundation/opaque/a01-primary-button.png`、`a01-secondary-button.png` | A01 登录与微信登录的完整按钮皮肤 | 2026-07-13 以运行时视觉基准的比例与画风生成;不裁切设计图 | `1877 × 418px`、`1881 × 419px` 不透明 PNG;分别承担底色、双线边框和四角纹样,DOM 仅叠放图标与文案 |
|
||||
| `static/assets/icons/auth/auth-divider-knot-v1.png` | 标题上下金线的中心对称中结 | 2026-07-13 根据 412 × 915 视觉基准生成,去绿幕后保存 | `160 × 96px` 透明 PNG;不得改用长祥云 |
|
||||
| `static/assets/icons/auth/auth-title-cloud-v1.png` | “家谱”标题左右祥云 | 2026-07-13 根据 412 × 915 视觉基准生成,去绿幕后保存 | `200 × 120px` 透明 PNG;右侧只允许镜像该素材 |
|
||||
|
||||
## 4. 静态设计验收与剩余缺口
|
||||
|
||||
- 静态设计验收已覆盖 320 × 568、360 × 640、360 × 800、412 × 915;四个尺寸均保持两个大按钮与单行文字注册入口。`412 × 915` 图是 A-01 协议未勾选状态的运行时像素基准;A-01 本地页以真实 DOM 还原其层级和比例,运行时仍须截图对比,不得以整图页面替代。
|
||||
- 归档文件:[320 × 568](screens/A01-启动登录引导-栅格验收-320x568.png)、[360 × 640](screens/A01-启动登录引导-栅格验收-360x640.png)、[360 × 800](screens/A01-启动登录引导-栅格验收-360x800.png)、[412 × 915](screens/A01-启动登录引导-栅格验收-412x915.png)。
|
||||
- 此结论只验证设计稿与固定栅格,不替代 uni-app 页面实现后的 Android 运行时验收。
|
||||
- 2026-07-13 用户已验收当前连续背景、完整按钮边框与单行协议区的 `412 × 915` 运行截图,A-01 当前视觉基准标记为完成;本轮不再授权继续微调视觉细节。
|
||||
- 页面实现阶段仍须按本记录在 Android 实机或同等运行环境复核四个尺寸的安全区、滚动与文字折行。
|
||||
- A-01 的协议最终页面去向及 A-04 至 A-06 的设计仍未完成;A-03 已删除。这些后续范围不影响当前 A-01 视觉基准的验收结论。
|
||||
|
||||
## 5. 推进约束
|
||||
|
||||
- `A-01` 与 `A-02` 的本地 UI 已按本记录及实现合同落地并获用户视觉验收;A-03 已删除,后续仅在 A04--A06 获得明确授权后推进。
|
||||
- 当前 A-01 视觉基准已完成;后续只在协议页面、认证接口或新页面范围获得明确授权后继续推进。
|
||||
- 用户已于 2026-07-13 授权落地 A-01 欢迎入口与 A-02 双标签登录页的本地 UI;仍不接接口、不执行真实登录、不改 Android 打包配置。实现细节的唯一所有者见 `docs/superpowers/specs/2026-07-13-a01-a02-static-ui-implementation-design.md`。
|
||||
@@ -1,73 +0,0 @@
|
||||
# A02 账号登录设计记录
|
||||
|
||||
> 状态:已于 2026-07-14 获用户视觉验收,A02 可见效果冻结。
|
||||
> 路由:`pages/auth/a02-login`;A01 已确认协议进入时附带 `?agreed=1`。
|
||||
> 视觉来源:已验收的 A01 启动/登录引导与 G01 我的家谱。
|
||||
|
||||
## 1. 页面任务
|
||||
|
||||
让用户在“账号密码登录”与“手机验证码登录”之间切换并进入家谱。A02 自己提供协议确认:直接进入默认未同意,A01 已确认协议进入时默认已同意;没有账号的新用户可从协议行下方进入既有 A04 注册路由。页面只保留本地字段校验、A04 注册跳转、A05 忘记密码跳转与待接入 toast;不新增真实账号、短信、微信或后端逻辑。
|
||||
|
||||
手机号验证码登录只由 A02 的“手机验证码登录”Tab 承接;重复的 A03 页面已于 2026-07-14 删除。绑定手机号以后属于个人中心安全设置,微信授权取消、失败与受限状态以后属于 A06。
|
||||
|
||||
## 2. 视觉结构
|
||||
|
||||
1. 使用与 A01 同高度的朱砂祠堂头和居中谱印,保证认证入口属于同一品牌场景。
|
||||
2. 头部下方放置一张完整不透明的卷轴认证面板,而不是普通 CSS 矩形卡;标题、双 Tab、输入、辅助链接和按钮文字都是可编辑的 Vue 内容层。
|
||||
3. 面板上部承载任务标题和双 Tab,中部承载两行输入与辅助操作,下部留给原比例朱砂按钮、可点击协议确认行、轻量文字注册入口与淡墨山水收束。
|
||||
4. 主按钮继续复用 A01 的 `a01-primary-button.png`,显示槽高度为 128rpx;不把约 4.49:1 的皮肤压进旧的 100rpx 矮按钮。
|
||||
5. 背景山水只出现在认证面板之外和面板底部,保证输入、标签与占位文本的对比度。
|
||||
|
||||
## 3. 资产合同
|
||||
|
||||
| 路径 | 属性/原图尺寸 | 页面显示与职责 |
|
||||
| --- | --- | --- |
|
||||
| `static/assets/foundation/opaque/auth-header.png` | opaque / 750×196 | 认证祠堂头,A01 与 A02 共用。 |
|
||||
| `static/assets/foundation/transparent/brand-seal.png` | transparent / 240×288 | 头部谱印。 |
|
||||
| `static/assets/modules/auth/opaque/a02-login-panel.png` | opaque / 940×1672 | 完整卷轴登录面板,承载纸纹、双金线、角纹、云纹与底部山水;A02 容器 666rpx × 1184rpx。 |
|
||||
| `static/assets/foundation/opaque/a01-primary-button.png` | opaque / 1877×418 | A02 唯一主操作按钮皮肤,显示槽 128rpx 高。 |
|
||||
| `static/assets/foundation/transparent/chevron-right.png` | transparent / 96×96 | 返回图标,水平翻转后使用,不再用文字符号或 CSS 圆框。 |
|
||||
| `static/assets/modules/auth/transparent/a02-agreement-unchecked.png` | transparent / 96×96 | A02 本页未同意协议图标;四角透明,不以 CSS 圆环代替。 |
|
||||
| `static/assets/modules/auth/transparent/a02-agreement-checked.png` | transparent / 96×96 | A02 本页已同意协议图标;四角透明,不以文字勾号或 CSS 底色代替。 |
|
||||
|
||||
认证面板由内置 ImageGen 根据 A01、G01 的已验收截图生成;提示词约束为“完整不透明暖宣纸、双古金线、角纹、边缘淡云竹、中央留给真实控件、无文字无按钮无图标”。生成源保留在 Codex 生成目录,项目运行时只引用上表中的最终资产。
|
||||
|
||||
## 4. 交互与可读性
|
||||
|
||||
- 两个 Tab 仍由 `activeTab` 切换;已验证手机验证码 Tab 的真实页面状态。
|
||||
- 账号、密码、手机号、验证码继续使用原生 `input`,不烘焙到图片。
|
||||
- A02 不绘制面板或主按钮的 CSS `border`/`background`;对应完整外观由图片资产承担。
|
||||
- A02 的 `agreed` 是本页登录提交的唯一协议状态;H5 从当前 `location.hash` 读取 `agreed=1`,Android 从当前 uni 页面选项读取,直接进入默认未同意。
|
||||
- 协议行点击可切换两枚透明图标;未同意提交时在本页提示“请先阅读并同意相关协议”,不再要求返回 A01。协议名称仍沿用“协议页面准备中”提示,不新增协议正文路由。
|
||||
- 协议行下方显示“还没有账号? 注册账号”;只有朱砂色“注册账号”具有 88rpx 高的文字触点,点击进入既有 `/pages/auth/a04-register`。该入口不依赖协议状态,也不在本轮重做 A04。
|
||||
- 320 × 568 下可纵向滚动,主按钮位于首屏可达区域;360 × 640、360 × 800、412 × 915 均无横向裁切。
|
||||
|
||||
## 5. 运行时证据
|
||||
|
||||
| 状态 | 截图 |
|
||||
| --- | --- |
|
||||
| 改造前,账号密码 Tab,360×800 | `screens/runtime/2026-07-13/A02-before-360x800.png` |
|
||||
| 改造后,账号密码 Tab,320×568 | `screens/runtime/2026-07-13/A02-after-320x568.png` |
|
||||
| 改造后,账号密码 Tab,360×640 | `screens/runtime/2026-07-13/A02-after-360x640.png` |
|
||||
| 改造后,账号密码 Tab,360×800 | `screens/runtime/2026-07-13/A02-after-360x800.png` |
|
||||
| 改造后,账号密码 Tab,412×915 | `screens/runtime/2026-07-13/A02-after-412x915.png` |
|
||||
| 改造后,手机验证码 Tab,360×800 | `screens/runtime/2026-07-13/A02-sms-360x800.png` |
|
||||
| 本页协议未同意,320×568 | `screens/runtime/2026-07-14/A02-agreement-unchecked-320x568.png` |
|
||||
| 本页协议未同意,360×640 | `screens/runtime/2026-07-14/A02-agreement-unchecked-360x640.png` |
|
||||
| 本页协议未同意,360×800 | `screens/runtime/2026-07-14/A02-agreement-unchecked-360x800.png` |
|
||||
| 本页协议未同意,412×915 | `screens/runtime/2026-07-14/A02-agreement-unchecked-412x915.png` |
|
||||
| A01 已确认协议进入,360×800 | `screens/runtime/2026-07-14/A02-agreement-checked-360x800.png` |
|
||||
| 含注册入口、本页协议未同意,360×800 | `screens/runtime/2026-07-14/A02-register-entry-360x800.png` |
|
||||
| 含注册入口、本页协议未同意,412×915 | `screens/runtime/2026-07-14/A02-register-entry-412x915.png` |
|
||||
| A03 删除后,手机验证码 Tab,360×800 | `screens/runtime/2026-07-14/A02-sms-tab-after-a03-removal-360x800.png` |
|
||||
|
||||
## 6. 自动检查
|
||||
|
||||
- `tests/a02-asset-alpha-audit.ps1`:A02 卷轴面板稳定;两枚协议图标均为 96×96、带 Alpha、四角透明且包含不透明图形。
|
||||
- `tests/a01-a02-ui-contract.ps1`:A02 使用完整卷轴面板与完整主按钮;协议行、双状态图标、H5/Android 路由初始化和本页提交门禁可审计;保留 Tab、原生输入、A05 跳转;拒绝 CSS 面板、主按钮和协议图标外观。
|
||||
- `tests/a02-register-entry-contract.ps1`:A02 注册入口的文案、阅读顺序、88rpx 触点、A04 跳转与目标路由均可审计。
|
||||
- `tests/a03-route-removal-contract.ps1`:A03 页面、路由和临时目录条目均不存在;G02/G04 合并后,56 条最终路由与 A02 的唯一短信入口职责同步收敛。
|
||||
- `tests/compile-audit.ps1`:Vue 结构可编译。
|
||||
- `tests/capture-chrome-page-contract.ps1`:Chrome 截图在目标 URL、新文档、页面选择器和全部图片资源就绪后再采集,防止复用状态、黑块或半渲染证据。
|
||||
|
||||
用户已确认本页视觉结果。后续认证页可参考其已验收的品牌层级与完整位图面板规则,但不得直接复制其登录表单或改变 A02 的可见效果。
|
||||
@@ -1,60 +0,0 @@
|
||||
# A04 注册账号设计记录
|
||||
|
||||
> 状态:用户已于 2026-07-14 视觉验收通过;A04 可见效果冻结。
|
||||
> 路由:`pages/auth/a04-register`;A02 的注册入口与本页“登录”入口构成新用户与已有用户的双向路径。
|
||||
> 视觉来源:已验收的 A01 启动/登录引导、A02 账号登录与 G01 我的家谱。
|
||||
|
||||
## 1. 页面任务
|
||||
|
||||
让新用户填写手机号、设置密码、确认密码并在本页主动确认协议。A02 是唯一的账号密码/手机验证码登录页;A04 只处理注册,不重复验证码登录。真实注册、短信、微信、协议正文和后端接口均不在本页实现。
|
||||
|
||||
## 2. 视觉结构
|
||||
|
||||
1. 复用 A02 的朱砂祠堂头与居中谱印,让注册仍属于同一认证场景。
|
||||
2. 使用完整不透明 `a02-login-panel.png` 承担卷轴纸纹、双金线、角纹、云纹和底部山水;A04 不绘制 CSS 卡片、边框或按钮表面。
|
||||
3. 面板标题下使用既有结饰位图建立分隔,随后是三行真实输入、完整朱砂“注册账号”按钮、协议确认行和“已有账号?登录”回流入口;不保留挤压表单的独立引导文案。
|
||||
4. 360 × 800 与 412 × 915 均完整呈现;较小屏幕保留纵向滚动,不允许横向裁切。
|
||||
|
||||
## 3. 资产合同
|
||||
|
||||
| 路径 | 属性/原图尺寸 | A04 职责 |
|
||||
| --- | --- | --- |
|
||||
| `static/assets/foundation/opaque/auth-page-paper.jpg` | opaque / 750×1334 | 认证页纸纹底色。 |
|
||||
| `static/assets/foundation/opaque/auth-header.png` | opaque / 750×196 | 朱砂祠堂头。 |
|
||||
| `static/assets/foundation/transparent/brand-seal.png` | transparent / 240×288 | 头部谱印。 |
|
||||
| `static/assets/foundation/transparent/auth-divider-knot.png` | transparent / 160×96 | 标题与表单之间的位图结饰分隔。 |
|
||||
| `static/assets/modules/auth/opaque/a02-login-panel.png` | opaque / 940×1672 | A04 完整卷轴注册面板。 |
|
||||
| `static/assets/foundation/opaque/a01-primary-button.png` | opaque / 1877×418 | A04 注册主按钮皮肤。 |
|
||||
| `static/assets/modules/auth/transparent/a02-agreement-unchecked.png` | transparent / 96×96 | 本页未同意协议图标。 |
|
||||
| `static/assets/modules/auth/transparent/a02-agreement-checked.png` | transparent / 96×96 | 本页已同意协议图标。 |
|
||||
|
||||
现有认证资产已完整覆盖本页,未使用 ImageGen,也未新增视觉资产。
|
||||
|
||||
## 4. 交互合同
|
||||
|
||||
- `phone`、`password`、`confirmPassword` 与 `agreed` 都是 A04 本页状态;协议初始未同意。
|
||||
- 点击协议行切换两枚透明图标;点击协议名称仅提示“协议页面准备中”。
|
||||
- 注册前依次校验协议、11 位手机号、密码非空和两次密码一致;通过后仅提示“注册服务待接入”。
|
||||
- “已有账号?登录”真实跳转 `/pages/auth/a02-login`;Chrome 已验证该 URL。
|
||||
- A04 已从 `data/page-catalog.js` 移出,不再使用 `ModulePage` 临时母版。
|
||||
|
||||
## 5. 运行时证据
|
||||
|
||||
| 状态 | 截图 |
|
||||
| --- | --- |
|
||||
| 改造前,通用表单壳,360×800 | `screens/runtime/2026-07-14/A04-before-360x800.png` |
|
||||
| 改造前,通用表单壳,412×915 | `screens/runtime/2026-07-14/A04-before-412x915.png` |
|
||||
| 改造后,注册初始态,360×800 | `screens/runtime/2026-07-14/A04-after-360x800.png` |
|
||||
| 改造后,注册初始态,412×915 | `screens/runtime/2026-07-14/A04-after-412x915.png` |
|
||||
| 平衡修订审视前,360×800 | `screens/runtime/2026-07-14/A04-audit-before-360x800.png` |
|
||||
| 平衡修订审视前,412×915 | `screens/runtime/2026-07-14/A04-audit-before-412x915.png` |
|
||||
| 平衡修订后,注册初始态,360×800 | `screens/runtime/2026-07-14/A04-after-balance-360x800.png` |
|
||||
| 平衡修订后,注册初始态,412×915 | `screens/runtime/2026-07-14/A04-after-balance-412x915.png` |
|
||||
|
||||
## 6. 自动检查
|
||||
|
||||
- `tests/a04-registration-contract.ps1`:独立页面、完整位图面板/按钮、结饰分隔、长标签留白、三项字段、协议状态、校验、A02 回流和禁止 CSS 伪造均可审计。
|
||||
- `tests/compile-audit.ps1`:A04 不使用失效目录条目,Vue 文件与现有本地导入可编译。
|
||||
- `tests/full-page-visual-contract.ps1`:A04 保持最终编号路由;G02/G04 合并后全项目共 56 条路由。
|
||||
|
||||
用户已于 2026-07-14 确认本页通过视觉验收;后续可审视 A05,但不得改变 A04 可见效果。
|
||||
@@ -1,57 +0,0 @@
|
||||
# A05 重置密码设计记录
|
||||
|
||||
> 状态:用户已于 2026-07-14 视觉验收通过;A05 可见效果冻结。
|
||||
> 路由:`pages/auth/a05-reset-password`;A02 的“忘记密码”入口进入本页,本页“返回登录”回到 A02。
|
||||
|
||||
## 1. 页面任务与边界
|
||||
|
||||
让用户通过已绑定手机号和验证码重设登录密码。A05 使用手机号、验证码、新密码、确认新密码四项本页状态,避免在没有两次确认的情况下覆盖密码。
|
||||
|
||||
真实短信、验证码下发、账号校验、密码重设接口和后端数据均不在本页实现。“获取验证码”仅提示“验证码功能待接入”;所有字段有效后仅提示“密码重设服务待接入”。
|
||||
|
||||
## 2. 视觉结构
|
||||
|
||||
1. 复用 A02/A04 已验收的朱砂祠堂头、谱印、宣纸、淡墨山水和完整卷轴认证面板,使重置密码仍属于同一认证场景。
|
||||
2. 标题为“重设密码”,副标题说明“验证手机号后重新设置登录密码”,不暴露 A-05 内部编号。
|
||||
3. 标题和表单之间使用既有结饰位图;正文依次为手机号、验证码(含文字型获取动作)、新密码、确认新密码、完整朱砂主按钮、完成提示与返回登录入口。
|
||||
4. 面板、按钮、页头和结饰均由既有位图提供;CSS 只处理布局、文字、输入与交互状态,不绘制 CSS 卡片或按钮表面。
|
||||
|
||||
## 3. 资产合同
|
||||
|
||||
| 路径 | 属性/原图尺寸 | A05 职责 |
|
||||
| --- | --- | --- |
|
||||
| `static/assets/foundation/opaque/auth-page-paper.jpg` | opaque / 750×1334 | 认证页纸纹底色。 |
|
||||
| `static/assets/foundation/transparent/auth-ink-scenery.png` | transparent / 750×1334 | 认证页淡墨叠层。 |
|
||||
| `static/assets/foundation/opaque/auth-header.png` | opaque / 750×196 | 朱砂祠堂头。 |
|
||||
| `static/assets/foundation/transparent/brand-seal.png` | transparent / 240×288 | 头部谱印。 |
|
||||
| `static/assets/modules/auth/opaque/a02-login-panel.png` | opaque / 940×1672 | A05 完整卷轴重设面板。 |
|
||||
| `static/assets/foundation/opaque/a01-primary-button.png` | opaque / 1877×418 | “确认重设”完整主按钮皮肤。 |
|
||||
| `static/assets/foundation/transparent/auth-title-cloud.png` | transparent / 200×120 | 标题右侧祥云。 |
|
||||
| `static/assets/foundation/transparent/auth-divider-knot.png` | transparent / 160×96 | 标题与表单之间的结饰。 |
|
||||
| `static/assets/foundation/transparent/chevron-right.png` | transparent / 96×96 | 返回动作图标。 |
|
||||
|
||||
现有认证资产已完整覆盖本页,未使用 ImageGen,也未新增视觉资产。
|
||||
|
||||
## 4. 交互合同
|
||||
|
||||
- 空提交先提示“请输入正确手机号”;手机号必须为 11 位,验证码必须为 6 位,密码非空且两次一致。
|
||||
- 点击“获取验证码”显示“验证码功能待接入”。
|
||||
- 点击“返回登录”真实跳转 `/pages/auth/a02-login`;Chrome 已验证 URL。
|
||||
- A05 已从 `data/page-catalog.js` 移出,不再使用 `ModulePage` 临时母版。
|
||||
|
||||
## 5. 运行时证据
|
||||
|
||||
| 状态 | 截图 |
|
||||
| --- | --- |
|
||||
| 改造前,通用白卡壳,360×800 | `screens/runtime/2026-07-14/A05-before-360x800.png` |
|
||||
| 改造前,通用白卡壳,412×915 | `screens/runtime/2026-07-14/A05-before-412x915.png` |
|
||||
| 改造后,重设密码初始态,360×800 | `screens/runtime/2026-07-14/A05-after-360x800.png` |
|
||||
| 改造后,重设密码初始态,412×915 | `screens/runtime/2026-07-14/A05-after-412x915.png` |
|
||||
|
||||
## 6. 自动检查
|
||||
|
||||
- `tests/a05-reset-password-contract.ps1`:独立页面、完整位图面板/按钮、四项字段、验证码动作、校验、A02 回流、目录项移除和禁止 CSS 伪造可审计。
|
||||
- `tests/compile-audit.ps1`:A05 不使用失效目录条目,Vue 文件与现有本地导入可编译。
|
||||
- `tests/full-page-visual-contract.ps1`:A05 保持最终编号路由;G02/G04 合并后全项目共 56 条路由。
|
||||
|
||||
用户已于 2026-07-14 确认本页通过视觉验收;后续可审视 A06,但不得改变 A05 可见效果。
|
||||
@@ -1,50 +0,0 @@
|
||||
# A06 登录状态设计记录
|
||||
|
||||
> 状态:用户已于 2026-07-14 视觉验收通过;A06 可见效果冻结。
|
||||
> 唯一路由:`pages/auth/a06-auth-status`;以 `?status=` 表示当前认证状态,不新建状态页面。
|
||||
|
||||
## 1. 页面任务与边界
|
||||
|
||||
A06 承接认证流程中不能继续进入家谱的结果:登录未完成、登录失败、账号暂时受限、注册尚未完成、微信授权取消和微信授权失败。
|
||||
|
||||
没有接入真实账号状态、客服、申诉、微信授权或后端接口。账号受限的“查看帮助”只显示“账号申诉功能待接入”;注册未完成真实跳转 A04;其余主操作与次操作真实跳转 A02。绑定手机号仍属于后续个人中心安全设置页面,本页不承担该功能。
|
||||
|
||||
## 2. 状态合同
|
||||
|
||||
| `status` 值 | 标题 | 主操作 | 次操作 |
|
||||
| --- | --- | --- | --- |
|
||||
| `normal`(含未知值回退) | 登录未完成 | 返回登录 → A02 | 无 |
|
||||
| `failed` | 登录失败 | 返回登录 → A02 | 无 |
|
||||
| `restricted` | 账号暂时受限 | 查看帮助 → 本地提示 | 返回登录 → A02 |
|
||||
| `register-pending` | 注册尚未完成 | 继续注册 → A04 | 返回登录 → A02 |
|
||||
| `wechat-cancelled` | 微信授权已取消 | 返回登录 → A02 | 无 |
|
||||
| `wechat-failed` | 微信授权失败 | 返回登录 → A02 | 无 |
|
||||
|
||||
H5 由 `location.hash` 的查询参数读取状态;uni-app 原生端读取当前页 `options`。两端对未知值统一回退 `normal`,避免状态壳无文案或失去下一步。
|
||||
|
||||
## 3. 视觉结构与资产
|
||||
|
||||
1. 删除通用白卡、CSS 红方徽章和压缩按钮,改为 A02/A04/A05 已验证的“朱砂祠堂头 → 卷轴面板 → 状态说明 → 朱砂操作”的认证节奏。
|
||||
2. 状态图形只使用已有透明 `auth-login-outline.png`,避免为每种异常绘制夸张警示符号;文案承担状态区别,朱砂只强调标签与主操作。
|
||||
3. 主操作保持完整 `a01-primary-button.png` 位图比例;受限与注册未完成的次操作为低权重朱砂文字,不与主按钮竞争。
|
||||
4. 页面壳、面板、按钮、边框、云纹和结饰均由既有位图提供;CSS 只处理布局、文字与点击态。现有资产已完整覆盖需求,因此未调用 ImageGen、未新增资产。
|
||||
|
||||
## 4. 运行时审视与证据
|
||||
|
||||
| 状态 | 截图 |
|
||||
| --- | --- |
|
||||
| 改造前通用壳,360×800 | `screens/runtime/2026-07-14/A06-before-360x800.png` |
|
||||
| 改造前通用壳,412×915 | `screens/runtime/2026-07-14/A06-before-412x915.png` |
|
||||
| `normal` 默认态,360×800 | `screens/runtime/2026-07-14/A06-after-360x800.png` |
|
||||
| `normal` 默认态,412×915 | `screens/runtime/2026-07-14/A06-after-412x915.png` |
|
||||
| `restricted` 受限态,360×800 | `screens/runtime/2026-07-14/A06-restricted-360x800.png` |
|
||||
|
||||
截图审视结论:两个尺寸中,朱砂头图、卷轴框、状态图形、标题/说明、完整主按钮与底部淡墨山水均未被裁切;360×800 的面板底部在首屏内结束,412×915 保留呼吸留白;`restricted` 的次操作保持在主按钮下方且不抢主路径。当前未发现黑块、图片未加载、横向溢出或 CSS 伪造完整表面。
|
||||
|
||||
## 5. 自动与交互检查
|
||||
|
||||
- `tests/a06-auth-status-contract.ps1`:约束唯一路由、独立页面、六个状态键、状态动作、A02/A04 目标、既有位图资产与禁止 CSS 伪造。
|
||||
- `tests/a06-auth-status-runtime-smoke.js`:通过 Chrome DevTools 9222 真实验证默认主操作进入 A02、受限主操作展示本地帮助提示、注册未完成主操作进入 A04。脚本在每次 hash 跳转后刷新文档,以规避 H5 Vue 实例保留上一次状态。
|
||||
- `tests/compile-audit.ps1`:确认 A06 不再依赖已移除的目录临时项。
|
||||
|
||||
用户已于 2026-07-14 确认本页通过视觉验收;后续可开始 G02,但不得改变 A01、A02、A04、A05、A06、G01 的可见效果。若用户后续指定调整 A06,仍须重新截图、运行上述检查和相关回归。
|
||||
@@ -1,69 +0,0 @@
|
||||
# D1 安卓视觉地基规范
|
||||
|
||||
> 状态:P-00 实施中。
|
||||
> 本文件是 Android 页面视觉、资产与公共组件的唯一施工规范;与历史设计记录冲突时,以本文件和用户最新确认优先。
|
||||
|
||||
## 1. 冻结视觉锚点
|
||||
|
||||
- A01 启动/登录引导:`screens/A01-启动登录引导-栅格验收-412x915.png`
|
||||
- G01 我的家谱:`screens/G01-我的家谱-紧凑版设计稿-v5-Tabbar安全区.png`
|
||||
|
||||
两页已获用户验收。P-00 可以重整文件、组件和资产路径,但在 412 × 915 下不得改变两页可见布局、色彩、纹样、按钮或背景组合。
|
||||
|
||||
## 2. 视觉语言
|
||||
|
||||
| 元素 | 规则 |
|
||||
| --- | --- |
|
||||
| 页面基调 | 暖宣纸、朱砂、古金、墨褐、淡墨山水/竹影;禁止绿色页面背景。 |
|
||||
| 文字 | 标题与重要文字用楷体语气;正文首先保证 Android 小屏可读性。 |
|
||||
| 主操作 | 朱砂完整按钮图片,含自身纹理、边框与四角;不可由 CSS 色块和零散图片拼装。 |
|
||||
| 次操作 | 纸白完整按钮图片,含自身金边、四角与纹理;不可借用主按钮角标。 |
|
||||
| 页面背景 | 使用完整不透明纸纹/页头/面板图片,必要时可叠加透明山水或竹影 PNG;不能使用截图拼接。 |
|
||||
| 装饰 | 云纹、谱印、分隔纹、图标、边框均为透明 PNG;不使用 CSS/SVG 临时绘制。 |
|
||||
|
||||
## 3. 资产分类与目录
|
||||
|
||||
所有最终资产放在 `static/assets/`,路径必须表达所属范围、背景属性和语义:
|
||||
|
||||
```text
|
||||
static/assets/
|
||||
foundation/
|
||||
opaque/ # 自带底色/纹理:纸纹、页头、完整按钮、卡片、导航
|
||||
transparent/ # 透明 PNG:Logo、图标、云纹、边框、分隔纹
|
||||
modules/
|
||||
genealogy/
|
||||
opaque/
|
||||
transparent/
|
||||
tree/
|
||||
opaque/
|
||||
transparent/
|
||||
family/
|
||||
opaque/
|
||||
transparent/
|
||||
```
|
||||
|
||||
- `opaque` 资产不能依赖页面 CSS 背景色才显示完整外观。
|
||||
- `transparent` 资产必须是 RGBA PNG;其使用位置要在资产清单中写明宿主不透明底图。
|
||||
- 每项资产都在 P00 清单中登记用途、显示尺寸、引用组件、引用页面和保留/删除状态。
|
||||
- 不再创建长期使用的 `-v1`、`-v2`、`-source`、`-copy` 文件;同一语义只保留一个已验收版本。
|
||||
|
||||
## 4. 页面与组件
|
||||
|
||||
- 页面文件:`<页面编号>-<语义名>.vue`;没有确认页面编号的临时骨架要标明 `skeleton`,不能伪装成完成页面。
|
||||
- 公共组件只收纳已被两个或以上确认页面使用的稳定结构:页面纸纹壳、顶部栏、底部导航、完整图片按钮、完整图片卡片、状态容器。
|
||||
- 页面与组件开头写中文用途注释;模板按背景、页头、主体、操作、状态分段;资产图层和复杂交互写中文约束说明。
|
||||
- CSS 只负责布局、尺寸、文字、可见/隐藏与交互状态;不能再用 CSS 绘制边角、金线、云纹、分隔结、纸纹、按钮或卡片外观。
|
||||
|
||||
## 5. Android 约束
|
||||
|
||||
- 核验尺寸:320 × 568、360 × 640、360 × 800、412 × 915。
|
||||
- 正文不小于 24rpx;主操作最小高度 88rpx;可点击区不小于约 44dp。
|
||||
- 使用 Flex、边距和基础布局;不依赖 CSS Grid、`flex gap`、复杂滤镜、大面积动画或全屏截图背景。
|
||||
- 顶部和底部预留状态栏、三键/手势导航安全区;固定底栏不得遮挡页面操作。
|
||||
|
||||
## 6. 验收和代码纪律
|
||||
|
||||
- 资产先验收,页面后验收;资产不通过不进入页面。
|
||||
- 所有页面只实现明确设计与交互;不增加推测性的 JS、接口、依赖、变量、方法和分支。
|
||||
- 每次结构迁移必须同时更新页面路径、`pages.json`、跳转、测试和文档;旧路径确认无引用后立即删除。
|
||||
- 每次完成后执行受影响审计、编译检查与 `git diff --check`;A01、G01 还需做截图回归。
|
||||
@@ -1,60 +0,0 @@
|
||||
# F/R/N/M 全量候选设计记录
|
||||
|
||||
> 日期:2026-07-14
|
||||
> 状态:F01–F10、R01–R11、N01–N02、M01–M10 共 33 个页面候选已形成,统一标记 `[~]`,等待用户逐页视觉审核。
|
||||
|
||||
## 1. Product Design 处理方式
|
||||
|
||||
- 先捕获旧通用壳的 `detail|list|form|timeline|settings|status` 六类真实运行基线,再升级共享母版。
|
||||
- 共享母版只统一视觉表面和状态机制;每条路由仍拥有独立页面编号、标题、字段、列表内容和业务任务,没有合并不同功能页面。
|
||||
- F01、F02、N01、M01 四个根页独立重做,保留家族/我的底部导航与关键跳转。
|
||||
- 接口文档只用于功能位置和字段语义;页面候选全部使用本地模拟数据,不调用 `appApi`,不修改 API 边界。
|
||||
|
||||
## 2. 位图与母版
|
||||
|
||||
- `g03-create-flow-panel.png`:表单、详情、设置、服务说明和统一状态的完整宣纸表面。
|
||||
- `g06-search-input-wide.png`:真实输入、详情段落和设置行的完整位图底层。
|
||||
- `application-status-card.png`:列表、时间轴、通知、动态与状态卡。
|
||||
- `a01-primary-button.png`/`a01-secondary-button.png`:所有主次操作。
|
||||
- CSS 不绘制卡片、按钮、边框、圆角或伪元素装饰,只负责布局、文字、控件和状态。
|
||||
|
||||
## 3. 截图证据
|
||||
|
||||
### 六类母版
|
||||
|
||||
目录:`screens/runtime/2026-07-14/module-page-audit/`
|
||||
|
||||
- 基线与候选:`00-*-before-360x800.png`、`01-f03-detail-360x800.png` 至 `10-f04-list-412x915.png`。
|
||||
- 同画布对比:`11-template-before-after-comparison.png`。
|
||||
- 共享空/成功/失败:`12-shared-states-comparison.png`。
|
||||
|
||||
### 29 个共享母版页面
|
||||
|
||||
目录:`screens/runtime/2026-07-14/all-page-candidates/`
|
||||
|
||||
- 每条路由均有独立 `*-360x800.png`。
|
||||
- 模块联系表:`00-family-contact-sheet.png`、`00-records-contact-sheet.png`、`00-profile-notification-contact-sheet.png`。
|
||||
|
||||
### F01/F02/N01/M01
|
||||
|
||||
目录:`screens/runtime/2026-07-14/root-pages-audit/`
|
||||
|
||||
- 基线:`00-f01-before-360x800.png`、`00-f02-before-360x800.png`、`00-n01-before-360x800.png`、`00-m01-before-360x800.png`。
|
||||
- 候选与状态:`01-f01-list-360x800.png` 至 `10-m01-ready-412x915.png`。
|
||||
- 同画布对比:`11-root-before-after-states.png`。
|
||||
|
||||
## 4. 审视结果
|
||||
|
||||
- 六类母版在 360 下没有卡片、按钮或文字裁切;列表较长时自然纵向滚动,完整面板保持一致的视觉终点。
|
||||
- 共享状态统一使用同一宣纸面板,但标题、动作和业务名称来自当前页面,避免“万能空态”丢失上下文。
|
||||
- F01、M01 的根页保留底部导航;F02 的发布编辑区和 N01 的消息卡均使用真实控件与本地可操作结果。
|
||||
- Product Design 对比中未发现 P1/P2 级裁切、假按钮槽或风格漂移;最终 Android 真机字体放大、读屏和触摸目标仍需后续设备复核。
|
||||
|
||||
## 5. 验证入口
|
||||
|
||||
- `tests/module-page-visual-contract.ps1`
|
||||
- `tests/module-page-runtime-smoke.js`
|
||||
- `tests/root-pages-visual-contract.ps1`
|
||||
- `tests/root-pages-runtime-smoke.js`
|
||||
- `tests/capture-chrome-page-contract.ps1`
|
||||
|
||||
@@ -1,75 +0,0 @@
|
||||
# G01 列表背景候选与换机重建
|
||||
|
||||
> 日期:2026-07-16
|
||||
> 状态:A/B/C 历史母版与旗舰长屏母版均已保存并通过本机确定性构建;用户已确认旗舰长屏方向作为 9 个活动 G 页面的公共背景,并确认共享图片画层使用 28% 不透明度。当前仅有 H5 运行证据,不得写成任一页面整页已验收或 Android 已通过。
|
||||
|
||||
## 1. 为什么重做
|
||||
|
||||
现有 `footer-mountain-bamboo.png` 为 `750×360`,视觉主体集中在底部,上半部留白较大;页面又把它定位到整个内容末尾并使用 `scaleToFill` 拉伸,因此列表变长时山水被推到“添加家谱”下方。用户提出背景应更早出现,并进一步提出 G01 可能只滚动下方列表、上方当前家谱与快捷入口固定。
|
||||
|
||||
连续长背景只替换各 G 页面最底层视觉。G01 固定区/独立滚动边界已在后续同日按用户确认方案单独实施,不影响其他 G 页面;具体合同见 `docs/superpowers/specs/2026-07-16-g01-fixed-header-independent-list-scroll-design.md`。
|
||||
|
||||
## 2. 稳定输入
|
||||
|
||||
稳定输入位于:
|
||||
|
||||
- `docs/design/assets/g01-background/masters/g01-list-background-direction-a-chroma-master.png`
|
||||
- `docs/design/assets/g01-background/masters/g01-list-background-direction-b-chroma-master.png`
|
||||
- `docs/design/assets/g01-background/masters/g01-list-background-direction-c-paper-master.png`
|
||||
- `docs/design/assets/g01-background/masters/genealogy-page-background-long-flagship-imagegen-source.png`(原始 ImageGen 输出,793×1983)
|
||||
- `docs/design/assets/g01-background/masters/genealogy-page-background-long-flagship-master.png`(确定性归一化母版,1536×3840)
|
||||
|
||||
准确提示词、标题、处理模式、源尺寸、目标尺寸、选择状态和运行输出路径由 `design-pipeline/manifests/g01-background-candidates.json` 单一维护。A/B 使用纯 `#00ff00` 绿幕并提取透明叠加层;C 与旗舰长屏图保留完整暖宣纸底。当前公共运行输出是 `static/assets/modules/genealogy/opaque/genealogy-page-background-long.png`,由 `components/GenealogyPageBackground.vue` 唯一持有路径、贴底规则和 `opacity: 0.28` 显示规则;各 G 页面不得重复覆盖。旧 `genealogy-page-background.png` 不覆盖、不删除,仅作为上一轮 C 输出保留。
|
||||
|
||||
## 3. 跨电脑重建
|
||||
|
||||
要求:Windows、Node.js 22+、npm、Python 3.12+。本机验证环境为 Node `24.15.0`、npm `11.12.1`、Python `3.14.6`、Pillow `12.3.0`。
|
||||
|
||||
在项目根目录执行:
|
||||
|
||||
```powershell
|
||||
python --version
|
||||
node --version
|
||||
npm.cmd --version
|
||||
python -m venv design-pipeline/.venv
|
||||
design-pipeline/.venv/Scripts/python.exe -m pip install --upgrade pip
|
||||
design-pipeline/.venv/Scripts/python.exe -m pip install -r design-pipeline/requirements.txt
|
||||
npm.cmd --prefix design-pipeline run build:g01-background-candidates
|
||||
design-pipeline/.venv/Scripts/python.exe -m unittest design-pipeline/tests/test_build_g01_backgrounds.py -v
|
||||
node --test design-pipeline/tests/g01-background-candidates.test.mjs
|
||||
```
|
||||
|
||||
构建输出进入已忽略目录 `design-pipeline/generated/g01-background/`:
|
||||
|
||||
- `g01-list-background-direction-a.png`
|
||||
- `g01-list-background-direction-b.png`
|
||||
- `g01-list-background-direction-c.png`
|
||||
- `genealogy-page-background-long-flagship.png`
|
||||
- `build-report.json`
|
||||
|
||||
Node 入口只使用内置模块做路径与清单编排;图片解码、尺寸检查和像素处理由锁定版本的 Pillow 统一负责,因此 G01 候选构建不依赖已删除的 `node_modules`。项目其他使用 Sharp 的旧流水线仍需执行 `npm.cmd ci --prefix design-pipeline`。
|
||||
|
||||
## 4. 当前质量基线
|
||||
|
||||
本机输出:
|
||||
|
||||
| 候选 | 模式 | 像素 | 字节 | SHA-256 |
|
||||
| --- | --- | ---: | ---: | --- |
|
||||
| A | chroma-key | 1024×1536 | 2,280,297 | `7b8a8abecc651b1bc4c589607780c8ff00ea0f13de018ca4b27a479b2a870fe4` |
|
||||
| B | chroma-key | 1024×1536 | 1,890,556 | `3a0420eaaac9a2821da0f9a2ddbbbd6fb29b463fa6f41be7fa5c23948f782e2a` |
|
||||
| C | opaque-paper | 1024×1536 | 2,407,477 | `f14aeed81046d2d5e7ae78b1e62f572689c38261a2cc043142bef9f70200dc5b` |
|
||||
| 旗舰长屏 | opaque-paper-resize | 1440×3600 | 6,538,134 | `9aa9ebdd231eddbc151e238ba340a00f8603f23143117ad8c93009947dcca2e2` |
|
||||
|
||||
四张输出均通过清单尺寸、处理模式和 sRGB 构建检查;A/B/C 继续保持原质量基线。旗舰长屏原始 ImageGen 输出为 2,333,286 字节、SHA-256 `3d5ada7a23edd8db98b3511d308d2d240d80fdd4ff9280a8c434b50fb68cac3a`;1536×3840 归一化母版为 6,822,092 字节、SHA-256 `2fb3aa9be7772beb6f620c49f096ed6d9c045a56cfe7ae342664ee05ca58862b`。若另一台电脑因 Python、Pillow 或压缩实现差异产生不同文件哈希,应先核对锁定依赖,再比较解码后的 RGBA 像素和质量报告,不能只凭压缩字节差异判定视觉失败。
|
||||
|
||||
## 5. 可复现边界
|
||||
|
||||
- 精确可复现:从仓库内保存的母版执行清单、Python 和 Node 流程,得到相同 RGBA 像素处理结果。
|
||||
- 方向可复现但非逐像素:在另一台电脑上使用清单中的提示词重新调用图像模型。
|
||||
- 不可依赖:Codex 本机生成目录、`tmp/`、`node_modules/`、`.venv/`、`generated/` 或未进入仓库的聊天图片。
|
||||
|
||||
## 6. 下一步
|
||||
|
||||
1. 用户查看 G01 固定信息区/独立列表滚动的实际 H5 页面;当前五档 runtime 截图仍是内部候选证据。
|
||||
2. 用户确认 G01 整页后,再保存一张长期代表证据并更新视觉证据索引。
|
||||
3. 完成 Android/HBuilderX 真机或模拟器复核,并专项检查区域滚动手感及 4GB Android 上约 19.8MiB 解码内存、切页与回收。
|
||||
@@ -1,134 +0,0 @@
|
||||
# G-01:我的家谱设计记录
|
||||
|
||||
> 状态:设计验收完成(2026-07-13,用户确认)
|
||||
> 美术锚点:[G01 我的家谱美术锚点](references/G01-我的家谱-美术锚点.png)
|
||||
> 布局锚点:[D1 安卓视觉规范与页面壳](D1_安卓视觉规范与页面壳.md#62-g-01-固定设计栅格360--800dp)
|
||||
|
||||
## 页面目标
|
||||
|
||||
让已登录用户一眼查看当前家谱、进入四个高频入口、切换已创建 / 已加入家谱。无家谱的创建或搜索加入状态归属 `G-02`。
|
||||
|
||||
## 背景组合
|
||||
|
||||
- 红头:`static/assets/backgrounds/header-hall-lineart.png` 叠加朱砂底色。
|
||||
- 内容纸面:`static/assets/backgrounds/paper-rice-texture.jpg`。
|
||||
- 页面底部:`static/assets/backgrounds/footer-mountain-bamboo.png`,只用于列表尾部与新建按钮附近。
|
||||
|
||||
## 图标资产清单
|
||||
|
||||
所有项目完成后均为独立、紧凑的 RGBA 透明 PNG;不能从大图裁切后直接使用。
|
||||
|
||||
| 区域 | 语义 | 目标文件 | 风格 | 状态 |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| 顶部左侧 | 项目 Logo | `brand/jiapu-seal-logo.png` | 用户确认的家祠 Logo 原图 | 已确认使用;待页面小尺寸摆放验收 |
|
||||
| 顶部右侧 | 通知 | `common/notice-v3.png` | 古金双线 + 轻云纹 | 已生成;`70 × 94px` 主体通过紧凑尺寸检查 |
|
||||
| 列表入口 | 右箭头 | `common/chevron-right-v2.png` | 古金简洁单线 | 已生成;`96 × 96px`、RGBA 四角透明、48px 检查通过 |
|
||||
| 快捷入口 | 世系图 | `genealogy/tree-v2.png` | 古金双线简化版 | 已生成;`96 × 96px`、RGBA 四角透明、48px 检查通过 |
|
||||
| 快捷入口 | 家族成员 | `genealogy/members-v2.png` | 古金双线简化版 | 已生成;`96 × 96px`、RGBA 四角透明、48px 检查通过 |
|
||||
| 快捷入口 | 字辈诗 | `genealogy/generation-poem-v2.png` | 古金双线简化版 | 已生成;`96 × 96px`、RGBA 四角透明、48px 检查通过 |
|
||||
| 快捷入口 | 申请审核 | `genealogy/application-v2.png` | 古金双线简化版 | 已生成;`96 × 96px`、RGBA 四角透明、48px 检查通过 |
|
||||
| 主操作 | 新建家谱 | `action/add-v2.png` | 古金双线 + 轻云纹 | 已生成;`96 × 96px`、RGBA 四角透明、48px 检查通过 |
|
||||
| 底部 Tab | 家谱 | `tab/genealogy-v4.png`、`genealogy-active-v4.png` | 简洁谱册 / 世系语义 | 已生成;同轮廓墨褐 / 朱砂成对导出,`96 × 96px`、48px 检查通过 |
|
||||
| 底部 Tab | 家族 | `tab/family-v4.png`、`family-active-v4.png` | 简洁双人群组语义 | 已生成;同轮廓墨褐 / 朱砂成对导出,`96 × 96px`、48px 检查通过 |
|
||||
| 底部 Tab | 我的 | `tab/profile-v4.png`、`profile-active-v4.png` | 简洁人物档案语义 | 已生成;同轮廓墨褐 / 朱砂成对导出,`96 × 96px`、48px 检查通过 |
|
||||
|
||||
## 页面完成条件
|
||||
|
||||
- [ ] 图标资产清单全部生成并在 `46rpx` / `24dp` 下逐个检查。
|
||||
- [ ] 背景组合在 `320 × 568` 下不遮挡标题、按钮和列表。
|
||||
- [ ] 按固定栅格完成正常态、无家谱态与加载态设计。
|
||||
- [ ] 不存在横线占位、重复家谱层级或无业务含义的颜色差异。
|
||||
|
||||
## Tabbar 重设计规则
|
||||
|
||||
- 当前 `static/assets/icons/tab/` 和 `static/icons/tab-*.png` 的图标不通过验收,不得使用。
|
||||
- 三个 Tab 重新采用等视觉重量的简洁符号:家谱为“世系关系 / 谱册”语义,家族为“双人群组”语义,我的为“人物档案”语义。
|
||||
- 三个图标同为 `24dp` 标准视口、无文字嵌入、无枝叶和云纹等细碎装饰;默认态为墨褐 / 古金描边,选中态为朱砂色的同一图形加粗或填充。
|
||||
- Logo 是品牌标识,不能拿来代替家谱 Tab 图标;Tab 图标只服务于快速识别目的地。
|
||||
|
||||
## 2026-07-11 当日确认记录
|
||||
|
||||
- 当前交接视觉稿:[G01-我的家谱-紧凑版设计稿-v5-Tabbar安全区.png](screens/G01-我的家谱-紧凑版设计稿-v5-Tabbar安全区.png)。它是明日施工时的美术与布局锚点,不替代真实页面和资产验收。
|
||||
- 顶部品牌使用项目原有 `static/assets/icons/brand/jiapu-seal-logo.png`;缩小后固定在左上安全区,避免与居中标题及殿宇线稿争夺视觉焦点。
|
||||
- 底部 Tabbar 仍然贴合屏幕底部;三个图标和文字整体上移至 Android 手势/三键导航安全区内,标签“家谱 / 家族 / 我的”加大,任何屏幕尺寸下不得被底边裁切。
|
||||
- 上述确认只锁定 G-01 的方向和版式。真实透明 PNG 图标、背景组合、320 × 568 小屏校验、空状态与加载状态尚未完成,因此 **G-01 仍不勾选为设计完成**。
|
||||
- 明日从 G-01 的可施工项开始:先按 D1 规范落实真实图标与背景资产、再还原页面布局;全量页面设计完成前,不恢复接口联调或 Android 打包工作。
|
||||
|
||||
## 2026-07-12 施工记录
|
||||
|
||||
- 已完成 G-01 独立图标资产:四个快捷入口、主操作、列表右箭头,以及三组 Tab 默认 / 选中态;全部为 `96 × 96px` RGBA PNG,四角透明,并按实际 `48px` 显示尺寸检查。
|
||||
- 已完成 G-01 首轮页面还原:朱砂祠堂红头、左上原 Logo、条件式消息圆点、宣纸与山水背景、当前家谱题签、快捷入口、已创建 / 已加入列表、空状态、加载状态和 Android 安全区 Tabbar。
|
||||
- 本页暂时使用 `data/mock.js` 的展示数据;已移除 G-01 的远程接口调用,接口联调仍按总规划暂停。
|
||||
- 已通过:`g01-visual-contract.ps1`、`compile-audit.ps1`、`uni-scss-injection.ps1`、`vue3-entry.ps1`。
|
||||
- 待完成:在已打开的 HBuilderX 中按 `360 × 800`、`320 × 568` 预览并记录无重叠、无底部裁切的证据。完成前不勾选本页“设计完成”条件。
|
||||
|
||||
## 2026-07-12 视觉校正记录
|
||||
|
||||
- 用户预览反馈首轮还原与确认稿差异过大。已定位为:祠堂线稿直接铺在红头导致视觉过满,且题签 / 列表使用普通圆角边框而不是确认稿的纸签资产。
|
||||
- 红头已改为纯朱砂底 + 独立 `header-hall-lineart.png` 低对比背景层(不透明度 `0.22`);Logo 放大并上移,标题与通知保持独立层级。
|
||||
- 已新增并接入 `backgrounds/genealogy-current-slip-frame.png` 与 `backgrounds/genealogy-list-slip-frame.png`;两者为透明 PNG 双金线纸签框,透明角检查通过。
|
||||
- 此次只修正可见还原偏差,不改变 G-01 的数据、路由和状态范围;仍待用户重新运行后的两组小屏预览确认。
|
||||
|
||||
## 2026-07-12 尺寸校正记录
|
||||
|
||||
- 原始品牌 Logo 的透明留白导致顶部显示偏小,已从同一原图无损裁切为 `brand/jiapu-seal-logo-header.png` 后接入;不改变 Logo 的图形、配色或品牌含义。
|
||||
- 快捷入口图标由 `52rpx` 调整为 `68rpx`;Tab 图标由 `48rpx` 调整为 `56rpx`,标签由 `24rpx` 调整为 `28rpx`,使实际显示尺寸靠近确认稿。
|
||||
- 校正后已重新通过 G-01 资产/页面契约、编译审计和 SCSS 注入审计;仍需 HBuilderX 截图作最终视觉依据。
|
||||
|
||||
## 2026-07-12 第三轮背景与栅格校正
|
||||
|
||||
- 红头高度由 `216rpx` 收至 `184rpx`,改用 `backgrounds/header-cinnabar-texture-v2.jpg`;祠堂线稿仅作为 `0.16` 不透明度的独立底纹层。
|
||||
- 内容左右边距由 `32rpx` 调整为 `48rpx`,当前家谱题签高度收至 `250rpx`,并在快捷入口和列表之间恢复 `backgrounds/genealogy-section-divider-v2.png` 金线云纹分隔。
|
||||
- 页面底色改用浅宣纸 `backgrounds/paper-rice-texture-v2.jpg`;山水竹影改用透明 `backgrounds/footer-mountain-bamboo-v2.png` 作为整页底部淡层,不再出现矩形背景块;已检查不含绿色像素。
|
||||
- 标题、分区标题与家谱名称增加宋/楷书本机回退链;题签框统一压为古金 `#B58A4B`。
|
||||
- 已通过:G-01 页面/资产契约、编译审计、SCSS 注入审计及页脚透明区域检查。仍待新截图比对,页面不标记为完成。
|
||||
|
||||
## 2026-07-12 第四轮底图与题签边角校正
|
||||
|
||||
- 根因核验:`genealogy-current-slip-frame.png` 与 `genealogy-list-slip-frame.png` 的四个角均为 Alpha 0;白色直角边并非 PNG 资产本身,而是题签容器在透明镂空角下铺了接近白色的矩形底色。
|
||||
- 已移除当前家谱题签和两条列表题签的矩形底色,让透明镂空角直接透出宣纸底纹;文字、边框和点击区域不变。
|
||||
- 已撤回错误的 `footer-mountain-bamboo-v2.png`(该图是连续实心灰山,无法还原设计稿的淡墨层次),恢复使用带亭、远山和竹影的 `footer-mountain-bamboo.png`,以 `0.48` 透明度贴在 Tabbar 上沿,不形成独立灰色矩形带。
|
||||
- `tests/g01-visual-contract.ps1` 先新增回归断言并确认旧实现失败,再完成最小改动后通过;仍需在 HBuilderX 的同一设备尺寸下由新截图确认最终观感,G-01 不标记为完成。
|
||||
|
||||
## 2026-07-12 第五轮信息层级与谱印施工
|
||||
|
||||
- 施工依据固定为 `screens/G01-我的家谱-紧凑版设计稿-v5-Tabbar安全区.png`,对应用户箭头指出的谱印、元信息、快捷入口、两条列表和新建按钮;未改接口、路由、页头、背景或 Tabbar。
|
||||
- 新增并接入透明 PNG:`common/location-v1.png`、`common/member-meta-v1.png`、`common/admin-v1.png`、`genealogy/seal-current-frame-v1.png`、`genealogy/seal-row-frame-v1.png`、`action/create-cloud-v1.png`。谱印框只承载朱砂和金白边框,谱印固定显示“家谱”,家谱名称和数值仍为动态文本。
|
||||
- 当前家谱改为地点、成员数、管理员三组图标化元信息;快捷入口显示框提升至 `82rpx`;新建按钮加入两侧云纹 PNG。
|
||||
- 家谱列表改为谱印框、标题、地点/成员元信息和“更新于”日期的两层结构;`320px` 小屏隐藏更新时间,优先保证标题、元信息、角色和箭头不重叠。
|
||||
- 已新增资产透明角/有效图形审计。验证通过:`g01-visual-contract.ps1`、`g01-asset-alpha-audit.ps1`、`compile-audit.ps1`、`uni-scss-injection.ps1`。待 HBuilderX 新截图与参考稿并排确认后,G-01 才能标记设计完成。
|
||||
|
||||
## 2026-07-12 第六轮参考稿字段与右侧基线校正
|
||||
|
||||
- 预览字段已切换为参考稿:当前家谱“汤氏家谱 / 河南·洛阳 / 158 位成员 / 管理员”,已加入家谱“汤氏宗谱 / 山东·济宁 / 286 位成员 / 成员”;不再使用四川、达州、刘氏等旧演示字段。
|
||||
- 当前题签恢复标题与元信息之间的细金线;短字段确保 430px 验收宽度内三组元信息同一行显示。
|
||||
- 列表谱印固定为竖排“家谱”;右侧角色与箭头改为同一水平基线;“更新于”保留为文本,移除参考稿中不存在的日历图标和对应闲置资产。
|
||||
- 新增字段/基线契约先失败后通过。完整验证再次通过:G-01 视觉契约、PNG 透明角审计、编译审计、SCSS 注入审计、Vue 3 入口审计。仍待用户提供 HBuilderX 新截图完成视觉验收。
|
||||
|
||||
## 2026-07-13 第七轮结构、屋檐与祥云校正
|
||||
|
||||
- 当前家谱题签由“谱印 + 右侧文案列”改为两层结构:上层为谱印和名称,下层为金线分隔后的地点、成员数、管理员三组元信息;三组元信息不再受谱印列宽挤压。
|
||||
- 家谱列表由独立的左侧内容列与右侧操作列改为单一主内容区:第一行是标题、角色与箭头,第二行是地点、成员数与“更新于”日期;`320px` 宽度仅隐藏日期,保留其余信息。
|
||||
- 根页头收窄至 `164rpx`;放大并提高现有祠堂屋檐线稿的可见度,保持其位于 Logo 和标题之后。Tabbar 容器收窄,图标和标签同步提升视觉重量。
|
||||
- 内容左右边距收至 `32rpx`,快捷入口到列表的无效留白减少;山水竹影扩展至两条列表和新建按钮后方,仍低于所有文字和操作层。
|
||||
- `action/create-cloud-v1.png` 已由独立透明 PNG `action/create-cloud-v2.png` 替换;v2 使用项目生成的古金线描祥云,四角透明且有效图形审计通过,旧资产已移除。
|
||||
- 已通过:更新后的 G-01 视觉契约、PNG 透明审计和编译审计。仍待 HBuilderX 的 `360 × 800`、`320 × 568` 截图与 v5 参考稿并排确认,G-01 不标记为设计完成。
|
||||
|
||||
## 2026-07-13 第八轮预览字号校正
|
||||
|
||||
- 根据 HBuilderX 预览,列表第二行的地点、成员数与更新时间分别提升至可读字号,元信息图标同步放大;`320px` 继续只隐藏更新时间。
|
||||
- 新建家谱按钮保留单行文字:缩小云纹和加号的占位宽度,并为按钮文字设置不换行约束,避免“新建家谱”折为两行。
|
||||
- 已通过更新后的 G-01 视觉契约与编译审计;仍待下一张 HBuilderX 截图复核实际观感。
|
||||
|
||||
## 2026-07-13 第九轮顶部状态栏兼容
|
||||
|
||||
- 用户确认根页 `124rpx` 的可视朱砂导航高度符合视觉稿;不再扩大其内容区。
|
||||
- 根页头总高度改为可视导航高度加 `var(--status-bar-height, 0px)`,并以相同变量作为顶部内边距;状态栏不会压缩 Logo、标题和通知入口。
|
||||
- 宣纸纸纹起点使用同一计算高度,避免状态栏存在时红头结束处出现错层。
|
||||
- Android App 配置朱砂状态栏背景和浅色系统图标;仍需在有刘海、无刘海与三键导航 Android 真机复核。
|
||||
|
||||
## 2026-07-13 最终视觉验收
|
||||
|
||||
- 用户已提供 `360 × 800` 正常页、`320 × 568` 顶部与滚动到底部的响应式预览截图:顶部、题签、四个快捷入口、两条列表、新建按钮、山水与 Tabbar 均无重叠;`320 × 568` 下新建按钮可完整滚动至 Tabbar 上方。
|
||||
- 用户已提供 HBuilderX Web 浏览器中 `Pixel 2 XL` 的常规屏预览:页面内容、山水和 Tabbar 均无裁切。
|
||||
- 用户于 2026-07-13 明确确认“没事你标记吧可以了”,据此将 G-01 标记为设计验收完成。该确认不等同于 Android 原生包、真机刘海/三键导航或接口联调验收,以上工作仍按总规划暂停。
|
||||
@@ -1,79 +0,0 @@
|
||||
# G05 家谱总览设计记录
|
||||
|
||||
> 日期:2026-07-14
|
||||
> 状态:视觉候选已实现并完成本轮 Product Design 截图审计;未获用户审美确认,等待 G 模块完成后集中查看。
|
||||
|
||||
## 1. 技能与证据
|
||||
|
||||
- 已调用 `Product Design:index`,将任务路由为现有产品的 `audit`、`get-context` 与实现后设计检查。
|
||||
- 已调用 `Product Design:audit`,以本轮新截的真实 H5 画面为证据,不用旧截图代替。
|
||||
- 已调用 ImageGen 生成完整不透明位图表面;没有用 CSS 伪造卡片、边框或装饰。
|
||||
- 改前证据:`screens/runtime/2026-07-14/g-module-audit/before/01-g05-before-360x800.png`。
|
||||
- 改后证据:`screens/runtime/2026-07-14/g-module-audit/g05/01-g05-ready-360x800.png`、`02-g05-empty-360x800.png`、`03-g05-error-360x800.png`、`04-g05-ready-412x915.png`。
|
||||
- 同画布对照:`screens/runtime/2026-07-14/g-module-audit/g05/05-g05-before-after-comparison.png`,左侧改前、右侧改后;所有判断均基于这张合并输入,不把两个分离预览冒充并排比较。
|
||||
|
||||
## 2. 产品与思维导图参考
|
||||
|
||||
外部只读参考位于 `C:\Users\Rain\Desktop\软件\JOB\app设计`。已查看 `思维导图.png`、`家谱主页.png`、`加入家谱.png` 与 `字辈谱.png`。
|
||||
|
||||
采用的只有信息结构:谱名、堂号、地点和成员概况置顶;世系、录入、字辈、审核作为核心入口;家族内容作为下一层。没有采用外部灰绿色卡片、现代图标、邀请码加入或底部双按钮视觉。
|
||||
|
||||
外部“邀请码加入”与当前产品不一致。当前唯一加入流程是 G06 公开检索 → G08 提交申请 → G09 查看进度 → G10 管理员审核,因此不把邀请码或邀请按钮加入 G05。
|
||||
|
||||
## 3. 路由与状态所有权
|
||||
|
||||
- 唯一路由:`pages/genealogy/g05-genealogy-overview`。
|
||||
- 唯一上下文查询键:`genealogyId`;G01 的旧 `id` 链接已同步迁移,不保留兼容读取。
|
||||
- 同页状态:`loading`、`ready`、`empty`、`error`,不为加载、空数据、无权或失败另建页面。
|
||||
- “录入族人”进入 G03 `step=ancestor&genealogyId=...`;已删除的 G04 不再被引用。
|
||||
- G05 不再把 G08 申请加入表单误写成管理员的“邀请亲人”。
|
||||
|
||||
## 4. OpenAPI 设计边界
|
||||
|
||||
| 页面内容/动作 | OpenAPI 依据 | 页面阶段处理 |
|
||||
| --- | --- | --- |
|
||||
| 谱名、堂号、地区、成员数、可见范围 | `GET /genealogy/app/genealogies/{genealogyId}/overview` | 正常态展示真实接口所需字段;本轮继续使用现有 mock |
|
||||
| 管理设置 | `GET/PUT /genealogy/app/genealogies/{genealogyId}` | 只进入 G11,不在 G05 复制设置表单 |
|
||||
| 世系树 | `GET /genealogy/app/genealogies/{genealogyId}/lineage/tree` | 进入 T01,不在总览加载完整树 |
|
||||
| 录入首代 | `POST /genealogy/app/genealogies/{genealogyId}/lineage/persons` | 进入 G03 的始祖步骤 |
|
||||
| 字辈谱 | `GET/POST /genealogy/app/genealogies/{genealogyId}/generation-poems` | 进入 G12 |
|
||||
| 入谱审核 | `GET .../join-applies/pending`、`PUT .../{applyId}/audit` | 进入 G10 |
|
||||
|
||||
接口响应仍使用通用 `JsonObject/ListResult`,因此页面不臆造邀请码、分享码、视频或多级管理员字段。
|
||||
|
||||
## 5. 位图资产
|
||||
|
||||
- 最终资产:`static/assets/modules/genealogy/opaque/g05-overview-surface.png`。
|
||||
- 尺寸:1122×1506;`Format24bppRgb`,四角 alpha 均为 255。
|
||||
- 内容:墨蓝谱名区、四个完整宣纸入口槽、古金分隔、底部访问说明面;文字和点击层不烘焙进图片。
|
||||
- ImageGen 使用 `ui-mockup` 模式,参考 G05 改前 360×800 截图,明确要求无文字、无图标、无占位符、无现代玻璃拟态。
|
||||
|
||||
## 6. Product Design 审计
|
||||
|
||||
### 改前主要风险
|
||||
|
||||
1. G05 的“录入族人”指向已删除 G04,核心动作实际不可达。
|
||||
2. “邀请亲人”跳到申请者填写的 G08,角色和任务语义错误;OpenAPI 也没有邀请端点。
|
||||
3. 深色头、四宫格卡片和底部邀请面均由 CSS 拼接,不符合完整视觉表面必须使用位图的项目规则。
|
||||
4. 只有模糊载入文案,没有区分空数据、无权限和加载失败。
|
||||
|
||||
### 改后步骤与健康度
|
||||
|
||||
1. `ready`:健康。谱名和成员概况在墨蓝区,四个核心任务在首屏可见,家族近况和访问说明层级清楚。
|
||||
2. `empty`:健康。仍在 G05 路由内说明缺少上下文,并返回“我的家谱”。
|
||||
3. `error`:健康。说明网络、数据或权限三类可能原因,提供同页重试。
|
||||
4. 412×915:健康。位图、文字和点击槽同步放大,没有横向溢出或裁切。
|
||||
|
||||
### 可访问性边界
|
||||
|
||||
- 本轮从截图确认了文字未裁切、主要入口具有完整卡片面积、按钮文案可见。
|
||||
- 审计后已提高谱名区元信息、入口说明、近况和底部说明字号,降低 360 宽屏小字风险。
|
||||
- 截图不能证明读屏语义、键盘焦点和 Android 字体放大行为;这些留到真机与可访问性专项验证,不能宣称完整 WCAG 合规。
|
||||
|
||||
## 7. 验证
|
||||
|
||||
- `tests/g05-overview-contract.ps1`
|
||||
- `tests/g05-overview-runtime-smoke.js`
|
||||
- `tests/g01-visual-contract.ps1`
|
||||
|
||||
以上在实现后均已通过;最终集中审美确认前不将 G05 标记为视觉冻结。
|
||||
@@ -1,81 +0,0 @@
|
||||
# G08–G10 入谱申请链路设计记录
|
||||
|
||||
> 日期:2026-07-14
|
||||
> 状态:页面候选、本地模拟交互与本轮 Product Design 截图审计已完成;标记为 `[~]`,等待后续逐页视觉审核。
|
||||
|
||||
## 1. Product Design 工作流
|
||||
|
||||
- `Product Design:index`:将工作路由为现有产品的上下文确认、截图审计和实现后设计检查。
|
||||
- `Product Design:audit`:本轮重新捕获 G08–G10 的表单、成功、列表、空态和 412 宽屏,不使用旧通用壳图冒充最终证据。
|
||||
- ImageGen:生成 `application-record-card.png`,并在截图审计发现假按钮后精确编辑出 `application-status-card.png`。
|
||||
- TDD:`tests/g08-g10-application-flow-contract.ps1` 与运行冒烟先因通用壳和缺少同页状态红灯,再由最小实现转绿。
|
||||
|
||||
## 2. 思维导图与外部参考
|
||||
|
||||
已查看 `C:\Users\Rain\Desktop\软件\JOB\app设计\思维导图.png` 与 `加入家谱.png`。外部参考的“加入家谱”依赖邀请码,但当前 OpenAPI 没有邀请码端点,因此不采用该流程。
|
||||
|
||||
当前申请链路唯一结构是:G06 公开家谱检索 → G08 填写真实身份与关系 → G09 查看自己的全部申请 → G10 管理员审核。三个阶段是不同用户任务,保留三条路由;各自的加载、空、失败、提交成功和审核结果只作为原路由状态,不再拆页。
|
||||
|
||||
## 3. 页面与状态
|
||||
|
||||
| 页面 | 独立任务 | 同页状态 |
|
||||
| --- | --- | --- |
|
||||
| G08 | 对某一 `genealogyId` 提交加入申请 | `form`、提交中、`success`、`error` |
|
||||
| G09 | 当前用户查看全部申请 | `loading`、`list`、`empty`、`error`;`PENDING/APPROVED/REJECTED` 为卡内状态 |
|
||||
| G10 | 当前家谱管理员审核待处理申请 | `loading`、`list`、`empty`、`error`;审核确认与结果留在卡内 |
|
||||
|
||||
G07/G08/G09 的旧 `ModulePage` 目录条目已经删除;G07 结果态继续只归 G06。
|
||||
|
||||
## 4. OpenAPI 功能参考
|
||||
|
||||
| 页面动作 | OpenAPI |
|
||||
| --- | --- |
|
||||
| G08 提交 | `POST /genealogy/app/genealogies/{genealogyId}/join-applies` |
|
||||
| G09 我的申请 | `GET /genealogy/app/genealogies/join-applies/mine` |
|
||||
| G09 撤销边界 | `DELETE /genealogy/app/genealogies/join-applies/{applyId}`;本轮页面不展示未接入的撤销按钮 |
|
||||
| G10 待审核列表 | `GET /genealogy/app/genealogies/{genealogyId}/join-applies/pending` |
|
||||
| G10 审核 | `PUT /genealogy/app/genealogies/{genealogyId}/join-applies/{applyId}/audit` |
|
||||
|
||||
OpenAPI 的申请体仍是 `JsonObject`,页面只使用已有业务能解释的 `realName`、`relation`、`message`,没有臆造邀请码、推荐人层级或多管理员审批字段。本阶段只据此安排字段、状态和动作位置;三页均使用页面内模拟数据与交互,不调用 `appApi`,不进行接口对接。
|
||||
|
||||
## 5. 视觉资产
|
||||
|
||||
- G08 复用 `g03-create-flow-panel.png` 的完整宣纸表单面,以及 `g06-search-input-wide.png` 的完整输入框皮肤;没有复制近似资产。
|
||||
- `application-record-card.png`:1050×360、24 位 RGB、不透明;右下留双审核槽,只供 G10 列表。
|
||||
- `application-status-card.png`:1050×360、24 位 RGB、不透明;移除双审核槽,供 G09 列表和 G09/G10 空/失败状态。
|
||||
- 主次操作分别使用 `a01-primary-button.png` 与 `a01-secondary-button.png`,CSS 只负责位置、文字和点击层。
|
||||
|
||||
## 6. 截图证据
|
||||
|
||||
- G08:`screens/runtime/2026-07-14/g-module-audit/applications/01-g08-form-360x800.png`、`02-g08-success-360x800.png`、`03-g08-form-412x915.png`。
|
||||
- G09:`04-g09-list-360x800.png`、`05-g09-empty-360x800.png`。
|
||||
- G10:`06-g10-list-360x800.png`、`07-g10-empty-360x800.png`、`08-g10-list-412x915.png`。
|
||||
- 同画布对照:`09-g08-form-success-comparison.png`、`10-g09-list-empty-comparison.png`、`11-g10-list-empty-comparison.png`。
|
||||
|
||||
## 7. Product Design 审计结果
|
||||
|
||||
### 强项
|
||||
|
||||
1. G08 表单在 360/412 下均完整显示,姓名、关系、说明和提交按钮顺序清楚;提交后仍在 G08 显示成功结果。
|
||||
2. G09 的申请状态以审核中、已通过、未通过映射接口枚举;通过项才可进入家谱。
|
||||
3. G10 的拒绝/通过使用主次完整位图按钮,动作前有确认弹窗,避免误审。
|
||||
4. 三页的空态均留在原路由,没有新增状态页。
|
||||
|
||||
### 已修复问题
|
||||
|
||||
- P2:第一张共享卡为审核按钮预留了两个槽,放到 G09 和空态时形成假按钮。已生成无按钮槽的状态卡并重新截图,问题消失。
|
||||
- P2:运行冒烟最初在面板出现后立即查找原生输入,可能早于 `uni-input` 内部节点渲染。测试改为等待真实输入节点,再填写和点击;真实提交已到达同页成功态。
|
||||
|
||||
### 可访问性边界
|
||||
|
||||
- 截图可确认主按钮、审核按钮、表单顺序和文字未裁切。
|
||||
- G10 审核按钮在 360 下仍有完整卡内点击区域,但最终 Android 真机仍需验证触摸目标、字体放大和读屏标签。
|
||||
- 截图不能证明弹窗焦点管理或读屏状态播报,不宣称完整无障碍合规。
|
||||
|
||||
## 8. 验证
|
||||
|
||||
- `G08-G10-APPLICATION-FLOW-CONTRACT PASS`
|
||||
- `G08-G10-APPLICATION-FLOW-RUNTIME-SMOKE PASS`,包含真实控件填写与本地模拟提交
|
||||
- `PASS compile audit`
|
||||
|
||||
三页仅标记为候选完成,用户集中看样式前不标记为视觉冻结。
|
||||
@@ -1,51 +0,0 @@
|
||||
# G11–G12 家谱设置与字辈诗设计记录
|
||||
|
||||
> 日期:2026-07-14
|
||||
> 状态:页面候选、同页状态、运行截图与 Product Design 对比审视已完成;标记为 `[~]`,等待用户后续逐页视觉审核。
|
||||
|
||||
## 1. 设计边界
|
||||
|
||||
- 继续使用项目已冻结的宣纸、朱砂、藏青与古金视觉体系,不照搬外部参考的颜色、卡片或按钮。
|
||||
- OpenAPI 只用于确认可设计的功能位置与数据语义,不导入 `appApi`、不新增 API 方法、不进行接口联调。
|
||||
- G11 与 G12 的空、失败、成功、编辑状态全部留在原路由,不为同一任务新增页面。
|
||||
- G11 不放置“转让管理权”假入口:该动作需要成员选择上下文,当前页面只安排名称、公开范围和访问说明。
|
||||
|
||||
## 2. 页面与状态
|
||||
|
||||
| 页面 | 核心任务 | 同页状态 |
|
||||
| --- | --- | --- |
|
||||
| G11 | 编辑家谱名称、公开范围、访问说明 | `loading`、`form`、`success`、`error` |
|
||||
| G12 | 查看逐代字辈、批量录入连续字辈 | `loading`、`list`、`empty`、`edit`、`error` |
|
||||
|
||||
G11 的公开范围提供“仅成员可见”和“公开可申请”两个明确选项,并给出权限解释。G12 采用外部思维导图可借鉴的逐世代行结构,但视觉表面仍完全使用本项目资产;编辑区保留接口清单所描述的连续 `content` 与“缺失旧世代时停止”策略位置。
|
||||
|
||||
## 3. 视觉资产
|
||||
|
||||
- 两页复用 `g03-create-flow-panel.png` 作为完整双金线宣纸面板。
|
||||
- 输入、字辈行使用 `g06-search-input-wide.png` 完整位图皮肤。
|
||||
- 主次操作分别使用 `a01-primary-button.png` 与 `a01-secondary-button.png`。
|
||||
- CSS 只负责布局、真实文字和点击层,不绘制卡片背景、边框、圆角或装饰。
|
||||
- 现有资产已能覆盖页面,因此本轮没有调用 ImageGen 新增近似资产。
|
||||
|
||||
## 4. 截图证据
|
||||
|
||||
目录:`screens/runtime/2026-07-14/g-module-audit/settings-poems/`
|
||||
|
||||
- 改前基线:`00-g11-before-360x800.png`、`00-g12-before-360x800.png`。
|
||||
- G11:`01-g11-form-360x800.png`、`02-g11-success-360x800.png`、`03-g11-form-412x915.png`。
|
||||
- G12:`04-g12-list-360x800.png`、`05-g12-empty-360x800.png`、`06-g12-edit-360x800.png`、`07-g12-list-412x915.png`。
|
||||
- 同画布对比:`08-g11-before-after-comparison.png`、`09-g12-before-after-comparison.png`。
|
||||
|
||||
## 5. Product Design 审视
|
||||
|
||||
- G11 相比通用设置壳,信息层级变为“设置目的 → 名称 → 公开范围及权限解释 → 访问说明 → 保存”,不再出现无法完成的管理权转让入口。
|
||||
- G12 相比通用详情壳,逐世代字辈可直接扫描,当前字辈使用朱砂强调;空态与编辑态仍保持同一页面语义。
|
||||
- 360 × 800 与 412 × 915 下,完整面板、按钮和底部山水均未被裁切;编辑态主次动作层级清楚。
|
||||
- 截图不能证明 Android 字体放大、读屏播报和真机触摸区域,留到最终真机验证阶段。
|
||||
|
||||
## 6. 验证
|
||||
|
||||
- `G11-G12-SETTINGS-POEMS-CONTRACT PASS`
|
||||
- `G11-G12-SETTINGS-POEMS-RUNTIME-SMOKE PASS`
|
||||
- 页面代码不引用 `@/utils/api.js` 或 `appApi`。
|
||||
|
||||
@@ -1,192 +0,0 @@
|
||||
# P00 页面结构与资产清单
|
||||
|
||||
> 状态覆盖:当前实际为 52 条活动路由、52 页全部 `[~]` 待审核、0 页冻结;A06 已封存且保留实现。页面状态以 `docs/交接记录.md` 与 `docs/验收规划.md` 为准。
|
||||
> 目的:让页面、路由和资产都有唯一、可读、可审计的归属;本文件是 P-00 的执行清单。
|
||||
|
||||
## 1. 页面命名规则
|
||||
|
||||
最终页面文件遵循 `<页面编号>-<语义名>.vue`:
|
||||
|
||||
- 账户:`a01-entry.vue`、`a02-login.vue`、`a04-register.vue`、`a05-reset-password.vue`、`a06-auth-status.vue`
|
||||
- 家谱:`g01-my-genealogies.vue`、`g03-create-genealogy.vue`
|
||||
- 世系:`t01-tree-overview.vue`、`t03-member-profile.vue`
|
||||
- 家族:`f01-family-feed.vue`、`f02-publish-feed.vue`
|
||||
- 消息:`n01-message-center.vue`
|
||||
- 我的:`m01-profile-home.vue`
|
||||
|
||||
尚未被全量设计确认、但为了保留现有功能骨架的通用页面,必须在文件名中使用 `skeleton`,不能使用含义不明的 `index.vue`、`list.vue`、`detail.vue`。
|
||||
|
||||
## 2. 首批 15 个路由的历史迁移映射
|
||||
|
||||
| 当前路由 | 当前可确认用途 | 目标文件 | 处理状态 |
|
||||
| --- | --- | --- | --- |
|
||||
| `pages/auth/a01-entry` | A01 启动/登录引导 | `pages/auth/a01-entry.vue` | 7 类非滑动验证视觉状态已获用户确认;真实滑动验证用接口组件自带样式,待接入审核;Android 复核前保持 `[~]` |
|
||||
| `pages/auth/a04-register` | A04 注册账号 | `pages/auth/a04-register.vue` | 当前 8 类 H5 视觉状态已获用户确认;真实滑动验证、接口结果态与 Android/HBuilderX 待审核;保持 `[~]` |
|
||||
| `pages/auth/a05-reset-password` | A05 重置密码 | `pages/auth/a05-reset-password.vue` | 当前 7 类 H5 视觉状态已获用户确认;接口回流手机号/焦点、真实滑动验证与 Android/HBuilderX 待审核;保持 `[~]` |
|
||||
| (已从 `pages.json` 移除) | A06 登录阻断状态(封存) | `pages/auth/a06-auth-status.vue` | 用户确认当前样式没有问题,但当前流程暂时不用;源码、测试和证据保留,不计入活动验收统计,恢复时重新接入真实入口并审核 |
|
||||
| `pages/auth/a02-login` | A02 账号登录 | `pages/auth/a02-login.vue` | 已迁移;已验收,视觉冻结 |
|
||||
| `pages/genealogy/g01-my-genealogies` | G01 我的家谱 | `pages/genealogy/g01-my-genealogies.vue` | 用户已选择 C 作为 9 个 G 页面的公共背景;完整宽度显示、固定贴底与上方宣纸承接的新四档 H5 候选待复核,整页仍为 `[~]`,固定区/独立滚动和 Android 待审核 |
|
||||
| `pages/genealogy/g03-create-genealogy` | G03 创建家谱/始祖录入 | `pages/genealogy/g03-create-genealogy.vue` | `step=create|ancestor`;已获用户验收,视觉冻结 |
|
||||
| `pages/genealogy/g05-genealogy-overview` | G05 家谱总览 | `pages/genealogy/g05-genealogy-overview.vue` | `loading|ready|empty|error`;视觉候选已实现,待集中审美确认 |
|
||||
| `pages/genealogy/g06-search-genealogies` | G06 公开家谱搜索/结果/无结果 | `pages/genealogy/g06-search-genealogies.vue` | 已收敛 G07;朱砂题签三态候选已截图,待集中审美确认 |
|
||||
| `pages/genealogy/g08-join-application` | G08 申请加入家谱 | `pages/genealogy/g08-join-application.vue` | `form|success|error`;本地模拟提交已验证,待集中审美确认 |
|
||||
| `pages/genealogy/g09-my-applications` | G09 我的申请 | `pages/genealogy/g09-my-applications.vue` | `loading|list|empty|error`;待集中审美确认 |
|
||||
| `pages/genealogy/g10-application-review` | G10 管理员入谱审核 | `pages/genealogy/g10-application-review.vue` | `loading|list|empty|error`;待集中审美确认 |
|
||||
| `pages/tree/t01-tree-overview` | T01 世系树总览 | `pages/tree/t01-tree-overview.vue` | 已收敛 T02 四态;视觉候选待审 |
|
||||
| `pages/tree/t03-member-profile` | T03 成员档案 | `pages/tree/t03-member-profile.vue` | 详情/失败候选待审 |
|
||||
| `pages/family/f01-family-feed` | F01 家族动态 | `pages/family/f01-family-feed.vue` | 已迁移;未验收 |
|
||||
| `pages/family/f02-publish-feed` | F02 发布动态 | `pages/family/f02-publish-feed.vue` | 已迁移;未验收 |
|
||||
| `pages/notification/n01-message-center` | N01 消息中心 | `pages/notification/n01-message-center.vue` | 已迁移;未验收 |
|
||||
| `pages/profile/m01-profile-home` | M01 我的首页 | `pages/profile/m01-profile-home.vue` | 已迁移;未验收 |
|
||||
|
||||
上述表仅保留首批迁移的历史背景;当前最终 54 路由、文件存在性和中文编号注释的唯一合同是 `tests/full-page-visual-contract.ps1`。临时 `content-list-skeleton` 已由谱文、相册、礼仪、备忘录和字辈诗最终页面替代并移除。
|
||||
|
||||
迁移要求:同一轮更新页面文件、`pages.json`、所有跳转路径、测试和中文文件头注释;未完成全部引用更新时,不得删除旧路径。
|
||||
|
||||
## 3. 最终资产目录
|
||||
|
||||
```text
|
||||
static/assets/
|
||||
foundation/
|
||||
opaque/
|
||||
page-paper.jpg
|
||||
auth-page-paper.jpg
|
||||
auth-header.png
|
||||
a01-primary-button.png
|
||||
a01-secondary-button.png
|
||||
root-header-cinnabar.jpg
|
||||
transparent/
|
||||
brand-seal.png
|
||||
auth-title-cloud.png
|
||||
auth-divider-knot.png
|
||||
...
|
||||
modules/
|
||||
auth/
|
||||
opaque/
|
||||
a02-login-panel.png
|
||||
transparent/
|
||||
a02-agreement-unchecked.png
|
||||
a02-agreement-checked.png
|
||||
genealogy/
|
||||
opaque/
|
||||
g01-empty-panel.png
|
||||
g03-create-flow-panel.png
|
||||
g05-overview-surface.png
|
||||
g06-search-title-strip.png
|
||||
g06-search-input-wide.png
|
||||
g06-search-button.png
|
||||
application-record-card.png
|
||||
application-status-card.png
|
||||
transparent/
|
||||
tree/
|
||||
opaque/
|
||||
transparent/
|
||||
family/
|
||||
opaque/
|
||||
transparent/
|
||||
```
|
||||
|
||||
当前已迁入的资产以第 4 节台账为准。A01 已引用版本化的卷轴祥云四件套 v3:主/次按钮与弹窗使用 `aspectFit`,Toast 使用固定切片的 `border-image`;文字和交互由代码承载。旧资产与 v2 试点资产保留,不覆盖、不删除。v3 已通过本机自动质量门和 H5 四尺寸检查,但尚未获得 Android 与用户验收。
|
||||
|
||||
## 4. 当前资产台账
|
||||
|
||||
以下为当前唯一可用资产路径。尺寸为原始像素;页面中的显示尺寸仍以组件/页面样式为准。
|
||||
|
||||
| 路径 | 属性/尺寸 | 用途 | 引用位置 | 状态 |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| `foundation/opaque/auth-page-paper.jpg` | opaque / 750×1334 | 认证页纸纹底色 | A01、A02、A04、A05、A06 | 保留 |
|
||||
| `foundation/opaque/auth-header.png` | opaque / 750×196 | 认证页红色页头 | A01、A02、A04、A05、A06 | 保留 |
|
||||
| `foundation/opaque/page-paper.jpg` | opaque / 852×1846 | 根页宣纸底图 | G、T、F、R、N、M 非认证页面 | 保留 |
|
||||
| `foundation/opaque/root-header-cinnabar.jpg` | opaque / 720×184 | 根页朱砂页头 | `PageHeader` | 保留 |
|
||||
| `foundation/transparent/auth-ink-scenery.png` | transparent / 750×1334 | 认证页淡墨叠层 | A01、A02、A04、A05、A06 | 保留 |
|
||||
| `foundation/transparent/auth-title-cloud.png` | transparent / 200×120 | 认证标题右侧云纹 | A01、A02、A04、A05、A06 | 保留 |
|
||||
| `foundation/transparent/auth-divider-knot.png` | transparent / 160×96 | 认证标题与正文结饰 | A01、A04、A05、A06 | 保留 |
|
||||
| `foundation/opaque/a01-primary-button.png` | opaque / 1877×418 | 旧朱砂主按钮皮肤 | 其他仍实际引用的模块页面 | 暂时保留;A01、A04、A05、A06 已停止引用,其他页面需逐页迁移 |
|
||||
| `foundation/opaque/a01-secondary-button.png` | opaque / 1881×419 | 旧宣纸次按钮皮肤 | 其他仍实际引用的页面 | 暂时保留;A01 可见按钮和 Toast 已停止引用,其他页面未逐页迁移 |
|
||||
| `foundation/transparent/a01-primary-button-v2.png` | transparent / 1866×276 | A01 固定槽位主按钮历史试点 | 当前 A01 已停止引用 | 保留,不覆盖、不删除 |
|
||||
| `foundation/transparent/a01-secondary-button-v2.png` | transparent / 1866×300 | A01 固定槽位次按钮历史试点 | 当前 A01 已停止引用 | 保留,不覆盖、不删除 |
|
||||
| `foundation/transparent/a01-scroll-primary-v3.png` | transparent / 1866×276 | 卷轴祥云朱砂主按钮皮肤 | A01 登录、A04 注册、A05 重设、A06 恢复入口及弹层按钮 | A01/A04/A05 当前 H5 状态已获用户确认;A06 待逐态审核;均待 Android 复核 |
|
||||
| `foundation/transparent/a01-scroll-secondary-v3.png` | transparent / 1866×300 | 卷轴祥云宣纸次按钮皮肤 | A01 微信按钮 | 用户 H5 视觉审核通过;待 Android 复核 |
|
||||
| `foundation/transparent/a01-scroll-toast-v3.png` | transparent / 1770×246 | 卷轴祥云 Toast 可切片表面 | A01、A04、A05 自定义反馈 Toast | A01/A04 用户 H5 审核通过;A05 待逐态审核;均待 Android 复核 |
|
||||
| `modules/auth/transparent/a01-scroll-dialog-v3.png` | transparent indexed PNG / 1860×1560 | 卷轴祥云对话容器 | A01 历史验证占位、A05 成功结果、A06 恢复说明 | A01 不得用于最终滑动验证;A05/A06 仅用于本地结果或说明候选 |
|
||||
| `modules/auth/opaque/a02-login-panel.png` | opaque / 940×1672 | 旧认证完整卷轴面板 | 当前认证页无引用 | 历史保留,不覆盖、不删除 |
|
||||
| `modules/auth/transparent/a02-agreement-unchecked.png` | transparent / 96×96 | 认证未同意协议图标 | A01、A04 协议确认行 | 保留;A01/A04 用户 H5 审核通过 |
|
||||
| `modules/auth/transparent/a02-agreement-checked.png` | transparent / 96×96 | 认证已同意协议图标 | A01、A04 协议确认行 | 保留;A01/A04 用户 H5 审核通过 |
|
||||
| `foundation/transparent/auth-login-outline.png` | transparent / 96×96 | 登录/状态图标 | A01、A06 | 保留 |
|
||||
| `foundation/transparent/auth-wechat.png` | transparent / 96×96 | 微信图标 | A01 | 保留 |
|
||||
| `foundation/transparent/brand-seal.png` | transparent / 240×288 | 品牌谱印 | A01、A02、A04、A05、A06、G06、`PageHeader` | 保留 |
|
||||
| `foundation/transparent/root-header-hall.png` | transparent / 750×300 | 根页祠堂线稿 | `PageHeader` | 保留 |
|
||||
| `foundation/transparent/footer-mountain-bamboo.png` | transparent / 750×360 | 根页底部山水竹影 | G01、G06 | 保留 |
|
||||
| `foundation/transparent/notice.png` | transparent / 96×96 | 通知图标 | `PageHeader` | 保留 |
|
||||
| `foundation/transparent/chevron-right.png` | transparent / 96×96 | 返回与列表箭头 | A02、A04、A05、A06、`GenealogyCard` | 保留 |
|
||||
| `docs/design/assets/g01-background/masters/g01-list-background-direction-a-chroma-master.png` | candidate master / 1024×1536 | G01 背景 A,绿幕透明历史候选 | 未接入 | 母版保留,不删除 |
|
||||
| `docs/design/assets/g01-background/masters/g01-list-background-direction-b-chroma-master.png` | candidate master / 1024×1536 | G01 背景 B,绿幕透明历史候选 | 未接入 | 母版保留,不删除 |
|
||||
| `docs/design/assets/g01-background/masters/g01-list-background-direction-c-paper-master.png` | historical selected master / 1024×1536 | 上一轮 G 模块公共暖宣纸背景 C 的稳定母版 | `genealogy-page-background.png` | 历史方向保留,不删除 |
|
||||
| `docs/design/assets/g01-background/masters/genealogy-page-background-long-flagship-imagegen-source.png` | source master / 793×1983 | 用户确认的连续长背景原始 ImageGen 输出 | 归一化母版输入 | 原稿保留,不删除 |
|
||||
| `docs/design/assets/g01-background/masters/genealogy-page-background-long-flagship-master.png` | selected master / 1536×3840 | G 模块旗舰长屏连续背景稳定母版 | `genealogy-page-background-long.png` | 当前选定母版;确定性输出输入 |
|
||||
| `modules/genealogy/opaque/genealogy-page-background.png` | opaque / 1024×1536 | 上一轮 C 公共运行图 | 当前无页面入口 | 历史输出保留,不删除 |
|
||||
| `modules/genealogy/opaque/genealogy-page-background-long.png` | opaque / 1440×3600 | G 模块公共旗舰长屏云竹亭台连续宣纸景 | 9 个活动 G 页面通过 `GenealogyPageBackground.vue` 使用,`widthFix` 固定贴底 | 当前公共运行输出;H5 候选已覆盖五档,待逐页审核及 Android 复核 |
|
||||
| `modules/genealogy/opaque/g01-empty-panel.png` | opaque / 1122×1402 | 无家谱空态完整宣纸卡,双金线、角饰、云纹与下沿山水 | G01 `state=empty` | 保留;由 ImageGen 生成,已获用户验收 |
|
||||
| `modules/genealogy/opaque/g03-create-flow-panel.png` | opaque / 1122×1500 | 完整宣纸任务面板,双金线、回纹角饰、云纹与下沿淡墨山水 | G03、G08、G11、G12、T01、T03 至 T06、T08、F/R/N/M 表单/详情/设置/状态 | 保留;由 ImageGen 生成,G03 已获用户验收,复用页待审 |
|
||||
| `modules/genealogy/opaque/g05-overview-surface.png` | opaque / 1122×1506 | 家谱总览完整视觉表面,含墨蓝谱名区、四宫格宣纸槽、金线近况分隔与底部访问说明面 | G05 `loading|ready|empty|error` | 保留;由 ImageGen 参考 G05 改前截图生成,页面只叠放真实文字和点击层;待集中审美确认 |
|
||||
| `modules/genealogy/opaque/g06-search-title-strip.png` | opaque / 1122×220 | 朱砂题签完整视觉面,含古金边饰与云纹,不烘焙文字或印玺 | G06 顶部检索题签 | 保留;用户选定 ImageGen 第 3 稿方向,Vue 叠放真实 `brand-seal.png` 与标题文字 |
|
||||
| `modules/genealogy/opaque/g06-search-input-wide.png` | opaque / 1120×248 | 宽版独立宣纸字段完整皮肤,含古金双线与角饰 | G、T 与 F/R/N/M 输入、详情段落、设置行 | 保留;真实文字与控件叠放其上 |
|
||||
| `modules/genealogy/opaque/g06-search-input.png` | opaque / 720×248 | 早期窄版输入框完整皮肤 | 当前无引用 | 已停用;仅因用户要求停止工作而保留,恢复工作后需在引用扫描通过且用户允许时删除 |
|
||||
| `modules/genealogy/opaque/g06-search-button.png` | opaque / 300×132 | 独立朱砂搜索按钮完整皮肤,含古金双线与角饰 | G06 真实搜索点击控件底层 | 保留;由 ImageGen 生成,真实点击控件与文本叠放其上 |
|
||||
| `modules/genealogy/opaque/application-record-card.png` | opaque / 1050×360 | 带右下双审核槽的完整申请记录卡 | G10 待审核申请 | 保留;由 ImageGen 参考 G09/G10 改前截图生成,按钮另用完整位图叠放 |
|
||||
| `modules/genealogy/opaque/application-status-card.png` | opaque / 1050×360 | 无按钮槽的完整信息状态卡 | G09/G10、T01/T07/T08、F/R/N/M 列表/时间轴/通知/状态 | 保留;由 ImageGen 精确移除审核槽,避免无操作状态出现假按钮 |
|
||||
| `foundation/transparent/meta-location.png` | transparent / 96×96 | 地点元信息 | G01、`GenealogyCard` | 保留 |
|
||||
| `foundation/transparent/meta-member.png` | transparent / 96×96 | 成员元信息 | G01、`GenealogyCard` | 保留 |
|
||||
| `foundation/transparent/meta-admin.png` | transparent / 96×96 | 管理员元信息 | G01 | 保留 |
|
||||
| `foundation/transparent/tab-genealogy.png` | transparent / 96×96 | 家谱 Tab 默认态 | `AppTabbar` | 保留 |
|
||||
| `foundation/transparent/tab-genealogy-active.png` | transparent / 96×96 | 家谱 Tab 选中态 | `AppTabbar` | 保留 |
|
||||
| `foundation/transparent/tab-family.png` | transparent / 96×96 | 家族 Tab 默认态 | `AppTabbar` | 保留 |
|
||||
| `foundation/transparent/tab-family-active.png` | transparent / 96×96 | 家族 Tab 选中态 | `AppTabbar` | 保留 |
|
||||
| `foundation/transparent/tab-profile.png` | transparent / 96×96 | 我的 Tab 默认态 | `AppTabbar` | 保留 |
|
||||
| `foundation/transparent/tab-profile-active.png` | transparent / 96×96 | 我的 Tab 选中态 | `AppTabbar` | 保留 |
|
||||
| `modules/genealogy/transparent/current-slip-frame.png` | transparent / 720×272 | 当前家谱题签框 | G01 | 保留 |
|
||||
| `modules/genealogy/transparent/list-slip-frame.png` | transparent / 720×144 | 家谱列表题签框 | `GenealogyCard` | 保留 |
|
||||
| `modules/genealogy/transparent/section-divider.png` | transparent / 720×30 | G01 分区金线 | G01 | 保留 |
|
||||
| `modules/genealogy/transparent/current-seal-frame.png` | transparent / 144×208 | 当前家谱谱印框 | G01 | 保留 |
|
||||
| `modules/genealogy/transparent/row-seal-frame.png` | transparent / 112×160 | 列表谱印框 | `GenealogyCard` | 保留 |
|
||||
| `modules/genealogy/transparent/create-cloud.png` | transparent / 192×96 | 新建/搜索状态云纹 | G01、G06 | 保留 |
|
||||
| `modules/genealogy/transparent/add.png` | transparent / 96×96 | 新建入口加号 | G01 | 保留 |
|
||||
| `modules/genealogy/transparent/shortcut-tree.png` | transparent / 96×96 | 世系图入口 | G01 | 保留 |
|
||||
| `modules/genealogy/transparent/shortcut-members.png` | transparent / 96×96 | 成员入口 | G01 | 保留 |
|
||||
| `modules/genealogy/transparent/shortcut-generation-poem.png` | transparent / 96×96 | 字辈诗入口 | G01 | 保留 |
|
||||
| `modules/genealogy/transparent/shortcut-application.png` | transparent / 96×96 | 审核入口 | G01 | 保留 |
|
||||
|
||||
本轮已迁入 38 项在用资产,删除 24 项运行时零引用的旧副本。A01 的两张完整按钮皮肤与 G06 检索资产均由图像生成工具制作,不是设计图裁切;G06 不再使用大型检索面板,当前使用朱砂题签、宽版输入框与搜索按钮三张独立位图,页面只叠放真实品牌印、文字与交互控件。旧版设计记录中的历史路径不再是资产依据;后续只按本台账引用和维护。
|
||||
|
||||
## 5. 混合资产属性规则
|
||||
|
||||
完整生产、质量门和换机恢复方法以 `docs/design/设计资产生产流水线规范.md` 为准。P00 只登记最终资产归属,不重复维护算法和阈值。
|
||||
|
||||
| 类型 | 文件格式与内容 | 典型用途 | 页面使用规则 |
|
||||
| --- | --- | --- | --- |
|
||||
| 代码原生 | Vue/SCSS,不产生图片 | 布局、文字、间距、普通分隔线、纯色和交互状态 | 不伪造复杂品牌装饰;普通内容保持正常布局流 |
|
||||
| 固定比例位图 | JPG、PNG 或 WebP;比例与槽位一致 | 宗祠、水墨背景、固定比例复杂视觉面 | manifest 声明槽位和 `uniform-only`;禁止变形拉伸 |
|
||||
| 透明叠加图 | RGBA PNG/WebP;外缘透明、主体边界干净 | Logo、图标、祥云、谱印、分隔纹 | manifest 声明 Alpha 与边缘策略;禁止白边、绿边 |
|
||||
| 可伸缩装饰框 | 九宫格或经过验证的等价组件资产 | 多宽度按钮框、卡片框、弹窗框 | 必须先验证 H5 与 Android;四角和边线不得被整体拉伸 |
|
||||
| 平铺纹理 | 可无缝重复的小图 | 宣纸、无方向纹理 | manifest 明确平铺/覆盖策略,不承担文字或边框 |
|
||||
|
||||
禁止项:整页截图、设计图局部页面裁切、半边边框、按钮角标拼接、临时 CSS/SVG 伪造复杂品牌装饰、未声明的 `scaleToFill`/`fit: 'fill'`、同一语义的多版本长期共存。
|
||||
|
||||
## 6. 资产清理登记规则
|
||||
|
||||
每项现有资产必须登记以下字段后才可以决定保留或删除:
|
||||
|
||||
| 字段 | 含义 |
|
||||
| --- | --- |
|
||||
| 当前路径 | 盘点时的真实文件路径 |
|
||||
| 语义用途 | 它服务的视觉元素,而不是模糊类别 |
|
||||
| 背景属性 | `opaque` 或 `transparent` |
|
||||
| 代码引用 | 页面、组件、配置或测试中的引用位置 |
|
||||
| 替代资产 | 若要迁移,写入唯一的新路径;无替代则留空 |
|
||||
| 决定 | `保留`、`迁移后删除` 或 `无引用删除` |
|
||||
|
||||
执行顺序固定为:扫描引用 → 填写登记 → 迁移引用 → 编译与截图验证 → 删除旧资产 → 再次扫描确认零引用。任何一步失败都停止删除。
|
||||
|
||||
## 7. 中文注释约定
|
||||
|
||||
每个页面和组件文件以中文注释写清页面编号、用途和关键依赖;模板至少区分背景、页头、主体、操作、状态五类区域。资产图层、路由跳转、非直观状态和防回归样式要解释“为什么这样写”。显而易见的单行 CSS 不写噪声注释。
|
||||
@@ -1,46 +0,0 @@
|
||||
# T01–T08 世系与成员设计记录
|
||||
|
||||
> 日期:2026-07-14
|
||||
> 状态:T02 已收敛至 T01;T01、T03–T08 共 7 个页面候选、运行截图和 Product Design 审视完成,统一标记 `[~]`。
|
||||
|
||||
## 1. 信息架构
|
||||
|
||||
- T01 是阅读世系的唯一页面,`tree|landscape|empty|error` 为同页状态;T02 路由、文件和目录条目已删除。
|
||||
- T03 负责成员档案;T04 新增亲属、T05 编辑成员、T06 维护关系分别保留独立任务页,但共享同一高质量表单母版。
|
||||
- T07 是成员检索目录;T08 将隐私、离世纪念和无权限保留为同一路由的三个状态,不拆成重复页面。
|
||||
- 接口文档仅用于确定成员字段、关系冲突、隐私和状态位置;所有候选使用页面内模拟数据,不调用 `appApi`。
|
||||
|
||||
## 2. 视觉体系
|
||||
|
||||
- 延续已确认的宣纸、朱砂、古金与墨褐体系。
|
||||
- 世系画布和成员表单复用 `g03-create-flow-panel.png` 完整宣纸面;成员节点/字段使用 `g06-search-input-wide.png`;信息条、目录卡和状态卡使用 `application-status-card.png`。
|
||||
- 主次操作使用 `a01-primary-button.png`/`a01-secondary-button.png`,CSS 仅负责布局、文字和点击层。
|
||||
- 现有完整位图已覆盖需要,因此没有生成语义重复的新资产。
|
||||
|
||||
## 3. Product Design 截图审视
|
||||
|
||||
### T01
|
||||
|
||||
目录:`screens/runtime/2026-07-14/t01-audit/`
|
||||
|
||||
- 候选:`04-t01-tree-360x800.png`、`05-t01-landscape-360x800.png`、`06-t01-empty-360x800.png`、`07-t01-error-360x800.png`、`08-t01-tree-412x915.png`。
|
||||
- 对比:`09-t01-before-after-comparison.png`。
|
||||
- 首轮状态截图发现状态文案位于 1180rpx 横向画布中导致右侧裁切;先更新契约要求 `tree-canvas--state`,再将非树状态画布收窄到当前视口,重新截图后已完整显示。
|
||||
|
||||
### T03–T08
|
||||
|
||||
目录:`screens/runtime/2026-07-14/t-module-audit/`
|
||||
|
||||
- 关键候选:`01-t03-detail-360x800.png` 至 `13-t07-list-412x915.png`。
|
||||
- 同画布对比:`14-t03-comparison.png`、`15-t04-t06-comparison.png`、`16-t07-t08-comparison.png`。
|
||||
- 360/412 下表单字段、成员卡、状态切换和主次操作均未裁切;T06 冲突动作与 T08 三种权限状态层级清楚。
|
||||
|
||||
## 4. 验证
|
||||
|
||||
- `T01-TREE-STATE-CONTRACT PASS`
|
||||
- `T01-TREE-STATE-RUNTIME-SMOKE PASS`
|
||||
- `T03-T08-MEMBER-FLOW-CONTRACT PASS`
|
||||
- `T03-T08-MEMBER-FLOW-RUNTIME-SMOKE PASS`
|
||||
- `PASS compile audit`
|
||||
|
||||
H5 截图不能替代 Android 真机字体放大、读屏和触摸目标验证;这些留到全量候选后的设备复核。
|
||||
|
Before Width: | Height: | Size: 1.9 MiB |
@@ -1,45 +0,0 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"canvas": {
|
||||
"width": 1236,
|
||||
"height": 2745,
|
||||
"logicalWidth": 412,
|
||||
"logicalHeight": 915,
|
||||
"scale": 3,
|
||||
"resolution": 72
|
||||
},
|
||||
"groups": [
|
||||
{ "name": "00-参考", "visible": false },
|
||||
{ "name": "10-背景", "visible": true },
|
||||
{ "name": "20-卷轴", "visible": true },
|
||||
{ "name": "30-品牌与标题装饰", "visible": true },
|
||||
{ "name": "40-公共控件皮肤", "visible": true },
|
||||
{ "name": "50-密码登录", "visible": true },
|
||||
{ "name": "60-验证码登录", "visible": false },
|
||||
{ "name": "70-内容与标注", "visible": true }
|
||||
],
|
||||
"layers": [],
|
||||
"states": [
|
||||
{
|
||||
"name": "password-hidden",
|
||||
"show": ["50-密码登录"],
|
||||
"hide": ["60-验证码登录"]
|
||||
},
|
||||
{
|
||||
"name": "password-visible",
|
||||
"show": ["50-密码登录"],
|
||||
"hide": ["60-验证码登录"]
|
||||
},
|
||||
{
|
||||
"name": "sms-default",
|
||||
"show": ["60-验证码登录"],
|
||||
"hide": ["50-密码登录"]
|
||||
},
|
||||
{
|
||||
"name": "sms-countdown",
|
||||
"show": ["60-验证码登录"],
|
||||
"hide": ["50-密码登录"]
|
||||
}
|
||||
],
|
||||
"exports": []
|
||||
}
|
||||
|
Before Width: | Height: | Size: 1.5 MiB |
|
Before Width: | Height: | Size: 1.4 MiB |
|
Before Width: | Height: | Size: 1.6 MiB |
|
Before Width: | Height: | Size: 957 KiB |
|
Before Width: | Height: | Size: 2.3 MiB |
|
Before Width: | Height: | Size: 2.0 MiB |
|
Before Width: | Height: | Size: 2.3 MiB |
|
Before Width: | Height: | Size: 2.2 MiB |
|
Before Width: | Height: | Size: 1.6 MiB After Width: | Height: | Size: 1.6 MiB |