修改完成

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
+32 -50
View File
@@ -374,19 +374,17 @@ const prepareAgreement = () => showFeedback("协议页面准备中");
<style scoped lang="scss">
.auth-page {
position: relative;
box-sizing: border-box;
width: 100%;
height: 100dvh;
min-height: 100%;
overflow-x: hidden;
overflow-y: auto;
background: #f7f0e5;
color: #493323;
}
.page-canvas {
position: relative;
display: grid;
box-sizing: border-box;
width: 100%;
min-height: calc(1665rpx + env(safe-area-inset-bottom));
@@ -394,28 +392,28 @@ const prepareAgreement = () => showFeedback("协议页面准备中");
}
.page-backdrop {
position: absolute;
z-index: 0;
inset: 0 0 auto;
grid-area: 1 / 1;
align-self: start;
width: 100%;
height: 1665rpx;
pointer-events: none;
}
.brand-seal {
position: absolute;
z-index: 1;
top: 64rpx;
left: 50%;
grid-area: 1 / 1;
align-self: start;
justify-self: center;
width: 184rpx;
height: 221rpx;
transform: translateX(-50%);
margin-top: 64rpx;
pointer-events: none;
}
.login-content {
position: relative;
z-index: 2;
grid-area: 1 / 1;
box-sizing: border-box;
width: 100%;
padding: 480rpx 64rpx 34rpx;
@@ -426,7 +424,7 @@ const prepareAgreement = () => showFeedback("协议页面准备中");
flex-direction: column;
align-items: center;
justify-content: center;
height: 142rpx;
min-height: 142rpx;
}
.login-title {
@@ -452,7 +450,6 @@ const prepareAgreement = () => showFeedback("协议页面准备中");
}
.login-tab {
position: relative;
display: flex;
flex: 1;
align-items: center;
@@ -464,30 +461,21 @@ const prepareAgreement = () => showFeedback("协议页面准备中");
}
.login-tab:first-child::before {
position: absolute;
top: 50%;
right: 0;
width: 1rpx;
height: 36rpx;
background: rgba(176, 120, 52, 0.58);
content: "";
transform: translateY(-50%);
content: none;
}
.login-tab:first-child {
border-right: 1rpx solid rgba(176, 120, 52, 0.58);
}
.login-tab.active {
background: linear-gradient(#ad160d, #ad160d) center bottom /
calc(100% - 116rpx) 5rpx no-repeat;
color: #a9160d;
font-weight: 700;
}
.login-tab.active::after {
position: absolute;
right: 58rpx;
bottom: 0;
left: 58rpx;
height: 5rpx;
border-radius: 5rpx;
background: #ad160d;
content: "";
content: none;
}
.form-content {
@@ -518,7 +506,7 @@ const prepareAgreement = () => showFeedback("协议页面准备中");
.auth-input {
flex: 1;
min-width: 0;
height: 104rpx;
min-height: 104rpx;
color: #493323;
font-size: 40rpx;
}
@@ -550,7 +538,6 @@ const prepareAgreement = () => showFeedback("协议页面准备中");
min-height: 88rpx;
color: #a9160d;
font-size: 32rpx;
white-space: nowrap;
}
.form-secondary-row {
@@ -570,28 +557,24 @@ const prepareAgreement = () => showFeedback("协议页面准备中");
.login-submit,
.wechat-login {
position: relative;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
display: grid;
place-items: center;
}
.login-submit {
height: 92rpx;
min-height: 92rpx;
}
.button-skin {
position: absolute;
inset: 0;
grid-area: 1 / 1;
width: 100%;
height: 100%;
pointer-events: none;
}
.login-submit__copy {
position: relative;
z-index: 1;
grid-area: 1 / 1;
color: #fffaf1;
font-size: 52rpx;
letter-spacing: 8rpx;
@@ -600,7 +583,7 @@ const prepareAgreement = () => showFeedback("协议页面准备中");
.other-login-divider {
display: flex;
align-items: center;
height: 100rpx;
min-height: 100rpx;
color: #9f6a27;
font-size: 32rpx;
white-space: nowrap;
@@ -625,16 +608,16 @@ const prepareAgreement = () => showFeedback("协议页面准备中");
}
.wechat-login {
height: 100rpx;
min-height: 100rpx;
color: #493323;
font-size: 40rpx;
letter-spacing: 3rpx;
}
.wechat-login__content {
position: relative;
z-index: 1;
display: flex;
grid-area: 1 / 1;
align-items: center;
justify-content: center;
}
@@ -689,7 +672,6 @@ const prepareAgreement = () => showFeedback("协议页面准备中");
flex: 0 1 auto;
align-items: center;
min-width: 0;
white-space: nowrap;
}
.agreement-link {
@@ -750,7 +732,6 @@ const prepareAgreement = () => showFeedback("协议页面准备中");
}
.feedback-toast__copy {
position: relative;
z-index: 1;
padding: 16rpx 36rpx;
color: #5c4330;
@@ -770,26 +751,27 @@ const prepareAgreement = () => showFeedback("协议页面准备中");
}
.verification-dialog {
position: relative;
display: grid;
width: 620rpx;
height: 520rpx;
min-height: 520rpx;
max-height: calc(100vh - 80rpx);
}
.verification-dialog__skin {
position: absolute;
inset: 0;
grid-area: 1 / 1;
width: 100%;
height: 100%;
}
.verification-dialog__content {
position: relative;
z-index: 1;
display: flex;
grid-area: 1 / 1;
flex-direction: column;
align-items: center;
box-sizing: border-box;
height: 100%;
min-height: 100%;
overflow-y: auto;
padding: 90rpx 64rpx 54rpx;
}
+25 -29
View File
@@ -229,18 +229,16 @@ const submitRegister = () => {
<style scoped lang="scss">
.auth-page {
position: relative;
width: 100%;
height: 100dvh;
min-height: 100%;
overflow-x: hidden;
overflow-y: auto;
background: #f7f0e4;
color: #493323;
}
.page-canvas {
position: relative;
display: grid;
box-sizing: border-box;
width: 100%;
min-height: calc(1665rpx + env(safe-area-inset-bottom));
@@ -248,48 +246,49 @@ const submitRegister = () => {
}
.page-backdrop {
position: absolute;
inset: 0 0 auto;
grid-area: 1 / 1;
align-self: start;
width: 100%;
height: 1665rpx;
pointer-events: none;
}
.brand-seal {
position: absolute;
z-index: 1;
top: 64rpx;
left: 50%;
grid-area: 1 / 1;
align-self: start;
justify-self: center;
width: 184rpx;
height: 221rpx;
transform: translateX(-50%);
margin-top: 64rpx;
pointer-events: none;
}
.register-content {
position: relative;
z-index: 2;
grid-area: 1 / 1;
box-sizing: border-box;
padding: 430rpx 64rpx 46rpx;
}
.page-heading {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
display: grid;
grid-template-rows: auto auto auto;
justify-items: center;
min-height: 154rpx;
}
.back-button {
position: absolute;
top: 10rpx;
left: 0;
display: flex;
grid-row: 1 / -1;
grid-column: 1;
align-self: start;
justify-self: start;
align-items: center;
justify-content: center;
width: 88rpx;
height: 88rpx;
margin-top: 10rpx;
}
.back-button__icon {
@@ -299,6 +298,7 @@ const submitRegister = () => {
}
.page-title {
grid-row: 1;
color: #9f170f;
font-size: 66rpx;
font-weight: 700;
@@ -306,6 +306,7 @@ const submitRegister = () => {
}
.page-subtitle {
grid-row: 2;
margin-top: 12rpx;
color: #806c58;
font-size: 25rpx;
@@ -313,6 +314,7 @@ const submitRegister = () => {
}
.register-divider {
grid-row: 3;
width: 300rpx;
height: 50rpx;
filter: brightness(0.68) saturate(1.5) contrast(1.2);
@@ -342,7 +344,7 @@ const submitRegister = () => {
.auth-input {
flex: 1;
min-width: 0;
height: 96rpx;
min-height: 96rpx;
color: #3f2c1d;
font-size: 29rpx;
}
@@ -368,25 +370,21 @@ const submitRegister = () => {
}
.register-submit {
position: relative;
display: flex;
align-items: center;
justify-content: center;
height: 104rpx;
display: grid;
place-items: center;
min-height: 104rpx;
margin-top: 36rpx;
overflow: hidden;
}
.register-submit__skin {
position: absolute;
inset: 0;
grid-area: 1 / 1;
width: 100%;
height: 100%;
}
.register-submit__content {
position: relative;
z-index: 1;
grid-area: 1 / 1;
color: #fffaf0;
font-size: 39rpx;
letter-spacing: 6rpx;
@@ -417,7 +415,6 @@ const submitRegister = () => {
.agreement-copy {
display: flex;
align-items: center;
white-space: nowrap;
}
.agreement-link {
@@ -490,7 +487,6 @@ const submitRegister = () => {
}
.feedback-toast__copy {
position: relative;
z-index: 1;
padding: 16rpx 36rpx;
color: #5c4330;
+41 -49
View File
@@ -157,7 +157,11 @@
mode="aspectFit"
/>
<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-copy">请返回登录页使用新密码登录</text>
<view
@@ -259,18 +263,16 @@ const submitReset = () => {
<style scoped lang="scss">
.auth-page {
position: relative;
width: 100%;
height: 100dvh;
min-height: 100%;
overflow-x: hidden;
overflow-y: auto;
background: #f7f0e4;
color: #493323;
}
.page-canvas {
position: relative;
display: grid;
box-sizing: border-box;
width: 100%;
min-height: calc(1665rpx + env(safe-area-inset-bottom));
@@ -278,48 +280,49 @@ const submitReset = () => {
}
.page-backdrop {
position: absolute;
inset: 0 0 auto;
grid-area: 1 / 1;
align-self: start;
width: 100%;
height: 1665rpx;
pointer-events: none;
}
.brand-seal {
position: absolute;
z-index: 1;
top: 64rpx;
left: 50%;
grid-area: 1 / 1;
align-self: start;
justify-self: center;
width: 184rpx;
height: 221rpx;
transform: translateX(-50%);
margin-top: 64rpx;
pointer-events: none;
}
.reset-content {
position: relative;
z-index: 2;
grid-area: 1 / 1;
box-sizing: border-box;
padding: 430rpx 64rpx 46rpx;
}
.page-heading {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
display: grid;
grid-template-rows: auto auto auto;
justify-items: center;
min-height: 154rpx;
}
.back-button {
position: absolute;
top: 10rpx;
left: 0;
display: flex;
grid-row: 1 / -1;
grid-column: 1;
align-self: start;
justify-self: start;
align-items: center;
justify-content: center;
width: 88rpx;
height: 88rpx;
margin-top: 10rpx;
}
.back-button__icon {
@@ -328,18 +331,21 @@ const submitReset = () => {
transform: scaleX(-1);
}
.page-title {
grid-row: 1;
color: #9f170f;
font-size: 66rpx;
font-weight: 700;
letter-spacing: 7rpx;
}
.page-subtitle {
grid-row: 2;
margin-top: 12rpx;
color: #806c58;
font-size: 25rpx;
letter-spacing: 1rpx;
}
.reset-divider {
grid-row: 3;
width: 300rpx;
height: 50rpx;
margin-top: 2rpx;
@@ -367,7 +373,7 @@ const submitReset = () => {
.auth-input {
flex: 1;
min-width: 0;
height: 82rpx;
min-height: 82rpx;
color: #3f2c1d;
font-size: 27rpx;
}
@@ -394,25 +400,21 @@ const submitReset = () => {
}
.reset-submit {
position: relative;
display: flex;
align-items: center;
justify-content: center;
height: 104rpx;
display: grid;
place-items: center;
min-height: 104rpx;
margin-top: 26rpx;
overflow: hidden;
}
.reset-submit__skin,
.success-action__skin {
position: absolute;
inset: 0;
grid-area: 1 / 1;
width: 100%;
height: 100%;
}
.reset-submit__content {
position: relative;
z-index: 1;
grid-area: 1 / 1;
color: #fffaf0;
font-size: 39rpx;
letter-spacing: 6rpx;
@@ -453,24 +455,25 @@ const submitReset = () => {
}
.success-dialog {
position: relative;
display: grid;
width: 620rpx;
height: 520rpx;
min-height: 520rpx;
max-height: calc(100vh - 80rpx);
}
.success-dialog__skin {
position: absolute;
inset: 0;
grid-area: 1 / 1;
width: 100%;
height: 100%;
}
.success-dialog__content {
position: relative;
z-index: 1;
display: flex;
grid-area: 1 / 1;
flex-direction: column;
align-items: center;
box-sizing: border-box;
height: 100%;
min-height: 100%;
overflow-y: auto;
padding: 64rpx 64rpx 42rpx;
text-align: center;
}
@@ -487,30 +490,20 @@ const submitReset = () => {
line-height: 40rpx;
}
.success-mark {
display: flex;
align-items: center;
justify-content: center;
width: 82rpx;
height: 82rpx;
margin-bottom: 20rpx;
border: 2rpx solid #a7160c;
border-radius: 50%;
color: #a7160c;
font-size: 48rpx;
}
.success-action {
position: relative;
display: flex;
align-items: center;
justify-content: center;
display: grid;
place-items: center;
width: 100%;
height: 82rpx;
min-height: 82rpx;
margin-top: 24rpx;
overflow: hidden;
}
.success-action__copy {
position: relative;
z-index: 1;
grid-area: 1 / 1;
color: #fffaf0;
font-size: 30rpx;
letter-spacing: 4rpx;
@@ -537,7 +530,6 @@ const submitReset = () => {
transform: translateX(-50%);
}
.feedback-toast__copy {
position: relative;
z-index: 1;
padding: 16rpx 36rpx;
color: #5c4330;
+35 -39
View File
@@ -204,62 +204,61 @@ const goBack = () => uni.navigateBack();
<style scoped lang="scss">
.auth-page {
position: relative;
width: 100%;
height: 100dvh;
min-height: 100%;
overflow-x: hidden;
overflow-y: auto;
background: #f7f0e4;
color: #493323;
}
.page-canvas {
position: relative;
display: grid;
box-sizing: border-box;
width: 100%;
min-height: calc(1665rpx + env(safe-area-inset-bottom));
padding-bottom: env(safe-area-inset-bottom);
}
.page-backdrop {
position: absolute;
inset: 0 0 auto;
grid-area: 1 / 1;
align-self: start;
width: 100%;
height: 1665rpx;
pointer-events: none;
}
.brand-seal {
position: absolute;
z-index: 1;
top: 64rpx;
left: 50%;
grid-area: 1 / 1;
align-self: start;
justify-self: center;
width: 184rpx;
height: 221rpx;
transform: translateX(-50%);
margin-top: 64rpx;
pointer-events: none;
}
.status-content {
position: relative;
z-index: 2;
grid-area: 1 / 1;
box-sizing: border-box;
padding: 430rpx 64rpx 46rpx;
}
.page-heading {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
display: grid;
grid-template-rows: auto auto auto;
justify-items: center;
min-height: 154rpx;
}
.back-button {
position: absolute;
top: 10rpx;
left: 0;
display: flex;
grid-row: 1 / -1;
grid-column: 1;
align-self: start;
justify-self: start;
align-items: center;
justify-content: center;
width: 88rpx;
height: 88rpx;
margin-top: 10rpx;
}
.back-button__icon {
width: 42rpx;
@@ -267,18 +266,21 @@ const goBack = () => uni.navigateBack();
transform: scaleX(-1);
}
.page-title {
grid-row: 1;
color: #9f170f;
font-size: 66rpx;
font-weight: 700;
letter-spacing: 7rpx;
}
.page-subtitle {
grid-row: 2;
margin-top: 12rpx;
color: #806c58;
font-size: 25rpx;
letter-spacing: 1rpx;
}
.status-divider {
grid-row: 3;
width: 300rpx;
height: 50rpx;
margin-top: 2rpx;
@@ -344,24 +346,20 @@ const goBack = () => uni.navigateBack();
}
.status-primary {
position: relative;
display: flex;
align-items: center;
justify-content: center;
height: 104rpx;
display: grid;
place-items: center;
min-height: 104rpx;
margin-top: 28rpx;
overflow: hidden;
}
.status-primary__skin,
.recovery-action__skin {
position: absolute;
inset: 0;
grid-area: 1 / 1;
width: 100%;
height: 100%;
}
.status-primary__copy {
position: relative;
z-index: 1;
grid-area: 1 / 1;
color: #fffaf0;
font-size: 38rpx;
letter-spacing: 5rpx;
@@ -387,24 +385,25 @@ const goBack = () => uni.navigateBack();
background: rgba(35, 18, 10, 0.62);
}
.recovery-dialog {
position: relative;
display: grid;
width: 620rpx;
height: 520rpx;
min-height: 520rpx;
max-height: calc(100vh - 80rpx);
}
.recovery-dialog__skin {
position: absolute;
inset: 0;
grid-area: 1 / 1;
width: 100%;
height: 100%;
}
.recovery-dialog__content {
position: relative;
z-index: 1;
display: flex;
grid-area: 1 / 1;
flex-direction: column;
align-items: center;
box-sizing: border-box;
height: 100%;
min-height: 100%;
overflow-y: auto;
padding: 58rpx 58rpx 36rpx;
text-align: center;
}
@@ -427,18 +426,15 @@ const goBack = () => uni.navigateBack();
line-height: 31rpx;
}
.recovery-action {
position: relative;
display: flex;
align-items: center;
justify-content: center;
display: grid;
place-items: center;
width: 100%;
height: 80rpx;
min-height: 80rpx;
margin-top: 22rpx;
overflow: hidden;
}
.recovery-action__copy {
position: relative;
z-index: 1;
grid-area: 1 / 1;
color: #fffaf0;
font-size: 29rpx;
letter-spacing: 4rpx;
+27 -57
View File
@@ -22,11 +22,6 @@
><text>汤氏家族圈</text><text>家宴通知与共同记忆</text></view
>
<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
v-for="item in shortcuts"
:key="item.key"
@@ -42,11 +37,6 @@
class="feed-card"
@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">
<text class="feed-card__meta"
>{{ item.tag }} · {{ item.time }}</text
@@ -58,11 +48,7 @@
</view>
</template>
<view v-else-if="feedState !== 'loading'" class="feed-state-card">
<image
src="/static/assets/modules/family/transparent/f01-family-letter-card.png"
mode="scaleToFill"
/>
<view
<view class="feed-state-card__copy"
><text>{{
feedState === "empty" ? "还没有家族动态" : "家族动态暂不可用"
}}</text
@@ -77,10 +63,7 @@
v-if="feedState !== 'loading'"
class="feed-action"
@click="feedState === 'error' ? (feedState = 'list') : toPublish()"
><image
src="/static/assets/foundation/transparent/a01-scroll-primary-v3.png"
mode="aspectFit"
/><text>{{
><text>{{
feedState === "error" ? "重新查看" : "发布家族动态"
}}</text></view
>
@@ -121,6 +104,10 @@ const shortcuts = [
{ key: "albums", label: "相册" },
{ key: "rituals", label: "礼仪" },
{ key: "memos", label: "备忘" },
{ key: "people", label: "人物录" },
{ key: "gifts", label: "贺礼簿" },
{ key: "merits", label: "功德录" },
{ key: "videos", label: "家族视频" },
];
onLoad((query) => {
genealogyId.value =
@@ -146,23 +133,29 @@ const openSection = (key) => {
albums: "/pages/family/f07-album-list",
rituals: "/pages/records/r05-ritual-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>
<style scoped lang="scss">
.family-page {
position: relative;
display: flex;
min-height: 100vh;
overflow: hidden;
flex-direction: column;
background: $paper;
}
.family-page__header,
.feed-content {
position: relative;
z-index: 2;
z-index: 1;
}
.feed-content {
flex: 1;
padding: 24rpx 24rpx 190rpx;
}
.feed-heading text {
@@ -180,23 +173,14 @@ const openSection = (key) => {
font-size: 23rpx;
}
.feed-shortcuts {
position: relative;
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
width: 100%;
height: 48px;
min-height: 48px;
margin-top: 15rpx;
}
.feed-shortcuts__skin {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
pointer-events: none;
background: url("/static/assets/foundation/transparent/a01-scroll-secondary-v3.png") center / 100% 100% no-repeat;
}
.feed-shortcut {
position: relative;
z-index: 1;
width: 100%;
height: 48px;
min-height: 44px;
@@ -212,28 +196,17 @@ const openSection = (key) => {
}
.feed-card,
.feed-state-card {
position: relative;
display: flex;
width: 100%;
height: 230rpx;
min-height: 230rpx;
min-height: 98px;
flex-direction: column;
justify-content: center;
margin-top: 17rpx;
box-sizing: border-box;
}
.feed-card__skin,
.feed-state-card > image,
.feed-action image {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
pointer-events: none;
background: url("/static/assets/modules/family/transparent/f01-family-letter-card.png") center / 100% 100% no-repeat;
}
.feed-card__copy {
position: relative;
z-index: 1;
padding: 15% 9%;
}
.feed-card text {
@@ -265,9 +238,7 @@ const openSection = (key) => {
.feed-state-card {
margin-top: 70rpx;
}
.feed-state-card > view {
position: relative;
z-index: 1;
.feed-state-card__copy {
padding: 23% 10%;
box-sizing: border-box;
text-align: center;
@@ -288,14 +259,13 @@ const openSection = (key) => {
line-height: 1.45;
}
.feed-action {
position: relative;
width: 100%;
height: 76rpx;
margin-top: 19rpx;
width: 514rpx;
max-width: 100%;
min-height: 76rpx;
margin: 19rpx auto 0;
background: url("/static/assets/foundation/transparent/a01-scroll-primary-v3.png") center / 100% 100% no-repeat;
}
.feed-action text {
position: relative;
z-index: 1;
display: flex;
align-items: center;
justify-content: center;
+8 -35
View File
@@ -10,19 +10,13 @@
><ModulePageBackground module="family" /><view class="publish-page__header"
><PageHeader title="发布动态" /></view
><view class="publish-panel"
><image
class="publish-panel__skin"
src="/static/assets/modules/family/transparent/module-content-frame.png"
mode="scaleToFill" /><view
><view
v-if="publishState === 'form'"
class="publish-form"
><text>记录此刻</text
><text>分享通知活动家族故事或一段共同记忆</text
><view class="publish-field"
><image
src="/static/assets/modules/family/transparent/module-field-frame.png"
mode="scaleToFill"
/><textarea
><textarea
v-model="content"
maxlength="300"
placeholder="写下想对家人说的话"
@@ -87,15 +81,14 @@ onUnmounted(() => {
</script>
<style scoped lang="scss">
.publish-page {
position: relative;
display: flex;
min-height: 100vh;
overflow: hidden;
flex-direction: column;
background: $paper;
}
.publish-page__header,
.publish-panel {
position: relative;
z-index: 2;
z-index: 1;
}
.publish-panel {
width: calc(100% - 32rpx);
@@ -103,18 +96,7 @@ onUnmounted(() => {
margin: 18rpx auto 0;
padding: 9%;
box-sizing: border-box;
}
.publish-panel__skin {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
pointer-events: none;
}
.publish-form,
.publish-result {
position: relative;
z-index: 1;
background: url("/static/assets/modules/family/transparent/module-content-frame.png") center / 100% 100% no-repeat;
}
.publish-form > text,
.publish-result > text {
@@ -135,25 +117,16 @@ onUnmounted(() => {
line-height: 1.6;
}
.publish-field {
position: relative;
display: flex;
min-height: 250rpx;
margin-top: 24rpx;
padding: 25rpx;
box-sizing: border-box;
}
.publish-field image {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
pointer-events: none;
background: url("/static/assets/modules/family/transparent/module-field-frame.png") center / 100% 100% no-repeat;
}
.publish-field textarea {
position: relative;
z-index: 1;
width: 100%;
height: 200rpx;
min-height: 200rpx;
color: $ink;
font-size: 24rpx;
line-height: 1.7;
+151 -3
View File
@@ -1,5 +1,153 @@
<!-- 页面编号F-03用途动态详情评论点赞与删除确认 -->
<template><ModulePage page-id="f03" /></template>
<!-- 页面编号F-03用途动态详情评论与内容失效状态 -->
<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>
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>
<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用途谱文分类与文章列表 -->
<template><ModulePage page-id="f04" /></template>
<!-- 页面编号F-04用途谱文分类搜索列表与新建入口 -->
<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>
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>
<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用途谱文详情 -->
<template><ModulePage page-id="f05" /></template>
<!-- 页面编号F-05用途谱文正文收藏编辑与受控状态 -->
<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>
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>
<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--error': fieldErrors.title }"
>
<image :src="fieldFrame" mode="scaleToFill" />
<input
v-model="form.title"
maxlength="40"
@@ -67,7 +66,6 @@
class="editor-control"
:class="{ 'editor-control--error': fieldErrors.category }"
>
<image :src="fieldFrame" mode="scaleToFill" />
<input
v-model="form.category"
maxlength="20"
@@ -87,7 +85,6 @@
class="editor-control editor-control--textarea"
:class="{ 'editor-control--error': fieldErrors.content }"
>
<image :src="fieldFrame" mode="scaleToFill" />
<textarea
v-model="form.content"
maxlength="1200"
@@ -127,8 +124,6 @@ import AppLoading from "@/components/AppLoading.vue";
import ModulePageBackground from "@/components/ModulePageBackground.vue";
import PageHeader from "@/components/PageHeader.vue";
const fieldFrame =
"/static/assets/modules/family/transparent/module-field-frame.png";
const draftFixture = {
title: "汤氏家训辑录",
category: "家风家训",
@@ -136,6 +131,9 @@ const draftFixture = {
"孝友传家,勤俭立业;敬祖睦宗,诚实待人。愿后人常怀感恩,彼此扶持。",
};
const editorState = ref("form");
const articleId = ref("");
const editorMode = ref("create");
const simulateSaveFailure = ref(false);
const allowedStates = new Set([
"draft",
"loading",
@@ -156,8 +154,8 @@ const actionLabel = computed(() =>
: "保存谱文",
);
const fillDraft = (failed = false) => {
Object.assign(form, draftFixture, failed ? { title: "保存失败" } : {});
const fillDraft = () => {
Object.assign(form, draftFixture);
};
const showAllFieldErrors = () => {
fieldErrors.title = "请填写文章标题";
@@ -166,9 +164,15 @@ const showAllFieldErrors = () => {
};
onLoad((query) => {
articleId.value = query.articleId || "";
editorMode.value = articleId.value || query.mode === "edit" ? "edit" : "create";
const requestedState = allowedStates.has(query.state) ? query.state : "form";
if (["draft", "saving", "error"].includes(requestedState)) {
fillDraft(requestedState === "error");
simulateSaveFailure.value = requestedState === "error";
if (
editorMode.value === "edit" ||
["draft", "saving", "error"].includes(requestedState)
) {
fillDraft();
}
if (requestedState === "validation") showAllFieldErrors();
editorState.value = requestedState;
@@ -197,12 +201,18 @@ const submit = () => {
}
editorState.value = "saving";
saveTimer = setTimeout(() => {
editorState.value =
form.title.trim() === "保存失败" ? "error" : "success";
editorState.value = simulateSaveFailure.value ? "error" : "success";
simulateSaveFailure.value = false;
saveTimer = null;
}, 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(() => {
if (saveTimer) clearTimeout(saveTimer);
@@ -211,16 +221,15 @@ onUnload(() => {
<style scoped lang="scss">
.article-editor-page {
position: relative;
display: flex;
min-height: 100vh;
overflow-x: hidden;
flex-direction: column;
background: $paper;
}
.article-editor-page__header,
.article-editor-loading,
.article-editor-content {
position: relative;
z-index: 2;
z-index: 1;
}
.article-editor-loading {
padding-top: 150rpx;
@@ -230,7 +239,6 @@ onUnload(() => {
}
.editor-panel,
.editor-result-card {
position: relative;
width: 100%;
border: 20rpx solid transparent;
border-image-source: url("/static/assets/modules/family/transparent/module-content-frame.png");
@@ -240,8 +248,6 @@ onUnload(() => {
box-sizing: border-box;
}
.editor-panel__body {
position: relative;
z-index: 1;
padding: 38rpx 34rpx 42rpx;
}
.editor-eyebrow {
@@ -279,22 +285,13 @@ onUnload(() => {
font-weight: 700;
}
.editor-control {
position: relative;
display: flex;
height: 82rpx;
min-height: 82rpx;
align-items: center;
}
.editor-control > image {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
pointer-events: none;
background: url("/static/assets/modules/family/transparent/module-field-frame.png") center / 100% 100% no-repeat;
}
.editor-control input,
.editor-control textarea {
position: relative;
z-index: 1;
box-sizing: border-box;
width: 100%;
color: $ink;
@@ -302,17 +299,17 @@ onUnload(() => {
}
.editor-control input {
width: 100%;
height: 82rpx;
min-height: 82rpx;
padding: 0 28rpx;
}
.editor-control--textarea {
height: 220rpx;
min-height: 220rpx;
padding: 20rpx 24rpx;
box-sizing: border-box;
}
.editor-control textarea {
width: 100%;
height: 180rpx;
min-height: 180rpx;
line-height: 1.65;
}
.editor-control--error {
@@ -350,8 +347,6 @@ onUnload(() => {
min-height: 420rpx;
}
.editor-result-card__body {
position: relative;
z-index: 1;
padding: 112rpx 52rpx 68rpx;
text-align: center;
}
+129 -3
View File
@@ -1,5 +1,131 @@
<!-- 页面编号F-07用途家族相册列表 -->
<template><ModulePage page-id="f07" /></template>
<!-- 页面编号F-07用途家族相册列表创建与受控状态 -->
<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>
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>
<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 class="album-upload-action" @click="showUploadNotice">
<view class="album-upload-action" @click="toUpload">
<AppButton block label="添加照片" />
</view>
</template>
@@ -93,24 +93,20 @@
</view>
</view>
<AppToast :visible="uploadNoticeVisible" :message="uploadNoticeMessage" />
</view>
</template>
<script setup>
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 AppToast from "@/components/AppToast.vue";
import ModulePageBackground from "@/components/ModulePageBackground.vue";
import PageHeader from "@/components/PageHeader.vue";
const albumState = ref("normal");
const albumId = ref("");
const previewVisible = ref(false);
const previewIndex = ref(0);
const uploadNoticeVisible = ref(false);
const uploadNoticeMessage = "上传照片将在入口接通后开放";
let uploadNoticeTimer;
const photos = [
{ src: "/static/assets/modules/family/f08/f08-reunion-hero.png", alt: "春节团圆时三代家人的合影", caption: "除夕团圆 · 2024" },
@@ -131,19 +127,17 @@ const closePreview = () => {
albumState.value = "normal";
};
const showUploadNotice = () => {
uploadNoticeVisible.value = true;
clearTimeout(uploadNoticeTimer);
uploadNoticeTimer = setTimeout(() => {
uploadNoticeVisible.value = false;
}, 1800);
};
const toUpload = () =>
uni.navigateTo({
url: `/pages/family/f09-media-upload?albumId=${albumId.value}`,
});
const returnToAlbums = () => {
uni.redirectTo({ url: "/pages/family/f07-album-list" });
};
onLoad((query) => {
albumId.value = query.albumId || "reunion";
albumState.value =
query.state === "empty"
? "empty"
@@ -161,22 +155,18 @@ onBackPress(() => {
return true;
});
onUnload(() => {
clearTimeout(uploadNoticeTimer);
});
</script>
<style scoped lang="scss">
.album-detail-page {
position: relative;
display: flex;
min-height: 100vh;
overflow-x: hidden;
flex-direction: column;
background: $paper;
}
.album-detail-header,
.album-detail-content {
position: relative;
z-index: 2;
z-index: 1;
}
.album-detail-content {
padding: 22rpx 24rpx calc(48rpx + env(safe-area-inset-bottom));
@@ -265,7 +255,6 @@ onUnload(() => {
}
.album-empty-state,
.album-expired-state {
position: relative;
width: 100%;
min-height: 430rpx;
box-sizing: border-box;
+11 -9
View File
@@ -237,6 +237,7 @@ const mockLibrary = [
];
const selectedPhotos = ref([]);
const albumId = ref("");
const activePhoto = computed(
() => selectedPhotos.value[activePhotoIndex.value] || null,
);
@@ -322,10 +323,13 @@ const retryFailed = () => {
};
const returnToAlbum = () => {
uni.redirectTo({ url: "/pages/family/f08-album-detail" });
uni.redirectTo({
url: `/pages/family/f08-album-detail?albumId=${albumId.value}`,
});
};
onLoad((query) => {
albumId.value = query.albumId || "reunion";
uploadState.value = ["permission", "selected", "uploading", "error", "success"].includes(query.state)
? query.state
: "initial";
@@ -339,21 +343,19 @@ onLoad((query) => {
<style scoped lang="scss">
.media-upload-page {
position: relative;
display: flex;
min-height: 100vh;
overflow-x: hidden;
flex-direction: column;
background: $paper;
}
.media-upload-header,
.media-upload-content {
position: relative;
z-index: 2;
z-index: 1;
}
.media-upload-content {
padding: 20rpx 24rpx calc(48rpx + env(safe-area-inset-bottom));
}
.media-album-card {
position: relative;
display: grid;
min-height: 154rpx;
box-sizing: border-box;
@@ -420,7 +422,6 @@ onLoad((query) => {
}
.media-photo-tile,
.media-add-tile {
position: relative;
min-width: 0;
min-height: 44px;
overflow: hidden;
@@ -429,6 +430,9 @@ onLoad((query) => {
border: 2rpx solid rgba(179, 133, 63, 0.62);
background: rgba(247, 241, 231, 0.88);
}
.media-photo-tile {
position: relative;
}
.media-photo-tile--active {
outline: 4rpx solid rgba(159, 44, 35, 0.78);
outline-offset: -4rpx;
@@ -570,7 +574,6 @@ onLoad((query) => {
.media-batch-field textarea,
.media-photo-field textarea {
width: 100%;
height: 92rpx;
min-height: 92rpx;
margin-top: 12rpx;
box-sizing: border-box;
@@ -601,7 +604,6 @@ onLoad((query) => {
}
.media-permission-card,
.media-success-card {
position: relative;
min-height: 410rpx;
margin-top: 24rpx;
box-sizing: border-box;
+7 -16
View File
@@ -46,56 +46,47 @@ const returnToFamily = () => {
<style scoped lang="scss">
.video-status-page {
position: relative;
min-height: 100vh;
overflow-x: hidden;
background: $paper;
}
.video-status-header,
.video-status-content {
position: relative;
z-index: 2;
}
.video-status-content {
padding: 16rpx 26rpx 56rpx;
}
.video-status-lead {
position: relative;
height: 56rpx;
display: grid;
min-height: 56rpx;
margin: 0 12rpx 18rpx;
color: $ink-muted;
font-size: 23rpx;
text-align: center;
}
.video-status-lead image {
position: absolute;
inset: 0;
grid-area: 1 / 1;
width: 100%;
height: 100%;
}
.video-status-lead text {
position: relative;
z-index: 1;
display: flex;
grid-area: 1 / 1;
align-items: center;
justify-content: center;
height: 100%;
}
.video-status-card {
position: relative;
display: grid;
width: 100%;
min-height: 330rpx;
text-align: center;
}
.video-status-card__skin {
position: absolute;
inset: 0;
grid-area: 1 / 1;
width: 100%;
height: 100%;
}
.video-status-card__body {
position: relative;
z-index: 1;
grid-area: 1 / 1;
padding: 70rpx 60rpx 48rpx;
}
.video-status-card__eyebrow,
+70 -194
View File
@@ -21,11 +21,6 @@
</view>
<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">
<image
class="error-panel__seal"
@@ -44,11 +39,6 @@
hover-class="action-hover"
@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>
</view>
</view>
@@ -57,18 +47,8 @@
<template v-else-if="hasGenealogies">
<view class="genealogy-fixed-zone">
<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-seal">
<image
class="current-seal-frame"
src="/static/assets/modules/genealogy/transparent/current-seal-frame.png"
mode="scaleToFill"
/>
<text class="current-seal-title">家谱</text>
</view>
<text class="current-name">{{ currentGenealogy.name }}</text>
@@ -165,11 +145,6 @@
hover-class="action-hover"
@click="openApplication(item)"
>
<image
class="application-record__skin"
src="/static/assets/modules/genealogy/transparent/list-slip-frame.png"
mode="scaleToFill"
/>
<view class="application-record__main">
<view class="application-record__title-row">
<text class="application-record__name">{{
@@ -217,18 +192,8 @@
<view v-else class="genealogy-empty-state">
<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-seal">
<image
class="empty-seal__skin"
src="/static/assets/modules/genealogy/transparent/current-seal-frame.png"
mode="scaleToFill"
/>
<text class="empty-seal__text">家谱</text>
</view>
<text class="empty-title">还没有加入任何家谱</text>
@@ -245,11 +210,6 @@
hover-class="action-hover"
@click="applyToJoin"
>
<image
class="empty-search-action__skin"
src="/static/assets/foundation/transparent/a01-scroll-primary-v3.png"
mode="aspectFit"
/>
<text
class="empty-search-action__copy empty-search-action__copy--primary"
>搜索家谱</text
@@ -260,11 +220,6 @@
hover-class="action-hover"
@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>
</view>
<view
@@ -393,6 +348,7 @@ import GenealogyCard from "@/components/GenealogyCard.vue";
import GenealogyPageBackground from "@/components/GenealogyPageBackground.vue";
import PageHeader from "@/components/PageHeader.vue";
import { genealogies, notifications } from "@/data/mock.js";
import { genealogyContext } from "@/utils/genealogy-context.js";
const isLoading = ref(false);
const hasError = ref(false);
@@ -400,7 +356,12 @@ const list = ref(genealogies);
const forceEmptyState = ref(false);
const addDialogVisible = 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 currentListScrollTop = ref(0);
@@ -422,7 +383,14 @@ const syncEmptyStateFromRoute = () => {
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();
});
@@ -440,10 +408,10 @@ const isListLayout = computed(
() => !isLoading.value && !hasError.value && hasGenealogies.value,
);
const createdGenealogies = computed(() =>
list.value.filter((item, index) => index === 0),
list.value.filter((item) => item.membership === "created"),
);
const joinedGenealogies = computed(() =>
list.value.filter((item, index) => index > 0),
list.value.filter((item) => item.membership === "joined"),
);
const availableGenealogies = computed(() => list.value);
const currentGenealogy = computed(
@@ -501,10 +469,12 @@ const shortcuts = [
},
];
const openGenealogy = (genealogy) =>
const openGenealogy = (genealogy) => {
genealogyContext.setCurrentGenealogyId(genealogy.id);
uni.navigateTo({
url: `/pages/genealogy/g05-genealogy-overview?genealogyId=${genealogy.id}`,
});
};
const createGenealogy = () =>
uni.navigateTo({ url: "/pages/genealogy/g03-create-genealogy" });
const applyToJoin = () =>
@@ -550,6 +520,7 @@ const resetListScroll = async () => {
};
const selectGenealogy = async (genealogy) => {
selectedGenealogyId.value = genealogy.id;
genealogyContext.setCurrentGenealogyId(genealogy.id);
closeSwitcher();
await resetListScroll();
};
@@ -565,11 +536,14 @@ const retryLoad = () => {
};
const openShortcut = (key) => {
if (!currentGenealogy.value) return;
const genealogyId = currentGenealogy.value.id;
genealogyContext.setCurrentGenealogyId(genealogyId);
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}`,
poem: "/pages/genealogy/g12-generation-poems",
applications: "/pages/genealogy/g10-application-review",
poem: `/pages/genealogy/g12-generation-poems?genealogyId=${currentGenealogy.value.id}`,
applications: `/pages/genealogy/g10-application-review?genealogyId=${currentGenealogy.value.id}`,
};
uni.navigateTo({ url: paths[key] });
};
@@ -577,15 +551,15 @@ const openShortcut = (key) => {
<style scoped lang="scss">
.genealogy-index {
position: relative;
display: flex;
min-height: 100vh;
flex-direction: column;
overflow: hidden;
background: #f9f6ef;
}
.genealogy-content {
position: relative;
z-index: 2;
z-index: 1;
padding: 24rpx 32rpx 176rpx;
}
@@ -618,29 +592,14 @@ const openShortcut = (key) => {
}
.current-slip {
position: relative;
display: block;
min-height: 268rpx;
padding: 30rpx 34rpx 28rpx;
box-sizing: border-box;
background: transparent;
}
.current-frame {
position: absolute;
top: 0;
right: 0;
bottom: 0;
left: 0;
z-index: 0;
width: 100%;
height: 100%;
pointer-events: none;
background: url("/static/assets/modules/genealogy/transparent/current-slip-frame.png") center / 100% 100% no-repeat;
}
.current-seal {
position: relative;
z-index: 1;
display: flex;
width: 80rpx;
height: 128rpx;
@@ -648,22 +607,10 @@ const openShortcut = (key) => {
align-items: center;
justify-content: center;
margin-right: 24rpx;
background: url("/static/assets/modules/genealogy/transparent/current-seal-frame.png") center / 100% 100% no-repeat;
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 {
position: relative;
z-index: 1;
color: #fff7e7;
font-family: "STKaiti", "KaiTi", serif;
font-size: 32rpx;
@@ -672,20 +619,16 @@ const openShortcut = (key) => {
writing-mode: vertical-rl;
}
.current-summary {
position: relative;
z-index: 1;
display: flex;
min-width: 0;
align-items: center;
}
.current-name {
overflow: hidden;
color: $ink;
font-family: "STKaiti", "KaiTi", serif;
font-size: 50rpx;
font-weight: 700;
text-overflow: ellipsis;
white-space: nowrap;
overflow-wrap: anywhere;
}
.current-switch-copy {
flex: 0 0 auto;
@@ -694,8 +637,6 @@ const openShortcut = (key) => {
font-size: 28rpx;
}
.current-info-divider {
position: relative;
z-index: 1;
width: 100%;
height: 1rpx;
margin: 14rpx 0 16rpx;
@@ -703,8 +644,6 @@ const openShortcut = (key) => {
}
.current-meta {
position: relative;
z-index: 1;
display: flex;
flex-wrap: nowrap;
justify-content: space-between;
@@ -719,10 +658,10 @@ const openShortcut = (key) => {
display: flex;
align-items: center;
margin: 0;
white-space: nowrap;
flex-wrap: wrap;
.current-meta-item-text {
white-space: nowrap;
overflow-wrap: anywhere;
}
}
.current-meta-icon {
@@ -752,7 +691,6 @@ const openShortcut = (key) => {
height: 82rpx;
}
.shortcut-label {
overflow: hidden;
width: 100%;
margin-top: 12rpx;
color: $ink;
@@ -760,8 +698,7 @@ const openShortcut = (key) => {
font-size: 29rpx;
font-weight: 700;
text-align: center;
text-overflow: ellipsis;
white-space: nowrap;
overflow-wrap: anywhere;
}
.section-divider {
@@ -803,25 +740,17 @@ const openShortcut = (key) => {
}
.application-record {
position: relative;
display: flex;
min-height: 138rpx;
align-items: center;
box-sizing: border-box;
padding: 22rpx 26rpx;
background: url("/static/assets/modules/genealogy/transparent/list-slip-frame.png") center / 100% 100% no-repeat;
}
.application-record + .application-record {
margin-top: 12rpx;
}
.application-record__skin {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
}
.application-record__main {
position: relative;
z-index: 1;
min-width: 0;
flex: 1;
}
@@ -831,13 +760,11 @@ const openShortcut = (key) => {
justify-content: space-between;
}
.application-record__name {
overflow: hidden;
color: $ink;
font-family: "STKaiti", "KaiTi", serif;
font-size: 32rpx;
font-weight: 700;
text-overflow: ellipsis;
white-space: nowrap;
overflow-wrap: anywhere;
}
.application-record__status {
flex: 0 0 auto;
@@ -861,8 +788,6 @@ const openShortcut = (key) => {
line-height: 1.4;
}
.application-record__chevron {
position: relative;
z-index: 1;
width: 32rpx;
height: 32rpx;
margin-left: 14rpx;
@@ -907,20 +832,18 @@ const openShortcut = (key) => {
}
.create-action > text {
flex: 0 0 auto;
white-space: nowrap;
text-align: center;
}
.create-cloud--right {
transform: scaleX(-1);
}
.state-panel {
position: relative;
display: flex;
min-height: 540rpx;
flex-direction: column;
align-items: center;
justify-content: center;
overflow: hidden;
padding: 42rpx;
box-sizing: border-box;
text-align: center;
@@ -941,25 +864,15 @@ const openShortcut = (key) => {
}
.state-panel--error {
height: 1120rpx;
min-height: 1120rpx;
padding: 0;
}
.error-panel__frame {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
pointer-events: none;
background: url("/static/assets/modules/genealogy/transparent/g01-empty-panel-frame.png") center / 100% 100% no-repeat;
}
.error-panel__content {
position: relative;
z-index: 1;
display: flex;
width: 100%;
height: 100%;
min-height: 1120rpx;
flex-direction: column;
align-items: center;
justify-content: center;
@@ -1000,7 +913,7 @@ const openShortcut = (key) => {
.state-panel--error .state-retry {
width: 560rpx;
height: 124rpx;
min-height: 124rpx;
margin-top: 22rpx;
}
@@ -1009,23 +922,13 @@ const openShortcut = (key) => {
}
.empty-panel {
position: relative;
height: 1120rpx;
}
.empty-panel__frame {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
pointer-events: none;
min-height: 1120rpx;
background: url("/static/assets/modules/genealogy/transparent/g01-empty-panel-frame.png") center / 100% 100% no-repeat;
}
.empty-panel__content {
position: relative;
z-index: 1;
display: flex;
height: 100%;
min-height: 1120rpx;
flex-direction: column;
align-items: center;
justify-content: center;
@@ -1035,24 +938,15 @@ const openShortcut = (key) => {
}
.empty-seal {
position: relative;
display: flex;
width: 120rpx;
height: 184rpx;
align-items: center;
justify-content: center;
}
.empty-seal__skin {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
background: url("/static/assets/modules/genealogy/transparent/current-seal-frame.png") center / 100% 100% no-repeat;
}
.empty-seal__text {
position: relative;
z-index: 1;
color: #fff7e7;
font-family: "STKaiti", "KaiTi", serif;
font-size: 32rpx;
@@ -1089,35 +983,25 @@ const openShortcut = (key) => {
.empty-search-action,
.empty-invite-action {
position: relative;
display: flex;
width: 560rpx;
height: 124rpx;
min-height: 124rpx;
align-items: center;
justify-content: center;
overflow: hidden;
}
.empty-search-action {
margin-top: 26rpx;
background: url("/static/assets/foundation/transparent/a01-scroll-primary-v3.png") center / contain no-repeat;
}
.empty-invite-action {
margin-top: 22rpx;
}
.empty-search-action__skin,
.empty-invite-action__skin {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
background: url("/static/assets/foundation/transparent/a01-scroll-secondary-v3.png") center / contain no-repeat;
}
.empty-search-action__copy,
.empty-invite-action__copy {
position: relative;
z-index: 1;
color: #7b4e24;
font-family: "STKaiti", "KaiTi", serif;
font-size: 34rpx;
@@ -1147,24 +1031,15 @@ const openShortcut = (key) => {
}
.state-retry {
position: relative;
display: flex;
width: 360rpx;
height: 96rpx;
min-height: 96rpx;
align-items: center;
justify-content: center;
margin-top: 30rpx;
overflow: hidden;
}
.state-retry__skin {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
background: url("/static/assets/foundation/transparent/a01-scroll-primary-v3.png") center / contain no-repeat;
}
.state-retry__copy {
position: relative;
z-index: 1;
color: #fff9ec;
font-family: "STKaiti", "KaiTi", serif;
font-size: 29rpx;
@@ -1181,7 +1056,6 @@ const openShortcut = (key) => {
background: rgba(34, 20, 12, 0.68);
}
.add-dialog {
position: relative;
width: 100%;
min-height: 780rpx;
max-height: calc(100vh - 80rpx);
@@ -1193,8 +1067,6 @@ const openShortcut = (key) => {
border-image-repeat: stretch;
}
.add-dialog__content {
position: relative;
z-index: 2;
display: flex;
min-height: 780rpx;
max-height: calc(100vh - 80rpx);
@@ -1208,24 +1080,26 @@ const openShortcut = (key) => {
margin: auto 0;
}
.add-dialog__heading {
position: relative;
padding-right: 96rpx;
display: grid;
grid-template-columns: minmax(0, 1fr) 96rpx;
}
.add-dialog .dialog-title,
.add-dialog .dialog-copy {
display: block;
grid-column: 1;
text-align: left;
}
.add-dialog__close {
position: absolute;
z-index: 3;
top: 0;
right: -22rpx;
display: flex;
grid-column: 2;
grid-row: 1 / span 2;
align-self: start;
justify-self: end;
width: 80rpx;
height: 80rpx;
align-items: center;
justify-content: center;
margin-right: -22rpx;
}
.add-dialog__close-icon {
width: 80rpx;
@@ -1258,7 +1132,6 @@ const openShortcut = (key) => {
background: rgba(34, 20, 12, 0.58);
}
.genealogy-switcher {
position: relative;
width: 670rpx;
max-width: 100%;
min-height: 600rpx;
@@ -1271,32 +1144,35 @@ const openShortcut = (key) => {
border-image-repeat: stretch;
}
.genealogy-switcher__content {
position: relative;
z-index: 1;
display: flex;
display: grid;
min-height: 600rpx;
max-height: calc(100vh - 120rpx);
flex-direction: column;
grid-template-rows: auto minmax(0, 1fr);
align-items: center;
box-sizing: border-box;
padding: 120rpx 58rpx 140rpx;
}
.genealogy-switcher__content > .dialog-title {
grid-area: 1 / 1;
}
.genealogy-switcher__close {
position: absolute;
z-index: 3;
top: 78rpx;
right: 34rpx;
display: flex;
grid-area: 1 / 1;
align-self: start;
justify-self: end;
width: 80rpx;
height: 80rpx;
align-items: center;
justify-content: center;
margin-top: -42rpx;
margin-right: -24rpx;
}
.genealogy-switcher__close-icon {
width: 80rpx;
height: 80rpx;
}
.genealogy-switcher__list {
grid-area: 2 / 1;
width: 100%;
min-height: 0;
max-height: calc(100vh - 456rpx);
+58 -104
View File
@@ -4,11 +4,6 @@
<GenealogyPageBackground />
<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__back" @click="goBack">
<image
@@ -26,12 +21,6 @@
<view class="flow-content">
<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 class="flow-step-label"><text>第一步 · 立谱信息</text></view>
<text class="flow-heading">为家族立一部可传承的谱</text>
@@ -125,11 +114,6 @@
hover-class="action-hover"
@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">{{
createState === "submitting" ? "正在创建…" : "创建并录入首代"
}}</text>
@@ -161,13 +145,30 @@
<text>世代</text>
<text class="fixed-value">第一世</text>
</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">
<text>出生日期</text>
<input
v-model="ancestorForm.birthDate"
placeholder="如:1940年"
placeholder-class="placeholder"
/>
<picker
mode="date"
:value="ancestorForm.birthDate"
start="1800-01-01"
:end="new Date().toISOString().slice(0, 10)"
@change="changeAncestorBirthDate"
>
<text class="fixed-value">{{
ancestorForm.birthDate || "请选择日期"
}}</text>
</picker>
</view>
</view>
<text v-if="ancestorState === 'error'" class="flow-error"
@@ -178,6 +179,7 @@
<text>生平简述</text>
<textarea
v-model="ancestorForm.introduction"
auto-height
maxlength="200"
placeholder="可选,记录家训、迁徙或重要经历"
placeholder-class="placeholder"
@@ -189,11 +191,6 @@
hover-class="action-hover"
@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">{{
ancestorState === "submitting" ? "正在保存…" : "保存并进入家谱"
}}</text>
@@ -208,11 +205,6 @@
@click="closeDuplicateReminder"
>
<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">
<text class="duplicate-reminder__title">先确认是否已有家谱</text>
<text class="duplicate-reminder__copy"
@@ -222,17 +214,9 @@
class="duplicate-reminder__search"
@click="searchExistingGenealogy"
>
<image
src="/static/assets/foundation/transparent/a01-scroll-secondary-v3.png"
mode="aspectFit"
/>
<text>先搜索已有家谱</text>
</view>
<view class="duplicate-reminder__confirm" @click="confirmCreate">
<image
src="/static/assets/foundation/transparent/a01-scroll-primary-v3.png"
mode="aspectFit"
/>
<text>确认没有继续创建</text>
</view>
<view
@@ -246,21 +230,12 @@
<view v-if="ancestorState === 'success'" class="flow-success-layer">
<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">
<text class="flow-success-dialog__title">家谱创建完成</text>
<text class="flow-success-dialog__copy"
>首代人物已保存接下来进入家谱总览继续完善资料</text
>
<view class="flow-success-dialog__action" @click="enterOverview">
<image
src="/static/assets/foundation/transparent/a01-scroll-primary-v3.png"
mode="aspectFit"
/>
<text>进入家谱总览</text>
</view>
</view>
@@ -302,6 +277,22 @@ const ancestorForm = reactive({
birthDate: "",
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");
@@ -426,29 +417,21 @@ const enterOverview = () =>
<style scoped lang="scss">
.flow-page {
position: relative;
display: flex;
min-height: 100vh;
overflow-x: hidden;
flex-direction: column;
background: #f9f6ef;
}
.flow-header {
position: relative;
z-index: 3;
height: 112rpx;
overflow: hidden;
}
.flow-header__skin {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
pointer-events: none;
background: url("/static/assets/foundation/opaque/root-header-cinnabar.jpg")
center / 100% 100% no-repeat;
}
.flow-header__content {
position: relative;
z-index: 1;
display: flex;
height: 100%;
@@ -482,26 +465,17 @@ const enterOverview = () =>
}
.flow-content {
position: relative;
z-index: 2;
padding: 24rpx 32rpx 48rpx;
}
.create-flow-panel {
position: relative;
min-height: 1000rpx;
}
.create-flow-panel__skin {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
pointer-events: none;
background: url("/static/assets/modules/genealogy/transparent/g01-empty-panel-frame.png")
center / 100% 100% no-repeat;
}
.create-flow-panel__content {
position: relative;
z-index: 1;
display: flex;
min-height: 1000rpx;
@@ -641,7 +615,7 @@ const enterOverview = () =>
.intro-field textarea {
width: 100%;
height: 142rpx;
min-height: 142rpx;
margin-top: 8rpx;
color: #392719;
font-family: "STKaiti", "KaiTi", serif;
@@ -650,25 +624,17 @@ const enterOverview = () =>
}
.flow-primary-action {
position: relative;
display: flex;
width: 100%;
height: 96rpx;
min-height: 96rpx;
align-items: center;
justify-content: center;
margin-top: auto;
overflow: hidden;
}
.flow-primary-action__skin {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
background: url("/static/assets/foundation/transparent/a01-scroll-primary-v3.png")
center / contain no-repeat;
}
.flow-primary-action__copy {
position: relative;
z-index: 1;
color: #fff9ec;
font-family: "STKaiti", "KaiTi", serif;
@@ -694,21 +660,14 @@ const enterOverview = () =>
}
.duplicate-reminder,
.flow-success-dialog {
position: relative;
width: 100%;
max-width: 670rpx;
min-height: 650rpx;
}
.duplicate-reminder__skin,
.flow-success-dialog__skin {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
background: url("/static/assets/modules/auth/transparent/a01-scroll-dialog-v3.png")
center / contain no-repeat;
}
.duplicate-reminder__content,
.flow-success-dialog__content {
position: relative;
z-index: 1;
display: flex;
min-height: 650rpx;
@@ -735,32 +694,25 @@ const enterOverview = () =>
.duplicate-reminder__search,
.duplicate-reminder__confirm,
.flow-success-dialog__action {
position: relative;
display: flex;
width: 100%;
height: 92rpx;
min-height: 92rpx;
align-items: center;
justify-content: center;
overflow: hidden;
}
.duplicate-reminder__search {
margin-top: 38rpx;
background: url("/static/assets/foundation/transparent/a01-scroll-secondary-v3.png")
center / contain no-repeat;
}
.duplicate-reminder__confirm {
margin-top: 14rpx;
}
.duplicate-reminder__search image,
.duplicate-reminder__confirm image,
.flow-success-dialog__action image {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
background: url("/static/assets/foundation/transparent/a01-scroll-primary-v3.png")
center / contain no-repeat;
}
.duplicate-reminder__search text,
.duplicate-reminder__confirm text,
.flow-success-dialog__action text {
position: relative;
z-index: 1;
color: #fff9ec;
font-size: 25rpx;
@@ -779,5 +731,7 @@ const enterOverview = () =>
}
.flow-success-dialog__action {
margin-top: 44rpx;
background: url("/static/assets/foundation/transparent/a01-scroll-primary-v3.png")
center / contain no-repeat;
}
</style>
+86 -133
View File
@@ -20,12 +20,6 @@
class="overview-surface"
:class="{ 'overview-surface--state': overviewState !== 'ready' }"
>
<image
class="overview-surface__skin"
src="/static/assets/modules/genealogy/opaque/g05-overview-surface.png"
mode="scaleToFill"
/>
<template
v-if="overviewState === 'ready' && genealogy && viewMode === 'member'"
>
@@ -44,6 +38,10 @@
<text>已激活 {{ genealogy.activeCount || 0 }} </text>
<text>{{ genealogy.visibility || "仅成员可见" }}</text>
</view>
<view class="overview-hero__stats">
<text>始祖 {{ genealogy.ancestorName }}</text>
<text>更新于 {{ genealogy.updatedAt }}</text>
</view>
</view>
<view
@@ -62,10 +60,6 @@
<text class="overview-action__title">录入族人</text
><text>从首代开始完善</text>
</view>
<view v-else class="overview-action-lock">
<text class="overview-action__title">录入族人</text
><text>仅管理员可用</text>
</view>
<view
class="overview-action overview-action--poem"
@click="toGenerationPoems"
@@ -81,10 +75,6 @@
<text class="overview-action__title">入谱审核</text
><text>处理加入申请</text>
</view>
<view v-else class="overview-action-lock">
<text class="overview-action__title">入谱审核</text
><text>仅管理员可用</text>
</view>
</view>
<view class="overview-family" @click="toFamily">
@@ -130,10 +120,6 @@
<text>{{ genealogy.publicDescription }}</text>
</view>
<view class="overview-public__action" @click="applyToJoin">
<image
src="/static/assets/foundation/transparent/a01-scroll-primary-v3.png"
mode="aspectFit"
/>
<text>申请加入这部家谱</text>
</view>
</view>
@@ -142,11 +128,6 @@
v-else-if="overviewState === 'loading'"
class="overview-state overview-state--loading"
>
<image
class="overview-state__frame"
src="/static/assets/modules/genealogy/transparent/g01-empty-panel-frame.png"
mode="scaleToFill"
/>
<AppLoading
text="正在展开家谱"
description="请稍候,正在读取家谱概览。"
@@ -162,11 +143,6 @@
'overview-state--no-permission': overviewState === 'no-permission',
}"
>
<image
class="overview-state__frame"
src="/static/assets/modules/genealogy/transparent/g01-empty-panel-frame.png"
mode="scaleToFill"
/>
<view class="overview-state__content">
<image
class="overview-state__seal"
@@ -181,10 +157,6 @@
overviewState === 'error' ? reloadOverview() : toGenealogies()
"
>
<image
src="/static/assets/foundation/transparent/a01-scroll-primary-v3.png"
mode="aspectFit"
/>
<text>{{
overviewState === "error" ? "重新查看" : "返回我的家谱"
}}</text>
@@ -223,6 +195,28 @@ const overviewFixture = {
memberCount: 428,
activeCount: 316,
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(
@@ -268,7 +262,13 @@ const loadGenealogy = (query = {}) => {
}
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";
};
@@ -305,43 +305,40 @@ const applyToJoin = () =>
<style scoped lang="scss">
.overview-page {
position: relative;
display: flex;
min-height: 100vh;
overflow-x: hidden;
flex-direction: column;
background: $paper;
}
.overview-page__header {
position: relative;
z-index: 3;
}
.overview-surface {
position: relative;
z-index: 2;
width: 100%;
height: min(483px, calc(100vw * 1.3422));
}
.overview-surface__skin {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
min-height: min(483px, calc(100vw * 1.3422));
flex: 0 0 auto;
background: url("/static/assets/modules/genealogy/opaque/g05-overview-surface.png")
center / 100% 100% no-repeat;
}
.overview-surface--state {
display: flex;
height: calc(100vh - 112rpx);
min-height: 1040rpx;
}
.overview-surface--state .overview-surface__skin {
display: none;
padding: 28rpx;
box-sizing: border-box;
background: none;
}
.overview-ready {
position: absolute;
inset: 0;
display: flex;
min-height: min(483px, calc(100vw * 1.3422));
flex-direction: column;
gap: 34rpx;
padding: 58rpx 0 42rpx;
box-sizing: border-box;
}
.overview-hero {
position: absolute;
top: 6.5%;
right: 8%;
left: 8%;
margin: 0 8%;
color: #fff8ec;
}
.overview-hero__kicker {
@@ -372,19 +369,15 @@ const applyToJoin = () =>
font-size: 22rpx;
}
.overview-actions {
position: absolute;
top: 29.2%;
right: 7.2%;
left: 7.2%;
display: grid;
min-height: 386rpx;
grid-template-columns: 1fr 1fr;
grid-template-rows: 1fr 1fr;
grid-template-rows: auto auto;
gap: 3.4% 3%;
height: 36.2%;
margin: 0 7.2%;
}
.overview-actions--member {
grid-template-rows: 1fr 1fr;
height: 36.2%;
min-height: 386rpx;
}
.overview-action,
.overview-action-lock {
@@ -392,7 +385,7 @@ const applyToJoin = () =>
flex-direction: column;
justify-content: center;
min-width: 0;
padding: 0 22rpx;
padding: 24rpx 22rpx;
box-sizing: border-box;
color: $ink-muted;
font-size: 22rpx;
@@ -408,14 +401,12 @@ const applyToJoin = () =>
font-weight: 700;
}
.overview-family {
position: absolute;
top: 70.2%;
right: 7.5%;
left: 7.5%;
display: grid;
grid-template-columns: auto 1fr auto;
align-items: center;
align-self: start;
gap: 16rpx;
margin: 0 7.5%;
color: $ink-muted;
font-size: 22rpx;
}
@@ -429,17 +420,14 @@ const applyToJoin = () =>
color: $brand-red;
}
.overview-note {
position: absolute;
top: 80.5%;
right: 8.5%;
left: 8.5%;
display: flex;
flex-direction: column;
justify-content: center;
height: 15%;
margin: 0 8.5%;
color: $ink-muted;
font-size: 22rpx;
line-height: 1.6;
margin-top: 100rpx;
}
.overview-note__title {
margin-bottom: 8rpx;
@@ -449,25 +437,16 @@ const applyToJoin = () =>
font-weight: 700;
}
.overview-state {
position: absolute;
top: 28rpx;
right: 28rpx;
bottom: 28rpx;
left: 28rpx;
display: flex;
width: 100%;
min-height: 0;
align-items: center;
justify-content: center;
background: url("/static/assets/modules/genealogy/transparent/g01-empty-panel-frame.png")
center / 100% 100% no-repeat;
text-align: center;
}
.overview-state__frame {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
pointer-events: none;
}
.overview-state__content {
position: relative;
z-index: 1;
display: flex;
width: 100%;
@@ -501,37 +480,31 @@ const applyToJoin = () =>
line-height: 1.7;
}
.overview-state-panel__action {
position: relative;
width: 560rpx;
height: 124rpx;
margin-top: 34rpx;
}
.overview-state-panel__action image {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
}
.overview-state-panel__action text {
position: relative;
z-index: 1;
display: flex;
width: 560rpx;
min-height: 124rpx;
margin-top: 34rpx;
align-items: 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;
font-size: 31rpx;
font-weight: 700;
}
.overview-public {
position: absolute;
inset: 0;
display: flex;
min-height: min(483px, calc(100vw * 1.3422));
flex-direction: column;
gap: 0;
padding: 58rpx 0 28rpx;
box-sizing: border-box;
}
.overview-public__hero {
position: absolute;
top: 6.5%;
right: 8%;
left: 8%;
margin: 0 8%;
color: #fff8ec;
}
.overview-public__eyebrow {
@@ -555,24 +528,19 @@ const applyToJoin = () =>
font-size: 22rpx;
}
.overview-public__details {
position: absolute;
top: 30%;
right: 9%;
left: 9%;
display: grid;
grid-template-columns: 1fr 1fr;
gap: 18rpx 28rpx;
min-height: 240rpx;
margin: 126rpx 9% 0;
}
.overview-public__details > view {
display: flex;
flex-direction: column;
}
.overview-public__details > view:nth-child(5) {
position: absolute;
top: 250rpx;
right: 0;
left: 0;
grid-column: 1 / -1;
align-self: start;
flex-direction: row;
align-items: center;
justify-content: space-between;
@@ -596,11 +564,8 @@ const applyToJoin = () =>
font-size: 27rpx;
}
.overview-public__notice {
position: absolute;
top: 78%;
right: 9%;
left: 9%;
display: flex;
margin: 54rpx 9% 0;
color: $ink-muted;
font-size: 19rpx;
line-height: 26rpx;
@@ -612,24 +577,15 @@ const applyToJoin = () =>
font-weight: 700;
}
.overview-public__action {
position: absolute;
right: 9%;
bottom: 2%;
left: 9%;
display: flex;
height: 82rpx;
min-height: 82rpx;
align-items: center;
justify-content: center;
overflow: hidden;
}
.overview-public__action image {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
margin: 204rpx 9% 0;
background: url("/static/assets/foundation/transparent/a01-scroll-primary-v3.png")
center / contain no-repeat;
}
.overview-public__action text {
position: relative;
z-index: 1;
color: #fff9ed;
font-size: 27rpx;
@@ -637,9 +593,6 @@ const applyToJoin = () =>
letter-spacing: 3rpx;
}
@media (min-width: 400px) {
.overview-hero {
top: 7%;
}
.overview-action {
padding: 0 28rpx;
}
+40 -112
View File
@@ -6,11 +6,6 @@
<view class="search-page__content">
<view class="search-title-strip">
<image
class="search-title-strip__skin"
src="/static/assets/modules/genealogy/opaque/g06-search-title-strip.png"
mode="scaleToFill"
/>
<image
class="search-title-strip__seal"
src="/static/assets/foundation/transparent/brand-seal.png"
@@ -37,11 +32,6 @@
<template v-if="mode === 'search'">
<view class="search-controls">
<view class="search-field">
<image
class="search-field__skin"
src="/static/assets/modules/genealogy/opaque/g06-search-input-wide.png"
mode="scaleToFill"
/>
<input
v-model.trim="keyword"
class="search-input"
@@ -54,11 +44,6 @@
>
</view>
<view class="search-action" @click="search">
<image
class="search-action__skin"
src="/static/assets/modules/genealogy/opaque/g06-search-button.png"
mode="scaleToFill"
/>
<text class="search-action__label">搜索</text>
</view>
</view>
@@ -110,11 +95,6 @@
class="result-card genealogy-card"
@click="openPreview(item)"
>
<image
class="result-card__skin"
src="/static/assets/modules/genealogy/transparent/list-slip-frame.png"
mode="scaleToFill"
/>
<view class="result-card__content">
<view class="result-card__head">
<view>
@@ -169,10 +149,6 @@
>当前仅展示失败样式请稍后重新搜索</text
>
<view class="status-retry" @click="search">
<image
src="/static/assets/foundation/transparent/a01-scroll-primary-v3.png"
mode="aspectFit"
/>
<text>重新搜索</text>
</view>
</view>
@@ -186,11 +162,6 @@
>
<view class="invite-controls">
<view class="invite-field">
<image
class="search-field__skin"
src="/static/assets/modules/genealogy/opaque/g06-search-input-wide.png"
mode="scaleToFill"
/>
<input
v-model.trim="inviteCode"
class="invite-input"
@@ -200,11 +171,6 @@
/>
</view>
<view class="search-action" @click="verifyInvite">
<image
class="search-action__skin"
src="/static/assets/modules/genealogy/opaque/g06-search-button.png"
mode="scaleToFill"
/>
<text class="search-action__label">验证</text>
</view>
</view>
@@ -237,11 +203,6 @@
<view v-else class="invite-result">
<text class="invite-result__label">已定位目标家谱</text>
<view class="result-card genealogy-card">
<image
class="result-card__skin"
src="/static/assets/modules/genealogy/transparent/list-slip-frame.png"
mode="scaleToFill"
/>
<view class="result-card__content">
<text class="result-card__name">{{ inviteTarget.name }}</text>
<view class="result-card__facts">
@@ -444,7 +405,7 @@ const clearSearch = () => {
const openPreview = (item) => {
if (item.relation === "available") {
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) => {
if (item.relation === "available")
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")
return uni.navigateTo({
@@ -460,7 +421,7 @@ const handleResultAction = (item) => {
});
if (item.relation === "rejected" || item.relation === "removed")
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({
url: `/pages/genealogy/g01-my-genealogies?genealogyId=${item.id}`,
@@ -473,51 +434,42 @@ const verifyInvite = () => {
};
const confirmInvite = () =>
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>
<style scoped lang="scss">
.search-page {
position: relative;
display: flex;
min-height: 100vh;
overflow-x: hidden;
flex-direction: column;
background: $paper;
}
.search-page__header {
position: relative;
z-index: 2;
}
.search-page__content {
position: relative;
z-index: 3;
min-height: calc(100vh - 104rpx);
padding: 30rpx 24rpx calc(58rpx + env(safe-area-inset-bottom));
box-sizing: border-box;
}
.search-title-strip {
position: relative;
display: grid;
width: 100%;
height: 138rpx;
}
.search-title-strip__skin {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
pointer-events: none;
grid-template-columns: 156rpx 1fr 170rpx;
align-items: center;
background: url("/static/assets/modules/genealogy/opaque/g06-search-title-strip.png")
center / 100% 100% no-repeat;
}
.search-title-strip__seal {
position: absolute;
top: 27rpx;
left: 52rpx;
z-index: 1;
width: 72rpx;
height: 84rpx;
margin-left: 52rpx;
}
.search-title-strip__text {
position: absolute;
inset: 0 170rpx 0 156rpx;
z-index: 1;
display: flex;
align-items: center;
@@ -535,7 +487,6 @@ const confirmInvite = () =>
border-bottom: 1rpx solid rgba(181, 138, 75, 0.45);
}
.mode-tab {
position: relative;
display: flex;
flex: 1;
align-items: center;
@@ -546,18 +497,12 @@ const confirmInvite = () =>
font-size: 28rpx;
}
.mode-tab--active {
margin-bottom: -2rpx;
background: linear-gradient($brand-red, $brand-red) center bottom /
calc(100% - 84rpx) 4rpx no-repeat;
color: $brand-red;
font-weight: 700;
}
.mode-tab--active::after {
position: absolute;
right: 42rpx;
bottom: -2rpx;
left: 42rpx;
height: 4rpx;
background: $brand-red;
content: "";
}
.search-controls {
display: flex;
align-items: center;
@@ -571,21 +516,15 @@ const confirmInvite = () =>
margin-top: 32rpx;
}
.search-field {
position: relative;
display: grid;
width: 430rpx;
height: 95rpx;
}
.search-field__skin,
.search-action__skin {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
pointer-events: none;
min-height: 95rpx;
background: url("/static/assets/modules/genealogy/opaque/g06-search-input-wide.png")
center / 100% 100% no-repeat;
}
.search-input {
position: relative;
z-index: 1;
grid-area: 1 / 1;
width: 100%;
height: 100%;
padding: 0 82rpx 0 30rpx;
@@ -597,26 +536,26 @@ const confirmInvite = () =>
color: #ab9c83;
}
.search-clear {
position: absolute;
z-index: 2;
top: 0;
right: 20rpx;
display: flex;
grid-area: 1 / 1;
justify-self: end;
height: 100%;
align-items: center;
margin-right: 20rpx;
color: $brand-red;
font-size: 21rpx;
}
.search-action {
position: relative;
display: flex;
width: 200rpx;
height: 88rpx;
min-height: 88rpx;
align-items: center;
justify-content: center;
background: url("/static/assets/modules/genealogy/opaque/g06-search-button.png")
center / 100% 100% no-repeat;
}
.search-action__label {
position: relative;
z-index: 1;
color: #fff4dc;
font-family: "STKaiti", "KaiTi", serif;
@@ -690,23 +629,16 @@ const confirmInvite = () =>
filter: grayscale(1);
}
.status-retry {
position: relative;
display: flex;
width: 300rpx;
height: 76rpx;
min-height: 76rpx;
align-items: center;
justify-content: center;
margin-top: 24rpx;
overflow: hidden;
}
.status-retry image {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
background: url("/static/assets/foundation/transparent/a01-scroll-primary-v3.png")
center / contain no-repeat;
}
.status-retry text {
position: relative;
z-index: 1;
color: #fff9ed;
font-size: 25rpx;
@@ -733,20 +665,13 @@ const confirmInvite = () =>
font-weight: 700;
}
.result-card {
position: relative;
min-height: 314rpx;
box-sizing: border-box;
padding: 28rpx 30rpx;
overflow: hidden;
}
.result-card__skin {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
background: url("/static/assets/modules/genealogy/transparent/list-slip-frame.png")
center / 100% 100% no-repeat;
}
.result-card__content {
position: relative;
z-index: 1;
}
.result-card__head {
@@ -823,13 +748,15 @@ const confirmInvite = () =>
line-height: 35rpx;
}
.invite-field {
position: relative;
display: grid;
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 {
position: relative;
z-index: 1;
grid-area: 1 / 1;
width: 100%;
height: 100%;
padding: 0 30rpx;
@@ -866,11 +793,12 @@ const confirmInvite = () =>
padding-left: 20rpx;
}
.search-title-strip__seal {
left: 44rpx;
margin-left: 44rpx;
}
.search-title-strip {
grid-template-columns: 138rpx 1fr 148rpx;
}
.search-title-strip__text {
right: 148rpx;
left: 138rpx;
font-size: 37rpx;
}
.search-field {
+59 -79
View File
@@ -14,24 +14,17 @@
'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">
<text class="join-form__eyebrow">{{ sourceContract.eyebrow }}</text>
<text class="join-form__title"
>{{ sourceContract.formTitle }} {{ genealogyName }}</text
>
<text class="join-form__copy">{{ sourceContract.formCopy }}</text>
<text v-if="previousNotice" class="join-form__previous">{{
previousNotice
}}</text>
<view class="join-field">
<image
src="/static/assets/modules/genealogy/transparent/g-form-field-frame.png"
mode="scaleToFill"
/>
<text>真实姓名</text
><input
v-model="form.realName"
@@ -44,10 +37,6 @@
fieldErrors.realName
}}</text>
<view class="join-field">
<image
src="/static/assets/modules/genealogy/transparent/g-form-field-frame.png"
mode="scaleToFill"
/>
<text>与家谱关系</text
><input
v-model="form.relation"
@@ -63,13 +52,10 @@
>请以家谱中一位已知长辈为参照例如汤正华堂侄</text
>
<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
><textarea
v-model="form.message"
auto-height
maxlength="80"
:placeholder="sourceContract.thirdFieldPlaceholder"
placeholder-class="join-placeholder"
@@ -78,10 +64,6 @@
<text class="join-form__note">{{ sourceContract.note }}</text>
<view class="join-action" @click="submitJoin">
<image
src="/static/assets/foundation/transparent/a01-scroll-primary-v3.png"
mode="aspectFit"
/>
<text>{{
isSubmitting
? sourceContract.submittingLabel
@@ -106,10 +88,6 @@
class="join-action"
@click="joinState === 'success' ? completeFlow() : retryForm()"
>
<image
src="/static/assets/foundation/transparent/a01-scroll-primary-v3.png"
mode="aspectFit"
/>
<text>{{
joinState === "success" ? sourceContract.nextLabel : "重新填写"
}}</text>
@@ -127,6 +105,7 @@ import PageHeader from "@/components/PageHeader.vue";
const genealogyId = ref("");
const source = ref("search");
const previousRelation = ref("");
const genealogyName = ref("这部家谱");
const genealogyPreview = {
1001: "汤氏家谱",
@@ -137,6 +116,15 @@ const isSubmitting = ref(false);
const errorMessage = ref("");
const form = reactive({ realName: "", relation: "", message: "" });
const fieldErrors = reactive({ realName: "", relation: "" });
const previousNotice = computed(() =>
previousRelation.value === "rejected"
? "上次申请未通过,请补充更准确的长辈姓名、祖居地或支系信息。"
: previousRelation.value === "removed"
? "你曾退出或被移出这部家谱,请重新确认身份关系后申请。"
: previousRelation.value === "withdrawn"
? "上次申请已撤回;如仍希望加入,请重新确认关系并提交。"
: "",
);
const sourceContract = computed(() =>
source.value === "invite"
? {
@@ -185,6 +173,9 @@ const resultCopy = computed(() =>
onLoad((query) => {
genealogyId.value = query.genealogyId || "";
source.value = query.source === "invite" ? "invite" : "search";
previousRelation.value = ["rejected", "removed", "withdrawn"].includes(query.previous)
? query.previous
: "";
if (query.state === "success") {
joinState.value = "success";
return;
@@ -194,7 +185,9 @@ onLoad((query) => {
joinState.value = "error";
return;
}
genealogyName.value = genealogyPreview[genealogyId.value] || "这部家谱";
genealogyName.value = query.genealogyName
? decodeURIComponent(query.genealogyName)
: genealogyPreview[genealogyId.value] || "这部家谱";
});
const submitJoin = () => {
@@ -232,31 +225,26 @@ const completeFlow = () =>
<style scoped lang="scss">
.join-page {
position: relative;
display: flex;
min-height: 100vh;
overflow-x: hidden;
flex-direction: column;
background: $paper;
}
.join-page__header {
position: relative;
z-index: 3;
}
.join-panel {
position: relative;
z-index: 2;
width: calc(100% - 32rpx);
height: min(590px, calc((100vw - 16px) * 1.337));
min-height: min(590px, calc((100vw - 16px) * 1.337));
margin: 22rpx auto 0;
}
.join-panel__skin {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
padding: 70rpx 8%;
box-sizing: border-box;
background: url("/static/assets/modules/genealogy/transparent/g01-empty-panel-frame.png")
center / 100% 100% no-repeat;
}
.join-form {
position: absolute;
inset: 8.5% 8%;
min-width: 0;
}
.join-form__eyebrow,
.join-result__eyebrow {
@@ -283,34 +271,37 @@ const completeFlow = () =>
line-height: 1.6;
}
.join-field {
position: relative;
height: 92rpx;
display: grid;
min-height: 92rpx;
margin-top: 18rpx;
background: url("/static/assets/modules/genealogy/transparent/g-form-field-frame.png")
center / 100% 100% no-repeat;
}
.join-field > image {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
.join-form__previous {
display: block;
margin-top: 14rpx;
color: $brand-red;
font-size: 22rpx;
line-height: 1.55;
}
.join-field > text {
position: absolute;
top: 31rpx;
left: 24rpx;
z-index: 1;
grid-area: 1 / 1;
align-self: center;
margin-left: 24rpx;
color: $ink;
font-size: 23rpx;
font-weight: 700;
}
.join-field input,
.join-field textarea {
position: absolute;
top: 0;
right: 20rpx;
bottom: 0;
left: 170rpx;
z-index: 1;
height: 92rpx;
grid-area: 1 / 1;
width: auto;
min-width: 0;
min-height: 92rpx;
margin-right: 20rpx;
margin-left: 170rpx;
color: $ink;
font-size: 23rpx;
line-height: 92rpx;
@@ -346,36 +337,29 @@ const completeFlow = () =>
text-align: center;
}
.join-action {
position: relative;
width: 100%;
height: 82rpx;
margin-top: 20rpx;
}
.join-action image {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
}
.join-action text {
position: relative;
z-index: 1;
display: flex;
width: 100%;
min-height: 82rpx;
align-items: 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;
font-size: 27rpx;
font-weight: 700;
letter-spacing: 3rpx;
}
.join-result {
position: absolute;
top: 28%;
right: 12%;
left: 12%;
text-align: center;
}
.join-state--success,
.join-state--error {
padding: 170rpx 12% 70rpx;
}
.join-result__eyebrow {
text-align: center;
}
@@ -391,9 +375,5 @@ const completeFlow = () =>
.join-panel {
width: calc(100% - 48rpx);
}
.join-form {
right: 9%;
left: 9%;
}
}
</style>
+71 -71
View File
@@ -23,11 +23,6 @@
:key="item.id"
class="application-card"
>
<image
class="application-card__skin"
src="/static/assets/modules/genealogy/transparent/list-slip-frame.png"
mode="scaleToFill"
/>
<view class="application-card__body">
<text class="application-card__name">{{ item.genealogyName }}</text>
<text class="application-card__time">{{ item.appliedAt }}</text>
@@ -56,10 +51,6 @@
description="请稍候,正在同步审核状态。"
/>
<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">
<text>{{ stateTitle }}</text>
<text>{{ stateCopy }}</text>
@@ -72,10 +63,6 @@
applicationState === 'error' ? loadApplications({}) : toSearch()
"
>
<image
src="/static/assets/foundation/transparent/a01-scroll-primary-v3.png"
mode="aspectFit"
/>
<text>{{
applicationState === "error" ? "重新查看" : "查找公开家谱"
}}</text>
@@ -156,7 +143,7 @@ const statusHint = (status) =>
})[status] || "";
const actionFor = (item) =>
({ PENDING: "撤回申请", APPROVED: "进入家谱", REJECTED: "修改后重新提交" })[
({ PENDING: "撤回申请", APPROVED: "进入家谱", REJECTED: "修改后重新提交", WITHDRAWN: "重新申请" })[
item.status
] || "";
const stateTitle = computed(() =>
@@ -190,7 +177,14 @@ const loadApplications = (query = {}) => {
return;
}
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);
@@ -201,7 +195,11 @@ const handleApplicationAction = (item) => {
});
if (item.status === "REJECTED")
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;
};
@@ -221,17 +219,15 @@ const toSearch = () =>
<style scoped lang="scss">
.application-page {
position: relative;
display: flex;
min-height: 100vh;
overflow-x: hidden;
flex-direction: column;
background: $paper;
}
.application-page__header {
position: relative;
z-index: 3;
}
.application-content {
position: relative;
z-index: 2;
padding: 28rpx 24rpx 60rpx;
}
@@ -252,47 +248,57 @@ const toSearch = () =>
font-size: 24rpx;
}
.application-card {
position: relative;
display: grid;
width: 100%;
height: calc((100vw - 24px) * 0.34286);
min-height: 228rpx;
max-height: 282rpx;
margin-bottom: 18rpx;
}
.application-card__skin {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
background: url("/static/assets/modules/genealogy/transparent/list-slip-frame.png")
center / 100% 100% no-repeat;
}
.application-card__body {
position: absolute;
inset: 18% 7% 14% 8.5%;
display: grid;
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 {
grid-row: 1;
grid-column: 1;
align-self: start;
justify-self: start;
color: $ink;
font-family: "STKaiti", "KaiTi", serif;
font-size: 30rpx;
font-weight: 700;
}
.application-card__time {
position: absolute;
top: 3rpx;
right: 0;
grid-row: 1;
grid-column: 2;
align-self: start;
justify-self: end;
margin-top: 3rpx;
color: #998873;
font-size: 23rpx;
}
.application-card__relation {
display: block;
max-width: 70%;
margin-top: 14rpx;
grid-row: 2;
grid-column: 1 / -1;
align-self: start;
justify-self: start;
margin-top: 0;
color: $ink-muted;
font-size: 24rpx;
}
.application-card__status {
position: absolute;
right: 3%;
bottom: 38%;
grid-row: 2;
grid-column: 2;
align-self: center;
justify-self: end;
margin-right: 3%;
color: $brand-red;
font-size: 25rpx;
font-weight: 700;
@@ -304,41 +310,42 @@ const toSearch = () =>
color: #7e6f62;
}
.application-card__hint {
position: absolute;
bottom: 5%;
left: 0;
max-width: 62%;
grid-row: 3;
grid-column: 1;
align-self: center;
justify-self: start;
margin-bottom: 4rpx;
color: #766653;
font-size: 23rpx;
}
.application-card__action {
position: absolute;
right: 2%;
bottom: 2%;
z-index: 2;
display: flex;
grid-row: 3;
grid-column: 2;
align-self: center;
justify-self: end;
min-height: 54rpx;
align-items: center;
margin-right: 2%;
margin-bottom: 0;
color: $brand-red;
font-size: 23rpx;
font-weight: 700;
}
.application-state-card {
position: relative;
display: flex;
width: 100%;
height: calc((100vw - 24px) * 0.34286);
min-height: 228rpx;
align-items: center;
justify-content: center;
margin-top: 80rpx;
}
.application-state-card > image {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
padding: 48rpx 12%;
box-sizing: border-box;
background: url("/static/assets/modules/genealogy/transparent/list-slip-frame.png")
center / 100% 100% no-repeat;
}
.application-state-card__copy {
position: absolute;
inset: 22% 12%;
display: flex;
flex-direction: column;
justify-content: center;
@@ -357,24 +364,17 @@ const toSearch = () =>
line-height: 1.6;
}
.application-page__action {
position: relative;
width: 420rpx;
height: 82rpx;
margin: 34rpx auto 0;
}
.application-page__action image {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
}
.application-page__action text {
position: relative;
z-index: 1;
display: flex;
width: 420rpx;
min-height: 82rpx;
align-items: 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;
font-size: 26rpx;
font-weight: 700;
+72 -91
View File
@@ -18,10 +18,6 @@
<view v-if="reviewState !== 'loading'" class="review-intro">
<text>核实亲属关系后再决定</text>
<text>审核结果会通过消息告知申请人</text>
<image
src="/static/assets/modules/genealogy/transparent/section-divider.png"
mode="scaleToFill"
/>
</view>
<template v-if="reviewState === 'list'">
@@ -30,11 +26,6 @@
:key="item.id"
class="application-card"
>
<image
class="application-card__skin"
src="/static/assets/modules/genealogy/transparent/list-slip-frame.png"
mode="scaleToFill"
/>
<view class="application-card__body">
<text class="application-card__name">{{ item.name }}</text>
<text class="application-card__phone">{{ item.phone }}</text>
@@ -85,10 +76,6 @@
description="请稍候,正在读取待审核记录。"
/>
<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">
<text>{{ stateTitle }}</text>
<text>{{ stateCopy }}</text>
@@ -133,13 +120,23 @@
@confirm="helpVisible ? closeDialog() : applyAudit()"
@cancel="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">
<image
src="/static/assets/foundation/transparent/a01-scroll-toast-v3.png"
mode="scaleToFill"
/>
<text>{{ feedbackMessage }}</text>
</view>
</view>
@@ -177,6 +174,8 @@ const genealogyId = ref("");
const reviewState = ref("loading");
const errorMessage = ref("");
const confirmation = ref(null);
const rejectionReason = ref("");
const rejectionError = ref("");
const helpVisible = ref(false);
const feedbackVisible = ref(false);
const feedbackMessage = ref("");
@@ -235,10 +234,14 @@ const loadApplications = (query = {}) => {
onLoad(loadApplications);
const confirmAudit = (item, approved) => {
rejectionReason.value = "";
rejectionError.value = "";
confirmation.value = { item, approved };
};
const closeDialog = () => {
confirmation.value = null;
rejectionReason.value = "";
rejectionError.value = "";
helpVisible.value = false;
};
const showFeedback = (message) => {
@@ -253,7 +256,12 @@ const showFeedback = (message) => {
const applyAudit = () => {
const current = confirmation.value;
if (!current) return;
if (!current.approved && !rejectionReason.value.trim()) {
rejectionError.value = "请填写拒绝原因,方便申请人补充资料";
return;
}
current.item.status = current.approved ? "APPROVED" : "REJECTED";
if (!current.approved) current.item.rejectionReason = rejectionReason.value.trim();
closeDialog();
showFeedback(current.approved ? "已通过申请" : "已拒绝申请");
};
@@ -264,24 +272,26 @@ const showHelp = () => {
<style scoped lang="scss">
.review-page {
position: relative;
display: flex;
min-height: 100vh;
overflow-x: hidden;
flex-direction: column;
background: $paper;
}
.review-page__header {
position: relative;
z-index: 3;
z-index: 1;
}
.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 {
position: relative;
z-index: 2;
z-index: 1;
padding: 24rpx 24rpx 60rpx;
}
.review-intro {
position: relative;
margin: 0 8rpx 24rpx;
padding-bottom: 22rpx;
background: url("/static/assets/modules/genealogy/transparent/section-divider.png") bottom center / 100% 14rpx no-repeat;
}
.review-intro text {
display: block;
@@ -297,80 +307,72 @@ const showHelp = () => {
color: $ink-muted;
font-size: 24rpx;
}
.review-intro image {
position: absolute;
right: 0;
bottom: 0;
left: 0;
width: 100%;
height: 14rpx;
}
.application-card {
position: relative;
width: 100%;
height: calc((100vw - 24px) * 0.34286);
min-height: 228rpx;
max-height: 282rpx;
margin-bottom: 18rpx;
}
.application-card__skin {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
background: url("/static/assets/modules/genealogy/transparent/list-slip-frame.png") center / 100% 100% no-repeat;
}
.application-card__body {
position: absolute;
inset: 18% 7% 12% 8.5%;
display: grid;
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 {
grid-column: 1;
color: $ink;
font-family: "STKaiti", "KaiTi", serif;
font-size: 30rpx;
font-weight: 700;
}
.application-card__phone {
grid-column: 2;
margin-left: 14rpx;
color: $ink-muted;
font-size: 23rpx;
}
.application-card__time {
position: absolute;
top: 3rpx;
right: 0;
grid-column: 3;
justify-self: end;
margin-top: 3rpx;
color: #998873;
font-size: 23rpx;
}
.application-card__relation {
display: block;
max-width: 52%;
grid-column: 1 / span 2;
grid-row: 2;
margin-top: 15rpx;
color: $ink-muted;
font-size: 24rpx;
line-height: 1.35;
}
.review-actions {
position: absolute;
right: 0;
bottom: 0;
display: flex;
grid-column: 2 / 4;
grid-row: 3;
justify-self: end;
gap: 12rpx;
}
.review-action {
position: relative;
display: grid;
width: 148rpx;
height: 68rpx;
min-height: 68rpx;
}
.review-action image {
position: absolute;
inset: 0;
grid-area: 1 / 1;
width: 100%;
height: 100%;
}
.review-action text {
position: relative;
z-index: 1;
display: flex;
grid-area: 1 / 1;
align-items: center;
justify-content: center;
height: 100%;
@@ -384,22 +386,22 @@ const showHelp = () => {
color: #fff9ed;
}
.review-result {
position: absolute;
right: 0;
bottom: 0;
display: grid;
grid-column: 2 / 4;
grid-row: 3;
justify-self: end;
width: 308rpx;
height: 68rpx;
min-height: 68rpx;
}
.review-result image {
position: absolute;
inset: 0;
grid-area: 1 / 1;
width: 100%;
height: 100%;
}
.application-card__status {
position: relative;
z-index: 1;
display: flex;
grid-area: 1 / 1;
align-items: center;
justify-content: center;
height: 100%;
@@ -414,24 +416,18 @@ const showHelp = () => {
color: $ink;
}
.review-state-card {
position: relative;
width: 100%;
height: calc((100vw - 24px) * 0.34286);
min-height: 228rpx;
margin-top: 70rpx;
}
.review-state-card > image {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
background: url("/static/assets/modules/genealogy/transparent/list-slip-frame.png") center / 100% 100% no-repeat;
}
.review-state-card__copy {
position: absolute;
inset: 22% 12%;
display: flex;
min-height: 228rpx;
flex-direction: column;
justify-content: center;
padding: 48rpx 12%;
box-sizing: border-box;
text-align: center;
}
.review-state-card__copy text:first-child {
@@ -447,20 +443,12 @@ const showHelp = () => {
line-height: 1.6;
}
.review-page__retry {
position: relative;
width: 420rpx;
height: 82rpx;
min-height: 82rpx;
margin: 32rpx auto 0;
}
.review-page__retry image {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
background: url("/static/assets/foundation/transparent/a01-scroll-primary-v3.png") center / contain no-repeat;
}
.review-page__retry text {
position: relative;
z-index: 1;
display: flex;
align-items: center;
justify-content: center;
@@ -480,17 +468,10 @@ const showHelp = () => {
align-items: center;
justify-content: center;
padding: 0 36rpx;
background: url("/static/assets/foundation/transparent/a01-scroll-toast-v3.png") center / 100% 100% no-repeat;
transform: translateX(-50%);
}
.review-feedback image {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
}
.review-feedback text {
position: relative;
z-index: 1;
color: $ink;
font-size: 23rpx;
}
+69 -90
View File
@@ -13,12 +13,6 @@
'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
v-if="settingsState === 'loading'"
text="正在读取家谱设置"
@@ -32,10 +26,6 @@
>
<view class="settings-field">
<image
src="/static/assets/modules/genealogy/transparent/g-form-field-frame.png"
mode="scaleToFill"
/>
<text>家谱名称</text>
<input
v-model="genealogyDraft.name"
@@ -79,13 +69,10 @@
</view>
<view class="settings-field settings-field--note">
<image
src="/static/assets/modules/genealogy/transparent/g-form-field-frame.png"
mode="scaleToFill"
/>
<text>访问说明</text>
<textarea
v-model="genealogyDraft.accessNote"
auto-height
maxlength="80"
placeholder="向访问者说明家谱用途"
placeholder-class="settings-placeholder"
@@ -93,10 +80,6 @@
</view>
<view class="settings-action" @click="saveSettings">
<image
src="/static/assets/foundation/transparent/a01-scroll-primary-v3.png"
mode="aspectFit"
/>
<text>保存设置</text>
</view>
</view>
@@ -124,10 +107,6 @@
: "请从家谱总览重新进入,当前修改不会被保留。"
}}</text>
<view class="settings-action" @click="settingsState = 'form'">
<image
src="/static/assets/foundation/transparent/a01-scroll-primary-v3.png"
mode="aspectFit"
/>
<text>{{
settingsState === "success" ? "继续调整" : "重新查看"
}}</text>
@@ -135,10 +114,6 @@
</view>
</view>
<view v-if="feedbackVisible" class="settings-feedback">
<image
src="/static/assets/foundation/transparent/a01-scroll-secondary-v3.png"
mode="aspectFit"
/>
<text>设置已保存</text>
</view>
</view>
@@ -156,11 +131,27 @@ const settingsState = ref("loading");
const nameError = ref("");
const feedbackVisible = ref(false);
let feedbackTimer = null;
const settingsFixtures = {
1001: {
name: "汤氏家谱",
visibility: "MEMBER_ONLY",
accessNote: "家族资料,请妥善保存",
},
1002: {
name: "汤氏宗谱",
visibility: "PUBLIC_APPLY",
accessNote: "公开家谱身份,成员资料需审核后查看",
},
};
const genealogyDraft = reactive({
name: "汤氏家谱",
visibility: "MEMBER_ONLY",
accessNote: "家族资料,请妥善保存",
});
const originalDraft = ref("");
const isDirty = computed(
() => JSON.stringify(genealogyDraft) !== originalDraft.value,
);
const visibilityOptions = [
{ value: "MEMBER_ONLY", label: "仅成员可见" },
{ value: "PUBLIC_APPLY", label: "公开可申请" },
@@ -178,6 +169,11 @@ const visibilityHint = computed(() =>
onLoad((query) => {
genealogyId.value = query.genealogyId || "";
Object.assign(
genealogyDraft,
settingsFixtures[genealogyId.value] || settingsFixtures[1001],
);
originalDraft.value = JSON.stringify(genealogyDraft);
settingsState.value =
query.state === "loading"
? "loading"
@@ -198,6 +194,11 @@ const saveSettings = () => {
nameError.value = "请填写家谱名称";
return;
}
if (genealogyDraft.name.trim().length > 30) {
nameError.value = "家谱名称不能超过 30 个字";
return;
}
originalDraft.value = JSON.stringify(genealogyDraft);
settingsState.value = "success";
feedbackVisible.value = true;
feedbackTimer = setTimeout(() => {
@@ -209,31 +210,29 @@ const saveSettings = () => {
<style scoped lang="scss">
.settings-page {
position: relative;
display: flex;
min-height: 100vh;
overflow-x: hidden;
flex-direction: column;
background: $paper;
}
.settings-page__header {
position: relative;
z-index: 3;
}
.settings-panel {
position: relative;
z-index: 2;
width: calc(100% - 32rpx);
height: min(620px, calc((100vw - 16px) * 1.43));
min-height: min(620px, calc((100vw - 16px) * 1.43));
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 {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
.settings-panel > .app-loading {
grid-area: 1 / 1 / -1 / -1;
}
.settings-form {
position: absolute;
inset: 7.5% 8%;
min-width: 0;
}
.settings-form__eyebrow,
.settings-result__eyebrow {
@@ -260,34 +259,30 @@ const saveSettings = () => {
line-height: 1.55;
}
.settings-field {
position: relative;
height: 92rpx;
display: grid;
min-height: 92rpx;
margin-top: 17rpx;
}
.settings-field > image {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
background: url("/static/assets/modules/genealogy/transparent/g-form-field-frame.png")
center / 100% 100% no-repeat;
}
.settings-field > text {
position: absolute;
top: 31rpx;
left: 24rpx;
z-index: 1;
grid-area: 1 / 1;
align-self: center;
margin-left: 24rpx;
color: $ink;
font-size: 24rpx;
font-weight: 700;
}
.settings-field input,
.settings-field textarea {
position: absolute;
top: 0;
right: 20rpx;
bottom: 0;
left: 164rpx;
z-index: 1;
height: 92rpx;
grid-area: 1 / 1;
width: auto;
min-width: 0;
min-height: 92rpx;
margin-right: 20rpx;
margin-left: 164rpx;
color: $ink;
font-size: 24rpx;
line-height: 92rpx;
@@ -323,20 +318,19 @@ const saveSettings = () => {
margin-top: 10rpx;
}
.visibility-option {
position: relative;
display: grid;
width: calc(50% - 7rpx);
height: 64rpx;
min-height: 64rpx;
}
.visibility-option image {
position: absolute;
inset: 0;
grid-area: 1 / 1;
width: 100%;
height: 100%;
}
.visibility-option text {
position: relative;
z-index: 1;
display: flex;
grid-area: 1 / 1;
align-items: center;
justify-content: center;
height: 100%;
@@ -358,36 +352,30 @@ const saveSettings = () => {
margin-top: 14rpx;
}
.settings-action {
position: relative;
width: 100%;
height: 78rpx;
margin-top: 19rpx;
}
.settings-action image {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
}
.settings-action text {
position: relative;
z-index: 1;
display: flex;
width: 100%;
min-height: 78rpx;
align-items: 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;
font-size: 26rpx;
font-weight: 700;
letter-spacing: 3rpx;
}
.settings-result {
position: absolute;
top: 29%;
right: 12%;
left: 12%;
text-align: center;
}
.settings-state--success,
.settings-state--error,
.settings-state--no-permission {
padding: 180rpx 12% 70rpx;
}
.settings-result__eyebrow {
text-align: center;
}
@@ -411,15 +399,10 @@ const saveSettings = () => {
justify-content: center;
padding: 0 34rpx;
transform: translateX(-50%);
}
.settings-feedback image {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
background: url("/static/assets/foundation/transparent/a01-scroll-secondary-v3.png")
center / contain no-repeat;
}
.settings-feedback text {
position: relative;
z-index: 1;
color: $ink;
font-size: 22rpx;
@@ -428,9 +411,5 @@ const saveSettings = () => {
.settings-panel {
width: calc(100% - 48rpx);
}
.settings-form {
right: 9%;
left: 9%;
}
}
</style>
+71 -95
View File
@@ -19,12 +19,6 @@
'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
v-if="poemState === 'loading'"
text="正在整理字辈诗"
@@ -43,10 +37,6 @@
class="poem-row"
:class="{ 'poem-row--current': item.current }"
>
<image
src="/static/assets/modules/genealogy/transparent/g-form-field-frame.png"
mode="scaleToFill"
/>
<text class="poem-row__number"> {{ item.generationNo }} </text>
<text class="poem-row__character">{{ item.character }}</text>
<text class="poem-row__status">{{
@@ -69,14 +59,11 @@
>按照接口支持的连续文本录入每个汉字对应一代保存前可预览新字序</text
>
<view class="poem-field">
<image
src="/static/assets/modules/genealogy/transparent/g-form-field-frame.png"
mode="scaleToFill"
/>
<text>字辈内容</text>
<textarea
v-model="poemDraft"
maxlength="24"
auto-height
maxlength="50"
placeholder="例如:启宗敦本继世传芳"
placeholder-class="poem-placeholder"
@input="poemError = ''"
@@ -158,10 +145,6 @@
</view>
</view>
<view v-if="feedbackVisible" class="poem-feedback">
<image
src="/static/assets/foundation/transparent/a01-scroll-secondary-v3.png"
mode="aspectFit"
/>
<text>字辈预览已更新</text>
</view>
</view>
@@ -181,6 +164,8 @@ const feedbackVisible = ref(false);
let feedbackTimer = null;
const poemDraft = ref("启宗敦本继世传芳");
const stopMissingOldGeneration = ref(true);
const startGeneration = ref(12);
const currentGeneration = ref(14);
const poemRows = ref([
{ generationNo: 12, character: "启", current: false },
{ generationNo: 13, character: "宗", current: false },
@@ -193,6 +178,11 @@ const previewCharacters = computed(() =>
onLoad((query) => {
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 =
query.state === "loading"
? "loading"
@@ -223,13 +213,20 @@ const savePoems = () => {
poemError.value = "字辈保存失败,请稍后重试";
return;
}
poemRows.value = characters
.slice(0, 4)
.map((character, index) => ({
generationNo: 12 + index,
const preservedRows = stopMissingOldGeneration.value
? []
: poemRows.value.filter(
(item) => item.generationNo < startGeneration.value,
);
const nextRows = characters.map((character, index) => {
const generationNo = startGeneration.value + index;
return {
generationNo,
character,
current: index === 2,
}));
current: generationNo === currentGeneration.value,
};
});
poemRows.value = [...preservedRows, ...nextRows];
poemState.value = "list";
feedbackVisible.value = true;
feedbackTimer = setTimeout(() => {
@@ -241,33 +238,31 @@ const savePoems = () => {
<style scoped lang="scss">
.poem-page {
position: relative;
display: flex;
min-height: 100vh;
overflow-x: hidden;
flex-direction: column;
background: $paper;
}
.poem-page__header {
position: relative;
z-index: 3;
}
.poem-panel {
position: relative;
z-index: 2;
width: calc(100% - 32rpx);
height: min(650px, calc((100vw - 16px) * 1.5));
min-height: min(650px, calc((100vw - 16px) * 1.5));
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 {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
.poem-panel > .app-loading {
grid-area: 1 / 1 / -1 / -1;
}
.poem-list,
.poem-editor,
.poem-state-card {
position: absolute;
inset: 7.5% 8%;
min-width: 0;
}
.poem-list__eyebrow {
display: block;
@@ -294,39 +289,33 @@ const savePoems = () => {
margin-top: 18rpx;
}
.poem-row {
position: relative;
height: 72rpx;
display: grid;
min-height: 72rpx;
margin-top: 10rpx;
}
.poem-row image {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
grid-template-columns: 48% auto 1fr auto;
align-items: center;
background: url("/static/assets/modules/genealogy/transparent/g-form-field-frame.png")
center / 100% 100% no-repeat;
}
.poem-row__number {
position: absolute;
top: 24rpx;
left: 24rpx;
z-index: 1;
grid-column: 1;
margin-left: 24rpx;
color: $ink-muted;
font-size: 23rpx;
}
.poem-row__character {
position: absolute;
top: 14rpx;
left: 48%;
z-index: 1;
grid-column: 2;
color: $ink;
font-family: "STKaiti", "KaiTi", serif;
font-size: 32rpx;
font-weight: 700;
}
.poem-row__status {
position: absolute;
top: 25rpx;
right: 22rpx;
z-index: 1;
grid-column: 4;
margin-right: 22rpx;
color: $ink-muted;
font-size: 22rpx;
}
@@ -335,21 +324,20 @@ const savePoems = () => {
color: $brand-red;
}
.poem-action {
position: relative;
display: grid;
width: 100%;
height: 76rpx;
min-height: 76rpx;
margin-top: 20rpx;
}
.poem-action image {
position: absolute;
inset: 0;
grid-area: 1 / 1;
width: 100%;
height: 100%;
}
.poem-action text {
position: relative;
z-index: 1;
display: flex;
grid-area: 1 / 1;
align-items: center;
justify-content: center;
height: 100%;
@@ -359,34 +347,30 @@ const savePoems = () => {
letter-spacing: 2rpx;
}
.poem-field {
position: relative;
height: 118rpx;
display: grid;
min-height: 118rpx;
margin-top: 22rpx;
}
.poem-field image {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
background: url("/static/assets/modules/genealogy/transparent/g-form-field-frame.png")
center / 100% 100% no-repeat;
}
.poem-field > text {
position: absolute;
top: 47rpx;
left: 24rpx;
z-index: 1;
grid-area: 1 / 1;
align-self: center;
margin-left: 24rpx;
color: $ink;
font-size: 22rpx;
font-weight: 700;
}
.poem-field textarea {
position: absolute;
top: 0;
right: 20rpx;
bottom: 0;
left: 164rpx;
z-index: 1;
grid-area: 1 / 1;
box-sizing: border-box;
height: 118rpx;
width: auto;
min-width: 0;
min-height: 118rpx;
margin-right: 20rpx;
margin-left: 164rpx;
padding-top: 34rpx;
color: $ink;
font-size: 22rpx;
@@ -415,20 +399,19 @@ const savePoems = () => {
font-weight: 700;
}
.poem-policy__option {
position: relative;
display: grid;
width: 248rpx;
height: 62rpx;
min-height: 62rpx;
}
.poem-policy__option image {
position: absolute;
inset: 0;
grid-area: 1 / 1;
width: 100%;
height: 100%;
}
.poem-policy__option text {
position: relative;
z-index: 1;
display: flex;
grid-area: 1 / 1;
align-items: center;
justify-content: center;
height: 100%;
@@ -459,9 +442,13 @@ const savePoems = () => {
color: $ink;
}
.poem-state-card {
top: 29%;
text-align: center;
}
.poem-state--empty,
.poem-state--error,
.poem-state--no-permission {
padding: 180rpx 4% 70rpx;
}
.poem-state-card .poem-list__eyebrow {
text-align: center;
}
@@ -485,15 +472,10 @@ const savePoems = () => {
justify-content: center;
padding: 0 34rpx;
transform: translateX(-50%);
}
.poem-feedback image {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
background: url("/static/assets/foundation/transparent/a01-scroll-secondary-v3.png")
center / contain no-repeat;
}
.poem-feedback text {
position: relative;
z-index: 1;
color: $ink;
font-size: 22rpx;
@@ -502,11 +484,5 @@ const savePoems = () => {
.poem-panel {
width: calc(100% - 48rpx);
}
.poem-list,
.poem-editor,
.poem-state-card {
right: 9%;
left: 9%;
}
}
</style>
+18 -40
View File
@@ -13,7 +13,7 @@
<view class="notice-page__header">
<PageHeader
title="消息中心"
:action="noticeState === 'list' ? '全部已读' : ''"
:action="unreadCount > 0 && noticeState === 'list' ? '全部已读' : ''"
@action="markAllRead"
/>
</view>
@@ -33,13 +33,8 @@
class="notice-card"
role="button"
: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">
<text
class="notice-card__status"
@@ -61,11 +56,6 @@
</template>
<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">
<text class="notice-state-card__title">{{
noticeState === "empty" ? "暂时没有新消息" : "消息中心暂不可用"
@@ -90,7 +80,7 @@
</template>
<script setup>
import { onUnmounted, ref } from "vue";
import { computed, onUnmounted, ref } from "vue";
import { onLoad } from "@dcloudio/uni-app";
import AppButton from "@/components/AppButton.vue";
import AppLoading from "@/components/AppLoading.vue";
@@ -112,6 +102,7 @@ const notices = ref([
content: "汤志成申请加入汤氏家谱,请核实亲属关系。",
time: "今天 10:28",
unread: true,
detailId: "review-1",
},
{
id: 2,
@@ -119,6 +110,7 @@ const notices = ref([
content: "你申请加入汝南汤氏家谱的请求已通过。",
time: "昨天 18:10",
unread: false,
detailId: "approved",
},
]);
@@ -135,8 +127,14 @@ onLoad((query) => {
: "list";
});
const readNotice = (item) => {
const unreadCount = computed(
() => notices.value.filter((item) => item.unread).length,
);
const openNotice = (item) => {
item.unread = false;
uni.navigateTo({
url: `/pages/notification/n02-message-detail?id=${item.detailId}&genealogyId=${genealogyId.value}`,
});
};
const restoreList = () => {
noticeState.value = "list";
@@ -168,17 +166,17 @@ onUnmounted(() => {
<style scoped lang="scss">
.notice-page {
position: relative;
display: flex;
min-height: 100vh;
overflow-x: hidden;
flex-direction: column;
background: $paper;
}
.notice-page__header,
.notice-content {
position: relative;
z-index: 2;
z-index: 1;
}
.notice-content {
flex: 1;
padding: 24rpx 28rpx 100rpx;
}
.notice-list {
@@ -187,19 +185,11 @@ onUnmounted(() => {
gap: 18rpx;
}
.notice-card {
position: relative;
width: 100%;
min-height: 220rpx;
}
.notice-card__skin {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
background: url("/static/assets/modules/notification/transparent/n01-notice-card.png") center / 100% 100% no-repeat;
}
.notice-card__copy {
position: relative;
z-index: 1;
display: flex;
min-height: 220rpx;
box-sizing: border-box;
@@ -238,24 +228,12 @@ onUnmounted(() => {
margin: 30rpx auto 0;
}
.notice-state-card {
position: relative;
margin-top: 38rpx;
background: url("/static/assets/modules/notification/transparent/n01-notice-card.png") top center / 100% 220rpx no-repeat;
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 {
position: relative;
z-index: 1;
display: flex;
height: 220rpx;
min-height: 118px;
box-sizing: border-box;
flex-direction: column;
+109 -3
View File
@@ -1,5 +1,111 @@
<!-- 页面编号N-02用途消息详情已读与空状态 -->
<template><ModulePage page-id="n02" /></template>
<!-- 页面编号N-02用途消息详情已读状态与上下文操作 -->
<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>
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>
<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>
+51 -75
View File
@@ -19,11 +19,6 @@
<view class="profile-content">
<template v-if="profileState === 'ready'">
<view class="profile-hero">
<image
class="profile-hero__frame"
src="/static/assets/modules/profile/transparent/m01-profile-summary-card.png"
mode="scaleToFill"
/>
<image
class="profile-hero__hall"
src="/static/assets/foundation/transparent/root-header-hall.png"
@@ -34,19 +29,21 @@
src="/static/assets/foundation/transparent/auth-title-cloud.png"
mode="aspectFit"
/>
<view class="profile-hero__label"
><text></text><text></text><text></text><text></text></view
>
<view class="profile-identity">
<image
class="profile-identity__seal"
src="/static/assets/foundation/transparent/brand-seal.png"
mode="aspectFit"
/>
<view class="profile-identity__copy">
<text class="profile-identity__name">汤文清</text>
<text class="profile-identity__role">氏家谱 · 家谱成员</text>
<text class="profile-identity__phone">139****6421</text>
<view class="profile-hero__content">
<view class="profile-hero__label"
><text></text><text></text><text></text><text></text></view
>
<view class="profile-identity">
<image
class="profile-identity__seal"
src="/static/assets/foundation/transparent/brand-seal.png"
mode="aspectFit"
/>
<view class="profile-identity__copy">
<text class="profile-identity__name">文清</text>
<text class="profile-identity__role">汤氏家谱 · 家谱成员</text>
<text class="profile-identity__phone">139****6421</text>
</view>
</view>
</view>
</view>
@@ -57,11 +54,6 @@
aria-label="查看待处理消息"
@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">
<text>待你处理</text>
<text>2 条家谱提醒与审核通知</text>
@@ -109,11 +101,6 @@
</template>
<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">
<text class="profile-error__title">个人资料暂不可用</text>
<text class="profile-error__description"
@@ -161,6 +148,18 @@ const menuItems = [
icon: "/static/assets/foundation/transparent/brand-seal.png",
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) => {
@@ -179,58 +178,58 @@ const openItem = (item) => uni.navigateTo({ url: item.url });
<style scoped lang="scss">
.profile-page {
position: relative;
display: flex;
min-height: 100vh;
overflow-x: hidden;
flex-direction: column;
background: $paper;
}
.profile-page__header,
.profile-content {
position: relative;
z-index: 2;
z-index: 1;
}
.profile-content {
flex: 1;
padding: 26rpx 30rpx 190rpx;
}
.profile-hero {
position: relative;
display: grid;
grid-template-columns: 42rpx minmax(0, 1fr);
width: 100%;
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;
gap: 30rpx;
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 {
position: absolute;
right: -18rpx;
bottom: -30rpx;
align-self: end;
justify-self: end;
grid-area: 1 / 1;
width: 330rpx;
height: 122rpx;
margin-right: -18rpx;
margin-bottom: -30rpx;
opacity: 0.12;
pointer-events: none;
}
.profile-hero__cloud {
position: absolute;
top: 22rpx;
right: 30rpx;
align-self: start;
justify-self: end;
grid-area: 1 / 1;
width: 96rpx;
height: 52rpx;
margin-top: 22rpx;
margin-right: 30rpx;
opacity: 0.32;
pointer-events: none;
}
.profile-hero__label {
position: relative;
z-index: 2;
display: flex;
width: 42rpx;
@@ -249,7 +248,6 @@ const openItem = (item) => uni.navigateTo({ url: item.url });
line-height: 1.12;
}
.profile-identity {
position: relative;
z-index: 1;
display: flex;
min-height: 252rpx;
@@ -286,21 +284,12 @@ const openItem = (item) => uni.navigateTo({ url: item.url });
letter-spacing: 2rpx;
}
.profile-scroll-notice {
position: relative;
width: 88%;
min-height: 112rpx;
margin: 22rpx auto 0;
}
.profile-scroll-notice__skin {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
pointer-events: none;
background: url("/static/assets/foundation/transparent/a01-scroll-primary-v3.png") center / 100% 100% no-repeat;
}
.profile-scroll-notice__copy {
position: relative;
z-index: 1;
display: flex;
min-height: 112rpx;
box-sizing: border-box;
@@ -389,25 +378,12 @@ const openItem = (item) => uni.navigateTo({ url: item.url });
opacity: 0.75;
}
.profile-error {
position: relative;
margin-top: 38rpx;
background: url("/static/assets/modules/profile/transparent/m01-profile-summary-card.png") top center / 100% 220rpx no-repeat;
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 {
position: relative;
z-index: 1;
display: flex;
height: 220rpx;
min-height: 118px;
box-sizing: border-box;
flex-direction: column;
+76 -2
View File
@@ -1,5 +1,79 @@
<!-- 页面编号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>
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>
<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用途账号与安全设置 -->
<template><ModulePage page-id="m03" /></template>
<!-- 页面编号M-03用途账号安全总览与安全功能入口 -->
<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>
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>
<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用途修改密码 -->
<template><ModulePage page-id="m04" /></template>
<!-- 页面编号M-04用途修改登录密码 -->
<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>
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>
<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用途修改手机号 -->
<template><ModulePage page-id="m05" /></template>
<!-- 页面编号M-05用途验证并更换绑定手机号 -->
<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>
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>
<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用途帮助中心 -->
<template><ModulePage page-id="m06" /></template>
<!-- 页面编号M-06用途帮助搜索分类与常见问题 -->
<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>
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>
<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用途意见反馈 -->
<template><ModulePage page-id="m07" /></template>
<!-- 页面编号M-07用途提交意见反馈 -->
<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>
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>
<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用途应用推广 -->
<template><ModulePage page-id="m08" /></template>
<!-- 页面编号M-08用途邀请家人共建家谱 -->
<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>
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>
<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 服务与订单 -->
<template><ModulePage page-id="m09" /></template>
<!-- 页面编号M-09用途服务权益与订单记录 -->
<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>
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>
<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用途关于协议隐私与退出确认 -->
<template><ModulePage page-id="m10" /></template>
<!-- 页面编号M-10用途协议隐私版本与退出登录 -->
<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>
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>
<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">
<template v-if="peopleState === 'ready'">
<view class="people-search">
<image
src="/static/assets/modules/records/transparent/r01-search-input-frame.png"
mode="scaleToFill"
/>
<input
v-model="keywordInput"
confirm-type="search"
@@ -41,11 +37,6 @@
class="person-card"
@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">
<text class="person-card__name">{{ person.name }}</text>
<text class="person-card__meta"
@@ -63,11 +54,7 @@
</view>
<view v-else class="people-result-empty">
<image
src="/static/assets/modules/records/transparent/r01-person-name-card.png"
mode="scaleToFill"
/>
<view
<view class="people-state-card__copy"
><text>没有找到相关人物</text
><text>请更换姓名身份或世代关键词后再试</text></view
>
@@ -81,11 +68,7 @@
</template>
<view v-else class="people-state-card">
<image
src="/static/assets/modules/records/transparent/r01-person-name-card.png"
mode="scaleToFill"
/>
<view>
<view class="people-state-card__copy">
<text>{{
peopleState === "empty" ? "还没有人物记录" : "人物录暂不可用"
}}</text>
@@ -171,12 +154,7 @@ const openPerson = (person) =>
url: `/pages/records/r02-person-detail?personId=${person.id}`,
});
const showCreateNotice = () => {
toastVisible.value = true;
if (toastTimer) clearTimeout(toastTimer);
toastTimer = setTimeout(() => {
toastVisible.value = false;
toastTimer = null;
}, 1800);
uni.navigateTo({ url: "/pages/records/r02-person-detail?mode=create" });
};
onUnmounted(() => {
@@ -186,37 +164,28 @@ onUnmounted(() => {
<style scoped lang="scss">
.people-page {
position: relative;
display: flex;
min-height: 100vh;
overflow-x: hidden;
flex-direction: column;
background: $paper;
}
.people-page__header,
.people-content {
position: relative;
z-index: 2;
z-index: 1;
}
.people-content {
flex: 1;
padding: 22rpx 24rpx 100rpx;
}
.people-search {
position: relative;
display: grid;
grid-template-columns: minmax(0, 1fr) 126rpx;
width: 100%;
height: 82rpx;
min-height: 44px;
align-items: center;
}
.people-search > image {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
pointer-events: none;
background: url("/static/assets/modules/records/transparent/r01-search-input-frame.png") center / 100% 100% no-repeat;
}
.people-search input {
position: relative;
z-index: 1;
width: 100%;
min-width: 0;
@@ -230,7 +199,6 @@ onUnmounted(() => {
color: #8a7965;
}
.people-search__action {
position: relative;
z-index: 2;
display: flex;
width: 126rpx;
@@ -246,7 +214,6 @@ onUnmounted(() => {
margin-top: 12px;
}
.person-card {
position: relative;
display: flex;
width: 100%;
height: clamp(92px, 190rpx, 108px);
@@ -254,20 +221,12 @@ onUnmounted(() => {
margin-top: 12px;
padding: 16% 10%;
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 {
margin-top: 0;
}
.person-card__skin {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
pointer-events: none;
}
.person-card__copy {
position: relative;
z-index: 1;
display: flex;
width: 100%;
flex-direction: column;
@@ -294,27 +253,12 @@ onUnmounted(() => {
}
.people-result-empty,
.people-state-card {
position: relative;
margin-top: 38rpx;
background: url("/static/assets/modules/records/transparent/r01-person-name-card.png") top center / 100% 220rpx no-repeat;
text-align: center;
}
.people-result-empty > image,
.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;
.people-state-card__copy {
display: flex;
height: 220rpx;
min-height: 118px;
flex-direction: column;
align-items: center;
+42 -22
View File
@@ -2,7 +2,7 @@
<template>
<view class="person-detail-page" :class="`person-detail-state--${personState}`">
<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">
<AppLoading text="正在读取人物档案" description="请稍候,正在整理人物资料。" />
@@ -11,7 +11,6 @@
<view v-else class="person-detail-content">
<template v-if="['detail', 'edit', 'privacy'].includes(personState)">
<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">
<text class="person-identity-card__name">{{ person.name }}</text>
<text class="person-identity-card__meta">{{ person.role }} · {{ person.generation }} </text>
@@ -22,23 +21,24 @@
<template v-if="personState === 'detail'">
<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>
<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>
</template>
<template v-else-if="personState === 'edit'">
<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>
<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>
</view>
<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>
<textarea v-model="draft[field.key]" :placeholder="`请输入${field.label}`" />
<textarea v-model="draft[field.key]" auto-height :placeholder="`请输入${field.label}`" />
</view>
<view class="person-edit-actions">
<view class="person-save-action" @click="savePerson"><AppButton block label="保存人物" /></view>
@@ -47,13 +47,11 @@
</template>
<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 class="person-state-action" @click="returnToPeople"><AppButton block label="返回人物录" /></view>
</view>
<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>
<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 errors = reactive({ name: "", role: "", generation: "" });
const personState = ref("loading");
const isCreateMode = ref(false);
const toastVisible = ref(false);
let toastTimer = null;
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.generation).trim()) errors.generation = "请填写世代";
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";
toastVisible.value = true;
if (toastTimer) clearTimeout(toastTimer);
@@ -108,7 +108,29 @@ const savePerson = () => {
};
const returnToPeople = () => uni.reLaunch({ url: "/pages/records/r01-people-list" });
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) => {
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 || ""));
if (selected) Object.assign(person, selected);
copyToDraft();
@@ -119,33 +141,31 @@ onUnmounted(() => { if (toastTimer) clearTimeout(toastTimer); });
</script>
<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-content { padding: 18rpx 24rpx 72rpx; }
.person-identity-card,.person-archive-card,.person-field,.person-long-field,.person-state-card { position: relative; }
.person-card-skin { position: absolute; inset: 0; z-index: 0; width: 100%; height: 100%; pointer-events: none; }
.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 { 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-identity-card__copy { display: flex; flex: 1; flex-direction: column; justify-content: center; }
.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__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-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: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-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-field__label { position: relative; z-index: 1; color: $ink; font-size: 23rpx; font-weight: 700; }
.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-error { position: relative; z-index: 1; grid-column: 1 / -1; display: block; color: $brand-red; font-size: 21rpx; text-align: right; }
.person-related-actions { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 14rpx; margin-top: 18rpx; }
.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__label { color: $ink; font-size: 23rpx; font-weight: 700; }
.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__label,.person-long-field textarea { position: relative; z-index: 1; }
.person-long-field textarea { width: 100%; height: 112rpx; min-height: 112rpx; margin-top: 14rpx; color: $ink; font-size: 23rpx; line-height: 1.5; }
.person-long-field textarea { width: 100%; 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-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: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; }
+191 -3
View File
@@ -1,5 +1,193 @@
<!-- 页面编号R-03用途贺礼列表 -->
<template><ModulePage page-id="r03" /></template>
<!-- 页面编号R-03用途贺礼簿列表空态失败与新增入口 -->
<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>
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>
<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用途贺礼新增详情与删除确认 -->
<template><ModulePage page-id="r04" /></template>
<!-- 页面编号R-04用途贺礼查看新增编辑保存与删除确认 -->
<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>
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>
<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用途礼仪活动列表 -->
<template><ModulePage page-id="r05" /></template>
<!-- 页面编号R-05用途礼仪活动列表状态与创建入口 -->
<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>
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>
<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用途礼仪详情 -->
<template><ModulePage page-id="r06" /></template>
<!-- 页面编号R-06用途礼仪详情参与者与受控状态 -->
<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>
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>
<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用途新建与编辑礼仪 -->
<template><ModulePage page-id="r07" /></template>
<!-- 页面编号R-07用途礼仪创建编辑校验保存与删除确认 -->
<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>
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>
<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用途成长日志 -->
<template><ModulePage page-id="r08" /></template>
<!-- 页面编号R-08用途人物成长日志时间轴与同页新增 -->
<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>
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>
<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用途生事 -->
<template><ModulePage page-id="r09" /></template>
<!-- 页面编号R-09用途物人生事时间轴与同页新增 -->
<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>
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>
<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用途家族备忘 -->
<template><ModulePage page-id="r10" /></template>
<!-- 页面编号R-10用途家族备忘列表完成状态与同页新增 -->
<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>
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>
<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用途功德记录 -->
<template><ModulePage page-id="r11" /></template>
<!-- 页面编号R-11用途功德记录贡献汇总与同页新增 -->
<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>
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>
<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>
+220 -157
View File
@@ -25,13 +25,20 @@
</view>
</view>
<view class="tree-stage">
<view v-if="treeState === 'tree'" class="generation-rail">
<view
class="tree-stage"
:class="{ 'tree-stage--lineage': treeState === 'tree' }"
>
<view
v-if="treeState === 'tree'"
class="generation-rail"
:style="generationRailStyle"
>
<view
v-for="row in generationRows"
:key="row.generation"
class="generation-band"
:style="generationRowStyle(row)"
:style="generationBandStyle(row)"
>
<image
src="/static/assets/modules/genealogy/transparent/section-divider.png"
@@ -50,10 +57,12 @@
scroll-x
:scroll-left="initialScrollLeft"
:show-scrollbar="false"
:style="treeScrollStyle"
>
<view
class="tree-canvas"
:class="{ 'tree-canvas--state': treeState !== 'tree' }"
:style="treeMetricsStyle"
>
<AppLoading
v-if="treeState === 'loading'"
@@ -66,24 +75,19 @@
<text>同代分支可左右查看</text>
</view>
<view class="lineage-connector lineage-connector--root" />
<view class="lineage-connector lineage-connector--root-branch" />
<view class="lineage-connector lineage-connector--gen13-left" />
<view class="lineage-connector lineage-connector--gen13-right" />
<view class="lineage-connector lineage-connector--left-trunk" />
<view class="lineage-connector lineage-connector--left-branch" />
<view class="lineage-connector lineage-connector--gen14-left" />
<view class="lineage-connector lineage-connector--gen14-middle" />
<view class="lineage-connector lineage-connector--right-trunk" />
<view class="lineage-connector lineage-connector--right-branch" />
<view class="lineage-connector lineage-connector--gen14-right" />
<view
v-for="connector in lineageConnectors"
:key="connector.id"
class="lineage-connector"
:style="connector.style"
/>
<view
v-for="member in members"
v-for="member in layoutMembers"
:key="member.id"
class="member-node"
:class="{ 'member-node--selected': selected?.id === member.id }"
:style="nodeStyle(member)"
:style="nodeGridStyle(member)"
@click="selected = member"
>
<image
@@ -95,11 +99,13 @@
"
mode="scaleToFill"
/>
<text class="node-name">{{ member.name }}</text>
<text class="node-relation"
>{{ member.relation }} · {{ member.branch }}</text
>
<text class="node-years">{{ member.years }}</text>
<view class="member-node__copy">
<text class="node-name">{{ member.name }}</text>
<text class="node-relation"
>{{ member.relation }} · {{ member.branch }}</text
>
<text class="node-years">{{ member.years }}</text>
</view>
</view>
</template>
@@ -171,11 +177,15 @@ const genealogyId = ref("");
const treeState = ref("loading");
const selected = ref(null);
const initialScrollLeft = ref(90);
const generationRows = [
{ generation: 12, label: "第十二世", summary: "始祖", y: 135 },
{ generation: 13, label: "第十三世", summary: "两房", y: 350 },
{ generation: 14, label: "第十四世", summary: "三支", y: 650 },
];
const GRID_UNIT = 5;
const NODE_HALF_HEIGHT = 47;
const MEMBER_GAP = 250;
const GENERATION_GAP = 220;
const generationMeta = {
12: { label: "第十二世", summary: "始祖" },
13: { label: "第十三世", summary: "两房" },
14: { label: "第十四世", summary: "三支" },
};
const members = ref([
{
id: 101,
@@ -184,8 +194,6 @@ const members = ref([
years: "1940—2012",
generation: 12,
branch: "主支",
x: 450,
y: 135,
},
{
id: 102,
@@ -193,9 +201,8 @@ const members = ref([
relation: "长子",
years: "1965—",
generation: 13,
parentId: 101,
branch: "长房",
x: 275,
y: 350,
},
{
id: 103,
@@ -203,9 +210,8 @@ const members = ref([
relation: "次子",
years: "1968—",
generation: 13,
parentId: 101,
branch: "二房",
x: 625,
y: 350,
},
{
id: 104,
@@ -213,9 +219,8 @@ const members = ref([
relation: "长孙",
years: "1992—",
generation: 14,
parentId: 102,
branch: "长房",
x: 150,
y: 650,
},
{
id: 105,
@@ -223,9 +228,8 @@ const members = ref([
relation: "长孙女",
years: "1995—",
generation: 14,
parentId: 102,
branch: "长房",
x: 400,
y: 650,
},
{
id: 106,
@@ -233,12 +237,132 @@ const members = ref([
relation: "次孙",
years: "1998—",
generation: 14,
parentId: 103,
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(
() =>
({
@@ -279,18 +403,15 @@ onLoad((query) => {
}
genealogyContext.setCurrentGenealogyId(genealogyId.value);
selected.value =
members.value.find(
layoutMembers.value.find(
(item) => String(item.id) === String(query.selectedId),
) || members.value[0];
) || layoutMembers.value[0];
treeState.value = "tree";
});
const nodeStyle = (member) => ({
left: `${member.x}rpx`,
top: `${member.y}rpx`,
});
const generationRowStyle = (row) => ({
top: `${row.y - 88}rpx`,
const nodeGridStyle = (member) => ({
gridColumn: `${member.x / 5 + 1}`,
gridRow: `${member.y / 5 + 1}`,
});
const handleStateAction = () => {
if (treeState.value === "empty") {
@@ -299,7 +420,7 @@ const handleStateAction = () => {
});
return;
}
selected.value = members.value[0];
selected.value = layoutMembers.value[0];
treeState.value = "tree";
};
const toMember = () =>
@@ -322,17 +443,16 @@ const toAddRelative = () =>
<style scoped lang="scss">
.tree-page {
position: relative;
display: grid;
height: 100vh;
grid-template-rows: auto auto minmax(0, 1fr);
overflow: hidden;
background: $paper;
}
.tree-page__header,
.tree-toolbar,
.tree-stage,
.member-sheet {
position: relative;
z-index: 2;
.tree-stage {
z-index: 1;
}
.tree-toolbar {
display: flex;
@@ -361,8 +481,13 @@ const toAddRelative = () =>
font-size: 23rpx;
}
.tree-stage {
position: relative;
height: calc(100vh - 272rpx);
min-height: 0;
overflow-y: auto;
}
.tree-stage--lineage {
display: grid;
grid-template-columns: 190rpx minmax(0, 1fr);
align-items: start;
}
.tree-scroll {
width: 100%;
@@ -370,40 +495,37 @@ const toAddRelative = () =>
white-space: nowrap;
}
.generation-rail {
position: absolute;
top: 0;
bottom: 0;
left: 0;
display: grid;
z-index: 4;
width: 190rpx;
height: 100%;
box-sizing: border-box;
border-right: 1rpx solid rgba(143, 108, 63, 0.2);
}
.tree-scroll--lineage {
width: calc(100% - 190rpx);
margin-left: 190rpx;
min-width: 0;
}
.tree-canvas {
position: relative;
width: 900rpx;
height: 900rpx;
display: grid;
margin: 0 16rpx;
}
.tree-canvas--state {
display: block;
width: calc(100vw - 32rpx);
}
.lineage-pan-cue {
position: absolute;
top: 8rpx;
right: 22rpx;
grid-area: 1 / 1 / -1 / -1;
align-self: start;
justify-self: end;
z-index: 3;
margin: 8rpx 22rpx 0 0;
color: #9a7748;
font-size: 20rpx;
letter-spacing: 1rpx;
}
.generation-band {
position: absolute;
left: 12rpx;
display: grid;
justify-self: center;
z-index: 3;
width: 166rpx;
height: 58rpx;
@@ -411,14 +533,15 @@ const toAddRelative = () =>
font-size: 22rpx;
text-align: center;
}
.generation-band image,
.generation-band__copy {
grid-area: 1 / 1;
}
.generation-band image {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
}
.generation-band__copy {
position: relative;
z-index: 1;
display: flex;
flex-direction: column;
@@ -431,78 +554,15 @@ const toAddRelative = () =>
font-size: 19rpx;
}
.lineage-connector {
position: absolute;
align-self: stretch;
justify-self: stretch;
z-index: 1;
background: #b78a42;
}
.lineage-connector--root {
left: 449rpx;
top: 177rpx;
width: 2rpx;
height: 88rpx;
}
.lineage-connector--root-branch {
left: 275rpx;
top: 264rpx;
width: 350rpx;
height: 2rpx;
}
.lineage-connector--gen13-left {
left: 274rpx;
top: 264rpx;
width: 2rpx;
height: 44rpx;
}
.lineage-connector--gen13-right {
left: 624rpx;
top: 264rpx;
width: 2rpx;
height: 44rpx;
}
.lineage-connector--left-trunk {
left: 274rpx;
top: 392rpx;
width: 2rpx;
height: 149rpx;
}
.lineage-connector--left-branch {
left: 150rpx;
top: 540rpx;
width: 250rpx;
height: 2rpx;
}
.lineage-connector--gen14-left {
left: 149rpx;
top: 540rpx;
width: 2rpx;
height: 68rpx;
}
.lineage-connector--gen14-middle {
left: 399rpx;
top: 540rpx;
width: 2rpx;
height: 68rpx;
}
.lineage-connector--right-trunk {
left: 624rpx;
top: 392rpx;
width: 2rpx;
height: 149rpx;
}
.lineage-connector--right-branch {
left: 625rpx;
top: 540rpx;
width: 65rpx;
height: 2rpx;
}
.lineage-connector--gen14-right {
left: 689rpx;
top: 540rpx;
width: 2rpx;
height: 68rpx;
}
.member-node {
position: absolute;
display: grid;
align-self: start;
justify-self: start;
z-index: 2;
width: 224rpx;
height: 86rpx;
@@ -510,16 +570,19 @@ const toAddRelative = () =>
text-align: center;
}
.member-node__skin {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
}
.member-node__skin,
.member-node__copy {
grid-area: 1 / 1;
}
.member-node__copy {
z-index: 1;
}
.node-name,
.node-relation,
.node-years {
position: relative;
z-index: 1;
display: block;
}
.node-name {
@@ -547,22 +610,23 @@ const toAddRelative = () =>
color: $brand-red;
}
.tree-state-card {
position: relative;
display: grid;
z-index: 2;
width: calc(100% - 48rpx);
height: 360rpx;
min-height: 360rpx;
margin: 150rpx 24rpx 0;
text-align: center;
white-space: normal;
}
.tree-state-card__skin {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
}
.tree-state-card__skin,
.tree-state-card__content {
grid-area: 1 / 1;
}
.tree-state-card__content {
position: relative;
z-index: 1;
padding: 62rpx 58rpx 42rpx;
}
@@ -588,19 +652,20 @@ const toAddRelative = () =>
line-height: 1.65;
}
.tree-state-card__action {
position: relative;
display: grid;
width: 360rpx;
height: 70rpx;
min-height: 70rpx;
margin: 22rpx auto 0;
}
.tree-state-card__action image {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
}
.tree-state-card__action image,
.tree-state-card__action text {
grid-area: 1 / 1;
}
.tree-state-card__action text {
position: relative;
z-index: 1;
display: flex;
align-items: center;
@@ -616,7 +681,7 @@ const toAddRelative = () =>
bottom: calc(14rpx + env(safe-area-inset-bottom));
left: 18rpx;
display: flex;
height: 240rpx;
min-height: 240rpx;
flex-direction: column;
padding: 38rpx 44rpx 24rpx;
box-sizing: border-box;
@@ -629,7 +694,6 @@ const toAddRelative = () =>
pointer-events: none;
}
.member-sheet__copy {
position: relative;
z-index: 1;
}
.sheet-name {
@@ -646,7 +710,6 @@ const toAddRelative = () =>
font-size: 22rpx;
}
.sheet-actions {
position: relative;
z-index: 1;
display: flex;
justify-content: center;
@@ -667,7 +730,7 @@ const toAddRelative = () =>
gap: 14rpx;
}
.member-sheet {
height: 240rpx;
min-height: 240rpx;
padding-top: 32rpx;
padding-right: 36rpx;
padding-left: 36rpx;
+123 -239
View File
@@ -1,4 +1,4 @@
<!-- 页面编号T-03用途成员档案详情与失败状态页面设计阶段使用本地模拟数据 -->
<!-- 页面编号T-03用途 personId 展示成员档案并进入其真实成员状态 -->
<template>
<view
class="member-page"
@@ -8,73 +8,57 @@
}"
>
<ModulePageBackground module="tree" />
<view class="member-page__header"
><PageHeader
title="成员档案"
:action="memberState === 'detail' ? '编辑' : ''"
@action="toEdit"
/></view>
<view class="member-page__header">
<PageHeader title="成员档案" :action="memberState === 'detail' && canEdit ? '编辑' : ''" @action="toEdit" />
</view>
<view class="member-context">
<text>{{ genealogyName }}</text>
<text>{{ memberState === "detail" ? "成员身份与亲属关系" : "请重新选择成员" }}</text>
</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">
<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"
><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 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>
<view v-for="item in details" :key="item.label" class="member-info-row">
<image
src="/static/assets/modules/tree/transparent/t07-search-input-frame.png"
mode="scaleToFill"
/><text>{{ item.label }}</text
><text>{{ item.value }}</text>
<text>{{ item.label }}</text><text>{{ item.value || "未填写" }}</text>
</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">
<text>长子 · 汤正国</text><text>次子 · 汤正华</text>
</view>
<view class="member-profile-action" @click="toEdit">
<image
src="/static/assets/foundation/transparent/a01-scroll-primary-v3.png"
mode="aspectFit"
/><text>完善成员档案</text>
<view v-for="relative in member.relatives" :key="relative.id" @click="openRelative(relative)">
<text>{{ relative.relation }}</text><text>{{ relative.name }}</text>
</view>
<text v-if="!member.relatives.length">尚未记录可查看的亲属</text>
</view>
<view v-if="canEdit" class="member-profile-action" @click="toEdit"><text>完善成员档案</text></view>
</view>
<view v-else class="member-error">
<text>成员档案暂不可用</text
><text>请从世系树重新选择成员当前没有可展示的个人资料</text>
<view class="member-profile-action" @click="toTree"
><image
src="/static/assets/foundation/transparent/a01-scroll-primary-v3.png"
mode="aspectFit"
/><text>返回世系树</text></view
>
<text>成员档案暂不可用</text>
<text>{{ errorMessage }}</text>
<view class="member-profile-action" @click="toTree"><text>返回世系树</text></view>
</view>
</view>
</view>
</template>
<script setup>
import { computed, ref } from "vue";
import { onLoad } from "@dcloudio/uni-app";
@@ -82,203 +66,103 @@ import AppLoading from "@/components/AppLoading.vue";
import ModulePageBackground from "@/components/ModulePageBackground.vue";
import PageHeader from "@/components/PageHeader.vue";
import { genealogyContext } from "@/utils/genealogy-context.js";
const genealogyId = ref("");
const personId = ref("");
const memberState = ref("loading");
const member = ref({
id: 101,
name: "汤文远",
generation: 12,
relation: "始祖",
branch: "主支",
generationName: "文字辈",
birthDate: "1940年3月",
years: "1940—2012",
});
const details = computed(() => [
const member = ref(null);
const errorMessage = ref("");
const genealogyName = ref("汤氏家谱");
const memberFixtures = {
101: {
id: 101, name: "汤文远", generation: 12, relation: "始祖", branch: "主支", generationName: "文字辈",
birthDate: "1940年3月", years: "1940—2012", birthplace: "河南南阳", status: "deceased", canEdit: true,
relatives: [{ id: 102, name: "汤正国", relation: "长子" }, { id: 103, name: "汤正华", relation: "次子" }],
},
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.birthDate },
{ label: "出生日期", value: member.value.status === "forbidden" ? "按权限隐藏" : member.value.birthDate },
{ label: "生卒信息", value: member.value.years },
{ label: "祖居地", value: member.value.status === "forbidden" ? "按权限隐藏" : member.value.birthplace },
{ 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) => {
genealogyId.value =
query.genealogyId || genealogyContext.getCurrentGenealogyId() || "";
genealogyId.value = query.genealogyId || genealogyContext.getCurrentGenealogyId() || "";
personId.value = query.personId || "";
if (genealogyId.value)
genealogyContext.setCurrentGenealogyId(genealogyId.value);
memberState.value =
query.state === "loading"
? "loading"
: query.state === "error" || !personId.value
? "error"
: "detail";
if (genealogyId.value) genealogyContext.setCurrentGenealogyId(genealogyId.value);
if (query.state === "loading") { memberState.value = "loading"; return; }
if (query.state === "error" || !personId.value || !memberFixtures[personId.value]) {
memberState.value = "error";
errorMessage.value = !personId.value ? "没有指定成员,请从世系树重新选择。" : "这位成员不存在或已不属于当前家谱。";
return;
}
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({
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();
</script>
<style scoped lang="scss">
.member-page {
position: relative;
min-height: 100vh;
overflow: hidden;
background: $paper;
}
.member-page__header {
position: relative;
z-index: 3;
}
.member-panel {
position: relative;
z-index: 2;
width: calc(100% - 32rpx);
height: min(650px, calc((100vw - 16px) * 1.5));
margin: 18rpx auto 0;
}
.member-panel__skin {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
}
.member-detail {
position: absolute;
inset: 7% 8%;
}
.member-heading {
display: flex;
align-items: center;
gap: 20rpx;
}
.member-heading__seal {
position: relative;
display: flex;
width: 84rpx;
height: 108rpx;
align-items: center;
justify-content: center;
}
.member-heading__seal image {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
}
.member-heading__seal text {
position: relative;
z-index: 1;
color: #fff8ec;
font-family: "STKaiti", "KaiTi", serif;
font-size: 33rpx;
}
.member-heading > view:last-child text {
display: block;
}
.member-heading > view:last-child text:first-child {
color: $ink;
font-family: "STKaiti", "KaiTi", serif;
font-size: 38rpx;
font-weight: 700;
}
.member-heading > view:last-child text:last-child {
margin-top: 8rpx;
color: $ink-muted;
font-size: 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);
}
}
.member-page { display: flex; min-height: 100vh; flex-direction: column; padding-bottom: 28rpx; box-sizing: border-box; background: $paper; }
.member-page__header, .member-context, .member-panel { z-index: 2; }
.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; }
.member-context text { display: block; color: $ink-muted; font-size: 23rpx; line-height: 1.45; }
.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-panel > .app-loading { margin-top: 30%; }
.member-heading { display: flex; align-items: center; gap: 20rpx; }
.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-heading > view:last-child text { display: block; color: $ink-muted; font-size: 23rpx; line-height: 1.45; }
.member-heading > view:last-child text:last-child { font-size: 23rpx; }
.member-heading > view:last-child text:first-child { color: $ink; font-family: "STKaiti", "KaiTi", serif; font-size: 36rpx; font-weight: 700; }
.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; }
.member-status-entry text { color: $ink-muted; font-size: 22rpx; line-height: 1.4; }
.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-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; }
.member-info-row text { color: $ink-muted; font-size: 23rpx; line-height: 1.45; }
.member-info-row text:last-child { color: $ink; text-align: right; }
.member-relatives { margin-top: 8rpx; font-size: 23rpx; }
.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-relatives > text { display: block; color: $ink-muted; font-size: 23rpx; line-height: 1.5; }
.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; }
.member-profile-action text { color: #fff9ed; font-size: 24rpx; font-weight: 700; }
.member-error { margin-top: 30%; text-align: center; }
.member-error > text { display: block; color: $ink-muted; font-size: 24rpx; line-height: 1.55; }
.member-error > text:nth-child(2) { font-size: 24rpx; }
.member-error > text:first-child { color: $ink; font-family: "STKaiti", "KaiTi", serif; font-size: 34rpx; font-weight: 700; }
@media (min-width: 400px) { .member-context, .member-panel { width: calc(100% - 48rpx); } }
</style>
+242 -3
View File
@@ -1,5 +1,244 @@
<!-- 页面编号T-04用途新增直系亲属与保存结果 -->
<template><TreeMemberForm kind="add" /></template>
<!-- 页面编号T-04用途录入首位成员或为指定成员新增亲属 -->
<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>
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>
<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用途编辑成员资料与保存结果 -->
<template><TreeMemberForm kind="edit" /></template>
<!-- 页面编号T-05用途维护指定成员的身份与生平资料 -->
<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>
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>
<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用途编辑亲属关系冲突提示与处理 -->
<template><TreeMemberForm kind="relation" /></template>
<!-- 页面编号T-06用途选择两位现有成员并校正其家族关系 -->
<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>
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>
<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'"
class="directory-search"
>
<image
class="directory-search__frame"
src="/static/assets/modules/tree/transparent/t07-search-input-frame.png"
mode="scaleToFill"
/>
<input
v-model="keyword"
aria-label="成员搜索关键词"
@@ -59,11 +54,6 @@
class="directory-card"
@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">
<text class="directory-card__name">{{ item.name }}</text>
<text class="directory-card__meta"
@@ -75,10 +65,6 @@
</view>
</template>
<view v-else class="directory-state-card">
<image
src="/static/assets/modules/genealogy/transparent/list-slip-frame.png"
mode="scaleToFill"
/>
<view
><text>{{
directoryState === "empty" ? "没有找到相关成员" : "成员目录暂不可用"
@@ -167,9 +153,9 @@ const openMember = (item) =>
</script>
<style scoped lang="scss">
.directory-page {
position: relative;
display: flex;
min-height: 100vh;
overflow-x: hidden;
flex-direction: column;
overflow-y: auto;
background: $paper;
}
@@ -177,7 +163,6 @@ const openMember = (item) =>
.directory-context,
.directory-search,
.directory-content {
position: relative;
z-index: 2;
}
.directory-context {
@@ -199,39 +184,33 @@ const openMember = (item) =>
font-weight: 500;
}
.directory-search {
display: grid;
width: calc(100% - 48rpx);
height: 44px;
min-height: 44px;
margin: 20rpx auto 0;
}
.directory-search__frame {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
pointer-events: none;
background: url("/static/assets/modules/tree/transparent/t07-search-input-frame.png")
center / 100% 100% no-repeat;
}
.directory-search input {
position: absolute;
top: 0;
right: 110rpx;
bottom: 0;
left: 26rpx;
z-index: 1;
height: 44px;
grid-area: 1 / 1;
min-height: 44px;
margin-right: 110rpx;
margin-left: 26rpx;
color: $ink;
font-size: 26rpx;
line-height: 44px;
}
.directory-search__action {
position: absolute;
top: 0;
right: 8rpx;
z-index: 1;
display: flex;
grid-area: 1 / 1;
justify-self: end;
min-width: 88rpx;
min-height: 44px;
align-items: center;
justify-content: center;
margin-right: 8rpx;
color: $brand-red;
font-size: 24rpx;
font-weight: 700;
@@ -258,25 +237,17 @@ const openMember = (item) =>
font-size: 23rpx;
}
.directory-card {
position: relative;
display: flex;
width: 100%;
height: 196rpx;
min-height: 196rpx;
align-items: center;
box-sizing: border-box;
margin-bottom: 16rpx;
padding: 18rpx 28rpx;
}
.directory-card__frame {
position: absolute;
inset: 0;
z-index: 0;
width: 100%;
height: 100%;
pointer-events: none;
background: url("/static/assets/modules/genealogy/transparent/list-slip-frame.png")
center / 100% 100% no-repeat;
}
.directory-card__copy {
position: relative;
z-index: 1;
display: flex;
min-width: 0;
@@ -285,21 +256,19 @@ const openMember = (item) =>
justify-content: center;
}
.directory-card__name {
overflow: hidden;
color: $ink;
font-family: "STKaiti", "KaiTi", serif;
font-size: 31rpx;
font-weight: 700;
text-overflow: ellipsis;
white-space: nowrap;
line-height: 1.3;
overflow-wrap: anywhere;
}
.directory-card__meta {
overflow: hidden;
margin-top: 7rpx;
color: #62584c;
font-size: 24rpx;
text-overflow: ellipsis;
white-space: nowrap;
line-height: 1.45;
overflow-wrap: anywhere;
}
.directory-card__status {
align-self: flex-start;
@@ -311,23 +280,21 @@ const openMember = (item) =>
line-height: 30rpx;
}
.directory-state-card {
position: relative;
display: flex;
width: 100%;
height: 220rpx;
min-height: 220rpx;
align-items: center;
justify-content: center;
margin-bottom: 16rpx;
}
.directory-state-card > image {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
padding: 48rpx 12%;
box-sizing: border-box;
background: url("/static/assets/modules/genealogy/transparent/list-slip-frame.png")
center / 100% 100% no-repeat;
}
.directory-state-card {
margin-top: 70rpx;
}
.directory-state-card > view {
position: absolute;
inset: 24% 12%;
z-index: 1;
text-align: center;
}
@@ -351,7 +318,7 @@ const openMember = (item) =>
}
@media screen and (max-width: 340px) {
.directory-card {
height: 192rpx;
min-height: 192rpx;
padding-right: 22rpx;
padding-left: 22rpx;
}
+87 -178
View File
@@ -1,4 +1,4 @@
<!-- 页面编号T-08用途成员隐私离世纪念无权限状态说明 -->
<!-- 页面编号T-08用途展示指定成员隐私纪念无权限状态 -->
<template>
<view
class="member-status-page"
@@ -6,220 +6,129 @@
'member-status--privacy': statusState === 'privacy',
'member-status--deceased': statusState === 'deceased',
'member-status--forbidden': statusState === 'forbidden',
'member-status--error': statusState === 'error',
}"
>
<ModulePageBackground module="tree" />
<view class="member-status-page__header"
><PageHeader title="成员状态"
/></view>
<view class="status-tabs">
<view
v-for="item in tabs"
:key="item.value"
class="status-tab"
@click="statusState = item.value"
>
<image
:src="
statusState === item.value
? '/static/assets/foundation/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 class="member-status-page__header"><PageHeader :title="pageTitle" /></view>
<view class="member-status-context">
<text>{{ genealogyName }}</text>
<text v-if="member">{{ member.name }} · {{ member.generation }} · {{ member.branch }}</text>
<text v-else>未找到成员身份</text>
</view>
<view class="status-card">
<image
src="/static/assets/modules/tree/transparent/t01-state-panel.png"
mode="scaleToFill"
/>
<view class="status-card__copy">
<text>{{ activeStatus.eyebrow }}</text
><text>{{ activeStatus.title }}</text
><text>{{ activeStatus.copy }}</text>
<text>{{ activeStatus.eyebrow }}</text>
<text>{{ activeStatus.title }}</text>
<text>{{ activeStatus.copy }}</text>
</view>
</view>
<view class="status-guidance">
<image
src="/static/assets/modules/tree/transparent/t01-state-panel.png"
mode="scaleToFill"
/>
<view>
<text>{{ activeStatus.guideTitle }}</text>
<text v-for="line in activeStatus.guides" :key="line">{{ line }}</text>
</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>
</template>
<script setup>
import { computed, ref } from "vue";
import { onLoad } from "@dcloudio/uni-app";
import ModulePageBackground from "@/components/ModulePageBackground.vue";
import PageHeader from "@/components/PageHeader.vue";
const statusState = ref("privacy");
const tabs = [
{ value: "privacy", label: "隐私" },
{ value: "deceased", label: "纪念" },
{ value: "forbidden", label: "无权限" },
];
import { genealogyContext } from "@/utils/genealogy-context.js";
const genealogyId = ref("");
const personId = ref("");
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 = {
privacy: {
eyebrow: "隐私成员",
title: "敏感资料只向授权成员展示",
copy: "姓名可保留在世系位置,出生信息、联系方式和生平内容根据权限隐藏。",
guideTitle: "隐私展示原则",
guides: [
"本人可以查看和维护自己的完整档案",
"谱主可协助处理身份与世系关系",
"普通成员只看到被允许公开的内容",
],
copy: "该成员姓名和世系位置仍然保留,出生信息、联系方式和生平内容权限隐藏。",
guideTitle: "当前可见范围",
guides: ["姓名、世代与所属支系可见", "联系方式和详细生平已隐藏", "本人或谱主可维护授权范围"],
action: "返回成员档案",
},
deceased: {
eyebrow: "离世成员",
title: "在世系中保留温和的纪念状态",
copy: "离世不会删除成员关系;档案可继续记录生平、影像和家人的追思内容。",
eyebrow: "离世纪念",
title: "这位家人的生命记录被温和保留",
copy: "离世状态不会删除成员关系;有权限的家人仍可共同维护生卒年月、生平和追思资料。",
guideTitle: "纪念资料范围",
guides: [
"生卒年月与世系关系继续保留",
"生平内容由有权限家人共同维护",
"敏感资料仍遵守原有隐私设置",
],
guides: ["生卒年月与世系关系继续保留", "生平内容由有权限家人维护", "敏感资料继续遵守原有隐私设置"],
action: "返回成员档案",
},
forbidden: {
eyebrow: "访问受限",
title: "当前账号没有查看该档案的权限",
copy: "为保护家人隐私,页面不会展示被隐藏字段,也不会提供绕过权限的入口。",
copy: "页面不会展示被隐藏字段,也不会提供绕过家谱权限的入口。",
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) => {
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>
<style scoped lang="scss">
.member-status-page {
position: relative;
min-height: 100vh;
overflow: hidden;
background: $paper;
}
.member-status-page__header,
.status-tabs,
.status-card,
.status-guidance {
position: relative;
z-index: 2;
}
.status-tabs {
display: flex;
gap: 10rpx;
padding: 22rpx 24rpx 0;
}
.status-tab {
position: relative;
width: calc(33.333% - 7rpx);
height: 62rpx;
}
.status-tab image {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
}
.status-tab text {
position: relative;
z-index: 1;
display: flex;
align-items: center;
justify-content: center;
height: 100%;
color: $ink;
font-size: 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;
}
.member-status-page { display: flex; min-height: 100vh; flex-direction: column; padding-bottom: 34rpx; box-sizing: border-box; background: $paper; }
.member-status-page__header, .member-status-context, .status-card, .status-guidance, .status-action { z-index: 2; }
.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; }
.member-status-context text { display: block; color: $ink-muted; font-size: 23rpx; line-height: 1.45; }
.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; }
.status-card__copy text { display: block; color: $ink-muted; font-size: 24rpx; line-height: 1.55; text-align: center; }
.status-card__copy text:first-child { color: $brand-red; font-size: 22rpx; letter-spacing: 3rpx; }
.status-card__copy text:nth-child(2) { margin-top: 12rpx; color: $ink; font-family: "STKaiti", "KaiTi", serif; font-size: 32rpx; font-weight: 700; }
.status-card__copy text:last-child { margin-top: 14rpx; font-size: 24rpx; }
.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; }
.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-action { display: grid; width: calc(100% - 72rpx); min-height: 76rpx; margin: 22rpx auto 0; }
.status-action image, .status-action text { grid-area: 1 / 1; width: 100%; height: 100%; }
.status-action text { z-index: 1; display: flex; align-items: center; justify-content: center; color: #fff9ed; font-size: 24rpx; font-weight: 700; }
@media (min-width: 400px) { .member-status-context, .status-card { width: calc(100% - 48rpx); } }
</style>