修改完成

This commit is contained in:
2026-07-21 07:53:03 +08:00
parent 01d246c47b
commit ff60119277
172 changed files with 7982 additions and 2999 deletions
+10
View File
@@ -51,3 +51,13 @@ nbdist/
robots.txt robots.txt
sitemap.xml sitemap.xml
# Regenerable local output and audit caches
**/node_modules/
/tmp/
/unpackage/
/.vite/
/design-pipeline/generated/
/docs/design/screens/runtime/
/docs/superpowers/specs/design/
/tmp-g01-icon-audit.png
+19 -10
View File
@@ -1,10 +1,12 @@
<template> <template>
<view <button
class="app-button" class="app-button"
:class="[ :class="[
`app-button--${type}`, `app-button--${type}`,
{ 'app-button--block': block, 'app-button--disabled': disabled }, { 'app-button--block': block, 'app-button--disabled': disabled },
]" ]"
:disabled="disabled"
:aria-label="label"
:hover-class="disabled ? 'none' : 'app-button--pressed'" :hover-class="disabled ? 'none' : 'app-button--pressed'"
@click="handleClick" @click="handleClick"
> >
@@ -12,7 +14,7 @@
<text class="app-button__label" <text class="app-button__label"
><slot>{{ label }}</slot></text ><slot>{{ label }}</slot></text
> >
</view> </button>
</template> </template>
<script setup> <script setup>
@@ -39,28 +41,35 @@ const handleClick = (event) => {
<style scoped lang="scss"> <style scoped lang="scss">
.app-button { .app-button {
position: relative; display: inline-grid;
display: inline-flex;
width: 420rpx; width: 420rpx;
max-width: 100%; max-width: 100%;
min-height: 88rpx; min-height: 88rpx;
align-items: center; place-items: center;
justify-content: center;
box-sizing: border-box; box-sizing: border-box;
margin: 0;
padding: 0;
border: 0;
background: transparent;
line-height: normal;
}
.app-button::after {
border: 0;
} }
.app-button--block { .app-button--block {
display: flex; display: grid;
width: 100%; width: 100%;
} }
.app-button__skin { .app-button__skin {
position: absolute;
inset: 0;
width: 100%; width: 100%;
height: 100%; height: 100%;
pointer-events: none; pointer-events: none;
} }
.app-button__skin,
.app-button__label {
grid-area: 1 / 1;
}
.app-button__label { .app-button__label {
position: relative;
z-index: 1; z-index: 1;
padding: 0 38rpx; padding: 0 38rpx;
color: #fffaf0; color: #fffaf0;
+35 -18
View File
@@ -4,13 +4,17 @@
class="app-dialog-layer" class="app-dialog-layer"
@click="closeOnMask && cancel()" @click="closeOnMask && cancel()"
> >
<view class="app-dialog" @click.stop> <view
<image ref="dialogRef"
class="app-dialog__skin" class="app-dialog"
src="/static/assets/modules/auth/transparent/a01-scroll-dialog-v3.png" role="dialog"
mode="aspectFit" aria-modal="true"
/> :aria-label="title"
<view class="app-dialog__content"> tabindex="-1"
@click.stop
@keydown.esc.stop="cancel"
>
<scroll-view class="app-dialog__content" scroll-y>
<text v-if="eyebrow" class="app-dialog__eyebrow">{{ eyebrow }}</text> <text v-if="eyebrow" class="app-dialog__eyebrow">{{ eyebrow }}</text>
<text class="app-dialog__title">{{ title }}</text> <text class="app-dialog__title">{{ title }}</text>
<text v-if="message" class="app-dialog__message">{{ message }}</text> <text v-if="message" class="app-dialog__message">{{ message }}</text>
@@ -27,15 +31,16 @@
/> />
<AppButton :label="confirmText" @click="$emit('confirm')" /> <AppButton :label="confirmText" @click="$emit('confirm')" />
</view> </view>
</view> </scroll-view>
</view> </view>
</view> </view>
</template> </template>
<script setup> <script setup>
import { nextTick, ref, watch } from "vue";
import AppButton from "@/components/AppButton.vue"; import AppButton from "@/components/AppButton.vue";
defineProps({ const props = defineProps({
visible: { type: Boolean, default: false }, visible: { type: Boolean, default: false },
eyebrow: { type: String, default: "" }, eyebrow: { type: String, default: "" },
title: { type: String, required: true }, title: { type: String, required: true },
@@ -46,6 +51,23 @@ defineProps({
closeOnMask: { type: Boolean, default: true }, closeOnMask: { type: Boolean, default: true },
}); });
const emit = defineEmits(["confirm", "cancel", "close"]); const emit = defineEmits(["confirm", "cancel", "close"]);
const dialogRef = ref(null);
let previousFocus = null;
watch(
() => props.visible,
async (visible) => {
if (typeof document === "undefined") return;
if (visible) {
previousFocus = document.activeElement;
await nextTick();
dialogRef.value?.focus?.();
return;
}
previousFocus?.focus?.();
previousFocus = null;
},
);
const cancel = () => { const cancel = () => {
emit("cancel"); emit("cancel");
emit("close"); emit("close");
@@ -65,28 +87,23 @@ const cancel = () => {
background: rgba(35, 18, 10, 0.62); background: rgba(35, 18, 10, 0.62);
} }
.app-dialog { .app-dialog {
position: relative;
width: 650rpx; width: 650rpx;
max-width: 100%; max-width: 100%;
min-height: 520rpx; min-height: 520rpx;
} background: url("/static/assets/modules/auth/transparent/a01-scroll-dialog-v3.png")
.app-dialog__skin { center / contain no-repeat;
position: absolute;
inset: 0;
width: 100%;
height: 100%;
pointer-events: none;
} }
.app-dialog__content { .app-dialog__content {
position: relative;
z-index: 1; z-index: 1;
display: flex; display: flex;
min-height: 520rpx; min-height: 520rpx;
max-height: calc(100vh - 80rpx);
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
box-sizing: border-box; box-sizing: border-box;
padding: 74rpx 66rpx 54rpx; padding: 74rpx 66rpx 54rpx;
text-align: center; text-align: center;
overflow-y: auto;
} }
.app-dialog__eyebrow { .app-dialog__eyebrow {
color: #9f170f; color: #9f170f;
+9 -4
View File
@@ -1,5 +1,11 @@
<template> <template>
<view class="app-loading" :class="`app-loading--${variant}`"> <view
class="app-loading"
:class="`app-loading--${variant}`"
role="status"
aria-live="polite"
aria-busy="true"
>
<view class="app-loading__emblem" aria-hidden="true"> <view class="app-loading__emblem" aria-hidden="true">
<image <image
class="app-loading__seal" class="app-loading__seal"
@@ -33,7 +39,6 @@ defineProps({
<style scoped lang="scss"> <style scoped lang="scss">
.app-loading { .app-loading {
position: relative;
z-index: 1; z-index: 1;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -144,11 +149,11 @@ defineProps({
} }
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {
.app-loading__seal { .app-loading__seal {
animation: app-loading-seal-essential-pulse 1.2s ease-in-out infinite; animation: none;
transform: none; transform: none;
} }
.app-loading__knot { .app-loading__knot {
animation: app-loading-knot-essential-pulse 1.2s ease-in-out 0.2s infinite; animation: none;
transform: none; transform: none;
} }
} }
+7 -2
View File
@@ -1,5 +1,11 @@
<template> <template>
<view v-if="visible" class="app-toast" aria-live="polite"> <view
v-show="visible"
class="app-toast"
role="status"
aria-live="polite"
aria-atomic="true"
>
<text class="app-toast__copy">{{ message }}</text> <text class="app-toast__copy">{{ message }}</text>
</view> </view>
</template> </template>
@@ -34,7 +40,6 @@ defineProps({
pointer-events: none; pointer-events: none;
} }
.app-toast__copy { .app-toast__copy {
position: relative;
z-index: 1; z-index: 1;
padding: 14rpx 30rpx; padding: 14rpx 30rpx;
color: #5c4330; color: #5c4330;
+38 -44
View File
@@ -71,10 +71,13 @@ defineEmits(["select"]);
<style scoped lang="scss"> <style scoped lang="scss">
.genealogy-card { .genealogy-card {
position: relative; --card-padding-x: 24rpx;
display: flex; --card-padding-y: 18rpx;
display: grid;
grid-template-columns: 72rpx minmax(0, 1fr);
min-height: 178rpx; min-height: 178rpx;
align-items: center; align-items: center;
column-gap: 22rpx;
padding: 18rpx 24rpx; padding: 18rpx 24rpx;
box-sizing: border-box; box-sizing: border-box;
background: transparent; background: transparent;
@@ -85,42 +88,35 @@ defineEmits(["select"]);
} }
.row-frame { .row-frame {
position: absolute; grid-area: 1 / 1 / 2 / -1;
top: 0; z-index: 1;
right: 0; width: calc(100% + var(--card-padding-x) + var(--card-padding-x));
bottom: 0; height: calc(100% + var(--card-padding-y) + var(--card-padding-y));
left: 0; margin: calc(-1 * var(--card-padding-y)) calc(-1 * var(--card-padding-x));
z-index: 0;
width: 100%;
height: 100%;
pointer-events: none; pointer-events: none;
} }
.surname-seal { .surname-seal {
position: relative; display: grid;
z-index: 1; grid-column: 1;
display: flex; grid-row: 1;
z-index: 2;
width: 72rpx; width: 72rpx;
height: 112rpx; height: 112rpx;
flex: 0 0 auto; place-items: center;
align-items: center;
justify-content: center;
margin-right: 22rpx;
color: #fff5df; color: #fff5df;
} }
.surname-seal-frame { .surname-seal-frame {
position: absolute;
top: 0;
right: 0;
bottom: 0;
left: 0;
z-index: 0; z-index: 0;
width: 100%; width: 100%;
height: 100%; height: 100%;
} }
.surname-seal-frame,
.surname-seal-copy {
grid-area: 1 / 1;
}
.surname-seal-copy { .surname-seal-copy {
position: relative;
z-index: 1; z-index: 1;
display: flex; display: flex;
height: 82rpx; height: 82rpx;
@@ -144,35 +140,30 @@ defineEmits(["select"]);
} }
.card-main { .card-main {
position: relative; grid-column: 2;
z-index: 1; grid-row: 1;
z-index: 2;
display: flex; display: flex;
min-width: 0; min-width: 0;
flex: 1; flex: 1;
flex-direction: column; flex-direction: column;
} }
.card-name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.card-meta {
white-space: nowrap;
}
.card-title-row { .card-title-row {
display: flex; display: flex;
min-width: 0; min-width: 0;
align-items: center; align-items: flex-start;
justify-content: space-between; justify-content: space-between;
} }
.card-name { .card-name {
min-width: 0;
flex: 1;
color: $ink; color: $ink;
font-family: "STKaiti", "KaiTi", serif; font-family: "STKaiti", "KaiTi", serif;
font-size: 37rpx; font-size: 37rpx;
font-weight: 700; font-weight: 700;
line-height: 1.25;
overflow-wrap: anywhere;
} }
.card-detail-row { .card-detail-row {
display: flex; display: flex;
@@ -186,8 +177,10 @@ defineEmits(["select"]);
display: flex; display: flex;
min-width: 0; min-width: 0;
width: 100%; width: 100%;
flex-wrap: wrap;
align-items: center; align-items: center;
margin-top: 2rpx; margin-top: 2rpx;
gap: 4rpx 18rpx;
} }
.card-meta-item { .card-meta-item {
display: flex; display: flex;
@@ -195,9 +188,6 @@ defineEmits(["select"]);
align-items: center; align-items: center;
margin-right: 0; margin-right: 0;
} }
.card-meta-item + .card-meta-item {
margin-left: 18rpx;
}
.card-meta-icon { .card-meta-icon {
width: 46rpx; width: 46rpx;
height: 46rpx; height: 46rpx;
@@ -207,9 +197,11 @@ defineEmits(["select"]);
filter: saturate(1.35) brightness(0.82) contrast(1.15); filter: saturate(1.35) brightness(0.82) contrast(1.15);
} }
.card-meta { .card-meta {
min-width: 0;
color: #62584c; color: #62584c;
font-size: 26rpx; font-size: 26rpx;
font-weight: 500; font-weight: 500;
overflow-wrap: anywhere;
} }
.card-updated { .card-updated {
display: flex; display: flex;
@@ -222,15 +214,14 @@ defineEmits(["select"]);
color: #62584c; color: #62584c;
font-size: 24rpx; font-size: 24rpx;
font-weight: 500; font-weight: 500;
white-space: nowrap; overflow-wrap: anywhere;
} }
.card-side { .card-side {
position: relative;
z-index: 1; z-index: 1;
display: flex; display: flex;
min-width: 0; min-width: 0;
flex: 0 0 auto; flex: 0 1 auto;
flex-direction: row; flex-direction: row;
align-items: center; align-items: center;
justify-content: flex-end; justify-content: flex-end;
@@ -238,10 +229,11 @@ defineEmits(["select"]);
} }
.card-role { .card-role {
min-width: 0;
color: #62584c; color: #62584c;
font-size: 26rpx; font-size: 26rpx;
font-weight: 500; font-weight: 500;
white-space: nowrap; overflow-wrap: anywhere;
} }
.card-chevron { .card-chevron {
width: 34rpx; width: 34rpx;
@@ -251,6 +243,9 @@ defineEmits(["select"]);
@media screen and (max-width: 340px) { @media screen and (max-width: 340px) {
.genealogy-card { .genealogy-card {
--card-padding-x: 18rpx;
grid-template-columns: 66rpx minmax(0, 1fr);
column-gap: 16rpx;
min-height: 148rpx; min-height: 148rpx;
padding-right: 18rpx; padding-right: 18rpx;
padding-left: 18rpx; padding-left: 18rpx;
@@ -258,7 +253,6 @@ defineEmits(["select"]);
.surname-seal { .surname-seal {
width: 66rpx; width: 66rpx;
height: 100rpx; height: 100rpx;
margin-right: 16rpx;
} }
.card-name { .card-name {
font-size: 32rpx; font-size: 32rpx;
+20 -73
View File
@@ -2,6 +2,7 @@
<template> <template>
<view <view
class="module-page" class="module-page"
:style="moduleAssetStyle"
:class="[ :class="[
`module-page--${moduleKey}`, `module-page--${moduleKey}`,
`module-page--${page.template}`, `module-page--${page.template}`,
@@ -20,17 +21,12 @@
<view v-else-if="moduleState === 'ready'" class="module-page__content"> <view v-else-if="moduleState === 'ready'" class="module-page__content">
<view class="module-lead"> <view class="module-lead">
<image
src="/static/assets/modules/genealogy/transparent/section-divider.png"
mode="scaleToFill"
/>
<text>{{ page.subtitle }}</text> <text>{{ page.subtitle }}</text>
</view> </view>
<view v-if="page.template === 'form'" class="module-panel module-form"> <view v-if="page.template === 'form'" class="module-panel module-form">
<text class="section-eyebrow">填写信息</text> <text class="section-eyebrow">填写信息</text>
<view v-for="field in page.fields" :key="field" class="form-row"> <view v-for="field in page.fields" :key="field" class="form-row">
<image :src="fieldAsset" mode="scaleToFill" />
<text>{{ field }}</text> <text>{{ field }}</text>
<input <input
:placeholder="`请输入${field}`" :placeholder="`请输入${field}`"
@@ -48,7 +44,6 @@
class="list-card" class="list-card"
@click="openPreview(item)" @click="openPreview(item)"
> >
<image :src="contentAsset" mode="scaleToFill" />
<view class="list-card__copy"> <view class="list-card__copy">
<text>{{ item[0] }}</text> <text>{{ item[0] }}</text>
<text>{{ item[1] }}</text> <text>{{ item[1] }}</text>
@@ -64,7 +59,6 @@
> >
<text class="section-eyebrow">档案详情</text> <text class="section-eyebrow">档案详情</text>
<view v-for="item in page.sections" :key="item[0]" class="detail-card"> <view v-for="item in page.sections" :key="item[0]" class="detail-card">
<image :src="contentAsset" mode="scaleToFill" />
<view <view
><text>{{ item[0] }}</text ><text>{{ item[0] }}</text
><text>{{ item[1] }}</text></view ><text>{{ item[1] }}</text></view
@@ -82,7 +76,6 @@
:key="item[0]" :key="item[0]"
class="timeline-row" class="timeline-row"
> >
<image :src="contentAsset" mode="scaleToFill" />
<view <view
><text> {{ index + 1 }} </text><text>{{ item[0] }}</text ><text> {{ index + 1 }} </text><text>{{ item[0] }}</text
><text>{{ item[1] }}</text></view ><text>{{ item[1] }}</text></view
@@ -102,7 +95,6 @@
class="settings-row" class="settings-row"
@click="openPreview(item)" @click="openPreview(item)"
> >
<image :src="fieldAsset" mode="scaleToFill" />
<view <view
><text>{{ item[0] }}</text ><text>{{ item[0] }}</text
><text>{{ normalizedSectionCopy(item[1]) }}</text></view ><text>{{ normalizedSectionCopy(item[1]) }}</text></view
@@ -113,11 +105,6 @@
</view> </view>
<view v-else class="module-panel status-card"> <view v-else class="module-panel status-card">
<image
class="status-card__skin"
:src="contentAsset"
mode="scaleToFill"
/>
<view class="status-card__body"> <view class="status-card__body">
<text class="status-card__eyebrow">{{ page.badge }} · 服务说明</text> <text class="status-card__eyebrow">{{ page.badge }} · 服务说明</text>
<text class="status-card__lead">{{ page.lead }}</text> <text class="status-card__lead">{{ page.lead }}</text>
@@ -129,11 +116,6 @@
<view v-else class="module-page__content module-page__content--state"> <view v-else class="module-page__content module-page__content--state">
<view class="module-panel status-card"> <view class="module-panel status-card">
<image
class="status-card__skin"
:src="contentAsset"
mode="scaleToFill"
/>
<view class="status-card__body"> <view class="status-card__body">
<text class="status-card__eyebrow">{{ stateCopy.eyebrow }}</text> <text class="status-card__eyebrow">{{ stateCopy.eyebrow }}</text>
<text class="status-card__lead">{{ stateCopy.title }}</text> <text class="status-card__lead">{{ stateCopy.title }}</text>
@@ -188,6 +170,10 @@ const fieldAsset = computed(
() => () =>
`/static/assets/modules/${assetModule.value}/transparent/module-field-frame.png`, `/static/assets/modules/${assetModule.value}/transparent/module-field-frame.png`,
); );
const moduleAssetStyle = computed(() => ({
"--module-content-asset": `url(${contentAsset.value})`,
"--module-field-asset": `url(${fieldAsset.value})`,
}));
const query = (() => { const query = (() => {
if (typeof location !== "undefined") if (typeof location !== "undefined")
return Object.fromEntries( return Object.fromEntries(
@@ -264,16 +250,15 @@ onUnmounted(() => {
<style scoped lang="scss"> <style scoped lang="scss">
.module-page { .module-page {
position: relative; display: flex;
min-height: 100vh; min-height: 100vh;
overflow-x: hidden; flex-direction: column;
background: $paper; background: $paper;
} }
.module-page__header, .module-page__header,
.module-page__content, .module-page__content,
.module-page__loading { .module-page__loading {
position: relative; z-index: 1;
z-index: 2;
} }
.module-page__loading { .module-page__loading {
min-height: calc(100vh - 100rpx); min-height: calc(100vh - 100rpx);
@@ -282,29 +267,20 @@ onUnmounted(() => {
padding: 16rpx 26rpx 56rpx; padding: 16rpx 26rpx 56rpx;
} }
.module-lead { .module-lead {
position: relative; min-height: 56rpx;
height: 56rpx;
margin: 0 12rpx 18rpx; margin: 0 12rpx 18rpx;
color: $ink-muted; color: $ink-muted;
font-size: 23rpx; font-size: 23rpx;
text-align: center; text-align: center;
} background: url("/static/assets/modules/genealogy/transparent/section-divider.png") center / 100% 100% no-repeat;
.module-lead image {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
} }
.module-lead text { .module-lead text {
position: relative;
z-index: 1;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
height: 100%; height: 100%;
} }
.module-panel { .module-panel {
position: relative;
width: 100%; width: 100%;
} }
.section-eyebrow { .section-eyebrow {
@@ -317,7 +293,6 @@ onUnmounted(() => {
letter-spacing: 3rpx; letter-spacing: 3rpx;
} }
.form-row { .form-row {
position: relative;
display: grid; display: grid;
grid-template-columns: auto minmax(0, 1fr); grid-template-columns: auto minmax(0, 1fr);
min-height: 84rpx; min-height: 84rpx;
@@ -327,30 +302,19 @@ onUnmounted(() => {
padding: 14rpx 26rpx; padding: 14rpx 26rpx;
box-sizing: border-box; box-sizing: border-box;
} }
.form-row image, .form-row,
.detail-card image, .settings-row {
.settings-row image, background: var(--module-field-asset) center / 100% 100% no-repeat;
.list-card > image,
.timeline-row > image {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
pointer-events: none;
} }
.form-row > text { .form-row > text {
position: relative;
z-index: 1;
color: $ink; color: $ink;
font-size: 24rpx; font-size: 24rpx;
font-weight: 700; font-weight: 700;
} }
.form-row input { .form-row input {
position: relative;
z-index: 1;
width: 100%; width: 100%;
min-width: 0; min-width: 0;
height: 56rpx; min-height: 56rpx;
color: $ink; color: $ink;
font-size: 23rpx; font-size: 23rpx;
text-align: right; text-align: right;
@@ -379,13 +343,16 @@ onUnmounted(() => {
} }
.list-card, .list-card,
.timeline-row { .timeline-row {
position: relative;
width: 100%; width: 100%;
min-height: 188rpx; min-height: 188rpx;
} }
.list-card,
.detail-card,
.timeline-row,
.status-card {
background: var(--module-content-asset) center / 100% 100% no-repeat;
}
.list-card__copy { .list-card__copy {
position: relative;
z-index: 1;
padding: 34rpx 46rpx 28rpx; padding: 34rpx 46rpx 28rpx;
} }
.list-card__copy text { .list-card__copy text {
@@ -410,13 +377,10 @@ onUnmounted(() => {
font-weight: 600; font-weight: 600;
} }
.detail-card { .detail-card {
position: relative;
min-height: 170rpx; min-height: 170rpx;
margin-top: 16rpx; margin-top: 16rpx;
} }
.detail-card > view { .detail-card > view {
position: relative;
z-index: 1;
padding: 34rpx 44rpx; padding: 34rpx 44rpx;
} }
.detail-card text, .detail-card text,
@@ -435,8 +399,6 @@ onUnmounted(() => {
line-height: 1.55; line-height: 1.55;
} }
.timeline-row > view { .timeline-row > view {
position: relative;
z-index: 1;
padding: 29rpx 44rpx; padding: 29rpx 44rpx;
} }
.timeline-row text { .timeline-row text {
@@ -460,7 +422,6 @@ onUnmounted(() => {
font-size: 23rpx; font-size: 23rpx;
} }
.settings-row { .settings-row {
position: relative;
display: grid; display: grid;
grid-template-columns: minmax(0, 1fr) auto; grid-template-columns: minmax(0, 1fr) auto;
min-height: 94rpx; min-height: 94rpx;
@@ -470,10 +431,6 @@ onUnmounted(() => {
padding: 20rpx 28rpx 18rpx 26rpx; padding: 20rpx 28rpx 18rpx 26rpx;
box-sizing: border-box; box-sizing: border-box;
} }
.settings-row > view {
position: relative;
z-index: 1;
}
.settings-row > view text:first-child { .settings-row > view text:first-child {
color: $ink; color: $ink;
font-size: 24rpx; font-size: 24rpx;
@@ -485,8 +442,6 @@ onUnmounted(() => {
font-size: 21rpx; font-size: 21rpx;
} }
.settings-row > text { .settings-row > text {
position: relative;
z-index: 1;
color: $brand-red; color: $brand-red;
font-size: 21rpx; font-size: 21rpx;
font-weight: 600; font-weight: 600;
@@ -495,15 +450,7 @@ onUnmounted(() => {
min-height: 330rpx; min-height: 330rpx;
text-align: center; text-align: center;
} }
.status-card__skin {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
}
.status-card__body { .status-card__body {
position: relative;
z-index: 1;
padding: 70rpx 60rpx 48rpx; padding: 70rpx 60rpx 48rpx;
} }
.status-card__eyebrow { .status-card__eyebrow {
+56 -17
View File
@@ -16,16 +16,22 @@
mode="aspectFit" mode="aspectFit"
/> />
<view class="header-side header-side--left"> <view class="header-side header-side--left">
<view v-if="root" class="header-icon-button" @click="$emit('brand')"> <button
v-if="root"
class="header-icon-button"
aria-label="返回首页"
@click="$emit('brand')"
>
<image <image
class="header-logo" class="header-logo"
src="/static/assets/foundation/transparent/brand-seal.png" src="/static/assets/foundation/transparent/brand-seal.png"
mode="aspectFit" mode="aspectFit"
/> />
</view> </button>
<view <button
v-else v-else
class="header-back" class="header-back"
aria-label="返回上一页"
hover-class="header-back--pressed" hover-class="header-back--pressed"
@click="goBack" @click="goBack"
> >
@@ -34,15 +40,16 @@
src="/static/assets/foundation/transparent/chevron-right.png" src="/static/assets/foundation/transparent/chevron-right.png"
mode="aspectFit" mode="aspectFit"
/> />
</view> </button>
</view> </view>
<text class="header-title">{{ title }}</text> <text class="header-title">{{ title }}</text>
<view class="header-side header-side--right"> <view class="header-side header-side--right">
<view <button
v-if="root" v-if="root"
class="header-icon-button header-notice" class="header-icon-button header-notice"
aria-label="打开消息中心"
@click="$emit('notice')" @click="$emit('notice')"
> >
<image <image
@@ -51,10 +58,10 @@
mode="aspectFit" mode="aspectFit"
/> />
<view v-if="unreadCount > 0" class="notice-dot"></view> <view v-if="unreadCount > 0" class="notice-dot"></view>
</view> </button>
<view v-else class="header-action" @click="$emit('action')">{{ <button v-else class="header-action" :disabled="!action" @click="$emit('action')">{{
action action
}}</view> }}</button>
</view> </view>
</view> </view>
</view> </view>
@@ -66,9 +73,17 @@ const props = defineProps({
action: { type: String, default: "" }, action: { type: String, default: "" },
root: { type: Boolean, default: false }, root: { type: Boolean, default: false },
unreadCount: { type: Number, default: 0 }, unreadCount: { type: Number, default: 0 },
fallbackUrl: { type: String, default: "/pages/genealogy/g01-my-genealogies" },
}); });
const goBack = () => uni.navigateBack(); const goBack = () => {
const stack = getCurrentPages();
if (stack.length > 1) {
uni.navigateBack();
return;
}
uni.reLaunch({ url: props.fallbackUrl });
};
</script> </script>
<style scoped lang="scss"> <style scoped lang="scss">
@@ -128,7 +143,6 @@ const goBack = () => uni.navigateBack();
.page-header--root .header-side, .page-header--root .header-side,
.page-header--root .header-title { .page-header--root .header-title {
position: relative;
z-index: 2; z-index: 2;
} }
@@ -147,12 +161,20 @@ const goBack = () => uni.navigateBack();
} }
.header-icon-button { .header-icon-button {
position: relative; display: grid;
display: flex;
width: 88rpx; width: 88rpx;
height: 88rpx; height: 88rpx;
align-items: center; place-items: center;
justify-content: center; margin: 0;
padding: 0;
border: 0;
background: transparent;
line-height: normal;
}
.header-icon-button::after,
.header-back::after,
.header-action::after {
border: 0;
} }
.header-logo { .header-logo {
@@ -164,12 +186,19 @@ const goBack = () => uni.navigateBack();
height: 48rpx; height: 48rpx;
} }
.header-logo,
.header-notice-icon,
.notice-dot { .notice-dot {
position: absolute; grid-area: 1 / 1;
top: 15rpx; }
right: 13rpx;
.notice-dot {
align-self: start;
justify-self: end;
width: 14rpx; width: 14rpx;
height: 14rpx; height: 14rpx;
margin-top: 15rpx;
margin-right: 13rpx;
border: 2rpx solid $brand-red; border: 2rpx solid $brand-red;
border-radius: 50%; border-radius: 50%;
background: #fff9ed; background: #fff9ed;
@@ -177,7 +206,12 @@ const goBack = () => uni.navigateBack();
.header-action { .header-action {
width: 100%; width: 100%;
margin: 0;
padding: 0;
border: 0;
background: transparent;
font-size: 27rpx; font-size: 27rpx;
line-height: normal;
} }
.header-back { .header-back {
@@ -186,6 +220,11 @@ const goBack = () => uni.navigateBack();
height: 88rpx; height: 88rpx;
align-items: center; align-items: center;
justify-content: flex-start; justify-content: flex-start;
margin: 0;
padding: 0;
border: 0;
background: transparent;
line-height: normal;
} }
.header-back__icon { .header-back__icon {
width: 42rpx; width: 42rpx;
+11 -38
View File
@@ -14,12 +14,6 @@
><PageHeader :title="config.pageTitle" ><PageHeader :title="config.pageTitle"
/></view> /></view>
<view class="member-form-panel"> <view class="member-form-panel">
<image
class="member-form-panel__skin"
src="/static/assets/modules/tree/transparent/t01-state-panel.png"
mode="scaleToFill"
/>
<view v-if="formState === 'form'" class="member-form"> <view v-if="formState === 'form'" class="member-form">
<text class="member-form__eyebrow">{{ config.eyebrow }}</text> <text class="member-form__eyebrow">{{ config.eyebrow }}</text>
<text class="member-form__title">{{ config.title }}</text> <text class="member-form__title">{{ config.title }}</text>
@@ -29,10 +23,6 @@
:key="field.key" :key="field.key"
class="member-field" class="member-field"
> >
<image
src="/static/assets/modules/tree/transparent/t07-search-input-frame.png"
mode="scaleToFill"
/>
<text>{{ field.label }}</text> <text>{{ field.label }}</text>
<input <input
v-model="form[field.key]" v-model="form[field.key]"
@@ -206,34 +196,26 @@ const showConflictHelp = () => {
<style scoped lang="scss"> <style scoped lang="scss">
.member-form-page { .member-form-page {
position: relative; display: flex;
min-height: 100vh; min-height: 100vh;
overflow: hidden; flex-direction: column;
background: $paper; background: $paper;
} }
.member-form-page__header { .member-form-page__header {
position: relative;
z-index: 3; z-index: 3;
} }
.member-form-panel { .member-form-panel {
position: relative;
z-index: 2; z-index: 2;
width: calc(100% - 32rpx); width: calc(100% - 32rpx);
min-height: min(640px, calc((100vw - 16px) * 1.48)); min-height: min(640px, calc((100vw - 16px) * 1.48));
margin: 18rpx auto 0; margin: 18rpx auto 0;
padding: 7.5% 8%; padding: 7.5% 8%;
box-sizing: border-box; box-sizing: border-box;
} background: url("/static/assets/modules/tree/transparent/t01-state-panel.png")
.member-form-panel__skin { center / 100% 100% no-repeat;
position: absolute;
inset: 0;
width: 100%;
height: 100%;
pointer-events: none;
} }
.member-form, .member-form,
.member-form-result { .member-form-result {
position: relative;
z-index: 1; z-index: 1;
} }
.member-form__eyebrow { .member-form__eyebrow {
@@ -258,7 +240,6 @@ const showConflictHelp = () => {
line-height: 1.5; line-height: 1.5;
} }
.member-field { .member-field {
position: relative;
display: grid; display: grid;
grid-template-columns: auto minmax(0, 1fr); grid-template-columns: auto minmax(0, 1fr);
min-height: 78rpx; min-height: 78rpx;
@@ -267,27 +248,20 @@ const showConflictHelp = () => {
margin-top: 12rpx; margin-top: 12rpx;
padding: 12rpx 22rpx; padding: 12rpx 22rpx;
box-sizing: border-box; box-sizing: border-box;
} background: url("/static/assets/modules/tree/transparent/t07-search-input-frame.png")
.member-field image { center / 100% 100% no-repeat;
position: absolute;
inset: 0;
width: 100%;
height: 100%;
pointer-events: none;
} }
.member-field > text { .member-field > text {
position: relative;
z-index: 1; z-index: 1;
color: $ink; color: $ink;
font-size: 24rpx; font-size: 24rpx;
font-weight: 700; font-weight: 700;
} }
.member-field input { .member-field input {
position: relative;
z-index: 1; z-index: 1;
width: 100%; width: 100%;
min-width: 0; min-width: 0;
height: 54rpx; min-height: 54rpx;
color: $ink; color: $ink;
font-size: 24rpx; font-size: 24rpx;
text-align: right; text-align: right;
@@ -304,22 +278,21 @@ const showConflictHelp = () => {
text-align: center; text-align: center;
} }
.member-form-action { .member-form-action {
position: relative; display: grid;
width: 100%; width: 100%;
height: 76rpx; min-height: 76rpx;
margin-top: 17rpx; margin-top: 17rpx;
} }
.member-form-action image { .member-form-action image {
position: absolute; grid-area: 1 / 1;
inset: 0;
width: 100%; width: 100%;
height: 100%; height: 100%;
pointer-events: none; pointer-events: none;
} }
.member-form-action text { .member-form-action text {
position: relative;
z-index: 1; z-index: 1;
display: flex; display: flex;
grid-area: 1 / 1;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
height: 100%; height: 100%;
+2
View File
@@ -16,6 +16,7 @@ export const genealogies = [
updatedAt: '2024-05-12', updatedAt: '2024-05-12',
activeCount: 8, activeCount: 8,
visibility: '仅成员可见', visibility: '仅成员可见',
membership: 'created',
motto: '敦亲睦族,敬祖传家。' motto: '敦亲睦族,敬祖传家。'
}, },
{ {
@@ -28,6 +29,7 @@ export const genealogies = [
updatedAt: '2024-04-28', updatedAt: '2024-04-28',
activeCount: 15, activeCount: 15,
visibility: '公开可申请', visibility: '公开可申请',
membership: 'joined',
motto: '继往开来,世守家风。' motto: '继往开来,世守家风。'
} }
] ]
@@ -0,0 +1,45 @@
# 全活动页面业务所有权审计(2026-07-20)
## 结论
52 条活动路由已经完成业务所有权收敛。活动页面不再引用 `ModulePage.vue``page-catalog.js`;共享层只保留按钮、弹层、Toast、加载、页头、背景和视觉卡片等原语。每个路由自行维护本页的模拟数据、状态、校验、动作及去向,仍未连接接口。
`ModulePage.vue``page-catalog.js``TreeMemberForm.vue` 按“不删除既有源码”的约束保留为历史文件,但活动路由不再消费这些通用业务母版。
## 五个方案与选择
1. 继续使用单一通用业务母版:改动最少,但页面语义、状态和真实入口继续失真,不采用。
2. 扩大 `page-catalog.js` 配置:能增加字段,却会把校验、状态机和导航也塞入配置,不采用。
3. 每个模块保留一个大型业务母版:比全局母版稍好,但 F、R、N、M 内部页面仍然职责不同,不采用。
4. 建立无头 schema 渲染器:扩展性高,但会引入当前项目不需要的抽象和维护成本,不采用。
5. 共享视觉原语、路由拥有业务内容:既保持统一国风视觉,又允许列表、详情、编辑器、时间线、消息、安全和服务页表达真实任务,采用。
## 本轮收敛范围
- F:动态详情、谱文列表/详情/编辑、相册列表/详情/上传、视频状态及 F01 八个真实入口形成闭环。
- R:人物、贺礼、礼仪、成长、人生事、备忘、功德分别拥有专属数据结构、字段、状态和动作。
- N/M:消息卡进入消息详情;资料、安全、密码、手机号、帮助、反馈、推广、订单和关于均为独立页面。
- G:家谱上下文、成员身份、加入申请来源、驳回/撤回再申请、设置草稿和字辈容量由路由参数与数据决定。
- TT01 根据 `generation`/`parentId` 计算树布局;T03—T08 按人物、关系及权限状态工作,不再用演示标签切状态。
- 共享组件:按钮改为原生可聚焦按钮;弹层补齐对话框语义、焦点和 EscapeToast/Loading 补齐 live region 与减少动态效果;页头返回支持深链兜底。
## 数据与布局边界
- 普通列表和卡片由数据循环与自然高度撑开,不依赖固定条数或绝对坐标。
- T01 的布局尺寸从成员关系计算,测试覆盖 10 世代、每代 12 人的 120 节点压力数据。
- 容量合同覆盖 50 条列表、50 条弹层、30 项媒体、三倍长文本和 1.3 倍字号;103 个保留风险均精确到文件、选择器和风险类型。
- 定位合同扫描所有 `position` 值(包括 `relative`);必要定位精确白名单之外无违规,也无失效白名单。
## 验证证据
- 52 条活动路由及 2 个共享组件 Vite 转换:54/54。
- PowerShell 合同:119 项中 118 项通过;唯一失败为既有基础资产候选式命名。A01 预览已从可复现管线重新生成,G06 一致性合同已改读长期 handoff 证据,不再依赖已清理的 runtime 目录。
- 浏览器运行时 smoke:排除会写回已清理长期目录的旧 T07 baseline 后,26/26 通过。
- 52 个活动页面均在 360×800 捕获当前图到 `%TEMP%`,按 A/G/T、F、R、N/M 生成联系表,并与现有同模块长期候选图复核;未发现横向溢出、明显裁切、错误复用母版或不可达主动作。
- 四档视口:320×568、360×640、360×800、412×915;覆盖模块响应式、数据压力和主要业务状态。
## 明确边界
- 本轮结论是 H5 本地模拟数据阶段的页面实现与内部审核完成,不代表接口、持久化、上传权限、微信能力、Android/HBuilderX、软键盘或真机性能完成。
- F01、T01、A05 及大量业务页发生可见变化;自动审计不能替用户维持或新增 `[x]`。这些页面保持“待用户复核”,只有用户再次明确说“通过”才更新视觉冻结状态。
- `foundation-asset-audit` 仍未通过,未放宽规则或删除实际资产。`repository-handoff-size-contract` 通过补齐可再生成目录的忽略规则后已真实通过(35 个长期截图、19.51MB、52 个索引链接),没有删除长期资料或放宽阈值。
@@ -0,0 +1,58 @@
# 全项目数据驱动布局审计(2026-07-20)
## 审计基线
- 权威基线:`main` / `01d246c 保存视觉审核与文档流迁移进度`
- 范围:`pages/``components/`、活动路由与封存 A06;当前只验证 H5,不接接口,不宣称 Android 完成。
- 首次容量合同发现 279 项待解释风险。修正“固定网格容量”规则后为 276 项:普通 `repeat(2/3/4, ...)` 是响应式列数,不等于数据容量上限;只有 12 列及以上的固定重复网格才按固定容量画布拦截。
- 当前截图证据:G01 切换家谱弹层在 412×915、6 条数据下可以自然增高并滚动,作为弹层容量参考;这不代表其他页面已经通过。
## 统一五方案决策
每个确认问题均评估以下五种方案:
1. 扩大固定尺寸:改动小,但只推迟溢出,淘汰。
2. 增加响应式断点:只解决设备宽高差异,不能解决未知数据量;仅作补充。
3. `max-height` 加内部滚动:适合弹层、独立列表和结构化画布,不用于普通内容卡片。
4. 正常文档流自然撑高:适合普通页面、列表卡、表单、状态说明和长文案,作为默认最优方案。
5. 由数据计算布局边界:适合世系树、可变列媒体和结构化关系画布。
## 已确认问题与选择
| 类别 | 已确认风险 | 五方案结论 | 最优方案 |
| --- | --- | --- | --- |
| 共享 `GenealogyCard` | 谱名省略、地点/人数/角色/更新时间不换行 | 1 不能覆盖未知长度;2 只能缓解窄屏;3 不应让普通卡片内部滚动;4 可保持完整数据并自然撑高;5 无结构坐标需求 | 4 |
| T07 成员目录 | 成员卡固定 192/196rpx,姓名和元信息强制单行省略 | 1 仍有上限;2 仍依赖样本;3 破坏页面主滚动;4 与普通列表语义一致;5 无结构坐标需求 | 4 |
| G06 搜索结果 | 结果卡虽有 `min-height`,仍用 `overflow: hidden` 裁切增长内容 | 1 无法解决裁切;2 无法覆盖长字段;3 普通结果卡不应独立滚动;4 删除裁切后由内容撑高;5 无结构坐标需求 | 4 |
| G05 家谱概览 | 内容区使用百分比/固定 grid 行,长标题与详情可能互相挤压 | 1/2 只能推迟;3 不适合普通概览正文;4 可让各段按内容增长;5 当前无坐标关系 | 4 |
| T01 世系树 | 固定 900rpx 画布、180×180 网格和固定世代轨道隐含容量上限 | 1/2 仍有容量上限;3 只能承担视口滚动;4 无法表达节点坐标与连线;5 可由世代数、单代节点数和间距推导画布 | 5,外层配合方案 3 |
## 保留边界
下列固定视觉边界不会因合同扫描被机械删除:图标尺寸、媒体缩略图比例与必要裁切、固定导航、真实弹窗/遮罩/Toast、装饰背景槽位。它们必须进入精确白名单并说明理由;普通正文、按钮文案、表单、列表卡和状态说明不得借白名单隐藏容量问题。
## 再审核要求
修复后必须重新运行全局容量合同、聚焦合同和压力数据 smoke,并在 320×568、360×640、360×800、412×915 下检查横向溢出、文本裁切、末项可达和滚动所有权。凡产生可见变化的既有 `[x]` 页面,只能作为新的视觉候选,必须由用户再次明确说“通过”后才能维持或新增 `[x]`
## 实施与二次审核结果
- 容量合同从初始 279 项、规则纠正后的 276 项,收敛为 0 个未解释风险和 0 个失效白名单。最终 103 个白名单键均精确到文件、选择器和风险类型,只保留图标/印章/装饰分隔、固定导航、媒体缩略图裁切、G01/F01/T01 明确内部滚动壳及 T01 结构化节点边界。
- 共享 `GenealogyCard`、T07、G06、F01、N01、M01、R01/R02、表单控件、状态卡与 G08—G12/T03/T08 的普通内容改为 `min-height`、换行和自然撑高;根容器不再用 `overflow-x: hidden` 掩盖横向问题。
- G08/G11/G12 文本域使用 `auto-height`;G09 状态与操作分为独立数据行。二次截图发现的 G08/G11 原生 textarea 固有高度和 G09 状态操作重叠均已再次修正并复核。
- T01 的画布宽高、行列数、世代轨、节点和关系线全部由成员数据计算。外层纵向滚动与内层横向滚动分工明确;120 节点(10 世代×每代 12 人)压力通过。
- G01 弹层已验证 2/6/12/50 条:少量内容自然增长,达到安全上限后仅列表区滚动,50 条末项可达。
- `data-driven-layout-runtime-smoke.js` 已验证:T07 50 条、G09 50 条、F09 30 项、3 倍长中文与 1.3 倍字号压力、四档视口、末项可达、横向溢出和表单自动增高。
- 二次视觉对比覆盖 G05、T01、G08—G12、T03、T07、T08;对照长期 handoff 图检查了边框、装饰资产、间距和内容区。当前结果只能作为新的 H5 视觉候选,不能代替用户重新确认。
## 验证边界
- 相关静态合同(排除已知独立失败/缺失生成物项)全通过;21 个既有核心 runtime smoke、G01 50 条弹层压力和新增数据容量 smoke 通过。
- `foundation-asset-audit``repository-handoff-size-contract` 的既有失败未通过删资料或放宽阈值处理;G06 runtime 截图一致性脚本因已授权清理 `docs/design/screens/runtime/` 不计入通过项。
- 未运行会恢复长期目录外短期截图且含旧行为断言的 `t07-module-baseline-runtime-smoke.js`;T03—T08 核心流程由独立 smoke 通过。
- 没有新的 Android/HBuilderX、系统字体缩放或真实接口证据;A06 仍为封存静态检查。
## 用户复核结论
- 用户已明确说“通过”,确认 G05、T01、G08—G12、T03、T07、T08 的本轮数据驱动布局迁移候选。
- T01、T07 原有 `[x]` 可以维持;其他页面的本次确认只覆盖迁移后的 H5 可见效果,不替代真实入口、适用状态和整页流程验收。
@@ -0,0 +1,66 @@
# 全活动页面业务所有权收敛实施计划
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development or superpowers:executing-plans task-by-task. Steps use checkbox syntax for tracking.
**Goal:** 移除活动页面对通用业务母版的依赖,使 52 个路由拥有正确、数据驱动且可本地审计的页面内容。
**Architecture:** 保留稳定视觉原语和模块资产,F/R/N/M 每个路由自己拥有业务数据、状态、校验和导航。`ModulePage.vue``page-catalog.js` 保留为封存历史文件,但活动路由和合同不再消费它们。
**Tech Stack:** uni-app、Vue 3 `<script setup>`、SCSS、PowerShell 合同、Chrome CDP runtime smoke。
## Global Constraints
- 不接接口,不导入 `@/utils/api.js`
- 不使用 worktree,不执行 git add/commit/push/reset/checkout。
- 不删除源码、测试、文档、长期截图或实际资产;不恢复 `unpackage/``docs/design/screens/runtime/`
- 普通内容使用文档流并支持未知数据量;定位与容量保留项必须进入精确白名单。
- 只复用现有真实视觉资产,不用 CSS/字符/占位图伪造可见资产。
---
### Task 1: 活动页面所有权合同
- [x] 新建合同枚举 52 个活动路由并拒绝活动页面引用 `ModulePage`
- [x] 为 F/R/N/M 建立页面专属字段、状态、动作和导航清单;先运行得到 RED。
- [x] 更新旧 ModulePage 合同,使其验证封存边界而非活动业务入口。
### Task 2: F 模块独立内容
- [x] F03 动态详情拥有正文、媒体、评论列表和评论动作。
- [x] F04 谱文列表拥有分类、搜索、列表、空/失败和新建入口。
- [x] F05 谱文详情拥有作者、时间、正文、收藏和失效回流。
- [x] F07 相册列表拥有封面、数量、权限、空/失败和新建入口。
- [x] 运行 F 聚焦合同与四档 smoke。
### Task 3: R 模块独立内容
- [x] R03/R04 实现贺礼列表与新增/编辑字段、校验和删除确认。
- [x] R05/R06/R07 实现礼仪列表、详情、编辑字段和失效状态。
- [x] R08/R09 实现成长与人生时间线、人物上下文和新增动作。
- [x] R10/R11 实现备忘和功德记录的专属数据结构与状态。
- [x] 运行 R 聚焦合同与四档 smoke。
### Task 4: N/M 模块独立内容
- [x] N02 实现消息详情、已读状态和类型对应去向。
- [x] M02–M10 分别实现资料、安全、密码、手机、帮助、反馈、推广、订单、关于的专属控件和状态。
- [x] 运行 N/M 聚焦合同与四档 smoke。
### Task 5: A/G/T 与共享组件复核
- [x] 逐页检查 A/G/T 是否错误复用业务结构;只修复确认问题。
- [x] 检查共享组件只承担视觉原语,不拥有页面业务。
- [x] 对发现的问题执行 RED/GREEN 并运行既有流程 smoke。
### Task 6: 全量压力与视觉复核
- [x] 52/52 路由 Vite 转换成功。
- [x] 四档视口运行全部活动页,检查横向溢出、内容裁切、末项与主动作可达。
- [x] 对列表 50 条、长文案三倍、字号 1.3 倍和表单长输入做压力验证。
- [x] 捕获代表截图到 `%TEMP%`,与长期同模块参考图同尺寸比较,修复后再审一次。
### Task 7: 最终回归与交接
- [x] 运行全 position、数据容量、资产、路由、页面专属合同和关键 runtime smoke。
- [x] 运行 `git diff --check`,确认已清理目录未恢复、Chrome 项目页仍为 1。
- [x] 更新验收规划、交接记录和审计报告,明确 H5/接口/Android 边界及用户复核停点。
@@ -0,0 +1,193 @@
# 全项目数据驱动布局审核实施计划
> **执行要求:** 使用 `superpowers:executing-plans` 在当前会话逐任务执行。用户明确禁止多代理、worktree 与全部 Git 写操作,因此在已获用户“开始”授权的当前 `main` 工作区内执行,不包含提交步骤。
**目标:** 让 52 条活动路由、共享组件及封存 A06 的布局能够承受数据数量、文案长度和字号增长,同时保留确有必要的固定视觉边界。
**架构:** 新增一个扫描固定容量风险的 PowerShell 合同和精确 JSON 白名单,作为规则单一所有者;新增一个 Chrome CDP 压力脚本,对代表性重复内容注入 1/10/50 条、2~3 倍文案和 1.3 倍字号。普通内容使用自然文档流,弹层使用限高滚动,世系树从数据计算画布边界。
**技术栈:** uni-app Vue 3、SCSS、PowerShell、Node.js、Chrome CDP。
## 全局约束
- 列表验证 1/10/50 条;弹层验证 2/6/12/50 条;相册验证 1/9/30 张。
- 世系树验证 1/5/10 代及单代 1/6/12 人。
- 文案验证正常、2 倍、3 倍;字号验证默认及约 1.3 倍。
- 固定视口为 320×568、360×640、360×800、412×915。
- 不接接口、不新增路由、不改变业务语义、不删除或重绘实际资产。
- 不执行多代理、worktree、git add/commit/push/reset/checkout。
- 每个问题记录五种方案:扩大固定尺寸、增加断点、限高滚动、自然撑高、数据计算边界;按内容类型选择最优方案。
---
### 任务 1:建立全局容量风险合同与审计报告
**文件:**
- 新建:`tests/data-driven-layout-risk-allowlist.json`
- 新建:`tests/data-driven-layout-contract.ps1`
- 新建:`docs/data-driven-layout-audit_2026-07-20.md`
**接口:**
- 合同扫描 `pages/**/*.vue``components/**/*.vue` 的 style 块。
- 风险类型固定为 `fixed-content-height``clipping-overflow``single-line-truncation``fixed-grid-track``fixed-capacity-canvas`
- 白名单项必须包含 `file``selector``risk``reason`,并拒绝失效条目。
- [ ] **步骤 1:编写失败合同**
合同提取选择器和声明,普通内容命中以下条件即失败:固定内容高度、`overflow: hidden``nowrap/ellipsis/line-clamp`、百分比或固定值 grid 行、固定容量 `repeat(N, ...)`
- [ ] **步骤 2:运行合同确认 RED**
运行:
```powershell
powershell.exe -NoProfile -ExecutionPolicy Bypass -File .\tests\data-driven-layout-contract.ps1
```
预期:FAIL,并至少报告 T01、G05、G06、T07、`GenealogyCard` 的已知风险。
- [ ] **步骤 3:逐项分类并写五方案审计表**
审计表每行包含:文件/选择器、真实风险、五个候选方案、最优方案、选择理由、验证用例。装饰背景、媒体比例、图标、固定导航和明确滚动视口才允许加入白名单。
- [ ] **步骤 4:运行白名单失效自测**
临时增加不存在的选择器,确认合同以“失效白名单”失败;随后移除该临时条目。
### 任务 2:共享卡片、目录和普通结果卡自然撑高
**文件:**
- 修改:`components/GenealogyCard.vue`
- 修改:`pages/tree/t07-member-directory.vue`
- 修改:`pages/genealogy/g06-search-genealogies.vue`
- 修改:相关聚焦合同
**接口:**
- 关键姓名、谱名、地区、支系和身份允许换行。
- 卡片使用 `min-height`,不得使用固定 `height` 裁切正文。
- 摘要省略仅在存在同屏完整值或明确详情入口时允许。
- [ ] **步骤 1:扩展聚焦合同并确认 RED**
明确禁止 `.directory-card` 固定高度、`.directory-card__name/.directory-card__meta` 单行截断、`.result-card` 裁切、共享卡关键字段 `nowrap`
- [ ] **步骤 2:记录五方案并选择自然撑高**
对上述普通卡片选择方案 4;方案 1 只延后溢出,方案 2 不解决数据量,方案 3 会制造卡片内滚动,方案 5 对普通文本过度复杂。
- [ ] **步骤 3:最小修改源码**
将固定高度改为 `min-height`,移除正文裁切和关键字段强制单行,使用 `flex-wrap``minmax(0, 1fr)` 和自然行高。
- [ ] **步骤 4:运行聚焦合同和现有 runtime smoke**
预期:聚焦合同、G06、T03—T08 与模块响应式 smoke 全部 PASS。
### 任务 3:G05 百分比轨道改为内容驱动
**文件:**
- 修改:`pages/genealogy/g05-genealogy-overview.vue`
- 修改:`tests/g05-document-flow-contract.ps1`
- 修改:`tests/g05-overview-runtime-smoke.js`
- [ ] **步骤 1:添加长谱名、长来源、长公开说明失败断言**
检查每个内容块 `scrollHeight <= clientHeight + 1` 或容器允许自然增长,操作按钮可达。
- [ ] **步骤 2:运行确认 RED**
预期:百分比固定行导致长内容裁切或重叠。
- [ ] **步骤 3:比较五方案并选择自然撑高**
普通概览正文选择方案 4;保留真实背景比例,但让内容层以 `auto/minmax` 和自然间距排列。
- [ ] **步骤 4:修改并验证**
移除正文百分比行轨道和固定详情行高,改为内容驱动 grid/flex;运行 G05 静态、运行时与四档压力检查。
### 任务 4:T01 世系树由数据计算画布
**文件:**
- 修改:`pages/tree/t01-tree-overview.vue`
- 修改:`tests/t01-tree-state-contract.ps1`
- 修改:`tests/t01-tree-state-runtime-smoke.js`
**接口:**
- `layoutMembers`:从成员的世代和同代顺序生成 grid 行列。
- `treeCanvasStyle`:根据最大列、最大行、节点尺寸和间距返回动态 `gridTemplateColumns``gridTemplateRows``width``height`
- `generationRows`:从实际成员世代分组生成,不维护固定轨道行高数组。
- [ ] **步骤 1:写动态 10 代/单代 12 人失败测试**
断言所有节点处于画布边界内、世代标签与对应节点纵向相交、画布可横纵滚动、节点正文不使用定位。
- [ ] **步骤 2:运行确认 RED**
预期:固定 `900rpx`、180×180 网格和固定世代轨道无法满足压力数据。
- [ ] **步骤 3:比较五方案并选择数据计算边界**
选择方案 5;方案 3 仅解决滚动而不解决画布容量,方案 4 无法表达关系图坐标,方案 1/2 仍有硬上限。
- [ ] **步骤 4:实现动态布局并验证**
用 computed 数据推导画布、节点和世代轨道;保留关系线局部绘制白名单。运行 T01 静态、全状态、运行时和定位合同。
### 任务 5:其余页面全量风险清零
**文件:**
- 修改:任务 1 合同报告的其余 `pages/**/*.vue``components/**/*.vue`
- 修改:对应聚焦测试
- [ ] **步骤 1:按风险类型逐项写 RED**
每次只处理一个选择器;普通正文优先方案 4,弹层列表优先方案 3,媒体网格优先方案 5 或稳定 `aspect-ratio` 白名单。
- [ ] **步骤 2:逐项实现最小修改**
不得顺手重构相邻代码;每行修改必须对应审计表中的风险和选择方案。
- [ ] **步骤 3:全局合同归零**
运行容量合同,预期零未白名单风险、零失效白名单。
### 任务 6:全项目压力运行和二次视觉审核
**文件:**
- 新建:`tests/data-driven-layout-runtime-smoke.js`
- 修改:`scripts/capture-chrome-page.js`(只在需要新增审计动作时)
- 修改:三份交接/验收文档与本计划
**运行接口:**
- 对代表性重复项选择器复制到 10/50 条或 9/30 项。
- 对关键文本替换为 2~3 倍真实中文长文案。
- 对页面根设置 1.3 倍字号压力类,不改变生产默认字号。
- 输出只写 `%TEMP%\jiapuapp-runtime\data-driven-layout-audit`
- [ ] **步骤 1:编写压力脚本并确认在未修页面 RED**
检查横向溢出、正文裁切、末项可达、弹层滚动、按钮可达和树节点边界。
- [ ] **步骤 2:运行四档全量压力 smoke**
预期:所有配置用例 PASS,且 9222 仍只有一个项目页面。
- [ ] **步骤 3:捕获并检查代表性截图**
至少覆盖共享卡、普通列表、弹层、G05、T01、T07、媒体网格;每张截图必须实际打开检查。
- [ ] **步骤 4:修改后再审核一次**
把修改前后同状态同尺寸图放入同一比较图,检查裁切、边距、字体、边框、圆角和装饰资产;发现问题回到对应任务继续 RED/GREEN。
- [ ] **步骤 5:最终验证与交接**
运行容量合同、定位合同、全部相关静态合同、关键 runtime smoke、`git diff --check`,确认 `docs/design/screens/runtime/` 不存在;不运行会恢复该目录的旧脚本。文档明确自动审核不等于用户新增 `[x]` 或 Android 完成。
## 2026-07-20 执行记录
- 任务 1—5 已完成:容量合同覆盖固定内容高度、裁切、单行截断、固定 grid 行和大固定画布;最终为零未解释风险、零失效白名单。
- TDD 记录已形成:共享卡/T07/G06、G05、T01、G08/G09/G11/G12 均先看到聚焦 RED,再完成最小实现和 GREEN。
- 任务 6 已完成 H5 自动化与二次目视审核:四档视口、50 条列表/弹层、30 项媒体、10×12 世系树、长文案和 1.3 倍字号模拟通过。
- 当前停点为用户视觉复核。所有有可见变化的旧 `[x]` 只能视为候选;用户再次明确“通过”前,不维护或新增验收勾选。
@@ -2,15 +2,23 @@
> **执行要求:** 使用 `superpowers:executing-plans` 当前会话内联执行。用户禁止多代理、worktree 及全部 Git 写操作;不执行提交步骤。 > **执行要求:** 使用 `superpowers:executing-plans` 当前会话内联执行。用户禁止多代理、worktree 及全部 Git 写操作;不执行提交步骤。
**目标:**用户指定 fixed 的顶部/底部导航与真实弹层外,清除全项目页面内容的脱离文档流定位,并保持现有业务状态与视觉语言。 **目标:**精确白名单内确有必要的定位职责外,清除全项目普通页面内容的全部无意义 `position` 声明(包括 `relative`,并保持现有业务状态与视觉语言。
**架构:** 以一个静态合同和精确 JSON 白名单为单一规则所有者;先完成 R02 样板,再迁移共享组件和模块页面。每批通过现有 CDP smoke、四档响应式及同尺寸截图后进入下一批。 **架构:** 以一个静态合同和精确 JSON 白名单为单一规则所有者;先完成 R02 样板,再迁移共享组件和模块页面。每批通过现有 CDP smoke、四档响应式及同尺寸截图后进入下一批。
**技术栈:** uni-app Vue 3、SCSS、PowerShell、Node.js、Chrome CDP。 **技术栈:** uni-app Vue 3、SCSS、PowerShell、Node.js、Chrome CDP。
## 2026-07-20 执行结果
- 合同已扫描全部 `position` 声明并去除注释干扰;初始基线为 35 个文件、390 条未白名单声明,其中 278 条为 `relative`。当前未白名单违规为 0,失效白名单为 0。
- F10 与 T01 完成后继续按 R → N → M → F → G → T → A 全面迁移,并覆盖共享组件、`ModulePage`、Tree 表单和封存 A06;没有把单页修复冒充全项目完成。
- 普通根容器、Header/Content、正文、字段、按钮和卡片使用正常文档流、flex、grid 或 grid 同单元叠放。白名单只保留固定导航、视口背景、真实弹层/Toast/遮罩/底部弹层/全屏预览、必要媒体覆盖、局部装饰与 T01 关系线等明确职责。
- 已完成迁移批次的聚焦合同、关键 H5 runtime smoke 与同尺寸前后对比;未观察到语义性可见变化。该自动结果不替代用户确认,本轮不新增 `[x]`A06 仅静态覆盖,Android 未验证。
- `tests/t07-module-baseline-runtime-smoke.js` 因会重新生成已清理的 runtime 截图且“清空搜索恢复全部成员”断言失败,不计入通过项;核心 `tests/t03-t08-member-flow-runtime-smoke.js` 独立通过。既有资产命名与仓库交接体积失败未通过放宽阈值或删除长期资料处理。
## 全局约束 ## 全局约束
- 禁止普通内容使用 `absolute/fixed/sticky`、负外边距或以位移 transform 承担布局;按钮按压等不改变排版的交互微动效可以保留。 - 禁止普通内容使用无明确必要的 `relative/absolute/fixed/sticky` 或其他 `position` 声明、负外边距或以位移 transform 承担布局;按钮按压等不改变排版的交互微动效可以保留。
- 允许定位的职责为固定顶部/底部导航、独立视口背景、真实弹层,以及受明确父容器约束的纯装饰、角标、红点和关系线,必须精确白名单。 - 允许定位的职责为固定顶部/底部导航、独立视口背景、真实弹层,以及受明确父容器约束的纯装饰、角标、红点和关系线,必须精确白名单。
- 不删除或重绘现有资产;装饰图优先改作 background/border-image,只有确需局部叠放时保留 absolute。 - 不删除或重绘现有资产;装饰图优先改作 background/border-image,只有确需局部叠放时保留 absolute。
- 四档为 320×568、360×640、360×800、412×915。 - 四档为 320×568、360×640、360×800、412×915。
@@ -24,12 +32,39 @@
- 新建:`tests/document-flow-position-allowlist.json` - 新建:`tests/document-flow-position-allowlist.json`
- 新建:`tests/document-flow-position-contract.ps1` - 新建:`tests/document-flow-position-contract.ps1`
- [ ] 白名单登记 `PageHeader.vue` 的固定顶部栏、`AppTabbar.vue` 的固定底部栏、`AppDialog.vue` 的弹窗层/皮肤和 `AppToast.vue` 的 Toast;其他页面本地弹层在迁移到对应批次时逐项审计后登记 - [x] 白名单登记固定顶部/底部导航、真实弹层、Toast、独立视口背景及经审计确有必要的局部装饰、角标和关系线;每项使用精确文件+选择器+用途,不放行普通 `relative`
- [ ] 合同扫描 `pages/**/*.vue``components/**/*.vue` 的 style 块,报告不在精确文件+选择器白名单内的 `absolute/fixed/sticky` - [x] 合同扫描 `pages/**/*.vue``components/**/*.vue` 的 style 块,报告不在精确白名单内的所有 `position` 声明,包括 `relative`
- [ ] 合同同时禁止普通选择器出现负 margin 和 `transform: translate(...)` 布局。 - [ ] 合同继续补齐普通选择器负 margin 和 `transform: translate(...)` 布局检查
- [ ] 运行合同并保存初始 37 文件违规清单;预期 FAIL,不放宽规则 - [x] 2026-07-20 新口径初始基线为 35 个文件、390 条未白名单声明,其中 278 条为 `relative`;预期 FAIL,不再使用旧“37 文件/112 条”作为完整数字
### 任务 2R02 文档流样板 ### 任务 2F10 无意义 relative 返工样板
**文件:**
- 修改:`pages/family/f10-video-list.vue`
- 修改:`tests/f10-video-status-contract.ps1`
- 验证:`tests/f10-video-status-runtime-smoke.js`
- [ ] 先扩展 F10 聚焦合同,禁止页面根容器、Header/Content 包装、导语正文、状态卡和状态卡正文使用 `position: relative`,保留已审计装饰层的精确白名单。
- [ ] 运行聚焦合同确认 RED。
- [ ] 使用正常文档流和 grid 同单元叠放移除 6 条无意义 `relative`,装饰图不得承担正文排版。
- [ ] 运行全局定位合同,确认 F10 的 6 条 `relative` 违规归零且其他基线未被掩盖。
- [ ] 复用唯一 51739222 项目页执行四档 smoke 和同尺寸前后截图;有可见变化时 F10 退回 `[~]`,只有用户重新明确通过才能维持 `[x]`
### 任务 3:T01 世代栏与成员节点文档流
**文件:**
- 修改:`pages/tree/t01-tree-overview.vue`
- 修改:`tests/t01-tree-state-contract.ps1`
- 修改:`tests/t01-all-states-visual-contract.ps1`
- 验证:`tests/t01-tree-state-runtime-smoke.js`
- [ ] 聚焦合同先禁止 `.generation-band``.generation-rail``.member-node` 使用定位摆放普通节点内容,关系线只保留经审计的局部绘制职责。
- [ ] 运行聚焦合同确认 RED。
- [ ] 世代栏、成员节点和节点正文改为 grid/flex 文档流;关系线使用 grid 边框或受父容器约束的精确白名单层。
- [ ] 运行 T01 状态合同、全状态视觉合同、运行时 smoke 和全局定位合同。
- [ ] 复用唯一项目页完成正常、状态、节点选择和四档同尺寸对比;有可见变化时 T01 退回 `[~]` 等待用户重新确认。
### 任务 4:R02 文档流样板复核
**文件:** **文件:**
- 修改:`pages/records/r02-person-detail.vue` - 修改:`pages/records/r02-person-detail.vue`
@@ -42,7 +77,7 @@
- [ ] 题签 copy、字段 label/input、textarea、状态文案全部使用文档流;容器高度由内容和 min-height 控制。 - [ ] 题签 copy、字段 label/input、textarea、状态文案全部使用文档流;容器高度由内容和 min-height 控制。
- [ ] 运行 R02 合同、入口交互 smoke、四档 smoke,捕获 412×915 详情和编辑态供复核。 - [ ] 运行 R02 合同、入口交互 smoke、四档 smoke,捕获 412×915 详情和编辑态供复核。
### 任务 3:共享基础组件 ### 任务 5:共享基础组件
**文件:** `components/AppButton.vue``PageHeader.vue``AppTabbar.vue``ModulePageBackground.vue``GenealogyPageBackground.vue``GenealogyCard.vue` **文件:** `components/AppButton.vue``PageHeader.vue``AppTabbar.vue``ModulePageBackground.vue``GenealogyPageBackground.vue``GenealogyCard.vue`
@@ -51,7 +86,7 @@
- [ ] GenealogyCard 内容改 flex/grid,皮肤转背景。 - [ ] GenealogyCard 内容改 flex/grid,皮肤转背景。
- [ ] 对 G01、T07、F01、R01、N01、M01 六基准做四档和同尺寸回归。 - [ ] 对 G01、T07、F01、R01、N01、M01 六基准做四档和同尺寸回归。
### 任务 4:通用 ModulePage ### 任务 6:通用 ModulePage
**文件:** `components/ModulePage.vue` 及其聚焦测试 **文件:** `components/ModulePage.vue` 及其聚焦测试
@@ -59,14 +94,14 @@
- [ ] 装饰图转背景;字段用 grid;卡片内容自然撑高。 - [ ] 装饰图转背景;字段用 grid;卡片内容自然撑高。
- [ ] 执行全部消费 ModulePage 的路由 smoke 和四档回归。 - [ ] 执行全部消费 ModulePage 的路由 smoke 和四档回归。
### 任务 5:模块分批迁移 ### 任务 7:模块分批迁移
- [ ] R → N → M → F:每页先合同 RED,再改文档流,再跑模块四档。 - [ ] R → N → M → F:每页先合同 RED,再改文档流,再跑模块四档。
- [ ] G:保留真实弹层白名单,页面卡片、表单、Header、背景全部文档流 - [ ] G:保留真实弹层白名单,页面卡片、表单、Header 和普通内容全部文档流;独立视口背景只保留精确白名单
- [ ] T:目录和表单使用 grid/flex;T01 世系节点和关系线用 grid/border 重做 - [ ] TT01 完成后,继续把目录和表单迁移到 grid/flex。
- [ ] A:登录、注册、重置和封存 A06 的普通内容文档流;验证弹窗/Toast 保留精确白名单。 - [ ] A:登录、注册、重置和封存 A06 的普通内容文档流;验证弹窗/Toast 保留精确白名单。
### 任务 6:最终收敛 ### 任务 8:最终收敛
- [ ] `tests/document-flow-position-contract.ps1` 最终 PASS,输出零普通内容违规。 - [ ] `tests/document-flow-position-contract.ps1` 最终 PASS,输出零普通内容违规。
- [ ] 运行活动路由转换、关键交互 smoke、四档响应式和 `git diff --check` - [ ] 运行活动路由转换、关键交互 smoke、四档响应式和 `git diff --check`
@@ -0,0 +1,50 @@
# 全活动页面业务所有权与成熟度收敛设计
## 目标
在不接业务接口、不改变现有浅色国风视觉体系的前提下,把 52 个活动路由从“视觉覆盖稿”收敛为可独立理解、可本地操作、可扩容的 H5 产品候选。共享组件只负责背景、页头、按钮、弹窗、提示和加载等视觉原语;每个业务页面必须自己拥有字段、内容结构、状态语义、入口与下一步。
## 已确认根因
`components/ModulePage.vue``data/page-catalog.js` 当前同时拥有 F/R/N/M 23 个路由的业务结构和交互。不同任务只替换标题、字段数组或模板类型,造成动态详情、谱文、相册、贺礼、礼仪、成长日志、消息详情、个人资料、安全、帮助、反馈、推广、订单和关于页共享同一套“查看详情/提交后成功”行为。历史规划已经把它定义为第一版覆盖工具而非最终视觉答案,因此用户对同质化的怀疑成立。
## 五方案比较
1. **继续沿用单一 `ModulePage`**:改动最少,但业务含义继续由模板猜测,不能成为成熟产品,淘汰。
2. **扩大 `pageCatalog` 配置协议**:可补字段类型和动作,但会把页面逻辑继续集中到一个巨型解释器,配置与运行时容易分叉,淘汰。
3. **按 form/list/detail/timeline/settings 拆五个业务组件**:比单一母版清晰,但“同样是表单”不代表个人资料、密码、礼仪和反馈拥有相同验证与状态,仅作为视觉结构参考。
4. **每页复制完整视觉与业务代码**:业务独立但重复背景、页头、按钮和状态样式,后续全局调整风险过高,淘汰。
5. **共享视觉原语、页面拥有业务(最优)**:活动路由不再委托 `ModulePage``pageCatalog`;每页直接声明真实本地数据、字段类型、状态和动作,只复用 `ModulePageBackground``PageHeader``AppButton``AppDialog``AppToast``AppLoading` 等稳定视觉原语与公共样式。
## 所有权边界
- 页面拥有:业务文案、字段与输入类型、列表项目结构、空/加载/失败/权限/失效/成功状态、校验、确认动作、入口和返回目标。
- 公共组件拥有:模块长背景、顶部/底部导航、按钮表面、卷轴弹窗、Toast、Loading、稳定卡片皮肤映射。
- `ModulePage.vue``page-catalog.js` 不再是活动路由入口;为遵守现有文件保留要求,文件保留为封存历史实现,并由合同禁止活动页面引用。
- 不接 `utils/api.js`;页面使用真实语义的本地 mock 数据与查询参数状态,接口阶段替换数据源而不重写视觉结构。
## 分模块业务内容
- F:动态详情/评论、谱文列表与详情、相册列表必须各自拥有内容和动作;编辑、上传与待开放页沿用现有独立实现。
- R:贺礼、礼仪、成长日志、人生事、备忘、功德分别拥有对应金额/关系/时间/地点/人物/提醒/贡献字段,不再是通用卡片换标题。
- N:消息详情拥有消息类型、时间、正文、已读与业务去向。
- M:资料、安全、改密、改手机、帮助、反馈、推广、订单、关于分别拥有正确控件、校验、确认与状态。
- A/G/T:保留现有独立页面,重点复核共享组件边界、长数据、长文本、状态与导航,不为了统一而退回母版。
## 数据驱动与视觉规则
- 普通内容由文档流自然撑高;列表使用数组渲染,不写死项目数。
- 文字允许换行;只有真实媒体缩略图、图标和结构画布可精确裁切。
- 表单使用与语义匹配的 `input` 类型和 `textarea auto-height`;长列表末项必须可达。
- 四档 H5320×568、360×640、360×800、412×915;压力覆盖 1/10/50 条、三倍长文案和 1.3 倍字号模拟。
- 视觉继续使用仓库现有真实位图资产,不新增 CSS 伪装饰、占位资产或手绘 SVG。
## 验收
1. 52 个活动路由均能被 Vite 转换并通过静态资产、定位、数据容量合同。
2. 活动页面对 `ModulePage` 的引用为 0;页面目录不再是活动业务合同。
3. 每个替换页至少有一个页面专属合同,验证核心字段、状态、动作、路由和禁止项。
4. 四档运行 smoke 无横向溢出,长内容不裁切,主动作和末项可达。
5. 代表页面与同模块长期参考图同尺寸比较;视觉自审不能替用户的最终 `[x]`,但代码与候选状态必须完整记录。
6. 不宣称接口、Android/HBuilderX、系统字体缩放或真实系统权限完成。
@@ -0,0 +1,72 @@
# 全项目数据驱动布局审核设计
> 日期:2026-07-20
> 阶段:H5 视觉与布局韧性,不接接口,不宣称 Android 完成
> 约束:不使用多代理或 worktree,不执行 Git 写操作,不删除源码、测试、文档、长期截图或实际资产
## 目标
在全项目文档流迁移完成后,再以真实数据增长为前提审核 `pages/``components/`、52 条活动路由及封存 A06。当前 Mock 数量或短文案能显示正常,不再视为数据驱动布局通过。
审核必须证明:列表、卡片、表单、弹层、相册、世系树和共享组件能够由数据数量、文案长度和字号自然驱动;固定尺寸只用于图标、媒体比例、明确的装饰槽位和视口级导航等稳定视觉边界。
## 压力基线
- 列表:1、10、50 条。
- 弹层列表:2、6、12、50 条;内容先自然增长,达到视口上限后仅列表区滚动。
- 相册/上传网格:1、9、30 张。
- 世系树:1、5、10 个世代;单代 1、6、12 个成员;节点和关系线边界由数据计算。
- 文案:正常、2 倍、3 倍长度,覆盖长谱名、长姓名、堂号、地区、支系、身份、状态、说明和按钮文案。
- 字号:默认与约 1.3 倍。
- 视口:320×568、360×640、360×800、412×915。
## 审核分类
1. 普通页面和共享卡片:检查固定 `height`、固定 grid 行、`overflow: hidden`、单行截断、不可换行和固定项目数量假设。
2. 列表和目录:检查数据增加后页面自然增长、末项可达、底部导航避让和滚动所有权。
3. 弹层和底部层:检查少量内容自然增长、大量内容达到 `max-height` 后内部滚动、标题和操作区不被挤出。
4. 表单和状态页:检查错误文案、长标签、长输入值、键盘前后及成功/失败信息自然撑高。
5. 媒体网格:检查项目数量改变、长说明、失败状态和当前项标识;媒体缩略图可保留稳定 `aspect-ratio`
6. 世系树:检查画布宽高、世代轨道、节点坐标和关系线是否由数据边界推导;不允许固定 `900rpx` 或固定 180×180 网格成为容量上限。
## 每项问题的五方案决策
每个确认问题都必须记录下列五种候选方案,并按该问题的内容类型选择最优方案,不机械套用同一种 CSS:
1. **扩大固定尺寸**:改动最小,但只是推迟溢出,默认淘汰。
2. **增加尺寸断点**:适合设备差异,不解决未知数据量,通常只作补充。
3. **容器限高并滚动**:适合弹层、独立列表和全屏画布;普通卡片不使用。
4. **正常文档流自然撑高**:适合正文、表单、状态卡、普通列表卡和长文案;为普通内容首选。
5. **由数据计算布局边界**:适合世系树、媒体网格、可变列数和结构化关系;由数据推导行列、画布和连接关系。
选择顺序:普通内容优先方案 4;独立滚动容器优先方案 3;结构化画布优先方案 5。方案 1 不作为最终修复,方案 2 只处理确实由视口造成的差异。
## 实施规则
- 先建立全局静态容量合同,列出风险选择器,但对白名单视觉边界做精确说明,不能简单禁止所有固定尺寸。
- 每个确认问题先添加最小失败测试并看到预期 RED,再修改源码。
- 卡片默认使用 `min-height` 和自然撑高;文本默认允许换行。只有同时存在完整值查看路径、非关键摘要语义和明确空间边界时才允许省略号。
- 页面主列表使用正常文档流;弹层列表、固定导航下的独立区域和世系画布可以拥有明确滚动容器。
- 背景图使用 `background`、九宫格或稳定比例承载装饰,不让装饰图高度决定正文容量。
- 不改变接口、路由、业务状态含义、当前视觉资产和用户已确认的色彩/字体/装饰语言。
## 验证与证据
1. 静态容量合同为零未解释风险。
2. 每类页面运行压力数据 smoke,检查横向溢出、内容裁切、末项可达、滚动所有权和操作区可达。
3. 使用唯一 51739222 Chrome 项目页,在四档尺寸获取当前审核运行的新截图。
4. 修改后再次获取同状态、同尺寸截图;将修改前后放入同一比较图检查可见变化。
5. 自动化与截图只能证明候选稳定,不能代替用户视觉确认或新增 `[x]`
## 已知首轮风险
- T01 使用固定 `900rpx` 画布、固定 180×180 网格及固定世代轨道行高,属于高风险。
- G05 使用百分比固定行轨道和局部固定行高,长文案可能挤压。
- G06 结果卡在可增长内容上保留 `overflow: hidden`,存在裁切风险。
- T07 成员卡固定高度,姓名和成员信息强制单行省略,存在长数据风险。
- 共享 `GenealogyCard` 的谱名、元信息、角色与更新时间多处不换行,存在真实数据截断风险。
- G01 切换家谱弹层已验证 2/6/12 条可增长与滚动,是可复用的弹层容量参考。
## 完成边界
本轮完成只表示 H5 数据容量候选通过源码合同、压力运行和同尺寸视觉回归。A06 封存路由只做静态检查;Android/HBuilderX、系统键盘、真实接口数据和设备级字体缩放仍需后续单独验证。既有 `foundation-asset-audit``repository-handoff-size-contract` 失败不得通过放宽阈值或删除长期资料处理。
@@ -6,7 +6,11 @@
## 当前证据 ## 当前证据
只读审计发现 37 个 Vue 文件包含 `position: absolute|fixed|sticky`。用法混合了正常内容、装饰图层、页面背景、Tabbar、世系关系线和真实弹层,不能机械删除。R02 已证明普通文字、字段标签和输入框也被定位到装饰图之上,系统字体、长文案、键盘或尺寸变化时存在重叠风险。 只读审计发现 37 个 Vue 文件包含 `position: absolute|fixed|sticky`,漏掉了普通容器上的 `relative`,因此旧“剩余 112 条”无效。2026-07-20 扩展合同后,新口径发现 35 个文件存在 390 条未白名单 `position` 声明,其中 278 条为 `relative`F10 一页即有 6 条无意义 `relative`。用法混合了正常内容、装饰图层、页面背景、Tabbar、世系关系线和真实弹层,不能机械删除。R02 已证明普通文字、字段标签和输入框也被定位到装饰图之上,系统字体、长文案、键盘或尺寸变化时存在重叠风险。
## 执行状态
2026-07-20 已按本设计完成全项目源码迁移。合同从上述 390 条未白名单声明收敛为零,并增加失效白名单检查,保证白名单与源码实际定位逐项对应。迁移覆盖共享组件、R/N/M/F/G/T/A 页面及封存 A06;普通内容改用正常文档流、flex、grid 或 grid 同单元叠放,只有本设计列出的明确职责保留定位。H5 同尺寸回归未发现语义性可见变化,但这不替代用户确认;本次不新增视觉 `[x]`,不宣称 Android 或接口完成。
## 单一合同 ## 单一合同
@@ -20,12 +24,13 @@
## 迁移顺序 ## 迁移顺序
1. 建立合同精确白名单 37 文件初始清单,测试先失败并输出全部违规位置。 1. 建立扫描全部 `position`合同精确白名单,以 35 文件/390 条未白名单声明作为新初始基线;测试先失败并输出全部违规位置。
2. R02 作为第一张页面样板:人物题签、档案卡、表单、状态卡全部改为背景 + flex/grid 文档流 2. F10 先移除页面根容器、Header/Content、正文和卡片的 6 条无意义 `relative`,作为新口径最小返工样板
3. 迁移共享控件:AppButton、PageHeader、AppTabbar、ModulePageBackground、GenealogyPageBackground、GenealogyCardPageHeader 与 AppTabbar 保留用户指定的 fixed 白名单,清除其内部普通内容和装饰定位;弹层组件保留白名单定位 3. T01 随后迁移世代栏与成员节点;节点正文使用 grid/flex,关系线只保留经审计的局部绘制职责
4. 迁移 `ModulePage`,让普通 F/R/N/M 页面共享文档流结构 4. 复核 R02 样板并迁移共享控件:AppButton、PageHeader、AppTabbar、ModulePageBackground、GenealogyPageBackground、GenealogyCardPageHeader 与 AppTabbar 保留用户指定的 fixed 白名单,清除其内部普通内容定位;弹层组件保留白名单定位
5. 按 R → N → M → F → G → T → A 迁移专属页面;A06 源码也纳入静态合同 5. 迁移 `ModulePage`,让普通 F/R/N/M 页面共享文档流结构
6. 每批执行聚焦合同、现有交互 smoke、四档响应式、真实截图和 `git diff --check` 6. 按 R → N → M → F → G → T → A 迁移其余专属页面;A06 源码也纳入静态合同
7. 每批执行聚焦合同、现有交互 smoke、四档响应式、真实截图和 `git diff --check`
## 冻结页 ## 冻结页
@@ -33,7 +38,7 @@
## 验收门槛 ## 验收门槛
- 全项目合同最终为零违规白名单只含固定顶部/底部导航和真实弹层 - 全项目合同最终为零未白名单违规白名单只含固定顶部/底部导航、独立视口背景、真实弹层/Toast/遮罩/底部弹层/全屏预览,以及经审计确有必要的局部装饰、角标和关系线
- 52 条活动路由和封存 A06 源码均被扫描。 - 52 条活动路由和封存 A06 源码均被扫描。
- 四档固定为 320×568、360×640、360×800、412×915。 - 四档固定为 320×568、360×640、360×800、412×915。
- 长文案、约 1.3 倍字号、输入校验和键盘场景不重叠,操作区可滚动到达。 - 长文案、约 1.3 倍字号、输入校验和键盘场景不重叠,操作区可滚动到达。
+32 -4
View File
@@ -3,12 +3,17 @@
> 最后更新:2026-07-20 > 最后更新:2026-07-20
> 用途:更换电脑、重新打开 Codex/GPT 后的唯一接管入口。 > 用途:更换电脑、重新打开 Codex/GPT 后的唯一接管入口。
> 当前阶段:只做页面样式与视觉流程验收;不对接接口,不做功能验收。 > 当前阶段:只做页面样式与视觉流程验收;不对接接口,不做功能验收。
> 当前施工停点:六个模块基准 G01、T07、F01、R01、N01、M01,以及 T01、G03、F09 和 F10 已由用户明确通过并进入当前 H5 视觉冻结。T08、F05、F06 仍按已记录的局部通过范围保持 `[~] ⚠`。F08 的正常、空相册、照片预览和相册失效四种 H5 视觉状态已由用户确认合适,但因真实入口、跳转、接口和 Android 未完成继续保持 `[~] ⚠`。F10 已成为专属视频待开放状态页,用户明确通过 412×915 候选;按钮真实返回 F01,静态合同、运行时跳转和四档响应式 smoke 通过,但 F01 真实入口、视频接口、列表/失败数据态和 Android 不在本次结论内。下一步审核 R02;其余页面保持原标记。最新事实与换机提示见 `docs/夜间批量收敛交接_2026-07-20.md`。 > 当前施工停点:全项目文档流迁移的源码与定位合同已完成收敛,正在做综合回归和用户复核交接。合同已覆盖包括 `relative` 在内的全部 `position` 声明,当前为零未白名单违规,且会拒绝失效白名单。旧“剩余 112 条”永久作废。现有 `[x]` 仅沿用迁移前用户明确通过的范围;本轮没有自动新增 `[x]`,也没有把自动截图对比冒充用户再次确认。下一步是向用户交付迁移后的同尺寸 H5 复核结论,再恢复其他页面审核。Android、接口和业务功能不在本次结论内。最新事实与换机提示见 `docs/夜间批量收敛交接_2026-07-20.md`。
## 0. 2026-07-20 最新接管覆盖说明 ## 0. 2026-07-20 最新接管覆盖说明
本节覆盖下方仍保留的 2026-07-19 历史停点;不要再从 G01 正常态重新开始。 本节覆盖下方仍保留的 2026-07-19 历史停点;不要再从 G01 正常态重新开始。
- 本次换机接管已只读确认:初始分支为 `main`,权威 HEAD 为 `01d246c 保存视觉审核与文档流迁移进度`,初始工作区为空。下方 `5a31a75` 和“56 项工作区变化”仅是该提交形成前的上传审计历史,不是当前未上传状态。
- 全项目定位合同的新初始基线为 35 个文件、390 条未白名单声明,其中 278 条为 `relative`;F10、T01 之后继续全面审查 R/N/M/F/G/T/A 页面、封存 A06 与共享组件,未停在单页修复。
- 当前 `tests/document-flow-position-contract.ps1` 扫描所有 style 块中的 `position` 声明,去除注释干扰,并对精确白名单同时检查“未登记定位”和“失效登记”;最新运行结果为 `DOCUMENT-FLOW-POSITION-CONTRACT PASS`,即普通内容违规为 0,白名单不存在失效项。
- 仍允许的定位只承担固定顶部/底部导航、视口背景、真实弹层、Toast、遮罩、底部弹层、全屏预览、媒体局部覆盖以及 T01 关系线/成员弹层等明确职责。普通根容器、Header/Content 包装、正文、表单、按钮和卡片均已改为文档流、flex、grid 或 grid 同单元叠放。
- 已在唯一 5173/9222 项目页做迁移批次的同尺寸 H5 对比;观察到的差异为无可见变化或栅格抗锯齿差异,但这不等于用户再次确认,也不扩大原 `[x]` 范围。A06 未注册路由,仅完成静态合同,不宣称运行截图;Android 未验证。
- 活动路由仍为 52 条,A02 已合并,A06 仍封存保留。 - 活动路由仍为 52 条,A02 已合并,A06 仍封存保留。
- 用户已明确通过 6 个模块基准、T01、G03、F09 及 F10G01、G03、T01、T07、F01、F09、F10、R01、N01、M01。`docs/验收规划.md` 应统计为 10 个 `[x]`、42 个 `[~]` - 用户已明确通过 6 个模块基准、T01、G03、F09 及 F10G01、G03、T01、T07、F01、F09、F10、R01、N01、M01。`docs/验收规划.md` 应统计为 10 个 `[x]`、42 个 `[~]`
- T01 已完成固定左侧代际栏返工,并在唯一 Chrome 项目页完成正常、加载、阅读提示、空、失败、节点选中及四档 H5 审核;用户已明确通过,现为 `[x]` 并冻结。状态联系表为 `docs/design/screens/runtime/2026-07-20/T01-state-contact-sheet.png`,响应式联系表为同目录 `T01-responsive-contact-sheet.png` - T01 已完成固定左侧代际栏返工,并在唯一 Chrome 项目页完成正常、加载、阅读提示、空、失败、节点选中及四档 H5 审核;用户已明确通过,现为 `[x]` 并冻结。状态联系表为 `docs/design/screens/runtime/2026-07-20/T01-state-contact-sheet.png`,响应式联系表为同目录 `T01-responsive-contact-sheet.png`
@@ -377,10 +382,33 @@ GPT 不操作 Git。用户切换电脑前应自行确认以下内容已纳入版
- 不要执行 `git add .` 后盲目提交;用户应先用 `git status --short --ignored` 确认缓存目录仍为 `!!` 忽略状态。 - 不要执行 `git add .` 后盲目提交;用户应先用 `git status --short --ignored` 确认缓存目录仍为 `!!` 忽略状态。
- 当前不重写 Git 历史、不执行 `git gc`、不删除 `.git` 内的孤立索引;若远端仍拒绝推送,应保留完整错误信息,再按远端限制单独排查,不能用 reset 或重建仓库规避。 - 当前不重写 Git 历史、不执行 `git gc`、不删除 `.git` 内的孤立索引;若远端仍拒绝推送,应保留完整错误信息,再按远端限制单独排查,不能用 reset 或重建仓库规避。
## 13. 2026-07-20 文档流迁移未完成阻断与清理记录 ## 13. 2026-07-20 文档流迁移收敛与清理记录
- 当前迁移**未完成**。原定位合同只扫描 `absolute/fixed/sticky`,漏掉了普通容器上不必要的 `position: relative`F10 已被用户现场指出,其他页面也必须按同一规则重新审计。后续不得再用“剩余 112 条”作为完整数字,必须先扩展合同覆盖所有 `position` 声明 - 原定位合同只扫描 `absolute/fixed/sticky`,漏掉了普通容器上不必要的 `position: relative`旧“剩余 112 条”无效。扩展后的初始基线为 35 个文件、390 条未白名单声明,其中 278 条为 `relative`
- 当前源码迁移已覆盖 F10、T01、共享组件及 R/N/M/F/G/T/A 全模块,封存 A06 也纳入静态扫描。全局合同已覆盖所有 `position` 声明,普通内容未白名单违规为 0,失效白名单为 0。
- 正确边界:固定顶部/底部导航、真实弹层、Toast、全屏预览等保留必要定位;普通页面根容器、Header/Content 包装、正文、表单、卡片和可用 grid 同单元叠放的装饰不得残留无意义 `relative/absolute` - 正确边界:固定顶部/底部导航、真实弹层、Toast、全屏预览等保留必要定位;普通页面根容器、Header/Content 包装、正文、表单、卡片和可用 grid 同单元叠放的装饰不得残留无意义 `relative/absolute`
- 当前停点:先返工 F10,再处理 T01 世代栏与成员节点;没有新的用户视觉确认,不新增 `[x]` - 当前停点:综合合同与 H5 回归完成后交给用户复核;没有新的用户视觉确认,不新增 `[x]`。迁移前冻结页若被用户认为存在可见变化,立即退回 `[~]` 重新审核
- 已知验证边界:`tests/t07-module-baseline-runtime-smoke.js` 会向已清理的 runtime 目录写短期截图,且其“清空搜索恢复全部成员”断言仍失败,因此未把它计入通过项,也未保留其生成物;覆盖 T03—T08 核心流程的 `tests/t03-t08-member-flow-runtime-smoke.js` 独立通过。A06 因路由封存不宣称运行态。没有新的 Android 证据。
- 本次用户明确授权清理无用文件。已确认 `unpackage/dist/` 为可再生成构建产物,`docs/design/screens/runtime/` 为短期诊断证据且不能作为长期唯一资料;两者可以删除。长期 `docs/design/screens/handoff/`、实际引用资产、有效测试与源码继续保留。 - 本次用户明确授权清理无用文件。已确认 `unpackage/dist/` 为可再生成构建产物,`docs/design/screens/runtime/` 为短期诊断证据且不能作为长期唯一资料;两者可以删除。长期 `docs/design/screens/handoff/`、实际引用资产、有效测试与源码继续保留。
- 2026-07-20 已完成或被总规划取代的 F05/F06/F08/F09/F10/R02/T01 单页 plan/spec 不再作为独立合同;有效结论以本记录、`docs/验收规划.md``2026-07-20-project-document-flow-migration` 总规划/设计为准。 - 2026-07-20 已完成或被总规划取代的 F05/F06/F08/F09/F10/R02/T01 单页 plan/spec 不再作为独立合同;有效结论以本记录、`docs/验收规划.md``2026-07-20-project-document-flow-migration` 总规划/设计为准。
## 14. 2026-07-20 全项目数据驱动布局审计
- 原容量扫描在纠正规则后发现 276 项固定高度、裁切、截断、固定 grid 行或固定大画布风险;现已收敛到零未解释风险和零失效白名单。103 个保留项全部精确到文件/选择器/风险类型,不能扩展成通配放行。
- 普通卡片、表单、状态说明和列表采用自然撑高;弹层长列表采用安全上限与内部滚动;T01 采用数据计算画布。完整五方案比较、选择和证据见 `docs/data-driven-layout-audit_2026-07-20.md`
- 压力证据:G01 弹层 2/6/12/50 条,T07/G09 各 50 条,F09 30 项,T01 10 世代×每代 12 人,3 倍中文长文案与 1.3 倍字号模拟,四档 H5 视口均通过相关 smoke。
- 二次截图复核已发现并修正 T01 世代栏/画布堆叠、G08/G11 textarea 固有高度、G09 状态与操作重叠;代表图只写 `%TEMP%\jiapuapp-runtime\data-driven-audit`,没有恢复 `docs/design/screens/runtime/`
- 当前停点:把有可见变化的 H5 候选交给用户复核。自动化和目视自审不能替用户维持或新增 `[x]`;没有新 Android、系统字体缩放或接口证据。
- 后续用户已明确说“通过”,确认 G05、T01、G08—G12、T03、T07、T08 的本轮数据驱动布局迁移候选。该确认使原已冻结的 T01、T07 继续保持 `[x]`;其余页面只通过本轮可见迁移复核,仍须按 `docs/验收规划.md` 补齐真实入口、适用状态和整页流程后才能标 `[x]`
## 15. 2026-07-20 全活动页面业务所有权收敛
- 用户确认需要全面审查,不只处理 F10;同时指出项目必须由数据驱动,且怀疑多个页面错误共用同一组件。审计确认 23 个 F/R/N/M 活动页面直接消费 `ModulePage.vue`,页面虽有不同标题,业务字段、状态和动作仍由同一目录配置模拟。
- 比较五个方案后采用“共享视觉原语、路由拥有业务内容”:活动页面继续复用背景、按钮、页头、弹层、Toast、加载和视觉资产,但每个路由自行拥有模拟数据、状态、校验、动作及导航。52 条活动路由对 `ModulePage.vue` / `page-catalog.js` 的引用已收敛为 0;历史文件按用户不删除约束保留。
- F03—F07、R03—R11、N02、M02—M10、T03—T08 已按业务拆分;F01 增加谱文、相册、礼仪、备忘、人物、贺礼、功德和视频入口,M01 增加推广/订单入口,N01 消息进入 N02R01/R02 与 F08/F09 建立创建/编辑上下文闭环。
- G01/G05/G06/G08—G12 改由家谱 ID、名称、成员身份和申请状态驱动;T01 根据 `generation`/`parentId` 计算成员位置,压力数据不再依赖手写 x/y;G03、G11、G12 的字段和容量边界同步补齐。
- 共享交互补齐原生按钮语义、对话框焦点/Escape、Toast live region、Loading 减少动态效果以及页头深链返回兜底。普通内容继续遵守全 position 和数据容量合同。
- 最新验证:52 路由加 2 共享组件 Vite 转换 54/54PowerShell 119 项中 118 项通过,唯一失败为既有且不可伪造的基础资产候选式命名;排除会恢复已清理 runtime 目录的旧 T07 baseline 后,浏览器 runtime smoke 26/26 通过。A01 预览已由代码管线重建,G06 一致性合同改读长期 handoff 证据;52 个活动页面均已捕获 360×800 当前图到 `%TEMP%` 并按模块复核。
- `repository-handoff-size-contract` 的当前阻塞根因只是 `.gitignore` 缺少合同要求的可再生成目录规则;补齐 `/tmp/``/unpackage/``/.vite/`、runtime 截图等忽略项后,合同在未删除长期资料、未放宽阈值的前提下通过:35 个截图、19.51MB、52 个索引链接。
- 仍未完成:接口、持久化、系统分享/上传权限、Android/HBuilderX、软键盘和真机性能。本轮页面存在可见变化,自动证据不能替用户维持或新增 `[x]`;醒来后的停点是用户查看全量 H5 候选并明确是否通过。
- 完整审计见 `docs/active-page-business-ownership-audit_2026-07-20.md`,执行计划见 `docs/superpowers/plans/2026-07-20-active-page-business-ownership.md`
+27 -14
View File
@@ -2,6 +2,10 @@
## 1. 当前准确停点 ## 1. 当前准确停点
- 换机接管的权威提交已更新为 `01d246c 保存视觉审核与文档流迁移进度`;接管时已确认 `main`、该 HEAD 与空工作区。文中后续出现的 `5a31a75`/56 项变化均为提交前历史审计,不是当前基线。
- 全项目文档流迁移已完成源码收敛并进入最终回归:新合同从 35 个文件、390 条未白名单声明(278 条 `relative`)收敛到零未白名单违规,并拒绝失效白名单。审查覆盖共享组件、全部模块页面与封存 A06,不只覆盖 F10。
- 必要的固定导航、真实弹层、Toast、遮罩、底部弹层、全屏预览、局部媒体覆盖和 T01 关系线等保留精确白名单;普通根容器、Header/Content、正文、表单、按钮和卡片已迁移到正常流、flex 或 grid。
- 本轮没有新增 `[x]`。同尺寸 H5 对比未发现语义性可见变化,但仍须由用户决定是否维持迁移前冻结结论;没有 Android 新证据。
- 当前阶段仍是 uni-app Android 项目的 H5 页面样式与视觉状态收敛,不对接接口。 - 当前阶段仍是 uni-app Android 项目的 H5 页面样式与视觉状态收敛,不对接接口。
- `pages.json` 注册 52 条活动路由;A02 已合并,A06 保持封存且源码、测试、文档和证据全部保留。 - `pages.json` 注册 52 条活动路由;A02 已合并,A06 保持封存且源码、测试、文档和证据全部保留。
- 用户已明确通过 6 个模块基准、T01 及 G03G01、G03、T01、T07、F01、R01、N01、M01。 - 用户已明确通过 6 个模块基准、T01 及 G03G01、G03、T01、T07、F01、R01、N01、M01。
@@ -26,15 +30,14 @@
### 普通 F/R/N/M 页面 ### 普通 F/R/N/M 页面
- `components/ModulePage.vue` 28 个普通任务页统一母版。 - `components/ModulePage.vue` 是普通任务页统一母版,现仅按不删除约束保留为历史文件;活动路由引用为 0
- 已删除重复的“页面编号 + 说明”介绍块,避免每页看起来像同一张编号展示页 - F03—F07、R03—R11、N02、M02—M10 已各自拥有业务数据、字段、状态、校验和导航,不再由 `page-catalog.js` 模拟页面差异
- 表单、列表、详情、时间轴、设置和状态仍是不同结构;加载统一使用 `AppLoading` - 页面继续共享 `AppLoading``AppButton``AppDialog``AppToast``PageHeader`、背景及模块视觉资产,不复制视觉原语
- 每个模块使用自己的长背景和 `module-content-frame.png` / `module-field-frame.png`,不再引用 G 模块旧不透明业务面板。
### 其他自定义页 ### 其他自定义页
- F02 改用 F01 所属 family 资产。 - F02 改用 F01 所属 family 资产。
- T03、T04—T06、T08 改用 T01/T07 的 tree 透明资产;`TreeMemberForm.vue` 继续统一 T04—T06 的表单与结果结构 - T03、T04—T06、T08 改用 T01/T07 的 tree 透明资产;T04—T06 已拆为添加亲属、编辑成员、维护关系三套独立业务结构,`TreeMemberForm.vue` 仅历史保留
- G03、G08—G12 改用 G01 的透明宣纸框、列表卷框和新 `g-form-field-frame.png` - G03、G08—G12 改用 G01 的透明宣纸框、列表卷框和新 `g-form-field-frame.png`
- G05、G06 保留其特殊总览/搜索结构;G01 和六个已通过基准没有被批量母版覆盖。 - G05、G06 保留其特殊总览/搜索结构;G01 和六个已通过基准没有被批量母版覆盖。
@@ -42,12 +45,12 @@
- 5173 H5 服务仍在运行。 - 5173 H5 服务仍在运行。
- 对 52 个活动页面和 2 个共享组件逐一请求 Vite 转换:`54/54` 返回 HTTP 200。 - 对 52 个活动页面和 2 个共享组件逐一请求 Vite 转换:`54/54` 返回 HTTP 200。
- PowerShell 静态契约总数 68:66 项通过,2 项为下述仓库治理失败 - PowerShell 静态契约总数 119:118 项通过;唯一失败为基础资产候选式命名。A01 新机预览已由代码管线重建,G06 一致性合同已改读长期 handoff 证据
- 页面与组件中的静态资产引用检查为 `0` 个缺失;活动页面和组件中的原生 `uni.showToast/showModal/showLoading/showActionSheet` 引用为 `0` - 页面与组件中的静态资产引用检查为 `0` 个缺失;活动页面和组件中的原生 `uni.showToast/showModal/showLoading/showActionSheet` 引用为 `0`
- `git diff --check` 退出码为 0;只有现有 LF/CRLF 转换提示,没有空白错误。 - `git diff --check` 退出码为 0;只有现有 LF/CRLF 转换提示,没有空白错误。
- `foundation-asset-audit.ps1` 仍因历史基础资产文件名含 `v1/v2/v3` 等候选式命名失败;这是既有已知问题,不能通过删除仍在使用的资产或放宽规则解决。 - `foundation-asset-audit.ps1` 仍因历史基础资产文件名含 `v1/v2/v3` 等候选式命名失败;这是既有已知问题,不能通过删除仍在使用的资产或放宽规则解决。
- `repository-handoff-size-contract.ps1` `docs/design/screens` 现有 647 张历史截图超过 35 张上限失败。用户明确要求不删除或清理截图,所以保留并如实记录 - `repository-handoff-size-contract.ps1` 已通过补齐可再生成目录忽略规则真实通过:35 个长期截图、19.51MB、52 个索引链接;没有删除长期资料或放宽阈值
- 9222 Chrome 调试端口当夜未监听,但有 Chrome 进程连接 5173。为遵守“已有项目页时不得新开第二个浏览器或标签页”,没有另启浏览器,也没有伪造运行时 smoke、截图或四档响应式证据 - 9222 后续已按用户许可开放并复用唯一项目页;排除会写回已清理 runtime 目录的旧 T07 baseline 后,26 项浏览器 runtime smoke 全部通过,52 页当前图已写入 `%TEMP%` 供复核
- 后续经用户明确许可重启 Chrome 并开放 9222;T01 复核期间始终复用同一项目标签页。5173 返回 HTTP 2009222 项目页面数为 1;T01 的 9 张原始状态/尺寸证据均核对为目标分辨率,两项聚焦契约通过。该后续结果不等于 Android 真机或其他候选页面已经验收。 - 后续经用户明确许可重启 Chrome 并开放 9222;T01 复核期间始终复用同一项目标签页。5173 返回 HTTP 2009222 项目页面数为 1;T01 的 9 张原始状态/尺寸证据均核对为目标分辨率,两项聚焦契约通过。该后续结果不等于 Android 真机或其他候选页面已经验收。
- Android/HBuilderX 真机或模拟器验证、系统字体放大、软键盘、4GB Android 长图性能仍未完成。 - Android/HBuilderX 真机或模拟器验证、系统字体放大、软键盘、4GB Android 长图性能仍未完成。
@@ -66,7 +69,7 @@
3. 保留所有现有修改、未跟踪与忽略文件;不要执行 add、commit、push、reset、checkout,不要使用 worktree 或多代理。 3. 保留所有现有修改、未跟踪与忽略文件;不要执行 add、commit、push、reset、checkout,不要使用 worktree 或多代理。
4. 检查 5173 和 9222。若现有 Chrome 项目页仍存在,只能复用;若需要为了 CDP 重启 Chrome,必须先让用户明确确认关闭现有窗口后再启动唯一审批窗口。 4. 检查 5173 和 9222。若现有 Chrome 项目页仍存在,只能复用;若需要为了 CDP 重启 Chrome,必须先让用户明确确认关闭现有窗口后再启动唯一审批窗口。
5. T01 已完成 412×915 正常树态、加载、阅读提示、空、失败、节点选择和四档响应式审核,并由用户明确通过;不要重新打开返工,除非用户指出新问题或共享视觉变更触发冻结回归。 5. T01 已完成 412×915 正常树态、加载、阅读提示、空、失败、节点选择和四档响应式审核,并由用户明确通过;不要重新打开返工,除非用户指出新问题或共享视觉变更触发冻结回归。
6. 页面视觉审核暂时让位于全项目文档流迁移。用户指出原合同漏扫普通容器上的 `position: relative`;当前先返工 F10 的无意义定位,再处理 T01 世代栏与成员节点,之后才恢复模块抽查 6. 全项目文档流、数据驱动容量和 52 路由业务所有权均已收敛;当前直接把全量 H5 候选交给用户复核,不再恢复旧的通用 `ModulePage` 逐页审核路线。自动化通过不能替用户维持或新增 `[x]`
7. 只有用户明确通过的页面才能标 `[x]`。没有 CDP/截图/Android 证据时不得宣称最终完成。 7. 只有用户明确通过的页面才能标 `[x]`。没有 CDP/截图/Android 证据时不得宣称最终完成。
## 5. Git 上传前注意 ## 5. Git 上传前注意
@@ -74,9 +77,9 @@
- 用户换机时只能通过 Git,不能复制项目目录。换机前必须由用户自行把全部应保留修改、未跟踪文件和删除记录提交并成功推送;否则新电脑 clone/pull 后必然缺失当前工作区状态。 - 用户换机时只能通过 Git,不能复制项目目录。换机前必须由用户自行把全部应保留修改、未跟踪文件和删除记录提交并成功推送;否则新电脑 clone/pull 后必然缺失当前工作区状态。
- 当前工作区本来就包含大量已修改和未跟踪文件,它们是用户要求保留的页面、测试、文档、截图、母版和候选资产,不属于可擅自清理的垃圾。 - 当前工作区本来就包含大量已修改和未跟踪文件,它们是用户要求保留的页面、测试、文档、截图、母版和候选资产,不属于可擅自清理的垃圾。
- 本轮没有执行任何 Git 暂存、提交、推送、重置或检出操作。 - 本轮没有执行任何 Git 暂存、提交、推送、重置或检出操作。
- 2026-07-20 最新只读复核:分支为 `main`HEAD 为 `5a31a75 Review changes batch 6 of 6``git status --short` 有 56 项,包括 25 项修改、4 项删除和 27 项未跟踪内容 - 上传前历史只读复核曾记录:分支为 `main`HEAD 为 `5a31a75 Review changes batch 6 of 6`,工作区有 56 项变化。上述内容已经合并进权威提交 `01d246c 保存视觉审核与文档流迁移进度`;本次换机接管已从该提交的空工作区开始
- 上传前由用户自行审阅 `git status --short``git diff --check`;不要让新的 GPT 自动删除未跟踪文件来缩小提交。 - 上传前由用户自行审阅 `git status --short``git diff --check`;不要让新的 GPT 自动删除未跟踪文件来缩小提交。
- `unpackage/``docs/design/screens/runtime/` 已按用户明确授权清理,不需要上传;长期 `docs/design/screens/handoff/`、实际运行资产、源码、测试和交接文档必须进入远端。既有 `repository-handoff-size-contract` 失败不得通过放宽阈值或继续删除长期资料伪造通过。 - `unpackage/``docs/design/screens/runtime/` 已按用户明确授权清理,不需要上传;长期 `docs/design/screens/handoff/`、实际运行资产、源码、测试和交接文档必须进入远端。`repository-handoff-size-contract` 已在不放宽阈值、不删除长期资料的前提下通过。
- 上传前只读审计未发现常见 Git 体积硬阻断:远端 `origin` 和上游 `origin/main` 已配置,候选内容没有超过 20MiB/100MiB 的单文件,最大单文件约 6.51MiB,未跟踪文件合计约 17.81MiB;关键交接文件均未被忽略,`git diff --check``git fsck --full` 均为 0。该结论只证明本地提交候选结构可上传,网络、认证和远端配额仍必须以用户实际 push 结果为准。 - 上传前只读审计未发现常见 Git 体积硬阻断:远端 `origin` 和上游 `origin/main` 已配置,候选内容没有超过 20MiB/100MiB 的单文件,最大单文件约 6.51MiB,未跟踪文件合计约 17.81MiB;关键交接文件均未被忽略,`git diff --check``git fsck --full` 均为 0。该结论只证明本地提交候选结构可上传,网络、认证和远端配额仍必须以用户实际 push 结果为准。
## 6. 可直接发给新 GPT 的提示词 ## 6. 可直接发给新 GPT 的提示词
@@ -84,15 +87,25 @@
```text ```text
请全程使用中文。不要根据旧对话猜状态,不使用多代理或 worktree,不执行 git add、commit、push、reset、checkout,也不要删除、覆盖或清理任何现有修改、未跟踪文件、测试、文档、截图、母版和候选资产。 请全程使用中文。不要根据旧对话猜状态,不使用多代理或 worktree,不执行 git add、commit、push、reset、checkout,也不要删除、覆盖或清理任何现有修改、未跟踪文件、测试、文档、截图、母版和候选资产。
先完整阅读 AGENTS.md、docs/交接记录.md、docs/验收规划.md、docs/夜间批量收敛交接_2026-07-20.md,然后运行 git branch --show-current、git log -1 --oneline、git status --short、git status --short --ignored。 远端 `main` 的接管权威提交为 `01d246c 保存视觉审核与文档流迁移进度`。先完整阅读 AGENTS.md、docs/交接记录.md、docs/验收规划.md、docs/夜间批量收敛交接_2026-07-20.md 及文档流迁移 plan/spec,然后运行 git branch --show-current、git log -1 --oneline、git status --short、git status --short --ignored;提交不符或初始工作区不空立即停止。文中 `5a31a75`/56 项变化只是上传前历史审计
当前仍是 uni-app Android 家谱项目的 H5 视觉阶段,不接接口。用户已明确通过 G01、G03、T01、T07、F01、F09、F10、R01、N01、M01 等当前 H5 视觉候选;其余页面以 `docs/验收规划.md` 为准,不能根据本提示词自行新增 `[x]`。Android/HBuilderX、接口和业务功能仍未完成。 当前仍是 uni-app Android 家谱项目的 H5 视觉阶段,不接接口。用户已明确通过 G01、G03、T01、T07、F01、F09、F10、R01、N01、M01 等当前 H5 视觉候选;其余页面以 `docs/验收规划.md` 为准,不能根据本提示词自行新增 `[x]`。Android/HBuilderX、接口和业务功能仍未完成。
当前最高优先级不是继续逐页视觉审核,而是完成全项目文档流迁移。原定位合同扫描 `absolute/fixed/sticky`,漏掉普通容器上无意义的 `position: relative`,所以此前“剩余 112 条”不能作为完整数字。先扩展合同覆盖所有 `position`,再返工 F10,随后处理 T01 世代栏与成员节点。顶部和底部导航使用必要的 `fixed`;普通根容器、Header/Content 包装、文本、表单和卡片不得滥用定位;真实弹窗、Toast、全屏预览和确有必要的局部装饰层可以保留精确白名单。 全项目文档流迁移源码已完成收敛,当前停点是综合回归与用户复核交接。合同扫描包括 `relative` 在内的全部 `position` 声明,从 35 个文件、390 条未白名单声明(278 条 `relative`)收敛到零未白名单违规,并拒绝失效白名单;旧“剩余 112 条”无效。审查覆盖共享组件、R/N/M/F/G/T/A 全模块和封存 A06,不只覆盖 F10。顶部和底部导航使用必要的 `fixed`;普通根容器、Header/Content、文本、表单、按钮和卡片不得滥用定位;真实弹窗、Toast、遮罩、底部弹层、全屏预览、必要媒体覆盖和关系线只保留精确白名单。
换机只能通过 Git。开始工作前先确认用户已把上一台电脑的全部应保留修改、未跟踪文件和删除记录提交并推送;然后核对当前分支、HEAD、`git status --short` 和 `git status --short --ignored`。若远端没有上述 2026-07-20 文档流迁移文件、测试、F08 资产或交接更新,立即停止,不能用旧 HEAD 猜状态。 换机只能通过 Git。开始工作前先确认用户已把上一台电脑的全部应保留修改、未跟踪文件和删除记录提交并推送;然后核对当前分支、HEAD、`git status --short` 和 `git status --short --ignored`。若远端没有上述 2026-07-20 文档流迁移文件、测试、F08 资产或交接更新,立即停止,不能用旧 HEAD 猜状态。
检查 5173 和 9222。如果已有 Chrome 项目页,只能复用同一个窗口和标签页;若必须重启以开放 9222,先征得我的明确同意。只有用户明确说“通过”才能新增 `[x]`;定位迁移造成可见变化时,冻结页面必须重新对比,不能用自动测试代替用户确认。 检查 5173 和 9222。如果已有 Chrome 项目页,只能复用同一个窗口和标签页;若必须重启以开放 9222,先征得我的明确同意。只有用户明确说“通过”才能新增 `[x]`;定位迁移造成可见变化时,冻结页面必须重新对比,不能用自动测试代替用户确认。
请先简要复述当前停点,再开始检查;没有真实证据不得宣称完成。已知 foundation-asset-auditrepository-handoff-size-contract 的失败不能靠放宽阈值或删除用户文件处理 请先简要复述当前停点,再开始检查;没有真实证据不得宣称完成。`foundation-asset-audit` 的失败不能靠放宽规则或删除实际资产处理;`repository-handoff-size-contract` 当前已真实通过
``` ```
## 7. 2026-07-20 夜间全量完成记录
- 23 个原 `ModulePage` 活动消费者已全部改为路由专属业务页面,52 条活动路由中直接引用 `ModulePage.vue` / `page-catalog.js` 的数量为 0。历史组件和目录按不删除约束保留。
- 原 22 个流程断点均已补齐本地 H5 入口、必要路由参数和返回方向;接口、持久化和系统权限仍延期到功能阶段。
- 全量验证为 Vite 54/54、浏览器 runtime smoke 26/26PowerShell 119 项中 118 项通过,唯一失败为基础资产候选式命名。未运行会写回 `docs/design/screens/runtime/` 的旧 T07 baseline。
- 52 个活动页面逐页抓取 360×800 当前图到 `%TEMP%\jiapuapp-runtime\all-pages-2026-07-20`,并生成 A/G/T、F、R、N/M 四张联系表;与长期同模块 360×800 候选图比较后未发现横向溢出、明显裁切、错误共用业务母版或主动作不可达。
- Chrome 9222 和 Vite 5173 均在运行,复核结束时只保留 1 个 localhost 项目页。`unpackage/``docs/design/screens/runtime/` 未恢复。
- 本轮不接接口、不宣称 Android/HBuilderX 完成;页面发生可见变化,用户醒来后仍需明确说“通过”才能更新 `[x]`
- 审计报告:`docs/active-page-business-ownership-audit_2026-07-20.md`
+21 -8
View File
@@ -17,6 +17,9 @@
### 2026-07-20 夜间批量收敛说明 ### 2026-07-20 夜间批量收敛说明
- 全项目文档流迁移已按新口径完成源码收敛:合同扫描包括 `relative` 在内的全部 `position` 声明;从 35 个文件、390 条未白名单声明(其中 278 条 `relative`)收敛到零未白名单违规,并新增失效白名单检查。旧“剩余 112 条”不再使用。
- 迁移范围不是只有 F10:已覆盖共享组件、R/N/M/F/G/T/A 全模块和封存 A06。普通根容器、Header/Content、正文、表单、按钮、卡片改用正常流、flex、grid 或 grid 同单元叠放;固定导航、真实弹层、Toast、遮罩、底部弹层、全屏预览、媒体覆盖、关系线等必要职责才进入精确白名单。
- 本轮同尺寸 H5 回归未观察到迁移造成的语义性可见变化,但自动对比不能代替用户确认,因此不自动新增 `[x]`,也不扩大已有 `[x]` 的含义。A06 仅有静态合同证据;Android/HBuilderX、接口与真实业务状态仍未完成。
- 用户已明确授权:其余未验收页面不再逐页提问,统一按 G01、T07、F01、R01、N01、M01 六个已确认基准推进;T01 已在后续人工审核中明确通过。 - 用户已明确授权:其余未验收页面不再逐页提问,统一按 G01、T07、F01、R01、N01、M01 六个已确认基准推进;T01 已在后续人工审核中明确通过。
- 本轮已将 F/R/N/M 的 28 个普通任务页统一到各自模块母版,删除重复的页面编号介绍块;表单、列表、详情、时间轴、设置和状态页仍保留不同业务结构。 - 本轮已将 F/R/N/M 的 28 个普通任务页统一到各自模块母版,删除重复的页面编号介绍块;表单、列表、详情、时间轴、设置和状态页仍保留不同业务结构。
- F02、T03—T06、T08、G03、G08—G12 已移除跨模块旧不透明业务面板引用,改用所属模块或 G01/T07 基准的真实透明资产。 - F02、T03—T06、T08、G03、G08—G12 已移除跨模块旧不透明业务面板引用,改用所属模块或 G01/T07 基准的真实透明资产。
@@ -294,15 +297,9 @@ G01、G03、G05、G06、G08、G09、G10、G11、G12 共 9 个活动 G 页面统
## 6. 当前视觉流程断点与后续功能依赖 ## 6. 当前视觉流程断点与后续功能依赖
除 APP 启动页 A01 外,目前有 22 个最终页面没有被其他页面真实跳转到: 2026-07-20 全活动页面业务所有权收敛后,原 22 个流程断点已补齐本地 H5 入口与返回路径:T08 由 T03 的人物状态进入;F05/F06 由 F04 进入,F08/F09 由 F07/F08 进入,F10 由 F01 进入;R01/R03/R05/R10/R11 由 F01 的记录入口进入,R02 由 R01 进入,R04 由 R03 进入,R06/R07 由 R05/R06 进入,R08/R09 由人物详情进入;N02 由 N01 消息卡进入;M04/M05 由 M03 进入,M07 由帮助/反馈入口进入,M08/M09 由 M01 进入。
- T08 上述结论只证明本地模拟数据阶段存在合理入口、路由参数和返回方向。接口返回、持久化、系统分享/上传权限以及 Android 返回栈仍须在后续功能阶段验证;直接输入路由仍只能作为开发状态定位,不可替代真实入口回归
- F05、F06、F08、F09、F10。
- R01、R02、R03、R04、R06、R07、R08、R09、R11。
- N02。
- M04、M05、M07、M08、M09。
这些页面必须先确定合理的上游入口和返回位置,再做用户流程验收。直接输入路由只能用于内部开发定位,不能作为用户流程证据。
后续功能验收必须覆盖:A01 微信登录、A01/A04/A05 公共行为验证、G06 邀请码验证、G08 邀请码直接加入、角色/权限清单和功能开关。它们不属于本文件的完成门槛,也不得因为页面样式通过而宣称真实数据链路通过。 后续功能验收必须覆盖:A01 微信登录、A01/A04/A05 公共行为验证、G06 邀请码验证、G08 邀请码直接加入、角色/权限清单和功能开关。它们不属于本文件的完成门槛,也不得因为页面样式通过而宣称真实数据链路通过。
@@ -349,3 +346,19 @@ G01、G03、G05、G06、G08、G09、G10、G11、G12 共 9 个活动 G 页面统
- 四尺寸检查、Android 真机复核、受影响测试、运行时 smoke、真实截图和 `git diff --check` 均有最新证据。 - 四尺寸检查、Android 真机复核、受影响测试、运行时 smoke、真实截图和 `git diff --check` 均有最新证据。
- 页面样式验收阶段未对接业务接口、未修改接口契约、未使用截图作为运行资产;后续另建功能验收规则验证真实数据链路。 - 页面样式验收阶段未对接业务接口、未修改接口契约、未使用截图作为运行资产;后续另建功能验收规则验证真实数据链路。
- 未执行 `git add`、commit、push、reset、checkout 或上传。 - 未执行 `git add`、commit、push、reset、checkout 或上传。
## 10. 2026-07-20 数据驱动布局复核停点
- 全项目容量合同已覆盖所有 Vue 样式块,当前零未解释风险、零失效白名单;定位合同继续保持通过。
- 已完成 50 条普通列表、50 条弹层、30 项媒体、10×12 世系树、三倍长文案、1.3 倍字号模拟和四档 H5 运行压力。
- 用户已在本轮明确说“通过”,确认 G05、T01、G08—G12、T03、T07、T08 的数据驱动布局迁移候选。T01、T07 恢复并维持视觉冻结;其余页面只确认本轮迁移后的 H5 可见效果,仍因真实入口、适用状态或整页流程尚未全部验收而保持 `[~]`,不能把本次确认扩大为整页功能通过。
- 本阶段仍不接接口、不宣称 Android/HBuilderX 或系统级字体缩放完成;A06 继续封存。
## 11. 2026-07-20 全活动页面业务所有权复核停点
- 已按五方案比较选择“共享视觉原语、路由拥有业务内容”。52 条活动路由对 `ModulePage.vue` / `page-catalog.js` 的引用为 0;历史文件按不删除约束保留但不再承担活动业务。
- F03—F07、R03—R11、N02、M02—M10、T03—T08 已拆为各自业务页面;F/G/T/A 与共享组件也完成逐页复核。页面数据、状态、校验和导航不再由一个目录配置模拟。
- 52 个活动页面已逐页捕获 360×800 当前图到 `%TEMP%`,并按模块与长期同尺寸候选联系表复核;四档响应式、长文本、1.3 倍字号和大列表由运行时 smoke/容量合同覆盖。
- 最新证据:Vite 54/54PowerShell 119 项中 118 项通过,唯一失败为明确保留的基础资产候选式命名;浏览器运行时 smoke 26/26 通过。A01 新机预览已由代码管线重建,G06 一致性合同改读长期 handoff 证据;旧 T07 baseline 因会恢复已清理 runtime 目录而未运行、未计数。
- 本轮发生可见变化的页面不能凭自动审计维持或新增 `[x]`。当前代码可作为全量 H5 复核候选,但最终视觉冻结仍待用户醒来后明确确认;接口、Android/HBuilderX、软键盘、系统权限和持久化不在本次完成结论内。
- 完整五方案、页面清单和证据见 `docs/active-page-business-ownership-audit_2026-07-20.md`
+32 -50
View File
@@ -374,19 +374,17 @@ const prepareAgreement = () => showFeedback("协议页面准备中");
<style scoped lang="scss"> <style scoped lang="scss">
.auth-page { .auth-page {
position: relative;
box-sizing: border-box; box-sizing: border-box;
width: 100%; width: 100%;
height: 100dvh; height: 100dvh;
min-height: 100%; min-height: 100%;
overflow-x: hidden;
overflow-y: auto; overflow-y: auto;
background: #f7f0e5; background: #f7f0e5;
color: #493323; color: #493323;
} }
.page-canvas { .page-canvas {
position: relative; display: grid;
box-sizing: border-box; box-sizing: border-box;
width: 100%; width: 100%;
min-height: calc(1665rpx + env(safe-area-inset-bottom)); min-height: calc(1665rpx + env(safe-area-inset-bottom));
@@ -394,28 +392,28 @@ const prepareAgreement = () => showFeedback("协议页面准备中");
} }
.page-backdrop { .page-backdrop {
position: absolute;
z-index: 0; z-index: 0;
inset: 0 0 auto; grid-area: 1 / 1;
align-self: start;
width: 100%; width: 100%;
height: 1665rpx; height: 1665rpx;
pointer-events: none; pointer-events: none;
} }
.brand-seal { .brand-seal {
position: absolute;
z-index: 1; z-index: 1;
top: 64rpx; grid-area: 1 / 1;
left: 50%; align-self: start;
justify-self: center;
width: 184rpx; width: 184rpx;
height: 221rpx; height: 221rpx;
transform: translateX(-50%); margin-top: 64rpx;
pointer-events: none; pointer-events: none;
} }
.login-content { .login-content {
position: relative;
z-index: 2; z-index: 2;
grid-area: 1 / 1;
box-sizing: border-box; box-sizing: border-box;
width: 100%; width: 100%;
padding: 480rpx 64rpx 34rpx; padding: 480rpx 64rpx 34rpx;
@@ -426,7 +424,7 @@ const prepareAgreement = () => showFeedback("协议页面准备中");
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
height: 142rpx; min-height: 142rpx;
} }
.login-title { .login-title {
@@ -452,7 +450,6 @@ const prepareAgreement = () => showFeedback("协议页面准备中");
} }
.login-tab { .login-tab {
position: relative;
display: flex; display: flex;
flex: 1; flex: 1;
align-items: center; align-items: center;
@@ -464,30 +461,21 @@ const prepareAgreement = () => showFeedback("协议页面准备中");
} }
.login-tab:first-child::before { .login-tab:first-child::before {
position: absolute; content: none;
top: 50%; }
right: 0; .login-tab:first-child {
width: 1rpx; border-right: 1rpx solid rgba(176, 120, 52, 0.58);
height: 36rpx;
background: rgba(176, 120, 52, 0.58);
content: "";
transform: translateY(-50%);
} }
.login-tab.active { .login-tab.active {
background: linear-gradient(#ad160d, #ad160d) center bottom /
calc(100% - 116rpx) 5rpx no-repeat;
color: #a9160d; color: #a9160d;
font-weight: 700; font-weight: 700;
} }
.login-tab.active::after { .login-tab.active::after {
position: absolute; content: none;
right: 58rpx;
bottom: 0;
left: 58rpx;
height: 5rpx;
border-radius: 5rpx;
background: #ad160d;
content: "";
} }
.form-content { .form-content {
@@ -518,7 +506,7 @@ const prepareAgreement = () => showFeedback("协议页面准备中");
.auth-input { .auth-input {
flex: 1; flex: 1;
min-width: 0; min-width: 0;
height: 104rpx; min-height: 104rpx;
color: #493323; color: #493323;
font-size: 40rpx; font-size: 40rpx;
} }
@@ -550,7 +538,6 @@ const prepareAgreement = () => showFeedback("协议页面准备中");
min-height: 88rpx; min-height: 88rpx;
color: #a9160d; color: #a9160d;
font-size: 32rpx; font-size: 32rpx;
white-space: nowrap;
} }
.form-secondary-row { .form-secondary-row {
@@ -570,28 +557,24 @@ const prepareAgreement = () => showFeedback("协议页面准备中");
.login-submit, .login-submit,
.wechat-login { .wechat-login {
position: relative; display: grid;
display: flex; place-items: center;
align-items: center;
justify-content: center;
overflow: hidden;
} }
.login-submit { .login-submit {
height: 92rpx; min-height: 92rpx;
} }
.button-skin { .button-skin {
position: absolute; grid-area: 1 / 1;
inset: 0;
width: 100%; width: 100%;
height: 100%; height: 100%;
pointer-events: none; pointer-events: none;
} }
.login-submit__copy { .login-submit__copy {
position: relative;
z-index: 1; z-index: 1;
grid-area: 1 / 1;
color: #fffaf1; color: #fffaf1;
font-size: 52rpx; font-size: 52rpx;
letter-spacing: 8rpx; letter-spacing: 8rpx;
@@ -600,7 +583,7 @@ const prepareAgreement = () => showFeedback("协议页面准备中");
.other-login-divider { .other-login-divider {
display: flex; display: flex;
align-items: center; align-items: center;
height: 100rpx; min-height: 100rpx;
color: #9f6a27; color: #9f6a27;
font-size: 32rpx; font-size: 32rpx;
white-space: nowrap; white-space: nowrap;
@@ -625,16 +608,16 @@ const prepareAgreement = () => showFeedback("协议页面准备中");
} }
.wechat-login { .wechat-login {
height: 100rpx; min-height: 100rpx;
color: #493323; color: #493323;
font-size: 40rpx; font-size: 40rpx;
letter-spacing: 3rpx; letter-spacing: 3rpx;
} }
.wechat-login__content { .wechat-login__content {
position: relative;
z-index: 1; z-index: 1;
display: flex; display: flex;
grid-area: 1 / 1;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
} }
@@ -689,7 +672,6 @@ const prepareAgreement = () => showFeedback("协议页面准备中");
flex: 0 1 auto; flex: 0 1 auto;
align-items: center; align-items: center;
min-width: 0; min-width: 0;
white-space: nowrap;
} }
.agreement-link { .agreement-link {
@@ -750,7 +732,6 @@ const prepareAgreement = () => showFeedback("协议页面准备中");
} }
.feedback-toast__copy { .feedback-toast__copy {
position: relative;
z-index: 1; z-index: 1;
padding: 16rpx 36rpx; padding: 16rpx 36rpx;
color: #5c4330; color: #5c4330;
@@ -770,26 +751,27 @@ const prepareAgreement = () => showFeedback("协议页面准备中");
} }
.verification-dialog { .verification-dialog {
position: relative; display: grid;
width: 620rpx; width: 620rpx;
height: 520rpx; min-height: 520rpx;
max-height: calc(100vh - 80rpx);
} }
.verification-dialog__skin { .verification-dialog__skin {
position: absolute; grid-area: 1 / 1;
inset: 0;
width: 100%; width: 100%;
height: 100%; height: 100%;
} }
.verification-dialog__content { .verification-dialog__content {
position: relative;
z-index: 1; z-index: 1;
display: flex; display: flex;
grid-area: 1 / 1;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
box-sizing: border-box; box-sizing: border-box;
height: 100%; min-height: 100%;
overflow-y: auto;
padding: 90rpx 64rpx 54rpx; padding: 90rpx 64rpx 54rpx;
} }
+25 -29
View File
@@ -229,18 +229,16 @@ const submitRegister = () => {
<style scoped lang="scss"> <style scoped lang="scss">
.auth-page { .auth-page {
position: relative;
width: 100%; width: 100%;
height: 100dvh; height: 100dvh;
min-height: 100%; min-height: 100%;
overflow-x: hidden;
overflow-y: auto; overflow-y: auto;
background: #f7f0e4; background: #f7f0e4;
color: #493323; color: #493323;
} }
.page-canvas { .page-canvas {
position: relative; display: grid;
box-sizing: border-box; box-sizing: border-box;
width: 100%; width: 100%;
min-height: calc(1665rpx + env(safe-area-inset-bottom)); min-height: calc(1665rpx + env(safe-area-inset-bottom));
@@ -248,48 +246,49 @@ const submitRegister = () => {
} }
.page-backdrop { .page-backdrop {
position: absolute; grid-area: 1 / 1;
inset: 0 0 auto; align-self: start;
width: 100%; width: 100%;
height: 1665rpx; height: 1665rpx;
pointer-events: none; pointer-events: none;
} }
.brand-seal { .brand-seal {
position: absolute;
z-index: 1; z-index: 1;
top: 64rpx; grid-area: 1 / 1;
left: 50%; align-self: start;
justify-self: center;
width: 184rpx; width: 184rpx;
height: 221rpx; height: 221rpx;
transform: translateX(-50%); margin-top: 64rpx;
pointer-events: none; pointer-events: none;
} }
.register-content { .register-content {
position: relative;
z-index: 2; z-index: 2;
grid-area: 1 / 1;
box-sizing: border-box; box-sizing: border-box;
padding: 430rpx 64rpx 46rpx; padding: 430rpx 64rpx 46rpx;
} }
.page-heading { .page-heading {
position: relative; display: grid;
display: flex; grid-template-rows: auto auto auto;
flex-direction: column; justify-items: center;
align-items: center;
min-height: 154rpx; min-height: 154rpx;
} }
.back-button { .back-button {
position: absolute;
top: 10rpx;
left: 0;
display: flex; display: flex;
grid-row: 1 / -1;
grid-column: 1;
align-self: start;
justify-self: start;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
width: 88rpx; width: 88rpx;
height: 88rpx; height: 88rpx;
margin-top: 10rpx;
} }
.back-button__icon { .back-button__icon {
@@ -299,6 +298,7 @@ const submitRegister = () => {
} }
.page-title { .page-title {
grid-row: 1;
color: #9f170f; color: #9f170f;
font-size: 66rpx; font-size: 66rpx;
font-weight: 700; font-weight: 700;
@@ -306,6 +306,7 @@ const submitRegister = () => {
} }
.page-subtitle { .page-subtitle {
grid-row: 2;
margin-top: 12rpx; margin-top: 12rpx;
color: #806c58; color: #806c58;
font-size: 25rpx; font-size: 25rpx;
@@ -313,6 +314,7 @@ const submitRegister = () => {
} }
.register-divider { .register-divider {
grid-row: 3;
width: 300rpx; width: 300rpx;
height: 50rpx; height: 50rpx;
filter: brightness(0.68) saturate(1.5) contrast(1.2); filter: brightness(0.68) saturate(1.5) contrast(1.2);
@@ -342,7 +344,7 @@ const submitRegister = () => {
.auth-input { .auth-input {
flex: 1; flex: 1;
min-width: 0; min-width: 0;
height: 96rpx; min-height: 96rpx;
color: #3f2c1d; color: #3f2c1d;
font-size: 29rpx; font-size: 29rpx;
} }
@@ -368,25 +370,21 @@ const submitRegister = () => {
} }
.register-submit { .register-submit {
position: relative; display: grid;
display: flex; place-items: center;
align-items: center; min-height: 104rpx;
justify-content: center;
height: 104rpx;
margin-top: 36rpx; margin-top: 36rpx;
overflow: hidden;
} }
.register-submit__skin { .register-submit__skin {
position: absolute; grid-area: 1 / 1;
inset: 0;
width: 100%; width: 100%;
height: 100%; height: 100%;
} }
.register-submit__content { .register-submit__content {
position: relative;
z-index: 1; z-index: 1;
grid-area: 1 / 1;
color: #fffaf0; color: #fffaf0;
font-size: 39rpx; font-size: 39rpx;
letter-spacing: 6rpx; letter-spacing: 6rpx;
@@ -417,7 +415,6 @@ const submitRegister = () => {
.agreement-copy { .agreement-copy {
display: flex; display: flex;
align-items: center; align-items: center;
white-space: nowrap;
} }
.agreement-link { .agreement-link {
@@ -490,7 +487,6 @@ const submitRegister = () => {
} }
.feedback-toast__copy { .feedback-toast__copy {
position: relative;
z-index: 1; z-index: 1;
padding: 16rpx 36rpx; padding: 16rpx 36rpx;
color: #5c4330; color: #5c4330;
+41 -49
View File
@@ -157,7 +157,11 @@
mode="aspectFit" mode="aspectFit"
/> />
<view class="success-dialog__content"> <view class="success-dialog__content">
<text class="success-mark"></text> <image
class="success-mark"
src="/static/assets/foundation/transparent/brand-seal.png"
mode="aspectFit"
/>
<text class="success-title">密码已重设</text> <text class="success-title">密码已重设</text>
<text class="success-copy">请返回登录页使用新密码登录</text> <text class="success-copy">请返回登录页使用新密码登录</text>
<view <view
@@ -259,18 +263,16 @@ const submitReset = () => {
<style scoped lang="scss"> <style scoped lang="scss">
.auth-page { .auth-page {
position: relative;
width: 100%; width: 100%;
height: 100dvh; height: 100dvh;
min-height: 100%; min-height: 100%;
overflow-x: hidden;
overflow-y: auto; overflow-y: auto;
background: #f7f0e4; background: #f7f0e4;
color: #493323; color: #493323;
} }
.page-canvas { .page-canvas {
position: relative; display: grid;
box-sizing: border-box; box-sizing: border-box;
width: 100%; width: 100%;
min-height: calc(1665rpx + env(safe-area-inset-bottom)); min-height: calc(1665rpx + env(safe-area-inset-bottom));
@@ -278,48 +280,49 @@ const submitReset = () => {
} }
.page-backdrop { .page-backdrop {
position: absolute; grid-area: 1 / 1;
inset: 0 0 auto; align-self: start;
width: 100%; width: 100%;
height: 1665rpx; height: 1665rpx;
pointer-events: none; pointer-events: none;
} }
.brand-seal { .brand-seal {
position: absolute;
z-index: 1; z-index: 1;
top: 64rpx; grid-area: 1 / 1;
left: 50%; align-self: start;
justify-self: center;
width: 184rpx; width: 184rpx;
height: 221rpx; height: 221rpx;
transform: translateX(-50%); margin-top: 64rpx;
pointer-events: none; pointer-events: none;
} }
.reset-content { .reset-content {
position: relative;
z-index: 2; z-index: 2;
grid-area: 1 / 1;
box-sizing: border-box; box-sizing: border-box;
padding: 430rpx 64rpx 46rpx; padding: 430rpx 64rpx 46rpx;
} }
.page-heading { .page-heading {
position: relative; display: grid;
display: flex; grid-template-rows: auto auto auto;
flex-direction: column; justify-items: center;
align-items: center;
min-height: 154rpx; min-height: 154rpx;
} }
.back-button { .back-button {
position: absolute;
top: 10rpx;
left: 0;
display: flex; display: flex;
grid-row: 1 / -1;
grid-column: 1;
align-self: start;
justify-self: start;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
width: 88rpx; width: 88rpx;
height: 88rpx; height: 88rpx;
margin-top: 10rpx;
} }
.back-button__icon { .back-button__icon {
@@ -328,18 +331,21 @@ const submitReset = () => {
transform: scaleX(-1); transform: scaleX(-1);
} }
.page-title { .page-title {
grid-row: 1;
color: #9f170f; color: #9f170f;
font-size: 66rpx; font-size: 66rpx;
font-weight: 700; font-weight: 700;
letter-spacing: 7rpx; letter-spacing: 7rpx;
} }
.page-subtitle { .page-subtitle {
grid-row: 2;
margin-top: 12rpx; margin-top: 12rpx;
color: #806c58; color: #806c58;
font-size: 25rpx; font-size: 25rpx;
letter-spacing: 1rpx; letter-spacing: 1rpx;
} }
.reset-divider { .reset-divider {
grid-row: 3;
width: 300rpx; width: 300rpx;
height: 50rpx; height: 50rpx;
margin-top: 2rpx; margin-top: 2rpx;
@@ -367,7 +373,7 @@ const submitReset = () => {
.auth-input { .auth-input {
flex: 1; flex: 1;
min-width: 0; min-width: 0;
height: 82rpx; min-height: 82rpx;
color: #3f2c1d; color: #3f2c1d;
font-size: 27rpx; font-size: 27rpx;
} }
@@ -394,25 +400,21 @@ const submitReset = () => {
} }
.reset-submit { .reset-submit {
position: relative; display: grid;
display: flex; place-items: center;
align-items: center; min-height: 104rpx;
justify-content: center;
height: 104rpx;
margin-top: 26rpx; margin-top: 26rpx;
overflow: hidden;
} }
.reset-submit__skin, .reset-submit__skin,
.success-action__skin { .success-action__skin {
position: absolute; grid-area: 1 / 1;
inset: 0;
width: 100%; width: 100%;
height: 100%; height: 100%;
} }
.reset-submit__content { .reset-submit__content {
position: relative;
z-index: 1; z-index: 1;
grid-area: 1 / 1;
color: #fffaf0; color: #fffaf0;
font-size: 39rpx; font-size: 39rpx;
letter-spacing: 6rpx; letter-spacing: 6rpx;
@@ -453,24 +455,25 @@ const submitReset = () => {
} }
.success-dialog { .success-dialog {
position: relative; display: grid;
width: 620rpx; width: 620rpx;
height: 520rpx; min-height: 520rpx;
max-height: calc(100vh - 80rpx);
} }
.success-dialog__skin { .success-dialog__skin {
position: absolute; grid-area: 1 / 1;
inset: 0;
width: 100%; width: 100%;
height: 100%; height: 100%;
} }
.success-dialog__content { .success-dialog__content {
position: relative;
z-index: 1; z-index: 1;
display: flex; display: flex;
grid-area: 1 / 1;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
box-sizing: border-box; box-sizing: border-box;
height: 100%; min-height: 100%;
overflow-y: auto;
padding: 64rpx 64rpx 42rpx; padding: 64rpx 64rpx 42rpx;
text-align: center; text-align: center;
} }
@@ -487,30 +490,20 @@ const submitReset = () => {
line-height: 40rpx; line-height: 40rpx;
} }
.success-mark { .success-mark {
display: flex;
align-items: center;
justify-content: center;
width: 82rpx; width: 82rpx;
height: 82rpx; height: 82rpx;
margin-bottom: 20rpx; margin-bottom: 20rpx;
border: 2rpx solid #a7160c;
border-radius: 50%;
color: #a7160c;
font-size: 48rpx;
} }
.success-action { .success-action {
position: relative; display: grid;
display: flex; place-items: center;
align-items: center;
justify-content: center;
width: 100%; width: 100%;
height: 82rpx; min-height: 82rpx;
margin-top: 24rpx; margin-top: 24rpx;
overflow: hidden;
} }
.success-action__copy { .success-action__copy {
position: relative;
z-index: 1; z-index: 1;
grid-area: 1 / 1;
color: #fffaf0; color: #fffaf0;
font-size: 30rpx; font-size: 30rpx;
letter-spacing: 4rpx; letter-spacing: 4rpx;
@@ -537,7 +530,6 @@ const submitReset = () => {
transform: translateX(-50%); transform: translateX(-50%);
} }
.feedback-toast__copy { .feedback-toast__copy {
position: relative;
z-index: 1; z-index: 1;
padding: 16rpx 36rpx; padding: 16rpx 36rpx;
color: #5c4330; color: #5c4330;
+35 -39
View File
@@ -204,62 +204,61 @@ const goBack = () => uni.navigateBack();
<style scoped lang="scss"> <style scoped lang="scss">
.auth-page { .auth-page {
position: relative;
width: 100%; width: 100%;
height: 100dvh; height: 100dvh;
min-height: 100%; min-height: 100%;
overflow-x: hidden;
overflow-y: auto; overflow-y: auto;
background: #f7f0e4; background: #f7f0e4;
color: #493323; color: #493323;
} }
.page-canvas { .page-canvas {
position: relative; display: grid;
box-sizing: border-box; box-sizing: border-box;
width: 100%; width: 100%;
min-height: calc(1665rpx + env(safe-area-inset-bottom)); min-height: calc(1665rpx + env(safe-area-inset-bottom));
padding-bottom: env(safe-area-inset-bottom); padding-bottom: env(safe-area-inset-bottom);
} }
.page-backdrop { .page-backdrop {
position: absolute; grid-area: 1 / 1;
inset: 0 0 auto; align-self: start;
width: 100%; width: 100%;
height: 1665rpx; height: 1665rpx;
pointer-events: none; pointer-events: none;
} }
.brand-seal { .brand-seal {
position: absolute;
z-index: 1; z-index: 1;
top: 64rpx; grid-area: 1 / 1;
left: 50%; align-self: start;
justify-self: center;
width: 184rpx; width: 184rpx;
height: 221rpx; height: 221rpx;
transform: translateX(-50%); margin-top: 64rpx;
pointer-events: none; pointer-events: none;
} }
.status-content { .status-content {
position: relative;
z-index: 2; z-index: 2;
grid-area: 1 / 1;
box-sizing: border-box; box-sizing: border-box;
padding: 430rpx 64rpx 46rpx; padding: 430rpx 64rpx 46rpx;
} }
.page-heading { .page-heading {
position: relative; display: grid;
display: flex; grid-template-rows: auto auto auto;
flex-direction: column; justify-items: center;
align-items: center;
min-height: 154rpx; min-height: 154rpx;
} }
.back-button { .back-button {
position: absolute;
top: 10rpx;
left: 0;
display: flex; display: flex;
grid-row: 1 / -1;
grid-column: 1;
align-self: start;
justify-self: start;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
width: 88rpx; width: 88rpx;
height: 88rpx; height: 88rpx;
margin-top: 10rpx;
} }
.back-button__icon { .back-button__icon {
width: 42rpx; width: 42rpx;
@@ -267,18 +266,21 @@ const goBack = () => uni.navigateBack();
transform: scaleX(-1); transform: scaleX(-1);
} }
.page-title { .page-title {
grid-row: 1;
color: #9f170f; color: #9f170f;
font-size: 66rpx; font-size: 66rpx;
font-weight: 700; font-weight: 700;
letter-spacing: 7rpx; letter-spacing: 7rpx;
} }
.page-subtitle { .page-subtitle {
grid-row: 2;
margin-top: 12rpx; margin-top: 12rpx;
color: #806c58; color: #806c58;
font-size: 25rpx; font-size: 25rpx;
letter-spacing: 1rpx; letter-spacing: 1rpx;
} }
.status-divider { .status-divider {
grid-row: 3;
width: 300rpx; width: 300rpx;
height: 50rpx; height: 50rpx;
margin-top: 2rpx; margin-top: 2rpx;
@@ -344,24 +346,20 @@ const goBack = () => uni.navigateBack();
} }
.status-primary { .status-primary {
position: relative; display: grid;
display: flex; place-items: center;
align-items: center; min-height: 104rpx;
justify-content: center;
height: 104rpx;
margin-top: 28rpx; margin-top: 28rpx;
overflow: hidden;
} }
.status-primary__skin, .status-primary__skin,
.recovery-action__skin { .recovery-action__skin {
position: absolute; grid-area: 1 / 1;
inset: 0;
width: 100%; width: 100%;
height: 100%; height: 100%;
} }
.status-primary__copy { .status-primary__copy {
position: relative;
z-index: 1; z-index: 1;
grid-area: 1 / 1;
color: #fffaf0; color: #fffaf0;
font-size: 38rpx; font-size: 38rpx;
letter-spacing: 5rpx; letter-spacing: 5rpx;
@@ -387,24 +385,25 @@ const goBack = () => uni.navigateBack();
background: rgba(35, 18, 10, 0.62); background: rgba(35, 18, 10, 0.62);
} }
.recovery-dialog { .recovery-dialog {
position: relative; display: grid;
width: 620rpx; width: 620rpx;
height: 520rpx; min-height: 520rpx;
max-height: calc(100vh - 80rpx);
} }
.recovery-dialog__skin { .recovery-dialog__skin {
position: absolute; grid-area: 1 / 1;
inset: 0;
width: 100%; width: 100%;
height: 100%; height: 100%;
} }
.recovery-dialog__content { .recovery-dialog__content {
position: relative;
z-index: 1; z-index: 1;
display: flex; display: flex;
grid-area: 1 / 1;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
box-sizing: border-box; box-sizing: border-box;
height: 100%; min-height: 100%;
overflow-y: auto;
padding: 58rpx 58rpx 36rpx; padding: 58rpx 58rpx 36rpx;
text-align: center; text-align: center;
} }
@@ -427,18 +426,15 @@ const goBack = () => uni.navigateBack();
line-height: 31rpx; line-height: 31rpx;
} }
.recovery-action { .recovery-action {
position: relative; display: grid;
display: flex; place-items: center;
align-items: center;
justify-content: center;
width: 100%; width: 100%;
height: 80rpx; min-height: 80rpx;
margin-top: 22rpx; margin-top: 22rpx;
overflow: hidden;
} }
.recovery-action__copy { .recovery-action__copy {
position: relative;
z-index: 1; z-index: 1;
grid-area: 1 / 1;
color: #fffaf0; color: #fffaf0;
font-size: 29rpx; font-size: 29rpx;
letter-spacing: 4rpx; letter-spacing: 4rpx;
+27 -57
View File
@@ -22,11 +22,6 @@
><text>汤氏家族圈</text><text>家宴通知与共同记忆</text></view ><text>汤氏家族圈</text><text>家宴通知与共同记忆</text></view
> >
<view v-if="feedState !== 'loading'" class="feed-shortcuts"> <view v-if="feedState !== 'loading'" class="feed-shortcuts">
<image
class="feed-shortcuts__skin"
src="/static/assets/foundation/transparent/a01-scroll-secondary-v3.png"
mode="scaleToFill"
/>
<view <view
v-for="item in shortcuts" v-for="item in shortcuts"
:key="item.key" :key="item.key"
@@ -42,11 +37,6 @@
class="feed-card" class="feed-card"
@click="openDetail(item)" @click="openDetail(item)"
> >
<image
class="feed-card__skin"
src="/static/assets/modules/family/transparent/f01-family-letter-card.png"
mode="scaleToFill"
/>
<view class="feed-card__copy"> <view class="feed-card__copy">
<text class="feed-card__meta" <text class="feed-card__meta"
>{{ item.tag }} · {{ item.time }}</text >{{ item.tag }} · {{ item.time }}</text
@@ -58,11 +48,7 @@
</view> </view>
</template> </template>
<view v-else-if="feedState !== 'loading'" class="feed-state-card"> <view v-else-if="feedState !== 'loading'" class="feed-state-card">
<image <view class="feed-state-card__copy"
src="/static/assets/modules/family/transparent/f01-family-letter-card.png"
mode="scaleToFill"
/>
<view
><text>{{ ><text>{{
feedState === "empty" ? "还没有家族动态" : "家族动态暂不可用" feedState === "empty" ? "还没有家族动态" : "家族动态暂不可用"
}}</text }}</text
@@ -77,10 +63,7 @@
v-if="feedState !== 'loading'" v-if="feedState !== 'loading'"
class="feed-action" class="feed-action"
@click="feedState === 'error' ? (feedState = 'list') : toPublish()" @click="feedState === 'error' ? (feedState = 'list') : toPublish()"
><image ><text>{{
src="/static/assets/foundation/transparent/a01-scroll-primary-v3.png"
mode="aspectFit"
/><text>{{
feedState === "error" ? "重新查看" : "发布家族动态" feedState === "error" ? "重新查看" : "发布家族动态"
}}</text></view }}</text></view
> >
@@ -121,6 +104,10 @@ const shortcuts = [
{ key: "albums", label: "相册" }, { key: "albums", label: "相册" },
{ key: "rituals", label: "礼仪" }, { key: "rituals", label: "礼仪" },
{ key: "memos", label: "备忘" }, { key: "memos", label: "备忘" },
{ key: "people", label: "人物录" },
{ key: "gifts", label: "贺礼簿" },
{ key: "merits", label: "功德录" },
{ key: "videos", label: "家族视频" },
]; ];
onLoad((query) => { onLoad((query) => {
genealogyId.value = genealogyId.value =
@@ -146,23 +133,29 @@ const openSection = (key) => {
albums: "/pages/family/f07-album-list", albums: "/pages/family/f07-album-list",
rituals: "/pages/records/r05-ritual-list", rituals: "/pages/records/r05-ritual-list",
memos: "/pages/records/r10-memo-list", memos: "/pages/records/r10-memo-list",
people: "/pages/records/r01-people-list",
gifts: "/pages/records/r03-gift-list",
merits: "/pages/records/r11-merit-records",
videos: "/pages/family/f10-video-list",
}; };
uni.navigateTo({ url: routes[key] }); uni.navigateTo({
url: `${routes[key]}?genealogyId=${genealogyId.value}`,
});
}; };
</script> </script>
<style scoped lang="scss"> <style scoped lang="scss">
.family-page { .family-page {
position: relative; display: flex;
min-height: 100vh; min-height: 100vh;
overflow: hidden; flex-direction: column;
background: $paper; background: $paper;
} }
.family-page__header, .family-page__header,
.feed-content { .feed-content {
position: relative; z-index: 1;
z-index: 2;
} }
.feed-content { .feed-content {
flex: 1;
padding: 24rpx 24rpx 190rpx; padding: 24rpx 24rpx 190rpx;
} }
.feed-heading text { .feed-heading text {
@@ -180,23 +173,14 @@ const openSection = (key) => {
font-size: 23rpx; font-size: 23rpx;
} }
.feed-shortcuts { .feed-shortcuts {
position: relative;
display: grid; display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr)); grid-template-columns: repeat(4, minmax(0, 1fr));
width: 100%; width: 100%;
height: 48px; min-height: 48px;
margin-top: 15rpx; margin-top: 15rpx;
} background: url("/static/assets/foundation/transparent/a01-scroll-secondary-v3.png") center / 100% 100% no-repeat;
.feed-shortcuts__skin {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
pointer-events: none;
} }
.feed-shortcut { .feed-shortcut {
position: relative;
z-index: 1;
width: 100%; width: 100%;
height: 48px; height: 48px;
min-height: 44px; min-height: 44px;
@@ -212,28 +196,17 @@ const openSection = (key) => {
} }
.feed-card, .feed-card,
.feed-state-card { .feed-state-card {
position: relative;
display: flex; display: flex;
width: 100%; width: 100%;
height: 230rpx; min-height: 230rpx;
min-height: 98px; min-height: 98px;
flex-direction: column; flex-direction: column;
justify-content: center; justify-content: center;
margin-top: 17rpx; margin-top: 17rpx;
box-sizing: border-box; box-sizing: border-box;
} background: url("/static/assets/modules/family/transparent/f01-family-letter-card.png") center / 100% 100% no-repeat;
.feed-card__skin,
.feed-state-card > image,
.feed-action image {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
pointer-events: none;
} }
.feed-card__copy { .feed-card__copy {
position: relative;
z-index: 1;
padding: 15% 9%; padding: 15% 9%;
} }
.feed-card text { .feed-card text {
@@ -265,9 +238,7 @@ const openSection = (key) => {
.feed-state-card { .feed-state-card {
margin-top: 70rpx; margin-top: 70rpx;
} }
.feed-state-card > view { .feed-state-card__copy {
position: relative;
z-index: 1;
padding: 23% 10%; padding: 23% 10%;
box-sizing: border-box; box-sizing: border-box;
text-align: center; text-align: center;
@@ -288,14 +259,13 @@ const openSection = (key) => {
line-height: 1.45; line-height: 1.45;
} }
.feed-action { .feed-action {
position: relative; width: 514rpx;
width: 100%; max-width: 100%;
height: 76rpx; min-height: 76rpx;
margin-top: 19rpx; margin: 19rpx auto 0;
background: url("/static/assets/foundation/transparent/a01-scroll-primary-v3.png") center / 100% 100% no-repeat;
} }
.feed-action text { .feed-action text {
position: relative;
z-index: 1;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
+8 -35
View File
@@ -10,19 +10,13 @@
><ModulePageBackground module="family" /><view class="publish-page__header" ><ModulePageBackground module="family" /><view class="publish-page__header"
><PageHeader title="发布动态" /></view ><PageHeader title="发布动态" /></view
><view class="publish-panel" ><view class="publish-panel"
><image ><view
class="publish-panel__skin"
src="/static/assets/modules/family/transparent/module-content-frame.png"
mode="scaleToFill" /><view
v-if="publishState === 'form'" v-if="publishState === 'form'"
class="publish-form" class="publish-form"
><text>记录此刻</text ><text>记录此刻</text
><text>分享通知活动家族故事或一段共同记忆</text ><text>分享通知活动家族故事或一段共同记忆</text
><view class="publish-field" ><view class="publish-field"
><image ><textarea
src="/static/assets/modules/family/transparent/module-field-frame.png"
mode="scaleToFill"
/><textarea
v-model="content" v-model="content"
maxlength="300" maxlength="300"
placeholder="写下想对家人说的话" placeholder="写下想对家人说的话"
@@ -87,15 +81,14 @@ onUnmounted(() => {
</script> </script>
<style scoped lang="scss"> <style scoped lang="scss">
.publish-page { .publish-page {
position: relative; display: flex;
min-height: 100vh; min-height: 100vh;
overflow: hidden; flex-direction: column;
background: $paper; background: $paper;
} }
.publish-page__header, .publish-page__header,
.publish-panel { .publish-panel {
position: relative; z-index: 1;
z-index: 2;
} }
.publish-panel { .publish-panel {
width: calc(100% - 32rpx); width: calc(100% - 32rpx);
@@ -103,18 +96,7 @@ onUnmounted(() => {
margin: 18rpx auto 0; margin: 18rpx auto 0;
padding: 9%; padding: 9%;
box-sizing: border-box; box-sizing: border-box;
} background: url("/static/assets/modules/family/transparent/module-content-frame.png") center / 100% 100% no-repeat;
.publish-panel__skin {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
pointer-events: none;
}
.publish-form,
.publish-result {
position: relative;
z-index: 1;
} }
.publish-form > text, .publish-form > text,
.publish-result > text { .publish-result > text {
@@ -135,25 +117,16 @@ onUnmounted(() => {
line-height: 1.6; line-height: 1.6;
} }
.publish-field { .publish-field {
position: relative;
display: flex; display: flex;
min-height: 250rpx; min-height: 250rpx;
margin-top: 24rpx; margin-top: 24rpx;
padding: 25rpx; padding: 25rpx;
box-sizing: border-box; box-sizing: border-box;
} background: url("/static/assets/modules/family/transparent/module-field-frame.png") center / 100% 100% no-repeat;
.publish-field image {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
pointer-events: none;
} }
.publish-field textarea { .publish-field textarea {
position: relative;
z-index: 1;
width: 100%; width: 100%;
height: 200rpx; min-height: 200rpx;
color: $ink; color: $ink;
font-size: 24rpx; font-size: 24rpx;
line-height: 1.7; line-height: 1.7;
+151 -3
View File
@@ -1,5 +1,153 @@
<!-- 页面编号F-03用途动态详情评论点赞与删除确认 --> <!-- 页面编号F-03用途动态详情评论与内容失效状态 -->
<template><ModulePage page-id="f03" /></template> <template>
<view class="feed-detail-page" :class="{ 'feed-state--expired': feedState === 'expired', 'feed-state--error': feedState === 'error', 'feed-state--ready': feedState === 'ready' }">
<ModulePageBackground module="family" />
<view class="feed-detail-header"><PageHeader title="动态详情" /></view>
<view v-if="feedState === 'loading'" class="feed-detail-loading">
<AppLoading text="正在读取家族动态" description="请稍候,正在整理正文与家人评论。" />
</view>
<view v-else class="feed-detail-content">
<template v-if="feedState === 'ready'">
<view class="feed-article-card">
<text class="feed-article-card__meta">{{ currentFeed.tag }} · {{ currentFeed.time }}</text>
<text class="feed-article-card__title">{{ currentFeed.title }}</text>
<text class="feed-article-card__body">{{ currentFeed.content }}</text>
<text class="feed-article-card__author">发布人{{ currentFeed.author }}</text>
</view>
<view class="feed-comments-panel">
<view class="feed-comments-heading">
<text>家人评论</text><text>{{ feedComments.length }} </text>
</view>
<view v-for="comment in feedComments" :key="comment.id" class="feed-comment-card">
<view><text>{{ comment.author }}</text><text>{{ comment.time }}</text></view>
<text>{{ comment.content }}</text>
</view>
<view v-if="!feedComments.length" class="feed-comments-empty">
<text>还没有评论</text><text>写下第一句祝福或共同记忆</text>
</view>
</view>
<view class="feed-comment-form" :class="{ 'comment-state--saving': commentState === 'saving', 'comment-state--error': commentState === 'error' }">
<text>写下评论</text>
<textarea v-model="commentDraft" auto-height maxlength="240" placeholder="对家人说点什么" />
<text v-if="commentError" class="feed-comment-error">{{ commentError }}</text>
<AppButton block :disabled="commentState === 'saving'" :label="commentState === 'saving' ? '正在发送' : '发送评论'" @click="submitComment" />
</view>
</template>
<view v-else class="feed-state-card">
<text>{{ feedState === 'expired' ? '动态已失效' : '动态暂不可用' }}</text>
<text>{{ feedState === 'expired' ? '这条动态可能已被发布人删除,请返回家族圈查看其他内容。' : '请稍后重新查看,已有家族记录不会受到影响。' }}</text>
<AppButton :type="feedState === 'error' ? 'secondary' : 'primary'" block :label="feedState === 'error' ? '重新查看' : '返回家族圈'" @click="feedState === 'error' ? restoreFeed() : backToFamily()" />
</view>
</view>
<AppToast :visible="toastVisible" message="评论已发送" />
</view>
</template>
<script setup> <script setup>
import ModulePage from "@/components/ModulePage.vue"; import { onUnmounted, ref } from "vue";
import { onLoad } from "@dcloudio/uni-app";
import AppButton from "@/components/AppButton.vue";
import AppLoading from "@/components/AppLoading.vue";
import AppToast from "@/components/AppToast.vue";
import ModulePageBackground from "@/components/ModulePageBackground.vue";
import PageHeader from "@/components/PageHeader.vue";
const feedRecords = [
{ id: "1", tag: "团圆记忆", time: "今天 10:24", title: "端午家宴", content: "今年端午全家相聚,长辈讲起祖居旧事,孩子们也为大家拍下了新的全家福。饭后我们把照片和口述片段整理进家族档案,让这份热闹成为往后仍能翻看的共同记忆。", author: "汤正国" },
{ id: "2", tag: "家族通知", time: "昨天 18:02", title: "修谱资料征集", content: "请家人补充老照片中的人物姓名、拍摄时间和地点。无法确认的信息也可以先写下线索,由熟悉往事的长辈共同核对。", author: "谱主" },
];
const feedId = ref("1");
const currentFeed = ref(feedRecords[0]);
const feedState = ref("loading");
const commentState = ref("idle");
const commentDraft = ref("");
const commentError = ref("");
const toastVisible = ref(false);
const forceCommentFailure = ref(false);
const feedComments = ref([
{ id: 1, author: "汤淑华", time: "今天 10:42", content: "一家人能常常相聚,就是最珍贵的福气。" },
{ id: 2, author: "汤文清", time: "今天 11:08", content: "照片已经整理好了,晚些时候放进春节团圆相册。" },
]);
let submitTimer = null;
let toastTimer = null;
onLoad((query) => {
feedId.value = String(query.feedId || "1");
const selected = feedRecords.find((item) => item.id === feedId.value);
currentFeed.value = selected || feedRecords[0];
forceCommentFailure.value = query.commentResult === "error";
feedState.value = ["loading", "error", "expired"].includes(query.state)
? query.state
: selected
? "ready"
: "expired";
});
const submitComment = () => {
if (commentState.value === "saving") return;
const content = commentDraft.value.trim();
if (!content) {
commentError.value = "请先写下评论内容";
return;
}
commentError.value = "";
commentState.value = "saving";
submitTimer = setTimeout(() => {
if (forceCommentFailure.value) {
commentState.value = "error";
commentError.value = "评论发送失败,请保留文字后重试";
forceCommentFailure.value = false;
return;
}
feedComments.value.push({ id: Date.now(), author: "我", time: "刚刚", content });
commentDraft.value = "";
commentState.value = "idle";
toastVisible.value = true;
toastTimer = setTimeout(() => { toastVisible.value = false; }, 1800);
}, 320);
};
const restoreFeed = () => { feedState.value = "ready"; };
const backToFamily = () => uni.redirectTo({ url: "/pages/family/f01-family-feed" });
onUnmounted(() => {
if (submitTimer) clearTimeout(submitTimer);
if (toastTimer) clearTimeout(toastTimer);
});
</script> </script>
<style scoped lang="scss">
.feed-detail-page { display: flex; min-height: 100vh; flex-direction: column; background: $paper; }
.feed-detail-header, .feed-detail-loading, .feed-detail-content { z-index: 1; }
.feed-detail-loading { min-height: calc(100vh - 100rpx); }
.feed-detail-content { padding: 18rpx 24rpx 72rpx; }
.feed-article-card, .feed-comments-panel, .feed-comment-form, .feed-state-card { width: 100%; box-sizing: border-box; background: url("/static/assets/modules/family/transparent/module-content-frame.png") center / 100% 100% no-repeat; }
.feed-article-card { padding: 46rpx 48rpx 42rpx; }
.feed-article-card text, .feed-state-card > text { display: block; }
.feed-article-card__meta, .feed-article-card__author { color: $ink-muted; font-size: 22rpx; }
.feed-article-card__title { margin-top: 12rpx; color: $ink; font-family: STKaiti, KaiTi, serif; font-size: 36rpx; font-weight: 700; }
.feed-article-card__body { margin-top: 18rpx; color: $ink; font-size: 25rpx; line-height: 1.75; }
.feed-article-card__author { margin-top: 22rpx; }
.feed-comments-panel { margin-top: 18rpx; padding: 38rpx 38rpx 34rpx; }
.feed-comments-heading { display: flex; align-items: center; justify-content: space-between; color: $brand-red; font-size: 24rpx; font-weight: 700; }
.feed-comments-heading text:last-child { color: $ink-muted; font-size: 21rpx; font-weight: 400; }
.feed-comment-card { margin-top: 16rpx; padding: 20rpx 22rpx; background: url("/static/assets/modules/family/transparent/module-field-frame.png") center / 100% 100% no-repeat; }
.feed-comment-card > view { display: flex; justify-content: space-between; gap: 18rpx; color: $ink-muted; font-size: 20rpx; }
.feed-comment-card > text { display: block; margin-top: 9rpx; color: $ink; font-size: 24rpx; line-height: 1.55; }
.feed-comments-empty { padding: 34rpx 10rpx 20rpx; text-align: center; }
.feed-comments-empty text { display: block; color: $ink-muted; font-size: 23rpx; line-height: 1.6; }
.feed-comments-empty text:first-child { color: $ink; font-size: 28rpx; font-weight: 700; }
.feed-comment-form { margin-top: 18rpx; padding: 38rpx 40rpx 42rpx; }
.feed-comment-form > text:first-child { color: $ink; font-family: STKaiti, KaiTi, serif; font-size: 30rpx; font-weight: 700; }
.feed-comment-form textarea { width: auto; min-width: 0; min-height: 126rpx; margin-top: 16rpx; padding: 20rpx 22rpx; box-sizing: border-box; color: $ink; font-size: 24rpx; line-height: 1.55; background: url("/static/assets/modules/family/transparent/module-field-frame.png") center / 100% 100% no-repeat; }
.feed-comment-error { display: block; margin-top: 10rpx; color: $brand-red; font-size: 22rpx; }
.feed-comment-form .app-button { margin-top: 22rpx; }
.feed-state-card { min-height: 340rpx; margin-top: 36rpx; padding: 78rpx 52rpx 50rpx; text-align: center; }
.feed-state-card > text:first-child { color: $ink; font-family: STKaiti, KaiTi, serif; font-size: 35rpx; font-weight: 700; }
.feed-state-card > text:nth-child(2) { margin-top: 18rpx; color: $ink-muted; font-size: 24rpx; line-height: 1.65; }
.feed-state-card .app-button { margin-top: 30rpx; }
</style>
+115 -3
View File
@@ -1,5 +1,117 @@
<!-- 页面编号F-04用途谱文分类与文章列表 --> <!-- 页面编号F-04用途谱文分类搜索列表与新建入口 -->
<template><ModulePage page-id="f04" /></template> <template>
<view class="article-list-page" :class="{ 'article-list-state--loading': listState === 'loading', 'article-list-state--empty': listState === 'empty', 'article-list-state--error': listState === 'error' }">
<ModulePageBackground module="family" />
<view class="article-list-header"><PageHeader title="谱文" action="新建" @action="createArticle" /></view>
<view v-if="listState === 'loading'" class="article-list-loading">
<AppLoading text="正在整理家族谱文" description="请稍候,正在读取家训、往事与序言。" />
</view>
<view v-else class="article-list-content">
<template v-if="listState === 'ready'">
<view class="article-search">
<input v-model="keyword" placeholder="搜索标题、作者或正文" confirm-type="search" />
</view>
<view class="article-categories">
<view class="article-categories__row">
<view v-for="category in articleCategories" :key="category" class="article-category" :class="{ 'article-category--active': activeCategory === category }" @click="activeCategory = category"><text>{{ category }}</text></view>
</view>
</view>
<view v-if="filteredArticles.length" class="article-list">
<view v-for="article in filteredArticles" :key="article.id" class="article-card" role="button" :aria-label="`查看谱文${article.title}`" @click="openArticle(article)">
<text class="article-card__category">{{ article.category }}</text>
<text class="article-card__title">{{ article.title }}</text>
<text class="article-card__summary">{{ article.summary }}</text>
<view class="article-card__meta"><text>{{ article.author }}</text><text>{{ article.updatedAt }}</text></view>
</view>
<AppButton block label="新建谱文" @click="createArticle" />
</view>
<view v-else class="article-list-state-card">
<text>{{ keyword || activeCategory !== '全部' ? '没有找到相关谱文' : '还没有谱文' }}</text>
<text>{{ keyword || activeCategory !== '全部' ? '换一个关键词或分类继续查找。' : '从第一篇家训、序言或家族往事开始记录。' }}</text>
<AppButton block :label="keyword || activeCategory !== '全部' ? '清空筛选' : '新建谱文'" @click="keyword || activeCategory !== '全部' ? resetFilters() : createArticle()" />
</view>
</template>
<view v-else class="article-list-state-card">
<text>{{ listState === 'empty' ? '还没有谱文' : '谱文列表暂不可用' }}</text>
<text>{{ listState === 'empty' ? '记录第一篇家风家训或家族往事。' : '请稍后重新查看,已有谱文不会受到影响。' }}</text>
<AppButton :type="listState === 'error' ? 'secondary' : 'primary'" block :label="listState === 'error' ? '重新查看' : '新建谱文'" @click="listState === 'error' ? restoreArticles() : createArticle()" />
</view>
</view>
</view>
</template>
<script setup> <script setup>
import ModulePage from "@/components/ModulePage.vue"; import { computed, ref } from "vue";
import { onLoad } from "@dcloudio/uni-app";
import AppButton from "@/components/AppButton.vue";
import AppLoading from "@/components/AppLoading.vue";
import ModulePageBackground from "@/components/ModulePageBackground.vue";
import PageHeader from "@/components/PageHeader.vue";
const articleCategories = ["全部", "家风家训", "家族往事", "族谱序言"];
const baseArticles = [
{ id: "101", category: "家风家训", title: "孝友传家的日常", summary: "从敬老、睦亲与守信的小事里,看见家风如何代代相传。", author: "汤文正", updatedAt: "今天更新" },
{ id: "102", category: "家族往事", title: "祖居门前的那棵桂花树", summary: "长辈口述的旧居记忆,以及每年中秋一家人相聚的故事。", author: "汤淑华", updatedAt: "昨天更新" },
{ id: "103", category: "族谱序言", title: "续修族谱序", summary: "说明本次续修的缘起、资料来源与共同参与的家人。", author: "谱主", updatedAt: "5 月 12 日" },
];
const articles = ref([...baseArticles]);
const activeCategory = ref("全部");
const keyword = ref("");
const listState = ref("loading");
const filteredArticles = computed(() => {
const term = keyword.value.trim().toLowerCase();
return articles.value.filter((article) => {
const categoryMatched = activeCategory.value === "全部" || article.category === activeCategory.value;
const keywordMatched = !term || `${article.title} ${article.summary} ${article.author}`.toLowerCase().includes(term);
return categoryMatched && keywordMatched;
});
});
onLoad((query) => {
const count = Math.max(1, Math.min(Number(query.count) || baseArticles.length, 50));
articles.value = Array.from({ length: count }, (_, index) => ({
...baseArticles[index % baseArticles.length],
id: String(101 + index),
title: count > baseArticles.length ? `${baseArticles[index % baseArticles.length].title}(第 ${index + 1} 篇)` : baseArticles[index].title,
}));
listState.value = ["loading", "empty", "error"].includes(query.state) ? query.state : "ready";
});
const openArticle = (article) => uni.navigateTo({ url: `/pages/family/f05-article-detail?articleId=${article.id}` });
const createArticle = () => uni.navigateTo({ url: "/pages/family/f06-article-editor?mode=create" });
const restoreArticles = () => { listState.value = "ready"; };
const resetFilters = () => { keyword.value = ""; activeCategory.value = "全部"; };
</script> </script>
<style scoped lang="scss">
.article-list-page { display: flex; min-height: 100vh; flex-direction: column; background: $paper; }
.article-list-header, .article-list-loading, .article-list-content { z-index: 1; }
.article-list-loading { min-height: calc(100vh - 100rpx); }
.article-list-content { padding: 18rpx 24rpx 72rpx; }
.article-search { min-height: 82rpx; padding: 12rpx 26rpx; box-sizing: border-box; background: url("/static/assets/modules/family/transparent/module-field-frame.png") center / 100% 100% no-repeat; }
.article-search input { width: 100%; min-height: 58rpx; color: $ink; font-size: 24rpx; }
.article-categories { width: 100%; margin-top: 16rpx; }
.article-categories__row { display: flex; flex-wrap: wrap; gap: 12rpx; padding: 2rpx 4rpx 8rpx; }
.article-category { min-height: 58rpx; padding: 0 28rpx; color: $ink-muted; font-size: 23rpx; background: url("/static/assets/modules/family/transparent/module-field-frame.png") center / 100% 100% no-repeat; }
.article-category text { display: flex; min-height: 58rpx; align-items: center; }
.article-category--active { color: $brand-red; font-weight: 700; }
.article-list { display: flex; flex-direction: column; gap: 16rpx; margin-top: 10rpx; }
.article-card, .article-list-state-card { width: 100%; box-sizing: border-box; background: url("/static/assets/modules/family/transparent/module-content-frame.png") center / 100% 100% no-repeat; }
.article-card { min-height: 196rpx; padding: 34rpx 46rpx 30rpx; }
.article-card > text { display: block; }
.article-card__category { color: $brand-red; font-size: 21rpx; font-weight: 700; letter-spacing: 2rpx; }
.article-card__title { margin-top: 7rpx; color: $ink; font-family: STKaiti, KaiTi, serif; font-size: 31rpx; font-weight: 700; }
.article-card__summary { margin-top: 10rpx; color: $ink-muted; font-size: 23rpx; line-height: 1.55; }
.article-card__meta { display: flex; flex-wrap: wrap; justify-content: space-between; gap: 6rpx 18rpx; margin-top: 12rpx; color: $ink-muted; font-size: 20rpx; }
.article-list > .app-button { margin-top: 8rpx; }
.article-list-state-card { min-height: 340rpx; margin-top: 30rpx; padding: 78rpx 52rpx 50rpx; text-align: center; }
.article-list-state-card > text { display: block; }
.article-list-state-card > text:first-child { color: $ink; font-family: STKaiti, KaiTi, serif; font-size: 35rpx; font-weight: 700; }
.article-list-state-card > text:nth-child(2) { margin-top: 18rpx; color: $ink-muted; font-size: 24rpx; line-height: 1.65; }
.article-list-state-card .app-button { margin-top: 28rpx; }
</style>
+111 -3
View File
@@ -1,5 +1,113 @@
<!-- 页面编号F-05用途谱文详情 --> <!-- 页面编号F-05用途谱文正文收藏编辑与受控状态 -->
<template><ModulePage page-id="f05" /></template> <template>
<view class="article-detail-page" :class="articleStateClasses">
<ModulePageBackground module="family" />
<view class="article-detail-header"><PageHeader title="谱文详情" /></view>
<view v-if="articleState === 'loading'" class="article-detail-loading">
<AppLoading text="正在读取谱文" description="请稍候,正在整理正文与收录信息。" />
</view>
<view v-else class="article-detail-content">
<template v-if="articleState === 'ready'">
<view class="article-paper">
<text class="article-paper__category">{{ article.category }}</text>
<text class="article-paper__title">{{ article.title }}</text>
<view class="article-paper__meta"><text>{{ article.author }}</text><text>{{ article.updatedAt }}</text></view>
<view class="article-paper__body">
<text v-for="(paragraph, index) in articleParagraphs" :key="index">{{ paragraph }}</text>
</view>
</view>
<view class="article-actions">
<AppButton block :label="favorite ? '已收藏谱文' : '收藏谱文'" @click="toggleFavorite" />
<AppButton type="secondary" block label="编辑谱文" @click="editArticle" />
</view>
</template>
<view v-else class="article-state-card">
<text>{{ stateCopy.title }}</text>
<text>{{ stateCopy.copy }}</text>
<AppButton :type="articleState === 'error' ? 'secondary' : 'primary'" block :label="stateCopy.action" @click="handleStateAction" />
</view>
</view>
<AppToast :visible="toastVisible" :message="favorite ? '已收藏谱文' : '已取消收藏'" />
</view>
</template>
<script setup> <script setup>
import ModulePage from "@/components/ModulePage.vue"; import { computed, onUnmounted, reactive, ref } from "vue";
import { onLoad } from "@dcloudio/uni-app";
import AppButton from "@/components/AppButton.vue";
import AppLoading from "@/components/AppLoading.vue";
import AppToast from "@/components/AppToast.vue";
import ModulePageBackground from "@/components/ModulePageBackground.vue";
import PageHeader from "@/components/PageHeader.vue";
const articleRecords = [
{ id: "101", category: "家风家训", title: "孝友传家的日常", author: "汤文正", updatedAt: "2024 年 5 月 12 日", paragraphs: ["孝友传家,不只在族谱序言里,也在一家人每日的言行中。长辈以宽厚待晚辈,晚辈以耐心照料长辈,亲友之间守信互助,便是最朴素也最长久的家风。", "勤俭并非一味节省,而是珍惜所得、量入为出,也愿意在家人需要时伸出援手。家中每一代人都可以用自己的方式,把这份分寸与担当继续传下去。", "敬祖睦宗,最终是为了让今天的家人彼此认识、彼此关心。记录姓名与世代之外,也应留下真实的生活、共同经历和温暖记忆。"] },
{ id: "102", category: "家族往事", title: "祖居门前的那棵桂花树", author: "汤淑华", updatedAt: "2024 年 5 月 10 日", paragraphs: ["祖居门前曾有一棵桂花树。每到中秋,院里都是清甜的香气,远道回来的家人也总能循着那股味道找到家门。", "后来房屋几经修缮,桂花树仍被大家小心保留下来。它见过孩子长大,也见过长辈把往事一遍遍讲给后来人。"] },
{ id: "103", category: "族谱序言", title: "续修族谱序", author: "谱主", updatedAt: "2024 年 5 月 8 日", paragraphs: ["本次续修以旧谱、碑记、户籍资料和长辈口述为基础,由家人共同核对补充。凡暂不能确认之处,均保留来源和疑问,留待后续查证。", "愿这份记录不仅理清世系,也能保存家风、人物与共同记忆。"] },
];
const articleId = ref("101");
const article = reactive({ ...articleRecords[0] });
const articleState = ref("loading");
const favorite = ref(false);
const toastVisible = ref(false);
let toastTimer = null;
const articleParagraphs = computed(() => article.paragraphs || []);
const articleStateClasses = computed(() => ({
[`article-state--${articleState.value}`]: true,
"article-state--expired": articleState.value === "expired",
"article-state--privacy": articleState.value === "privacy",
"article-state--error": articleState.value === "error",
}));
const stateCopy = computed(() => ({
expired: { title: "这篇谱文已无法查看", copy: "内容可能已被作者删除或取消公开,请返回谱文列表查看其他内容。", action: "返回谱文列表" },
privacy: { title: "这篇谱文暂未公开", copy: "作者仅向有权限的家人开放正文,请返回谱文列表查看其他内容。", action: "返回谱文列表" },
error: { title: "谱文暂不可用", copy: "请稍后重新查看,已有谱文不会受到影响。", action: "重新查看" },
}[articleState.value] || {}));
onLoad((query) => {
articleId.value = String(query.articleId || "101");
const selected = articleRecords.find((item) => item.id === articleId.value);
if (selected) Object.assign(article, selected);
articleState.value = ["loading", "error", "expired", "privacy"].includes(query.state)
? query.state
: selected
? "ready"
: "expired";
});
const toggleFavorite = () => {
favorite.value = !favorite.value;
toastVisible.value = true;
if (toastTimer) clearTimeout(toastTimer);
toastTimer = setTimeout(() => { toastVisible.value = false; }, 1800);
};
const editArticle = () => uni.navigateTo({ url: `/pages/family/f06-article-editor?mode=edit&articleId=${articleId.value}` });
const backToArticles = () => uni.redirectTo({ url: "/pages/family/f04-article-list" });
const handleStateAction = () => { if (articleState.value === "error") articleState.value = "ready"; else backToArticles(); };
onUnmounted(() => { if (toastTimer) clearTimeout(toastTimer); });
</script> </script>
<style scoped lang="scss">
.article-detail-page { display: flex; min-height: 100vh; flex-direction: column; background: $paper; }
.article-detail-header, .article-detail-loading, .article-detail-content { z-index: 1; }
.article-detail-loading { min-height: calc(100vh - 100rpx); }
.article-detail-content { padding: 18rpx 24rpx 72rpx; }
.article-paper, .article-state-card { width: 100%; box-sizing: border-box; background: url("/static/assets/modules/family/transparent/module-content-frame.png") center / 100% 100% no-repeat; }
.article-paper { padding: 50rpx 48rpx 54rpx; }
.article-paper > text { display: block; }
.article-paper__category { color: $brand-red; font-size: 22rpx; font-weight: 700; letter-spacing: 3rpx; }
.article-paper__title { margin-top: 12rpx; color: $ink; font-family: STKaiti, KaiTi, serif; font-size: 39rpx; font-weight: 700; line-height: 1.35; }
.article-paper__meta { display: flex; flex-wrap: wrap; justify-content: space-between; gap: 6rpx 18rpx; margin-top: 16rpx; color: $ink-muted; font-size: 21rpx; }
.article-paper__body { margin-top: 28rpx; }
.article-paper__body text { display: block; margin-top: 18rpx; color: $ink; font-size: 25rpx; line-height: 1.85; text-align: justify; }
.article-actions { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: 14rpx; margin-top: 18rpx; }
.article-state-card { min-height: 350rpx; margin-top: 36rpx; padding: 78rpx 52rpx 50rpx; text-align: center; }
.article-state-card > text { display: block; }
.article-state-card > text:first-child { color: $ink; font-family: STKaiti, KaiTi, serif; font-size: 35rpx; font-weight: 700; }
.article-state-card > text:nth-child(2) { margin-top: 18rpx; color: $ink-muted; font-size: 24rpx; line-height: 1.65; }
.article-state-card .app-button { margin-top: 30rpx; }
@media (max-width: 340px) { .article-actions { grid-template-columns: minmax(0, 1fr); } }
</style>
+30 -35
View File
@@ -47,7 +47,6 @@
class="editor-control" class="editor-control"
:class="{ 'editor-control--error': fieldErrors.title }" :class="{ 'editor-control--error': fieldErrors.title }"
> >
<image :src="fieldFrame" mode="scaleToFill" />
<input <input
v-model="form.title" v-model="form.title"
maxlength="40" maxlength="40"
@@ -67,7 +66,6 @@
class="editor-control" class="editor-control"
:class="{ 'editor-control--error': fieldErrors.category }" :class="{ 'editor-control--error': fieldErrors.category }"
> >
<image :src="fieldFrame" mode="scaleToFill" />
<input <input
v-model="form.category" v-model="form.category"
maxlength="20" maxlength="20"
@@ -87,7 +85,6 @@
class="editor-control editor-control--textarea" class="editor-control editor-control--textarea"
:class="{ 'editor-control--error': fieldErrors.content }" :class="{ 'editor-control--error': fieldErrors.content }"
> >
<image :src="fieldFrame" mode="scaleToFill" />
<textarea <textarea
v-model="form.content" v-model="form.content"
maxlength="1200" maxlength="1200"
@@ -127,8 +124,6 @@ import AppLoading from "@/components/AppLoading.vue";
import ModulePageBackground from "@/components/ModulePageBackground.vue"; import ModulePageBackground from "@/components/ModulePageBackground.vue";
import PageHeader from "@/components/PageHeader.vue"; import PageHeader from "@/components/PageHeader.vue";
const fieldFrame =
"/static/assets/modules/family/transparent/module-field-frame.png";
const draftFixture = { const draftFixture = {
title: "汤氏家训辑录", title: "汤氏家训辑录",
category: "家风家训", category: "家风家训",
@@ -136,6 +131,9 @@ const draftFixture = {
"孝友传家,勤俭立业;敬祖睦宗,诚实待人。愿后人常怀感恩,彼此扶持。", "孝友传家,勤俭立业;敬祖睦宗,诚实待人。愿后人常怀感恩,彼此扶持。",
}; };
const editorState = ref("form"); const editorState = ref("form");
const articleId = ref("");
const editorMode = ref("create");
const simulateSaveFailure = ref(false);
const allowedStates = new Set([ const allowedStates = new Set([
"draft", "draft",
"loading", "loading",
@@ -156,8 +154,8 @@ const actionLabel = computed(() =>
: "保存谱文", : "保存谱文",
); );
const fillDraft = (failed = false) => { const fillDraft = () => {
Object.assign(form, draftFixture, failed ? { title: "保存失败" } : {}); Object.assign(form, draftFixture);
}; };
const showAllFieldErrors = () => { const showAllFieldErrors = () => {
fieldErrors.title = "请填写文章标题"; fieldErrors.title = "请填写文章标题";
@@ -166,9 +164,15 @@ const showAllFieldErrors = () => {
}; };
onLoad((query) => { onLoad((query) => {
articleId.value = query.articleId || "";
editorMode.value = articleId.value || query.mode === "edit" ? "edit" : "create";
const requestedState = allowedStates.has(query.state) ? query.state : "form"; const requestedState = allowedStates.has(query.state) ? query.state : "form";
if (["draft", "saving", "error"].includes(requestedState)) { simulateSaveFailure.value = requestedState === "error";
fillDraft(requestedState === "error"); if (
editorMode.value === "edit" ||
["draft", "saving", "error"].includes(requestedState)
) {
fillDraft();
} }
if (requestedState === "validation") showAllFieldErrors(); if (requestedState === "validation") showAllFieldErrors();
editorState.value = requestedState; editorState.value = requestedState;
@@ -197,12 +201,18 @@ const submit = () => {
} }
editorState.value = "saving"; editorState.value = "saving";
saveTimer = setTimeout(() => { saveTimer = setTimeout(() => {
editorState.value = editorState.value = simulateSaveFailure.value ? "error" : "success";
form.title.trim() === "保存失败" ? "error" : "success"; simulateSaveFailure.value = false;
saveTimer = null; saveTimer = null;
}, 320); }, 320);
}; };
const returnToList = () => uni.navigateBack(); const returnToList = () =>
uni.redirectTo({
url:
editorMode.value === "edit" && articleId.value
? `/pages/family/f05-article-detail?articleId=${articleId.value}`
: "/pages/family/f04-article-list",
});
onUnload(() => { onUnload(() => {
if (saveTimer) clearTimeout(saveTimer); if (saveTimer) clearTimeout(saveTimer);
@@ -211,16 +221,15 @@ onUnload(() => {
<style scoped lang="scss"> <style scoped lang="scss">
.article-editor-page { .article-editor-page {
position: relative; display: flex;
min-height: 100vh; min-height: 100vh;
overflow-x: hidden; flex-direction: column;
background: $paper; background: $paper;
} }
.article-editor-page__header, .article-editor-page__header,
.article-editor-loading, .article-editor-loading,
.article-editor-content { .article-editor-content {
position: relative; z-index: 1;
z-index: 2;
} }
.article-editor-loading { .article-editor-loading {
padding-top: 150rpx; padding-top: 150rpx;
@@ -230,7 +239,6 @@ onUnload(() => {
} }
.editor-panel, .editor-panel,
.editor-result-card { .editor-result-card {
position: relative;
width: 100%; width: 100%;
border: 20rpx solid transparent; border: 20rpx solid transparent;
border-image-source: url("/static/assets/modules/family/transparent/module-content-frame.png"); border-image-source: url("/static/assets/modules/family/transparent/module-content-frame.png");
@@ -240,8 +248,6 @@ onUnload(() => {
box-sizing: border-box; box-sizing: border-box;
} }
.editor-panel__body { .editor-panel__body {
position: relative;
z-index: 1;
padding: 38rpx 34rpx 42rpx; padding: 38rpx 34rpx 42rpx;
} }
.editor-eyebrow { .editor-eyebrow {
@@ -279,22 +285,13 @@ onUnload(() => {
font-weight: 700; font-weight: 700;
} }
.editor-control { .editor-control {
position: relative;
display: flex; display: flex;
height: 82rpx; min-height: 82rpx;
align-items: center; align-items: center;
} background: url("/static/assets/modules/family/transparent/module-field-frame.png") center / 100% 100% no-repeat;
.editor-control > image {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
pointer-events: none;
} }
.editor-control input, .editor-control input,
.editor-control textarea { .editor-control textarea {
position: relative;
z-index: 1;
box-sizing: border-box; box-sizing: border-box;
width: 100%; width: 100%;
color: $ink; color: $ink;
@@ -302,17 +299,17 @@ onUnload(() => {
} }
.editor-control input { .editor-control input {
width: 100%; width: 100%;
height: 82rpx; min-height: 82rpx;
padding: 0 28rpx; padding: 0 28rpx;
} }
.editor-control--textarea { .editor-control--textarea {
height: 220rpx; min-height: 220rpx;
padding: 20rpx 24rpx; padding: 20rpx 24rpx;
box-sizing: border-box; box-sizing: border-box;
} }
.editor-control textarea { .editor-control textarea {
width: 100%; width: 100%;
height: 180rpx; min-height: 180rpx;
line-height: 1.65; line-height: 1.65;
} }
.editor-control--error { .editor-control--error {
@@ -350,8 +347,6 @@ onUnload(() => {
min-height: 420rpx; min-height: 420rpx;
} }
.editor-result-card__body { .editor-result-card__body {
position: relative;
z-index: 1;
padding: 112rpx 52rpx 68rpx; padding: 112rpx 52rpx 68rpx;
text-align: center; text-align: center;
} }
+129 -3
View File
@@ -1,5 +1,131 @@
<!-- 页面编号F-07用途家族相册列表 --> <!-- 页面编号F-07用途家族相册列表创建与受控状态 -->
<template><ModulePage page-id="f07" /></template> <template>
<view class="album-list-page" :class="albumStateClasses">
<ModulePageBackground module="family" />
<view class="album-list-header"><PageHeader title="家族相册" action="新建" @action="createAlbum" /></view>
<view v-if="albumState === 'loading'" class="album-list-loading">
<AppLoading text="正在整理家族相册" description="请稍候,正在读取照片与更新时间。" />
</view>
<view v-else class="album-list-content">
<template v-if="albumState === 'ready'">
<view class="album-list-lead"><text>让每一张照片都回到家人身边</text><text> {{ albums.length }} 本相册</text></view>
<view v-if="albums.length" class="album-list">
<view v-for="album in albums" :key="album.id" class="album-card" role="button" :aria-label="`打开相册${album.name}`" @click="openAlbum(album)">
<view class="album-card__media"><image class="album-card__cover" :src="album.cover" mode="aspectFill" :alt="album.name" /></view>
<view class="album-card__copy">
<text>{{ album.name }}</text>
<text>{{ album.photoCount }} 张照片 · {{ album.updatedAt }}</text>
<text>{{ album.description }}</text>
</view>
</view>
<AppButton block label="新建相册" @click="createAlbum" />
</view>
<view v-else class="album-state-card">
<text>还没有相册</text><text>创建一本相册把团圆成长与祖居旧影整理在一起</text>
<AppButton block label="新建相册" @click="createAlbum" />
</view>
</template>
<view v-else class="album-state-card">
<text>{{ albumState === 'empty' ? '还没有相册' : '相册暂不可用' }}</text>
<text>{{ albumState === 'empty' ? '从第一本团圆相册开始收集家族影像。' : '请稍后重新查看,已有照片不会受到影响。' }}</text>
<AppButton :type="albumState === 'error' ? 'secondary' : 'primary'" block :label="albumState === 'error' ? '重新查看' : '新建相册'" @click="albumState === 'error' ? restoreAlbums() : createAlbum()" />
</view>
</view>
<AppDialog :visible="dialogVisible" eyebrow="新建相册" title="为家人整理一段影像" message="相册创建后可继续添加照片和说明。" confirm-text="创建相册" cancel-text="取消" show-cancel @confirm="confirmCreateAlbum" @cancel="closeCreateDialog">
<view class="album-dialog-field">
<text>相册名称</text>
<input v-model="albumNameDraft" maxlength="30" placeholder="例如:春节团圆" />
<text v-if="albumNameError">{{ albumNameError }}</text>
</view>
</AppDialog>
<AppToast :visible="toastVisible" message="相册已创建" />
</view>
</template>
<script setup> <script setup>
import ModulePage from "@/components/ModulePage.vue"; import { computed, onUnmounted, ref } from "vue";
import { onLoad } from "@dcloudio/uni-app";
import AppButton from "@/components/AppButton.vue";
import AppDialog from "@/components/AppDialog.vue";
import AppLoading from "@/components/AppLoading.vue";
import AppToast from "@/components/AppToast.vue";
import ModulePageBackground from "@/components/ModulePageBackground.vue";
import PageHeader from "@/components/PageHeader.vue";
const baseAlbums = [
{ id: "201", name: "2024 春节团圆", photoCount: 18, updatedAt: "今天更新", description: "三代家人的团圆饭与院前合影", cover: "/static/assets/modules/family/f08/f08-reunion-hero.png" },
{ id: "202", name: "祖居旧影", photoCount: 32, updatedAt: "5 月 10 日更新", description: "祖居、旧物与长辈珍藏的老照片", cover: "/static/assets/modules/family/f08/f08-ancestral-home.png" },
{ id: "203", name: "儿童成长", photoCount: 46, updatedAt: "持续更新", description: "记录孩子们每一个值得珍藏的瞬间", cover: "/static/assets/modules/family/f08/f08-family-portrait.png" },
];
const albums = ref([...baseAlbums]);
const albumState = ref("loading");
const dialogVisible = ref(false);
const albumNameDraft = ref("");
const albumNameError = ref("");
const toastVisible = ref(false);
let toastTimer = null;
const albumStateClasses = computed(() => ({
[`album-list-state--${albumState.value}`]: true,
"album-state--empty": albumState.value === "empty",
"album-list-state--loading": albumState.value === "loading",
"album-list-state--error": albumState.value === "error",
}));
onLoad((query) => {
const count = Math.max(1, Math.min(Number(query.count) || baseAlbums.length, 30));
albums.value = Array.from({ length: count }, (_, index) => ({
...baseAlbums[index % baseAlbums.length],
id: String(201 + index),
name: count > baseAlbums.length ? `${baseAlbums[index % baseAlbums.length].name}${index + 1}` : baseAlbums[index].name,
}));
albumState.value = ["loading", "empty", "error"].includes(query.state) ? query.state : "ready";
});
const openAlbum = (album) => uni.navigateTo({ url: `/pages/family/f08-album-detail?albumId=${album.id}` });
const createAlbum = () => { albumNameDraft.value = ""; albumNameError.value = ""; dialogVisible.value = true; };
const closeCreateDialog = () => { dialogVisible.value = false; };
const confirmCreateAlbum = () => {
const name = albumNameDraft.value.trim();
if (!name) { albumNameError.value = "请填写相册名称"; return; }
albums.value.unshift({ id: String(Date.now()), name, photoCount: 0, updatedAt: "刚刚创建", description: "等待添加第一张照片", cover: "/static/assets/modules/family/f08/f08-reunion-hero.png" });
albumState.value = "ready";
dialogVisible.value = false;
toastVisible.value = true;
toastTimer = setTimeout(() => { toastVisible.value = false; }, 1800);
};
const restoreAlbums = () => { albumState.value = "ready"; };
onUnmounted(() => { if (toastTimer) clearTimeout(toastTimer); });
</script> </script>
<style scoped lang="scss">
.album-list-page { display: flex; min-height: 100vh; flex-direction: column; background: $paper; }
.album-list-header, .album-list-loading, .album-list-content { z-index: 1; }
.album-list-loading { min-height: calc(100vh - 100rpx); }
.album-list-content { padding: 18rpx 24rpx 72rpx; }
.album-list-lead { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 8rpx 18rpx; min-height: 62rpx; padding: 0 20rpx; color: $ink-muted; font-size: 22rpx; background: url("/static/assets/modules/genealogy/transparent/section-divider.png") center / 100% 100% no-repeat; }
.album-list { display: flex; flex-direction: column; gap: 16rpx; margin-top: 16rpx; }
.album-card, .album-state-card { width: 100%; box-sizing: border-box; background: url("/static/assets/modules/family/transparent/module-content-frame.png") center / 100% 100% no-repeat; }
.album-card { display: grid; grid-template-columns: minmax(150rpx, 0.7fr) minmax(0, 1.3fr); min-height: 210rpx; gap: 22rpx; padding: 30rpx 38rpx; }
.album-card__media { width: 100%; aspect-ratio: 4 / 3; align-self: center; }
.album-card__cover { display: block; width: 100%; height: 100%; }
.album-card__copy { align-self: center; min-width: 0; }
.album-card__copy text { display: block; overflow-wrap: anywhere; }
.album-card__copy text:first-child { color: $ink; font-family: STKaiti, KaiTi, serif; font-size: 30rpx; font-weight: 700; }
.album-card__copy text:nth-child(2) { margin-top: 9rpx; color: $brand-red; font-size: 21rpx; }
.album-card__copy text:last-child { margin-top: 8rpx; color: $ink-muted; font-size: 22rpx; line-height: 1.5; }
.album-list > .app-button { margin-top: 8rpx; }
.album-state-card { min-height: 340rpx; margin-top: 30rpx; padding: 78rpx 52rpx 50rpx; text-align: center; }
.album-state-card > text { display: block; }
.album-state-card > text:first-child { color: $ink; font-family: STKaiti, KaiTi, serif; font-size: 35rpx; font-weight: 700; }
.album-state-card > text:nth-child(2) { margin-top: 18rpx; color: $ink-muted; font-size: 24rpx; line-height: 1.65; }
.album-state-card .app-button { margin-top: 28rpx; }
.album-dialog-field { width: 100%; margin: 24rpx 0; text-align: left; }
.album-dialog-field > text:first-child { display: block; color: $ink; font-size: 23rpx; font-weight: 700; }
.album-dialog-field input { width: 100%; min-height: 76rpx; margin-top: 10rpx; padding: 0 22rpx; box-sizing: border-box; color: $ink; font-size: 24rpx; background: url("/static/assets/modules/family/transparent/module-field-frame.png") center / 100% 100% no-repeat; }
.album-dialog-field > text:last-child { display: block; margin-top: 8rpx; color: $brand-red; font-size: 21rpx; }
@media (max-width: 340px) { .album-card { grid-template-columns: 130rpx minmax(0, 1fr); gap: 16rpx; padding-right: 30rpx; padding-left: 30rpx; } }
</style>
+11 -22
View File
@@ -68,7 +68,7 @@
</view> </view>
</view> </view>
<view class="album-upload-action" @click="showUploadNotice"> <view class="album-upload-action" @click="toUpload">
<AppButton block label="添加照片" /> <AppButton block label="添加照片" />
</view> </view>
</template> </template>
@@ -93,24 +93,20 @@
</view> </view>
</view> </view>
<AppToast :visible="uploadNoticeVisible" :message="uploadNoticeMessage" />
</view> </view>
</template> </template>
<script setup> <script setup>
import { ref } from "vue"; import { ref } from "vue";
import { onBackPress, onLoad, onUnload } from "@dcloudio/uni-app"; import { onBackPress, onLoad } from "@dcloudio/uni-app";
import AppButton from "@/components/AppButton.vue"; import AppButton from "@/components/AppButton.vue";
import AppToast from "@/components/AppToast.vue";
import ModulePageBackground from "@/components/ModulePageBackground.vue"; import ModulePageBackground from "@/components/ModulePageBackground.vue";
import PageHeader from "@/components/PageHeader.vue"; import PageHeader from "@/components/PageHeader.vue";
const albumState = ref("normal"); const albumState = ref("normal");
const albumId = ref("");
const previewVisible = ref(false); const previewVisible = ref(false);
const previewIndex = ref(0); const previewIndex = ref(0);
const uploadNoticeVisible = ref(false);
const uploadNoticeMessage = "上传照片将在入口接通后开放";
let uploadNoticeTimer;
const photos = [ const photos = [
{ src: "/static/assets/modules/family/f08/f08-reunion-hero.png", alt: "春节团圆时三代家人的合影", caption: "除夕团圆 · 2024" }, { src: "/static/assets/modules/family/f08/f08-reunion-hero.png", alt: "春节团圆时三代家人的合影", caption: "除夕团圆 · 2024" },
@@ -131,19 +127,17 @@ const closePreview = () => {
albumState.value = "normal"; albumState.value = "normal";
}; };
const showUploadNotice = () => { const toUpload = () =>
uploadNoticeVisible.value = true; uni.navigateTo({
clearTimeout(uploadNoticeTimer); url: `/pages/family/f09-media-upload?albumId=${albumId.value}`,
uploadNoticeTimer = setTimeout(() => { });
uploadNoticeVisible.value = false;
}, 1800);
};
const returnToAlbums = () => { const returnToAlbums = () => {
uni.redirectTo({ url: "/pages/family/f07-album-list" }); uni.redirectTo({ url: "/pages/family/f07-album-list" });
}; };
onLoad((query) => { onLoad((query) => {
albumId.value = query.albumId || "reunion";
albumState.value = albumState.value =
query.state === "empty" query.state === "empty"
? "empty" ? "empty"
@@ -161,22 +155,18 @@ onBackPress(() => {
return true; return true;
}); });
onUnload(() => {
clearTimeout(uploadNoticeTimer);
});
</script> </script>
<style scoped lang="scss"> <style scoped lang="scss">
.album-detail-page { .album-detail-page {
position: relative; display: flex;
min-height: 100vh; min-height: 100vh;
overflow-x: hidden; flex-direction: column;
background: $paper; background: $paper;
} }
.album-detail-header, .album-detail-header,
.album-detail-content { .album-detail-content {
position: relative; z-index: 1;
z-index: 2;
} }
.album-detail-content { .album-detail-content {
padding: 22rpx 24rpx calc(48rpx + env(safe-area-inset-bottom)); padding: 22rpx 24rpx calc(48rpx + env(safe-area-inset-bottom));
@@ -265,7 +255,6 @@ onUnload(() => {
} }
.album-empty-state, .album-empty-state,
.album-expired-state { .album-expired-state {
position: relative;
width: 100%; width: 100%;
min-height: 430rpx; min-height: 430rpx;
box-sizing: border-box; box-sizing: border-box;
+11 -9
View File
@@ -237,6 +237,7 @@ const mockLibrary = [
]; ];
const selectedPhotos = ref([]); const selectedPhotos = ref([]);
const albumId = ref("");
const activePhoto = computed( const activePhoto = computed(
() => selectedPhotos.value[activePhotoIndex.value] || null, () => selectedPhotos.value[activePhotoIndex.value] || null,
); );
@@ -322,10 +323,13 @@ const retryFailed = () => {
}; };
const returnToAlbum = () => { const returnToAlbum = () => {
uni.redirectTo({ url: "/pages/family/f08-album-detail" }); uni.redirectTo({
url: `/pages/family/f08-album-detail?albumId=${albumId.value}`,
});
}; };
onLoad((query) => { onLoad((query) => {
albumId.value = query.albumId || "reunion";
uploadState.value = ["permission", "selected", "uploading", "error", "success"].includes(query.state) uploadState.value = ["permission", "selected", "uploading", "error", "success"].includes(query.state)
? query.state ? query.state
: "initial"; : "initial";
@@ -339,21 +343,19 @@ onLoad((query) => {
<style scoped lang="scss"> <style scoped lang="scss">
.media-upload-page { .media-upload-page {
position: relative; display: flex;
min-height: 100vh; min-height: 100vh;
overflow-x: hidden; flex-direction: column;
background: $paper; background: $paper;
} }
.media-upload-header, .media-upload-header,
.media-upload-content { .media-upload-content {
position: relative; z-index: 1;
z-index: 2;
} }
.media-upload-content { .media-upload-content {
padding: 20rpx 24rpx calc(48rpx + env(safe-area-inset-bottom)); padding: 20rpx 24rpx calc(48rpx + env(safe-area-inset-bottom));
} }
.media-album-card { .media-album-card {
position: relative;
display: grid; display: grid;
min-height: 154rpx; min-height: 154rpx;
box-sizing: border-box; box-sizing: border-box;
@@ -420,7 +422,6 @@ onLoad((query) => {
} }
.media-photo-tile, .media-photo-tile,
.media-add-tile { .media-add-tile {
position: relative;
min-width: 0; min-width: 0;
min-height: 44px; min-height: 44px;
overflow: hidden; overflow: hidden;
@@ -429,6 +430,9 @@ onLoad((query) => {
border: 2rpx solid rgba(179, 133, 63, 0.62); border: 2rpx solid rgba(179, 133, 63, 0.62);
background: rgba(247, 241, 231, 0.88); background: rgba(247, 241, 231, 0.88);
} }
.media-photo-tile {
position: relative;
}
.media-photo-tile--active { .media-photo-tile--active {
outline: 4rpx solid rgba(159, 44, 35, 0.78); outline: 4rpx solid rgba(159, 44, 35, 0.78);
outline-offset: -4rpx; outline-offset: -4rpx;
@@ -570,7 +574,6 @@ onLoad((query) => {
.media-batch-field textarea, .media-batch-field textarea,
.media-photo-field textarea { .media-photo-field textarea {
width: 100%; width: 100%;
height: 92rpx;
min-height: 92rpx; min-height: 92rpx;
margin-top: 12rpx; margin-top: 12rpx;
box-sizing: border-box; box-sizing: border-box;
@@ -601,7 +604,6 @@ onLoad((query) => {
} }
.media-permission-card, .media-permission-card,
.media-success-card { .media-success-card {
position: relative;
min-height: 410rpx; min-height: 410rpx;
margin-top: 24rpx; margin-top: 24rpx;
box-sizing: border-box; box-sizing: border-box;
+7 -16
View File
@@ -46,56 +46,47 @@ const returnToFamily = () => {
<style scoped lang="scss"> <style scoped lang="scss">
.video-status-page { .video-status-page {
position: relative;
min-height: 100vh; min-height: 100vh;
overflow-x: hidden;
background: $paper; background: $paper;
} }
.video-status-header,
.video-status-content {
position: relative;
z-index: 2;
}
.video-status-content { .video-status-content {
padding: 16rpx 26rpx 56rpx; padding: 16rpx 26rpx 56rpx;
} }
.video-status-lead { .video-status-lead {
position: relative; display: grid;
height: 56rpx; min-height: 56rpx;
margin: 0 12rpx 18rpx; margin: 0 12rpx 18rpx;
color: $ink-muted; color: $ink-muted;
font-size: 23rpx; font-size: 23rpx;
text-align: center; text-align: center;
} }
.video-status-lead image { .video-status-lead image {
position: absolute; grid-area: 1 / 1;
inset: 0;
width: 100%; width: 100%;
height: 100%; height: 100%;
} }
.video-status-lead text { .video-status-lead text {
position: relative;
z-index: 1; z-index: 1;
display: flex; display: flex;
grid-area: 1 / 1;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
height: 100%; height: 100%;
} }
.video-status-card { .video-status-card {
position: relative; display: grid;
width: 100%; width: 100%;
min-height: 330rpx; min-height: 330rpx;
text-align: center; text-align: center;
} }
.video-status-card__skin { .video-status-card__skin {
position: absolute; grid-area: 1 / 1;
inset: 0;
width: 100%; width: 100%;
height: 100%; height: 100%;
} }
.video-status-card__body { .video-status-card__body {
position: relative;
z-index: 1; z-index: 1;
grid-area: 1 / 1;
padding: 70rpx 60rpx 48rpx; padding: 70rpx 60rpx 48rpx;
} }
.video-status-card__eyebrow, .video-status-card__eyebrow,
+70 -194
View File
@@ -21,11 +21,6 @@
</view> </view>
<view v-else-if="hasError" class="state-panel state-panel--error"> <view v-else-if="hasError" class="state-panel state-panel--error">
<image
class="error-panel__frame"
src="/static/assets/modules/genealogy/transparent/g01-empty-panel-frame.png"
mode="scaleToFill"
/>
<view class="error-panel__content"> <view class="error-panel__content">
<image <image
class="error-panel__seal" class="error-panel__seal"
@@ -44,11 +39,6 @@
hover-class="action-hover" hover-class="action-hover"
@click="retryLoad" @click="retryLoad"
> >
<image
class="state-retry__skin"
src="/static/assets/foundation/transparent/a01-scroll-primary-v3.png"
mode="aspectFit"
/>
<text class="state-retry__copy">重新加载</text> <text class="state-retry__copy">重新加载</text>
</view> </view>
</view> </view>
@@ -57,18 +47,8 @@
<template v-else-if="hasGenealogies"> <template v-else-if="hasGenealogies">
<view class="genealogy-fixed-zone"> <view class="genealogy-fixed-zone">
<view class="current-slip" @click="openSwitcher"> <view class="current-slip" @click="openSwitcher">
<image
class="current-frame"
src="/static/assets/modules/genealogy/transparent/current-slip-frame.png"
mode="scaleToFill"
/>
<view class="current-summary"> <view class="current-summary">
<view class="current-seal"> <view class="current-seal">
<image
class="current-seal-frame"
src="/static/assets/modules/genealogy/transparent/current-seal-frame.png"
mode="scaleToFill"
/>
<text class="current-seal-title">家谱</text> <text class="current-seal-title">家谱</text>
</view> </view>
<text class="current-name">{{ currentGenealogy.name }}</text> <text class="current-name">{{ currentGenealogy.name }}</text>
@@ -165,11 +145,6 @@
hover-class="action-hover" hover-class="action-hover"
@click="openApplication(item)" @click="openApplication(item)"
> >
<image
class="application-record__skin"
src="/static/assets/modules/genealogy/transparent/list-slip-frame.png"
mode="scaleToFill"
/>
<view class="application-record__main"> <view class="application-record__main">
<view class="application-record__title-row"> <view class="application-record__title-row">
<text class="application-record__name">{{ <text class="application-record__name">{{
@@ -217,18 +192,8 @@
<view v-else class="genealogy-empty-state"> <view v-else class="genealogy-empty-state">
<view class="empty-panel"> <view class="empty-panel">
<image
class="empty-panel__frame"
src="/static/assets/modules/genealogy/transparent/g01-empty-panel-frame.png"
mode="scaleToFill"
/>
<view class="empty-panel__content"> <view class="empty-panel__content">
<view class="empty-seal"> <view class="empty-seal">
<image
class="empty-seal__skin"
src="/static/assets/modules/genealogy/transparent/current-seal-frame.png"
mode="scaleToFill"
/>
<text class="empty-seal__text">家谱</text> <text class="empty-seal__text">家谱</text>
</view> </view>
<text class="empty-title">还没有加入任何家谱</text> <text class="empty-title">还没有加入任何家谱</text>
@@ -245,11 +210,6 @@
hover-class="action-hover" hover-class="action-hover"
@click="applyToJoin" @click="applyToJoin"
> >
<image
class="empty-search-action__skin"
src="/static/assets/foundation/transparent/a01-scroll-primary-v3.png"
mode="aspectFit"
/>
<text <text
class="empty-search-action__copy empty-search-action__copy--primary" class="empty-search-action__copy empty-search-action__copy--primary"
>搜索家谱</text >搜索家谱</text
@@ -260,11 +220,6 @@
hover-class="action-hover" hover-class="action-hover"
@click="joinByInvite" @click="joinByInvite"
> >
<image
class="empty-invite-action__skin"
src="/static/assets/foundation/transparent/a01-scroll-secondary-v3.png"
mode="aspectFit"
/>
<text class="empty-invite-action__copy">邀请码加入</text> <text class="empty-invite-action__copy">邀请码加入</text>
</view> </view>
<view <view
@@ -393,6 +348,7 @@ import GenealogyCard from "@/components/GenealogyCard.vue";
import GenealogyPageBackground from "@/components/GenealogyPageBackground.vue"; import GenealogyPageBackground from "@/components/GenealogyPageBackground.vue";
import PageHeader from "@/components/PageHeader.vue"; import PageHeader from "@/components/PageHeader.vue";
import { genealogies, notifications } from "@/data/mock.js"; import { genealogies, notifications } from "@/data/mock.js";
import { genealogyContext } from "@/utils/genealogy-context.js";
const isLoading = ref(false); const isLoading = ref(false);
const hasError = ref(false); const hasError = ref(false);
@@ -400,7 +356,12 @@ const list = ref(genealogies);
const forceEmptyState = ref(false); const forceEmptyState = ref(false);
const addDialogVisible = ref(false); const addDialogVisible = ref(false);
const switcherVisible = ref(false); const switcherVisible = ref(false);
const selectedGenealogyId = ref(genealogies[0]?.id || null); const storedGenealogyId = Number(genealogyContext.getCurrentGenealogyId());
const selectedGenealogyId = ref(
genealogies.some((item) => item.id === storedGenealogyId)
? storedGenealogyId
: genealogies[0]?.id || null,
);
const listScrollCommand = ref(0); const listScrollCommand = ref(0);
const currentListScrollTop = ref(0); const currentListScrollTop = ref(0);
@@ -422,7 +383,14 @@ const syncEmptyStateFromRoute = () => {
hasError.value = presentationState === "error"; hasError.value = presentationState === "error";
}; };
onLoad(() => { onLoad((query) => {
const requestedId = Number(query?.genealogyId);
if (genealogies.some((item) => item.id === requestedId)) {
selectedGenealogyId.value = requestedId;
genealogyContext.setCurrentGenealogyId(requestedId);
} else if (selectedGenealogyId.value) {
genealogyContext.setCurrentGenealogyId(selectedGenealogyId.value);
}
syncEmptyStateFromRoute(); syncEmptyStateFromRoute();
}); });
@@ -440,10 +408,10 @@ const isListLayout = computed(
() => !isLoading.value && !hasError.value && hasGenealogies.value, () => !isLoading.value && !hasError.value && hasGenealogies.value,
); );
const createdGenealogies = computed(() => const createdGenealogies = computed(() =>
list.value.filter((item, index) => index === 0), list.value.filter((item) => item.membership === "created"),
); );
const joinedGenealogies = computed(() => const joinedGenealogies = computed(() =>
list.value.filter((item, index) => index > 0), list.value.filter((item) => item.membership === "joined"),
); );
const availableGenealogies = computed(() => list.value); const availableGenealogies = computed(() => list.value);
const currentGenealogy = computed( const currentGenealogy = computed(
@@ -501,10 +469,12 @@ const shortcuts = [
}, },
]; ];
const openGenealogy = (genealogy) => const openGenealogy = (genealogy) => {
genealogyContext.setCurrentGenealogyId(genealogy.id);
uni.navigateTo({ uni.navigateTo({
url: `/pages/genealogy/g05-genealogy-overview?genealogyId=${genealogy.id}`, url: `/pages/genealogy/g05-genealogy-overview?genealogyId=${genealogy.id}`,
}); });
};
const createGenealogy = () => const createGenealogy = () =>
uni.navigateTo({ url: "/pages/genealogy/g03-create-genealogy" }); uni.navigateTo({ url: "/pages/genealogy/g03-create-genealogy" });
const applyToJoin = () => const applyToJoin = () =>
@@ -550,6 +520,7 @@ const resetListScroll = async () => {
}; };
const selectGenealogy = async (genealogy) => { const selectGenealogy = async (genealogy) => {
selectedGenealogyId.value = genealogy.id; selectedGenealogyId.value = genealogy.id;
genealogyContext.setCurrentGenealogyId(genealogy.id);
closeSwitcher(); closeSwitcher();
await resetListScroll(); await resetListScroll();
}; };
@@ -565,11 +536,14 @@ const retryLoad = () => {
}; };
const openShortcut = (key) => { const openShortcut = (key) => {
if (!currentGenealogy.value) return;
const genealogyId = currentGenealogy.value.id;
genealogyContext.setCurrentGenealogyId(genealogyId);
const paths = { const paths = {
tree: "/pages/tree/t01-tree-overview", tree: `/pages/tree/t01-tree-overview?genealogyId=${currentGenealogy.value.id}`,
members: `/pages/genealogy/g05-genealogy-overview?genealogyId=${currentGenealogy.value.id}`, members: `/pages/genealogy/g05-genealogy-overview?genealogyId=${currentGenealogy.value.id}`,
poem: "/pages/genealogy/g12-generation-poems", poem: `/pages/genealogy/g12-generation-poems?genealogyId=${currentGenealogy.value.id}`,
applications: "/pages/genealogy/g10-application-review", applications: `/pages/genealogy/g10-application-review?genealogyId=${currentGenealogy.value.id}`,
}; };
uni.navigateTo({ url: paths[key] }); uni.navigateTo({ url: paths[key] });
}; };
@@ -577,15 +551,15 @@ const openShortcut = (key) => {
<style scoped lang="scss"> <style scoped lang="scss">
.genealogy-index { .genealogy-index {
position: relative; display: flex;
min-height: 100vh; min-height: 100vh;
flex-direction: column;
overflow: hidden; overflow: hidden;
background: #f9f6ef; background: #f9f6ef;
} }
.genealogy-content { .genealogy-content {
position: relative; z-index: 1;
z-index: 2;
padding: 24rpx 32rpx 176rpx; padding: 24rpx 32rpx 176rpx;
} }
@@ -618,29 +592,14 @@ const openShortcut = (key) => {
} }
.current-slip { .current-slip {
position: relative;
display: block; display: block;
min-height: 268rpx; min-height: 268rpx;
padding: 30rpx 34rpx 28rpx; padding: 30rpx 34rpx 28rpx;
box-sizing: border-box; box-sizing: border-box;
background: transparent; background: url("/static/assets/modules/genealogy/transparent/current-slip-frame.png") center / 100% 100% no-repeat;
}
.current-frame {
position: absolute;
top: 0;
right: 0;
bottom: 0;
left: 0;
z-index: 0;
width: 100%;
height: 100%;
pointer-events: none;
} }
.current-seal { .current-seal {
position: relative;
z-index: 1;
display: flex; display: flex;
width: 80rpx; width: 80rpx;
height: 128rpx; height: 128rpx;
@@ -648,22 +607,10 @@ const openShortcut = (key) => {
align-items: center; align-items: center;
justify-content: center; justify-content: center;
margin-right: 24rpx; margin-right: 24rpx;
background: url("/static/assets/modules/genealogy/transparent/current-seal-frame.png") center / 100% 100% no-repeat;
color: #fff7e7; color: #fff7e7;
} }
.current-seal-frame {
position: absolute;
top: 0;
right: 0;
bottom: 0;
left: 0;
z-index: 0;
width: 100%;
height: 100%;
}
.current-seal-title { .current-seal-title {
position: relative;
z-index: 1;
color: #fff7e7; color: #fff7e7;
font-family: "STKaiti", "KaiTi", serif; font-family: "STKaiti", "KaiTi", serif;
font-size: 32rpx; font-size: 32rpx;
@@ -672,20 +619,16 @@ const openShortcut = (key) => {
writing-mode: vertical-rl; writing-mode: vertical-rl;
} }
.current-summary { .current-summary {
position: relative;
z-index: 1;
display: flex; display: flex;
min-width: 0; min-width: 0;
align-items: center; align-items: center;
} }
.current-name { .current-name {
overflow: hidden;
color: $ink; color: $ink;
font-family: "STKaiti", "KaiTi", serif; font-family: "STKaiti", "KaiTi", serif;
font-size: 50rpx; font-size: 50rpx;
font-weight: 700; font-weight: 700;
text-overflow: ellipsis; overflow-wrap: anywhere;
white-space: nowrap;
} }
.current-switch-copy { .current-switch-copy {
flex: 0 0 auto; flex: 0 0 auto;
@@ -694,8 +637,6 @@ const openShortcut = (key) => {
font-size: 28rpx; font-size: 28rpx;
} }
.current-info-divider { .current-info-divider {
position: relative;
z-index: 1;
width: 100%; width: 100%;
height: 1rpx; height: 1rpx;
margin: 14rpx 0 16rpx; margin: 14rpx 0 16rpx;
@@ -703,8 +644,6 @@ const openShortcut = (key) => {
} }
.current-meta { .current-meta {
position: relative;
z-index: 1;
display: flex; display: flex;
flex-wrap: nowrap; flex-wrap: nowrap;
justify-content: space-between; justify-content: space-between;
@@ -719,10 +658,10 @@ const openShortcut = (key) => {
display: flex; display: flex;
align-items: center; align-items: center;
margin: 0; margin: 0;
white-space: nowrap; flex-wrap: wrap;
.current-meta-item-text { .current-meta-item-text {
white-space: nowrap; overflow-wrap: anywhere;
} }
} }
.current-meta-icon { .current-meta-icon {
@@ -752,7 +691,6 @@ const openShortcut = (key) => {
height: 82rpx; height: 82rpx;
} }
.shortcut-label { .shortcut-label {
overflow: hidden;
width: 100%; width: 100%;
margin-top: 12rpx; margin-top: 12rpx;
color: $ink; color: $ink;
@@ -760,8 +698,7 @@ const openShortcut = (key) => {
font-size: 29rpx; font-size: 29rpx;
font-weight: 700; font-weight: 700;
text-align: center; text-align: center;
text-overflow: ellipsis; overflow-wrap: anywhere;
white-space: nowrap;
} }
.section-divider { .section-divider {
@@ -803,25 +740,17 @@ const openShortcut = (key) => {
} }
.application-record { .application-record {
position: relative;
display: flex; display: flex;
min-height: 138rpx; min-height: 138rpx;
align-items: center; align-items: center;
box-sizing: border-box; box-sizing: border-box;
padding: 22rpx 26rpx; padding: 22rpx 26rpx;
background: url("/static/assets/modules/genealogy/transparent/list-slip-frame.png") center / 100% 100% no-repeat;
} }
.application-record + .application-record { .application-record + .application-record {
margin-top: 12rpx; margin-top: 12rpx;
} }
.application-record__skin {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
}
.application-record__main { .application-record__main {
position: relative;
z-index: 1;
min-width: 0; min-width: 0;
flex: 1; flex: 1;
} }
@@ -831,13 +760,11 @@ const openShortcut = (key) => {
justify-content: space-between; justify-content: space-between;
} }
.application-record__name { .application-record__name {
overflow: hidden;
color: $ink; color: $ink;
font-family: "STKaiti", "KaiTi", serif; font-family: "STKaiti", "KaiTi", serif;
font-size: 32rpx; font-size: 32rpx;
font-weight: 700; font-weight: 700;
text-overflow: ellipsis; overflow-wrap: anywhere;
white-space: nowrap;
} }
.application-record__status { .application-record__status {
flex: 0 0 auto; flex: 0 0 auto;
@@ -861,8 +788,6 @@ const openShortcut = (key) => {
line-height: 1.4; line-height: 1.4;
} }
.application-record__chevron { .application-record__chevron {
position: relative;
z-index: 1;
width: 32rpx; width: 32rpx;
height: 32rpx; height: 32rpx;
margin-left: 14rpx; margin-left: 14rpx;
@@ -907,20 +832,18 @@ const openShortcut = (key) => {
} }
.create-action > text { .create-action > text {
flex: 0 0 auto; flex: 0 0 auto;
white-space: nowrap; text-align: center;
} }
.create-cloud--right { .create-cloud--right {
transform: scaleX(-1); transform: scaleX(-1);
} }
.state-panel { .state-panel {
position: relative;
display: flex; display: flex;
min-height: 540rpx; min-height: 540rpx;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
overflow: hidden;
padding: 42rpx; padding: 42rpx;
box-sizing: border-box; box-sizing: border-box;
text-align: center; text-align: center;
@@ -941,25 +864,15 @@ const openShortcut = (key) => {
} }
.state-panel--error { .state-panel--error {
height: 1120rpx;
min-height: 1120rpx; min-height: 1120rpx;
padding: 0; padding: 0;
} background: url("/static/assets/modules/genealogy/transparent/g01-empty-panel-frame.png") center / 100% 100% no-repeat;
.error-panel__frame {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
pointer-events: none;
} }
.error-panel__content { .error-panel__content {
position: relative;
z-index: 1;
display: flex; display: flex;
width: 100%; width: 100%;
height: 100%; min-height: 1120rpx;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
@@ -1000,7 +913,7 @@ const openShortcut = (key) => {
.state-panel--error .state-retry { .state-panel--error .state-retry {
width: 560rpx; width: 560rpx;
height: 124rpx; min-height: 124rpx;
margin-top: 22rpx; margin-top: 22rpx;
} }
@@ -1009,23 +922,13 @@ const openShortcut = (key) => {
} }
.empty-panel { .empty-panel {
position: relative; min-height: 1120rpx;
height: 1120rpx; background: url("/static/assets/modules/genealogy/transparent/g01-empty-panel-frame.png") center / 100% 100% no-repeat;
}
.empty-panel__frame {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
pointer-events: none;
} }
.empty-panel__content { .empty-panel__content {
position: relative;
z-index: 1;
display: flex; display: flex;
height: 100%; min-height: 1120rpx;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
@@ -1035,24 +938,15 @@ const openShortcut = (key) => {
} }
.empty-seal { .empty-seal {
position: relative;
display: flex; display: flex;
width: 120rpx; width: 120rpx;
height: 184rpx; height: 184rpx;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
} background: url("/static/assets/modules/genealogy/transparent/current-seal-frame.png") center / 100% 100% no-repeat;
.empty-seal__skin {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
} }
.empty-seal__text { .empty-seal__text {
position: relative;
z-index: 1;
color: #fff7e7; color: #fff7e7;
font-family: "STKaiti", "KaiTi", serif; font-family: "STKaiti", "KaiTi", serif;
font-size: 32rpx; font-size: 32rpx;
@@ -1089,35 +983,25 @@ const openShortcut = (key) => {
.empty-search-action, .empty-search-action,
.empty-invite-action { .empty-invite-action {
position: relative;
display: flex; display: flex;
width: 560rpx; width: 560rpx;
height: 124rpx; min-height: 124rpx;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
overflow: hidden;
} }
.empty-search-action { .empty-search-action {
margin-top: 26rpx; margin-top: 26rpx;
background: url("/static/assets/foundation/transparent/a01-scroll-primary-v3.png") center / contain no-repeat;
} }
.empty-invite-action { .empty-invite-action {
margin-top: 22rpx; margin-top: 22rpx;
} background: url("/static/assets/foundation/transparent/a01-scroll-secondary-v3.png") center / contain no-repeat;
.empty-search-action__skin,
.empty-invite-action__skin {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
} }
.empty-search-action__copy, .empty-search-action__copy,
.empty-invite-action__copy { .empty-invite-action__copy {
position: relative;
z-index: 1;
color: #7b4e24; color: #7b4e24;
font-family: "STKaiti", "KaiTi", serif; font-family: "STKaiti", "KaiTi", serif;
font-size: 34rpx; font-size: 34rpx;
@@ -1147,24 +1031,15 @@ const openShortcut = (key) => {
} }
.state-retry { .state-retry {
position: relative;
display: flex; display: flex;
width: 360rpx; width: 360rpx;
height: 96rpx; min-height: 96rpx;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
margin-top: 30rpx; margin-top: 30rpx;
overflow: hidden; background: url("/static/assets/foundation/transparent/a01-scroll-primary-v3.png") center / contain no-repeat;
}
.state-retry__skin {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
} }
.state-retry__copy { .state-retry__copy {
position: relative;
z-index: 1;
color: #fff9ec; color: #fff9ec;
font-family: "STKaiti", "KaiTi", serif; font-family: "STKaiti", "KaiTi", serif;
font-size: 29rpx; font-size: 29rpx;
@@ -1181,7 +1056,6 @@ const openShortcut = (key) => {
background: rgba(34, 20, 12, 0.68); background: rgba(34, 20, 12, 0.68);
} }
.add-dialog { .add-dialog {
position: relative;
width: 100%; width: 100%;
min-height: 780rpx; min-height: 780rpx;
max-height: calc(100vh - 80rpx); max-height: calc(100vh - 80rpx);
@@ -1193,8 +1067,6 @@ const openShortcut = (key) => {
border-image-repeat: stretch; border-image-repeat: stretch;
} }
.add-dialog__content { .add-dialog__content {
position: relative;
z-index: 2;
display: flex; display: flex;
min-height: 780rpx; min-height: 780rpx;
max-height: calc(100vh - 80rpx); max-height: calc(100vh - 80rpx);
@@ -1208,24 +1080,26 @@ const openShortcut = (key) => {
margin: auto 0; margin: auto 0;
} }
.add-dialog__heading { .add-dialog__heading {
position: relative; display: grid;
padding-right: 96rpx; grid-template-columns: minmax(0, 1fr) 96rpx;
} }
.add-dialog .dialog-title, .add-dialog .dialog-title,
.add-dialog .dialog-copy { .add-dialog .dialog-copy {
display: block; display: block;
grid-column: 1;
text-align: left; text-align: left;
} }
.add-dialog__close { .add-dialog__close {
position: absolute;
z-index: 3;
top: 0;
right: -22rpx;
display: flex; display: flex;
grid-column: 2;
grid-row: 1 / span 2;
align-self: start;
justify-self: end;
width: 80rpx; width: 80rpx;
height: 80rpx; height: 80rpx;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
margin-right: -22rpx;
} }
.add-dialog__close-icon { .add-dialog__close-icon {
width: 80rpx; width: 80rpx;
@@ -1258,7 +1132,6 @@ const openShortcut = (key) => {
background: rgba(34, 20, 12, 0.58); background: rgba(34, 20, 12, 0.58);
} }
.genealogy-switcher { .genealogy-switcher {
position: relative;
width: 670rpx; width: 670rpx;
max-width: 100%; max-width: 100%;
min-height: 600rpx; min-height: 600rpx;
@@ -1271,32 +1144,35 @@ const openShortcut = (key) => {
border-image-repeat: stretch; border-image-repeat: stretch;
} }
.genealogy-switcher__content { .genealogy-switcher__content {
position: relative; display: grid;
z-index: 1;
display: flex;
min-height: 600rpx; min-height: 600rpx;
max-height: calc(100vh - 120rpx); max-height: calc(100vh - 120rpx);
flex-direction: column; grid-template-rows: auto minmax(0, 1fr);
align-items: center; align-items: center;
box-sizing: border-box; box-sizing: border-box;
padding: 120rpx 58rpx 140rpx; padding: 120rpx 58rpx 140rpx;
} }
.genealogy-switcher__content > .dialog-title {
grid-area: 1 / 1;
}
.genealogy-switcher__close { .genealogy-switcher__close {
position: absolute;
z-index: 3;
top: 78rpx;
right: 34rpx;
display: flex; display: flex;
grid-area: 1 / 1;
align-self: start;
justify-self: end;
width: 80rpx; width: 80rpx;
height: 80rpx; height: 80rpx;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
margin-top: -42rpx;
margin-right: -24rpx;
} }
.genealogy-switcher__close-icon { .genealogy-switcher__close-icon {
width: 80rpx; width: 80rpx;
height: 80rpx; height: 80rpx;
} }
.genealogy-switcher__list { .genealogy-switcher__list {
grid-area: 2 / 1;
width: 100%; width: 100%;
min-height: 0; min-height: 0;
max-height: calc(100vh - 456rpx); max-height: calc(100vh - 456rpx);
+58 -104
View File
@@ -4,11 +4,6 @@
<GenealogyPageBackground /> <GenealogyPageBackground />
<view class="flow-header"> <view class="flow-header">
<image
class="flow-header__skin"
src="/static/assets/foundation/opaque/root-header-cinnabar.jpg"
mode="scaleToFill"
/>
<view class="flow-header__content"> <view class="flow-header__content">
<view class="flow-header__back" @click="goBack"> <view class="flow-header__back" @click="goBack">
<image <image
@@ -26,12 +21,6 @@
<view class="flow-content"> <view class="flow-content">
<view class="create-flow-panel"> <view class="create-flow-panel">
<image
class="create-flow-panel__skin"
src="/static/assets/modules/genealogy/transparent/g01-empty-panel-frame.png"
mode="scaleToFill"
/>
<view v-if="!isAncestorStep" class="create-flow-panel__content"> <view v-if="!isAncestorStep" class="create-flow-panel__content">
<view class="flow-step-label"><text>第一步 · 立谱信息</text></view> <view class="flow-step-label"><text>第一步 · 立谱信息</text></view>
<text class="flow-heading">为家族立一部可传承的谱</text> <text class="flow-heading">为家族立一部可传承的谱</text>
@@ -125,11 +114,6 @@
hover-class="action-hover" hover-class="action-hover"
@click="submitCreate" @click="submitCreate"
> >
<image
class="flow-primary-action__skin"
src="/static/assets/foundation/transparent/a01-scroll-primary-v3.png"
mode="aspectFit"
/>
<text class="flow-primary-action__copy">{{ <text class="flow-primary-action__copy">{{
createState === "submitting" ? "正在创建…" : "创建并录入首代" createState === "submitting" ? "正在创建…" : "创建并录入首代"
}}</text> }}</text>
@@ -161,13 +145,30 @@
<text>世代</text> <text>世代</text>
<text class="fixed-value">第一世</text> <text class="fixed-value">第一世</text>
</view> </view>
<picker
mode="selector"
:range="sexOptions"
range-key="label"
@change="changeAncestorSex"
>
<view class="field-row">
<text>性别</text>
<text class="fixed-value">{{ sexLabel }}</text>
</view>
</picker>
<view class="field-row"> <view class="field-row">
<text>出生日期</text> <text>出生日期</text>
<input <picker
v-model="ancestorForm.birthDate" mode="date"
placeholder="如:1940年" :value="ancestorForm.birthDate"
placeholder-class="placeholder" start="1800-01-01"
/> :end="new Date().toISOString().slice(0, 10)"
@change="changeAncestorBirthDate"
>
<text class="fixed-value">{{
ancestorForm.birthDate || "请选择日期"
}}</text>
</picker>
</view> </view>
</view> </view>
<text v-if="ancestorState === 'error'" class="flow-error" <text v-if="ancestorState === 'error'" class="flow-error"
@@ -178,6 +179,7 @@
<text>生平简述</text> <text>生平简述</text>
<textarea <textarea
v-model="ancestorForm.introduction" v-model="ancestorForm.introduction"
auto-height
maxlength="200" maxlength="200"
placeholder="可选,记录家训、迁徙或重要经历" placeholder="可选,记录家训、迁徙或重要经历"
placeholder-class="placeholder" placeholder-class="placeholder"
@@ -189,11 +191,6 @@
hover-class="action-hover" hover-class="action-hover"
@click="submitAncestor" @click="submitAncestor"
> >
<image
class="flow-primary-action__skin"
src="/static/assets/foundation/transparent/a01-scroll-primary-v3.png"
mode="aspectFit"
/>
<text class="flow-primary-action__copy">{{ <text class="flow-primary-action__copy">{{
ancestorState === "submitting" ? "正在保存…" : "保存并进入家谱" ancestorState === "submitting" ? "正在保存…" : "保存并进入家谱"
}}</text> }}</text>
@@ -208,11 +205,6 @@
@click="closeDuplicateReminder" @click="closeDuplicateReminder"
> >
<view class="duplicate-reminder" @click.stop> <view class="duplicate-reminder" @click.stop>
<image
class="duplicate-reminder__skin"
src="/static/assets/modules/auth/transparent/a01-scroll-dialog-v3.png"
mode="aspectFit"
/>
<view class="duplicate-reminder__content"> <view class="duplicate-reminder__content">
<text class="duplicate-reminder__title">先确认是否已有家谱</text> <text class="duplicate-reminder__title">先确认是否已有家谱</text>
<text class="duplicate-reminder__copy" <text class="duplicate-reminder__copy"
@@ -222,17 +214,9 @@
class="duplicate-reminder__search" class="duplicate-reminder__search"
@click="searchExistingGenealogy" @click="searchExistingGenealogy"
> >
<image
src="/static/assets/foundation/transparent/a01-scroll-secondary-v3.png"
mode="aspectFit"
/>
<text>先搜索已有家谱</text> <text>先搜索已有家谱</text>
</view> </view>
<view class="duplicate-reminder__confirm" @click="confirmCreate"> <view class="duplicate-reminder__confirm" @click="confirmCreate">
<image
src="/static/assets/foundation/transparent/a01-scroll-primary-v3.png"
mode="aspectFit"
/>
<text>确认没有继续创建</text> <text>确认没有继续创建</text>
</view> </view>
<view <view
@@ -246,21 +230,12 @@
<view v-if="ancestorState === 'success'" class="flow-success-layer"> <view v-if="ancestorState === 'success'" class="flow-success-layer">
<view class="flow-success-dialog"> <view class="flow-success-dialog">
<image
class="flow-success-dialog__skin"
src="/static/assets/modules/auth/transparent/a01-scroll-dialog-v3.png"
mode="aspectFit"
/>
<view class="flow-success-dialog__content"> <view class="flow-success-dialog__content">
<text class="flow-success-dialog__title">家谱创建完成</text> <text class="flow-success-dialog__title">家谱创建完成</text>
<text class="flow-success-dialog__copy" <text class="flow-success-dialog__copy"
>首代人物已保存接下来进入家谱总览继续完善资料</text >首代人物已保存接下来进入家谱总览继续完善资料</text
> >
<view class="flow-success-dialog__action" @click="enterOverview"> <view class="flow-success-dialog__action" @click="enterOverview">
<image
src="/static/assets/foundation/transparent/a01-scroll-primary-v3.png"
mode="aspectFit"
/>
<text>进入家谱总览</text> <text>进入家谱总览</text>
</view> </view>
</view> </view>
@@ -302,6 +277,22 @@ const ancestorForm = reactive({
birthDate: "", birthDate: "",
introduction: "", introduction: "",
}); });
const sexOptions = [
{ value: "0", label: "男" },
{ value: "1", label: "女" },
{ value: "2", label: "暂不填写" },
];
const sexLabel = computed(
() =>
sexOptions.find((option) => option.value === ancestorForm.sex)?.label ||
"请选择",
);
const changeAncestorSex = (event) => {
ancestorForm.sex = sexOptions[Number(event.detail.value)]?.value || "2";
};
const changeAncestorBirthDate = (event) => {
ancestorForm.birthDate = event.detail.value;
};
const isAncestorStep = computed(() => currentStep.value === "ancestor"); const isAncestorStep = computed(() => currentStep.value === "ancestor");
@@ -426,29 +417,21 @@ const enterOverview = () =>
<style scoped lang="scss"> <style scoped lang="scss">
.flow-page { .flow-page {
position: relative; display: flex;
min-height: 100vh; min-height: 100vh;
overflow-x: hidden; flex-direction: column;
background: #f9f6ef; background: #f9f6ef;
} }
.flow-header { .flow-header {
position: relative;
z-index: 3; z-index: 3;
height: 112rpx; height: 112rpx;
overflow: hidden; overflow: hidden;
} background: url("/static/assets/foundation/opaque/root-header-cinnabar.jpg")
center / 100% 100% no-repeat;
.flow-header__skin {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
pointer-events: none;
} }
.flow-header__content { .flow-header__content {
position: relative;
z-index: 1; z-index: 1;
display: flex; display: flex;
height: 100%; height: 100%;
@@ -482,26 +465,17 @@ const enterOverview = () =>
} }
.flow-content { .flow-content {
position: relative;
z-index: 2; z-index: 2;
padding: 24rpx 32rpx 48rpx; padding: 24rpx 32rpx 48rpx;
} }
.create-flow-panel { .create-flow-panel {
position: relative;
min-height: 1000rpx; min-height: 1000rpx;
} background: url("/static/assets/modules/genealogy/transparent/g01-empty-panel-frame.png")
center / 100% 100% no-repeat;
.create-flow-panel__skin {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
pointer-events: none;
} }
.create-flow-panel__content { .create-flow-panel__content {
position: relative;
z-index: 1; z-index: 1;
display: flex; display: flex;
min-height: 1000rpx; min-height: 1000rpx;
@@ -641,7 +615,7 @@ const enterOverview = () =>
.intro-field textarea { .intro-field textarea {
width: 100%; width: 100%;
height: 142rpx; min-height: 142rpx;
margin-top: 8rpx; margin-top: 8rpx;
color: #392719; color: #392719;
font-family: "STKaiti", "KaiTi", serif; font-family: "STKaiti", "KaiTi", serif;
@@ -650,25 +624,17 @@ const enterOverview = () =>
} }
.flow-primary-action { .flow-primary-action {
position: relative;
display: flex; display: flex;
width: 100%; width: 100%;
height: 96rpx; min-height: 96rpx;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
margin-top: auto; margin-top: auto;
overflow: hidden; background: url("/static/assets/foundation/transparent/a01-scroll-primary-v3.png")
} center / contain no-repeat;
.flow-primary-action__skin {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
} }
.flow-primary-action__copy { .flow-primary-action__copy {
position: relative;
z-index: 1; z-index: 1;
color: #fff9ec; color: #fff9ec;
font-family: "STKaiti", "KaiTi", serif; font-family: "STKaiti", "KaiTi", serif;
@@ -694,21 +660,14 @@ const enterOverview = () =>
} }
.duplicate-reminder, .duplicate-reminder,
.flow-success-dialog { .flow-success-dialog {
position: relative;
width: 100%; width: 100%;
max-width: 670rpx; max-width: 670rpx;
min-height: 650rpx; min-height: 650rpx;
} background: url("/static/assets/modules/auth/transparent/a01-scroll-dialog-v3.png")
.duplicate-reminder__skin, center / contain no-repeat;
.flow-success-dialog__skin {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
} }
.duplicate-reminder__content, .duplicate-reminder__content,
.flow-success-dialog__content { .flow-success-dialog__content {
position: relative;
z-index: 1; z-index: 1;
display: flex; display: flex;
min-height: 650rpx; min-height: 650rpx;
@@ -735,32 +694,25 @@ const enterOverview = () =>
.duplicate-reminder__search, .duplicate-reminder__search,
.duplicate-reminder__confirm, .duplicate-reminder__confirm,
.flow-success-dialog__action { .flow-success-dialog__action {
position: relative;
display: flex; display: flex;
width: 100%; width: 100%;
height: 92rpx; min-height: 92rpx;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
overflow: hidden;
} }
.duplicate-reminder__search { .duplicate-reminder__search {
margin-top: 38rpx; margin-top: 38rpx;
background: url("/static/assets/foundation/transparent/a01-scroll-secondary-v3.png")
center / contain no-repeat;
} }
.duplicate-reminder__confirm { .duplicate-reminder__confirm {
margin-top: 14rpx; margin-top: 14rpx;
} background: url("/static/assets/foundation/transparent/a01-scroll-primary-v3.png")
.duplicate-reminder__search image, center / contain no-repeat;
.duplicate-reminder__confirm image,
.flow-success-dialog__action image {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
} }
.duplicate-reminder__search text, .duplicate-reminder__search text,
.duplicate-reminder__confirm text, .duplicate-reminder__confirm text,
.flow-success-dialog__action text { .flow-success-dialog__action text {
position: relative;
z-index: 1; z-index: 1;
color: #fff9ec; color: #fff9ec;
font-size: 25rpx; font-size: 25rpx;
@@ -779,5 +731,7 @@ const enterOverview = () =>
} }
.flow-success-dialog__action { .flow-success-dialog__action {
margin-top: 44rpx; margin-top: 44rpx;
background: url("/static/assets/foundation/transparent/a01-scroll-primary-v3.png")
center / contain no-repeat;
} }
</style> </style>
+86 -133
View File
@@ -20,12 +20,6 @@
class="overview-surface" class="overview-surface"
:class="{ 'overview-surface--state': overviewState !== 'ready' }" :class="{ 'overview-surface--state': overviewState !== 'ready' }"
> >
<image
class="overview-surface__skin"
src="/static/assets/modules/genealogy/opaque/g05-overview-surface.png"
mode="scaleToFill"
/>
<template <template
v-if="overviewState === 'ready' && genealogy && viewMode === 'member'" v-if="overviewState === 'ready' && genealogy && viewMode === 'member'"
> >
@@ -44,6 +38,10 @@
<text>已激活 {{ genealogy.activeCount || 0 }} </text> <text>已激活 {{ genealogy.activeCount || 0 }} </text>
<text>{{ genealogy.visibility || "仅成员可见" }}</text> <text>{{ genealogy.visibility || "仅成员可见" }}</text>
</view> </view>
<view class="overview-hero__stats">
<text>始祖 {{ genealogy.ancestorName }}</text>
<text>更新于 {{ genealogy.updatedAt }}</text>
</view>
</view> </view>
<view <view
@@ -62,10 +60,6 @@
<text class="overview-action__title">录入族人</text <text class="overview-action__title">录入族人</text
><text>从首代开始完善</text> ><text>从首代开始完善</text>
</view> </view>
<view v-else class="overview-action-lock">
<text class="overview-action__title">录入族人</text
><text>仅管理员可用</text>
</view>
<view <view
class="overview-action overview-action--poem" class="overview-action overview-action--poem"
@click="toGenerationPoems" @click="toGenerationPoems"
@@ -81,10 +75,6 @@
<text class="overview-action__title">入谱审核</text <text class="overview-action__title">入谱审核</text
><text>处理加入申请</text> ><text>处理加入申请</text>
</view> </view>
<view v-else class="overview-action-lock">
<text class="overview-action__title">入谱审核</text
><text>仅管理员可用</text>
</view>
</view> </view>
<view class="overview-family" @click="toFamily"> <view class="overview-family" @click="toFamily">
@@ -130,10 +120,6 @@
<text>{{ genealogy.publicDescription }}</text> <text>{{ genealogy.publicDescription }}</text>
</view> </view>
<view class="overview-public__action" @click="applyToJoin"> <view class="overview-public__action" @click="applyToJoin">
<image
src="/static/assets/foundation/transparent/a01-scroll-primary-v3.png"
mode="aspectFit"
/>
<text>申请加入这部家谱</text> <text>申请加入这部家谱</text>
</view> </view>
</view> </view>
@@ -142,11 +128,6 @@
v-else-if="overviewState === 'loading'" v-else-if="overviewState === 'loading'"
class="overview-state overview-state--loading" class="overview-state overview-state--loading"
> >
<image
class="overview-state__frame"
src="/static/assets/modules/genealogy/transparent/g01-empty-panel-frame.png"
mode="scaleToFill"
/>
<AppLoading <AppLoading
text="正在展开家谱" text="正在展开家谱"
description="请稍候,正在读取家谱概览。" description="请稍候,正在读取家谱概览。"
@@ -162,11 +143,6 @@
'overview-state--no-permission': overviewState === 'no-permission', 'overview-state--no-permission': overviewState === 'no-permission',
}" }"
> >
<image
class="overview-state__frame"
src="/static/assets/modules/genealogy/transparent/g01-empty-panel-frame.png"
mode="scaleToFill"
/>
<view class="overview-state__content"> <view class="overview-state__content">
<image <image
class="overview-state__seal" class="overview-state__seal"
@@ -181,10 +157,6 @@
overviewState === 'error' ? reloadOverview() : toGenealogies() overviewState === 'error' ? reloadOverview() : toGenealogies()
" "
> >
<image
src="/static/assets/foundation/transparent/a01-scroll-primary-v3.png"
mode="aspectFit"
/>
<text>{{ <text>{{
overviewState === "error" ? "重新查看" : "返回我的家谱" overviewState === "error" ? "重新查看" : "返回我的家谱"
}}</text> }}</text>
@@ -223,6 +195,28 @@ const overviewFixture = {
memberCount: 428, memberCount: 428,
activeCount: 316, activeCount: 316,
visibility: "仅成员可见", visibility: "仅成员可见",
ancestorName: "汤文远",
updatedAt: "2026-07-12",
};
const overviewFixtures = {
1001: {
...overviewFixture,
id: "1001",
name: "汤氏家谱",
location: "河南·洛阳",
memberCount: 158,
activeCount: 108,
},
1002: {
...overviewFixture,
id: "1002",
name: "汤氏宗谱",
hall: "承志堂",
location: "山东·济宁",
memberCount: 286,
activeCount: 215,
},
2001: overviewFixture,
}; };
const stateTitle = computed( const stateTitle = computed(
@@ -268,7 +262,13 @@ const loadGenealogy = (query = {}) => {
} }
if (query.state === "loading") return; if (query.state === "loading") return;
genealogy.value = { ...overviewFixture, id: genealogyId.value }; genealogy.value = {
...(overviewFixtures[genealogyId.value] || overviewFixture),
id: genealogyId.value,
};
if (query.genealogyName) {
genealogy.value.name = decodeURIComponent(query.genealogyName);
}
overviewState.value = "ready"; overviewState.value = "ready";
}; };
@@ -305,43 +305,40 @@ const applyToJoin = () =>
<style scoped lang="scss"> <style scoped lang="scss">
.overview-page { .overview-page {
position: relative; display: flex;
min-height: 100vh; min-height: 100vh;
overflow-x: hidden; flex-direction: column;
background: $paper; background: $paper;
} }
.overview-page__header { .overview-page__header {
position: relative;
z-index: 3; z-index: 3;
} }
.overview-surface { .overview-surface {
position: relative;
z-index: 2; z-index: 2;
width: 100%; width: 100%;
height: min(483px, calc(100vw * 1.3422)); min-height: min(483px, calc(100vw * 1.3422));
} flex: 0 0 auto;
.overview-surface__skin { background: url("/static/assets/modules/genealogy/opaque/g05-overview-surface.png")
position: absolute; center / 100% 100% no-repeat;
inset: 0;
width: 100%;
height: 100%;
} }
.overview-surface--state { .overview-surface--state {
display: flex;
height: calc(100vh - 112rpx); height: calc(100vh - 112rpx);
min-height: 1040rpx; min-height: 1040rpx;
} padding: 28rpx;
.overview-surface--state .overview-surface__skin { box-sizing: border-box;
display: none; background: none;
} }
.overview-ready { .overview-ready {
position: absolute; display: flex;
inset: 0; min-height: min(483px, calc(100vw * 1.3422));
flex-direction: column;
gap: 34rpx;
padding: 58rpx 0 42rpx;
box-sizing: border-box;
} }
.overview-hero { .overview-hero {
position: absolute; margin: 0 8%;
top: 6.5%;
right: 8%;
left: 8%;
color: #fff8ec; color: #fff8ec;
} }
.overview-hero__kicker { .overview-hero__kicker {
@@ -372,19 +369,15 @@ const applyToJoin = () =>
font-size: 22rpx; font-size: 22rpx;
} }
.overview-actions { .overview-actions {
position: absolute;
top: 29.2%;
right: 7.2%;
left: 7.2%;
display: grid; display: grid;
min-height: 386rpx;
grid-template-columns: 1fr 1fr; grid-template-columns: 1fr 1fr;
grid-template-rows: 1fr 1fr; grid-template-rows: auto auto;
gap: 3.4% 3%; gap: 3.4% 3%;
height: 36.2%; margin: 0 7.2%;
} }
.overview-actions--member { .overview-actions--member {
grid-template-rows: 1fr 1fr; min-height: 386rpx;
height: 36.2%;
} }
.overview-action, .overview-action,
.overview-action-lock { .overview-action-lock {
@@ -392,7 +385,7 @@ const applyToJoin = () =>
flex-direction: column; flex-direction: column;
justify-content: center; justify-content: center;
min-width: 0; min-width: 0;
padding: 0 22rpx; padding: 24rpx 22rpx;
box-sizing: border-box; box-sizing: border-box;
color: $ink-muted; color: $ink-muted;
font-size: 22rpx; font-size: 22rpx;
@@ -408,14 +401,12 @@ const applyToJoin = () =>
font-weight: 700; font-weight: 700;
} }
.overview-family { .overview-family {
position: absolute;
top: 70.2%;
right: 7.5%;
left: 7.5%;
display: grid; display: grid;
grid-template-columns: auto 1fr auto; grid-template-columns: auto 1fr auto;
align-items: center; align-items: center;
align-self: start;
gap: 16rpx; gap: 16rpx;
margin: 0 7.5%;
color: $ink-muted; color: $ink-muted;
font-size: 22rpx; font-size: 22rpx;
} }
@@ -429,17 +420,14 @@ const applyToJoin = () =>
color: $brand-red; color: $brand-red;
} }
.overview-note { .overview-note {
position: absolute;
top: 80.5%;
right: 8.5%;
left: 8.5%;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
justify-content: center; justify-content: center;
height: 15%; margin: 0 8.5%;
color: $ink-muted; color: $ink-muted;
font-size: 22rpx; font-size: 22rpx;
line-height: 1.6; line-height: 1.6;
margin-top: 100rpx;
} }
.overview-note__title { .overview-note__title {
margin-bottom: 8rpx; margin-bottom: 8rpx;
@@ -449,25 +437,16 @@ const applyToJoin = () =>
font-weight: 700; font-weight: 700;
} }
.overview-state { .overview-state {
position: absolute;
top: 28rpx;
right: 28rpx;
bottom: 28rpx;
left: 28rpx;
display: flex; display: flex;
width: 100%;
min-height: 0;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
background: url("/static/assets/modules/genealogy/transparent/g01-empty-panel-frame.png")
center / 100% 100% no-repeat;
text-align: center; text-align: center;
} }
.overview-state__frame {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
pointer-events: none;
}
.overview-state__content { .overview-state__content {
position: relative;
z-index: 1; z-index: 1;
display: flex; display: flex;
width: 100%; width: 100%;
@@ -501,37 +480,31 @@ const applyToJoin = () =>
line-height: 1.7; line-height: 1.7;
} }
.overview-state-panel__action { .overview-state-panel__action {
position: relative;
width: 560rpx;
height: 124rpx;
margin-top: 34rpx;
}
.overview-state-panel__action image {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
}
.overview-state-panel__action text {
position: relative;
z-index: 1;
display: flex; display: flex;
width: 560rpx;
min-height: 124rpx;
margin-top: 34rpx;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
height: 100%; background: url("/static/assets/foundation/transparent/a01-scroll-primary-v3.png")
center / contain no-repeat;
}
.overview-state-panel__action text {
z-index: 1;
color: #fff9ed; color: #fff9ed;
font-size: 31rpx; font-size: 31rpx;
font-weight: 700; font-weight: 700;
} }
.overview-public { .overview-public {
position: absolute; display: flex;
inset: 0; min-height: min(483px, calc(100vw * 1.3422));
flex-direction: column;
gap: 0;
padding: 58rpx 0 28rpx;
box-sizing: border-box;
} }
.overview-public__hero { .overview-public__hero {
position: absolute; margin: 0 8%;
top: 6.5%;
right: 8%;
left: 8%;
color: #fff8ec; color: #fff8ec;
} }
.overview-public__eyebrow { .overview-public__eyebrow {
@@ -555,24 +528,19 @@ const applyToJoin = () =>
font-size: 22rpx; font-size: 22rpx;
} }
.overview-public__details { .overview-public__details {
position: absolute;
top: 30%;
right: 9%;
left: 9%;
display: grid; display: grid;
grid-template-columns: 1fr 1fr; grid-template-columns: 1fr 1fr;
gap: 18rpx 28rpx; gap: 18rpx 28rpx;
min-height: 240rpx;
margin: 126rpx 9% 0;
} }
.overview-public__details > view { .overview-public__details > view {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
} }
.overview-public__details > view:nth-child(5) { .overview-public__details > view:nth-child(5) {
position: absolute;
top: 250rpx;
right: 0;
left: 0;
grid-column: 1 / -1; grid-column: 1 / -1;
align-self: start;
flex-direction: row; flex-direction: row;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
@@ -596,11 +564,8 @@ const applyToJoin = () =>
font-size: 27rpx; font-size: 27rpx;
} }
.overview-public__notice { .overview-public__notice {
position: absolute;
top: 78%;
right: 9%;
left: 9%;
display: flex; display: flex;
margin: 54rpx 9% 0;
color: $ink-muted; color: $ink-muted;
font-size: 19rpx; font-size: 19rpx;
line-height: 26rpx; line-height: 26rpx;
@@ -612,24 +577,15 @@ const applyToJoin = () =>
font-weight: 700; font-weight: 700;
} }
.overview-public__action { .overview-public__action {
position: absolute;
right: 9%;
bottom: 2%;
left: 9%;
display: flex; display: flex;
height: 82rpx; min-height: 82rpx;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
overflow: hidden; margin: 204rpx 9% 0;
} background: url("/static/assets/foundation/transparent/a01-scroll-primary-v3.png")
.overview-public__action image { center / contain no-repeat;
position: absolute;
inset: 0;
width: 100%;
height: 100%;
} }
.overview-public__action text { .overview-public__action text {
position: relative;
z-index: 1; z-index: 1;
color: #fff9ed; color: #fff9ed;
font-size: 27rpx; font-size: 27rpx;
@@ -637,9 +593,6 @@ const applyToJoin = () =>
letter-spacing: 3rpx; letter-spacing: 3rpx;
} }
@media (min-width: 400px) { @media (min-width: 400px) {
.overview-hero {
top: 7%;
}
.overview-action { .overview-action {
padding: 0 28rpx; padding: 0 28rpx;
} }
+40 -112
View File
@@ -6,11 +6,6 @@
<view class="search-page__content"> <view class="search-page__content">
<view class="search-title-strip"> <view class="search-title-strip">
<image
class="search-title-strip__skin"
src="/static/assets/modules/genealogy/opaque/g06-search-title-strip.png"
mode="scaleToFill"
/>
<image <image
class="search-title-strip__seal" class="search-title-strip__seal"
src="/static/assets/foundation/transparent/brand-seal.png" src="/static/assets/foundation/transparent/brand-seal.png"
@@ -37,11 +32,6 @@
<template v-if="mode === 'search'"> <template v-if="mode === 'search'">
<view class="search-controls"> <view class="search-controls">
<view class="search-field"> <view class="search-field">
<image
class="search-field__skin"
src="/static/assets/modules/genealogy/opaque/g06-search-input-wide.png"
mode="scaleToFill"
/>
<input <input
v-model.trim="keyword" v-model.trim="keyword"
class="search-input" class="search-input"
@@ -54,11 +44,6 @@
> >
</view> </view>
<view class="search-action" @click="search"> <view class="search-action" @click="search">
<image
class="search-action__skin"
src="/static/assets/modules/genealogy/opaque/g06-search-button.png"
mode="scaleToFill"
/>
<text class="search-action__label">搜索</text> <text class="search-action__label">搜索</text>
</view> </view>
</view> </view>
@@ -110,11 +95,6 @@
class="result-card genealogy-card" class="result-card genealogy-card"
@click="openPreview(item)" @click="openPreview(item)"
> >
<image
class="result-card__skin"
src="/static/assets/modules/genealogy/transparent/list-slip-frame.png"
mode="scaleToFill"
/>
<view class="result-card__content"> <view class="result-card__content">
<view class="result-card__head"> <view class="result-card__head">
<view> <view>
@@ -169,10 +149,6 @@
>当前仅展示失败样式请稍后重新搜索</text >当前仅展示失败样式请稍后重新搜索</text
> >
<view class="status-retry" @click="search"> <view class="status-retry" @click="search">
<image
src="/static/assets/foundation/transparent/a01-scroll-primary-v3.png"
mode="aspectFit"
/>
<text>重新搜索</text> <text>重新搜索</text>
</view> </view>
</view> </view>
@@ -186,11 +162,6 @@
> >
<view class="invite-controls"> <view class="invite-controls">
<view class="invite-field"> <view class="invite-field">
<image
class="search-field__skin"
src="/static/assets/modules/genealogy/opaque/g06-search-input-wide.png"
mode="scaleToFill"
/>
<input <input
v-model.trim="inviteCode" v-model.trim="inviteCode"
class="invite-input" class="invite-input"
@@ -200,11 +171,6 @@
/> />
</view> </view>
<view class="search-action" @click="verifyInvite"> <view class="search-action" @click="verifyInvite">
<image
class="search-action__skin"
src="/static/assets/modules/genealogy/opaque/g06-search-button.png"
mode="scaleToFill"
/>
<text class="search-action__label">验证</text> <text class="search-action__label">验证</text>
</view> </view>
</view> </view>
@@ -237,11 +203,6 @@
<view v-else class="invite-result"> <view v-else class="invite-result">
<text class="invite-result__label">已定位目标家谱</text> <text class="invite-result__label">已定位目标家谱</text>
<view class="result-card genealogy-card"> <view class="result-card genealogy-card">
<image
class="result-card__skin"
src="/static/assets/modules/genealogy/transparent/list-slip-frame.png"
mode="scaleToFill"
/>
<view class="result-card__content"> <view class="result-card__content">
<text class="result-card__name">{{ inviteTarget.name }}</text> <text class="result-card__name">{{ inviteTarget.name }}</text>
<view class="result-card__facts"> <view class="result-card__facts">
@@ -444,7 +405,7 @@ const clearSearch = () => {
const openPreview = (item) => { const openPreview = (item) => {
if (item.relation === "available") { if (item.relation === "available") {
uni.navigateTo({ uni.navigateTo({
url: `/pages/genealogy/g05-genealogy-overview?mode=public&genealogyId=${item.id}`, url: `/pages/genealogy/g05-genealogy-overview?mode=public&genealogyId=${item.id}&genealogyName=${encodeURIComponent(item.name)}`,
}); });
} }
}; };
@@ -452,7 +413,7 @@ const openPreview = (item) => {
const handleResultAction = (item) => { const handleResultAction = (item) => {
if (item.relation === "available") if (item.relation === "available")
return uni.navigateTo({ return uni.navigateTo({
url: `/pages/genealogy/g08-join-application?source=search&genealogyId=${item.id}`, url: `/pages/genealogy/g08-join-application?source=search&genealogyId=${item.id}&genealogyName=${encodeURIComponent(item.name)}`,
}); });
if (item.relation === "pending") if (item.relation === "pending")
return uni.navigateTo({ return uni.navigateTo({
@@ -460,7 +421,7 @@ const handleResultAction = (item) => {
}); });
if (item.relation === "rejected" || item.relation === "removed") if (item.relation === "rejected" || item.relation === "removed")
return uni.navigateTo({ return uni.navigateTo({
url: `/pages/genealogy/g08-join-application?source=search&previous=${item.relation}&genealogyId=${item.id}`, url: `/pages/genealogy/g08-join-application?source=search&previous=${item.relation}&genealogyId=${item.id}&genealogyName=${encodeURIComponent(item.name)}`,
}); });
return uni.reLaunch({ return uni.reLaunch({
url: `/pages/genealogy/g01-my-genealogies?genealogyId=${item.id}`, url: `/pages/genealogy/g01-my-genealogies?genealogyId=${item.id}`,
@@ -473,51 +434,42 @@ const verifyInvite = () => {
}; };
const confirmInvite = () => const confirmInvite = () =>
uni.navigateTo({ uni.navigateTo({
url: `/pages/genealogy/g08-join-application?source=invite&genealogyId=${inviteTarget.value.id}`, url: `/pages/genealogy/g08-join-application?source=invite&genealogyId=${inviteTarget.value.id}&genealogyName=${encodeURIComponent(inviteTarget.value.name)}`,
}); });
</script> </script>
<style scoped lang="scss"> <style scoped lang="scss">
.search-page { .search-page {
position: relative; display: flex;
min-height: 100vh; min-height: 100vh;
overflow-x: hidden; flex-direction: column;
background: $paper; background: $paper;
} }
.search-page__header { .search-page__header {
position: relative;
z-index: 2; z-index: 2;
} }
.search-page__content { .search-page__content {
position: relative;
z-index: 3; z-index: 3;
min-height: calc(100vh - 104rpx); min-height: calc(100vh - 104rpx);
padding: 30rpx 24rpx calc(58rpx + env(safe-area-inset-bottom)); padding: 30rpx 24rpx calc(58rpx + env(safe-area-inset-bottom));
box-sizing: border-box; box-sizing: border-box;
} }
.search-title-strip { .search-title-strip {
position: relative; display: grid;
width: 100%; width: 100%;
height: 138rpx; height: 138rpx;
} grid-template-columns: 156rpx 1fr 170rpx;
.search-title-strip__skin { align-items: center;
position: absolute; background: url("/static/assets/modules/genealogy/opaque/g06-search-title-strip.png")
inset: 0; center / 100% 100% no-repeat;
width: 100%;
height: 100%;
pointer-events: none;
} }
.search-title-strip__seal { .search-title-strip__seal {
position: absolute;
top: 27rpx;
left: 52rpx;
z-index: 1; z-index: 1;
width: 72rpx; width: 72rpx;
height: 84rpx; height: 84rpx;
margin-left: 52rpx;
} }
.search-title-strip__text { .search-title-strip__text {
position: absolute;
inset: 0 170rpx 0 156rpx;
z-index: 1; z-index: 1;
display: flex; display: flex;
align-items: center; align-items: center;
@@ -535,7 +487,6 @@ const confirmInvite = () =>
border-bottom: 1rpx solid rgba(181, 138, 75, 0.45); border-bottom: 1rpx solid rgba(181, 138, 75, 0.45);
} }
.mode-tab { .mode-tab {
position: relative;
display: flex; display: flex;
flex: 1; flex: 1;
align-items: center; align-items: center;
@@ -546,18 +497,12 @@ const confirmInvite = () =>
font-size: 28rpx; font-size: 28rpx;
} }
.mode-tab--active { .mode-tab--active {
margin-bottom: -2rpx;
background: linear-gradient($brand-red, $brand-red) center bottom /
calc(100% - 84rpx) 4rpx no-repeat;
color: $brand-red; color: $brand-red;
font-weight: 700; font-weight: 700;
} }
.mode-tab--active::after {
position: absolute;
right: 42rpx;
bottom: -2rpx;
left: 42rpx;
height: 4rpx;
background: $brand-red;
content: "";
}
.search-controls { .search-controls {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -571,21 +516,15 @@ const confirmInvite = () =>
margin-top: 32rpx; margin-top: 32rpx;
} }
.search-field { .search-field {
position: relative; display: grid;
width: 430rpx; width: 430rpx;
height: 95rpx; min-height: 95rpx;
} background: url("/static/assets/modules/genealogy/opaque/g06-search-input-wide.png")
.search-field__skin, center / 100% 100% no-repeat;
.search-action__skin {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
pointer-events: none;
} }
.search-input { .search-input {
position: relative;
z-index: 1; z-index: 1;
grid-area: 1 / 1;
width: 100%; width: 100%;
height: 100%; height: 100%;
padding: 0 82rpx 0 30rpx; padding: 0 82rpx 0 30rpx;
@@ -597,26 +536,26 @@ const confirmInvite = () =>
color: #ab9c83; color: #ab9c83;
} }
.search-clear { .search-clear {
position: absolute;
z-index: 2; z-index: 2;
top: 0;
right: 20rpx;
display: flex; display: flex;
grid-area: 1 / 1;
justify-self: end;
height: 100%; height: 100%;
align-items: center; align-items: center;
margin-right: 20rpx;
color: $brand-red; color: $brand-red;
font-size: 21rpx; font-size: 21rpx;
} }
.search-action { .search-action {
position: relative;
display: flex; display: flex;
width: 200rpx; width: 200rpx;
height: 88rpx; min-height: 88rpx;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
background: url("/static/assets/modules/genealogy/opaque/g06-search-button.png")
center / 100% 100% no-repeat;
} }
.search-action__label { .search-action__label {
position: relative;
z-index: 1; z-index: 1;
color: #fff4dc; color: #fff4dc;
font-family: "STKaiti", "KaiTi", serif; font-family: "STKaiti", "KaiTi", serif;
@@ -690,23 +629,16 @@ const confirmInvite = () =>
filter: grayscale(1); filter: grayscale(1);
} }
.status-retry { .status-retry {
position: relative;
display: flex; display: flex;
width: 300rpx; width: 300rpx;
height: 76rpx; min-height: 76rpx;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
margin-top: 24rpx; margin-top: 24rpx;
overflow: hidden; background: url("/static/assets/foundation/transparent/a01-scroll-primary-v3.png")
} center / contain no-repeat;
.status-retry image {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
} }
.status-retry text { .status-retry text {
position: relative;
z-index: 1; z-index: 1;
color: #fff9ed; color: #fff9ed;
font-size: 25rpx; font-size: 25rpx;
@@ -733,20 +665,13 @@ const confirmInvite = () =>
font-weight: 700; font-weight: 700;
} }
.result-card { .result-card {
position: relative;
min-height: 314rpx; min-height: 314rpx;
box-sizing: border-box; box-sizing: border-box;
padding: 28rpx 30rpx; padding: 28rpx 30rpx;
overflow: hidden; background: url("/static/assets/modules/genealogy/transparent/list-slip-frame.png")
} center / 100% 100% no-repeat;
.result-card__skin {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
} }
.result-card__content { .result-card__content {
position: relative;
z-index: 1; z-index: 1;
} }
.result-card__head { .result-card__head {
@@ -823,13 +748,15 @@ const confirmInvite = () =>
line-height: 35rpx; line-height: 35rpx;
} }
.invite-field { .invite-field {
position: relative; display: grid;
flex: 1; flex: 1;
height: 95rpx; min-height: 95rpx;
background: url("/static/assets/modules/genealogy/opaque/g06-search-input-wide.png")
center / 100% 100% no-repeat;
} }
.invite-input { .invite-input {
position: relative;
z-index: 1; z-index: 1;
grid-area: 1 / 1;
width: 100%; width: 100%;
height: 100%; height: 100%;
padding: 0 30rpx; padding: 0 30rpx;
@@ -866,11 +793,12 @@ const confirmInvite = () =>
padding-left: 20rpx; padding-left: 20rpx;
} }
.search-title-strip__seal { .search-title-strip__seal {
left: 44rpx; margin-left: 44rpx;
}
.search-title-strip {
grid-template-columns: 138rpx 1fr 148rpx;
} }
.search-title-strip__text { .search-title-strip__text {
right: 148rpx;
left: 138rpx;
font-size: 37rpx; font-size: 37rpx;
} }
.search-field { .search-field {
+59 -79
View File
@@ -14,24 +14,17 @@
'join-state--error': joinState === 'error', 'join-state--error': joinState === 'error',
}" }"
> >
<image
class="join-panel__skin"
src="/static/assets/modules/genealogy/transparent/g01-empty-panel-frame.png"
mode="scaleToFill"
/>
<view v-if="joinState === 'form'" class="join-form"> <view v-if="joinState === 'form'" class="join-form">
<text class="join-form__eyebrow">{{ sourceContract.eyebrow }}</text> <text class="join-form__eyebrow">{{ sourceContract.eyebrow }}</text>
<text class="join-form__title" <text class="join-form__title"
>{{ sourceContract.formTitle }} {{ genealogyName }}</text >{{ sourceContract.formTitle }} {{ genealogyName }}</text
> >
<text class="join-form__copy">{{ sourceContract.formCopy }}</text> <text class="join-form__copy">{{ sourceContract.formCopy }}</text>
<text v-if="previousNotice" class="join-form__previous">{{
previousNotice
}}</text>
<view class="join-field"> <view class="join-field">
<image
src="/static/assets/modules/genealogy/transparent/g-form-field-frame.png"
mode="scaleToFill"
/>
<text>真实姓名</text <text>真实姓名</text
><input ><input
v-model="form.realName" v-model="form.realName"
@@ -44,10 +37,6 @@
fieldErrors.realName fieldErrors.realName
}}</text> }}</text>
<view class="join-field"> <view class="join-field">
<image
src="/static/assets/modules/genealogy/transparent/g-form-field-frame.png"
mode="scaleToFill"
/>
<text>与家谱关系</text <text>与家谱关系</text
><input ><input
v-model="form.relation" v-model="form.relation"
@@ -63,13 +52,10 @@
>请以家谱中一位已知长辈为参照例如汤正华堂侄</text >请以家谱中一位已知长辈为参照例如汤正华堂侄</text
> >
<view class="join-field join-field--message"> <view class="join-field join-field--message">
<image
src="/static/assets/modules/genealogy/transparent/g-form-field-frame.png"
mode="scaleToFill"
/>
<text>{{ sourceContract.thirdFieldLabel }}</text <text>{{ sourceContract.thirdFieldLabel }}</text
><textarea ><textarea
v-model="form.message" v-model="form.message"
auto-height
maxlength="80" maxlength="80"
:placeholder="sourceContract.thirdFieldPlaceholder" :placeholder="sourceContract.thirdFieldPlaceholder"
placeholder-class="join-placeholder" placeholder-class="join-placeholder"
@@ -78,10 +64,6 @@
<text class="join-form__note">{{ sourceContract.note }}</text> <text class="join-form__note">{{ sourceContract.note }}</text>
<view class="join-action" @click="submitJoin"> <view class="join-action" @click="submitJoin">
<image
src="/static/assets/foundation/transparent/a01-scroll-primary-v3.png"
mode="aspectFit"
/>
<text>{{ <text>{{
isSubmitting isSubmitting
? sourceContract.submittingLabel ? sourceContract.submittingLabel
@@ -106,10 +88,6 @@
class="join-action" class="join-action"
@click="joinState === 'success' ? completeFlow() : retryForm()" @click="joinState === 'success' ? completeFlow() : retryForm()"
> >
<image
src="/static/assets/foundation/transparent/a01-scroll-primary-v3.png"
mode="aspectFit"
/>
<text>{{ <text>{{
joinState === "success" ? sourceContract.nextLabel : "重新填写" joinState === "success" ? sourceContract.nextLabel : "重新填写"
}}</text> }}</text>
@@ -127,6 +105,7 @@ import PageHeader from "@/components/PageHeader.vue";
const genealogyId = ref(""); const genealogyId = ref("");
const source = ref("search"); const source = ref("search");
const previousRelation = ref("");
const genealogyName = ref("这部家谱"); const genealogyName = ref("这部家谱");
const genealogyPreview = { const genealogyPreview = {
1001: "汤氏家谱", 1001: "汤氏家谱",
@@ -137,6 +116,15 @@ const isSubmitting = ref(false);
const errorMessage = ref(""); const errorMessage = ref("");
const form = reactive({ realName: "", relation: "", message: "" }); const form = reactive({ realName: "", relation: "", message: "" });
const fieldErrors = reactive({ realName: "", relation: "" }); const fieldErrors = reactive({ realName: "", relation: "" });
const previousNotice = computed(() =>
previousRelation.value === "rejected"
? "上次申请未通过,请补充更准确的长辈姓名、祖居地或支系信息。"
: previousRelation.value === "removed"
? "你曾退出或被移出这部家谱,请重新确认身份关系后申请。"
: previousRelation.value === "withdrawn"
? "上次申请已撤回;如仍希望加入,请重新确认关系并提交。"
: "",
);
const sourceContract = computed(() => const sourceContract = computed(() =>
source.value === "invite" source.value === "invite"
? { ? {
@@ -185,6 +173,9 @@ const resultCopy = computed(() =>
onLoad((query) => { onLoad((query) => {
genealogyId.value = query.genealogyId || ""; genealogyId.value = query.genealogyId || "";
source.value = query.source === "invite" ? "invite" : "search"; source.value = query.source === "invite" ? "invite" : "search";
previousRelation.value = ["rejected", "removed", "withdrawn"].includes(query.previous)
? query.previous
: "";
if (query.state === "success") { if (query.state === "success") {
joinState.value = "success"; joinState.value = "success";
return; return;
@@ -194,7 +185,9 @@ onLoad((query) => {
joinState.value = "error"; joinState.value = "error";
return; return;
} }
genealogyName.value = genealogyPreview[genealogyId.value] || "这部家谱"; genealogyName.value = query.genealogyName
? decodeURIComponent(query.genealogyName)
: genealogyPreview[genealogyId.value] || "这部家谱";
}); });
const submitJoin = () => { const submitJoin = () => {
@@ -232,31 +225,26 @@ const completeFlow = () =>
<style scoped lang="scss"> <style scoped lang="scss">
.join-page { .join-page {
position: relative; display: flex;
min-height: 100vh; min-height: 100vh;
overflow-x: hidden; flex-direction: column;
background: $paper; background: $paper;
} }
.join-page__header { .join-page__header {
position: relative;
z-index: 3; z-index: 3;
} }
.join-panel { .join-panel {
position: relative;
z-index: 2; z-index: 2;
width: calc(100% - 32rpx); width: calc(100% - 32rpx);
height: min(590px, calc((100vw - 16px) * 1.337)); min-height: min(590px, calc((100vw - 16px) * 1.337));
margin: 22rpx auto 0; margin: 22rpx auto 0;
} padding: 70rpx 8%;
.join-panel__skin { box-sizing: border-box;
position: absolute; background: url("/static/assets/modules/genealogy/transparent/g01-empty-panel-frame.png")
inset: 0; center / 100% 100% no-repeat;
width: 100%;
height: 100%;
} }
.join-form { .join-form {
position: absolute; min-width: 0;
inset: 8.5% 8%;
} }
.join-form__eyebrow, .join-form__eyebrow,
.join-result__eyebrow { .join-result__eyebrow {
@@ -283,34 +271,37 @@ const completeFlow = () =>
line-height: 1.6; line-height: 1.6;
} }
.join-field { .join-field {
position: relative; display: grid;
height: 92rpx; min-height: 92rpx;
margin-top: 18rpx; margin-top: 18rpx;
background: url("/static/assets/modules/genealogy/transparent/g-form-field-frame.png")
center / 100% 100% no-repeat;
} }
.join-field > image { .join-form__previous {
position: absolute; display: block;
inset: 0; margin-top: 14rpx;
width: 100%; color: $brand-red;
height: 100%; font-size: 22rpx;
line-height: 1.55;
} }
.join-field > text { .join-field > text {
position: absolute;
top: 31rpx;
left: 24rpx;
z-index: 1; z-index: 1;
grid-area: 1 / 1;
align-self: center;
margin-left: 24rpx;
color: $ink; color: $ink;
font-size: 23rpx; font-size: 23rpx;
font-weight: 700; font-weight: 700;
} }
.join-field input, .join-field input,
.join-field textarea { .join-field textarea {
position: absolute;
top: 0;
right: 20rpx;
bottom: 0;
left: 170rpx;
z-index: 1; z-index: 1;
height: 92rpx; grid-area: 1 / 1;
width: auto;
min-width: 0;
min-height: 92rpx;
margin-right: 20rpx;
margin-left: 170rpx;
color: $ink; color: $ink;
font-size: 23rpx; font-size: 23rpx;
line-height: 92rpx; line-height: 92rpx;
@@ -346,36 +337,29 @@ const completeFlow = () =>
text-align: center; text-align: center;
} }
.join-action { .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; display: flex;
width: 100%;
min-height: 82rpx;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
height: 100%; margin-top: 20rpx;
background: url("/static/assets/foundation/transparent/a01-scroll-primary-v3.png")
center / contain no-repeat;
}
.join-action text {
z-index: 1;
color: #fff9ed; color: #fff9ed;
font-size: 27rpx; font-size: 27rpx;
font-weight: 700; font-weight: 700;
letter-spacing: 3rpx; letter-spacing: 3rpx;
} }
.join-result { .join-result {
position: absolute;
top: 28%;
right: 12%;
left: 12%;
text-align: center; text-align: center;
} }
.join-state--success,
.join-state--error {
padding: 170rpx 12% 70rpx;
}
.join-result__eyebrow { .join-result__eyebrow {
text-align: center; text-align: center;
} }
@@ -391,9 +375,5 @@ const completeFlow = () =>
.join-panel { .join-panel {
width: calc(100% - 48rpx); width: calc(100% - 48rpx);
} }
.join-form {
right: 9%;
left: 9%;
}
} }
</style> </style>
+71 -71
View File
@@ -23,11 +23,6 @@
:key="item.id" :key="item.id"
class="application-card" class="application-card"
> >
<image
class="application-card__skin"
src="/static/assets/modules/genealogy/transparent/list-slip-frame.png"
mode="scaleToFill"
/>
<view class="application-card__body"> <view class="application-card__body">
<text class="application-card__name">{{ item.genealogyName }}</text> <text class="application-card__name">{{ item.genealogyName }}</text>
<text class="application-card__time">{{ item.appliedAt }}</text> <text class="application-card__time">{{ item.appliedAt }}</text>
@@ -56,10 +51,6 @@
description="请稍候,正在同步审核状态。" description="请稍候,正在同步审核状态。"
/> />
<view v-else class="application-state-card"> <view v-else class="application-state-card">
<image
src="/static/assets/modules/genealogy/transparent/list-slip-frame.png"
mode="scaleToFill"
/>
<view class="application-state-card__copy"> <view class="application-state-card__copy">
<text>{{ stateTitle }}</text> <text>{{ stateTitle }}</text>
<text>{{ stateCopy }}</text> <text>{{ stateCopy }}</text>
@@ -72,10 +63,6 @@
applicationState === 'error' ? loadApplications({}) : toSearch() applicationState === 'error' ? loadApplications({}) : toSearch()
" "
> >
<image
src="/static/assets/foundation/transparent/a01-scroll-primary-v3.png"
mode="aspectFit"
/>
<text>{{ <text>{{
applicationState === "error" ? "重新查看" : "查找公开家谱" applicationState === "error" ? "重新查看" : "查找公开家谱"
}}</text> }}</text>
@@ -156,7 +143,7 @@ const statusHint = (status) =>
})[status] || ""; })[status] || "";
const actionFor = (item) => const actionFor = (item) =>
({ PENDING: "撤回申请", APPROVED: "进入家谱", REJECTED: "修改后重新提交" })[ ({ PENDING: "撤回申请", APPROVED: "进入家谱", REJECTED: "修改后重新提交", WITHDRAWN: "重新申请" })[
item.status item.status
] || ""; ] || "";
const stateTitle = computed(() => const stateTitle = computed(() =>
@@ -190,7 +177,14 @@ const loadApplications = (query = {}) => {
return; return;
} }
applications.value = applicationSamples.map((item) => ({ ...item })); applications.value = applicationSamples.map((item) => ({ ...item }));
applicationState.value = "list"; if (query.status) {
const requestedStatus = String(query.status).toUpperCase();
applications.value = applications.value.filter(
(item) => item.status === requestedStatus,
);
if (!applications.value.length) applicationState.value = "empty";
}
if (applicationState.value !== "empty") applicationState.value = "list";
}; };
onLoad(loadApplications); onLoad(loadApplications);
@@ -201,7 +195,11 @@ const handleApplicationAction = (item) => {
}); });
if (item.status === "REJECTED") if (item.status === "REJECTED")
return uni.navigateTo({ return uni.navigateTo({
url: `/pages/genealogy/g08-join-application?source=search&previous=rejected&genealogyId=${item.genealogyId}`, url: `/pages/genealogy/g08-join-application?source=search&previous=rejected&genealogyId=${item.genealogyId}&genealogyName=${encodeURIComponent(item.genealogyName)}`,
});
if (item.status === "WITHDRAWN")
return uni.navigateTo({
url: `/pages/genealogy/g08-join-application?source=search&previous=withdrawn&genealogyId=${item.genealogyId}&genealogyName=${encodeURIComponent(item.genealogyName)}`,
}); });
if (item.status === "PENDING") withdrawTarget.value = item; if (item.status === "PENDING") withdrawTarget.value = item;
}; };
@@ -221,17 +219,15 @@ const toSearch = () =>
<style scoped lang="scss"> <style scoped lang="scss">
.application-page { .application-page {
position: relative; display: flex;
min-height: 100vh; min-height: 100vh;
overflow-x: hidden; flex-direction: column;
background: $paper; background: $paper;
} }
.application-page__header { .application-page__header {
position: relative;
z-index: 3; z-index: 3;
} }
.application-content { .application-content {
position: relative;
z-index: 2; z-index: 2;
padding: 28rpx 24rpx 60rpx; padding: 28rpx 24rpx 60rpx;
} }
@@ -252,47 +248,57 @@ const toSearch = () =>
font-size: 24rpx; font-size: 24rpx;
} }
.application-card { .application-card {
position: relative; display: grid;
width: 100%; width: 100%;
height: calc((100vw - 24px) * 0.34286);
min-height: 228rpx; min-height: 228rpx;
max-height: 282rpx;
margin-bottom: 18rpx; margin-bottom: 18rpx;
} background: url("/static/assets/modules/genealogy/transparent/list-slip-frame.png")
.application-card__skin { center / 100% 100% no-repeat;
position: absolute;
inset: 0;
width: 100%;
height: 100%;
} }
.application-card__body { .application-card__body {
position: absolute; display: grid;
inset: 18% 7% 14% 8.5%; min-width: 0;
min-height: 228rpx;
grid-template-columns: minmax(0, 1fr) auto;
gap: 12rpx 18rpx;
padding: 42rpx 52rpx 34rpx 64rpx;
box-sizing: border-box;
} }
.application-card__name { .application-card__name {
grid-row: 1;
grid-column: 1;
align-self: start;
justify-self: start;
color: $ink; color: $ink;
font-family: "STKaiti", "KaiTi", serif; font-family: "STKaiti", "KaiTi", serif;
font-size: 30rpx; font-size: 30rpx;
font-weight: 700; font-weight: 700;
} }
.application-card__time { .application-card__time {
position: absolute; grid-row: 1;
top: 3rpx; grid-column: 2;
right: 0; align-self: start;
justify-self: end;
margin-top: 3rpx;
color: #998873; color: #998873;
font-size: 23rpx; font-size: 23rpx;
} }
.application-card__relation { .application-card__relation {
display: block; display: block;
max-width: 70%; grid-row: 2;
margin-top: 14rpx; grid-column: 1 / -1;
align-self: start;
justify-self: start;
margin-top: 0;
color: $ink-muted; color: $ink-muted;
font-size: 24rpx; font-size: 24rpx;
} }
.application-card__status { .application-card__status {
position: absolute; grid-row: 2;
right: 3%; grid-column: 2;
bottom: 38%; align-self: center;
justify-self: end;
margin-right: 3%;
color: $brand-red; color: $brand-red;
font-size: 25rpx; font-size: 25rpx;
font-weight: 700; font-weight: 700;
@@ -304,41 +310,42 @@ const toSearch = () =>
color: #7e6f62; color: #7e6f62;
} }
.application-card__hint { .application-card__hint {
position: absolute; grid-row: 3;
bottom: 5%; grid-column: 1;
left: 0; align-self: center;
max-width: 62%; justify-self: start;
margin-bottom: 4rpx;
color: #766653; color: #766653;
font-size: 23rpx; font-size: 23rpx;
} }
.application-card__action { .application-card__action {
position: absolute;
right: 2%;
bottom: 2%;
z-index: 2; z-index: 2;
display: flex; display: flex;
grid-row: 3;
grid-column: 2;
align-self: center;
justify-self: end;
min-height: 54rpx; min-height: 54rpx;
align-items: center; align-items: center;
margin-right: 2%;
margin-bottom: 0;
color: $brand-red; color: $brand-red;
font-size: 23rpx; font-size: 23rpx;
font-weight: 700; font-weight: 700;
} }
.application-state-card { .application-state-card {
position: relative; display: flex;
width: 100%; width: 100%;
height: calc((100vw - 24px) * 0.34286);
min-height: 228rpx; min-height: 228rpx;
align-items: center;
justify-content: center;
margin-top: 80rpx; margin-top: 80rpx;
} padding: 48rpx 12%;
.application-state-card > image { box-sizing: border-box;
position: absolute; background: url("/static/assets/modules/genealogy/transparent/list-slip-frame.png")
inset: 0; center / 100% 100% no-repeat;
width: 100%;
height: 100%;
} }
.application-state-card__copy { .application-state-card__copy {
position: absolute;
inset: 22% 12%;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
justify-content: center; justify-content: center;
@@ -357,24 +364,17 @@ const toSearch = () =>
line-height: 1.6; line-height: 1.6;
} }
.application-page__action { .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; display: flex;
width: 420rpx;
min-height: 82rpx;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
height: 100%; margin: 34rpx auto 0;
background: url("/static/assets/foundation/transparent/a01-scroll-primary-v3.png")
center / contain no-repeat;
}
.application-page__action text {
z-index: 1;
color: #fff9ed; color: #fff9ed;
font-size: 26rpx; font-size: 26rpx;
font-weight: 700; font-weight: 700;
+71 -90
View File
@@ -18,10 +18,6 @@
<view v-if="reviewState !== 'loading'" class="review-intro"> <view v-if="reviewState !== 'loading'" class="review-intro">
<text>核实亲属关系后再决定</text> <text>核实亲属关系后再决定</text>
<text>审核结果会通过消息告知申请人</text> <text>审核结果会通过消息告知申请人</text>
<image
src="/static/assets/modules/genealogy/transparent/section-divider.png"
mode="scaleToFill"
/>
</view> </view>
<template v-if="reviewState === 'list'"> <template v-if="reviewState === 'list'">
@@ -30,11 +26,6 @@
:key="item.id" :key="item.id"
class="application-card" class="application-card"
> >
<image
class="application-card__skin"
src="/static/assets/modules/genealogy/transparent/list-slip-frame.png"
mode="scaleToFill"
/>
<view class="application-card__body"> <view class="application-card__body">
<text class="application-card__name">{{ item.name }}</text> <text class="application-card__name">{{ item.name }}</text>
<text class="application-card__phone">{{ item.phone }}</text> <text class="application-card__phone">{{ item.phone }}</text>
@@ -85,10 +76,6 @@
description="请稍候,正在读取待审核记录。" description="请稍候,正在读取待审核记录。"
/> />
<view v-else class="review-state-card"> <view v-else class="review-state-card">
<image
src="/static/assets/modules/genealogy/transparent/list-slip-frame.png"
mode="scaleToFill"
/>
<view class="review-state-card__copy"> <view class="review-state-card__copy">
<text>{{ stateTitle }}</text> <text>{{ stateTitle }}</text>
<text>{{ stateCopy }}</text> <text>{{ stateCopy }}</text>
@@ -133,13 +120,23 @@
@confirm="helpVisible ? closeDialog() : applyAudit()" @confirm="helpVisible ? closeDialog() : applyAudit()"
@cancel="closeDialog" @cancel="closeDialog"
@close="closeDialog" @close="closeDialog"
>
<view v-if="confirmation && !confirmation.approved" class="rejection-field">
<text>拒绝原因</text>
<textarea
v-model="rejectionReason"
auto-height
maxlength="120"
placeholder="请说明需要补充或核实的信息"
@input="rejectionError = ''"
/> />
<text v-if="rejectionError" class="rejection-field__error">{{
rejectionError
}}</text>
</view>
</AppDialog>
<view v-if="feedbackVisible" class="review-feedback"> <view v-if="feedbackVisible" class="review-feedback">
<image
src="/static/assets/foundation/transparent/a01-scroll-toast-v3.png"
mode="scaleToFill"
/>
<text>{{ feedbackMessage }}</text> <text>{{ feedbackMessage }}</text>
</view> </view>
</view> </view>
@@ -177,6 +174,8 @@ const genealogyId = ref("");
const reviewState = ref("loading"); const reviewState = ref("loading");
const errorMessage = ref(""); const errorMessage = ref("");
const confirmation = ref(null); const confirmation = ref(null);
const rejectionReason = ref("");
const rejectionError = ref("");
const helpVisible = ref(false); const helpVisible = ref(false);
const feedbackVisible = ref(false); const feedbackVisible = ref(false);
const feedbackMessage = ref(""); const feedbackMessage = ref("");
@@ -235,10 +234,14 @@ const loadApplications = (query = {}) => {
onLoad(loadApplications); onLoad(loadApplications);
const confirmAudit = (item, approved) => { const confirmAudit = (item, approved) => {
rejectionReason.value = "";
rejectionError.value = "";
confirmation.value = { item, approved }; confirmation.value = { item, approved };
}; };
const closeDialog = () => { const closeDialog = () => {
confirmation.value = null; confirmation.value = null;
rejectionReason.value = "";
rejectionError.value = "";
helpVisible.value = false; helpVisible.value = false;
}; };
const showFeedback = (message) => { const showFeedback = (message) => {
@@ -253,7 +256,12 @@ const showFeedback = (message) => {
const applyAudit = () => { const applyAudit = () => {
const current = confirmation.value; const current = confirmation.value;
if (!current) return; if (!current) return;
if (!current.approved && !rejectionReason.value.trim()) {
rejectionError.value = "请填写拒绝原因,方便申请人补充资料";
return;
}
current.item.status = current.approved ? "APPROVED" : "REJECTED"; current.item.status = current.approved ? "APPROVED" : "REJECTED";
if (!current.approved) current.item.rejectionReason = rejectionReason.value.trim();
closeDialog(); closeDialog();
showFeedback(current.approved ? "已通过申请" : "已拒绝申请"); showFeedback(current.approved ? "已通过申请" : "已拒绝申请");
}; };
@@ -264,24 +272,26 @@ const showHelp = () => {
<style scoped lang="scss"> <style scoped lang="scss">
.review-page { .review-page {
position: relative; display: flex;
min-height: 100vh; min-height: 100vh;
overflow-x: hidden; flex-direction: column;
background: $paper; background: $paper;
} }
.review-page__header { .review-page__header {
position: relative; z-index: 1;
z-index: 3;
} }
.rejection-field { width: 100%; margin: 20rpx 0; text-align: left; }
.rejection-field > text:first-child { display: block; color: $ink; font-size: 23rpx; font-weight: 700; }
.rejection-field textarea { width: 100%; min-height: 110rpx; margin-top: 10rpx; padding: 18rpx; box-sizing: border-box; color: $ink; font-size: 23rpx; line-height: 1.55; background: url("/static/assets/modules/genealogy/transparent/g-form-field-frame.png") center / 100% 100% no-repeat; }
.rejection-field__error { display: block; margin-top: 8rpx; color: $brand-red; font-size: 21rpx; }
.review-content { .review-content {
position: relative; z-index: 1;
z-index: 2;
padding: 24rpx 24rpx 60rpx; padding: 24rpx 24rpx 60rpx;
} }
.review-intro { .review-intro {
position: relative;
margin: 0 8rpx 24rpx; margin: 0 8rpx 24rpx;
padding-bottom: 22rpx; padding-bottom: 22rpx;
background: url("/static/assets/modules/genealogy/transparent/section-divider.png") bottom center / 100% 14rpx no-repeat;
} }
.review-intro text { .review-intro text {
display: block; display: block;
@@ -297,80 +307,72 @@ const showHelp = () => {
color: $ink-muted; color: $ink-muted;
font-size: 24rpx; font-size: 24rpx;
} }
.review-intro image {
position: absolute;
right: 0;
bottom: 0;
left: 0;
width: 100%;
height: 14rpx;
}
.application-card { .application-card {
position: relative;
width: 100%; width: 100%;
height: calc((100vw - 24px) * 0.34286);
min-height: 228rpx; min-height: 228rpx;
max-height: 282rpx;
margin-bottom: 18rpx; margin-bottom: 18rpx;
} background: url("/static/assets/modules/genealogy/transparent/list-slip-frame.png") center / 100% 100% no-repeat;
.application-card__skin {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
} }
.application-card__body { .application-card__body {
position: absolute; display: grid;
inset: 18% 7% 12% 8.5%; width: 100%;
min-height: 228rpx;
grid-template-columns: auto minmax(0, 1fr) auto;
grid-template-rows: auto auto auto;
gap: 12rpx 0;
padding: 44rpx 52rpx 29rpx 64rpx;
box-sizing: border-box;
} }
.application-card__name { .application-card__name {
grid-column: 1;
color: $ink; color: $ink;
font-family: "STKaiti", "KaiTi", serif; font-family: "STKaiti", "KaiTi", serif;
font-size: 30rpx; font-size: 30rpx;
font-weight: 700; font-weight: 700;
} }
.application-card__phone { .application-card__phone {
grid-column: 2;
margin-left: 14rpx; margin-left: 14rpx;
color: $ink-muted; color: $ink-muted;
font-size: 23rpx; font-size: 23rpx;
} }
.application-card__time { .application-card__time {
position: absolute; grid-column: 3;
top: 3rpx; justify-self: end;
right: 0; margin-top: 3rpx;
color: #998873; color: #998873;
font-size: 23rpx; font-size: 23rpx;
} }
.application-card__relation { .application-card__relation {
display: block; display: block;
max-width: 52%; grid-column: 1 / span 2;
grid-row: 2;
margin-top: 15rpx; margin-top: 15rpx;
color: $ink-muted; color: $ink-muted;
font-size: 24rpx; font-size: 24rpx;
line-height: 1.35; line-height: 1.35;
} }
.review-actions { .review-actions {
position: absolute;
right: 0;
bottom: 0;
display: flex; display: flex;
grid-column: 2 / 4;
grid-row: 3;
justify-self: end;
gap: 12rpx; gap: 12rpx;
} }
.review-action { .review-action {
position: relative; display: grid;
width: 148rpx; width: 148rpx;
height: 68rpx; min-height: 68rpx;
} }
.review-action image { .review-action image {
position: absolute; grid-area: 1 / 1;
inset: 0;
width: 100%; width: 100%;
height: 100%; height: 100%;
} }
.review-action text { .review-action text {
position: relative;
z-index: 1; z-index: 1;
display: flex; display: flex;
grid-area: 1 / 1;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
height: 100%; height: 100%;
@@ -384,22 +386,22 @@ const showHelp = () => {
color: #fff9ed; color: #fff9ed;
} }
.review-result { .review-result {
position: absolute; display: grid;
right: 0; grid-column: 2 / 4;
bottom: 0; grid-row: 3;
justify-self: end;
width: 308rpx; width: 308rpx;
height: 68rpx; min-height: 68rpx;
} }
.review-result image { .review-result image {
position: absolute; grid-area: 1 / 1;
inset: 0;
width: 100%; width: 100%;
height: 100%; height: 100%;
} }
.application-card__status { .application-card__status {
position: relative;
z-index: 1; z-index: 1;
display: flex; display: flex;
grid-area: 1 / 1;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
height: 100%; height: 100%;
@@ -414,24 +416,18 @@ const showHelp = () => {
color: $ink; color: $ink;
} }
.review-state-card { .review-state-card {
position: relative;
width: 100%; width: 100%;
height: calc((100vw - 24px) * 0.34286);
min-height: 228rpx; min-height: 228rpx;
margin-top: 70rpx; margin-top: 70rpx;
} background: url("/static/assets/modules/genealogy/transparent/list-slip-frame.png") center / 100% 100% no-repeat;
.review-state-card > image {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
} }
.review-state-card__copy { .review-state-card__copy {
position: absolute;
inset: 22% 12%;
display: flex; display: flex;
min-height: 228rpx;
flex-direction: column; flex-direction: column;
justify-content: center; justify-content: center;
padding: 48rpx 12%;
box-sizing: border-box;
text-align: center; text-align: center;
} }
.review-state-card__copy text:first-child { .review-state-card__copy text:first-child {
@@ -447,20 +443,12 @@ const showHelp = () => {
line-height: 1.6; line-height: 1.6;
} }
.review-page__retry { .review-page__retry {
position: relative;
width: 420rpx; width: 420rpx;
height: 82rpx; min-height: 82rpx;
margin: 32rpx auto 0; margin: 32rpx auto 0;
} background: url("/static/assets/foundation/transparent/a01-scroll-primary-v3.png") center / contain no-repeat;
.review-page__retry image {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
} }
.review-page__retry text { .review-page__retry text {
position: relative;
z-index: 1;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
@@ -480,17 +468,10 @@ const showHelp = () => {
align-items: center; align-items: center;
justify-content: center; justify-content: center;
padding: 0 36rpx; padding: 0 36rpx;
background: url("/static/assets/foundation/transparent/a01-scroll-toast-v3.png") center / 100% 100% no-repeat;
transform: translateX(-50%); transform: translateX(-50%);
} }
.review-feedback image {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
}
.review-feedback text { .review-feedback text {
position: relative;
z-index: 1;
color: $ink; color: $ink;
font-size: 23rpx; font-size: 23rpx;
} }
+69 -90
View File
@@ -13,12 +13,6 @@
'settings-state--no-permission': settingsState === 'no-permission', 'settings-state--no-permission': settingsState === 'no-permission',
}" }"
> >
<image
class="settings-panel__skin"
src="/static/assets/modules/genealogy/transparent/g01-empty-panel-frame.png"
mode="scaleToFill"
/>
<AppLoading <AppLoading
v-if="settingsState === 'loading'" v-if="settingsState === 'loading'"
text="正在读取家谱设置" text="正在读取家谱设置"
@@ -32,10 +26,6 @@
> >
<view class="settings-field"> <view class="settings-field">
<image
src="/static/assets/modules/genealogy/transparent/g-form-field-frame.png"
mode="scaleToFill"
/>
<text>家谱名称</text> <text>家谱名称</text>
<input <input
v-model="genealogyDraft.name" v-model="genealogyDraft.name"
@@ -79,13 +69,10 @@
</view> </view>
<view class="settings-field settings-field--note"> <view class="settings-field settings-field--note">
<image
src="/static/assets/modules/genealogy/transparent/g-form-field-frame.png"
mode="scaleToFill"
/>
<text>访问说明</text> <text>访问说明</text>
<textarea <textarea
v-model="genealogyDraft.accessNote" v-model="genealogyDraft.accessNote"
auto-height
maxlength="80" maxlength="80"
placeholder="向访问者说明家谱用途" placeholder="向访问者说明家谱用途"
placeholder-class="settings-placeholder" placeholder-class="settings-placeholder"
@@ -93,10 +80,6 @@
</view> </view>
<view class="settings-action" @click="saveSettings"> <view class="settings-action" @click="saveSettings">
<image
src="/static/assets/foundation/transparent/a01-scroll-primary-v3.png"
mode="aspectFit"
/>
<text>保存设置</text> <text>保存设置</text>
</view> </view>
</view> </view>
@@ -124,10 +107,6 @@
: "请从家谱总览重新进入,当前修改不会被保留。" : "请从家谱总览重新进入,当前修改不会被保留。"
}}</text> }}</text>
<view class="settings-action" @click="settingsState = 'form'"> <view class="settings-action" @click="settingsState = 'form'">
<image
src="/static/assets/foundation/transparent/a01-scroll-primary-v3.png"
mode="aspectFit"
/>
<text>{{ <text>{{
settingsState === "success" ? "继续调整" : "重新查看" settingsState === "success" ? "继续调整" : "重新查看"
}}</text> }}</text>
@@ -135,10 +114,6 @@
</view> </view>
</view> </view>
<view v-if="feedbackVisible" class="settings-feedback"> <view v-if="feedbackVisible" class="settings-feedback">
<image
src="/static/assets/foundation/transparent/a01-scroll-secondary-v3.png"
mode="aspectFit"
/>
<text>设置已保存</text> <text>设置已保存</text>
</view> </view>
</view> </view>
@@ -156,11 +131,27 @@ const settingsState = ref("loading");
const nameError = ref(""); const nameError = ref("");
const feedbackVisible = ref(false); const feedbackVisible = ref(false);
let feedbackTimer = null; let feedbackTimer = null;
const settingsFixtures = {
1001: {
name: "汤氏家谱",
visibility: "MEMBER_ONLY",
accessNote: "家族资料,请妥善保存",
},
1002: {
name: "汤氏宗谱",
visibility: "PUBLIC_APPLY",
accessNote: "公开家谱身份,成员资料需审核后查看",
},
};
const genealogyDraft = reactive({ const genealogyDraft = reactive({
name: "汤氏家谱", name: "汤氏家谱",
visibility: "MEMBER_ONLY", visibility: "MEMBER_ONLY",
accessNote: "家族资料,请妥善保存", accessNote: "家族资料,请妥善保存",
}); });
const originalDraft = ref("");
const isDirty = computed(
() => JSON.stringify(genealogyDraft) !== originalDraft.value,
);
const visibilityOptions = [ const visibilityOptions = [
{ value: "MEMBER_ONLY", label: "仅成员可见" }, { value: "MEMBER_ONLY", label: "仅成员可见" },
{ value: "PUBLIC_APPLY", label: "公开可申请" }, { value: "PUBLIC_APPLY", label: "公开可申请" },
@@ -178,6 +169,11 @@ const visibilityHint = computed(() =>
onLoad((query) => { onLoad((query) => {
genealogyId.value = query.genealogyId || ""; genealogyId.value = query.genealogyId || "";
Object.assign(
genealogyDraft,
settingsFixtures[genealogyId.value] || settingsFixtures[1001],
);
originalDraft.value = JSON.stringify(genealogyDraft);
settingsState.value = settingsState.value =
query.state === "loading" query.state === "loading"
? "loading" ? "loading"
@@ -198,6 +194,11 @@ const saveSettings = () => {
nameError.value = "请填写家谱名称"; nameError.value = "请填写家谱名称";
return; return;
} }
if (genealogyDraft.name.trim().length > 30) {
nameError.value = "家谱名称不能超过 30 个字";
return;
}
originalDraft.value = JSON.stringify(genealogyDraft);
settingsState.value = "success"; settingsState.value = "success";
feedbackVisible.value = true; feedbackVisible.value = true;
feedbackTimer = setTimeout(() => { feedbackTimer = setTimeout(() => {
@@ -209,31 +210,29 @@ const saveSettings = () => {
<style scoped lang="scss"> <style scoped lang="scss">
.settings-page { .settings-page {
position: relative; display: flex;
min-height: 100vh; min-height: 100vh;
overflow-x: hidden; flex-direction: column;
background: $paper; background: $paper;
} }
.settings-page__header { .settings-page__header {
position: relative;
z-index: 3; z-index: 3;
} }
.settings-panel { .settings-panel {
position: relative;
z-index: 2; z-index: 2;
width: calc(100% - 32rpx); width: calc(100% - 32rpx);
height: min(620px, calc((100vw - 16px) * 1.43)); min-height: min(620px, calc((100vw - 16px) * 1.43));
margin: 18rpx auto 0; margin: 18rpx auto 0;
padding: 70rpx 8%;
box-sizing: border-box;
background: url("/static/assets/modules/genealogy/transparent/g01-empty-panel-frame.png")
center / 100% 100% no-repeat;
} }
.settings-panel__skin { .settings-panel > .app-loading {
position: absolute; grid-area: 1 / 1 / -1 / -1;
inset: 0;
width: 100%;
height: 100%;
} }
.settings-form { .settings-form {
position: absolute; min-width: 0;
inset: 7.5% 8%;
} }
.settings-form__eyebrow, .settings-form__eyebrow,
.settings-result__eyebrow { .settings-result__eyebrow {
@@ -260,34 +259,30 @@ const saveSettings = () => {
line-height: 1.55; line-height: 1.55;
} }
.settings-field { .settings-field {
position: relative; display: grid;
height: 92rpx; min-height: 92rpx;
margin-top: 17rpx; margin-top: 17rpx;
} background: url("/static/assets/modules/genealogy/transparent/g-form-field-frame.png")
.settings-field > image { center / 100% 100% no-repeat;
position: absolute;
inset: 0;
width: 100%;
height: 100%;
} }
.settings-field > text { .settings-field > text {
position: absolute;
top: 31rpx;
left: 24rpx;
z-index: 1; z-index: 1;
grid-area: 1 / 1;
align-self: center;
margin-left: 24rpx;
color: $ink; color: $ink;
font-size: 24rpx; font-size: 24rpx;
font-weight: 700; font-weight: 700;
} }
.settings-field input, .settings-field input,
.settings-field textarea { .settings-field textarea {
position: absolute;
top: 0;
right: 20rpx;
bottom: 0;
left: 164rpx;
z-index: 1; z-index: 1;
height: 92rpx; grid-area: 1 / 1;
width: auto;
min-width: 0;
min-height: 92rpx;
margin-right: 20rpx;
margin-left: 164rpx;
color: $ink; color: $ink;
font-size: 24rpx; font-size: 24rpx;
line-height: 92rpx; line-height: 92rpx;
@@ -323,20 +318,19 @@ const saveSettings = () => {
margin-top: 10rpx; margin-top: 10rpx;
} }
.visibility-option { .visibility-option {
position: relative; display: grid;
width: calc(50% - 7rpx); width: calc(50% - 7rpx);
height: 64rpx; min-height: 64rpx;
} }
.visibility-option image { .visibility-option image {
position: absolute; grid-area: 1 / 1;
inset: 0;
width: 100%; width: 100%;
height: 100%; height: 100%;
} }
.visibility-option text { .visibility-option text {
position: relative;
z-index: 1; z-index: 1;
display: flex; display: flex;
grid-area: 1 / 1;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
height: 100%; height: 100%;
@@ -358,36 +352,30 @@ const saveSettings = () => {
margin-top: 14rpx; margin-top: 14rpx;
} }
.settings-action { .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; display: flex;
width: 100%;
min-height: 78rpx;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
height: 100%; margin-top: 19rpx;
background: url("/static/assets/foundation/transparent/a01-scroll-primary-v3.png")
center / contain no-repeat;
}
.settings-action text {
z-index: 1;
color: #fff9ed; color: #fff9ed;
font-size: 26rpx; font-size: 26rpx;
font-weight: 700; font-weight: 700;
letter-spacing: 3rpx; letter-spacing: 3rpx;
} }
.settings-result { .settings-result {
position: absolute;
top: 29%;
right: 12%;
left: 12%;
text-align: center; text-align: center;
} }
.settings-state--success,
.settings-state--error,
.settings-state--no-permission {
padding: 180rpx 12% 70rpx;
}
.settings-result__eyebrow { .settings-result__eyebrow {
text-align: center; text-align: center;
} }
@@ -411,15 +399,10 @@ const saveSettings = () => {
justify-content: center; justify-content: center;
padding: 0 34rpx; padding: 0 34rpx;
transform: translateX(-50%); transform: translateX(-50%);
} background: url("/static/assets/foundation/transparent/a01-scroll-secondary-v3.png")
.settings-feedback image { center / contain no-repeat;
position: absolute;
inset: 0;
width: 100%;
height: 100%;
} }
.settings-feedback text { .settings-feedback text {
position: relative;
z-index: 1; z-index: 1;
color: $ink; color: $ink;
font-size: 22rpx; font-size: 22rpx;
@@ -428,9 +411,5 @@ const saveSettings = () => {
.settings-panel { .settings-panel {
width: calc(100% - 48rpx); width: calc(100% - 48rpx);
} }
.settings-form {
right: 9%;
left: 9%;
}
} }
</style> </style>
+71 -95
View File
@@ -19,12 +19,6 @@
'poem-state--no-permission': poemState === 'no-permission', 'poem-state--no-permission': poemState === 'no-permission',
}" }"
> >
<image
class="poem-panel__skin"
src="/static/assets/modules/genealogy/transparent/g01-empty-panel-frame.png"
mode="scaleToFill"
/>
<AppLoading <AppLoading
v-if="poemState === 'loading'" v-if="poemState === 'loading'"
text="正在整理字辈诗" text="正在整理字辈诗"
@@ -43,10 +37,6 @@
class="poem-row" class="poem-row"
:class="{ 'poem-row--current': item.current }" :class="{ 'poem-row--current': item.current }"
> >
<image
src="/static/assets/modules/genealogy/transparent/g-form-field-frame.png"
mode="scaleToFill"
/>
<text class="poem-row__number"> {{ item.generationNo }} </text> <text class="poem-row__number"> {{ item.generationNo }} </text>
<text class="poem-row__character">{{ item.character }}</text> <text class="poem-row__character">{{ item.character }}</text>
<text class="poem-row__status">{{ <text class="poem-row__status">{{
@@ -69,14 +59,11 @@
>按照接口支持的连续文本录入每个汉字对应一代保存前可预览新字序</text >按照接口支持的连续文本录入每个汉字对应一代保存前可预览新字序</text
> >
<view class="poem-field"> <view class="poem-field">
<image
src="/static/assets/modules/genealogy/transparent/g-form-field-frame.png"
mode="scaleToFill"
/>
<text>字辈内容</text> <text>字辈内容</text>
<textarea <textarea
v-model="poemDraft" v-model="poemDraft"
maxlength="24" auto-height
maxlength="50"
placeholder="例如:启宗敦本继世传芳" placeholder="例如:启宗敦本继世传芳"
placeholder-class="poem-placeholder" placeholder-class="poem-placeholder"
@input="poemError = ''" @input="poemError = ''"
@@ -158,10 +145,6 @@
</view> </view>
</view> </view>
<view v-if="feedbackVisible" class="poem-feedback"> <view v-if="feedbackVisible" class="poem-feedback">
<image
src="/static/assets/foundation/transparent/a01-scroll-secondary-v3.png"
mode="aspectFit"
/>
<text>字辈预览已更新</text> <text>字辈预览已更新</text>
</view> </view>
</view> </view>
@@ -181,6 +164,8 @@ const feedbackVisible = ref(false);
let feedbackTimer = null; let feedbackTimer = null;
const poemDraft = ref("启宗敦本继世传芳"); const poemDraft = ref("启宗敦本继世传芳");
const stopMissingOldGeneration = ref(true); const stopMissingOldGeneration = ref(true);
const startGeneration = ref(12);
const currentGeneration = ref(14);
const poemRows = ref([ const poemRows = ref([
{ generationNo: 12, character: "启", current: false }, { generationNo: 12, character: "启", current: false },
{ generationNo: 13, character: "宗", current: false }, { generationNo: 13, character: "宗", current: false },
@@ -193,6 +178,11 @@ const previewCharacters = computed(() =>
onLoad((query) => { onLoad((query) => {
genealogyId.value = query.genealogyId || ""; genealogyId.value = query.genealogyId || "";
startGeneration.value = Math.max(1, Number(query.startGeneration) || 12);
currentGeneration.value = Math.max(
startGeneration.value,
Number(query.currentGeneration) || 14,
);
poemState.value = poemState.value =
query.state === "loading" query.state === "loading"
? "loading" ? "loading"
@@ -223,13 +213,20 @@ const savePoems = () => {
poemError.value = "字辈保存失败,请稍后重试"; poemError.value = "字辈保存失败,请稍后重试";
return; return;
} }
poemRows.value = characters const preservedRows = stopMissingOldGeneration.value
.slice(0, 4) ? []
.map((character, index) => ({ : poemRows.value.filter(
generationNo: 12 + index, (item) => item.generationNo < startGeneration.value,
);
const nextRows = characters.map((character, index) => {
const generationNo = startGeneration.value + index;
return {
generationNo,
character, character,
current: index === 2, current: generationNo === currentGeneration.value,
})); };
});
poemRows.value = [...preservedRows, ...nextRows];
poemState.value = "list"; poemState.value = "list";
feedbackVisible.value = true; feedbackVisible.value = true;
feedbackTimer = setTimeout(() => { feedbackTimer = setTimeout(() => {
@@ -241,33 +238,31 @@ const savePoems = () => {
<style scoped lang="scss"> <style scoped lang="scss">
.poem-page { .poem-page {
position: relative; display: flex;
min-height: 100vh; min-height: 100vh;
overflow-x: hidden; flex-direction: column;
background: $paper; background: $paper;
} }
.poem-page__header { .poem-page__header {
position: relative;
z-index: 3; z-index: 3;
} }
.poem-panel { .poem-panel {
position: relative;
z-index: 2; z-index: 2;
width: calc(100% - 32rpx); width: calc(100% - 32rpx);
height: min(650px, calc((100vw - 16px) * 1.5)); min-height: min(650px, calc((100vw - 16px) * 1.5));
margin: 18rpx auto 0; margin: 18rpx auto 0;
padding: 76rpx 8%;
box-sizing: border-box;
background: url("/static/assets/modules/genealogy/transparent/g01-empty-panel-frame.png")
center / 100% 100% no-repeat;
} }
.poem-panel__skin { .poem-panel > .app-loading {
position: absolute; grid-area: 1 / 1 / -1 / -1;
inset: 0;
width: 100%;
height: 100%;
} }
.poem-list, .poem-list,
.poem-editor, .poem-editor,
.poem-state-card { .poem-state-card {
position: absolute; min-width: 0;
inset: 7.5% 8%;
} }
.poem-list__eyebrow { .poem-list__eyebrow {
display: block; display: block;
@@ -294,39 +289,33 @@ const savePoems = () => {
margin-top: 18rpx; margin-top: 18rpx;
} }
.poem-row { .poem-row {
position: relative; display: grid;
height: 72rpx; min-height: 72rpx;
margin-top: 10rpx; margin-top: 10rpx;
} grid-template-columns: 48% auto 1fr auto;
.poem-row image { align-items: center;
position: absolute; background: url("/static/assets/modules/genealogy/transparent/g-form-field-frame.png")
inset: 0; center / 100% 100% no-repeat;
width: 100%;
height: 100%;
} }
.poem-row__number { .poem-row__number {
position: absolute;
top: 24rpx;
left: 24rpx;
z-index: 1; z-index: 1;
grid-column: 1;
margin-left: 24rpx;
color: $ink-muted; color: $ink-muted;
font-size: 23rpx; font-size: 23rpx;
} }
.poem-row__character { .poem-row__character {
position: absolute;
top: 14rpx;
left: 48%;
z-index: 1; z-index: 1;
grid-column: 2;
color: $ink; color: $ink;
font-family: "STKaiti", "KaiTi", serif; font-family: "STKaiti", "KaiTi", serif;
font-size: 32rpx; font-size: 32rpx;
font-weight: 700; font-weight: 700;
} }
.poem-row__status { .poem-row__status {
position: absolute;
top: 25rpx;
right: 22rpx;
z-index: 1; z-index: 1;
grid-column: 4;
margin-right: 22rpx;
color: $ink-muted; color: $ink-muted;
font-size: 22rpx; font-size: 22rpx;
} }
@@ -335,21 +324,20 @@ const savePoems = () => {
color: $brand-red; color: $brand-red;
} }
.poem-action { .poem-action {
position: relative; display: grid;
width: 100%; width: 100%;
height: 76rpx; min-height: 76rpx;
margin-top: 20rpx; margin-top: 20rpx;
} }
.poem-action image { .poem-action image {
position: absolute; grid-area: 1 / 1;
inset: 0;
width: 100%; width: 100%;
height: 100%; height: 100%;
} }
.poem-action text { .poem-action text {
position: relative;
z-index: 1; z-index: 1;
display: flex; display: flex;
grid-area: 1 / 1;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
height: 100%; height: 100%;
@@ -359,34 +347,30 @@ const savePoems = () => {
letter-spacing: 2rpx; letter-spacing: 2rpx;
} }
.poem-field { .poem-field {
position: relative; display: grid;
height: 118rpx; min-height: 118rpx;
margin-top: 22rpx; margin-top: 22rpx;
} background: url("/static/assets/modules/genealogy/transparent/g-form-field-frame.png")
.poem-field image { center / 100% 100% no-repeat;
position: absolute;
inset: 0;
width: 100%;
height: 100%;
} }
.poem-field > text { .poem-field > text {
position: absolute;
top: 47rpx;
left: 24rpx;
z-index: 1; z-index: 1;
grid-area: 1 / 1;
align-self: center;
margin-left: 24rpx;
color: $ink; color: $ink;
font-size: 22rpx; font-size: 22rpx;
font-weight: 700; font-weight: 700;
} }
.poem-field textarea { .poem-field textarea {
position: absolute;
top: 0;
right: 20rpx;
bottom: 0;
left: 164rpx;
z-index: 1; z-index: 1;
grid-area: 1 / 1;
box-sizing: border-box; box-sizing: border-box;
height: 118rpx; width: auto;
min-width: 0;
min-height: 118rpx;
margin-right: 20rpx;
margin-left: 164rpx;
padding-top: 34rpx; padding-top: 34rpx;
color: $ink; color: $ink;
font-size: 22rpx; font-size: 22rpx;
@@ -415,20 +399,19 @@ const savePoems = () => {
font-weight: 700; font-weight: 700;
} }
.poem-policy__option { .poem-policy__option {
position: relative; display: grid;
width: 248rpx; width: 248rpx;
height: 62rpx; min-height: 62rpx;
} }
.poem-policy__option image { .poem-policy__option image {
position: absolute; grid-area: 1 / 1;
inset: 0;
width: 100%; width: 100%;
height: 100%; height: 100%;
} }
.poem-policy__option text { .poem-policy__option text {
position: relative;
z-index: 1; z-index: 1;
display: flex; display: flex;
grid-area: 1 / 1;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
height: 100%; height: 100%;
@@ -459,9 +442,13 @@ const savePoems = () => {
color: $ink; color: $ink;
} }
.poem-state-card { .poem-state-card {
top: 29%;
text-align: center; text-align: center;
} }
.poem-state--empty,
.poem-state--error,
.poem-state--no-permission {
padding: 180rpx 4% 70rpx;
}
.poem-state-card .poem-list__eyebrow { .poem-state-card .poem-list__eyebrow {
text-align: center; text-align: center;
} }
@@ -485,15 +472,10 @@ const savePoems = () => {
justify-content: center; justify-content: center;
padding: 0 34rpx; padding: 0 34rpx;
transform: translateX(-50%); transform: translateX(-50%);
} background: url("/static/assets/foundation/transparent/a01-scroll-secondary-v3.png")
.poem-feedback image { center / contain no-repeat;
position: absolute;
inset: 0;
width: 100%;
height: 100%;
} }
.poem-feedback text { .poem-feedback text {
position: relative;
z-index: 1; z-index: 1;
color: $ink; color: $ink;
font-size: 22rpx; font-size: 22rpx;
@@ -502,11 +484,5 @@ const savePoems = () => {
.poem-panel { .poem-panel {
width: calc(100% - 48rpx); width: calc(100% - 48rpx);
} }
.poem-list,
.poem-editor,
.poem-state-card {
right: 9%;
left: 9%;
}
} }
</style> </style>
+18 -40
View File
@@ -13,7 +13,7 @@
<view class="notice-page__header"> <view class="notice-page__header">
<PageHeader <PageHeader
title="消息中心" title="消息中心"
:action="noticeState === 'list' ? '全部已读' : ''" :action="unreadCount > 0 && noticeState === 'list' ? '全部已读' : ''"
@action="markAllRead" @action="markAllRead"
/> />
</view> </view>
@@ -33,13 +33,8 @@
class="notice-card" class="notice-card"
role="button" role="button"
:aria-label="`${item.unread ? '未读' : '已读'}消息${item.title}`" :aria-label="`${item.unread ? '未读' : '已读'}消息${item.title}`"
@click="readNotice(item)" @click="openNotice(item)"
> >
<image
class="notice-card__skin"
src="/static/assets/modules/notification/transparent/n01-notice-card.png"
mode="scaleToFill"
/>
<view class="notice-card__copy"> <view class="notice-card__copy">
<text <text
class="notice-card__status" class="notice-card__status"
@@ -61,11 +56,6 @@
</template> </template>
<view v-else class="notice-state-card"> <view v-else class="notice-state-card">
<image
class="notice-state-card__skin"
src="/static/assets/modules/notification/transparent/n01-notice-card.png"
mode="scaleToFill"
/>
<view class="notice-state-card__copy"> <view class="notice-state-card__copy">
<text class="notice-state-card__title">{{ <text class="notice-state-card__title">{{
noticeState === "empty" ? "暂时没有新消息" : "消息中心暂不可用" noticeState === "empty" ? "暂时没有新消息" : "消息中心暂不可用"
@@ -90,7 +80,7 @@
</template> </template>
<script setup> <script setup>
import { onUnmounted, ref } from "vue"; import { computed, onUnmounted, ref } from "vue";
import { onLoad } from "@dcloudio/uni-app"; import { onLoad } from "@dcloudio/uni-app";
import AppButton from "@/components/AppButton.vue"; import AppButton from "@/components/AppButton.vue";
import AppLoading from "@/components/AppLoading.vue"; import AppLoading from "@/components/AppLoading.vue";
@@ -112,6 +102,7 @@ const notices = ref([
content: "汤志成申请加入汤氏家谱,请核实亲属关系。", content: "汤志成申请加入汤氏家谱,请核实亲属关系。",
time: "今天 10:28", time: "今天 10:28",
unread: true, unread: true,
detailId: "review-1",
}, },
{ {
id: 2, id: 2,
@@ -119,6 +110,7 @@ const notices = ref([
content: "你申请加入汝南汤氏家谱的请求已通过。", content: "你申请加入汝南汤氏家谱的请求已通过。",
time: "昨天 18:10", time: "昨天 18:10",
unread: false, unread: false,
detailId: "approved",
}, },
]); ]);
@@ -135,8 +127,14 @@ onLoad((query) => {
: "list"; : "list";
}); });
const readNotice = (item) => { const unreadCount = computed(
() => notices.value.filter((item) => item.unread).length,
);
const openNotice = (item) => {
item.unread = false; item.unread = false;
uni.navigateTo({
url: `/pages/notification/n02-message-detail?id=${item.detailId}&genealogyId=${genealogyId.value}`,
});
}; };
const restoreList = () => { const restoreList = () => {
noticeState.value = "list"; noticeState.value = "list";
@@ -168,17 +166,17 @@ onUnmounted(() => {
<style scoped lang="scss"> <style scoped lang="scss">
.notice-page { .notice-page {
position: relative; display: flex;
min-height: 100vh; min-height: 100vh;
overflow-x: hidden; flex-direction: column;
background: $paper; background: $paper;
} }
.notice-page__header, .notice-page__header,
.notice-content { .notice-content {
position: relative; z-index: 1;
z-index: 2;
} }
.notice-content { .notice-content {
flex: 1;
padding: 24rpx 28rpx 100rpx; padding: 24rpx 28rpx 100rpx;
} }
.notice-list { .notice-list {
@@ -187,19 +185,11 @@ onUnmounted(() => {
gap: 18rpx; gap: 18rpx;
} }
.notice-card { .notice-card {
position: relative;
width: 100%; width: 100%;
min-height: 220rpx; min-height: 220rpx;
} background: url("/static/assets/modules/notification/transparent/n01-notice-card.png") center / 100% 100% no-repeat;
.notice-card__skin {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
} }
.notice-card__copy { .notice-card__copy {
position: relative;
z-index: 1;
display: flex; display: flex;
min-height: 220rpx; min-height: 220rpx;
box-sizing: border-box; box-sizing: border-box;
@@ -238,24 +228,12 @@ onUnmounted(() => {
margin: 30rpx auto 0; margin: 30rpx auto 0;
} }
.notice-state-card { .notice-state-card {
position: relative;
margin-top: 38rpx; margin-top: 38rpx;
background: url("/static/assets/modules/notification/transparent/n01-notice-card.png") top center / 100% 220rpx no-repeat;
text-align: center; text-align: center;
} }
.notice-state-card__skin {
position: absolute;
top: 0;
right: 0;
left: 0;
width: 100%;
height: 220rpx;
min-height: 118px;
}
.notice-state-card__copy { .notice-state-card__copy {
position: relative;
z-index: 1;
display: flex; display: flex;
height: 220rpx;
min-height: 118px; min-height: 118px;
box-sizing: border-box; box-sizing: border-box;
flex-direction: column; flex-direction: column;
+109 -3
View File
@@ -1,5 +1,111 @@
<!-- 页面编号N-02用途消息详情已读与空状态 --> <!-- 页面编号N-02用途消息详情已读状态与上下文操作 -->
<template><ModulePage page-id="n02" /></template> <template>
<view class="notice-detail-page" :class="{ 'notice-state--ready': noticeState === 'ready', 'notice-state--loading': noticeState === 'loading', 'notice-state--expired': noticeState === 'expired' }">
<ModulePageBackground module="notification" />
<view class="page-layer"><PageHeader title="消息详情" /></view>
<view class="notice-content page-layer">
<AppLoading v-if="noticeState === 'loading'" text="正在读取消息" description="请稍候,正在整理消息详情。" />
<view v-else-if="noticeState === 'expired'" class="paper-panel state-card">
<text class="state-title">这条消息已失效</text>
<text class="state-copy">消息可能已撤回或超过保留期限请返回消息中心查看其他内容</text>
<AppButton block label="返回消息中心" @click="backToMessages" />
</view>
<template v-else>
<view class="paper-panel notice-card">
<view class="notice-meta">
<text :class="{ 'is-unread': !noticeDetail.read }">{{ noticeDetail.read ? "已读" : "未读提醒" }}</text>
<text>{{ noticeDetail.time }}</text>
</view>
<text class="notice-title">{{ noticeDetail.title }}</text>
<text class="notice-body">{{ noticeDetail.body }}</text>
<text class="notice-source">来自{{ noticeDetail.source }}</text>
</view>
<view class="action-stack">
<AppButton v-if="!noticeDetail.read" block label="标记已读" @click="markAsRead" />
<AppButton v-if="noticeDetail.target" block type="secondary" :label="noticeDetail.targetLabel" @click="openNoticeTarget" />
</view>
</template>
</view>
<AppToast :visible="toastVisible" :message="toastMessage" />
</view>
</template>
<script setup> <script setup>
import ModulePage from "@/components/ModulePage.vue"; import { onUnmounted, ref } from "vue";
import { onLoad } from "@dcloudio/uni-app";
import AppButton from "@/components/AppButton.vue";
import AppLoading from "@/components/AppLoading.vue";
import AppToast from "@/components/AppToast.vue";
import ModulePageBackground from "@/components/ModulePageBackground.vue";
import PageHeader from "@/components/PageHeader.vue";
const noticeState = ref("loading");
const toastVisible = ref(false);
const toastMessage = ref("");
let toastTimer = null;
const noticeDetail = ref({
id: "review-1",
title: "申请待审核",
body: "汤志成申请加入汤氏家谱,请核实申请人的亲属关系与世代信息后完成审核。",
time: "今天 10:28",
source: "汝南汤氏家谱",
read: false,
target: "/pages/genealogy/g10-application-review",
targetLabel: "前往入谱审核",
});
onLoad((query) => {
if (query.state === "expired") {
noticeState.value = "expired";
return;
}
if (query.id === "approved") {
noticeDetail.value = {
id: "approved",
title: "入谱申请已通过",
body: "你申请加入汝南汤氏家谱的请求已通过,现在可以查看家谱与家族动态。",
time: "昨天 18:10",
source: "汝南汤氏家谱",
read: true,
target: "/pages/genealogy/g01-my-genealogies",
targetLabel: "查看我的家谱",
};
}
noticeState.value = "ready";
});
const showToast = (message) => {
toastMessage.value = message;
toastVisible.value = true;
if (toastTimer) clearTimeout(toastTimer);
toastTimer = setTimeout(() => (toastVisible.value = false), 1800);
};
const markAsRead = () => {
noticeDetail.value.read = true;
showToast("已标记为已读");
};
const openNoticeTarget = () => uni.navigateTo({ url: noticeDetail.value.target });
const backToMessages = () => uni.navigateBack();
onUnmounted(() => toastTimer && clearTimeout(toastTimer));
</script> </script>
<style scoped lang="scss">
.notice-detail-page { display: flex; min-height: 100vh; flex-direction: column; background: $paper; }
.page-layer { z-index: 1; }
.notice-content { flex: 1; padding: 28rpx 30rpx 72rpx; }
.paper-panel { background: url("/static/assets/modules/notification/transparent/n01-notice-card.png") center / 100% 100% no-repeat; }
.notice-card { min-height: 430rpx; padding: 54rpx 52rpx; box-sizing: border-box; }
.notice-meta { display: flex; flex-wrap: wrap; justify-content: space-between; gap: 8rpx 20rpx; color: $ink-muted; font-size: 22rpx; }
.notice-meta .is-unread { color: $brand-red; font-weight: 700; }
.notice-title { display: block; margin-top: 24rpx; color: $ink; font-family: STKaiti, KaiTi, serif; font-size: 38rpx; font-weight: 700; overflow-wrap: anywhere; }
.notice-body { display: block; margin-top: 22rpx; color: #5f4a38; font-size: 25rpx; line-height: 1.75; overflow-wrap: anywhere; }
.notice-source { display: block; margin-top: 28rpx; color: $ink-muted; font-size: 22rpx; overflow-wrap: anywhere; }
.action-stack { display: flex; flex-direction: column; gap: 18rpx; margin-top: 28rpx; }
.state-card { min-height: 340rpx; padding: 72rpx 50rpx 48rpx; box-sizing: border-box; text-align: center; }
.state-title { display: block; color: $ink; font-size: 34rpx; font-weight: 700; }
.state-copy { display: block; margin: 18rpx 0 28rpx; color: $ink-muted; font-size: 24rpx; line-height: 1.65; }
@media (max-width: 340px) { .notice-content { padding-right: 22rpx; padding-left: 22rpx; } .notice-card { padding-right: 40rpx; padding-left: 40rpx; } }
</style>
+38 -62
View File
@@ -19,11 +19,6 @@
<view class="profile-content"> <view class="profile-content">
<template v-if="profileState === 'ready'"> <template v-if="profileState === 'ready'">
<view class="profile-hero"> <view class="profile-hero">
<image
class="profile-hero__frame"
src="/static/assets/modules/profile/transparent/m01-profile-summary-card.png"
mode="scaleToFill"
/>
<image <image
class="profile-hero__hall" class="profile-hero__hall"
src="/static/assets/foundation/transparent/root-header-hall.png" src="/static/assets/foundation/transparent/root-header-hall.png"
@@ -34,6 +29,7 @@
src="/static/assets/foundation/transparent/auth-title-cloud.png" src="/static/assets/foundation/transparent/auth-title-cloud.png"
mode="aspectFit" mode="aspectFit"
/> />
<view class="profile-hero__content">
<view class="profile-hero__label" <view class="profile-hero__label"
><text></text><text></text><text></text><text></text></view ><text></text><text></text><text></text><text></text></view
> >
@@ -50,6 +46,7 @@
</view> </view>
</view> </view>
</view> </view>
</view>
<view <view
class="profile-scroll-notice" class="profile-scroll-notice"
@@ -57,11 +54,6 @@
aria-label="查看待处理消息" aria-label="查看待处理消息"
@click="toNotifications" @click="toNotifications"
> >
<image
class="profile-scroll-notice__skin"
src="/static/assets/foundation/transparent/a01-scroll-primary-v3.png"
mode="scaleToFill"
/>
<view class="profile-scroll-notice__copy"> <view class="profile-scroll-notice__copy">
<text>待你处理</text> <text>待你处理</text>
<text>2 条家谱提醒与审核通知</text> <text>2 条家谱提醒与审核通知</text>
@@ -109,11 +101,6 @@
</template> </template>
<view v-else class="profile-error"> <view v-else class="profile-error">
<image
class="profile-error__skin"
src="/static/assets/modules/profile/transparent/m01-profile-summary-card.png"
mode="scaleToFill"
/>
<view class="profile-error__copy"> <view class="profile-error__copy">
<text class="profile-error__title">个人资料暂不可用</text> <text class="profile-error__title">个人资料暂不可用</text>
<text class="profile-error__description" <text class="profile-error__description"
@@ -161,6 +148,18 @@ const menuItems = [
icon: "/static/assets/foundation/transparent/brand-seal.png", icon: "/static/assets/foundation/transparent/brand-seal.png",
url: "/pages/profile/m10-about-settings", url: "/pages/profile/m10-about-settings",
}, },
{
label: "邀请家人",
note: "邀请码与家谱推广",
icon: "/static/assets/foundation/transparent/brand-seal.png",
url: "/pages/profile/m08-promotion",
},
{
label: "服务与订单",
note: "权益说明与订单记录",
icon: "/static/assets/foundation/transparent/notice.png",
url: "/pages/profile/m09-vip-orders",
},
]; ];
onLoad((query) => { onLoad((query) => {
@@ -179,58 +178,58 @@ const openItem = (item) => uni.navigateTo({ url: item.url });
<style scoped lang="scss"> <style scoped lang="scss">
.profile-page { .profile-page {
position: relative; display: flex;
min-height: 100vh; min-height: 100vh;
overflow-x: hidden; flex-direction: column;
background: $paper; background: $paper;
} }
.profile-page__header, .profile-page__header,
.profile-content { .profile-content {
position: relative; z-index: 1;
z-index: 2;
} }
.profile-content { .profile-content {
flex: 1;
padding: 26rpx 30rpx 190rpx; padding: 26rpx 30rpx 190rpx;
} }
.profile-hero { .profile-hero {
position: relative;
display: grid; display: grid;
grid-template-columns: 42rpx minmax(0, 1fr);
width: 100%; width: 100%;
min-height: 252rpx; min-height: 252rpx;
box-sizing: border-box;
background: url("/static/assets/modules/profile/transparent/m01-profile-summary-card.png") center / 100% 100% no-repeat;
}
.profile-hero__content {
z-index: 1;
display: grid;
grid-area: 1 / 1;
grid-template-columns: 42rpx minmax(0, 1fr);
align-items: center; align-items: center;
gap: 30rpx; gap: 30rpx;
padding: 34rpx 52rpx 32rpx 40rpx; padding: 34rpx 52rpx 32rpx 40rpx;
box-sizing: border-box;
overflow: hidden;
}
.profile-hero__frame {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
pointer-events: none;
} }
.profile-hero__hall { .profile-hero__hall {
position: absolute; align-self: end;
right: -18rpx; justify-self: end;
bottom: -30rpx; grid-area: 1 / 1;
width: 330rpx; width: 330rpx;
height: 122rpx; height: 122rpx;
margin-right: -18rpx;
margin-bottom: -30rpx;
opacity: 0.12; opacity: 0.12;
pointer-events: none; pointer-events: none;
} }
.profile-hero__cloud { .profile-hero__cloud {
position: absolute; align-self: start;
top: 22rpx; justify-self: end;
right: 30rpx; grid-area: 1 / 1;
width: 96rpx; width: 96rpx;
height: 52rpx; height: 52rpx;
margin-top: 22rpx;
margin-right: 30rpx;
opacity: 0.32; opacity: 0.32;
pointer-events: none; pointer-events: none;
} }
.profile-hero__label { .profile-hero__label {
position: relative;
z-index: 2; z-index: 2;
display: flex; display: flex;
width: 42rpx; width: 42rpx;
@@ -249,7 +248,6 @@ const openItem = (item) => uni.navigateTo({ url: item.url });
line-height: 1.12; line-height: 1.12;
} }
.profile-identity { .profile-identity {
position: relative;
z-index: 1; z-index: 1;
display: flex; display: flex;
min-height: 252rpx; min-height: 252rpx;
@@ -286,21 +284,12 @@ const openItem = (item) => uni.navigateTo({ url: item.url });
letter-spacing: 2rpx; letter-spacing: 2rpx;
} }
.profile-scroll-notice { .profile-scroll-notice {
position: relative;
width: 88%; width: 88%;
min-height: 112rpx; min-height: 112rpx;
margin: 22rpx auto 0; margin: 22rpx auto 0;
} background: url("/static/assets/foundation/transparent/a01-scroll-primary-v3.png") center / 100% 100% no-repeat;
.profile-scroll-notice__skin {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
pointer-events: none;
} }
.profile-scroll-notice__copy { .profile-scroll-notice__copy {
position: relative;
z-index: 1;
display: flex; display: flex;
min-height: 112rpx; min-height: 112rpx;
box-sizing: border-box; box-sizing: border-box;
@@ -389,25 +378,12 @@ const openItem = (item) => uni.navigateTo({ url: item.url });
opacity: 0.75; opacity: 0.75;
} }
.profile-error { .profile-error {
position: relative;
margin-top: 38rpx; margin-top: 38rpx;
background: url("/static/assets/modules/profile/transparent/m01-profile-summary-card.png") top center / 100% 220rpx no-repeat;
text-align: center; text-align: center;
} }
.profile-error__skin {
position: absolute;
top: 0;
right: 0;
left: 0;
width: 100%;
height: 220rpx;
min-height: 118px;
pointer-events: none;
}
.profile-error__copy { .profile-error__copy {
position: relative;
z-index: 1;
display: flex; display: flex;
height: 220rpx;
min-height: 118px; min-height: 118px;
box-sizing: border-box; box-sizing: border-box;
flex-direction: column; flex-direction: column;
+76 -2
View File
@@ -1,5 +1,79 @@
<!-- 页面编号M-02用途编辑个人资料 --> <!-- 页面编号M-02用途编辑个人资料 -->
<template><ModulePage page-id="m02" /></template> <template>
<view class="profile-edit-page" :class="{ 'profile-state--ready': profileState === 'ready', 'profile-state--saving': profileState === 'saving' }">
<ModulePageBackground module="profile" />
<view class="page-layer"><PageHeader title="个人资料" /></view>
<view class="page-content page-layer">
<view class="profile-avatar-card">
<image class="avatar-seal" src="/static/assets/foundation/transparent/brand-seal.png" mode="aspectFit" />
<view class="avatar-copy"><text>{{ profileForm.nickname || "未填写昵称" }}</text><text>头像将在相册权限接入后支持上传</text></view>
<view class="text-action" role="button" aria-label="选择头像" @click="chooseAvatar">选择头像</view>
</view>
<view class="form-panel">
<view class="form-row"><text>昵称</text><input v-model.trim="profileForm.nickname" maxlength="20" aria-label="昵称" placeholder="请输入昵称" /></view>
<text v-if="errors.nickname" class="field-error">{{ errors.nickname }}</text>
<view class="form-row"><text>真实姓名</text><input v-model.trim="profileForm.realName" maxlength="20" aria-label="真实姓名" placeholder="请输入真实姓名" /></view>
<view class="form-row"><text>常住地区</text><input v-model.trim="profileForm.region" maxlength="40" aria-label="常住地区" placeholder="省 / 市 / 区县" /></view>
<view class="textarea-row"><text>个人简介</text><textarea v-model.trim="profileForm.bio" auto-height maxlength="300" aria-label="个人简介" placeholder="介绍你的家族身份或经历" /></view>
<text class="counter">{{ profileForm.bio.length }}/300</text>
<text v-if="errors.bio" class="field-error">{{ errors.bio }}</text>
</view>
<AppButton block :disabled="profileState === 'saving'" :label="profileState === 'saving' ? '正在保存' : '保存资料'" @click="saveProfile" />
</view>
<AppToast :visible="toastVisible" :message="toastMessage" />
</view>
</template>
<script setup> <script setup>
import ModulePage from "@/components/ModulePage.vue"; import { onUnmounted, reactive, ref } from "vue";
import AppButton from "@/components/AppButton.vue";
import AppToast from "@/components/AppToast.vue";
import ModulePageBackground from "@/components/ModulePageBackground.vue";
import PageHeader from "@/components/PageHeader.vue";
const profileForm = reactive({ nickname: "汤文清", realName: "汤文清", region: "河南省 周口市", bio: "热心参与家谱资料整理。" });
const errors = reactive({ nickname: "", bio: "" });
const profileState = ref("ready");
const toastVisible = ref(false);
const toastMessage = ref("");
let timer = null;
const showToast = (message) => { toastMessage.value = message; toastVisible.value = true; clearTimeout(timer); timer = setTimeout(() => (toastVisible.value = false), 1800); };
const chooseAvatar = () => showToast("头像选择将在相册权限接入后开放");
const validateProfile = () => {
errors.nickname = profileForm.nickname ? "" : "请填写昵称";
errors.bio = profileForm.bio.length > 300 ? "个人简介不能超过 300 字" : "";
return !errors.nickname && !errors.bio;
};
const saveProfile = () => {
if (!validateProfile() || profileState.value === "saving") return;
profileState.value = "saving";
clearTimeout(timer);
timer = setTimeout(() => { profileState.value = "ready"; showToast("个人资料已保存"); }, 500);
};
onUnmounted(() => clearTimeout(timer));
</script> </script>
<style scoped lang="scss">
.profile-edit-page { display: flex; min-height: 100vh; flex-direction: column; background: $paper; }
.page-layer { z-index: 1; }
.page-content { flex: 1; padding: 26rpx 30rpx 70rpx; }
.profile-avatar-card, .form-panel { background: var(--module-field-asset, url("/static/assets/modules/profile/transparent/module-field-frame.png")) center / 100% 100% no-repeat; }
.profile-avatar-card { display: grid; grid-template-columns: 92rpx minmax(0,1fr) auto; align-items: center; gap: 20rpx; min-height: 150rpx; padding: 24rpx 34rpx; box-sizing: border-box; }
.avatar-seal { width: 82rpx; height: auto; max-height: 92rpx; aspect-ratio: 82 / 92; }
.avatar-copy { min-width: 0; }
.avatar-copy text { display: block; overflow-wrap: anywhere; }
.avatar-copy text:first-child { color: $ink; font-size: 29rpx; font-weight: 700; }
.avatar-copy text:last-child { margin-top: 6rpx; color: $ink-muted; font-size: 20rpx; line-height: 1.45; }
.text-action { min-height: 72rpx; display: flex; align-items: center; color: $brand-red; font-size: 22rpx; }
.form-panel { margin-top: 22rpx; padding: 20rpx 34rpx 30rpx; box-sizing: border-box; }
.form-row { display: grid; grid-template-columns: 150rpx minmax(0,1fr); min-height: 92rpx; align-items: center; border-bottom: 1px solid rgba(181,137,63,.42); gap: 18rpx; }
.form-row > text, .textarea-row > text { color: $ink; font-size: 24rpx; font-weight: 700; }
.form-row input { width: auto; min-width: 0; min-height: 70rpx; color: $ink; font-size: 24rpx; text-align: right; }
.textarea-row { padding-top: 24rpx; }
.textarea-row textarea { display: block; width: auto; min-width: 0; min-height: 150rpx; margin-top: 14rpx; color: $ink; font-size: 24rpx; line-height: 1.6; }
.counter { display: block; color: $ink-muted; font-size: 20rpx; text-align: right; }
.field-error { display: block; padding-top: 7rpx; color: #b42318; font-size: 21rpx; text-align: right; }
.page-content > .app-button { margin-top: 26rpx; }
@media(max-width:340px){.page-content{padding-right:22rpx;padding-left:22rpx}.profile-avatar-card{grid-template-columns:72rpx minmax(0,1fr);padding-right:26rpx;padding-left:26rpx}.text-action{grid-column:2}.avatar-seal{width:68rpx;max-height:78rpx}.form-row{grid-template-columns:126rpx minmax(0,1fr)}}
</style>
+45 -3
View File
@@ -1,5 +1,47 @@
<!-- 页面编号M-03用途账号与安全设置 --> <!-- 页面编号M-03用途账号安全总览与安全功能入口 -->
<template><ModulePage page-id="m03" /></template> <template>
<view class="security-page device-state--safe">
<ModulePageBackground module="profile" />
<view class="page-layer"><PageHeader title="账号与安全" /></view>
<view class="page-content page-layer">
<view class="security-summary">
<text>账号状态安全</text>
<text>手机号已绑定最近登录设备未发现异常</text>
</view>
<view class="security-list">
<view v-for="item in securityItems" :key="item.key" class="security-row" role="button" :aria-label="item.label" @click="openSecurityItem(item)">
<image :src="item.icon" mode="aspectFit" />
<view><text>{{ item.label }}</text><text>{{ item.note }}</text></view>
<image class="chevron" src="/static/assets/foundation/transparent/chevron-right.png" mode="aspectFit" />
</view>
</view>
<AppButton block type="secondary" label="检查账号安全" @click="checkSecurity" />
</view>
<AppToast :visible="toastVisible" :message="toastMessage" />
</view>
</template>
<script setup> <script setup>
import ModulePage from "@/components/ModulePage.vue"; import { onUnmounted, ref } from "vue";
import AppButton from "@/components/AppButton.vue";
import AppToast from "@/components/AppToast.vue";
import ModulePageBackground from "@/components/ModulePageBackground.vue";
import PageHeader from "@/components/PageHeader.vue";
const securityItems = [
{ key: "password", label: "登录密码", note: "建议定期更新密码", icon: "/static/assets/modules/auth/transparent/a01-icon-lock-v1.png", url: "/pages/profile/m04-change-password" },
{ key: "phone", label: "绑定手机号", note: "139****6421", icon: "/static/assets/modules/auth/transparent/a01-icon-phone-v1.png", url: "/pages/profile/m05-change-phone" },
{ key: "device", label: "登录设备", note: "当前设备 · 今天登录", icon: "/static/assets/foundation/transparent/auth-login-outline.png" },
];
const toastVisible = ref(false);
const toastMessage = ref("");
let timer = null;
const showToast = (message) => { toastMessage.value = message; toastVisible.value = true; clearTimeout(timer); timer = setTimeout(() => (toastVisible.value = false), 1800); };
const openSecurityItem = (item) => item.url ? uni.navigateTo({ url: item.url }) : showToast("当前设备登录正常,未发现异常记录");
const checkSecurity = () => showToast("安全检查完成,当前账号状态正常");
onUnmounted(() => clearTimeout(timer));
</script> </script>
<style scoped lang="scss">
.security-page{display:flex;min-height:100vh;flex-direction:column;background:$paper}.page-layer{z-index:1}.page-content{flex:1;padding:28rpx 30rpx 72rpx}.security-summary{min-height:190rpx;padding:42rpx 48rpx;box-sizing:border-box;background:url("/static/assets/modules/profile/transparent/m01-profile-summary-card.png") center/100% 100% no-repeat;text-align:center}.security-summary text{display:block;overflow-wrap:anywhere}.security-summary text:first-child{color:$ink;font-family:STKaiti,KaiTi,serif;font-size:34rpx;font-weight:700}.security-summary text:last-child{margin-top:12rpx;color:$ink-muted;font-size:23rpx;line-height:1.55}.security-list{margin-top:22rpx}.security-row{display:grid;grid-template-columns:56rpx minmax(0,1fr) 34rpx;min-height:110rpx;align-items:center;gap:18rpx;padding:16rpx 26rpx;box-sizing:border-box;background:url("/static/assets/modules/profile/transparent/module-field-frame.png") center/100% 100% no-repeat}.security-row+ .security-row{margin-top:12rpx}.security-row>image{width:50rpx;height:50rpx}.security-row .chevron{width:30rpx;height:30rpx}.security-row view{min-width:0}.security-row text{display:block;overflow-wrap:anywhere}.security-row text:first-child{color:$ink;font-size:25rpx;font-weight:700}.security-row text:last-child{margin-top:6rpx;color:$ink-muted;font-size:21rpx}.page-content>.app-button{margin-top:28rpx}@media(max-width:340px){.page-content{padding-right:22rpx;padding-left:22rpx}}
</style>
+57 -3
View File
@@ -1,5 +1,59 @@
<!-- 页面编号M-04用途修改密码 --> <!-- 页面编号M-04用途修改登录密码 -->
<template><ModulePage page-id="m04" /></template> <template>
<view class="password-page" :class="{ 'password-state--ready': passwordState === 'ready', 'password-state--saving': passwordState === 'saving' }">
<ModulePageBackground module="profile" />
<view class="page-layer"><PageHeader title="修改密码" /></view>
<view class="page-content page-layer">
<view class="security-tip"><text>设置安全密码</text><text>建议使用 832 位字母与数字组合不要与其他应用共用</text></view>
<view class="form-panel">
<view v-for="field in passwordFields" :key="field.key" class="field-block">
<view class="form-row">
<text>{{ field.label }}</text>
<input v-model="passwordForm[field.key]" :password="!passwordVisible[field.key]" maxlength="32" :aria-label="field.label" :placeholder="field.placeholder" @input="errors[field.key] = ''" />
<view class="password-toggle" role="button" :aria-label="`${passwordVisible[field.key] ? '隐藏' : '显示'}${field.label}`" @click="togglePassword(field.key)">{{ passwordVisible[field.key] ? "隐藏" : "显示" }}</view>
</view>
<text v-if="errors[field.key]" class="field-error">{{ errors[field.key] }}</text>
</view>
</view>
<AppButton block :disabled="passwordState === 'saving'" :label="passwordState === 'saving' ? '正在修改' : '确认修改'" @click="savePassword" />
</view>
<AppToast :visible="toastVisible" :message="toastMessage" />
</view>
</template>
<script setup> <script setup>
import ModulePage from "@/components/ModulePage.vue"; import { onUnmounted, reactive, ref } from "vue";
import AppButton from "@/components/AppButton.vue";
import AppToast from "@/components/AppToast.vue";
import ModulePageBackground from "@/components/ModulePageBackground.vue";
import PageHeader from "@/components/PageHeader.vue";
const passwordForm = reactive({ current: "", next: "", confirm: "" });
const passwordVisible = reactive({ current: false, next: false, confirm: false });
const errors = reactive({ current: "", next: "", confirm: "" });
const passwordState = ref("ready");
const passwordFields = [
{ key: "current", label: "当前密码", placeholder: "请输入当前密码" },
{ key: "next", label: "新密码", placeholder: "832 位字母与数字" },
{ key: "confirm", label: "确认新密码", placeholder: "请再次输入新密码" },
];
const toastVisible = ref(false); const toastMessage = ref(""); let timer = null;
const showToast = (message) => { toastMessage.value = message; toastVisible.value = true; clearTimeout(timer); timer = setTimeout(() => (toastVisible.value = false), 1800); };
const togglePassword = (key) => { passwordVisible[key] = !passwordVisible[key]; };
const validatePassword = () => {
errors.current = passwordForm.current ? "" : "请输入当前密码";
errors.next = /^(?=.*[A-Za-z])(?=.*\d).{8,32}$/.test(passwordForm.next) ? "" : "新密码需为 8–32 位,并同时包含字母和数字";
errors.confirm = !passwordForm.confirm ? "请再次输入新密码" : passwordForm.confirm !== passwordForm.next ? "两次输入的新密码不一致" : "";
return !Object.values(errors).some(Boolean);
};
const savePassword = () => {
if (!validatePassword() || passwordState.value === "saving") return;
passwordState.value = "saving";
timer = setTimeout(() => { passwordState.value = "ready"; passwordForm.current = ""; passwordForm.next = ""; passwordForm.confirm = ""; showToast("密码已修改,请妥善保管新密码"); }, 500);
};
onUnmounted(() => clearTimeout(timer));
</script> </script>
<style scoped lang="scss">
.password-page{display:flex;min-height:100vh;flex-direction:column;background:$paper}.page-layer{z-index:1}.page-content{flex:1;padding:28rpx 30rpx 72rpx}.security-tip,.form-panel{background:url("/static/assets/modules/profile/transparent/module-content-frame.png") center/100% 100% no-repeat}.security-tip{min-height:170rpx;padding:38rpx 44rpx;box-sizing:border-box;text-align:center}.security-tip text{display:block}.security-tip text:first-child{color:$ink;font-size:32rpx;font-weight:700}.security-tip text:last-child{margin-top:10rpx;color:$ink-muted;font-size:22rpx;line-height:1.55}.form-panel{margin-top:20rpx;padding:22rpx 34rpx 30rpx;box-sizing:border-box}.field-block+.field-block{margin-top:6rpx}.form-row{display:grid;grid-template-columns:142rpx minmax(0,1fr) 74rpx;min-height:92rpx;align-items:center;gap:12rpx;border-bottom:1px solid rgba(181,137,63,.42)}.form-row>text{color:$ink;font-size:23rpx;font-weight:700}.form-row input{width:auto;min-width:0;min-height:68rpx;color:$ink;font-size:23rpx}.password-toggle{display:flex;min-height:68rpx;align-items:center;justify-content:flex-end;color:$brand-red;font-size:21rpx}.field-error{display:block;padding-top:7rpx;color:#b42318;font-size:20rpx;line-height:1.4;text-align:right}.page-content>.app-button{margin-top:28rpx}@media(max-width:340px){.page-content{padding-right:22rpx;padding-left:22rpx}.form-panel{padding-right:26rpx;padding-left:26rpx}.form-row{grid-template-columns:120rpx minmax(0,1fr) 64rpx;gap:8rpx}}
</style>
+53 -3
View File
@@ -1,5 +1,55 @@
<!-- 页面编号M-05用途修改手机号 --> <!-- 页面编号M-05用途验证并更换绑定手机号 -->
<template><ModulePage page-id="m05" /></template> <template>
<view class="phone-page" :class="{ 'phone-state--ready': phoneState === 'ready', 'phone-state--saving': phoneState === 'saving' }">
<ModulePageBackground module="profile" />
<view class="page-layer"><PageHeader title="修改手机号" /></view>
<view class="page-content page-layer">
<view class="current-phone"><text>当前绑定手机号</text><text>{{ phoneForm.currentPhone }}</text><text>更换后新手机号将用于登录与安全验证</text></view>
<view class="form-panel">
<view class="form-row"><text>新手机号</text><input v-model.trim="phoneForm.newPhone" type="number" maxlength="11" aria-label="新手机号" placeholder="请输入新手机号" @input="errors.newPhone = ''" /></view>
<text v-if="errors.newPhone" class="field-error">{{ errors.newPhone }}</text>
<view class="form-row code-row"><text>验证码</text><input v-model.trim="phoneForm.code" type="number" maxlength="6" aria-label="短信验证码" placeholder="6 位验证码" @input="errors.code = ''" /><view class="code-action" role="button" :aria-label="codeCountdown ? `${codeCountdown}秒后可重新发送` : '发送验证码'" @click="sendCode">{{ codeCountdown ? `${codeCountdown}s` : "发送验证码" }}</view></view>
<text v-if="errors.code" class="field-error">{{ errors.code }}</text>
</view>
<AppButton block :disabled="phoneState === 'saving'" :label="phoneState === 'saving' ? '正在更换' : '确认更换'" @click="savePhone" />
</view>
<AppToast :visible="toastVisible" :message="toastMessage" />
</view>
</template>
<script setup> <script setup>
import ModulePage from "@/components/ModulePage.vue"; import { onUnmounted, reactive, ref } from "vue";
import AppButton from "@/components/AppButton.vue";
import AppToast from "@/components/AppToast.vue";
import ModulePageBackground from "@/components/ModulePageBackground.vue";
import PageHeader from "@/components/PageHeader.vue";
const phoneForm = reactive({ currentPhone: "139****6421", newPhone: "", code: "" });
const errors = reactive({ newPhone: "", code: "" });
const phoneState = ref("ready");
const codeCountdown = ref(0);
const toastVisible = ref(false); const toastMessage = ref("");
let countdownTimer = null; let stateTimer = null; let toastTimer = null;
const showToast = (message) => { toastMessage.value = message; toastVisible.value = true; clearTimeout(toastTimer); toastTimer = setTimeout(() => (toastVisible.value = false), 1800); };
const sendCode = () => {
if (codeCountdown.value) return;
if (!/^1\d{10}$/.test(phoneForm.newPhone)) { errors.newPhone = "请输入正确的新手机号"; return; }
errors.newPhone = ""; codeCountdown.value = 60; showToast("演示验证码已发送,请输入任意 6 位数字");
countdownTimer = setInterval(() => { codeCountdown.value -= 1; if (!codeCountdown.value) { clearInterval(countdownTimer); countdownTimer = null; } }, 1000);
};
const validatePhone = () => {
errors.newPhone = /^1\d{10}$/.test(phoneForm.newPhone) ? "" : "请输入正确的新手机号";
errors.code = /^\d{6}$/.test(phoneForm.code) ? "" : "请输入 6 位验证码";
return !errors.newPhone && !errors.code;
};
const savePhone = () => {
if (!validatePhone() || phoneState.value === "saving") return;
phoneState.value = "saving";
stateTimer = setTimeout(() => { phoneState.value = "ready"; phoneForm.currentPhone = `${phoneForm.newPhone.slice(0,3)}****${phoneForm.newPhone.slice(-4)}`; phoneForm.newPhone = ""; phoneForm.code = ""; showToast("绑定手机号已更新"); }, 500);
};
onUnmounted(() => { clearInterval(countdownTimer); clearTimeout(stateTimer); clearTimeout(toastTimer); });
</script> </script>
<style scoped lang="scss">
.phone-page{display:flex;min-height:100vh;flex-direction:column;background:$paper}.page-layer{z-index:1}.page-content{flex:1;padding:28rpx 30rpx 72rpx}.current-phone,.form-panel{background:url("/static/assets/modules/profile/transparent/module-content-frame.png") center/100% 100% no-repeat}.current-phone{min-height:210rpx;padding:40rpx 48rpx;box-sizing:border-box;text-align:center}.current-phone text{display:block}.current-phone text:first-child{color:$ink-muted;font-size:22rpx}.current-phone text:nth-child(2){margin-top:8rpx;color:$ink;font-size:38rpx;font-weight:700;letter-spacing:3rpx}.current-phone text:last-child{margin-top:12rpx;color:$ink-muted;font-size:21rpx;line-height:1.5}.form-panel{margin-top:22rpx;padding:24rpx 34rpx 32rpx;box-sizing:border-box}.form-row{display:grid;grid-template-columns:130rpx minmax(0,1fr);min-height:94rpx;align-items:center;gap:16rpx;border-bottom:1px solid rgba(181,137,63,.42)}.code-row{grid-template-columns:130rpx minmax(0,1fr) auto}.form-row>text{color:$ink;font-size:23rpx;font-weight:700}.form-row input{width:auto;min-width:0;min-height:70rpx;color:$ink;font-size:23rpx}.code-action{display:flex;min-width:126rpx;min-height:70rpx;align-items:center;justify-content:flex-end;color:$brand-red;font-size:21rpx}.field-error{display:block;padding-top:7rpx;color:#b42318;font-size:20rpx;text-align:right}.page-content>.app-button{margin-top:28rpx}@media(max-width:340px){.page-content{padding-right:22rpx;padding-left:22rpx}.form-panel{padding-right:26rpx;padding-left:26rpx}.form-row{grid-template-columns:112rpx minmax(0,1fr)}.code-row{grid-template-columns:112rpx minmax(0,1fr);}.code-action{grid-column:2;justify-content:flex-start}}
</style>
+46 -3
View File
@@ -1,5 +1,48 @@
<!-- 页面编号M-06用途帮助中心 --> <!-- 页面编号M-06用途帮助搜索分类与常见问题 -->
<template><ModulePage page-id="m06" /></template> <template>
<view class="help-page">
<ModulePageBackground module="profile" />
<view class="page-layer"><PageHeader title="帮助中心" /></view>
<view class="page-content page-layer">
<view class="search-box"><input v-model.trim="keyword" aria-label="搜索帮助" placeholder="搜索问题关键词" /><text>{{ filteredQuestions.length }} </text></view>
<scroll-view scroll-x class="category-scroll" :show-scrollbar="false"><view class="category-row"><view v-for="category in helpCategories" :key="category" class="category-chip" :class="{ active: activeCategory === category }" role="button" @click="activeCategory = category">{{ category }}</view></view></scroll-view>
<view v-if="filteredQuestions.length" class="question-list">
<view v-for="item in filteredQuestions" :key="item.id" class="question-card">
<view class="question-heading" role="button" :aria-label="item.question" @click="toggleQuestion(item.id)"><text>{{ item.question }}</text><text>{{ expandedIds.includes(item.id) ? "收起" : "查看" }}</text></view>
<text v-if="expandedIds.includes(item.id)" class="answer">{{ item.answer }}</text>
</view>
</view>
<view v-else class="empty-card"><text>没有找到相关问题</text><text>换个关键词试试或直接提交意见反馈</text></view>
<AppButton block type="secondary" label="联系家谱助手" @click="contactSupport" />
</view>
</view>
</template>
<script setup> <script setup>
import ModulePage from "@/components/ModulePage.vue"; import { computed, ref } from "vue";
import AppButton from "@/components/AppButton.vue";
import ModulePageBackground from "@/components/ModulePageBackground.vue";
import PageHeader from "@/components/PageHeader.vue";
const helpCategories = ["全部", "家谱", "成员", "隐私", "账号"];
const activeCategory = ref("全部");
const keyword = ref("");
const expandedIds = ref([]);
const questions = [
{ id: 1, category: "家谱", question: "如何创建一部新家谱?", answer: "进入“我的家谱”,选择新建家谱,按步骤填写姓氏、堂号和地区等基础资料。" },
{ id: 2, category: "成员", question: "如何邀请家人共同完善家谱?", answer: "家人可搜索家谱后提交加入申请,管理员核实亲属关系后完成审核。" },
{ id: 3, category: "隐私", question: "哪些个人资料会展示给其他成员?", answer: "资料按家谱角色与权限展示;敏感联系方式默认脱敏,后续可在家谱设置中管理权限。" },
{ id: 4, category: "账号", question: "忘记密码后怎样恢复账号?", answer: "在登录页选择“忘记密码”,通过绑定手机号验证后设置新密码。" },
{ id: 5, category: "家谱", question: "家谱资料填写错了怎么办?", answer: "有编辑权限的成员可以进入对应资料页修改;关键世系关系建议核对后再保存。" },
];
const filteredQuestions = computed(() => {
const query = keyword.value.toLowerCase();
return questions.filter((item) => (activeCategory.value === "全部" || item.category === activeCategory.value) && (!query || `${item.question}${item.answer}`.toLowerCase().includes(query)));
});
const toggleQuestion = (id) => { expandedIds.value = expandedIds.value.includes(id) ? expandedIds.value.filter((item) => item !== id) : [...expandedIds.value, id]; };
const contactSupport = () => uni.navigateTo({ url: "/pages/profile/m07-feedback" });
</script> </script>
<style scoped lang="scss">
.help-page{display:flex;min-height:100vh;flex-direction:column;background:$paper}.page-layer{z-index:1}.page-content{flex:1;padding:24rpx 28rpx 72rpx}.search-box{display:grid;grid-template-columns:minmax(0,1fr) auto;min-height:86rpx;align-items:center;gap:16rpx;padding:0 30rpx;background:url("/static/assets/modules/profile/transparent/module-field-frame.png") center/100% 100% no-repeat}.search-box input{width:auto;min-width:0;min-height:68rpx;color:$ink;font-size:23rpx}.search-box text{color:$ink-muted;font-size:20rpx}.category-scroll{width:100%;margin-top:18rpx}.category-row{display:flex;width:max-content;gap:12rpx;padding:2rpx}.category-chip{display:flex;min-width:100rpx;min-height:64rpx;align-items:center;justify-content:center;padding:0 22rpx;box-sizing:border-box;color:$ink-muted;font-size:22rpx}.category-chip.active{background:url("/static/assets/foundation/transparent/a01-scroll-secondary-v3.png") center/100% 100% no-repeat;color:$brand-red;font-weight:700}.question-list{display:flex;flex-direction:column;gap:14rpx;margin-top:18rpx}.question-card,.empty-card{background:url("/static/assets/modules/profile/transparent/module-content-frame.png") center/100% 100% no-repeat}.question-card{min-height:104rpx;padding:24rpx 34rpx;box-sizing:border-box}.question-heading{display:grid;grid-template-columns:minmax(0,1fr) auto;min-height:58rpx;align-items:center;gap:18rpx}.question-heading text:first-child{color:$ink;font-size:24rpx;font-weight:700;overflow-wrap:anywhere}.question-heading text:last-child{color:$brand-red;font-size:20rpx}.answer{display:block;padding:14rpx 4rpx 6rpx;border-top:1px solid rgba(181,137,63,.32);color:$ink-muted;font-size:22rpx;line-height:1.65;overflow-wrap:anywhere}.empty-card{min-height:220rpx;padding:58rpx 44rpx;box-sizing:border-box;text-align:center}.empty-card text{display:block}.empty-card text:first-child{color:$ink;font-size:29rpx;font-weight:700}.empty-card text:last-child{margin-top:12rpx;color:$ink-muted;font-size:22rpx;line-height:1.5}.page-content>.app-button{margin-top:28rpx}@media(max-width:340px){.page-content{padding-right:20rpx;padding-left:20rpx}}
</style>
+50 -3
View File
@@ -1,5 +1,52 @@
<!-- 页面编号M-07用途意见反馈 --> <!-- 页面编号M-07用途提交意见反馈 -->
<template><ModulePage page-id="m07" /></template> <template>
<view class="feedback-page" :class="{ 'feedback-state--ready': feedbackState === 'ready', 'feedback-state--submitting': feedbackState === 'submitting' }">
<ModulePageBackground module="profile" />
<view class="page-layer"><PageHeader title="意见反馈" /></view>
<view class="page-content page-layer">
<text class="lead">你的建议会帮助我们把家谱做得更好</text>
<view class="type-grid">
<view v-for="type in feedbackTypes" :key="type" class="type-chip" :class="{ active: feedbackForm.type === type }" role="button" @click="feedbackForm.type = type; errors.type = ''">{{ type }}</view>
</view>
<text v-if="errors.type" class="field-error">{{ errors.type }}</text>
<view class="form-panel">
<textarea v-model.trim="feedbackForm.description" auto-height maxlength="500" aria-label="问题描述" placeholder="请说明遇到的问题、操作步骤或建议" @input="errors.description = ''" />
<text class="counter">{{ feedbackForm.description.length }}/500</text>
<text v-if="errors.description" class="field-error">{{ errors.description }}</text>
<view class="contact-row"><text>联系方式</text><input v-model.trim="feedbackForm.contact" maxlength="50" aria-label="联系方式" placeholder="手机号或邮箱(选填)" /></view>
</view>
<text class="privacy-note">仅用于跟进本次反馈不会在家谱中公开</text>
<AppButton block :disabled="feedbackState === 'submitting'" :label="feedbackState === 'submitting' ? '正在提交' : '提交反馈'" @click="submitFeedback" />
</view>
<AppToast :visible="toastVisible" :message="toastMessage" />
</view>
</template>
<script setup> <script setup>
import ModulePage from "@/components/ModulePage.vue"; import { onUnmounted, reactive, ref } from "vue";
import AppButton from "@/components/AppButton.vue";
import AppToast from "@/components/AppToast.vue";
import ModulePageBackground from "@/components/ModulePageBackground.vue";
import PageHeader from "@/components/PageHeader.vue";
const feedbackTypes = ["功能问题", "使用建议", "内容纠错", "其他"];
const feedbackForm = reactive({ type: "", description: "", contact: "" });
const errors = reactive({ type: "", description: "" });
const feedbackState = ref("ready");
const toastVisible = ref(false); const toastMessage = ref(""); let timer = null;
const validateFeedback = () => {
errors.type = feedbackForm.type ? "" : "请选择反馈类型";
errors.description = feedbackForm.description.length < 5 ? "请至少填写 5 个字的问题描述" : "";
return !errors.type && !errors.description;
};
const submitFeedback = () => {
if (!validateFeedback() || feedbackState.value === "submitting") return;
feedbackState.value = "submitting";
timer = setTimeout(() => { feedbackState.value = "ready"; feedbackForm.type = ""; feedbackForm.description = ""; feedbackForm.contact = ""; toastMessage.value = "反馈已保存,服务接入后将提交给家谱助手"; toastVisible.value = true; timer = setTimeout(() => (toastVisible.value = false), 2200); }, 500);
};
onUnmounted(() => clearTimeout(timer));
</script> </script>
<style scoped lang="scss">
.feedback-page{display:flex;min-height:100vh;flex-direction:column;background:$paper}.page-layer{z-index:1}.page-content{flex:1;padding:26rpx 30rpx 72rpx}.lead{display:block;color:$ink-muted;font-size:23rpx;line-height:1.5;text-align:center}.type-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:14rpx;margin-top:20rpx}.type-chip{display:flex;min-height:76rpx;align-items:center;justify-content:center;padding:10rpx 16rpx;box-sizing:border-box;background:url("/static/assets/modules/profile/transparent/module-field-frame.png") center/100% 100% no-repeat;color:$ink-muted;font-size:22rpx;text-align:center}.type-chip.active{color:$brand-red;font-weight:700;filter:saturate(1.2)}.form-panel{margin-top:20rpx;padding:30rpx 34rpx;background:url("/static/assets/modules/profile/transparent/module-content-frame.png") center/100% 100% no-repeat}.form-panel textarea{display:block;width:auto;min-width:0;min-height:210rpx;color:$ink;font-size:24rpx;line-height:1.65}.counter{display:block;color:$ink-muted;font-size:20rpx;text-align:right}.contact-row{display:grid;grid-template-columns:130rpx minmax(0,1fr);min-height:88rpx;align-items:center;gap:16rpx;margin-top:16rpx;border-top:1px solid rgba(181,137,63,.38)}.contact-row text{color:$ink;font-size:23rpx;font-weight:700}.contact-row input{width:auto;min-width:0;min-height:68rpx;color:$ink;font-size:22rpx;text-align:right}.field-error{display:block;margin-top:7rpx;color:#b42318;font-size:20rpx;line-height:1.4;text-align:right}.privacy-note{display:block;margin-top:15rpx;color:$ink-muted;font-size:20rpx;line-height:1.5;text-align:center}.page-content>.app-button{margin-top:24rpx}@media(max-width:340px){.page-content{padding-right:22rpx;padding-left:22rpx}.form-panel{padding-right:26rpx;padding-left:26rpx}}
</style>
+44 -3
View File
@@ -1,5 +1,46 @@
<!-- 页面编号M-08用途应用推广 --> <!-- 页面编号M-08用途邀请家人共建家谱 -->
<template><ModulePage page-id="m08" /></template> <template>
<view class="promotion-page share-state--ready">
<ModulePageBackground module="profile" />
<view class="page-layer"><PageHeader title="邀请家人" /></view>
<view class="page-content page-layer">
<view class="invite-hero">
<image src="/static/assets/foundation/transparent/brand-seal.png" mode="aspectFit" />
<text>邀请亲友共建家族记忆</text>
<text>家人搜索家谱或使用邀请码后仍需管理员审核才能加入</text>
</view>
<view class="invite-code-card">
<text>汝南汤氏家谱邀请码</text>
<text class="invite-code">{{ inviteCode }}</text>
<view class="copy-action" role="button" aria-label="复制邀请码" @click="copyInviteCode">复制邀请码</view>
</view>
<view class="steps-card"><text>邀请步骤</text><text>1. 生成邀请信息</text><text>2. 发送给家人</text><text>3. 家人提交加入申请</text><text>4. 管理员核实并审核</text></view>
<AppButton block label="生成邀请海报" @click="generatePoster" />
</view>
<AppDialog :visible="posterVisible" eyebrow="家谱邀请" title="汝南汤氏家谱" :message="`邀请码 ${inviteCode}。长按或复制邀请码发送给家人。`" confirm-text="完成" @confirm="posterVisible = false" @close="posterVisible = false">
<image class="poster-seal" src="/static/assets/foundation/transparent/brand-seal.png" mode="aspectFit" />
</AppDialog>
<AppToast :visible="toastVisible" :message="toastMessage" />
</view>
</template>
<script setup> <script setup>
import ModulePage from "@/components/ModulePage.vue"; import { onUnmounted, ref } from "vue";
import AppButton from "@/components/AppButton.vue";
import AppDialog from "@/components/AppDialog.vue";
import AppToast from "@/components/AppToast.vue";
import ModulePageBackground from "@/components/ModulePageBackground.vue";
import PageHeader from "@/components/PageHeader.vue";
const inviteCode = ref("TN20260720");
const posterVisible = ref(false);
const toastVisible = ref(false); const toastMessage = ref(""); let timer = null;
const showToast = (message) => { toastMessage.value = message; toastVisible.value = true; clearTimeout(timer); timer = setTimeout(() => (toastVisible.value = false), 1800); };
const copyInviteCode = () => uni.setClipboardData({ data: inviteCode.value, showToast: false, success: () => showToast("邀请码已复制"), fail: () => showToast("复制失败,请长按邀请码复制") });
const generatePoster = () => { posterVisible.value = true; };
onUnmounted(() => clearTimeout(timer));
</script> </script>
<style scoped lang="scss">
.promotion-page{display:flex;min-height:100vh;flex-direction:column;background:$paper}.page-layer{z-index:1}.page-content{flex:1;padding:28rpx 30rpx 72rpx}.invite-hero,.invite-code-card,.steps-card{background:url("/static/assets/modules/profile/transparent/module-content-frame.png") center/100% 100% no-repeat}.invite-hero{display:flex;min-height:300rpx;flex-direction:column;align-items:center;justify-content:center;padding:38rpx 48rpx;box-sizing:border-box;text-align:center}.invite-hero image{width:90rpx;height:auto;max-height:102rpx;aspect-ratio:90/102}.invite-hero text{display:block}.invite-hero text:nth-child(2){margin-top:12rpx;color:$ink;font-family:STKaiti,KaiTi,serif;font-size:34rpx;font-weight:700}.invite-hero text:last-child{margin-top:12rpx;color:$ink-muted;font-size:22rpx;line-height:1.55}.invite-code-card{min-height:220rpx;margin-top:20rpx;padding:38rpx 44rpx;box-sizing:border-box;text-align:center}.invite-code-card>text:first-child{display:block;color:$ink-muted;font-size:21rpx}.invite-code{display:block;margin-top:10rpx;color:$brand-red;font-size:42rpx;font-weight:700;letter-spacing:5rpx;overflow-wrap:anywhere}.copy-action{display:flex;min-height:62rpx;align-items:center;justify-content:center;color:$brand-red;font-size:22rpx}.steps-card{display:flex;min-height:260rpx;flex-direction:column;gap:10rpx;margin-top:20rpx;padding:34rpx 44rpx;box-sizing:border-box;color:$ink-muted;font-size:22rpx}.steps-card text:first-child{margin-bottom:4rpx;color:$ink;font-size:27rpx;font-weight:700}.page-content>.app-button{margin-top:26rpx}.poster-seal{width:96rpx;height:auto;max-height:110rpx;aspect-ratio:96/110;margin:18rpx auto 24rpx}@media(max-width:340px){.page-content{padding-right:22rpx;padding-left:22rpx}.invite-code{font-size:35rpx;letter-spacing:3rpx}}
</style>
+37 -3
View File
@@ -1,5 +1,39 @@
<!-- 页面编号M-09用途VIP 服务与订单 --> <!-- 页面编号M-09用途服务权益与订单记录 -->
<template><ModulePage page-id="m09" /></template> <template>
<view class="orders-page" :class="orders.length ? 'order-state--ready' : 'order-state--empty'">
<ModulePageBackground module="profile" />
<view class="page-layer"><PageHeader title="VIP 与订单" /></view>
<view class="page-content page-layer">
<view class="service-card"><text>家谱基础服务</text><text>当前未开通付费服务</text><text>基础家谱浏览成员资料与家族记录可正常使用</text></view>
<view class="benefit-grid"><view v-for="benefit in serviceBenefits" :key="benefit.title" class="benefit-card"><text>{{ benefit.title }}</text><text>{{ benefit.copy }}</text></view></view>
<view class="section-heading"><text>订单记录</text><text>{{ orders.length }} </text></view>
<view v-if="orders.length" class="order-list"><view v-for="order in orders" :key="order.id" class="order-card"><view><text>{{ order.name }}</text><text>{{ order.createdAt }}</text></view><view><text>{{ order.amount }}</text><text>{{ order.status }}</text></view></view></view>
<view v-else class="empty-card"><text>暂无订单记录</text><text>服务开放并完成购买后订单状态会在这里展示</text></view>
<AppButton block type="secondary" label="查看服务说明" @click="openServiceNotice" />
</view>
<AppDialog :visible="serviceNoticeVisible" eyebrow="服务说明" title="付费服务尚未开放" message="当前版本不会产生扣费或订单。未来开放前会明确展示价格、权益、续费与退款规则。" confirm-text="我知道了" @confirm="serviceNoticeVisible = false" @close="serviceNoticeVisible = false" />
</view>
</template>
<script setup> <script setup>
import ModulePage from "@/components/ModulePage.vue"; import { ref } from "vue";
import { onLoad } from "@dcloudio/uni-app";
import AppButton from "@/components/AppButton.vue";
import AppDialog from "@/components/AppDialog.vue";
import ModulePageBackground from "@/components/ModulePageBackground.vue";
import PageHeader from "@/components/PageHeader.vue";
const serviceBenefits = [
{ title: "更大存储", copy: "为家族影像和资料提供更多空间" },
{ title: "资料导出", copy: "按规则整理并导出家谱资料" },
{ title: "专属服务", copy: "获得家谱整理与使用支持" },
];
const orders = ref([]);
const serviceNoticeVisible = ref(false);
onLoad((query) => { if (query.state === "ready") orders.value = [{ id: "O20260720001", name: "家谱服务演示订单", createdAt: "2026-07-20 08:30", amount: "¥0.00", status: "演示记录" }]; });
const openServiceNotice = () => { serviceNoticeVisible.value = true; };
</script> </script>
<style scoped lang="scss">
.orders-page{display:flex;min-height:100vh;flex-direction:column;background:$paper}.page-layer{z-index:1}.page-content{flex:1;padding:28rpx 30rpx 72rpx}.service-card,.benefit-card,.order-card,.empty-card{background:url("/static/assets/modules/profile/transparent/module-content-frame.png") center/100% 100% no-repeat}.service-card{min-height:220rpx;padding:42rpx 48rpx;box-sizing:border-box;text-align:center}.service-card text{display:block}.service-card text:first-child{color:$ink;font-family:STKaiti,KaiTi,serif;font-size:34rpx;font-weight:700}.service-card text:nth-child(2){margin-top:10rpx;color:$brand-red;font-size:23rpx;font-weight:700}.service-card text:last-child{margin-top:12rpx;color:$ink-muted;font-size:21rpx;line-height:1.55}.benefit-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:12rpx;margin-top:18rpx}.benefit-card{min-height:150rpx;padding:28rpx 18rpx;box-sizing:border-box;text-align:center}.benefit-card text{display:block;overflow-wrap:anywhere}.benefit-card text:first-child{color:$ink;font-size:23rpx;font-weight:700}.benefit-card text:last-child{margin-top:8rpx;color:$ink-muted;font-size:19rpx;line-height:1.45}.section-heading{display:flex;flex-wrap:wrap;justify-content:space-between;gap:8rpx 20rpx;margin:28rpx 6rpx 14rpx}.section-heading text:first-child{color:$ink;font-size:27rpx;font-weight:700}.section-heading text:last-child{color:$ink-muted;font-size:21rpx}.order-list{display:flex;flex-direction:column;gap:14rpx}.order-card{display:grid;grid-template-columns:minmax(0,1fr) auto;min-height:130rpx;align-items:center;gap:20rpx;padding:26rpx 34rpx;box-sizing:border-box}.order-card text{display:block;overflow-wrap:anywhere}.order-card view:first-child text:first-child{color:$ink;font-size:23rpx;font-weight:700}.order-card text:last-child{margin-top:6rpx;color:$ink-muted;font-size:20rpx}.order-card view:last-child{text-align:right}.order-card view:last-child text:first-child{color:$brand-red;font-size:23rpx;font-weight:700}.empty-card{min-height:190rpx;padding:48rpx 42rpx;box-sizing:border-box;text-align:center}.empty-card text{display:block}.empty-card text:first-child{color:$ink;font-size:28rpx;font-weight:700}.empty-card text:last-child{margin-top:12rpx;color:$ink-muted;font-size:21rpx;line-height:1.55}.page-content>.app-button{margin-top:26rpx}@media(max-width:340px){.page-content{padding-right:22rpx;padding-left:22rpx}.benefit-grid{grid-template-columns:1fr}.benefit-card{min-height:100rpx}.order-card{grid-template-columns:1fr}.order-card view:last-child{text-align:left}}
</style>
+40 -3
View File
@@ -1,5 +1,42 @@
<!-- 页面编号M-10用途关于协议隐私与退出确认 --> <!-- 页面编号M-10用途协议隐私版本与退出登录 -->
<template><ModulePage page-id="m10" /></template> <template>
<view class="about-page">
<ModulePageBackground module="profile" />
<view class="page-layer"><PageHeader title="关于家谱" /></view>
<view class="page-content page-layer">
<view class="brand-card"><image src="/static/assets/foundation/transparent/brand-seal.png" mode="aspectFit" /><text>家谱</text><text>传承每一段值得珍藏的家族记忆</text><text>版本 {{ appVersion }}</text></view>
<view class="settings-list">
<view v-for="item in agreementItems" :key="item.key" class="settings-row" role="button" :aria-label="item.label" @click="openAgreement(item)"><view><text>{{ item.label }}</text><text>{{ item.note }}</text></view><image src="/static/assets/foundation/transparent/chevron-right.png" mode="aspectFit" /></view>
</view>
<AppButton block type="secondary" :label="loggedOut ? '已退出登录' : '退出登录'" :disabled="loggedOut" @click="logoutVisible = true" />
</view>
<AppDialog :visible="agreementVisible" eyebrow="协议与说明" :title="activeAgreement.label" :message="activeAgreement.copy" confirm-text="关闭" @confirm="agreementVisible = false" @close="agreementVisible = false" />
<AppDialog :visible="logoutVisible" eyebrow="账号操作" title="确认退出登录?" message="退出后需要重新验证账号;本机保存的密码不会被保留。" confirm-text="确认退出" cancel-text="取消" show-cancel :close-on-mask="false" @confirm="confirmLogout" @cancel="logoutVisible = false" @close="logoutVisible = false" />
<AppToast :visible="toastVisible" message="已退出当前账号" />
</view>
</template>
<script setup> <script setup>
import ModulePage from "@/components/ModulePage.vue"; import { onUnmounted, reactive, ref } from "vue";
import AppButton from "@/components/AppButton.vue";
import AppDialog from "@/components/AppDialog.vue";
import AppToast from "@/components/AppToast.vue";
import ModulePageBackground from "@/components/ModulePageBackground.vue";
import PageHeader from "@/components/PageHeader.vue";
const appVersion = "1.0.0";
const agreementItems = [
{ key: "terms", label: "用户协议", note: "了解账号与服务使用规则", copy: "用户协议正文将在正式协议服务接入后展示。当前页面不代表最终法律文本。" },
{ key: "privacy", label: "隐私政策", note: "了解个人资料如何使用与保护", copy: "隐私政策正文将在正式协议服务接入后展示。敏感资料默认按权限与脱敏规则展示。" },
{ key: "version", label: "版本说明", note: `当前版本 ${appVersion}`, copy: `当前安装版本为 ${appVersion}。基础版本采用浅色国风主题。` },
];
const activeAgreement = reactive({ label: "", copy: "" });
const agreementVisible = ref(false); const logoutVisible = ref(false); const toastVisible = ref(false); const loggedOut = ref(false); let timer = null;
const openAgreement = (item) => { activeAgreement.label = item.label; activeAgreement.copy = item.copy; agreementVisible.value = true; };
const confirmLogout = () => { logoutVisible.value = false; loggedOut.value = true; toastVisible.value = true; timer = setTimeout(() => (toastVisible.value = false), 1800); };
onUnmounted(() => clearTimeout(timer));
</script> </script>
<style scoped lang="scss">
.about-page{display:flex;min-height:100vh;flex-direction:column;background:$paper}.page-layer{z-index:1}.page-content{flex:1;padding:28rpx 30rpx 72rpx}.brand-card{display:flex;min-height:300rpx;flex-direction:column;align-items:center;justify-content:center;padding:36rpx 44rpx;box-sizing:border-box;background:url("/static/assets/modules/profile/transparent/m01-profile-summary-card.png") center/100% 100% no-repeat;text-align:center}.brand-card image{width:92rpx;height:106rpx}.brand-card text{display:block}.brand-card text:nth-child(2){margin-top:6rpx;color:$ink;font-family:STKaiti,KaiTi,serif;font-size:40rpx;font-weight:700;letter-spacing:4rpx}.brand-card text:nth-child(3){margin-top:8rpx;color:$ink-muted;font-size:22rpx;line-height:1.5}.brand-card text:last-child{margin-top:10rpx;color:$ink-muted;font-size:20rpx}.settings-list{display:flex;flex-direction:column;gap:12rpx;margin-top:22rpx}.settings-row{display:grid;grid-template-columns:minmax(0,1fr) 32rpx;min-height:104rpx;align-items:center;gap:18rpx;padding:18rpx 30rpx;box-sizing:border-box;background:url("/static/assets/modules/profile/transparent/module-field-frame.png") center/100% 100% no-repeat}.settings-row view{min-width:0}.settings-row text{display:block;overflow-wrap:anywhere}.settings-row text:first-child{color:$ink;font-size:24rpx;font-weight:700}.settings-row text:last-child{margin-top:6rpx;color:$ink-muted;font-size:20rpx}.settings-row image{width:30rpx;height:30rpx}.page-content>.app-button{margin-top:28rpx}@media(max-width:340px){.page-content{padding-right:22rpx;padding-left:22rpx}}
</style>
+11 -67
View File
@@ -14,10 +14,6 @@
<view class="people-content"> <view class="people-content">
<template v-if="peopleState === 'ready'"> <template v-if="peopleState === 'ready'">
<view class="people-search"> <view class="people-search">
<image
src="/static/assets/modules/records/transparent/r01-search-input-frame.png"
mode="scaleToFill"
/>
<input <input
v-model="keywordInput" v-model="keywordInput"
confirm-type="search" confirm-type="search"
@@ -41,11 +37,6 @@
class="person-card" class="person-card"
@click="openPerson(person)" @click="openPerson(person)"
> >
<image
class="person-card__skin"
src="/static/assets/modules/records/transparent/r01-person-name-card.png"
mode="scaleToFill"
/>
<view class="person-card__copy"> <view class="person-card__copy">
<text class="person-card__name">{{ person.name }}</text> <text class="person-card__name">{{ person.name }}</text>
<text class="person-card__meta" <text class="person-card__meta"
@@ -63,11 +54,7 @@
</view> </view>
<view v-else class="people-result-empty"> <view v-else class="people-result-empty">
<image <view class="people-state-card__copy"
src="/static/assets/modules/records/transparent/r01-person-name-card.png"
mode="scaleToFill"
/>
<view
><text>没有找到相关人物</text ><text>没有找到相关人物</text
><text>请更换姓名身份或世代关键词后再试</text></view ><text>请更换姓名身份或世代关键词后再试</text></view
> >
@@ -81,11 +68,7 @@
</template> </template>
<view v-else class="people-state-card"> <view v-else class="people-state-card">
<image <view class="people-state-card__copy">
src="/static/assets/modules/records/transparent/r01-person-name-card.png"
mode="scaleToFill"
/>
<view>
<text>{{ <text>{{
peopleState === "empty" ? "还没有人物记录" : "人物录暂不可用" peopleState === "empty" ? "还没有人物记录" : "人物录暂不可用"
}}</text> }}</text>
@@ -171,12 +154,7 @@ const openPerson = (person) =>
url: `/pages/records/r02-person-detail?personId=${person.id}`, url: `/pages/records/r02-person-detail?personId=${person.id}`,
}); });
const showCreateNotice = () => { const showCreateNotice = () => {
toastVisible.value = true; uni.navigateTo({ url: "/pages/records/r02-person-detail?mode=create" });
if (toastTimer) clearTimeout(toastTimer);
toastTimer = setTimeout(() => {
toastVisible.value = false;
toastTimer = null;
}, 1800);
}; };
onUnmounted(() => { onUnmounted(() => {
@@ -186,37 +164,28 @@ onUnmounted(() => {
<style scoped lang="scss"> <style scoped lang="scss">
.people-page { .people-page {
position: relative; display: flex;
min-height: 100vh; min-height: 100vh;
overflow-x: hidden; flex-direction: column;
background: $paper; background: $paper;
} }
.people-page__header, .people-page__header,
.people-content { .people-content {
position: relative; z-index: 1;
z-index: 2;
} }
.people-content { .people-content {
flex: 1;
padding: 22rpx 24rpx 100rpx; padding: 22rpx 24rpx 100rpx;
} }
.people-search { .people-search {
position: relative;
display: grid; display: grid;
grid-template-columns: minmax(0, 1fr) 126rpx; grid-template-columns: minmax(0, 1fr) 126rpx;
width: 100%; width: 100%;
height: 82rpx;
min-height: 44px; min-height: 44px;
align-items: center; align-items: center;
} background: url("/static/assets/modules/records/transparent/r01-search-input-frame.png") center / 100% 100% no-repeat;
.people-search > image {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
pointer-events: none;
} }
.people-search input { .people-search input {
position: relative;
z-index: 1; z-index: 1;
width: 100%; width: 100%;
min-width: 0; min-width: 0;
@@ -230,7 +199,6 @@ onUnmounted(() => {
color: #8a7965; color: #8a7965;
} }
.people-search__action { .people-search__action {
position: relative;
z-index: 2; z-index: 2;
display: flex; display: flex;
width: 126rpx; width: 126rpx;
@@ -246,7 +214,6 @@ onUnmounted(() => {
margin-top: 12px; margin-top: 12px;
} }
.person-card { .person-card {
position: relative;
display: flex; display: flex;
width: 100%; width: 100%;
height: clamp(92px, 190rpx, 108px); height: clamp(92px, 190rpx, 108px);
@@ -254,20 +221,12 @@ onUnmounted(() => {
margin-top: 12px; margin-top: 12px;
padding: 16% 10%; padding: 16% 10%;
box-sizing: border-box; box-sizing: border-box;
background: url("/static/assets/modules/records/transparent/r01-person-name-card.png") center / 100% 100% no-repeat;
} }
.person-card:first-child { .person-card:first-child {
margin-top: 0; margin-top: 0;
} }
.person-card__skin {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
pointer-events: none;
}
.person-card__copy { .person-card__copy {
position: relative;
z-index: 1;
display: flex; display: flex;
width: 100%; width: 100%;
flex-direction: column; flex-direction: column;
@@ -294,27 +253,12 @@ onUnmounted(() => {
} }
.people-result-empty, .people-result-empty,
.people-state-card { .people-state-card {
position: relative;
margin-top: 38rpx; margin-top: 38rpx;
background: url("/static/assets/modules/records/transparent/r01-person-name-card.png") top center / 100% 220rpx no-repeat;
text-align: center; text-align: center;
} }
.people-result-empty > image, .people-state-card__copy {
.people-state-card > image {
position: absolute;
top: 0;
right: 0;
left: 0;
width: 100%;
height: 220rpx;
min-height: 118px;
pointer-events: none;
}
.people-result-empty > view,
.people-state-card > view {
position: relative;
z-index: 1;
display: flex; display: flex;
height: 220rpx;
min-height: 118px; min-height: 118px;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
+42 -22
View File
@@ -2,7 +2,7 @@
<template> <template>
<view class="person-detail-page" :class="`person-detail-state--${personState}`"> <view class="person-detail-page" :class="`person-detail-state--${personState}`">
<ModulePageBackground module="records" /> <ModulePageBackground module="records" />
<view class="person-detail-header"><PageHeader :title="personState === 'edit' ? '编辑人物' : '人物详情'" /></view> <view class="person-detail-header"><PageHeader :title="personState === 'edit' ? (isCreateMode ? '新建人物' : '编辑人物') : '人物详情'" /></view>
<view v-if="personState === 'loading'" class="person-detail-loading"> <view v-if="personState === 'loading'" class="person-detail-loading">
<AppLoading text="正在读取人物档案" description="请稍候,正在整理人物资料。" /> <AppLoading text="正在读取人物档案" description="请稍候,正在整理人物资料。" />
@@ -11,7 +11,6 @@
<view v-else class="person-detail-content"> <view v-else class="person-detail-content">
<template v-if="['detail', 'edit', 'privacy'].includes(personState)"> <template v-if="['detail', 'edit', 'privacy'].includes(personState)">
<view class="person-identity-card"> <view class="person-identity-card">
<image class="person-card-skin" src="/static/assets/modules/records/transparent/r01-person-name-card.png" mode="scaleToFill" />
<view class="person-identity-card__copy"> <view class="person-identity-card__copy">
<text class="person-identity-card__name">{{ person.name }}</text> <text class="person-identity-card__name">{{ person.name }}</text>
<text class="person-identity-card__meta">{{ person.role }} · {{ person.generation }} </text> <text class="person-identity-card__meta">{{ person.role }} · {{ person.generation }} </text>
@@ -22,23 +21,24 @@
<template v-if="personState === 'detail'"> <template v-if="personState === 'detail'">
<view v-for="item in detailSections" :key="item.title" class="person-archive-card"> <view v-for="item in detailSections" :key="item.title" class="person-archive-card">
<image class="person-card-skin" src="/static/assets/modules/records/transparent/module-content-frame.png" mode="scaleToFill" />
<view><text>{{ item.title }}</text><text>{{ item.copy }}</text></view> <view><text>{{ item.title }}</text><text>{{ item.copy }}</text></view>
</view> </view>
<view class="person-related-actions">
<AppButton type="secondary" block label="成长日志" @click="toGrowthJournal" />
<AppButton type="secondary" block label="人生大事" @click="toLifeEvents" />
</view>
<view class="person-edit-action" @click="enterEdit"><AppButton block label="编辑人物" /></view> <view class="person-edit-action" @click="enterEdit"><AppButton block label="编辑人物" /></view>
</template> </template>
<template v-else-if="personState === 'edit'"> <template v-else-if="personState === 'edit'">
<view v-for="field in shortFields" :key="field.key" class="person-field"> <view v-for="field in shortFields" :key="field.key" class="person-field">
<image class="person-card-skin" src="/static/assets/modules/records/transparent/module-field-frame.png" mode="scaleToFill" />
<text class="person-field__label">{{ field.label }}</text> <text class="person-field__label">{{ field.label }}</text>
<input v-model="draft[field.key]" :type="field.key === 'generation' ? 'number' : 'text'" :placeholder="`请输入${field.label}`" /> <input v-model="draft[field.key]" :type="field.key === 'generation' ? 'number' : 'text'" :placeholder="`请输入${field.label}`" />
<text v-if="errors[field.key]" class="person-field-error">{{ errors[field.key] }}</text> <text v-if="errors[field.key]" class="person-field-error">{{ errors[field.key] }}</text>
</view> </view>
<view v-for="field in longFields" :key="field.key" class="person-long-field"> <view v-for="field in longFields" :key="field.key" class="person-long-field">
<image class="person-card-skin" src="/static/assets/modules/records/transparent/module-content-frame.png" mode="scaleToFill" />
<text class="person-long-field__label">{{ field.label }}</text> <text class="person-long-field__label">{{ field.label }}</text>
<textarea v-model="draft[field.key]" :placeholder="`请输入${field.label}`" /> <textarea v-model="draft[field.key]" auto-height :placeholder="`请输入${field.label}`" />
</view> </view>
<view class="person-edit-actions"> <view class="person-edit-actions">
<view class="person-save-action" @click="savePerson"><AppButton block label="保存人物" /></view> <view class="person-save-action" @click="savePerson"><AppButton block label="保存人物" /></view>
@@ -47,13 +47,11 @@
</template> </template>
<view v-else-if="personState === 'privacy'" class="person-state-card"> <view v-else-if="personState === 'privacy'" class="person-state-card">
<image class="person-card-skin" src="/static/assets/modules/records/transparent/module-content-frame.png" mode="scaleToFill" />
<view><text>部分资料未公开</text><text>人物小传与家族印记受隐私设置保护当前只展示公开身份</text></view> <view><text>部分资料未公开</text><text>人物小传与家族印记受隐私设置保护当前只展示公开身份</text></view>
<view class="person-state-action" @click="returnToPeople"><AppButton block label="返回人物录" /></view> <view class="person-state-action" @click="returnToPeople"><AppButton block label="返回人物录" /></view>
</view> </view>
<view v-else class="person-state-card"> <view v-else class="person-state-card">
<image class="person-card-skin" src="/static/assets/modules/records/transparent/module-content-frame.png" mode="scaleToFill" />
<view> <view>
<text>{{ personState === 'expired' ? '人物档案已失效' : '人物档案暂不可用' }}</text> <text>{{ personState === 'expired' ? '人物档案已失效' : '人物档案暂不可用' }}</text>
<text>{{ personState === 'expired' ? '这份人物资料已无法查看,请返回人物录选择其他档案。' : '请稍后重新查看,已有资料不会受到影响。' }}</text> <text>{{ personState === 'expired' ? '这份人物资料已无法查看,请返回人物录选择其他档案。' : '请稍后重新查看,已有资料不会受到影响。' }}</text>
@@ -85,6 +83,7 @@ const person = reactive({ ...people[0] });
const draft = reactive({ name: "", role: "", generation: "", biography: "", legacy: "" }); const draft = reactive({ name: "", role: "", generation: "", biography: "", legacy: "" });
const errors = reactive({ name: "", role: "", generation: "" }); const errors = reactive({ name: "", role: "", generation: "" });
const personState = ref("loading"); const personState = ref("loading");
const isCreateMode = ref(false);
const toastVisible = ref(false); const toastVisible = ref(false);
let toastTimer = null; let toastTimer = null;
const shortFields = [{ key: "name", label: "姓名" }, { key: "role", label: "身份" }, { key: "generation", label: "世代" }]; const shortFields = [{ key: "name", label: "姓名" }, { key: "role", label: "身份" }, { key: "generation", label: "世代" }];
@@ -100,7 +99,8 @@ const savePerson = () => {
if (!String(draft.role).trim()) errors.role = "请填写身份"; if (!String(draft.role).trim()) errors.role = "请填写身份";
if (!String(draft.generation).trim()) errors.generation = "请填写世代"; if (!String(draft.generation).trim()) errors.generation = "请填写世代";
if (errors.name || errors.role || errors.generation) return; if (errors.name || errors.role || errors.generation) return;
Object.assign(person, { ...draft, name: draft.name.trim(), role: draft.role.trim(), generation: String(draft.generation).trim() }); Object.assign(person, { id: person.id || "new", ...draft, name: draft.name.trim(), role: draft.role.trim(), generation: String(draft.generation).trim() });
isCreateMode.value = false;
personState.value = "detail"; personState.value = "detail";
toastVisible.value = true; toastVisible.value = true;
if (toastTimer) clearTimeout(toastTimer); if (toastTimer) clearTimeout(toastTimer);
@@ -108,7 +108,29 @@ const savePerson = () => {
}; };
const returnToPeople = () => uni.reLaunch({ url: "/pages/records/r01-people-list" }); const returnToPeople = () => uni.reLaunch({ url: "/pages/records/r01-people-list" });
const restoreDetail = () => { personState.value = "detail"; }; const restoreDetail = () => { personState.value = "detail"; };
const toGrowthJournal = () =>
uni.navigateTo({
url: `/pages/records/r08-growth-journal?personId=${person.id}`,
});
const toLifeEvents = () =>
uni.navigateTo({
url: `/pages/records/r09-life-events?personId=${person.id}`,
});
onLoad((query) => { onLoad((query) => {
isCreateMode.value = query.mode === "create";
if (isCreateMode.value) {
Object.assign(person, {
id: "",
name: "",
role: "",
generation: "",
biography: "",
legacy: "",
});
copyToDraft();
personState.value = "edit";
return;
}
const selected = people.find((item) => item.id === String(query.personId || "")); const selected = people.find((item) => item.id === String(query.personId || ""));
if (selected) Object.assign(person, selected); if (selected) Object.assign(person, selected);
copyToDraft(); copyToDraft();
@@ -119,33 +141,31 @@ onUnmounted(() => { if (toastTimer) clearTimeout(toastTimer); });
</script> </script>
<style scoped lang="scss"> <style scoped lang="scss">
.person-detail-page { min-height: 100vh; overflow-x: hidden; background: $paper; } .person-detail-page { display: flex; min-height: 100vh; flex-direction: column; background: $paper; }
.person-detail-header,.person-detail-loading,.person-detail-content { z-index: 1; }
.person-detail-loading { min-height: calc(100vh - 100rpx); } .person-detail-loading { min-height: calc(100vh - 100rpx); }
.person-detail-content { padding: 18rpx 24rpx 72rpx; } .person-detail-content { padding: 18rpx 24rpx 72rpx; }
.person-identity-card,.person-archive-card,.person-field,.person-long-field,.person-state-card { position: relative; } .person-identity-card { display: flex; min-height: 190rpx; padding: 30rpx 10%; box-sizing: border-box; background: url("/static/assets/modules/records/transparent/r01-person-name-card.png") center / 100% 100% no-repeat; }
.person-card-skin { position: absolute; inset: 0; z-index: 0; width: 100%; height: 100%; pointer-events: none; } .person-identity-card__copy { display: flex; flex: 1; flex-direction: column; justify-content: center; }
.person-identity-card { display: flex; min-height: 190rpx; padding: 30rpx 10%; box-sizing: border-box; }
.person-identity-card__copy { position: relative; z-index: 1; display: flex; flex: 1; flex-direction: column; justify-content: center; }
.person-identity-card__copy text { display: block; } .person-identity-card__copy text { display: block; }
.person-identity-card__name { color: $ink; font-family: STKaiti,KaiTi,serif; font-size: 38rpx; font-weight: 700; } .person-identity-card__name { color: $ink; font-family: STKaiti,KaiTi,serif; font-size: 38rpx; font-weight: 700; }
.person-identity-card__meta { margin-top: 8rpx; color: $ink-muted; font-size: 25rpx; font-weight: 600; } .person-identity-card__meta { margin-top: 8rpx; color: $ink-muted; font-size: 25rpx; font-weight: 600; }
.person-identity-card__hint { margin-top: 8rpx; color: #806a51; font-size: 21rpx; } .person-identity-card__hint { margin-top: 8rpx; color: #806a51; font-size: 21rpx; }
.person-archive-card { min-height: 154rpx; margin-top: 14rpx; padding: 32rpx 42rpx; box-sizing: border-box; } .person-archive-card { min-height: 154rpx; margin-top: 14rpx; padding: 32rpx 42rpx; box-sizing: border-box; }
.person-archive-card > view { position: relative; z-index: 1; } .person-archive-card,.person-long-field,.person-state-card { background: url("/static/assets/modules/records/transparent/module-content-frame.png") center / 100% 100% no-repeat; }
.person-archive-card text { display: block; } .person-archive-card text { display: block; }
.person-archive-card text:first-child,.person-long-field__label { color: $brand-red; font-size: 24rpx; font-weight: 700; } .person-archive-card text:first-child,.person-long-field__label { color: $brand-red; font-size: 24rpx; font-weight: 700; }
.person-archive-card text:last-child { margin-top: 11rpx; color: $ink; font-size: 24rpx; line-height: 1.55; } .person-archive-card text:last-child { margin-top: 11rpx; color: $ink; font-size: 24rpx; line-height: 1.55; }
.person-edit-action { margin-top: 20rpx; } .person-edit-action { margin-top: 20rpx; }
.person-field { display: grid; grid-template-columns: auto minmax(0, 1fr); align-items: center; gap: 8rpx 24rpx; min-height: 92rpx; margin-top: 12rpx; padding: 18rpx 28rpx; box-sizing: border-box; } .person-related-actions { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 14rpx; margin-top: 18rpx; }
.person-field__label { position: relative; z-index: 1; color: $ink; font-size: 23rpx; font-weight: 700; } .person-field { display: grid; grid-template-columns: auto minmax(0, 1fr); align-items: center; gap: 8rpx 24rpx; min-height: 92rpx; margin-top: 12rpx; padding: 18rpx 28rpx; box-sizing: border-box; background: url("/static/assets/modules/records/transparent/module-field-frame.png") center / 100% 100% no-repeat; }
.person-field input { position: relative; z-index: 1; width: 100%; min-width: 0; height: 56rpx; color: $ink; font-size: 23rpx; text-align: right; } .person-field__label { color: $ink; font-size: 23rpx; font-weight: 700; }
.person-field-error { position: relative; z-index: 1; grid-column: 1 / -1; display: block; color: $brand-red; font-size: 21rpx; text-align: right; } .person-field input { width: 100%; min-width: 0; min-height: 56rpx; color: $ink; font-size: 23rpx; text-align: right; }
.person-field-error { grid-column: 1 / -1; display: block; color: $brand-red; font-size: 21rpx; text-align: right; }
.person-long-field { display: flex; flex-direction: column; min-height: 210rpx; margin-top: 14rpx; padding: 28rpx 38rpx; box-sizing: border-box; } .person-long-field { display: flex; flex-direction: column; min-height: 210rpx; margin-top: 14rpx; padding: 28rpx 38rpx; box-sizing: border-box; }
.person-long-field__label,.person-long-field textarea { position: relative; z-index: 1; } .person-long-field textarea { width: 100%; min-height: 112rpx; margin-top: 14rpx; color: $ink; font-size: 23rpx; line-height: 1.5; }
.person-long-field textarea { width: 100%; height: 112rpx; min-height: 112rpx; margin-top: 14rpx; color: $ink; font-size: 23rpx; line-height: 1.5; }
.person-edit-actions { display: flex; flex-direction: column; gap: 14rpx; margin-top: 20rpx; } .person-edit-actions { display: flex; flex-direction: column; gap: 14rpx; margin-top: 20rpx; }
.person-state-card { display: flex; flex-direction: column; min-height: 300rpx; margin-top: 26rpx; padding: 72rpx 54rpx 42rpx; box-sizing: border-box; text-align: center; } .person-state-card { display: flex; flex-direction: column; min-height: 300rpx; margin-top: 26rpx; padding: 72rpx 54rpx 42rpx; box-sizing: border-box; text-align: center; }
.person-state-card > view { position: relative; z-index: 1; }
.person-state-card text { display: block; } .person-state-card text { display: block; }
.person-state-card text:first-child { color: $ink; font-family: STKaiti,KaiTi,serif; font-size: 34rpx; font-weight: 700; } .person-state-card text:first-child { color: $ink; font-family: STKaiti,KaiTi,serif; font-size: 34rpx; font-weight: 700; }
.person-state-card text:last-child { margin-top: 16rpx; color: $ink-muted; font-size: 24rpx; line-height: 1.6; } .person-state-card text:last-child { margin-top: 16rpx; color: $ink-muted; font-size: 24rpx; line-height: 1.6; }
+191 -3
View File
@@ -1,5 +1,193 @@
<!-- 页面编号R-03用途贺礼列表 --> <!-- 页面编号R-03用途贺礼簿列表空态失败与新增入口 -->
<template><ModulePage page-id="r03" /></template> <template>
<view class="gift-page" :class="stateClasses">
<ModulePageBackground module="records" />
<view class="page-header">
<PageHeader title="贺礼簿" action="新增" @action="createGift" />
</view>
<view v-if="giftState === 'loading'" class="page-loading">
<AppLoading
text="正在整理贺礼簿"
description="请稍候,正在读取家人的礼仪往来。"
/>
</view>
<view v-else class="page-content">
<template v-if="giftState === 'ready' && giftBooks.length">
<view
v-for="gift in giftBooks"
:key="gift.id"
class="record-card"
role="button"
:aria-label="`查看${gift.title}`"
@click="openGiftBook(gift)"
>
<text class="record-card__tag">{{ gift.occasion }}</text>
<text class="record-card__title">{{ gift.title }}</text>
<text class="record-card__copy">
{{ gift.from }} · {{ gift.date }}
</text>
<text class="record-card__hint">查看并编辑贺礼</text>
</view>
<AppButton block label="新增贺礼" @click="createGift" />
</template>
<view v-else class="state-card">
<text>
{{ giftState === "error" ? "贺礼簿暂不可用" : "还没有贺礼记录" }}
</text>
<text>
{{
giftState === "error"
? "请稍后重新查看,已有记录不会受到影响。"
: "从第一份家人之间的心意开始记录。"
}}
</text>
<AppButton
:type="giftState === 'error' ? 'secondary' : 'primary'"
block
:label="giftState === 'error' ? '重新查看' : '新增贺礼'"
@click="giftState === 'error' ? restoreGifts() : createGift()"
/>
</view>
</view>
</view>
</template>
<script setup> <script setup>
import ModulePage from "@/components/ModulePage.vue"; import { computed, ref } from "vue";
import { onLoad } from "@dcloudio/uni-app";
import AppButton from "@/components/AppButton.vue";
import AppLoading from "@/components/AppLoading.vue";
import ModulePageBackground from "@/components/ModulePageBackground.vue";
import PageHeader from "@/components/PageHeader.vue";
const baseGifts = [
{
id: "301",
title: "新春贺礼",
occasion: "春节",
from: "汤文正一家",
date: "2024 年 2 月 10 日",
},
{
id: "302",
title: "寿宴礼单",
occasion: "寿辰",
from: "汤淑华",
date: "2024 年 4 月 18 日",
},
{
id: "303",
title: "添丁祝福",
occasion: "新生",
from: "汤文清一家",
date: "2024 年 6 月 2 日",
},
];
const giftBooks = ref([...baseGifts]);
const giftState = ref("loading");
const stateClasses = computed(() => ({
"gift-state--loading": giftState.value === "loading",
"gift-state--empty": giftState.value === "empty",
"gift-state--error": giftState.value === "error",
}));
onLoad((query) => {
const count = Math.max(
1,
Math.min(Number(query.count) || baseGifts.length, 50),
);
giftBooks.value = Array.from({ length: count }, (_, i) => ({
...baseGifts[i % baseGifts.length],
id: String(301 + i),
title:
count > 3 ? `${baseGifts[i % 3].title}${i + 1}` : baseGifts[i].title,
}));
giftState.value = ["loading", "empty", "error"].includes(query.state)
? query.state
: "ready";
});
const openGiftBook = (gift) =>
uni.navigateTo({
url: `/pages/records/r04-gift-editor?mode=view&giftId=${gift.id}`,
});
const createGift = () =>
uni.navigateTo({ url: "/pages/records/r04-gift-editor?mode=create" });
const restoreGifts = () => {
giftState.value = "ready";
};
</script> </script>
<style scoped lang="scss">
.gift-page {
display: flex;
min-height: 100vh;
flex-direction: column;
background: $paper;
}
.page-header,
.page-loading,
.page-content {
z-index: 1;
}
.page-loading {
min-height: calc(100vh - 100rpx);
}
.page-content {
display: flex;
flex-direction: column;
gap: 16rpx;
padding: 18rpx 24rpx 72rpx;
}
.record-card,
.state-card {
box-sizing: border-box;
background: url("/static/assets/modules/records/transparent/module-content-frame.png")
center/100% 100% no-repeat;
}
.record-card {
min-height: 190rpx;
padding: 34rpx 46rpx;
}
.record-card > text,
.state-card > text {
display: block;
}
.record-card__tag {
color: $brand-red;
font-size: 21rpx;
font-weight: 700;
}
.record-card__title {
margin-top: 6rpx;
color: $ink;
font-family: STKaiti, KaiTi, serif;
font-size: 31rpx;
font-weight: 700;
}
.record-card__copy {
margin-top: 9rpx;
color: $ink-muted;
font-size: 23rpx;
line-height: 1.5;
}
.record-card__hint {
margin-top: 10rpx;
color: $brand-red;
font-size: 21rpx;
}
.state-card {
min-height: 340rpx;
padding: 78rpx 52rpx 50rpx;
text-align: center;
}
.state-card > text:first-child {
color: $ink;
font-size: 35rpx;
font-weight: 700;
}
.state-card > text:nth-child(2) {
margin-top: 18rpx;
color: $ink-muted;
font-size: 24rpx;
line-height: 1.65;
}
.state-card .app-button {
margin-top: 28rpx;
}
</style>
+258 -3
View File
@@ -1,5 +1,260 @@
<!-- 页面编号R-04用途贺礼新增详情与删除确认 --> <!-- 页面编号R-04用途贺礼查看新增编辑保存与删除确认 -->
<template><ModulePage page-id="r04" /></template> <template>
<view class="gift-editor-page" :class="editorClasses">
<ModulePageBackground module="records" />
<view class="page-header">
<PageHeader
:title="
mode === 'create'
? '新增贺礼'
: mode === 'view'
? '贺礼详情'
: '编辑贺礼'
"
:action="mode === 'view' ? '编辑' : ''"
@action="mode = 'edit'"
/>
</view>
<view v-if="editorState === 'loading'" class="page-loading">
<AppLoading
text="正在读取贺礼"
description="请稍候,正在整理这份礼仪记录。"
/>
</view>
<view v-else class="page-content">
<view v-if="editorState === 'success'" class="state-card">
<text>贺礼已保存</text>
<text>这份心意已加入家族贺礼簿</text>
<AppButton block label="返回贺礼簿" @click="backToGifts" />
</view>
<view v-else-if="mode === 'view'" class="detail-card">
<text>{{ giftForm.title }}</text>
<view v-for="item in detailRows" :key="item.label">
<text>{{ item.label }}</text>
<text>{{ item.value }}</text>
</view>
<AppButton block label="编辑贺礼" @click="mode = 'edit'" />
<AppButton
type="secondary"
block
label="删除记录"
@click="confirmDelete"
/>
</view>
<view v-else class="form-card">
<text>
{{ mode === "create" ? "记录一份家人心意" : "修改贺礼信息" }}
</text>
<view v-for="field in fields" :key="field.key" class="field-row">
<text>{{ field.label }}</text>
<input
v-model="giftForm[field.key]"
:placeholder="`请输入${field.label}`"
/>
<text v-if="giftErrors[field.key]">{{ giftErrors[field.key] }}</text>
</view>
<text v-if="editorState === 'error'" class="save-error">
保存失败请检查内容后重试
</text>
<AppButton
block
:disabled="editorState === 'saving'"
:label="editorState === 'saving' ? '正在保存' : '保存贺礼'"
@click="saveGift"
/>
<AppButton
v-if="mode === 'edit'"
type="secondary"
block
label="删除记录"
@click="confirmDelete"
/>
</view>
</view>
<AppDialog
:visible="deleteVisible"
eyebrow="删除确认"
title="删除这份贺礼?"
message="删除后将返回贺礼簿,本地演示记录不会继续显示。"
confirm-text="确认删除"
cancel-text="保留记录"
show-cancel
@confirm="deleteGift"
@cancel="deleteVisible = false"
/>
</view>
</template>
<script setup> <script setup>
import ModulePage from "@/components/ModulePage.vue"; import { computed, onUnmounted, reactive, ref } from "vue";
import { onLoad } from "@dcloudio/uni-app";
import AppButton from "@/components/AppButton.vue";
import AppDialog from "@/components/AppDialog.vue";
import AppLoading from "@/components/AppLoading.vue";
import ModulePageBackground from "@/components/ModulePageBackground.vue";
import PageHeader from "@/components/PageHeader.vue";
const records = [
{
id: "301",
title: "新春贺礼",
from: "汤文正一家",
date: "2024-02-10",
note: "新春团拜时赠予长辈的心意",
},
{
id: "302",
title: "寿宴礼单",
from: "汤淑华",
date: "2024-04-18",
note: "汤老先生八十寿辰",
},
];
const giftId = ref("");
const mode = ref("create");
const editorState = ref("ready");
const deleteVisible = ref(false);
const forceSaveFailure = ref(false);
const giftForm = reactive({ title: "", from: "", date: "", note: "" });
const giftErrors = reactive({ title: "", from: "", date: "" });
const fields = [
{ key: "title", label: "贺礼名称" },
{ key: "from", label: "赠送人" },
{ key: "date", label: "日期" },
{ key: "note", label: "备注" },
];
let saveTimer = null;
const editorClasses = computed(() => ({
"gift-editor-state--saving": editorState.value === "saving",
"gift-editor-state--error": editorState.value === "error",
}));
const detailRows = computed(() =>
fields
.slice(1)
.map((f) => ({ label: f.label, value: giftForm[f.key] || "未填写" })),
);
onLoad((query) => {
giftId.value = String(query.giftId || "");
mode.value = ["view", "edit"].includes(query.mode) ? query.mode : "create";
forceSaveFailure.value = query.saveResult === "error";
const selected = records.find((x) => x.id === giftId.value);
if (selected) Object.assign(giftForm, selected);
else if (mode.value !== "create") editorState.value = "error";
if (query.state === "loading") editorState.value = "loading";
});
const validateGift = () => {
giftErrors.title = giftForm.title.trim() ? "" : "请填写贺礼名称";
giftErrors.from = giftForm.from.trim() ? "" : "请填写赠送人";
giftErrors.date = giftForm.date.trim() ? "" : "请填写日期";
return !giftErrors.title && !giftErrors.from && !giftErrors.date;
};
const saveGift = () => {
if (editorState.value === "saving" || !validateGift()) return;
editorState.value = "saving";
saveTimer = setTimeout(() => {
editorState.value = forceSaveFailure.value ? "error" : "success";
forceSaveFailure.value = false;
}, 320);
};
const confirmDelete = () => {
deleteVisible.value = true;
};
const deleteGift = () => {
deleteVisible.value = false;
backToGifts();
};
const backToGifts = () =>
uni.redirectTo({ url: "/pages/records/r03-gift-list" });
onUnmounted(() => {
if (saveTimer) clearTimeout(saveTimer);
});
</script> </script>
<style scoped lang="scss">
.gift-editor-page {
display: flex;
min-height: 100vh;
flex-direction: column;
background: $paper;
}
.page-header,
.page-loading,
.page-content {
z-index: 1;
}
.page-loading {
min-height: calc(100vh - 100rpx);
}
.page-content {
padding: 18rpx 24rpx 72rpx;
}
.form-card,
.detail-card,
.state-card {
box-sizing: border-box;
padding: 46rpx;
background: url("/static/assets/modules/records/transparent/module-content-frame.png")
center/100% 100% no-repeat;
}
.form-card > text:first-child,
.detail-card > text:first-child,
.state-card > text:first-child {
display: block;
color: $ink;
font-size: 34rpx;
font-weight: 700;
}
.field-row,
.detail-card > view {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
align-items: center;
gap: 12rpx 20rpx;
min-height: 82rpx;
margin-top: 14rpx;
padding: 14rpx 24rpx;
box-sizing: border-box;
background: url("/static/assets/modules/records/transparent/module-field-frame.png")
center/100% 100% no-repeat;
}
.field-row > text:first-child,
.detail-card > view > text:first-child {
color: $ink;
font-size: 23rpx;
font-weight: 700;
}
.field-row input,
.detail-card > view > text:last-child {
min-width: 0;
color: $ink;
font-size: 23rpx;
text-align: right;
}
.field-row > text:last-child {
grid-column: 1/-1;
color: $brand-red;
font-size: 20rpx;
text-align: right;
}
.form-card .app-button,
.detail-card .app-button {
margin-top: 18rpx;
}
.save-error {
display: block;
margin-top: 14rpx;
color: $brand-red;
font-size: 22rpx;
}
.state-card {
min-height: 340rpx;
padding-top: 80rpx;
text-align: center;
}
.state-card > text:nth-child(2) {
display: block;
margin-top: 18rpx;
color: $ink-muted;
font-size: 24rpx;
}
.state-card .app-button {
margin-top: 28rpx;
}
</style>
+179 -3
View File
@@ -1,5 +1,181 @@
<!-- 页面编号R-05用途礼仪活动列表 --> <!-- 页面编号R-05用途礼仪活动列表状态与创建入口 -->
<template><ModulePage page-id="r05" /></template> <template>
<view class="ritual-page" :class="stateClasses">
<ModulePageBackground module="records" />
<view class="page-header">
<PageHeader title="礼仪活动" action="新建" @action="createRitual" />
</view>
<view v-if="ritualState === 'loading'" class="page-loading">
<AppLoading
text="正在整理礼仪活动"
description="请稍候,正在读取时间与地点。"
/>
</view>
<view v-else class="page-content">
<template v-if="ritualState === 'ready' && rituals.length">
<view
v-for="ritual in rituals"
:key="ritual.id"
class="record-card"
@click="openRitual(ritual)"
>
<text>{{ ritual.status }}</text>
<text>{{ ritual.name }}</text>
<text>{{ ritual.date }} · {{ ritual.place }}</text>
<text>查看活动详情</text>
</view>
<AppButton block label="新建礼仪" @click="createRitual" />
</template>
<view v-else class="state-card">
<text>
{{ ritualState === "error" ? "礼仪活动暂不可用" : "还没有礼仪活动" }}
</text>
<text>
{{
ritualState === "error"
? "请稍后重新查看,已有活动不会受到影响。"
: "从一次祭祖、家宴或团拜开始安排。"
}}
</text>
<AppButton
:type="ritualState === 'error' ? 'secondary' : 'primary'"
block
:label="ritualState === 'error' ? '重新查看' : '新建礼仪'"
@click="ritualState === 'error' ? restoreRituals() : createRitual()"
/>
</view>
</view>
</view>
</template>
<script setup> <script setup>
import ModulePage from "@/components/ModulePage.vue"; import { computed, ref } from "vue";
import { onLoad } from "@dcloudio/uni-app";
import AppButton from "@/components/AppButton.vue";
import AppLoading from "@/components/AppLoading.vue";
import ModulePageBackground from "@/components/ModulePageBackground.vue";
import PageHeader from "@/components/PageHeader.vue";
const base = [
{
id: "501",
name: "清明祭祖",
status: "报名中",
date: "2025 年 4 月 4 日",
place: "汤氏宗祠",
},
{
id: "502",
name: "中秋家宴",
status: "筹备中",
date: "2025 年 9 月 17 日",
place: "祖居院落",
},
{
id: "503",
name: "新春团拜",
status: "已结束",
date: "2025 年 1 月 29 日",
place: "家族礼堂",
},
];
const rituals = ref([...base]);
const ritualState = ref("loading");
const stateClasses = computed(() => ({
"ritual-state--loading": ritualState.value === "loading",
"ritual-state--empty": ritualState.value === "empty",
"ritual-state--error": ritualState.value === "error",
}));
onLoad((q) => {
const n = Math.max(1, Math.min(Number(q.count) || base.length, 50));
rituals.value = Array.from({ length: n }, (_, i) => ({
...base[i % 3],
id: String(501 + i),
name: n > 3 ? `${base[i % 3].name}${i + 1}` : base[i].name,
}));
ritualState.value = ["loading", "empty", "error"].includes(q.state)
? q.state
: "ready";
});
const openRitual = (r) =>
uni.navigateTo({ url: `/pages/records/r06-ritual-detail?ritualId=${r.id}` });
const createRitual = () =>
uni.navigateTo({ url: "/pages/records/r07-ritual-editor?mode=create" });
const restoreRituals = () => {
ritualState.value = "ready";
};
</script> </script>
<style scoped lang="scss">
.ritual-page {
display: flex;
min-height: 100vh;
flex-direction: column;
background: $paper;
}
.page-header,
.page-loading,
.page-content {
z-index: 1;
}
.page-loading {
min-height: calc(100vh - 100rpx);
}
.page-content {
display: flex;
flex-direction: column;
gap: 16rpx;
padding: 18rpx 24rpx 72rpx;
}
.record-card,
.state-card {
box-sizing: border-box;
background: url("/static/assets/modules/records/transparent/module-content-frame.png")
center/100% 100% no-repeat;
}
.record-card {
min-height: 190rpx;
padding: 32rpx 46rpx;
}
.record-card > text,
.state-card > text {
display: block;
}
.record-card > text:first-child {
color: $brand-red;
font-size: 21rpx;
}
.record-card > text:nth-child(2) {
margin-top: 6rpx;
color: $ink;
font-size: 31rpx;
font-weight: 700;
}
.record-card > text:nth-child(3) {
margin-top: 9rpx;
color: $ink-muted;
font-size: 23rpx;
line-height: 1.5;
}
.record-card > text:last-child {
margin-top: 9rpx;
color: $brand-red;
font-size: 21rpx;
}
.state-card {
min-height: 340rpx;
padding: 78rpx 52rpx 50rpx;
text-align: center;
}
.state-card > text:first-child {
color: $ink;
font-size: 35rpx;
font-weight: 700;
}
.state-card > text:nth-child(2) {
margin-top: 18rpx;
color: $ink-muted;
font-size: 24rpx;
line-height: 1.65;
}
.state-card .app-button {
margin-top: 28rpx;
}
</style>
+220 -3
View File
@@ -1,5 +1,222 @@
<!-- 页面编号R-06用途礼仪详情 --> <!-- 页面编号R-06用途礼仪详情参与者与受控状态 -->
<template><ModulePage page-id="r06" /></template> <template>
<view class="ritual-detail-page" :class="stateClasses">
<ModulePageBackground module="records" />
<view class="page-header">
<PageHeader
title="礼仪详情"
:action="ritualState === 'ready' ? '编辑' : ''"
@action="editRitual"
/>
</view>
<view v-if="ritualState === 'loading'" class="page-loading">
<AppLoading
text="正在读取礼仪详情"
description="请稍候,正在整理活动与参与信息。"
/>
</view>
<view v-else class="page-content">
<template v-if="ritualState === 'ready'">
<view class="detail-card">
<text>{{ ritualDetail.status }}</text>
<text>{{ ritualDetail.name }}</text>
<text>{{ ritualDetail.date }} · {{ ritualDetail.place }}</text>
<text>{{ ritualDetail.description }}</text>
</view>
<view class="participant-card">
<view>
<text>参与家人</text>
<text>{{ participants.length }} </text>
</view>
<view v-for="person in participants" :key="person.id">
<text>{{ person.name }}</text>
<text>{{ person.role }}</text>
</view>
</view>
<AppButton block label="编辑活动" @click="editRitual" />
</template>
<view v-else class="state-card">
<text>{{ stateCopy.title }}</text>
<text>{{ stateCopy.copy }}</text>
<AppButton
:type="ritualState === 'error' ? 'secondary' : 'primary'"
block
:label="ritualState === 'error' ? '重新查看' : '返回礼仪列表'"
@click="ritualState === 'error' ? restoreRitual() : backToRituals()"
/>
</view>
</view>
</view>
</template>
<script setup> <script setup>
import ModulePage from "@/components/ModulePage.vue"; import { computed, reactive, ref } from "vue";
import { onLoad } from "@dcloudio/uni-app";
import AppButton from "@/components/AppButton.vue";
import AppLoading from "@/components/AppLoading.vue";
import ModulePageBackground from "@/components/ModulePageBackground.vue";
import PageHeader from "@/components/PageHeader.vue";
const records = [
{
id: "501",
name: "清明祭祖",
status: "报名中",
date: "2025 年 4 月 4 日",
place: "汤氏宗祠",
description: "缅怀先祖,整理祭扫礼序,并由长辈讲述家族往事。",
},
{
id: "502",
name: "中秋家宴",
status: "筹备中",
date: "2025 年 9 月 17 日",
place: "祖居院落",
description: "家人团聚,共叙近况并整理年度家族影像。",
},
];
const ritualDetail = reactive({ ...records[0] });
const ritualId = ref("501");
const ritualState = ref("loading");
const participants = ref([
{ id: 1, name: "汤文正", role: "主理人" },
{ id: 2, name: "汤淑华", role: "家族长辈" },
{ id: 3, name: "汤文清", role: "影像记录" },
]);
const stateClasses = computed(() => ({
"ritual-state--expired": ritualState.value === "expired",
"ritual-state--privacy": ritualState.value === "privacy",
"ritual-state--error": ritualState.value === "error",
}));
const stateCopy = computed(
() =>
({
expired: {
title: "活动已失效",
copy: "这项礼仪活动已取消或结束归档,请返回列表查看其他活动。",
},
privacy: {
title: "活动信息未公开",
copy: "当前活动只向受邀家人展示,请返回礼仪列表。",
},
error: {
title: "礼仪详情暂不可用",
copy: "请稍后重新查看,已有活动不会受到影响。",
},
})[ritualState.value] || {},
);
onLoad((q) => {
ritualId.value = String(q.ritualId || "501");
const selected = records.find((x) => x.id === ritualId.value);
if (selected) Object.assign(ritualDetail, selected);
ritualState.value = ["loading", "expired", "privacy", "error"].includes(
q.state,
)
? q.state
: selected
? "ready"
: "expired";
});
const editRitual = () =>
uni.navigateTo({
url: `/pages/records/r07-ritual-editor?mode=edit&ritualId=${ritualId.value}`,
});
const restoreRitual = () => {
ritualState.value = "ready";
};
const backToRituals = () =>
uni.redirectTo({ url: "/pages/records/r05-ritual-list" });
</script> </script>
<style scoped lang="scss">
.ritual-detail-page {
display: flex;
min-height: 100vh;
flex-direction: column;
background: $paper;
}
.page-header,
.page-loading,
.page-content {
z-index: 1;
}
.page-loading {
min-height: calc(100vh - 100rpx);
}
.page-content {
display: flex;
flex-direction: column;
gap: 16rpx;
padding: 18rpx 24rpx 72rpx;
}
.detail-card,
.participant-card,
.state-card {
box-sizing: border-box;
padding: 42rpx 46rpx;
background: url("/static/assets/modules/records/transparent/module-content-frame.png")
center/100% 100% no-repeat;
}
.detail-card > text {
display: block;
}
.detail-card > text:first-child {
color: $brand-red;
font-size: 21rpx;
}
.detail-card > text:nth-child(2) {
margin-top: 8rpx;
color: $ink;
font-size: 36rpx;
font-weight: 700;
}
.detail-card > text:nth-child(3) {
margin-top: 10rpx;
color: $ink-muted;
font-size: 23rpx;
}
.detail-card > text:last-child {
margin-top: 20rpx;
color: $ink;
font-size: 24rpx;
line-height: 1.7;
}
.participant-card > view {
display: flex;
flex-wrap: wrap;
justify-content: space-between;
gap: 8rpx 18rpx;
min-height: 52rpx;
align-items: center;
color: $ink;
font-size: 23rpx;
}
.participant-card > view:first-child {
color: $brand-red;
font-weight: 700;
}
.participant-card > view + view {
margin-top: 9rpx;
padding-top: 9rpx;
border-top: 1px solid rgba(136, 84, 42, 0.18);
}
.state-card {
min-height: 340rpx;
padding-top: 78rpx;
text-align: center;
}
.state-card > text {
display: block;
}
.state-card > text:first-child {
color: $ink;
font-size: 35rpx;
font-weight: 700;
}
.state-card > text:nth-child(2) {
margin-top: 18rpx;
color: $ink-muted;
font-size: 24rpx;
line-height: 1.65;
}
.state-card .app-button {
margin-top: 28rpx;
}
</style>
+225 -3
View File
@@ -1,5 +1,227 @@
<!-- 页面编号R-07用途新建与编辑礼仪 --> <!-- 页面编号R-07用途礼仪创建编辑校验保存与删除确认 -->
<template><ModulePage page-id="r07" /></template> <template>
<view class="ritual-editor-page" :class="editorClasses">
<ModulePageBackground module="records" />
<view class="page-header">
<PageHeader :title="mode === 'create' ? '新建礼仪' : '编辑礼仪'" />
</view>
<view class="page-content">
<view v-if="editorState === 'success'" class="state-card">
<text>礼仪活动已保存</text>
<text>时间地点与活动说明已整理完成</text>
<AppButton block label="返回礼仪列表" @click="backToRituals" />
</view>
<view v-else class="form-card">
<text>
{{ mode === "create" ? "安排一次家族礼仪" : "修改活动信息" }}
</text>
<view v-for="field in fields" :key="field.key" class="field-row">
<text>{{ field.label }}</text>
<textarea
v-if="field.long"
v-model="ritualForm[field.key]"
auto-height
:placeholder="`请输入${field.label}`"
/>
<input
v-else
v-model="ritualForm[field.key]"
:placeholder="`请输入${field.label}`"
/>
<text v-if="ritualErrors[field.key]">
{{ ritualErrors[field.key] }}
</text>
</view>
<text v-if="editorState === 'error'" class="save-error">
保存失败请保留内容后重试
</text>
<AppButton
block
:disabled="editorState === 'saving'"
:label="editorState === 'saving' ? '正在保存' : '保存活动'"
@click="saveRitual"
/>
<AppButton
v-if="mode === 'edit'"
type="secondary"
block
label="删除活动"
@click="confirmDelete"
/>
</view>
</view>
<AppDialog
:visible="deleteVisible"
eyebrow="删除确认"
title="删除这项礼仪活动?"
message="删除后将返回礼仪列表。"
confirm-text="确认删除"
cancel-text="保留活动"
show-cancel
@confirm="deleteRitual"
@cancel="deleteVisible = false"
/>
</view>
</template>
<script setup> <script setup>
import ModulePage from "@/components/ModulePage.vue"; import { computed, onUnmounted, reactive, ref } from "vue";
import { onLoad } from "@dcloudio/uni-app";
import AppButton from "@/components/AppButton.vue";
import AppDialog from "@/components/AppDialog.vue";
import AppLoading from "@/components/AppLoading.vue";
import ModulePageBackground from "@/components/ModulePageBackground.vue";
import PageHeader from "@/components/PageHeader.vue";
const records = [
{
id: "501",
name: "清明祭祖",
date: "2025-04-04",
place: "汤氏宗祠",
description: "缅怀先祖,凝聚家人,共叙家风传承。",
},
];
const ritualId = ref("");
const mode = ref("create");
const editorState = ref("ready");
const deleteVisible = ref(false);
const forceSaveFailure = ref(false);
const ritualForm = reactive({ name: "", date: "", place: "", description: "" });
const ritualErrors = reactive({
name: "",
date: "",
place: "",
description: "",
});
const fields = [
{ key: "name", label: "活动名称" },
{ key: "date", label: "活动日期" },
{ key: "place", label: "举办地点" },
{ key: "description", label: "活动说明", long: true },
];
let saveTimer = null;
const editorClasses = computed(() => ({
"ritual-editor-state--saving": editorState.value === "saving",
"ritual-editor-state--error": editorState.value === "error",
}));
onLoad((q) => {
ritualId.value = String(q.ritualId || "");
mode.value = q.mode === "edit" ? "edit" : "create";
forceSaveFailure.value = q.saveResult === "error";
const selected = records.find((x) => x.id === ritualId.value);
if (selected) Object.assign(ritualForm, selected);
});
const validateRitual = () => {
for (const f of fields)
ritualErrors[f.key] = String(ritualForm[f.key]).trim()
? ""
: `请填写${f.label}`;
return fields.every((f) => !ritualErrors[f.key]);
};
const saveRitual = () => {
if (editorState.value === "saving" || !validateRitual()) return;
editorState.value = "saving";
saveTimer = setTimeout(() => {
editorState.value = forceSaveFailure.value ? "error" : "success";
forceSaveFailure.value = false;
}, 320);
};
const confirmDelete = () => {
deleteVisible.value = true;
};
const deleteRitual = () => {
deleteVisible.value = false;
backToRituals();
};
const backToRituals = () =>
uni.redirectTo({ url: "/pages/records/r05-ritual-list" });
onUnmounted(() => {
if (saveTimer) clearTimeout(saveTimer);
});
</script> </script>
<style scoped lang="scss">
.ritual-editor-page {
display: flex;
min-height: 100vh;
flex-direction: column;
background: $paper;
}
.page-header,
.page-content {
z-index: 1;
}
.page-content {
padding: 18rpx 24rpx 72rpx;
}
.form-card,
.state-card {
box-sizing: border-box;
padding: 46rpx;
background: url("/static/assets/modules/records/transparent/module-content-frame.png")
center/100% 100% no-repeat;
}
.form-card > text:first-child,
.state-card > text:first-child {
display: block;
color: $ink;
font-size: 34rpx;
font-weight: 700;
}
.field-row {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
align-items: center;
gap: 10rpx 18rpx;
min-height: 82rpx;
margin-top: 14rpx;
padding: 14rpx 24rpx;
box-sizing: border-box;
background: url("/static/assets/modules/records/transparent/module-field-frame.png")
center/100% 100% no-repeat;
}
.field-row > text:first-child {
color: $ink;
font-size: 23rpx;
font-weight: 700;
}
.field-row input,
.field-row textarea {
width: auto;
min-width: 0;
color: $ink;
font-size: 23rpx;
text-align: right;
}
.field-row textarea {
min-height: 76rpx;
line-height: 1.5;
}
.field-row > text:last-child {
grid-column: 1/-1;
color: $brand-red;
font-size: 20rpx;
text-align: right;
}
.form-card .app-button {
margin-top: 18rpx;
}
.save-error {
display: block;
margin-top: 14rpx;
color: $brand-red;
font-size: 22rpx;
}
.state-card {
min-height: 340rpx;
padding-top: 80rpx;
text-align: center;
}
.state-card > text:nth-child(2) {
display: block;
margin-top: 18rpx;
color: $ink-muted;
font-size: 24rpx;
}
.state-card .app-button {
margin-top: 28rpx;
}
</style>
+251 -3
View File
@@ -1,5 +1,253 @@
<!-- 页面编号R-08用途成长日志 --> <!-- 页面编号R-08用途人物成长日志时间轴与同页新增 -->
<template><ModulePage page-id="r08" /></template> <template>
<view class="timeline-page" :class="stateClasses">
<ModulePageBackground module="records" />
<view class="page-header">
<PageHeader title="成长日志" action="记录" @action="recordGrowth" />
</view>
<view v-if="timelineState === 'loading'" class="page-loading">
<AppLoading
text="正在读取成长日志"
:description="`请稍候,正在整理${personName}的成长记录。`"
/>
</view>
<view v-else class="page-content">
<view class="person-lead">
<text>{{ personName }}</text>
<text>成长中的每一个瞬间</text>
</view>
<template v-if="timelineState === 'ready' && growthRecords.length">
<view
v-for="(record, index) in growthRecords"
:key="record.id"
class="timeline-card"
>
<text> {{ growthRecords.length - index }} </text>
<text>{{ record.title }}</text>
<text>{{ record.date }}</text>
<text>{{ record.description }}</text>
</view>
<AppButton block label="记录成长" @click="recordGrowth" />
</template>
<view v-else class="state-card">
<text>
{{
timelineState === "error" ? "成长日志暂不可用" : "还没有成长记录"
}}
</text>
<text>
{{
timelineState === "error"
? "请稍后重新查看,已有记录不会受到影响。"
: "从第一次微笑、入园或毕业开始记录。"
}}
</text>
<AppButton
:type="timelineState === 'error' ? 'secondary' : 'primary'"
block
:label="timelineState === 'error' ? '重新查看' : '记录成长'"
@click="
timelineState === 'error'
? (timelineState = 'ready')
: recordGrowth()
"
/>
</view>
</view>
<AppDialog
:visible="dialogVisible"
eyebrow="成长日志"
title="记录一个成长瞬间"
confirm-text="保存记录"
cancel-text="取消"
show-cancel
@confirm="saveGrowth"
@cancel="dialogVisible = false"
>
<view class="dialog-form">
<input v-model="growthForm.title" placeholder="事件名称" />
<input v-model="growthForm.date" placeholder="日期" />
<textarea
v-model="growthForm.description"
auto-height
placeholder="写下当时的故事"
/>
<text v-if="formError">{{ formError }}</text>
</view>
</AppDialog>
<AppToast :visible="toastVisible" message="成长记录已保存" />
</view>
</template>
<script setup> <script setup>
import ModulePage from "@/components/ModulePage.vue"; import { computed, onUnmounted, reactive, ref } from "vue";
import { onLoad } from "@dcloudio/uni-app";
import AppButton from "@/components/AppButton.vue";
import AppDialog from "@/components/AppDialog.vue";
import AppLoading from "@/components/AppLoading.vue";
import AppToast from "@/components/AppToast.vue";
import ModulePageBackground from "@/components/ModulePageBackground.vue";
import PageHeader from "@/components/PageHeader.vue";
const personName = ref("汤小满");
const growthRecords = ref([
{
id: 1,
title: "第一次叫爸爸",
date: "2024 年 3 月",
description: "家人共同听见了这声清晰的呼唤。",
},
{
id: 2,
title: "入园第一天",
date: "2024 年 9 月",
description: "背着小书包,勇敢地向家人挥手。",
},
]);
const timelineState = ref("loading");
const dialogVisible = ref(false);
const toastVisible = ref(false);
const formError = ref("");
const growthForm = reactive({ title: "", date: "", description: "" });
let timer = null;
const stateClasses = computed(() => ({
"timeline-state--loading": timelineState.value === "loading",
"timeline-state--empty": timelineState.value === "empty",
"timeline-state--error": timelineState.value === "error",
}));
onLoad((q) => {
personName.value = String(q.personName || "汤小满");
timelineState.value = ["loading", "empty", "error"].includes(q.state)
? q.state
: "ready";
});
const recordGrowth = () => {
Object.assign(growthForm, { title: "", date: "", description: "" });
formError.value = "";
dialogVisible.value = true;
};
const saveGrowth = () => {
if (!growthForm.title.trim() || !growthForm.date.trim()) {
formError.value = "请填写事件名称和日期";
return;
}
growthRecords.value.unshift({ id: Date.now(), ...growthForm });
timelineState.value = "ready";
dialogVisible.value = false;
toastVisible.value = true;
timer = setTimeout(() => (toastVisible.value = false), 1800);
};
onUnmounted(() => {
if (timer) clearTimeout(timer);
});
</script> </script>
<style scoped lang="scss">
.timeline-page {
display: flex;
min-height: 100vh;
flex-direction: column;
background: $paper;
}
.page-header,
.page-loading,
.page-content {
z-index: 1;
}
.page-loading {
min-height: calc(100vh - 100rpx);
}
.page-content {
display: flex;
flex-direction: column;
gap: 16rpx;
padding: 18rpx 24rpx 72rpx;
}
.person-lead {
display: flex;
flex-wrap: wrap;
justify-content: space-between;
gap: 8rpx 18rpx;
min-height: 62rpx;
align-items: center;
padding: 0 20rpx;
color: $ink-muted;
font-size: 22rpx;
background: url("/static/assets/modules/genealogy/transparent/section-divider.png")
center/100% 100% no-repeat;
}
.person-lead text:first-child {
color: $brand-red;
font-weight: 700;
}
.timeline-card,
.state-card {
box-sizing: border-box;
padding: 34rpx 46rpx;
background: url("/static/assets/modules/records/transparent/module-content-frame.png")
center/100% 100% no-repeat;
}
.timeline-card > text,
.state-card > text {
display: block;
}
.timeline-card > text:first-child {
color: $brand-red;
font-size: 20rpx;
}
.timeline-card > text:nth-child(2) {
margin-top: 6rpx;
color: $ink;
font-size: 31rpx;
font-weight: 700;
}
.timeline-card > text:nth-child(3) {
margin-top: 8rpx;
color: $ink-muted;
font-size: 21rpx;
}
.timeline-card > text:last-child {
margin-top: 10rpx;
color: $ink;
font-size: 23rpx;
line-height: 1.55;
}
.state-card {
min-height: 340rpx;
padding-top: 78rpx;
text-align: center;
}
.state-card > text:first-child {
color: $ink;
font-size: 35rpx;
font-weight: 700;
}
.state-card > text:nth-child(2) {
margin-top: 18rpx;
color: $ink-muted;
font-size: 24rpx;
line-height: 1.65;
}
.state-card .app-button {
margin-top: 28rpx;
}
.dialog-form {
width: 100%;
margin: 18rpx 0;
}
.dialog-form input,
.dialog-form textarea {
width: 100%;
min-height: 70rpx;
margin-top: 10rpx;
padding: 14rpx 20rpx;
box-sizing: border-box;
color: $ink;
font-size: 23rpx;
background: url("/static/assets/modules/records/transparent/module-field-frame.png")
center/100% 100% no-repeat;
}
.dialog-form text {
display: block;
margin-top: 8rpx;
color: $brand-red;
font-size: 20rpx;
}
</style>
+258 -3
View File
@@ -1,5 +1,260 @@
<!-- 页面编号R-09用途生事 --> <!-- 页面编号R-09用途物人生事时间轴与同页新增 -->
<template><ModulePage page-id="r09" /></template> <template>
<view class="timeline-page" :class="stateClasses">
<ModulePageBackground module="records" />
<view class="page-header">
<PageHeader title="人生事" action="新增" @action="createLifeEvent" />
</view>
<view v-if="timelineState === 'loading'" class="page-loading">
<AppLoading
text="正在读取人生事"
:description="`请稍候,正在整理${personName}的重要节点。`"
/>
</view>
<view v-else class="page-content">
<view class="person-lead">
<text>{{ personName }}</text>
<text>值得回望的人生节点</text>
</view>
<template v-if="timelineState === 'ready' && lifeEvents.length">
<view v-for="event in lifeEvents" :key="event.id" class="timeline-card">
<text>{{ event.year }}</text>
<text>{{ event.title }}</text>
<text>{{ event.place }}</text>
<text>{{ event.description }}</text>
</view>
<AppButton block label="新增人生事" @click="createLifeEvent" />
</template>
<view v-else class="state-card">
<text>
{{ timelineState === "error" ? "人生事暂不可用" : "还没有人生事" }}
</text>
<text>
{{
timelineState === "error"
? "请稍后重新查看,已有记录不会受到影响。"
: "从毕业、成家或重要迁居开始记录。"
}}
</text>
<AppButton
:type="timelineState === 'error' ? 'secondary' : 'primary'"
block
:label="timelineState === 'error' ? '重新查看' : '新增人生事'"
@click="
timelineState === 'error'
? (timelineState = 'ready')
: createLifeEvent()
"
/>
</view>
</view>
<AppDialog
:visible="dialogVisible"
eyebrow="人生事"
title="记录一个人生节点"
confirm-text="保存记录"
cancel-text="取消"
show-cancel
@confirm="saveLifeEvent"
@cancel="dialogVisible = false"
>
<view class="dialog-form">
<input v-model="lifeEventForm.title" placeholder="事件名称" />
<input v-model="lifeEventForm.year" placeholder="年份或日期" />
<input v-model="lifeEventForm.place" placeholder="地点(选填)" />
<textarea
v-model="lifeEventForm.description"
auto-height
placeholder="写下这段经历"
/>
<text v-if="formError">{{ formError }}</text>
</view>
</AppDialog>
<AppToast :visible="toastVisible" message="人生事已保存" />
</view>
</template>
<script setup> <script setup>
import ModulePage from "@/components/ModulePage.vue"; import { computed, onUnmounted, reactive, ref } from "vue";
import { onLoad } from "@dcloudio/uni-app";
import AppButton from "@/components/AppButton.vue";
import AppDialog from "@/components/AppDialog.vue";
import AppLoading from "@/components/AppLoading.vue";
import AppToast from "@/components/AppToast.vue";
import ModulePageBackground from "@/components/ModulePageBackground.vue";
import PageHeader from "@/components/PageHeader.vue";
const personName = ref("汤文清");
const lifeEvents = ref([
{
id: 1,
title: "大学毕业",
year: "2018 年 6 月",
place: "杭州",
description: "完成学业,带着家人的祝福走向新的生活。",
},
{
id: 2,
title: "结为连理",
year: "2022 年 10 月",
place: "汤氏祖居",
description: "在家人见证下组成新的家庭。",
},
]);
const timelineState = ref("loading");
const dialogVisible = ref(false);
const toastVisible = ref(false);
const formError = ref("");
const lifeEventForm = reactive({
title: "",
year: "",
place: "",
description: "",
});
let timer = null;
const stateClasses = computed(() => ({
"timeline-state--loading": timelineState.value === "loading",
"timeline-state--empty": timelineState.value === "empty",
"timeline-state--error": timelineState.value === "error",
}));
onLoad((q) => {
personName.value = String(q.personName || "汤文清");
timelineState.value = ["loading", "empty", "error"].includes(q.state)
? q.state
: "ready";
});
const createLifeEvent = () => {
Object.assign(lifeEventForm, {
title: "",
year: "",
place: "",
description: "",
});
formError.value = "";
dialogVisible.value = true;
};
const saveLifeEvent = () => {
if (!lifeEventForm.title.trim() || !lifeEventForm.year.trim()) {
formError.value = "请填写事件名称和年份";
return;
}
lifeEvents.value.unshift({ id: Date.now(), ...lifeEventForm });
timelineState.value = "ready";
dialogVisible.value = false;
toastVisible.value = true;
timer = setTimeout(() => (toastVisible.value = false), 1800);
};
onUnmounted(() => {
if (timer) clearTimeout(timer);
});
</script> </script>
<style scoped lang="scss">
.timeline-page {
display: flex;
min-height: 100vh;
flex-direction: column;
background: $paper;
}
.page-header,
.page-loading,
.page-content {
z-index: 1;
}
.page-loading {
min-height: calc(100vh - 100rpx);
}
.page-content {
display: flex;
flex-direction: column;
gap: 16rpx;
padding: 18rpx 24rpx 72rpx;
}
.person-lead {
display: flex;
flex-wrap: wrap;
justify-content: space-between;
gap: 8rpx 18rpx;
min-height: 62rpx;
align-items: center;
padding: 0 20rpx;
color: $ink-muted;
font-size: 22rpx;
background: url("/static/assets/modules/genealogy/transparent/section-divider.png")
center/100% 100% no-repeat;
}
.person-lead text:first-child {
color: $brand-red;
font-weight: 700;
}
.timeline-card,
.state-card {
box-sizing: border-box;
padding: 34rpx 46rpx;
background: url("/static/assets/modules/records/transparent/module-content-frame.png")
center/100% 100% no-repeat;
}
.timeline-card > text,
.state-card > text {
display: block;
}
.timeline-card > text:first-child {
color: $brand-red;
font-size: 20rpx;
}
.timeline-card > text:nth-child(2) {
margin-top: 6rpx;
color: $ink;
font-size: 31rpx;
font-weight: 700;
}
.timeline-card > text:nth-child(3) {
margin-top: 8rpx;
color: $ink-muted;
font-size: 21rpx;
}
.timeline-card > text:last-child {
margin-top: 10rpx;
color: $ink;
font-size: 23rpx;
line-height: 1.55;
}
.state-card {
min-height: 340rpx;
padding-top: 78rpx;
text-align: center;
}
.state-card > text:first-child {
color: $ink;
font-size: 35rpx;
font-weight: 700;
}
.state-card > text:nth-child(2) {
margin-top: 18rpx;
color: $ink-muted;
font-size: 24rpx;
line-height: 1.65;
}
.state-card .app-button {
margin-top: 28rpx;
}
.dialog-form {
width: 100%;
margin: 18rpx 0;
}
.dialog-form input,
.dialog-form textarea {
width: 100%;
min-height: 66rpx;
margin-top: 8rpx;
padding: 12rpx 20rpx;
box-sizing: border-box;
color: $ink;
font-size: 22rpx;
background: url("/static/assets/modules/records/transparent/module-field-frame.png")
center/100% 100% no-repeat;
}
.dialog-form text {
display: block;
margin-top: 8rpx;
color: $brand-red;
font-size: 20rpx;
}
</style>
+244 -3
View File
@@ -1,5 +1,246 @@
<!-- 页面编号R-10用途家族备忘 --> <!-- 页面编号R-10用途家族备忘列表完成状态与同页新增 -->
<template><ModulePage page-id="r10" /></template> <template>
<view class="memo-page" :class="stateClasses">
<ModulePageBackground module="records" />
<view class="page-header">
<PageHeader title="家族备忘" action="新增" @action="createMemo" />
</view>
<view v-if="memoState === 'loading'" class="page-loading">
<AppLoading
text="正在读取家族备忘"
description="请稍候,正在整理待办事项。"
/>
</view>
<view v-else class="page-content">
<template v-if="memoState === 'ready' && memos.length">
<view
v-for="memo in memos"
:key="memo.id"
class="memo-card"
:class="{ 'memo-card--done': memo.done }"
@click="toggleMemo(memo)"
>
<view>
<text>{{ memo.done ? "已完成" : "待办理" }}</text>
<text>{{ memo.due }}</text>
</view>
<text>{{ memo.title }}</text>
<text>{{ memo.description }}</text>
<text>{{ memo.done ? "点击恢复待办" : "点击标记完成" }}</text>
</view>
<AppButton block label="新增备忘" @click="createMemo" />
</template>
<view v-else class="state-card">
<text>
{{ memoState === "error" ? "家族备忘暂不可用" : "还没有备忘" }}
</text>
<text>
{{
memoState === "error"
? "请稍后重新查看,已有备忘不会受到影响。"
: "把需要家人共同记住的事情写在这里。"
}}
</text>
<AppButton
:type="memoState === 'error' ? 'secondary' : 'primary'"
block
:label="memoState === 'error' ? '重新查看' : '新增备忘'"
@click="memoState === 'error' ? (memoState = 'ready') : createMemo()"
/>
</view>
</view>
<AppDialog
:visible="dialogVisible"
eyebrow="家族备忘"
title="新增一项备忘"
confirm-text="保存备忘"
cancel-text="取消"
show-cancel
@confirm="saveMemo"
@cancel="dialogVisible = false"
>
<view class="dialog-form">
<input v-model="memoForm.title" placeholder="备忘标题" />
<input v-model="memoForm.due" placeholder="截止日期或时间" />
<textarea
v-model="memoForm.description"
auto-height
placeholder="补充具体事项"
/>
<text v-if="formError">{{ formError }}</text>
</view>
</AppDialog>
<AppToast :visible="toastVisible" message="备忘已更新" />
</view>
</template>
<script setup> <script setup>
import ModulePage from "@/components/ModulePage.vue"; import { computed, onUnmounted, reactive, ref } from "vue";
import { onLoad } from "@dcloudio/uni-app";
import AppButton from "@/components/AppButton.vue";
import AppDialog from "@/components/AppDialog.vue";
import AppLoading from "@/components/AppLoading.vue";
import AppToast from "@/components/AppToast.vue";
import ModulePageBackground from "@/components/ModulePageBackground.vue";
import PageHeader from "@/components/PageHeader.vue";
const memos = ref([
{
id: 1,
title: "修谱资料整理",
due: "本月底前",
description: "补充老照片中的人物姓名和拍摄时间。",
done: false,
},
{
id: 2,
title: "重阳敬老活动",
due: "10 月 11 日上午",
description: "在祠堂集合,并确认接送长辈的车辆。",
done: true,
},
]);
const memoState = ref("loading");
const dialogVisible = ref(false);
const toastVisible = ref(false);
const formError = ref("");
const memoForm = reactive({ title: "", due: "", description: "" });
let timer = null;
const stateClasses = computed(() => ({
"memo-state--loading": memoState.value === "loading",
"memo-state--empty": memoState.value === "empty",
"memo-state--error": memoState.value === "error",
}));
onLoad((q) => {
memoState.value = ["loading", "empty", "error"].includes(q.state)
? q.state
: "ready";
});
const showToast = () => {
toastVisible.value = true;
if (timer) clearTimeout(timer);
timer = setTimeout(() => (toastVisible.value = false), 1800);
};
const toggleMemo = (memo) => {
memo.done = !memo.done;
showToast();
};
const createMemo = () => {
Object.assign(memoForm, { title: "", due: "", description: "" });
formError.value = "";
dialogVisible.value = true;
};
const saveMemo = () => {
if (!memoForm.title.trim()) {
formError.value = "请填写备忘标题";
return;
}
memos.value.unshift({ id: Date.now(), ...memoForm, done: false });
memoState.value = "ready";
dialogVisible.value = false;
showToast();
};
onUnmounted(() => {
if (timer) clearTimeout(timer);
});
</script> </script>
<style scoped lang="scss">
.memo-page {
display: flex;
min-height: 100vh;
flex-direction: column;
background: $paper;
}
.page-header,
.page-loading,
.page-content {
z-index: 1;
}
.page-loading {
min-height: calc(100vh - 100rpx);
}
.page-content {
display: flex;
flex-direction: column;
gap: 16rpx;
padding: 18rpx 24rpx 72rpx;
}
.memo-card,
.state-card {
box-sizing: border-box;
padding: 34rpx 46rpx;
background: url("/static/assets/modules/records/transparent/module-content-frame.png")
center/100% 100% no-repeat;
}
.memo-card > view {
display: flex;
flex-wrap: wrap;
justify-content: space-between;
gap: 6rpx 18rpx;
color: $brand-red;
font-size: 20rpx;
}
.memo-card > text,
.state-card > text {
display: block;
}
.memo-card > text:nth-child(2) {
margin-top: 8rpx;
color: $ink;
font-size: 31rpx;
font-weight: 700;
}
.memo-card > text:nth-child(3) {
margin-top: 9rpx;
color: $ink-muted;
font-size: 23rpx;
line-height: 1.55;
}
.memo-card > text:last-child {
margin-top: 10rpx;
color: $brand-red;
font-size: 20rpx;
}
.memo-card--done {
opacity: 0.68;
}
.state-card {
min-height: 340rpx;
padding-top: 78rpx;
text-align: center;
}
.state-card > text:first-child {
color: $ink;
font-size: 35rpx;
font-weight: 700;
}
.state-card > text:nth-child(2) {
margin-top: 18rpx;
color: $ink-muted;
font-size: 24rpx;
line-height: 1.65;
}
.state-card .app-button {
margin-top: 28rpx;
}
.dialog-form {
width: 100%;
margin: 18rpx 0;
}
.dialog-form input,
.dialog-form textarea {
width: 100%;
min-height: 68rpx;
margin-top: 9rpx;
padding: 13rpx 20rpx;
box-sizing: border-box;
color: $ink;
font-size: 23rpx;
background: url("/static/assets/modules/records/transparent/module-field-frame.png")
center/100% 100% no-repeat;
}
.dialog-form text {
display: block;
margin-top: 8rpx;
color: $brand-red;
font-size: 20rpx;
}
</style>
+264 -3
View File
@@ -1,5 +1,266 @@
<!-- 页面编号R-11用途功德记录 --> <!-- 页面编号R-11用途功德记录贡献汇总与同页新增 -->
<template><ModulePage page-id="r11" /></template> <template>
<view class="merit-page" :class="stateClasses">
<ModulePageBackground module="records" />
<view class="page-header">
<PageHeader title="功德记录" action="新增" @action="createMerit" />
</view>
<view v-if="meritState === 'loading'" class="page-loading">
<AppLoading
text="正在整理功德记录"
description="请稍候,正在读取家人对家族事务的支持。"
/>
</view>
<view v-else class="page-content">
<view v-if="meritState === 'ready'" class="merit-summary">
<text>共同贡献</text>
<text>{{ totalContribution }} </text>
<text>每一次时间物资与心力的付出都值得被记住</text>
</view>
<template v-if="meritState === 'ready' && meritRecords.length">
<view v-for="merit in meritRecords" :key="merit.id" class="merit-card">
<text>{{ merit.category }}</text>
<text>{{ merit.title }}</text>
<text>{{ merit.contributor }} · {{ merit.date }}</text>
<text>{{ merit.description }}</text>
</view>
<AppButton block label="新增功德记录" @click="createMerit" />
</template>
<view v-else class="state-card">
<text>
{{ meritState === "error" ? "功德记录暂不可用" : "还没有功德记录" }}
</text>
<text>
{{
meritState === "error"
? "请稍后重新查看,已有记录不会受到影响。"
: "记录第一份对家族事务的时间、物资或心力支持。"
}}
</text>
<AppButton
:type="meritState === 'error' ? 'secondary' : 'primary'"
block
:label="meritState === 'error' ? '重新查看' : '新增记录'"
@click="
meritState === 'error' ? (meritState = 'ready') : createMerit()
"
/>
</view>
</view>
<AppDialog
:visible="dialogVisible"
eyebrow="功德记录"
title="记下一份家族贡献"
confirm-text="保存记录"
cancel-text="取消"
show-cancel
@confirm="saveMerit"
@cancel="dialogVisible = false"
>
<view class="dialog-form">
<input v-model="meritForm.title" placeholder="贡献事项" />
<input v-model="meritForm.contributor" placeholder="贡献人" />
<input v-model="meritForm.date" placeholder="日期" />
<textarea
v-model="meritForm.description"
auto-height
placeholder="说明时间、物资或具体帮助"
/>
<text v-if="formError">{{ formError }}</text>
</view>
</AppDialog>
<AppToast :visible="toastVisible" message="功德记录已保存" />
</view>
</template>
<script setup> <script setup>
import ModulePage from "@/components/ModulePage.vue"; import { computed, onUnmounted, reactive, ref } from "vue";
import { onLoad } from "@dcloudio/uni-app";
import AppButton from "@/components/AppButton.vue";
import AppDialog from "@/components/AppDialog.vue";
import AppLoading from "@/components/AppLoading.vue";
import AppToast from "@/components/AppToast.vue";
import ModulePageBackground from "@/components/ModulePageBackground.vue";
import PageHeader from "@/components/PageHeader.vue";
const meritRecords = ref([
{
id: 1,
category: "共同修缮",
title: "修缮祠堂",
contributor: "汤氏家人共同参与",
date: "2024 年春",
description: "协助整理院落、修补门窗并登记旧物。",
},
{
id: 2,
category: "奖学助学",
title: "支持后辈勤学",
contributor: "家族教育小组",
date: "2024 年夏",
description: "为家族中努力求学的孩子提供书籍与经验分享。",
},
]);
const totalContribution = computed(() => meritRecords.value.length);
const meritState = ref("loading");
const dialogVisible = ref(false);
const toastVisible = ref(false);
const formError = ref("");
const meritForm = reactive({
title: "",
contributor: "",
date: "",
description: "",
category: "家族贡献",
});
let timer = null;
const stateClasses = computed(() => ({
"merit-state--loading": meritState.value === "loading",
"merit-state--empty": meritState.value === "empty",
"merit-state--error": meritState.value === "error",
}));
onLoad((q) => {
meritState.value = ["loading", "empty", "error"].includes(q.state)
? q.state
: "ready";
});
const createMerit = () => {
Object.assign(meritForm, {
title: "",
contributor: "",
date: "",
description: "",
category: "家族贡献",
});
formError.value = "";
dialogVisible.value = true;
};
const saveMerit = () => {
if (!meritForm.title.trim() || !meritForm.contributor.trim()) {
formError.value = "请填写贡献事项和贡献人";
return;
}
meritRecords.value.unshift({ id: Date.now(), ...meritForm });
meritState.value = "ready";
dialogVisible.value = false;
toastVisible.value = true;
timer = setTimeout(() => (toastVisible.value = false), 1800);
};
onUnmounted(() => {
if (timer) clearTimeout(timer);
});
</script> </script>
<style scoped lang="scss">
.merit-page {
display: flex;
min-height: 100vh;
flex-direction: column;
background: $paper;
}
.page-header,
.page-loading,
.page-content {
z-index: 1;
}
.page-loading {
min-height: calc(100vh - 100rpx);
}
.page-content {
display: flex;
flex-direction: column;
gap: 16rpx;
padding: 18rpx 24rpx 72rpx;
}
.merit-summary,
.merit-card,
.state-card {
box-sizing: border-box;
padding: 34rpx 46rpx;
background: url("/static/assets/modules/records/transparent/module-content-frame.png")
center/100% 100% no-repeat;
}
.merit-summary {
text-align: center;
}
.merit-summary > text,
.merit-card > text,
.state-card > text {
display: block;
}
.merit-summary > text:first-child {
color: $brand-red;
font-size: 21rpx;
}
.merit-summary > text:nth-child(2) {
margin-top: 5rpx;
color: $ink;
font-size: 38rpx;
font-weight: 700;
}
.merit-summary > text:last-child {
margin-top: 9rpx;
color: $ink-muted;
font-size: 22rpx;
line-height: 1.5;
}
.merit-card > text:first-child {
color: $brand-red;
font-size: 20rpx;
}
.merit-card > text:nth-child(2) {
margin-top: 6rpx;
color: $ink;
font-size: 31rpx;
font-weight: 700;
}
.merit-card > text:nth-child(3) {
margin-top: 8rpx;
color: $ink-muted;
font-size: 21rpx;
}
.merit-card > text:last-child {
margin-top: 9rpx;
color: $ink;
font-size: 23rpx;
line-height: 1.55;
}
.state-card {
min-height: 340rpx;
padding-top: 78rpx;
text-align: center;
}
.state-card > text:first-child {
color: $ink;
font-size: 35rpx;
font-weight: 700;
}
.state-card > text:nth-child(2) {
margin-top: 18rpx;
color: $ink-muted;
font-size: 24rpx;
line-height: 1.65;
}
.state-card .app-button {
margin-top: 28rpx;
}
.dialog-form {
width: 100%;
margin: 14rpx 0;
}
.dialog-form input,
.dialog-form textarea {
width: 100%;
min-height: 62rpx;
margin-top: 7rpx;
padding: 11rpx 18rpx;
box-sizing: border-box;
color: $ink;
font-size: 22rpx;
background: url("/static/assets/modules/records/transparent/module-field-frame.png")
center/100% 100% no-repeat;
}
.dialog-form text {
display: block;
margin-top: 7rpx;
color: $brand-red;
font-size: 20rpx;
}
</style>
+215 -152
View File
@@ -25,13 +25,20 @@
</view> </view>
</view> </view>
<view class="tree-stage"> <view
<view v-if="treeState === 'tree'" class="generation-rail"> class="tree-stage"
:class="{ 'tree-stage--lineage': treeState === 'tree' }"
>
<view
v-if="treeState === 'tree'"
class="generation-rail"
:style="generationRailStyle"
>
<view <view
v-for="row in generationRows" v-for="row in generationRows"
:key="row.generation" :key="row.generation"
class="generation-band" class="generation-band"
:style="generationRowStyle(row)" :style="generationBandStyle(row)"
> >
<image <image
src="/static/assets/modules/genealogy/transparent/section-divider.png" src="/static/assets/modules/genealogy/transparent/section-divider.png"
@@ -50,10 +57,12 @@
scroll-x scroll-x
:scroll-left="initialScrollLeft" :scroll-left="initialScrollLeft"
:show-scrollbar="false" :show-scrollbar="false"
:style="treeScrollStyle"
> >
<view <view
class="tree-canvas" class="tree-canvas"
:class="{ 'tree-canvas--state': treeState !== 'tree' }" :class="{ 'tree-canvas--state': treeState !== 'tree' }"
:style="treeMetricsStyle"
> >
<AppLoading <AppLoading
v-if="treeState === 'loading'" v-if="treeState === 'loading'"
@@ -66,24 +75,19 @@
<text>同代分支可左右查看</text> <text>同代分支可左右查看</text>
</view> </view>
<view class="lineage-connector lineage-connector--root" /> <view
<view class="lineage-connector lineage-connector--root-branch" /> v-for="connector in lineageConnectors"
<view class="lineage-connector lineage-connector--gen13-left" /> :key="connector.id"
<view class="lineage-connector lineage-connector--gen13-right" /> class="lineage-connector"
<view class="lineage-connector lineage-connector--left-trunk" /> :style="connector.style"
<view class="lineage-connector lineage-connector--left-branch" /> />
<view class="lineage-connector lineage-connector--gen14-left" />
<view class="lineage-connector lineage-connector--gen14-middle" />
<view class="lineage-connector lineage-connector--right-trunk" />
<view class="lineage-connector lineage-connector--right-branch" />
<view class="lineage-connector lineage-connector--gen14-right" />
<view <view
v-for="member in members" v-for="member in layoutMembers"
:key="member.id" :key="member.id"
class="member-node" class="member-node"
:class="{ 'member-node--selected': selected?.id === member.id }" :class="{ 'member-node--selected': selected?.id === member.id }"
:style="nodeStyle(member)" :style="nodeGridStyle(member)"
@click="selected = member" @click="selected = member"
> >
<image <image
@@ -95,12 +99,14 @@
" "
mode="scaleToFill" mode="scaleToFill"
/> />
<view class="member-node__copy">
<text class="node-name">{{ member.name }}</text> <text class="node-name">{{ member.name }}</text>
<text class="node-relation" <text class="node-relation"
>{{ member.relation }} · {{ member.branch }}</text >{{ member.relation }} · {{ member.branch }}</text
> >
<text class="node-years">{{ member.years }}</text> <text class="node-years">{{ member.years }}</text>
</view> </view>
</view>
</template> </template>
<view v-else class="tree-state-card"> <view v-else class="tree-state-card">
@@ -171,11 +177,15 @@ const genealogyId = ref("");
const treeState = ref("loading"); const treeState = ref("loading");
const selected = ref(null); const selected = ref(null);
const initialScrollLeft = ref(90); const initialScrollLeft = ref(90);
const generationRows = [ const GRID_UNIT = 5;
{ generation: 12, label: "第十二世", summary: "始祖", y: 135 }, const NODE_HALF_HEIGHT = 47;
{ generation: 13, label: "第十三世", summary: "两房", y: 350 }, const MEMBER_GAP = 250;
{ generation: 14, label: "第十四世", summary: "三支", y: 650 }, const GENERATION_GAP = 220;
]; const generationMeta = {
12: { label: "第十二世", summary: "始祖" },
13: { label: "第十三世", summary: "两房" },
14: { label: "第十四世", summary: "三支" },
};
const members = ref([ const members = ref([
{ {
id: 101, id: 101,
@@ -184,8 +194,6 @@ const members = ref([
years: "1940—2012", years: "1940—2012",
generation: 12, generation: 12,
branch: "主支", branch: "主支",
x: 450,
y: 135,
}, },
{ {
id: 102, id: 102,
@@ -193,9 +201,8 @@ const members = ref([
relation: "长子", relation: "长子",
years: "1965—", years: "1965—",
generation: 13, generation: 13,
parentId: 101,
branch: "长房", branch: "长房",
x: 275,
y: 350,
}, },
{ {
id: 103, id: 103,
@@ -203,9 +210,8 @@ const members = ref([
relation: "次子", relation: "次子",
years: "1968—", years: "1968—",
generation: 13, generation: 13,
parentId: 101,
branch: "二房", branch: "二房",
x: 625,
y: 350,
}, },
{ {
id: 104, id: 104,
@@ -213,9 +219,8 @@ const members = ref([
relation: "长孙", relation: "长孙",
years: "1992—", years: "1992—",
generation: 14, generation: 14,
parentId: 102,
branch: "长房", branch: "长房",
x: 150,
y: 650,
}, },
{ {
id: 105, id: 105,
@@ -223,9 +228,8 @@ const members = ref([
relation: "长孙女", relation: "长孙女",
years: "1995—", years: "1995—",
generation: 14, generation: 14,
parentId: 102,
branch: "长房", branch: "长房",
x: 400,
y: 650,
}, },
{ {
id: 106, id: 106,
@@ -233,12 +237,132 @@ const members = ref([
relation: "次孙", relation: "次孙",
years: "1998—", years: "1998—",
generation: 14, generation: 14,
parentId: 103,
branch: "二房", branch: "二房",
x: 690,
y: 650,
}, },
]); ]);
const snapToGrid = (value) => Math.ceil(value / GRID_UNIT) * GRID_UNIT;
const layoutMembers = computed(() => {
const generations = Array.from(
new Set(members.value.map((member) => Number(member.generation))),
).sort((left, right) => left - right);
const groups = new Map(
generations.map((generation) => [
generation,
members.value
.filter((member) => Number(member.generation) === generation)
.sort(
(left, right) =>
Number(left.parentId || 0) - Number(right.parentId || 0) ||
Number(left.id) - Number(right.id),
),
]),
);
const maxCount = Math.max(1, ...Array.from(groups.values()).map((group) => group.length));
const canvasWidth = Math.max(720, maxCount * MEMBER_GAP + 100);
return generations.flatMap((generation, generationIndex) => {
const group = groups.get(generation) || [];
const occupiedWidth = Math.max(0, (group.length - 1) * MEMBER_GAP);
const startX = (canvasWidth - occupiedWidth) / 2;
return group.map((member, memberIndex) => ({
...member,
x: snapToGrid(startX + memberIndex * MEMBER_GAP),
y: snapToGrid(135 + generationIndex * GENERATION_GAP),
}));
});
});
const treeMetrics = computed(() => {
const memberList = layoutMembers.value;
const maxX = Math.max(0, ...memberList.map((member) => member.x));
const maxY = Math.max(0, ...memberList.map((member) => member.y));
const width = Math.max(720, snapToGrid(maxX + 210));
const height = Math.max(640, snapToGrid(maxY + 250));
return {
width,
height,
columns: width / GRID_UNIT,
rows: height / GRID_UNIT,
};
});
const treeMetricsStyle = computed(() => ({
width: `${treeMetrics.value.width}rpx`,
height: `${treeMetrics.value.height}rpx`,
gridTemplateColumns: `repeat(${treeMetrics.value.columns}, ${GRID_UNIT}rpx)`,
gridTemplateRows: `repeat(${treeMetrics.value.rows}, ${GRID_UNIT}rpx)`,
}));
const generationRailStyle = computed(() => ({
height: `${treeMetrics.value.height}rpx`,
gridTemplateRows: `repeat(${treeMetrics.value.rows}, ${GRID_UNIT}rpx)`,
}));
const treeScrollStyle = computed(() => ({
height: `${treeMetrics.value.height}rpx`,
}));
const generationRows = computed(() => {
const groups = new Map();
layoutMembers.value.forEach((member) => {
const group = groups.get(member.generation) || [];
group.push(member);
groups.set(member.generation, group);
});
return Array.from(groups.entries())
.sort(([left], [right]) => left - right)
.map(([generation, group]) => {
const y = Math.min(...group.map((member) => member.y));
return {
generation,
label: generationMeta[generation]?.label || `${generation}`,
summary: generationMeta[generation]?.summary || `${group.length} 位成员`,
y,
};
});
});
const generationBandStyle = (row) => ({
gridRow: `${Math.max(1, Math.round((row.y - 88) / GRID_UNIT) + 1)} / span 12`,
});
const lineageConnectors = computed(() => {
const memberById = new Map(
layoutMembers.value.map((member) => [member.id, member]),
);
const childrenByParent = new Map();
layoutMembers.value.forEach((member) => {
if (!member.parentId || !memberById.has(member.parentId)) return;
const children = childrenByParent.get(member.parentId) || [];
children.push(member);
childrenByParent.set(member.parentId, children);
});
const connectors = [];
childrenByParent.forEach((children, parentId) => {
const parent = memberById.get(parentId);
const childTop = Math.min(...children.map((child) => child.y - NODE_HALF_HEIGHT));
const parentBottom = parent.y + NODE_HALF_HEIGHT;
const branchY = snapToGrid((parentBottom + childTop) / 2);
const minX = Math.min(...children.map((child) => child.x));
const maxX = Math.max(...children.map((child) => child.x));
const verticalStyle = (x, top, bottom) => ({
gridColumn: `${Math.round(x / GRID_UNIT) + 1} / span 1`,
gridRow: `${Math.round(top / GRID_UNIT) + 1} / ${Math.round(bottom / GRID_UNIT) + 1}`,
});
connectors.push({
id: `${parentId}-trunk`,
style: verticalStyle(parent.x, parentBottom, branchY),
});
connectors.push({
id: `${parentId}-branch`,
style: {
gridColumn: `${Math.round(minX / GRID_UNIT) + 1} / ${Math.round(maxX / GRID_UNIT) + 2}`,
gridRow: `${Math.round(branchY / GRID_UNIT) + 1} / span 1`,
},
});
children.forEach((child) => connectors.push({
id: `${parentId}-${child.id}`,
style: verticalStyle(child.x, branchY, child.y - NODE_HALF_HEIGHT),
}));
});
return connectors;
});
const stateCopy = computed( const stateCopy = computed(
() => () =>
({ ({
@@ -279,18 +403,15 @@ onLoad((query) => {
} }
genealogyContext.setCurrentGenealogyId(genealogyId.value); genealogyContext.setCurrentGenealogyId(genealogyId.value);
selected.value = selected.value =
members.value.find( layoutMembers.value.find(
(item) => String(item.id) === String(query.selectedId), (item) => String(item.id) === String(query.selectedId),
) || members.value[0]; ) || layoutMembers.value[0];
treeState.value = "tree"; treeState.value = "tree";
}); });
const nodeStyle = (member) => ({ const nodeGridStyle = (member) => ({
left: `${member.x}rpx`, gridColumn: `${member.x / 5 + 1}`,
top: `${member.y}rpx`, gridRow: `${member.y / 5 + 1}`,
});
const generationRowStyle = (row) => ({
top: `${row.y - 88}rpx`,
}); });
const handleStateAction = () => { const handleStateAction = () => {
if (treeState.value === "empty") { if (treeState.value === "empty") {
@@ -299,7 +420,7 @@ const handleStateAction = () => {
}); });
return; return;
} }
selected.value = members.value[0]; selected.value = layoutMembers.value[0];
treeState.value = "tree"; treeState.value = "tree";
}; };
const toMember = () => const toMember = () =>
@@ -322,17 +443,16 @@ const toAddRelative = () =>
<style scoped lang="scss"> <style scoped lang="scss">
.tree-page { .tree-page {
position: relative; display: grid;
height: 100vh; height: 100vh;
grid-template-rows: auto auto minmax(0, 1fr);
overflow: hidden; overflow: hidden;
background: $paper; background: $paper;
} }
.tree-page__header, .tree-page__header,
.tree-toolbar, .tree-toolbar,
.tree-stage, .tree-stage {
.member-sheet { z-index: 1;
position: relative;
z-index: 2;
} }
.tree-toolbar { .tree-toolbar {
display: flex; display: flex;
@@ -361,8 +481,13 @@ const toAddRelative = () =>
font-size: 23rpx; font-size: 23rpx;
} }
.tree-stage { .tree-stage {
position: relative; min-height: 0;
height: calc(100vh - 272rpx); overflow-y: auto;
}
.tree-stage--lineage {
display: grid;
grid-template-columns: 190rpx minmax(0, 1fr);
align-items: start;
} }
.tree-scroll { .tree-scroll {
width: 100%; width: 100%;
@@ -370,40 +495,37 @@ const toAddRelative = () =>
white-space: nowrap; white-space: nowrap;
} }
.generation-rail { .generation-rail {
position: absolute; display: grid;
top: 0;
bottom: 0;
left: 0;
z-index: 4; z-index: 4;
width: 190rpx; width: 190rpx;
height: 100%;
box-sizing: border-box; box-sizing: border-box;
border-right: 1rpx solid rgba(143, 108, 63, 0.2); border-right: 1rpx solid rgba(143, 108, 63, 0.2);
} }
.tree-scroll--lineage { .tree-scroll--lineage {
width: calc(100% - 190rpx); min-width: 0;
margin-left: 190rpx;
} }
.tree-canvas { .tree-canvas {
position: relative; display: grid;
width: 900rpx;
height: 900rpx;
margin: 0 16rpx; margin: 0 16rpx;
} }
.tree-canvas--state { .tree-canvas--state {
display: block;
width: calc(100vw - 32rpx); width: calc(100vw - 32rpx);
} }
.lineage-pan-cue { .lineage-pan-cue {
position: absolute; grid-area: 1 / 1 / -1 / -1;
top: 8rpx; align-self: start;
right: 22rpx; justify-self: end;
z-index: 3; z-index: 3;
margin: 8rpx 22rpx 0 0;
color: #9a7748; color: #9a7748;
font-size: 20rpx; font-size: 20rpx;
letter-spacing: 1rpx; letter-spacing: 1rpx;
} }
.generation-band { .generation-band {
position: absolute; display: grid;
left: 12rpx; justify-self: center;
z-index: 3; z-index: 3;
width: 166rpx; width: 166rpx;
height: 58rpx; height: 58rpx;
@@ -411,14 +533,15 @@ const toAddRelative = () =>
font-size: 22rpx; font-size: 22rpx;
text-align: center; text-align: center;
} }
.generation-band image,
.generation-band__copy {
grid-area: 1 / 1;
}
.generation-band image { .generation-band image {
position: absolute;
inset: 0;
width: 100%; width: 100%;
height: 100%; height: 100%;
} }
.generation-band__copy { .generation-band__copy {
position: relative;
z-index: 1; z-index: 1;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -431,78 +554,15 @@ const toAddRelative = () =>
font-size: 19rpx; font-size: 19rpx;
} }
.lineage-connector { .lineage-connector {
position: absolute; align-self: stretch;
justify-self: stretch;
z-index: 1; z-index: 1;
background: #b78a42; background: #b78a42;
} }
.lineage-connector--root {
left: 449rpx;
top: 177rpx;
width: 2rpx;
height: 88rpx;
}
.lineage-connector--root-branch {
left: 275rpx;
top: 264rpx;
width: 350rpx;
height: 2rpx;
}
.lineage-connector--gen13-left {
left: 274rpx;
top: 264rpx;
width: 2rpx;
height: 44rpx;
}
.lineage-connector--gen13-right {
left: 624rpx;
top: 264rpx;
width: 2rpx;
height: 44rpx;
}
.lineage-connector--left-trunk {
left: 274rpx;
top: 392rpx;
width: 2rpx;
height: 149rpx;
}
.lineage-connector--left-branch {
left: 150rpx;
top: 540rpx;
width: 250rpx;
height: 2rpx;
}
.lineage-connector--gen14-left {
left: 149rpx;
top: 540rpx;
width: 2rpx;
height: 68rpx;
}
.lineage-connector--gen14-middle {
left: 399rpx;
top: 540rpx;
width: 2rpx;
height: 68rpx;
}
.lineage-connector--right-trunk {
left: 624rpx;
top: 392rpx;
width: 2rpx;
height: 149rpx;
}
.lineage-connector--right-branch {
left: 625rpx;
top: 540rpx;
width: 65rpx;
height: 2rpx;
}
.lineage-connector--gen14-right {
left: 689rpx;
top: 540rpx;
width: 2rpx;
height: 68rpx;
}
.member-node { .member-node {
position: absolute; display: grid;
align-self: start;
justify-self: start;
z-index: 2; z-index: 2;
width: 224rpx; width: 224rpx;
height: 86rpx; height: 86rpx;
@@ -510,16 +570,19 @@ const toAddRelative = () =>
text-align: center; text-align: center;
} }
.member-node__skin { .member-node__skin {
position: absolute;
inset: 0;
width: 100%; width: 100%;
height: 100%; height: 100%;
} }
.member-node__skin,
.member-node__copy {
grid-area: 1 / 1;
}
.member-node__copy {
z-index: 1;
}
.node-name, .node-name,
.node-relation, .node-relation,
.node-years { .node-years {
position: relative;
z-index: 1;
display: block; display: block;
} }
.node-name { .node-name {
@@ -547,22 +610,23 @@ const toAddRelative = () =>
color: $brand-red; color: $brand-red;
} }
.tree-state-card { .tree-state-card {
position: relative; display: grid;
z-index: 2; z-index: 2;
width: calc(100% - 48rpx); width: calc(100% - 48rpx);
height: 360rpx; min-height: 360rpx;
margin: 150rpx 24rpx 0; margin: 150rpx 24rpx 0;
text-align: center; text-align: center;
white-space: normal; white-space: normal;
} }
.tree-state-card__skin { .tree-state-card__skin {
position: absolute;
inset: 0;
width: 100%; width: 100%;
height: 100%; height: 100%;
} }
.tree-state-card__skin,
.tree-state-card__content {
grid-area: 1 / 1;
}
.tree-state-card__content { .tree-state-card__content {
position: relative;
z-index: 1; z-index: 1;
padding: 62rpx 58rpx 42rpx; padding: 62rpx 58rpx 42rpx;
} }
@@ -588,19 +652,20 @@ const toAddRelative = () =>
line-height: 1.65; line-height: 1.65;
} }
.tree-state-card__action { .tree-state-card__action {
position: relative; display: grid;
width: 360rpx; width: 360rpx;
height: 70rpx; min-height: 70rpx;
margin: 22rpx auto 0; margin: 22rpx auto 0;
} }
.tree-state-card__action image { .tree-state-card__action image {
position: absolute;
inset: 0;
width: 100%; width: 100%;
height: 100%; height: 100%;
} }
.tree-state-card__action image,
.tree-state-card__action text {
grid-area: 1 / 1;
}
.tree-state-card__action text { .tree-state-card__action text {
position: relative;
z-index: 1; z-index: 1;
display: flex; display: flex;
align-items: center; align-items: center;
@@ -616,7 +681,7 @@ const toAddRelative = () =>
bottom: calc(14rpx + env(safe-area-inset-bottom)); bottom: calc(14rpx + env(safe-area-inset-bottom));
left: 18rpx; left: 18rpx;
display: flex; display: flex;
height: 240rpx; min-height: 240rpx;
flex-direction: column; flex-direction: column;
padding: 38rpx 44rpx 24rpx; padding: 38rpx 44rpx 24rpx;
box-sizing: border-box; box-sizing: border-box;
@@ -629,7 +694,6 @@ const toAddRelative = () =>
pointer-events: none; pointer-events: none;
} }
.member-sheet__copy { .member-sheet__copy {
position: relative;
z-index: 1; z-index: 1;
} }
.sheet-name { .sheet-name {
@@ -646,7 +710,6 @@ const toAddRelative = () =>
font-size: 22rpx; font-size: 22rpx;
} }
.sheet-actions { .sheet-actions {
position: relative;
z-index: 1; z-index: 1;
display: flex; display: flex;
justify-content: center; justify-content: center;
@@ -667,7 +730,7 @@ const toAddRelative = () =>
gap: 14rpx; gap: 14rpx;
} }
.member-sheet { .member-sheet {
height: 240rpx; min-height: 240rpx;
padding-top: 32rpx; padding-top: 32rpx;
padding-right: 36rpx; padding-right: 36rpx;
padding-left: 36rpx; padding-left: 36rpx;
+124 -240
View File
@@ -1,4 +1,4 @@
<!-- 页面编号T-03用途成员档案详情与失败状态页面设计阶段使用本地模拟数据 --> <!-- 页面编号T-03用途 personId 展示成员档案并进入其真实成员状态 -->
<template> <template>
<view <view
class="member-page" class="member-page"
@@ -8,73 +8,57 @@
}" }"
> >
<ModulePageBackground module="tree" /> <ModulePageBackground module="tree" />
<view class="member-page__header" <view class="member-page__header">
><PageHeader <PageHeader title="成员档案" :action="memberState === 'detail' && canEdit ? '编辑' : ''" @action="toEdit" />
title="成员档案"
:action="memberState === 'detail' ? '编辑' : ''"
@action="toEdit"
/></view>
<view class="member-panel">
<image
class="member-panel__skin"
src="/static/assets/modules/tree/transparent/t01-state-panel.png"
mode="scaleToFill"
/>
<AppLoading
v-if="memberState === 'loading'"
text="正在读取成员档案"
description="请稍候,正在整理成员资料。"
/>
<view v-else-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> </view>
<view class="member-context">
<text>{{ genealogyName }}</text>
<text>{{ memberState === "detail" ? "成员身份与亲属关系" : "请重新选择成员" }}</text>
</view>
<view class="member-panel">
<AppLoading v-if="memberState === 'loading'" text="正在读取成员档案" description="请稍候,正在整理成员资料。" />
<view v-else-if="memberState === 'detail' && member" class="member-detail">
<view class="member-heading">
<view class="member-heading__seal"><text>{{ member.name.slice(0, 1) }}</text></view>
<view>
<text>{{ member.name }}</text>
<text> {{ member.generation }} · {{ member.relation }} · {{ member.branch }}</text>
</view>
</view>
<view class="member-status-entry" @click="openMemberState(member.status)">
<text>{{ statusLabel }}</text>
<text>{{ statusDescription }}</text>
<text>查看</text>
</view>
<text class="member-section-title">基本资料</text> <text class="member-section-title">基本资料</text>
<view v-for="item in details" :key="item.label" class="member-info-row"> <view v-for="item in details" :key="item.label" class="member-info-row">
<image <text>{{ item.label }}</text><text>{{ item.value || "未填写" }}</text>
src="/static/assets/modules/tree/transparent/t07-search-input-frame.png"
mode="scaleToFill"
/><text>{{ item.label }}</text
><text>{{ item.value }}</text>
</view> </view>
<text class="member-section-title member-section-title--relation"
>亲属关系</text <text class="member-section-title member-section-title--relation">亲属关系</text>
>
<view class="member-relatives"> <view class="member-relatives">
<text>长子 · 汤正国</text><text>次子 · 汤正华</text> <view v-for="relative in member.relatives" :key="relative.id" @click="openRelative(relative)">
<text>{{ relative.relation }}</text><text>{{ relative.name }}</text>
</view> </view>
<view class="member-profile-action" @click="toEdit"> <text v-if="!member.relatives.length">尚未记录可查看的亲属</text>
<image
src="/static/assets/foundation/transparent/a01-scroll-primary-v3.png"
mode="aspectFit"
/><text>完善成员档案</text>
</view> </view>
<view v-if="canEdit" class="member-profile-action" @click="toEdit"><text>完善成员档案</text></view>
</view> </view>
<view v-else class="member-error"> <view v-else class="member-error">
<text>成员档案暂不可用</text <text>成员档案暂不可用</text>
><text>请从世系树重新选择成员当前没有可展示的个人资料</text> <text>{{ errorMessage }}</text>
<view class="member-profile-action" @click="toTree" <view class="member-profile-action" @click="toTree"><text>返回世系树</text></view>
><image
src="/static/assets/foundation/transparent/a01-scroll-primary-v3.png"
mode="aspectFit"
/><text>返回世系树</text></view
>
</view> </view>
</view> </view>
</view> </view>
</template> </template>
<script setup> <script setup>
import { computed, ref } from "vue"; import { computed, ref } from "vue";
import { onLoad } from "@dcloudio/uni-app"; import { onLoad } from "@dcloudio/uni-app";
@@ -82,203 +66,103 @@ import AppLoading from "@/components/AppLoading.vue";
import ModulePageBackground from "@/components/ModulePageBackground.vue"; import ModulePageBackground from "@/components/ModulePageBackground.vue";
import PageHeader from "@/components/PageHeader.vue"; import PageHeader from "@/components/PageHeader.vue";
import { genealogyContext } from "@/utils/genealogy-context.js"; import { genealogyContext } from "@/utils/genealogy-context.js";
const genealogyId = ref(""); const genealogyId = ref("");
const personId = ref(""); const personId = ref("");
const memberState = ref("loading"); const memberState = ref("loading");
const member = ref({ const member = ref(null);
id: 101, const errorMessage = ref("");
name: "汤文远", const genealogyName = ref("汤氏家谱");
generation: 12,
relation: "始祖", const memberFixtures = {
branch: "主支", 101: {
generationName: "文字辈", id: 101, name: "汤文远", generation: 12, relation: "始祖", branch: "主支", generationName: "文字辈",
birthDate: "1940年3月", birthDate: "1940年3月", years: "1940—2012", birthplace: "河南南阳", status: "deceased", canEdit: true,
years: "1940—2012", relatives: [{ id: 102, name: "汤正国", relation: "长子" }, { id: 103, name: "汤正华", relation: "次子" }],
}); },
const details = computed(() => [ 102: {
id: 102, name: "汤正国", generation: 13, relation: "长子", branch: "长房", generationName: "正字辈",
birthDate: "1965年5月", years: "1965—", birthplace: "河南洛阳", status: "privacy", canEdit: true,
relatives: [{ id: 101, name: "汤文远", relation: "父亲" }, { id: 104, name: "汤凯", relation: "长子" }],
},
103: {
id: 103, name: "汤正华", generation: 13, relation: "次子", branch: "二房", generationName: "正字辈",
birthDate: "", years: "资料受限", birthplace: "", status: "forbidden", canEdit: false,
relatives: [{ id: 101, name: "汤文远", relation: "父亲" }],
},
};
const details = computed(() => member.value ? [
{ label: "字辈", value: member.value.generationName }, { label: "字辈", value: member.value.generationName },
{ label: "出生日期", value: member.value.birthDate }, { label: "出生日期", value: member.value.status === "forbidden" ? "按权限隐藏" : member.value.birthDate },
{ label: "生卒信息", value: member.value.years }, { label: "生卒信息", value: member.value.years },
{ label: "祖居地", value: member.value.status === "forbidden" ? "按权限隐藏" : member.value.birthplace },
{ label: "所属支系", value: member.value.branch }, { label: "所属支系", value: member.value.branch },
]); ] : []);
const canEdit = computed(() => Boolean(member.value?.canEdit));
const statusLabel = computed(() => ({ privacy: "隐私资料", deceased: "离世纪念", forbidden: "访问受限" })[member.value?.status] || "成员状态");
const statusDescription = computed(() => ({
privacy: "部分资料仅向授权成员展示",
deceased: "查看生平保留与纪念资料说明",
forbidden: "当前账号只能查看有限身份信息",
})[member.value?.status] || "查看成员状态说明");
onLoad((query) => { onLoad((query) => {
genealogyId.value = genealogyId.value = query.genealogyId || genealogyContext.getCurrentGenealogyId() || "";
query.genealogyId || genealogyContext.getCurrentGenealogyId() || "";
personId.value = query.personId || ""; personId.value = query.personId || "";
if (genealogyId.value) if (genealogyId.value) genealogyContext.setCurrentGenealogyId(genealogyId.value);
genealogyContext.setCurrentGenealogyId(genealogyId.value); if (query.state === "loading") { memberState.value = "loading"; return; }
memberState.value = if (query.state === "error" || !personId.value || !memberFixtures[personId.value]) {
query.state === "loading" memberState.value = "error";
? "loading" errorMessage.value = !personId.value ? "没有指定成员,请从世系树重新选择。" : "这位成员不存在或已不属于当前家谱。";
: query.state === "error" || !personId.value return;
? "error" }
: "detail"; member.value = { ...memberFixtures[personId.value] };
memberState.value = "detail";
}); });
const toEdit = () =>
const toEdit = () => {
if (!canEdit.value) { openMemberState("forbidden"); return; }
uni.navigateTo({ url: `/pages/tree/t05-edit-member?genealogyId=${genealogyId.value}&personId=${personId.value}` });
};
const openMemberState = (state) =>
uni.navigateTo({ uni.navigateTo({
url: `/pages/tree/t05-edit-member?genealogyId=${genealogyId.value}&personId=${personId.value}`, url: `/pages/tree/t08-member-states?genealogyId=${genealogyId.value}&personId=${personId.value}&state=${state}`,
}); });
const openRelative = (relative) =>
uni.navigateTo({ url: `/pages/tree/t03-member-profile?genealogyId=${genealogyId.value}&personId=${relative.id}` });
const toTree = () => uni.navigateBack(); const toTree = () => uni.navigateBack();
</script> </script>
<style scoped lang="scss"> <style scoped lang="scss">
.member-page { .member-page { display: flex; min-height: 100vh; flex-direction: column; padding-bottom: 28rpx; box-sizing: border-box; background: $paper; }
position: relative; .member-page__header, .member-context, .member-panel { z-index: 2; }
min-height: 100vh; .member-context { width: calc(100% - 32rpx); margin: 18rpx auto 0; padding: 16rpx 24rpx; box-sizing: border-box; background: url("/static/assets/modules/tree/transparent/t07-search-input-frame.png") center / 100% 100% no-repeat; }
overflow: hidden; .member-context text { display: block; color: $ink-muted; font-size: 23rpx; line-height: 1.45; }
background: $paper; .member-context text:first-child { color: $ink; font-size: 26rpx; font-weight: 700; }
} .member-panel { width: calc(100% - 32rpx); min-height: min(620px, calc((100vw - 16px) * 1.42)); margin: 16rpx auto 0; padding: 9% 8%; box-sizing: border-box; background: url("/static/assets/modules/tree/transparent/t01-state-panel.png") center / 100% 100% no-repeat; }
.member-page__header { .member-panel > .app-loading { margin-top: 30%; }
position: relative; .member-heading { display: flex; align-items: center; gap: 20rpx; }
z-index: 3; .member-heading__seal { display: flex; width: 74rpx; height: 74rpx; flex: 0 0 74rpx; align-items: center; justify-content: center; background: url("/static/assets/modules/genealogy/transparent/current-seal-frame.png") center / contain no-repeat; }
} .member-heading__seal text { color: #fff7e7; font-size: 28rpx; }
.member-panel { .member-heading > view:last-child text { display: block; color: $ink-muted; font-size: 23rpx; line-height: 1.45; }
position: relative; .member-heading > view:last-child text:last-child { font-size: 23rpx; }
z-index: 2; .member-heading > view:last-child text:first-child { color: $ink; font-family: "STKaiti", "KaiTi", serif; font-size: 36rpx; font-weight: 700; }
width: calc(100% - 32rpx); .member-status-entry { display: grid; grid-template-columns: auto minmax(0, 1fr) auto; align-items: center; gap: 14rpx; margin-top: 18rpx; padding: 18rpx 22rpx; background: url("/static/assets/modules/genealogy/transparent/list-slip-frame.png") center / 100% 100% no-repeat; }
height: min(650px, calc((100vw - 16px) * 1.5)); .member-status-entry text { color: $ink-muted; font-size: 22rpx; line-height: 1.4; }
margin: 18rpx auto 0; .member-status-entry text:first-child, .member-status-entry text:last-child { color: $brand-red; font-weight: 700; }
} .member-section-title { display: block; margin-top: 24rpx; color: $brand-red; font-size: 24rpx; font-weight: 700; }
.member-panel__skin { .member-info-row { display: grid; grid-template-columns: auto minmax(0, 1fr); min-height: 64rpx; align-items: center; gap: 18rpx; margin-top: 8rpx; padding: 8rpx 18rpx; box-sizing: border-box; background: url("/static/assets/modules/tree/transparent/t07-search-input-frame.png") center / 100% 100% no-repeat; }
position: absolute; .member-info-row text { color: $ink-muted; font-size: 23rpx; line-height: 1.45; }
inset: 0; .member-info-row text:last-child { color: $ink; text-align: right; }
width: 100%; .member-relatives { margin-top: 8rpx; font-size: 23rpx; }
height: 100%; .member-relatives > view { display: flex; justify-content: space-between; gap: 20rpx; padding: 12rpx 18rpx; color: $ink; font-size: 23rpx; }
} .member-relatives > view text:last-child { color: $brand-red; }
.member-detail { .member-relatives > text { display: block; color: $ink-muted; font-size: 23rpx; line-height: 1.5; }
position: absolute; .member-profile-action { display: flex; min-height: 76rpx; align-items: center; justify-content: center; margin-top: 22rpx; background: url("/static/assets/foundation/transparent/a01-scroll-primary-v3.png") center / 100% 100% no-repeat; }
inset: 7% 8%; .member-profile-action text { color: #fff9ed; font-size: 24rpx; font-weight: 700; }
} .member-error { margin-top: 30%; text-align: center; }
.member-heading { .member-error > text { display: block; color: $ink-muted; font-size: 24rpx; line-height: 1.55; }
display: flex; .member-error > text:nth-child(2) { font-size: 24rpx; }
align-items: center; .member-error > text:first-child { color: $ink; font-family: "STKaiti", "KaiTi", serif; font-size: 34rpx; font-weight: 700; }
gap: 20rpx; @media (min-width: 400px) { .member-context, .member-panel { width: calc(100% - 48rpx); } }
}
.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: 23rpx;
}
.member-section-title {
display: block;
margin: 20rpx 0 4rpx;
color: $brand-red;
font-size: 24rpx;
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: 23rpx;
}
.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: 23rpx;
}
.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: 24rpx;
line-height: 1.65;
}
@media (min-width: 400px) {
.member-panel {
width: calc(100% - 48rpx);
}
}
</style> </style>
+242 -3
View File
@@ -1,5 +1,244 @@
<!-- 页面编号T-04用途新增直系亲属与保存结果 --> <!-- 页面编号T-04用途录入首位成员或为指定成员新增亲属 -->
<template><TreeMemberForm kind="add" /></template> <template>
<view
class="add-relative-page"
:class="{
'add-state--form': addState === 'form',
'add-state--success': addState === 'success',
'add-state--error': addState === 'error',
}"
>
<ModulePageBackground module="tree" />
<view class="add-relative-page__header">
<PageHeader :title="isFirstMember ? '录入首位成员' : '新增亲属'" />
</view>
<view class="add-relative-panel">
<view v-if="addState === 'form'" class="add-relative-form">
<text class="form-eyebrow">{{ isFirstMember ? "建立世系起点" : "补全家族关系" }}</text>
<text class="form-title">{{ formTitle }}</text>
<text class="form-copy">{{ formCopy }}</text>
<view v-if="!isFirstMember" class="member-context">
<text>当前成员</text><text>{{ currentMember.name }} · {{ currentMember.generation }} </text>
</view>
<view class="form-field">
<text>姓名</text>
<input
v-model="addForm.name"
maxlength="20"
placeholder="请输入真实姓名"
placeholder-class="form-placeholder"
@input="clearError('name')"
/>
</view>
<text v-if="fieldErrors.name" class="field-error">{{ fieldErrors.name }}</text>
<picker
v-if="!isFirstMember"
:range="relationOptions"
:value="relationIndex"
@change="selectRelation"
>
<view class="form-field form-field--picker">
<text>与本人关系</text>
<text>{{ addForm.relation || "请选择亲属关系" }}</text>
</view>
</picker>
<text v-if="fieldErrors.relation" class="field-error">{{ fieldErrors.relation }}</text>
<picker :range="genderOptions" :value="genderIndex" @change="selectGender">
<view class="form-field form-field--picker">
<text>性别</text><text>{{ addForm.gender || "请选择" }}</text>
</view>
</picker>
<text v-if="fieldErrors.gender" class="field-error">{{ fieldErrors.gender }}</text>
<picker mode="date" :value="addForm.birthDate" @change="selectBirthDate">
<view class="form-field form-field--picker">
<text>出生日期</text><text>{{ addForm.birthDate || "选填" }}</text>
</view>
</picker>
<view class="form-field form-field--summary">
<text>简要说明</text>
<textarea
v-model="addForm.summary"
auto-height
maxlength="200"
placeholder="选填:字辈、祖居地或身份说明"
placeholder-class="form-placeholder"
/>
</view>
<text class="form-note">{{ formNote }}</text>
<view class="form-action" @click="submitAdd">
<image src="/static/assets/foundation/transparent/a01-scroll-primary-v3.png" mode="scaleToFill" />
<text>{{ isSubmitting ? "正在保存…" : isFirstMember ? "保存首位成员" : "保存亲属" }}</text>
</view>
</view>
<view v-else class="add-result">
<text class="form-eyebrow">{{ addState === "success" ? "世系资料已更新" : "成员未保存" }}</text>
<text class="form-title">{{ addState === "success" ? successTitle : "暂时无法保存成员" }}</text>
<text class="form-copy">{{ addState === "success" ? successCopy : "当前填写内容仍保留,可返回修改后重试。" }}</text>
<view class="form-action" @click="addState === 'success' ? returnToTree() : retryForm()">
<image src="/static/assets/foundation/transparent/a01-scroll-primary-v3.png" mode="scaleToFill" />
<text>{{ addState === "success" ? "返回世系树" : "返回修改" }}</text>
</view>
</view>
</view>
<AppDialog
:visible="discardDialogVisible"
eyebrow="尚未保存"
title="要放弃本次填写吗"
message="返回后,本次新增成员的内容不会保留。"
cancel-text="继续填写"
confirm-text="放弃并返回"
show-cancel
@close="discardDialogVisible = false"
@cancel="discardDialogVisible = false"
@confirm="discardAndBack"
/>
</view>
</template>
<script setup> <script setup>
import TreeMemberForm from "@/components/tree/TreeMemberForm.vue"; import { computed, reactive, ref } from "vue";
import { onBackPress, onLoad, onUnload } from "@dcloudio/uni-app";
import AppDialog from "@/components/AppDialog.vue";
import ModulePageBackground from "@/components/ModulePageBackground.vue";
import PageHeader from "@/components/PageHeader.vue";
import { genealogyContext } from "@/utils/genealogy-context.js";
const addState = ref("form");
const genealogyId = ref("");
const personId = ref("");
const mode = ref("relative");
const isSubmitting = ref(false);
const discardDialogVisible = ref(false);
let submitTimer = null;
const memberFixtures = {
101: { id: 101, name: "汤文远", generation: 12 },
102: { id: 102, name: "汤正国", generation: 13 },
103: { id: 103, name: "汤正华", generation: 13 },
};
const relationOptions = ["长子", "次子", "女儿", "配偶", "兄弟", "姐妹"];
const genderOptions = ["男", "女", "未说明"];
const addForm = reactive({ name: "", relation: "", gender: "", birthDate: "", summary: "" });
const fieldErrors = reactive({ name: "", relation: "", gender: "" });
const isFirstMember = computed(
() => mode.value === "first" || (!personId.value && mode.value !== "relative"),
);
const currentMember = computed(
() => memberFixtures[personId.value] || { id: personId.value, name: "当前成员", generation: "待确认" },
);
const relationIndex = computed(() => Math.max(0, relationOptions.indexOf(addForm.relation)));
const genderIndex = computed(() => Math.max(0, genderOptions.indexOf(addForm.gender)));
const formTitle = computed(() =>
isFirstMember.value ? "录入家谱中的第一位成员" : `${currentMember.value.name}添加一位亲属`,
);
const formCopy = computed(() =>
isFirstMember.value
? "首位成员将成为世系起点,后续可从此人继续补充配偶、子女和后代。"
: "先确认新成员与当前成员的关系,再填写可核实的身份信息。",
);
const formNote = computed(() =>
isFirstMember.value
? "保存后进入世系树,并可继续为首位成员添加亲属。"
: "保存后成员会出现在相应世代;详细生平可在成员档案中继续完善。",
);
const successTitle = computed(() =>
isFirstMember.value ? `${addForm.name}已成为世系起点` : `${addForm.name}已加入世系`,
);
const successCopy = computed(() =>
isFirstMember.value
? "首位成员已保存,世系树现在可以继续向下补充。"
: `${addForm.relation}关系已记录,返回后可查看新的成员节点。`,
);
const hasDraft = computed(() =>
Object.values(addForm).some((value) => String(value).trim()),
);
onLoad((query) => {
genealogyId.value = query.genealogyId || genealogyContext.getCurrentGenealogyId() || "";
personId.value = query.personId || "";
mode.value = query.mode === "first" ? "first" : "relative";
if (genealogyId.value) genealogyContext.setCurrentGenealogyId(genealogyId.value);
addState.value = query.state === "success" ? "success" : query.state === "error" ? "error" : "form";
});
onUnload(() => {
if (submitTimer) clearTimeout(submitTimer);
});
onBackPress(() => {
if (discardDialogVisible.value) {
discardDialogVisible.value = false;
return true;
}
if (addState.value === "form" && hasDraft.value) {
discardDialogVisible.value = true;
return true;
}
return false;
});
const clearError = (field) => { fieldErrors[field] = ""; };
const selectRelation = (event) => {
addForm.relation = relationOptions[Number(event.detail.value)] || "";
clearError("relation");
};
const selectGender = (event) => {
addForm.gender = genderOptions[Number(event.detail.value)] || "";
clearError("gender");
};
const selectBirthDate = (event) => { addForm.birthDate = event.detail.value || ""; };
const validateAddForm = () => {
fieldErrors.name = addForm.name.trim() ? "" : "请填写成员姓名";
fieldErrors.relation = isFirstMember.value || addForm.relation ? "" : "请选择与当前成员的关系";
fieldErrors.gender = addForm.gender ? "" : "请选择性别或未说明";
return !fieldErrors.name && !fieldErrors.relation && !fieldErrors.gender;
};
const submitAdd = () => {
if (isSubmitting.value || !validateAddForm()) return;
isSubmitting.value = true;
submitTimer = setTimeout(() => {
addState.value = addForm.name.trim() === "失败" ? "error" : "success";
isSubmitting.value = false;
submitTimer = null;
}, 280);
};
const retryForm = () => { addState.value = "form"; };
const returnToTree = () => uni.navigateBack();
const discardAndBack = () => {
discardDialogVisible.value = false;
uni.navigateBack();
};
</script> </script>
<style scoped lang="scss">
.add-relative-page { display: flex; min-height: 100vh; flex-direction: column; background: $paper; }
.add-relative-page__header { z-index: 3; }
.add-relative-panel { z-index: 2; width: calc(100% - 32rpx); min-height: min(680px, calc((100vw - 16px) * 1.5)); margin: 18rpx auto 28rpx; padding: 7.5% 8%; box-sizing: border-box; background: url("/static/assets/modules/tree/transparent/t01-state-panel.png") center / 100% 100% no-repeat; }
.form-eyebrow { display: block; color: $brand-red; font-size: 22rpx; letter-spacing: 3rpx; }
.form-title { display: block; margin-top: 10rpx; color: $ink; font-family: "STKaiti", "KaiTi", serif; font-size: 34rpx; font-weight: 700; line-height: 1.35; }
.form-copy, .form-note { display: block; margin-top: 10rpx; color: $ink-muted; font-size: 24rpx; line-height: 1.5; }
.member-context, .form-field { display: grid; grid-template-columns: auto minmax(0, 1fr); min-height: 78rpx; align-items: center; gap: 20rpx; margin-top: 14rpx; padding: 12rpx 22rpx; box-sizing: border-box; background: url("/static/assets/modules/tree/transparent/t07-search-input-frame.png") center / 100% 100% no-repeat; }
.member-context text:first-child, .form-field > text:first-child { color: $ink; font-size: 24rpx; font-weight: 700; }
.member-context text:last-child, .form-field > text:last-child, .form-field input, .form-field textarea { min-width: 0; color: $ink; font-size: 24rpx; line-height: 1.45; text-align: right; }
.form-field textarea { width: auto; min-height: 54rpx; text-align: left; }
.form-field--summary { align-items: start; }
.form-placeholder { color: #a79884; }
.field-error { display: block; margin: 5rpx 18rpx 0; color: $brand-red; font-size: 22rpx; line-height: 32rpx; }
.form-note { text-align: center; }
.form-action { display: grid; width: 100%; min-height: 76rpx; margin-top: 18rpx; }
.form-action image, .form-action text { grid-area: 1 / 1; width: 100%; height: 100%; }
.form-action text { z-index: 1; display: flex; align-items: center; justify-content: center; color: #fff9ed; font-size: 24rpx; font-weight: 700; }
.add-result { margin-top: 30%; text-align: center; }
.add-result .form-eyebrow, .add-result .form-copy { text-align: center; }
.add-result .form-action { width: 420rpx; max-width: 100%; margin-right: auto; margin-left: auto; }
@media (min-width: 400px) { .add-relative-panel { width: calc(100% - 48rpx); } }
</style>
+191 -3
View File
@@ -1,5 +1,193 @@
<!-- 页面编号T-05用途编辑成员资料与保存结果 --> <!-- 页面编号T-05用途维护指定成员的身份与生平资料 -->
<template><TreeMemberForm kind="edit" /></template> <template>
<view
class="edit-member-page"
:class="{
'edit-state--form': editState === 'form',
'edit-state--success': editState === 'success',
'edit-state--error': editState === 'error',
'edit-state--no-permission': editState === 'no-permission',
}"
>
<ModulePageBackground module="tree" />
<view class="edit-member-page__header"><PageHeader title="编辑成员" /></view>
<view class="edit-member-panel">
<view v-if="editState === 'form'" class="edit-member-form">
<text class="form-eyebrow">成员档案维护</text>
<text class="form-title">完善{{ originalMember.name }}的生命记录</text>
<text class="form-copy">基础身份用于世系展示生平说明会显示在有权限查看的成员档案中</text>
<view class="member-context">
<text>成员身份</text><text> {{ originalMember.generation }} · {{ originalMember.branch }}</text>
</view>
<view class="form-field">
<text>姓名</text>
<input v-model="editForm.name" maxlength="20" placeholder="请输入姓名" placeholder-class="form-placeholder" @input="clearError('name')" />
</view>
<text v-if="fieldErrors.name" class="field-error">{{ fieldErrors.name }}</text>
<view class="form-field">
<text>字辈</text>
<input v-model="editForm.generationName" maxlength="12" placeholder="例如:文字辈" placeholder-class="form-placeholder" />
</view>
<picker mode="date" :value="editForm.birthDate" @change="selectDate('birthDate', $event)">
<view class="form-field form-field--picker"><text>出生日期</text><text>{{ editForm.birthDate || "未填写" }}</text></view>
</picker>
<picker mode="date" :value="editForm.deathDate" @change="selectDate('deathDate', $event)">
<view class="form-field form-field--picker"><text>离世日期</text><text>{{ editForm.deathDate || "在世或未填写" }}</text></view>
</picker>
<view class="form-field form-field--summary">
<text>人物简介</text>
<textarea v-model="editForm.summary" auto-height maxlength="500" placeholder="记录生平、迁徙或重要经历" placeholder-class="form-placeholder" />
</view>
<text v-if="fieldErrors.dates" class="field-error">{{ fieldErrors.dates }}</text>
<text class="form-note">隐私字段只向本人和具备维护权限的家谱管理员展示</text>
<view class="form-action" @click="saveMember">
<image src="/static/assets/foundation/transparent/a01-scroll-primary-v3.png" mode="scaleToFill" />
<text>{{ isSubmitting ? "正在保存…" : "保存资料" }}</text>
</view>
</view>
<view v-else class="edit-result">
<text class="form-eyebrow">{{ resultCopy.eyebrow }}</text>
<text class="form-title">{{ resultCopy.title }}</text>
<text class="form-copy">{{ resultCopy.copy }}</text>
<view class="form-action" @click="handleResultAction">
<image src="/static/assets/foundation/transparent/a01-scroll-primary-v3.png" mode="scaleToFill" />
<text>{{ resultCopy.action }}</text>
</view>
</view>
</view>
<AppDialog
:visible="discardDialogVisible"
eyebrow="资料尚未保存"
title="要放弃本次修改吗"
message="返回后,本次对成员档案的修改不会保留。"
cancel-text="继续编辑"
confirm-text="放弃修改"
show-cancel
@close="discardDialogVisible = false"
@cancel="discardDialogVisible = false"
@confirm="discardAndBack"
/>
</view>
</template>
<script setup> <script setup>
import TreeMemberForm from "@/components/tree/TreeMemberForm.vue"; import { computed, reactive, ref } from "vue";
import { onBackPress, onLoad, onUnload } from "@dcloudio/uni-app";
import AppDialog from "@/components/AppDialog.vue";
import ModulePageBackground from "@/components/ModulePageBackground.vue";
import PageHeader from "@/components/PageHeader.vue";
import { genealogyContext } from "@/utils/genealogy-context.js";
const editState = ref("form");
const genealogyId = ref("");
const personId = ref("");
const isSubmitting = ref(false);
const discardDialogVisible = ref(false);
let submitTimer = null;
const memberFixtures = {
101: { id: 101, name: "汤文远", generation: 12, generationName: "文字辈", branch: "主支", birthDate: "1940-03-01", deathDate: "2012-08-16", summary: "一生敦亲睦族,参与整理家族旧谱。" },
102: { id: 102, name: "汤正国", generation: 13, generationName: "正字辈", branch: "长房", birthDate: "1965-05-12", deathDate: "", summary: "负责长房资料核对。" },
103: { id: 103, name: "汤正华", generation: 13, generationName: "正字辈", branch: "二房", birthDate: "1968-09-03", deathDate: "", summary: "资料仍在补充。" },
};
const fallbackMember = { id: "", name: "当前成员", generation: "待确认", generationName: "", branch: "待确认", birthDate: "", deathDate: "", summary: "" };
const originalMember = ref({ ...fallbackMember });
const baseline = ref("");
const editForm = reactive({ name: "", generationName: "", birthDate: "", deathDate: "", summary: "" });
const fieldErrors = reactive({ name: "", dates: "" });
const formSnapshot = computed(() => JSON.stringify(editForm));
const isDirty = computed(
() => editState.value === "form" && baseline.value && formSnapshot.value !== baseline.value,
);
const resultCopy = computed(() => ({
success: { eyebrow: "成员资料已更新", title: `${editForm.name}的档案已保存`, copy: "返回成员档案后可以查看本次修改。", action: "返回成员档案" },
error: { eyebrow: "资料未保存", title: "暂时无法保存成员档案", copy: "当前修改仍保留,可返回表单后重试。", action: "返回修改" },
"no-permission": { eyebrow: "权限不足", title: "当前账号不能编辑这位成员", copy: "本人或具备成员维护权限的家谱管理员才能修改档案。", action: "返回成员档案" },
}[editState.value] || {}));
const loadMember = (id) => {
const member = memberFixtures[id] || { ...fallbackMember, id, name: "待核实成员" };
originalMember.value = { ...member };
Object.assign(editForm, {
name: member.name,
generationName: member.generationName,
birthDate: member.birthDate,
deathDate: member.deathDate,
summary: member.summary,
});
baseline.value = formSnapshot.value;
};
onLoad((query) => {
genealogyId.value = query.genealogyId || genealogyContext.getCurrentGenealogyId() || "";
personId.value = query.personId || "";
if (genealogyId.value) genealogyContext.setCurrentGenealogyId(genealogyId.value);
loadMember(personId.value);
editState.value = query.state === "success" ? "success" : query.state === "error" ? "error" : query.state === "no-permission" ? "no-permission" : !personId.value ? "error" : "form";
});
onUnload(() => { if (submitTimer) clearTimeout(submitTimer); });
onBackPress(() => {
if (discardDialogVisible.value) { discardDialogVisible.value = false; return true; }
if (isDirty.value) { discardDialogVisible.value = true; return true; }
return false;
});
const clearError = (field) => { fieldErrors[field] = ""; };
const selectDate = (field, event) => {
editForm[field] = event.detail.value || "";
fieldErrors.dates = "";
};
const validateEditForm = () => {
fieldErrors.name = editForm.name.trim() ? "" : "请填写成员姓名";
fieldErrors.dates = editForm.birthDate && editForm.deathDate && editForm.deathDate < editForm.birthDate ? "离世日期不能早于出生日期" : "";
return !fieldErrors.name && !fieldErrors.dates;
};
const saveMember = () => {
if (isSubmitting.value || !validateEditForm()) return;
isSubmitting.value = true;
submitTimer = setTimeout(() => {
editState.value = editForm.name.trim() === "失败" ? "error" : "success";
if (editState.value === "success") baseline.value = formSnapshot.value;
isSubmitting.value = false;
submitTimer = null;
}, 280);
};
const handleResultAction = () => {
if (editState.value === "error") { editState.value = "form"; return; }
uni.navigateBack();
};
const discardAndBack = () => {
discardDialogVisible.value = false;
uni.navigateBack();
};
</script> </script>
<style scoped lang="scss">
.edit-member-page { display: flex; min-height: 100vh; flex-direction: column; background: $paper; }
.edit-member-page__header { z-index: 3; }
.edit-member-panel { z-index: 2; width: calc(100% - 32rpx); min-height: min(690px, calc((100vw - 16px) * 1.52)); margin: 18rpx auto 28rpx; padding: 7.5% 8%; box-sizing: border-box; background: url("/static/assets/modules/tree/transparent/t01-state-panel.png") center / 100% 100% no-repeat; }
.form-eyebrow { display: block; color: $brand-red; font-size: 22rpx; letter-spacing: 3rpx; }
.form-title { display: block; margin-top: 10rpx; color: $ink; font-family: "STKaiti", "KaiTi", serif; font-size: 34rpx; font-weight: 700; line-height: 1.35; }
.form-copy, .form-note { display: block; margin-top: 10rpx; color: $ink-muted; font-size: 24rpx; line-height: 1.5; }
.member-context, .form-field { display: grid; grid-template-columns: auto minmax(0, 1fr); min-height: 78rpx; align-items: center; gap: 20rpx; margin-top: 14rpx; padding: 12rpx 22rpx; box-sizing: border-box; background: url("/static/assets/modules/tree/transparent/t07-search-input-frame.png") center / 100% 100% no-repeat; }
.member-context text:first-child, .form-field > text:first-child { color: $ink; font-size: 24rpx; font-weight: 700; }
.member-context text:last-child, .form-field > text:last-child, .form-field input, .form-field textarea { min-width: 0; color: $ink; font-size: 24rpx; line-height: 1.45; text-align: right; }
.form-field textarea { width: auto; min-height: 54rpx; text-align: left; }
.form-field--summary { align-items: start; }
.form-placeholder { color: #a79884; }
.field-error { display: block; margin: 5rpx 18rpx 0; color: $brand-red; font-size: 22rpx; line-height: 32rpx; }
.form-note { text-align: center; }
.form-action { display: grid; width: 100%; min-height: 76rpx; margin-top: 18rpx; }
.form-action image, .form-action text { grid-area: 1 / 1; width: 100%; height: 100%; }
.form-action text { z-index: 1; display: flex; align-items: center; justify-content: center; color: #fff9ed; font-size: 24rpx; font-weight: 700; }
.edit-result { margin-top: 30%; text-align: center; }
.edit-result .form-eyebrow, .edit-result .form-copy { text-align: center; }
.edit-result .form-action { width: 420rpx; max-width: 100%; margin-right: auto; margin-left: auto; }
@media (min-width: 400px) { .edit-member-panel { width: calc(100% - 48rpx); } }
</style>
+213 -3
View File
@@ -1,5 +1,215 @@
<!-- 页面编号T-06用途编辑亲属关系冲突提示与处理 --> <!-- 页面编号T-06用途选择两位现有成员并校正其家族关系 -->
<template><TreeMemberForm kind="relation" /></template> <template>
<view
class="relationship-page"
:class="{
'relationship-state--form': relationshipState === 'form',
'relationship-state--success': relationshipState === 'success',
'relationship-state--conflict': relationshipState === 'conflict',
'relationship-state--error': relationshipState === 'error',
}"
>
<ModulePageBackground module="tree" />
<view class="relationship-page__header"><PageHeader title="关系维护" /></view>
<view class="relationship-panel">
<view v-if="relationshipState === 'form'" class="relationship-form">
<text class="form-eyebrow">亲属关系校正</text>
<text class="form-title">确认两位成员的家族关系</text>
<text class="form-copy">选择成员和关系类型后先查看对世系的影响再决定是否保存</text>
<picker :range="memberLabels" :value="sourceIndex" @change="selectMember('sourceId', $event)">
<view class="form-field form-field--picker"><text>当前成员</text><text>{{ memberName(relationshipForm.sourceId) || "请选择成员" }}</text></view>
</picker>
<text v-if="fieldErrors.sourceId" class="field-error">{{ fieldErrors.sourceId }}</text>
<picker :range="memberLabels" :value="targetIndex" @change="selectMember('targetId', $event)">
<view class="form-field form-field--picker"><text>关联成员</text><text>{{ memberName(relationshipForm.targetId) || "请选择另一位成员" }}</text></view>
</picker>
<text v-if="fieldErrors.targetId" class="field-error">{{ fieldErrors.targetId }}</text>
<picker :range="relationshipOptions" :value="relationshipIndex" @change="selectRelationship">
<view class="form-field form-field--picker"><text>关系类型</text><text>{{ relationshipForm.relationship || "请选择关系" }}</text></view>
</picker>
<text v-if="fieldErrors.relationship" class="field-error">{{ fieldErrors.relationship }}</text>
<view class="relationship-preview">
<text>关系影响预览</text>
<text>{{ relationshipPreview }}</text>
</view>
<text class="form-note">父母子女关系会改变世系位置配偶和兄弟姐妹关系不会自动改写现有父母</text>
<view class="form-action" @click="saveRelationship">
<image src="/static/assets/foundation/transparent/a01-scroll-primary-v3.png" mode="scaleToFill" />
<text>{{ isSubmitting ? "正在校验…" : "校验并保存关系" }}</text>
</view>
</view>
<view v-else class="relationship-result">
<text class="form-eyebrow">{{ resultCopy.eyebrow }}</text>
<text class="form-title">{{ resultCopy.title }}</text>
<text class="form-copy">{{ resultCopy.copy }}</text>
<view v-if="relationshipState === 'conflict'" class="result-actions">
<view class="form-action form-action--secondary" @click="relationshipState = 'form'">
<image src="/static/assets/foundation/transparent/a01-scroll-secondary-v3.png" mode="scaleToFill" /><text>返回核对</text>
</view>
<view class="form-action" @click="conflictDialogVisible = true">
<image src="/static/assets/foundation/transparent/a01-scroll-primary-v3.png" mode="scaleToFill" /><text>查看规则</text>
</view>
</view>
<view v-else class="form-action" @click="handleResultAction">
<image src="/static/assets/foundation/transparent/a01-scroll-primary-v3.png" mode="scaleToFill" />
<text>{{ relationshipState === "success" ? "返回世系树" : "重新选择" }}</text>
</view>
</view>
</view>
<AppDialog
:visible="conflictDialogVisible"
eyebrow="关系校验规则"
title="为什么不能保存这段关系"
:message="conflictReason || '同一成员不能成为自己的亲属,也不能形成上下代循环或重复父母关系。'"
@confirm="conflictDialogVisible = false"
@close="conflictDialogVisible = false"
/>
</view>
</template>
<script setup> <script setup>
import TreeMemberForm from "@/components/tree/TreeMemberForm.vue"; import { computed, reactive, ref } from "vue";
import { onLoad, onUnload } from "@dcloudio/uni-app";
import AppDialog from "@/components/AppDialog.vue";
import ModulePageBackground from "@/components/ModulePageBackground.vue";
import PageHeader from "@/components/PageHeader.vue";
import { genealogyContext } from "@/utils/genealogy-context.js";
const relationshipState = ref("form");
const genealogyId = ref("");
const personId = ref("");
const isSubmitting = ref(false);
const conflictDialogVisible = ref(false);
const conflictReason = ref("");
let submitTimer = null;
const memberOptions = [
{ id: "101", name: "汤文远", generation: 12, parentId: "" },
{ id: "102", name: "汤正国", generation: 13, parentId: "101" },
{ id: "103", name: "汤正华", generation: 13, parentId: "101" },
{ id: "104", name: "汤凯", generation: 14, parentId: "102" },
{ id: "105", name: "汤悦", generation: 14, parentId: "102" },
];
const relationshipOptions = ["父子(当前成员为父)", "父女(当前成员为父)", "母子(当前成员为母)", "母女(当前成员为母)", "配偶", "兄弟姐妹"];
const relationshipForm = reactive({ sourceId: "", targetId: "", relationship: "" });
const fieldErrors = reactive({ sourceId: "", targetId: "", relationship: "" });
const memberLabels = computed(() => memberOptions.map((item) => `${item.name} · 第 ${item.generation}`));
const memberById = computed(() => new Map(memberOptions.map((item) => [item.id, item])));
const sourceIndex = computed(() => Math.max(0, memberOptions.findIndex((item) => item.id === relationshipForm.sourceId)));
const targetIndex = computed(() => Math.max(0, memberOptions.findIndex((item) => item.id === relationshipForm.targetId)));
const relationshipIndex = computed(() => Math.max(0, relationshipOptions.indexOf(relationshipForm.relationship)));
const memberName = (id) => memberById.value.get(String(id))?.name || "";
const relationshipPreview = computed(
() => relationshipForm.sourceId && relationshipForm.targetId && relationshipForm.relationship
? `${memberName(relationshipForm.sourceId)}将以“${relationshipForm.relationship}”关联${memberName(relationshipForm.targetId)}`
: "完成三项选择后,这里会说明世系位置将如何变化。",
);
const resultCopy = computed(() => ({
success: { eyebrow: "关系已保存", title: "世系关系已经更新", copy: relationshipPreview.value },
conflict: { eyebrow: "发现关系冲突", title: "这段关系会造成世系矛盾", copy: conflictReason.value },
error: { eyebrow: "关系未保存", title: "暂时无法完成关系调整", copy: "当前选择仍然保留,可返回后重新校验。" },
}[relationshipState.value] || {}));
onLoad((query) => {
genealogyId.value = query.genealogyId || genealogyContext.getCurrentGenealogyId() || "";
personId.value = query.personId || "";
relationshipForm.sourceId = personId.value && memberById.value.has(String(personId.value)) ? String(personId.value) : memberOptions[0].id;
if (genealogyId.value) genealogyContext.setCurrentGenealogyId(genealogyId.value);
relationshipState.value = query.state === "success" ? "success" : query.state === "conflict" ? "conflict" : query.state === "error" ? "error" : "form";
if (relationshipState.value === "conflict") conflictReason.value = "目标成员已经存在父级关系,请先核对原关系。";
});
onUnload(() => { if (submitTimer) clearTimeout(submitTimer); });
const clearFieldError = (field) => { fieldErrors[field] = ""; };
const selectMember = (field, event) => {
relationshipForm[field] = memberOptions[Number(event.detail.value)]?.id || "";
clearFieldError(field);
};
const selectRelationship = (event) => {
relationshipForm.relationship = relationshipOptions[Number(event.detail.value)] || "";
clearFieldError("relationship");
};
const isAncestor = (possibleAncestorId, memberId) => {
let current = memberById.value.get(String(memberId));
const visited = new Set();
while (current?.parentId && !visited.has(current.id)) {
if (current.parentId === String(possibleAncestorId)) return true;
visited.add(current.id);
current = memberById.value.get(current.parentId);
}
return false;
};
const validateRelationship = () => {
fieldErrors.sourceId = relationshipForm.sourceId ? "" : "请选择当前成员";
fieldErrors.targetId = relationshipForm.targetId ? "" : "请选择关联成员";
fieldErrors.relationship = relationshipForm.relationship ? "" : "请选择关系类型";
if (fieldErrors.sourceId || fieldErrors.targetId || fieldErrors.relationship) return false;
if (relationshipForm.sourceId === relationshipForm.targetId) {
conflictReason.value = "同一成员不能与自己建立亲属关系。";
return false;
}
const isParentRelationship = relationshipForm.relationship.includes("当前成员为");
const target = memberById.value.get(relationshipForm.targetId);
if (isParentRelationship && isAncestor(relationshipForm.targetId, relationshipForm.sourceId)) {
conflictReason.value = "保存后会形成上下代循环,请重新选择成员方向。";
return false;
}
if (isParentRelationship && target?.parentId && target.parentId !== relationshipForm.sourceId) {
conflictReason.value = `${target.name}已经存在父级成员,不能直接重复建立父母关系。`;
return false;
}
conflictReason.value = "";
return true;
};
const saveRelationship = () => {
if (isSubmitting.value) return;
if (!validateRelationship()) {
if (conflictReason.value) relationshipState.value = "conflict";
return;
}
isSubmitting.value = true;
submitTimer = setTimeout(() => {
relationshipState.value = relationshipForm.relationship === "失败" ? "error" : "success";
isSubmitting.value = false;
submitTimer = null;
}, 280);
};
const handleResultAction = () => {
if (relationshipState.value === "error") { relationshipState.value = "form"; return; }
uni.navigateBack();
};
</script> </script>
<style scoped lang="scss">
.relationship-page { display: flex; min-height: 100vh; flex-direction: column; background: $paper; }
.relationship-page__header { z-index: 3; }
.relationship-panel { z-index: 2; width: calc(100% - 32rpx); min-height: min(650px, calc((100vw - 16px) * 1.46)); margin: 18rpx auto 28rpx; padding: 7.5% 8%; box-sizing: border-box; background: url("/static/assets/modules/tree/transparent/t01-state-panel.png") center / 100% 100% no-repeat; }
.form-eyebrow { display: block; color: $brand-red; font-size: 22rpx; letter-spacing: 3rpx; }
.form-title { display: block; margin-top: 10rpx; color: $ink; font-family: "STKaiti", "KaiTi", serif; font-size: 34rpx; font-weight: 700; line-height: 1.35; }
.form-copy, .form-note { display: block; margin-top: 10rpx; color: $ink-muted; font-size: 24rpx; line-height: 1.5; }
.form-field { display: grid; grid-template-columns: auto minmax(0, 1fr); min-height: 78rpx; align-items: center; gap: 20rpx; margin-top: 14rpx; padding: 12rpx 22rpx; box-sizing: border-box; background: url("/static/assets/modules/tree/transparent/t07-search-input-frame.png") center / 100% 100% no-repeat; }
.form-field text:first-child { color: $ink; font-size: 24rpx; font-weight: 700; }
.form-field text:last-child { min-width: 0; color: $ink; font-size: 24rpx; line-height: 1.45; text-align: right; }
.field-error { display: block; margin: 5rpx 18rpx 0; color: $brand-red; font-size: 22rpx; line-height: 32rpx; }
.relationship-preview { margin-top: 18rpx; padding: 20rpx 22rpx; background: url("/static/assets/modules/genealogy/transparent/list-slip-frame.png") center / 100% 100% no-repeat; }
.relationship-preview text { display: block; color: $ink-muted; font-size: 23rpx; line-height: 1.5; }
.relationship-preview text:first-child { color: $brand-red; font-weight: 700; }
.form-note { text-align: center; }
.form-action { display: grid; width: 100%; min-height: 76rpx; margin-top: 18rpx; }
.form-action image, .form-action text { grid-area: 1 / 1; width: 100%; height: 100%; }
.form-action text { z-index: 1; display: flex; align-items: center; justify-content: center; color: #fff9ed; font-size: 24rpx; font-weight: 700; }
.form-action--secondary text { color: $ink; }
.relationship-result { margin-top: 28%; text-align: center; }
.relationship-result .form-eyebrow, .relationship-result .form-copy { text-align: center; }
.relationship-result > .form-action { width: 420rpx; max-width: 100%; margin-right: auto; margin-left: auto; }
.result-actions { display: flex; gap: 14rpx; margin-top: 24rpx; }
.result-actions .form-action { width: calc(50% - 7rpx); margin-top: 0; }
@media (min-width: 400px) { .relationship-panel { width: calc(100% - 48rpx); } }
</style>
+29 -62
View File
@@ -21,11 +21,6 @@
v-if="directoryState === 'list' || directoryState === 'empty'" v-if="directoryState === 'list' || directoryState === 'empty'"
class="directory-search" class="directory-search"
> >
<image
class="directory-search__frame"
src="/static/assets/modules/tree/transparent/t07-search-input-frame.png"
mode="scaleToFill"
/>
<input <input
v-model="keyword" v-model="keyword"
aria-label="成员搜索关键词" aria-label="成员搜索关键词"
@@ -59,11 +54,6 @@
class="directory-card" class="directory-card"
@click="openMember(item)" @click="openMember(item)"
> >
<image
class="directory-card__frame"
src="/static/assets/modules/genealogy/transparent/list-slip-frame.png"
mode="scaleToFill"
/>
<view class="directory-card__copy"> <view class="directory-card__copy">
<text class="directory-card__name">{{ item.name }}</text> <text class="directory-card__name">{{ item.name }}</text>
<text class="directory-card__meta" <text class="directory-card__meta"
@@ -75,10 +65,6 @@
</view> </view>
</template> </template>
<view v-else class="directory-state-card"> <view v-else class="directory-state-card">
<image
src="/static/assets/modules/genealogy/transparent/list-slip-frame.png"
mode="scaleToFill"
/>
<view <view
><text>{{ ><text>{{
directoryState === "empty" ? "没有找到相关成员" : "成员目录暂不可用" directoryState === "empty" ? "没有找到相关成员" : "成员目录暂不可用"
@@ -167,9 +153,9 @@ const openMember = (item) =>
</script> </script>
<style scoped lang="scss"> <style scoped lang="scss">
.directory-page { .directory-page {
position: relative; display: flex;
min-height: 100vh; min-height: 100vh;
overflow-x: hidden; flex-direction: column;
overflow-y: auto; overflow-y: auto;
background: $paper; background: $paper;
} }
@@ -177,7 +163,6 @@ const openMember = (item) =>
.directory-context, .directory-context,
.directory-search, .directory-search,
.directory-content { .directory-content {
position: relative;
z-index: 2; z-index: 2;
} }
.directory-context { .directory-context {
@@ -199,39 +184,33 @@ const openMember = (item) =>
font-weight: 500; font-weight: 500;
} }
.directory-search { .directory-search {
display: grid;
width: calc(100% - 48rpx); width: calc(100% - 48rpx);
height: 44px; min-height: 44px;
margin: 20rpx auto 0; margin: 20rpx auto 0;
} background: url("/static/assets/modules/tree/transparent/t07-search-input-frame.png")
.directory-search__frame { center / 100% 100% no-repeat;
position: absolute;
inset: 0;
width: 100%;
height: 100%;
pointer-events: none;
} }
.directory-search input { .directory-search input {
position: absolute;
top: 0;
right: 110rpx;
bottom: 0;
left: 26rpx;
z-index: 1; z-index: 1;
height: 44px; grid-area: 1 / 1;
min-height: 44px;
margin-right: 110rpx;
margin-left: 26rpx;
color: $ink; color: $ink;
font-size: 26rpx; font-size: 26rpx;
line-height: 44px; line-height: 44px;
} }
.directory-search__action { .directory-search__action {
position: absolute;
top: 0;
right: 8rpx;
z-index: 1; z-index: 1;
display: flex; display: flex;
grid-area: 1 / 1;
justify-self: end;
min-width: 88rpx; min-width: 88rpx;
min-height: 44px; min-height: 44px;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
margin-right: 8rpx;
color: $brand-red; color: $brand-red;
font-size: 24rpx; font-size: 24rpx;
font-weight: 700; font-weight: 700;
@@ -258,25 +237,17 @@ const openMember = (item) =>
font-size: 23rpx; font-size: 23rpx;
} }
.directory-card { .directory-card {
position: relative;
display: flex; display: flex;
width: 100%; width: 100%;
height: 196rpx; min-height: 196rpx;
align-items: center; align-items: center;
box-sizing: border-box; box-sizing: border-box;
margin-bottom: 16rpx; margin-bottom: 16rpx;
padding: 18rpx 28rpx; padding: 18rpx 28rpx;
} background: url("/static/assets/modules/genealogy/transparent/list-slip-frame.png")
.directory-card__frame { center / 100% 100% no-repeat;
position: absolute;
inset: 0;
z-index: 0;
width: 100%;
height: 100%;
pointer-events: none;
} }
.directory-card__copy { .directory-card__copy {
position: relative;
z-index: 1; z-index: 1;
display: flex; display: flex;
min-width: 0; min-width: 0;
@@ -285,21 +256,19 @@ const openMember = (item) =>
justify-content: center; justify-content: center;
} }
.directory-card__name { .directory-card__name {
overflow: hidden;
color: $ink; color: $ink;
font-family: "STKaiti", "KaiTi", serif; font-family: "STKaiti", "KaiTi", serif;
font-size: 31rpx; font-size: 31rpx;
font-weight: 700; font-weight: 700;
text-overflow: ellipsis; line-height: 1.3;
white-space: nowrap; overflow-wrap: anywhere;
} }
.directory-card__meta { .directory-card__meta {
overflow: hidden;
margin-top: 7rpx; margin-top: 7rpx;
color: #62584c; color: #62584c;
font-size: 24rpx; font-size: 24rpx;
text-overflow: ellipsis; line-height: 1.45;
white-space: nowrap; overflow-wrap: anywhere;
} }
.directory-card__status { .directory-card__status {
align-self: flex-start; align-self: flex-start;
@@ -311,23 +280,21 @@ const openMember = (item) =>
line-height: 30rpx; line-height: 30rpx;
} }
.directory-state-card { .directory-state-card {
position: relative; display: flex;
width: 100%; width: 100%;
height: 220rpx; min-height: 220rpx;
align-items: center;
justify-content: center;
margin-bottom: 16rpx; margin-bottom: 16rpx;
} padding: 48rpx 12%;
.directory-state-card > image { box-sizing: border-box;
position: absolute; background: url("/static/assets/modules/genealogy/transparent/list-slip-frame.png")
inset: 0; center / 100% 100% no-repeat;
width: 100%;
height: 100%;
} }
.directory-state-card { .directory-state-card {
margin-top: 70rpx; margin-top: 70rpx;
} }
.directory-state-card > view { .directory-state-card > view {
position: absolute;
inset: 24% 12%;
z-index: 1; z-index: 1;
text-align: center; text-align: center;
} }
@@ -351,7 +318,7 @@ const openMember = (item) =>
} }
@media screen and (max-width: 340px) { @media screen and (max-width: 340px) {
.directory-card { .directory-card {
height: 192rpx; min-height: 192rpx;
padding-right: 22rpx; padding-right: 22rpx;
padding-left: 22rpx; padding-left: 22rpx;
} }
+87 -178
View File
@@ -1,4 +1,4 @@
<!-- 页面编号T-08用途成员隐私离世纪念无权限状态说明 --> <!-- 页面编号T-08用途展示指定成员隐私纪念无权限状态 -->
<template> <template>
<view <view
class="member-status-page" class="member-status-page"
@@ -6,220 +6,129 @@
'member-status--privacy': statusState === 'privacy', 'member-status--privacy': statusState === 'privacy',
'member-status--deceased': statusState === 'deceased', 'member-status--deceased': statusState === 'deceased',
'member-status--forbidden': statusState === 'forbidden', 'member-status--forbidden': statusState === 'forbidden',
'member-status--error': statusState === 'error',
}" }"
> >
<ModulePageBackground module="tree" /> <ModulePageBackground module="tree" />
<view class="member-status-page__header" <view class="member-status-page__header"><PageHeader :title="pageTitle" /></view>
><PageHeader title="成员状态"
/></view> <view class="member-status-context">
<view class="status-tabs"> <text>{{ genealogyName }}</text>
<view <text v-if="member">{{ member.name }} · {{ member.generation }} · {{ member.branch }}</text>
v-for="item in tabs" <text v-else>未找到成员身份</text>
:key="item.value"
class="status-tab"
@click="statusState = item.value"
>
<image
:src="
statusState === item.value
? '/static/assets/foundation/transparent/a01-scroll-primary-v3.png'
: '/static/assets/foundation/transparent/a01-scroll-secondary-v3.png'
"
mode="aspectFit"
/>
<text
:class="{ 'status-tab__text--active': statusState === item.value }"
>{{ item.label }}</text
>
</view>
</view> </view>
<view class="status-card"> <view class="status-card">
<image
src="/static/assets/modules/tree/transparent/t01-state-panel.png"
mode="scaleToFill"
/>
<view class="status-card__copy"> <view class="status-card__copy">
<text>{{ activeStatus.eyebrow }}</text <text>{{ activeStatus.eyebrow }}</text>
><text>{{ activeStatus.title }}</text <text>{{ activeStatus.title }}</text>
><text>{{ activeStatus.copy }}</text> <text>{{ activeStatus.copy }}</text>
</view> </view>
</view> </view>
<view class="status-guidance"> <view class="status-guidance">
<image
src="/static/assets/modules/tree/transparent/t01-state-panel.png"
mode="scaleToFill"
/>
<view> <view>
<text>{{ activeStatus.guideTitle }}</text> <text>{{ activeStatus.guideTitle }}</text>
<text v-for="line in activeStatus.guides" :key="line">{{ line }}</text> <text v-for="line in activeStatus.guides" :key="line">{{ line }}</text>
</view> </view>
</view> </view>
<view class="status-action" @click="handleAction">
<image src="/static/assets/foundation/transparent/a01-scroll-primary-v3.png" mode="scaleToFill" />
<text>{{ activeStatus.action }}</text>
</view>
</view> </view>
</template> </template>
<script setup> <script setup>
import { computed, ref } from "vue"; import { computed, ref } from "vue";
import { onLoad } from "@dcloudio/uni-app"; import { onLoad } from "@dcloudio/uni-app";
import ModulePageBackground from "@/components/ModulePageBackground.vue"; import ModulePageBackground from "@/components/ModulePageBackground.vue";
import PageHeader from "@/components/PageHeader.vue"; import PageHeader from "@/components/PageHeader.vue";
const statusState = ref("privacy"); import { genealogyContext } from "@/utils/genealogy-context.js";
const tabs = [
{ value: "privacy", label: "隐私" }, const genealogyId = ref("");
{ value: "deceased", label: "纪念" }, const personId = ref("");
{ value: "forbidden", label: "无权限" }, const statusState = ref("loading");
]; const genealogyName = ref("汤氏家谱");
const member = ref(null);
const memberFixtures = {
101: { id: 101, name: "汤文远", generation: 12, branch: "主支", allowedStates: ["deceased"] },
102: { id: 102, name: "汤正国", generation: 13, branch: "长房", allowedStates: ["privacy"] },
103: { id: 103, name: "汤正华", generation: 13, branch: "二房", allowedStates: ["forbidden"] },
};
const states = { const states = {
privacy: { privacy: {
eyebrow: "隐私成员", eyebrow: "隐私成员",
title: "敏感资料只向授权成员展示", title: "敏感资料只向授权成员展示",
copy: "姓名可保留在世系位置,出生信息、联系方式和生平内容根据权限隐藏。", copy: "该成员姓名和世系位置仍然保留,出生信息、联系方式和生平内容权限隐藏。",
guideTitle: "隐私展示原则", guideTitle: "当前可见范围",
guides: [ guides: ["姓名、世代与所属支系可见", "联系方式和详细生平已隐藏", "本人或谱主可维护授权范围"],
"本人可以查看和维护自己的完整档案", action: "返回成员档案",
"谱主可协助处理身份与世系关系",
"普通成员只看到被允许公开的内容",
],
}, },
deceased: { deceased: {
eyebrow: "离世成员", eyebrow: "离世纪念",
title: "在世系中保留温和的纪念状态", title: "这位家人的生命记录被温和保留",
copy: "离世不会删除成员关系;档案可继续记录生平、影像和家人的追思内容。", copy: "离世状态不会删除成员关系;有权限的家人仍可共同维护生卒年月、生平和追思资料。",
guideTitle: "纪念资料范围", guideTitle: "纪念资料范围",
guides: [ guides: ["生卒年月与世系关系继续保留", "生平内容由有权限家人维护", "敏感资料继续遵守原有隐私设置"],
"生卒年月与世系关系继续保留", action: "返回成员档案",
"生平内容由有权限家人共同维护",
"敏感资料仍遵守原有隐私设置",
],
}, },
forbidden: { forbidden: {
eyebrow: "访问受限", eyebrow: "访问受限",
title: "当前账号没有查看该档案的权限", title: "当前账号没有查看该档案的权限",
copy: "为保护家人隐私,页面不会展示被隐藏字段,也不会提供绕过权限的入口。", copy: "页面不会展示被隐藏字段,也不会提供绕过家谱权限的入口。",
guideTitle: "如何申请查看", guideTitle: "如何申请查看",
guides: [ guides: ["先确认已经加入对应家谱", "联系谱主说明亲属关系和用途", "权限变更后重新进入成员档案"],
"先确认已经加入对应家谱", action: "返回我的家谱",
"联系谱主说明亲属关系和用途", },
"权限变更后重新进入成员档案", error: {
], eyebrow: "成员状态不可用",
title: "没有找到要查看的成员",
copy: "请从成员档案或世系树重新选择成员。",
guideTitle: "重新进入方式",
guides: ["从世系树选择成员节点", "从成员目录打开成员档案", "确认当前家谱仍然有效"],
action: "返回上一页",
}, },
}; };
const activeStatus = computed(() => states[statusState.value]); const activeStatus = computed(() => states[statusState.value] || states.error);
const pageTitle = computed(() => statusState.value === "deceased" ? "成员纪念" : statusState.value === "privacy" ? "隐私资料" : "成员状态");
onLoad((query) => { onLoad((query) => {
statusState.value = states[query.state] ? query.state : "privacy"; genealogyId.value = query.genealogyId || genealogyContext.getCurrentGenealogyId() || "";
personId.value = query.personId || "";
if (genealogyId.value) genealogyContext.setCurrentGenealogyId(genealogyId.value);
member.value = memberFixtures[personId.value] || null;
const requestedState = ["privacy", "deceased", "forbidden"].includes(query.state) ? query.state : "";
statusState.value = member.value?.allowedStates.includes(requestedState) ? requestedState : "error";
}); });
const handleAction = () => {
if (statusState.value === "forbidden") {
uni.reLaunch({ url: "/pages/genealogy/g01-my-genealogies" });
return;
}
uni.navigateBack();
};
</script> </script>
<style scoped lang="scss"> <style scoped lang="scss">
.member-status-page { .member-status-page { display: flex; min-height: 100vh; flex-direction: column; padding-bottom: 34rpx; box-sizing: border-box; background: $paper; }
position: relative; .member-status-page__header, .member-status-context, .status-card, .status-guidance, .status-action { z-index: 2; }
min-height: 100vh; .member-status-context { width: calc(100% - 32rpx); margin: 18rpx auto 0; padding: 16rpx 24rpx; box-sizing: border-box; background: url("/static/assets/modules/tree/transparent/t07-search-input-frame.png") center / 100% 100% no-repeat; }
overflow: hidden; .member-status-context text { display: block; color: $ink-muted; font-size: 23rpx; line-height: 1.45; }
background: $paper; .member-status-context text:first-child { color: $ink; font-size: 26rpx; font-weight: 700; }
} .status-card { width: calc(100% - 32rpx); min-height: 330rpx; margin: 18rpx auto 0; padding: 12% 10%; box-sizing: border-box; background: url("/static/assets/modules/tree/transparent/t01-state-panel.png") center / 100% 100% no-repeat; }
.member-status-page__header, .status-card__copy text { display: block; color: $ink-muted; font-size: 24rpx; line-height: 1.55; text-align: center; }
.status-tabs, .status-card__copy text:first-child { color: $brand-red; font-size: 22rpx; letter-spacing: 3rpx; }
.status-card, .status-card__copy text:nth-child(2) { margin-top: 12rpx; color: $ink; font-family: "STKaiti", "KaiTi", serif; font-size: 32rpx; font-weight: 700; }
.status-guidance { .status-card__copy text:last-child { margin-top: 14rpx; font-size: 24rpx; }
position: relative; .status-guidance { width: calc(100% - 48rpx); margin: 16rpx auto 0; padding: 24rpx 28rpx; box-sizing: border-box; background: url("/static/assets/modules/genealogy/transparent/list-slip-frame.png") center / 100% 100% no-repeat; }
z-index: 2; .status-guidance text { display: block; color: $ink-muted; font-size: 24rpx; line-height: 1.6; }
} .status-guidance text:first-child { margin-bottom: 8rpx; color: $ink; font-size: 26rpx; font-weight: 700; }
.status-tabs { .status-action { display: grid; width: calc(100% - 72rpx); min-height: 76rpx; margin: 22rpx auto 0; }
display: flex; .status-action image, .status-action text { grid-area: 1 / 1; width: 100%; height: 100%; }
gap: 10rpx; .status-action text { z-index: 1; display: flex; align-items: center; justify-content: center; color: #fff9ed; font-size: 24rpx; font-weight: 700; }
padding: 22rpx 24rpx 0; @media (min-width: 400px) { .member-status-context, .status-card { width: calc(100% - 48rpx); } }
}
.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: 23rpx;
font-weight: 700;
}
.status-tab .status-tab__text--active {
color: #fff9ed;
}
.status-card {
width: calc(100% - 48rpx);
height: calc((100vw - 24px) * 0.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: 22rpx;
letter-spacing: 3rpx;
}
.status-card__copy text:nth-child(2) {
margin-top: 7rpx;
color: $ink;
font-family: "STKaiti", "KaiTi", serif;
font-size: 32rpx;
font-weight: 700;
}
.status-card__copy text:last-child {
margin-top: 10rpx;
color: $ink-muted;
font-size: 24rpx;
line-height: 1.5;
}
.status-guidance {
width: calc(100% - 48rpx);
height: min(400px, calc((100vw - 24px) * 0.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: 24rpx;
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> </style>
+14
View File
@@ -124,6 +124,17 @@ const prepareA01LoginState = async (send, action) => {
} }
} }
const prepareG01State = async (send, action) => {
if (action === 'g01-add-dialog') {
await send('Runtime.evaluate', { expression: "document.querySelector('.create-action')?.click()" })
await waitForSelector(send, '.add-dialog-layer', 'G01 add dialog did not render')
return
}
if (action !== 'g01-switcher') return
await send('Runtime.evaluate', { expression: "document.querySelector('.current-slip')?.click()" })
await waitForSelector(send, '.genealogy-switcher-layer', 'G01 switcher did not render')
}
const waitForRequestedUrl = async (send, url) => { const waitForRequestedUrl = async (send, url) => {
for (let attempt = 0; attempt < 30; attempt += 1) { for (let attempt = 0; attempt < 30; attempt += 1) {
const result = await send('Runtime.evaluate', { const result = await send('Runtime.evaluate', {
@@ -506,7 +517,10 @@ const capture = async () => {
await sleep(100) await sleep(100)
} }
await send('Runtime.evaluate', { expression: 'scrollTo(0, 0)' })
await prepareA01LoginState(send, action) await prepareA01LoginState(send, action)
await prepareG01State(send, action)
await prepareG06State(send, action) await prepareG06State(send, action)
await prepareG03State(send, action) await prepareG03State(send, action)
await prepareG08State(send, action) await prepareG08State(send, action)
+9 -8
View File
@@ -90,8 +90,9 @@ if (-not $brandSealRule.Success) {
} }
Assert-Contains -Content $brandSealRule.Groups['Body'].Value -Expected 'width: 184rpx;' -Message 'A01 brand seal must use the approved smaller width.' Assert-Contains -Content $brandSealRule.Groups['Body'].Value -Expected 'width: 184rpx;' -Message 'A01 brand seal must use the approved smaller width.'
Assert-Contains -Content $brandSealRule.Groups['Body'].Value -Expected 'height: 221rpx;' -Message 'A01 brand seal must preserve its aspect ratio at the approved smaller height.' Assert-Contains -Content $brandSealRule.Groups['Body'].Value -Expected 'height: 221rpx;' -Message 'A01 brand seal must preserve its aspect ratio at the approved smaller height.'
Assert-Contains -Content $brandSealRule.Groups['Body'].Value -Expected 'top: 64rpx;' -Message 'A01 brand seal must keep its approved vertical anchor.' Assert-Contains -Content $brandSealRule.Groups['Body'].Value -Expected 'margin-top: 64rpx;' -Message 'A01 brand seal must keep its approved vertical spacing in document flow.'
Assert-Contains -Content $brandSealRule.Groups['Body'].Value -Expected 'left: 50%;' -Message 'A01 brand seal must remain horizontally centered.' Assert-Contains -Content $brandSealRule.Groups['Body'].Value -Expected 'justify-self: center;' -Message 'A01 brand seal must remain horizontally centered by grid alignment.'
Assert-NotContains -Content $brandSealRule.Groups['Body'].Value -Unexpected 'position:' -Message 'A01 brand seal must not use positioning for ordinary layout.'
# Final decorative surfaces must come from real bitmap assets. # Final decorative surfaces must come from real bitmap assets.
foreach ($asset in @( foreach ($asset in @(
@@ -129,7 +130,7 @@ foreach ($obsoleteVisualAsset in @(
Assert-NotContains -Content $entry -Unexpected $obsoleteVisualAsset -Message "A01 must not retain the rejected header, scenery, or scroll asset: $obsoleteVisualAsset" Assert-NotContains -Content $entry -Unexpected $obsoleteVisualAsset -Message "A01 must not retain the rejected header, scenery, or scroll asset: $obsoleteVisualAsset"
} }
$buttonSkinTags = [regex]::Matches($entry, '<image class="button-skin"[^>]+>') $buttonSkinTags = [regex]::Matches($entry, '<image\s+class="button-skin"[^>]+>')
if ($buttonSkinTags.Count -ne 2) { if ($buttonSkinTags.Count -ne 2) {
throw "A01 must have exactly two visible login button skins, found $($buttonSkinTags.Count)." throw "A01 must have exactly two visible login button skins, found $($buttonSkinTags.Count)."
} }
@@ -161,15 +162,15 @@ $smsCodeCopy = ConvertFrom-Utf8Base64 '55+t5L+h6aqM6K+B56CB'
Assert-Contains -Content $entry -Expected $smsCodeCopy -Message 'A01 SMS state must use the full SMS code field label.' Assert-Contains -Content $entry -Expected $smsCodeCopy -Message 'A01 SMS state must use the full SMS code field label.'
foreach ($contract in @( foreach ($contract in @(
"const activeLoginMethod = ref('password')", 'const activeLoginMethod = ref("password")',
'const passwordVisible = ref(false)', 'const passwordVisible = ref(false)',
"const LOGIN_METHOD_STORAGE_KEY = 'a01:last-login-method'", 'const LOGIN_METHOD_STORAGE_KEY = "a01:last-login-method"',
'uni.getStorageSync(LOGIN_METHOD_STORAGE_KEY)', 'uni.getStorageSync(LOGIN_METHOD_STORAGE_KEY)',
'uni.setStorageSync(LOGIN_METHOD_STORAGE_KEY, method)', 'uni.setStorageSync(LOGIN_METHOD_STORAGE_KEY, method)',
'const switchLoginMethod = (method) =>', 'const switchLoginMethod = (method) =>',
'const togglePasswordVisibility = () =>', 'const togglePasswordVisibility = () =>',
"url: '/pages/auth/a04-register'", '/pages/auth/a04-register',
"url: '/pages/auth/a05-reset-password'", '/pages/auth/a05-reset-password',
'class="feedback-toast"', 'class="feedback-toast"',
'class="verification-layer"', 'class="verification-layer"',
'class="login-submit"', 'class="login-submit"',
@@ -198,7 +199,7 @@ $feedbackToastRule = [regex]::Match($entry, '(?ms)^\.feedback-toast\s*\{(?<Body>
if (-not $feedbackToastRule.Success) { if (-not $feedbackToastRule.Success) {
throw 'A01 is missing the custom feedback Toast style rule.' throw 'A01 is missing the custom feedback Toast style rule.'
} }
Assert-Contains -Content $feedbackToastRule.Groups['Body'].Value -Expected "border-image-source: url('/static/assets/foundation/transparent/a01-scroll-toast-v3.png');" -Message 'A01 feedback Toast must use the approved v3 nine-slice asset.' Assert-Contains -Content $feedbackToastRule.Groups['Body'].Value -Expected '/static/assets/foundation/transparent/a01-scroll-toast-v3.png' -Message 'A01 feedback Toast must use the approved v3 nine-slice asset.'
Assert-Contains -Content $feedbackToastRule.Groups['Body'].Value -Expected 'border-image-slice:' -Message 'A01 feedback Toast must declare fixed decorative slices.' Assert-Contains -Content $feedbackToastRule.Groups['Body'].Value -Expected 'border-image-slice:' -Message 'A01 feedback Toast must declare fixed decorative slices.'
Assert-Contains -Content $feedbackToastRule.Groups['Body'].Value -Expected 'fill' -Message 'A01 feedback Toast nine-slice must fill the paper center.' Assert-Contains -Content $feedbackToastRule.Groups['Body'].Value -Expected 'fill' -Message 'A01 feedback Toast nine-slice must fill the paper center.'
Write-Output 'A01-LOGIN-MERGE-CONTRACT PASS' Write-Output 'A01-LOGIN-MERGE-CONTRACT PASS'
+1 -1
View File
@@ -24,7 +24,7 @@ foreach ($relativePath in $ownedFiles) {
foreach ($returnPage in @('pages/auth/a04-register.vue', 'pages/auth/a05-reset-password.vue', 'pages/auth/a06-auth-status.vue')) { foreach ($returnPage in @('pages/auth/a04-register.vue', 'pages/auth/a05-reset-password.vue', 'pages/auth/a06-auth-status.vue')) {
$content = Get-Content -LiteralPath (Join-Path $root $returnPage) -Raw -Encoding utf8 $content = Get-Content -LiteralPath (Join-Path $root $returnPage) -Raw -Encoding utf8
if ($content -notmatch [regex]::Escape("url: '/pages/auth/a01-entry'")) { if ($content -notmatch 'url:\s*["'']/pages/auth/a01-entry["'']') {
throw "$returnPage must return to A01." throw "$returnPage must return to A01."
} }
} }
+2 -2
View File
@@ -45,7 +45,8 @@ foreach ($required in @(
'const submitRegister = () =>', 'const submitRegister = () =>',
'class="feedback-toast"', 'class="feedback-toast"',
$sliderPendingCopy, $sliderPendingCopy,
"const prepareLogin = () => uni.redirectTo({ url: '/pages/auth/a01-entry' })", 'const prepareLogin = () => uni.redirectTo({ url:',
'/pages/auth/a01-entry',
'if (!/^1\d{10}$/.test(phone.value))', 'if (!/^1\d{10}$/.test(phone.value))',
'if (!password.value)', 'if (!password.value)',
'if (!confirmPassword.value)', 'if (!confirmPassword.value)',
@@ -78,7 +79,6 @@ foreach ($required in @(
'mode="aspectFit"', 'mode="aspectFit"',
'width: 184rpx;', 'width: 184rpx;',
'height: 221rpx;', 'height: 221rpx;',
"border-image-source: url('/static/assets/foundation/transparent/a01-scroll-toast-v3.png');",
'border-image-slice: 58 280 fill;' 'border-image-slice: 58 280 fill;'
)) { )) {
Assert-Contains -Content $register -Expected $required -Message "Missing A-04 visual balance contract: $required" Assert-Contains -Content $register -Expected $required -Message "Missing A-04 visual balance contract: $required"
+2 -2
View File
@@ -46,14 +46,14 @@ foreach ($required in @(
'const successVisible = ref(false)', 'const successVisible = ref(false)',
'class="feedback-toast"', 'class="feedback-toast"',
'class="success-layer"', 'class="success-layer"',
"const prepareLogin = () => uni.redirectTo({ url: '/pages/auth/a01-entry' })", 'const prepareLogin = () => uni.redirectTo({ url:',
'/pages/auth/a01-entry',
'if (!/^1\d{10}$/.test(phone.value))', 'if (!/^1\d{10}$/.test(phone.value))',
'if (!/^\d{6}$/.test(verificationCode.value))', 'if (!/^\d{6}$/.test(verificationCode.value))',
'if (!password.value)', 'if (!password.value)',
'if (password.value !== confirmPassword.value)', 'if (password.value !== confirmPassword.value)',
'width: 184rpx;', 'width: 184rpx;',
'height: 221rpx;', 'height: 221rpx;',
"border-image-source: url('/static/assets/foundation/transparent/a01-scroll-toast-v3.png');",
'border-image-slice: 58 280 fill;' 'border-image-slice: 58 280 fill;'
)) { )) {
Assert-Contains -Content $page -Expected $required -Message "Missing A-05 reset-password contract: $required" Assert-Contains -Content $page -Expected $required -Message "Missing A-05 reset-password contract: $required"
+13 -10
View File
@@ -55,13 +55,16 @@ const navigate = async (send, url, selector) => {
await waitFor(send, `Boolean(document.querySelector(${JSON.stringify(selector)}))`, `Page did not render ${selector}`) await waitFor(send, `Boolean(document.querySelector(${JSON.stringify(selector)}))`, `Page did not render ${selector}`)
} }
const setInputs = (values) => `(() => { const setInputs = async (send, values) => {
const inputs = document.querySelectorAll('.auth-input input') for (const [index, value] of values.entries()) {
;${JSON.stringify(values)}.forEach((value, index) => { await valueOf(send, `(() => {
inputs[index].value = value const input = document.querySelectorAll('.auth-input input')[${index}]
inputs[index].dispatchEvent(new Event('input', { bubbles: true })) input.value = ${JSON.stringify(value)}
}) input.dispatchEvent(new Event('input', { bubbles: true }))
})()` })()`)
await sleep(30)
}
}
const run = async () => { const run = async () => {
const { socket, send, exceptions } = await connect() const { socket, send, exceptions } = await connect()
@@ -89,7 +92,7 @@ const run = async () => {
await valueOf(send, "document.querySelector('.get-code').click()") await valueOf(send, "document.querySelector('.get-code').click()")
await waitFor(send, "Boolean(document.querySelector('.field-error'))", 'A05 invalid phone did not show inline error') await waitFor(send, "Boolean(document.querySelector('.field-error'))", 'A05 invalid phone did not show inline error')
await valueOf(send, setInputs(['13800138000', '', '', ''])) await setInputs(send, ['13800138000', '', '', ''])
await valueOf(send, "document.querySelector('.get-code').click()") await valueOf(send, "document.querySelector('.get-code').click()")
await waitFor(send, "Boolean(document.querySelector('.feedback-toast'))", 'A05 code request did not use custom pending feedback') await waitFor(send, "Boolean(document.querySelector('.feedback-toast'))", 'A05 code request did not use custom pending feedback')
assert.strictEqual(await valueOf(send, "document.querySelector('.feedback-toast').textContent.trim()"), '滑动验证待接口接入', 'A05 code request showed the wrong pending feedback') assert.strictEqual(await valueOf(send, "document.querySelector('.feedback-toast').textContent.trim()"), '滑动验证待接口接入', 'A05 code request showed the wrong pending feedback')
@@ -99,7 +102,7 @@ const run = async () => {
await valueOf(send, "document.querySelector('.reset-submit').click()") await valueOf(send, "document.querySelector('.reset-submit').click()")
await waitFor(send, "document.querySelectorAll('.field-error').length === 3", 'A05 incomplete submit did not show three remaining field errors') await waitFor(send, "document.querySelectorAll('.field-error').length === 3", 'A05 incomplete submit did not show three remaining field errors')
await valueOf(send, setInputs(['13800138000', '123456', 'new-password', 'different-password'])) await setInputs(send, ['13800138000', '123456', 'new-password', 'different-password'])
await sleep(100) await sleep(100)
await valueOf(send, "document.querySelector('.reset-submit').click()") await valueOf(send, "document.querySelector('.reset-submit').click()")
await sleep(100) await sleep(100)
@@ -110,7 +113,7 @@ const run = async () => {
})`) })`)
assert(mismatchState.errors.some((message) => message.includes('不一致')), `A05 mismatched passwords did not show inline error: ${JSON.stringify(mismatchState)}`) assert(mismatchState.errors.some((message) => message.includes('不一致')), `A05 mismatched passwords did not show inline error: ${JSON.stringify(mismatchState)}`)
await valueOf(send, setInputs(['13800138000', '123456', 'new-password', 'new-password'])) await setInputs(send, ['13800138000', '123456', 'new-password', 'new-password'])
await sleep(100) await sleep(100)
await valueOf(send, "document.querySelector('.reset-submit').click()") await valueOf(send, "document.querySelector('.reset-submit').click()")
await waitFor(send, "Boolean(document.querySelector('.success-layer'))", 'A05 valid submit did not show custom success result') await waitFor(send, "Boolean(document.querySelector('.success-layer'))", 'A05 valid submit did not show custom success result')
+8
View File
@@ -0,0 +1,8 @@
$ErrorActionPreference = 'Stop'
$root = Split-Path -Parent $PSScriptRoot
$page = Get-Content -LiteralPath (Join-Path $root 'pages/auth/a05-reset-password.vue') -Raw -Encoding UTF8
if ($page.Contains('<text class="success-mark">')) { throw 'A05 must not use a text symbol as its success asset' }
foreach ($token in @('<image', 'class="success-mark"', 'brand-seal.png')) {
if (-not $page.Contains($token)) { throw "A05 success asset missing: $token" }
}
Write-Output 'A05-SUCCESS-ASSET-CONTRACT PASS'
+2 -1
View File
@@ -25,7 +25,8 @@ Assert-NotContains -Content $page -Unexpected 'ModulePage' -Message 'A-06 must n
Assert-NotContains -Content $catalog -Unexpected 'a06:' -Message 'A-06 must not remain in the temporary page catalog' Assert-NotContains -Content $catalog -Unexpected 'a06:' -Message 'A-06 must not remain in the temporary page catalog'
foreach ($required in @( foreach ($required in @(
"const status = ref('risk')", 'const status = ref(',
'risk',
"const statusConfig = {", "const statusConfig = {",
'frozen:', 'frozen:',
'disabled:', 'disabled:',
@@ -0,0 +1,57 @@
$ErrorActionPreference = 'Stop'
$root = Split-Path -Parent $PSScriptRoot
$pages = Get-Content -LiteralPath (Join-Path $root 'pages.json') -Raw -Encoding UTF8 | ConvertFrom-Json
$activePaths = @($pages.pages | ForEach-Object { "$($_.path).vue" })
$moduleConsumers = @()
foreach ($relativePath in $activePaths) {
$fullPath = Join-Path $root $relativePath
if (-not (Test-Path -LiteralPath $fullPath)) { throw "Missing active page: $relativePath" }
$source = Get-Content -LiteralPath $fullPath -Raw -Encoding UTF8
if ($source -match '<ModulePage(?:\s|/|>)|import\s+ModulePage\s+from') { $moduleConsumers += $relativePath }
if ($source -match '@/utils/api\.js|\bappApi\b') { throw "$relativePath must remain local-design only" }
if ($source -match 'uni\.(showToast|showModal|showLoading|showActionSheet)') { throw "$relativePath must use project feedback components" }
}
if ($moduleConsumers.Count -gt 0) {
throw "Active pages must own business content instead of ModulePage: $($moduleConsumers -join ', ')"
}
$contracts = [ordered]@{
'pages/family/f03-feed-detail.vue' = @('feedComments', 'commentDraft', 'submitComment', 'feed-state--expired')
'pages/family/f04-article-list.vue' = @('articleCategories', 'filteredArticles', 'openArticle', 'createArticle')
'pages/family/f05-article-detail.vue' = @('articleParagraphs', 'toggleFavorite', 'article-state--expired', 'backToArticles')
'pages/family/f07-album-list.vue' = @('albums', 'openAlbum', 'createAlbum', 'album-state--empty')
'pages/records/r03-gift-list.vue' = @('giftBooks', 'openGiftBook', 'createGift', 'gift-state--empty')
'pages/records/r04-gift-editor.vue' = @('giftForm', 'validateGift', 'saveGift', 'confirmDelete')
'pages/records/r05-ritual-list.vue' = @('rituals', 'openRitual', 'createRitual', 'ritual-state--empty')
'pages/records/r06-ritual-detail.vue' = @('ritualDetail', 'participants', 'editRitual', 'ritual-state--expired')
'pages/records/r07-ritual-editor.vue' = @('ritualForm', 'validateRitual', 'saveRitual', 'confirmDelete')
'pages/records/r08-growth-journal.vue' = @('growthRecords', 'recordGrowth', 'personName', 'timeline-state--empty')
'pages/records/r09-life-events.vue' = @('lifeEvents', 'createLifeEvent', 'personName', 'timeline-state--empty')
'pages/records/r10-memo-list.vue' = @('memos', 'toggleMemo', 'createMemo', 'memo-state--empty')
'pages/records/r11-merit-records.vue' = @('meritRecords', 'createMerit', 'totalContribution', 'merit-state--empty')
'pages/notification/n02-message-detail.vue' = @('noticeDetail', 'markAsRead', 'openNoticeTarget', 'notice-state--expired')
'pages/profile/m02-edit-profile.vue' = @('profileForm', 'chooseAvatar', 'validateProfile', 'saveProfile')
'pages/profile/m03-security-settings.vue' = @('securityItems', 'openSecurityItem', 'device-state--safe', 'checkSecurity')
'pages/profile/m04-change-password.vue' = @('passwordForm', 'validatePassword', 'togglePassword', 'savePassword')
'pages/profile/m05-change-phone.vue' = @('phoneForm', 'sendCode', 'codeCountdown', 'savePhone')
'pages/profile/m06-help-center.vue' = @('helpCategories', 'filteredQuestions', 'toggleQuestion', 'contactSupport')
'pages/profile/m07-feedback.vue' = @('feedbackForm', 'feedbackTypes', 'validateFeedback', 'submitFeedback')
'pages/profile/m08-promotion.vue' = @('inviteCode', 'copyInviteCode', 'generatePoster', 'share-state--ready')
'pages/profile/m09-vip-orders.vue' = @('serviceBenefits', 'orders', 'order-state--empty', 'openServiceNotice')
'pages/profile/m10-about-settings.vue' = @('agreementItems', 'openAgreement', 'confirmLogout', 'appVersion')
}
foreach ($entry in $contracts.GetEnumerator()) {
$source = Get-Content -LiteralPath (Join-Path $root $entry.Key) -Raw -Encoding UTF8
foreach ($token in $entry.Value) {
if (-not $source.Contains($token)) { throw "$($entry.Key) missing page-owned contract: $token" }
}
foreach ($shared in @('ModulePageBackground', 'PageHeader')) {
if (-not $source.Contains($shared)) { throw "$($entry.Key) must consume shared visual primitive: $shared" }
}
}
Write-Output 'ACTIVE-PAGE-BUSINESS-OWNERSHIP-CONTRACT PASS'
+5 -10
View File
@@ -9,10 +9,10 @@ function Assert-Match {
} }
Assert-Match -Content $component -Pattern ':class="`app-loading--\$\{variant\}`"' -Message 'AppLoading must expose page and section modifier classes' Assert-Match -Content $component -Pattern ':class="`app-loading--\$\{variant\}`"' -Message 'AppLoading must expose page and section modifier classes'
Assert-Match -Content $component -Pattern 'variant:\s*\{\s*type:\s*String,\s*default:\s*''page'',\s*validator:' -Message 'AppLoading must own a validated page/section variant prop' Assert-Match -Content $component -Pattern 'variant:\s*\{\s*type:\s*String,\s*default:\s*["'']page["''],\s*validator:' -Message 'AppLoading must own a validated page/section variant prop'
Assert-Match -Content $component -Pattern 'description:\s*\{\s*type:\s*String,\s*default:\s*''''\s*\}' -Message 'AppLoading must expose an optional description prop' Assert-Match -Content $component -Pattern 'description:\s*\{\s*type:\s*String,\s*default:\s*["'']["'']\s*\}' -Message 'AppLoading must expose an optional description prop'
Assert-Match -Content $component -Pattern 'v-if="description" class="app-loading__description"' -Message 'AppLoading must render its optional description' Assert-Match -Content $component -Pattern 'v-if="description" class="app-loading__description"' -Message 'AppLoading must render its optional description'
Assert-Match -Content $component -Pattern '(?s)\.app-loading\s*\{[^}]*position:\s*relative;[^}]*z-index:\s*1;' -Message 'AppLoading must stay above page panel skins without page-specific overrides' if ($component -match '(?s)\.app-loading\s*\{[^}]*position\s*:') { throw 'AppLoading ordinary content must remain in document flow' }
Assert-Match -Content $component -Pattern 'class="app-loading__seal"\s+src="/static/assets/foundation/transparent/brand-seal\.png"' -Message 'AppLoading must render the approved real red-gold seal asset' Assert-Match -Content $component -Pattern 'class="app-loading__seal"\s+src="/static/assets/foundation/transparent/brand-seal\.png"' -Message 'AppLoading must render the approved real red-gold seal asset'
Assert-Match -Content $component -Pattern 'class="app-loading__knot"\s+src="/static/assets/foundation/transparent/auth-divider-knot\.png"' -Message 'AppLoading must render the approved real gold knot asset' Assert-Match -Content $component -Pattern 'class="app-loading__knot"\s+src="/static/assets/foundation/transparent/auth-divider-knot\.png"' -Message 'AppLoading must render the approved real gold knot asset'
Assert-Match -Content $component -Pattern '(?s)\.app-loading--page\s*\{[^}]*min-height:\s*320rpx;' -Message 'AppLoading page variant must use the approved minimum height' Assert-Match -Content $component -Pattern '(?s)\.app-loading--page\s*\{[^}]*min-height:\s*320rpx;' -Message 'AppLoading page variant must use the approved minimum height'
@@ -25,13 +25,8 @@ Assert-Match -Content $component -Pattern '(?s)\.app-loading--section\s+\.app-lo
Assert-Match -Content $component -Pattern '(?s)\.app-loading--section\s+\.app-loading__description\s*\{[^}]*font-size:\s*22rpx;' -Message 'AppLoading section description must use the approved size' Assert-Match -Content $component -Pattern '(?s)\.app-loading--section\s+\.app-loading__description\s*\{[^}]*font-size:\s*22rpx;' -Message 'AppLoading section description must use the approved size'
Assert-Match -Content $component -Pattern 'app-loading-seal-breathe\s+1\.6s' -Message 'AppLoading seal must use the approved restrained breathing rhythm' Assert-Match -Content $component -Pattern 'app-loading-seal-breathe\s+1\.6s' -Message 'AppLoading seal must use the approved restrained breathing rhythm'
Assert-Match -Content $component -Pattern '@media\s*\(prefers-reduced-motion:\s*reduce\)' -Message 'AppLoading must respect reduced-motion preferences' Assert-Match -Content $component -Pattern '@media\s*\(prefers-reduced-motion:\s*reduce\)' -Message 'AppLoading must respect reduced-motion preferences'
Assert-Match -Content $component -Pattern '@keyframes\s+app-loading-seal-essential-pulse' -Message 'Reduced-motion AppLoading must retain an essential seal opacity pulse' Assert-Match -Content $component -Pattern '(?s)@media\s*\(prefers-reduced-motion:\s*reduce\).*?\.app-loading__seal\s*\{[^}]*animation:\s*none;[^}]*transform:\s*none;' -Message 'Reduced-motion seal must stop animation and transforms'
Assert-Match -Content $component -Pattern '@keyframes\s+app-loading-knot-essential-pulse' -Message 'Reduced-motion AppLoading must retain an essential knot opacity pulse' Assert-Match -Content $component -Pattern '(?s)@media\s*\(prefers-reduced-motion:\s*reduce\).*?\.app-loading__knot\s*\{[^}]*animation:\s*none;[^}]*transform:\s*none;' -Message 'Reduced-motion knot must stop animation and transforms'
Assert-Match -Content $component -Pattern '(?s)@media\s*\(prefers-reduced-motion:\s*reduce\).*?\.app-loading__seal\s*\{[^}]*animation:\s*app-loading-seal-essential-pulse\s+1\.2s\s+ease-in-out\s+infinite;[^}]*transform:\s*none;' -Message 'Reduced-motion seal must pulse opacity without transform motion'
Assert-Match -Content $component -Pattern '(?s)@media\s*\(prefers-reduced-motion:\s*reduce\).*?\.app-loading__knot\s*\{[^}]*animation:\s*app-loading-knot-essential-pulse\s+1\.2s\s+ease-in-out\s+\.2s\s+infinite;[^}]*transform:\s*none;' -Message 'Reduced-motion knot must pulse opacity without transform motion'
if ($component -match '(?s)@media\s*\(prefers-reduced-motion:\s*reduce\).*?animation:\s*none') {
throw 'Reduced-motion AppLoading must not become completely static'
}
if ($component -match 'app-loading__mark|border:\s*4rpx\s+double') { throw 'AppLoading must not retain the legacy CSS box mark' } if ($component -match 'app-loading__mark|border:\s*4rpx\s+double') { throw 'AppLoading must not retain the legacy CSS box mark' }
+2 -2
View File
@@ -71,7 +71,7 @@ foreach ($pageFile in @('pages/genealogy/g05-genealogy-overview.vue', 'pages/tre
throw "Hard-coded route ID remains in $pageFile" throw "Hard-coded route ID remains in $pageFile"
} }
if ($pageFile -eq 'pages/genealogy/g05-genealogy-overview.vue') { if ($pageFile -eq 'pages/genealogy/g05-genealogy-overview.vue') {
if ($page -notmatch "const genealogyId = ref\(''\)" -or $page -notmatch 'query\.genealogyId') { if ($page -notmatch 'const\s+genealogyId\s*=\s*ref\(["'']["'']\)' -or $page -notmatch 'query\.genealogyId') {
throw 'G05 must own its explicit genealogyId route context' throw 'G05 must own its explicit genealogyId route context'
} }
} elseif ($page -notmatch 'genealogyContext') { } elseif ($page -notmatch 'genealogyContext') {
@@ -98,7 +98,7 @@ if ($applications -notmatch 'genealogyContext') {
} }
$notifications = Get-Content -Raw -Encoding UTF8 (Join-Path $root 'pages/notification/n01-message-center.vue') $notifications = Get-Content -Raw -Encoding UTF8 (Join-Path $root 'pages/notification/n01-message-center.vue')
foreach ($method in @('readNotice', 'markAllRead', 'notice-state--list')) { foreach ($method in @('openNotice', 'markAllRead', 'notice-state--list')) {
if ($notifications -notmatch [regex]::Escape($method)) { throw "Notification page does not expose local design interaction: $method" } 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' } if ($notifications -match "@/utils/api\.js|\bappApi\b") { throw 'Notification design page must not connect the API layer' }
+84
View File
@@ -0,0 +1,84 @@
$ErrorActionPreference = 'Stop'
$root = Split-Path $PSScriptRoot -Parent
$allowlistPath = Join-Path $PSScriptRoot 'data-driven-layout-risk-allowlist.json'
$allowlist = Get-Content -LiteralPath $allowlistPath -Raw -Encoding UTF8 | ConvertFrom-Json
$allowed = @{}
$seenAllowed = @{}
foreach ($entry in $allowlist) {
foreach ($required in @('file', 'selector', 'risk', 'reason')) {
if (-not $entry.$required) { throw "DATA-DRIVEN-LAYOUT-CONTRACT allowlist entry missing $required" }
}
$allowed["$($entry.file)::$($entry.selector)::$($entry.risk)"] = $entry.reason
}
$violations = New-Object System.Collections.Generic.List[string]
$files = Get-ChildItem -LiteralPath (Join-Path $root 'pages'), (Join-Path $root 'components') -Recurse -File -Filter '*.vue'
function Add-Risk {
param(
[string]$File,
[string]$Selector,
[string]$Risk,
[string]$Evidence
)
$key = "$File::$Selector::$Risk"
if ($allowed.ContainsKey($key)) {
$seenAllowed[$key] = $true
} else {
$violations.Add("$File :: $Selector :: $Risk :: $Evidence")
}
}
foreach ($file in $files) {
$relative = $file.FullName.Substring($root.Length + 1).Replace('\', '/')
$source = [System.IO.File]::ReadAllText($file.FullName, [System.Text.Encoding]::UTF8)
foreach ($styleMatch in [regex]::Matches($source, '(?s)<style\b[^>]*>(?<css>.*?)</style>')) {
$css = [regex]::Replace($styleMatch.Groups['css'].Value, '(?s)/\*.*?\*/', '')
foreach ($rule in [regex]::Matches($css, '(?s)(?<selector>[^{}]+)\{(?<body>[^{}]*)\}')) {
$selector = (($rule.Groups['selector'].Value -replace '(?s)^.*@media[^\{]*', '') -replace '\s+', ' ').Trim()
$body = $rule.Groups['body'].Value
if (-not $selector) { continue }
$height = [regex]::Match($body, '(?im)(?<![-\w])height\s*:\s*(?<value>\d+(?:\.\d+)?(?:rpx|px))\s*;')
if ($height.Success) {
Add-Risk -File $relative -Selector $selector -Risk 'fixed-content-height' -Evidence "height:$($height.Groups['value'].Value)"
}
$overflow = [regex]::Match($body, '(?im)(?<![-\w])overflow(?:-[xy])?\s*:\s*hidden\s*;')
if ($overflow.Success) {
Add-Risk -File $relative -Selector $selector -Risk 'clipping-overflow' -Evidence (($overflow.Value -replace '\s+', ' ').Trim())
}
$singleLine = [regex]::Match($body, '(?im)(white-space\s*:\s*nowrap|text-overflow\s*:\s*ellipsis|-webkit-line-clamp\s*:\s*\d+)\s*;')
if ($singleLine.Success) {
Add-Risk -File $relative -Selector $selector -Risk 'single-line-truncation' -Evidence (($singleLine.Value -replace '\s+', ' ').Trim())
}
$gridRows = [regex]::Match($body, '(?im)grid-template-rows\s*:\s*(?<value>[^;{}]+)\s*;')
if ($gridRows.Success -and $gridRows.Groups['value'].Value -match '(\d+(?:\.\d+)?%|\d+(?:\.\d+)?(?:rpx|px))') {
Add-Risk -File $relative -Selector $selector -Risk 'fixed-grid-track' -Evidence "grid-template-rows:$((($gridRows.Groups['value'].Value -replace '\s+', ' ').Trim()))"
}
$repeat = [regex]::Match($body, '(?im)grid-template-(?:columns|rows)\s*:\s*repeat\(\s*(?<count>\d+)\s*,')
if ($repeat.Success -and [int]$repeat.Groups['count'].Value -ge 12) {
Add-Risk -File $relative -Selector $selector -Risk 'fixed-capacity-canvas' -Evidence (($repeat.Value -replace '\s+', ' ').Trim())
}
}
}
}
$stale = @($allowed.Keys | Where-Object { -not $seenAllowed.ContainsKey($_) } | Sort-Object)
if ($stale.Count -gt 0) {
$stale | ForEach-Object { Write-Output "STALE ALLOWLIST :: $_" }
throw "DATA-DRIVEN-LAYOUT-CONTRACT found $($stale.Count) stale allowlist entries."
}
if ($violations.Count -gt 0) {
$violations | Sort-Object | ForEach-Object { Write-Output $_ }
throw "DATA-DRIVEN-LAYOUT-CONTRACT found $($violations.Count) non-allowlisted layout capacity risks."
}
Write-Output 'DATA-DRIVEN-LAYOUT-CONTRACT PASS'
File diff suppressed because one or more lines are too long
+188
View File
@@ -0,0 +1,188 @@
const assert = require('assert')
const origin = process.argv[2] || 'http://localhost:5173'
const sizes = [
{ width: 320, height: 568 },
{ width: 360, height: 640 },
{ width: 360, height: 800 },
{ width: 412, height: 915 }
]
const sleep = (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds))
const connect = async () => {
const pages = await (await fetch('http://127.0.0.1:9222/json/list')).json()
const projectPages = pages.filter((page) => page.type === 'page' && page.url.startsWith(origin))
assert.strictEqual(projectPages.length, 1, `Expected one project page, found ${projectPages.length}`)
const socket = new WebSocket(projectPages[0].webSocketDebuggerUrl)
await new Promise((resolve, reject) => {
socket.addEventListener('open', resolve, { once: true })
socket.addEventListener('error', reject, { once: true })
})
let id = 0
const pending = new Map()
socket.addEventListener('message', (event) => {
const message = JSON.parse(event.data)
const request = pending.get(message.id)
if (!request) return
pending.delete(message.id)
message.error ? request.reject(new Error(message.error.message)) : request.resolve(message.result)
})
const send = (method, params = {}) => new Promise((resolve, reject) => {
id += 1
pending.set(id, { resolve, reject })
socket.send(JSON.stringify({ id, method, params }))
})
return { socket, send }
}
const valueOf = async (send, expression) => {
const result = await send('Runtime.evaluate', { expression, returnByValue: true })
if (result.exceptionDetails) throw new Error(result.exceptionDetails.exception?.description || result.exceptionDetails.text)
return result.result?.value
}
const waitFor = async (send, expression, message) => {
for (let attempt = 0; attempt < 50; attempt += 1) {
if (await valueOf(send, expression)) return
await sleep(80)
}
throw new Error(message)
}
let auditId = 0
const open = async (send, route, query, selector) => {
auditId += 1
const url = `${origin}/?dataLayoutAudit=${auditId}#${route}${query}`
await send('Page.navigate', { url })
await waitFor(send, `location.href === ${JSON.stringify(url)}`, `Navigation failed: ${route}${query}`)
await waitFor(send, `Boolean(document.querySelector(${JSON.stringify(selector)}))`, `State did not render: ${selector}`)
}
const setSize = (send, size) => send('Emulation.setDeviceMetricsOverride', {
...size,
deviceScaleFactor: 1,
mobile: true,
screenWidth: size.width,
screenHeight: size.height
})
const assertViewport = async (send, label, expectedCount, selector, allowVisualClipping = false) => {
const metrics = await valueOf(send, `(() => {
const items = Array.from(document.querySelectorAll(${JSON.stringify(selector)}))
const last = items.at(-1)
last?.scrollIntoView({ block: 'end' })
const lastRect = last?.getBoundingClientRect()
return {
count: items.length,
documentWidth: document.documentElement.scrollWidth,
viewportWidth: innerWidth,
viewportHeight: innerHeight,
lastBottom: lastRect?.bottom || 0,
clipped: items.some((item) => item.scrollWidth > item.clientWidth + 1 || item.scrollHeight > item.clientHeight + 1)
}
})()`)
assert.strictEqual(metrics.count, expectedCount, `${label}: data count changed`)
assert(metrics.documentWidth <= metrics.viewportWidth + 1, `${label}: horizontal overflow ${JSON.stringify(metrics)}`)
assert(metrics.lastBottom <= metrics.viewportHeight + 1, `${label}: last item is not reachable ${JSON.stringify(metrics)}`)
if (!allowVisualClipping) assert.strictEqual(metrics.clipped, false, `${label}: a data item clips its content`)
}
const stressDirectory = async (send, size) => {
await setSize(send, size)
await open(send, '/pages/tree/t07-member-directory', '?genealogyId=1001', '.directory-state--list')
const prepared = await valueOf(send, `(() => {
const first = document.querySelector('.directory-card')
const parent = first?.parentElement
if (!first || !parent) return false
const originals = Array.from(parent.querySelectorAll('.directory-card'))
for (let index = originals.length; index < 50; index += 1) {
const clone = originals[index % originals.length].cloneNode(true)
clone.querySelector('.directory-card__name').textContent = '汤氏超长成员姓名用于三倍文案压力验证' + (index + 1)
clone.querySelector('.directory-card__meta').textContent = '第十世 · 超长字辈名称 · 超长支系与地区说明用于验证数据自然换行'
parent.appendChild(clone)
}
parent.querySelectorAll('.directory-card__name, .directory-card__meta').forEach((node) => {
node.style.fontSize = (parseFloat(getComputedStyle(node).fontSize) * 1.3) + 'px'
})
return true
})()`)
assert.strictEqual(prepared, true, 'T07 directory stress data could not be prepared')
await assertViewport(send, `T07 ${size.width}x${size.height}`, 50, '.directory-card')
}
const stressApplications = async (send, size) => {
await setSize(send, size)
await open(send, '/pages/genealogy/g09-my-applications', '', '.application-state--list')
assert(await valueOf(send, `(() => {
const first = document.querySelector('.application-card')
const parent = first?.parentElement
if (!first || !parent) return false
const originals = Array.from(parent.querySelectorAll('.application-card'))
for (let index = originals.length; index < 50; index += 1) {
const clone = originals[index % originals.length].cloneNode(true)
clone.querySelector('.application-card__name').textContent = '超长家谱名称与地区支系压力验证' + (index + 1)
clone.querySelector('.application-card__relation').textContent = '祖居河南南阳并迁居多地的三倍关系说明,验证卡片由数据自然撑高'
parent.appendChild(clone)
}
parent.querySelectorAll('.application-card__name, .application-card__relation, .application-card__hint').forEach((node) => {
node.style.fontSize = (parseFloat(getComputedStyle(node).fontSize) * 1.3) + 'px'
})
return true
})()`), 'G09 application stress data could not be prepared')
await assertViewport(send, `G09 ${size.width}x${size.height}`, 50, '.application-card')
}
const stressMedia = async (send, size) => {
await setSize(send, size)
await open(send, '/pages/family/f09-media-upload', '', '.media-upload-state--initial')
await valueOf(send, "document.querySelector('.media-primary-action')?.click()")
await waitFor(send, "document.querySelectorAll('.media-photo-tile').length === 4", 'F09 selected media did not render')
assert(await valueOf(send, `(() => {
const first = document.querySelector('.media-photo-tile')
const parent = first?.parentElement
if (!first || !parent) return false
const originals = Array.from(parent.querySelectorAll('.media-photo-tile'))
for (let index = originals.length; index < 30; index += 1) parent.appendChild(originals[index % originals.length].cloneNode(true))
return true
})()`), 'F09 media stress data could not be prepared')
await assertViewport(send, `F09 ${size.width}x${size.height}`, 30, '.media-photo-tile', true)
}
const stressAutoHeightForm = async (send) => {
await setSize(send, { width: 320, height: 568 })
await open(send, '/pages/genealogy/g08-join-application', '?source=search&genealogyId=2001', '.join-state--form')
const metrics = await valueOf(send, `(() => {
const textarea = document.querySelector('.join-field textarea')
const before = textarea.getBoundingClientRect().height
textarea.value = '这是用于验证表单由数据驱动自然增高的长说明。'.repeat(8)
textarea.dispatchEvent(new Event('input', { bubbles: true }))
return { before, after: textarea.getBoundingClientRect().height, documentWidth: document.documentElement.scrollWidth }
})()`)
await sleep(100)
const after = await valueOf(send, "document.querySelector('.join-field textarea').getBoundingClientRect().height")
assert(after > metrics.before * 1.5, `G08 auto-height textarea did not grow: ${JSON.stringify({ ...metrics, after })}`)
assert(metrics.documentWidth <= 321, 'G08 long form value caused horizontal overflow')
}
const run = async () => {
const { socket, send } = await connect()
try {
await send('Page.enable')
await send('Runtime.enable')
for (const size of sizes) await stressDirectory(send, size)
for (const size of [sizes[0], sizes[3]]) {
await stressApplications(send, size)
await stressMedia(send, size)
}
await stressAutoHeightForm(send)
process.stdout.write('DATA-DRIVEN-LAYOUT-RUNTIME-SMOKE PASS\n')
} finally {
try { await send('Emulation.clearDeviceMetricsOverride') } catch (_) {}
socket.close()
}
}
run().catch((error) => {
process.stderr.write(`${error.stack || error.message}\n`)
process.exit(1)
})
+23 -273
View File
@@ -4,11 +4,6 @@
"selector": ".app-dialog-layer", "selector": ".app-dialog-layer",
"reason": "全屏弹窗遮罩" "reason": "全屏弹窗遮罩"
}, },
{
"file": "components/AppDialog.vue",
"selector": ".app-dialog__skin",
"reason": "弹窗内部装饰皮肤"
},
{ {
"file": "components/AppToast.vue", "file": "components/AppToast.vue",
"selector": ".app-toast", "selector": ".app-toast",
@@ -34,31 +29,6 @@
"selector": ".header-hall", "selector": ".header-hall",
"reason": "固定顶部栏内部纯装饰祠堂层" "reason": "固定顶部栏内部纯装饰祠堂层"
}, },
{
"file": "components/PageHeader.vue",
"selector": ".notice-dot",
"reason": "依附通知按钮的局部未读角标"
},
{
"file": "pages/records/r02-person-detail.vue",
"selector": ".person-card-skin",
"reason": "依附卡片容器且不承担排版的装饰框层"
},
{
"file": "components/AppButton.vue",
"selector": ".app-button__skin",
"reason": "依附按钮容器且不承担文字排版的卷轴皮肤"
},
{
"file": "components/GenealogyCard.vue",
"selector": ".row-frame",
"reason": "依附家谱卡片容器的装饰框层"
},
{
"file": "components/GenealogyCard.vue",
"selector": ".surname-seal-frame",
"reason": "依附姓氏印章容器的装饰框层"
},
{ {
"file": "components/GenealogyPageBackground.vue", "file": "components/GenealogyPageBackground.vue",
"selector": ".genealogy-page-background", "selector": ".genealogy-page-background",
@@ -80,74 +50,9 @@
"reason": "独立模块背景层内部的底部背景画面" "reason": "独立模块背景层内部的底部背景画面"
}, },
{ {
"file": "components/ModulePage.vue", "file": "pages/family/f08-album-detail.vue",
"selector": ".module-lead image", "selector": ".album-photo-tile",
"reason": "依附模块导语容器且不承担文字排版的分隔装饰" "reason": "仅作为照片裁切层与底部说明叠层的精确局部边界"
},
{
"file": "components/ModulePage.vue",
"selector": ".form-row image, .detail-card image, .settings-row image, .list-card > image, .timeline-row > image",
"reason": "依附各内容容器且不承担正文排版的装饰框层"
},
{
"file": "components/ModulePage.vue",
"selector": ".status-card__skin",
"reason": "依附状态卡容器且不承担正文排版的装饰框层"
},
{
"file": "components/tree/TreeMemberForm.vue",
"selector": ".member-form-panel__skin",
"reason": "依附成员表单容器且不承担正文排版的装饰框层"
},
{
"file": "components/tree/TreeMemberForm.vue",
"selector": ".member-field image",
"reason": "依附成员字段容器且不承担表单排版的装饰框层"
},
{
"file": "components/tree/TreeMemberForm.vue",
"selector": ".member-form-action image",
"reason": "依附成员表单按钮且不承担文字排版的卷轴皮肤"
},
{
"file": "pages/records/r01-people-list.vue",
"selector": ".people-search > image",
"reason": "依附人物搜索容器且不承担输入排版的装饰框层"
},
{
"file": "pages/records/r01-people-list.vue",
"selector": ".person-card__skin",
"reason": "依附人物卡片且不承担正文排版的装饰框层"
},
{
"file": "pages/records/r01-people-list.vue",
"selector": ".people-result-empty > image, .people-state-card > image",
"reason": "依附人物状态卡且不承担正文排版的装饰框层"
},
{
"file": "pages/family/f01-family-feed.vue",
"selector": ".feed-shortcuts__skin",
"reason": "依附快捷入口容器且不承担文字排版的卷轴皮肤"
},
{
"file": "pages/family/f01-family-feed.vue",
"selector": ".feed-card__skin, .feed-state-card > image, .feed-action image",
"reason": "依附动态卡片、状态卡和按钮且不承担正文排版的装饰皮肤"
},
{
"file": "pages/family/f02-publish-feed.vue",
"selector": ".publish-panel__skin",
"reason": "依附发布面板且不承担正文排版的装饰框层"
},
{
"file": "pages/family/f02-publish-feed.vue",
"selector": ".publish-field image",
"reason": "依附发布文本域且不承担输入排版的装饰框层"
},
{
"file": "pages/family/f06-article-editor.vue",
"selector": ".editor-control > image",
"reason": "依附文章编辑控件且不承担输入排版的装饰框层"
}, },
{ {
"file": "pages/family/f08-album-detail.vue", "file": "pages/family/f08-album-detail.vue",
@@ -164,6 +69,11 @@
"selector": ".album-preview", "selector": ".album-preview",
"reason": "用户触发的全屏照片预览层" "reason": "用户触发的全屏照片预览层"
}, },
{
"file": "pages/family/f09-media-upload.vue",
"selector": ".media-photo-tile",
"reason": "仅作为照片缩略图角标与删除操作的精确局部边界"
},
{ {
"file": "pages/family/f09-media-upload.vue", "file": "pages/family/f09-media-upload.vue",
"selector": ".media-photo-order, .media-photo-current, .media-photo-status", "selector": ".media-photo-order, .media-photo-current, .media-photo-status",
@@ -174,66 +84,6 @@
"selector": ".media-photo-remove", "selector": ".media-photo-remove",
"reason": "依附照片缩略图右上角的删除操作" "reason": "依附照片缩略图右上角的删除操作"
}, },
{
"file": "pages/family/f10-video-list.vue",
"selector": ".video-status-lead image",
"reason": "依附视频状态导语的分隔装饰层"
},
{
"file": "pages/family/f10-video-list.vue",
"selector": ".video-status-card__skin",
"reason": "依附视频状态卡且不承担正文排版的装饰框层"
},
{
"file": "pages/notification/n01-message-center.vue",
"selector": ".notice-card__skin",
"reason": "依附消息卡且不承担正文排版的装饰框层"
},
{
"file": "pages/notification/n01-message-center.vue",
"selector": ".notice-state-card__skin",
"reason": "依附消息状态卡且不承担正文排版的装饰框层"
},
{
"file": "pages/profile/m01-profile-home.vue",
"selector": ".profile-hero__frame",
"reason": "依附个人摘要容器的装饰框层"
},
{
"file": "pages/profile/m01-profile-home.vue",
"selector": ".profile-hero__hall",
"reason": "个人摘要容器内的纯装饰祠堂层"
},
{
"file": "pages/profile/m01-profile-home.vue",
"selector": ".profile-hero__cloud",
"reason": "个人摘要容器内的纯装饰云纹层"
},
{
"file": "pages/profile/m01-profile-home.vue",
"selector": ".profile-scroll-notice__skin",
"reason": "依附提醒入口且不承担文字排版的卷轴皮肤"
},
{
"file": "pages/profile/m01-profile-home.vue",
"selector": ".profile-error__skin",
"reason": "依附个人资料错误卡且不承担正文排版的装饰框层"
},
{
"file": "pages/tree/t01-tree-overview.vue",
"selector": ".lineage-connector",
"reason": "依附世系画布且不承担节点排版的关系线绘制层"
},
{
"file": "pages/tree/t01-tree-overview.vue",
"selector": ".tree-state-card__skin",
"reason": "依附世系状态卡且不承担正文排版的装饰框层"
},
{
"file": "pages/tree/t01-tree-overview.vue",
"selector": ".tree-state-card__action image",
"reason": "依附世系状态按钮且不承担文字排版的卷轴皮肤"
},
{ {
"file": "pages/tree/t01-tree-overview.vue", "file": "pages/tree/t01-tree-overview.vue",
"selector": ".member-sheet", "selector": ".member-sheet",
@@ -244,150 +94,50 @@
"selector": ".member-sheet__skin", "selector": ".member-sheet__skin",
"reason": "固定底部详情抽屉内部的装饰框层" "reason": "固定底部详情抽屉内部的装饰框层"
}, },
{
"file": "pages/auth/a01-entry.vue",
"selector": ".brand-seal",
"reason": "依附登录品牌标题的印章装饰"
},
{
"file": "pages/auth/a01-entry.vue",
"selector": ".button-skin",
"reason": "依附登录按钮且不承担文字排版的卷轴皮肤"
},
{ {
"file": "pages/auth/a01-entry.vue", "file": "pages/auth/a01-entry.vue",
"selector": ".feedback-toast", "selector": ".feedback-toast",
"reason": "登录页面跨内容轻提示" "reason": "登录页面跨内容轻提示"
}, },
{
"file": "pages/auth/a01-entry.vue",
"selector": ".login-tab.active::after",
"reason": "依附激活登录标签的局部下划线指示"
},
{
"file": "pages/auth/a01-entry.vue",
"selector": ".login-tab:first-child::before",
"reason": "登录标签组内部的局部分隔装饰"
},
{
"file": "pages/auth/a01-entry.vue",
"selector": ".page-backdrop",
"reason": "不参与登录内容排版的页面背景层"
},
{ {
"file": "pages/auth/a01-entry.vue", "file": "pages/auth/a01-entry.vue",
"selector": ".verification-layer", "selector": ".verification-layer",
"reason": "用户触发的验证码弹窗遮罩" "reason": "用户触发的验证码弹窗遮罩"
}, },
{
"file": "pages/auth/a01-entry.vue",
"selector": ".verification-dialog__skin",
"reason": "验证码弹窗内部装饰框层"
},
{
"file": "pages/auth/a04-register.vue",
"selector": ".back-button",
"reason": "认证全屏页左上角返回操作"
},
{
"file": "pages/auth/a04-register.vue",
"selector": ".brand-seal",
"reason": "依附注册品牌标题的印章装饰"
},
{ {
"file": "pages/auth/a04-register.vue", "file": "pages/auth/a04-register.vue",
"selector": ".feedback-toast", "selector": ".feedback-toast",
"reason": "注册页面跨内容轻提示" "reason": "注册页面跨内容轻提示"
}, },
{
"file": "pages/auth/a04-register.vue",
"selector": ".page-backdrop",
"reason": "不参与注册内容排版的页面背景层"
},
{
"file": "pages/auth/a04-register.vue",
"selector": ".register-submit__skin",
"reason": "依附注册按钮且不承担文字排版的卷轴皮肤"
},
{
"file": "pages/auth/a05-reset-password.vue",
"selector": ".back-button",
"reason": "认证全屏页左上角返回操作"
},
{
"file": "pages/auth/a05-reset-password.vue",
"selector": ".brand-seal",
"reason": "依附重置密码品牌标题的印章装饰"
},
{ {
"file": "pages/auth/a05-reset-password.vue", "file": "pages/auth/a05-reset-password.vue",
"selector": ".feedback-toast", "selector": ".feedback-toast",
"reason": "重置密码页面跨内容轻提示" "reason": "重置密码页面跨内容轻提示"
}, },
{
"file": "pages/auth/a05-reset-password.vue",
"selector": ".page-backdrop",
"reason": "不参与重置密码内容排版的页面背景层"
},
{
"file": "pages/auth/a05-reset-password.vue",
"selector": ".reset-submit__skin, .success-action__skin",
"reason": "依附重置与成功操作按钮的卷轴皮肤"
},
{ {
"file": "pages/auth/a05-reset-password.vue", "file": "pages/auth/a05-reset-password.vue",
"selector": ".success-layer", "selector": ".success-layer",
"reason": "用户触发的重置成功弹窗遮罩" "reason": "用户触发的重置成功弹窗遮罩"
}, },
{
"file": "pages/auth/a05-reset-password.vue",
"selector": ".success-dialog__skin",
"reason": "重置成功弹窗内部装饰框层"
},
{
"file": "pages/auth/a06-auth-status.vue",
"selector": ".back-button",
"reason": "认证全屏页左上角返回操作"
},
{
"file": "pages/auth/a06-auth-status.vue",
"selector": ".brand-seal",
"reason": "依附认证状态品牌标题的印章装饰"
},
{
"file": "pages/auth/a06-auth-status.vue",
"selector": ".page-backdrop",
"reason": "不参与认证状态内容排版的页面背景层"
},
{
"file": "pages/auth/a06-auth-status.vue",
"selector": ".status-primary__skin, .recovery-action__skin",
"reason": "依附认证状态操作按钮的卷轴皮肤"
},
{ {
"file": "pages/auth/a06-auth-status.vue", "file": "pages/auth/a06-auth-status.vue",
"selector": ".recovery-layer", "selector": ".recovery-layer",
"reason": "用户触发的账号恢复弹窗遮罩" "reason": "用户触发的账号恢复弹窗遮罩"
}, },
{ {
"file": "pages/auth/a06-auth-status.vue", "file": "pages/genealogy/g01-my-genealogies.vue",
"selector": ".recovery-dialog__skin", "selector": ".add-dialog-layer",
"reason": "账号恢复弹窗内部装饰框层" "reason": "用户触发的固定底部添加家谱弹层"
}, },
{ {
"file": "pages/genealogy/g03-create-genealogy.vue", "file": "pages/genealogy/g01-my-genealogies.vue",
"selector": ".create-flow-panel__skin", "selector": ".genealogy-switcher-layer",
"reason": "依附创建家谱流程面板的装饰框层" "reason": "用户触发的固定家谱切换弹窗遮罩"
}, },
{ {
"file": "pages/genealogy/g03-create-genealogy.vue", "file": "pages/genealogy/g10-application-review.vue",
"selector": ".flow-header__skin", "selector": ".review-feedback",
"reason": "依附创建流程标题区的装饰皮肤" "reason": "审核操作后跨内容显示的固定轻提示"
},
{
"file": "pages/genealogy/g03-create-genealogy.vue",
"selector": ".flow-primary-action__skin",
"reason": "依附创建流程主操作的卷轴皮肤"
}, },
{ {
"file": "pages/genealogy/g03-create-genealogy.vue", "file": "pages/genealogy/g03-create-genealogy.vue",
@@ -395,13 +145,13 @@
"reason": "用户触发的重复提醒与成功弹窗遮罩" "reason": "用户触发的重复提醒与成功弹窗遮罩"
}, },
{ {
"file": "pages/genealogy/g03-create-genealogy.vue", "file": "pages/genealogy/g11-genealogy-settings.vue",
"selector": ".duplicate-reminder__skin, .flow-success-dialog__skin", "selector": ".settings-feedback",
"reason": "创建流程弹窗内部装饰框层" "reason": "保存设置后跨内容显示的固定轻提示"
}, },
{ {
"file": "pages/genealogy/g03-create-genealogy.vue", "file": "pages/genealogy/g12-generation-poems.vue",
"selector": ".duplicate-reminder__search image, .duplicate-reminder__confirm image, .flow-success-dialog__action image", "selector": ".poem-feedback",
"reason": "创建流程弹窗操作按钮的卷轴皮肤" "reason": "保存字辈后跨内容显示的固定轻提示"
} }
] ]
+17 -5
View File
@@ -3,6 +3,7 @@ $root = Split-Path $PSScriptRoot -Parent
$allowlistPath = Join-Path $PSScriptRoot 'document-flow-position-allowlist.json' $allowlistPath = Join-Path $PSScriptRoot 'document-flow-position-allowlist.json'
$allowlist = Get-Content -LiteralPath $allowlistPath -Raw -Encoding UTF8 | ConvertFrom-Json $allowlist = Get-Content -LiteralPath $allowlistPath -Raw -Encoding UTF8 | ConvertFrom-Json
$allowed = @{} $allowed = @{}
$seenAllowed = @{}
foreach ($entry in $allowlist) { foreach ($entry in $allowlist) {
$allowed["$($entry.file)::$($entry.selector)"] = $entry.reason $allowed["$($entry.file)::$($entry.selector)"] = $entry.reason
} }
@@ -15,20 +16,31 @@ foreach ($file in $files) {
foreach ($styleMatch in [regex]::Matches($source, '(?s)<style\b[^>]*>(?<css>.*?)</style>')) { foreach ($styleMatch in [regex]::Matches($source, '(?s)<style\b[^>]*>(?<css>.*?)</style>')) {
$css = $styleMatch.Groups['css'].Value $css = $styleMatch.Groups['css'].Value
foreach ($rule in [regex]::Matches($css, '(?s)(?<selector>[^{}]+)\{(?<body>[^{}]*)\}')) { foreach ($rule in [regex]::Matches($css, '(?s)(?<selector>[^{}]+)\{(?<body>[^{}]*)\}')) {
$body = $rule.Groups['body'].Value $body = [regex]::Replace($rule.Groups['body'].Value, '(?s)/\*.*?\*/', '')
if ($body -notmatch '(?m)position\s*:\s*(absolute|fixed|sticky)\s*;') { continue } $positionMatches = [regex]::Matches($body, '(?im)(?<![-\w])position\s*:\s*(?<value>[^;{}]+?)\s*;')
if ($positionMatches.Count -eq 0) { continue }
$selector = (($rule.Groups['selector'].Value -replace '(?s)^.*@media[^\{]*', '') -replace '\s+', ' ').Trim() $selector = (($rule.Groups['selector'].Value -replace '(?s)^.*@media[^\{]*', '') -replace '\s+', ' ').Trim()
$key = "$relative::$selector" $key = "$relative::$selector"
if (-not $allowed.ContainsKey($key)) { if ($allowed.ContainsKey($key)) {
$position = [regex]::Match($body, '(?m)position\s*:\s*(absolute|fixed|sticky)\s*;').Groups[1].Value $seenAllowed[$key] = $true
} else {
foreach ($positionMatch in $positionMatches) {
$position = ($positionMatch.Groups['value'].Value -replace '\s+', ' ').Trim()
$violations.Add("$relative :: $selector :: position:$position") $violations.Add("$relative :: $selector :: position:$position")
} }
} }
} }
} }
}
$staleAllowlistEntries = @($allowed.Keys | Where-Object { -not $seenAllowed.ContainsKey($_) } | Sort-Object)
if ($staleAllowlistEntries.Count -gt 0) {
$staleAllowlistEntries | ForEach-Object { Write-Output "STALE ALLOWLIST :: $_" }
throw "DOCUMENT-FLOW-POSITION-CONTRACT found $($staleAllowlistEntries.Count) stale allowlist entries."
}
if ($violations.Count -gt 0) { if ($violations.Count -gt 0) {
$violations | Sort-Object | ForEach-Object { Write-Output $_ } $violations | Sort-Object | ForEach-Object { Write-Output $_ }
throw "DOCUMENT-FLOW-POSITION-CONTRACT found $($violations.Count) non-overlay positioning rules." throw "DOCUMENT-FLOW-POSITION-CONTRACT found $($violations.Count) non-allowlisted position declarations."
} }
Write-Output 'DOCUMENT-FLOW-POSITION-CONTRACT PASS' Write-Output 'DOCUMENT-FLOW-POSITION-CONTRACT PASS'
+50
View File
@@ -0,0 +1,50 @@
$ErrorActionPreference = 'Stop'
$root = Split-Path -Parent $PSScriptRoot
function Read-Utf8([string]$relativePath) {
return Get-Content -LiteralPath (Join-Path $root $relativePath) -Raw -Encoding UTF8
}
function Assert-Contains([string]$source, [string]$expected, [string]$page) {
if (-not $source.Contains($expected)) { throw "$page missing F business-flow anchor: $expected" }
}
$contracts = [ordered]@{
'pages/family/f03-feed-detail.vue' = @(
'feedComments', 'commentDraft', 'submitComment', 'feed-state--expired',
'feedId', 'comment-state--saving', 'comment-state--error',
'/pages/family/f01-family-feed'
)
'pages/family/f04-article-list.vue' = @(
'articleCategories', 'filteredArticles', 'openArticle', 'createArticle',
'article-list-state--loading', 'article-list-state--empty', 'article-list-state--error',
'/pages/family/f05-article-detail?articleId=', '/pages/family/f06-article-editor?mode=create'
)
'pages/family/f05-article-detail.vue' = @(
'articleParagraphs', 'toggleFavorite', 'article-state--expired', 'backToArticles',
'articleId', 'article-state--privacy', 'article-state--error',
'/pages/family/f04-article-list', '/pages/family/f06-article-editor?mode=edit&articleId='
)
'pages/family/f07-album-list.vue' = @(
'albums', 'openAlbum', 'createAlbum', 'album-state--empty',
'album-list-state--loading', 'album-list-state--error', 'albumNameDraft',
'/pages/family/f08-album-detail?albumId='
)
}
foreach ($entry in $contracts.GetEnumerator()) {
$source = Read-Utf8 $entry.Key
foreach ($anchor in $entry.Value) { Assert-Contains $source $anchor $entry.Key }
foreach ($required in @('ModulePageBackground', 'PageHeader', 'AppButton', 'AppLoading')) {
Assert-Contains $source $required $entry.Key
}
foreach ($forbidden in @('import ModulePage from', 'uni.showToast', 'uni.showModal')) {
if ($source.Contains($forbidden)) { throw "$($entry.Key) retains forbidden implementation: $forbidden" }
}
if ($source -match '<ModulePage(?:\s|/|>)') { throw "$($entry.Key) retains forbidden ModulePage owner" }
if ($source -match '(?im)(?<![-\w])position\s*:') { throw "$($entry.Key) ordinary content must not use position" }
if ($source -match '(?im)overflow\s*:\s*hidden') { throw "$($entry.Key) must not clip data-driven content" }
}
Write-Output 'F-BUSINESS-FLOW-CONTRACT PASS'
+90
View File
@@ -0,0 +1,90 @@
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, 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((item) => item.type === "page" && item.url.startsWith(`${origin}/`));
if (!page) throw new Error(`Chrome debugging has no ${origin} project 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 index = 0; index < 60; index += 1) {
if (await valueOf(send, expression)) return;
await sleep(100);
}
throw new Error(message);
};
let auditId = 0;
const open = async (send, route, selector) => {
auditId += 1;
const url = `${origin}/?fBusiness=${auditId}#${route}`;
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)}))`, `missing ${selector}: ${route}`);
};
const click = (send, selector) => valueOf(send, `document.querySelector(${JSON.stringify(selector)}).click()`);
const setInput = (send, selector, value) => valueOf(send, `(() => { const input = document.querySelector(${JSON.stringify(selector)}); input.value = ${JSON.stringify(value)}; input.dispatchEvent(new Event('input', { bubbles: true })); return input.value; })()`);
const run = async () => {
const { socket, send } = await connect();
try {
await send("Page.enable");
await send("Runtime.enable");
for (const size of [{ width: 320, height: 568 }, { width: 412, height: 915 }]) {
await send("Emulation.setDeviceMetricsOverride", { ...size, deviceScaleFactor: 1, mobile: true, screenWidth: size.width, screenHeight: size.height });
await open(send, "/pages/family/f04-article-list?count=50", ".article-card");
if ((await valueOf(send, "document.querySelectorAll('.article-card').length")) !== 50) throw new Error(`F04 did not render 50 articles at ${size.width}`);
if ((await valueOf(send, "document.documentElement.scrollWidth")) > size.width + 1) throw new Error(`F04 horizontal overflow at ${size.width}`);
await valueOf(send, "document.querySelector('.article-card:last-of-type').scrollIntoView()");
await open(send, "/pages/family/f07-album-list?count=30", ".album-card");
if ((await valueOf(send, "document.querySelectorAll('.album-card').length")) !== 30) throw new Error(`F07 did not render 30 albums at ${size.width}`);
if ((await valueOf(send, "document.documentElement.scrollWidth")) > size.width + 1) throw new Error(`F07 horizontal overflow at ${size.width}`);
}
await open(send, "/pages/family/f04-article-list", ".article-card");
await click(send, ".article-card");
await waitFor(send, "location.hash.includes('/pages/family/f05-article-detail?articleId=101')", "F04 card did not open ID-driven F05");
await open(send, "/pages/family/f03-feed-detail?feedId=1", ".feed-comment-form textarea");
const before = await valueOf(send, "document.querySelectorAll('.feed-comment-card').length");
await setInput(send, ".feed-comment-form textarea", "愿家人岁岁平安,常聚常新。");
await click(send, ".feed-comment-form .app-button");
await waitFor(send, `document.querySelectorAll('.feed-comment-card').length === ${before + 1}`, "F03 comment was not appended");
await open(send, "/pages/family/f07-album-list", ".album-list > .app-button");
await click(send, ".album-list > .app-button");
await waitFor(send, "Boolean(document.querySelector('.app-dialog-layer'))", "F07 create dialog did not open");
await setInput(send, ".album-dialog-field input", "清明祭祖影像");
await click(send, ".app-dialog__actions .app-button:last-child");
await waitFor(send, "document.querySelector('.album-card .album-card__copy').innerText.includes('清明祭祖影像')", "F07 created album was not added");
process.stdout.write("F-BUSINESS-FLOW-RUNTIME-SMOKE PASS\n");
} finally {
try { await send("Emulation.clearDeviceMetricsOverride"); } catch (_) {}
socket.close();
}
};
run().catch((error) => { process.stderr.write(`${error.stack || error.message}\n`); process.exit(1); });
+12 -11
View File
@@ -2,26 +2,27 @@ $ErrorActionPreference = 'Stop'
function Read-Utf8([string]$Path) { [System.IO.File]::ReadAllText((Join-Path (Join-Path $PSScriptRoot '..') $Path), [System.Text.Encoding]::UTF8) } function Read-Utf8([string]$Path) { [System.IO.File]::ReadAllText((Join-Path (Join-Path $PSScriptRoot '..') $Path), [System.Text.Encoding]::UTF8) }
function Assert-Contains([string]$Content, [string]$Expected, [string]$Message) { if (-not $Content.Contains($Expected)) { throw $Message } } function Assert-Contains([string]$Content, [string]$Expected, [string]$Message) { if (-not $Content.Contains($Expected)) { throw $Message } }
function Assert-Match([string]$Content, [string]$Pattern, [string]$Message) { if ($Content -notmatch $Pattern) { throw $Message } }
$f01 = Read-Utf8 'pages/family/f01-family-feed.vue' $f01 = Read-Utf8 'pages/family/f01-family-feed.vue'
$f02 = Read-Utf8 'pages/family/f02-publish-feed.vue' $f02 = Read-Utf8 'pages/family/f02-publish-feed.vue'
$module = Read-Utf8 'components/ModulePage.vue' $module = Read-Utf8 'components/ModulePage.vue'
foreach ($expected in @('font-size:24rpx','font-size:23rpx','font-size:22rpx')) { foreach ($expected in @('24rpx','23rpx','22rpx')) {
Assert-Contains $f01 $expected "F01 readability token missing: $expected" Assert-Match $f01 "font-size:\s*$expected" "F01 readability token missing: $expected"
} }
foreach ($expected in @('font-size:24rpx','font-size:23rpx')) { foreach ($expected in @('24rpx','23rpx')) {
Assert-Contains $f02 $expected "F02 readability token missing: $expected" Assert-Match $f02 "font-size:\s*$expected" "F02 readability token missing: $expected"
} }
foreach ($expected in @( foreach ($expected in @(
'.module-lead { position: relative; height: 56rpx; margin: 0 12rpx 18rpx; color: $ink-muted; font-size: 23rpx;', '(?s)\.module-lead\s*\{[^}]*height:\s*56rpx;[^}]*font-size:\s*23rpx;',
'.form-row > text { position: absolute; top: 28rpx; left: 26rpx; z-index: 1; color: $ink; font-size: 24rpx;', '(?s)\.form-row > text\s*\{[^}]*font-size:\s*24rpx;',
'.form-row input { position: absolute; top: 0; right: 22rpx; bottom: 0; left: 178rpx; z-index: 1; height: 84rpx; color: $ink; font-size: 23rpx;', '(?s)\.form-row input\s*\{[^}]*height:\s*56rpx;[^}]*font-size:\s*23rpx;',
'.list-card__copy text:nth-child(2) { margin-top: 10rpx; color: $ink-muted; font-size: 23rpx;', '(?s)\.list-card__copy text:nth-child\(2\)\s*\{[^}]*font-size:\s*23rpx;',
'.detail-card text:last-child { margin-top: 10rpx; color: $ink; font-size: 24rpx;', '(?s)\.detail-card text:last-child\s*\{[^}]*font-size:\s*24rpx;',
'.status-card__note { display: block; margin-top: 17rpx; color: $ink-muted; font-size: 24rpx;' '(?s)\.status-card__note\s*\{[^}]*font-size:\s*24rpx;'
)) { )) {
Assert-Contains $module $expected "ModulePage readability contract missing: $expected" Assert-Match $module $expected "ModulePage readability contract missing: $expected"
} }
Write-Output 'F series all-states visual contract passed.' Write-Output 'F series all-states visual contract passed.'
+9 -7
View File
@@ -2,14 +2,16 @@ $ErrorActionPreference = 'Stop'
$root = Split-Path $PSScriptRoot -Parent $root = Split-Path $PSScriptRoot -Parent
$page = [System.IO.File]::ReadAllText((Join-Path $root 'pages/family/f01-family-feed.vue'), [System.Text.Encoding]::UTF8) $page = [System.IO.File]::ReadAllText((Join-Path $root 'pages/family/f01-family-feed.vue'), [System.Text.Encoding]::UTF8)
foreach ($expected in @('box-sizing: border-box','pointer-events: none')) { foreach ($expected in @(
'box-sizing: border-box',
'width: 514rpx',
'background: url("/static/assets/foundation/transparent/a01-scroll-secondary-v3.png") center / 100% 100% no-repeat',
'background: url("/static/assets/modules/family/transparent/f01-family-letter-card.png") center / 100% 100% no-repeat',
'background: url("/static/assets/foundation/transparent/a01-scroll-primary-v3.png") center / 100% 100% no-repeat'
)) {
if (-not $page.Contains($expected)) { throw "F01 document-flow contract missing: $expected" } if (-not $page.Contains($expected)) { throw "F01 document-flow contract missing: $expected" }
} }
foreach ($selector in @('.feed-card__copy','.feed-state-card > view')) { if ($page -match '<image\s+(?:[^>]*\s)?class="(?:feed-shortcuts__skin|feed-card__skin)"|feed-state-card\s*>\s*<image|feed-action[^>]*>\s*<image') { throw 'F01 decorative skins must be container backgrounds' }
$escaped = [regex]::Escape($selector) if ($page -match 'position\s*:') { throw 'F01 must keep ordinary content in document flow' }
if ($page -match "(?s)$escaped\s*\{[^}]*position\s*:\s*(absolute|fixed|sticky)\s*;") {
throw "F01 normal content selector uses positioning: $selector"
}
}
Write-Output 'F01-DOCUMENT-FLOW-CONTRACT PASS' Write-Output 'F01-DOCUMENT-FLOW-CONTRACT PASS'
+3 -2
View File
@@ -13,7 +13,8 @@ Assert-Match 'class="feed-card__meta"' 'F01 must expose category and time as sec
Assert-Match 'class="feed-card__summary"' 'F01 must expose feed summary copy.' Assert-Match 'class="feed-card__summary"' 'F01 must expose feed summary copy.'
Assert-Match 'class="feed-card__author"' 'F01 must expose the author as tertiary information.' Assert-Match 'class="feed-card__author"' 'F01 must expose the author as tertiary information.'
Assert-Match '(?s)\.feed-shortcut\s*\{[^}]*min-height:\s*44px;' 'F01 shortcuts must preserve a 44 CSS px touch height.' Assert-Match '(?s)\.feed-shortcut\s*\{[^}]*min-height:\s*44px;' 'F01 shortcuts must preserve a 44 CSS px touch height.'
Assert-Match 'class="feed-shortcuts__skin"' 'F01 shortcuts must share one visible navigation skin.' Assert-Match 'a01-scroll-secondary-v3\.png' 'F01 shortcuts must share one visible navigation skin.'
Assert-Match '(?s)\.feed-shortcuts\s*\{[^}]*grid-template-columns:\s*repeat\(4,minmax\(0,1fr\)\);' 'F01 shortcuts must use one four-column equal-width navigation strip.' Assert-Match '(?s)\.feed-shortcuts\s*\{[^}]*grid-template-columns:\s*repeat\(4,\s*minmax\(0,\s*1fr\)\);' 'F01 shortcuts must use one four-column equal-width navigation strip.'
if ($page -match 'position\s*:') { throw 'F01 must keep ordinary content in document flow.' }
Write-Output 'F01-MODULE-BASELINE-CONTRACT PASS' Write-Output 'F01-MODULE-BASELINE-CONTRACT PASS'
+9
View File
@@ -0,0 +1,9 @@
$ErrorActionPreference = 'Stop'
$root = Split-Path -Parent $PSScriptRoot
$page = Get-Content -LiteralPath (Join-Path $root 'pages/family/f01-family-feed.vue') -Raw -Encoding UTF8
foreach ($route in @('/pages/records/r01-people-list','/pages/records/r03-gift-list','/pages/records/r11-merit-records','/pages/family/f10-video-list')) {
if (-not $page.Contains($route)) { throw "F01 product entry missing: $route" }
}
if ($page -match '(?s)\.family-page\s*\{[^}]*overflow:\s*hidden') { throw 'F01 must not clip long page content' }
if (-not $page.Contains('genealogyId=${genealogyId.value}')) { throw 'F01 child routes must preserve genealogy context' }
Write-Output 'F01-PRODUCT-ENTRY-CONTRACT PASS'
+15
View File
@@ -0,0 +1,15 @@
$ErrorActionPreference = 'Stop'
$root = Split-Path $PSScriptRoot -Parent
$page = [System.IO.File]::ReadAllText((Join-Path $root 'pages/family/f02-publish-feed.vue'), [System.Text.Encoding]::UTF8)
foreach ($expected in @(
'background: url("/static/assets/modules/family/transparent/module-content-frame.png") center / 100% 100% no-repeat',
'background: url("/static/assets/modules/family/transparent/module-field-frame.png") center / 100% 100% no-repeat',
'min-height: min(640px, calc((100vw - 16px) * 1.48))'
)) {
if (-not $page.Contains($expected)) { throw "F02 document-flow contract missing: $expected" }
}
if ($page -match '<image\s+(?:[^>]*\s)?class="publish-panel__skin"|publish-field[^>]*>\s*<image') { throw 'F02 decorative frames must be container backgrounds' }
if ($page -match 'position\s*:') { throw 'F02 must keep panel, form, result, and field in document flow' }
Write-Output 'F02-DOCUMENT-FLOW-CONTRACT PASS'
+2
View File
@@ -53,6 +53,8 @@ foreach ($forbidden in @('<template><ModulePage', 'import ModulePage from', 'pag
if ($page.Contains($forbidden)) { throw "F06 retains forbidden dependency: $forbidden" } if ($page.Contains($forbidden)) { throw "F06 retains forbidden dependency: $forbidden" }
} }
if ($page -match 'position\s*:') { throw 'F06 must keep editor content in document flow' }
if ($catalog -match '(?m)^\s*f06\s*:') { throw 'F06 obsolete page-catalog owner must be removed' } if ($catalog -match '(?m)^\s*f06\s*:') { throw 'F06 obsolete page-catalog owner must be removed' }
Write-Output 'F06-ARTICLE-EDITOR-CONTRACT PASS' Write-Output 'F06-ARTICLE-EDITOR-CONTRACT PASS'
+8
View File
@@ -0,0 +1,8 @@
$ErrorActionPreference = 'Stop'
$root = Split-Path -Parent $PSScriptRoot
$page = Get-Content -LiteralPath (Join-Path $root 'pages/family/f06-article-editor.vue') -Raw -Encoding UTF8
foreach ($token in @('articleId', 'editorMode', 'simulateSaveFailure', 'query.articleId', '/pages/family/f05-article-detail?articleId=${articleId.value}')) {
if (-not $page.Contains($token)) { throw "F06 editor context missing: $token" }
}
if ($page.Contains('form.title.trim() === "保存失败"')) { throw 'F06 must not use visible title as a failure control channel' }
Write-Output 'F06-EDITOR-CONTEXT-CONTRACT PASS'
+13 -4
View File
@@ -26,14 +26,14 @@ foreach ($expected in @(
'const photos = [', 'const photos = [',
'const openPreview = (index) =>', 'const openPreview = (index) =>',
'const closePreview = () =>', 'const closePreview = () =>',
'const showUploadNotice = () =>', 'const toUpload = () =>',
'/pages/family/f09-media-upload?albumId=${albumId.value}',
'query.state === "empty"', 'query.state === "empty"',
'query.state === "preview"', 'query.state === "preview"',
'query.state === "expired"', 'query.state === "expired"',
'class="album-empty-state"', 'class="album-empty-state"',
'class="album-expired-state"', 'class="album-expired-state"',
'class="album-preview__position"', 'class="album-preview__position"',
':message="uploadNoticeMessage"',
'f08-reunion-hero.png', 'f08-reunion-hero.png',
'f08-family-portrait.png', 'f08-family-portrait.png',
'f08-reunion-table.png', 'f08-reunion-table.png',
@@ -42,8 +42,7 @@ foreach ($expected in @(
':alt="photo.alt"', ':alt="photo.alt"',
'ModulePageBackground', 'ModulePageBackground',
'PageHeader', 'PageHeader',
'AppButton', 'AppButton'
'AppToast'
)) { )) {
Assert-Contains $page $expected "F08 contract missing: $expected" Assert-Contains $page $expected "F08 contract missing: $expected"
} }
@@ -52,6 +51,16 @@ foreach ($forbidden in @('<ModulePage page-id="f08"', 'import ModulePage from',
if ($page.Contains($forbidden)) { throw "F08 retains forbidden dependency: $forbidden" } if ($page.Contains($forbidden)) { throw "F08 retains forbidden dependency: $forbidden" }
} }
foreach ($requiredPosition in @(
'(?s)\.album-photo-tile\s*\{[^}]*position:\s*relative;',
'(?s)\.album-hero-photo,\s*\.album-photo-tile__image\s*\{[^}]*position:\s*absolute;',
'(?s)\.album-photo-tile__caption\s*\{[^}]*position:\s*absolute;',
'(?s)\.album-preview\s*\{[^}]*position:\s*fixed;'
)) {
if ($page -notmatch $requiredPosition) { throw "F08 required local overlay boundary missing: $requiredPosition" }
}
if ([regex]::Matches($page, 'position\s*:').Count -ne 4) { throw 'F08 must keep only its photo-tile overlays and full-screen preview positioned' }
if ($catalog -match '(?m)^\s*f08\s*:') { if ($catalog -match '(?m)^\s*f08\s*:') {
throw 'F08 obsolete page-catalog owner must be removed' throw 'F08 obsolete page-catalog owner must be removed'
} }

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