修改完成55%

This commit is contained in:
2026-07-15 08:31:21 +08:00
parent 8c98936da5
commit 241a7af54c
98 changed files with 3269 additions and 782 deletions
+6
View File
@@ -0,0 +1,6 @@
/tmp/
/unpackage/
/docs/superpowers/specs/design/
/docs/design/screens/runtime/
/tmp-g01-icon-audit.png
/node_modules/
+142 -48
View File
@@ -1,83 +1,177 @@
<!-- 公共组件全量页面视觉母版根据页面目录展示认证表单列表详情时间轴设置六类布局 -->
<!-- 公共组件F/R/N/M 剩余页面的高规格多任务母版内容独立视觉表面统一复用完整位图 -->
<template>
<view class="module-page" :class="`module-page--${page.template}`">
<!-- 背景层复用已验收的宣纸淡墨山水资产保持全量页面视觉一致 -->
<image class="module-page__paper" :src="isAuth ? '/static/assets/foundation/opaque/auth-page-paper.jpg' : '/static/assets/foundation/opaque/page-paper.jpg'" mode="aspectFill" />
<image class="module-page__scenery" src="/static/assets/foundation/transparent/auth-ink-scenery.png" mode="aspectFill" />
<PageHeader :title="page.title" :root="isRoot" :action="page.number" />
<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'
}
]">
<image class="module-page__paper" src="/static/assets/foundation/opaque/page-paper.jpg" mode="aspectFill" />
<image class="module-page__footer" src="/static/assets/foundation/transparent/footer-mountain-bamboo.png" mode="widthFix" />
<view class="module-page__header"><PageHeader :title="page.title" :action="page.number" /></view>
<view class="module-page__content" :class="{ 'module-page__content--tab': page.tab }">
<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>
<!-- 表单与认证母版保留真实 input便于后续接入接口 -->
<view v-if="page.template === 'auth' || page.template === 'form'" class="form-card paper-card">
<view v-for="field in page.fields" :key="field" class="form-row"><text>{{ field }}</text><input :placeholder="`请输入${field}`" placeholder-class="form-placeholder" /></view>
<text v-if="page.note" class="form-note">{{ page.note }}</text>
<view class="primary-action" @click="showPending"><text>{{ page.action }}</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>
<view class="primary-action" @click="completeAction"><image src="/static/assets/foundation/opaque/a01-primary-button.png" mode="scaleToFill" /><text>{{ page.action }}</text></view>
</view>
</view>
<!-- 列表母版用于家谱成员文章相册礼仪及记录 -->
<view v-else-if="page.template === 'list'" class="content-stack">
<view v-for="item in page.sections" :key="item[0]" class="list-card paper-card" @click="showPending"><view class="list-card__mark">谱</view><view class="list-card__copy"><text>{{ item[0] }}</text><text>{{ item[1] }}</text></view><text class="list-card__arrow"></text></view>
<view class="primary-action primary-action--compact" @click="showPending"><text>{{ page.action }}</text></view>
<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>
<view class="primary-action primary-action--compact" @click="completeAction"><image src="/static/assets/foundation/opaque/a01-primary-button.png" mode="scaleToFill" /><text>{{ page.action }}</text></view>
</view>
<!-- 详情母版强调一项主题信息与分段资料 -->
<view v-else-if="page.template === 'detail'" class="content-stack">
<view class="detail-hero paper-card"><text class="detail-hero__seal">{{ page.number.slice(0, 1) }}</text><text class="detail-hero__title">{{ page.title }}</text><text>{{ page.subtitle }}</text></view>
<view v-for="item in page.sections" :key="item[0]" class="detail-card paper-card"><text>{{ item[0] }}</text><text>{{ item[1] }}</text></view>
<view class="primary-action" @click="showPending"><text>{{ page.action }}</text></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>
<view class="primary-action" @click="completeAction"><image src="/static/assets/foundation/opaque/a01-primary-button.png" mode="scaleToFill" /><text>{{ page.action }}</text></view>
</view>
</view>
<!-- 时间轴母版用于申请成长和人生事件 -->
<view v-else-if="page.template === 'timeline'" class="timeline paper-card">
<view v-for="item in page.sections" :key="item[0]" class="timeline__item"><view class="timeline__dot"></view><view><text>{{ item[0] }}</text><text>{{ item[1] }}</text></view></view>
<view class="primary-action primary-action--compact" @click="showPending"><text>{{ page.action }}</text></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>
<view class="primary-action primary-action--compact" @click="completeAction"><image src="/static/assets/foundation/opaque/a01-primary-button.png" mode="scaleToFill" /><text>{{ page.action }}</text></view>
</view>
<!-- 设置母版提供清晰的分组入口和状态说明 -->
<view v-else-if="page.template === 'settings'" class="settings paper-card">
<view v-for="item in page.sections" :key="item[0]" class="settings__row" @click="showPending"><view><text>{{ item[0] }}</text><text>{{ item[1] }}</text></view><text></text></view>
<view class="primary-action primary-action--compact" @click="showPending"><text>{{ page.action }}</text></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>
<view class="primary-action" @click="completeAction"><image src="/static/assets/foundation/opaque/a01-primary-button.png" mode="scaleToFill" /><text>{{ page.action }}</text></view>
</view>
</view>
<!-- 待接入状态使用题签式留白避免裸页面 -->
<view v-else class="status-card paper-card"><text class="status-card__badge">{{ page.badge }}</text><text class="status-card__lead">{{ page.lead }}</text><text class="status-card__note">{{ page.note }}</text><view class="primary-action" @click="showPending"><text>{{ page.action }}</text></view></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 class="primary-action" @click="completeAction"><image src="/static/assets/foundation/opaque/a01-primary-button.png" mode="scaleToFill" /><text>{{ page.action }}</text></view>
</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>
<view class="primary-action" @click="moduleState = 'ready'"><image :src="moduleState === 'error' ? '/static/assets/foundation/opaque/a01-secondary-button.png' : '/static/assets/foundation/opaque/a01-primary-button.png'" mode="scaleToFill" /><text :class="{ 'primary-action__secondary': moduleState === 'error' }">{{ stateCopy.action }}</text></view>
</view>
</view>
</view>
<AppTabbar v-if="page.tab" :active="page.tab" />
</view>
</template>
<script setup>
import { computed } from 'vue'
import { computed, ref } from 'vue'
import PageHeader from '@/components/PageHeader.vue'
import AppTabbar from '@/components/AppTabbar.vue'
import { pageCatalog } from '@/data/page-catalog.js'
const props = defineProps({ pageId: { type: String, required: true } })
const page = computed(() => pageCatalog[props.pageId])
const isAuth = computed(() => page.value.template === 'auth')
const isRoot = computed(() => false)
const showPending = () => uni.showToast({ title: '该功能正在完善中', icon: 'none' })
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')
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' }
const openPreview = (item) => uni.showToast({ title: item[0], icon: 'none' })
</script>
<style scoped lang="scss">
.module-page { position: relative; min-height: 100vh; overflow: hidden; background: $paper; }
.module-page__paper, .module-page__scenery { position: absolute; inset: 0; width: 100%; height: 100%; pointer-events: none; }
.module-page__paper { opacity: .98; }.module-page__scenery { opacity: .32; }
.module-page__content { position: relative; z-index: 1; min-height: calc(100vh - 104rpx); padding: 34rpx 28rpx calc(64rpx + env(safe-area-inset-bottom)); box-sizing: border-box; }
.module-page__content--tab { padding-bottom: calc(190rpx + env(safe-area-inset-bottom)); }
.page-intro { display: flex; align-items: center; min-height: 104rpx; margin: 0 10rpx 26rpx; }
.page-intro__seal { width: 74rpx; height: 74rpx; margin-right: 14rpx; }.page-intro__number, .page-intro__subtitle { display: block; }
.page-intro__number { color: $brand-red; font-family: STKaiti, KaiTi, serif; font-size: 27rpx; font-weight: 700; letter-spacing: 3rpx; }.page-intro__subtitle { margin-top: 6rpx; color: $ink-muted; font-size: 22rpx; }
.paper-card { position: relative; border: 1rpx solid $gold-soft; background: rgba(255, 250, 240, .9); box-shadow: inset 0 0 0 6rpx rgba(255, 255, 255, .48); }
.form-card { padding: 16rpx 30rpx 34rpx; }.form-row { display: flex; align-items: center; min-height: 104rpx; border-bottom: 1rpx solid #ead9ba; }.form-row text { width: 142rpx; color: $ink; font-family: STKaiti, KaiTi, serif; font-size: 29rpx; }.form-row input { min-width: 0; flex: 1; color: $ink; font-size: 27rpx; }.form-placeholder { color: #b3a895; }.form-note { display: block; margin-top: 22rpx; color: $ink-muted; font-size: 21rpx; text-align: center; }
.primary-action { position: relative; display: flex; height: 100rpx; align-items: center; justify-content: center; margin-top: 34rpx; overflow: hidden; background-image: url('/static/assets/foundation/opaque/a01-primary-button.png'); background-position: center; background-repeat: no-repeat; background-size: 100% 100%; color: #fff9ec; font-family: STKaiti, KaiTi, serif; font-size: 34rpx; font-weight: 700; letter-spacing: 6rpx; }.primary-action--compact { height: 82rpx; font-size: 28rpx; }
.content-stack { display: flex; flex-direction: column; gap: 20rpx; }.list-card { display: flex; min-height: 132rpx; align-items: center; padding: 16rpx 22rpx; }.list-card__mark { display: flex; width: 74rpx; height: 86rpx; align-items: center; justify-content: center; margin-right: 18rpx; border: 2rpx solid $brand-red; color: $brand-red; font-family: STKaiti, KaiTi, serif; font-size: 29rpx; font-weight: 700; }.list-card__copy { min-width: 0; flex: 1; }.list-card__copy text, .detail-card text, .timeline__item text, .settings__row text { display: block; }.list-card__copy text:first-child { color: $ink; font-family: STKaiti, KaiTi, serif; font-size: 32rpx; font-weight: 700; }.list-card__copy text:last-child { margin-top: 9rpx; color: $ink-muted; font-size: 21rpx; }.list-card__arrow { color: $gold; font-size: 44rpx; }
.detail-hero { display: flex; flex-direction: column; align-items: center; padding: 38rpx 30rpx; text-align: center; }.detail-hero__seal { display: flex; width: 80rpx; height: 96rpx; align-items: center; justify-content: center; border: 2rpx solid $brand-red; color: $brand-red; font-family: STKaiti, KaiTi, serif; font-size: 38rpx; font-weight: 700; }.detail-hero__title { margin: 20rpx 0 12rpx; color: $ink; font-family: STKaiti, KaiTi, serif; font-size: 40rpx; font-weight: 700; }.detail-hero > text:last-child { color: $ink-muted; font-size: 22rpx; }.detail-card { padding: 28rpx; }.detail-card text:first-child { color: $brand-red; font-family: STKaiti, KaiTi, serif; font-size: 29rpx; font-weight: 700; }.detail-card text:last-child { margin-top: 14rpx; color: $ink-muted; font-size: 25rpx; line-height: 1.8; }
.timeline { padding: 12rpx 28rpx 30rpx; }.timeline__item { position: relative; display: flex; gap: 22rpx; min-height: 112rpx; padding: 30rpx 0 0; }.timeline__item:not(:last-of-type)::before { position: absolute; top: 55rpx; left: 9rpx; width: 2rpx; height: 78rpx; background: $gold-soft; content: ''; }.timeline__dot { width: 20rpx; height: 20rpx; margin-top: 8rpx; border-radius: 50%; background: $brand-red; }.timeline__item text:first-child { color: $ink; font-size: 29rpx; font-weight: 700; }.timeline__item text:last-child { margin-top: 9rpx; color: $ink-muted; font-size: 22rpx; }
.settings { padding: 0 28rpx 28rpx; }.settings__row { display: flex; min-height: 116rpx; align-items: center; justify-content: space-between; border-bottom: 1rpx solid #ead9ba; }.settings__row text:first-child { color: $ink; font-size: 29rpx; }.settings__row text:last-child { margin-top: 8rpx; color: $ink-muted; font-size: 21rpx; }.settings__row > text { color: $gold; font-size: 42rpx; }.status-card { display: flex; min-height: 540rpx; flex-direction: column; align-items: center; justify-content: center; padding: 40rpx; text-align: center; }.status-card__badge { display: flex; width: 116rpx; height: 136rpx; align-items: center; justify-content: center; border: 2rpx solid $brand-red; color: $brand-red; font-family: STKaiti, KaiTi, serif; font-size: 50rpx; font-weight: 700; }.status-card__lead { margin-top: 30rpx; color: $ink; font-family: STKaiti, KaiTi, serif; font-size: 36rpx; font-weight: 700; }.status-card__note { margin-top: 16rpx; color: $ink-muted; font-size: 23rpx; line-height: 1.7; }
.module-page__paper { position: absolute; inset: 0; z-index: 0; width: 100%; height: 100%; pointer-events: none; }
.module-page__footer { position: absolute; right: 0; bottom: 0; left: 0; z-index: 1; width: 100%; opacity: .12; pointer-events: none; }
.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, .primary-action 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; }
.primary-action { position: relative; width: 100%; height: 76rpx; margin-top: 18rpx; }
.primary-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; letter-spacing: 2rpx; }
.primary-action .primary-action__secondary { color: $ink; }
.primary-action--compact { width: 100%; }
.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 .primary-action { width: 420rpx; max-width: 100%; margin: 30rpx auto 0; }
.module-page__content--state { padding-top: 18rpx; }
@media (min-width: 400px) { .module-page__content { padding-right: 32rpx; padding-left: 32rpx; } }
</style>
+137
View File
@@ -0,0 +1,137 @@
<!-- T04-T06 共用成员表单仅复用视觉与交互骨架各页面通过 kind 拥有独立任务和文案 -->
<template>
<view class="member-form-page" :class="{
'form-state--form': formState === 'form',
'form-state--success': formState === 'success',
'form-state--conflict': formState === 'conflict',
'form-state--error': formState === 'error'
}">
<image class="member-form-page__paper" src="/static/assets/foundation/opaque/page-paper.jpg" mode="aspectFill" />
<view class="member-form-page__header"><PageHeader :title="config.pageTitle" /></view>
<view class="member-form-panel">
<image class="member-form-panel__skin" src="/static/assets/modules/genealogy/opaque/g03-create-flow-panel.png" mode="scaleToFill" />
<view v-if="formState === 'form'" class="member-form">
<text class="member-form__eyebrow">{{ config.eyebrow }}</text>
<text class="member-form__title">{{ config.title }}</text>
<text class="member-form__copy">{{ config.copy }}</text>
<view v-for="field in config.fields" :key="field.key" class="member-field">
<image src="/static/assets/modules/genealogy/opaque/g06-search-input-wide.png" mode="scaleToFill" />
<text>{{ field.label }}</text>
<input v-model="form[field.key]" :placeholder="field.placeholder" placeholder-class="member-placeholder" />
</view>
<text class="member-form__note">{{ config.note }}</text>
<view class="member-form-action" @click="saveForm">
<image src="/static/assets/foundation/opaque/a01-primary-button.png" mode="scaleToFill" /><text>{{ config.action }}</text>
</view>
</view>
<view v-else class="member-form-result">
<text class="member-form__eyebrow">{{ resultCopy.eyebrow }}</text>
<text class="member-form__title">{{ resultCopy.title }}</text>
<text class="member-form__copy">{{ resultCopy.copy }}</text>
<view v-if="formState === 'conflict'" class="conflict-actions">
<view class="member-form-action" @click="formState = 'form'">
<image src="/static/assets/foundation/opaque/a01-secondary-button.png" mode="scaleToFill" /><text class="member-form-action__secondary">返回核对</text>
</view>
<view class="member-form-action" @click="showConflictHelp">
<image src="/static/assets/foundation/opaque/a01-primary-button.png" mode="scaleToFill" /><text>查看冲突</text>
</view>
</view>
<view v-else class="member-form-action" @click="formState = 'form'">
<image src="/static/assets/foundation/opaque/a01-primary-button.png" mode="scaleToFill" /><text>{{ formState === 'success' ? '继续完善' : '重新填写' }}</text>
</view>
</view>
</view>
</view>
</template>
<script setup>
import { computed, reactive, ref } from 'vue'
import PageHeader from '@/components/PageHeader.vue'
const props = defineProps({ kind: { type: String, required: true } })
const configs = {
add: {
pageTitle: '新增亲属', eyebrow: '补全家族关系', title: '为汤文远添加一位亲属',
copy: '先确认与当前成员的关系,再录入基础身份信息。', action: '保存亲属', note: '保存后将在世系图中显示,可继续补充详细档案。',
fields: [
{ key: 'name', label: '姓名', placeholder: '请输入真实姓名' },
{ key: 'relation', label: '与本人关系', placeholder: '例如:长子、配偶' },
{ key: 'birthDate', label: '出生日期', placeholder: '例如:1992年' },
{ key: 'gender', label: '性别', placeholder: '请选择或输入' }
]
},
edit: {
pageTitle: '编辑成员', eyebrow: '成员档案维护', title: '完善汤文远的生命记录',
copy: '基础身份用于世系展示,生平信息可在成员档案中继续补充。', action: '保存资料', note: '隐私字段只向本人和有权限的家谱管理员展示。',
fields: [
{ key: 'name', label: '姓名', placeholder: '汤文远' },
{ key: 'generation', label: '字辈', placeholder: '例如:文' },
{ key: 'birthDate', label: '出生日期', placeholder: '1940年' },
{ key: 'summary', label: '人物简介', placeholder: '简要记录生平' }
]
},
relation: {
pageTitle: '关系维护', eyebrow: '亲属关系校正', title: '确认两位成员的家族关系',
copy: '调整关系会影响世系位置,保存前请核对成员和关系类型。', action: '保存关系', note: '若形成重复父母、代际倒置等情况,页面会先提示冲突。',
fields: [
{ key: 'source', label: '当前成员', placeholder: '汤文远' },
{ key: 'target', label: '关联成员', placeholder: '请选择家谱成员' },
{ key: 'relation', label: '关系类型', placeholder: '父子、配偶等' },
{ key: 'effectiveDate', label: '生效日期', placeholder: '选填' }
]
}
}
const config = computed(() => configs[props.kind])
const form = reactive({ name: '', relation: '', birthDate: '', gender: '', generation: '', summary: '', source: '', target: '', effectiveDate: '' })
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 formState = ref(query.state === 'success' ? 'success' : query.state === 'conflict' ? 'conflict' : query.state === 'error' ? 'error' : 'form')
const resultCopy = computed(() => ({
success: { eyebrow: '本地预览已更新', title: `${config.value.pageTitle}内容已保存`, copy: '返回世系树后可查看新的展示位置;正式数据将在接口阶段接入。' },
conflict: { eyebrow: '发现关系冲突', title: '这段关系会造成代际矛盾', copy: '目标成员已经存在父级关系,请先核对原关系,再决定是否调整。' },
error: { eyebrow: '内容未保存', title: '暂时无法完成这次操作', copy: '请返回成员档案重新进入,当前填写内容不会写入正式数据。' }
}[formState.value] || {}))
const saveForm = () => {
if (props.kind === 'relation' && (!form.target.trim() || !form.relation.trim())) {
formState.value = 'conflict'
return
}
formState.value = 'success'
}
const showConflictHelp = () => uni.showModal({ title: '关系冲突说明', content: '同一成员不能同时存在两个生父或形成上下代循环。请返回世系树核对原关系。', showCancel: false })
</script>
<style scoped lang="scss">
.member-form-page { position: relative; min-height: 100vh; overflow: hidden; background: $paper; }
.member-form-page__paper { position: absolute; inset: 0; z-index: 0; width: 100%; height: 100%; pointer-events: none; }
.member-form-page__header { position: relative; z-index: 3; }
.member-form-panel { position: relative; z-index: 2; width: calc(100% - 32rpx); height: min(640px, calc((100vw - 16px) * 1.48)); margin: 18rpx auto 0; }
.member-form-panel__skin { position: absolute; inset: 0; width: 100%; height: 100%; }
.member-form, .member-form-result { position: absolute; inset: 7.5% 8%; }
.member-form__eyebrow { display: block; color: $brand-red; font-size: 22rpx; letter-spacing: 3rpx; }
.member-form__title { display: block; margin-top: 10rpx; color: $ink; font-family: 'STKaiti', 'KaiTi', serif; font-size: 34rpx; font-weight: 700; }
.member-form__copy { display: block; margin-top: 9rpx; color: $ink-muted; font-size: 20rpx; line-height: 1.5; }
.member-field { position: relative; height: 78rpx; margin-top: 12rpx; }
.member-field image { position: absolute; inset: 0; width: 100%; height: 100%; }
.member-field > text { position: absolute; top: 27rpx; left: 22rpx; z-index: 1; color: $ink; font-size: 21rpx; font-weight: 700; }
.member-field input { position: absolute; top: 0; right: 18rpx; bottom: 0; left: 164rpx; z-index: 1; height: 78rpx; color: $ink; font-size: 21rpx; line-height: 78rpx; }
.member-placeholder { color: #a79884; }
.member-form__note { display: block; margin-top: 14rpx; color: $ink-muted; font-size: 19rpx; line-height: 1.45; text-align: center; }
.member-form-action { position: relative; width: 100%; height: 76rpx; margin-top: 17rpx; }
.member-form-action image { position: absolute; inset: 0; width: 100%; height: 100%; }
.member-form-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-form-action .member-form-action__secondary { color: $ink; }
.member-form-result { top: 29%; text-align: center; }
.member-form-result .member-form__eyebrow { text-align: center; }
.member-form-result .member-form__copy { margin-top: 20rpx; }
.member-form-result > .member-form-action { width: 420rpx; max-width: 100%; margin: 32rpx auto 0; }
.conflict-actions { display: flex; gap: 14rpx; margin-top: 30rpx; }
.conflict-actions .member-form-action { width: calc(50% - 7rpx); margin-top: 0; }
@media (min-width: 400px) { .member-form-panel { width: calc(100% - 48rpx); } }
</style>
-13
View File
@@ -1,19 +1,6 @@
// 全量视觉交付的本地页面目录:只保存页面内容与状态,样式由 ModulePage 的六类母版统一维护。
export const pageCatalog = {
g07: { number: 'G-07', title: '搜索结果', subtitle: '为你找到 3 部公开家谱', template: 'list', sections: [['洛阳汤氏家谱', '河南 · 洛阳 · 158 位成员'], ['汝南周氏家谱', '河南 · 周口 · 86 位成员'], ['清河张氏家谱', '河北 · 邢台 · 214 位成员']], action: '申请加入' },
g08: { number: 'G-08', title: '申请加入家谱', subtitle: '留下真实姓名,等待管理员审核', template: 'form', fields: ['真实姓名', '与家谱关系', '申请说明'], action: '提交申请', note: '提交后可在我的申请中查看进度' },
g09: { number: 'G-09', title: '我的申请', subtitle: '查看加入家谱的审核进度', template: 'timeline', sections: [['洛阳汤氏家谱', '审核中 · 已提交于今天'], ['清河张氏家谱', '已通过 · 可进入家谱']], action: '查看申请说明' },
g11: { number: 'G-11', title: '家谱设置', subtitle: '管理名称、公开范围与访问说明', template: 'settings', sections: [['家谱名称', '汤氏家谱'], ['公开范围', '仅成员可见'], ['访问说明', '家族资料,请妥善保存'], ['转让管理权', '功能待接入']], action: '保存设置' },
g12: { number: 'G-12', title: '字辈诗', subtitle: '承载世代相传的字序与家训', template: 'detail', sections: [['启宗敦本,继世传芳', '当前使用字辈 · 敦'], ['字辈维护说明', '管理员可补录或调整字辈诗']], action: '维护字辈诗' },
t02: { number: 'T-02', title: '世系提示', subtitle: '世系图当前状态', template: 'status', badge: '树', lead: '请横屏查看完整世系图', note: '无成员、加载失败和权限不足会在此处提示', action: '返回成员目录' },
t04: { number: 'T-04', title: '新增亲属', subtitle: '补全一段真实的家族关系', template: 'form', fields: ['姓名', '与本人关系', '出生日期', '性别'], action: '保存亲属', note: '保存后将在世系图中显示' },
t05: { number: 'T-05', title: '编辑成员资料', subtitle: '妥善记录家人的生命信息', template: 'form', fields: ['姓名', '字辈', '出生日期', '人物简介'], action: '保存资料', note: '隐私字段仅向有权限成员展示' },
t06: { number: 'T-06', title: '编辑亲属关系', subtitle: '确认成员之间的血缘与婚姻关系', template: 'form', fields: ['关联成员', '关系类型', '生效日期'], action: '保存关系', note: '发现关系冲突时会提示管理员处理' },
t07: { number: 'T-07', title: '成员目录', subtitle: '按世系、姓名或字辈查找成员', template: 'list', sections: [['汤文正', '第 18 世 · 家谱管理员'], ['汤文德', '第 19 世 · 已完善资料'], ['汤文清', '第 19 世 · 待补充信息']], action: '搜索成员' },
t08: { number: 'T-08', title: '成员状态', subtitle: '隐私、离世与访问权限说明', template: 'settings', sections: [['隐私成员', '仅本人和管理员可见'], ['已故成员', '在世系图中以纪念样式展示'], ['无访问权限', '请联系家谱管理员']], action: '查看隐私说明' },
f03: { number: 'F-03', title: '动态详情', subtitle: '一段属于家人的共同记忆', template: 'detail', sections: [['端午家宴', '今年端午全家相聚,留下了许多温暖照片。'], ['家人评论', '愿家族和睦兴旺,岁岁平安。']], action: '写下评论' },
f04: { number: 'F-04', title: '谱文', subtitle: '收录家族文章、家训与往事', template: 'list', sections: [['家风家训', '代代相传的处世之道'], ['家族往事', '珍贵的口述与文字记录'], ['族谱序言', '一部家谱的起源']], action: '新建谱文' },
f05: { number: 'F-05', title: '谱文详情', subtitle: '家族文字档案', template: 'detail', sections: [['家风家训', '孝友传家,勤俭立业;敬祖睦宗,诚实待人。'], ['收录时间', '2024 年 5 月 12 日']], action: '收藏谱文' },
+49 -76
View File
@@ -1,94 +1,67 @@
# A02 账号登录 Design QA
# Design QA — G05 家谱总览
## Comparison target
- source visual truth path: `docs/design/screens/runtime/2026-07-14/g-module-audit/before/01-g05-before-360x800.png``static/assets/modules/genealogy/opaque/g05-overview-surface.png`、外部思维导图与 `家谱主页.png` 的信息层级参考
- implementation screenshot path: `docs/design/screens/runtime/2026-07-14/g-module-audit/g05/01-g05-ready-360x800.png`
- viewport: 360×800;另查 412×915
- state: `ready`;另查 `empty``error`
- full-view comparison evidence: `docs/design/screens/runtime/2026-07-14/g-module-audit/g05/05-g05-before-after-comparison.png`
- focused region comparison evidence: 未单独裁切;合并图中 360px 单页按原始像素显示,谱名区、四宫格、近况行和底部说明文字均可直接辨认。
- Source visual truth: `docs/design/screens/runtime/2026-07-13/A01-360x800.png` and `docs/design/screens/runtime/2026-07-13/G01-360x800.png` are the user-approved brand references. They are not a user-approved A02 mockup.
- Earlier A02 implementation: `docs/design/screens/runtime/2026-07-13/A02-before-360x800.png`.
- Current implementation: `docs/design/screens/runtime/2026-07-13/A02-after-360x800.png`.
- Full-view comparison evidence: `docs/design/screens/runtime/2026-07-13/A02-before-after-360x800.png` at 360 × 800.
- Focused region review: full-view comparison is sufficient for this iteration because the corrected areas are the whole panel proportion, complete frame, button ratio and background treatment; no text or icon asset is too small to identify in the captured images.
## Findings
没有遗留的 P0/P1/P2 问题。
- 字体与层级:谱名、核心动作、辅助说明形成三级层级;第一次审计发现元信息和说明文字偏小,已将相关字号提高到 22–23rpx 后重截。
- 间距与布局:四个点击区与位图槽位一致;360 与 412 均无横向溢出、裁切或底部操作遮挡。
- 颜色与令牌:继续使用朱砂、墨蓝、暖宣纸与古金,没有采用外部参考的灰绿色视觉。
- 图片质量:总览面、四宫格边框、分隔和底部说明面全部来自 1122×1506 不透明位图;没有用 CSS 边框、渐变、SVG 或占位图形代替。
- 文案与内容:删除了“邀请亲人→申请加入”的角色错误;保留 OpenAPI 能支持的概览、世系、始祖、字辈、审核和设置任务。
## Comparison history
### Iteration 1: Before refactor
1. 改前 P1:录入族人指向已删除 G04;已改为 G03 `step=ancestor`,静态契约和真实点击通过。
2. 改前 P1:管理员邀请动作错误进入申请者 G08;已删除该动作,不臆造 OpenAPI 未提供的邀请功能。
3. 改前 P2:完整卡片与面板由 CSS 拼接;已改成 ImageGen 不透明位图并验证尺寸和 alpha。
4. 首次改后 P2:部分 360 宽屏辅助文字偏小;已提高字号并重新捕获四张最终证据。
**Findings**
## Evidence limits
- [P1] The login form was a thin CSS rectangle rather than a complete genealogy panel.
Location: `pages/auth/a02-login.vue`, `.login-panel`.
Evidence: the left half of `A02-before-after-360x800.png` has a narrow plain outline, unlike A01s complete buttons and G01s complete title slips.
Fix: introduced `static/assets/modules/auth/opaque/a02-login-panel.png` as the full opaque paper, double-gold-frame and corner-ornament surface.
截图能够确认层级、裁切、图像质量和可见点击面积,不能单独证明读屏顺序、外接键盘焦点、Android 系统字体放大或完整 WCAG 合规;这些留给真机专项验证。
- [P1] The A01 primary button skin was vertically compressed.
Location: `pages/auth/a02-login.vue`, `.login-submit`.
Evidence: the earlier button had a noticeably flatter appearance than the A01 reference.
Fix: set the A02 display slot to 128rpx high and preserved the complete skin as the only button surface.
## Implementation checklist
- [P2] The main task was visually weaker than the scenery below it.
Location: the A02 above-the-fold layout.
Evidence: the earlier card ended high in the viewport and left more visual weight to the empty mountain background.
Fix: aligned the header with A01 and expanded the panel so its lower paper landscape becomes a deliberate task-ending surface.
- [x] G01 统一使用 `genealogyId` 查询键
- [x] G05 同页承载 `loading|ready|empty|error`
- [x] G04 旧路径清除
- [x] 误导邀请动作清除
- [x] 360×800 与 412×915 截图检查
- [x] 静态契约与浏览器运行冒烟通过
### Iteration 2: Current implementation
final result: passed
**Findings**
---
- [P3] The page uses one existing cloud asset at the heading while the new panel supplies additional pale clouds. This is visually coherent at 360 × 800, but user review should decide whether the heading cloud is one layer too many.
Location: A02 heading, right side.
Evidence: `A02-after-360x800.png`.
Follow-up: remove only the heading cloud if the user finds the top-right decoration busy; do not alter the panel frame or input structure.
# Design QA — 54 路由候选覆盖审计
### Iteration 3: A02 本页协议确认
- 审计日期:2026-07-14
- 用户验收状态:7 个冻结页为 `[x]`;其余 47 个页面仅为 `[~]` 候选,本文件的 `passed` 只表示本轮内部截图 QA 通过,不代表用户审美验收。
- 全量候选截图:`docs/design/screens/runtime/2026-07-14/all-page-candidates/`
- G 模块证据:`g-module-audit/`
- T 模块证据:`t01-audit/``t-module-audit/`
- F01/F02/N01/M01 根页证据:`root-pages-audit/`
- 六类共享母版证据:`module-page-audit/`
**Findings**
## Audit summary
- [P1] 直接进入 A02 时,协议确认以前只是一句“请先在登录引导页阅读并确认协议”,用户无法在当前任务页修正状态
Location: `pages/auth/a02-login.vue`,原 `.agreement-reminder`
Evidence: `docs/design/screens/runtime/2026-07-14/A02-without-agreement-360x800.png`
Fix: 以 A02 卷轴面板内的可点击协议行替换提示;未选中与选中状态分别使用两枚 96×96 透明 PNG,文字和链接保留在 Vue 文字层
- 54 条最终路由已按当前信息架构覆盖;G07→G06、T02→T01 的重复状态路由已收敛
- 候选页面继续使用宣纸、朱砂、古金、墨褐视觉体系;按钮、卡片、字段、面板和边框使用完整位图,未在共享母版中使用 CSS 伪元素或 CSS 构造可见表面
- 47 个待审页面均使用本地模拟数据与可操作状态,不调用 `appApi`;接口清单只用于功能位置、字段与状态语义
- 29 个原通用壳页面逐页捕获 360×800 候选图,并按 F、R、M/N 生成模块联系表统一审视
- F01、F02、N01、M01 根页在 360×800 与关键 412×915 下复核;底部导航、主操作和状态卡没有裁切。
- 首轮 T01 状态卡横向裁切已按契约先行修复并重截;G09/G10 假按钮槽已用独立状态位图修复;当前没有遗留的 P0/P1/P2 截图问题。
- [P2] Chrome 捕获脚本在选择器出现 300ms 后就截图,A02 图层尚未加载时会产生黑块或过渡状态,不能作为视觉证据。
Location: `scripts/capture-chrome-page.js`
Evidence: 本轮已拒绝的半渲染截图;修复后截图均由 `tests/capture-chrome-page-contract.ps1` 约束为“图片资源加载完成且额外稳定等待后再采集”。
Fix: 截图脚本等待全部 `document.images` 加载完成,再等待 900ms;新截图逐张打开检查。
## Evidence limits
### Iteration 4: A02 注册入口与证据隔离
H5 Chrome 截图不能证明 Android 真机字体放大、读屏语义、系统返回手势、软键盘遮挡和所有触摸目标;这些项目不在本轮候选 QA 中冒充完成,留到用户视觉审核后的 Android 设备复核。
**Findings**
- [P2] 直接进入 A02 的新用户在账号密码与验证码登录之外没有注册路径,页面功能感不完整。
Location: `pages/auth/a02-login.vue`,协议行下方。
Evidence: 用户审视的 412×915 运行截图与本轮 `A02-register-entry-412x915.png`
Fix: 在协议行下方的既有留白内增加“还没有账号? 注册账号”;仅朱砂色“注册账号”可点,真实跳转到既有 A04 路由,不新增按钮资产或重做 A04。
- [P1] Chrome 对同一路由的 hash 导航会复用 Vue 实例;直接进入 URL 的截图可能继承上一次勾选协议的状态,不能证明初始态。
Location: `scripts/capture-chrome-page.js`
Evidence: 导航后 URL 已是无参数 A02,但实际图标仍为 `a02-agreement-checked.png`;整页刷新后才回到未选中图标。
Fix: 截图脚本在到达目标 URL 后刷新,并等待 `performance.timeOrigin` 变化,再检查选择器、图片和稳定时间。该顺序由 `tests/capture-chrome-page-contract.ps1` 约束。
## Required fidelity surfaces
- Fonts and typography: A02 uses the projects existing STKaiti/KaiTi hierarchy; current capture keeps the title above form labels and retains readable body copy.
- Spacing and layout rhythm: the larger panel establishes a clear header → title → tab → form → action cadence; 320 × 568 and 360 × 640 intentionally scroll rather than clip the action.
- Colors and visual tokens: cinnabar remains limited to the header, selected Tab, helper action and primary button; antique gold frames and ink-brown body text follow A01/G01.
- Image quality and asset fidelity: the panel is a complete opaque PNG with no generated text, placeholder controls, or CSS-drawn frame; the button remains the approved complete A01 asset.
- Agreement control: the 96×96 unchecked and checked icons are separate transparent PNGs with transparent corners; A02 does not draw the circle, fill, or checkmark with CSS.
- Registration entry: the low-emphasis text path follows the agreement row and uses only the existing panel whitespace; it does not compete with the full-surface cinnabar login button or invent a second button asset.
- Copy and content: account and SMS flows retain their original Chinese copy and real input controls; no visible workflow was invented.
## Primary interactions checked
- Account/password state captured at 320 × 568, 360 × 640, 360 × 800 and 412 × 915.
- SMS Tab was clicked through the Chrome debugging session and captured at 360 × 800: `docs/design/screens/runtime/2026-07-13/A02-sms-360x800.png`.
- Form submission behavior is covered by the existing A01/A02 contract and was not changed in this visual pass.
- Direct A02 now opens unchecked at 320×568, 360×640, 360×800 and 412×915; `?agreed=1` opens checked at 360×800. A real Chrome click changed the checked icon back to the unchecked PNG, and an unchecked submit displayed the current-page agreement message.
- Registration entry was captured at 360×800 and 412×915 after a fresh-document reload; a real Chrome click reached `http://localhost:5173/#/pages/auth/a04-register`.
- A03 重复验证码页已删除;删除后重新捕获 A02 的手机验证码 Tab(360×800),手机号、验证码、获取验证码、登录、协议和注册入口均完整显示,作为唯一验证码登录入口的运行证据。
## Acceptance
用户已于 2026-07-14 确认 A02 账号密码登录、协议确认和注册入口的视觉结果。本页从待验收样板转为冻结的认证模块参考;后续页面可继承其视觉语言,但不能改动 A02 可见效果。
## Final result
final result: accepted
final result: passed (internal candidate QA only; user review pending)
@@ -0,0 +1,60 @@
# FRNM 全量候选设计记录
> 日期:2026-07-14
> 状态:F01F10、R01R11、N01N02、M01M10 共 33 个页面候选已形成,统一标记 `[~]`,等待用户逐页视觉审核。
## 1. Product Design 处理方式
- 先捕获旧通用壳的 `detail|list|form|timeline|settings|status` 六类真实运行基线,再升级共享母版。
- 共享母版只统一视觉表面和状态机制;每条路由仍拥有独立页面编号、标题、字段、列表内容和业务任务,没有合并不同功能页面。
- F01、F02、N01、M01 四个根页独立重做,保留家族/我的底部导航与关键跳转。
- 接口文档只用于功能位置和字段语义;页面候选全部使用本地模拟数据,不调用 `appApi`,不修改 API 边界。
## 2. 位图与母版
- `g03-create-flow-panel.png`:表单、详情、设置、服务说明和统一状态的完整宣纸表面。
- `g06-search-input-wide.png`:真实输入、详情段落和设置行的完整位图底层。
- `application-status-card.png`:列表、时间轴、通知、动态与状态卡。
- `a01-primary-button.png``a01-secondary-button.png`:所有主次操作。
- CSS 不绘制卡片、按钮、边框、圆角或伪元素装饰,只负责布局、文字、控件和状态。
## 3. 截图证据
### 六类母版
目录:`screens/runtime/2026-07-14/module-page-audit/`
- 基线与候选:`00-*-before-360x800.png``01-f03-detail-360x800.png``10-f04-list-412x915.png`
- 同画布对比:`11-template-before-after-comparison.png`
- 共享空/成功/失败:`12-shared-states-comparison.png`
### 29 个共享母版页面
目录:`screens/runtime/2026-07-14/all-page-candidates/`
- 每条路由均有独立 `*-360x800.png`
- 模块联系表:`00-family-contact-sheet.png``00-records-contact-sheet.png``00-profile-notification-contact-sheet.png`
### F01F02N01M01
目录:`screens/runtime/2026-07-14/root-pages-audit/`
- 基线:`00-f01-before-360x800.png``00-f02-before-360x800.png``00-n01-before-360x800.png``00-m01-before-360x800.png`
- 候选与状态:`01-f01-list-360x800.png``10-m01-ready-412x915.png`
- 同画布对比:`11-root-before-after-states.png`
## 4. 审视结果
- 六类母版在 360 下没有卡片、按钮或文字裁切;列表较长时自然纵向滚动,完整面板保持一致的视觉终点。
- 共享状态统一使用同一宣纸面板,但标题、动作和业务名称来自当前页面,避免“万能空态”丢失上下文。
- F01、M01 的根页保留底部导航;F02 的发布编辑区和 N01 的消息卡均使用真实控件与本地可操作结果。
- Product Design 对比中未发现 P1/P2 级裁切、假按钮槽或风格漂移;最终 Android 真机字体放大、读屏和触摸目标仍需后续设备复核。
## 5. 验证入口
- `tests/module-page-visual-contract.ps1`
- `tests/module-page-runtime-smoke.js`
- `tests/root-pages-visual-contract.ps1`
- `tests/root-pages-runtime-smoke.js`
- `tests/capture-chrome-page-contract.ps1`
@@ -0,0 +1,79 @@
# G05 家谱总览设计记录
> 日期:2026-07-14
> 状态:视觉候选已实现并完成本轮 Product Design 截图审计;未获用户审美确认,等待 G 模块完成后集中查看。
## 1. 技能与证据
- 已调用 `Product Design:index`,将任务路由为现有产品的 `audit``get-context` 与实现后设计检查。
- 已调用 `Product Design:audit`,以本轮新截的真实 H5 画面为证据,不用旧截图代替。
- 已调用 ImageGen 生成完整不透明位图表面;没有用 CSS 伪造卡片、边框或装饰。
- 改前证据:`screens/runtime/2026-07-14/g-module-audit/before/01-g05-before-360x800.png`
- 改后证据:`screens/runtime/2026-07-14/g-module-audit/g05/01-g05-ready-360x800.png``02-g05-empty-360x800.png``03-g05-error-360x800.png``04-g05-ready-412x915.png`
- 同画布对照:`screens/runtime/2026-07-14/g-module-audit/g05/05-g05-before-after-comparison.png`,左侧改前、右侧改后;所有判断均基于这张合并输入,不把两个分离预览冒充并排比较。
## 2. 产品与思维导图参考
外部只读参考位于 `C:\Users\Rain\Desktop\软件\JOB\app设计`。已查看 `思维导图.png``家谱主页.png``加入家谱.png``字辈谱.png`
采用的只有信息结构:谱名、堂号、地点和成员概况置顶;世系、录入、字辈、审核作为核心入口;家族内容作为下一层。没有采用外部灰绿色卡片、现代图标、邀请码加入或底部双按钮视觉。
外部“邀请码加入”与当前产品不一致。当前唯一加入流程是 G06 公开检索 → G08 提交申请 → G09 查看进度 → G10 管理员审核,因此不把邀请码或邀请按钮加入 G05。
## 3. 路由与状态所有权
- 唯一路由:`pages/genealogy/g05-genealogy-overview`
- 唯一上下文查询键:`genealogyId`G01 的旧 `id` 链接已同步迁移,不保留兼容读取。
- 同页状态:`loading``ready``empty``error`,不为加载、空数据、无权或失败另建页面。
- “录入族人”进入 G03 `step=ancestor&genealogyId=...`;已删除的 G04 不再被引用。
- G05 不再把 G08 申请加入表单误写成管理员的“邀请亲人”。
## 4. OpenAPI 设计边界
| 页面内容/动作 | OpenAPI 依据 | 页面阶段处理 |
| --- | --- | --- |
| 谱名、堂号、地区、成员数、可见范围 | `GET /genealogy/app/genealogies/{genealogyId}/overview` | 正常态展示真实接口所需字段;本轮继续使用现有 mock |
| 管理设置 | `GET/PUT /genealogy/app/genealogies/{genealogyId}` | 只进入 G11,不在 G05 复制设置表单 |
| 世系树 | `GET /genealogy/app/genealogies/{genealogyId}/lineage/tree` | 进入 T01,不在总览加载完整树 |
| 录入首代 | `POST /genealogy/app/genealogies/{genealogyId}/lineage/persons` | 进入 G03 的始祖步骤 |
| 字辈谱 | `GET/POST /genealogy/app/genealogies/{genealogyId}/generation-poems` | 进入 G12 |
| 入谱审核 | `GET .../join-applies/pending``PUT .../{applyId}/audit` | 进入 G10 |
接口响应仍使用通用 `JsonObject/ListResult`,因此页面不臆造邀请码、分享码、视频或多级管理员字段。
## 5. 位图资产
- 最终资产:`static/assets/modules/genealogy/opaque/g05-overview-surface.png`
- 尺寸:1122×1506`Format24bppRgb`,四角 alpha 均为 255。
- 内容:墨蓝谱名区、四个完整宣纸入口槽、古金分隔、底部访问说明面;文字和点击层不烘焙进图片。
- ImageGen 使用 `ui-mockup` 模式,参考 G05 改前 360×800 截图,明确要求无文字、无图标、无占位符、无现代玻璃拟态。
## 6. Product Design 审计
### 改前主要风险
1. G05 的“录入族人”指向已删除 G04,核心动作实际不可达。
2. “邀请亲人”跳到申请者填写的 G08,角色和任务语义错误;OpenAPI 也没有邀请端点。
3. 深色头、四宫格卡片和底部邀请面均由 CSS 拼接,不符合完整视觉表面必须使用位图的项目规则。
4. 只有模糊载入文案,没有区分空数据、无权限和加载失败。
### 改后步骤与健康度
1. `ready`:健康。谱名和成员概况在墨蓝区,四个核心任务在首屏可见,家族近况和访问说明层级清楚。
2. `empty`:健康。仍在 G05 路由内说明缺少上下文,并返回“我的家谱”。
3. `error`:健康。说明网络、数据或权限三类可能原因,提供同页重试。
4. 412×915:健康。位图、文字和点击槽同步放大,没有横向溢出或裁切。
### 可访问性边界
- 本轮从截图确认了文字未裁切、主要入口具有完整卡片面积、按钮文案可见。
- 审计后已提高谱名区元信息、入口说明、近况和底部说明字号,降低 360 宽屏小字风险。
- 截图不能证明读屏语义、键盘焦点和 Android 字体放大行为;这些留到真机与可访问性专项验证,不能宣称完整 WCAG 合规。
## 7. 验证
- `tests/g05-overview-contract.ps1`
- `tests/g05-overview-runtime-smoke.js`
- `tests/g01-visual-contract.ps1`
以上在实现后均已通过;最终集中审美确认前不将 G05 标记为视觉冻结。
@@ -0,0 +1,81 @@
# G08–G10 入谱申请链路设计记录
> 日期:2026-07-14
> 状态:页面候选、本地模拟交互与本轮 Product Design 截图审计已完成;标记为 `[~]`,等待后续逐页视觉审核。
## 1. Product Design 工作流
- `Product Design:index`:将工作路由为现有产品的上下文确认、截图审计和实现后设计检查。
- `Product Design:audit`:本轮重新捕获 G08–G10 的表单、成功、列表、空态和 412 宽屏,不使用旧通用壳图冒充最终证据。
- ImageGen:生成 `application-record-card.png`,并在截图审计发现假按钮后精确编辑出 `application-status-card.png`
- TDD`tests/g08-g10-application-flow-contract.ps1` 与运行冒烟先因通用壳和缺少同页状态红灯,再由最小实现转绿。
## 2. 思维导图与外部参考
已查看 `C:\Users\Rain\Desktop\软件\JOB\app设计\思维导图.png``加入家谱.png`。外部参考的“加入家谱”依赖邀请码,但当前 OpenAPI 没有邀请码端点,因此不采用该流程。
当前申请链路唯一结构是:G06 公开家谱检索 → G08 填写真实身份与关系 → G09 查看自己的全部申请 → G10 管理员审核。三个阶段是不同用户任务,保留三条路由;各自的加载、空、失败、提交成功和审核结果只作为原路由状态,不再拆页。
## 3. 页面与状态
| 页面 | 独立任务 | 同页状态 |
| --- | --- | --- |
| G08 | 对某一 `genealogyId` 提交加入申请 | `form`、提交中、`success``error` |
| G09 | 当前用户查看全部申请 | `loading``list``empty``error``PENDING/APPROVED/REJECTED` 为卡内状态 |
| G10 | 当前家谱管理员审核待处理申请 | `loading``list``empty``error`;审核确认与结果留在卡内 |
G07/G08/G09 的旧 `ModulePage` 目录条目已经删除;G07 结果态继续只归 G06。
## 4. OpenAPI 功能参考
| 页面动作 | OpenAPI |
| --- | --- |
| G08 提交 | `POST /genealogy/app/genealogies/{genealogyId}/join-applies` |
| G09 我的申请 | `GET /genealogy/app/genealogies/join-applies/mine` |
| G09 撤销边界 | `DELETE /genealogy/app/genealogies/join-applies/{applyId}`;本轮页面不展示未接入的撤销按钮 |
| G10 待审核列表 | `GET /genealogy/app/genealogies/{genealogyId}/join-applies/pending` |
| G10 审核 | `PUT /genealogy/app/genealogies/{genealogyId}/join-applies/{applyId}/audit` |
OpenAPI 的申请体仍是 `JsonObject`,页面只使用已有业务能解释的 `realName``relation``message`,没有臆造邀请码、推荐人层级或多管理员审批字段。本阶段只据此安排字段、状态和动作位置;三页均使用页面内模拟数据与交互,不调用 `appApi`,不进行接口对接。
## 5. 视觉资产
- G08 复用 `g03-create-flow-panel.png` 的完整宣纸表单面,以及 `g06-search-input-wide.png` 的完整输入框皮肤;没有复制近似资产。
- `application-record-card.png`1050×360、24 位 RGB、不透明;右下留双审核槽,只供 G10 列表。
- `application-status-card.png`1050×360、24 位 RGB、不透明;移除双审核槽,供 G09 列表和 G09/G10 空/失败状态。
- 主次操作分别使用 `a01-primary-button.png``a01-secondary-button.png`CSS 只负责位置、文字和点击层。
## 6. 截图证据
- G08`screens/runtime/2026-07-14/g-module-audit/applications/01-g08-form-360x800.png``02-g08-success-360x800.png``03-g08-form-412x915.png`
- G09`04-g09-list-360x800.png``05-g09-empty-360x800.png`
- G10`06-g10-list-360x800.png``07-g10-empty-360x800.png``08-g10-list-412x915.png`
- 同画布对照:`09-g08-form-success-comparison.png``10-g09-list-empty-comparison.png``11-g10-list-empty-comparison.png`
## 7. Product Design 审计结果
### 强项
1. G08 表单在 360/412 下均完整显示,姓名、关系、说明和提交按钮顺序清楚;提交后仍在 G08 显示成功结果。
2. G09 的申请状态以审核中、已通过、未通过映射接口枚举;通过项才可进入家谱。
3. G10 的拒绝/通过使用主次完整位图按钮,动作前有确认弹窗,避免误审。
4. 三页的空态均留在原路由,没有新增状态页。
### 已修复问题
- P2:第一张共享卡为审核按钮预留了两个槽,放到 G09 和空态时形成假按钮。已生成无按钮槽的状态卡并重新截图,问题消失。
- P2:运行冒烟最初在面板出现后立即查找原生输入,可能早于 `uni-input` 内部节点渲染。测试改为等待真实输入节点,再填写和点击;真实提交已到达同页成功态。
### 可访问性边界
- 截图可确认主按钮、审核按钮、表单顺序和文字未裁切。
- G10 审核按钮在 360 下仍有完整卡内点击区域,但最终 Android 真机仍需验证触摸目标、字体放大和读屏标签。
- 截图不能证明弹窗焦点管理或读屏状态播报,不宣称完整无障碍合规。
## 8. 验证
- `G08-G10-APPLICATION-FLOW-CONTRACT PASS`
- `G08-G10-APPLICATION-FLOW-RUNTIME-SMOKE PASS`,包含真实控件填写与本地模拟提交
- `PASS compile audit`
三页仅标记为候选完成,用户集中看样式前不标记为视觉冻结。
@@ -0,0 +1,51 @@
# G11–G12 家谱设置与字辈诗设计记录
> 日期:2026-07-14
> 状态:页面候选、同页状态、运行截图与 Product Design 对比审视已完成;标记为 `[~]`,等待用户后续逐页视觉审核。
## 1. 设计边界
- 继续使用项目已冻结的宣纸、朱砂、藏青与古金视觉体系,不照搬外部参考的颜色、卡片或按钮。
- OpenAPI 只用于确认可设计的功能位置与数据语义,不导入 `appApi`、不新增 API 方法、不进行接口联调。
- G11 与 G12 的空、失败、成功、编辑状态全部留在原路由,不为同一任务新增页面。
- G11 不放置“转让管理权”假入口:该动作需要成员选择上下文,当前页面只安排名称、公开范围和访问说明。
## 2. 页面与状态
| 页面 | 核心任务 | 同页状态 |
| --- | --- | --- |
| G11 | 编辑家谱名称、公开范围、访问说明 | `loading``form``success``error` |
| G12 | 查看逐代字辈、批量录入连续字辈 | `loading``list``empty``edit``error` |
G11 的公开范围提供“仅成员可见”和“公开可申请”两个明确选项,并给出权限解释。G12 采用外部思维导图可借鉴的逐世代行结构,但视觉表面仍完全使用本项目资产;编辑区保留接口清单所描述的连续 `content` 与“缺失旧世代时停止”策略位置。
## 3. 视觉资产
- 两页复用 `g03-create-flow-panel.png` 作为完整双金线宣纸面板。
- 输入、字辈行使用 `g06-search-input-wide.png` 完整位图皮肤。
- 主次操作分别使用 `a01-primary-button.png``a01-secondary-button.png`
- CSS 只负责布局、真实文字和点击层,不绘制卡片背景、边框、圆角或装饰。
- 现有资产已能覆盖页面,因此本轮没有调用 ImageGen 新增近似资产。
## 4. 截图证据
目录:`screens/runtime/2026-07-14/g-module-audit/settings-poems/`
- 改前基线:`00-g11-before-360x800.png``00-g12-before-360x800.png`
- G11`01-g11-form-360x800.png``02-g11-success-360x800.png``03-g11-form-412x915.png`
- G12`04-g12-list-360x800.png``05-g12-empty-360x800.png``06-g12-edit-360x800.png``07-g12-list-412x915.png`
- 同画布对比:`08-g11-before-after-comparison.png``09-g12-before-after-comparison.png`
## 5. Product Design 审视
- G11 相比通用设置壳,信息层级变为“设置目的 → 名称 → 公开范围及权限解释 → 访问说明 → 保存”,不再出现无法完成的管理权转让入口。
- G12 相比通用详情壳,逐世代字辈可直接扫描,当前字辈使用朱砂强调;空态与编辑态仍保持同一页面语义。
- 360 × 800 与 412 × 915 下,完整面板、按钮和底部山水均未被裁切;编辑态主次动作层级清楚。
- 截图不能证明 Android 字体放大、读屏播报和真机触摸区域,留到最终真机验证阶段。
## 6. 验证
- `G11-G12-SETTINGS-POEMS-CONTRACT PASS`
- `G11-G12-SETTINGS-POEMS-RUNTIME-SMOKE PASS`
- 页面代码不引用 `@/utils/api.js``appApi`
+18 -10
View File
@@ -1,6 +1,6 @@
# P00 页面结构与资产清单
> 状态:55 条最终编号路由与第一版统一视觉母版已落地;A01/A02/A04/A05/A06/G01 两种状态与 G03 `create|ancestor` 已验收;G06 已合并 G07 的三态,最新「朱砂题签」候选只完成初始态截图,结果/无结果同版截图与用户审美确认待补
> 状态:54 条最终编号路由已落地;A01/A02/A04/A05/A06/G01 两种状态与 G03 `create|ancestor` 已验收;其余 47 条路由均已有 `[~]` 视觉候选和真实运行截图,等待用户逐页审核
> 目的:让页面、路由和资产都有唯一、可读、可审计的归属;本文件是 P-00 的执行清单。
## 1. 页面命名规则
@@ -24,17 +24,19 @@
| `pages/auth/a02-login` | A02 账号登录 | `pages/auth/a02-login.vue` | 已迁移;已验收,视觉冻结 |
| `pages/genealogy/g01-my-genealogies` | G01 我的家谱 | `pages/genealogy/g01-my-genealogies.vue` | 已迁移;视觉冻结 |
| `pages/genealogy/g03-create-genealogy` | G03 创建家谱/始祖录入 | `pages/genealogy/g03-create-genealogy.vue` | `step=create|ancestor`;已获用户验收,视觉冻结 |
| `pages/genealogy/g05-genealogy-overview` | G05 家谱总览 | `pages/genealogy/g05-genealogy-overview.vue` | 已迁移;未验收 |
| `pages/genealogy/g06-search-genealogies` | G06 公开家谱搜索/结果/无结果 | `pages/genealogy/g06-search-genealogies.vue` | 已收敛 G07;朱砂题签候选已实现,只有初始态新截图,三态复核与用户审美确认待补 |
| `pages/genealogy/g10-application-review` | G10 管理员入谱审核 | `pages/genealogy/g10-application-review.vue` | 已迁移;未验收 |
| `pages/tree/t01-tree-overview` | T01 世系树总览 | `pages/tree/t01-tree-overview.vue` | 已迁移;未验收 |
| `pages/tree/t03-member-profile` | T03 成员档案 | `pages/tree/t03-member-profile.vue` | 已迁移;未验收 |
| `pages/genealogy/g05-genealogy-overview` | G05 家谱总览 | `pages/genealogy/g05-genealogy-overview.vue` | `loading|ready|empty|error`;视觉候选已实现,待集中审美确认 |
| `pages/genealogy/g06-search-genealogies` | G06 公开家谱搜索/结果/无结果 | `pages/genealogy/g06-search-genealogies.vue` | 已收敛 G07;朱砂题签三态候选已截图,待集中审美确认 |
| `pages/genealogy/g08-join-application` | G08 申请加入家谱 | `pages/genealogy/g08-join-application.vue` | `form|success|error`;本地模拟提交已验证,待集中审美确认 |
| `pages/genealogy/g09-my-applications` | G09 我的申请 | `pages/genealogy/g09-my-applications.vue` | `loading|list|empty|error`;待集中审美确认 |
| `pages/genealogy/g10-application-review` | G10 管理员入谱审核 | `pages/genealogy/g10-application-review.vue` | `loading|list|empty|error`;待集中审美确认 |
| `pages/tree/t01-tree-overview` | T01 世系树总览 | `pages/tree/t01-tree-overview.vue` | 已收敛 T02 四态;视觉候选待审 |
| `pages/tree/t03-member-profile` | T03 成员档案 | `pages/tree/t03-member-profile.vue` | 详情/失败候选待审 |
| `pages/family/f01-family-feed` | F01 家族动态 | `pages/family/f01-family-feed.vue` | 已迁移;未验收 |
| `pages/family/f02-publish-feed` | F02 发布动态 | `pages/family/f02-publish-feed.vue` | 已迁移;未验收 |
| `pages/notification/n01-message-center` | N01 消息中心 | `pages/notification/n01-message-center.vue` | 已迁移;未验收 |
| `pages/profile/m01-profile-home` | M01 我的首页 | `pages/profile/m01-profile-home.vue` | 已迁移;未验收 |
上述表仅保留首批迁移的历史背景;当前最终 55 路由、文件存在性和中文编号注释的唯一合同是 `tests/full-page-visual-contract.ps1`。临时 `content-list-skeleton` 已由谱文、相册、礼仪、备忘录和字辈诗最终页面替代并移除。
上述表仅保留首批迁移的历史背景;当前最终 54 路由、文件存在性和中文编号注释的唯一合同是 `tests/full-page-visual-contract.ps1`。临时 `content-list-skeleton` 已由谱文、相册、礼仪、备忘录和字辈诗最终页面替代并移除。
迁移要求:同一轮更新页面文件、`pages.json`、所有跳转路径、测试和中文文件头注释;未完成全部引用更新时,不得删除旧路径。
@@ -66,9 +68,12 @@ static/assets/
opaque/
g01-empty-panel.png
g03-create-flow-panel.png
g05-overview-surface.png
g06-search-title-strip.png
g06-search-input-wide.png
g06-search-button.png
application-record-card.png
application-status-card.png
transparent/
tree/
opaque/
@@ -88,7 +93,7 @@ static/assets/
| --- | --- | --- | --- | --- |
| `foundation/opaque/auth-page-paper.jpg` | opaque / 750×1334 | 认证页纸纹底色 | A01、A02、A04、A05、A06 | 保留 |
| `foundation/opaque/auth-header.png` | opaque / 750×196 | 认证页红色页头 | A01、A02、A04、A05、A06 | 保留 |
| `foundation/opaque/page-paper.jpg` | opaque / 852×1846 | 根页宣纸底图 | G01、G06 | 保留 |
| `foundation/opaque/page-paper.jpg` | opaque / 852×1846 | 根页宣纸底图 | G、T、F、R、N、M 非认证页面 | 保留 |
| `foundation/opaque/root-header-cinnabar.jpg` | opaque / 720×184 | 根页朱砂页头 | `PageHeader` | 保留 |
| `foundation/transparent/auth-ink-scenery.png` | transparent / 750×1334 | 认证页淡墨叠层 | A01、A02、A04、A05、A06 | 保留 |
| `foundation/transparent/auth-title-cloud.png` | transparent / 200×120 | 认证标题右侧云纹 | A01、A02、A04、A05、A06 | 保留 |
@@ -106,11 +111,14 @@ static/assets/
| `foundation/transparent/notice.png` | transparent / 96×96 | 通知图标 | `PageHeader` | 保留 |
| `foundation/transparent/chevron-right.png` | transparent / 96×96 | 返回与列表箭头 | A02、A04、A05、A06、`GenealogyCard` | 保留 |
| `modules/genealogy/opaque/g01-empty-panel.png` | opaque / 1122×1402 | 无家谱空态完整宣纸卡,双金线、角饰、云纹与下沿山水 | G01 `state=empty` | 保留;由 ImageGen 生成,已获用户验收 |
| `modules/genealogy/opaque/g03-create-flow-panel.png` | opaque / 1122×1500 | 创建流程完整宣纸表单面板,双金线、回纹角饰、云纹与下沿淡墨山水 | G03 `step=create|ancestor` | 保留;由 ImageGen 生成,已获用户验收 |
| `modules/genealogy/opaque/g03-create-flow-panel.png` | opaque / 1122×1500 | 完整宣纸任务面板,双金线、回纹角饰、云纹与下沿淡墨山水 | G03、G08、G11、G12、T01、T03 至 T06、T08、F/R/N/M 表单/详情/设置/状态 | 保留;由 ImageGen 生成,G03 已获用户验收,复用页待审 |
| `modules/genealogy/opaque/g05-overview-surface.png` | opaque / 1122×1506 | 家谱总览完整视觉表面,含墨蓝谱名区、四宫格宣纸槽、金线近况分隔与底部访问说明面 | G05 `loading|ready|empty|error` | 保留;由 ImageGen 参考 G05 改前截图生成,页面只叠放真实文字和点击层;待集中审美确认 |
| `modules/genealogy/opaque/g06-search-title-strip.png` | opaque / 1122×220 | 朱砂题签完整视觉面,含古金边饰与云纹,不烘焙文字或印玺 | G06 顶部检索题签 | 保留;用户选定 ImageGen 第 3 稿方向,Vue 叠放真实 `brand-seal.png` 与标题文字 |
| `modules/genealogy/opaque/g06-search-input-wide.png` | opaque / 1120×248 | 宽版独立宣纸输入框完整皮肤,含古金双线与角饰 | G06 真实输入框底层 | 保留;真实原生输入控件叠放其上 |
| `modules/genealogy/opaque/g06-search-input-wide.png` | opaque / 1120×248 | 宽版独立宣纸字段完整皮肤,含古金双线与角饰 | G、T 与 F/R/N/M 输入、详情段落、设置行 | 保留;真实文字与控件叠放其上 |
| `modules/genealogy/opaque/g06-search-input.png` | opaque / 720×248 | 早期窄版输入框完整皮肤 | 当前无引用 | 已停用;仅因用户要求停止工作而保留,恢复工作后需在引用扫描通过且用户允许时删除 |
| `modules/genealogy/opaque/g06-search-button.png` | opaque / 300×132 | 独立朱砂搜索按钮完整皮肤,含古金双线与角饰 | G06 真实搜索点击控件底层 | 保留;由 ImageGen 生成,真实点击控件与文本叠放其上 |
| `modules/genealogy/opaque/application-record-card.png` | opaque / 1050×360 | 带右下双审核槽的完整申请记录卡 | G10 待审核申请 | 保留;由 ImageGen 参考 G09/G10 改前截图生成,按钮另用完整位图叠放 |
| `modules/genealogy/opaque/application-status-card.png` | opaque / 1050×360 | 无按钮槽的完整信息状态卡 | G09/G10、T01/T07/T08、F/R/N/M 列表/时间轴/通知/状态 | 保留;由 ImageGen 精确移除审核槽,避免无操作状态出现假按钮 |
| `foundation/transparent/meta-location.png` | transparent / 96×96 | 地点元信息 | G01、`GenealogyCard` | 保留 |
| `foundation/transparent/meta-member.png` | transparent / 96×96 | 成员元信息 | G01、`GenealogyCard` | 保留 |
| `foundation/transparent/meta-admin.png` | transparent / 96×96 | 管理员元信息 | G01 | 保留 |
@@ -0,0 +1,46 @@
# T01–T08 世系与成员设计记录
> 日期:2026-07-14
> 状态:T02 已收敛至 T01T01、T03–T08 共 7 个页面候选、运行截图和 Product Design 审视完成,统一标记 `[~]`。
## 1. 信息架构
- T01 是阅读世系的唯一页面,`tree|landscape|empty|error` 为同页状态;T02 路由、文件和目录条目已删除。
- T03 负责成员档案;T04 新增亲属、T05 编辑成员、T06 维护关系分别保留独立任务页,但共享同一高质量表单母版。
- T07 是成员检索目录;T08 将隐私、离世纪念和无权限保留为同一路由的三个状态,不拆成重复页面。
- 接口文档仅用于确定成员字段、关系冲突、隐私和状态位置;所有候选使用页面内模拟数据,不调用 `appApi`
## 2. 视觉体系
- 延续已确认的宣纸、朱砂、古金与墨褐体系。
- 世系画布和成员表单复用 `g03-create-flow-panel.png` 完整宣纸面;成员节点/字段使用 `g06-search-input-wide.png`;信息条、目录卡和状态卡使用 `application-status-card.png`
- 主次操作使用 `a01-primary-button.png``a01-secondary-button.png`CSS 仅负责布局、文字和点击层。
- 现有完整位图已覆盖需要,因此没有生成语义重复的新资产。
## 3. Product Design 截图审视
### T01
目录:`screens/runtime/2026-07-14/t01-audit/`
- 候选:`04-t01-tree-360x800.png``05-t01-landscape-360x800.png``06-t01-empty-360x800.png``07-t01-error-360x800.png``08-t01-tree-412x915.png`
- 对比:`09-t01-before-after-comparison.png`
- 首轮状态截图发现状态文案位于 1180rpx 横向画布中导致右侧裁切;先更新契约要求 `tree-canvas--state`,再将非树状态画布收窄到当前视口,重新截图后已完整显示。
### T03T08
目录:`screens/runtime/2026-07-14/t-module-audit/`
- 关键候选:`01-t03-detail-360x800.png``13-t07-list-412x915.png`
- 同画布对比:`14-t03-comparison.png``15-t04-t06-comparison.png``16-t07-t08-comparison.png`
- 360/412 下表单字段、成员卡、状态切换和主次操作均未裁切;T06 冲突动作与 T08 三种权限状态层级清楚。
## 4. 验证
- `T01-TREE-STATE-CONTRACT PASS`
- `T01-TREE-STATE-RUNTIME-SMOKE PASS`
- `T03-T08-MEMBER-FLOW-CONTRACT PASS`
- `T03-T08-MEMBER-FLOW-RUNTIME-SMOKE PASS`
- `PASS compile audit`
H5 截图不能替代 Android 真机字体放大、读屏和触摸目标验证;这些留到全量候选后的设备复核。
Binary file not shown.

After

Width:  |  Height:  |  Size: 343 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 360 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 359 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 360 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 350 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 288 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 318 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 260 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 262 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 266 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 321 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 264 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 251 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 312 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 326 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 320 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 254 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 308 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 256 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 297 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 314 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 318 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 317 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 320 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 269 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 302 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 322 KiB

+14 -41
View File
@@ -9,42 +9,33 @@
必须保留已有未提交改动;不使用 worktree 或多代理;不执行 `git add``git commit``git push`、上传、`git reset``git checkout`。A01、A02、A04、A05、A06、G01、G03 的可见效果已获用户验收并冻结,不能以重构或顺手优化的名义改变。G07 是 G06 的页面内状态,不得重新建为独立页。
当前唯一应向用户确认的候选是 G06:用户选定最近 ImageGen 第 3 稿的「朱砂题签 + 宽输入框 + 搜索按钮」方向,通用印玺已替换为项目真实 `brand-seal.png`。最后实际运行证据只有初始态 `docs/design/screens/runtime/2026-07-14/G06-selected-strip-final-initial-360x800.png`;同版结果态和无结果态截图未完成,G06 也**未验收**。不要把旧 `G06-no-panel-*` 截图当作当前最终版本。先请用户看当前初始态并决定是否继续;用户要求继续 G06 时,先截图审视,再补三态和验证;用户明确验收后,才继续 T02→T01(除非用户指定新优先级)
用户已授权先完成全部页面候选、后集中审核。54 条最终路由均已覆盖:7 个已验收冻结,其余 47 个统一标 `[~]`;不得把候选写成用户已验收。下一阶段由用户按规划逐页勾选,接口只作功能布局参考,本轮没有对接
新环境必须可用并实际调用:`product-design:index``product-design:audit``imagegen``superpowers:test-driven-development``superpowers:systematic-debugging``superpowers:verification-before-completion`。没有这些能力时,先安装/启用同等能力,不得假装已经完成设计审视或位图生成。
## 1. 当前停点:先看这一节
项目已有 55 条最终编号路由,第一版都可以浏览;这不等于 55 个页面已完成视觉验收。
项目已有 54 条最终编号路由T02 已收敛到 T01 同页状态。这不等于 54 个页面已完成视觉验收。
- 用户已经验收的视觉基准有个:**A01 启动/登录引导**、**A02 账号登录**、**A04 注册账号**、**A05 重置密码**、**A06 登录状态**、**G01 我的家谱**。它们的可见效果已冻结。
- **A02 账号登录**已经完成资产化重做、功能回归和多尺寸截图,并于 2026-07-14 获用户审美验收。A03 已删除;A02 的短信 Tab 是唯一手机号验证码登录入口。A04、A05、A06 均于同日完成独立重做并获用户审美验收;G02 已按用户确认合并为 G01 的 `?state=empty` 并获用户审美确认G04 已合并为 G03 的 `?step=ancestor`G03 双步骤已获用户继续授权通过;G07 已合并为 G06 初始/结果/无结果状态。G06 曾被指出页头层级、伪控件和大面板截图感;用户最后选择最近 ImageGen 第 3 稿,当前以朱砂题签、真实品牌谱印、独立宽输入框和搜索按钮实现。最后候选只完成初始态新截图,结果态/无结果态的同版截图和用户审美确认尚未完成
- 其余模块的页面仍属于后续视觉设计任务;其中不少页面目前由 `components/ModulePage.vue``data/page-catalog.js` 提供第一版可浏览壳。这个壳是过渡方案,不是最终 UI
- 用户已经验收的视觉基准有个:**A01 启动/登录引导**、**A02 账号登录**、**A04 注册账号**、**A05 重置密码**、**A06 登录状态**、**G01 我的家谱**、**G03 创建家谱**。它们的可见效果已冻结。
- **A02 账号登录**已经完成资产化重做、功能回归和多尺寸截图,并于 2026-07-14 获用户审美验收。A03 已删除;A02 的短信 Tab 是唯一手机号验证码登录入口。A04、A05、A06 均于同日完成独立重做并获用户审美验收;G02 已按用户确认合并为 G01 的 `?state=empty`G04 已合并为 G03 的 `?step=ancestor`G07 已合并为 G06 初始/结果/无结果状态。G06 三态候选截图和内部审视已经完成,但尚未获得用户视觉验收
- G05、G06、G08G12、T01、T03T08、F01F10、R01R11、N01N02、M01–M10 已完成候选截图和审视;共享 `ModulePage.vue` 已升级为全位图高规格母版,不再是旧 CSS 过渡壳
- 不提交、不推送、不上传 Git;由用户自行处理版本库操作。
下一位执行者先读取当前停点与已验收页面,再向用户展示 **G06 当前候选的初始态** 并等待反馈:
- `docs/design/screens/runtime/2026-07-14/A05-after-360x800.png`
- `docs/design/screens/runtime/2026-07-14/A05-after-412x915.png`
- `docs/design/screens/runtime/2026-07-14/A06-after-360x800.png`
- `docs/design/screens/runtime/2026-07-14/A06-after-412x915.png`
- `docs/design/screens/runtime/2026-07-14/A06-restricted-360x800.png`
- `docs/design/screens/runtime/2026-07-14/G06-selected-strip-final-initial-360x800.png`
`G06-no-panel-*` 图是被用户否定后各轮尝试的历史证据,只能帮助理解迭代原因,不能用于当前验收。用户若要求调整,只修改对应页面状态并重新截图、复核;当前 A01、A02、A04、A05、A06、G01 两种状态与 G03 `create|ancestor` 均已明确通过。G06 当前候选的初始态待用户确认,结果态/无结果态的新截图待补;不得把 G06 写成已验收。只有用户明确通过 G06 或明确要求跳过它,下一项才按 T02→T01 状态收敛执行。
下一位执行者先读取当前停点与已验收页面,然后按 `docs/规划.md``[~]` 清单协助用户逐页审核。默认只从 `docs/design/视觉证据索引.md` 打开一张代表图或一张状态联系表;多尺寸、before/after 与历史废稿仅在发现问题时展开。旧 `G06-no-panel-*` 图只能帮助理解迭代原因,不能用于当前验收。全部 47 个候选都等待用户逐页确认,不再按模块批量推进新页面。
## 1.1 迁移前必须保留的文件
本手册、G06 最终候选资产和截图工具均是接管所需文件;换电脑前,用户需在自行提交/上传时包含它们,或完整复制当前工作区。我没有执行任何 Git 暂存、提交、推送或上传操作。
至少要保留:`docs/design/视觉设计交接手册.md``docs/交接记录.md``scripts/capture-chrome-page.js`G06 页面/资产/测试、`docs/design/screens/runtime/2026-07-14/G06-selected-strip-final-initial-360x800.png`、A02 页面/资产/测试、`docs/design/screens/runtime/2026-07-13/` 的运行证据,以及现有计划与设计记录。只复制已提交的旧版本会丢失当前停点。
至少要保留:`docs/design/视觉设计交接手册.md``docs/交接记录.md``docs/design/视觉证据索引.md``scripts/capture-chrome-page.js`全部页面/资产/测试、`docs/design/screens/runtime/` 的运行证据,以及现有计划与设计记录。只复制已提交的旧版本会丢失当前停点。
## 2. 必读文件与唯一职责
| 文件 | 负责什么 |
| --- | --- |
| `AGENTS.md` | 最小修改、保护既有未提交内容、验证后再交付的项目规则。 |
| `docs/规划.md` | 55 条独立路由的范围、模块顺序和“完成”的定义。 |
| `docs/规划.md` | 54 条独立路由的范围、模块顺序和“完成”的定义。 |
| `docs/design/D1_安卓视觉规范与页面壳.md` | Android 页面、资产、布局与中文注释的硬约束。 |
| `docs/design/P00_页面结构与资产清单.md` | 路由、页面文件和资产台账的唯一清单。 |
| `docs/superpowers/plans/2026-07-13-product-design-visual-refinement.md` | 已批准的分模块视觉精修计划和验收顺序。 |
@@ -85,17 +76,7 @@
### 已保存的运行证据
目录:`docs/design/screens/runtime/2026-07-13/`
| 文件 | 状态 |
| --- | --- |
| `A02-before-360x800.png` | 改造前基线。 |
| `A02-after-320x568.png` | 小屏账号密码态;允许纵向滚动,不允许横向裁切。 |
| `A02-after-360x640.png` | 中屏账号密码态。 |
| `A02-after-360x800.png` | 主对比尺寸。 |
| `A02-after-412x915.png` | 大屏账号密码态。 |
| `A02-sms-360x800.png` | 真实切换后的短信验证码态。 |
| `A02-before-after-360x800.png` | 左旧右新的并排对比。 |
默认只查看 `docs/design/screens/handoff/2026-07-13/A02-after-360x800.png`。小屏、大屏、短信状态和 before/after 证据保存在项目外完整归档中,按需展开,不在交接正文连续罗列。
### 通过/未通过的界线
@@ -137,7 +118,7 @@ node scripts/capture-chrome-page.js `
360 800
```
截图只作为 `docs/design/screens/runtime/<日期>/` 下的设计证据,绝不能放回 `static/assets/` 作为运行资源。每个代表页面至少做 360 × 800;模块代表页还要做 320 × 568、360 × 640、360 × 800、412 × 915。最终 Android/HBuilderX 截图仍需补做,H5 Chrome 截图不能替代它。
截图先保存到已忽略的 `docs/design/screens/runtime/<日期>/`,绝不能放回 `static/assets/` 作为运行资源。每个代表页面至少做 360 × 800;只有出现比例风险或用户要求时再展开 320 × 568、360 × 640、412 × 915,多状态页面优先合成一张联系表。用户确认需要长期交接时,只把代表图复制到 `docs/design/screens/handoff/`。最终 Android/HBuilderX 截图仍需补做,H5 Chrome 截图不能替代它。
## 6. 后续执行顺序与每轮产物
@@ -145,19 +126,11 @@ node scripts/capture-chrome-page.js `
1. A02 与 A04 均已通过;不因后续页面改动而触碰其可见效果。
2. 若用户提出明确调整:只在对应页面修正,先调整对应契约测试,再改页面/资产,重新截图复核。
3. A05、A06 与 G01 空状态均已通过并完成状态同步;G04 已合并为 G03 `step=ancestor`,该双步骤已获用户通过;G07 已合并为 G06 三态。G06 当前朱砂题签候选只完成初始态新截图,尚待结果/无结果截图、审视和用户审美确认。
3. A05、A06 与 G01 空状态均已通过并完成状态同步;G04 已合并为 G03 `step=ancestor`,该双步骤已获用户通过;G07 已合并为 G06 三态。G06 三态候选和内部审视已完成,仍待用户视觉确认。
### 其后的模块顺序
### 当前唯一顺序:逐页视觉审核
1. **账号 A04--A06**:以已验收 A02 为认证样板,但每个页面保留各自的任务与状态,不能直接复制表单。绑定手机号以后归个人中心安全设置的独立入口;微信授权取消、失败、受限归 A06
2. **家谱 G03、G05--G12**:G07→G06 搜索流程已收敛;先由用户审美确认 G06 当前候选,并补齐同版三态截图;之后再推进家谱概览、世代字、申请与设置。用户明确授权连续推进时,先执行 T02→T01 状态收敛。
3. **世系树 T01--T08**:先建立树、成员档案、关系编辑三类代表页。
4. **家族内容 F01--F10**:动态、文章、相册、媒体上传分别建立内容档案式层级。
5. **人物记录 R01--R11**:人物档案、礼仪活动、成长时间线先定样板。
6. **通知 N01--N02**:区分未读、已读、时间和详情层级,不只依赖颜色。
7. **我的 M01--M10**:个人身份首页与安全设置先做,后续设置页按统一行式结构展开。
`ModulePage.vue`/`page-catalog.js` 中属于本轮模块的临时入口,必须在同一轮被真实页面替换并移除相应依赖;不得保留“新旧两套入口”或把通用壳伪称为精修完成。
54 条路由候选已经全部覆盖。用户从 `docs/规划.md` 指定或按清单顺序审核页面;只有用户明确通过才改 `[x]`,要求返工则改 `[!]` 并只修改对应页面。接口、Android 打包和真机复核均留在视觉审核之后
### 每个模块的固定工作流
@@ -174,7 +147,7 @@ node scripts/capture-chrome-page.js `
| 能力/技能 | 何时使用 | 项目中的正确做法 |
| --- | --- | --- |
| `product-design:index``product-design:audit` | 审视、评价或设计一个已运行页面/流程时;接管 G06 后的第一项视觉工作也必须使用。 | 先拿到真实路由截图,再依据 A01/G01 与页面任务写审视结论;不要靠源码猜好不好看。 |
| `product-design:index``product-design:audit` | 审视、评价或设计一个已运行页面/流程时。 | 先拿到真实路由截图,再依据 A01/G01 与页面任务写审视结论;不要靠源码猜好不好看。 |
| Product Design 的 `user-context``get-context``ideate``image-to-code`、Design QA 流程 | 需要探索方向、选择视觉方案、将已选样稿落地、或交付前比对时。 | 有明确视觉真源时先忠实落地;没有真源且需要新方向时先给可比较方案,选定后再实现;最后在 `design-qa.md` 诚实记录通过或阻塞。 |
| `imagegen` | 现有资产无法承担完整视觉面时。 | 先用 A01/G01 实图作为参考,明确输出是完整不透明面板还是透明叠加件;检查实际尺寸/Alpha 后再放入项目。不能用截图代替资产。 |
| `superpowers:brainstorming``superpowers:writing-plans` | 新模块或会改变页面行为的创作工作开始前。 | 先写清用户目标、交互和验收,不以“尽快”跳过设计决定。 |
+64
View File
@@ -0,0 +1,64 @@
# 视觉证据索引
> 本仓库只保留低负担交接证据:每页一张代表图,复杂模块使用联系表。完整 before/after、多尺寸、历史废稿和全部运行截图保存在项目外本地归档,不进入 Git。
## 完整本地归档
Windows 路径:`C:\Users\Rain\Desktop\job\jiapuapp-design-evidence-archive-2026-07-15`
归档内的 `design-full/` 是精简前完整 `docs/design/``tmp/` 是 ImageGen 与调试临时源文件。新电脑默认不需要这份归档;只有追溯历史决策或检查旧尺寸时才单独复制。
## 已验收视觉基准
- A01[启动/登录引导](screens/handoff/2026-07-13/A01-360x800.png)
- A02[账号登录](screens/handoff/2026-07-13/A02-after-360x800.png)
- A04[注册账号](screens/handoff/2026-07-14/A04-after-balance-360x800.png)
- A05[重置密码](screens/handoff/2026-07-14/A05-after-360x800.png)
- A06[登录状态](screens/handoff/2026-07-14/A06-after-360x800.png)
- G01[我的家谱](screens/handoff/2026-07-14/G01-default-after-360x800.png)
- G03[创建家谱](screens/handoff/2026-07-14/G03-create-after-360x800.png)
## F、R、N、M 默认审核入口
- [F03F10 联系表](screens/handoff/2026-07-14/all-page-candidates/00-family-contact-sheet.png)
- [R01R11 联系表](screens/handoff/2026-07-14/all-page-candidates/00-records-contact-sheet.png)
- [N02、M02M10 联系表](screens/handoff/2026-07-14/all-page-candidates/00-profile-notification-contact-sheet.png)
- [F01 家族动态](screens/handoff/2026-07-14/root-pages-audit/01-f01-list-360x800.png)
- [F02 发布动态](screens/handoff/2026-07-14/root-pages-audit/03-f02-form-360x800.png)
- [N01 消息中心](screens/handoff/2026-07-14/root-pages-audit/05-n01-list-360x800.png)
- [M01 我的首页](screens/handoff/2026-07-14/root-pages-audit/07-m01-ready-360x800.png)
## G 家谱候选
- [G05 家谱总览](screens/handoff/2026-07-14/g-module-audit/g05/01-g05-ready-360x800.png)
- G06[初始态](screens/handoff/2026-07-14/G06-selected-strip-final-initial-recheck-360x800.png)、[结果态](screens/handoff/2026-07-14/G06-selected-strip-final-results-360x800.png)、[无结果态](screens/handoff/2026-07-14/G06-selected-strip-final-empty-360x800.png)
- [G08 申请入谱](screens/handoff/2026-07-14/g-module-audit/applications/01-g08-form-360x800.png)
- [G09 我的申请](screens/handoff/2026-07-14/g-module-audit/applications/04-g09-list-360x800.png)
- [G10 申请审核](screens/handoff/2026-07-14/g-module-audit/applications/06-g10-list-360x800.png)
- [G11 家谱设置](screens/handoff/2026-07-14/g-module-audit/settings-poems/01-g11-form-360x800.png)
- [G12 字辈诗](screens/handoff/2026-07-14/g-module-audit/settings-poems/04-g12-list-360x800.png)
## T 世系与成员候选
- [T01 世系树](screens/handoff/2026-07-14/t01-audit/04-t01-tree-360x800.png)
- [T03 成员档案](screens/handoff/2026-07-14/t-module-audit/01-t03-detail-360x800.png)
- [T04 新增亲属](screens/handoff/2026-07-14/t-module-audit/03-t04-form-360x800.png)
- [T05 编辑成员](screens/handoff/2026-07-14/t-module-audit/04-t05-form-360x800.png)
- [T06 编辑关系](screens/handoff/2026-07-14/t-module-audit/05-t06-form-360x800.png)
- [T07 成员目录](screens/handoff/2026-07-14/t-module-audit/07-t07-list-360x800.png)
- [T08 成员状态](screens/handoff/2026-07-14/t-module-audit/09-t08-privacy-360x800.png)
## 风格锚点与设计记录
- [G01 美术锚点](references/G01-我的家谱-美术锚点.png)
- [公共操作图标锚点](references/公共操作图标-风格锚点.png)
- [页面与状态合并审计](2026-07-14_页面与状态合并审计.md)
- [G05 设计记录](G05_家谱总览_设计记录.md)
- [G08G10 设计记录](G08-G10_入谱申请链路_设计记录.md)
- [G11G12 设计记录](G11-G12_设置与字辈诗_设计记录.md)
- [T01T08 设计记录](T01-T08_世系与成员_设计记录.md)
- [F/R/N/M 设计记录](F-R-N-M_全量候选设计记录.md)
## 后续截图规则
新的运行截图仍输出到 `docs/design/screens/runtime/<日期>/`,该目录已被 `.gitignore` 排除。用户确认需要作为长期交接基准时,再把单张代表图复制到 `docs/design/screens/handoff/` 并更新本索引;不要把整轮截图重新加入 Git。
@@ -4,9 +4,9 @@
**Goal:** 将 T02 小屏、空数据和失败提示收敛进 T01,保留世系节点交互并以位图资产替代 CSS 表面。
**Architecture:** T01 读取 `state``genealogyId`,由一个 `treeState` 唯一管理四种同页状态。T02 文件与路由删除;所有节点/画布/底部信息表面由三个不透明位图承载。
**Architecture:** T01 读取 `state``genealogyId`,由一个 `treeState` 唯一管理四种同页状态。T02 文件与路由删除;所有节点/画布/底部信息表面复用现有完整不透明位图承载。
**Tech Stack:** uni-app、Vue 3、现有 mock `appApi`、ImageGen、PowerShell 静态契约、Chrome DevTools Protocol 冒烟。
**Tech Stack:** uni-app、Vue 3、页面内模拟数据、现有完整位图资产、PowerShell 静态契约、Chrome DevTools Protocol 冒烟。
## Global Constraints
@@ -16,22 +16,22 @@
### Task 1: TDD 契约(RED
- [ ] 创建 `tests/t01-tree-state-contract.ps1``tests/t01-tree-state-runtime-smoke.js`
- [ ] 静态契约要求 T02 路由/文件不存在,T01 含 `tree|landscape|empty|error`、三位图、T03/T04 跳转和无 CSS 表面;运行冒烟要求三种查询状态及 `genealogyId=1001` 节点跳转。
- [ ] 运行两个新测试,确认当前实现因 T02 仍存在和 T01 缺少状态节点而红灯。
- [x] 创建 `tests/t01-tree-state-contract.ps1``tests/t01-tree-state-runtime-smoke.js`
- [x] 静态契约要求 T02 路由/文件不存在,T01 含 `tree|landscape|empty|error`、三类完整位图、T03/T04 跳转和无 CSS 表面;运行冒烟要求三种查询状态及 `genealogyId=1001` 节点跳转。
- [x] 运行两个新测试,确认当前实现因 T02 仍存在和 T01 缺少状态节点而红灯。
### Task 2: 位图资产
### Task 2: 位图资产复用
- [ ] 用 ImageGen 生成 1122×1320 不透明 `t01-tree-canvas.png`、280×228 不透明 `t01-member-node.png`、1122×280 不透明 `t01-member-sheet.png`;均检查尺寸和四角 alpha
- [ ] 在 P00 登记路径、用途、尺寸、T01 引用与不透明属性
- [x] 审视现有完整资产后,复用 `g03-create-flow-panel.png``g06-search-input-wide.png``application-status-card.png`,不生成语义重复资产
- [x] 在 P00 登记 T01 与后续 T 页面引用
### Task 3: T01 同页状态与 T02 删除(GREEN
- [ ] 修改 `pages/tree/t01-tree-overview.vue`加载后由状态和真实成员确定 `treeState`;画布、节点、信息条使用位图;保留原有 T03/T04/T06/T07 跳转。
- [ ]`pages.json` 删除 `pages/tree/t02-tree-states`,删除 `pages/tree/t02-tree-states.vue`,移除 `data/page-catalog.js``t02`
- [ ] 运行新契约和运行冒烟转绿。
- [x] 修改 `pages/tree/t01-tree-overview.vue`:由状态和页面内模拟成员确定 `treeState`;画布、节点、信息条使用完整位图;保留 T03/T04/T06/T07 跳转。
- [x]`pages.json` 删除 `pages/tree/t02-tree-states`,删除文件并移除 `page-catalog.js``t02`
- [x] 运行新契约和运行冒烟转绿。
### Task 4: 收口
- [ ] 全量路由契约和 A03 路由计数更新为 54;当前文档同步 T02→T01 已完成、G06 仍待审美确认
- [ ] 截图并审视 T01 tree/landscape/empty/error412×915 tree运行全部静态契约、相关冒烟和 `git diff --check`
- [x] 全量路由契约和 A03 路由计数更新为 54;当前文档同步 T02→T01 已完成。
- [x] 截图并审视 T01 tree/landscape/empty/error412×915 tree相关静态契约和冒烟通过。最终全量 `git diff --check` 留到所有模块候选完成前统一执行
@@ -0,0 +1,213 @@
# 交接文档与视觉证据精简实施计划
> **For agentic workers:** REQUIRED SUB-SKILL: Use `superpowers:executing-plans` to implement this plan task-by-task. The user explicitly forbids subagents and worktrees. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** 把当前交接整理为一个无陈旧状态、低图片阅读负担、能让新 GPT 直接恢复技能和审核流程的入口。
**Architecture:** `docs/交接记录.md` 是快速入口,`docs/design/视觉设计交接手册.md` 承担详细操作说明,新增 `docs/design/视觉证据索引.md` 集中保存截图路径;契约测试负责阻止旧状态和技能说明缺失。所有现有 PNG 原地保留。
**Tech Stack:** Markdown、PowerShell 契约测试、Git 只读检查。
## Global Constraints
- 仅修改交接、证据索引、早期报告说明和对应测试,不修改页面、路由、接口或运行资产。
- 54 条最终路由保持 7 个 `[x]`、47 个 `[~]`;候选不能写成用户已验收。
- 不删除、不移动任何现有 PNG。
- 不使用 worktree、多代理、`git add`、commit、push、上传、reset 或 checkout。
- 正文默认每页只指向一张 360×800 代表图;复杂状态优先使用一张状态联系表;历史对比图只进入归档索引。
---
### Task 1: 建立交接文档契约
**Files:**
- Create: `tests/handoff-documentation-contract.ps1`
**Interfaces:**
- Consumes: `docs/交接记录.md``docs/design/视觉设计交接手册.md``docs/规划.md`
- Produces: 一个可重复运行的文档状态与技能说明契约
- [x] **Step 1: 写入预期失败的契约测试**
测试必须断言:
```powershell
$handoff = Get-Content -Raw -Encoding UTF8 'docs/交接记录.md'
$manual = Get-Content -Raw -Encoding UTF8 'docs/design/视觉设计交接手册.md'
$planning = Get-Content -Raw -Encoding UTF8 'docs/规划.md'
foreach ($required in @(
'54 条最终路由', '7 个已验收', '47 个待审核候选',
'product-design:index', 'product-design:audit', 'imagegen',
'superpowers:test-driven-development',
'superpowers:systematic-debugging',
'superpowers:verification-before-completion',
'docs/design/视觉证据索引.md'
)) {
if ($handoff -notmatch [regex]::Escape($required)) {
throw "handoff missing: $required"
}
}
foreach ($stale in @(
'六个已验收',
'结果态和无结果态的同版重新截图尚未完成',
'结果态/无结果态的同版截图和用户审美确认尚未完成',
'下一项按 F、R、N、M 推进'
)) {
if (($handoff + $manual) -match [regex]::Escape($stale)) {
throw "stale handoff text remains: $stale"
}
}
$approved = ([regex]::Matches($planning, '- \[x\]')).Count
$candidate = ([regex]::Matches($planning, '- \[~\]')).Count
if ($approved -ne 7 -or $candidate -ne 47) {
throw "planning counts changed: approved=$approved candidate=$candidate"
}
```
- [x] **Step 2: 运行测试并确认 RED**
Run:
```powershell
powershell -NoProfile -ExecutionPolicy Bypass -File tests/handoff-documentation-contract.ps1
```
Expected: FAIL,至少报告当前交接缺少 `docs/design/视觉证据索引.md` 或仍包含陈旧 G06 表述。
### Task 2: 精简快速入口并建立证据索引
**Files:**
- Modify: `docs/交接记录.md`
- Modify: `docs/design/视觉设计交接手册.md`
- Create: `docs/design/视觉证据索引.md`
- Modify: `.superpowers/sdd/task-1-report.md`
**Interfaces:**
- Consumes: `docs/规划.md` 的 7/47 审核状态、现有 `docs/design/screens/runtime/` 证据目录
- Produces: 快速入口、详细恢复手册、低噪声证据归档和早期报告边界说明
- [x] **Step 1: 重写快速交接入口**
`docs/交接记录.md` 只保留以下五节:
```markdown
# 家谱 APP 交接记录
## 1. 当前准确停点
54 条最终路由;7 个已验收;47 个待审核候选;下一步逐页审核,不对接接口。
## 2. 新 GPT 启动顺序
必读文件、`git status --short`、中文复述停点、禁止修改冻结页。
## 3. 必须调用的能力
Product Design、ImageGen、TDD、系统化排错、完成前验证的触发条件。
## 4. 视觉审核降噪规则
每页一张代表图;复杂状态一张联系表;历史对比按需展开。
## 5. 禁止事项与验证
Git、多代理、worktree、接口对接禁令,以及测试、截图和 `git diff --check`
```
- [x] **Step 2: 修正详细手册中的陈旧停点**
`docs/design/视觉设计交接手册.md` 中的数量统一为 7/47;删除 G06 三态截图未完成和“继续推进 F/R/N/M”的旧描述;保留 G06 未获用户视觉验收、H5 不能替代 Android 真机复核两条边界。
- [x] **Step 3: 新建视觉证据索引**
`docs/design/视觉证据索引.md` 按以下层级组织,所有条目只链接现有文件或目录:
```markdown
# 视觉证据索引
## 默认审核入口
- 全页面候选联系表目录
- 根页面联系表
- G、T 模块联系表
## 已验收基准
- A01、A02、A04、A05、A06、G01、G03 的代表证据
## 候选设计记录
- G、T、F/R/N/M 设计记录
## 历史审计证据
- before/after、多尺寸和废稿目录;仅在回归或争议时展开
```
- [x] **Step 4: 标注早期专项报告的职责边界**
`.superpowers/sdd/task-1-report.md` 标题下增加:
```markdown
> 历史说明:本文件仅记录早期 A02 Task 1 的 RED 验证,不是当前项目总交接入口。当前停点以 `docs/交接记录.md` 和 `docs/规划.md` 为准。
```
- [x] **Step 5: 运行契约并确认 GREEN**
Run:
```powershell
powershell -NoProfile -ExecutionPolicy Bypass -File tests/handoff-documentation-contract.ps1
```
Expected: `HANDOFF-DOCUMENTATION-CONTRACT PASS`
### Task 3: 收口验证
**Files:**
- Verify only: `docs/交接记录.md`
- Verify only: `docs/design/视觉设计交接手册.md`
- Verify only: `docs/design/视觉证据索引.md`
- Verify only: `.superpowers/sdd/task-1-report.md`
- Verify only: `tests/handoff-documentation-contract.ps1`
**Interfaces:**
- Consumes: Task 1 与 Task 2 的文档和契约
- Produces: 可交接的验证证据
- [x] **Step 1: 检查陈旧表述和技能清单**
Run:
```powershell
rg -n "六个已验收|结果态和无结果态.*未完成|下一项按 F、R、N、M 推进" docs/交接记录.md docs/design/视觉设计交接手册.md
rg -n "product-design:index|product-design:audit|imagegen|test-driven-development|systematic-debugging|verification-before-completion" docs/交接记录.md
```
Expected: 第一条无匹配;第二条六类能力全部有匹配。
- [x] **Step 2: 确认规划计数未改变**
Run:
```powershell
$text = Get-Content -Raw -Encoding UTF8 'docs/规划.md'
"approved=$(([regex]::Matches($text, '- \[x\]')).Count)"
"candidate=$(([regex]::Matches($text, '- \[~\]')).Count)"
```
Expected: `approved=7``candidate=47`
- [x] **Step 3: 确认没有删除 PNG**
Run:
```powershell
git diff --name-status -- '*.png'
```
Expected: 不出现以 `D` 开头的 PNG 记录。
- [x] **Step 4: 最终格式检查**
Run:
```powershell
git diff --check
```
Expected: exit code 0。允许 Git 输出 LF/CRLF 提示,但不允许 whitespace error。
@@ -0,0 +1,47 @@
# 仓库精简与交接证据保留实施计划
> **For agentic workers:** REQUIRED SUB-SKILL: Use `superpowers:executing-plans`. The user forbids subagents, worktrees and Git write operations.
**Goal:** 将当前可交接项目整理为约 30MB 的 Git 快照,同时在项目外完整保留设计审计资料。
**Architecture:** 使用 `tests/repository-handoff-size-contract.ps1` 约束标准路径、证据体积和忽略规则;先暂存精简证据,再把完整 `docs/design/``tmp/` 移到固定外部归档,最后恢复精简 `docs/design/` 并更新索引。
**Tech Stack:** Markdown、PowerShell、Git 只读检查。
## Global Constraints
- 不修改 `static/`、页面、路由、接口或运行行为。
- 不删除完整证据,只移动到 `C:\Users\Rain\Desktop\job\jiapuapp-design-evidence-archive-2026-07-15`
- 不使用多代理、worktree、`git add`、commit、push、reset、checkout 或历史重写。
### Task 1: 建立精简契约
**Files:**
- Create: `tests/repository-handoff-size-contract.ps1`
- [x] 写入标准文档路径、证据上限、忽略规则和索引链接检查。
- [x] 运行测试,确认因缺少 `.gitignore` 或证据超限而 RED。
### Task 2: 建立外部完整归档并恢复精简证据
**Files:**
- Create: `.gitignore`
- Modify: `docs/design/视觉证据索引.md`
- Move outside repository: full original `docs/design/`
- Move outside repository: `tmp/` and `tmp-g01-icon-audit.png`
- Restore: compact `docs/design/`
- [x] 将全部设计 Markdown、两张风格锚点和批准的代表截图复制到项目内临时保留区。
- [x] 校验源和目标绝对路径后,将完整 `docs/design/` 移到固定外部归档。
- [x]`tmp/` 和根目录临时审计图移入归档。
- [x] 把临时保留区恢复为标准 `docs/design/`
- [x] 更新视觉证据索引和 `.gitignore`
- [x] 运行精简契约并确认 GREEN。
### Task 3: 收口验证
- [x] 确认外部归档大于 90MB。
- [x] 确认精简截图不超过 20MB、35 个文件。
- [x] 确认 `git diff --name-status -- static` 无输出。
- [x] 运行 `tests/handoff-documentation-contract.ps1`
- [x] 运行 `git diff --check`
@@ -20,12 +20,11 @@
## 视觉
- 保留 `PageHeader`、暖宣纸与朱砂/古金/墨褐的已验收语言,不触及 A01、A02、A04--A06、G01、G03。
- 新增不透明位图:世系画布 `t01-tree-canvas.png`、成员节点 `t01-member-node.png`、底部成员信息条 `t01-member-sheet.png`。它们承载纸纹、边框、关系线和卡片表面;Vue 只叠放真实文本、滚动和点击层。
- 完整画布含轻古金三代关系线,适配当前 mock 的一祖、两中代、三后代布局;远端节点仍使用同一空白节点皮肤,保持可读优先。
- 复用已验收体系中的完整不透明位图:`g03-create-flow-panel.png` 作为世系画布,`g06-search-input-wide.png` 作为成员节点,`application-status-card.png` 作为底部信息条Vue 只叠放真实文本、滚动和点击层,避免新增语义重复资产
- `landscape``empty``error` 使用画布内留白与现有透明云纹,不再生成一个通用状态卡页。
## 契约与验证
- 先红灯:T01 存在且 T02 路由/文件不存在;T01 有四态、三位图、成员资料与新增亲属跳转;节点和信息条不允许 CSS 构造表面。
- 先红灯:T01 存在且 T02 路由/文件不存在;T01 有四态、三类完整位图、成员资料与新增亲属跳转;节点和信息条不允许 CSS 构造表面。
- 运行冒烟:默认 `?state=landscape``?state=empty``?state=error` 分别显示同页状态;`?genealogyId=1001` 显示节点并点选进入 T03。
- 更新全量路由契约至 54,更新交接/P00/规划;截图至少包含 tree、empty、error 与 412×915 tree。
@@ -0,0 +1,57 @@
# 交接文档与视觉证据精简设计
> 日期:2026-07-15
> 状态:用户已确认“正文精简、证据不删除”方向
> 范围:仅整理交接、审核入口与证据索引,不修改页面代码、视觉资产、路由或接口
## 目标
让更换电脑后的新 GPT 只阅读一个简洁入口,就能准确知道当前停点、必须调用的技能、禁止事项、页面审核方式和验证要求;同时减少连续阅读前后对比图、多尺寸截图与历史废稿造成的视觉疲劳。
## 信息结构
1. `docs/交接记录.md` 作为唯一快速入口,只保留:
- 54 条最终路由、7 个已验收页、47 个待审核候选的准确状态;
- 下一步是按 `docs/规划.md` 逐页审核,而不是继续批量设计或对接接口;
- 新 GPT 的必读顺序、技能调用顺序、禁止事项和最小恢复检查;
- 一处“视觉证据归档”链接,不逐张罗列历史对比图。
2. `docs/design/视觉设计交接手册.md` 保留完整操作方法,但删除重复、陈旧的截图清单和过期停点;截图规则改为“审核时默认只看一张代表图,状态复杂时看一张状态联系表,发现问题后才展开历史对比与多尺寸证据”。
3. 新增 `docs/design/视觉证据索引.md`,集中保存现有代表截图、状态联系表、历史前后对比和模块设计记录的路径。证据文件不删除、不移动。
4. `.superpowers/sdd/task-1-report.md` 明确标记为早期 A02 专项报告,不再承担当前总交接职责;当前交接以 `docs/交接记录.md` 为准。
## 新 GPT 的启动契约
交接入口必须明确写出以下顺序:
1. 完整阅读 `AGENTS.md``docs/交接记录.md``docs/规划.md` 和视觉交接手册,先运行 `git status --short`,不得覆盖未提交改动。
2. 设计或审视页面时,先调用 `product-design:index`;基于真实运行截图调用 `product-design:audit`
3. 只有缺少完整位图视觉资产时才调用 `imagegen`;按钮、卡片、边框和装饰面不得用 CSS 伪造。
4. 修改页面行为、路由或可审计视觉结构前调用 `superpowers:test-driven-development`,先更新最小契约测试。
5. 测试、截图、路由或构建异常时调用 `superpowers:systematic-debugging`
6. 宣称完成前调用 `superpowers:verification-before-completion`,运行受影响测试、真实截图复核和 `git diff --check`
7. 不使用 worktree、多代理、`git add`、commit、push、上传、reset 或 checkout。
## 视觉审核降噪规则
- 默认审核材料:每页一张 360×800 代表截图。
- 同页包含关键状态时:使用一张状态联系表代替多张连续截图。
- 412×915、小屏、多尺寸和前后对比图只在发现比例问题、回归问题或用户要求时展开。
- 历史废稿与 before/after 图保留在证据索引的“历史审计”区域,不出现在快速交接正文。
- 用户明确通过一页后才把 `docs/规划.md``[~]` 改为 `[x]`;要求返工时改为 `[!]`
## 需要修正的当前矛盾
- 将已验收数量统一为 7A01、A02、A04、A05、A06、G01、G03。
- 将候选数量统一为 47,并保持“候选不等于用户验收”。
- 删除 G06“结果态、无结果态截图尚未完成”的陈旧描述;保留 G06 尚未获用户视觉验收的事实。
- 删除“下一项继续推进 F/R/N/M”的旧停点,改为“全部候选已覆盖,等待逐页审核”。
- 明确 H5 截图是候选审计证据,最终 Android/HBuilderX 真机或模拟器复核仍在视觉审核之后。
## 验证
- 用文本检索确认交接文档不再出现“六个已验收”“G06 结果态未截图”“继续推进 F/R/N/M”等陈旧表述。
- 确认快速交接入口包含全部六类技能调用规则和 Git/多代理禁令。
- 确认 `docs/规划.md` 仍为 7 个 `[x]`、47 个 `[~]`、0 个遗漏项。
- 运行 `git diff --check`
- 不删除任何现有 PNG,不修改页面、路由、接口或运行资产。
@@ -0,0 +1,33 @@
# 仓库精简与交接证据保留设计
> 日期:2026-07-15
> 状态:用户已批准执行
> 范围:整理设计证据和临时文件,不修改页面、路由、接口或 `static/` 运行资产
## 目标
把可随 Git 上传的当前快照控制在约 30MB,同时让新 GPT 仍能通过标准 `docs/design/` 路径阅读视觉规范、设计记录和代表截图。完整审计证据移到项目外的本地归档,不删除。
## 文件边界
- 完整保留:源码、测试、`static/``docs/design/*.md``docs/design/references/` 两张风格锚点。
- Git 内保留:7 个已验收页面代表图、F/R/N/M 三张联系表、根页面代表图、G05/G06/G08G12 代表图、T01/T03T08 代表图。
- 项目外归档:其余 before/after、多尺寸、历史废稿、完整运行截图以及 `tmp/` 临时源文件。
- 新增 `.gitignore`:排除 `tmp/``unpackage/`、临时审计图和误放到 `docs/superpowers/specs/design/` 的目录。
- 更新 `docs/design/视觉证据索引.md`:只链接 Git 内保留的证据;完整历史归档写明本机路径和非 Git 属性。
## 归档位置
`C:\Users\Rain\Desktop\job\jiapuapp-design-evidence-archive-2026-07-15`
归档目录包含原始 `docs/design/` 完整副本和项目根 `tmp/` 临时源文件。移动前必须确认源目录位于项目内、目标目录位于上述固定路径且目标不存在;不得覆盖已有目录。
## 验收
- `docs/design/` 标准路径恢复,17 个设计 Markdown 均保留。
- `docs/design/screens/` 不超过 20MB、35 个文件。
- `docs/design/视觉证据索引.md` 的 Markdown 链接全部有效。
- `static/` 无任何内容差异。
- 完整外部归档存在且大于 90MB。
- 精简契约、交接契约和 `git diff --check` 通过。
- 不执行 `git add`、commit、push、reset、checkout 或历史重写。
+36 -21
View File
@@ -1,33 +1,48 @@
# 家谱 APP 交接记录
> 更新时间2026-07-14
> 完整恢复手册见:[视觉设计交接手册](design/视觉设计交接手册.md)。本文件只保留当前真实停点,避免与历史草案互相矛盾
> 更新日期2026-07-15
> 这是更换电脑或交给新 GPT 时的唯一快速入口。详细操作见 [视觉设计交接手册](design/视觉设计交接手册.md),页面审核状态以 [总规划](规划.md) 为准
## 当前状态
## 1. 当前准确停点
- `pages.json` 登记 55 条最终编号路由,并由 `tests/full-page-visual-contract.ps1` 约束
- 用户已经验收并冻结可见效果的页面:A01 启动/登录引导、A02 账号登录、A04 注册账号、A05 重置密码、A06 登录状态、G01 我的家谱
- A02 已完成资产化重做、运行截图与自动回归,并于 2026-07-14 获用户视觉验收;A03 已删除,A02 的短信 Tab 是唯一手机号验证码登录入口;A04 已于 2026-07-14 完成平衡修订并获用户视觉验收;A05、A06 已于同日获用户视觉验收。不能改变 A01、A02、A04、A05、A06、G01、G03 的已验收可见效果。G01 的 `?state=empty` 已获用户审美确认;G04 已按用户确认合并为 G03 的 `?step=ancestor`,G03 双步骤已获用户继续授权通过。G07 已收敛为 G06 的初始/结果/无结果状态。G06 经过多轮审视后,用户选定最近 ImageGen 第 3 稿的「朱砂题签 + 独立输入框 + 独立按钮」方向;题签中原本的通用印玺已改为项目真实 `brand-seal.png`。当前仅保存了该最终候选的初始态运行图,结果态和无结果态的同版重新截图尚未完成;**G06 尚未获用户视觉验收,不能写成已完成或冻结。**
- 其余页面可浏览,但包含 `ModulePage.vue`/`page-catalog.js` 的临时第一版壳,不能当作最终视觉设计
- 不提交、不推送、不上传 Git;由用户自行处理
- `pages.json` 当前登记 **54 条最终路由**;重复状态已经收敛到同一页面,例如 G02→G01、G04→G03、G07→G06、T02→T01
- 当前有 **7 个已验收**页面:A01、A02、A04、A05、A06、G01、G03。除非用户提出明确调整,不得改变这些页面已经验收的可见效果
- 其余 **47 个待审核候选**已经完成页面实现、H5 运行截图和内部视觉审视,统一在 `docs/规划.md` 标记为 `[~]`。候选不等于用户验收,只有用户明确通过后才能改为 `[x]`;要求返工时改为 `[!]`
- 下一步是按 `docs/规划.md` 逐页视觉审核,不再批量新增页面。接口资料只用于理解功能、字段和状态位置;本阶段不对接接口
- H5 Chrome 截图是候选审计证据,最终 Android/HBuilderX 真机或模拟器复核仍需在视觉审核后完成
## 新电脑的第一步
## 2. 新 GPT 启动顺序
1. 按顺序阅读 `AGENTS.md`、本文件、[完整交接手册](design/视觉设计交接手册.md)、[总规划](规划.md)、[D1 视觉规范](design/D1_安卓视觉规范与页面壳.md)、[P00 资产台账](design/P00_页面结构与资产清单.md)、`docs/superpowers/plans/2026-07-13-product-design-visual-refinement.md``design-qa.md`;然后先用中文向用户复述当前停点,不改代码
2. 保留工作区改动,运行 `git status --short`;不执行 reset、checkout 或覆盖用户文件
3. A02、A04、A05、A06 已验收、A03 已删除;A04 最终证据为 `A04-after-balance-360x800.png``A04-after-balance-412x915.png`A05 证据为 `A05-after-360x800.png``A05-after-412x915.png`A06 证据为 `A06-after-360x800.png``A06-after-412x915.png``A06-restricted-360x800.png`。G01 默认态前后比对为 `G01-default-before-after-360x800.png`,空状态已验收;G03 双步骤证据为 `G03-create-after-360x800.png``G03-ancestor-after-360x800.png``G03-ancestor-after-412x915.png`,均已获用户继续授权通过。G06 的当前候选图是 `G06-selected-strip-final-initial-360x800.png`:它只证明最后选定样式的初始态。旧 `G06-no-panel-*` 图仅保留为迭代历史,不可当作当前最终三态证据;新的结果态、无结果态截图与用户验收都待补
4. 需要自行截图时,用 `scripts/capture-chrome-page.js` 连接专用 Chrome 的调试端口 `9222`;完整启动命令和截图方式见交接手册第 5 节
1. 全程使用中文。先完整阅读 `AGENTS.md`、本文件、`docs/规划.md``docs/design/视觉设计交接手册.md``docs/design/D1_安卓视觉规范与页面壳.md``docs/design/P00_页面结构与资产清单.md``design-qa.md`
2. 运行 `git status --short`,识别并保留全部未提交改动。不要把旧提交状态当成当前工作区状态
3. 先向用户简洁复述:54 条路由、7 个已验收、47 个待审核,以及准备审核的具体页面;在用户给出页面反馈前不要盲目重做
4. 审核时先看 [视觉证据索引](design/视觉证据索引.md) 的默认代表图;发现具体问题后再展开多尺寸、before/after 或历史废稿
## 已知验证与限制
## 3. 必须调用的能力
- A02 相关的 `a02-asset-alpha-audit``a01-a02-ui-contract``a02-register-entry-contract``capture-chrome-page-contract``compile-audit` 和全量页面路由契约均已在本轮通过;后续修改仍要重新运行受影响检查。
- `design-qa.md` 当前是 `final result: accepted`,仅记录已冻结 A02;A02 已不再阻塞后续页面,但后续页面必须分别经过截图、审视和用户验收。A06、G01 两个状态与 G03 双步骤均已获用户确认;G06 的最终候选只有初始态新图,三态复核与用户审美确认均待补。
- 现有运行图来自 H5 Chrome 调试;最终 Android/HBuilderX 真机/模拟器视觉复核仍待补做,不能被 H5 截图替代。
新 GPT 必须实际具备并按触发条件调用:
## 给新 GPT 的直接任务说明
- `product-design:index`:开始设计、审视或评价页面时先调用,用于选择正确的产品设计工作流。
- `product-design:audit`:必须基于真实运行截图审视页面,不能只看源码判断好不好看。
- `imagegen`:仅在缺少完整位图视觉资产时调用。完整按钮、卡片、边框和装饰面必须使用独立位图,不能用 CSS 伪造。
- `superpowers:test-driven-development`:修改页面行为、路由或可审计视觉结构前,先写或更新最小契约测试并确认 RED。
- `superpowers:systematic-debugging`:测试、截图、路由或构建出现异常时,先复现并定位根因再修改。
- `superpowers:verification-before-completion`:宣称完成前,运行受影响测试、真实截图复核和 `git diff --check`
这是一个高质量的 uni-app Android 家谱视觉设计任务,不是 CSS 微调任务。先读取本文件列出的必读材料和技能说明,保留工作区全部未提交改动,不使用 worktree、多代理、`git add``commit``push`、上传、`reset``checkout`
能力缺失时应先安装或启用同等能力,不得假装已经完成视觉审计、位图生成或验证
当前第一优先级不是开启新页面:先向用户展示并审视 `docs/design/screens/runtime/2026-07-14/G06-selected-strip-final-initial-360x800.png`,说明 G06 仍待验收。只有用户要求继续 G06 时,才先用 Product Design 对真实运行页面截图审视;若要改行为、路由或可审计视觉结构,先写/更新最小失败契约测试,再实现。继续时要补最后选定样式的结果态和无结果态截图、运行 G06 静态契约与 Chrome 冒烟、更新 `design-qa.md`,再请求用户审美确认。用户明确验收 G06 后,下一优先级才是 T02→T01 的世系树状态收敛(除非用户给出新的优先级)。
## 4. 视觉审核降噪规则
新 GPT 必须实际具备并调用:`product-design:index``product-design:audit`(基于真实截图审视)、`imagegen`(仅在缺完整位图资产时生成)、`superpowers:test-driven-development`(先契约测试)、`superpowers:systematic-debugging`(测试/截图/路由/构建异常时先定位)、`superpowers:verification-before-completion`(宣称完成前测试、截图和 `git diff --check`)。完整按钮、卡片、边框和装饰面必须使用独立位图;CSS 仅用于布局、文字、状态与真实交互
- 默认每页只看一张 360×800 代表截图
- 同页有多个关键状态时,优先看一张状态联系表,不连续展示多张相似截图。
- 412×915、小屏、多尺寸和前后对比图仅在发现比例问题、回归问题或用户要求时展开。
- 历史废稿和 before/after 证据不删除,但只保留在 `docs/design/视觉证据索引.md` 的历史审计区。
- 审核结论记录在 `docs/规划.md`;不要因为内部测试或 Product Design 审视通过就替用户勾选。
## 5. 禁止事项与完成验证
- 不使用 worktree 或多代理。
- 不执行 `git add`、commit、push、上传、reset、checkout,也不覆盖用户现有文件。
- 不擅自对接接口、修改接口契约、打 Android 包或把截图放入 `static/assets/` 当运行资源。
- 只修改用户当前反馈涉及的页面和对应测试、截图、设计记录;不要顺手重构冻结页或相邻模块。
- 完成一次页面返工前,至少运行对应契约测试、运行时 smoke、真实路由截图复核和 `git diff --check`;无法执行的检查必须明确说明。
+65 -48
View File
@@ -2,16 +2,16 @@
> 电脑或 GPT 更换时,先阅读:[项目交接记录](交接记录.md)。
> 当前阶段:Product Design 视觉精修已启动55 条路由第一版可浏览,A01、A02、A04、A05、A06、G01 两种状态与 G03 `create|ancestor` 双步骤已获用户视觉验收;G07 已收敛到 G06 初始/结果/无结果状态。G06 最新朱砂题签候选只完成初始态截图,结果/无结果同版截图与用户审美确认待补
> 当前阶段:Product Design 视觉精修持续推进54最终路由可浏览,A01、A02、A04、A05、A06、G01 两种状态与 G03 `create|ancestor` 双步骤已获用户视觉验收;G 模块与 T 模块候选稿已完成,统一等待全量设计完成后逐页审核
> 适用端:Android `uni-app`HBuilderX 打包)
> 页面总量:55 个独立页面/状态单元;当前已注册 55 条最终编号路由。
> 页面总量:54 个独立页面/状态单元;当前已注册 54 条最终编号路由。
## 1. 本轮目标与边界
- 只做 Android 用户端;不做 iOS、H5、PC 管理端、后台管理端或官网。
- 本窗口以“全量覆盖后集中精修”为顺序推进:先让 55 条独立路由拥有统一视觉与本地可浏览入口,再按截图集中修改。
- 本窗口以“全量覆盖后集中精修”为顺序推进:先让 54 条独立路由拥有统一视觉与本地可浏览入口,再按截图集中修改。
- 已由用户验收的 A01 启动/登录引导、A02 账号登录、A04 注册账号、A05 重置密码、A06 登录状态与 G01 默认有家谱态是**冻结视觉基准**。可重整其内部目录和复用方式,但已验收状态的 412 × 915 可见效果不得改变。
- 55 条最终路由与文件清单由 `tests/full-page-visual-contract.ps1` 单一维护;代码覆盖不等同于用户视觉验收完成。
- 54 条最终路由与文件清单由 `tests/full-page-visual-contract.ps1` 单一维护;代码覆盖不等同于用户视觉验收完成。
- 不新增业务接口、真实账号联调、Android 打包或设计清单以外的功能。
## 2. 唯一依据与完成口径
@@ -61,18 +61,20 @@
- 非直观跳转、状态分支、资产图层和关键样式必须写中文注释,说明用途和约束;不为显而易见的单行属性堆无意义注释。
- 每轮改动后删除本轮产生的无用导入、变量、函数、路径和资产。
## 5. 55 个独立页面/状态单元
## 5. 54 个独立页面/状态单元
| 模块 | 范围 | 数量 | 当前状态 |
| --- | --- | ---: | --- |
| 账户与首次使用 | A-01、A-02、A-04 至 A-06 | 5 | A01、A02、A04、A05、A06 已验收 |
| 家谱 | G-01、G-03、G-05、G-06、G-08 至 G-12 | 9 | G01 两种状态与 G03 双步骤已验收;G06 当前候选只完成初始态新图,结果/无结果复核与用户确认待补;其余未完成 |
| 世系树与成员 | T-01 至 T-08 | 8 | 未完成 |
| 家族内容 | F-01 至 F-10 | 10 | 未完成 |
| 人物、礼仪与档案 | R-01 至 R-11 | 11 | 未完成 |
| 消息 | N-01 至 N-02 | 2 | 未完成 |
| 个人中心 | M-01 至 M-10 | 10 | 未完成 |
| **合计** | | **55** | **已验收 7 个;G06 当前候选待补三态复核与确认** |
| 家谱 | G-01、G-03、G-05、G-06、G-08 至 G-12 | 9 | G01 两种状态与 G03 双步骤已验收;G05、G06、G08 至 G12 候选稿待用户逐页审核 |
| 世系树与成员 | T-01、T-03 至 T-08 | 7 | T02 已收敛为 T01 同页状态;7 个页面候选稿待用户逐页审核 |
| 家族内容 | F-01 至 F-10 | 10 | 10 个页面候选稿待用户逐页审核 |
| 人物、礼仪与档案 | R-01 至 R-11 | 11 | 11 个页面候选稿待用户逐页审核 |
| 消息 | N-01 至 N-02 | 2 | 2 个页面候选稿待用户逐页审核 |
| 个人中心 | M-01 至 M-10 | 10 | 10 个页面候选稿待用户逐页审核 |
| **合计** | | **54** | **已验收 7 个;其余 47 个均已形成 `[~]` 候选,等待用户逐页审核** |
视觉审核标记由用户逐页确认:`[ ]` 待设计,`[~]` 候选稿已完成、待用户审核,`[x]` 用户已验收,`[!]` 用户要求返工。Codex 只能把页面推进到 `[~]`;只有用户明确确认后才能改为 `[x]`。每个候选页面必须保留关键状态截图目录,便于连续勾选直至 54 个页面全部验收。
### 5.1 账户与首次使用
@@ -86,58 +88,73 @@
- [x] G-01 我的家谱:已创建、已加入、通知入口与 `?state=empty` 无家谱状态均获用户视觉验收。
- [x] G-03 创建家谱:`step=create|ancestor` 管理创建表单与首代录入,已获用户视觉验收,冻结可见效果。
- [ ] G-05 家谱总览。
- [ ] G-06 公开家谱搜索与筛选:已合并 G07 的初始、结果与无结果状态;用户选定朱砂题签候选,当前仅初始态新图,待补结果/无结果同版截图、审视与用户审美确认
- [ ] G-08 申请加入提交成功审核
- [ ] G-09 我的申请记录。
- [ ] G-10 管理员申请审核与确认。
- [ ] G-11 家谱设置、公开范围与访问说明。
- [ ] G-12 字辈诗列表、详情与维护。
- [~] G-05 家谱总览:候选稿已完成,含正常、空、失败状态与 360 × 800/412 × 915 截图,待用户视觉审核
- [~] G-06 公开家谱搜索与筛选:已合并 G07 的初始、结果与无结果状态;朱砂题签候选及三态运行截图已完成,待用户视觉审核
- [~] G-08 申请加入提交成功:候选稿已完成,使用页面内模拟交互,不对接接口,待用户视觉审核。
- [~] G-09 我的申请记录:候选稿已完成,含列表、空、失败状态,待用户视觉审核
- [~] G-10 管理员申请审核与确认:候选稿已完成,含列表、空、失败与审核确认交互,待用户视觉审核
- [~] G-11 家谱设置、公开范围与访问说明:候选稿已完成,含表单、成功、失败状态与双尺寸截图,待用户视觉审核
- [~] G-12 字辈诗列表、空态与维护:候选稿已完成,含列表、空、编辑、失败状态与双尺寸截图,待用户视觉审核
### 5.3 世系树与成员
- [ ] T-01 世系树总览。
- [ ] T-02 小屏提示、无成员与加载失败状态
- [ ] T-03 成员档案详情
- [ ] T-04 新增直系亲属
- [ ] T-05 编辑成员资料
- [ ] T-06 编辑亲属关系与冲突提示
- [ ] T-07 成员目录与搜索
- [ ] T-08 去世、隐私隐藏和无权限等状态。
- [~] T-01 世系树总览:已收敛 T02 的横向提示、空态与失败状态,含树态和双尺寸截图,待用户视觉审核
- [~] T-03 成员档案详情:含详情与失败状态,待用户视觉审核
- [~] T-04 新增直系亲属:含表单与保存结果,待用户视觉审核
- [~] T-05 编辑成员资料:含表单与保存结果,待用户视觉审核
- [~] T-06 编辑亲属关系与冲突提示:含表单、冲突与失败状态,待用户视觉审核
- [~] T-07 成员目录与搜索:含列表、搜索、空态与失败状态,待用户视觉审核
- [~] T-08 去世、隐私隐藏和无权限状态:三态留在同一路由,待用户视觉审核
### 5.4 家族内容
- [ ] F-01 家族首页/动态流。
- [ ] F-02 发布动态。
- [ ] F-03 动态详情、评论、点赞与删除确认
- [ ] F-04 谱文分类与文章列表。
- [ ] F-05 谱文详情。
- [ ] F-06 新建/编辑谱文。
- [ ] F-07 相册列表。
- [ ] F-08 相册详情与照片墙。
- [ ] F-09 图片预览、上传与失败状态。
- [ ] F-10 视频列表、详情与接口未开放状态
- [~] F-01 家族首页/动态流:列表、空态、失败态与双尺寸截图已完成,待用户视觉审核
- [~] F-02 发布动态:表单、成功与失败状态已完成,待用户视觉审核
- [~] F-03 动态详情、评论入口与操作结果,待用户视觉审核
- [~] F-04 谱文分类与文章列表,待用户视觉审核
- [~] F-05 谱文详情,待用户视觉审核
- [~] F-06 新建/编辑谱文,待用户视觉审核
- [~] F-07 相册列表,待用户视觉审核
- [~] F-08 相册详情与照片墙入口,待用户视觉审核
- [~] F-09 图片选择、上传与失败状态,待用户视觉审核
- [~] F-10 视频服务说明状态,待用户视觉审核
### 5.5 人物、礼仪与档案
- [ ] R-01 人物录列表R-02 人物录详情与编辑
- [ ] R-03 贺礼列表;R-04 贺礼新增、详情与删除确认
- [ ] R-05 礼仪活动列表;R-06 礼仪详情;R-07 新建/编辑礼仪
- [ ] R-08 成长日志;R-09 人生事;R-10 备忘录;R-11 功德记录
- [~] R-01 人物录列表,待用户视觉审核
- [~] R-02 人物录详情与编辑入口,待用户视觉审核
- [~] R-03 贺礼列表,待用户视觉审核
- [~] R-04 贺礼新增、保存结果与删除说明,待用户视觉审核
- [~] R-05 礼仪活动列表,待用户视觉审核。
- [~] R-06 礼仪详情,待用户视觉审核。
- [~] R-07 新建/编辑礼仪,待用户视觉审核。
- [~] R-08 成长日志,待用户视觉审核。
- [~] R-09 人生事,待用户视觉审核。
- [~] R-10 备忘录,待用户视觉审核。
- [~] R-11 功德记录,待用户视觉审核。
### 5.6 消息与个人中心
- [ ] N-01 消息中心N-02 消息详情、已读与空状态
- [ ] M-01 我的首页;M-02 个人资料;M-03 安全设置;M-04 修改密码;M-05 修改手机号
- [ ] M-06 帮助中心;M-07 意见反馈;M-08 应用推广;M-09 VIP 与订单;M-10 关于、协议、隐私与退出确认
- [~] N-01 消息中心:列表、已读、空态与失败状态已完成,待用户视觉审核
- [~] N-02 消息详情、已读与空/失败状态,待用户视觉审核
- [~] M-01 我的首页:资料、提醒、服务入口与失败状态已完成,待用户视觉审核
- [~] M-02 个人资料,待用户视觉审核。
- [~] M-03 安全设置,待用户视觉审核。
- [~] M-04 修改密码,待用户视觉审核。
- [~] M-05 修改手机号,待用户视觉审核。
- [~] M-06 帮助中心,待用户视觉审核。
- [~] M-07 意见反馈,待用户视觉审核。
- [~] M-08 应用推广,待用户视觉审核。
- [~] M-09 VIP 与订单,待用户视觉审核。
- [~] M-10 关于、协议、隐私与退出说明,待用户视觉审核。
## 6. 推进顺序
1. **视觉基线审视(进行中)**:已完成页面映射、资产迁移、公共组件中文注释和运行时零引用旧资源清理;A01 两张完整按钮已接入。A01、A02、A04、A05、A06、G01 两种状态与 G03 双步骤均已获用户视觉验收;A03 已删除;G02 已合并入 G01 `state=empty` 并通过验收;G04 已合并入 G03 `step=ancestor`G07 已合并入 G06 三态。G06 当前的朱砂题签候选只有初始态新截图,必须补同版结果/无结果截图、审视并取得用户确认;不修改已冻结页面的可见效果。
1. **视觉基线审视(进行中)**:已完成页面映射、资产迁移、公共组件中文注释和运行时零引用旧资源清理;A01 两张完整按钮已接入。A01、A02、A04、A05、A06、G01 两种状态与 G03 双步骤均已获用户视觉验收;A03 已删除;G02 已合并入 G01 `state=empty` 并通过验收;G04 已合并入 G03 `step=ancestor`G07 已合并入 G06 三态。G05、G06、G08、G09、G10 已进入 `[~]` 候选待审;不修改已冻结页面的可见效果。
2. A-04 至 A-06:在冻结的 A01/A02 认证资产包上完成注册、重设和登录状态页面。
3. G-03、G-05 至 G-12在冻结的 G01 列表、题签、导航资产包上完成家谱模块;G07→G06 已完成,下一项为 T02→T01 的世系树状态收敛
4. T、F、R、N、M 模块:每个模块先确认母版和完整资产包,再批量实现其页面/状态
5. 全量四尺寸复核、真实接口接入与 Android 打包。
3. G-03、G-05 至 G-12家谱模块候选稿已全部完成;G07→G06 已收敛,G05、G06、G08 至 G12 均为 `[~]` 待审,集中审核留到全量候选完成后进行
4. T、F、R、N、M 模块:候选稿与 360 × 800 运行截图已全部完成;关键页面另有 412 × 915 截图和状态对比
5. 当前进入用户逐页视觉审核;全部 `[x]` 后再安排四尺寸复核、接口阶段与 Android 打包。
## 7. 本轮完成前的验收
-6
View File
@@ -94,12 +94,6 @@
"disableScroll": true
}
},
{
"path": "pages/tree/t02-tree-states",
"style": {
"navigationStyle": "custom"
}
},
{
"path": "pages/tree/t03-member-profile",
"style": {
+26 -61
View File
@@ -1,69 +1,34 @@
<!-- 页面编号F-01用途家族动态首页 -->
<!-- 页面编号F-01用途家族动态首页列表空态与失败状态 -->
<template>
<view class="page-shell family-page">
<view class="family-top"><view><text class="top-kicker">家族共建</text><text class="page-name">家族记事</text></view><text class="publish" @click="publishFeed">发布动态</text></view>
<view v-if="loadError" class="error-copy">{{ loadError }}</view>
<view class="content-rail"><view v-for="item in sections" :key="item.type" class="rail-item" @click="openSection(item.type)"><text>{{ item.name }}</text><text>{{ item.detail }}</text></view></view>
<view class="feed-heading"><text class="section-title">家族近况</text><text class="feed-filter">全部动态</text></view>
<view class="feed-list"><view v-for="feed in feeds" :key="feed.id" class="feed-card paper-card"><view class="feed-meta"><text>{{ feed.type || feed.feedType || '动态' }}</text><text>{{ feed.date || feed.createTime || '' }}</text></view><text class="feed-title">{{ feed.title || '家族动态' }}</text><text class="feed-content">{{ feed.content || feed.feedContent }}</text><text class="feed-author">发布人:{{ feed.author || feed.creatorName || '族人' }}</text></view><view v-if="!feeds.length && !loadError" class="empty-copy">暂未发布家族动态</view></view>
<view class="family-page" :class="{ 'feed-state--list': feedState === 'list', 'feed-state--empty': feedState === 'empty', 'feed-state--error': feedState === 'error' }">
<image class="family-page__paper" src="/static/assets/foundation/opaque/page-paper.jpg" mode="aspectFill" />
<view class="family-page__header"><PageHeader title="家族动态" action="发布" @action="toPublish" /></view>
<view class="feed-content">
<view class="feed-heading"><text>汤氏家族圈</text><text>家宴通知与共同记忆</text></view>
<scroll-view class="feed-shortcuts" scroll-x :show-scrollbar="false">
<view v-for="item in shortcuts" :key="item.key" class="feed-shortcut" @click="openSection(item.key)"><image src="/static/assets/foundation/opaque/a01-secondary-button.png" mode="scaleToFill" /><text>{{ item.label }}</text></view>
</scroll-view>
<template v-if="feedState === 'list'">
<view v-for="item in feeds" :key="item.id" class="feed-card" @click="openDetail(item)">
<image src="/static/assets/modules/genealogy/opaque/application-status-card.png" mode="scaleToFill" />
<view><text>{{ item.tag }} · {{ item.time }}</text><text>{{ item.title }}</text><text>{{ item.content }}</text><text>发布人{{ item.author }}</text></view>
</view>
</template>
<view v-else class="feed-state-card">
<image src="/static/assets/modules/genealogy/opaque/application-status-card.png" mode="scaleToFill" />
<view><text>{{ feedState === 'empty' ? '还没有家族动态' : '家族动态暂不可用' }}</text><text>{{ feedState === 'empty' ? '发布第一条通知、家宴记录或家族故事。' : '请稍后重新进入,已有内容不会受到影响。' }}</text></view>
</view>
<view class="feed-action" @click="feedState === 'error' ? feedState = 'list' : toPublish()"><image src="/static/assets/foundation/opaque/a01-primary-button.png" mode="scaleToFill" /><text>{{ feedState === 'error' ? '重新查看' : '发布家族动态' }}</text></view>
</view>
<AppTabbar active="family" />
</view>
</template>
<script setup>
import { onMounted, ref } from 'vue'
import AppTabbar from '@/components/AppTabbar.vue'
import { appApi } from '@/utils/api.js'
import { genealogyContext } from '@/utils/genealogy-context.js'
const feeds = ref([])
const loadError = ref('')
const genealogyId = ref('')
const sections = [
{ type: 'article', name: '谱文', detail: '族史与家训' },
{ type: 'album', name: '相册', detail: '珍存旧影' },
{ type: 'ceremony', name: '祭祀', detail: '慎终追远' },
{ type: 'record', name: '族务', detail: '共同记事' }
]
onMounted(async () => {
genealogyId.value = genealogyContext.getCurrentGenealogyId()
if (!genealogyId.value) { loadError.value = '请先从“我的家谱”选择一个家谱。'; return }
try { feeds.value = await appApi.getFeeds(genealogyId.value) } catch (error) { loadError.value = error.message || '家族动态加载失败。' }
})
const openSection = (type) => {
const routes = {
article: '/pages/family/f04-article-list',
album: '/pages/family/f07-album-list',
ceremony: '/pages/records/r05-ritual-list',
record: '/pages/records/r10-memo-list'
}
uni.navigateTo({ url: `${routes[type]}?genealogyId=${genealogyId.value}` })
}
const publishFeed = () => {
if (!genealogyId.value) { uni.showToast({ title: '请先选择家谱', icon: 'none' }); return }
uni.navigateTo({ url: `/pages/family/f02-publish-feed?type=feed&genealogyId=${genealogyId.value}` })
}
import { ref } from 'vue';import { onLoad } from '@dcloudio/uni-app';import PageHeader from '@/components/PageHeader.vue';import AppTabbar from '@/components/AppTabbar.vue';import{genealogyContext}from'@/utils/genealogy-context.js'
const genealogyId=ref('');const feedState=ref('loading');const feeds=[{id:1,tag:'团圆记忆',time:'今天 10:24',title:'端午家宴',content:'今年端午全家相聚,留下了许多温暖照片。',author:'汤正国'},{id:2,tag:'家族通知',time:'昨天 18:02',title:'修谱资料征集',content:'请家人补充老照片中的人物姓名与拍摄时间。',author:'谱主'}];const shortcuts=[{key:'articles',label:'谱文'},{key:'albums',label:'相册'},{key:'rituals',label:'礼仪'},{key:'memos',label:'备忘'}]
onLoad(query=>{genealogyId.value=query.genealogyId||genealogyContext.getCurrentGenealogyId()||'';feedState.value=query.state==='empty'?'empty':query.state==='error'?'error':'list'})
const toPublish=()=>uni.navigateTo({url:`/pages/family/f02-publish-feed?genealogyId=${genealogyId.value}`});const openDetail=item=>uni.navigateTo({url:`/pages/family/f03-feed-detail?feedId=${item.id}`});const openSection=key=>{const routes={articles:'/pages/family/f04-article-list',albums:'/pages/family/f07-album-list',rituals:'/pages/records/r05-ritual-list',memos:'/pages/records/r10-memo-list'};uni.navigateTo({url:routes[key]})}
</script>
<style scoped lang="scss">
.family-page { padding: 42rpx 28rpx 178rpx; }
.family-top { display: flex; align-items: flex-start; justify-content: space-between; }
.top-kicker { display: block; color: $brand-red; font-size: 22rpx; letter-spacing: 5rpx; }
.page-name { display: block; margin-top: 10rpx; color: $ink; font-family: serif; font-size: 52rpx; font-weight: 700; letter-spacing: 5rpx; }
.publish { margin-top: 22rpx; color: $brand-red; font-size: 26rpx; }
.error-copy { margin-top: 24rpx; color: $brand-red; font-size: 24rpx; }
.content-rail { display: flex; gap: 12rpx; margin-top: 34rpx; overflow-x: auto; }
.rail-item { min-width: 150rpx; padding: 18rpx; border: 1rpx solid $gold-soft; background: #fffaf0; }
.rail-item text { display: block; color: $ink; font-size: 28rpx; font-weight: 700; }
.rail-item text + text { margin-top: 8rpx; color: $ink-muted; font-size: 20rpx; font-weight: 400; }
.feed-heading { display: flex; justify-content: space-between; align-items: center; margin-top: 38rpx; }
.feed-filter { color: $brand-red; font-size: 24rpx; }
.feed-list { display: flex; flex-direction: column; gap: 18rpx; margin-top: 20rpx; }
.feed-card { padding: 28rpx; }
.feed-meta { display: flex; justify-content: space-between; color: $gold; font-size: 21rpx; }
.feed-title { display: block; margin-top: 16rpx; color: $ink; font-size: 32rpx; font-weight: 700; }
.feed-content { display: block; margin-top: 12rpx; color: $ink-muted; font-size: 25rpx; line-height: 1.65; }
.feed-author { display: block; margin-top: 16rpx; color: #9a8c78; font-size: 21rpx; }
.empty-copy { padding: 80rpx 0; color: $ink-muted; font-size: 26rpx; text-align: center; }
.family-page{position:relative;min-height:100vh;overflow:hidden;background:$paper}.family-page__paper{position:absolute;inset:0;z-index:0;width:100%;height:100%;pointer-events:none}.family-page__header,.feed-content{position:relative;z-index:2}.feed-content{padding:24rpx 24rpx 190rpx}.feed-heading text{display:block}.feed-heading text:first-child{color:$ink;font-family:STKaiti,KaiTi,serif;font-size:31rpx;font-weight:700}.feed-heading text:last-child{margin-top:6rpx;color:$ink-muted;font-size:20rpx}.feed-shortcuts{width:100%;margin-top:15rpx;white-space:nowrap}.feed-shortcut{position:relative;display:inline-block;width:150rpx;height:58rpx;margin-right:10rpx}.feed-shortcut image{position:absolute;inset:0;width:100%;height:100%}.feed-shortcut text{position:relative;z-index:1;display:flex;align-items:center;justify-content:center;height:100%;color:$ink;font-size:20rpx;font-weight:700}.feed-card,.feed-state-card{position:relative;width:100%;height:calc((100vw - 24px)*.34286);min-height:220rpx;margin-top:17rpx}.feed-card>image,.feed-state-card>image,.feed-action image{position:absolute;inset:0;width:100%;height:100%}.feed-card>view{position:absolute;inset:16% 9%;z-index:1}.feed-card text{display:block}.feed-card text:first-child{color:$brand-red;font-size:18rpx;letter-spacing:2rpx}.feed-card text:nth-child(2){margin-top:5rpx;color:$ink;font-family:STKaiti,KaiTi,serif;font-size:28rpx;font-weight:700}.feed-card text:nth-child(3){margin-top:6rpx;color:$ink-muted;font-size:19rpx}.feed-card text:last-child{margin-top:6rpx;color:#917b62;font-size:17rpx}.feed-state-card{margin-top:70rpx}.feed-state-card>view{position:absolute;inset:25% 12%;z-index:1;text-align:center}.feed-state-card text{display:block}.feed-state-card text:first-child{color:$ink;font-family:STKaiti,KaiTi,serif;font-size:30rpx;font-weight:700}.feed-state-card text:last-child{margin-top:13rpx;color:$ink-muted;font-size:20rpx}.feed-action{position:relative;width:100%;height:76rpx;margin-top:19rpx}.feed-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}
</style>
+4 -36
View File
@@ -1,36 +1,4 @@
<!-- 页面编号F-02用途发布家族动态 -->
<template>
<view class="page-shell editor-page">
<PageHeader title="发布家族动态" />
<view class="form-paper"><view class="section-title">记录此刻</view><text class="form-note">发布后当前家谱的成员可以在家族圈查看</text><textarea v-model="feedContent" maxlength="300" placeholder="写下通知、活动、家族故事或照片说明" placeholder-class="placeholder" /></view>
<view class="bottom-action"><button class="primary-button" :loading="isSubmitting" :disabled="isSubmitting" @click="submit">发布动态</button></view>
</view>
</template>
<script setup>
import { ref } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
import PageHeader from '@/components/PageHeader.vue'
import { appApi } from '@/utils/api.js'
import { genealogyContext } from '@/utils/genealogy-context.js'
const genealogyId = ref('')
const feedContent = ref('')
const isSubmitting = ref(false)
onLoad((query) => { genealogyId.value = query.genealogyId || genealogyContext.getCurrentGenealogyId() })
const submit = async () => {
if (!genealogyId.value) { uni.showToast({ title: '未找到当前家谱', icon: 'none' }); return }
if (!feedContent.value.trim()) { uni.showToast({ title: '请填写动态内容', icon: 'none' }); return }
isSubmitting.value = true
try { await appApi.createFeed(genealogyId.value, { feedType: 'text', feedContent: feedContent.value.trim() }); uni.navigateBack() } catch (error) { uni.showToast({ title: error.message || '发布失败,请稍后重试', icon: 'none' }) } finally { isSubmitting.value = false }
}
</script>
<style scoped lang="scss">
.editor-page { padding-bottom: 136rpx; }
.form-paper { margin: 28rpx; padding: 34rpx 30rpx; border: 1rpx solid $gold-soft; border-radius: 22rpx; background: $paper-white; }
.form-note { display: block; margin-top: 14rpx; color: $ink-muted; font-size: 23rpx; line-height: 1.65; }
textarea { width: 100%; height: 360rpx; margin-top: 30rpx; color: $ink; font-size: 28rpx; line-height: 1.7; }
.placeholder { color: #b7aa97; }
.bottom-action { position: fixed; right: 0; bottom: 0; left: 0; padding: 20rpx 28rpx calc(20rpx + env(safe-area-inset-bottom)); background: #fffaf0; border-top: 1rpx solid $gold-soft; }
</style>
<!-- 页面编号F-02用途发布家族动态与提交结果 -->
<template><view class="publish-page" :class="{'publish-state--form':publishState==='form','publish-state--success':publishState==='success','publish-state--error':publishState==='error'}"><image class="publish-page__paper" src="/static/assets/foundation/opaque/page-paper.jpg" mode="aspectFill"/><view class="publish-page__header"><PageHeader title="发布动态"/></view><view class="publish-panel"><image class="publish-panel__skin" src="/static/assets/modules/genealogy/opaque/g03-create-flow-panel.png" mode="scaleToFill"/><view v-if="publishState==='form'" class="publish-form"><text>记录此刻</text><text>分享通知、活动、家族故事或一段共同记忆。</text><view class="publish-field"><image src="/static/assets/modules/genealogy/opaque/g06-search-input-wide.png" mode="scaleToFill"/><textarea v-model="content" maxlength="300" placeholder="写下想对家人说的话" placeholder-class="publish-placeholder"/></view><view class="publish-action" @click="submit"><image src="/static/assets/foundation/opaque/a01-primary-button.png" mode="scaleToFill"/><text>发布动态</text></view></view><view v-else class="publish-result"><text>{{publishState==='success'?'动态已发布':'动态未发布'}}</text><text>{{publishState==='success'?'家人现在可以在家族圈看到这条记录。':'请检查内容后重新发布,当前文字仍保留在页面中。'}}</text><view class="publish-action" @click="publishState='form'"><image src="/static/assets/foundation/opaque/a01-primary-button.png" mode="scaleToFill"/><text>{{publishState==='success'?'继续发布':'重新填写'}}</text></view></view></view></view></template>
<script setup>import{ref}from'vue';import{onLoad}from'@dcloudio/uni-app';import PageHeader from'@/components/PageHeader.vue';const content=ref('');const publishState=ref('form');onLoad(query=>{publishState.value=query.state==='success'?'success':query.state==='error'?'error':'form'});const submit=()=>{if(!content.value.trim()){uni.showToast({title:'请先写下动态内容',icon:'none'});return}publishState.value='success'}</script>
<style scoped lang="scss">.publish-page{position:relative;min-height:100vh;overflow:hidden;background:$paper}.publish-page__paper{position:absolute;inset:0;z-index:0;width:100%;height:100%;pointer-events:none}.publish-page__header,.publish-panel{position:relative;z-index:2}.publish-panel{width:calc(100% - 32rpx);height:min(640px,calc((100vw - 16px)*1.48));margin:18rpx auto 0}.publish-panel__skin{position:absolute;inset:0;width:100%;height:100%}.publish-form,.publish-result{position:absolute;inset:9% 9%}.publish-form>text,.publish-result>text{display:block}.publish-form>text:first-child,.publish-result>text:first-child{color:$ink;font-family:STKaiti,KaiTi,serif;font-size:36rpx;font-weight:700}.publish-form>text:nth-child(2),.publish-result>text:nth-child(2){margin-top:12rpx;color:$ink-muted;font-size:21rpx;line-height:1.6}.publish-field{position:relative;height:250rpx;margin-top:24rpx}.publish-field image,.publish-action image{position:absolute;inset:0;width:100%;height:100%}.publish-field textarea{position:absolute;inset:25rpx;z-index:1;width:auto;height:200rpx;color:$ink;font-size:22rpx;line-height:1.7}.publish-placeholder{color:#a79884}.publish-action{position:relative;width:100%;height:78rpx;margin-top:24rpx}.publish-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}.publish-result{top:30%;text-align:center}.publish-result .publish-action{width:420rpx;max-width:100%;margin:30rpx auto 0}</style>
+2 -2
View File
@@ -278,7 +278,7 @@ const shortcuts = [
];
const openGenealogy = (genealogy) =>
uni.navigateTo({ url: `/pages/genealogy/g05-genealogy-overview?id=${genealogy.id}` });
uni.navigateTo({ url: `/pages/genealogy/g05-genealogy-overview?genealogyId=${genealogy.id}` });
const createGenealogy = () =>
uni.navigateTo({ url: "/pages/genealogy/g03-create-genealogy" });
const applyToJoin = () => uni.navigateTo({ url: "/pages/genealogy/g06-search-genealogies" });
@@ -288,7 +288,7 @@ const toNotifications = () =>
const openShortcut = (key) => {
const paths = {
tree: "/pages/tree/t01-tree-overview",
members: `/pages/genealogy/g05-genealogy-overview?id=${currentGenealogy.value.id}`,
members: `/pages/genealogy/g05-genealogy-overview?genealogyId=${currentGenealogy.value.id}`,
poem: "/pages/genealogy/g12-generation-poems",
applications: "/pages/genealogy/g10-application-review",
};
+123 -50
View File
@@ -1,33 +1,71 @@
<!-- 页面编号G-05用途家谱总览 -->
<!-- 页面编号G-05用途家谱总览与加载空数据失败状态 -->
<template>
<view class="page-shell detail-page">
<PageHeader title="家谱总览" action="管理" @action="toSettings" />
<view v-if="genealogy" class="genealogy-hero">
<text class="hero-kicker">{{ genealogy.hall || '堂号待补' }} · {{ genealogy.location || '所在地待补' }}</text>
<text class="hero-title">{{ genealogy.name }}</text>
<text class="hero-motto">{{ genealogy.motto || '敦亲睦族,敬祖传家。' }}</text>
<view class="hero-stats"><text> {{ genealogy.memberCount || 0 }} </text><text>已激活 {{ genealogy.activeCount || 0 }} </text><text>{{ genealogy.visibility || '仅成员可见' }}</text></view>
<view class="overview-page">
<image class="overview-page__paper" src="/static/assets/foundation/opaque/page-paper.jpg" mode="aspectFill" />
<image class="overview-page__footer" src="/static/assets/foundation/transparent/footer-mountain-bamboo.png" mode="widthFix" />
<view class="overview-page__header">
<PageHeader title="家谱总览" :action="overviewState === 'ready' ? '管理' : ''" @action="toSettings" />
</view>
<view v-else class="state-copy">{{ loadError || '正在载入家谱…' }}</view>
<template v-if="genealogy">
<view class="core-actions paper-card">
<view class="section-title">家谱核心</view>
<view class="action-grid">
<view class="action-cell primary-cell" @click="toTree"><text>世系树</text><text>查看家脉关系</text></view>
<view class="action-cell" @click="toFirstPerson"><text>录入族人</text><text>从首代开始完善</text></view>
<view class="action-cell" @click="toGenerationPoems"><text>字辈谱</text><text>传承行辈次序</text></view>
<view class="action-cell" @click="toApplications"><text>入谱审核</text><text>处理加入申请</text></view>
<view class="overview-surface">
<image class="overview-surface__skin" src="/static/assets/modules/genealogy/opaque/g05-overview-surface.png" mode="scaleToFill" />
<template v-if="overviewState === 'ready' && genealogy">
<view class="overview-ready">
<view class="overview-hero">
<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>
<view class="overview-hero__stats">
<text> {{ genealogy.memberCount || 0 }} </text>
<text>已激活 {{ genealogy.activeCount || 0 }} </text>
<text>{{ genealogy.visibility || '仅成员可见' }}</text>
</view>
</view>
<view class="overview-actions">
<view class="overview-action overview-action--tree" @click="toTree">
<text class="overview-action__title">世系树</text><text>查看家脉关系</text>
</view>
<view class="overview-action overview-action--ancestor" @click="toFirstPerson">
<text class="overview-action__title">录入族人</text><text>从首代开始完善</text>
</view>
<view class="overview-action overview-action--poem" @click="toGenerationPoems">
<text class="overview-action__title">字辈谱</text><text>传承行辈次序</text>
</view>
<view class="overview-action overview-action--review" @click="toApplications">
<text class="overview-action__title">入谱审核</text><text>处理加入申请</text>
</view>
</view>
<view class="overview-family" @click="toFamily">
<text>家族近况</text><text>进入家族圈查看动态</text><text>查看</text>
</view>
<view class="overview-note">
<text class="overview-note__title">家谱资料仅向家人开放</text>
<text>公开范围访问说明与管理权由家谱管理员在设置中维护</text>
</view>
</view>
</template>
<view v-else class="overview-state-panel" :class="{
'overview-state--loading': overviewState === 'loading',
'overview-state--empty': overviewState === 'empty',
'overview-state--error': overviewState === 'error'
}">
<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/opaque/a01-primary-button.png" mode="scaleToFill" />
<text>{{ overviewState === 'error' ? '重新查看' : '返回我的家谱' }}</text>
</view>
</view>
<view class="family-summary"><text class="section-title">家族近况</text><view class="summary-row" @click="toFamily"><text>进入家族圈查看动态</text><text>查看</text></view></view>
<view class="invite-block"><text class="invite-title">邀亲人共建家谱</text><text class="invite-copy">族人申请加入后由创建者审核确认</text><button class="primary-button" @click="toJoinApplication">邀请亲人</button></view>
</template>
</view>
</view>
</template>
<script setup>
import { ref } from 'vue'
import { computed, ref } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
import PageHeader from '@/components/PageHeader.vue'
import { appApi } from '@/utils/api.js'
@@ -35,52 +73,87 @@ import { genealogyContext } from '@/utils/genealogy-context.js'
const genealogy = ref(null)
const genealogyId = ref('')
const overviewState = ref('loading')
const loadError = ref('')
const loadGenealogy = async (id) => {
genealogyId.value = id || genealogyContext.getCurrentGenealogyId()
if (!genealogyId.value) {
loadError.value = '未找到当前家谱,请从“我的家谱”重新进入。'
const stateTitle = computed(() => ({
loading: '正在展开家谱…',
empty: '还没有可查看的家谱',
error: '家谱暂时无法打开'
}[overviewState.value] || ''))
const stateCopy = computed(() => loadError.value || ({
loading: '请稍候,正在读取家谱概览。',
empty: '请从“我的家谱”选择一部家谱后再进入。',
error: '可能是网络波动、家谱不存在或当前账号无访问权限。'
}[overviewState.value] || ''))
const loadGenealogy = async (query = {}) => {
overviewState.value = 'loading'
loadError.value = ''
genealogy.value = null
genealogyId.value = query.genealogyId || genealogyId.value || genealogyContext.getCurrentGenealogyId()
if (query.state === 'empty' || !genealogyId.value) {
overviewState.value = 'empty'
return
}
if (query.state === 'error') {
overviewState.value = 'error'
return
}
genealogyContext.setCurrentGenealogyId(genealogyId.value)
try {
genealogy.value = await appApi.getOverview(genealogyId.value)
if (!genealogy.value) loadError.value = '家谱不存在或无权访问。'
overviewState.value = genealogy.value ? 'ready' : 'error'
if (!genealogy.value) loadError.value = '家谱不存在或当前账号无权访问。'
} catch (error) {
loadError.value = error.message || '家谱加载失败。'
loadError.value = error.message || '家谱加载失败,请稍后重试。'
overviewState.value = 'error'
}
}
onLoad((query) => loadGenealogy(query.id))
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/g04-first-ancestor?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 toJoinApplication = () => uni.navigateTo({ url: `/pages/genealogy/g08-join-application?genealogyId=${genealogyId.value}` })
</script>
<style scoped lang="scss">
.detail-page { padding-bottom: 48rpx; }
.genealogy-hero { padding: 48rpx 34rpx 38rpx; background: $navy; color: #fff8ec; }
.hero-kicker { display: block; color: #e6c57f; font-size: 22rpx; letter-spacing: 4rpx; }
.hero-title { display: block; margin-top: 20rpx; font-family: serif; font-size: 52rpx; font-weight: 700; letter-spacing: 4rpx; }
.hero-motto { display: block; margin-top: 16rpx; color: #eadcbf; font-size: 25rpx; }
.hero-stats { display: flex; flex-wrap: wrap; gap: 12rpx 20rpx; margin-top: 28rpx; color: #dac797; font-size: 22rpx; }
.state-copy { padding: 80rpx 40rpx; color: $ink-muted; font-size: 27rpx; text-align: center; }
.core-actions { margin: -18rpx 28rpx 0; padding: 30rpx; }
.action-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 16rpx; margin-top: 26rpx; }
.action-cell { min-height: 122rpx; padding: 20rpx; box-sizing: border-box; border: 1rpx solid #e7d9be; background: #fbf5e9; }
.action-cell text { display: block; color: $ink; font-size: 28rpx; font-weight: 700; }
.action-cell text + text { margin-top: 10rpx; color: $ink-muted; font-size: 21rpx; font-weight: 400; }
.primary-cell { border-color: $brand-red; background: #f8ede3; }
.primary-cell text:first-child { color: $brand-red; }
.family-summary { margin: 38rpx 28rpx 0; }
.summary-row { display: flex; justify-content: space-between; margin-top: 14rpx; padding: 24rpx 6rpx; border-bottom: 1rpx solid #dfd1b6; color: $ink-muted; font-size: 25rpx; }
.summary-row text:last-child { color: $brand-red; }
.invite-block { margin: 42rpx 28rpx 0; padding: 32rpx; border: 1rpx solid $gold; background: #efe0c0; }
.invite-title { display: block; color: $brand-red; font-family: serif; font-size: 37rpx; font-weight: 700; }
.invite-copy { display: block; margin: 14rpx 0 24rpx; color: $ink-muted; font-size: 23rpx; }
.overview-page { position: relative; min-height: 100vh; overflow: hidden; background: $paper; }
.overview-page__paper { position: absolute; inset: 0; z-index: 0; width: 100%; height: 100%; opacity: .98; pointer-events: none; }
.overview-page__footer { position: absolute; right: 0; bottom: 0; left: 0; z-index: 1; width: 100%; opacity: .13; pointer-events: none; }
.overview-page__header { position: relative; z-index: 3; }
.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-action { display: flex; flex-direction: column; justify-content: center; min-width: 0; padding: 0 22rpx; box-sizing: border-box; color: $ink-muted; font-size: 22rpx; }
.overview-action__title { margin-bottom: 9rpx; color: $ink; font-family: 'STKaiti', 'KaiTi', serif; font-size: 30rpx; font-weight: 700; }
.overview-action--tree .overview-action__title { color: $brand-red; }
.overview-family { position: absolute; top: 70.2%; right: 7.5%; left: 7.5%; display: grid; grid-template-columns: auto 1fr auto; align-items: center; gap: 16rpx; color: $ink-muted; font-size: 22rpx; }
.overview-family text:first-child { color: $ink; font-family: 'STKaiti', 'KaiTi', serif; font-size: 28rpx; font-weight: 700; }
.overview-family text:last-child { color: $brand-red; }
.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; }
@media (min-width: 400px) {
.overview-hero { top: 7%; }
.overview-action { padding: 0 28rpx; }
}
</style>
+131 -3
View File
@@ -1,3 +1,131 @@
<!-- 页面编号G-08用途申请加入家谱与提交成功状态 -->
<template><ModulePage page-id="g08" /></template>
<script setup>import ModulePage from '@/components/ModulePage.vue'</script>
<!-- 页面编号G-08用途申请加入家谱与提交成功/失败状态 -->
<template>
<view class="join-page">
<image class="join-page__paper" src="/static/assets/foundation/opaque/page-paper.jpg" mode="aspectFill" />
<image class="join-page__footer" src="/static/assets/foundation/transparent/footer-mountain-bamboo.png" mode="widthFix" />
<view class="join-page__header"><PageHeader title="申请加入家谱" /></view>
<view class="join-panel" :class="{
'join-state--form': joinState === 'form',
'join-state--success': joinState === 'success',
'join-state--error': joinState === 'error'
}">
<image class="join-panel__skin" src="/static/assets/modules/genealogy/opaque/g03-create-flow-panel.png" mode="scaleToFill" />
<view v-if="joinState === 'form'" class="join-form">
<text class="join-form__eyebrow">公开家谱入谱申请</text>
<text class="join-form__title">申请加入 {{ genealogyName }}</text>
<text class="join-form__copy">请填写真实身份和亲属关系管理员审核后会通过消息告知结果</text>
<view class="join-field">
<image src="/static/assets/modules/genealogy/opaque/g06-search-input-wide.png" mode="scaleToFill" />
<text>真实姓名</text><input v-model="form.realName" placeholder="请输入真实姓名" placeholder-class="join-placeholder" />
</view>
<view class="join-field">
<image src="/static/assets/modules/genealogy/opaque/g06-search-input-wide.png" mode="scaleToFill" />
<text>与家谱关系</text><input v-model="form.relation" placeholder="例如:汤正华堂侄" placeholder-class="join-placeholder" />
</view>
<view class="join-field join-field--message">
<image src="/static/assets/modules/genealogy/opaque/g06-search-input-wide.png" mode="scaleToFill" />
<text>申请说明</text><textarea v-model="form.message" maxlength="80" placeholder="补充祖居地、长辈姓名等核验信息" placeholder-class="join-placeholder" />
</view>
<text class="join-form__note">提交后可在我的申请中查看审核进度</text>
<view class="join-action" @click="submitJoin">
<image src="/static/assets/foundation/opaque/a01-primary-button.png" mode="scaleToFill" />
<text>{{ isSubmitting ? '正在提交…' : '提交申请' }}</text>
</view>
</view>
<view v-else class="join-result">
<text class="join-result__eyebrow">{{ joinState === 'success' ? '申请已送达' : '申请未提交' }}</text>
<text class="join-result__title">{{ joinState === 'success' ? '等待管理员核实亲属关系' : '暂时无法提交申请' }}</text>
<text class="join-result__copy">{{ resultCopy }}</text>
<view class="join-action" @click="joinState === 'success' ? toMyApplications() : retryForm()">
<image src="/static/assets/foundation/opaque/a01-primary-button.png" mode="scaleToFill" />
<text>{{ joinState === 'success' ? '查看我的申请' : '重新填写' }}</text>
</view>
</view>
</view>
</view>
</template>
<script setup>
import { computed, reactive, ref } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
import PageHeader from '@/components/PageHeader.vue'
const genealogyId = ref('')
const genealogyName = ref('这部家谱')
const genealogyPreview = {
'1001': '汤氏家谱',
'1002': '汝南汤氏家谱'
}
const joinState = ref('form')
const isSubmitting = ref(false)
const errorMessage = ref('')
const form = reactive({ realName: '', relation: '', message: '' })
const resultCopy = computed(() => joinState.value === 'success'
? `${genealogyName.value}”的管理员会在核实后给出结果,请留意消息中心。`
: errorMessage.value || '请检查网络后重新填写;未成功提交的内容不会进入审核列表。')
onLoad((query) => {
genealogyId.value = query.genealogyId || ''
if (query.state === 'success') {
joinState.value = 'success'
return
}
if (!genealogyId.value) {
errorMessage.value = '没有找到要申请加入的家谱,请先返回公开家谱检索。'
joinState.value = 'error'
return
}
genealogyName.value = genealogyPreview[genealogyId.value] || '这部家谱'
})
const submitJoin = () => {
if (isSubmitting.value) return
if (!form.realName.trim() || !form.relation.trim()) {
uni.showToast({ title: '请填写真实姓名和亲属关系', icon: 'none' })
return
}
isSubmitting.value = true
setTimeout(() => {
joinState.value = 'success'
isSubmitting.value = false
}, 280)
}
const retryForm = () => { joinState.value = 'form'; errorMessage.value = '' }
const toMyApplications = () => uni.redirectTo({ url: '/pages/genealogy/g09-my-applications' })
</script>
<style scoped lang="scss">
.join-page { position: relative; min-height: 100vh; overflow: hidden; background: $paper; }
.join-page__paper { position: absolute; inset: 0; z-index: 0; width: 100%; height: 100%; opacity: .98; pointer-events: none; }
.join-page__footer { position: absolute; right: 0; bottom: 0; left: 0; z-index: 1; width: 100%; opacity: .13; pointer-events: none; }
.join-page__header { position: relative; z-index: 3; }
.join-panel { position: relative; z-index: 2; width: calc(100% - 32rpx); height: min(590px, calc((100vw - 16px) * 1.337)); margin: 22rpx auto 0; }
.join-panel__skin { position: absolute; inset: 0; width: 100%; height: 100%; }
.join-form { position: absolute; inset: 8.5% 8%; }
.join-form__eyebrow, .join-result__eyebrow { display: block; color: $brand-red; font-size: 23rpx; letter-spacing: 3rpx; }
.join-form__title, .join-result__title { display: block; margin-top: 12rpx; color: $ink; font-family: 'STKaiti', 'KaiTi', serif; font-size: 36rpx; font-weight: 700; }
.join-form__copy, .join-result__copy { display: block; margin-top: 12rpx; color: $ink-muted; font-size: 22rpx; line-height: 1.6; }
.join-field { position: relative; height: 92rpx; margin-top: 18rpx; }
.join-field > image { position: absolute; inset: 0; width: 100%; height: 100%; }
.join-field > text { position: absolute; top: 31rpx; left: 24rpx; z-index: 1; color: $ink; font-size: 23rpx; font-weight: 700; }
.join-field input, .join-field textarea { position: absolute; top: 0; right: 20rpx; bottom: 0; left: 170rpx; z-index: 1; height: 92rpx; color: $ink; font-size: 23rpx; line-height: 92rpx; }
.join-field textarea { box-sizing: border-box; padding-top: 26rpx; line-height: 1.5; }
.join-placeholder { color: #a79884; }
.join-form__note { display: block; margin-top: 18rpx; color: $ink-muted; font-size: 21rpx; text-align: center; }
.join-action { position: relative; width: 100%; height: 82rpx; margin-top: 20rpx; }
.join-action image { position: absolute; inset: 0; width: 100%; height: 100%; }
.join-action text { position: relative; z-index: 1; display: flex; align-items: center; justify-content: center; height: 100%; color: #fff9ed; font-size: 27rpx; font-weight: 700; letter-spacing: 3rpx; }
.join-result { position: absolute; top: 28%; right: 12%; left: 12%; text-align: center; }
.join-result__eyebrow { text-align: center; }
.join-result__copy { margin-top: 22rpx; }
.join-result .join-action { width: 420rpx; max-width: 100%; margin: 34rpx auto 0; }
@media (min-width: 400px) {
.join-panel { width: calc(100% - 48rpx); }
.join-form { right: 9%; left: 9%; }
}
</style>
+108 -3
View File
@@ -1,3 +1,108 @@
<!-- 页面编号G-09用途我的家谱申请记录 -->
<template><ModulePage page-id="g09" /></template>
<script setup>import ModulePage from '@/components/ModulePage.vue'</script>
<!-- 页面编号G-09用途我的家谱申请列表与空/失败状态 -->
<template>
<view class="application-page">
<image class="application-page__paper" src="/static/assets/foundation/opaque/page-paper.jpg" mode="aspectFill" />
<image class="application-page__footer" src="/static/assets/foundation/transparent/footer-mountain-bamboo.png" mode="widthFix" />
<view class="application-page__header"><PageHeader title="我的申请" /></view>
<view class="application-content" :class="{
'application-state--list': applicationState === 'list',
'application-state--empty': applicationState === 'empty',
'application-state--error': applicationState === 'error'
}">
<template v-if="applicationState === 'list'">
<view class="application-intro">
<text>入谱申请进度</text><text>审核结果会同步到消息中心</text>
</view>
<view v-for="item in applications" :key="item.id" class="application-card" @click="openApprovedGenealogy(item)">
<image class="application-card__skin" src="/static/assets/modules/genealogy/opaque/application-status-card.png" mode="scaleToFill" />
<view class="application-card__body">
<text class="application-card__name">{{ item.genealogyName }}</text>
<text class="application-card__time">{{ item.appliedAt }}</text>
<text class="application-card__relation">{{ item.relation }}</text>
<text class="application-card__status" :class="`application-card__status--${item.status.toLowerCase()}`">{{ statusLabel(item.status) }}</text>
<text class="application-card__hint">{{ item.status === 'APPROVED' ? '点按进入家谱' : item.status === 'PENDING' ? '请耐心等待管理员核实' : '可重新检索并提交申请' }}</text>
</view>
</view>
</template>
<view v-else class="application-state-card">
<image src="/static/assets/modules/genealogy/opaque/application-status-card.png" mode="scaleToFill" />
<view class="application-state-card__copy">
<text>{{ applicationState === 'empty' ? '还没有入谱申请' : '申请记录暂时无法读取' }}</text>
<text>{{ applicationState === 'empty' ? '从公开家谱检索开始,找到家谱后可提交加入申请。' : errorMessage || '请检查网络后重新查看。' }}</text>
</view>
</view>
<view v-if="applicationState !== 'list'" class="application-page__action" @click="applicationState === 'error' ? loadApplications({}) : toSearch()">
<image src="/static/assets/foundation/opaque/a01-primary-button.png" mode="scaleToFill" />
<text>{{ applicationState === 'error' ? '重新查看' : '查找公开家谱' }}</text>
</view>
</view>
</view>
</template>
<script setup>
import { ref } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
import PageHeader from '@/components/PageHeader.vue'
const applications = ref([])
const applicationState = ref('loading')
const errorMessage = ref('')
const applicationSamples = [
{ id: 1, genealogyId: 1001, genealogyName: '汤氏家谱', relation: '自述为汤正华堂侄', appliedAt: '今天 10:24', status: 'PENDING' },
{ id: 2, genealogyId: 1002, genealogyName: '汝南汤氏家谱', relation: '祖居河南汝南', appliedAt: '昨天 18:02', status: 'APPROVED' },
{ id: 3, genealogyId: 1003, genealogyName: '清河汤氏家谱', relation: '补充材料不足', appliedAt: '7月12日 09:18', status: 'REJECTED' }
]
const statusLabel = (status) => ({
'PENDING': '审核中',
'APPROVED': '已通过',
'REJECTED': '未通过'
}[status] || '状态未知')
const loadApplications = (query = {}) => {
applicationState.value = 'loading'
errorMessage.value = ''
if (query.state === 'empty') { applicationState.value = 'empty'; return }
if (query.state === 'error') { applicationState.value = 'error'; return }
applications.value = applicationSamples.map((item) => ({ ...item }))
applicationState.value = 'list'
}
onLoad(loadApplications)
const openApprovedGenealogy = (item) => {
if (item.status !== 'APPROVED') return
uni.navigateTo({ url: `/pages/genealogy/g05-genealogy-overview?genealogyId=${item.genealogyId}` })
}
const toSearch = () => uni.navigateTo({ url: '/pages/genealogy/g06-search-genealogies' })
</script>
<style scoped lang="scss">
.application-page { position: relative; min-height: 100vh; overflow: hidden; background: $paper; }
.application-page__paper { position: absolute; inset: 0; z-index: 0; width: 100%; height: 100%; opacity: .98; pointer-events: none; }
.application-page__footer { position: absolute; right: 0; bottom: 0; left: 0; z-index: 1; width: 100%; opacity: .13; pointer-events: none; }
.application-page__header { position: relative; z-index: 3; }
.application-content { position: relative; z-index: 2; padding: 28rpx 24rpx 60rpx; }
.application-intro { display: flex; align-items: baseline; justify-content: space-between; margin: 0 8rpx 20rpx; }
.application-intro text:first-child { color: $ink; font-family: 'STKaiti', 'KaiTi', serif; font-size: 31rpx; font-weight: 700; }
.application-intro text:last-child { color: $ink-muted; font-size: 21rpx; }
.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% 14% 8.5%; }
.application-card__name { color: $ink; font-family: 'STKaiti', 'KaiTi', serif; font-size: 30rpx; font-weight: 700; }
.application-card__time { position: absolute; top: 3rpx; right: 0; color: #998873; font-size: 20rpx; }
.application-card__relation { display: block; max-width: 70%; margin-top: 14rpx; color: $ink-muted; font-size: 22rpx; }
.application-card__status { position: absolute; right: 3%; bottom: 11%; color: $brand-red; font-size: 24rpx; font-weight: 700; }
.application-card__status--approved { color: #537368; }
.application-card__status--rejected { color: #7e6f62; }
.application-card__hint { position: absolute; bottom: 5%; left: 0; color: #8d7c67; font-size: 20rpx; }
.application-state-card { position: relative; width: 100%; height: calc((100vw - 24px) * .34286); min-height: 228rpx; margin-top: 80rpx; }
.application-state-card > image { position: absolute; inset: 0; width: 100%; height: 100%; }
.application-state-card__copy { position: absolute; inset: 22% 12%; display: flex; flex-direction: column; justify-content: center; text-align: center; }
.application-state-card__copy text:first-child { color: $ink; font-family: 'STKaiti', 'KaiTi', serif; font-size: 31rpx; font-weight: 700; }
.application-state-card__copy text:last-child { margin-top: 16rpx; color: $ink-muted; font-size: 22rpx; line-height: 1.6; }
.application-page__action { position: relative; width: 420rpx; height: 82rpx; margin: 34rpx auto 0; }
.application-page__action image { position: absolute; inset: 0; width: 100%; height: 100%; }
.application-page__action text { position: relative; z-index: 1; display: flex; align-items: center; justify-content: center; height: 100%; color: #fff9ed; font-size: 26rpx; font-weight: 700; }
</style>
+112 -47
View File
@@ -1,16 +1,52 @@
<!-- 页面编号G-10用途管理员入谱申请审核 -->
<!-- 页面编号G-10用途管理员入谱申请审核与空/失败状态 -->
<template>
<view class="page-shell applications-page">
<PageHeader title="入谱审核" action="审核说明" @action="showHelp" />
<view class="audit-tip"><text>{{ loadError || '通过前请核实申请人的亲属关系;审核结果会通过通知告知对方。' }}</text></view>
<view class="application-list">
<view v-for="item in applications" :key="item.id" class="application-card paper-card">
<view class="application-top"><view><text class="applicant-name">{{ item.name }}</text><text class="applicant-phone">{{ item.phone }}</text></view><text class="applicant-time">{{ item.appliedAt }}</text></view>
<text class="relation-copy">{{ item.relation }}</text>
<view v-if="item.status === 'PENDING'" class="audit-actions"><button class="reject-button" @click="audit(item, false)">拒绝</button><button class="approve-button" @click="audit(item, true)">通过</button></view>
<text v-else class="audit-status">{{ item.status === 'APPROVED' ? '已通过' : '已拒绝' }}</text>
<view class="review-page">
<image class="review-page__paper" src="/static/assets/foundation/opaque/page-paper.jpg" mode="aspectFill" />
<image class="review-page__footer" src="/static/assets/foundation/transparent/footer-mountain-bamboo.png" mode="widthFix" />
<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'
}">
<view class="review-intro">
<text>核实亲属关系后再决定</text>
<text>审核结果会通过消息告知申请人</text>
<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 class="application-card__body">
<text class="application-card__name">{{ item.name }}</text>
<text class="application-card__phone">{{ item.phone }}</text>
<text class="application-card__time">{{ item.appliedAt }}</text>
<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/opaque/a01-secondary-button.png" mode="scaleToFill" /><text>拒绝</text>
</view>
<view class="review-action" @click="confirmAudit(item, true)">
<image src="/static/assets/foundation/opaque/a01-primary-button.png" mode="scaleToFill" /><text>通过</text>
</view>
</view>
<text v-else class="application-card__status">{{ item.status === 'APPROVED' ? '已通过' : '已拒绝' }}</text>
</view>
</view>
</template>
<view v-else class="review-state-card">
<image src="/static/assets/modules/genealogy/opaque/application-status-card.png" mode="scaleToFill" />
<view class="review-state-card__copy">
<text>{{ reviewState === 'empty' ? '暂无待审核申请' : '审核列表暂时无法读取' }}</text>
<text>{{ reviewState === 'empty' ? '新的入谱申请会在这里出现,并同步发送消息提醒。' : errorMessage || '请检查网络或家谱权限后重试。' }}</text>
</view>
</view>
<view v-if="reviewState === 'error'" class="review-page__retry" @click="loadApplications({ genealogyId })">
<image src="/static/assets/foundation/opaque/a01-primary-button.png" mode="scaleToFill" /><text>重新查看</text>
</view>
<view v-if="!applications.length && !loadError" class="empty-copy">暂无待审核申请</view>
</view>
</view>
</template>
@@ -19,50 +55,79 @@
import { ref } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
import PageHeader from '@/components/PageHeader.vue'
import { appApi } from '@/utils/api.js'
import { genealogyContext } from '@/utils/genealogy-context.js'
const 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 loadError = ref('')
const reviewState = ref('loading')
const errorMessage = ref('')
onLoad(async (query) => {
genealogyId.value = query.genealogyId || genealogyContext.getCurrentGenealogyId()
if (!genealogyId.value) { loadError.value = '未找到当前家谱,请从家谱总览进入。'; return }
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 (!genealogyId.value) {
errorMessage.value = '没有找到当前家谱,请从家谱总览进入。'
reviewState.value = 'error'
return
}
genealogyContext.setCurrentGenealogyId(genealogyId.value)
try {
applications.value = await appApi.getPendingApplications(genealogyId.value)
} catch (error) {
loadError.value = error.message || '审核列表加载失败。'
}
})
const audit = async (item, approved) => {
try {
await appApi.auditApplication(genealogyId.value, item.id, approved)
item.status = approved ? 'APPROVED' : 'REJECTED'
uni.showToast({ title: approved ? '已通过申请' : '已拒绝申请', icon: 'none' })
} catch (error) {
uni.showToast({ title: error.message || '审核失败,请稍后重试', icon: 'none' })
}
applications.value = reviewSamples.map((item) => ({ ...item }))
reviewState.value = 'list'
}
const showHelp = () => uni.showModal({ title: '审核说明', content: '仅确认与本家谱存在真实亲属关系的申请,避免错误入谱。', showCancel: false })
onLoad(loadApplications)
const confirmAudit = (item, approved) => {
uni.showModal({
title: approved ? '确认通过申请?' : '确认拒绝申请?',
content: approved ? '通过后,申请人将成为本家谱成员。' : '拒绝后,申请人会收到审核结果。',
confirmText: approved ? '确认通过' : '确认拒绝',
success: ({ confirm }) => {
if (!confirm) return
item.status = approved ? 'APPROVED' : 'REJECTED'
uni.showToast({ title: approved ? '已通过申请' : '已拒绝申请', icon: 'none' })
}
})
}
const showHelp = () => uni.showModal({ title: '审核说明', content: '请核对申请人的姓名、亲属关系和补充说明,仅确认与本家谱存在真实关系的申请。', showCancel: false })
</script>
<style scoped lang="scss">
.applications-page { padding-bottom: 36rpx; }
.audit-tip { margin: 26rpx 28rpx 0; padding: 24rpx; border-left: 5rpx solid $brand-red; background: #efe3cd; color: $ink-muted; font-size: 24rpx; line-height: 1.6; }
.application-list { display: flex; flex-direction: column; gap: 20rpx; margin: 24rpx 28rpx; }
.application-card { padding: 28rpx; }
.application-top { display: flex; justify-content: space-between; }
.applicant-name { color: $ink; font-size: 32rpx; font-weight: 700; }
.applicant-phone { margin-left: 16rpx; color: $ink-muted; font-size: 22rpx; }
.applicant-time { color: #a39583; font-size: 21rpx; }
.relation-copy { display: block; margin-top: 16rpx; color: $ink-muted; font-size: 25rpx; }
.audit-actions { display: flex; justify-content: flex-end; gap: 16rpx; margin-top: 24rpx; }
.audit-actions button { min-width: 128rpx; min-height: 64rpx; margin: 0; border-radius: 8rpx; font-size: 24rpx; }
.reject-button { border: 1rpx solid $gold-soft; background: #fffaf0; color: $ink-muted; }
.approve-button { border: 1rpx solid $brand-red; background: $brand-red; color: #fff9ed; }
.audit-status { display: block; margin-top: 24rpx; color: $brand-red; font-size: 24rpx; text-align: right; }
.empty-copy { padding: 80rpx 0; color: $ink-muted; font-size: 26rpx; text-align: center; }
.review-page { position: relative; min-height: 100vh; overflow: hidden; background: $paper; }
.review-page__paper { position: absolute; inset: 0; z-index: 0; width: 100%; height: 100%; opacity: .98; pointer-events: none; }
.review-page__footer { position: absolute; right: 0; bottom: 0; left: 0; z-index: 1; width: 100%; opacity: .13; pointer-events: none; }
.review-page__header { position: relative; z-index: 3; }
.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; }
</style>
+131 -3
View File
@@ -1,3 +1,131 @@
<!-- 页面编号G-11用途家谱设置公开范围与访问说明 -->
<template><ModulePage page-id="g11" /></template>
<script setup>import ModulePage from '@/components/ModulePage.vue'</script>
<!-- 页面编号G-11用途家谱设置公开范围与访问说明页面设计阶段使用本地模拟交互 -->
<template>
<view class="settings-page">
<image class="settings-page__paper" src="/static/assets/foundation/opaque/page-paper.jpg" mode="aspectFill" />
<image class="settings-page__footer" src="/static/assets/foundation/transparent/footer-mountain-bamboo.png" mode="widthFix" />
<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'
}">
<image class="settings-panel__skin" src="/static/assets/modules/genealogy/opaque/g03-create-flow-panel.png" mode="scaleToFill" />
<view v-if="settingsState === 'form'" class="settings-form">
<text class="settings-form__eyebrow">谱主可见 · 基础设置</text>
<text class="settings-form__title">完善家谱访问规则</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" />
<text>家谱名称</text>
<input v-model="genealogyDraft.name" maxlength="20" placeholder="请输入家谱名称" placeholder-class="settings-placeholder" />
</view>
<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/opaque/a01-primary-button.png' : '/static/assets/foundation/opaque/a01-secondary-button.png'" mode="scaleToFill" />
<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" />
<text>访问说明</text>
<textarea v-model="genealogyDraft.accessNote" maxlength="80" placeholder="向访问者说明家谱用途" placeholder-class="settings-placeholder" />
</view>
<view class="settings-action" @click="saveSettings">
<image src="/static/assets/foundation/opaque/a01-primary-button.png" mode="scaleToFill" />
<text>保存设置</text>
</view>
</view>
<view v-else class="settings-result">
<text class="settings-result__eyebrow">{{ settingsState === 'success' ? '设置已保存' : '设置未保存' }}</text>
<text class="settings-result__title">{{ settingsState === 'success' ? '新的访问规则已经生效' : '暂时无法打开家谱设置' }}</text>
<text class="settings-result__copy">{{ settingsState === 'success' ? `${genealogyDraft.name} · ${visibilityLabel}` : '请从家谱总览重新进入,当前修改不会被保留。' }}</text>
<view class="settings-action" @click="settingsState = 'form'">
<image src="/static/assets/foundation/opaque/a01-primary-button.png" mode="scaleToFill" />
<text>{{ settingsState === 'success' ? '继续调整' : '重新查看' }}</text>
</view>
</view>
</view>
</view>
</template>
<script setup>
import { computed, reactive, ref } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
import PageHeader from '@/components/PageHeader.vue'
const genealogyId = ref('')
const settingsState = ref('loading')
const genealogyDraft = reactive({
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'
? '只有已加入本家谱的成员可以查看谱系和家族资料。'
: '访客可检索到家谱并提交入谱申请,资料仍需审核后查看。')
onLoad((query) => {
genealogyId.value = query.genealogyId || ''
settingsState.value = query.state === 'success' ? 'success' : query.state === 'error' || !genealogyId.value ? 'error' : 'form'
})
const saveSettings = () => {
if (!genealogyDraft.name.trim()) {
uni.showToast({ title: '请填写家谱名称', icon: 'none' })
return
}
settingsState.value = 'success'
}
</script>
<style scoped lang="scss">
.settings-page { position: relative; min-height: 100vh; overflow: hidden; background: $paper; }
.settings-page__paper { position: absolute; inset: 0; z-index: 0; width: 100%; height: 100%; opacity: .98; pointer-events: none; }
.settings-page__footer { position: absolute; right: 0; bottom: 0; left: 0; z-index: 1; width: 100%; opacity: .13; pointer-events: none; }
.settings-page__header { position: relative; z-index: 3; }
.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; }
.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; }
@media (min-width: 400px) { .settings-panel { width: calc(100% - 48rpx); } .settings-form { right: 9%; left: 9%; } }
</style>
+148 -3
View File
@@ -1,3 +1,148 @@
<!-- 页面编号G-12用途字辈诗列表详情与维护 -->
<template><ModulePage page-id="g12" /></template>
<script setup>import ModulePage from '@/components/ModulePage.vue'</script>
<!-- 页面编号G-12用途字辈诗列表空状态与批量维护页面设计阶段使用本地模拟交互 -->
<template>
<view class="poem-page">
<image class="poem-page__paper" src="/static/assets/foundation/opaque/page-paper.jpg" mode="aspectFill" />
<image class="poem-page__footer" src="/static/assets/foundation/transparent/footer-mountain-bamboo.png" mode="widthFix" />
<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'
}">
<image class="poem-panel__skin" src="/static/assets/modules/genealogy/opaque/g03-create-flow-panel.png" mode="scaleToFill" />
<view v-if="poemState === 'list'" class="poem-list">
<text class="poem-list__eyebrow">汤氏家谱 · 传承字序</text>
<text class="poem-list__title">启宗敦本继世传芳</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" />
<text class="poem-row__number"> {{ item.generationNo }} </text>
<text class="poem-row__character">{{ item.character }}</text>
<text class="poem-row__status">{{ item.current ? '当前字辈' : '传承字序' }}</text>
</view>
</view>
<view class="poem-action" @click="openEditor">
<image src="/static/assets/foundation/opaque/a01-primary-button.png" mode="scaleToFill" /><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>
<view class="poem-field">
<image src="/static/assets/modules/genealogy/opaque/g06-search-input-wide.png" mode="scaleToFill" />
<text>字辈内容</text>
<textarea v-model="poemDraft" maxlength="24" placeholder="例如:启宗敦本继世传芳" placeholder-class="poem-placeholder" />
</view>
<view class="poem-policy">
<text>缺失旧世代时</text>
<view class="poem-policy__option" @click="stopMissingOldGeneration = !stopMissingOldGeneration">
<image :src="stopMissingOldGeneration ? '/static/assets/foundation/opaque/a01-primary-button.png' : '/static/assets/foundation/opaque/a01-secondary-button.png'" mode="scaleToFill" />
<text :class="{ 'poem-policy__option-text--active': stopMissingOldGeneration }">{{ stopMissingOldGeneration ? '停止并提醒' : '继续补录' }}</text>
</view>
</view>
<text class="poem-preview">预览{{ previewCharacters || '尚未录入字辈' }}</text>
<view class="poem-editor__actions">
<view class="poem-action" @click="poemState = 'list'">
<image src="/static/assets/foundation/opaque/a01-secondary-button.png" mode="scaleToFill" /><text class="poem-action__secondary">取消</text>
</view>
<view class="poem-action" @click="savePoems">
<image src="/static/assets/foundation/opaque/a01-primary-button.png" mode="scaleToFill" /><text>保存字辈</text>
</view>
</view>
</view>
<view v-else class="poem-state-card">
<text class="poem-list__eyebrow">{{ poemState === 'empty' ? '尚未建立字辈' : '字辈暂不可用' }}</text>
<text class="poem-list__title">{{ poemState === 'empty' ? '从第一段传承字序开始' : '暂时无法读取字辈诗' }}</text>
<text class="poem-list__copy">{{ poemState === 'empty' ? '管理员可以一次录入连续字辈,系统会按世代拆分展示。' : '请从家谱总览重新进入,或稍后再试。' }}</text>
<view class="poem-action" @click="poemState === 'empty' ? openEditor() : poemState = 'list'">
<image src="/static/assets/foundation/opaque/a01-primary-button.png" mode="scaleToFill" /><text>{{ poemState === 'empty' ? '开始录入' : '重新查看' }}</text>
</view>
</view>
</view>
</view>
</template>
<script setup>
import { computed, ref } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
import PageHeader from '@/components/PageHeader.vue'
const genealogyId = ref('')
const poemState = ref('loading')
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(' · '))
onLoad((query) => {
genealogyId.value = query.genealogyId || ''
poemState.value = query.state === 'empty' ? 'empty' : query.state === 'edit' ? 'edit' : query.state === 'error' || !genealogyId.value ? 'error' : 'list'
})
const openEditor = () => { poemState.value = 'edit' }
const savePoems = () => {
const characters = poemDraft.value.trim().split('').filter(Boolean)
if (!characters.length) {
uni.showToast({ title: '请录入字辈内容', icon: 'none' })
return
}
poemRows.value = characters.slice(0, 4).map((character, index) => ({ generationNo: 12 + index, character, current: index === 2 }))
poemState.value = 'list'
uni.showToast({ title: '字辈预览已更新', icon: 'none' })
}
</script>
<style scoped lang="scss">
.poem-page { position: relative; min-height: 100vh; overflow: hidden; background: $paper; }
.poem-page__paper { position: absolute; inset: 0; z-index: 0; width: 100%; height: 100%; opacity: .98; pointer-events: none; }
.poem-page__footer { position: absolute; right: 0; bottom: 0; left: 0; z-index: 1; width: 100%; opacity: .13; pointer-events: none; }
.poem-page__header { position: relative; z-index: 3; }
.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-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; }
@media (min-width: 400px) { .poem-panel { width: calc(100% - 48rpx); } .poem-list, .poem-editor, .poem-state-card { right: 9%; left: 9%; } }
</style>
+4 -59
View File
@@ -1,59 +1,4 @@
<!-- 页面编号N-01用途消息中心 -->
<template>
<view class="page-shell notification-page">
<PageHeader title="消息通知" action="全部已读" @action="readAll" />
<view class="notice-intro"><text>{{ loadError || '与家谱相关的提醒都会留在这里。' }}</text></view>
<view class="notice-list"><view v-for="item in list" :key="item.id" class="notice-row" :class="{ unread: item.unread }" @click="read(item)"><view class="notice-dot"></view><view class="notice-copy"><text class="notice-title">{{ item.title }}</text><text class="notice-content">{{ item.content }}</text></view><text class="notice-time">{{ item.time }}</text></view></view>
</view>
</template>
<script setup>
import { onMounted, ref } from 'vue'
import PageHeader from '@/components/PageHeader.vue'
import { appApi } from '@/utils/api.js'
const list = ref([])
const loadError = ref('')
onMounted(async () => {
try {
list.value = await appApi.getNotifications()
} catch (error) {
loadError.value = error.message || '通知加载失败。'
}
})
const read = async (item) => {
if (!item.unread) {
uni.navigateTo({ url: `/pages/notification/n02-message-detail?id=${item.id}` })
return
}
try {
await appApi.markNotificationRead(item.id)
item.unread = false
uni.navigateTo({ url: `/pages/notification/n02-message-detail?id=${item.id}` })
} catch (error) {
uni.showToast({ title: error.message || '标记已读失败', icon: 'none' })
}
}
const readAll = async () => {
try {
await appApi.markAllNotificationsRead()
list.value.forEach((item) => { item.unread = false })
uni.showToast({ title: '已全部标记为已读', icon: 'none' })
} catch (error) {
uni.showToast({ title: error.message || '操作失败,请稍后重试', icon: 'none' })
}
}
</script>
<style scoped lang="scss">
.notification-page { padding-bottom: 36rpx; }
.notice-intro { padding: 28rpx 30rpx; background: #efe3cd; color: $ink-muted; font-size: 24rpx; }
.notice-list { margin: 0 28rpx; }
.notice-row { display: flex; align-items: flex-start; gap: 16rpx; padding: 28rpx 0; border-bottom: 1rpx solid #e4d8c0; }
.notice-dot { width: 14rpx; height: 14rpx; flex: 0 0 auto; margin-top: 13rpx; border-radius: 50%; background: transparent; }
.notice-row.unread .notice-dot { background: $brand-red; }
.notice-copy { min-width: 0; flex: 1; }
.notice-title { display: block; color: $ink; font-size: 30rpx; font-weight: 700; }
.notice-content { display: block; margin-top: 10rpx; color: $ink-muted; font-size: 24rpx; line-height: 1.55; }
.notice-time { flex: 0 0 auto; color: #a19482; font-size: 20rpx; }
</style>
<!-- 页面编号N-01用途消息中心已读操作空态与失败状态 -->
<template><view class="notice-page" :class="{'notice-state--list':noticeState==='list','notice-state--empty':noticeState==='empty','notice-state--error':noticeState==='error'}"><image class="notice-page__paper" src="/static/assets/foundation/opaque/page-paper.jpg" mode="aspectFill"/><view class="notice-page__header"><PageHeader title="消息中心" action="全部已读" @action="markAllRead"/></view><view class="notice-content"><template v-if="noticeState==='list'"><view v-for="item in notices" :key="item.id" class="notice-card" @click="readNotice(item)"><image src="/static/assets/modules/genealogy/opaque/application-status-card.png" mode="scaleToFill"/><view><text>{{item.unread?'未读提醒':'已读'}} · {{item.time}}</text><text>{{item.title}}</text><text>{{item.content}}</text></view></view></template><view v-else class="notice-state-card"><image src="/static/assets/modules/genealogy/opaque/application-status-card.png" mode="scaleToFill"/><view><text>{{noticeState==='empty'?'暂时没有新消息':'消息中心暂不可用'}}</text><text>{{noticeState==='empty'?'家谱申请、审核结果和家族提醒会留在这里。':'请稍后重新进入,已读状态不会受到影响。'}}</text></view></view><view class="notice-action" @click="noticeState==='error'?noticeState='list':toReview()"><image src="/static/assets/foundation/opaque/a01-secondary-button.png" mode="scaleToFill"/><text>{{noticeState==='error'?'重新查看':'前往入谱审核'}}</text></view></view></view></template>
<script setup>import{ref}from'vue';import{onLoad}from'@dcloudio/uni-app';import PageHeader from'@/components/PageHeader.vue';import{genealogyContext}from'@/utils/genealogy-context.js';const genealogyId=ref('');const noticeState=ref('loading');const notices=ref([{id:1,title:'申请待审核',content:'汤志成申请加入汤氏家谱,请核实亲属关系。',time:'今天 10:28',unread:true},{id:2,title:'入谱申请已通过',content:'你申请加入汝南汤氏家谱的请求已通过。',time:'昨天 18:10',unread:false}]);onLoad(query=>{genealogyId.value=query.genealogyId||genealogyContext.getCurrentGenealogyId()||'';noticeState.value=query.state==='empty'?'empty':query.state==='error'?'error':'list'});const readNotice=item=>{item.unread=false};const markAllRead=()=>{notices.value.forEach(x=>x.unread=false);uni.showToast({title:'已全部标记为已读',icon:'none'})};const toReview=()=>uni.navigateTo({url:`/pages/genealogy/g10-application-review?genealogyId=${genealogyId.value}`})</script>
<style scoped lang="scss">.notice-page{position:relative;min-height:100vh;overflow:hidden;background:$paper}.notice-page__paper{position:absolute;inset:0;z-index:0;width:100%;height:100%;pointer-events:none}.notice-page__header,.notice-content{position:relative;z-index:2}.notice-content{padding:24rpx}.notice-card,.notice-state-card{position:relative;width:100%;height:calc((100vw - 24px)*.34286);min-height:220rpx;margin-bottom:16rpx}.notice-card>image,.notice-state-card>image,.notice-action image{position:absolute;inset:0;width:100%;height:100%}.notice-card>view{position:absolute;inset:17% 9%;z-index:1}.notice-card text{display:block}.notice-card text:first-child{color:$brand-red;font-size:18rpx;letter-spacing:2rpx}.notice-card text:nth-child(2){margin-top:6rpx;color:$ink;font-family:STKaiti,KaiTi,serif;font-size:28rpx;font-weight:700}.notice-card text:last-child{margin-top:7rpx;color:$ink-muted;font-size:19rpx}.notice-state-card{margin-top:70rpx}.notice-state-card>view{position:absolute;inset:25% 12%;z-index:1;text-align:center}.notice-state-card text{display:block}.notice-state-card text:first-child{color:$ink;font-family:STKaiti,KaiTi,serif;font-size:30rpx;font-weight:700}.notice-state-card text:last-child{margin-top:13rpx;color:$ink-muted;font-size:20rpx}.notice-action{position:relative;width:420rpx;height:76rpx;margin:20rpx auto 0}.notice-action text{position:relative;z-index:1;display:flex;align-items:center;justify-content:center;height:100%;color:$ink;font-size:23rpx;font-weight:700}</style>
+4 -58
View File
@@ -1,58 +1,4 @@
<!-- 页面编号M-01用途我的首页 -->
<template>
<view class="page-shell profile-page">
<view class="profile-hero"><view class="profile-seal">{{ profile.name ? profile.name.slice(0, 1) : '谱' }}</view><view class="profile-copy"><text class="profile-name">{{ profile.name || '家谱用户' }}</text><text class="profile-meta">{{ profile.phone || '资料加载中' }} · {{ profile.role || '家谱成员' }}</text></view><text class="profile-edit" @click="toProfile">资料</text></view>
<text v-if="loadError" class="error-copy">{{ loadError }}</text>
<view class="todo-card paper-card" @click="toNotifications"><view><text class="todo-title">待你处理</text><text class="todo-copy">家谱相关提醒与审核通知</text></view><text class="todo-count">{{ unreadCount }}</text></view>
<view class="menu-group paper-card"><view v-for="item in accountItems" :key="item.label" class="menu-row" @click="openItem(item)"><text>{{ item.label }}</text><text>查看</text></view></view>
<view class="menu-group paper-card"><view v-for="item in serviceItems" :key="item.label" class="menu-row" @click="openItem(item)"><text>{{ item.label }}</text><text>查看</text></view></view>
<AppTabbar active="profile" />
</view>
</template>
<script setup>
import { computed, onMounted, ref } from 'vue'
import AppTabbar from '@/components/AppTabbar.vue'
import { appApi } from '@/utils/api.js'
import { notifications } from '@/data/mock.js'
const profile = ref({})
const loadError = ref('')
const unreadCount = computed(() => notifications.filter((item) => item.unread).length)
const accountItems = [
{ label: '我的资料', path: '/pages/profile/m02-edit-profile' },
{ label: '我的加入申请', path: '/pages/genealogy/g09-my-applications' },
{ label: '账号与安全', path: '/pages/profile/m03-security-settings' }
]
const serviceItems = [
{ label: '帮助中心', path: '/pages/profile/m06-help-center' },
{ label: '意见反馈', path: '/pages/profile/m07-feedback' },
{ label: 'VIP 服务', path: '/pages/profile/m09-vip-orders' }
]
onMounted(async () => {
try { profile.value = await appApi.getProfile() } catch (error) { loadError.value = error.message || '个人资料加载失败。' }
})
const toNotifications = () => uni.navigateTo({ url: '/pages/notification/n01-message-center' })
const toProfile = () => uni.navigateTo({ url: '/pages/profile/m02-edit-profile' })
const openItem = (item) => uni.navigateTo({ url: item.path })
</script>
<style scoped lang="scss">
.profile-page { padding-bottom: 178rpx; }
.profile-hero { display: flex; align-items: center; gap: 22rpx; padding: 48rpx 30rpx; background: $navy; }
.profile-seal { display: flex; width: 88rpx; height: 88rpx; align-items: center; justify-content: center; border: 3rpx solid #e0bd77; border-radius: 50%; color: #f4daa1; font-family: serif; font-size: 43rpx; }
.profile-copy { min-width: 0; flex: 1; }
.profile-name { display: block; color: #fff8ec; font-family: serif; font-size: 43rpx; font-weight: 700; }
.profile-meta { display: block; margin-top: 10rpx; color: #dfc590; font-size: 21rpx; }
.profile-edit { color: #f4dca8; font-size: 24rpx; }
.error-copy { display: block; margin: 18rpx 28rpx 0; color: $brand-red; font-size: 23rpx; }
.todo-card { display: flex; align-items: center; justify-content: space-between; margin: -14rpx 28rpx 0; padding: 28rpx; background: #fff8ea; }
.todo-title { display: block; color: $ink; font-size: 30rpx; font-weight: 700; }
.todo-copy { display: block; margin-top: 8rpx; color: $ink-muted; font-size: 22rpx; }
.todo-count { display: flex; width: 52rpx; height: 52rpx; align-items: center; justify-content: center; border-radius: 50%; background: $brand-red; color: #fff8ec; font-size: 26rpx; }
.menu-group { margin: 28rpx 28rpx 0; padding: 0 28rpx; }
.menu-row { display: flex; align-items: center; justify-content: space-between; min-height: 102rpx; border-bottom: 1rpx solid #eadfc9; color: $ink; font-size: 29rpx; }
.menu-row:last-child { border-bottom: 0; }
.menu-row text:last-child { color: $brand-red; font-size: 23rpx; }
</style>
<!-- 页面编号M-01用途个人中心首页提醒与服务导航 -->
<template><view class="profile-page" :class="{'profile-state--ready':profileState==='ready','profile-state--error':profileState==='error'}"><image class="profile-page__paper" src="/static/assets/foundation/opaque/page-paper.jpg" mode="aspectFill"/><view class="profile-page__header"><PageHeader title="我的" action="资料" @action="toProfile"/></view><view class="profile-content"><view v-if="profileState==='ready'" class="profile-panel"><image class="profile-panel__skin" src="/static/assets/modules/genealogy/opaque/g03-create-flow-panel.png" mode="scaleToFill"/><view class="profile-panel__body"><view class="profile-heading"><image src="/static/assets/modules/genealogy/transparent/current-seal-frame.png" mode="scaleToFill"/><view><text>汤文清</text><text>139****6421 · 家谱成员</text></view></view><view class="profile-todo" @click="toNotifications"><image src="/static/assets/modules/genealogy/opaque/application-status-card.png" mode="scaleToFill"/><view><text>待你处理</text><text>2 条家谱提醒与审核通知</text></view></view><view v-for="item in menuItems" :key="item.label" class="profile-menu" @click="openItem(item)"><image src="/static/assets/modules/genealogy/opaque/g06-search-input-wide.png" mode="scaleToFill"/><view><text>{{item.label}}</text><text>{{item.note}}</text></view><text>查看</text></view></view></view><view v-else class="profile-error"><image src="/static/assets/modules/genealogy/opaque/application-status-card.png" mode="scaleToFill"/><view><text>个人资料暂不可用</text><text>请稍后重新进入,账号和家谱资料不会受到影响。</text></view></view></view><AppTabbar active="profile"/></view></template>
<script setup>import{ref}from'vue';import{onLoad}from'@dcloudio/uni-app';import PageHeader from'@/components/PageHeader.vue';import AppTabbar from'@/components/AppTabbar.vue';const profileState=ref('ready');const menuItems=[{label:'账号与安全',note:'密码、手机与设备',url:'/pages/profile/m03-security-settings'},{label:'帮助与反馈',note:'使用说明与问题反馈',url:'/pages/profile/m06-help-center'},{label:'关于家谱',note:'协议、隐私与版本',url:'/pages/profile/m10-about-settings'}];onLoad(query=>{profileState.value=query.state==='error'?'error':'ready'});const toProfile=()=>uni.navigateTo({url:'/pages/profile/m02-edit-profile'});const toNotifications=()=>uni.navigateTo({url:'/pages/notification/n01-message-center'});const openItem=item=>uni.navigateTo({url:item.url})</script>
<style scoped lang="scss">.profile-page{position:relative;min-height:100vh;overflow:hidden;background:$paper}.profile-page__paper{position:absolute;inset:0;z-index:0;width:100%;height:100%;pointer-events:none}.profile-page__header,.profile-content{position:relative;z-index:2}.profile-content{padding:18rpx 24rpx 190rpx}.profile-panel{position:relative;width:100%;height:min(650px,calc((100vw - 24px)*1.5))}.profile-panel__skin{position:absolute;inset:0;width:100%;height:100%}.profile-panel__body{position:absolute;inset:7% 8%}.profile-heading{display:flex;align-items:center;gap:18rpx}.profile-heading>image{width:76rpx;height:98rpx}.profile-heading text{display:block}.profile-heading text:first-child{color:$ink;font-family:STKaiti,KaiTi,serif;font-size:35rpx;font-weight:700}.profile-heading text:last-child{margin-top:7rpx;color:$ink-muted;font-size:19rpx}.profile-todo{position:relative;height:124rpx;margin-top:18rpx}.profile-todo>image,.profile-menu>image,.profile-error>image{position:absolute;inset:0;width:100%;height:100%}.profile-todo>view{position:absolute;inset:22% 9%;z-index:1}.profile-todo text{display:block}.profile-todo text:first-child{color:$brand-red;font-size:22rpx;font-weight:700}.profile-todo text:last-child{margin-top:7rpx;color:$ink-muted;font-size:19rpx}.profile-menu{position:relative;height:76rpx;margin-top:11rpx}.profile-menu>view{position:absolute;top:14rpx;left:22rpx;z-index:1}.profile-menu>view text{display:block}.profile-menu>view text:first-child{color:$ink;font-size:21rpx;font-weight:700}.profile-menu>view text:last-child{margin-top:3rpx;color:$ink-muted;font-size:17rpx}.profile-menu>text{position:absolute;top:29rpx;right:22rpx;z-index:1;color:$brand-red;font-size:18rpx}.profile-error{position:relative;height:230rpx;margin-top:70rpx}.profile-error>view{position:absolute;inset:27% 12%;z-index:1;text-align:center}.profile-error text{display:block}.profile-error text:first-child{color:$ink;font-family:STKaiti,KaiTi,serif;font-size:30rpx;font-weight:700}.profile-error text:last-child{margin-top:13rpx;color:$ink-muted;font-size:20rpx}</style>
+140 -41
View File
@@ -1,44 +1,119 @@
<!-- 页面编号T-01用途世系树总览 -->
<!-- 页面编号T-01用途世系树阅读同页提示空态与失败状态页面设计阶段使用本地模拟数据 -->
<template>
<view class="tree-page">
<PageHeader title="世系树" action="树形视图" />
<view class="tree-toolbar"><view class="branch-select">当前家谱世系</view><view class="tree-tools"><text @click="toDirectory">查找</text><text @click="toRelationship">编辑</text></view></view>
<scroll-view class="tree-scroll" scroll-x scroll-y>
<view class="tree-canvas">
<view v-if="!members.length" class="tree-empty">{{ loadError || '暂未录入族人' }}</view>
<view v-for="member in members" :key="member.id" class="member-node" :class="{ selected: selected && selected.id === member.id }" :style="nodeStyle(member)" @click="selected = member"><text class="node-name">{{ member.name }}</text><text class="node-relation">{{ member.relation }}</text><text class="node-years">{{ member.years }}</text></view>
<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'
}">
<image class="tree-page__paper" src="/static/assets/foundation/opaque/page-paper.jpg" mode="aspectFill" />
<view class="tree-page__header"><PageHeader title="世系树" action="成员目录" @action="toDirectory" /></view>
<view class="tree-toolbar">
<view class="tree-toolbar__title">
<text>汤氏家谱</text><text>主支 · 1214 </text>
</view>
<view class="tree-toolbar__actions">
<text @click="treeState = 'landscape'">阅读提示</text>
<text @click="toRelationship">关系维护</text>
</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" />
<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" />
<text class="node-name">{{ member.name }}</text>
<text class="node-relation">{{ member.relation }}</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/opaque/a01-primary-button.png" mode="scaleToFill" />
<text>{{ stateCopy.action }}</text>
</view>
</view>
</view>
</scroll-view>
<view v-if="selected" class="member-sheet"><view><text class="sheet-name">{{ selected.name }}</text><text class="sheet-meta">第 {{ selected.generation }} 世 · {{ selected.relation }} · {{ selected.branch }}</text></view><view class="sheet-actions"><text @click="toMember">查看资料</text><text @click="toAddRelative">添加亲属</text></view></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" />
<view class="member-sheet__copy">
<text class="sheet-name">{{ selected.name }}</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/opaque/a01-secondary-button.png" mode="scaleToFill" /><text>查看资料</text>
</view>
<view class="sheet-action" @click="toAddRelative">
<image src="/static/assets/foundation/opaque/a01-primary-button.png" mode="scaleToFill" /><text>添加亲属</text>
</view>
</view>
</view>
</view>
</template>
<script setup>
import { ref } from 'vue'
import { computed, ref } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
import PageHeader from '@/components/PageHeader.vue'
import { appApi } from '@/utils/api.js'
import { genealogyContext } from '@/utils/genealogy-context.js'
const members = ref([])
const selected = ref(null)
const genealogyId = ref('')
const loadError = ref('')
const treeState = ref('loading')
const selected = ref(null)
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 }
])
const loadTree = async (query) => {
genealogyId.value = query.genealogyId || genealogyContext.getCurrentGenealogyId()
if (!genealogyId.value) { loadError.value = '未找到当前家谱。'; return }
genealogyContext.setCurrentGenealogyId(genealogyId.value)
try {
members.value = await appApi.getTree(genealogyId.value)
selected.value = members.value.find((item) => String(item.id) === String(query.selectedId)) || members.value[0] || null
} catch (error) {
loadError.value = error.message || '世系树加载失败。'
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] || {}))
onLoad((query) => {
genealogyId.value = query.genealogyId || genealogyContext.getCurrentGenealogyId() || ''
if (query.state === 'landscape' || query.state === 'empty' || query.state === 'error') {
treeState.value = query.state
return
}
}
if (!genealogyId.value) {
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'
})
onLoad(loadTree)
const nodeStyle = (member) => ({ left: `${member.x}%`, top: `${member.y}%` })
const handleStateAction = () => {
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}` })
@@ -46,20 +121,44 @@ const toAddRelative = () => uni.navigateTo({ url: `/pages/tree/t04-add-relative?
</script>
<style scoped lang="scss">
.tree-page { height: 100vh; overflow: hidden; background: $paper; }
.tree-toolbar { display: flex; align-items: center; justify-content: space-between; padding: 20rpx 28rpx; border-bottom: 1rpx solid $gold-soft; background: #fffaf0; }
.branch-select { padding: 12rpx 18rpx; border: 1rpx solid $gold; color: $ink; font-size: 25rpx; }
.tree-tools { display: flex; gap: 24rpx; color: $brand-red; font-size: 26rpx; }
.tree-scroll { height: calc(100vh - 382rpx); white-space: nowrap; }
.tree-canvas { position: relative; width: 1180rpx; height: 1120rpx; background: $paper; }
.tree-empty { padding: 240rpx 0; color: $ink-muted; font-size: 28rpx; text-align: center; }
.member-node { position: absolute; width: 150rpx; min-height: 148rpx; padding: 20rpx 12rpx; box-sizing: border-box; border: 2rpx solid $gold-soft; background: #fffaf0; transform: translateX(-50%); white-space: normal; text-align: center; }
.member-node.selected { border-color: $brand-red; box-shadow: 0 0 0 6rpx #f2dfcf; }
.node-name { display: block; color: $ink; font-family: serif; font-size: 32rpx; font-weight: 700; }
.node-relation { display: block; margin-top: 10rpx; color: $brand-red; font-size: 22rpx; }
.node-years { display: block; margin-top: 8rpx; color: $ink-muted; font-size: 20rpx; }
.member-sheet { position: fixed; right: 0; bottom: 0; left: 0; display: flex; align-items: center; justify-content: space-between; padding: 26rpx 28rpx calc(26rpx + env(safe-area-inset-bottom)); border-top: 1rpx solid $gold; background: $navy; color: #fff8ec; }
.sheet-name { display: block; font-family: serif; font-size: 36rpx; font-weight: 700; }
.sheet-meta { display: block; margin-top: 8rpx; color: #dfc590; font-size: 21rpx; }
.sheet-actions { display: flex; flex-direction: column; gap: 12rpx; color: #f3dba7; font-size: 24rpx; text-align: right; }
.tree-page { position: relative; height: 100vh; overflow: hidden; background: $paper; }
.tree-page__paper { position: absolute; inset: 0; z-index: 0; width: 100%; height: 100%; pointer-events: none; }
.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; }
</style>
+73 -46
View File
@@ -1,59 +1,86 @@
<!-- 页面编号T-03用途成员档案详情 -->
<!-- 页面编号T-03用途成员档案详情与失败状态页面设计阶段使用本地模拟数据 -->
<template>
<view class="page-shell member-page">
<PageHeader title="成员档案" action="编辑" @action="toEdit" />
<view v-if="member" class="member-hero"><view class="member-seal">{{ member.name.slice(0, 1) }}</view><view><text class="member-name">{{ member.name }}</text><text class="member-role"> {{ member.generation }} · {{ member.relation }} · 家谱成员</text></view></view>
<view v-else class="state-copy">{{ loadError || '正在载入成员档案…' }}</view>
<template v-if="member">
<view class="profile-card paper-card"><text class="section-title">基本资料</text><view v-for="item in details" :key="item.label" class="info-row"><text>{{ item.label }}</text><text>{{ item.value }}</text></view></view>
<view class="profile-card paper-card"><text class="section-title">亲属关系</text><text class="bio">亲属关系将在编辑功能接入后完整维护</text></view>
<view v-if="member.introduction" class="profile-card paper-card"><text class="section-title">生平事迹</text><text class="bio">{{ member.introduction }}</text></view>
</template>
<view class="member-page" :class="{ 'member-state--detail': memberState === 'detail', 'member-state--error': memberState === 'error' }">
<image class="member-page__paper" src="/static/assets/foundation/opaque/page-paper.jpg" mode="aspectFill" />
<view class="member-page__header"><PageHeader title="成员档案" :action="memberState === 'detail' ? '编辑' : ''" @action="toEdit" /></view>
<view class="member-panel">
<image class="member-panel__skin" src="/static/assets/modules/genealogy/opaque/g03-create-flow-panel.png" mode="scaleToFill" />
<view v-if="memberState === 'detail'" class="member-detail">
<view class="member-heading">
<view class="member-heading__seal"><image src="/static/assets/modules/genealogy/transparent/current-seal-frame.png" mode="scaleToFill" /><text>{{ member.name.slice(0, 1) }}</text></view>
<view><text>{{ member.name }}</text><text> {{ member.generation }} · {{ member.relation }} · {{ member.branch }}</text></view>
</view>
<text class="member-section-title">基本资料</text>
<view v-for="item in details" :key="item.label" class="member-info-row">
<image src="/static/assets/modules/genealogy/opaque/g06-search-input-wide.png" mode="scaleToFill" /><text>{{ item.label }}</text><text>{{ item.value }}</text>
</view>
<text class="member-section-title member-section-title--relation">亲属关系</text>
<view class="member-relatives">
<text>长子 · 汤正国</text><text>次子 · 汤正华</text>
</view>
<view class="member-profile-action" @click="toEdit">
<image src="/static/assets/foundation/opaque/a01-primary-button.png" mode="scaleToFill" /><text>完善成员档案</text>
</view>
</view>
<view v-else class="member-error">
<text>成员档案暂不可用</text><text>请从世系树重新选择成员当前没有可展示的个人资料</text>
<view class="member-profile-action" @click="toTree"><image src="/static/assets/foundation/opaque/a01-primary-button.png" mode="scaleToFill" /><text>返回世系树</text></view>
</view>
</view>
</view>
</template>
<script setup>
import { computed, ref } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
import PageHeader from '@/components/PageHeader.vue'
import { appApi } from '@/utils/api.js'
import { genealogyContext } from '@/utils/genealogy-context.js'
const member = ref(null)
const loadError = ref('')
const genealogyId = ref('')
const details = computed(() => member.value ? [
{ label: '字辈', value: member.value.generationName || `${member.value.generation}` },
{ label: '出生日期', value: member.value.birthDate || '待补' },
{ label: '生卒信息', value: member.value.years || '待补' },
{ label: '所属支系', value: member.value.branch || '主支' }
] : [])
onLoad(async (query) => {
genealogyId.value = query.genealogyId || genealogyContext.getCurrentGenealogyId()
if (!genealogyId.value || !query.personId) { loadError.value = '成员参数不完整,请从世系树进入。'; return }
genealogyContext.setCurrentGenealogyId(genealogyId.value)
try {
member.value = await appApi.getPerson(genealogyId.value, query.personId)
if (!member.value) loadError.value = '未找到该成员。'
} catch (error) {
loadError.value = error.message || '成员档案加载失败。'
}
const personId = ref('')
const memberState = ref('loading')
const member = ref({ id: 101, name: '汤文远', generation: 12, relation: '始祖', branch: '主支', generationName: '文字辈', birthDate: '1940年3月', years: '1940—2012' })
const details = computed(() => [
{ label: '字辈', value: member.value.generationName },
{ label: '出生日期', value: member.value.birthDate },
{ label: '生卒信息', value: member.value.years },
{ label: '所属支系', value: member.value.branch }
])
onLoad((query) => {
genealogyId.value = query.genealogyId || genealogyContext.getCurrentGenealogyId() || ''
personId.value = query.personId || ''
if (genealogyId.value) genealogyContext.setCurrentGenealogyId(genealogyId.value)
memberState.value = query.state === 'error' || !personId.value ? 'error' : 'detail'
})
const toEdit = () => uni.navigateTo({ url: `/pages/tree/t05-edit-member?genealogyId=${genealogyId.value}&personId=${member.value?.id || ''}` })
const toEdit = () => uni.navigateTo({ url: `/pages/tree/t05-edit-member?genealogyId=${genealogyId.value}&personId=${personId.value}` })
const toTree = () => uni.navigateBack()
</script>
<style scoped lang="scss">
.member-page { padding-bottom: 36rpx; }
.member-hero { display: flex; align-items: center; gap: 24rpx; padding: 38rpx 34rpx; background: $navy; }
.member-seal { display: flex; width: 92rpx; height: 92rpx; align-items: center; justify-content: center; border: 3rpx solid #e0bd77; border-radius: 50%; color: #f4daa1; font-family: serif; font-size: 46rpx; }
.member-name { display: block; color: #fff8ec; font-family: serif; font-size: 46rpx; font-weight: 700; }
.member-role { display: block; margin-top: 10rpx; color: #e0c993; font-size: 23rpx; }
.state-copy { padding: 80rpx 40rpx; color: $ink-muted; font-size: 27rpx; text-align: center; }
.profile-card { margin: 26rpx 28rpx 0; padding: 30rpx; }
.info-row { display: flex; justify-content: space-between; padding: 25rpx 0; border-bottom: 1rpx solid #eadfc9; color: $ink-muted; font-size: 27rpx; }
.info-row text:last-child { color: $ink; }
.info-row:last-child { border-bottom: 0; }
.bio { display: block; margin-top: 22rpx; color: $ink-muted; font-size: 27rpx; line-height: 1.9; }
.member-page { position: relative; min-height: 100vh; overflow: hidden; background: $paper; }
.member-page__paper { position: absolute; inset: 0; z-index: 0; width: 100%; height: 100%; pointer-events: none; }
.member-page__header { position: relative; z-index: 3; }
.member-panel { position: relative; z-index: 2; width: calc(100% - 32rpx); height: min(650px, calc((100vw - 16px) * 1.5)); margin: 18rpx auto 0; }
.member-panel__skin { position: absolute; inset: 0; width: 100%; height: 100%; }
.member-detail { position: absolute; inset: 7% 8%; }
.member-heading { display: flex; align-items: center; gap: 20rpx; }
.member-heading__seal { position: relative; display: flex; width: 84rpx; height: 108rpx; align-items: center; justify-content: center; }
.member-heading__seal image { position: absolute; inset: 0; width: 100%; height: 100%; }
.member-heading__seal text { position: relative; z-index: 1; color: #fff8ec; font-family: 'STKaiti', 'KaiTi', serif; font-size: 33rpx; }
.member-heading > view:last-child text { display: block; }
.member-heading > view:last-child text:first-child { color: $ink; font-family: 'STKaiti', 'KaiTi', serif; font-size: 38rpx; font-weight: 700; }
.member-heading > view:last-child text:last-child { margin-top: 8rpx; color: $ink-muted; font-size: 20rpx; }
.member-section-title { display: block; margin: 20rpx 0 4rpx; color: $brand-red; font-size: 22rpx; font-weight: 700; letter-spacing: 2rpx; }
.member-info-row { position: relative; height: 62rpx; margin-top: 7rpx; }
.member-info-row image { position: absolute; inset: 0; width: 100%; height: 100%; }
.member-info-row text { position: absolute; top: 21rpx; z-index: 1; font-size: 20rpx; }
.member-info-row text:nth-child(2) { left: 22rpx; color: $ink-muted; }
.member-info-row text:last-child { right: 22rpx; color: $ink; font-weight: 700; }
.member-section-title--relation { margin-top: 18rpx; }
.member-relatives { display: flex; justify-content: space-between; color: $ink; font-size: 20rpx; }
.member-profile-action { position: relative; width: 100%; height: 72rpx; margin-top: 20rpx; }
.member-profile-action image { position: absolute; inset: 0; width: 100%; height: 100%; }
.member-profile-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-error { position: absolute; top: 31%; right: 12%; left: 12%; text-align: center; }
.member-error > text { display: block; }
.member-error > text:first-child { color: $ink; font-family: 'STKaiti', 'KaiTi', serif; font-size: 35rpx; font-weight: 700; }
.member-error > text:nth-child(2) { margin-top: 18rpx; color: $ink-muted; font-size: 21rpx; line-height: 1.65; }
@media (min-width: 400px) { .member-panel { width: calc(100% - 48rpx); } }
</style>
+3 -3
View File
@@ -1,3 +1,3 @@
<!-- 页面编号T-04用途新增直系亲属 -->
<template><ModulePage page-id="t04" /></template>
<script setup>import ModulePage from '@/components/ModulePage.vue'</script>
<!-- 页面编号T-04用途新增直系亲属与保存结果 -->
<template><TreeMemberForm kind="add" /></template>
<script setup>import TreeMemberForm from '@/components/tree/TreeMemberForm.vue'</script>
+3 -3
View File
@@ -1,3 +1,3 @@
<!-- 页面编号T-05用途编辑成员资料 -->
<template><ModulePage page-id="t05" /></template>
<script setup>import ModulePage from '@/components/ModulePage.vue'</script>
<!-- 页面编号T-05用途编辑成员资料与保存结果 -->
<template><TreeMemberForm kind="edit" /></template>
<script setup>import TreeMemberForm from '@/components/tree/TreeMemberForm.vue'</script>
+3 -3
View File
@@ -1,3 +1,3 @@
<!-- 页面编号T-06用途编辑亲属关系冲突提示 -->
<template><ModulePage page-id="t06" /></template>
<script setup>import ModulePage from '@/components/ModulePage.vue'</script>
<!-- 页面编号T-06用途编辑亲属关系冲突提示与处理 -->
<template><TreeMemberForm kind="relation" /></template>
<script setup>import TreeMemberForm from '@/components/tree/TreeMemberForm.vue'</script>
+72 -3
View File
@@ -1,3 +1,72 @@
<!-- 页面编号T-07用途成员目录与搜索 -->
<template><ModulePage page-id="t07" /></template>
<script setup>import ModulePage from '@/components/ModulePage.vue'</script>
<!-- 页面编号T-07用途成员目录搜索空态与失败状态 -->
<template>
<view class="directory-page" :class="{ 'directory-state--list': directoryState === 'list', 'directory-state--empty': directoryState === 'empty', 'directory-state--error': directoryState === 'error' }">
<image class="directory-page__paper" src="/static/assets/foundation/opaque/page-paper.jpg" mode="aspectFill" />
<view class="directory-page__header"><PageHeader title="成员目录" /></view>
<view class="directory-search">
<image src="/static/assets/modules/genealogy/opaque/g06-search-input-wide.png" mode="scaleToFill" />
<input v-model="keyword" placeholder="按姓名、字辈或支系查找" placeholder-class="directory-placeholder" @confirm="searchMembers" />
<text @click="searchMembers">查找</text>
</view>
<view class="directory-content">
<template v-if="directoryState === 'list'">
<view class="directory-summary"><text>家谱成员</text><text>{{ filteredMembers.length }} · 按世代排列</text></view>
<view v-for="item in filteredMembers" :key="item.id" class="directory-card" @click="openMember(item)">
<image src="/static/assets/modules/genealogy/opaque/application-status-card.png" mode="scaleToFill" />
<view class="directory-card__copy"><text>{{ item.name }}</text><text> {{ item.generation }} · {{ item.generationName }} · {{ item.branch }}</text><text>{{ item.note }}</text></view>
</view>
</template>
<view v-else class="directory-state-card">
<image src="/static/assets/modules/genealogy/opaque/application-status-card.png" mode="scaleToFill" />
<view><text>{{ directoryState === 'empty' ? '没有找到相关成员' : '成员目录暂不可用' }}</text><text>{{ directoryState === 'empty' ? '换一个姓名、字辈或支系关键词再试。' : '请从当前家谱重新进入,成员资料不会受到影响。' }}</text></view>
</view>
</view>
</view>
</template>
<script setup>
import { computed, ref } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
import PageHeader from '@/components/PageHeader.vue'
const genealogyId = ref('')
const keyword = ref('')
const directoryState = ref('loading')
const members = [
{ id: 101, name: '汤文远', generation: 12, generationName: '文字辈', branch: '主支', note: '始祖 · 档案完整' },
{ id: 102, name: '汤正国', generation: 13, generationName: '正字辈', branch: '长房', note: '家谱管理员' },
{ id: 103, name: '汤正华', generation: 13, generationName: '正字辈', branch: '二房', note: '资料待补充' }
]
const filteredMembers = computed(() => {
const value = keyword.value.trim()
if (!value) return members
return members.filter((item) => `${item.name}${item.generationName}${item.branch}`.includes(value))
})
onLoad((query) => { genealogyId.value = query.genealogyId || ''; directoryState.value = query.state === 'empty' ? 'empty' : query.state === 'error' ? 'error' : 'list' })
const searchMembers = () => { directoryState.value = filteredMembers.value.length ? 'list' : 'empty' }
const openMember = (item) => uni.navigateTo({ url: `/pages/tree/t03-member-profile?genealogyId=${genealogyId.value}&personId=${item.id}` })
</script>
<style scoped lang="scss">
.directory-page { position: relative; min-height: 100vh; overflow: hidden; background: $paper; }
.directory-page__paper { position: absolute; inset: 0; z-index: 0; width: 100%; height: 100%; pointer-events: none; }
.directory-page__header, .directory-search, .directory-content { position: relative; z-index: 2; }
.directory-search { width: calc(100% - 48rpx); height: 84rpx; margin: 20rpx auto 0; }
.directory-search image { position: absolute; inset: 0; width: 100%; height: 100%; }
.directory-search input { position: absolute; top: 0; right: 110rpx; bottom: 0; left: 26rpx; z-index: 1; height: 84rpx; color: $ink; font-size: 22rpx; line-height: 84rpx; }
.directory-search > text { position: absolute; top: 29rpx; right: 28rpx; z-index: 1; color: $brand-red; font-size: 22rpx; font-weight: 700; }
.directory-placeholder { color: #a79884; }
.directory-content { padding: 22rpx 24rpx 50rpx; }
.directory-summary { display: flex; justify-content: space-between; margin: 0 8rpx 18rpx; }
.directory-summary text:first-child { color: $ink; font-family: 'STKaiti', 'KaiTi', serif; font-size: 29rpx; font-weight: 700; }
.directory-summary text:last-child { color: $ink-muted; font-size: 20rpx; }
.directory-card, .directory-state-card { position: relative; width: 100%; height: calc((100vw - 24px) * .34286); min-height: 220rpx; margin-bottom: 16rpx; }
.directory-card > image, .directory-state-card > image { position: absolute; inset: 0; width: 100%; height: 100%; }
.directory-card__copy { position: absolute; inset: 20% 9%; z-index: 1; }
.directory-card__copy text { display: block; }
.directory-card__copy text:first-child { color: $ink; font-family: 'STKaiti', 'KaiTi', serif; font-size: 29rpx; font-weight: 700; }
.directory-card__copy text:nth-child(2) { margin-top: 10rpx; color: $ink-muted; font-size: 21rpx; }
.directory-card__copy text:last-child { margin-top: 9rpx; color: $brand-red; font-size: 19rpx; }
.directory-state-card { margin-top: 70rpx; }
.directory-state-card > view { position: absolute; inset: 24% 12%; z-index: 1; text-align: center; }
.directory-state-card text { display: block; }
.directory-state-card text:first-child { color: $ink; font-family: 'STKaiti', 'KaiTi', serif; font-size: 30rpx; font-weight: 700; }
.directory-state-card text:last-child { margin-top: 14rpx; color: $ink-muted; font-size: 21rpx; line-height: 1.5; }
</style>
+67 -3
View File
@@ -1,3 +1,67 @@
<!-- 页面编号T-08用途成员离世隐私隐藏与无权限状态 -->
<template><ModulePage page-id="t08" /></template>
<script setup>import ModulePage from '@/components/ModulePage.vue'</script>
<!-- 页面编号T-08用途成员隐私离世纪念与无权限状态说明 -->
<template>
<view class="member-status-page" :class="{
'member-status--privacy': statusState === 'privacy',
'member-status--deceased': statusState === 'deceased',
'member-status--forbidden': statusState === 'forbidden'
}">
<image class="member-status-page__paper" src="/static/assets/foundation/opaque/page-paper.jpg" mode="aspectFill" />
<view class="member-status-page__header"><PageHeader title="成员状态" /></view>
<view class="status-tabs">
<view v-for="item in tabs" :key="item.value" class="status-tab" @click="statusState = item.value">
<image :src="statusState === item.value ? '/static/assets/foundation/opaque/a01-primary-button.png' : '/static/assets/foundation/opaque/a01-secondary-button.png'" mode="scaleToFill" />
<text :class="{ 'status-tab__text--active': statusState === item.value }">{{ item.label }}</text>
</view>
</view>
<view class="status-card">
<image src="/static/assets/modules/genealogy/opaque/application-status-card.png" mode="scaleToFill" />
<view class="status-card__copy">
<text>{{ activeStatus.eyebrow }}</text><text>{{ activeStatus.title }}</text><text>{{ activeStatus.copy }}</text>
</view>
</view>
<view class="status-guidance">
<image src="/static/assets/modules/genealogy/opaque/g03-create-flow-panel.png" mode="scaleToFill" />
<view>
<text>{{ activeStatus.guideTitle }}</text>
<text v-for="line in activeStatus.guides" :key="line">{{ line }}</text>
</view>
</view>
</view>
</template>
<script setup>
import { computed, ref } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
import PageHeader from '@/components/PageHeader.vue'
const statusState = ref('privacy')
const tabs = [{ value: 'privacy', label: '隐私' }, { value: 'deceased', label: '纪念' }, { value: 'forbidden', label: '无权限' }]
const states = {
privacy: { eyebrow: '隐私成员', title: '敏感资料只向授权成员展示', copy: '姓名可保留在世系位置,出生信息、联系方式和生平内容根据权限隐藏。', guideTitle: '隐私展示原则', guides: ['本人可以查看和维护自己的完整档案', '谱主可协助处理身份与世系关系', '普通成员只看到被允许公开的内容'] },
deceased: { eyebrow: '离世成员', title: '在世系中保留温和的纪念状态', copy: '离世不会删除成员关系;档案可继续记录生平、影像和家人的追思内容。', guideTitle: '纪念资料范围', guides: ['生卒年月与世系关系继续保留', '生平内容由有权限家人共同维护', '敏感资料仍遵守原有隐私设置'] },
forbidden: { eyebrow: '访问受限', title: '当前账号没有查看该档案的权限', copy: '为保护家人隐私,页面不会展示被隐藏的字段,也不会提供绕过权限的入口。', guideTitle: '如何申请查看', guides: ['先确认已经加入对应家谱', '联系谱主说明亲属关系和用途', '权限变更后重新进入成员档案'] }
}
const activeStatus = computed(() => states[statusState.value])
onLoad((query) => { statusState.value = states[query.state] ? query.state : 'privacy' })
</script>
<style scoped lang="scss">
.member-status-page { position: relative; min-height: 100vh; overflow: hidden; background: $paper; }
.member-status-page__paper { position: absolute; inset: 0; z-index: 0; width: 100%; height: 100%; pointer-events: none; }
.member-status-page__header, .status-tabs, .status-card, .status-guidance { position: relative; z-index: 2; }
.status-tabs { display: flex; gap: 10rpx; padding: 22rpx 24rpx 0; }
.status-tab { position: relative; width: calc(33.333% - 7rpx); height: 62rpx; }
.status-tab image { position: absolute; inset: 0; width: 100%; height: 100%; }
.status-tab text { position: relative; z-index: 1; display: flex; align-items: center; justify-content: center; height: 100%; color: $ink; font-size: 21rpx; font-weight: 700; }
.status-tab .status-tab__text--active { color: #fff9ed; }
.status-card { width: calc(100% - 48rpx); height: calc((100vw - 24px) * .34286); min-height: 224rpx; margin: 24rpx auto 0; }
.status-card > image { position: absolute; inset: 0; width: 100%; height: 100%; }
.status-card__copy { position: absolute; inset: 18% 10%; z-index: 1; text-align: center; }
.status-card__copy text { display: block; }
.status-card__copy text:first-child { color: $brand-red; font-size: 20rpx; letter-spacing: 3rpx; }
.status-card__copy text:nth-child(2) { margin-top: 7rpx; color: $ink; font-family: 'STKaiti', 'KaiTi', serif; font-size: 29rpx; font-weight: 700; }
.status-card__copy text:last-child { margin-top: 10rpx; color: $ink-muted; font-size: 20rpx; line-height: 1.5; }
.status-guidance { width: calc(100% - 48rpx); height: min(400px, calc((100vw - 24px) * .9)); margin: 18rpx auto 0; }
.status-guidance > image { position: absolute; inset: 0; width: 100%; height: 100%; }
.status-guidance > view { position: absolute; inset: 12% 11%; z-index: 1; }
.status-guidance text { display: block; color: $ink-muted; font-size: 21rpx; line-height: 1.65; }
.status-guidance text:first-child { margin-bottom: 18rpx; color: $ink; font-family: 'STKaiti', 'KaiTi', serif; font-size: 31rpx; font-weight: 700; }
.status-guidance text:not(:first-child) { margin-top: 12rpx; }
</style>
+37 -4
View File
@@ -5,14 +5,14 @@ const [url, selector, output, width = '360', height = '800', action] = process.a
const renderSettleMilliseconds = 900
if (!url || !selector || !output) {
throw new Error('Usage: node scripts/capture-chrome-page.js <url> <selector> <output> [width] [height] [sms-tab]')
throw new Error('Usage: node scripts/capture-chrome-page.js <url> <selector> <output> [width] [height] [sms-tab|g06-results|g06-empty|fresh-navigation]')
}
const sleep = (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds))
const connect = async () => {
const pages = await (await fetch('http://127.0.0.1:9222/json/list')).json()
const page = pages.find((item) => item.type === 'page' && item.url.includes('localhost:5173'))
const page = pages.find((item) => item.type === 'page' && item.url.startsWith('http://localhost:5173'))
if (!page) throw new Error('Chrome debugging has no localhost:5173 page')
const socket = new WebSocket(page.webSocketDebuggerUrl)
@@ -77,6 +77,36 @@ const waitForFreshDocument = async (send, documentTimeOrigin) => {
}
}
const prepareG06State = async (send, action) => {
if (action !== 'g06-results' && action !== 'g06-empty') return
const keyword = action === 'g06-results' ? '汤' : '不存在的家谱'
const prepared = await send('Runtime.evaluate', {
expression: `(() => {
const input = document.querySelector('.search-input input')
const button = document.querySelector('.search-action')
if (!input || !button) return false
input.value = ${JSON.stringify(keyword)}
input.dispatchEvent(new Event('input', { bubbles: true }))
button.click()
return true
})()`,
returnByValue: true
})
if (!prepared.result?.value) throw new Error(`Could not prepare ${action}`)
const stateSelector = action === 'g06-results' ? '.search-results .genealogy-card' : '.search-empty'
for (let attempt = 0; attempt < 30; attempt += 1) {
const result = await send('Runtime.evaluate', {
expression: `Boolean(document.querySelector(${JSON.stringify(stateSelector)}))`,
returnByValue: true
})
if (result.result?.value) return
if (attempt === 29) throw new Error(`Expected G06 state did not render: ${action}`)
await sleep(100)
}
}
const capture = async () => {
const { socket, send } = await connect()
const viewportWidth = Number(width)
@@ -100,8 +130,10 @@ const capture = async () => {
const documentTimeOrigin = timeOriginResult.result?.value
await send('Page.navigate', { url })
await waitForRequestedUrl(send, url)
await send('Page.reload')
await waitForFreshDocument(send, documentTimeOrigin)
if (action !== 'fresh-navigation') {
await send('Page.reload')
await waitForFreshDocument(send, documentTimeOrigin)
}
for (let attempt = 0; attempt < 30; attempt += 1) {
const result = await send('Runtime.evaluate', {
@@ -118,6 +150,7 @@ const capture = async () => {
expression: "document.querySelectorAll('.login-tab')[1]?.click()"
})
}
await prepareG06State(send, action)
await waitForLoadedImages(send)
await sleep(renderSettleMilliseconds)
Binary file not shown.

After

Width:  |  Height:  |  Size: 490 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 467 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 MiB

+1 -1
View File
@@ -8,6 +8,6 @@ $a03File = Join-Path $root 'pages/auth/a03-mobile-verify.vue'
if ($pages.pages.path -contains 'pages/auth/a03-mobile-verify') { throw 'A-03 must not remain in pages.json' }
if (Test-Path -LiteralPath $a03File) { throw 'A-03 page file must be deleted' }
if ($catalog -match '(?m)^\s*a03\s*:') { throw 'A-03 catalog entry must be deleted' }
if ($pages.pages.Count -ne 55) { throw "Expected 55 final routes after the approved G02-to-G01, G04-to-G03 and G07-to-G06 state merges, found $($pages.pages.Count)." }
if ($pages.pages.Count -ne 54) { throw "Expected 54 final routes after the approved G02-to-G01, G04-to-G03, G07-to-G06 and T02-to-T01 state merges, found $($pages.pages.Count)." }
Write-Output 'A03-ROUTE-REMOVAL-CONTRACT PASS'
+20 -1
View File
@@ -42,5 +42,24 @@ if ($captureScript -notmatch 'performance\.timeOrigin') {
if ($captureScript -notmatch 'await waitForFreshDocument\(send, documentTimeOrigin\)') {
throw 'Chrome capture must wait for a fresh document before checking the target selector'
}
if ($captureScript -notmatch "action !== 'fresh-navigation'") {
throw 'Chrome capture must support fresh-navigation for root pages whose tab lifecycle closes a reloaded CDP target'
}
foreach ($g06Action in @('g06-results', 'g06-empty')) {
if ($captureScript -notmatch [regex]::Escape($g06Action)) {
throw "Chrome capture must support the $g06Action visual state"
}
}
if ($captureScript -notmatch 'const prepareG06State = async \(send, action\) =>') {
throw 'Chrome capture must prepare G06 search states through the real input and action controls'
}
if ($captureScript -notmatch 'await prepareG06State\(send, action\)') {
throw 'Chrome capture must wait for the requested G06 state before taking a screenshot'
}
if ($captureScript -notmatch "item\.url\.startsWith\('http://localhost:5173'\)") {
throw 'Chrome capture must select the application page instead of a DevTools page containing localhost in its query'
}
if ($captureScript -match "item\.url\.includes\('localhost:5173'\)") {
throw 'Chrome capture must not use an ambiguous localhost substring when selecting its CDP target'
}
Write-Output 'CAPTURE-CHROME-PAGE-CONTRACT PASS'
+6 -6
View File
@@ -93,11 +93,10 @@ if ($applications -notmatch 'genealogyContext') {
}
$notifications = Get-Content -Raw -Encoding UTF8 (Join-Path $root 'pages/notification/n01-message-center.vue')
foreach ($method in @('markNotificationRead', 'markAllNotificationsRead')) {
if ($notifications -notmatch [regex]::Escape($method)) {
throw "Notification page does not use $method"
}
foreach ($method in @('readNotice', 'markAllRead', 'notice-state--list')) {
if ($notifications -notmatch [regex]::Escape($method)) { throw "Notification page does not expose local design interaction: $method" }
}
if ($notifications -match "@/utils/api\.js|\bappApi\b") { throw 'Notification design page must not connect the API layer' }
$family = Get-Content -Raw -Encoding UTF8 (Join-Path $root 'pages/family/f01-family-feed.vue')
foreach ($token in @('genealogyContext', 'openSection', 'pages/family/f04-article-list', 'pages/family/f07-album-list', 'pages/records/r05-ritual-list', 'pages/records/r10-memo-list', 'pages/family/f02-publish-feed')) {
@@ -107,10 +106,11 @@ foreach ($token in @('genealogyContext', 'openSection', 'pages/family/f04-articl
}
$profile = Get-Content -Raw -Encoding UTF8 (Join-Path $root 'pages/profile/m01-profile-home.vue')
foreach ($token in @('appApi', 'getProfile', 'profile.name')) {
foreach ($token in @('profile-state--ready', 'menuItems', 'toProfile')) {
if ($profile -notmatch [regex]::Escape($token)) {
throw "Missing dynamic profile behavior: $token"
throw "Missing local profile design behavior: $token"
}
}
if ($profile -match "@/utils/api\.js|\bappApi\b") { throw 'Profile design page must not connect the API layer' }
Write-Output 'PASS core-flow session and auth contract'
+4 -4
View File
@@ -11,7 +11,7 @@ $pageHeader = ConvertFrom-Utf8Base64 '6aG16Z2i57yW5Y+377ya'
$expectedPages = @(
'pages/auth/a01-entry', 'pages/auth/a02-login', 'pages/auth/a04-register', 'pages/auth/a05-reset-password', 'pages/auth/a06-auth-status',
'pages/genealogy/g01-my-genealogies', 'pages/genealogy/g03-create-genealogy', 'pages/genealogy/g05-genealogy-overview', 'pages/genealogy/g06-search-genealogies', 'pages/genealogy/g08-join-application', 'pages/genealogy/g09-my-applications', 'pages/genealogy/g10-application-review', 'pages/genealogy/g11-genealogy-settings', 'pages/genealogy/g12-generation-poems',
'pages/tree/t01-tree-overview', 'pages/tree/t02-tree-states', 'pages/tree/t03-member-profile', 'pages/tree/t04-add-relative', 'pages/tree/t05-edit-member', 'pages/tree/t06-edit-relationship', 'pages/tree/t07-member-directory', 'pages/tree/t08-member-states',
'pages/tree/t01-tree-overview', 'pages/tree/t03-member-profile', 'pages/tree/t04-add-relative', 'pages/tree/t05-edit-member', 'pages/tree/t06-edit-relationship', 'pages/tree/t07-member-directory', 'pages/tree/t08-member-states',
'pages/family/f01-family-feed', 'pages/family/f02-publish-feed', 'pages/family/f03-feed-detail', 'pages/family/f04-article-list', 'pages/family/f05-article-detail', 'pages/family/f06-article-editor', 'pages/family/f07-album-list', 'pages/family/f08-album-detail', 'pages/family/f09-media-upload', 'pages/family/f10-video-list',
'pages/records/r01-people-list', 'pages/records/r02-person-detail', 'pages/records/r03-gift-list', 'pages/records/r04-gift-editor', 'pages/records/r05-ritual-list', 'pages/records/r06-ritual-detail', 'pages/records/r07-ritual-editor', 'pages/records/r08-growth-journal', 'pages/records/r09-life-events', 'pages/records/r10-memo-list', 'pages/records/r11-merit-records',
'pages/notification/n01-message-center', 'pages/notification/n02-message-detail',
@@ -20,11 +20,11 @@ $expectedPages = @(
$pages = Get-Content -LiteralPath $pagesPath -Raw -Encoding utf8 | ConvertFrom-Json
$actualPaths = @($pages.pages.path)
if ($actualPaths.Count -ne 55) {
throw "Expected 55 visual routes after the approved G02-to-G01, G04-to-G03 and G07-to-G06 state merges, found $($actualPaths.Count)."
if ($actualPaths.Count -ne 54) {
throw "Expected 54 visual routes after the approved G02-to-G01, G04-to-G03, G07-to-G06 and T02-to-T01 state merges, found $($actualPaths.Count)."
}
if (Compare-Object -ReferenceObject $expectedPages -DifferenceObject $actualPaths) {
throw 'pages.json routes do not match the 55-route visual delivery contract.'
throw 'pages.json routes do not match the 54-route visual delivery contract.'
}
foreach ($page in $expectedPages) {
+10 -7
View File
@@ -2,7 +2,7 @@ const sleep = (milliseconds) => new Promise((resolve) => setTimeout(resolve, mil
const connect = async () => {
const pages = await (await fetch('http://127.0.0.1:9222/json/list')).json()
const page = pages.find((item) => item.type === 'page' && item.url.includes('localhost:5173'))
const page = pages.find((item) => item.type === 'page' && item.url.startsWith(origin))
if (!page) throw new Error('Chrome debugging has no localhost:5173 page')
const socket = new WebSocket(page.webSocketDebuggerUrl)
@@ -50,14 +50,17 @@ const waitFor = async (send, expression, message) => {
const origin = process.argv[2] || 'http://localhost:5173'
const g01Path = '/pages/genealogy/g01-my-genealogies'
const emptyUrl = `${origin}/#${g01Path}?state=empty`
const defaultUrl = `${origin}/#${g01Path}`
let auditId = 0
const nextUrl = (query = '') => {
auditId += 1
return `${origin}/?g01Audit=${auditId}#${g01Path}${query}`
}
const openEmptyG01 = async (send) => {
const emptyUrl = nextUrl('?state=empty')
await send('Page.navigate', { url: emptyUrl })
await waitFor(send, "location.href.includes('g01-my-genealogies?state=empty')", 'Did not navigate to G01 empty state')
await send('Page.reload')
await waitFor(send, "Boolean(document.querySelector('.genealogy-empty-state .empty-create-action'))", 'G01 empty state did not render after reload')
await waitFor(send, "Boolean(document.querySelector('.genealogy-empty-state .empty-create-action'))", 'G01 empty state did not render')
}
const run = async () => {
@@ -75,10 +78,10 @@ const run = async () => {
await valueOf(send, "document.querySelector('.empty-search-action')?.click()")
await waitFor(send, "location.href.includes('/pages/genealogy/g06-search-genealogies')", 'G01 empty search action did not open G06')
const defaultUrl = nextUrl()
await send('Page.navigate', { url: defaultUrl })
await waitFor(send, "location.href.endsWith('/pages/genealogy/g01-my-genealogies')", 'Did not navigate to default G01')
await send('Page.reload')
await waitFor(send, "Boolean(document.querySelector('.current-slip'))", 'Default G01 list did not render after reload')
await waitFor(send, "Boolean(document.querySelector('.current-slip'))", 'Default G01 list did not render')
if (await valueOf(send, "Boolean(document.querySelector('.genealogy-empty-state'))")) {
throw new Error('Default G01 must not render the empty state')
}
+5
View File
@@ -45,6 +45,11 @@ const waitFor = async (send, expression, message) => {
}
const setInput = async (send, index, value) => {
await waitFor(
send,
`Boolean(document.querySelectorAll('.flow-fields input')[${index}])`,
`G03 input ${index} did not render`
)
const expression = `(() => {
const input = document.querySelectorAll('.flow-fields input')[${index}]
if (!input) return false
+49
View File
@@ -0,0 +1,49 @@
$ErrorActionPreference = 'Stop'
function Assert-Contains {
param([string]$Content, [string]$Expected, [string]$Message)
if ($Content -notmatch [regex]::Escape($Expected)) { throw $Message }
}
function Assert-NoCssSurface {
param([string]$Content, [string]$ClassName)
foreach ($property in @('border', 'background', 'border-radius')) {
if ($Content -match "(?s)\\.$ClassName\\s*\\{[^}]*\\b$property\\s*:") {
throw "G05 must not construct .$ClassName with CSS $property"
}
}
}
$root = Split-Path -Parent $PSScriptRoot
$g01 = Get-Content -LiteralPath (Join-Path $root 'pages/genealogy/g01-my-genealogies.vue') -Raw -Encoding utf8
$g05 = Get-Content -LiteralPath (Join-Path $root 'pages/genealogy/g05-genealogy-overview.vue') -Raw -Encoding utf8
$asset = Join-Path $root 'static/assets/modules/genealogy/opaque/g05-overview-surface.png'
Assert-Contains $g01 'g05-genealogy-overview?genealogyId=' 'G01 must open G05 with the genealogyId query contract'
if ($g01 -match 'g05-genealogy-overview\?id=') { throw 'G01 must not retain the obsolete G05 id query key' }
foreach ($required in @(
"const overviewState = ref('loading')",
"query.genealogyId",
'overview-ready',
'overview-state--empty',
'overview-state--error',
'g05-overview-surface.png',
'/pages/genealogy/g03-create-genealogy?step=ancestor&genealogyId=',
'/pages/tree/t01-tree-overview?genealogyId=',
'/pages/genealogy/g10-application-review?genealogyId=',
'/pages/genealogy/g11-genealogy-settings?genealogyId=',
'/pages/genealogy/g12-generation-poems?genealogyId=',
'/pages/family/f01-family-feed'
)) {
Assert-Contains $g05 $required "Missing G05 overview contract: $required"
}
if ($g05 -match 'g04-first-ancestor') { throw 'G05 must not navigate to the removed G04 route' }
if ($g05 -match 'toJoinApplication') { throw 'G05 must not mislabel the applicant join form as an owner invitation action' }
foreach ($className in @('overview-surface', 'overview-action', 'overview-state-panel')) {
Assert-NoCssSurface $g05 $className
}
if (-not (Test-Path -LiteralPath $asset)) { throw 'G05 requires final opaque asset: g05-overview-surface.png' }
Write-Output 'G05-OVERVIEW-CONTRACT PASS'
+66
View File
@@ -0,0 +1,66 @@
const sleep = (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds))
const origin = process.argv[2] || 'http://localhost:5173'
const route = '/pages/genealogy/g05-genealogy-overview'
const connect = async () => {
const pages = await (await fetch('http://127.0.0.1:9222/json/list')).json()
const page = pages.find((item) => item.type === 'page' && item.url.startsWith(`${origin}/`))
if (!page) throw new Error(`Chrome debugging has no ${origin} application page`)
const socket = new WebSocket(page.webSocketDebuggerUrl)
await new Promise((resolve, reject) => {
socket.addEventListener('open', resolve, { once: true })
socket.addEventListener('error', reject, { once: true })
})
let id = 0
const pending = new Map()
socket.addEventListener('message', (event) => {
const message = JSON.parse(event.data)
const request = pending.get(message.id)
if (!request) return
pending.delete(message.id)
message.error ? request.reject(new Error(message.error.message)) : request.resolve(message.result)
})
const send = (method, params = {}) => new Promise((resolve, reject) => {
id += 1
pending.set(id, { resolve, reject })
socket.send(JSON.stringify({ id, method, params }))
})
return { socket, send }
}
const valueOf = async (send, expression) => (await send('Runtime.evaluate', { expression, returnByValue: true })).result?.value
const waitFor = async (send, expression, message) => {
for (let attempt = 0; attempt < 40; attempt += 1) {
if (await valueOf(send, expression)) return
await sleep(100)
}
throw new Error(message)
}
const open = async (send, query, selector) => {
const url = `${origin}/#${route}${query}`
await send('Page.navigate', { url })
await waitFor(send, `location.href === ${JSON.stringify(url)}`, 'G05 navigation failed')
await send('Page.reload')
await waitFor(send, `Boolean(document.querySelector(${JSON.stringify(selector)}))`, `G05 ${query} did not render`)
}
const run = async () => {
const { socket, send } = await connect()
try {
await send('Page.enable')
await send('Runtime.enable')
await open(send, '?state=empty', '.overview-state--empty')
await open(send, '?state=error&genealogyId=1001', '.overview-state--error')
await open(send, '?genealogyId=1001', '.overview-ready')
await valueOf(send, "document.querySelector('.overview-action--ancestor')?.click()")
await waitFor(send, "location.href.includes('/pages/genealogy/g03-create-genealogy?step=ancestor&genealogyId=1001')", 'G05 ancestor action did not open the G03 ancestor step')
process.stdout.write('G05-OVERVIEW-RUNTIME-SMOKE PASS\n')
} finally {
socket.close()
}
}
run().catch((error) => {
process.stderr.write(`${error.stack || error.message}\n`)
process.exit(1)
})
+58
View File
@@ -0,0 +1,58 @@
$ErrorActionPreference = 'Stop'
Add-Type -AssemblyName System.Drawing
$root = Split-Path -Parent $PSScriptRoot
$screenshots = Join-Path $root 'docs/design/screens/runtime/2026-07-14'
$files = [ordered]@{
initial = Join-Path $screenshots 'G06-selected-strip-final-initial-recheck-360x800.png'
results = Join-Path $screenshots 'G06-selected-strip-final-results-360x800.png'
empty = Join-Path $screenshots 'G06-selected-strip-final-empty-360x800.png'
}
$images = @{}
try {
foreach ($state in $files.Keys) {
$file = $files[$state]
if (-not (Test-Path -LiteralPath $file)) {
throw "Missing G06 $state screenshot: $file"
}
$image = [System.Drawing.Bitmap]::new($file)
$images[$state] = $image
if ($image.Width -ne 360 -or $image.Height -ne 800) {
throw "G06 $state screenshot must be 360x800, got $($image.Width)x$($image.Height)"
}
foreach ($point in @(@(0, 0), @(359, 0), @(0, 799), @(359, 799))) {
if ($image.GetPixel($point[0], $point[1]).A -ne 255) {
throw "G06 $state screenshot must have an opaque viewport"
}
}
}
foreach ($state in @('results', 'empty')) {
$stableDifferenceCount = 0
$fullDifferenceCount = 0
for ($y = 0; $y -lt 800; $y++) {
for ($x = 0; $x -lt 360; $x++) {
if ($images.initial.GetPixel($x, $y).ToArgb() -ne $images[$state].GetPixel($x, $y).ToArgb()) {
$fullDifferenceCount++
if ($y -lt 150) { $stableDifferenceCount++ }
}
}
}
if ($stableDifferenceCount -ne 0) {
throw "G06 $state screenshot changed $stableDifferenceCount pixels in the stable header/title region"
}
if ($fullDifferenceCount -lt 100) {
throw "G06 $state screenshot does not visibly differ from the initial state"
}
}
}
finally {
foreach ($image in $images.Values) { $image.Dispose() }
}
Write-Output 'G06-SCREENSHOT-CONSISTENCY PASS'
+1 -1
View File
@@ -2,7 +2,7 @@ const sleep = (milliseconds) => new Promise((resolve) => setTimeout(resolve, mil
const connect = async () => {
const pages = await (await fetch('http://127.0.0.1:9222/json/list')).json()
const page = pages.find((item) => item.type === 'page' && item.url.includes('localhost:5173'))
const page = pages.find((item) => item.type === 'page' && item.url.startsWith('http://localhost:5173'))
if (!page) throw new Error('Chrome debugging has no localhost:5173 page')
const socket = new WebSocket(page.webSocketDebuggerUrl)
@@ -0,0 +1,78 @@
$ErrorActionPreference = 'Stop'
function Assert-Contains {
param([string]$Content, [string]$Expected, [string]$Message)
if ($Content -notmatch [regex]::Escape($Expected)) { throw $Message }
}
function Assert-NoCssSurface {
param([string]$Content, [string]$ClassName)
foreach ($property in @('border', 'background', 'border-radius')) {
if ($Content -match "(?s)\\.$ClassName\\s*\\{[^}]*\\b$property\\s*:") {
throw "Application flow must not construct .$ClassName with CSS $property"
}
}
}
$root = Split-Path -Parent $PSScriptRoot
$g08 = Get-Content -LiteralPath (Join-Path $root 'pages/genealogy/g08-join-application.vue') -Raw -Encoding utf8
$g09 = Get-Content -LiteralPath (Join-Path $root 'pages/genealogy/g09-my-applications.vue') -Raw -Encoding utf8
$g10 = Get-Content -LiteralPath (Join-Path $root 'pages/genealogy/g10-application-review.vue') -Raw -Encoding utf8
$catalog = Get-Content -LiteralPath (Join-Path $root 'data/page-catalog.js') -Raw -Encoding utf8
foreach ($page in @($g08, $g09, $g10)) {
if ($page -match '<ModulePage') { throw 'G08-G10 must not retain the generic ModulePage shell' }
Assert-Contains $page 'page-paper.jpg' 'G08-G10 must use the accepted genealogy paper background'
if ($page -match "@/utils/api\.js|\bappApi\b") { throw 'G08-G10 design phase must not connect the API layer' }
}
foreach ($required in @(
"const joinState = ref('form')",
'join-state--form',
'join-state--success',
'join-state--error',
'g03-create-flow-panel.png',
'a01-primary-button.png',
'const genealogyPreview',
'query.genealogyId',
'/pages/genealogy/g09-my-applications'
)) { Assert-Contains $g08 $required "Missing G08 join contract: $required" }
foreach ($required in @(
"const applicationState = ref('loading')",
'application-state--list',
'application-state--empty',
'application-state--error',
'application-status-card.png',
"'PENDING'",
"'APPROVED'",
"'REJECTED'",
'const applicationSamples'
)) { Assert-Contains $g09 $required "Missing G09 application contract: $required" }
foreach ($required in @(
"const reviewState = ref('loading')",
'review-state--list',
'review-state--empty',
'review-state--error',
'application-record-card.png',
'application-status-card.png',
'a01-primary-button.png',
'a01-secondary-button.png',
'const reviewSamples',
'uni.showModal'
)) { Assert-Contains $g10 $required "Missing G10 review contract: $required" }
foreach ($className in @('join-panel', 'join-action', 'application-card', 'review-action')) {
foreach ($page in @($g08, $g09, $g10)) { Assert-NoCssSurface $page $className }
}
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' }
Write-Output 'G08-G10-APPLICATION-FLOW-CONTRACT PASS'
@@ -0,0 +1,79 @@
const sleep = (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds))
const origin = process.argv[2] || 'http://localhost:5173'
const connect = async () => {
const pages = await (await fetch('http://127.0.0.1:9222/json/list')).json()
const page = pages.find((item) => item.type === 'page' && item.url.startsWith(`${origin}/`))
if (!page) throw new Error(`Chrome debugging has no ${origin} application page`)
const socket = new WebSocket(page.webSocketDebuggerUrl)
await new Promise((resolve, reject) => {
socket.addEventListener('open', resolve, { once: true })
socket.addEventListener('error', reject, { once: true })
})
let id = 0
const pending = new Map()
socket.addEventListener('message', (event) => {
const message = JSON.parse(event.data)
const request = pending.get(message.id)
if (!request) return
pending.delete(message.id)
message.error ? request.reject(new Error(message.error.message)) : request.resolve(message.result)
})
const send = (method, params = {}) => new Promise((resolve, reject) => {
id += 1
pending.set(id, { resolve, reject })
socket.send(JSON.stringify({ id, method, params }))
})
return { socket, send }
}
const valueOf = async (send, expression) => (await send('Runtime.evaluate', { expression, returnByValue: true })).result?.value
const waitFor = async (send, expression, message) => {
for (let attempt = 0; attempt < 40; attempt += 1) {
if (await valueOf(send, expression)) return
await sleep(100)
}
throw new Error(message)
}
let auditId = 0
const open = async (send, route, query, selector) => {
auditId += 1
const url = `${origin}/?g0810Audit=${auditId}#${route}${query}`
await send('Page.navigate', { url })
await waitFor(send, `location.href === ${JSON.stringify(url)}`, `${route} navigation failed`)
await waitFor(send, `Boolean(document.querySelector(${JSON.stringify(selector)}))`, `${route}${query} did not render`)
}
const run = async () => {
const { socket, send } = await connect()
try {
await send('Page.enable')
await send('Runtime.enable')
await open(send, '/pages/genealogy/g08-join-application', '?genealogyId=1002', '.join-state--form')
await waitFor(send, "document.querySelectorAll('.join-field input').length >= 2", 'G08 real input controls did not render')
const prepared = await valueOf(send, `(() => {
const inputs = Array.from(document.querySelectorAll('.join-field input'))
if (inputs.length < 2) return false
inputs[0].value = '汤明远'
inputs[0].dispatchEvent(new Event('input', { bubbles: true }))
inputs[1].value = '汤正华堂侄'
inputs[1].dispatchEvent(new Event('input', { bubbles: true }))
document.querySelector('.join-action')?.click()
return true
})()`)
if (!prepared) throw new Error('G08 form controls could not be prepared')
await waitFor(send, "Boolean(document.querySelector('.join-state--success'))", 'G08 real form submission did not reach the same-page success state')
await open(send, '/pages/genealogy/g09-my-applications', '', '.application-state--list')
await open(send, '/pages/genealogy/g09-my-applications', '?state=empty', '.application-state--empty')
await open(send, '/pages/genealogy/g10-application-review', '?genealogyId=1001', '.review-state--list')
await open(send, '/pages/genealogy/g10-application-review', '?state=empty&genealogyId=1001', '.review-state--empty')
process.stdout.write('G08-G10-APPLICATION-FLOW-RUNTIME-SMOKE PASS\n')
} finally {
socket.close()
}
}
run().catch((error) => {
process.stderr.write(`${error.stack || error.message}\n`)
process.exit(1)
})
+63
View File
@@ -0,0 +1,63 @@
$ErrorActionPreference = 'Stop'
function Assert-Contains {
param([string]$Content, [string]$Expected, [string]$Message)
if ($Content -notmatch [regex]::Escape($Expected)) { throw $Message }
}
function Assert-NoCssSurface {
param([string]$Content, [string]$ClassName)
foreach ($property in @('border', 'background', 'border-radius')) {
if ($Content -match "(?s)\\.$ClassName\\s*\\{[^}]*\\b$property\\s*:") {
throw "G11-G12 must not construct .$ClassName with CSS $property"
}
}
}
$root = Split-Path -Parent $PSScriptRoot
$g11 = Get-Content -LiteralPath (Join-Path $root 'pages/genealogy/g11-genealogy-settings.vue') -Raw -Encoding utf8
$g12 = Get-Content -LiteralPath (Join-Path $root 'pages/genealogy/g12-generation-poems.vue') -Raw -Encoding utf8
$catalog = Get-Content -LiteralPath (Join-Path $root 'data/page-catalog.js') -Raw -Encoding utf8
foreach ($page in @($g11, $g12)) {
if ($page -match '<ModulePage') { throw 'G11-G12 must not retain the generic ModulePage shell' }
Assert-Contains $page 'page-paper.jpg' 'G11-G12 must use the accepted genealogy paper background'
Assert-Contains $page 'g03-create-flow-panel.png' 'G11-G12 forms must reuse the complete genealogy paper panel'
if ($page -match "@/utils/api\.js|\bappApi\b") { throw 'G11-G12 design phase must not connect the API layer' }
}
foreach ($required in @(
"const settingsState = ref('loading')",
'settings-state--form',
'settings-state--success',
'settings-state--error',
'g06-search-input-wide.png',
'a01-primary-button.png',
'const genealogyDraft',
'query.genealogyId',
"'MEMBER_ONLY'",
"'PUBLIC_APPLY'"
)) { Assert-Contains $g11 $required "Missing G11 settings contract: $required" }
foreach ($required in @(
"const poemState = ref('loading')",
'poem-state--list',
'poem-state--empty',
'poem-state--edit',
'poem-state--error',
'g06-search-input-wide.png',
'a01-primary-button.png',
'a01-secondary-button.png',
'const poemRows',
'stopMissingOldGeneration',
'query.genealogyId'
)) { Assert-Contains $g12 $required "Missing G12 generation-poem contract: $required" }
foreach ($className in @('settings-panel', 'settings-action', 'poem-row', 'poem-editor', 'poem-action')) {
foreach ($page in @($g11, $g12)) { Assert-NoCssSurface $page $className }
}
foreach ($entry in @('g11:', 'g12:')) {
if ($catalog -match [regex]::Escape($entry)) { throw "Page catalog must remove migrated generic entry: $entry" }
}
Write-Output 'G11-G12-SETTINGS-POEMS-CONTRACT PASS'
@@ -0,0 +1,65 @@
const sleep = (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds))
const origin = process.argv[2] || 'http://localhost:5173'
const connect = async () => {
const pages = await (await fetch('http://127.0.0.1:9222/json/list')).json()
const page = pages.find((item) => item.type === 'page' && item.url.startsWith(`${origin}/`))
if (!page) throw new Error(`Chrome debugging has no ${origin} application page`)
const socket = new WebSocket(page.webSocketDebuggerUrl)
await new Promise((resolve, reject) => {
socket.addEventListener('open', resolve, { once: true })
socket.addEventListener('error', reject, { once: true })
})
let id = 0
const pending = new Map()
socket.addEventListener('message', (event) => {
const message = JSON.parse(event.data)
const request = pending.get(message.id)
if (!request) return
pending.delete(message.id)
message.error ? request.reject(new Error(message.error.message)) : request.resolve(message.result)
})
const send = (method, params = {}) => new Promise((resolve, reject) => {
id += 1
pending.set(id, { resolve, reject })
socket.send(JSON.stringify({ id, method, params }))
})
return { socket, send }
}
const valueOf = async (send, expression) => (await send('Runtime.evaluate', { expression, returnByValue: true })).result?.value
const waitFor = async (send, expression, message) => {
for (let attempt = 0; attempt < 40; attempt += 1) {
if (await valueOf(send, expression)) return
await sleep(100)
}
throw new Error(message)
}
const open = async (send, route, query, selector) => {
const url = `${origin}/#${route}${query}`
await send('Page.navigate', { url })
await waitFor(send, `location.href === ${JSON.stringify(url)}`, `${route} navigation failed`)
await send('Page.reload')
await waitFor(send, `Boolean(document.querySelector(${JSON.stringify(selector)}))`, `${route}${query} did not render`)
}
const run = async () => {
const { socket, send } = await connect()
try {
await send('Page.enable')
await send('Runtime.enable')
await open(send, '/pages/genealogy/g11-genealogy-settings', '?genealogyId=1001', '.settings-state--form')
await open(send, '/pages/genealogy/g11-genealogy-settings', '?state=success&genealogyId=1001', '.settings-state--success')
await open(send, '/pages/genealogy/g12-generation-poems', '?genealogyId=1001', '.poem-state--list')
await open(send, '/pages/genealogy/g12-generation-poems', '?state=empty&genealogyId=1001', '.poem-state--empty')
await open(send, '/pages/genealogy/g12-generation-poems', '?state=edit&genealogyId=1001', '.poem-state--edit')
process.stdout.write('G11-G12-SETTINGS-POEMS-RUNTIME-SMOKE PASS\n')
} finally {
socket.close()
}
}
run().catch((error) => {
process.stderr.write(`${error.stack || error.message}\n`)
process.exit(1)
})
+46
View File
@@ -0,0 +1,46 @@
$ErrorActionPreference = 'Stop'
$decode = {
param([string]$value)
[Text.Encoding]::UTF8.GetString([Convert]::FromBase64String($value))
}
$handoff = Get-Content -Raw -Encoding UTF8 (& $decode 'ZG9jcy/kuqTmjqXorrDlvZUubWQ=')
$manual = Get-Content -Raw -Encoding UTF8 (& $decode 'ZG9jcy9kZXNpZ24v6KeG6KeJ6K6+6K6h5Lqk5o6l5omL5YaMLm1k')
$planning = Get-Content -Raw -Encoding UTF8 (& $decode 'ZG9jcy/op4TliJIubWQ=')
foreach ($required in @(
(& $decode 'NTQg5p2h5pyA57uI6Lev55Sx'),
(& $decode 'NyDkuKrlt7LpqozmlLY='),
(& $decode 'NDcg5Liq5b6F5a6h5qC45YCZ6YCJ'),
'product-design:index',
'product-design:audit',
'imagegen',
'superpowers:test-driven-development',
'superpowers:systematic-debugging',
'superpowers:verification-before-completion',
(& $decode 'ZG9jcy9kZXNpZ24v6KeG6KeJ6K+B5o2u57Si5byVLm1k')
)) {
if ($handoff -notmatch [regex]::Escape($required)) {
throw "handoff missing: $required"
}
}
foreach ($stale in @(
(& $decode '5YWt5Liq5bey6aqM5pS2'),
(& $decode '57uT5p6c5oCB5ZKM5peg57uT5p6c5oCB55qE5ZCM54mI6YeN5paw5oiq5Zu+5bCa5pyq5a6M5oiQ'),
(& $decode '57uT5p6c5oCBL+aXoOe7k+aenOaAgeeahOWQjOeJiOaIquWbvuWSjOeUqOaIt+Wuoee+juehruiupOWwmuacquWujOaIkA=='),
(& $decode '5LiL5LiA6aG55oyJIEbjgIFS44CBTuOAgU0g5o6o6L+b')
)) {
if (($handoff + $manual) -match [regex]::Escape($stale)) {
throw "stale handoff text remains: $stale"
}
}
$approved = ([regex]::Matches($planning, '- \[x\]')).Count
$candidate = ([regex]::Matches($planning, '- \[~\]')).Count
if ($approved -ne 7 -or $candidate -ne 47) {
throw "planning counts changed: approved=$approved candidate=$candidate"
}
Write-Output 'HANDOFF-DOCUMENTATION-CONTRACT PASS'
+41
View File
@@ -0,0 +1,41 @@
const sleep = (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds))
const origin = process.argv[2] || 'http://localhost:5173'
const connect = async () => {
const pages = await (await fetch('http://127.0.0.1:9222/json/list')).json()
const page = pages.find((item) => item.type === 'page' && item.url.startsWith(`${origin}/`))
if (!page) throw new Error(`Chrome debugging has no ${origin} page`)
const socket = new WebSocket(page.webSocketDebuggerUrl)
await new Promise((resolve, reject) => { socket.addEventListener('open', resolve, { once: true }); socket.addEventListener('error', reject, { once: true }) })
let id = 0
const pending = new Map()
socket.addEventListener('message', (event) => {
const message = JSON.parse(event.data); const request = pending.get(message.id); if (!request) return
pending.delete(message.id); message.error ? request.reject(new Error(message.error.message)) : request.resolve(message.result)
})
const send = (method, params = {}) => new Promise((resolve, reject) => { id += 1; pending.set(id, { resolve, reject }); socket.send(JSON.stringify({ id, method, params })) })
return { socket, send }
}
const valueOf = async (send, expression) => (await send('Runtime.evaluate', { expression, returnByValue: true })).result?.value
const waitFor = async (send, expression, message) => { for (let i = 0; i < 50; i += 1) { if (await valueOf(send, expression)) return; await sleep(100) } throw new Error(message) }
let auditId = 0
const open = async (send, route, query, selector) => {
auditId += 1
const url = `${origin}/?moduleAudit=${auditId}#${route}${query}`; await send('Page.navigate', { url }); await waitFor(send, `location.href === ${JSON.stringify(url)}`, `Navigation failed: ${route}`); await waitFor(send, `Boolean(document.querySelector(${JSON.stringify(selector)}))`, `State missing: ${route}${query}`)
}
const run = async () => {
const { socket, send } = await connect()
try {
await send('Page.enable'); await send('Runtime.enable')
await open(send, '/pages/family/f04-article-list', '', '.module-page--list.module-state--ready')
await open(send, '/pages/family/f06-article-editor', '', '.module-page--form.module-state--ready')
await open(send, '/pages/family/f03-feed-detail', '', '.module-page--detail.module-state--ready')
await open(send, '/pages/records/r08-growth-journal', '', '.module-page--timeline.module-state--ready')
await open(send, '/pages/profile/m03-security-settings', '', '.module-page--settings.module-state--ready')
await open(send, '/pages/family/f10-video-list', '', '.module-page--status.module-state--ready')
await open(send, '/pages/family/f04-article-list', '?state=empty', '.module-state--empty')
await open(send, '/pages/family/f06-article-editor', '?state=success', '.module-state--success')
await open(send, '/pages/profile/m03-security-settings', '?state=error', '.module-state--error')
process.stdout.write('MODULE-PAGE-RUNTIME-SMOKE PASS\n')
} finally { socket.close() }
}
run().catch((error) => { process.stderr.write(`${error.stack || error.message}\n`); process.exit(1) })
+20 -17
View File
@@ -2,30 +2,33 @@ $ErrorActionPreference = 'Stop'
$root = Split-Path -Parent $PSScriptRoot
$componentPath = Join-Path $root 'components/ModulePage.vue'
if (-not (Test-Path -LiteralPath $componentPath)) {
throw 'Missing shared module page component'
}
if (-not (Test-Path -LiteralPath $componentPath)) { throw 'Missing shared module page component' }
$component = Get-Content -LiteralPath $componentPath -Raw -Encoding utf8
foreach ($anchor in @(
"const moduleState = ref('ready')",
'module-state--ready',
'module-state--empty',
'module-state--success',
'module-state--error',
'g03-create-flow-panel.png',
'g06-search-input-wide.png',
'application-status-card.png',
'a01-primary-button.png',
'background-image:',
'background-size: 100% 100%',
'.primary-action--compact'
'a01-secondary-button.png',
'query.state'
)) {
if ($component -notmatch [regex]::Escape($anchor)) {
throw "ModulePage is missing primary-action visual anchor: $anchor"
if ($component -notmatch [regex]::Escape($anchor)) { throw "ModulePage is missing high-spec anchor: $anchor" }
}
foreach ($className in @('module-panel', 'form-row', 'list-card', 'detail-card', 'timeline-row', 'settings-row', 'status-card', 'primary-action')) {
foreach ($property in @('border', 'background', 'border-radius', 'box-shadow', 'background-image')) {
if ($component -match "(?s)\.$className\s*\{[^}]*\b$property\s*:") {
throw "ModulePage must not construct .$className with CSS $property"
}
}
}
if ($component -match '(?s)\.primary-action\s*\{[^}]*\bborder\s*:') {
throw 'ModulePage primary actions must not redraw the final button border with CSS'
}
if ($component -match '(?s)\.primary-action\s*\{[^}]*\bbackground\s*:\s*\$brand-red') {
throw 'ModulePage primary actions must not redraw the final button surface with a plain CSS color'
}
if ($component -match '::before|::after') { throw 'ModulePage must not draw visible decorations with CSS pseudo-elements' }
Write-Output 'MODULE-PAGE-VISUAL-CONTRACT PASS'
@@ -0,0 +1,44 @@
$ErrorActionPreference = 'Stop'
$decode = {
param([string]$value)
[Text.Encoding]::UTF8.GetString([Convert]::FromBase64String($value))
}
$root = (Resolve-Path '.').Path
$requiredPaths = @(
(& $decode 'ZG9jcy9kZXNpZ24='),
(& $decode 'ZG9jcy9kZXNpZ24v6KeG6KeJ6K6+6K6h5Lqk5o6l5omL5YaMLm1k'),
(& $decode 'ZG9jcy9kZXNpZ24v6KeG6KeJ6K+B5o2u57Si5byVLm1k'),
(& $decode 'ZG9jcy9kZXNpZ24vRDFf5a6J5Y2T6KeG6KeJ6KeE6IyD5LiO6aG16Z2i5aOzLm1k'),
(& $decode 'ZG9jcy9kZXNpZ24vUDAwX+mhtemdoue7k+aehOS4jui1hOS6p+a4heWNlS5tZA==')
)
foreach ($path in $requiredPaths) {
if (-not (Test-Path $path)) { throw "required handoff path missing: $path" }
}
if (-not (Test-Path '.gitignore')) { throw '.gitignore is required' }
$ignore = Get-Content -Raw -Encoding UTF8 '.gitignore'
foreach ($pattern in @('/tmp/', '/unpackage/', '/docs/superpowers/specs/design/', '/tmp-g01-icon-audit.png')) {
if (-not $ignore.Contains($pattern)) { throw "missing ignore pattern: $pattern" }
}
if (Test-Path 'docs/superpowers/specs/design') {
throw 'misplaced design archive remains inside repository'
}
$screens = Get-ChildItem 'docs/design/screens' -File -Recurse
$screenBytes = ($screens | Measure-Object Length -Sum).Sum
if ($screens.Count -gt 35) { throw "too many handoff screenshots: $($screens.Count)" }
if ($screenBytes -gt 20MB) { throw "handoff screenshots exceed 20MB: $([math]::Round($screenBytes / 1MB, 2))MB" }
$indexPath = (& $decode 'ZG9jcy9kZXNpZ24v6KeG6KeJ6K+B5o2u57Si5byVLm1k')
$index = Get-Content -Raw -Encoding UTF8 $indexPath
$links = [regex]::Matches($index, '\[[^\]]+\]\(([^)]+)\)')
foreach ($link in $links) {
$target = Join-Path (Split-Path $indexPath) $link.Groups[1].Value
if (-not (Test-Path $target)) { throw "broken evidence link: $($link.Groups[1].Value)" }
}
Write-Output "REPOSITORY-HANDOFF-SIZE-CONTRACT PASS files=$($screens.Count) mb=$([math]::Round($screenBytes / 1MB, 2)) links=$($links.Count)"
+4
View File
@@ -0,0 +1,4 @@
const sleep=(ms)=>new Promise(r=>setTimeout(r,ms));const origin=process.argv[2]||'http://localhost:5173';
const connect=async()=>{const pages=await(await fetch('http://127.0.0.1:9222/json/list')).json();const page=pages.find(x=>x.type==='page'&&x.url.startsWith(`${origin}/`));if(!page)throw new Error('No application page');const socket=new WebSocket(page.webSocketDebuggerUrl);await new Promise((r,j)=>{socket.addEventListener('open',r,{once:true});socket.addEventListener('error',j,{once:true})});let id=0;const pending=new Map();socket.addEventListener('message',e=>{const m=JSON.parse(e.data),p=pending.get(m.id);if(!p)return;pending.delete(m.id);m.error?p.reject(new Error(m.error.message)):p.resolve(m.result)});const send=(method,params={})=>new Promise((resolve,reject)=>{id+=1;pending.set(id,{resolve,reject});socket.send(JSON.stringify({id,method,params}))});return{socket,send}}
const valueOf=async(s,e)=>(await s('Runtime.evaluate',{expression:e,returnByValue:true})).result?.value;const waitFor=async(s,e,m)=>{for(let i=0;i<50;i+=1){if(await valueOf(s,e))return;await sleep(100)}throw new Error(m)};let auditId=0;const open=async(s,r,q,sel)=>{auditId+=1;const u=`${origin}/?audit=${auditId}#${r}${q}`;await s('Page.navigate',{url:u});await waitFor(s,`location.href === ${JSON.stringify(u)}`,'navigation failed');await waitFor(s,`Boolean(document.querySelector(${JSON.stringify(sel)}))`,`missing ${sel}`)};
const run=async()=>{const{socket,send}=await connect();try{await send('Page.enable');await send('Runtime.enable');await open(send,'/pages/family/f01-family-feed','?genealogyId=1001','.feed-state--list');await open(send,'/pages/family/f02-publish-feed','?genealogyId=1001','.publish-state--form');await open(send,'/pages/notification/n01-message-center','','.notice-state--list');await open(send,'/pages/profile/m01-profile-home','','.profile-state--ready');await open(send,'/pages/family/f01-family-feed','?state=empty&genealogyId=1001','.feed-state--empty');await open(send,'/pages/family/f02-publish-feed','?state=success&genealogyId=1001','.publish-state--success');await open(send,'/pages/notification/n01-message-center','?state=empty','.notice-state--empty');await open(send,'/pages/profile/m01-profile-home','?state=error','.profile-state--error');process.stdout.write('ROOT-PAGES-RUNTIME-SMOKE PASS\n')}finally{socket.close()}};run().catch(e=>{process.stderr.write(`${e.stack||e.message}\n`);process.exit(1)})
+11
View File
@@ -0,0 +1,11 @@
$ErrorActionPreference = 'Stop'
function Assert-Contains { param([string]$Content,[string]$Expected,[string]$Message); if($Content -notmatch [regex]::Escape($Expected)){ throw $Message } }
$root=Split-Path -Parent $PSScriptRoot
$files=@('pages/family/f01-family-feed.vue','pages/family/f02-publish-feed.vue','pages/notification/n01-message-center.vue','pages/profile/m01-profile-home.vue')
$pages=@{}
foreach($file in $files){$content=Get-Content -LiteralPath(Join-Path $root $file)-Raw -Encoding utf8;if($content-match"@/utils/api\.js|\bappApi\b"){ throw "$file must not connect the API layer" };Assert-Contains $content 'page-paper.jpg' "$file must use page paper";$pages[$file]=$content}
foreach($required in @('feed-state--list','feed-state--empty','feed-state--error','application-status-card.png','/pages/family/f02-publish-feed')){Assert-Contains $pages['pages/family/f01-family-feed.vue'] $required "Missing F01 contract: $required"}
foreach($required in @('publish-state--form','publish-state--success','publish-state--error','g03-create-flow-panel.png','g06-search-input-wide.png','a01-primary-button.png')){Assert-Contains $pages['pages/family/f02-publish-feed.vue'] $required "Missing F02 contract: $required"}
foreach($required in @('notice-state--list','notice-state--empty','notice-state--error','application-status-card.png','a01-secondary-button.png','/pages/genealogy/g10-application-review')){Assert-Contains $pages['pages/notification/n01-message-center.vue'] $required "Missing N01 contract: $required"}
foreach($required in @('profile-state--ready','profile-state--error','g03-create-flow-panel.png','g06-search-input-wide.png','application-status-card.png','/pages/profile/m02-edit-profile')){Assert-Contains $pages['pages/profile/m01-profile-home.vue'] $required "Missing M01 contract: $required"}
Write-Output 'ROOT-PAGES-VISUAL-CONTRACT PASS'
+31 -17
View File
@@ -7,30 +7,44 @@ function Assert-Contains {
function Assert-NoCssSurface {
param([string]$Content, [string]$ClassName)
foreach ($property in @('border', 'background', 'border-radius')) {
if ($Content -match "(?s)\\.$ClassName\\s*\\{[^}]*\\b$property\\s*:") { throw "T01 must not construct .$ClassName with CSS $property" }
foreach ($property in @('border', 'background', 'border-radius', 'box-shadow')) {
if ($Content -match "(?s)\.$ClassName\s*\{[^}]*\b$property\s*:") {
throw "T01 must not construct .$ClassName with CSS $property"
}
}
}
$root = Split-Path -Parent $PSScriptRoot
$pages = Get-Content -LiteralPath (Join-Path $root 'pages.json') -Raw -Encoding utf8 | ConvertFrom-Json
$paths = @($pages.pages.path)
$t01 = Get-Content -LiteralPath (Join-Path $root 'pages/tree/t01-tree-overview.vue') -Raw -Encoding utf8
$t02 = Join-Path $root 'pages/tree/t02-tree-states.vue'
$pagePath = Join-Path $root 'pages/tree/t01-tree-overview.vue'
$t02Path = Join-Path $root 'pages/tree/t02-tree-states.vue'
$page = Get-Content -LiteralPath $pagePath -Raw -Encoding utf8
$pages = Get-Content -LiteralPath (Join-Path $root 'pages.json') -Raw -Encoding utf8
$catalog = Get-Content -LiteralPath (Join-Path $root 'data/page-catalog.js') -Raw -Encoding utf8
if (-not ($paths -contains 'pages/tree/t01-tree-overview')) { throw 'T01 route missing' }
if ($paths -contains 'pages/tree/t02-tree-states') { throw 'T02 route must be removed; tree states belong to T01' }
if (Test-Path -LiteralPath $t02) { throw 'T02 page file must be deleted; tree states belong to T01' }
if ($paths.Count -ne 54) { throw "Expected 54 routes after the approved T02-to-T01 state merge, found $($paths.Count)." }
if (Test-Path -LiteralPath $t02Path) { throw 'T02 duplicate route file must be removed' }
if ($pages -match 'pages/tree/t02-tree-states') { throw 'T02 duplicate route must be removed from pages.json' }
if ($catalog -match 't02:') { throw 'T02 duplicate catalog entry must be removed' }
if ($page -match "@/utils/api\.js|\bappApi\b") { throw 'T01 design phase must not connect the API layer' }
foreach ($required in @("const treeState = ref('loading')", "'tree'", "'landscape'", "'empty'", "'error'", 't01-tree-canvas.png', 't01-member-node.png', 't01-member-sheet.png', 'tree-state--landscape', 'tree-state--empty', 'tree-state--error', '/pages/tree/t03-member-profile?genealogyId=', '/pages/tree/t04-add-relative?genealogyId=')) {
Assert-Contains -Content $t01 -Expected $required -Message "Missing T01 tree-state contract: $required"
}
foreach ($required in @(
"const treeState = ref('loading')",
'tree-state--tree',
'tree-state--landscape',
'tree-state--empty',
'tree-state--error',
'tree-canvas--state',
'g03-create-flow-panel.png',
'g06-search-input-wide.png',
'application-status-card.png',
'/pages/tree/t03-member-profile',
'/pages/tree/t04-add-relative',
'/pages/tree/t06-edit-relationship',
'/pages/tree/t07-member-directory',
'query.genealogyId'
)) { Assert-Contains $page $required "Missing T01 contract: $required" }
foreach ($className in @('tree-canvas', 'member-node', 'member-sheet')) { Assert-NoCssSurface -Content $t01 -ClassName $className }
foreach ($asset in @('t01-tree-canvas.png', 't01-member-node.png', 't01-member-sheet.png')) {
if (-not (Test-Path -LiteralPath (Join-Path $root "static/assets/modules/tree/opaque/$asset"))) { throw "T01 requires final opaque asset: $asset" }
foreach ($className in @('tree-canvas', 'member-node', 'member-sheet', 'tree-state-card')) {
Assert-NoCssSurface $page $className
}
Write-Output 'T01-TREE-STATE-CONTRACT PASS'
+40 -18
View File
@@ -1,45 +1,67 @@
const sleep = (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds))
const origin = process.argv[2] || 'http://localhost:5173'
const t01Path = '/pages/tree/t01-tree-overview'
const connect = async () => {
const pages = await (await fetch('http://127.0.0.1:9222/json/list')).json()
const page = pages.find((item) => item.type === 'page' && item.url.includes('localhost:5173'))
if (!page) throw new Error('Chrome debugging has no localhost:5173 page')
const page = pages.find((item) => item.type === 'page' && item.url.startsWith(`${origin}/`))
if (!page) throw new Error(`Chrome debugging has no ${origin} application page`)
const socket = new WebSocket(page.webSocketDebuggerUrl)
await new Promise((resolve, reject) => { socket.addEventListener('open', resolve, { once: true }); socket.addEventListener('error', reject, { once: true }) })
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 })) })
socket.addEventListener('message', (event) => {
const message = JSON.parse(event.data)
const request = pending.get(message.id)
if (!request) return
pending.delete(message.id)
message.error ? request.reject(new Error(message.error.message)) : request.resolve(message.result)
})
const send = (method, params = {}) => new Promise((resolve, reject) => {
id += 1
pending.set(id, { resolve, reject })
socket.send(JSON.stringify({ id, method, params }))
})
return { socket, send }
}
const valueOf = async (send, expression) => (await send('Runtime.evaluate', { expression, returnByValue: true })).result?.value
const waitFor = async (send, expression, message) => {
for (let attempt = 0; attempt < 40; attempt += 1) { if (await valueOf(send, expression)) return; await sleep(100) }
for (let attempt = 0; attempt < 50; attempt += 1) {
if (await valueOf(send, expression)) return
await sleep(100)
}
throw new Error(message)
}
const open = async (send, query, selector) => {
const url = `${origin}/#${t01Path}${query}`
const url = `${origin}/#/pages/tree/t01-tree-overview${query}`
await send('Page.navigate', { url })
await waitFor(send, `location.href === ${JSON.stringify(url)}`, 'T01 navigation failed')
await waitFor(send, `location.href === ${JSON.stringify(url)}`, `T01 navigation failed: ${query}`)
await send('Page.reload')
await waitFor(send, `Boolean(document.querySelector(${JSON.stringify(selector)}))`, `T01 ${query} did not render`)
await waitFor(send, `Boolean(document.querySelector(${JSON.stringify(selector)}))`, `T01 state did not render: ${selector}`)
}
const run = async () => {
const { socket, send } = await connect()
try {
await send('Page.enable'); await send('Runtime.enable')
await open(send, '?state=landscape', '.tree-state--landscape')
await open(send, '?state=empty', '.tree-state--empty')
await open(send, '?state=error', '.tree-state--error')
await open(send, '?genealogyId=1001', '.tree-canvas .member-node')
await send('Page.enable')
await send('Runtime.enable')
await open(send, '?state=landscape&genealogyId=1001', '.tree-state--landscape')
await open(send, '?state=empty&genealogyId=1001', '.tree-state--empty')
await open(send, '?state=error&genealogyId=1001', '.tree-state--error')
await open(send, '?genealogyId=1001', '.tree-state--tree .member-node')
await valueOf(send, "document.querySelector('.member-node')?.click()")
await waitFor(send, "location.href.includes('/pages/tree/t03-member-profile?genealogyId=1001&personId=')", 'Selecting a T01 member did not open T03')
await valueOf(send, "document.querySelector('.sheet-member-action')?.click()")
await waitFor(send, "location.href.includes('/pages/tree/t03-member-profile?genealogyId=1001&personId=')", 'T01 selected member did not open T03')
process.stdout.write('T01-TREE-STATE-RUNTIME-SMOKE PASS\n')
} finally { socket.close() }
} finally {
socket.close()
}
}
run().catch((error) => { process.stderr.write(`${error.stack || error.message}\n`); process.exit(1) })
run().catch((error) => {
process.stderr.write(`${error.stack || error.message}\n`)
process.exit(1)
})
+60
View File
@@ -0,0 +1,60 @@
$ErrorActionPreference = 'Stop'
function Assert-Contains {
param([string]$Content, [string]$Expected, [string]$Message)
if ($Content -notmatch [regex]::Escape($Expected)) { throw $Message }
}
$root = Split-Path -Parent $PSScriptRoot
$paths = @(
'pages/tree/t03-member-profile.vue',
'pages/tree/t04-add-relative.vue',
'pages/tree/t05-edit-member.vue',
'pages/tree/t06-edit-relationship.vue',
'pages/tree/t07-member-directory.vue',
'pages/tree/t08-member-states.vue'
)
$pages = @{}
foreach ($path in $paths) {
$content = Get-Content -LiteralPath (Join-Path $root $path) -Raw -Encoding utf8
if ($content -match '<ModulePage') { throw "$path must not retain ModulePage" }
if ($content -match "@/utils/api\.js|\bappApi\b") { throw "$path must not connect the API layer" }
$pages[$path] = $content
}
foreach ($path in @('pages/tree/t03-member-profile.vue', 'pages/tree/t07-member-directory.vue', 'pages/tree/t08-member-states.vue')) {
Assert-Contains $pages[$path] 'page-paper.jpg' "$path must use the accepted paper background"
}
$t03 = $pages['pages/tree/t03-member-profile.vue']
foreach ($required in @('member-state--detail', 'member-state--error', 'g03-create-flow-panel.png', 'g06-search-input-wide.png', '/pages/tree/t05-edit-member', 'query.personId')) {
Assert-Contains $t03 $required "Missing T03 contract: $required"
}
$formComponentPath = Join-Path $root 'components/tree/TreeMemberForm.vue'
if (-not (Test-Path -LiteralPath $formComponentPath)) { throw 'T04-T06 require shared TreeMemberForm component' }
$form = Get-Content -LiteralPath $formComponentPath -Raw -Encoding utf8
if ($form -match "@/utils/api\.js|\bappApi\b") { throw 'TreeMemberForm must not connect the API layer' }
foreach ($required in @('form-state--form', 'form-state--success', 'form-state--conflict', 'form-state--error', 'g03-create-flow-panel.png', 'g06-search-input-wide.png', 'a01-primary-button.png', 'a01-secondary-button.png')) {
Assert-Contains $form $required "Missing shared form contract: $required"
}
foreach ($route in @('t04-add-relative.vue', 't05-edit-member.vue', 't06-edit-relationship.vue')) {
Assert-Contains $pages["pages/tree/$route"] 'TreeMemberForm' "$route must use TreeMemberForm"
}
$t07 = $pages['pages/tree/t07-member-directory.vue']
foreach ($required in @('directory-state--list', 'directory-state--empty', 'directory-state--error', 'application-status-card.png', 'g06-search-input-wide.png', '/pages/tree/t03-member-profile')) {
Assert-Contains $t07 $required "Missing T07 contract: $required"
}
$t08 = $pages['pages/tree/t08-member-states.vue']
foreach ($required in @('member-status--privacy', 'member-status--deceased', 'member-status--forbidden', 'application-status-card.png', 'query.state')) {
Assert-Contains $t08 $required "Missing T08 contract: $required"
}
$catalog = Get-Content -LiteralPath (Join-Path $root 'data/page-catalog.js') -Raw -Encoding utf8
foreach ($entry in @('t04:', 't05:', 't06:', 't07:', 't08:')) {
if ($catalog -match [regex]::Escape($entry)) { throw "Page catalog must remove migrated entry: $entry" }
}
Write-Output 'T03-T08-MEMBER-FLOW-CONTRACT PASS'
@@ -0,0 +1,63 @@
const sleep = (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds))
const origin = process.argv[2] || 'http://localhost:5173'
const connect = async () => {
const pages = await (await fetch('http://127.0.0.1:9222/json/list')).json()
const page = pages.find((item) => item.type === 'page' && item.url.startsWith(`${origin}/`))
if (!page) throw new Error(`Chrome debugging has no ${origin} application page`)
const socket = new WebSocket(page.webSocketDebuggerUrl)
await new Promise((resolve, reject) => {
socket.addEventListener('open', resolve, { once: true })
socket.addEventListener('error', reject, { once: true })
})
let id = 0
const pending = new Map()
socket.addEventListener('message', (event) => {
const message = JSON.parse(event.data)
const request = pending.get(message.id)
if (!request) return
pending.delete(message.id)
message.error ? request.reject(new Error(message.error.message)) : request.resolve(message.result)
})
const send = (method, params = {}) => new Promise((resolve, reject) => {
id += 1
pending.set(id, { resolve, reject })
socket.send(JSON.stringify({ id, method, params }))
})
return { socket, send }
}
const valueOf = async (send, expression) => (await send('Runtime.evaluate', { expression, returnByValue: true })).result?.value
const waitFor = async (send, expression, message) => {
for (let attempt = 0; attempt < 50; attempt += 1) {
if (await valueOf(send, expression)) return
await sleep(100)
}
throw new Error(message)
}
const open = async (send, route, query, selector) => {
const url = `${origin}/#${route}${query}`
await send('Page.navigate', { url })
await waitFor(send, `location.href === ${JSON.stringify(url)}`, `${route} navigation failed`)
await send('Page.reload')
await waitFor(send, `Boolean(document.querySelector(${JSON.stringify(selector)}))`, `${route}${query} did not render`)
}
const run = async () => {
const { socket, send } = await connect()
try {
await send('Page.enable')
await send('Runtime.enable')
await open(send, '/pages/tree/t03-member-profile', '?genealogyId=1001&personId=101', '.member-state--detail')
await open(send, '/pages/tree/t04-add-relative', '?genealogyId=1001&personId=101', '.form-state--form')
await open(send, '/pages/tree/t05-edit-member', '?state=success&genealogyId=1001&personId=101', '.form-state--success')
await open(send, '/pages/tree/t06-edit-relationship', '?state=conflict&genealogyId=1001', '.form-state--conflict')
await open(send, '/pages/tree/t07-member-directory', '?genealogyId=1001', '.directory-state--list')
await open(send, '/pages/tree/t07-member-directory', '?state=empty&genealogyId=1001', '.directory-state--empty')
await open(send, '/pages/tree/t08-member-states', '?state=privacy&genealogyId=1001', '.member-status--privacy')
await open(send, '/pages/tree/t08-member-states', '?state=deceased&genealogyId=1001', '.member-status--deceased')
await open(send, '/pages/tree/t08-member-states', '?state=forbidden&genealogyId=1001', '.member-status--forbidden')
process.stdout.write('T03-T08-MEMBER-FLOW-RUNTIME-SMOKE PASS\n')
} finally { socket.close() }
}
run().catch((error) => {
process.stderr.write(`${error.stack || error.message}\n`)
process.exit(1)
})