Review changes batch 5 of 6
@@ -1,185 +1,551 @@
|
||||
<!-- 公共组件:F/R/N/M 剩余页面的高规格多任务母版;内容独立,视觉表面统一复用完整位图。 -->
|
||||
<!-- 公共组件:F/R/N/M 普通任务页母版;业务结构按类型区分,视觉继承各模块基准页。 -->
|
||||
<template>
|
||||
<view class="module-page" :class="[
|
||||
`module-page--${page.template}`,
|
||||
{
|
||||
'module-state--ready': moduleState === 'ready',
|
||||
'module-state--empty': moduleState === 'empty',
|
||||
'module-state--success': moduleState === 'success',
|
||||
'module-state--error': moduleState === 'error'
|
||||
}
|
||||
]">
|
||||
<view
|
||||
class="module-page"
|
||||
:class="[
|
||||
`module-page--${moduleKey}`,
|
||||
`module-page--${page.template}`,
|
||||
`module-state--${moduleState}`,
|
||||
]"
|
||||
>
|
||||
<ModulePageBackground :module="moduleKey" />
|
||||
<view class="module-page__header"><PageHeader :title="page.title" /></view>
|
||||
|
||||
<view v-if="moduleState === 'ready'" class="module-page__content">
|
||||
<view class="page-intro">
|
||||
<image class="page-intro__seal" src="/static/assets/foundation/transparent/brand-seal.png" mode="aspectFit" />
|
||||
<view><text class="page-intro__number">{{ page.number }}</text><text class="page-intro__subtitle">{{ page.subtitle }}</text></view>
|
||||
<view v-if="moduleState === 'loading'" class="module-page__loading">
|
||||
<AppLoading
|
||||
:text="`正在整理${page.title}`"
|
||||
description="请稍候,内容正在归卷。"
|
||||
/>
|
||||
</view>
|
||||
|
||||
<view v-else-if="moduleState === 'ready'" class="module-page__content">
|
||||
<view class="module-lead">
|
||||
<image
|
||||
src="/static/assets/modules/genealogy/transparent/section-divider.png"
|
||||
mode="scaleToFill"
|
||||
/>
|
||||
<text>{{ page.subtitle }}</text>
|
||||
</view>
|
||||
|
||||
<view v-if="page.template === 'form'" class="module-panel">
|
||||
<image class="module-panel__skin" src="/static/assets/modules/genealogy/opaque/g03-create-flow-panel.png" mode="scaleToFill" />
|
||||
<view class="module-panel__body module-form">
|
||||
<text class="module-heading">{{ page.title }}</text>
|
||||
<text class="module-copy">{{ page.subtitle }}</text>
|
||||
<view v-for="field in page.fields" :key="field" class="form-row">
|
||||
<image src="/static/assets/modules/genealogy/opaque/g06-search-input-wide.png" mode="scaleToFill" /><text>{{ field }}</text><input :placeholder="`请输入${field}`" placeholder-class="form-placeholder" />
|
||||
</view>
|
||||
<text v-if="page.note" class="form-note">{{ normalizedNote }}</text>
|
||||
<AppButton block :label="page.action" @click="completeAction" />
|
||||
<view v-if="page.template === 'form'" class="module-panel module-form">
|
||||
<text class="section-eyebrow">填写信息</text>
|
||||
<view v-for="field in page.fields" :key="field" class="form-row">
|
||||
<image :src="fieldAsset" mode="scaleToFill" />
|
||||
<text>{{ field }}</text>
|
||||
<input
|
||||
:placeholder="`请输入${field}`"
|
||||
placeholder-class="form-placeholder"
|
||||
/>
|
||||
</view>
|
||||
<text v-if="page.note" class="form-note">{{ normalizedNote }}</text>
|
||||
<AppButton block :label="page.action" @click="completeAction" />
|
||||
</view>
|
||||
|
||||
<view v-else-if="page.template === 'list'" class="content-stack">
|
||||
<view v-for="item in page.sections" :key="item[0]" class="list-card" @click="openPreview(item)">
|
||||
<image src="/static/assets/modules/genealogy/opaque/application-status-card.png" mode="scaleToFill" />
|
||||
<view class="list-card__copy"><text>{{ item[0] }}</text><text>{{ item[1] }}</text><text>点按查看详情</text></view>
|
||||
<view
|
||||
v-for="item in page.sections"
|
||||
:key="item[0]"
|
||||
class="list-card"
|
||||
@click="openPreview(item)"
|
||||
>
|
||||
<image :src="contentAsset" mode="scaleToFill" />
|
||||
<view class="list-card__copy">
|
||||
<text>{{ item[0] }}</text>
|
||||
<text>{{ item[1] }}</text>
|
||||
<text>查看详情</text>
|
||||
</view>
|
||||
</view>
|
||||
<AppButton block :label="page.action" @click="completeAction" />
|
||||
</view>
|
||||
|
||||
<view v-else-if="page.template === 'detail'" class="module-panel module-panel--detail">
|
||||
<image class="module-panel__skin" src="/static/assets/modules/genealogy/opaque/g03-create-flow-panel.png" mode="scaleToFill" />
|
||||
<view class="module-panel__body module-detail">
|
||||
<text class="detail-eyebrow">{{ page.number }} · 家族档案</text>
|
||||
<text class="module-heading">{{ page.title }}</text>
|
||||
<text class="module-copy">{{ page.subtitle }}</text>
|
||||
<view v-for="item in page.sections" :key="item[0]" class="detail-card">
|
||||
<image src="/static/assets/modules/genealogy/opaque/g06-search-input-wide.png" mode="scaleToFill" /><view><text>{{ item[0] }}</text><text>{{ item[1] }}</text></view>
|
||||
</view>
|
||||
<AppButton block :label="page.action" @click="completeAction" />
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view v-else-if="page.template === 'timeline'" class="content-stack">
|
||||
<view v-for="(item, index) in page.sections" :key="item[0]" class="timeline-row">
|
||||
<image src="/static/assets/modules/genealogy/opaque/application-status-card.png" mode="scaleToFill" />
|
||||
<view><text>第 {{ index + 1 }} 则</text><text>{{ item[0] }}</text><text>{{ item[1] }}</text></view>
|
||||
<view
|
||||
v-else-if="page.template === 'detail'"
|
||||
class="module-panel module-detail"
|
||||
>
|
||||
<text class="section-eyebrow">档案详情</text>
|
||||
<view v-for="item in page.sections" :key="item[0]" class="detail-card">
|
||||
<image :src="contentAsset" mode="scaleToFill" />
|
||||
<view
|
||||
><text>{{ item[0] }}</text
|
||||
><text>{{ item[1] }}</text></view
|
||||
>
|
||||
</view>
|
||||
<AppButton block :label="page.action" @click="completeAction" />
|
||||
</view>
|
||||
|
||||
<view v-else-if="page.template === 'settings'" class="module-panel">
|
||||
<image class="module-panel__skin" src="/static/assets/modules/genealogy/opaque/g03-create-flow-panel.png" mode="scaleToFill" />
|
||||
<view class="module-panel__body module-settings">
|
||||
<text class="module-heading">{{ page.title }}</text><text class="module-copy">{{ page.subtitle }}</text>
|
||||
<view v-for="item in page.sections" :key="item[0]" class="settings-row" @click="openPreview(item)">
|
||||
<image src="/static/assets/modules/genealogy/opaque/g06-search-input-wide.png" mode="scaleToFill" /><view><text>{{ item[0] }}</text><text>{{ item[1] }}</text></view><text>查看</text>
|
||||
</view>
|
||||
<AppButton block :label="page.action" @click="completeAction" />
|
||||
<view
|
||||
v-else-if="page.template === 'timeline'"
|
||||
class="content-stack timeline-stack"
|
||||
>
|
||||
<view
|
||||
v-for="(item, index) in page.sections"
|
||||
:key="item[0]"
|
||||
class="timeline-row"
|
||||
>
|
||||
<image :src="contentAsset" mode="scaleToFill" />
|
||||
<view
|
||||
><text>第 {{ index + 1 }} 则</text><text>{{ item[0] }}</text
|
||||
><text>{{ item[1] }}</text></view
|
||||
>
|
||||
</view>
|
||||
<AppButton block :label="page.action" @click="completeAction" />
|
||||
</view>
|
||||
|
||||
<view v-else class="module-panel">
|
||||
<image class="module-panel__skin" src="/static/assets/modules/genealogy/opaque/g03-create-flow-panel.png" mode="scaleToFill" />
|
||||
<view class="module-panel__body status-card">
|
||||
<text class="status-card__eyebrow">{{ page.badge }} · 服务说明</text><text class="status-card__lead">{{ page.lead }}</text><text class="status-card__note">{{ normalizedNote }}</text>
|
||||
<view
|
||||
v-else-if="page.template === 'settings'"
|
||||
class="module-panel module-settings"
|
||||
>
|
||||
<text class="section-eyebrow">服务与设置</text>
|
||||
<view
|
||||
v-for="item in page.sections"
|
||||
:key="item[0]"
|
||||
class="settings-row"
|
||||
@click="openPreview(item)"
|
||||
>
|
||||
<image :src="fieldAsset" mode="scaleToFill" />
|
||||
<view
|
||||
><text>{{ item[0] }}</text
|
||||
><text>{{ normalizedSectionCopy(item[1]) }}</text></view
|
||||
>
|
||||
<text>查看</text>
|
||||
</view>
|
||||
<AppButton block :label="page.action" @click="completeAction" />
|
||||
</view>
|
||||
|
||||
<view v-else class="module-panel status-card">
|
||||
<image
|
||||
class="status-card__skin"
|
||||
:src="contentAsset"
|
||||
mode="scaleToFill"
|
||||
/>
|
||||
<view class="status-card__body">
|
||||
<text class="status-card__eyebrow">{{ page.badge }} · 服务说明</text>
|
||||
<text class="status-card__lead">{{ page.lead }}</text>
|
||||
<text class="status-card__note">{{ normalizedNote }}</text>
|
||||
<AppButton block :label="page.action" @click="completeAction" />
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view v-else class="module-page__content module-page__content--state">
|
||||
<view class="module-panel">
|
||||
<image class="module-panel__skin" src="/static/assets/modules/genealogy/opaque/g03-create-flow-panel.png" mode="scaleToFill" />
|
||||
<view class="module-panel__body status-card">
|
||||
<text class="status-card__eyebrow">{{ stateCopy.eyebrow }}</text><text class="status-card__lead">{{ stateCopy.title }}</text><text class="status-card__note">{{ stateCopy.copy }}</text>
|
||||
<AppButton block :type="moduleState === 'error' ? 'secondary' : 'primary'" :label="stateCopy.action" @click="moduleState = 'ready'" />
|
||||
<view class="module-panel status-card">
|
||||
<image
|
||||
class="status-card__skin"
|
||||
:src="contentAsset"
|
||||
mode="scaleToFill"
|
||||
/>
|
||||
<view class="status-card__body">
|
||||
<text class="status-card__eyebrow">{{ stateCopy.eyebrow }}</text>
|
||||
<text class="status-card__lead">{{ stateCopy.title }}</text>
|
||||
<text class="status-card__note">{{ stateCopy.copy }}</text>
|
||||
<AppButton
|
||||
block
|
||||
:type="moduleState === 'error' ? 'secondary' : 'primary'"
|
||||
:label="stateCopy.action"
|
||||
@click="moduleState = 'ready'"
|
||||
/>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<AppTabbar v-if="page.tab" :active="page.tab" />
|
||||
<AppToast :visible="toastVisible" :message="toastMessage" />
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, onUnmounted, ref } from 'vue'
|
||||
import AppButton from '@/components/AppButton.vue'
|
||||
import PageHeader from '@/components/PageHeader.vue'
|
||||
import AppTabbar from '@/components/AppTabbar.vue'
|
||||
import AppToast from '@/components/AppToast.vue'
|
||||
import ModulePageBackground from '@/components/ModulePageBackground.vue'
|
||||
import { pageCatalog } from '@/data/page-catalog.js'
|
||||
import { computed, onUnmounted, ref } from "vue";
|
||||
import AppButton from "@/components/AppButton.vue";
|
||||
import AppLoading from "@/components/AppLoading.vue";
|
||||
import PageHeader from "@/components/PageHeader.vue";
|
||||
import AppTabbar from "@/components/AppTabbar.vue";
|
||||
import AppToast from "@/components/AppToast.vue";
|
||||
import ModulePageBackground from "@/components/ModulePageBackground.vue";
|
||||
import { pageCatalog } from "@/data/page-catalog.js";
|
||||
|
||||
const props = defineProps({ pageId: { type: String, required: true } })
|
||||
const page = computed(() => pageCatalog[props.pageId])
|
||||
const moduleKey = computed(() => ({ f: 'family', r: 'records', n: 'notification', m: 'profile' }[props.pageId[0]] || 'profile'))
|
||||
const props = defineProps({ pageId: { type: String, required: true } });
|
||||
const page = computed(() => pageCatalog[props.pageId]);
|
||||
const moduleKey = computed(
|
||||
() =>
|
||||
({ f: "family", r: "records", n: "notification", m: "profile" })[
|
||||
props.pageId[0]
|
||||
] || "profile",
|
||||
);
|
||||
const assetModule = computed(
|
||||
() =>
|
||||
({
|
||||
family: "family",
|
||||
records: "records",
|
||||
notification: "notification",
|
||||
profile: "profile",
|
||||
})[moduleKey.value],
|
||||
);
|
||||
const contentAsset = computed(
|
||||
() =>
|
||||
`/static/assets/modules/${assetModule.value}/transparent/module-content-frame.png`,
|
||||
);
|
||||
const fieldAsset = computed(
|
||||
() =>
|
||||
`/static/assets/modules/${assetModule.value}/transparent/module-field-frame.png`,
|
||||
);
|
||||
const query = (() => {
|
||||
if (typeof location !== 'undefined') return Object.fromEntries(new URLSearchParams(location.hash.split('?')[1] || ''))
|
||||
const pages = getCurrentPages(); return pages[pages.length - 1]?.options || {}
|
||||
})()
|
||||
const moduleState = ref('ready')
|
||||
const toastVisible = ref(false)
|
||||
const toastMessage = ref('')
|
||||
let toastTimer = null
|
||||
if (query.state === 'empty' || query.state === 'success' || query.state === 'error') moduleState.value = query.state
|
||||
const normalizedNote = computed(() => (page.value.note || '').replace(/待接入|服务待接入|功能待接入/g, '将在后续功能阶段开放'))
|
||||
const stateCopy = computed(() => ({
|
||||
empty: { eyebrow: `${page.value.number} · 暂无内容`, title: `还没有${page.value.title}记录`, copy: '完成第一条记录后,内容会按时间或类别整理在这里。', action: page.value.action },
|
||||
success: { eyebrow: '本地预览已保存', title: `${page.value.title}内容已更新`, copy: '当前候选已展示完整操作结果;正式数据将在接口阶段接入。', action: '继续查看' },
|
||||
error: { eyebrow: '内容暂不可用', title: `暂时无法打开${page.value.title}`, copy: '请稍后重新进入,现有资料不会受到影响。', action: '重新查看' }
|
||||
}[moduleState.value] || {}))
|
||||
const completeAction = () => { moduleState.value = 'success' }
|
||||
if (typeof location !== "undefined")
|
||||
return Object.fromEntries(
|
||||
new URLSearchParams(location.hash.split("?")[1] || ""),
|
||||
);
|
||||
const pages = getCurrentPages();
|
||||
return pages[pages.length - 1]?.options || {};
|
||||
})();
|
||||
const moduleState = ref(
|
||||
["loading", "empty", "success", "error"].includes(query.state)
|
||||
? query.state
|
||||
: "ready",
|
||||
);
|
||||
const toastVisible = ref(false);
|
||||
const toastMessage = ref("");
|
||||
let toastTimer = null;
|
||||
const normalizedNote = computed(() =>
|
||||
(page.value.note || "").replace(
|
||||
/待接入|服务待接入|功能待接入/g,
|
||||
"将在后续功能阶段开放",
|
||||
),
|
||||
);
|
||||
const normalizedSectionCopy = (copy) =>
|
||||
String(copy || "").replace(/待接入|功能待接入/g, "后续功能阶段开放");
|
||||
const stateCopy = computed(
|
||||
() =>
|
||||
({
|
||||
empty: {
|
||||
eyebrow: "暂无内容",
|
||||
title: `还没有${page.value.title}记录`,
|
||||
copy: "完成第一条记录后,内容会按时间或类别整理在这里。",
|
||||
action: page.value.action,
|
||||
},
|
||||
success: {
|
||||
eyebrow: "操作结果",
|
||||
title: `${page.value.title}内容已更新`,
|
||||
copy: "当前页面已展示操作结果;正式数据将在接口阶段接入。",
|
||||
action: "继续查看",
|
||||
},
|
||||
error: {
|
||||
eyebrow: "内容暂不可用",
|
||||
title: `暂时无法打开${page.value.title}`,
|
||||
copy: "请稍后重新进入,现有资料不会受到影响。",
|
||||
action: "重新查看",
|
||||
},
|
||||
})[moduleState.value] || {},
|
||||
);
|
||||
const completeAction = () => {
|
||||
moduleState.value = "success";
|
||||
};
|
||||
const openPreview = (item) => {
|
||||
toastMessage.value = item[0]
|
||||
toastVisible.value = true
|
||||
if (toastTimer) clearTimeout(toastTimer)
|
||||
toastTimer = setTimeout(() => { toastVisible.value = false; toastTimer = null }, 1800)
|
||||
}
|
||||
onUnmounted(() => { if (toastTimer) clearTimeout(toastTimer) })
|
||||
toastMessage.value = item[0];
|
||||
toastVisible.value = true;
|
||||
if (toastTimer) clearTimeout(toastTimer);
|
||||
toastTimer = setTimeout(() => {
|
||||
toastVisible.value = false;
|
||||
toastTimer = null;
|
||||
}, 1800);
|
||||
};
|
||||
onUnmounted(() => {
|
||||
if (toastTimer) clearTimeout(toastTimer);
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.module-page { position: relative; min-height: 100vh; overflow: hidden; background: $paper; }
|
||||
.module-page__header, .module-page__content { position: relative; z-index: 2; }
|
||||
.module-page__content { padding: 20rpx 24rpx 50rpx; }
|
||||
.page-intro { display: flex; align-items: center; min-height: 86rpx; margin: 0 8rpx 14rpx; }
|
||||
.page-intro__seal { width: 66rpx; height: 66rpx; margin-right: 13rpx; }
|
||||
.page-intro__number, .page-intro__subtitle { display: block; }
|
||||
.page-intro__number { color: $brand-red; font-size: 23rpx; font-weight: 700; letter-spacing: 3rpx; }
|
||||
.page-intro__subtitle { margin-top: 5rpx; color: $ink-muted; font-size: 20rpx; }
|
||||
.module-panel { position: relative; width: 100%; height: min(640px, calc((100vw - 24px) * 1.48)); }
|
||||
.module-panel__skin { position: absolute; inset: 0; width: 100%; height: 100%; }
|
||||
.module-panel__body { position: absolute; inset: 7.5% 8%; }
|
||||
.module-heading { display: block; color: $ink; font-family: STKaiti, KaiTi, serif; font-size: 34rpx; font-weight: 700; }
|
||||
.module-copy { display: block; margin-top: 8rpx; color: $ink-muted; font-size: 20rpx; line-height: 1.5; }
|
||||
.form-row { position: relative; height: 76rpx; margin-top: 12rpx; }
|
||||
.form-row image, .detail-card image, .settings-row image, .list-card > image, .timeline-row > image { position: absolute; inset: 0; width: 100%; height: 100%; }
|
||||
.form-row > text { position: absolute; top: 26rpx; left: 22rpx; z-index: 1; color: $ink; font-size: 21rpx; font-weight: 700; }
|
||||
.form-row input { position: absolute; top: 0; right: 18rpx; bottom: 0; left: 164rpx; z-index: 1; height: 76rpx; color: $ink; font-size: 21rpx; line-height: 76rpx; }
|
||||
.form-placeholder { color: #a79884; }
|
||||
.form-note { display: block; margin-top: 14rpx; color: $ink-muted; font-size: 19rpx; text-align: center; }
|
||||
.module-form > .app-button, .module-detail > .app-button, .module-settings > .app-button, .content-stack > .app-button { margin-top: 18rpx; }
|
||||
.content-stack { display: flex; flex-direction: column; gap: 14rpx; }
|
||||
.list-card, .timeline-row { position: relative; width: 100%; height: calc((100vw - 24px) * .34286); min-height: 220rpx; }
|
||||
.list-card__copy { position: absolute; inset: 19% 9%; z-index: 1; }
|
||||
.list-card__copy text { display: block; }
|
||||
.list-card__copy text:first-child { color: $ink; font-family: STKaiti, KaiTi, serif; font-size: 29rpx; font-weight: 700; }
|
||||
.list-card__copy text:nth-child(2) { margin-top: 9rpx; color: $ink-muted; font-size: 20rpx; }
|
||||
.list-card__copy text:last-child { margin-top: 9rpx; color: $brand-red; font-size: 18rpx; }
|
||||
.detail-eyebrow { display: block; color: $brand-red; font-size: 21rpx; letter-spacing: 3rpx; }
|
||||
.module-detail .module-heading { margin-top: 9rpx; }
|
||||
.detail-card, .settings-row { position: relative; min-height: 88rpx; margin-top: 14rpx; }
|
||||
.detail-card > view { position: relative; z-index: 1; padding: 18rpx 24rpx; }
|
||||
.detail-card text, .settings-row text { display: block; }
|
||||
.detail-card text:first-child { color: $brand-red; font-size: 21rpx; font-weight: 700; }
|
||||
.detail-card text:last-child { margin-top: 7rpx; color: $ink-muted; font-size: 20rpx; line-height: 1.45; }
|
||||
.timeline-row > view { position: absolute; inset: 17% 9%; z-index: 1; }
|
||||
.timeline-row text { display: block; }
|
||||
.timeline-row text:first-child { color: $brand-red; font-size: 18rpx; letter-spacing: 2rpx; }
|
||||
.timeline-row text:nth-child(2) { margin-top: 5rpx; color: $ink; font-family: STKaiti, KaiTi, serif; font-size: 29rpx; font-weight: 700; }
|
||||
.timeline-row text:last-child { margin-top: 8rpx; color: $ink-muted; font-size: 20rpx; }
|
||||
.settings-row > view { position: absolute; top: 19rpx; left: 22rpx; z-index: 1; }
|
||||
.settings-row > view text:first-child { color: $ink; font-size: 22rpx; font-weight: 700; }
|
||||
.settings-row > view text:last-child { margin-top: 5rpx; color: $ink-muted; font-size: 18rpx; }
|
||||
.settings-row > text { position: absolute; top: 34rpx; right: 22rpx; z-index: 1; color: $brand-red; font-size: 18rpx; }
|
||||
.status-card { top: 28%; text-align: center; }
|
||||
.status-card__eyebrow { display: block; color: $brand-red; font-size: 21rpx; letter-spacing: 3rpx; }
|
||||
.status-card__lead { display: block; margin-top: 14rpx; color: $ink; font-family: STKaiti, KaiTi, serif; font-size: 35rpx; font-weight: 700; }
|
||||
.status-card__note { display: block; margin-top: 17rpx; color: $ink-muted; font-size: 21rpx; line-height: 1.65; }
|
||||
.status-card .app-button { margin: 30rpx auto 0; }
|
||||
.module-page__content--state { padding-top: 18rpx; }
|
||||
@media (min-width: 400px) { .module-page__content { padding-right: 32rpx; padding-left: 32rpx; } }
|
||||
.module-page {
|
||||
position: relative;
|
||||
min-height: 100vh;
|
||||
overflow-x: hidden;
|
||||
background: $paper;
|
||||
}
|
||||
.module-page__header,
|
||||
.module-page__content,
|
||||
.module-page__loading {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
}
|
||||
.module-page__loading {
|
||||
min-height: calc(100vh - 100rpx);
|
||||
}
|
||||
.module-page__content {
|
||||
padding: 16rpx 26rpx 56rpx;
|
||||
}
|
||||
.module-lead {
|
||||
position: relative;
|
||||
height: 56rpx;
|
||||
margin: 0 12rpx 18rpx;
|
||||
color: $ink-muted;
|
||||
font-size: 23rpx;
|
||||
text-align: center;
|
||||
}
|
||||
.module-lead image {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
.module-lead text {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
}
|
||||
.module-panel {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
}
|
||||
.section-eyebrow {
|
||||
display: block;
|
||||
margin: 2rpx 8rpx 14rpx;
|
||||
color: $brand-red;
|
||||
font-family: STKaiti, KaiTi, serif;
|
||||
font-size: 29rpx;
|
||||
font-weight: 700;
|
||||
letter-spacing: 3rpx;
|
||||
}
|
||||
.form-row {
|
||||
position: relative;
|
||||
height: 84rpx;
|
||||
margin-top: 14rpx;
|
||||
}
|
||||
.form-row image,
|
||||
.detail-card image,
|
||||
.settings-row image,
|
||||
.list-card > image,
|
||||
.timeline-row > image {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
.form-row > text {
|
||||
position: absolute;
|
||||
top: 28rpx;
|
||||
left: 26rpx;
|
||||
z-index: 1;
|
||||
color: $ink;
|
||||
font-size: 24rpx;
|
||||
font-weight: 700;
|
||||
}
|
||||
.form-row input {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 22rpx;
|
||||
bottom: 0;
|
||||
left: 178rpx;
|
||||
z-index: 1;
|
||||
height: 84rpx;
|
||||
color: $ink;
|
||||
font-size: 23rpx;
|
||||
line-height: 84rpx;
|
||||
}
|
||||
.form-placeholder {
|
||||
color: #9e8e79;
|
||||
}
|
||||
.form-note {
|
||||
display: block;
|
||||
margin: 18rpx 18rpx 0;
|
||||
color: $ink-muted;
|
||||
font-size: 22rpx;
|
||||
line-height: 1.5;
|
||||
text-align: center;
|
||||
}
|
||||
.module-form > .app-button,
|
||||
.module-detail > .app-button,
|
||||
.module-settings > .app-button,
|
||||
.content-stack > .app-button {
|
||||
margin-top: 22rpx;
|
||||
}
|
||||
.content-stack {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16rpx;
|
||||
}
|
||||
.list-card,
|
||||
.timeline-row {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
min-height: 188rpx;
|
||||
}
|
||||
.list-card__copy {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
padding: 34rpx 46rpx 28rpx;
|
||||
}
|
||||
.list-card__copy text {
|
||||
display: block;
|
||||
}
|
||||
.list-card__copy text:first-child {
|
||||
color: $ink;
|
||||
font-family: STKaiti, KaiTi, serif;
|
||||
font-size: 30rpx;
|
||||
font-weight: 700;
|
||||
}
|
||||
.list-card__copy text:nth-child(2) {
|
||||
margin-top: 10rpx;
|
||||
color: $ink-muted;
|
||||
font-size: 23rpx;
|
||||
line-height: 1.45;
|
||||
}
|
||||
.list-card__copy text:last-child {
|
||||
margin-top: 11rpx;
|
||||
color: $brand-red;
|
||||
font-size: 21rpx;
|
||||
font-weight: 600;
|
||||
}
|
||||
.detail-card {
|
||||
position: relative;
|
||||
min-height: 170rpx;
|
||||
margin-top: 16rpx;
|
||||
}
|
||||
.detail-card > view {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
padding: 34rpx 44rpx;
|
||||
}
|
||||
.detail-card text,
|
||||
.settings-row text {
|
||||
display: block;
|
||||
}
|
||||
.detail-card text:first-child {
|
||||
color: $brand-red;
|
||||
font-size: 23rpx;
|
||||
font-weight: 700;
|
||||
}
|
||||
.detail-card text:last-child {
|
||||
margin-top: 10rpx;
|
||||
color: $ink;
|
||||
font-size: 24rpx;
|
||||
line-height: 1.55;
|
||||
}
|
||||
.timeline-row > view {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
padding: 29rpx 44rpx;
|
||||
}
|
||||
.timeline-row text {
|
||||
display: block;
|
||||
}
|
||||
.timeline-row text:first-child {
|
||||
color: $brand-red;
|
||||
font-size: 21rpx;
|
||||
letter-spacing: 2rpx;
|
||||
}
|
||||
.timeline-row text:nth-child(2) {
|
||||
margin-top: 6rpx;
|
||||
color: $ink;
|
||||
font-family: STKaiti, KaiTi, serif;
|
||||
font-size: 30rpx;
|
||||
font-weight: 700;
|
||||
}
|
||||
.timeline-row text:last-child {
|
||||
margin-top: 9rpx;
|
||||
color: $ink-muted;
|
||||
font-size: 23rpx;
|
||||
}
|
||||
.settings-row {
|
||||
position: relative;
|
||||
min-height: 94rpx;
|
||||
margin-top: 14rpx;
|
||||
}
|
||||
.settings-row > view {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
padding: 20rpx 98rpx 18rpx 26rpx;
|
||||
}
|
||||
.settings-row > view text:first-child {
|
||||
color: $ink;
|
||||
font-size: 24rpx;
|
||||
font-weight: 700;
|
||||
}
|
||||
.settings-row > view text:last-child {
|
||||
margin-top: 6rpx;
|
||||
color: $ink-muted;
|
||||
font-size: 21rpx;
|
||||
}
|
||||
.settings-row > text {
|
||||
position: absolute;
|
||||
top: 34rpx;
|
||||
right: 28rpx;
|
||||
z-index: 1;
|
||||
color: $brand-red;
|
||||
font-size: 21rpx;
|
||||
font-weight: 600;
|
||||
}
|
||||
.status-card {
|
||||
min-height: 330rpx;
|
||||
text-align: center;
|
||||
}
|
||||
.status-card__skin {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
.status-card__body {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
padding: 70rpx 60rpx 48rpx;
|
||||
}
|
||||
.status-card__eyebrow {
|
||||
display: block;
|
||||
color: $brand-red;
|
||||
font-size: 23rpx;
|
||||
letter-spacing: 3rpx;
|
||||
}
|
||||
.status-card__lead {
|
||||
display: block;
|
||||
margin-top: 14rpx;
|
||||
color: $ink;
|
||||
font-family: STKaiti, KaiTi, serif;
|
||||
font-size: 35rpx;
|
||||
font-weight: 700;
|
||||
}
|
||||
.status-card__note {
|
||||
display: block;
|
||||
margin-top: 17rpx;
|
||||
color: $ink-muted;
|
||||
font-size: 24rpx;
|
||||
line-height: 1.6;
|
||||
}
|
||||
.status-card .app-button {
|
||||
margin: 28rpx auto 0;
|
||||
}
|
||||
.module-page__content--state {
|
||||
padding-top: 72rpx;
|
||||
}
|
||||
.module-page--family .section-eyebrow {
|
||||
color: #9f2c23;
|
||||
}
|
||||
.module-page--records .section-eyebrow {
|
||||
color: #8c261f;
|
||||
}
|
||||
.module-page--notification .section-eyebrow {
|
||||
color: #9b2b21;
|
||||
}
|
||||
.module-page--profile .section-eyebrow {
|
||||
color: #a22d24;
|
||||
}
|
||||
@media (min-width: 400px) {
|
||||
.module-page__content {
|
||||
padding-right: 34rpx;
|
||||
padding-left: 34rpx;
|
||||
}
|
||||
}
|
||||
@media (max-width: 340px) {
|
||||
.module-page__content {
|
||||
padding-right: 20rpx;
|
||||
padding-left: 20rpx;
|
||||
}
|
||||
.form-row input {
|
||||
left: 162rpx;
|
||||
}
|
||||
.status-card__body {
|
||||
padding-right: 44rpx;
|
||||
padding-left: 44rpx;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
|
After Width: | Height: | Size: 1.4 MiB |
@@ -0,0 +1,275 @@
|
||||
# G01 Fixed Header And Independent List Scroll Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. The user forbids subagents, worktrees, git add, commit, push, reset, and checkout; execute inline in the existing workspace and preserve every pre-existing change.
|
||||
|
||||
**Goal:** Make G01's normal genealogy state keep its header, current genealogy card, shortcut grid, divider, and bottom tabbar fixed while only the three list groups and add action scroll vertically.
|
||||
|
||||
**Architecture:** Add one G01-only split-layout modifier for the normal `hasGenealogies` state. Keep the existing header/background/tabbar outside a single vertical `scroll-view`, place the current card/shortcuts/divider in a fixed flex section, and allocate all remaining viewport height to the list scroller. Loading, error, and empty states retain the existing normal-flow layout.
|
||||
|
||||
**Tech Stack:** Vue 3 `<script setup>`, uni-app `scroll-view`, SCSS, PowerShell static contracts, Node Chrome DevTools Protocol runtime smoke.
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- Modify only `pages/genealogy/g01-my-genealogies.vue`, directly related G01 tests, G01 design/acceptance documentation, and new runtime screenshots.
|
||||
- Do not modify `PageHeader.vue`, `AppTabbar.vue`, `GenealogyPageBackground.vue`, or another G page.
|
||||
- Do not add APIs, global state, pull-to-refresh, sticky group headings, collapse animations, or native UniApp prompts.
|
||||
- Preserve the approved `12rpx` spacing between adjacent application cards.
|
||||
- Verify 320×568, 360×640, 360×800, 412×915, and 412×1000.
|
||||
- H5 evidence remains internal; Android/HBuilderX is still required.
|
||||
- Do not use subagents, worktrees, git add, commit, push, reset, or checkout.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Lock the split-scroll contract with failing tests
|
||||
|
||||
**Files:**
|
||||
- Modify: `tests/g01-empty-state-contract.ps1`
|
||||
- Modify: `tests/g01-empty-state-runtime-smoke.js`
|
||||
- Test: `tests/g01-empty-state-contract.ps1`
|
||||
- Test: `tests/g01-empty-state-runtime-smoke.js`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: the existing G01 selectors `.current-slip`, `.shortcut-grid`, `.section-divider`, `.genealogy-lower`, `.create-action`.
|
||||
- Produces: required selectors `.genealogy-fixed-zone`, `.genealogy-list-scroll`, `.genealogy-index--split`, plus the runtime behavior contract for fixed and moving regions.
|
||||
|
||||
- [ ] **Step 1: Extend the PowerShell static contract**
|
||||
|
||||
Require one normal-state vertical scroll view, keep the fixed zone before it, keep `.genealogy-lower` inside it, and require the reset handlers:
|
||||
|
||||
```powershell
|
||||
foreach ($required in @(
|
||||
'class="genealogy-fixed-zone"',
|
||||
'class="genealogy-list-scroll"',
|
||||
'scroll-y',
|
||||
':scroll-top="listScrollCommand"',
|
||||
'@scroll="handleListScroll"',
|
||||
'const isListLayout = computed(',
|
||||
'const resetListScroll = async () =>'
|
||||
)) {
|
||||
if ($g01 -notmatch [regex]::Escape($required)) { throw "Missing G01 split-scroll contract: $required" }
|
||||
}
|
||||
|
||||
Assert-Match -Content $g01 -Pattern '(?s)<view class="genealogy-fixed-zone">.*class="current-slip".*class="shortcut-grid".*class="section-divider".*</view>\s*<scroll-view[^>]*class="genealogy-list-scroll"[^>]*>.*class="genealogy-lower".*</scroll-view>' -Message 'G01 fixed and scrolling regions are not separated correctly'
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Extend the runtime smoke**
|
||||
|
||||
For each required viewport, record fixed element rectangles, scroll the inner region, and assert only the lower heading moves:
|
||||
|
||||
```js
|
||||
const before = await valueOf(send, `(() => {
|
||||
const selectors = ['.page-header', '.current-slip', '.shortcut-grid', '.section-divider', '.app-tabbar']
|
||||
return Object.fromEntries(selectors.map((selector) => [selector, document.querySelector(selector)?.getBoundingClientRect().top]))
|
||||
})()`)
|
||||
|
||||
await valueOf(send, `(() => {
|
||||
const host = document.querySelector('.genealogy-list-scroll')
|
||||
const scroller = host?.querySelector('.uni-scroll-view') || host
|
||||
scroller.scrollTop = Math.min(180, scroller.scrollHeight - scroller.clientHeight)
|
||||
scroller.dispatchEvent(new Event('scroll', { bubbles: true }))
|
||||
})()`)
|
||||
```
|
||||
|
||||
Wait until `.section-heading` moves upward, then assert every fixed selector changes by no more than one pixel. Also assert the inner region has positive height, `scrollHeight > clientHeight`, and the document has no horizontal overflow.
|
||||
|
||||
- [ ] **Step 3: Run the static contract and observe RED**
|
||||
|
||||
Run:
|
||||
|
||||
```powershell
|
||||
powershell -ExecutionPolicy Bypass -File tests/g01-empty-state-contract.ps1
|
||||
```
|
||||
|
||||
Expected: FAIL because `.genealogy-fixed-zone` and `.genealogy-list-scroll` do not exist.
|
||||
|
||||
- [ ] **Step 4: Run the runtime smoke and observe RED**
|
||||
|
||||
Run:
|
||||
|
||||
```powershell
|
||||
node tests/g01-empty-state-runtime-smoke.js http://localhost:5173
|
||||
```
|
||||
|
||||
Expected: FAIL because the normal state has no independent list scroller.
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Implement the G01-only split layout and position rules
|
||||
|
||||
**Files:**
|
||||
- Modify: `pages/genealogy/g01-my-genealogies.vue:3-165`
|
||||
- Modify: `pages/genealogy/g01-my-genealogies.vue:270-393`
|
||||
- Modify: `pages/genealogy/g01-my-genealogies.vue:395-563`
|
||||
- Test: `tests/g01-empty-state-contract.ps1`
|
||||
- Test: `tests/g01-empty-state-runtime-smoke.js`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `isLoading`, `hasError`, `hasGenealogies`, `selectGenealogy(genealogy)` and existing page components.
|
||||
- Produces: `isListLayout: ComputedRef<boolean>`, `listScrollCommand: Ref<number>`, `currentListScrollTop: Ref<number>`, `handleListScroll(event): void`, and `resetListScroll(): Promise<void>`.
|
||||
|
||||
- [ ] **Step 1: Add the normal-state layout modifier**
|
||||
|
||||
Update the page shell and script imports:
|
||||
|
||||
```vue
|
||||
<view
|
||||
class="page-shell genealogy-index"
|
||||
:class="{ 'genealogy-index--split': isListLayout }"
|
||||
>
|
||||
```
|
||||
|
||||
```js
|
||||
import { computed, nextTick, ref } from "vue";
|
||||
|
||||
const isListLayout = computed(
|
||||
() => !isLoading.value && !hasError.value && hasGenealogies.value,
|
||||
);
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Separate fixed and scrolling regions**
|
||||
|
||||
Inside the existing `v-else-if="hasGenealogies"` branch, wrap only the card, shortcuts, and divider in the fixed zone, and wrap `.genealogy-lower` in the single scroll view:
|
||||
|
||||
```vue
|
||||
<view class="genealogy-fixed-zone">
|
||||
<view class="current-slip" @click="openSwitcher">...</view>
|
||||
<view class="shortcut-grid">...</view>
|
||||
<image class="section-divider" ... />
|
||||
</view>
|
||||
|
||||
<scroll-view
|
||||
class="genealogy-list-scroll"
|
||||
scroll-y
|
||||
:scroll-top="listScrollCommand"
|
||||
@scroll="handleListScroll"
|
||||
>
|
||||
<view class="genealogy-lower">...</view>
|
||||
</scroll-view>
|
||||
```
|
||||
|
||||
Do not move the background, title bar, tabbar, dialog layers, or non-normal states into the scroll view.
|
||||
|
||||
- [ ] **Step 3: Add scroll tracking and explicit reset after genealogy switching**
|
||||
|
||||
```js
|
||||
const listScrollCommand = ref(0);
|
||||
const currentListScrollTop = ref(0);
|
||||
|
||||
const handleListScroll = (event) => {
|
||||
currentListScrollTop.value = Number(event?.detail?.scrollTop || 0);
|
||||
};
|
||||
|
||||
const resetListScroll = async () => {
|
||||
listScrollCommand.value = currentListScrollTop.value;
|
||||
await nextTick();
|
||||
listScrollCommand.value = 0;
|
||||
currentListScrollTop.value = 0;
|
||||
};
|
||||
|
||||
const selectGenealogy = async (genealogy) => {
|
||||
selectedGenealogyId.value = genealogy.id;
|
||||
closeSwitcher();
|
||||
await resetListScroll();
|
||||
};
|
||||
```
|
||||
|
||||
Opening/closing dialogs and returning from a pushed child page do not call `resetListScroll`, so the existing component instance retains its scroll position.
|
||||
|
||||
- [ ] **Step 4: Add the split-only flex sizing**
|
||||
|
||||
Keep the current default styles for loading/error/empty, and scope viewport locking to the modifier:
|
||||
|
||||
```scss
|
||||
.genealogy-index--split {
|
||||
display: flex;
|
||||
height: 100vh;
|
||||
min-height: 0;
|
||||
flex-direction: column;
|
||||
padding-bottom: calc(112rpx + env(safe-area-inset-bottom));
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.genealogy-index--split .genealogy-content {
|
||||
display: flex;
|
||||
min-height: 0;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
.genealogy-fixed-zone { flex: 0 0 auto; }
|
||||
|
||||
.genealogy-list-scroll {
|
||||
width: 100%;
|
||||
height: 0;
|
||||
min-height: 0;
|
||||
flex: 1;
|
||||
}
|
||||
```
|
||||
|
||||
Do not add a height media query or hide the system scroll indicator as a product contract.
|
||||
|
||||
- [ ] **Step 5: Run the focused tests and reach GREEN**
|
||||
|
||||
Run:
|
||||
|
||||
```powershell
|
||||
powershell -ExecutionPolicy Bypass -File tests/g01-empty-state-contract.ps1
|
||||
node tests/g01-empty-state-runtime-smoke.js http://localhost:5173
|
||||
powershell -ExecutionPolicy Bypass -File tests/g01-visual-contract.ps1
|
||||
powershell -ExecutionPolicy Bypass -File tests/genealogy-shared-background-contract.ps1
|
||||
```
|
||||
|
||||
Expected: all four commands exit 0.
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Capture evidence, update QA, and protect adjacent G behavior
|
||||
|
||||
**Files:**
|
||||
- Create: `docs/design/screens/runtime/2026-07-16/G01-fixed-list-scroll-320x568.png`
|
||||
- Create: `docs/design/screens/runtime/2026-07-16/G01-fixed-list-scroll-360x640.png`
|
||||
- Create: `docs/design/screens/runtime/2026-07-16/G01-fixed-list-scroll-360x800.png`
|
||||
- Create: `docs/design/screens/runtime/2026-07-16/G01-fixed-list-scroll-412x915.png`
|
||||
- Create: `docs/design/screens/runtime/2026-07-16/G01-fixed-list-scroll-412x1000.png`
|
||||
- Modify: `design-qa.md`
|
||||
- Modify: `docs/验收规划.md`
|
||||
- Modify: `docs/交接记录.md`
|
||||
- Test: G01 and adjacent G runtime/static contracts
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: the implemented `.genealogy-list-scroll` behavior and existing Chrome debug server on port 9222.
|
||||
- Produces: five internal H5 screenshots, a current QA record, and an accurate handoff state that does not claim Android or whole-page acceptance.
|
||||
|
||||
- [ ] **Step 1: Capture five real H5 viewports**
|
||||
|
||||
Use Chrome DevTools Protocol to navigate to G01, set each required viewport, scroll the inner list to a representative position, and save the five named PNGs. Capture with `captureBeyondViewport: false` and check browser exceptions plus `console.error`.
|
||||
|
||||
- [ ] **Step 2: Visually compare fixed and moving regions**
|
||||
|
||||
Open the 320×568, 412×915, and 412×1000 screenshots together. Confirm the fixed area remains complete, the first visible list row is not covered by the divider, the tabbar does not cover the last reachable action, and the long background remains continuous.
|
||||
|
||||
- [ ] **Step 3: Update QA and authoritative status documents**
|
||||
|
||||
Prepend a G01 split-scroll section to `design-qa.md` with source spec, implementation screenshots, five required fidelity surfaces, comparison history, evidence limits, and `final result: passed` only if no H5 P0/P1/P2 remains.
|
||||
|
||||
Update `docs/验收规划.md` and `docs/交接记录.md` to say the fixed/scroll structure is implemented as an internal H5 candidate. Keep G01 `[~]`, explicitly retain Android/HBuilderX and 4GB-device verification, and do not mark the page frozen or accepted.
|
||||
|
||||
- [ ] **Step 4: Run final verification**
|
||||
|
||||
Run:
|
||||
|
||||
```powershell
|
||||
powershell -ExecutionPolicy Bypass -File tests/g01-empty-state-contract.ps1
|
||||
powershell -ExecutionPolicy Bypass -File tests/g01-visual-contract.ps1
|
||||
powershell -ExecutionPolicy Bypass -File tests/genealogy-shared-background-contract.ps1
|
||||
node tests/g01-empty-state-runtime-smoke.js http://localhost:5173
|
||||
node tests/g03-create-flow-runtime-smoke.js http://localhost:5173
|
||||
node tests/g05-overview-runtime-smoke.js http://localhost:5173
|
||||
node tests/g06-search-flow-runtime-smoke.js http://localhost:5173
|
||||
node tests/g08-g10-application-flow-runtime-smoke.js http://localhost:5173
|
||||
node tests/g11-g12-settings-poems-runtime-smoke.js http://localhost:5173
|
||||
git diff --check
|
||||
```
|
||||
|
||||
Expected: every command exits 0. Report Android/HBuilderX as still pending.
|
||||
@@ -0,0 +1,235 @@
|
||||
# G01 添加家谱底部弹层实施计划
|
||||
|
||||
> **状态:已废止。** 用户否决了完整卷轴背景方向;不得继续执行本文。当前唯一实施计划为 `docs/superpowers/plans/2026-07-19-g01-add-genealogy-paper-sheet.md`。
|
||||
|
||||
> **执行要求:** 使用 `superpowers:executing-plans` 在当前会话内联执行;禁止多代理、worktree、`git add`、`commit`、`push`、`reset` 和 `checkout`。
|
||||
|
||||
**目标:** 将 G01 当前居中大卷轴“添加家谱”弹窗改成紧凑、可明确关闭的自定义底部弹层,并保持三个现有跳转行为不变。
|
||||
|
||||
**架构:** 只在 `g01-my-genealogies.vue` 内拆分添加弹层与切换弹层的模板选择器和样式责任;添加弹层底部对齐并使用自己的关闭入口,切换弹层继续沿用居中卷轴。先用现有 PowerShell 视觉契约锁定结构、文案、布局和返回键行为,再做最小实现。
|
||||
|
||||
**技术栈:** uni-app、Vue 3 `<script setup>`、SCSS、PowerShell 契约测试、Chrome DevTools Protocol 9222。
|
||||
|
||||
## 全局约束
|
||||
|
||||
- 只返工 G01“添加家谱”弹层,不修改切换家谱弹层、其他 G01 状态或其他页面。
|
||||
- 不对接接口;三个入口继续调用 `applyToJoin`、`joinByInvite`、`createGenealogy`。
|
||||
- 继续使用项目自定义组件和现有真实 PNG 资产,不使用原生 UniApp 弹窗、Toast、Loading 或 ActionSheet。
|
||||
- 只复用当前 9222 Chrome 窗口和唯一项目标签页,不打开第二个浏览器或第二个项目标签页。
|
||||
- 不覆盖、删除或清理现有修改、未跟踪文件、测试、文档、截图、母版和候选资产。
|
||||
- 不执行任何 Git 暂存、提交、推送、重置或检出命令。
|
||||
- H5 截图只作为内部候选证据;Android/HBuilderX 仍标记为未验证。
|
||||
- 没有用户明确“通过”,不得将 G01 标记为 `[x]` 或冻结。
|
||||
|
||||
---
|
||||
|
||||
### 任务 1:锁定底部弹层的结构、文案与视觉契约
|
||||
|
||||
**文件:**
|
||||
|
||||
- 修改:`tests/g01-visual-contract.ps1`
|
||||
- 修改:`pages/genealogy/g01-my-genealogies.vue` 的添加弹层模板与弹层样式
|
||||
|
||||
**接口:**
|
||||
|
||||
- 消费:`addDialogVisible`、`closeAddDialog()`、`applyToJoin()`、`joinByInvite()`、`createGenealogy()`、现有 `AppButton`。
|
||||
- 产出:`.add-dialog-layer`、`.add-dialog`、`.add-dialog__heading`、`.add-dialog__close`、`.add-dialog__content`;切换弹层选择器和表现保持不变。
|
||||
|
||||
- [ ] **步骤 1:先加入会失败的结构与样式契约**
|
||||
|
||||
在 `tests/g01-visual-contract.ps1` 读取 `$page` 后增加:
|
||||
|
||||
```powershell
|
||||
foreach ($token in @('class="add-dialog__heading"', 'class="add-dialog__close"', '>关闭</view>', 'label="继续创建家谱"')) {
|
||||
if ($page -notmatch [regex]::Escape($token)) { throw "G-01 add sheet is missing $token" }
|
||||
}
|
||||
if ($page -match [regex]::Escape('label="确认没有现有家谱,继续创建"')) {
|
||||
throw 'G-01 add sheet still puts guidance copy inside the create button.'
|
||||
}
|
||||
if ($page -notmatch '(?s)\.add-dialog-layer\s*\{[^}]*align-items:\s*flex-end;[^}]*background:\s*rgba\(34,\s*20,\s*12,\s*0\.68\);') {
|
||||
throw 'G-01 add sheet is not a bottom-aligned layer with the approved mask.'
|
||||
}
|
||||
if ($page -notmatch '(?s)\.add-dialog\s*\{[^}]*max-width:\s*650rpx;[^}]*min-height:\s*545rpx;') {
|
||||
throw 'G-01 add sheet does not use the compact approved proportions.'
|
||||
}
|
||||
if ($page -notmatch '(?s)\.add-dialog__close\s*\{[^}]*min-height:\s*72rpx;') {
|
||||
throw 'G-01 add sheet close control does not reserve the approved hit area.'
|
||||
}
|
||||
if ($page -notmatch '(?s)\.genealogy-switcher-layer\s*\{[^}]*align-items:\s*center;[^}]*padding:\s*40rpx;') {
|
||||
throw 'G-01 switcher must remain a centered dialog.'
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **步骤 2:运行契约并确认按预期失败**
|
||||
|
||||
运行:
|
||||
|
||||
```powershell
|
||||
powershell -NoProfile -ExecutionPolicy Bypass -File tests/g01-visual-contract.ps1
|
||||
```
|
||||
|
||||
预期:`FAIL`,第一条失败信息为缺少 `class="add-dialog__heading"`;失败原因必须是新结构尚未实现,而不是脚本语法或文件编码错误。
|
||||
|
||||
- [ ] **步骤 3:最小修改添加弹层模板**
|
||||
|
||||
将添加弹层模板改为以下结构;切换家谱模板不动:
|
||||
|
||||
```vue
|
||||
<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/auth/transparent/a01-scroll-dialog-v3.png" mode="aspectFit" />
|
||||
<view class="add-dialog__content">
|
||||
<view class="add-dialog__heading">
|
||||
<text class="dialog-title">添加家谱</text>
|
||||
<text class="dialog-copy">建议先搜索已有家谱,避免重复创建</text>
|
||||
</view>
|
||||
<view class="add-dialog__close" hover-class="action-hover" @click="closeAddDialog">关闭</view>
|
||||
<AppButton block label="搜索家谱" @click="applyToJoin" />
|
||||
<AppButton block type="secondary" label="邀请码加入" @click="joinByInvite" />
|
||||
<AppButton block type="secondary" label="继续创建家谱" @click="createGenealogy" />
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
```
|
||||
|
||||
- [ ] **步骤 4:拆分添加弹层和切换弹层样式**
|
||||
|
||||
用以下责任边界替换当前合并规则;保留现有 `.switcher-item` 及其后续规则:
|
||||
|
||||
```scss
|
||||
.add-dialog-layer { position: fixed; z-index: 40; inset: 0; display: flex; align-items: flex-end; justify-content: center; box-sizing: border-box; padding: 0 20rpx; background: rgba(34, 20, 12, 0.68); }
|
||||
.add-dialog { position: relative; width: 100%; max-width: 650rpx; min-height: 545rpx; max-height: calc(100vh - 24rpx); }
|
||||
.add-dialog__skin { position: absolute; inset: 0; width: 100%; height: 100%; pointer-events: none; }
|
||||
.add-dialog__content { position: relative; z-index: 1; display: flex; min-height: 545rpx; max-height: calc(100vh - 24rpx); flex-direction: column; align-items: stretch; box-sizing: border-box; padding: 48rpx 58rpx calc(36rpx + env(safe-area-inset-bottom)); overflow-y: auto; }
|
||||
.add-dialog__heading { padding-right: 104rpx; }
|
||||
.add-dialog .dialog-title,
|
||||
.add-dialog .dialog-copy { display: block; text-align: left; }
|
||||
.add-dialog .dialog-title { padding-left: 54rpx; }
|
||||
.add-dialog__close { position: absolute; z-index: 2; top: 38rpx; right: 120rpx; display: flex; min-width: 88rpx; min-height: 72rpx; align-items: center; justify-content: center; color: $ink-muted; font-size: 26rpx; }
|
||||
.add-dialog .app-button { min-height: 92rpx; }
|
||||
.add-dialog__content > .app-button:first-of-type { margin-top: 24rpx; }
|
||||
.add-dialog__content > .app-button + .app-button { margin-top: 6rpx; }
|
||||
|
||||
.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); }
|
||||
.genealogy-switcher { position: relative; width: 100%; max-width: 670rpx; min-height: 720rpx; }
|
||||
.genealogy-switcher__skin { position: absolute; inset: 0; width: 100%; height: 100%; }
|
||||
.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: 12rpx; color: $ink-muted; font-size: 25rpx; line-height: 1.5; }
|
||||
.dialog-close { display: flex; min-height: 72rpx; align-items: center; justify-content: center; margin-top: auto; color: $ink-muted; font-size: 24rpx; }
|
||||
```
|
||||
|
||||
- [ ] **步骤 5:运行契约并确认通过**
|
||||
|
||||
运行同一步骤 2。预期输出:
|
||||
|
||||
```text
|
||||
PASS G-01 visual contract
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 任务 2:让 Android 返回键优先关闭添加弹层
|
||||
|
||||
**文件:**
|
||||
|
||||
- 修改:`tests/g01-visual-contract.ps1`
|
||||
- 修改:`pages/genealogy/g01-my-genealogies.vue` 的 uni-app 生命周期导入和返回键处理
|
||||
|
||||
**接口:**
|
||||
|
||||
- 消费:`addDialogVisible: Ref<boolean>`、`closeAddDialog(): void`。
|
||||
- 产出:`onBackPress` 回调;弹层打开时返回 `true` 并关闭,未打开时不拦截页面返回。
|
||||
|
||||
- [ ] **步骤 1:先加入会失败的返回键契约**
|
||||
|
||||
在 `tests/g01-visual-contract.ps1` 增加:
|
||||
|
||||
```powershell
|
||||
if ($page -notmatch 'import\s*\{[^}]*onBackPress[^}]*\}\s*from\s*"@dcloudio/uni-app"') {
|
||||
throw 'G-01 add sheet does not import onBackPress.'
|
||||
}
|
||||
if ($page -notmatch '(?s)onBackPress\(\(\)\s*=>\s*\{\s*if\s*\(!addDialogVisible\.value\)\s*return\s*false;\s*closeAddDialog\(\);\s*return\s*true;\s*\}\);') {
|
||||
throw 'G-01 add sheet does not consume Android back before page navigation.'
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **步骤 2:运行契约并确认按预期失败**
|
||||
|
||||
运行视觉契约。预期:`FAIL: G-01 add sheet does not import onBackPress.`
|
||||
|
||||
- [ ] **步骤 3:加入最小返回键处理**
|
||||
|
||||
将导入改为:
|
||||
|
||||
```js
|
||||
import { onBackPress, onLoad, onShow } from "@dcloudio/uni-app";
|
||||
```
|
||||
|
||||
在 `closeAddDialog` 定义之后加入:
|
||||
|
||||
```js
|
||||
onBackPress(() => {
|
||||
if (!addDialogVisible.value) return false;
|
||||
closeAddDialog();
|
||||
return true;
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **步骤 4:运行契约并确认通过**
|
||||
|
||||
预期输出:`PASS G-01 visual contract`。
|
||||
|
||||
---
|
||||
|
||||
### 任务 3:聚焦验证并在唯一标签页恢复审批状态
|
||||
|
||||
**文件:**
|
||||
|
||||
- 验证:`pages/genealogy/g01-my-genealogies.vue`
|
||||
- 读取:`tests/g01-empty-state-contract.ps1`
|
||||
- 读取:`tests/g01-visual-contract.ps1`
|
||||
- 生成候选截图:`docs/design/screens/runtime/2026-07-19/g01-approval/05-add-dialog-bottom-sheet-412x915.png`
|
||||
|
||||
**接口:**
|
||||
|
||||
- 消费:5173 H5 服务、9222 唯一 Chrome 项目标签页、当前 G01 默认列表态。
|
||||
- 产出:412×915 当前添加弹层的候选证据;不改变验收规划勾选状态。
|
||||
|
||||
- [ ] **步骤 1:运行最小相关静态检查**
|
||||
|
||||
```powershell
|
||||
powershell -NoProfile -ExecutionPolicy Bypass -File tests/g01-visual-contract.ps1
|
||||
powershell -NoProfile -ExecutionPolicy Bypass -File tests/g01-empty-state-contract.ps1
|
||||
git diff --check -- pages/genealogy/g01-my-genealogies.vue tests/g01-visual-contract.ps1 docs/superpowers/specs/2026-07-19-g01-add-genealogy-bottom-sheet-design.md docs/superpowers/plans/2026-07-19-g01-add-genealogy-bottom-sheet.md
|
||||
```
|
||||
|
||||
预期:两个测试均输出 `PASS`,`git diff --check` 无输出且退出码为 0。不修改测试阈值来换取通过。
|
||||
|
||||
- [ ] **步骤 2:确认服务、标签页和视口约束**
|
||||
|
||||
只读检查 5173、9222 和 `http://127.0.0.1:9222/json/list`。预期:5173 可访问;9222 只有一个 `localhost:5173` 项目页;不得启动第二个 Chrome。
|
||||
|
||||
- [ ] **步骤 3:在同一标签页刷新现有 G01 代码并打开添加弹层**
|
||||
|
||||
保持 G01 默认列表态,将视口设为 412×915;若热更新已生效则不导航,关闭旧弹层后点击现有 `.create-action` 重新打开。等待 `.add-dialog-layer`、`.add-dialog__close` 和三个 `.app-button` 同时出现。
|
||||
|
||||
- [ ] **步骤 4:检查运行时几何和交互**
|
||||
|
||||
通过当前 CDP 连接仅检查同一个“添加家谱”状态,先检查 320×568,再恢复并检查 412×915:
|
||||
|
||||
- `.add-dialog` 底边与当前视口底边重合,允许 1px 取整误差;
|
||||
- 弹层没有横向溢出,且高度小于视口高度的 50%;
|
||||
- `.add-dialog__close` 完全位于弹层可视区域内;
|
||||
- 三个按钮同时可见,第三个文本为“继续创建家谱”;
|
||||
- 点击弹层内部不关闭;点击“关闭”会关闭;再次打开后点击遮罩会关闭;
|
||||
- 关闭和重新打开不改变 G01 独立列表的滚动位置;
|
||||
- 最后恢复 412×915,并让添加家谱弹层保持打开。
|
||||
|
||||
- [ ] **步骤 5:截取并检查候选证据**
|
||||
|
||||
使用当前标签页的 `Page.captureScreenshot` 保存指定 PNG,打开原图检查:没有错误页、加载中、裁切、横向溢出、标题压线、关闭入口消失或大块无效留白。若不满足,只返工当前弹层。
|
||||
|
||||
- [ ] **步骤 6:交回用户审批**
|
||||
|
||||
只报告当前添加弹层的变更和真实验证结果,明确 Android/HBuilderX 尚未验证;等待用户明确“通过”,不得切换到“切换家谱弹层”,不得更新 G01 为 `[x]`。
|
||||
@@ -0,0 +1,216 @@
|
||||
# G01 添加家谱宣纸底部弹层实施计划
|
||||
|
||||
> **执行要求:** 使用 `superpowers:executing-plans` 在当前会话内联执行;禁止多代理、worktree、`git add`、`commit`、`push`、`reset` 和 `checkout`。步骤使用复选框跟踪。
|
||||
|
||||
**目标:** 以已选定视觉目标重做 G01“添加家谱”:删除完整卷轴背景和无效装饰,改为宣纸底面、顶部收边、关闭图标,并原样复用现有 `AppButton` 卷轴按钮。
|
||||
|
||||
**架构:** `g01-my-genealogies.vue` 继续独占当前弹层结构和交互;现有 `AppButton.vue` 不修改。新增一个真实透明关闭图标 PNG,弹层使用现有 `page-paper.jpg` 和 `a01-paper-transition-v1.png`,切换家谱弹层仍使用原居中卷轴结构。
|
||||
|
||||
**技术栈:** uni-app、Vue 3 `<script setup>`、SCSS、现有 `AppButton`、PowerShell 契约测试、Chrome DevTools Protocol 9222、内置 Image Gen。
|
||||
|
||||
## 全局约束
|
||||
|
||||
- 唯一视觉目标:`docs/design/mockups/2026-07-19/g01-add-dialog-paper-sheet-target.png`。
|
||||
- 只修改 G01“添加家谱”弹层,不修改切换家谱弹层、其他 G01 状态或其他页面。
|
||||
- 三个入口继续调用 `applyToJoin`、`joinByInvite`、`createGenealogy`,不对接接口。
|
||||
- 按钮必须直接复用现有 `AppButton`、`a01-scroll-primary-v3.png` 和 `a01-scroll-secondary-v3.png`;不得生成、重绘或近似实现按钮皮肤。
|
||||
- 不删除任何现有资产文件、历史截图、测试或文档;只删除当前 G01 添加弹层内已经失去引用的模板节点和专用样式。
|
||||
- 只复用当前 9222 Chrome 窗口和唯一项目标签页。
|
||||
- 不执行 Git 暂存、提交、推送、重置或检出。
|
||||
- H5 截图只作为候选证据;Android/HBuilderX 仍为未验证。
|
||||
- 没有用户明确“通过”,不得切换下一状态、冻结 G01 或更新为 `[x]`。
|
||||
|
||||
---
|
||||
|
||||
### 任务 1:生成并验证真实关闭图标资产
|
||||
|
||||
**文件:**
|
||||
|
||||
- 参考:`docs/design/mockups/2026-07-19/g01-add-dialog-paper-sheet-target.png`
|
||||
- 创建候选:`docs/design/assets/candidates/2026-07-19/g01-dialog-close-source-green.png`
|
||||
- 创建成品:`static/assets/modules/genealogy/transparent/g01-dialog-close.png`
|
||||
|
||||
**接口:**
|
||||
|
||||
- 产出:带透明通道的朱砂细线关闭图标 PNG;后续模板只通过 `/static/assets/modules/genealogy/transparent/g01-dialog-close.png` 使用。
|
||||
|
||||
- [ ] **步骤 1:用内置 Image Gen 生成单独图标候选**
|
||||
|
||||
附加选定视觉目标作为参考,使用以下完整提示:
|
||||
|
||||
```text
|
||||
Use case: background-extraction
|
||||
Asset type: mobile UI close icon
|
||||
Primary request: Create one standard close icon matching the selected G01 bottom-sheet mockup: two thin diagonal cinnabar-red ink strokes, balanced and optically centered, no circle, no label, no shadow, no texture inside the strokes.
|
||||
Scene/backdrop: perfectly flat solid #00ff00 chroma-key background.
|
||||
Composition: square canvas, icon centered, icon occupies about 34% of canvas width, generous equal padding.
|
||||
Constraints: one icon only; crisp antialiased edges; no text; no watermark; no extra decoration; do not use #00ff00 in the icon.
|
||||
```
|
||||
|
||||
将工具返回的原始图片复制为 `docs/design/assets/candidates/2026-07-19/g01-dialog-close-source-green.png`,保留原始生成文件。
|
||||
|
||||
- [ ] **步骤 2:移除色键并保留真实透明通道**
|
||||
|
||||
使用已安装的绝对 Python 解释器和技能脚本:
|
||||
|
||||
```powershell
|
||||
& 'C:\Users\Rain\AppData\Local\Python\bin\python.exe' 'C:\Users\Rain\.codex\skills\.system\imagegen\scripts\remove_chroma_key.py' --input 'docs\design\assets\candidates\2026-07-19\g01-dialog-close-source-green.png' --out 'static\assets\modules\genealogy\transparent\g01-dialog-close.png' --auto-key border --soft-matte --transparent-threshold 12 --opaque-threshold 220 --despill
|
||||
```
|
||||
|
||||
预期:命令退出码为 0,生成文件存在且非空。若依赖缺失,停止并报告,不安装新依赖、不切换到 CLI 图片模型。
|
||||
|
||||
- [ ] **步骤 3:检查图标成品**
|
||||
|
||||
用 `view_image` 打开成品,并用 `System.Drawing.Image` 确认 `PixelFormat` 包含 `Alpha`。拒绝绿色边缘、多余文字、圆圈、阴影或多图标输出;不从效果图裁切替代。
|
||||
|
||||
---
|
||||
|
||||
### 任务 2:用失败契约锁定宣纸结构和现有按钮
|
||||
|
||||
**文件:**
|
||||
|
||||
- 修改:`tests/g01-visual-contract.ps1`
|
||||
- 修改:`pages/genealogy/g01-my-genealogies.vue`
|
||||
- 不修改:`components/AppButton.vue`
|
||||
|
||||
**接口:**
|
||||
|
||||
- 消费:任务 1 的 `g01-dialog-close.png`;现有 `AppButton`、`addDialogVisible`、`closeAddDialog()` 和三个导航函数。
|
||||
- 产出:`.add-dialog__paper`、`.add-dialog__edge`、`.add-dialog__edge-image`、`.add-dialog__close-icon`;切换弹层选择器和表现保持不变。
|
||||
|
||||
- [ ] **步骤 1:先改契约,要求旧实现失败**
|
||||
|
||||
在读取 `$page` 后隔离添加弹层模板,并使用 Base64 解码中文断言:
|
||||
|
||||
```powershell
|
||||
$addMarkup = [regex]::Match($page, '(?s)<view v-if="addDialogVisible".*?<view v-if="switcherVisible"').Value
|
||||
if (-not $addMarkup) { throw 'G-01 add sheet markup could not be isolated.' }
|
||||
foreach ($token in @(
|
||||
'class="add-dialog__paper"',
|
||||
'page-paper.jpg',
|
||||
'class="add-dialog__edge"',
|
||||
'a01-paper-transition-v1.png',
|
||||
'class="add-dialog__close-icon"',
|
||||
'g01-dialog-close.png',
|
||||
'aria-label="关闭"'
|
||||
)) {
|
||||
if ($addMarkup -notmatch [regex]::Escape($token)) { throw "G-01 paper sheet is missing $token" }
|
||||
}
|
||||
if ($addMarkup -match 'a01-scroll-dialog-v3\.png') { throw 'G-01 add sheet still uses the rejected complete dialog frame.' }
|
||||
if ($addMarkup -match '>关闭</view>|>取消</view>') { throw 'G-01 add sheet still renders a text close control.' }
|
||||
foreach ($token in @('label="搜索家谱"', 'label="邀请码加入"', 'label="继续创建家谱"')) {
|
||||
if ($addMarkup -notmatch [regex]::Escape($token)) { throw "G-01 add sheet no longer reuses AppButton action $token" }
|
||||
}
|
||||
if ($page -match '(?s)\.add-dialog\s+\.app-button\s*\{') { throw 'G-01 add sheet must not restyle the existing AppButton.' }
|
||||
```
|
||||
|
||||
在实际 PowerShell 文件中将“关闭、取消、搜索家谱、邀请码加入、继续创建家谱”通过 UTF-8 Base64 解码构造,避免 Windows PowerShell 5 脚本编码误读。
|
||||
|
||||
- [ ] **步骤 2:运行契约并确认 RED**
|
||||
|
||||
```powershell
|
||||
powershell -NoProfile -ExecutionPolicy Bypass -File tests/g01-visual-contract.ps1
|
||||
```
|
||||
|
||||
预期:失败信息为 `G-01 paper sheet is missing class="add-dialog__paper"`,不是语法、编码或文件缺失错误。
|
||||
|
||||
- [ ] **步骤 3:最小替换添加弹层模板**
|
||||
|
||||
```vue
|
||||
<view v-if="addDialogVisible" class="add-dialog-layer" @click="closeAddDialog">
|
||||
<view class="add-dialog" @click.stop>
|
||||
<image class="add-dialog__paper" src="/static/assets/foundation/opaque/page-paper.jpg" mode="aspectFill" />
|
||||
<view class="add-dialog__edge" aria-hidden="true">
|
||||
<image class="add-dialog__edge-image" src="/static/assets/modules/auth/transparent/a01-paper-transition-v1.png" mode="widthFix" />
|
||||
</view>
|
||||
<view class="add-dialog__content">
|
||||
<view class="add-dialog__heading">
|
||||
<text class="dialog-title">添加家谱</text>
|
||||
<text class="dialog-copy">建议先搜索已有家谱,避免重复创建</text>
|
||||
</view>
|
||||
<view class="add-dialog__close" role="button" aria-label="关闭" hover-class="action-hover" @click="closeAddDialog">
|
||||
<image class="add-dialog__close-icon" src="/static/assets/modules/genealogy/transparent/g01-dialog-close.png" mode="aspectFit" />
|
||||
</view>
|
||||
<AppButton block label="搜索家谱" @click="applyToJoin" />
|
||||
<AppButton block type="secondary" label="邀请码加入" @click="joinByInvite" />
|
||||
<AppButton block type="secondary" label="继续创建家谱" @click="createGenealogy" />
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
```
|
||||
|
||||
- [ ] **步骤 4:最小替换添加弹层专用样式**
|
||||
|
||||
保留 `.genealogy-switcher-*` 和 `.dialog-close` 规则,只替换 `.add-dialog*`:
|
||||
|
||||
```scss
|
||||
.add-dialog-layer { position: fixed; z-index: 40; inset: 0; display: flex; align-items: flex-end; justify-content: center; box-sizing: border-box; background: rgba(34, 20, 12, 0.68); }
|
||||
.add-dialog { position: relative; width: 100%; max-height: calc(100vh - 80rpx); }
|
||||
.add-dialog__paper { position: absolute; inset: 0; width: 100%; height: 100%; pointer-events: none; }
|
||||
.add-dialog__edge { position: absolute; z-index: 1; top: -50rpx; right: 0; left: 0; height: 92rpx; overflow: hidden; pointer-events: none; }
|
||||
.add-dialog__edge-image { position: absolute; right: 0; bottom: 0; left: 0; width: 100%; }
|
||||
.add-dialog__content { position: relative; z-index: 2; display: flex; max-height: calc(100vh - 80rpx); flex-direction: column; align-items: stretch; box-sizing: border-box; padding: 44rpx 52rpx calc(34rpx + env(safe-area-inset-bottom)); overflow-y: auto; }
|
||||
.add-dialog__heading { padding-right: 96rpx; }
|
||||
.add-dialog .dialog-title,
|
||||
.add-dialog .dialog-copy { display: block; text-align: left; }
|
||||
.add-dialog__close { position: absolute; z-index: 3; top: 22rpx; right: 30rpx; display: flex; width: 80rpx; height: 80rpx; align-items: center; justify-content: center; }
|
||||
.add-dialog__close-icon { width: 36rpx; height: 36rpx; }
|
||||
.add-dialog__content > .app-button:first-of-type { margin-top: 20rpx; }
|
||||
.add-dialog__content > .app-button + .app-button { margin-top: 6rpx; }
|
||||
```
|
||||
|
||||
删除被否决实现专用的 `.add-dialog__skin`、标题左偏移、关闭文字颜色字号、`right: 120rpx`、固定 `545rpx` 高度和 `.add-dialog .app-button` 覆盖;不删除共享或切换弹层样式。
|
||||
|
||||
- [ ] **步骤 5:运行契约并确认 GREEN**
|
||||
|
||||
运行步骤 2,预期输出:`PASS G-01 visual contract`。
|
||||
|
||||
---
|
||||
|
||||
### 任务 3:聚焦回归与唯一标签页视觉验证
|
||||
|
||||
**文件:**
|
||||
|
||||
- 验证:`pages/genealogy/g01-my-genealogies.vue`
|
||||
- 验证:`tests/g01-visual-contract.ps1`
|
||||
- 验证:`tests/g01-empty-state-contract.ps1`
|
||||
- 验证:`tests/g01-loading-state-contract.ps1`
|
||||
- 验证:`tests/g01-error-state-contract.ps1`
|
||||
- 生成候选:`docs/design/screens/runtime/2026-07-19/g01-approval/05-add-dialog-paper-sheet-412x915.png`
|
||||
|
||||
**接口:**
|
||||
|
||||
- 消费:5173 H5、9222 唯一项目标签页、G01 当前添加弹层。
|
||||
- 产出:当前状态候选截图和真实运行时几何/交互证据;不改变审批勾选状态。
|
||||
|
||||
- [ ] **步骤 1:运行全部聚焦契约**
|
||||
|
||||
```powershell
|
||||
powershell -NoProfile -ExecutionPolicy Bypass -File tests/g01-visual-contract.ps1
|
||||
powershell -NoProfile -ExecutionPolicy Bypass -File tests/g01-empty-state-contract.ps1
|
||||
powershell -NoProfile -ExecutionPolicy Bypass -File tests/g01-loading-state-contract.ps1
|
||||
powershell -NoProfile -ExecutionPolicy Bypass -File tests/g01-error-state-contract.ps1
|
||||
git diff --check -- pages/genealogy/g01-my-genealogies.vue tests/g01-visual-contract.ps1 docs/superpowers/specs/2026-07-19-g01-add-genealogy-bottom-sheet-design.md docs/superpowers/plans/2026-07-19-g01-add-genealogy-paper-sheet.md
|
||||
```
|
||||
|
||||
预期:四个测试全部 `PASS`;`git diff --check` 退出码 0。不得放宽任何既有阈值。
|
||||
|
||||
- [ ] **步骤 2:确认运行环境约束**
|
||||
|
||||
只读检查 5173、9222 和 `/json/list`。预期:5173 HTTP 200;恰好一个 `localhost:5173` 项目页;不启动新 Chrome。
|
||||
|
||||
- [ ] **步骤 3:仅检查当前弹层的两档几何**
|
||||
|
||||
在同一标签页保持 G01“添加家谱”打开,依次检查 320×568 和 412×915:弹层底边贴合视口;无横向溢出;标题、说明、关闭图标及三个按钮全部可见且互不遮挡;关闭触控区至少 `80rpx × 80rpx`;页面内仍有且只有三个现有 `.app-button`。
|
||||
|
||||
- [ ] **步骤 4:检查交互并恢复 412×915**
|
||||
|
||||
点击弹层内部不得关闭;点击关闭图标应关闭;重新打开后点击遮罩应关闭;再打开后列表滚动位置不变。最后恢复 412×915,并保持添加弹层打开。
|
||||
|
||||
- [ ] **步骤 5:截取并检查原图**
|
||||
|
||||
通过当前 CDP `Page.captureScreenshot` 保存候选,使用 `view_image` 打开原图。拒绝矩形硬边、顶部收边裁坏、纸面接缝、关闭图标绿色边缘、按钮被重绘、文字裁切、空白过大或后方信息压不住。
|
||||
|
||||
- [ ] **步骤 6:交回当前状态审批**
|
||||
|
||||
只报告真实测试和 H5 证据,明确 Android/HBuilderX 未验证。等待用户明确“通过”,不得切换到“切换家谱弹层”,不得更新 G01 为 `[x]`。
|
||||
@@ -0,0 +1,287 @@
|
||||
# G01 Centered Minimum-Height Add Sheet Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use `superpowers:executing-plans` to implement this plan task-by-task. Subagents and worktrees are prohibited for this project.
|
||||
|
||||
**Goal:** Make the G01 “添加家谱” sheet use a `780rpx` minimum height whose visible upper edge reaches the user-marked position, center the complete title-and-actions group, and separate adjacent buttons by `24rpx` while preserving safe growth and scrolling for longer content.
|
||||
|
||||
**Architecture:** Keep the existing single `g01-add-sheet-background-v3.png` nine-slice sheet and existing `AppButton` assets. The `add-dialog__body` flow container uses vertical `auto` margins to remain centered as its two adjacent-button gaps increase from `6rpx` to `24rpx`; the margins collapse when content grows, while the close control follows the heading in every height state.
|
||||
|
||||
**Tech Stack:** uni-app Vue single-file component, SCSS with `rpx`, PowerShell visual-contract tests, Chrome DevTools Protocol on the existing port-9222 project tab.
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- Modify only G01’s current “添加家谱” sheet; do not change other G01 states, the genealogy switcher, or other pages.
|
||||
- Do not use subagents, worktrees, `git add`, `git commit`, `git push`, `git reset`, or `git checkout`.
|
||||
- Preserve every existing modified, untracked, ignored, test, document, screenshot, master, candidate, and asset file.
|
||||
- Do not loosen test thresholds and do not connect APIs.
|
||||
- Keep the single complete background asset and existing project `AppButton` styles.
|
||||
- Keep `max-height: calc(100vh - 80rpx)` and content scrolling.
|
||||
- H5 evidence is internal only; Android/HBuilderX remains unverified.
|
||||
- Do not mark G01 `[x]`, frozen, or accepted without the user’s explicit “通过”.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Tighten the G01 visual contract for the centered body
|
||||
|
||||
**Files:**
|
||||
- Modify: `tests/g01-visual-contract.ps1:78-135`
|
||||
- Test: `tests/g01-visual-contract.ps1`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: the G01 add-sheet template and SCSS as plain UTF-8 source text.
|
||||
- Produces: a source contract requiring `add-dialog__body`, nested close control, `780rpx` minimum height, symmetric content padding, and flow-based centering.
|
||||
|
||||
- [ ] **Step 1: Require the new body structure and nested close control**
|
||||
|
||||
Add `class="add-dialog__body"` to the required markup tokens and add this structural check:
|
||||
|
||||
```powershell
|
||||
if ($addMarkup -notmatch '(?s)<view class="add-dialog__body">\s*<view class="add-dialog__heading">.*?class="add-dialog__close".*?</view>\s*</view>\s*<view class="add-dialog__actions">') {
|
||||
throw 'G-01 add sheet must keep the close control with the centered heading and actions body.'
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Replace the obsolete `620rpx` and fixed-close assertions**
|
||||
|
||||
Use these exact contracts:
|
||||
|
||||
```powershell
|
||||
if ($page -notmatch '(?s)\.add-dialog\s*\{[^}]*min-height:\s*780rpx;[^}]*max-height:\s*calc\(100vh\s*-\s*80rpx\);') {
|
||||
throw 'G-01 add sheet does not reach the approved arrow-aligned minimum height.'
|
||||
}
|
||||
if ($page -notmatch '(?s)\.add-dialog__content\s*\{[^}]*min-height:\s*780rpx;[^}]*max-height:\s*calc\(100vh\s*-\s*80rpx\);[^}]*padding:\s*96rpx\s+52rpx\s+calc\(96rpx\s*\+\s*env\(safe-area-inset-bottom\)\);') {
|
||||
throw 'G-01 add sheet does not reserve the approved symmetric centering area.'
|
||||
}
|
||||
if ($page -notmatch '(?s)\.add-dialog__body\s*\{[^}]*margin:\s*auto\s+0;') {
|
||||
throw 'G-01 add sheet body does not center safely with collapsible auto margins.'
|
||||
}
|
||||
if ($page -notmatch '(?s)\.add-dialog__heading\s*\{[^}]*position:\s*relative;[^}]*padding-right:\s*96rpx;') {
|
||||
throw 'G-01 add sheet heading does not own the close-control positioning context.'
|
||||
}
|
||||
if ($page -notmatch '(?s)\.add-dialog__close\s*\{[^}]*position:\s*absolute;[^}]*top:\s*0;[^}]*right:\s*-22rpx;') {
|
||||
throw 'G-01 add sheet close control does not follow the centered heading.'
|
||||
}
|
||||
if ($page -notmatch '(?s)\.add-dialog__actions\s*>\s*\.app-button\s*\+\s*\.app-button\s*\{[^}]*margin-top:\s*24rpx;') {
|
||||
throw 'G-01 add sheet buttons do not keep the approved 24rpx spacing.'
|
||||
}
|
||||
```
|
||||
|
||||
Remove the assertions requiring `620rpx`, `118rpx 52rpx calc(64rpx...)`, and `top: 116rpx`.
|
||||
|
||||
- [ ] **Step 3: Run the contract and observe the intended failure**
|
||||
|
||||
Run:
|
||||
|
||||
```powershell
|
||||
powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests/g01-visual-contract.ps1
|
||||
```
|
||||
|
||||
Expected: FAIL with `G-01 add sheet must keep the close control with the centered heading and actions body.` or the new `780rpx` minimum-height failure. A script parse error is not an acceptable red state.
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Implement the centered, safely growing sheet body
|
||||
|
||||
**Files:**
|
||||
- Modify: `pages/genealogy/g01-my-genealogies.vue:235-250`
|
||||
- Modify: `pages/genealogy/g01-my-genealogies.vue:952-961`
|
||||
- Test: `tests/g01-visual-contract.ps1`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: existing `addDialogVisible`, `closeAddDialog`, `applyToJoin`, `joinByInvite`, and `createGenealogy` behavior.
|
||||
- Produces: `add-dialog__body`, which centers at the minimum height, grows with content, and becomes top-origin scroll content at the maximum height.
|
||||
|
||||
- [ ] **Step 1: Group the heading and actions, and nest close under the heading**
|
||||
|
||||
Replace only the inner add-sheet markup with:
|
||||
|
||||
```vue
|
||||
<view class="add-dialog__content">
|
||||
<view class="add-dialog__body">
|
||||
<view class="add-dialog__heading">
|
||||
<text class="dialog-title">添加家谱</text>
|
||||
<text class="dialog-copy">建议先搜索已有家谱,避免重复创建</text>
|
||||
<view class="add-dialog__close" role="button" aria-label="关闭" hover-class="action-hover" @click="closeAddDialog">
|
||||
<image class="add-dialog__close-icon" src="/static/assets/modules/genealogy/transparent/g01-dialog-close.png" mode="aspectFit" />
|
||||
</view>
|
||||
</view>
|
||||
<view class="add-dialog__actions">
|
||||
<AppButton block label="搜索家谱" @click="applyToJoin" />
|
||||
<AppButton block type="secondary" label="邀请码加入" @click="joinByInvite" />
|
||||
<AppButton block type="secondary" label="继续创建家谱" @click="createGenealogy" />
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Apply the minimum-height and safe-centering styles**
|
||||
|
||||
Use these exact declarations while preserving the current border-image declarations:
|
||||
|
||||
```scss
|
||||
.add-dialog { position: relative; width: 100%; min-height: 780rpx; max-height: calc(100vh - 80rpx); box-sizing: border-box; border: 1px solid transparent; border-image-source: url("/static/assets/modules/genealogy/transparent/g01-add-sheet-background-v3.png"); border-image-slice: 220 0 1 0 fill; border-image-width: 118rpx 0 1rpx; border-image-repeat: stretch; }
|
||||
.add-dialog__content { position: relative; z-index: 2; display: flex; min-height: 780rpx; max-height: calc(100vh - 80rpx); flex-direction: column; align-items: stretch; box-sizing: border-box; padding: 96rpx 52rpx calc(96rpx + env(safe-area-inset-bottom)); overflow-y: auto; }
|
||||
.add-dialog__body { margin: auto 0; }
|
||||
.add-dialog__heading { position: relative; padding-right: 96rpx; }
|
||||
.add-dialog__close { position: absolute; z-index: 3; top: 0; right: -22rpx; display: flex; width: 80rpx; height: 80rpx; align-items: center; justify-content: center; }
|
||||
.add-dialog__actions { display: flex; flex-direction: column; margin: 62rpx -32rpx 0; }
|
||||
.add-dialog__actions > .app-button + .app-button { margin-top: 24rpx; }
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Run the focused contract**
|
||||
|
||||
Run:
|
||||
|
||||
```powershell
|
||||
powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests/g01-visual-contract.ps1
|
||||
```
|
||||
|
||||
Expected: `PASS G-01 visual contract`.
|
||||
|
||||
- [ ] **Step 4: Run the adjacent G01 state contracts**
|
||||
|
||||
Run:
|
||||
|
||||
```powershell
|
||||
powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests/g01-empty-state-contract.ps1
|
||||
powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests/g01-loading-state-contract.ps1
|
||||
powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests/g01-error-state-contract.ps1
|
||||
git diff --check
|
||||
```
|
||||
|
||||
Expected: all four G01 contracts pass; `git diff --check` exits 0. Existing line-ending warnings may remain, but no whitespace error may be introduced.
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Verify minimum, growth, overflow, and interactions in the existing Chrome tab
|
||||
|
||||
**Files:**
|
||||
- Create evidence only: `docs/design/screens/runtime/2026-07-19/g01-approval/05-add-dialog-centered-min-412x915.png`
|
||||
- Create evidence only: `docs/design/screens/runtime/2026-07-19/g01-approval/05-add-dialog-centered-six-buttons-412x915.png`
|
||||
- No production or test file changes.
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: the unique existing `http://localhost:5173` page exposed by Chrome debugging port 9222.
|
||||
- Produces: measured H5 evidence without opening another browser or project tab.
|
||||
|
||||
- [ ] **Step 1: Assert the unique project tab and restore 412×915**
|
||||
|
||||
Connect through CDP, filter pages with:
|
||||
|
||||
```js
|
||||
const projectPages = pages.filter(page => page.type === 'page' && page.url.startsWith('http://localhost:5173'))
|
||||
if (projectPages.length !== 1) throw new Error(`Expected one existing project tab, found ${projectPages.length}`)
|
||||
```
|
||||
|
||||
Set device metrics to `412×915`, navigate that same page to G01, and open `.create-action`.
|
||||
|
||||
- [ ] **Step 2: Measure the three-button minimum and centering**
|
||||
|
||||
Capture the dialog and body rectangles:
|
||||
|
||||
```js
|
||||
const dialog = document.querySelector('.add-dialog').getBoundingClientRect()
|
||||
const body = document.querySelector('.add-dialog__body').getBoundingClientRect()
|
||||
const topSpace = body.top - dialog.top
|
||||
const bottomSpace = dialog.bottom - body.bottom
|
||||
```
|
||||
|
||||
Expected at 412×915:
|
||||
|
||||
- `dialog.height` is approximately `429px` (`780rpx`, tolerance ±3px).
|
||||
- `Math.abs(topSpace - bottomSpace) <= 3` in H5 where the safe-area inset is zero.
|
||||
- Each adjacent-button visual gap is approximately `13.2px` (`24rpx`, tolerance ±2px).
|
||||
- Three `.app-button` elements are visible and horizontal overflow is `0`.
|
||||
- Save `05-add-dialog-centered-min-412x915.png`.
|
||||
|
||||
- [ ] **Step 3: Verify 320×568**
|
||||
|
||||
Set `320×568`, reopen the same state, and measure again.
|
||||
|
||||
Expected:
|
||||
|
||||
- Dialog minimum is approximately `333px` (tolerance ±3px).
|
||||
- Each adjacent-button visual gap is approximately `10.2px` (`24rpx`, tolerance ±2px).
|
||||
- The last button bottom is not below the viewport.
|
||||
- No horizontal overflow or heading/close overlap.
|
||||
|
||||
- [ ] **Step 4: Verify natural six-button growth**
|
||||
|
||||
At 412×915, clone the three existing button nodes once inside `.add-dialog__actions` for pressure evidence only.
|
||||
|
||||
Expected:
|
||||
|
||||
- Button count is 6.
|
||||
- Dialog height is greater than the three-button minimum and less than its maximum.
|
||||
- `content.scrollHeight === content.clientHeight` for this medium pressure state.
|
||||
- Save `05-add-dialog-centered-six-buttons-412x915.png`.
|
||||
|
||||
- [ ] **Step 5: Verify safe maximum-height scrolling**
|
||||
|
||||
Continue cloning button nodes until content exceeds the maximum height.
|
||||
|
||||
Expected:
|
||||
|
||||
- Dialog height does not exceed `calc(100vh - 80rpx)` by more than 2px.
|
||||
- `content.scrollHeight > content.clientHeight`.
|
||||
- Initial `content.scrollTop === 0` and the title is reachable at the top.
|
||||
- Setting `content.scrollTop = content.scrollHeight` produces a positive scroll position and reaches the final button.
|
||||
|
||||
- [ ] **Step 6: Verify close behavior and restore approval state**
|
||||
|
||||
Verify in order:
|
||||
|
||||
- Clicking `.add-dialog__content` keeps the layer open.
|
||||
- Clicking `.add-dialog__close` closes it.
|
||||
- Reopening and clicking `.add-dialog-layer` closes it.
|
||||
- No runtime exceptions or failed 4xx/5xx asset responses occur on a fresh navigation.
|
||||
|
||||
Finally restore the same tab to 412×915, three buttons, no injected clones, and keep the add sheet open.
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Record internal design QA without claiming user or Android acceptance
|
||||
|
||||
**Files:**
|
||||
- Modify: `design-qa.md`
|
||||
- Test: `tests/g01-visual-contract.ps1`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: the user-marked screenshot, three-button capture, six-button capture, measured centering, responsive check, interaction results, and console/network results.
|
||||
- Produces: an internal H5 QA entry whose final result describes only the candidate evidence.
|
||||
|
||||
- [ ] **Step 1: Create a same-viewport comparison image**
|
||||
|
||||
Place the previous 412×915 state and `05-add-dialog-centered-min-412x915.png` side by side without rescaling either app viewport. Save it under the existing G01 approval evidence folder.
|
||||
|
||||
- [ ] **Step 2: Inspect the combined comparison and both pressure captures**
|
||||
|
||||
Check the five required surfaces explicitly: typography, spacing/layout, color/tokens, image quality/assets, and copy. Record any P0/P1/P2 finding before claiming an internal pass.
|
||||
|
||||
- [ ] **Step 3: Update `design-qa.md`**
|
||||
|
||||
Record:
|
||||
|
||||
- Source screenshot and approved arrow target.
|
||||
- Implementation and comparison paths.
|
||||
- 412×915 and 320×568 metrics.
|
||||
- Three-button centering, six-button growth, and maximum-height scrolling.
|
||||
- Interaction and console/network evidence.
|
||||
- `final result: passed` only if no actionable H5 P0/P1/P2 issue remains.
|
||||
- Explicit limits: user acceptance pending; Android/HBuilderX unverified; G01 not frozen.
|
||||
|
||||
- [ ] **Step 4: Run the final verification gate**
|
||||
|
||||
Run:
|
||||
|
||||
```powershell
|
||||
powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests/g01-visual-contract.ps1
|
||||
powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests/g01-empty-state-contract.ps1
|
||||
powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests/g01-loading-state-contract.ps1
|
||||
powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests/g01-error-state-contract.ps1
|
||||
git diff --check
|
||||
```
|
||||
|
||||
Expected: all contracts pass and `git diff --check` exits 0. Report line-ending warnings separately from failures.
|
||||
|
||||
Do not run any Git write command. Leave the existing 412×915 Chrome tab showing only the three-button add sheet and wait for explicit user approval.
|
||||
@@ -0,0 +1,250 @@
|
||||
# G01 可伸缩切换家谱弹层 Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. The user explicitly forbids subagents and worktrees, so execution must remain inline in the current workspace. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** 将 G01“切换当前家谱”改为紧凑、可读、带关闭图标且背景随内容九宫格伸缩的居中弹层。
|
||||
|
||||
**Architecture:** 继续使用现有完整 PNG `a01-scroll-dialog-v3.png`,由弹层根容器通过 `border-image` 九宫格渲染,固定装饰区并仅拉伸中间宣纸。标题和关闭入口固定在安全区,家谱列表按内容自然增高,到视口上限后独立滚动;不抽取全局组件。
|
||||
|
||||
**Tech Stack:** uni-app、Vue 3 `<script setup>`、SCSS、PowerShell 合同测试、Chrome DevTools Protocol 9222。
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- 只修改 G01“切换当前家谱”弹层,不修改公共 `AppDialog`、`AppButton`、其他状态或其他页面。
|
||||
- 唯一背景资产为 `static/assets/modules/auth/transparent/a01-scroll-dialog-v3.png`,源尺寸 `1860×1560`;不得覆盖、删除、重绘或拆分资产。
|
||||
- 九宫格源切片为 `300 260 360 260 fill`,显示边宽为 `110rpx 48rpx 132rpx 48rpx`。
|
||||
- 弹层宽度 `670rpx`、最小高度 `600rpx`、最大高度 `calc(100vh - 120rpx)`;内容区内边距为 `120rpx 58rpx 140rpx`。
|
||||
- 关闭入口复用 `g01-dialog-close.png`,热区 `80rpx × 80rpx`,定位 `top: 78rpx; right: 34rpx`,可访问名称为“关闭”。
|
||||
- 复用 9222 上唯一的 `localhost:5173` 项目标签页,不新开浏览器或第二个项目标签页。
|
||||
- 不使用多代理、worktree;不执行 `git add`、`commit`、`push`、`reset` 或 `checkout`;不删除或清理现有文件。
|
||||
- H5 截图仅为内部候选;Android/HBuilderX 未验证;未经用户明确“通过”不得标记或冻结 G01。
|
||||
|
||||
---
|
||||
|
||||
### Task 1: 建立可伸缩切换弹层视觉合同
|
||||
|
||||
**Files:**
|
||||
- Modify: `tests/g01-visual-contract.ps1`
|
||||
- Test: `tests/g01-visual-contract.ps1`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: G01 源文件字符串 `$page` 和切换弹层现有类名。
|
||||
- Produces: 九宫格、动态高度、安全区、滚动列表、关闭图标和返回键的单一合同。
|
||||
|
||||
- [ ] **Step 1: 添加失败合同**
|
||||
|
||||
在现有 switcher 断言附近加入:
|
||||
|
||||
```powershell
|
||||
if ($page -match 'class="genealogy-switcher__skin"') {
|
||||
throw 'G-01 switcher must not render the complete background as a fixed aspectFit image.'
|
||||
}
|
||||
if ($page -notmatch '(?s)\.genealogy-switcher\s*\{[^}]*min-height:\s*600rpx;[^}]*max-height:\s*calc\(100vh\s*-\s*120rpx\);[^}]*border-image-source:\s*url\("/static/assets/modules/auth/transparent/a01-scroll-dialog-v3\.png"\);[^}]*border-image-slice:\s*300\s+260\s+360\s+260\s+fill;[^}]*border-image-width:\s*110rpx\s+48rpx\s+132rpx\s+48rpx;') {
|
||||
throw 'G-01 switcher does not use the approved stretchable complete background.'
|
||||
}
|
||||
if ($page -notmatch '(?s)\.genealogy-switcher__content\s*\{[^}]*min-height:\s*600rpx;[^}]*max-height:\s*calc\(100vh\s*-\s*120rpx\);[^}]*padding:\s*120rpx\s+58rpx\s+140rpx;') {
|
||||
throw 'G-01 switcher content does not keep the approved decoration safe area.'
|
||||
}
|
||||
if ($page -notmatch 'class="genealogy-switcher__list"[^>]*scroll-y') {
|
||||
throw 'G-01 switcher does not provide an independent scroll list.'
|
||||
}
|
||||
if ($page -notmatch '(?s)class="genealogy-switcher__close"[^>]*aria-label="关闭".*?g01-dialog-close\.png') {
|
||||
throw 'G-01 switcher does not use the custom accessible close control.'
|
||||
}
|
||||
if ($page -match '<view class="dialog-close" @click="closeSwitcher">关闭</view>') {
|
||||
throw 'G-01 switcher still exposes the obsolete bottom close copy.'
|
||||
}
|
||||
if ($page -notmatch '(?s)onBackPress\(\(\)\s*=>\s*\{\s*if\s*\(switcherVisible\.value\)\s*\{\s*closeSwitcher\(\);\s*return\s*true;\s*\}\s*if\s*\(addDialogVisible\.value\)') {
|
||||
throw 'G-01 switcher does not consume Android back before the add dialog and page navigation.'
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 运行合同并确认 RED**
|
||||
|
||||
Run:
|
||||
|
||||
```powershell
|
||||
powershell -NoProfile -ExecutionPolicy Bypass -File tests/g01-visual-contract.ps1
|
||||
```
|
||||
|
||||
Expected: FAIL,首先输出 `G-01 switcher must not render the complete background as a fixed aspectFit image.`。
|
||||
|
||||
### Task 2: 最小实现动态背景、紧凑内容和关闭交互
|
||||
|
||||
**Files:**
|
||||
- Modify: `pages/genealogy/g01-my-genealogies.vue:256-276`
|
||||
- Modify: `pages/genealogy/g01-my-genealogies.vue:389-395`
|
||||
- Modify: `pages/genealogy/g01-my-genealogies.vue:967-981`
|
||||
- Test: `tests/g01-visual-contract.ps1`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `availableGenealogies`、`selectedGenealogyId`、`selectGenealogy(item)`、`closeSwitcher()`。
|
||||
- Produces: `.genealogy-switcher__list` 独立滚动区和 `.genealogy-switcher__close` 自定义关闭控件。
|
||||
|
||||
- [ ] **Step 1: 替换切换弹层模板**
|
||||
|
||||
将当前弹层内部结构替换为:
|
||||
|
||||
```vue
|
||||
<view v-if="switcherVisible" class="genealogy-switcher-layer" @click="closeSwitcher">
|
||||
<view class="genealogy-switcher" role="dialog" aria-modal="true" aria-label="切换当前家谱" @click.stop>
|
||||
<view class="genealogy-switcher__content">
|
||||
<text class="dialog-title">切换当前家谱</text>
|
||||
<view class="genealogy-switcher__close" role="button" aria-label="关闭" hover-class="action-hover" @click="closeSwitcher">
|
||||
<image class="genealogy-switcher__close-icon" src="/static/assets/modules/genealogy/transparent/g01-dialog-close.png" mode="aspectFit" />
|
||||
</view>
|
||||
<scroll-view class="genealogy-switcher__list" scroll-y>
|
||||
<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>
|
||||
</scroll-view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 调整 Android 返回键优先级**
|
||||
|
||||
将处理器改为:
|
||||
|
||||
```js
|
||||
onBackPress(() => {
|
||||
if (switcherVisible.value) {
|
||||
closeSwitcher()
|
||||
return true
|
||||
}
|
||||
if (addDialogVisible.value) {
|
||||
closeAddDialog()
|
||||
return true
|
||||
}
|
||||
return false
|
||||
})
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 写入最小页面级样式**
|
||||
|
||||
使用以下结构替换旧 switcher 样式:
|
||||
|
||||
```scss
|
||||
.genealogy-switcher-layer { position: fixed; z-index: 40; inset: 0; display: flex; align-items: center; justify-content: center; box-sizing: border-box; padding: 60rpx 40rpx; background: rgba(34, 20, 12, 0.58); }
|
||||
.genealogy-switcher { position: relative; width: 670rpx; max-width: 100%; min-height: 600rpx; max-height: calc(100vh - 120rpx); box-sizing: border-box; border: 1px solid transparent; border-image-source: url("/static/assets/modules/auth/transparent/a01-scroll-dialog-v3.png"); border-image-slice: 300 260 360 260 fill; border-image-width: 110rpx 48rpx 132rpx 48rpx; border-image-repeat: stretch; }
|
||||
.genealogy-switcher__content { position: relative; z-index: 1; display: flex; min-height: 600rpx; max-height: calc(100vh - 120rpx); flex-direction: column; align-items: center; box-sizing: border-box; padding: 120rpx 58rpx 140rpx; }
|
||||
.genealogy-switcher__close { position: absolute; z-index: 3; top: 78rpx; right: 34rpx; display: flex; width: 80rpx; height: 80rpx; align-items: center; justify-content: center; }
|
||||
.genealogy-switcher__close-icon { width: 80rpx; height: 80rpx; }
|
||||
.genealogy-switcher__list { width: 100%; min-height: 0; max-height: calc(100vh - 500rpx); margin-top: 24rpx; overflow-y: auto; }
|
||||
.switcher-item { display: flex; width: 100%; min-height: 112rpx; align-items: center; justify-content: space-between; box-sizing: border-box; padding: 18rpx 16rpx; border: 1rpx solid transparent; border-bottom-color: rgba(181, 138, 75, 0.42); }
|
||||
.switcher-item__name,
|
||||
.switcher-item__meta { display: block; }
|
||||
.switcher-item__name { color: $ink; font-family: "STKaiti", "KaiTi", serif; font-size: 32rpx; font-weight: 700; }
|
||||
.switcher-item__meta { margin-top: 6rpx; color: #62584c; font-size: 24rpx; }
|
||||
.switcher-item__state { color: $brand-red; font-size: 25rpx; font-weight: 600; }
|
||||
.switcher-item--active { border-color: rgba(159, 23, 15, 0.22); background: rgba(159, 23, 15, 0.055); }
|
||||
.switcher-item--active .switcher-item__name { color: $brand-red; }
|
||||
```
|
||||
|
||||
不得修改公共组件或任何图片资产。
|
||||
|
||||
- [ ] **Step 4: 运行聚焦合同并确认 GREEN**
|
||||
|
||||
Run:
|
||||
|
||||
```powershell
|
||||
powershell -NoProfile -ExecutionPolicy Bypass -File tests/g01-visual-contract.ps1
|
||||
```
|
||||
|
||||
Expected: `PASS G-01 visual contract`。
|
||||
|
||||
- [ ] **Step 5: 运行相邻 G01 合同**
|
||||
|
||||
Run each command independently:
|
||||
|
||||
```powershell
|
||||
powershell -NoProfile -ExecutionPolicy Bypass -File tests/g01-empty-state-contract.ps1
|
||||
powershell -NoProfile -ExecutionPolicy Bypass -File tests/g01-loading-state-contract.ps1
|
||||
powershell -NoProfile -ExecutionPolicy Bypass -File tests/g01-error-state-contract.ps1
|
||||
git diff --check
|
||||
```
|
||||
|
||||
Expected: 三个状态合同均输出 `PASS`;`git diff --check` 退出码为 0,允许仅出现工作区既有 LF/CRLF 警告。
|
||||
|
||||
### Task 3: 在唯一 Chrome 标签页验证四档、伸缩和交互
|
||||
|
||||
**Files:**
|
||||
- Modify: `design-qa.md`
|
||||
- Create: `docs/design/screens/runtime/2026-07-19/g01-approval/06-switch-dialog-stretchable-412x915.png`
|
||||
- Create: `docs/design/screens/runtime/2026-07-19/g01-approval/06-switch-dialog-stretchable-320x568.png`
|
||||
- Create: `docs/design/screens/runtime/2026-07-19/g01-approval/06-switch-dialog-stretchable-six-items-412x915.png`
|
||||
- Create: `docs/design/screens/runtime/2026-07-19/g01-approval/06-switch-dialog-before-vs-stretchable.png`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: 9222 上唯一的 `localhost:5173` 页面和当前 G01 列表态。
|
||||
- Produces: 四档两项状态、六项自然增高、十二项封顶滚动及最终审批状态的运行证据。
|
||||
|
||||
- [ ] **Step 1: 确认唯一项目标签页并新鲜打开弹层**
|
||||
|
||||
读取 `http://127.0.0.1:9222/json/list`,严格确认只有一个 `type: page` 且 URL 以 `http://localhost:5173` 开头的项目页。刷新同一页面,点击 `.current-slip`,等待 `.genealogy-switcher` 可见。
|
||||
|
||||
Expected: 项目标签页数量为 1,切换弹层打开,没有添加弹层或临时克隆。
|
||||
|
||||
- [ ] **Step 2: 验证四档两项状态**
|
||||
|
||||
在同一标签页依次设置 `320×568`、`360×640`、`360×800`、`412×915`。
|
||||
|
||||
Expected:
|
||||
|
||||
```text
|
||||
dialog min-height = 600rpx 对应像素值
|
||||
title 不与顶部边框或祥云重叠
|
||||
close control 完整可见
|
||||
itemCount = 2
|
||||
列表不滚动
|
||||
末项下方纯内容空白 <= 72rpx 对应像素值
|
||||
horizontalOverflow = false
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 验证六项自然增高**
|
||||
|
||||
只在浏览器运行时向 `.genealogy-switcher__list` 临时克隆四项,并标记 `data-cdp-clone="1"`。
|
||||
|
||||
Expected: 弹层高度大于两项状态、低于最大高度;背景中间宣纸伸长,顶部祥云和底部山水尺寸不变;列表无需滚动即可看全六项。
|
||||
|
||||
- [ ] **Step 4: 验证十二项封顶滚动**
|
||||
|
||||
清除六项克隆后临时补足十二项。
|
||||
|
||||
Expected: 弹层高度等于最大安全高度;标题和关闭图标固定;列表 `scrollHeight > clientHeight`、初始 `scrollTop = 0`,滚到底后末项可见;后方页面不滚动。
|
||||
|
||||
- [ ] **Step 5: 验证关闭和选择交互**
|
||||
|
||||
依次验证:弹层内部点击保持打开;关闭图标关闭;遮罩关闭;Android 返回处理器合同;点击第二项更新当前家谱并关闭。每次通过 `.current-slip` 在同一标签页重新打开。
|
||||
|
||||
Expected: 不出现原生 Toast、Loading、Modal 或 ActionSheet;控制台无异常,无 4xx/5xx 资源响应。
|
||||
|
||||
- [ ] **Step 6: 清理运行时克隆并恢复审批状态**
|
||||
|
||||
移除所有 `[data-cdp-clone]`,将视口恢复为 `412×915`,恢复两条真实数据并保持切换弹层打开。
|
||||
|
||||
Expected: 项目标签页仍为 1;添加弹层关闭;切换弹层打开;两项、无克隆、无横向溢出。
|
||||
|
||||
- [ ] **Step 7: 生成对比并更新设计质检**
|
||||
|
||||
把审查前截图 `audit-switch-dialog/01-switch-dialog-412x915.png` 与新 412×915 截图合成同尺寸横向对比;在 `design-qa.md` 记录标题安全区、空白量、九宫格装饰尺寸、文本可读性、关闭入口、四档、压力滚动、交互、控制台和证据限制。
|
||||
|
||||
Expected: 若 H5 内部候选不存在可执行 P0/P1/P2,则当前小节 `final result: passed`;同时明确用户尚未通过、Android/HBuilderX 未验证。
|
||||
|
||||
- [ ] **Step 8: 最终新鲜验证**
|
||||
|
||||
重新运行四个 G01 合同与 `git diff --check`,再读取浏览器最终状态。
|
||||
|
||||
Expected: 所有合同退出码为 0;同一项目标签页为 `412×915`、两项、切换弹层打开、无临时克隆、无横向溢出。
|
||||
|
||||
@@ -0,0 +1,494 @@
|
||||
# T07 模块基准页实施计划
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use `superpowers:executing-plans` to implement this plan task-by-task. 本项目明确禁止多代理与 worktree,因此只允许当前会话内联执行。
|
||||
|
||||
**Goal:** 把 T07“成员目录”完善为 T 模块常规页面的视觉基准,并在用户明确通过前停留在 T07,不修改 T01、F01 或其他页面。
|
||||
|
||||
**Architecture:** T07 保留现有搜索与成员列表业务结构,补齐当前家谱上下文、失败恢复入口、可访问操作语义和小屏自然滚动。页面继续消费 `ModulePageBackground`、`PageHeader`、`AppLoading`、`AppButton` 及现有真实位图资产,不抽取新全局组件;新增聚焦静态合同与 Chrome CDP 运行检查作为后续 T 页面推广的基准合同。
|
||||
|
||||
**Tech Stack:** uni-app、Vue 3 `<script setup>`、SCSS、PowerShell 合同测试、Node.js Chrome DevTools Protocol 9222、现有 Chrome 5173 H5。
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- 全程只处理 T07,用户指出问题时不得跨页修改。
|
||||
- 不对接接口,不把 Mock 或 H5 截图描述为功能通过。
|
||||
- 只复用 9222 上唯一的 `localhost:5173` Chrome 标签页,不新开浏览器或第二个项目标签页。
|
||||
- 不使用多代理,不使用 worktree。
|
||||
- 不执行 `git add`、`commit`、`push`、`reset`、`checkout`。
|
||||
- 保留全部现有修改、未跟踪文件、测试、文档、截图、母版和候选资产。
|
||||
- 不为测试通过而放宽阈值。
|
||||
- 所有弹窗、Toast、Loading 和 ActionSheet 使用项目自定义组件。
|
||||
- T07 只有全部适用状态、四档尺寸和用户明确结论均通过后,才能成为 T 模块冻结基准。
|
||||
- Android/HBuilderX 真机或模拟器仍未验证,最终报告必须保留此限制。
|
||||
|
||||
---
|
||||
|
||||
### Task 1: 固化 T07 基准页视觉与状态合同
|
||||
|
||||
**Files:**
|
||||
- Create: `tests/t07-module-baseline-contract.ps1`
|
||||
- Read: `pages/tree/t07-member-directory.vue`
|
||||
- Read: `docs/superpowers/specs/2026-07-19-module-baseline-accelerated-visual-review-design.md`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: T07 当前 `directoryState`、`filteredMembers`、`searchMembers()`、`openMember(item)`。
|
||||
- Produces: T07 唯一的聚焦静态基准合同,约束 `.directory-context`、搜索语义、四个状态、恢复动作和滚动规则。
|
||||
|
||||
- [ ] **Step 1: 写入失败合同**
|
||||
|
||||
创建 `tests/t07-module-baseline-contract.ps1`,完整内容如下:
|
||||
|
||||
```powershell
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$root = Split-Path -Parent $PSScriptRoot
|
||||
$page = Get-Content -LiteralPath (Join-Path $root 'pages/tree/t07-member-directory.vue') -Raw -Encoding utf8
|
||||
|
||||
function Assert-Match([string]$Pattern, [string]$Message) {
|
||||
if ($page -notmatch $Pattern) { throw $Message }
|
||||
}
|
||||
|
||||
Assert-Match 'class="directory-context"' 'T07 must show the current genealogy context.'
|
||||
Assert-Match '汤氏家谱' 'T07 baseline must identify the current genealogy in the H5 mock state.'
|
||||
Assert-Match '''directory-state--loading'': directoryState === ''loading''' 'T07 loading state must have an explicit root class.'
|
||||
Assert-Match 'v-if="directoryState === ''list'' \|\| directoryState === ''empty''"' 'T07 search must remain available for list and empty states only.'
|
||||
Assert-Match 'class="directory-search__action"[^>]*role="button"[^>]*aria-label="查找成员"' 'T07 search action must expose button semantics and an accessible label.'
|
||||
Assert-Match '<AppButton\s+v-if="directoryState === ''error''"[^>]+type="secondary"[^>]+label="重新查看"[^>]+@click="retryDirectory"' 'T07 error state must provide a custom recovery action.'
|
||||
Assert-Match 'const retryDirectory = \(\) => \{ directoryState\.value = ''list'' \}' 'T07 retry action must return to the list state.'
|
||||
Assert-Match '(?s)\.directory-page\s*\{[^}]*overflow-x:\s*hidden;[^}]*overflow-y:\s*auto;' 'T07 must allow natural vertical scrolling without horizontal overflow.'
|
||||
Assert-Match '(?s)\.directory-search__action\s*\{[^}]*min-width:\s*88rpx;[^}]*min-height:\s*72rpx;' 'T07 search action must preserve a usable touch target.'
|
||||
|
||||
foreach ($state in @('loading', 'list', 'empty', 'error')) {
|
||||
Assert-Match $state "T07 must preserve the $state state."
|
||||
}
|
||||
|
||||
Write-Output 'T07-MODULE-BASELINE-CONTRACT PASS'
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 运行合同确认旧实现失败**
|
||||
|
||||
Run:
|
||||
|
||||
```powershell
|
||||
powershell -NoProfile -ExecutionPolicy Bypass -File tests/t07-module-baseline-contract.ps1
|
||||
```
|
||||
|
||||
Expected: FAIL,首个失败信息为 `T07 must show the current genealogy context.`;不得先修改断言。
|
||||
|
||||
---
|
||||
|
||||
### Task 2: 最小化完善 T07 模板、状态恢复与视觉层级
|
||||
|
||||
**Files:**
|
||||
- Modify: `pages/tree/t07-member-directory.vue`
|
||||
- Test: `tests/t07-module-baseline-contract.ps1`
|
||||
- Test: `tests/t07-t08-all-states-visual-contract.ps1`
|
||||
- Test: `tests/module-app-loading-contract.ps1`
|
||||
- Test: `tests/t03-t08-member-flow-contract.ps1`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: Task 1 的静态合同;现有 `AppLoading`、`AppButton`、`PageHeader`、`ModulePageBackground`。
|
||||
- Produces: T07 常规基准结构:当前谱上下文、列表/空态搜索、错误恢复、自然纵向滚动。
|
||||
|
||||
- [ ] **Step 1: 调整模板但保留原有业务结构**
|
||||
|
||||
在 `PageHeader` 后增加当前谱上下文;搜索只在列表和空态显示;把搜索文字改为具有按钮语义的命名元素;在失败状态卡后增加自定义恢复按钮:
|
||||
|
||||
同时在页面根节点的状态 class 对象中补入:
|
||||
|
||||
```vue
|
||||
'directory-state--loading': directoryState === 'loading'
|
||||
```
|
||||
|
||||
```vue
|
||||
<view class="directory-context">
|
||||
<text class="directory-context__name">汤氏家谱</text>
|
||||
<text class="directory-context__meta">主支 · {{ members.length }} 位成员</text>
|
||||
</view>
|
||||
<view v-if="directoryState === 'list' || directoryState === 'empty'" class="directory-search">
|
||||
<image src="/static/assets/modules/genealogy/opaque/g06-search-input-wide.png" mode="scaleToFill" />
|
||||
<input v-model="keyword" aria-label="成员搜索关键词" placeholder="按姓名、字辈或支系查找" placeholder-class="directory-placeholder" @confirm="searchMembers" />
|
||||
<view class="directory-search__action" role="button" aria-label="查找成员" hover-class="action-hover" @click="searchMembers"><text>查找</text></view>
|
||||
</view>
|
||||
```
|
||||
|
||||
失败卡片后加入:
|
||||
|
||||
```vue
|
||||
<AppButton
|
||||
v-if="directoryState === 'error'"
|
||||
block
|
||||
type="secondary"
|
||||
label="重新查看"
|
||||
@click="retryDirectory"
|
||||
/>
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 增加最小脚本逻辑**
|
||||
|
||||
只增加现有组件导入和恢复函数,不新增接口、计时器或抽象:
|
||||
|
||||
```js
|
||||
import AppButton from '@/components/AppButton.vue'
|
||||
|
||||
const retryDirectory = () => { directoryState.value = 'list' }
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 收敛 T07 基准样式**
|
||||
|
||||
保留现有位图和卡片尺寸,只增加上下文、触控区和自然滚动样式;把旧 `.directory-search > text` 规则替换为 `.directory-search__action`:
|
||||
|
||||
```scss
|
||||
.directory-page { position: relative; min-height: 100vh; overflow-x: hidden; overflow-y: auto; background: $paper; }
|
||||
.directory-page__header, .directory-context, .directory-search, .directory-content { position: relative; z-index: 2; }
|
||||
.directory-context { display: flex; align-items: baseline; justify-content: space-between; box-sizing: border-box; padding: 22rpx 32rpx 0; }
|
||||
.directory-context__name { color: $ink; font-family: 'STKaiti', 'KaiTi', serif; font-size: 32rpx; font-weight: 700; }
|
||||
.directory-context__meta { color: #62584c; font-size: 24rpx; font-weight: 500; }
|
||||
.directory-search__action { position: absolute; top: 6rpx; right: 8rpx; z-index: 1; display: flex; min-width: 88rpx; min-height: 72rpx; align-items: center; justify-content: center; color: $brand-red; font-size: 24rpx; font-weight: 700; }
|
||||
.directory-content > .app-button { margin: 20rpx auto 0; }
|
||||
```
|
||||
|
||||
不得修改共享组件、T08 或其他页面。
|
||||
|
||||
- [ ] **Step 4: 运行聚焦与既有合同**
|
||||
|
||||
Run:
|
||||
|
||||
```powershell
|
||||
$tests = @(
|
||||
'tests/t07-module-baseline-contract.ps1',
|
||||
'tests/t07-t08-all-states-visual-contract.ps1',
|
||||
'tests/module-app-loading-contract.ps1',
|
||||
'tests/t03-t08-member-flow-contract.ps1'
|
||||
)
|
||||
foreach ($test in $tests) {
|
||||
powershell -NoProfile -ExecutionPolicy Bypass -File $test
|
||||
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
|
||||
}
|
||||
```
|
||||
|
||||
Expected: 四个合同均输出 `PASS`,退出码为 0。
|
||||
|
||||
---
|
||||
|
||||
### Task 3: 增加唯一 Chrome 标签页的 T07 响应式与交互检查
|
||||
|
||||
**Files:**
|
||||
- Create: `tests/t07-module-baseline-runtime-smoke.js`
|
||||
- Create: `docs/design/screens/runtime/2026-07-19/t07-baseline/01-list-412x915.png`
|
||||
- Create: `docs/design/screens/runtime/2026-07-19/t07-baseline/02-loading-412x915.png`
|
||||
- Create: `docs/design/screens/runtime/2026-07-19/t07-baseline/03-empty-412x915.png`
|
||||
- Create: `docs/design/screens/runtime/2026-07-19/t07-baseline/04-error-412x915.png`
|
||||
- Create: `docs/design/screens/runtime/2026-07-19/t07-baseline/05-responsive-contact-sheet.png`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: 9222 上唯一 `localhost:5173` 页面;T07 查询状态 `state=loading|empty|error`。
|
||||
- Produces: 四状态、四尺寸、搜索和错误恢复的运行证据;最终把当前标签页停在 `412×915` T07 列表态。
|
||||
|
||||
- [ ] **Step 1: 编写 CDP 运行检查**
|
||||
|
||||
创建 `tests/t07-module-baseline-runtime-smoke.js`,完整内容如下。运行脚本不得打开新标签页、不得调用 Playwright、不得清除 Chrome 用户数据。
|
||||
|
||||
```js
|
||||
const fs = require('fs')
|
||||
const path = require('path')
|
||||
|
||||
const origin = 'http://localhost:5173'
|
||||
const outputDirectory = path.resolve('docs/design/screens/runtime/2026-07-19/t07-baseline')
|
||||
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 pages = await (await fetch('http://127.0.0.1:9222/json/list')).json()
|
||||
const projectPages = pages.filter((page) => page.type === 'page' && page.url.startsWith(origin))
|
||||
if (projectPages.length !== 1) throw new Error(`Expected one project page, found ${projectPages.length}`)
|
||||
const socket = new WebSocket(projectPages[0].webSocketDebuggerUrl)
|
||||
await new Promise((resolve, reject) => {
|
||||
socket.addEventListener('open', resolve, { once: true })
|
||||
socket.addEventListener('error', reject, { once: true })
|
||||
})
|
||||
let id = 0
|
||||
const pending = new Map()
|
||||
socket.addEventListener('message', (event) => {
|
||||
const message = JSON.parse(event.data)
|
||||
const request = pending.get(message.id)
|
||||
if (!request) return
|
||||
pending.delete(message.id)
|
||||
message.error ? request.reject(new Error(message.error.message)) : request.resolve(message.result)
|
||||
})
|
||||
const send = (method, params = {}) => new Promise((resolve, reject) => {
|
||||
id += 1
|
||||
pending.set(id, { resolve, reject })
|
||||
socket.send(JSON.stringify({ id, method, params }))
|
||||
})
|
||||
return { projectPageCount: projectPages.length, socket, send }
|
||||
}
|
||||
|
||||
const valueOf = async (send, expression) => {
|
||||
const result = await send('Runtime.evaluate', { expression, returnByValue: true })
|
||||
if (result.exceptionDetails) throw new Error(result.exceptionDetails.text || 'Runtime evaluation failed')
|
||||
return result.result?.value
|
||||
}
|
||||
|
||||
const waitFor = async (send, expression, message) => {
|
||||
for (let attempt = 0; attempt < 60; attempt += 1) {
|
||||
try {
|
||||
if (await valueOf(send, expression)) return
|
||||
} catch (error) {
|
||||
if (!String(error.message).includes('Inspected target navigated or closed')) throw error
|
||||
}
|
||||
await sleep(100)
|
||||
}
|
||||
throw new Error(message)
|
||||
}
|
||||
|
||||
const setSize = (send, size) => send('Emulation.setDeviceMetricsOverride', {
|
||||
...size,
|
||||
deviceScaleFactor: 1,
|
||||
mobile: true,
|
||||
screenWidth: size.width,
|
||||
screenHeight: size.height
|
||||
})
|
||||
|
||||
let navigationId = 0
|
||||
const openState = async (send, query = '') => {
|
||||
navigationId += 1
|
||||
const url = `${origin}/?t07Baseline=${navigationId}#/pages/tree/t07-member-directory${query}`
|
||||
await send('Page.navigate', { url })
|
||||
await waitFor(send, `location.href === ${JSON.stringify(url)} && Boolean(document.querySelector('.directory-page'))`, `T07 did not render ${query || 'list'}`)
|
||||
await sleep(250)
|
||||
}
|
||||
|
||||
const capture = async (send, filename) => {
|
||||
const screenshot = await send('Page.captureScreenshot', { format: 'png', fromSurface: true, captureBeyondViewport: false })
|
||||
fs.mkdirSync(outputDirectory, { recursive: true })
|
||||
fs.writeFileSync(path.join(outputDirectory, filename), Buffer.from(screenshot.data, 'base64'))
|
||||
}
|
||||
|
||||
const metrics = (send) => valueOf(send, `(() => {
|
||||
const rect = (selector) => {
|
||||
const node = document.querySelector(selector)
|
||||
if (!node) return null
|
||||
const box = node.getBoundingClientRect()
|
||||
return { top: box.top, right: box.right, bottom: box.bottom, left: box.left, width: box.width, height: box.height }
|
||||
}
|
||||
return {
|
||||
viewport: { width: innerWidth, height: innerHeight },
|
||||
context: rect('.directory-context'),
|
||||
search: rect('.directory-search'),
|
||||
firstCard: rect('.directory-card'),
|
||||
lastCard: rect('.directory-card:last-child'),
|
||||
cardCount: document.querySelectorAll('.directory-card').length,
|
||||
state: document.querySelector('.directory-state--loading') ? 'loading' : document.querySelector('.directory-state--empty') ? 'empty' : document.querySelector('.directory-state--error') ? 'error' : 'list',
|
||||
horizontalOverflow: document.documentElement.scrollWidth > innerWidth || document.body.scrollWidth > innerWidth,
|
||||
documentScrollHeight: document.documentElement.scrollHeight
|
||||
}
|
||||
})()`)
|
||||
|
||||
const assert = (condition, message) => { if (!condition) throw new Error(message) }
|
||||
|
||||
const run = async () => {
|
||||
const { projectPageCount, socket, send } = await connect()
|
||||
const runtimeErrors = []
|
||||
const resourceErrors = []
|
||||
socket.addEventListener('message', (event) => {
|
||||
const message = JSON.parse(event.data)
|
||||
if (message.method === 'Runtime.exceptionThrown') runtimeErrors.push(message.params.exceptionDetails?.text || 'runtime exception')
|
||||
if (message.method === 'Network.responseReceived' && message.params.response.status >= 400) resourceErrors.push(`${message.params.response.status} ${message.params.response.url}`)
|
||||
})
|
||||
|
||||
try {
|
||||
await send('Page.enable')
|
||||
await send('Runtime.enable')
|
||||
await send('Network.enable')
|
||||
const responsive = []
|
||||
for (const size of sizes) {
|
||||
await setSize(send, size)
|
||||
await openState(send, '?genealogyId=1001')
|
||||
const current = await metrics(send)
|
||||
assert(!current.horizontalOverflow, `${size.width}x${size.height}: horizontal overflow`)
|
||||
assert(current.context && current.search && current.firstCard, `${size.width}x${size.height}: missing baseline content`)
|
||||
assert(current.cardCount === 3, `${size.width}x${size.height}: expected three members`)
|
||||
await valueOf(send, 'scrollTo(0, document.documentElement.scrollHeight)')
|
||||
await sleep(50)
|
||||
const lastBottom = await valueOf(send, "document.querySelector('.directory-card:last-child').getBoundingClientRect().bottom")
|
||||
assert(lastBottom <= size.height + 1, `${size.width}x${size.height}: last member is not reachable`)
|
||||
await valueOf(send, 'scrollTo(0, 0)')
|
||||
responsive.push(current)
|
||||
await capture(send, `responsive-${size.width}x${size.height}.png`)
|
||||
}
|
||||
|
||||
await setSize(send, { width: 412, height: 915 })
|
||||
await openState(send, '?state=loading&genealogyId=1001')
|
||||
assert((await metrics(send)).state === 'loading', 'Loading state did not render')
|
||||
await capture(send, '02-loading-412x915.png')
|
||||
|
||||
await openState(send, '?state=empty&genealogyId=1001')
|
||||
assert((await metrics(send)).state === 'empty', 'Empty state did not render')
|
||||
await capture(send, '03-empty-412x915.png')
|
||||
|
||||
await openState(send, '?state=error&genealogyId=1001')
|
||||
assert((await metrics(send)).state === 'error', 'Error state did not render')
|
||||
assert(!await valueOf(send, "Boolean(document.querySelector('.directory-search'))"), 'Error state must not expose search')
|
||||
await capture(send, '04-error-412x915.png')
|
||||
await valueOf(send, "document.querySelector('.directory-content .app-button').click()")
|
||||
await waitFor(send, "document.querySelectorAll('.directory-card').length === 3 && Boolean(document.querySelector('.directory-search'))", 'Error retry did not restore the list')
|
||||
|
||||
const inputSearch = async (value) => {
|
||||
await valueOf(send, `(() => {
|
||||
const input = document.querySelector('.directory-search input')
|
||||
input.value = ${JSON.stringify(value)}
|
||||
input.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
return true
|
||||
})()`)
|
||||
await sleep(50)
|
||||
await valueOf(send, "document.querySelector('.directory-search__action').click()")
|
||||
}
|
||||
await inputSearch('不存在')
|
||||
await waitFor(send, "Boolean(document.querySelector('.directory-state--empty'))", 'No-result search did not render the empty state')
|
||||
await inputSearch('')
|
||||
await waitFor(send, "document.querySelectorAll('.directory-card').length === 3", 'Cleared search did not restore all members')
|
||||
|
||||
await openState(send, '?genealogyId=1001')
|
||||
await capture(send, '01-list-412x915.png')
|
||||
const final = await metrics(send)
|
||||
assert(final.viewport.width === 412 && final.viewport.height === 915 && final.state === 'list', 'Final approval state is invalid')
|
||||
assert(runtimeErrors.length === 0, `Runtime errors: ${runtimeErrors.join(' | ')}`)
|
||||
assert(resourceErrors.length === 0, `Resource errors: ${resourceErrors.join(' | ')}`)
|
||||
process.stdout.write(`${JSON.stringify({ projectPageCount, responsive, final, runtimeErrors, resourceErrors }, null, 2)}\n`)
|
||||
process.stdout.write('PASS T07 module baseline runtime smoke\n')
|
||||
} finally {
|
||||
socket.close()
|
||||
}
|
||||
}
|
||||
|
||||
run().catch((error) => {
|
||||
process.stderr.write(`${error.stack || error.message}\n`)
|
||||
process.exit(1)
|
||||
})
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 运行 CDP 检查**
|
||||
|
||||
Run:
|
||||
|
||||
```powershell
|
||||
node tests/t07-module-baseline-runtime-smoke.js
|
||||
```
|
||||
|
||||
Expected: 输出四档几何数据、四状态结果、`projectPageCount: 1`、`runtimeErrors: []`、`resourceErrors: []`,最后输出 `PASS T07 module baseline runtime smoke`。
|
||||
|
||||
- [ ] **Step 3: 打开并检查全部截图**
|
||||
|
||||
先生成四档联系表:
|
||||
|
||||
```powershell
|
||||
powershell -NoProfile -ExecutionPolicy Bypass -Command "& '.\scripts\create-horizontal-contact-sheet.ps1' -Output '.\docs\design\screens\runtime\2026-07-19\t07-baseline\05-responsive-contact-sheet.png' -InputPaths @('.\docs\design\screens\runtime\2026-07-19\t07-baseline\responsive-320x568.png','.\docs\design\screens\runtime\2026-07-19\t07-baseline\responsive-360x640.png','.\docs\design\screens\runtime\2026-07-19\t07-baseline\responsive-360x800.png','.\docs\design\screens\runtime\2026-07-19\t07-baseline\responsive-412x915.png')"
|
||||
```
|
||||
|
||||
使用本地图片查看工具逐张打开四状态和四尺寸证据。拒绝以下问题:
|
||||
|
||||
- 当前谱名缺失或与搜索框挤压。
|
||||
- 320×568 出现横向溢出、卡片文字裁切或末项不可达。
|
||||
- 空态仍显示旧列表,失败态仍显示可误操作搜索框。
|
||||
- 恢复按钮不是项目卷轴按钮。
|
||||
- 背景、标题栏、文字层级与 T 模块候选不一致。
|
||||
|
||||
发现问题时只返工 T07,并重新执行 Task 1–3 的相关步骤。
|
||||
|
||||
---
|
||||
|
||||
### Task 4: 完成 T07 内部设计 QA 并交给用户逐态审批
|
||||
|
||||
**Files:**
|
||||
- Modify: `design-qa.md`
|
||||
- Create: `docs/design/screens/runtime/2026-07-19/t07-baseline/06-before-vs-after.png`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: 审查前 `module-baseline-audit/04-t07.png`、Task 3 新鲜运行截图。
|
||||
- Produces: T07 基准页内部 QA 记录和唯一 Chrome 审批现场。
|
||||
|
||||
- [ ] **Step 1: 生成同尺寸前后对比**
|
||||
|
||||
使用 `scripts/create-horizontal-contact-sheet.ps1` 合并:
|
||||
|
||||
```text
|
||||
docs/design/screens/runtime/2026-07-19/module-baseline-audit/04-t07.png
|
||||
docs/design/screens/runtime/2026-07-19/t07-baseline/01-list-412x915.png
|
||||
```
|
||||
|
||||
输出 `06-before-vs-after.png`,必须打开原图检查,而不是只确认文件存在。
|
||||
|
||||
- [ ] **Step 2: 更新 `design-qa.md`**
|
||||
|
||||
新增 T07 基准页小节,明确记录:
|
||||
|
||||
- source visual truth:已批准的模块基准规范与审查前截图。
|
||||
- implementation screenshot:T07 新鲜 412×915 列表态。
|
||||
- 字体、间距、颜色、图片资产、文案五项检查。
|
||||
- 四状态、四尺寸、搜索交互、失败恢复、控制台和资源错误检查。
|
||||
- 仍未验证 Android/HBuilderX、系统字体、软键盘和读屏。
|
||||
- 没有 P0/P1/P2 时写入精确的 `final result: passed`;有问题则写 `final result: blocked` 并继续返工。
|
||||
|
||||
- [ ] **Step 3: 在同一标签页按顺序交付用户审批**
|
||||
|
||||
依次只展示一个状态并等待用户结论:
|
||||
|
||||
1. 正常列表态。
|
||||
2. 搜索无结果空态。
|
||||
3. 加载态。
|
||||
4. 失败态与“重新查看”。
|
||||
5. 320×568、360×640、360×800、412×915 四档。
|
||||
|
||||
没有用户明确“通过”,不得把 T07 标记为模块基准或冻结。
|
||||
|
||||
---
|
||||
|
||||
### Task 5: 用户通过后记录 T07 基准状态并准备 F01
|
||||
|
||||
**Files:**
|
||||
- Modify: `docs/验收规划.md`
|
||||
- Modify: `docs/交接记录.md`
|
||||
- Modify: `docs/新会话审批交接_2026-07-19.md`
|
||||
- Modify: `docs/superpowers/specs/2026-07-19-module-baseline-accelerated-visual-review-design.md`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: 用户对 T07 全部适用状态与四档尺寸的明确通过结论。
|
||||
- Produces: T07 成为 T 模块基准的权威记录;下一审核页为 F01。
|
||||
|
||||
- [ ] **Step 1: 只在用户整页通过后更新文档**
|
||||
|
||||
记录以下事实,不扩大结论:
|
||||
|
||||
- G01 已是 G 模块 H5 视觉基准。
|
||||
- T07 已通过 H5 基准页审核,成为 T 模块常规页面基准。
|
||||
- 下一基准页为 F01;T01 必须等 T07、F01、R01、N01、M01 全部通过后处理。
|
||||
- Android/HBuilderX、真实接口和真机性能仍未完成。
|
||||
|
||||
若用户指出问题,保持 T07 当前审核状态,只返工 T07,不执行本任务。
|
||||
|
||||
- [ ] **Step 2: 最终新鲜验证**
|
||||
|
||||
Run:
|
||||
|
||||
```powershell
|
||||
$tests = @(
|
||||
'tests/t07-module-baseline-contract.ps1',
|
||||
'tests/t07-t08-all-states-visual-contract.ps1',
|
||||
'tests/module-app-loading-contract.ps1',
|
||||
'tests/t03-t08-member-flow-contract.ps1'
|
||||
)
|
||||
foreach ($test in $tests) {
|
||||
powershell -NoProfile -ExecutionPolicy Bypass -File $test
|
||||
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
|
||||
}
|
||||
node tests/t07-module-baseline-runtime-smoke.js
|
||||
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
|
||||
git diff --check
|
||||
```
|
||||
|
||||
Expected: 所有合同和运行检查通过;`git diff --check` 退出码为 0,仅允许工作区既有 LF/CRLF 警告;Chrome 最终保持唯一项目标签页。
|
||||
@@ -1,6 +1,6 @@
|
||||
# 家谱 APP 页面样式验收规划
|
||||
|
||||
> 更新日期:2026-07-15
|
||||
> 更新日期:2026-07-20
|
||||
> 适用范围:Android `uni-app` 用户端
|
||||
> 当前实现:`pages.json` 已收敛为 52 条活动路由;A02 已并入 A01,A06 已从活动路由与验收队列封存。历史上的 7 个验收结果仅作为旧视觉证据,不计入本轮完成状态。
|
||||
> 本文件只管理逐页视觉、页面状态外观、弹窗与加载样式、手机适配和表现质量;不替代 `docs/规划.md` 对项目范围和最终路由的管理,也不承担接口与业务功能验收。功能验收规则后续单独编写。
|
||||
@@ -15,6 +15,15 @@
|
||||
| `[!]` | 用户要求返工;只修改该项涉及的页面、组件、资产、测试和证据 |
|
||||
| `⚠` | 当前没有真实可达入口或存在明显流程断点,不能用直接路由冒充流程通过 |
|
||||
|
||||
### 2026-07-20 夜间批量收敛说明
|
||||
|
||||
- 用户已明确授权:T01 留待次日人工审核,其余未验收页面不再逐页提问,统一按 G01、T07、F01、R01、N01、M01 六个已确认基准推进。
|
||||
- 本轮已将 F/R/N/M 的 28 个普通任务页统一到各自模块母版,删除重复的页面编号介绍块;表单、列表、详情、时间轴、设置和状态页仍保留不同业务结构。
|
||||
- F02、T03—T06、T08、G03、G08—G12 已移除跨模块旧不透明业务面板引用,改用所属模块或 G01/T07 基准的真实透明资产。
|
||||
- T01 已形成纵向世系轴候选:始祖在上、后代向下分代、同代横向扩展、关系线连续、节点可选择、底部人物信息栏独立。
|
||||
- 52 条活动路由及 2 个共享组件已通过 5173 Vite 逐文件转换(54/54);68 项 PowerShell 静态测试中 66 项通过。剩余 2 项为已知仓库治理项:旧基础资产命名审计和历史截图数量上限。
|
||||
- 当前 Chrome 项目页仍连接 5173,但没有开放 9222 调试端口;为避免违反“不得新开第二个浏览器或第二个项目标签页”,本轮未伪造 CDP 运行截图、四档响应式和交互 smoke 证据。除用户已明确通过的基准页外,其余页面继续保持 `[~]`,明早人工复核后再决定是否标 `[x]`。
|
||||
|
||||
页面只有同时满足以下条件才能从 `[~]` 改为 `[x]`:
|
||||
|
||||
1. 页面在真实运行环境中打开;流程页优先从上游可见入口进入,尚未实现入口时明确标记 `⚠`,不能冒充流程设计完整。
|
||||
@@ -189,7 +198,7 @@ G01、G03、G05、G06、G08、G09、G10、G11、G12 共 9 个活动 G 页面统
|
||||
|
||||
| 状态 | 序号 | 页面 | 真实入口与必须覆盖的可能性 |
|
||||
| --- | ---: | --- | --- |
|
||||
| `[~]` | 4 | G01 我的家谱 | 登录成功→同页多状态;空态保留搜索/邀请码/创建三入口,以搜索为主、邀请码快捷、创建为次并提示先确认无现有家谱;三分组列表、顶部当前项切换、上次选择恢复、待录入始祖/继续完善、审核中、被拒绝、已退出/被移除、加载、失败、添加家谱弹层、通知入口;用户已确认 `1440×3600` 连续长图作为 9 个 G 页面的公共背景,并在查看多档实际 H5 对比后确认共享图片画层使用 28% 不透明度;用户已选择标题栏、当前家谱卡、四快捷入口和分隔线固定,从“我创建的”开始独立滚动;“加入申请”最终采用 B 方案(说明 `28rpx / 500`、状态 `27rpx / 600`),当前家谱与列表卡片的地区、人数、角色、更新时间进一步统一为更深颜色与中等字重;添加家谱/切换家谱弹层和按钮已向 A 系列卷轴标准收拢;G01 整页尚未正式验收,Android/HBuilderX 与 4GB Android 图片性能仍待审核 |
|
||||
| `[x]` | 4 | G01 我的家谱 | 用户已在本轮逐项通过正常、空、加载、失败、添加家谱弹层、切换家谱弹层、独立滚动及响应式检查;作为 G 系列基准冻结。Android/HBuilderX 与 4GB Android 长图性能仍待后续平台验证,不等于接口或 Android 功能通过 |
|
||||
| `[~]` | 5 | G03 创建家谱 | G01 空态或“添加家谱”→创建独立家谱;姓氏、谱名、堂号、所在地、访问规则,创建前提示搜索潜在已有家谱;覆盖必填校验、创建中、失败和已创建但始祖未完成的中断恢复;成功后直接成为创建者→录入始祖→进入 G05 总览,不直接跳 T01;下属支系创建待接口提供上级谱与权限字段 |
|
||||
| `[~]` | 6 | G06 加入家谱 | G01 空态或“添加家谱”→加入;搜索/邀请码双模式、地区与谱名筛选;结果卡展示谱名、姓氏、地区、堂号、所属上级谱、当前支系、管理/认证信息、成员规模和更新时间,并覆盖未加入、已加入、审核中、被拒绝、已退出/移除、我创建的六种操作状态;邀请码验证、初始、加载、结果、无结果、无效/过期邀请码、失败、清空输入 |
|
||||
| `[~]` | 7 | G08 关系确认与入谱 | G06 选定家谱→填写真实姓名和以已知长辈为参照的文字关系;搜索态显示申请说明/提交申请并在成功后进入 G09,邀请码态显示可选补充信息/确认加入并明确无需审核,成功后返回 G01 选中新家谱;覆盖校验、重复提交、提交中、成功/失败、返回保护;结构化关系待接口提供后迁移 |
|
||||
@@ -208,9 +217,9 @@ G01、G03、G05、G06、G08、G09、G10、G11、G12 共 9 个活动 G 页面统
|
||||
|
||||
| 状态 | 序号 | 页面 | 真实入口与必须覆盖的可能性 |
|
||||
| --- | ---: | --- | --- |
|
||||
| `[~]` | 13 | T01 世系树 | G05 新建引导卡或常规世系入口→世系树;G03 始祖保存不得直接跳入;覆盖加载、树、空、失败、横向提示、节点选中 |
|
||||
| `[~]` | 13 | T01 世系树 | 已按方案 1 重构为纵向世系轴候选:始祖居上、后代向下分代、同代横向扩展、关系线连续、节点选中与底部人物信息栏;加载、树、空、失败、阅读提示均保留。52 路由 Vite 转换和聚焦静态契约已通过,等待 2026-07-21 用户视觉审核;9222 缺失导致当夜未完成 CDP 四档和交互 smoke |
|
||||
| `[~]` | 14 | T04 新增亲属 | T01→新增;首位成员/普通亲属、关系选择、必填、保存中、结果 |
|
||||
| `[~]` | 15 | T07 成员目录 | T01→成员目录;列表、搜索、无结果、失败、成员选择 |
|
||||
| `[x]` | 15 | T07 成员目录 | T01→成员目录;列表、搜索、无结果、失败、成员选择 |
|
||||
| `[~]` | 16 | T03 成员档案 | T01/T07→成员;正常、失败、隐私字段、编辑入口 |
|
||||
| `[~]` | 17 | T05 编辑成员 | T03→编辑;字段校验、保存中、成功/失败、无权限 |
|
||||
| `[~]` | 18 | T06 编辑关系 | T01→编辑关系;冲突、循环关系、说明弹窗、保存失败 |
|
||||
@@ -220,7 +229,7 @@ G01、G03、G05、G06、G08、G09、G10、G11、G12 共 9 个活动 G 页面统
|
||||
|
||||
| 状态 | 序号 | 页面 | 真实入口与必须覆盖的可能性 |
|
||||
| --- | ---: | --- | --- |
|
||||
| `[~]` | 20 | F01 家族首页 | 底部“家族”Tab;加载、列表、空、失败、发布及内容入口 |
|
||||
| `[x]` | 20 | F01 家族首页 | 底部“家族”Tab;加载、列表、空、失败、发布及内容入口 |
|
||||
| `[~]` | 21 | F02 发布动态 | F01→发布;空内容、键盘、提交中、成功/失败、重复提交 |
|
||||
| `[~]` | 22 | F03 动态详情 | F01 动态卡→详情;正文、评论、操作结果、内容失效 |
|
||||
| `[~]` | 23 | F04 谱文列表 | F01→谱文;分类、列表、空、失败、新建入口 |
|
||||
@@ -235,7 +244,7 @@ G01、G03、G05、G06、G08、G09、G10、G11、G12 共 9 个活动 G 页面统
|
||||
|
||||
| 状态 | 序号 | 页面 | 真实入口与必须覆盖的可能性 |
|
||||
| --- | ---: | --- | --- |
|
||||
| `[~]` ⚠ | 30 | R01 人物录 | 应由家族档案入口进入;列表、搜索、空、失败、新建 |
|
||||
| `[x]` | 30 | R01 人物录 | 应由家族档案入口进入;列表、搜索、空、失败、新建 |
|
||||
| `[~]` ⚠ | 31 | R02 人物详情 | 应由 R01 人物进入;详情、编辑、隐私、内容失效 |
|
||||
| `[~]` ⚠ | 32 | R03 贺礼列表 | 应由家族工具进入;列表、空、失败、新增 |
|
||||
| `[~]` ⚠ | 33 | R04 编辑贺礼 | 应由 R03 进入;新增/编辑、校验、保存、删除确认 |
|
||||
@@ -251,14 +260,14 @@ G01、G03、G05、G06、G08、G09、G10、G11、G12 共 9 个活动 G 页面统
|
||||
|
||||
| 状态 | 序号 | 页面 | 真实入口与必须覆盖的可能性 |
|
||||
| --- | ---: | --- | --- |
|
||||
| `[~]` | 41 | N01 消息中心 | G01/M01→消息;加载、未读、已读、全部已读、空、失败、审核跳转 |
|
||||
| `[x]` | 41 | N01 消息中心 | G01/M01→消息;加载、未读、已读、全部已读、空、失败、审核跳转 |
|
||||
| `[~]` ⚠ | 42 | N02 消息详情 | 应由 N01 消息卡进入;详情、已读、失效消息、业务跳转;当前点击只改已读 |
|
||||
|
||||
### 5.8 个人中心
|
||||
|
||||
| 状态 | 序号 | 页面 | 真实入口与必须覆盖的可能性 |
|
||||
| --- | ---: | --- | --- |
|
||||
| `[~]` | 43 | M01 我的首页 | 底部“我的”Tab;资料、提醒、菜单、失败状态 |
|
||||
| `[x]` | 43 | M01 我的首页 | 底部“我的”Tab;资料、提醒、菜单、失败状态 |
|
||||
| `[~]` | 44 | M02 个人资料 | M01→资料;头像权限、必填、保存中、成功/失败 |
|
||||
| `[~]` | 45 | M03 账号与安全 | M01→安全;密码、手机号、设备、异常提醒 |
|
||||
| `[~]` ⚠ | 46 | M04 修改密码 | 应由 M03 进入;旧密码、新密码、不一致、成功/失败 |
|
||||
|
||||
@@ -2,97 +2,252 @@
|
||||
<template>
|
||||
<view class="auth-page login-page">
|
||||
<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" />
|
||||
<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="login-content">
|
||||
<view class="login-heading">
|
||||
<text class="login-title">登录家谱</text>
|
||||
<image class="title-divider" src="/static/assets/modules/auth/transparent/a01-vnext-divider-v1.png" mode="aspectFit" />
|
||||
<image
|
||||
class="title-divider"
|
||||
src="/static/assets/modules/auth/transparent/a01-vnext-divider-v1.png"
|
||||
mode="aspectFit"
|
||||
/>
|
||||
</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-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" />
|
||||
<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" />
|
||||
<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 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>
|
||||
<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="form-secondary-row">
|
||||
<view v-if="activeLoginMethod === 'password'" class="forgot-password" hover-class="tap-fade" @click="prepareForgotPassword">忘记密码</view>
|
||||
<view
|
||||
v-if="activeLoginMethod === 'password'"
|
||||
class="forgot-password"
|
||||
hover-class="tap-fade"
|
||||
@click="prepareForgotPassword"
|
||||
>忘记密码</view
|
||||
>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="login-submit" hover-class="button-pressed" @click="submitLogin">
|
||||
<image class="button-skin" src="/static/assets/foundation/transparent/a01-scroll-primary-v3.png" mode="aspectFit" />
|
||||
<view
|
||||
class="login-submit"
|
||||
hover-class="button-pressed"
|
||||
@click="submitLogin"
|
||||
>
|
||||
<image
|
||||
class="button-skin"
|
||||
src="/static/assets/foundation/transparent/a01-scroll-primary-v3.png"
|
||||
mode="aspectFit"
|
||||
/>
|
||||
<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" />
|
||||
<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" />
|
||||
<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/transparent/a01-scroll-secondary-v3.png" mode="aspectFit" />
|
||||
<view
|
||||
class="wechat-login"
|
||||
hover-class="button-pressed"
|
||||
@click="prepareWechatLogin"
|
||||
>
|
||||
<image
|
||||
class="button-skin"
|
||||
src="/static/assets/foundation/transparent/a01-scroll-secondary-v3.png"
|
||||
mode="aspectFit"
|
||||
/>
|
||||
<view class="wechat-login__content">
|
||||
<image class="wechat-icon" src="/static/assets/foundation/transparent/auth-wechat.png" mode="aspectFit" />
|
||||
<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
|
||||
class="register-link"
|
||||
hover-class="tap-fade"
|
||||
@click="prepareRegister"
|
||||
>注册账号</view
|
||||
>
|
||||
</view>
|
||||
|
||||
<view class="agreement-area" :class="{ 'agreement-area--error': agreementError }">
|
||||
<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" />
|
||||
<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">
|
||||
<text>我已阅读并同意</text>
|
||||
<text class="agreement-link" @click.stop="prepareAgreement">《用户协议》</text>
|
||||
<text class="agreement-link" @click.stop="prepareAgreement"
|
||||
>《用户协议》</text
|
||||
>
|
||||
<text>与</text>
|
||||
<text class="agreement-link" @click.stop="prepareAgreement">《隐私政策》</text>
|
||||
<text class="agreement-link" @click.stop="prepareAgreement"
|
||||
>《隐私政策》</text
|
||||
>
|
||||
</view>
|
||||
</view>
|
||||
<text v-if="agreementError" class="agreement-error">请先阅读并同意用户协议与隐私政策</text>
|
||||
<text v-if="agreementError" class="agreement-error"
|
||||
>请先阅读并同意用户协议与隐私政策</text
|
||||
>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 当前只验收自定义容器外观,真实行为验证数据留到接口阶段接入。 -->
|
||||
<view v-if="verificationVisible" class="verification-layer" @click="closeVerification">
|
||||
<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/transparent/a01-scroll-dialog-v3.png" mode="aspectFit" />
|
||||
<image
|
||||
class="verification-dialog__skin"
|
||||
src="/static/assets/modules/auth/transparent/a01-scroll-dialog-v3.png"
|
||||
mode="aspectFit"
|
||||
/>
|
||||
<view class="verification-dialog__content">
|
||||
<text class="verification-title">安全验证</text>
|
||||
<text class="verification-copy">{{ verificationPurpose }}前需要完成行为验证</text>
|
||||
<text class="verification-copy"
|
||||
>{{ verificationPurpose }}前需要完成行为验证</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
|
||||
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>
|
||||
@@ -105,111 +260,116 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import { onLoad, onUnload } from '@dcloudio/uni-app'
|
||||
import { ref } from "vue";
|
||||
import { onLoad, onUnload } from "@dcloudio/uni-app";
|
||||
|
||||
const LOGIN_METHOD_STORAGE_KEY = 'a01:last-login-method'
|
||||
const activeLoginMethod = ref('password')
|
||||
const passwordVisible = ref(false)
|
||||
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)
|
||||
const feedbackMessage = ref('')
|
||||
let feedbackTimer = null
|
||||
const LOGIN_METHOD_STORAGE_KEY = "a01:last-login-method";
|
||||
const activeLoginMethod = ref("password");
|
||||
const passwordVisible = ref(false);
|
||||
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);
|
||||
const feedbackMessage = ref("");
|
||||
let feedbackTimer = null;
|
||||
|
||||
onLoad(() => {
|
||||
const storedMethod = uni.getStorageSync(LOGIN_METHOD_STORAGE_KEY)
|
||||
if (storedMethod === 'sms') activeLoginMethod.value = 'sms'
|
||||
})
|
||||
const storedMethod = uni.getStorageSync(LOGIN_METHOD_STORAGE_KEY);
|
||||
if (storedMethod === "sms") activeLoginMethod.value = "sms";
|
||||
});
|
||||
|
||||
onUnload(() => {
|
||||
if (feedbackTimer) clearTimeout(feedbackTimer)
|
||||
})
|
||||
if (feedbackTimer) clearTimeout(feedbackTimer);
|
||||
});
|
||||
|
||||
const showFeedback = (message) => {
|
||||
feedbackMessage.value = message
|
||||
feedbackVisible.value = true
|
||||
if (feedbackTimer) clearTimeout(feedbackTimer)
|
||||
feedbackMessage.value = message;
|
||||
feedbackVisible.value = true;
|
||||
if (feedbackTimer) clearTimeout(feedbackTimer);
|
||||
feedbackTimer = setTimeout(() => {
|
||||
feedbackVisible.value = false
|
||||
feedbackTimer = null
|
||||
}, 2200)
|
||||
}
|
||||
feedbackVisible.value = false;
|
||||
feedbackTimer = null;
|
||||
}, 2200);
|
||||
};
|
||||
|
||||
const switchLoginMethod = (method) => {
|
||||
if (method !== 'password' && method !== 'sms') return
|
||||
activeLoginMethod.value = method
|
||||
uni.setStorageSync(LOGIN_METHOD_STORAGE_KEY, method)
|
||||
}
|
||||
if (method !== "password" && method !== "sms") return;
|
||||
activeLoginMethod.value = method;
|
||||
uni.setStorageSync(LOGIN_METHOD_STORAGE_KEY, method);
|
||||
};
|
||||
|
||||
const togglePasswordVisibility = () => {
|
||||
passwordVisible.value = !passwordVisible.value
|
||||
}
|
||||
passwordVisible.value = !passwordVisible.value;
|
||||
};
|
||||
|
||||
const toggleAgreement = () => {
|
||||
agreed.value = !agreed.value
|
||||
if (agreed.value) agreementError.value = false
|
||||
}
|
||||
agreed.value = !agreed.value;
|
||||
if (agreed.value) agreementError.value = false;
|
||||
};
|
||||
|
||||
const validatePhone = () => {
|
||||
if (/^1\d{10}$/.test(phone.value)) return true
|
||||
showFeedback('请输入正确的手机号')
|
||||
return false
|
||||
}
|
||||
if (/^1\d{10}$/.test(phone.value)) return true;
|
||||
showFeedback("请输入正确的手机号");
|
||||
return false;
|
||||
};
|
||||
|
||||
const requireAgreement = () => {
|
||||
if (agreed.value) return true
|
||||
agreementError.value = true
|
||||
return false
|
||||
}
|
||||
if (agreed.value) return true;
|
||||
agreementError.value = true;
|
||||
return false;
|
||||
};
|
||||
|
||||
const openVerification = (purpose) => {
|
||||
verificationPurpose.value = purpose
|
||||
verificationVisible.value = true
|
||||
}
|
||||
verificationPurpose.value = purpose;
|
||||
verificationVisible.value = true;
|
||||
};
|
||||
|
||||
const closeVerification = () => {
|
||||
verificationVisible.value = false
|
||||
}
|
||||
verificationVisible.value = false;
|
||||
};
|
||||
|
||||
const confirmVerification = () => {
|
||||
closeVerification()
|
||||
showFeedback('行为验证接口待接入')
|
||||
}
|
||||
closeVerification();
|
||||
showFeedback("行为验证接口待接入");
|
||||
};
|
||||
|
||||
const prepareGetCode = () => {
|
||||
if (!validatePhone() || !requireAgreement()) return
|
||||
openVerification('发送验证码')
|
||||
}
|
||||
if (!validatePhone() || !requireAgreement()) return;
|
||||
openVerification("发送验证码");
|
||||
};
|
||||
|
||||
const submitLogin = () => {
|
||||
if (!validatePhone()) return
|
||||
if (activeLoginMethod.value === 'password' && !password.value) {
|
||||
showFeedback('请输入密码')
|
||||
return
|
||||
if (!validatePhone()) return;
|
||||
if (activeLoginMethod.value === "password" && !password.value) {
|
||||
showFeedback("请输入密码");
|
||||
return;
|
||||
}
|
||||
if (activeLoginMethod.value === 'sms' && !/^\d{6}$/.test(verificationCode.value)) {
|
||||
showFeedback('请输入 6 位验证码')
|
||||
return
|
||||
if (
|
||||
activeLoginMethod.value === "sms" &&
|
||||
!/^\d{6}$/.test(verificationCode.value)
|
||||
) {
|
||||
showFeedback("请输入 6 位验证码");
|
||||
return;
|
||||
}
|
||||
if (!requireAgreement()) return
|
||||
openVerification('登录')
|
||||
}
|
||||
if (!requireAgreement()) return;
|
||||
openVerification("登录");
|
||||
};
|
||||
|
||||
const prepareForgotPassword = () => uni.navigateTo({ url: '/pages/auth/a05-reset-password' })
|
||||
const prepareRegister = () => uni.navigateTo({ url: '/pages/auth/a04-register' })
|
||||
const prepareForgotPassword = () =>
|
||||
uni.navigateTo({ url: "/pages/auth/a05-reset-password" });
|
||||
const prepareRegister = () =>
|
||||
uni.navigateTo({ url: "/pages/auth/a04-register" });
|
||||
|
||||
const prepareWechatLogin = () => {
|
||||
if (!requireAgreement()) return
|
||||
showFeedback('微信登录功能准备中')
|
||||
}
|
||||
if (!requireAgreement()) return;
|
||||
showFeedback("微信登录功能准备中");
|
||||
};
|
||||
|
||||
const prepareAgreement = () => showFeedback('协议页面准备中')
|
||||
const prepareAgreement = () => showFeedback("协议页面准备中");
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@@ -310,7 +470,7 @@ const prepareAgreement = () => showFeedback('协议页面准备中')
|
||||
width: 1rpx;
|
||||
height: 36rpx;
|
||||
background: rgba(176, 120, 52, 0.58);
|
||||
content: '';
|
||||
content: "";
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
|
||||
@@ -327,10 +487,12 @@ const prepareAgreement = () => showFeedback('协议页面准备中')
|
||||
height: 5rpx;
|
||||
border-radius: 5rpx;
|
||||
background: #ad160d;
|
||||
content: '';
|
||||
content: "";
|
||||
}
|
||||
|
||||
.form-content { margin-top: 10rpx; }
|
||||
.form-content {
|
||||
margin-top: 10rpx;
|
||||
}
|
||||
|
||||
.input-row {
|
||||
display: flex;
|
||||
@@ -349,7 +511,9 @@ const prepareAgreement = () => showFeedback('协议页面准备中')
|
||||
transform: translateX(-12rpx);
|
||||
}
|
||||
|
||||
.input-icon--sms { transform: translateX(-12rpx) scale(1.45); }
|
||||
.input-icon--sms {
|
||||
transform: translateX(-12rpx) scale(1.45);
|
||||
}
|
||||
|
||||
.auth-input {
|
||||
flex: 1;
|
||||
@@ -359,7 +523,9 @@ const prepareAgreement = () => showFeedback('协议页面准备中')
|
||||
font-size: 40rpx;
|
||||
}
|
||||
|
||||
.input-placeholder { color: #9f968d; }
|
||||
.input-placeholder {
|
||||
color: #9f968d;
|
||||
}
|
||||
|
||||
.password-toggle {
|
||||
display: flex;
|
||||
@@ -411,7 +577,9 @@ const prepareAgreement = () => showFeedback('协议页面准备中')
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.login-submit { height: 92rpx; }
|
||||
.login-submit {
|
||||
height: 92rpx;
|
||||
}
|
||||
|
||||
.button-skin {
|
||||
position: absolute;
|
||||
@@ -524,7 +692,9 @@ const prepareAgreement = () => showFeedback('协议页面准备中')
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.agreement-link { color: #a9160d; }
|
||||
.agreement-link {
|
||||
color: #a9160d;
|
||||
}
|
||||
|
||||
.agreement-error {
|
||||
display: block;
|
||||
@@ -539,13 +709,24 @@ const prepareAgreement = () => showFeedback('协议页面准备中')
|
||||
}
|
||||
|
||||
@keyframes agreement-shake {
|
||||
0%, 100% { transform: translateX(0); }
|
||||
33% { transform: translateX(-8rpx); }
|
||||
66% { transform: translateX(8rpx); }
|
||||
0%,
|
||||
100% {
|
||||
transform: translateX(0);
|
||||
}
|
||||
33% {
|
||||
transform: translateX(-8rpx);
|
||||
}
|
||||
66% {
|
||||
transform: translateX(8rpx);
|
||||
}
|
||||
}
|
||||
|
||||
.tap-fade { opacity: 0.62; }
|
||||
.button-pressed { opacity: 0.82; }
|
||||
.tap-fade {
|
||||
opacity: 0.62;
|
||||
}
|
||||
.button-pressed {
|
||||
opacity: 0.82;
|
||||
}
|
||||
|
||||
.feedback-toast {
|
||||
position: fixed;
|
||||
@@ -560,7 +741,7 @@ const prepareAgreement = () => showFeedback('协议页面准备中')
|
||||
min-height: 82rpx;
|
||||
border: 16rpx solid transparent;
|
||||
border-width: 16rpx 74rpx;
|
||||
border-image-source: url('/static/assets/foundation/transparent/a01-scroll-toast-v3.png');
|
||||
border-image-source: url("/static/assets/foundation/transparent/a01-scroll-toast-v3.png");
|
||||
border-image-slice: 58 280 fill;
|
||||
border-image-width: 16rpx 74rpx;
|
||||
border-image-repeat: stretch;
|
||||
@@ -651,5 +832,4 @@ const prepareAgreement = () => showFeedback('协议页面准备中')
|
||||
color: #a9160d;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
</style>
|
||||
|
||||
@@ -2,66 +2,151 @@
|
||||
<template>
|
||||
<view class="auth-page register-page">
|
||||
<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" />
|
||||
<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="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" />
|
||||
<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/modules/auth/transparent/a01-vnext-divider-v1.png" mode="aspectFit" />
|
||||
<image
|
||||
class="register-divider"
|
||||
src="/static/assets/modules/auth/transparent/a01-vnext-divider-v1.png"
|
||||
mode="aspectFit"
|
||||
/>
|
||||
</view>
|
||||
|
||||
<view class="form-content">
|
||||
<view class="field-block" :class="{ 'field-block--error': fieldErrors.phone }">
|
||||
<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="clearFieldError('phone')" />
|
||||
<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>
|
||||
<text v-if="fieldErrors.phone" class="field-error">{{
|
||||
fieldErrors.phone
|
||||
}}</text>
|
||||
</view>
|
||||
|
||||
<view class="field-block" :class="{ 'field-block--error': fieldErrors.password }">
|
||||
<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 maxlength="32" placeholder="请设置登录密码" placeholder-class="placeholder" @input="clearFieldError('password')" />
|
||||
<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>
|
||||
<text v-if="fieldErrors.password" class="field-error">{{
|
||||
fieldErrors.password
|
||||
}}</text>
|
||||
</view>
|
||||
|
||||
<view class="field-block" :class="{ 'field-block--error': fieldErrors.confirmPassword }">
|
||||
<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 maxlength="32" placeholder="请再次输入密码" placeholder-class="placeholder" @input="clearFieldError('confirmPassword')" />
|
||||
<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>
|
||||
<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/transparent/a01-scroll-primary-v3.png" mode="aspectFit" />
|
||||
<view
|
||||
class="register-submit"
|
||||
hover-class="button-hover"
|
||||
@click="submitRegister"
|
||||
>
|
||||
<image
|
||||
class="register-submit__skin"
|
||||
src="/static/assets/foundation/transparent/a01-scroll-primary-v3.png"
|
||||
mode="aspectFit"
|
||||
/>
|
||||
<text class="register-submit__content">注册账号</text>
|
||||
</view>
|
||||
|
||||
<view class="agreement-area" :class="{ 'agreement-area--error': agreementError }">
|
||||
<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" />
|
||||
<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">
|
||||
<text>我已阅读并同意</text>
|
||||
<text class="agreement-link" @click.stop="prepareAgreement">《用户协议》</text>
|
||||
<text class="agreement-link" @click.stop="prepareAgreement"
|
||||
>《用户协议》</text
|
||||
>
|
||||
<text>与</text>
|
||||
<text class="agreement-link" @click.stop="prepareAgreement">《隐私政策》</text>
|
||||
<text class="agreement-link" @click.stop="prepareAgreement"
|
||||
>《隐私政策》</text
|
||||
>
|
||||
</view>
|
||||
</view>
|
||||
<text v-if="agreementError" class="agreement-error">请先阅读并同意用户协议与隐私政策</text>
|
||||
<text v-if="agreementError" class="agreement-error"
|
||||
>请先阅读并同意用户协议与隐私政策</text
|
||||
>
|
||||
</view>
|
||||
|
||||
<view class="login-entry">
|
||||
<text>已有账号?</text>
|
||||
<view class="login-entry__link" hover-class="tap-fade" @click="prepareLogin">登录</view>
|
||||
<view
|
||||
class="login-entry__link"
|
||||
hover-class="tap-fade"
|
||||
@click="prepareLogin"
|
||||
>登录</view
|
||||
>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
@@ -73,72 +158,73 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import { onUnload } from '@dcloudio/uni-app'
|
||||
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 phone = ref("");
|
||||
const password = ref("");
|
||||
const confirmPassword = ref("");
|
||||
const agreed = ref(false);
|
||||
const agreementError = ref(false);
|
||||
const fieldErrors = ref({
|
||||
phone: '',
|
||||
password: '',
|
||||
confirmPassword: ''
|
||||
})
|
||||
const feedbackVisible = ref(false)
|
||||
const feedbackMessage = ref('')
|
||||
let feedbackTimer = null
|
||||
phone: "",
|
||||
password: "",
|
||||
confirmPassword: "",
|
||||
});
|
||||
const feedbackVisible = ref(false);
|
||||
const feedbackMessage = ref("");
|
||||
let feedbackTimer = null;
|
||||
|
||||
onUnload(() => {
|
||||
if (feedbackTimer) clearTimeout(feedbackTimer)
|
||||
})
|
||||
if (feedbackTimer) clearTimeout(feedbackTimer);
|
||||
});
|
||||
|
||||
const goBack = () => {
|
||||
uni.navigateBack()
|
||||
}
|
||||
uni.navigateBack();
|
||||
};
|
||||
|
||||
// “已有账号”用于退出注册流程,替换当前页可避免登录/注册页面反复叠加。
|
||||
const prepareLogin = () => uni.redirectTo({ url: '/pages/auth/a01-entry' })
|
||||
const prepareLogin = () => uni.redirectTo({ url: "/pages/auth/a01-entry" });
|
||||
|
||||
const showFeedback = (message) => {
|
||||
feedbackMessage.value = message
|
||||
feedbackVisible.value = true
|
||||
if (feedbackTimer) clearTimeout(feedbackTimer)
|
||||
feedbackMessage.value = message;
|
||||
feedbackVisible.value = true;
|
||||
if (feedbackTimer) clearTimeout(feedbackTimer);
|
||||
feedbackTimer = setTimeout(() => {
|
||||
feedbackVisible.value = false
|
||||
feedbackTimer = null
|
||||
}, 2200)
|
||||
}
|
||||
feedbackVisible.value = false;
|
||||
feedbackTimer = null;
|
||||
}, 2200);
|
||||
};
|
||||
|
||||
const prepareAgreement = () => showFeedback('协议页面准备中')
|
||||
const prepareAgreement = () => showFeedback("协议页面准备中");
|
||||
|
||||
const toggleAgreement = () => {
|
||||
agreed.value = !agreed.value
|
||||
if (agreed.value) agreementError.value = false
|
||||
}
|
||||
agreed.value = !agreed.value;
|
||||
if (agreed.value) agreementError.value = false;
|
||||
};
|
||||
|
||||
const clearFieldError = (field) => {
|
||||
fieldErrors.value[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 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 submitRegister = () => {
|
||||
const formValid = validateForm()
|
||||
if (!agreed.value) agreementError.value = true
|
||||
if (!formValid || !agreed.value) return
|
||||
const formValid = validateForm();
|
||||
if (!agreed.value) agreementError.value = true;
|
||||
if (!formValid || !agreed.value) return;
|
||||
// 真实滑动验证由接口阶段的组件自带样式接管,本页不保留自定义假面板。
|
||||
showFeedback('滑动验证待接口接入')
|
||||
}
|
||||
showFeedback("滑动验证待接口接入");
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@@ -232,9 +318,13 @@ const submitRegister = () => {
|
||||
filter: brightness(0.68) saturate(1.5) contrast(1.2);
|
||||
}
|
||||
|
||||
.form-content { margin-top: 12rpx; }
|
||||
.form-content {
|
||||
margin-top: 12rpx;
|
||||
}
|
||||
|
||||
.field-block { min-height: 116rpx; }
|
||||
.field-block {
|
||||
min-height: 116rpx;
|
||||
}
|
||||
|
||||
.input-row {
|
||||
display: flex;
|
||||
@@ -257,7 +347,9 @@ const submitRegister = () => {
|
||||
font-size: 29rpx;
|
||||
}
|
||||
|
||||
.placeholder { color: #a79e94; }
|
||||
.placeholder {
|
||||
color: #a79e94;
|
||||
}
|
||||
|
||||
.field-error,
|
||||
.agreement-error {
|
||||
@@ -267,8 +359,13 @@ const submitRegister = () => {
|
||||
line-height: 32rpx;
|
||||
}
|
||||
|
||||
.field-error { padding-top: 4rpx; text-align: right; }
|
||||
.field-block--error .input-row { border-bottom-color: #b42318; }
|
||||
.field-error {
|
||||
padding-top: 4rpx;
|
||||
text-align: right;
|
||||
}
|
||||
.field-block--error .input-row {
|
||||
border-bottom-color: #b42318;
|
||||
}
|
||||
|
||||
.register-submit {
|
||||
position: relative;
|
||||
@@ -295,7 +392,10 @@ const submitRegister = () => {
|
||||
letter-spacing: 6rpx;
|
||||
}
|
||||
|
||||
.agreement-area { min-height: 118rpx; margin-top: 18rpx; }
|
||||
.agreement-area {
|
||||
min-height: 118rpx;
|
||||
margin-top: 18rpx;
|
||||
}
|
||||
|
||||
.agreement-row {
|
||||
display: flex;
|
||||
@@ -320,15 +420,28 @@ const submitRegister = () => {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.agreement-link { color: #a7160c; }
|
||||
.agreement-error { text-align: center; }
|
||||
.agreement-link {
|
||||
color: #a7160c;
|
||||
}
|
||||
.agreement-error {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.agreement-area--error { animation: agreement-shake 240ms ease-out; }
|
||||
.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); }
|
||||
0%,
|
||||
100% {
|
||||
transform: translateX(0);
|
||||
}
|
||||
33% {
|
||||
transform: translateX(-8rpx);
|
||||
}
|
||||
66% {
|
||||
transform: translateX(8rpx);
|
||||
}
|
||||
}
|
||||
|
||||
.login-entry {
|
||||
@@ -348,8 +461,12 @@ const submitRegister = () => {
|
||||
color: #a7160c;
|
||||
}
|
||||
|
||||
.tap-fade { opacity: 0.62; }
|
||||
.button-hover { opacity: 0.84; }
|
||||
.tap-fade {
|
||||
opacity: 0.62;
|
||||
}
|
||||
.button-hover {
|
||||
opacity: 0.84;
|
||||
}
|
||||
|
||||
.feedback-toast {
|
||||
position: fixed;
|
||||
@@ -364,7 +481,7 @@ const submitRegister = () => {
|
||||
min-height: 82rpx;
|
||||
border: 16rpx solid transparent;
|
||||
border-width: 16rpx 74rpx;
|
||||
border-image-source: url('/static/assets/foundation/transparent/a01-scroll-toast-v3.png');
|
||||
border-image-source: url("/static/assets/foundation/transparent/a01-scroll-toast-v3.png");
|
||||
border-image-slice: 58 280 fill;
|
||||
border-image-width: 16rpx 74rpx;
|
||||
border-image-repeat: stretch;
|
||||
@@ -380,5 +497,4 @@ const submitRegister = () => {
|
||||
font-size: 25rpx;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
</style>
|
||||
|
||||
@@ -2,76 +2,174 @@
|
||||
<template>
|
||||
<view class="auth-page reset-page">
|
||||
<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" />
|
||||
<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="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" />
|
||||
<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/modules/auth/transparent/a01-vnext-divider-v1.png" mode="aspectFit" />
|
||||
<image
|
||||
class="reset-divider"
|
||||
src="/static/assets/modules/auth/transparent/a01-vnext-divider-v1.png"
|
||||
mode="aspectFit"
|
||||
/>
|
||||
</view>
|
||||
|
||||
<view class="form-content">
|
||||
<view class="field-block" :class="{ 'field-block--error': fieldErrors.phone }">
|
||||
<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="clearFieldError('phone')" />
|
||||
<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>
|
||||
<text v-if="fieldErrors.phone" class="field-error">{{
|
||||
fieldErrors.phone
|
||||
}}</text>
|
||||
</view>
|
||||
|
||||
<view class="field-block" :class="{ 'field-block--error': fieldErrors.verificationCode }">
|
||||
<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" @input="clearFieldError('verificationCode')" />
|
||||
<view class="get-code" hover-class="tap-fade" @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"
|
||||
>获取验证码</view
|
||||
>
|
||||
</view>
|
||||
<text v-if="fieldErrors.verificationCode" class="field-error">{{ fieldErrors.verificationCode }}</text>
|
||||
<text v-if="fieldErrors.verificationCode" class="field-error">{{
|
||||
fieldErrors.verificationCode
|
||||
}}</text>
|
||||
</view>
|
||||
|
||||
<view class="field-block" :class="{ 'field-block--error': fieldErrors.password }">
|
||||
<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 maxlength="32" placeholder="请设置新密码" placeholder-class="placeholder" @input="clearFieldError('password')" />
|
||||
<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>
|
||||
<text v-if="fieldErrors.password" class="field-error">{{
|
||||
fieldErrors.password
|
||||
}}</text>
|
||||
</view>
|
||||
|
||||
<view class="field-block" :class="{ 'field-block--error': fieldErrors.confirmPassword }">
|
||||
<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 maxlength="32" placeholder="请再次输入新密码" placeholder-class="placeholder" @input="clearFieldError('confirmPassword')" />
|
||||
<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>
|
||||
<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/transparent/a01-scroll-primary-v3.png" mode="aspectFit" />
|
||||
<view
|
||||
class="reset-submit"
|
||||
hover-class="button-hover"
|
||||
@click="submitReset"
|
||||
>
|
||||
<image
|
||||
class="reset-submit__skin"
|
||||
src="/static/assets/foundation/transparent/a01-scroll-primary-v3.png"
|
||||
mode="aspectFit"
|
||||
/>
|
||||
<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="tap-fade" @click="prepareLogin">返回登录</view>
|
||||
<view
|
||||
class="login-entry__link"
|
||||
hover-class="tap-fade"
|
||||
@click="prepareLogin"
|
||||
>返回登录</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/transparent/a01-scroll-dialog-v3.png" mode="aspectFit" />
|
||||
<image
|
||||
class="success-dialog__skin"
|
||||
src="/static/assets/modules/auth/transparent/a01-scroll-dialog-v3.png"
|
||||
mode="aspectFit"
|
||||
/>
|
||||
<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/transparent/a01-scroll-primary-v3.png" mode="aspectFit" />
|
||||
<view
|
||||
class="success-action"
|
||||
hover-class="button-hover"
|
||||
@click="prepareLogin"
|
||||
>
|
||||
<image
|
||||
class="success-action__skin"
|
||||
src="/static/assets/foundation/transparent/a01-scroll-primary-v3.png"
|
||||
mode="aspectFit"
|
||||
/>
|
||||
<text class="success-action__copy">返回登录</text>
|
||||
</view>
|
||||
</view>
|
||||
@@ -85,71 +183,78 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import { onUnload } from '@dcloudio/uni-app'
|
||||
import { ref } from "vue";
|
||||
import { onUnload } from "@dcloudio/uni-app";
|
||||
|
||||
const phone = ref('')
|
||||
const verificationCode = ref('')
|
||||
const password = ref('')
|
||||
const confirmPassword = ref('')
|
||||
const successVisible = ref(false)
|
||||
const feedbackVisible = ref(false)
|
||||
const feedbackMessage = ref('')
|
||||
const phone = ref("");
|
||||
const verificationCode = ref("");
|
||||
const password = ref("");
|
||||
const confirmPassword = ref("");
|
||||
const successVisible = ref(false);
|
||||
const feedbackVisible = ref(false);
|
||||
const feedbackMessage = ref("");
|
||||
const fieldErrors = ref({
|
||||
phone: '',
|
||||
verificationCode: '',
|
||||
password: '',
|
||||
confirmPassword: ''
|
||||
})
|
||||
let feedbackTimer = null
|
||||
phone: "",
|
||||
verificationCode: "",
|
||||
password: "",
|
||||
confirmPassword: "",
|
||||
});
|
||||
let feedbackTimer = null;
|
||||
|
||||
onUnload(() => {
|
||||
if (feedbackTimer) clearTimeout(feedbackTimer)
|
||||
})
|
||||
if (feedbackTimer) clearTimeout(feedbackTimer);
|
||||
});
|
||||
|
||||
const goBack = () => uni.navigateBack()
|
||||
const goBack = () => uni.navigateBack();
|
||||
|
||||
// 返回登录时替换当前认证页,避免登录与重设页反复叠加。
|
||||
const prepareLogin = () => uni.redirectTo({ url: '/pages/auth/a01-entry' })
|
||||
const prepareLogin = () => uni.redirectTo({ url: "/pages/auth/a01-entry" });
|
||||
|
||||
const showFeedback = (message) => {
|
||||
feedbackMessage.value = message
|
||||
feedbackVisible.value = true
|
||||
if (feedbackTimer) clearTimeout(feedbackTimer)
|
||||
feedbackMessage.value = message;
|
||||
feedbackVisible.value = true;
|
||||
if (feedbackTimer) clearTimeout(feedbackTimer);
|
||||
feedbackTimer = setTimeout(() => {
|
||||
feedbackVisible.value = false
|
||||
feedbackTimer = null
|
||||
}, 2200)
|
||||
}
|
||||
feedbackVisible.value = false;
|
||||
feedbackTimer = null;
|
||||
}, 2200);
|
||||
};
|
||||
|
||||
const clearFieldError = (field) => {
|
||||
fieldErrors.value[field] = ''
|
||||
}
|
||||
fieldErrors.value[field] = "";
|
||||
};
|
||||
|
||||
const prepareGetCode = () => {
|
||||
if (!/^1\d{10}$/.test(phone.value)) {
|
||||
fieldErrors.value.phone = '请输入正确手机号'
|
||||
return
|
||||
fieldErrors.value.phone = "请输入正确手机号";
|
||||
return;
|
||||
}
|
||||
fieldErrors.value.phone = ''
|
||||
showFeedback('滑动验证待接口接入')
|
||||
}
|
||||
fieldErrors.value.phone = "";
|
||||
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 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 (!validateForm()) return
|
||||
successVisible.value = true
|
||||
}
|
||||
if (!validateForm()) return;
|
||||
successVisible.value = true;
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@@ -217,12 +322,35 @@ const submitReset = () => {
|
||||
height: 88rpx;
|
||||
}
|
||||
|
||||
.back-button__icon { width: 42rpx; height: 42rpx; transform: scaleX(-1); }
|
||||
.page-title { color: #9f170f; 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: 300rpx; height: 50rpx; margin-top: 2rpx; filter: brightness(0.68) saturate(1.5) contrast(1.2); }
|
||||
.form-content { margin-top: 8rpx; }
|
||||
.field-block { min-height: 114rpx; }
|
||||
.back-button__icon {
|
||||
width: 42rpx;
|
||||
height: 42rpx;
|
||||
transform: scaleX(-1);
|
||||
}
|
||||
.page-title {
|
||||
color: #9f170f;
|
||||
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: 300rpx;
|
||||
height: 50rpx;
|
||||
margin-top: 2rpx;
|
||||
filter: brightness(0.68) saturate(1.5) contrast(1.2);
|
||||
}
|
||||
.form-content {
|
||||
margin-top: 8rpx;
|
||||
}
|
||||
.field-block {
|
||||
min-height: 114rpx;
|
||||
}
|
||||
|
||||
.input-row {
|
||||
display: flex;
|
||||
@@ -231,12 +359,39 @@ const submitReset = () => {
|
||||
border-bottom: 1rpx solid #cfaa70;
|
||||
}
|
||||
|
||||
.input-label { flex: 0 0 162rpx; color: #674b35; 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; }
|
||||
.input-label {
|
||||
flex: 0 0 162rpx;
|
||||
color: #674b35;
|
||||
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;
|
||||
@@ -249,11 +404,41 @@ const submitReset = () => {
|
||||
}
|
||||
|
||||
.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-size: 39rpx; 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; }
|
||||
.success-action__skin {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
.reset-submit__content {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
color: #fffaf0;
|
||||
font-size: 39rpx;
|
||||
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;
|
||||
}
|
||||
|
||||
.success-layer {
|
||||
position: fixed;
|
||||
@@ -267,23 +452,113 @@ const submitReset = () => {
|
||||
background: rgba(35, 18, 10, 0.62);
|
||||
}
|
||||
|
||||
.success-dialog { position: relative; width: 620rpx; height: 520rpx; }
|
||||
.success-dialog__skin { position: absolute; inset: 0; width: 100%; height: 100%; }
|
||||
.success-dialog__content { position: relative; z-index: 1; display: flex; flex-direction: column; align-items: center; box-sizing: border-box; height: 100%; padding: 64rpx 64rpx 42rpx; text-align: center; }
|
||||
.success-title { color: #8f160f; font-size: 43rpx; font-weight: 700; letter-spacing: 4rpx; }
|
||||
.success-copy { margin-top: 18rpx; color: #513a28; font-size: 26rpx; line-height: 40rpx; }
|
||||
.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: 82rpx; margin-top: 24rpx; overflow: hidden; }
|
||||
.success-action__copy { position: relative; z-index: 1; color: #fffaf0; font-size: 30rpx; letter-spacing: 4rpx; }
|
||||
.success-dialog {
|
||||
position: relative;
|
||||
width: 620rpx;
|
||||
height: 520rpx;
|
||||
}
|
||||
.success-dialog__skin {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
.success-dialog__content {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
box-sizing: border-box;
|
||||
height: 100%;
|
||||
padding: 64rpx 64rpx 42rpx;
|
||||
text-align: center;
|
||||
}
|
||||
.success-title {
|
||||
color: #8f160f;
|
||||
font-size: 43rpx;
|
||||
font-weight: 700;
|
||||
letter-spacing: 4rpx;
|
||||
}
|
||||
.success-copy {
|
||||
margin-top: 18rpx;
|
||||
color: #513a28;
|
||||
font-size: 26rpx;
|
||||
line-height: 40rpx;
|
||||
}
|
||||
.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: 82rpx;
|
||||
margin-top: 24rpx;
|
||||
overflow: hidden;
|
||||
}
|
||||
.success-action__copy {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
color: #fffaf0;
|
||||
font-size: 30rpx;
|
||||
letter-spacing: 4rpx;
|
||||
}
|
||||
|
||||
.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; box-sizing: border-box; width: 590rpx; min-height: 82rpx; border: 16rpx solid transparent; border-width: 16rpx 74rpx; border-image-source: url('/static/assets/foundation/transparent/a01-scroll-toast-v3.png'); border-image-slice: 58 280 fill; border-image-width: 16rpx 74rpx; border-image-repeat: stretch; background: transparent; transform: translateX(-50%); }
|
||||
.feedback-toast__copy { position: relative; z-index: 1; padding: 16rpx 36rpx; 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;
|
||||
box-sizing: border-box;
|
||||
width: 590rpx;
|
||||
min-height: 82rpx;
|
||||
border: 16rpx solid transparent;
|
||||
border-width: 16rpx 74rpx;
|
||||
border-image-source: url("/static/assets/foundation/transparent/a01-scroll-toast-v3.png");
|
||||
border-image-slice: 58 280 fill;
|
||||
border-image-width: 16rpx 74rpx;
|
||||
border-image-repeat: stretch;
|
||||
background: transparent;
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
.feedback-toast__copy {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
padding: 16rpx 36rpx;
|
||||
color: #5c4330;
|
||||
font-size: 25rpx;
|
||||
text-align: center;
|
||||
}
|
||||
.tap-fade,
|
||||
.button-hover { opacity: 0.72; }
|
||||
.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; }
|
||||
.reset-content {
|
||||
padding-right: 48rpx;
|
||||
padding-left: 48rpx;
|
||||
}
|
||||
.page-title {
|
||||
font-size: 60rpx;
|
||||
}
|
||||
.input-label {
|
||||
flex-basis: 150rpx;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -2,58 +2,114 @@
|
||||
<template>
|
||||
<view class="auth-page status-page">
|
||||
<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" />
|
||||
<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="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" />
|
||||
<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/modules/auth/transparent/a01-vnext-divider-v1.png" mode="aspectFit" />
|
||||
<image
|
||||
class="status-divider"
|
||||
src="/static/assets/modules/auth/transparent/a01-vnext-divider-v1.png"
|
||||
mode="aspectFit"
|
||||
/>
|
||||
</view>
|
||||
|
||||
<view class="status-body">
|
||||
<image class="status-icon" src="/static/assets/foundation/transparent/auth-login-outline.png" mode="aspectFit" />
|
||||
<image
|
||||
class="status-icon"
|
||||
src="/static/assets/foundation/transparent/auth-login-outline.png"
|
||||
mode="aspectFit"
|
||||
/>
|
||||
<text class="status-eyebrow">{{ currentState.eyebrow }}</text>
|
||||
<text class="status-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>
|
||||
<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>
|
||||
<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>
|
||||
<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="openRecovery">
|
||||
<image class="status-primary__skin" src="/static/assets/foundation/transparent/a01-scroll-primary-v3.png" mode="aspectFit" />
|
||||
<view
|
||||
class="status-primary"
|
||||
hover-class="button-hover"
|
||||
@click="openRecovery"
|
||||
>
|
||||
<image
|
||||
class="status-primary__skin"
|
||||
src="/static/assets/foundation/transparent/a01-scroll-primary-v3.png"
|
||||
mode="aspectFit"
|
||||
/>
|
||||
<text class="status-primary__copy">查看恢复方式</text>
|
||||
</view>
|
||||
<view class="status-secondary" hover-class="tap-fade" @click="goLogin">返回登录</view>
|
||||
<view class="status-secondary" hover-class="tap-fade" @click="goLogin"
|
||||
>返回登录</view
|
||||
>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<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/transparent/a01-scroll-dialog-v3.png" mode="aspectFit" />
|
||||
<image
|
||||
class="recovery-dialog__skin"
|
||||
src="/static/assets/modules/auth/transparent/a01-scroll-dialog-v3.png"
|
||||
mode="aspectFit"
|
||||
/>
|
||||
<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/transparent/a01-scroll-primary-v3.png" mode="aspectFit" />
|
||||
<text class="recovery-note"
|
||||
>账号恢复服务将在功能阶段接入,当前仅审核页面样式与状态位置。</text
|
||||
>
|
||||
<view
|
||||
class="recovery-action"
|
||||
hover-class="button-hover"
|
||||
@click="closeRecovery"
|
||||
>
|
||||
<image
|
||||
class="recovery-action__skin"
|
||||
src="/static/assets/foundation/transparent/a01-scroll-primary-v3.png"
|
||||
mode="aspectFit"
|
||||
/>
|
||||
<text class="recovery-action__copy">我知道了</text>
|
||||
</view>
|
||||
</view>
|
||||
@@ -63,77 +119,87 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue'
|
||||
import { onLoad, onShow } from '@dcloudio/uni-app'
|
||||
import { computed, ref } from "vue";
|
||||
import { onLoad, onShow } from "@dcloudio/uni-app";
|
||||
|
||||
const status = ref('risk')
|
||||
const recoveryVisible = ref(false)
|
||||
const status = ref("risk");
|
||||
const recoveryVisible = ref(false);
|
||||
|
||||
// A06 阻断状态的唯一合同;普通登录错误必须留在 A01 就近处理。
|
||||
const statusConfig = {
|
||||
frozen: {
|
||||
eyebrow: '账号冻结',
|
||||
title: '账号已被冻结',
|
||||
description: '当前账号因安全审核暂时无法登录。',
|
||||
reasonLabel: '限制原因',
|
||||
reason: '账号进入人工安全审核',
|
||||
impactLabel: '影响范围',
|
||||
impact: '暂时无法进入家谱及管理资料',
|
||||
recoveryLabel: '恢复方式',
|
||||
recovery: '提交账号申诉并等待审核结果',
|
||||
recoveryTitle: '申请解除冻结',
|
||||
recoveryDetail: '请准备注册手机号与身份说明,提交后由平台进行人工核验。'
|
||||
eyebrow: "账号冻结",
|
||||
title: "账号已被冻结",
|
||||
description: "当前账号因安全审核暂时无法登录。",
|
||||
reasonLabel: "限制原因",
|
||||
reason: "账号进入人工安全审核",
|
||||
impactLabel: "影响范围",
|
||||
impact: "暂时无法进入家谱及管理资料",
|
||||
recoveryLabel: "恢复方式",
|
||||
recovery: "提交账号申诉并等待审核结果",
|
||||
recoveryTitle: "申请解除冻结",
|
||||
recoveryDetail: "请准备注册手机号与身份说明,提交后由平台进行人工核验。",
|
||||
},
|
||||
disabled: {
|
||||
eyebrow: '账号停用',
|
||||
title: '账号已被停用',
|
||||
description: '当前账号处于停用状态,登录入口已关闭。',
|
||||
reasonLabel: '限制原因',
|
||||
reason: '账号已执行停用处理',
|
||||
impactLabel: '影响范围',
|
||||
impact: '无法登录,原有家谱资料不会被删除',
|
||||
recoveryLabel: '恢复方式',
|
||||
recovery: '联系平台核实停用原因与恢复条件',
|
||||
recoveryTitle: '联系平台核实',
|
||||
recoveryDetail: '请提供注册手机号和账号归属信息,平台核实后告知是否可以恢复。'
|
||||
eyebrow: "账号停用",
|
||||
title: "账号已被停用",
|
||||
description: "当前账号处于停用状态,登录入口已关闭。",
|
||||
reasonLabel: "限制原因",
|
||||
reason: "账号已执行停用处理",
|
||||
impactLabel: "影响范围",
|
||||
impact: "无法登录,原有家谱资料不会被删除",
|
||||
recoveryLabel: "恢复方式",
|
||||
recovery: "联系平台核实停用原因与恢复条件",
|
||||
recoveryTitle: "联系平台核实",
|
||||
recoveryDetail:
|
||||
"请提供注册手机号和账号归属信息,平台核实后告知是否可以恢复。",
|
||||
},
|
||||
risk: {
|
||||
eyebrow: '风险限制',
|
||||
title: '账号存在安全风险',
|
||||
description: '系统检测到异常登录,为保护家谱资料已暂停本次登录。',
|
||||
reasonLabel: '限制原因',
|
||||
reason: '登录环境或操作行为存在异常',
|
||||
impactLabel: '影响范围',
|
||||
impact: '本次登录被阻断,账号资料保持不变',
|
||||
recoveryLabel: '恢复方式',
|
||||
recovery: '完成身份核验后重新登录',
|
||||
recoveryTitle: '完成身份核验',
|
||||
recoveryDetail: '请使用注册手机号完成身份核验;核验通过后可重新尝试登录。'
|
||||
}
|
||||
}
|
||||
eyebrow: "风险限制",
|
||||
title: "账号存在安全风险",
|
||||
description: "系统检测到异常登录,为保护家谱资料已暂停本次登录。",
|
||||
reasonLabel: "限制原因",
|
||||
reason: "登录环境或操作行为存在异常",
|
||||
impactLabel: "影响范围",
|
||||
impact: "本次登录被阻断,账号资料保持不变",
|
||||
recoveryLabel: "恢复方式",
|
||||
recovery: "完成身份核验后重新登录",
|
||||
recoveryTitle: "完成身份核验",
|
||||
recoveryDetail: "请使用注册手机号完成身份核验;核验通过后可重新尝试登录。",
|
||||
},
|
||||
};
|
||||
|
||||
const currentState = computed(() => statusConfig[status.value])
|
||||
const currentState = computed(() => statusConfig[status.value]);
|
||||
|
||||
const resolveStatus = () => {
|
||||
let requestedStatus = 'risk'
|
||||
if (typeof location !== 'undefined') {
|
||||
const query = new URLSearchParams(location.hash.split('?')[1] || '')
|
||||
requestedStatus = query.get('status') || 'risk'
|
||||
let requestedStatus = "risk";
|
||||
if (typeof location !== "undefined") {
|
||||
const query = new URLSearchParams(location.hash.split("?")[1] || "");
|
||||
requestedStatus = query.get("status") || "risk";
|
||||
} else {
|
||||
const pages = getCurrentPages()
|
||||
requestedStatus = pages[pages.length - 1]?.options?.status || 'risk'
|
||||
const pages = getCurrentPages();
|
||||
requestedStatus = pages[pages.length - 1]?.options?.status || "risk";
|
||||
}
|
||||
status.value = Object.prototype.hasOwnProperty.call(statusConfig, requestedStatus) ? requestedStatus : 'risk'
|
||||
recoveryVisible.value = false
|
||||
}
|
||||
status.value = Object.prototype.hasOwnProperty.call(
|
||||
statusConfig,
|
||||
requestedStatus,
|
||||
)
|
||||
? requestedStatus
|
||||
: "risk";
|
||||
recoveryVisible.value = false;
|
||||
};
|
||||
|
||||
onLoad(resolveStatus)
|
||||
onShow(resolveStatus)
|
||||
onLoad(resolveStatus);
|
||||
onShow(resolveStatus);
|
||||
|
||||
const goLogin = () => uni.redirectTo({ url: '/pages/auth/a01-entry' })
|
||||
const openRecovery = () => { recoveryVisible.value = true }
|
||||
const closeRecovery = () => { recoveryVisible.value = false }
|
||||
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">
|
||||
@@ -148,48 +214,250 @@ const goBack = () => uni.navigateBack()
|
||||
color: #493323;
|
||||
}
|
||||
|
||||
.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: 184rpx; height: 221rpx; 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-size: 66rpx; font-weight: 700; letter-spacing: 7rpx; }
|
||||
.page-subtitle { margin-top: 12rpx; color: #806c58; font-size: 25rpx; letter-spacing: 1rpx; }
|
||||
.status-divider { width: 300rpx; height: 50rpx; margin-top: 2rpx; filter: brightness(0.68) saturate(1.5) contrast(1.2); }
|
||||
.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: 184rpx;
|
||||
height: 221rpx;
|
||||
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-size: 66rpx;
|
||||
font-weight: 700;
|
||||
letter-spacing: 7rpx;
|
||||
}
|
||||
.page-subtitle {
|
||||
margin-top: 12rpx;
|
||||
color: #806c58;
|
||||
font-size: 25rpx;
|
||||
letter-spacing: 1rpx;
|
||||
}
|
||||
.status-divider {
|
||||
width: 300rpx;
|
||||
height: 50rpx;
|
||||
margin-top: 2rpx;
|
||||
filter: brightness(0.68) saturate(1.5) contrast(1.2);
|
||||
}
|
||||
|
||||
.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-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-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-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 {
|
||||
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-size: 38rpx; letter-spacing: 5rpx; }
|
||||
.status-secondary { display: flex; align-items: center; justify-content: center; min-height: 82rpx; color: #a7160c; font-size: 24rpx; }
|
||||
.recovery-action__skin {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
.status-primary__copy {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
color: #fffaf0;
|
||||
font-size: 38rpx;
|
||||
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: 620rpx; height: 520rpx; }
|
||||
.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; height: 100%; padding: 58rpx 58rpx 36rpx; text-align: center; }
|
||||
.recovery-title { color: #8f160f; font-size: 42rpx; font-weight: 700; letter-spacing: 4rpx; }
|
||||
.recovery-copy { margin-top: 20rpx; color: #513a28; font-size: 26rpx; line-height: 40rpx; }
|
||||
.recovery-note { margin-top: 12rpx; color: #8d7965; font-size: 20rpx; line-height: 31rpx; }
|
||||
.recovery-action { position: relative; display: flex; align-items: center; justify-content: center; width: 100%; height: 80rpx; margin-top: 22rpx; overflow: hidden; }
|
||||
.recovery-action__copy { position: relative; z-index: 1; color: #fffaf0; font-size: 29rpx; letter-spacing: 4rpx; }
|
||||
.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: 620rpx;
|
||||
height: 520rpx;
|
||||
}
|
||||
.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;
|
||||
height: 100%;
|
||||
padding: 58rpx 58rpx 36rpx;
|
||||
text-align: center;
|
||||
}
|
||||
.recovery-title {
|
||||
color: #8f160f;
|
||||
font-size: 42rpx;
|
||||
font-weight: 700;
|
||||
letter-spacing: 4rpx;
|
||||
}
|
||||
.recovery-copy {
|
||||
margin-top: 20rpx;
|
||||
color: #513a28;
|
||||
font-size: 26rpx;
|
||||
line-height: 40rpx;
|
||||
}
|
||||
.recovery-note {
|
||||
margin-top: 12rpx;
|
||||
color: #8d7965;
|
||||
font-size: 20rpx;
|
||||
line-height: 31rpx;
|
||||
}
|
||||
.recovery-action {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
height: 80rpx;
|
||||
margin-top: 22rpx;
|
||||
overflow: hidden;
|
||||
}
|
||||
.recovery-action__copy {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
color: #fffaf0;
|
||||
font-size: 29rpx;
|
||||
letter-spacing: 4rpx;
|
||||
}
|
||||
.tap-fade,
|
||||
.button-hover { opacity: 0.72; }
|
||||
.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; }
|
||||
.status-content {
|
||||
padding-right: 48rpx;
|
||||
padding-left: 48rpx;
|
||||
}
|
||||
.page-title {
|
||||
font-size: 60rpx;
|
||||
}
|
||||
.status-detail__label {
|
||||
flex-basis: 108rpx;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -3,42 +3,93 @@
|
||||
<view class="overview-page">
|
||||
<GenealogyPageBackground />
|
||||
<view class="overview-page__header">
|
||||
<PageHeader :title="viewMode === 'public' ? '家谱公开预览' : '家谱总览'" :action="overviewState === 'ready' && viewMode === 'member' && accessRole === 'owner' ? '管理' : ''" @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" />
|
||||
<view
|
||||
class="overview-surface"
|
||||
:class="{ 'overview-surface--state': overviewState !== 'ready' }"
|
||||
>
|
||||
<image
|
||||
class="overview-surface__skin"
|
||||
src="/static/assets/modules/genealogy/opaque/g05-overview-surface.png"
|
||||
mode="scaleToFill"
|
||||
/>
|
||||
|
||||
<template v-if="overviewState === 'ready' && genealogy && viewMode === 'member'">
|
||||
<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>
|
||||
<text class="overview-hero__kicker"
|
||||
>{{ genealogy.hall || "堂号待补" }} ·
|
||||
{{ genealogy.location || "所在地待补" }}</text
|
||||
>
|
||||
<text class="overview-hero__title">{{ genealogy.name }}</text>
|
||||
<text class="overview-hero__motto">{{ genealogy.motto || '敦亲睦族,敬祖传家。' }}</text>
|
||||
<text class="overview-hero__motto">{{
|
||||
genealogy.motto || "敦亲睦族,敬祖传家。"
|
||||
}}</text>
|
||||
<view class="overview-hero__stats">
|
||||
<text>共 {{ genealogy.memberCount || 0 }} 人</text>
|
||||
<text>已激活 {{ genealogy.activeCount || 0 }} 人</text>
|
||||
<text>{{ genealogy.visibility || '仅成员可见' }}</text>
|
||||
<text>{{ genealogy.visibility || "仅成员可见" }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="overview-actions" :class="{ 'overview-actions--member': accessRole === 'member' }">
|
||||
<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>
|
||||
<text class="overview-action__title">世系树</text
|
||||
><text>查看家脉关系</text>
|
||||
</view>
|
||||
<view v-if="accessRole === 'owner'" class="overview-action overview-action--ancestor" @click="toFirstPerson">
|
||||
<text class="overview-action__title">录入族人</text><text>从首代开始完善</text>
|
||||
<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 v-else class="overview-action-lock">
|
||||
<text class="overview-action__title">录入族人</text
|
||||
><text>仅管理员可用</text>
|
||||
</view>
|
||||
<view v-if="accessRole === 'owner'" class="overview-action overview-action--review" @click="toApplications">
|
||||
<text class="overview-action__title">入谱审核</text><text>处理加入申请</text>
|
||||
<view
|
||||
class="overview-action overview-action--poem"
|
||||
@click="toGenerationPoems"
|
||||
>
|
||||
<text class="overview-action__title">字辈谱</text
|
||||
><text>传承行辈次序</text>
|
||||
</view>
|
||||
<view
|
||||
v-if="accessRole === 'owner'"
|
||||
class="overview-action overview-action--review"
|
||||
@click="toApplications"
|
||||
>
|
||||
<text class="overview-action__title">入谱审核</text
|
||||
><text>处理加入申请</text>
|
||||
</view>
|
||||
<view v-else class="overview-action-lock">
|
||||
<text class="overview-action__title">入谱审核</text
|
||||
><text>仅管理员可用</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="overview-family" @click="toFamily">
|
||||
<text>家族近况</text><text>进入家族圈查看动态</text><text>查看</text>
|
||||
<text>家族近况</text><text>进入家族圈查看动态</text
|
||||
><text>查看</text>
|
||||
</view>
|
||||
<view class="overview-note">
|
||||
<text class="overview-note__title">家谱资料,仅向家人开放</text>
|
||||
@@ -47,40 +98,97 @@
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<view v-else-if="overviewState === 'ready' && genealogy" class="overview-public">
|
||||
<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
|
||||
><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/transparent/a01-scroll-primary-v3.png" mode="aspectFit" />
|
||||
<image
|
||||
src="/static/assets/foundation/transparent/a01-scroll-primary-v3.png"
|
||||
mode="aspectFit"
|
||||
/>
|
||||
<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--no-permission': overviewState === 'no-permission'
|
||||
}">
|
||||
<text class="overview-state-panel__title">{{ stateTitle }}</text>
|
||||
<text class="overview-state-panel__copy">{{ stateCopy }}</text>
|
||||
<view v-if="overviewState !== 'loading'" class="overview-state-panel__action" @click="overviewState === 'error' ? reloadOverview() : toGenealogies()">
|
||||
<image src="/static/assets/foundation/transparent/a01-scroll-primary-v3.png" mode="aspectFit" />
|
||||
<text>{{ overviewState === 'error' ? '重新查看' : '返回我的家谱' }}</text>
|
||||
<view
|
||||
v-else-if="overviewState === 'loading'"
|
||||
class="overview-state overview-state--loading"
|
||||
>
|
||||
<image
|
||||
class="overview-state__frame"
|
||||
src="/static/assets/modules/genealogy/transparent/g01-empty-panel-frame.png"
|
||||
mode="scaleToFill"
|
||||
/>
|
||||
<AppLoading
|
||||
text="正在展开家谱"
|
||||
description="请稍候,正在读取家谱概览。"
|
||||
/>
|
||||
</view>
|
||||
<view
|
||||
v-else
|
||||
class="overview-state overview-state-panel"
|
||||
:class="{
|
||||
'overview-state--loading': overviewState === 'loading',
|
||||
'overview-state--empty': overviewState === 'empty',
|
||||
'overview-state--error': overviewState === 'error',
|
||||
'overview-state--no-permission': overviewState === 'no-permission',
|
||||
}"
|
||||
>
|
||||
<image
|
||||
class="overview-state__frame"
|
||||
src="/static/assets/modules/genealogy/transparent/g01-empty-panel-frame.png"
|
||||
mode="scaleToFill"
|
||||
/>
|
||||
<view class="overview-state__content">
|
||||
<image
|
||||
class="overview-state__seal"
|
||||
src="/static/assets/foundation/transparent/brand-seal.png"
|
||||
mode="aspectFit"
|
||||
/>
|
||||
<text class="overview-state-panel__title">{{ stateTitle }}</text>
|
||||
<text class="overview-state-panel__copy">{{ stateCopy }}</text>
|
||||
<view
|
||||
class="overview-state-panel__action"
|
||||
@click="
|
||||
overviewState === 'error' ? reloadOverview() : toGenealogies()
|
||||
"
|
||||
>
|
||||
<image
|
||||
src="/static/assets/foundation/transparent/a01-scroll-primary-v3.png"
|
||||
mode="aspectFit"
|
||||
/>
|
||||
<text>{{
|
||||
overviewState === "error" ? "重新查看" : "返回我的家谱"
|
||||
}}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
@@ -88,126 +196,452 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue'
|
||||
import { onLoad } from '@dcloudio/uni-app'
|
||||
import GenealogyPageBackground from '@/components/GenealogyPageBackground.vue'
|
||||
import PageHeader from '@/components/PageHeader.vue'
|
||||
import { computed, ref } from "vue";
|
||||
import { onLoad } from "@dcloudio/uni-app";
|
||||
import AppLoading from "@/components/AppLoading.vue";
|
||||
import GenealogyPageBackground from "@/components/GenealogyPageBackground.vue";
|
||||
import PageHeader from "@/components/PageHeader.vue";
|
||||
|
||||
const genealogy = ref(null)
|
||||
const genealogyId = ref('')
|
||||
const overviewState = ref('loading')
|
||||
const loadError = ref('')
|
||||
const viewMode = ref('member')
|
||||
const accessRole = ref('owner')
|
||||
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: '敦亲睦族,敬祖传家。',
|
||||
id: "2001",
|
||||
surname: "汤",
|
||||
name: "汤氏南阳宗谱",
|
||||
hall: "敦睦堂",
|
||||
location: "河南·南阳",
|
||||
parentName: "汤氏中华总谱",
|
||||
branchName: "南阳主支",
|
||||
source: "由南阳汤氏族人整理并维护",
|
||||
publicDescription:
|
||||
"公开展示家谱身份、地区、堂号与支系信息;成员资料和世系详情仅向已加入成员开放。",
|
||||
motto: "敦亲睦族,敬祖传家。",
|
||||
memberCount: 428,
|
||||
activeCount: 316,
|
||||
visibility: '仅成员可见'
|
||||
}
|
||||
visibility: "仅成员可见",
|
||||
};
|
||||
|
||||
const stateTitle = computed(() => ({
|
||||
loading: '正在展开家谱…',
|
||||
empty: '还没有可查看的家谱',
|
||||
error: '家谱暂时无法打开',
|
||||
'no-permission': '当前账号无权查看'
|
||||
}[overviewState.value] || ''))
|
||||
const stateCopy = computed(() => loadError.value || ({
|
||||
loading: '请稍候,正在读取家谱概览。',
|
||||
empty: '请从“我的家谱”选择一部家谱后再进入。',
|
||||
error: '可能是网络波动或家谱不存在。',
|
||||
'no-permission': '这部家谱未向当前账号开放,请返回我的家谱或联系管理员。'
|
||||
}[overviewState.value] || ''))
|
||||
const stateTitle = computed(
|
||||
() =>
|
||||
({
|
||||
loading: "正在展开家谱…",
|
||||
empty: "还没有可查看的家谱",
|
||||
error: "家谱暂时无法打开",
|
||||
"no-permission": "当前账号无权查看",
|
||||
})[overviewState.value] || "",
|
||||
);
|
||||
const stateCopy = computed(
|
||||
() =>
|
||||
loadError.value ||
|
||||
{
|
||||
loading: "请稍候,正在读取家谱概览。",
|
||||
empty: "请从“我的家谱”选择一部家谱后再进入。",
|
||||
error: "可能是网络波动或家谱不存在。",
|
||||
"no-permission": "这部家谱未向当前账号开放,请返回我的家谱或联系管理员。",
|
||||
}[overviewState.value] ||
|
||||
"",
|
||||
);
|
||||
|
||||
const loadGenealogy = (query = {}) => {
|
||||
overviewState.value = 'loading'
|
||||
loadError.value = ''
|
||||
genealogy.value = null
|
||||
genealogyId.value = query.genealogyId || genealogyId.value || ''
|
||||
viewMode.value = query.mode === 'public' ? 'public' : 'member'
|
||||
accessRole.value = query.role === 'member' ? 'member' : 'owner'
|
||||
overviewState.value = "loading";
|
||||
loadError.value = "";
|
||||
genealogy.value = null;
|
||||
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'
|
||||
return
|
||||
if (query.state === "empty" || !genealogyId.value) {
|
||||
overviewState.value = "empty";
|
||||
return;
|
||||
}
|
||||
if (query.state === 'error') {
|
||||
overviewState.value = 'error'
|
||||
return
|
||||
if (query.state === "error") {
|
||||
overviewState.value = "error";
|
||||
return;
|
||||
}
|
||||
if (query.state === 'no-permission') {
|
||||
overviewState.value = 'no-permission'
|
||||
return
|
||||
if (query.state === "no-permission") {
|
||||
overviewState.value = "no-permission";
|
||||
return;
|
||||
}
|
||||
if (query.state === 'loading') return
|
||||
if (query.state === "loading") return;
|
||||
|
||||
genealogy.value = { ...overviewFixture, id: genealogyId.value }
|
||||
overviewState.value = 'ready'
|
||||
}
|
||||
genealogy.value = { ...overviewFixture, id: genealogyId.value };
|
||||
overviewState.value = "ready";
|
||||
};
|
||||
|
||||
onLoad(loadGenealogy)
|
||||
const reloadOverview = () => loadGenealogy({ genealogyId: genealogyId.value })
|
||||
const toGenealogies = () => uni.reLaunch({ url: '/pages/genealogy/g01-my-genealogies' })
|
||||
const toTree = () => uni.navigateTo({ url: `/pages/tree/t01-tree-overview?genealogyId=${genealogyId.value}` })
|
||||
const toFirstPerson = () => uni.navigateTo({ url: `/pages/genealogy/g03-create-genealogy?step=ancestor&genealogyId=${genealogyId.value}` })
|
||||
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}` })
|
||||
onLoad(loadGenealogy);
|
||||
const reloadOverview = () => loadGenealogy({ genealogyId: genealogyId.value });
|
||||
const toGenealogies = () =>
|
||||
uni.reLaunch({ url: "/pages/genealogy/g01-my-genealogies" });
|
||||
const toTree = () =>
|
||||
uni.navigateTo({
|
||||
url: `/pages/tree/t01-tree-overview?genealogyId=${genealogyId.value}`,
|
||||
});
|
||||
const toFirstPerson = () =>
|
||||
uni.navigateTo({
|
||||
url: `/pages/genealogy/g03-create-genealogy?step=ancestor&genealogyId=${genealogyId.value}`,
|
||||
});
|
||||
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-x: hidden; background: $paper; }
|
||||
.overview-page__header { position: relative; z-index: 3; }
|
||||
.overview-surface { position: relative; z-index: 2; width: 100%; height: min(483px, calc(100vw * 1.3422)); }
|
||||
.overview-surface__skin { position: absolute; inset: 0; width: 100%; height: 100%; }
|
||||
.overview-ready { position: absolute; inset: 0; }
|
||||
.overview-hero { position: absolute; top: 6.5%; right: 8%; left: 8%; color: #fff8ec; }
|
||||
.overview-hero__kicker { display: block; color: #dec483; font-size: 22rpx; letter-spacing: 3rpx; }
|
||||
.overview-hero__title { display: block; margin-top: 12rpx; font-family: 'STKaiti', 'KaiTi', serif; font-size: 46rpx; font-weight: 700; letter-spacing: 4rpx; }
|
||||
.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-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; }
|
||||
.overview-note { position: absolute; top: 80.5%; right: 8.5%; left: 8.5%; display: flex; flex-direction: column; justify-content: center; height: 15%; color: $ink-muted; font-size: 22rpx; line-height: 1.6; }
|
||||
.overview-note__title { margin-bottom: 8rpx; color: $brand-red; font-family: 'STKaiti', 'KaiTi', serif; font-size: 28rpx; font-weight: 700; }
|
||||
.overview-state-panel { position: absolute; top: 29%; right: 8%; left: 8%; display: flex; flex-direction: column; align-items: center; justify-content: center; height: 48%; padding: 0 40rpx; box-sizing: border-box; text-align: center; }
|
||||
.overview-state-panel__title { color: $ink; font-family: 'STKaiti', 'KaiTi', serif; font-size: 34rpx; font-weight: 700; }
|
||||
.overview-state-panel__copy { max-width: 480rpx; margin-top: 18rpx; color: $ink-muted; font-size: 23rpx; line-height: 1.7; }
|
||||
.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; }
|
||||
.overview-page {
|
||||
position: relative;
|
||||
min-height: 100vh;
|
||||
overflow-x: hidden;
|
||||
background: $paper;
|
||||
}
|
||||
.overview-page__header {
|
||||
position: relative;
|
||||
z-index: 3;
|
||||
}
|
||||
.overview-surface {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
width: 100%;
|
||||
height: min(483px, calc(100vw * 1.3422));
|
||||
}
|
||||
.overview-surface__skin {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
.overview-surface--state {
|
||||
height: calc(100vh - 112rpx);
|
||||
min-height: 1040rpx;
|
||||
}
|
||||
.overview-surface--state .overview-surface__skin {
|
||||
display: none;
|
||||
}
|
||||
.overview-ready {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
}
|
||||
.overview-hero {
|
||||
position: absolute;
|
||||
top: 6.5%;
|
||||
right: 8%;
|
||||
left: 8%;
|
||||
color: #fff8ec;
|
||||
}
|
||||
.overview-hero__kicker {
|
||||
display: block;
|
||||
color: #dec483;
|
||||
font-size: 22rpx;
|
||||
letter-spacing: 3rpx;
|
||||
}
|
||||
.overview-hero__title {
|
||||
display: block;
|
||||
margin-top: 12rpx;
|
||||
font-family: "STKaiti", "KaiTi", serif;
|
||||
font-size: 46rpx;
|
||||
font-weight: 700;
|
||||
letter-spacing: 4rpx;
|
||||
}
|
||||
.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 1fr;
|
||||
height: 36.2%;
|
||||
}
|
||||
.overview-action,
|
||||
.overview-action-lock {
|
||||
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-lock {
|
||||
opacity: 0.58;
|
||||
}
|
||||
.overview-action__title {
|
||||
margin-bottom: 9rpx;
|
||||
color: $ink;
|
||||
font-family: "STKaiti", "KaiTi", serif;
|
||||
font-size: 30rpx;
|
||||
font-weight: 700;
|
||||
}
|
||||
.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;
|
||||
}
|
||||
.overview-note {
|
||||
position: absolute;
|
||||
top: 80.5%;
|
||||
right: 8.5%;
|
||||
left: 8.5%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
height: 15%;
|
||||
color: $ink-muted;
|
||||
font-size: 22rpx;
|
||||
line-height: 1.6;
|
||||
}
|
||||
.overview-note__title {
|
||||
margin-bottom: 8rpx;
|
||||
color: $brand-red;
|
||||
font-family: "STKaiti", "KaiTi", serif;
|
||||
font-size: 28rpx;
|
||||
font-weight: 700;
|
||||
}
|
||||
.overview-state {
|
||||
position: absolute;
|
||||
top: 28rpx;
|
||||
right: 28rpx;
|
||||
bottom: 28rpx;
|
||||
left: 28rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
}
|
||||
.overview-state__frame {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
pointer-events: none;
|
||||
}
|
||||
.overview-state__content {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-sizing: border-box;
|
||||
padding: 48rpx 34rpx;
|
||||
}
|
||||
.overview-state--loading .app-loading {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
.overview-state__seal {
|
||||
width: 132rpx;
|
||||
height: 136rpx;
|
||||
}
|
||||
.overview-state-panel__title {
|
||||
margin-top: 28rpx;
|
||||
color: $ink;
|
||||
font-family: "STKaiti", "KaiTi", serif;
|
||||
font-size: 42rpx;
|
||||
font-weight: 700;
|
||||
}
|
||||
.overview-state-panel__copy {
|
||||
max-width: 520rpx;
|
||||
margin-top: 18rpx;
|
||||
color: $ink-muted;
|
||||
font-size: 27rpx;
|
||||
line-height: 1.7;
|
||||
}
|
||||
.overview-state-panel__action {
|
||||
position: relative;
|
||||
width: 560rpx;
|
||||
height: 124rpx;
|
||||
margin-top: 34rpx;
|
||||
}
|
||||
.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: 31rpx;
|
||||
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 > view:nth-child(5) {
|
||||
position: absolute;
|
||||
top: 250rpx;
|
||||
right: 0;
|
||||
left: 0;
|
||||
grid-column: 1 / -1;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
box-sizing: border-box;
|
||||
padding: 0 24rpx;
|
||||
}
|
||||
.overview-public__details > view:nth-child(5) text:first-child {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
.overview-public__details > view:nth-child(5) text:last-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
.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: 78%;
|
||||
right: 9%;
|
||||
left: 9%;
|
||||
display: flex;
|
||||
color: $ink-muted;
|
||||
font-size: 19rpx;
|
||||
line-height: 26rpx;
|
||||
}
|
||||
.overview-public__notice text:first-child {
|
||||
flex: 0 0 auto;
|
||||
margin-right: 12rpx;
|
||||
color: $brand-red;
|
||||
font-weight: 700;
|
||||
}
|
||||
.overview-public__action {
|
||||
position: absolute;
|
||||
right: 9%;
|
||||
bottom: 2%;
|
||||
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; }
|
||||
.overview-hero {
|
||||
top: 7%;
|
||||
}
|
||||
.overview-action {
|
||||
padding: 0 28rpx;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -2,23 +2,39 @@
|
||||
<template>
|
||||
<view class="review-page">
|
||||
<GenealogyPageBackground />
|
||||
<view class="review-page__header"><PageHeader title="入谱审核" action="说明" @action="showHelp" /></view>
|
||||
<view class="review-page__header"
|
||||
><PageHeader title="入谱审核" action="说明" @action="showHelp"
|
||||
/></view>
|
||||
|
||||
<view class="review-content" :class="{
|
||||
'review-state--list': reviewState === 'list',
|
||||
'review-state--empty': reviewState === 'empty',
|
||||
'review-state--error': reviewState === 'error',
|
||||
'review-state--no-permission': reviewState === 'no-permission'
|
||||
}">
|
||||
<view class="review-intro">
|
||||
<view
|
||||
class="review-content"
|
||||
:class="{
|
||||
'review-state--list': reviewState === 'list',
|
||||
'review-state--empty': reviewState === 'empty',
|
||||
'review-state--error': reviewState === 'error',
|
||||
'review-state--no-permission': reviewState === 'no-permission',
|
||||
}"
|
||||
>
|
||||
<view v-if="reviewState !== 'loading'" class="review-intro">
|
||||
<text>核实亲属关系后再决定</text>
|
||||
<text>审核结果会通过消息告知申请人</text>
|
||||
<image src="/static/assets/modules/genealogy/transparent/section-divider.png" mode="scaleToFill" />
|
||||
<image
|
||||
src="/static/assets/modules/genealogy/transparent/section-divider.png"
|
||||
mode="scaleToFill"
|
||||
/>
|
||||
</view>
|
||||
|
||||
<template v-if="reviewState === 'list'">
|
||||
<view v-for="item in applications" :key="item.id" class="application-card">
|
||||
<image class="application-card__skin" src="/static/assets/modules/genealogy/opaque/application-record-card.png" mode="scaleToFill" />
|
||||
<view
|
||||
v-for="item in applications"
|
||||
:key="item.id"
|
||||
class="application-card"
|
||||
>
|
||||
<image
|
||||
class="application-card__skin"
|
||||
src="/static/assets/modules/genealogy/transparent/list-slip-frame.png"
|
||||
mode="scaleToFill"
|
||||
/>
|
||||
<view class="application-card__body">
|
||||
<text class="application-card__name">{{ item.name }}</text>
|
||||
<text class="application-card__phone">{{ item.phone }}</text>
|
||||
@@ -26,34 +42,93 @@
|
||||
<text class="application-card__relation">{{ item.relation }}</text>
|
||||
<view v-if="item.status === 'PENDING'" class="review-actions">
|
||||
<view class="review-action" @click="confirmAudit(item, false)">
|
||||
<image src="/static/assets/foundation/transparent/a01-scroll-secondary-v3.png" mode="aspectFit" /><text>拒绝</text>
|
||||
<image
|
||||
src="/static/assets/foundation/transparent/a01-secondary-button-v2.png"
|
||||
mode="aspectFit"
|
||||
/><text>拒绝申请</text>
|
||||
</view>
|
||||
<view class="review-action" @click="confirmAudit(item, true)">
|
||||
<image src="/static/assets/foundation/transparent/a01-scroll-primary-v3.png" mode="aspectFit" /><text>通过</text>
|
||||
<image
|
||||
src="/static/assets/foundation/transparent/a01-primary-button-v2.png"
|
||||
mode="aspectFit"
|
||||
/><text>通过申请</text>
|
||||
</view>
|
||||
</view>
|
||||
<text v-else class="application-card__status">{{ item.status === 'APPROVED' ? '已通过' : '已拒绝' }}</text>
|
||||
<view
|
||||
v-else
|
||||
class="review-result"
|
||||
:class="
|
||||
item.status === 'APPROVED'
|
||||
? 'review-result--approved'
|
||||
: 'review-result--rejected'
|
||||
"
|
||||
>
|
||||
<image
|
||||
:src="
|
||||
item.status === 'APPROVED'
|
||||
? '/static/assets/foundation/transparent/a01-scroll-primary-v3.png'
|
||||
: '/static/assets/foundation/transparent/a01-scroll-secondary-v3.png'
|
||||
"
|
||||
mode="aspectFit"
|
||||
/>
|
||||
<text class="application-card__status">{{
|
||||
item.status === "APPROVED" ? "已通过" : "已拒绝"
|
||||
}}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<AppLoading
|
||||
v-else-if="reviewState === 'loading'"
|
||||
text="正在整理审核申请"
|
||||
description="请稍候,正在读取待审核记录。"
|
||||
/>
|
||||
<view v-else class="review-state-card">
|
||||
<image src="/static/assets/modules/genealogy/opaque/application-status-card.png" mode="scaleToFill" />
|
||||
<image
|
||||
src="/static/assets/modules/genealogy/transparent/list-slip-frame.png"
|
||||
mode="scaleToFill"
|
||||
/>
|
||||
<view class="review-state-card__copy">
|
||||
<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/transparent/a01-scroll-primary-v3.png" mode="aspectFit" /><text>重新查看</text>
|
||||
<view
|
||||
v-if="reviewState === 'error'"
|
||||
class="review-page__retry"
|
||||
@click="loadApplications({ genealogyId })"
|
||||
>
|
||||
<image
|
||||
src="/static/assets/foundation/transparent/a01-scroll-primary-v3.png"
|
||||
mode="aspectFit"
|
||||
/><text>重新查看</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<AppDialog
|
||||
:visible="!!confirmation || helpVisible"
|
||||
:title="helpVisible ? '审核说明' : confirmation?.approved ? '确认通过申请?' : '确认拒绝申请?'"
|
||||
:message="helpVisible ? '请核对申请人的姓名、亲属关系和补充说明,仅确认与本家谱存在真实关系的申请。' : confirmation?.approved ? '通过后,申请人将成为本家谱成员。' : '拒绝后,申请人会收到审核结果,并可修改后重新申请。'"
|
||||
:confirm-text="helpVisible ? '我知道了' : confirmation?.approved ? '确认通过' : '确认拒绝'"
|
||||
:title="
|
||||
helpVisible
|
||||
? '审核说明'
|
||||
: confirmation?.approved
|
||||
? '确认通过申请?'
|
||||
: '确认拒绝申请?'
|
||||
"
|
||||
:message="
|
||||
helpVisible
|
||||
? '请核对申请人的姓名、亲属关系和补充说明,仅确认与本家谱存在真实关系的申请。'
|
||||
: confirmation?.approved
|
||||
? '通过后,申请人将成为本家谱成员。'
|
||||
: '拒绝后,申请人会收到审核结果,并可修改后重新申请。'
|
||||
"
|
||||
:confirm-text="
|
||||
helpVisible
|
||||
? '我知道了'
|
||||
: confirmation?.approved
|
||||
? '确认通过'
|
||||
: '确认拒绝'
|
||||
"
|
||||
:show-cancel="!!confirmation"
|
||||
@confirm="helpVisible ? closeDialog() : applyAudit()"
|
||||
@cancel="closeDialog"
|
||||
@@ -61,106 +136,362 @@
|
||||
/>
|
||||
|
||||
<view v-if="feedbackVisible" class="review-feedback">
|
||||
<image src="/static/assets/foundation/transparent/a01-scroll-toast-v3.png" mode="scaleToFill" />
|
||||
<image
|
||||
src="/static/assets/foundation/transparent/a01-scroll-toast-v3.png"
|
||||
mode="scaleToFill"
|
||||
/>
|
||||
<text>{{ feedbackMessage }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue'
|
||||
import { onLoad, onUnload } from '@dcloudio/uni-app'
|
||||
import AppDialog from '@/components/AppDialog.vue'
|
||||
import GenealogyPageBackground from '@/components/GenealogyPageBackground.vue'
|
||||
import PageHeader from '@/components/PageHeader.vue'
|
||||
import { genealogyContext } from '@/utils/genealogy-context.js'
|
||||
import { computed, ref } from "vue";
|
||||
import { onLoad, onUnload } from "@dcloudio/uni-app";
|
||||
import AppDialog from "@/components/AppDialog.vue";
|
||||
import AppLoading from "@/components/AppLoading.vue";
|
||||
import GenealogyPageBackground from "@/components/GenealogyPageBackground.vue";
|
||||
import PageHeader from "@/components/PageHeader.vue";
|
||||
import { genealogyContext } from "@/utils/genealogy-context.js";
|
||||
|
||||
const applications = ref([])
|
||||
const applications = ref([]);
|
||||
const reviewSamples = [
|
||||
{ id: 1, name: '汤志成', phone: '139****6421', relation: '自述为汤正华堂侄 · 祖居洛阳', appliedAt: '今天 10:24', status: 'PENDING' },
|
||||
{ id: 2, name: '汤雨薇', phone: '136****2798', relation: '自述为汤正国之女 · 已补充长辈姓名', appliedAt: '昨天 18:02', status: 'PENDING' }
|
||||
]
|
||||
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 || '请检查网络或家谱权限后重试。')
|
||||
{
|
||||
id: 1,
|
||||
name: "汤志成",
|
||||
phone: "139****6421",
|
||||
relation: "自述为汤正华堂侄 · 祖居洛阳",
|
||||
appliedAt: "今天 10:24",
|
||||
status: "PENDING",
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: "汤雨薇",
|
||||
phone: "136****2798",
|
||||
relation: "自述为汤正国之女 · 已补充长辈姓名",
|
||||
appliedAt: "昨天 18:02",
|
||||
status: "PENDING",
|
||||
},
|
||||
];
|
||||
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'
|
||||
errorMessage.value = ''
|
||||
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'
|
||||
return
|
||||
reviewState.value = "loading";
|
||||
errorMessage.value = "";
|
||||
genealogyId.value =
|
||||
query.genealogyId?.value ||
|
||||
query.genealogyId ||
|
||||
genealogyId.value ||
|
||||
genealogyContext.getCurrentGenealogyId();
|
||||
if (query.state === "loading") {
|
||||
reviewState.value = "loading";
|
||||
return;
|
||||
}
|
||||
genealogyContext.setCurrentGenealogyId(genealogyId.value)
|
||||
applications.value = reviewSamples.map((item) => ({ ...item }))
|
||||
reviewState.value = 'list'
|
||||
}
|
||||
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";
|
||||
return;
|
||||
}
|
||||
genealogyContext.setCurrentGenealogyId(genealogyId.value);
|
||||
applications.value = reviewSamples.map((item) => ({ ...item }));
|
||||
reviewState.value = "list";
|
||||
};
|
||||
|
||||
onLoad(loadApplications)
|
||||
onLoad(loadApplications);
|
||||
const confirmAudit = (item, approved) => {
|
||||
confirmation.value = { item, approved }
|
||||
}
|
||||
const closeDialog = () => { confirmation.value = null; helpVisible.value = false }
|
||||
confirmation.value = { item, approved };
|
||||
};
|
||||
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)
|
||||
}
|
||||
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 }
|
||||
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-x: hidden; background: $paper; }
|
||||
.review-page__header { position: relative; z-index: 3; }
|
||||
.review-content { position: relative; z-index: 2; padding: 24rpx 24rpx 60rpx; }
|
||||
.review-intro { position: relative; margin: 0 8rpx 24rpx; padding-bottom: 22rpx; }
|
||||
.review-intro text { display: block; }
|
||||
.review-intro text:first-child { color: $ink; font-family: 'STKaiti', 'KaiTi', serif; font-size: 29rpx; font-weight: 700; }
|
||||
.review-intro text:nth-child(2) { margin-top: 8rpx; color: $ink-muted; font-size: 21rpx; }
|
||||
.review-intro image { position: absolute; right: 0; bottom: 0; left: 0; width: 100%; height: 14rpx; }
|
||||
.application-card { position: relative; width: 100%; height: calc((100vw - 24px) * .34286); min-height: 228rpx; max-height: 282rpx; margin-bottom: 18rpx; }
|
||||
.application-card__skin { position: absolute; inset: 0; width: 100%; height: 100%; }
|
||||
.application-card__body { position: absolute; inset: 18% 7% 12% 8.5%; }
|
||||
.application-card__name { color: $ink; font-family: 'STKaiti', 'KaiTi', serif; font-size: 30rpx; font-weight: 700; }
|
||||
.application-card__phone { margin-left: 14rpx; color: $ink-muted; font-size: 20rpx; }
|
||||
.application-card__time { position: absolute; top: 3rpx; right: 0; color: #998873; font-size: 20rpx; }
|
||||
.application-card__relation { display: block; max-width: 58%; margin-top: 15rpx; color: $ink-muted; font-size: 22rpx; }
|
||||
.review-actions { position: absolute; right: 0; bottom: 0; display: flex; gap: 12rpx; }
|
||||
.review-action { position: relative; width: 128rpx; height: 60rpx; }
|
||||
.review-action image { position: absolute; inset: 0; width: 100%; height: 100%; }
|
||||
.review-action text { position: relative; z-index: 1; display: flex; align-items: center; justify-content: center; height: 100%; color: $ink; font-size: 22rpx; font-weight: 700; }
|
||||
.review-action:last-child text { color: #fff9ed; }
|
||||
.application-card__status { position: absolute; right: 3%; bottom: 10%; color: $brand-red; font-size: 24rpx; font-weight: 700; }
|
||||
.review-state-card { position: relative; width: 100%; height: calc((100vw - 24px) * .34286); min-height: 228rpx; margin-top: 70rpx; }
|
||||
.review-state-card > image { position: absolute; inset: 0; width: 100%; height: 100%; }
|
||||
.review-state-card__copy { position: absolute; inset: 22% 12%; display: flex; flex-direction: column; justify-content: center; text-align: center; }
|
||||
.review-state-card__copy text:first-child { color: $ink; font-family: 'STKaiti', 'KaiTi', serif; font-size: 31rpx; font-weight: 700; }
|
||||
.review-state-card__copy text:last-child { margin-top: 15rpx; color: $ink-muted; font-size: 22rpx; line-height: 1.6; }
|
||||
.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-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; }
|
||||
.review-page {
|
||||
position: relative;
|
||||
min-height: 100vh;
|
||||
overflow-x: hidden;
|
||||
background: $paper;
|
||||
}
|
||||
.review-page__header {
|
||||
position: relative;
|
||||
z-index: 3;
|
||||
}
|
||||
.review-content {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
padding: 24rpx 24rpx 60rpx;
|
||||
}
|
||||
.review-intro {
|
||||
position: relative;
|
||||
margin: 0 8rpx 24rpx;
|
||||
padding-bottom: 22rpx;
|
||||
}
|
||||
.review-intro text {
|
||||
display: block;
|
||||
}
|
||||
.review-intro text:first-child {
|
||||
color: $ink;
|
||||
font-family: "STKaiti", "KaiTi", serif;
|
||||
font-size: 29rpx;
|
||||
font-weight: 700;
|
||||
}
|
||||
.review-intro text:nth-child(2) {
|
||||
margin-top: 8rpx;
|
||||
color: $ink-muted;
|
||||
font-size: 24rpx;
|
||||
}
|
||||
.review-intro image {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 14rpx;
|
||||
}
|
||||
.application-card {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: calc((100vw - 24px) * 0.34286);
|
||||
min-height: 228rpx;
|
||||
max-height: 282rpx;
|
||||
margin-bottom: 18rpx;
|
||||
}
|
||||
.application-card__skin {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
.application-card__body {
|
||||
position: absolute;
|
||||
inset: 18% 7% 12% 8.5%;
|
||||
}
|
||||
.application-card__name {
|
||||
color: $ink;
|
||||
font-family: "STKaiti", "KaiTi", serif;
|
||||
font-size: 30rpx;
|
||||
font-weight: 700;
|
||||
}
|
||||
.application-card__phone {
|
||||
margin-left: 14rpx;
|
||||
color: $ink-muted;
|
||||
font-size: 23rpx;
|
||||
}
|
||||
.application-card__time {
|
||||
position: absolute;
|
||||
top: 3rpx;
|
||||
right: 0;
|
||||
color: #998873;
|
||||
font-size: 23rpx;
|
||||
}
|
||||
.application-card__relation {
|
||||
display: block;
|
||||
max-width: 52%;
|
||||
margin-top: 15rpx;
|
||||
color: $ink-muted;
|
||||
font-size: 24rpx;
|
||||
line-height: 1.35;
|
||||
}
|
||||
.review-actions {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
display: flex;
|
||||
gap: 12rpx;
|
||||
}
|
||||
.review-action {
|
||||
position: relative;
|
||||
width: 148rpx;
|
||||
height: 68rpx;
|
||||
}
|
||||
.review-action image {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
.review-action text {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
color: $ink;
|
||||
font-family: "Microsoft YaHei", sans-serif;
|
||||
font-size: 24rpx;
|
||||
font-weight: 700;
|
||||
letter-spacing: 1rpx;
|
||||
}
|
||||
.review-action:last-child text {
|
||||
color: #fff9ed;
|
||||
}
|
||||
.review-result {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
width: 308rpx;
|
||||
height: 68rpx;
|
||||
}
|
||||
.review-result image {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
.application-card__status {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
color: $brand-red;
|
||||
font-size: 24rpx;
|
||||
font-weight: 700;
|
||||
}
|
||||
.review-result--approved .application-card__status {
|
||||
color: #fff9ed;
|
||||
}
|
||||
.review-result--rejected .application-card__status {
|
||||
color: $ink;
|
||||
}
|
||||
.review-state-card {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: calc((100vw - 24px) * 0.34286);
|
||||
min-height: 228rpx;
|
||||
margin-top: 70rpx;
|
||||
}
|
||||
.review-state-card > image {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
.review-state-card__copy {
|
||||
position: absolute;
|
||||
inset: 22% 12%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
}
|
||||
.review-state-card__copy text:first-child {
|
||||
color: $ink;
|
||||
font-family: "STKaiti", "KaiTi", serif;
|
||||
font-size: 31rpx;
|
||||
font-weight: 700;
|
||||
}
|
||||
.review-state-card__copy text:last-child {
|
||||
margin-top: 15rpx;
|
||||
color: $ink-muted;
|
||||
font-size: 24rpx;
|
||||
line-height: 1.6;
|
||||
}
|
||||
.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-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>
|
||||
|
||||
@@ -4,142 +4,433 @@
|
||||
<GenealogyPageBackground />
|
||||
<view class="settings-page__header"><PageHeader title="家谱设置" /></view>
|
||||
|
||||
<view class="settings-panel" :class="{
|
||||
'settings-state--form': settingsState === 'form',
|
||||
'settings-state--success': settingsState === 'success',
|
||||
'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" />
|
||||
<view
|
||||
class="settings-panel"
|
||||
:class="{
|
||||
'settings-state--form': settingsState === 'form',
|
||||
'settings-state--success': settingsState === 'success',
|
||||
'settings-state--error': settingsState === 'error',
|
||||
'settings-state--no-permission': settingsState === 'no-permission',
|
||||
}"
|
||||
>
|
||||
<image
|
||||
class="settings-panel__skin"
|
||||
src="/static/assets/modules/genealogy/transparent/g01-empty-panel-frame.png"
|
||||
mode="scaleToFill"
|
||||
/>
|
||||
|
||||
<view v-if="settingsState === 'form'" class="settings-form">
|
||||
<AppLoading
|
||||
v-if="settingsState === 'loading'"
|
||||
text="正在读取家谱设置"
|
||||
description="请稍候,正在准备名称与访问规则。"
|
||||
/>
|
||||
<view v-else-if="settingsState === 'form'" class="settings-form">
|
||||
<text class="settings-form__eyebrow">谱主可见 · 基础设置</text>
|
||||
<text class="settings-form__title">完善家谱访问规则</text>
|
||||
<text class="settings-form__copy">这里仅安排接口支持的名称、公开范围与访问说明;管理权转让将在成员选择场景中单独处理。</text>
|
||||
<text class="settings-form__copy"
|
||||
>这里仅安排接口支持的名称、公开范围与访问说明;管理权转让将在成员选择场景中单独处理。</text
|
||||
>
|
||||
|
||||
<view class="settings-field">
|
||||
<image src="/static/assets/modules/genealogy/opaque/g06-search-input-wide.png" mode="scaleToFill" />
|
||||
<image
|
||||
src="/static/assets/modules/genealogy/transparent/g-form-field-frame.png"
|
||||
mode="scaleToFill"
|
||||
/>
|
||||
<text>家谱名称</text>
|
||||
<input v-model="genealogyDraft.name" maxlength="20" placeholder="请输入家谱名称" placeholder-class="settings-placeholder" @input="nameError = ''" />
|
||||
<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>
|
||||
<text v-if="nameError" class="settings-field-error">{{
|
||||
nameError
|
||||
}}</text>
|
||||
|
||||
<view class="visibility-block">
|
||||
<text class="visibility-block__label">公开范围</text>
|
||||
<view class="visibility-options">
|
||||
<view v-for="option in visibilityOptions" :key="option.value" class="visibility-option" @click="genealogyDraft.visibility = option.value">
|
||||
<image :src="genealogyDraft.visibility === option.value ? '/static/assets/foundation/transparent/a01-scroll-primary-v3.png' : '/static/assets/foundation/transparent/a01-scroll-secondary-v3.png'" mode="aspectFit" />
|
||||
<text :class="{ 'visibility-option__text--active': genealogyDraft.visibility === option.value }">{{ option.label }}</text>
|
||||
<view
|
||||
v-for="option in visibilityOptions"
|
||||
:key="option.value"
|
||||
class="visibility-option"
|
||||
@click="genealogyDraft.visibility = option.value"
|
||||
>
|
||||
<image
|
||||
:src="
|
||||
genealogyDraft.visibility === option.value
|
||||
? '/static/assets/foundation/transparent/a01-scroll-primary-v3.png'
|
||||
: '/static/assets/foundation/transparent/a01-scroll-secondary-v3.png'
|
||||
"
|
||||
mode="aspectFit"
|
||||
/>
|
||||
<text
|
||||
:class="{
|
||||
'visibility-option__text--active':
|
||||
genealogyDraft.visibility === option.value,
|
||||
}"
|
||||
>{{ option.label }}</text
|
||||
>
|
||||
</view>
|
||||
</view>
|
||||
<text class="visibility-block__hint">{{ visibilityHint }}</text>
|
||||
</view>
|
||||
|
||||
<view class="settings-field settings-field--note">
|
||||
<image src="/static/assets/modules/genealogy/opaque/g06-search-input-wide.png" mode="scaleToFill" />
|
||||
<image
|
||||
src="/static/assets/modules/genealogy/transparent/g-form-field-frame.png"
|
||||
mode="scaleToFill"
|
||||
/>
|
||||
<text>访问说明</text>
|
||||
<textarea v-model="genealogyDraft.accessNote" maxlength="80" placeholder="向访问者说明家谱用途" placeholder-class="settings-placeholder" />
|
||||
<textarea
|
||||
v-model="genealogyDraft.accessNote"
|
||||
maxlength="80"
|
||||
placeholder="向访问者说明家谱用途"
|
||||
placeholder-class="settings-placeholder"
|
||||
/>
|
||||
</view>
|
||||
|
||||
<view class="settings-action" @click="saveSettings">
|
||||
<image src="/static/assets/foundation/transparent/a01-scroll-primary-v3.png" mode="aspectFit" />
|
||||
<image
|
||||
src="/static/assets/foundation/transparent/a01-scroll-primary-v3.png"
|
||||
mode="aspectFit"
|
||||
/>
|
||||
<text>保存设置</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view v-else class="settings-result">
|
||||
<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>
|
||||
<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/transparent/a01-scroll-primary-v3.png" mode="aspectFit" />
|
||||
<text>{{ settingsState === 'success' ? '继续调整' : '重新查看' }}</text>
|
||||
<image
|
||||
src="/static/assets/foundation/transparent/a01-scroll-primary-v3.png"
|
||||
mode="aspectFit"
|
||||
/>
|
||||
<text>{{
|
||||
settingsState === "success" ? "继续调整" : "重新查看"
|
||||
}}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<view v-if="feedbackVisible" class="settings-feedback">
|
||||
<image src="/static/assets/foundation/transparent/a01-scroll-secondary-v3.png" mode="aspectFit" />
|
||||
<image
|
||||
src="/static/assets/foundation/transparent/a01-scroll-secondary-v3.png"
|
||||
mode="aspectFit"
|
||||
/>
|
||||
<text>设置已保存</text>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, reactive, ref } from 'vue'
|
||||
import { onLoad, onUnload } from '@dcloudio/uni-app'
|
||||
import GenealogyPageBackground from '@/components/GenealogyPageBackground.vue'
|
||||
import PageHeader from '@/components/PageHeader.vue'
|
||||
import { computed, reactive, ref } from "vue";
|
||||
import { onLoad, onUnload } from "@dcloudio/uni-app";
|
||||
import AppLoading from "@/components/AppLoading.vue";
|
||||
import GenealogyPageBackground from "@/components/GenealogyPageBackground.vue";
|
||||
import PageHeader from "@/components/PageHeader.vue";
|
||||
|
||||
const genealogyId = ref('')
|
||||
const settingsState = ref('loading')
|
||||
const nameError = ref('')
|
||||
const feedbackVisible = ref(false)
|
||||
let feedbackTimer = null
|
||||
const genealogyId = ref("");
|
||||
const settingsState = ref("loading");
|
||||
const nameError = ref("");
|
||||
const feedbackVisible = ref(false);
|
||||
let feedbackTimer = null;
|
||||
const genealogyDraft = reactive({
|
||||
name: '汤氏家谱',
|
||||
visibility: 'MEMBER_ONLY',
|
||||
accessNote: '家族资料,请妥善保存'
|
||||
})
|
||||
name: "汤氏家谱",
|
||||
visibility: "MEMBER_ONLY",
|
||||
accessNote: "家族资料,请妥善保存",
|
||||
});
|
||||
const visibilityOptions = [
|
||||
{ value: 'MEMBER_ONLY', label: '仅成员可见' },
|
||||
{ value: 'PUBLIC_APPLY', label: '公开可申请' }
|
||||
]
|
||||
const visibilityLabel = computed(() => visibilityOptions.find((item) => item.value === genealogyDraft.visibility)?.label || '')
|
||||
const visibilityHint = computed(() => genealogyDraft.visibility === 'MEMBER_ONLY'
|
||||
? '只有已加入本家谱的成员可以查看谱系和家族资料。'
|
||||
: '访客可检索到家谱并提交入谱申请,资料仍需审核后查看。')
|
||||
{ value: "MEMBER_ONLY", label: "仅成员可见" },
|
||||
{ value: "PUBLIC_APPLY", label: "公开可申请" },
|
||||
];
|
||||
const visibilityLabel = computed(
|
||||
() =>
|
||||
visibilityOptions.find((item) => item.value === genealogyDraft.visibility)
|
||||
?.label || "",
|
||||
);
|
||||
const visibilityHint = computed(() =>
|
||||
genealogyDraft.visibility === "MEMBER_ONLY"
|
||||
? "只有已加入本家谱的成员可以查看谱系和家族资料。"
|
||||
: "访客可检索到家谱并提交入谱申请,资料仍需审核后查看。",
|
||||
);
|
||||
|
||||
onLoad((query) => {
|
||||
genealogyId.value = query.genealogyId || ''
|
||||
settingsState.value = query.state === 'success' ? 'success' : query.state === 'no-permission' ? 'no-permission' : query.state === 'error' || !genealogyId.value ? 'error' : 'form'
|
||||
})
|
||||
onUnload(() => { if (feedbackTimer) clearTimeout(feedbackTimer) })
|
||||
genealogyId.value = query.genealogyId || "";
|
||||
settingsState.value =
|
||||
query.state === "loading"
|
||||
? "loading"
|
||||
: 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()) {
|
||||
nameError.value = '请填写家谱名称'
|
||||
return
|
||||
nameError.value = "请填写家谱名称";
|
||||
return;
|
||||
}
|
||||
settingsState.value = 'success'
|
||||
feedbackVisible.value = true
|
||||
feedbackTimer = setTimeout(() => { feedbackVisible.value = false; feedbackTimer = null }, 1800)
|
||||
}
|
||||
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-x: hidden; background: $paper; }
|
||||
.settings-page__header { position: relative; z-index: 3; }
|
||||
.settings-panel { position: relative; z-index: 2; width: calc(100% - 32rpx); height: min(620px, calc((100vw - 16px) * 1.43)); margin: 18rpx auto 0; }
|
||||
.settings-panel__skin { position: absolute; inset: 0; width: 100%; height: 100%; }
|
||||
.settings-form { position: absolute; inset: 7.5% 8%; }
|
||||
.settings-form__eyebrow, .settings-result__eyebrow { display: block; color: $brand-red; font-size: 23rpx; letter-spacing: 3rpx; }
|
||||
.settings-form__title, .settings-result__title { display: block; margin-top: 10rpx; color: $ink; font-family: 'STKaiti', 'KaiTi', serif; font-size: 35rpx; font-weight: 700; }
|
||||
.settings-form__copy, .settings-result__copy { display: block; margin-top: 10rpx; color: $ink-muted; font-size: 21rpx; line-height: 1.55; }
|
||||
.settings-field { position: relative; height: 92rpx; margin-top: 17rpx; }
|
||||
.settings-field > image { position: absolute; inset: 0; width: 100%; height: 100%; }
|
||||
.settings-field > text { position: absolute; top: 31rpx; left: 24rpx; z-index: 1; color: $ink; font-size: 22rpx; font-weight: 700; }
|
||||
.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; }
|
||||
.visibility-option { position: relative; width: calc(50% - 7rpx); height: 64rpx; }
|
||||
.visibility-option image { position: absolute; inset: 0; width: 100%; height: 100%; }
|
||||
.visibility-option text { position: relative; z-index: 1; display: flex; align-items: center; justify-content: center; height: 100%; color: $ink; font-size: 22rpx; font-weight: 700; }
|
||||
.visibility-option .visibility-option__text--active { color: #fff9ed; }
|
||||
.visibility-block__hint { display: block; margin-top: 9rpx; color: $ink-muted; font-size: 19rpx; line-height: 1.45; }
|
||||
.settings-field--note { margin-top: 14rpx; }
|
||||
.settings-action { position: relative; width: 100%; height: 78rpx; margin-top: 19rpx; }
|
||||
.settings-action image { position: absolute; inset: 0; width: 100%; height: 100%; }
|
||||
.settings-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; letter-spacing: 3rpx; }
|
||||
.settings-result { position: absolute; top: 29%; right: 12%; left: 12%; text-align: center; }
|
||||
.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%; } }
|
||||
.settings-page {
|
||||
position: relative;
|
||||
min-height: 100vh;
|
||||
overflow-x: hidden;
|
||||
background: $paper;
|
||||
}
|
||||
.settings-page__header {
|
||||
position: relative;
|
||||
z-index: 3;
|
||||
}
|
||||
.settings-panel {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
width: calc(100% - 32rpx);
|
||||
height: min(620px, calc((100vw - 16px) * 1.43));
|
||||
margin: 18rpx auto 0;
|
||||
}
|
||||
.settings-panel__skin {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
.settings-form {
|
||||
position: absolute;
|
||||
inset: 7.5% 8%;
|
||||
}
|
||||
.settings-form__eyebrow,
|
||||
.settings-result__eyebrow {
|
||||
display: block;
|
||||
color: $brand-red;
|
||||
font-size: 23rpx;
|
||||
letter-spacing: 3rpx;
|
||||
}
|
||||
.settings-form__title,
|
||||
.settings-result__title {
|
||||
display: block;
|
||||
margin-top: 10rpx;
|
||||
color: $ink;
|
||||
font-family: "STKaiti", "KaiTi", serif;
|
||||
font-size: 35rpx;
|
||||
font-weight: 700;
|
||||
}
|
||||
.settings-form__copy,
|
||||
.settings-result__copy {
|
||||
display: block;
|
||||
margin-top: 10rpx;
|
||||
color: $ink-muted;
|
||||
font-size: 24rpx;
|
||||
line-height: 1.55;
|
||||
}
|
||||
.settings-field {
|
||||
position: relative;
|
||||
height: 92rpx;
|
||||
margin-top: 17rpx;
|
||||
}
|
||||
.settings-field > image {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
.settings-field > text {
|
||||
position: absolute;
|
||||
top: 31rpx;
|
||||
left: 24rpx;
|
||||
z-index: 1;
|
||||
color: $ink;
|
||||
font-size: 24rpx;
|
||||
font-weight: 700;
|
||||
}
|
||||
.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: 24rpx;
|
||||
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: 24rpx;
|
||||
line-height: 34rpx;
|
||||
text-align: right;
|
||||
}
|
||||
.visibility-block {
|
||||
margin-top: 17rpx;
|
||||
}
|
||||
.visibility-block__label {
|
||||
display: block;
|
||||
color: $ink;
|
||||
font-size: 24rpx;
|
||||
font-weight: 700;
|
||||
}
|
||||
.visibility-options {
|
||||
display: flex;
|
||||
gap: 14rpx;
|
||||
margin-top: 10rpx;
|
||||
}
|
||||
.visibility-option {
|
||||
position: relative;
|
||||
width: calc(50% - 7rpx);
|
||||
height: 64rpx;
|
||||
}
|
||||
.visibility-option image {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
.visibility-option text {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
color: $ink;
|
||||
font-size: 24rpx;
|
||||
font-weight: 700;
|
||||
}
|
||||
.visibility-option .visibility-option__text--active {
|
||||
color: #fff9ed;
|
||||
}
|
||||
.visibility-block__hint {
|
||||
display: block;
|
||||
margin-top: 9rpx;
|
||||
color: $ink-muted;
|
||||
font-size: 22rpx;
|
||||
line-height: 1.45;
|
||||
}
|
||||
.settings-field--note {
|
||||
margin-top: 14rpx;
|
||||
}
|
||||
.settings-action {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 78rpx;
|
||||
margin-top: 19rpx;
|
||||
}
|
||||
.settings-action image {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
.settings-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;
|
||||
letter-spacing: 3rpx;
|
||||
}
|
||||
.settings-result {
|
||||
position: absolute;
|
||||
top: 29%;
|
||||
right: 12%;
|
||||
left: 12%;
|
||||
text-align: center;
|
||||
}
|
||||
.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>
|
||||
|
||||
@@ -2,164 +2,511 @@
|
||||
<template>
|
||||
<view class="poem-page">
|
||||
<GenealogyPageBackground />
|
||||
<view class="poem-page__header"><PageHeader title="字辈诗" :action="poemState === 'list' ? '维护' : ''" @action="openEditor" /></view>
|
||||
<view class="poem-page__header"
|
||||
><PageHeader
|
||||
title="字辈诗"
|
||||
:action="poemState === 'list' ? '维护' : ''"
|
||||
@action="openEditor"
|
||||
/></view>
|
||||
|
||||
<view class="poem-panel" :class="{
|
||||
'poem-state--list': poemState === 'list',
|
||||
'poem-state--empty': poemState === 'empty',
|
||||
'poem-state--edit': poemState === 'edit',
|
||||
'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" />
|
||||
<view
|
||||
class="poem-panel"
|
||||
:class="{
|
||||
'poem-state--list': poemState === 'list',
|
||||
'poem-state--empty': poemState === 'empty',
|
||||
'poem-state--edit': poemState === 'edit',
|
||||
'poem-state--error': poemState === 'error',
|
||||
'poem-state--no-permission': poemState === 'no-permission',
|
||||
}"
|
||||
>
|
||||
<image
|
||||
class="poem-panel__skin"
|
||||
src="/static/assets/modules/genealogy/transparent/g01-empty-panel-frame.png"
|
||||
mode="scaleToFill"
|
||||
/>
|
||||
|
||||
<view v-if="poemState === 'list'" class="poem-list">
|
||||
<AppLoading
|
||||
v-if="poemState === 'loading'"
|
||||
text="正在整理字辈诗"
|
||||
description="请稍候,正在读取家谱字序。"
|
||||
/>
|
||||
<view v-else-if="poemState === 'list'" class="poem-list">
|
||||
<text class="poem-list__eyebrow">汤氏家谱 · 传承字序</text>
|
||||
<text class="poem-list__title">启宗敦本,继世传芳</text>
|
||||
<text class="poem-list__copy">按世代查看字辈,当前家谱使用到“敦”字辈。</text>
|
||||
<text class="poem-list__copy"
|
||||
>按世代查看字辈,当前家谱使用到“敦”字辈。</text
|
||||
>
|
||||
<view class="poem-rows">
|
||||
<view v-for="item in poemRows" :key="item.generationNo" class="poem-row" :class="{ 'poem-row--current': item.current }">
|
||||
<image src="/static/assets/modules/genealogy/opaque/g06-search-input-wide.png" mode="scaleToFill" />
|
||||
<view
|
||||
v-for="item in poemRows"
|
||||
:key="item.generationNo"
|
||||
class="poem-row"
|
||||
:class="{ 'poem-row--current': item.current }"
|
||||
>
|
||||
<image
|
||||
src="/static/assets/modules/genealogy/transparent/g-form-field-frame.png"
|
||||
mode="scaleToFill"
|
||||
/>
|
||||
<text class="poem-row__number">第 {{ item.generationNo }} 世</text>
|
||||
<text class="poem-row__character">{{ item.character }}</text>
|
||||
<text class="poem-row__status">{{ item.current ? '当前字辈' : '传承字序' }}</text>
|
||||
<text class="poem-row__status">{{
|
||||
item.current ? "当前字辈" : "传承字序"
|
||||
}}</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="poem-action" @click="openEditor">
|
||||
<image src="/static/assets/foundation/transparent/a01-scroll-primary-v3.png" mode="aspectFit" /><text>维护字辈诗</text>
|
||||
<image
|
||||
src="/static/assets/foundation/transparent/a01-scroll-primary-v3.png"
|
||||
mode="aspectFit"
|
||||
/><text>维护字辈诗</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view v-else-if="poemState === 'edit'" class="poem-editor">
|
||||
<text class="poem-list__eyebrow">批量维护</text>
|
||||
<text class="poem-list__title">录入连续字辈</text>
|
||||
<text class="poem-list__copy">按照接口支持的连续文本录入,每个汉字对应一代;保存前可预览新字序。</text>
|
||||
<text class="poem-list__copy"
|
||||
>按照接口支持的连续文本录入,每个汉字对应一代;保存前可预览新字序。</text
|
||||
>
|
||||
<view class="poem-field">
|
||||
<image src="/static/assets/modules/genealogy/opaque/g06-search-input-wide.png" mode="scaleToFill" />
|
||||
<image
|
||||
src="/static/assets/modules/genealogy/transparent/g-form-field-frame.png"
|
||||
mode="scaleToFill"
|
||||
/>
|
||||
<text>字辈内容</text>
|
||||
<textarea v-model="poemDraft" maxlength="24" placeholder="例如:启宗敦本继世传芳" placeholder-class="poem-placeholder" @input="poemError = ''" />
|
||||
<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">
|
||||
<image :src="stopMissingOldGeneration ? '/static/assets/foundation/transparent/a01-scroll-primary-v3.png' : '/static/assets/foundation/transparent/a01-scroll-secondary-v3.png'" mode="aspectFit" />
|
||||
<text :class="{ 'poem-policy__option-text--active': stopMissingOldGeneration }">{{ stopMissingOldGeneration ? '停止并提醒' : '继续补录' }}</text>
|
||||
<view
|
||||
class="poem-policy__option"
|
||||
@click="stopMissingOldGeneration = !stopMissingOldGeneration"
|
||||
>
|
||||
<image
|
||||
:src="
|
||||
stopMissingOldGeneration
|
||||
? '/static/assets/foundation/transparent/a01-scroll-primary-v3.png'
|
||||
: '/static/assets/foundation/transparent/a01-scroll-secondary-v3.png'
|
||||
"
|
||||
mode="aspectFit"
|
||||
/>
|
||||
<text
|
||||
:class="{
|
||||
'poem-policy__option-text--active': stopMissingOldGeneration,
|
||||
}"
|
||||
>{{ stopMissingOldGeneration ? "停止并提醒" : "继续补录" }}</text
|
||||
>
|
||||
</view>
|
||||
</view>
|
||||
<text class="poem-preview">预览:{{ previewCharacters || '尚未录入字辈' }}</text>
|
||||
<text class="poem-preview"
|
||||
>预览:{{ previewCharacters || "尚未录入字辈" }}</text
|
||||
>
|
||||
<view class="poem-editor__actions">
|
||||
<view class="poem-action" @click="poemState = 'list'">
|
||||
<image src="/static/assets/foundation/transparent/a01-scroll-secondary-v3.png" mode="aspectFit" /><text class="poem-action__secondary">取消</text>
|
||||
<image
|
||||
src="/static/assets/foundation/transparent/a01-scroll-secondary-v3.png"
|
||||
mode="aspectFit"
|
||||
/><text class="poem-action__secondary">取消</text>
|
||||
</view>
|
||||
<view class="poem-action" @click="savePoems">
|
||||
<image src="/static/assets/foundation/transparent/a01-scroll-primary-v3.png" mode="aspectFit" /><text>保存字辈</text>
|
||||
<image
|
||||
src="/static/assets/foundation/transparent/a01-scroll-primary-v3.png"
|
||||
mode="aspectFit"
|
||||
/><text>保存字辈</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view v-else class="poem-state-card">
|
||||
<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/transparent/a01-scroll-primary-v3.png" mode="aspectFit" /><text>{{ 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/transparent/a01-scroll-primary-v3.png"
|
||||
mode="aspectFit"
|
||||
/><text>{{ poemState === "empty" ? "开始录入" : "重新查看" }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<view v-if="feedbackVisible" class="poem-feedback">
|
||||
<image src="/static/assets/foundation/transparent/a01-scroll-secondary-v3.png" mode="aspectFit" />
|
||||
<image
|
||||
src="/static/assets/foundation/transparent/a01-scroll-secondary-v3.png"
|
||||
mode="aspectFit"
|
||||
/>
|
||||
<text>字辈预览已更新</text>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue'
|
||||
import { onLoad, onUnload } from '@dcloudio/uni-app'
|
||||
import GenealogyPageBackground from '@/components/GenealogyPageBackground.vue'
|
||||
import PageHeader from '@/components/PageHeader.vue'
|
||||
import { computed, ref } from "vue";
|
||||
import { onLoad, onUnload } from "@dcloudio/uni-app";
|
||||
import AppLoading from "@/components/AppLoading.vue";
|
||||
import GenealogyPageBackground from "@/components/GenealogyPageBackground.vue";
|
||||
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 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([
|
||||
{ generationNo: 12, character: '启', current: false },
|
||||
{ generationNo: 13, character: '宗', current: false },
|
||||
{ generationNo: 14, character: '敦', current: true },
|
||||
{ generationNo: 15, character: '本', current: false }
|
||||
])
|
||||
const previewCharacters = computed(() => poemDraft.value.trim().split('').join(' · '))
|
||||
{ generationNo: 12, character: "启", current: false },
|
||||
{ generationNo: 13, character: "宗", current: false },
|
||||
{ generationNo: 14, character: "敦", current: true },
|
||||
{ generationNo: 15, character: "本", current: false },
|
||||
]);
|
||||
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 === 'no-permission' ? 'no-permission' : query.state === 'error' || !genealogyId.value ? 'error' : 'list'
|
||||
})
|
||||
onUnload(() => { if (feedbackTimer) clearTimeout(feedbackTimer) })
|
||||
genealogyId.value = query.genealogyId || "";
|
||||
poemState.value =
|
||||
query.state === "loading"
|
||||
? "loading"
|
||||
: query.state === "empty"
|
||||
? "empty"
|
||||
: query.state === "edit"
|
||||
? "edit"
|
||||
: query.state === "no-permission"
|
||||
? "no-permission"
|
||||
: query.state === "error" || !genealogyId.value
|
||||
? "error"
|
||||
: "list";
|
||||
});
|
||||
onUnload(() => {
|
||||
if (feedbackTimer) clearTimeout(feedbackTimer);
|
||||
});
|
||||
|
||||
const openEditor = () => { poemState.value = 'edit' }
|
||||
const openEditor = () => {
|
||||
poemState.value = "edit";
|
||||
};
|
||||
const savePoems = () => {
|
||||
const characters = poemDraft.value.trim().split('').filter(Boolean)
|
||||
const characters = poemDraft.value.trim().split("").filter(Boolean);
|
||||
if (!characters.length) {
|
||||
poemError.value = '请录入字辈内容'
|
||||
return
|
||||
poemError.value = "请录入字辈内容";
|
||||
return;
|
||||
}
|
||||
if (poemDraft.value.trim() === '失败') {
|
||||
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'
|
||||
feedbackVisible.value = true
|
||||
feedbackTimer = setTimeout(() => { feedbackVisible.value = false; feedbackTimer = null }, 1800)
|
||||
}
|
||||
poemRows.value = characters
|
||||
.slice(0, 4)
|
||||
.map((character, index) => ({
|
||||
generationNo: 12 + index,
|
||||
character,
|
||||
current: index === 2,
|
||||
}));
|
||||
poemState.value = "list";
|
||||
feedbackVisible.value = true;
|
||||
feedbackTimer = setTimeout(() => {
|
||||
feedbackVisible.value = false;
|
||||
feedbackTimer = null;
|
||||
}, 1800);
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.poem-page { position: relative; min-height: 100vh; overflow-x: hidden; background: $paper; }
|
||||
.poem-page__header { position: relative; z-index: 3; }
|
||||
.poem-panel { position: relative; z-index: 2; width: calc(100% - 32rpx); height: min(650px, calc((100vw - 16px) * 1.5)); margin: 18rpx auto 0; }
|
||||
.poem-panel__skin { position: absolute; inset: 0; width: 100%; height: 100%; }
|
||||
.poem-list, .poem-editor, .poem-state-card { position: absolute; inset: 7.5% 8%; }
|
||||
.poem-list__eyebrow { display: block; color: $brand-red; font-size: 23rpx; letter-spacing: 3rpx; }
|
||||
.poem-list__title { display: block; margin-top: 11rpx; color: $ink; font-family: 'STKaiti', 'KaiTi', serif; font-size: 35rpx; font-weight: 700; }
|
||||
.poem-list__copy { display: block; margin-top: 10rpx; color: $ink-muted; font-size: 21rpx; line-height: 1.55; }
|
||||
.poem-rows { margin-top: 18rpx; }
|
||||
.poem-row { position: relative; height: 72rpx; margin-top: 10rpx; }
|
||||
.poem-row image { position: absolute; inset: 0; width: 100%; height: 100%; }
|
||||
.poem-row__number { position: absolute; top: 24rpx; left: 24rpx; z-index: 1; color: $ink-muted; font-size: 21rpx; }
|
||||
.poem-row__character { position: absolute; top: 14rpx; left: 48%; z-index: 1; color: $ink; font-family: 'STKaiti', 'KaiTi', serif; font-size: 32rpx; font-weight: 700; }
|
||||
.poem-row__status { position: absolute; top: 25rpx; right: 22rpx; z-index: 1; color: $ink-muted; font-size: 19rpx; }
|
||||
.poem-row--current .poem-row__character, .poem-row--current .poem-row__status { color: $brand-red; }
|
||||
.poem-action { position: relative; width: 100%; height: 76rpx; margin-top: 20rpx; }
|
||||
.poem-action image { position: absolute; inset: 0; width: 100%; height: 100%; }
|
||||
.poem-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; letter-spacing: 2rpx; }
|
||||
.poem-field { position: relative; height: 118rpx; margin-top: 22rpx; }
|
||||
.poem-field image { position: absolute; inset: 0; width: 100%; height: 100%; }
|
||||
.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; }
|
||||
.poem-policy__option image { position: absolute; inset: 0; width: 100%; height: 100%; }
|
||||
.poem-policy__option text { position: relative; z-index: 1; display: flex; align-items: center; justify-content: center; height: 100%; color: $ink; font-size: 21rpx; font-weight: 700; }
|
||||
.poem-policy__option .poem-policy__option-text--active { color: #fff9ed; }
|
||||
.poem-preview { display: block; margin-top: 22rpx; color: $ink-muted; font-size: 21rpx; line-height: 1.6; }
|
||||
.poem-editor__actions { display: flex; gap: 14rpx; margin-top: 18rpx; }
|
||||
.poem-editor__actions .poem-action { width: calc(50% - 7rpx); margin-top: 0; }
|
||||
.poem-action .poem-action__secondary { color: $ink; }
|
||||
.poem-state-card { top: 29%; text-align: center; }
|
||||
.poem-state-card .poem-list__eyebrow { text-align: center; }
|
||||
.poem-state-card .poem-list__copy { margin-top: 22rpx; }
|
||||
.poem-state-card .poem-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%; } }
|
||||
.poem-page {
|
||||
position: relative;
|
||||
min-height: 100vh;
|
||||
overflow-x: hidden;
|
||||
background: $paper;
|
||||
}
|
||||
.poem-page__header {
|
||||
position: relative;
|
||||
z-index: 3;
|
||||
}
|
||||
.poem-panel {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
width: calc(100% - 32rpx);
|
||||
height: min(650px, calc((100vw - 16px) * 1.5));
|
||||
margin: 18rpx auto 0;
|
||||
}
|
||||
.poem-panel__skin {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
.poem-list,
|
||||
.poem-editor,
|
||||
.poem-state-card {
|
||||
position: absolute;
|
||||
inset: 7.5% 8%;
|
||||
}
|
||||
.poem-list__eyebrow {
|
||||
display: block;
|
||||
color: $brand-red;
|
||||
font-size: 23rpx;
|
||||
letter-spacing: 3rpx;
|
||||
}
|
||||
.poem-list__title {
|
||||
display: block;
|
||||
margin-top: 11rpx;
|
||||
color: $ink;
|
||||
font-family: "STKaiti", "KaiTi", serif;
|
||||
font-size: 35rpx;
|
||||
font-weight: 700;
|
||||
}
|
||||
.poem-list__copy {
|
||||
display: block;
|
||||
margin-top: 10rpx;
|
||||
color: $ink-muted;
|
||||
font-size: 24rpx;
|
||||
line-height: 1.55;
|
||||
}
|
||||
.poem-rows {
|
||||
margin-top: 18rpx;
|
||||
}
|
||||
.poem-row {
|
||||
position: relative;
|
||||
height: 72rpx;
|
||||
margin-top: 10rpx;
|
||||
}
|
||||
.poem-row image {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
.poem-row__number {
|
||||
position: absolute;
|
||||
top: 24rpx;
|
||||
left: 24rpx;
|
||||
z-index: 1;
|
||||
color: $ink-muted;
|
||||
font-size: 23rpx;
|
||||
}
|
||||
.poem-row__character {
|
||||
position: absolute;
|
||||
top: 14rpx;
|
||||
left: 48%;
|
||||
z-index: 1;
|
||||
color: $ink;
|
||||
font-family: "STKaiti", "KaiTi", serif;
|
||||
font-size: 32rpx;
|
||||
font-weight: 700;
|
||||
}
|
||||
.poem-row__status {
|
||||
position: absolute;
|
||||
top: 25rpx;
|
||||
right: 22rpx;
|
||||
z-index: 1;
|
||||
color: $ink-muted;
|
||||
font-size: 22rpx;
|
||||
}
|
||||
.poem-row--current .poem-row__character,
|
||||
.poem-row--current .poem-row__status {
|
||||
color: $brand-red;
|
||||
}
|
||||
.poem-action {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 76rpx;
|
||||
margin-top: 20rpx;
|
||||
}
|
||||
.poem-action image {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
.poem-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;
|
||||
letter-spacing: 2rpx;
|
||||
}
|
||||
.poem-field {
|
||||
position: relative;
|
||||
height: 118rpx;
|
||||
margin-top: 22rpx;
|
||||
}
|
||||
.poem-field image {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
.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: 24rpx;
|
||||
line-height: 34rpx;
|
||||
text-align: right;
|
||||
}
|
||||
.poem-policy {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-top: 22rpx;
|
||||
}
|
||||
.poem-policy > text {
|
||||
color: $ink;
|
||||
font-size: 24rpx;
|
||||
font-weight: 700;
|
||||
}
|
||||
.poem-policy__option {
|
||||
position: relative;
|
||||
width: 248rpx;
|
||||
height: 62rpx;
|
||||
}
|
||||
.poem-policy__option image {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
.poem-policy__option text {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
color: $ink;
|
||||
font-size: 23rpx;
|
||||
font-weight: 700;
|
||||
}
|
||||
.poem-policy__option .poem-policy__option-text--active {
|
||||
color: #fff9ed;
|
||||
}
|
||||
.poem-preview {
|
||||
display: block;
|
||||
margin-top: 22rpx;
|
||||
color: $ink-muted;
|
||||
font-size: 23rpx;
|
||||
line-height: 1.6;
|
||||
}
|
||||
.poem-editor__actions {
|
||||
display: flex;
|
||||
gap: 14rpx;
|
||||
margin-top: 18rpx;
|
||||
}
|
||||
.poem-editor__actions .poem-action {
|
||||
width: calc(50% - 7rpx);
|
||||
margin-top: 0;
|
||||
}
|
||||
.poem-action .poem-action__secondary {
|
||||
color: $ink;
|
||||
}
|
||||
.poem-state-card {
|
||||
top: 29%;
|
||||
text-align: center;
|
||||
}
|
||||
.poem-state-card .poem-list__eyebrow {
|
||||
text-align: center;
|
||||
}
|
||||
.poem-state-card .poem-list__copy {
|
||||
margin-top: 22rpx;
|
||||
}
|
||||
.poem-state-card .poem-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,17 +1,23 @@
|
||||
<!-- 页面编号:T-01;用途:世系树阅读、同页提示、空态与失败状态(页面设计阶段使用本地模拟数据)。 -->
|
||||
<!-- 页面编号:T-01;用途:纵向世系树阅读、同代横向提示、空态与失败态。 -->
|
||||
<template>
|
||||
<view class="tree-page" :class="{
|
||||
'tree-state--tree': treeState === 'tree',
|
||||
'tree-state--landscape': treeState === 'landscape',
|
||||
'tree-state--empty': treeState === 'empty',
|
||||
'tree-state--error': treeState === 'error'
|
||||
}">
|
||||
<view
|
||||
class="tree-page"
|
||||
:class="{
|
||||
'tree-state--tree': treeState === 'tree',
|
||||
'tree-state--landscape': treeState === 'landscape',
|
||||
'tree-state--empty': treeState === 'empty',
|
||||
'tree-state--error': treeState === 'error',
|
||||
}"
|
||||
>
|
||||
<ModulePageBackground module="tree" />
|
||||
<view class="tree-page__header"><PageHeader title="世系树" action="成员目录" @action="toDirectory" /></view>
|
||||
<view class="tree-page__header">
|
||||
<PageHeader title="世系树" action="成员目录" @action="toDirectory" />
|
||||
</view>
|
||||
|
||||
<view class="tree-toolbar">
|
||||
<view v-if="treeState !== 'loading'" class="tree-toolbar">
|
||||
<view class="tree-toolbar__title">
|
||||
<text>汤氏家谱</text><text>主支 · 第 12—14 世</text>
|
||||
<text>汤氏家谱</text>
|
||||
<text>主支 · 第 12—14 世</text>
|
||||
</view>
|
||||
<view class="tree-toolbar__actions">
|
||||
<text @click="treeState = 'landscape'">阅读提示</text>
|
||||
@@ -19,46 +25,137 @@
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<scroll-view class="tree-scroll" scroll-x :show-scrollbar="false">
|
||||
<view class="tree-canvas" :class="{ 'tree-canvas--state': treeState !== 'tree' }">
|
||||
<image class="tree-canvas__skin" src="/static/assets/modules/genealogy/opaque/g03-create-flow-panel.png" mode="scaleToFill" />
|
||||
<scroll-view
|
||||
class="tree-scroll"
|
||||
scroll-x
|
||||
:scroll-left="initialScrollLeft"
|
||||
:show-scrollbar="false"
|
||||
>
|
||||
<view
|
||||
class="tree-canvas"
|
||||
:class="{ 'tree-canvas--state': treeState !== 'tree' }"
|
||||
>
|
||||
<AppLoading
|
||||
v-if="treeState === 'loading'"
|
||||
text="正在整理世系"
|
||||
description="请稍候,正在准备家谱成员关系。"
|
||||
/>
|
||||
|
||||
<template v-if="treeState === 'tree'">
|
||||
<view class="generation-label generation-label--one"><text>第十二世</text></view>
|
||||
<view class="generation-label generation-label--two"><text>第十三世</text></view>
|
||||
<view class="generation-label generation-label--three"><text>第十四世</text></view>
|
||||
<view v-for="member in members" :key="member.id" class="member-node" :class="{ 'member-node--selected': selected?.id === member.id }" :style="nodeStyle(member)" @click="selected = member">
|
||||
<image class="member-node__skin" src="/static/assets/modules/genealogy/opaque/g06-search-input-wide.png" mode="scaleToFill" />
|
||||
<template v-else-if="treeState === 'tree'">
|
||||
<view class="lineage-pan-cue">
|
||||
<text>同代分支可左右查看</text>
|
||||
</view>
|
||||
|
||||
<view class="generation-band generation-band--twelve">
|
||||
<image
|
||||
src="/static/assets/modules/genealogy/transparent/section-divider.png"
|
||||
mode="scaleToFill"
|
||||
/>
|
||||
<text>第十二世 · 始祖</text>
|
||||
</view>
|
||||
<view class="generation-band generation-band--thirteen">
|
||||
<image
|
||||
src="/static/assets/modules/genealogy/transparent/section-divider.png"
|
||||
mode="scaleToFill"
|
||||
/>
|
||||
<text>第十三世 · 两房</text>
|
||||
</view>
|
||||
<view class="generation-band generation-band--fourteen">
|
||||
<image
|
||||
src="/static/assets/modules/genealogy/transparent/section-divider.png"
|
||||
mode="scaleToFill"
|
||||
/>
|
||||
<text>第十四世 · 三支</text>
|
||||
</view>
|
||||
|
||||
<view class="lineage-connector lineage-connector--root" />
|
||||
<view class="lineage-connector lineage-connector--root-branch" />
|
||||
<view class="lineage-connector lineage-connector--gen13-left" />
|
||||
<view class="lineage-connector lineage-connector--gen13-right" />
|
||||
<view class="lineage-connector lineage-connector--left-trunk" />
|
||||
<view class="lineage-connector lineage-connector--left-branch" />
|
||||
<view class="lineage-connector lineage-connector--gen14-left" />
|
||||
<view class="lineage-connector lineage-connector--gen14-middle" />
|
||||
<view class="lineage-connector lineage-connector--right-trunk" />
|
||||
<view class="lineage-connector lineage-connector--right-branch" />
|
||||
<view class="lineage-connector lineage-connector--gen14-right" />
|
||||
|
||||
<view
|
||||
v-for="member in members"
|
||||
:key="member.id"
|
||||
class="member-node"
|
||||
:class="{ 'member-node--selected': selected?.id === member.id }"
|
||||
:style="nodeStyle(member)"
|
||||
@click="selected = member"
|
||||
>
|
||||
<image
|
||||
class="member-node__skin"
|
||||
:src="
|
||||
selected?.id === member.id
|
||||
? '/static/assets/modules/tree/transparent/t01-member-node-selected.png'
|
||||
: '/static/assets/modules/tree/transparent/t01-member-node-standard.png'
|
||||
"
|
||||
mode="scaleToFill"
|
||||
/>
|
||||
<text class="node-name">{{ member.name }}</text>
|
||||
<text class="node-relation">{{ member.relation }}</text>
|
||||
<text class="node-relation"
|
||||
>{{ member.relation }} · {{ member.branch }}</text
|
||||
>
|
||||
<text class="node-years">{{ member.years }}</text>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<view v-else class="tree-state-card">
|
||||
<text class="tree-state-card__eyebrow">{{ stateCopy.eyebrow }}</text>
|
||||
<text class="tree-state-card__title">{{ stateCopy.title }}</text>
|
||||
<text class="tree-state-card__copy">{{ stateCopy.copy }}</text>
|
||||
<view class="tree-state-card__action" @click="handleStateAction">
|
||||
<image src="/static/assets/foundation/transparent/a01-scroll-primary-v3.png" mode="aspectFit" />
|
||||
<text>{{ stateCopy.action }}</text>
|
||||
<image
|
||||
class="tree-state-card__skin"
|
||||
src="/static/assets/modules/tree/transparent/t01-state-panel.png"
|
||||
mode="scaleToFill"
|
||||
/>
|
||||
<view class="tree-state-card__content">
|
||||
<text class="tree-state-card__eyebrow">{{
|
||||
stateCopy.eyebrow
|
||||
}}</text>
|
||||
<text class="tree-state-card__title">{{ stateCopy.title }}</text>
|
||||
<text class="tree-state-card__copy">{{ stateCopy.copy }}</text>
|
||||
<view class="tree-state-card__action" @click="handleStateAction">
|
||||
<image
|
||||
src="/static/assets/foundation/transparent/a01-scroll-primary-v3.png"
|
||||
mode="aspectFit"
|
||||
/>
|
||||
<text>{{ stateCopy.action }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
|
||||
<view v-if="treeState === 'tree' && selected" class="member-sheet">
|
||||
<image class="member-sheet__skin" src="/static/assets/modules/genealogy/opaque/application-status-card.png" mode="scaleToFill" />
|
||||
<image
|
||||
class="member-sheet__skin"
|
||||
src="/static/assets/modules/tree/transparent/t01-member-drawer.png"
|
||||
mode="scaleToFill"
|
||||
/>
|
||||
<view class="member-sheet__copy">
|
||||
<text class="sheet-name">{{ selected.name }}</text>
|
||||
<text class="sheet-meta">第 {{ selected.generation }} 世 · {{ selected.relation }} · {{ selected.branch }}</text>
|
||||
<text class="sheet-meta"
|
||||
>第 {{ selected.generation }} 世 · {{ selected.relation }} ·
|
||||
{{ selected.branch }}</text
|
||||
>
|
||||
</view>
|
||||
<view class="sheet-actions">
|
||||
<view class="sheet-action sheet-member-action" @click="toMember">
|
||||
<image src="/static/assets/foundation/transparent/a01-scroll-secondary-v3.png" mode="aspectFit" /><text>查看资料</text>
|
||||
<image
|
||||
src="/static/assets/foundation/transparent/a01-secondary-button-v2.png"
|
||||
mode="aspectFit"
|
||||
/>
|
||||
<text>查看资料</text>
|
||||
</view>
|
||||
<view class="sheet-action" @click="toAddRelative">
|
||||
<image src="/static/assets/foundation/transparent/a01-scroll-primary-v3.png" mode="aspectFit" /><text>添加亲属</text>
|
||||
<image
|
||||
src="/static/assets/foundation/transparent/a01-primary-button-v2.png"
|
||||
mode="aspectFit"
|
||||
/>
|
||||
<text>添加亲属</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
@@ -66,99 +163,523 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue'
|
||||
import { onLoad } from '@dcloudio/uni-app'
|
||||
import ModulePageBackground from '@/components/ModulePageBackground.vue'
|
||||
import PageHeader from '@/components/PageHeader.vue'
|
||||
import { genealogyContext } from '@/utils/genealogy-context.js'
|
||||
import { computed, ref } from "vue";
|
||||
import { onLoad } from "@dcloudio/uni-app";
|
||||
import AppLoading from "@/components/AppLoading.vue";
|
||||
import ModulePageBackground from "@/components/ModulePageBackground.vue";
|
||||
import PageHeader from "@/components/PageHeader.vue";
|
||||
import { genealogyContext } from "@/utils/genealogy-context.js";
|
||||
|
||||
const genealogyId = ref('')
|
||||
const treeState = ref('loading')
|
||||
const selected = ref(null)
|
||||
const genealogyId = ref("");
|
||||
const treeState = ref("loading");
|
||||
const selected = ref(null);
|
||||
const initialScrollLeft = ref(20);
|
||||
const members = ref([
|
||||
{ id: 101, name: '汤文远', relation: '始祖', years: '1940—2012', generation: 12, branch: '主支', x: 50, y: 15 },
|
||||
{ id: 102, name: '汤正国', relation: '长子', years: '1965—', generation: 13, branch: '长房', x: 31, y: 43 },
|
||||
{ id: 103, name: '汤正华', relation: '次子', years: '1968—', generation: 13, branch: '二房', x: 69, y: 43 },
|
||||
{ id: 104, name: '汤凯', relation: '长孙', years: '1992—', generation: 14, branch: '长房', x: 20, y: 72 },
|
||||
{ id: 105, name: '汤悦', relation: '长孙女', years: '1995—', generation: 14, branch: '长房', x: 50, y: 72 },
|
||||
{ id: 106, name: '汤晨', relation: '次孙', years: '1998—', generation: 14, branch: '二房', x: 80, y: 72 }
|
||||
])
|
||||
{
|
||||
id: 101,
|
||||
name: "汤文远",
|
||||
relation: "始祖",
|
||||
years: "1940—2012",
|
||||
generation: 12,
|
||||
branch: "主支",
|
||||
x: 450,
|
||||
y: 135,
|
||||
},
|
||||
{
|
||||
id: 102,
|
||||
name: "汤正国",
|
||||
relation: "长子",
|
||||
years: "1965—",
|
||||
generation: 13,
|
||||
branch: "长房",
|
||||
x: 275,
|
||||
y: 350,
|
||||
},
|
||||
{
|
||||
id: 103,
|
||||
name: "汤正华",
|
||||
relation: "次子",
|
||||
years: "1968—",
|
||||
generation: 13,
|
||||
branch: "二房",
|
||||
x: 625,
|
||||
y: 350,
|
||||
},
|
||||
{
|
||||
id: 104,
|
||||
name: "汤凯",
|
||||
relation: "长孙",
|
||||
years: "1992—",
|
||||
generation: 14,
|
||||
branch: "长房",
|
||||
x: 150,
|
||||
y: 650,
|
||||
},
|
||||
{
|
||||
id: 105,
|
||||
name: "汤悦",
|
||||
relation: "长孙女",
|
||||
years: "1995—",
|
||||
generation: 14,
|
||||
branch: "长房",
|
||||
x: 400,
|
||||
y: 650,
|
||||
},
|
||||
{
|
||||
id: 106,
|
||||
name: "汤晨",
|
||||
relation: "次孙",
|
||||
years: "1998—",
|
||||
generation: 14,
|
||||
branch: "二房",
|
||||
x: 690,
|
||||
y: 650,
|
||||
},
|
||||
]);
|
||||
|
||||
const stateCopy = computed(() => ({
|
||||
landscape: { eyebrow: '横向阅读提示', title: '左右拖动查看完整世系', copy: '世系按代际从上到下排列;横向拖动画布可以查看同一世代的其他支系。', action: '进入世系图' },
|
||||
empty: { eyebrow: '尚未建立世系', title: '从第一位先祖开始记录', copy: '录入首代成员后,即可继续补充子女、配偶与后代关系。', action: '录入首代成员' },
|
||||
error: { eyebrow: '世系暂不可用', title: '暂时无法读取成员关系', copy: '请从当前家谱重新进入;已经录入的成员资料不会受到影响。', action: '重新查看' },
|
||||
loading: { eyebrow: '正在整理世系', title: '请稍候', copy: '正在准备家谱成员关系。', action: '重新查看' }
|
||||
}[treeState.value] || {}))
|
||||
const stateCopy = computed(
|
||||
() =>
|
||||
({
|
||||
landscape: {
|
||||
eyebrow: "世系阅读提示",
|
||||
title: "上下看世代,左右看同代分支",
|
||||
copy: "始祖位于上方,后代逐世向下展开;同一世代分支较多时,可左右拖动查看。",
|
||||
action: "进入世系树",
|
||||
},
|
||||
empty: {
|
||||
eyebrow: "尚未建立世系",
|
||||
title: "从第一位先祖开始记录",
|
||||
copy: "录入首代成员后,即可继续补充子女、配偶与后代关系。",
|
||||
action: "录入首代成员",
|
||||
},
|
||||
error: {
|
||||
eyebrow: "世系暂不可用",
|
||||
title: "暂时无法读取成员关系",
|
||||
copy: "请从当前家谱重新进入;已经录入的成员资料不会受到影响。",
|
||||
action: "重新查看",
|
||||
},
|
||||
})[treeState.value] || {},
|
||||
);
|
||||
|
||||
onLoad((query) => {
|
||||
genealogyId.value = query.genealogyId || genealogyContext.getCurrentGenealogyId() || ''
|
||||
if (query.state === 'landscape' || query.state === 'empty' || query.state === 'error') {
|
||||
treeState.value = query.state
|
||||
return
|
||||
genealogyId.value =
|
||||
query.genealogyId || genealogyContext.getCurrentGenealogyId() || "";
|
||||
if (
|
||||
query.state === "loading" ||
|
||||
["landscape", "empty", "error"].includes(query.state)
|
||||
) {
|
||||
treeState.value = query.state;
|
||||
return;
|
||||
}
|
||||
if (!genealogyId.value) {
|
||||
treeState.value = 'empty'
|
||||
return
|
||||
treeState.value = "empty";
|
||||
return;
|
||||
}
|
||||
genealogyContext.setCurrentGenealogyId(genealogyId.value)
|
||||
selected.value = members.value.find((item) => String(item.id) === String(query.selectedId)) || members.value[0]
|
||||
treeState.value = 'tree'
|
||||
})
|
||||
genealogyContext.setCurrentGenealogyId(genealogyId.value);
|
||||
selected.value =
|
||||
members.value.find(
|
||||
(item) => String(item.id) === String(query.selectedId),
|
||||
) || members.value[0];
|
||||
treeState.value = "tree";
|
||||
});
|
||||
|
||||
const nodeStyle = (member) => ({ left: `${member.x}%`, top: `${member.y}%` })
|
||||
const nodeStyle = (member) => ({
|
||||
left: `${member.x}rpx`,
|
||||
top: `${member.y}rpx`,
|
||||
});
|
||||
const handleStateAction = () => {
|
||||
if (treeState.value === 'empty') {
|
||||
uni.navigateTo({ url: `/pages/tree/t04-add-relative?genealogyId=${genealogyId.value || 1001}&mode=first` })
|
||||
return
|
||||
if (treeState.value === "empty") {
|
||||
uni.navigateTo({
|
||||
url: `/pages/tree/t04-add-relative?genealogyId=${genealogyId.value || 1001}&mode=first`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
selected.value = members.value[0]
|
||||
treeState.value = 'tree'
|
||||
}
|
||||
const toMember = () => uni.navigateTo({ url: `/pages/tree/t03-member-profile?genealogyId=${genealogyId.value}&personId=${selected.value.id}` })
|
||||
const toDirectory = () => uni.navigateTo({ url: `/pages/tree/t07-member-directory?genealogyId=${genealogyId.value}` })
|
||||
const toRelationship = () => uni.navigateTo({ url: `/pages/tree/t06-edit-relationship?genealogyId=${genealogyId.value}` })
|
||||
const toAddRelative = () => uni.navigateTo({ url: `/pages/tree/t04-add-relative?genealogyId=${genealogyId.value}&personId=${selected.value.id}` })
|
||||
selected.value = members.value[0];
|
||||
treeState.value = "tree";
|
||||
};
|
||||
const toMember = () =>
|
||||
uni.navigateTo({
|
||||
url: `/pages/tree/t03-member-profile?genealogyId=${genealogyId.value}&personId=${selected.value.id}`,
|
||||
});
|
||||
const toDirectory = () =>
|
||||
uni.navigateTo({
|
||||
url: `/pages/tree/t07-member-directory?genealogyId=${genealogyId.value}`,
|
||||
});
|
||||
const toRelationship = () =>
|
||||
uni.navigateTo({
|
||||
url: `/pages/tree/t06-edit-relationship?genealogyId=${genealogyId.value}`,
|
||||
});
|
||||
const toAddRelative = () =>
|
||||
uni.navigateTo({
|
||||
url: `/pages/tree/t04-add-relative?genealogyId=${genealogyId.value}&personId=${selected.value.id}`,
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.tree-page { position: relative; height: 100vh; overflow: hidden; background: $paper; }
|
||||
.tree-page__header, .tree-toolbar, .tree-scroll, .member-sheet { position: relative; z-index: 2; }
|
||||
.tree-toolbar { display: flex; align-items: center; justify-content: space-between; padding: 18rpx 28rpx 14rpx; }
|
||||
.tree-toolbar__title text { display: block; }
|
||||
.tree-toolbar__title text:first-child { color: $ink; font-family: 'STKaiti', 'KaiTi', serif; font-size: 28rpx; font-weight: 700; }
|
||||
.tree-toolbar__title text:last-child { margin-top: 5rpx; color: $ink-muted; font-size: 19rpx; }
|
||||
.tree-toolbar__actions { display: flex; gap: 24rpx; color: $brand-red; font-size: 21rpx; }
|
||||
.tree-scroll { width: 100%; height: calc(100vh - 330rpx); white-space: nowrap; }
|
||||
.tree-canvas { position: relative; width: 1180rpx; height: 1040rpx; margin: 0 18rpx; }
|
||||
.tree-canvas--state { width: calc(100vw - 36rpx); }
|
||||
.tree-canvas__skin { position: absolute; inset: 0; width: 100%; height: 100%; }
|
||||
.generation-label { position: absolute; left: 3.5%; z-index: 2; color: #92764f; font-size: 19rpx; writing-mode: vertical-rl; letter-spacing: 3rpx; }
|
||||
.generation-label--one { top: 16%; }
|
||||
.generation-label--two { top: 44%; }
|
||||
.generation-label--three { top: 73%; }
|
||||
.member-node { position: absolute; width: 190rpx; height: 132rpx; transform: translate(-50%, -50%); text-align: center; }
|
||||
.member-node__skin { position: absolute; inset: 0; width: 100%; height: 100%; }
|
||||
.node-name, .node-relation, .node-years { position: relative; z-index: 1; display: block; }
|
||||
.node-name { padding-top: 22rpx; color: $ink; font-family: 'STKaiti', 'KaiTi', serif; font-size: 29rpx; font-weight: 700; }
|
||||
.node-relation { margin-top: 6rpx; color: $brand-red; font-size: 19rpx; }
|
||||
.node-years { margin-top: 4rpx; color: $ink-muted; font-size: 17rpx; }
|
||||
.member-node--selected .node-name { color: $brand-red; }
|
||||
.tree-state-card { position: absolute; top: 28%; right: 9%; left: 9%; z-index: 2; text-align: center; white-space: normal; }
|
||||
.tree-state-card__eyebrow { display: block; color: $brand-red; font-size: 22rpx; letter-spacing: 3rpx; }
|
||||
.tree-state-card__title { display: block; margin-top: 14rpx; color: $ink; font-family: 'STKaiti', 'KaiTi', serif; font-size: 36rpx; font-weight: 700; }
|
||||
.tree-state-card__copy { display: block; margin-top: 18rpx; color: $ink-muted; font-size: 22rpx; line-height: 1.7; }
|
||||
.tree-state-card__action { position: relative; width: 380rpx; height: 76rpx; margin: 30rpx auto 0; }
|
||||
.tree-state-card__action image { position: absolute; inset: 0; width: 100%; height: 100%; }
|
||||
.tree-state-card__action text { position: relative; z-index: 1; display: flex; align-items: center; justify-content: center; height: 100%; color: #fff9ed; font-size: 24rpx; font-weight: 700; }
|
||||
.member-sheet { position: fixed; right: 18rpx; bottom: calc(14rpx + env(safe-area-inset-bottom)); left: 18rpx; height: 178rpx; }
|
||||
.member-sheet__skin { position: absolute; inset: 0; width: 100%; height: 100%; }
|
||||
.member-sheet__copy { position: absolute; top: 34rpx; left: 44rpx; z-index: 1; }
|
||||
.sheet-name { display: block; color: $ink; font-family: 'STKaiti', 'KaiTi', serif; font-size: 31rpx; font-weight: 700; }
|
||||
.sheet-meta { display: block; margin-top: 10rpx; color: $ink-muted; font-size: 19rpx; }
|
||||
.sheet-actions { position: absolute; top: 46rpx; right: 34rpx; z-index: 1; display: flex; gap: 10rpx; }
|
||||
.sheet-action { position: relative; width: 130rpx; height: 62rpx; }
|
||||
.sheet-action image { position: absolute; inset: 0; width: 100%; height: 100%; }
|
||||
.sheet-action text { position: relative; z-index: 1; display: flex; align-items: center; justify-content: center; height: 100%; color: $ink; font-size: 20rpx; font-weight: 700; }
|
||||
.sheet-action:last-child text { color: #fff9ed; }
|
||||
.tree-page {
|
||||
position: relative;
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
background: $paper;
|
||||
}
|
||||
.tree-page__header,
|
||||
.tree-toolbar,
|
||||
.tree-scroll,
|
||||
.member-sheet {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
}
|
||||
.tree-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 18rpx 28rpx 12rpx;
|
||||
}
|
||||
.tree-toolbar__title text {
|
||||
display: block;
|
||||
}
|
||||
.tree-toolbar__title text:first-child {
|
||||
color: $ink;
|
||||
font-family: "STKaiti", "KaiTi", serif;
|
||||
font-size: 28rpx;
|
||||
font-weight: 700;
|
||||
}
|
||||
.tree-toolbar__title text:last-child {
|
||||
margin-top: 5rpx;
|
||||
color: $ink-muted;
|
||||
font-size: 22rpx;
|
||||
}
|
||||
.tree-toolbar__actions {
|
||||
display: flex;
|
||||
gap: 24rpx;
|
||||
color: $brand-red;
|
||||
font-size: 23rpx;
|
||||
}
|
||||
.tree-scroll {
|
||||
width: 100%;
|
||||
height: calc(100vh - 272rpx);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.tree-canvas {
|
||||
position: relative;
|
||||
width: 900rpx;
|
||||
height: 900rpx;
|
||||
margin: 0 16rpx;
|
||||
}
|
||||
.tree-canvas--state {
|
||||
width: calc(100vw - 32rpx);
|
||||
}
|
||||
.lineage-pan-cue {
|
||||
position: absolute;
|
||||
top: 8rpx;
|
||||
right: 22rpx;
|
||||
z-index: 3;
|
||||
color: #9a7748;
|
||||
font-size: 20rpx;
|
||||
letter-spacing: 1rpx;
|
||||
}
|
||||
.generation-band {
|
||||
position: absolute;
|
||||
left: 24rpx;
|
||||
z-index: 3;
|
||||
width: 184rpx;
|
||||
height: 38rpx;
|
||||
color: #8f6c3f;
|
||||
font-size: 22rpx;
|
||||
text-align: center;
|
||||
}
|
||||
.generation-band image {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
.generation-band text {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
}
|
||||
.generation-band--twelve {
|
||||
top: 48rpx;
|
||||
}
|
||||
.generation-band--thirteen {
|
||||
top: 250rpx;
|
||||
}
|
||||
.generation-band--fourteen {
|
||||
top: 522rpx;
|
||||
}
|
||||
.lineage-connector {
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
background: #b78a42;
|
||||
}
|
||||
.lineage-connector--root {
|
||||
left: 449rpx;
|
||||
top: 177rpx;
|
||||
width: 2rpx;
|
||||
height: 88rpx;
|
||||
}
|
||||
.lineage-connector--root-branch {
|
||||
left: 275rpx;
|
||||
top: 264rpx;
|
||||
width: 350rpx;
|
||||
height: 2rpx;
|
||||
}
|
||||
.lineage-connector--gen13-left {
|
||||
left: 274rpx;
|
||||
top: 264rpx;
|
||||
width: 2rpx;
|
||||
height: 44rpx;
|
||||
}
|
||||
.lineage-connector--gen13-right {
|
||||
left: 624rpx;
|
||||
top: 264rpx;
|
||||
width: 2rpx;
|
||||
height: 44rpx;
|
||||
}
|
||||
.lineage-connector--left-trunk {
|
||||
left: 274rpx;
|
||||
top: 392rpx;
|
||||
width: 2rpx;
|
||||
height: 149rpx;
|
||||
}
|
||||
.lineage-connector--left-branch {
|
||||
left: 150rpx;
|
||||
top: 540rpx;
|
||||
width: 250rpx;
|
||||
height: 2rpx;
|
||||
}
|
||||
.lineage-connector--gen14-left {
|
||||
left: 149rpx;
|
||||
top: 540rpx;
|
||||
width: 2rpx;
|
||||
height: 68rpx;
|
||||
}
|
||||
.lineage-connector--gen14-middle {
|
||||
left: 399rpx;
|
||||
top: 540rpx;
|
||||
width: 2rpx;
|
||||
height: 68rpx;
|
||||
}
|
||||
.lineage-connector--right-trunk {
|
||||
left: 624rpx;
|
||||
top: 392rpx;
|
||||
width: 2rpx;
|
||||
height: 149rpx;
|
||||
}
|
||||
.lineage-connector--right-branch {
|
||||
left: 625rpx;
|
||||
top: 540rpx;
|
||||
width: 65rpx;
|
||||
height: 2rpx;
|
||||
}
|
||||
.lineage-connector--gen14-right {
|
||||
left: 689rpx;
|
||||
top: 540rpx;
|
||||
width: 2rpx;
|
||||
height: 68rpx;
|
||||
}
|
||||
.member-node {
|
||||
position: absolute;
|
||||
z-index: 2;
|
||||
width: 224rpx;
|
||||
height: 86rpx;
|
||||
transform: translate(-50%, -50%);
|
||||
text-align: center;
|
||||
}
|
||||
.member-node__skin {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
.node-name,
|
||||
.node-relation,
|
||||
.node-years {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: block;
|
||||
}
|
||||
.node-name {
|
||||
padding-top: 5rpx;
|
||||
color: $ink;
|
||||
font-family: "STKaiti", "KaiTi", serif;
|
||||
font-size: 25rpx;
|
||||
font-weight: 700;
|
||||
}
|
||||
.node-relation {
|
||||
margin-top: 0;
|
||||
color: $brand-red;
|
||||
font-size: 21rpx;
|
||||
}
|
||||
.node-years {
|
||||
margin-top: 0;
|
||||
color: $ink-muted;
|
||||
font-size: 20rpx;
|
||||
}
|
||||
.member-node--selected {
|
||||
width: 236rpx;
|
||||
height: 94rpx;
|
||||
}
|
||||
.member-node--selected .node-name {
|
||||
color: $brand-red;
|
||||
}
|
||||
.tree-state-card {
|
||||
position: absolute;
|
||||
top: 150rpx;
|
||||
right: 24rpx;
|
||||
left: 24rpx;
|
||||
z-index: 2;
|
||||
height: 360rpx;
|
||||
text-align: center;
|
||||
white-space: normal;
|
||||
}
|
||||
.tree-state-card__skin {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
.tree-state-card__content {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
padding: 62rpx 58rpx 42rpx;
|
||||
}
|
||||
.tree-state-card__eyebrow {
|
||||
display: block;
|
||||
color: $brand-red;
|
||||
font-size: 22rpx;
|
||||
letter-spacing: 3rpx;
|
||||
}
|
||||
.tree-state-card__title {
|
||||
display: block;
|
||||
margin-top: 12rpx;
|
||||
color: $ink;
|
||||
font-family: "STKaiti", "KaiTi", serif;
|
||||
font-size: 34rpx;
|
||||
font-weight: 700;
|
||||
}
|
||||
.tree-state-card__copy {
|
||||
display: block;
|
||||
margin-top: 16rpx;
|
||||
color: $ink-muted;
|
||||
font-size: 24rpx;
|
||||
line-height: 1.65;
|
||||
}
|
||||
.tree-state-card__action {
|
||||
position: relative;
|
||||
width: 360rpx;
|
||||
height: 70rpx;
|
||||
margin: 22rpx auto 0;
|
||||
}
|
||||
.tree-state-card__action image {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
.tree-state-card__action text {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
color: #fff9ed;
|
||||
font-size: 24rpx;
|
||||
font-weight: 700;
|
||||
}
|
||||
.member-sheet {
|
||||
position: fixed;
|
||||
right: 18rpx;
|
||||
bottom: calc(14rpx + env(safe-area-inset-bottom));
|
||||
left: 18rpx;
|
||||
height: 190rpx;
|
||||
}
|
||||
.member-sheet__skin {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
.member-sheet__copy {
|
||||
position: absolute;
|
||||
top: 38rpx;
|
||||
left: 44rpx;
|
||||
z-index: 1;
|
||||
}
|
||||
.sheet-name {
|
||||
display: block;
|
||||
color: $ink;
|
||||
font-family: "STKaiti", "KaiTi", serif;
|
||||
font-size: 31rpx;
|
||||
font-weight: 700;
|
||||
}
|
||||
.sheet-meta {
|
||||
display: block;
|
||||
margin-top: 8rpx;
|
||||
color: $ink-muted;
|
||||
font-size: 22rpx;
|
||||
}
|
||||
.sheet-actions {
|
||||
position: absolute;
|
||||
right: 36rpx;
|
||||
bottom: 30rpx;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
gap: 12rpx;
|
||||
}
|
||||
.sheet-action {
|
||||
position: relative;
|
||||
width: 148rpx;
|
||||
height: 60rpx;
|
||||
}
|
||||
.sheet-action image {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
.sheet-action text {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
color: $ink;
|
||||
font-family: "Microsoft YaHei", sans-serif;
|
||||
font-size: 22rpx;
|
||||
font-weight: 700;
|
||||
}
|
||||
.sheet-action:last-child text {
|
||||
color: #fff9ed;
|
||||
}
|
||||
|
||||
@media (max-width: 360px) {
|
||||
.tree-toolbar {
|
||||
padding-right: 20rpx;
|
||||
padding-left: 20rpx;
|
||||
}
|
||||
.tree-toolbar__actions {
|
||||
gap: 14rpx;
|
||||
}
|
||||
.member-sheet {
|
||||
height: 206rpx;
|
||||
}
|
||||
.member-sheet__copy {
|
||||
top: 32rpx;
|
||||
left: 36rpx;
|
||||
}
|
||||
.sheet-actions {
|
||||
right: 28rpx;
|
||||
bottom: 26rpx;
|
||||
}
|
||||
.sheet-action {
|
||||
width: 160rpx;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -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] [password-tab|sms-tab|invalid-login|verification-dialog|g06-results|g06-empty|g06-invite|fresh-navigation]')
|
||||
throw new Error('Usage: node scripts/capture-chrome-page.js <url> <selector> <output> [width] [height] [password-tab|sms-tab|invalid-login|verification-dialog|g06-results|g06-empty|g06-invite|g03-*|fresh-navigation]')
|
||||
}
|
||||
|
||||
const sleep = (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds))
|
||||
@@ -149,9 +149,9 @@ const waitForFreshDocument = async (send, documentTimeOrigin) => {
|
||||
}
|
||||
|
||||
const prepareG06State = async (send, action) => {
|
||||
if (action !== 'g06-results' && action !== 'g06-empty' && action !== 'g06-invite') return
|
||||
if (action !== 'g06-results' && action !== 'g06-empty' && action !== 'g06-error' && action !== 'g06-invite' && action !== 'g06-invite-invalid') return
|
||||
|
||||
if (action === 'g06-invite') {
|
||||
if (action === 'g06-invite' || action === 'g06-invite-invalid') {
|
||||
const switched = await send('Runtime.evaluate', {
|
||||
expression: `(() => {
|
||||
const tab = document.querySelectorAll('.mode-tab')[1]
|
||||
@@ -168,7 +168,7 @@ const prepareG06State = async (send, action) => {
|
||||
const input = document.querySelector('.invite-input input')
|
||||
const button = document.querySelector('.invite-controls .search-action')
|
||||
if (!input || !button) return false
|
||||
input.value = 'JP2026'
|
||||
input.value = ${JSON.stringify(action === 'g06-invite' ? 'JP2026' : 'INVALID')}
|
||||
input.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
return true
|
||||
})()`,
|
||||
@@ -179,7 +179,7 @@ const prepareG06State = async (send, action) => {
|
||||
await send('Runtime.evaluate', { expression: "document.querySelector('.invite-controls .search-action').click()" })
|
||||
} else {
|
||||
|
||||
const keyword = action === 'g06-results' ? '汤' : '不存在的家谱'
|
||||
const keyword = action === 'g06-results' ? '汤' : action === 'g06-error' ? '失败' : '不存在的家谱'
|
||||
const prepared = await send('Runtime.evaluate', {
|
||||
expression: `(() => {
|
||||
const input = document.querySelector('.search-input input')
|
||||
@@ -195,7 +195,7 @@ const prepareG06State = async (send, action) => {
|
||||
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'
|
||||
const stateSelector = action === 'g06-results' ? '.search-results .genealogy-card' : action === 'g06-empty' ? '.search-empty' : action === 'g06-error' ? '.search-error' : action === 'g06-invite-invalid' ? '.invite-invalid' : '.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)}))`,
|
||||
@@ -207,6 +207,267 @@ const prepareG06State = async (send, action) => {
|
||||
}
|
||||
}
|
||||
|
||||
const waitForPageLoad = async (socket, trigger) => new Promise((resolve, reject) => {
|
||||
const timeout = setTimeout(() => {
|
||||
socket.removeEventListener('message', onMessage)
|
||||
reject(new Error('Page reload did not emit Page.loadEventFired'))
|
||||
}, 4000)
|
||||
const onMessage = (event) => {
|
||||
const message = JSON.parse(event.data)
|
||||
if (message.method !== 'Page.loadEventFired') return
|
||||
clearTimeout(timeout)
|
||||
socket.removeEventListener('message', onMessage)
|
||||
resolve()
|
||||
}
|
||||
socket.addEventListener('message', onMessage)
|
||||
Promise.resolve().then(trigger).catch((error) => {
|
||||
clearTimeout(timeout)
|
||||
socket.removeEventListener('message', onMessage)
|
||||
reject(error)
|
||||
})
|
||||
})
|
||||
|
||||
const waitForSelector = async (send, selector, message) => {
|
||||
for (let attempt = 0; attempt < 30; attempt += 1) {
|
||||
const result = await send('Runtime.evaluate', {
|
||||
expression: `Boolean(document.querySelector(${JSON.stringify(selector)}))`,
|
||||
returnByValue: true
|
||||
})
|
||||
if (result.result?.value) return
|
||||
if (attempt === 29) throw new Error(message)
|
||||
await sleep(100)
|
||||
}
|
||||
}
|
||||
|
||||
const waitForText = async (send, selector, text, message) => {
|
||||
for (let attempt = 0; attempt < 30; attempt += 1) {
|
||||
const result = await send('Runtime.evaluate', {
|
||||
expression: `document.querySelector(${JSON.stringify(selector)})?.textContent.includes(${JSON.stringify(text)}) === true`,
|
||||
returnByValue: true
|
||||
})
|
||||
if (result.result?.value) return
|
||||
if (attempt === 29) throw new Error(message)
|
||||
await sleep(50)
|
||||
}
|
||||
}
|
||||
|
||||
const prepareG03State = async (send, action) => {
|
||||
const actions = new Set([
|
||||
'g03-create-validation',
|
||||
'g03-duplicate-reminder',
|
||||
'g03-create-submitting',
|
||||
'g03-create-error',
|
||||
'g03-ancestor-validation',
|
||||
'g03-ancestor-submitting',
|
||||
'g03-ancestor-error',
|
||||
'g03-ancestor-success'
|
||||
])
|
||||
if (!actions.has(action)) return
|
||||
|
||||
if (action === 'g03-create-validation') {
|
||||
await send('Runtime.evaluate', { expression: "document.querySelector('.flow-primary-action').click()" })
|
||||
await waitForSelector(send, '.field-error', 'G03 create validation did not render')
|
||||
return
|
||||
}
|
||||
|
||||
if (action.startsWith('g03-create') || action === 'g03-duplicate-reminder') {
|
||||
const name = action === 'g03-create-error' ? '失败' : '汤氏家谱'
|
||||
const prepared = await send('Runtime.evaluate', {
|
||||
expression: `(() => {
|
||||
const inputs = document.querySelectorAll('.field-row input')
|
||||
if (inputs.length < 4) return false
|
||||
const values = ['汤', ${JSON.stringify(name)}, '', '河南·洛阳']
|
||||
inputs.forEach((input, index) => {
|
||||
input.value = values[index]
|
||||
input.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
})
|
||||
document.querySelector('.flow-primary-action').click()
|
||||
return true
|
||||
})()`,
|
||||
returnByValue: true
|
||||
})
|
||||
if (!prepared.result?.value) throw new Error(`Could not prepare ${action}`)
|
||||
await waitForSelector(send, '.duplicate-reminder', `G03 duplicate reminder did not render for ${action}`)
|
||||
if (action === 'g03-duplicate-reminder') return
|
||||
await send('Runtime.evaluate', { expression: "document.querySelector('.duplicate-reminder__confirm').click()" })
|
||||
if (action === 'g03-create-submitting') {
|
||||
await waitForText(send, '.flow-primary-action__copy', '正在创建', 'G03 create submitting state did not render')
|
||||
return
|
||||
}
|
||||
await waitForSelector(send, '.flow-error', 'G03 create error state did not render')
|
||||
return
|
||||
}
|
||||
|
||||
if (action === 'g03-ancestor-validation') {
|
||||
await send('Runtime.evaluate', { expression: "document.querySelector('.flow-primary-action').click()" })
|
||||
await waitForSelector(send, '.field-error', 'G03 ancestor validation did not render')
|
||||
return
|
||||
}
|
||||
|
||||
const personName = action === 'g03-ancestor-error' ? '失败' : '汤远'
|
||||
const prepared = await send('Runtime.evaluate', {
|
||||
expression: `(() => {
|
||||
const input = document.querySelector('.field-row input')
|
||||
if (!input) return false
|
||||
input.value = ${JSON.stringify(personName)}
|
||||
input.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
document.querySelector('.flow-primary-action').click()
|
||||
return true
|
||||
})()`,
|
||||
returnByValue: true
|
||||
})
|
||||
if (!prepared.result?.value) throw new Error(`Could not prepare ${action}`)
|
||||
if (action === 'g03-ancestor-submitting') {
|
||||
await waitForText(send, '.flow-primary-action__copy', '正在保存', 'G03 ancestor submitting state did not render')
|
||||
} else if (action === 'g03-ancestor-error') {
|
||||
await waitForSelector(send, '.flow-error', 'G03 ancestor error state did not render')
|
||||
} else {
|
||||
await waitForSelector(send, '.flow-success-layer', 'G03 ancestor success state did not render')
|
||||
}
|
||||
}
|
||||
|
||||
const prepareG08State = async (send, action) => {
|
||||
if (!['g08-validation', 'g08-submitting', 'g08-error', 'g08-success'].includes(action)) return
|
||||
if (action === 'g08-validation') {
|
||||
await send('Runtime.evaluate', { expression: "document.querySelector('.join-action').click()" })
|
||||
await waitForSelector(send, '.join-field-error', 'G08 validation errors did not render')
|
||||
return
|
||||
}
|
||||
|
||||
const realName = action === 'g08-error' ? '失败' : '汤明'
|
||||
const prepared = await send('Runtime.evaluate', {
|
||||
expression: `(() => {
|
||||
const inputs = document.querySelectorAll('.join-field input')
|
||||
if (inputs.length < 2) return false
|
||||
const values = [${JSON.stringify(realName)}, '堂侄']
|
||||
inputs.forEach((input, index) => {
|
||||
input.value = values[index]
|
||||
input.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
})
|
||||
return true
|
||||
})()`,
|
||||
returnByValue: true
|
||||
})
|
||||
if (!prepared.result?.value) throw new Error(`Could not prepare ${action}`)
|
||||
await sleep(50)
|
||||
await send('Runtime.evaluate', { expression: "document.querySelector('.join-action').click()" })
|
||||
if (action === 'g08-submitting') {
|
||||
await waitForText(send, '.join-action', '正在', 'G08 submitting state did not render')
|
||||
} else if (action === 'g08-error') {
|
||||
await waitForSelector(send, '.join-state--error', 'G08 error state did not render')
|
||||
} else {
|
||||
await waitForSelector(send, '.join-state--success', 'G08 success state did not render')
|
||||
}
|
||||
}
|
||||
|
||||
const prepareG09State = async (send, action) => {
|
||||
if (!['g09-withdraw-dialog', 'g09-withdrawn'].includes(action)) return
|
||||
await send('Runtime.evaluate', { expression: "document.querySelector('.application-card__action')?.click()" })
|
||||
await waitForSelector(send, '.app-dialog-layer', 'G09 withdrawal dialog did not render')
|
||||
if (action === 'g09-withdrawn') {
|
||||
await send('Runtime.evaluate', { expression: "document.querySelector('.app-dialog__actions .app-button:last-child')?.click()" })
|
||||
await waitForText(send, '.application-card__status', '已撤回', 'G09 withdrawn state did not render')
|
||||
}
|
||||
}
|
||||
|
||||
const prepareG10State = async (send, action) => {
|
||||
const actions = ['g10-help', 'g10-approve-dialog', 'g10-approved', 'g10-reject-dialog', 'g10-rejected']
|
||||
if (!actions.includes(action)) return
|
||||
if (action === 'g10-help') {
|
||||
await send('Runtime.evaluate', { expression: "document.querySelector('.header-action')?.click()" })
|
||||
await waitForText(send, '.app-dialog__title', '审核说明', 'G10 help dialog did not render')
|
||||
return
|
||||
}
|
||||
const isApprove = action === 'g10-approve-dialog' || action === 'g10-approved'
|
||||
const selector = isApprove ? '.review-action:last-child' : '.review-action:first-child'
|
||||
await send('Runtime.evaluate', { expression: `document.querySelector(${JSON.stringify(selector)})?.click()` })
|
||||
await waitForSelector(send, '.app-dialog-layer', 'G10 audit confirmation did not render')
|
||||
if (action === 'g10-approved' || action === 'g10-rejected') {
|
||||
await send('Runtime.evaluate', { expression: "document.querySelector('.app-dialog__actions .app-button:last-child')?.click()" })
|
||||
await waitForText(send, '.application-card__status', isApprove ? '已通过' : '已拒绝', 'G10 audit result did not render')
|
||||
}
|
||||
}
|
||||
|
||||
const prepareG11State = async (send, action) => {
|
||||
if (!['g11-validation', 'g11-saved'].includes(action)) return
|
||||
if (action === 'g11-validation') {
|
||||
await send('Runtime.evaluate', {
|
||||
expression: `(() => {
|
||||
const input = document.querySelector('.settings-field input')
|
||||
if (!input) return false
|
||||
input.value = ''
|
||||
input.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
document.querySelector('.settings-action')?.click()
|
||||
return true
|
||||
})()`,
|
||||
returnByValue: true
|
||||
})
|
||||
await waitForSelector(send, '.settings-field-error', 'G11 validation state did not render')
|
||||
return
|
||||
}
|
||||
await send('Runtime.evaluate', { expression: "document.querySelector('.settings-action')?.click()" })
|
||||
await waitForSelector(send, '.settings-state--success', 'G11 saved state did not render')
|
||||
await waitForSelector(send, '.settings-feedback', 'G11 saved feedback did not render')
|
||||
}
|
||||
|
||||
const prepareG12State = async (send, action) => {
|
||||
if (!['g12-validation', 'g12-save-error', 'g12-saved'].includes(action)) return
|
||||
const value = action === 'g12-validation' ? '' : action === 'g12-save-error' ? '失败' : '启宗敦本继世传芳'
|
||||
await send('Runtime.evaluate', {
|
||||
expression: `(() => {
|
||||
const textarea = document.querySelector('.poem-field textarea')
|
||||
if (!textarea) return false
|
||||
textarea.value = ${JSON.stringify(value)}
|
||||
textarea.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
document.querySelector('.poem-editor__actions .poem-action:last-child')?.click()
|
||||
return true
|
||||
})()`,
|
||||
returnByValue: true
|
||||
})
|
||||
if (action === 'g12-saved') {
|
||||
await waitForSelector(send, '.poem-state--list', 'G12 saved list did not render')
|
||||
await waitForSelector(send, '.poem-feedback', 'G12 saved feedback did not render')
|
||||
} else {
|
||||
await waitForSelector(send, '.poem-field-error', 'G12 validation or save error did not render')
|
||||
}
|
||||
}
|
||||
|
||||
const prepareT03T06State = async (send, action) => {
|
||||
if (action !== 't06-conflict-help') return
|
||||
await send('Runtime.evaluate', { expression: "document.querySelector('.conflict-actions .member-form-action:last-child')?.click()" })
|
||||
await waitForSelector(send, '.app-dialog-layer', 'T06 conflict help dialog did not render')
|
||||
}
|
||||
|
||||
const prepareFSeriesState = async (send, action) => {
|
||||
if (action === 'f02-validation') {
|
||||
await send('Runtime.evaluate', { expression: "document.querySelector('.publish-form .app-button')?.click()" })
|
||||
await waitForSelector(send, '.app-toast', 'F02 empty-content toast did not render')
|
||||
return
|
||||
}
|
||||
if (action !== 'module-preview-toast') return
|
||||
await send('Runtime.evaluate', { expression: "(document.querySelector('.list-card') || document.querySelector('.settings-row'))?.click()" })
|
||||
await waitForSelector(send, '.app-toast', 'ModulePage preview toast did not render')
|
||||
}
|
||||
|
||||
const prepareNSeriesState = async (send, action) => {
|
||||
if (action === 'n01-read-first') {
|
||||
await send('Runtime.evaluate', { expression: "document.querySelector('.notice-card')?.click()" })
|
||||
for (let attempt = 0; attempt < 30; attempt += 1) {
|
||||
const result = await send('Runtime.evaluate', {
|
||||
expression: "document.querySelector('.notice-card')?.textContent.includes('未读提醒') === false",
|
||||
returnByValue: true
|
||||
})
|
||||
if (result.result?.value) return
|
||||
if (attempt === 29) throw new Error('N01 first notice did not enter the read state')
|
||||
await sleep(50)
|
||||
}
|
||||
return
|
||||
}
|
||||
if (action !== 'n01-mark-all') return
|
||||
await send('Runtime.evaluate', { expression: "document.querySelector('.header-action')?.click()" })
|
||||
await waitForSelector(send, '.app-toast', 'N01 mark-all-read toast did not render')
|
||||
}
|
||||
|
||||
const capture = async () => {
|
||||
const { socket, send } = await connect()
|
||||
const viewportWidth = Number(width)
|
||||
@@ -231,7 +492,7 @@ const capture = async () => {
|
||||
await send('Page.navigate', { url })
|
||||
await waitForRequestedUrl(send, url)
|
||||
if (action !== 'fresh-navigation') {
|
||||
await send('Page.reload')
|
||||
await waitForPageLoad(socket, () => send('Page.reload'))
|
||||
await waitForFreshDocument(send, documentTimeOrigin)
|
||||
}
|
||||
|
||||
@@ -247,9 +508,18 @@ const capture = async () => {
|
||||
|
||||
await prepareA01LoginState(send, action)
|
||||
await prepareG06State(send, action)
|
||||
await prepareG03State(send, action)
|
||||
await prepareG08State(send, action)
|
||||
await prepareG09State(send, action)
|
||||
await prepareG10State(send, action)
|
||||
await prepareG11State(send, action)
|
||||
await prepareG12State(send, action)
|
||||
await prepareT03T06State(send, action)
|
||||
await prepareFSeriesState(send, action)
|
||||
await prepareNSeriesState(send, action)
|
||||
|
||||
await waitForLoadedImages(send)
|
||||
await sleep(renderSettleMilliseconds)
|
||||
await sleep(action?.endsWith('-submitting') ? 80 : renderSettleMilliseconds)
|
||||
const screenshot = await send('Page.captureScreenshot', { format: 'png', fromSurface: true, captureBeyondViewport: false })
|
||||
fs.mkdirSync(path.dirname(output), { recursive: true })
|
||||
fs.writeFileSync(output, Buffer.from(screenshot.data, 'base64'))
|
||||
|
||||
|
After Width: | Height: | Size: 1.4 MiB |
|
After Width: | Height: | Size: 1.4 MiB |
|
After Width: | Height: | Size: 1.0 MiB |
|
After Width: | Height: | Size: 1.0 MiB |
|
After Width: | Height: | Size: 1.4 MiB |
|
After Width: | Height: | Size: 1.4 MiB |
|
After Width: | Height: | Size: 249 KiB |
|
After Width: | Height: | Size: 1.0 MiB |
|
After Width: | Height: | Size: 249 KiB |
|
After Width: | Height: | Size: 249 KiB |
|
After Width: | Height: | Size: 249 KiB |
|
After Width: | Height: | Size: 1.0 MiB |
|
After Width: | Height: | Size: 1.0 MiB |
|
After Width: | Height: | Size: 1.0 MiB |
|
After Width: | Height: | Size: 249 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 249 KiB |
|
After Width: | Height: | Size: 1.0 MiB |
@@ -0,0 +1,256 @@
|
||||
const fs = require('fs')
|
||||
const path = require('path')
|
||||
|
||||
const origin = 'http://localhost:5173'
|
||||
const outputDirectory = path.resolve('docs/design/screens/runtime/2026-07-19/g01-approval')
|
||||
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 pages = await (await fetch('http://127.0.0.1:9222/json/list')).json()
|
||||
const projectPages = pages.filter((page) => page.type === 'page' && page.url.startsWith(origin))
|
||||
if (projectPages.length !== 1) throw new Error(`Expected one project page, found ${projectPages.length}`)
|
||||
|
||||
const socket = new WebSocket(projectPages[0].webSocketDebuggerUrl)
|
||||
await new Promise((resolve, reject) => {
|
||||
socket.addEventListener('open', resolve, { once: true })
|
||||
socket.addEventListener('error', reject, { once: true })
|
||||
})
|
||||
|
||||
let id = 0
|
||||
const pending = new Map()
|
||||
socket.addEventListener('message', (event) => {
|
||||
const message = JSON.parse(event.data)
|
||||
const request = pending.get(message.id)
|
||||
if (!request) return
|
||||
pending.delete(message.id)
|
||||
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 { projectPageCount: projectPages.length, socket, send }
|
||||
}
|
||||
|
||||
const valueOf = async (send, expression) => {
|
||||
const result = await send('Runtime.evaluate', { expression, returnByValue: true })
|
||||
if (result.exceptionDetails) throw new Error(result.exceptionDetails.text || 'Runtime evaluation failed')
|
||||
return result.result?.value
|
||||
}
|
||||
|
||||
const waitFor = async (send, expression, message) => {
|
||||
for (let attempt = 0; attempt < 50; attempt += 1) {
|
||||
if (await valueOf(send, expression)) return
|
||||
await sleep(100)
|
||||
}
|
||||
throw new Error(message)
|
||||
}
|
||||
|
||||
const setSize = async (send, width, height) => {
|
||||
await send('Emulation.setDeviceMetricsOverride', {
|
||||
width,
|
||||
height,
|
||||
deviceScaleFactor: 1,
|
||||
mobile: true,
|
||||
screenWidth: width,
|
||||
screenHeight: height
|
||||
})
|
||||
await sleep(150)
|
||||
}
|
||||
|
||||
const openSwitcher = async (send) => {
|
||||
if (!await valueOf(send, "Boolean(document.querySelector('.genealogy-switcher'))")) {
|
||||
const clicked = await valueOf(send, `(() => {
|
||||
const trigger = document.querySelector('.current-slip')
|
||||
if (!trigger) return false
|
||||
trigger.click()
|
||||
return true
|
||||
})()`)
|
||||
if (!clicked) throw new Error('Could not find the switch genealogy trigger')
|
||||
}
|
||||
await waitFor(send, "Boolean(document.querySelector('.genealogy-switcher'))", 'Switcher did not open')
|
||||
}
|
||||
|
||||
const clearClones = (send) => valueOf(send, `(() => {
|
||||
document.querySelectorAll('[data-cdp-clone="1"]').forEach((node) => node.remove())
|
||||
return true
|
||||
})()`)
|
||||
|
||||
const addClonesToTotal = (send, total) => valueOf(send, `(() => {
|
||||
document.querySelectorAll('[data-cdp-clone="1"]').forEach((node) => node.remove())
|
||||
const list = document.querySelector('.genealogy-switcher__list')
|
||||
const originals = Array.from(list?.querySelectorAll('.switcher-item') || [])
|
||||
if (!list || originals.length !== 2) return false
|
||||
for (let index = originals.length; index < ${total}; index += 1) {
|
||||
const clone = originals[index % originals.length].cloneNode(true)
|
||||
clone.dataset.cdpClone = '1'
|
||||
clone.querySelector('.switcher-item__name').textContent = '压力测试家谱 ' + (index + 1)
|
||||
clone.querySelector('.switcher-item__state').textContent = '选择'
|
||||
clone.classList.remove('switcher-item--active')
|
||||
list.appendChild(clone)
|
||||
}
|
||||
return true
|
||||
})()`)
|
||||
|
||||
const getMetrics = (send) => valueOf(send, `(() => {
|
||||
const dialog = document.querySelector('.genealogy-switcher')
|
||||
const content = document.querySelector('.genealogy-switcher__content')
|
||||
const title = document.querySelector('.dialog-title')
|
||||
const close = document.querySelector('.genealogy-switcher__close')
|
||||
const list = document.querySelector('.genealogy-switcher__list')
|
||||
const items = Array.from(document.querySelectorAll('.genealogy-switcher__list .switcher-item'))
|
||||
const rect = (node) => node ? { top: node.getBoundingClientRect().top, right: node.getBoundingClientRect().right, bottom: node.getBoundingClientRect().bottom, left: node.getBoundingClientRect().left, width: node.getBoundingClientRect().width, height: node.getBoundingClientRect().height } : null
|
||||
const last = items.at(-1)
|
||||
return {
|
||||
viewport: { width: innerWidth, height: innerHeight },
|
||||
dialog: rect(dialog),
|
||||
content: rect(content),
|
||||
title: rect(title),
|
||||
close: rect(close),
|
||||
list: rect(list),
|
||||
itemCount: items.length,
|
||||
cloneCount: document.querySelectorAll('[data-cdp-clone="1"]').length,
|
||||
listClientHeight: list?.clientHeight || 0,
|
||||
listScrollHeight: list?.scrollHeight || 0,
|
||||
listScrollTop: list?.scrollTop || 0,
|
||||
blankBelowLastItem: list && last ? list.getBoundingClientRect().bottom - last.getBoundingClientRect().bottom : null,
|
||||
horizontalOverflow: document.documentElement.scrollWidth > innerWidth || document.body.scrollWidth > innerWidth,
|
||||
addDialogVisible: Boolean(document.querySelector('.add-genealogy-sheet')),
|
||||
switcherVisible: Boolean(dialog),
|
||||
borderImageSource: dialog ? getComputedStyle(dialog).borderImageSource : '',
|
||||
borderImageSlice: dialog ? getComputedStyle(dialog).borderImageSlice : ''
|
||||
}
|
||||
})()`)
|
||||
|
||||
const capture = async (send, filename) => {
|
||||
const screenshot = await send('Page.captureScreenshot', {
|
||||
format: 'png',
|
||||
fromSurface: true,
|
||||
captureBeyondViewport: false
|
||||
})
|
||||
fs.mkdirSync(outputDirectory, { recursive: true })
|
||||
fs.writeFileSync(path.join(outputDirectory, filename), Buffer.from(screenshot.data, 'base64'))
|
||||
}
|
||||
|
||||
const assert = (condition, message) => {
|
||||
if (!condition) throw new Error(message)
|
||||
}
|
||||
|
||||
const run = async () => {
|
||||
const { projectPageCount, socket, send } = await connect()
|
||||
const runtimeErrors = []
|
||||
const resourceErrors = []
|
||||
|
||||
socket.addEventListener('message', (event) => {
|
||||
const message = JSON.parse(event.data)
|
||||
if (message.method === 'Runtime.exceptionThrown') runtimeErrors.push(message.params.exceptionDetails?.text || 'runtime exception')
|
||||
if (message.method === 'Network.responseReceived' && message.params.response.status >= 400) {
|
||||
resourceErrors.push(`${message.params.response.status} ${message.params.response.url}`)
|
||||
}
|
||||
})
|
||||
|
||||
try {
|
||||
await send('Page.enable')
|
||||
await send('Runtime.enable')
|
||||
await send('Network.enable')
|
||||
await setSize(send, 412, 915)
|
||||
await send('Page.reload')
|
||||
await waitFor(send, "Boolean(document.querySelector('.current-slip'))", 'G01 did not render after reload')
|
||||
await openSwitcher(send)
|
||||
|
||||
const sizeResults = []
|
||||
for (const size of sizes) {
|
||||
await setSize(send, size.width, size.height)
|
||||
await clearClones(send)
|
||||
await openSwitcher(send)
|
||||
const metrics = await getMetrics(send)
|
||||
const rpx = size.width / 750
|
||||
assert(metrics.itemCount === 2, `${size.width}x${size.height}: expected two items`)
|
||||
const expectedDialogHeight = (600 * rpx) + 2
|
||||
assert(Math.abs(metrics.dialog.height - expectedDialogHeight) < 1, `${size.width}x${size.height}: dialog height ${metrics.dialog.height}px is not the expected ${expectedDialogHeight}px including its 1px border`)
|
||||
assert(metrics.title.top > metrics.dialog.top + (78 * rpx), `${size.width}x${size.height}: title overlaps top decoration`)
|
||||
assert(metrics.close.top >= metrics.dialog.top && metrics.close.right <= metrics.dialog.right + 1, `${size.width}x${size.height}: close control is clipped`)
|
||||
assert(metrics.listScrollHeight <= metrics.listClientHeight + 1, `${size.width}x${size.height}: two-item list unexpectedly scrolls`)
|
||||
assert(metrics.blankBelowLastItem <= (72 * rpx) + 2, `${size.width}x${size.height}: too much blank space below last item`)
|
||||
assert(!metrics.horizontalOverflow, `${size.width}x${size.height}: horizontal overflow`)
|
||||
sizeResults.push(metrics)
|
||||
await capture(send, `06-switch-dialog-stretchable-${size.width}x${size.height}.png`)
|
||||
}
|
||||
|
||||
await setSize(send, 412, 915)
|
||||
assert(await addClonesToTotal(send, 6), 'Could not prepare six-item stress state')
|
||||
await sleep(100)
|
||||
const sixItems = await getMetrics(send)
|
||||
assert(sixItems.itemCount === 6, 'Six-item state did not contain six items')
|
||||
assert(sixItems.dialog.height > sizeResults.at(-1).dialog.height + 1, 'Six-item dialog did not grow')
|
||||
assert(sixItems.dialog.height < 915 - ((120 * 412) / 750) - 1, 'Six-item dialog reached the maximum height too early')
|
||||
assert(sixItems.listScrollHeight <= sixItems.listClientHeight + 1, 'Six-item list unexpectedly scrolls')
|
||||
await capture(send, '06-switch-dialog-stretchable-six-items-412x915.png')
|
||||
|
||||
assert(await addClonesToTotal(send, 12), 'Could not prepare twelve-item stress state')
|
||||
await sleep(100)
|
||||
const twelveItemsTop = await getMetrics(send)
|
||||
const expectedMaximumHeight = 915 - ((120 * 412) / 750)
|
||||
assert(Math.abs(twelveItemsTop.dialog.height - expectedMaximumHeight) < 3, `Twelve-item dialog height ${twelveItemsTop.dialog.height}px did not stop at the expected ${expectedMaximumHeight}px safe maximum: ${JSON.stringify(twelveItemsTop)}`)
|
||||
assert(twelveItemsTop.listScrollHeight > twelveItemsTop.listClientHeight + 1, 'Twelve-item list does not scroll')
|
||||
assert(twelveItemsTop.listScrollTop === 0, 'Twelve-item list did not start at the top')
|
||||
await valueOf(send, `(() => {
|
||||
const list = document.querySelector('.genealogy-switcher__list')
|
||||
list.scrollTop = list.scrollHeight
|
||||
return true
|
||||
})()`)
|
||||
await sleep(50)
|
||||
const twelveItemsBottom = await getMetrics(send)
|
||||
assert(twelveItemsBottom.listScrollTop > 0, 'Twelve-item list did not scroll to the bottom')
|
||||
|
||||
await clearClones(send)
|
||||
await valueOf(send, "document.querySelector('.genealogy-switcher__content').click()")
|
||||
assert(await valueOf(send, "Boolean(document.querySelector('.genealogy-switcher'))"), 'Inner click closed the switcher')
|
||||
await valueOf(send, "document.querySelector('.genealogy-switcher__close').click()")
|
||||
assert(!await valueOf(send, "Boolean(document.querySelector('.genealogy-switcher'))"), 'Close icon did not close the switcher')
|
||||
|
||||
await openSwitcher(send)
|
||||
await valueOf(send, "document.querySelector('.genealogy-switcher-layer').click()")
|
||||
assert(!await valueOf(send, "Boolean(document.querySelector('.genealogy-switcher'))"), 'Mask did not close the switcher')
|
||||
|
||||
await openSwitcher(send)
|
||||
await valueOf(send, "document.querySelectorAll('.switcher-item')[1].click()")
|
||||
await waitFor(send, "!document.querySelector('.genealogy-switcher')", 'Selecting the second genealogy did not close the switcher')
|
||||
assert((await valueOf(send, "document.querySelector('.current-slip')?.textContent"))?.includes('山东'), 'Selecting the second genealogy did not update the current genealogy')
|
||||
|
||||
await openSwitcher(send)
|
||||
await valueOf(send, "document.querySelectorAll('.switcher-item')[0].click()")
|
||||
await waitFor(send, "!document.querySelector('.genealogy-switcher')", 'Restoring the first genealogy did not close the switcher')
|
||||
await openSwitcher(send)
|
||||
await clearClones(send)
|
||||
|
||||
const final = await getMetrics(send)
|
||||
assert(final.viewport.width === 412 && final.viewport.height === 915, 'Final viewport is not 412x915')
|
||||
assert(final.itemCount === 2 && final.cloneCount === 0, 'Final state contains temporary items')
|
||||
assert(final.switcherVisible && !final.addDialogVisible, 'Final state does not show only the switcher')
|
||||
assert(!final.horizontalOverflow, 'Final state has horizontal overflow')
|
||||
assert(runtimeErrors.length === 0, `Runtime exceptions: ${runtimeErrors.join(' | ')}`)
|
||||
assert(resourceErrors.length === 0, `Resource errors: ${resourceErrors.join(' | ')}`)
|
||||
|
||||
process.stdout.write(`${JSON.stringify({ projectPageCount, sizeResults, sixItems, twelveItemsTop, twelveItemsBottom, final, runtimeErrors, resourceErrors }, null, 2)}\n`)
|
||||
process.stdout.write('PASS G-01 switch dialog runtime smoke\n')
|
||||
} finally {
|
||||
socket.close()
|
||||
}
|
||||
}
|
||||
|
||||
run().catch((error) => {
|
||||
process.stderr.write(`${error.stack || error.message}\n`)
|
||||
process.exit(1)
|
||||
})
|
||||
@@ -25,6 +25,10 @@ $requiredAssets = @(
|
||||
'static/assets/foundation/transparent/tab-profile-active.png',
|
||||
'static/assets/modules/genealogy/transparent/current-slip-frame.png',
|
||||
'static/assets/modules/genealogy/transparent/list-slip-frame.png',
|
||||
'static/assets/modules/genealogy/transparent/g01-dialog-close.png',
|
||||
'static/assets/modules/genealogy/transparent/g01-add-sheet-background-v3.png',
|
||||
'static/assets/modules/auth/transparent/a01-paper-transition-v1.png',
|
||||
'static/assets/foundation/opaque/page-paper.jpg',
|
||||
'static/assets/foundation/opaque/root-header-cinnabar.jpg',
|
||||
'static/assets/modules/genealogy/opaque/genealogy-page-background-long.png',
|
||||
'static/assets/modules/genealogy/transparent/section-divider.png'
|
||||
@@ -65,6 +69,113 @@ foreach ($state in @('hasError', 'applicationRecords', 'addDialogVisible', 'swit
|
||||
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" }
|
||||
}
|
||||
$closeCopy = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('5YWz6Zet'))
|
||||
$cancelCopy = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('5Y+W5raI'))
|
||||
$searchCopy = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('5pCc57Si5a626LCx'))
|
||||
$inviteCopy = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('6YKA6K+356CB5Yqg5YWl'))
|
||||
$continueCreateCopy = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('57un57ut5Yib5bu65a626LCx'))
|
||||
$legacyCreateCopy = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('56Gu6K6k5rKh5pyJ546w5pyJ5a626LCx77yM57un57ut5Yib5bu6'))
|
||||
$addMarkup = [regex]::Match($page, '(?s)<view v-if="addDialogVisible".*?<view v-if="switcherVisible"').Value
|
||||
if (-not $addMarkup) { throw 'G-01 add sheet markup could not be isolated.' }
|
||||
foreach ($token in @(
|
||||
'class="add-dialog__body"',
|
||||
'class="add-dialog__close-icon"',
|
||||
'class="add-dialog__actions"',
|
||||
'g01-dialog-close.png',
|
||||
"aria-label=`"$closeCopy`""
|
||||
)) {
|
||||
if ($addMarkup -notmatch [regex]::Escape($token)) { throw "G-01 paper sheet is missing $token" }
|
||||
}
|
||||
if ($addMarkup -notmatch '(?s)<view class="add-dialog__body">\s*<view class="add-dialog__heading">.*?class="add-dialog__close".*?</view>\s*</view>\s*<view class="add-dialog__actions">') {
|
||||
throw 'G-01 add sheet must keep the close control with the centered heading and actions body.'
|
||||
}
|
||||
if ($addMarkup -match 'add-dialog__paper|add-dialog__edge|page-paper\.jpg|a01-paper-transition-v1\.png') {
|
||||
throw 'G-01 add sheet still assembles its background from separate paper and edge layers.'
|
||||
}
|
||||
if ($addMarkup -match 'a01-scroll-dialog-v3\.png') { throw 'G-01 add sheet still uses the rejected complete dialog frame.' }
|
||||
foreach ($copy in @($closeCopy, $cancelCopy)) {
|
||||
if ($addMarkup -match [regex]::Escape(">$copy</view>")) { throw 'G-01 add sheet still renders a text close control.' }
|
||||
}
|
||||
foreach ($copy in @($searchCopy, $inviteCopy, $continueCreateCopy)) {
|
||||
$token = "label=`"$copy`""
|
||||
if ($addMarkup -notmatch [regex]::Escape($token)) { throw "G-01 add sheet no longer reuses AppButton action $token" }
|
||||
}
|
||||
if ($addMarkup -match [regex]::Escape("label=`"$legacyCreateCopy`"")) {
|
||||
throw 'G-01 add sheet still puts guidance copy inside the create button.'
|
||||
}
|
||||
if ($page -notmatch '(?s)\.add-dialog-layer\s*\{[^}]*align-items:\s*flex-end;[^}]*background:\s*rgba\(34,\s*20,\s*12,\s*0\.68\);') {
|
||||
throw 'G-01 add sheet is not a bottom-aligned layer with the approved mask.'
|
||||
}
|
||||
if ($page -notmatch '(?s)\.add-dialog__close\s*\{[^}]*width:\s*80rpx;[^}]*height:\s*80rpx;') {
|
||||
throw 'G-01 add sheet close control does not reserve the approved hit area.'
|
||||
}
|
||||
if ($page -notmatch '(?s)\.add-dialog\s*\{[^}]*min-height:\s*780rpx;[^}]*max-height:\s*calc\(100vh\s*-\s*80rpx\);') {
|
||||
throw 'G-01 add sheet does not reach the approved arrow-aligned minimum height.'
|
||||
}
|
||||
if ($page -notmatch '(?s)\.add-dialog__content\s*\{[^}]*min-height:\s*780rpx;[^}]*max-height:\s*calc\(100vh\s*-\s*80rpx\);[^}]*padding:\s*96rpx\s+52rpx\s+calc\(96rpx\s*\+\s*env\(safe-area-inset-bottom\)\);') {
|
||||
throw 'G-01 add sheet does not reserve the approved symmetric centering area.'
|
||||
}
|
||||
if ($page -notmatch '(?s)\.add-dialog__body\s*\{[^}]*margin:\s*auto\s+0;') {
|
||||
throw 'G-01 add sheet body does not center safely with collapsible auto margins.'
|
||||
}
|
||||
if ($page -notmatch '(?s)\.add-dialog__heading\s*\{[^}]*position:\s*relative;[^}]*padding-right:\s*96rpx;') {
|
||||
throw 'G-01 add sheet heading does not own the close-control positioning context.'
|
||||
}
|
||||
if ($page -match '(?s)\.add-dialog__heading\s*\{[^}]*top\s*:') {
|
||||
throw 'G-01 add sheet heading must use real flow spacing instead of a visual offset.'
|
||||
}
|
||||
if ($page -notmatch '(?s)\.add-dialog__close\s*\{[^}]*position:\s*absolute;[^}]*top:\s*0;[^}]*right:\s*-22rpx;') {
|
||||
throw 'G-01 add sheet close control does not follow the centered heading.'
|
||||
}
|
||||
if ($page -notmatch [regex]::Escape('/static/assets/modules/genealogy/transparent/g01-add-sheet-background-v3.png')) {
|
||||
throw 'G-01 add sheet does not use the approved complete background asset.'
|
||||
}
|
||||
if ($page -notmatch '(?s)\.add-dialog\s*\{[^}]*border-image-source:\s*url\("/static/assets/modules/genealogy/transparent/g01-add-sheet-background-v3\.png"\);[^}]*border-image-slice:\s*220\s+0\s+1\s+0\s+fill;[^}]*border-image-width:\s*118rpx\s+0\s+1rpx;') {
|
||||
throw 'G-01 add sheet does not preserve the complete sheet top while stretching only the paper body.'
|
||||
}
|
||||
if ($page -notmatch '(?s)\.add-dialog__close-icon\s*\{[^}]*width:\s*80rpx;[^}]*height:\s*80rpx;') {
|
||||
throw 'G-01 add sheet close icon canvas does not compensate for the asset transparent padding.'
|
||||
}
|
||||
if ($page -match '(?s)\.add-dialog\s+\.app-button\s*\{') { throw 'G-01 add sheet must not restyle the existing AppButton.' }
|
||||
if ($page -notmatch '(?s)\.add-dialog__actions\s*\{[^}]*margin:\s*62rpx\s+-32rpx\s+0;') {
|
||||
throw 'G-01 add sheet actions do not compensate for the existing button assets transparent side padding.'
|
||||
}
|
||||
if ($page -notmatch '(?s)\.add-dialog__actions\s*>\s*\.app-button\s*\{[^}]*width:\s*595rpx;[^}]*max-width:\s*100%;[^}]*min-height:\s*96rpx;') {
|
||||
throw 'G-01 add sheet buttons do not keep the approved equal-width geometry.'
|
||||
}
|
||||
if ($page -notmatch '(?s)\.add-dialog__actions\s*\{[^}]*align-items:\s*center;') {
|
||||
throw 'G-01 add sheet buttons are not centered after equal-width sizing.'
|
||||
}
|
||||
if ($page -notmatch '(?s)\.add-dialog__actions\s*>\s*\.app-button\s*\+\s*\.app-button\s*\{[^}]*margin-top:\s*24rpx;') {
|
||||
throw 'G-01 add sheet buttons do not keep the approved 24rpx spacing.'
|
||||
}
|
||||
if ($page -notmatch '(?s)\.genealogy-switcher-layer\s*\{[^}]*align-items:\s*center;[^}]*padding:\s*40rpx;') {
|
||||
throw 'G-01 switcher must remain a centered dialog.'
|
||||
}
|
||||
if ($page -match 'class="genealogy-switcher__skin"') {
|
||||
throw 'G-01 switcher must not render the complete background as a fixed aspectFit image.'
|
||||
}
|
||||
if ($page -notmatch '(?s)\.genealogy-switcher\s*\{[^}]*min-height:\s*600rpx;[^}]*max-height:\s*calc\(100vh\s*-\s*120rpx\);[^}]*border-image-source:\s*url\("/static/assets/modules/auth/transparent/a01-scroll-dialog-v3\.png"\);[^}]*border-image-slice:\s*300\s+260\s+360\s+260\s+fill;[^}]*border-image-width:\s*110rpx\s+48rpx\s+132rpx\s+48rpx;') {
|
||||
throw 'G-01 switcher does not use the approved stretchable complete background.'
|
||||
}
|
||||
if ($page -notmatch '(?s)\.genealogy-switcher__content\s*\{[^}]*min-height:\s*600rpx;[^}]*max-height:\s*calc\(100vh\s*-\s*120rpx\);[^}]*padding:\s*120rpx\s+58rpx\s+140rpx;') {
|
||||
throw 'G-01 switcher content does not keep the approved decoration safe area.'
|
||||
}
|
||||
if ($page -notmatch 'class="genealogy-switcher__list"[^>]*scroll-y') {
|
||||
throw 'G-01 switcher does not provide an independent scroll list.'
|
||||
}
|
||||
if ($page -notmatch '(?s)class="genealogy-switcher__close"[^>]*aria-label="\u5173\u95ed".*?g01-dialog-close\.png') {
|
||||
throw 'G-01 switcher does not use the custom accessible close control.'
|
||||
}
|
||||
if ($page -match 'class="dialog-close"[^>]*@click="closeSwitcher"') {
|
||||
throw 'G-01 switcher still exposes the obsolete bottom close copy.'
|
||||
}
|
||||
if ($page -notmatch '(?s)onBackPress\(\(\)\s*=>\s*\{\s*if\s*\(switcherVisible\.value\)\s*\{\s*closeSwitcher\(\);\s*return\s*true;\s*\}\s*if\s*\(addDialogVisible\.value\)') {
|
||||
throw 'G-01 switcher does not consume Android back before the add dialog and page navigation.'
|
||||
}
|
||||
if ($page -notmatch 'import\s*\{[^}]*onBackPress[^}]*\}\s*from\s*"@dcloudio/uni-app"') {
|
||||
throw 'G-01 add sheet does not import onBackPress.'
|
||||
}
|
||||
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" }
|
||||
}
|
||||
@@ -78,8 +189,9 @@ foreach ($token in @('current-summary', 'current-meta-item', 'current-seal-frame
|
||||
if ($page -notmatch 'width:\s*82rpx') { throw 'G-01 shortcut icons remain below the reference visual size.' }
|
||||
if ($page -notmatch '<GenealogyPageBackground\s*/>') { throw 'G-01 does not consume the shared genealogy background component.' }
|
||||
if ($page -notmatch 'section-divider\.png') { throw 'G-01 does not consume the approved section divider asset.' }
|
||||
$pageWithoutAddMarkup = $page.Replace($addMarkup, '')
|
||||
foreach ($legacyBackground in @('page-paper.jpg', 'footer-mountain-bamboo.png', 'page-paper-texture', 'page-footer-landscape')) {
|
||||
if ($page -match [regex]::Escape($legacyBackground)) { throw "G-01 must replace the legacy layered background: $legacyBackground" }
|
||||
if ($pageWithoutAddMarkup -match [regex]::Escape($legacyBackground)) { throw "G-01 must replace the legacy layered page background: $legacyBackground" }
|
||||
}
|
||||
$backgroundComponent = Get-Content -Raw -Encoding UTF8 (Join-Path $root 'components/GenealogyPageBackground.vue')
|
||||
if ($backgroundComponent -notmatch 'mode="widthFix"') { throw 'G shared background must preserve the complete C artwork without horizontal cropping.' }
|
||||
@@ -127,8 +239,22 @@ if ($card -match 'calendar-v1\.png') { throw 'G-01 list shows a calendar icon th
|
||||
if ($card -match 'genealogy\.surname') { throw 'G-01 list seal still shows a surname instead of the fixed 家谱 seal.' }
|
||||
if ($card -match 'card-copy') { throw 'G-01 list still retains the superseded split content column.' }
|
||||
if ($card -notmatch '(?s)\.card-title-row\s*\{.*?justify-content:\s*space-between') { throw 'G-01 list title, role, and chevron do not share the required first row.' }
|
||||
if ($card -notmatch '(?s)\.card-meta\s*\{.*?font-size:\s*26rpx') { throw 'G-01 list metadata is below the approved readable size.' }
|
||||
if ($card -notmatch '(?s)\.card-updated\s*\{.*?font-size:\s*22rpx') { throw 'G-01 list update date is below the approved readable size.' }
|
||||
if ($page -notmatch '(?s)\.current-switch-copy\s*\{[^}]*font-size:\s*28rpx') { throw 'G-01 switch copy does not use the approved 28rpx size.' }
|
||||
if ($page -notmatch '(?s)\.current-meta\s*\{[^}]*font-size:\s*27rpx') { throw 'G-01 current genealogy metadata must retain its original 27rpx size.' }
|
||||
if ($page -notmatch '(?s)\.current-meta-icon\s*\{[^}]*width:\s*36rpx;[^}]*height:\s*36rpx;[^}]*opacity:\s*1;') { throw 'G-01 current genealogy metadata icons do not use the approved 36rpx size and full opacity.' }
|
||||
if ($page -notmatch '(?s)@media screen and \(max-width:\s*340px\)[^{]*\{.*?\.current-meta-icon\s*\{[^}]*width:\s*32rpx;[^}]*height:\s*32rpx;') { throw 'G-01 compact viewport metadata icons do not preserve the approved 32rpx size.' }
|
||||
if ($card -notmatch '(?s)\.card-meta-icon\s*\{[^}]*width:\s*46rpx;[^}]*height:\s*46rpx;[^}]*flex:\s*0 0 auto;[^}]*opacity:\s*1;[^}]*filter:\s*saturate\(1\.35\) brightness\(0\.82\) contrast\(1\.15\);') { throw 'G-01 list location and member icons do not use the approved balanced size and contrast.' }
|
||||
if ($card -notmatch '(?s)\.card-meta-item\s*\{[^}]*margin-right:\s*0;') { throw 'G-01 list metadata groups still retain the superseded trailing margin.' }
|
||||
if ($card -notmatch '(?s)\.card-meta-item\s*\+\s*\.card-meta-item\s*\{[^}]*margin-left:\s*18rpx;') { throw 'G-01 list metadata groups do not keep the approved 18rpx separation.' }
|
||||
if ($card -notmatch '(?s)\.genealogy-card\s*\{[^}]*min-height:\s*178rpx;') { throw 'G-01 list cards do not reserve the approved height for the update-date row.' }
|
||||
if ($card -notmatch '(?s)\.card-detail-row\s*\{[^}]*flex-wrap:\s*wrap;') { throw 'G-01 list detail row does not allow the update date to occupy its own row.' }
|
||||
if ($card -notmatch '(?s)<view class="card-detail-row">\s*<view class="card-updated">.*?</view>\s*<view class="card-metas">') { throw 'G-01 update date must precede metadata in the approved visual and DOM order.' }
|
||||
if ($card -notmatch '(?s)\.card-updated\s*\{[^}]*width:\s*100%;[^}]*justify-content:\s*flex-end;[^}]*margin-top:\s*0;[^}]*margin-left:\s*0;') { throw 'G-01 update date does not use the approved right-aligned second row.' }
|
||||
if ($card -notmatch '(?s)\.card-metas\s*\{[^}]*width:\s*100%;[^}]*margin-top:\s*2rpx;') { throw 'G-01 list metadata does not use the approved third row.' }
|
||||
if ($card -notmatch '(?s)@media screen and \(max-width:\s*340px\)[^{]*\{.*?\.genealogy-card\s*\{[^}]*min-height:\s*148rpx;') { throw 'G-01 compact list cards must retain their original 148rpx height.' }
|
||||
if ($card -notmatch '(?s)\.card-meta\s*\{[^}]*font-size:\s*26rpx') { throw 'G-01 list metadata must retain its original 26rpx size.' }
|
||||
if ($card -notmatch '(?s)\.card-updated\s*\{[^}]*font-size:\s*24rpx') { throw 'G-01 list update date must retain its original 24rpx size.' }
|
||||
if ($card -notmatch '(?s)\.card-role\s*\{[^}]*font-size:\s*26rpx') { throw 'G-01 list role must retain its original 26rpx size.' }
|
||||
if ($page -notmatch '(?s)\.create-action\s*>\s*text\s*\{.*?white-space:\s*nowrap') { throw 'G-01 create action text can wrap onto multiple lines.' }
|
||||
if ($page -notmatch '(?s)\.application-record\s*\+\s*\.application-record\s*\{[^}]*margin-top:\s*12rpx') { throw 'G-01 adjacent application cards must keep the approved 12rpx separation.' }
|
||||
if ($page -notmatch '(?s)\.application-record__copy\s*\{[^}]*color:\s*#62584c;[^}]*font-size:\s*28rpx;[^}]*font-weight:\s*500;[^}]*line-height:\s*1\.4;') { throw 'G-01 application descriptions do not use the approved readable style.' }
|
||||
|
||||
@@ -31,7 +31,8 @@ foreach ($required in @(
|
||||
'join-state--form',
|
||||
'join-state--success',
|
||||
'join-state--error',
|
||||
'g03-create-flow-panel.png',
|
||||
'g01-empty-panel-frame.png',
|
||||
'g-form-field-frame.png',
|
||||
'a01-scroll-primary-v3.png',
|
||||
'const genealogyPreview',
|
||||
'query.genealogyId',
|
||||
@@ -44,13 +45,16 @@ foreach ($required in @(
|
||||
foreach ($nativeUi in @('uni.showToast', 'uni.showModal', 'uni.showLoading', 'uni.showActionSheet')) {
|
||||
if ($g08 -match [regex]::Escape($nativeUi)) { throw "G08 must not use native UniApp UI: $nativeUi" }
|
||||
}
|
||||
if ($g08 -notmatch '(?s)\.join-field-error\s*\{[^}]*font-size:\s*24rpx;[^}]*line-height:\s*34rpx;') {
|
||||
throw 'G08 inline validation must remain readable over the illustrated panel'
|
||||
}
|
||||
|
||||
foreach ($required in @(
|
||||
"const applicationState = ref('loading')",
|
||||
'application-state--list',
|
||||
'application-state--empty',
|
||||
'application-state--error',
|
||||
'application-status-card.png',
|
||||
'list-slip-frame.png',
|
||||
"'PENDING'",
|
||||
"'APPROVED'",
|
||||
"'REJECTED'",
|
||||
@@ -70,8 +74,7 @@ foreach ($required in @(
|
||||
'review-state--list',
|
||||
'review-state--empty',
|
||||
'review-state--error',
|
||||
'application-record-card.png',
|
||||
'application-status-card.png',
|
||||
'list-slip-frame.png',
|
||||
'a01-scroll-primary-v3.png',
|
||||
'a01-scroll-secondary-v3.png',
|
||||
'const reviewSamples',
|
||||
@@ -91,9 +94,7 @@ foreach ($className in @('join-panel', 'join-action', 'application-card', 'revie
|
||||
foreach ($entry in @('g07:', 'g08:', 'g09:')) {
|
||||
if ($catalog -match [regex]::Escape($entry)) { throw "Page catalog must remove migrated generic entry: $entry" }
|
||||
}
|
||||
$asset = Join-Path $root 'static/assets/modules/genealogy/opaque/application-record-card.png'
|
||||
if (-not (Test-Path -LiteralPath $asset)) { throw 'G08-G10 require application-record-card.png' }
|
||||
$statusAsset = Join-Path $root 'static/assets/modules/genealogy/opaque/application-status-card.png'
|
||||
if (-not (Test-Path -LiteralPath $statusAsset)) { throw 'G09 and application empty states require application-status-card.png' }
|
||||
$statusAsset = Join-Path $root 'static/assets/modules/genealogy/transparent/list-slip-frame.png'
|
||||
if (-not (Test-Path -LiteralPath $statusAsset)) { throw 'G09-G10 cards and application empty states require list-slip-frame.png' }
|
||||
|
||||
Write-Output 'G08-G10-APPLICATION-FLOW-CONTRACT PASS'
|
||||
|
||||