验收20%

This commit is contained in:
2026-07-16 07:42:07 +08:00
parent 0dec90ffdd
commit cb25317412
108 changed files with 6477 additions and 1308 deletions
+4
View File
@@ -4,4 +4,8 @@
/docs/design/screens/runtime/ /docs/design/screens/runtime/
/tmp-g01-icon-audit.png /tmp-g01-icon-audit.png
/node_modules/ /node_modules/
**/node_modules/
/design-pipeline/generated/
/docs/design/assets/a01-vnext/candidates/
/docs/design/assets/a01-vnext/review/
/.superpowers/ /.superpowers/
+21 -1
View File
@@ -43,6 +43,20 @@
</view> </view>
</view> </view>
</view> </view>
<view v-if="conflictHelpVisible" class="conflict-help" @click.self="conflictHelpVisible = false">
<view class="conflict-help__card">
<image class="conflict-help__skin" src="/static/assets/modules/genealogy/opaque/application-status-card.png" mode="scaleToFill" />
<view class="conflict-help__content">
<text class="member-form__eyebrow">关系校验规则</text>
<text class="member-form__title">关系冲突说明</text>
<text class="member-form__copy">同一成员不能同时存在两个生父也不能形成上下代循环请返回世系树核对原关系后再调整</text>
<view class="member-form-action conflict-help__action" @click="conflictHelpVisible = false">
<image src="/static/assets/foundation/opaque/a01-primary-button.png" mode="scaleToFill" /><text>我知道了</text>
</view>
</view>
</view>
</view>
</view> </view>
</template> </template>
@@ -91,6 +105,7 @@ const query = (() => {
return pages[pages.length - 1]?.options || {} return pages[pages.length - 1]?.options || {}
})() })()
const formState = ref(query.state === 'success' ? 'success' : query.state === 'conflict' ? 'conflict' : query.state === 'error' ? 'error' : 'form') const formState = ref(query.state === 'success' ? 'success' : query.state === 'conflict' ? 'conflict' : query.state === 'error' ? 'error' : 'form')
const conflictHelpVisible = ref(false)
const resultCopy = computed(() => ({ const resultCopy = computed(() => ({
success: { eyebrow: '本地预览已更新', title: `${config.value.pageTitle}内容已保存`, copy: '返回世系树后可查看新的展示位置;正式数据将在接口阶段接入。' }, success: { eyebrow: '本地预览已更新', title: `${config.value.pageTitle}内容已保存`, copy: '返回世系树后可查看新的展示位置;正式数据将在接口阶段接入。' },
conflict: { eyebrow: '发现关系冲突', title: '这段关系会造成代际矛盾', copy: '目标成员已经存在父级关系,请先核对原关系,再决定是否调整。' }, conflict: { eyebrow: '发现关系冲突', title: '这段关系会造成代际矛盾', copy: '目标成员已经存在父级关系,请先核对原关系,再决定是否调整。' },
@@ -104,7 +119,7 @@ const saveForm = () => {
} }
formState.value = 'success' formState.value = 'success'
} }
const showConflictHelp = () => uni.showModal({ title: '关系冲突说明', content: '同一成员不能同时存在两个生父或形成上下代循环。请返回世系树核对原关系。', showCancel: false }) const showConflictHelp = () => { conflictHelpVisible.value = true }
</script> </script>
<style scoped lang="scss"> <style scoped lang="scss">
@@ -133,5 +148,10 @@ const showConflictHelp = () => uni.showModal({ title: '关系冲突说明', cont
.member-form-result > .member-form-action { width: 420rpx; max-width: 100%; margin: 32rpx auto 0; } .member-form-result > .member-form-action { width: 420rpx; max-width: 100%; margin: 32rpx auto 0; }
.conflict-actions { display: flex; gap: 14rpx; margin-top: 30rpx; } .conflict-actions { display: flex; gap: 14rpx; margin-top: 30rpx; }
.conflict-actions .member-form-action { width: calc(50% - 7rpx); margin-top: 0; } .conflict-actions .member-form-action { width: calc(50% - 7rpx); margin-top: 0; }
.conflict-help { position: fixed; inset: 0; z-index: 20; display: flex; align-items: center; justify-content: center; padding: 32rpx; background: rgba(34, 20, 13, .58); }
.conflict-help__card { position: relative; width: min(620rpx, 92vw); height: 390rpx; }
.conflict-help__skin { position: absolute; inset: 0; width: 100%; height: 100%; }
.conflict-help__content { position: absolute; inset: 62rpx 56rpx 46rpx; text-align: center; }
.conflict-help__action { margin-top: 30rpx; }
@media (min-width: 400px) { .member-form-panel { width: calc(100% - 48rpx); } } @media (min-width: 400px) { .member-form-panel { width: calc(100% - 48rpx); } }
</style> </style>
+119
View File
@@ -0,0 +1,119 @@
{
"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 }
]
}
+608
View File
@@ -0,0 +1,608 @@
{
"name": "jiapu-design-pipeline",
"lockfileVersion": 3,
"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
}
}
}
+11
View File
@@ -0,0 +1,11 @@
{
"name": "jiapu-design-pipeline",
"private": true,
"type": "module",
"scripts": {
"build:a01": "node scripts/build-a01.mjs"
},
"dependencies": {
"sharp": "0.34.5"
}
}
+159
View File
@@ -0,0 +1,159 @@
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')
+464
View File
@@ -1,3 +1,467 @@
# Design QA — T04 新增亲属
- representative contact sheet: `docs/design/screens/handoff/2026-07-16/T04-current-form-success-contact-720x800.png`
- viewport: 360×800;成员链路 smoke 覆盖 T04–T08 关键路由与状态
## Findings
没有遗留的 H5 P0/P1/P2 问题。新增亲属表单的关系、姓名、出生日期和性别层级清楚,成功结果与表单使用同一纸面体系。共用成员表单中残留的原生 `uni.showModal` 已替换为项目自定义冲突说明层。
## Evidence limits
当前是 H5 内部候选;关系选择器、真实保存、键盘与 Android 返回手势仍待功能阶段和设备复核,用户视觉验收尚未进行。
# Design QA — T01 世系树
- representative contact sheet: `docs/design/screens/handoff/2026-07-16/T01-current-tree-empty-contact-720x800.png`
- viewport: 360×800;运行 smoke 覆盖横向提示、空、失败、树态、节点选择和成员档案跳转
## Findings
没有遗留的 H5 P0/P1/P2 问题。
- 树态使用横向可拖动画布表达同代支系,页面本身无横向溢出;首屏保留阅读提示、代际标识、节点层级与选中成员操作条。
- 空态说明内容产生方式,只突出“录入首代成员”;加载失败与横向提示继续由 T01 同页状态承接。
- 移除了未注册路由但仍残留的 T02 旧占位文件,T01 成为世系阅读状态的唯一所有者。
## Evidence limits
当前是 H5 内部候选,不证明真实世系接口、超大族谱性能或 Android 手势已通过;用户验收和 Android/HBuilderX 复核仍待进行。
## Implementation checklist
- [x] T01 聚焦静态合同
- [x] T01 H5 运行 smoke
- [x] 树态/空态真实运行联系表及人工审视
- [ ] Android/HBuilderX 真机或模拟器复核
- [ ] 用户视觉验收
# Design QA — G11 家谱设置/G12 字辈诗
- G11 representative contact sheet: `docs/design/screens/handoff/2026-07-16/G11-current-form-permission-contact-720x800.png`
- G12 representative contact sheet: `docs/design/screens/handoff/2026-07-16/G12-current-list-edit-contact-720x800.png`
- viewport: 360×800;运行 smoke 覆盖列表、空、编辑、成功、失败与无权限
## Findings
没有遗留的 H5 P0/P1/P2 问题。
- G11:名称、公开范围、访问说明保持接口可解释边界;名称错误就近显示,保存反馈为项目自定义,无权限独立表达。
- G12:列表、空、编辑、校验、保存失败、无权限均有位置;连续文本预览与缺失旧世代策略保持清晰。
- 两页均移除原生 Toast,保留现有完整纸面板、按钮和输入资产。
## Evidence limits
当前保存只更新本地候选,不证明设置/字辈接口、权限或并发编辑已经接通;Android 仍待设备复核。
## Implementation checklist
- [x] G11 表单/成功/错误/无权限与自定义反馈
- [x] G12 列表/空/编辑/校验/失败/无权限与自定义反馈
- [x] H5 真实运行烟测和联系表
- [ ] Android/HBuilderX 真机或模拟器复核
- [ ] 用户验收
final result: passed (internal H5 candidate QA only; user review pending)
---
# Design QA — G10 入谱审核
- source visual truth path: 现有审核记录卡视觉与 `docs/验收规划.md` G10 状态合同
- implementation screenshot path: `docs/design/screens/runtime/2026-07-16/G10-current-list-360x800.png``G10-current-no-permission-360x800.png`
- representative contact sheet: `docs/design/screens/handoff/2026-07-16/G10-current-list-permission-contact-720x800.png`
- viewport: 360×800;运行 smoke 覆盖说明、确认和结果反馈
- state: 列表、空、失败、无权限、通过确认、拒绝确认、说明
## Findings
没有遗留的 H5 P0/P1/P2 问题。
- 审核操作:每条待审记录保留拒绝/通过,二者都必须进入自定义二次确认后才更新结果。
- 自定义反馈:顶栏审核说明、确认弹层和处理结果全部使用项目组件,已移除原生 Modal/Toast。
- 权限状态:无权限与网络失败分开表达,避免把角色限制误报成加载错误。
- 卡片信息:姓名、脱敏手机号、关系说明和提交时间保持同屏可核对。
## Evidence limits
当前审核结果只更新本地候选卡片,不证明服务端权限、并发审核、消息通知或成员身份写入已经完成。
## Implementation checklist
- [x] 列表/空/失败/无权限
- [x] 自定义通过/拒绝确认
- [x] 自定义审核说明与结果反馈
- [x] H5 真实运行烟测
- [ ] Android/HBuilderX 真机或模拟器复核
- [ ] 用户验收
final result: passed (internal H5 candidate QA only; user review pending)
---
# Design QA — G05 成员总览/公开预览
- source visual truth path: 已选 G05 总览位图与 `docs/验收规划.md` 公开/成员权限合同
- implementation screenshot path: `docs/design/screens/runtime/2026-07-16/G05-current-member-owner-360x800.png``G05-current-public-360x800.png`
- representative contact sheet: `docs/design/screens/handoff/2026-07-16/G05-current-member-public-contact-720x800.png`
- viewport: 360×800;运行 smoke 另查 320×568、412×915
- state: 公开预览、成员/所有者、加载、空、失败、无权限
## Findings
没有遗留的 H5 P0/P1/P2 问题。
- 公开边界:只展示身份、来源、地区、堂号、支系、上级谱和公开说明;不渲染成员统计、世系或管理入口。
- 申请入口:公开态底部唯一主操作进入 G08 搜索来源。
- 角色差异:所有者显示四个业务入口与顶栏管理;普通成员只显示世系和字辈,不显示录入、审核或设置。
- 视觉层级:世系入口不再单独用朱砂强调,保持“总览优先、世系次级”的产品层级。
- 实现边界:移除远程 API,加载/错误/无权限均为同页本地样式状态。
## Evidence limits
当前公开字段和角色由本地候选决定,不证明真实接口字段级权限、成员身份、管理权限或全局家谱上下文已接通。
## Implementation checklist
- [x] 公开预览最小字段与申请入口
- [x] 成员/所有者入口差异
- [x] loading/empty/error/no-permission 同页状态
- [x] 三档 H5 响应式与真实运行烟测
- [ ] Android/HBuilderX 真机或模拟器复核
- [ ] 用户验收
final result: passed (internal H5 candidate QA only; user review pending)
---
# Design QA — G03 创建家谱/首代人物
- source visual truth path: 现有 G03 两步题签设计与 `docs/验收规划.md` 最新创建合同
- implementation screenshot path: `docs/design/screens/runtime/2026-07-16/G03-current-create-360x800.png``G03-current-ancestor-360x800.png`
- representative contact sheet: `docs/design/screens/handoff/2026-07-16/G03-current-create-ancestor-contact-720x800.png`
- viewport: 360×800;运行 smoke 另查 320×568、412×915
- state: 创建表单/校验/重复建谱提醒/提交中/失败,首代恢复/校验/保存中/失败/成功
## Findings
没有遗留的 H5 P0/P1/P2 问题。
- 创建字段:姓氏、谱名、堂号、所在地和访问规则齐全,访问规则提供仅成员可见/可搜索申请两个明确候选。
- 防重复建谱:本地校验通过后先打开自定义提醒层,可先去 G06 搜索或确认继续创建。
- 中断恢复:已创建未录始祖继续使用同一路由 `step=ancestor&genealogyId=`,不重复建谱。
- 完成路径:首代保存成功显示自定义结果并进入 G05 总览,不再错误直跳 T01。
- 实现边界:移除本页远程 API 和原生 UniApp 提示,当前只审核页面样式状态。
## Evidence limits
当前创建 ID、保存结果和失败状态均为本地候选,不证明服务端创建、重复谱检索、始祖持久化或断点恢复已经接通。
## Implementation checklist
- [x] 创建字段与页内校验
- [x] 自定义已有谱提醒和访问规则候选
- [x] 两步提交中/失败/成功与中断恢复外观
- [x] 完成后进入 G05
- [x] 三档 H5 响应式与真实运行烟测
- [ ] Android/HBuilderX 真机或模拟器复核
- [ ] 用户验收
final result: passed (internal H5 candidate QA only; user review pending)
---
# Design QA — G09 我的申请
- source visual truth path: 现有申请状态题签视觉与 `docs/验收规划.md` G09 状态合同
- implementation screenshot path: `docs/design/screens/runtime/2026-07-16/G09-current-list-360x800.png``G09-current-empty-360x800.png`
- representative contact sheet: `docs/design/screens/handoff/2026-07-16/G09-current-list-empty-contact-720x800.png`
- viewport: 360×800;运行 smoke 同时验证交互路径
- state: 待审、通过、拒绝、撤回、修改后重提、空、失败、加载
## Findings
没有遗留的 H5 P0/P1/P2 问题。
- 状态动作:待审只显示撤回,通过只显示进入家谱,拒绝只显示修改后重新提交;状态与动作分行显示,不再重叠。
- 撤回流程:使用自定义确认层,确认后本地状态变为已撤回且不再显示审核动作。
- 来源边界:空态明确只有搜索申请进入本页,邀请码直接加入不生成审核记录。
- 路由语义:通过进入 G05;拒绝携带 `source=search&previous=rejected` 返回 G08。
## Evidence limits
当前记录和撤回结果为本地样式状态,不证明服务端撤回、审核同步、消息通知或列表现场恢复已经完成。
## Implementation checklist
- [x] 待审/通过/拒绝/撤回状态
- [x] 状态唯一动作与自定义撤回确认
- [x] 修改后重提和通过后进入家谱路径
- [x] loading/empty/error 样式与运行烟测
- [ ] 320/412 Android 真机专项复核
- [ ] 用户验收
final result: passed (internal H5 candidate QA only; user review pending)
---
# Design QA — G08 搜索申请/邀请码直加入谱
- source visual truth path: 现有入谱表单题签视觉与 `docs/验收规划.md` 双来源合同
- implementation screenshot path: `docs/design/screens/runtime/2026-07-16/G08-current-search-360x800.png``G08-current-invite-360x800.png`
- representative contact sheet: `docs/design/screens/handoff/2026-07-16/G08-current-search-invite-contact-720x800.png`
- viewport: 360×800;运行 smoke 另查 320×568、412×915
- state: 搜索申请/邀请直加表单、页内错误、提交中、成功、失败、来源对应下一步
## Findings
没有遗留的 H5 P0/P1/P2 问题。
- 单一来源合同:页面通过 `sourceContract` 集中管理两种来源的标题、说明、第三字段、按钮、结果和下一步,避免“申请/直接加入”文案混用。
- 字段边界:真实姓名、以已知长辈为参照的文字关系必填;没有虚构接口尚不支持的成员选择器或结构化关系。
- 搜索来源:第三字段为申请说明,成功后去 G09 查看审核进度。
- 邀请来源:第三字段为可选补充信息,明确无需审核;成功后返回 G01 并携带选中家谱。
- 错误反馈:必填错误就近显示,重复点击提交被拦截,没有原生 UniApp 提示。
## Evidence limits
当前提交结果为本地样式状态,不证明真实申请、邀请码直加、重复提交防护服务端逻辑或 G01 全局上下文刷新已经接通。
## Implementation checklist
- [x] 搜索/邀请双来源单一合同
- [x] 真实姓名与参照长辈文字关系校验
- [x] 来源对应第三字段、结果和下一步
- [x] 本地提交中/成功/失败与三档响应式
- [ ] Android/HBuilderX 真机或模拟器复核
- [ ] 用户验收
final result: passed (internal H5 candidate QA only; user review pending)
---
# Design QA — G06 搜索/邀请码加入
- source visual truth path: 已选 G06 题签检索视觉与 `docs/验收规划.md` 最新双模式/关系状态合同
- implementation screenshot path: `docs/design/screens/runtime/2026-07-16/G06-current-initial-360x800.png``G06-current-results-360x800.png``G06-current-invite-360x800.png`
- representative contact sheet: `docs/design/screens/handoff/2026-07-16/G06-current-initial-results-invite-contact-1080x800.png`
- viewport: 360×800;运行 smoke 另查 320×568、412×915
- state: 搜索初始/加载/结果/空/失败/清空,邀请码初始/无效/有效目标,六种用户关系动作
## Findings
没有遗留的 H5 P0/P1/P2 问题。
- 双模式:同一路由明确区分“搜索家谱”和“邀请码加入”,从 G01 邀请入口可直接进入邀请码状态。
- 识别信息:结果卡同时展示谱名、姓氏、地区、堂号、上级谱、支系、管理/认证、成员数和更新时间,降低同名谱误选。
- 关系动作:未加入、已加入、审核中、被拒绝、已退出/移除、我创建的六种状态各只有一个明确动作,不再统一显示“可申请”。
- 公开预览:未加入用户点结果卡进入 G05 `mode=public`;申请按钮单独进入 G08 搜索来源。
- 邀请流程:无效邀请码就近显示;有效目标明确提示确认关系后直接加入、无需等待审核。
- 实现边界:移除远程 `appApi`,当前只用本地展示数据审核样式;没有原生 UniApp 提示。
## Evidence limits
当前邀请码 `JP2026` 和六种结果均为样式数据,不证明真实搜索、邀请码有效性、成员继承规则、认证信息或接口权限已经接通。
## Implementation checklist
- [x] 搜索/邀请码双模式
- [x] 详细结果字段和六种关系动作
- [x] 搜索五类状态、清空与地区筛选位置
- [x] 邀请码无效/有效目标与 G08 来源合同
- [x] 三档 H5 响应式、真实点击与联系表
- [ ] Android/HBuilderX 真机或模拟器复核
- [ ] 用户验收
final result: passed (internal H5 candidate QA only; user review pending)
---
# Design QA — G01 我的家谱重新审核
- source visual truth path: 历史 G01 成熟视觉基准与 `docs/验收规划.md` 最新 G01 状态合同
- implementation screenshot path: `docs/design/screens/runtime/2026-07-16/G01-current-default-360x800.png``docs/design/screens/runtime/2026-07-16/G01-current-empty-360x800.png`
- representative contact sheet: `docs/design/screens/handoff/2026-07-16/G01-current-default-empty-contact-720x800.png`
- viewport: 360×800;运行 smoke 另查 320×568、412×915
- state: 默认三分组、空态三入口、loading、error、添加家谱弹层、当前谱切换层
## Findings
没有遗留的 H5 P0/P1/P2 问题。
- 视觉策略:保留现有成熟的朱砂顶栏、宣纸、题签、金线图标和水墨页脚,不为重审无必要地更换风格。
- 空态分流:搜索家谱为朱砂主入口,邀请码为快捷入口,创建为三级入口并明确提示先确认无现有家谱;三者始终同屏可见。
- 默认状态:我创建的、我加入的、加入申请三组均存在,加入申请示例覆盖审核中、被拒绝和已退出的位置与语义。
- 页面操作:添加家谱不再直接跳创建页,而是打开自定义三入口弹层;当前大卡支持自定义切换层。
- 兼容性:三档无横向溢出,底部 Tabbar 保留内容安全距离;短屏正常滚动。
## Evidence limits
当前使用本地展示数据验证页面样式,不代表全局家谱上下文已经持久化,也不代表真实申请状态、接口加载、Android 返回现场恢复或用户验收完成。
## Implementation checklist
- [x] 空态搜索/邀请码/创建三入口及优先级
- [x] 默认三分组与关键申请状态
- [x] loading/error 与自定义重新加载
- [x] 添加家谱弹层和当前谱切换层
- [x] 三档 H5 响应式、真实点击与截图 QA
- [ ] 全局家谱上下文真实持久化
- [ ] Android/HBuilderX 真机或模拟器复核
- [ ] 用户验收
final result: passed (internal H5 candidate QA only; user review pending)
---
# Design QA — A06 阻断登录状态
- source visual truth path: A01/A04/A05 红色宗祠水墨认证系列与 `docs/验收规划.md` A06 最新产品合同
- implementation screenshot path: `docs/design/screens/runtime/2026-07-16/A06-current-risk-412x915.png`
- viewport: 412×915;运行 smoke 另查 320×568、360×800
- state: 账号冻结、账号停用、风险限制、未知旧状态回落、自定义恢复说明层
## Findings
没有遗留的 H5 P0/P1/P2 问题。
- 产品边界:旧 normal/failed/register/wechat 状态已移除;A06 仅保留冻结、停用、风险限制,普通登录错误继续由 A01 就近承接。
- 信息完整性:每种状态明确展示限制原因、影响范围和恢复方式,不只给出模糊失败提示。
- 视觉与反馈:保持认证系列底板和品牌印;恢复入口使用自定义说明层,不调用原生 Toast/Modal。
- 尺寸适配:三档无横向溢出,320×568 按正常比例自然滚动。
## Evidence limits
当前结论仅为 HBuilderX H5 + Chrome 内部 QA,不等于用户验收;真实状态来源、账号申诉服务、Android 返回手势和字体放大仍待功能/真机阶段。
## Implementation checklist
- [x] 只保留冻结、停用、风险限制
- [x] 原因、影响范围、恢复入口完整
- [x] 未知旧状态安全回落到风险限制
- [x] 自定义恢复说明层与返回 A01
- [x] 三档 H5 响应式和运行烟测
- [ ] Android/HBuilderX 真机或模拟器复核
- [ ] 用户验收
final result: passed (internal H5 candidate QA only; user review pending)
---
# Design QA — A05 重设密码
- source visual truth path: A01/A04 红色宗祠水墨认证系列
- implementation screenshot path: `docs/design/screens/runtime/2026-07-16/A05-current-reset-412x915.png`
- viewport: 412×915;运行 smoke 另查 320×568、360×800
- state: 默认表单、手机号错误、发送验证码前验证、验证关闭保值、发送反馈、密码不一致、自定义成功结果
## Findings
没有遗留的 H5 P0/P1/P2 问题。
- 视觉连续性:使用同一宗祠水墨底板、独立品牌印、朱砂主按钮和宣纸表单,旧大面板已移除。
- 流程顺序:获取验证码先校验手机号,再打开自定义行为验证;重设提交不重复验证。
- 错误与结果:四字段错误就近显示,验证码发送反馈和重设成功结果均为项目内自定义组件,不使用原生 UniApp 提示。
- 尺寸适配:三档无横向溢出,320×568 保持正常比例并自然滚动。
## Evidence limits
当前结论仅为 HBuilderX H5 + Chrome 内部 QA,不等于用户验收;真实短信、行为验证令牌、重设接口、Android 软键盘与成功后手机号回填属于后续功能/真机复核。
## Implementation checklist
- [x] A01/A04 同系列视觉与真实资产
- [x] 发送验证码前自定义行为验证
- [x] 关闭验证保留表单与自定义发送反馈
- [x] 四字段页内校验与自定义成功结果
- [x] 三档 H5 响应式和运行烟测
- [ ] Android/HBuilderX 真机或模拟器复核
- [ ] 用户验收
final result: passed (internal H5 candidate QA only; user review pending)
---
# Design QA — A04 注册账号
- source visual truth path: A01 已选红色宗祠水墨视觉体系与 `docs/design/screens/handoff/2026-07-16/A01-current-password-sms-contact-824x915.png`
- implementation screenshot path: `docs/design/screens/runtime/2026-07-16/A04-current-register-412x915.png`
- viewport: 412×915;运行 smoke 另查 320×568、360×800
- state: 默认注册、空表单错误、协议错误、自定义行为验证、自定义反馈
## Findings
没有遗留的 H5 P0/P1/P2 问题。
- 视觉连续性:沿用 A01 的红色宗祠、水墨宣纸、独立 `brand-seal.png`、朱砂按钮与古金分隔,不再使用旧大厅与大面板。
- 表单层级:手机号、设置密码、确认密码三行保持清晰标签和输入提示;错误信息就近显示,不调用原生 UniApp 提示。
- 尺寸适配:三档宽高无横向溢出;320×568 按最新决定保持正常比例并自然纵向滚动。
- 流程验证:A01 注册入口、三字段校验、协议校验、自定义验证层、自定义反馈以及返回登录均通过浏览器运行烟测。
## Evidence limits
当前结论仅为 HBuilderX H5 + Chrome 内部 QA,通过不等于用户验收;Android 状态栏、安全区、软键盘、字体放大和返回手势仍需真机或模拟器复核。
## Implementation checklist
- [x] A01 同系列视觉与真实资产
- [x] 三字段就近校验与协议页内错误
- [x] 自定义验证层与反馈提示
- [x] 320×568 自然滚动、360×800 与 412×915 无横向溢出
- [x] H5 静态契约与真实运行烟测
- [ ] Android/HBuilderX 真机或模拟器复核
- [ ] 用户验收
final result: passed (internal H5 candidate QA only; user review pending)
---
# Design QA — A01 红色宗祠水墨登录页
- source visual truth path: `docs/design/screens/handoff/2026-07-16/A01-selected-password-red-hall-ink-412x915.png``docs/design/screens/handoff/2026-07-16/A01-selected-sms-red-hall-ink-412x915.png`
- implementation screenshot path: `docs/design/screens/runtime/2026-07-16/A01-password-final-412x915.png``docs/design/screens/runtime/2026-07-16/A01-sms-final-412x915.png`
- viewport: 412×915;另查 320×568、360×640、360×800
- state: 密码登录、短信验证码登录;运行 smoke 另查协议页内错误与自定义行为验证层
- full-view comparison evidence: `docs/design/screens/runtime/2026-07-16/A01-password-compare-final.png``docs/design/screens/runtime/2026-07-16/A01-sms-compare-final.png`
- focused region comparison evidence: 全页比较以原始 412px 宽度并排显示,顶部宗祠、Logo、输入图标和协议文字均可辨认;没有需要额外放大的高密度区域。
## Findings
没有遗留的 P0/P1/P2 问题。
- 字体与层级:标题、Tab、输入文案、主按钮和辅助文案已形成与选定稿一致的层级;中文字体受 H5 系统宋体渲染影响存在轻微字形差异,归为 P3。
- 间距与布局:第二轮已扩大标题和表单节奏,协议区不再过早结束;四档宽度无横向溢出,320×568 按用户最新决定自然纵向滚动。
- 颜色与令牌:朱砂红、暗红宗祠、象牙宣纸、淡墨山水和旧金线条与选定稿一致。
- 图片质量:宗祠水墨底板为 824×1830 PNG`brand-seal.png` 独立叠放,未让生成模型重绘“谱”字;锁、闭眼瞳孔、短信气泡三点和微信均使用真实资产。
- 文案与内容:密码/短信标签、短信验证码、获取验证码、忘记密码、微信登录、注册和协议文案齐全。
- 交互与可达性:密码/短信状态切换、密码显隐、协议页内错误、自定义行为验证层均通过浏览器 smoke;截图不能证明读屏顺序和 Android 真机触摸体验。
## Comparison history
1. v1 P1:标题、Tab、输入和按钮文字明显偏小,表单整体比设计稿提前约 90px 结束;已扩大字号并重排垂直节奏。
2. v2 P2:宗祠区比设计稿高约 17px;已对新底板分段缩放,保持总尺寸不变。
3. v3 P2:短信气泡、微信图标偏小且输入图标槽位偏右;已在固定槽位内调整显示比例和视觉位移。
4. final:密码/短信同尺寸比较未发现新的 P0/P1/P2。
## Evidence limits
本轮是 HBuilderX H5 + Chrome 调试截图,不能替代 Android/HBuilderX 真机或模拟器对状态栏、安全区、软键盘、系统字体放大、读屏和返回手势的最终复核,也不代表用户已经验收。
## Implementation checklist
- [x] 密码/短信共用红色宗祠水墨底板
- [x] 独立使用现有 `brand-seal.png`
- [x] 密码锁、闭眼瞳孔和横线下方忘记密码
- [x] 短信气泡三点和获取验证码
- [x] 320×568 自然滚动与四档无横向溢出
- [x] 协议页内错误和自定义行为验证层 smoke
- [ ] Android/HBuilderX 真机或模拟器复核
- [ ] 用户验收
final result: passed
---
# Design QA — G05 家谱总览 # Design QA — G05 家谱总览
- source visual truth path: `docs/design/screens/runtime/2026-07-14/g-module-audit/before/01-g05-before-360x800.png``static/assets/modules/genealogy/opaque/g05-overview-surface.png`、外部思维导图与 `家谱主页.png` 的信息层级参考 - source visual truth path: `docs/design/screens/runtime/2026-07-14/g-module-audit/before/01-g05-before-360x800.png``static/assets/modules/genealogy/opaque/g05-overview-surface.png`、外部思维导图与 `家谱主页.png` 的信息层级参考
@@ -0,0 +1,45 @@
{
"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": []
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 498 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 477 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 479 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 488 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 626 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 476 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 451 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 693 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 424 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 475 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 420 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 497 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 496 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 698 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 493 KiB

+15 -1
View File
@@ -2,7 +2,21 @@
## 当前换机检查点 ## 当前换机检查点
- A01 当前短信登录(待审核,不代表通过):[412×915 真实运行代表图](screens/handoff/2026-07-15/A01-current-sms-generated-412x915.png) - A01 当前密码/短信登录联系表(待审核,不代表通过):[824×915 真实运行代表图](screens/handoff/2026-07-16/A01-current-password-sms-contact-824x915.png)
- A04 当前注册页(待审核,不代表通过):[412×915 真实运行代表图](screens/handoff/2026-07-16/A04-current-register-412x915.png)
- A05 当前重设密码页(待审核,不代表通过):[412×915 真实运行代表图](screens/handoff/2026-07-16/A05-current-reset-412x915.png)
- A06 当前风险限制页(待审核,不代表通过):[412×915 真实运行代表图](screens/handoff/2026-07-16/A06-current-risk-412x915.png)
- G01 当前默认/空态联系表(待审核,不代表通过):[720×800 真实运行联系表](screens/handoff/2026-07-16/G01-current-default-empty-contact-720x800.png)
- G06 当前搜索初始/结果/邀请码联系表(待审核,不代表通过):[1080×800 真实运行联系表](screens/handoff/2026-07-16/G06-current-initial-results-invite-contact-1080x800.png)
- G08 当前搜索申请/邀请码直加联系表(待审核,不代表通过):[720×800 真实运行联系表](screens/handoff/2026-07-16/G08-current-search-invite-contact-720x800.png)
- G09 当前申请列表/空态联系表(待审核,不代表通过):[720×800 真实运行联系表](screens/handoff/2026-07-16/G09-current-list-empty-contact-720x800.png)
- G03 当前创建/首代人物联系表(待审核,不代表通过):[720×800 真实运行联系表](screens/handoff/2026-07-16/G03-current-create-ancestor-contact-720x800.png)
- G05 当前成员总览/公开预览联系表(待审核,不代表通过):[720×800 真实运行联系表](screens/handoff/2026-07-16/G05-current-member-public-contact-720x800.png)
- G10 当前审核列表/无权限联系表(待审核,不代表通过):[720×800 真实运行联系表](screens/handoff/2026-07-16/G10-current-list-permission-contact-720x800.png)
- G11 当前设置/无权限联系表(待审核,不代表通过):[720×800 真实运行联系表](screens/handoff/2026-07-16/G11-current-form-permission-contact-720x800.png)
- G12 当前字辈列表/编辑联系表(待审核,不代表通过):[720×800 真实运行联系表](screens/handoff/2026-07-16/G12-current-list-edit-contact-720x800.png)
- T01 当前世系树/空态联系表(待审核,不代表通过):[720×800 真实运行联系表](screens/handoff/2026-07-16/T01-current-tree-empty-contact-720x800.png)
- T04 当前新增亲属表单/成功态联系表(待审核,不代表通过):[720×800 真实运行联系表](screens/handoff/2026-07-16/T04-current-form-success-contact-720x800.png)
- 当前实际为 53 页全部重新待审核;下方旧“已验收”图片只作为历史视觉基础,不计入本轮完成状态。 - 当前实际为 53 页全部重新待审核;下方旧“已验收”图片只作为历史视觉基础,不计入本轮完成状态。
> 本仓库只保留低负担交接证据:每页一张代表图,复杂模块使用联系表。完整 before/after、多尺寸、历史废稿和全部运行截图保存在项目外本地归档,不进入 Git。 > 本仓库只保留低负担交接证据:每页一张代表图,复杂模块使用联系表。完整 before/after、多尺寸、历史废稿和全部运行截图保存在项目外本地归档,不进入 Git。
@@ -0,0 +1,102 @@
# A01 资产优先设计落地计划
> 本计划由 `superpowers:writing-plans` 生成并在当前会话内直接执行。遵守用户最新约束:不使用子代理、不使用 worktree、不执行 `git add/commit/push/reset/checkout`,资产与设计验收通过前不修改 A01 页面代码。
**目标:** 以冻结的密码/验证码视觉真源为依据,制作一套可投入 uni-app Android 的 A01 最终资产,并使用同一批资产组装可编辑 Figma 设计。
**架构:** 密码与验证码状态共用唯一的背景、宗祠、卷轴、纹理、按钮和排版组件,仅切换标签、第二输入行和辅助操作。复杂装饰使用最终 PNG,文字、输入框布局、点击区域和状态切换保持可编辑;Figma 和应用后续必须引用相同资产文件与尺寸规则。
**技术栈:** Figma Design、Figma Plugin API、ImageGen 位图资产、uni-app/Vue、Android WebView/HBuilderX。
## 全局约束
- 唯一 Logo`static/assets/foundation/transparent/brand-seal.png`;后续 Android 桌面图标也以它为源。
- 密码状态:左侧使用 `static/assets/modules/auth/transparent/a01-icon-lock-v1.png`,右侧是眼睛按钮,“忘记密码”在输入横线下方右对齐。
- 验证码状态:使用“消息气泡+三点”图标和“获取验证码”,不显示眼睛或忘记密码。
-`brand-seal``a01-icon-lock-v1` 外,A01 可见装饰资产全部制作新版本;旧文件保留但最终不引用。
- 不使用原生 UniApp 提示组件作为最终视觉;本阶段不做接口对接。
- 基础版本只覆盖当前浅色国风主题,以及 320×568、360×640、360×800、412×915;深色与跟随系统延期到下一版本。
- 设计审核必须基于真实截图;H5 仅为候选证据,最终需要 Android/HBuilderX 复核。
---
### 任务 1:冻结视觉真源
**文件:**
- 密码:`docs/design/screens/handoff/2026-07-15/A01-visual-source-password-selected-v2-841x1870.png`
- 验证码:`docs/design/screens/handoff/2026-07-15/A01-visual-source-sms-selected-v1-841x1870.png`
**验收:**
- 密码稿 SHA256`A93F201349C1BB1F2DFAB50BA0B5372A6FC1C4762661326ADE17AA29F31960EF`
- 验证码稿 SHA256`31D023A523DA8CA25DF00736793FAF40086B5B46F32664B724E570F432BF9D9E`
- 两张均为 `841×1870`,后续按 `412×915` 等比参考,不从聊天缩略图反推。
### 任务 2:建立 Figma 视觉真源与资产板
**目标文件:** [A01 登录页重设计(可编辑)](https://www.figma.com/design/fWhC0maHCxh5STQsjlOoWw)
**页面结构:**
- `00 视觉真源`:密码与验证码全尺寸参考图并排放置,标注文件名和 SHA256。
- `01 资产板`:Logo、宗祠、卷轴分片、纹理、标题纹样、按钮分片、输入图标、Android 图标安全区。
- `02 页面状态`:密码隐藏、密码显示、验证码默认、验证码倒计时。
- `03 响应式验收`:当前浅色国风主题的四档尺寸状态。
**验收:** 每个 Figma 写入步骤返回节点 ID,并对新增 Frame 生成截图检查裁切、比例、文字和图标。
### 任务 3:制作新资产
**候选目录:** `docs/design/assets/a01-vnext/candidates/`
**最终目录:**
- `static/assets/modules/auth/opaque/`
- `static/assets/modules/auth/transparent/`
- `static/assets/foundation/opaque/`
**资产边界:**
- 宗祠头图:独立不透明宽图,包含 `brand-seal` 安全槽,但 Logo 本体仍单独引用。
- 卷轴:顶部卷轴、中央纸纹理、左右边框、底部卷轴分离,禁止整张卷轴跨尺寸压缩。
- 按钮:左端、中部可延展纹理、右端分离,避免边角被拉伸。
- 图标:手机、开眼、闭眼保留瞳孔、消息气泡三点制作新版本;锁继续使用 `a01-icon-lock-v1.png`
- Android 图标:以 `brand-seal.png` 生成自适应图标前景、安全区预览与纯色背景方案,不改变 Logo 本体。
**验收:** 检查像素尺寸、ARGB/Alpha、透明边角、主体覆盖率、边缘杂色和小尺寸可读性;不合格资产不进入 `static`
### 任务 4:用最终资产组装可编辑状态
**Figma Frame**
- `A01 / Password / Hidden / 412×915`
- `A01 / Password / Visible / 412×915`
- `A01 / SMS / Default / 412×915`
- `A01 / SMS / Countdown / 412×915`
**状态差异:**
- 密码隐藏:掩码文本、闭眼保留瞳孔。
- 密码显示:明文文本、开眼图标。
- 验证码默认:气泡三点图标、“获取验证码”。
- 验证码倒计时:气泡三点图标、不可点击倒计时文案。
**验收:** 背景和共用资产必须为同一组件实例;切换状态时公共区域位置不得漂移。
### 任务 5:设计验收后进入代码实现
**后续才允许修改:**
- `pages/auth/a01-entry.vue`
- A01 最终资产引用
- `manifest.json` 或 HBuilderX Android 图标配置
- `tests/a01-a02-ui-contract.ps1`
**验证命令:**
```powershell
powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests/a01-a02-ui-contract.ps1
```
**完成标准:** 合同测试通过,四档 H5 截图通过视觉比较,并取得 Android/HBuilderX 真机或模拟器截图;在此之前不得声明 A01 完成或验收通过。
@@ -0,0 +1,199 @@
# A01 纯代码资产流水线与页面落地实施计划
> **For agentic workers:** REQUIRED SUB-SKILL: Use `superpowers:executing-plans` to implement this plan task-by-task. 本项目明确禁止 worktree、子代理和任何 Git 提交操作,执行时以项目规则为准。
**Goal:** 不依赖 Photoshop,用同一份清单和源资产生成 A01 正式 PNG、设计预览与状态联系表,并将选定视觉一比一落到 `pages/auth/a01-entry.vue`
**Architecture:** `design-pipeline/manifests/a01.json` 是 A01 源资产、正式输出、状态和尺寸的唯一机器可读所有者。Node.js 调用 Sharp 完成缩放、PNG 优化、同源预览与联系表生成;A01 页面只引用确认后的正式资产,真实运行截图仍是最终视觉证据。
**Tech Stack:** Node.js 24、Sharp、PowerShell 契约测试、Vue 3 `<script setup>`、uni-app、SCSS。
## Global Constraints
- 当前只修改 A01、新流水线、A01 测试和 A01 证据,不修改其他页面。
- 基础版本只支持当前浅色国风主题;深色与跟随系统延期到下一版本。
- 保留全部 Photoshop、PSD、候选图、未跟踪文件和用户已有修改,不删除、不移动、不覆盖。
- 不接接口;密码、短信、微信和行为验证只实现当前页面样式与 Mock 状态。
- `brand-seal.png``a01-icon-lock-v1.png` 继续作为唯一正式来源,不重绘。
- 闭眼图标保留中心瞳孔;短信图标是消息气泡并且仅有三个点。
- 320×568 下核心入口同屏且页面不依赖纵向滚动;同时检查 360×640、360×800、412×915。
- 所有可见提示使用项目自定义组件,不使用原生 UniApp Toast、Modal、Loading 或 ActionSheet。
- 不执行 `git add`、commit、push、reset、checkout、上传、worktree 或多代理。
---
### Task 1: 建立纯代码流水线契约
**Files:**
- Create: `tests/a01-code-pipeline-contract.ps1`
- Create later: `design-pipeline/package.json`
- Create later: `design-pipeline/manifests/a01.json`
- Create later: `design-pipeline/scripts/build-a01.mjs`
**Interfaces:**
- Consumes: 当前工作区和 A01 规格。
- Produces: 缺少入口、清单、脚本或正式输出时失败,全部满足时输出 `A01-CODE-PIPELINE-CONTRACT PASS`
- [ ] **Step 1: 写入失败契约测试**
测试必须断言:入口文件存在;清单 `schemaVersion=1`;逻辑画布为 412×915;状态包含 `password-hidden``password-visible``sms-default``sms-countdown`;正式输出全部位于 `static/assets/modules/auth/`;运行资产不存在 Photoshop/PSD/整页截图依赖;每个正式 PNG 存在且尺寸、Alpha 和最大体积符合清单。
- [ ] **Step 2: 运行 RED**
```powershell
powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests/a01-code-pipeline-contract.ps1
```
Expected: FAIL,首个原因是缺少 `design-pipeline/package.json``design-pipeline/manifests/a01.json`
---
### Task 2: 实现 A01 同源资产生成器
**Files:**
- Create: `design-pipeline/package.json`
- Create: `design-pipeline/package-lock.json`(由 `npm.cmd install` 生成)
- Create: `design-pipeline/manifests/a01.json`
- Create: `design-pipeline/scripts/build-a01.mjs`
- Generate: `design-pipeline/generated/a01/`
- Generate: `static/assets/modules/auth/opaque/a01-vnext-header-v1.png`
- Generate: `static/assets/modules/auth/transparent/a01-vnext-scroll-v1.png`
- Generate: `static/assets/modules/auth/transparent/a01-vnext-title-ornament-v1.png`
- Generate: `static/assets/modules/auth/transparent/a01-vnext-divider-v1.png`
- Generate: `static/assets/modules/auth/opaque/a01-vnext-primary-button-v1.png`
- Generate: `static/assets/modules/auth/opaque/a01-vnext-secondary-button-v1.png`
- Generate: `static/assets/modules/auth/transparent/a01-vnext-phone-v1.png`
- Generate: `static/assets/modules/auth/transparent/a01-vnext-eye-open-v1.png`
- Generate: `static/assets/modules/auth/transparent/a01-vnext-eye-closed-pupil-v1.png`
- Generate: `static/assets/modules/auth/transparent/a01-vnext-sms-three-dots-v1.png`
**Interfaces:**
- Consumes: `docs/design/assets/a01-vnext/candidates/*.png`、现有 `brand-seal.png`、现有锁和微信资产。
- Produces: 正式槽位尺寸 PNG、四尺寸预览、密码/短信联系表及 `build-report.json`
- [ ] **Step 1: 声明最小依赖**
`design-pipeline/package.json` 只包含 `sharp``build:a01` 命令,不引入前端框架或通用构建系统。
- [ ] **Step 2: 安装 Sharp**
```powershell
Set-Location design-pipeline
npm.cmd install
```
Expected: 生成锁文件,`npm.cmd audit` 不报告高危漏洞。
- [ ] **Step 3: 写入 A01 清单**
清单明确每个源文件、正式输出、目标像素尺寸、Alpha、最大体积、复用角色和四个页面状态;正式资源不得指向 `review/page-v2` 的整页 PNG 或 PSD。
- [ ] **Step 4: 实现生成脚本**
脚本必须:校验所有源文件;拒绝源输出路径越出工作区;用 Sharp 生成版本化 PNG;用同一正式 PNG 组合 412×915 密码/短信设计预览;生成四尺寸检查图;输出尺寸、Alpha、SHA256 和体积报告;任一失败返回非零退出码。
- [ ] **Step 5: 生成并验证 GREEN**
```powershell
npm.cmd --prefix design-pipeline run build:a01
powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests/a01-code-pipeline-contract.ps1
```
Expected: `A01-CODE-PIPELINE BUILD PASS``A01-CODE-PIPELINE-CONTRACT PASS`
---
### Task 3: 先收紧 A01 页面契约
**Files:**
- Modify: `tests/a01-a02-ui-contract.ps1`
- Modify later: `pages/auth/a01-entry.vue`
**Interfaces:**
- Consumes: Task 2 的正式资产文件名与 A01 验收合同。
- Produces: 页面没有引用新资产、仍依赖旧头部/旧卷轴、缺少小屏同屏结构或恢复原生提示时失败。
- [ ] **Step 1: 更新页面契约断言**
要求 A01 引用全部 `a01-vnext-*` 正式资源、`brand-seal.png`、现有锁、闭眼瞳孔和短信三点图标;禁止引用 `a01-login-header-exact-v2.png`、旧卷轴框、PSD、设计预览和整页截图;要求密码/短信独立状态、协议页内错误、验证码倒计时文案和自定义行为验证层继续存在。
- [ ] **Step 2: 运行 RED**
```powershell
powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests/a01-a02-ui-contract.ps1
```
Expected: FAIL,原因是 A01 尚未引用 `a01-vnext-header-v1.png`
---
### Task 4: 一比一落地 A01 页面
**Files:**
- Modify: `pages/auth/a01-entry.vue`
- Test: `tests/a01-a02-ui-contract.ps1`
**Interfaces:**
- Consumes: Task 2 正式资产、选定联系表和 Task 3 契约。
- Produces: 可交互的密码/短信登录页;首次密码态、上次方式恢复、眼睛切换、验证码按钮状态、协议错误、行为验证层和自定义反馈保持可用。
- [ ] **Step 1: 替换视觉壳**
页面结构固定为宗祠头部、独立 `brand-seal`、完整卷轴容器、标题装饰、Tab、两行表单、主按钮、其他登录方式、微信入口、注册和协议。所有文字保持 DOM 文本和真实控件,不把整页预览当背景。
- [ ] **Step 2: 保持两种登录状态**
密码态使用现有锁、显示/隐藏眼睛和横线下方忘记密码;短信态使用消息气泡三点图标、验证码输入和“获取验证码/Ns 后重试”位置,不显示眼睛或忘记密码。
- [ ] **Step 3: 实现四尺寸响应式布局**
默认以 412×915 为视觉基准;对高度不超过 640px 的设备压缩宗祠头部、标题装饰和非触控间距,不把输入框、按钮、协议或交互热区压缩到约 44dp 以下;根容器禁止因主体布局产生纵向滚动。
- [ ] **Step 4: 运行 GREEN**
```powershell
powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests/a01-a02-ui-contract.ps1
powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests/a01-code-pipeline-contract.ps1
```
Expected: 两项均 PASS。
---
### Task 5: 真实运行截图与视觉闭环
**Files:**
- Generate: `docs/design/screens/runtime/2026-07-15/A01-*.png`
- Modify: `design-qa.md`
- Preserve: `docs/design/assets/a01-vnext/review/page-v2/A01-page-password-sms-contact-v2-824x915.png`
**Interfaces:**
- Consumes: 已运行的 A01 H5、选定联系表、四尺寸与状态动作。
- Produces: 真实密码/短信截图、四尺寸内部证据和基于同视口比较的 QA 结论。
- [ ] **Step 1: 确认 H5 服务与真实路由**
```powershell
Invoke-WebRequest 'http://localhost:5173/#/pages/auth/a01-entry' -UseBasicParsing
```
Expected: HTTP 200;若服务未运行,只报告需要 HBuilderX 启动,不伪造截图。
- [ ] **Step 2: 捕获密码/短信状态**
使用现有 `scripts/capture-chrome-page.js` 捕获 412×915 两种状态,并生成一张联系表;再内部捕获 320×568、360×640、360×800。
- [ ] **Step 3: 执行 Product Design 视觉比对**
把选定联系表与真实运行联系表放在同一比较输入中,检查结构、比例、间距、字体、裁切、触控区和资源清晰度。P0/P1/P2 必须修复后重截;无法获取真实截图时把 `design-qa.md` 标为 `blocked`,不得写 `passed`
- [ ] **Step 4: 最终验证**
```powershell
powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests/a01-code-pipeline-contract.ps1
powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests/a01-a02-ui-contract.ps1
git diff --check
git status --short
```
Expected: 两项测试 PASS、无空白错误;A01 仍保持待用户验收,不修改其他页面状态。
@@ -0,0 +1,380 @@
# A01 分层 PSD 与正式 PNG 资产实施计划
> **执行要求:** 只允许在当前会话内使用 `superpowers:executing-plans` 逐任务执行。用户明确禁止子代理、worktree、`git add`、`commit`、`push`、`reset`、`checkout` 和上传,因此本计划不包含这些动作;每个任务以测试输出和用户视觉确认作为检查点。
**目标:** 使用本机 Adobe Photoshop 2021 建立 A01 真正分层的 PSD,并从同一 PSD 导出可直接投入 uni-app 的干净 PNG 资产与密码/验证码代表图。
**架构:** `a01-psd-manifest.json` 是 PSD 图层、坐标、导出尺寸和文件路径的唯一机器可读所有者;PowerShell 负责校验环境并调用 Photoshop COMExtendScript 负责建立 PSD、切换状态和导出。ImageGen 只逐项生成缺少的原始美术层,所有候选先进入设计候选目录,经检查和用户确认后才进入 PSD 与正式资产目录。
**技术栈:** Windows PowerShell、Adobe Photoshop 2021 COM、ExtendScript JSX、PNG/PSD、ImageGen、uni-app 现有 PowerShell 合同测试。
## 全局约束
- 当前只处理 A01 分层 PSD 和 PNG 资产,不修改 `pages/auth/a01-entry.vue`,不进行接口对接。
- 不使用 Figma,不把整张效果图作为页面资产,不从扁平图硬裁被遮挡的脏素材。
- PSD 工作画布为 `1236 × 2745`,对应 `412 × 915` 的三倍密度基准。
- `brand-seal` 唯一来源为 `static/assets/foundation/transparent/brand-seal.png`
- 宗祠背景不得生成空牌匾、替代牌匾或新 Logo 底座;现有 `brand-seal.png` 直接作为独立图层叠放在宗祠中央。
- 密码锁唯一复用来源为 `static/assets/modules/auth/transparent/a01-icon-lock-v1.png`
- 闭眼图标必须保留中心瞳孔;短信验证码图标必须是消息气泡加且仅加三个点。
- 密码和验证码为独立图层组,共用背景、卷轴、品牌和按钮资产。
- 所有生成资产先写入 `docs/design/assets/a01-vnext/candidates/`,不得覆盖现有候选或正式文件。
- Photoshop 自动化不得保存、关闭或覆盖用户原先打开的文档,只处理脚本自己创建或打开的临时文档。
- 没有 PSD 图层复核、PNG Alpha 检查、412 × 915 代表图和用户确认,不得宣称资产完成。
## 文件边界
**新建:**
- `docs/design/assets/a01-vnext/source/a01-psd-manifest.json`:图层、组、位置、状态和导出规格的唯一机器可读所有者。
- `docs/design/assets/a01-vnext/source/A01-layered-source-v1.psd`:最终分层源文件。
- `scripts/photoshop/a01-build-layered-psd.jsx`:按清单建立 PSD、组和图层。
- `scripts/photoshop/a01-export-layered-assets.jsx`:从 PSD 导出 PNG 与四状态代表图。
- `scripts/build-a01-layered-psd.ps1`:验证环境和路径后调用构建 JSX。
- `scripts/export-a01-layered-assets.ps1`:调用导出 JSX 并校验输出。
- `tests/a01-layered-psd-contract.ps1`:审计清单、脚本、PSD 和 PNG 契约。
- `docs/design/assets/a01-vnext/review/`:透明检查图、联系表和代表图候选。
**仅在资产通过用户确认后修改:**
- `docs/design/A01_启动登录引导_设计记录.md`
- `docs/design/P00_页面结构与资产清单.md`
- `docs/交接记录.md`
**本计划禁止修改:**
- `pages/auth/a01-entry.vue`
- `pages.json`
- A01 之外的页面、测试和资产
---
### 任务 1:建立 PSD 资产契约测试
**文件:**
- 新建:`tests/a01-layered-psd-contract.ps1`
- 读取:`docs/superpowers/specs/2026-07-15-a01-layered-psd-assets-design.md`
**接口:** 缺少文件或契约时抛出明确错误;全部满足时输出 `A01-LAYERED-PSD-CONTRACT PASS`
- [ ] **步骤 1:编写失败测试**
测试至少包含以下可执行断言:
```powershell
$ErrorActionPreference = 'Stop'
$root = Split-Path -Parent $PSScriptRoot
$requiredFiles = @(
'docs/design/assets/a01-vnext/source/a01-psd-manifest.json',
'scripts/build-a01-layered-psd.ps1',
'scripts/export-a01-layered-assets.ps1',
'scripts/photoshop/a01-build-layered-psd.jsx',
'scripts/photoshop/a01-export-layered-assets.jsx'
)
foreach ($relative in $requiredFiles) {
if (-not (Test-Path -LiteralPath (Join-Path $root $relative))) {
throw "Missing layered PSD contract file: $relative"
}
}
$manifestPath = Join-Path $root $requiredFiles[0]
$manifest = Get-Content -Raw -Encoding UTF8 -LiteralPath $manifestPath | ConvertFrom-Json
if ($manifest.canvas.width -ne 1236 -or $manifest.canvas.height -ne 2745) {
throw 'PSD canvas must be 1236x2745.'
}
foreach ($group in @('00-参考','10-背景','20-卷轴','30-品牌与标题装饰','40-公共控件皮肤','50-密码登录','60-验证码登录','70-内容与标注')) {
if ($manifest.groups.name -notcontains $group) { throw "Missing PSD group: $group" }
}
foreach ($state in @('password-hidden','password-visible','sms-default','sms-countdown')) {
if ($manifest.states.name -notcontains $state) { throw "Missing PSD state: $state" }
}
Write-Output 'A01-LAYERED-PSD-CONTRACT PASS'
```
- [ ] **步骤 2:运行测试并确认按预期失败**
```powershell
powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests/a01-layered-psd-contract.ps1
```
预期:失败并显示 `Missing layered PSD contract file`
- [ ] **步骤 3:观察并保留工作区状态**
```powershell
git status --short
```
预期:只记录输出,不清理、暂存或提交任何文件。
---
### 任务 2:建立清单和 Photoshop 自动化空壳
**文件:**
- 新建:`docs/design/assets/a01-vnext/source/a01-psd-manifest.json`
- 新建:`scripts/build-a01-layered-psd.ps1`
- 新建:`scripts/export-a01-layered-assets.ps1`
- 新建:`scripts/photoshop/a01-build-layered-psd.jsx`
- 新建:`scripts/photoshop/a01-export-layered-assets.jsx`
- 测试:`tests/a01-layered-psd-contract.ps1`
**接口:**
- 清单提供 `canvas``groups``layers``states``exports`
- `build-a01-layered-psd.ps1 -ManifestPath <path> -OutputPath <path>` 使用 `Photoshop.Application.150`
- `export-a01-layered-assets.ps1 -ManifestPath <path> -PsdPath <path>` 使用同一 COM 接口。
- JSX 从 `JIAPU_A01_ROOT``JIAPU_A01_MANIFEST``JIAPU_A01_PSD` 读取路径,不硬编码用户名。
- [ ] **步骤 1:创建最小清单**
```json
{
"schemaVersion": 1,
"canvas": { "width": 1236, "height": 2745, "logicalWidth": 412, "logicalHeight": 915, "scale": 3 },
"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": []
}
```
- [ ] **步骤 2:创建 PowerShell COM 包装**
核心调用固定为:
```powershell
$app = New-Object -ComObject 'Photoshop.Application.150'
$env:JIAPU_A01_ROOT = $root
$env:JIAPU_A01_MANIFEST = $resolvedManifest
$env:JIAPU_A01_PSD = $resolvedOutput
$jsx = (Resolve-Path -LiteralPath (Join-Path $root 'scripts/photoshop/a01-build-layered-psd.jsx')).Path
$escaped = $jsx.Replace('\', '\\')
$app.DoJavaScript("$.evalFile(new File('$escaped'));", @(), 1)
```
调用前验证 Photoshop 正在运行、输入输出位于项目根目录,并拒绝覆盖不符合 `A01-layered-source-v*.psd` 的文件。
- [ ] **步骤 3:创建 JSX 空壳构建器**
```javascript
#target photoshop
app.displayDialogs = DialogModes.NO;
var created = null;
try {
var manifestFile = File($.getenv('JIAPU_A01_MANIFEST'));
var psdFile = File($.getenv('JIAPU_A01_PSD'));
manifestFile.encoding = 'UTF8';
manifestFile.open('r');
var manifest = eval('(' + manifestFile.read() + ')');
manifestFile.close();
created = app.documents.add(manifest.canvas.width, manifest.canvas.height, 72, 'JIAPU_A01_SCRIPT_SOURCE', NewDocumentMode.RGB, DocumentFill.TRANSPARENT);
for (var i = 0; i < manifest.groups.length; i++) {
var group = created.layerSets.add();
group.name = manifest.groups[i].name;
group.visible = manifest.groups[i].visible;
}
var options = new PhotoshopSaveOptions();
options.layers = true;
created.saveAs(psdFile, options, true, Extension.LOWERCASE);
created.close(SaveOptions.DONOTSAVECHANGES);
created = null;
} catch (error) {
if (created) { created.close(SaveOptions.DONOTSAVECHANGES); }
throw error;
}
```
- [ ] **步骤 4:运行契约测试**
```powershell
powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests/a01-layered-psd-contract.ps1
```
预期:`A01-LAYERED-PSD-CONTRACT PASS`
- [ ] **步骤 5:运行空壳 PSD 冒烟检查**
```powershell
powershell.exe -NoProfile -ExecutionPolicy Bypass -File scripts/build-a01-layered-psd.ps1
```
预期:生成 `docs/design/assets/a01-vnext/source/A01-layered-source-v1.psd`,Photoshop 中包含八个命名组;脚本执行前已打开的用户文档未被保存或关闭。
---
### 任务 3:制作三类母图并由 Photoshop 拆出干净图层
**文件:**
- 新建候选:`docs/design/assets/a01-vnext/candidates/` 下的版本化 PNG
- 修改:`docs/design/assets/a01-vnext/source/a01-psd-manifest.json`
- 新建检查图:`docs/design/assets/a01-vnext/review/`
**接口:** ImageGen 只生成完整无字卷轴、无字按钮与装饰板、四枚功能图标板三类母图;Photoshop 将母图拆成每个只承载一个清单图层的 PNG。每个 `layers` 项包含 `name``group``source``x``y``width``height``visible``exportName``alpha`
- [ ] **步骤 1:制作宗祠背景候选**
使用用户已认可的宗祠母图,不再重新生成。保留 ImageGen 原始母图,再由 Photoshop 裁剪为 `1236 × 510` 候选;现有 `brand-seal.png` 后续作为独立图层叠放。
验收:中央为 `brand-seal` 留出干净视觉空间,但建筑本身不出现空牌匾、替代牌匾或新 Logo 底座;四边没有被截断的文字或控件。
- [ ] **步骤 2:生成一张完整无字卷轴母图并拆分**
一次生成完整无字卷轴母图,使用可清除的纯色背景。Photoshop 从同一母图拆出顶部卷轴、无字宣纸平铺纹理、左边框、右边框和底部卷轴,不从完整登录页面截图裁切。
验收:在纯黑、纯白和项目暖宣纸底色上检查透明边缘;拼接处没有亮线、断纹或重复阴影。
- [ ] **步骤 3:生成一张无字按钮与装饰母图并拆分**
主按钮先形成无字完整皮肤,再由 Photoshop 分出左端、可横向延展中段和右端;标题云纹、分隔纹样和四角回纹分别独立。
验收:按钮三段重新拼接后与完整皮肤一致;所有纹样不包含文字、Logo 或不可复用背景。
- [ ] **步骤 4:生成一张四图标母板并拆分**
在同一高分辨率纯色背景母板中生成彼此完全分离的手机、睁眼、保留瞳孔的闭眼、短信气泡三点图标,再由 Photoshop 分别导出;密码锁继续引用 `a01-icon-lock-v1.png`Logo 继续引用 `brand-seal.png`。微信标志不由 ImageGen 重绘,继续使用具有来源记录的项目资产,只重新制作其容器皮肤。
验收:目标显示尺寸下仍清楚;短信气泡只有三个点;闭眼中心瞳孔可见。
- [ ] **步骤 5:每类输出一张检查联系表并等待确认**
联系表包含原尺寸、白底、黑底和页面底色预览。未收到用户确认前,不把该类候选写入正式导出清单,也不修改 A01 页面。
---
### 任务 4:组装真实 PSD
**文件:**
- 修改:`docs/design/assets/a01-vnext/source/a01-psd-manifest.json`
- 修改:`scripts/photoshop/a01-build-layered-psd.jsx`
- 生成:`docs/design/assets/a01-vnext/source/A01-layered-source-v1.psd`
- 测试:`tests/a01-layered-psd-contract.ps1`
**接口:** JSX 读取 `layers`,打开 PNG,复制到目标组并设置名称、位置和可见性。目标层名与 `exportName` 一一对应;每次从清单重建,不在旧 PSD 上堆叠。
- [ ] **步骤 1:扩展失败测试**
验证每个源文件存在,坐标和尺寸为整数,源路径位于工作区,`exportName` 唯一,且可导出层属于 `10``60` 的合法组。
- [ ] **步骤 2:先运行测试确认不完整清单失败**
```powershell
powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests/a01-layered-psd-contract.ps1
```
预期:报告第一项缺失或重复的图层契约。
- [ ] **步骤 3:实现 PNG 导入与精确定位**
JSX 只复制脚本打开的源 PNG 合并像素层,复制后立即关闭源文档且不保存;位置按 `x × 3``y × 3` 和清单目标尺寸设置,不手工拖动猜测。
- [ ] **步骤 4:重建并复核 PSD**
```powershell
powershell.exe -NoProfile -ExecutionPolicy Bypass -File scripts/build-a01-layered-psd.ps1
```
预期:八个组顺序正确,密码和验证码可单独显示,公共图层只有一份,参考图默认隐藏。
- [ ] **步骤 5:确认用户原文档未受影响**
检查 Photoshop:脚本运行前用户已打开文档的数量、名称和未保存状态保持不变;脚本临时文档已关闭。
---
### 任务 5:从 PSD 导出 PNG 与四状态代表图
**文件:**
- 修改:`scripts/photoshop/a01-export-layered-assets.jsx`
- 生成候选:`docs/design/assets/a01-vnext/exports/v1/`
- 生成联系表:`docs/design/assets/a01-vnext/review/A01-layered-states-v1-contact-sheet.png`
- 测试:`tests/a01-layered-psd-contract.ps1`
**接口:** `exports` 定义来源层、裁切矩形、尺寸、Alpha 和文件名;四状态固定为 `password-hidden``password-visible``sms-default``sms-countdown`
- [ ] **步骤 1:添加导出失败测试**
验证每个 `exports.path` 存在、PNG 文件头正确、像素尺寸与清单一致;透明资产四角 Alpha 为 0,不透明资产四角 Alpha 为 255。未执行导出时测试必须失败。
- [ ] **步骤 2:实现独立图层导出**
JSX 打开 PSD 后记录原可见性,只显示目标层或组,按清单矩形复制到新的透明临时文档并保存 PNG;完成后关闭脚本打开的 PSD 副本且不写回可见性变化。
- [ ] **步骤 3:实现四状态代表图和联系表**
按清单切换密码/验证码组以及眼睛、倒计时等状态专用层,分别导出 412 × 915 预览,再组合为一张两行两列联系表。代表图只进入 `review`,页面不得引用。
- [ ] **步骤 4:运行导出验证**
```powershell
powershell.exe -NoProfile -ExecutionPolicy Bypass -File scripts/export-a01-layered-assets.ps1
powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests/a01-layered-psd-contract.ps1
git diff --check
```
预期:导出成功,合同输出 `A01-LAYERED-PSD-CONTRACT PASS`,无新增空白错误。
- [ ] **步骤 5:只展示一张联系表并等待用户确认**
确认宗祠、卷轴、按钮、图标、密码/验证码状态和公共区域。用户未明确通过前,导出仍是候选,不复制到 `static/assets`,不修改页面。
---
### 任务 6:冻结资产证据并停在页面实施门禁前
**文件:**
- 修改:`docs/design/A01_启动登录引导_设计记录.md`
- 修改:`docs/design/P00_页面结构与资产清单.md`
- 修改:`docs/交接记录.md`
- 保留:`docs/design/assets/a01-vnext/source/A01-layered-source-v1.psd`
- 保留:`docs/design/assets/a01-vnext/exports/v1/`
**接口:** A01 设计记录拥有用户视觉决定;P00 拥有正式资产路径、尺寸、Alpha 和引用状态;交接记录只保存真实停点。
- [ ] **步骤 1:记录文件证据**
```powershell
Get-FileHash -Algorithm SHA256 docs/design/assets/a01-vnext/source/A01-layered-source-v1.psd
Get-ChildItem docs/design/assets/a01-vnext/exports/v1 -File | Get-FileHash -Algorithm SHA256
```
把 PSD 和已确认 PNG 的哈希、尺寸、Alpha、用途写入 A01 设计记录和 P00。
- [ ] **步骤 2:更新真实状态**
只能写“PSD 与 PNG 资产已获用户确认,A01 页面实现尚未开始”或事实对应的更早状态;不得写 A01 已验收、已冻结或页面已完成。
- [ ] **步骤 3:运行最终验证**
```powershell
powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests/a01-layered-psd-contract.ps1
powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests/a01-a02-ui-contract.ps1
git diff --check
git status --short
git status --short --ignored
```
预期:PSD 合同通过;既有 A01/A02 合同保持当前事实,若因页面尚未切换而失败则如实记录、不改页面;所有既有修改和未跟踪文件继续保留。
- [ ] **步骤 4:停止**
只有用户再次确认进入页面落地,才新建独立的 A01 image-to-code/TDD 实施计划;不得在本计划中顺手修改页面。
@@ -0,0 +1,177 @@
# A01 红色宗祠水墨登录页实施计划
> **For agentic workers:** 本项目明确禁止子代理与 worktree;本计划仅允许当前会话内联执行,并且禁止 `git add`、`commit`、`push`、`reset`、`checkout`。
**Goal:** 将用户确认的“顶部红色宗祠+下半区淡水墨宣纸”密码/验证码两种状态一比一落到 `pages/auth/a01-entry.vue`,保留现有登录交互并提供真实运行截图证据。
**Architecture:** A01 使用一张不含表单文字的 412:915 同比例背景底板承载宗祠、品牌印章、宣纸、水墨远山、枝叶和祥云;所有标签、输入、按钮文字与交互仍由 uni-app 组件渲染。密码态与验证码态只切换第二输入行及标签状态,共用同一背景和布局,旧卷轴方向资产保留但不引用。
**Tech Stack:** uni-app、Vue 3 `<script setup>`、SCSS、PowerShell 契约测试、H5/Android 运行截图。
## Global Constraints
- 只修改 A01、A01 专属测试、A01 专属设计证据和 A01 新资产,不修改其他页面。
- 基础版本只实现浅色主题;深色和跟随系统延后到下一版本。
- A02 已删除并合入 A01`pages.json` 保持 53 条最终路由。
- 保留 `brand-seal.png` 的现有图形,不重新设计项目 Logo。
- 密码态必须保留左侧锁图标、右侧眼睛按钮;闭眼图标必须保留中心瞳孔;“忘记密码”位于密码横线下方右侧。
- 验证码态必须使用 `a01-icon-sms-code-v2.png` 的“消息气泡+三点”图标,不得出现锁、眼睛、勾选或临时第三方 SVG。
- 不使用原生 `uni.showToast``uni.showModal``uni.showLoading``uni.showActionSheet` 作为最终视觉。
- 320×568、360×640、360×800、412×915 均保持设计比例;短屏允许页面纵向滚动,不通过高度媒体查询压缩布局。
- H5 截图仅为候选证据,最终仍需 Android/HBuilderX 真机或模拟器复核。
---
### Task 1: 锁定选定视觉证据与新资产契约
**Files:**
- Create: `docs/design/screens/handoff/2026-07-16/A01-selected-password-red-hall-ink-412x915.png`
- Create: `docs/design/screens/handoff/2026-07-16/A01-selected-sms-red-hall-ink-412x915.png`
- Modify: `tests/a01-a02-ui-contract.ps1`
- Create later: `static/assets/modules/auth/opaque/a01-red-hall-ink-backdrop-v1.png`
**Interfaces:**
- Consumes: 用户在当前对话确认的密码态与验证码态 ImageGen 结果。
- Produces: 唯一选定视觉证据路径,以及页面必须引用新底板、不得引用旧卷轴框的自动契约。
- [ ] **Step 1: 保存两张选定状态图到交接证据目录**
原始结果分别为:
```text
C:\Users\Rain\.codex\generated_images\019f653e-e7be-7900-a280-4ded33822764\exec-be61b836-b23d-46f3-99dd-b85cfac91055.png
C:\Users\Rain\.codex\generated_images\019f653e-e7be-7900-a280-4ded33822764\exec-6e7d86cb-5b39-467d-abe1-a0afdc1bf0a4.png
```
- [ ] **Step 2: 先修改契约测试**
`tests/a01-a02-ui-contract.ps1` 要求页面包含:
```powershell
'a01-red-hall-ink-backdrop-v1.png'
'brand-seal.png'
'a01-icon-lock-v1.png'
'a01-icon-eye-closed-pupil-v2.png'
'a01-icon-sms-code-v2.png'
```
并明确禁止页面继续引用:
```powershell
'a01-login-scroll-frame-v1.png'
'a01-login-header-exact-v2.png'
'a01-vnext-scroll-v1.png'
```
- [ ] **Step 3: 运行测试并确认正确失败**
Run:
```powershell
powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests/a01-a02-ui-contract.ps1
```
Expected: FAIL,原因是 A01 尚未引用 `a01-red-hall-ink-backdrop-v1.png`。
### Task 2: 生成无 UI 的共用视觉底板
**Files:**
- Create: `static/assets/modules/auth/opaque/a01-red-hall-ink-backdrop-v1.png`
- Preserve: `static/assets/foundation/transparent/brand-seal.png`
**Interfaces:**
- Consumes: Task 1 的两张选定视觉证据、现有 `brand-seal.png`、现有宗祠候选图。
- Produces: 824×1830、2× 像素密度、无表单文字和控件的页面底板。
- [ ] **Step 1: 用 ImageGen 生成干净底板**
底板只允许包含顶部红色宗祠、现有品牌印章、象牙白宣纸、边缘淡水墨远山/枝叶/祥云;不得包含标题、标签、输入框、按钮、协议文字或手机状态栏。
- [ ] **Step 2: 检查并裁切为 824×1830 PNG**
视觉检查必须确认品牌印章没有被改字,中心表单区域保持低对比留白,顶部宗祠高度与选定状态图一致。
- [ ] **Step 3: 运行资产尺寸检查**
Run:
```powershell
powershell.exe -NoProfile -Command "$b=[IO.File]::ReadAllBytes('static/assets/modules/auth/opaque/a01-red-hall-ink-backdrop-v1.png'); if($b.Length -lt 100000){throw 'backdrop too small'}"
```
Expected: exit code 0。
### Task 3: 一比一实现密码/验证码状态
**Files:**
- Modify: `pages/auth/a01-entry.vue`
- Test: `tests/a01-a02-ui-contract.ps1`
**Interfaces:**
- Consumes: `a01-red-hall-ink-backdrop-v1.png` 与现有 A01 图标、按钮、微信和协议资产。
- Produces: 同一路由内可交互切换的密码态与验证码态。
- [ ] **Step 1: 用单一底板替换旧宗祠叠层和卷轴框**
页面保留真实 UI 组件,移除旧 `login-scroll__skin`、`exact-header-overlay` 以及相关引用;逻辑画布保持 750rpx × 1665rpx,并加底部安全区。
- [ ] **Step 2: 对齐密码态**
密码态第二行使用 `a01-icon-lock-v1.png` 和闭眼/睁眼切换图标;“忘记密码”在输入横线下方右侧;所有触控区至少 80rpx 高。
- [ ] **Step 3: 对齐验证码态**
验证码态第二行使用 `a01-icon-sms-code-v2.png`,占位文字为“短信验证码”,右侧为“获取验证码”;不得渲染密码态的锁、眼睛和“忘记密码”。
- [ ] **Step 4: 保留现有交互契约**
保留登录方式持久化、密码显隐、协议勾选、注册/忘记密码跳转、自定义反馈 Toast 和自定义行为验证层;不接接口。
- [ ] **Step 5: 运行契约测试并确认通过**
Run:
```powershell
powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests/a01-a02-ui-contract.ps1
```
Expected: `A01-LOGIN-MERGE-CONTRACT PASS`。
### Task 4: 真实运行截图与设计 QA
**Files:**
- Create: `docs/design/screens/runtime/2026-07-16/A01-password-412x915.png`
- Create: `docs/design/screens/runtime/2026-07-16/A01-sms-412x915.png`
- Modify: `design-qa.md`
**Interfaces:**
- Consumes: 运行中的 A01 H5、两张选定设计图、密码/验证码交互状态。
- Produces: 同尺寸参考/实现对比和 QA 结论;Android 复核完成前不得写“用户已验收”。
- [ ] **Step 1: 启动或连接现有 H5 运行环境**
若项目缺少可用启动命令,只报告需要 HBuilderX 启动,不能用源码检查代替截图。
- [ ] **Step 2: 在 412×915 捕获两种状态**
密码态验证锁、闭眼瞳孔和横线下方“忘记密码”;验证码态验证消息气泡+三点和“获取验证码”。
- [ ] **Step 3: 检查四种尺寸与关键 Android 场景**
检查 320×568、360×640、360×800、412×915 的横向溢出、字体放大、状态栏、安全区、软键盘、返回手势;短屏允许纵向滚动。
- [ ] **Step 4: 更新 `design-qa.md`**
仅当参考图与真实运行图在同一视口完成比较且 P0/P1/P2 全部解决时写 `final result: passed`Android/HBuilderX 未复核时明确记录剩余验证,不将 A01 标记为已验收或冻结。
- [ ] **Step 5: 最终验证**
Run:
```powershell
powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests/a01-a02-ui-contract.ps1
git diff --check
git status --short
```
Expected: 契约 PASS、`git diff --check` 无输出;状态列表仅用于证明已有改动均被保留。
@@ -0,0 +1,87 @@
# A04 认证系列注册页实施计划
> **For agentic workers:** 本项目禁止子代理、worktree 和任何 Git 暂存/提交/推送操作;仅允许当前会话内联执行。
**Goal:** 将 A04 注册页统一到 A01 红色宗祠水墨认证视觉,并用项目自定义反馈完成协议校验与提交前行为验证外观。
**Architecture:** A04 复用 A01 的 `a01-red-hall-ink-backdrop-v1.png``brand-seal.png`,表单继续使用真实 uni-app 控件。字段错误和协议错误在页面就近显示;完整本地校验通过后打开 A04 自有的自定义行为验证层,不对接注册接口。
**Tech Stack:** uni-app、Vue 3 `<script setup>`、SCSS、PowerShell 契约测试、Chrome CDP 运行截图。
## Global Constraints
- 只修改 A04、A04 测试、A04 证据和必要的认证截图工具,不修改相邻页面。
- 基础版本只实现浅色主题;320×568 允许自然纵向滚动。
- 禁止 `uni.showToast``uni.showModal``uni.showLoading``uni.showActionSheet`
- 不对接接口;注册成功登录态与进入 G01 只记录为后续功能合同。
- 保留 A01 视觉冻结候选,不修改 A01 页面。
---
### Task 1: 收紧 A04 页面合同
**Files:**
- Modify: `tests/a04-registration-contract.ps1`
- Modify later: `pages/auth/a04-register.vue`
**Interfaces:**
- Consumes: A01 认证视觉资产与验收规划 A04 合同。
- Produces: 新底板、页内错误、自定义反馈和自定义验证层的静态契约。
- [ ] **Step 1: 新增失败断言**
要求页面引用 `a01-red-hall-ink-backdrop-v1.png``brand-seal.png`,包含 `fieldErrors``agreementError``feedback-toast``verification-layer`,并禁止四个原生 UniApp 提示 API。
- [ ] **Step 2: 运行红灯**
```powershell
powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests/a04-registration-contract.ps1
```
Expected: FAIL,首个原因为缺少新宗祠水墨底板。
### Task 2: 实现 A04 注册候选
**Files:**
- Modify: `pages/auth/a04-register.vue`
- Test: `tests/a04-registration-contract.ps1`
**Interfaces:**
- Consumes: Task 1 契约。
- Produces: 手机号、密码、确认密码、协议、返回登录和提交前验证的可交互页面。
- [ ] **Step 1: 替换旧宗祠与大面板结构**
使用与 A01 一致的页面底板、独立 Logo、宋体标题和宣纸表单,不保留 `auth-design-scenery-v1.png` 或旧大面板外框。
- [ ] **Step 2: 实现页内校验**
`validateForm()` 返回布尔值并写入 `fieldErrors.phone|password|confirmPassword`;协议未勾选只设置 `agreementError`,勾选后立即清除。
- [ ] **Step 3: 实现自定义反馈与行为验证层**
协议链接使用 `showFeedback('协议页面准备中')`;完整本地校验后设置 `verificationVisible=true`;验证确认后显示“注册服务待接入”。
- [ ] **Step 4: 运行绿灯**
```powershell
powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests/a04-registration-contract.ps1
```
Expected: `A04-REGISTRATION-CONTRACT PASS`。
### Task 3: 真实运行检查与证据
**Files:**
- Create: `tests/a04-registration-runtime-smoke.js`
- Create: `docs/design/screens/runtime/2026-07-16/A04-*.png`
- Update later: `docs/design/screens/handoff/2026-07-16/A04-*.png`
**Interfaces:**
- Consumes: 运行中的 A04。
- Produces: 360×800 代表图、320×568 滚动检查、校验与验证层运行证据。
- [ ] **Step 1: 验证 A01 注册入口可达 A04**
- [ ] **Step 2: 捕获 360×800 正常态并目视检查**
- [ ] **Step 3: smoke 检查字段错误、协议错误、验证层和返回登录**
- [ ] **Step 4: 运行 `git diff --check`,保持 A04 为 `[~]` 待用户审核**
@@ -0,0 +1,37 @@
# A05 红色宗祠水墨重设密码实施计划
> 本项目禁止子代理、worktree 和任何 Git 暂存/提交/推送操作;仅允许当前会话内联执行。
**Goal:** 将 A05 重设密码页统一到 A01/A04 认证视觉,完整表现发送验证码前行为验证、页内校验、自定义结果和返回登录状态。
**Architecture:** 复用 `a01-red-hall-ink-backdrop-v1.png`、独立 `brand-seal.png` 与认证按钮资产;真实输入保持 uni-app 控件。请求验证码前先验证手机号再打开自定义行为验证层;重设提交只做本地校验并显示自定义成功结果,不接接口、不重复行为验证。
**Tech Stack:** uni-app、Vue 3 `<script setup>`、SCSS、PowerShell 契约测试、Chrome CDP 运行烟测与截图。
## Constraints
- 只修改 A05、A05 测试、A05 证据与必要文档,不重构相邻页面。
- 基础版本只实现浅色主题;320×568 允许自然纵向滚动。
- 禁止原生 UniApp Toast、Modal、Loading、ActionSheet。
- 用户关闭行为验证时保留所有表单内容;提交重设时不再次验证。
- 页面始终保持 `[~]`,内部 H5 QA 不代表用户验收或 Android 通过。
## Task 1: 收紧页面合同
- [ ] 要求新底板、独立品牌印、自定义验证层、自定义反馈、自定义成功结果和字段错误。
- [ ] 禁止旧 scenery/大面板与四种原生反馈 API。
- [ ] 先运行合同得到红灯。
## Task 2: 实现候选页面
- [ ] 四字段就近错误与正常比例滚动布局。
- [ ] 获取验证码:校验手机号 → 自定义行为验证 → 自定义“验证码已发送”反馈。
- [ ] 确认重设:本地校验 → 自定义成功结果;不重复验证。
- [ ] 返回登录使用替换式导航,避免认证页堆栈累积。
## Task 3: 运行与证据
- [ ] 320×568、360×800、412×915 无横向溢出,短屏自然滚动。
- [ ] 烟测字段错误、验证层关闭保值、验证成功、密码不一致、成功结果和返回登录。
- [ ] 保存一张 412×915 代表图,更新视觉证据索引和 Design QA。
- [ ] 运行静态合同、运行烟测和 `git diff --check`
@@ -0,0 +1,19 @@
# A06 阻断登录状态实施计划
**Goal:** 将 A06 从普通登录错误汇总页收敛为仅处理冻结、停用、风险限制的阻断状态页,并统一到红色宗祠水墨认证视觉。
**Contract owner:** `pages/auth/a06-auth-status.vue``statusConfig` 是 A06 状态、原因、影响范围和恢复入口的唯一所有者;旧 normal/failed/register/wechat 状态必须同时从页面与测试移除。
## Constraints
- 只修改 A06、A06 测试、证据和必要文档;不修改 A01/A04/A05。
- 基础版仅浅色;320×568 正常比例自然滚动。
- 不使用任何原生 UniApp 提示;帮助入口使用项目自定义恢复说明层。
- 页面保持 `[~]` 待用户审核,Android 复核不冒充完成。
## Tasks
- [ ] 契约先行:只允许 `frozen|disabled|risk`,要求原因、影响范围、恢复入口和自定义恢复层,禁止旧状态及原生 UI。
- [ ] 实现三状态同页切换和未知状态安全回落到 `risk`
- [ ] 验证恢复层、返回 A01、三档响应式和旧状态回落。
- [ ] 保存代表图,更新证据索引与 Design QA。
@@ -0,0 +1,20 @@
# G01 我的家谱重新审核实施计划
**Goal:** 保留当前成熟的宣纸、朱砂、题签视觉,同时补齐最新验收规划中的空态三入口、三分组状态、当前谱切换、加载/失败和添加家谱弹层。
**Architecture:** `g01-my-genealogies.vue` 是本轮页面状态唯一所有者;用本地展示数据覆盖视觉状态,不接接口。可用家谱继续驱动全局上下文候选;申请/失效记录只在 G01 展示,不伪装成可用家谱。
## Constraints
- 不改相邻业务页,不接接口,不使用原生 UniApp UI。
- 保留现有真实题签、按钮、背景和 Tabbar 资产,避免无必要重做成熟视觉。
- 320×568 允许正常滚动;底部 Tabbar 不遮挡内容。
- 保持 `[~]` 待用户审核;H5 内部 QA 不代表 Android 或用户验收。
## Tasks
- [ ] 收紧合同:空态搜索主入口、邀请码快捷入口、创建次入口与防重复提示必须同时存在。
- [ ] 默认态补齐我创建的、我加入的、加入申请三组;展示待完善、审核中、被拒绝、已退出/移除状态位置。
- [ ] 新建/添加操作统一打开自定义三入口弹层;当前谱提供自定义切换层。
- [ ] 覆盖 loading/error/empty/default 查询状态与真实点击。
- [ ] 捕获默认/空态联系表,更新证据索引与 Design QA。
@@ -0,0 +1,20 @@
# G03 创建家谱与首代人物流程实施计划
**Goal:** 将 G03 两步流程改为纯本地页面样式合同,补齐创建前已有谱提醒、页内校验、提交中/失败、中断恢复和完成后进入 G05。
**Contract owner:** G03 的 `currentStep``createState``ancestorState` 是两步页面状态唯一所有者;移除当前页面的远程 API 调用和旧的完成后直跳 T01 路径。
## Constraints
- 第一步字段:姓氏、谱名、堂号、所在地、访问规则。
- 创建前先显示自定义“搜索潜在已有家谱”提醒,允许去 G06 或确认创建。
- 已创建未录始祖时以 `step=ancestor&genealogyId=` 恢复,不重复建谱。
- 第二步完成后进入 G05 总览,不直接跳 T01。
- 禁止原生 UniApp UI;320×568 正常滚动;页面保持 `[~]`
## Tasks
- [ ] 契约先行:无 appApi/原生 UI,要求提醒层、页内错误、两步状态和 G05 完成路径。
- [ ] 实现第一步校验、访问规则、重复建谱提醒、创建中/失败。
- [ ] 实现首代校验、中断恢复、保存中/失败/成功结果。
- [ ] 两步真实运行和联系表 QA。
@@ -0,0 +1,19 @@
# G05 公开预览/成员总览实施计划
**Goal:** 同一路由准确区分未加入用户的公开预览和已加入用户的成员总览,并覆盖所有者/成员入口差异与异常状态。
**Contract owner:** G05 的 `viewMode``accessRole``overviewFixture` 是页面样式阶段唯一合同;移除远程 API 和当前模糊的单一 ready 状态。
## Constraints
- 公开态只展示身份、来源、地区、堂号、支系、公开说明和申请入口;不得暴露成员统计细节、世系和管理入口。
- 成员态展示家谱概览;所有者显示审核/设置,普通成员不显示管理入口;世系图保持次级入口。
- 无权限、加载、空、失败均同页处理;禁止原生 UniApp UI。
- 页面保持 `[~]`320×568 允许滚动。
## Tasks
- [ ] 契约先行:public/member、owner/member、no-permission、无 appApi。
- [ ] 实现公开详情与申请入口、成员角色入口差异。
- [ ] 真实运行多状态与三档响应式。
- [ ] 生成公开/成员联系表并更新 QA。
@@ -0,0 +1,20 @@
# G06 搜索/邀请码加入双模式实施计划
**Goal:** 将 G06 收敛为“加入家谱”单页双模式,补齐详细结果卡、六种用户关系操作和邀请码状态,同时保持已选题签视觉资产。
**Contract owner:** `g06-search-genealogies.vue` 的本地 `resultFixtures``mode`/`searchState`/`inviteState` 是页面样式阶段唯一状态合同;移除远程 API 调用和旧的“结果卡统一可申请”逻辑。
## Constraints
- 只做本地样式状态,不接搜索或邀请码接口。
- 结果卡必须明确谱名、姓氏、地区、堂号、上级谱、当前支系、管理/认证、成员数、更新时间。
- 六种关系各只有一个明确动作;未加入卡片可进入 G05 公开预览,申请动作进入 G08。
- 使用自定义页内错误/状态,不使用原生 UniApp UI;320×568 正常滚动。
- 页面保持 `[~]` 待用户审核。
## Tasks
- [ ] 契约先行:双模式、详细字段、六种状态、本地数据、无原生 UI/无 appApi。
- [ ] 实现搜索初始/加载/结果/空/失败/清空与邀请码初始/无效/有效目标。
- [ ] 实现关系动作路由和公开预览入口。
- [ ] 三档运行烟测,生成初始/结果/邀请码联系表并更新 QA。
@@ -0,0 +1,19 @@
# G08 搜索申请/邀请码直加入谱双来源实施计划
**Goal:** 同一路由根据 `source=search|invite` 准确切换申请语义、第三字段、提交结果和下一步,保留统一家谱视觉。
**Contract owner:** G08 的 `source``sourceContract` 是双来源文案和结果路径的唯一所有者;不得在模板散落两套互相矛盾的“申请/直接加入”判断。
## Constraints
- 真实姓名与参照已知长辈的文字关系必填,不虚构结构化关系树。
- 搜索来源第三字段为申请说明,成功去 G09;邀请来源为可选补充信息,成功回 G01。
- 页内字段错误、自定义提交中/成功/失败;禁止原生 UniApp UI。
- 320×568 正常滚动;页面保持 `[~]`
## Tasks
- [ ] 契约先行:双来源、字段错误、两条成功路径与无原生 UI。
- [ ] 实现来源合同和本地状态,重复点击不重复提交。
- [ ] 运行搜索/邀请两套表单和成功路径烟测。
- [ ] 生成双来源联系表,更新证据与 QA。
@@ -0,0 +1,18 @@
# G09 我的申请状态与操作实施计划
**Goal:** 补齐待审、通过、拒绝、修改后重提、撤回、空和失败状态,并让每张记录只显示符合状态的动作。
**Architecture:** G09 的 `applicationSamples``actionFor()` 是页面样式阶段状态与操作的唯一合同;撤回使用自定义确认层并只更新本地候选数据。
## Constraints
- 邀请码直接加入不进入 G09;本页只展示搜索申请。
- 禁止原生 UniApp UI,不接接口。
- 通过进入 G05,拒绝回 G08 搜索来源,待审可撤回。
- 页面保持 `[~]`320×568 允许滚动。
## Tasks
- [ ] 契约先行:状态动作、自定义撤回层、无原生 UI。
- [ ] 实现卡片动作、撤回结果、空/失败/加载外观。
- [ ] 三档运行烟测与代表图 QA。
@@ -0,0 +1,17 @@
# G10 入谱审核自定义确认实施计划
**Goal:** 用项目自定义说明/确认/反馈取代原生 Modal/Toast,并补齐无权限状态。
**Contract owner:** G10 的 `confirmation``helpVisible` 是审核弹层的唯一状态所有者;禁止保留原生 UniApp UI 备用路径。
## Constraints
- 通过/拒绝都必须二次确认并更新当前卡片结果。
- 说明、确认、反馈全部项目自定义;无权限同页状态。
- 不接接口;页面保持 `[~]`
## Tasks
- [ ] 契约先行:自定义审核层/说明层/反馈,无原生 UI。
- [ ] 实现通过/拒绝确认和无权限状态。
- [ ] 真实运行与代表图 QA。
@@ -0,0 +1,15 @@
# G11/G12 设置与字辈诗实施计划
**Goal:** 保留现有纸面板视觉,补齐页内校验、自定义保存反馈与无权限状态,移除原生 Toast。
## Constraints
- G11 只覆盖接口可解释的名称、公开范围、访问说明;无权限独立状态。
- G12 覆盖列表、空、编辑、校验、保存失败、无权限;不接接口。
- 所有反馈项目自定义;页面保持 `[~]`
## Tasks
- [ ] 契约先行:字段错误、反馈、无权限、禁止原生 UI。
- [ ] G11 保存状态与 G12 编辑/保存状态。
- [ ] 真实运行和联系表 QA。
@@ -0,0 +1,118 @@
# A01 分层 PSD 与正式 PNG 资产设计
## 决策
A01 不再以 Figma 作为设计与资产生产环节。重新建立一份真正分层的 PSD,将它作为 A01 美术资产的唯一源文件;正式 PNG 必须从 PSD 的对应图层或图层组直接导出,密码登录和验证码登录代表图也必须由同一份 PSD 组装导出。
现有两张选定效果图只保留为风格、构图和状态参考,不再作为必须反向拆分的扁平像素真值:
- `docs/design/screens/handoff/2026-07-15/A01-visual-source-password-selected-v2-841x1870.png`
- `docs/design/screens/handoff/2026-07-15/A01-visual-source-sms-selected-v1-841x1870.png`
新的最终视觉真值是用户确认后的 PSD 组装效果。只有这样,设计稿与页面才能使用同一批美术资产,避免重新生成导致的偏差。
## 目标与成功标准
- 交付一份可以显示、隐藏、移动和单独导出各资产层的 PSD。
- 宗祠、卷轴、宣纸、边框、按钮皮肤和功能图标均为独立、干净的图层或图层组。
- PSD 中不以整页截图冒充分层设计,也不从扁平截图硬裁被遮挡或带文字的脏资产。
- PSD 导出的正式 PNG 具有明确尺寸、Alpha 属性、拉伸规则和页面用途。
- 密码与验证码代表图由正式 PNG 和独立内容层重新组装,不进行第二次美术生成。
- 412 × 915 为像素校准基准;页面使用同一批 PNG 后,在该尺寸对齐资产比例与位置。
- 320 × 568、360 × 640、360 × 800、412 × 915 使用同一资产体系,依靠分片、平铺和真实布局适配,不压缩整张卷轴。
## PSD 结构
PSD 采用 412 × 915 的三倍工作画布,即 1236 × 2745 像素。页面布局仍以 412 × 915 逻辑尺寸记录,正式位图按三倍密度保留细节;导出时在清单中同时记录源像素尺寸和页面显示尺寸。
图层组固定如下:
1. `00-参考`
- 密码效果图,仅作锁定参考,不导出。
- 验证码效果图,仅作锁定参考,不导出。
2. `10-背景`
- 页面底色。
- 宗祠红色头图,不含 Logo、文字和卷轴,也不生成空牌匾、替代牌匾或新的 Logo 底座。
3. `20-卷轴`
- 顶部卷轴杆与卷边。
- 可纵向平铺或延展的无字宣纸主体。
- 左边框。
- 右边框。
- 底部卷轴杆与收口。
- 四角回纹及其他可独立复用的装饰。
4. `30-品牌与标题装饰`
- `brand-seal` 独立层,唯一来源为 `static/assets/foundation/transparent/brand-seal.png`
- `brand-seal` 直接叠放在宗祠中央;宗祠背景只为它保留干净的视觉空间,不重画或补画牌匾。
- 标题两侧装饰和分隔纹样。
5. `40-公共控件皮肤`
- 主按钮左端、中部可延展纹理、右端,或经过验证的九宫格皮肤。
- 微信按钮边框和必要装饰,不包含按钮文字。
6. `50-密码登录`
- 手机图标。
- `a01-icon-lock-v1` 密码锁。
- 睁眼图标。
- 保留中心瞳孔的闭眼图标。
- 忘记密码为独立内容层,不烧入卷轴或输入区资产。
7. `60-验证码登录`
- 与密码状态共用同一个手机图标层实例。
- 消息气泡加且仅加三个点的验证码图标。
- 获取验证码及倒计时为独立内容层,不烧入输入区资产。
8. `70-内容与标注`
- 标题、Tab、输入文字、横线、按钮文字、注册与协议等用于代表图组装和定位。
- 这些内容不作为页面位图导出;页面中仍由真实 uni-app 组件渲染。
密码登录与验证码登录通过图层组显示和隐藏形成两种独立状态,共用 `10``40` 的全部公共资产,不复制背景和卷轴。
## 资产生产规则
- 新资产直接以单个图层所需的内容生成或编辑,不让 ImageGen 生成带完整页面、文字或其他控件的混合图。
- 为提高速度,相关结构允许先生成一张干净母图,再由 Photoshop 分层拆出正式 PNG;母图只能包含同一资产族,不能退回生成完整登录页面。
- 剩余生成收敛为三类母图:完整无字卷轴、无字按钮与装饰板、四枚功能图标板。宗祠头图使用已选候选裁剪,不再重新生成。
- 卷轴母图同时提供顶部、底部、左右边框和无字宣纸区域;按钮母图拆为左端、可延展中段和右端;图标板中的四枚图标必须彼此完全分离。
- 透明资产必须检查真实 Alpha、边缘杂色、白边、阴影截断和透明区域中的脏像素。
- 不透明纹理必须检查接缝、重复感和拉伸后失真;宣纸主体优先使用可平铺纹理。
- 卷轴和按钮不得作为整张长图跨尺寸压缩。端部、边框与中部延展区域必须分开。
- 图标在目标显示尺寸下检查可读性,不以大图缩略预览代替小尺寸验收。
- 除用户明确指定的 `brand-seal``a01-icon-lock-v1` 外,当前 A01 新视觉使用的新资产不得偷偷回退到旧候选文件。
- 候选资产只保存在设计候选目录;通过单项验收后才复制为新的版本化正式文件,不能覆盖现有未提交资产。
## PSD 与 PNG 导出契约
每个可导出的图层或图层组使用最终 PNG 文件名作为图层名。导出结果分为:
- 不透明背景类 PNG:宗祠头图等。
- 透明结构类 PNG:卷轴端部、边框、装饰和按钮切片。
- 透明图标类 PNG:手机、眼睛和短信气泡等。
- 代表图:密码隐藏、密码显示、验证码默认、验证码倒计时;代表图只用于设计对比,不能被页面引用。
每个导出文件必须同时记录:PSD 图层名、文件路径、像素尺寸、页面显示尺寸、Alpha 属性、平铺或拉伸规则、引用状态和验收结果。该记录后续写入 A01 设计记录与 P00 资产清单,避免同一规则在多个临时文件中重复定义。
## 制作工具边界
2026-07-15 已实际检测到正在运行的 Adobe Photoshop 2021
- 可执行文件:`E:\Program Files (x86)\Photoshop\Adobe Photoshop 2021\Photoshop.exe`
- ProductVersion`22.0`
- COM ProgID`Photoshop.Application.150`
- ExtendScript 目录:`E:\Program Files (x86)\Photoshop\Adobe Photoshop 2021\Presets\Scripts`
后续实现使用项目内 PowerShell 启动脚本调用已注册的 Photoshop COM,再由项目内 ExtendScript 按资产清单建立命名图层、图层组、PSD 和 PNG 导出结果。脚本、清单与结果均保存在项目允许的目录内,不向 Photoshop 安装目录写文件,也不上传项目资产到第三方服务。
工具只是容器生成方式,不改变本规格中的图层结构和导出契约。任何自动化脚本都必须关闭自己创建的临时文档,不能关闭、覆盖或保存用户原先在 Photoshop 中打开的文档。
## 验收流程
1. 先建立空白分层结构和资产槽位,不修改 A01 页面代码。
2. 每次只制作一个资产类别,输出透明底检查图和深浅底联系表,由用户确认后再进入下一类。
3. 使用通过检查的图层组装密码和验证码两种 412 × 915 代表图,检查公共区域是否完全一致。
4. 用户确认 PSD 组装效果后,冻结 PSD 与导出 PNG 的文件哈希。
5. 随后才允许进入页面实现:先更新 A01 资产合同测试,再让页面引用同一批正式 PNG。
6. 页面完成后获取四档 H5 真实运行截图,并补做 Android/HBuilderX 真机或模拟器复核;没有这些证据不得宣称 A01 完成或验收通过。
## 范围外
- Figma 文件继续制作或同步。
- 将整张设计图铺成页面背景。
- 接口对接、真实短信、真实微信授权和登录业务实现。
- A01 之外的相邻页面、路由和资产调整。
- 清理、删除、覆盖现有候选资产或用户未提交修改。
@@ -0,0 +1,100 @@
# 家谱 APP 纯代码资产流水线设计规格
> 日期:2026-07-15
> 当前试点:A01 登录页
> 状态:等待用户复核规格,尚未开始实现
## 1. 目标
建立一条不依赖 Photoshop、Figma 或人工反向切图的资产生产链。复杂国风位图只在缺失时由 ImageGen 生成一次原始母图;之后的透明处理、裁切、缩放、组合、预览和运行资产导出全部由代码完成。整页设计预览与 uni-app 使用的独立 PNG 必须来自同一批源像素和同一份布局清单。
首轮只验证 A01,不修改其他 52 页。成功标准不是脚本能够输出图片,而是 A01 的代码预览、独立资产、uni-app 真实运行画面和验收规划中的页面合同能够形成可重复验证的闭环。
## 2. 当前版本范围
- 基础版本固定使用当前浅色国风主题。
- 深色与跟随系统整体延期到下一版本,不阻塞本轮 53 页验收。
- 下一版本增加主题时必须另建全局主题合同,并对已冻结页面执行影响分析和真实截图回归。
- 当前只做页面样式、视觉状态和 Android 兼容性,不对接接口,不宣称 Mock 状态代表功能通过。
## 3. 保留现场
- 现有 `docs/design/assets/a01-vnext/`、PSD、Photoshop 脚本、候选图片和历史证据全部保留。
- 不删除、不移动、不覆盖旧实验文件。
- Photoshop 生产链停止扩展,新流水线不得引用 Photoshop COM、JSX 或 PSD 作为运行依赖。
- 旧文件是否归档必须等 A01 新流水线和真实页面通过后另行决定。
## 4. 唯一所有者与目录边界
新流水线使用以下边界:
```text
design-pipeline/
manifests/ 页面、状态、图层、尺寸和导出合同
sources/ 经确认的原始母资产,只读输入
scripts/ Node.js 渲染、切图、校验和报告脚本
generated/ 可再生成的设计预览、联系表和检查报告
static/assets/ 用户确认后进入页面的正式运行资产
docs/design/screens/ 真实运行截图证据,不作为页面资源
```
页面清单是坐标、状态、源文件、目标文件、尺寸和复用关系的唯一机器可读所有者。脚本和页面不得重新硬编码同一套资产规则;uni-app 页面只消费已确认的正式运行资产及必要布局常量。
## 5. 技术结构
- Node.js:命令入口、清单读取、状态遍历、输出隔离和失败报告。
- Sharp/libvipsAlpha、裁切、合成、缩放、PNG 优化、尺寸与体积检查。
- JSON:页面画布、图层顺序、资产槽位、状态差异和导出规格。
- ImageGen:只生成缺失的完整复杂位图母资产;不生成带表单文字的整页截图,不重绘现有 `brand-seal.png`
- uni-app:最终页面实现与真实交互状态;不把整页设计预览当运行资产。
## 6. A01 首轮输出
同一次构建必须生成:
1. A01 独立透明 PNG 资产及尺寸、Alpha、SHA256 和文件体积报告。
2. 密码登录与短信验证码登录的 412×915 代表图。
3. 复杂状态联系表,至少覆盖密码隐藏、密码显示、短信默认和短信倒计时。
4. 320×568、360×640、360×800、412×915 四尺寸布局预览。
5. 可供 A01 契约测试读取的生成清单。
代码预览必须覆盖 A01 当前合同:现有 `brand-seal.png`、现有锁图标、保留中心瞳孔的闭眼图标、消息气泡加且仅加三点的短信图标、忘记密码位于密码横线下方,以及密码/短信两种独立状态。
## 7. 验收边界
- 代码生成图只用于选择和定位视觉问题,不计为真实运行证据。
- A01 实施后必须从真实路由进入,获取当前浅色国风主题的运行截图。
- 内部检查四档尺寸、默认字号与约 1.3 倍字号、状态栏、安全区、软键盘、返回手势和图片解码表现。
- 320×568 下品牌区、登录方式、当前表单、登录按钮、微信入口、注册、忘记密码和协议入口必须同屏可见且不依赖纵向滚动。
- 默认只向用户展示一张代表图;密码/短信等复杂状态使用一张联系表。
- H5 截图是候选证据,最终仍需 Android/HBuilderX 真机或模拟器复核。
- 用户明确确认前,A01 保持 `[~]`,不得写成已验收或冻结。
## 8. 性能与工程约束
- 正式 PNG 按实际显示槽位输出,不让页面解码无必要的超大位图。
- 同一公共资产只保留一个正式来源;页面状态不得复制公共背景和按钮像素。
- 脚本拒绝覆盖已存在的版本化输出,失败时保留旧结果并返回非零退出码。
- 生成目录与正式运行目录严格分离;只有用户确认的资产才进入 `static/assets/`
- 不使用整页截图作为运行资产,不用 CSS 或手写 SVG 假造完整卷轴、宗祠、牌匾、按钮和装饰面。
- 所有可见提示继续使用项目自定义组件,禁止以原生 UniApp 提示作为最终视觉。
## 9. 测试与证据
- 先建立纯代码流水线契约测试并确认因入口和清单缺失而失败。
- 最小实现后验证清单、源文件、四尺寸输出、状态输出、PNG 尺寸、Alpha、文件体积和哈希。
- A01 页面实施前先更新页面契约测试并确认 RED,实施后恢复 GREEN。
- 运行受影响测试、运行时 smoke、真实截图人工复核和 `git diff --check`
- 不执行 `git add`、commit、push、reset、checkout、上传、worktree 或多代理。
## 10. 推广门槛
A01 只有同时满足以下条件,纯代码流水线才可推广:
1. 同一清单能够稳定重复生成全部 A01 候选资产和预览。
2. 独立 PNG 与设计预览来自相同源像素,不再需要人工反向切图。
3. A01 真实运行截图与选定预览达到用户认可的视觉一致性。
4. 四档尺寸、约 1.3 倍字号、软键盘、安全区和 Android 复核有真实证据。
5. 用户明确确认 A01 基础版本通过。
未达到推广门槛时,只修正 A01 流水线和页面,不修改其他页面。
+74 -20
View File
@@ -1,9 +1,9 @@
# 家谱 APP 换机交接记录 # 家谱 APP 换机交接记录
> 最后更新:2026-07-15 > 最后更新:2026-07-16
> 用途:更换电脑、重新打开 Codex/GPT 后的唯一接管入口。 > 用途:更换电脑、重新打开 Codex/GPT 后的唯一接管入口。
> 当前阶段:只做页面样式与视觉流程验收;不对接接口,不做功能验收。 > 当前阶段:只做页面样式与视觉流程验收;不对接接口,不做功能验收。
> 当前工作页A01 登录页,尚未通过用户验收 > 当前施工停点A01 至 T04 共 15 页已有内部 H5 候选,均尚未通过用户验收;下一页是 T07 成员目录
## 1. 接管后先复述的准确停点 ## 1. 接管后先复述的准确停点
@@ -12,9 +12,9 @@
- `docs/验收规划.md` 当前统计为:**53 个待审核、0 个已验收、0 个返工标记**。 - `docs/验收规划.md` 当前统计为:**53 个待审核、0 个已验收、0 个返工标记**。
- 历史上曾标记通过的 A01、A02、A04、A05、A06、G01、G03 只保留为历史视觉证据,**本轮全部重新审核**。 - 历史上曾标记通过的 A01、A02、A04、A05、A06、G01、G03 只保留为历史视觉证据,**本轮全部重新审核**。
- 当前没有任何页面处于视觉冻结状态;只有用户在本轮明确说“通过”后,对应页面才能改为 `[x]` 并冻结。 - 当前没有任何页面处于视觉冻结状态;只有用户在本轮明确说“通过”后,对应页面才能改为 `[x]` 并冻结。
- 当前停在 **A01 登录页视觉返工**。用户认为整页与选定设计稿仍有明显差距,A01 不得写成“完成”或“通过”。 - 2026-07-16 已按用户“继续按验收规划自主推进”的最新授权,完成 **A01、A04、A05、A06、G01、G03、G05、G06、G08、G09、G10、G11、G12、T01、T04** 的内部 H5 候选、聚焦合同和运行时检查;这些结果都只是待审候选,不能写成“用户已验收”或“已冻结”。
- 最新完成的局部修正是:闭眼图标保留中心瞳孔;短信验证码输入图标改为自生成的“消息气泡 + 三点”,不再复用锁、勾选或临时第三方 SVG。 - A01 当前候选已落实:闭眼图标保留中心瞳孔;密码行使用项目现有锁图标;短信验证码输入图标使用自生成的“消息气泡 + 三点”;不得恢复勾选图标或临时第三方 SVG。密码/短信两态代表联系表为 `docs/design/screens/handoff/2026-07-16/A01-current-password-sms-contact-824x915.png`
- 下一步不是跳去 G05,也不是批量改其他页面;应先继续完成 A01 的真实运行截图审视、修正和用户确认 - 当前施工停在 **T04 新增亲属之后**,下一页严格按 `docs/验收规划.md` 进入 **T07 成员目录**。T01 收敛时删除了未注册路由但仍被 Git 跟踪的旧占位文件 `pages/tree/t02-tree-states.vue`;T04 复核时把共用成员表单残留的原生 `uni.showModal` 替换为项目自定义冲突说明层。用户明早仍需从 A01 开始逐页审核;未明确说“通过”的页面一律保持 `[~]`
如果其他文档仍写“54 条路由、7 个已验收或冻结”,那是旧阶段记录。当前状态和验收数量以本文件、实际 `pages.json``docs/验收规划.md` 为准。 如果其他文档仍写“54 条路由、7 个已验收或冻结”,那是旧阶段记录。当前状态和验收数量以本文件、实际 `pages.json``docs/验收规划.md` 为准。
@@ -40,7 +40,7 @@ git status --short
必须保留全部已有未提交改动。不要因为文件是未跟踪状态就删除,也不要用旧提交覆盖当前工作区。 必须保留全部已有未提交改动。不要因为文件是未跟踪状态就删除,也不要用旧提交覆盖当前工作区。
随后先向用户复述:53 条最终路由、53 页全部待审核、当前无冻结页、当前停在 A01、准备使用的技能、A01 的下一步。复述准确前不要继续修改页面。 随后先向用户复述:53 条最终路由、53 页全部待审核、当前无冻结页、内部候选施工停在 T01 之后、用户逐页审核仍从 A01 开始、下一施工页是 T04,以及准备使用的技能。复述准确前不要继续修改页面。
## 3. 文档权威顺序 ## 3. 文档权威顺序
@@ -65,7 +65,7 @@ git status --short
- 首次默认密码登录;后续只恢复用户上次选择的密码/短信方式,不保存密码。 - 首次默认密码登录;后续只恢复用户上次选择的密码/短信方式,不保存密码。
- 登录在本地字段与协议校验通过后才显示行为验证弹层。 - 登录在本地字段与协议校验通过后才显示行为验证弹层。
- 未勾选协议时使用页内高亮、错误文案和轻微震动,不使用原生 Toast 或原生确认框。 - 未勾选协议时使用页内高亮、错误文案和轻微震动,不使用原生 Toast 或原生确认框。
- 320×568 下核心入口必须同屏可见,不依赖纵向滚动 - 320×568 下保持正常字号、触控高度和设计比例,允许自然纵向滚动;不得为了全部入口同屏而压扁品牌区或表单
- 微信登录保留入口,真实登录留到功能阶段。 - 微信登录保留入口,真实登录留到功能阶段。
- 行为验证当前只审核弹层外观状态;真实 TAC、`validToken` 和接口续接留到功能阶段。 - 行为验证当前只审核弹层外观状态;真实 TAC、`validToken` 和接口续接留到功能阶段。
@@ -91,9 +91,41 @@ git status --short
- `tests/a01-a02-ui-contract.ps1` 已要求页面引用 `a01-icon-sms-code-v2.png`,并禁止回退到勾选图标和临时 SVG。 - `tests/a01-a02-ui-contract.ps1` 已要求页面引用 `a01-icon-sms-code-v2.png`,并禁止回退到勾选图标和临时 SVG。
- 最新真实运行截图已确认短信验证码行显示棕色消息气泡三点图标。 - 最新真实运行截图已确认短信验证码行显示棕色消息气泡三点图标。
- A01 整页仍缺少本轮用户最终确认;深色主题四档尺寸完整复核和 Android 真机复核均不能写成已通过。 - A01 整页仍缺少本轮用户最终确认;当前浅色国风主题四档尺寸完整复核和 Android 真机复核均不能写成已通过。深色与跟随系统已由用户决定延期到下一版本,不是基础版本验收门槛。
- 旧的 `A01-password-icons-412x915.png` 实际仍是短信 Tab 且使用旧临时图标,不能当密码态证据。 - 旧的 `A01-password-icons-412x915.png` 实际仍是短信 Tab 且使用旧临时图标,不能当密码态证据。
### 4.4 最新资产生产决策
- 用户已决定停止扩展 PhotoshopPSD 生产链,改用“ImageGen 原始母图 + 纯代码处理与渲染”的新流水线。
- 2026-07-16 用户最新决定:不保留 `design-pipeline/node_modules/``design-pipeline/generated/``docs/design/assets/a01-vnext/candidates/``docs/design/assets/a01-vnext/review/`;这些目录既不进入 Git,也不留在当前电脑。Photoshop 历史脚本仍作为文字/实现历史保留,但新流水线不得依赖已删除的 PSD、候选图或审阅中间稿。
- 新流水线使用独立目录,以 JSON 作为页面图层、状态、尺寸和导出路径的唯一机器可读所有者;Node.js 负责组合,Sharp 负责裁切、透明处理、缩放、体积检查和 PNG 导出。
- A01 的稳定输入已迁移为 Git 中正式保留的 `static/assets/modules/auth/` 运行资产;`design-pipeline/manifests/a01.json` 的每个 `source``output` 指向同一正式资产,流水线负责验证这些正式资产并生成多尺寸预览。这样任何电脑只需仓库内容和 npm 依赖即可运行,不需要本机候选图或 Photoshop。
- 同一批源资产同时生成独立页面资产、四尺寸设计预览和复杂状态联系表,避免“设计图与页面资产来自两套像素”。设计预览仍只是候选证据,最终必须进入 uni-app 并获取真实运行截图。
- 先只以 A01 验证完整流水线;A01 未经用户确认前不推广到其他 52 页,也不清理旧实验文件。
- 基础版本只支持当前浅色国风主题;深色与跟随系统整体延期到下一版本。
### 4.5 新电脑恢复代码资产流水线
仓库必须保留以下可复现文件:
- `design-pipeline/package.json`
- `design-pipeline/package-lock.json`
- `design-pipeline/manifests/a01.json`
- `design-pipeline/scripts/build-a01.mjs`
- `static/assets/modules/auth/` 中清单引用的正式资产
- `tests/a01-code-pipeline-contract.ps1`
新电脑在项目根目录执行:
```powershell
node --version
npm.cmd ci --prefix design-pipeline
npm.cmd --prefix design-pipeline run build:a01
powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests/a01-code-pipeline-contract.ps1
```
预期输出分别包含 `A01-CODE-PIPELINE BUILD PASS``A01-CODE-PIPELINE-CONTRACT PASS`。构建结果生成在被忽略的 `design-pipeline/generated/a01/`;它只用于本机预览和验证,不得提交。需要再次腾出空间时可以删除 `design-pipeline/node_modules/``design-pipeline/generated/`,下次重新执行 `npm.cmd ci` 与构建命令即可。不得把已删除的 `candidates/``review/` 恢复为清单输入。
## 5. 下一步严格执行顺序 ## 5. 下一步严格执行顺序
### 第一步:恢复环境与确认文件完整 ### 第一步:恢复环境与确认文件完整
@@ -112,19 +144,19 @@ http://localhost:5173/#/pages/auth/a01-entry
换机前用户需要自行把当前修改和未跟踪的最终资产加入 Git 并同步;GPT 不执行 Git 暂存、提交、推送或上传。特别确认本节列出的 A01 PNG 资产、测试、本文件和交接截图已被用户纳入 Git,否则换电脑后会缺图。 换机前用户需要自行把当前修改和未跟踪的最终资产加入 Git 并同步;GPT 不执行 Git 暂存、提交、推送或上传。特别确认本节列出的 A01 PNG 资产、测试、本文件和交接截图已被用户纳入 Git,否则换电脑后会缺图。
### 第二步:继续 A01,而不是切换页面 ### 第二步:先让用户从 A01 开始审核现有候选
1. 打开 A01 密码登录态,确认手机、锁、闭眼/睁眼图标在真实尺寸下清晰且风格统一。 1. 打开 A01 密码登录态,确认手机、锁、闭眼/睁眼图标在真实尺寸下清晰且风格统一。
2. 打开短信登录态,使用当前生成的消息气泡三点图标;不要恢复锁、勾选或第三方临时 SVG。 2. 打开短信登录态,使用当前生成的消息气泡三点图标;不要恢复锁、勾选或第三方临时 SVG。
3. 对照当前选定方向检查整体层级:红色宗祠顶部、完整宣纸卷轴登录区、标题、Tab、输入区、主按钮、微信入口、注册与协议必须形成一个整体,不能只修局部 CSS。 3. 对照当前选定方向检查整体层级:红色宗祠顶部、完整宣纸卷轴登录区、标题、Tab、输入区、主按钮、微信入口、注册与协议必须形成一个整体,不能只修局部 CSS。
4. 优先修用户指出的明显问题;每轮只处理一个可验证问题,避免盲目大改导致新的比例错误 4. 优先修用户在审核时指出的明显问题;返工只触碰当前审核页,避免相邻页面一起漂移
5. 先完成浅色代表态,再补深色主题;两套主题必须在同一个 A01 验收项内提交 5. 基础版本只完成当前浅色国风主题;深色与跟随系统延期到下一版本,不在 A01 本轮验收项内实施
6. 内部检查 320×568、360×640、360×800、412×915;默认只向用户展示一张代表图。密码/短信、明/暗等复杂状态用一张联系表,不连续发送大量对比图。 6. 内部检查 320×568、360×640、360×800、412×915;默认只向用户展示一张代表图。密码/短信等复杂状态用一张联系表,不连续发送大量对比图。
7. 用户明确确认 A01 通过后,才把 `docs/验收规划.md` 中 A01 从 `[~]` 改为 `[x]` 并冻结;否则保持 `[~]`,明确返工时改 `[!]` 7. 用户明确确认 A01 通过后,才把 `docs/验收规划.md` 中 A01 从 `[~]` 改为 `[x]` 并冻结;否则保持 `[~]`,明确返工时改 `[!]`
### 第三步:A01 通过后按真实用户流程继续 ### 第三步:无人审核期间从 T01 继续施工;用户审核时以当前审核页优先
严格按 `docs/验收规划.md` 的 53 页表推进 截至 2026-07-16,以下 15 页已生成内部候选并完成聚焦检查,但仍全部为 `[~]`
```text ```text
A01 登录 A01 登录
@@ -132,14 +164,20 @@ A01 登录
→ A05 重置密码 → A05 重置密码
→ A06 阻断登录状态 → A06 阻断登录状态
→ G01 我的家谱 → G01 我的家谱
→ G03 创建家谱/录入始祖
→ G05 家谱总览
→ G06 搜索/邀请码加入 → G06 搜索/邀请码加入
→ G08 加入申请 → G08 加入申请
→ G09 我的申请 → G09 我的申请
→ G03 创建家谱/录入始祖 → G10 入谱审核
→ G05 家谱总览 → G11 家谱设置
后续管理、世系、家族内容、档案、消息和个人中心 G12 字辈诗
→ T01 世系树
→ T04 新增亲属
``` ```
下一施工页是 `T07 成员目录`,随后继续 T03、T05 等验收表顺序。若用户开始逐页审核或指出返工项,立即停下后续施工,回到用户当前指定页面;只有用户明确说“通过”后才能把该页改为 `[x]` 并冻结。
真实入口优先。开发者直达路由只用于排错,不能冒充流程可达证据。不同用户身份、空态、加载、失败、权限、审核中、被拒绝、已退出/移除等状态必须在同一页面验收项下覆盖。 真实入口优先。开发者直达路由只用于排错,不能冒充流程可达证据。不同用户身份、空态、加载、失败、权限、审核中、被拒绝、已退出/移除等状态必须在同一页面验收项下覆盖。
## 6. 必须使用的技能与触发时机 ## 6. 必须使用的技能与触发时机
@@ -200,7 +238,7 @@ node scripts/capture-chrome-page.js `
- 不是简单改 CSS;必须同时考虑用户流程、身份、错误操作、状态反馈、弹窗、加载、空态、主题、适配和性能。 - 不是简单改 CSS;必须同时考虑用户流程、身份、错误操作、状态反馈、弹窗、加载、空态、主题、适配和性能。
- 所有提示、确认、结果、底部选择和加载效果使用项目自定义组件;禁止直接展示原生 `uni.showToast``uni.showModal``uni.showLoading``uni.showActionSheet` - 所有提示、确认、结果、底部选择和加载效果使用项目自定义组件;禁止直接展示原生 `uni.showToast``uni.showModal``uni.showLoading``uni.showActionSheet`
- 主题必须提供“浅色、深色跟随系统”三个选项;深色主题沿用同一国风体系,不使用系统自动反色 - 基础版本固定使用当前浅色国风主题,不展示未生效的主题入口;深色跟随系统延期到下一版本,并在升级时另建全局主题合同与冻结页面回归计划
- 列表接近底部时使用项目自定义“正在展开更多……”视觉;失败保留已加载内容并允许重试;结束显示“已阅至末尾”。 - 列表接近底部时使用项目自定义“正在展开更多……”视觉;失败保留已加载内容并允许重试;结束显示“已阅至末尾”。
- 兼容 320×568、360×640、360×800、412×915、系统字体放大、状态栏、安全区、软键盘和返回手势。 - 兼容 320×568、360×640、360×800、412×915、系统字体放大、状态栏、安全区、软键盘和返回手势。
- 动画只使用 `transform``opacity` 等低成本属性;避免大面积高频重绘、同步阻塞、重复图片解码和无边界监听。 - 动画只使用 `transform``opacity` 等低成本属性;避免大面积高频重绘、同步阻塞、重复图片解码和无边界监听。
@@ -225,6 +263,14 @@ node scripts/capture-chrome-page.js `
## 10. 每轮完成前验证 ## 10. 每轮完成前验证
2026-07-16 已取得的内部验证证据如下(均不等于用户验收或 Android 通过):
- 静态合同通过:A01/A02、A04、A05、A06、G01、G03、G05、G06、G08-G10、G11-G12、T01、T03-T08,以及截图脚本和整页视觉合同。
- H5 运行时 smoke 分别通过:A01、A04、A05、A06、G01、G03、G05、G06、G08-G10、G11-G12、T01、T03-T08。G05 与 T03-T08 在串联 CDP 测试中曾因页面目标重建瞬时失败,独立重跑均已通过;不要把该诊断噪声写成功能缺陷,也不要隐瞒独立复核方式。
- 实际路由统计为 53`git diff --check` 退出码为 0,仅有 CRLF 警告。
- 长期代表图集中在 `docs/design/screens/handoff/2026-07-16/`,并已登记到 `docs/design/视觉证据索引.md`;这些仍是 H5 候选,Android/HBuilderX 真机或模拟器复核尚未完成。
- 仓库交接截图体积合同未通过,具体数据与处置边界见第 12 节。
先跑当前页面的聚焦验证。A01 当前最小集合: 先跑当前页面的聚焦验证。A01 当前最小集合:
```powershell ```powershell
@@ -243,7 +289,7 @@ git diff --check
2. 适用的运行时 smoke。 2. 适用的运行时 smoke。
3. 真实路由截图并人工查看,不只确认文件生成。 3. 真实路由截图并人工查看,不只确认文件生成。
4. 四档尺寸内部复核。 4. 四档尺寸内部复核。
5. 浅色、深色与跟随系统入口复核 5. 当前浅色国风主题及状态栏配色复核;深色与跟随系统不在基础版本范围内
6. `git diff --check` 6. `git diff --check`
7. 用户明确确认。 7. 用户明确确认。
@@ -260,20 +306,28 @@ GPT 不操作 Git。用户切换电脑前应自行确认以下内容已纳入版
- `tests/a02-route-removal-contract.ps1` - `tests/a02-route-removal-contract.ps1`
- `docs/验收规划.md` - `docs/验收规划.md`
- `docs/交接记录.md` - `docs/交接记录.md`
- `docs/design/视觉证据索引.md`
- `design-qa.md`
- `docs/design/screens/handoff/2026-07-15/A01-current-sms-generated-412x915.png` - `docs/design/screens/handoff/2026-07-15/A01-current-sms-generated-412x915.png`
- `docs/design/screens/handoff/2026-07-16/` 中 A01 至 G12 的当前代表图
- A01 当前页面实际引用的全部新增 PNG 资产 - A01 当前页面实际引用的全部新增 PNG 资产
- 2026-07-16 新增的各页面聚焦合同、运行时 smoke、截图脚本和实施计划
- `static/vendor/tac/`(后续行为验证功能阶段需要) - `static/vendor/tac/`(后续行为验证功能阶段需要)
`docs/design/screens/runtime/``tmp/` 和本机 Chrome 调试目录不能作为换机后的唯一资料来源。最终运行资产必须在 `static/assets/`,长期代表证据必须在 `docs/design/screens/handoff/`,决策与下一步必须写进本文件或 `docs/验收规划.md` `docs/design/screens/runtime/``tmp/` 和本机 Chrome 调试目录不能作为换机后的唯一资料来源。最终运行资产必须在 `static/assets/`,长期代表证据必须在 `docs/design/screens/handoff/`,决策与下一步必须写进本文件或 `docs/验收规划.md`
## 12. 仓库体积与本地缓存规则 ## 12. 仓库体积与本地缓存规则
2026-07-16 推送失败根因已处理:原本地提交包含约 **229,085,446 字节**新 Blob,混入嵌套 `node_modules`、生成预览、4 个大型 PSD 和候选中间 PNG,压缩上传包达到 81.39MiB 后被远端断开。清单改为正式运行资产自包含后,上述四类目录已从提交和本机删除;最新待推送提交的新 Blob 总量复核约 **20,518,032 字节**。旧大对象可能暂时存在于本机 `.git` reflog/对象库,但不再由最新提交引用,也不会随本次正常推送上传;不要为清理它们执行 reset 或手工删除 `.git` 对象。
2026-07-16 最新检查:`tests/repository-handoff-size-contract.ps1` **未通过**,报告 `too many handoff screenshots: 217`(该次检查发生在新增 T04 代表图之前)。该脚本当前统计了 `docs/design/screens/` 下包含已忽略 runtime 在内的全部截图;最终单独统计长期交接目录 `docs/design/screens/handoff/`**52 个文件、33,477,839 字节(约 31.93MB**,同样超过合同的 35 个文件/20MB门槛。本轮遵守用户禁令,没有删除、覆盖或清理任何历史证据、候选资产、runtime、tmp 或测试,也没有为了变绿而擅自放宽阈值。换机后应先让用户决定历史证据的归档策略,再单独处理体积合同;在此之前必须如实保留失败状态。
2026-07-15 已完成一次安全瘦身:项目总占用从约 1.03GB 降到约 232.76MB`tmp/` 从 922.89MB 降到 22.18MB。被清理的是 7 组可再生成的 `tmp/chrome-qa-*` 无头 Chrome 用户目录,没有删除源码、最终资产、代表证据或 ImageGen 源稿。 2026-07-15 已完成一次安全瘦身:项目总占用从约 1.03GB 降到约 232.76MB`tmp/` 从 922.89MB 降到 22.18MB。被清理的是 7 组可再生成的 `tmp/chrome-qa-*` 无头 Chrome 用户目录,没有删除源码、最终资产、代表证据或 ImageGen 源稿。
- `.git` 当前约 101.64MB`git fsck --full` 退出码为 0,只有可忽略的 dangling tree/blob,没有对象损坏报告。 - `.git` 当前约 101.64MB`git fsck --full` 退出码为 0,只有可忽略的 dangling tree/blob,没有对象损坏报告。
- 当前受版本控制的最大单文件约 2.91MB,不存在单文件 100MB 上传阻断。 - 当前受版本控制的最大单文件约 2.91MB,不存在单文件 100MB 上传阻断。
- 本轮需要用户纳入 Git 的新增规划、A01 资产、TAC 和代表图合计约 4.73MB。 - 本轮需要用户纳入 Git 的新增规划、A01 资产、TAC 和代表图合计约 4.73MB。
- `.gitignore` 已忽略 `/tmp/``/unpackage/``/node_modules/``/docs/design/screens/runtime/``/.superpowers/` 和临时 G01 审计图。 - `.gitignore` 已忽略 `/tmp/``/unpackage/`任意层级 `node_modules/``design-pipeline/generated/`、A01 `candidates/`、A01 `review/``/docs/design/screens/runtime/``/.superpowers/` 和临时 G01 审计图。
- 后续启动截图 Chrome 时,`--user-data-dir` 必须放在 `$env:TEMP`,禁止再把 Chrome 用户目录放进项目 `tmp/` - 后续启动截图 Chrome 时,`--user-data-dir` 必须放在 `$env:TEMP`,禁止再把 Chrome 用户目录放进项目 `tmp/`
- `tmp/` 只允许保存短期诊断图和 ImageGen 中间源,最终资产必须进入 `static/assets/`,换机代表证据必须进入 `docs/design/screens/handoff/` - `tmp/` 只允许保存短期诊断图和 ImageGen 中间源,最终资产必须进入 `static/assets/`,换机代表证据必须进入 `docs/design/screens/handoff/`
- 不要执行 `git add .` 后盲目提交;用户应先用 `git status --short --ignored` 确认缓存目录仍为 `!!` 忽略状态。 - 不要执行 `git add .` 后盲目提交;用户应先用 `git status --short --ignored` 确认缓存目录仍为 `!!` 忽略状态。
+14 -14
View File
@@ -2,7 +2,7 @@
> 更新日期:2026-07-15 > 更新日期:2026-07-15
> 适用范围:Android `uni-app` 用户端 > 适用范围:Android `uni-app` 用户端
> 当前实现:`pages.json` 仍有 54 条路由;A02 已确定并入 A01,完成收敛后的最终验收目标为 53 条路由。历史上的 7 个验收结果仅作为旧视觉证据,不计入本轮完成状态。 > 当前实现:`pages.json` 已收敛为 53最终路由;A02 已并入 A01。历史上的 7 个验收结果仅作为旧视觉证据,不计入本轮完成状态。
> 本文件只管理逐页视觉、页面状态外观、弹窗与加载样式、手机适配和表现质量;不替代 `docs/规划.md` 对项目范围和最终路由的管理,也不承担接口与业务功能验收。功能验收规则后续单独编写。 > 本文件只管理逐页视觉、页面状态外观、弹窗与加载样式、手机适配和表现质量;不替代 `docs/规划.md` 对项目范围和最终路由的管理,也不承担接口与业务功能验收。功能验收规则后续单独编写。
## 1. 标记与审核规则 ## 1. 标记与审核规则
@@ -19,7 +19,7 @@
1. 页面在真实运行环境中打开;流程页优先从上游可见入口进入,尚未实现入口时明确标记 `⚠`,不能冒充流程设计完整。 1. 页面在真实运行环境中打开;流程页优先从上游可见入口进入,尚未实现入口时明确标记 `⚠`,不能冒充流程设计完整。
2. 正常、加载、空、失败、权限、校验和成功等适用状态的视觉呈现均已检查;本阶段只验状态外观,不验证后台结果真伪。 2. 正常、加载、空、失败、权限、校验和成功等适用状态的视觉呈现均已检查;本阶段只验状态外观,不验证后台结果真伪。
3. 浅色、深色两套主题在 320×568、360×640、360×800、412×915 四个尺寸无溢出、裁切、遮挡或错位;跟随系统”使用当前系统主题,但不得依赖系统自动反色 3. 基础版本只验收当前浅色国风主题,并在 320×568、360×640、360×800、412×915 四个尺寸确认无溢出、裁切、遮挡或错位;深色与跟随系统整体延期到下一版本,不作为本轮 53 页通过条件
4. 默认系统字号和约 1.3 倍系统字号均可完整操作;关键按钮、表单错误、姓名、关系和操作结果不得截断,空间不足时允许合理换行或滚动,不得强行缩小文字或遮挡主操作,并使用超长族谱名、生僻姓名完成边界检查。 4. 默认系统字号和约 1.3 倍系统字号均可完整操作;关键按钮、表单错误、姓名、关系和操作结果不得截断,空间不足时允许合理换行或滚动,不得强行缩小文字或遮挡主操作,并使用超长族谱名、生僻姓名完成边界检查。
5. 状态栏、底部手势区、软键盘、系统返回和长文本不会破坏页面。 5. 状态栏、底部手势区、软键盘、系统返回和长文本不会破坏页面。
6. 点击反馈、加载、滚动、动画和图片解码在当前静态/Mock 数据下无明显卡顿。 6. 点击反馈、加载、滚动、动画和图片解码在当前静态/Mock 数据下无明显卡顿。
@@ -37,9 +37,9 @@
本轮不沿用任何页面的历史完成标记。A01、A04、A05、A06、G01、G03 也必须重新完成页面结构、视觉状态、兼容性、弹窗加载样式和表现质量检查;用户重新确认后才能标记 `[x]` 本轮不沿用任何页面的历史完成标记。A01、A04、A05、A06、G01、G03 也必须重新完成页面结构、视觉状态、兼容性、弹窗加载样式和表现质量检查;用户重新确认后才能标记 `[x]`
审核证据默认每页只展示一张 360×800 代表图;需要同时确认明暗主题时,将浅色与深色代表态合成一张联系表,不连续展示张前后对比图。其余尺寸在内部完成检查,只有发现问题或用户要求时才展开。 审核证据默认每页只展示一张 360×800 代表图;复杂状态合成一张联系表,不连续展示张前后对比图。其余尺寸在内部完成检查,只有发现问题或用户要求时才展开。
每个页面必须在同一验收项内完成浅色与深色主题,不拆分为两轮:两套主题的代表态、适用关键状态、四档尺寸、代码质量和性能全部通过后,才可提交一张明暗联系表供用户确认;用户确认后两套主题同时标记 `[x]` 并冻结。任一主题未完成或未通过时,整个页面保持 `[~]``[!]` 基础版本的每个页面只按当前浅色国风主题完成适用关键状态、四档尺寸、代码质量和性能验收;用户确认后标记 `[x]` 并冻结。下一版本引入深色与跟随系统时,属于会影响全部冻结页面的全局视觉变更,必须单独建立升级验收计划,并按冻结回归规则重新复核受影响页面
## 2. 已确认的核心用户流程合同 ## 2. 已确认的核心用户流程合同
@@ -53,7 +53,7 @@
- A04 注册成功后直接建立登录状态并进入 G01“我的家谱”,不得要求用户返回 A01 重复输入账号密码;注册接口能否返回有效登录凭证在后续功能验收核对,当前页面样式验收先覆盖提交中、成功过渡和进入 G01 的视觉连续性。 - A04 注册成功后直接建立登录状态并进入 G01“我的家谱”,不得要求用户返回 A01 重复输入账号密码;注册接口能否返回有效登录凭证在后续功能验收核对,当前页面样式验收先覆盖提交中、成功过渡和进入 G01 的视觉连续性。
- A05 重置密码成功后不自动登录:先显示短暂的自定义成功结果,再返回 A01,自动保留刚才使用的手机号、切换到密码登录并聚焦密码框,让用户使用新密码完成登录;不得清空手机号或要求重新寻找登录方式。 - A05 重置密码成功后不自动登录:先显示短暂的自定义成功结果,再返回 A01,自动保留刚才使用的手机号、切换到密码登录并聚焦密码框,让用户使用新密码完成登录;不得清空手机号或要求重新寻找登录方式。
- A01 内部直接处理密码错误、验证码错误和手机号未注册,使用就近校验,不跳转 A06;微信授权取消仍留在 A01,视为用户取消而非登录失败。A06 只承接账号冻结、停用、风险限制或其他无法继续登录的阻断性状态,必须说明原因、影响范围和明确恢复入口,不得成为普通错误的汇总页。 - A01 内部直接处理密码错误、验证码错误和手机号未注册,使用就近校验,不跳转 A06;微信授权取消仍留在 A01,视为用户取消而非登录失败。A06 只承接账号冻结、停用、风险限制或其他无法继续登录的阻断性状态,必须说明原因、影响范围和明确恢复入口,不得成为普通错误的汇总页。
- A01 在 320×568 最小验收尺寸下不得依赖纵向滚动:品牌区、密码/验证码切换、当前登录表单、登录按钮、微信登录、注册、忘记密码和协议入口必须同屏可见。小屏允许明显压缩品牌头与山水装饰高度,但不得缩小输入框、按钮和协议的有效触控区 - A01 在 320×568 最小验收尺寸下允许自然纵向滚动,不为追求全部核心入口同屏而压缩品牌头、表单、字号或有效触控区;必须保证首屏层级清楚、滚动顺畅、无横向溢出,底部协议和主操作可完整滚动到达
- A01 未勾选协议时采用页内校验:协议区域轻微震动并高亮,下方显示明确错误文案;不弹原生 Toast 或确认弹窗,不跳转。用户勾选后错误样式立即消失。 - A01 未勾选协议时采用页内校验:协议区域轻微震动并高亮,下方显示明确错误文案;不弹原生 Toast 或确认弹窗,不跳转。用户勾选后错误样式立即消失。
- A02 从最终路由和验收表移除,不保留兼容入口;A04、A05、A06 的登录返回统一指向 A01。实际代码收敛完成前,`pages.json` 的 54 条是实施现状,不是最终验收数量。 - A02 从最终路由和验收表移除,不保留兼容入口;A04、A05、A06 的登录返回统一指向 A01。实际代码收敛完成前,`pages.json` 的 54 条是实施现状,不是最终验收数量。
- A01、A04、A05 共用同一行为验证弹层,但触发时机不同。A01 登录在账号信息、协议等本地校验通过并点击“登录”后弹出,验证成功后自动继续登录;A05 忘记密码在用户请求发送短信验证码前弹出,验证成功后自动发送短信,重置提交时不重复验证。A04 注册当前因短信注册接口尚未提供,临时按 A01 方式在完整填写并点击“注册”后弹出;注册短信接口提供即为临时契约的移除条件,届时必须迁移为“发送短信验证码前验证”,并删除注册提交前的旧触发路径,不保留双重验证。用户关闭或验证失败时必须保留原表单内容。当前页面样式阶段只审核弹层初始、拖动、成功、失败和关闭状态,不审核真实滑动轨迹、`validToken` 或接口自动续接。原始 TAC 运行库保存在 `static/vendor/tac/`,留待功能阶段使用。 - A01、A04、A05 共用同一行为验证弹层,但触发时机不同。A01 登录在账号信息、协议等本地校验通过并点击“登录”后弹出,验证成功后自动继续登录;A05 忘记密码在用户请求发送短信验证码前弹出,验证成功后自动发送短信,重置提交时不重复验证。A04 注册当前因短信注册接口尚未提供,临时按 A01 方式在完整填写并点击“注册”后弹出;注册短信接口提供即为临时契约的移除条件,届时必须迁移为“发送短信验证码前验证”,并删除注册提交前的旧触发路径,不保留双重验证。用户关闭或验证失败时必须保留原表单内容。当前页面样式阶段只审核弹层初始、拖动、成功、失败和关闭状态,不审核真实滑动轨迹、`validToken` 或接口自动续接。原始 TAC 运行库保存在 `static/vendor/tac/`,留待功能阶段使用。
@@ -122,7 +122,7 @@
所有表单采用“就近显示错误”:单字段错误显示在对应输入框下;关联错误同时标记相关字段;页面级错误显示在提交区附近;网络或系统错误使用自定义结果弹层。提交时错误字段执行一次 200–260ms、3–5px 的横向视觉抖动,只使用 `transform`,不调用手机振动、不循环闪烁;动画必须与静态错误描边和文字同时存在,减少动态效果时仅保留静态提示。 所有表单采用“就近显示错误”:单字段错误显示在对应输入框下;关联错误同时标记相关字段;页面级错误显示在提交区附近;网络或系统错误使用自定义结果弹层。提交时错误字段执行一次 200–260ms、3–5px 的横向视觉抖动,只使用 `transform`,不调用手机振动、不循环闪烁;动画必须与静态错误描边和文字同时存在,减少动态效果时仅保留静态提示。
主题模式统一提供“浅色、深色、跟随系统”三个选项。入口放在 M10“设置与关于”页面顶部,由 M01 的“设置”菜单进入,不新增独立路由。浅色与深色共用页面结构、组件层级、交互规则和国风视觉语言,但必须通过统一主题变量分别定义背景、文字、边框、遮罩、状态色、状态栏图标和必要位图资产;严禁使用系统自动反色或在页面内散落颜色覆盖。用户选择后当前页面立即生效,不重启、不重新进入页面、不闪白屏;页面、弹窗和状态栏使用一次 150–200ms 的轻微明暗过渡同步完成切换,不得出现局部延迟或颜色割裂。当前页面样式验收检查三选项控件和两套主题的完整视觉;选项持久化、重启恢复、系统主题变化后的自动联动归入后续功能验收 基础版本固定使用当前浅色国风主题,M10“设置与关于”不展示尚未生效的外观模式入口,也不实现主题切换、持久化或跟随系统。深色与跟随系统统一延期到下一版本;升级时继续使用统一主题变量管理背景、文字、边框、遮罩、状态色、状态栏图标和必要位图资产,禁止依赖系统自动反色或在页面内散落覆盖样式。下一版本的主题切换、过渡、持久化和系统联动必须另建产品合同与验收计划
所有分页列表共用同一套底部加载视觉:接近列表末尾时在内容流内显示轻量水墨/印章动效和“正在展开更多……”;失败时保留已加载内容并显示“加载失败,点击重试”;数据结束时显示“已阅至末尾”;数据不足一页时不强行显示结束文案,也不循环触发加载。底部分页不得使用全屏遮罩、清空现有列表、改变当前滚动位置或造成列表跳动。 所有分页列表共用同一套底部加载视觉:接近列表末尾时在内容流内显示轻量水墨/印章动效和“正在展开更多……”;失败时保留已加载内容并显示“加载失败,点击重试”;数据结束时显示“已阅至末尾”;数据不足一页时不强行显示结束文案,也不循环触发加载。底部分页不得使用全屏遮罩、清空现有列表、改变当前滚动位置或造成列表跳动。
@@ -158,14 +158,14 @@
| `[ ]` | C05 | 页面级加载 | 首屏使用结构稳定的骨架屏;按钮在原位置显示内部进度;局部卡片仅覆盖局部;列表统一下拉刷新与底部加载状态;仅在页面结构完全不可预知时使用全屏加载,不闪白屏或突然跳布局 | | `[ ]` | C05 | 页面级加载 | 首屏使用结构稳定的骨架屏;按钮在原位置显示内部进度;局部卡片仅覆盖局部;列表统一下拉刷新与底部加载状态;仅在页面结构完全不可预知时使用全屏加载,不闪白屏或突然跳布局 |
| `[ ]` | C06 | 按钮级加载 | 登录、提交、保存期间立即禁用重复点击并显示明确进度 | | `[ ]` | C06 | 按钮级加载 | 登录、提交、保存期间立即禁用重复点击并显示明确进度 |
| `[ ]` | C07 | 权限与进度 | 相册、上传、分享权限及上传进度、取消、失败重试均有自定义反馈 | | `[ ]` | C07 | 权限与进度 | 相册、上传、分享权限及上传进度、取消、失败重试均有自定义反馈 |
| `[ ]` | C08 | 主题与四尺寸适配 | 浅色、深色均通过 320×568、360×640、360×800、412×915跟随系统不自动反色,状态栏、弹窗和页面颜色一致且无溢出、裁切和错位 | | `[ ]` | C08 | 基础主题与四尺寸适配 | 当前浅色国风主题通过 320×568、360×640、360×800、412×915;状态栏、弹窗和页面颜色一致且无溢出、裁切和错位;深色与跟随系统延期到下一版本 |
| `[ ]` | C09 | 安全区适配 | 状态栏、刘海、底部手势区和固定导航不遮挡页面内容或操作 | | `[ ]` | C09 | 安全区适配 | 状态栏、刘海、底部手势区和固定导航不遮挡页面内容或操作 |
| `[ ]` | C10 | 软键盘适配 | 输入框、校验信息和提交按钮不被软键盘遮挡,页面可滚动到当前字段 | | `[ ]` | C10 | 软键盘适配 | 输入框、校验信息和提交按钮不被软键盘遮挡,页面可滚动到当前字段 |
| `[ ]` | C11 | 字体与长文本 | 默认字号与约 1.3 倍系统字号下,Android 字体回退、超长族谱名、生僻姓名和长文案均不重叠、不截断关键含义、不遮挡主操作 | | `[ ]` | C11 | 字体与长文本 | 默认字号与约 1.3 倍系统字号下,Android 字体回退、超长族谱名、生僻姓名和长文案均不重叠、不截断关键含义、不遮挡主操作 |
| `[ ]` | C12 | 触控与误操作 | 点击区不小于约 44dp;连续点击不重复提交;返回前保护未保存内容 | | `[ ]` | C12 | 触控与误操作 | 点击区不小于约 44dp;连续点击不重复提交;返回前保护未保存内容 |
| `[ ]` | C13 | 图片性能 | 图片按显示槽位使用;避免同页同时解码多张超大位图;屏外图片延迟加载 | | `[ ]` | C13 | 图片性能 | 图片按显示槽位使用;避免同页同时解码多张超大位图;屏外图片延迟加载 |
| `[ ]` | C14 | 列表性能 | 使用至少 500 条 Mock 数据测试;长列表分批渲染并使用稳定 `key`;下拉刷新和触底加载均保留已有内容、滚动位置与布局高度;短列表不循环触发,滚动时不反复重绘整页 | | `[ ]` | C14 | 列表性能 | 使用至少 500 条 Mock 数据测试;长列表分批渲染并使用稳定 `key`;下拉刷新和触底加载均保留已有内容、滚动位置与布局高度;短列表不循环触发,滚动时不反复重绘整页 |
| `[ ]` | C15 | 动画性能 | 优先使用 `transform``opacity`;不使用大面积模糊、复杂滤镜和重阴影动画;主题切换在 150–200ms 内同步完成且不白屏、不持续掉帧 | | `[ ]` | C15 | 动画性能 | 优先使用 `transform``opacity`;不使用大面积模糊、复杂滤镜和重阴影动画;页面过渡不白屏、不持续掉帧 |
| `[ ]` | C16 | 响应反馈 | 点击后 100ms 内出现反馈;超过 300ms 的操作必须显示自定义加载状态 | | `[ ]` | C16 | 响应反馈 | 点击后 100ms 内出现反馈;超过 300ms 的操作必须显示自定义加载状态 |
| `[ ]` | C17 | 页面生命周期 | 离开页面时清理定时器、监听器、上传任务和动画状态,避免越用越卡 | | `[ ]` | C17 | 页面生命周期 | 离开页面时清理定时器、监听器、上传任务和动画状态,避免越用越卡 |
| `[ ]` | C18 | 路由压力 | 同一详情连续进入/退出 20 次,快速切 Tab、重复开关弹窗,不白屏、不串状态、不重复堆栈、不残留遮罩且不逐次变慢 | | `[ ]` | C18 | 路由压力 | 同一详情连续进入/退出 20 次,快速切 Tab、重复开关弹窗,不白屏、不串状态、不重复堆栈、不残留遮罩且不逐次变慢 |
@@ -178,7 +178,7 @@
| 状态 | 序号 | 页面 | 真实入口与必须覆盖的可能性 | | 状态 | 序号 | 页面 | 真实入口与必须覆盖的可能性 |
| --- | ---: | --- | --- | | --- | ---: | --- | --- |
| `[~]` | 1 | A01 登录 | APP 首次打开;首次默认密码登录、后续恢复上次密码/短信方式,品牌区、微信登录入口、注册、忘记密码、协议未勾选/已勾选、空输入、错误提示、完整输入后点击登录再显示行为验证、按钮加载、键盘遮挡;320×568 全部核心入口同屏且不滚动 | | `[~]` | 1 | A01 登录 | APP 首次打开;首次默认密码登录、后续恢复上次密码/短信方式,品牌区、微信登录入口、注册、忘记密码、协议未勾选/已勾选、空输入、错误提示、完整输入后点击登录再显示行为验证、按钮加载、键盘遮挡;320×568 保持正常比例并允许自然纵向滚动 |
| `[~]` | 2 | A04 注册账号 | A01→注册;协议、手机号错误、密码不一致、注册中、成功/失败页面样式;当前临时在完整填写并点击注册后显示行为验证,注册短信接口提供后迁移为发送验证码前验证;注册成功后自动建立登录状态并进入 G01 | | `[~]` | 2 | A04 注册账号 | A01→注册;协议、手机号错误、密码不一致、注册中、成功/失败页面样式;当前临时在完整填写并点击注册后显示行为验证,注册短信接口提供后迁移为发送验证码前验证;注册成功后自动建立登录状态并进入 G01 |
| `[~]` | 3 | A05 重置密码 | A01→忘记密码;手机号、发送短信前行为验证、验证码、新密码、两次不一致、重设中、成功/失败页面样式;成功后返回 A01,保留手机号、切到密码登录并聚焦密码框,不自动登录 | | `[~]` | 3 | A05 重置密码 | A01→忘记密码;手机号、发送短信前行为验证、验证码、新密码、两次不一致、重设中、成功/失败页面样式;成功后返回 A01,保留手机号、切到密码登录并聚焦密码框,不自动登录 |
| `[~]` ⚠ | 4 | A06 登录状态 | A01 登录结果→阻断状态;仅覆盖账号冻结、停用、风险限制等无法继续登录的状态,必须展示原因、影响范围和恢复入口;普通密码/验证码错误、未注册及微信取消均留在 A01;当前缺少真实结果跳转 | | `[~]` ⚠ | 4 | A06 登录状态 | A01 登录结果→阻断状态;仅覆盖账号冻结、停用、风险限制等无法继续登录的状态,必须展示原因、影响范围和恢复入口;普通密码/验证码错误、未注册及微信取消均留在 A01;当前缺少真实结果跳转 |
@@ -265,7 +265,7 @@
| `[~]` ⚠ | 50 | M07 意见反馈 | 应由 M06 进入;校验、提交中、成功/失败 | | `[~]` ⚠ | 50 | M07 意见反馈 | 应由 M06 进入;校验、提交中、成功/失败 |
| `[~]` ⚠ | 51 | M08 应用推广 | 应由 M01 进入;分享权限、生成海报、取消、失败 | | `[~]` ⚠ | 51 | M08 应用推广 | 应由 M01 进入;分享权限、生成海报、取消、失败 |
| `[~]` ⚠ | 52 | M09 VIP 与订单 | 应由 M01 进入;无订单、权益、订单列表、加载失败 | | `[~]` ⚠ | 52 | M09 VIP 与订单 | 应由 M01 进入;无订单、权益、订单列表、加载失败 |
| `[~]` | 53 | M10 设置与关于 | M01→设置;页面顶部外观模式(浅色/深色/跟随系统)、协议、隐私、版本、退出确认/取消;明暗主题视觉均需审核 | | `[~]` | 53 | M10 设置与关于 | M01→设置;协议、隐私、版本、退出确认/取消;基础版本不展示外观模式入口,深色与跟随系统延期到下一版本 |
## 6. 当前视觉流程断点与后续功能依赖 ## 6. 当前视觉流程断点与后续功能依赖
@@ -286,12 +286,12 @@
1. 从本表上一项或明确的分支入口进入页面。 1. 从本表上一项或明确的分支入口进入页面。
2. 捕获本轮真实运行截图并检查是否为正确页面、正确状态、非加载中画面。 2. 捕获本轮真实运行截图并检查是否为正确页面、正确状态、非加载中画面。
3. 默认展示一张代表图;明暗主题或复杂状态展示一张联系表,不连续铺陈对比截图。 3. 默认展示一张代表图;复杂状态展示一张联系表,不连续铺陈对比截图。
4. 确认浅色与深色的关键状态、四档尺寸、代码质量和性能均已通过;任一主题未通过时不得提交冻结 4. 确认当前浅色国风主题的关键状态、四档尺寸、代码质量和性能均已通过。
5. 用户确认通过则两套主题同时`[x]` 并冻结;用户指出问题则整个页面标 `[!]` 5. 用户确认通过则当前基础版本页面`[x]` 并冻结;用户指出问题则整个页面标 `[!]`
6. 返工前先更新最小契约测试并确认 RED,再做最小实现。 6. 返工前先更新最小契约测试并确认 RED,再做最小实现。
7. 测试、构建、截图或路由异常时先复现并定位根因,不盲改样式。 7. 测试、构建、截图或路由异常时先复现并定位根因,不盲改样式。
8. 返工后运行受影响测试、运行时 smoke、两套主题四尺寸复核、真实路由截图和 `git diff --check` 8. 返工后运行受影响测试、运行时 smoke、当前主题四尺寸复核、真实路由截图和 `git diff --check`
9. 接口资料只用于理解功能、字段和状态位置;本阶段不对接接口,也不执行接口功能验收。 9. 接口资料只用于理解功能、字段和状态位置;本阶段不对接接口,也不执行接口功能验收。
## 8. 单项验收记录模板 ## 8. 单项验收记录模板
+475 -126
View File
@@ -1,80 +1,73 @@
<!-- 页面编号A-01用途APP 唯一登录入口承载密码验证码与微信登录入口 --> <!-- 页面编号A-01用途APP 唯一登录入口承载密码验证码与微信登录入口 -->
<template> <template>
<view class="auth-page login-page"> <view class="auth-page login-page">
<image class="paper-background" src="/static/assets/foundation/opaque/auth-page-paper.jpg" mode="aspectFill" /> <view class="page-canvas">
<image class="scenery-background" src="/static/assets/foundation/transparent/auth-ink-scenery.png" mode="aspectFill" /> <image class="page-backdrop" src="/static/assets/modules/auth/opaque/a01-red-hall-ink-backdrop-v1.png" mode="scaleToFill" />
<image class="brand-seal" src="/static/assets/foundation/transparent/brand-seal.png" mode="aspectFit" />
<view class="auth-content"> <view class="login-content">
<view class="auth-header"> <view class="login-heading">
<image class="header-background" src="/static/assets/modules/auth/opaque/a01-login-header-v1.png" mode="scaleToFill" /> <text class="login-title">登录家谱</text>
</view>
<view class="login-scroll">
<image class="login-scroll__skin" src="/static/assets/modules/auth/transparent/a01-login-scroll-frame-v1.png" mode="scaleToFill" />
<view class="login-scroll__content">
<view class="login-heading">
<image class="heading-cloud" src="/static/assets/foundation/transparent/auth-title-cloud.png" mode="aspectFit" />
<text class="login-title">登录家谱</text>
<image class="heading-cloud heading-cloud--right" src="/static/assets/foundation/transparent/auth-title-cloud.png" mode="aspectFit" />
</view>
<image class="title-divider" src="/static/assets/modules/auth/opaque/a01-title-divider-v2.png" mode="scaleToFill" /> <image class="title-divider" src="/static/assets/modules/auth/opaque/a01-title-divider-v2.png" mode="scaleToFill" />
</view>
<view class="login-tabs"> <view class="login-tabs">
<view class="login-tab" :class="{ active: activeLoginMethod === 'password' }" hover-class="tap-fade" @click="switchLoginMethod('password')">密码登录</view> <view class="login-tab" :class="{ active: activeLoginMethod === 'password' }" hover-class="tap-fade" @click="switchLoginMethod('password')">密码登录</view>
<view class="login-tab" :class="{ active: activeLoginMethod === 'sms' }" hover-class="tap-fade" @click="switchLoginMethod('sms')">验证码登录</view> <view class="login-tab" :class="{ active: activeLoginMethod === 'sms' }" hover-class="tap-fade" @click="switchLoginMethod('sms')">验证码登录</view>
</view>
<view class="form-content">
<view class="input-row">
<image class="input-icon" src="/static/assets/modules/auth/transparent/a01-icon-phone-v1.png" mode="aspectFit" />
<input v-model.trim="phone" class="auth-input" type="number" maxlength="11" placeholder="手机号" placeholder-class="input-placeholder" confirm-type="next" />
</view> </view>
<view class="form-content"> <view v-if="activeLoginMethod === 'password'" class="input-row">
<view class="input-row"> <image class="input-icon" src="/static/assets/modules/auth/transparent/a01-icon-lock-v1.png" mode="aspectFit" />
<image class="input-icon" src="/static/assets/modules/auth/transparent/a01-icon-phone-v1.png" mode="aspectFit" /> <input v-model="password" class="auth-input" :password="!passwordVisible" maxlength="32" placeholder="密码" placeholder-class="input-placeholder" confirm-type="done" @confirm="submitLogin" />
<input v-model.trim="phone" class="auth-input" type="number" maxlength="11" placeholder="手机号" placeholder-class="input-placeholder" confirm-type="next" /> <view class="password-toggle" hover-class="tap-fade" @click="togglePasswordVisibility">
</view> <image class="password-toggle__icon" :src="passwordVisible ? '/static/assets/modules/auth/transparent/a01-icon-eye-open-v1.png' : '/static/assets/modules/auth/transparent/a01-icon-eye-closed-pupil-v2.png'" mode="aspectFit" />
<view v-if="activeLoginMethod === 'password'" class="input-row">
<image class="input-icon" src="/static/assets/modules/auth/transparent/a01-icon-lock-v1.png" mode="aspectFit" />
<input v-model="password" class="auth-input" :password="!passwordVisible" maxlength="32" placeholder="密码" placeholder-class="input-placeholder" confirm-type="done" @confirm="submitLogin" />
<view class="password-toggle" hover-class="tap-fade" @click="togglePasswordVisibility">
<image class="password-toggle__icon" :src="passwordVisible ? '/static/assets/modules/auth/transparent/a01-icon-eye-open-v1.png' : '/static/assets/modules/auth/transparent/a01-icon-eye-closed-pupil-v2.png'" mode="aspectFit" />
</view>
</view>
<view v-else class="input-row">
<image class="input-icon" src="/static/assets/modules/auth/transparent/a01-icon-sms-code-v2.png" mode="aspectFit" />
<input v-model.trim="verificationCode" class="auth-input" type="number" maxlength="6" placeholder="验证码" placeholder-class="input-placeholder" confirm-type="done" @confirm="submitLogin" />
<view class="get-code" hover-class="tap-fade" @click="prepareGetCode">获取验证码</view>
</view>
<view class="form-secondary-row">
<view v-if="activeLoginMethod === 'password'" class="forgot-password" hover-class="tap-fade" @click="prepareForgotPassword">忘记密码</view>
</view> </view>
</view> </view>
<view class="login-submit" hover-class="button-pressed" @click="submitLogin"> <view v-else class="input-row">
<image class="button-skin" src="/static/assets/foundation/opaque/a01-primary-button.png" mode="scaleToFill" /> <image class="input-icon input-icon--sms" src="/static/assets/modules/auth/transparent/a01-icon-sms-code-v2.png" mode="aspectFit" />
<text class="login-submit__copy">登录</text> <input v-model.trim="verificationCode" class="auth-input" type="number" maxlength="6" placeholder="短信验证码" placeholder-class="input-placeholder" confirm-type="done" @confirm="submitLogin" />
<view class="get-code" hover-class="tap-fade" @click="prepareGetCode">获取验证码</view>
</view> </view>
<view class="other-login-divider"> <view class="form-secondary-row">
<view class="divider-line" /> <view v-if="activeLoginMethod === 'password'" class="forgot-password" hover-class="tap-fade" @click="prepareForgotPassword">忘记密码</view>
<image class="divider-knot" src="/static/assets/foundation/transparent/auth-divider-knot.png" mode="aspectFit" />
<text>其他登录方式</text>
<image class="divider-knot divider-knot--right" src="/static/assets/foundation/transparent/auth-divider-knot.png" mode="aspectFit" />
<view class="divider-line" />
</view> </view>
</view>
<view class="wechat-login" hover-class="button-pressed" @click="prepareWechatLogin"> <view class="login-submit" hover-class="button-pressed" @click="submitLogin">
<image class="button-skin" src="/static/assets/foundation/opaque/a01-secondary-button.png" mode="scaleToFill" /> <image class="button-skin" src="/static/assets/foundation/opaque/a01-primary-button.png" mode="scaleToFill" />
<view class="wechat-login__content"> <text class="login-submit__copy">登录</text>
<image class="wechat-icon" src="/static/assets/foundation/transparent/auth-wechat.png" mode="aspectFit" /> </view>
<text>微信登录</text>
</view> <view class="other-login-divider">
<view class="divider-line" />
<image class="divider-knot" src="/static/assets/foundation/transparent/auth-divider-knot.png" mode="aspectFit" />
<text>其他登录方式</text>
<image class="divider-knot divider-knot--right" src="/static/assets/foundation/transparent/auth-divider-knot.png" mode="aspectFit" />
<view class="divider-line" />
</view>
<view class="wechat-login" hover-class="button-pressed" @click="prepareWechatLogin">
<image class="button-skin" src="/static/assets/foundation/opaque/a01-secondary-button.png" mode="scaleToFill" />
<view class="wechat-login__content">
<image class="wechat-icon" src="/static/assets/foundation/transparent/auth-wechat.png" mode="aspectFit" />
<text>微信登录</text>
</view> </view>
</view>
<view class="register-entry"> <view class="register-entry">
<text>还没有账号</text> <text>还没有账号</text>
<view class="register-link" hover-class="tap-fade" @click="prepareRegister">注册账号</view> <view class="register-link" hover-class="tap-fade" @click="prepareRegister">注册账号</view>
</view> </view>
<view class="agreement-area" :class="{ 'agreement-area--error': agreementError }">
<view class="agreement-row" @click="toggleAgreement"> <view class="agreement-row" @click="toggleAgreement">
<image class="agreement-icon" :src="agreed ? '/static/assets/modules/auth/transparent/a02-agreement-checked.png' : '/static/assets/modules/auth/transparent/a02-agreement-unchecked.png'" mode="aspectFit" /> <image class="agreement-icon" :src="agreed ? '/static/assets/modules/auth/transparent/a02-agreement-checked.png' : '/static/assets/modules/auth/transparent/a02-agreement-unchecked.png'" mode="aspectFit" />
<view class="agreement-copy"> <view class="agreement-copy">
@@ -84,13 +77,12 @@
<text class="agreement-link" @click.stop="prepareAgreement">隐私政策</text> <text class="agreement-link" @click.stop="prepareAgreement">隐私政策</text>
</view> </view>
</view> </view>
<text v-if="agreementError" class="agreement-error">请先阅读并同意用户协议与隐私政策</text>
</view> </view>
</view> </view>
</view> </view>
<image class="exact-header-overlay" src="/static/assets/modules/auth/opaque/a01-login-header-exact-v2.png" mode="scaleToFill" /> <!-- 当前只验收自定义容器外观真实行为验证数据留到接口阶段接入 -->
<!-- 先验收自定义容器真实滑块数据留到接口阶段接入 -->
<view v-if="verificationVisible" class="verification-layer" @click="closeVerification"> <view v-if="verificationVisible" class="verification-layer" @click="closeVerification">
<view class="verification-dialog" @click.stop> <view class="verification-dialog" @click.stop>
<image class="verification-dialog__skin" src="/static/assets/modules/auth/opaque/a02-login-panel.png" mode="scaleToFill" /> <image class="verification-dialog__skin" src="/static/assets/modules/auth/opaque/a02-login-panel.png" mode="scaleToFill" />
@@ -124,6 +116,7 @@ const phone = ref('')
const password = ref('') const password = ref('')
const verificationCode = ref('') const verificationCode = ref('')
const agreed = ref(false) const agreed = ref(false)
const agreementError = ref(false)
const verificationVisible = ref(false) const verificationVisible = ref(false)
const verificationPurpose = ref('登录') const verificationPurpose = ref('登录')
const feedbackVisible = ref(false) const feedbackVisible = ref(false)
@@ -161,6 +154,7 @@ const togglePasswordVisibility = () => {
const toggleAgreement = () => { const toggleAgreement = () => {
agreed.value = !agreed.value agreed.value = !agreed.value
if (agreed.value) agreementError.value = false
} }
const validatePhone = () => { const validatePhone = () => {
@@ -171,7 +165,7 @@ const validatePhone = () => {
const requireAgreement = () => { const requireAgreement = () => {
if (agreed.value) return true if (agreed.value) return true
showFeedback('请先阅读并同意用户协议与隐私政策') agreementError.value = true
return false return false
} }
@@ -228,87 +222,442 @@ const prepareAgreement = () => showFeedback('协议页面准备中')
min-height: 100%; min-height: 100%;
overflow-x: hidden; overflow-x: hidden;
overflow-y: auto; overflow-y: auto;
background: #f6efe2; background: #f7f0e5;
color: #493323; color: #493323;
} }
.paper-background, .page-canvas {
.scenery-background { position: relative;
position: fixed; box-sizing: border-box;
width: 100%;
min-height: calc(1665rpx + env(safe-area-inset-bottom));
padding-bottom: env(safe-area-inset-bottom);
}
.page-backdrop {
position: absolute;
z-index: 0;
inset: 0 0 auto;
width: 100%;
height: 1665rpx;
pointer-events: none;
}
.brand-seal {
position: absolute;
z-index: 1;
top: 64rpx;
left: 50%;
width: 220rpx;
height: 264rpx;
transform: translateX(-50%);
pointer-events: none;
}
.login-content {
position: relative;
z-index: 2;
box-sizing: border-box;
width: 100%;
padding: 480rpx 64rpx 34rpx;
}
.login-heading {
position: relative;
display: flex;
align-items: center;
justify-content: center;
height: 118rpx;
}
.login-title {
color: #9f170f;
font-family: STSong, SimSun, serif;
font-size: 82rpx;
font-weight: 700;
letter-spacing: 8rpx;
line-height: 1;
}
.title-divider {
position: absolute;
bottom: -4rpx;
left: 50%;
width: 286rpx;
height: 32rpx;
transform: translateX(-50%);
}
.login-tabs {
display: flex;
align-items: flex-end;
height: 114rpx;
margin-top: 10rpx;
}
.login-tab {
position: relative;
display: flex;
flex: 1;
align-items: center;
justify-content: center;
min-height: 88rpx;
color: #80684f;
font-family: STSong, SimSun, serif;
font-size: 40rpx;
letter-spacing: 2rpx;
}
.login-tab:first-child::before {
position: absolute;
top: 50%;
right: 0;
width: 1rpx;
height: 36rpx;
background: rgba(176, 120, 52, 0.58);
content: '';
transform: translateY(-50%);
}
.login-tab.active {
color: #a9160d;
font-weight: 700;
}
.login-tab.active::after {
position: absolute;
right: 58rpx;
bottom: 0;
left: 58rpx;
height: 5rpx;
border-radius: 5rpx;
background: #ad160d;
content: '';
}
.form-content { margin-top: 10rpx; }
.input-row {
display: flex;
align-items: center;
box-sizing: border-box;
min-height: 112rpx;
padding-left: 12rpx;
border-bottom: 1rpx solid rgba(182, 116, 43, 0.68);
}
.input-icon {
flex: 0 0 auto;
width: 64rpx;
height: 64rpx;
margin-right: 22rpx;
transform: translateX(-12rpx);
}
.input-icon--sms { transform: translateX(-12rpx) scale(1.45); }
.auth-input {
flex: 1;
min-width: 0;
height: 104rpx;
color: #493323;
font-size: 40rpx;
}
.input-placeholder { color: #9f968d; }
.password-toggle {
display: flex;
flex: 0 0 auto;
align-items: center;
justify-content: center;
width: 88rpx;
height: 88rpx;
}
.password-toggle__icon {
width: 64rpx;
height: 64rpx;
}
.get-code {
display: flex;
flex: 0 0 auto;
align-items: center;
justify-content: flex-end;
min-width: 142rpx;
min-height: 88rpx;
color: #a9160d;
font-size: 32rpx;
white-space: nowrap;
}
.form-secondary-row {
display: flex;
align-items: center;
justify-content: flex-end;
min-height: 110rpx;
}
.forgot-password {
display: flex;
align-items: center;
min-height: 88rpx;
color: #a9160d;
font-size: 34rpx;
}
.login-submit,
.wechat-login {
position: relative;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
}
.login-submit { height: 92rpx; }
.button-skin {
position: absolute;
inset: 0; inset: 0;
width: 100%; width: 100%;
height: 100%; height: 100%;
pointer-events: none; pointer-events: none;
} }
.paper-background { opacity: 0.98; }
.scenery-background { opacity: 0.72; }
.exact-header-overlay { position: fixed; z-index: 5; top: 0; left: 0; width: 100%; height: 267rpx; pointer-events: none; }
.auth-content { position: relative; z-index: 1; min-height: 100%; padding-bottom: env(safe-area-inset-bottom); } .login-submit__copy {
.auth-header { position: relative; height: 304rpx; } position: relative;
.header-background { width: 100%; height: 100%; } z-index: 1;
color: #fffaf1;
font-family: STSong, SimSun, serif;
font-size: 52rpx;
letter-spacing: 8rpx;
}
.login-scroll { position: relative; z-index: 4; box-sizing: border-box; height: 1520rpx; margin: -80rpx 12rpx 0; } .other-login-divider {
.login-scroll__skin { position: absolute; inset: 0; width: 100%; height: 100%; pointer-events: none; } display: flex;
.login-scroll__content { position: relative; z-index: 1; box-sizing: border-box; height: 100%; padding: 224rpx 97rpx 66rpx; } align-items: center;
height: 100rpx;
color: #9f6a27;
font-family: STSong, SimSun, serif;
font-size: 32rpx;
white-space: nowrap;
}
.login-heading { display: flex; align-items: center; justify-content: center; height: 74rpx; } .divider-line {
.login-title { margin: 0 8rpx; color: #6f140f; font-family: STKaiti, KaiTi, serif; font-size: 64rpx; font-weight: 700; letter-spacing: 8rpx; line-height: 1; } flex: 1;
.heading-cloud { width: 48rpx; height: 34rpx; } height: 1rpx;
.heading-cloud--right { transform: scaleX(-1); } margin: 0 8rpx;
.title-divider { position: absolute; top: 310rpx; left: 50%; display: block; width: 286rpx; height: 38rpx; transform: translateX(-50%); } background: rgba(180, 113, 38, 0.64);
}
.login-tabs { display: flex; align-items: flex-end; height: 104rpx; margin: 14rpx 0 0; border-bottom: 1rpx solid rgba(193, 142, 67, 0.48); } .divider-knot {
.login-tab { position: relative; display: flex; flex: 1; align-items: center; justify-content: center; min-height: 88rpx; color: #7f6856; font-family: STKaiti, KaiTi, serif; font-size: 33rpx; letter-spacing: 2rpx; } width: 17rpx;
.login-tab:first-child::before { position: absolute; top: 50%; right: 0; width: 1rpx; height: 38rpx; background: rgba(193, 142, 67, 0.42); content: ''; transform: translateY(-50%); } height: 17rpx;
.login-tab.active { color: #a9160d; font-weight: 700; } margin-right: 7rpx;
.login-tab.active::after { position: absolute; right: 102rpx; bottom: -2rpx; left: 102rpx; height: 5rpx; border-radius: 5rpx; background: #ad160d; content: ''; } }
.form-content { margin-top: 16rpx; } .divider-knot--right {
.input-row { display: flex; align-items: center; box-sizing: border-box; min-height: 128rpx; padding-left: 14rpx; border-bottom: 1rpx solid rgba(197, 145, 70, 0.62); } margin: 0 0 0 7rpx;
.input-icon { flex: 0 0 auto; width: 58rpx; height: 58rpx; margin-right: 22rpx; } transform: scaleX(-1);
.auth-input { flex: 1; min-width: 0; height: 116rpx; color: #493323; font-size: 32rpx; } }
.input-placeholder { color: #a79e94; }
.password-toggle { display: flex; flex: 0 0 auto; align-items: center; justify-content: center; width: 88rpx; height: 88rpx; margin-right: 0; } .wechat-login {
.password-toggle__icon { width: 56rpx; height: 56rpx; } height: 100rpx;
.get-code { display: flex; flex: 0 0 auto; align-items: center; min-height: 88rpx; padding-left: 18rpx; color: #a9160d; font-size: 24rpx; white-space: nowrap; } color: #493323;
.form-secondary-row { display: flex; align-items: center; justify-content: flex-end; min-height: 80rpx; } font-family: STSong, SimSun, serif;
.forgot-password { display: flex; align-items: center; min-height: 80rpx; color: #a9160d; font-size: 28rpx; } font-size: 40rpx;
letter-spacing: 3rpx;
}
.wechat-login__content {
position: relative;
z-index: 1;
display: flex;
align-items: center;
justify-content: center;
}
.wechat-icon {
width: 48rpx;
height: 48rpx;
margin-right: 14rpx;
transform: translateX(-8rpx) scale(1.22);
}
.register-entry {
display: flex;
align-items: center;
justify-content: center;
min-height: 100rpx;
color: #493323;
font-size: 31rpx;
}
.register-link {
display: flex;
align-items: center;
min-height: 88rpx;
margin-left: 8rpx;
color: #a9160d;
}
.agreement-area {
min-height: 106rpx;
color: #58483c;
}
.agreement-row {
display: flex;
align-items: center;
justify-content: center;
min-height: 74rpx;
font-size: 24rpx;
line-height: 32rpx;
}
.agreement-icon {
flex: 0 0 auto;
width: 34rpx;
height: 34rpx;
margin-right: 10rpx;
}
.agreement-copy {
display: flex;
flex: 0 1 auto;
align-items: center;
min-width: 0;
white-space: nowrap;
}
.login-submit,
.wechat-login { position: relative; display: flex; align-items: center; justify-content: center; overflow: hidden; }
.login-submit { height: 112rpx; margin-top: 34rpx; }
.button-skin { position: absolute; inset: 0; width: 100%; height: 100%; pointer-events: none; }
.login-submit__copy { position: relative; z-index: 1; color: #fffaf1; font-family: STKaiti, KaiTi, serif; font-size: 42rpx; letter-spacing: 8rpx; }
.other-login-divider { display: flex; align-items: center; height: 74rpx; margin-top: 36rpx; color: #b07b31; font-family: STKaiti, KaiTi, serif; font-size: 27rpx; white-space: nowrap; }
.divider-line { flex: 1; height: 1rpx; margin: 0 10rpx; background: rgba(194, 139, 60, 0.58); }
.divider-knot { width: 18rpx; height: 18rpx; margin-right: 8rpx; }
.divider-knot--right { margin: 0 0 0 8rpx; transform: scaleX(-1); }
.wechat-login { height: 114rpx; margin-top: 8rpx; color: #493323; font-family: STKaiti, KaiTi, serif; font-size: 34rpx; letter-spacing: 4rpx; }
.wechat-login__content { position: relative; z-index: 1; display: flex; align-items: center; justify-content: center; }
.wechat-icon { width: 48rpx; height: 48rpx; margin-right: 14rpx; filter: sepia(1) saturate(2.7) hue-rotate(52deg) brightness(0.78); }
.register-entry { display: flex; align-items: center; justify-content: center; min-height: 66rpx; margin-top: 32rpx; color: #493323; font-size: 27rpx; }
.register-link { display: flex; align-items: center; min-height: 66rpx; margin-left: 10rpx; color: #a9160d; }
.agreement-row { display: flex; align-items: center; justify-content: center; min-height: 68rpx; margin-top: 24rpx; color: #58483c; font-size: 20rpx; line-height: 30rpx; }
.agreement-icon { flex: 0 0 auto; width: 32rpx; height: 32rpx; margin-right: 10rpx; }
.agreement-copy { display: flex; flex: 0 1 auto; align-items: center; min-width: 0; white-space: nowrap; }
.agreement-link { color: #a9160d; } .agreement-link { color: #a9160d; }
.agreement-error {
display: block;
color: #b42318;
font-size: 20rpx;
line-height: 30rpx;
text-align: center;
}
.agreement-area--error {
animation: agreement-shake 240ms ease-out;
}
@keyframes agreement-shake {
0%, 100% { transform: translateX(0); }
33% { transform: translateX(-8rpx); }
66% { transform: translateX(8rpx); }
}
.tap-fade { opacity: 0.62; } .tap-fade { opacity: 0.62; }
.button-pressed { opacity: 0.82; } .button-pressed { opacity: 0.82; }
.feedback-toast { position: fixed; z-index: 30; top: calc(env(safe-area-inset-top) + 32rpx); left: 50%; display: flex; align-items: center; justify-content: center; width: 590rpx; min-height: 88rpx; transform: translateX(-50%); } .feedback-toast {
.feedback-toast__skin { position: absolute; inset: 0; width: 100%; height: 100%; } position: fixed;
.feedback-toast__copy { position: relative; z-index: 1; padding: 18rpx 40rpx; color: #5c4330; font-size: 25rpx; text-align: center; } z-index: 30;
top: calc(env(safe-area-inset-top) + 24rpx);
left: 50%;
display: flex;
align-items: center;
justify-content: center;
width: 590rpx;
min-height: 82rpx;
transform: translateX(-50%);
}
.verification-layer { position: fixed; z-index: 40; inset: 0; display: flex; align-items: center; justify-content: center; padding: 40rpx; background: rgba(31, 20, 13, 0.56); } .feedback-toast__skin {
.verification-dialog { position: relative; width: 620rpx; height: 520rpx; } position: absolute;
.verification-dialog__skin { position: absolute; inset: 0; width: 100%; height: 100%; } inset: 0;
.verification-dialog__content { position: relative; z-index: 1; display: flex; flex-direction: column; align-items: center; box-sizing: border-box; height: 100%; padding: 90rpx 64rpx 54rpx; } width: 100%;
.verification-title { color: #6f140f; font-family: STKaiti, KaiTi, serif; font-size: 42rpx; font-weight: 700; } height: 100%;
.verification-copy { margin-top: 42rpx; color: #493323; font-size: 28rpx; text-align: center; } }
.verification-note { margin-top: 20rpx; color: #8d7a68; font-size: 23rpx; }
.verification-actions { display: flex; width: 100%; margin-top: auto; } .feedback-toast__copy {
.verification-action { display: flex; flex: 1; align-items: center; justify-content: center; min-height: 88rpx; color: #745b46; font-size: 28rpx; } position: relative;
.verification-action--primary { color: #a9160d; font-weight: 700; } z-index: 1;
padding: 16rpx 36rpx;
color: #5c4330;
font-size: 25rpx;
text-align: center;
}
.verification-layer {
position: fixed;
z-index: 40;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
padding: 40rpx;
background: rgba(31, 20, 13, 0.56);
}
.verification-dialog {
position: relative;
width: 620rpx;
height: 520rpx;
}
.verification-dialog__skin {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
}
.verification-dialog__content {
position: relative;
z-index: 1;
display: flex;
flex-direction: column;
align-items: center;
box-sizing: border-box;
height: 100%;
padding: 90rpx 64rpx 54rpx;
}
.verification-title {
color: #6f140f;
font-family: STSong, SimSun, serif;
font-size: 42rpx;
font-weight: 700;
}
.verification-copy {
margin-top: 42rpx;
color: #493323;
font-size: 28rpx;
text-align: center;
}
.verification-note {
margin-top: 20rpx;
color: #8d7a68;
font-size: 23rpx;
}
.verification-actions {
display: flex;
width: 100%;
margin-top: auto;
}
.verification-action {
display: flex;
flex: 1;
align-items: center;
justify-content: center;
min-height: 88rpx;
color: #745b46;
font-size: 28rpx;
}
.verification-action--primary {
color: #a9160d;
font-weight: 700;
}
</style> </style>
+284 -161
View File
@@ -1,51 +1,52 @@
<!-- 页面编号A-04用途注册账号与本页服务协议确认 --> <!-- 页面编号A-04用途注册账号与本页服务协议确认 -->
<template> <template>
<view class="auth-page register-page"> <view class="auth-page register-page">
<image class="paper-background" src="/static/assets/foundation/opaque/auth-page-paper.jpg" mode="aspectFill" /> <view class="page-canvas">
<image class="scenery-background" src="/static/assets/foundation/transparent/auth-design-scenery-v1.png" mode="scaleToFill" /> <image class="page-backdrop" src="/static/assets/modules/auth/opaque/a01-red-hall-ink-backdrop-v1.png" mode="scaleToFill" />
<image class="brand-seal" src="/static/assets/foundation/transparent/brand-seal.png" mode="aspectFit" />
<view class="auth-content"> <view class="register-content">
<view class="auth-header"> <view class="page-heading">
<image class="header-background" src="/static/assets/foundation/opaque/auth-header.png" mode="aspectFill" /> <view class="back-button" hover-class="tap-fade" @click="goBack">
<image class="header-seal" src="/static/assets/foundation/transparent/brand-seal.png" mode="aspectFit" /> <image class="back-button__icon" src="/static/assets/foundation/transparent/chevron-right.png" mode="aspectFit" />
</view>
<view class="register-panel">
<image class="register-panel__skin" src="/static/assets/modules/auth/opaque/a02-login-panel.png" mode="scaleToFill" />
<view class="register-panel__content">
<view class="page-heading">
<image class="register-title-cloud" src="/static/assets/foundation/transparent/auth-title-cloud.png" mode="aspectFit" />
<view class="back-button" hover-class="back-hover" @click="goBack">
<image class="back-button__icon" src="/static/assets/foundation/transparent/chevron-right.png" mode="aspectFit" />
</view>
<view class="page-heading__copy">
<text class="page-title">注册账号</text>
<text class="page-subtitle">创建属于你的家谱账号</text>
</view>
</view> </view>
<text class="page-title">注册账号</text>
<text class="page-subtitle">创建属于你的家谱账号</text>
<image class="register-divider" src="/static/assets/foundation/transparent/auth-divider-knot.png" mode="aspectFit" /> <image class="register-divider" src="/static/assets/foundation/transparent/auth-divider-knot.png" mode="aspectFit" />
</view>
<view class="form-content"> <view class="form-content">
<view class="field-block" :class="{ 'field-block--error': fieldErrors.phone }">
<view class="input-row"> <view class="input-row">
<text class="input-label">手机号</text> <text class="input-label">手机号</text>
<input v-model.trim="phone" class="auth-input" type="number" maxlength="11" placeholder="请输入手机号" placeholder-class="placeholder" /> <input v-model.trim="phone" class="auth-input" type="number" maxlength="11" placeholder="请输入手机号" placeholder-class="placeholder" @input="clearFieldError('phone')" />
</view> </view>
<text v-if="fieldErrors.phone" class="field-error">{{ fieldErrors.phone }}</text>
</view>
<view class="field-block" :class="{ 'field-block--error': fieldErrors.password }">
<view class="input-row"> <view class="input-row">
<text class="input-label">设置密码</text> <text class="input-label">设置密码</text>
<input v-model="password" class="auth-input" password placeholder="请设置登录密码" placeholder-class="placeholder" /> <input v-model="password" class="auth-input" password maxlength="32" placeholder="请设置登录密码" placeholder-class="placeholder" @input="clearFieldError('password')" />
</view> </view>
<text v-if="fieldErrors.password" class="field-error">{{ fieldErrors.password }}</text>
</view>
<view class="field-block" :class="{ 'field-block--error': fieldErrors.confirmPassword }">
<view class="input-row"> <view class="input-row">
<text class="input-label">确认密码</text> <text class="input-label">确认密码</text>
<input v-model="confirmPassword" class="auth-input" password placeholder="请再次输入密码" placeholder-class="placeholder" /> <input v-model="confirmPassword" class="auth-input" password maxlength="32" placeholder="请再次输入密码" placeholder-class="placeholder" @input="clearFieldError('confirmPassword')" />
</view> </view>
<text v-if="fieldErrors.confirmPassword" class="field-error">{{ fieldErrors.confirmPassword }}</text>
</view> </view>
</view>
<view class="register-submit" hover-class="button-hover" @click="submitRegister"> <view class="register-submit" hover-class="button-hover" @click="submitRegister">
<image class="register-submit__skin" src="/static/assets/foundation/opaque/a01-primary-button.png" mode="scaleToFill" /> <image class="register-submit__skin" src="/static/assets/foundation/opaque/a01-primary-button.png" mode="scaleToFill" />
<text class="register-submit__content">注册账号</text> <text class="register-submit__content">注册账号</text>
</view> </view>
<view class="agreement-area" :class="{ 'agreement-area--error': agreementError }">
<view class="agreement-row" @click="toggleAgreement"> <view class="agreement-row" @click="toggleAgreement">
<image class="agreement-icon" :src="agreed ? '/static/assets/modules/auth/transparent/a02-agreement-checked.png' : '/static/assets/modules/auth/transparent/a02-agreement-unchecked.png'" mode="aspectFit" /> <image class="agreement-icon" :src="agreed ? '/static/assets/modules/auth/transparent/a02-agreement-checked.png' : '/static/assets/modules/auth/transparent/a02-agreement-unchecked.png'" mode="aspectFit" />
<view class="agreement-copy"> <view class="agreement-copy">
@@ -55,226 +56,255 @@
<text class="agreement-link" @click.stop="prepareAgreement">隐私政策</text> <text class="agreement-link" @click.stop="prepareAgreement">隐私政策</text>
</view> </view>
</view> </view>
<text v-if="agreementError" class="agreement-error">请先阅读并同意用户协议与隐私政策</text>
</view>
<view class="login-entry"> <view class="login-entry">
<text>已有账号</text> <text>已有账号</text>
<view class="login-entry__link" hover-class="text-hover" @click="prepareLogin">登录</view> <view class="login-entry__link" hover-class="tap-fade" @click="prepareLogin">登录</view>
</view>
</view>
</view>
<!-- 注册短信接口未提供前完整本地校验通过后使用同系列行为验证容器 -->
<view v-if="verificationVisible" class="verification-layer" @click="closeVerification">
<view class="verification-dialog" @click.stop>
<image class="verification-dialog__skin" src="/static/assets/modules/auth/opaque/a02-login-panel.png" mode="scaleToFill" />
<view class="verification-dialog__content">
<text class="verification-title">安全验证</text>
<text class="verification-copy">注册前需要完成行为验证</text>
<text class="verification-note">验证数据将在接口接入后启用</text>
<view class="verification-actions">
<view class="verification-action" hover-class="tap-fade" @click="closeVerification">取消</view>
<view class="verification-action verification-action--primary" hover-class="tap-fade" @click="confirmVerification">知道了</view>
</view> </view>
</view> </view>
</view> </view>
</view> </view>
<view v-if="feedbackVisible" class="feedback-toast">
<image class="feedback-toast__skin" src="/static/assets/foundation/opaque/a01-secondary-button.png" mode="scaleToFill" />
<text class="feedback-toast__copy">{{ feedbackMessage }}</text>
</view>
</view> </view>
</template> </template>
<script setup> <script setup>
import { ref } from 'vue' import { ref } from 'vue'
import { onUnload } from '@dcloudio/uni-app'
const phone = ref('') const phone = ref('')
const password = ref('') const password = ref('')
const confirmPassword = ref('') const confirmPassword = ref('')
const agreed = ref(false) const agreed = ref(false)
const agreementError = ref(false)
const fieldErrors = ref({
phone: '',
password: '',
confirmPassword: ''
})
const verificationVisible = ref(false)
const feedbackVisible = ref(false)
const feedbackMessage = ref('')
let feedbackTimer = null
onUnload(() => {
if (feedbackTimer) clearTimeout(feedbackTimer)
})
const goBack = () => { const goBack = () => {
uni.navigateBack() uni.navigateBack()
} }
const prepareLogin = () => uni.navigateTo({ url: '/pages/auth/a01-entry' }) // “已有账号”用于退出注册流程,替换当前页可避免登录/注册页面反复叠加。
const prepareLogin = () => uni.redirectTo({ url: '/pages/auth/a01-entry' })
const prepareAgreement = () => { const showFeedback = (message) => {
uni.showToast({ title: '协议页面准备中', icon: 'none' }) feedbackMessage.value = message
feedbackVisible.value = true
if (feedbackTimer) clearTimeout(feedbackTimer)
feedbackTimer = setTimeout(() => {
feedbackVisible.value = false
feedbackTimer = null
}, 2200)
} }
const prepareAgreement = () => showFeedback('协议页面准备中')
const toggleAgreement = () => { const toggleAgreement = () => {
agreed.value = !agreed.value agreed.value = !agreed.value
if (agreed.value) agreementError.value = false
}
const clearFieldError = (field) => {
fieldErrors.value[field] = ''
}
const validateForm = () => {
const nextErrors = { phone: '', password: '', confirmPassword: '' }
if (!/^1\d{10}$/.test(phone.value)) nextErrors.phone = '请输入正确手机号'
if (!password.value) nextErrors.password = '请设置密码'
if (!confirmPassword.value) nextErrors.confirmPassword = '请再次输入密码'
else if (password.value !== confirmPassword.value) nextErrors.confirmPassword = '两次输入的密码不一致'
fieldErrors.value = nextErrors
return !Object.values(nextErrors).some(Boolean)
}
const closeVerification = () => {
verificationVisible.value = false
}
const confirmVerification = () => {
closeVerification()
showFeedback('注册服务待接入')
} }
const submitRegister = () => { const submitRegister = () => {
if (!agreed.value) { const formValid = validateForm()
uni.showToast({ title: '请先阅读并同意相关协议', icon: 'none' }) if (!agreed.value) agreementError.value = true
return if (!formValid || !agreed.value) return
} verificationVisible.value = true
if (!/^1\d{10}$/.test(phone.value)) {
uni.showToast({ title: '请输入正确手机号', icon: 'none' })
return
}
if (!password.value) {
uni.showToast({ title: '请设置密码', icon: 'none' })
return
}
if (password.value !== confirmPassword.value) {
uni.showToast({ title: '两次输入的密码不一致', icon: 'none' })
return
}
uni.showToast({ title: '注册服务待接入', icon: 'none' })
} }
</script> </script>
<style scoped lang="scss"> <style scoped lang="scss">
.auth-page { .auth-page {
position: relative; position: relative;
min-height: 100vh;
background: #f7f1e6;
}
.paper-background,
.scenery-background {
position: absolute;
inset: 0;
width: 100%; width: 100%;
height: 100%; height: 100dvh;
min-height: 100%;
overflow-x: hidden;
overflow-y: auto;
background: #f7f0e4;
color: #493323;
} }
.paper-background { opacity: 0.96; } .page-canvas {
.scenery-background { opacity: 0.76; }
.auth-content {
position: relative; position: relative;
z-index: 1;
box-sizing: border-box; box-sizing: border-box;
min-height: 100vh;
padding-bottom: calc(44rpx + env(safe-area-inset-bottom));
}
.auth-header {
position: relative;
height: 253rpx;
overflow: hidden;
}
.header-background {
width: 100%; width: 100%;
height: 100%; min-height: calc(1665rpx + env(safe-area-inset-bottom));
padding-bottom: env(safe-area-inset-bottom);
} }
.header-seal { .page-backdrop {
position: absolute; position: absolute;
top: 70rpx; inset: 0 0 auto;
left: 50%;
width: 136rpx;
height: 136rpx;
transform: translateX(-50%);
}
.register-panel {
position: relative;
min-height: 1184rpx;
margin: 32rpx 42rpx 0;
}
.register-panel__skin {
position: absolute;
inset: 0;
width: 100%; width: 100%;
height: 100%; height: 1665rpx;
pointer-events: none; pointer-events: none;
} }
.register-panel__content { .brand-seal {
position: relative; position: absolute;
z-index: 1; z-index: 1;
top: 64rpx;
left: 50%;
width: 220rpx;
height: 264rpx;
transform: translateX(-50%);
pointer-events: none;
}
.register-content {
position: relative;
z-index: 2;
box-sizing: border-box; box-sizing: border-box;
min-height: 1184rpx; padding: 430rpx 64rpx 46rpx;
padding: 66rpx 50rpx 76rpx;
} }
.page-heading { .page-heading {
position: relative; position: relative;
display: flex; display: flex;
flex-direction: column;
align-items: center; align-items: center;
} min-height: 154rpx;
.register-title-cloud {
position: absolute;
top: -26rpx;
right: 2rpx;
width: 154rpx;
height: 92rpx;
opacity: 0.62;
}
.page-heading__copy {
position: relative;
z-index: 1;
} }
.back-button { .back-button {
position: absolute;
top: 10rpx;
left: 0;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
width: 56rpx; width: 88rpx;
height: 56rpx; height: 88rpx;
margin-right: 18rpx;
} }
.back-hover { opacity: 0.62; }
.back-button__icon { .back-button__icon {
width: 42rpx; width: 42rpx;
height: 42rpx; height: 42rpx;
transform: scaleX(-1); transform: scaleX(-1);
} }
.page-title,
.page-subtitle {
display: block;
}
.page-title { .page-title {
color: #3f2c1d; color: #9f170f;
font-family: STKaiti, KaiTi, serif; font-family: STSong, SimSun, serif;
font-size: 50rpx; font-size: 66rpx;
font-weight: 700; font-weight: 700;
letter-spacing: 4rpx; letter-spacing: 7rpx;
} }
.page-subtitle { .page-subtitle {
margin-top: 10rpx; margin-top: 12rpx;
color: #8c7864; color: #806c58;
font-size: 24rpx; font-size: 25rpx;
letter-spacing: 2rpx; letter-spacing: 2rpx;
} }
.register-divider { .register-divider {
display: block;
width: 154rpx; width: 154rpx;
height: 42rpx; height: 34rpx;
margin: 40rpx auto 0; margin-top: 10rpx;
} }
.form-content { margin-top: 30rpx; } .form-content { margin-top: 12rpx; }
.field-block { min-height: 116rpx; }
.input-row { .input-row {
display: flex; display: flex;
align-items: center; align-items: center;
min-height: 112rpx; min-height: 104rpx;
border-bottom: 1rpx solid #e5d4b7; border-bottom: 1rpx solid rgba(182, 116, 43, 0.68);
} }
.input-label { .input-label {
width: 154rpx; flex: 0 0 152rpx;
color: #674b35; color: #674b35;
font-family: STKaiti, KaiTi, serif; font-family: STSong, SimSun, serif;
font-size: 31rpx; font-size: 31rpx;
} }
.auth-input { .auth-input {
flex: 1; flex: 1;
min-width: 0; min-width: 0;
height: 104rpx; height: 96rpx;
color: #3f2c1d; color: #3f2c1d;
font-size: 29rpx; font-size: 29rpx;
} }
.placeholder { color: #b3a895; } .placeholder { color: #a79e94; }
.field-error,
.agreement-error {
display: block;
color: #b42318;
font-size: 21rpx;
line-height: 32rpx;
}
.field-error { padding-top: 4rpx; text-align: right; }
.field-block--error .input-row { border-bottom-color: #b42318; }
.register-submit { .register-submit {
position: relative; position: relative;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
height: 128rpx; height: 104rpx;
margin-top: 60rpx; margin-top: 36rpx;
overflow: hidden; overflow: hidden;
} }
@@ -289,57 +319,150 @@ const submitRegister = () => {
position: relative; position: relative;
z-index: 1; z-index: 1;
color: #fffaf0; color: #fffaf0;
font-family: STKaiti, KaiTi, serif; font-family: STSong, SimSun, serif;
font-size: 36rpx; font-size: 39rpx;
letter-spacing: 6rpx; letter-spacing: 6rpx;
} }
.button-hover { opacity: 0.84; } .agreement-area { min-height: 118rpx; margin-top: 18rpx; }
.agreement-row { .agreement-row {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
min-height: 88rpx; min-height: 76rpx;
margin-top: 22rpx; color: #58483c;
padding: 0 4rpx; font-size: 22rpx;
color: #6b5a4a; line-height: 30rpx;
font-size: 24rpx;
line-height: 32rpx;
} }
.agreement-icon { .agreement-icon {
flex: 0 0 auto; flex: 0 0 auto;
width: 32rpx; width: 34rpx;
height: 32rpx; height: 34rpx;
margin-right: 12rpx; margin-right: 10rpx;
} }
.agreement-copy { .agreement-copy {
display: flex; display: flex;
align-items: center; align-items: center;
text-align: center;
white-space: nowrap; white-space: nowrap;
} }
.agreement-link { color: #a7160c; } .agreement-link { color: #a7160c; }
.agreement-error { text-align: center; }
.agreement-area--error { animation: agreement-shake 240ms ease-out; }
@keyframes agreement-shake {
0%, 100% { transform: translateX(0); }
33% { transform: translateX(-8rpx); }
66% { transform: translateX(8rpx); }
}
.login-entry { .login-entry {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
margin-top: 2rpx; min-height: 82rpx;
color: #6b5a4a; color: #6b5a4a;
font-size: 24rpx; font-size: 25rpx;
} }
.login-entry__link { .login-entry__link {
display: flex; display: flex;
align-items: center; align-items: center;
min-height: 88rpx; min-height: 82rpx;
margin-left: 8rpx; margin-left: 8rpx;
color: #a7160c; color: #a7160c;
} }
.text-hover { opacity: 0.64; } .tap-fade { opacity: 0.62; }
.button-hover { opacity: 0.84; }
.feedback-toast {
position: fixed;
z-index: 30;
top: calc(env(safe-area-inset-top) + 24rpx);
left: 50%;
display: flex;
align-items: center;
justify-content: center;
width: 590rpx;
min-height: 82rpx;
transform: translateX(-50%);
}
.feedback-toast__skin,
.verification-dialog__skin {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
}
.feedback-toast__copy {
position: relative;
z-index: 1;
padding: 16rpx 36rpx;
color: #5c4330;
font-size: 25rpx;
text-align: center;
}
.verification-layer {
position: fixed;
z-index: 40;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
padding: 40rpx;
background: rgba(31, 20, 13, 0.56);
}
.verification-dialog {
position: relative;
width: 620rpx;
height: 520rpx;
}
.verification-dialog__content {
position: relative;
z-index: 1;
display: flex;
flex-direction: column;
align-items: center;
box-sizing: border-box;
height: 100%;
padding: 90rpx 64rpx 54rpx;
}
.verification-title {
color: #6f140f;
font-family: STSong, SimSun, serif;
font-size: 42rpx;
font-weight: 700;
}
.verification-copy { margin-top: 42rpx; color: #493323; font-size: 28rpx; }
.verification-note { margin-top: 20rpx; color: #8d7a68; font-size: 23rpx; }
.verification-actions {
display: flex;
width: 100%;
margin-top: auto;
}
.verification-action {
display: flex;
flex: 1;
align-items: center;
justify-content: center;
min-height: 88rpx;
color: #745b46;
font-size: 28rpx;
}
.verification-action--primary { color: #a9160d; font-weight: 700; }
</style> </style>
+227 -226
View File
@@ -1,328 +1,329 @@
<!-- 页面编号A-05用途通过手机号验证后重设登录密码 --> <!-- 页面编号A-05用途通过手机号验证后重设登录密码 -->
<template> <template>
<view class="auth-page reset-page"> <view class="auth-page reset-page">
<!-- 认证页背景复用已验收纸纹与淡墨山水不绘制 CSS 装饰面 --> <view class="page-canvas">
<image class="paper-background" src="/static/assets/foundation/opaque/auth-page-paper.jpg" mode="aspectFill" /> <image class="page-backdrop" src="/static/assets/modules/auth/opaque/a01-red-hall-ink-backdrop-v1.png" mode="scaleToFill" />
<image class="scenery-background" src="/static/assets/foundation/transparent/auth-design-scenery-v1.png" mode="scaleToFill" /> <image class="brand-seal" src="/static/assets/foundation/transparent/brand-seal.png" mode="aspectFit" />
<view class="auth-content"> <view class="reset-content">
<!-- 朱砂祠堂头和谱印与 A02/A04 保持同一认证入口 --> <view class="page-heading">
<view class="auth-header"> <view class="back-button" hover-class="tap-fade" @click="goBack">
<image class="header-background" src="/static/assets/foundation/opaque/auth-header.png" mode="aspectFill" /> <image class="back-button__icon" src="/static/assets/foundation/transparent/chevron-right.png" mode="aspectFit" />
<image class="header-seal" src="/static/assets/foundation/transparent/brand-seal.png" mode="aspectFit" />
</view>
<view class="reset-panel">
<image class="reset-panel__skin" src="/static/assets/modules/auth/opaque/a02-login-panel.png" mode="scaleToFill" />
<view class="reset-panel__content">
<!-- 页标题只说明当前任务不暴露内部页面编号 -->
<view class="page-heading">
<image class="reset-title-cloud" src="/static/assets/foundation/transparent/auth-title-cloud.png" mode="aspectFit" />
<view class="back-button" hover-class="back-hover" @click="goBack">
<image class="back-button__icon" src="/static/assets/foundation/transparent/chevron-right.png" mode="aspectFit" />
</view>
<view class="page-heading__copy">
<text class="page-title">重设密码</text>
<text class="page-subtitle">验证手机号后重新设置登录密码</text>
</view>
</view> </view>
<text class="page-title">重设密码</text>
<text class="page-subtitle">验证手机号后设置新的登录密码</text>
<image class="reset-divider" src="/static/assets/foundation/transparent/auth-divider-knot.png" mode="aspectFit" /> <image class="reset-divider" src="/static/assets/foundation/transparent/auth-divider-knot.png" mode="aspectFit" />
</view>
<!-- 四项真实输入在当前页完成身份验证与两次密码确认 --> <view class="form-content">
<view class="form-content"> <view class="field-block" :class="{ 'field-block--error': fieldErrors.phone }">
<view class="input-row"> <view class="input-row">
<text class="input-label">手机号</text> <text class="input-label">手机号</text>
<input v-model.trim="phone" class="auth-input" type="number" maxlength="11" placeholder="请输入手机号" placeholder-class="placeholder" /> <input v-model.trim="phone" class="auth-input" type="number" maxlength="11" placeholder="请输入手机号" placeholder-class="placeholder" @input="clearFieldError('phone')" />
</view> </view>
<text v-if="fieldErrors.phone" class="field-error">{{ fieldErrors.phone }}</text>
</view>
<view class="field-block" :class="{ 'field-block--error': fieldErrors.verificationCode }">
<view class="input-row code-row"> <view class="input-row code-row">
<text class="input-label">验证码</text> <text class="input-label">验证码</text>
<input v-model.trim="verificationCode" class="auth-input" type="number" maxlength="6" placeholder="请输入验证码" placeholder-class="placeholder" /> <input v-model.trim="verificationCode" class="auth-input" type="number" maxlength="6" placeholder="请输入验证码" placeholder-class="placeholder" @input="clearFieldError('verificationCode')" />
<view class="get-code" hover-class="text-hover" @click="prepareGetCode">获取验证码</view> <view class="get-code" hover-class="tap-fade" @click="prepareGetCode">{{ codeRequested ? '重新获取' : '获取验证码' }}</view>
</view> </view>
<text v-if="fieldErrors.verificationCode" class="field-error">{{ fieldErrors.verificationCode }}</text>
</view>
<view class="field-block" :class="{ 'field-block--error': fieldErrors.password }">
<view class="input-row"> <view class="input-row">
<text class="input-label">新密码</text> <text class="input-label">新密码</text>
<input v-model="password" class="auth-input" password placeholder="请设置新密码" placeholder-class="placeholder" /> <input v-model="password" class="auth-input" password maxlength="32" placeholder="请设置新密码" placeholder-class="placeholder" @input="clearFieldError('password')" />
</view> </view>
<text v-if="fieldErrors.password" class="field-error">{{ fieldErrors.password }}</text>
</view>
<view class="field-block" :class="{ 'field-block--error': fieldErrors.confirmPassword }">
<view class="input-row"> <view class="input-row">
<text class="input-label">确认新密码</text> <text class="input-label">确认新密码</text>
<input v-model="confirmPassword" class="auth-input" password placeholder="请再次输入新密码" placeholder-class="placeholder" /> <input v-model="confirmPassword" class="auth-input" password maxlength="32" placeholder="请再次输入新密码" placeholder-class="placeholder" @input="clearFieldError('confirmPassword')" />
</view> </view>
<text v-if="fieldErrors.confirmPassword" class="field-error">{{ fieldErrors.confirmPassword }}</text>
</view> </view>
</view>
<view class="reset-submit" hover-class="button-hover" @click="submitReset"> <view class="reset-submit" hover-class="button-hover" @click="submitReset">
<image class="reset-submit__skin" src="/static/assets/foundation/opaque/a01-primary-button.png" mode="scaleToFill" /> <image class="reset-submit__skin" src="/static/assets/foundation/opaque/a01-primary-button.png" mode="scaleToFill" />
<text class="reset-submit__content">确认重设</text> <text class="reset-submit__content">确认重设</text>
</view> </view>
<text class="reset-tip">重设成后请使用新密码登录</text> <text class="reset-tip">重设成请使用新密码登录</text>
<view class="login-entry"> <view class="login-entry">
<text>想起密码了</text> <text>想起密码了</text>
<view class="login-entry__link" hover-class="text-hover" @click="prepareLogin">返回登录</view> <view class="login-entry__link" hover-class="tap-fade" @click="prepareLogin">返回登录</view>
</view>
</view>
</view>
<!-- 发送短信验证码前进行行为验证关闭时不清空表单 -->
<view v-if="verificationVisible" class="verification-layer" @click="closeVerification">
<view class="verification-dialog" @click.stop>
<image class="verification-dialog__skin" src="/static/assets/modules/auth/opaque/a02-login-panel.png" mode="scaleToFill" />
<view class="verification-dialog__content">
<text class="verification-title">安全验证</text>
<text class="verification-copy">发送验证码前需要完成行为验证</text>
<text class="verification-note">验证数据将在接口接入后启用</text>
<view class="verification-actions">
<view class="verification-action" hover-class="tap-fade" @click="closeVerification">取消</view>
<view class="verification-action verification-action--primary" hover-class="tap-fade" @click="confirmVerification">完成验证</view>
</view> </view>
</view> </view>
</view> </view>
</view> </view>
<view v-if="successVisible" class="success-layer">
<view class="success-dialog">
<image class="success-dialog__skin" src="/static/assets/modules/auth/opaque/a02-login-panel.png" mode="scaleToFill" />
<view class="success-dialog__content">
<text class="success-mark"></text>
<text class="success-title">密码已重设</text>
<text class="success-copy">请返回登录页使用新密码登录</text>
<view class="success-action" hover-class="button-hover" @click="prepareLogin">
<image class="success-action__skin" src="/static/assets/foundation/opaque/a01-primary-button.png" mode="scaleToFill" />
<text class="success-action__copy">返回登录</text>
</view>
</view>
</view>
</view>
<view v-if="feedbackVisible" class="feedback-toast">
<image class="feedback-toast__skin" src="/static/assets/foundation/opaque/a01-secondary-button.png" mode="scaleToFill" />
<text class="feedback-toast__copy">{{ feedbackMessage }}</text>
</view>
</view> </view>
</template> </template>
<script setup> <script setup>
import { ref } from 'vue' import { ref } from 'vue'
import { onUnload } from '@dcloudio/uni-app'
const phone = ref('') const phone = ref('')
const verificationCode = ref('') const verificationCode = ref('')
const password = ref('') const password = ref('')
const confirmPassword = ref('') const confirmPassword = ref('')
const codeRequested = ref(false)
const verificationVisible = ref(false)
const successVisible = ref(false)
const feedbackVisible = ref(false)
const feedbackMessage = ref('')
const fieldErrors = ref({
phone: '',
verificationCode: '',
password: '',
confirmPassword: ''
})
let feedbackTimer = null
const goBack = () => { onUnload(() => {
uni.navigateBack() if (feedbackTimer) clearTimeout(feedbackTimer)
})
const goBack = () => uni.navigateBack()
// 返回登录时替换当前认证页,避免登录与重设页反复叠加。
const prepareLogin = () => uni.redirectTo({ url: '/pages/auth/a01-entry' })
const showFeedback = (message) => {
feedbackMessage.value = message
feedbackVisible.value = true
if (feedbackTimer) clearTimeout(feedbackTimer)
feedbackTimer = setTimeout(() => {
feedbackVisible.value = false
feedbackTimer = null
}, 2200)
} }
const prepareLogin = () => uni.navigateTo({ url: '/pages/auth/a01-entry' }) const clearFieldError = (field) => {
fieldErrors.value[field] = ''
}
const prepareGetCode = () => { const prepareGetCode = () => {
uni.showToast({ title: '验证码功能待接入', icon: 'none' }) if (!/^1\d{10}$/.test(phone.value)) {
fieldErrors.value.phone = '请输入正确手机号'
return
}
fieldErrors.value.phone = ''
verificationVisible.value = true
}
const closeVerification = () => {
verificationVisible.value = false
}
const confirmVerification = () => {
closeVerification()
codeRequested.value = true
showFeedback('验证码已发送')
}
const validateForm = () => {
const nextErrors = { phone: '', verificationCode: '', password: '', confirmPassword: '' }
if (!/^1\d{10}$/.test(phone.value)) nextErrors.phone = '请输入正确手机号'
if (!/^\d{6}$/.test(verificationCode.value)) nextErrors.verificationCode = '请输入 6 位验证码'
if (!password.value) nextErrors.password = '请设置新密码'
if (!confirmPassword.value) nextErrors.confirmPassword = '请再次输入新密码'
else if (password.value !== confirmPassword.value) nextErrors.confirmPassword = '两次密码输入不一致'
fieldErrors.value = nextErrors
return !Object.values(nextErrors).some(Boolean)
} }
const submitReset = () => { const submitReset = () => {
if (!/^1\d{10}$/.test(phone.value)) { if (!validateForm()) return
uni.showToast({ title: '请输入正确手机号', icon: 'none' }) successVisible.value = true
return
}
if (!/^\d{6}$/.test(verificationCode.value)) {
uni.showToast({ title: '请输入 6 位验证码', icon: 'none' })
return
}
if (!password.value) {
uni.showToast({ title: '请设置新密码', icon: 'none' })
return
}
if (password.value !== confirmPassword.value) {
uni.showToast({ title: '两次密码输入不一致', icon: 'none' })
return
}
uni.showToast({ title: '密码重设服务待接入', icon: 'none' })
} }
</script> </script>
<style scoped lang="scss"> <style scoped lang="scss">
.auth-page { .auth-page {
position: relative; position: relative;
min-height: 100vh;
background: #f7f1e6;
}
.paper-background,
.scenery-background {
position: absolute;
inset: 0;
width: 100%; width: 100%;
height: 100%; height: 100dvh;
min-height: 100%;
overflow-x: hidden;
overflow-y: auto;
background: #f7f0e4;
color: #493323;
} }
.paper-background { opacity: 0.96; } .page-canvas {
.scenery-background { opacity: 0.76; }
.auth-content {
position: relative; position: relative;
z-index: 1;
box-sizing: border-box; box-sizing: border-box;
min-height: 100vh;
padding-bottom: calc(44rpx + env(safe-area-inset-bottom));
}
.auth-header {
position: relative;
height: 253rpx;
overflow: hidden;
}
.header-background {
width: 100%; width: 100%;
height: 100%; min-height: calc(1665rpx + env(safe-area-inset-bottom));
padding-bottom: env(safe-area-inset-bottom);
} }
.header-seal { .page-backdrop {
position: absolute; position: absolute;
top: 70rpx; inset: 0 0 auto;
left: 50%;
width: 136rpx;
height: 136rpx;
transform: translateX(-50%);
}
.reset-panel {
position: relative;
min-height: 1240rpx;
margin: 32rpx 42rpx 0;
}
.reset-panel__skin {
position: absolute;
inset: 0;
width: 100%; width: 100%;
height: 100%; height: 1665rpx;
pointer-events: none; pointer-events: none;
} }
.reset-panel__content { .brand-seal {
position: relative; position: absolute;
z-index: 1; z-index: 1;
top: 64rpx;
left: 50%;
width: 220rpx;
height: 264rpx;
transform: translateX(-50%);
pointer-events: none;
}
.reset-content {
position: relative;
z-index: 2;
box-sizing: border-box; box-sizing: border-box;
min-height: 1240rpx; padding: 430rpx 64rpx 46rpx;
padding: 66rpx 50rpx 72rpx;
} }
.page-heading { .page-heading {
position: relative; position: relative;
display: flex; display: flex;
flex-direction: column;
align-items: center; align-items: center;
} min-height: 154rpx;
.reset-title-cloud {
position: absolute;
top: -26rpx;
right: 2rpx;
width: 154rpx;
height: 92rpx;
opacity: 0.62;
}
.page-heading__copy {
position: relative;
z-index: 1;
} }
.back-button { .back-button {
position: absolute;
top: 10rpx;
left: 0;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
width: 56rpx; width: 88rpx;
height: 56rpx; height: 88rpx;
margin-right: 18rpx;
} }
.back-hover { opacity: 0.62; } .back-button__icon { width: 42rpx; height: 42rpx; transform: scaleX(-1); }
.page-title { color: #9f170f; font-family: STSong, SimSun, serif; font-size: 66rpx; font-weight: 700; letter-spacing: 7rpx; }
.back-button__icon { .page-subtitle { margin-top: 12rpx; color: #806c58; font-size: 25rpx; letter-spacing: 1rpx; }
width: 42rpx; .reset-divider { width: 154rpx; height: 34rpx; margin-top: 10rpx; }
height: 42rpx; .form-content { margin-top: 8rpx; }
transform: scaleX(-1); .field-block { min-height: 114rpx; }
}
.page-title,
.page-subtitle {
display: block;
}
.page-title {
color: #3f2c1d;
font-family: STKaiti, KaiTi, serif;
font-size: 50rpx;
font-weight: 700;
letter-spacing: 4rpx;
}
.page-subtitle {
margin-top: 10rpx;
color: #8c7864;
font-size: 24rpx;
letter-spacing: 1rpx;
}
.reset-divider {
display: block;
width: 154rpx;
height: 42rpx;
margin: 40rpx auto 0;
}
.form-content { margin-top: 30rpx; }
.input-row { .input-row {
display: flex; display: flex;
align-items: center; align-items: center;
min-height: 112rpx; min-height: 84rpx;
border-bottom: 1rpx solid #e5d4b7; border-bottom: 1rpx solid #cfaa70;
} }
.input-label { .input-label { flex: 0 0 162rpx; color: #674b35; font-family: STSong, SimSun, serif; font-size: 29rpx; }
width: 174rpx; .auth-input { flex: 1; min-width: 0; height: 82rpx; color: #3f2c1d; font-size: 27rpx; }
color: #674b35; .placeholder { color: #a79e94; }
font-family: STKaiti, KaiTi, serif; .get-code { flex: 0 0 auto; padding-left: 14rpx; border-left: 1rpx solid #d7bd94; color: #a7160c; font-size: 23rpx; }
font-size: 31rpx; .field-error { display: block; padding-top: 4rpx; color: #b42318; font-size: 21rpx; line-height: 28rpx; text-align: right; }
} .field-block--error .input-row { border-bottom-color: #b42318; }
.auth-input {
flex: 1;
min-width: 0;
height: 104rpx;
color: #3f2c1d;
font-size: 29rpx;
}
.placeholder { color: #b3a895; }
.get-code {
flex: 0 0 auto;
padding-left: 18rpx;
border-left: 1rpx solid #ddc7a3;
color: #a7160c;
font-size: 24rpx;
}
.reset-submit { .reset-submit {
position: relative; position: relative;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
height: 128rpx; height: 104rpx;
margin-top: 52rpx; margin-top: 26rpx;
overflow: hidden; overflow: hidden;
} }
.reset-submit__skin { .reset-submit__skin,
position: absolute; .success-action__skin { position: absolute; inset: 0; width: 100%; height: 100%; }
.reset-submit__content { position: relative; z-index: 1; color: #fffaf0; font-family: STSong, SimSun, serif; font-size: 35rpx; letter-spacing: 6rpx; }
.reset-tip { display: block; margin-top: 22rpx; color: #806c58; font-size: 22rpx; text-align: center; }
.login-entry { display: flex; align-items: center; justify-content: center; min-height: 78rpx; color: #6b5a4a; font-size: 24rpx; }
.login-entry__link { display: flex; align-items: center; min-height: 78rpx; margin-left: 8rpx; color: #a7160c; }
.verification-layer,
.success-layer {
position: fixed;
z-index: 20;
inset: 0; inset: 0;
width: 100%;
height: 100%;
}
.reset-submit__content {
position: relative;
z-index: 1;
color: #fffaf0;
font-family: STKaiti, KaiTi, serif;
font-size: 36rpx;
letter-spacing: 6rpx;
}
.button-hover { opacity: 0.84; }
.text-hover { opacity: 0.64; }
.reset-tip {
display: block;
margin-top: 26rpx;
color: #8c7864;
font-size: 22rpx;
text-align: center;
}
.login-entry {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
margin-top: 2rpx; box-sizing: border-box;
color: #6b5a4a; padding: 42rpx;
font-size: 24rpx; background: rgba(35, 18, 10, 0.62);
} }
.login-entry__link { .verification-dialog,
display: flex; .success-dialog { position: relative; width: 100%; max-width: 670rpx; min-height: 500rpx; }
align-items: center; .verification-dialog__skin,
min-height: 82rpx; .success-dialog__skin { position: absolute; inset: 0; width: 100%; height: 100%; }
margin-left: 8rpx; .verification-dialog__content,
color: #a7160c; .success-dialog__content { position: relative; z-index: 1; display: flex; flex-direction: column; align-items: center; box-sizing: border-box; min-height: 500rpx; padding: 92rpx 70rpx 64rpx; text-align: center; }
.verification-title,
.success-title { color: #8f160f; font-family: STSong, SimSun, serif; font-size: 45rpx; font-weight: 700; letter-spacing: 4rpx; }
.verification-copy,
.success-copy { margin-top: 32rpx; color: #513a28; font-size: 27rpx; line-height: 42rpx; }
.verification-note { margin-top: 14rpx; color: #8d7965; font-size: 22rpx; }
.verification-actions { display: flex; width: 100%; margin-top: 56rpx; border-top: 1rpx solid #d6bb91; }
.verification-action { display: flex; flex: 1; align-items: center; justify-content: center; min-height: 86rpx; color: #73533a; font-size: 27rpx; }
.verification-action--primary { border-left: 1rpx solid #d6bb91; color: #a7160c; }
.success-mark { display: flex; align-items: center; justify-content: center; width: 82rpx; height: 82rpx; margin-bottom: 20rpx; border: 2rpx solid #a7160c; border-radius: 50%; color: #a7160c; font-size: 48rpx; }
.success-action { position: relative; display: flex; align-items: center; justify-content: center; width: 100%; height: 92rpx; margin-top: 38rpx; overflow: hidden; }
.success-action__copy { position: relative; z-index: 1; color: #fffaf0; font-family: STSong, SimSun, serif; font-size: 30rpx; letter-spacing: 4rpx; }
.feedback-toast { position: fixed; z-index: 30; top: calc(40rpx + env(safe-area-inset-top)); left: 50%; display: flex; align-items: center; justify-content: center; min-width: 280rpx; min-height: 80rpx; padding: 0 42rpx; transform: translateX(-50%); }
.feedback-toast__skin { position: absolute; inset: 0; width: 100%; height: 100%; }
.feedback-toast__copy { position: relative; z-index: 1; color: #5e402b; font-size: 24rpx; }
.tap-fade,
.button-hover { opacity: 0.72; }
@media screen and (max-width: 340px) {
.reset-content { padding-right: 48rpx; padding-left: 48rpx; }
.page-title { font-size: 60rpx; }
.input-label { flex-basis: 150rpx; }
} }
</style> </style>
+146 -322
View File
@@ -1,46 +1,60 @@
<!-- 页面编号A-06用途承接登录未完成失败受限与授权结果 --> <!-- 页面编号A-06用途承接账号冻结停用与风险限制等阻断登录状态 -->
<template> <template>
<view class="auth-page status-page"> <view class="auth-page status-page">
<!-- 认证入口沿用纸纹淡墨与祠堂头图不以 CSS 重绘完整装饰面 --> <view class="page-canvas">
<image class="paper-background" src="/static/assets/foundation/opaque/auth-page-paper.jpg" mode="aspectFill" /> <image class="page-backdrop" src="/static/assets/modules/auth/opaque/a01-red-hall-ink-backdrop-v1.png" mode="scaleToFill" />
<image class="scenery-background" src="/static/assets/foundation/transparent/auth-design-scenery-v1.png" mode="scaleToFill" /> <image class="brand-seal" src="/static/assets/foundation/transparent/brand-seal.png" mode="aspectFit" />
<view class="auth-content"> <view class="status-content">
<view class="auth-header"> <view class="page-heading">
<image class="header-background" src="/static/assets/foundation/opaque/auth-header.png" mode="aspectFill" /> <view class="back-button" hover-class="tap-fade" @click="goBack">
<image class="header-seal" src="/static/assets/foundation/transparent/brand-seal.png" mode="aspectFit" /> <image class="back-button__icon" src="/static/assets/foundation/transparent/chevron-right.png" mode="aspectFit" />
</view>
<view class="status-panel">
<image class="status-panel__skin" src="/static/assets/modules/auth/opaque/a02-login-panel.png" mode="scaleToFill" />
<view class="status-panel__content">
<view class="page-heading">
<image class="status-title-cloud" src="/static/assets/foundation/transparent/auth-title-cloud.png" mode="aspectFit" />
<view class="back-button" hover-class="back-hover" @click="goBack">
<image class="back-button__icon" src="/static/assets/foundation/transparent/chevron-right.png" mode="aspectFit" />
</view>
<view class="page-heading__copy">
<text class="page-title">登录提示</text>
<text class="page-subtitle">请确认当前状态后继续操作</text>
</view>
</view> </view>
<text class="page-title">登录受限</text>
<text class="page-subtitle">请根据当前账号状态完成恢复</text>
<image class="status-divider" src="/static/assets/foundation/transparent/auth-divider-knot.png" mode="aspectFit" /> <image class="status-divider" src="/static/assets/foundation/transparent/auth-divider-knot.png" mode="aspectFit" />
</view>
<view class="status-body"> <view class="status-body">
<image class="status-icon" src="/static/assets/foundation/transparent/auth-login-outline.png" mode="aspectFit" /> <image class="status-icon" src="/static/assets/foundation/transparent/auth-login-outline.png" mode="aspectFit" />
<text class="status-eyebrow">{{ currentState.eyebrow }}</text> <text class="status-eyebrow">{{ currentState.eyebrow }}</text>
<text class="status-title">{{ currentState.title }}</text> <text class="status-title">{{ currentState.title }}</text>
<text class="status-description">{{ currentState.description }}</text> <text class="status-description">{{ currentState.description }}</text>
<view class="status-details">
<view class="status-detail">
<text class="status-detail__label">{{ currentState.reasonLabel }}</text>
<text class="status-detail__value">{{ currentState.reason }}</text>
</view>
<view class="status-detail">
<text class="status-detail__label">{{ currentState.impactLabel }}</text>
<text class="status-detail__value">{{ currentState.impact }}</text>
</view>
<view class="status-detail">
<text class="status-detail__label">{{ currentState.recoveryLabel }}</text>
<text class="status-detail__value">{{ currentState.recovery }}</text>
</view>
</view> </view>
</view>
<view class="status-primary" hover-class="button-hover" @click="handlePrimary"> <view class="status-primary" hover-class="button-hover" @click="openRecovery">
<image class="status-primary__skin" src="/static/assets/foundation/opaque/a01-primary-button.png" mode="scaleToFill" /> <image class="status-primary__skin" src="/static/assets/foundation/opaque/a01-primary-button.png" mode="scaleToFill" />
<text class="status-primary__copy">{{ currentState.primaryLabel }}</text> <text class="status-primary__copy">查看恢复方式</text>
</view> </view>
<view class="status-secondary" hover-class="tap-fade" @click="goLogin">返回登录</view>
</view>
</view>
<view v-if="currentState.secondaryLabel" class="status-secondary" hover-class="text-hover" @click="goLogin"> <view v-if="recoveryVisible" class="recovery-layer" @click="closeRecovery">
{{ currentState.secondaryLabel }} <view class="recovery-dialog" @click.stop>
<image class="recovery-dialog__skin" src="/static/assets/modules/auth/opaque/a02-login-panel.png" mode="scaleToFill" />
<view class="recovery-dialog__content">
<text class="recovery-title">{{ currentState.recoveryTitle }}</text>
<text class="recovery-copy">{{ currentState.recoveryDetail }}</text>
<text class="recovery-note">账号恢复服务将在功能阶段接入当前仅审核页面样式与状态位置</text>
<view class="recovery-action" hover-class="button-hover" @click="closeRecovery">
<image class="recovery-action__skin" src="/static/assets/foundation/opaque/a01-primary-button.png" mode="scaleToFill" />
<text class="recovery-action__copy">我知道了</text>
</view> </view>
</view> </view>
</view> </view>
@@ -52,320 +66,130 @@
import { computed, ref } from 'vue' import { computed, ref } from 'vue'
import { onLoad, onShow } from '@dcloudio/uni-app' import { onLoad, onShow } from '@dcloudio/uni-app'
const status = ref('normal') const status = ref('risk')
const recoveryVisible = ref(false)
// A06 阻断状态的唯一合同;普通登录错误必须留在 A01 就近处理。
const statusConfig = { const statusConfig = {
normal: { frozen: {
eyebrow: '登录提示', eyebrow: '账号冻结',
title: '登录未完成', title: '账号已被冻结',
description: '登录流程尚未完成,请选择一种方式继续。', description: '当前账号因安全审核暂时无法登录。',
primaryLabel: '返回登录', reasonLabel: '限制原因',
action: 'login', reason: '账号进入人工安全审核',
secondaryLabel: '' impactLabel: '影响范围',
impact: '暂时无法进入家谱及管理资料',
recoveryLabel: '恢复方式',
recovery: '提交账号申诉并等待审核结果',
recoveryTitle: '申请解除冻结',
recoveryDetail: '请准备注册手机号与身份说明,提交后由平台进行人工核验。'
}, },
failed: { disabled: {
eyebrow: '登录提示', eyebrow: '账号停用',
title: '登录失败', title: '账号已被停用',
description: '当前无法完成登录,请稍后重试或改用其他方式。', description: '当前账号处于停用状态,登录入口已关闭。',
primaryLabel: '返回登录', reasonLabel: '限制原因',
action: 'login', reason: '账号已执行停用处理',
secondaryLabel: '' impactLabel: '影响范围',
impact: '无法登录,原有家谱资料不会被删除',
recoveryLabel: '恢复方式',
recovery: '联系平台核实停用原因与恢复条件',
recoveryTitle: '联系平台核实',
recoveryDetail: '请提供注册手机号和账号归属信息,平台核实后告知是否可以恢复。'
}, },
restricted: { risk: {
eyebrow: '账号安全', eyebrow: '风险限制',
title: '账号暂时受限', title: '账号存在安全风险',
description: '为保护家谱资料,当前账号暂不能登录。', description: '系统检测到异常登录,为保护家谱资料已暂停本次登录。',
primaryLabel: '查看帮助', reasonLabel: '限制原因',
action: 'help', reason: '登录环境或操作行为存在异常',
secondaryLabel: '返回登录' impactLabel: '影响范围',
}, impact: '本次登录被阻断,账号资料保持不变',
'register-pending': { recoveryLabel: '恢复方式',
eyebrow: '注册提示', recovery: '完成身份核验后重新登录',
title: '注册尚未完成', recoveryTitle: '完成身份核验',
description: '请返回注册页完善信息后再登录。', recoveryDetail: '请使用注册手机号完成身份核验;核验通过后可重新尝试登录。'
primaryLabel: '继续注册',
action: 'register',
secondaryLabel: '返回登录'
},
'wechat-cancelled': {
eyebrow: '微信登录',
title: '微信授权已取消',
description: '未获得微信授权,不会影响账号安全。',
primaryLabel: '返回登录',
action: 'login',
secondaryLabel: ''
},
'wechat-failed': {
eyebrow: '微信登录',
title: '微信授权失败',
description: '当前无法完成微信授权,请使用账号密码登录。',
primaryLabel: '返回登录',
action: 'login',
secondaryLabel: ''
} }
} }
const currentState = computed(() => statusConfig[status.value]) const currentState = computed(() => statusConfig[status.value])
const resolveStatus = () => { const resolveStatus = () => {
let requestedStatus = 'normal' let requestedStatus = 'risk'
if (typeof location !== 'undefined') { if (typeof location !== 'undefined') {
const query = new URLSearchParams(location.hash.split('?')[1] || '') const query = new URLSearchParams(location.hash.split('?')[1] || '')
requestedStatus = query.get('status') || 'normal' requestedStatus = query.get('status') || 'risk'
} else { } else {
const pages = getCurrentPages() const pages = getCurrentPages()
const currentPage = pages[pages.length - 1] requestedStatus = pages[pages.length - 1]?.options?.status || 'risk'
requestedStatus = currentPage?.options?.status || 'normal'
} }
status.value = Object.prototype.hasOwnProperty.call(statusConfig, requestedStatus) ? requestedStatus : 'risk'
status.value = Object.prototype.hasOwnProperty.call(statusConfig, requestedStatus) ? requestedStatus : 'normal' recoveryVisible.value = false
} }
onLoad(() => { onLoad(resolveStatus)
resolveStatus() onShow(resolveStatus)
})
onShow(() => { const goLogin = () => uni.redirectTo({ url: '/pages/auth/a01-entry' })
resolveStatus() const openRecovery = () => { recoveryVisible.value = true }
}) const closeRecovery = () => { recoveryVisible.value = false }
const goBack = () => uni.navigateBack()
const goLogin = () => {
uni.navigateTo({ url: '/pages/auth/a01-entry' })
}
const goRegister = () => {
uni.navigateTo({ url: '/pages/auth/a04-register' })
}
const handlePrimary = () => {
if (currentState.value.action === 'register') {
goRegister()
return
}
if (currentState.value.action === 'help') {
uni.showToast({ title: '账号申诉功能待接入', icon: 'none' })
return
}
goLogin()
}
const goBack = () => {
uni.navigateBack()
}
</script> </script>
<style scoped lang="scss"> <style scoped lang="scss">
.auth-page { .auth-page {
position: relative; position: relative;
min-height: 100vh;
background: #f7f1e6;
}
.paper-background,
.scenery-background {
position: absolute;
inset: 0;
width: 100%; width: 100%;
height: 100%; height: 100dvh;
min-height: 100%;
overflow-x: hidden;
overflow-y: auto;
background: #f7f0e4;
color: #493323;
} }
.paper-background { opacity: 0.96; } .page-canvas { position: relative; box-sizing: border-box; width: 100%; min-height: calc(1665rpx + env(safe-area-inset-bottom)); padding-bottom: env(safe-area-inset-bottom); }
.scenery-background { opacity: 0.76; } .page-backdrop { position: absolute; inset: 0 0 auto; width: 100%; height: 1665rpx; pointer-events: none; }
.brand-seal { position: absolute; z-index: 1; top: 64rpx; left: 50%; width: 220rpx; height: 264rpx; transform: translateX(-50%); pointer-events: none; }
.status-content { position: relative; z-index: 2; box-sizing: border-box; padding: 430rpx 64rpx 46rpx; }
.page-heading { position: relative; display: flex; flex-direction: column; align-items: center; min-height: 154rpx; }
.back-button { position: absolute; top: 10rpx; left: 0; display: flex; align-items: center; justify-content: center; width: 88rpx; height: 88rpx; }
.back-button__icon { width: 42rpx; height: 42rpx; transform: scaleX(-1); }
.page-title { color: #9f170f; font-family: STSong, SimSun, serif; font-size: 66rpx; font-weight: 700; letter-spacing: 7rpx; }
.page-subtitle { margin-top: 12rpx; color: #806c58; font-size: 25rpx; letter-spacing: 1rpx; }
.status-divider { width: 154rpx; height: 34rpx; margin-top: 10rpx; }
.auth-content { .status-body { display: flex; flex-direction: column; align-items: center; padding-top: 6rpx; text-align: center; }
position: relative; .status-icon { width: 94rpx; height: 94rpx; opacity: 0.82; }
z-index: 1; .status-eyebrow { margin-top: 10rpx; color: #a7160c; font-size: 22rpx; letter-spacing: 3rpx; }
box-sizing: border-box; .status-title { margin-top: 10rpx; color: #3f2c1d; font-family: STSong, SimSun, serif; font-size: 40rpx; font-weight: 700; letter-spacing: 3rpx; }
min-height: 100vh; .status-description { box-sizing: border-box; width: 100%; margin-top: 12rpx; color: #786654; font-size: 24rpx; line-height: 1.65; }
padding-bottom: calc(44rpx + env(safe-area-inset-bottom)); .status-details { width: 100%; margin-top: 22rpx; border-top: 1rpx solid #cfaa70; }
.status-detail { display: flex; box-sizing: border-box; min-height: 78rpx; padding: 16rpx 0; border-bottom: 1rpx solid rgba(207, 170, 112, 0.72); text-align: left; }
.status-detail__label { flex: 0 0 116rpx; color: #9f170f; font-size: 23rpx; }
.status-detail__value { flex: 1; color: #5f4a38; font-size: 23rpx; line-height: 34rpx; }
.status-primary { position: relative; display: flex; align-items: center; justify-content: center; height: 104rpx; margin-top: 28rpx; overflow: hidden; }
.status-primary__skin,
.recovery-action__skin { position: absolute; inset: 0; width: 100%; height: 100%; }
.status-primary__copy { position: relative; z-index: 1; color: #fffaf0; font-family: STSong, SimSun, serif; font-size: 34rpx; letter-spacing: 5rpx; }
.status-secondary { display: flex; align-items: center; justify-content: center; min-height: 82rpx; color: #a7160c; font-size: 24rpx; }
.recovery-layer { position: fixed; z-index: 20; inset: 0; display: flex; align-items: center; justify-content: center; box-sizing: border-box; padding: 42rpx; background: rgba(35, 18, 10, 0.62); }
.recovery-dialog { position: relative; width: 100%; max-width: 670rpx; min-height: 560rpx; }
.recovery-dialog__skin { position: absolute; inset: 0; width: 100%; height: 100%; }
.recovery-dialog__content { position: relative; z-index: 1; display: flex; flex-direction: column; align-items: center; box-sizing: border-box; min-height: 560rpx; padding: 92rpx 70rpx 64rpx; text-align: center; }
.recovery-title { color: #8f160f; font-family: STSong, SimSun, serif; font-size: 43rpx; font-weight: 700; letter-spacing: 4rpx; }
.recovery-copy { margin-top: 32rpx; color: #513a28; font-size: 27rpx; line-height: 43rpx; }
.recovery-note { margin-top: 18rpx; color: #8d7965; font-size: 21rpx; line-height: 34rpx; }
.recovery-action { position: relative; display: flex; align-items: center; justify-content: center; width: 100%; height: 92rpx; margin-top: 38rpx; overflow: hidden; }
.recovery-action__copy { position: relative; z-index: 1; color: #fffaf0; font-family: STSong, SimSun, serif; font-size: 30rpx; letter-spacing: 4rpx; }
.tap-fade,
.button-hover { opacity: 0.72; }
@media screen and (max-width: 340px) {
.status-content { padding-right: 48rpx; padding-left: 48rpx; }
.page-title { font-size: 60rpx; }
.status-detail__label { flex-basis: 108rpx; }
} }
.auth-header {
position: relative;
height: 253rpx;
overflow: hidden;
}
.header-background {
width: 100%;
height: 100%;
}
.header-seal {
position: absolute;
top: 70rpx;
left: 50%;
width: 136rpx;
height: 136rpx;
transform: translateX(-50%);
}
.status-panel {
position: relative;
min-height: 1030rpx;
margin: 32rpx 42rpx 0;
}
.status-panel__skin {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
pointer-events: none;
}
.status-panel__content {
position: relative;
z-index: 1;
box-sizing: border-box;
min-height: 1030rpx;
padding: 66rpx 50rpx 72rpx;
}
.page-heading {
position: relative;
display: flex;
align-items: center;
}
.status-title-cloud {
position: absolute;
top: -26rpx;
right: 2rpx;
width: 154rpx;
height: 92rpx;
opacity: 0.62;
}
.back-button {
display: flex;
align-items: center;
justify-content: center;
width: 56rpx;
height: 56rpx;
margin-right: 18rpx;
}
.back-hover { opacity: 0.62; }
.back-button__icon {
width: 42rpx;
height: 42rpx;
transform: scaleX(-1);
}
.page-heading__copy {
position: relative;
z-index: 1;
}
.page-title,
.page-subtitle {
display: block;
}
.page-title {
color: #3f2c1d;
font-family: STKaiti, KaiTi, serif;
font-size: 50rpx;
font-weight: 700;
letter-spacing: 4rpx;
}
.page-subtitle {
margin-top: 10rpx;
color: #8c7864;
font-size: 24rpx;
letter-spacing: 1rpx;
}
.status-divider {
display: block;
width: 154rpx;
height: 42rpx;
margin: 40rpx auto 0;
}
.status-body {
display: flex;
flex-direction: column;
align-items: center;
min-height: 430rpx;
padding-top: 54rpx;
text-align: center;
}
.status-icon {
width: 132rpx;
height: 132rpx;
opacity: 0.82;
}
.status-eyebrow {
display: block;
margin-top: 28rpx;
color: #a7160c;
font-size: 24rpx;
letter-spacing: 3rpx;
}
.status-title {
display: block;
margin-top: 18rpx;
color: #3f2c1d;
font-family: STKaiti, KaiTi, serif;
font-size: 44rpx;
font-weight: 700;
letter-spacing: 4rpx;
}
.status-description {
display: block;
box-sizing: border-box;
width: 100%;
min-height: 64rpx;
margin-top: 20rpx;
padding: 0 8rpx;
color: #786654;
font-size: 26rpx;
line-height: 1.8;
}
.status-primary {
position: relative;
display: flex;
align-items: center;
justify-content: center;
height: 128rpx;
overflow: hidden;
}
.status-primary__skin {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
}
.status-primary__copy {
position: relative;
z-index: 1;
color: #fffaf0;
font-family: STKaiti, KaiTi, serif;
font-size: 36rpx;
letter-spacing: 6rpx;
}
.status-secondary {
display: flex;
align-items: center;
justify-content: center;
min-height: 88rpx;
color: #a7160c;
font-size: 25rpx;
}
.button-hover { opacity: 0.84; }
.text-hover { opacity: 0.64; }
</style> </style>
+212 -70
View File
@@ -1,4 +1,4 @@
<!-- 页面编号G-01用途我的家谱列表视觉基准已由用户验收地基迁移不得改变可见效果 --> <!-- 页面编号G-01用途我的家谱列表与首次分流本轮仍待用户重新审核 -->
<template> <template>
<view class="page-shell genealogy-index"> <view class="page-shell genealogy-index">
<image <image
@@ -25,8 +25,20 @@
<text class="state-copy">请稍候家族记忆正在归卷</text> <text class="state-copy">请稍候家族记忆正在归卷</text>
</view> </view>
<view v-else-if="hasError" class="state-panel state-panel--error">
<image class="state-panel__skin" src="/static/assets/modules/genealogy/opaque/g01-empty-panel.png" mode="scaleToFill" />
<view class="state-panel__content">
<text class="state-title">暂时无法读取家谱</text>
<text class="state-copy">网络或服务暂不可用请稍后重新查看</text>
<view class="state-retry" hover-class="action-hover" @click="retryLoad">
<image class="state-retry__skin" src="/static/assets/foundation/opaque/a01-primary-button.png" mode="scaleToFill" />
<text class="state-retry__copy">重新加载</text>
</view>
</view>
</view>
<template v-else-if="hasGenealogies"> <template v-else-if="hasGenealogies">
<view class="current-slip" @click="openGenealogy(currentGenealogy)"> <view class="current-slip" @click="openSwitcher">
<image <image
class="current-frame" class="current-frame"
src="/static/assets/modules/genealogy/transparent/current-slip-frame.png" src="/static/assets/modules/genealogy/transparent/current-slip-frame.png"
@@ -42,6 +54,7 @@
<text class="current-seal-title">家谱</text> <text class="current-seal-title">家谱</text>
</view> </view>
<text class="current-name">{{ currentGenealogy.name }}</text> <text class="current-name">{{ currentGenealogy.name }}</text>
<text class="current-switch-copy">切换</text>
</view> </view>
<view class="current-info-divider"></view> <view class="current-info-divider"></view>
<view class="current-meta"> <view class="current-meta">
@@ -118,7 +131,28 @@
/> />
</view> </view>
<view class="create-action" @click="createGenealogy"> <view class="list-section application-section">
<view class="section-heading"><text>加入申请</text></view>
<view
v-for="item in applicationRecords"
:key="item.id"
class="application-record"
hover-class="action-hover"
@click="openApplication(item)"
>
<image class="application-record__skin" src="/static/assets/modules/genealogy/transparent/list-slip-frame.png" mode="scaleToFill" />
<view class="application-record__main">
<view class="application-record__title-row">
<text class="application-record__name">{{ item.name }}</text>
<text class="application-record__status" :class="`application-record__status--${item.tone}`">{{ item.statusLabel }}</text>
</view>
<text class="application-record__copy">{{ item.description }}</text>
</view>
<image class="application-record__chevron" src="/static/assets/foundation/transparent/chevron-right.png" mode="aspectFit" />
</view>
</view>
<view class="create-action" @click="openAddDialog">
<image <image
class="create-cloud" class="create-cloud"
src="/static/assets/modules/genealogy/transparent/create-cloud.png" src="/static/assets/modules/genealogy/transparent/create-cloud.png"
@@ -129,7 +163,7 @@
src="/static/assets/modules/genealogy/transparent/add.png" src="/static/assets/modules/genealogy/transparent/add.png"
mode="aspectFit" mode="aspectFit"
/> />
<text>新建家谱</text> <text>添加家谱</text>
<image <image
class="create-cloud create-cloud--right" class="create-cloud create-cloud--right"
src="/static/assets/modules/genealogy/transparent/create-cloud.png" src="/static/assets/modules/genealogy/transparent/create-cloud.png"
@@ -156,36 +190,12 @@
<text class="empty-seal__text">家谱</text> <text class="empty-seal__text">家谱</text>
</view> </view>
<text class="empty-title">还没有加入任何家谱</text> <text class="empty-title">还没有加入任何家谱</text>
<text class="empty-copy">为自己的家族立一部谱或搜索家谱申请加入</text> <text class="empty-copy">搜索家族是否已有家谱再选择加入或创建</text>
<image <image
class="empty-divider" class="empty-divider"
src="/static/assets/modules/genealogy/transparent/section-divider.png" src="/static/assets/modules/genealogy/transparent/section-divider.png"
mode="scaleToFill" mode="scaleToFill"
/> />
<view
class="empty-create-action"
hover-class="action-hover"
@click="createGenealogy"
>
<image
class="empty-create-action__skin"
src="/static/assets/foundation/opaque/a01-primary-button.png"
mode="scaleToFill"
/>
<view class="empty-create-action__content">
<image
class="empty-create-action__icon"
src="/static/assets/modules/genealogy/transparent/add.png"
mode="aspectFit"
/>
<text>新建家谱</text>
<image
class="empty-create-action__cloud"
src="/static/assets/modules/genealogy/transparent/create-cloud.png"
mode="aspectFit"
/>
</view>
</view>
<view <view
class="empty-search-action" class="empty-search-action"
hover-class="action-hover" hover-class="action-hover"
@@ -193,16 +203,75 @@
> >
<image <image
class="empty-search-action__skin" class="empty-search-action__skin"
src="/static/assets/foundation/opaque/a01-primary-button.png"
mode="scaleToFill"
/>
<text class="empty-search-action__copy empty-search-action__copy--primary">搜索家谱</text>
</view>
<view
class="empty-invite-action"
hover-class="action-hover"
@click="joinByInvite"
>
<image
class="empty-invite-action__skin"
src="/static/assets/foundation/opaque/a01-secondary-button.png" src="/static/assets/foundation/opaque/a01-secondary-button.png"
mode="scaleToFill" mode="scaleToFill"
/> />
<text class="empty-search-action__copy">搜索并申请加入</text> <text class="empty-invite-action__copy">邀请码加入</text>
</view> </view>
<view class="empty-create-action" hover-class="action-hover" @click="createGenealogy">
<text class="empty-create-action__copy">创建家谱</text>
</view>
<text class="empty-create-note">确认没有现有家谱后再创建避免重复建谱</text>
</view> </view>
</view> </view>
</view> </view>
</view> </view>
<view v-if="addDialogVisible" class="add-dialog-layer" @click="closeAddDialog">
<view class="add-dialog" @click.stop>
<image class="add-dialog__skin" src="/static/assets/modules/genealogy/opaque/g01-empty-panel.png" mode="scaleToFill" />
<view class="add-dialog__content">
<text class="dialog-title">添加家谱</text>
<text class="dialog-copy">建议先搜索已有家谱避免重复创建</text>
<view class="dialog-primary" @click="applyToJoin">
<image class="dialog-action__skin" src="/static/assets/foundation/opaque/a01-primary-button.png" mode="scaleToFill" />
<text class="dialog-primary__copy">搜索家谱</text>
</view>
<view class="dialog-secondary" @click="joinByInvite">
<image class="dialog-action__skin" src="/static/assets/foundation/opaque/a01-secondary-button.png" mode="scaleToFill" />
<text>邀请码加入</text>
</view>
<view class="dialog-create" @click="createGenealogy">确认没有现有家谱继续创建</view>
<view class="dialog-close" @click="closeAddDialog">取消</view>
</view>
</view>
</view>
<view v-if="switcherVisible" class="genealogy-switcher-layer" @click="closeSwitcher">
<view class="genealogy-switcher" @click.stop>
<image class="genealogy-switcher__skin" src="/static/assets/modules/genealogy/opaque/g01-empty-panel.png" mode="scaleToFill" />
<view class="genealogy-switcher__content">
<text class="dialog-title">切换当前家谱</text>
<view
v-for="item in availableGenealogies"
:key="item.id"
class="switcher-item"
:class="{ 'switcher-item--active': item.id === selectedGenealogyId }"
@click="selectGenealogy(item)"
>
<view>
<text class="switcher-item__name">{{ item.name }}</text>
<text class="switcher-item__meta">{{ item.location }} · {{ item.memberCount }} 位成员</text>
</view>
<text class="switcher-item__state">{{ item.id === selectedGenealogyId ? '当前' : '选择' }}</text>
</view>
<view class="dialog-close" @click="closeSwitcher">关闭</view>
</view>
</view>
</view>
<AppTabbar active="genealogy" /> <AppTabbar active="genealogy" />
</view> </view>
</template> </template>
@@ -216,22 +285,29 @@ import PageHeader from "@/components/PageHeader.vue";
import { genealogies, notifications } from "@/data/mock.js"; import { genealogies, notifications } from "@/data/mock.js";
const isLoading = ref(false); const isLoading = ref(false);
const hasError = ref(false);
const list = ref(genealogies); const list = ref(genealogies);
const forceEmptyState = ref(false); const forceEmptyState = ref(false);
const addDialogVisible = ref(false);
const switcherVisible = ref(false);
const selectedGenealogyId = ref(genealogies[0]?.id || null);
const isEmptyStateRoute = () => { const readPresentationState = () => {
if (typeof location !== "undefined") { if (typeof location !== "undefined") {
const query = new URLSearchParams(location.hash.split("?")[1] || ""); const query = new URLSearchParams(location.hash.split("?")[1] || "");
return query.get("state") === "empty"; return query.get("state") || "default";
} }
const pages = getCurrentPages(); const pages = getCurrentPages();
const currentPage = pages[pages.length - 1]; const currentPage = pages[pages.length - 1];
return currentPage?.options?.state === "empty"; return currentPage?.options?.state || "default";
}; };
const syncEmptyStateFromRoute = () => { const syncEmptyStateFromRoute = () => {
forceEmptyState.value = isEmptyStateRoute(); const presentationState = readPresentationState();
forceEmptyState.value = presentationState === "empty";
isLoading.value = presentationState === "loading";
hasError.value = presentationState === "error";
}; };
onLoad(() => { onLoad(() => {
@@ -252,7 +328,17 @@ const createdGenealogies = computed(() =>
const joinedGenealogies = computed(() => const joinedGenealogies = computed(() =>
list.value.filter((item, index) => index > 0), list.value.filter((item, index) => index > 0),
); );
const currentGenealogy = computed(() => list.value[0]); const availableGenealogies = computed(() => list.value);
const currentGenealogy = computed(() =>
availableGenealogies.value.find((item) => item.id === selectedGenealogyId.value) || availableGenealogies.value[0],
);
// 仅用于页面样式阶段覆盖加入申请的关键状态,不作为接口数据。
const applicationRecords = [
{ id: "pending", name: "汤氏南阳宗谱", statusLabel: "审核中", tone: "pending", description: "申请已提交,等待管理员审核" },
{ id: "rejected", name: "汤氏清河家谱", statusLabel: "被拒绝", tone: "rejected", description: "可修改关系说明后重新申请" },
{ id: "removed", name: "汤氏汝南支谱", statusLabel: "已退出", tone: "muted", description: "如需恢复成员身份,可重新申请加入" },
];
const shortcuts = [ const shortcuts = [
{ {
@@ -282,9 +368,27 @@ const openGenealogy = (genealogy) =>
const createGenealogy = () => const createGenealogy = () =>
uni.navigateTo({ url: "/pages/genealogy/g03-create-genealogy" }); uni.navigateTo({ url: "/pages/genealogy/g03-create-genealogy" });
const applyToJoin = () => uni.navigateTo({ url: "/pages/genealogy/g06-search-genealogies" }); const applyToJoin = () => uni.navigateTo({ url: "/pages/genealogy/g06-search-genealogies" });
const joinByInvite = () => uni.navigateTo({ url: "/pages/genealogy/g06-search-genealogies?mode=invite" });
const toNotifications = () => const toNotifications = () =>
uni.navigateTo({ url: "/pages/notification/n01-message-center" }); uni.navigateTo({ url: "/pages/notification/n01-message-center" });
const openAddDialog = () => { addDialogVisible.value = true; };
const closeAddDialog = () => { addDialogVisible.value = false; };
const openSwitcher = () => { switcherVisible.value = true; };
const closeSwitcher = () => { switcherVisible.value = false; };
const selectGenealogy = (genealogy) => {
selectedGenealogyId.value = genealogy.id;
closeSwitcher();
};
const openApplication = (record) => {
const statusQuery = record.id === "pending" ? "pending" : record.id;
uni.navigateTo({ url: `/pages/genealogy/g09-my-applications?status=${statusQuery}` });
};
const retryLoad = () => {
hasError.value = false;
isLoading.value = false;
};
const openShortcut = (key) => { const openShortcut = (key) => {
const paths = { const paths = {
tree: "/pages/tree/t01-tree-overview", tree: "/pages/tree/t01-tree-overview",
@@ -405,6 +509,12 @@ const openShortcut = (key) => {
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
} }
.current-switch-copy {
flex: 0 0 auto;
margin-left: auto;
color: $brand-red;
font-size: 23rpx;
}
.current-info-divider { .current-info-divider {
position: relative; position: relative;
z-index: 1; z-index: 1;
@@ -512,6 +622,24 @@ const openShortcut = (key) => {
margin-top: 18rpx; margin-top: 18rpx;
} }
.application-record {
position: relative;
display: flex;
min-height: 138rpx;
align-items: center;
box-sizing: border-box;
padding: 22rpx 26rpx;
}
.application-record__skin { position: absolute; inset: 0; width: 100%; height: 100%; }
.application-record__main { position: relative; z-index: 1; min-width: 0; flex: 1; }
.application-record__title-row { display: flex; align-items: center; justify-content: space-between; }
.application-record__name { overflow: hidden; color: $ink; font-family: "STKaiti", "KaiTi", serif; font-size: 32rpx; font-weight: 700; text-overflow: ellipsis; white-space: nowrap; }
.application-record__status { flex: 0 0 auto; margin-left: 16rpx; color: #9a641f; font-size: 23rpx; }
.application-record__status--rejected { color: #a7160c; }
.application-record__status--muted { color: $ink-muted; }
.application-record__copy { display: block; margin-top: 10rpx; color: $ink-muted; font-size: 23rpx; }
.application-record__chevron { position: relative; z-index: 1; width: 32rpx; height: 32rpx; margin-left: 14rpx; }
.create-action { .create-action {
display: flex; display: flex;
min-height: 96rpx; min-height: 96rpx;
@@ -558,16 +686,15 @@ const openShortcut = (key) => {
} }
.state-panel { .state-panel {
position: relative;
display: flex; display: flex;
min-height: 540rpx; min-height: 540rpx;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
overflow: hidden;
padding: 42rpx; padding: 42rpx;
box-sizing: border-box; box-sizing: border-box;
border: 2rpx solid $gold-soft;
border-radius: 18rpx;
background: rgba(255, 249, 238, 0.76);
text-align: center; text-align: center;
} }
@@ -676,8 +803,8 @@ const openShortcut = (key) => {
margin-top: 16rpx; margin-top: 16rpx;
} }
.empty-create-action, .empty-search-action,
.empty-search-action { .empty-invite-action {
position: relative; position: relative;
display: flex; display: flex;
width: 468rpx; width: 468rpx;
@@ -687,47 +814,24 @@ const openShortcut = (key) => {
overflow: hidden; overflow: hidden;
} }
.empty-create-action { .empty-search-action {
margin-top: 20rpx; margin-top: 20rpx;
} }
.empty-search-action { .empty-invite-action {
margin-top: 18rpx; margin-top: 14rpx;
} }
.empty-create-action__skin, .empty-search-action__skin,
.empty-search-action__skin { .empty-invite-action__skin {
position: absolute; position: absolute;
inset: 0; inset: 0;
width: 100%; width: 100%;
height: 100%; height: 100%;
} }
.empty-create-action__content { .empty-search-action__copy,
position: relative; .empty-invite-action__copy {
z-index: 1;
display: flex;
align-items: center;
color: #fff9ec;
font-family: "STKaiti", "KaiTi", serif;
font-size: 31rpx;
font-weight: 700;
letter-spacing: 4rpx;
}
.empty-create-action__icon {
width: 34rpx;
height: 34rpx;
margin-right: 8rpx;
}
.empty-create-action__cloud {
width: 46rpx;
height: 24rpx;
margin-left: 8rpx;
}
.empty-search-action__copy {
position: relative; position: relative;
z-index: 1; z-index: 1;
color: #7b4e24; color: #7b4e24;
@@ -736,6 +840,44 @@ const openShortcut = (key) => {
font-weight: 700; font-weight: 700;
letter-spacing: 3rpx; letter-spacing: 3rpx;
} }
.empty-search-action__copy--primary { color: #fff9ec; }
.empty-create-action { display: flex; align-items: center; justify-content: center; min-height: 64rpx; margin-top: 8rpx; color: $brand-red; }
.empty-create-action__copy { font-family: "STKaiti", "KaiTi", serif; font-size: 27rpx; text-decoration: underline; text-underline-offset: 8rpx; }
.empty-create-note { display: block; color: $ink-muted; font-size: 20rpx; line-height: 30rpx; }
.state-panel__skin { position: absolute; inset: 0; width: 100%; height: 100%; }
.state-panel__content { position: relative; z-index: 1; display: flex; min-height: 540rpx; flex-direction: column; align-items: center; justify-content: center; padding: 42rpx; box-sizing: border-box; }
.state-retry { position: relative; display: flex; width: 360rpx; height: 96rpx; align-items: center; justify-content: center; margin-top: 30rpx; overflow: hidden; }
.state-retry__skin { position: absolute; inset: 0; width: 100%; height: 100%; }
.state-retry__copy { position: relative; z-index: 1; color: #fff9ec; font-family: "STKaiti", "KaiTi", serif; font-size: 29rpx; }
.add-dialog-layer,
.genealogy-switcher-layer { position: fixed; z-index: 40; inset: 0; display: flex; align-items: center; justify-content: center; box-sizing: border-box; padding: 40rpx; background: rgba(34, 20, 12, 0.58); }
.add-dialog,
.genealogy-switcher { position: relative; width: 100%; max-width: 670rpx; min-height: 720rpx; }
.add-dialog__skin,
.genealogy-switcher__skin { position: absolute; inset: 0; width: 100%; height: 100%; }
.add-dialog__content,
.genealogy-switcher__content { position: relative; z-index: 1; display: flex; min-height: 720rpx; flex-direction: column; align-items: center; box-sizing: border-box; padding: 78rpx 58rpx 50rpx; }
.dialog-title { color: $brand-red; font-family: "STKaiti", "KaiTi", serif; font-size: 42rpx; font-weight: 700; letter-spacing: 4rpx; }
.dialog-copy { margin-top: 18rpx; color: $ink-muted; font-size: 23rpx; }
.dialog-primary,
.dialog-secondary { position: relative; display: flex; width: 100%; height: 98rpx; align-items: center; justify-content: center; overflow: hidden; font-family: "STKaiti", "KaiTi", serif; font-size: 29rpx; }
.dialog-primary { margin-top: 42rpx; color: #fff9ec; }
.dialog-secondary { margin-top: 16rpx; color: #7b4e24; }
.dialog-action__skin { position: absolute; inset: 0; width: 100%; height: 100%; }
.dialog-primary__copy,
.dialog-secondary > text { position: relative; z-index: 1; }
.dialog-create { display: flex; min-height: 76rpx; align-items: center; margin-top: 8rpx; color: $brand-red; font-size: 23rpx; }
.dialog-close { display: flex; min-height: 72rpx; align-items: center; justify-content: center; margin-top: auto; color: $ink-muted; font-size: 24rpx; }
.switcher-item { display: flex; width: 100%; min-height: 108rpx; align-items: center; justify-content: space-between; box-sizing: border-box; padding: 18rpx 12rpx; border-bottom: 1rpx solid rgba(181, 138, 75, 0.42); }
.switcher-item:first-of-type { margin-top: 28rpx; }
.switcher-item__name,
.switcher-item__meta { display: block; }
.switcher-item__name { color: $ink; font-family: "STKaiti", "KaiTi", serif; font-size: 31rpx; font-weight: 700; }
.switcher-item__meta { margin-top: 6rpx; color: $ink-muted; font-size: 21rpx; }
.switcher-item__state { color: $brand-red; font-size: 23rpx; }
.switcher-item--active .switcher-item__name { color: $brand-red; }
.action-hover { .action-hover {
opacity: 0.82; opacity: 0.82;
+129 -46
View File
@@ -49,27 +49,34 @@
<view class="flow-fields"> <view class="flow-fields">
<view class="field-row"> <view class="field-row">
<text>姓氏</text> <text>姓氏</text>
<input v-model="createForm.surname" maxlength="4" placeholder="如:汤" placeholder-class="placeholder" /> <input v-model="createForm.surname" maxlength="4" placeholder="如:汤" placeholder-class="placeholder" @input="clearFieldError('surname')" />
</view> </view>
<text v-if="fieldErrors.surname" class="field-error">{{ fieldErrors.surname }}</text>
<view class="field-row"> <view class="field-row">
<text>谱名</text> <text>谱名</text>
<input v-model="createForm.name" maxlength="24" placeholder="如:汤氏家谱" placeholder-class="placeholder" /> <input v-model="createForm.name" maxlength="24" placeholder="如:汤氏家谱" placeholder-class="placeholder" @input="clearFieldError('name')" />
</view> </view>
<text v-if="fieldErrors.name" class="field-error">{{ fieldErrors.name }}</text>
<view class="field-row"> <view class="field-row">
<text>堂号</text> <text>堂号</text>
<input v-model="createForm.hall" maxlength="12" placeholder="选填" placeholder-class="placeholder" /> <input v-model="createForm.hall" maxlength="12" placeholder="选填" placeholder-class="placeholder" />
</view> </view>
<view class="field-row"> <view class="field-row">
<text>所在地</text> <text>所在地</text>
<input v-model="createForm.location" maxlength="30" placeholder="请选择或输入" placeholder-class="placeholder" /> <input v-model="createForm.location" maxlength="30" placeholder="请选择或输入" placeholder-class="placeholder" @input="clearFieldError('location')" />
</view> </view>
<text v-if="fieldErrors.location" class="field-error">{{ fieldErrors.location }}</text>
</view> </view>
<view class="flow-rule"> <view class="flow-rule">
<text class="flow-rule__label">访问规则</text> <text class="flow-rule__label">访问规则</text>
<text class="flow-rule__value">默认仅成员可见</text> <view class="flow-rule__options">
<view class="flow-rule__option" :class="{ 'flow-rule__option--active': createForm.visibility === 'MEMBER_ONLY' }" @click="createForm.visibility = 'MEMBER_ONLY'">仅成员可见</view>
<view class="flow-rule__option" :class="{ 'flow-rule__option--active': createForm.visibility === 'SEARCHABLE' }" @click="createForm.visibility = 'SEARCHABLE'">可搜索申请</view>
</view>
</view> </view>
<text class="flow-rule__note">保护族人资料创建后可在家谱设置中开放搜索与申请加入</text> <text class="flow-rule__note">保护族人资料创建后可在家谱设置中开放搜索与申请加入</text>
<text v-if="createState === 'error'" class="flow-error">创建样式模拟失败请检查信息后重试</text>
<view class="flow-primary-action" hover-class="action-hover" @click="submitCreate"> <view class="flow-primary-action" hover-class="action-hover" @click="submitCreate">
<image <image
@@ -77,7 +84,7 @@
src="/static/assets/foundation/opaque/a01-primary-button.png" src="/static/assets/foundation/opaque/a01-primary-button.png"
mode="scaleToFill" mode="scaleToFill"
/> />
<text class="flow-primary-action__copy">创建并录入首代</text> <text class="flow-primary-action__copy">{{ createState === 'submitting' ? '正在创建…' : '创建并录入首代' }}</text>
</view> </view>
</view> </view>
@@ -89,8 +96,9 @@
<view class="flow-fields"> <view class="flow-fields">
<view class="field-row"> <view class="field-row">
<text>姓名</text> <text>姓名</text>
<input v-model="ancestorForm.personName" maxlength="20" placeholder="请输入首代姓名" placeholder-class="placeholder" /> <input v-model="ancestorForm.personName" maxlength="20" placeholder="请输入首代姓名" placeholder-class="placeholder" @input="clearFieldError('personName')" />
</view> </view>
<text v-if="fieldErrors.personName" class="field-error">{{ fieldErrors.personName }}</text>
<view class="field-row"> <view class="field-row">
<text>世代</text> <text>世代</text>
<text class="fixed-value">第一世</text> <text class="fixed-value">第一世</text>
@@ -100,6 +108,7 @@
<input v-model="ancestorForm.birthDate" placeholder="如:1940年" placeholder-class="placeholder" /> <input v-model="ancestorForm.birthDate" placeholder="如:1940年" placeholder-class="placeholder" />
</view> </view>
</view> </view>
<text v-if="ancestorState === 'error'" class="flow-error">首代人物保存失败请稍后重试</text>
<view class="intro-field"> <view class="intro-field">
<text>生平简述</text> <text>生平简述</text>
@@ -112,7 +121,40 @@
src="/static/assets/foundation/opaque/a01-primary-button.png" src="/static/assets/foundation/opaque/a01-primary-button.png"
mode="scaleToFill" mode="scaleToFill"
/> />
<text class="flow-primary-action__copy">保存并查看世系树</text> <text class="flow-primary-action__copy">{{ ancestorState === 'submitting' ? '正在保存…' : '保存并进入家谱' }}</text>
</view>
</view>
</view>
</view>
<view v-if="duplicateReminderVisible" class="duplicate-reminder-layer" @click="closeDuplicateReminder">
<view class="duplicate-reminder" @click.stop>
<image class="duplicate-reminder__skin" src="/static/assets/modules/genealogy/opaque/g03-create-flow-panel.png" mode="scaleToFill" />
<view class="duplicate-reminder__content">
<text class="duplicate-reminder__title">先确认是否已有家谱</text>
<text class="duplicate-reminder__copy">同一家族可能已经建谱建议先搜索谱名地区和堂号避免重复创建</text>
<view class="duplicate-reminder__search" @click="searchExistingGenealogy">
<image src="/static/assets/foundation/opaque/a01-secondary-button.png" mode="scaleToFill" />
<text>先搜索已有家谱</text>
</view>
<view class="duplicate-reminder__confirm" @click="confirmCreate">
<image src="/static/assets/foundation/opaque/a01-primary-button.png" mode="scaleToFill" />
<text>确认没有继续创建</text>
</view>
<view class="duplicate-reminder__cancel" @click="closeDuplicateReminder">返回修改</view>
</view>
</view>
</view>
<view v-if="ancestorState === 'success'" class="flow-success-layer">
<view class="flow-success-dialog">
<image class="flow-success-dialog__skin" src="/static/assets/modules/genealogy/opaque/g03-create-flow-panel.png" mode="scaleToFill" />
<view class="flow-success-dialog__content">
<text class="flow-success-dialog__title">家谱创建完成</text>
<text class="flow-success-dialog__copy">首代人物已保存接下来进入家谱总览继续完善资料</text>
<view class="flow-success-dialog__action" @click="enterOverview">
<image src="/static/assets/foundation/opaque/a01-primary-button.png" mode="scaleToFill" />
<text>进入家谱总览</text>
</view> </view>
</view> </view>
</view> </view>
@@ -123,12 +165,15 @@
<script setup> <script setup>
import { computed, onMounted, onUnmounted, reactive, ref } from "vue"; import { computed, onMounted, onUnmounted, reactive, ref } from "vue";
import { onLoad, onShow } from "@dcloudio/uni-app"; import { onLoad, onShow } from "@dcloudio/uni-app";
import { appApi } from "@/utils/api.js";
import { genealogyContext } from "@/utils/genealogy-context.js";
const currentStep = ref("create"); const currentStep = ref("create");
const genealogyId = ref(""); const genealogyId = ref("");
const isSubmitting = ref(false); const isSubmitting = ref(false);
const createState = ref("form");
const ancestorState = ref("form");
const duplicateReminderVisible = ref(false);
const fieldErrors = reactive({ surname: "", name: "", location: "", personName: "" });
let submitTimer = null;
const createForm = reactive({ const createForm = reactive({
surname: "", surname: "",
name: "", name: "",
@@ -164,11 +209,7 @@ const syncFlowFromRoute = () => {
: query.step === "ancestor"; : query.step === "ancestor";
const routeGenealogyId = query.get ? query.get("genealogyId") : query.genealogyId; const routeGenealogyId = query.get ? query.get("genealogyId") : query.genealogyId;
currentStep.value = isAncestorRoute ? "ancestor" : "create"; currentStep.value = isAncestorRoute ? "ancestor" : "create";
genealogyId.value = routeGenealogyId || (isAncestorStep.value ? genealogyContext.getCurrentGenealogyId() : ""); genealogyId.value = routeGenealogyId || (isAncestorStep.value ? "local-created-genealogy" : "");
if (isAncestorStep.value && genealogyId.value) {
genealogyContext.setCurrentGenealogyId(genealogyId.value);
}
}; };
onLoad(() => { onLoad(() => {
@@ -186,6 +227,7 @@ onMounted(() => {
}); });
onUnmounted(() => { onUnmounted(() => {
if (submitTimer) clearTimeout(submitTimer);
if (typeof window !== "undefined") { if (typeof window !== "undefined") {
window.removeEventListener("hashchange", syncFlowFromRoute); window.removeEventListener("hashchange", syncFlowFromRoute);
} }
@@ -200,52 +242,66 @@ const goBack = () => {
uni.navigateBack(); uni.navigateBack();
}; };
const submitCreate = async () => { const clearFieldError = (field) => { fieldErrors[field] = ""; };
if (!createForm.surname.trim() || !createForm.name.trim()) {
uni.showToast({ title: "请填写姓氏和谱名", icon: "none" });
return;
}
const created = await appApi.createGenealogy({ ...createForm }); const validateCreateForm = () => {
genealogyContext.setCurrentGenealogyId(created.id); fieldErrors.surname = createForm.surname.trim() ? "" : "请填写姓氏";
uni.redirectTo({ fieldErrors.name = createForm.name.trim() ? "" : "请填写谱名";
url: `/pages/genealogy/g03-create-genealogy?step=ancestor&genealogyId=${created.id}`, fieldErrors.location = createForm.location.trim() ? "" : "请填写所在地";
}); return !fieldErrors.surname && !fieldErrors.name && !fieldErrors.location;
}; };
const submitAncestor = async () => { const submitCreate = () => {
if (isSubmitting.value) return;
createState.value = "form";
if (!validateCreateForm()) return;
duplicateReminderVisible.value = true;
};
const closeDuplicateReminder = () => { duplicateReminderVisible.value = false; };
const searchExistingGenealogy = () => uni.navigateTo({ url: "/pages/genealogy/g06-search-genealogies" });
const confirmCreate = () => {
closeDuplicateReminder();
isSubmitting.value = true;
createState.value = "submitting";
submitTimer = setTimeout(() => {
isSubmitting.value = false;
if (createForm.name.trim() === "失败") {
createState.value = "error";
return;
}
const createdId = "local-created-genealogy";
uni.redirectTo({ url: `/pages/genealogy/g03-create-genealogy?step=ancestor&genealogyId=${createdId}` });
}, 320);
};
const submitAncestor = () => {
if (isSubmitting.value) return; if (isSubmitting.value) return;
if (!genealogyId.value) { if (!genealogyId.value) {
uni.showToast({ title: "未找到当前家谱", icon: "none" }); ancestorState.value = "error";
return; return;
} }
if (!ancestorForm.personName.trim()) { if (!ancestorForm.personName.trim()) {
uni.showToast({ title: "请填写首代姓名", icon: "none" }); fieldErrors.personName = "请填写首代姓名";
return; return;
} }
isSubmitting.value = true; isSubmitting.value = true;
try { ancestorState.value = "submitting";
const person = await appApi.createPerson(genealogyId.value, { submitTimer = setTimeout(() => {
...ancestorForm,
personName: ancestorForm.personName.trim(),
});
uni.redirectTo({
url: `/pages/tree/t01-tree-overview?genealogyId=${genealogyId.value}&selectedId=${person.id}`,
});
} catch (error) {
uni.showToast({ title: error.message || "保存失败,请稍后重试", icon: "none" });
} finally {
isSubmitting.value = false; isSubmitting.value = false;
} ancestorState.value = ancestorForm.personName.trim() === "失败" ? "error" : "success";
}, 320);
}; };
const enterOverview = () => uni.redirectTo({ url: `/pages/genealogy/g05-genealogy-overview?genealogyId=${genealogyId.value}` });
</script> </script>
<style scoped lang="scss"> <style scoped lang="scss">
.flow-page { .flow-page {
position: relative; position: relative;
min-height: 100vh; min-height: 100vh;
overflow: hidden; overflow-x: hidden;
background: #f9f6ef; background: #f9f6ef;
} }
@@ -338,7 +394,7 @@ const submitAncestor = async () => {
.create-flow-panel { .create-flow-panel {
position: relative; position: relative;
min-height: 876rpx; min-height: 1000rpx;
} }
.create-flow-panel__skin { .create-flow-panel__skin {
@@ -353,7 +409,7 @@ const submitAncestor = async () => {
position: relative; position: relative;
z-index: 1; z-index: 1;
display: flex; display: flex;
min-height: 876rpx; min-height: 1000rpx;
flex-direction: column; flex-direction: column;
box-sizing: border-box; box-sizing: border-box;
padding: 48rpx 50rpx 42rpx; padding: 48rpx 50rpx 42rpx;
@@ -420,6 +476,7 @@ const submitAncestor = async () => {
.placeholder { .placeholder {
color: #b7aa97; color: #b7aa97;
} }
.field-error { display: block; margin-top: 3rpx; color: $brand-red; font-size: 20rpx; text-align: right; }
.fixed-value { .fixed-value {
color: #a33b2b !important; color: #a33b2b !important;
@@ -439,14 +496,14 @@ const submitAncestor = async () => {
font-weight: 700; font-weight: 700;
} }
.flow-rule__value { .flow-rule__options { display: flex; gap: 12rpx; }
color: #a33b2b; .flow-rule__option { display: flex; min-height: 54rpx; align-items: center; padding: 0 10rpx; color: #786654; font-size: 21rpx; }
font-size: 23rpx; .flow-rule__option--active { color: $brand-red; font-weight: 700; }
}
.flow-rule__note { .flow-rule__note {
margin-top: 8rpx; margin-top: 8rpx;
} }
.flow-error { display: block; margin-top: 10rpx; color: $brand-red; font-size: 21rpx; text-align: center; }
.intro-field { .intro-field {
margin-top: 18rpx; margin-top: 18rpx;
@@ -501,4 +558,30 @@ const submitAncestor = async () => {
.action-hover { .action-hover {
opacity: 0.82; opacity: 0.82;
} }
.duplicate-reminder-layer,
.flow-success-layer { position: fixed; z-index: 30; inset: 0; display: flex; align-items: center; justify-content: center; box-sizing: border-box; padding: 42rpx; background: rgba(34, 20, 12, .6); }
.duplicate-reminder,
.flow-success-dialog { position: relative; width: 100%; max-width: 670rpx; min-height: 650rpx; }
.duplicate-reminder__skin,
.flow-success-dialog__skin { position: absolute; inset: 0; width: 100%; height: 100%; }
.duplicate-reminder__content,
.flow-success-dialog__content { position: relative; z-index: 1; display: flex; min-height: 650rpx; flex-direction: column; align-items: center; box-sizing: border-box; padding: 92rpx 64rpx 56rpx; text-align: center; }
.duplicate-reminder__title,
.flow-success-dialog__title { color: $ink; font-family: "STKaiti", "KaiTi", serif; font-size: 39rpx; font-weight: 700; }
.duplicate-reminder__copy,
.flow-success-dialog__copy { margin-top: 24rpx; color: $ink-muted; font-size: 24rpx; line-height: 40rpx; }
.duplicate-reminder__search,
.duplicate-reminder__confirm,
.flow-success-dialog__action { position: relative; display: flex; width: 100%; height: 92rpx; align-items: center; justify-content: center; overflow: hidden; }
.duplicate-reminder__search { margin-top: 38rpx; }
.duplicate-reminder__confirm { margin-top: 14rpx; }
.duplicate-reminder__search image,
.duplicate-reminder__confirm image,
.flow-success-dialog__action image { position: absolute; inset: 0; width: 100%; height: 100%; }
.duplicate-reminder__search text,
.duplicate-reminder__confirm text,
.flow-success-dialog__action text { position: relative; z-index: 1; color: #fff9ec; font-size: 25rpx; font-weight: 700; }
.duplicate-reminder__search text { color: #7b4e24; }
.duplicate-reminder__cancel { display: flex; min-height: 68rpx; align-items: center; margin-top: auto; color: $ink-muted; font-size: 23rpx; }
.flow-success-dialog__action { margin-top: 44rpx; }
</style> </style>
+79 -23
View File
@@ -4,13 +4,13 @@
<image class="overview-page__paper" src="/static/assets/foundation/opaque/page-paper.jpg" mode="aspectFill" /> <image class="overview-page__paper" src="/static/assets/foundation/opaque/page-paper.jpg" mode="aspectFill" />
<image class="overview-page__footer" src="/static/assets/foundation/transparent/footer-mountain-bamboo.png" mode="widthFix" /> <image class="overview-page__footer" src="/static/assets/foundation/transparent/footer-mountain-bamboo.png" mode="widthFix" />
<view class="overview-page__header"> <view class="overview-page__header">
<PageHeader title="家谱总览" :action="overviewState === 'ready' ? '管理' : ''" @action="toSettings" /> <PageHeader :title="viewMode === 'public' ? '家谱公开预览' : '家谱总览'" :action="overviewState === 'ready' && viewMode === 'member' && accessRole === 'owner' ? '管理' : ''" @action="toSettings" />
</view> </view>
<view class="overview-surface"> <view class="overview-surface">
<image class="overview-surface__skin" src="/static/assets/modules/genealogy/opaque/g05-overview-surface.png" mode="scaleToFill" /> <image class="overview-surface__skin" src="/static/assets/modules/genealogy/opaque/g05-overview-surface.png" mode="scaleToFill" />
<template v-if="overviewState === 'ready' && genealogy"> <template v-if="overviewState === 'ready' && genealogy && viewMode === 'member'">
<view class="overview-ready"> <view class="overview-ready">
<view class="overview-hero"> <view class="overview-hero">
<text class="overview-hero__kicker">{{ genealogy.hall || '堂号待补' }} · {{ genealogy.location || '所在地待补' }}</text> <text class="overview-hero__kicker">{{ genealogy.hall || '堂号待补' }} · {{ genealogy.location || '所在地待补' }}</text>
@@ -23,17 +23,17 @@
</view> </view>
</view> </view>
<view class="overview-actions"> <view class="overview-actions" :class="{ 'overview-actions--member': accessRole === 'member' }">
<view class="overview-action overview-action--tree" @click="toTree"> <view class="overview-action overview-action--tree" @click="toTree">
<text class="overview-action__title">世系树</text><text>查看家脉关系</text> <text class="overview-action__title">世系树</text><text>查看家脉关系</text>
</view> </view>
<view class="overview-action overview-action--ancestor" @click="toFirstPerson"> <view v-if="accessRole === 'owner'" class="overview-action overview-action--ancestor" @click="toFirstPerson">
<text class="overview-action__title">录入族人</text><text>从首代开始完善</text> <text class="overview-action__title">录入族人</text><text>从首代开始完善</text>
</view> </view>
<view class="overview-action overview-action--poem" @click="toGenerationPoems"> <view class="overview-action overview-action--poem" @click="toGenerationPoems">
<text class="overview-action__title">字辈谱</text><text>传承行辈次序</text> <text class="overview-action__title">字辈谱</text><text>传承行辈次序</text>
</view> </view>
<view class="overview-action overview-action--review" @click="toApplications"> <view v-if="accessRole === 'owner'" class="overview-action overview-action--review" @click="toApplications">
<text class="overview-action__title">入谱审核</text><text>处理加入申请</text> <text class="overview-action__title">入谱审核</text><text>处理加入申请</text>
</view> </view>
</view> </view>
@@ -48,10 +48,34 @@
</view> </view>
</template> </template>
<view v-else-if="overviewState === 'ready' && genealogy" class="overview-public">
<view class="overview-public__hero">
<text class="overview-public__eyebrow">公开家谱</text>
<text class="overview-public__title">{{ genealogy.name }}</text>
<text class="overview-public__source">{{ genealogy.source }}</text>
</view>
<view class="overview-public__details">
<view><text>姓氏</text><text>{{ genealogy.surname }}</text></view>
<view><text>地区</text><text>{{ genealogy.location }}</text></view>
<view><text>堂号</text><text>{{ genealogy.hall }}</text></view>
<view><text>当前支系</text><text>{{ genealogy.branchName }}</text></view>
<view><text>所属上级谱</text><text>{{ genealogy.parentName }}</text></view>
</view>
<view class="overview-public__notice">
<text>公开说明</text>
<text>{{ genealogy.publicDescription }}</text>
</view>
<view class="overview-public__action" @click="applyToJoin">
<image src="/static/assets/foundation/opaque/a01-primary-button.png" mode="scaleToFill" />
<text>申请加入这部家谱</text>
</view>
</view>
<view v-else class="overview-state-panel" :class="{ <view v-else class="overview-state-panel" :class="{
'overview-state--loading': overviewState === 'loading', 'overview-state--loading': overviewState === 'loading',
'overview-state--empty': overviewState === 'empty', 'overview-state--empty': overviewState === 'empty',
'overview-state--error': overviewState === 'error' 'overview-state--error': overviewState === 'error',
'overview-state--no-permission': overviewState === 'no-permission'
}"> }">
<text class="overview-state-panel__title">{{ stateTitle }}</text> <text class="overview-state-panel__title">{{ stateTitle }}</text>
<text class="overview-state-panel__copy">{{ stateCopy }}</text> <text class="overview-state-panel__copy">{{ stateCopy }}</text>
@@ -68,30 +92,49 @@
import { computed, ref } from 'vue' import { computed, ref } from 'vue'
import { onLoad } from '@dcloudio/uni-app' import { onLoad } from '@dcloudio/uni-app'
import PageHeader from '@/components/PageHeader.vue' import PageHeader from '@/components/PageHeader.vue'
import { appApi } from '@/utils/api.js'
import { genealogyContext } from '@/utils/genealogy-context.js'
const genealogy = ref(null) const genealogy = ref(null)
const genealogyId = ref('') const genealogyId = ref('')
const overviewState = ref('loading') const overviewState = ref('loading')
const loadError = ref('') const loadError = ref('')
const viewMode = ref('member')
const accessRole = ref('owner')
const overviewFixture = {
id: '2001',
surname: '汤',
name: '汤氏南阳宗谱',
hall: '敦睦堂',
location: '河南·南阳',
parentName: '汤氏中华总谱',
branchName: '南阳主支',
source: '由南阳汤氏族人整理并维护',
publicDescription: '公开展示家谱身份、地区、堂号与支系信息;成员资料和世系详情仅向已加入成员开放。',
motto: '敦亲睦族,敬祖传家。',
memberCount: 428,
activeCount: 316,
visibility: '仅成员可见'
}
const stateTitle = computed(() => ({ const stateTitle = computed(() => ({
loading: '正在展开家谱…', loading: '正在展开家谱…',
empty: '还没有可查看的家谱', empty: '还没有可查看的家谱',
error: '家谱暂时无法打开' error: '家谱暂时无法打开',
'no-permission': '当前账号无权查看'
}[overviewState.value] || '')) }[overviewState.value] || ''))
const stateCopy = computed(() => loadError.value || ({ const stateCopy = computed(() => loadError.value || ({
loading: '请稍候,正在读取家谱概览。', loading: '请稍候,正在读取家谱概览。',
empty: '请从“我的家谱”选择一部家谱后再进入。', empty: '请从“我的家谱”选择一部家谱后再进入。',
error: '可能是网络波动家谱不存在或当前账号无访问权限。' error: '可能是网络波动家谱不存在。',
'no-permission': '这部家谱未向当前账号开放,请返回我的家谱或联系管理员。'
}[overviewState.value] || '')) }[overviewState.value] || ''))
const loadGenealogy = async (query = {}) => { const loadGenealogy = (query = {}) => {
overviewState.value = 'loading' overviewState.value = 'loading'
loadError.value = '' loadError.value = ''
genealogy.value = null genealogy.value = null
genealogyId.value = query.genealogyId || genealogyId.value || genealogyContext.getCurrentGenealogyId() genealogyId.value = query.genealogyId || genealogyId.value || ''
viewMode.value = query.mode === 'public' ? 'public' : 'member'
accessRole.value = query.role === 'member' ? 'member' : 'owner'
if (query.state === 'empty' || !genealogyId.value) { if (query.state === 'empty' || !genealogyId.value) {
overviewState.value = 'empty' overviewState.value = 'empty'
@@ -101,16 +144,14 @@ const loadGenealogy = async (query = {}) => {
overviewState.value = 'error' overviewState.value = 'error'
return return
} }
if (query.state === 'no-permission') {
genealogyContext.setCurrentGenealogyId(genealogyId.value) overviewState.value = 'no-permission'
try { return
genealogy.value = await appApi.getOverview(genealogyId.value)
overviewState.value = genealogy.value ? 'ready' : 'error'
if (!genealogy.value) loadError.value = '家谱不存在或当前账号无权访问。'
} catch (error) {
loadError.value = error.message || '家谱加载失败,请稍后重试。'
overviewState.value = 'error'
} }
if (query.state === 'loading') return
genealogy.value = { ...overviewFixture, id: genealogyId.value }
overviewState.value = 'ready'
} }
onLoad(loadGenealogy) onLoad(loadGenealogy)
@@ -122,10 +163,11 @@ const toFamily = () => uni.reLaunch({ url: '/pages/family/f01-family-feed' })
const toApplications = () => uni.navigateTo({ url: `/pages/genealogy/g10-application-review?genealogyId=${genealogyId.value}` }) const toApplications = () => uni.navigateTo({ url: `/pages/genealogy/g10-application-review?genealogyId=${genealogyId.value}` })
const toSettings = () => uni.navigateTo({ url: `/pages/genealogy/g11-genealogy-settings?genealogyId=${genealogyId.value}` }) const toSettings = () => uni.navigateTo({ url: `/pages/genealogy/g11-genealogy-settings?genealogyId=${genealogyId.value}` })
const toGenerationPoems = () => uni.navigateTo({ url: `/pages/genealogy/g12-generation-poems?genealogyId=${genealogyId.value}` }) const toGenerationPoems = () => uni.navigateTo({ url: `/pages/genealogy/g12-generation-poems?genealogyId=${genealogyId.value}` })
const applyToJoin = () => uni.navigateTo({ url: `/pages/genealogy/g08-join-application?source=search&genealogyId=${genealogyId.value}` })
</script> </script>
<style scoped lang="scss"> <style scoped lang="scss">
.overview-page { position: relative; min-height: 100vh; overflow: hidden; background: $paper; } .overview-page { position: relative; min-height: 100vh; overflow-x: hidden; background: $paper; }
.overview-page__paper { position: absolute; inset: 0; z-index: 0; width: 100%; height: 100%; opacity: .98; pointer-events: none; } .overview-page__paper { position: absolute; inset: 0; z-index: 0; width: 100%; height: 100%; opacity: .98; pointer-events: none; }
.overview-page__footer { position: absolute; right: 0; bottom: 0; left: 0; z-index: 1; width: 100%; opacity: .13; pointer-events: none; } .overview-page__footer { position: absolute; right: 0; bottom: 0; left: 0; z-index: 1; width: 100%; opacity: .13; pointer-events: none; }
.overview-page__header { position: relative; z-index: 3; } .overview-page__header { position: relative; z-index: 3; }
@@ -138,9 +180,9 @@ const toGenerationPoems = () => uni.navigateTo({ url: `/pages/genealogy/g12-gene
.overview-hero__motto { display: block; margin-top: 6rpx; color: #e9dcc1; font-size: 23rpx; } .overview-hero__motto { display: block; margin-top: 6rpx; color: #e9dcc1; font-size: 23rpx; }
.overview-hero__stats { display: flex; gap: 18rpx; margin-top: 12rpx; color: #dbc990; font-size: 22rpx; } .overview-hero__stats { display: flex; gap: 18rpx; margin-top: 12rpx; color: #dbc990; font-size: 22rpx; }
.overview-actions { position: absolute; top: 29.2%; right: 7.2%; left: 7.2%; display: grid; grid-template-columns: 1fr 1fr; grid-template-rows: 1fr 1fr; gap: 3.4% 3%; height: 36.2%; } .overview-actions { position: absolute; top: 29.2%; right: 7.2%; left: 7.2%; display: grid; grid-template-columns: 1fr 1fr; grid-template-rows: 1fr 1fr; gap: 3.4% 3%; height: 36.2%; }
.overview-actions--member { grid-template-rows: 1fr; height: 18%; }
.overview-action { display: flex; flex-direction: column; justify-content: center; min-width: 0; padding: 0 22rpx; box-sizing: border-box; color: $ink-muted; font-size: 22rpx; } .overview-action { display: flex; flex-direction: column; justify-content: center; min-width: 0; padding: 0 22rpx; box-sizing: border-box; color: $ink-muted; font-size: 22rpx; }
.overview-action__title { margin-bottom: 9rpx; color: $ink; font-family: 'STKaiti', 'KaiTi', serif; font-size: 30rpx; font-weight: 700; } .overview-action__title { margin-bottom: 9rpx; color: $ink; font-family: 'STKaiti', 'KaiTi', serif; font-size: 30rpx; font-weight: 700; }
.overview-action--tree .overview-action__title { color: $brand-red; }
.overview-family { position: absolute; top: 70.2%; right: 7.5%; left: 7.5%; display: grid; grid-template-columns: auto 1fr auto; align-items: center; gap: 16rpx; color: $ink-muted; font-size: 22rpx; } .overview-family { position: absolute; top: 70.2%; right: 7.5%; left: 7.5%; display: grid; grid-template-columns: auto 1fr auto; align-items: center; gap: 16rpx; color: $ink-muted; font-size: 22rpx; }
.overview-family text:first-child { color: $ink; font-family: 'STKaiti', 'KaiTi', serif; font-size: 28rpx; font-weight: 700; } .overview-family text:first-child { color: $ink; font-family: 'STKaiti', 'KaiTi', serif; font-size: 28rpx; font-weight: 700; }
.overview-family text:last-child { color: $brand-red; } .overview-family text:last-child { color: $brand-red; }
@@ -152,6 +194,20 @@ const toGenerationPoems = () => uni.navigateTo({ url: `/pages/genealogy/g12-gene
.overview-state-panel__action { position: relative; width: 300rpx; height: 76rpx; margin-top: 30rpx; } .overview-state-panel__action { position: relative; width: 300rpx; height: 76rpx; margin-top: 30rpx; }
.overview-state-panel__action image { position: absolute; inset: 0; width: 100%; height: 100%; } .overview-state-panel__action image { position: absolute; inset: 0; width: 100%; height: 100%; }
.overview-state-panel__action text { position: relative; z-index: 1; display: flex; align-items: center; justify-content: center; height: 100%; color: #fff9ed; font-size: 25rpx; font-weight: 700; } .overview-state-panel__action text { position: relative; z-index: 1; display: flex; align-items: center; justify-content: center; height: 100%; color: #fff9ed; font-size: 25rpx; font-weight: 700; }
.overview-public { position: absolute; inset: 0; }
.overview-public__hero { position: absolute; top: 6.5%; right: 8%; left: 8%; color: #fff8ec; }
.overview-public__eyebrow { display: block; color: #dec483; font-size: 21rpx; letter-spacing: 3rpx; }
.overview-public__title { display: block; margin-top: 10rpx; font-family: 'STKaiti', 'KaiTi', serif; font-size: 43rpx; font-weight: 700; letter-spacing: 4rpx; }
.overview-public__source { display: block; margin-top: 7rpx; color: #e9dcc1; font-size: 22rpx; }
.overview-public__details { position: absolute; top: 30%; right: 9%; left: 9%; display: grid; grid-template-columns: 1fr 1fr; gap: 18rpx 28rpx; }
.overview-public__details > view { display: flex; flex-direction: column; }
.overview-public__details text:first-child { color: $brand-red; font-size: 21rpx; }
.overview-public__details text:last-child { margin-top: 6rpx; color: $ink; font-family: 'STKaiti', 'KaiTi', serif; font-size: 27rpx; }
.overview-public__notice { position: absolute; top: 58%; right: 9%; left: 9%; display: flex; flex-direction: column; color: $ink-muted; font-size: 22rpx; line-height: 36rpx; }
.overview-public__notice text:first-child { margin-bottom: 8rpx; color: $brand-red; font-family: 'STKaiti', 'KaiTi', serif; font-size: 27rpx; font-weight: 700; }
.overview-public__action { position: absolute; right: 9%; bottom: 7%; left: 9%; display: flex; height: 82rpx; align-items: center; justify-content: center; overflow: hidden; }
.overview-public__action image { position: absolute; inset: 0; width: 100%; height: 100%; }
.overview-public__action text { position: relative; z-index: 1; color: #fff9ed; font-size: 27rpx; font-weight: 700; letter-spacing: 3rpx; }
@media (min-width: 400px) { @media (min-width: 400px) {
.overview-hero { top: 7%; } .overview-hero { top: 7%; }
.overview-action { padding: 0 28rpx; } .overview-action { padding: 0 28rpx; }
+246 -70
View File
@@ -1,116 +1,292 @@
<!-- 页面编号G-06用途公开家谱检索统一承载初始加载结果与无结果状态 --> <!-- 页面编号G-06用途搜索家谱邀请码加入双模式定位目标家谱 -->
<template> <template>
<view class="search-page"> <view class="search-page">
<!-- 底图只提供完整纸纹不以 CSS 补色或绘制装饰 --> <image class="search-page__paper" src="/static/assets/foundation/opaque/page-paper.jpg" mode="scaleToFill" />
<image class="search-page__paper" src="/static/assets/foundation/opaque/page-paper.jpg" mode="aspectFill" />
<image class="search-page__footer" src="/static/assets/foundation/transparent/footer-mountain-bamboo.png" mode="widthFix" /> <image class="search-page__footer" src="/static/assets/foundation/transparent/footer-mountain-bamboo.png" mode="widthFix" />
<view class="search-page__header"><PageHeader title="查找家谱" /></view> <view class="search-page__header"><PageHeader title="加入家谱" /></view>
<view class="search-page__content"> <view class="search-page__content">
<!-- 选定题签稿题签位图只提供材质品牌谱印和标题保留为真实应用元素 -->
<view class="search-title-strip"> <view class="search-title-strip">
<image class="search-title-strip__skin" src="/static/assets/modules/genealogy/opaque/g06-search-title-strip.png" mode="scaleToFill" /> <image class="search-title-strip__skin" src="/static/assets/modules/genealogy/opaque/g06-search-title-strip.png" mode="scaleToFill" />
<image class="search-title-strip__seal" src="/static/assets/foundation/transparent/brand-seal.png" mode="aspectFit" /> <image class="search-title-strip__seal" src="/static/assets/foundation/transparent/brand-seal.png" mode="aspectFit" />
<text class="search-title-strip__text">公开家谱检索</text> <text class="search-title-strip__text">加入家谱</text>
</view> </view>
<view class="search-controls">
<view class="search-field">
<image class="search-field__skin" src="/static/assets/modules/genealogy/opaque/g06-search-input-wide.png" mode="scaleToFill" />
<input v-model="keyword" class="search-input" placeholder="输入姓氏、谱名或地区" placeholder-class="search-input__placeholder" @confirm="search" />
</view>
<view class="search-action" @click="search">
<image class="search-action__skin" src="/static/assets/modules/genealogy/opaque/g06-search-button.png" mode="scaleToFill" />
<text class="search-action__label">搜索</text>
</view>
</view>
<image class="search-divider" src="/static/assets/modules/genealogy/transparent/section-divider.png" mode="scaleToFill" />
<!-- 同一路由内以数据状态切换避免为结果或空结果新增占位页面 --> <view class="mode-tabs">
<view v-if="searchState === 'loading'" class="search-status search-loading"> <view class="mode-tab" :class="{ 'mode-tab--active': mode === 'search' }" @click="switchMode('search')">搜索家谱</view>
<text>正在整理可申请加入的家谱</text> <view class="mode-tab" :class="{ 'mode-tab--active': mode === 'invite' }" @click="switchMode('invite')">邀请码加入</view>
</view>
<view v-else-if="searchState === 'initial'" class="search-status search-initial">
<text class="search-status__lead">从姓氏谱名或祖居地开始</text>
<text class="search-status__copy">找到合适的家谱后可申请加入并查阅</text>
<image class="search-status__hall" src="/static/assets/foundation/transparent/root-header-hall.png" mode="aspectFit" />
</view>
<view v-else-if="searchState === 'results'" class="search-results">
<view class="search-results__head"><text>检索到 {{ results.length }} 部可申请的家谱</text><text>点选后填写申请</text></view>
<GenealogyCard v-for="item in results" :key="item.id" :genealogy="item" role="可申请" @select="openApply" />
</view>
<view v-else class="search-status search-empty">
<image class="search-status__cloud" src="/static/assets/modules/genealogy/transparent/create-cloud.png" mode="aspectFit" />
<text class="search-status__lead">暂未找到公开家谱</text>
<text class="search-status__copy">可换一个姓氏谱名或祖居地再试</text>
</view> </view>
<template v-if="mode === 'search'">
<view class="search-controls">
<view class="search-field">
<image class="search-field__skin" src="/static/assets/modules/genealogy/opaque/g06-search-input-wide.png" mode="scaleToFill" />
<input v-model.trim="keyword" class="search-input" placeholder="姓氏、谱名、地区或堂号" placeholder-class="search-input__placeholder" @confirm="search" />
<view v-if="keyword" class="search-clear" @click="clearSearch">清空</view>
</view>
<view class="search-action" @click="search">
<image class="search-action__skin" src="/static/assets/modules/genealogy/opaque/g06-search-button.png" mode="scaleToFill" />
<text class="search-action__label">搜索</text>
</view>
</view>
<view class="filter-row">
<text class="filter-label">地区</text>
<view v-for="area in areas" :key="area" class="filter-chip" :class="{ 'filter-chip--active': selectedArea === area }" @click="selectedArea = area">{{ area }}</view>
</view>
<image class="search-divider" src="/static/assets/modules/genealogy/transparent/section-divider.png" mode="scaleToFill" />
<view v-if="searchState === 'loading'" class="search-status search-loading">
<text class="search-status__lead">正在检索公开家谱</text>
<text class="search-status__copy">请稍候正在整理匹配结果</text>
</view>
<view v-else-if="searchState === 'initial'" class="search-status search-initial">
<text class="search-status__lead">先确认家族是否已有家谱</text>
<text class="search-status__copy">可按姓氏谱名地区或堂号查找避免重复创建</text>
<image class="search-status__hall" src="/static/assets/foundation/transparent/root-header-hall.png" mode="aspectFit" />
</view>
<view v-else-if="searchState === 'results'" class="search-results">
<view class="search-results__head"><text>检索到 {{ results.length }} 部家谱</text><text>请核对支系与管理信息</text></view>
<view v-for="item in results" :key="item.id" class="result-card genealogy-card" @click="openPreview(item)">
<image class="result-card__skin" src="/static/assets/modules/genealogy/transparent/list-slip-frame.png" mode="scaleToFill" />
<view class="result-card__content">
<view class="result-card__head">
<view>
<text class="result-card__name">{{ item.name }}</text>
<text class="result-card__identity">{{ item.surname }} · {{ item.hall }}</text>
</view>
<text class="result-card__relation" :class="`result-card__relation--${item.relation}`">{{ item.actionLabel }}</text>
</view>
<view class="result-card__facts">
<text>地区{{ item.location }}</text>
<text>所属上级谱{{ item.parentName }}</text>
<text>当前支系{{ item.branchName }}</text>
<text>管理信息{{ item.manager }} · {{ item.certification }}</text>
<text>{{ item.memberCount }} 位成员 · 更新于 {{ item.updatedAt }}</text>
</view>
<view class="result-card__action" @click.stop="handleResultAction(item)">{{ item.actionLabel }}</view>
</view>
</view>
</view>
<view v-else-if="searchState === 'empty'" class="search-status search-empty">
<image class="search-status__cloud" src="/static/assets/modules/genealogy/transparent/create-cloud.png" mode="aspectFit" />
<text class="search-status__lead">暂未找到匹配家谱</text>
<text class="search-status__copy">可调整地区或关键词后重试也可使用邀请码加入</text>
</view>
<view v-else class="search-status search-error">
<text class="search-status__lead">检索暂时失败</text>
<text class="search-status__copy">当前仅展示失败样式请稍后重新搜索</text>
<view class="status-retry" @click="search">重新搜索</view>
</view>
</template>
<template v-else>
<view class="invite-block">
<text class="invite-title">输入家谱邀请码</text>
<text class="invite-copy">邀请码用于定位指定家谱加入前仍需确认真实姓名和关系</text>
<view class="invite-controls">
<view class="invite-field">
<image class="search-field__skin" src="/static/assets/modules/genealogy/opaque/g06-search-input-wide.png" mode="scaleToFill" />
<input v-model.trim="inviteCode" class="invite-input" maxlength="12" placeholder="请输入邀请码" placeholder-class="search-input__placeholder" />
</view>
<view class="search-action" @click="verifyInvite">
<image class="search-action__skin" src="/static/assets/modules/genealogy/opaque/g06-search-button.png" mode="scaleToFill" />
<text class="search-action__label">验证</text>
</view>
</view>
<text class="invite-demo">样式验证可输入 JP2026</text>
</view>
<image class="search-divider" src="/static/assets/modules/genealogy/transparent/section-divider.png" mode="scaleToFill" />
<view v-if="inviteState === 'initial'" class="search-status invite-initial">
<text class="search-status__lead">通过邀请码直接定位家谱</text>
<text class="search-status__copy">验证有效后会显示目标家谱确认关系后可直接加入</text>
</view>
<view v-else-if="inviteState === 'invalid'" class="search-status invite-invalid">
<text class="search-status__lead">邀请码无效或已过期</text>
<text class="search-status__copy">请核对邀请码或向家谱管理员重新获取</text>
</view>
<view v-else class="invite-result">
<text class="invite-result__label">已定位目标家谱</text>
<view class="result-card genealogy-card">
<image class="result-card__skin" src="/static/assets/modules/genealogy/transparent/list-slip-frame.png" mode="scaleToFill" />
<view class="result-card__content">
<text class="result-card__name">{{ inviteTarget.name }}</text>
<view class="result-card__facts">
<text>{{ inviteTarget.surname }} · {{ inviteTarget.hall }} · {{ inviteTarget.location }}</text>
<text>所属上级谱{{ inviteTarget.parentName }}</text>
<text>当前支系{{ inviteTarget.branchName }}</text>
<text>{{ inviteTarget.manager }} · {{ inviteTarget.certification }}</text>
</view>
<view class="result-card__action" @click="confirmInvite">确认关系并加入</view>
</view>
</view>
<text class="invite-result__note">提交后直接加入无需等待审核</text>
</view>
</template>
</view> </view>
</view> </view>
</template> </template>
<script setup> <script setup>
import { onMounted, ref } from 'vue' import { computed, ref } from 'vue'
import { onLoad, onUnload, onShow } from '@dcloudio/uni-app'
import PageHeader from '@/components/PageHeader.vue' import PageHeader from '@/components/PageHeader.vue'
import GenealogyCard from '@/components/GenealogyCard.vue'
import { appApi } from '@/utils/api.js'
const mode = ref('search')
const keyword = ref('') const keyword = ref('')
const allResults = ref([]) const selectedArea = ref('全部')
const results = ref([])
const searchState = ref('initial') const searchState = ref('initial')
const inviteCode = ref('')
const inviteState = ref('initial')
const results = ref([])
let searchTimer = null
onMounted(async () => { const areas = ['全部', '河南', '山东']
searchState.value = 'loading' const resultFixtures = [
allResults.value = await appApi.getPublicGenealogies() { id: 2001, surname: '汤', name: '汤氏南阳宗谱', hall: '敦睦堂', location: '河南·南阳', parentName: '汤氏中华总谱', branchName: '南阳主支', manager: '管理员 汤文礼', certification: '资料已认证', memberCount: 428, updatedAt: '2026-07-12', relation: 'available', actionLabel: '申请加入' },
searchState.value = 'initial' { id: 2002, surname: '汤', name: '汤氏洛阳家谱', hall: '承志堂', location: '河南·洛阳', parentName: '汤氏中华总谱', branchName: '洛阳二支', manager: '管理员 汤文远', certification: '资料已认证', memberCount: 158, updatedAt: '2026-07-10', relation: 'joined', actionLabel: '已加入' },
}) { id: 2003, surname: '汤', name: '汤氏济宁宗谱', hall: '敬宗堂', location: '山东·济宁', parentName: '汤氏鲁西总谱', branchName: '济宁主支', manager: '管理员 汤正明', certification: '管理员已实名', memberCount: 286, updatedAt: '2026-07-08', relation: 'pending', actionLabel: '审核中' },
{ id: 2004, surname: '汤', name: '汤氏清河家谱', hall: '思源堂', location: '山东·临清', parentName: '汤氏鲁西总谱', branchName: '清河支系', manager: '管理员 汤志成', certification: '资料已认证', memberCount: 96, updatedAt: '2026-07-05', relation: 'rejected', actionLabel: '修改后重新申请' },
{ id: 2005, surname: '汤', name: '汤氏汝南支谱', hall: '崇本堂', location: '河南·驻马店', parentName: '汤氏中原总谱', branchName: '汝南三支', manager: '管理员 汤国安', certification: '管理员已实名', memberCount: 72, updatedAt: '2026-07-02', relation: 'removed', actionLabel: '重新申请' },
{ id: 2006, surname: '汤', name: '汤氏新安家谱', hall: '继善堂', location: '河南·三门峡', parentName: '无上级谱', branchName: '新安主支', manager: '创建者 当前用户', certification: '资料待完善', memberCount: 34, updatedAt: '2026-06-28', relation: 'owned', actionLabel: '我创建的' }
]
const search = () => { const inviteTarget = computed(() => resultFixtures[0])
const value = keyword.value.trim()
results.value = value const syncModeFromRoute = () => {
? allResults.value.filter((item) => `${item.name}${item.surname}${item.location}`.includes(value)) let requestedMode = 'search'
: allResults.value if (typeof location !== 'undefined') {
searchState.value = results.value.length ? 'results' : 'empty' requestedMode = new URLSearchParams(location.hash.split('?')[1] || '').get('mode') || 'search'
} else {
const pages = getCurrentPages()
requestedMode = pages[pages.length - 1]?.options?.mode || 'search'
}
mode.value = requestedMode === 'invite' ? 'invite' : 'search'
} }
const openApply = (genealogy) => uni.navigateTo({ url: `/pages/genealogy/g08-join-application?genealogyId=${genealogy.id}` }) onLoad(syncModeFromRoute)
onShow(syncModeFromRoute)
onUnload(() => { if (searchTimer) clearTimeout(searchTimer) })
const switchMode = (nextMode) => {
mode.value = nextMode
searchState.value = 'initial'
inviteState.value = 'initial'
}
const search = () => {
if (searchTimer) clearTimeout(searchTimer)
searchState.value = 'loading'
searchTimer = setTimeout(() => {
const value = keyword.value.trim()
if (value === '失败') {
searchState.value = 'error'
return
}
results.value = resultFixtures.filter((item) => {
const matchesKeyword = !value || `${item.name}${item.surname}${item.location}${item.hall}`.includes(value)
const matchesArea = selectedArea.value === '全部' || item.location.includes(selectedArea.value)
return matchesKeyword && matchesArea
})
searchState.value = results.value.length ? 'results' : 'empty'
searchTimer = null
}, 260)
}
const clearSearch = () => {
keyword.value = ''
results.value = []
searchState.value = 'initial'
}
const openPreview = (item) => {
if (item.relation === 'available') {
uni.navigateTo({ url: `/pages/genealogy/g05-genealogy-overview?mode=public&genealogyId=${item.id}` })
}
}
const handleResultAction = (item) => {
if (item.relation === 'available') return uni.navigateTo({ url: `/pages/genealogy/g08-join-application?source=search&genealogyId=${item.id}` })
if (item.relation === 'pending') return uni.navigateTo({ url: '/pages/genealogy/g09-my-applications?status=pending' })
if (item.relation === 'rejected' || item.relation === 'removed') return uni.navigateTo({ url: `/pages/genealogy/g08-join-application?source=search&previous=${item.relation}&genealogyId=${item.id}` })
return uni.reLaunch({ url: `/pages/genealogy/g01-my-genealogies?genealogyId=${item.id}` })
}
const verifyInvite = () => {
inviteState.value = inviteCode.value.toUpperCase() === 'JP2026' ? 'valid' : 'invalid'
}
const confirmInvite = () => uni.navigateTo({ url: `/pages/genealogy/g08-join-application?source=invite&genealogyId=${inviteTarget.value.id}` })
</script> </script>
<style scoped lang="scss"> <style scoped lang="scss">
/* G06 的完整检索位图与叠放控件样式必须作为同一 SFC 样式模块刷新。 */ .search-page { position: relative; min-height: 100vh; overflow-x: hidden; background: $paper; }
.search-page { position: relative; min-height: 100vh; overflow: hidden; background: $paper; }
.search-page__paper { position: absolute; inset: 0; z-index: 0; width: 100%; height: 100%; opacity: .98; pointer-events: none; } .search-page__paper { position: absolute; inset: 0; z-index: 0; width: 100%; height: 100%; opacity: .98; pointer-events: none; }
.search-page__footer { position: absolute; right: 0; bottom: 0; left: 0; z-index: 1; width: 100%; opacity: .15; pointer-events: none; } .search-page__footer { position: fixed; right: 0; bottom: 0; left: 0; z-index: 1; width: 100%; opacity: .15; pointer-events: none; }
.search-page__header { position: relative; z-index: 2; } .search-page__header { position: relative; z-index: 2; }
.search-page__content { position: relative; z-index: 3; min-height: calc(100vh - 104rpx); padding: 30rpx 24rpx calc(58rpx + env(safe-area-inset-bottom)); box-sizing: border-box; } .search-page__content { position: relative; z-index: 3; min-height: calc(100vh - 104rpx); padding: 30rpx 24rpx calc(58rpx + env(safe-area-inset-bottom)); box-sizing: border-box; }
.search-title-strip { position: relative; width: 100%; height: 138rpx; } .search-title-strip { position: relative; width: 100%; height: 138rpx; }
.search-title-strip__skin { position: absolute; inset: 0; width: 100%; height: 100%; pointer-events: none; } .search-title-strip__skin { position: absolute; inset: 0; width: 100%; height: 100%; pointer-events: none; }
.search-title-strip__seal { position: absolute; top: 27rpx; left: 52rpx; z-index: 1; width: 72rpx; height: 84rpx; } .search-title-strip__seal { position: absolute; top: 27rpx; left: 52rpx; z-index: 1; width: 72rpx; height: 84rpx; }
.search-title-strip__text { position: absolute; top: 0; right: 170rpx; bottom: 0; left: 156rpx; z-index: 1; display: flex; align-items: center; justify-content: center; color: #f5d596; font-family: 'STKaiti', 'KaiTi', serif; font-size: 39rpx; font-weight: 700; letter-spacing: 5rpx; } .search-title-strip__text { position: absolute; inset: 0 170rpx 0 156rpx; z-index: 1; display: flex; align-items: center; justify-content: center; color: #f5d596; font-family: 'STKaiti', 'KaiTi', serif; font-size: 39rpx; font-weight: 700; letter-spacing: 5rpx; }
.search-controls { display: flex; align-items: center; gap: 20rpx; margin-top: 48rpx; } .mode-tabs { display: flex; width: 500rpx; margin: 30rpx auto 0; border-bottom: 1rpx solid rgba(181, 138, 75, .45); }
.mode-tab { position: relative; display: flex; flex: 1; align-items: center; justify-content: center; min-height: 70rpx; color: $ink-muted; font-family: 'STKaiti', 'KaiTi', serif; font-size: 28rpx; }
.mode-tab--active { color: $brand-red; font-weight: 700; }
.mode-tab--active::after { position: absolute; right: 42rpx; bottom: -2rpx; left: 42rpx; height: 4rpx; background: $brand-red; content: ''; }
.search-controls { display: flex; align-items: center; gap: 20rpx; margin-top: 32rpx; }
.invite-controls { display: flex; align-items: center; gap: 20rpx; margin-top: 32rpx; }
.search-field { position: relative; width: 430rpx; height: 95rpx; } .search-field { position: relative; width: 430rpx; height: 95rpx; }
.search-field__skin, .search-action__skin { position: absolute; inset: 0; width: 100%; height: 100%; pointer-events: none; } .search-field__skin,
.search-input { position: relative; z-index: 1; width: 100%; height: 100%; padding: 0 30rpx; box-sizing: border-box; color: $ink; font-size: 26rpx; } .search-action__skin { position: absolute; inset: 0; width: 100%; height: 100%; pointer-events: none; }
.search-input { position: relative; z-index: 1; width: 100%; height: 100%; padding: 0 82rpx 0 30rpx; box-sizing: border-box; color: $ink; font-size: 25rpx; }
.search-input__placeholder { color: #ab9c83; } .search-input__placeholder { color: #ab9c83; }
.search-clear { position: absolute; z-index: 2; top: 0; right: 20rpx; display: flex; height: 100%; align-items: center; color: $brand-red; font-size: 21rpx; }
.search-action { position: relative; display: flex; width: 200rpx; height: 88rpx; align-items: center; justify-content: center; } .search-action { position: relative; display: flex; width: 200rpx; height: 88rpx; align-items: center; justify-content: center; }
.search-action__label { position: relative; z-index: 1; color: #fff4dc; font-family: 'STKaiti', 'KaiTi', serif; font-size: 31rpx; font-weight: 700; letter-spacing: 7rpx; } .search-action__label { position: relative; z-index: 1; color: #fff4dc; font-family: 'STKaiti', 'KaiTi', serif; font-size: 31rpx; font-weight: 700; letter-spacing: 5rpx; }
.search-divider { display: block; width: 100%; height: 30rpx; margin: 40rpx 0 34rpx; } .filter-row { display: flex; align-items: center; gap: 12rpx; margin: 16rpx 14rpx 0; }
.filter-label { color: $ink-muted; font-size: 22rpx; }
.search-status { display: flex; min-height: 190rpx; flex-direction: column; align-items: center; justify-content: center; padding: 10rpx 28rpx 24rpx; box-sizing: border-box; text-align: center; } .filter-chip { display: flex; min-width: 82rpx; min-height: 52rpx; align-items: center; justify-content: center; color: $ink-muted; font-size: 22rpx; }
.filter-chip--active { color: $brand-red; font-weight: 700; }
.search-divider { display: block; width: 100%; height: 30rpx; margin: 24rpx 0 24rpx; }
.search-status { display: flex; min-height: 240rpx; flex-direction: column; align-items: center; justify-content: center; padding: 16rpx 28rpx 24rpx; box-sizing: border-box; text-align: center; }
.search-status__cloud { width: 92rpx; height: 46rpx; margin-bottom: 4rpx; opacity: .78; } .search-status__cloud { width: 92rpx; height: 46rpx; margin-bottom: 4rpx; opacity: .78; }
.search-status__lead { color: $ink; font-family: 'STKaiti', 'KaiTi', serif; font-size: 29rpx; font-weight: 700; letter-spacing: 2rpx; } .search-status__lead { color: $ink; font-family: 'STKaiti', 'KaiTi', serif; font-size: 30rpx; font-weight: 700; letter-spacing: 2rpx; }
.search-status__copy { margin-top: 12rpx; color: $ink-muted; font-size: 22rpx; } .search-status__copy { margin-top: 12rpx; color: $ink-muted; font-size: 22rpx; line-height: 36rpx; }
.search-status__hall { width: 330rpx; height: 132rpx; margin-top: 330rpx; opacity: .22; filter: grayscale(1); } .search-status__hall { width: 330rpx; height: 132rpx; margin-top: 210rpx; opacity: .22; filter: grayscale(1); }
.search-loading { min-height: 168rpx; color: $ink-muted; font-size: 24rpx; } .status-retry { display: flex; min-height: 70rpx; align-items: center; margin-top: 18rpx; color: $brand-red; font-size: 24rpx; }
.search-results { display: flex; flex-direction: column; gap: 18rpx; margin-top: 6rpx; } .search-results { display: flex; flex-direction: column; gap: 16rpx; margin-top: 4rpx; }
.search-results__head { display: flex; align-items: center; justify-content: space-between; padding: 0 16rpx; color: $ink-muted; font-size: 22rpx; } .search-results__head { display: flex; align-items: center; justify-content: space-between; padding: 0 16rpx; color: $ink-muted; font-size: 21rpx; }
.search-results__head text:first-child { color: $ink; font-family: 'STKaiti', 'KaiTi', serif; font-size: 27rpx; font-weight: 700; } .search-results__head text:first-child { color: $ink; font-family: 'STKaiti', 'KaiTi', serif; font-size: 27rpx; font-weight: 700; }
.result-card { position: relative; min-height: 314rpx; box-sizing: border-box; padding: 28rpx 30rpx; overflow: hidden; }
.result-card__skin { position: absolute; inset: 0; width: 100%; height: 100%; }
.result-card__content { position: relative; z-index: 1; }
.result-card__head { display: flex; align-items: flex-start; justify-content: space-between; }
.result-card__name,
.result-card__identity { display: block; }
.result-card__name { color: $ink; font-family: 'STKaiti', 'KaiTi', serif; font-size: 33rpx; font-weight: 700; }
.result-card__identity { margin-top: 5rpx; color: $ink-muted; font-size: 22rpx; }
.result-card__relation { flex: 0 0 auto; max-width: 190rpx; margin-left: 12rpx; color: #9a641f; font-size: 21rpx; text-align: right; }
.result-card__relation--rejected,
.result-card__relation--removed { color: $brand-red; }
.result-card__facts { display: grid; grid-template-columns: 1fr 1fr; gap: 8rpx 18rpx; margin-top: 16rpx; color: $ink-muted; font-size: 23rpx; line-height: 32rpx; }
.result-card__facts text:nth-child(4),
.result-card__facts text:nth-child(5) { grid-column: 1 / -1; }
.result-card__action { display: flex; min-height: 58rpx; align-items: center; justify-content: center; margin-top: 14rpx; color: $brand-red; font-family: 'STKaiti', 'KaiTi', serif; font-size: 27rpx; font-weight: 700; }
.invite-block { padding: 24rpx 18rpx 0; }
.invite-title { display: block; color: $ink; font-family: 'STKaiti', 'KaiTi', serif; font-size: 31rpx; font-weight: 700; }
.invite-copy { display: block; margin-top: 10rpx; color: $ink-muted; font-size: 22rpx; line-height: 35rpx; }
.invite-field { position: relative; flex: 1; height: 95rpx; }
.invite-input { position: relative; z-index: 1; width: 100%; height: 100%; padding: 0 30rpx; box-sizing: border-box; color: $ink; font-size: 25rpx; letter-spacing: 3rpx; }
.invite-demo { display: block; margin-top: 8rpx; color: #9b876f; font-size: 19rpx; }
.invite-result__label { display: block; margin: 0 12rpx 12rpx; color: $ink; font-family: 'STKaiti', 'KaiTi', serif; font-size: 27rpx; font-weight: 700; }
.invite-result__note { display: block; margin-top: 12rpx; color: $brand-red; font-size: 22rpx; text-align: center; }
@media screen and (max-width: 340px) { @media screen and (max-width: 340px) {
.search-page__content { padding-right: 20rpx; padding-left: 20rpx; } .search-page__content { padding-right: 20rpx; padding-left: 20rpx; }
.search-title-strip__seal { left: 44rpx; } .search-title-strip__seal { left: 44rpx; }
.search-title-strip__text { right: 148rpx; left: 138rpx; font-size: 37rpx; } .search-title-strip__text { right: 148rpx; left: 138rpx; font-size: 37rpx; }
.search-field { width: 430rpx; } .search-field { width: 430rpx; }
.search-action { width: 200rpx; height: 88rpx; } .result-card { padding-right: 24rpx; padding-left: 24rpx; }
.result-card__facts { grid-template-columns: 1fr; }
.result-card__facts text { grid-column: 1 !important; }
} }
</style> </style>
+64 -20
View File
@@ -3,7 +3,7 @@
<view class="join-page"> <view class="join-page">
<image class="join-page__paper" src="/static/assets/foundation/opaque/page-paper.jpg" mode="aspectFill" /> <image class="join-page__paper" src="/static/assets/foundation/opaque/page-paper.jpg" mode="aspectFill" />
<image class="join-page__footer" src="/static/assets/foundation/transparent/footer-mountain-bamboo.png" mode="widthFix" /> <image class="join-page__footer" src="/static/assets/foundation/transparent/footer-mountain-bamboo.png" mode="widthFix" />
<view class="join-page__header"><PageHeader title="申请加入家谱" /></view> <view class="join-page__header"><PageHeader :title="sourceContract.headerTitle" /></view>
<view class="join-panel" :class="{ <view class="join-panel" :class="{
'join-state--form': joinState === 'form', 'join-state--form': joinState === 'form',
@@ -13,37 +13,40 @@
<image class="join-panel__skin" src="/static/assets/modules/genealogy/opaque/g03-create-flow-panel.png" mode="scaleToFill" /> <image class="join-panel__skin" src="/static/assets/modules/genealogy/opaque/g03-create-flow-panel.png" mode="scaleToFill" />
<view v-if="joinState === 'form'" class="join-form"> <view v-if="joinState === 'form'" class="join-form">
<text class="join-form__eyebrow">公开家谱入谱申请</text> <text class="join-form__eyebrow">{{ sourceContract.eyebrow }}</text>
<text class="join-form__title">申请加入 {{ genealogyName }}</text> <text class="join-form__title">{{ sourceContract.formTitle }} {{ genealogyName }}</text>
<text class="join-form__copy">请填写真实身份和亲属关系管理员审核后会通过消息告知结果</text> <text class="join-form__copy">{{ sourceContract.formCopy }}</text>
<view class="join-field"> <view class="join-field">
<image src="/static/assets/modules/genealogy/opaque/g06-search-input-wide.png" mode="scaleToFill" /> <image src="/static/assets/modules/genealogy/opaque/g06-search-input-wide.png" mode="scaleToFill" />
<text>真实姓名</text><input v-model="form.realName" placeholder="请输入真实姓名" placeholder-class="join-placeholder" /> <text>真实姓名</text><input v-model="form.realName" placeholder="请输入真实姓名" placeholder-class="join-placeholder" @input="clearFieldError('realName')" />
</view> </view>
<text v-if="fieldErrors.realName" class="join-field-error">{{ fieldErrors.realName }}</text>
<view class="join-field"> <view class="join-field">
<image src="/static/assets/modules/genealogy/opaque/g06-search-input-wide.png" mode="scaleToFill" /> <image src="/static/assets/modules/genealogy/opaque/g06-search-input-wide.png" mode="scaleToFill" />
<text>与家谱关系</text><input v-model="form.relation" placeholder="例如:汤正华堂侄" placeholder-class="join-placeholder" /> <text>与家谱关系</text><input v-model="form.relation" placeholder="例如:汤正华堂侄" placeholder-class="join-placeholder" @input="clearFieldError('relation')" />
</view> </view>
<text v-if="fieldErrors.relation" class="join-field-error">{{ fieldErrors.relation }}</text>
<text class="relation-help">请以家谱中一位已知长辈为参照例如汤正华堂侄</text>
<view class="join-field join-field--message"> <view class="join-field join-field--message">
<image src="/static/assets/modules/genealogy/opaque/g06-search-input-wide.png" mode="scaleToFill" /> <image src="/static/assets/modules/genealogy/opaque/g06-search-input-wide.png" mode="scaleToFill" />
<text>申请说明</text><textarea v-model="form.message" maxlength="80" placeholder="补充祖居地、长辈姓名等核验信息" placeholder-class="join-placeholder" /> <text>{{ sourceContract.thirdFieldLabel }}</text><textarea v-model="form.message" maxlength="80" :placeholder="sourceContract.thirdFieldPlaceholder" placeholder-class="join-placeholder" />
</view> </view>
<text class="join-form__note">提交后可在我的申请中查看审核进度</text> <text class="join-form__note">{{ sourceContract.note }}</text>
<view class="join-action" @click="submitJoin"> <view class="join-action" @click="submitJoin">
<image src="/static/assets/foundation/opaque/a01-primary-button.png" mode="scaleToFill" /> <image src="/static/assets/foundation/opaque/a01-primary-button.png" mode="scaleToFill" />
<text>{{ isSubmitting ? '正在提交…' : '提交申请' }}</text> <text>{{ isSubmitting ? sourceContract.submittingLabel : sourceContract.submitLabel }}</text>
</view> </view>
</view> </view>
<view v-else class="join-result"> <view v-else class="join-result">
<text class="join-result__eyebrow">{{ joinState === 'success' ? '申请已送达' : '申请未提交' }}</text> <text class="join-result__eyebrow">{{ joinState === 'success' ? sourceContract.successEyebrow : sourceContract.errorEyebrow }}</text>
<text class="join-result__title">{{ joinState === 'success' ? '等待管理员核实亲属关系' : '暂时无法提交申请' }}</text> <text class="join-result__title">{{ joinState === 'success' ? sourceContract.successTitle : sourceContract.errorTitle }}</text>
<text class="join-result__copy">{{ resultCopy }}</text> <text class="join-result__copy">{{ resultCopy }}</text>
<view class="join-action" @click="joinState === 'success' ? toMyApplications() : retryForm()"> <view class="join-action" @click="joinState === 'success' ? completeFlow() : retryForm()">
<image src="/static/assets/foundation/opaque/a01-primary-button.png" mode="scaleToFill" /> <image src="/static/assets/foundation/opaque/a01-primary-button.png" mode="scaleToFill" />
<text>{{ joinState === 'success' ? '查看我的申请' : '重新填写' }}</text> <text>{{ joinState === 'success' ? sourceContract.nextLabel : '重新填写' }}</text>
</view> </view>
</view> </view>
</view> </view>
@@ -56,6 +59,7 @@ import { onLoad } from '@dcloudio/uni-app'
import PageHeader from '@/components/PageHeader.vue' import PageHeader from '@/components/PageHeader.vue'
const genealogyId = ref('') const genealogyId = ref('')
const source = ref('search')
const genealogyName = ref('这部家谱') const genealogyName = ref('这部家谱')
const genealogyPreview = { const genealogyPreview = {
'1001': '汤氏家谱', '1001': '汤氏家谱',
@@ -65,12 +69,47 @@ const joinState = ref('form')
const isSubmitting = ref(false) const isSubmitting = ref(false)
const errorMessage = ref('') const errorMessage = ref('')
const form = reactive({ realName: '', relation: '', message: '' }) const form = reactive({ realName: '', relation: '', message: '' })
const fieldErrors = reactive({ realName: '', relation: '' })
const sourceContract = computed(() => source.value === 'invite' ? {
headerTitle: '确认关系并加入',
eyebrow: '邀请码直接加入',
formTitle: '确认加入',
formCopy: '请填写真实身份和亲属关系;提交后直接加入,无需等待管理员审核。',
thirdFieldLabel: '补充信息',
thirdFieldPlaceholder: '选填:补充祖居地、长辈姓名等信息',
note: '邀请码来源提交后直接加入,无需等待审核。',
submitLabel: '确认加入',
submittingLabel: '正在加入…',
successEyebrow: '已加入家谱',
successTitle: '关系信息已提交',
errorEyebrow: '加入未完成',
errorTitle: '暂时无法加入家谱',
nextLabel: '返回我的家谱',
successCopy: `已直接加入“${genealogyName.value}”,无需等待审核;返回后将刷新并选中这部家谱。`
} : {
headerTitle: '申请加入家谱',
eyebrow: '公开家谱入谱申请',
formTitle: '申请加入',
formCopy: '请填写真实身份和亲属关系,管理员审核后会通过消息告知结果。',
thirdFieldLabel: '申请说明',
thirdFieldPlaceholder: '补充祖居地、长辈姓名等核验信息',
note: '提交后可在“我的申请”中查看审核进度。',
submitLabel: '提交申请',
submittingLabel: '正在提交…',
successEyebrow: '申请已送达',
successTitle: '等待管理员核实亲属关系',
errorEyebrow: '申请未提交',
errorTitle: '暂时无法提交申请',
nextLabel: '查看我的申请',
successCopy: `${genealogyName.value}”的管理员会在核实后给出结果,请留意消息中心。`
})
const resultCopy = computed(() => joinState.value === 'success' const resultCopy = computed(() => joinState.value === 'success'
? `${genealogyName.value}”的管理员会在核实后给出结果,请留意消息中心。` ? sourceContract.value.successCopy
: errorMessage.value || '请检查网络后重新填写;未成功提交的内容不会进入审核列表。') : errorMessage.value || '请检查网络后重新填写;未成功提交的内容不会进入审核列表。')
onLoad((query) => { onLoad((query) => {
genealogyId.value = query.genealogyId || '' genealogyId.value = query.genealogyId || ''
source.value = query.source === 'invite' ? 'invite' : 'search'
if (query.state === 'success') { if (query.state === 'success') {
joinState.value = 'success' joinState.value = 'success'
return return
@@ -85,22 +124,25 @@ onLoad((query) => {
const submitJoin = () => { const submitJoin = () => {
if (isSubmitting.value) return if (isSubmitting.value) return
if (!form.realName.trim() || !form.relation.trim()) { fieldErrors.realName = form.realName.trim() ? '' : '请填写真实姓名'
uni.showToast({ title: '请填写真实姓名和亲属关系', icon: 'none' }) fieldErrors.relation = form.relation.trim() ? '' : '请填写与家谱的关系'
return if (fieldErrors.realName || fieldErrors.relation) return
}
isSubmitting.value = true isSubmitting.value = true
setTimeout(() => { setTimeout(() => {
joinState.value = 'success' joinState.value = form.realName.trim() === '失败' ? 'error' : 'success'
if (joinState.value === 'error') errorMessage.value = source.value === 'invite' ? '邀请码加入暂未完成,请稍后重试。' : '申请暂未提交,请稍后重试。'
isSubmitting.value = false isSubmitting.value = false
}, 280) }, 280)
} }
const clearFieldError = (field) => { fieldErrors[field] = '' }
const retryForm = () => { joinState.value = 'form'; errorMessage.value = '' } const retryForm = () => { joinState.value = 'form'; errorMessage.value = '' }
const toMyApplications = () => uni.redirectTo({ url: '/pages/genealogy/g09-my-applications' }) const toMyApplications = () => uni.redirectTo({ url: '/pages/genealogy/g09-my-applications' })
const toMyGenealogies = () => uni.reLaunch({ url: `/pages/genealogy/g01-my-genealogies?genealogyId=${genealogyId.value}` })
const completeFlow = () => source.value === 'invite' ? toMyGenealogies() : toMyApplications()
</script> </script>
<style scoped lang="scss"> <style scoped lang="scss">
.join-page { position: relative; min-height: 100vh; overflow: hidden; background: $paper; } .join-page { position: relative; min-height: 100vh; overflow-x: hidden; background: $paper; }
.join-page__paper { position: absolute; inset: 0; z-index: 0; width: 100%; height: 100%; opacity: .98; pointer-events: none; } .join-page__paper { position: absolute; inset: 0; z-index: 0; width: 100%; height: 100%; opacity: .98; pointer-events: none; }
.join-page__footer { position: absolute; right: 0; bottom: 0; left: 0; z-index: 1; width: 100%; opacity: .13; pointer-events: none; } .join-page__footer { position: absolute; right: 0; bottom: 0; left: 0; z-index: 1; width: 100%; opacity: .13; pointer-events: none; }
.join-page__header { position: relative; z-index: 3; } .join-page__header { position: relative; z-index: 3; }
@@ -116,6 +158,8 @@ const toMyApplications = () => uni.redirectTo({ url: '/pages/genealogy/g09-my-ap
.join-field input, .join-field textarea { position: absolute; top: 0; right: 20rpx; bottom: 0; left: 170rpx; z-index: 1; height: 92rpx; color: $ink; font-size: 23rpx; line-height: 92rpx; } .join-field input, .join-field textarea { position: absolute; top: 0; right: 20rpx; bottom: 0; left: 170rpx; z-index: 1; height: 92rpx; color: $ink; font-size: 23rpx; line-height: 92rpx; }
.join-field textarea { box-sizing: border-box; padding-top: 26rpx; line-height: 1.5; } .join-field textarea { box-sizing: border-box; padding-top: 26rpx; line-height: 1.5; }
.join-placeholder { color: #a79884; } .join-placeholder { color: #a79884; }
.join-field-error { display: block; margin-top: 3rpx; color: $brand-red; font-size: 20rpx; text-align: right; }
.relation-help { display: block; margin-top: 5rpx; color: #8e7b67; font-size: 19rpx; line-height: 29rpx; }
.join-form__note { display: block; margin-top: 18rpx; color: $ink-muted; font-size: 21rpx; text-align: center; } .join-form__note { display: block; margin-top: 18rpx; color: $ink-muted; font-size: 21rpx; text-align: center; }
.join-action { position: relative; width: 100%; height: 82rpx; margin-top: 20rpx; } .join-action { position: relative; width: 100%; height: 82rpx; margin-top: 20rpx; }
.join-action image { position: absolute; inset: 0; width: 100%; height: 100%; } .join-action image { position: absolute; inset: 0; width: 100%; height: 100%; }
+58 -12
View File
@@ -14,14 +14,15 @@
<view class="application-intro"> <view class="application-intro">
<text>入谱申请进度</text><text>审核结果会同步到消息中心</text> <text>入谱申请进度</text><text>审核结果会同步到消息中心</text>
</view> </view>
<view v-for="item in applications" :key="item.id" class="application-card" @click="openApprovedGenealogy(item)"> <view v-for="item in applications" :key="item.id" class="application-card">
<image class="application-card__skin" src="/static/assets/modules/genealogy/opaque/application-status-card.png" mode="scaleToFill" /> <image class="application-card__skin" src="/static/assets/modules/genealogy/opaque/application-status-card.png" mode="scaleToFill" />
<view class="application-card__body"> <view class="application-card__body">
<text class="application-card__name">{{ item.genealogyName }}</text> <text class="application-card__name">{{ item.genealogyName }}</text>
<text class="application-card__time">{{ item.appliedAt }}</text> <text class="application-card__time">{{ item.appliedAt }}</text>
<text class="application-card__relation">{{ item.relation }}</text> <text class="application-card__relation">{{ item.relation }}</text>
<text class="application-card__status" :class="`application-card__status--${item.status.toLowerCase()}`">{{ statusLabel(item.status) }}</text> <text class="application-card__status" :class="`application-card__status--${item.status.toLowerCase()}`">{{ statusLabel(item.status) }}</text>
<text class="application-card__hint">{{ item.status === 'APPROVED' ? '点按进入家谱' : item.status === 'PENDING' ? '请耐心等待管理员核实' : '可重新检索并提交申请' }}</text> <text class="application-card__hint">{{ statusHint(item.status) }}</text>
<view v-if="actionFor(item)" class="application-card__action" @click="handleApplicationAction(item)">{{ actionFor(item) }}</view>
</view> </view>
</view> </view>
</template> </template>
@@ -29,8 +30,8 @@
<view v-else class="application-state-card"> <view v-else class="application-state-card">
<image src="/static/assets/modules/genealogy/opaque/application-status-card.png" mode="scaleToFill" /> <image src="/static/assets/modules/genealogy/opaque/application-status-card.png" mode="scaleToFill" />
<view class="application-state-card__copy"> <view class="application-state-card__copy">
<text>{{ applicationState === 'empty' ? '还没有入谱申请' : '申请记录暂时无法读取' }}</text> <text>{{ stateTitle }}</text>
<text>{{ applicationState === 'empty' ? '从公开家谱检索开始,找到家谱后可提交加入申请。' : errorMessage || '请检查网络后重新查看。' }}</text> <text>{{ stateCopy }}</text>
</view> </view>
</view> </view>
<view v-if="applicationState !== 'list'" class="application-page__action" @click="applicationState === 'error' ? loadApplications({}) : toSearch()"> <view v-if="applicationState !== 'list'" class="application-page__action" @click="applicationState === 'error' ? loadApplications({}) : toSearch()">
@@ -38,17 +39,32 @@
<text>{{ applicationState === 'error' ? '重新查看' : '查找公开家谱' }}</text> <text>{{ applicationState === 'error' ? '重新查看' : '查找公开家谱' }}</text>
</view> </view>
</view> </view>
<view v-if="withdrawTarget" class="withdraw-layer" @click="cancelWithdraw">
<view class="withdraw-dialog" @click.stop>
<image class="withdraw-dialog__skin" src="/static/assets/modules/genealogy/opaque/application-status-card.png" mode="scaleToFill" />
<view class="withdraw-dialog__content">
<text class="withdraw-dialog__title">撤回加入申请</text>
<text class="withdraw-dialog__copy">确认撤回对{{ withdrawTarget.genealogyName }}的申请撤回后如需加入可重新提交</text>
<view class="withdraw-dialog__actions">
<view class="withdraw-action" @click="cancelWithdraw">暂不撤回</view>
<view class="withdraw-action withdraw-action--danger" @click="confirmWithdraw">确认撤回</view>
</view>
</view>
</view>
</view>
</view> </view>
</template> </template>
<script setup> <script setup>
import { ref } from 'vue' import { computed, ref } from 'vue'
import { onLoad } from '@dcloudio/uni-app' import { onLoad } from '@dcloudio/uni-app'
import PageHeader from '@/components/PageHeader.vue' import PageHeader from '@/components/PageHeader.vue'
const applications = ref([]) const applications = ref([])
const applicationState = ref('loading') const applicationState = ref('loading')
const errorMessage = ref('') const errorMessage = ref('')
const withdrawTarget = ref(null)
const applicationSamples = [ const applicationSamples = [
{ id: 1, genealogyId: 1001, genealogyName: '汤氏家谱', relation: '自述为汤正华堂侄', appliedAt: '今天 10:24', status: 'PENDING' }, { id: 1, genealogyId: 1001, genealogyName: '汤氏家谱', relation: '自述为汤正华堂侄', appliedAt: '今天 10:24', status: 'PENDING' },
{ id: 2, genealogyId: 1002, genealogyName: '汝南汤氏家谱', relation: '祖居河南汝南', appliedAt: '昨天 18:02', status: 'APPROVED' }, { id: 2, genealogyId: 1002, genealogyName: '汝南汤氏家谱', relation: '祖居河南汝南', appliedAt: '昨天 18:02', status: 'APPROVED' },
@@ -58,28 +74,48 @@ const applicationSamples = [
const statusLabel = (status) => ({ const statusLabel = (status) => ({
'PENDING': '审核中', 'PENDING': '审核中',
'APPROVED': '已通过', 'APPROVED': '已通过',
'REJECTED': '未通过' 'REJECTED': '未通过',
'WITHDRAWN': '已撤回'
}[status] || '状态未知') }[status] || '状态未知')
const statusHint = (status) => ({
PENDING: '管理员尚未处理,可在审核前撤回',
APPROVED: '申请已通过,可进入这部家谱',
REJECTED: '请修改关系说明后重新提交',
WITHDRAWN: '申请已撤回,不再进入管理员审核'
}[status] || '')
const actionFor = (item) => ({ PENDING: '撤回申请', APPROVED: '进入家谱', REJECTED: '修改后重新提交' }[item.status] || '')
const stateTitle = computed(() => applicationState.value === 'empty' ? '还没有入谱申请' : applicationState.value === 'loading' ? '正在整理申请记录' : '申请记录暂时无法读取')
const stateCopy = computed(() => applicationState.value === 'empty' ? '从家谱搜索提交的申请会显示在这里;邀请码直接加入不进入本页。' : applicationState.value === 'loading' ? '请稍候,正在同步审核状态。' : errorMessage.value || '请检查网络后重新查看。')
const loadApplications = (query = {}) => { const loadApplications = (query = {}) => {
applicationState.value = 'loading' applicationState.value = 'loading'
errorMessage.value = '' errorMessage.value = ''
if (query.state === 'empty') { applicationState.value = 'empty'; return } if (query.state === 'empty') { applicationState.value = 'empty'; return }
if (query.state === 'error') { applicationState.value = 'error'; return } if (query.state === 'error') { applicationState.value = 'error'; return }
if (query.state === 'loading') { applicationState.value = 'loading'; return }
applications.value = applicationSamples.map((item) => ({ ...item })) applications.value = applicationSamples.map((item) => ({ ...item }))
applicationState.value = 'list' applicationState.value = 'list'
} }
onLoad(loadApplications) onLoad(loadApplications)
const openApprovedGenealogy = (item) => { const handleApplicationAction = (item) => {
if (item.status !== 'APPROVED') return if (item.status === 'APPROVED') return uni.navigateTo({ url: `/pages/genealogy/g05-genealogy-overview?genealogyId=${item.genealogyId}` })
uni.navigateTo({ url: `/pages/genealogy/g05-genealogy-overview?genealogyId=${item.genealogyId}` }) if (item.status === 'REJECTED') return uni.navigateTo({ url: `/pages/genealogy/g08-join-application?source=search&previous=rejected&genealogyId=${item.genealogyId}` })
if (item.status === 'PENDING') withdrawTarget.value = item
}
const cancelWithdraw = () => { withdrawTarget.value = null }
const confirmWithdraw = () => {
const target = applications.value.find((item) => item.id === withdrawTarget.value?.id)
if (target) target.status = 'WITHDRAWN'
cancelWithdraw()
} }
const toSearch = () => uni.navigateTo({ url: '/pages/genealogy/g06-search-genealogies' }) const toSearch = () => uni.navigateTo({ url: '/pages/genealogy/g06-search-genealogies' })
</script> </script>
<style scoped lang="scss"> <style scoped lang="scss">
.application-page { position: relative; min-height: 100vh; overflow: hidden; background: $paper; } .application-page { position: relative; min-height: 100vh; overflow-x: hidden; background: $paper; }
.application-page__paper { position: absolute; inset: 0; z-index: 0; width: 100%; height: 100%; opacity: .98; pointer-events: none; } .application-page__paper { position: absolute; inset: 0; z-index: 0; width: 100%; height: 100%; opacity: .98; pointer-events: none; }
.application-page__footer { position: absolute; right: 0; bottom: 0; left: 0; z-index: 1; width: 100%; opacity: .13; pointer-events: none; } .application-page__footer { position: absolute; right: 0; bottom: 0; left: 0; z-index: 1; width: 100%; opacity: .13; pointer-events: none; }
.application-page__header { position: relative; z-index: 3; } .application-page__header { position: relative; z-index: 3; }
@@ -93,10 +129,11 @@ const toSearch = () => uni.navigateTo({ url: '/pages/genealogy/g06-search-geneal
.application-card__name { color: $ink; font-family: 'STKaiti', 'KaiTi', serif; font-size: 30rpx; font-weight: 700; } .application-card__name { color: $ink; font-family: 'STKaiti', 'KaiTi', serif; font-size: 30rpx; font-weight: 700; }
.application-card__time { position: absolute; top: 3rpx; right: 0; color: #998873; font-size: 20rpx; } .application-card__time { position: absolute; top: 3rpx; right: 0; color: #998873; font-size: 20rpx; }
.application-card__relation { display: block; max-width: 70%; margin-top: 14rpx; color: $ink-muted; font-size: 22rpx; } .application-card__relation { display: block; max-width: 70%; margin-top: 14rpx; color: $ink-muted; font-size: 22rpx; }
.application-card__status { position: absolute; right: 3%; bottom: 11%; color: $brand-red; font-size: 24rpx; font-weight: 700; } .application-card__status { position: absolute; right: 3%; bottom: 38%; color: $brand-red; font-size: 23rpx; font-weight: 700; }
.application-card__status--approved { color: #537368; } .application-card__status--approved { color: #537368; }
.application-card__status--rejected { color: #7e6f62; } .application-card__status--rejected { color: #7e6f62; }
.application-card__hint { position: absolute; bottom: 5%; left: 0; color: #8d7c67; font-size: 20rpx; } .application-card__hint { position: absolute; bottom: 5%; left: 0; max-width: 62%; color: #8d7c67; font-size: 20rpx; }
.application-card__action { position: absolute; right: 2%; bottom: 2%; z-index: 2; display: flex; min-height: 54rpx; align-items: center; color: $brand-red; font-size: 21rpx; font-weight: 700; }
.application-state-card { position: relative; width: 100%; height: calc((100vw - 24px) * .34286); min-height: 228rpx; margin-top: 80rpx; } .application-state-card { position: relative; width: 100%; height: calc((100vw - 24px) * .34286); min-height: 228rpx; margin-top: 80rpx; }
.application-state-card > image { position: absolute; inset: 0; width: 100%; height: 100%; } .application-state-card > image { position: absolute; inset: 0; width: 100%; height: 100%; }
.application-state-card__copy { position: absolute; inset: 22% 12%; display: flex; flex-direction: column; justify-content: center; text-align: center; } .application-state-card__copy { position: absolute; inset: 22% 12%; display: flex; flex-direction: column; justify-content: center; text-align: center; }
@@ -105,4 +142,13 @@ const toSearch = () => uni.navigateTo({ url: '/pages/genealogy/g06-search-geneal
.application-page__action { position: relative; width: 420rpx; height: 82rpx; margin: 34rpx auto 0; } .application-page__action { position: relative; width: 420rpx; height: 82rpx; margin: 34rpx auto 0; }
.application-page__action image { position: absolute; inset: 0; width: 100%; height: 100%; } .application-page__action image { position: absolute; inset: 0; width: 100%; height: 100%; }
.application-page__action text { position: relative; z-index: 1; display: flex; align-items: center; justify-content: center; height: 100%; color: #fff9ed; font-size: 26rpx; font-weight: 700; } .application-page__action text { position: relative; z-index: 1; display: flex; align-items: center; justify-content: center; height: 100%; color: #fff9ed; font-size: 26rpx; font-weight: 700; }
.withdraw-layer { position: fixed; z-index: 30; inset: 0; display: flex; align-items: center; justify-content: center; box-sizing: border-box; padding: 42rpx; background: rgba(34, 20, 12, .58); }
.withdraw-dialog { position: relative; width: 100%; max-width: 660rpx; min-height: 390rpx; }
.withdraw-dialog__skin { position: absolute; inset: 0; width: 100%; height: 100%; }
.withdraw-dialog__content { position: relative; z-index: 1; display: flex; min-height: 390rpx; flex-direction: column; align-items: center; box-sizing: border-box; padding: 74rpx 60rpx 46rpx; text-align: center; }
.withdraw-dialog__title { color: $ink; font-family: 'STKaiti', 'KaiTi', serif; font-size: 36rpx; font-weight: 700; }
.withdraw-dialog__copy { margin-top: 24rpx; color: $ink-muted; font-size: 23rpx; line-height: 38rpx; }
.withdraw-dialog__actions { display: flex; width: 100%; margin-top: auto; border-top: 1rpx solid rgba(181, 138, 75, .42); }
.withdraw-action { display: flex; flex: 1; min-height: 76rpx; align-items: center; justify-content: center; color: $ink-muted; font-size: 23rpx; }
.withdraw-action--danger { border-left: 1rpx solid rgba(181, 138, 75, .42); color: $brand-red; }
</style> </style>
+71 -17
View File
@@ -8,7 +8,8 @@
<view class="review-content" :class="{ <view class="review-content" :class="{
'review-state--list': reviewState === 'list', 'review-state--list': reviewState === 'list',
'review-state--empty': reviewState === 'empty', 'review-state--empty': reviewState === 'empty',
'review-state--error': reviewState === 'error' 'review-state--error': reviewState === 'error',
'review-state--no-permission': reviewState === 'no-permission'
}"> }">
<view class="review-intro"> <view class="review-intro">
<text>核实亲属关系后再决定</text> <text>核实亲属关系后再决定</text>
@@ -40,20 +41,46 @@
<view v-else class="review-state-card"> <view v-else class="review-state-card">
<image src="/static/assets/modules/genealogy/opaque/application-status-card.png" mode="scaleToFill" /> <image src="/static/assets/modules/genealogy/opaque/application-status-card.png" mode="scaleToFill" />
<view class="review-state-card__copy"> <view class="review-state-card__copy">
<text>{{ reviewState === 'empty' ? '暂无待审核申请' : '审核列表暂时无法读取' }}</text> <text>{{ stateTitle }}</text>
<text>{{ reviewState === 'empty' ? '新的入谱申请会在这里出现,并同步发送消息提醒。' : errorMessage || '请检查网络或家谱权限后重试。' }}</text> <text>{{ stateCopy }}</text>
</view> </view>
</view> </view>
<view v-if="reviewState === 'error'" class="review-page__retry" @click="loadApplications({ genealogyId })"> <view v-if="reviewState === 'error'" class="review-page__retry" @click="loadApplications({ genealogyId })">
<image src="/static/assets/foundation/opaque/a01-primary-button.png" mode="scaleToFill" /><text>重新查看</text> <image src="/static/assets/foundation/opaque/a01-primary-button.png" mode="scaleToFill" /><text>重新查看</text>
</view> </view>
</view> </view>
<view v-if="confirmation || helpVisible" class="review-dialog-layer" @click="closeDialog">
<view class="review-dialog" @click.stop>
<image class="review-dialog__skin" src="/static/assets/modules/genealogy/opaque/application-status-card.png" mode="scaleToFill" />
<view class="review-dialog__content">
<template v-if="helpVisible">
<text class="review-dialog__title">审核说明</text>
<text class="review-dialog__copy">请核对申请人的姓名亲属关系和补充说明仅确认与本家谱存在真实关系的申请</text>
<view class="review-dialog__single" @click="closeDialog">我知道了</view>
</template>
<template v-else>
<text class="review-dialog__title">{{ confirmation.approved ? '确认通过申请?' : '确认拒绝申请?' }}</text>
<text class="review-dialog__copy">{{ confirmation.approved ? '通过后,申请人将成为本家谱成员。' : '拒绝后,申请人会收到审核结果,并可修改后重新申请。' }}</text>
<view class="review-dialog__actions">
<view class="review-dialog__action" @click="closeDialog">取消</view>
<view class="review-dialog__action review-dialog__action--primary" @click="applyAudit">{{ confirmation.approved ? '确认通过' : '确认拒绝' }}</view>
</view>
</template>
</view>
</view>
</view>
<view v-if="feedbackVisible" class="review-feedback">
<image src="/static/assets/foundation/opaque/a01-secondary-button.png" mode="scaleToFill" />
<text>{{ feedbackMessage }}</text>
</view>
</view> </view>
</template> </template>
<script setup> <script setup>
import { ref } from 'vue' import { computed, ref } from 'vue'
import { onLoad } from '@dcloudio/uni-app' import { onLoad, onUnload } from '@dcloudio/uni-app'
import PageHeader from '@/components/PageHeader.vue' import PageHeader from '@/components/PageHeader.vue'
import { genealogyContext } from '@/utils/genealogy-context.js' import { genealogyContext } from '@/utils/genealogy-context.js'
@@ -65,6 +92,14 @@ const reviewSamples = [
const genealogyId = ref('') const genealogyId = ref('')
const reviewState = ref('loading') const reviewState = ref('loading')
const errorMessage = ref('') const errorMessage = ref('')
const confirmation = ref(null)
const helpVisible = ref(false)
const feedbackVisible = ref(false)
const feedbackMessage = ref('')
let feedbackTimer = null
onUnload(() => { if (feedbackTimer) clearTimeout(feedbackTimer) })
const stateTitle = computed(() => reviewState.value === 'empty' ? '暂无待审核申请' : reviewState.value === 'no-permission' ? '当前账号无审核权限' : '审核列表暂时无法读取')
const stateCopy = computed(() => reviewState.value === 'empty' ? '新的入谱申请会在这里出现,并同步发送消息提醒。' : reviewState.value === 'no-permission' ? '只有家谱所有者或具备审核权限的管理员可以处理申请。' : errorMessage.value || '请检查网络或家谱权限后重试。')
const loadApplications = (query = {}) => { const loadApplications = (query = {}) => {
reviewState.value = 'loading' reviewState.value = 'loading'
@@ -72,6 +107,7 @@ const loadApplications = (query = {}) => {
genealogyId.value = query.genealogyId?.value || query.genealogyId || genealogyId.value || genealogyContext.getCurrentGenealogyId() genealogyId.value = query.genealogyId?.value || query.genealogyId || genealogyId.value || genealogyContext.getCurrentGenealogyId()
if (query.state === 'empty') { reviewState.value = 'empty'; return } if (query.state === 'empty') { reviewState.value = 'empty'; return }
if (query.state === 'error') { reviewState.value = 'error'; return } if (query.state === 'error') { reviewState.value = 'error'; return }
if (query.state === 'no-permission') { reviewState.value = 'no-permission'; return }
if (!genealogyId.value) { if (!genealogyId.value) {
errorMessage.value = '没有找到当前家谱,请从家谱总览进入。' errorMessage.value = '没有找到当前家谱,请从家谱总览进入。'
reviewState.value = 'error' reviewState.value = 'error'
@@ -84,22 +120,27 @@ const loadApplications = (query = {}) => {
onLoad(loadApplications) onLoad(loadApplications)
const confirmAudit = (item, approved) => { const confirmAudit = (item, approved) => {
uni.showModal({ confirmation.value = { item, approved }
title: approved ? '确认通过申请?' : '确认拒绝申请?',
content: approved ? '通过后,申请人将成为本家谱成员。' : '拒绝后,申请人会收到审核结果。',
confirmText: approved ? '确认通过' : '确认拒绝',
success: ({ confirm }) => {
if (!confirm) return
item.status = approved ? 'APPROVED' : 'REJECTED'
uni.showToast({ title: approved ? '已通过申请' : '已拒绝申请', icon: 'none' })
}
})
} }
const showHelp = () => uni.showModal({ title: '审核说明', content: '请核对申请人的姓名、亲属关系和补充说明,仅确认与本家谱存在真实关系的申请。', showCancel: false }) const closeDialog = () => { confirmation.value = null; helpVisible.value = false }
const showFeedback = (message) => {
feedbackMessage.value = message
feedbackVisible.value = true
if (feedbackTimer) clearTimeout(feedbackTimer)
feedbackTimer = setTimeout(() => { feedbackVisible.value = false; feedbackTimer = null }, 1800)
}
const applyAudit = () => {
const current = confirmation.value
if (!current) return
current.item.status = current.approved ? 'APPROVED' : 'REJECTED'
closeDialog()
showFeedback(current.approved ? '已通过申请' : '已拒绝申请')
}
const showHelp = () => { helpVisible.value = true }
</script> </script>
<style scoped lang="scss"> <style scoped lang="scss">
.review-page { position: relative; min-height: 100vh; overflow: hidden; background: $paper; } .review-page { position: relative; min-height: 100vh; overflow-x: hidden; background: $paper; }
.review-page__paper { position: absolute; inset: 0; z-index: 0; width: 100%; height: 100%; opacity: .98; pointer-events: none; } .review-page__paper { position: absolute; inset: 0; z-index: 0; width: 100%; height: 100%; opacity: .98; pointer-events: none; }
.review-page__footer { position: absolute; right: 0; bottom: 0; left: 0; z-index: 1; width: 100%; opacity: .13; pointer-events: none; } .review-page__footer { position: absolute; right: 0; bottom: 0; left: 0; z-index: 1; width: 100%; opacity: .13; pointer-events: none; }
.review-page__header { position: relative; z-index: 3; } .review-page__header { position: relative; z-index: 3; }
@@ -130,4 +171,17 @@ const showHelp = () => uni.showModal({ title: '审核说明', content: '请核
.review-page__retry { position: relative; width: 420rpx; height: 82rpx; margin: 32rpx auto 0; } .review-page__retry { position: relative; width: 420rpx; height: 82rpx; margin: 32rpx auto 0; }
.review-page__retry image { position: absolute; inset: 0; width: 100%; height: 100%; } .review-page__retry image { position: absolute; inset: 0; width: 100%; height: 100%; }
.review-page__retry text { position: relative; z-index: 1; display: flex; align-items: center; justify-content: center; height: 100%; color: #fff9ed; font-size: 26rpx; font-weight: 700; } .review-page__retry text { position: relative; z-index: 1; display: flex; align-items: center; justify-content: center; height: 100%; color: #fff9ed; font-size: 26rpx; font-weight: 700; }
.review-dialog-layer { position: fixed; z-index: 30; inset: 0; display: flex; align-items: center; justify-content: center; box-sizing: border-box; padding: 42rpx; background: rgba(34, 20, 12, .58); }
.review-dialog { position: relative; width: 100%; max-width: 660rpx; min-height: 410rpx; }
.review-dialog__skin { position: absolute; inset: 0; width: 100%; height: 100%; }
.review-dialog__content { position: relative; z-index: 1; display: flex; min-height: 410rpx; flex-direction: column; align-items: center; box-sizing: border-box; padding: 72rpx 60rpx 42rpx; text-align: center; }
.review-dialog__title { color: $ink; font-family: 'STKaiti', 'KaiTi', serif; font-size: 36rpx; font-weight: 700; }
.review-dialog__copy { margin-top: 24rpx; color: $ink-muted; font-size: 23rpx; line-height: 38rpx; }
.review-dialog__actions { display: flex; width: 100%; margin-top: auto; border-top: 1rpx solid rgba(181, 138, 75, .42); }
.review-dialog__action { display: flex; flex: 1; min-height: 74rpx; align-items: center; justify-content: center; color: $ink-muted; font-size: 23rpx; }
.review-dialog__action--primary { border-left: 1rpx solid rgba(181, 138, 75, .42); color: $brand-red; font-weight: 700; }
.review-dialog__single { display: flex; min-height: 76rpx; align-items: center; margin-top: auto; color: $brand-red; font-size: 24rpx; font-weight: 700; }
.review-feedback { position: fixed; z-index: 40; top: 140rpx; left: 50%; display: flex; min-width: 250rpx; min-height: 76rpx; align-items: center; justify-content: center; padding: 0 36rpx; transform: translateX(-50%); }
.review-feedback image { position: absolute; inset: 0; width: 100%; height: 100%; }
.review-feedback text { position: relative; z-index: 1; color: $ink; font-size: 23rpx; }
</style> </style>
+25 -9
View File
@@ -8,7 +8,8 @@
<view class="settings-panel" :class="{ <view class="settings-panel" :class="{
'settings-state--form': settingsState === 'form', 'settings-state--form': settingsState === 'form',
'settings-state--success': settingsState === 'success', 'settings-state--success': settingsState === 'success',
'settings-state--error': settingsState === 'error' 'settings-state--error': settingsState === 'error',
'settings-state--no-permission': settingsState === 'no-permission'
}"> }">
<image class="settings-panel__skin" src="/static/assets/modules/genealogy/opaque/g03-create-flow-panel.png" mode="scaleToFill" /> <image class="settings-panel__skin" src="/static/assets/modules/genealogy/opaque/g03-create-flow-panel.png" mode="scaleToFill" />
@@ -20,8 +21,9 @@
<view class="settings-field"> <view class="settings-field">
<image src="/static/assets/modules/genealogy/opaque/g06-search-input-wide.png" mode="scaleToFill" /> <image src="/static/assets/modules/genealogy/opaque/g06-search-input-wide.png" mode="scaleToFill" />
<text>家谱名称</text> <text>家谱名称</text>
<input v-model="genealogyDraft.name" maxlength="20" placeholder="请输入家谱名称" placeholder-class="settings-placeholder" /> <input v-model="genealogyDraft.name" maxlength="20" placeholder="请输入家谱名称" placeholder-class="settings-placeholder" @input="nameError = ''" />
</view> </view>
<text v-if="nameError" class="settings-field-error">{{ nameError }}</text>
<view class="visibility-block"> <view class="visibility-block">
<text class="visibility-block__label">公开范围</text> <text class="visibility-block__label">公开范围</text>
@@ -47,25 +49,32 @@
</view> </view>
<view v-else class="settings-result"> <view v-else class="settings-result">
<text class="settings-result__eyebrow">{{ settingsState === 'success' ? '设置已保存' : '设置未保存' }}</text> <text class="settings-result__eyebrow">{{ settingsState === 'success' ? '设置已保存' : settingsState === 'no-permission' ? '权限不足' : '设置未保存' }}</text>
<text class="settings-result__title">{{ settingsState === 'success' ? '新的访问规则已经生效' : '暂时无法打开家谱设置' }}</text> <text class="settings-result__title">{{ settingsState === 'success' ? '新的访问规则已经生效' : settingsState === 'no-permission' ? '当前账号不能修改家谱' : '暂时无法打开家谱设置' }}</text>
<text class="settings-result__copy">{{ settingsState === 'success' ? `${genealogyDraft.name} · ${visibilityLabel}` : '请从家谱总览重新进入,当前修改不会被保留。' }}</text> <text class="settings-result__copy">{{ settingsState === 'success' ? `${genealogyDraft.name} · ${visibilityLabel}` : settingsState === 'no-permission' ? '只有家谱所有者可以修改名称、公开范围和访问说明。' : '请从家谱总览重新进入,当前修改不会被保留。' }}</text>
<view class="settings-action" @click="settingsState = 'form'"> <view class="settings-action" @click="settingsState = 'form'">
<image src="/static/assets/foundation/opaque/a01-primary-button.png" mode="scaleToFill" /> <image src="/static/assets/foundation/opaque/a01-primary-button.png" mode="scaleToFill" />
<text>{{ settingsState === 'success' ? '继续调整' : '重新查看' }}</text> <text>{{ settingsState === 'success' ? '继续调整' : '重新查看' }}</text>
</view> </view>
</view> </view>
</view> </view>
<view v-if="feedbackVisible" class="settings-feedback">
<image src="/static/assets/foundation/opaque/a01-secondary-button.png" mode="scaleToFill" />
<text>设置已保存</text>
</view>
</view> </view>
</template> </template>
<script setup> <script setup>
import { computed, reactive, ref } from 'vue' import { computed, reactive, ref } from 'vue'
import { onLoad } from '@dcloudio/uni-app' import { onLoad, onUnload } from '@dcloudio/uni-app'
import PageHeader from '@/components/PageHeader.vue' import PageHeader from '@/components/PageHeader.vue'
const genealogyId = ref('') const genealogyId = ref('')
const settingsState = ref('loading') const settingsState = ref('loading')
const nameError = ref('')
const feedbackVisible = ref(false)
let feedbackTimer = null
const genealogyDraft = reactive({ const genealogyDraft = reactive({
name: '汤氏家谱', name: '汤氏家谱',
visibility: 'MEMBER_ONLY', visibility: 'MEMBER_ONLY',
@@ -82,20 +91,23 @@ const visibilityHint = computed(() => genealogyDraft.visibility === 'MEMBER_ONLY
onLoad((query) => { onLoad((query) => {
genealogyId.value = query.genealogyId || '' genealogyId.value = query.genealogyId || ''
settingsState.value = query.state === 'success' ? 'success' : query.state === 'error' || !genealogyId.value ? 'error' : 'form' settingsState.value = query.state === 'success' ? 'success' : query.state === 'no-permission' ? 'no-permission' : query.state === 'error' || !genealogyId.value ? 'error' : 'form'
}) })
onUnload(() => { if (feedbackTimer) clearTimeout(feedbackTimer) })
const saveSettings = () => { const saveSettings = () => {
if (!genealogyDraft.name.trim()) { if (!genealogyDraft.name.trim()) {
uni.showToast({ title: '请填写家谱名称', icon: 'none' }) nameError.value = '请填写家谱名称'
return return
} }
settingsState.value = 'success' settingsState.value = 'success'
feedbackVisible.value = true
feedbackTimer = setTimeout(() => { feedbackVisible.value = false; feedbackTimer = null }, 1800)
} }
</script> </script>
<style scoped lang="scss"> <style scoped lang="scss">
.settings-page { position: relative; min-height: 100vh; overflow: hidden; background: $paper; } .settings-page { position: relative; min-height: 100vh; overflow-x: hidden; background: $paper; }
.settings-page__paper { position: absolute; inset: 0; z-index: 0; width: 100%; height: 100%; opacity: .98; pointer-events: none; } .settings-page__paper { position: absolute; inset: 0; z-index: 0; width: 100%; height: 100%; opacity: .98; pointer-events: none; }
.settings-page__footer { position: absolute; right: 0; bottom: 0; left: 0; z-index: 1; width: 100%; opacity: .13; pointer-events: none; } .settings-page__footer { position: absolute; right: 0; bottom: 0; left: 0; z-index: 1; width: 100%; opacity: .13; pointer-events: none; }
.settings-page__header { position: relative; z-index: 3; } .settings-page__header { position: relative; z-index: 3; }
@@ -111,6 +123,7 @@ const saveSettings = () => {
.settings-field input, .settings-field textarea { position: absolute; top: 0; right: 20rpx; bottom: 0; left: 164rpx; z-index: 1; height: 92rpx; color: $ink; font-size: 22rpx; line-height: 92rpx; } .settings-field input, .settings-field textarea { position: absolute; top: 0; right: 20rpx; bottom: 0; left: 164rpx; z-index: 1; height: 92rpx; color: $ink; font-size: 22rpx; line-height: 92rpx; }
.settings-field textarea { box-sizing: border-box; padding-top: 26rpx; line-height: 1.5; } .settings-field textarea { box-sizing: border-box; padding-top: 26rpx; line-height: 1.5; }
.settings-placeholder { color: #a79884; } .settings-placeholder { color: #a79884; }
.settings-field-error { display: block; margin-top: 3rpx; color: $brand-red; font-size: 20rpx; text-align: right; }
.visibility-block { margin-top: 17rpx; } .visibility-block { margin-top: 17rpx; }
.visibility-block__label { display: block; color: $ink; font-size: 22rpx; font-weight: 700; } .visibility-block__label { display: block; color: $ink; font-size: 22rpx; font-weight: 700; }
.visibility-options { display: flex; gap: 14rpx; margin-top: 10rpx; } .visibility-options { display: flex; gap: 14rpx; margin-top: 10rpx; }
@@ -127,5 +140,8 @@ const saveSettings = () => {
.settings-result__eyebrow { text-align: center; } .settings-result__eyebrow { text-align: center; }
.settings-result__copy { margin-top: 22rpx; } .settings-result__copy { margin-top: 22rpx; }
.settings-result .settings-action { width: 420rpx; max-width: 100%; margin: 34rpx auto 0; } .settings-result .settings-action { width: 420rpx; max-width: 100%; margin: 34rpx auto 0; }
.settings-feedback { position: fixed; z-index: 30; top: 138rpx; left: 50%; display: flex; min-width: 240rpx; min-height: 74rpx; align-items: center; justify-content: center; padding: 0 34rpx; transform: translateX(-50%); }
.settings-feedback image { position: absolute; inset: 0; width: 100%; height: 100%; }
.settings-feedback text { position: relative; z-index: 1; color: $ink; font-size: 22rpx; }
@media (min-width: 400px) { .settings-panel { width: calc(100% - 48rpx); } .settings-form { right: 9%; left: 9%; } } @media (min-width: 400px) { .settings-panel { width: calc(100% - 48rpx); } .settings-form { right: 9%; left: 9%; } }
</style> </style>
+29 -10
View File
@@ -9,7 +9,8 @@
'poem-state--list': poemState === 'list', 'poem-state--list': poemState === 'list',
'poem-state--empty': poemState === 'empty', 'poem-state--empty': poemState === 'empty',
'poem-state--edit': poemState === 'edit', 'poem-state--edit': poemState === 'edit',
'poem-state--error': poemState === 'error' 'poem-state--error': poemState === 'error',
'poem-state--no-permission': poemState === 'no-permission'
}"> }">
<image class="poem-panel__skin" src="/static/assets/modules/genealogy/opaque/g03-create-flow-panel.png" mode="scaleToFill" /> <image class="poem-panel__skin" src="/static/assets/modules/genealogy/opaque/g03-create-flow-panel.png" mode="scaleToFill" />
@@ -37,8 +38,9 @@
<view class="poem-field"> <view class="poem-field">
<image src="/static/assets/modules/genealogy/opaque/g06-search-input-wide.png" mode="scaleToFill" /> <image src="/static/assets/modules/genealogy/opaque/g06-search-input-wide.png" mode="scaleToFill" />
<text>字辈内容</text> <text>字辈内容</text>
<textarea v-model="poemDraft" maxlength="24" placeholder="例如:启宗敦本继世传芳" placeholder-class="poem-placeholder" /> <textarea v-model="poemDraft" maxlength="24" placeholder="例如:启宗敦本继世传芳" placeholder-class="poem-placeholder" @input="poemError = ''" />
</view> </view>
<text v-if="poemError" class="poem-field-error">{{ poemError }}</text>
<view class="poem-policy"> <view class="poem-policy">
<text>缺失旧世代时</text> <text>缺失旧世代时</text>
<view class="poem-policy__option" @click="stopMissingOldGeneration = !stopMissingOldGeneration"> <view class="poem-policy__option" @click="stopMissingOldGeneration = !stopMissingOldGeneration">
@@ -58,24 +60,31 @@
</view> </view>
<view v-else class="poem-state-card"> <view v-else class="poem-state-card">
<text class="poem-list__eyebrow">{{ poemState === 'empty' ? '尚未建立字辈' : '字辈暂不可用' }}</text> <text class="poem-list__eyebrow">{{ poemState === 'empty' ? '尚未建立字辈' : poemState === 'no-permission' ? '权限不足' : '字辈暂不可用' }}</text>
<text class="poem-list__title">{{ poemState === 'empty' ? '从第一段传承字序开始' : '暂时无法读取字辈诗' }}</text> <text class="poem-list__title">{{ poemState === 'empty' ? '从第一段传承字序开始' : poemState === 'no-permission' ? '当前账号不能维护字辈' : '暂时无法读取字辈诗' }}</text>
<text class="poem-list__copy">{{ poemState === 'empty' ? '管理员可以一次录入连续字辈,系统会按世代拆分展示。' : '请从家谱总览重新进入,或稍后再试。' }}</text> <text class="poem-list__copy">{{ poemState === 'empty' ? '管理员可以一次录入连续字辈,系统会按世代拆分展示。' : poemState === 'no-permission' ? '普通成员可以查看字辈,但只有具备管理权限的成员可以维护。' : '请从家谱总览重新进入,或稍后再试。' }}</text>
<view class="poem-action" @click="poemState === 'empty' ? openEditor() : poemState = 'list'"> <view class="poem-action" @click="poemState === 'empty' ? openEditor() : poemState = 'list'">
<image src="/static/assets/foundation/opaque/a01-primary-button.png" mode="scaleToFill" /><text>{{ poemState === 'empty' ? '开始录入' : '重新查看' }}</text> <image src="/static/assets/foundation/opaque/a01-primary-button.png" mode="scaleToFill" /><text>{{ poemState === 'empty' ? '开始录入' : '重新查看' }}</text>
</view> </view>
</view> </view>
</view> </view>
<view v-if="feedbackVisible" class="poem-feedback">
<image src="/static/assets/foundation/opaque/a01-secondary-button.png" mode="scaleToFill" />
<text>字辈预览已更新</text>
</view>
</view> </view>
</template> </template>
<script setup> <script setup>
import { computed, ref } from 'vue' import { computed, ref } from 'vue'
import { onLoad } from '@dcloudio/uni-app' import { onLoad, onUnload } from '@dcloudio/uni-app'
import PageHeader from '@/components/PageHeader.vue' import PageHeader from '@/components/PageHeader.vue'
const genealogyId = ref('') const genealogyId = ref('')
const poemState = ref('loading') const poemState = ref('loading')
const poemError = ref('')
const feedbackVisible = ref(false)
let feedbackTimer = null
const poemDraft = ref('启宗敦本继世传芳') const poemDraft = ref('启宗敦本继世传芳')
const stopMissingOldGeneration = ref(true) const stopMissingOldGeneration = ref(true)
const poemRows = ref([ const poemRows = ref([
@@ -88,24 +97,30 @@ const previewCharacters = computed(() => poemDraft.value.trim().split('').join('
onLoad((query) => { onLoad((query) => {
genealogyId.value = query.genealogyId || '' genealogyId.value = query.genealogyId || ''
poemState.value = query.state === 'empty' ? 'empty' : query.state === 'edit' ? 'edit' : query.state === 'error' || !genealogyId.value ? 'error' : 'list' poemState.value = query.state === 'empty' ? 'empty' : query.state === 'edit' ? 'edit' : query.state === 'no-permission' ? 'no-permission' : query.state === 'error' || !genealogyId.value ? 'error' : 'list'
}) })
onUnload(() => { if (feedbackTimer) clearTimeout(feedbackTimer) })
const openEditor = () => { poemState.value = 'edit' } const openEditor = () => { poemState.value = 'edit' }
const savePoems = () => { const savePoems = () => {
const characters = poemDraft.value.trim().split('').filter(Boolean) const characters = poemDraft.value.trim().split('').filter(Boolean)
if (!characters.length) { if (!characters.length) {
uni.showToast({ title: '请录入字辈内容', icon: 'none' }) poemError.value = '请录入字辈内容'
return
}
if (poemDraft.value.trim() === '失败') {
poemError.value = '字辈保存失败,请稍后重试'
return return
} }
poemRows.value = characters.slice(0, 4).map((character, index) => ({ generationNo: 12 + index, character, current: index === 2 })) poemRows.value = characters.slice(0, 4).map((character, index) => ({ generationNo: 12 + index, character, current: index === 2 }))
poemState.value = 'list' poemState.value = 'list'
uni.showToast({ title: '字辈预览已更新', icon: 'none' }) feedbackVisible.value = true
feedbackTimer = setTimeout(() => { feedbackVisible.value = false; feedbackTimer = null }, 1800)
} }
</script> </script>
<style scoped lang="scss"> <style scoped lang="scss">
.poem-page { position: relative; min-height: 100vh; overflow: hidden; background: $paper; } .poem-page { position: relative; min-height: 100vh; overflow-x: hidden; background: $paper; }
.poem-page__paper { position: absolute; inset: 0; z-index: 0; width: 100%; height: 100%; opacity: .98; pointer-events: none; } .poem-page__paper { position: absolute; inset: 0; z-index: 0; width: 100%; height: 100%; opacity: .98; pointer-events: none; }
.poem-page__footer { position: absolute; right: 0; bottom: 0; left: 0; z-index: 1; width: 100%; opacity: .13; pointer-events: none; } .poem-page__footer { position: absolute; right: 0; bottom: 0; left: 0; z-index: 1; width: 100%; opacity: .13; pointer-events: none; }
.poem-page__header { position: relative; z-index: 3; } .poem-page__header { position: relative; z-index: 3; }
@@ -130,6 +145,7 @@ const savePoems = () => {
.poem-field > text { position: absolute; top: 47rpx; left: 24rpx; z-index: 1; color: $ink; font-size: 22rpx; font-weight: 700; } .poem-field > text { position: absolute; top: 47rpx; left: 24rpx; z-index: 1; color: $ink; font-size: 22rpx; font-weight: 700; }
.poem-field textarea { position: absolute; top: 0; right: 20rpx; bottom: 0; left: 164rpx; z-index: 1; box-sizing: border-box; height: 118rpx; padding-top: 34rpx; color: $ink; font-size: 22rpx; line-height: 1.5; } .poem-field textarea { position: absolute; top: 0; right: 20rpx; bottom: 0; left: 164rpx; z-index: 1; box-sizing: border-box; height: 118rpx; padding-top: 34rpx; color: $ink; font-size: 22rpx; line-height: 1.5; }
.poem-placeholder { color: #a79884; } .poem-placeholder { color: #a79884; }
.poem-field-error { display: block; margin-top: 4rpx; color: $brand-red; font-size: 20rpx; text-align: right; }
.poem-policy { display: flex; align-items: center; justify-content: space-between; margin-top: 22rpx; } .poem-policy { display: flex; align-items: center; justify-content: space-between; margin-top: 22rpx; }
.poem-policy > text { color: $ink; font-size: 22rpx; font-weight: 700; } .poem-policy > text { color: $ink; font-size: 22rpx; font-weight: 700; }
.poem-policy__option { position: relative; width: 248rpx; height: 62rpx; } .poem-policy__option { position: relative; width: 248rpx; height: 62rpx; }
@@ -144,5 +160,8 @@ const savePoems = () => {
.poem-state-card .poem-list__eyebrow { text-align: center; } .poem-state-card .poem-list__eyebrow { text-align: center; }
.poem-state-card .poem-list__copy { margin-top: 22rpx; } .poem-state-card .poem-list__copy { margin-top: 22rpx; }
.poem-state-card .poem-action { width: 420rpx; max-width: 100%; margin: 34rpx auto 0; } .poem-state-card .poem-action { width: 420rpx; max-width: 100%; margin: 34rpx auto 0; }
.poem-feedback { position: fixed; z-index: 30; top: 138rpx; left: 50%; display: flex; min-width: 260rpx; min-height: 74rpx; align-items: center; justify-content: center; padding: 0 34rpx; transform: translateX(-50%); }
.poem-feedback image { position: absolute; inset: 0; width: 100%; height: 100%; }
.poem-feedback text { position: relative; z-index: 1; color: $ink; font-size: 22rpx; }
@media (min-width: 400px) { .poem-panel { width: calc(100% - 48rpx); } .poem-list, .poem-editor, .poem-state-card { right: 9%; left: 9%; } } @media (min-width: 400px) { .poem-panel { width: calc(100% - 48rpx); } .poem-list, .poem-editor, .poem-state-card { right: 9%; left: 9%; } }
</style> </style>
-3
View File
@@ -1,3 +0,0 @@
<!-- 页面编号T-02用途世系图小屏无成员与加载失败状态 -->
<template><ModulePage page-id="t02" /></template>
<script setup>import ModulePage from '@/components/ModulePage.vue'</script>
+65
View File
@@ -0,0 +1,65 @@
param(
[string]$ManifestPath = 'docs/design/assets/a01-vnext/source/a01-psd-manifest.json',
[string]$OutputPath = 'docs/design/assets/a01-vnext/source/A01-layered-source-v1.psd'
)
$ErrorActionPreference = 'Stop'
$root = Split-Path -Parent $PSScriptRoot
$rootFullPath = [IO.Path]::GetFullPath($root).TrimEnd([IO.Path]::DirectorySeparatorChar) + [IO.Path]::DirectorySeparatorChar
function Resolve-WorkspacePath {
param(
[string]$Path,
[bool]$MustExist
)
$candidate = if ([IO.Path]::IsPathRooted($Path)) { $Path } else { Join-Path $root $Path }
$fullPath = [IO.Path]::GetFullPath($candidate)
if (-not $fullPath.StartsWith($rootFullPath, [StringComparison]::OrdinalIgnoreCase)) {
throw "Path must remain inside the workspace: $Path"
}
if ($MustExist -and -not (Test-Path -LiteralPath $fullPath -PathType Leaf)) {
throw "Required file does not exist: $fullPath"
}
return $fullPath
}
function ConvertTo-JavaScriptString {
param([string]$Value)
$escaped = $Value.Replace('\', '\\').Replace("'", "\'").Replace("`r", '\r').Replace("`n", '\n')
return "'$escaped'"
}
$resolvedManifest = Resolve-WorkspacePath -Path $ManifestPath -MustExist $true
$resolvedOutput = Resolve-WorkspacePath -Path $OutputPath -MustExist $false
$outputName = [IO.Path]::GetFileName($resolvedOutput)
if ($outputName -notmatch '^A01-layered-source-v[0-9]+\.psd$') {
throw "PSD output must use a versioned A01 source name: $outputName"
}
if (Test-Path -LiteralPath $resolvedOutput) {
throw "Refusing to overwrite an existing PSD: $resolvedOutput"
}
if (-not (Get-Process -Name Photoshop -ErrorAction SilentlyContinue)) {
throw 'Adobe Photoshop must already be running.'
}
$outputDirectory = Split-Path -Parent $resolvedOutput
if (-not (Test-Path -LiteralPath $outputDirectory -PathType Container)) {
New-Item -ItemType Directory -Path $outputDirectory | Out-Null
}
$jsxPath = Resolve-WorkspacePath -Path 'scripts/photoshop/a01-build-layered-psd.jsx' -MustExist $true
$app = New-Object -ComObject 'Photoshop.Application.150'
$bootstrap = @"
var JIAPU_A01_ROOT = $(ConvertTo-JavaScriptString $rootFullPath.TrimEnd([IO.Path]::DirectorySeparatorChar));
var JIAPU_A01_MANIFEST = $(ConvertTo-JavaScriptString $resolvedManifest);
var JIAPU_A01_PSD = $(ConvertTo-JavaScriptString $resolvedOutput);
$.evalFile(new File($(ConvertTo-JavaScriptString $jsxPath)));
"@
$app.DoJavaScript($bootstrap, @(), 1)
if (-not (Test-Path -LiteralPath $resolvedOutput -PathType Leaf)) {
throw "Photoshop did not create the expected PSD: $resolvedOutput"
}
Write-Output "A01-LAYERED-PSD BUILD PASS: $resolvedOutput"
+77 -22
View File
@@ -6,7 +6,7 @@ const renderSettleMilliseconds = 900
const chromeDebuggingPort = process.env.CHROME_DEBUGGING_PORT || '9222' const chromeDebuggingPort = process.env.CHROME_DEBUGGING_PORT || '9222'
if (!url || !selector || !output) { if (!url || !selector || !output) {
throw new Error('Usage: node scripts/capture-chrome-page.js <url> <selector> <output> [width] [height] [sms-tab|g06-results|g06-empty|fresh-navigation]') throw new Error('Usage: node scripts/capture-chrome-page.js <url> <selector> <output> [width] [height] [password-tab|sms-tab|g06-results|g06-empty|g06-invite|fresh-navigation]')
} }
const sleep = (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds)) const sleep = (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds))
@@ -54,6 +54,36 @@ const waitForLoadedImages = async (send) => {
} }
} }
const prepareA01LoginState = async (send, action) => {
if (action !== 'password-tab' && action !== 'sms-tab') return
const tabIndex = action === 'password-tab' ? 0 : 1
for (let attempt = 0; attempt < 30; attempt += 1) {
const prepared = await send('Runtime.evaluate', {
expression: `(() => {
const tabs = document.querySelectorAll('.login-tab')
if (tabs.length < 2) return false
tabs[${tabIndex}].click()
return true
})()`,
returnByValue: true
})
if (prepared.result?.value) break
if (attempt === 29) throw new Error(`A01 login tabs did not render for ${action}`)
await sleep(100)
}
for (let attempt = 0; attempt < 30; attempt += 1) {
const active = await send('Runtime.evaluate', {
expression: `document.querySelectorAll('.login-tab')[${tabIndex}]?.classList.contains('active') === true`,
returnByValue: true
})
if (active.result?.value) return
if (attempt === 29) throw new Error(`A01 did not enter the requested ${action} state`)
await sleep(100)
}
}
const waitForRequestedUrl = async (send, url) => { const waitForRequestedUrl = async (send, url) => {
for (let attempt = 0; attempt < 30; attempt += 1) { for (let attempt = 0; attempt < 30; attempt += 1) {
const result = await send('Runtime.evaluate', { const result = await send('Runtime.evaluate', {
@@ -79,24 +109,53 @@ const waitForFreshDocument = async (send, documentTimeOrigin) => {
} }
const prepareG06State = async (send, action) => { const prepareG06State = async (send, action) => {
if (action !== 'g06-results' && action !== 'g06-empty') return if (action !== 'g06-results' && action !== 'g06-empty' && action !== 'g06-invite') return
const keyword = action === 'g06-results' ? '汤' : '不存在的家谱' if (action === 'g06-invite') {
const prepared = await send('Runtime.evaluate', { const switched = await send('Runtime.evaluate', {
expression: `(() => { expression: `(() => {
const input = document.querySelector('.search-input input') const tab = document.querySelectorAll('.mode-tab')[1]
const button = document.querySelector('.search-action') if (!tab) return false
if (!input || !button) return false tab.click()
input.value = ${JSON.stringify(keyword)} return true
input.dispatchEvent(new Event('input', { bubbles: true })) })()`,
button.click() returnByValue: true
return true })
})()`, if (!switched.result?.value) throw new Error('Could not switch G06 to invite mode')
returnByValue: true await sleep(100)
}) const prepared = await send('Runtime.evaluate', {
if (!prepared.result?.value) throw new Error(`Could not prepare ${action}`) expression: `(() => {
const input = document.querySelector('.invite-input input')
const button = document.querySelector('.invite-controls .search-action')
if (!input || !button) return false
input.value = 'JP2026'
input.dispatchEvent(new Event('input', { bubbles: true }))
return true
})()`,
returnByValue: true
})
if (!prepared.result?.value) throw new Error('Could not prepare G06 invite code')
await sleep(100)
await send('Runtime.evaluate', { expression: "document.querySelector('.invite-controls .search-action').click()" })
} else {
const stateSelector = action === 'g06-results' ? '.search-results .genealogy-card' : '.search-empty' const keyword = action === 'g06-results' ? '汤' : '不存在的家谱'
const prepared = await send('Runtime.evaluate', {
expression: `(() => {
const input = document.querySelector('.search-input input')
if (!input) return false
input.value = ${JSON.stringify(keyword)}
input.dispatchEvent(new Event('input', { bubbles: true }))
return true
})()`,
returnByValue: true
})
if (!prepared.result?.value) throw new Error(`Could not prepare ${action}`)
await sleep(100)
await send('Runtime.evaluate', { expression: "document.querySelector('.search-action').click()" })
}
const stateSelector = action === 'g06-results' ? '.search-results .genealogy-card' : action === 'g06-empty' ? '.search-empty' : '.invite-result .genealogy-card'
for (let attempt = 0; attempt < 30; attempt += 1) { for (let attempt = 0; attempt < 30; attempt += 1) {
const result = await send('Runtime.evaluate', { const result = await send('Runtime.evaluate', {
expression: `Boolean(document.querySelector(${JSON.stringify(stateSelector)}))`, expression: `Boolean(document.querySelector(${JSON.stringify(stateSelector)}))`,
@@ -146,11 +205,7 @@ const capture = async () => {
await sleep(100) await sleep(100)
} }
if (action === 'sms-tab') { await prepareA01LoginState(send, action)
await send('Runtime.evaluate', {
expression: "document.querySelectorAll('.login-tab')[1]?.click()"
})
}
await prepareG06State(send, action) await prepareG06State(send, action)
await waitForLoadedImages(send) await waitForLoadedImages(send)
@@ -0,0 +1,43 @@
param(
[Parameter(Mandatory = $true)][string]$Output,
[Parameter(Mandatory = $true)][string[]]$InputPaths
)
$ErrorActionPreference = 'Stop'
Add-Type -AssemblyName System.Drawing
if ($InputPaths.Count -lt 2) { throw 'Contact sheet requires at least two images.' }
$images = @()
try {
foreach ($inputPath in $InputPaths) {
$resolved = (Resolve-Path -LiteralPath $inputPath).Path
$images += [System.Drawing.Bitmap]::FromFile($resolved)
}
$width = ($images | Measure-Object -Property Width -Sum).Sum
$height = ($images | Measure-Object -Property Height -Maximum).Maximum
$canvas = [System.Drawing.Bitmap]::new($width, $height, [System.Drawing.Imaging.PixelFormat]::Format32bppArgb)
try {
$graphics = [System.Drawing.Graphics]::FromImage($canvas)
try {
$graphics.Clear([System.Drawing.Color]::FromArgb(247, 240, 228))
$offsetX = 0
foreach ($image in $images) {
$graphics.DrawImageUnscaled($image, $offsetX, 0)
$offsetX += $image.Width
}
} finally {
$graphics.Dispose()
}
$outputPath = [System.IO.Path]::GetFullPath($Output)
[System.IO.Directory]::CreateDirectory([System.IO.Path]::GetDirectoryName($outputPath)) | Out-Null
$canvas.Save($outputPath, [System.Drawing.Imaging.ImageFormat]::Png)
Write-Output "CONTACT-SHEET $outputPath"
} finally {
$canvas.Dispose()
}
} finally {
foreach ($image in $images) { $image.Dispose() }
}
+45
View File
@@ -0,0 +1,45 @@
param(
[string]$ManifestPath = 'docs/design/assets/a01-vnext/source/a01-psd-manifest.json',
[string]$PsdPath = 'docs/design/assets/a01-vnext/source/A01-layered-source-v1.psd'
)
$ErrorActionPreference = 'Stop'
$root = Split-Path -Parent $PSScriptRoot
$rootFullPath = [IO.Path]::GetFullPath($root).TrimEnd([IO.Path]::DirectorySeparatorChar) + [IO.Path]::DirectorySeparatorChar
function Resolve-WorkspaceFile {
param([string]$Path)
$candidate = if ([IO.Path]::IsPathRooted($Path)) { $Path } else { Join-Path $root $Path }
$fullPath = [IO.Path]::GetFullPath($candidate)
if (-not $fullPath.StartsWith($rootFullPath, [StringComparison]::OrdinalIgnoreCase)) {
throw "Path must remain inside the workspace: $Path"
}
if (-not (Test-Path -LiteralPath $fullPath -PathType Leaf)) {
throw "Required file does not exist: $fullPath"
}
return $fullPath
}
function ConvertTo-JavaScriptString {
param([string]$Value)
$escaped = $Value.Replace('\', '\\').Replace("'", "\'").Replace("`r", '\r').Replace("`n", '\n')
return "'$escaped'"
}
$resolvedManifest = Resolve-WorkspaceFile -Path $ManifestPath
$resolvedPsd = Resolve-WorkspaceFile -Path $PsdPath
$jsxPath = Resolve-WorkspaceFile -Path 'scripts/photoshop/a01-export-layered-assets.jsx'
if (-not (Get-Process -Name Photoshop -ErrorAction SilentlyContinue)) {
throw 'Adobe Photoshop must already be running.'
}
$app = New-Object -ComObject 'Photoshop.Application.150'
$bootstrap = @"
var JIAPU_A01_ROOT = $(ConvertTo-JavaScriptString $rootFullPath.TrimEnd([IO.Path]::DirectorySeparatorChar));
var JIAPU_A01_MANIFEST = $(ConvertTo-JavaScriptString $resolvedManifest);
var JIAPU_A01_PSD = $(ConvertTo-JavaScriptString $resolvedPsd);
$.evalFile(new File($(ConvertTo-JavaScriptString $jsxPath)));
"@
$app.DoJavaScript($bootstrap, @(), 1)
Write-Output 'A01-LAYERED-ASSETS EXPORT PASS'
@@ -0,0 +1,63 @@
#target photoshop
app.displayDialogs = DialogModes.NO;
var createdDocument = null;
var previousRulerUnits = app.preferences.rulerUnits;
function readJson(filePath) {
var file = File(filePath);
if (!file.exists) {
throw new Error('Manifest does not exist: ' + filePath);
}
file.encoding = 'UTF8';
if (!file.open('r')) {
throw new Error('Unable to open manifest: ' + filePath);
}
var content = file.read();
file.close();
return eval('(' + content + ')');
}
try {
app.preferences.rulerUnits = Units.PIXELS;
var manifest = readJson(JIAPU_A01_MANIFEST);
var psdFile = File(JIAPU_A01_PSD);
if (psdFile.exists) {
throw new Error('Refusing to overwrite existing PSD: ' + psdFile.fsName);
}
createdDocument = app.documents.add(
manifest.canvas.width,
manifest.canvas.height,
manifest.canvas.resolution,
'JIAPU_A01_SCRIPT_SOURCE',
NewDocumentMode.RGB,
DocumentFill.TRANSPARENT
);
for (var groupIndex = manifest.groups.length - 1; groupIndex >= 0; groupIndex--) {
var groupSpec = manifest.groups[groupIndex];
var group = createdDocument.layerSets.add();
group.name = groupSpec.name;
group.visible = groupSpec.visible;
}
if (createdDocument.artLayers.length > 0) {
createdDocument.artLayers[0].remove();
}
var saveOptions = new PhotoshopSaveOptions();
saveOptions.layers = true;
createdDocument.saveAs(psdFile, saveOptions, true, Extension.LOWERCASE);
createdDocument.close(SaveOptions.DONOTSAVECHANGES);
createdDocument = null;
} catch (error) {
if (createdDocument !== null) {
createdDocument.close(SaveOptions.DONOTSAVECHANGES);
}
throw error;
} finally {
app.preferences.rulerUnits = previousRulerUnits;
}
@@ -0,0 +1,178 @@
#target photoshop
app.displayDialogs = DialogModes.NO;
app.preferences.rulerUnits = Units.PIXELS;
var ROOT = new Folder(JIAPU_A01_ROOT).fsName;
var OUTPUT = new Folder(JIAPU_A01_OUTPUT_DIR);
var PREVIEW_VERSION = typeof JIAPU_A01_PREVIEW_VERSION === 'undefined' ? 'v2' : JIAPU_A01_PREVIEW_VERSION;
if (!OUTPUT.exists) {
OUTPUT.create();
}
function assetPath(relativePath) {
return ROOT + '/' + relativePath;
}
function color(hex) {
var value = hex.replace('#', '');
var result = new SolidColor();
result.rgb.red = parseInt(value.substring(0, 2), 16);
result.rgb.green = parseInt(value.substring(2, 4), 16);
result.rgb.blue = parseInt(value.substring(4, 6), 16);
return result;
}
function placeImage(document, sourcePath, layerName, x, y, width, height, group) {
var sourceDocument = app.open(File(sourcePath));
var layer = sourceDocument.activeLayer.duplicate(document, ElementPlacement.PLACEATBEGINNING);
sourceDocument.close(SaveOptions.DONOTSAVECHANGES);
app.activeDocument = document;
layer.name = layerName;
var bounds = layer.bounds;
var currentWidth = bounds[2].as('px') - bounds[0].as('px');
var currentHeight = bounds[3].as('px') - bounds[1].as('px');
layer.resize(width / currentWidth * 100, height / currentHeight * 100, AnchorPosition.TOPLEFT);
bounds = layer.bounds;
layer.translate(x - bounds[0].as('px'), y - bounds[1].as('px'));
if (group) {
layer.move(group, ElementPlacement.PLACEATBEGINNING);
}
return layer;
}
function addText(document, group, layerName, contents, x, y, size, hex, alignment) {
app.activeDocument = document;
var layer = document.artLayers.add();
layer.kind = LayerKind.TEXT;
layer.name = layerName;
layer.textItem.contents = contents;
layer.textItem.font = 'KaiTi';
layer.textItem.size = UnitValue(size, 'px');
layer.textItem.color = color(hex);
layer.textItem.position = [UnitValue(x, 'px'), UnitValue(y, 'px')];
layer.textItem.justification = alignment === 'left' ? Justification.LEFT : Justification.CENTER;
layer.textItem.antiAliasMethod = AntiAlias.SMOOTH;
layer.move(group, ElementPlacement.PLACEATBEGINNING);
return layer;
}
function addRect(document, group, layerName, x, y, width, height, hex) {
app.activeDocument = document;
var layer = document.artLayers.add();
layer.name = layerName;
document.selection.select([
[x, y],
[x + width, y],
[x + width, y + height],
[x, y + height]
]);
document.selection.fill(color(hex));
document.selection.deselect();
layer.move(group, ElementPlacement.PLACEATBEGINNING);
return layer;
}
function savePsd(document, filePath) {
var options = new PhotoshopSaveOptions();
options.layers = true;
document.saveAs(File(filePath), options, true, Extension.LOWERCASE);
}
function savePng(document, filePath) {
var options = new PNGSaveOptions();
options.interlaced = false;
document.saveAs(File(filePath), options, true, Extension.LOWERCASE);
}
function compose(stateName) {
var document = app.documents.add(1236, 2745, 72, 'A01-' + stateName, NewDocumentMode.RGB, DocumentFill.WHITE);
var common = document.layerSets.add();
common.name = '00-common';
var state = document.layerSets.add();
state.name = stateName === 'password' ? '10-password' : '10-sms';
addRect(document, common, 'page-background', 0, 0, 1236, 2745, '#f4eadc');
placeImage(document, assetPath('docs/design/assets/a01-vnext/candidates/a01-header-background-candidate-v3.png'), 'ancestral-header', 0, 0, 1236, 510, common);
// 设计预览使用完整母图,避免拆片尚未定稿时产生重叠接缝;运行时切片另行验收。
placeImage(document, assetPath('docs/design/assets/a01-vnext/candidates/a01-scroll-master-transparent-candidate-v4-1236x2745.png'), 'scroll-master', 29, 430, 1178, 2315, common);
placeImage(document, assetPath('static/assets/foundation/transparent/brand-seal.png'), 'brand-seal', 510, 105, 216, 259, common);
placeImage(document, assetPath('docs/design/assets/a01-vnext/candidates/a01-title-ornament-candidate-v2.png'), 'title-ornament', 430, 700, 376, 130, common);
addText(document, common, 'page-title', '\u767b\u5f55\u5bb6\u8c31', 618, 960, 126, '#721417', 'center');
placeImage(document, assetPath('docs/design/assets/a01-vnext/candidates/a01-divider-ornament-candidate-v2.png'), 'title-divider', 348, 995, 540, 90, common);
addText(document, common, 'password-tab', '\u5bc6\u7801\u767b\u5f55', 390, 1165, 70, stateName === 'password' ? '#b10f18' : '#aa8240', 'center');
addText(document, common, 'sms-tab', '\u9a8c\u8bc1\u7801\u767b\u5f55', 846, 1165, 70, stateName === 'sms' ? '#b10f18' : '#aa8240', 'center');
addRect(document, common, 'tab-rule', 180, 1210, 876, 3, '#d7b37a');
addRect(document, common, 'active-tab-rule', stateName === 'password' ? 300 : 756, 1200, 180, 12, '#c3151d');
placeImage(document, assetPath('docs/design/assets/a01-vnext/candidates/a01-icon-phone-candidate-v1.png'), 'phone-icon', 190, 1270, 100, 100, state);
addText(document, state, 'phone-placeholder', '\u624b\u673a\u53f7', 330, 1355, 60, '#8b8580', 'left');
addRect(document, state, 'phone-line', 180, 1440, 876, 3, '#d7b37a');
if (stateName === 'password') {
placeImage(document, assetPath('static/assets/modules/auth/transparent/a01-icon-lock-v1.png'), 'lock-icon', 196, 1518, 82, 98, state);
addText(document, state, 'password-placeholder', '\u5bc6\u7801', 330, 1590, 60, '#8b8580', 'left');
placeImage(document, assetPath('docs/design/assets/a01-vnext/candidates/a01-icon-eye-closed-pupil-candidate-v1.png'), 'eye-closed-pupil', 952, 1530, 84, 70, state);
addRect(document, state, 'password-line', 180, 1665, 876, 3, '#d7b37a');
addText(document, state, 'forgot-password', '\u5fd8\u8bb0\u5bc6\u7801', 1020, 1745, 48, '#b10f18', 'center');
} else {
placeImage(document, assetPath('docs/design/assets/a01-vnext/candidates/a01-icon-sms-three-dots-candidate-v1.png'), 'sms-three-dots', 190, 1510, 100, 100, state);
addText(document, state, 'code-placeholder', '\u9a8c\u8bc1\u7801', 330, 1590, 60, '#8b8580', 'left');
addText(document, state, 'get-code', '\u83b7\u53d6\u9a8c\u8bc1\u7801', 930, 1590, 48, '#b10f18', 'center');
addRect(document, state, 'code-line', 180, 1665, 876, 3, '#d7b37a');
}
placeImage(document, assetPath('docs/design/assets/a01-vnext/candidates/a01-primary-button-master-candidate-v2.png'), 'primary-button-skin', 180, 1790, 876, 195, common);
addText(document, common, 'login-label', '\u767b\u5f55', 618, 1925, 82, '#ffffff', 'center');
addRect(document, common, 'other-left-line', 190, 2072, 260, 3, '#d7b37a');
addRect(document, common, 'other-right-line', 786, 2072, 260, 3, '#d7b37a');
addText(document, common, 'other-methods', '\u5176\u4ed6\u767b\u5f55\u65b9\u5f0f', 618, 2100, 50, '#aa8240', 'center');
placeImage(document, assetPath('docs/design/assets/a01-vnext/candidates/a01-secondary-button-master-candidate-v2.png'), 'secondary-button-skin', 180, 2160, 876, 180, common);
placeImage(document, assetPath('static/assets/foundation/transparent/auth-wechat.png'), 'wechat-icon', 365, 2190, 105, 105, common);
addText(document, common, 'wechat-label', '\u5fae\u4fe1\u767b\u5f55', 690, 2290, 66, '#30241d', 'center');
addText(document, common, 'register-copy', '\u8fd8\u6ca1\u6709\u8d26\u53f7\uff1f \u6ce8\u518c\u8d26\u53f7', 618, 2425, 46, '#4c3b30', 'center');
placeImage(document, assetPath('static/assets/modules/auth/transparent/a02-agreement-unchecked.png'), 'agreement-unchecked', 180, 2460, 66, 66, common);
addText(document, common, 'agreement-copy', '\u6211\u5df2\u9605\u8bfb\u5e76\u540c\u610f\u300a\u7528\u6237\u534f\u8bae\u300b\u4e0e\u300a\u9690\u79c1\u653f\u7b56\u300b', 270, 2515, 36, '#4c3b30', 'left');
var baseName = stateName === 'password' ? 'A01-page-password-' + PREVIEW_VERSION : 'A01-page-sms-' + PREVIEW_VERSION;
savePsd(document, OUTPUT.fsName + '/' + baseName + '.psd');
savePng(document, OUTPUT.fsName + '/' + baseName + '-1236x2745.png');
document.close(SaveOptions.DONOTSAVECHANGES);
}
function makeContactSheet() {
var contact = null;
var step = 'create';
try {
contact = app.documents.add(2472, 2745, 72, 'A01-contact-sheet', NewDocumentMode.RGB, DocumentFill.WHITE);
step = 'place-password';
placeImage(contact, OUTPUT.fsName + '/A01-page-password-' + PREVIEW_VERSION + '-1236x2745.png', 'password', 0, 0, 1236, 2745, null);
step = 'place-sms';
placeImage(contact, OUTPUT.fsName + '/A01-page-sms-' + PREVIEW_VERSION + '-1236x2745.png', 'sms', 1236, 0, 1236, 2745, null);
step = 'resize';
contact.resizeImage(UnitValue(824, 'px'), UnitValue(915, 'px'), null, ResampleMethod.BICUBICSHARPER);
step = 'save';
savePng(contact, OUTPUT.fsName + '/A01-page-password-sms-contact-' + PREVIEW_VERSION + '-824x915.png');
contact.close(SaveOptions.DONOTSAVECHANGES);
contact = null;
} catch (error) {
if (contact) {
contact.close(SaveOptions.DONOTSAVECHANGES);
}
throw new Error('CONTACT_' + step + ': ' + error.message);
}
}
if (typeof JIAPU_A01_CONTACT_ONLY === 'undefined' || !JIAPU_A01_CONTACT_ONLY) {
compose('password');
compose('sms');
}
if (typeof JIAPU_A01_SKIP_CONTACT === 'undefined' || !JIAPU_A01_SKIP_CONTACT) {
makeContactSheet();
}
'A01_PAGE_PREVIEWS_OK';
@@ -0,0 +1,38 @@
#target photoshop
app.displayDialogs = DialogModes.NO;
var openedDocument = null;
function readJson(filePath) {
var file = File(filePath);
if (!file.exists) {
throw new Error('Manifest does not exist: ' + filePath);
}
file.encoding = 'UTF8';
if (!file.open('r')) {
throw new Error('Unable to open manifest: ' + filePath);
}
var content = file.read();
file.close();
return eval('(' + content + ')');
}
try {
var manifest = readJson(JIAPU_A01_MANIFEST);
var psdFile = File(JIAPU_A01_PSD);
if (!psdFile.exists) {
throw new Error('PSD does not exist: ' + psdFile.fsName);
}
openedDocument = app.open(psdFile);
if (manifest.exports.length !== 0) {
throw new Error('Layer export is not implemented until assets are approved.');
}
openedDocument.close(SaveOptions.DONOTSAVECHANGES);
openedDocument = null;
} catch (error) {
if (openedDocument !== null) {
openedDocument.close(SaveOptions.DONOTSAVECHANGES);
}
throw error;
}
@@ -0,0 +1,20 @@
#target photoshop
var runnerFile = new File($.fileName);
var root = runnerFile.parent.parent.parent.fsName;
var JIAPU_A01_ROOT = root;
var JIAPU_A01_OUTPUT_DIR = root + '/docs/design/assets/a01-vnext/review/page-v2';
var JIAPU_A01_PREVIEW_VERSION = 'v2';
var JIAPU_A01_SKIP_CONTACT = true;
try {
$.evalFile(new File(root + '/scripts/photoshop/a01-compose-page-preview.jsx'));
} catch (error) {
var log = new File(root + '/docs/design/assets/a01-vnext/review/page-v2/photoshop-error.log');
log.encoding = 'UTF8';
log.open('w');
log.write('number=' + error.number + '\nline=' + error.line + '\nmessage=' + error.message + '\nsource=' + error.source);
log.close();
throw error;
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 654 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 492 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 991 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

+19 -9
View File
@@ -56,10 +56,8 @@ $entry = Get-Content -LiteralPath $entryPath -Raw -Encoding utf8
# Final decorative surfaces must come from real bitmap assets. # Final decorative surfaces must come from real bitmap assets.
foreach ($asset in @( foreach ($asset in @(
'a01-login-header-v1.png', 'a01-red-hall-ink-backdrop-v1.png',
'a01-login-header-exact-v2.png', 'brand-seal.png',
'auth-ink-scenery.png',
'a01-login-scroll-frame-v1.png',
'a01-title-divider-v2.png', 'a01-title-divider-v2.png',
'a01-primary-button.png', 'a01-primary-button.png',
'a01-secondary-button.png', 'a01-secondary-button.png',
@@ -74,8 +72,16 @@ foreach ($asset in @(
)) { )) {
Assert-Contains -Content $entry -Expected $asset -Message "A01 is missing asset reference: $asset" Assert-Contains -Content $entry -Expected $asset -Message "A01 is missing asset reference: $asset"
} }
foreach ($obsoleteHeaderAsset in @('auth-header.png', 'brand-seal.png', 'a01-paper-transition-v1.png')) { foreach ($obsoleteVisualAsset in @(
Assert-NotContains -Content $entry -Unexpected $obsoleteHeaderAsset -Message "A01 must use the selected design's single exact header bitmap instead of composing $obsoleteHeaderAsset." 'auth-header.png',
'a01-paper-transition-v1.png',
'a01-login-header-v1.png',
'a01-login-header-exact-v2.png',
'a01-login-scroll-frame-v1.png',
'a01-vnext-scroll-v1.png',
'auth-ink-scenery.png'
)) {
Assert-NotContains -Content $entry -Unexpected $obsoleteVisualAsset -Message "A01 must not retain the rejected header, scenery, or scroll asset: $obsoleteVisualAsset"
} }
$requiredCopy = @( $requiredCopy = @(
@@ -96,6 +102,8 @@ $requiredCopy = @(
foreach ($copy in $requiredCopy) { foreach ($copy in $requiredCopy) {
Assert-Contains -Content $entry -Expected $copy -Message "A01 is missing required visible copy: $copy" Assert-Contains -Content $entry -Expected $copy -Message "A01 is missing required visible copy: $copy"
} }
$smsCodeCopy = ConvertFrom-Utf8Base64 '55+t5L+h6aqM6K+B56CB'
Assert-Contains -Content $entry -Expected $smsCodeCopy -Message 'A01 SMS state must use the full SMS code field label.'
foreach ($contract in @( foreach ($contract in @(
"const activeLoginMethod = ref('password')", "const activeLoginMethod = ref('password')",
@@ -109,7 +117,9 @@ foreach ($contract in @(
"url: '/pages/auth/a05-reset-password'", "url: '/pages/auth/a05-reset-password'",
'class="feedback-toast"', 'class="feedback-toast"',
'class="verification-layer"', 'class="verification-layer"',
'class="login-submit"' 'class="login-submit"',
'class="agreement-error"',
'const agreementError = ref(false)'
)) { )) {
Assert-Contains -Content $entry -Expected $contract -Message "A01 is missing state or interaction contract: $contract" Assert-Contains -Content $entry -Expected $contract -Message "A01 is missing state or interaction contract: $contract"
} }
@@ -121,10 +131,10 @@ Assert-NotContains -Content $entry -Unexpected "url: '/pages/auth/a02-login" -Me
Assert-NotContains -Content $entry -Unexpected 'brand-intro' -Message 'A01 must remove the legacy oversized brand introduction.' Assert-NotContains -Content $entry -Unexpected 'brand-intro' -Message 'A01 must remove the legacy oversized brand introduction.'
Assert-NotContains -Content $entry -Unexpected 'min-height: 100vh' -Message 'A01 must not depend on 100vh for its page shell.' Assert-NotContains -Content $entry -Unexpected 'min-height: 100vh' -Message 'A01 must not depend on 100vh for its page shell.'
Assert-NotContains -Content $entry -Unexpected 'exact-chrome-overlay' -Message 'A01 must not stack a second full-screen scroll perimeter over the complete scroll frame.' Assert-NotContains -Content $entry -Unexpected 'exact-chrome-overlay' -Message 'A01 must not stack a second full-screen scroll perimeter over the complete scroll frame.'
Assert-NotContains -Content $entry -Unexpected 'exact-header-overlay' -Message 'A01 must not stack a second header bitmap over the selected backdrop.'
Assert-NotContains -Content $entry -Unexpected 'auth-design-scenery-v1.png' -Message 'A01 must not use the scenery bitmap that contains stray scroll handles.' Assert-NotContains -Content $entry -Unexpected 'auth-design-scenery-v1.png' -Message 'A01 must not use the scenery bitmap that contains stray scroll handles.'
Assert-NotContains -Content $entry -Unexpected 'a01-icon-eye-closed-v1.png' -Message 'A01 hidden-password icon must retain the center pupil instead of using the hollow crossed eye.' Assert-NotContains -Content $entry -Unexpected 'a01-icon-eye-closed-v1.png' -Message 'A01 hidden-password icon must retain the center pupil instead of using the hollow crossed eye.'
Assert-NotContains -Content $entry -Unexpected 'a01-icon-verification-v1.png' -Message 'A01 SMS field must use a message-code icon instead of a success-state checkmark.' Assert-NotContains -Content $entry -Unexpected 'a01-icon-verification-v1.png' -Message 'A01 SMS field must use a message-code icon instead of a success-state checkmark.'
Assert-NotContains -Content $entry -Unexpected 'a01-icon-sms-code-v1.svg' -Message 'A01 SMS field must use the custom generated icon instead of the temporary third-party SVG.' Assert-NotContains -Content $entry -Unexpected 'a01-icon-sms-code-v1.svg' -Message 'A01 SMS field must use the custom generated icon instead of the temporary third-party SVG.'
Assert-NotContains -Content $entry -Unexpected '@media (max-height:' -Message 'A01 must preserve the design ratio on short screens and scroll instead of compressing with pixel breakpoints.' Assert-NotContains -Content $entry -Unexpected '@media (max-height:' -Message 'A01 must scroll naturally on short screens instead of compressing the selected design with height breakpoints.'
Write-Output 'A01-LOGIN-MERGE-CONTRACT PASS' Write-Output 'A01-LOGIN-MERGE-CONTRACT PASS'
+112
View File
@@ -0,0 +1,112 @@
$ErrorActionPreference = 'Stop'
$root = Split-Path -Parent $PSScriptRoot
$manifestRelativePath = 'design-pipeline/manifests/a01.json'
$requiredFiles = @(
'design-pipeline/package.json',
$manifestRelativePath,
'design-pipeline/scripts/build-a01.mjs'
)
foreach ($relativePath in $requiredFiles) {
$absolutePath = Join-Path $root $relativePath
if (-not (Test-Path -LiteralPath $absolutePath -PathType Leaf)) {
throw "Missing A01 code pipeline file: $relativePath"
}
}
$manifest = Get-Content -LiteralPath (Join-Path $root $manifestRelativePath) -Raw -Encoding UTF8 | ConvertFrom-Json
if ($manifest.schemaVersion -ne 1) {
throw "Expected A01 pipeline schemaVersion 1, found $($manifest.schemaVersion)."
}
if ($manifest.canvas.width -ne 412 -or $manifest.canvas.height -ne 915) {
throw 'A01 logical canvas must be 412x915.'
}
$requiredStates = @('password-hidden', 'password-visible', 'sms-default', 'sms-countdown')
foreach ($stateName in $requiredStates) {
if ($manifest.states.name -notcontains $stateName) {
throw "Missing A01 preview state: $stateName"
}
}
$workspacePrefix = [IO.Path]::GetFullPath($root).TrimEnd([IO.Path]::DirectorySeparatorChar) + [IO.Path]::DirectorySeparatorChar
$runtimePrefix = 'static/assets/modules/auth/'
$assetIds = @{}
foreach ($asset in $manifest.assets) {
if (-not $asset.id -or $assetIds.ContainsKey($asset.id)) {
throw "A01 asset id must be present and unique: $($asset.id)"
}
$assetIds[$asset.id] = $true
foreach ($propertyName in @('source', 'output', 'width', 'height', 'alpha', 'maxBytes')) {
if ($null -eq $asset.$propertyName -or "$($asset.$propertyName)" -eq '') {
throw "A01 asset '$($asset.id)' is missing property: $propertyName"
}
}
if (-not $asset.output.StartsWith($runtimePrefix, [StringComparison]::OrdinalIgnoreCase)) {
throw "A01 runtime asset must stay in ${runtimePrefix}: $($asset.output)"
}
if ($asset.source -ne $asset.output) {
throw "A01 portable pipeline must use the committed runtime asset as its stable source: $($asset.id)"
}
if ($asset.source -match '(?i)candidates|review|generated|node_modules|\.psd$') {
throw "A01 portable pipeline source must not depend on local intermediate files: $($asset.source)"
}
if ($asset.output -match '(?i)review|page-v2|\.psd$') {
throw "A01 runtime asset must not use a preview, full-page image, or PSD: $($asset.output)"
}
$sourcePath = [IO.Path]::GetFullPath((Join-Path $root $asset.source))
$outputPath = [IO.Path]::GetFullPath((Join-Path $root $asset.output))
foreach ($path in @($sourcePath, $outputPath)) {
if (-not $path.StartsWith($workspacePrefix, [StringComparison]::OrdinalIgnoreCase)) {
throw "A01 pipeline path escaped the workspace: $path"
}
}
if (-not (Test-Path -LiteralPath $sourcePath -PathType Leaf)) {
throw "Missing A01 source asset: $($asset.source)"
}
if (-not (Test-Path -LiteralPath $outputPath -PathType Leaf)) {
throw "Missing generated A01 runtime asset: $($asset.output)"
}
$bytes = [IO.File]::ReadAllBytes($outputPath)
if ($bytes.Length -lt 24 -or $bytes[0] -ne 0x89 -or $bytes[1] -ne 0x50 -or $bytes[2] -ne 0x4E -or $bytes[3] -ne 0x47) {
throw "Generated A01 asset is not a PNG: $($asset.output)"
}
$width = [Net.IPAddress]::NetworkToHostOrder([BitConverter]::ToInt32($bytes, 16))
$height = [Net.IPAddress]::NetworkToHostOrder([BitConverter]::ToInt32($bytes, 20))
if ($width -ne $asset.width -or $height -ne $asset.height) {
throw "Generated A01 asset has wrong dimensions: $($asset.output) expected $($asset.width)x$($asset.height), found ${width}x${height}."
}
if ($bytes.Length -gt $asset.maxBytes) {
throw "Generated A01 asset exceeds maxBytes: $($asset.output) has $($bytes.Length), limit $($asset.maxBytes)."
}
$pngColorType = $bytes[25]
$hasAlpha = $pngColorType -in @(4, 6)
if ([bool]$asset.alpha -ne $hasAlpha) {
throw "Generated A01 asset Alpha contract mismatch: $($asset.output)"
}
}
foreach ($preview in $manifest.previews) {
$previewPath = Join-Path $root $preview.output
if (-not (Test-Path -LiteralPath $previewPath -PathType Leaf)) {
throw "Missing generated A01 preview: $($preview.output)"
}
}
$buildReport = Join-Path $root 'design-pipeline/generated/a01/build-report.json'
if (-not (Test-Path -LiteralPath $buildReport -PathType Leaf)) {
throw 'Missing A01 code pipeline build report.'
}
$builder = Get-Content -LiteralPath (Join-Path $root 'design-pipeline/scripts/build-a01.mjs') -Raw -Encoding UTF8
foreach ($forbidden in @('Photoshop.Application', '.psd', 'scripts/photoshop')) {
if ($builder -match [regex]::Escape($forbidden)) {
throw "A01 code pipeline must not depend on Photoshop or PSD: $forbidden"
}
}
Write-Output 'A01-CODE-PIPELINE-CONTRACT PASS'
+88
View File
@@ -0,0 +1,88 @@
$ErrorActionPreference = 'Stop'
$root = Split-Path -Parent $PSScriptRoot
$manifestRelativePath = 'docs/design/assets/a01-vnext/source/a01-psd-manifest.json'
function ConvertFrom-Utf8Base64 {
param([string]$Value)
return [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String($Value))
}
$requiredFiles = @(
$manifestRelativePath,
'scripts/build-a01-layered-psd.ps1',
'scripts/export-a01-layered-assets.ps1',
'scripts/photoshop/a01-build-layered-psd.jsx',
'scripts/photoshop/a01-export-layered-assets.jsx',
'scripts/photoshop/a01-compose-page-preview.jsx'
)
foreach ($relativePath in $requiredFiles) {
$absolutePath = Join-Path $root $relativePath
if (-not (Test-Path -LiteralPath $absolutePath -PathType Leaf)) {
throw "Missing layered PSD contract file: $relativePath"
}
}
$manifestPath = Join-Path $root $manifestRelativePath
$manifest = Get-Content -LiteralPath $manifestPath -Raw -Encoding utf8 | ConvertFrom-Json
if ($manifest.schemaVersion -ne 1) {
throw "Expected PSD manifest schemaVersion 1, found $($manifest.schemaVersion)."
}
if ($manifest.canvas.width -ne 1236 -or $manifest.canvas.height -ne 2745) {
throw 'PSD canvas must be 1236x2745.'
}
if ($manifest.canvas.logicalWidth -ne 412 -or $manifest.canvas.logicalHeight -ne 915 -or $manifest.canvas.scale -ne 3) {
throw 'PSD logical canvas must be 412x915 at scale 3.'
}
$requiredGroups = @(
'MDAt5Y+C6ICD',
'MTAt6IOM5pmv',
'MjAt5Y236L20',
'MzAt5ZOB54mM5LiO5qCH6aKY6KOF6aWw',
'NDAt5YWs5YWx5o6n5Lu255qu6IKk',
'NTAt5a+G56CB55m75b2V',
'NjAt6aqM6K+B56CB55m75b2V',
'NzAt5YaF5a655LiO5qCH5rOo'
) | ForEach-Object { ConvertFrom-Utf8Base64 $_ }
foreach ($groupName in $requiredGroups) {
if ($manifest.groups.name -notcontains $groupName) {
throw "Missing PSD group: $groupName"
}
}
foreach ($stateName in @('password-hidden', 'password-visible', 'sms-default', 'sms-countdown')) {
if ($manifest.states.name -notcontains $stateName) {
throw "Missing PSD state: $stateName"
}
}
$buildWrapper = Get-Content -LiteralPath (Join-Path $root 'scripts/build-a01-layered-psd.ps1') -Raw -Encoding utf8
$exportWrapper = Get-Content -LiteralPath (Join-Path $root 'scripts/export-a01-layered-assets.ps1') -Raw -Encoding utf8
$buildJsx = Get-Content -LiteralPath (Join-Path $root 'scripts/photoshop/a01-build-layered-psd.jsx') -Raw -Encoding utf8
$exportJsx = Get-Content -LiteralPath (Join-Path $root 'scripts/photoshop/a01-export-layered-assets.jsx') -Raw -Encoding utf8
$composeJsx = Get-Content -LiteralPath (Join-Path $root 'scripts/photoshop/a01-compose-page-preview.jsx') -Raw -Encoding utf8
foreach ($wrapper in @($buildWrapper, $exportWrapper)) {
foreach ($variableName in @('JIAPU_A01_ROOT', 'JIAPU_A01_MANIFEST', 'JIAPU_A01_PSD')) {
if ($wrapper -notmatch [regex]::Escape("var $variableName =")) {
throw "Photoshop wrapper must inject the JSX variable directly: $variableName"
}
}
}
foreach ($jsx in @($buildJsx, $exportJsx)) {
if ($jsx -match [regex]::Escape('$.getenv(')) {
throw 'Photoshop JSX must not depend on environment variables from an already-running process.'
}
}
if ($composeJsx -notmatch [regex]::Escape('new Folder(JIAPU_A01_ROOT)') -or
$composeJsx -notmatch [regex]::Escape('new Folder(JIAPU_A01_OUTPUT_DIR)')) {
throw 'Photoshop preview composer must construct Folder objects explicitly.'
}
if ($composeJsx -match [regex]::Escape("var password = app.open(File(OUTPUT.fsName + '/A01-page-password-v1-1236x2745.png'))")) {
throw 'Contact-sheet composition must not pre-open a source that placeImage opens itself.'
}
Write-Output 'A01-LAYERED-PSD-CONTRACT PASS'
+132
View File
@@ -0,0 +1,132 @@
const assert = require('assert')
const url = 'http://localhost:5173/#/pages/auth/a01-entry'
const sizes = [
{ width: 320, height: 568 },
{ width: 360, height: 640 },
{ width: 360, height: 800 },
{ width: 412, height: 915 }
]
const sleep = (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds))
const connect = async () => {
const port = process.env.CHROME_DEBUGGING_PORT || '9222'
const pages = await (await fetch(`http://127.0.0.1:${port}/json/list`)).json()
const page = pages.find((item) => item.type === 'page' && item.url.startsWith('http://localhost:5173'))
if (!page) throw new Error('Chrome debugging has no application page')
const socket = new WebSocket(page.webSocketDebuggerUrl)
await new Promise((resolve, reject) => {
socket.addEventListener('open', resolve, { once: true })
socket.addEventListener('error', reject, { once: true })
})
let id = 0
const pending = new Map()
const exceptions = []
socket.addEventListener('message', (event) => {
const message = JSON.parse(event.data)
if (message.method === 'Runtime.exceptionThrown') exceptions.push(message.params.exceptionDetails.text)
const request = pending.get(message.id)
if (!request) return
pending.delete(message.id)
if (message.error) request.reject(new Error(message.error.message))
else request.resolve(message.result)
})
const send = (method, params = {}) => new Promise((resolve, reject) => {
id += 1
pending.set(id, { resolve, reject })
socket.send(JSON.stringify({ id, method, params }))
})
return { socket, send, exceptions }
}
const valueOf = async (send, expression) => {
const result = await send('Runtime.evaluate', { expression, returnByValue: true })
return result.result?.value
}
const waitFor = async (send, expression, message) => {
for (let attempt = 0; attempt < 40; attempt += 1) {
if (await valueOf(send, expression)) return
await sleep(100)
}
throw new Error(message)
}
const run = async () => {
const { socket, send, exceptions } = await connect()
try {
await send('Page.enable')
await send('Runtime.enable')
for (const size of sizes) {
await send('Emulation.setDeviceMetricsOverride', {
width: size.width,
height: size.height,
deviceScaleFactor: 1,
mobile: true
})
await send('Page.navigate', { url })
await send('Page.reload')
await waitFor(send, "document.querySelectorAll('.login-tab').length === 2", `A01 did not render at ${size.width}x${size.height}`)
if (size.width === 320 && size.height === 568) {
await waitFor(send, "document.querySelector('.auth-page')?.scrollHeight > document.querySelector('.auth-page')?.clientHeight", 'A01 did not establish natural scrolling at 320x568')
}
const metrics = await valueOf(send, `({
innerWidth: window.innerWidth,
innerHeight: window.innerHeight,
scrollWidth: document.documentElement.scrollWidth,
scrollHeight: document.documentElement.scrollHeight,
pageClientHeight: document.querySelector('.auth-page')?.clientHeight,
pageScrollHeight: document.querySelector('.auth-page')?.scrollHeight
})`)
assert.strictEqual(metrics.innerWidth, size.width, `A01 viewport width mismatch at ${size.width}x${size.height}`)
assert(metrics.scrollWidth <= size.width + 1, `A01 has horizontal overflow at ${size.width}x${size.height}: ${metrics.scrollWidth}`)
if (size.width === 320 && size.height === 568) {
assert(metrics.pageScrollHeight > metrics.pageClientHeight, 'A01 must scroll naturally at 320x568')
}
}
await valueOf(send, "document.querySelectorAll('.login-tab')[1].click()")
await waitFor(send, "document.querySelectorAll('.login-tab')[1].classList.contains('active')", 'A01 SMS tab did not activate')
await waitFor(send, "Boolean(document.querySelector('.input-icon--sms img'))", 'A01 SMS field image did not render after tab activation')
const smsIconSource = await valueOf(send, "document.querySelector('.input-icon--sms img')?.getAttribute('src')")
assert(smsIconSource?.includes('a01-icon-sms-code-v2.png'), `A01 SMS state did not use the approved message icon: ${smsIconSource}`)
assert.strictEqual(await valueOf(send, "Boolean(document.querySelector('.password-toggle'))"), false, 'A01 SMS state retained the password eye')
assert.strictEqual(await valueOf(send, "Boolean(document.querySelector('.forgot-password'))"), false, 'A01 SMS state retained forgot password')
await valueOf(send, "document.querySelectorAll('.login-tab')[0].click()")
await waitFor(send, "document.querySelectorAll('.login-tab')[0].classList.contains('active')", 'A01 password tab did not activate')
assert(await valueOf(send, "Boolean(document.querySelector('.password-toggle'))"), 'A01 password state is missing the eye control')
assert(await valueOf(send, "Boolean(document.querySelector('.forgot-password'))"), 'A01 password state is missing forgot password')
await valueOf(send, `(() => {
const inputs = document.querySelectorAll('.auth-input input')
inputs[0].value = '13800138000'
inputs[0].dispatchEvent(new Event('input', { bubbles: true }))
inputs[1].value = 'demo-password'
inputs[1].dispatchEvent(new Event('input', { bubbles: true }))
document.querySelector('.login-submit').click()
})()`)
await waitFor(send, "Boolean(document.querySelector('.agreement-error'))", 'A01 did not show inline agreement validation')
await valueOf(send, "document.querySelector('.agreement-row').click()")
await waitFor(send, "!document.querySelector('.agreement-error')", 'A01 agreement error did not clear after selection')
await valueOf(send, "document.querySelector('.login-submit').click()")
await waitFor(send, "Boolean(document.querySelector('.verification-layer'))", 'A01 did not open the custom verification layer after local validation')
assert.deepStrictEqual(exceptions, [], `A01 raised browser exceptions: ${exceptions.join('; ')}`)
process.stdout.write('A01-RESPONSIVE-RUNTIME-SMOKE PASS\n')
} finally {
try { await send('Emulation.clearDeviceMetricsOverride') } catch (_) {}
socket.close()
}
}
run().catch((error) => {
process.stderr.write(`${error.stack || error.message}\n`)
process.exit(1)
})
+13 -3
View File
@@ -26,8 +26,8 @@ $loginCopy = ConvertFrom-Utf8Base64 '55m75b2V'
if (-not ($pages.pages.path -contains 'pages/auth/a04-register')) { throw 'A-04 route must remain declared in pages.json' } if (-not ($pages.pages.path -contains 'pages/auth/a04-register')) { throw 'A-04 route must remain declared in pages.json' }
Assert-NotContains -Content $register -Unexpected 'ModulePage' -Message 'A-04 must not remain a ModulePage shell' Assert-NotContains -Content $register -Unexpected 'ModulePage' -Message 'A-04 must not remain a ModulePage shell'
foreach ($required in @( foreach ($required in @(
'auth-header', 'a01-red-hall-ink-backdrop-v1.png',
'register-panel__skin', 'brand-seal.png',
'a02-login-panel.png', 'a02-login-panel.png',
'a01-primary-button.png', 'a01-primary-button.png',
'a02-agreement-unchecked.png', 'a02-agreement-unchecked.png',
@@ -36,11 +36,17 @@ foreach ($required in @(
'v-model="password"', 'v-model="password"',
'v-model="confirmPassword"', 'v-model="confirmPassword"',
'const agreed = ref(false)', 'const agreed = ref(false)',
'const agreementError = ref(false)',
'const fieldErrors = ref({',
'const verificationVisible = ref(false)',
'const toggleAgreement = () =>', 'const toggleAgreement = () =>',
'const submitRegister = () =>', 'const submitRegister = () =>',
"const prepareLogin = () => uni.navigateTo({ url: '/pages/auth/a01-entry' })", 'class="feedback-toast"',
'class="verification-layer"',
"const prepareLogin = () => uni.redirectTo({ url: '/pages/auth/a01-entry' })",
'if (!/^1\d{10}$/.test(phone.value))', 'if (!/^1\d{10}$/.test(phone.value))',
'if (!password.value)', 'if (!password.value)',
'if (!confirmPassword.value)',
'if (password.value !== confirmPassword.value)' 'if (password.value !== confirmPassword.value)'
)) { )) {
Assert-Contains -Content $register -Expected $required -Message "Missing A-04 registration contract: $required" Assert-Contains -Content $register -Expected $required -Message "Missing A-04 registration contract: $required"
@@ -49,6 +55,10 @@ Assert-Contains -Content $register -Expected $registerCopy -Message 'A-04 must v
Assert-Contains -Content $register -Expected $loginCopy -Message 'A-04 must provide a login return path' Assert-Contains -Content $register -Expected $loginCopy -Message 'A-04 must provide a login return path'
Assert-NotContains -Content $catalog -Unexpected 'a04:' -Message 'A-04 must not remain in the temporary page catalog' Assert-NotContains -Content $catalog -Unexpected 'a04:' -Message 'A-04 must not remain in the temporary page catalog'
Assert-NotContains -Content $register -Unexpected 'register-intro' -Message 'A-04 must not retain the redundant registration intro copy' Assert-NotContains -Content $register -Unexpected 'register-intro' -Message 'A-04 must not retain the redundant registration intro copy'
Assert-NotContains -Content $register -Unexpected 'auth-design-scenery-v1.png' -Message 'A-04 must use the shared red-hall ink backdrop instead of the rejected scenery layer'
foreach ($nativeUi in @('uni.showToast', 'uni.showModal', 'uni.showLoading', 'uni.showActionSheet')) {
Assert-NotContains -Content $register -Unexpected $nativeUi -Message "A-04 must not use native UniApp feedback: $nativeUi"
}
foreach ($required in @( foreach ($required in @(
'auth-divider-knot.png', 'auth-divider-knot.png',
'register-divider', 'register-divider',
+116
View File
@@ -0,0 +1,116 @@
const assert = require('assert')
const a01Url = 'http://localhost:5173/#/pages/auth/a01-entry'
const a04Url = 'http://localhost:5173/#/pages/auth/a04-register'
const sleep = (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds))
const connect = async () => {
const port = process.env.CHROME_DEBUGGING_PORT || '9222'
const pages = await (await fetch(`http://127.0.0.1:${port}/json/list`)).json()
const page = pages.find((item) => item.type === 'page' && item.url.startsWith('http://localhost:5173'))
if (!page) throw new Error('Chrome debugging has no application page')
const socket = new WebSocket(page.webSocketDebuggerUrl)
await new Promise((resolve, reject) => {
socket.addEventListener('open', resolve, { once: true })
socket.addEventListener('error', reject, { once: true })
})
let id = 0
const pending = new Map()
const exceptions = []
socket.addEventListener('message', (event) => {
const message = JSON.parse(event.data)
if (message.method === 'Runtime.exceptionThrown') exceptions.push(message.params.exceptionDetails.text)
const request = pending.get(message.id)
if (!request) return
pending.delete(message.id)
if (message.error) request.reject(new Error(message.error.message))
else request.resolve(message.result)
})
const send = (method, params = {}) => new Promise((resolve, reject) => {
id += 1
pending.set(id, { resolve, reject })
socket.send(JSON.stringify({ id, method, params }))
})
return { socket, send, exceptions }
}
const valueOf = async (send, expression) => {
const result = await send('Runtime.evaluate', { expression, returnByValue: true })
if (result.exceptionDetails) {
throw new Error(result.exceptionDetails.exception?.description || result.exceptionDetails.text)
}
return result.result?.value
}
const waitFor = async (send, expression, message) => {
for (let attempt = 0; attempt < 40; attempt += 1) {
if (await valueOf(send, expression)) return
await sleep(100)
}
throw new Error(message)
}
const navigate = async (send, url, selector) => {
// 每次增加查询戳,确保同一路由也会建立全新文档,避免沿用上一次表单状态。
const targetUrl = url.replace('/#/', `/?runtime=${Date.now()}#/`)
await send('Page.navigate', { url: targetUrl })
await waitFor(send, `location.href.includes(${JSON.stringify(new URL(url).hash.slice(1))})`, `Page did not navigate to ${url}`)
await waitFor(send, `Boolean(document.querySelector(${JSON.stringify(selector)}))`, `Page did not render ${selector}`)
}
const run = async () => {
const { socket, send, exceptions } = await connect()
try {
await send('Page.enable')
await send('Runtime.enable')
await navigate(send, a01Url, '.register-link')
await valueOf(send, "document.querySelector('.register-link').click()")
await waitFor(send, "location.href.includes('/pages/auth/a04-register')", 'A01 registration entry did not open A04')
for (const size of [{ width: 320, height: 568 }, { width: 360, height: 800 }, { width: 412, height: 915 }]) {
await send('Emulation.setDeviceMetricsOverride', { ...size, deviceScaleFactor: 1, mobile: true })
await navigate(send, a04Url, '.register-submit')
const metrics = await valueOf(send, `({
width: document.documentElement.scrollWidth,
pageClientHeight: document.querySelector('.auth-page').clientHeight,
pageScrollHeight: document.querySelector('.auth-page').scrollHeight
})`)
assert(metrics.width <= size.width + 1, `A04 has horizontal overflow at ${size.width}x${size.height}`)
if (size.width === 320) assert(metrics.pageScrollHeight > metrics.pageClientHeight, 'A04 must scroll naturally at 320x568')
}
await valueOf(send, "document.querySelector('.register-submit').click()")
await waitFor(send, "document.querySelectorAll('.field-error').length === 3", 'A04 empty submit did not show three field errors')
assert(await valueOf(send, "Boolean(document.querySelector('.agreement-error'))"), 'A04 empty submit did not show agreement error')
await valueOf(send, `(() => {
const inputs = document.querySelectorAll('.auth-input input')
const values = ['13800138000', 'demo-password', 'demo-password']
inputs.forEach((input, index) => {
input.value = values[index]
input.dispatchEvent(new Event('input', { bubbles: true }))
})
document.querySelector('.agreement-row').click()
document.querySelector('.register-submit').click()
})()`)
await waitFor(send, "Boolean(document.querySelector('.verification-layer'))", 'A04 valid submit did not open custom verification')
await valueOf(send, "document.querySelector('.verification-action--primary').click()")
await waitFor(send, "Boolean(document.querySelector('.feedback-toast'))", 'A04 verification result did not use custom feedback')
await navigate(send, a04Url, '.login-entry__link')
await valueOf(send, "document.querySelector('.login-entry__link').click()")
await waitFor(send, "Boolean(document.querySelector('.login-tab')) && (location.hash === '#/' || location.href.includes('/pages/auth/a01-entry'))", 'A04 login entry did not return to A01')
assert.deepStrictEqual(exceptions, [], `A04 raised browser exceptions: ${exceptions.join('; ')}`)
process.stdout.write('A04-REGISTRATION-RUNTIME-SMOKE PASS\n')
} finally {
try { await send('Emulation.clearDeviceMetricsOverride') } catch (_) {}
socket.close()
}
}
run().catch((error) => {
process.stderr.write(`${error.stack || error.message}\n`)
process.exit(1)
})
+13 -3
View File
@@ -28,8 +28,8 @@ $loginCopy = ConvertFrom-Utf8Base64 '6L+U5Zue55m75b2V'
if (-not ($pages.pages.path -contains 'pages/auth/a05-reset-password')) { throw 'A-05 route must remain declared in pages.json' } if (-not ($pages.pages.path -contains 'pages/auth/a05-reset-password')) { throw 'A-05 route must remain declared in pages.json' }
Assert-NotContains -Content $page -Unexpected 'ModulePage' -Message 'A-05 must not remain a ModulePage shell' Assert-NotContains -Content $page -Unexpected 'ModulePage' -Message 'A-05 must not remain a ModulePage shell'
foreach ($required in @( foreach ($required in @(
'auth-header', 'a01-red-hall-ink-backdrop-v1.png',
'reset-panel__skin', 'brand-seal.png',
'a02-login-panel.png', 'a02-login-panel.png',
'a01-primary-button.png', 'a01-primary-button.png',
'auth-divider-knot.png', 'auth-divider-knot.png',
@@ -40,7 +40,13 @@ foreach ($required in @(
'v-model="confirmPassword"', 'v-model="confirmPassword"',
'const prepareGetCode = () =>', 'const prepareGetCode = () =>',
'const submitReset = () =>', 'const submitReset = () =>',
"const prepareLogin = () => uni.navigateTo({ url: '/pages/auth/a01-entry' })", 'const fieldErrors = ref({',
'const verificationVisible = ref(false)',
'const successVisible = ref(false)',
'class="verification-layer"',
'class="feedback-toast"',
'class="success-layer"',
"const prepareLogin = () => uni.redirectTo({ url: '/pages/auth/a01-entry' })",
'if (!/^1\d{10}$/.test(phone.value))', 'if (!/^1\d{10}$/.test(phone.value))',
'if (!/^\d{6}$/.test(verificationCode.value))', 'if (!/^\d{6}$/.test(verificationCode.value))',
'if (!password.value)', 'if (!password.value)',
@@ -53,6 +59,10 @@ Assert-Contains -Content $page -Expected $confirmPasswordCopy -Message 'A-05 mus
Assert-Contains -Content $page -Expected $getCodeCopy -Message 'A-05 must expose the verification-code action' Assert-Contains -Content $page -Expected $getCodeCopy -Message 'A-05 must expose the verification-code action'
Assert-Contains -Content $page -Expected $loginCopy -Message 'A-05 must provide the A01 return path' Assert-Contains -Content $page -Expected $loginCopy -Message 'A-05 must provide the A01 return path'
Assert-NotContains -Content $catalog -Unexpected 'a05:' -Message 'A-05 must not remain in the temporary page catalog' Assert-NotContains -Content $catalog -Unexpected 'a05:' -Message 'A-05 must not remain in the temporary page catalog'
Assert-NotContains -Content $page -Unexpected 'auth-design-scenery-v1.png' -Message 'A-05 must use the selected red-hall ink backdrop'
foreach ($nativeUi in @('uni.showToast', 'uni.showModal', 'uni.showLoading', 'uni.showActionSheet')) {
Assert-NotContains -Content $page -Unexpected $nativeUi -Message "A-05 must not use native UniApp feedback: $nativeUi"
}
foreach ($forbidden in @( foreach ($forbidden in @(
'(?s)\.reset-panel\s*\{[^}]*\bborder\s*:', '(?s)\.reset-panel\s*\{[^}]*\bborder\s*:',
+134
View File
@@ -0,0 +1,134 @@
const assert = require('assert')
const a01Url = 'http://localhost:5173/#/pages/auth/a01-entry'
const a05Url = 'http://localhost:5173/#/pages/auth/a05-reset-password'
const sleep = (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds))
const connect = async () => {
const port = process.env.CHROME_DEBUGGING_PORT || '9222'
const pages = await (await fetch(`http://127.0.0.1:${port}/json/list`)).json()
const page = pages.find((item) => item.type === 'page' && item.url.startsWith('http://localhost:5173'))
if (!page) throw new Error('Chrome debugging has no application page')
const socket = new WebSocket(page.webSocketDebuggerUrl)
await new Promise((resolve, reject) => {
socket.addEventListener('open', resolve, { once: true })
socket.addEventListener('error', reject, { once: true })
})
let id = 0
const pending = new Map()
const exceptions = []
socket.addEventListener('message', (event) => {
const message = JSON.parse(event.data)
if (message.method === 'Runtime.exceptionThrown') exceptions.push(message.params.exceptionDetails.text)
const request = pending.get(message.id)
if (!request) return
pending.delete(message.id)
if (message.error) request.reject(new Error(message.error.message))
else request.resolve(message.result)
})
const send = (method, params = {}) => new Promise((resolve, reject) => {
id += 1
pending.set(id, { resolve, reject })
socket.send(JSON.stringify({ id, method, params }))
})
return { socket, send, exceptions }
}
const valueOf = async (send, expression) => {
const result = await send('Runtime.evaluate', { expression, returnByValue: true })
if (result.exceptionDetails) throw new Error(result.exceptionDetails.exception?.description || result.exceptionDetails.text)
return result.result?.value
}
const waitFor = async (send, expression, message) => {
for (let attempt = 0; attempt < 40; attempt += 1) {
if (await valueOf(send, expression)) return
await sleep(100)
}
throw new Error(message)
}
const navigate = async (send, url, selector) => {
const targetUrl = url.replace('/#/', `/?runtime=${Date.now()}#/`)
await send('Page.navigate', { url: targetUrl })
await waitFor(send, `location.href.includes(${JSON.stringify(new URL(url).hash.slice(1))})`, `Page did not navigate to ${url}`)
await waitFor(send, `Boolean(document.querySelector(${JSON.stringify(selector)}))`, `Page did not render ${selector}`)
}
const setInputs = (values) => `(() => {
const inputs = document.querySelectorAll('.auth-input input')
;${JSON.stringify(values)}.forEach((value, index) => {
inputs[index].value = value
inputs[index].dispatchEvent(new Event('input', { bubbles: true }))
})
})()`
const run = async () => {
const { socket, send, exceptions } = await connect()
try {
await send('Page.enable')
await send('Runtime.enable')
await navigate(send, a01Url, '.forgot-password')
await valueOf(send, "document.querySelector('.forgot-password').click()")
await waitFor(send, "location.href.includes('/pages/auth/a05-reset-password')", 'A01 forgot-password entry did not open A05')
for (const size of [{ width: 320, height: 568 }, { width: 360, height: 800 }, { width: 412, height: 915 }]) {
await send('Emulation.setDeviceMetricsOverride', { ...size, deviceScaleFactor: 1, mobile: true })
await navigate(send, a05Url, '.reset-submit')
const metrics = await valueOf(send, `({
width: document.documentElement.scrollWidth,
pageClientHeight: document.querySelector('.auth-page').clientHeight,
pageScrollHeight: document.querySelector('.auth-page').scrollHeight
})`)
assert(metrics.width <= size.width + 1, `A05 has horizontal overflow at ${size.width}x${size.height}`)
if (size.width === 320) assert(metrics.pageScrollHeight > metrics.pageClientHeight, 'A05 must scroll naturally at 320x568')
}
await valueOf(send, "document.querySelector('.get-code').click()")
await waitFor(send, "Boolean(document.querySelector('.field-error'))", 'A05 invalid phone did not show inline error')
await valueOf(send, setInputs(['13800138000', '', '', '']))
await valueOf(send, "document.querySelector('.get-code').click()")
await waitFor(send, "Boolean(document.querySelector('.verification-layer'))", 'A05 code request did not open custom verification')
await valueOf(send, "document.querySelector('.verification-action').click()")
await waitFor(send, "!document.querySelector('.verification-layer')", 'A05 verification did not close')
assert.strictEqual(await valueOf(send, "document.querySelector('.auth-input input').value"), '13800138000', 'A05 verification close cleared the form')
await valueOf(send, "document.querySelector('.get-code').click()")
await waitFor(send, "Boolean(document.querySelector('.verification-layer'))", 'A05 verification did not reopen')
await valueOf(send, "document.querySelector('.verification-action--primary').click()")
await waitFor(send, "Boolean(document.querySelector('.feedback-toast'))", 'A05 verified code request did not use custom feedback')
assert.strictEqual(await valueOf(send, "document.querySelector('.get-code').textContent"), '重新获取', 'A05 code action did not enter requested state')
await valueOf(send, "document.querySelector('.reset-submit').click()")
await waitFor(send, "document.querySelectorAll('.field-error').length === 3", 'A05 incomplete submit did not show three remaining field errors')
await valueOf(send, setInputs(['13800138000', '123456', 'new-password', 'different-password']))
await sleep(100)
await valueOf(send, "document.querySelector('.reset-submit').click()")
await sleep(100)
const mismatchState = await valueOf(send, `({
values: Array.from(document.querySelectorAll('.auth-input input')).map((item) => item.value),
errors: Array.from(document.querySelectorAll('.field-error')).map((item) => item.textContent),
success: Boolean(document.querySelector('.success-layer'))
})`)
assert(mismatchState.errors.some((message) => message.includes('不一致')), `A05 mismatched passwords did not show inline error: ${JSON.stringify(mismatchState)}`)
await valueOf(send, setInputs(['13800138000', '123456', 'new-password', 'new-password']))
await sleep(100)
await valueOf(send, "document.querySelector('.reset-submit').click()")
await waitFor(send, "Boolean(document.querySelector('.success-layer'))", 'A05 valid submit did not show custom success result')
await valueOf(send, "document.querySelector('.success-action').click()")
await waitFor(send, "Boolean(document.querySelector('.login-tab')) && (location.hash === '#/' || location.href.includes('/pages/auth/a01-entry'))", 'A05 success action did not return to A01')
assert.deepStrictEqual(exceptions, [], `A05 raised browser exceptions: ${exceptions.join('; ')}`)
process.stdout.write('A05-RESET-PASSWORD-RUNTIME-SMOKE PASS\n')
} finally {
try { await send('Emulation.clearDeviceMetricsOverride') } catch (_) {}
socket.close()
}
}
run().catch((error) => {
process.stderr.write(`${error.stack || error.message}\n`)
process.exit(1)
})
+20 -13
View File
@@ -17,37 +17,44 @@ $catalog = Get-Content -LiteralPath (Join-Path $root 'data/page-catalog.js') -Ra
if (-not ($pages.pages.path -contains 'pages/auth/a06-auth-status')) { throw 'A-06 route must remain declared in pages.json' } if (-not ($pages.pages.path -contains 'pages/auth/a06-auth-status')) { throw 'A-06 route must remain declared in pages.json' }
if (-not ($pages.pages.path -contains 'pages/auth/a01-entry')) { throw 'A-06 must retain the A01 return target' } if (-not ($pages.pages.path -contains 'pages/auth/a01-entry')) { throw 'A-06 must retain the A01 return target' }
if (-not ($pages.pages.path -contains 'pages/auth/a04-register')) { throw 'A-06 must retain the A04 registration target' }
Assert-NotContains -Content $page -Unexpected 'ModulePage' -Message 'A-06 must not remain a ModulePage shell' Assert-NotContains -Content $page -Unexpected 'ModulePage' -Message 'A-06 must not remain a ModulePage shell'
Assert-NotContains -Content $catalog -Unexpected 'a06:' -Message 'A-06 must not remain in the temporary page catalog' Assert-NotContains -Content $catalog -Unexpected 'a06:' -Message 'A-06 must not remain in the temporary page catalog'
foreach ($required in @( foreach ($required in @(
"const status = ref('normal')", "const status = ref('risk')",
"const statusConfig = {", "const statusConfig = {",
'normal:', 'frozen:',
'failed:', 'disabled:',
'restricted:', 'risk:',
"'register-pending':", 'reasonLabel:',
"'wechat-cancelled':", 'impactLabel:',
"'wechat-failed':", 'recoveryLabel:',
'const resolveStatus = () =>', 'const resolveStatus = () =>',
'const goLogin = () =>', 'const goLogin = () =>',
'const goRegister = () =>', 'const openRecovery = () =>',
'const handlePrimary = () =>', 'const closeRecovery = () =>',
'const goBack = () =>', 'const goBack = () =>',
'auth-header.png', 'a01-red-hall-ink-backdrop-v1.png',
'brand-seal.png', 'brand-seal.png',
'a02-login-panel.png', 'a02-login-panel.png',
'a01-primary-button.png', 'a01-primary-button.png',
'auth-title-cloud.png',
'auth-divider-knot.png', 'auth-divider-knot.png',
'chevron-right.png', 'chevron-right.png',
'auth-login-outline.png' 'auth-login-outline.png',
'class="recovery-layer"'
)) { )) {
Assert-Contains -Content $page -Expected $required -Message "Missing A-06 auth-status contract: $required" Assert-Contains -Content $page -Expected $required -Message "Missing A-06 auth-status contract: $required"
} }
foreach ($removedState in @('normal:', 'failed:', 'restricted:', "'register-pending':", "'wechat-cancelled':", "'wechat-failed':", 'const goRegister = () =>')) {
Assert-NotContains -Content $page -Unexpected $removedState -Message "A-06 must remove obsolete non-blocking state: $removedState"
}
foreach ($nativeUi in @('uni.showToast', 'uni.showModal', 'uni.showLoading', 'uni.showActionSheet')) {
Assert-NotContains -Content $page -Unexpected $nativeUi -Message "A-06 must not use native UniApp feedback: $nativeUi"
}
Assert-NotContains -Content $page -Unexpected 'auth-design-scenery-v1.png' -Message 'A-06 must use the selected red-hall ink backdrop'
foreach ($forbidden in @( foreach ($forbidden in @(
'(?s)\.status-panel\s*\{[^}]*\bborder\s*:', '(?s)\.status-panel\s*\{[^}]*\bborder\s*:',
'(?s)\.status-panel\s*\{[^}]*\bbackground\s*:', '(?s)\.status-panel\s*\{[^}]*\bbackground\s*:',
+43 -38
View File
@@ -1,93 +1,98 @@
const assert = require('assert') const assert = require('assert')
const baseUrl = 'http://localhost:5173/#/pages/auth/a06-auth-status' const baseUrl = 'http://localhost:5173/#/pages/auth/a06-auth-status'
const text = (base64) => Buffer.from(base64, 'base64').toString('utf8')
const expected = {
normal: text('55m75b2V5pyq5a6M5oiQ'),
restricted: text('6LSm5Y+35pqC5pe25Y+X6ZmQ'),
help: text('6LSm5Y+355Sz6K+J5Yqf6IO95b6F5o6l5YWl')
}
const sleep = (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds)) const sleep = (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds))
const connect = async () => { const connect = async () => {
const pages = await (await fetch('http://127.0.0.1:9222/json/list')).json() const port = process.env.CHROME_DEBUGGING_PORT || '9222'
const page = pages.find((item) => item.type === 'page' && item.url.includes('localhost:5173')) const pages = await (await fetch(`http://127.0.0.1:${port}/json/list`)).json()
if (!page) throw new Error('Chrome debugging has no localhost:5173 page') const page = pages.find((item) => item.type === 'page' && item.url.startsWith('http://localhost:5173'))
if (!page) throw new Error('Chrome debugging has no application page')
const socket = new WebSocket(page.webSocketDebuggerUrl) const socket = new WebSocket(page.webSocketDebuggerUrl)
await new Promise((resolve, reject) => { await new Promise((resolve, reject) => {
socket.addEventListener('open', resolve, { once: true }) socket.addEventListener('open', resolve, { once: true })
socket.addEventListener('error', reject, { once: true }) socket.addEventListener('error', reject, { once: true })
}) })
let id = 0 let id = 0
const pending = new Map() const pending = new Map()
const exceptions = []
socket.addEventListener('message', (event) => { socket.addEventListener('message', (event) => {
const message = JSON.parse(event.data) const message = JSON.parse(event.data)
if (message.method === 'Runtime.exceptionThrown') exceptions.push(message.params.exceptionDetails.text)
const request = pending.get(message.id) const request = pending.get(message.id)
if (!request) return if (!request) return
pending.delete(message.id) pending.delete(message.id)
if (message.error) request.reject(new Error(message.error.message)) if (message.error) request.reject(new Error(message.error.message))
else request.resolve(message.result) else request.resolve(message.result)
}) })
const send = (method, params = {}) => new Promise((resolve, reject) => { const send = (method, params = {}) => new Promise((resolve, reject) => {
id += 1 id += 1
pending.set(id, { resolve, reject }) pending.set(id, { resolve, reject })
socket.send(JSON.stringify({ id, method, params })) socket.send(JSON.stringify({ id, method, params }))
}) })
return { socket, send, exceptions }
return { socket, send }
} }
const valueOf = async (send, expression) => { const valueOf = async (send, expression) => {
const result = await send('Runtime.evaluate', { expression, returnByValue: true }) const result = await send('Runtime.evaluate', { expression, returnByValue: true })
if (result.exceptionDetails) throw new Error(result.exceptionDetails.exception?.description || result.exceptionDetails.text)
return result.result?.value return result.result?.value
} }
const waitFor = async (send, expression, message) => { const waitFor = async (send, expression, message) => {
for (let attempt = 0; attempt < 30; attempt += 1) { for (let attempt = 0; attempt < 40; attempt += 1) {
if (await valueOf(send, expression)) return if (await valueOf(send, expression)) return
await sleep(100) await sleep(100)
} }
throw new Error(message) throw new Error(message)
} }
const navigate = async (send, url, title) => { const navigate = async (send, status, title) => {
await send('Page.navigate', { url }) const routeQuery = status ? `?status=${status}` : ''
await waitFor(send, `location.href === ${JSON.stringify(url)}`, `Did not navigate to ${url}`) const targetUrl = `http://localhost:5173/?runtime=${Date.now()}#/pages/auth/a06-auth-status${routeQuery}`
await send('Page.reload') await send('Page.navigate', { url: targetUrl })
await waitFor(send, `location.href === ${JSON.stringify(url)}`, `Reload did not retain ${url}`) await waitFor(send, `document.querySelector('.status-title')?.textContent === ${JSON.stringify(title)}`, `A06 did not render ${title}`)
try {
await waitFor(send, `document.querySelector('.status-title')?.innerText === ${JSON.stringify(title)}`, `A06 did not render ${title}`)
} catch (error) {
const heading = await valueOf(send, "document.querySelector('.status-title')?.innerText")
throw new Error(`${error.message}; current heading: ${heading}`)
}
} }
const run = async () => { const run = async () => {
const { socket, send } = await connect() const { socket, send, exceptions } = await connect()
try { try {
await send('Page.enable') await send('Page.enable')
await send('Runtime.enable') await send('Runtime.enable')
await navigate(send, baseUrl, expected.normal) for (const size of [{ width: 320, height: 568 }, { width: 360, height: 800 }, { width: 412, height: 915 }]) {
await valueOf(send, "document.querySelector('.status-primary')?.click()") await send('Emulation.setDeviceMetricsOverride', { ...size, deviceScaleFactor: 1, mobile: true })
await waitFor(send, "location.href.includes('/pages/auth/a01-entry')", 'Default A06 action did not return to A01') await navigate(send, 'risk', '账号存在安全风险')
const metrics = await valueOf(send, `({
width: document.documentElement.scrollWidth,
pageClientHeight: document.querySelector('.auth-page').clientHeight,
pageScrollHeight: document.querySelector('.auth-page').scrollHeight
})`)
assert(metrics.width <= size.width + 1, `A06 has horizontal overflow at ${size.width}x${size.height}`)
if (size.width === 320) assert(metrics.pageScrollHeight > metrics.pageClientHeight, 'A06 must scroll naturally at 320x568')
}
await navigate(send, `${baseUrl}?status=restricted`, expected.restricted) for (const state of [
await valueOf(send, "document.querySelector('.status-primary')?.click()") { query: 'frozen', title: '账号已被冻结' },
await waitFor(send, `document.body.innerText.includes(${JSON.stringify(expected.help)})`, 'Restricted A06 action did not show the local help notice') { query: 'disabled', title: '账号已被停用' },
{ query: 'risk', title: '账号存在安全风险' },
{ query: 'failed', title: '账号存在安全风险' }
]) {
await navigate(send, state.query, state.title)
assert.strictEqual(await valueOf(send, "document.querySelectorAll('.status-detail').length"), 3, `A06 ${state.query} is missing reason/impact/recovery details`)
}
await navigate(send, `${baseUrl}?status=register-pending`, text('5rOo5YaM5bCa5pyq5a6M5oiQ')) await valueOf(send, "document.querySelector('.status-primary').click()")
await valueOf(send, "document.querySelector('.status-primary')?.click()") await waitFor(send, "Boolean(document.querySelector('.recovery-layer'))", 'A06 did not open the custom recovery layer')
await waitFor(send, "location.href.includes('/pages/auth/a04-register')", 'Registration-pending A06 action did not open A04') await valueOf(send, "document.querySelector('.recovery-action').click()")
await waitFor(send, "!document.querySelector('.recovery-layer')", 'A06 recovery layer did not close')
await valueOf(send, "document.querySelector('.status-secondary').click()")
await waitFor(send, "Boolean(document.querySelector('.login-tab')) && (location.hash === '#/' || location.href.includes('/pages/auth/a01-entry'))", 'A06 did not return to A01')
assert.deepStrictEqual(exceptions, [], `A06 raised browser exceptions: ${exceptions.join('; ')}`)
process.stdout.write('A06-AUTH-STATUS-RUNTIME-SMOKE PASS\n') process.stdout.write('A06-AUTH-STATUS-RUNTIME-SMOKE PASS\n')
} finally { } finally {
try { await send('Emulation.clearDeviceMetricsOverride') } catch (_) {}
socket.close() socket.close()
} }
} }
+12 -1
View File
@@ -45,11 +45,22 @@ if ($captureScript -notmatch 'await waitForFreshDocument\(send, documentTimeOrig
if ($captureScript -notmatch "action !== 'fresh-navigation'") { if ($captureScript -notmatch "action !== 'fresh-navigation'") {
throw 'Chrome capture must support fresh-navigation for root pages whose tab lifecycle closes a reloaded CDP target' throw 'Chrome capture must support fresh-navigation for root pages whose tab lifecycle closes a reloaded CDP target'
} }
foreach ($g06Action in @('g06-results', 'g06-empty')) { foreach ($g06Action in @('g06-results', 'g06-empty', 'g06-invite')) {
if ($captureScript -notmatch [regex]::Escape($g06Action)) { if ($captureScript -notmatch [regex]::Escape($g06Action)) {
throw "Chrome capture must support the $g06Action visual state" throw "Chrome capture must support the $g06Action visual state"
} }
} }
foreach ($a01Action in @('password-tab', 'sms-tab')) {
if ($captureScript -notmatch [regex]::Escape($a01Action)) {
throw "Chrome capture must support the $a01Action A01 state"
}
}
if ($captureScript -notmatch 'const prepareA01LoginState = async \(send, action\) =>') {
throw 'Chrome capture must wait for A01 login tabs before switching state'
}
if ($captureScript -notmatch 'await prepareA01LoginState\(send, action\)') {
throw 'Chrome capture must verify the requested A01 state before taking a screenshot'
}
if ($captureScript -notmatch 'const prepareG06State = async \(send, action\) =>') { if ($captureScript -notmatch 'const prepareG06State = async \(send, action\) =>') {
throw 'Chrome capture must prepare G06 search states through the real input and action controls' throw 'Chrome capture must prepare G06 search states through the real input and action controls'
} }
+3 -1
View File
@@ -26,10 +26,12 @@ if ($paths -contains 'pages/genealogy/g02-empty-genealogies') { throw 'G02 route
foreach ($required in @( foreach ($required in @(
'forceEmptyState', 'forceEmptyState',
'query.get("state") === "empty"', 'query.get("state") || "default"',
'g01-empty-panel.png', 'g01-empty-panel.png',
'empty-create-action', 'empty-create-action',
'empty-search-action', 'empty-search-action',
'empty-invite-action',
'empty-create-note',
'const createGenealogy = () =>', 'const createGenealogy = () =>',
'/pages/genealogy/g03-create-genealogy', '/pages/genealogy/g03-create-genealogy',
'/pages/genealogy/g06-search-genealogies', '/pages/genealogy/g06-search-genealogies',
+43 -2
View File
@@ -13,8 +13,10 @@ const connect = async () => {
let id = 0 let id = 0
const pending = new Map() const pending = new Map()
const exceptions = []
socket.addEventListener('message', (event) => { socket.addEventListener('message', (event) => {
const message = JSON.parse(event.data) const message = JSON.parse(event.data)
if (message.method === 'Runtime.exceptionThrown') exceptions.push(message.params.exceptionDetails.text)
const request = pending.get(message.id) const request = pending.get(message.id)
if (!request) return if (!request) return
pending.delete(message.id) pending.delete(message.id)
@@ -28,11 +30,12 @@ const connect = async () => {
socket.send(JSON.stringify({ id, method, params })) socket.send(JSON.stringify({ id, method, params }))
}) })
return { socket, send } return { socket, send, exceptions }
} }
const valueOf = async (send, expression) => { const valueOf = async (send, expression) => {
const result = await send('Runtime.evaluate', { expression, returnByValue: true }) const result = await send('Runtime.evaluate', { expression, returnByValue: true })
if (result.exceptionDetails) throw new Error(result.exceptionDetails.exception?.description || result.exceptionDetails.text)
return result.result?.value return result.result?.value
} }
@@ -64,7 +67,7 @@ const openEmptyG01 = async (send) => {
} }
const run = async () => { const run = async () => {
const { socket, send } = await connect() const { socket, send, exceptions } = await connect()
try { try {
await send('Page.enable') await send('Page.enable')
@@ -78,6 +81,10 @@ const run = async () => {
await valueOf(send, "document.querySelector('.empty-search-action')?.click()") await valueOf(send, "document.querySelector('.empty-search-action')?.click()")
await waitFor(send, "location.href.includes('/pages/genealogy/g06-search-genealogies')", 'G01 empty search action did not open G06') await waitFor(send, "location.href.includes('/pages/genealogy/g06-search-genealogies')", 'G01 empty search action did not open G06')
await openEmptyG01(send)
await valueOf(send, "document.querySelector('.empty-invite-action')?.click()")
await waitFor(send, "location.href.includes('/pages/genealogy/g06-search-genealogies?mode=invite')", 'G01 empty invite action did not open G06 invite mode')
const defaultUrl = nextUrl() const defaultUrl = nextUrl()
await send('Page.navigate', { url: defaultUrl }) await send('Page.navigate', { url: defaultUrl })
await waitFor(send, "location.href.endsWith('/pages/genealogy/g01-my-genealogies')", 'Did not navigate to default G01') await waitFor(send, "location.href.endsWith('/pages/genealogy/g01-my-genealogies')", 'Did not navigate to default G01')
@@ -85,9 +92,43 @@ const run = async () => {
if (await valueOf(send, "Boolean(document.querySelector('.genealogy-empty-state'))")) { if (await valueOf(send, "Boolean(document.querySelector('.genealogy-empty-state'))")) {
throw new Error('Default G01 must not render the empty state') throw new Error('Default G01 must not render the empty state')
} }
if ((await valueOf(send, "document.querySelectorAll('.application-record').length")) !== 3) {
throw new Error('Default G01 must render the application status group')
}
await valueOf(send, "document.querySelector('.create-action').click()")
await waitFor(send, "Boolean(document.querySelector('.add-dialog-layer'))", 'G01 add action did not open the custom add dialog')
if ((await valueOf(send, "document.querySelectorAll('.add-dialog .dialog-primary, .add-dialog .dialog-secondary, .add-dialog .dialog-create').length")) !== 3) {
throw new Error('G01 add dialog must keep search, invite, and create visible together')
}
await valueOf(send, "document.querySelector('.dialog-close').click()")
await waitFor(send, "!document.querySelector('.add-dialog-layer')", 'G01 add dialog did not close')
await valueOf(send, "document.querySelector('.current-slip').click()")
await waitFor(send, "Boolean(document.querySelector('.genealogy-switcher-layer'))", 'G01 current genealogy did not open the switcher')
await valueOf(send, "document.querySelectorAll('.switcher-item')[1].click()")
await waitFor(send, "document.querySelector('.current-name')?.textContent.includes('汤氏宗谱')", 'G01 switcher did not change the displayed current genealogy')
for (const state of ['loading', 'error']) {
const stateUrl = nextUrl(`?state=${state}`)
await send('Page.navigate', { url: stateUrl })
await waitFor(send, `Boolean(document.querySelector('.state-panel--${state}'))`, `G01 did not render ${state}`)
}
for (const size of [{ width: 320, height: 568 }, { width: 360, height: 800 }, { width: 412, height: 915 }]) {
await send('Emulation.setDeviceMetricsOverride', { ...size, deviceScaleFactor: 1, mobile: true })
const responsiveUrl = nextUrl()
await send('Page.navigate', { url: responsiveUrl })
await waitFor(send, "Boolean(document.querySelector('.current-slip'))", `G01 did not render at ${size.width}x${size.height}`)
const width = await valueOf(send, 'document.documentElement.scrollWidth')
if (width > size.width + 1) throw new Error(`G01 has horizontal overflow at ${size.width}x${size.height}`)
}
if (exceptions.length) throw new Error(`G01 raised browser exceptions: ${exceptions.join('; ')}`)
process.stdout.write('G01-EMPTY-STATE-RUNTIME-SMOKE PASS\n') process.stdout.write('G01-EMPTY-STATE-RUNTIME-SMOKE PASS\n')
} finally { } finally {
try { await send('Emulation.clearDeviceMetricsOverride') } catch (_) {}
socket.close() socket.close()
} }
} }
+9
View File
@@ -59,6 +59,15 @@ if ($page -match "from '@/utils/api\.js'") { throw 'G-01 visual phase must not c
foreach ($state in @('isLoading', 'hasGenealogies', 'createdGenealogies', 'joinedGenealogies')) { foreach ($state in @('isLoading', 'hasGenealogies', 'createdGenealogies', 'joinedGenealogies')) {
if ($page -notmatch $state) { throw "G-01 is missing presentation state: $state" } if ($page -notmatch $state) { throw "G-01 is missing presentation state: $state" }
} }
foreach ($state in @('hasError', 'applicationRecords', 'addDialogVisible', 'switcherVisible')) {
if ($page -notmatch $state) { throw "G-01 is missing current acceptance state: $state" }
}
foreach ($required in @('class="add-dialog-layer"', 'class="genealogy-switcher-layer"', 'application-section')) {
if ($page -notmatch [regex]::Escape($required)) { throw "G-01 is missing current acceptance structure: $required" }
}
foreach ($nativeUi in @('uni.showToast', 'uni.showModal', 'uni.showLoading', 'uni.showActionSheet')) {
if ($page -match [regex]::Escape($nativeUi)) { throw "G-01 must not use native UniApp UI: $nativeUi" }
}
foreach ($asset in @('shortcut-tree.png', 'shortcut-members.png', 'shortcut-generation-poem.png', 'shortcut-application.png', 'add.png')) { foreach ($asset in @('shortcut-tree.png', 'shortcut-members.png', 'shortcut-generation-poem.png', 'shortcut-application.png', 'add.png')) {
if ($page -notmatch [regex]::Escape($asset)) { throw "G-01 does not consume $asset" } if ($page -notmatch [regex]::Escape($asset)) { throw "G-01 does not consume $asset" }
} }
+10 -4
View File
@@ -29,11 +29,13 @@ foreach ($required in @(
'query.get("step") === "ancestor"', 'query.get("step") === "ancestor"',
'window.addEventListener("hashchange", syncFlowFromRoute)', 'window.addEventListener("hashchange", syncFlowFromRoute)',
'step=ancestor&genealogyId=', 'step=ancestor&genealogyId=',
'appApi.createGenealogy', 'const createState = ref("form");',
'genealogyContext.setCurrentGenealogyId', 'const ancestorState = ref("form");',
'appApi.createPerson', 'const fieldErrors = reactive({',
'const duplicateReminderVisible = ref(false);',
'class="duplicate-reminder-layer"',
'if (isSubmitting.value) return;', 'if (isSubmitting.value) return;',
'/pages/tree/t01-tree-overview?genealogyId=', '/pages/genealogy/g05-genealogy-overview?genealogyId=',
'g03-create-flow-panel.png', 'g03-create-flow-panel.png',
'root-header-cinnabar.jpg', 'root-header-cinnabar.jpg',
'a01-primary-button.png', 'a01-primary-button.png',
@@ -43,6 +45,10 @@ foreach ($required in @(
Assert-Contains -Content $g03 -Expected $required -Message "Missing G03 flow contract: $required" 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=')) {
if ($g03 -match [regex]::Escape($forbidden)) { throw "G03 retains forbidden implementation: $forbidden" }
}
foreach ($className in @('create-flow-panel', 'flow-primary-action')) { foreach ($className in @('create-flow-panel', 'flow-primary-action')) {
Assert-NoCssSurface -Content $g03 -ClassName $className Assert-NoCssSurface -Content $g03 -ClassName $className
} }
+23 -4
View File
@@ -2,7 +2,7 @@ const sleep = (milliseconds) => new Promise((resolve) => setTimeout(resolve, mil
const connect = async () => { const connect = async () => {
const pages = await (await fetch('http://127.0.0.1:9222/json/list')).json() const pages = await (await fetch('http://127.0.0.1:9222/json/list')).json()
const page = pages.find((item) => item.type === 'page' && item.url.includes('localhost:5173')) const page = pages.find((item) => item.type === 'page' && item.url.startsWith('http://localhost:5173'))
if (!page) throw new Error('Chrome debugging has no localhost:5173 page') if (!page) throw new Error('Chrome debugging has no localhost:5173 page')
const socket = new WebSocket(page.webSocketDebuggerUrl) const socket = new WebSocket(page.webSocketDebuggerUrl)
@@ -13,8 +13,10 @@ const connect = async () => {
let id = 0 let id = 0
const pending = new Map() const pending = new Map()
const exceptions = []
socket.addEventListener('message', (event) => { socket.addEventListener('message', (event) => {
const message = JSON.parse(event.data) const message = JSON.parse(event.data)
if (message.method === 'Runtime.exceptionThrown') exceptions.push(message.params.exceptionDetails.text)
const request = pending.get(message.id) const request = pending.get(message.id)
if (!request) return if (!request) return
pending.delete(message.id) pending.delete(message.id)
@@ -28,11 +30,12 @@ const connect = async () => {
socket.send(JSON.stringify({ id, method, params })) socket.send(JSON.stringify({ id, method, params }))
}) })
return { socket, send } return { socket, send, exceptions }
} }
const valueOf = async (send, expression) => { const valueOf = async (send, expression) => {
const result = await send('Runtime.evaluate', { expression, returnByValue: true }) const result = await send('Runtime.evaluate', { expression, returnByValue: true })
if (result.exceptionDetails) throw new Error(result.exceptionDetails.exception?.description || result.exceptionDetails.text)
return result.result?.value return result.result?.value
} }
@@ -70,7 +73,7 @@ const openDefaultG03 = async (send) => {
} }
const run = async () => { const run = async () => {
const { socket, send } = await connect() const { socket, send, exceptions } = await connect()
try { try {
await send('Page.enable') await send('Page.enable')
@@ -79,23 +82,39 @@ const run = async () => {
await openDefaultG03(send) await openDefaultG03(send)
await setInput(send, 0, '汤') await setInput(send, 0, '汤')
await setInput(send, 1, '自动验证汤氏家谱') await setInput(send, 1, '自动验证汤氏家谱')
await setInput(send, 3, '河南·洛阳')
await sleep(100)
await valueOf(send, "document.querySelector('.flow-primary-action')?.click()") await valueOf(send, "document.querySelector('.flow-primary-action')?.click()")
await waitFor(send, "Boolean(document.querySelector('.duplicate-reminder-layer'))", 'G03 did not show the existing-genealogy reminder before creation')
await valueOf(send, "document.querySelector('.duplicate-reminder__confirm').click()")
await waitFor(send, "location.href.includes('g03-create-genealogy?step=ancestor&genealogyId=')", 'Creating a genealogy did not open G03 ancestor step') await waitFor(send, "location.href.includes('g03-create-genealogy?step=ancestor&genealogyId=')", 'Creating a genealogy did not open G03 ancestor step')
await waitFor(send, "Boolean(document.querySelector('.intro-field'))", 'G03 ancestor step did not render') await waitFor(send, "Boolean(document.querySelector('.intro-field'))", 'G03 ancestor step did not render')
await send('Page.reload') await send('Page.reload')
await waitFor(send, "Boolean(document.querySelector('.intro-field'))", 'G03 ancestor step did not survive reload') await waitFor(send, "Boolean(document.querySelector('.intro-field'))", 'G03 ancestor step did not survive reload')
await setInput(send, 0, '汤始祖') await setInput(send, 0, '汤始祖')
await sleep(100)
await valueOf(send, "document.querySelector('.flow-primary-action')?.click()") await valueOf(send, "document.querySelector('.flow-primary-action')?.click()")
await waitFor(send, "location.href.includes('/pages/tree/t01-tree-overview?genealogyId=')", 'Saving the first ancestor did not open T01') await waitFor(send, "Boolean(document.querySelector('.flow-success-layer'))", 'Saving the first ancestor did not show the custom success result')
await valueOf(send, "document.querySelector('.flow-success-dialog__action').click()")
await waitFor(send, "location.href.includes('/pages/genealogy/g05-genealogy-overview?genealogyId=')", 'Saving the first ancestor did not open G05')
await openDefaultG03(send) await openDefaultG03(send)
if (await valueOf(send, "Boolean(document.querySelector('.intro-field'))")) { if (await valueOf(send, "Boolean(document.querySelector('.intro-field'))")) {
throw new Error('Default G03 must not render the ancestor step') throw new Error('Default G03 must not render the ancestor step')
} }
for (const size of [{ width: 320, height: 568 }, { width: 360, height: 800 }, { width: 412, height: 915 }]) {
await send('Emulation.setDeviceMetricsOverride', { ...size, deviceScaleFactor: 1, mobile: true })
await openDefaultG03(send)
const width = await valueOf(send, 'document.documentElement.scrollWidth')
if (width > size.width + 1) throw new Error(`G03 has horizontal overflow at ${size.width}x${size.height}`)
}
if (exceptions.length) throw new Error(`G03 raised browser exceptions: ${exceptions.join('; ')}`)
process.stdout.write('G03-CREATE-FLOW-RUNTIME-SMOKE PASS\n') process.stdout.write('G03-CREATE-FLOW-RUNTIME-SMOKE PASS\n')
} finally { } finally {
try { await send('Emulation.clearDeviceMetricsOverride') } catch (_) {}
socket.close() socket.close()
} }
} }
+8
View File
@@ -25,9 +25,14 @@ if ($g01 -match 'g05-genealogy-overview\?id=') { throw 'G01 must not retain the
foreach ($required in @( foreach ($required in @(
"const overviewState = ref('loading')", "const overviewState = ref('loading')",
"query.genealogyId", "query.genealogyId",
"const viewMode = ref('member')",
"const accessRole = ref('owner')",
'const overviewFixture = {',
'overview-ready', 'overview-ready',
'overview-public',
'overview-state--empty', 'overview-state--empty',
'overview-state--error', 'overview-state--error',
'overview-state--no-permission',
'g05-overview-surface.png', 'g05-overview-surface.png',
'/pages/genealogy/g03-create-genealogy?step=ancestor&genealogyId=', '/pages/genealogy/g03-create-genealogy?step=ancestor&genealogyId=',
'/pages/tree/t01-tree-overview?genealogyId=', '/pages/tree/t01-tree-overview?genealogyId=',
@@ -38,6 +43,9 @@ foreach ($required in @(
)) { )) {
Assert-Contains $g05 $required "Missing G05 overview contract: $required" Assert-Contains $g05 $required "Missing G05 overview contract: $required"
} }
foreach ($forbidden in @("from '@/utils/api.js'", 'appApi.', 'uni.showToast', 'uni.showModal', 'uni.showLoading', 'uni.showActionSheet')) {
if ($g05 -match [regex]::Escape($forbidden)) { throw "G05 retains forbidden implementation: $forbidden" }
}
if ($g05 -match 'g04-first-ancestor') { throw 'G05 must not navigate to the removed G04 route' } if ($g05 -match 'g04-first-ancestor') { throw 'G05 must not navigate to the removed G04 route' }
if ($g05 -match 'toJoinApplication') { throw 'G05 must not mislabel the applicant join form as an owner invitation action' } if ($g05 -match 'toJoinApplication') { throw 'G05 must not mislabel the applicant join form as an owner invitation action' }

Some files were not shown because too many files have changed in this diff Show More