验收20%
@@ -4,4 +4,8 @@
|
||||
/docs/design/screens/runtime/
|
||||
/tmp-g01-icon-audit.png
|
||||
/node_modules/
|
||||
**/node_modules/
|
||||
/design-pipeline/generated/
|
||||
/docs/design/assets/a01-vnext/candidates/
|
||||
/docs/design/assets/a01-vnext/review/
|
||||
/.superpowers/
|
||||
|
||||
@@ -43,6 +43,20 @@
|
||||
</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>
|
||||
</template>
|
||||
|
||||
@@ -91,6 +105,7 @@ const query = (() => {
|
||||
return pages[pages.length - 1]?.options || {}
|
||||
})()
|
||||
const formState = ref(query.state === 'success' ? 'success' : query.state === 'conflict' ? 'conflict' : query.state === 'error' ? 'error' : 'form')
|
||||
const conflictHelpVisible = ref(false)
|
||||
const resultCopy = computed(() => ({
|
||||
success: { eyebrow: '本地预览已更新', title: `${config.value.pageTitle}内容已保存`, copy: '返回世系树后可查看新的展示位置;正式数据将在接口阶段接入。' },
|
||||
conflict: { eyebrow: '发现关系冲突', title: '这段关系会造成代际矛盾', copy: '目标成员已经存在父级关系,请先核对原关系,再决定是否调整。' },
|
||||
@@ -104,7 +119,7 @@ const saveForm = () => {
|
||||
}
|
||||
formState.value = 'success'
|
||||
}
|
||||
const showConflictHelp = () => uni.showModal({ title: '关系冲突说明', content: '同一成员不能同时存在两个生父或形成上下代循环。请返回世系树核对原关系。', showCancel: false })
|
||||
const showConflictHelp = () => { conflictHelpVisible.value = true }
|
||||
</script>
|
||||
|
||||
<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; }
|
||||
.conflict-actions { display: flex; gap: 14rpx; margin-top: 30rpx; }
|
||||
.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); } }
|
||||
</style>
|
||||
|
||||
@@ -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 }
|
||||
]
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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')
|
||||
@@ -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 家谱总览
|
||||
|
||||
- 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": []
|
||||
}
|
||||
|
After Width: | Height: | Size: 1.7 MiB |
|
After Width: | Height: | Size: 1.7 MiB |
|
After Width: | Height: | Size: 1.7 MiB |
|
After Width: | Height: | Size: 1.7 MiB |
|
After Width: | Height: | Size: 498 KiB |
|
After Width: | Height: | Size: 2.0 MiB |
|
After Width: | Height: | Size: 1.8 MiB |
|
After Width: | Height: | Size: 477 KiB |
|
After Width: | Height: | Size: 479 KiB |
|
After Width: | Height: | Size: 488 KiB |
|
After Width: | Height: | Size: 626 KiB |
|
After Width: | Height: | Size: 476 KiB |
|
After Width: | Height: | Size: 451 KiB |
|
After Width: | Height: | Size: 693 KiB |
|
After Width: | Height: | Size: 424 KiB |
|
After Width: | Height: | Size: 475 KiB |
|
After Width: | Height: | Size: 420 KiB |
|
After Width: | Height: | Size: 497 KiB |
|
After Width: | Height: | Size: 496 KiB |
|
After Width: | Height: | Size: 698 KiB |
|
After Width: | Height: | Size: 493 KiB |
@@ -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 页全部重新待审核;下方旧“已验收”图片只作为历史视觉基础,不计入本轮完成状态。
|
||||
|
||||
> 本仓库只保留低负担交接证据:每页一张代表图,复杂模块使用联系表。完整 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 COM,ExtendScript 负责建立 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/libvips:Alpha、裁切、合成、缩放、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 流水线和页面,不修改其他页面。
|
||||
@@ -1,9 +1,9 @@
|
||||
# 家谱 APP 换机交接记录
|
||||
|
||||
> 最后更新:2026-07-15
|
||||
> 最后更新:2026-07-16
|
||||
> 用途:更换电脑、重新打开 Codex/GPT 后的唯一接管入口。
|
||||
> 当前阶段:只做页面样式与视觉流程验收;不对接接口,不做功能验收。
|
||||
> 当前工作页:A01 登录页,尚未通过用户验收。
|
||||
> 当前施工停点:A01 至 T04 共 15 页已有内部 H5 候选,均尚未通过用户验收;下一页是 T07 成员目录。
|
||||
|
||||
## 1. 接管后先复述的准确停点
|
||||
|
||||
@@ -12,9 +12,9 @@
|
||||
- `docs/验收规划.md` 当前统计为:**53 个待审核、0 个已验收、0 个返工标记**。
|
||||
- 历史上曾标记通过的 A01、A02、A04、A05、A06、G01、G03 只保留为历史视觉证据,**本轮全部重新审核**。
|
||||
- 当前没有任何页面处于视觉冻结状态;只有用户在本轮明确说“通过”后,对应页面才能改为 `[x]` 并冻结。
|
||||
- 当前停在 **A01 登录页视觉返工**。用户认为整页与选定设计稿仍有明显差距,A01 不得写成“完成”或“通过”。
|
||||
- 最新完成的局部修正是:闭眼图标保留中心瞳孔;短信验证码输入图标改为自生成的“消息气泡 + 三点”,不再复用锁、勾选或临时第三方 SVG。
|
||||
- 下一步不是跳去 G05,也不是批量改其他页面;应先继续完成 A01 的真实运行截图审视、修正和用户确认。
|
||||
- 2026-07-16 已按用户“继续按验收规划自主推进”的最新授权,完成 **A01、A04、A05、A06、G01、G03、G05、G06、G08、G09、G10、G11、G12、T01、T04** 的内部 H5 候选、聚焦合同和运行时检查;这些结果都只是待审候选,不能写成“用户已验收”或“已冻结”。
|
||||
- A01 当前候选已落实:闭眼图标保留中心瞳孔;密码行使用项目现有锁图标;短信验证码输入图标使用自生成的“消息气泡 + 三点”;不得恢复勾选图标或临时第三方 SVG。密码/短信两态代表联系表为 `docs/design/screens/handoff/2026-07-16/A01-current-password-sms-contact-824x915.png`。
|
||||
- 当前施工停在 **T04 新增亲属之后**,下一页严格按 `docs/验收规划.md` 进入 **T07 成员目录**。T01 收敛时删除了未注册路由但仍被 Git 跟踪的旧占位文件 `pages/tree/t02-tree-states.vue`;T04 复核时把共用成员表单残留的原生 `uni.showModal` 替换为项目自定义冲突说明层。用户明早仍需从 A01 开始逐页审核;未明确说“通过”的页面一律保持 `[~]`。
|
||||
|
||||
如果其他文档仍写“54 条路由、7 个已验收或冻结”,那是旧阶段记录。当前状态和验收数量以本文件、实际 `pages.json`、`docs/验收规划.md` 为准。
|
||||
|
||||
@@ -40,7 +40,7 @@ git status --short
|
||||
|
||||
必须保留全部已有未提交改动。不要因为文件是未跟踪状态就删除,也不要用旧提交覆盖当前工作区。
|
||||
|
||||
随后先向用户复述:53 条最终路由、53 页全部待审核、当前无冻结页、当前停在 A01、准备使用的技能、A01 的下一步。复述准确前不要继续修改页面。
|
||||
随后先向用户复述:53 条最终路由、53 页全部待审核、当前无冻结页、内部候选施工停在 T01 之后、用户逐页审核仍从 A01 开始、下一施工页是 T04,以及准备使用的技能。复述准确前不要继续修改页面。
|
||||
|
||||
## 3. 文档权威顺序
|
||||
|
||||
@@ -65,7 +65,7 @@ git status --short
|
||||
- 首次默认密码登录;后续只恢复用户上次选择的密码/短信方式,不保存密码。
|
||||
- 登录在本地字段与协议校验通过后才显示行为验证弹层。
|
||||
- 未勾选协议时使用页内高亮、错误文案和轻微震动,不使用原生 Toast 或原生确认框。
|
||||
- 320×568 下核心入口必须同屏可见,不依赖纵向滚动。
|
||||
- 320×568 下保持正常字号、触控高度和设计比例,允许自然纵向滚动;不得为了全部入口同屏而压扁品牌区或表单。
|
||||
- 微信登录保留入口,真实登录留到功能阶段。
|
||||
- 行为验证当前只审核弹层外观状态;真实 TAC、`validToken` 和接口续接留到功能阶段。
|
||||
|
||||
@@ -91,9 +91,41 @@ git status --short
|
||||
|
||||
- `tests/a01-a02-ui-contract.ps1` 已要求页面引用 `a01-icon-sms-code-v2.png`,并禁止回退到勾选图标和临时 SVG。
|
||||
- 最新真实运行截图已确认短信验证码行显示棕色消息气泡三点图标。
|
||||
- A01 整页仍缺少本轮用户最终确认;深色主题、四档尺寸完整复核和 Android 真机复核均不能写成已通过。
|
||||
- A01 整页仍缺少本轮用户最终确认;当前浅色国风主题的四档尺寸完整复核和 Android 真机复核均不能写成已通过。深色与跟随系统已由用户决定延期到下一版本,不是基础版本验收门槛。
|
||||
- 旧的 `A01-password-icons-412x915.png` 实际仍是短信 Tab 且使用旧临时图标,不能当密码态证据。
|
||||
|
||||
### 4.4 最新资产生产决策
|
||||
|
||||
- 用户已决定停止扩展 Photoshop/PSD 生产链,改用“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. 下一步严格执行顺序
|
||||
|
||||
### 第一步:恢复环境与确认文件完整
|
||||
@@ -112,19 +144,19 @@ http://localhost:5173/#/pages/auth/a01-entry
|
||||
|
||||
换机前用户需要自行把当前修改和未跟踪的最终资产加入 Git 并同步;GPT 不执行 Git 暂存、提交、推送或上传。特别确认本节列出的 A01 PNG 资产、测试、本文件和交接截图已被用户纳入 Git,否则换电脑后会缺图。
|
||||
|
||||
### 第二步:继续 A01,而不是切换页面
|
||||
### 第二步:先让用户从 A01 开始审核现有候选
|
||||
|
||||
1. 打开 A01 密码登录态,确认手机、锁、闭眼/睁眼图标在真实尺寸下清晰且风格统一。
|
||||
2. 打开短信登录态,使用当前生成的消息气泡三点图标;不要恢复锁、勾选或第三方临时 SVG。
|
||||
3. 对照当前选定方向检查整体层级:红色宗祠顶部、完整宣纸卷轴登录区、标题、Tab、输入区、主按钮、微信入口、注册与协议必须形成一个整体,不能只修局部 CSS。
|
||||
4. 优先修用户已指出的明显问题;每轮只处理一个可验证问题,避免盲目大改导致新的比例错误。
|
||||
5. 先完成浅色代表态,再补深色主题;两套主题必须在同一个 A01 验收项内提交。
|
||||
6. 内部检查 320×568、360×640、360×800、412×915;默认只向用户展示一张代表图。密码/短信、明/暗等复杂状态用一张联系表,不连续发送大量对比图。
|
||||
4. 优先修用户在审核时指出的明显问题;返工只触碰当前审核页,避免相邻页面一起漂移。
|
||||
5. 基础版本只完成当前浅色国风主题;深色与跟随系统延期到下一版本,不在 A01 本轮验收项内实施。
|
||||
6. 内部检查 320×568、360×640、360×800、412×915;默认只向用户展示一张代表图。密码/短信等复杂状态用一张联系表,不连续发送大量对比图。
|
||||
7. 用户明确确认 A01 通过后,才把 `docs/验收规划.md` 中 A01 从 `[~]` 改为 `[x]` 并冻结;否则保持 `[~]`,明确返工时改 `[!]`。
|
||||
|
||||
### 第三步:A01 通过后按真实用户流程继续
|
||||
### 第三步:无人审核期间从 T01 继续施工;用户审核时以当前审核页优先
|
||||
|
||||
严格按 `docs/验收规划.md` 的 53 页表推进:
|
||||
截至 2026-07-16,以下 15 页已生成内部候选并完成聚焦检查,但仍全部为 `[~]`:
|
||||
|
||||
```text
|
||||
A01 登录
|
||||
@@ -132,14 +164,20 @@ A01 登录
|
||||
→ A05 重置密码
|
||||
→ A06 阻断登录状态
|
||||
→ G01 我的家谱
|
||||
→ G03 创建家谱/录入始祖
|
||||
→ G05 家谱总览
|
||||
→ G06 搜索/邀请码加入
|
||||
→ G08 加入申请
|
||||
→ G09 我的申请
|
||||
→ G03 创建家谱/录入始祖
|
||||
→ G05 家谱总览
|
||||
→ 后续管理、世系、家族内容、档案、消息和个人中心
|
||||
→ G10 入谱审核
|
||||
→ G11 家谱设置
|
||||
→ G12 字辈诗
|
||||
→ T01 世系树
|
||||
→ T04 新增亲属
|
||||
```
|
||||
|
||||
下一施工页是 `T07 成员目录`,随后继续 T03、T05 等验收表顺序。若用户开始逐页审核或指出返工项,立即停下后续施工,回到用户当前指定页面;只有用户明确说“通过”后才能把该页改为 `[x]` 并冻结。
|
||||
|
||||
真实入口优先。开发者直达路由只用于排错,不能冒充流程可达证据。不同用户身份、空态、加载、失败、权限、审核中、被拒绝、已退出/移除等状态必须在同一页面验收项下覆盖。
|
||||
|
||||
## 6. 必须使用的技能与触发时机
|
||||
@@ -200,7 +238,7 @@ node scripts/capture-chrome-page.js `
|
||||
|
||||
- 不是简单改 CSS;必须同时考虑用户流程、身份、错误操作、状态反馈、弹窗、加载、空态、主题、适配和性能。
|
||||
- 所有提示、确认、结果、底部选择和加载效果使用项目自定义组件;禁止直接展示原生 `uni.showToast`、`uni.showModal`、`uni.showLoading`、`uni.showActionSheet`。
|
||||
- 主题必须提供“浅色、深色、跟随系统”三个选项;深色主题沿用同一国风体系,不使用系统自动反色。
|
||||
- 基础版本固定使用当前浅色国风主题,不展示未生效的主题入口;深色与跟随系统延期到下一版本,并在升级时另建全局主题合同与冻结页面回归计划。
|
||||
- 列表接近底部时使用项目自定义“正在展开更多……”视觉;失败保留已加载内容并允许重试;结束显示“已阅至末尾”。
|
||||
- 兼容 320×568、360×640、360×800、412×915、系统字体放大、状态栏、安全区、软键盘和返回手势。
|
||||
- 动画只使用 `transform`、`opacity` 等低成本属性;避免大面积高频重绘、同步阻塞、重复图片解码和无边界监听。
|
||||
@@ -225,6 +263,14 @@ node scripts/capture-chrome-page.js `
|
||||
|
||||
## 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 当前最小集合:
|
||||
|
||||
```powershell
|
||||
@@ -243,7 +289,7 @@ git diff --check
|
||||
2. 适用的运行时 smoke。
|
||||
3. 真实路由截图并人工查看,不只确认文件生成。
|
||||
4. 四档尺寸内部复核。
|
||||
5. 浅色、深色与跟随系统入口复核。
|
||||
5. 当前浅色国风主题及状态栏配色复核;深色与跟随系统不在基础版本范围内。
|
||||
6. `git diff --check`。
|
||||
7. 用户明确确认。
|
||||
|
||||
@@ -260,20 +306,28 @@ GPT 不操作 Git。用户切换电脑前应自行确认以下内容已纳入版
|
||||
- `tests/a02-route-removal-contract.ps1`
|
||||
- `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-16/` 中 A01 至 G12 的当前代表图
|
||||
- A01 当前页面实际引用的全部新增 PNG 资产
|
||||
- 2026-07-16 新增的各页面聚焦合同、运行时 smoke、截图脚本和实施计划
|
||||
- `static/vendor/tac/`(后续行为验证功能阶段需要)
|
||||
|
||||
`docs/design/screens/runtime/`、`tmp/` 和本机 Chrome 调试目录不能作为换机后的唯一资料来源。最终运行资产必须在 `static/assets/`,长期代表证据必须在 `docs/design/screens/handoff/`,决策与下一步必须写进本文件或 `docs/验收规划.md`。
|
||||
|
||||
## 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 源稿。
|
||||
|
||||
- `.git` 当前约 101.64MB;`git fsck --full` 退出码为 0,只有可忽略的 dangling tree/blob,没有对象损坏报告。
|
||||
- 当前受版本控制的最大单文件约 2.91MB,不存在单文件 100MB 上传阻断。
|
||||
- 本轮需要用户纳入 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/`。
|
||||
- `tmp/` 只允许保存短期诊断图和 ImageGen 中间源,最终资产必须进入 `static/assets/`,换机代表证据必须进入 `docs/design/screens/handoff/`。
|
||||
- 不要执行 `git add .` 后盲目提交;用户应先用 `git status --short --ignored` 确认缓存目录仍为 `!!` 忽略状态。
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
> 更新日期:2026-07-15
|
||||
> 适用范围:Android `uni-app` 用户端
|
||||
> 当前实现:`pages.json` 仍有 54 条路由;A02 已确定并入 A01,完成收敛后的最终验收目标为 53 条路由。历史上的 7 个验收结果仅作为旧视觉证据,不计入本轮完成状态。
|
||||
> 当前实现:`pages.json` 已收敛为 53 条最终路由;A02 已并入 A01。历史上的 7 个验收结果仅作为旧视觉证据,不计入本轮完成状态。
|
||||
> 本文件只管理逐页视觉、页面状态外观、弹窗与加载样式、手机适配和表现质量;不替代 `docs/规划.md` 对项目范围和最终路由的管理,也不承担接口与业务功能验收。功能验收规则后续单独编写。
|
||||
|
||||
## 1. 标记与审核规则
|
||||
@@ -19,7 +19,7 @@
|
||||
|
||||
1. 页面在真实运行环境中打开;流程页优先从上游可见入口进入,尚未实现入口时明确标记 `⚠`,不能冒充流程设计完整。
|
||||
2. 正常、加载、空、失败、权限、校验和成功等适用状态的视觉呈现均已检查;本阶段只验状态外观,不验证后台结果真伪。
|
||||
3. 浅色、深色两套主题在 320×568、360×640、360×800、412×915 四个尺寸均无溢出、裁切、遮挡或错位;“跟随系统”使用当前系统主题,但不得依赖系统自动反色。
|
||||
3. 基础版本只验收当前浅色国风主题,并在 320×568、360×640、360×800、412×915 四个尺寸确认无溢出、裁切、遮挡或错位;深色与跟随系统整体延期到下一版本,不作为本轮 53 页通过条件。
|
||||
4. 默认系统字号和约 1.3 倍系统字号均可完整操作;关键按钮、表单错误、姓名、关系和操作结果不得截断,空间不足时允许合理换行或滚动,不得强行缩小文字或遮挡主操作,并使用超长族谱名、生僻姓名完成边界检查。
|
||||
5. 状态栏、底部手势区、软键盘、系统返回和长文本不会破坏页面。
|
||||
6. 点击反馈、加载、滚动、动画和图片解码在当前静态/Mock 数据下无明显卡顿。
|
||||
@@ -37,9 +37,9 @@
|
||||
|
||||
本轮不沿用任何页面的历史完成标记。A01、A04、A05、A06、G01、G03 也必须重新完成页面结构、视觉状态、兼容性、弹窗加载样式和表现质量检查;用户重新确认后才能标记 `[x]`。
|
||||
|
||||
审核证据默认每页只展示一张 360×800 代表图;需要同时确认明暗主题时,将浅色与深色代表态合成一张联系表,不连续展示两张前后对比图。其余尺寸在内部完成检查,只有发现问题或用户要求时才展开。
|
||||
审核证据默认每页只展示一张 360×800 代表图;复杂状态合成一张联系表,不连续展示多张前后对比图。其余尺寸在内部完成检查,只有发现问题或用户要求时才展开。
|
||||
|
||||
每个页面必须在同一验收项内完成浅色与深色主题,不拆分为两轮:两套主题的代表态、适用关键状态、四档尺寸、代码质量和性能全部通过后,才可提交一张明暗联系表供用户确认;用户确认后两套主题同时标记 `[x]` 并冻结。任一主题未完成或未通过时,整个页面保持 `[~]` 或 `[!]`。
|
||||
基础版本的每个页面只按当前浅色国风主题完成适用关键状态、四档尺寸、代码质量和性能验收;用户确认后标记 `[x]` 并冻结。下一版本引入深色与跟随系统时,属于会影响全部冻结页面的全局视觉变更,必须单独建立升级验收计划,并按冻结回归规则重新复核受影响页面。
|
||||
|
||||
## 2. 已确认的核心用户流程合同
|
||||
|
||||
@@ -53,7 +53,7 @@
|
||||
- A04 注册成功后直接建立登录状态并进入 G01“我的家谱”,不得要求用户返回 A01 重复输入账号密码;注册接口能否返回有效登录凭证在后续功能验收核对,当前页面样式验收先覆盖提交中、成功过渡和进入 G01 的视觉连续性。
|
||||
- A05 重置密码成功后不自动登录:先显示短暂的自定义成功结果,再返回 A01,自动保留刚才使用的手机号、切换到密码登录并聚焦密码框,让用户使用新密码完成登录;不得清空手机号或要求重新寻找登录方式。
|
||||
- A01 内部直接处理密码错误、验证码错误和手机号未注册,使用就近校验,不跳转 A06;微信授权取消仍留在 A01,视为用户取消而非登录失败。A06 只承接账号冻结、停用、风险限制或其他无法继续登录的阻断性状态,必须说明原因、影响范围和明确恢复入口,不得成为普通错误的汇总页。
|
||||
- A01 在 320×568 最小验收尺寸下不得依赖纵向滚动:品牌区、密码/验证码切换、当前登录表单、登录按钮、微信登录、注册、忘记密码和协议入口必须同屏可见。小屏允许明显压缩品牌头与山水装饰高度,但不得缩小输入框、按钮和协议的有效触控区。
|
||||
- A01 在 320×568 最小验收尺寸下允许自然纵向滚动,不为追求全部核心入口同屏而压缩品牌头、表单、字号或有效触控区;必须保证首屏层级清楚、滚动顺畅、无横向溢出,底部协议和主操作可完整滚动到达。
|
||||
- A01 未勾选协议时采用页内校验:协议区域轻微震动并高亮,下方显示明确错误文案;不弹原生 Toast 或确认弹窗,不跳转。用户勾选后错误样式立即消失。
|
||||
- A02 从最终路由和验收表移除,不保留兼容入口;A04、A05、A06 的登录返回统一指向 A01。实际代码收敛完成前,`pages.json` 的 54 条是实施现状,不是最终验收数量。
|
||||
- A01、A04、A05 共用同一行为验证弹层,但触发时机不同。A01 登录在账号信息、协议等本地校验通过并点击“登录”后弹出,验证成功后自动继续登录;A05 忘记密码在用户请求发送短信验证码前弹出,验证成功后自动发送短信,重置提交时不重复验证。A04 注册当前因短信注册接口尚未提供,临时按 A01 方式在完整填写并点击“注册”后弹出;注册短信接口提供即为临时契约的移除条件,届时必须迁移为“发送短信验证码前验证”,并删除注册提交前的旧触发路径,不保留双重验证。用户关闭或验证失败时必须保留原表单内容。当前页面样式阶段只审核弹层初始、拖动、成功、失败和关闭状态,不审核真实滑动轨迹、`validToken` 或接口自动续接。原始 TAC 运行库保存在 `static/vendor/tac/`,留待功能阶段使用。
|
||||
@@ -122,7 +122,7 @@
|
||||
|
||||
所有表单采用“就近显示错误”:单字段错误显示在对应输入框下;关联错误同时标记相关字段;页面级错误显示在提交区附近;网络或系统错误使用自定义结果弹层。提交时错误字段执行一次 200–260ms、3–5px 的横向视觉抖动,只使用 `transform`,不调用手机振动、不循环闪烁;动画必须与静态错误描边和文字同时存在,减少动态效果时仅保留静态提示。
|
||||
|
||||
主题模式统一提供“浅色、深色、跟随系统”三个选项。入口放在 M10“设置与关于”页面顶部,由 M01 的“设置”菜单进入,不新增独立路由。浅色与深色共用页面结构、组件层级、交互规则和国风视觉语言,但必须通过统一主题变量分别定义背景、文字、边框、遮罩、状态色、状态栏图标和必要的位图资产;严禁使用系统自动反色或在页面内散落颜色覆盖。用户选择后当前页面立即生效,不重启、不重新进入页面、不闪白屏;页面、弹窗和状态栏使用一次 150–200ms 的轻微明暗过渡同步完成切换,不得出现局部延迟或颜色割裂。当前页面样式验收检查三选项控件和两套主题的完整视觉;选项持久化、重启恢复、系统主题变化后的自动联动归入后续功能验收。
|
||||
基础版本固定使用当前浅色国风主题,M10“设置与关于”不展示尚未生效的外观模式入口,也不实现主题切换、持久化或跟随系统。深色与跟随系统统一延期到下一版本;升级时继续使用统一主题变量管理背景、文字、边框、遮罩、状态色、状态栏图标和必要位图资产,禁止依赖系统自动反色或在页面内散落覆盖样式。下一版本的主题切换、过渡、持久化和系统联动必须另建产品合同与验收计划。
|
||||
|
||||
所有分页列表共用同一套底部加载视觉:接近列表末尾时在内容流内显示轻量水墨/印章动效和“正在展开更多……”;失败时保留已加载内容并显示“加载失败,点击重试”;数据结束时显示“已阅至末尾”;数据不足一页时不强行显示结束文案,也不循环触发加载。底部分页不得使用全屏遮罩、清空现有列表、改变当前滚动位置或造成列表跳动。
|
||||
|
||||
@@ -158,14 +158,14 @@
|
||||
| `[ ]` | C05 | 页面级加载 | 首屏使用结构稳定的骨架屏;按钮在原位置显示内部进度;局部卡片仅覆盖局部;列表统一下拉刷新与底部加载状态;仅在页面结构完全不可预知时使用全屏加载,不闪白屏或突然跳布局 |
|
||||
| `[ ]` | C06 | 按钮级加载 | 登录、提交、保存期间立即禁用重复点击并显示明确进度 |
|
||||
| `[ ]` | C07 | 权限与进度 | 相册、上传、分享权限及上传进度、取消、失败重试均有自定义反馈 |
|
||||
| `[ ]` | C08 | 主题与四尺寸适配 | 浅色、深色均通过 320×568、360×640、360×800、412×915;跟随系统不自动反色,状态栏、弹窗和页面颜色一致且无溢出、裁切和错位 |
|
||||
| `[ ]` | C08 | 基础主题与四尺寸适配 | 当前浅色国风主题通过 320×568、360×640、360×800、412×915;状态栏、弹窗和页面颜色一致且无溢出、裁切和错位;深色与跟随系统延期到下一版本 |
|
||||
| `[ ]` | C09 | 安全区适配 | 状态栏、刘海、底部手势区和固定导航不遮挡页面内容或操作 |
|
||||
| `[ ]` | C10 | 软键盘适配 | 输入框、校验信息和提交按钮不被软键盘遮挡,页面可滚动到当前字段 |
|
||||
| `[ ]` | C11 | 字体与长文本 | 默认字号与约 1.3 倍系统字号下,Android 字体回退、超长族谱名、生僻姓名和长文案均不重叠、不截断关键含义、不遮挡主操作 |
|
||||
| `[ ]` | C12 | 触控与误操作 | 点击区不小于约 44dp;连续点击不重复提交;返回前保护未保存内容 |
|
||||
| `[ ]` | C13 | 图片性能 | 图片按显示槽位使用;避免同页同时解码多张超大位图;屏外图片延迟加载 |
|
||||
| `[ ]` | C14 | 列表性能 | 使用至少 500 条 Mock 数据测试;长列表分批渲染并使用稳定 `key`;下拉刷新和触底加载均保留已有内容、滚动位置与布局高度;短列表不循环触发,滚动时不反复重绘整页 |
|
||||
| `[ ]` | C15 | 动画性能 | 优先使用 `transform` 和 `opacity`;不使用大面积模糊、复杂滤镜和重阴影动画;主题切换在 150–200ms 内同步完成且不白屏、不持续掉帧 |
|
||||
| `[ ]` | C15 | 动画性能 | 优先使用 `transform` 和 `opacity`;不使用大面积模糊、复杂滤镜和重阴影动画;页面过渡不白屏、不持续掉帧 |
|
||||
| `[ ]` | C16 | 响应反馈 | 点击后 100ms 内出现反馈;超过 300ms 的操作必须显示自定义加载状态 |
|
||||
| `[ ]` | C17 | 页面生命周期 | 离开页面时清理定时器、监听器、上传任务和动画状态,避免越用越卡 |
|
||||
| `[ ]` | C18 | 路由压力 | 同一详情连续进入/退出 20 次,快速切 Tab、重复开关弹窗,不白屏、不串状态、不重复堆栈、不残留遮罩且不逐次变慢 |
|
||||
@@ -178,7 +178,7 @@
|
||||
|
||||
| 状态 | 序号 | 页面 | 真实入口与必须覆盖的可能性 |
|
||||
| --- | ---: | --- | --- |
|
||||
| `[~]` | 1 | A01 登录 | APP 首次打开;首次默认密码登录、后续恢复上次密码/短信方式,品牌区、微信登录入口、注册、忘记密码、协议未勾选/已勾选、空输入、错误提示、完整输入后点击登录再显示行为验证、按钮加载、键盘遮挡;320×568 全部核心入口同屏且不滚动 |
|
||||
| `[~]` | 1 | A01 登录 | APP 首次打开;首次默认密码登录、后续恢复上次密码/短信方式,品牌区、微信登录入口、注册、忘记密码、协议未勾选/已勾选、空输入、错误提示、完整输入后点击登录再显示行为验证、按钮加载、键盘遮挡;320×568 保持正常比例并允许自然纵向滚动 |
|
||||
| `[~]` | 2 | A04 注册账号 | A01→注册;协议、手机号错误、密码不一致、注册中、成功/失败页面样式;当前临时在完整填写并点击注册后显示行为验证,注册短信接口提供后迁移为发送验证码前验证;注册成功后自动建立登录状态并进入 G01 |
|
||||
| `[~]` | 3 | A05 重置密码 | A01→忘记密码;手机号、发送短信前行为验证、验证码、新密码、两次不一致、重设中、成功/失败页面样式;成功后返回 A01,保留手机号、切到密码登录并聚焦密码框,不自动登录 |
|
||||
| `[~]` ⚠ | 4 | A06 登录状态 | A01 登录结果→阻断状态;仅覆盖账号冻结、停用、风险限制等无法继续登录的状态,必须展示原因、影响范围和恢复入口;普通密码/验证码错误、未注册及微信取消均留在 A01;当前缺少真实结果跳转 |
|
||||
@@ -265,7 +265,7 @@
|
||||
| `[~]` ⚠ | 50 | M07 意见反馈 | 应由 M06 进入;校验、提交中、成功/失败 |
|
||||
| `[~]` ⚠ | 51 | M08 应用推广 | 应由 M01 进入;分享权限、生成海报、取消、失败 |
|
||||
| `[~]` ⚠ | 52 | M09 VIP 与订单 | 应由 M01 进入;无订单、权益、订单列表、加载失败 |
|
||||
| `[~]` | 53 | M10 设置与关于 | M01→设置;页面顶部外观模式(浅色/深色/跟随系统)、协议、隐私、版本、退出确认/取消;明暗主题视觉均需审核 |
|
||||
| `[~]` | 53 | M10 设置与关于 | M01→设置;协议、隐私、版本、退出确认/取消;基础版本不展示外观模式入口,深色与跟随系统延期到下一版本 |
|
||||
|
||||
## 6. 当前视觉流程断点与后续功能依赖
|
||||
|
||||
@@ -286,12 +286,12 @@
|
||||
|
||||
1. 从本表上一项或明确的分支入口进入页面。
|
||||
2. 捕获本轮真实运行截图并检查是否为正确页面、正确状态、非加载中画面。
|
||||
3. 默认展示一张代表图;明暗主题或复杂状态展示一张联系表,不连续铺陈对比截图。
|
||||
4. 确认浅色与深色的关键状态、四档尺寸、代码质量和性能均已通过;任一主题未通过时不得提交冻结。
|
||||
5. 用户确认通过则两套主题同时标 `[x]` 并冻结;用户指出问题则整个页面标 `[!]`。
|
||||
3. 默认展示一张代表图;复杂状态展示一张联系表,不连续铺陈对比截图。
|
||||
4. 确认当前浅色国风主题的关键状态、四档尺寸、代码质量和性能均已通过。
|
||||
5. 用户确认通过则当前基础版本页面标 `[x]` 并冻结;用户指出问题则整个页面标 `[!]`。
|
||||
6. 返工前先更新最小契约测试并确认 RED,再做最小实现。
|
||||
7. 测试、构建、截图或路由异常时先复现并定位根因,不盲改样式。
|
||||
8. 返工后运行受影响测试、运行时 smoke、两套主题四尺寸复核、真实路由截图和 `git diff --check`。
|
||||
8. 返工后运行受影响测试、运行时 smoke、当前主题四尺寸复核、真实路由截图和 `git diff --check`。
|
||||
9. 接口资料只用于理解功能、字段和状态位置;本阶段不对接接口,也不执行接口功能验收。
|
||||
|
||||
## 8. 单项验收记录模板
|
||||
|
||||
@@ -1,80 +1,73 @@
|
||||
<!-- 页面编号:A-01;用途:APP 唯一登录入口,承载密码、验证码与微信登录入口。 -->
|
||||
<template>
|
||||
<view class="auth-page login-page">
|
||||
<image class="paper-background" src="/static/assets/foundation/opaque/auth-page-paper.jpg" mode="aspectFill" />
|
||||
<image class="scenery-background" src="/static/assets/foundation/transparent/auth-ink-scenery.png" mode="aspectFill" />
|
||||
<view class="page-canvas">
|
||||
<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="auth-header">
|
||||
<image class="header-background" src="/static/assets/modules/auth/opaque/a01-login-header-v1.png" mode="scaleToFill" />
|
||||
</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>
|
||||
<view class="login-content">
|
||||
<view class="login-heading">
|
||||
<text class="login-title">登录家谱</text>
|
||||
<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-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-tabs">
|
||||
<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>
|
||||
|
||||
<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 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 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 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 class="login-submit" hover-class="button-pressed" @click="submitLogin">
|
||||
<image class="button-skin" src="/static/assets/foundation/opaque/a01-primary-button.png" mode="scaleToFill" />
|
||||
<text class="login-submit__copy">登录</text>
|
||||
<view v-else class="input-row">
|
||||
<image class="input-icon input-icon--sms" 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="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 class="form-secondary-row">
|
||||
<view v-if="activeLoginMethod === 'password'" class="forgot-password" hover-class="tap-fade" @click="prepareForgotPassword">忘记密码</view>
|
||||
</view>
|
||||
</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 class="login-submit" hover-class="button-pressed" @click="submitLogin">
|
||||
<image class="button-skin" src="/static/assets/foundation/opaque/a01-primary-button.png" mode="scaleToFill" />
|
||||
<text class="login-submit__copy">登录</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 class="register-entry">
|
||||
<text>还没有账号?</text>
|
||||
<view class="register-link" hover-class="tap-fade" @click="prepareRegister">注册账号</view>
|
||||
</view>
|
||||
<view class="register-entry">
|
||||
<text>还没有账号?</text>
|
||||
<view class="register-link" hover-class="tap-fade" @click="prepareRegister">注册账号</view>
|
||||
</view>
|
||||
|
||||
<view class="agreement-area" :class="{ 'agreement-area--error': agreementError }">
|
||||
<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" />
|
||||
<view class="agreement-copy">
|
||||
@@ -84,13 +77,12 @@
|
||||
<text class="agreement-link" @click.stop="prepareAgreement">《隐私政策》</text>
|
||||
</view>
|
||||
</view>
|
||||
<text v-if="agreementError" class="agreement-error">请先阅读并同意用户协议与隐私政策</text>
|
||||
</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 class="verification-dialog" @click.stop>
|
||||
<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 verificationCode = ref('')
|
||||
const agreed = ref(false)
|
||||
const agreementError = ref(false)
|
||||
const verificationVisible = ref(false)
|
||||
const verificationPurpose = ref('登录')
|
||||
const feedbackVisible = ref(false)
|
||||
@@ -161,6 +154,7 @@ const togglePasswordVisibility = () => {
|
||||
|
||||
const toggleAgreement = () => {
|
||||
agreed.value = !agreed.value
|
||||
if (agreed.value) agreementError.value = false
|
||||
}
|
||||
|
||||
const validatePhone = () => {
|
||||
@@ -171,7 +165,7 @@ const validatePhone = () => {
|
||||
|
||||
const requireAgreement = () => {
|
||||
if (agreed.value) return true
|
||||
showFeedback('请先阅读并同意用户协议与隐私政策')
|
||||
agreementError.value = true
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -228,87 +222,442 @@ const prepareAgreement = () => showFeedback('协议页面准备中')
|
||||
min-height: 100%;
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
background: #f6efe2;
|
||||
background: #f7f0e5;
|
||||
color: #493323;
|
||||
}
|
||||
|
||||
.paper-background,
|
||||
.scenery-background {
|
||||
position: fixed;
|
||||
.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);
|
||||
}
|
||||
|
||||
.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;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
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); }
|
||||
.auth-header { position: relative; height: 304rpx; }
|
||||
.header-background { width: 100%; height: 100%; }
|
||||
.login-submit__copy {
|
||||
position: relative;
|
||||
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; }
|
||||
.login-scroll__skin { position: absolute; inset: 0; width: 100%; height: 100%; pointer-events: none; }
|
||||
.login-scroll__content { position: relative; z-index: 1; box-sizing: border-box; height: 100%; padding: 224rpx 97rpx 66rpx; }
|
||||
.other-login-divider {
|
||||
display: flex;
|
||||
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; }
|
||||
.login-title { margin: 0 8rpx; color: #6f140f; font-family: STKaiti, KaiTi, serif; font-size: 64rpx; font-weight: 700; letter-spacing: 8rpx; line-height: 1; }
|
||||
.heading-cloud { width: 48rpx; height: 34rpx; }
|
||||
.heading-cloud--right { transform: scaleX(-1); }
|
||||
.title-divider { position: absolute; top: 310rpx; left: 50%; display: block; width: 286rpx; height: 38rpx; transform: translateX(-50%); }
|
||||
.divider-line {
|
||||
flex: 1;
|
||||
height: 1rpx;
|
||||
margin: 0 8rpx;
|
||||
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); }
|
||||
.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; }
|
||||
.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%); }
|
||||
.login-tab.active { color: #a9160d; font-weight: 700; }
|
||||
.login-tab.active::after { position: absolute; right: 102rpx; bottom: -2rpx; left: 102rpx; height: 5rpx; border-radius: 5rpx; background: #ad160d; content: ''; }
|
||||
.divider-knot {
|
||||
width: 17rpx;
|
||||
height: 17rpx;
|
||||
margin-right: 7rpx;
|
||||
}
|
||||
|
||||
.form-content { margin-top: 16rpx; }
|
||||
.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); }
|
||||
.input-icon { flex: 0 0 auto; width: 58rpx; height: 58rpx; margin-right: 22rpx; }
|
||||
.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; }
|
||||
.password-toggle__icon { width: 56rpx; height: 56rpx; }
|
||||
.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; }
|
||||
.form-secondary-row { display: flex; align-items: center; justify-content: flex-end; min-height: 80rpx; }
|
||||
.forgot-password { display: flex; align-items: center; min-height: 80rpx; color: #a9160d; font-size: 28rpx; }
|
||||
.divider-knot--right {
|
||||
margin: 0 0 0 7rpx;
|
||||
transform: scaleX(-1);
|
||||
}
|
||||
|
||||
.wechat-login {
|
||||
height: 100rpx;
|
||||
color: #493323;
|
||||
font-family: STSong, SimSun, serif;
|
||||
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-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; }
|
||||
.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__skin { position: absolute; inset: 0; width: 100%; height: 100%; }
|
||||
.feedback-toast__copy { position: relative; z-index: 1; padding: 18rpx 40rpx; color: #5c4330; font-size: 25rpx; text-align: center; }
|
||||
.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%);
|
||||
}
|
||||
|
||||
.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: STKaiti, KaiTi, 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; }
|
||||
.feedback-toast__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__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>
|
||||
|
||||
@@ -1,51 +1,52 @@
|
||||
<!-- 页面编号:A-04;用途:注册账号与本页服务协议确认。 -->
|
||||
<template>
|
||||
<view class="auth-page register-page">
|
||||
<image class="paper-background" src="/static/assets/foundation/opaque/auth-page-paper.jpg" mode="aspectFill" />
|
||||
<image class="scenery-background" src="/static/assets/foundation/transparent/auth-design-scenery-v1.png" mode="scaleToFill" />
|
||||
<view class="page-canvas">
|
||||
<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="auth-header">
|
||||
<image class="header-background" src="/static/assets/foundation/opaque/auth-header.png" mode="aspectFill" />
|
||||
<image class="header-seal" src="/static/assets/foundation/transparent/brand-seal.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 class="register-content">
|
||||
<view class="page-heading">
|
||||
<view class="back-button" hover-class="tap-fade" @click="goBack">
|
||||
<image class="back-button__icon" src="/static/assets/foundation/transparent/chevron-right.png" mode="aspectFit" />
|
||||
</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" />
|
||||
</view>
|
||||
|
||||
<view class="form-content">
|
||||
<view class="form-content">
|
||||
<view class="field-block" :class="{ 'field-block--error': fieldErrors.phone }">
|
||||
<view class="input-row">
|
||||
<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>
|
||||
<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">
|
||||
<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>
|
||||
<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">
|
||||
<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>
|
||||
<text v-if="fieldErrors.confirmPassword" class="field-error">{{ fieldErrors.confirmPassword }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<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" />
|
||||
<text class="register-submit__content">注册账号</text>
|
||||
</view>
|
||||
<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" />
|
||||
<text class="register-submit__content">注册账号</text>
|
||||
</view>
|
||||
|
||||
<view class="agreement-area" :class="{ 'agreement-area--error': agreementError }">
|
||||
<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" />
|
||||
<view class="agreement-copy">
|
||||
@@ -55,226 +56,255 @@
|
||||
<text class="agreement-link" @click.stop="prepareAgreement">《隐私政策》</text>
|
||||
</view>
|
||||
</view>
|
||||
<text v-if="agreementError" class="agreement-error">请先阅读并同意用户协议与隐私政策</text>
|
||||
</view>
|
||||
|
||||
<view class="login-entry">
|
||||
<text>已有账号?</text>
|
||||
<view class="login-entry__link" hover-class="text-hover" @click="prepareLogin">登录</view>
|
||||
<view class="login-entry">
|
||||
<text>已有账号?</text>
|
||||
<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 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>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import { onUnload } from '@dcloudio/uni-app'
|
||||
|
||||
const phone = ref('')
|
||||
const password = ref('')
|
||||
const confirmPassword = ref('')
|
||||
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 = () => {
|
||||
uni.navigateBack()
|
||||
}
|
||||
|
||||
const prepareLogin = () => uni.navigateTo({ url: '/pages/auth/a01-entry' })
|
||||
// “已有账号”用于退出注册流程,替换当前页可避免登录/注册页面反复叠加。
|
||||
const prepareLogin = () => uni.redirectTo({ url: '/pages/auth/a01-entry' })
|
||||
|
||||
const prepareAgreement = () => {
|
||||
uni.showToast({ title: '协议页面准备中', icon: 'none' })
|
||||
const showFeedback = (message) => {
|
||||
feedbackMessage.value = message
|
||||
feedbackVisible.value = true
|
||||
if (feedbackTimer) clearTimeout(feedbackTimer)
|
||||
feedbackTimer = setTimeout(() => {
|
||||
feedbackVisible.value = false
|
||||
feedbackTimer = null
|
||||
}, 2200)
|
||||
}
|
||||
|
||||
const prepareAgreement = () => showFeedback('协议页面准备中')
|
||||
|
||||
const toggleAgreement = () => {
|
||||
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 = () => {
|
||||
if (!agreed.value) {
|
||||
uni.showToast({ title: '请先阅读并同意相关协议', icon: 'none' })
|
||||
return
|
||||
}
|
||||
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' })
|
||||
const formValid = validateForm()
|
||||
if (!agreed.value) agreementError.value = true
|
||||
if (!formValid || !agreed.value) return
|
||||
verificationVisible.value = true
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.auth-page {
|
||||
position: relative;
|
||||
min-height: 100vh;
|
||||
background: #f7f1e6;
|
||||
}
|
||||
|
||||
.paper-background,
|
||||
.scenery-background {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
height: 100dvh;
|
||||
min-height: 100%;
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
background: #f7f0e4;
|
||||
color: #493323;
|
||||
}
|
||||
|
||||
.paper-background { opacity: 0.96; }
|
||||
.scenery-background { opacity: 0.76; }
|
||||
|
||||
.auth-content {
|
||||
.page-canvas {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
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%;
|
||||
height: 100%;
|
||||
min-height: calc(1665rpx + env(safe-area-inset-bottom));
|
||||
padding-bottom: env(safe-area-inset-bottom);
|
||||
}
|
||||
|
||||
.header-seal {
|
||||
.page-backdrop {
|
||||
position: absolute;
|
||||
top: 70rpx;
|
||||
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;
|
||||
inset: 0 0 auto;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
height: 1665rpx;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.register-panel__content {
|
||||
position: relative;
|
||||
.brand-seal {
|
||||
position: absolute;
|
||||
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;
|
||||
min-height: 1184rpx;
|
||||
padding: 66rpx 50rpx 76rpx;
|
||||
padding: 430rpx 64rpx 46rpx;
|
||||
}
|
||||
|
||||
.page-heading {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.register-title-cloud {
|
||||
position: absolute;
|
||||
top: -26rpx;
|
||||
right: 2rpx;
|
||||
width: 154rpx;
|
||||
height: 92rpx;
|
||||
opacity: 0.62;
|
||||
}
|
||||
|
||||
.page-heading__copy {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
min-height: 154rpx;
|
||||
}
|
||||
|
||||
.back-button {
|
||||
position: absolute;
|
||||
top: 10rpx;
|
||||
left: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 56rpx;
|
||||
height: 56rpx;
|
||||
margin-right: 18rpx;
|
||||
width: 88rpx;
|
||||
height: 88rpx;
|
||||
}
|
||||
|
||||
.back-hover { opacity: 0.62; }
|
||||
|
||||
.back-button__icon {
|
||||
width: 42rpx;
|
||||
height: 42rpx;
|
||||
transform: scaleX(-1);
|
||||
}
|
||||
|
||||
.page-title,
|
||||
.page-subtitle {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
color: #3f2c1d;
|
||||
font-family: STKaiti, KaiTi, serif;
|
||||
font-size: 50rpx;
|
||||
color: #9f170f;
|
||||
font-family: STSong, SimSun, serif;
|
||||
font-size: 66rpx;
|
||||
font-weight: 700;
|
||||
letter-spacing: 4rpx;
|
||||
letter-spacing: 7rpx;
|
||||
}
|
||||
|
||||
.page-subtitle {
|
||||
margin-top: 10rpx;
|
||||
color: #8c7864;
|
||||
font-size: 24rpx;
|
||||
margin-top: 12rpx;
|
||||
color: #806c58;
|
||||
font-size: 25rpx;
|
||||
letter-spacing: 2rpx;
|
||||
}
|
||||
|
||||
.register-divider {
|
||||
display: block;
|
||||
width: 154rpx;
|
||||
height: 42rpx;
|
||||
margin: 40rpx auto 0;
|
||||
height: 34rpx;
|
||||
margin-top: 10rpx;
|
||||
}
|
||||
|
||||
.form-content { margin-top: 30rpx; }
|
||||
.form-content { margin-top: 12rpx; }
|
||||
|
||||
.field-block { min-height: 116rpx; }
|
||||
|
||||
.input-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-height: 112rpx;
|
||||
border-bottom: 1rpx solid #e5d4b7;
|
||||
min-height: 104rpx;
|
||||
border-bottom: 1rpx solid rgba(182, 116, 43, 0.68);
|
||||
}
|
||||
|
||||
.input-label {
|
||||
width: 154rpx;
|
||||
flex: 0 0 152rpx;
|
||||
color: #674b35;
|
||||
font-family: STKaiti, KaiTi, serif;
|
||||
font-family: STSong, SimSun, serif;
|
||||
font-size: 31rpx;
|
||||
}
|
||||
|
||||
.auth-input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
height: 104rpx;
|
||||
height: 96rpx;
|
||||
color: #3f2c1d;
|
||||
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 {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 128rpx;
|
||||
margin-top: 60rpx;
|
||||
height: 104rpx;
|
||||
margin-top: 36rpx;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@@ -289,57 +319,150 @@ const submitRegister = () => {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
color: #fffaf0;
|
||||
font-family: STKaiti, KaiTi, serif;
|
||||
font-size: 36rpx;
|
||||
font-family: STSong, SimSun, serif;
|
||||
font-size: 39rpx;
|
||||
letter-spacing: 6rpx;
|
||||
}
|
||||
|
||||
.button-hover { opacity: 0.84; }
|
||||
.agreement-area { min-height: 118rpx; margin-top: 18rpx; }
|
||||
|
||||
.agreement-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 88rpx;
|
||||
margin-top: 22rpx;
|
||||
padding: 0 4rpx;
|
||||
color: #6b5a4a;
|
||||
font-size: 24rpx;
|
||||
line-height: 32rpx;
|
||||
min-height: 76rpx;
|
||||
color: #58483c;
|
||||
font-size: 22rpx;
|
||||
line-height: 30rpx;
|
||||
}
|
||||
|
||||
.agreement-icon {
|
||||
flex: 0 0 auto;
|
||||
width: 32rpx;
|
||||
height: 32rpx;
|
||||
margin-right: 12rpx;
|
||||
width: 34rpx;
|
||||
height: 34rpx;
|
||||
margin-right: 10rpx;
|
||||
}
|
||||
|
||||
.agreement-copy {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.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 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-top: 2rpx;
|
||||
min-height: 82rpx;
|
||||
color: #6b5a4a;
|
||||
font-size: 24rpx;
|
||||
font-size: 25rpx;
|
||||
}
|
||||
|
||||
.login-entry__link {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-height: 88rpx;
|
||||
min-height: 82rpx;
|
||||
margin-left: 8rpx;
|
||||
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>
|
||||
|
||||
@@ -1,328 +1,329 @@
|
||||
<!-- 页面编号:A-05;用途:通过手机号验证后重设登录密码。 -->
|
||||
<template>
|
||||
<view class="auth-page reset-page">
|
||||
<!-- 认证页背景复用已验收纸纹与淡墨山水,不绘制 CSS 装饰面。 -->
|
||||
<image class="paper-background" src="/static/assets/foundation/opaque/auth-page-paper.jpg" mode="aspectFill" />
|
||||
<image class="scenery-background" src="/static/assets/foundation/transparent/auth-design-scenery-v1.png" mode="scaleToFill" />
|
||||
<view class="page-canvas">
|
||||
<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">
|
||||
<!-- 朱砂祠堂头和谱印与 A02/A04 保持同一认证入口。 -->
|
||||
<view class="auth-header">
|
||||
<image class="header-background" src="/static/assets/foundation/opaque/auth-header.png" mode="aspectFill" />
|
||||
<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 class="reset-content">
|
||||
<view class="page-heading">
|
||||
<view class="back-button" hover-class="tap-fade" @click="goBack">
|
||||
<image class="back-button__icon" src="/static/assets/foundation/transparent/chevron-right.png" mode="aspectFit" />
|
||||
</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" />
|
||||
</view>
|
||||
|
||||
<!-- 四项真实输入在当前页完成身份验证与两次密码确认。 -->
|
||||
<view class="form-content">
|
||||
<view class="form-content">
|
||||
<view class="field-block" :class="{ 'field-block--error': fieldErrors.phone }">
|
||||
<view class="input-row">
|
||||
<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>
|
||||
<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">
|
||||
<text class="input-label">验证码</text>
|
||||
<input v-model.trim="verificationCode" class="auth-input" type="number" maxlength="6" placeholder="请输入验证码" placeholder-class="placeholder" />
|
||||
<view class="get-code" hover-class="text-hover" @click="prepareGetCode">获取验证码</view>
|
||||
<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="tap-fade" @click="prepareGetCode">{{ codeRequested ? '重新获取' : '获取验证码' }}</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">
|
||||
<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>
|
||||
<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">
|
||||
<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>
|
||||
<text v-if="fieldErrors.confirmPassword" class="field-error">{{ fieldErrors.confirmPassword }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<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" />
|
||||
<text class="reset-submit__content">确认重设</text>
|
||||
</view>
|
||||
<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" />
|
||||
<text class="reset-submit__content">确认重设</text>
|
||||
</view>
|
||||
|
||||
<text class="reset-tip">重设完成后,请使用新密码登录</text>
|
||||
<view class="login-entry">
|
||||
<text>想起密码了?</text>
|
||||
<view class="login-entry__link" hover-class="text-hover" @click="prepareLogin">返回登录</view>
|
||||
<text class="reset-tip">重设成功后,请使用新密码登录</text>
|
||||
<view class="login-entry">
|
||||
<text>想起密码了?</text>
|
||||
<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 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>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import { onUnload } from '@dcloudio/uni-app'
|
||||
|
||||
const phone = ref('')
|
||||
const verificationCode = ref('')
|
||||
const password = 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 = () => {
|
||||
uni.navigateBack()
|
||||
onUnload(() => {
|
||||
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 = () => {
|
||||
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 = () => {
|
||||
if (!/^1\d{10}$/.test(phone.value)) {
|
||||
uni.showToast({ title: '请输入正确手机号', icon: 'none' })
|
||||
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' })
|
||||
if (!validateForm()) return
|
||||
successVisible.value = true
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.auth-page {
|
||||
position: relative;
|
||||
min-height: 100vh;
|
||||
background: #f7f1e6;
|
||||
}
|
||||
|
||||
.paper-background,
|
||||
.scenery-background {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
height: 100dvh;
|
||||
min-height: 100%;
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
background: #f7f0e4;
|
||||
color: #493323;
|
||||
}
|
||||
|
||||
.paper-background { opacity: 0.96; }
|
||||
.scenery-background { opacity: 0.76; }
|
||||
|
||||
.auth-content {
|
||||
.page-canvas {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
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%;
|
||||
height: 100%;
|
||||
min-height: calc(1665rpx + env(safe-area-inset-bottom));
|
||||
padding-bottom: env(safe-area-inset-bottom);
|
||||
}
|
||||
|
||||
.header-seal {
|
||||
.page-backdrop {
|
||||
position: absolute;
|
||||
top: 70rpx;
|
||||
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;
|
||||
inset: 0 0 auto;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
height: 1665rpx;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.reset-panel__content {
|
||||
position: relative;
|
||||
.brand-seal {
|
||||
position: absolute;
|
||||
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;
|
||||
min-height: 1240rpx;
|
||||
padding: 66rpx 50rpx 72rpx;
|
||||
padding: 430rpx 64rpx 46rpx;
|
||||
}
|
||||
|
||||
.page-heading {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.reset-title-cloud {
|
||||
position: absolute;
|
||||
top: -26rpx;
|
||||
right: 2rpx;
|
||||
width: 154rpx;
|
||||
height: 92rpx;
|
||||
opacity: 0.62;
|
||||
}
|
||||
|
||||
.page-heading__copy {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
min-height: 154rpx;
|
||||
}
|
||||
|
||||
.back-button {
|
||||
position: absolute;
|
||||
top: 10rpx;
|
||||
left: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 56rpx;
|
||||
height: 56rpx;
|
||||
margin-right: 18rpx;
|
||||
width: 88rpx;
|
||||
height: 88rpx;
|
||||
}
|
||||
|
||||
.back-hover { opacity: 0.62; }
|
||||
|
||||
.back-button__icon {
|
||||
width: 42rpx;
|
||||
height: 42rpx;
|
||||
transform: scaleX(-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;
|
||||
}
|
||||
|
||||
.reset-divider {
|
||||
display: block;
|
||||
width: 154rpx;
|
||||
height: 42rpx;
|
||||
margin: 40rpx auto 0;
|
||||
}
|
||||
|
||||
.form-content { margin-top: 30rpx; }
|
||||
.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; }
|
||||
.reset-divider { width: 154rpx; height: 34rpx; margin-top: 10rpx; }
|
||||
.form-content { margin-top: 8rpx; }
|
||||
.field-block { min-height: 114rpx; }
|
||||
|
||||
.input-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-height: 112rpx;
|
||||
border-bottom: 1rpx solid #e5d4b7;
|
||||
min-height: 84rpx;
|
||||
border-bottom: 1rpx solid #cfaa70;
|
||||
}
|
||||
|
||||
.input-label {
|
||||
width: 174rpx;
|
||||
color: #674b35;
|
||||
font-family: STKaiti, KaiTi, serif;
|
||||
font-size: 31rpx;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
.input-label { flex: 0 0 162rpx; color: #674b35; font-family: STSong, SimSun, serif; font-size: 29rpx; }
|
||||
.auth-input { flex: 1; min-width: 0; height: 82rpx; color: #3f2c1d; font-size: 27rpx; }
|
||||
.placeholder { color: #a79e94; }
|
||||
.get-code { flex: 0 0 auto; padding-left: 14rpx; border-left: 1rpx solid #d7bd94; color: #a7160c; font-size: 23rpx; }
|
||||
.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; }
|
||||
|
||||
.reset-submit {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 128rpx;
|
||||
margin-top: 52rpx;
|
||||
height: 104rpx;
|
||||
margin-top: 26rpx;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.reset-submit__skin {
|
||||
position: absolute;
|
||||
.reset-submit__skin,
|
||||
.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;
|
||||
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;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-top: 2rpx;
|
||||
color: #6b5a4a;
|
||||
font-size: 24rpx;
|
||||
box-sizing: border-box;
|
||||
padding: 42rpx;
|
||||
background: rgba(35, 18, 10, 0.62);
|
||||
}
|
||||
|
||||
.login-entry__link {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-height: 82rpx;
|
||||
margin-left: 8rpx;
|
||||
color: #a7160c;
|
||||
.verification-dialog,
|
||||
.success-dialog { position: relative; width: 100%; max-width: 670rpx; min-height: 500rpx; }
|
||||
.verification-dialog__skin,
|
||||
.success-dialog__skin { position: absolute; inset: 0; width: 100%; height: 100%; }
|
||||
.verification-dialog__content,
|
||||
.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>
|
||||
|
||||
@@ -1,46 +1,60 @@
|
||||
<!-- 页面编号:A-06;用途:承接登录未完成、失败、受限与授权结果。 -->
|
||||
<!-- 页面编号:A-06;用途:只承接账号冻结、停用与风险限制等阻断登录状态。 -->
|
||||
<template>
|
||||
<view class="auth-page status-page">
|
||||
<!-- 认证入口沿用纸纹、淡墨与祠堂头图,不以 CSS 重绘完整装饰面。 -->
|
||||
<image class="paper-background" src="/static/assets/foundation/opaque/auth-page-paper.jpg" mode="aspectFill" />
|
||||
<image class="scenery-background" src="/static/assets/foundation/transparent/auth-design-scenery-v1.png" mode="scaleToFill" />
|
||||
<view class="page-canvas">
|
||||
<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="auth-header">
|
||||
<image class="header-background" src="/static/assets/foundation/opaque/auth-header.png" mode="aspectFill" />
|
||||
<image class="header-seal" src="/static/assets/foundation/transparent/brand-seal.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 class="status-content">
|
||||
<view class="page-heading">
|
||||
<view class="back-button" hover-class="tap-fade" @click="goBack">
|
||||
<image class="back-button__icon" src="/static/assets/foundation/transparent/chevron-right.png" mode="aspectFit" />
|
||||
</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" />
|
||||
</view>
|
||||
|
||||
<view class="status-body">
|
||||
<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-title">{{ currentState.title }}</text>
|
||||
<text class="status-description">{{ currentState.description }}</text>
|
||||
<view class="status-body">
|
||||
<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-title">{{ currentState.title }}</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 class="status-primary" hover-class="button-hover" @click="handlePrimary">
|
||||
<image class="status-primary__skin" src="/static/assets/foundation/opaque/a01-primary-button.png" mode="scaleToFill" />
|
||||
<text class="status-primary__copy">{{ currentState.primaryLabel }}</text>
|
||||
</view>
|
||||
<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" />
|
||||
<text class="status-primary__copy">查看恢复方式</text>
|
||||
</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">
|
||||
{{ currentState.secondaryLabel }}
|
||||
<view v-if="recoveryVisible" class="recovery-layer" @click="closeRecovery">
|
||||
<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>
|
||||
@@ -52,320 +66,130 @@
|
||||
import { computed, ref } from 'vue'
|
||||
import { onLoad, onShow } from '@dcloudio/uni-app'
|
||||
|
||||
const status = ref('normal')
|
||||
const status = ref('risk')
|
||||
const recoveryVisible = ref(false)
|
||||
|
||||
// A06 阻断状态的唯一合同;普通登录错误必须留在 A01 就近处理。
|
||||
const statusConfig = {
|
||||
normal: {
|
||||
eyebrow: '登录提示',
|
||||
title: '登录未完成',
|
||||
description: '登录流程尚未完成,请选择一种方式继续。',
|
||||
primaryLabel: '返回登录',
|
||||
action: 'login',
|
||||
secondaryLabel: ''
|
||||
frozen: {
|
||||
eyebrow: '账号冻结',
|
||||
title: '账号已被冻结',
|
||||
description: '当前账号因安全审核暂时无法登录。',
|
||||
reasonLabel: '限制原因',
|
||||
reason: '账号进入人工安全审核',
|
||||
impactLabel: '影响范围',
|
||||
impact: '暂时无法进入家谱及管理资料',
|
||||
recoveryLabel: '恢复方式',
|
||||
recovery: '提交账号申诉并等待审核结果',
|
||||
recoveryTitle: '申请解除冻结',
|
||||
recoveryDetail: '请准备注册手机号与身份说明,提交后由平台进行人工核验。'
|
||||
},
|
||||
failed: {
|
||||
eyebrow: '登录提示',
|
||||
title: '登录失败',
|
||||
description: '当前无法完成登录,请稍后重试或改用其他方式。',
|
||||
primaryLabel: '返回登录',
|
||||
action: 'login',
|
||||
secondaryLabel: ''
|
||||
disabled: {
|
||||
eyebrow: '账号停用',
|
||||
title: '账号已被停用',
|
||||
description: '当前账号处于停用状态,登录入口已关闭。',
|
||||
reasonLabel: '限制原因',
|
||||
reason: '账号已执行停用处理',
|
||||
impactLabel: '影响范围',
|
||||
impact: '无法登录,原有家谱资料不会被删除',
|
||||
recoveryLabel: '恢复方式',
|
||||
recovery: '联系平台核实停用原因与恢复条件',
|
||||
recoveryTitle: '联系平台核实',
|
||||
recoveryDetail: '请提供注册手机号和账号归属信息,平台核实后告知是否可以恢复。'
|
||||
},
|
||||
restricted: {
|
||||
eyebrow: '账号安全',
|
||||
title: '账号暂时受限',
|
||||
description: '为保护家谱资料,当前账号暂不能登录。',
|
||||
primaryLabel: '查看帮助',
|
||||
action: 'help',
|
||||
secondaryLabel: '返回登录'
|
||||
},
|
||||
'register-pending': {
|
||||
eyebrow: '注册提示',
|
||||
title: '注册尚未完成',
|
||||
description: '请返回注册页完善信息后再登录。',
|
||||
primaryLabel: '继续注册',
|
||||
action: 'register',
|
||||
secondaryLabel: '返回登录'
|
||||
},
|
||||
'wechat-cancelled': {
|
||||
eyebrow: '微信登录',
|
||||
title: '微信授权已取消',
|
||||
description: '未获得微信授权,不会影响账号安全。',
|
||||
primaryLabel: '返回登录',
|
||||
action: 'login',
|
||||
secondaryLabel: ''
|
||||
},
|
||||
'wechat-failed': {
|
||||
eyebrow: '微信登录',
|
||||
title: '微信授权失败',
|
||||
description: '当前无法完成微信授权,请使用账号密码登录。',
|
||||
primaryLabel: '返回登录',
|
||||
action: 'login',
|
||||
secondaryLabel: ''
|
||||
risk: {
|
||||
eyebrow: '风险限制',
|
||||
title: '账号存在安全风险',
|
||||
description: '系统检测到异常登录,为保护家谱资料已暂停本次登录。',
|
||||
reasonLabel: '限制原因',
|
||||
reason: '登录环境或操作行为存在异常',
|
||||
impactLabel: '影响范围',
|
||||
impact: '本次登录被阻断,账号资料保持不变',
|
||||
recoveryLabel: '恢复方式',
|
||||
recovery: '完成身份核验后重新登录',
|
||||
recoveryTitle: '完成身份核验',
|
||||
recoveryDetail: '请使用注册手机号完成身份核验;核验通过后可重新尝试登录。'
|
||||
}
|
||||
}
|
||||
|
||||
const currentState = computed(() => statusConfig[status.value])
|
||||
|
||||
const resolveStatus = () => {
|
||||
let requestedStatus = 'normal'
|
||||
|
||||
let requestedStatus = 'risk'
|
||||
if (typeof location !== 'undefined') {
|
||||
const query = new URLSearchParams(location.hash.split('?')[1] || '')
|
||||
requestedStatus = query.get('status') || 'normal'
|
||||
requestedStatus = query.get('status') || 'risk'
|
||||
} else {
|
||||
const pages = getCurrentPages()
|
||||
const currentPage = pages[pages.length - 1]
|
||||
requestedStatus = currentPage?.options?.status || 'normal'
|
||||
requestedStatus = pages[pages.length - 1]?.options?.status || 'risk'
|
||||
}
|
||||
|
||||
status.value = Object.prototype.hasOwnProperty.call(statusConfig, requestedStatus) ? requestedStatus : 'normal'
|
||||
status.value = Object.prototype.hasOwnProperty.call(statusConfig, requestedStatus) ? requestedStatus : 'risk'
|
||||
recoveryVisible.value = false
|
||||
}
|
||||
|
||||
onLoad(() => {
|
||||
resolveStatus()
|
||||
})
|
||||
onLoad(resolveStatus)
|
||||
onShow(resolveStatus)
|
||||
|
||||
onShow(() => {
|
||||
resolveStatus()
|
||||
})
|
||||
|
||||
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()
|
||||
}
|
||||
const goLogin = () => uni.redirectTo({ url: '/pages/auth/a01-entry' })
|
||||
const openRecovery = () => { recoveryVisible.value = true }
|
||||
const closeRecovery = () => { recoveryVisible.value = false }
|
||||
const goBack = () => uni.navigateBack()
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.auth-page {
|
||||
position: relative;
|
||||
min-height: 100vh;
|
||||
background: #f7f1e6;
|
||||
}
|
||||
|
||||
.paper-background,
|
||||
.scenery-background {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
height: 100dvh;
|
||||
min-height: 100%;
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
background: #f7f0e4;
|
||||
color: #493323;
|
||||
}
|
||||
|
||||
.paper-background { opacity: 0.96; }
|
||||
.scenery-background { opacity: 0.76; }
|
||||
.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); }
|
||||
.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 {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
box-sizing: border-box;
|
||||
min-height: 100vh;
|
||||
padding-bottom: calc(44rpx + env(safe-area-inset-bottom));
|
||||
.status-body { display: flex; flex-direction: column; align-items: center; padding-top: 6rpx; text-align: center; }
|
||||
.status-icon { width: 94rpx; height: 94rpx; opacity: 0.82; }
|
||||
.status-eyebrow { margin-top: 10rpx; color: #a7160c; font-size: 22rpx; letter-spacing: 3rpx; }
|
||||
.status-title { margin-top: 10rpx; color: #3f2c1d; font-family: STSong, SimSun, serif; font-size: 40rpx; font-weight: 700; letter-spacing: 3rpx; }
|
||||
.status-description { box-sizing: border-box; width: 100%; margin-top: 12rpx; color: #786654; font-size: 24rpx; line-height: 1.65; }
|
||||
.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>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<!-- 页面编号:G-01;用途:我的家谱列表;视觉基准已由用户验收,地基迁移不得改变可见效果。 -->
|
||||
<!-- 页面编号:G-01;用途:我的家谱列表与首次分流;本轮仍待用户重新审核。 -->
|
||||
<template>
|
||||
<view class="page-shell genealogy-index">
|
||||
<image
|
||||
@@ -25,8 +25,20 @@
|
||||
<text class="state-copy">请稍候,家族记忆正在归卷。</text>
|
||||
</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">
|
||||
<view class="current-slip" @click="openGenealogy(currentGenealogy)">
|
||||
<view class="current-slip" @click="openSwitcher">
|
||||
<image
|
||||
class="current-frame"
|
||||
src="/static/assets/modules/genealogy/transparent/current-slip-frame.png"
|
||||
@@ -42,6 +54,7 @@
|
||||
<text class="current-seal-title">家谱</text>
|
||||
</view>
|
||||
<text class="current-name">{{ currentGenealogy.name }}</text>
|
||||
<text class="current-switch-copy">切换</text>
|
||||
</view>
|
||||
<view class="current-info-divider"></view>
|
||||
<view class="current-meta">
|
||||
@@ -118,7 +131,28 @@
|
||||
/>
|
||||
</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
|
||||
class="create-cloud"
|
||||
src="/static/assets/modules/genealogy/transparent/create-cloud.png"
|
||||
@@ -129,7 +163,7 @@
|
||||
src="/static/assets/modules/genealogy/transparent/add.png"
|
||||
mode="aspectFit"
|
||||
/>
|
||||
<text>新建家谱</text>
|
||||
<text>添加家谱</text>
|
||||
<image
|
||||
class="create-cloud create-cloud--right"
|
||||
src="/static/assets/modules/genealogy/transparent/create-cloud.png"
|
||||
@@ -156,36 +190,12 @@
|
||||
<text class="empty-seal__text">家谱</text>
|
||||
</view>
|
||||
<text class="empty-title">还没有加入任何家谱</text>
|
||||
<text class="empty-copy">先为自己的家族立一部谱,或搜索家谱申请加入</text>
|
||||
<text class="empty-copy">先搜索家族是否已有家谱,再选择加入或创建</text>
|
||||
<image
|
||||
class="empty-divider"
|
||||
src="/static/assets/modules/genealogy/transparent/section-divider.png"
|
||||
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
|
||||
class="empty-search-action"
|
||||
hover-class="action-hover"
|
||||
@@ -193,16 +203,75 @@
|
||||
>
|
||||
<image
|
||||
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"
|
||||
mode="scaleToFill"
|
||||
/>
|
||||
<text class="empty-search-action__copy">搜索并申请加入</text>
|
||||
<text class="empty-invite-action__copy">邀请码加入</text>
|
||||
</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 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" />
|
||||
</view>
|
||||
</template>
|
||||
@@ -216,22 +285,29 @@ import PageHeader from "@/components/PageHeader.vue";
|
||||
import { genealogies, notifications } from "@/data/mock.js";
|
||||
|
||||
const isLoading = ref(false);
|
||||
const hasError = ref(false);
|
||||
const list = ref(genealogies);
|
||||
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") {
|
||||
const query = new URLSearchParams(location.hash.split("?")[1] || "");
|
||||
return query.get("state") === "empty";
|
||||
return query.get("state") || "default";
|
||||
}
|
||||
|
||||
const pages = getCurrentPages();
|
||||
const currentPage = pages[pages.length - 1];
|
||||
return currentPage?.options?.state === "empty";
|
||||
return currentPage?.options?.state || "default";
|
||||
};
|
||||
|
||||
const syncEmptyStateFromRoute = () => {
|
||||
forceEmptyState.value = isEmptyStateRoute();
|
||||
const presentationState = readPresentationState();
|
||||
forceEmptyState.value = presentationState === "empty";
|
||||
isLoading.value = presentationState === "loading";
|
||||
hasError.value = presentationState === "error";
|
||||
};
|
||||
|
||||
onLoad(() => {
|
||||
@@ -252,7 +328,17 @@ const createdGenealogies = computed(() =>
|
||||
const joinedGenealogies = computed(() =>
|
||||
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 = [
|
||||
{
|
||||
@@ -282,9 +368,27 @@ const openGenealogy = (genealogy) =>
|
||||
const createGenealogy = () =>
|
||||
uni.navigateTo({ url: "/pages/genealogy/g03-create-genealogy" });
|
||||
const applyToJoin = () => uni.navigateTo({ url: "/pages/genealogy/g06-search-genealogies" });
|
||||
const joinByInvite = () => uni.navigateTo({ url: "/pages/genealogy/g06-search-genealogies?mode=invite" });
|
||||
const toNotifications = () =>
|
||||
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 paths = {
|
||||
tree: "/pages/tree/t01-tree-overview",
|
||||
@@ -405,6 +509,12 @@ const openShortcut = (key) => {
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.current-switch-copy {
|
||||
flex: 0 0 auto;
|
||||
margin-left: auto;
|
||||
color: $brand-red;
|
||||
font-size: 23rpx;
|
||||
}
|
||||
.current-info-divider {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
@@ -512,6 +622,24 @@ const openShortcut = (key) => {
|
||||
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 {
|
||||
display: flex;
|
||||
min-height: 96rpx;
|
||||
@@ -558,16 +686,15 @@ const openShortcut = (key) => {
|
||||
}
|
||||
|
||||
.state-panel {
|
||||
position: relative;
|
||||
display: flex;
|
||||
min-height: 540rpx;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
padding: 42rpx;
|
||||
box-sizing: border-box;
|
||||
border: 2rpx solid $gold-soft;
|
||||
border-radius: 18rpx;
|
||||
background: rgba(255, 249, 238, 0.76);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
@@ -676,8 +803,8 @@ const openShortcut = (key) => {
|
||||
margin-top: 16rpx;
|
||||
}
|
||||
|
||||
.empty-create-action,
|
||||
.empty-search-action {
|
||||
.empty-search-action,
|
||||
.empty-invite-action {
|
||||
position: relative;
|
||||
display: flex;
|
||||
width: 468rpx;
|
||||
@@ -687,47 +814,24 @@ const openShortcut = (key) => {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.empty-create-action {
|
||||
.empty-search-action {
|
||||
margin-top: 20rpx;
|
||||
}
|
||||
|
||||
.empty-search-action {
|
||||
margin-top: 18rpx;
|
||||
.empty-invite-action {
|
||||
margin-top: 14rpx;
|
||||
}
|
||||
|
||||
.empty-create-action__skin,
|
||||
.empty-search-action__skin {
|
||||
.empty-search-action__skin,
|
||||
.empty-invite-action__skin {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.empty-create-action__content {
|
||||
position: relative;
|
||||
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 {
|
||||
.empty-search-action__copy,
|
||||
.empty-invite-action__copy {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
color: #7b4e24;
|
||||
@@ -736,6 +840,44 @@ const openShortcut = (key) => {
|
||||
font-weight: 700;
|
||||
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 {
|
||||
opacity: 0.82;
|
||||
|
||||
@@ -49,27 +49,34 @@
|
||||
<view class="flow-fields">
|
||||
<view class="field-row">
|
||||
<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>
|
||||
<text v-if="fieldErrors.surname" class="field-error">{{ fieldErrors.surname }}</text>
|
||||
<view class="field-row">
|
||||
<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>
|
||||
<text v-if="fieldErrors.name" class="field-error">{{ fieldErrors.name }}</text>
|
||||
<view class="field-row">
|
||||
<text>堂号</text>
|
||||
<input v-model="createForm.hall" maxlength="12" placeholder="选填" placeholder-class="placeholder" />
|
||||
</view>
|
||||
<view class="field-row">
|
||||
<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>
|
||||
<text v-if="fieldErrors.location" class="field-error">{{ fieldErrors.location }}</text>
|
||||
</view>
|
||||
|
||||
<view class="flow-rule">
|
||||
<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>
|
||||
<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">
|
||||
<image
|
||||
@@ -77,7 +84,7 @@
|
||||
src="/static/assets/foundation/opaque/a01-primary-button.png"
|
||||
mode="scaleToFill"
|
||||
/>
|
||||
<text class="flow-primary-action__copy">创建并录入首代</text>
|
||||
<text class="flow-primary-action__copy">{{ createState === 'submitting' ? '正在创建…' : '创建并录入首代' }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
@@ -89,8 +96,9 @@
|
||||
<view class="flow-fields">
|
||||
<view class="field-row">
|
||||
<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>
|
||||
<text v-if="fieldErrors.personName" class="field-error">{{ fieldErrors.personName }}</text>
|
||||
<view class="field-row">
|
||||
<text>世代</text>
|
||||
<text class="fixed-value">第一世</text>
|
||||
@@ -100,6 +108,7 @@
|
||||
<input v-model="ancestorForm.birthDate" placeholder="如:1940年" placeholder-class="placeholder" />
|
||||
</view>
|
||||
</view>
|
||||
<text v-if="ancestorState === 'error'" class="flow-error">首代人物保存失败,请稍后重试。</text>
|
||||
|
||||
<view class="intro-field">
|
||||
<text>生平简述</text>
|
||||
@@ -112,7 +121,40 @@
|
||||
src="/static/assets/foundation/opaque/a01-primary-button.png"
|
||||
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>
|
||||
@@ -123,12 +165,15 @@
|
||||
<script setup>
|
||||
import { computed, onMounted, onUnmounted, reactive, ref } from "vue";
|
||||
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 genealogyId = ref("");
|
||||
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({
|
||||
surname: "",
|
||||
name: "",
|
||||
@@ -164,11 +209,7 @@ const syncFlowFromRoute = () => {
|
||||
: query.step === "ancestor";
|
||||
const routeGenealogyId = query.get ? query.get("genealogyId") : query.genealogyId;
|
||||
currentStep.value = isAncestorRoute ? "ancestor" : "create";
|
||||
genealogyId.value = routeGenealogyId || (isAncestorStep.value ? genealogyContext.getCurrentGenealogyId() : "");
|
||||
|
||||
if (isAncestorStep.value && genealogyId.value) {
|
||||
genealogyContext.setCurrentGenealogyId(genealogyId.value);
|
||||
}
|
||||
genealogyId.value = routeGenealogyId || (isAncestorStep.value ? "local-created-genealogy" : "");
|
||||
};
|
||||
|
||||
onLoad(() => {
|
||||
@@ -186,6 +227,7 @@ onMounted(() => {
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
if (submitTimer) clearTimeout(submitTimer);
|
||||
if (typeof window !== "undefined") {
|
||||
window.removeEventListener("hashchange", syncFlowFromRoute);
|
||||
}
|
||||
@@ -200,52 +242,66 @@ const goBack = () => {
|
||||
uni.navigateBack();
|
||||
};
|
||||
|
||||
const submitCreate = async () => {
|
||||
if (!createForm.surname.trim() || !createForm.name.trim()) {
|
||||
uni.showToast({ title: "请填写姓氏和谱名", icon: "none" });
|
||||
return;
|
||||
}
|
||||
const clearFieldError = (field) => { fieldErrors[field] = ""; };
|
||||
|
||||
const created = await appApi.createGenealogy({ ...createForm });
|
||||
genealogyContext.setCurrentGenealogyId(created.id);
|
||||
uni.redirectTo({
|
||||
url: `/pages/genealogy/g03-create-genealogy?step=ancestor&genealogyId=${created.id}`,
|
||||
});
|
||||
const validateCreateForm = () => {
|
||||
fieldErrors.surname = createForm.surname.trim() ? "" : "请填写姓氏";
|
||||
fieldErrors.name = createForm.name.trim() ? "" : "请填写谱名";
|
||||
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 (!genealogyId.value) {
|
||||
uni.showToast({ title: "未找到当前家谱", icon: "none" });
|
||||
ancestorState.value = "error";
|
||||
return;
|
||||
}
|
||||
if (!ancestorForm.personName.trim()) {
|
||||
uni.showToast({ title: "请填写首代姓名", icon: "none" });
|
||||
fieldErrors.personName = "请填写首代姓名";
|
||||
return;
|
||||
}
|
||||
|
||||
isSubmitting.value = true;
|
||||
try {
|
||||
const person = await appApi.createPerson(genealogyId.value, {
|
||||
...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 {
|
||||
ancestorState.value = "submitting";
|
||||
submitTimer = setTimeout(() => {
|
||||
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>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.flow-page {
|
||||
position: relative;
|
||||
min-height: 100vh;
|
||||
overflow: hidden;
|
||||
overflow-x: hidden;
|
||||
background: #f9f6ef;
|
||||
}
|
||||
|
||||
@@ -338,7 +394,7 @@ const submitAncestor = async () => {
|
||||
|
||||
.create-flow-panel {
|
||||
position: relative;
|
||||
min-height: 876rpx;
|
||||
min-height: 1000rpx;
|
||||
}
|
||||
|
||||
.create-flow-panel__skin {
|
||||
@@ -353,7 +409,7 @@ const submitAncestor = async () => {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
min-height: 876rpx;
|
||||
min-height: 1000rpx;
|
||||
flex-direction: column;
|
||||
box-sizing: border-box;
|
||||
padding: 48rpx 50rpx 42rpx;
|
||||
@@ -420,6 +476,7 @@ const submitAncestor = async () => {
|
||||
.placeholder {
|
||||
color: #b7aa97;
|
||||
}
|
||||
.field-error { display: block; margin-top: 3rpx; color: $brand-red; font-size: 20rpx; text-align: right; }
|
||||
|
||||
.fixed-value {
|
||||
color: #a33b2b !important;
|
||||
@@ -439,14 +496,14 @@ const submitAncestor = async () => {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.flow-rule__value {
|
||||
color: #a33b2b;
|
||||
font-size: 23rpx;
|
||||
}
|
||||
.flow-rule__options { display: flex; gap: 12rpx; }
|
||||
.flow-rule__option { display: flex; min-height: 54rpx; align-items: center; padding: 0 10rpx; color: #786654; font-size: 21rpx; }
|
||||
.flow-rule__option--active { color: $brand-red; font-weight: 700; }
|
||||
|
||||
.flow-rule__note {
|
||||
margin-top: 8rpx;
|
||||
}
|
||||
.flow-error { display: block; margin-top: 10rpx; color: $brand-red; font-size: 21rpx; text-align: center; }
|
||||
|
||||
.intro-field {
|
||||
margin-top: 18rpx;
|
||||
@@ -501,4 +558,30 @@ const submitAncestor = async () => {
|
||||
.action-hover {
|
||||
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>
|
||||
|
||||
@@ -4,13 +4,13 @@
|
||||
<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" />
|
||||
<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 class="overview-surface">
|
||||
<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-hero">
|
||||
<text class="overview-hero__kicker">{{ genealogy.hall || '堂号待补' }} · {{ genealogy.location || '所在地待补' }}</text>
|
||||
@@ -23,17 +23,17 @@
|
||||
</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">
|
||||
<text class="overview-action__title">世系树</text><text>查看家脉关系</text>
|
||||
</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>
|
||||
</view>
|
||||
<view class="overview-action overview-action--poem" @click="toGenerationPoems">
|
||||
<text class="overview-action__title">字辈谱</text><text>传承行辈次序</text>
|
||||
</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>
|
||||
</view>
|
||||
</view>
|
||||
@@ -48,10 +48,34 @@
|
||||
</view>
|
||||
</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="{
|
||||
'overview-state--loading': overviewState === 'loading',
|
||||
'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__copy">{{ stateCopy }}</text>
|
||||
@@ -68,30 +92,49 @@
|
||||
import { computed, ref } from 'vue'
|
||||
import { onLoad } from '@dcloudio/uni-app'
|
||||
import PageHeader from '@/components/PageHeader.vue'
|
||||
import { appApi } from '@/utils/api.js'
|
||||
import { genealogyContext } from '@/utils/genealogy-context.js'
|
||||
|
||||
const genealogy = ref(null)
|
||||
const genealogyId = ref('')
|
||||
const overviewState = ref('loading')
|
||||
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(() => ({
|
||||
loading: '正在展开家谱…',
|
||||
empty: '还没有可查看的家谱',
|
||||
error: '家谱暂时无法打开'
|
||||
error: '家谱暂时无法打开',
|
||||
'no-permission': '当前账号无权查看'
|
||||
}[overviewState.value] || ''))
|
||||
const stateCopy = computed(() => loadError.value || ({
|
||||
loading: '请稍候,正在读取家谱概览。',
|
||||
empty: '请从“我的家谱”选择一部家谱后再进入。',
|
||||
error: '可能是网络波动、家谱不存在或当前账号无访问权限。'
|
||||
error: '可能是网络波动或家谱不存在。',
|
||||
'no-permission': '这部家谱未向当前账号开放,请返回我的家谱或联系管理员。'
|
||||
}[overviewState.value] || ''))
|
||||
|
||||
const loadGenealogy = async (query = {}) => {
|
||||
const loadGenealogy = (query = {}) => {
|
||||
overviewState.value = 'loading'
|
||||
loadError.value = ''
|
||||
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) {
|
||||
overviewState.value = 'empty'
|
||||
@@ -101,16 +144,14 @@ const loadGenealogy = async (query = {}) => {
|
||||
overviewState.value = 'error'
|
||||
return
|
||||
}
|
||||
|
||||
genealogyContext.setCurrentGenealogyId(genealogyId.value)
|
||||
try {
|
||||
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 === 'no-permission') {
|
||||
overviewState.value = 'no-permission'
|
||||
return
|
||||
}
|
||||
if (query.state === 'loading') return
|
||||
|
||||
genealogy.value = { ...overviewFixture, id: genealogyId.value }
|
||||
overviewState.value = 'ready'
|
||||
}
|
||||
|
||||
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 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 applyToJoin = () => uni.navigateTo({ url: `/pages/genealogy/g08-join-application?source=search&genealogyId=${genealogyId.value}` })
|
||||
</script>
|
||||
|
||||
<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__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; }
|
||||
@@ -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__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--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__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 text:first-child { color: $ink; font-family: 'STKaiti', 'KaiTi', serif; font-size: 28rpx; font-weight: 700; }
|
||||
.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 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-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) {
|
||||
.overview-hero { top: 7%; }
|
||||
.overview-action { padding: 0 28rpx; }
|
||||
|
||||
@@ -1,116 +1,292 @@
|
||||
<!-- 页面编号:G-06;用途:公开家谱检索,统一承载初始、加载、结果与无结果状态。 -->
|
||||
<!-- 页面编号:G-06;用途:搜索家谱/邀请码加入双模式定位目标家谱。 -->
|
||||
<template>
|
||||
<view class="search-page">
|
||||
<!-- 底图只提供完整纸纹,不以 CSS 补色或绘制装饰。 -->
|
||||
<image class="search-page__paper" src="/static/assets/foundation/opaque/page-paper.jpg" mode="aspectFill" />
|
||||
<image class="search-page__paper" src="/static/assets/foundation/opaque/page-paper.jpg" mode="scaleToFill" />
|
||||
<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-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__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 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 v-if="searchState === 'loading'" class="search-status search-loading">
|
||||
<text>正在整理可申请加入的家谱</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>
|
||||
<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 class="mode-tabs">
|
||||
<view class="mode-tab" :class="{ 'mode-tab--active': mode === 'search' }" @click="switchMode('search')">搜索家谱</view>
|
||||
<view class="mode-tab" :class="{ 'mode-tab--active': mode === 'invite' }" @click="switchMode('invite')">邀请码加入</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>
|
||||
</template>
|
||||
|
||||
<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 GenealogyCard from '@/components/GenealogyCard.vue'
|
||||
import { appApi } from '@/utils/api.js'
|
||||
|
||||
const mode = ref('search')
|
||||
const keyword = ref('')
|
||||
const allResults = ref([])
|
||||
const results = ref([])
|
||||
const selectedArea = ref('全部')
|
||||
const searchState = ref('initial')
|
||||
const inviteCode = ref('')
|
||||
const inviteState = ref('initial')
|
||||
const results = ref([])
|
||||
let searchTimer = null
|
||||
|
||||
onMounted(async () => {
|
||||
searchState.value = 'loading'
|
||||
allResults.value = await appApi.getPublicGenealogies()
|
||||
searchState.value = 'initial'
|
||||
})
|
||||
const areas = ['全部', '河南', '山东']
|
||||
const resultFixtures = [
|
||||
{ id: 2001, surname: '汤', name: '汤氏南阳宗谱', hall: '敦睦堂', location: '河南·南阳', parentName: '汤氏中华总谱', branchName: '南阳主支', manager: '管理员 汤文礼', certification: '资料已认证', memberCount: 428, updatedAt: '2026-07-12', relation: 'available', actionLabel: '申请加入' },
|
||||
{ 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 value = keyword.value.trim()
|
||||
results.value = value
|
||||
? allResults.value.filter((item) => `${item.name}${item.surname}${item.location}`.includes(value))
|
||||
: allResults.value
|
||||
searchState.value = results.value.length ? 'results' : 'empty'
|
||||
const inviteTarget = computed(() => resultFixtures[0])
|
||||
|
||||
const syncModeFromRoute = () => {
|
||||
let requestedMode = 'search'
|
||||
if (typeof location !== 'undefined') {
|
||||
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>
|
||||
|
||||
<style scoped lang="scss">
|
||||
/* G06 的完整检索位图与叠放控件样式必须作为同一 SFC 样式模块刷新。 */
|
||||
.search-page { position: relative; min-height: 100vh; overflow: hidden; background: $paper; }
|
||||
.search-page { position: relative; min-height: 100vh; overflow-x: hidden; background: $paper; }
|
||||
.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__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__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__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-controls { display: flex; align-items: center; gap: 20rpx; margin-top: 48rpx; }
|
||||
.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; }
|
||||
.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__skin, .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 30rpx; box-sizing: border-box; color: $ink; font-size: 26rpx; }
|
||||
.search-field__skin,
|
||||
.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-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__label { position: relative; z-index: 1; color: #fff4dc; font-family: 'STKaiti', 'KaiTi', serif; font-size: 31rpx; font-weight: 700; letter-spacing: 7rpx; }
|
||||
.search-divider { display: block; width: 100%; height: 30rpx; margin: 40rpx 0 34rpx; }
|
||||
|
||||
.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; }
|
||||
.search-action__label { position: relative; z-index: 1; color: #fff4dc; font-family: 'STKaiti', 'KaiTi', serif; font-size: 31rpx; font-weight: 700; letter-spacing: 5rpx; }
|
||||
.filter-row { display: flex; align-items: center; gap: 12rpx; margin: 16rpx 14rpx 0; }
|
||||
.filter-label { color: $ink-muted; font-size: 22rpx; }
|
||||
.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__lead { color: $ink; font-family: 'STKaiti', 'KaiTi', serif; font-size: 29rpx; font-weight: 700; letter-spacing: 2rpx; }
|
||||
.search-status__copy { margin-top: 12rpx; color: $ink-muted; font-size: 22rpx; }
|
||||
.search-status__hall { width: 330rpx; height: 132rpx; margin-top: 330rpx; opacity: .22; filter: grayscale(1); }
|
||||
.search-loading { min-height: 168rpx; color: $ink-muted; font-size: 24rpx; }
|
||||
.search-results { display: flex; flex-direction: column; gap: 18rpx; margin-top: 6rpx; }
|
||||
.search-results__head { display: flex; align-items: center; justify-content: space-between; padding: 0 16rpx; color: $ink-muted; font-size: 22rpx; }
|
||||
.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; line-height: 36rpx; }
|
||||
.search-status__hall { width: 330rpx; height: 132rpx; margin-top: 210rpx; opacity: .22; filter: grayscale(1); }
|
||||
.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: 16rpx; margin-top: 4rpx; }
|
||||
.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; }
|
||||
.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) {
|
||||
.search-page__content { padding-right: 20rpx; padding-left: 20rpx; }
|
||||
.search-title-strip__seal { left: 44rpx; }
|
||||
.search-title-strip__text { right: 148rpx; left: 138rpx; font-size: 37rpx; }
|
||||
.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>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<view class="join-page">
|
||||
<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" />
|
||||
<view class="join-page__header"><PageHeader title="申请加入家谱" /></view>
|
||||
<view class="join-page__header"><PageHeader :title="sourceContract.headerTitle" /></view>
|
||||
|
||||
<view class="join-panel" :class="{
|
||||
'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" />
|
||||
|
||||
<view v-if="joinState === 'form'" class="join-form">
|
||||
<text class="join-form__eyebrow">公开家谱入谱申请</text>
|
||||
<text class="join-form__title">申请加入 {{ genealogyName }}</text>
|
||||
<text class="join-form__copy">请填写真实身份和亲属关系,管理员审核后会通过消息告知结果。</text>
|
||||
<text class="join-form__eyebrow">{{ sourceContract.eyebrow }}</text>
|
||||
<text class="join-form__title">{{ sourceContract.formTitle }} {{ genealogyName }}</text>
|
||||
<text class="join-form__copy">{{ sourceContract.formCopy }}</text>
|
||||
|
||||
<view class="join-field">
|
||||
<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>
|
||||
<text v-if="fieldErrors.realName" class="join-field-error">{{ fieldErrors.realName }}</text>
|
||||
<view class="join-field">
|
||||
<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>
|
||||
<text v-if="fieldErrors.relation" class="join-field-error">{{ fieldErrors.relation }}</text>
|
||||
<text class="relation-help">请以家谱中一位已知长辈为参照,例如:汤正华堂侄</text>
|
||||
<view class="join-field join-field--message">
|
||||
<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>
|
||||
|
||||
<text class="join-form__note">提交后可在“我的申请”中查看审核进度。</text>
|
||||
<text class="join-form__note">{{ sourceContract.note }}</text>
|
||||
<view class="join-action" @click="submitJoin">
|
||||
<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 v-else class="join-result">
|
||||
<text class="join-result__eyebrow">{{ joinState === 'success' ? '申请已送达' : '申请未提交' }}</text>
|
||||
<text class="join-result__title">{{ joinState === 'success' ? '等待管理员核实亲属关系' : '暂时无法提交申请' }}</text>
|
||||
<text class="join-result__eyebrow">{{ joinState === 'success' ? sourceContract.successEyebrow : sourceContract.errorEyebrow }}</text>
|
||||
<text class="join-result__title">{{ joinState === 'success' ? sourceContract.successTitle : sourceContract.errorTitle }}</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" />
|
||||
<text>{{ joinState === 'success' ? '查看我的申请' : '重新填写' }}</text>
|
||||
<text>{{ joinState === 'success' ? sourceContract.nextLabel : '重新填写' }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
@@ -56,6 +59,7 @@ import { onLoad } from '@dcloudio/uni-app'
|
||||
import PageHeader from '@/components/PageHeader.vue'
|
||||
|
||||
const genealogyId = ref('')
|
||||
const source = ref('search')
|
||||
const genealogyName = ref('这部家谱')
|
||||
const genealogyPreview = {
|
||||
'1001': '汤氏家谱',
|
||||
@@ -65,12 +69,47 @@ const joinState = ref('form')
|
||||
const isSubmitting = ref(false)
|
||||
const errorMessage = ref('')
|
||||
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'
|
||||
? `“${genealogyName.value}”的管理员会在核实后给出结果,请留意消息中心。`
|
||||
? sourceContract.value.successCopy
|
||||
: errorMessage.value || '请检查网络后重新填写;未成功提交的内容不会进入审核列表。')
|
||||
|
||||
onLoad((query) => {
|
||||
genealogyId.value = query.genealogyId || ''
|
||||
source.value = query.source === 'invite' ? 'invite' : 'search'
|
||||
if (query.state === 'success') {
|
||||
joinState.value = 'success'
|
||||
return
|
||||
@@ -85,22 +124,25 @@ onLoad((query) => {
|
||||
|
||||
const submitJoin = () => {
|
||||
if (isSubmitting.value) return
|
||||
if (!form.realName.trim() || !form.relation.trim()) {
|
||||
uni.showToast({ title: '请填写真实姓名和亲属关系', icon: 'none' })
|
||||
return
|
||||
}
|
||||
fieldErrors.realName = form.realName.trim() ? '' : '请填写真实姓名'
|
||||
fieldErrors.relation = form.relation.trim() ? '' : '请填写与家谱的关系'
|
||||
if (fieldErrors.realName || fieldErrors.relation) return
|
||||
isSubmitting.value = true
|
||||
setTimeout(() => {
|
||||
joinState.value = 'success'
|
||||
joinState.value = form.realName.trim() === '失败' ? 'error' : 'success'
|
||||
if (joinState.value === 'error') errorMessage.value = source.value === 'invite' ? '邀请码加入暂未完成,请稍后重试。' : '申请暂未提交,请稍后重试。'
|
||||
isSubmitting.value = false
|
||||
}, 280)
|
||||
}
|
||||
const clearFieldError = (field) => { fieldErrors[field] = '' }
|
||||
const retryForm = () => { joinState.value = 'form'; errorMessage.value = '' }
|
||||
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>
|
||||
|
||||
<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__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; }
|
||||
@@ -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 textarea { box-sizing: border-box; padding-top: 26rpx; line-height: 1.5; }
|
||||
.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-action { position: relative; width: 100%; height: 82rpx; margin-top: 20rpx; }
|
||||
.join-action image { position: absolute; inset: 0; width: 100%; height: 100%; }
|
||||
|
||||
@@ -14,14 +14,15 @@
|
||||
<view class="application-intro">
|
||||
<text>入谱申请进度</text><text>审核结果会同步到消息中心</text>
|
||||
</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" />
|
||||
<view class="application-card__body">
|
||||
<text class="application-card__name">{{ item.genealogyName }}</text>
|
||||
<text class="application-card__time">{{ item.appliedAt }}</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__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>
|
||||
</template>
|
||||
@@ -29,8 +30,8 @@
|
||||
<view v-else class="application-state-card">
|
||||
<image src="/static/assets/modules/genealogy/opaque/application-status-card.png" mode="scaleToFill" />
|
||||
<view class="application-state-card__copy">
|
||||
<text>{{ applicationState === 'empty' ? '还没有入谱申请' : '申请记录暂时无法读取' }}</text>
|
||||
<text>{{ applicationState === 'empty' ? '从公开家谱检索开始,找到家谱后可提交加入申请。' : errorMessage || '请检查网络后重新查看。' }}</text>
|
||||
<text>{{ stateTitle }}</text>
|
||||
<text>{{ stateCopy }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<view v-if="applicationState !== 'list'" class="application-page__action" @click="applicationState === 'error' ? loadApplications({}) : toSearch()">
|
||||
@@ -38,17 +39,32 @@
|
||||
<text>{{ applicationState === 'error' ? '重新查看' : '查找公开家谱' }}</text>
|
||||
</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>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import { computed, ref } from 'vue'
|
||||
import { onLoad } from '@dcloudio/uni-app'
|
||||
import PageHeader from '@/components/PageHeader.vue'
|
||||
|
||||
const applications = ref([])
|
||||
const applicationState = ref('loading')
|
||||
const errorMessage = ref('')
|
||||
const withdrawTarget = ref(null)
|
||||
const applicationSamples = [
|
||||
{ id: 1, genealogyId: 1001, genealogyName: '汤氏家谱', relation: '自述为汤正华堂侄', appliedAt: '今天 10:24', status: 'PENDING' },
|
||||
{ id: 2, genealogyId: 1002, genealogyName: '汝南汤氏家谱', relation: '祖居河南汝南', appliedAt: '昨天 18:02', status: 'APPROVED' },
|
||||
@@ -58,28 +74,48 @@ const applicationSamples = [
|
||||
const statusLabel = (status) => ({
|
||||
'PENDING': '审核中',
|
||||
'APPROVED': '已通过',
|
||||
'REJECTED': '未通过'
|
||||
'REJECTED': '未通过',
|
||||
'WITHDRAWN': '已撤回'
|
||||
}[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 = {}) => {
|
||||
applicationState.value = 'loading'
|
||||
errorMessage.value = ''
|
||||
if (query.state === 'empty') { applicationState.value = 'empty'; return }
|
||||
if (query.state === 'error') { applicationState.value = 'error'; return }
|
||||
if (query.state === 'loading') { applicationState.value = 'loading'; return }
|
||||
applications.value = applicationSamples.map((item) => ({ ...item }))
|
||||
applicationState.value = 'list'
|
||||
}
|
||||
|
||||
onLoad(loadApplications)
|
||||
const openApprovedGenealogy = (item) => {
|
||||
if (item.status !== 'APPROVED') return
|
||||
uni.navigateTo({ url: `/pages/genealogy/g05-genealogy-overview?genealogyId=${item.genealogyId}` })
|
||||
const handleApplicationAction = (item) => {
|
||||
if (item.status === 'APPROVED') return 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' })
|
||||
</script>
|
||||
|
||||
<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__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; }
|
||||
@@ -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__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__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--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 > 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; }
|
||||
@@ -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 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; }
|
||||
.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>
|
||||
|
||||
@@ -8,7 +8,8 @@
|
||||
<view class="review-content" :class="{
|
||||
'review-state--list': reviewState === 'list',
|
||||
'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">
|
||||
<text>核实亲属关系后再决定</text>
|
||||
@@ -40,20 +41,46 @@
|
||||
<view v-else class="review-state-card">
|
||||
<image src="/static/assets/modules/genealogy/opaque/application-status-card.png" mode="scaleToFill" />
|
||||
<view class="review-state-card__copy">
|
||||
<text>{{ reviewState === 'empty' ? '暂无待审核申请' : '审核列表暂时无法读取' }}</text>
|
||||
<text>{{ reviewState === 'empty' ? '新的入谱申请会在这里出现,并同步发送消息提醒。' : errorMessage || '请检查网络或家谱权限后重试。' }}</text>
|
||||
<text>{{ stateTitle }}</text>
|
||||
<text>{{ stateCopy }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<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>
|
||||
</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>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import { onLoad } from '@dcloudio/uni-app'
|
||||
import { computed, ref } from 'vue'
|
||||
import { onLoad, onUnload } from '@dcloudio/uni-app'
|
||||
import PageHeader from '@/components/PageHeader.vue'
|
||||
import { genealogyContext } from '@/utils/genealogy-context.js'
|
||||
|
||||
@@ -65,6 +92,14 @@ const reviewSamples = [
|
||||
const genealogyId = ref('')
|
||||
const reviewState = ref('loading')
|
||||
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 = {}) => {
|
||||
reviewState.value = 'loading'
|
||||
@@ -72,6 +107,7 @@ const loadApplications = (query = {}) => {
|
||||
genealogyId.value = query.genealogyId?.value || query.genealogyId || genealogyId.value || genealogyContext.getCurrentGenealogyId()
|
||||
if (query.state === 'empty') { reviewState.value = 'empty'; return }
|
||||
if (query.state === 'error') { reviewState.value = 'error'; return }
|
||||
if (query.state === 'no-permission') { reviewState.value = 'no-permission'; return }
|
||||
if (!genealogyId.value) {
|
||||
errorMessage.value = '没有找到当前家谱,请从家谱总览进入。'
|
||||
reviewState.value = 'error'
|
||||
@@ -84,22 +120,27 @@ const loadApplications = (query = {}) => {
|
||||
|
||||
onLoad(loadApplications)
|
||||
const confirmAudit = (item, approved) => {
|
||||
uni.showModal({
|
||||
title: approved ? '确认通过申请?' : '确认拒绝申请?',
|
||||
content: approved ? '通过后,申请人将成为本家谱成员。' : '拒绝后,申请人会收到审核结果。',
|
||||
confirmText: approved ? '确认通过' : '确认拒绝',
|
||||
success: ({ confirm }) => {
|
||||
if (!confirm) return
|
||||
item.status = approved ? 'APPROVED' : 'REJECTED'
|
||||
uni.showToast({ title: approved ? '已通过申请' : '已拒绝申请', icon: 'none' })
|
||||
}
|
||||
})
|
||||
confirmation.value = { item, approved }
|
||||
}
|
||||
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>
|
||||
|
||||
<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__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; }
|
||||
@@ -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 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-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>
|
||||
|
||||
@@ -8,7 +8,8 @@
|
||||
<view class="settings-panel" :class="{
|
||||
'settings-state--form': settingsState === 'form',
|
||||
'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" />
|
||||
|
||||
@@ -20,8 +21,9 @@
|
||||
<view class="settings-field">
|
||||
<image src="/static/assets/modules/genealogy/opaque/g06-search-input-wide.png" mode="scaleToFill" />
|
||||
<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>
|
||||
<text v-if="nameError" class="settings-field-error">{{ nameError }}</text>
|
||||
|
||||
<view class="visibility-block">
|
||||
<text class="visibility-block__label">公开范围</text>
|
||||
@@ -47,25 +49,32 @@
|
||||
</view>
|
||||
|
||||
<view v-else class="settings-result">
|
||||
<text class="settings-result__eyebrow">{{ settingsState === 'success' ? '设置已保存' : '设置未保存' }}</text>
|
||||
<text class="settings-result__title">{{ settingsState === 'success' ? '新的访问规则已经生效' : '暂时无法打开家谱设置' }}</text>
|
||||
<text class="settings-result__copy">{{ settingsState === 'success' ? `${genealogyDraft.name} · ${visibilityLabel}` : '请从家谱总览重新进入,当前修改不会被保留。' }}</text>
|
||||
<text class="settings-result__eyebrow">{{ settingsState === 'success' ? '设置已保存' : settingsState === 'no-permission' ? '权限不足' : '设置未保存' }}</text>
|
||||
<text class="settings-result__title">{{ settingsState === 'success' ? '新的访问规则已经生效' : settingsState === 'no-permission' ? '当前账号不能修改家谱' : '暂时无法打开家谱设置' }}</text>
|
||||
<text class="settings-result__copy">{{ settingsState === 'success' ? `${genealogyDraft.name} · ${visibilityLabel}` : settingsState === 'no-permission' ? '只有家谱所有者可以修改名称、公开范围和访问说明。' : '请从家谱总览重新进入,当前修改不会被保留。' }}</text>
|
||||
<view class="settings-action" @click="settingsState = 'form'">
|
||||
<image src="/static/assets/foundation/opaque/a01-primary-button.png" mode="scaleToFill" />
|
||||
<text>{{ settingsState === 'success' ? '继续调整' : '重新查看' }}</text>
|
||||
</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>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
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'
|
||||
|
||||
const genealogyId = ref('')
|
||||
const settingsState = ref('loading')
|
||||
const nameError = ref('')
|
||||
const feedbackVisible = ref(false)
|
||||
let feedbackTimer = null
|
||||
const genealogyDraft = reactive({
|
||||
name: '汤氏家谱',
|
||||
visibility: 'MEMBER_ONLY',
|
||||
@@ -82,20 +91,23 @@ const visibilityHint = computed(() => genealogyDraft.visibility === 'MEMBER_ONLY
|
||||
|
||||
onLoad((query) => {
|
||||
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 = () => {
|
||||
if (!genealogyDraft.name.trim()) {
|
||||
uni.showToast({ title: '请填写家谱名称', icon: 'none' })
|
||||
nameError.value = '请填写家谱名称'
|
||||
return
|
||||
}
|
||||
settingsState.value = 'success'
|
||||
feedbackVisible.value = true
|
||||
feedbackTimer = setTimeout(() => { feedbackVisible.value = false; feedbackTimer = null }, 1800)
|
||||
}
|
||||
</script>
|
||||
|
||||
<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__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; }
|
||||
@@ -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 textarea { box-sizing: border-box; padding-top: 26rpx; line-height: 1.5; }
|
||||
.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__label { display: block; color: $ink; font-size: 22rpx; font-weight: 700; }
|
||||
.visibility-options { display: flex; gap: 14rpx; margin-top: 10rpx; }
|
||||
@@ -127,5 +140,8 @@ const saveSettings = () => {
|
||||
.settings-result__eyebrow { text-align: center; }
|
||||
.settings-result__copy { margin-top: 22rpx; }
|
||||
.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%; } }
|
||||
</style>
|
||||
|
||||
@@ -9,7 +9,8 @@
|
||||
'poem-state--list': poemState === 'list',
|
||||
'poem-state--empty': poemState === 'empty',
|
||||
'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" />
|
||||
|
||||
@@ -37,8 +38,9 @@
|
||||
<view class="poem-field">
|
||||
<image src="/static/assets/modules/genealogy/opaque/g06-search-input-wide.png" mode="scaleToFill" />
|
||||
<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>
|
||||
<text v-if="poemError" class="poem-field-error">{{ poemError }}</text>
|
||||
<view class="poem-policy">
|
||||
<text>缺失旧世代时</text>
|
||||
<view class="poem-policy__option" @click="stopMissingOldGeneration = !stopMissingOldGeneration">
|
||||
@@ -58,24 +60,31 @@
|
||||
</view>
|
||||
|
||||
<view v-else class="poem-state-card">
|
||||
<text class="poem-list__eyebrow">{{ poemState === 'empty' ? '尚未建立字辈' : '字辈暂不可用' }}</text>
|
||||
<text class="poem-list__title">{{ poemState === 'empty' ? '从第一段传承字序开始' : '暂时无法读取字辈诗' }}</text>
|
||||
<text class="poem-list__copy">{{ poemState === 'empty' ? '管理员可以一次录入连续字辈,系统会按世代拆分展示。' : '请从家谱总览重新进入,或稍后再试。' }}</text>
|
||||
<text class="poem-list__eyebrow">{{ poemState === 'empty' ? '尚未建立字辈' : poemState === 'no-permission' ? '权限不足' : '字辈暂不可用' }}</text>
|
||||
<text class="poem-list__title">{{ poemState === 'empty' ? '从第一段传承字序开始' : poemState === 'no-permission' ? '当前账号不能维护字辈' : '暂时无法读取字辈诗' }}</text>
|
||||
<text class="poem-list__copy">{{ poemState === 'empty' ? '管理员可以一次录入连续字辈,系统会按世代拆分展示。' : poemState === 'no-permission' ? '普通成员可以查看字辈,但只有具备管理权限的成员可以维护。' : '请从家谱总览重新进入,或稍后再试。' }}</text>
|
||||
<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>
|
||||
</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>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue'
|
||||
import { onLoad } from '@dcloudio/uni-app'
|
||||
import { onLoad, onUnload } from '@dcloudio/uni-app'
|
||||
import PageHeader from '@/components/PageHeader.vue'
|
||||
|
||||
const genealogyId = ref('')
|
||||
const poemState = ref('loading')
|
||||
const poemError = ref('')
|
||||
const feedbackVisible = ref(false)
|
||||
let feedbackTimer = null
|
||||
const poemDraft = ref('启宗敦本继世传芳')
|
||||
const stopMissingOldGeneration = ref(true)
|
||||
const poemRows = ref([
|
||||
@@ -88,24 +97,30 @@ const previewCharacters = computed(() => poemDraft.value.trim().split('').join('
|
||||
|
||||
onLoad((query) => {
|
||||
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 savePoems = () => {
|
||||
const characters = poemDraft.value.trim().split('').filter(Boolean)
|
||||
if (!characters.length) {
|
||||
uni.showToast({ title: '请录入字辈内容', icon: 'none' })
|
||||
poemError.value = '请录入字辈内容'
|
||||
return
|
||||
}
|
||||
if (poemDraft.value.trim() === '失败') {
|
||||
poemError.value = '字辈保存失败,请稍后重试'
|
||||
return
|
||||
}
|
||||
poemRows.value = characters.slice(0, 4).map((character, index) => ({ generationNo: 12 + index, character, current: index === 2 }))
|
||||
poemState.value = 'list'
|
||||
uni.showToast({ title: '字辈预览已更新', icon: 'none' })
|
||||
feedbackVisible.value = true
|
||||
feedbackTimer = setTimeout(() => { feedbackVisible.value = false; feedbackTimer = null }, 1800)
|
||||
}
|
||||
</script>
|
||||
|
||||
<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__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; }
|
||||
@@ -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 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-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 > text { color: $ink; font-size: 22rpx; font-weight: 700; }
|
||||
.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__copy { margin-top: 22rpx; }
|
||||
.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%; } }
|
||||
</style>
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
<!-- 页面编号:T-02;用途:世系图小屏、无成员与加载失败状态。 -->
|
||||
<template><ModulePage page-id="t02" /></template>
|
||||
<script setup>import ModulePage from '@/components/ModulePage.vue'</script>
|
||||
@@ -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"
|
||||
@@ -6,7 +6,7 @@ const renderSettleMilliseconds = 900
|
||||
const chromeDebuggingPort = process.env.CHROME_DEBUGGING_PORT || '9222'
|
||||
|
||||
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))
|
||||
@@ -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) => {
|
||||
for (let attempt = 0; attempt < 30; attempt += 1) {
|
||||
const result = await send('Runtime.evaluate', {
|
||||
@@ -79,24 +109,53 @@ const waitForFreshDocument = async (send, documentTimeOrigin) => {
|
||||
}
|
||||
|
||||
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' ? '汤' : '不存在的家谱'
|
||||
const prepared = await send('Runtime.evaluate', {
|
||||
expression: `(() => {
|
||||
const input = document.querySelector('.search-input input')
|
||||
const button = document.querySelector('.search-action')
|
||||
if (!input || !button) return false
|
||||
input.value = ${JSON.stringify(keyword)}
|
||||
input.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
button.click()
|
||||
return true
|
||||
})()`,
|
||||
returnByValue: true
|
||||
})
|
||||
if (!prepared.result?.value) throw new Error(`Could not prepare ${action}`)
|
||||
if (action === 'g06-invite') {
|
||||
const switched = await send('Runtime.evaluate', {
|
||||
expression: `(() => {
|
||||
const tab = document.querySelectorAll('.mode-tab')[1]
|
||||
if (!tab) return false
|
||||
tab.click()
|
||||
return true
|
||||
})()`,
|
||||
returnByValue: true
|
||||
})
|
||||
if (!switched.result?.value) throw new Error('Could not switch G06 to invite mode')
|
||||
await sleep(100)
|
||||
const prepared = await send('Runtime.evaluate', {
|
||||
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) {
|
||||
const result = await send('Runtime.evaluate', {
|
||||
expression: `Boolean(document.querySelector(${JSON.stringify(stateSelector)}))`,
|
||||
@@ -146,11 +205,7 @@ const capture = async () => {
|
||||
await sleep(100)
|
||||
}
|
||||
|
||||
if (action === 'sms-tab') {
|
||||
await send('Runtime.evaluate', {
|
||||
expression: "document.querySelectorAll('.login-tab')[1]?.click()"
|
||||
})
|
||||
}
|
||||
await prepareA01LoginState(send, action)
|
||||
await prepareG06State(send, action)
|
||||
|
||||
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() }
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
After Width: | Height: | Size: 654 KiB |
|
After Width: | Height: | Size: 492 KiB |
|
After Width: | Height: | Size: 6.1 KiB |
|
After Width: | Height: | Size: 8.5 KiB |
|
After Width: | Height: | Size: 7.3 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 84 KiB |
|
After Width: | Height: | Size: 991 KiB |
|
After Width: | Height: | Size: 76 KiB |
|
After Width: | Height: | Size: 9.8 KiB |
|
After Width: | Height: | Size: 19 KiB |
@@ -56,10 +56,8 @@ $entry = Get-Content -LiteralPath $entryPath -Raw -Encoding utf8
|
||||
|
||||
# Final decorative surfaces must come from real bitmap assets.
|
||||
foreach ($asset in @(
|
||||
'a01-login-header-v1.png',
|
||||
'a01-login-header-exact-v2.png',
|
||||
'auth-ink-scenery.png',
|
||||
'a01-login-scroll-frame-v1.png',
|
||||
'a01-red-hall-ink-backdrop-v1.png',
|
||||
'brand-seal.png',
|
||||
'a01-title-divider-v2.png',
|
||||
'a01-primary-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"
|
||||
}
|
||||
foreach ($obsoleteHeaderAsset in @('auth-header.png', 'brand-seal.png', 'a01-paper-transition-v1.png')) {
|
||||
Assert-NotContains -Content $entry -Unexpected $obsoleteHeaderAsset -Message "A01 must use the selected design's single exact header bitmap instead of composing $obsoleteHeaderAsset."
|
||||
foreach ($obsoleteVisualAsset in @(
|
||||
'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 = @(
|
||||
@@ -96,6 +102,8 @@ $requiredCopy = @(
|
||||
foreach ($copy in $requiredCopy) {
|
||||
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 @(
|
||||
"const activeLoginMethod = ref('password')",
|
||||
@@ -109,7 +117,9 @@ foreach ($contract in @(
|
||||
"url: '/pages/auth/a05-reset-password'",
|
||||
'class="feedback-toast"',
|
||||
'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"
|
||||
}
|
||||
@@ -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 '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-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 '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-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'
|
||||
|
||||
@@ -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'
|
||||
@@ -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'
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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' }
|
||||
Assert-NotContains -Content $register -Unexpected 'ModulePage' -Message 'A-04 must not remain a ModulePage shell'
|
||||
foreach ($required in @(
|
||||
'auth-header',
|
||||
'register-panel__skin',
|
||||
'a01-red-hall-ink-backdrop-v1.png',
|
||||
'brand-seal.png',
|
||||
'a02-login-panel.png',
|
||||
'a01-primary-button.png',
|
||||
'a02-agreement-unchecked.png',
|
||||
@@ -36,11 +36,17 @@ foreach ($required in @(
|
||||
'v-model="password"',
|
||||
'v-model="confirmPassword"',
|
||||
'const agreed = ref(false)',
|
||||
'const agreementError = ref(false)',
|
||||
'const fieldErrors = ref({',
|
||||
'const verificationVisible = ref(false)',
|
||||
'const toggleAgreement = () =>',
|
||||
'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 (!password.value)',
|
||||
'if (!confirmPassword.value)',
|
||||
'if (password.value !== confirmPassword.value)'
|
||||
)) {
|
||||
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-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 '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 @(
|
||||
'auth-divider-knot.png',
|
||||
'register-divider',
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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' }
|
||||
Assert-NotContains -Content $page -Unexpected 'ModulePage' -Message 'A-05 must not remain a ModulePage shell'
|
||||
foreach ($required in @(
|
||||
'auth-header',
|
||||
'reset-panel__skin',
|
||||
'a01-red-hall-ink-backdrop-v1.png',
|
||||
'brand-seal.png',
|
||||
'a02-login-panel.png',
|
||||
'a01-primary-button.png',
|
||||
'auth-divider-knot.png',
|
||||
@@ -40,7 +40,13 @@ foreach ($required in @(
|
||||
'v-model="confirmPassword"',
|
||||
'const prepareGetCode = () =>',
|
||||
'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 (!/^\d{6}$/.test(verificationCode.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 $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 $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 @(
|
||||
'(?s)\.reset-panel\s*\{[^}]*\bborder\s*:',
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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/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 $catalog -Unexpected 'a06:' -Message 'A-06 must not remain in the temporary page catalog'
|
||||
|
||||
foreach ($required in @(
|
||||
"const status = ref('normal')",
|
||||
"const status = ref('risk')",
|
||||
"const statusConfig = {",
|
||||
'normal:',
|
||||
'failed:',
|
||||
'restricted:',
|
||||
"'register-pending':",
|
||||
"'wechat-cancelled':",
|
||||
"'wechat-failed':",
|
||||
'frozen:',
|
||||
'disabled:',
|
||||
'risk:',
|
||||
'reasonLabel:',
|
||||
'impactLabel:',
|
||||
'recoveryLabel:',
|
||||
'const resolveStatus = () =>',
|
||||
'const goLogin = () =>',
|
||||
'const goRegister = () =>',
|
||||
'const handlePrimary = () =>',
|
||||
'const openRecovery = () =>',
|
||||
'const closeRecovery = () =>',
|
||||
'const goBack = () =>',
|
||||
'auth-header.png',
|
||||
'a01-red-hall-ink-backdrop-v1.png',
|
||||
'brand-seal.png',
|
||||
'a02-login-panel.png',
|
||||
'a01-primary-button.png',
|
||||
'auth-title-cloud.png',
|
||||
'auth-divider-knot.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"
|
||||
}
|
||||
|
||||
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 @(
|
||||
'(?s)\.status-panel\s*\{[^}]*\bborder\s*:',
|
||||
'(?s)\.status-panel\s*\{[^}]*\bbackground\s*:',
|
||||
|
||||
@@ -1,93 +1,98 @@
|
||||
const assert = require('assert')
|
||||
|
||||
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 connect = async () => {
|
||||
const pages = await (await fetch('http://127.0.0.1:9222/json/list')).json()
|
||||
const page = pages.find((item) => item.type === 'page' && item.url.includes('localhost:5173'))
|
||||
if (!page) throw new Error('Chrome debugging has no localhost:5173 page')
|
||||
|
||||
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 }
|
||||
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 < 30; attempt += 1) {
|
||||
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, title) => {
|
||||
await send('Page.navigate', { url })
|
||||
await waitFor(send, `location.href === ${JSON.stringify(url)}`, `Did not navigate to ${url}`)
|
||||
await send('Page.reload')
|
||||
await waitFor(send, `location.href === ${JSON.stringify(url)}`, `Reload did not retain ${url}`)
|
||||
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 navigate = async (send, status, title) => {
|
||||
const routeQuery = status ? `?status=${status}` : ''
|
||||
const targetUrl = `http://localhost:5173/?runtime=${Date.now()}#/pages/auth/a06-auth-status${routeQuery}`
|
||||
await send('Page.navigate', { url: targetUrl })
|
||||
await waitFor(send, `document.querySelector('.status-title')?.textContent === ${JSON.stringify(title)}`, `A06 did not render ${title}`)
|
||||
}
|
||||
|
||||
const run = async () => {
|
||||
const { socket, send } = await connect()
|
||||
|
||||
const { socket, send, exceptions } = await connect()
|
||||
try {
|
||||
await send('Page.enable')
|
||||
await send('Runtime.enable')
|
||||
|
||||
await navigate(send, baseUrl, expected.normal)
|
||||
await valueOf(send, "document.querySelector('.status-primary')?.click()")
|
||||
await waitFor(send, "location.href.includes('/pages/auth/a01-entry')", 'Default A06 action did not return to A01')
|
||||
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, '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)
|
||||
await valueOf(send, "document.querySelector('.status-primary')?.click()")
|
||||
await waitFor(send, `document.body.innerText.includes(${JSON.stringify(expected.help)})`, 'Restricted A06 action did not show the local help notice')
|
||||
for (const state of [
|
||||
{ query: 'frozen', title: '账号已被冻结' },
|
||||
{ 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 waitFor(send, "location.href.includes('/pages/auth/a04-register')", 'Registration-pending A06 action did not open A04')
|
||||
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 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')
|
||||
} finally {
|
||||
try { await send('Emulation.clearDeviceMetricsOverride') } catch (_) {}
|
||||
socket.close()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,11 +45,22 @@ if ($captureScript -notmatch 'await waitForFreshDocument\(send, documentTimeOrig
|
||||
if ($captureScript -notmatch "action !== 'fresh-navigation'") {
|
||||
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)) {
|
||||
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\) =>') {
|
||||
throw 'Chrome capture must prepare G06 search states through the real input and action controls'
|
||||
}
|
||||
|
||||
@@ -26,10 +26,12 @@ if ($paths -contains 'pages/genealogy/g02-empty-genealogies') { throw 'G02 route
|
||||
|
||||
foreach ($required in @(
|
||||
'forceEmptyState',
|
||||
'query.get("state") === "empty"',
|
||||
'query.get("state") || "default"',
|
||||
'g01-empty-panel.png',
|
||||
'empty-create-action',
|
||||
'empty-search-action',
|
||||
'empty-invite-action',
|
||||
'empty-create-note',
|
||||
'const createGenealogy = () =>',
|
||||
'/pages/genealogy/g03-create-genealogy',
|
||||
'/pages/genealogy/g06-search-genealogies',
|
||||
|
||||
@@ -13,8 +13,10 @@ const connect = async () => {
|
||||
|
||||
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)
|
||||
@@ -28,11 +30,12 @@ const connect = async () => {
|
||||
socket.send(JSON.stringify({ id, method, params }))
|
||||
})
|
||||
|
||||
return { socket, send }
|
||||
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
|
||||
}
|
||||
|
||||
@@ -64,7 +67,7 @@ const openEmptyG01 = async (send) => {
|
||||
}
|
||||
|
||||
const run = async () => {
|
||||
const { socket, send } = await connect()
|
||||
const { socket, send, exceptions } = await connect()
|
||||
|
||||
try {
|
||||
await send('Page.enable')
|
||||
@@ -78,6 +81,10 @@ const run = async () => {
|
||||
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 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()
|
||||
await send('Page.navigate', { url: defaultUrl })
|
||||
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'))")) {
|
||||
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')
|
||||
} finally {
|
||||
try { await send('Emulation.clearDeviceMetricsOverride') } catch (_) {}
|
||||
socket.close()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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')) {
|
||||
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')) {
|
||||
if ($page -notmatch [regex]::Escape($asset)) { throw "G-01 does not consume $asset" }
|
||||
}
|
||||
|
||||
@@ -29,11 +29,13 @@ foreach ($required in @(
|
||||
'query.get("step") === "ancestor"',
|
||||
'window.addEventListener("hashchange", syncFlowFromRoute)',
|
||||
'step=ancestor&genealogyId=',
|
||||
'appApi.createGenealogy',
|
||||
'genealogyContext.setCurrentGenealogyId',
|
||||
'appApi.createPerson',
|
||||
'const createState = ref("form");',
|
||||
'const ancestorState = ref("form");',
|
||||
'const fieldErrors = reactive({',
|
||||
'const duplicateReminderVisible = ref(false);',
|
||||
'class="duplicate-reminder-layer"',
|
||||
'if (isSubmitting.value) return;',
|
||||
'/pages/tree/t01-tree-overview?genealogyId=',
|
||||
'/pages/genealogy/g05-genealogy-overview?genealogyId=',
|
||||
'g03-create-flow-panel.png',
|
||||
'root-header-cinnabar.jpg',
|
||||
'a01-primary-button.png',
|
||||
@@ -43,6 +45,10 @@ foreach ($required in @(
|
||||
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')) {
|
||||
Assert-NoCssSurface -Content $g03 -ClassName $className
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ const sleep = (milliseconds) => new Promise((resolve) => setTimeout(resolve, mil
|
||||
|
||||
const connect = async () => {
|
||||
const pages = await (await fetch('http://127.0.0.1:9222/json/list')).json()
|
||||
const page = pages.find((item) => item.type === 'page' && item.url.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')
|
||||
|
||||
const socket = new WebSocket(page.webSocketDebuggerUrl)
|
||||
@@ -13,8 +13,10 @@ const connect = async () => {
|
||||
|
||||
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)
|
||||
@@ -28,11 +30,12 @@ const connect = async () => {
|
||||
socket.send(JSON.stringify({ id, method, params }))
|
||||
})
|
||||
|
||||
return { socket, send }
|
||||
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
|
||||
}
|
||||
|
||||
@@ -70,7 +73,7 @@ const openDefaultG03 = async (send) => {
|
||||
}
|
||||
|
||||
const run = async () => {
|
||||
const { socket, send } = await connect()
|
||||
const { socket, send, exceptions } = await connect()
|
||||
|
||||
try {
|
||||
await send('Page.enable')
|
||||
@@ -79,23 +82,39 @@ const run = async () => {
|
||||
await openDefaultG03(send)
|
||||
await setInput(send, 0, '汤')
|
||||
await setInput(send, 1, '自动验证汤氏家谱')
|
||||
await setInput(send, 3, '河南·洛阳')
|
||||
await sleep(100)
|
||||
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, "Boolean(document.querySelector('.intro-field'))", 'G03 ancestor step did not render')
|
||||
|
||||
await send('Page.reload')
|
||||
await waitFor(send, "Boolean(document.querySelector('.intro-field'))", 'G03 ancestor step did not survive reload')
|
||||
await setInput(send, 0, '汤始祖')
|
||||
await sleep(100)
|
||||
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)
|
||||
if (await valueOf(send, "Boolean(document.querySelector('.intro-field'))")) {
|
||||
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')
|
||||
} finally {
|
||||
try { await send('Emulation.clearDeviceMetricsOverride') } catch (_) {}
|
||||
socket.close()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,9 +25,14 @@ if ($g01 -match 'g05-genealogy-overview\?id=') { throw 'G01 must not retain the
|
||||
foreach ($required in @(
|
||||
"const overviewState = ref('loading')",
|
||||
"query.genealogyId",
|
||||
"const viewMode = ref('member')",
|
||||
"const accessRole = ref('owner')",
|
||||
'const overviewFixture = {',
|
||||
'overview-ready',
|
||||
'overview-public',
|
||||
'overview-state--empty',
|
||||
'overview-state--error',
|
||||
'overview-state--no-permission',
|
||||
'g05-overview-surface.png',
|
||||
'/pages/genealogy/g03-create-genealogy?step=ancestor&genealogyId=',
|
||||
'/pages/tree/t01-tree-overview?genealogyId=',
|
||||
@@ -38,6 +43,9 @@ foreach ($required in @(
|
||||
)) {
|
||||
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 'toJoinApplication') { throw 'G05 must not mislabel the applicant join form as an owner invitation action' }
|
||||
|
||||