修改未完成
This commit is contained in:
@@ -18,18 +18,32 @@
|
||||
|
||||
<view
|
||||
class="overview-surface"
|
||||
:class="{ 'overview-surface--state': overviewState !== 'ready' }"
|
||||
:class="{
|
||||
'overview-surface--state': overviewState !== 'ready',
|
||||
'overview-surface--member':
|
||||
overviewState === 'ready' && viewMode === 'member',
|
||||
}"
|
||||
>
|
||||
<template
|
||||
v-if="overviewState === 'ready' && genealogy && viewMode === 'member'"
|
||||
>
|
||||
<view class="overview-ready">
|
||||
<view class="overview-hero">
|
||||
<text class="overview-hero__kicker"
|
||||
>{{ genealogy.hall || "堂号待补" }} ·
|
||||
{{ genealogy.location || "所在地待补" }}</text
|
||||
>
|
||||
<text class="overview-hero__title">{{ genealogy.name }}</text>
|
||||
<view class="overview-hero__identity">
|
||||
<view class="overview-hero__emblem">
|
||||
<image
|
||||
src="/static/assets/modules/genealogy/transparent/shortcut-tree.png"
|
||||
mode="aspectFit"
|
||||
/>
|
||||
</view>
|
||||
<view class="overview-hero__headings">
|
||||
<text class="overview-hero__title">{{ genealogy.name }}</text>
|
||||
<text class="overview-hero__kicker"
|
||||
>{{ genealogy.hall || "堂号待补" }} ·
|
||||
{{ genealogy.location || "所在地待补" }}</text
|
||||
>
|
||||
</view>
|
||||
</view>
|
||||
<text class="overview-hero__motto">{{
|
||||
genealogy.intro || "简介待补充"
|
||||
}}</text>
|
||||
@@ -41,53 +55,97 @@
|
||||
<text>{{
|
||||
getGenealogyAccessPresetLabel(genealogy.accessPreset)
|
||||
}}</text>
|
||||
</view>
|
||||
<view class="overview-hero__stats">
|
||||
<text v-if="genealogy.joinTime"
|
||||
>加入于 {{ formatGenealogyTime(genealogy.joinTime) }}</text
|
||||
>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view
|
||||
class="overview-actions"
|
||||
:class="{ 'overview-actions--member': accessRole === 'member' }"
|
||||
>
|
||||
<view class="overview-actions">
|
||||
<view class="overview-action overview-action--tree" @click="toTree">
|
||||
<text class="overview-action__title">世系树</text
|
||||
><text>查看家脉关系</text>
|
||||
</view>
|
||||
<view
|
||||
class="overview-action overview-action--poem"
|
||||
@click="toGenerationPoems"
|
||||
>
|
||||
<text class="overview-action__title">字辈谱</text
|
||||
><text>传承行辈次序</text>
|
||||
<view class="overview-action__icon overview-action__icon--primary">
|
||||
<image
|
||||
src="/static/assets/modules/genealogy/transparent/shortcut-tree.png"
|
||||
mode="aspectFit"
|
||||
/>
|
||||
</view>
|
||||
<view class="overview-action__copy">
|
||||
<text class="overview-action__title">世系树</text>
|
||||
<text>查看家脉关系</text>
|
||||
</view>
|
||||
<image
|
||||
class="overview-action__chevron"
|
||||
src="/static/assets/foundation/transparent/chevron-right.png"
|
||||
mode="aspectFit"
|
||||
/>
|
||||
</view>
|
||||
<view
|
||||
v-if="accessRole === 'owner'"
|
||||
class="overview-action overview-action--review"
|
||||
@click="toApplications"
|
||||
>
|
||||
<text class="overview-action__title">入谱审核</text
|
||||
><text>处理加入申请</text>
|
||||
<image
|
||||
class="overview-action__icon"
|
||||
src="/static/assets/modules/genealogy/transparent/shortcut-application.png"
|
||||
mode="aspectFit"
|
||||
/>
|
||||
<view class="overview-action__copy">
|
||||
<text class="overview-action__title">入谱审核</text>
|
||||
<text>处理加入申请</text>
|
||||
</view>
|
||||
<image
|
||||
class="overview-action__chevron"
|
||||
src="/static/assets/foundation/transparent/chevron-right.png"
|
||||
mode="aspectFit"
|
||||
/>
|
||||
</view>
|
||||
<view
|
||||
class="overview-action overview-action--poem"
|
||||
@click="toGenerationPoems"
|
||||
>
|
||||
<image
|
||||
class="overview-action__icon"
|
||||
src="/static/assets/foundation/transparent/tab-genealogy.png"
|
||||
mode="aspectFit"
|
||||
/>
|
||||
<view class="overview-action__copy">
|
||||
<text class="overview-action__title">字辈谱</text>
|
||||
<text>传承行辈次序</text>
|
||||
</view>
|
||||
<image
|
||||
class="overview-action__chevron"
|
||||
src="/static/assets/foundation/transparent/chevron-right.png"
|
||||
mode="aspectFit"
|
||||
/>
|
||||
</view>
|
||||
<view
|
||||
v-if="accessRole === 'owner'"
|
||||
class="overview-action overview-action--settings"
|
||||
@click="toSettings"
|
||||
>
|
||||
<text class="overview-action__title">家谱设置</text
|
||||
><text>维护公开范围与基础资料</text>
|
||||
<image
|
||||
class="overview-action__icon"
|
||||
src="/static/assets/modules/genealogy/transparent/g05-settings-gear.png"
|
||||
mode="aspectFit"
|
||||
/>
|
||||
<view class="overview-action__copy">
|
||||
<text class="overview-action__title">家谱设置</text>
|
||||
<text>维护公开范围与基础资料</text>
|
||||
</view>
|
||||
<image
|
||||
class="overview-action__chevron"
|
||||
src="/static/assets/foundation/transparent/chevron-right.png"
|
||||
mode="aspectFit"
|
||||
/>
|
||||
</view>
|
||||
<template v-else>
|
||||
<view class="overview-summary">
|
||||
<text class="overview-action__title">成员身份</text
|
||||
><text>已加入 · 普通成员</text>
|
||||
<text class="overview-action__title">成员身份</text>
|
||||
<text>已加入 · 普通成员</text>
|
||||
</view>
|
||||
<view class="overview-summary">
|
||||
<text class="overview-action__title">访问范围</text
|
||||
><text>{{
|
||||
<text class="overview-action__title">访问范围</text>
|
||||
<text>{{
|
||||
getGenealogyAccessPresetLabel(genealogy.accessPreset)
|
||||
}}</text>
|
||||
</view>
|
||||
@@ -95,8 +153,14 @@
|
||||
</view>
|
||||
|
||||
<view class="overview-family" @click="toFamily">
|
||||
<text>家族近况</text><text>进入家族圈查看动态</text
|
||||
><text>查看</text>
|
||||
<view>
|
||||
<text>家族近况</text>
|
||||
<text>进入家族圈查看动态</text>
|
||||
</view>
|
||||
<image
|
||||
src="/static/assets/foundation/transparent/chevron-right.png"
|
||||
mode="aspectFit"
|
||||
/>
|
||||
</view>
|
||||
<view class="overview-note">
|
||||
<text class="overview-note__title">成员资料按家谱访问规则保护</text>
|
||||
@@ -388,6 +452,12 @@ const applyToJoin = () => {
|
||||
min-height: min(483px, calc(100vw * 1.3422));
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
.overview-surface--member {
|
||||
min-height: 100%;
|
||||
border: 0;
|
||||
border-image: none;
|
||||
background: #fbf4e7;
|
||||
}
|
||||
.overview-surface--state {
|
||||
display: flex;
|
||||
height: calc(100vh - 112rpx);
|
||||
@@ -398,35 +468,64 @@ const applyToJoin = () => {
|
||||
}
|
||||
.overview-ready {
|
||||
display: flex;
|
||||
min-height: min(540px, calc(100vw * 1.5));
|
||||
flex-direction: column;
|
||||
gap: 34rpx;
|
||||
padding: 10rpx 0 42rpx;
|
||||
min-height: 0;
|
||||
padding: 0 0 calc(34rpx + env(safe-area-inset-bottom));
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.overview-hero {
|
||||
margin: 0 8%;
|
||||
display: flex;
|
||||
min-height: 286rpx;
|
||||
flex-direction: column;
|
||||
justify-content: flex-end;
|
||||
margin: 0;
|
||||
padding: 34rpx 42rpx 28rpx;
|
||||
background: #10293b
|
||||
url("/static/assets/modules/genealogy/opaque/g05-overview-surface.png")
|
||||
center top / auto 1220rpx no-repeat;
|
||||
color: #fff8ec;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.overview-hero__identity {
|
||||
display: grid;
|
||||
grid-template-columns: 108rpx minmax(0, 1fr);
|
||||
align-items: center;
|
||||
gap: 22rpx;
|
||||
}
|
||||
.overview-hero__emblem {
|
||||
display: grid;
|
||||
width: 108rpx;
|
||||
height: 108rpx;
|
||||
place-items: center;
|
||||
border: 2rpx solid rgba(225, 190, 111, 0.86);
|
||||
border-radius: 50%;
|
||||
background: rgba(16, 41, 59, 0.56);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.overview-hero__emblem image {
|
||||
width: 64rpx;
|
||||
height: 64rpx;
|
||||
}
|
||||
.overview-hero__kicker {
|
||||
display: block;
|
||||
color: #dec483;
|
||||
font-size: 22rpx;
|
||||
letter-spacing: 3rpx;
|
||||
font-size: 20rpx;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.overview-hero__title {
|
||||
display: block;
|
||||
margin-top: 12rpx;
|
||||
margin-bottom: 6rpx;
|
||||
font-family: "STKaiti", "KaiTi", serif;
|
||||
font-size: 46rpx;
|
||||
font-size: 42rpx;
|
||||
font-weight: 700;
|
||||
letter-spacing: 4rpx;
|
||||
letter-spacing: 2rpx;
|
||||
}
|
||||
.overview-hero__motto {
|
||||
display: block;
|
||||
margin-top: 6rpx;
|
||||
height: 66rpx;
|
||||
margin-top: 22rpx;
|
||||
color: #e9dcc1;
|
||||
font-size: 23rpx;
|
||||
font-size: 21rpx;
|
||||
line-height: 1.55;
|
||||
overflow: hidden;
|
||||
display: -webkit-box;
|
||||
@@ -434,79 +533,138 @@ const applyToJoin = () => {
|
||||
-webkit-line-clamp: 2;
|
||||
}
|
||||
.overview-hero__stats {
|
||||
display: inline-flex;
|
||||
gap: 18rpx;
|
||||
margin-top: 12rpx;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8rpx 0;
|
||||
margin-top: 18rpx;
|
||||
color: #dbc990;
|
||||
font-size: 22rpx;
|
||||
transform: translateY(-18rpx);
|
||||
font-size: 19rpx;
|
||||
line-height: 1.4;
|
||||
}
|
||||
.overview-hero__stats + .overview-hero__stats {
|
||||
margin-left: 18rpx;
|
||||
.overview-hero__stats text {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
.overview-hero__stats text + text::before {
|
||||
width: 1rpx;
|
||||
height: 20rpx;
|
||||
margin: 0 14rpx;
|
||||
background: rgba(220, 201, 144, 0.55);
|
||||
content: "";
|
||||
}
|
||||
.overview-actions {
|
||||
display: grid;
|
||||
min-height: 386rpx;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
grid-template-rows: auto auto;
|
||||
gap: 3.4% 3%;
|
||||
margin: 0 7.2%;
|
||||
}
|
||||
.overview-actions--member {
|
||||
min-height: 386rpx;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 28rpx 42rpx 0;
|
||||
background: #fbf4e7;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.overview-action,
|
||||
.overview-summary {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
display: grid;
|
||||
grid-template-columns: 72rpx minmax(0, 1fr) 28rpx;
|
||||
min-height: 126rpx;
|
||||
align-items: center;
|
||||
gap: 24rpx;
|
||||
min-width: 0;
|
||||
padding: 24rpx 22rpx;
|
||||
padding: 18rpx 0;
|
||||
border-top: 1rpx solid rgba(152, 119, 72, 0.28);
|
||||
box-sizing: border-box;
|
||||
color: $ink-muted;
|
||||
font-size: 22rpx;
|
||||
font-size: 21rpx;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.overview-action--tree {
|
||||
min-height: 176rpx;
|
||||
grid-template-columns: 128rpx minmax(0, 1fr) 28rpx;
|
||||
gap: 28rpx;
|
||||
border-top: 0;
|
||||
}
|
||||
.overview-action__icon {
|
||||
width: 64rpx;
|
||||
height: 64rpx;
|
||||
justify-self: center;
|
||||
object-fit: contain;
|
||||
}
|
||||
.overview-action__icon--primary {
|
||||
display: grid;
|
||||
width: 118rpx;
|
||||
height: 118rpx;
|
||||
place-items: center;
|
||||
border: 2rpx solid rgba(160, 37, 30, 0.88);
|
||||
border-radius: 50%;
|
||||
background: #aa3129;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.overview-action__icon--primary image {
|
||||
width: 68rpx;
|
||||
height: 68rpx;
|
||||
filter: brightness(0) invert(1);
|
||||
}
|
||||
.overview-action__copy {
|
||||
min-width: 0;
|
||||
}
|
||||
.overview-action__copy text,
|
||||
.overview-summary text {
|
||||
display: block;
|
||||
}
|
||||
.overview-action__chevron,
|
||||
.overview-family image {
|
||||
width: 26rpx;
|
||||
height: 26rpx;
|
||||
opacity: 0.68;
|
||||
}
|
||||
.overview-action__title {
|
||||
margin-bottom: 9rpx;
|
||||
margin-bottom: 5rpx;
|
||||
color: $ink;
|
||||
font-family: "STKaiti", "KaiTi", serif;
|
||||
font-size: 30rpx;
|
||||
font-weight: 700;
|
||||
}
|
||||
.overview-action--tree .overview-action__title {
|
||||
font-size: 40rpx;
|
||||
}
|
||||
.overview-summary {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
gap: 0;
|
||||
}
|
||||
.overview-family {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr auto;
|
||||
grid-template-columns: minmax(0, 1fr) 26rpx;
|
||||
align-items: center;
|
||||
align-self: start;
|
||||
gap: 16rpx;
|
||||
margin: 0 7.5%;
|
||||
gap: 20rpx;
|
||||
margin: 14rpx 42rpx 0;
|
||||
padding: 20rpx 0;
|
||||
border-top: 1rpx solid rgba(152, 119, 72, 0.28);
|
||||
color: $ink-muted;
|
||||
font-size: 22rpx;
|
||||
font-size: 20rpx;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.overview-family text {
|
||||
display: block;
|
||||
}
|
||||
.overview-family text:first-child {
|
||||
margin-bottom: 3rpx;
|
||||
color: $ink;
|
||||
font-family: "STKaiti", "KaiTi", serif;
|
||||
font-size: 28rpx;
|
||||
font-size: 27rpx;
|
||||
font-weight: 700;
|
||||
}
|
||||
.overview-family text:last-child {
|
||||
color: $brand-red;
|
||||
}
|
||||
.overview-note {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
margin: 0 8.5%;
|
||||
margin: 18rpx 42rpx 0;
|
||||
padding: 18rpx 0 0;
|
||||
border-top: 1rpx solid rgba(152, 119, 72, 0.28);
|
||||
color: $ink-muted;
|
||||
font-size: 22rpx;
|
||||
line-height: 1.6;
|
||||
margin-top: 100rpx;
|
||||
font-size: 19rpx;
|
||||
line-height: 1.55;
|
||||
}
|
||||
.overview-note__title {
|
||||
margin-bottom: 8rpx;
|
||||
margin-bottom: 5rpx;
|
||||
color: $brand-red;
|
||||
font-family: "STKaiti", "KaiTi", serif;
|
||||
font-size: 28rpx;
|
||||
font-size: 24rpx;
|
||||
font-weight: 700;
|
||||
}
|
||||
.overview-state {
|
||||
|
||||
+503
-151
@@ -1,70 +1,152 @@
|
||||
<!-- 页面编号:M-01;用途:个人中心,显示已核实的当前用户资料。 -->
|
||||
<!-- 页面编号:M-01;用途:个人中心,显示当前用户资料与服务入口。 -->
|
||||
<template>
|
||||
<view class="profile-page">
|
||||
<ModulePageBackground module="profile" />
|
||||
<view class="page-header"><PageHeader root title="我的" /></view>
|
||||
<view class="page-content">
|
||||
<view v-if="loading" class="state-card"
|
||||
><text>正在读取个人资料…</text></view
|
||||
>
|
||||
<view v-else-if="loadError" class="state-card">
|
||||
<text>个人资料暂时无法读取</text>
|
||||
<text>{{ loadError }}</text>
|
||||
<AppButton block label="重新读取" @click="loadProfile" />
|
||||
</view>
|
||||
<template v-else>
|
||||
<view class="profile-summary">
|
||||
<view class="profile-summary__avatar"
|
||||
><image
|
||||
src="/static/assets/foundation/transparent/auth-login-outline.png"
|
||||
mode="aspectFit"
|
||||
/></view>
|
||||
<view class="profile-summary__identity">
|
||||
<text>{{ profile.nickName || "未设置昵称" }}</text>
|
||||
<text>{{ profile.realName || "未设置真实姓名" }}</text>
|
||||
<text>{{ profile.phone || "未提供手机号" }}</text>
|
||||
</view>
|
||||
<AppButton
|
||||
class="profile-summary__edit"
|
||||
label="编辑资料"
|
||||
@click="openEdit"
|
||||
/>
|
||||
</view>
|
||||
|
||||
<view class="profile-fields">
|
||||
<view class="profile-field"
|
||||
><text>性别</text><text>{{ sexText }}</text></view
|
||||
>
|
||||
<view class="profile-field"
|
||||
><text>生日</text><text>{{ birthdayText }}</text></view
|
||||
>
|
||||
<view class="profile-field"
|
||||
><text>邮箱</text><text>{{ profile.email || "未设置" }}</text></view
|
||||
>
|
||||
</view>
|
||||
|
||||
<view class="profile-menu">
|
||||
<AppButton block label="账号与安全" @click="open('M03')" />
|
||||
<AppButton block label="消息中心" @click="open('N01')" />
|
||||
<AppButton block label="帮助中心" @click="open('M06')" />
|
||||
<AppButton block label="意见反馈" @click="open('M07')" />
|
||||
<AppButton block label="应用推广" @click="open('M08')" />
|
||||
<AppButton block label="VIP 服务" @click="open('M09')" />
|
||||
<AppButton block label="关于与设置" @click="open('M10')" />
|
||||
</view>
|
||||
</template>
|
||||
<view
|
||||
class="profile-page"
|
||||
:class="{
|
||||
'profile-state--ready': !loading && !loadError,
|
||||
'profile-state--error': Boolean(loadError),
|
||||
}"
|
||||
>
|
||||
<view v-if="compactHeader" class="profile-compact-header" aria-hidden="true">
|
||||
<text>我的</text>
|
||||
</view>
|
||||
|
||||
<view v-if="loading" class="profile-state-card">
|
||||
<text>正在读取个人资料…</text>
|
||||
</view>
|
||||
|
||||
<view v-else-if="loadError" class="profile-state-card">
|
||||
<text>个人资料暂时无法读取</text>
|
||||
<text>{{ loadError }}</text>
|
||||
<AppButton block label="重新读取" @click="loadProfile" />
|
||||
</view>
|
||||
|
||||
<template v-else>
|
||||
<view class="profile-hero">
|
||||
<image
|
||||
class="profile-hero__art"
|
||||
src="/static/assets/modules/profile/opaque/m01-profile-archive-hero-v2.png"
|
||||
mode="scaleToFill"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<view class="profile-hero__content">
|
||||
<view class="profile-hero__label">
|
||||
<text>我的</text>
|
||||
<view
|
||||
class="profile-hero__setting"
|
||||
role="button"
|
||||
aria-label="关于与设置"
|
||||
@click="openSettings"
|
||||
><image
|
||||
class="profile-hero__setting-icon"
|
||||
src="/static/assets/modules/profile/transparent/m01-icon-settings.png"
|
||||
mode="aspectFit"
|
||||
aria-hidden="true"
|
||||
/></view>
|
||||
</view>
|
||||
|
||||
<view class="profile-hero__identity">
|
||||
<image
|
||||
class="profile-hero__emblem"
|
||||
src="/static/assets/modules/profile/transparent/m01-profile-tree-medallion.png"
|
||||
mode="aspectFit"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<view class="profile-hero__copy">
|
||||
<text>{{ profile.nickName || "未设置昵称" }}</text>
|
||||
<text>{{ profile.realName || "未设置真实姓名" }}</text>
|
||||
<text>{{ profile.phone || "未提供手机号" }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<button
|
||||
class="profile-hero__edit"
|
||||
@click="openEdit"
|
||||
>编辑资料</button>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="profile-metadata" aria-label="个人资料摘要">
|
||||
<view class="profile-metadata__item">
|
||||
<image
|
||||
class="profile-metadata__icon"
|
||||
src="/static/assets/modules/profile/transparent/m01-icon-person.png"
|
||||
mode="aspectFit"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<view><text>性别</text><text>{{ sexText }}</text></view>
|
||||
</view>
|
||||
<view class="profile-metadata__item">
|
||||
<image
|
||||
class="profile-metadata__icon"
|
||||
src="/static/assets/modules/profile/transparent/m01-icon-calendar.png"
|
||||
mode="aspectFit"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<view><text>生日</text><text>{{ birthdayText }}</text></view>
|
||||
</view>
|
||||
<view class="profile-metadata__item profile-metadata__item--email">
|
||||
<image
|
||||
class="profile-metadata__icon"
|
||||
src="/static/assets/modules/profile/transparent/m01-icon-envelope.png"
|
||||
mode="aspectFit"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<view><text>邮箱</text><text>{{ profile.email || "未设置" }}</text></view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="profile-services">
|
||||
<view
|
||||
v-for="group in serviceGroups"
|
||||
:key="group.title"
|
||||
class="profile-service-group"
|
||||
>
|
||||
<view class="profile-section-title">
|
||||
<view class="profile-section-title__mark" aria-hidden="true"></view>
|
||||
<text>{{ group.title }}</text>
|
||||
</view>
|
||||
<view class="profile-service-list">
|
||||
<view
|
||||
v-for="item in group.items"
|
||||
:key="item.routeKey"
|
||||
class="profile-service-row"
|
||||
role="button"
|
||||
:aria-label="item.label"
|
||||
@click="openService(item)"
|
||||
>
|
||||
<view class="profile-service-row__leading">
|
||||
<image
|
||||
:class="[
|
||||
'profile-service-row__icon',
|
||||
`profile-service-row__icon--${item.routeKey.toLowerCase()}`,
|
||||
]"
|
||||
:src="item.icon"
|
||||
mode="aspectFit"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<text>{{ item.label }}</text>
|
||||
</view>
|
||||
<image
|
||||
class="profile-service-row__chevron"
|
||||
src="/static/assets/foundation/transparent/chevron-right.png"
|
||||
mode="aspectFit"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<AppTabbar active="profile" />
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, reactive, ref } from "vue";
|
||||
import { onShow, onUnload } from "@dcloudio/uni-app";
|
||||
import { onPageScroll, onShow, onUnload } from "@dcloudio/uni-app";
|
||||
import AppButton from "@/components/AppButton.vue";
|
||||
import AppTabbar from "@/components/AppTabbar.vue";
|
||||
import ModulePageBackground from "@/components/ModulePageBackground.vue";
|
||||
import PageHeader from "@/components/PageHeader.vue";
|
||||
import {
|
||||
appApi,
|
||||
createRequestController,
|
||||
@@ -83,11 +165,59 @@ const profile = reactive({
|
||||
});
|
||||
const loading = ref(false);
|
||||
const loadError = ref("");
|
||||
const compactHeader = ref(false);
|
||||
const requestController = createRequestController();
|
||||
let pageActive = true;
|
||||
|
||||
const sexText = computed(() => (profile.sex ? "待确认" : "未设置"));
|
||||
const serviceGroups = [
|
||||
{
|
||||
title: "账户与服务",
|
||||
items: [
|
||||
{
|
||||
routeKey: "M03",
|
||||
label: "账号与安全",
|
||||
icon: "/static/assets/modules/profile/transparent/m01-icon-security.png",
|
||||
},
|
||||
{
|
||||
routeKey: "N01",
|
||||
label: "消息中心",
|
||||
icon: "/static/assets/modules/profile/transparent/m01-icon-message.png",
|
||||
},
|
||||
{
|
||||
routeKey: "M09",
|
||||
label: "VIP 服务",
|
||||
icon: "/static/assets/modules/profile/transparent/m01-icon-vip.png",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "支持与其他",
|
||||
items: [
|
||||
{
|
||||
routeKey: "M06",
|
||||
label: "帮助中心",
|
||||
icon: "/static/assets/modules/profile/transparent/m01-icon-help.png",
|
||||
},
|
||||
{
|
||||
routeKey: "M07",
|
||||
label: "意见反馈",
|
||||
icon: "/static/assets/modules/profile/transparent/m01-icon-feedback.png",
|
||||
},
|
||||
{
|
||||
routeKey: "M08",
|
||||
label: "应用推广",
|
||||
icon: "/static/assets/modules/profile/transparent/m01-icon-promotion.png",
|
||||
},
|
||||
{
|
||||
routeKey: "M10",
|
||||
label: "关于与设置",
|
||||
icon: "/static/assets/modules/profile/transparent/m01-icon-settings.png",
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const sexText = computed(() => (profile.sex ? "待确认" : "未设置"));
|
||||
const birthdayText = computed(() =>
|
||||
/^\d{4}-\d{2}-\d{2}/.test(profile.birthday)
|
||||
? profile.birthday.slice(0, 10)
|
||||
@@ -111,143 +241,365 @@ const loadProfile = async () => {
|
||||
}
|
||||
};
|
||||
|
||||
const open = (route) => openPage(route, {}, "M01");
|
||||
const openEdit = () => open("M02");
|
||||
const openService = (item) => openPage(item.routeKey, {}, "M01");
|
||||
const openEdit = () => openPage("M02", {}, "M01");
|
||||
const openSettings = () => openPage("M10", {}, "M01");
|
||||
|
||||
onShow(loadProfile);
|
||||
onPageScroll(({ scrollTop = 0 }) => {
|
||||
compactHeader.value = scrollTop > 44;
|
||||
});
|
||||
onUnload(() => {
|
||||
pageActive = false;
|
||||
compactHeader.value = false;
|
||||
requestController.abort();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@use "../../styles/adaptive-frame-profiles.scss" as adaptive;
|
||||
|
||||
.profile-page {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
min-height: 100vh;
|
||||
flex-direction: column;
|
||||
background: $paper;
|
||||
}
|
||||
.page-header,
|
||||
.page-content {
|
||||
z-index: 1;
|
||||
}
|
||||
.page-content {
|
||||
flex: 1;
|
||||
padding: 18rpx 24rpx 190rpx;
|
||||
}
|
||||
.state-card,
|
||||
.profile-summary,
|
||||
.profile-fields {
|
||||
@include adaptive.adaptive-family-content;
|
||||
}
|
||||
.state-card {
|
||||
padding-bottom: 166rpx;
|
||||
box-sizing: border-box;
|
||||
min-height: 340rpx;
|
||||
padding: 78rpx 52rpx 50rpx;
|
||||
overflow-x: hidden;
|
||||
background: #f3eee5 url("/static/assets/modules/profile/opaque/m01-profile-paper-mountains-v1.png") center 360rpx / 100% auto no-repeat;
|
||||
}
|
||||
|
||||
.profile-compact-header {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
right: 0;
|
||||
left: 0;
|
||||
z-index: 30;
|
||||
height: 88rpx;
|
||||
box-sizing: border-box;
|
||||
padding: 38rpx 48rpx 0;
|
||||
background: linear-gradient(90deg, #6a1d17, #4e120f);
|
||||
box-shadow: 0 2rpx 12rpx rgba(58, 16, 12, 0.22);
|
||||
color: #f2d9a8;
|
||||
font-family: STKaiti, KaiTi, serif;
|
||||
font-size: 28rpx;
|
||||
font-weight: 700;
|
||||
letter-spacing: 3rpx;
|
||||
line-height: 1;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.profile-state-card {
|
||||
@include adaptive.adaptive-profile-summary;
|
||||
margin: 48rpx 30rpx;
|
||||
padding: 64rpx 44rpx;
|
||||
color: #382c25;
|
||||
text-align: center;
|
||||
}
|
||||
.state-card text {
|
||||
|
||||
.profile-state-card text {
|
||||
display: block;
|
||||
font-size: 26rpx;
|
||||
line-height: 1.6;
|
||||
}
|
||||
.state-card text:first-child {
|
||||
color: $ink;
|
||||
font-family: STKaiti, KaiTi, serif;
|
||||
font-size: 35rpx;
|
||||
font-weight: 700;
|
||||
|
||||
.profile-state-card text + text {
|
||||
margin-top: 12rpx;
|
||||
color: #806e5d;
|
||||
font-size: 22rpx;
|
||||
}
|
||||
.state-card text:nth-child(2) {
|
||||
margin-top: 18rpx;
|
||||
color: $ink-muted;
|
||||
font-size: 24rpx;
|
||||
line-height: 1.65;
|
||||
}
|
||||
.state-card .app-button {
|
||||
|
||||
.profile-state-card .app-button {
|
||||
@include adaptive.adaptive-scroll-button(primary);
|
||||
margin-top: 28rpx;
|
||||
}
|
||||
.profile-summary {
|
||||
|
||||
.profile-hero {
|
||||
display: grid;
|
||||
grid-template-columns: 96rpx minmax(0, 1fr);
|
||||
align-items: center;
|
||||
gap: 18rpx;
|
||||
padding: 30rpx;
|
||||
width: 100%;
|
||||
height: 503rpx;
|
||||
}
|
||||
.profile-summary__avatar {
|
||||
|
||||
.profile-hero__art,
|
||||
.profile-hero__content {
|
||||
grid-area: 1 / 1;
|
||||
}
|
||||
|
||||
.profile-hero__art {
|
||||
width: 100%;
|
||||
height: 480rpx;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.profile-hero__content {
|
||||
display: flex;
|
||||
width: 96rpx;
|
||||
height: 96rpx;
|
||||
flex-direction: column;
|
||||
z-index: 1;
|
||||
padding: 100rpx 56rpx 50rpx 54rpx;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.profile-hero__label {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
color: #f0d6a4;
|
||||
font-family: STKaiti, KaiTi, serif;
|
||||
font-size: 54rpx;
|
||||
font-weight: 700;
|
||||
letter-spacing: 5rpx;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.profile-hero__setting {
|
||||
display: flex;
|
||||
width: 48rpx;
|
||||
height: 48rpx;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 1rpx solid rgba(159, 23, 15, 0.32);
|
||||
border-radius: 50%;
|
||||
background: rgba(159, 23, 15, 0.06);
|
||||
}
|
||||
.profile-summary__avatar image {
|
||||
width: 64rpx;
|
||||
height: 64rpx;
|
||||
opacity: 0.8;
|
||||
|
||||
.profile-hero__setting-icon {
|
||||
width: 42rpx;
|
||||
height: 42rpx;
|
||||
filter: grayscale(1) sepia(0.9) saturate(1.6) hue-rotate(348deg) brightness(1.35);
|
||||
transform: scale(1.25);
|
||||
}
|
||||
.profile-summary__identity {
|
||||
|
||||
.profile-hero__identity {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
align-items: flex-start;
|
||||
position: relative;
|
||||
margin-top: 43rpx;
|
||||
margin-left: -30rpx;
|
||||
}
|
||||
.profile-summary__identity text {
|
||||
|
||||
.profile-hero__emblem {
|
||||
width: 220rpx;
|
||||
height: 220rpx;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.profile-hero__copy {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
margin: 46rpx 0 0 -5rpx;
|
||||
}
|
||||
|
||||
.profile-hero__copy text {
|
||||
display: block;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.profile-summary__identity text:first-child {
|
||||
color: $ink;
|
||||
font-family: STKaiti, KaiTi, serif;
|
||||
|
||||
.profile-hero__copy text:first-child {
|
||||
color: #fff2da;
|
||||
font-family: STSong, SimSun, serif;
|
||||
font-size: 32rpx;
|
||||
font-weight: 700;
|
||||
letter-spacing: 1rpx;
|
||||
line-height: 1.25;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.profile-summary__identity text:nth-child(2),
|
||||
.profile-summary__identity text:last-child {
|
||||
margin-top: 5rpx;
|
||||
color: $ink-muted;
|
||||
font-size: 21rpx;
|
||||
|
||||
.profile-hero__copy text:nth-child(2) {
|
||||
margin-top: 14rpx;
|
||||
color: #ecd2a4;
|
||||
font-size: 27rpx;
|
||||
line-height: 1.35;
|
||||
}
|
||||
.profile-summary__edit {
|
||||
grid-column: 1 / -1;
|
||||
margin-top: 6rpx;
|
||||
}
|
||||
.profile-fields {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
.profile-hero__copy text:last-child {
|
||||
margin-top: 18rpx;
|
||||
padding: 8rpx 30rpx;
|
||||
color: #e8bd78;
|
||||
font-size: 28rpx;
|
||||
letter-spacing: 1rpx;
|
||||
}
|
||||
.profile-field {
|
||||
|
||||
.profile-hero__edit {
|
||||
position: absolute;
|
||||
top: 337rpx;
|
||||
right: 61rpx;
|
||||
width: 154rpx;
|
||||
min-height: 52rpx;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
background: transparent url("/static/assets/modules/profile/transparent/m01-profile-edit-button-v2.png") center / 100% 100% no-repeat;
|
||||
box-shadow: none;
|
||||
color: #fff6e5;
|
||||
font-size: 22rpx;
|
||||
font-weight: 700;
|
||||
letter-spacing: 1rpx;
|
||||
}
|
||||
|
||||
.profile-hero__edit::after {
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.profile-metadata {
|
||||
display: flex;
|
||||
min-height: 82rpx;
|
||||
min-height: 146rpx;
|
||||
z-index: 1;
|
||||
margin: -36rpx 34rpx 0;
|
||||
padding: 5rpx 0;
|
||||
border: 1rpx solid rgba(117, 83, 52, 0.09);
|
||||
border-radius: 18rpx;
|
||||
background: rgba(239, 234, 224, 0.92);
|
||||
}
|
||||
|
||||
.profile-metadata__item {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
flex: 1;
|
||||
gap: 10rpx;
|
||||
padding: 0 10rpx;
|
||||
border-right: 1rpx solid rgba(117, 83, 52, 0.16);
|
||||
}
|
||||
|
||||
.profile-metadata__item:last-child {
|
||||
border-right: 0;
|
||||
}
|
||||
|
||||
.profile-metadata__item--email {
|
||||
flex: 2.3;
|
||||
}
|
||||
|
||||
.profile-metadata__icon {
|
||||
width: 42rpx;
|
||||
height: 42rpx;
|
||||
filter: sepia(0.7) saturate(0.8) hue-rotate(330deg) brightness(0.82);
|
||||
transform: scale(2.1);
|
||||
}
|
||||
|
||||
.profile-metadata__item view {
|
||||
min-width: 0;
|
||||
transform: translateY(14rpx);
|
||||
}
|
||||
|
||||
.profile-metadata__item text {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.profile-metadata__item text:first-child {
|
||||
color: #857263;
|
||||
font-size: 20rpx;
|
||||
}
|
||||
|
||||
.profile-metadata__item text:last-child {
|
||||
margin-top: 0;
|
||||
color: #382c25;
|
||||
font-size: 24rpx;
|
||||
line-height: 1.25;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.profile-metadata__item--email text:last-child {
|
||||
font-size: 13rpx;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.profile-services {
|
||||
margin: 49rpx 58rpx 0 48rpx;
|
||||
}
|
||||
|
||||
.profile-service-group + .profile-service-group {
|
||||
margin-top: 51rpx;
|
||||
}
|
||||
|
||||
.profile-section-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
color: #382c25;
|
||||
font-family: STSong, SimSun, serif;
|
||||
font-size: 34rpx;
|
||||
font-weight: 700;
|
||||
letter-spacing: 2rpx;
|
||||
}
|
||||
|
||||
.profile-section-title__mark {
|
||||
width: 6rpx;
|
||||
height: 24rpx;
|
||||
border-radius: 6rpx;
|
||||
background: #8d2722;
|
||||
pointer-events: none;
|
||||
flex: 0 0 auto;
|
||||
margin-right: 24rpx;
|
||||
}
|
||||
|
||||
.profile-service-list {
|
||||
margin-top: 2rpx;
|
||||
}
|
||||
|
||||
.profile-service-row {
|
||||
display: flex;
|
||||
min-height: 96rpx;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 20rpx;
|
||||
border-bottom: 1rpx solid rgba(181, 137, 63, 0.3);
|
||||
gap: 24rpx;
|
||||
border-bottom: 1rpx solid rgba(112, 85, 61, 0.2);
|
||||
color: #45362d;
|
||||
}
|
||||
.profile-field:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
.profile-field text:first-child {
|
||||
flex: 0 0 auto;
|
||||
color: $ink;
|
||||
font-size: 24rpx;
|
||||
font-weight: 700;
|
||||
}
|
||||
.profile-field text:last-child {
|
||||
|
||||
.profile-service-row__leading {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
color: $ink-muted;
|
||||
font-size: 22rpx;
|
||||
overflow-wrap: anywhere;
|
||||
text-align: right;
|
||||
align-items: center;
|
||||
gap: 30rpx;
|
||||
padding-left: 7rpx;
|
||||
}
|
||||
.profile-menu {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 14rpx;
|
||||
margin-top: 24rpx;
|
||||
|
||||
.profile-service-row__icon {
|
||||
width: 44rpx;
|
||||
height: 44rpx;
|
||||
flex: 0 0 auto;
|
||||
transform: translate(7rpx, 0) scale(1.8);
|
||||
}
|
||||
.profile-menu .app-button {
|
||||
margin: 0;
|
||||
|
||||
.profile-service-row__icon--m03 {
|
||||
transform: translate(9rpx, -9rpx) scale(1.8);
|
||||
}
|
||||
|
||||
.profile-service-row__icon--n01 {
|
||||
transform: translate(10rpx, -10rpx) scale(1.8);
|
||||
}
|
||||
|
||||
.profile-service-row__icon--m09 {
|
||||
transform: translate(-5rpx, 5rpx) scale(1.8);
|
||||
}
|
||||
|
||||
.profile-service-row__icon--m06 {
|
||||
transform: translate(-3rpx, 7rpx) scale(1.8);
|
||||
}
|
||||
|
||||
.profile-service-row__icon--m07 {
|
||||
transform: translate(4rpx, 8rpx) scale(1.8);
|
||||
}
|
||||
|
||||
.profile-service-row__icon--m08 {
|
||||
transform: translate(11rpx, 7rpx) scale(1.8);
|
||||
}
|
||||
|
||||
.profile-service-row__icon--m10 {
|
||||
transform: translate(9rpx, 7rpx) scale(1.8);
|
||||
}
|
||||
|
||||
.profile-service-row text {
|
||||
font-size: 32rpx;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.profile-service-row__chevron {
|
||||
width: 24rpx;
|
||||
height: 24rpx;
|
||||
flex: 0 0 auto;
|
||||
opacity: 0.86;
|
||||
}
|
||||
|
||||
.profile-service-row:active {
|
||||
opacity: 0.68;
|
||||
}
|
||||
|
||||
</style>
|
||||
|
||||
@@ -1,28 +1,324 @@
|
||||
<!-- 页面编号:M-05;用途:换绑手机号。缺专用发码/资料 DTO 且需人工 TAC/短信时保持关闭。 -->
|
||||
<!-- 页面编号:M-05;用途:换绑已登录账号的手机号。 -->
|
||||
<template>
|
||||
<view class="phone-page"
|
||||
><ModulePageBackground module="profile" /><view class="page-header"
|
||||
><PageHeader
|
||||
title="换绑手机号"
|
||||
custom-back
|
||||
@back="backToSecurity" /></view
|
||||
><view class="page-content"
|
||||
><view class="state-card"
|
||||
><text>暂不能在线换绑手机号</text
|
||||
><text>为保护账户安全,请联系管理员协助办理手机号换绑。</text
|
||||
><AppButton
|
||||
block
|
||||
label="返回账号与安全"
|
||||
@click="backToSecurity" /></view></view
|
||||
></view>
|
||||
<view
|
||||
class="phone-page"
|
||||
:class="{
|
||||
'phone-state--sending': phoneState === 'sending',
|
||||
'phone-state--submitting': phoneState === 'submitting',
|
||||
}"
|
||||
>
|
||||
<ModulePageBackground module="profile" />
|
||||
<view class="page-layer"
|
||||
><PageHeader title="换绑手机号" custom-back @back="requestBack"
|
||||
/></view>
|
||||
<view class="page-content page-layer">
|
||||
<view class="security-tip"
|
||||
><text>验证新手机号</text
|
||||
><text>完成行为验证和短信校验后,立即更新当前账号的登录手机号。</text></view
|
||||
>
|
||||
<view class="form-panel">
|
||||
<view class="field-block">
|
||||
<view class="form-row">
|
||||
<text>新手机号</text>
|
||||
<input
|
||||
v-model.trim="phone"
|
||||
type="number"
|
||||
maxlength="11"
|
||||
aria-label="新手机号"
|
||||
placeholder="请输入新手机号"
|
||||
@input="handlePhoneInput"
|
||||
/>
|
||||
</view>
|
||||
<text v-if="errors.phone" class="field-error">{{ errors.phone }}</text>
|
||||
</view>
|
||||
<view class="field-block">
|
||||
<view class="form-row form-row--code">
|
||||
<text>短信验证码</text>
|
||||
<input
|
||||
v-model.trim="smsCode"
|
||||
type="number"
|
||||
maxlength="4"
|
||||
aria-label="短信验证码"
|
||||
placeholder="4 位验证码"
|
||||
@input="errors.smsCode = ''"
|
||||
/>
|
||||
<AppButton
|
||||
compact
|
||||
type="secondary"
|
||||
:disabled="phoneState !== 'ready' || cooldownSeconds > 0"
|
||||
:label="cooldownSeconds > 0 ? `${cooldownSeconds}s 后重试` : '获取验证码'"
|
||||
@click="prepareGetCode"
|
||||
/>
|
||||
</view>
|
||||
<text v-if="errors.smsCode" class="field-error">{{ errors.smsCode }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<AppButton
|
||||
block
|
||||
:disabled="phoneState !== 'ready'"
|
||||
:label="phoneState === 'submitting' ? '正在换绑' : '确认换绑手机号'"
|
||||
@click="submitPhoneChange"
|
||||
/>
|
||||
</view>
|
||||
<TacVerification
|
||||
:visible="tacVisible"
|
||||
:context="tacContext"
|
||||
@success="completeTac"
|
||||
@failure="handleTacFailure"
|
||||
@error="handleTacError"
|
||||
@cancel="closeTac"
|
||||
/>
|
||||
<AppToast :visible="toastVisible" :message="toastMessage" />
|
||||
<AppDialog
|
||||
:visible="discardVisible"
|
||||
:close-on-mask="false"
|
||||
eyebrow="放弃确认"
|
||||
title="放弃手机号换绑?"
|
||||
message="新手机号和验证码尚未提交服务器。"
|
||||
confirm-text="确认放弃"
|
||||
cancel-text="继续填写"
|
||||
show-cancel
|
||||
@confirm="confirmDiscard"
|
||||
@cancel="cancelDiscard"
|
||||
/>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, reactive, ref } from "vue";
|
||||
import { onBackPress, onShow, onUnload } from "@dcloudio/uni-app";
|
||||
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";
|
||||
import { returnTo } from "@/utils/navigation.js";
|
||||
const backToSecurity = () => returnTo("M03");
|
||||
import TacVerification from "@/components/TacVerification.vue";
|
||||
import {
|
||||
appApi,
|
||||
createRequestController,
|
||||
isRequestCancelled,
|
||||
} from "@/utils/api.js";
|
||||
import {
|
||||
AUTH_VERIFICATION_OPERATION,
|
||||
createTacRenderContext,
|
||||
isAuthPhone,
|
||||
isSmsDeliveryOutcomeUnknown,
|
||||
normalizeCaptchaRequirement,
|
||||
normalizeTacSuccess,
|
||||
} from "@/utils/auth-verification.js";
|
||||
import { createAuthSmsCooldown } from "@/utils/auth-sms-cooldown.js";
|
||||
import { runtimeConfig } from "@/utils/config.js";
|
||||
import { createDiscardConfirmation } from "@/utils/discard-confirmation.js";
|
||||
import { handleBackPress, runBackGuard } from "@/utils/navigation.js";
|
||||
|
||||
const phone = ref("");
|
||||
const smsCode = ref("");
|
||||
const sentPhone = ref("");
|
||||
const errors = reactive({ phone: "", smsCode: "" });
|
||||
const phoneState = ref("ready");
|
||||
const cooldownSeconds = ref(0);
|
||||
const tacVisible = ref(false);
|
||||
const tacContext = ref(null);
|
||||
const discardVisible = ref(false);
|
||||
const toastVisible = ref(false);
|
||||
const toastMessage = ref("");
|
||||
const formSnapshot = computed(() => JSON.stringify({ phone: phone.value, smsCode: smsCode.value }));
|
||||
const baseline = ref(formSnapshot.value);
|
||||
const isDirty = computed(() => formSnapshot.value !== baseline.value);
|
||||
const requestController = createRequestController();
|
||||
const smsCooldown = createAuthSmsCooldown({
|
||||
operationCode: AUTH_VERIFICATION_OPERATION.PHONE_CHANGE,
|
||||
onChange: (seconds) => {
|
||||
cooldownSeconds.value = seconds;
|
||||
},
|
||||
});
|
||||
const discardConfirmation = createDiscardConfirmation((visible) => {
|
||||
discardVisible.value = visible;
|
||||
});
|
||||
const requestDiscardConfirmation = discardConfirmation.request;
|
||||
const confirmDiscard = discardConfirmation.confirm;
|
||||
const cancelDiscard = discardConfirmation.cancel;
|
||||
let toastTimer = null;
|
||||
let tacSequence = 0;
|
||||
let pageActive = true;
|
||||
|
||||
const showToast = (message) => {
|
||||
toastMessage.value = message;
|
||||
toastVisible.value = true;
|
||||
clearTimeout(toastTimer);
|
||||
toastTimer = setTimeout(() => {
|
||||
toastVisible.value = false;
|
||||
}, 2200);
|
||||
};
|
||||
|
||||
const closeTac = () => {
|
||||
tacVisible.value = false;
|
||||
tacContext.value = null;
|
||||
};
|
||||
|
||||
const handlePhoneInput = () => {
|
||||
errors.phone = "";
|
||||
if (phone.value === sentPhone.value) return;
|
||||
smsCode.value = "";
|
||||
sentPhone.value = "";
|
||||
errors.smsCode = "";
|
||||
};
|
||||
|
||||
const sendSmsCode = async (requestedPhone, validToken) => {
|
||||
await appApi.sendSmsCode(
|
||||
{
|
||||
operationCode: AUTH_VERIFICATION_OPERATION.PHONE_CHANGE,
|
||||
phone: requestedPhone,
|
||||
...(validToken ? { validToken } : {}),
|
||||
},
|
||||
{ requestController },
|
||||
);
|
||||
if (!pageActive) return;
|
||||
sentPhone.value = requestedPhone;
|
||||
smsCooldown.start();
|
||||
showToast("验证码已发送");
|
||||
};
|
||||
|
||||
const prepareGetCode = async () => {
|
||||
if (phoneState.value !== "ready" || cooldownSeconds.value > 0) return;
|
||||
if (!isAuthPhone(phone.value)) {
|
||||
errors.phone = "请输入正确手机号";
|
||||
return;
|
||||
}
|
||||
errors.phone = "";
|
||||
phoneState.value = "sending";
|
||||
try {
|
||||
const requestedPhone = phone.value;
|
||||
const response = await appApi.getCaptchaRequirement(
|
||||
{
|
||||
operationCode: AUTH_VERIFICATION_OPERATION.PHONE_CHANGE,
|
||||
subject: requestedPhone,
|
||||
},
|
||||
{ requestController },
|
||||
);
|
||||
if (!pageActive) return;
|
||||
if (phone.value !== requestedPhone) {
|
||||
throw new Error("手机号已变化,请重新获取验证码");
|
||||
}
|
||||
const requirement = normalizeCaptchaRequirement(response);
|
||||
if (!requirement.required) {
|
||||
await sendSmsCode(requestedPhone);
|
||||
return;
|
||||
}
|
||||
tacSequence += 1;
|
||||
tacContext.value = createTacRenderContext({
|
||||
requestId: `m05-phone-change-${tacSequence}`,
|
||||
baseUrl: runtimeConfig.baseUrl,
|
||||
clientId: runtimeConfig.clientId,
|
||||
tenantId: runtimeConfig.tenantId,
|
||||
operationCode: AUTH_VERIFICATION_OPERATION.PHONE_CHANGE,
|
||||
subject: requestedPhone,
|
||||
requirement,
|
||||
});
|
||||
tacVisible.value = true;
|
||||
} catch (error) {
|
||||
if (pageActive && !isRequestCancelled(error)) {
|
||||
showToast(error?.message || "安全验证暂不可用");
|
||||
}
|
||||
} finally {
|
||||
if (pageActive) phoneState.value = "ready";
|
||||
}
|
||||
};
|
||||
|
||||
const completeTac = async (result) => {
|
||||
const context = tacContext.value;
|
||||
if (!context || phoneState.value !== "ready") return;
|
||||
phoneState.value = "sending";
|
||||
try {
|
||||
const ticket = normalizeTacSuccess(result, context.requestId);
|
||||
if (phone.value !== context.subject) {
|
||||
throw new Error("手机号已变化,请重新验证");
|
||||
}
|
||||
closeTac();
|
||||
await sendSmsCode(context.subject, ticket.validToken);
|
||||
} catch (error) {
|
||||
closeTac();
|
||||
if (pageActive) {
|
||||
if (isSmsDeliveryOutcomeUnknown(error)) {
|
||||
sentPhone.value = context.subject;
|
||||
smsCooldown.start();
|
||||
showToast("发送结果未知,如收到短信可直接填写;60 秒后可重试");
|
||||
} else if (!isRequestCancelled(error)) {
|
||||
showToast(error?.message || "验证码发送失败");
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
if (pageActive) phoneState.value = "ready";
|
||||
}
|
||||
};
|
||||
|
||||
const handleTacFailure = ({ message } = {}) => {
|
||||
showToast(message || "行为验证未通过,请重试");
|
||||
};
|
||||
|
||||
const handleTacError = ({ message } = {}) => {
|
||||
closeTac();
|
||||
showToast(message || "安全验证暂不可用");
|
||||
};
|
||||
|
||||
const validateForm = () => {
|
||||
errors.phone = isAuthPhone(phone.value) ? "" : "请输入正确手机号";
|
||||
errors.smsCode =
|
||||
sentPhone.value !== phone.value
|
||||
? "请先获取当前手机号的验证码"
|
||||
: /^\d{4}$/.test(smsCode.value)
|
||||
? ""
|
||||
: "请输入 4 位验证码";
|
||||
return !errors.phone && !errors.smsCode;
|
||||
};
|
||||
|
||||
const submitPhoneChange = async () => {
|
||||
if (phoneState.value !== "ready" || !validateForm()) return;
|
||||
phoneState.value = "submitting";
|
||||
try {
|
||||
await appApi.changePhone(
|
||||
{ phone: phone.value, smsCode: smsCode.value },
|
||||
{ requestController },
|
||||
);
|
||||
if (!pageActive) return;
|
||||
phone.value = "";
|
||||
smsCode.value = "";
|
||||
sentPhone.value = "";
|
||||
baseline.value = formSnapshot.value;
|
||||
showToast("手机号换绑成功");
|
||||
} catch (error) {
|
||||
if (pageActive && !isRequestCancelled(error)) {
|
||||
showToast(error?.message || "换绑结果未知,请重新登录确认手机号");
|
||||
}
|
||||
} finally {
|
||||
if (pageActive) phoneState.value = "ready";
|
||||
}
|
||||
};
|
||||
|
||||
const requestBack = () =>
|
||||
runBackGuard({
|
||||
transientOpen: tacVisible.value || discardVisible.value,
|
||||
dirty: isDirty.value,
|
||||
submitting: phoneState.value !== "ready",
|
||||
"close-transient": tacVisible.value ? closeTac : cancelDiscard,
|
||||
"block-submitting": () => {
|
||||
requestController.abort();
|
||||
return true;
|
||||
},
|
||||
"confirm-discard": requestDiscardConfirmation,
|
||||
});
|
||||
|
||||
onBackPress((event) => handleBackPress(event, requestBack));
|
||||
onShow(() => smsCooldown.sync());
|
||||
onUnload(() => {
|
||||
pageActive = false;
|
||||
requestController.abort();
|
||||
smsCooldown.dispose();
|
||||
discardConfirmation.dispose();
|
||||
clearTimeout(toastTimer);
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@use "../../styles/adaptive-frame-profiles.scss" as adaptive;
|
||||
.phone-page {
|
||||
@@ -31,36 +327,100 @@ const backToSecurity = () => returnTo("M03");
|
||||
flex-direction: column;
|
||||
background: $paper;
|
||||
}
|
||||
.page-header,
|
||||
.page-content {
|
||||
.page-layer {
|
||||
z-index: 1;
|
||||
}
|
||||
.page-content {
|
||||
padding: 18rpx 24rpx 72rpx;
|
||||
flex: 1;
|
||||
padding: 28rpx 30rpx 72rpx;
|
||||
}
|
||||
.state-card {
|
||||
box-sizing: border-box;
|
||||
min-height: 340rpx;
|
||||
padding: 78rpx 52rpx 50rpx;
|
||||
.security-tip,
|
||||
.form-panel {
|
||||
@include adaptive.adaptive-profile-content;
|
||||
}
|
||||
.security-tip {
|
||||
min-height: 170rpx;
|
||||
padding: 38rpx 44rpx;
|
||||
text-align: center;
|
||||
@include adaptive.adaptive-family-content;
|
||||
}
|
||||
.state-card text {
|
||||
.security-tip text {
|
||||
display: block;
|
||||
}
|
||||
.state-card text:first-child {
|
||||
.security-tip text:first-child {
|
||||
color: $ink;
|
||||
font-family: STKaiti, KaiTi, serif;
|
||||
font-size: 35rpx;
|
||||
font-size: 32rpx;
|
||||
font-weight: 700;
|
||||
}
|
||||
.state-card text:nth-child(2) {
|
||||
margin-top: 18rpx;
|
||||
.security-tip text:last-child {
|
||||
margin-top: 10rpx;
|
||||
color: $ink-muted;
|
||||
font-size: 24rpx;
|
||||
line-height: 1.65;
|
||||
font-size: 22rpx;
|
||||
line-height: 1.55;
|
||||
}
|
||||
.state-card .app-button {
|
||||
.form-panel {
|
||||
margin-top: 20rpx;
|
||||
padding: 22rpx 34rpx 30rpx;
|
||||
}
|
||||
.field-block + .field-block {
|
||||
margin-top: 6rpx;
|
||||
}
|
||||
.form-row {
|
||||
display: grid;
|
||||
grid-template-columns: 142rpx minmax(0, 1fr);
|
||||
min-height: 92rpx;
|
||||
align-items: center;
|
||||
gap: 12rpx;
|
||||
border-bottom: 1px solid rgba(181, 137, 63, 0.42);
|
||||
}
|
||||
.form-row--code {
|
||||
grid-template-columns: 142rpx minmax(0, 1fr) 170rpx;
|
||||
}
|
||||
.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;
|
||||
}
|
||||
.form-row--code .app-button {
|
||||
justify-self: end;
|
||||
width: 170rpx;
|
||||
min-height: 68rpx;
|
||||
}
|
||||
.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);
|
||||
gap: 8rpx;
|
||||
}
|
||||
.form-row--code {
|
||||
grid-template-columns: 120rpx minmax(0, 1fr) 150rpx;
|
||||
}
|
||||
.form-row--code .app-button {
|
||||
width: 150rpx;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -2,13 +2,26 @@
|
||||
<view class="orders-page">
|
||||
<ModulePageBackground module="profile" />
|
||||
<view class="page-layer"
|
||||
><PageHeader title="VIP 与订单" custom-back @back="requestBack"
|
||||
><PageHeader
|
||||
class="orders-header"
|
||||
title="VIP 与订单"
|
||||
custom-back
|
||||
@back="requestBack"
|
||||
/></view>
|
||||
<view class="page-content page-layer">
|
||||
<view class="service-card"
|
||||
><text>服务套餐</text
|
||||
><text>套餐和订单均从服务端读取;本页不发起支付或创建订单。</text></view
|
||||
>
|
||||
<view class="vip-intro">
|
||||
<image
|
||||
class="vip-intro__background"
|
||||
src="/static/assets/modules/profile/opaque/m09-vip-hero-landscape.png"
|
||||
mode="aspectFill"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<view class="vip-intro__content">
|
||||
<text class="vip-intro__eyebrow">家谱传承服务</text>
|
||||
<text class="vip-intro__title">开通 VIP,守护家谱长久传承</text>
|
||||
<text class="vip-intro__copy">选择适合家谱规模的服务套餐。</text>
|
||||
</view>
|
||||
</view>
|
||||
<view v-if="state === 'loading'" class="state-card"
|
||||
><AppLoading text="正在读取服务套餐"
|
||||
/></view>
|
||||
@@ -18,12 +31,33 @@
|
||||
/></view>
|
||||
<template v-else>
|
||||
<view v-if="packages.length" class="package-list"
|
||||
><view v-for="item in packages" :key="item.id" class="package-card"
|
||||
><view class="package-heading"
|
||||
><text>{{ item.name }}</text
|
||||
><text v-if="item.price">¥{{ item.price }}</text></view
|
||||
><text v-if="item.description">{{ item.description }}</text
|
||||
><text v-if="item.duration">{{ item.duration }}</text></view
|
||||
><view
|
||||
v-for="item in packages"
|
||||
:key="item.id"
|
||||
class="package-card"
|
||||
><image
|
||||
class="package-courtyard"
|
||||
src="/static/assets/modules/profile/opaque/m09-featured-courtyard.png"
|
||||
mode="aspectFill"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<view class="package-heading">
|
||||
<view>
|
||||
<text>{{ item.name }}</text>
|
||||
<text v-if="item.description">{{ item.description }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="package-meta"
|
||||
><text v-if="item.price" class="package-price">¥{{ item.price }}</text
|
||||
><text class="package-duration">{{ item.duration || "服务期限以订单为准" }}</text
|
||||
><button
|
||||
class="purchase-button"
|
||||
:disabled="creating"
|
||||
@click="openPurchase(item)"
|
||||
>立即开通</button
|
||||
></view
|
||||
>
|
||||
</view
|
||||
></view
|
||||
>
|
||||
<view v-else class="state-card"
|
||||
@@ -32,22 +66,46 @@
|
||||
<view class="section-heading"
|
||||
><text>订单记录</text><text>{{ orders.length }} 条</text></view
|
||||
>
|
||||
<text v-if="orderNotice" class="order-notice">{{ orderNotice }}</text>
|
||||
<view v-if="orders.length" class="order-list"
|
||||
><view v-for="item in orders" :key="item.id" class="order-card"
|
||||
><text>{{ item.title }}</text
|
||||
><text>{{ item.status }}</text></view
|
||||
><view><text>{{ item.title }}</text><text>订单编号已由服务端生成</text></view
|
||||
><text>{{ item.status || "状态待服务端确认" }}</text></view
|
||||
></view
|
||||
>
|
||||
<view v-else class="order-empty"><text>暂无订单记录</text></view>
|
||||
</template>
|
||||
</view>
|
||||
<AppDialog
|
||||
class="payment-sheet"
|
||||
:visible="Boolean(selectedPackage)"
|
||||
title="确认下单"
|
||||
:confirm-text="creating ? '正在创建订单' : '创建订单'"
|
||||
cancel-text="暂不购买"
|
||||
show-cancel
|
||||
:close-on-mask="!creating"
|
||||
@confirm="createOrder"
|
||||
@cancel="closePurchase"
|
||||
>
|
||||
<view class="sheet-order-summary"
|
||||
><view
|
||||
><text>套餐</text><text>{{ selectedPackage?.name }}</text></view
|
||||
><view v-if="selectedPackage?.duration"
|
||||
><text>有效期</text><text>{{ selectedPackage.duration }}</text></view
|
||||
><view><text>应付金额</text><text>¥{{ selectedPackage?.price }}</text></view></view
|
||||
>
|
||||
<view class="payment-method"><text>支付方式</text><text>微信支付</text></view>
|
||||
<text class="payment-copy">将创建微信支付订单,支付完成后请以订单状态为准。</text>
|
||||
<text v-if="purchaseError" class="purchase-error">{{ purchaseError }}</text>
|
||||
</AppDialog>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from "vue";
|
||||
import { onBackPress, onLoad, onShow, onUnload } from "@dcloudio/uni-app";
|
||||
import { onBackPress, onLoad, onReady, onShow, onUnload } 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";
|
||||
@@ -61,8 +119,13 @@ import { handleBackPress, runBackGuard } from "@/utils/navigation.js";
|
||||
const packages = ref([]);
|
||||
const orders = ref([]);
|
||||
const state = ref("loading");
|
||||
const selectedPackage = ref(null);
|
||||
const creating = ref(false);
|
||||
const purchaseError = ref("");
|
||||
const orderNotice = ref("");
|
||||
const controller = createRequestController();
|
||||
let active = true;
|
||||
let statusBarTimer = null;
|
||||
const formatPrice = (value) =>
|
||||
Number.isFinite(Number(value)) ? Number(value).toFixed(2) : "";
|
||||
const durationUnits = Object.freeze({ year: "年", month: "个月", day: "天" });
|
||||
@@ -107,14 +170,67 @@ const loadVip = async () => {
|
||||
state.value = "error";
|
||||
}
|
||||
};
|
||||
const openPurchase = (item) => {
|
||||
if (creating.value || !item?.id) return;
|
||||
purchaseError.value = "";
|
||||
selectedPackage.value = item;
|
||||
};
|
||||
const closePurchase = () => {
|
||||
if (creating.value) return;
|
||||
selectedPackage.value = null;
|
||||
purchaseError.value = "";
|
||||
};
|
||||
const createOrder = async () => {
|
||||
const item = selectedPackage.value;
|
||||
if (!item?.id || creating.value) return;
|
||||
creating.value = true;
|
||||
purchaseError.value = "";
|
||||
try {
|
||||
await appApi.createVipOrder(
|
||||
{ packageId: item.id, payType: "wechat" },
|
||||
{ requestController: controller },
|
||||
);
|
||||
if (!active) return;
|
||||
selectedPackage.value = null;
|
||||
orderNotice.value = `“${item.name}”订单已创建,并已从服务端重新读取订单状态。`;
|
||||
await loadVip();
|
||||
} catch (error) {
|
||||
if (!active || isRequestCancelled(error)) return;
|
||||
purchaseError.value = error?.message || "订单创建失败,请稍后重试。";
|
||||
} finally {
|
||||
creating.value = false;
|
||||
}
|
||||
};
|
||||
const requestBack = () => runBackGuard({});
|
||||
onLoad(loadVip);
|
||||
const setVipStatusBar = (style, color) => {
|
||||
const navigator = globalThis.plus?.navigator;
|
||||
if (!navigator) return;
|
||||
const apply = () => {
|
||||
navigator.setStatusBarStyle(style);
|
||||
navigator.setStatusBarBackground(color);
|
||||
};
|
||||
apply();
|
||||
if (statusBarTimer) clearTimeout(statusBarTimer);
|
||||
statusBarTimer = setTimeout(apply, 80);
|
||||
};
|
||||
onLoad(() => {
|
||||
setVipStatusBar("dark", "#f4ebde");
|
||||
loadVip();
|
||||
});
|
||||
onReady(() => setVipStatusBar("dark", "#f4ebde"));
|
||||
onShow(() => {
|
||||
setVipStatusBar("dark", "#f4ebde");
|
||||
if (state.value !== "loading") loadVip();
|
||||
});
|
||||
onUnload(() => {
|
||||
active = false;
|
||||
controller.abort();
|
||||
if (statusBarTimer) clearTimeout(statusBarTimer);
|
||||
const navigator = globalThis.plus?.navigator;
|
||||
if (navigator) {
|
||||
navigator.setStatusBarStyle("light");
|
||||
navigator.setStatusBarBackground("#b52e22");
|
||||
}
|
||||
});
|
||||
onBackPress((event) => handleBackPress(event, requestBack));
|
||||
</script>
|
||||
@@ -125,40 +241,61 @@ onBackPress((event) => handleBackPress(event, requestBack));
|
||||
display: flex;
|
||||
min-height: 100vh;
|
||||
flex-direction: column;
|
||||
background: $paper;
|
||||
background: #f7f2e9;
|
||||
}
|
||||
.page-layer {
|
||||
z-index: 1;
|
||||
}
|
||||
.page-content {
|
||||
flex: 1;
|
||||
padding: 28rpx 30rpx 72rpx;
|
||||
padding: 20rpx 30rpx 72rpx;
|
||||
}
|
||||
.service-card,
|
||||
.state-card,
|
||||
.package-card,
|
||||
.order-card,
|
||||
.order-empty {
|
||||
@include adaptive.adaptive-profile-content;
|
||||
}
|
||||
.service-card {
|
||||
min-height: 170rpx;
|
||||
padding: 38rpx 44rpx;
|
||||
.vip-intro {
|
||||
position: relative;
|
||||
min-height: 284rpx;
|
||||
overflow: hidden;
|
||||
margin: -20rpx -30rpx 0;
|
||||
padding: 36rpx 48rpx 30rpx;
|
||||
text-align: center;
|
||||
}
|
||||
.service-card text {
|
||||
.vip-intro__background {
|
||||
position: absolute;
|
||||
z-index: 0;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
opacity: 0.78;
|
||||
pointer-events: none;
|
||||
}
|
||||
.vip-intro__content {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
.vip-intro text {
|
||||
display: block;
|
||||
}
|
||||
.service-card text:first-child {
|
||||
color: $ink;
|
||||
font-family: STKaiti, KaiTi, serif;
|
||||
font-size: 34rpx;
|
||||
.vip-intro__eyebrow {
|
||||
color: $brand-red;
|
||||
font-size: 20rpx;
|
||||
font-weight: 700;
|
||||
letter-spacing: 5rpx;
|
||||
}
|
||||
.service-card text:last-child {
|
||||
margin-top: 12rpx;
|
||||
.vip-intro__title {
|
||||
margin-top: 16rpx;
|
||||
color: #33261d;
|
||||
font-family: STKaiti, KaiTi, serif;
|
||||
font-size: 38rpx;
|
||||
font-weight: 700;
|
||||
letter-spacing: 1rpx;
|
||||
}
|
||||
.vip-intro__copy {
|
||||
margin-top: 14rpx;
|
||||
color: $ink-muted;
|
||||
font-size: 21rpx;
|
||||
font-size: 23rpx;
|
||||
line-height: 1.55;
|
||||
}
|
||||
.state-card {
|
||||
@@ -181,55 +318,138 @@ onBackPress((event) => handleBackPress(event, requestBack));
|
||||
.order-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14rpx;
|
||||
margin-top: 18rpx;
|
||||
gap: 18rpx;
|
||||
}
|
||||
.package-card {
|
||||
padding: 28rpx 32rpx;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
padding: 38rpx 36rpx 32rpx;
|
||||
border: 1rpx solid rgba(159, 23, 15, 0.52);
|
||||
border-radius: 26rpx;
|
||||
background: rgba(255, 253, 248, 0.95);
|
||||
box-shadow: 0 16rpx 32rpx rgba(102, 48, 33, 0.12);
|
||||
}
|
||||
.package-heading {
|
||||
.package-courtyard {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
z-index: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
opacity: 0.38;
|
||||
pointer-events: none;
|
||||
}
|
||||
.package-card > :not(.package-courtyard) {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
.package-heading > view {
|
||||
min-width: 0;
|
||||
}
|
||||
.package-heading > view text {
|
||||
display: block;
|
||||
}
|
||||
.package-heading > view text:first-child {
|
||||
color: #302319;
|
||||
font-family: STKaiti, KaiTi, serif;
|
||||
font-size: 36rpx;
|
||||
font-weight: 700;
|
||||
}
|
||||
.package-heading > view text:last-child {
|
||||
margin-top: 10rpx;
|
||||
color: #77675a;
|
||||
font-size: 23rpx;
|
||||
line-height: 1.55;
|
||||
}
|
||||
.package-price {
|
||||
color: $brand-red;
|
||||
font-family: Georgia, STKaiti, serif;
|
||||
font-size: 38rpx;
|
||||
font-weight: 700;
|
||||
line-height: 1.1;
|
||||
}
|
||||
.package-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16rpx;
|
||||
color: $ink;
|
||||
font-size: 27rpx;
|
||||
gap: 20rpx;
|
||||
margin-top: 30rpx;
|
||||
padding-top: 22rpx;
|
||||
border-top: 1rpx solid rgba(117, 84, 52, 0.14);
|
||||
}
|
||||
.package-duration {
|
||||
color: #837164;
|
||||
font-size: 23rpx;
|
||||
}
|
||||
.purchase-button {
|
||||
display: inline-flex;
|
||||
min-width: 184rpx;
|
||||
min-height: 70rpx;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0 28rpx;
|
||||
border: 0;
|
||||
border-radius: 999rpx;
|
||||
background: $brand-red;
|
||||
color: #fffaf1;
|
||||
font-size: 24rpx;
|
||||
font-weight: 700;
|
||||
letter-spacing: 1rpx;
|
||||
line-height: normal;
|
||||
}
|
||||
.package-heading text:last-child {
|
||||
flex: 0 0 auto;
|
||||
color: $brand-red;
|
||||
.purchase-button::after {
|
||||
border: 0;
|
||||
}
|
||||
.package-card > text {
|
||||
display: block;
|
||||
margin-top: 10rpx;
|
||||
color: $ink-muted;
|
||||
font-size: 21rpx;
|
||||
line-height: 1.5;
|
||||
.purchase-button[disabled] {
|
||||
opacity: 0.5;
|
||||
}
|
||||
.section-heading {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin: 28rpx 6rpx 12rpx;
|
||||
color: $ink;
|
||||
font-size: 27rpx;
|
||||
margin: 44rpx 6rpx 16rpx;
|
||||
color: #38281d;
|
||||
font-family: STKaiti, KaiTi, serif;
|
||||
font-size: 32rpx;
|
||||
font-weight: 700;
|
||||
}
|
||||
.section-heading text:last-child {
|
||||
color: $ink-muted;
|
||||
color: #8a796b;
|
||||
font-size: 21rpx;
|
||||
font-weight: 400;
|
||||
}
|
||||
.order-notice {
|
||||
display: block;
|
||||
margin: 0 6rpx 14rpx;
|
||||
color: #786556;
|
||||
font-size: 21rpx;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.order-card {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 18rpx;
|
||||
padding: 26rpx 32rpx;
|
||||
color: $ink;
|
||||
padding: 26rpx 4rpx;
|
||||
border-bottom: 1rpx solid rgba(117, 84, 52, 0.14);
|
||||
color: #48372a;
|
||||
font-size: 24rpx;
|
||||
}
|
||||
.order-card > view text {
|
||||
display: block;
|
||||
}
|
||||
.order-card > view text:first-child {
|
||||
font-weight: 700;
|
||||
}
|
||||
.order-card > view text:last-child {
|
||||
margin-top: 8rpx;
|
||||
color: #8a796b;
|
||||
font-size: 20rpx;
|
||||
}
|
||||
.order-card text:last-child {
|
||||
color: $ink-muted;
|
||||
flex: 0 0 auto;
|
||||
color: #7f6959;
|
||||
font-size: 21rpx;
|
||||
}
|
||||
.order-empty {
|
||||
padding: 38rpx;
|
||||
@@ -237,10 +457,158 @@ onBackPress((event) => handleBackPress(event, requestBack));
|
||||
color: $ink-muted;
|
||||
font-size: 23rpx;
|
||||
}
|
||||
:deep(.orders-header .page-header) {
|
||||
height: calc(104rpx + var(--status-bar-height, 0px));
|
||||
border-bottom: 1rpx solid rgba(117, 84, 52, 0.08);
|
||||
background: #f4ebde;
|
||||
}
|
||||
:deep(.orders-header.page-header-slot) {
|
||||
height: calc(104rpx + var(--status-bar-height, 0px));
|
||||
}
|
||||
:deep(.orders-header .header-title) {
|
||||
color: #302319;
|
||||
font-family: STKaiti, KaiTi, serif;
|
||||
font-size: 37rpx;
|
||||
}
|
||||
:deep(.orders-header .header-back__icon) {
|
||||
filter: none;
|
||||
opacity: 0.78;
|
||||
}
|
||||
:deep(.payment-sheet.app-dialog-layer) {
|
||||
align-items: flex-end;
|
||||
padding: 0;
|
||||
background: rgba(37, 26, 18, 0.38);
|
||||
}
|
||||
:deep(.payment-sheet .app-dialog) {
|
||||
width: 100%;
|
||||
max-width: none;
|
||||
max-height: 76vh;
|
||||
border: 0;
|
||||
border-radius: 38rpx 38rpx 0 0;
|
||||
background: #fffdf9;
|
||||
box-shadow: 0 -12rpx 38rpx rgba(50, 33, 20, 0.16);
|
||||
}
|
||||
:deep(.payment-sheet .app-dialog__content) {
|
||||
align-items: stretch;
|
||||
padding: 34rpx 42rpx calc(30rpx + env(safe-area-inset-bottom));
|
||||
text-align: left;
|
||||
}
|
||||
:deep(.payment-sheet .app-dialog__content::before) {
|
||||
display: block;
|
||||
width: 64rpx;
|
||||
height: 8rpx;
|
||||
align-self: center;
|
||||
margin-bottom: 24rpx;
|
||||
border-radius: 999rpx;
|
||||
background: rgba(76, 58, 45, 0.22);
|
||||
content: "";
|
||||
}
|
||||
:deep(.payment-sheet .app-dialog__copy) {
|
||||
align-items: flex-start;
|
||||
}
|
||||
:deep(.payment-sheet .app-dialog__title) {
|
||||
color: #302319;
|
||||
font-family: STKaiti, KaiTi, serif;
|
||||
font-size: 42rpx;
|
||||
letter-spacing: 1rpx;
|
||||
text-align: left;
|
||||
}
|
||||
:deep(.payment-sheet .app-dialog__actions) {
|
||||
display: flex;
|
||||
flex-direction: column-reverse;
|
||||
gap: 4rpx;
|
||||
margin-top: 28rpx;
|
||||
}
|
||||
:deep(.payment-sheet .app-button) {
|
||||
width: 100%;
|
||||
min-height: 88rpx;
|
||||
}
|
||||
:deep(.payment-sheet .app-button__skin) {
|
||||
display: none;
|
||||
}
|
||||
:deep(.payment-sheet .app-button--primary) {
|
||||
border-radius: 999rpx;
|
||||
background: $brand-red;
|
||||
}
|
||||
:deep(.payment-sheet .app-button--secondary) {
|
||||
min-height: 60rpx;
|
||||
background: transparent;
|
||||
}
|
||||
:deep(.payment-sheet .app-button--secondary .app-button__label) {
|
||||
color: #8a796b;
|
||||
font-size: 24rpx;
|
||||
font-weight: 400;
|
||||
}
|
||||
.sheet-order-summary {
|
||||
margin-top: 24rpx;
|
||||
overflow: hidden;
|
||||
border: 1rpx solid rgba(117, 84, 52, 0.12);
|
||||
border-radius: 20rpx;
|
||||
background: #fffaf4;
|
||||
}
|
||||
.sheet-order-summary > view,
|
||||
.payment-method {
|
||||
display: flex;
|
||||
min-height: 82rpx;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 24rpx;
|
||||
padding: 0 24rpx;
|
||||
border-bottom: 1rpx solid rgba(117, 84, 52, 0.1);
|
||||
color: #5a4432;
|
||||
font-size: 24rpx;
|
||||
}
|
||||
.sheet-order-summary > view:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
.sheet-order-summary > view text:last-child,
|
||||
.payment-method text:last-child {
|
||||
color: #302319;
|
||||
font-weight: 700;
|
||||
text-align: right;
|
||||
}
|
||||
.sheet-order-summary > view:last-child text:last-child {
|
||||
color: $brand-red;
|
||||
font-family: Georgia, STKaiti, serif;
|
||||
font-size: 34rpx;
|
||||
}
|
||||
.payment-method {
|
||||
width: 100%;
|
||||
min-height: 88rpx;
|
||||
box-sizing: border-box;
|
||||
margin-top: 16rpx;
|
||||
border: 1rpx solid rgba(117, 84, 52, 0.12);
|
||||
border-radius: 20rpx;
|
||||
background: #fffaf4;
|
||||
}
|
||||
.payment-method text:last-child {
|
||||
color: $brand-red;
|
||||
}
|
||||
.payment-copy,
|
||||
.purchase-error {
|
||||
display: block;
|
||||
width: 100%;
|
||||
margin-top: 16rpx;
|
||||
color: #8a796b;
|
||||
font-size: 20rpx;
|
||||
line-height: 1.55;
|
||||
}
|
||||
.purchase-error {
|
||||
color: $brand-red;
|
||||
}
|
||||
@media (max-width: 340px) {
|
||||
.page-content {
|
||||
padding-right: 22rpx;
|
||||
padding-left: 22rpx;
|
||||
}
|
||||
.package-card {
|
||||
padding-right: 28rpx;
|
||||
padding-left: 28rpx;
|
||||
}
|
||||
.purchase-button {
|
||||
min-width: 166rpx;
|
||||
padding-right: 20rpx;
|
||||
padding-left: 20rpx;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
>
|
||||
<ModulePageBackground module="tree" />
|
||||
<view class="tree-page__header">
|
||||
<PageHeader title="世系树" action="成员目录" @action="toDirectory" />
|
||||
<PageHeader title="树状图" action="世系谱" @action="toPedigree" />
|
||||
</view>
|
||||
|
||||
<view v-if="treeState !== 'loading'" class="tree-toolbar">
|
||||
@@ -782,6 +782,8 @@ const toMember = () => {
|
||||
};
|
||||
const toDirectory = () =>
|
||||
openPage("T07", { genealogyId: genealogyId.value }, "T01");
|
||||
const toPedigree = () =>
|
||||
openPage("T02", { genealogyId: genealogyId.value }, "T01");
|
||||
const toRank = () =>
|
||||
selected.value
|
||||
? openPage(
|
||||
|
||||
@@ -0,0 +1,586 @@
|
||||
<!-- 页面编号:T-02;用途:世系谱表阅读、同世成员横向滑页。 -->
|
||||
<template>
|
||||
<view class="pedigree-page">
|
||||
<ModulePageBackground module="tree" />
|
||||
<view class="pedigree-page__header">
|
||||
<PageHeader title="世系谱" action="树状图" @action="toTree" />
|
||||
</view>
|
||||
|
||||
<view class="pedigree-stage">
|
||||
<AppLoading
|
||||
v-if="treeState === 'loading'"
|
||||
text="正在整理世系谱"
|
||||
description="请稍候,正在准备家谱成员关系。"
|
||||
/>
|
||||
|
||||
<template v-else-if="treeState === 'tree'">
|
||||
<view class="pedigree-layout">
|
||||
<swiper
|
||||
class="pedigree-swiper"
|
||||
:current="pedigreePageIndex"
|
||||
:duration="260"
|
||||
:disable-touch="false"
|
||||
@change="handlePedigreePageChange"
|
||||
>
|
||||
<swiper-item
|
||||
v-for="(page, pageIndex) in pedigreePages"
|
||||
:key="`generation-${page.generation}-page-${pageIndex}`"
|
||||
>
|
||||
<scroll-view
|
||||
class="pedigree-scroll"
|
||||
scroll-y
|
||||
:show-scrollbar="false"
|
||||
:style="pedigreeScrollStyle"
|
||||
>
|
||||
<view class="pedigree-sheet" :style="pedigreeSheetStyle">
|
||||
<view class="pedigree-column pedigree-column--legend">
|
||||
<view class="pedigree-column__relation"><text>身份</text></view>
|
||||
<view class="pedigree-column__name"><text>{{ page.label }}</text></view>
|
||||
<view class="pedigree-column__detail"><text>{{ page.summary }}</text></view>
|
||||
</view>
|
||||
|
||||
<view
|
||||
v-for="(member, memberIndex) in page.members"
|
||||
:key="member?.id || `empty-${pageIndex}-${memberIndex}`"
|
||||
class="member-node"
|
||||
:class="{
|
||||
'member-node--blank': !member,
|
||||
'member-node--selected': selectedId === String(member?.id || ''),
|
||||
'member-node--spouse': Boolean(member?.spouseOf),
|
||||
}"
|
||||
>
|
||||
<view v-if="member" class="member-node__surface">
|
||||
<view class="member-node__relation"><text>{{ memberRelation(member) }}</text></view>
|
||||
<view
|
||||
class="member-node__name"
|
||||
role="button"
|
||||
:aria-label="`查看${member.name}的资料`"
|
||||
@click.stop="openMemberProfile(member)"
|
||||
><text>{{ member.name }}</text></view>
|
||||
<view
|
||||
class="member-node__copy"
|
||||
role="button"
|
||||
:aria-label="`查看${member.name}的生平详情`"
|
||||
@click.stop="openMemberDetail(member)"
|
||||
>
|
||||
<text class="node-relation">{{ member.branch }}</text>
|
||||
<text class="node-years">{{ memberDetail(member) }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<view v-else class="member-node__surface">
|
||||
<view class="member-node__relation"></view>
|
||||
<view class="member-node__name"></view>
|
||||
<view class="member-node__copy"></view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
</swiper-item>
|
||||
</swiper>
|
||||
|
||||
<view class="generation-rail" :style="pedigreeSheetStyle">
|
||||
<view class="generation-band">
|
||||
<image
|
||||
class="generation-band__arrow generation-band__arrow--previous"
|
||||
src="/static/assets/foundation/transparent/chevron-right.png"
|
||||
mode="aspectFit"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<view class="generation-band__copy">
|
||||
<text>{{ activePage?.label || '未定世代' }}</text>
|
||||
<text>{{ activePage?.memberCount || 0 }} 位成员</text>
|
||||
</view>
|
||||
<image
|
||||
class="generation-band__arrow"
|
||||
src="/static/assets/foundation/transparent/chevron-right.png"
|
||||
mode="aspectFit"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<view v-else class="pedigree-state-card">
|
||||
<image
|
||||
class="pedigree-state-card__skin"
|
||||
src="/static/assets/modules/tree/transparent/t01-state-panel.png"
|
||||
mode="scaleToFill"
|
||||
/>
|
||||
<view class="pedigree-state-card__content">
|
||||
<text class="pedigree-state-card__eyebrow">{{ stateCopy.eyebrow }}</text>
|
||||
<text class="pedigree-state-card__title">{{ stateCopy.title }}</text>
|
||||
<text class="pedigree-state-card__copy">{{ stateCopy.copy }}</text>
|
||||
<view class="pedigree-state-card__action" @click="handleStateAction">
|
||||
<image
|
||||
src="/static/assets/foundation/transparent/a01-scroll-primary-v3.png"
|
||||
mode="aspectFit"
|
||||
/>
|
||||
<text>{{ stateCopy.action }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<AppDialog
|
||||
:visible="detailVisible"
|
||||
eyebrow="人物生平"
|
||||
:title="detailMember?.name || '人物详情'"
|
||||
:message="detailMessage"
|
||||
confirm-text="关闭"
|
||||
@confirm="closeMemberDetail"
|
||||
@cancel="closeMemberDetail"
|
||||
/>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref } from "vue";
|
||||
import { onLoad, onShow, onUnload } from "@dcloudio/uni-app";
|
||||
import AppDialog from "@/components/AppDialog.vue";
|
||||
import AppLoading from "@/components/AppLoading.vue";
|
||||
import ModulePageBackground from "@/components/ModulePageBackground.vue";
|
||||
import PageHeader from "@/components/PageHeader.vue";
|
||||
import {
|
||||
appApi,
|
||||
createRequestController,
|
||||
isRequestCancelled,
|
||||
} from "@/utils/api.js";
|
||||
import { genealogyContext } from "@/utils/genealogy-context.js";
|
||||
import { openPage } from "@/utils/navigation.js";
|
||||
|
||||
const PEDIGREE_PAGE_SIZE = 5;
|
||||
const genealogyId = ref("");
|
||||
const treeState = ref("loading");
|
||||
const treeLoadError = ref("");
|
||||
const members = ref([]);
|
||||
const selectedId = ref("");
|
||||
const pedigreePageIndex = ref(0);
|
||||
const detailVisible = ref(false);
|
||||
const detailMember = ref(null);
|
||||
const detailMessage = ref("");
|
||||
const pedigreeRequestController = createRequestController();
|
||||
const detailRequestController = createRequestController();
|
||||
let loadGeneration = 0;
|
||||
let detailLoadGeneration = 0;
|
||||
let pageActive = true;
|
||||
let skipNextShowRefresh = true;
|
||||
|
||||
const compareMembers = (left, right) =>
|
||||
Number(left.generation) - Number(right.generation) ||
|
||||
String(left.id).localeCompare(String(right.id));
|
||||
const generationRows = computed(() => {
|
||||
const groups = new Map();
|
||||
members.value.forEach((member) => {
|
||||
const generation = Number(member.generation) || 0;
|
||||
const group = groups.get(generation) || [];
|
||||
group.push(member);
|
||||
groups.set(generation, group);
|
||||
});
|
||||
return Array.from(groups.entries())
|
||||
.sort(([left], [right]) => left - right)
|
||||
.map(([generation, group]) => ({
|
||||
generation,
|
||||
label: generation ? `第 ${generation} 世` : "未定世代",
|
||||
members: group.sort(compareMembers),
|
||||
}));
|
||||
});
|
||||
const pedigreePages = computed(() => {
|
||||
const pages = [];
|
||||
generationRows.value.forEach((row) => {
|
||||
const branch = row.members.find((member) => member.branch)?.branch;
|
||||
for (let index = 0; index < row.members.length; index += PEDIGREE_PAGE_SIZE) {
|
||||
const pageMembers = row.members.slice(index, index + PEDIGREE_PAGE_SIZE);
|
||||
while (pageMembers.length < PEDIGREE_PAGE_SIZE) pageMembers.push(null);
|
||||
pages.push({
|
||||
generation: row.generation,
|
||||
label: row.label,
|
||||
memberCount: row.members.length,
|
||||
summary: [branch, `${row.members.length} 位成员`]
|
||||
.filter(Boolean)
|
||||
.join(" · "),
|
||||
members: pageMembers,
|
||||
});
|
||||
}
|
||||
});
|
||||
return pages;
|
||||
});
|
||||
const activePage = computed(
|
||||
() => pedigreePages.value[pedigreePageIndex.value] || null,
|
||||
);
|
||||
const detailLength = computed(() =>
|
||||
Math.max(
|
||||
0,
|
||||
...members.value.map((member) =>
|
||||
Array.from(`${member.branch || ""}${memberDetail(member)}`).length,
|
||||
),
|
||||
),
|
||||
);
|
||||
const sheetHeight = computed(() => Math.max(1100, 720 + detailLength.value * 20));
|
||||
const pedigreeSheetStyle = computed(() => ({
|
||||
height: `${sheetHeight.value}rpx`,
|
||||
}));
|
||||
const pedigreeScrollStyle = computed(() => ({
|
||||
height: `${sheetHeight.value}rpx`,
|
||||
}));
|
||||
const stateCopy = computed(
|
||||
() =>
|
||||
({
|
||||
landscape: {
|
||||
eyebrow: "世系谱阅读提示",
|
||||
title: "左右滑动查看成员",
|
||||
copy: "同一世代每页展示五位成员,继续向左滑动即可查看下一页。",
|
||||
action: "返回树状图",
|
||||
},
|
||||
empty: {
|
||||
eyebrow: "尚未建立世系",
|
||||
title: "从第一位先祖开始记录",
|
||||
copy: "录入首代成员后,即可在世系树与世系谱中查看关系。",
|
||||
action: "添加首位成员",
|
||||
},
|
||||
error: {
|
||||
eyebrow: "世系谱读取失败",
|
||||
title: "暂时无法展示家谱成员",
|
||||
copy: treeLoadError.value || "请稍后重试。",
|
||||
action: "重新查看",
|
||||
},
|
||||
})[treeState.value] || {},
|
||||
);
|
||||
|
||||
const handlePedigreePageChange = (event) => {
|
||||
pedigreePageIndex.value = Number(event?.detail?.current || 0);
|
||||
};
|
||||
const memberRelation = (member) =>
|
||||
member.spouseOf ? "配偶" : member.relation || "家谱成员";
|
||||
const memberDetail = (member) =>
|
||||
[member.years, member.sex, member.personStatus]
|
||||
.filter((item) => item && item !== "生卒待补")
|
||||
.join(" · ") || "生卒待补";
|
||||
const openMemberProfile = (member) =>
|
||||
openPage(
|
||||
"T03",
|
||||
{ genealogyId: genealogyId.value, personId: String(member.id) },
|
||||
"T02",
|
||||
);
|
||||
const closeMemberDetail = () => {
|
||||
detailVisible.value = false;
|
||||
detailRequestController.abort();
|
||||
};
|
||||
const openMemberDetail = async (member) => {
|
||||
if (!member) return;
|
||||
const generation = ++detailLoadGeneration;
|
||||
detailRequestController.abort();
|
||||
detailMember.value = member;
|
||||
detailMessage.value = "正在读取人物生平…";
|
||||
detailVisible.value = true;
|
||||
try {
|
||||
const detail = await appApi.getPerson(genealogyId.value, member.id, {
|
||||
requestController: detailRequestController,
|
||||
});
|
||||
if (generation !== detailLoadGeneration || !detailVisible.value) return;
|
||||
detailMember.value = detail;
|
||||
detailMessage.value =
|
||||
[detail.biography, detail.remark].filter(Boolean).join("\n\n") ||
|
||||
"暂未填写生平说明。";
|
||||
} catch (error) {
|
||||
if (isRequestCancelled(error) || generation !== detailLoadGeneration) return;
|
||||
detailMessage.value = error?.message || "人物生平读取失败,请稍后重试。";
|
||||
}
|
||||
};
|
||||
const toTree = () => {
|
||||
const params = { genealogyId: genealogyId.value };
|
||||
if (selectedId.value) params.selectedId = selectedId.value;
|
||||
return openPage("T01", params, "T02");
|
||||
};
|
||||
const handleStateAction = () => {
|
||||
if (treeState.value === "landscape") return toTree();
|
||||
if (treeState.value === "empty") {
|
||||
return openPage(
|
||||
"T04",
|
||||
{ genealogyId: genealogyId.value, mode: "first" },
|
||||
"T02",
|
||||
);
|
||||
}
|
||||
return loadPedigree({ genealogyId: genealogyId.value });
|
||||
};
|
||||
|
||||
const loadPedigree = async (query = {}) => {
|
||||
const generation = ++loadGeneration;
|
||||
pedigreeRequestController.abort();
|
||||
treeLoadError.value = "";
|
||||
genealogyId.value = String(
|
||||
query.genealogyId || genealogyContext.getCurrentGenealogyId() || "",
|
||||
);
|
||||
selectedId.value = String(query.selectedId || "");
|
||||
if (!genealogyId.value) {
|
||||
members.value = [];
|
||||
treeState.value = "empty";
|
||||
return;
|
||||
}
|
||||
if (["landscape", "empty", "error"].includes(query.state)) {
|
||||
treeState.value = query.state;
|
||||
return;
|
||||
}
|
||||
treeState.value = "loading";
|
||||
try {
|
||||
const result = await appApi.getTree(genealogyId.value, {
|
||||
requestController: pedigreeRequestController,
|
||||
});
|
||||
if (!pageActive || generation !== loadGeneration) return;
|
||||
members.value = result;
|
||||
genealogyContext.setCurrentGenealogyId(genealogyId.value);
|
||||
const selectedPage = pedigreePages.value.findIndex((page) =>
|
||||
page.members.some((member) => String(member?.id) === selectedId.value),
|
||||
);
|
||||
pedigreePageIndex.value = selectedPage < 0 ? 0 : selectedPage;
|
||||
treeState.value = members.value.length ? "tree" : "empty";
|
||||
} catch (error) {
|
||||
if (!pageActive || generation !== loadGeneration || isRequestCancelled(error)) {
|
||||
return;
|
||||
}
|
||||
members.value = [];
|
||||
treeLoadError.value = error?.message || "世系谱读取失败,请稍后重试。";
|
||||
treeState.value = "error";
|
||||
}
|
||||
};
|
||||
|
||||
onLoad(loadPedigree);
|
||||
onShow(() => {
|
||||
if (skipNextShowRefresh) {
|
||||
skipNextShowRefresh = false;
|
||||
return;
|
||||
}
|
||||
if (genealogyId.value) loadPedigree({ genealogyId: genealogyId.value });
|
||||
});
|
||||
onUnload(() => {
|
||||
pageActive = false;
|
||||
loadGeneration += 1;
|
||||
detailLoadGeneration += 1;
|
||||
pedigreeRequestController.abort();
|
||||
detailRequestController.abort();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.pedigree-page {
|
||||
display: grid;
|
||||
min-height: 100vh;
|
||||
grid-template-rows: auto minmax(0, 1fr);
|
||||
overflow: hidden;
|
||||
background: $paper;
|
||||
}
|
||||
.pedigree-page__header,
|
||||
.pedigree-stage {
|
||||
z-index: 1;
|
||||
}
|
||||
.pedigree-stage {
|
||||
min-height: 0;
|
||||
}
|
||||
.pedigree-layout {
|
||||
display: grid;
|
||||
height: 100%;
|
||||
grid-template-columns: minmax(0, 1fr) 92rpx;
|
||||
background: #fbfbfb;
|
||||
}
|
||||
.pedigree-swiper {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
.pedigree-scroll {
|
||||
width: 100%;
|
||||
}
|
||||
.pedigree-sheet {
|
||||
display: grid;
|
||||
width: 100%;
|
||||
min-height: 100%;
|
||||
grid-template-columns: repeat(6, minmax(0, 1fr));
|
||||
align-items: stretch;
|
||||
border-top: 1rpx solid #e2e2e2;
|
||||
border-bottom: 1rpx solid #e2e2e2;
|
||||
background: #fbfbfb;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.pedigree-column,
|
||||
.member-node {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
min-height: 100%;
|
||||
grid-template-rows: 144rpx 274rpx minmax(0, 1fr);
|
||||
border-right: 1rpx solid #e2e2e2;
|
||||
box-sizing: border-box;
|
||||
color: #38312d;
|
||||
}
|
||||
.pedigree-column--legend {
|
||||
border-left: 1rpx solid #e2e2e2;
|
||||
background: #f6f6f6;
|
||||
color: #665d56;
|
||||
}
|
||||
.pedigree-column__relation,
|
||||
.pedigree-column__name,
|
||||
.pedigree-column__detail,
|
||||
.member-node__relation,
|
||||
.member-node__name,
|
||||
.member-node__copy {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
box-sizing: border-box;
|
||||
writing-mode: vertical-rl;
|
||||
text-orientation: upright;
|
||||
}
|
||||
.pedigree-column__relation,
|
||||
.member-node__relation {
|
||||
align-items: center;
|
||||
border-bottom: 1rpx solid #e2e2e2;
|
||||
font-size: 22rpx;
|
||||
}
|
||||
.pedigree-column__name,
|
||||
.member-node__name {
|
||||
align-items: center;
|
||||
border-bottom: 1rpx solid #e2e2e2;
|
||||
color: #2f2925;
|
||||
font-family: "STKaiti", "KaiTi", serif;
|
||||
font-size: 34rpx;
|
||||
font-weight: 700;
|
||||
}
|
||||
.pedigree-column__detail,
|
||||
.member-node__copy {
|
||||
align-items: flex-start;
|
||||
gap: 18rpx;
|
||||
padding: 28rpx 20rpx;
|
||||
font-size: 21rpx;
|
||||
line-height: 1.7;
|
||||
white-space: normal;
|
||||
}
|
||||
.pedigree-column__detail {
|
||||
font-size: 20rpx;
|
||||
}
|
||||
.member-node__surface {
|
||||
display: contents;
|
||||
}
|
||||
.member-node--blank {
|
||||
pointer-events: none;
|
||||
}
|
||||
.member-node--selected .member-node__relation,
|
||||
.member-node--selected .member-node__name,
|
||||
.member-node--selected .member-node__copy {
|
||||
background: rgba(181, 46, 34, 0.06);
|
||||
}
|
||||
.member-node--selected .member-node__name {
|
||||
color: #a7241d;
|
||||
}
|
||||
.member-node--spouse .member-node__relation {
|
||||
color: #7a4d43;
|
||||
}
|
||||
.node-relation {
|
||||
color: #675f57;
|
||||
}
|
||||
.node-years {
|
||||
color: #3f3934;
|
||||
}
|
||||
.generation-rail {
|
||||
width: 92rpx;
|
||||
border-left: 1rpx solid #e2e2e2;
|
||||
background: rgba(252, 252, 252, 0.96);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.generation-band {
|
||||
display: flex;
|
||||
width: 72rpx;
|
||||
min-height: 250rpx;
|
||||
margin: 250rpx auto 0;
|
||||
padding: 12rpx 0;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
border: 1rpx solid #b52e22;
|
||||
background: rgba(255, 255, 255, 0.88);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.generation-band__arrow {
|
||||
width: 26rpx;
|
||||
height: 26rpx;
|
||||
filter: sepia(1) saturate(3) hue-rotate(318deg);
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
.generation-band__arrow--previous {
|
||||
transform: rotate(-90deg);
|
||||
}
|
||||
.generation-band__copy {
|
||||
display: flex;
|
||||
gap: 12rpx;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
color: #a7241d;
|
||||
font-size: 21rpx;
|
||||
writing-mode: vertical-rl;
|
||||
text-orientation: upright;
|
||||
}
|
||||
.generation-band__copy text:last-child {
|
||||
font-size: 18rpx;
|
||||
}
|
||||
.pedigree-state-card {
|
||||
display: grid;
|
||||
width: calc(100% - 48rpx);
|
||||
min-height: 360rpx;
|
||||
margin: 150rpx 24rpx 0;
|
||||
text-align: center;
|
||||
}
|
||||
.pedigree-state-card__skin {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
.pedigree-state-card__skin,
|
||||
.pedigree-state-card__content {
|
||||
grid-area: 1 / 1;
|
||||
}
|
||||
.pedigree-state-card__content {
|
||||
z-index: 1;
|
||||
padding: 62rpx 58rpx 42rpx;
|
||||
}
|
||||
.pedigree-state-card__eyebrow,
|
||||
.pedigree-state-card__title,
|
||||
.pedigree-state-card__copy {
|
||||
display: block;
|
||||
}
|
||||
.pedigree-state-card__eyebrow {
|
||||
color: $brand-red;
|
||||
font-size: 22rpx;
|
||||
letter-spacing: 3rpx;
|
||||
}
|
||||
.pedigree-state-card__title {
|
||||
margin-top: 12rpx;
|
||||
color: $ink;
|
||||
font-family: "STKaiti", "KaiTi", serif;
|
||||
font-size: 34rpx;
|
||||
font-weight: 700;
|
||||
}
|
||||
.pedigree-state-card__copy {
|
||||
margin-top: 16rpx;
|
||||
color: $ink-muted;
|
||||
font-size: 24rpx;
|
||||
line-height: 1.65;
|
||||
}
|
||||
.pedigree-state-card__action {
|
||||
display: grid;
|
||||
width: 360rpx;
|
||||
min-height: 70rpx;
|
||||
margin: 22rpx auto 0;
|
||||
}
|
||||
.pedigree-state-card__action image,
|
||||
.pedigree-state-card__action text {
|
||||
grid-area: 1 / 1;
|
||||
}
|
||||
.pedigree-state-card__action image {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
.pedigree-state-card__action text {
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #fff9ed;
|
||||
font-size: 24rpx;
|
||||
font-weight: 700;
|
||||
}
|
||||
</style>
|
||||
@@ -158,69 +158,59 @@ const trailIndex = ref(-1);
|
||||
const memberRequestController = createRequestController();
|
||||
let loadSequence = 0;
|
||||
|
||||
const details = computed(() =>
|
||||
member.value
|
||||
? [
|
||||
{ label: "别名", value: member.value.aliasName },
|
||||
{ label: "字辈", value: member.value.generationName },
|
||||
{ label: "性别(字典值)", value: member.value.sex },
|
||||
{ label: "人物状态(字典值)", value: member.value.personStatus },
|
||||
{
|
||||
label: "出生日期",
|
||||
value:
|
||||
member.value.status === "forbidden"
|
||||
? "按权限隐藏"
|
||||
: member.value.birthDate,
|
||||
},
|
||||
{
|
||||
label: "出生农历",
|
||||
value:
|
||||
member.value.status === "forbidden"
|
||||
? "按权限隐藏"
|
||||
: member.value.birthLunar,
|
||||
},
|
||||
{ label: "生卒信息", value: member.value.years },
|
||||
{
|
||||
label: "出生地",
|
||||
value:
|
||||
member.value.status === "forbidden"
|
||||
? "按权限隐藏"
|
||||
: member.value.birthplace,
|
||||
},
|
||||
{
|
||||
label: "逝世农历",
|
||||
value:
|
||||
member.value.status === "forbidden"
|
||||
? "按权限隐藏"
|
||||
: member.value.deathLunar,
|
||||
},
|
||||
{
|
||||
label: "逝世地",
|
||||
value:
|
||||
member.value.status === "forbidden"
|
||||
? "按权限隐藏"
|
||||
: member.value.deathPlace,
|
||||
},
|
||||
{
|
||||
label: "安葬地",
|
||||
value:
|
||||
member.value.status === "forbidden"
|
||||
? "按权限隐藏"
|
||||
: member.value.burialPlace,
|
||||
},
|
||||
{ label: "配偶", value: member.value.spouseNames },
|
||||
{ label: "所属支系", value: member.value.branch },
|
||||
{
|
||||
label: "生平",
|
||||
value:
|
||||
member.value.status === "forbidden"
|
||||
? "按权限隐藏"
|
||||
: member.value.biography,
|
||||
},
|
||||
{ label: "备注", value: member.value.remark },
|
||||
]
|
||||
: [],
|
||||
);
|
||||
const dictionaryLabel = (value, labels) => labels[String(value || "")] || "";
|
||||
const isDeceased = computed(() => member.value?.personStatus === "1");
|
||||
const details = computed(() => {
|
||||
if (!member.value) return [];
|
||||
const protectedValue = (value) =>
|
||||
member.value.status === "forbidden" ? "按权限隐藏" : value;
|
||||
return [
|
||||
{ label: "别名", value: member.value.aliasName },
|
||||
{ label: "字辈", value: member.value.generationName },
|
||||
{
|
||||
label: "性别",
|
||||
value: dictionaryLabel(member.value.sex, {
|
||||
0: "男",
|
||||
1: "女",
|
||||
2: "未知",
|
||||
}),
|
||||
},
|
||||
{
|
||||
label: "人物状态",
|
||||
value: dictionaryLabel(member.value.personStatus, {
|
||||
0: "健在",
|
||||
1: "已故",
|
||||
2: "未知",
|
||||
}),
|
||||
},
|
||||
{ label: "出生日期", value: protectedValue(member.value.birthDate) },
|
||||
{
|
||||
label: "出生农历",
|
||||
value: protectedValue(
|
||||
dictionaryLabel(member.value.birthLunar, { 0: "否", 1: "是" }),
|
||||
),
|
||||
},
|
||||
{ label: "生卒信息", value: member.value.years },
|
||||
{ label: "出生地", value: protectedValue(member.value.birthplace) },
|
||||
...(isDeceased.value
|
||||
? [
|
||||
{ label: "逝世日期", value: protectedValue(member.value.deathDate) },
|
||||
{
|
||||
label: "逝世农历",
|
||||
value: protectedValue(
|
||||
dictionaryLabel(member.value.deathLunar, { 0: "否", 1: "是" }),
|
||||
),
|
||||
},
|
||||
{ label: "逝世地", value: protectedValue(member.value.deathPlace) },
|
||||
{ label: "安葬地", value: protectedValue(member.value.burialPlace) },
|
||||
]
|
||||
: []),
|
||||
{ label: "配偶", value: member.value.spouseNames },
|
||||
{ label: "所属支系", value: member.value.branch },
|
||||
{ label: "生平", value: protectedValue(member.value.biography) },
|
||||
{ label: "备注", value: member.value.remark },
|
||||
];
|
||||
});
|
||||
const canPreviewEdit = computed(() => memberState.value === "detail");
|
||||
const restrictedCopy = computed(() =>
|
||||
member.value?.status === "forbidden"
|
||||
@@ -377,6 +367,14 @@ onShow(() => {
|
||||
const result = consumeNavigationResult("T03");
|
||||
if (result?.operation === "member-open-requested" && result.entityId) {
|
||||
void openRelative(result.entityId);
|
||||
return;
|
||||
}
|
||||
if (
|
||||
result?.operation === "member-updated" &&
|
||||
result.refresh &&
|
||||
result.entityId === String(personId.value)
|
||||
) {
|
||||
void loadMember(result.entityId, { preserveCurrent: true });
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -53,15 +53,6 @@
|
||||
fieldErrors.name
|
||||
}}</text>
|
||||
|
||||
<view class="form-field">
|
||||
<text>人物编号</text>
|
||||
<input
|
||||
v-model="addForm.personNo"
|
||||
maxlength="40"
|
||||
placeholder="不填则由服务端生成"
|
||||
placeholder-class="form-placeholder"
|
||||
/>
|
||||
</view>
|
||||
<picker
|
||||
:range="sexOptions.map((item) => item.label)"
|
||||
:value="optionIndex(sexOptions, addForm.sex)"
|
||||
@@ -154,27 +145,6 @@
|
||||
/>
|
||||
<text v-else>首位成员固定为 1</text>
|
||||
</view>
|
||||
<picker
|
||||
:range="personOptionLabels"
|
||||
:value="personOptionIndex(addForm.fatherId)"
|
||||
@change="selectPerson('fatherId', $event)"
|
||||
>
|
||||
<view class="form-field form-field--picker"
|
||||
><text>父亲</text
|
||||
><text>{{ personOptionLabel(addForm.fatherId) }}</text></view
|
||||
>
|
||||
</picker>
|
||||
<picker
|
||||
:range="personOptionLabels"
|
||||
:value="personOptionIndex(addForm.motherId)"
|
||||
@change="selectPerson('motherId', $event)"
|
||||
>
|
||||
<view class="form-field form-field--picker"
|
||||
><text>母亲</text
|
||||
><text>{{ personOptionLabel(addForm.motherId) }}</text></view
|
||||
>
|
||||
</picker>
|
||||
|
||||
<picker
|
||||
mode="date"
|
||||
:value="addForm.birthDate"
|
||||
@@ -208,6 +178,7 @@
|
||||
</view>
|
||||
|
||||
<picker
|
||||
v-if="isDeceased"
|
||||
mode="date"
|
||||
:value="addForm.deathDate"
|
||||
@change="selectDeathDate"
|
||||
@@ -218,6 +189,7 @@
|
||||
</view>
|
||||
</picker>
|
||||
<picker
|
||||
v-if="isDeceased"
|
||||
:range="lunarOptions.map((item) => item.label)"
|
||||
:value="optionIndex(lunarOptions, addForm.deathLunar)"
|
||||
@change="selectOption('deathLunar', lunarOptions, $event)"
|
||||
@@ -229,7 +201,7 @@
|
||||
}}</text>
|
||||
</view>
|
||||
</picker>
|
||||
<view class="form-field">
|
||||
<view v-if="isDeceased" class="form-field">
|
||||
<text>逝世地</text>
|
||||
<input
|
||||
v-model="addForm.deathPlace"
|
||||
@@ -238,7 +210,7 @@
|
||||
placeholder-class="form-placeholder"
|
||||
/>
|
||||
</view>
|
||||
<view class="form-field">
|
||||
<view v-if="isDeceased" class="form-field">
|
||||
<text>安葬地</text>
|
||||
<input
|
||||
v-model="addForm.burialPlace"
|
||||
@@ -268,18 +240,6 @@
|
||||
placeholder-class="form-placeholder"
|
||||
/>
|
||||
</view>
|
||||
<view class="form-field">
|
||||
<text>关系名称</text>
|
||||
<input
|
||||
v-if="isFirstMember"
|
||||
v-model="addForm.relationName"
|
||||
maxlength="30"
|
||||
placeholder="可选关系显示名称"
|
||||
placeholder-class="form-placeholder"
|
||||
/>
|
||||
<text v-else>{{ relationLabel }}</text>
|
||||
</view>
|
||||
|
||||
<view class="form-field form-field--summary">
|
||||
<text>人物简介</text>
|
||||
<textarea
|
||||
@@ -372,7 +332,6 @@ const discardDialogVisible = ref(false);
|
||||
const currentMember = ref(null);
|
||||
const errorMessage = ref("");
|
||||
const addRequestController = createRequestController();
|
||||
const personOptionsRequestController = createRequestController();
|
||||
let loadSequence = 0;
|
||||
|
||||
const relationIntents = Object.freeze({
|
||||
@@ -408,23 +367,15 @@ const personStatusOptions = Object.freeze([
|
||||
const bindingModeOptions = Object.freeze([
|
||||
{ label: "不绑定账号", value: "NONE" },
|
||||
{ label: "绑定当前账号", value: "SELF" },
|
||||
{ label: "绑定指定用户", value: "SPECIFIED" },
|
||||
]);
|
||||
const personOptions = ref([{ label: "不选择", value: "" }]);
|
||||
const personOptionLabels = computed(() =>
|
||||
personOptions.value.map((item) => item.label),
|
||||
);
|
||||
const addForm = reactive({
|
||||
bindingMode: "NONE",
|
||||
personNo: "",
|
||||
name: "",
|
||||
relation: "",
|
||||
aliasName: "",
|
||||
sex: "",
|
||||
generation: "",
|
||||
generationName: "",
|
||||
fatherId: "",
|
||||
motherId: "",
|
||||
avatarOssId: "",
|
||||
birthDate: "",
|
||||
birthLunar: "",
|
||||
@@ -437,11 +388,11 @@ const addForm = reactive({
|
||||
biography: "",
|
||||
remark: "",
|
||||
sortOrder: "",
|
||||
relationName: "",
|
||||
});
|
||||
const fieldErrors = reactive({ name: "", relation: "", bindingMode: "" });
|
||||
|
||||
const isFirstMember = computed(() => mode.value === "first");
|
||||
const isDeceased = computed(() => addForm.personStatus === "1");
|
||||
const activeRelationIntent = computed(
|
||||
() => relationIntents[relationType.value] || null,
|
||||
);
|
||||
@@ -524,27 +475,6 @@ const loadCurrentMember = async () => {
|
||||
errorMessage.value = error?.message || "当前成员暂不可用。";
|
||||
}
|
||||
};
|
||||
const loadPersonOptions = async () => {
|
||||
try {
|
||||
const rows = await appApi.getLineagePersonOptions(genealogyId.value, {
|
||||
requestController: personOptionsRequestController,
|
||||
});
|
||||
const options = rows
|
||||
.map((item) => {
|
||||
const value = String(item?.personId ?? item?.id ?? "");
|
||||
if (!/^[1-9]\d*$/.test(value)) return null;
|
||||
const name = String(
|
||||
item?.name || item?.personName || item?.personNo || "未命名人物",
|
||||
);
|
||||
return { value, label: `${name}(${value})` };
|
||||
})
|
||||
.filter(Boolean);
|
||||
personOptions.value = [{ label: "不选择", value: "" }, ...options];
|
||||
} catch (error) {
|
||||
if (!isRequestCancelled(error))
|
||||
personOptions.value = [{ label: "暂无可选人物", value: "" }];
|
||||
}
|
||||
};
|
||||
onLoad((query) => {
|
||||
genealogyId.value = String(query.genealogyId || "");
|
||||
personId.value = String(query.personId || "");
|
||||
@@ -567,7 +497,6 @@ onLoad((query) => {
|
||||
if (activeRelationIntent.value) {
|
||||
addForm.relation = activeRelationIntent.value.label;
|
||||
}
|
||||
void loadPersonOptions();
|
||||
if (isFirstMember.value) {
|
||||
addState.value = "form";
|
||||
return;
|
||||
@@ -577,7 +506,6 @@ onLoad((query) => {
|
||||
onUnload(() => {
|
||||
loadSequence += 1;
|
||||
addRequestController.abort();
|
||||
personOptionsRequestController.abort();
|
||||
discardConfirmation.dispose();
|
||||
});
|
||||
|
||||
@@ -609,22 +537,20 @@ const optionLabel = (options, value) =>
|
||||
options.find((item) => item.value === value)?.label || "";
|
||||
const selectOption = (field, options, event) => {
|
||||
addForm[field] = options[Number(event.detail.value)]?.value || "";
|
||||
if (field === "personStatus" && addForm.personStatus !== "1") {
|
||||
Object.assign(addForm, {
|
||||
deathDate: "",
|
||||
deathLunar: "",
|
||||
deathPlace: "",
|
||||
burialPlace: "",
|
||||
});
|
||||
}
|
||||
};
|
||||
const selectBindingMode = (event) => {
|
||||
addForm.bindingMode =
|
||||
bindingModeOptions[Number(event.detail.value)]?.value || "NONE";
|
||||
fieldErrors.bindingMode = "";
|
||||
};
|
||||
const personOptionIndex = (value) =>
|
||||
Math.max(
|
||||
0,
|
||||
personOptions.value.findIndex((item) => item.value === value),
|
||||
);
|
||||
const personOptionLabel = (value) =>
|
||||
personOptions.value.find((item) => item.value === value)?.label || "不选择";
|
||||
const selectPerson = (field, event) => {
|
||||
addForm[field] = personOptions.value[Number(event.detail.value)]?.value || "";
|
||||
};
|
||||
const uploadAvatar = async () => {
|
||||
if (isAvatarUploading.value || isSubmitting.value) return;
|
||||
isAvatarUploading.value = true;
|
||||
@@ -665,13 +591,10 @@ const submitAdd = async () => {
|
||||
try {
|
||||
const payload = {
|
||||
bindingMode: addForm.bindingMode,
|
||||
personNo: addForm.personNo,
|
||||
name: addForm.name,
|
||||
aliasName: addForm.aliasName,
|
||||
sex: addForm.sex,
|
||||
generationName: addForm.generationName,
|
||||
fatherId: addForm.fatherId,
|
||||
motherId: addForm.motherId,
|
||||
avatarOssId: addForm.avatarOssId,
|
||||
birthDate: addForm.birthDate,
|
||||
birthLunar: addForm.birthLunar,
|
||||
@@ -685,12 +608,14 @@ const submitAdd = async () => {
|
||||
remark: addForm.remark,
|
||||
sortOrder: addForm.sortOrder,
|
||||
...(isFirstMember.value
|
||||
? { generation: 1, relationName: addForm.relationName }
|
||||
? { generation: 1 }
|
||||
: {
|
||||
...(addForm.generation
|
||||
? { generation: Number(addForm.generation) }
|
||||
: {}),
|
||||
relationName: relationLabel.value,
|
||||
...(relationType.value === "SPOUSE"
|
||||
? { relationName: relationLabel.value }
|
||||
: {}),
|
||||
}),
|
||||
};
|
||||
if (isFirstMember.value) {
|
||||
@@ -711,15 +636,12 @@ const submitAdd = async () => {
|
||||
}
|
||||
Object.assign(addForm, {
|
||||
bindingMode: "NONE",
|
||||
personNo: "",
|
||||
name: "",
|
||||
relation: "",
|
||||
aliasName: "",
|
||||
sex: "",
|
||||
generation: "",
|
||||
generationName: "",
|
||||
fatherId: "",
|
||||
motherId: "",
|
||||
avatarOssId: "",
|
||||
birthDate: "",
|
||||
birthLunar: "",
|
||||
@@ -732,7 +654,6 @@ const submitAdd = async () => {
|
||||
biography: "",
|
||||
remark: "",
|
||||
sortOrder: "",
|
||||
relationName: "",
|
||||
});
|
||||
avatarFileName.value = "";
|
||||
await returnTo("T01", { genealogyId: genealogyId.value });
|
||||
|
||||
@@ -49,12 +49,7 @@
|
||||
|
||||
<view class="form-field">
|
||||
<text>人物编号</text>
|
||||
<input
|
||||
v-model="editForm.personNo"
|
||||
maxlength="40"
|
||||
placeholder="不填则由服务端生成"
|
||||
placeholder-class="form-placeholder"
|
||||
/>
|
||||
<text>{{ originalMember.personNo || "服务端生成" }}</text>
|
||||
</view>
|
||||
<picker
|
||||
:range="sexOptions.map((item) => item.label)"
|
||||
@@ -68,7 +63,14 @@
|
||||
}}</text>
|
||||
</view>
|
||||
</picker>
|
||||
<view
|
||||
v-if="editForm.bindingMode === 'SPECIFIED'"
|
||||
class="form-field form-field--picker"
|
||||
>
|
||||
<text>身份认领</text><text>已绑定指定用户</text>
|
||||
</view>
|
||||
<picker
|
||||
v-else
|
||||
:range="bindingModeOptions.map((item) => item.label)"
|
||||
:value="optionIndex(bindingModeOptions, editForm.bindingMode)"
|
||||
@change="selectBindingMode"
|
||||
@@ -183,6 +185,7 @@
|
||||
/>
|
||||
</view>
|
||||
<picker
|
||||
v-if="isDeceased"
|
||||
mode="date"
|
||||
:value="editForm.deathDate"
|
||||
@change="selectDate('deathDate', $event)"
|
||||
@@ -193,6 +196,7 @@
|
||||
>
|
||||
</picker>
|
||||
<picker
|
||||
v-if="isDeceased"
|
||||
:range="lunarOptions.map((item) => item.label)"
|
||||
:value="optionIndex(lunarOptions, editForm.deathLunar)"
|
||||
@change="selectOption('deathLunar', lunarOptions, $event)"
|
||||
@@ -204,7 +208,7 @@
|
||||
}}</text>
|
||||
</view>
|
||||
</picker>
|
||||
<view class="form-field">
|
||||
<view v-if="isDeceased" class="form-field">
|
||||
<text>逝世地</text>
|
||||
<input
|
||||
v-model="editForm.deathPlace"
|
||||
@@ -212,7 +216,7 @@
|
||||
placeholder-class="form-placeholder"
|
||||
/>
|
||||
</view>
|
||||
<view class="form-field">
|
||||
<view v-if="isDeceased" class="form-field">
|
||||
<text>安葬地</text>
|
||||
<input
|
||||
v-model="editForm.burialPlace"
|
||||
@@ -242,15 +246,6 @@
|
||||
placeholder-class="form-placeholder"
|
||||
/>
|
||||
</view>
|
||||
<view class="form-field">
|
||||
<text>关系名称</text>
|
||||
<input
|
||||
v-model="editForm.relationName"
|
||||
maxlength="30"
|
||||
placeholder="可选关系显示名称"
|
||||
placeholder-class="form-placeholder"
|
||||
/>
|
||||
</view>
|
||||
<view class="form-field form-field--summary">
|
||||
<text>人物简介</text>
|
||||
<textarea
|
||||
@@ -332,7 +327,12 @@ import {
|
||||
pickAndUploadImage,
|
||||
} from "@/utils/resumable-image-upload.js";
|
||||
import { createDiscardConfirmation } from "@/utils/discard-confirmation.js";
|
||||
import { goBack, handleBackPress, runBackGuard } from "@/utils/navigation.js";
|
||||
import {
|
||||
finishPage,
|
||||
goBack,
|
||||
handleBackPress,
|
||||
runBackGuard,
|
||||
} from "@/utils/navigation.js";
|
||||
|
||||
const editState = ref("loading");
|
||||
const genealogyId = ref("");
|
||||
@@ -353,7 +353,6 @@ const baseline = ref("");
|
||||
const editForm = reactive({
|
||||
appUserId: "",
|
||||
bindingMode: "NONE",
|
||||
personNo: "",
|
||||
name: "",
|
||||
aliasName: "",
|
||||
sex: "",
|
||||
@@ -373,7 +372,6 @@ const editForm = reactive({
|
||||
summary: "",
|
||||
remark: "",
|
||||
sortOrder: "",
|
||||
relationName: "",
|
||||
});
|
||||
const fieldErrors = reactive({ name: "", dates: "", bindingMode: "" });
|
||||
const sexOptions = Object.freeze([
|
||||
@@ -393,7 +391,6 @@ const personStatusOptions = Object.freeze([
|
||||
const bindingModeOptions = Object.freeze([
|
||||
{ label: "不绑定账号", value: "NONE" },
|
||||
{ label: "绑定当前账号", value: "SELF" },
|
||||
{ label: "绑定指定用户", value: "SPECIFIED" },
|
||||
]);
|
||||
const personOptions = ref([{ label: "不选择", value: "" }]);
|
||||
const personOptionLabels = computed(() =>
|
||||
@@ -408,6 +405,7 @@ const appUserBindingHint = computed(() => {
|
||||
? "保留当前已认领的指定业务用户"
|
||||
: "需先选择指定业务用户;当前没有可用候选接口";
|
||||
});
|
||||
const isDeceased = computed(() => editForm.personStatus === "1");
|
||||
|
||||
const formSnapshot = computed(() => JSON.stringify(editForm));
|
||||
const hasValidContext = computed(() =>
|
||||
@@ -463,11 +461,11 @@ const loadMember = async () => {
|
||||
});
|
||||
if (activeLoad !== loadSequence) return;
|
||||
originalMember.value = member;
|
||||
const deceased = member.personStatus === "1";
|
||||
Object.assign(editForm, {
|
||||
appUserId: member.appUserId || "",
|
||||
bindingMode:
|
||||
member.bindingMode || (member.appUserId ? "SPECIFIED" : "NONE"),
|
||||
personNo: member.personNo || "",
|
||||
name: member.name,
|
||||
aliasName: member.aliasName || "",
|
||||
sex: member.sex || "",
|
||||
@@ -481,15 +479,14 @@ const loadMember = async () => {
|
||||
birthDate: datePart(member.birthDate),
|
||||
birthLunar: member.birthLunar || "",
|
||||
birthPlace: member.birthplace || "",
|
||||
deathDate: datePart(member.deathDate),
|
||||
deathLunar: member.deathLunar || "",
|
||||
deathPlace: member.deathPlace || "",
|
||||
burialPlace: member.burialPlace || "",
|
||||
deathDate: deceased ? datePart(member.deathDate) : "",
|
||||
deathLunar: deceased ? member.deathLunar || "" : "",
|
||||
deathPlace: deceased ? member.deathPlace || "" : "",
|
||||
burialPlace: deceased ? member.burialPlace || "" : "",
|
||||
personStatus: member.personStatus || "",
|
||||
summary: member.biography || "",
|
||||
remark: member.remark || "",
|
||||
sortOrder: member.sortOrder ?? "",
|
||||
relationName: member.relationName || "",
|
||||
});
|
||||
baseline.value = formSnapshot.value;
|
||||
errorMessage.value = "";
|
||||
@@ -567,6 +564,14 @@ const optionLabel = (options, value) =>
|
||||
options.find((item) => item.value === value)?.label || "";
|
||||
const selectOption = (field, options, event) => {
|
||||
editForm[field] = options[Number(event.detail.value)]?.value || "";
|
||||
if (field === "personStatus" && editForm.personStatus !== "1") {
|
||||
Object.assign(editForm, {
|
||||
deathDate: "",
|
||||
deathLunar: "",
|
||||
deathPlace: "",
|
||||
burialPlace: "",
|
||||
});
|
||||
}
|
||||
};
|
||||
const selectBindingMode = (event) => {
|
||||
editForm.bindingMode =
|
||||
@@ -632,7 +637,6 @@ const saveMember = async () => {
|
||||
...(editForm.bindingMode === "SPECIFIED"
|
||||
? { appUserId: editForm.appUserId }
|
||||
: {}),
|
||||
personNo: editForm.personNo,
|
||||
name: editForm.name,
|
||||
aliasName: editForm.aliasName,
|
||||
sex: editForm.sex,
|
||||
@@ -646,20 +650,34 @@ const saveMember = async () => {
|
||||
birthDate: editForm.birthDate,
|
||||
birthLunar: editForm.birthLunar,
|
||||
birthPlace: editForm.birthPlace,
|
||||
deathDate: editForm.deathDate,
|
||||
deathLunar: editForm.deathLunar,
|
||||
deathPlace: editForm.deathPlace,
|
||||
burialPlace: editForm.burialPlace,
|
||||
...(isDeceased.value
|
||||
? {
|
||||
deathDate: editForm.deathDate,
|
||||
deathLunar: editForm.deathLunar,
|
||||
deathPlace: editForm.deathPlace,
|
||||
burialPlace: editForm.burialPlace,
|
||||
}
|
||||
: {}),
|
||||
personStatus: editForm.personStatus,
|
||||
biography: editForm.summary,
|
||||
remark: editForm.remark,
|
||||
sortOrder: editForm.sortOrder,
|
||||
relationName: editForm.relationName,
|
||||
},
|
||||
{ requestController: editRequestController },
|
||||
);
|
||||
baseline.value = formSnapshot.value;
|
||||
await goBack();
|
||||
await finishPage(
|
||||
"T03",
|
||||
{
|
||||
genealogyId: genealogyId.value,
|
||||
personId: personId.value,
|
||||
},
|
||||
{
|
||||
operation: "member-updated",
|
||||
entityId: personId.value,
|
||||
refresh: true,
|
||||
},
|
||||
);
|
||||
} catch (error) {
|
||||
if (isRequestCancelled(error)) return;
|
||||
failedAction.value = "save";
|
||||
|
||||
Reference in New Issue
Block a user