feat: migrate app routes and business modules
This commit is contained in:
@@ -1,414 +0,0 @@
|
||||
<!-- 页面编号:A-06;用途:只承接账号冻结、停用与风险限制等阻断登录状态。 -->
|
||||
<template>
|
||||
<AuthPageShell class="auth-page status-page">
|
||||
<view class="status-content">
|
||||
<view class="page-heading">
|
||||
<view class="back-button" hover-class="tap-fade" @click="requestBack">
|
||||
<image
|
||||
class="back-button__icon"
|
||||
src="/static/assets/foundation/transparent/chevron-right.png"
|
||||
mode="aspectFit"
|
||||
/>
|
||||
</view>
|
||||
<text class="page-title">登录受限</text>
|
||||
<text class="page-subtitle">请根据当前账号状态完成恢复</text>
|
||||
<image
|
||||
class="status-divider"
|
||||
src="/static/assets/modules/auth/transparent/a01-vnext-divider-v1.png"
|
||||
mode="aspectFit"
|
||||
/>
|
||||
</view>
|
||||
|
||||
<view class="status-body">
|
||||
<image
|
||||
class="status-icon"
|
||||
src="/static/assets/foundation/transparent/auth-login-outline.png"
|
||||
mode="aspectFit"
|
||||
/>
|
||||
<text class="status-eyebrow">{{ currentState.eyebrow }}</text>
|
||||
<text class="status-title">{{ currentState.title }}</text>
|
||||
<text class="status-description">{{ currentState.description }}</text>
|
||||
|
||||
<view class="status-details">
|
||||
<view class="status-detail">
|
||||
<text class="status-detail__label">{{
|
||||
currentState.reasonLabel
|
||||
}}</text>
|
||||
<text class="status-detail__value">{{ currentState.reason }}</text>
|
||||
</view>
|
||||
<view class="status-detail">
|
||||
<text class="status-detail__label">{{
|
||||
currentState.impactLabel
|
||||
}}</text>
|
||||
<text class="status-detail__value">{{ currentState.impact }}</text>
|
||||
</view>
|
||||
<view class="status-detail">
|
||||
<text class="status-detail__label">{{
|
||||
currentState.recoveryLabel
|
||||
}}</text>
|
||||
<text class="status-detail__value">{{
|
||||
currentState.recovery
|
||||
}}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view
|
||||
class="status-primary"
|
||||
hover-class="button-hover"
|
||||
@click="openRecovery"
|
||||
>
|
||||
<image
|
||||
class="status-primary__skin"
|
||||
src="/static/assets/foundation/transparent/a01-scroll-primary-v3.png"
|
||||
mode="aspectFit"
|
||||
/>
|
||||
<text class="status-primary__copy">查看恢复方式</text>
|
||||
</view>
|
||||
<view class="status-secondary" hover-class="tap-fade" @click="requestBack"
|
||||
>返回登录</view
|
||||
>
|
||||
</view>
|
||||
|
||||
<template #overlay>
|
||||
<view
|
||||
v-if="recoveryVisible"
|
||||
class="recovery-layer"
|
||||
@click="closeRecovery"
|
||||
>
|
||||
<view class="recovery-dialog" @click.stop>
|
||||
<image
|
||||
class="recovery-dialog__skin"
|
||||
src="/static/assets/modules/auth/transparent/a01-scroll-dialog-v3.png"
|
||||
mode="aspectFit"
|
||||
/>
|
||||
<view class="recovery-dialog__content">
|
||||
<text class="recovery-title">{{ currentState.recoveryTitle }}</text>
|
||||
<text class="recovery-copy">{{ currentState.recoveryDetail }}</text>
|
||||
<text class="recovery-note"
|
||||
>账号恢复服务将在功能阶段接入,当前仅审核页面样式与状态位置。</text
|
||||
>
|
||||
<view
|
||||
class="recovery-action"
|
||||
hover-class="button-hover"
|
||||
@click="closeRecovery"
|
||||
>
|
||||
<image
|
||||
class="recovery-action__skin"
|
||||
src="/static/assets/foundation/transparent/a01-scroll-primary-v3.png"
|
||||
mode="aspectFit"
|
||||
/>
|
||||
<text class="recovery-action__copy">我知道了</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
</AuthPageShell>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref } from "vue";
|
||||
import { onBackPress, onLoad } from "@dcloudio/uni-app";
|
||||
import AuthPageShell from "@/components/AuthPageShell.vue";
|
||||
import { goRoot, handleBackPress, runBackGuard } from "@/utils/navigation.js";
|
||||
|
||||
const status = ref("risk");
|
||||
const recoveryVisible = ref(false);
|
||||
|
||||
// A06 阻断状态的唯一合同;普通登录错误必须留在 A01 就近处理。
|
||||
const statusConfig = {
|
||||
frozen: {
|
||||
eyebrow: "账号冻结",
|
||||
title: "账号已被冻结",
|
||||
description: "当前账号因安全审核暂时无法登录。",
|
||||
reasonLabel: "限制原因",
|
||||
reason: "账号进入人工安全审核",
|
||||
impactLabel: "影响范围",
|
||||
impact: "暂时无法进入家谱及管理资料",
|
||||
recoveryLabel: "恢复方式",
|
||||
recovery: "提交账号申诉并等待审核结果",
|
||||
recoveryTitle: "申请解除冻结",
|
||||
recoveryDetail: "请准备注册手机号与身份说明,提交后由平台进行人工核验。",
|
||||
},
|
||||
disabled: {
|
||||
eyebrow: "账号停用",
|
||||
title: "账号已被停用",
|
||||
description: "当前账号处于停用状态,登录入口已关闭。",
|
||||
reasonLabel: "限制原因",
|
||||
reason: "账号已执行停用处理",
|
||||
impactLabel: "影响范围",
|
||||
impact: "无法登录,原有家谱资料不会被删除",
|
||||
recoveryLabel: "恢复方式",
|
||||
recovery: "联系平台核实停用原因与恢复条件",
|
||||
recoveryTitle: "联系平台核实",
|
||||
recoveryDetail:
|
||||
"请提供注册手机号和账号归属信息,平台核实后告知是否可以恢复。",
|
||||
},
|
||||
risk: {
|
||||
eyebrow: "风险限制",
|
||||
title: "账号存在安全风险",
|
||||
description: "系统检测到异常登录,为保护家谱资料已暂停本次登录。",
|
||||
reasonLabel: "限制原因",
|
||||
reason: "登录环境或操作行为存在异常",
|
||||
impactLabel: "影响范围",
|
||||
impact: "本次登录被阻断,账号资料保持不变",
|
||||
recoveryLabel: "恢复方式",
|
||||
recovery: "完成身份核验后重新登录",
|
||||
recoveryTitle: "完成身份核验",
|
||||
recoveryDetail: "请使用注册手机号完成身份核验;核验通过后可重新尝试登录。",
|
||||
},
|
||||
};
|
||||
|
||||
const currentState = computed(() => statusConfig[status.value]);
|
||||
|
||||
const resolveStatus = (options = {}) => {
|
||||
const requestedStatus = options.status || "risk";
|
||||
status.value = Object.prototype.hasOwnProperty.call(
|
||||
statusConfig,
|
||||
requestedStatus,
|
||||
)
|
||||
? requestedStatus
|
||||
: "risk";
|
||||
recoveryVisible.value = false;
|
||||
};
|
||||
|
||||
onLoad(resolveStatus);
|
||||
|
||||
const openRecovery = () => {
|
||||
recoveryVisible.value = true;
|
||||
};
|
||||
const closeRecovery = () => {
|
||||
recoveryVisible.value = false;
|
||||
};
|
||||
const requestBack = () => {
|
||||
if (recoveryVisible.value) {
|
||||
return runBackGuard({
|
||||
transientOpen: true,
|
||||
"close-transient": closeRecovery,
|
||||
});
|
||||
}
|
||||
return goRoot("A01");
|
||||
};
|
||||
|
||||
onBackPress((event) => handleBackPress(event, requestBack));
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.status-content {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
max-width: 480px;
|
||||
min-width: 0;
|
||||
margin: 0 auto;
|
||||
padding: clamp(10px, 2.2vh, 20px) clamp(24px, 8.5vw, 44px);
|
||||
}
|
||||
.page-heading {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
grid-template-rows: auto auto auto;
|
||||
justify-items: center;
|
||||
min-height: 154rpx;
|
||||
}
|
||||
.back-button {
|
||||
display: flex;
|
||||
grid-row: 1 / -1;
|
||||
grid-column: 1;
|
||||
align-self: start;
|
||||
justify-self: start;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: var(--app-touch-min);
|
||||
height: var(--app-touch-min);
|
||||
margin-top: 10rpx;
|
||||
}
|
||||
.back-button__icon {
|
||||
width: 42rpx;
|
||||
height: 42rpx;
|
||||
transform: scaleX(-1);
|
||||
}
|
||||
.page-title {
|
||||
grid-column: 1;
|
||||
grid-row: 1;
|
||||
color: #9f170f;
|
||||
font-size: clamp(29px, 58rpx, 36px);
|
||||
font-weight: 700;
|
||||
letter-spacing: 7rpx;
|
||||
}
|
||||
.page-subtitle {
|
||||
grid-column: 1;
|
||||
grid-row: 2;
|
||||
margin-top: 12rpx;
|
||||
color: #806c58;
|
||||
font-size: clamp(15px, 25rpx, 18px);
|
||||
letter-spacing: 1rpx;
|
||||
}
|
||||
.status-divider {
|
||||
grid-column: 1;
|
||||
grid-row: 3;
|
||||
width: 300rpx;
|
||||
height: 50rpx;
|
||||
margin-top: 2rpx;
|
||||
filter: brightness(0.68) saturate(1.5) contrast(1.2);
|
||||
}
|
||||
|
||||
.status-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding-top: 6rpx;
|
||||
text-align: center;
|
||||
}
|
||||
.status-icon {
|
||||
width: 94rpx;
|
||||
height: 94rpx;
|
||||
opacity: 0.82;
|
||||
}
|
||||
.status-eyebrow {
|
||||
margin-top: 10rpx;
|
||||
color: #a7160c;
|
||||
font-size: clamp(14px, 22rpx, 17px);
|
||||
letter-spacing: 3rpx;
|
||||
}
|
||||
.status-title {
|
||||
margin-top: 10rpx;
|
||||
color: #3f2c1d;
|
||||
font-size: clamp(20px, 40rpx, 26px);
|
||||
font-weight: 700;
|
||||
letter-spacing: 3rpx;
|
||||
}
|
||||
.status-description {
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
margin-top: 12rpx;
|
||||
color: #786654;
|
||||
font-size: clamp(15px, 24rpx, 18px);
|
||||
line-height: 1.65;
|
||||
}
|
||||
.status-details {
|
||||
width: 100%;
|
||||
margin-top: 22rpx;
|
||||
border-top: 1rpx solid #cfaa70;
|
||||
}
|
||||
.status-detail {
|
||||
display: flex;
|
||||
box-sizing: border-box;
|
||||
min-height: 78rpx;
|
||||
padding: 16rpx 0;
|
||||
border-bottom: 1rpx solid rgba(207, 170, 112, 0.72);
|
||||
text-align: left;
|
||||
}
|
||||
.status-detail__label {
|
||||
flex: 0 0 116rpx;
|
||||
color: #9f170f;
|
||||
font-size: clamp(14px, 23rpx, 17px);
|
||||
}
|
||||
.status-detail__value {
|
||||
flex: 1;
|
||||
color: #5f4a38;
|
||||
font-size: clamp(14px, 23rpx, 17px);
|
||||
line-height: max(1.35em, clamp(19px, 34rpx, 24px));
|
||||
}
|
||||
|
||||
.status-primary {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
min-height: var(--app-touch-min);
|
||||
margin-top: 28rpx;
|
||||
}
|
||||
.status-primary__skin,
|
||||
.recovery-action__skin {
|
||||
grid-area: 1 / 1;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
.status-primary__copy {
|
||||
z-index: 1;
|
||||
grid-area: 1 / 1;
|
||||
color: #fffaf0;
|
||||
font-size: clamp(20px, 38rpx, 26px);
|
||||
letter-spacing: 5rpx;
|
||||
}
|
||||
.status-secondary {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: var(--app-touch-min);
|
||||
color: #a7160c;
|
||||
font-size: clamp(15px, 24rpx, 18px);
|
||||
}
|
||||
|
||||
.recovery-layer {
|
||||
position: fixed;
|
||||
z-index: 20;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-sizing: border-box;
|
||||
padding: 42rpx;
|
||||
background: rgba(35, 18, 10, 0.62);
|
||||
}
|
||||
.recovery-dialog {
|
||||
display: grid;
|
||||
width: 620rpx;
|
||||
min-height: 520rpx;
|
||||
max-height: calc(var(--app-viewport-height) - 40px);
|
||||
}
|
||||
.recovery-dialog__skin {
|
||||
grid-area: 1 / 1;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
.recovery-dialog__content {
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
grid-area: 1 / 1;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
box-sizing: border-box;
|
||||
min-height: 100%;
|
||||
overflow-y: auto;
|
||||
padding: 58rpx 58rpx 36rpx;
|
||||
text-align: center;
|
||||
}
|
||||
.recovery-title {
|
||||
color: #8f160f;
|
||||
font-size: clamp(22px, 42rpx, 28px);
|
||||
font-weight: 700;
|
||||
letter-spacing: 4rpx;
|
||||
}
|
||||
.recovery-copy {
|
||||
margin-top: 20rpx;
|
||||
color: #513a28;
|
||||
font-size: clamp(16px, 26rpx, 20px);
|
||||
line-height: max(1.35em, clamp(20px, 40rpx, 26px));
|
||||
}
|
||||
.recovery-note {
|
||||
margin-top: 12rpx;
|
||||
color: #8d7965;
|
||||
font-size: clamp(13px, 20rpx, 16px);
|
||||
line-height: max(1.35em, clamp(17px, 31rpx, 22px));
|
||||
}
|
||||
.recovery-action {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 100%;
|
||||
min-height: 80rpx;
|
||||
margin-top: 22rpx;
|
||||
}
|
||||
.recovery-action__copy {
|
||||
z-index: 1;
|
||||
grid-area: 1 / 1;
|
||||
color: #fffaf0;
|
||||
font-size: clamp(16px, 29rpx, 20px);
|
||||
letter-spacing: 4rpx;
|
||||
}
|
||||
.tap-fade,
|
||||
.button-hover {
|
||||
opacity: 0.72;
|
||||
}
|
||||
</style>
|
||||
@@ -1,4 +1,3 @@
|
||||
<!-- 页面编号:A-04;用途:注册账号与本页服务协议确认。 -->
|
||||
<template>
|
||||
<AuthPageShell class="auth-page register-page">
|
||||
<view class="register-content">
|
||||
@@ -20,7 +19,7 @@
|
||||
<text class="page-subtitle">创建属于你的家谱账号</text>
|
||||
<image
|
||||
class="register-divider"
|
||||
src="/static/assets/modules/auth/transparent/a01-vnext-divider-v1.png"
|
||||
src="/static/assets/modules/auth/transparent/title-divider.png"
|
||||
mode="aspectFit"
|
||||
/>
|
||||
</view>
|
||||
@@ -31,11 +30,11 @@
|
||||
:class="{ 'field-block--error': fieldErrors.phone }"
|
||||
>
|
||||
<view class="input-row">
|
||||
<label class="input-label" for="a04-phone"
|
||||
<label class="input-label" for="register-phone"
|
||||
><text class="required-mark">*</text>手机号</label
|
||||
>
|
||||
<input
|
||||
id="a04-phone"
|
||||
id="register-phone"
|
||||
v-model.trim="phone"
|
||||
class="auth-input"
|
||||
type="number"
|
||||
@@ -51,14 +50,14 @@
|
||||
placeholder-class="placeholder"
|
||||
:aria-invalid="Boolean(fieldErrors.phone)"
|
||||
:aria-describedby="
|
||||
fieldErrors.phone ? 'a04-phone-error' : undefined
|
||||
fieldErrors.phone ? 'register-phone-error' : undefined
|
||||
"
|
||||
@input="handlePhoneInput"
|
||||
/>
|
||||
</view>
|
||||
<text
|
||||
v-if="fieldErrors.phone"
|
||||
id="a04-phone-error"
|
||||
id="register-phone-error"
|
||||
class="field-error"
|
||||
role="alert"
|
||||
>{{ fieldErrors.phone }}</text
|
||||
@@ -67,9 +66,9 @@
|
||||
|
||||
<view class="field-block">
|
||||
<view class="input-row">
|
||||
<label class="input-label" for="a04-nickname">昵称</label>
|
||||
<label class="input-label" for="register-nickname">昵称</label>
|
||||
<input
|
||||
id="a04-nickname"
|
||||
id="register-nickname"
|
||||
v-model.trim="nickName"
|
||||
class="auth-input"
|
||||
:disabled="submitting || registrationCommitted"
|
||||
@@ -84,11 +83,11 @@
|
||||
:class="{ 'field-block--error': fieldErrors.verificationCode }"
|
||||
>
|
||||
<view class="input-row code-row">
|
||||
<label class="input-label" for="a04-verification-code"
|
||||
<label class="input-label" for="register-verification-code"
|
||||
><text class="required-mark">*</text>验证码</label
|
||||
>
|
||||
<input
|
||||
id="a04-verification-code"
|
||||
id="register-verification-code"
|
||||
v-model.trim="verificationCode"
|
||||
class="auth-input"
|
||||
type="number"
|
||||
@@ -99,7 +98,7 @@
|
||||
:aria-invalid="Boolean(fieldErrors.verificationCode)"
|
||||
:aria-describedby="
|
||||
fieldErrors.verificationCode
|
||||
? 'a04-verification-code-error'
|
||||
? 'register-verification-code-error'
|
||||
: undefined
|
||||
"
|
||||
@input="clearFieldError('verificationCode')"
|
||||
@@ -129,7 +128,7 @@
|
||||
</view>
|
||||
<text
|
||||
v-if="fieldErrors.verificationCode"
|
||||
id="a04-verification-code-error"
|
||||
id="register-verification-code-error"
|
||||
class="field-error"
|
||||
role="alert"
|
||||
>{{ fieldErrors.verificationCode }}</text
|
||||
@@ -141,11 +140,11 @@
|
||||
:class="{ 'field-block--error': fieldErrors.password }"
|
||||
>
|
||||
<view class="input-row">
|
||||
<label class="input-label input-label--password" for="a04-password"
|
||||
<label class="input-label input-label--password" for="register-password"
|
||||
><text class="required-mark">*</text>设置密码</label
|
||||
>
|
||||
<input
|
||||
id="a04-password"
|
||||
id="register-password"
|
||||
v-model="password"
|
||||
class="auth-input"
|
||||
password
|
||||
@@ -155,14 +154,14 @@
|
||||
placeholder-class="placeholder"
|
||||
:aria-invalid="Boolean(fieldErrors.password)"
|
||||
:aria-describedby="
|
||||
fieldErrors.password ? 'a04-password-error' : undefined
|
||||
fieldErrors.password ? 'register-password-error' : undefined
|
||||
"
|
||||
@input="clearFieldError('password')"
|
||||
/>
|
||||
</view>
|
||||
<text
|
||||
v-if="fieldErrors.password"
|
||||
id="a04-password-error"
|
||||
id="register-password-error"
|
||||
class="field-error"
|
||||
role="alert"
|
||||
>{{ fieldErrors.password }}</text
|
||||
@@ -174,11 +173,11 @@
|
||||
:class="{ 'field-block--error': fieldErrors.confirmPassword }"
|
||||
>
|
||||
<view class="input-row">
|
||||
<label class="input-label input-label--password" for="a04-confirm-password"
|
||||
<label class="input-label input-label--password" for="register-confirm-password"
|
||||
><text class="required-mark">*</text>确认密码</label
|
||||
>
|
||||
<input
|
||||
id="a04-confirm-password"
|
||||
id="register-confirm-password"
|
||||
v-model="confirmPassword"
|
||||
class="auth-input"
|
||||
password
|
||||
@@ -189,7 +188,7 @@
|
||||
:aria-invalid="Boolean(fieldErrors.confirmPassword)"
|
||||
:aria-describedby="
|
||||
fieldErrors.confirmPassword
|
||||
? 'a04-confirm-password-error'
|
||||
? 'register-confirm-password-error'
|
||||
: undefined
|
||||
"
|
||||
@input="clearFieldError('confirmPassword')"
|
||||
@@ -197,7 +196,7 @@
|
||||
</view>
|
||||
<text
|
||||
v-if="fieldErrors.confirmPassword"
|
||||
id="a04-confirm-password-error"
|
||||
id="register-confirm-password-error"
|
||||
class="field-error"
|
||||
role="alert"
|
||||
>{{ fieldErrors.confirmPassword }}</text
|
||||
@@ -214,7 +213,7 @@
|
||||
>
|
||||
<image
|
||||
class="register-submit__skin"
|
||||
src="/static/assets/foundation/transparent/a01-scroll-primary-v3.png"
|
||||
src="/static/assets/foundation/transparent/scroll-primary.png"
|
||||
mode="aspectFit"
|
||||
/>
|
||||
<text class="register-submit__content">{{
|
||||
@@ -249,8 +248,8 @@
|
||||
class="agreement-icon"
|
||||
:src="
|
||||
agreed
|
||||
? '/static/assets/modules/auth/transparent/a02-agreement-checked.png'
|
||||
: '/static/assets/modules/auth/transparent/a02-agreement-unchecked.png'
|
||||
? '/static/assets/modules/auth/transparent/agreement-checked.png'
|
||||
: '/static/assets/modules/auth/transparent/agreement-unchecked.png'
|
||||
"
|
||||
mode="aspectFit"
|
||||
aria-hidden="true"
|
||||
@@ -322,30 +321,26 @@ import { computed, ref } from "vue";
|
||||
import { onBackPress, onShow, onUnload } from "@dcloudio/uni-app";
|
||||
import AppDialog from "@/components/AppDialog.vue";
|
||||
import AppToast from "@/components/AppToast.vue";
|
||||
import AuthPageShell from "@/components/AuthPageShell.vue";
|
||||
import TacVerification from "@/components/TacVerification.vue";
|
||||
import AuthPageShell from "@/components/auth/PageShell.vue";
|
||||
import TacVerification from "@/components/auth/TacVerification.vue";
|
||||
import { useSmsVerification } from "@/composables/auth/use-sms-verification.js";
|
||||
import {
|
||||
appApi,
|
||||
createRequestController,
|
||||
isRequestCancelled,
|
||||
} from "@/utils/api.js";
|
||||
isRequestCancelled
|
||||
} from "@/services/api/request-controller.js";
|
||||
import { authApi } from "@/services/api/auth-service.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";
|
||||
} from "@/utils/auth/verification.js";
|
||||
import { createDiscardConfirmation } from "@/utils/discard-confirmation.js";
|
||||
import { calcMD5 } from "@/utils/md5.js";
|
||||
import { goRoot, handleBackPress, runBackGuard } from "@/utils/navigation.js";
|
||||
import { createNonIdempotentWriteGuard } from "@/utils/non-idempotent-write-guard.js";
|
||||
import { goRoot, handleBackPress, runBackGuard } from "@/utils/navigation/gateway.js";
|
||||
import {
|
||||
PASSWORD_POLICY_MESSAGE,
|
||||
validatePassword,
|
||||
} from "@/utils/validation.js";
|
||||
} from "@/utils/auth/password-policy.js";
|
||||
|
||||
const phone = ref("");
|
||||
const nickName = ref("");
|
||||
@@ -363,13 +358,8 @@ const fieldErrors = ref({
|
||||
const feedbackVisible = ref(false);
|
||||
const feedbackMessage = ref("");
|
||||
const discardVisible = ref(false);
|
||||
const tacVisible = ref(false);
|
||||
const tacContext = ref(null);
|
||||
const sendingCode = ref(false);
|
||||
const submitting = ref(false);
|
||||
const registrationCommitted = ref(false);
|
||||
const cooldownSeconds = ref(0);
|
||||
const sentPhone = ref("");
|
||||
const isDirty = computed(
|
||||
() =>
|
||||
!registrationCommitted.value &&
|
||||
@@ -383,17 +373,11 @@ const isDirty = computed(
|
||||
),
|
||||
);
|
||||
let feedbackTimer = null;
|
||||
let tacSequence = 0;
|
||||
let pageActive = true;
|
||||
const registrationNavigationFailure =
|
||||
"注册已完成,但暂时无法进入家谱,请再次点击进入";
|
||||
const authRequestController = createRequestController();
|
||||
const smsCooldown = createAuthSmsCooldown({
|
||||
operationCode: AUTH_VERIFICATION_OPERATION.REGISTER,
|
||||
onChange: (seconds) => {
|
||||
cooldownSeconds.value = seconds;
|
||||
},
|
||||
});
|
||||
const registrationSubmissionRequestController = createRequestController();
|
||||
const registrationGuard = createNonIdempotentWriteGuard();
|
||||
const discardConfirmation = createDiscardConfirmation((visible) => {
|
||||
discardVisible.value = visible;
|
||||
});
|
||||
@@ -401,17 +385,6 @@ const requestDiscardConfirmation = discardConfirmation.request;
|
||||
const confirmDiscard = discardConfirmation.confirm;
|
||||
const cancelDiscard = discardConfirmation.cancel;
|
||||
|
||||
const closeTac = () => {
|
||||
tacVisible.value = false;
|
||||
tacContext.value = null;
|
||||
};
|
||||
|
||||
const cancelPendingRequest = () => {
|
||||
authRequestController.abort();
|
||||
sendingCode.value = false;
|
||||
submitting.value = false;
|
||||
};
|
||||
|
||||
const requestBack = () =>
|
||||
registrationCommitted.value
|
||||
? enterAuthenticatedRoot()
|
||||
@@ -420,10 +393,7 @@ const requestBack = () =>
|
||||
submitting: submitting.value || sendingCode.value,
|
||||
dirty: isDirty.value,
|
||||
"close-transient": tacVisible.value ? closeTac : cancelDiscard,
|
||||
"block-submitting": () => {
|
||||
cancelPendingRequest();
|
||||
return requestBack();
|
||||
},
|
||||
"block-submitting": () => true,
|
||||
"confirm-discard": requestDiscardConfirmation,
|
||||
});
|
||||
|
||||
@@ -431,12 +401,12 @@ onBackPress((event) => handleBackPress(event, requestBack));
|
||||
|
||||
onUnload(() => {
|
||||
pageActive = false;
|
||||
authRequestController.abort();
|
||||
smsVerification.dispose();
|
||||
registrationSubmissionRequestController.abort();
|
||||
if (feedbackTimer) clearTimeout(feedbackTimer);
|
||||
smsCooldown.dispose();
|
||||
discardConfirmation.dispose();
|
||||
});
|
||||
onShow(() => smsCooldown.sync());
|
||||
onShow(() => smsVerification.syncCooldown());
|
||||
|
||||
const showFeedback = (message) => {
|
||||
feedbackMessage.value = message;
|
||||
@@ -448,6 +418,25 @@ const showFeedback = (message) => {
|
||||
}, 2200);
|
||||
};
|
||||
|
||||
const smsVerification = useSmsVerification({
|
||||
operationCode: AUTH_VERIFICATION_OPERATION.REGISTER,
|
||||
requestIdPrefix: "register",
|
||||
phone,
|
||||
isActive: () => pageActive,
|
||||
showFeedback,
|
||||
});
|
||||
const {
|
||||
tacVisible,
|
||||
tacContext,
|
||||
sendingCode,
|
||||
cooldownSeconds,
|
||||
sentPhone,
|
||||
closeTac,
|
||||
completeTac,
|
||||
handleTacFailure,
|
||||
handleTacError,
|
||||
} = smsVerification;
|
||||
|
||||
const enterAuthenticatedRoot = async () => {
|
||||
if (!pageActive) return false;
|
||||
submitting.value = true;
|
||||
@@ -495,92 +484,7 @@ const prepareGetCode = async () => {
|
||||
agreementError.value = true;
|
||||
return;
|
||||
}
|
||||
sendingCode.value = true;
|
||||
try {
|
||||
const operationCode = AUTH_VERIFICATION_OPERATION.REGISTER;
|
||||
const requestedPhone = phone.value;
|
||||
const response = await appApi.getCaptchaRequirement(
|
||||
{ operationCode, subject: requestedPhone },
|
||||
{ requestController: authRequestController },
|
||||
);
|
||||
if (!pageActive) return;
|
||||
if (phone.value !== requestedPhone)
|
||||
throw new Error("手机号已变化,请重新获取验证码");
|
||||
const requirement = normalizeCaptchaRequirement(response);
|
||||
if (!requirement.required) {
|
||||
await appApi.sendSmsCode(
|
||||
{ operationCode, phone: requestedPhone },
|
||||
{ requestController: authRequestController },
|
||||
);
|
||||
if (!pageActive) return;
|
||||
sentPhone.value = requestedPhone;
|
||||
smsCooldown.start();
|
||||
showFeedback("验证码已发送");
|
||||
return;
|
||||
}
|
||||
tacSequence += 1;
|
||||
tacContext.value = createTacRenderContext({
|
||||
requestId: `a04-register-${tacSequence}`,
|
||||
baseUrl: runtimeConfig.baseUrl,
|
||||
clientId: runtimeConfig.clientId,
|
||||
tenantId: runtimeConfig.tenantId,
|
||||
operationCode,
|
||||
subject: requestedPhone,
|
||||
requirement,
|
||||
});
|
||||
tacVisible.value = true;
|
||||
} catch (error) {
|
||||
if (pageActive && !isRequestCancelled(error)) {
|
||||
showFeedback(error.message || "安全验证暂不可用");
|
||||
}
|
||||
} finally {
|
||||
if (pageActive) sendingCode.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const completeTac = async (result) => {
|
||||
const expectedContext = tacContext.value;
|
||||
if (!expectedContext) return;
|
||||
try {
|
||||
const ticket = normalizeTacSuccess(result, expectedContext.requestId);
|
||||
if (phone.value !== expectedContext.subject)
|
||||
throw new Error("手机号已变化,请重新验证");
|
||||
closeTac();
|
||||
sendingCode.value = true;
|
||||
await appApi.sendSmsCode(
|
||||
{
|
||||
operationCode: AUTH_VERIFICATION_OPERATION.REGISTER,
|
||||
phone: expectedContext.subject,
|
||||
validToken: ticket.validToken,
|
||||
},
|
||||
{ requestController: authRequestController },
|
||||
);
|
||||
if (!pageActive) return;
|
||||
sentPhone.value = expectedContext.subject;
|
||||
smsCooldown.start();
|
||||
showFeedback("验证码已发送");
|
||||
} catch (error) {
|
||||
closeTac();
|
||||
if (pageActive) {
|
||||
if (isSmsDeliveryOutcomeUnknown(error)) {
|
||||
sentPhone.value = expectedContext.subject;
|
||||
smsCooldown.start();
|
||||
showFeedback("发送结果未知,如收到短信可直接填写;60 秒后可重试");
|
||||
} else if (!isRequestCancelled(error)) {
|
||||
showFeedback(error.message || "验证码发送失败");
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
if (pageActive) sendingCode.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleTacFailure = ({ message } = {}) =>
|
||||
showFeedback(message || "行为验证未通过,请重试");
|
||||
|
||||
const handleTacError = ({ message } = {}) => {
|
||||
closeTac();
|
||||
showFeedback(message || "安全验证暂不可用");
|
||||
return smsVerification.requestCode();
|
||||
};
|
||||
|
||||
const validateForm = () => {
|
||||
@@ -610,23 +514,37 @@ const submitRegister = async () => {
|
||||
const formValid = validateForm();
|
||||
if (!agreed.value) agreementError.value = true;
|
||||
if (!formValid || !agreed.value) return;
|
||||
const registrationPayload = {
|
||||
phone: phone.value,
|
||||
nickName: nickName.value,
|
||||
passwordHash: calcMD5(password.value),
|
||||
smsCode: verificationCode.value,
|
||||
};
|
||||
const registrationAttempt = registrationGuard.begin(registrationPayload);
|
||||
if (registrationAttempt === null) {
|
||||
showFeedback(
|
||||
"上次注册结果暂时无法确认,请先返回登录页尝试登录,不要重复注册。",
|
||||
);
|
||||
return;
|
||||
}
|
||||
submitting.value = true;
|
||||
try {
|
||||
await appApi.registerWithPassword(
|
||||
{
|
||||
phone: phone.value,
|
||||
nickName: nickName.value,
|
||||
passwordHash: calcMD5(password.value),
|
||||
smsCode: verificationCode.value,
|
||||
},
|
||||
{ requestController: authRequestController },
|
||||
await authApi.registerWithPassword(
|
||||
registrationPayload,
|
||||
{ requestController: registrationSubmissionRequestController },
|
||||
);
|
||||
if (!pageActive) return;
|
||||
registrationCommitted.value = true;
|
||||
} catch (error) {
|
||||
if (pageActive && !isRequestCancelled(error)) {
|
||||
showFeedback(error.message || "注册失败,请稍后重试");
|
||||
if (!pageActive) return;
|
||||
if (registrationGuard.recordFailure(registrationAttempt, error)) {
|
||||
showFeedback(
|
||||
"注册结果暂时无法确认,请先返回登录页尝试登录,不要重复注册。",
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (!isRequestCancelled(error))
|
||||
showFeedback(error.message || "注册失败,请稍后重试");
|
||||
return;
|
||||
} finally {
|
||||
if (pageActive) submitting.value = false;
|
||||
@@ -1,4 +1,3 @@
|
||||
<!-- 页面编号:A-05;用途:通过手机号验证后重设登录密码。 -->
|
||||
<template>
|
||||
<AuthPageShell class="auth-page reset-page">
|
||||
<view class="reset-content">
|
||||
@@ -20,7 +19,7 @@
|
||||
<text class="page-subtitle">验证手机号后设置新的登录密码</text>
|
||||
<image
|
||||
class="reset-divider"
|
||||
src="/static/assets/modules/auth/transparent/a01-vnext-divider-v1.png"
|
||||
src="/static/assets/modules/auth/transparent/title-divider.png"
|
||||
mode="aspectFit"
|
||||
/>
|
||||
</view>
|
||||
@@ -31,11 +30,11 @@
|
||||
:class="{ 'field-block--error': fieldErrors.phone }"
|
||||
>
|
||||
<view class="input-row">
|
||||
<label class="input-label" for="a05-phone"
|
||||
<label class="input-label" for="reset-password-phone"
|
||||
><text class="required-mark">*</text>手机号</label
|
||||
>
|
||||
<input
|
||||
id="a05-phone"
|
||||
id="reset-password-phone"
|
||||
v-model.trim="phone"
|
||||
class="auth-input"
|
||||
type="number"
|
||||
@@ -49,14 +48,14 @@
|
||||
placeholder-class="placeholder"
|
||||
:aria-invalid="Boolean(fieldErrors.phone)"
|
||||
:aria-describedby="
|
||||
fieldErrors.phone ? 'a05-phone-error' : undefined
|
||||
fieldErrors.phone ? 'reset-password-phone-error' : undefined
|
||||
"
|
||||
@input="handlePhoneInput"
|
||||
/>
|
||||
</view>
|
||||
<text
|
||||
v-if="fieldErrors.phone"
|
||||
id="a05-phone-error"
|
||||
id="reset-password-phone-error"
|
||||
class="field-error"
|
||||
role="alert"
|
||||
>{{ fieldErrors.phone }}</text
|
||||
@@ -68,11 +67,11 @@
|
||||
:class="{ 'field-block--error': fieldErrors.verificationCode }"
|
||||
>
|
||||
<view class="input-row code-row">
|
||||
<label class="input-label" for="a05-verification-code"
|
||||
<label class="input-label" for="reset-password-verification-code"
|
||||
><text class="required-mark">*</text>验证码</label
|
||||
>
|
||||
<input
|
||||
id="a05-verification-code"
|
||||
id="reset-password-verification-code"
|
||||
v-model.trim="verificationCode"
|
||||
class="auth-input"
|
||||
type="number"
|
||||
@@ -83,7 +82,7 @@
|
||||
:aria-invalid="Boolean(fieldErrors.verificationCode)"
|
||||
:aria-describedby="
|
||||
fieldErrors.verificationCode
|
||||
? 'a05-verification-code-error'
|
||||
? 'reset-password-verification-code-error'
|
||||
: undefined
|
||||
"
|
||||
@input="clearFieldError('verificationCode')"
|
||||
@@ -108,7 +107,7 @@
|
||||
</view>
|
||||
<text
|
||||
v-if="fieldErrors.verificationCode"
|
||||
id="a05-verification-code-error"
|
||||
id="reset-password-verification-code-error"
|
||||
class="field-error"
|
||||
role="alert"
|
||||
>{{ fieldErrors.verificationCode }}</text
|
||||
@@ -120,11 +119,11 @@
|
||||
:class="{ 'field-block--error': fieldErrors.password }"
|
||||
>
|
||||
<view class="input-row">
|
||||
<label class="input-label" for="a05-password"
|
||||
<label class="input-label" for="reset-password-new-password"
|
||||
><text class="required-mark">*</text>新密码</label
|
||||
>
|
||||
<input
|
||||
id="a05-password"
|
||||
id="reset-password-new-password"
|
||||
v-model="password"
|
||||
class="auth-input"
|
||||
password
|
||||
@@ -134,14 +133,14 @@
|
||||
placeholder-class="placeholder"
|
||||
:aria-invalid="Boolean(fieldErrors.password)"
|
||||
:aria-describedby="
|
||||
fieldErrors.password ? 'a05-password-error' : undefined
|
||||
fieldErrors.password ? 'reset-password-new-password-error' : undefined
|
||||
"
|
||||
@input="clearFieldError('password')"
|
||||
/>
|
||||
</view>
|
||||
<text
|
||||
v-if="fieldErrors.password"
|
||||
id="a05-password-error"
|
||||
id="reset-password-new-password-error"
|
||||
class="field-error"
|
||||
role="alert"
|
||||
>{{ fieldErrors.password }}</text
|
||||
@@ -153,11 +152,11 @@
|
||||
:class="{ 'field-block--error': fieldErrors.confirmPassword }"
|
||||
>
|
||||
<view class="input-row">
|
||||
<label class="input-label input-label--confirm" for="a05-confirm-password"
|
||||
<label class="input-label input-label--confirm" for="reset-password-confirm-password"
|
||||
><text class="required-mark">*</text>确认新密码</label
|
||||
>
|
||||
<input
|
||||
id="a05-confirm-password"
|
||||
id="reset-password-confirm-password"
|
||||
v-model="confirmPassword"
|
||||
class="auth-input"
|
||||
password
|
||||
@@ -168,7 +167,7 @@
|
||||
:aria-invalid="Boolean(fieldErrors.confirmPassword)"
|
||||
:aria-describedby="
|
||||
fieldErrors.confirmPassword
|
||||
? 'a05-confirm-password-error'
|
||||
? 'reset-password-confirm-password-error'
|
||||
: undefined
|
||||
"
|
||||
@input="clearFieldError('confirmPassword')"
|
||||
@@ -176,7 +175,7 @@
|
||||
</view>
|
||||
<text
|
||||
v-if="fieldErrors.confirmPassword"
|
||||
id="a05-confirm-password-error"
|
||||
id="reset-password-confirm-password-error"
|
||||
class="field-error"
|
||||
role="alert"
|
||||
>{{ fieldErrors.confirmPassword }}</text
|
||||
@@ -193,7 +192,7 @@
|
||||
>
|
||||
<image
|
||||
class="reset-submit__skin"
|
||||
src="/static/assets/foundation/transparent/a01-scroll-primary-v3.png"
|
||||
src="/static/assets/foundation/transparent/scroll-primary.png"
|
||||
mode="aspectFit"
|
||||
/>
|
||||
<text class="reset-submit__content">{{
|
||||
@@ -263,30 +262,26 @@ import { computed, ref } from "vue";
|
||||
import { onBackPress, onShow, onUnload } from "@dcloudio/uni-app";
|
||||
import AppDialog from "@/components/AppDialog.vue";
|
||||
import AppToast from "@/components/AppToast.vue";
|
||||
import AuthPageShell from "@/components/AuthPageShell.vue";
|
||||
import TacVerification from "@/components/TacVerification.vue";
|
||||
import AuthPageShell from "@/components/auth/PageShell.vue";
|
||||
import TacVerification from "@/components/auth/TacVerification.vue";
|
||||
import { useSmsVerification } from "@/composables/auth/use-sms-verification.js";
|
||||
import {
|
||||
appApi,
|
||||
createRequestController,
|
||||
isRequestCancelled,
|
||||
} from "@/utils/api.js";
|
||||
isRequestCancelled
|
||||
} from "@/services/api/request-controller.js";
|
||||
import { authApi } from "@/services/api/auth-service.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";
|
||||
} from "@/utils/auth/verification.js";
|
||||
import { createDiscardConfirmation } from "@/utils/discard-confirmation.js";
|
||||
import { calcMD5 } from "@/utils/md5.js";
|
||||
import { handleBackPress, returnTo, runBackGuard } from "@/utils/navigation.js";
|
||||
import { createNonIdempotentWriteGuard } from "@/utils/non-idempotent-write-guard.js";
|
||||
import { handleBackPress, returnTo, runBackGuard } from "@/utils/navigation/gateway.js";
|
||||
import {
|
||||
PASSWORD_POLICY_MESSAGE,
|
||||
validatePassword,
|
||||
} from "@/utils/validation.js";
|
||||
} from "@/utils/auth/password-policy.js";
|
||||
|
||||
const phone = ref("");
|
||||
const verificationCode = ref("");
|
||||
@@ -296,12 +291,7 @@ const successVisible = ref(false);
|
||||
const feedbackVisible = ref(false);
|
||||
const feedbackMessage = ref("");
|
||||
const discardVisible = ref(false);
|
||||
const tacVisible = ref(false);
|
||||
const tacContext = ref(null);
|
||||
const sendingCode = ref(false);
|
||||
const submitting = ref(false);
|
||||
const cooldownSeconds = ref(0);
|
||||
const sentPhone = ref("");
|
||||
const fieldErrors = ref({
|
||||
phone: "",
|
||||
verificationCode: "",
|
||||
@@ -319,15 +309,9 @@ const isDirty = computed(
|
||||
),
|
||||
);
|
||||
let feedbackTimer = null;
|
||||
let tacSequence = 0;
|
||||
let pageActive = true;
|
||||
const authRequestController = createRequestController();
|
||||
const smsCooldown = createAuthSmsCooldown({
|
||||
operationCode: AUTH_VERIFICATION_OPERATION.FORGOT_PASSWORD,
|
||||
onChange: (seconds) => {
|
||||
cooldownSeconds.value = seconds;
|
||||
},
|
||||
});
|
||||
const passwordResetSubmissionRequestController = createRequestController();
|
||||
const passwordResetGuard = createNonIdempotentWriteGuard();
|
||||
const discardConfirmation = createDiscardConfirmation((visible) => {
|
||||
discardVisible.value = visible;
|
||||
});
|
||||
@@ -338,17 +322,6 @@ const cancelDiscard = discardConfirmation.cancel;
|
||||
// 重设成功不产生可跨页消费的数据;只有远端 PUT 已成功后才显示该终态。
|
||||
const leaveResetSuccess = () => returnTo("A01", {});
|
||||
|
||||
const closeTac = () => {
|
||||
tacVisible.value = false;
|
||||
tacContext.value = null;
|
||||
};
|
||||
|
||||
const cancelPendingRequest = () => {
|
||||
authRequestController.abort();
|
||||
sendingCode.value = false;
|
||||
submitting.value = false;
|
||||
};
|
||||
|
||||
const requestBack = () => {
|
||||
if (successVisible.value) return leaveResetSuccess();
|
||||
return runBackGuard({
|
||||
@@ -356,10 +329,7 @@ const requestBack = () => {
|
||||
submitting: submitting.value || sendingCode.value,
|
||||
dirty: isDirty.value,
|
||||
"close-transient": tacVisible.value ? closeTac : cancelDiscard,
|
||||
"block-submitting": () => {
|
||||
cancelPendingRequest();
|
||||
return requestBack();
|
||||
},
|
||||
"block-submitting": () => true,
|
||||
"confirm-discard": requestDiscardConfirmation,
|
||||
});
|
||||
};
|
||||
@@ -368,12 +338,12 @@ onBackPress((event) => handleBackPress(event, requestBack));
|
||||
|
||||
onUnload(() => {
|
||||
pageActive = false;
|
||||
authRequestController.abort();
|
||||
smsVerification.dispose();
|
||||
passwordResetSubmissionRequestController.abort();
|
||||
if (feedbackTimer) clearTimeout(feedbackTimer);
|
||||
smsCooldown.dispose();
|
||||
discardConfirmation.dispose();
|
||||
});
|
||||
onShow(() => smsCooldown.sync());
|
||||
onShow(() => smsVerification.syncCooldown());
|
||||
|
||||
const showFeedback = (message) => {
|
||||
feedbackMessage.value = message;
|
||||
@@ -385,6 +355,25 @@ const showFeedback = (message) => {
|
||||
}, 2200);
|
||||
};
|
||||
|
||||
const smsVerification = useSmsVerification({
|
||||
operationCode: AUTH_VERIFICATION_OPERATION.FORGOT_PASSWORD,
|
||||
requestIdPrefix: "forgot-password",
|
||||
phone,
|
||||
isActive: () => pageActive,
|
||||
showFeedback,
|
||||
});
|
||||
const {
|
||||
tacVisible,
|
||||
tacContext,
|
||||
sendingCode,
|
||||
cooldownSeconds,
|
||||
sentPhone,
|
||||
closeTac,
|
||||
completeTac,
|
||||
handleTacFailure,
|
||||
handleTacError,
|
||||
} = smsVerification;
|
||||
|
||||
const clearFieldError = (field) => {
|
||||
fieldErrors.value[field] = "";
|
||||
};
|
||||
@@ -404,92 +393,7 @@ const prepareGetCode = async () => {
|
||||
return;
|
||||
}
|
||||
fieldErrors.value.phone = "";
|
||||
sendingCode.value = true;
|
||||
try {
|
||||
const operationCode = AUTH_VERIFICATION_OPERATION.FORGOT_PASSWORD;
|
||||
const requestedPhone = phone.value;
|
||||
const response = await appApi.getCaptchaRequirement(
|
||||
{ operationCode, subject: requestedPhone },
|
||||
{ requestController: authRequestController },
|
||||
);
|
||||
if (!pageActive) return;
|
||||
if (phone.value !== requestedPhone)
|
||||
throw new Error("手机号已变化,请重新获取验证码");
|
||||
const requirement = normalizeCaptchaRequirement(response);
|
||||
if (!requirement.required) {
|
||||
await appApi.sendSmsCode(
|
||||
{ operationCode, phone: requestedPhone },
|
||||
{ requestController: authRequestController },
|
||||
);
|
||||
if (!pageActive) return;
|
||||
sentPhone.value = requestedPhone;
|
||||
smsCooldown.start();
|
||||
showFeedback("验证码已发送");
|
||||
return;
|
||||
}
|
||||
tacSequence += 1;
|
||||
tacContext.value = createTacRenderContext({
|
||||
requestId: `a05-forgot-${tacSequence}`,
|
||||
baseUrl: runtimeConfig.baseUrl,
|
||||
clientId: runtimeConfig.clientId,
|
||||
tenantId: runtimeConfig.tenantId,
|
||||
operationCode,
|
||||
subject: requestedPhone,
|
||||
requirement,
|
||||
});
|
||||
tacVisible.value = true;
|
||||
} catch (error) {
|
||||
if (pageActive && !isRequestCancelled(error)) {
|
||||
showFeedback(error.message || "安全验证暂不可用");
|
||||
}
|
||||
} finally {
|
||||
if (pageActive) sendingCode.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const completeTac = async (result) => {
|
||||
const expectedContext = tacContext.value;
|
||||
if (!expectedContext) return;
|
||||
try {
|
||||
const ticket = normalizeTacSuccess(result, expectedContext.requestId);
|
||||
if (phone.value !== expectedContext.subject)
|
||||
throw new Error("手机号已变化,请重新验证");
|
||||
closeTac();
|
||||
sendingCode.value = true;
|
||||
await appApi.sendSmsCode(
|
||||
{
|
||||
operationCode: AUTH_VERIFICATION_OPERATION.FORGOT_PASSWORD,
|
||||
phone: expectedContext.subject,
|
||||
validToken: ticket.validToken,
|
||||
},
|
||||
{ requestController: authRequestController },
|
||||
);
|
||||
if (!pageActive) return;
|
||||
sentPhone.value = expectedContext.subject;
|
||||
smsCooldown.start();
|
||||
showFeedback("验证码已发送");
|
||||
} catch (error) {
|
||||
closeTac();
|
||||
if (pageActive) {
|
||||
if (isSmsDeliveryOutcomeUnknown(error)) {
|
||||
sentPhone.value = expectedContext.subject;
|
||||
smsCooldown.start();
|
||||
showFeedback("发送结果未知,如收到短信可直接填写;60 秒后可重试");
|
||||
} else if (!isRequestCancelled(error)) {
|
||||
showFeedback(error.message || "验证码发送失败");
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
if (pageActive) sendingCode.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleTacFailure = ({ message } = {}) =>
|
||||
showFeedback(message || "行为验证未通过,请重试");
|
||||
|
||||
const handleTacError = ({ message } = {}) => {
|
||||
closeTac();
|
||||
showFeedback(message || "安全验证暂不可用");
|
||||
return smsVerification.requestCode();
|
||||
};
|
||||
|
||||
const validateForm = () => {
|
||||
@@ -516,22 +420,36 @@ const validateForm = () => {
|
||||
const submitReset = async () => {
|
||||
if (submitting.value || sendingCode.value) return;
|
||||
if (!validateForm()) return;
|
||||
const resetPayload = {
|
||||
phone: phone.value,
|
||||
passwordHash: calcMD5(password.value),
|
||||
smsCode: verificationCode.value,
|
||||
};
|
||||
const resetAttempt = passwordResetGuard.begin(resetPayload);
|
||||
if (resetAttempt === null) {
|
||||
showFeedback(
|
||||
"上次重设结果暂时无法确认,请先返回登录页尝试新密码,不要重复提交。",
|
||||
);
|
||||
return;
|
||||
}
|
||||
submitting.value = true;
|
||||
try {
|
||||
await appApi.resetPassword(
|
||||
{
|
||||
phone: phone.value,
|
||||
passwordHash: calcMD5(password.value),
|
||||
smsCode: verificationCode.value,
|
||||
},
|
||||
{ requestController: authRequestController },
|
||||
await authApi.resetPassword(
|
||||
resetPayload,
|
||||
{ requestController: passwordResetSubmissionRequestController },
|
||||
);
|
||||
if (!pageActive) return;
|
||||
successVisible.value = true;
|
||||
} catch (error) {
|
||||
if (pageActive && !isRequestCancelled(error)) {
|
||||
showFeedback(error.message || "密码重设失败,请稍后重试");
|
||||
if (!pageActive) return;
|
||||
if (passwordResetGuard.recordFailure(resetAttempt, error)) {
|
||||
showFeedback(
|
||||
"密码重设结果暂时无法确认,请先返回登录页尝试新密码,不要重复提交。",
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (!isRequestCancelled(error))
|
||||
showFeedback(error.message || "密码重设失败,请稍后重试");
|
||||
} finally {
|
||||
if (pageActive) submitting.value = false;
|
||||
}
|
||||
@@ -1,4 +1,3 @@
|
||||
<!-- 页面编号:A-01;用途:APP 唯一登录入口,承载密码与验证码登录。 -->
|
||||
<template>
|
||||
<AuthPageShell class="auth-page login-page">
|
||||
<view class="login-content">
|
||||
@@ -6,7 +5,7 @@
|
||||
<text class="login-title">登录家谱</text>
|
||||
<image
|
||||
class="title-divider"
|
||||
src="/static/assets/modules/auth/transparent/a01-vnext-divider-v1.png"
|
||||
src="/static/assets/modules/auth/transparent/title-divider.png"
|
||||
mode="aspectFit"
|
||||
/>
|
||||
</view>
|
||||
@@ -40,7 +39,7 @@
|
||||
<view class="input-row input-row--required">
|
||||
<image
|
||||
class="input-icon"
|
||||
src="/static/assets/modules/auth/transparent/a01-icon-phone-v1.png"
|
||||
src="/static/assets/modules/auth/transparent/icon-phone.png"
|
||||
mode="aspectFit"
|
||||
/>
|
||||
<input
|
||||
@@ -69,7 +68,7 @@
|
||||
>
|
||||
<image
|
||||
class="input-icon"
|
||||
src="/static/assets/modules/auth/transparent/a01-icon-lock-v1.png"
|
||||
src="/static/assets/modules/auth/transparent/icon-password.png"
|
||||
mode="aspectFit"
|
||||
/>
|
||||
<input
|
||||
@@ -96,8 +95,8 @@
|
||||
class="password-toggle__icon"
|
||||
:src="
|
||||
passwordVisible
|
||||
? '/static/assets/modules/auth/transparent/a01-icon-eye-open-v1.png'
|
||||
: '/static/assets/modules/auth/transparent/a01-icon-eye-closed-pupil-v2.png'
|
||||
? '/static/assets/modules/auth/transparent/password-visible.png'
|
||||
: '/static/assets/modules/auth/transparent/password-hidden.png'
|
||||
"
|
||||
mode="aspectFit"
|
||||
aria-hidden="true"
|
||||
@@ -108,7 +107,7 @@
|
||||
<view v-else class="input-row input-row--required">
|
||||
<image
|
||||
class="input-icon input-icon--sms"
|
||||
src="/static/assets/modules/auth/transparent/a01-icon-sms-code-v2.png"
|
||||
src="/static/assets/modules/auth/transparent/icon-verification-code.png"
|
||||
mode="aspectFit"
|
||||
/>
|
||||
<input
|
||||
@@ -162,7 +161,7 @@
|
||||
>
|
||||
<image
|
||||
class="button-skin"
|
||||
src="/static/assets/foundation/transparent/a01-scroll-primary-v3.png"
|
||||
src="/static/assets/foundation/transparent/scroll-primary.png"
|
||||
mode="aspectFit"
|
||||
/>
|
||||
<text class="login-submit__copy">{{
|
||||
@@ -208,8 +207,8 @@
|
||||
class="agreement-icon"
|
||||
:src="
|
||||
agreed
|
||||
? '/static/assets/modules/auth/transparent/a02-agreement-checked.png'
|
||||
: '/static/assets/modules/auth/transparent/a02-agreement-unchecked.png'
|
||||
? '/static/assets/modules/auth/transparent/agreement-checked.png'
|
||||
: '/static/assets/modules/auth/transparent/agreement-unchecked.png'
|
||||
"
|
||||
mode="aspectFit"
|
||||
aria-hidden="true"
|
||||
@@ -256,14 +255,14 @@
|
||||
<script setup>
|
||||
import { ref } from "vue";
|
||||
import { onBackPress, onShow, onUnload } from "@dcloudio/uni-app";
|
||||
import AuthPageShell from "@/components/AuthPageShell.vue";
|
||||
import AuthPageShell from "@/components/auth/PageShell.vue";
|
||||
import AppToast from "@/components/AppToast.vue";
|
||||
import TacVerification from "@/components/TacVerification.vue";
|
||||
import TacVerification from "@/components/auth/TacVerification.vue";
|
||||
import {
|
||||
appApi,
|
||||
createRequestController,
|
||||
isRequestCancelled,
|
||||
} from "@/utils/api.js";
|
||||
isRequestCancelled
|
||||
} from "@/services/api/request-controller.js";
|
||||
import { authApi } from "@/services/api/auth-service.js";
|
||||
import {
|
||||
AUTH_VERIFICATION_OPERATION,
|
||||
createTacRenderContext,
|
||||
@@ -271,9 +270,9 @@ import {
|
||||
isSmsDeliveryOutcomeUnknown,
|
||||
normalizeCaptchaRequirement,
|
||||
normalizeTacSuccess,
|
||||
} from "@/utils/auth-verification.js";
|
||||
import { createAuthSmsCooldown } from "@/utils/auth-sms-cooldown.js";
|
||||
import { runtimeConfig } from "@/utils/config.js";
|
||||
} from "@/utils/auth/verification.js";
|
||||
import { createAuthSmsCooldown } from "@/utils/auth/sms-cooldown.js";
|
||||
import { runtimeConfig } from "@/utils/runtime-config.js";
|
||||
import { calcMD5 } from "@/utils/md5.js";
|
||||
import { session } from "@/utils/session.js";
|
||||
import {
|
||||
@@ -281,7 +280,7 @@ import {
|
||||
handleBackPress,
|
||||
openPage,
|
||||
runBackGuard,
|
||||
} from "@/utils/navigation.js";
|
||||
} from "@/utils/navigation/gateway.js";
|
||||
|
||||
const activeLoginMethod = ref("password");
|
||||
const passwordVisible = ref(false);
|
||||
@@ -306,7 +305,9 @@ let pendingTacAction = null;
|
||||
let pageActive = true;
|
||||
const authenticationNavigationFailure =
|
||||
"登录已完成,但暂时无法进入家谱,请再次点击进入";
|
||||
const authRequestController = createRequestController();
|
||||
const captchaRequirementRequestController = createRequestController();
|
||||
const smsDeliveryRequestController = createRequestController();
|
||||
const signInSubmissionRequestController = createRequestController();
|
||||
const smsCooldown = createAuthSmsCooldown({
|
||||
operationCode: AUTH_VERIFICATION_OPERATION.SMS_LOGIN,
|
||||
onChange: (seconds) => {
|
||||
@@ -316,7 +317,9 @@ const smsCooldown = createAuthSmsCooldown({
|
||||
|
||||
onUnload(() => {
|
||||
pageActive = false;
|
||||
authRequestController.abort();
|
||||
captchaRequirementRequestController.abort();
|
||||
smsDeliveryRequestController.abort();
|
||||
signInSubmissionRequestController.abort();
|
||||
if (feedbackTimer) clearTimeout(feedbackTimer);
|
||||
smsCooldown.dispose();
|
||||
});
|
||||
@@ -409,7 +412,9 @@ const closeTac = () => {
|
||||
};
|
||||
|
||||
const cancelPendingRequest = () => {
|
||||
authRequestController.abort();
|
||||
captchaRequirementRequestController.abort();
|
||||
smsDeliveryRequestController.abort();
|
||||
signInSubmissionRequestController.abort();
|
||||
sendingCode.value = false;
|
||||
submitting.value = false;
|
||||
};
|
||||
@@ -425,7 +430,7 @@ const requestBack = () =>
|
||||
},
|
||||
});
|
||||
|
||||
// A01 是认证根页:没有浮层时必须把系统返回交还 Android,只在行为验证浮层
|
||||
// 登录页是认证根页:没有浮层时必须把系统返回交还 Android,只在行为验证浮层
|
||||
// 可见时消费返回键,避免根页被守卫误拦而无法正常退出应用。
|
||||
onBackPress((event) => {
|
||||
if (!tacVisible.value && (submitting.value || sendingCode.value)) {
|
||||
@@ -439,22 +444,24 @@ onBackPress((event) => {
|
||||
const prepareGetCode = async () => {
|
||||
if (sendingCode.value || cooldownSeconds.value > 0) return;
|
||||
if (!validatePhone() || !requireAgreement()) return;
|
||||
const requestedPhone = phone.value;
|
||||
let smsDeliveryStarted = false;
|
||||
sendingCode.value = true;
|
||||
try {
|
||||
const operationCode = AUTH_VERIFICATION_OPERATION.SMS_LOGIN;
|
||||
const requestedPhone = phone.value;
|
||||
const response = await appApi.getCaptchaRequirement(
|
||||
const response = await authApi.getCaptchaRequirement(
|
||||
{ operationCode, subject: requestedPhone },
|
||||
{ requestController: authRequestController },
|
||||
{ requestController: captchaRequirementRequestController },
|
||||
);
|
||||
if (!pageActive) return;
|
||||
if (phone.value !== requestedPhone)
|
||||
throw new Error("手机号已变化,请重新获取验证码");
|
||||
const requirement = normalizeCaptchaRequirement(response);
|
||||
if (!requirement.required) {
|
||||
await appApi.sendSmsCode(
|
||||
smsDeliveryStarted = true;
|
||||
await authApi.sendSmsCode(
|
||||
{ operationCode, phone: requestedPhone },
|
||||
{ requestController: authRequestController },
|
||||
{ requestController: smsDeliveryRequestController },
|
||||
);
|
||||
if (!pageActive) return;
|
||||
sentPhone.value = requestedPhone;
|
||||
@@ -464,7 +471,7 @@ const prepareGetCode = async () => {
|
||||
}
|
||||
tacSequence += 1;
|
||||
tacContext.value = createTacRenderContext({
|
||||
requestId: `a01-sms-${tacSequence}`,
|
||||
requestId: `sign-in-sms-${tacSequence}`,
|
||||
baseUrl: runtimeConfig.baseUrl,
|
||||
clientId: runtimeConfig.clientId,
|
||||
tenantId: runtimeConfig.tenantId,
|
||||
@@ -478,8 +485,14 @@ const prepareGetCode = async () => {
|
||||
};
|
||||
tacVisible.value = true;
|
||||
} catch (error) {
|
||||
if (pageActive && !isRequestCancelled(error)) {
|
||||
showFeedback(error.message || "安全验证暂不可用");
|
||||
if (pageActive) {
|
||||
if (smsDeliveryStarted && isSmsDeliveryOutcomeUnknown(error)) {
|
||||
sentPhone.value = requestedPhone;
|
||||
smsCooldown.start();
|
||||
showFeedback("发送结果未知,如收到短信可直接填写;60 秒后可重试");
|
||||
} else if (!isRequestCancelled(error)) {
|
||||
showFeedback(error.message || "安全验证暂不可用");
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
if (pageActive) sendingCode.value = false;
|
||||
@@ -492,9 +505,9 @@ const preparePasswordLogin = async () => {
|
||||
submitting.value = true;
|
||||
try {
|
||||
const operationCode = AUTH_VERIFICATION_OPERATION.PASSWORD_LOGIN;
|
||||
const response = await appApi.getCaptchaRequirement(
|
||||
const response = await authApi.getCaptchaRequirement(
|
||||
{ operationCode, subject: requestedPhone },
|
||||
{ requestController: authRequestController },
|
||||
{ requestController: captchaRequirementRequestController },
|
||||
);
|
||||
if (!pageActive) return;
|
||||
if (phone.value !== requestedPhone) {
|
||||
@@ -502,9 +515,9 @@ const preparePasswordLogin = async () => {
|
||||
}
|
||||
const requirement = normalizeCaptchaRequirement(response);
|
||||
if (!requirement.required) {
|
||||
await appApi.loginWithPassword(
|
||||
await authApi.loginWithPassword(
|
||||
{ phone: requestedPhone, passwordHash },
|
||||
{ requestController: authRequestController },
|
||||
{ requestController: signInSubmissionRequestController },
|
||||
);
|
||||
if (!pageActive) return;
|
||||
authenticationCommitted.value = true;
|
||||
@@ -513,7 +526,7 @@ const preparePasswordLogin = async () => {
|
||||
}
|
||||
tacSequence += 1;
|
||||
tacContext.value = createTacRenderContext({
|
||||
requestId: `a01-password-${tacSequence}`,
|
||||
requestId: `sign-in-password-${tacSequence}`,
|
||||
baseUrl: runtimeConfig.baseUrl,
|
||||
clientId: runtimeConfig.clientId,
|
||||
tenantId: runtimeConfig.tenantId,
|
||||
@@ -555,13 +568,13 @@ const completeTac = async (result) => {
|
||||
tacContext.value = null;
|
||||
pendingTacAction = null;
|
||||
submitting.value = true;
|
||||
await appApi.loginWithPassword(
|
||||
await authApi.loginWithPassword(
|
||||
{
|
||||
phone: action.phone,
|
||||
passwordHash: action.passwordHash,
|
||||
validToken: ticket.validToken,
|
||||
},
|
||||
{ requestController: authRequestController },
|
||||
{ requestController: signInSubmissionRequestController },
|
||||
);
|
||||
if (!pageActive) return;
|
||||
authenticationCommitted.value = true;
|
||||
@@ -575,13 +588,13 @@ const completeTac = async (result) => {
|
||||
}
|
||||
closeTac();
|
||||
sendingCode.value = true;
|
||||
await appApi.sendSmsCode(
|
||||
await authApi.sendSmsCode(
|
||||
{
|
||||
operationCode: AUTH_VERIFICATION_OPERATION.SMS_LOGIN,
|
||||
phone: expectedContext.subject,
|
||||
validToken: ticket.validToken,
|
||||
},
|
||||
{ requestController: authRequestController },
|
||||
{ requestController: smsDeliveryRequestController },
|
||||
);
|
||||
if (!pageActive) return;
|
||||
sentPhone.value = expectedContext.subject;
|
||||
@@ -644,12 +657,12 @@ const submitLogin = async () => {
|
||||
}
|
||||
submitting.value = true;
|
||||
try {
|
||||
await appApi.loginWithSms(
|
||||
await authApi.loginWithSms(
|
||||
{
|
||||
phone: phone.value,
|
||||
smsCode: verificationCode.value,
|
||||
},
|
||||
{ requestController: authRequestController },
|
||||
{ requestController: signInSubmissionRequestController },
|
||||
);
|
||||
if (!pageActive) return;
|
||||
authenticationCommitted.value = true;
|
||||
@@ -1,4 +1,3 @@
|
||||
<!-- 页面编号:F-09;用途:上传真实图片并创建相册照片记录。 -->
|
||||
<template>
|
||||
<view class="media-upload-page" :class="`media-state--${pageState}`">
|
||||
<ModulePageBackground module="family" />
|
||||
@@ -9,7 +8,7 @@
|
||||
<view v-if="pageState === 'form'" class="media-panel">
|
||||
<text class="media-panel__title">添加一张家族照片</text>
|
||||
<text class="media-panel__note"
|
||||
>请先选择图片。保存时只会提交真实上传回执中的文件标识。</text
|
||||
>请先选择图片。上传成功后,照片才会保存到相册。</text
|
||||
>
|
||||
|
||||
<view class="media-field media-field--upload">
|
||||
@@ -80,16 +79,6 @@
|
||||
}}</view>
|
||||
</picker>
|
||||
</view>
|
||||
<view class="media-field">
|
||||
<text class="media-field__label">排序值</text>
|
||||
<input
|
||||
v-model="form.sortOrder"
|
||||
type="number"
|
||||
placeholder="数值越小越靠前"
|
||||
placeholder-class="placeholder"
|
||||
@input="submitError = ''"
|
||||
/>
|
||||
</view>
|
||||
<text v-if="submitError" class="field-error">{{ submitError }}</text>
|
||||
<AppButton
|
||||
block
|
||||
@@ -109,7 +98,7 @@
|
||||
<AppDialog
|
||||
:visible="discardVisible"
|
||||
title="放弃照片草稿?"
|
||||
message="当前内容尚未保存到服务端,返回后不会保留。"
|
||||
message="照片还没有保存,返回后将不会保留。"
|
||||
confirm-text="放弃并返回"
|
||||
cancel-text="继续填写"
|
||||
show-cancel
|
||||
@@ -120,28 +109,30 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, onUnmounted, reactive, ref } from "vue";
|
||||
import { onBackPress, onLoad } from "@dcloudio/uni-app";
|
||||
import { computed, reactive, ref } from "vue";
|
||||
import { onBackPress, onLoad, onUnload } from "@dcloudio/uni-app";
|
||||
import AppButton from "@/components/AppButton.vue";
|
||||
import AppDialog from "@/components/AppDialog.vue";
|
||||
import ModulePageBackground from "@/components/ModulePageBackground.vue";
|
||||
import PageHeader from "@/components/PageHeader.vue";
|
||||
import {
|
||||
appApi,
|
||||
createRequestController,
|
||||
isRequestCancelled,
|
||||
} from "@/utils/api.js";
|
||||
isRequestCancelled
|
||||
} from "@/services/api/request-controller.js";
|
||||
import { familyMediaApi } from "@/services/api/family-media-service.js";
|
||||
import { getRequestErrorMessage } from "@/services/api/request-error-message.js";
|
||||
import {
|
||||
isImagePickCancelled,
|
||||
pickAndUploadImage,
|
||||
} from "@/utils/resumable-image-upload.js";
|
||||
} from "@/utils/media-upload.js";
|
||||
import { createDiscardConfirmation } from "@/utils/discard-confirmation.js";
|
||||
import { createNonIdempotentWriteGuard } from "@/utils/non-idempotent-write-guard.js";
|
||||
import {
|
||||
goBack,
|
||||
handleBackPress,
|
||||
returnTo,
|
||||
runBackGuard,
|
||||
} from "@/utils/navigation.js";
|
||||
} from "@/utils/navigation/gateway.js";
|
||||
|
||||
const genealogyId = ref("");
|
||||
const albumId = ref("");
|
||||
@@ -157,9 +148,11 @@ const form = reactive({
|
||||
photographer: "",
|
||||
shootDate: "",
|
||||
shootClock: "",
|
||||
sortOrder: "",
|
||||
});
|
||||
const controller = createRequestController();
|
||||
const photoUploadController = createRequestController();
|
||||
const photoSaveController = createRequestController();
|
||||
const photoCreateGuard = createNonIdempotentWriteGuard();
|
||||
let pageActive = true;
|
||||
const discardVisible = ref(false);
|
||||
const confirmation = createDiscardConfirmation((visible) => {
|
||||
discardVisible.value = visible;
|
||||
@@ -179,13 +172,13 @@ const shootTime = computed(() =>
|
||||
const stateCopy = computed(() =>
|
||||
pageState.value === "success"
|
||||
? {
|
||||
title: "照片已提交服务端",
|
||||
copy: "服务端已返回成功结果。",
|
||||
title: "照片已添加",
|
||||
copy: "照片已保存到相册。",
|
||||
action: "返回相册",
|
||||
}
|
||||
: {
|
||||
title: "照片入口无效",
|
||||
copy: "没有取得有效家谱或相册标识。",
|
||||
title: "暂时无法添加照片",
|
||||
copy: "未找到家谱或相册信息,请返回后重新进入。",
|
||||
action: "返回上一页",
|
||||
},
|
||||
);
|
||||
@@ -201,13 +194,17 @@ const selectPhoto = async () => {
|
||||
uploading.value = true;
|
||||
uploadError.value = "";
|
||||
try {
|
||||
receipt.value = await pickAndUploadImage({ requestController: controller });
|
||||
const uploadedPhoto = await pickAndUploadImage({
|
||||
requestController: photoUploadController,
|
||||
});
|
||||
if (!pageActive) return;
|
||||
receipt.value = uploadedPhoto;
|
||||
} catch (error) {
|
||||
if (!isImagePickCancelled(error) && !isRequestCancelled(error)) {
|
||||
uploadError.value = error?.message || "图片上传失败,请稍后重试";
|
||||
if (pageActive && !isImagePickCancelled(error) && !isRequestCancelled(error)) {
|
||||
uploadError.value = getRequestErrorMessage(error, "图片上传失败,请稍后重试");
|
||||
}
|
||||
} finally {
|
||||
uploading.value = false;
|
||||
if (pageActive) uploading.value = false;
|
||||
}
|
||||
};
|
||||
const selectShootDate = (event) => {
|
||||
@@ -225,26 +222,41 @@ const submitPhoto = async () => {
|
||||
submitError.value = "请先选择并上传照片";
|
||||
return;
|
||||
}
|
||||
const { shootDate, shootClock, ...photoForm } = form;
|
||||
const payload = {
|
||||
...photoForm,
|
||||
shootTime: shootTime.value,
|
||||
ossId: receipt.value.ossId,
|
||||
};
|
||||
const createAttempt = photoCreateGuard.begin(payload);
|
||||
if (createAttempt === null) {
|
||||
submitError.value =
|
||||
"上次保存结果暂时无法确认,请先返回相册检查,避免重复添加。";
|
||||
return;
|
||||
}
|
||||
|
||||
submitting.value = true;
|
||||
submitError.value = "";
|
||||
try {
|
||||
const { shootDate, shootClock, ...photoForm } = form;
|
||||
await appApi.createAlbumPhoto(
|
||||
await familyMediaApi.createAlbumPhoto(
|
||||
genealogyId.value,
|
||||
albumId.value,
|
||||
{
|
||||
...photoForm,
|
||||
shootTime: shootTime.value,
|
||||
ossId: receipt.value.ossId,
|
||||
},
|
||||
{ requestController: controller },
|
||||
payload,
|
||||
{ requestController: photoSaveController },
|
||||
);
|
||||
if (!pageActive) return;
|
||||
pageState.value = "success";
|
||||
} catch (error) {
|
||||
if (!pageActive) return;
|
||||
if (photoCreateGuard.recordFailure(createAttempt, error)) {
|
||||
submitError.value =
|
||||
"保存结果暂时无法确认,请先返回相册检查,避免重复添加。";
|
||||
return;
|
||||
}
|
||||
if (!isRequestCancelled(error))
|
||||
submitError.value = error?.message || "照片保存失败,请稍后重试";
|
||||
submitError.value = getRequestErrorMessage(error, "照片保存失败,请稍后重试");
|
||||
} finally {
|
||||
submitting.value = false;
|
||||
if (pageActive) submitting.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -267,8 +279,10 @@ const requestBack = () =>
|
||||
const handleStateAction = () =>
|
||||
pageState.value === "success" ? returnToAlbum() : goBack();
|
||||
onBackPress((event) => handleBackPress(event, requestBack));
|
||||
onUnmounted(() => {
|
||||
controller.abort();
|
||||
onUnload(() => {
|
||||
pageActive = false;
|
||||
photoUploadController.abort();
|
||||
photoSaveController.abort();
|
||||
confirmation.dispose();
|
||||
});
|
||||
</script>
|
||||
@@ -0,0 +1,266 @@
|
||||
<template>
|
||||
<view class="album-detail-page">
|
||||
<ModulePageBackground module="family" />
|
||||
<view class="album-detail-header">
|
||||
<PageHeader
|
||||
title="相册详情"
|
||||
:action="valid ? '添加' : ''"
|
||||
custom-back
|
||||
@back="returnToAlbums"
|
||||
@action="addPhoto"
|
||||
/>
|
||||
</view>
|
||||
<view class="album-detail-content">
|
||||
<view v-if="!valid" class="album-state-card">
|
||||
<text>暂时无法打开相册</text>
|
||||
<AppButton block label="返回相册列表" @click="returnToAlbums" />
|
||||
</view>
|
||||
<view v-else-if="albumPhotoListState === 'loading'" class="album-state-card">
|
||||
<AppLoading text="正在读取相册照片" />
|
||||
</view>
|
||||
<view v-else-if="albumPhotoListState === 'error'" class="album-state-card">
|
||||
<text>暂时无法读取相册照片</text>
|
||||
<AppButton block type="secondary" label="重新加载" @click="loadPhotos" />
|
||||
</view>
|
||||
<view v-else-if="albumPhotoListState === 'empty'" class="album-state-card">
|
||||
<text>还没有照片</text>
|
||||
<text>添加照片后,页面会显示最新相册内容。</text>
|
||||
<AppButton block label="添加照片" @click="addPhoto" />
|
||||
</view>
|
||||
<view v-else class="photo-list">
|
||||
<text v-if="deleteError" class="photo-list__error">{{ deleteError }}</text>
|
||||
<view v-for="item in photos" :key="item.id" class="photo-card">
|
||||
<image
|
||||
class="photo-card__image"
|
||||
:src="item.photoFile.accessUrl"
|
||||
mode="widthFix"
|
||||
role="button"
|
||||
:aria-label="`查看大图:${item.title}`"
|
||||
@click="previewPhoto(item)"
|
||||
/>
|
||||
<text>{{ item.title }}</text>
|
||||
<text v-if="item.description">{{ item.description }}</text>
|
||||
<text v-if="item.meta">{{ item.meta }}</text>
|
||||
<view v-if="item.canDelete" class="photo-card__actions">
|
||||
<AppButton compact type="secondary" label="删除照片" @click.stop="requestDeletePhoto(item)" />
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<AppDialog
|
||||
:visible="deleteConfirmationVisible"
|
||||
:close-on-mask="false"
|
||||
eyebrow="删除确认"
|
||||
title="删除这张照片?"
|
||||
message="删除后无法恢复,请确认影像已另行保存。"
|
||||
confirm-text="确认删除"
|
||||
cancel-text="保留照片"
|
||||
show-cancel
|
||||
@confirm="deletePhoto"
|
||||
@cancel="closeDeleteConfirmation"
|
||||
/>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref } from "vue";
|
||||
import { onLoad, 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";
|
||||
import {
|
||||
createRequestController,
|
||||
isRequestCancelled
|
||||
} from "@/services/api/request-controller.js";
|
||||
import { familyMediaApi } from "@/services/api/family-media-service.js";
|
||||
import { getRequestErrorMessage } from "@/services/api/request-error-message.js";
|
||||
import { goBack, openPage, returnTo } from "@/utils/navigation/gateway.js";
|
||||
|
||||
const genealogyId = ref("");
|
||||
const albumId = ref("");
|
||||
const photos = ref([]);
|
||||
const albumPhotoListState = ref("loading");
|
||||
const deleteTarget = ref(null);
|
||||
const deleteConfirmationVisible = ref(false);
|
||||
const deletingPhotoId = ref("");
|
||||
const deleteError = ref("");
|
||||
const albumPhotoListController = createRequestController();
|
||||
const albumPhotoDeleteController = createRequestController();
|
||||
let isPageActive = true;
|
||||
const valid = computed(
|
||||
() =>
|
||||
/^[1-9]\d*$/.test(genealogyId.value) && /^[1-9]\d*$/.test(albumId.value),
|
||||
);
|
||||
const loadPhotos = async () => {
|
||||
if (!valid.value) return;
|
||||
albumPhotoListController.abort();
|
||||
albumPhotoListState.value = "loading";
|
||||
try {
|
||||
const albumPhotos = await familyMediaApi.getAlbumPhotos(genealogyId.value, albumId.value, {
|
||||
requestController: albumPhotoListController,
|
||||
});
|
||||
if (!isPageActive) return;
|
||||
photos.value = albumPhotos.map((photo) => ({
|
||||
...photo,
|
||||
meta: [photo.photographer, photo.shootTime].filter(Boolean).join(" · "),
|
||||
}));
|
||||
albumPhotoListState.value = photos.value.length ? "ready" : "empty";
|
||||
} catch (error) {
|
||||
if (!isPageActive || isRequestCancelled(error)) return;
|
||||
albumPhotoListState.value = "error";
|
||||
}
|
||||
};
|
||||
const returnToAlbums = () =>
|
||||
/^[1-9]\d*$/.test(genealogyId.value)
|
||||
? returnTo("F07", { genealogyId: genealogyId.value })
|
||||
: goBack();
|
||||
const addPhoto = () =>
|
||||
valid.value
|
||||
? openPage(
|
||||
"F09",
|
||||
{ genealogyId: genealogyId.value, albumId: albumId.value },
|
||||
"F08",
|
||||
)
|
||||
: Promise.resolve(false);
|
||||
const previewPhoto = (photo) => {
|
||||
const urls = photos.value.map((item) => item.photoFile?.accessUrl).filter(Boolean);
|
||||
const current = photo?.photoFile?.accessUrl;
|
||||
if (!current || !urls.length || typeof uni?.previewImage !== "function") return;
|
||||
uni.previewImage({ current, urls });
|
||||
};
|
||||
const requestDeletePhoto = (photo) => {
|
||||
if (!photo?.canDelete || deletingPhotoId.value) return;
|
||||
deleteError.value = "";
|
||||
deleteTarget.value = photo;
|
||||
deleteConfirmationVisible.value = true;
|
||||
};
|
||||
const closeDeleteConfirmation = () => {
|
||||
if (!deletingPhotoId.value) {
|
||||
deleteConfirmationVisible.value = false;
|
||||
deleteTarget.value = null;
|
||||
}
|
||||
};
|
||||
const deletePhoto = async () => {
|
||||
const photo = deleteTarget.value;
|
||||
if (!photo?.canDelete || deletingPhotoId.value) return;
|
||||
deletingPhotoId.value = photo.id;
|
||||
deleteError.value = "";
|
||||
try {
|
||||
await familyMediaApi.deleteAlbumPhoto(genealogyId.value, albumId.value, photo.id, {
|
||||
requestController: albumPhotoDeleteController,
|
||||
});
|
||||
if (!isPageActive) return;
|
||||
deleteConfirmationVisible.value = false;
|
||||
deleteTarget.value = null;
|
||||
await loadPhotos();
|
||||
} catch (error) {
|
||||
if (!isPageActive || isRequestCancelled(error)) return;
|
||||
deleteError.value = getRequestErrorMessage(error, "照片删除失败,请稍后重试。");
|
||||
deleteConfirmationVisible.value = false;
|
||||
} finally {
|
||||
if (isPageActive) deletingPhotoId.value = "";
|
||||
}
|
||||
};
|
||||
onLoad((query) => {
|
||||
genealogyId.value = String(query?.genealogyId || "");
|
||||
albumId.value = String(query?.albumId || "");
|
||||
if (valid.value) loadPhotos();
|
||||
});
|
||||
onShow(() => {
|
||||
if (valid.value && albumPhotoListState.value !== "loading") loadPhotos();
|
||||
});
|
||||
onUnload(() => {
|
||||
isPageActive = false;
|
||||
albumPhotoListController.abort();
|
||||
albumPhotoDeleteController.abort();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@import "../../styles/adaptive-frame-profiles.scss";
|
||||
.album-detail-page {
|
||||
display: flex;
|
||||
min-height: 100vh;
|
||||
flex-direction: column;
|
||||
background: $paper;
|
||||
}
|
||||
.album-detail-header,
|
||||
.album-detail-content {
|
||||
z-index: 1;
|
||||
}
|
||||
.album-detail-content {
|
||||
padding: 22rpx 24rpx calc(48rpx + env(safe-area-inset-bottom));
|
||||
}
|
||||
.album-state-card,
|
||||
.photo-card {
|
||||
@include adaptive-family-content;
|
||||
}
|
||||
.album-state-card {
|
||||
width: 100%;
|
||||
min-height: 340rpx;
|
||||
padding: 84rpx 44rpx 56rpx;
|
||||
box-sizing: border-box;
|
||||
text-align: center;
|
||||
}
|
||||
.album-state-card text {
|
||||
display: block;
|
||||
}
|
||||
.album-state-card text:first-child {
|
||||
color: $ink;
|
||||
font-family: STKaiti, KaiTi, serif;
|
||||
font-size: clamp(19px, 36rpx, 24px);
|
||||
font-weight: 700;
|
||||
}
|
||||
.album-state-card text:nth-child(2) {
|
||||
margin-top: 10rpx;
|
||||
color: $ink-muted;
|
||||
font-size: clamp(14px, 23rpx, 17px);
|
||||
line-height: 1.6;
|
||||
}
|
||||
.album-state-card .app-button {
|
||||
margin-top: 34rpx;
|
||||
}
|
||||
.photo-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16rpx;
|
||||
}
|
||||
.photo-card {
|
||||
padding: 28rpx 32rpx;
|
||||
}
|
||||
.photo-list__error {
|
||||
display: block;
|
||||
color: $brand-red;
|
||||
font-size: clamp(13px, 21rpx, 16px);
|
||||
line-height: 1.5;
|
||||
}
|
||||
.photo-card__image {
|
||||
display: block;
|
||||
width: 100%;
|
||||
margin-bottom: 20rpx;
|
||||
border-radius: 10rpx;
|
||||
background: rgba(128, 89, 49, 0.12);
|
||||
}
|
||||
.photo-card__actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-top: 16rpx;
|
||||
}
|
||||
.photo-card text {
|
||||
display: block;
|
||||
}
|
||||
.photo-card text:first-child {
|
||||
color: $ink;
|
||||
font-family: STKaiti, KaiTi, serif;
|
||||
font-size: clamp(17px, 30rpx, 22px);
|
||||
font-weight: 700;
|
||||
}
|
||||
.photo-card text:not(:first-child) {
|
||||
margin-top: 8rpx;
|
||||
color: $ink-muted;
|
||||
font-size: clamp(14px, 22rpx, 17px);
|
||||
line-height: 1.5;
|
||||
}
|
||||
</style>
|
||||
@@ -1,4 +1,3 @@
|
||||
<!-- 页面编号:F-07;用途:读取并创建当前家谱的相册。 -->
|
||||
<template>
|
||||
<view class="album-list-page">
|
||||
<ModulePageBackground module="family" />
|
||||
@@ -12,7 +11,7 @@
|
||||
/></view>
|
||||
<view class="album-list-content">
|
||||
<view v-if="!hasValidContext" class="album-state-card"
|
||||
><text>相册入口无效</text><text>没有取得有效家谱标识。</text
|
||||
><text>暂时无法打开相册</text><text>未找到家谱信息,请返回后重新进入。</text
|
||||
><AppButton block label="返回上一页" @click="goBack"
|
||||
/></view>
|
||||
<view v-else-if="listState === 'loading'" class="album-state-card"
|
||||
@@ -27,22 +26,50 @@
|
||||
><AppButton block label="新建相册" @click="openCreateDialog"
|
||||
/></view>
|
||||
<view v-else class="album-list">
|
||||
<text v-if="deleteError" class="album-list__error">{{ deleteError }}</text>
|
||||
<view
|
||||
v-for="item in albums"
|
||||
:key="item.id"
|
||||
class="album-card"
|
||||
@click="openAlbum(item)"
|
||||
><text>{{ item.name }}</text
|
||||
><text v-if="item.description">{{ item.description }}</text
|
||||
><text>{{ item.photoCount }} 张照片</text></view
|
||||
>
|
||||
<image
|
||||
v-if="item.coverFile?.accessUrl"
|
||||
class="album-card__cover"
|
||||
:src="item.coverFile.accessUrl"
|
||||
mode="aspectFill"
|
||||
/>
|
||||
<text>{{ item.name }}</text>
|
||||
<text v-if="item.description">{{ item.description }}</text>
|
||||
<text>{{ item.photoCount }} 张照片</text>
|
||||
<view
|
||||
v-if="item.canEdit || item.canDelete"
|
||||
class="album-card__actions"
|
||||
@click.stop
|
||||
>
|
||||
<AppButton
|
||||
v-if="item.canEdit"
|
||||
compact
|
||||
type="secondary"
|
||||
label="编辑相册"
|
||||
@click="openEditDialog(item)"
|
||||
/>
|
||||
<AppButton
|
||||
v-if="item.canDelete"
|
||||
compact
|
||||
type="secondary"
|
||||
label="删除相册"
|
||||
@click="requestDeleteAlbum(item)"
|
||||
/>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<AppDialog
|
||||
:visible="dialogVisible"
|
||||
eyebrow="新建相册"
|
||||
title="为家人整理一段影像"
|
||||
:confirm-text="isSubmitting ? '正在提交' : '提交相册'"
|
||||
:eyebrow="editingAlbum ? '编辑相册' : '新建相册'"
|
||||
:title="editingAlbum ? '更新这本家族相册' : '为家人整理一段影像'"
|
||||
:confirm-text="isSubmitting ? '正在提交' : editingAlbum ? '保存修改' : '提交相册'"
|
||||
cancel-text="取消"
|
||||
show-cancel
|
||||
:close-on-mask="false"
|
||||
@@ -69,7 +96,7 @@
|
||||
><view
|
||||
><text>封面图片</text
|
||||
><text class="album-field-hint"
|
||||
>选择图片后会取得真实上传回执,并作为封面关联。</text
|
||||
>图片上传成功后,会作为相册封面保存。</text
|
||||
></view
|
||||
><button
|
||||
class="upload-button"
|
||||
@@ -83,104 +110,129 @@
|
||||
uploadError
|
||||
}}</text></view
|
||||
>
|
||||
<view class="album-dialog-field"
|
||||
><text>排序值</text
|
||||
><input
|
||||
v-model="form.sortOrder"
|
||||
type="number"
|
||||
placeholder="数值越小越靠前"
|
||||
@input="submitError = ''"
|
||||
/></view>
|
||||
<text v-if="submitError" class="album-field-error">{{
|
||||
submitError
|
||||
}}</text>
|
||||
</AppDialog>
|
||||
<AppDialog
|
||||
:visible="deleteConfirmationVisible"
|
||||
:close-on-mask="false"
|
||||
eyebrow="删除确认"
|
||||
title="删除这个相册?"
|
||||
message="相册中的照片也可能无法恢复,请确认已另行保存。"
|
||||
confirm-text="确认删除"
|
||||
cancel-text="保留相册"
|
||||
show-cancel
|
||||
@confirm="deleteAlbum"
|
||||
@cancel="closeDeleteConfirmation"
|
||||
/>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, onUnmounted, reactive, ref } from "vue";
|
||||
import { onBackPress, onLoad, onShow } from "@dcloudio/uni-app";
|
||||
import { computed, reactive, ref } from "vue";
|
||||
import { onBackPress, onLoad, 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";
|
||||
import {
|
||||
appApi,
|
||||
createRequestController,
|
||||
isRequestCancelled,
|
||||
} from "@/utils/api.js";
|
||||
isRequestCancelled
|
||||
} from "@/services/api/request-controller.js";
|
||||
import { familyMediaApi } from "@/services/api/family-media-service.js";
|
||||
import { getRequestErrorMessage } from "@/services/api/request-error-message.js";
|
||||
import {
|
||||
isImagePickCancelled,
|
||||
pickAndUploadImage,
|
||||
} from "@/utils/resumable-image-upload.js";
|
||||
} from "@/utils/media-upload.js";
|
||||
import { createNonIdempotentWriteGuard } from "@/utils/non-idempotent-write-guard.js";
|
||||
import {
|
||||
goBack,
|
||||
handleBackPress,
|
||||
openPage,
|
||||
runBackGuard,
|
||||
} from "@/utils/navigation.js";
|
||||
} from "@/utils/navigation/gateway.js";
|
||||
|
||||
const genealogyId = ref("");
|
||||
const albums = ref([]);
|
||||
const listState = ref("loading");
|
||||
const listError = ref("");
|
||||
const dialogVisible = ref(false);
|
||||
const form = reactive({ albumName: "", albumDesc: "", sortOrder: "" });
|
||||
const form = reactive({ albumName: "", albumDesc: "" });
|
||||
const editingAlbum = ref(null);
|
||||
const coverOssId = ref(null);
|
||||
const coverFileName = ref("");
|
||||
const submitError = ref("");
|
||||
const uploadError = ref("");
|
||||
const uploading = ref(false);
|
||||
const isSubmitting = ref(false);
|
||||
const controller = createRequestController();
|
||||
let active = true;
|
||||
const deleteTarget = ref(null);
|
||||
const deleteConfirmationVisible = ref(false);
|
||||
const deletingAlbumId = ref("");
|
||||
const deleteError = ref("");
|
||||
const albumListController = createRequestController();
|
||||
const albumCoverUploadController = createRequestController();
|
||||
const albumSaveController = createRequestController();
|
||||
const albumDeleteController = createRequestController();
|
||||
const albumCreateGuard = createNonIdempotentWriteGuard();
|
||||
let isPageActive = true;
|
||||
const hasValidContext = computed(() => /^[1-9]\d*$/.test(genealogyId.value));
|
||||
const text = (value) =>
|
||||
typeof value === "string" || typeof value === "number"
|
||||
? String(value).trim()
|
||||
: "";
|
||||
const toAlbum = (item) => {
|
||||
const id = text(item?.albumId);
|
||||
if (!/^[1-9]\d*$/.test(id)) return null;
|
||||
return {
|
||||
id,
|
||||
name: text(item.albumName) || "未命名相册",
|
||||
description: text(item.albumDesc),
|
||||
photoCount: Number.isSafeInteger(item.photoCount) ? item.photoCount : 0,
|
||||
};
|
||||
};
|
||||
const loadAlbums = async () => {
|
||||
if (!hasValidContext.value) return;
|
||||
controller.abort();
|
||||
albumListController.abort();
|
||||
listState.value = "loading";
|
||||
listError.value = "";
|
||||
try {
|
||||
const rows = await appApi.getAlbums(genealogyId.value, {
|
||||
requestController: controller,
|
||||
const rows = await familyMediaApi.getAlbums(genealogyId.value, {
|
||||
requestController: albumListController,
|
||||
});
|
||||
if (!active) return;
|
||||
albums.value = rows.map(toAlbum).filter(Boolean);
|
||||
if (!isPageActive) return;
|
||||
albums.value = rows;
|
||||
listState.value = albums.value.length ? "list" : "empty";
|
||||
} catch (error) {
|
||||
if (!active || isRequestCancelled(error)) return;
|
||||
if (!isPageActive || isRequestCancelled(error)) return;
|
||||
listState.value = "error";
|
||||
listError.value = error?.message || "请稍后重试。";
|
||||
listError.value = getRequestErrorMessage(error, "请稍后重试。");
|
||||
}
|
||||
};
|
||||
const resetDraft = () => {
|
||||
Object.assign(form, { albumName: "", albumDesc: "", sortOrder: "" });
|
||||
Object.assign(form, { albumName: "", albumDesc: "" });
|
||||
coverOssId.value = null;
|
||||
coverFileName.value = "";
|
||||
submitError.value = "";
|
||||
uploadError.value = "";
|
||||
editingAlbum.value = null;
|
||||
};
|
||||
const openCreateDialog = () => {
|
||||
if (!hasValidContext.value || isSubmitting.value || uploading.value) return;
|
||||
resetDraft();
|
||||
dialogVisible.value = true;
|
||||
};
|
||||
const openEditDialog = (album) => {
|
||||
if (
|
||||
!album?.canEdit ||
|
||||
isSubmitting.value ||
|
||||
uploading.value ||
|
||||
!Number.isSafeInteger(album.sortOrder) ||
|
||||
!["0", "1"].includes(album.status)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
resetDraft();
|
||||
Object.assign(form, { albumName: album.name, albumDesc: album.description });
|
||||
coverOssId.value = album.coverFile?.ossId || null;
|
||||
coverFileName.value = album.coverFile
|
||||
? album.coverFile.fileName || "当前封面图片"
|
||||
: "";
|
||||
editingAlbum.value = {
|
||||
id: album.id,
|
||||
sortOrder: album.sortOrder,
|
||||
status: album.status,
|
||||
};
|
||||
dialogVisible.value = true;
|
||||
};
|
||||
const closeCreateDialog = () => {
|
||||
if (!isSubmitting.value && !uploading.value) dialogVisible.value = false;
|
||||
};
|
||||
@@ -189,14 +241,18 @@ const uploadCover = async () => {
|
||||
uploading.value = true;
|
||||
uploadError.value = "";
|
||||
try {
|
||||
const receipt = await pickAndUploadImage({ requestController: controller });
|
||||
const receipt = await pickAndUploadImage({
|
||||
requestController: albumCoverUploadController,
|
||||
});
|
||||
if (!isPageActive) return;
|
||||
coverOssId.value = receipt.ossId;
|
||||
coverFileName.value = receipt.fileName || "封面图片";
|
||||
} catch (error) {
|
||||
if (!isPageActive) return;
|
||||
if (!isImagePickCancelled(error) && !isRequestCancelled(error))
|
||||
uploadError.value = error?.message || "封面图片上传失败,请稍后重试。";
|
||||
uploadError.value = getRequestErrorMessage(error, "封面图片上传失败,请稍后重试。");
|
||||
} finally {
|
||||
uploading.value = false;
|
||||
if (isPageActive) uploading.value = false;
|
||||
}
|
||||
};
|
||||
const submitAlbum = async () => {
|
||||
@@ -205,25 +261,89 @@ const submitAlbum = async () => {
|
||||
submitError.value = "请填写相册名称";
|
||||
return;
|
||||
}
|
||||
const payload = {
|
||||
...form,
|
||||
coverOssId: coverOssId.value,
|
||||
...(editingAlbum.value
|
||||
? {
|
||||
sortOrder: editingAlbum.value.sortOrder,
|
||||
status: editingAlbum.value.status,
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
const createAttempt = editingAlbum.value ? null : albumCreateGuard.begin(payload);
|
||||
if (!editingAlbum.value && createAttempt === null) {
|
||||
submitError.value =
|
||||
"上次提交结果暂时无法确认,请先关闭窗口并检查相册列表,避免重复创建。";
|
||||
return;
|
||||
}
|
||||
|
||||
isSubmitting.value = true;
|
||||
submitError.value = "";
|
||||
try {
|
||||
await appApi.createAlbum(
|
||||
genealogyId.value,
|
||||
{ ...form, coverOssId: coverOssId.value },
|
||||
{ requestController: controller },
|
||||
);
|
||||
if (editingAlbum.value) {
|
||||
await familyMediaApi.updateAlbum(
|
||||
genealogyId.value,
|
||||
editingAlbum.value.id,
|
||||
payload,
|
||||
{ requestController: albumSaveController },
|
||||
);
|
||||
} else {
|
||||
await familyMediaApi.createAlbum(genealogyId.value, payload, {
|
||||
requestController: albumSaveController,
|
||||
});
|
||||
}
|
||||
if (!isPageActive) return;
|
||||
dialogVisible.value = false;
|
||||
await loadAlbums();
|
||||
} catch (error) {
|
||||
if (!isPageActive) return;
|
||||
if (!editingAlbum.value && albumCreateGuard.recordFailure(createAttempt, error)) {
|
||||
submitError.value =
|
||||
"提交结果暂时无法确认,请先关闭窗口并检查相册列表,避免重复创建。";
|
||||
return;
|
||||
}
|
||||
if (!isRequestCancelled(error))
|
||||
submitError.value = error?.message || "相册提交失败,请稍后重试。";
|
||||
submitError.value = getRequestErrorMessage(error, "相册提交失败,请稍后重试。");
|
||||
} finally {
|
||||
isSubmitting.value = false;
|
||||
if (isPageActive) isSubmitting.value = false;
|
||||
}
|
||||
};
|
||||
const openAlbum = (item) =>
|
||||
openPage("F08", { genealogyId: genealogyId.value, albumId: item.id }, "F07");
|
||||
const requestDeleteAlbum = (album) => {
|
||||
if (!album?.canDelete || deletingAlbumId.value) return;
|
||||
deleteError.value = "";
|
||||
deleteTarget.value = album;
|
||||
deleteConfirmationVisible.value = true;
|
||||
};
|
||||
const closeDeleteConfirmation = () => {
|
||||
if (!deletingAlbumId.value) {
|
||||
deleteConfirmationVisible.value = false;
|
||||
deleteTarget.value = null;
|
||||
}
|
||||
};
|
||||
const deleteAlbum = async () => {
|
||||
const album = deleteTarget.value;
|
||||
if (!album?.canDelete || deletingAlbumId.value) return;
|
||||
deletingAlbumId.value = album.id;
|
||||
deleteError.value = "";
|
||||
try {
|
||||
await familyMediaApi.deleteAlbum(genealogyId.value, album.id, {
|
||||
requestController: albumDeleteController,
|
||||
});
|
||||
if (!isPageActive) return;
|
||||
deleteConfirmationVisible.value = false;
|
||||
deleteTarget.value = null;
|
||||
await loadAlbums();
|
||||
} catch (error) {
|
||||
if (!isPageActive || isRequestCancelled(error)) return;
|
||||
deleteError.value = getRequestErrorMessage(error, "相册删除失败,请稍后重试。");
|
||||
deleteConfirmationVisible.value = false;
|
||||
} finally {
|
||||
if (isPageActive) deletingAlbumId.value = "";
|
||||
}
|
||||
};
|
||||
const requestBack = () =>
|
||||
runBackGuard({
|
||||
transientOpen: dialogVisible.value,
|
||||
@@ -245,9 +365,12 @@ onShow(() => {
|
||||
loadAlbums();
|
||||
});
|
||||
onBackPress((event) => handleBackPress(event, requestBack));
|
||||
onUnmounted(() => {
|
||||
active = false;
|
||||
controller.abort();
|
||||
onUnload(() => {
|
||||
isPageActive = false;
|
||||
albumListController.abort();
|
||||
albumCoverUploadController.abort();
|
||||
albumSaveController.abort();
|
||||
albumDeleteController.abort();
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -279,6 +402,18 @@ onUnmounted(() => {
|
||||
.album-card text {
|
||||
display: block;
|
||||
}
|
||||
.album-card__actions {
|
||||
display: flex;
|
||||
gap: 12rpx;
|
||||
justify-content: flex-end;
|
||||
margin-top: 14rpx;
|
||||
}
|
||||
.album-list__error {
|
||||
display: block;
|
||||
color: $brand-red;
|
||||
font-size: clamp(13px, 21rpx, 16px);
|
||||
line-height: 1.5;
|
||||
}
|
||||
.album-state-card text:first-child,
|
||||
.album-card text:first-child {
|
||||
color: $ink;
|
||||
@@ -304,6 +439,14 @@ onUnmounted(() => {
|
||||
.album-card {
|
||||
padding: 28rpx 30rpx;
|
||||
}
|
||||
.album-card__cover {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 240rpx;
|
||||
margin-bottom: 20rpx;
|
||||
border-radius: 10rpx;
|
||||
background: rgba(128, 89, 49, 0.12);
|
||||
}
|
||||
.album-dialog-field {
|
||||
width: 100%;
|
||||
margin: 20rpx 0;
|
||||
@@ -0,0 +1,457 @@
|
||||
<template>
|
||||
<view class="article-detail-page" :class="`article-state--${articleState}`">
|
||||
<ModulePageBackground module="family" />
|
||||
<view class="article-detail-header"
|
||||
><PageHeader title="谱文详情" custom-back @back="backToArticles"
|
||||
/></view>
|
||||
|
||||
<view class="article-detail-content">
|
||||
<AppLoading
|
||||
v-if="articleState === 'loading'"
|
||||
text="正在读取谱文"
|
||||
description="请稍候,正在同步谱文正文。"
|
||||
/>
|
||||
<view v-else-if="articleState === 'ready'" class="article-card">
|
||||
<text v-if="article.category" class="article-card__category">{{
|
||||
article.category
|
||||
}}</text>
|
||||
<text class="article-card__title">{{ article.title }}</text>
|
||||
<text class="article-card__meta"
|
||||
>{{ article.author }} · {{ article.time || "未标注时间" }}</text
|
||||
>
|
||||
<text v-if="article.summary" class="article-card__summary">{{
|
||||
article.summary
|
||||
}}</text>
|
||||
<view class="article-card__divider" />
|
||||
<view v-if="article.contentProtected && !article.contentUnlocked" class="article-lock-card">
|
||||
<text>这篇谱文已设置内容密码</text>
|
||||
<input v-model="protectionPassword" password maxlength="128" placeholder="请输入8至128位内容密码" />
|
||||
<AppButton block :disabled="protectionSubmitting" :label="protectionSubmitting ? '正在验证' : '解锁并查看'" @click="unlockArticle" />
|
||||
<text v-if="protectionError">{{ protectionError }}</text>
|
||||
</view>
|
||||
<text class="article-card__content">{{
|
||||
article.contentProtected && !article.contentUnlocked ? "" : article.content || "作者暂未填写正文。"
|
||||
}}</text>
|
||||
<text class="article-card__views">阅读 {{ article.viewCount }} 次</text>
|
||||
<view v-if="article.canEdit || article.canDelete" class="article-card__actions">
|
||||
<AppButton
|
||||
v-if="article.canEdit && article.content"
|
||||
compact
|
||||
type="secondary"
|
||||
label="编辑谱文"
|
||||
@click="editArticle"
|
||||
/>
|
||||
<AppButton
|
||||
v-if="article.canDelete"
|
||||
compact
|
||||
type="secondary"
|
||||
label="删除谱文"
|
||||
@click="requestDeleteArticle"
|
||||
/>
|
||||
<AppButton
|
||||
v-if="article.canManageProtection"
|
||||
compact
|
||||
type="secondary"
|
||||
:label="article.contentProtected ? '修改内容密码' : '设置内容密码'"
|
||||
@click="openProtectionDialog('set')"
|
||||
/>
|
||||
<AppButton
|
||||
v-if="article.canManageProtection && article.contentProtected"
|
||||
compact
|
||||
type="secondary"
|
||||
label="关闭内容密码"
|
||||
@click="openProtectionDialog('disable')"
|
||||
/>
|
||||
</view>
|
||||
<text v-if="deleteError" class="article-card__error">{{ deleteError }}</text>
|
||||
</view>
|
||||
<view v-else class="article-state-card">
|
||||
<text>{{ stateCopy.title }}</text>
|
||||
<text>{{ stateCopy.copy }}</text>
|
||||
<AppButton block :label="stateCopy.action" @click="handleStateAction" />
|
||||
</view>
|
||||
</view>
|
||||
<AppDialog
|
||||
:visible="deleteConfirmationVisible"
|
||||
:close-on-mask="false"
|
||||
eyebrow="删除确认"
|
||||
title="删除这篇谱文?"
|
||||
message="删除后无法恢复,请确认当前内容不再需要。"
|
||||
confirm-text="确认删除"
|
||||
cancel-text="保留谱文"
|
||||
show-cancel
|
||||
@confirm="deleteArticle"
|
||||
@cancel="closeDeleteConfirmation"
|
||||
/>
|
||||
<AppDialog
|
||||
:visible="protectionDialogVisible"
|
||||
eyebrow="内容密码"
|
||||
:title="protectionMode === 'disable' ? '关闭内容密码?' : article?.contentProtected ? '修改内容密码' : '设置内容密码'"
|
||||
:message="protectionMode === 'disable' ? '关闭后,有权查看谱文的成员无需密码即可阅读正文。' : '设置8至128位密码,之后阅读正文需要先验证。'"
|
||||
:confirm-text="protectionSubmitting ? '正在保存' : protectionMode === 'disable' ? '确认关闭' : '确认保存'"
|
||||
cancel-text="取消"
|
||||
show-cancel
|
||||
:close-on-mask="false"
|
||||
@confirm="confirmProtectionChange"
|
||||
@cancel="closeProtectionDialog"
|
||||
>
|
||||
<input v-if="protectionMode === 'set'" v-model="protectionPassword" class="protection-dialog-input" password maxlength="128" placeholder="请输入8至128位内容密码" />
|
||||
<text v-if="protectionError" class="article-card__error">{{ protectionError }}</text>
|
||||
</AppDialog>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref } from "vue";
|
||||
import { onLoad, 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";
|
||||
import {
|
||||
createRequestController,
|
||||
isRequestCancelled
|
||||
} from "@/services/api/request-controller.js";
|
||||
import { familyArticleApi } from "@/services/api/family-article-service.js";
|
||||
import { getRequestErrorMessage } from "@/services/api/request-error-message.js";
|
||||
import { goBack, openPage, returnTo } from "@/utils/navigation/gateway.js";
|
||||
|
||||
const genealogyId = ref("");
|
||||
const articleId = ref("");
|
||||
const articleState = ref("loading");
|
||||
const article = ref(null);
|
||||
const deleteConfirmationVisible = ref(false);
|
||||
const deleting = ref(false);
|
||||
const deleteError = ref("");
|
||||
const articleAccessToken = ref("");
|
||||
const protectionPassword = ref("");
|
||||
const protectionError = ref("");
|
||||
const protectionDialogVisible = ref(false);
|
||||
const protectionMode = ref("set");
|
||||
const protectionSubmitting = ref(false);
|
||||
const articleReadController = createRequestController();
|
||||
const articleProtectionController = createRequestController();
|
||||
const articleDeleteController = createRequestController();
|
||||
let pageActive = true;
|
||||
let skipInitialShowRefresh = true;
|
||||
|
||||
const hasValidContext = computed(
|
||||
() =>
|
||||
/^[1-9]\d*$/.test(genealogyId.value) && /^[1-9]\d*$/.test(articleId.value),
|
||||
);
|
||||
const stateCopy = computed(() => {
|
||||
if (!hasValidContext.value) {
|
||||
return {
|
||||
title: "暂时无法打开谱文",
|
||||
copy: "未找到家谱或谱文信息,请返回后重新进入。",
|
||||
action: "返回上一页",
|
||||
};
|
||||
}
|
||||
if (articleState.value === "missing") {
|
||||
return {
|
||||
title: "该谱文已不存在",
|
||||
copy: "它可能已被作者删除,或你暂时无法查看。",
|
||||
action: "返回谱文列表",
|
||||
};
|
||||
}
|
||||
return {
|
||||
title: "谱文暂时无法读取",
|
||||
copy: "请检查网络后重新读取。",
|
||||
action: "重新读取",
|
||||
};
|
||||
});
|
||||
|
||||
const loadArticle = async (accessToken = articleAccessToken.value) => {
|
||||
if (!hasValidContext.value) {
|
||||
articleState.value = "invalid";
|
||||
return;
|
||||
}
|
||||
articleState.value = "loading";
|
||||
try {
|
||||
const current = await familyArticleApi.getArticleDetail(
|
||||
genealogyId.value,
|
||||
articleId.value,
|
||||
accessToken,
|
||||
{ requestController: articleReadController },
|
||||
);
|
||||
if (!pageActive) return;
|
||||
article.value = current;
|
||||
articleState.value = "ready";
|
||||
} catch (error) {
|
||||
if (!pageActive || isRequestCancelled(error)) return;
|
||||
articleState.value =
|
||||
error?.code === "HTTP_ERROR" && error?.httpStatus === 404
|
||||
? "missing"
|
||||
: "error";
|
||||
}
|
||||
};
|
||||
|
||||
onLoad((query) => {
|
||||
genealogyId.value = String(query?.genealogyId || "");
|
||||
articleId.value = String(query?.articleId || "");
|
||||
void loadArticle();
|
||||
});
|
||||
onShow(() => {
|
||||
if (skipInitialShowRefresh) {
|
||||
skipInitialShowRefresh = false;
|
||||
return;
|
||||
}
|
||||
if (hasValidContext.value) void loadArticle();
|
||||
});
|
||||
onUnload(() => {
|
||||
pageActive = false;
|
||||
articleReadController.abort();
|
||||
articleProtectionController.abort();
|
||||
articleDeleteController.abort();
|
||||
});
|
||||
|
||||
const backToArticles = () =>
|
||||
hasValidContext.value
|
||||
? returnTo("F04", { genealogyId: genealogyId.value })
|
||||
: goBack();
|
||||
const handleStateAction = () =>
|
||||
articleState.value === "error" ? loadArticle() : backToArticles();
|
||||
const requestDeleteArticle = () => {
|
||||
if (!article.value?.canDelete || deleting.value) return;
|
||||
deleteError.value = "";
|
||||
deleteConfirmationVisible.value = true;
|
||||
};
|
||||
const unlockArticle = async () => {
|
||||
if (protectionSubmitting.value) return;
|
||||
if (protectionPassword.value.length < 8 || protectionPassword.value.length > 128) {
|
||||
protectionError.value = "请输入8至128位内容密码。";
|
||||
return;
|
||||
}
|
||||
protectionSubmitting.value = true;
|
||||
protectionError.value = "";
|
||||
try {
|
||||
const grant = await familyArticleApi.unlockArticle(
|
||||
genealogyId.value,
|
||||
articleId.value,
|
||||
protectionPassword.value,
|
||||
{ requestController: articleProtectionController },
|
||||
);
|
||||
articleAccessToken.value = grant.accessToken;
|
||||
protectionPassword.value = "";
|
||||
await loadArticle(grant.accessToken);
|
||||
} catch (error) {
|
||||
if (!isRequestCancelled(error)) protectionError.value = getRequestErrorMessage(error, "密码不正确,请重新输入。");
|
||||
} finally {
|
||||
protectionSubmitting.value = false;
|
||||
}
|
||||
};
|
||||
const openProtectionDialog = (mode) => {
|
||||
if (!article.value?.canManageProtection || protectionSubmitting.value) return;
|
||||
protectionMode.value = mode;
|
||||
protectionPassword.value = "";
|
||||
protectionError.value = "";
|
||||
protectionDialogVisible.value = true;
|
||||
};
|
||||
const closeProtectionDialog = () => {
|
||||
if (protectionSubmitting.value) return;
|
||||
protectionDialogVisible.value = false;
|
||||
protectionPassword.value = "";
|
||||
protectionError.value = "";
|
||||
};
|
||||
const confirmProtectionChange = async () => {
|
||||
if (!article.value?.canManageProtection || protectionSubmitting.value) return;
|
||||
if (protectionMode.value === "set" && (protectionPassword.value.length < 8 || protectionPassword.value.length > 128)) {
|
||||
protectionError.value = "请输入8至128位内容密码。";
|
||||
return;
|
||||
}
|
||||
protectionSubmitting.value = true;
|
||||
protectionError.value = "";
|
||||
try {
|
||||
if (protectionMode.value === "disable") {
|
||||
await familyArticleApi.disableArticlePassword(genealogyId.value, articleId.value, {
|
||||
requestController: articleProtectionController,
|
||||
});
|
||||
} else {
|
||||
await familyArticleApi.setArticlePassword(
|
||||
genealogyId.value,
|
||||
articleId.value,
|
||||
protectionPassword.value,
|
||||
{ requestController: articleProtectionController },
|
||||
);
|
||||
}
|
||||
if (!pageActive) return;
|
||||
articleAccessToken.value = "";
|
||||
protectionDialogVisible.value = false;
|
||||
protectionPassword.value = "";
|
||||
await loadArticle("");
|
||||
} catch (error) {
|
||||
if (pageActive && !isRequestCancelled(error))
|
||||
protectionError.value = getRequestErrorMessage(
|
||||
error,
|
||||
"内容密码设置没有保存,请稍后重试。",
|
||||
);
|
||||
} finally {
|
||||
if (pageActive) protectionSubmitting.value = false;
|
||||
}
|
||||
};
|
||||
const editArticle = () =>
|
||||
article.value?.canEdit && article.value.content && !deleting.value
|
||||
? openPage(
|
||||
"F06",
|
||||
{
|
||||
genealogyId: genealogyId.value,
|
||||
articleId: articleId.value,
|
||||
mode: "edit",
|
||||
},
|
||||
"F05",
|
||||
)
|
||||
: Promise.resolve(false);
|
||||
const closeDeleteConfirmation = () => {
|
||||
if (!deleting.value) deleteConfirmationVisible.value = false;
|
||||
};
|
||||
const deleteArticle = async () => {
|
||||
if (!article.value?.canDelete || deleting.value) return;
|
||||
deleting.value = true;
|
||||
deleteError.value = "";
|
||||
let deletionCommitted = false;
|
||||
try {
|
||||
await familyArticleApi.deleteArticle(genealogyId.value, articleId.value, {
|
||||
requestController: articleDeleteController,
|
||||
});
|
||||
deletionCommitted = true;
|
||||
if (!pageActive) return;
|
||||
deleteConfirmationVisible.value = false;
|
||||
article.value = null;
|
||||
articleState.value = "missing";
|
||||
await backToArticles();
|
||||
} catch (error) {
|
||||
if (!pageActive) return;
|
||||
if (deletionCommitted) {
|
||||
deleteConfirmationVisible.value = false;
|
||||
article.value = null;
|
||||
articleState.value = "missing";
|
||||
return;
|
||||
}
|
||||
if (isRequestCancelled(error)) return;
|
||||
deleteError.value = getRequestErrorMessage(error, "谱文删除失败,请稍后重试。");
|
||||
deleteConfirmationVisible.value = false;
|
||||
} finally {
|
||||
if (pageActive) deleting.value = false;
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@import "../../styles/adaptive-frame-profiles.scss";
|
||||
|
||||
.article-detail-page {
|
||||
display: flex;
|
||||
min-height: 100vh;
|
||||
flex-direction: column;
|
||||
background: $paper;
|
||||
}
|
||||
.article-detail-header,
|
||||
.article-detail-content {
|
||||
z-index: 1;
|
||||
}
|
||||
.article-detail-content {
|
||||
flex: 1;
|
||||
padding: 18rpx 24rpx 72rpx;
|
||||
}
|
||||
.article-card,
|
||||
.article-state-card {
|
||||
@include adaptive-family-content;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.article-card {
|
||||
margin-top: 18rpx;
|
||||
padding: 34rpx 30rpx;
|
||||
}
|
||||
.article-card__category,
|
||||
.article-card__title,
|
||||
.article-card__meta,
|
||||
.article-card__summary,
|
||||
.article-card__content,
|
||||
.article-card__views {
|
||||
display: block;
|
||||
}
|
||||
.article-card__category {
|
||||
color: $brand-red;
|
||||
font-size: clamp(14px, 23rpx, 17px);
|
||||
font-weight: 700;
|
||||
}
|
||||
.article-card__title {
|
||||
margin-top: 14rpx;
|
||||
color: $ink;
|
||||
font-family: STKaiti, KaiTi, serif;
|
||||
font-size: clamp(20px, 40rpx, 26px);
|
||||
font-weight: 700;
|
||||
line-height: 1.32;
|
||||
}
|
||||
.article-card__meta {
|
||||
margin-top: 16rpx;
|
||||
color: $ink-muted;
|
||||
font-size: clamp(14px, 22rpx, 17px);
|
||||
}
|
||||
.article-card__summary {
|
||||
margin-top: 22rpx;
|
||||
color: $ink-muted;
|
||||
font-size: clamp(15px, 25rpx, 18px);
|
||||
line-height: 1.6;
|
||||
}
|
||||
.article-card__divider {
|
||||
height: 1rpx;
|
||||
margin: 26rpx 0;
|
||||
background: rgba(128, 89, 49, 0.22);
|
||||
}
|
||||
.article-card__content {
|
||||
color: $ink;
|
||||
font-size: clamp(16px, 28rpx, 20px);
|
||||
line-height: 1.85;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
.article-card__views {
|
||||
margin-top: 30rpx;
|
||||
color: $ink-muted;
|
||||
font-size: clamp(13px, 21rpx, 16px);
|
||||
text-align: right;
|
||||
}
|
||||
.article-card__actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10rpx;
|
||||
justify-content: flex-end;
|
||||
margin-top: 18rpx;
|
||||
}
|
||||
.article-lock-card { margin: 16rpx 0; padding: 24rpx; border: 1rpx solid rgba(159, 23, 15, 0.3); border-radius: 10rpx; background: rgba(159, 23, 15, 0.05); }
|
||||
.article-lock-card > text { display: block; color: $ink; font-size: clamp(14px, 22rpx, 17px); }
|
||||
.article-lock-card input,
|
||||
.protection-dialog-input { box-sizing: border-box; width: 100%; min-height: 76rpx; margin: 16rpx 0; padding: 14rpx 18rpx; border: 1rpx solid rgba(128, 89, 49, 0.32); border-radius: 8rpx; background: #fffdf8; color: $ink; font-size: clamp(14px, 22rpx, 17px); }
|
||||
.article-card__error {
|
||||
margin-top: 12rpx;
|
||||
color: $brand-red;
|
||||
font-size: clamp(13px, 21rpx, 16px);
|
||||
line-height: 1.5;
|
||||
text-align: right;
|
||||
}
|
||||
.article-state-card {
|
||||
min-height: 350rpx;
|
||||
margin-top: 36rpx;
|
||||
padding: 78rpx 52rpx 50rpx;
|
||||
text-align: center;
|
||||
}
|
||||
.article-state-card > text {
|
||||
display: block;
|
||||
}
|
||||
.article-state-card > text:first-child {
|
||||
color: $ink;
|
||||
font-family: STKaiti, KaiTi, serif;
|
||||
font-size: clamp(19px, 35rpx, 24px);
|
||||
font-weight: 700;
|
||||
}
|
||||
.article-state-card > text:nth-child(2) {
|
||||
margin-top: 18rpx;
|
||||
color: $ink-muted;
|
||||
font-size: clamp(15px, 24rpx, 18px);
|
||||
line-height: 1.65;
|
||||
}
|
||||
.article-state-card .app-button {
|
||||
margin-top: 28rpx;
|
||||
}
|
||||
</style>
|
||||
@@ -1,4 +1,3 @@
|
||||
<!-- 页面编号:F-06;用途:按 Apifox 的完整 AppArticleBody 创建谱文。 -->
|
||||
<template>
|
||||
<view
|
||||
class="article-editor-page"
|
||||
@@ -6,22 +5,32 @@
|
||||
>
|
||||
<ModulePageBackground module="family" />
|
||||
<view class="article-editor-page__header"
|
||||
><PageHeader title="新建谱文" custom-back @back="requestBack"
|
||||
><PageHeader :title="isEdit ? '编辑谱文' : '新建谱文'" custom-back @back="requestBack"
|
||||
/></view>
|
||||
<view class="article-editor-content">
|
||||
<view v-if="editorState === 'form'" class="editor-panel">
|
||||
<AppLoading v-if="editorState === 'loading'" text="正在读取谱文" />
|
||||
<view v-else-if="editorState === 'form'" class="editor-panel">
|
||||
<view class="editor-panel__body">
|
||||
<text class="editor-eyebrow">服务端创建</text>
|
||||
<text class="editor-title">把值得传承的故事写下来</text>
|
||||
<text class="editor-eyebrow">{{ isEdit ? '编辑谱文' : '新建谱文' }}</text>
|
||||
<text class="editor-title">{{ isEdit ? '更新这篇传承故事' : '把值得传承的故事写下来' }}</text>
|
||||
<text class="editor-intro"
|
||||
>除标题和正文外,其余字段均可按需填写;留空时不提交该字段。</text
|
||||
>{{ isEdit ? '未重新选择封面时,原封面会保留。' : '除标题和正文外,其他内容可按需填写。' }}</text
|
||||
>
|
||||
|
||||
<view class="editor-field">
|
||||
<text class="editor-field__label">文章分类</text>
|
||||
<view class="editor-control editor-control--unavailable"
|
||||
><text>服务端暂未提供可选择的分类项</text></view
|
||||
<picker
|
||||
:range="categoryOptionLabels"
|
||||
:value="categoryOptionIndex"
|
||||
:disabled="categoryOptionsState !== 'ready' || !categoryOptions.length"
|
||||
@change="selectCategory"
|
||||
>
|
||||
<view class="editor-control editor-control--picker" :class="{ 'editor-control--unavailable': categoryOptionsState !== 'ready' }">
|
||||
<text>{{ categoryOptionLabel }}</text>
|
||||
</view>
|
||||
</picker>
|
||||
<text v-if="categoryOptionsState === 'loading'" class="editor-field__hint">正在获取文章分类</text>
|
||||
<text v-else-if="categoryOptionsState === 'error'" class="editor-field__hint">暂时无法获取文章分类,可不选分类继续填写。</text>
|
||||
</view>
|
||||
<view class="editor-field">
|
||||
<text class="editor-field__label"
|
||||
@@ -51,7 +60,7 @@
|
||||
<view>
|
||||
<text class="editor-field__label">封面图片</text>
|
||||
<text class="editor-field__hint"
|
||||
>选择图片后会取得真实上传回执,并作为封面关联。</text
|
||||
>图片上传成功后,会作为谱文封面保存。</text
|
||||
>
|
||||
</view>
|
||||
<button
|
||||
@@ -94,25 +103,14 @@
|
||||
@input="submitError = ''"
|
||||
/></view>
|
||||
</view>
|
||||
<view class="editor-field">
|
||||
<text class="editor-field__label">排序值</text>
|
||||
<view class="editor-control"
|
||||
><input
|
||||
v-model="form.sortOrder"
|
||||
type="number"
|
||||
placeholder="数值越小越靠前"
|
||||
placeholder-class="editor-placeholder"
|
||||
@input="submitError = ''"
|
||||
/></view>
|
||||
</view>
|
||||
<text v-if="submitError" class="editor-save-error">{{
|
||||
submitError
|
||||
}}</text>
|
||||
<AppButton
|
||||
block
|
||||
:label="isSubmitting ? '正在提交' : '提交谱文'"
|
||||
:label="isSubmitting ? '正在提交' : isEdit ? '保存修改' : '提交谱文'"
|
||||
:disabled="isSubmitting || uploading"
|
||||
@click="submit"
|
||||
@click="saveArticle"
|
||||
/>
|
||||
</view>
|
||||
</view>
|
||||
@@ -133,7 +131,7 @@
|
||||
<AppDialog
|
||||
:visible="discardVisible"
|
||||
title="放弃谱文草稿?"
|
||||
message="当前内容尚未提交服务器,确认返回后不会保留。"
|
||||
message="谱文还没有保存,确认返回后将不会保留。"
|
||||
confirm-text="放弃并返回"
|
||||
cancel-text="继续编辑"
|
||||
show-cancel
|
||||
@@ -144,31 +142,36 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, onUnmounted, reactive, ref } from "vue";
|
||||
import { onBackPress, onLoad } from "@dcloudio/uni-app";
|
||||
import { computed, reactive, ref } from "vue";
|
||||
import { onBackPress, onLoad, 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";
|
||||
import {
|
||||
appApi,
|
||||
createRequestController,
|
||||
isRequestCancelled,
|
||||
} from "@/utils/api.js";
|
||||
isRequestCancelled
|
||||
} from "@/services/api/request-controller.js";
|
||||
import { familyArticleApi } from "@/services/api/family-article-service.js";
|
||||
import { getRequestErrorMessage } from "@/services/api/request-error-message.js";
|
||||
import { createDiscardConfirmation } from "@/utils/discard-confirmation.js";
|
||||
import { createNonIdempotentWriteGuard } from "@/utils/non-idempotent-write-guard.js";
|
||||
import {
|
||||
isImagePickCancelled,
|
||||
pickAndUploadImage,
|
||||
} from "@/utils/resumable-image-upload.js";
|
||||
} from "@/utils/media-upload.js";
|
||||
import {
|
||||
goBack,
|
||||
handleBackPress,
|
||||
returnTo,
|
||||
runBackGuard,
|
||||
} from "@/utils/navigation.js";
|
||||
} from "@/utils/navigation/gateway.js";
|
||||
|
||||
const editorState = ref("form");
|
||||
const genealogyId = ref("");
|
||||
const articleId = ref("");
|
||||
const mode = ref("");
|
||||
const isSubmitting = ref(false);
|
||||
const uploading = ref(false);
|
||||
const discardVisible = ref(false);
|
||||
@@ -176,34 +179,64 @@ const submitError = ref("");
|
||||
const uploadError = ref("");
|
||||
const coverOssId = ref(null);
|
||||
const coverFileName = ref("");
|
||||
const categoryOptionsState = ref("loading");
|
||||
const categoryOptions = ref([]);
|
||||
const preservedUpdateFields = ref({ sortOrder: null, status: "" });
|
||||
const formBaseline = ref("");
|
||||
const form = reactive({
|
||||
categoryId: "",
|
||||
articleTitle: "",
|
||||
articleSummary: "",
|
||||
articleContent: "",
|
||||
authorName: "",
|
||||
sortOrder: "",
|
||||
});
|
||||
const requestController = createRequestController();
|
||||
const isDirty = computed(() =>
|
||||
Boolean(
|
||||
Object.values(form).some((value) => value.trim()) || coverOssId.value,
|
||||
),
|
||||
const articleDetailController = createRequestController();
|
||||
const articleCategoryController = createRequestController();
|
||||
const articleCoverUploadController = createRequestController();
|
||||
const articleSaveController = createRequestController();
|
||||
const articleCreateGuard = createNonIdempotentWriteGuard();
|
||||
let pageActive = true;
|
||||
const categoryOptionLabels = computed(() => ["不设置分类", ...categoryOptions.value.map((item) => item.name)]);
|
||||
const categoryOptionIndex = computed(() => {
|
||||
const index = categoryOptions.value.findIndex((item) => item.id === form.categoryId);
|
||||
return index < 0 ? 0 : index + 1;
|
||||
});
|
||||
const categoryOptionLabel = computed(() => categoryOptionLabels.value[categoryOptionIndex.value] || "不设置分类");
|
||||
const isEdit = computed(() => mode.value === "edit");
|
||||
const formSnapshot = computed(() =>
|
||||
JSON.stringify({ ...form, coverOssId: coverOssId.value || "" }),
|
||||
);
|
||||
const isDirty = computed(() =>
|
||||
isEdit.value
|
||||
? Boolean(formBaseline.value) && formSnapshot.value !== formBaseline.value
|
||||
: Boolean(Object.values(form).some((value) => value.trim()) || coverOssId.value),
|
||||
);
|
||||
const hasValidContext = computed(
|
||||
() =>
|
||||
/^[1-9]\d*$/.test(genealogyId.value) &&
|
||||
(mode.value === "create" || (mode.value === "edit" && /^[1-9]\d*$/.test(articleId.value))),
|
||||
);
|
||||
const hasValidContext = computed(() => /^[1-9]\d*$/.test(genealogyId.value));
|
||||
const resultCopy = computed(() =>
|
||||
editorState.value === "success"
|
||||
? {
|
||||
eyebrow: "服务端已接受",
|
||||
title: "谱文已提交",
|
||||
copy: "服务端已返回成功信封。返回谱文列表后将重新读取服务端数据。",
|
||||
action: "返回谱文列表",
|
||||
eyebrow: "保存成功",
|
||||
title: isEdit.value ? "谱文已更新" : "谱文已提交",
|
||||
copy: "已保存,返回后会显示最新内容。",
|
||||
action: isEdit.value ? "返回谱文详情" : "返回谱文列表",
|
||||
}
|
||||
: {
|
||||
eyebrow: "谱文入口无效",
|
||||
title: "无法创建谱文",
|
||||
copy: "没有取得有效家谱标识,页面不会创建无归属谱文。",
|
||||
action: "返回上一页",
|
||||
},
|
||||
: editorState.value === "error"
|
||||
? {
|
||||
eyebrow: "谱文暂不可编辑",
|
||||
title: "这篇谱文暂时无法编辑",
|
||||
copy: submitError.value || "请返回后重新查看。",
|
||||
action: "返回谱文详情",
|
||||
}
|
||||
: {
|
||||
eyebrow: "暂时无法打开谱文",
|
||||
title: "无法编辑谱文",
|
||||
copy: "未找到家谱或谱文信息,请返回后重新进入。",
|
||||
action: "返回上一页",
|
||||
},
|
||||
);
|
||||
const discardConfirmation = createDiscardConfirmation((visible) => {
|
||||
discardVisible.value = visible;
|
||||
@@ -214,28 +247,104 @@ const cancelDiscard = discardConfirmation.cancel;
|
||||
|
||||
onLoad((query) => {
|
||||
genealogyId.value = String(query?.genealogyId || "");
|
||||
if (!hasValidContext.value || query?.mode !== "create")
|
||||
articleId.value = String(query?.articleId || "");
|
||||
mode.value = String(query?.mode || "");
|
||||
if (!hasValidContext.value) {
|
||||
editorState.value = "invalid";
|
||||
return;
|
||||
}
|
||||
void initializeEditor();
|
||||
});
|
||||
|
||||
const initializeEditor = async () => {
|
||||
editorState.value = "loading";
|
||||
try {
|
||||
await loadArticleCategories();
|
||||
if (isEdit.value) await loadArticleForEdit();
|
||||
if (!pageActive) return;
|
||||
if (editorState.value === "loading") editorState.value = "form";
|
||||
} catch (error) {
|
||||
if (!pageActive || isRequestCancelled(error)) return;
|
||||
submitError.value = getRequestErrorMessage(error, "谱文详情暂时无法读取,请稍后重试。");
|
||||
editorState.value = "error";
|
||||
}
|
||||
};
|
||||
const loadArticleCategories = async () => {
|
||||
categoryOptionsState.value = "loading";
|
||||
try {
|
||||
const categories = await familyArticleApi.getArticleCategories(genealogyId.value, {
|
||||
requestController: articleCategoryController,
|
||||
});
|
||||
if (!pageActive) return;
|
||||
categoryOptions.value = categories.filter((item) => item.enabled);
|
||||
categoryOptionsState.value = "ready";
|
||||
} catch (error) {
|
||||
if (!pageActive || isRequestCancelled(error)) return;
|
||||
categoryOptions.value = [];
|
||||
categoryOptionsState.value = "error";
|
||||
}
|
||||
};
|
||||
const loadArticleForEdit = async () => {
|
||||
const article = await familyArticleApi.getArticleDetail(genealogyId.value, articleId.value, "", {
|
||||
requestController: articleDetailController,
|
||||
});
|
||||
if (!pageActive) return;
|
||||
if (!article.canEdit) {
|
||||
throw new Error("你暂时不能编辑这篇谱文。");
|
||||
}
|
||||
if (!article.content || !["0", "1"].includes(article.status) || !Number.isSafeInteger(article.sortOrder)) {
|
||||
throw new Error("这篇谱文的信息不完整,暂未保存修改,以免覆盖原内容。");
|
||||
}
|
||||
if (
|
||||
article.categoryId &&
|
||||
!categoryOptions.value.some((item) => item.id === article.categoryId)
|
||||
) {
|
||||
throw new Error("这篇谱文暂时无法编辑,请稍后再试。");
|
||||
}
|
||||
Object.assign(form, {
|
||||
categoryId: article.categoryId || "",
|
||||
articleTitle: article.title,
|
||||
articleSummary: article.summary,
|
||||
articleContent: article.content,
|
||||
authorName: article.authorName,
|
||||
});
|
||||
coverOssId.value = article.coverFile?.ossId || null;
|
||||
coverFileName.value = article.coverFile
|
||||
? article.coverFile.fileName || "当前封面图片"
|
||||
: "";
|
||||
preservedUpdateFields.value = {
|
||||
sortOrder: article.sortOrder,
|
||||
status: article.status,
|
||||
};
|
||||
formBaseline.value = formSnapshot.value;
|
||||
};
|
||||
const selectCategory = (event) => {
|
||||
const index = Number(event.detail.value);
|
||||
form.categoryId = index > 0 ? categoryOptions.value[index - 1]?.id || "" : "";
|
||||
submitError.value = "";
|
||||
};
|
||||
|
||||
const uploadCover = async () => {
|
||||
if (uploading.value || isSubmitting.value) return;
|
||||
uploading.value = true;
|
||||
uploadError.value = "";
|
||||
try {
|
||||
const receipt = await pickAndUploadImage({ requestController });
|
||||
const receipt = await pickAndUploadImage({
|
||||
requestController: articleCoverUploadController,
|
||||
});
|
||||
if (!pageActive) return;
|
||||
coverOssId.value = receipt.ossId;
|
||||
coverFileName.value = receipt.fileName || "封面图片";
|
||||
} catch (error) {
|
||||
if (!isImagePickCancelled(error) && !isRequestCancelled(error)) {
|
||||
uploadError.value = error?.message || "封面图片上传失败,请稍后重试。";
|
||||
if (pageActive && !isImagePickCancelled(error) && !isRequestCancelled(error)) {
|
||||
uploadError.value = getRequestErrorMessage(error, "封面图片上传失败,请稍后重试。");
|
||||
}
|
||||
} finally {
|
||||
uploading.value = false;
|
||||
if (pageActive) uploading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const submit = async () => {
|
||||
const saveArticle = async () => {
|
||||
if (isSubmitting.value || uploading.value || !hasValidContext.value) return;
|
||||
if (!form.articleTitle.trim() || !form.articleContent.trim()) {
|
||||
submitError.value = !form.articleTitle.trim()
|
||||
@@ -243,41 +352,71 @@ const submit = async () => {
|
||||
: "请填写正文内容";
|
||||
return;
|
||||
}
|
||||
const payload = {
|
||||
...form,
|
||||
coverOssId: coverOssId.value,
|
||||
...(isEdit.value ? preservedUpdateFields.value : {}),
|
||||
};
|
||||
const createAttempt = isEdit.value ? null : articleCreateGuard.begin(payload);
|
||||
if (!isEdit.value && createAttempt === null) {
|
||||
submitError.value =
|
||||
"上次提交结果暂时无法确认,请先返回谱文列表检查,避免重复创建。";
|
||||
return;
|
||||
}
|
||||
|
||||
isSubmitting.value = true;
|
||||
submitError.value = "";
|
||||
try {
|
||||
await appApi.createArticle(
|
||||
genealogyId.value,
|
||||
{
|
||||
...form,
|
||||
coverOssId: coverOssId.value,
|
||||
},
|
||||
{ requestController },
|
||||
);
|
||||
if (isEdit.value) {
|
||||
await familyArticleApi.updateArticle(genealogyId.value, articleId.value, payload, {
|
||||
requestController: articleSaveController,
|
||||
});
|
||||
} else {
|
||||
await familyArticleApi.createArticle(genealogyId.value, payload, {
|
||||
requestController: articleSaveController,
|
||||
});
|
||||
}
|
||||
if (!pageActive) return;
|
||||
editorState.value = "success";
|
||||
} catch (error) {
|
||||
if (!pageActive) return;
|
||||
if (!isEdit.value && articleCreateGuard.recordFailure(createAttempt, error)) {
|
||||
submitError.value =
|
||||
"提交结果暂时无法确认,请先返回谱文列表检查,避免重复创建。";
|
||||
return;
|
||||
}
|
||||
if (!isRequestCancelled(error))
|
||||
submitError.value = error?.message || "谱文提交失败,请稍后重试。";
|
||||
submitError.value = getRequestErrorMessage(error, "谱文提交失败,请稍后重试。");
|
||||
} finally {
|
||||
isSubmitting.value = false;
|
||||
if (pageActive) isSubmitting.value = false;
|
||||
}
|
||||
};
|
||||
const requestBack = () =>
|
||||
runBackGuard({
|
||||
editorState.value !== "form"
|
||||
? goBack()
|
||||
: runBackGuard({
|
||||
transientOpen: discardVisible.value,
|
||||
dirty: isDirty.value,
|
||||
submitting: isSubmitting.value || uploading.value,
|
||||
"close-transient": cancelDiscard,
|
||||
"block-submitting": () => true,
|
||||
"confirm-discard": requestDiscardConfirmation,
|
||||
});
|
||||
});
|
||||
const handleResultAction = () =>
|
||||
editorState.value === "success"
|
||||
? returnTo("F04", { genealogyId: genealogyId.value })
|
||||
: goBack();
|
||||
? isEdit.value
|
||||
? returnTo("F05", { genealogyId: genealogyId.value, articleId: articleId.value })
|
||||
: returnTo("F04", { genealogyId: genealogyId.value })
|
||||
: isEdit.value
|
||||
? returnTo("F05", { genealogyId: genealogyId.value, articleId: articleId.value })
|
||||
: goBack();
|
||||
onBackPress((event) => handleBackPress(event, requestBack));
|
||||
onUnmounted(() => {
|
||||
requestController.abort();
|
||||
onUnload(() => {
|
||||
pageActive = false;
|
||||
articleDetailController.abort();
|
||||
articleCategoryController.abort();
|
||||
articleCoverUploadController.abort();
|
||||
articleSaveController.abort();
|
||||
discardConfirmation.dispose();
|
||||
});
|
||||
</script>
|
||||
@@ -1,4 +1,3 @@
|
||||
<!-- 页面编号:F-04;用途:读取并展示当前家谱的谱文列表。 -->
|
||||
<template>
|
||||
<view class="article-list-page" :class="`article-list-state--${listState}`">
|
||||
<ModulePageBackground module="family" />
|
||||
@@ -9,9 +8,15 @@
|
||||
@action="createArticle"
|
||||
/></view>
|
||||
<view class="article-list-content">
|
||||
<view v-if="categoryOptions.length > 1" class="article-filter">
|
||||
<text>文章分类</text>
|
||||
<picker :range="categoryLabels" :value="categoryIndex" @change="selectCategory">
|
||||
<view class="article-filter__value">{{ selectedCategoryLabel }} ›</view>
|
||||
</picker>
|
||||
</view>
|
||||
<view v-if="listState === 'list'" class="article-list-items">
|
||||
<view
|
||||
v-for="item in articles"
|
||||
v-for="item in filteredArticles"
|
||||
:key="item.id"
|
||||
class="article-card"
|
||||
@click="openArticle(item)"
|
||||
@@ -21,10 +26,14 @@
|
||||
item.summary || item.content
|
||||
}}</text>
|
||||
<text class="article-card__meta"
|
||||
>{{ item.author || "家族成员" }} ·
|
||||
>{{ item.category ? `${item.category} · ` : "" }}{{ item.author || "家族成员" }} ·
|
||||
{{ item.time || "未标注时间" }}</text
|
||||
>
|
||||
</view>
|
||||
<view v-if="!filteredArticles.length" class="article-filter-empty">
|
||||
<text>这个分类下还没有谱文</text>
|
||||
<text>可以切换其他分类查看。</text>
|
||||
</view>
|
||||
</view>
|
||||
<view v-else class="article-list-state-card">
|
||||
<text>{{ stateCopy.title }}</text>
|
||||
@@ -42,17 +51,41 @@
|
||||
|
||||
<script setup>
|
||||
import { computed, ref } from "vue";
|
||||
import { onLoad, onShow } from "@dcloudio/uni-app";
|
||||
import { onLoad, onShow, onUnload } from "@dcloudio/uni-app";
|
||||
import AppButton from "@/components/AppButton.vue";
|
||||
import ModulePageBackground from "@/components/ModulePageBackground.vue";
|
||||
import PageHeader from "@/components/PageHeader.vue";
|
||||
import { appApi } from "@/utils/api.js";
|
||||
import { goBack, openPage } from "@/utils/navigation.js";
|
||||
import {
|
||||
createRequestController,
|
||||
isRequestCancelled
|
||||
} from "@/services/api/request-controller.js";
|
||||
import { familyArticleApi } from "@/services/api/family-article-service.js";
|
||||
import { goBack, openPage } from "@/utils/navigation/gateway.js";
|
||||
|
||||
const genealogyId = ref("");
|
||||
const hasValidContext = ref(false);
|
||||
const listState = ref("loading");
|
||||
const articles = ref([]);
|
||||
const categories = ref([]);
|
||||
const selectedCategoryId = ref("");
|
||||
const articleListRequestController = createRequestController();
|
||||
const articleCategoryRequestController = createRequestController();
|
||||
let pageActive = true;
|
||||
let skipInitialShowRefresh = true;
|
||||
const categoryOptions = computed(() => [
|
||||
{ id: "", name: "全部分类" },
|
||||
...categories.value.filter((item) => item.enabled),
|
||||
]);
|
||||
const categoryLabels = computed(() => categoryOptions.value.map((item) => item.name));
|
||||
const categoryIndex = computed(() =>
|
||||
Math.max(0, categoryOptions.value.findIndex((item) => item.id === selectedCategoryId.value)),
|
||||
);
|
||||
const selectedCategoryLabel = computed(() => categoryOptions.value[categoryIndex.value]?.name || "全部分类");
|
||||
const filteredArticles = computed(() =>
|
||||
selectedCategoryId.value
|
||||
? articles.value.filter((item) => item.categoryId === selectedCategoryId.value)
|
||||
: articles.value,
|
||||
);
|
||||
const stateCopy = computed(() =>
|
||||
hasValidContext.value
|
||||
? listState.value === "empty"
|
||||
@@ -73,8 +106,8 @@ const stateCopy = computed(() =>
|
||||
action: "重新读取",
|
||||
}
|
||||
: {
|
||||
title: "谱文入口无效",
|
||||
copy: "没有取得有效家谱标识,页面不会展示其他家谱内容。",
|
||||
title: "暂时无法打开谱文",
|
||||
copy: "未找到家谱信息,请返回后重新进入。",
|
||||
action: "返回上一页",
|
||||
},
|
||||
);
|
||||
@@ -85,18 +118,53 @@ onLoad((query) => {
|
||||
else listState.value = "invalid";
|
||||
});
|
||||
onShow(() => {
|
||||
if (skipInitialShowRefresh) {
|
||||
skipInitialShowRefresh = false;
|
||||
return;
|
||||
}
|
||||
if (hasValidContext.value) void loadArticles();
|
||||
});
|
||||
onUnload(() => {
|
||||
pageActive = false;
|
||||
articleListRequestController.abort();
|
||||
articleCategoryRequestController.abort();
|
||||
});
|
||||
const loadArticleCategories = async () => {
|
||||
try {
|
||||
return await familyArticleApi.getArticleCategories(genealogyId.value, {
|
||||
requestController: articleCategoryRequestController,
|
||||
});
|
||||
} catch (error) {
|
||||
if (isRequestCancelled(error)) throw error;
|
||||
return [];
|
||||
}
|
||||
};
|
||||
const loadArticles = async () => {
|
||||
articleListRequestController.abort();
|
||||
articleCategoryRequestController.abort();
|
||||
listState.value = "loading";
|
||||
try {
|
||||
const rows = await appApi.getArticles(genealogyId.value);
|
||||
const [rows, categoryRows] = await Promise.all([
|
||||
familyArticleApi.getArticles(genealogyId.value, {
|
||||
requestController: articleListRequestController,
|
||||
}),
|
||||
loadArticleCategories(),
|
||||
]);
|
||||
if (!pageActive) return;
|
||||
articles.value = rows;
|
||||
categories.value = categoryRows;
|
||||
if (!categoryOptions.value.some((item) => item.id === selectedCategoryId.value)) {
|
||||
selectedCategoryId.value = "";
|
||||
}
|
||||
listState.value = articles.value.length ? "list" : "empty";
|
||||
} catch {
|
||||
} catch (error) {
|
||||
if (!pageActive || isRequestCancelled(error)) return;
|
||||
listState.value = "error";
|
||||
}
|
||||
};
|
||||
const selectCategory = (event) => {
|
||||
selectedCategoryId.value = categoryOptions.value[Number(event.detail.value)]?.id || "";
|
||||
};
|
||||
const createArticle = () =>
|
||||
hasValidContext.value
|
||||
? openPage("F06", { genealogyId: genealogyId.value, mode: "create" }, "F04")
|
||||
@@ -129,6 +197,35 @@ const handleStateAction = () => {
|
||||
.article-list-items {
|
||||
margin-top: 24rpx;
|
||||
}
|
||||
.article-filter {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-top: 20rpx;
|
||||
padding: 22rpx 26rpx;
|
||||
border: 1rpx solid rgba(159, 35, 35, 0.18);
|
||||
background: rgba(255, 252, 242, 0.92);
|
||||
color: $ink;
|
||||
font-size: clamp(14px, 24rpx, 17px);
|
||||
}
|
||||
.article-filter__value {
|
||||
color: $brand-red;
|
||||
}
|
||||
.article-filter-empty {
|
||||
padding: 64rpx 24rpx;
|
||||
text-align: center;
|
||||
}
|
||||
.article-filter-empty text {
|
||||
display: block;
|
||||
color: $ink-muted;
|
||||
font-size: clamp(14px, 24rpx, 17px);
|
||||
line-height: 1.7;
|
||||
}
|
||||
.article-filter-empty text:first-child {
|
||||
color: $ink;
|
||||
font-size: clamp(17px, 30rpx, 21px);
|
||||
font-weight: 700;
|
||||
}
|
||||
.article-card {
|
||||
@include adaptive-family-content;
|
||||
display: flex;
|
||||
@@ -1,232 +0,0 @@
|
||||
<!-- 页面编号:F-05;用途:谱文详情。 -->
|
||||
<template>
|
||||
<view class="article-detail-page" :class="`article-state--${articleState}`">
|
||||
<ModulePageBackground module="family" />
|
||||
<view class="article-detail-header"
|
||||
><PageHeader title="谱文详情" custom-back @back="backToArticles"
|
||||
/></view>
|
||||
|
||||
<view class="article-detail-content">
|
||||
<AppLoading
|
||||
v-if="articleState === 'loading'"
|
||||
text="正在读取谱文"
|
||||
description="请稍候,正在同步谱文正文。"
|
||||
/>
|
||||
<view v-else-if="articleState === 'ready'" class="article-card">
|
||||
<text v-if="article.category" class="article-card__category">{{
|
||||
article.category
|
||||
}}</text>
|
||||
<text class="article-card__title">{{ article.title }}</text>
|
||||
<text class="article-card__meta"
|
||||
>{{ article.author }} · {{ article.time || "未标注时间" }}</text
|
||||
>
|
||||
<text v-if="article.summary" class="article-card__summary">{{
|
||||
article.summary
|
||||
}}</text>
|
||||
<view class="article-card__divider" />
|
||||
<text class="article-card__content">{{
|
||||
article.content || "作者暂未填写正文。"
|
||||
}}</text>
|
||||
<text class="article-card__views">阅读 {{ article.viewCount }} 次</text>
|
||||
</view>
|
||||
<view v-else class="article-state-card">
|
||||
<text>{{ stateCopy.title }}</text>
|
||||
<text>{{ stateCopy.copy }}</text>
|
||||
<AppButton block :label="stateCopy.action" @click="handleStateAction" />
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref } from "vue";
|
||||
import { onLoad, onShow, onUnload } from "@dcloudio/uni-app";
|
||||
import AppButton from "@/components/AppButton.vue";
|
||||
import AppLoading from "@/components/AppLoading.vue";
|
||||
import ModulePageBackground from "@/components/ModulePageBackground.vue";
|
||||
import PageHeader from "@/components/PageHeader.vue";
|
||||
import {
|
||||
appApi,
|
||||
createRequestController,
|
||||
isRequestCancelled,
|
||||
} from "@/utils/api.js";
|
||||
import { goBack, returnTo } from "@/utils/navigation.js";
|
||||
|
||||
const genealogyId = ref("");
|
||||
const articleId = ref("");
|
||||
const articleState = ref("loading");
|
||||
const article = ref(null);
|
||||
const controller = createRequestController();
|
||||
let pageActive = true;
|
||||
|
||||
const hasValidContext = computed(
|
||||
() =>
|
||||
/^[1-9]\d*$/.test(genealogyId.value) && /^[1-9]\d*$/.test(articleId.value),
|
||||
);
|
||||
const stateCopy = computed(() => {
|
||||
if (!hasValidContext.value) {
|
||||
return {
|
||||
title: "谱文入口无效",
|
||||
copy: "没有取得有效家谱或文章标识,页面不会展示其他谱文。",
|
||||
action: "返回上一页",
|
||||
};
|
||||
}
|
||||
if (articleState.value === "missing") {
|
||||
return {
|
||||
title: "该谱文已不存在",
|
||||
copy: "它可能已被作者删除,或当前账号已不再拥有查看权限。",
|
||||
action: "返回谱文列表",
|
||||
};
|
||||
}
|
||||
return {
|
||||
title: "谱文暂时无法读取",
|
||||
copy: "请检查网络后重新读取;本页不会替换为其他谱文。",
|
||||
action: "重新读取",
|
||||
};
|
||||
});
|
||||
|
||||
const loadArticle = async () => {
|
||||
if (!hasValidContext.value) {
|
||||
articleState.value = "invalid";
|
||||
return;
|
||||
}
|
||||
articleState.value = "loading";
|
||||
try {
|
||||
const current = await appApi.getArticleDetail(
|
||||
genealogyId.value,
|
||||
articleId.value,
|
||||
{ requestController: controller },
|
||||
);
|
||||
if (!pageActive) return;
|
||||
article.value = current;
|
||||
articleState.value = "ready";
|
||||
} catch (error) {
|
||||
if (!pageActive || isRequestCancelled(error)) return;
|
||||
articleState.value =
|
||||
error?.code === "HTTP_ERROR" && error?.httpStatus === 404
|
||||
? "missing"
|
||||
: "error";
|
||||
}
|
||||
};
|
||||
|
||||
onLoad((query) => {
|
||||
genealogyId.value = String(query?.genealogyId || "");
|
||||
articleId.value = String(query?.articleId || "");
|
||||
void loadArticle();
|
||||
});
|
||||
onShow(() => {
|
||||
if (hasValidContext.value) void loadArticle();
|
||||
});
|
||||
onUnload(() => {
|
||||
pageActive = false;
|
||||
controller.abort();
|
||||
});
|
||||
|
||||
const backToArticles = () =>
|
||||
hasValidContext.value
|
||||
? returnTo("F04", { genealogyId: genealogyId.value })
|
||||
: goBack();
|
||||
const handleStateAction = () =>
|
||||
articleState.value === "error" ? loadArticle() : backToArticles();
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@import "../../styles/adaptive-frame-profiles.scss";
|
||||
|
||||
.article-detail-page {
|
||||
display: flex;
|
||||
min-height: 100vh;
|
||||
flex-direction: column;
|
||||
background: $paper;
|
||||
}
|
||||
.article-detail-header,
|
||||
.article-detail-content {
|
||||
z-index: 1;
|
||||
}
|
||||
.article-detail-content {
|
||||
flex: 1;
|
||||
padding: 18rpx 24rpx 72rpx;
|
||||
}
|
||||
.article-card,
|
||||
.article-state-card {
|
||||
@include adaptive-family-content;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.article-card {
|
||||
margin-top: 18rpx;
|
||||
padding: 34rpx 30rpx;
|
||||
}
|
||||
.article-card__category,
|
||||
.article-card__title,
|
||||
.article-card__meta,
|
||||
.article-card__summary,
|
||||
.article-card__content,
|
||||
.article-card__views {
|
||||
display: block;
|
||||
}
|
||||
.article-card__category {
|
||||
color: $brand-red;
|
||||
font-size: clamp(14px, 23rpx, 17px);
|
||||
font-weight: 700;
|
||||
}
|
||||
.article-card__title {
|
||||
margin-top: 14rpx;
|
||||
color: $ink;
|
||||
font-family: STKaiti, KaiTi, serif;
|
||||
font-size: clamp(20px, 40rpx, 26px);
|
||||
font-weight: 700;
|
||||
line-height: 1.32;
|
||||
}
|
||||
.article-card__meta {
|
||||
margin-top: 16rpx;
|
||||
color: $ink-muted;
|
||||
font-size: clamp(14px, 22rpx, 17px);
|
||||
}
|
||||
.article-card__summary {
|
||||
margin-top: 22rpx;
|
||||
color: $ink-muted;
|
||||
font-size: clamp(15px, 25rpx, 18px);
|
||||
line-height: 1.6;
|
||||
}
|
||||
.article-card__divider {
|
||||
height: 1rpx;
|
||||
margin: 26rpx 0;
|
||||
background: rgba(128, 89, 49, 0.22);
|
||||
}
|
||||
.article-card__content {
|
||||
color: $ink;
|
||||
font-size: clamp(16px, 28rpx, 20px);
|
||||
line-height: 1.85;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
.article-card__views {
|
||||
margin-top: 30rpx;
|
||||
color: $ink-muted;
|
||||
font-size: clamp(13px, 21rpx, 16px);
|
||||
text-align: right;
|
||||
}
|
||||
.article-state-card {
|
||||
min-height: 350rpx;
|
||||
margin-top: 36rpx;
|
||||
padding: 78rpx 52rpx 50rpx;
|
||||
text-align: center;
|
||||
}
|
||||
.article-state-card > text {
|
||||
display: block;
|
||||
}
|
||||
.article-state-card > text:first-child {
|
||||
color: $ink;
|
||||
font-family: STKaiti, KaiTi, serif;
|
||||
font-size: clamp(19px, 35rpx, 24px);
|
||||
font-weight: 700;
|
||||
}
|
||||
.article-state-card > text:nth-child(2) {
|
||||
margin-top: 18rpx;
|
||||
color: $ink-muted;
|
||||
font-size: clamp(15px, 24rpx, 18px);
|
||||
line-height: 1.65;
|
||||
}
|
||||
.article-state-card .app-button {
|
||||
margin-top: 28rpx;
|
||||
}
|
||||
</style>
|
||||
@@ -1,180 +0,0 @@
|
||||
<!-- 页面编号:F-08;用途:读取当前相册的照片记录。 -->
|
||||
<template>
|
||||
<view class="album-detail-page">
|
||||
<ModulePageBackground module="family" />
|
||||
<view class="album-detail-header"
|
||||
><PageHeader
|
||||
title="相册详情"
|
||||
:action="valid ? '添加' : ''"
|
||||
custom-back
|
||||
@back="returnToAlbums"
|
||||
@action="addPhoto"
|
||||
/></view>
|
||||
<view class="album-detail-content">
|
||||
<view v-if="!valid" class="album-state-card"
|
||||
><text>相册入口无效</text
|
||||
><AppButton block label="返回相册列表" @click="returnToAlbums"
|
||||
/></view>
|
||||
<view v-else-if="state === 'loading'" class="album-state-card"
|
||||
><AppLoading text="正在读取相册照片"
|
||||
/></view>
|
||||
<view v-else-if="state === 'error'" class="album-state-card"
|
||||
><text>暂时无法读取相册照片</text
|
||||
><AppButton block type="secondary" label="重新加载" @click="loadPhotos"
|
||||
/></view>
|
||||
<view v-else-if="state === 'empty'" class="album-state-card"
|
||||
><text>还没有照片</text><text>添加照片后会直接读取服务端相册记录。</text
|
||||
><AppButton block label="添加照片" @click="addPhoto"
|
||||
/></view>
|
||||
<view v-else class="photo-list">
|
||||
<view v-for="item in photos" :key="item.id" class="photo-card"
|
||||
><text>{{ item.title }}</text
|
||||
><text v-if="item.description">{{ item.description }}</text
|
||||
><text v-if="item.meta">{{ item.meta }}</text></view
|
||||
>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref } from "vue";
|
||||
import { onLoad, onShow, onUnload } from "@dcloudio/uni-app";
|
||||
import AppButton from "@/components/AppButton.vue";
|
||||
import AppLoading from "@/components/AppLoading.vue";
|
||||
import ModulePageBackground from "@/components/ModulePageBackground.vue";
|
||||
import PageHeader from "@/components/PageHeader.vue";
|
||||
import {
|
||||
appApi,
|
||||
createRequestController,
|
||||
isRequestCancelled,
|
||||
} from "@/utils/api.js";
|
||||
import { goBack, openPage, returnTo } from "@/utils/navigation.js";
|
||||
|
||||
const genealogyId = ref("");
|
||||
const albumId = ref("");
|
||||
const photos = ref([]);
|
||||
const state = ref("loading");
|
||||
const controller = createRequestController();
|
||||
let active = true;
|
||||
const valid = computed(
|
||||
() =>
|
||||
/^[1-9]\d*$/.test(genealogyId.value) && /^[1-9]\d*$/.test(albumId.value),
|
||||
);
|
||||
const loadPhotos = async () => {
|
||||
if (!valid.value) return;
|
||||
controller.abort();
|
||||
state.value = "loading";
|
||||
try {
|
||||
const rows = await appApi.getAlbumPhotos(genealogyId.value, albumId.value, {
|
||||
requestController: controller,
|
||||
});
|
||||
if (!active) return;
|
||||
photos.value = rows
|
||||
.map((item) => ({
|
||||
id: String(item.photoId || ""),
|
||||
title: String(item.photoTitle || "未命名照片"),
|
||||
description: String(item.photoDesc || ""),
|
||||
meta: [item.photographer, item.shootTime].filter(Boolean).join(" · "),
|
||||
}))
|
||||
.filter((item) => /^[1-9]\d*$/.test(item.id));
|
||||
state.value = photos.value.length ? "ready" : "empty";
|
||||
} catch (error) {
|
||||
if (!active || isRequestCancelled(error)) return;
|
||||
state.value = "error";
|
||||
}
|
||||
};
|
||||
const returnToAlbums = () =>
|
||||
/^[1-9]\d*$/.test(genealogyId.value)
|
||||
? returnTo("F07", { genealogyId: genealogyId.value })
|
||||
: goBack();
|
||||
const addPhoto = () =>
|
||||
valid.value
|
||||
? openPage(
|
||||
"F09",
|
||||
{ genealogyId: genealogyId.value, albumId: albumId.value },
|
||||
"F08",
|
||||
)
|
||||
: Promise.resolve(false);
|
||||
onLoad((query) => {
|
||||
genealogyId.value = String(query?.genealogyId || "");
|
||||
albumId.value = String(query?.albumId || "");
|
||||
if (valid.value) loadPhotos();
|
||||
});
|
||||
onShow(() => {
|
||||
if (valid.value && state.value !== "loading") loadPhotos();
|
||||
});
|
||||
onUnload(() => {
|
||||
active = false;
|
||||
controller.abort();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@import "../../styles/adaptive-frame-profiles.scss";
|
||||
.album-detail-page {
|
||||
display: flex;
|
||||
min-height: 100vh;
|
||||
flex-direction: column;
|
||||
background: $paper;
|
||||
}
|
||||
.album-detail-header,
|
||||
.album-detail-content {
|
||||
z-index: 1;
|
||||
}
|
||||
.album-detail-content {
|
||||
padding: 22rpx 24rpx calc(48rpx + env(safe-area-inset-bottom));
|
||||
}
|
||||
.album-state-card,
|
||||
.photo-card {
|
||||
@include adaptive-family-content;
|
||||
}
|
||||
.album-state-card {
|
||||
width: 100%;
|
||||
min-height: 340rpx;
|
||||
padding: 84rpx 44rpx 56rpx;
|
||||
box-sizing: border-box;
|
||||
text-align: center;
|
||||
}
|
||||
.album-state-card text {
|
||||
display: block;
|
||||
}
|
||||
.album-state-card text:first-child {
|
||||
color: $ink;
|
||||
font-family: STKaiti, KaiTi, serif;
|
||||
font-size: clamp(19px, 36rpx, 24px);
|
||||
font-weight: 700;
|
||||
}
|
||||
.album-state-card text:nth-child(2) {
|
||||
margin-top: 10rpx;
|
||||
color: $ink-muted;
|
||||
font-size: clamp(14px, 23rpx, 17px);
|
||||
line-height: 1.6;
|
||||
}
|
||||
.album-state-card .app-button {
|
||||
margin-top: 34rpx;
|
||||
}
|
||||
.photo-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16rpx;
|
||||
}
|
||||
.photo-card {
|
||||
padding: 28rpx 32rpx;
|
||||
}
|
||||
.photo-card text {
|
||||
display: block;
|
||||
}
|
||||
.photo-card text:first-child {
|
||||
color: $ink;
|
||||
font-family: STKaiti, KaiTi, serif;
|
||||
font-size: clamp(17px, 30rpx, 22px);
|
||||
font-weight: 700;
|
||||
}
|
||||
.photo-card text:not(:first-child) {
|
||||
margin-top: 8rpx;
|
||||
color: $ink-muted;
|
||||
font-size: clamp(14px, 22rpx, 17px);
|
||||
line-height: 1.5;
|
||||
}
|
||||
</style>
|
||||
@@ -1,150 +0,0 @@
|
||||
<template>
|
||||
<view class="video-page" :class="`video-state--${pageState}`">
|
||||
<ModulePageBackground module="family" />
|
||||
<view class="page-header">
|
||||
<PageHeader title="家族视频" custom-back @back="returnToFamily" />
|
||||
</view>
|
||||
<view class="page-content">
|
||||
<view v-if="pageState === 'form'" class="video-panel">
|
||||
<text class="video-panel__title">发布家族视频</text>
|
||||
<text class="video-panel__note">视频文件由上传回执自动关联,发布时不需要填写文件 ID。</text>
|
||||
|
||||
<view class="video-field video-field--upload">
|
||||
<text class="video-field__label"><text class="required-mark">*</text>视频文件</text>
|
||||
<button class="upload-button" :disabled="uploading || submitting" @click="selectVideo">
|
||||
{{ uploading ? "上传中…" : receipt ? "重新选择视频" : "选择视频" }}
|
||||
</button>
|
||||
<text v-if="receipt" class="upload-receipt">已上传:{{ receipt.fileName || "视频" }}</text>
|
||||
</view>
|
||||
<text v-if="uploadError" class="field-error">{{ uploadError }}</text>
|
||||
|
||||
<view class="video-field">
|
||||
<text class="video-field__label"><text class="required-mark">*</text>视频标题</text>
|
||||
<input v-model="form.videoTitle" maxlength="100" placeholder="例如:2026 年清明祭祖活动" placeholder-class="placeholder" @input="submitError = ''" />
|
||||
</view>
|
||||
<view class="video-field video-field--textarea">
|
||||
<text class="video-field__label">视频说明</text>
|
||||
<textarea v-model="form.videoDesc" maxlength="500" auto-height placeholder="补充视频中的人物、场景或故事" placeholder-class="placeholder" @input="submitError = ''" />
|
||||
</view>
|
||||
<text v-if="submitError" class="field-error">{{ submitError }}</text>
|
||||
<AppButton block :disabled="uploading || submitting" :label="submitting ? '正在发布…' : '发布视频'" @click="submitVideo" />
|
||||
</view>
|
||||
|
||||
<view v-else class="video-state-card">
|
||||
<text class="video-state-card__title">{{ stateCopy.title }}</text>
|
||||
<text class="video-state-card__copy">{{ stateCopy.copy }}</text>
|
||||
<AppButton block :label="stateCopy.action" @click="handleStateAction" />
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, onUnmounted, reactive, ref } from "vue";
|
||||
import { onLoad } from "@dcloudio/uni-app";
|
||||
import AppButton from "@/components/AppButton.vue";
|
||||
import ModulePageBackground from "@/components/ModulePageBackground.vue";
|
||||
import PageHeader from "@/components/PageHeader.vue";
|
||||
import { appApi, createRequestController, isRequestCancelled } from "@/utils/api.js";
|
||||
import { isVideoPickCancelled, pickAndUploadVideo } from "@/utils/resumable-image-upload.js";
|
||||
import { goBack, returnTo } from "@/utils/navigation.js";
|
||||
|
||||
const genealogyId = ref("");
|
||||
const pageState = ref("form");
|
||||
const receipt = ref(null);
|
||||
const uploading = ref(false);
|
||||
const submitting = ref(false);
|
||||
const uploadError = ref("");
|
||||
const submitError = ref("");
|
||||
const form = reactive({ videoTitle: "", videoDesc: "" });
|
||||
const controller = createRequestController();
|
||||
const hasValidContext = computed(() => /^[1-9]\d*$/.test(genealogyId.value));
|
||||
const stateCopy = computed(() => pageState.value === "success"
|
||||
? {
|
||||
title: "视频已提交服务端",
|
||||
copy: "视频已按上传回执关联到当前家谱。视频列表接口尚未提供可消费的返回字段,因此此处不猜测播放地址或卡片内容。",
|
||||
action: "返回家族动态",
|
||||
}
|
||||
: {
|
||||
title: "视频入口无效",
|
||||
copy: "没有取得有效的家谱标识。",
|
||||
action: "返回上一页",
|
||||
});
|
||||
|
||||
onLoad((query) => {
|
||||
genealogyId.value = String(query?.genealogyId || "");
|
||||
if (!hasValidContext.value) pageState.value = "invalid";
|
||||
});
|
||||
onUnmounted(() => controller.abort());
|
||||
|
||||
const selectVideo = async () => {
|
||||
if (uploading.value || submitting.value) return;
|
||||
uploading.value = true;
|
||||
uploadError.value = "";
|
||||
try {
|
||||
receipt.value = await pickAndUploadVideo({ requestController: controller });
|
||||
} catch (error) {
|
||||
if (!isVideoPickCancelled(error) && !isRequestCancelled(error)) {
|
||||
uploadError.value = error?.message || "视频上传失败,请稍后重试";
|
||||
}
|
||||
} finally {
|
||||
uploading.value = false;
|
||||
}
|
||||
};
|
||||
const submitVideo = async () => {
|
||||
if (uploading.value || submitting.value || !hasValidContext.value) return;
|
||||
const videoTitle = form.videoTitle.trim();
|
||||
if (!receipt.value) {
|
||||
submitError.value = "请先选择并上传视频";
|
||||
return;
|
||||
}
|
||||
if (!videoTitle) {
|
||||
submitError.value = "请填写视频标题";
|
||||
return;
|
||||
}
|
||||
submitting.value = true;
|
||||
submitError.value = "";
|
||||
try {
|
||||
await appApi.createVideo(genealogyId.value, {
|
||||
videoTitle,
|
||||
videoDesc: form.videoDesc.trim(),
|
||||
videoOssId: receipt.value.ossId,
|
||||
}, { requestController: controller });
|
||||
pageState.value = "success";
|
||||
} catch (error) {
|
||||
if (!isRequestCancelled(error)) submitError.value = error?.message || "视频发布失败,请稍后重试";
|
||||
} finally {
|
||||
submitting.value = false;
|
||||
}
|
||||
};
|
||||
const returnToFamily = () => hasValidContext.value
|
||||
? returnTo("F01", { genealogyId: genealogyId.value })
|
||||
: goBack();
|
||||
const handleStateAction = () => returnToFamily();
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@import "../../styles/adaptive-frame-profiles.scss";
|
||||
.video-page { display: flex; min-height: 100vh; flex-direction: column; background: $paper; }
|
||||
.page-header, .page-content { z-index: 1; }
|
||||
.page-content { flex: 1; padding: 18rpx 24rpx 72rpx; }
|
||||
.video-panel, .video-state-card { box-sizing: border-box; @include adaptive-family-content; }
|
||||
.video-panel { padding: 30rpx; }
|
||||
.video-panel__title, .video-panel__note, .video-field__label, .video-state-card text { display: block; }
|
||||
.video-panel__title, .video-state-card__title { color: $ink; font-family: STKaiti, KaiTi, serif; font-size: clamp(19px, 34rpx, 24px); font-weight: 700; }
|
||||
.video-panel__note, .video-state-card__copy { margin-top: 12rpx; color: $ink-muted; font-size: clamp(14px, 23rpx, 17px); line-height: 1.6; }
|
||||
.video-field { margin-top: 24rpx; }
|
||||
.video-field__label { margin-bottom: 10rpx; color: $ink; font-size: clamp(15px, 24rpx, 18px); font-weight: 700; }
|
||||
.video-field input, .video-field textarea { width: 100%; box-sizing: border-box; border: 1rpx solid rgba(143, 108, 63, .34); border-radius: 8rpx; background: rgba(255, 253, 247, .8); color: $ink; font-size: clamp(15px, 24rpx, 18px); }
|
||||
.video-field input { height: 76rpx; padding: 0 18rpx; }
|
||||
.video-field textarea { min-height: 140rpx; padding: 16rpx 18rpx; }
|
||||
.video-field--upload { display: flex; flex-wrap: wrap; align-items: center; gap: 12rpx; }
|
||||
.video-field--upload .video-field__label { width: 100%; }
|
||||
.upload-button { margin: 0; padding: 0 26rpx; border: 1rpx solid #b78a42; border-radius: 8rpx; background: #fffaf0; color: #805723; font-size: clamp(14px, 23rpx, 17px); line-height: 64rpx; }
|
||||
.upload-receipt { color: $ink-muted; font-size: clamp(13px, 21rpx, 16px); }
|
||||
.required-mark, .field-error { color: $brand-red; }
|
||||
.field-error { display: block; margin-top: 12rpx; font-size: clamp(14px, 22rpx, 17px); }
|
||||
.video-panel .app-button { margin-top: 28rpx; }
|
||||
.video-state-card { min-height: 340rpx; padding: 78rpx 52rpx 50rpx; text-align: center; }
|
||||
.video-state-card .app-button { margin-top: 28rpx; }
|
||||
</style>
|
||||
@@ -1,4 +1,3 @@
|
||||
<!-- 页面编号:F-03;用途:家族动态详情与一级评论。 -->
|
||||
<template>
|
||||
<view class="feed-detail-page" :class="`feed-state--${feedState}`">
|
||||
<ModulePageBackground module="family" />
|
||||
@@ -17,9 +16,10 @@
|
||||
<view class="feed-card">
|
||||
<view class="feed-card__heading">
|
||||
<text>{{ feedTypeLabel(feed.type) }}</text>
|
||||
<text>{{ feed.time || "未标注时间" }}</text>
|
||||
<text>{{ formatMinuteTimestamp(feed.time) || "未标注时间" }}</text>
|
||||
</view>
|
||||
<text class="feed-card__content">{{ feed.content }}</text>
|
||||
<FamilyFeedMedia :files="feed.mediaFiles" />
|
||||
<view class="feed-card__meta">
|
||||
<text>发布:{{ feed.publisher }}</text>
|
||||
<text
|
||||
@@ -27,61 +27,48 @@
|
||||
{{ feed.commentCount }} 条评论</text
|
||||
>
|
||||
</view>
|
||||
<AppButton
|
||||
compact
|
||||
type="secondary"
|
||||
:disabled="isTogglingLike || !hasLikeState"
|
||||
:label="
|
||||
isTogglingLike
|
||||
? '正在提交'
|
||||
: hasLikeState
|
||||
? feed.likedByMe
|
||||
? '取消点赞'
|
||||
: '点赞'
|
||||
: '点赞状态不可用'
|
||||
"
|
||||
@click="toggleLike"
|
||||
/>
|
||||
<view class="feed-card__actions">
|
||||
<AppButton
|
||||
compact
|
||||
type="secondary"
|
||||
:disabled="isTogglingLike || !hasLikeState"
|
||||
:label="
|
||||
isTogglingLike
|
||||
? '正在提交'
|
||||
: hasLikeState
|
||||
? feed.likedByMe
|
||||
? '取消点赞'
|
||||
: '点赞'
|
||||
: '点赞不可用'
|
||||
"
|
||||
@click="toggleLike"
|
||||
/>
|
||||
<AppButton
|
||||
v-if="feed.canEdit"
|
||||
compact
|
||||
type="secondary"
|
||||
label="编辑动态"
|
||||
@click="openEditFeed"
|
||||
/>
|
||||
<AppButton
|
||||
compact
|
||||
type="secondary"
|
||||
label="删除动态"
|
||||
@click="requestDeleteFeed"
|
||||
/>
|
||||
</view>
|
||||
<text v-if="!hasLikeState" class="like-note"
|
||||
>点赞状态暂不可用,页面不会猜测下一次操作。</text
|
||||
>暂时无法确认是否已点赞,请稍后再试。</text
|
||||
>
|
||||
<text v-if="likeError" class="like-error">{{ likeError }}</text>
|
||||
<text v-if="deleteError" class="delete-error">{{ deleteError }}</text>
|
||||
</view>
|
||||
|
||||
<view class="comment-section">
|
||||
<text class="section-title">评论</text>
|
||||
<AppLoading v-if="commentState === 'loading'" text="正在读取评论" />
|
||||
<view v-else-if="commentState === 'list'" class="comment-list">
|
||||
<view v-for="item in comments" :key="item.id" class="comment-card">
|
||||
<view class="comment-card__heading">
|
||||
<text>{{ item.author }}</text>
|
||||
<text>{{ item.time || "刚刚" }}</text>
|
||||
</view>
|
||||
<text class="comment-card__content">{{ item.content }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<text v-else class="comment-state-copy">{{ commentStateCopy }}</text>
|
||||
|
||||
<view class="comment-editor">
|
||||
<textarea
|
||||
v-model="commentDraft"
|
||||
auto-height
|
||||
maxlength="1000"
|
||||
placeholder="写下你的评论"
|
||||
placeholder-class="comment-editor__placeholder"
|
||||
@input="commentError = ''"
|
||||
/>
|
||||
<text v-if="commentError" class="comment-error">{{
|
||||
commentError
|
||||
}}</text>
|
||||
<AppButton
|
||||
block
|
||||
:disabled="isSubmittingComment"
|
||||
:label="isSubmittingComment ? '正在提交' : '发表评论'"
|
||||
@click="submitComment"
|
||||
/>
|
||||
</view>
|
||||
</view>
|
||||
<FeedCommentSection
|
||||
:genealogy-id="genealogyId"
|
||||
:feed-id="feedId"
|
||||
:refresh-feed-summary="refreshFeedSummary"
|
||||
/>
|
||||
</view>
|
||||
|
||||
<view v-else class="feed-state-card">
|
||||
@@ -95,6 +82,18 @@
|
||||
/>
|
||||
</view>
|
||||
</view>
|
||||
<AppDialog
|
||||
:visible="deleteConfirmationVisible"
|
||||
:close-on-mask="false"
|
||||
eyebrow="删除确认"
|
||||
title="删除这条动态?"
|
||||
message="删除后无法恢复,请确认当前内容不再需要。"
|
||||
confirm-text="确认删除"
|
||||
cancel-text="保留动态"
|
||||
show-cancel
|
||||
@confirm="deleteFeed"
|
||||
@cancel="closeDeleteConfirmation"
|
||||
/>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
@@ -102,29 +101,35 @@
|
||||
import { computed, ref } from "vue";
|
||||
import { onBackPress, onLoad, 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 FeedCommentSection from "@/components/family/FeedCommentSection.vue";
|
||||
import FamilyFeedMedia from "@/components/family/FeedMedia.vue";
|
||||
import ModulePageBackground from "@/components/ModulePageBackground.vue";
|
||||
import PageHeader from "@/components/PageHeader.vue";
|
||||
import {
|
||||
appApi,
|
||||
createRequestController,
|
||||
isRequestCancelled,
|
||||
} from "@/utils/api.js";
|
||||
import { goBack, handleBackPress, returnTo } from "@/utils/navigation.js";
|
||||
isRequestCancelled
|
||||
} from "@/services/api/request-controller.js";
|
||||
import { familyFeedApi } from "@/services/api/family-feed-service.js";
|
||||
import { getRequestErrorMessage } from "@/services/api/request-error-message.js";
|
||||
import { formatMinuteTimestamp } from "@/utils/display-time.js";
|
||||
import { goBack, handleBackPress, openPage, returnTo } from "@/utils/navigation/gateway.js";
|
||||
|
||||
const genealogyId = ref("");
|
||||
const feedId = ref("");
|
||||
const feedState = ref("loading");
|
||||
const commentState = ref("loading");
|
||||
const feed = ref(null);
|
||||
const comments = ref([]);
|
||||
const commentDraft = ref("");
|
||||
const commentError = ref("");
|
||||
const isSubmittingComment = ref(false);
|
||||
const isTogglingLike = ref(false);
|
||||
const likeError = ref("");
|
||||
const controller = createRequestController();
|
||||
const deleteConfirmationVisible = ref(false);
|
||||
const deleting = ref(false);
|
||||
const deleteError = ref("");
|
||||
const feedReadController = createRequestController();
|
||||
const feedLikeRequestController = createRequestController();
|
||||
const feedDeletionRequestController = createRequestController();
|
||||
let pageActive = true;
|
||||
let skipInitialShowRefresh = true;
|
||||
const feedTypeLabel = (type) =>
|
||||
String(type || "")
|
||||
.trim()
|
||||
@@ -139,73 +144,55 @@ const hasLikeState = computed(() => typeof feed.value?.likedByMe === "boolean");
|
||||
const stateCopy = computed(() => {
|
||||
if (!hasValidContext.value) {
|
||||
return {
|
||||
title: "动态入口无效",
|
||||
copy: "没有取得当前家谱和动态标识,页面不会展示其他动态。",
|
||||
title: "暂时无法打开动态",
|
||||
copy: "未找到家谱或动态信息,请返回后重新进入。",
|
||||
action: "返回上一页",
|
||||
};
|
||||
}
|
||||
if (feedState.value === "missing") {
|
||||
return {
|
||||
title: "该动态已不存在",
|
||||
copy: "它可能已被发布者删除,或当前账号已不再拥有查看权限。",
|
||||
copy: "它可能已被发布者删除,或你暂时无法查看。",
|
||||
action: "返回家族动态",
|
||||
};
|
||||
}
|
||||
return {
|
||||
title: "动态暂时无法读取",
|
||||
copy: "请检查网络后重新读取;本页不会替换为其他动态。",
|
||||
copy: "请检查网络后重新读取。",
|
||||
action: "重新读取",
|
||||
};
|
||||
});
|
||||
const commentStateCopy = computed(() => {
|
||||
if (commentState.value === "empty") return "还没有评论,欢迎留下第一句话。";
|
||||
return "评论暂时无法读取,稍后可重新进入本页查看。";
|
||||
});
|
||||
|
||||
const loadFeed = async () => {
|
||||
const loadFeed = async ({ preserveContent = false } = {}) => {
|
||||
if (!hasValidContext.value) {
|
||||
feedState.value = "invalid";
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
feedState.value = "loading";
|
||||
if (!preserveContent) feedState.value = "loading";
|
||||
try {
|
||||
const current = await appApi.getFeedDetail(
|
||||
const current = await familyFeedApi.getFeedDetail(
|
||||
genealogyId.value,
|
||||
feedId.value,
|
||||
{ requestController: controller },
|
||||
{ requestController: feedReadController },
|
||||
);
|
||||
if (!pageActive) return;
|
||||
feed.value = current;
|
||||
feedState.value = "ready";
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (!pageActive || isRequestCancelled(error)) return;
|
||||
feedState.value =
|
||||
error?.code === "HTTP_ERROR" && error?.httpStatus === 404
|
||||
? "missing"
|
||||
: "error";
|
||||
if (!pageActive || isRequestCancelled(error)) return null;
|
||||
if (!preserveContent) {
|
||||
feedState.value =
|
||||
error?.code === "HTTP_ERROR" && error?.httpStatus === 404
|
||||
? "missing"
|
||||
: "error";
|
||||
}
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const loadComments = async () => {
|
||||
if (!hasValidContext.value || feedState.value !== "ready") return;
|
||||
commentState.value = "loading";
|
||||
try {
|
||||
const rows = await appApi.getFeedComments(genealogyId.value, feedId.value, {
|
||||
requestController: controller,
|
||||
});
|
||||
if (!pageActive) return;
|
||||
comments.value = rows;
|
||||
commentState.value = rows.length ? "list" : "empty";
|
||||
} catch (error) {
|
||||
if (!pageActive || isRequestCancelled(error)) return;
|
||||
commentState.value = "error";
|
||||
}
|
||||
};
|
||||
|
||||
const refresh = async () => {
|
||||
await loadFeed();
|
||||
if (feedState.value === "ready") await loadComments();
|
||||
};
|
||||
const refresh = () => loadFeed();
|
||||
const refreshFeedSummary = () => loadFeed({ preserveContent: true });
|
||||
|
||||
const toggleLike = async () => {
|
||||
if (
|
||||
@@ -219,43 +206,66 @@ const toggleLike = async () => {
|
||||
isTogglingLike.value = true;
|
||||
likeError.value = "";
|
||||
try {
|
||||
await appApi.setFeedLike(genealogyId.value, feedId.value, nextLiked, {
|
||||
requestController: controller,
|
||||
await familyFeedApi.setFeedLike(genealogyId.value, feedId.value, nextLiked, {
|
||||
requestController: feedLikeRequestController,
|
||||
});
|
||||
if (!pageActive) return;
|
||||
await loadFeed();
|
||||
const feedRefreshed = await refreshFeedSummary();
|
||||
if (feedRefreshed === false) {
|
||||
likeError.value = "点赞已提交,最新状态暂时无法读取。";
|
||||
}
|
||||
} catch (error) {
|
||||
if (!pageActive || isRequestCancelled(error)) return;
|
||||
likeError.value = error?.message || "点赞操作失败,请稍后重试";
|
||||
likeError.value = getRequestErrorMessage(error, "点赞操作失败,请稍后重试");
|
||||
} finally {
|
||||
if (pageActive) isTogglingLike.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const submitComment = async () => {
|
||||
if (isSubmittingComment.value || !hasValidContext.value) return;
|
||||
const commentContent = commentDraft.value.trim();
|
||||
if (!commentContent) {
|
||||
commentError.value = "请填写评论内容";
|
||||
return;
|
||||
}
|
||||
isSubmittingComment.value = true;
|
||||
commentError.value = "";
|
||||
const requestDeleteFeed = () => {
|
||||
if (!feed.value?.canDelete || deleting.value) return;
|
||||
deleteError.value = "";
|
||||
deleteConfirmationVisible.value = true;
|
||||
};
|
||||
const closeDeleteConfirmation = () => {
|
||||
if (!deleting.value) deleteConfirmationVisible.value = false;
|
||||
};
|
||||
const openEditFeed = () => {
|
||||
if (!feed.value?.canEdit || !hasValidContext.value) return;
|
||||
openPage(
|
||||
"F02",
|
||||
{ genealogyId: genealogyId.value, mode: "edit", feedId: feedId.value },
|
||||
"F03",
|
||||
);
|
||||
};
|
||||
const deleteFeed = async () => {
|
||||
if (!feed.value?.canDelete || deleting.value) return;
|
||||
deleting.value = true;
|
||||
deleteError.value = "";
|
||||
let deletionCommitted = false;
|
||||
try {
|
||||
await appApi.createFeedComment(
|
||||
genealogyId.value,
|
||||
feedId.value,
|
||||
{ commentContent },
|
||||
{ requestController: controller },
|
||||
);
|
||||
await familyFeedApi.deleteFeed(genealogyId.value, feedId.value, {
|
||||
requestController: feedDeletionRequestController,
|
||||
});
|
||||
deletionCommitted = true;
|
||||
if (!pageActive) return;
|
||||
commentDraft.value = "";
|
||||
await refresh();
|
||||
deleteConfirmationVisible.value = false;
|
||||
feed.value = null;
|
||||
feedState.value = "missing";
|
||||
await backToFamily();
|
||||
} catch (error) {
|
||||
if (!pageActive || isRequestCancelled(error)) return;
|
||||
commentError.value = error?.message || "评论提交失败,请稍后重试";
|
||||
if (!pageActive) return;
|
||||
if (deletionCommitted) {
|
||||
deleteConfirmationVisible.value = false;
|
||||
feed.value = null;
|
||||
feedState.value = "missing";
|
||||
return;
|
||||
}
|
||||
if (isRequestCancelled(error)) return;
|
||||
deleteError.value = getRequestErrorMessage(error, "动态删除失败,请稍后重试。");
|
||||
deleteConfirmationVisible.value = false;
|
||||
} finally {
|
||||
if (pageActive) isSubmittingComment.value = false;
|
||||
if (pageActive) deleting.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -265,11 +275,17 @@ onLoad((query) => {
|
||||
void refresh();
|
||||
});
|
||||
onShow(() => {
|
||||
if (skipInitialShowRefresh) {
|
||||
skipInitialShowRefresh = false;
|
||||
return;
|
||||
}
|
||||
if (hasValidContext.value) void refresh();
|
||||
});
|
||||
onUnload(() => {
|
||||
pageActive = false;
|
||||
controller.abort();
|
||||
feedReadController.abort();
|
||||
feedLikeRequestController.abort();
|
||||
feedDeletionRequestController.abort();
|
||||
});
|
||||
|
||||
const backToFamily = () =>
|
||||
@@ -303,7 +319,6 @@ onBackPress((event) => handleBackPress(event, requestBack));
|
||||
margin-top: 18rpx;
|
||||
}
|
||||
.feed-card,
|
||||
.comment-section,
|
||||
.feed-state-card {
|
||||
@include adaptive-family-content;
|
||||
box-sizing: border-box;
|
||||
@@ -312,20 +327,17 @@ onBackPress((event) => handleBackPress(event, requestBack));
|
||||
padding: 30rpx;
|
||||
}
|
||||
.feed-card__heading,
|
||||
.feed-card__meta,
|
||||
.comment-card__heading {
|
||||
.feed-card__meta {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 18rpx;
|
||||
}
|
||||
.feed-card__heading text:first-child,
|
||||
.comment-card__heading text:first-child {
|
||||
.feed-card__heading text:first-child {
|
||||
color: $brand-red;
|
||||
font-size: clamp(14px, 23rpx, 17px);
|
||||
font-weight: 700;
|
||||
}
|
||||
.feed-card__heading text:last-child,
|
||||
.comment-card__heading text:last-child {
|
||||
.feed-card__heading text:last-child {
|
||||
color: $ink-muted;
|
||||
font-size: clamp(13px, 21rpx, 16px);
|
||||
text-align: right;
|
||||
@@ -346,6 +358,17 @@ onBackPress((event) => handleBackPress(event, requestBack));
|
||||
.feed-card .app-button {
|
||||
margin-top: 18rpx;
|
||||
}
|
||||
.feed-card__actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10rpx;
|
||||
}
|
||||
.feed-card__actions .app-button {
|
||||
width: auto;
|
||||
min-width: 180rpx;
|
||||
flex: 1 1 180rpx;
|
||||
margin-top: 18rpx;
|
||||
}
|
||||
.like-note,
|
||||
.like-error {
|
||||
display: block;
|
||||
@@ -358,67 +381,12 @@ onBackPress((event) => handleBackPress(event, requestBack));
|
||||
.like-error {
|
||||
color: $brand-red;
|
||||
}
|
||||
.comment-section {
|
||||
margin-top: 18rpx;
|
||||
padding: 28rpx;
|
||||
}
|
||||
.section-title {
|
||||
display: block;
|
||||
color: $ink;
|
||||
font-family: STKaiti, KaiTi, serif;
|
||||
font-size: clamp(17px, 32rpx, 22px);
|
||||
font-weight: 700;
|
||||
}
|
||||
.comment-list {
|
||||
margin-top: 18rpx;
|
||||
}
|
||||
.comment-card {
|
||||
padding: 18rpx 0;
|
||||
border-bottom: 1rpx solid rgba(128, 89, 49, 0.16);
|
||||
}
|
||||
.comment-card__content {
|
||||
display: block;
|
||||
margin-top: 10rpx;
|
||||
color: $ink;
|
||||
font-size: clamp(15px, 25rpx, 18px);
|
||||
line-height: 1.55;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
.comment-state-copy {
|
||||
display: block;
|
||||
margin-top: 18rpx;
|
||||
color: $ink-muted;
|
||||
font-size: clamp(15px, 24rpx, 18px);
|
||||
line-height: 1.5;
|
||||
}
|
||||
.comment-editor {
|
||||
margin-top: 24rpx;
|
||||
padding-top: 22rpx;
|
||||
border-top: 1rpx solid rgba(128, 89, 49, 0.18);
|
||||
}
|
||||
.comment-editor textarea {
|
||||
display: block;
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
min-height: 130rpx;
|
||||
padding: 18rpx;
|
||||
border: 1rpx solid rgba(128, 89, 49, 0.3);
|
||||
border-radius: 12rpx;
|
||||
color: $ink;
|
||||
font-size: clamp(15px, 25rpx, 18px);
|
||||
line-height: 1.55;
|
||||
}
|
||||
.comment-editor__placeholder {
|
||||
color: #ab9a86;
|
||||
}
|
||||
.comment-editor .app-button {
|
||||
margin-top: 18rpx;
|
||||
}
|
||||
.comment-error {
|
||||
.delete-error {
|
||||
display: block;
|
||||
margin-top: 10rpx;
|
||||
color: $brand-red;
|
||||
font-size: clamp(14px, 22rpx, 17px);
|
||||
text-align: right;
|
||||
}
|
||||
.feed-state-card {
|
||||
min-height: 340rpx;
|
||||
@@ -1,14 +1,13 @@
|
||||
<!-- 页面编号:F-02;用途:按 Apifox 的完整 AppFamilyFeedBody 创建家族动态。 -->
|
||||
<template>
|
||||
<view class="publish-page" :class="`publish-state--${publishState}`">
|
||||
<ModulePageBackground module="family" />
|
||||
<view class="publish-page__header"
|
||||
><PageHeader title="发布动态" custom-back @back="requestBack"
|
||||
><PageHeader :title="isEdit ? '编辑动态' : '发布动态'" custom-back @back="requestBack"
|
||||
/></view>
|
||||
<view class="publish-panel">
|
||||
<view v-if="publishState === 'form'" class="publish-form">
|
||||
<text>记录此刻</text>
|
||||
<text>填写需要的内容;留空的字段由服务端按接口默认行为处理。</text>
|
||||
<text>{{ isEdit ? '编辑此刻' : '记录此刻' }}</text>
|
||||
<text>{{ isEdit ? '原有图片和相关设置会保留。' : '填写要发布的内容,其余项目可按需补充。' }}</text>
|
||||
|
||||
<view class="publish-field publish-field--content">
|
||||
<text class="publish-field__label"
|
||||
@@ -33,7 +32,7 @@
|
||||
<view>
|
||||
<text class="publish-field__label">动态配图</text>
|
||||
<text class="publish-field__hint"
|
||||
>图片选定后会先取得真实上传回执,并在提交动态时关联。</text
|
||||
>图片上传成功后,会随动态一起发布。</text
|
||||
>
|
||||
</view>
|
||||
<button
|
||||
@@ -53,24 +52,17 @@
|
||||
uploadError
|
||||
}}</text>
|
||||
</view>
|
||||
<view class="publish-field">
|
||||
<text class="publish-field__label">排序值</text>
|
||||
<input
|
||||
v-model="form.sortOrder"
|
||||
type="number"
|
||||
placeholder="留空时服务端默认为 0"
|
||||
placeholder-class="publish-placeholder"
|
||||
@input="submitError = ''"
|
||||
/>
|
||||
</view>
|
||||
<text v-if="submitError" class="publish-error">{{ submitError }}</text>
|
||||
<AppButton
|
||||
block
|
||||
:label="isSubmitting ? '正在提交' : '提交动态'"
|
||||
:label="isSubmitting ? '正在提交' : isEdit ? '保存动态' : '提交动态'"
|
||||
:disabled="isSubmitting || isUploading"
|
||||
@click="submit"
|
||||
@click="saveFeed"
|
||||
/>
|
||||
</view>
|
||||
<view v-else-if="publishState === 'loading'" class="publish-result"
|
||||
><AppLoading text="正在读取动态"
|
||||
/></view>
|
||||
<view v-else class="publish-result">
|
||||
<text>{{ resultCopy.title }}</text>
|
||||
<text>{{ resultCopy.copy }}</text>
|
||||
@@ -84,7 +76,7 @@
|
||||
<AppDialog
|
||||
:visible="discardVisible"
|
||||
title="放弃动态草稿?"
|
||||
message="当前内容尚未提交服务器,确认返回后不会保留。"
|
||||
message="动态还没有发布,确认返回后将不会保留。"
|
||||
confirm-text="放弃并返回"
|
||||
cancel-text="继续填写"
|
||||
show-cancel
|
||||
@@ -96,57 +88,70 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, onUnmounted, reactive, ref } from "vue";
|
||||
import { onBackPress, onLoad } from "@dcloudio/uni-app";
|
||||
import { computed, reactive, ref } from "vue";
|
||||
import { onBackPress, onLoad, 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";
|
||||
import {
|
||||
appApi,
|
||||
createRequestController,
|
||||
isRequestCancelled,
|
||||
} from "@/utils/api.js";
|
||||
isRequestCancelled
|
||||
} from "@/services/api/request-controller.js";
|
||||
import { familyFeedApi } from "@/services/api/family-feed-service.js";
|
||||
import { getRequestErrorMessage } from "@/services/api/request-error-message.js";
|
||||
import { createDiscardConfirmation } from "@/utils/discard-confirmation.js";
|
||||
import { createNonIdempotentWriteGuard } from "@/utils/non-idempotent-write-guard.js";
|
||||
import {
|
||||
isImagePickCancelled,
|
||||
pickAndUploadImage,
|
||||
} from "@/utils/resumable-image-upload.js";
|
||||
} from "@/utils/media-upload.js";
|
||||
import {
|
||||
goBack,
|
||||
handleBackPress,
|
||||
returnTo,
|
||||
runBackGuard,
|
||||
} from "@/utils/navigation.js";
|
||||
} from "@/utils/navigation/gateway.js";
|
||||
|
||||
const genealogyId = ref("");
|
||||
const form = reactive({ feedContent: "", feedType: "text", sortOrder: "" });
|
||||
const form = reactive({ feedContent: "", feedType: "text" });
|
||||
const mediaReceipts = ref([]);
|
||||
const publishState = ref("form");
|
||||
const publishState = ref("loading");
|
||||
const isSubmitting = ref(false);
|
||||
const isUploading = ref(false);
|
||||
const submitError = ref("");
|
||||
const uploadError = ref("");
|
||||
const discardVisible = ref(false);
|
||||
const requestController = createRequestController();
|
||||
const editingFeed = ref(null);
|
||||
const editFeedId = ref("");
|
||||
const formBaseline = ref("");
|
||||
const submittedEdit = ref(false);
|
||||
const feedDetailController = createRequestController();
|
||||
const feedMediaUploadController = createRequestController();
|
||||
const feedSaveController = createRequestController();
|
||||
const feedCreateGuard = createNonIdempotentWriteGuard();
|
||||
let pageActive = true;
|
||||
const mediaOssIds = computed(() =>
|
||||
mediaReceipts.value.map((item) => item.ossId).join(","),
|
||||
);
|
||||
const isEdit = computed(() => Boolean(editingFeed.value));
|
||||
const formSnapshot = computed(() =>
|
||||
JSON.stringify({ ...form, mediaOssIds: mediaOssIds.value }),
|
||||
);
|
||||
const isDirty = computed(() =>
|
||||
Boolean(
|
||||
form.feedContent.trim() ||
|
||||
form.sortOrder.trim() ||
|
||||
mediaReceipts.value.length,
|
||||
),
|
||||
isEdit.value
|
||||
? Boolean(formBaseline.value) && formSnapshot.value !== formBaseline.value
|
||||
: Boolean(form.feedContent.trim() || mediaReceipts.value.length),
|
||||
);
|
||||
const hasValidContext = computed(() => /^[1-9]\d*$/.test(genealogyId.value));
|
||||
const resultCopy = computed(
|
||||
() =>
|
||||
({
|
||||
success: {
|
||||
title: "动态已提交服务端",
|
||||
copy: "服务端已返回成功信封。返回动态列表后将重新读取服务端数据。",
|
||||
action: "返回家族动态",
|
||||
title: submittedEdit.value ? "动态已更新" : "动态发布成功",
|
||||
copy: "已保存,返回后会显示最新内容。",
|
||||
action: submittedEdit.value ? "返回动态详情" : "返回家族动态",
|
||||
},
|
||||
error: {
|
||||
title: "动态未提交",
|
||||
@@ -154,10 +159,15 @@ const resultCopy = computed(
|
||||
action: "返回填写",
|
||||
},
|
||||
invalid: {
|
||||
title: "动态入口无效",
|
||||
copy: "没有取得有效家谱标识,页面不会创建无归属动态。",
|
||||
title: "暂时无法打开动态",
|
||||
copy: "未找到家谱信息,请返回家谱首页后重新进入。",
|
||||
action: "返回上一页",
|
||||
},
|
||||
unavailable: {
|
||||
title: "暂时无法编辑动态",
|
||||
copy: "这条动态已经变化,暂未保存。",
|
||||
action: "返回动态详情",
|
||||
},
|
||||
})[publishState.value],
|
||||
);
|
||||
const discardConfirmation = createDiscardConfirmation((visible) => {
|
||||
@@ -167,9 +177,62 @@ const requestDiscardConfirmation = discardConfirmation.request;
|
||||
const confirmDiscard = discardConfirmation.confirm;
|
||||
const cancelDiscard = discardConfirmation.cancel;
|
||||
|
||||
const resetForm = () => {
|
||||
Object.assign(form, { feedContent: "", feedType: "text" });
|
||||
mediaReceipts.value = [];
|
||||
editingFeed.value = null;
|
||||
formBaseline.value = "";
|
||||
submitError.value = "";
|
||||
uploadError.value = "";
|
||||
};
|
||||
const loadEditFeed = async (feedId) => {
|
||||
try {
|
||||
const detail = await familyFeedApi.getFeedDetail(genealogyId.value, feedId, {
|
||||
requestController: feedDetailController,
|
||||
});
|
||||
if (!pageActive) return;
|
||||
if (
|
||||
!detail.canEdit ||
|
||||
detail.type.toLowerCase() !== "text" ||
|
||||
!["0", "1"].includes(detail.status) ||
|
||||
!Number.isSafeInteger(detail.sortOrder)
|
||||
) {
|
||||
throw new Error("这条动态的信息不完整,暂未保存修改,以免覆盖原内容。");
|
||||
}
|
||||
resetForm();
|
||||
Object.assign(form, { feedContent: detail.content, feedType: detail.type });
|
||||
mediaReceipts.value = detail.mediaFiles.map((file) => ({
|
||||
ossId: file.ossId,
|
||||
fileName: file.fileName,
|
||||
}));
|
||||
editingFeed.value = {
|
||||
id: detail.id,
|
||||
sortOrder: detail.sortOrder,
|
||||
status: detail.status,
|
||||
};
|
||||
formBaseline.value = formSnapshot.value;
|
||||
publishState.value = "form";
|
||||
} catch (error) {
|
||||
if (pageActive && !isRequestCancelled(error)) publishState.value = "unavailable";
|
||||
}
|
||||
};
|
||||
onLoad((query) => {
|
||||
genealogyId.value = String(query?.genealogyId || "");
|
||||
if (!hasValidContext.value) publishState.value = "invalid";
|
||||
if (!hasValidContext.value) {
|
||||
publishState.value = "invalid";
|
||||
return;
|
||||
}
|
||||
if (query?.mode === "create") {
|
||||
publishState.value = "form";
|
||||
return;
|
||||
}
|
||||
const feedId = String(query?.feedId || "");
|
||||
if (query?.mode === "edit" && /^[1-9]\d*$/.test(feedId)) {
|
||||
editFeedId.value = feedId;
|
||||
loadEditFeed(feedId);
|
||||
return;
|
||||
}
|
||||
publishState.value = "invalid";
|
||||
});
|
||||
|
||||
const uploadImage = async () => {
|
||||
@@ -177,45 +240,74 @@ const uploadImage = async () => {
|
||||
isUploading.value = true;
|
||||
uploadError.value = "";
|
||||
try {
|
||||
const receipt = await pickAndUploadImage({ requestController });
|
||||
const receipt = await pickAndUploadImage({
|
||||
requestController: feedMediaUploadController,
|
||||
});
|
||||
if (!pageActive) return;
|
||||
mediaReceipts.value = [...mediaReceipts.value, receipt];
|
||||
} catch (error) {
|
||||
if (!isImagePickCancelled(error) && !isRequestCancelled(error)) {
|
||||
uploadError.value = error?.message || "图片上传失败,请稍后重试。";
|
||||
if (pageActive && !isImagePickCancelled(error) && !isRequestCancelled(error)) {
|
||||
uploadError.value = getRequestErrorMessage(error, "图片上传失败,请稍后重试。");
|
||||
}
|
||||
} finally {
|
||||
isUploading.value = false;
|
||||
if (pageActive) isUploading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const submit = async () => {
|
||||
const saveFeed = async () => {
|
||||
if (isSubmitting.value || isUploading.value || !hasValidContext.value) return;
|
||||
if (!form.feedContent.trim()) {
|
||||
submitError.value = "请填写动态内容";
|
||||
return;
|
||||
}
|
||||
const payload = {
|
||||
feedContent: form.feedContent,
|
||||
feedType: form.feedType,
|
||||
mediaOssIds: mediaOssIds.value,
|
||||
...(editingFeed.value
|
||||
? {
|
||||
sortOrder: editingFeed.value.sortOrder,
|
||||
status: editingFeed.value.status,
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
const createAttempt = editingFeed.value ? null : feedCreateGuard.begin(payload);
|
||||
if (!editingFeed.value && createAttempt === null) {
|
||||
publishState.value = "error";
|
||||
submitError.value =
|
||||
"上次发布结果暂时无法确认,请先返回动态列表检查,避免重复发布。";
|
||||
return;
|
||||
}
|
||||
|
||||
isSubmitting.value = true;
|
||||
submitError.value = "";
|
||||
try {
|
||||
await appApi.createFeed(
|
||||
genealogyId.value,
|
||||
{
|
||||
feedContent: form.feedContent,
|
||||
feedType: form.feedType,
|
||||
mediaOssIds: mediaOssIds.value,
|
||||
sortOrder: form.sortOrder,
|
||||
},
|
||||
{ requestController },
|
||||
);
|
||||
Object.assign(form, { feedContent: "", feedType: "text", sortOrder: "" });
|
||||
mediaReceipts.value = [];
|
||||
submittedEdit.value = Boolean(editingFeed.value);
|
||||
if (editingFeed.value) {
|
||||
await familyFeedApi.updateFeed(genealogyId.value, editingFeed.value.id, payload, {
|
||||
requestController: feedSaveController,
|
||||
});
|
||||
} else {
|
||||
await familyFeedApi.createFeed(genealogyId.value, payload, {
|
||||
requestController: feedSaveController,
|
||||
});
|
||||
}
|
||||
if (!pageActive) return;
|
||||
resetForm();
|
||||
publishState.value = "success";
|
||||
} catch (error) {
|
||||
if (!pageActive) return;
|
||||
if (!editingFeed.value && feedCreateGuard.recordFailure(createAttempt, error)) {
|
||||
publishState.value = "error";
|
||||
submitError.value =
|
||||
"发布结果暂时无法确认,请先返回动态列表检查,避免重复发布。";
|
||||
return;
|
||||
}
|
||||
if (isRequestCancelled(error)) return;
|
||||
publishState.value = "error";
|
||||
submitError.value = error?.message || "动态提交失败,请稍后重试。";
|
||||
submitError.value = getRequestErrorMessage(error, "动态提交失败,请稍后重试。");
|
||||
} finally {
|
||||
isSubmitting.value = false;
|
||||
if (pageActive) isSubmitting.value = false;
|
||||
}
|
||||
};
|
||||
const requestBack = () =>
|
||||
@@ -233,16 +325,32 @@ const returnToFamily = async () => {
|
||||
return returnTo("F01", { genealogyId: genealogyId.value });
|
||||
};
|
||||
const handleResultAction = () => {
|
||||
if (publishState.value === "success") return returnToFamily();
|
||||
if (publishState.value === "success") {
|
||||
return submittedEdit.value
|
||||
? returnTo("F03", {
|
||||
genealogyId: genealogyId.value,
|
||||
feedId: editFeedId.value,
|
||||
})
|
||||
: returnToFamily();
|
||||
}
|
||||
if (publishState.value === "error") {
|
||||
publishState.value = "form";
|
||||
return;
|
||||
}
|
||||
if (publishState.value === "unavailable") {
|
||||
return returnTo("F03", {
|
||||
genealogyId: genealogyId.value,
|
||||
feedId: editFeedId.value,
|
||||
});
|
||||
}
|
||||
return goBack();
|
||||
};
|
||||
onBackPress((event) => handleBackPress(event, requestBack));
|
||||
onUnmounted(() => {
|
||||
requestController.abort();
|
||||
onUnload(() => {
|
||||
pageActive = false;
|
||||
feedDetailController.abort();
|
||||
feedMediaUploadController.abort();
|
||||
feedSaveController.abort();
|
||||
discardConfirmation.dispose();
|
||||
});
|
||||
</script>
|
||||
@@ -13,12 +13,13 @@
|
||||
><text>家族圈</text><text>家宴、通知与共同记忆</text></view
|
||||
>
|
||||
<view v-if="hasValidContext" class="feed-shortcuts"
|
||||
><view
|
||||
><button
|
||||
v-for="item in shortcuts"
|
||||
:key="item.key"
|
||||
class="feed-shortcut"
|
||||
:aria-label="`打开${item.label}`"
|
||||
@click="openSection(item.key)"
|
||||
><text>{{ item.label }}</text></view
|
||||
><text>{{ item.label }}</text></button
|
||||
></view
|
||||
>
|
||||
<AppLoading
|
||||
@@ -26,19 +27,26 @@
|
||||
text="正在读取家族动态"
|
||||
description="请稍候,正在整理家族近况。"
|
||||
/>
|
||||
<view v-else-if="feedState === 'list'" class="feed-list"
|
||||
><view
|
||||
<view v-else-if="feedState === 'list'" class="feed-list">
|
||||
<view
|
||||
v-for="item in feeds"
|
||||
:key="item.id"
|
||||
class="feed-card"
|
||||
@click="openFeed(item)"
|
||||
><text class="feed-card__publisher">{{ item.publisher }}</text
|
||||
><text class="feed-card__content">{{ item.content }}</text
|
||||
><text class="feed-card__meta"
|
||||
>{{ feedTypeLabel(item.type) }} · {{ item.time }}</text
|
||||
></view
|
||||
></view
|
||||
>
|
||||
>
|
||||
<text class="feed-card__publisher">{{ item.publisher }}</text>
|
||||
<text class="feed-card__content">{{ feedPreview(item.content) }}</text>
|
||||
<FamilyFeedMedia :files="item.mediaFiles" />
|
||||
<text class="feed-card__meta">{{ feedTypeLabel(item.type) }} · {{ formatMinuteTimestamp(item.time) }}</text>
|
||||
<text v-if="item.recommendationReason" class="feed-card__reason">{{ item.recommendationReason }}</text>
|
||||
</view>
|
||||
<button
|
||||
v-if="hasMoreFeeds || loadMoreState === 'loading' || loadMoreState === 'error'"
|
||||
class="feed-more"
|
||||
:disabled="loadMoreState === 'loading'"
|
||||
@click="loadMoreFeeds"
|
||||
>{{ loadMoreLabel }}</button>
|
||||
</view>
|
||||
<view v-else class="feed-state-card"
|
||||
><view class="feed-state-card__copy"
|
||||
><text>{{ stateCopy.title }}</text
|
||||
@@ -55,26 +63,51 @@
|
||||
|
||||
<script setup>
|
||||
import { computed, ref } from "vue";
|
||||
import { onLoad, onShow } from "@dcloudio/uni-app";
|
||||
import { onLoad, onShow, onUnload } from "@dcloudio/uni-app";
|
||||
import AppLoading from "@/components/AppLoading.vue";
|
||||
import AppTabbar from "@/components/AppTabbar.vue";
|
||||
import FamilyFeedMedia from "@/components/family/FeedMedia.vue";
|
||||
import ModulePageBackground from "@/components/ModulePageBackground.vue";
|
||||
import PageHeader from "@/components/PageHeader.vue";
|
||||
import { appApi, isRequestCancelled } from "@/utils/api.js";
|
||||
import { genealogyContext } from "@/utils/genealogy-context.js";
|
||||
import { goRoot, openPage } from "@/utils/navigation.js";
|
||||
import {
|
||||
createRequestController,
|
||||
isRequestCancelled
|
||||
} from "@/services/api/request-controller.js";
|
||||
import { familyFeedApi } from "@/services/api/family-feed-service.js";
|
||||
import { formatMinuteTimestamp } from "@/utils/display-time.js";
|
||||
import { genealogyContext } from "@/utils/genealogy/context.js";
|
||||
import { goRoot, openPage } from "@/utils/navigation/gateway.js";
|
||||
|
||||
const genealogyId = ref("");
|
||||
const hasValidContext = ref(false);
|
||||
const feedState = ref("loading");
|
||||
const feeds = ref([]);
|
||||
const FEED_PAGE_SIZE = 20;
|
||||
const totalFeedCount = ref(0);
|
||||
const currentFeedPage = ref(1);
|
||||
const loadMoreState = ref("idle");
|
||||
const hasMoreFeeds = computed(
|
||||
() => feedState.value === "list" && feeds.value.length < totalFeedCount.value,
|
||||
);
|
||||
const loadMoreLabel = computed(() => {
|
||||
if (loadMoreState.value === "loading") return "正在加载";
|
||||
if (loadMoreState.value === "error") return "加载失败,重新加载";
|
||||
return "继续加载";
|
||||
});
|
||||
let active = true;
|
||||
let skipInitialShowRefresh = true;
|
||||
const feedListRequestController = createRequestController();
|
||||
const recommendationRequestController = createRequestController();
|
||||
const feedTypeLabel = (type) =>
|
||||
String(type || "")
|
||||
.trim()
|
||||
.toLowerCase() === "text"
|
||||
? "文字动态"
|
||||
: "家族动态";
|
||||
const feedPreview = (content) => {
|
||||
const text = String(content || "").trim();
|
||||
return text.length > 96 ? `${text.slice(0, 96).trimEnd()}…` : text;
|
||||
};
|
||||
const shortcuts = [
|
||||
{ key: "articles", label: "谱文" },
|
||||
{ key: "albums", label: "相册" },
|
||||
@@ -109,16 +142,67 @@ const stateCopy = computed(() =>
|
||||
const loadFeeds = async () => {
|
||||
if (!hasValidContext.value) return;
|
||||
feedState.value = "loading";
|
||||
loadMoreState.value = "idle";
|
||||
currentFeedPage.value = 1;
|
||||
feedListRequestController.abort();
|
||||
recommendationRequestController.abort();
|
||||
try {
|
||||
const rows = await appApi.getFeeds(genealogyId.value);
|
||||
const [pageResult, recommendationResult] = await Promise.allSettled([
|
||||
familyFeedApi.getFeedPage(
|
||||
genealogyId.value,
|
||||
{ pageNum: 1, pageSize: FEED_PAGE_SIZE },
|
||||
{ requestController: feedListRequestController },
|
||||
),
|
||||
familyFeedApi.getFeedRecommendations(genealogyId.value, {
|
||||
requestController: recommendationRequestController,
|
||||
}),
|
||||
]);
|
||||
if (pageResult.status === "rejected") throw pageResult.reason;
|
||||
if (!active) return;
|
||||
feeds.value = rows;
|
||||
const recommendations =
|
||||
recommendationResult.status === "fulfilled"
|
||||
? recommendationResult.value
|
||||
: [];
|
||||
const reasons = new Map(
|
||||
recommendations.map((item) => [String(item.id), item.recommendationReason]),
|
||||
);
|
||||
feeds.value = pageResult.value.rows.map((item) => ({
|
||||
...item,
|
||||
recommendationReason: reasons.get(String(item.id)) || "",
|
||||
}));
|
||||
totalFeedCount.value = pageResult.value.total;
|
||||
loadMoreState.value =
|
||||
feeds.value.length < totalFeedCount.value ? "idle" : "done";
|
||||
feedState.value = feeds.value.length ? "list" : "empty";
|
||||
} catch (error) {
|
||||
if (!active || isRequestCancelled(error)) return;
|
||||
feedState.value = "error";
|
||||
}
|
||||
};
|
||||
const loadMoreFeeds = async () => {
|
||||
if (!hasMoreFeeds.value || loadMoreState.value === "loading") return;
|
||||
loadMoreState.value = "loading";
|
||||
try {
|
||||
const nextPage = currentFeedPage.value + 1;
|
||||
const page = await familyFeedApi.getFeedPage(
|
||||
genealogyId.value,
|
||||
{ pageNum: nextPage, pageSize: FEED_PAGE_SIZE },
|
||||
{ requestController: feedListRequestController },
|
||||
);
|
||||
if (!active) return;
|
||||
const knownIds = new Set(feeds.value.map((item) => String(item.id)));
|
||||
feeds.value = feeds.value.concat(
|
||||
page.rows.filter((item) => !knownIds.has(String(item.id))),
|
||||
);
|
||||
currentFeedPage.value = nextPage;
|
||||
totalFeedCount.value = page.total;
|
||||
loadMoreState.value =
|
||||
feeds.value.length < totalFeedCount.value ? "idle" : "done";
|
||||
} catch (error) {
|
||||
if (!active || isRequestCancelled(error)) return;
|
||||
loadMoreState.value = "error";
|
||||
}
|
||||
};
|
||||
onLoad((query) => {
|
||||
const supplied = Object.prototype.hasOwnProperty.call(
|
||||
query || {},
|
||||
@@ -132,11 +216,20 @@ onLoad((query) => {
|
||||
else feedState.value = "error";
|
||||
});
|
||||
onShow(() => {
|
||||
if (skipInitialShowRefresh) {
|
||||
skipInitialShowRefresh = false;
|
||||
return;
|
||||
}
|
||||
if (hasValidContext.value) void loadFeeds();
|
||||
});
|
||||
onUnload(() => {
|
||||
active = false;
|
||||
feedListRequestController.abort();
|
||||
recommendationRequestController.abort();
|
||||
});
|
||||
const toPublish = () =>
|
||||
hasValidContext.value
|
||||
? openPage("F02", { genealogyId: genealogyId.value }, "F01")
|
||||
? openPage("F02", { genealogyId: genealogyId.value, mode: "create" }, "F01")
|
||||
: goRoot("G01");
|
||||
const openFeed = (item) =>
|
||||
openPage("F03", { genealogyId: genealogyId.value, feedId: item.id }, "F01");
|
||||
@@ -190,10 +283,17 @@ const handlePrimaryAction = () =>
|
||||
.feed-shortcuts {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 8rpx;
|
||||
margin-top: 15rpx;
|
||||
}
|
||||
.feed-shortcut {
|
||||
box-sizing: border-box;
|
||||
min-height: 48px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: 1rpx solid rgba(181, 137, 63, 0.45);
|
||||
border-radius: 8rpx;
|
||||
background: rgba(255, 252, 244, 0.58);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
@@ -237,6 +337,37 @@ const handlePrimaryAction = () =>
|
||||
color: $ink-muted;
|
||||
font-size: clamp(13px, 21rpx, 16px);
|
||||
}
|
||||
.feed-shortcut::after {
|
||||
border: 0;
|
||||
}
|
||||
.feed-shortcut:active {
|
||||
background: rgba(181, 137, 63, 0.1);
|
||||
}
|
||||
.feed-card__reason {
|
||||
margin-top: 8rpx;
|
||||
color: #9a6555;
|
||||
font-size: clamp(12px, 20rpx, 15px);
|
||||
}
|
||||
.feed-more {
|
||||
@include adaptive-scroll-button(secondary);
|
||||
display: block;
|
||||
width: 420rpx;
|
||||
max-width: 100%;
|
||||
min-height: 76rpx;
|
||||
margin: 22rpx auto 0;
|
||||
padding: 0 24rpx;
|
||||
border: 0;
|
||||
color: $ink;
|
||||
font-size: clamp(14px, 22rpx, 17px);
|
||||
font-weight: 700;
|
||||
line-height: 76rpx;
|
||||
}
|
||||
.feed-more::after {
|
||||
border: 0;
|
||||
}
|
||||
.feed-more[disabled] {
|
||||
opacity: 0.62;
|
||||
}
|
||||
.feed-state-card {
|
||||
min-height: 230rpx;
|
||||
display: flex;
|
||||
@@ -0,0 +1,660 @@
|
||||
<template>
|
||||
<view class="video-page" :class="`video-state--${pageState}`">
|
||||
<ModulePageBackground module="family" />
|
||||
<view class="page-header">
|
||||
<PageHeader
|
||||
title="家族视频"
|
||||
:action="pageState === 'list' ? '发布' : ''"
|
||||
custom-back
|
||||
@back="returnToFamily"
|
||||
@action="openPublishForm"
|
||||
/>
|
||||
</view>
|
||||
<view class="page-content">
|
||||
<view v-if="pageState === 'form'" class="video-panel">
|
||||
<text class="video-panel__title">{{
|
||||
isEdit ? "编辑家族视频" : "发布家族视频"
|
||||
}}</text>
|
||||
<text class="video-panel__note">{{
|
||||
isEdit
|
||||
? "原视频、封面和相关设置会保留。"
|
||||
: "视频上传成功后会自动关联,无需额外填写。"
|
||||
}}</text>
|
||||
|
||||
<view class="video-field video-field--upload">
|
||||
<text class="video-field__label"
|
||||
><text class="required-mark">*</text>视频文件</text
|
||||
>
|
||||
<button
|
||||
class="upload-button"
|
||||
:disabled="isEdit || uploading || coverUploading || submitting"
|
||||
@click="selectVideo"
|
||||
>
|
||||
{{
|
||||
isEdit
|
||||
? "当前视频已关联"
|
||||
: uploading
|
||||
? "上传中…"
|
||||
: receipt
|
||||
? "重新选择视频"
|
||||
: "选择视频"
|
||||
}}
|
||||
</button>
|
||||
<text v-if="receipt" class="upload-receipt"
|
||||
>已上传:{{ receipt.fileName || "视频" }}</text
|
||||
>
|
||||
</view>
|
||||
<text v-if="uploadError" class="field-error">{{ uploadError }}</text>
|
||||
|
||||
<view class="video-field video-field--upload">
|
||||
<text class="video-field__label">视频封面</text>
|
||||
<button
|
||||
class="upload-button"
|
||||
:disabled="uploading || coverUploading || submitting"
|
||||
@click="selectCover"
|
||||
>
|
||||
{{
|
||||
coverUploading
|
||||
? "上传中…"
|
||||
: coverReceipt
|
||||
? "重新选择封面"
|
||||
: "选择图片"
|
||||
}}
|
||||
</button>
|
||||
<text v-if="coverReceipt" class="upload-receipt"
|
||||
>已上传:{{ coverReceipt.fileName || "视频封面" }}</text
|
||||
>
|
||||
</view>
|
||||
<text v-if="coverUploadError" class="field-error">{{
|
||||
coverUploadError
|
||||
}}</text>
|
||||
|
||||
<view class="video-field">
|
||||
<text class="video-field__label"
|
||||
><text class="required-mark">*</text>视频标题</text
|
||||
>
|
||||
<input
|
||||
v-model="form.videoTitle"
|
||||
maxlength="100"
|
||||
placeholder="例如:2026 年清明祭祖活动"
|
||||
placeholder-class="placeholder"
|
||||
@input="submitError = ''"
|
||||
/>
|
||||
</view>
|
||||
<view class="video-field video-field--textarea">
|
||||
<text class="video-field__label">视频说明</text>
|
||||
<textarea
|
||||
v-model="form.videoDesc"
|
||||
maxlength="500"
|
||||
auto-height
|
||||
placeholder="补充视频中的人物、场景或故事"
|
||||
placeholder-class="placeholder"
|
||||
@input="submitError = ''"
|
||||
/>
|
||||
</view>
|
||||
<text v-if="submitError" class="field-error">{{ submitError }}</text>
|
||||
<AppButton
|
||||
block
|
||||
:disabled="uploading || coverUploading || submitting"
|
||||
:label="submitting ? '正在提交…' : isEdit ? '保存视频' : '发布视频'"
|
||||
@click="submitVideo"
|
||||
/>
|
||||
</view>
|
||||
|
||||
<view v-else-if="pageState === 'form-loading'" class="video-state-card">
|
||||
<AppLoading text="正在读取视频详情" />
|
||||
</view>
|
||||
|
||||
<view v-else-if="pageState === 'list'" class="video-list-panel">
|
||||
<view v-if="videoListState === 'loading'" class="video-state-card">
|
||||
<AppLoading text="正在读取家族视频" />
|
||||
</view>
|
||||
<view v-else-if="videoListState === 'error'" class="video-state-card">
|
||||
<text class="video-state-card__title">暂时无法读取视频</text>
|
||||
<text class="video-state-card__copy">请检查网络后重新加载。</text>
|
||||
<AppButton
|
||||
block
|
||||
type="secondary"
|
||||
label="重新加载"
|
||||
@click="loadVideos"
|
||||
/>
|
||||
</view>
|
||||
<view v-else-if="!videos.length" class="video-state-card">
|
||||
<text class="video-state-card__title">还没有家族视频</text>
|
||||
<text class="video-state-card__copy"
|
||||
>可以先发布一段值得留存的影像。</text
|
||||
>
|
||||
<AppButton block label="发布视频" @click="openPublishForm" />
|
||||
</view>
|
||||
<view v-else class="video-card-list">
|
||||
<view v-for="video in videos" :key="video.id" class="video-card">
|
||||
<video
|
||||
class="video-card__player"
|
||||
:src="video.videoFile.accessUrl"
|
||||
controls
|
||||
/>
|
||||
<text class="video-card__title">{{ video.title }}</text>
|
||||
<text v-if="video.description" class="video-card__copy">{{
|
||||
video.description
|
||||
}}</text>
|
||||
<text class="video-card__meta">{{
|
||||
video.publishTime || "刚刚发布"
|
||||
}}</text>
|
||||
<view
|
||||
v-if="video.canEdit || video.canDelete"
|
||||
class="video-card__actions"
|
||||
>
|
||||
<AppButton
|
||||
v-if="video.canEdit"
|
||||
compact
|
||||
type="secondary"
|
||||
label="编辑视频"
|
||||
@click="openEditVideo(video)"
|
||||
/>
|
||||
<AppButton
|
||||
compact
|
||||
type="secondary"
|
||||
:disabled="deletingVideoId === video.id"
|
||||
:label="deletingVideoId === video.id ? '正在删除' : '删除视频'"
|
||||
@click="requestDeleteVideo(video)"
|
||||
/>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<text v-if="videoActionError" class="field-error">{{
|
||||
videoActionError
|
||||
}}</text>
|
||||
</view>
|
||||
<view v-else class="video-state-card">
|
||||
<text class="video-state-card__title">{{ stateCopy.title }}</text>
|
||||
<text class="video-state-card__copy">{{ stateCopy.copy }}</text>
|
||||
<AppButton block :label="stateCopy.action" @click="handleStateAction" />
|
||||
</view>
|
||||
</view>
|
||||
<AppDialog
|
||||
:visible="deleteConfirmVisible"
|
||||
eyebrow="删除确认"
|
||||
title="删除这段家族视频?"
|
||||
:message="deleteTarget ? `《${deleteTarget.title}》删除后不可恢复。` : ''"
|
||||
:confirm-text="deletingVideoId ? '正在删除' : '确认删除'"
|
||||
cancel-text="保留视频"
|
||||
show-cancel
|
||||
:close-on-mask="false"
|
||||
@confirm="confirmDeleteVideo"
|
||||
@cancel="deleteConfirmVisible = false"
|
||||
/>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, reactive, ref } from "vue";
|
||||
import { onLoad, onUnload } from "@dcloudio/uni-app";
|
||||
import AppLoading from "@/components/AppLoading.vue";
|
||||
import AppButton from "@/components/AppButton.vue";
|
||||
import AppDialog from "@/components/AppDialog.vue";
|
||||
import ModulePageBackground from "@/components/ModulePageBackground.vue";
|
||||
import PageHeader from "@/components/PageHeader.vue";
|
||||
import {
|
||||
createRequestController,
|
||||
isRequestCancelled
|
||||
} from "@/services/api/request-controller.js";
|
||||
import { familyMediaApi } from "@/services/api/family-media-service.js";
|
||||
import { getRequestErrorMessage } from "@/services/api/request-error-message.js";
|
||||
import {
|
||||
isImagePickCancelled,
|
||||
isVideoPickCancelled,
|
||||
pickAndUploadImage,
|
||||
pickAndUploadVideo,
|
||||
} from "@/utils/media-upload.js";
|
||||
import { createNonIdempotentWriteGuard } from "@/utils/non-idempotent-write-guard.js";
|
||||
import { goBack, returnTo } from "@/utils/navigation/gateway.js";
|
||||
|
||||
const genealogyId = ref("");
|
||||
const pageState = ref("list");
|
||||
const videoListState = ref("loading");
|
||||
const videos = ref([]);
|
||||
const receipt = ref(null);
|
||||
const coverReceipt = ref(null);
|
||||
const uploading = ref(false);
|
||||
const coverUploading = ref(false);
|
||||
const submitting = ref(false);
|
||||
const uploadError = ref("");
|
||||
const coverUploadError = ref("");
|
||||
const submitError = ref("");
|
||||
const form = reactive({ videoTitle: "", videoDesc: "" });
|
||||
const editingVideo = ref(null);
|
||||
const videoListRequestController = createRequestController();
|
||||
const videoDetailRequestController = createRequestController();
|
||||
const videoUploadRequestController = createRequestController();
|
||||
const coverUploadRequestController = createRequestController();
|
||||
const videoSaveRequestController = createRequestController();
|
||||
const videoDeletionRequestController = createRequestController();
|
||||
const videoCreateGuard = createNonIdempotentWriteGuard();
|
||||
const deleteTarget = ref(null);
|
||||
const deleteConfirmVisible = ref(false);
|
||||
const deletingVideoId = ref("");
|
||||
const videoActionError = ref("");
|
||||
let pageActive = true;
|
||||
const hasValidContext = computed(() => /^[1-9]\d*$/.test(genealogyId.value));
|
||||
const isEdit = computed(() => Boolean(editingVideo.value));
|
||||
const stateCopy = computed(() =>
|
||||
pageState.value === "success"
|
||||
? {
|
||||
title: "视频已保存",
|
||||
copy: "视频已关联到当前家谱,播放内容准备好后会在这里显示。",
|
||||
action: "返回家族动态",
|
||||
}
|
||||
: {
|
||||
title: "暂时无法发布视频",
|
||||
copy: "未找到家谱信息,请返回后重新进入。",
|
||||
action: "返回上一页",
|
||||
},
|
||||
);
|
||||
|
||||
onLoad((query) => {
|
||||
genealogyId.value = String(query?.genealogyId || "");
|
||||
if (!hasValidContext.value) pageState.value = "invalid";
|
||||
else loadVideos();
|
||||
});
|
||||
onUnload(() => {
|
||||
pageActive = false;
|
||||
videoListRequestController.abort();
|
||||
videoDetailRequestController.abort();
|
||||
videoUploadRequestController.abort();
|
||||
coverUploadRequestController.abort();
|
||||
videoSaveRequestController.abort();
|
||||
videoDeletionRequestController.abort();
|
||||
});
|
||||
|
||||
const loadVideos = async () => {
|
||||
if (!hasValidContext.value) return;
|
||||
videoListState.value = "loading";
|
||||
try {
|
||||
const videoRows = await familyMediaApi.getVideos(genealogyId.value, {
|
||||
requestController: videoListRequestController,
|
||||
});
|
||||
if (!pageActive) return;
|
||||
videos.value = videoRows;
|
||||
videoListState.value = "ready";
|
||||
} catch (error) {
|
||||
if (!pageActive || isRequestCancelled(error)) return;
|
||||
videoListState.value = "error";
|
||||
}
|
||||
};
|
||||
const openPublishForm = () => {
|
||||
if (!hasValidContext.value) return;
|
||||
receipt.value = null;
|
||||
coverReceipt.value = null;
|
||||
editingVideo.value = null;
|
||||
form.videoTitle = "";
|
||||
form.videoDesc = "";
|
||||
uploadError.value = "";
|
||||
coverUploadError.value = "";
|
||||
submitError.value = "";
|
||||
pageState.value = "form";
|
||||
};
|
||||
const openEditVideo = async (video) => {
|
||||
if (
|
||||
!video?.canEdit ||
|
||||
uploading.value ||
|
||||
coverUploading.value ||
|
||||
submitting.value
|
||||
)
|
||||
return;
|
||||
pageState.value = "form-loading";
|
||||
videoActionError.value = "";
|
||||
try {
|
||||
const detail = await familyMediaApi.getVideoDetail(genealogyId.value, video.id, {
|
||||
requestController: videoDetailRequestController,
|
||||
});
|
||||
if (
|
||||
!detail.canEdit ||
|
||||
!detail.videoFile?.ossId ||
|
||||
!["0", "1"].includes(detail.status) ||
|
||||
!Number.isSafeInteger(detail.durationSeconds) ||
|
||||
!Number.isSafeInteger(detail.sortOrder)
|
||||
) {
|
||||
throw new Error("视频信息不完整,暂未保存修改,以免覆盖原内容。");
|
||||
}
|
||||
receipt.value = {
|
||||
ossId: detail.videoFile.ossId,
|
||||
fileName: detail.videoFile.fileName,
|
||||
};
|
||||
coverReceipt.value = detail.coverFile
|
||||
? { ossId: detail.coverFile.ossId, fileName: detail.coverFile.fileName }
|
||||
: null;
|
||||
form.videoTitle = detail.title;
|
||||
form.videoDesc = detail.description;
|
||||
editingVideo.value = {
|
||||
id: detail.id,
|
||||
durationSeconds: detail.durationSeconds,
|
||||
sortOrder: detail.sortOrder,
|
||||
status: detail.status,
|
||||
};
|
||||
uploadError.value = "";
|
||||
coverUploadError.value = "";
|
||||
submitError.value = "";
|
||||
pageState.value = "form";
|
||||
} catch (error) {
|
||||
if (!isRequestCancelled(error)) {
|
||||
videoActionError.value = "视频信息不完整,暂未保存修改,以免覆盖原内容。";
|
||||
pageState.value = "list";
|
||||
}
|
||||
}
|
||||
};
|
||||
const requestDeleteVideo = (video) => {
|
||||
if (!video?.canDelete || deletingVideoId.value) return;
|
||||
videoActionError.value = "";
|
||||
deleteTarget.value = video;
|
||||
deleteConfirmVisible.value = true;
|
||||
};
|
||||
const confirmDeleteVideo = async () => {
|
||||
const target = deleteTarget.value;
|
||||
if (!target?.canDelete || deletingVideoId.value) return;
|
||||
deletingVideoId.value = target.id;
|
||||
videoActionError.value = "";
|
||||
try {
|
||||
await familyMediaApi.deleteVideo(genealogyId.value, target.id, {
|
||||
requestController: videoDeletionRequestController,
|
||||
});
|
||||
if (!pageActive) return;
|
||||
deleteConfirmVisible.value = false;
|
||||
deleteTarget.value = null;
|
||||
await loadVideos();
|
||||
} catch (error) {
|
||||
if (!pageActive || isRequestCancelled(error)) return;
|
||||
videoActionError.value = getRequestErrorMessage(
|
||||
error,
|
||||
"视频删除失败,请稍后重试",
|
||||
);
|
||||
deleteConfirmVisible.value = false;
|
||||
} finally {
|
||||
if (pageActive) deletingVideoId.value = "";
|
||||
}
|
||||
};
|
||||
|
||||
const selectVideo = async () => {
|
||||
if (
|
||||
isEdit.value ||
|
||||
uploading.value ||
|
||||
coverUploading.value ||
|
||||
submitting.value
|
||||
)
|
||||
return;
|
||||
uploading.value = true;
|
||||
uploadError.value = "";
|
||||
try {
|
||||
const videoReceipt = await pickAndUploadVideo({
|
||||
requestController: videoUploadRequestController,
|
||||
});
|
||||
if (!pageActive) return;
|
||||
receipt.value = videoReceipt;
|
||||
} catch (error) {
|
||||
if (pageActive && !isVideoPickCancelled(error) && !isRequestCancelled(error)) {
|
||||
uploadError.value = getRequestErrorMessage(error, "视频上传失败,请稍后重试");
|
||||
}
|
||||
} finally {
|
||||
if (pageActive) uploading.value = false;
|
||||
}
|
||||
};
|
||||
const selectCover = async () => {
|
||||
if (uploading.value || coverUploading.value || submitting.value) return;
|
||||
coverUploading.value = true;
|
||||
coverUploadError.value = "";
|
||||
try {
|
||||
const uploadedCover = await pickAndUploadImage({
|
||||
requestController: coverUploadRequestController,
|
||||
});
|
||||
if (!pageActive) return;
|
||||
coverReceipt.value = uploadedCover;
|
||||
} catch (error) {
|
||||
if (pageActive && !isImagePickCancelled(error) && !isRequestCancelled(error)) {
|
||||
coverUploadError.value = getRequestErrorMessage(
|
||||
error,
|
||||
"视频封面上传失败,请稍后重试",
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
if (pageActive) coverUploading.value = false;
|
||||
}
|
||||
};
|
||||
const submitVideo = async () => {
|
||||
if (
|
||||
uploading.value ||
|
||||
coverUploading.value ||
|
||||
submitting.value ||
|
||||
!hasValidContext.value
|
||||
)
|
||||
return;
|
||||
const videoTitle = form.videoTitle.trim();
|
||||
if (!receipt.value) {
|
||||
submitError.value = "请先选择并上传视频";
|
||||
return;
|
||||
}
|
||||
if (!videoTitle) {
|
||||
submitError.value = "请填写视频标题";
|
||||
return;
|
||||
}
|
||||
const payload = {
|
||||
videoTitle,
|
||||
videoDesc: form.videoDesc.trim(),
|
||||
videoOssId: receipt.value.ossId,
|
||||
...(coverReceipt.value ? { coverOssId: coverReceipt.value.ossId } : {}),
|
||||
...(editingVideo.value
|
||||
? {
|
||||
durationSeconds: editingVideo.value.durationSeconds,
|
||||
sortOrder: editingVideo.value.sortOrder,
|
||||
status: editingVideo.value.status,
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
const createAttempt = editingVideo.value ? null : videoCreateGuard.begin(payload);
|
||||
if (!editingVideo.value && createAttempt === null) {
|
||||
submitError.value =
|
||||
"上次发布结果暂时无法确认,请先返回视频列表检查,避免重复发布。";
|
||||
return;
|
||||
}
|
||||
|
||||
submitting.value = true;
|
||||
submitError.value = "";
|
||||
try {
|
||||
if (editingVideo.value) {
|
||||
await familyMediaApi.updateVideo(
|
||||
genealogyId.value,
|
||||
editingVideo.value.id,
|
||||
payload,
|
||||
{
|
||||
requestController: videoSaveRequestController,
|
||||
},
|
||||
);
|
||||
} else {
|
||||
await familyMediaApi.createVideo(genealogyId.value, payload, {
|
||||
requestController: videoSaveRequestController,
|
||||
});
|
||||
}
|
||||
if (!pageActive) return;
|
||||
editingVideo.value = null;
|
||||
pageState.value = "list";
|
||||
await loadVideos();
|
||||
} catch (error) {
|
||||
if (!pageActive) return;
|
||||
if (!editingVideo.value && videoCreateGuard.recordFailure(createAttempt, error)) {
|
||||
submitError.value =
|
||||
"发布结果暂时无法确认,请先返回视频列表检查,避免重复发布。";
|
||||
return;
|
||||
}
|
||||
if (!isRequestCancelled(error))
|
||||
submitError.value = getRequestErrorMessage(error, "视频发布失败,请稍后重试");
|
||||
} finally {
|
||||
if (pageActive) submitting.value = false;
|
||||
}
|
||||
};
|
||||
const returnToFamily = () =>
|
||||
hasValidContext.value
|
||||
? returnTo("F01", { genealogyId: genealogyId.value })
|
||||
: goBack();
|
||||
const handleStateAction = () => returnToFamily();
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@import "../../styles/adaptive-frame-profiles.scss";
|
||||
.video-page {
|
||||
display: flex;
|
||||
min-height: 100vh;
|
||||
flex-direction: column;
|
||||
background: $paper;
|
||||
}
|
||||
.page-header,
|
||||
.page-content {
|
||||
z-index: 1;
|
||||
}
|
||||
.page-content {
|
||||
flex: 1;
|
||||
padding: 18rpx 24rpx 72rpx;
|
||||
}
|
||||
.video-panel,
|
||||
.video-state-card {
|
||||
box-sizing: border-box;
|
||||
@include adaptive-family-content;
|
||||
}
|
||||
.video-list-panel {
|
||||
@include adaptive-family-content;
|
||||
}
|
||||
.video-panel {
|
||||
padding: 30rpx;
|
||||
}
|
||||
.video-panel__title,
|
||||
.video-panel__note,
|
||||
.video-field__label,
|
||||
.video-state-card text {
|
||||
display: block;
|
||||
}
|
||||
.video-panel__title,
|
||||
.video-state-card__title {
|
||||
color: $ink;
|
||||
font-family: STKaiti, KaiTi, serif;
|
||||
font-size: clamp(19px, 34rpx, 24px);
|
||||
font-weight: 700;
|
||||
}
|
||||
.video-panel__note,
|
||||
.video-state-card__copy {
|
||||
margin-top: 12rpx;
|
||||
color: $ink-muted;
|
||||
font-size: clamp(14px, 23rpx, 17px);
|
||||
line-height: 1.6;
|
||||
}
|
||||
.video-field {
|
||||
margin-top: 24rpx;
|
||||
}
|
||||
.video-field__label {
|
||||
margin-bottom: 10rpx;
|
||||
color: $ink;
|
||||
font-size: clamp(15px, 24rpx, 18px);
|
||||
font-weight: 700;
|
||||
}
|
||||
.video-field input,
|
||||
.video-field textarea {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
border: 1rpx solid rgba(143, 108, 63, 0.34);
|
||||
border-radius: 8rpx;
|
||||
background: rgba(255, 253, 247, 0.8);
|
||||
color: $ink;
|
||||
font-size: clamp(15px, 24rpx, 18px);
|
||||
}
|
||||
.video-field input {
|
||||
min-height: 76rpx;
|
||||
padding: 0 18rpx;
|
||||
}
|
||||
.video-field textarea {
|
||||
min-height: 140rpx;
|
||||
padding: 16rpx 18rpx;
|
||||
}
|
||||
.video-field--upload {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 12rpx;
|
||||
}
|
||||
.video-field--upload .video-field__label {
|
||||
width: 100%;
|
||||
}
|
||||
.upload-button {
|
||||
margin: 0;
|
||||
padding: 0 26rpx;
|
||||
border: 1rpx solid #b78a42;
|
||||
border-radius: 8rpx;
|
||||
background: #fffaf0;
|
||||
color: #805723;
|
||||
font-size: clamp(14px, 23rpx, 17px);
|
||||
line-height: 64rpx;
|
||||
}
|
||||
.upload-receipt {
|
||||
color: $ink-muted;
|
||||
font-size: clamp(13px, 21rpx, 16px);
|
||||
}
|
||||
.required-mark,
|
||||
.field-error {
|
||||
color: $brand-red;
|
||||
}
|
||||
.field-error {
|
||||
display: block;
|
||||
margin-top: 12rpx;
|
||||
font-size: clamp(14px, 22rpx, 17px);
|
||||
}
|
||||
.video-panel .app-button {
|
||||
margin-top: 28rpx;
|
||||
}
|
||||
.video-state-card {
|
||||
min-height: 340rpx;
|
||||
padding: 78rpx 52rpx 50rpx;
|
||||
text-align: center;
|
||||
}
|
||||
.video-state-card .app-button {
|
||||
margin-top: 28rpx;
|
||||
}
|
||||
.video-card-list {
|
||||
display: grid;
|
||||
gap: 20rpx;
|
||||
}
|
||||
.video-card {
|
||||
padding: 22rpx;
|
||||
border: 1rpx solid rgba(128, 89, 49, 0.28);
|
||||
border-radius: 12rpx;
|
||||
background: rgba(255, 252, 245, 0.78);
|
||||
}
|
||||
.video-card__player {
|
||||
display: block;
|
||||
width: 100%;
|
||||
min-height: 340rpx;
|
||||
border-radius: 8rpx;
|
||||
background: #161616;
|
||||
}
|
||||
.video-card__title,
|
||||
.video-card__copy,
|
||||
.video-card__meta {
|
||||
display: block;
|
||||
}
|
||||
.video-card__title {
|
||||
margin-top: 16rpx;
|
||||
color: $ink;
|
||||
font-size: clamp(17px, 28rpx, 21px);
|
||||
font-weight: 700;
|
||||
}
|
||||
.video-card__copy {
|
||||
margin-top: 8rpx;
|
||||
color: $ink-muted;
|
||||
font-size: clamp(14px, 23rpx, 17px);
|
||||
line-height: 1.55;
|
||||
}
|
||||
.video-card__meta {
|
||||
margin-top: 10rpx;
|
||||
color: $ink-muted;
|
||||
font-size: clamp(13px, 21rpx, 16px);
|
||||
}
|
||||
.video-card__actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-top: 14rpx;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,258 @@
|
||||
<template>
|
||||
<view class="review-page">
|
||||
<GenealogyPageBackground />
|
||||
<view class="page-header">
|
||||
<PageHeader title="申请审核" custom-back @back="returnToGenealogies" />
|
||||
</view>
|
||||
<view class="page-content">
|
||||
<view v-if="!valid" class="state-card">
|
||||
<text>暂时无法打开审核页面</text>
|
||||
<text>未找到家谱信息,请返回后重新进入。</text>
|
||||
<AppButton block label="返回我的家谱" @click="returnToGenealogies" />
|
||||
</view>
|
||||
<view v-else-if="applicationReviewState === 'loading'" class="state-card">
|
||||
<AppLoading text="正在读取待审核申请" />
|
||||
</view>
|
||||
<view v-else-if="applicationReviewState === 'error'" class="state-card">
|
||||
<text>暂时无法读取待审核申请</text>
|
||||
<text>{{ applicationReviewError || "请检查网络后重试。" }}</text>
|
||||
<AppButton block type="secondary" label="重新加载" @click="loadApplications" />
|
||||
</view>
|
||||
<view v-else-if="applicationReviewState === 'empty'" class="state-card">
|
||||
<text>暂无待审核申请</text>
|
||||
<text>新的加入申请会显示在这里。</text>
|
||||
<AppButton block label="返回家谱总览" @click="returnToOverview" />
|
||||
</view>
|
||||
<view v-else class="review-list">
|
||||
<text class="review-list__count">共 {{ applications.length }} 条待审核申请</text>
|
||||
<text v-if="feedbackMessage" class="page-feedback" role="status">{{ feedbackMessage }}</text>
|
||||
<view v-for="(item, index) in applications" :key="applicationKey(item, index)" class="review-card">
|
||||
<view class="review-card__heading">
|
||||
<text>{{ applicantName(item) }}</text>
|
||||
<text>{{ applicationTime(item) }}</text>
|
||||
</view>
|
||||
<text v-if="applicantPhone(item)" class="review-card__phone">{{ applicantPhone(item) }}</text>
|
||||
<text class="review-card__relation">{{ applicationRelation(item) }}</text>
|
||||
<text v-if="applicationReason(item)" class="review-card__reason">{{ applicationReason(item) }}</text>
|
||||
<view class="review-card__actions">
|
||||
<AppButton compact type="secondary" label="拒绝" :disabled="!applicationId(item) || operationPending" @click="openAudit(item, JOIN_APPLICATION_STATUS.REJECTED)" />
|
||||
<AppButton compact label="通过" :disabled="!applicationId(item) || operationPending" @click="openAudit(item, JOIN_APPLICATION_STATUS.APPROVED)" />
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<AppDialog
|
||||
:visible="Boolean(auditTarget)"
|
||||
eyebrow="申请审核"
|
||||
:title="auditTarget?.decision === JOIN_APPLICATION_STATUS.APPROVED ? '通过这条申请?' : '拒绝这条申请?'"
|
||||
:message="auditDialogMessage"
|
||||
:confirm-text="operationPending ? '正在提交…' : auditTarget?.decision === JOIN_APPLICATION_STATUS.APPROVED ? '确认通过' : '确认拒绝'"
|
||||
cancel-text="取消"
|
||||
show-cancel
|
||||
:close-on-mask="!operationPending"
|
||||
@confirm="submitAudit"
|
||||
@cancel="closeAudit"
|
||||
>
|
||||
<view v-if="auditTarget?.decision === JOIN_APPLICATION_STATUS.REJECTED" class="rejection-field">
|
||||
<text>拒绝原因</text>
|
||||
<textarea
|
||||
id="application-rejection-reason"
|
||||
v-model.trim="rejectionReason"
|
||||
auto-height
|
||||
maxlength="120"
|
||||
placeholder="请说明需要补充或核实的信息"
|
||||
:disabled="operationPending"
|
||||
:aria-invalid="!!rejectionError"
|
||||
aria-describedby="application-rejection-error"
|
||||
:focus="rejectionFocused"
|
||||
@input="clearRejectionError"
|
||||
/>
|
||||
<text v-if="rejectionError" id="application-rejection-error" class="rejection-field__error" role="alert">{{ rejectionError }}</text>
|
||||
</view>
|
||||
<text v-if="operationError" class="dialog-error" role="alert">{{ operationError }}</text>
|
||||
</AppDialog>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, nextTick, ref } from "vue";
|
||||
import { onBackPress, onLoad, 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 GenealogyPageBackground from "@/components/genealogy/PageBackground.vue";
|
||||
import PageHeader from "@/components/PageHeader.vue";
|
||||
import {
|
||||
JOIN_APPLICATION_STATUS
|
||||
} from "@/services/api/genealogy-membership-contract.js";
|
||||
import {
|
||||
createRequestController,
|
||||
isRequestCancelled
|
||||
} from "@/services/api/request-controller.js";
|
||||
import { genealogyMembershipApi } from "@/services/api/genealogy-membership-service.js";
|
||||
import { getRequestErrorMessage } from "@/services/api/request-error-message.js";
|
||||
import { handleBackPress, returnTo, runBackGuard } from "@/utils/navigation/gateway.js";
|
||||
|
||||
const genealogyId = ref("");
|
||||
const applications = ref([]);
|
||||
const applicationReviewState = ref("loading");
|
||||
const applicationReviewError = ref("");
|
||||
const auditTarget = ref(null);
|
||||
const rejectionReason = ref("");
|
||||
const rejectionError = ref("");
|
||||
const rejectionFocused = ref(false);
|
||||
const operationError = ref("");
|
||||
const operationPending = ref(false);
|
||||
const feedbackMessage = ref("");
|
||||
const applicationListController = createRequestController();
|
||||
const applicationAuditController = createRequestController();
|
||||
let isPageActive = true;
|
||||
|
||||
const auditDialogMessage = computed(() => {
|
||||
const name = applicantName(auditTarget.value?.item);
|
||||
return auditTarget.value?.decision === JOIN_APPLICATION_STATUS.APPROVED
|
||||
? `通过后,“${name}”将加入家谱。`
|
||||
: "拒绝后,对方可在“我的申请”中查看处理结果。";
|
||||
});
|
||||
|
||||
const valid = computed(() => /^[1-9]\d*$/.test(genealogyId.value));
|
||||
const applicationId = (item) => {
|
||||
const applicationIdValue = item?.applyId ?? item?.applicationId ?? item?.id;
|
||||
const text = String(applicationIdValue ?? "").trim();
|
||||
return /^[1-9]\d*$/.test(text) ? text : "";
|
||||
};
|
||||
const applicationKey = (item, index) => applicationId(item) || `application-${index}`;
|
||||
const applicantName = (item) => String(item?.applicantName ?? item?.name ?? "申请人");
|
||||
const applicantPhone = (item) => String(item?.phone ?? item?.mobile ?? "");
|
||||
const applicationTime = (item) => String(item?.appliedAt ?? item?.applyTime ?? item?.createTime ?? item?.createdAt ?? "");
|
||||
const applicationRelation = (item) => String(item?.relationDesc ?? item?.relation ?? "未填写关系说明");
|
||||
const applicationReason = (item) => String(item?.applyReason ?? item?.reason ?? "");
|
||||
|
||||
const loadApplications = async () => {
|
||||
if (!valid.value) return;
|
||||
applicationListController.abort();
|
||||
applicationReviewState.value = "loading";
|
||||
applicationReviewError.value = "";
|
||||
feedbackMessage.value = "";
|
||||
try {
|
||||
const loadedApplications = await genealogyMembershipApi.getPendingApplications(genealogyId.value, {
|
||||
requestController: applicationListController,
|
||||
});
|
||||
if (!isPageActive) return;
|
||||
applications.value = loadedApplications;
|
||||
applicationReviewState.value = applications.value.length ? "ready" : "empty";
|
||||
} catch (error) {
|
||||
if (!isPageActive || isRequestCancelled(error)) return;
|
||||
applicationReviewError.value = getRequestErrorMessage(error, "请稍后重试。");
|
||||
applicationReviewState.value = "error";
|
||||
}
|
||||
};
|
||||
const returnToGenealogies = () => returnTo("G01");
|
||||
const returnToOverview = () => returnTo("G05", { genealogyId: genealogyId.value });
|
||||
const clearRejectionError = () => {
|
||||
rejectionError.value = "";
|
||||
operationError.value = "";
|
||||
};
|
||||
const openAudit = (item, decision) => {
|
||||
rejectionReason.value = "";
|
||||
rejectionError.value = "";
|
||||
rejectionFocused.value = false;
|
||||
operationError.value = "";
|
||||
auditTarget.value = { item, decision };
|
||||
};
|
||||
const resetAuditDialog = () => {
|
||||
auditTarget.value = null;
|
||||
rejectionReason.value = "";
|
||||
rejectionError.value = "";
|
||||
rejectionFocused.value = false;
|
||||
operationError.value = "";
|
||||
};
|
||||
const closeAudit = () => {
|
||||
if (operationPending.value) return;
|
||||
resetAuditDialog();
|
||||
};
|
||||
const submitAudit = async () => {
|
||||
const current = auditTarget.value;
|
||||
const id = applicationId(current?.item);
|
||||
if (!current || !id || operationPending.value) return;
|
||||
if (current.decision === JOIN_APPLICATION_STATUS.REJECTED && !rejectionReason.value.trim()) {
|
||||
rejectionError.value = "请填写拒绝原因,方便申请人补充资料。";
|
||||
rejectionFocused.value = false;
|
||||
await nextTick();
|
||||
rejectionFocused.value = true;
|
||||
return;
|
||||
}
|
||||
operationPending.value = true;
|
||||
operationError.value = "";
|
||||
try {
|
||||
await genealogyMembershipApi.auditApplication(
|
||||
genealogyId.value,
|
||||
id,
|
||||
{
|
||||
status: current.decision,
|
||||
...(current.decision === JOIN_APPLICATION_STATUS.REJECTED ? { auditRemark: rejectionReason.value.trim() } : {}),
|
||||
},
|
||||
{ requestController: applicationAuditController },
|
||||
);
|
||||
if (!isPageActive) return;
|
||||
applications.value = applications.value.filter((item) => applicationId(item) !== id);
|
||||
applicationReviewState.value = applications.value.length ? "ready" : "empty";
|
||||
feedbackMessage.value = current.decision === JOIN_APPLICATION_STATUS.APPROVED ? "申请已通过。" : "申请已拒绝,并已附上审核说明。";
|
||||
resetAuditDialog();
|
||||
} catch (error) {
|
||||
if (!isPageActive || isRequestCancelled(error)) return;
|
||||
operationError.value = getRequestErrorMessage(error, "提交审核失败,请稍后重试。");
|
||||
} finally {
|
||||
if (isPageActive) operationPending.value = false;
|
||||
}
|
||||
};
|
||||
const requestBack = () =>
|
||||
runBackGuard({
|
||||
transientOpen: Boolean(auditTarget.value),
|
||||
submitting: operationPending.value,
|
||||
"close-transient": closeAudit,
|
||||
"block-submitting": () => true,
|
||||
});
|
||||
|
||||
onLoad((query) => {
|
||||
genealogyId.value = String(query?.genealogyId || "");
|
||||
if (valid.value) loadApplications();
|
||||
});
|
||||
onShow(() => {
|
||||
if (valid.value && applicationReviewState.value !== "loading" && !operationPending.value) loadApplications();
|
||||
});
|
||||
onBackPress((event) => handleBackPress(event, requestBack));
|
||||
onUnload(() => {
|
||||
isPageActive = false;
|
||||
applicationListController.abort();
|
||||
applicationAuditController.abort();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@use "../../styles/adaptive-frame-profiles.scss" as adaptive;
|
||||
.review-page { display: flex; min-height: 100vh; flex-direction: column; background: $paper; }
|
||||
.page-header, .page-content { z-index: 1; }
|
||||
.page-content { padding: 18rpx 24rpx 72rpx; }
|
||||
.state-card, .review-card { box-sizing: border-box; @include adaptive.adaptive-genealogy-state-panel; }
|
||||
.state-card { min-height: 340rpx; padding: 78rpx 52rpx 50rpx; text-align: center; }
|
||||
.state-card text, .review-card__phone, .review-card__relation, .review-card__reason, .page-feedback { display: block; }
|
||||
.state-card text:first-child { color: $ink; font-family: STKaiti, KaiTi, serif; font-size: clamp(19px, 35rpx, 24px); font-weight: 700; }
|
||||
.state-card text:nth-child(2) { margin-top: 18rpx; color: $ink-muted; font-size: clamp(15px, 24rpx, 18px); line-height: 1.65; }
|
||||
.state-card .app-button { margin-top: 28rpx; }
|
||||
.review-list { display: flex; flex-direction: column; gap: 16rpx; }
|
||||
.review-list__count { color: $ink-muted; font-size: clamp(13px, 21rpx, 16px); }
|
||||
.page-feedback { padding: 18rpx 22rpx; border: 1rpx solid rgba(66, 107, 88, .32); border-radius: 12rpx; background: rgba(66, 107, 88, .08); color: #426b58; font-size: clamp(14px, 23rpx, 17px); }
|
||||
.review-card { padding: 28rpx 30rpx; }
|
||||
.review-card__heading { display: flex; align-items: start; justify-content: space-between; gap: 20rpx; }
|
||||
.review-card__heading text:first-child { min-width: 0; color: $ink; font-family: STKaiti, KaiTi, serif; font-size: clamp(18px, 32rpx, 23px); font-weight: 700; overflow-wrap: anywhere; }
|
||||
.review-card__heading text:last-child { flex: 0 0 auto; color: $ink-muted; font-size: clamp(13px, 21rpx, 16px); text-align: right; }
|
||||
.review-card__phone, .review-card__relation, .review-card__reason { margin-top: 12rpx; color: $ink-muted; font-size: clamp(15px, 24rpx, 18px); line-height: 1.55; overflow-wrap: anywhere; }
|
||||
.review-card__reason { color: #725840; }
|
||||
.review-card__actions { display: flex; justify-content: flex-end; margin-top: 22rpx; gap: 16rpx; }
|
||||
.review-card__actions .app-button { min-width: 140rpx; }
|
||||
.rejection-field { width: 100%; margin-top: 22rpx; text-align: left; }
|
||||
.rejection-field > text:first-child { display: block; color: $ink; font-size: clamp(15px, 24rpx, 18px); font-weight: 700; }
|
||||
.rejection-field textarea { @include adaptive.adaptive-genealogy-form-field; box-sizing: border-box; display: block; width: 100%; min-height: 118rpx; margin-top: 10rpx; padding: 16rpx 20rpx; color: $ink; font-size: clamp(15px, 24rpx, 18px); line-height: 1.55; }
|
||||
.rejection-field__error, .dialog-error { display: block; width: 100%; margin-top: 10rpx; color: $brand-red; font-size: clamp(14px, 23rpx, 17px); line-height: 1.5; text-align: left; }
|
||||
</style>
|
||||
@@ -1,4 +1,3 @@
|
||||
<!-- 页面编号:G-03;用途:创建家谱。首代人物需在服务端提供可恢复合同后另行录入。 -->
|
||||
<template>
|
||||
<view class="create-page">
|
||||
<GenealogyPageBackground />
|
||||
@@ -9,7 +8,7 @@
|
||||
<text class="create-card__eyebrow">立谱信息</text>
|
||||
<text class="create-card__title">为家族创建一部家谱</text>
|
||||
<text class="create-card__note"
|
||||
>创建成功后会回到我的家谱;首代人物可在后续合同开放后再录入。</text
|
||||
>创建成功后会回到“我的家谱”;可在世系树中录入首位成员。</text
|
||||
>
|
||||
|
||||
<view class="field-row">
|
||||
@@ -44,7 +43,12 @@
|
||||
fieldErrors.genealogyName
|
||||
}}</text>
|
||||
|
||||
<view class="field-row field-row--selector" @click="openRegionPicker">
|
||||
<view
|
||||
class="field-row field-row--selector"
|
||||
role="button"
|
||||
aria-label="选择所在地区"
|
||||
@click="openRegionPicker"
|
||||
>
|
||||
<text class="field-row__label"
|
||||
><text class="required-mark">*</text>所在地区</text
|
||||
>
|
||||
@@ -59,6 +63,12 @@
|
||||
: regionPickerError || "请选择所在地区"
|
||||
}}</text
|
||||
>
|
||||
<image
|
||||
class="region-selector-chevron"
|
||||
src="/static/assets/foundation/transparent/chevron-right.png"
|
||||
mode="aspectFit"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</view>
|
||||
<text v-if="fieldErrors.regionCode" class="field-error">{{
|
||||
fieldErrors.regionCode
|
||||
@@ -109,7 +119,7 @@
|
||||
<view>
|
||||
<text class="cover-field__label">封面图片</text>
|
||||
<text class="cover-field__hint"
|
||||
>选择图片后会取得真实上传回执,并在创建家谱时关联。</text
|
||||
>图片上传成功后,会作为家谱封面保存。</text
|
||||
>
|
||||
</view>
|
||||
<button
|
||||
@@ -127,7 +137,11 @@
|
||||
|
||||
<view class="access-rule">
|
||||
<text class="access-rule__label">访问规则</text>
|
||||
<view class="access-rule__options">
|
||||
<view
|
||||
class="access-rule__options"
|
||||
role="radiogroup"
|
||||
aria-label="家谱访问规则"
|
||||
>
|
||||
<view
|
||||
v-for="option in GENEALOGY_ACCESS_PRESET_OPTIONS"
|
||||
:key="option.value"
|
||||
@@ -136,6 +150,9 @@
|
||||
'access-rule__option--active':
|
||||
form.accessPreset === option.value,
|
||||
}"
|
||||
role="radio"
|
||||
:aria-checked="form.accessPreset === option.value"
|
||||
:aria-label="option.label"
|
||||
@click="form.accessPreset = option.value"
|
||||
>{{ option.label }}</view
|
||||
>
|
||||
@@ -146,60 +163,20 @@
|
||||
<AppButton
|
||||
block
|
||||
:disabled="isSubmitting || isUploading"
|
||||
:label="isSubmitting ? '正在创建…' : '确认创建家谱'"
|
||||
:label="isSubmitting ? '正在创建…' : createdGenealogyId ? '返回我的家谱' : '确认创建家谱'"
|
||||
@click="submitCreate"
|
||||
/>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view v-if="regionPickerOpen" class="region-sheet">
|
||||
<view class="region-sheet__mask" />
|
||||
<view class="region-sheet__panel">
|
||||
<view class="region-sheet__intro">
|
||||
<text class="region-sheet__title">选择地区</text>
|
||||
</view>
|
||||
<view class="region-sheet__picker">
|
||||
<view class="region-sheet__column-headings">
|
||||
<text
|
||||
v-for="label in ['省份', '城市', '区县']"
|
||||
:key="label"
|
||||
class="region-sheet__column-heading"
|
||||
>{{ label }}</text
|
||||
>
|
||||
</view>
|
||||
<picker-view
|
||||
class="region-sheet__picker-view"
|
||||
:indicator-style="regionPickerIndicatorStyle"
|
||||
:value="regionPickerIndexes"
|
||||
@change="handleRegionPickerChange"
|
||||
>
|
||||
<picker-view-column
|
||||
v-for="(column, columnIndex) in regionPickerColumns"
|
||||
:key="columnIndex"
|
||||
>
|
||||
<view
|
||||
v-for="(option, optionIndex) in column"
|
||||
:key="option.regionCode"
|
||||
class="region-sheet__picker-item"
|
||||
:class="{
|
||||
'region-sheet__picker-item--selected':
|
||||
regionPickerIndexes[columnIndex] === optionIndex,
|
||||
}"
|
||||
>{{ option.label }}</view
|
||||
>
|
||||
</picker-view-column>
|
||||
</picker-view>
|
||||
</view>
|
||||
<view class="region-sheet__footer">
|
||||
<view class="region-sheet__cancel" @click="closeRegionPicker"
|
||||
>取消</view
|
||||
>
|
||||
<button class="region-sheet__confirm" @click="confirmRegionSelection">
|
||||
确认选择
|
||||
</button>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<RegionPickerDialog
|
||||
ref="regionPickerDialog"
|
||||
:max-levels="3"
|
||||
@select="selectRegion"
|
||||
@loading-change="regionLoading = $event"
|
||||
@error-change="regionPickerError = $event"
|
||||
@transient-change="regionPickerTransientOpen = $event"
|
||||
/>
|
||||
|
||||
<AppDialog
|
||||
:visible="discardDialogVisible"
|
||||
@@ -221,28 +198,32 @@ import { computed, onMounted, reactive, ref } from "vue";
|
||||
import { onBackPress, onUnload } from "@dcloudio/uni-app";
|
||||
import AppButton from "@/components/AppButton.vue";
|
||||
import AppDialog from "@/components/AppDialog.vue";
|
||||
import GenealogyPageBackground from "@/components/GenealogyPageBackground.vue";
|
||||
import GenealogyPageBackground from "@/components/genealogy/PageBackground.vue";
|
||||
import RegionPickerDialog from "@/components/genealogy/RegionPickerDialog.vue";
|
||||
import PageHeader from "@/components/PageHeader.vue";
|
||||
import {
|
||||
appApi,
|
||||
createRequestController,
|
||||
isRequestCancelled,
|
||||
} from "@/utils/api.js";
|
||||
isRequestCancelled
|
||||
} from "@/services/api/request-controller.js";
|
||||
import { genealogyApi } from "@/services/api/genealogy-service.js";
|
||||
import { getRequestErrorMessage } from "@/services/api/request-error-message.js";
|
||||
import { createDiscardConfirmation } from "@/utils/discard-confirmation.js";
|
||||
import { createNonIdempotentWriteGuard } from "@/utils/non-idempotent-write-guard.js";
|
||||
import {
|
||||
GENEALOGY_ACCESS_PRESET,
|
||||
GENEALOGY_ACCESS_PRESET_OPTIONS,
|
||||
toApiGenealogyAccess,
|
||||
} from "@/utils/genealogy-contracts.js";
|
||||
} from "@/utils/genealogy/access-policy.js";
|
||||
import {
|
||||
isImagePickCancelled,
|
||||
pickAndUploadImage,
|
||||
} from "@/utils/resumable-image-upload.js";
|
||||
} from "@/utils/media-upload.js";
|
||||
import {
|
||||
finishPage,
|
||||
handleBackPress,
|
||||
returnTo,
|
||||
runBackGuard,
|
||||
} from "@/utils/navigation.js";
|
||||
} from "@/utils/navigation/gateway.js";
|
||||
|
||||
const form = reactive({
|
||||
surname: "",
|
||||
@@ -260,22 +241,21 @@ const fieldErrors = reactive({
|
||||
});
|
||||
const submitError = ref("");
|
||||
const isSubmitting = ref(false);
|
||||
const createdGenealogyId = ref("");
|
||||
const isUploading = ref(false);
|
||||
const uploadError = ref("");
|
||||
const coverOssId = ref(null);
|
||||
const coverFileName = ref("");
|
||||
const createController = createRequestController();
|
||||
const regionController = createRequestController();
|
||||
const coverUploadRequestController = createRequestController();
|
||||
const genealogyCreateRequestController = createRequestController();
|
||||
const genealogyCreateGuard = createNonIdempotentWriteGuard();
|
||||
const selectedRegion = ref(null);
|
||||
const regionPickerTrail = ref([]);
|
||||
const regionPickerColumns = ref([]);
|
||||
const regionPickerIndexes = ref([0]);
|
||||
const regionPickerDialog = ref(null);
|
||||
const regionPickerError = ref("");
|
||||
const regionLoading = ref(false);
|
||||
const regionPickerOpen = ref(false);
|
||||
const regionPickerTransientOpen = ref(false);
|
||||
const discardDialogVisible = ref(false);
|
||||
const regionPickerIndicatorStyle =
|
||||
"height: 104rpx; border-top: 1px solid rgba(159, 23, 15, .46); border-bottom: 1px solid rgba(159, 23, 15, .46); background: rgba(159, 23, 15, .08);";
|
||||
let pageActive = true;
|
||||
const hasDraft = computed(
|
||||
() =>
|
||||
@@ -312,118 +292,36 @@ const validate = () => {
|
||||
);
|
||||
};
|
||||
|
||||
const fetchRegionChildren = async (parentCode) => {
|
||||
regionLoading.value = true;
|
||||
regionPickerError.value = "";
|
||||
try {
|
||||
return await appApi.getRegionChildren(parentCode, {
|
||||
requestController: regionController,
|
||||
});
|
||||
} catch (error) {
|
||||
if (!isRequestCancelled(error))
|
||||
regionPickerError.value = error?.message || "地区列表加载失败,请重试";
|
||||
return [];
|
||||
} finally {
|
||||
regionLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const loadRegionRoot = async () => {
|
||||
if (isSubmitting.value || isUploading.value || regionLoading.value) return;
|
||||
const roots = await fetchRegionChildren("0");
|
||||
if (!roots.length || regionPickerError.value) return;
|
||||
regionPickerColumns.value = [roots];
|
||||
regionPickerIndexes.value = [0];
|
||||
regionPickerTrail.value = [];
|
||||
await loadPickerColumns();
|
||||
};
|
||||
|
||||
const loadPickerColumns = async (provinceIndex = 0, cityIndex = 0) => {
|
||||
const provinces =
|
||||
regionPickerColumns.value[0] || (await fetchRegionChildren("0"));
|
||||
const province = provinces[provinceIndex];
|
||||
if (!province) return;
|
||||
|
||||
const cities = await fetchRegionChildren(province.regionCode);
|
||||
const city = cities[cityIndex];
|
||||
if (!city) {
|
||||
regionPickerColumns.value = [provinces];
|
||||
regionPickerIndexes.value = [provinceIndex];
|
||||
return;
|
||||
}
|
||||
|
||||
const districts = await fetchRegionChildren(city.regionCode);
|
||||
if (!districts.length || regionPickerError.value) return;
|
||||
regionPickerColumns.value = [provinces, cities, districts];
|
||||
regionPickerIndexes.value = [provinceIndex, cityIndex, 0];
|
||||
};
|
||||
|
||||
const handleRegionPickerChange = async (event) => {
|
||||
if (regionLoading.value) return;
|
||||
const nextIndexes = (event?.detail?.value || []).map(
|
||||
(index) => Number(index) || 0,
|
||||
);
|
||||
const indexes = regionPickerIndexes.value;
|
||||
const provinceIndex = nextIndexes[0] || 0;
|
||||
const cityIndex = nextIndexes[1] || 0;
|
||||
const districtIndex = nextIndexes[2] || 0;
|
||||
|
||||
if (provinceIndex !== (indexes[0] || 0)) {
|
||||
await loadPickerColumns(provinceIndex, 0);
|
||||
return;
|
||||
}
|
||||
if (cityIndex !== (indexes[1] || 0)) {
|
||||
await loadPickerColumns(indexes[0] || 0, cityIndex);
|
||||
return;
|
||||
}
|
||||
regionPickerIndexes.value = [indexes[0] || 0, indexes[1] || 0, districtIndex];
|
||||
};
|
||||
|
||||
const openRegionPicker = async () => {
|
||||
if (isSubmitting.value || isUploading.value || regionLoading.value) return;
|
||||
if (!regionPickerColumns.value.length) await loadRegionRoot();
|
||||
if (regionPickerColumns.value.length) {
|
||||
regionPickerOpen.value = true;
|
||||
}
|
||||
await regionPickerDialog.value?.open(selectedRegion.value?.regionCode || "");
|
||||
};
|
||||
|
||||
const closeRegionPicker = () => {
|
||||
regionPickerOpen.value = false;
|
||||
};
|
||||
|
||||
const confirmRegionSelection = () => {
|
||||
const indexes = regionPickerIndexes.value;
|
||||
const trail = [];
|
||||
regionPickerColumns.value.forEach((column, index) => {
|
||||
const option = column[Number(indexes[index])];
|
||||
if (option) trail.push(option);
|
||||
});
|
||||
const region = trail[trail.length - 1];
|
||||
if (!region) return;
|
||||
regionPickerIndexes.value = trail.map(
|
||||
(_, index) => Number(indexes[index]) || 0,
|
||||
);
|
||||
const selectRegion = ({ region, trail }) => {
|
||||
selectedRegion.value = region;
|
||||
regionPickerTrail.value = trail;
|
||||
fieldErrors.regionCode = "";
|
||||
regionPickerError.value = "";
|
||||
regionPickerOpen.value = false;
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
loadRegionRoot();
|
||||
void regionPickerDialog.value?.prepare();
|
||||
});
|
||||
|
||||
const requestBack = () =>
|
||||
runBackGuard({
|
||||
transientOpen: regionPickerOpen.value || discardDialogVisible.value,
|
||||
dirty: hasDraft.value,
|
||||
submitting: isSubmitting.value || isUploading.value,
|
||||
"close-transient": () =>
|
||||
regionPickerOpen.value ? closeRegionPicker() : cancelDiscard(),
|
||||
"block-submitting": () => true,
|
||||
"confirm-discard": requestDiscardConfirmation,
|
||||
});
|
||||
createdGenealogyId.value
|
||||
? returnTo("G01")
|
||||
: runBackGuard({
|
||||
transientOpen:
|
||||
regionPickerTransientOpen.value || discardDialogVisible.value,
|
||||
dirty: hasDraft.value,
|
||||
submitting: isSubmitting.value || isUploading.value,
|
||||
"close-transient": () =>
|
||||
regionPickerTransientOpen.value
|
||||
? regionPickerDialog.value?.close()
|
||||
: cancelDiscard(),
|
||||
"block-submitting": () => true,
|
||||
"confirm-discard": requestDiscardConfirmation,
|
||||
});
|
||||
|
||||
onBackPress((event) => handleBackPress(event, requestBack));
|
||||
|
||||
@@ -433,7 +331,7 @@ const uploadCover = async () => {
|
||||
uploadError.value = "";
|
||||
try {
|
||||
const receipt = await pickAndUploadImage({
|
||||
requestController: createController,
|
||||
requestController: coverUploadRequestController,
|
||||
});
|
||||
if (!pageActive) return;
|
||||
coverOssId.value = receipt.ossId;
|
||||
@@ -444,7 +342,7 @@ const uploadCover = async () => {
|
||||
!isImagePickCancelled(error) &&
|
||||
!isRequestCancelled(error)
|
||||
) {
|
||||
uploadError.value = error?.message || "封面图片上传失败,请稍后重试";
|
||||
uploadError.value = getRequestErrorMessage(error, "封面图片上传失败,请稍后重试");
|
||||
}
|
||||
} finally {
|
||||
if (pageActive) isUploading.value = false;
|
||||
@@ -452,43 +350,71 @@ const uploadCover = async () => {
|
||||
};
|
||||
|
||||
const submitCreate = async () => {
|
||||
if (isSubmitting.value || isUploading.value || !validate()) return;
|
||||
const access = toApiGenealogyAccess(form.accessPreset);
|
||||
if (!access) {
|
||||
submitError.value = "访问规则无效,请重新选择";
|
||||
return;
|
||||
if (isSubmitting.value || isUploading.value) return;
|
||||
if (!createdGenealogyId.value && !validate()) return;
|
||||
|
||||
let createPayload = null;
|
||||
let createAttempt = null;
|
||||
if (!createdGenealogyId.value) {
|
||||
const access = toApiGenealogyAccess(form.accessPreset);
|
||||
if (!access) {
|
||||
submitError.value = "请选择家谱开放方式";
|
||||
return;
|
||||
}
|
||||
createPayload = {
|
||||
surname: form.surname,
|
||||
genealogyName: form.genealogyName,
|
||||
regionCode: selectedRegion.value.regionCode,
|
||||
ancestralHall: form.ancestralHall,
|
||||
originPlace: form.originPlace,
|
||||
addressDetail: form.addressDetail,
|
||||
intro: form.intro,
|
||||
coverOssId: coverOssId.value,
|
||||
...access,
|
||||
};
|
||||
createAttempt = genealogyCreateGuard.begin(createPayload);
|
||||
if (createAttempt === null) {
|
||||
submitError.value =
|
||||
"上次创建结果暂时无法确认,请先返回“我的家谱”检查,避免重复创建。";
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
isSubmitting.value = true;
|
||||
submitError.value = "";
|
||||
try {
|
||||
const created = await appApi.createGenealogy(
|
||||
{
|
||||
surname: form.surname,
|
||||
genealogyName: form.genealogyName,
|
||||
regionCode: selectedRegion.value.regionCode,
|
||||
ancestralHall: form.ancestralHall,
|
||||
originPlace: form.originPlace,
|
||||
addressDetail: form.addressDetail,
|
||||
intro: form.intro,
|
||||
coverOssId: coverOssId.value,
|
||||
...access,
|
||||
},
|
||||
{ requestController: createController },
|
||||
);
|
||||
if (!pageActive) return;
|
||||
if (!createdGenealogyId.value) {
|
||||
const created = await genealogyApi.createGenealogy(
|
||||
createPayload,
|
||||
{ requestController: genealogyCreateRequestController },
|
||||
);
|
||||
if (!pageActive) return;
|
||||
createdGenealogyId.value = created.id;
|
||||
}
|
||||
await finishPage(
|
||||
"G01",
|
||||
{},
|
||||
{
|
||||
operation: "genealogy-created",
|
||||
entityId: created.id,
|
||||
entityId: createdGenealogyId.value,
|
||||
refresh: true,
|
||||
},
|
||||
);
|
||||
} catch (error) {
|
||||
if (!pageActive || isRequestCancelled(error)) return;
|
||||
submitError.value = error?.message || "创建失败,请稍后重试";
|
||||
if (!pageActive) return;
|
||||
if (
|
||||
!createdGenealogyId.value &&
|
||||
createAttempt &&
|
||||
genealogyCreateGuard.recordFailure(createAttempt, error)
|
||||
) {
|
||||
submitError.value =
|
||||
"创建结果暂时无法确认,请先返回“我的家谱”检查,避免重复创建。";
|
||||
return;
|
||||
}
|
||||
if (isRequestCancelled(error)) return;
|
||||
submitError.value = createdGenealogyId.value
|
||||
? "家谱已经创建,但页面返回失败。请再次点击“返回我的家谱”,不要重复创建。"
|
||||
: getRequestErrorMessage(error, "创建失败,请稍后重试");
|
||||
} finally {
|
||||
if (pageActive) isSubmitting.value = false;
|
||||
}
|
||||
@@ -496,8 +422,8 @@ const submitCreate = async () => {
|
||||
|
||||
onUnload(() => {
|
||||
pageActive = false;
|
||||
createController.abort();
|
||||
regionController.abort();
|
||||
coverUploadRequestController.abort();
|
||||
genealogyCreateRequestController.abort();
|
||||
discardConfirmation.dispose();
|
||||
});
|
||||
</script>
|
||||
@@ -590,6 +516,13 @@ onUnload(() => {
|
||||
text-align: right;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.region-selector-chevron {
|
||||
width: 28rpx;
|
||||
height: 28rpx;
|
||||
margin-left: 12rpx;
|
||||
flex: 0 0 auto;
|
||||
opacity: 0.7;
|
||||
}
|
||||
.region-selector-value--placeholder {
|
||||
color: #ab9a86;
|
||||
}
|
||||
@@ -644,7 +577,7 @@ onUnload(() => {
|
||||
}
|
||||
.upload-button {
|
||||
justify-self: start;
|
||||
min-height: 60rpx;
|
||||
min-height: 88rpx;
|
||||
margin: 0;
|
||||
padding: 0 20rpx;
|
||||
border: 1rpx solid rgba(159, 23, 15, 0.42);
|
||||
@@ -681,6 +614,11 @@ onUnload(() => {
|
||||
}
|
||||
.access-rule__option {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
min-height: 88rpx;
|
||||
box-sizing: border-box;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 20rpx 12rpx;
|
||||
border: 1rpx solid rgba(128, 89, 49, 0.34);
|
||||
border-radius: 12rpx;
|
||||
@@ -697,108 +635,4 @@ onUnload(() => {
|
||||
.create-card .app-button {
|
||||
margin-top: 32rpx;
|
||||
}
|
||||
.region-sheet {
|
||||
position: fixed;
|
||||
z-index: 10;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
}
|
||||
.region-sheet__mask {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: rgba(43, 30, 20, 0.42);
|
||||
}
|
||||
.region-sheet__panel {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
padding: 22rpx 28rpx calc(24rpx + env(safe-area-inset-bottom));
|
||||
border-radius: 30rpx 30rpx 0 0;
|
||||
background: #fdf9ef;
|
||||
box-shadow: 0 -12rpx 36rpx rgba(43, 30, 20, 0.2);
|
||||
}
|
||||
.region-sheet__intro {
|
||||
padding: 0 10rpx 16rpx;
|
||||
text-align: center;
|
||||
}
|
||||
.region-sheet__title {
|
||||
display: block;
|
||||
}
|
||||
.region-sheet__title {
|
||||
color: $ink;
|
||||
font-family: STKaiti, KaiTi, serif;
|
||||
font-size: clamp(19px, 36rpx, 24px);
|
||||
font-weight: 700;
|
||||
}
|
||||
.region-sheet__picker {
|
||||
overflow: hidden;
|
||||
border: 1rpx solid rgba(128, 89, 49, 0.28);
|
||||
border-radius: 16rpx;
|
||||
background: rgba(255, 252, 245, 0.8);
|
||||
}
|
||||
.region-sheet__column-headings {
|
||||
display: flex;
|
||||
height: 84rpx;
|
||||
border-bottom: 1rpx solid rgba(128, 89, 49, 0.18);
|
||||
}
|
||||
.region-sheet__column-heading {
|
||||
box-sizing: border-box;
|
||||
width: 33.333%;
|
||||
padding: 24rpx 12rpx;
|
||||
color: $ink-muted;
|
||||
font-size: clamp(16px, 26rpx, 20px);
|
||||
text-align: center;
|
||||
}
|
||||
.region-sheet__column-heading + .region-sheet__column-heading {
|
||||
border-left: 1rpx solid rgba(128, 89, 49, 0.16);
|
||||
}
|
||||
.region-sheet__picker-view {
|
||||
width: 100%;
|
||||
height: 520rpx;
|
||||
}
|
||||
.region-sheet__picker-item {
|
||||
box-sizing: border-box;
|
||||
height: 104rpx;
|
||||
overflow: hidden;
|
||||
padding: 0 6rpx;
|
||||
color: $ink-muted;
|
||||
font-size: clamp(16px, 26rpx, 20px);
|
||||
line-height: 104rpx;
|
||||
text-align: center;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.region-sheet__picker-item--selected {
|
||||
color: $brand-red;
|
||||
font-weight: 700;
|
||||
}
|
||||
.region-sheet__footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-top: 22rpx;
|
||||
gap: 12rpx;
|
||||
}
|
||||
.region-sheet__cancel {
|
||||
min-width: 116rpx;
|
||||
padding: 20rpx 10rpx;
|
||||
color: $ink-muted;
|
||||
font-size: clamp(16px, 28rpx, 20px);
|
||||
text-align: center;
|
||||
}
|
||||
.region-sheet__confirm {
|
||||
flex: 1;
|
||||
height: 82rpx;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-radius: 10rpx;
|
||||
background: $brand-red;
|
||||
color: #fff;
|
||||
font-size: clamp(16px, 29rpx, 20px);
|
||||
font-weight: 700;
|
||||
line-height: 82rpx;
|
||||
}
|
||||
.region-sheet__confirm::after {
|
||||
display: none;
|
||||
}
|
||||
</style>
|
||||
@@ -1,232 +0,0 @@
|
||||
<template>
|
||||
<view class="search-page">
|
||||
<GenealogyPageBackground />
|
||||
<view class="page-header"
|
||||
><PageHeader title="搜索家谱" custom-back @back="backToGenealogies"
|
||||
/></view>
|
||||
<view class="page-content">
|
||||
<view class="search-note"
|
||||
><text>公开家谱</text
|
||||
><text>以下结果来自服务端公开家谱列表。</text></view
|
||||
>
|
||||
<view v-if="state === 'loading'" class="state-card"
|
||||
><AppLoading text="正在读取公开家谱"
|
||||
/></view>
|
||||
<view v-else-if="state === 'error'" class="state-card"
|
||||
><text>暂时无法读取公开家谱</text
|
||||
><AppButton
|
||||
block
|
||||
type="secondary"
|
||||
label="重新加载"
|
||||
@click="loadGenealogies"
|
||||
/></view>
|
||||
<view v-else-if="state === 'empty'" class="state-card"
|
||||
><text>暂未找到公开家谱</text></view
|
||||
>
|
||||
<view v-else class="result-list">
|
||||
<view v-for="item in rows" :key="item.id" class="genealogy-card">
|
||||
<view class="card-heading"
|
||||
><text>{{ item.name }}</text
|
||||
><text v-if="item.surname">{{ item.surname }}氏</text></view
|
||||
>
|
||||
<text v-if="item.location" class="card-meta">{{
|
||||
item.location
|
||||
}}</text>
|
||||
<text v-if="item.intro" class="card-copy">{{ item.intro }}</text>
|
||||
<view class="card-footer"
|
||||
><text>{{ item.memberCount }} 位成员</text
|
||||
><AppButton
|
||||
:label="item.canManage ? '已在我的家谱' : '申请加入'"
|
||||
:disabled="item.canManage"
|
||||
@click="applyToJoin(item)"
|
||||
/></view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from "vue";
|
||||
import { onLoad, onShow, onUnload } from "@dcloudio/uni-app";
|
||||
import AppButton from "@/components/AppButton.vue";
|
||||
import AppLoading from "@/components/AppLoading.vue";
|
||||
import GenealogyPageBackground from "@/components/GenealogyPageBackground.vue";
|
||||
import PageHeader from "@/components/PageHeader.vue";
|
||||
import {
|
||||
appApi,
|
||||
createRequestController,
|
||||
isRequestCancelled,
|
||||
} from "@/utils/api.js";
|
||||
import { openPage, returnTo } from "@/utils/navigation.js";
|
||||
|
||||
const rows = ref([]);
|
||||
const state = ref("loading");
|
||||
const controller = createRequestController();
|
||||
let active = true;
|
||||
const loadGenealogies = async () => {
|
||||
controller.abort();
|
||||
state.value = "loading";
|
||||
try {
|
||||
const result = await appApi.getPublicGenealogies({
|
||||
requestController: controller,
|
||||
});
|
||||
if (!active) return;
|
||||
rows.value = result
|
||||
.map((item) => ({
|
||||
id: String(item.genealogyId),
|
||||
name: String(item.genealogyName || "未命名家谱"),
|
||||
surname: String(item.surname || ""),
|
||||
location: String(
|
||||
item.regionFullName ||
|
||||
item.regionName ||
|
||||
item.originPlace ||
|
||||
item.addressDetail ||
|
||||
"",
|
||||
),
|
||||
intro: String(item.intro || ""),
|
||||
memberCount: Number.isSafeInteger(item.memberCount)
|
||||
? item.memberCount
|
||||
: 0,
|
||||
canManage: item.canManage === true,
|
||||
}))
|
||||
.filter((item) => /^[1-9]\d*$/.test(item.id));
|
||||
state.value = rows.value.length ? "ready" : "empty";
|
||||
} catch (error) {
|
||||
if (!active || isRequestCancelled(error)) return;
|
||||
state.value = "error";
|
||||
}
|
||||
};
|
||||
const applyToJoin = (item) =>
|
||||
openPage("G08", { genealogyId: item.id, genealogyName: item.name }, "G06");
|
||||
const backToGenealogies = () => returnTo("G01");
|
||||
onLoad(loadGenealogies);
|
||||
onShow(() => {
|
||||
if (state.value !== "loading") loadGenealogies();
|
||||
});
|
||||
onUnload(() => {
|
||||
active = false;
|
||||
controller.abort();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@import "../../styles/adaptive-frame-profiles.scss";
|
||||
.search-page {
|
||||
display: flex;
|
||||
min-height: 100vh;
|
||||
flex-direction: column;
|
||||
background: $paper;
|
||||
}
|
||||
.page-header,
|
||||
.page-content {
|
||||
z-index: 1;
|
||||
}
|
||||
.page-content {
|
||||
padding: 18rpx 24rpx 72rpx;
|
||||
}
|
||||
.search-note,
|
||||
.state-card,
|
||||
.genealogy-card {
|
||||
@include adaptive-genealogy-state-panel;
|
||||
}
|
||||
.search-note {
|
||||
display: flex;
|
||||
min-height: 112rpx;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
padding: 20rpx 30rpx;
|
||||
}
|
||||
.search-note text:first-child {
|
||||
color: $ink;
|
||||
font-family: STKaiti, KaiTi, serif;
|
||||
font-size: clamp(17px, 30rpx, 22px);
|
||||
font-weight: 700;
|
||||
}
|
||||
.search-note text:last-child {
|
||||
margin-top: 6rpx;
|
||||
color: $ink-muted;
|
||||
font-size: clamp(13px, 21rpx, 16px);
|
||||
}
|
||||
.state-card {
|
||||
display: flex;
|
||||
min-height: 310rpx;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-top: 18rpx;
|
||||
padding: 42rpx;
|
||||
text-align: center;
|
||||
color: $ink;
|
||||
font-size: clamp(16px, 28rpx, 20px);
|
||||
}
|
||||
.state-card .app-button {
|
||||
width: 100%;
|
||||
margin-top: 22rpx;
|
||||
}
|
||||
.result-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16rpx;
|
||||
margin-top: 18rpx;
|
||||
}
|
||||
.genealogy-card {
|
||||
padding: 28rpx 32rpx;
|
||||
}
|
||||
.card-heading {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 18rpx;
|
||||
}
|
||||
.card-heading text:first-child {
|
||||
color: $ink;
|
||||
font-family: STKaiti, KaiTi, serif;
|
||||
font-size: clamp(17px, 31rpx, 22px);
|
||||
font-weight: 700;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.card-heading text:last-child {
|
||||
flex: 0 0 auto;
|
||||
color: $brand-red;
|
||||
font-size: clamp(14px, 22rpx, 17px);
|
||||
}
|
||||
.card-meta,
|
||||
.card-copy {
|
||||
display: block;
|
||||
color: $ink-muted;
|
||||
font-size: clamp(14px, 22rpx, 17px);
|
||||
line-height: 1.5;
|
||||
}
|
||||
.card-meta {
|
||||
margin-top: 10rpx;
|
||||
}
|
||||
.card-copy {
|
||||
margin-top: 8rpx;
|
||||
}
|
||||
.card-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 18rpx;
|
||||
margin-top: 20rpx;
|
||||
color: $ink-muted;
|
||||
font-size: clamp(13px, 21rpx, 16px);
|
||||
}
|
||||
.card-footer .app-button {
|
||||
min-width: 180rpx;
|
||||
}
|
||||
@media (max-width: 340px) {
|
||||
.page-content {
|
||||
padding-right: 20rpx;
|
||||
padding-left: 20rpx;
|
||||
}
|
||||
.card-footer {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
}
|
||||
.card-footer .app-button {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,116 +0,0 @@
|
||||
<!-- 页面编号:G-09;用途:读取当前账号的加入申请。 -->
|
||||
<template>
|
||||
<view class="applications-page">
|
||||
<GenealogyPageBackground />
|
||||
<view class="page-header"
|
||||
><PageHeader title="我的申请" custom-back @back="returnToGenealogies"
|
||||
/></view>
|
||||
<view class="page-content">
|
||||
<view v-if="state === 'loading'" class="state-card"
|
||||
><AppLoading text="正在读取我的申请"
|
||||
/></view>
|
||||
<view v-else-if="state === 'error'" class="state-card"
|
||||
><text>暂时无法读取我的申请</text
|
||||
><AppButton
|
||||
block
|
||||
type="secondary"
|
||||
label="重新加载"
|
||||
@click="loadApplications"
|
||||
/></view>
|
||||
<view v-else-if="state === 'empty'" class="state-card"
|
||||
><text>暂无加入申请</text><text>申请记录会直接从服务端读取。</text
|
||||
><AppButton block label="返回我的家谱" @click="returnToGenealogies"
|
||||
/></view>
|
||||
<view v-else class="state-card"
|
||||
><text>已读取 {{ applications.length }} 条申请</text
|
||||
><text>申请详情接口尚未开放,当前不会用本地数据补造申请内容。</text
|
||||
><AppButton block label="返回我的家谱" @click="returnToGenealogies"
|
||||
/></view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from "vue";
|
||||
import { onLoad, onShow, onUnload } from "@dcloudio/uni-app";
|
||||
import AppButton from "@/components/AppButton.vue";
|
||||
import AppLoading from "@/components/AppLoading.vue";
|
||||
import GenealogyPageBackground from "@/components/GenealogyPageBackground.vue";
|
||||
import PageHeader from "@/components/PageHeader.vue";
|
||||
import {
|
||||
appApi,
|
||||
createRequestController,
|
||||
isRequestCancelled,
|
||||
} from "@/utils/api.js";
|
||||
import { returnTo } from "@/utils/navigation.js";
|
||||
|
||||
const applications = ref([]);
|
||||
const state = ref("loading");
|
||||
const controller = createRequestController();
|
||||
let active = true;
|
||||
const loadApplications = async () => {
|
||||
controller.abort();
|
||||
state.value = "loading";
|
||||
try {
|
||||
applications.value = await appApi.getMyJoinApplications({
|
||||
requestController: controller,
|
||||
});
|
||||
if (!active) return;
|
||||
state.value = applications.value.length ? "ready" : "empty";
|
||||
} catch (error) {
|
||||
if (!active || isRequestCancelled(error)) return;
|
||||
state.value = "error";
|
||||
}
|
||||
};
|
||||
const returnToGenealogies = () => returnTo("G01");
|
||||
onLoad(loadApplications);
|
||||
onShow(() => {
|
||||
if (state.value !== "loading") loadApplications();
|
||||
});
|
||||
onUnload(() => {
|
||||
active = false;
|
||||
controller.abort();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@import "../../styles/adaptive-frame-profiles.scss";
|
||||
.applications-page {
|
||||
display: flex;
|
||||
min-height: 100vh;
|
||||
flex-direction: column;
|
||||
background: $paper;
|
||||
}
|
||||
.page-header,
|
||||
.page-content {
|
||||
z-index: 1;
|
||||
}
|
||||
.page-content {
|
||||
padding: 18rpx 24rpx 72rpx;
|
||||
}
|
||||
.state-card {
|
||||
box-sizing: border-box;
|
||||
min-height: 340rpx;
|
||||
padding: 78rpx 52rpx 50rpx;
|
||||
text-align: center;
|
||||
@include adaptive-genealogy-state-panel;
|
||||
}
|
||||
.state-card text {
|
||||
display: block;
|
||||
}
|
||||
.state-card text:first-child {
|
||||
color: $ink;
|
||||
font-family: STKaiti, KaiTi, serif;
|
||||
font-size: clamp(19px, 35rpx, 24px);
|
||||
font-weight: 700;
|
||||
}
|
||||
.state-card text:nth-child(2) {
|
||||
margin-top: 18rpx;
|
||||
color: $ink-muted;
|
||||
font-size: clamp(15px, 24rpx, 18px);
|
||||
line-height: 1.65;
|
||||
}
|
||||
.state-card .app-button {
|
||||
margin-top: 28rpx;
|
||||
}
|
||||
</style>
|
||||
@@ -1,128 +0,0 @@
|
||||
<!-- 页面编号:G-10;用途:读取当前家谱待审核的加入申请。 -->
|
||||
<template>
|
||||
<view class="review-page">
|
||||
<GenealogyPageBackground />
|
||||
<view class="page-header"
|
||||
><PageHeader title="申请审核" custom-back @back="returnToGenealogies"
|
||||
/></view>
|
||||
<view class="page-content">
|
||||
<view v-if="!valid" class="state-card"
|
||||
><text>申请审核入口无效</text
|
||||
><AppButton block label="返回我的家谱" @click="returnToGenealogies"
|
||||
/></view>
|
||||
<view v-else-if="state === 'loading'" class="state-card"
|
||||
><AppLoading text="正在读取待审核申请"
|
||||
/></view>
|
||||
<view v-else-if="state === 'error'" class="state-card"
|
||||
><text>暂时无法读取待审核申请</text
|
||||
><AppButton
|
||||
block
|
||||
type="secondary"
|
||||
label="重新加载"
|
||||
@click="loadApplications"
|
||||
/></view>
|
||||
<view v-else-if="state === 'empty'" class="state-card"
|
||||
><text>暂无待审核申请</text><text>待审核记录会直接从服务端读取。</text
|
||||
><AppButton block label="返回我的家谱" @click="returnToGenealogies"
|
||||
/></view>
|
||||
<view v-else class="state-card"
|
||||
><text>已读取 {{ applications.length }} 条待审核申请</text
|
||||
><text
|
||||
>审核详情接口尚未开放,当前不会用本地数据补造申请内容或审核结果。</text
|
||||
><AppButton block label="返回我的家谱" @click="returnToGenealogies"
|
||||
/></view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref } from "vue";
|
||||
import { onLoad, onShow, onUnload } from "@dcloudio/uni-app";
|
||||
import AppButton from "@/components/AppButton.vue";
|
||||
import AppLoading from "@/components/AppLoading.vue";
|
||||
import GenealogyPageBackground from "@/components/GenealogyPageBackground.vue";
|
||||
import PageHeader from "@/components/PageHeader.vue";
|
||||
import {
|
||||
appApi,
|
||||
createRequestController,
|
||||
isRequestCancelled,
|
||||
} from "@/utils/api.js";
|
||||
import { returnTo } from "@/utils/navigation.js";
|
||||
|
||||
const genealogyId = ref("");
|
||||
const applications = ref([]);
|
||||
const state = ref("loading");
|
||||
const controller = createRequestController();
|
||||
let active = true;
|
||||
const valid = computed(() => /^[1-9]\d*$/.test(genealogyId.value));
|
||||
const loadApplications = async () => {
|
||||
if (!valid.value) return;
|
||||
controller.abort();
|
||||
state.value = "loading";
|
||||
try {
|
||||
applications.value = await appApi.getPendingApplications(
|
||||
genealogyId.value,
|
||||
{ requestController: controller },
|
||||
);
|
||||
if (!active) return;
|
||||
state.value = applications.value.length ? "ready" : "empty";
|
||||
} catch (error) {
|
||||
if (!active || isRequestCancelled(error)) return;
|
||||
state.value = "error";
|
||||
}
|
||||
};
|
||||
const returnToGenealogies = () => returnTo("G01");
|
||||
onLoad((query) => {
|
||||
genealogyId.value = String(query?.genealogyId || "");
|
||||
if (valid.value) loadApplications();
|
||||
});
|
||||
onShow(() => {
|
||||
if (valid.value && state.value !== "loading") loadApplications();
|
||||
});
|
||||
onUnload(() => {
|
||||
active = false;
|
||||
controller.abort();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@import "../../styles/adaptive-frame-profiles.scss";
|
||||
.review-page {
|
||||
display: flex;
|
||||
min-height: 100vh;
|
||||
flex-direction: column;
|
||||
background: $paper;
|
||||
}
|
||||
.page-header,
|
||||
.page-content {
|
||||
z-index: 1;
|
||||
}
|
||||
.page-content {
|
||||
padding: 18rpx 24rpx 72rpx;
|
||||
}
|
||||
.state-card {
|
||||
box-sizing: border-box;
|
||||
min-height: 340rpx;
|
||||
padding: 78rpx 52rpx 50rpx;
|
||||
text-align: center;
|
||||
@include adaptive-genealogy-state-panel;
|
||||
}
|
||||
.state-card text {
|
||||
display: block;
|
||||
}
|
||||
.state-card text:first-child {
|
||||
color: $ink;
|
||||
font-family: STKaiti, KaiTi, serif;
|
||||
font-size: clamp(19px, 35rpx, 24px);
|
||||
font-weight: 700;
|
||||
}
|
||||
.state-card text:nth-child(2) {
|
||||
margin-top: 18rpx;
|
||||
color: $ink-muted;
|
||||
font-size: clamp(15px, 24rpx, 18px);
|
||||
line-height: 1.65;
|
||||
}
|
||||
.state-card .app-button {
|
||||
margin-top: 28rpx;
|
||||
}
|
||||
</style>
|
||||
@@ -1,4 +1,3 @@
|
||||
<!-- 页面编号:G-12;用途:按 Apifox GenerationPoemView 读取字辈,并以服务端预览维护。 -->
|
||||
<template>
|
||||
<view class="poem-page">
|
||||
<GenealogyPageBackground />
|
||||
@@ -45,29 +44,37 @@
|
||||
<view
|
||||
v-if="remainingPoemCount > 0"
|
||||
class="poem-load-more"
|
||||
role="button"
|
||||
:aria-label="'继续加载后续字辈,剩余 ' + remainingPoemCount + ' 代'"
|
||||
@click="loadMorePoems"
|
||||
>
|
||||
<text>继续加载后续字辈(剩余 {{ remainingPoemCount }} 代)</text>
|
||||
</view>
|
||||
<view class="poem-action" @click="enterManagement">
|
||||
<view
|
||||
class="poem-action"
|
||||
role="button"
|
||||
:aria-label="canManage ? '继续维护字辈诗' : '查看字辈列表'"
|
||||
@click="enterManagement"
|
||||
>
|
||||
<image
|
||||
src="/static/assets/foundation/transparent/a01-scroll-primary-v3.png"
|
||||
src="/static/assets/foundation/transparent/scroll-primary.png"
|
||||
mode="aspectFit"
|
||||
/><text>{{ canManage ? "继续维护字辈诗" : "读取维护列表" }}</text>
|
||||
/><text>{{ canManage ? "继续维护字辈诗" : "查看字辈列表" }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view v-else-if="poemState === 'edit'" class="poem-editor">
|
||||
<text class="poem-list__eyebrow">服务端批量维护</text>
|
||||
<text class="poem-list__title">录入完整字辈序列</text>
|
||||
<text class="poem-list__eyebrow">统一维护</text>
|
||||
<text class="poem-list__title">填写完整字辈</text>
|
||||
<text class="poem-list__copy"
|
||||
>提交前会先请求服务端批量预览。无分隔符时每个字符对应一代;也可用空格、逗号、分号、顿号、斜杠或竖线分隔多字字辈。</text
|
||||
>保存前可先查看调整结果。没有分隔符时,每个字对应一代;多个字可用空格、逗号、分号、顿号、斜杠或竖线分开。</text
|
||||
>
|
||||
<view class="poem-field">
|
||||
<text>字辈内容</text>
|
||||
<textarea
|
||||
v-model="poemDraft"
|
||||
auto-height
|
||||
:disabled="saving"
|
||||
:maxlength="MAX_GENERATION_POEM_INPUT_LENGTH"
|
||||
placeholder="例如:启宗敦本,或 克勤 克俭 承先 启后"
|
||||
placeholder-class="poem-placeholder"
|
||||
@@ -76,13 +83,21 @@
|
||||
</view>
|
||||
<text v-if="poemError" class="poem-field-error">{{ poemError }}</text>
|
||||
<view class="poem-policy">
|
||||
<text>未被新文本覆盖的后续世代</text>
|
||||
<view class="poem-policy__option" @click="toggleDisableMissing">
|
||||
<text>这次未填写到的后续字辈</text>
|
||||
<view
|
||||
class="poem-policy__option"
|
||||
:class="{ 'poem-policy__option--disabled': saving }"
|
||||
role="checkbox"
|
||||
:aria-checked="disableMissing"
|
||||
:aria-disabled="saving"
|
||||
aria-label="停用未填写到的后续字辈"
|
||||
@click="toggleDisableMissing"
|
||||
>
|
||||
<image
|
||||
:src="
|
||||
disableMissing
|
||||
? '/static/assets/foundation/transparent/a01-scroll-primary-v3.png'
|
||||
: '/static/assets/foundation/transparent/a01-scroll-secondary-v3.png'
|
||||
? '/static/assets/foundation/transparent/scroll-primary.png'
|
||||
: '/static/assets/foundation/transparent/scroll-secondary.png'
|
||||
"
|
||||
mode="aspectFit"
|
||||
/>
|
||||
@@ -94,31 +109,44 @@
|
||||
</view>
|
||||
<text class="poem-preview">{{ previewSummary }}</text>
|
||||
<view class="poem-editor__actions">
|
||||
<view class="poem-action" @click="requestLeaveEditor">
|
||||
<view
|
||||
class="poem-action"
|
||||
:class="{ 'poem-action--disabled': previewing || saving }"
|
||||
role="button"
|
||||
:aria-disabled="previewing || saving"
|
||||
aria-label="取消"
|
||||
@click="requestLeaveEditor"
|
||||
>
|
||||
<image
|
||||
src="/static/assets/foundation/transparent/a01-scroll-secondary-v3.png"
|
||||
src="/static/assets/foundation/transparent/scroll-secondary.png"
|
||||
mode="aspectFit"
|
||||
/><text class="poem-action__secondary">取消</text>
|
||||
</view>
|
||||
<view
|
||||
class="poem-action"
|
||||
:class="{ 'poem-action--disabled': previewing || saving }"
|
||||
role="button"
|
||||
:aria-disabled="previewing || saving"
|
||||
aria-label="查看调整结果"
|
||||
@click="previewPoems"
|
||||
>
|
||||
<image
|
||||
src="/static/assets/foundation/transparent/a01-scroll-secondary-v3.png"
|
||||
src="/static/assets/foundation/transparent/scroll-secondary.png"
|
||||
mode="aspectFit"
|
||||
/><text class="poem-action__secondary">{{
|
||||
previewing ? "正在预览" : "服务端预览"
|
||||
previewing ? "正在查看" : "查看调整结果"
|
||||
}}</text>
|
||||
</view>
|
||||
<view
|
||||
class="poem-action"
|
||||
:class="{ 'poem-action--disabled': !canSave || saving }"
|
||||
role="button"
|
||||
:aria-disabled="!canSave || saving"
|
||||
aria-label="保存字辈"
|
||||
@click="savePoems"
|
||||
>
|
||||
<image
|
||||
src="/static/assets/foundation/transparent/a01-scroll-primary-v3.png"
|
||||
src="/static/assets/foundation/transparent/scroll-primary.png"
|
||||
mode="aspectFit"
|
||||
/><text>{{ saving ? "正在保存" : "保存字辈" }}</text>
|
||||
</view>
|
||||
@@ -131,16 +159,21 @@
|
||||
}}</text>
|
||||
<text class="poem-list__title">{{
|
||||
poemState === "empty"
|
||||
? "当前读取没有正常状态字辈"
|
||||
? "暂时没有可显示的字辈"
|
||||
: "暂时无法读取字辈诗"
|
||||
}}</text>
|
||||
<text class="poem-list__copy">{{ stateCopy }}</text>
|
||||
<view class="poem-action" @click="handleStateAction">
|
||||
<view
|
||||
class="poem-action"
|
||||
role="button"
|
||||
:aria-label="poemState === 'empty' ? '查看字辈列表' : '重新读取'"
|
||||
@click="handleStateAction"
|
||||
>
|
||||
<image
|
||||
src="/static/assets/foundation/transparent/a01-scroll-primary-v3.png"
|
||||
src="/static/assets/foundation/transparent/scroll-primary.png"
|
||||
mode="aspectFit"
|
||||
/><text>{{
|
||||
poemState === "empty" ? "读取维护列表" : "重新读取"
|
||||
poemState === "empty" ? "查看字辈列表" : "重新读取"
|
||||
}}</text>
|
||||
</view>
|
||||
</view>
|
||||
@@ -148,7 +181,7 @@
|
||||
<AppDialog
|
||||
:visible="discardVisible"
|
||||
title="放弃字辈修改?"
|
||||
message="当前草稿尚未保存,确认后不会提交服务端。"
|
||||
message="当前修改还没有保存,确认返回后将不会保留。"
|
||||
confirm-text="放弃修改"
|
||||
cancel-text="继续编辑"
|
||||
show-cancel
|
||||
@@ -168,20 +201,21 @@ import { computed, ref } from "vue";
|
||||
import { onBackPress, onLoad, onUnload } from "@dcloudio/uni-app";
|
||||
import AppDialog from "@/components/AppDialog.vue";
|
||||
import AppLoading from "@/components/AppLoading.vue";
|
||||
import GenealogyPageBackground from "@/components/GenealogyPageBackground.vue";
|
||||
import GenealogyPageBackground from "@/components/genealogy/PageBackground.vue";
|
||||
import PageHeader from "@/components/PageHeader.vue";
|
||||
import { createDiscardConfirmation } from "@/utils/discard-confirmation.js";
|
||||
import {
|
||||
appApi,
|
||||
createRequestController,
|
||||
isRequestCancelled,
|
||||
} from "@/utils/api.js";
|
||||
isRequestCancelled
|
||||
} from "@/services/api/request-controller.js";
|
||||
import { generationPoemApi } from "@/services/api/generation-poem-service.js";
|
||||
import { getRequestErrorMessage } from "@/services/api/request-error-message.js";
|
||||
import {
|
||||
MAX_GENERATION_POEM_INPUT_LENGTH,
|
||||
GENERATION_POEM_STATUS,
|
||||
validateGenerationPoemText,
|
||||
} from "@/utils/generation-poem.js";
|
||||
import { handleBackPress, runBackGuard } from "@/utils/navigation.js";
|
||||
} from "@/utils/genealogy/generation-poem.js";
|
||||
import { handleBackPress, runBackGuard } from "@/utils/navigation/gateway.js";
|
||||
|
||||
const POEM_RENDER_BATCH_SIZE = 50;
|
||||
const genealogyId = ref("");
|
||||
@@ -200,8 +234,11 @@ const poemRows = ref([]);
|
||||
const visiblePoemCount = ref(POEM_RENDER_BATCH_SIZE);
|
||||
const preview = ref(null);
|
||||
const previewSignature = ref("");
|
||||
const requestController = createRequestController();
|
||||
const poemListController = createRequestController();
|
||||
const poemPreviewController = createRequestController();
|
||||
const poemSaveController = createRequestController();
|
||||
let requestSequence = 0;
|
||||
let pageActive = true;
|
||||
|
||||
const visiblePoemRows = computed(() =>
|
||||
poemRows.value.slice(0, visiblePoemCount.value),
|
||||
@@ -226,9 +263,9 @@ const canSave = computed(
|
||||
Boolean(preview.value) && previewSignature.value === draftSignature.value,
|
||||
);
|
||||
const previewSummary = computed(() => {
|
||||
if (previewing.value) return "正在请求服务端预览。";
|
||||
if (!preview.value) return "尚未请求服务端预览;预览成功后才可保存。";
|
||||
return `服务端预览:新增 ${preview.value.createCount} 条,更新 ${preview.value.updateCount} 条,保留 ${preview.value.keepCount} 条,停用 ${preview.value.disableCount} 条。`;
|
||||
if (previewing.value) return "正在查看调整结果。";
|
||||
if (!preview.value) return "请先查看调整结果,再保存。";
|
||||
return `调整结果:新增 ${preview.value.createCount} 条,更新 ${preview.value.updateCount} 条,保留 ${preview.value.keepCount} 条,停用 ${preview.value.disableCount} 条。`;
|
||||
});
|
||||
const stateCopy = computed(() =>
|
||||
poemState.value === "empty"
|
||||
@@ -266,19 +303,19 @@ const loadPoems = async ({ management = false } = {}) => {
|
||||
poemError.value = "";
|
||||
try {
|
||||
const rows = management
|
||||
? await appApi.getGenerationPoemManagement(genealogyId.value, {
|
||||
requestController,
|
||||
? await generationPoemApi.getGenerationPoemManagement(genealogyId.value, {
|
||||
requestController: poemListController,
|
||||
})
|
||||
: await appApi.getGenerationPoems(genealogyId.value, {
|
||||
requestController,
|
||||
: await generationPoemApi.getGenerationPoems(genealogyId.value, {
|
||||
requestController: poemListController,
|
||||
});
|
||||
if (sequence !== requestSequence) return false;
|
||||
if (!pageActive || sequence !== requestSequence) return false;
|
||||
applyRows(rows);
|
||||
canManage.value = management;
|
||||
poemState.value = rows.length ? "list" : "empty";
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (isRequestCancelled(error) || sequence !== requestSequence) return false;
|
||||
if (!pageActive || isRequestCancelled(error) || sequence !== requestSequence) return false;
|
||||
poemState.value = "error";
|
||||
return false;
|
||||
}
|
||||
@@ -295,6 +332,7 @@ const invalidatePreview = () => {
|
||||
previewSignature.value = "";
|
||||
};
|
||||
const toggleDisableMissing = () => {
|
||||
if (saving.value) return;
|
||||
disableMissing.value = !disableMissing.value;
|
||||
invalidatePreview();
|
||||
};
|
||||
@@ -317,9 +355,10 @@ const openEditor = () => {
|
||||
const enterManagement = async () => {
|
||||
if (previewing.value || saving.value) return;
|
||||
const loaded = await loadPoems({ management: true });
|
||||
if (loaded) openEditor();
|
||||
if (pageActive && loaded) openEditor();
|
||||
};
|
||||
const requestLeaveEditor = async () => {
|
||||
if (previewing.value || saving.value) return false;
|
||||
if (isDirty.value) {
|
||||
const confirmed = await requestDiscardConfirmation();
|
||||
if (!confirmed) return false;
|
||||
@@ -339,23 +378,27 @@ const validateDraft = () => {
|
||||
};
|
||||
const previewPoems = async () => {
|
||||
if (previewing.value || saving.value || !validateDraft()) return;
|
||||
const submittedDraft = {
|
||||
poemText: poemDraft.value,
|
||||
disableMissing: disableMissing.value,
|
||||
};
|
||||
const submittedSignature = draftSignature.value;
|
||||
previewing.value = true;
|
||||
poemError.value = "";
|
||||
try {
|
||||
preview.value = await appApi.previewGenerationPoemBatch(
|
||||
const previewResult = await generationPoemApi.previewGenerationPoemBatch(
|
||||
genealogyId.value,
|
||||
{
|
||||
poemText: poemDraft.value,
|
||||
disableMissing: disableMissing.value,
|
||||
},
|
||||
{ requestController },
|
||||
submittedDraft,
|
||||
{ requestController: poemPreviewController },
|
||||
);
|
||||
previewSignature.value = draftSignature.value;
|
||||
if (!pageActive || submittedSignature !== draftSignature.value) return;
|
||||
preview.value = previewResult;
|
||||
previewSignature.value = submittedSignature;
|
||||
} catch (error) {
|
||||
if (!isRequestCancelled(error))
|
||||
poemError.value = error?.message || "服务端预览失败,请稍后重试。";
|
||||
if (pageActive && !isRequestCancelled(error))
|
||||
poemError.value = getRequestErrorMessage(error, "暂时无法查看调整结果,请稍后重试。");
|
||||
} finally {
|
||||
previewing.value = false;
|
||||
if (pageActive) previewing.value = false;
|
||||
}
|
||||
};
|
||||
const savePoems = async () => {
|
||||
@@ -363,27 +406,29 @@ const savePoems = async () => {
|
||||
saving.value = true;
|
||||
poemError.value = "";
|
||||
try {
|
||||
await appApi.saveGenerationPoemBatch(
|
||||
await generationPoemApi.saveGenerationPoemBatch(
|
||||
genealogyId.value,
|
||||
{
|
||||
poemText: poemDraft.value,
|
||||
disableMissing: disableMissing.value,
|
||||
},
|
||||
{ requestController },
|
||||
{ requestController: poemSaveController },
|
||||
);
|
||||
feedbackMessage.value = "服务端字辈已保存,正在刷新维护列表。";
|
||||
if (!pageActive) return;
|
||||
feedbackMessage.value = "字辈已保存,正在刷新列表。";
|
||||
const loaded = await loadPoems({ management: true });
|
||||
if (!pageActive) return;
|
||||
if (!loaded) {
|
||||
poemError.value = "字辈已提交,但维护列表刷新失败;请稍后重新查看。";
|
||||
poemError.value = "字辈已保存,但页面暂时未更新,请稍后重新查看。";
|
||||
return;
|
||||
}
|
||||
editorSnapshot.value = null;
|
||||
feedbackMessage.value = "服务端字辈已保存并已刷新。";
|
||||
feedbackMessage.value = "字辈已保存。";
|
||||
} catch (error) {
|
||||
if (!isRequestCancelled(error))
|
||||
poemError.value = error?.message || "字辈保存失败,请稍后重试。";
|
||||
if (pageActive && !isRequestCancelled(error))
|
||||
poemError.value = getRequestErrorMessage(error, "字辈保存失败,请稍后重试。");
|
||||
} finally {
|
||||
saving.value = false;
|
||||
if (pageActive) saving.value = false;
|
||||
}
|
||||
};
|
||||
const requestBack = () =>
|
||||
@@ -404,8 +449,11 @@ onLoad((query) => {
|
||||
});
|
||||
onBackPress((event) => handleBackPress(event, requestBack));
|
||||
onUnload(() => {
|
||||
pageActive = false;
|
||||
++requestSequence;
|
||||
requestController.abort();
|
||||
poemListController.abort();
|
||||
poemPreviewController.abort();
|
||||
poemSaveController.abort();
|
||||
discardConfirmation.dispose();
|
||||
});
|
||||
</script>
|
||||
@@ -463,11 +511,11 @@ onUnload(() => {
|
||||
}
|
||||
.poem-load-more {
|
||||
display: flex;
|
||||
min-height: 64rpx;
|
||||
min-height: 88rpx;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-top: 14rpx;
|
||||
background: url("/static/assets/foundation/transparent/a01-scroll-secondary-v3.png")
|
||||
background: url("/static/assets/foundation/transparent/scroll-secondary.png")
|
||||
center / contain no-repeat;
|
||||
}
|
||||
.poem-load-more text {
|
||||
@@ -518,7 +566,7 @@ onUnload(() => {
|
||||
.poem-action {
|
||||
display: grid;
|
||||
width: 100%;
|
||||
min-height: 76rpx;
|
||||
min-height: 88rpx;
|
||||
margin-top: 20rpx;
|
||||
}
|
||||
.poem-action--disabled {
|
||||
@@ -595,7 +643,10 @@ onUnload(() => {
|
||||
.poem-policy__option {
|
||||
display: grid;
|
||||
width: 248rpx;
|
||||
min-height: 62rpx;
|
||||
min-height: 88rpx;
|
||||
}
|
||||
.poem-policy__option--disabled {
|
||||
opacity: 0.55;
|
||||
}
|
||||
.poem-policy__option image {
|
||||
grid-area: 1 / 1;
|
||||
@@ -662,7 +713,7 @@ onUnload(() => {
|
||||
justify-content: center;
|
||||
padding: 0 34rpx;
|
||||
transform: translateX(-50%);
|
||||
background: url("/static/assets/foundation/transparent/a01-scroll-secondary-v3.png")
|
||||
background: url("/static/assets/foundation/transparent/scroll-secondary.png")
|
||||
center / contain no-repeat;
|
||||
}
|
||||
.poem-feedback text {
|
||||
@@ -1,4 +1,3 @@
|
||||
<!-- 页面编号:G-08;用途:按 APP GenealogyJoinApplyBody 提交加入申请。 -->
|
||||
<template>
|
||||
<view class="join-page">
|
||||
<GenealogyPageBackground />
|
||||
@@ -7,14 +6,14 @@
|
||||
/></view>
|
||||
<view class="page-content">
|
||||
<view v-if="!hasValidContext" class="state-card">
|
||||
<text>申请入口无效</text>
|
||||
<text>没有取得可申请的家谱标识。</text>
|
||||
<text>暂时无法提交申请</text>
|
||||
<text>未找到可申请的家谱,请返回后重新进入。</text>
|
||||
<AppButton block label="返回搜索家谱" @click="backToSearch" />
|
||||
</view>
|
||||
<view v-else-if="state === 'success'" class="state-card">
|
||||
<view v-else-if="joinApplicationState === 'success'" class="state-card">
|
||||
<text>申请已提交</text>
|
||||
<text>服务端已确认本次加入申请,可返回继续查看公开家谱。</text>
|
||||
<AppButton block label="返回搜索家谱" @click="backToSearch" />
|
||||
<text>申请已提交,可在“我的申请”中查看审核进度。</text>
|
||||
<AppButton block label="查看我的申请" @click="openMyApplications" />
|
||||
</view>
|
||||
<view v-else class="join-form">
|
||||
<view class="form-heading">
|
||||
@@ -29,7 +28,7 @@
|
||||
auto-height
|
||||
:maxlength="field.maxlength"
|
||||
:placeholder="field.placeholder"
|
||||
:disabled="state === 'submitting'"
|
||||
:disabled="joinApplicationState === 'submitting'"
|
||||
@input="error = ''"
|
||||
/>
|
||||
<input
|
||||
@@ -38,16 +37,19 @@
|
||||
:type="field.inputType || 'text'"
|
||||
:maxlength="field.maxlength"
|
||||
:placeholder="field.placeholder"
|
||||
:disabled="state === 'submitting'"
|
||||
:disabled="joinApplicationState === 'submitting'"
|
||||
@input="error = ''"
|
||||
/>
|
||||
<text v-if="field.key === 'relationDesc'" class="form-field__hint"
|
||||
>请填写您与家谱成员的关系,例如:我是某某某的堂侄。</text
|
||||
>
|
||||
</view>
|
||||
<text v-if="error" class="form-error">{{ error }}</text>
|
||||
<AppButton
|
||||
block
|
||||
:disabled="state === 'submitting'"
|
||||
:label="state === 'submitting' ? '正在提交' : '提交申请'"
|
||||
@click="submit"
|
||||
:disabled="joinApplicationState === 'submitting'"
|
||||
:label="joinApplicationState === 'submitting' ? '正在提交' : '提交申请'"
|
||||
@click="submitJoinApplication"
|
||||
/>
|
||||
</view>
|
||||
</view>
|
||||
@@ -67,23 +69,25 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, onUnmounted, reactive, ref } from "vue";
|
||||
import { onBackPress, onLoad } from "@dcloudio/uni-app";
|
||||
import { computed, reactive, ref } from "vue";
|
||||
import { onBackPress, onLoad, onUnload } from "@dcloudio/uni-app";
|
||||
import AppButton from "@/components/AppButton.vue";
|
||||
import AppDialog from "@/components/AppDialog.vue";
|
||||
import GenealogyPageBackground from "@/components/GenealogyPageBackground.vue";
|
||||
import GenealogyPageBackground from "@/components/genealogy/PageBackground.vue";
|
||||
import PageHeader from "@/components/PageHeader.vue";
|
||||
import {
|
||||
appApi,
|
||||
createRequestController,
|
||||
isRequestCancelled,
|
||||
} from "@/utils/api.js";
|
||||
isRequestCancelled
|
||||
} from "@/services/api/request-controller.js";
|
||||
import { genealogyMembershipApi } from "@/services/api/genealogy-membership-service.js";
|
||||
import { getRequestErrorMessage } from "@/services/api/request-error-message.js";
|
||||
import { createDiscardConfirmation } from "@/utils/discard-confirmation.js";
|
||||
import { handleBackPress, returnTo, runBackGuard } from "@/utils/navigation.js";
|
||||
import { createNonIdempotentWriteGuard } from "@/utils/non-idempotent-write-guard.js";
|
||||
import { handleBackPress, openPage, returnTo, runBackGuard } from "@/utils/navigation/gateway.js";
|
||||
|
||||
const genealogyId = ref("");
|
||||
const genealogyName = ref("");
|
||||
const state = ref("form");
|
||||
const joinApplicationState = ref("form");
|
||||
const error = ref("");
|
||||
const discardVisible = ref(false);
|
||||
const form = reactive({
|
||||
@@ -120,7 +124,9 @@ const fields = [
|
||||
placeholder: "说明申请加入的原因",
|
||||
},
|
||||
];
|
||||
const controller = createRequestController();
|
||||
const joinApplicationSubmitController = createRequestController();
|
||||
const joinApplicationGuard = createNonIdempotentWriteGuard();
|
||||
let pageActive = true;
|
||||
const hasValidContext = computed(() => /^[1-9]\d*$/.test(genealogyId.value));
|
||||
const dirty = computed(() => Object.values(form).some((value) => value.trim()));
|
||||
const confirmation = createDiscardConfirmation((visible) => {
|
||||
@@ -135,37 +141,52 @@ onLoad((query) => {
|
||||
});
|
||||
|
||||
const backToSearch = () => returnTo("G06");
|
||||
const submit = async () => {
|
||||
if (state.value === "submitting" || !hasValidContext.value) return;
|
||||
state.value = "submitting";
|
||||
const openMyApplications = () => openPage("G09", {}, "G08");
|
||||
const submitJoinApplication = async () => {
|
||||
if (joinApplicationState.value === "submitting" || !hasValidContext.value) return;
|
||||
const payload = { ...form };
|
||||
const submitAttempt = joinApplicationGuard.begin(payload);
|
||||
if (submitAttempt === null) {
|
||||
error.value =
|
||||
"上次提交结果暂时无法确认,请先查看“我的申请”,避免重复提交。";
|
||||
return;
|
||||
}
|
||||
joinApplicationState.value = "submitting";
|
||||
error.value = "";
|
||||
try {
|
||||
await appApi.applyToJoin(
|
||||
await genealogyMembershipApi.applyToJoin(
|
||||
genealogyId.value,
|
||||
{ ...form },
|
||||
{ requestController: controller },
|
||||
payload,
|
||||
{ requestController: joinApplicationSubmitController },
|
||||
);
|
||||
state.value = "success";
|
||||
if (!pageActive) return;
|
||||
joinApplicationState.value = "success";
|
||||
} catch (cause) {
|
||||
if (!isRequestCancelled(cause)) {
|
||||
state.value = "form";
|
||||
error.value = cause?.message || "申请提交失败,请稍后重试。";
|
||||
if (!pageActive) return;
|
||||
joinApplicationState.value = "form";
|
||||
if (joinApplicationGuard.recordFailure(submitAttempt, cause)) {
|
||||
error.value =
|
||||
"申请结果暂时无法确认,请先查看“我的申请”,避免重复提交。";
|
||||
return;
|
||||
}
|
||||
if (!isRequestCancelled(cause))
|
||||
error.value = getRequestErrorMessage(cause, "申请提交失败,请稍后重试。");
|
||||
}
|
||||
};
|
||||
const requestBack = () =>
|
||||
runBackGuard({
|
||||
transientOpen: discardVisible.value,
|
||||
dirty: dirty.value && state.value === "form",
|
||||
submitting: state.value === "submitting",
|
||||
dirty: dirty.value && joinApplicationState.value === "form",
|
||||
submitting: joinApplicationState.value === "submitting",
|
||||
"close-transient": cancelDiscard,
|
||||
"block-submitting": () => true,
|
||||
"confirm-discard": confirmation.request,
|
||||
});
|
||||
|
||||
onBackPress((event) => handleBackPress(event, requestBack));
|
||||
onUnmounted(() => {
|
||||
controller.abort();
|
||||
onUnload(() => {
|
||||
pageActive = false;
|
||||
joinApplicationSubmitController.abort();
|
||||
confirmation.dispose();
|
||||
});
|
||||
</script>
|
||||
@@ -248,6 +269,13 @@ onUnmounted(() => {
|
||||
color: $ink;
|
||||
font-size: clamp(15px, 24rpx, 18px);
|
||||
}
|
||||
.form-field > text.form-field__hint {
|
||||
margin-top: 8rpx;
|
||||
color: $ink-muted;
|
||||
font-size: clamp(13px, 21rpx, 16px);
|
||||
font-weight: 400;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.form-field input {
|
||||
min-height: 76rpx;
|
||||
padding-top: 0;
|
||||
@@ -0,0 +1,728 @@
|
||||
<template>
|
||||
<view class="member-page">
|
||||
<GenealogyPageBackground />
|
||||
<view class="page-header">
|
||||
<PageHeader title="家谱成员" custom-back @back="requestBack" />
|
||||
</view>
|
||||
|
||||
<view class="page-content">
|
||||
<view v-if="!hasValidGenealogyId" class="state-card">
|
||||
<text>暂时无法打开成员页面</text>
|
||||
<text>没有找到家谱信息,请返回家谱总览后重新进入。</text>
|
||||
<AppButton block label="返回家谱总览" @click="returnToOverview" />
|
||||
</view>
|
||||
<view v-else-if="memberListState === 'loading'" class="state-card">
|
||||
<AppLoading text="正在读取家谱成员" description="请稍候,正在同步成员和管理权限。" />
|
||||
</view>
|
||||
<view v-else-if="memberListState === 'error'" class="state-card">
|
||||
<text>暂时无法读取成员</text>
|
||||
<text>{{ memberListError || "请检查网络后重试。" }}</text>
|
||||
<AppButton block type="secondary" label="重新加载" @click="loadMembers" />
|
||||
</view>
|
||||
<view v-else-if="memberListState === 'empty'" class="state-card">
|
||||
<text>{{ hasLeftGenealogy ? "已退出这部家谱" : "还没有成员记录" }}</text>
|
||||
<text>{{ hasLeftGenealogy ? "你的退出操作已经完成。" : "家人加入后,会显示在这里。" }}</text>
|
||||
<AppButton
|
||||
block
|
||||
:label="hasLeftGenealogy ? '返回我的家谱' : '返回家谱总览'"
|
||||
@click="returnToOverview"
|
||||
/>
|
||||
</view>
|
||||
<view v-else class="member-list">
|
||||
<view class="member-intro">
|
||||
<text>共 {{ members.length }} 位成员</text>
|
||||
<text>这里管理的是已加入家谱的账号成员;家谱中的人物资料请到“世系树”维护。</text>
|
||||
</view>
|
||||
<text v-if="feedbackMessage" class="page-feedback" role="status">
|
||||
{{ feedbackMessage }}
|
||||
</text>
|
||||
|
||||
<view
|
||||
v-for="member in members"
|
||||
:key="member.memberId"
|
||||
class="member-card"
|
||||
>
|
||||
<view class="member-card__heading">
|
||||
<view class="member-card__identity">
|
||||
<text>{{ member.memberName }}</text>
|
||||
<text
|
||||
v-if="member.appUserNickName && member.appUserNickName !== member.memberName"
|
||||
>
|
||||
账号昵称:{{ member.appUserNickName }}
|
||||
</text>
|
||||
</view>
|
||||
<text class="role-badge">{{ roleLabel(member.roleType) }}</text>
|
||||
</view>
|
||||
<view class="member-card__details">
|
||||
<text>与家谱关系:{{ member.relationName || "未填写" }}</text>
|
||||
<text>世系人物:{{ lineageLabel(member) }}</text>
|
||||
<text v-if="member.joinTime">
|
||||
加入时间:{{ formatMinuteTimestamp(member.joinTime) }}
|
||||
</text>
|
||||
</view>
|
||||
<view v-if="hasMemberActions(member)" class="member-card__actions">
|
||||
<AppButton
|
||||
v-if="member.capabilities.canEdit"
|
||||
compact
|
||||
type="secondary"
|
||||
label="编辑资料"
|
||||
@click="openMemberEditor(member)"
|
||||
/>
|
||||
<AppButton
|
||||
v-if="member.capabilities.canEdit && member.lineagePersonId"
|
||||
compact
|
||||
type="secondary"
|
||||
label="解除人物绑定"
|
||||
@click="openConfirmation('unlink', member)"
|
||||
/>
|
||||
<AppButton
|
||||
v-if="member.capabilities.canTransferOwner"
|
||||
compact
|
||||
type="secondary"
|
||||
label="转让谱主"
|
||||
@click="openConfirmation('transfer', member)"
|
||||
/>
|
||||
<AppButton
|
||||
v-if="member.capabilities.canRemove"
|
||||
compact
|
||||
type="secondary"
|
||||
label="移出家谱"
|
||||
@click="openConfirmation('remove', member)"
|
||||
/>
|
||||
<AppButton
|
||||
v-if="member.capabilities.canLeave"
|
||||
compact
|
||||
type="secondary"
|
||||
label="退出家谱"
|
||||
@click="openConfirmation('leave', member)"
|
||||
/>
|
||||
</view>
|
||||
<text v-else class="member-card__readonly">当前账号只能查看这位成员。</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<AppDialog
|
||||
:visible="Boolean(editTarget)"
|
||||
eyebrow="成员资料"
|
||||
:title="`编辑${editTarget?.memberName || '成员'}`"
|
||||
message="称呼和关系需要填写;角色、世系人物请从已有选项中选择。"
|
||||
:confirm-text="operationPending ? '正在保存…' : '保存修改'"
|
||||
cancel-text="取消"
|
||||
show-cancel
|
||||
:close-on-mask="!operationPending"
|
||||
@confirm="saveEdit"
|
||||
@cancel="closeEdit"
|
||||
>
|
||||
<view class="edit-form">
|
||||
<label class="edit-field">
|
||||
<text>成员称呼</text>
|
||||
<input v-model.trim="editForm.memberName" maxlength="50" placeholder="例如:王叔叔" />
|
||||
</label>
|
||||
<label class="edit-field">
|
||||
<text>与家谱关系</text>
|
||||
<input v-model.trim="editForm.relationName" maxlength="100" placeholder="例如:本族成员" />
|
||||
</label>
|
||||
<picker
|
||||
:disabled="editTarget?.roleType === GENEALOGY_MEMBER_ROLE.OWNER"
|
||||
:range="roleOptions.map((roleOption) => roleOption.label)"
|
||||
:value="roleIndex"
|
||||
@change="selectRole"
|
||||
>
|
||||
<view class="edit-field edit-field--picker">
|
||||
<text>成员角色</text><text>{{ roleLabel(editForm.roleType) }}</text>
|
||||
</view>
|
||||
</picker>
|
||||
<picker :disabled="personOptionsState === 'loading'" :range="personOptionLabels" :value="personOptionIndex" @change="selectPerson">
|
||||
<view class="edit-field edit-field--picker">
|
||||
<text>绑定世系人物</text>
|
||||
<text>{{ selectedPersonLabel }}</text>
|
||||
</view>
|
||||
</picker>
|
||||
<text v-if="personOptionsState === 'loading'" class="edit-hint">正在读取世系人物…</text>
|
||||
<text v-else-if="personOptionsState === 'error'" class="edit-error">人物选项暂时无法读取,本次可先修改其他资料。</text>
|
||||
<text v-if="editError" class="edit-error" role="alert">{{ editError }}</text>
|
||||
</view>
|
||||
</AppDialog>
|
||||
|
||||
<AppDialog
|
||||
:visible="Boolean(confirmTarget)"
|
||||
eyebrow="请确认"
|
||||
:title="confirmationCopy.title"
|
||||
:message="confirmationCopy.message"
|
||||
:confirm-text="operationPending ? '正在处理…' : confirmationCopy.confirm"
|
||||
cancel-text="取消"
|
||||
show-cancel
|
||||
:close-on-mask="!operationPending"
|
||||
@confirm="runConfirmedOperation"
|
||||
@cancel="closeConfirmation"
|
||||
>
|
||||
<text v-if="operationError" class="edit-error" role="alert">{{ operationError }}</text>
|
||||
</AppDialog>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, reactive, ref } from "vue";
|
||||
import { onBackPress, onLoad, 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 GenealogyPageBackground from "@/components/genealogy/PageBackground.vue";
|
||||
import PageHeader from "@/components/PageHeader.vue";
|
||||
import {
|
||||
GENEALOGY_MEMBER_ROLE,
|
||||
GENEALOGY_MEMBER_ROLE_LABELS
|
||||
} from "@/services/api/genealogy-member-contract.js";
|
||||
import {
|
||||
createRequestController,
|
||||
isRequestCancelled
|
||||
} from "@/services/api/request-controller.js";
|
||||
import { genealogyMemberApi } from "@/services/api/genealogy-member-service.js";
|
||||
import { lineageApi } from "@/services/api/lineage-service.js";
|
||||
import { getRequestErrorMessage } from "@/services/api/request-error-message.js";
|
||||
import { formatMinuteTimestamp } from "@/utils/display-time.js";
|
||||
import { handleBackPress, returnTo, runBackGuard } from "@/utils/navigation/gateway.js";
|
||||
|
||||
const genealogyId = ref("");
|
||||
const members = ref([]);
|
||||
const memberListState = ref("loading");
|
||||
const memberListError = ref("");
|
||||
const feedbackMessage = ref("");
|
||||
const editTarget = ref(null);
|
||||
const confirmTarget = ref(null);
|
||||
const editError = ref("");
|
||||
const operationError = ref("");
|
||||
const operationPending = ref(false);
|
||||
const hasLeftGenealogy = ref(false);
|
||||
const personOptions = ref([]);
|
||||
const personOptionsState = ref("idle");
|
||||
const memberListController = createRequestController();
|
||||
const personOptionsController = createRequestController();
|
||||
const memberUpdateController = createRequestController();
|
||||
const membershipOperationController = createRequestController();
|
||||
let isPageActive = true;
|
||||
|
||||
const editForm = reactive({
|
||||
memberName: "",
|
||||
relationName: "",
|
||||
roleType: GENEALOGY_MEMBER_ROLE.MEMBER,
|
||||
lineagePersonId: "",
|
||||
});
|
||||
const hasValidGenealogyId = computed(() => /^[1-9]\d*$/.test(genealogyId.value));
|
||||
const isOwnerViewer = computed(() =>
|
||||
members.value.some((member) => member.capabilities.canTransferOwner),
|
||||
);
|
||||
const roleOptions = computed(() => {
|
||||
const options = [
|
||||
...(isOwnerViewer.value
|
||||
? [{ label: GENEALOGY_MEMBER_ROLE_LABELS[GENEALOGY_MEMBER_ROLE.ADMIN], value: GENEALOGY_MEMBER_ROLE.ADMIN }]
|
||||
: []),
|
||||
{ label: GENEALOGY_MEMBER_ROLE_LABELS[GENEALOGY_MEMBER_ROLE.EDITOR], value: GENEALOGY_MEMBER_ROLE.EDITOR },
|
||||
{ label: GENEALOGY_MEMBER_ROLE_LABELS[GENEALOGY_MEMBER_ROLE.MEMBER], value: GENEALOGY_MEMBER_ROLE.MEMBER },
|
||||
];
|
||||
const currentRole = editTarget.value?.roleType;
|
||||
if (currentRole && !options.some((option) => option.value === currentRole)) {
|
||||
options.unshift({
|
||||
label: GENEALOGY_MEMBER_ROLE_LABELS[currentRole] || "当前角色",
|
||||
value: currentRole,
|
||||
});
|
||||
}
|
||||
return options;
|
||||
});
|
||||
const roleIndex = computed(() =>
|
||||
Math.max(
|
||||
0,
|
||||
roleOptions.value.findIndex(
|
||||
(roleOption) => roleOption.value === editForm.roleType,
|
||||
),
|
||||
),
|
||||
);
|
||||
const personOptionLabels = computed(() => [
|
||||
"保持当前绑定",
|
||||
...personOptions.value.map(
|
||||
(person) =>
|
||||
`${person.name}${person.generation ? ` · 第${person.generation}世` : ""}`,
|
||||
),
|
||||
]);
|
||||
const personOptionIndex = computed(() => {
|
||||
if (!editForm.lineagePersonId || editForm.lineagePersonId === editTarget.value?.lineagePersonId) return 0;
|
||||
const index = personOptions.value.findIndex(
|
||||
(person) => person.id === editForm.lineagePersonId,
|
||||
);
|
||||
return index < 0 ? 0 : index + 1;
|
||||
});
|
||||
const selectedPersonLabel = computed(
|
||||
() => personOptionLabels.value[personOptionIndex.value] || "保持当前绑定",
|
||||
);
|
||||
const confirmationCopy = computed(() => {
|
||||
const memberName = confirmTarget.value?.member?.memberName || "这位成员";
|
||||
const copyByOperation = {
|
||||
unlink: {
|
||||
title: "解除人物绑定?",
|
||||
message: `解除后,“${memberName}”的账号仍在家谱中,但不再对应世系树人物。`,
|
||||
confirm: "确认解除",
|
||||
},
|
||||
remove: {
|
||||
title: "将成员移出家谱?",
|
||||
message: `移出后,“${memberName}”将不能再以成员身份访问这部家谱。`,
|
||||
confirm: "确认移出",
|
||||
},
|
||||
leave: {
|
||||
title: "退出这部家谱?",
|
||||
message: "退出后,你将不能再查看仅对成员开放的内容。",
|
||||
confirm: "确认退出",
|
||||
},
|
||||
transfer: {
|
||||
title: "转让谱主身份?",
|
||||
message: `转让后,“${memberName}”将成为新谱主,你会变为管理员。`,
|
||||
confirm: "确认转让",
|
||||
},
|
||||
};
|
||||
return copyByOperation[confirmTarget.value?.kind] || {
|
||||
title: "确认操作?",
|
||||
message: "请确认是否继续。",
|
||||
confirm: "确认",
|
||||
};
|
||||
});
|
||||
|
||||
const roleLabel = (role) => GENEALOGY_MEMBER_ROLE_LABELS[role] || "成员";
|
||||
const lineageLabel = (member) =>
|
||||
member.lineagePersonName || (member.lineagePersonId ? "已绑定" : "未绑定");
|
||||
const hasMemberActions = (member) =>
|
||||
Object.values(member.capabilities).some(Boolean);
|
||||
|
||||
const loadMembers = async () => {
|
||||
if (!hasValidGenealogyId.value) return;
|
||||
memberListController.abort();
|
||||
memberListState.value = "loading";
|
||||
memberListError.value = "";
|
||||
try {
|
||||
const loadedMembers = await genealogyMemberApi.getMembers(genealogyId.value, {
|
||||
requestController: memberListController,
|
||||
});
|
||||
if (!isPageActive) return;
|
||||
members.value = loadedMembers;
|
||||
memberListState.value = members.value.length ? "ready" : "empty";
|
||||
} catch (error) {
|
||||
if (!isPageActive || isRequestCancelled(error)) return;
|
||||
memberListError.value = getRequestErrorMessage(error, "成员加载失败,请稍后重试。");
|
||||
memberListState.value = "error";
|
||||
}
|
||||
};
|
||||
const loadPersonOptions = async () => {
|
||||
if (personOptionsState.value === "loading" || personOptionsState.value === "ready") return;
|
||||
personOptionsState.value = "loading";
|
||||
try {
|
||||
const loadedPersonOptions = await lineageApi.getLineagePersonOptions(genealogyId.value, {
|
||||
requestController: personOptionsController,
|
||||
});
|
||||
if (!isPageActive) return;
|
||||
personOptions.value = loadedPersonOptions;
|
||||
personOptionsState.value = "ready";
|
||||
} catch (error) {
|
||||
if (!isPageActive || isRequestCancelled(error)) return;
|
||||
personOptionsState.value = "error";
|
||||
}
|
||||
};
|
||||
const openMemberEditor = (member) => {
|
||||
if (!member.capabilities.canEdit || operationPending.value) return;
|
||||
editTarget.value = member;
|
||||
editForm.memberName = member.memberName;
|
||||
editForm.relationName = member.relationName;
|
||||
editForm.roleType = member.roleType;
|
||||
editForm.lineagePersonId = member.lineagePersonId || "";
|
||||
editError.value = "";
|
||||
void loadPersonOptions();
|
||||
};
|
||||
const closeEdit = () => {
|
||||
if (operationPending.value) return;
|
||||
editTarget.value = null;
|
||||
editError.value = "";
|
||||
};
|
||||
const selectRole = (event) => {
|
||||
editForm.roleType = roleOptions.value[Number(event.detail.value)]?.value || editForm.roleType;
|
||||
};
|
||||
const selectPerson = (event) => {
|
||||
const index = Number(event.detail.value);
|
||||
editForm.lineagePersonId = index > 0 ? personOptions.value[index - 1]?.id || "" : editTarget.value?.lineagePersonId || "";
|
||||
};
|
||||
const saveEdit = async () => {
|
||||
const target = editTarget.value;
|
||||
if (!target || operationPending.value) return;
|
||||
if (!editForm.memberName.trim()) {
|
||||
editError.value = "请填写成员称呼。";
|
||||
return;
|
||||
}
|
||||
operationPending.value = true;
|
||||
editError.value = "";
|
||||
try {
|
||||
const updated = await genealogyMemberApi.updateMember(
|
||||
genealogyId.value,
|
||||
target.memberId,
|
||||
{
|
||||
memberName: editForm.memberName,
|
||||
relationName: editForm.relationName,
|
||||
...(editForm.roleType !== target.roleType
|
||||
? { roleType: editForm.roleType }
|
||||
: {}),
|
||||
...(editForm.lineagePersonId &&
|
||||
editForm.lineagePersonId !== target.lineagePersonId
|
||||
? { lineagePersonId: editForm.lineagePersonId }
|
||||
: {}),
|
||||
},
|
||||
{ requestController: memberUpdateController },
|
||||
);
|
||||
if (!isPageActive) return;
|
||||
members.value = members.value.map((member) =>
|
||||
member.memberId === updated.memberId ? updated : member,
|
||||
);
|
||||
feedbackMessage.value = `“${updated.memberName}”的成员资料已更新。`;
|
||||
editTarget.value = null;
|
||||
} catch (error) {
|
||||
if (!isPageActive || isRequestCancelled(error)) return;
|
||||
editError.value = getRequestErrorMessage(error, "保存失败,请稍后重试。");
|
||||
} finally {
|
||||
if (isPageActive) operationPending.value = false;
|
||||
}
|
||||
};
|
||||
const openConfirmation = (kind, member) => {
|
||||
if (operationPending.value) return;
|
||||
confirmTarget.value = { kind, member };
|
||||
operationError.value = "";
|
||||
};
|
||||
const closeConfirmation = () => {
|
||||
if (operationPending.value) return;
|
||||
confirmTarget.value = null;
|
||||
operationError.value = "";
|
||||
};
|
||||
const runConfirmedOperation = async () => {
|
||||
const target = confirmTarget.value;
|
||||
if (!target || operationPending.value) return;
|
||||
operationPending.value = true;
|
||||
operationError.value = "";
|
||||
let operationCommitted = false;
|
||||
try {
|
||||
const requestOptions = {
|
||||
requestController: membershipOperationController,
|
||||
};
|
||||
switch (target.kind) {
|
||||
case "unlink":
|
||||
await genealogyMemberApi.unlinkMemberLineagePerson(
|
||||
genealogyId.value,
|
||||
target.member.memberId,
|
||||
requestOptions,
|
||||
);
|
||||
break;
|
||||
case "remove":
|
||||
await genealogyMemberApi.removeMember(
|
||||
genealogyId.value,
|
||||
target.member.memberId,
|
||||
requestOptions,
|
||||
);
|
||||
break;
|
||||
case "leave":
|
||||
await genealogyMemberApi.leaveGenealogy(genealogyId.value, requestOptions);
|
||||
break;
|
||||
case "transfer":
|
||||
await genealogyMemberApi.transferGenealogyOwner(
|
||||
genealogyId.value,
|
||||
target.member.memberId,
|
||||
requestOptions,
|
||||
);
|
||||
break;
|
||||
default:
|
||||
throw new Error(`不支持的成员操作:${target.kind}`);
|
||||
}
|
||||
operationCommitted = true;
|
||||
if (!isPageActive) return;
|
||||
confirmTarget.value = null;
|
||||
if (target.kind === "leave") {
|
||||
hasLeftGenealogy.value = true;
|
||||
members.value = [];
|
||||
memberListState.value = "empty";
|
||||
await returnTo("G01");
|
||||
return;
|
||||
}
|
||||
feedbackMessage.value =
|
||||
({
|
||||
unlink: "人物绑定已解除。",
|
||||
remove: "成员已移出家谱。",
|
||||
transfer: "谱主身份已转让。",
|
||||
})[target.kind] || "操作已完成。";
|
||||
await loadMembers();
|
||||
} catch (error) {
|
||||
if (!isPageActive) return;
|
||||
if (operationCommitted && target.kind === "leave") {
|
||||
hasLeftGenealogy.value = true;
|
||||
members.value = [];
|
||||
memberListState.value = "empty";
|
||||
confirmTarget.value = null;
|
||||
return;
|
||||
}
|
||||
if (isRequestCancelled(error)) return;
|
||||
operationError.value = getRequestErrorMessage(error, "操作失败,请稍后重试。");
|
||||
} finally {
|
||||
if (isPageActive) operationPending.value = false;
|
||||
}
|
||||
};
|
||||
const returnToOverview = () =>
|
||||
hasLeftGenealogy.value
|
||||
? returnTo("G01")
|
||||
: returnTo("G05", { genealogyId: genealogyId.value });
|
||||
const requestBack = () =>
|
||||
runBackGuard({
|
||||
transientOpen: Boolean(editTarget.value || confirmTarget.value),
|
||||
submitting: operationPending.value,
|
||||
"close-transient": () =>
|
||||
editTarget.value ? closeEdit() : closeConfirmation(),
|
||||
"block-submitting": () => true,
|
||||
});
|
||||
|
||||
onLoad((query) => {
|
||||
genealogyId.value = String(query?.genealogyId || "");
|
||||
if (hasValidGenealogyId.value) void loadMembers();
|
||||
});
|
||||
onShow(() => {
|
||||
if (
|
||||
!hasLeftGenealogy.value &&
|
||||
hasValidGenealogyId.value &&
|
||||
memberListState.value !== "loading" &&
|
||||
!operationPending.value
|
||||
) {
|
||||
void loadMembers();
|
||||
}
|
||||
});
|
||||
onBackPress((event) => handleBackPress(event, requestBack));
|
||||
onUnload(() => {
|
||||
isPageActive = false;
|
||||
memberListController.abort();
|
||||
personOptionsController.abort();
|
||||
memberUpdateController.abort();
|
||||
membershipOperationController.abort();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@use "../../styles/adaptive-frame-profiles.scss" as adaptive;
|
||||
.member-page {
|
||||
display: flex;
|
||||
min-height: 100vh;
|
||||
flex-direction: column;
|
||||
background: $paper;
|
||||
}
|
||||
|
||||
.page-header,
|
||||
.page-content {
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.page-content {
|
||||
padding: 18rpx 24rpx 72rpx;
|
||||
}
|
||||
|
||||
.state-card,
|
||||
.member-intro,
|
||||
.member-card {
|
||||
box-sizing: border-box;
|
||||
@include adaptive.adaptive-genealogy-state-panel;
|
||||
}
|
||||
|
||||
.state-card {
|
||||
min-height: 340rpx;
|
||||
padding: 78rpx 52rpx 50rpx;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.state-card > text {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.state-card > text:first-child {
|
||||
color: $ink;
|
||||
font-family: STKaiti, KaiTi, serif;
|
||||
font-size: clamp(19px, 35rpx, 24px);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.state-card > text:nth-child(2) {
|
||||
margin-top: 18rpx;
|
||||
color: $ink-muted;
|
||||
font-size: clamp(15px, 24rpx, 18px);
|
||||
line-height: 1.65;
|
||||
}
|
||||
|
||||
.state-card .app-button {
|
||||
margin-top: 28rpx;
|
||||
}
|
||||
|
||||
.member-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16rpx;
|
||||
}
|
||||
|
||||
.member-intro {
|
||||
padding: 24rpx 28rpx;
|
||||
}
|
||||
|
||||
.member-intro text {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.member-intro text:first-child {
|
||||
color: $ink;
|
||||
font-family: STKaiti, KaiTi, serif;
|
||||
font-size: clamp(18px, 31rpx, 22px);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.member-intro text:last-child {
|
||||
margin-top: 10rpx;
|
||||
color: $ink-muted;
|
||||
font-size: clamp(14px, 22rpx, 17px);
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.page-feedback {
|
||||
display: block;
|
||||
padding: 18rpx 22rpx;
|
||||
border: 1rpx solid rgba(66, 107, 88, 0.32);
|
||||
border-radius: 12rpx;
|
||||
background: rgba(66, 107, 88, 0.08);
|
||||
color: #426b58;
|
||||
font-size: clamp(14px, 23rpx, 17px);
|
||||
}
|
||||
|
||||
.member-card {
|
||||
padding: 26rpx 28rpx;
|
||||
}
|
||||
|
||||
.member-card__heading {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 18rpx;
|
||||
}
|
||||
|
||||
.member-card__identity {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.member-card__identity text {
|
||||
display: block;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.member-card__identity text:first-child {
|
||||
color: $ink;
|
||||
font-family: STKaiti, KaiTi, serif;
|
||||
font-size: clamp(18px, 32rpx, 23px);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.member-card__identity text:last-child {
|
||||
margin-top: 6rpx;
|
||||
color: $ink-muted;
|
||||
font-size: clamp(13px, 21rpx, 16px);
|
||||
}
|
||||
|
||||
.role-badge {
|
||||
flex: 0 0 auto;
|
||||
padding: 7rpx 14rpx;
|
||||
border: 1rpx solid rgba(174, 113, 39, 0.38);
|
||||
border-radius: 999rpx;
|
||||
background: rgba(205, 161, 84, 0.12);
|
||||
color: #8f160f;
|
||||
font-size: clamp(13px, 21rpx, 16px);
|
||||
}
|
||||
|
||||
.member-card__details {
|
||||
margin-top: 18rpx;
|
||||
}
|
||||
|
||||
.member-card__details text,
|
||||
.member-card__readonly {
|
||||
display: block;
|
||||
margin-top: 7rpx;
|
||||
overflow-wrap: anywhere;
|
||||
color: $ink-muted;
|
||||
font-size: clamp(14px, 23rpx, 17px);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.member-card__actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
gap: 12rpx;
|
||||
margin-top: 22rpx;
|
||||
}
|
||||
|
||||
.member-card__actions .app-button {
|
||||
width: auto;
|
||||
min-width: 138rpx;
|
||||
}
|
||||
|
||||
.member-card__readonly {
|
||||
margin-top: 18rpx;
|
||||
}
|
||||
|
||||
.edit-form {
|
||||
width: 100%;
|
||||
margin-top: 22rpx;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.edit-field {
|
||||
@include adaptive.adaptive-genealogy-form-field;
|
||||
display: flex;
|
||||
width: 100%;
|
||||
min-height: 86rpx;
|
||||
box-sizing: border-box;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 18rpx;
|
||||
margin-top: 12rpx;
|
||||
padding: 14rpx 18rpx;
|
||||
}
|
||||
|
||||
.edit-field > text:first-child {
|
||||
flex: 0 0 auto;
|
||||
color: $ink;
|
||||
font-size: clamp(14px, 23rpx, 17px);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.edit-field input,
|
||||
.edit-field > text:last-child {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
overflow-wrap: anywhere;
|
||||
color: $ink;
|
||||
font-size: clamp(14px, 23rpx, 17px);
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.edit-field--picker > text:last-child {
|
||||
color: #8f160f;
|
||||
}
|
||||
|
||||
.edit-hint,
|
||||
.edit-error {
|
||||
display: block;
|
||||
width: 100%;
|
||||
margin-top: 12rpx;
|
||||
font-size: clamp(13px, 21rpx, 16px);
|
||||
line-height: 1.5;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.edit-hint {
|
||||
color: $ink-muted;
|
||||
}
|
||||
|
||||
.edit-error {
|
||||
color: $brand-red;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,265 @@
|
||||
<template>
|
||||
<view class="applications-page">
|
||||
<GenealogyPageBackground />
|
||||
<view class="page-header">
|
||||
<PageHeader title="我的申请" custom-back @back="returnToGenealogies" />
|
||||
</view>
|
||||
<view class="page-content">
|
||||
<view v-if="applicationListState === 'loading'" class="state-card">
|
||||
<AppLoading text="正在读取我的申请" />
|
||||
</view>
|
||||
<view v-else-if="applicationListState === 'error'" class="state-card">
|
||||
<text>暂时无法读取我的申请</text>
|
||||
<text>{{ applicationListError || "请检查网络后重试。" }}</text>
|
||||
<AppButton block type="secondary" label="重新加载" @click="loadApplications" />
|
||||
</view>
|
||||
<view v-else-if="applicationListState === 'empty'" class="state-card">
|
||||
<text>暂无加入申请</text>
|
||||
<text>从公开家谱提交的申请会显示在这里。</text>
|
||||
<AppButton block label="查找公开家谱" @click="toSearch" />
|
||||
</view>
|
||||
<view v-else class="application-list">
|
||||
<text class="application-list__count">共 {{ visibleApplications.length }} 条申请</text>
|
||||
<text v-if="feedbackMessage" class="page-feedback" role="status">{{ feedbackMessage }}</text>
|
||||
<view
|
||||
v-for="(item, index) in visibleApplications"
|
||||
:key="applicationKey(item, index)"
|
||||
class="application-card"
|
||||
>
|
||||
<view class="application-card__heading">
|
||||
<text>{{ applicationGenealogyName(item) }}</text>
|
||||
<text>{{ applicationTime(item) }}</text>
|
||||
</view>
|
||||
<text class="application-card__relation">{{ applicationRelation(item) }}</text>
|
||||
<text
|
||||
class="application-card__status"
|
||||
:class="`application-card__status--${applicationStatusTone(applicationStatus(item))}`"
|
||||
>{{ statusLabel(applicationStatus(item)) }}</text>
|
||||
<text v-if="applicationRemark(item)" class="application-card__remark">{{
|
||||
applicationRemark(item)
|
||||
}}</text>
|
||||
<view class="application-card__actions">
|
||||
<AppButton
|
||||
v-if="applicationStatus(item) === JOIN_APPLICATION_STATUS.PENDING"
|
||||
compact
|
||||
type="secondary"
|
||||
label="撤回申请"
|
||||
:disabled="!applicationId(item) || operationPending"
|
||||
@click="openWithdraw(item)"
|
||||
/>
|
||||
<AppButton
|
||||
v-else-if="applicationStatus(item) === JOIN_APPLICATION_STATUS.APPROVED"
|
||||
compact
|
||||
label="查看家谱"
|
||||
:disabled="!applicationGenealogyId(item)"
|
||||
@click="openGenealogy(item)"
|
||||
/>
|
||||
<AppButton
|
||||
v-else-if="applicationStatus(item) === JOIN_APPLICATION_STATUS.REJECTED"
|
||||
compact
|
||||
type="secondary"
|
||||
label="重新申请"
|
||||
:disabled="!applicationGenealogyId(item)"
|
||||
@click="reapply(item)"
|
||||
/>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<AppDialog
|
||||
:visible="Boolean(withdrawTarget)"
|
||||
eyebrow="申请管理"
|
||||
title="撤回这条申请?"
|
||||
:message="withdrawTarget ? `将撤回“${applicationGenealogyName(withdrawTarget)}”的加入申请。` : ''"
|
||||
:confirm-text="operationPending ? '正在撤回…' : '确认撤回'"
|
||||
cancel-text="暂不撤回"
|
||||
show-cancel
|
||||
:close-on-mask="!operationPending"
|
||||
@confirm="withdrawApplication"
|
||||
@cancel="closeWithdraw"
|
||||
>
|
||||
<text v-if="operationError" class="dialog-error" role="alert">{{ operationError }}</text>
|
||||
</AppDialog>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref } from "vue";
|
||||
import { onBackPress, onLoad, 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 GenealogyPageBackground from "@/components/genealogy/PageBackground.vue";
|
||||
import PageHeader from "@/components/PageHeader.vue";
|
||||
import {
|
||||
JOIN_APPLICATION_STATUS,
|
||||
JOIN_APPLICATION_STATUS_LABELS
|
||||
} from "@/services/api/genealogy-membership-contract.js";
|
||||
import {
|
||||
createRequestController,
|
||||
isRequestCancelled
|
||||
} from "@/services/api/request-controller.js";
|
||||
import { genealogyMembershipApi } from "@/services/api/genealogy-membership-service.js";
|
||||
import { getRequestErrorMessage } from "@/services/api/request-error-message.js";
|
||||
import { handleBackPress, openPage, returnTo, runBackGuard } from "@/utils/navigation/gateway.js";
|
||||
|
||||
const applications = ref([]);
|
||||
const applicationListState = ref("loading");
|
||||
const applicationListError = ref("");
|
||||
const requestedStatus = ref("");
|
||||
const withdrawTarget = ref(null);
|
||||
const operationPending = ref(false);
|
||||
const operationError = ref("");
|
||||
const feedbackMessage = ref("");
|
||||
const applicationListController = createRequestController();
|
||||
const applicationWithdrawalController = createRequestController();
|
||||
let isPageActive = true;
|
||||
|
||||
const applicationId = (item) => {
|
||||
const applicationIdValue = item?.applyId ?? item?.applicationId ?? item?.id;
|
||||
const text = String(applicationIdValue ?? "").trim();
|
||||
return /^[1-9]\d*$/.test(text) ? text : "";
|
||||
};
|
||||
const applicationKey = (item, index) => applicationId(item) || `application-${index}`;
|
||||
const applicationGenealogyId = (item) => {
|
||||
const genealogyIdValue = item?.genealogyId ?? item?.familyId;
|
||||
const text = String(genealogyIdValue ?? "").trim();
|
||||
return /^[1-9]\d*$/.test(text) ? text : "";
|
||||
};
|
||||
const applicationGenealogyName = (item) =>
|
||||
String(item?.genealogyName ?? item?.familyName ?? item?.genealogyTitle ?? "家谱加入申请");
|
||||
const applicationTime = (item) =>
|
||||
String(item?.appliedAt ?? item?.applyTime ?? item?.createTime ?? item?.createdAt ?? "");
|
||||
const applicationRelation = (item) =>
|
||||
String(item?.relationDesc ?? item?.relation ?? "未填写关系说明");
|
||||
const applicationRemark = (item) =>
|
||||
String(item?.auditRemark ?? item?.rejectionReason ?? item?.reason ?? "");
|
||||
const applicationStatus = (item) =>
|
||||
String(item?.status ?? JOIN_APPLICATION_STATUS.PENDING).trim();
|
||||
const statusLabel = (status) => JOIN_APPLICATION_STATUS_LABELS[status] || "状态待确认";
|
||||
const applicationStatusTones = Object.freeze({
|
||||
[JOIN_APPLICATION_STATUS.PENDING]: "pending",
|
||||
[JOIN_APPLICATION_STATUS.APPROVED]: "approved",
|
||||
[JOIN_APPLICATION_STATUS.REJECTED]: "rejected",
|
||||
[JOIN_APPLICATION_STATUS.CANCELLED]: "cancelled",
|
||||
});
|
||||
const applicationStatusTone = (status) => applicationStatusTones[status] || "unknown";
|
||||
const visibleApplications = computed(() =>
|
||||
requestedStatus.value
|
||||
? applications.value.filter((item) => applicationStatus(item) === requestedStatus.value)
|
||||
: applications.value,
|
||||
);
|
||||
|
||||
const loadApplications = async () => {
|
||||
applicationListController.abort();
|
||||
applicationListState.value = "loading";
|
||||
applicationListError.value = "";
|
||||
feedbackMessage.value = "";
|
||||
try {
|
||||
const loadedApplications = await genealogyMembershipApi.getMyJoinApplications({
|
||||
requestController: applicationListController,
|
||||
});
|
||||
if (!isPageActive) return;
|
||||
applications.value = loadedApplications;
|
||||
applicationListState.value = visibleApplications.value.length ? "ready" : "empty";
|
||||
} catch (error) {
|
||||
if (!isPageActive || isRequestCancelled(error)) return;
|
||||
applicationListError.value = getRequestErrorMessage(error, "请稍后重试。");
|
||||
applicationListState.value = "error";
|
||||
}
|
||||
};
|
||||
const returnToGenealogies = () => returnTo("G01");
|
||||
const toSearch = () => openPage("G06", {}, "G09");
|
||||
const openGenealogy = (item) =>
|
||||
openPage("G05", { genealogyId: applicationGenealogyId(item) }, "G09");
|
||||
const reapply = (item) =>
|
||||
openPage("G08", { genealogyId: applicationGenealogyId(item), source: "search" }, "G09");
|
||||
const openWithdraw = (item) => {
|
||||
operationError.value = "";
|
||||
withdrawTarget.value = item;
|
||||
};
|
||||
const closeWithdraw = () => {
|
||||
if (operationPending.value) return;
|
||||
withdrawTarget.value = null;
|
||||
operationError.value = "";
|
||||
};
|
||||
const withdrawApplication = async () => {
|
||||
const application = withdrawTarget.value;
|
||||
const id = applicationId(application);
|
||||
if (!application || !id || operationPending.value) return;
|
||||
operationPending.value = true;
|
||||
operationError.value = "";
|
||||
try {
|
||||
await genealogyMembershipApi.withdrawJoinApplication(id, {
|
||||
requestController: applicationWithdrawalController,
|
||||
});
|
||||
if (!isPageActive) return;
|
||||
applications.value = applications.value.map((item) =>
|
||||
applicationId(item) === id
|
||||
? { ...item, status: JOIN_APPLICATION_STATUS.CANCELLED }
|
||||
: item,
|
||||
);
|
||||
applicationListState.value = visibleApplications.value.length ? "ready" : "empty";
|
||||
feedbackMessage.value = "申请已撤回。";
|
||||
withdrawTarget.value = null;
|
||||
} catch (error) {
|
||||
if (!isPageActive || isRequestCancelled(error)) return;
|
||||
operationError.value = getRequestErrorMessage(error, "撤回申请失败,请稍后重试。");
|
||||
} finally {
|
||||
if (isPageActive) operationPending.value = false;
|
||||
}
|
||||
};
|
||||
const requestBack = () =>
|
||||
runBackGuard({
|
||||
transientOpen: Boolean(withdrawTarget.value),
|
||||
submitting: operationPending.value,
|
||||
"close-transient": closeWithdraw,
|
||||
"block-submitting": () => true,
|
||||
});
|
||||
|
||||
onLoad((query) => {
|
||||
const status = String(query?.status || "").trim();
|
||||
requestedStatus.value = Object.values(JOIN_APPLICATION_STATUS).includes(status)
|
||||
? status
|
||||
: "";
|
||||
loadApplications();
|
||||
});
|
||||
onShow(() => {
|
||||
if (applicationListState.value !== "loading" && !operationPending.value) loadApplications();
|
||||
});
|
||||
onBackPress((event) => handleBackPress(event, requestBack));
|
||||
onUnload(() => {
|
||||
isPageActive = false;
|
||||
applicationListController.abort();
|
||||
applicationWithdrawalController.abort();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@use "../../styles/adaptive-frame-profiles.scss" as adaptive;
|
||||
.applications-page { display: flex; min-height: 100vh; flex-direction: column; background: $paper; }
|
||||
.page-header, .page-content { z-index: 1; }
|
||||
.page-content { padding: 18rpx 24rpx 72rpx; }
|
||||
.state-card, .application-card { box-sizing: border-box; @include adaptive.adaptive-genealogy-state-panel; }
|
||||
.state-card { min-height: 340rpx; padding: 78rpx 52rpx 50rpx; text-align: center; }
|
||||
.state-card text, .application-card__relation, .application-card__remark, .page-feedback { display: block; }
|
||||
.state-card text:first-child { color: $ink; font-family: STKaiti, KaiTi, serif; font-size: clamp(19px, 35rpx, 24px); font-weight: 700; }
|
||||
.state-card text:nth-child(2) { margin-top: 18rpx; color: $ink-muted; font-size: clamp(15px, 24rpx, 18px); line-height: 1.65; }
|
||||
.state-card .app-button { margin-top: 28rpx; }
|
||||
.application-list { display: flex; flex-direction: column; gap: 16rpx; }
|
||||
.application-list__count { color: $ink-muted; font-size: clamp(13px, 21rpx, 16px); }
|
||||
.page-feedback { padding: 18rpx 22rpx; border: 1rpx solid rgba(66, 107, 88, .32); border-radius: 12rpx; background: rgba(66, 107, 88, .08); color: #426b58; font-size: clamp(14px, 23rpx, 17px); }
|
||||
.application-card { padding: 28rpx 30rpx; }
|
||||
.application-card__heading { display: flex; align-items: start; justify-content: space-between; gap: 20rpx; }
|
||||
.application-card__heading text:first-child { min-width: 0; color: $ink; font-family: STKaiti, KaiTi, serif; font-size: clamp(18px, 32rpx, 23px); font-weight: 700; overflow-wrap: anywhere; }
|
||||
.application-card__heading text:last-child { flex: 0 0 auto; color: $ink-muted; font-size: clamp(13px, 21rpx, 16px); text-align: right; }
|
||||
.application-card__relation { margin-top: 14rpx; color: $ink-muted; font-size: clamp(15px, 24rpx, 18px); line-height: 1.55; overflow-wrap: anywhere; }
|
||||
.application-card__status { display: block; margin-top: 16rpx; color: $brand-red; font-size: clamp(15px, 24rpx, 18px); font-weight: 700; }
|
||||
.application-card__status--approved { color: #426b58; }
|
||||
.application-card__status--rejected { color: #886037; }
|
||||
.application-card__status--cancelled, .application-card__status--unknown { color: $ink-muted; }
|
||||
.application-card__remark { margin-top: 8rpx; color: $ink-muted; font-size: clamp(14px, 22rpx, 17px); line-height: 1.5; overflow-wrap: anywhere; }
|
||||
.application-card__actions { display: flex; justify-content: flex-end; margin-top: 22rpx; }
|
||||
.application-card__actions .app-button { min-width: 164rpx; }
|
||||
.dialog-error { display: block; width: 100%; margin-top: 18rpx; color: $brand-red; font-size: clamp(14px, 23rpx, 17px); line-height: 1.5; text-align: left; }
|
||||
</style>
|
||||
@@ -1,4 +1,3 @@
|
||||
<!-- 页面编号:G-01;用途:我的家谱列表与首次分流;本轮仍待用户重新审核。 -->
|
||||
<template>
|
||||
<view
|
||||
class="page-shell genealogy-index"
|
||||
@@ -38,7 +37,7 @@
|
||||
<view
|
||||
class="state-retry"
|
||||
hover-class="action-hover"
|
||||
@click="retryLoad"
|
||||
@click="retryGenealogyLoad"
|
||||
>
|
||||
<text class="state-retry__copy">重新加载</text>
|
||||
</view>
|
||||
@@ -103,13 +102,13 @@
|
||||
|
||||
<view class="shortcut-grid">
|
||||
<view
|
||||
v-for="item in visibleShortcuts"
|
||||
:key="item.key"
|
||||
v-for="shortcut in visibleShortcuts"
|
||||
:key="shortcut.key"
|
||||
class="shortcut-item"
|
||||
@click="openShortcut(item.key)"
|
||||
@click="openShortcut(shortcut.key)"
|
||||
>
|
||||
<image class="shortcut-icon" :src="item.icon" mode="aspectFit" />
|
||||
<text class="shortcut-label">{{ item.label }}</text>
|
||||
<image class="shortcut-icon" :src="shortcut.icon" mode="aspectFit" />
|
||||
<text class="shortcut-label">{{ shortcut.label }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
@@ -127,63 +126,49 @@
|
||||
@scroll="handleListScroll"
|
||||
>
|
||||
<view class="genealogy-lower">
|
||||
<view v-if="createdGenealogies.length" class="list-section">
|
||||
<view v-if="managedGenealogies.length" class="list-section">
|
||||
<view class="section-heading"><text>我管理的</text></view>
|
||||
<GenealogyCard
|
||||
v-for="item in createdGenealogies"
|
||||
:key="item.id"
|
||||
:genealogy="item"
|
||||
v-for="genealogy in managedGenealogies"
|
||||
:key="genealogy.id"
|
||||
:genealogy="genealogy"
|
||||
role="管理员"
|
||||
:selected="item.id === currentGenealogy.id"
|
||||
:selected="genealogy.id === currentGenealogy.id"
|
||||
@select="openGenealogy"
|
||||
/>
|
||||
</view>
|
||||
|
||||
<view v-if="joinedGenealogies.length" class="list-section">
|
||||
<view v-if="memberGenealogies.length" class="list-section">
|
||||
<view class="section-heading"><text>我加入的</text></view>
|
||||
<GenealogyCard
|
||||
v-for="item in joinedGenealogies"
|
||||
:key="item.id"
|
||||
:genealogy="item"
|
||||
v-for="genealogy in memberGenealogies"
|
||||
:key="genealogy.id"
|
||||
:genealogy="genealogy"
|
||||
role="成员"
|
||||
:selected="item.id === currentGenealogy.id"
|
||||
:selected="genealogy.id === currentGenealogy.id"
|
||||
@select="openGenealogy"
|
||||
/>
|
||||
</view>
|
||||
|
||||
<view
|
||||
v-if="applicationRecords.length"
|
||||
class="list-section application-section"
|
||||
v-if="genealogies.length > 1"
|
||||
class="genealogy-order-trigger"
|
||||
role="button"
|
||||
aria-label="调整我的家谱排序"
|
||||
hover-class="action-hover"
|
||||
@click="openOrderDialog"
|
||||
>
|
||||
<view class="section-heading"><text>加入申请</text></view>
|
||||
<view
|
||||
v-for="item in applicationRecords"
|
||||
:key="item.id"
|
||||
class="application-record"
|
||||
hover-class="action-hover"
|
||||
@click="openApplication(item)"
|
||||
>
|
||||
<view class="application-record__main">
|
||||
<view class="application-record__title-row">
|
||||
<text class="application-record__name">{{
|
||||
item.name
|
||||
}}</text>
|
||||
<text
|
||||
class="application-record__status"
|
||||
:class="`application-record__status--${item.tone}`"
|
||||
>{{ item.statusLabel }}</text
|
||||
>
|
||||
</view>
|
||||
<text class="application-record__copy">{{
|
||||
item.description
|
||||
}}</text>
|
||||
</view>
|
||||
<image
|
||||
class="application-record__chevron"
|
||||
src="/static/assets/foundation/transparent/chevron-right.png"
|
||||
mode="aspectFit"
|
||||
/>
|
||||
<view>
|
||||
<text class="genealogy-order-trigger__title">调整家谱排序</text>
|
||||
<text class="genealogy-order-trigger__copy"
|
||||
>按上移、下移调整显示顺序</text
|
||||
>
|
||||
</view>
|
||||
<image
|
||||
class="genealogy-order-trigger__chevron"
|
||||
src="/static/assets/foundation/transparent/chevron-right.png"
|
||||
mode="aspectFit"
|
||||
/>
|
||||
</view>
|
||||
|
||||
<view class="create-action" @click="openAddDialog">
|
||||
@@ -204,6 +189,7 @@
|
||||
mode="aspectFit"
|
||||
/>
|
||||
</view>
|
||||
<AppPromotionStrip placement="home_bottom" title="家谱服务推荐" />
|
||||
</view>
|
||||
</scroll-view>
|
||||
</template>
|
||||
@@ -233,13 +219,6 @@
|
||||
>搜索家谱</text
|
||||
>
|
||||
</view>
|
||||
<view
|
||||
class="empty-invite-action"
|
||||
hover-class="action-hover"
|
||||
@click="joinByInvite"
|
||||
>
|
||||
<text class="empty-invite-action__copy">邀请码加入</text>
|
||||
</view>
|
||||
<view
|
||||
class="empty-create-action"
|
||||
hover-class="action-hover"
|
||||
@@ -255,110 +234,29 @@
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view
|
||||
v-if="addDialogVisible"
|
||||
class="add-dialog-layer"
|
||||
@click="closeAddDialog"
|
||||
>
|
||||
<view class="add-dialog" @click.stop>
|
||||
<view class="add-dialog__content">
|
||||
<view class="add-dialog__body">
|
||||
<view class="add-dialog__heading">
|
||||
<text class="dialog-title">添加家谱</text>
|
||||
<text class="dialog-copy">建议先搜索已有家谱,避免重复创建</text>
|
||||
<text v-if="quota" class="dialog-copy">{{
|
||||
quota.createRemaining === -1
|
||||
? "当前可继续创建家谱"
|
||||
: `还可创建 ${quota.createRemaining} 部家谱`
|
||||
}}</text>
|
||||
<view
|
||||
class="add-dialog__close"
|
||||
role="button"
|
||||
aria-label="关闭"
|
||||
hover-class="action-hover"
|
||||
@click="closeAddDialog"
|
||||
>
|
||||
<image
|
||||
class="add-dialog__close-icon"
|
||||
src="/static/assets/modules/genealogy/transparent/g01-dialog-close.png"
|
||||
mode="aspectFit"
|
||||
/>
|
||||
</view>
|
||||
</view>
|
||||
<view class="add-dialog__actions">
|
||||
<AppButton block label="搜索家谱" @click="applyToJoin" />
|
||||
<AppButton
|
||||
block
|
||||
type="secondary"
|
||||
label="邀请码加入"
|
||||
@click="joinByInvite"
|
||||
/>
|
||||
<AppButton
|
||||
block
|
||||
type="secondary"
|
||||
label="继续创建家谱"
|
||||
:disabled="quota?.canCreate === false"
|
||||
@click="createGenealogy"
|
||||
/>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<GenealogyAddDialog
|
||||
:visible="addDialogVisible"
|
||||
:creation-quota="creationQuota"
|
||||
@close="closeAddDialog"
|
||||
@search="applyToJoin"
|
||||
@create="createGenealogy"
|
||||
/>
|
||||
|
||||
<view
|
||||
v-if="switcherVisible"
|
||||
class="genealogy-switcher-layer"
|
||||
@click="closeSwitcher"
|
||||
>
|
||||
<view
|
||||
class="genealogy-switcher"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="切换当前家谱"
|
||||
@click.stop
|
||||
>
|
||||
<view class="genealogy-switcher__content">
|
||||
<text class="dialog-title">切换当前家谱</text>
|
||||
<view
|
||||
class="genealogy-switcher__close"
|
||||
role="button"
|
||||
aria-label="关闭"
|
||||
hover-class="action-hover"
|
||||
@click="closeSwitcher"
|
||||
>
|
||||
<image
|
||||
class="genealogy-switcher__close-icon"
|
||||
src="/static/assets/modules/genealogy/transparent/g01-dialog-close.png"
|
||||
mode="aspectFit"
|
||||
/>
|
||||
</view>
|
||||
<scroll-view class="genealogy-switcher__list" scroll-y>
|
||||
<button
|
||||
v-for="item in availableGenealogies"
|
||||
:key="item.id"
|
||||
class="switcher-item"
|
||||
:class="{
|
||||
'switcher-item--active': item.id === selectedGenealogyId,
|
||||
}"
|
||||
:aria-pressed="item.id === selectedGenealogyId"
|
||||
:aria-label="`${item.name},${item.location},${item.memberCount} 位成员`"
|
||||
@click="selectGenealogy(item)"
|
||||
>
|
||||
<view>
|
||||
<text class="switcher-item__name">{{ item.name }}</text>
|
||||
<text class="switcher-item__meta"
|
||||
>{{ item.location }} · {{ item.memberCount }} 位成员</text
|
||||
>
|
||||
</view>
|
||||
<text class="switcher-item__state">{{
|
||||
item.id === selectedGenealogyId ? "当前" : "选择"
|
||||
}}</text>
|
||||
</button>
|
||||
</scroll-view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<GenealogySwitcherDialog
|
||||
:visible="switcherVisible"
|
||||
:genealogies="genealogies"
|
||||
:selected-genealogy-id="selectedGenealogyId"
|
||||
@close="closeSwitcher"
|
||||
@select="selectGenealogy"
|
||||
/>
|
||||
|
||||
<GenealogyOrderDialog
|
||||
ref="genealogyOrderDialog"
|
||||
:visible="orderDialogVisible"
|
||||
:genealogies="genealogies"
|
||||
@close="closeOrderDialog"
|
||||
@saved="applySavedGenealogyOrder"
|
||||
/>
|
||||
|
||||
<AppTabbar active="genealogy" />
|
||||
</view>
|
||||
@@ -370,58 +268,58 @@ import { onBackPress, onLoad, onShow, onUnload } from "@dcloudio/uni-app";
|
||||
import AppLoading from "@/components/AppLoading.vue";
|
||||
import AppTabbar from "@/components/AppTabbar.vue";
|
||||
import AppButton from "@/components/AppButton.vue";
|
||||
import GenealogyCard from "@/components/GenealogyCard.vue";
|
||||
import GenealogyPageBackground from "@/components/GenealogyPageBackground.vue";
|
||||
import AppPromotionStrip from "@/components/AppPromotionStrip.vue";
|
||||
import GenealogyAddDialog from "@/components/genealogy/AddDialog.vue";
|
||||
import GenealogyCard from "@/components/genealogy/Card.vue";
|
||||
import GenealogyOrderDialog from "@/components/genealogy/OrderDialog.vue";
|
||||
import GenealogyPageBackground from "@/components/genealogy/PageBackground.vue";
|
||||
import GenealogySwitcherDialog from "@/components/genealogy/SwitcherDialog.vue";
|
||||
import PageHeader from "@/components/PageHeader.vue";
|
||||
import {
|
||||
appApi,
|
||||
createRequestController,
|
||||
isRequestCancelled,
|
||||
} from "@/utils/api.js";
|
||||
import { genealogyContext } from "@/utils/genealogy-context.js";
|
||||
isRequestCancelled
|
||||
} from "@/services/api/request-controller.js";
|
||||
import { genealogyApi } from "@/services/api/genealogy-service.js";
|
||||
import { notificationApi } from "@/services/api/notification-service.js";
|
||||
import { genealogyContext } from "@/utils/genealogy/context.js";
|
||||
import {
|
||||
consumeNavigationResult,
|
||||
handleBackPress,
|
||||
openPage,
|
||||
runBackGuard,
|
||||
} from "@/utils/navigation.js";
|
||||
} from "@/utils/navigation/gateway.js";
|
||||
|
||||
const isLoading = ref(false);
|
||||
const hasError = ref(false);
|
||||
const list = ref([]);
|
||||
const forceEmptyState = ref(false);
|
||||
const genealogies = ref([]);
|
||||
const contextInvalidated = ref(false);
|
||||
const contextReconcileFailed = ref(false);
|
||||
const requestedGenealogyId = ref("");
|
||||
const addDialogVisible = ref(false);
|
||||
const switcherVisible = ref(false);
|
||||
const orderDialogVisible = ref(false);
|
||||
const genealogyOrderDialog = ref(null);
|
||||
const selectedGenealogyId = ref(null);
|
||||
const listScrollCommand = ref(0);
|
||||
const currentListScrollTop = ref(0);
|
||||
const unreadCount = ref(0);
|
||||
const quota = ref(null);
|
||||
const listRequestController = createRequestController();
|
||||
const creationQuota = ref(null);
|
||||
const genealogyListRequestController = createRequestController();
|
||||
const unreadRequestController = createRequestController();
|
||||
const quotaRequestController = createRequestController();
|
||||
let loadGeneration = 0;
|
||||
// uni-app 的 abort 与成功回调可能在同一事件循环竞争。控制器负责取消任务,
|
||||
// generation 再阻止已经迟到的旧响应覆盖新页面状态,两层保护不能互相替代。
|
||||
let genealogyLoadGeneration = 0;
|
||||
let unreadLoadGeneration = 0;
|
||||
let quotaLoadGeneration = 0;
|
||||
let pageActive = true;
|
||||
let presentationState = "default";
|
||||
let skipNextShowRefresh = true;
|
||||
|
||||
const syncEmptyStateFromRoute = (query = {}) => {
|
||||
presentationState = ["empty", "loading", "error"].includes(query?.state)
|
||||
? query.state
|
||||
: "default";
|
||||
forceEmptyState.value = presentationState === "empty";
|
||||
isLoading.value = presentationState === "loading";
|
||||
hasError.value = presentationState === "error";
|
||||
};
|
||||
|
||||
const reconcilePageGenealogyContext = () => {
|
||||
try {
|
||||
const availableIds = list.value.map((item) => String(item.id));
|
||||
const availableIds = genealogies.value.map((genealogy) =>
|
||||
String(genealogy.id),
|
||||
);
|
||||
const previousId = genealogyContext.getCurrentGenealogyId();
|
||||
selectedGenealogyId.value =
|
||||
genealogyContext.reconcileCurrentGenealogyId(
|
||||
@@ -448,28 +346,28 @@ const reconcilePageGenealogyContext = () => {
|
||||
};
|
||||
|
||||
const loadGenealogies = async () => {
|
||||
const generation = ++loadGeneration;
|
||||
listRequestController.abort();
|
||||
const generation = ++genealogyLoadGeneration;
|
||||
genealogyListRequestController.abort();
|
||||
isLoading.value = true;
|
||||
hasError.value = false;
|
||||
try {
|
||||
const result = await appApi.getMyGenealogies({
|
||||
requestController: listRequestController,
|
||||
const loadedGenealogies = await genealogyApi.getMyGenealogies({
|
||||
requestController: genealogyListRequestController,
|
||||
});
|
||||
if (!pageActive || generation !== loadGeneration) return;
|
||||
list.value = result;
|
||||
if (!pageActive || generation !== genealogyLoadGeneration) return;
|
||||
genealogies.value = loadedGenealogies;
|
||||
reconcilePageGenealogyContext();
|
||||
} catch (error) {
|
||||
if (
|
||||
!pageActive ||
|
||||
generation !== loadGeneration ||
|
||||
generation !== genealogyLoadGeneration ||
|
||||
isRequestCancelled(error)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
hasError.value = true;
|
||||
} finally {
|
||||
if (pageActive && generation === loadGeneration) {
|
||||
if (pageActive && generation === genealogyLoadGeneration) {
|
||||
isLoading.value = false;
|
||||
}
|
||||
}
|
||||
@@ -479,7 +377,7 @@ const loadUnreadCount = async () => {
|
||||
const generation = ++unreadLoadGeneration;
|
||||
unreadRequestController.abort();
|
||||
try {
|
||||
const count = await appApi.getUnreadNotificationCount({
|
||||
const count = await notificationApi.getUnreadNotificationCount({
|
||||
requestController: unreadRequestController,
|
||||
});
|
||||
if (pageActive && generation === unreadLoadGeneration)
|
||||
@@ -493,40 +391,37 @@ const loadQuota = async () => {
|
||||
const generation = ++quotaLoadGeneration;
|
||||
quotaRequestController.abort();
|
||||
try {
|
||||
const result = await appApi.getGenealogyQuota({
|
||||
const genealogyQuota = await genealogyApi.getGenealogyQuota({
|
||||
requestController: quotaRequestController,
|
||||
});
|
||||
if (pageActive && generation === quotaLoadGeneration) quota.value = result;
|
||||
if (pageActive && generation === quotaLoadGeneration) {
|
||||
creationQuota.value = genealogyQuota;
|
||||
}
|
||||
} catch (error) {
|
||||
if (
|
||||
!isRequestCancelled(error) &&
|
||||
pageActive &&
|
||||
generation === quotaLoadGeneration
|
||||
)
|
||||
quota.value = null;
|
||||
creationQuota.value = null;
|
||||
}
|
||||
};
|
||||
|
||||
onLoad((query) => {
|
||||
syncEmptyStateFromRoute(query);
|
||||
requestedGenealogyId.value = String(query?.genealogyId || "");
|
||||
if (presentationState === "default") {
|
||||
loadGenealogies();
|
||||
loadUnreadCount();
|
||||
loadQuota();
|
||||
} else {
|
||||
reconcilePageGenealogyContext();
|
||||
}
|
||||
loadGenealogies();
|
||||
loadUnreadCount();
|
||||
loadQuota();
|
||||
});
|
||||
|
||||
onShow(() => {
|
||||
const result = consumeNavigationResult("G01");
|
||||
const navigationResult = consumeNavigationResult("G01");
|
||||
if (
|
||||
result?.operation === "genealogy-created" &&
|
||||
result.refresh &&
|
||||
result.entityId
|
||||
navigationResult?.operation === "genealogy-created" &&
|
||||
navigationResult.refresh &&
|
||||
navigationResult.entityId
|
||||
) {
|
||||
requestedGenealogyId.value = result.entityId;
|
||||
requestedGenealogyId.value = navigationResult.entityId;
|
||||
loadGenealogies();
|
||||
return;
|
||||
}
|
||||
@@ -534,24 +429,22 @@ onShow(() => {
|
||||
skipNextShowRefresh = false;
|
||||
return;
|
||||
}
|
||||
if (presentationState === "default") loadGenealogies();
|
||||
if (presentationState === "default") loadUnreadCount();
|
||||
if (presentationState === "default") loadQuota();
|
||||
loadGenealogies();
|
||||
loadUnreadCount();
|
||||
loadQuota();
|
||||
});
|
||||
|
||||
onUnload(() => {
|
||||
pageActive = false;
|
||||
loadGeneration += 1;
|
||||
genealogyLoadGeneration += 1;
|
||||
unreadLoadGeneration += 1;
|
||||
quotaLoadGeneration += 1;
|
||||
listRequestController.abort();
|
||||
genealogyListRequestController.abort();
|
||||
unreadRequestController.abort();
|
||||
quotaRequestController.abort();
|
||||
});
|
||||
|
||||
const hasGenealogies = computed(
|
||||
() => !forceEmptyState.value && list.value.length > 0,
|
||||
);
|
||||
const hasGenealogies = computed(() => genealogies.value.length > 0);
|
||||
const isListLayout = computed(
|
||||
() =>
|
||||
!isLoading.value &&
|
||||
@@ -559,29 +452,25 @@ const isListLayout = computed(
|
||||
!contextInvalidated.value &&
|
||||
hasGenealogies.value,
|
||||
);
|
||||
const createdGenealogies = computed(() =>
|
||||
list.value.filter((item) => item.accessRole === "owner"),
|
||||
const managedGenealogies = computed(() =>
|
||||
genealogies.value.filter((genealogy) => genealogy.canManage),
|
||||
);
|
||||
const joinedGenealogies = computed(() =>
|
||||
list.value.filter((item) => item.accessRole === "member"),
|
||||
const memberGenealogies = computed(() =>
|
||||
genealogies.value.filter((genealogy) => !genealogy.canManage),
|
||||
);
|
||||
const availableGenealogies = computed(() => list.value);
|
||||
const currentGenealogy = computed(
|
||||
() =>
|
||||
availableGenealogies.value.find(
|
||||
(item) => item.id === selectedGenealogyId.value,
|
||||
genealogies.value.find(
|
||||
(genealogy) => genealogy.id === selectedGenealogyId.value,
|
||||
) || null,
|
||||
);
|
||||
const isCurrentGenealogyOwner = computed(
|
||||
() => currentGenealogy.value?.accessRole === "owner",
|
||||
const canManageCurrentGenealogy = computed(
|
||||
() => currentGenealogy.value?.canManage === true,
|
||||
);
|
||||
const currentRoleLabel = computed(() =>
|
||||
isCurrentGenealogyOwner.value ? "管理员" : "成员",
|
||||
canManageCurrentGenealogy.value ? "管理员" : "成员",
|
||||
);
|
||||
|
||||
// 普通加入申请批次接通前不展示本地伪记录。
|
||||
const applicationRecords = ref([]);
|
||||
|
||||
const shortcuts = [
|
||||
{
|
||||
key: "tree",
|
||||
@@ -605,9 +494,9 @@ const shortcuts = [
|
||||
},
|
||||
];
|
||||
const visibleShortcuts = computed(() =>
|
||||
isCurrentGenealogyOwner.value
|
||||
canManageCurrentGenealogy.value
|
||||
? shortcuts
|
||||
: shortcuts.filter((item) => item.key !== "applications"),
|
||||
: shortcuts.filter((shortcut) => shortcut.key !== "applications"),
|
||||
);
|
||||
|
||||
const openGenealogy = (genealogy) =>
|
||||
@@ -628,10 +517,6 @@ const applyToJoin = () => {
|
||||
closeAddDialog();
|
||||
return openPage("G06", {}, "G01");
|
||||
};
|
||||
const joinByInvite = () => {
|
||||
closeAddDialog();
|
||||
return openPage("G06", { mode: "invite" }, "G01");
|
||||
};
|
||||
const toNotifications = () => {
|
||||
const notificationParams = currentGenealogy.value
|
||||
? { genealogyId: String(currentGenealogy.value.id) }
|
||||
@@ -651,17 +536,39 @@ const openSwitcher = () => {
|
||||
const closeSwitcher = () => {
|
||||
switcherVisible.value = false;
|
||||
};
|
||||
const openOrderDialog = () => {
|
||||
if (genealogies.value.length < 2) return;
|
||||
orderDialogVisible.value = true;
|
||||
};
|
||||
const closeOrderDialog = () => {
|
||||
orderDialogVisible.value = false;
|
||||
};
|
||||
const applySavedGenealogyOrder = async (confirmedGenealogies) => {
|
||||
if (!pageActive) return;
|
||||
genealogies.value = confirmedGenealogies;
|
||||
reconcilePageGenealogyContext();
|
||||
orderDialogVisible.value = false;
|
||||
await resetListScroll();
|
||||
};
|
||||
const closeActiveOverlay = () => {
|
||||
if (switcherVisible.value) closeSwitcher();
|
||||
else if (orderDialogVisible.value) genealogyOrderDialog.value?.requestClose();
|
||||
else closeAddDialog();
|
||||
};
|
||||
const requestBack = () =>
|
||||
runBackGuard({
|
||||
transientOpen: switcherVisible.value || addDialogVisible.value,
|
||||
transientOpen:
|
||||
switcherVisible.value || addDialogVisible.value || orderDialogVisible.value,
|
||||
"close-transient": closeActiveOverlay,
|
||||
});
|
||||
onBackPress((event) => {
|
||||
if (!switcherVisible.value && !addDialogVisible.value) return false;
|
||||
if (
|
||||
!switcherVisible.value &&
|
||||
!addDialogVisible.value &&
|
||||
!orderDialogVisible.value
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
return handleBackPress(event, requestBack);
|
||||
});
|
||||
const handleListScroll = (event) => {
|
||||
@@ -680,23 +587,11 @@ const selectGenealogy = async (genealogy) => {
|
||||
closeSwitcher();
|
||||
await resetListScroll();
|
||||
};
|
||||
const openApplication = (record) => {
|
||||
if (record.id === "pending")
|
||||
return openPage("G09", { status: "pending" }, "G01");
|
||||
return openPage(
|
||||
"G08",
|
||||
{
|
||||
genealogyId: String(record.genealogyId),
|
||||
source: "search",
|
||||
},
|
||||
"G01",
|
||||
);
|
||||
};
|
||||
const retryLoad = () => {
|
||||
const retryGenealogyLoad = () => {
|
||||
loadGenealogies();
|
||||
};
|
||||
|
||||
const openShortcut = (key) => {
|
||||
const openShortcut = (shortcutKey) => {
|
||||
if (!currentGenealogy.value) return;
|
||||
const genealogyId = String(currentGenealogy.value.id);
|
||||
const actions = {
|
||||
@@ -705,7 +600,7 @@ const openShortcut = (key) => {
|
||||
poem: () => openPage("G12", { genealogyId }, "G01"),
|
||||
applications: () => openPage("G10", { genealogyId }, "G01"),
|
||||
};
|
||||
return actions[key]?.();
|
||||
return actions[shortcutKey]?.();
|
||||
};
|
||||
</script>
|
||||
|
||||
@@ -923,57 +818,41 @@ const openShortcut = (key) => {
|
||||
margin-top: 18rpx;
|
||||
}
|
||||
|
||||
.application-record {
|
||||
@include adaptive-genealogy-list-card;
|
||||
display: flex;
|
||||
min-height: 138rpx;
|
||||
align-items: center;
|
||||
padding: 22rpx 26rpx;
|
||||
}
|
||||
.application-record + .application-record {
|
||||
margin-top: 12rpx;
|
||||
}
|
||||
.application-record__main {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
.application-record__title-row {
|
||||
.genealogy-order-trigger {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 20rpx;
|
||||
min-height: 112rpx;
|
||||
margin: 20rpx 0 24rpx;
|
||||
padding: 24rpx 28rpx;
|
||||
border: 1rpx solid rgba(149, 103, 49, 0.24);
|
||||
border-radius: 14rpx;
|
||||
background: rgba(255, 250, 240, 0.72);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.application-record__name {
|
||||
color: $ink;
|
||||
font-family: "STKaiti", "KaiTi", serif;
|
||||
font-size: clamp(17px, 32rpx, 22px);
|
||||
font-weight: 700;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.application-record__status {
|
||||
flex: 0 0 auto;
|
||||
margin-left: 16rpx;
|
||||
color: #7f4f16;
|
||||
font-size: clamp(16px, 27rpx, 20px);
|
||||
font-weight: 600;
|
||||
}
|
||||
.application-record__status--rejected {
|
||||
color: #a7160c;
|
||||
}
|
||||
.application-record__status--muted {
|
||||
color: #62584c;
|
||||
}
|
||||
.application-record__copy {
|
||||
|
||||
.genealogy-order-trigger__title,
|
||||
.genealogy-order-trigger__copy {
|
||||
display: block;
|
||||
margin-top: 10rpx;
|
||||
color: #62584c;
|
||||
font-size: clamp(16px, 28rpx, 20px);
|
||||
font-weight: 500;
|
||||
line-height: 1.4;
|
||||
}
|
||||
.application-record__chevron {
|
||||
width: 32rpx;
|
||||
height: 32rpx;
|
||||
margin-left: 14rpx;
|
||||
|
||||
.genealogy-order-trigger__title {
|
||||
color: #5c4330;
|
||||
font-size: clamp(16px, 28rpx, 20px);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.genealogy-order-trigger__copy {
|
||||
margin-top: 8rpx;
|
||||
color: #8a7564;
|
||||
font-size: clamp(14px, 23rpx, 17px);
|
||||
}
|
||||
|
||||
.genealogy-order-trigger__chevron {
|
||||
width: 26rpx;
|
||||
height: 26rpx;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.create-action {
|
||||
@@ -1169,8 +1048,7 @@ const openShortcut = (key) => {
|
||||
margin-top: 26rpx;
|
||||
}
|
||||
|
||||
.empty-search-action,
|
||||
.empty-invite-action {
|
||||
.empty-search-action {
|
||||
display: flex;
|
||||
width: 560rpx;
|
||||
min-height: 124rpx;
|
||||
@@ -1180,18 +1058,11 @@ const openShortcut = (key) => {
|
||||
|
||||
.empty-search-action {
|
||||
margin-top: 26rpx;
|
||||
background: url("/static/assets/foundation/transparent/a01-scroll-primary-v3.png")
|
||||
background: url("/static/assets/foundation/transparent/scroll-primary.png")
|
||||
center / contain no-repeat;
|
||||
}
|
||||
|
||||
.empty-invite-action {
|
||||
margin-top: 22rpx;
|
||||
background: url("/static/assets/foundation/transparent/a01-scroll-secondary-v3.png")
|
||||
center / contain no-repeat;
|
||||
}
|
||||
|
||||
.empty-search-action__copy,
|
||||
.empty-invite-action__copy {
|
||||
.empty-search-action__copy {
|
||||
color: #7b4e24;
|
||||
font-family: "STKaiti", "KaiTi", serif;
|
||||
font-size: clamp(19px, 34rpx, 24px);
|
||||
@@ -1227,7 +1098,7 @@ const openShortcut = (key) => {
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-top: 30rpx;
|
||||
background: url("/static/assets/foundation/transparent/a01-scroll-primary-v3.png")
|
||||
background: url("/static/assets/foundation/transparent/scroll-primary.png")
|
||||
center / contain no-repeat;
|
||||
}
|
||||
.state-retry__copy {
|
||||
@@ -1236,189 +1107,6 @@ const openShortcut = (key) => {
|
||||
font-size: clamp(16px, 29rpx, 20px);
|
||||
}
|
||||
|
||||
.add-dialog-layer {
|
||||
position: fixed;
|
||||
z-index: 40;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: center;
|
||||
box-sizing: border-box;
|
||||
background: rgba(34, 20, 12, 0.68);
|
||||
}
|
||||
.add-dialog {
|
||||
@include adaptive-g01-add-sheet;
|
||||
width: 100%;
|
||||
min-height: 780rpx;
|
||||
max-height: calc(100vh - 80rpx);
|
||||
}
|
||||
.add-dialog__content {
|
||||
display: flex;
|
||||
min-height: 780rpx;
|
||||
max-height: calc(100vh - 80rpx);
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
box-sizing: border-box;
|
||||
padding: 96rpx 52rpx calc(96rpx + env(safe-area-inset-bottom));
|
||||
overflow-y: auto;
|
||||
}
|
||||
.add-dialog__body {
|
||||
margin: auto 0;
|
||||
}
|
||||
.add-dialog__heading {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 96rpx;
|
||||
}
|
||||
.add-dialog .dialog-title,
|
||||
.add-dialog .dialog-copy {
|
||||
display: block;
|
||||
grid-column: 1;
|
||||
text-align: left;
|
||||
}
|
||||
.add-dialog__close {
|
||||
display: flex;
|
||||
grid-column: 2;
|
||||
grid-row: 1 / span 2;
|
||||
align-self: start;
|
||||
justify-self: end;
|
||||
width: 80rpx;
|
||||
height: 80rpx;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-right: -22rpx;
|
||||
}
|
||||
.add-dialog__close-icon {
|
||||
width: 80rpx;
|
||||
height: 80rpx;
|
||||
}
|
||||
.add-dialog__actions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
margin: 62rpx -32rpx 0;
|
||||
}
|
||||
.add-dialog__actions > .app-button {
|
||||
width: 595rpx;
|
||||
max-width: 100%;
|
||||
min-height: 96rpx;
|
||||
}
|
||||
.add-dialog__actions > .app-button + .app-button {
|
||||
margin-top: 24rpx;
|
||||
}
|
||||
|
||||
.genealogy-switcher-layer {
|
||||
position: fixed;
|
||||
z-index: 40;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-sizing: border-box;
|
||||
padding: 40rpx;
|
||||
background: rgba(34, 20, 12, 0.58);
|
||||
}
|
||||
.genealogy-switcher {
|
||||
@include adaptive-g01-switcher;
|
||||
width: 670rpx;
|
||||
max-width: 100%;
|
||||
min-height: 600rpx;
|
||||
max-height: calc(100vh - 120rpx);
|
||||
}
|
||||
.genealogy-switcher__content {
|
||||
display: grid;
|
||||
min-height: 600rpx;
|
||||
max-height: calc(100vh - 120rpx);
|
||||
grid-template-rows: auto minmax(0, 1fr);
|
||||
align-items: center;
|
||||
box-sizing: border-box;
|
||||
padding: 120rpx 58rpx 140rpx;
|
||||
}
|
||||
.genealogy-switcher__content > .dialog-title {
|
||||
grid-area: 1 / 1;
|
||||
}
|
||||
.genealogy-switcher__close {
|
||||
display: flex;
|
||||
grid-area: 1 / 1;
|
||||
align-self: start;
|
||||
justify-self: end;
|
||||
width: 80rpx;
|
||||
height: 80rpx;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-top: -42rpx;
|
||||
margin-right: -24rpx;
|
||||
}
|
||||
.genealogy-switcher__close-icon {
|
||||
width: 80rpx;
|
||||
height: 80rpx;
|
||||
}
|
||||
.genealogy-switcher__list {
|
||||
grid-area: 2 / 1;
|
||||
width: 100%;
|
||||
min-height: 0;
|
||||
max-height: calc(100vh - 456rpx);
|
||||
margin-top: 24rpx;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.dialog-title {
|
||||
color: $brand-red;
|
||||
font-family: "STKaiti", "KaiTi", serif;
|
||||
font-size: clamp(22px, 42rpx, 28px);
|
||||
font-weight: 700;
|
||||
letter-spacing: 4rpx;
|
||||
}
|
||||
.dialog-copy {
|
||||
margin-top: 12rpx;
|
||||
color: $ink-muted;
|
||||
font-size: clamp(15px, 25rpx, 18px);
|
||||
line-height: 1.5;
|
||||
}
|
||||
.switcher-item {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
min-height: 112rpx;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 18rpx 16rpx;
|
||||
border: 1rpx solid transparent;
|
||||
border-bottom-color: rgba(181, 138, 75, 0.42);
|
||||
background: transparent;
|
||||
line-height: normal;
|
||||
text-align: left;
|
||||
}
|
||||
.switcher-item::after {
|
||||
border: 0;
|
||||
}
|
||||
.switcher-item__name,
|
||||
.switcher-item__meta {
|
||||
display: block;
|
||||
}
|
||||
.switcher-item__name {
|
||||
color: $ink;
|
||||
font-family: "STKaiti", "KaiTi", serif;
|
||||
font-size: clamp(17px, 32rpx, 22px);
|
||||
font-weight: 700;
|
||||
}
|
||||
.switcher-item__meta {
|
||||
margin-top: 6rpx;
|
||||
color: #62584c;
|
||||
font-size: clamp(15px, 24rpx, 18px);
|
||||
}
|
||||
.switcher-item__state {
|
||||
color: $brand-red;
|
||||
font-size: clamp(15px, 25rpx, 18px);
|
||||
font-weight: 600;
|
||||
}
|
||||
.switcher-item--active {
|
||||
border-color: rgba(159, 23, 15, 0.22);
|
||||
background: rgba(159, 23, 15, 0.055);
|
||||
}
|
||||
.switcher-item--active .switcher-item__name {
|
||||
color: $brand-red;
|
||||
}
|
||||
|
||||
.action-hover {
|
||||
opacity: 0.82;
|
||||
}
|
||||
@@ -1,16 +1,9 @@
|
||||
<!-- 页面编号:G-05;用途:家谱总览与加载、空数据、失败状态。 -->
|
||||
<template>
|
||||
<view class="overview-page">
|
||||
<GenealogyPageBackground />
|
||||
<view class="overview-page__header">
|
||||
<PageHeader
|
||||
:title="
|
||||
viewMode === 'public'
|
||||
? '家谱公开预览'
|
||||
: viewMode === 'preview'
|
||||
? '创建流程预览'
|
||||
: '家谱总览'
|
||||
"
|
||||
title="家谱总览"
|
||||
custom-back
|
||||
@back="requestBack"
|
||||
/>
|
||||
@@ -20,13 +13,10 @@
|
||||
class="overview-surface"
|
||||
:class="{
|
||||
'overview-surface--state': overviewState !== 'ready',
|
||||
'overview-surface--member':
|
||||
overviewState === 'ready' && viewMode === 'member',
|
||||
'overview-surface--member': overviewState === 'ready',
|
||||
}"
|
||||
>
|
||||
<template
|
||||
v-if="overviewState === 'ready' && genealogy && viewMode === 'member'"
|
||||
>
|
||||
<template v-if="overviewState === 'ready' && genealogy">
|
||||
<view class="overview-ready">
|
||||
<view class="overview-hero">
|
||||
<view class="overview-hero__identity">
|
||||
@@ -48,7 +38,7 @@
|
||||
genealogy.intro || "简介待补充"
|
||||
}}</text>
|
||||
<view class="overview-hero__stats">
|
||||
<text>共 {{ genealogy.memberCount || 0 }} 人</text>
|
||||
<text>已加入 {{ genealogy.memberCount || 0 }} 人</text>
|
||||
<text v-if="genealogy.personCount !== null"
|
||||
>世系 {{ genealogy.personCount }} 人</text
|
||||
>
|
||||
@@ -79,8 +69,40 @@
|
||||
mode="aspectFit"
|
||||
/>
|
||||
</view>
|
||||
<view class="overview-action" @click="openInvitationManager">
|
||||
<image
|
||||
class="overview-action__icon"
|
||||
src="/static/assets/modules/genealogy/transparent/shortcut-members.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" @click="toMembers">
|
||||
<image
|
||||
class="overview-action__icon"
|
||||
src="/static/assets/modules/genealogy/transparent/shortcut-members.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'"
|
||||
v-if="canManageGenealogy"
|
||||
class="overview-action overview-action--review"
|
||||
@click="toApplications"
|
||||
>
|
||||
@@ -119,13 +141,13 @@
|
||||
/>
|
||||
</view>
|
||||
<view
|
||||
v-if="accessRole === 'owner'"
|
||||
v-if="canManageGenealogy"
|
||||
class="overview-action overview-action--settings"
|
||||
@click="toSettings"
|
||||
>
|
||||
<image
|
||||
class="overview-action__icon"
|
||||
src="/static/assets/modules/genealogy/transparent/g05-settings-gear.png"
|
||||
src="/static/assets/modules/genealogy/transparent/settings-gear.png"
|
||||
mode="aspectFit"
|
||||
/>
|
||||
<view class="overview-action__copy">
|
||||
@@ -169,71 +191,6 @@
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<view
|
||||
v-else-if="overviewState === 'ready' && genealogy"
|
||||
class="overview-public"
|
||||
>
|
||||
<view class="overview-public__hero">
|
||||
<text class="overview-public__eyebrow">{{
|
||||
viewMode === "preview" ? "本地流程预览" : "公开家谱"
|
||||
}}</text>
|
||||
<text class="overview-public__title">{{ genealogy.name }}</text>
|
||||
<text class="overview-public__source">{{
|
||||
genealogy.source || "来源信息待同步"
|
||||
}}</text>
|
||||
</view>
|
||||
<view class="overview-public__details">
|
||||
<view
|
||||
><text>姓氏</text><text>{{ genealogy.surname }}氏</text></view
|
||||
>
|
||||
<view
|
||||
><text>地区</text
|
||||
><text>{{ genealogy.location || "待补充" }}</text></view
|
||||
>
|
||||
<view
|
||||
><text>堂号</text
|
||||
><text>{{ genealogy.hall || "待补充" }}</text></view
|
||||
>
|
||||
<view
|
||||
><text>当前支系</text
|
||||
><text>{{ genealogy.branchName || "待补充" }}</text></view
|
||||
>
|
||||
<view
|
||||
><text>{{
|
||||
viewMode === "preview" ? "首代人物" : "所属上级谱"
|
||||
}}</text
|
||||
><text>{{
|
||||
(viewMode === "preview"
|
||||
? genealogy.ancestorName
|
||||
: genealogy.parentName) || "待补充"
|
||||
}}</text></view
|
||||
>
|
||||
</view>
|
||||
<view
|
||||
v-if="
|
||||
viewMode === 'public' &&
|
||||
(genealogy.manager || genealogy.certification)
|
||||
"
|
||||
class="overview-public__trust"
|
||||
>
|
||||
<text>{{ genealogy.manager || "管理者待确认" }}</text>
|
||||
<text>{{ genealogy.certification || "认证信息待确认" }}</text>
|
||||
<text>{{ genealogy.memberCount || 0 }} 位成员</text>
|
||||
<text>更新于 {{ genealogy.updatedAt || "待同步" }}</text>
|
||||
</view>
|
||||
<view class="overview-public__notice">
|
||||
<text>{{ viewMode === "preview" ? "预览说明" : "公开说明" }}</text>
|
||||
<text>{{ genealogy.publicDescription || "公开说明待补充" }}</text>
|
||||
</view>
|
||||
<view
|
||||
v-if="viewMode === 'public' && publicActionLabel"
|
||||
class="overview-public__action"
|
||||
@click="applyToJoin"
|
||||
>
|
||||
<text>{{ publicActionLabel }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view
|
||||
v-else-if="overviewState === 'loading'"
|
||||
class="overview-state overview-state--loading"
|
||||
@@ -274,6 +231,12 @@
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<InvitationManager
|
||||
ref="invitationManager"
|
||||
:genealogy-id="genealogyId"
|
||||
@busy-change="invitationBusy = $event"
|
||||
@transient-change="invitationTransientOpen = $event"
|
||||
/>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
@@ -281,45 +244,36 @@
|
||||
import { computed, ref } from "vue";
|
||||
import { onBackPress, onLoad, onShow, onUnload } from "@dcloudio/uni-app";
|
||||
import AppLoading from "@/components/AppLoading.vue";
|
||||
import GenealogyPageBackground from "@/components/GenealogyPageBackground.vue";
|
||||
import InvitationManager from "@/components/genealogy/InvitationManager.vue";
|
||||
import GenealogyPageBackground from "@/components/genealogy/PageBackground.vue";
|
||||
import PageHeader from "@/components/PageHeader.vue";
|
||||
import {
|
||||
appApi,
|
||||
createRequestController,
|
||||
isRequestCancelled,
|
||||
} from "@/utils/api.js";
|
||||
import { getGenealogyAccessPresetLabel } from "@/utils/genealogy-contracts.js";
|
||||
isRequestCancelled
|
||||
} from "@/services/api/request-controller.js";
|
||||
import { genealogyApi } from "@/services/api/genealogy-service.js";
|
||||
import { getRequestErrorMessage } from "@/services/api/request-error-message.js";
|
||||
import { getGenealogyAccessPresetLabel } from "@/utils/genealogy/access-policy.js";
|
||||
import {
|
||||
goBack,
|
||||
goRoot,
|
||||
handleBackPress,
|
||||
openPage,
|
||||
returnTo,
|
||||
} from "@/utils/navigation.js";
|
||||
} from "@/utils/navigation/gateway.js";
|
||||
|
||||
const genealogy = ref(null);
|
||||
const genealogyId = ref("");
|
||||
const overviewState = ref("loading");
|
||||
const loadError = ref("");
|
||||
const viewMode = ref("member");
|
||||
const accessRole = ref("guest");
|
||||
const publicRelation = ref("unknown");
|
||||
const publicCanApply = ref(false);
|
||||
const overviewRequestController = createRequestController();
|
||||
const overviewLoadError = ref("");
|
||||
const canManageGenealogy = ref(false);
|
||||
const invitationManager = ref(null);
|
||||
const invitationBusy = ref(false);
|
||||
const invitationTransientOpen = ref(false);
|
||||
const genealogyOverviewRequestController = createRequestController();
|
||||
let loadGeneration = 0;
|
||||
let pageActive = true;
|
||||
let skipNextShowRefresh = true;
|
||||
const publicActionLabel = computed(() => {
|
||||
if (publicRelation.value === "pending") return "查看申请进度";
|
||||
if (!publicCanApply.value) return "";
|
||||
return (
|
||||
{
|
||||
available: "申请加入这部家谱",
|
||||
rejected: "修改后重新申请",
|
||||
removed: "重新申请加入",
|
||||
}[publicRelation.value] || ""
|
||||
);
|
||||
});
|
||||
|
||||
const stateTitle = computed(
|
||||
() =>
|
||||
@@ -327,17 +281,17 @@ const stateTitle = computed(
|
||||
loading: "正在展开家谱…",
|
||||
empty: "还没有可查看的家谱",
|
||||
error: "家谱暂时无法打开",
|
||||
"no-permission": "当前账号无权查看",
|
||||
"no-permission": "暂时无法查看",
|
||||
})[overviewState.value] || "",
|
||||
);
|
||||
const stateCopy = computed(
|
||||
() =>
|
||||
loadError.value ||
|
||||
overviewLoadError.value ||
|
||||
{
|
||||
loading: "请稍候,正在读取家谱概览。",
|
||||
loading: "请稍候,正在加载家谱概览。",
|
||||
empty: "请从“我的家谱”选择一部家谱后再进入。",
|
||||
error: "可能是网络波动或家谱不存在。",
|
||||
"no-permission": "这部家谱未向当前账号开放,请返回我的家谱或联系管理员。",
|
||||
"no-permission": "这部家谱暂未向你开放,请返回我的家谱或联系管理员。",
|
||||
}[overviewState.value] ||
|
||||
"",
|
||||
);
|
||||
@@ -347,38 +301,25 @@ const formatGenealogyTime = (value) =>
|
||||
|
||||
const loadGenealogy = async (query = {}) => {
|
||||
const generation = ++loadGeneration;
|
||||
overviewRequestController.abort();
|
||||
genealogyOverviewRequestController.abort();
|
||||
overviewState.value = "loading";
|
||||
loadError.value = "";
|
||||
overviewLoadError.value = "";
|
||||
genealogy.value = null;
|
||||
genealogyId.value = String(query.genealogyId || genealogyId.value || "");
|
||||
viewMode.value = "member";
|
||||
accessRole.value = "guest";
|
||||
publicRelation.value = "unknown";
|
||||
publicCanApply.value = false;
|
||||
canManageGenealogy.value = false;
|
||||
|
||||
if (query.state === "empty" || !genealogyId.value) {
|
||||
if (!genealogyId.value) {
|
||||
overviewState.value = "empty";
|
||||
return;
|
||||
}
|
||||
if (query.state === "error") {
|
||||
overviewState.value = "error";
|
||||
return;
|
||||
}
|
||||
if (query.state === "no-permission") {
|
||||
overviewState.value = "no-permission";
|
||||
return;
|
||||
}
|
||||
if (query.state === "loading") return;
|
||||
|
||||
try {
|
||||
const result = await appApi.getOverview(genealogyId.value, {
|
||||
requestController: overviewRequestController,
|
||||
const overviewDetails = await genealogyApi.getOverview(genealogyId.value, {
|
||||
requestController: genealogyOverviewRequestController,
|
||||
});
|
||||
if (!pageActive || generation !== loadGeneration) return;
|
||||
genealogy.value = result;
|
||||
viewMode.value = "member";
|
||||
accessRole.value = result.accessRole;
|
||||
genealogy.value = overviewDetails;
|
||||
canManageGenealogy.value = overviewDetails.canManage;
|
||||
overviewState.value = "ready";
|
||||
} catch (error) {
|
||||
if (
|
||||
@@ -392,13 +333,11 @@ const loadGenealogy = async (query = {}) => {
|
||||
overviewState.value = "no-permission";
|
||||
return;
|
||||
}
|
||||
loadError.value = error?.message || "家谱概览读取失败,请稍后重试。";
|
||||
overviewLoadError.value = getRequestErrorMessage(error, "家谱概览加载失败,请稍后重试。");
|
||||
overviewState.value = "error";
|
||||
}
|
||||
};
|
||||
|
||||
const requestBack = () => goBack();
|
||||
onBackPress((event) => handleBackPress(event, requestBack));
|
||||
onLoad(loadGenealogy);
|
||||
onShow(() => {
|
||||
if (skipNextShowRefresh) {
|
||||
@@ -410,7 +349,7 @@ onShow(() => {
|
||||
onUnload(() => {
|
||||
pageActive = false;
|
||||
loadGeneration += 1;
|
||||
overviewRequestController.abort();
|
||||
genealogyOverviewRequestController.abort();
|
||||
});
|
||||
const reloadOverview = () => loadGenealogy({ genealogyId: genealogyId.value });
|
||||
const toGenealogies = () => returnTo("G01", {});
|
||||
@@ -422,24 +361,24 @@ const toSettings = () =>
|
||||
openPage("G11", { genealogyId: genealogyId.value }, "G05");
|
||||
const toGenerationPoems = () =>
|
||||
openPage("G12", { genealogyId: genealogyId.value }, "G05");
|
||||
const applyToJoin = () => {
|
||||
if (publicRelation.value === "pending")
|
||||
return openPage("G09", { status: "pending" }, "G05");
|
||||
if (!publicCanApply.value) return false;
|
||||
return openPage(
|
||||
"G08",
|
||||
{
|
||||
genealogyId: genealogyId.value,
|
||||
genealogyName: genealogy.name,
|
||||
source: "search",
|
||||
},
|
||||
"G05",
|
||||
);
|
||||
const toMembers = () =>
|
||||
openPage("G13", { genealogyId: genealogyId.value }, "G05");
|
||||
const openInvitationManager = () => {
|
||||
if (overviewState.value !== "ready" || !genealogyId.value) return;
|
||||
invitationManager.value?.open();
|
||||
};
|
||||
const requestBack = () => {
|
||||
if (invitationBusy.value || invitationTransientOpen.value) {
|
||||
invitationManager.value?.closeTransient();
|
||||
return true;
|
||||
}
|
||||
return goBack();
|
||||
};
|
||||
onBackPress((event) => handleBackPress(event, requestBack));
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@import "../../styles/adaptive-frame-profiles.scss";
|
||||
@use "../../styles/adaptive-frame-profiles.scss" as adaptive;
|
||||
.overview-page {
|
||||
display: flex;
|
||||
min-height: 100vh;
|
||||
@@ -450,7 +389,7 @@ const applyToJoin = () => {
|
||||
z-index: 3;
|
||||
}
|
||||
.overview-surface {
|
||||
@include adaptive-g05-overview-surface;
|
||||
@include adaptive.adaptive-genealogy-overview-surface;
|
||||
z-index: 2;
|
||||
width: 100%;
|
||||
min-height: min(483px, calc(100vw * 1.3422));
|
||||
@@ -485,8 +424,8 @@ const applyToJoin = () => {
|
||||
margin: 0;
|
||||
padding: 34rpx 42rpx 28rpx;
|
||||
background: #10293b
|
||||
url("/static/assets/modules/genealogy/opaque/g05-overview-surface.png")
|
||||
center top / auto 1220rpx no-repeat;
|
||||
url("/static/assets/modules/genealogy/opaque/overview-surface.png")
|
||||
center top / auto 400% no-repeat;
|
||||
color: #fff8ec;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
@@ -667,7 +606,7 @@ const applyToJoin = () => {
|
||||
font-weight: 700;
|
||||
}
|
||||
.overview-state {
|
||||
@include adaptive-genealogy-state-panel;
|
||||
@include adaptive.adaptive-genealogy-state-panel;
|
||||
display: flex;
|
||||
width: 100%;
|
||||
min-height: 0;
|
||||
@@ -715,7 +654,7 @@ const applyToJoin = () => {
|
||||
margin-top: 34rpx;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: url("/static/assets/foundation/transparent/a01-scroll-primary-v3.png")
|
||||
background: url("/static/assets/foundation/transparent/scroll-primary.png")
|
||||
center / contain no-repeat;
|
||||
}
|
||||
.overview-state-panel__action text {
|
||||
@@ -724,112 +663,6 @@ const applyToJoin = () => {
|
||||
font-size: clamp(17px, 31rpx, 22px);
|
||||
font-weight: 700;
|
||||
}
|
||||
.overview-public {
|
||||
display: flex;
|
||||
min-height: min(540px, calc(100vw * 1.5));
|
||||
flex-direction: column;
|
||||
gap: 0;
|
||||
padding: 58rpx 0 28rpx;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.overview-public__hero {
|
||||
margin: 0 8%;
|
||||
color: #fff8ec;
|
||||
}
|
||||
.overview-public__eyebrow {
|
||||
display: block;
|
||||
color: #dec483;
|
||||
font-size: clamp(13px, 21rpx, 16px);
|
||||
letter-spacing: 3rpx;
|
||||
}
|
||||
.overview-public__title {
|
||||
display: block;
|
||||
margin-top: 10rpx;
|
||||
font-family: "STKaiti", "KaiTi", serif;
|
||||
font-size: clamp(22px, 43rpx, 28px);
|
||||
font-weight: 700;
|
||||
letter-spacing: 4rpx;
|
||||
}
|
||||
.overview-public__source {
|
||||
display: block;
|
||||
margin-top: 7rpx;
|
||||
color: #e9dcc1;
|
||||
font-size: clamp(14px, 22rpx, 17px);
|
||||
}
|
||||
.overview-public__details {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 18rpx 28rpx;
|
||||
min-height: 240rpx;
|
||||
margin: 126rpx 9% 0;
|
||||
}
|
||||
.overview-public__details > view {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.overview-public__details > view:nth-child(5) {
|
||||
grid-column: 1 / -1;
|
||||
align-self: start;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
box-sizing: border-box;
|
||||
padding: 0 24rpx;
|
||||
}
|
||||
.overview-public__details > view:nth-child(5) text:first-child {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
.overview-public__details > view:nth-child(5) text:last-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
.overview-public__details text:first-child {
|
||||
color: $brand-red;
|
||||
font-size: clamp(13px, 21rpx, 16px);
|
||||
}
|
||||
.overview-public__details text:last-child {
|
||||
margin-top: 6rpx;
|
||||
color: $ink;
|
||||
font-family: "STKaiti", "KaiTi", serif;
|
||||
font-size: clamp(16px, 27rpx, 20px);
|
||||
}
|
||||
.overview-public__trust {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
|
||||
gap: 10rpx 24rpx;
|
||||
margin: 28rpx 9% 0;
|
||||
color: $ink-muted;
|
||||
font-size: clamp(13px, 21rpx, 16px);
|
||||
line-height: 1.45;
|
||||
}
|
||||
.overview-public__notice {
|
||||
display: flex;
|
||||
margin: 54rpx 9% 0;
|
||||
color: $ink-muted;
|
||||
font-size: clamp(13px, 19rpx, 16px);
|
||||
line-height: max(1.35em, clamp(16px, 26rpx, 20px));
|
||||
}
|
||||
.overview-public__notice text:first-child {
|
||||
flex: 0 0 auto;
|
||||
margin-right: 12rpx;
|
||||
color: $brand-red;
|
||||
font-weight: 700;
|
||||
}
|
||||
.overview-public__action {
|
||||
display: flex;
|
||||
min-height: 82rpx;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin: 204rpx 9% 0;
|
||||
background: url("/static/assets/foundation/transparent/a01-scroll-primary-v3.png")
|
||||
center / contain no-repeat;
|
||||
}
|
||||
.overview-public__action text {
|
||||
z-index: 1;
|
||||
color: #fff9ed;
|
||||
font-size: clamp(16px, 27rpx, 20px);
|
||||
font-weight: 700;
|
||||
letter-spacing: 3rpx;
|
||||
}
|
||||
@media (min-width: 400px) {
|
||||
.overview-action {
|
||||
padding: 0 28rpx;
|
||||
@@ -0,0 +1,433 @@
|
||||
<template>
|
||||
<view class="search-page">
|
||||
<GenealogyPageBackground />
|
||||
<view class="page-header"
|
||||
><PageHeader title="搜索家谱" custom-back @back="backToGenealogies"
|
||||
/></view>
|
||||
<view class="page-content">
|
||||
<view class="invite-card">
|
||||
<text class="invite-card__title">邀请码加入</text>
|
||||
<text class="invite-card__copy"
|
||||
>输入收到的邀请码,查看家谱信息后再确认加入。</text
|
||||
>
|
||||
<input
|
||||
v-model.trim="inviteToken"
|
||||
class="invite-card__input"
|
||||
type="password"
|
||||
maxlength="128"
|
||||
placeholder="请输入邀请码"
|
||||
:disabled="inviteState === 'previewing' || inviteState === 'redeeming'"
|
||||
@input="resetInvitePreview"
|
||||
/>
|
||||
<text v-if="inviteError" class="invite-card__error">{{ inviteError }}</text>
|
||||
<AppButton
|
||||
block
|
||||
type="secondary"
|
||||
:disabled="!inviteToken || inviteState === 'previewing' || inviteState === 'redeeming'"
|
||||
:label="inviteState === 'previewing' ? '正在查看' : '查看家谱信息'"
|
||||
@click="previewInvite"
|
||||
/>
|
||||
<view v-if="invitePreview" class="invite-preview">
|
||||
<text>可加入家谱:{{ invitePreview.genealogyName }}</text>
|
||||
<text>有效至:{{ invitePreview.expiresAt }}</text>
|
||||
<AppButton
|
||||
block
|
||||
:disabled="inviteState === 'redeeming'"
|
||||
:label="inviteState === 'redeeming' ? '正在加入' : '确认加入这部家谱'"
|
||||
@click="openRedeemConfirmation"
|
||||
/>
|
||||
</view>
|
||||
<view v-if="inviteResult" class="invite-result">
|
||||
<text>{{ inviteResultCopy.title }}</text>
|
||||
<text>{{ inviteResultCopy.copy }}</text>
|
||||
<AppButton block :label="inviteResultCopy.action" @click="handleInviteResult" />
|
||||
</view>
|
||||
</view>
|
||||
<view class="search-note"
|
||||
><text>公开家谱</text
|
||||
><text>以下是可加入的公开家谱。</text></view
|
||||
>
|
||||
<view v-if="genealogySearchState === 'loading'" class="state-card"
|
||||
><AppLoading
|
||||
text="正在读取公开家谱"
|
||||
variant="section"
|
||||
description="正在查询可加入的公开家谱。"
|
||||
/></view>
|
||||
<view v-else-if="genealogySearchState === 'error'" class="state-card"
|
||||
><text>暂时无法读取公开家谱</text
|
||||
><AppButton
|
||||
block
|
||||
type="secondary"
|
||||
label="重新加载"
|
||||
@click="loadGenealogies"
|
||||
/></view>
|
||||
<view v-else-if="genealogySearchState === 'empty'" class="state-card"
|
||||
><text>暂未找到公开家谱</text></view
|
||||
>
|
||||
<view v-else class="result-list">
|
||||
<view v-for="item in rows" :key="item.id" class="genealogy-card">
|
||||
<view class="card-heading"
|
||||
><text>{{ item.name }}</text
|
||||
><text v-if="item.surname">{{ item.surname }}氏</text></view
|
||||
>
|
||||
<text v-if="item.location" class="card-meta">{{
|
||||
item.location
|
||||
}}</text>
|
||||
<text v-if="item.intro" class="card-copy">{{ item.intro }}</text>
|
||||
<view class="card-footer"
|
||||
><text>{{ item.memberCount }} 位成员</text
|
||||
><AppButton
|
||||
:label="item.canManage ? '已在我的家谱' : '申请加入'"
|
||||
:disabled="item.canManage"
|
||||
@click="applyToJoin(item)"
|
||||
/></view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<AppDialog
|
||||
:visible="redeemConfirmationVisible"
|
||||
:close-on-mask="false"
|
||||
eyebrow="加入确认"
|
||||
title="确认使用邀请码加入?"
|
||||
:message="redeemConfirmationMessage"
|
||||
confirm-text="确认加入"
|
||||
cancel-text="暂不加入"
|
||||
show-cancel
|
||||
@confirm="redeemInvite"
|
||||
@cancel="closeRedeemConfirmation"
|
||||
/>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref } from "vue";
|
||||
import { onLoad, 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 GenealogyPageBackground from "@/components/genealogy/PageBackground.vue";
|
||||
import PageHeader from "@/components/PageHeader.vue";
|
||||
import {
|
||||
createRequestController,
|
||||
isRequestCancelled
|
||||
} from "@/services/api/request-controller.js";
|
||||
import { genealogyMembershipApi } from "@/services/api/genealogy-membership-service.js";
|
||||
import { genealogyApi } from "@/services/api/genealogy-service.js";
|
||||
import { getRequestErrorMessage } from "@/services/api/request-error-message.js";
|
||||
import { createNonIdempotentWriteGuard } from "@/utils/non-idempotent-write-guard.js";
|
||||
import { openPage, returnTo } from "@/utils/navigation/gateway.js";
|
||||
|
||||
const rows = ref([]);
|
||||
const genealogySearchState = ref("loading");
|
||||
const inviteToken = ref("");
|
||||
const inviteState = ref("idle");
|
||||
const inviteError = ref("");
|
||||
const invitePreview = ref(null);
|
||||
const inviteResult = ref(null);
|
||||
const redeemConfirmationVisible = ref(false);
|
||||
const publicGenealogyListController = createRequestController();
|
||||
const invitationPreviewController = createRequestController();
|
||||
const invitationRedemptionController = createRequestController();
|
||||
const invitationRedemptionGuard = createNonIdempotentWriteGuard();
|
||||
let isPageActive = true;
|
||||
const loadGenealogies = async () => {
|
||||
publicGenealogyListController.abort();
|
||||
genealogySearchState.value = "loading";
|
||||
try {
|
||||
const publicGenealogies = await genealogyApi.getPublicGenealogies({
|
||||
requestController: publicGenealogyListController,
|
||||
});
|
||||
if (!isPageActive) return;
|
||||
rows.value = publicGenealogies;
|
||||
genealogySearchState.value = rows.value.length ? "ready" : "empty";
|
||||
} catch (error) {
|
||||
if (!isPageActive || isRequestCancelled(error)) return;
|
||||
genealogySearchState.value = "error";
|
||||
}
|
||||
};
|
||||
const applyToJoin = (item) =>
|
||||
openPage("G08", { genealogyId: item.id, genealogyName: item.name }, "G06");
|
||||
const backToGenealogies = () => returnTo("G01");
|
||||
const inviteResultCopy = computed(() =>
|
||||
inviteResult.value?.redemptionResult === "DIRECT_MEMBER"
|
||||
? {
|
||||
title: "已加入家谱",
|
||||
copy: "你已加入这部家谱。",
|
||||
action: "查看我的家谱",
|
||||
}
|
||||
: {
|
||||
title: "加入申请已提交",
|
||||
copy: "申请已提交,请等待审核结果。",
|
||||
action: "查看我的申请",
|
||||
},
|
||||
);
|
||||
const redeemConfirmationMessage = computed(() =>
|
||||
invitePreview.value
|
||||
? `确认加入「${invitePreview.value.genealogyName}」?如果这部家谱需要审核,我们会先提交申请。`
|
||||
: "请先查看家谱信息。",
|
||||
);
|
||||
const resetInvitePreview = () => {
|
||||
if (inviteState.value === "previewing") invitationPreviewController.abort();
|
||||
invitePreview.value = null;
|
||||
inviteResult.value = null;
|
||||
inviteError.value = "";
|
||||
redeemConfirmationVisible.value = false;
|
||||
if (inviteState.value !== "previewing" && inviteState.value !== "redeeming")
|
||||
inviteState.value = "idle";
|
||||
};
|
||||
const previewInvite = async () => {
|
||||
if (!inviteToken.value || inviteState.value === "previewing") return;
|
||||
const token = inviteToken.value;
|
||||
invitationPreviewController.abort();
|
||||
inviteState.value = "previewing";
|
||||
inviteError.value = "";
|
||||
invitePreview.value = null;
|
||||
inviteResult.value = null;
|
||||
try {
|
||||
const invitationPreview = await genealogyMembershipApi.previewGenealogyInvitation(token, {
|
||||
requestController: invitationPreviewController,
|
||||
});
|
||||
if (!isPageActive || inviteToken.value !== token) return;
|
||||
invitePreview.value = invitationPreview;
|
||||
inviteState.value = "ready";
|
||||
} catch (error) {
|
||||
if (!isPageActive || isRequestCancelled(error)) return;
|
||||
inviteState.value = "idle";
|
||||
inviteError.value = getRequestErrorMessage(error, "邀请码暂时无法查看,请检查后重试。");
|
||||
}
|
||||
};
|
||||
const openRedeemConfirmation = () => {
|
||||
if (inviteState.value === "ready" && invitePreview.value)
|
||||
redeemConfirmationVisible.value = true;
|
||||
};
|
||||
const closeRedeemConfirmation = () => {
|
||||
if (inviteState.value !== "redeeming") redeemConfirmationVisible.value = false;
|
||||
};
|
||||
const redeemInvite = async () => {
|
||||
if (inviteState.value !== "ready" || !invitePreview.value) return;
|
||||
const redemptionPayload = { token: inviteToken.value };
|
||||
const redemptionAttempt = invitationRedemptionGuard.begin(redemptionPayload);
|
||||
if (redemptionAttempt === null) {
|
||||
inviteError.value =
|
||||
"上次兑换结果暂时无法确认,请先返回“我的家谱”检查,避免重复兑换。";
|
||||
return;
|
||||
}
|
||||
inviteState.value = "redeeming";
|
||||
inviteError.value = "";
|
||||
try {
|
||||
const redemptionResult = await genealogyMembershipApi.redeemGenealogyInvitation(
|
||||
redemptionPayload,
|
||||
{ requestController: invitationRedemptionController },
|
||||
);
|
||||
if (!isPageActive) return;
|
||||
inviteResult.value = redemptionResult;
|
||||
invitePreview.value = null;
|
||||
inviteToken.value = "";
|
||||
inviteState.value = "success";
|
||||
redeemConfirmationVisible.value = false;
|
||||
} catch (error) {
|
||||
if (!isPageActive) return;
|
||||
inviteState.value = "ready";
|
||||
redeemConfirmationVisible.value = false;
|
||||
if (invitationRedemptionGuard.recordFailure(redemptionAttempt, error)) {
|
||||
inviteError.value =
|
||||
"兑换结果暂时无法确认,请先返回“我的家谱”检查,避免重复兑换。";
|
||||
return;
|
||||
}
|
||||
if (isRequestCancelled(error)) return;
|
||||
inviteError.value = getRequestErrorMessage(error, "加入未完成,请重新查看邀请码信息。");
|
||||
}
|
||||
};
|
||||
const handleInviteResult = () =>
|
||||
inviteResult.value?.redemptionResult === "DIRECT_MEMBER"
|
||||
? returnTo("G01")
|
||||
: openPage("G09", {}, "G06");
|
||||
onLoad(loadGenealogies);
|
||||
onShow(() => {
|
||||
if (genealogySearchState.value !== "loading") loadGenealogies();
|
||||
});
|
||||
onUnload(() => {
|
||||
isPageActive = false;
|
||||
publicGenealogyListController.abort();
|
||||
invitationPreviewController.abort();
|
||||
invitationRedemptionController.abort();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@import "../../styles/adaptive-frame-profiles.scss";
|
||||
.search-page {
|
||||
display: flex;
|
||||
min-height: 100vh;
|
||||
flex-direction: column;
|
||||
background: $paper;
|
||||
}
|
||||
.page-header,
|
||||
.page-content {
|
||||
z-index: 1;
|
||||
}
|
||||
.page-content {
|
||||
padding: 18rpx 24rpx 72rpx;
|
||||
}
|
||||
.search-note,
|
||||
.state-card,
|
||||
.genealogy-card,
|
||||
.invite-card {
|
||||
@include adaptive-genealogy-state-panel;
|
||||
}
|
||||
.invite-card {
|
||||
padding: 28rpx 30rpx;
|
||||
}
|
||||
.invite-card__title,
|
||||
.invite-card__copy,
|
||||
.invite-card__error,
|
||||
.invite-preview text,
|
||||
.invite-result text {
|
||||
display: block;
|
||||
}
|
||||
.invite-card__title {
|
||||
color: $ink;
|
||||
font-family: STKaiti, KaiTi, serif;
|
||||
font-size: clamp(18px, 32rpx, 23px);
|
||||
font-weight: 700;
|
||||
}
|
||||
.invite-card__copy,
|
||||
.invite-preview text,
|
||||
.invite-result text {
|
||||
margin-top: 8rpx;
|
||||
color: $ink-muted;
|
||||
font-size: clamp(14px, 22rpx, 17px);
|
||||
line-height: 1.55;
|
||||
}
|
||||
.invite-card__input {
|
||||
@include adaptive-genealogy-form-field;
|
||||
display: block;
|
||||
width: 100%;
|
||||
min-height: 78rpx;
|
||||
margin-top: 18rpx;
|
||||
padding: 0 22rpx;
|
||||
box-sizing: border-box;
|
||||
color: $ink;
|
||||
font-size: clamp(15px, 24rpx, 18px);
|
||||
}
|
||||
.invite-card__error {
|
||||
margin-top: 12rpx;
|
||||
color: $brand-red;
|
||||
font-size: clamp(14px, 22rpx, 17px);
|
||||
line-height: 1.5;
|
||||
}
|
||||
.invite-card > .app-button,
|
||||
.invite-preview > .app-button,
|
||||
.invite-result > .app-button {
|
||||
margin-top: 18rpx;
|
||||
}
|
||||
.invite-preview,
|
||||
.invite-result {
|
||||
margin-top: 20rpx;
|
||||
padding-top: 20rpx;
|
||||
border-top: 1rpx solid rgba(128, 89, 49, 0.24);
|
||||
}
|
||||
.invite-result text:first-child {
|
||||
color: $brand-red;
|
||||
font-weight: 700;
|
||||
}
|
||||
.search-note {
|
||||
display: flex;
|
||||
min-height: 112rpx;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
padding: 20rpx 30rpx;
|
||||
}
|
||||
.search-note text:first-child {
|
||||
color: $ink;
|
||||
font-family: STKaiti, KaiTi, serif;
|
||||
font-size: clamp(17px, 30rpx, 22px);
|
||||
font-weight: 700;
|
||||
}
|
||||
.search-note text:last-child {
|
||||
margin-top: 6rpx;
|
||||
color: $ink-muted;
|
||||
font-size: clamp(13px, 21rpx, 16px);
|
||||
}
|
||||
.state-card {
|
||||
display: flex;
|
||||
min-height: 310rpx;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-top: 18rpx;
|
||||
padding: 42rpx;
|
||||
text-align: center;
|
||||
color: $ink;
|
||||
font-size: clamp(16px, 28rpx, 20px);
|
||||
}
|
||||
.state-card .app-button {
|
||||
width: 100%;
|
||||
margin-top: 22rpx;
|
||||
}
|
||||
.result-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16rpx;
|
||||
margin-top: 18rpx;
|
||||
}
|
||||
.genealogy-card {
|
||||
padding: 28rpx 32rpx;
|
||||
}
|
||||
.card-heading {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 18rpx;
|
||||
}
|
||||
.card-heading text:first-child {
|
||||
color: $ink;
|
||||
font-family: STKaiti, KaiTi, serif;
|
||||
font-size: clamp(17px, 31rpx, 22px);
|
||||
font-weight: 700;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.card-heading text:last-child {
|
||||
flex: 0 0 auto;
|
||||
color: $brand-red;
|
||||
font-size: clamp(14px, 22rpx, 17px);
|
||||
}
|
||||
.card-meta,
|
||||
.card-copy {
|
||||
display: block;
|
||||
color: $ink-muted;
|
||||
font-size: clamp(14px, 22rpx, 17px);
|
||||
line-height: 1.5;
|
||||
}
|
||||
.card-meta {
|
||||
margin-top: 10rpx;
|
||||
}
|
||||
.card-copy {
|
||||
margin-top: 8rpx;
|
||||
}
|
||||
.card-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 18rpx;
|
||||
margin-top: 20rpx;
|
||||
color: $ink-muted;
|
||||
font-size: clamp(13px, 21rpx, 16px);
|
||||
}
|
||||
.card-footer .app-button {
|
||||
min-width: 180rpx;
|
||||
}
|
||||
@media (max-width: 340px) {
|
||||
.page-content {
|
||||
padding-right: 20rpx;
|
||||
padding-left: 20rpx;
|
||||
}
|
||||
.card-footer {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
}
|
||||
.card-footer .app-button {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,4 +1,3 @@
|
||||
<!-- 页面编号:G-11;用途:按 GenealogyUpdateBody 更新当前家谱。 -->
|
||||
<template>
|
||||
<view class="settings-page" :class="`settings-state--${pageState}`">
|
||||
<GenealogyPageBackground />
|
||||
@@ -15,6 +14,10 @@
|
||||
|
||||
<view v-else-if="pageState === 'form'" class="settings-card">
|
||||
<text class="settings-card__title">编辑家谱信息</text>
|
||||
<view v-if="lifecycleStatus === GENEALOGY_LIFECYCLE_STATUS.ARCHIVED" class="lifecycle-note">
|
||||
<text>这部家谱已归档</text>
|
||||
<text>归档期间可以查看内容,但不能修改。恢复后可继续编辑。</text>
|
||||
</view>
|
||||
<text class="settings-card__note"
|
||||
>带红色星号的内容不能为空;其余内容可按需要补充。</text
|
||||
>
|
||||
@@ -51,7 +54,12 @@
|
||||
fieldErrors.genealogyName
|
||||
}}</text>
|
||||
|
||||
<view class="field-row field-row--selector" @click="openRegionPicker">
|
||||
<view
|
||||
class="field-row field-row--selector"
|
||||
role="button"
|
||||
aria-label="选择所在地区"
|
||||
@click="openRegionPicker"
|
||||
>
|
||||
<text class="field-row__label"
|
||||
><text class="required-mark">*</text>所在地区</text
|
||||
>
|
||||
@@ -65,6 +73,12 @@
|
||||
: regionPickerError || "请选择所在地区")
|
||||
}}</text
|
||||
>
|
||||
<image
|
||||
class="region-selector-chevron"
|
||||
src="/static/assets/foundation/transparent/chevron-right.png"
|
||||
mode="aspectFit"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</view>
|
||||
<text v-if="fieldErrors.regionCode" class="field-error">{{
|
||||
fieldErrors.regionCode
|
||||
@@ -114,8 +128,8 @@
|
||||
<text class="cover-field__label">封面图片</text>
|
||||
<text class="cover-field__hint">{{
|
||||
coverOssId
|
||||
? "当前已关联封面;重新选择后会以新的真实上传回执更新。"
|
||||
: "选择图片后会取得真实上传回执,并在保存时关联。"
|
||||
? "当前已有封面;重新选择后会用新图片替换。"
|
||||
: "图片上传成功后,会作为家谱封面保存。"
|
||||
}}</text>
|
||||
</view>
|
||||
<button
|
||||
@@ -133,7 +147,11 @@
|
||||
|
||||
<view class="access-rule">
|
||||
<text class="access-rule__label">访问规则</text>
|
||||
<view class="access-rule__options">
|
||||
<view
|
||||
class="access-rule__options"
|
||||
role="radiogroup"
|
||||
aria-label="家谱访问规则"
|
||||
>
|
||||
<view
|
||||
v-for="option in GENEALOGY_ACCESS_PRESET_OPTIONS"
|
||||
:key="option.value"
|
||||
@@ -142,6 +160,9 @@
|
||||
'access-rule__option--active':
|
||||
form.accessPreset === option.value,
|
||||
}"
|
||||
role="radio"
|
||||
:aria-checked="form.accessPreset === option.value"
|
||||
:aria-label="option.label"
|
||||
@click="form.accessPreset = option.value"
|
||||
>{{ option.label }}</view
|
||||
>
|
||||
@@ -151,10 +172,20 @@
|
||||
<text v-if="submitError" class="submit-error">{{ submitError }}</text>
|
||||
<AppButton
|
||||
block
|
||||
:disabled="isSubmitting || isUploading"
|
||||
:disabled="isSubmitting || isUploading || lifecycleStatus === GENEALOGY_LIFECYCLE_STATUS.ARCHIVED"
|
||||
:label="isSubmitting ? '正在保存…' : '保存设置'"
|
||||
@click="submitUpdate"
|
||||
/>
|
||||
<view v-if="canArchive || canRestore" class="lifecycle-actions">
|
||||
<text>{{ canRestore ? "需要继续维护这部家谱时,可以恢复编辑。" : "暂时不再维护时,可以归档;内容仍可查看。" }}</text>
|
||||
<AppButton
|
||||
block
|
||||
type="secondary"
|
||||
:disabled="lifecycleSubmitting"
|
||||
:label="lifecycleSubmitting ? '正在处理' : canRestore ? '恢复家谱' : '归档家谱'"
|
||||
@click="requestLifecycleChange"
|
||||
/>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view v-else class="state-card">
|
||||
@@ -163,55 +194,26 @@
|
||||
<AppButton block :label="stateCopy.action" @click="handleStateAction" />
|
||||
</view>
|
||||
</view>
|
||||
<AppDialog
|
||||
:visible="lifecycleDialogVisible"
|
||||
eyebrow="家谱状态"
|
||||
:title="canRestore ? '恢复这部家谱?' : '归档这部家谱?'"
|
||||
:message="canRestore ? '恢复后可继续修改家谱内容。' : '归档后内容仍可查看,但暂时不能修改。'"
|
||||
:confirm-text="lifecycleSubmitting ? '正在处理' : canRestore ? '确认恢复' : '确认归档'"
|
||||
cancel-text="取消"
|
||||
show-cancel
|
||||
:close-on-mask="false"
|
||||
@confirm="confirmLifecycleChange"
|
||||
@cancel="lifecycleDialogVisible = false"
|
||||
/>
|
||||
|
||||
<view v-if="regionPickerOpen" class="region-sheet">
|
||||
<view class="region-sheet__mask" @click="closeRegionPicker" />
|
||||
<view class="region-sheet__panel">
|
||||
<view class="region-sheet__intro"
|
||||
><text class="region-sheet__title">选择地区</text></view
|
||||
>
|
||||
<view class="region-sheet__picker">
|
||||
<view class="region-sheet__column-headings">
|
||||
<text
|
||||
v-for="label in ['省份', '城市', '区县']"
|
||||
:key="label"
|
||||
class="region-sheet__column-heading"
|
||||
>{{ label }}</text
|
||||
>
|
||||
</view>
|
||||
<picker-view
|
||||
class="region-sheet__picker-view"
|
||||
:indicator-style="regionPickerIndicatorStyle"
|
||||
:value="regionPickerIndexes"
|
||||
@change="handleRegionPickerChange"
|
||||
>
|
||||
<picker-view-column
|
||||
v-for="(column, columnIndex) in regionPickerColumns"
|
||||
:key="columnIndex"
|
||||
>
|
||||
<view
|
||||
v-for="(option, optionIndex) in column"
|
||||
:key="option.regionCode"
|
||||
class="region-sheet__picker-item"
|
||||
:class="{
|
||||
'region-sheet__picker-item--selected':
|
||||
regionPickerIndexes[columnIndex] === optionIndex,
|
||||
}"
|
||||
>{{ option.label }}</view
|
||||
>
|
||||
</picker-view-column>
|
||||
</picker-view>
|
||||
</view>
|
||||
<view class="region-sheet__footer">
|
||||
<view class="region-sheet__cancel" @click="closeRegionPicker"
|
||||
>取消</view
|
||||
>
|
||||
<button class="region-sheet__confirm" @click="confirmRegionSelection">
|
||||
确认选择
|
||||
</button>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<RegionPickerDialog
|
||||
ref="regionPickerDialog"
|
||||
close-on-mask
|
||||
@select="selectRegion"
|
||||
@loading-change="regionLoading = $event"
|
||||
@error-change="regionPickerError = $event"
|
||||
/>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
@@ -219,24 +221,31 @@
|
||||
import { computed, onMounted, reactive, ref } from "vue";
|
||||
import { onLoad, onUnload } from "@dcloudio/uni-app";
|
||||
import AppButton from "@/components/AppButton.vue";
|
||||
import AppDialog from "@/components/AppDialog.vue";
|
||||
import AppLoading from "@/components/AppLoading.vue";
|
||||
import GenealogyPageBackground from "@/components/GenealogyPageBackground.vue";
|
||||
import GenealogyPageBackground from "@/components/genealogy/PageBackground.vue";
|
||||
import RegionPickerDialog from "@/components/genealogy/RegionPickerDialog.vue";
|
||||
import PageHeader from "@/components/PageHeader.vue";
|
||||
import {
|
||||
appApi,
|
||||
GENEALOGY_LIFECYCLE_STATUS
|
||||
} from "@/services/api/genealogy-contract.js";
|
||||
import {
|
||||
createRequestController,
|
||||
isRequestCancelled,
|
||||
} from "@/utils/api.js";
|
||||
isRequestCancelled
|
||||
} from "@/services/api/request-controller.js";
|
||||
import { genealogyApi } from "@/services/api/genealogy-service.js";
|
||||
import { getRequestErrorMessage } from "@/services/api/request-error-message.js";
|
||||
import {
|
||||
GENEALOGY_ACCESS_PRESET,
|
||||
GENEALOGY_ACCESS_PRESET_OPTIONS,
|
||||
isGenealogyAccessPreset,
|
||||
toApiGenealogyAccess,
|
||||
} from "@/utils/genealogy-contracts.js";
|
||||
} from "@/utils/genealogy/access-policy.js";
|
||||
import {
|
||||
isImagePickCancelled,
|
||||
pickAndUploadImage,
|
||||
} from "@/utils/resumable-image-upload.js";
|
||||
import { goBack, returnTo } from "@/utils/navigation.js";
|
||||
} from "@/utils/media-upload.js";
|
||||
import { goBack, returnTo } from "@/utils/navigation/gateway.js";
|
||||
|
||||
const genealogyId = ref("");
|
||||
const pageState = ref("loading");
|
||||
@@ -260,38 +269,44 @@ const isUploading = ref(false);
|
||||
const uploadError = ref("");
|
||||
const coverOssId = ref(null);
|
||||
const coverFileName = ref("");
|
||||
const lifecycleStatus = ref(GENEALOGY_LIFECYCLE_STATUS.NORMAL);
|
||||
const canArchive = ref(false);
|
||||
const canRestore = ref(false);
|
||||
const lifecycleDialogVisible = ref(false);
|
||||
const lifecycleSubmitting = ref(false);
|
||||
const currentRegionCode = ref("");
|
||||
const currentRegionDisplay = ref("");
|
||||
const selectedRegion = ref(null);
|
||||
const regionPickerTrail = ref([]);
|
||||
const regionPickerColumns = ref([]);
|
||||
const regionPickerIndexes = ref([0]);
|
||||
const regionPickerDialog = ref(null);
|
||||
const regionPickerError = ref("");
|
||||
const regionLoading = ref(false);
|
||||
const regionPickerOpen = ref(false);
|
||||
const regionPickerIndicatorStyle =
|
||||
"height: 104rpx; border-top: 1px solid rgba(159, 23, 15, .46); border-bottom: 1px solid rgba(159, 23, 15, .46); background: rgba(159, 23, 15, .08);";
|
||||
const controller = createRequestController();
|
||||
const settingsReadRequestController = createRequestController();
|
||||
const genealogyLifecycleRequestController = createRequestController();
|
||||
const coverUploadRequestController = createRequestController();
|
||||
const settingsSaveRequestController = createRequestController();
|
||||
// 封面上传、归档和设置保存属于独立操作。分别持有取消槽,避免后发操作
|
||||
// 取消前一条仍在收敛的请求,留下无法解释的加载状态。
|
||||
let pageActive = true;
|
||||
|
||||
const hasValidContext = computed(() => /^[1-9]\d*$/.test(genealogyId.value));
|
||||
const regionDisplay = computed(() =>
|
||||
regionPickerTrail.value.length
|
||||
? regionPickerTrail.value.map((item) => item.label).join(" / ")
|
||||
? regionPickerTrail.value.map((regionNode) => regionNode.label).join(" / ")
|
||||
: currentRegionDisplay.value,
|
||||
);
|
||||
const stateCopy = computed(() => {
|
||||
if (pageState.value === "success") {
|
||||
return {
|
||||
title: "家谱设置已保存",
|
||||
copy: "服务端已返回成功结果。",
|
||||
copy: "家谱设置已保存。",
|
||||
action: "返回家谱总览",
|
||||
};
|
||||
}
|
||||
if (!hasValidContext.value) {
|
||||
return {
|
||||
title: "家谱入口无效",
|
||||
copy: "没有取得有效家谱标识。",
|
||||
title: "暂时无法打开家谱设置",
|
||||
copy: "未找到家谱信息,请返回后重新进入。",
|
||||
action: "返回上一页",
|
||||
};
|
||||
}
|
||||
@@ -320,89 +335,16 @@ const validate = () => {
|
||||
);
|
||||
};
|
||||
|
||||
const fetchRegionChildren = async (parentCode) => {
|
||||
regionLoading.value = true;
|
||||
regionPickerError.value = "";
|
||||
try {
|
||||
return await appApi.getRegionChildren(parentCode, {
|
||||
requestController: controller,
|
||||
});
|
||||
} catch (error) {
|
||||
if (!isRequestCancelled(error))
|
||||
regionPickerError.value = error?.message || "地区列表加载失败,请重试";
|
||||
return [];
|
||||
} finally {
|
||||
regionLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const loadPickerColumns = async (provinceIndex = 0, cityIndex = 0) => {
|
||||
const provinces =
|
||||
regionPickerColumns.value[0] || (await fetchRegionChildren("0"));
|
||||
const province = provinces[provinceIndex];
|
||||
if (!province) return;
|
||||
const cities = await fetchRegionChildren(province.regionCode);
|
||||
const city = cities[cityIndex];
|
||||
if (!city) {
|
||||
regionPickerColumns.value = [provinces];
|
||||
regionPickerIndexes.value = [provinceIndex];
|
||||
return;
|
||||
}
|
||||
const districts = await fetchRegionChildren(city.regionCode);
|
||||
if (!districts.length || regionPickerError.value) return;
|
||||
regionPickerColumns.value = [provinces, cities, districts];
|
||||
regionPickerIndexes.value = [provinceIndex, cityIndex, 0];
|
||||
};
|
||||
|
||||
const loadRegionRoot = async () => {
|
||||
if (regionLoading.value) return;
|
||||
const roots = await fetchRegionChildren("0");
|
||||
if (!roots.length || regionPickerError.value) return;
|
||||
regionPickerColumns.value = [roots];
|
||||
regionPickerIndexes.value = [0];
|
||||
await loadPickerColumns();
|
||||
};
|
||||
|
||||
const handleRegionPickerChange = async (event) => {
|
||||
if (regionLoading.value) return;
|
||||
const nextIndexes = (event?.detail?.value || []).map(
|
||||
(index) => Number(index) || 0,
|
||||
);
|
||||
const indexes = regionPickerIndexes.value;
|
||||
const provinceIndex = nextIndexes[0] || 0;
|
||||
const cityIndex = nextIndexes[1] || 0;
|
||||
const districtIndex = nextIndexes[2] || 0;
|
||||
if (provinceIndex !== (indexes[0] || 0)) {
|
||||
await loadPickerColumns(provinceIndex, 0);
|
||||
return;
|
||||
}
|
||||
if (cityIndex !== (indexes[1] || 0)) {
|
||||
await loadPickerColumns(indexes[0] || 0, cityIndex);
|
||||
return;
|
||||
}
|
||||
regionPickerIndexes.value = [indexes[0] || 0, indexes[1] || 0, districtIndex];
|
||||
};
|
||||
|
||||
const openRegionPicker = async () => {
|
||||
if (isSubmitting.value || isUploading.value || regionLoading.value) return;
|
||||
if (!regionPickerColumns.value.length) await loadRegionRoot();
|
||||
if (regionPickerColumns.value.length) regionPickerOpen.value = true;
|
||||
await regionPickerDialog.value?.open(currentRegionCode.value);
|
||||
};
|
||||
const closeRegionPicker = () => {
|
||||
regionPickerOpen.value = false;
|
||||
};
|
||||
const confirmRegionSelection = () => {
|
||||
const trail = regionPickerColumns.value
|
||||
.map((column, index) => column[Number(regionPickerIndexes.value[index])])
|
||||
.filter(Boolean);
|
||||
const region = trail[trail.length - 1];
|
||||
if (!region) return;
|
||||
const selectRegion = ({ region, trail }) => {
|
||||
selectedRegion.value = region;
|
||||
currentRegionCode.value = region.regionCode;
|
||||
regionPickerTrail.value = trail;
|
||||
fieldErrors.regionCode = "";
|
||||
regionPickerError.value = "";
|
||||
regionPickerOpen.value = false;
|
||||
};
|
||||
|
||||
const loadSettings = async () => {
|
||||
@@ -412,10 +354,14 @@ const loadSettings = async () => {
|
||||
}
|
||||
pageState.value = "loading";
|
||||
try {
|
||||
const settings = await appApi.getGenealogySettings(genealogyId.value, {
|
||||
requestController: controller,
|
||||
const settings = await genealogyApi.getGenealogySettings(genealogyId.value, {
|
||||
requestController: settingsReadRequestController,
|
||||
});
|
||||
if (!pageActive) return;
|
||||
if (!isGenealogyAccessPreset(settings.accessPreset)) {
|
||||
pageState.value = "error";
|
||||
return;
|
||||
}
|
||||
Object.assign(form, {
|
||||
surname: settings.surname,
|
||||
genealogyName: settings.genealogyName,
|
||||
@@ -423,35 +369,69 @@ const loadSettings = async () => {
|
||||
originPlace: settings.originPlace,
|
||||
addressDetail: settings.addressDetail,
|
||||
intro: settings.intro,
|
||||
accessPreset:
|
||||
settings.accessPreset || GENEALOGY_ACCESS_PRESET.MEMBER_ONLY,
|
||||
accessPreset: settings.accessPreset,
|
||||
});
|
||||
currentRegionCode.value = settings.regionCode;
|
||||
currentRegionDisplay.value = settings.regionFullName || settings.regionName;
|
||||
coverOssId.value = settings.coverOssId;
|
||||
coverOssId.value = settings.coverFile?.ossId ?? null;
|
||||
coverFileName.value = settings.coverFile?.fileName || "";
|
||||
lifecycleStatus.value = settings.lifecycleStatus;
|
||||
canArchive.value = settings.canArchive;
|
||||
canRestore.value = settings.canRestore;
|
||||
pageState.value = "form";
|
||||
} catch (error) {
|
||||
if (!pageActive || isRequestCancelled(error)) return;
|
||||
pageState.value = "error";
|
||||
}
|
||||
};
|
||||
const requestLifecycleChange = () => {
|
||||
if ((!canArchive.value && !canRestore.value) || lifecycleSubmitting.value) return;
|
||||
submitError.value = "";
|
||||
lifecycleDialogVisible.value = true;
|
||||
};
|
||||
const confirmLifecycleChange = async () => {
|
||||
if ((!canArchive.value && !canRestore.value) || lifecycleSubmitting.value) return;
|
||||
lifecycleSubmitting.value = true;
|
||||
try {
|
||||
const lifecycleResult = canRestore.value
|
||||
? await genealogyApi.restoreGenealogy(genealogyId.value, {
|
||||
requestController: genealogyLifecycleRequestController,
|
||||
})
|
||||
: await genealogyApi.archiveGenealogy(genealogyId.value, {
|
||||
requestController: genealogyLifecycleRequestController,
|
||||
});
|
||||
if (!pageActive) return;
|
||||
lifecycleStatus.value = lifecycleResult.lifecycleStatus;
|
||||
canArchive.value = lifecycleResult.canArchive;
|
||||
canRestore.value = lifecycleResult.canRestore;
|
||||
lifecycleDialogVisible.value = false;
|
||||
} catch (error) {
|
||||
if (!pageActive || isRequestCancelled(error)) return;
|
||||
submitError.value = getRequestErrorMessage(error, canRestore.value ? "家谱恢复失败,请稍后重试。" : "家谱归档失败,请稍后重试。");
|
||||
lifecycleDialogVisible.value = false;
|
||||
} finally {
|
||||
if (pageActive) lifecycleSubmitting.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const uploadCover = async () => {
|
||||
if (isUploading.value || isSubmitting.value) return;
|
||||
isUploading.value = true;
|
||||
uploadError.value = "";
|
||||
try {
|
||||
const receipt = await pickAndUploadImage({ requestController: controller });
|
||||
const coverUpload = await pickAndUploadImage({
|
||||
requestController: coverUploadRequestController,
|
||||
});
|
||||
if (!pageActive) return;
|
||||
coverOssId.value = receipt.ossId;
|
||||
coverFileName.value = receipt.fileName || "封面图片";
|
||||
coverOssId.value = coverUpload.ossId;
|
||||
coverFileName.value = coverUpload.fileName || "封面图片";
|
||||
} catch (error) {
|
||||
if (
|
||||
pageActive &&
|
||||
!isImagePickCancelled(error) &&
|
||||
!isRequestCancelled(error)
|
||||
) {
|
||||
uploadError.value = error?.message || "封面图片上传失败,请稍后重试";
|
||||
uploadError.value = getRequestErrorMessage(error, "封面图片上传失败,请稍后重试");
|
||||
}
|
||||
} finally {
|
||||
if (pageActive) isUploading.value = false;
|
||||
@@ -462,13 +442,13 @@ const submitUpdate = async () => {
|
||||
if (isSubmitting.value || isUploading.value || !validate()) return;
|
||||
const access = toApiGenealogyAccess(form.accessPreset);
|
||||
if (!access) {
|
||||
submitError.value = "访问规则无效,请重新选择";
|
||||
submitError.value = "请选择家谱开放方式";
|
||||
return;
|
||||
}
|
||||
isSubmitting.value = true;
|
||||
submitError.value = "";
|
||||
try {
|
||||
await appApi.updateGenealogy(
|
||||
await genealogyApi.updateGenealogy(
|
||||
genealogyId.value,
|
||||
{
|
||||
surname: form.surname,
|
||||
@@ -481,13 +461,13 @@ const submitUpdate = async () => {
|
||||
...(coverOssId.value ? { coverOssId: coverOssId.value } : {}),
|
||||
...access,
|
||||
},
|
||||
{ requestController: controller },
|
||||
{ requestController: settingsSaveRequestController },
|
||||
);
|
||||
if (!pageActive) return;
|
||||
pageState.value = "success";
|
||||
} catch (error) {
|
||||
if (!pageActive || isRequestCancelled(error)) return;
|
||||
submitError.value = error?.message || "家谱设置保存失败,请稍后重试";
|
||||
submitError.value = getRequestErrorMessage(error, "家谱设置保存失败,请稍后重试");
|
||||
} finally {
|
||||
if (pageActive) isSubmitting.value = false;
|
||||
}
|
||||
@@ -508,7 +488,10 @@ onMounted(() => {
|
||||
});
|
||||
onUnload(() => {
|
||||
pageActive = false;
|
||||
controller.abort();
|
||||
settingsReadRequestController.abort();
|
||||
genealogyLifecycleRequestController.abort();
|
||||
coverUploadRequestController.abort();
|
||||
settingsSaveRequestController.abort();
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -600,6 +583,25 @@ onUnload(() => {
|
||||
text-align: right;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.lifecycle-note,
|
||||
.lifecycle-actions {
|
||||
margin-top: 20rpx;
|
||||
padding: 20rpx 22rpx;
|
||||
border: 1rpx solid rgba(159, 23, 15, 0.26);
|
||||
border-radius: 10rpx;
|
||||
background: rgba(159, 23, 15, 0.05);
|
||||
}
|
||||
.lifecycle-note text,
|
||||
.lifecycle-actions text { display: block; color: $ink-muted; font-size: clamp(14px, 22rpx, 17px); line-height: 1.55; }
|
||||
.lifecycle-note text:first-child { color: $brand-red; font-weight: 700; }
|
||||
.lifecycle-actions .app-button { margin-top: 18rpx; }
|
||||
.region-selector-chevron {
|
||||
width: 28rpx;
|
||||
height: 28rpx;
|
||||
margin-left: 12rpx;
|
||||
flex: 0 0 auto;
|
||||
opacity: 0.7;
|
||||
}
|
||||
.region-selector-value--placeholder,
|
||||
.placeholder {
|
||||
color: #ab9a86;
|
||||
@@ -642,7 +644,7 @@ onUnload(() => {
|
||||
}
|
||||
.upload-button {
|
||||
justify-self: start;
|
||||
min-height: 60rpx;
|
||||
min-height: 88rpx;
|
||||
margin: 0;
|
||||
padding: 0 20rpx;
|
||||
border: 1rpx solid rgba(159, 23, 15, 0.42);
|
||||
@@ -679,6 +681,11 @@ onUnload(() => {
|
||||
}
|
||||
.access-rule__option {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
min-height: 88rpx;
|
||||
box-sizing: border-box;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 20rpx 12rpx;
|
||||
border: 1rpx solid rgba(128, 89, 49, 0.34);
|
||||
border-radius: 12rpx;
|
||||
@@ -718,103 +725,4 @@ onUnload(() => {
|
||||
.state-card .app-button {
|
||||
margin-top: 28rpx;
|
||||
}
|
||||
.region-sheet {
|
||||
position: fixed;
|
||||
z-index: 10;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
}
|
||||
.region-sheet__mask {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: rgba(43, 30, 20, 0.42);
|
||||
}
|
||||
.region-sheet__panel {
|
||||
width: 100%;
|
||||
padding: 22rpx 28rpx calc(24rpx + env(safe-area-inset-bottom));
|
||||
border-radius: 30rpx 30rpx 0 0;
|
||||
background: #fdf9ef;
|
||||
box-shadow: 0 -12rpx 36rpx rgba(43, 30, 20, 0.2);
|
||||
}
|
||||
.region-sheet__intro {
|
||||
padding: 0 10rpx 16rpx;
|
||||
text-align: center;
|
||||
}
|
||||
.region-sheet__title {
|
||||
display: block;
|
||||
color: $ink;
|
||||
font-family: STKaiti, KaiTi, serif;
|
||||
font-size: clamp(19px, 36rpx, 24px);
|
||||
font-weight: 700;
|
||||
}
|
||||
.region-sheet__picker {
|
||||
border: 1rpx solid rgba(128, 89, 49, 0.28);
|
||||
border-radius: 16rpx;
|
||||
background: rgba(255, 252, 245, 0.8);
|
||||
}
|
||||
.region-sheet__column-headings {
|
||||
display: flex;
|
||||
height: 84rpx;
|
||||
border-bottom: 1rpx solid rgba(128, 89, 49, 0.18);
|
||||
}
|
||||
.region-sheet__column-heading {
|
||||
box-sizing: border-box;
|
||||
width: 33.333%;
|
||||
padding: 24rpx 12rpx;
|
||||
color: $ink-muted;
|
||||
font-size: clamp(16px, 26rpx, 20px);
|
||||
text-align: center;
|
||||
}
|
||||
.region-sheet__column-heading + .region-sheet__column-heading {
|
||||
border-left: 1rpx solid rgba(128, 89, 49, 0.16);
|
||||
}
|
||||
.region-sheet__picker-view {
|
||||
width: 100%;
|
||||
height: 520rpx;
|
||||
}
|
||||
.region-sheet__picker-item {
|
||||
box-sizing: border-box;
|
||||
height: 104rpx;
|
||||
padding: 0 6rpx;
|
||||
color: $ink-muted;
|
||||
font-size: clamp(16px, 26rpx, 20px);
|
||||
line-height: 104rpx;
|
||||
text-align: center;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.region-sheet__picker-item--selected {
|
||||
color: $brand-red;
|
||||
font-weight: 700;
|
||||
}
|
||||
.region-sheet__footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-top: 22rpx;
|
||||
gap: 12rpx;
|
||||
}
|
||||
.region-sheet__cancel {
|
||||
min-width: 116rpx;
|
||||
padding: 20rpx 10rpx;
|
||||
color: $ink-muted;
|
||||
font-size: clamp(16px, 28rpx, 20px);
|
||||
text-align: center;
|
||||
}
|
||||
.region-sheet__confirm {
|
||||
flex: 1;
|
||||
height: 82rpx;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-radius: 10rpx;
|
||||
background: $brand-red;
|
||||
color: #fff;
|
||||
font-size: clamp(16px, 29rpx, 20px);
|
||||
font-weight: 700;
|
||||
line-height: 82rpx;
|
||||
}
|
||||
.region-sheet__confirm::after {
|
||||
display: none;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,211 @@
|
||||
<template>
|
||||
<view class="message-page">
|
||||
<ModulePageBackground module="notification" />
|
||||
<view class="page-header"><PageHeader root title="消息中心" :action="unreadCount > 0 ? '设为已读' : ''" @action="requestMarkAllRead" /></view>
|
||||
<view class="page-content">
|
||||
<view v-if="notificationListState === 'loading'" class="state-card"><AppLoading text="正在读取消息通知" /></view>
|
||||
<view v-else-if="notificationListState === 'error'" class="state-card">
|
||||
<text>暂时无法读取消息状态</text>
|
||||
<text>{{ notificationListError || "请检查网络后重新加载。" }}</text>
|
||||
<AppButton block type="secondary" label="重新加载" @click="loadNotifications" />
|
||||
<AppButton block label="返回我的" @click="returnToProfile" />
|
||||
</view>
|
||||
<view v-else-if="!notifications.length" class="state-card message-status-card">
|
||||
<text>当前没有通知</text>
|
||||
<text>新的家谱动态、审核和活动消息会在这里展示。</text>
|
||||
<text v-if="markAllReadError" class="message-operation-error">{{ markAllReadError }}</text>
|
||||
<AppButton block type="secondary" label="返回我的" @click="returnToProfile" />
|
||||
</view>
|
||||
<view v-else class="message-list">
|
||||
<view
|
||||
v-for="item in notifications"
|
||||
:key="item.notificationId"
|
||||
class="message-row"
|
||||
hover-class="action-hover"
|
||||
@click="openNotification(item)"
|
||||
>
|
||||
<view class="message-row__body">
|
||||
<view class="message-row__title-line">
|
||||
<text class="message-row__title">{{ item.noticeTitle || '通知消息' }}</text>
|
||||
<text v-if="item.readStatus === '0'" class="message-row__unread">未读</text>
|
||||
</view>
|
||||
<text class="message-row__summary">{{ item.noticeContent || item.bizSummary || '暂无通知摘要' }}</text>
|
||||
<text class="message-row__meta">{{ item.publishTime || '刚刚发布' }}</text>
|
||||
</view>
|
||||
<text class="message-row__chevron">›</text>
|
||||
</view>
|
||||
<text v-if="markAllReadError" class="message-operation-error">{{ markAllReadError }}</text>
|
||||
<AppButton v-if="unreadCount" block label="全部设为已读" @click="requestMarkAllRead" />
|
||||
</view>
|
||||
</view>
|
||||
<AppPromotionStrip placement="message_bottom" title="消息页推荐" />
|
||||
<AppTabbar active="profile" />
|
||||
<AppDialog
|
||||
:visible="markAllVisible"
|
||||
eyebrow="消息状态"
|
||||
title="把全部消息设为已读?"
|
||||
message="这些消息将不再显示为未读。"
|
||||
:confirm-text="markAllSubmitting ? '正在设置' : '确认设为已读'"
|
||||
cancel-text="取消"
|
||||
show-cancel
|
||||
:close-on-mask="false"
|
||||
@confirm="confirmMarkAllRead"
|
||||
@cancel="markAllVisible = false"
|
||||
/>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from "vue";
|
||||
import { onLoad, 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 AppPromotionStrip from "@/components/AppPromotionStrip.vue";
|
||||
import AppTabbar from "@/components/AppTabbar.vue";
|
||||
import ModulePageBackground from "@/components/ModulePageBackground.vue";
|
||||
import PageHeader from "@/components/PageHeader.vue";
|
||||
import {
|
||||
createRequestController,
|
||||
isRequestCancelled
|
||||
} from "@/services/api/request-controller.js";
|
||||
import { notificationApi } from "@/services/api/notification-service.js";
|
||||
import { getRequestErrorMessage } from "@/services/api/request-error-message.js";
|
||||
import { goRoot, openPage } from "@/utils/navigation/gateway.js";
|
||||
|
||||
const unreadCount = ref(0);
|
||||
const notifications = ref([]);
|
||||
const notificationListState = ref("loading");
|
||||
const notificationListError = ref("");
|
||||
const notificationListController = createRequestController();
|
||||
const markAllReadController = createRequestController();
|
||||
const markAllVisible = ref(false);
|
||||
const markAllSubmitting = ref(false);
|
||||
const markAllReadError = ref("");
|
||||
let isPageActive = true;
|
||||
|
||||
const loadNotifications = async () => {
|
||||
notificationListController.abort();
|
||||
notificationListState.value = "loading";
|
||||
notificationListError.value = "";
|
||||
try {
|
||||
const notificationRows = await notificationApi.getNotifications({
|
||||
requestController: notificationListController,
|
||||
});
|
||||
if (!isPageActive) return;
|
||||
notifications.value = notificationRows;
|
||||
unreadCount.value = notificationRows.filter((item) => item.readStatus === "0").length;
|
||||
notificationListState.value = "ready";
|
||||
} catch (error) {
|
||||
if (!isPageActive || isRequestCancelled(error)) return;
|
||||
notificationListError.value = getRequestErrorMessage(error, "请稍后重试。");
|
||||
notificationListState.value = "error";
|
||||
}
|
||||
};
|
||||
|
||||
const requestMarkAllRead = () => {
|
||||
if (notificationListState.value !== "ready" || !unreadCount.value || markAllSubmitting.value) return;
|
||||
markAllReadError.value = "";
|
||||
markAllVisible.value = true;
|
||||
};
|
||||
|
||||
const confirmMarkAllRead = async () => {
|
||||
if (markAllSubmitting.value || !unreadCount.value) return;
|
||||
markAllSubmitting.value = true;
|
||||
markAllReadError.value = "";
|
||||
try {
|
||||
await notificationApi.markAllNotificationsRead({ requestController: markAllReadController });
|
||||
if (!isPageActive) return;
|
||||
markAllVisible.value = false;
|
||||
await loadNotifications();
|
||||
} catch (error) {
|
||||
if (!isPageActive || isRequestCancelled(error)) return;
|
||||
markAllReadError.value = getRequestErrorMessage(error, "全部标记已读失败,请稍后重试。");
|
||||
} finally {
|
||||
if (isPageActive) markAllSubmitting.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const returnToProfile = () => goRoot("M01");
|
||||
const openNotification = (item) => openPage("N02", { id: item.notificationId });
|
||||
onLoad(loadNotifications);
|
||||
onShow(() => {
|
||||
if (notificationListState.value !== "loading") loadNotifications();
|
||||
});
|
||||
onUnload(() => {
|
||||
isPageActive = false;
|
||||
notificationListController.abort();
|
||||
markAllReadController.abort();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@import "../../styles/adaptive-frame-profiles.scss";
|
||||
.message-page {
|
||||
display: flex;
|
||||
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 {
|
||||
box-sizing: border-box;
|
||||
min-height: 340rpx;
|
||||
padding: 78rpx 52rpx 50rpx;
|
||||
text-align: center;
|
||||
@include adaptive-notification-content;
|
||||
}
|
||||
.state-card text {
|
||||
display: block;
|
||||
}
|
||||
.state-card text:first-child {
|
||||
color: $ink;
|
||||
font-family: STKaiti, KaiTi, serif;
|
||||
font-size: clamp(19px, 35rpx, 24px);
|
||||
font-weight: 700;
|
||||
}
|
||||
.state-card text:nth-child(2) {
|
||||
margin-top: 18rpx;
|
||||
color: $ink-muted;
|
||||
font-size: clamp(15px, 24rpx, 18px);
|
||||
line-height: 1.65;
|
||||
}
|
||||
.state-card .app-button {
|
||||
margin-top: 28rpx;
|
||||
}
|
||||
.message-list {
|
||||
display: grid;
|
||||
gap: 16rpx;
|
||||
@include adaptive-notification-content;
|
||||
}
|
||||
.message-row {
|
||||
display: flex;
|
||||
gap: 18rpx;
|
||||
align-items: center;
|
||||
padding: 24rpx;
|
||||
border: 1rpx solid rgba(128, 89, 49, 0.26);
|
||||
border-radius: 12rpx;
|
||||
background: rgba(255, 252, 245, 0.78);
|
||||
}
|
||||
.message-row__body { min-width: 0; flex: 1; }
|
||||
.message-row__title-line { display: flex; gap: 12rpx; align-items: center; }
|
||||
.message-row__title { overflow: hidden; flex: 1; color: $ink; font-size: clamp(16px, 27rpx, 20px); font-weight: 700; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.message-row__unread { flex: 0 0 auto; color: $brand-red; font-size: clamp(12px, 20rpx, 15px); }
|
||||
.message-row__summary, .message-row__meta { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.message-row__summary { margin-top: 9rpx; color: $ink-muted; font-size: clamp(14px, 23rpx, 17px); }
|
||||
.message-row__meta { margin-top: 9rpx; color: #8b7d6c; font-size: clamp(13px, 21rpx, 16px); }
|
||||
.message-row__chevron { color: $ink-muted; font-size: clamp(24px, 42rpx, 30px); line-height: 1; }
|
||||
.message-operation-error {
|
||||
margin-top: 18rpx;
|
||||
color: $brand-red;
|
||||
font-size: clamp(14px, 22rpx, 17px);
|
||||
line-height: 1.5;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,282 @@
|
||||
<template>
|
||||
<view class="message-detail-page">
|
||||
<ModulePageBackground module="notification" />
|
||||
<view class="page-header"
|
||||
><PageHeader
|
||||
title="消息详情"
|
||||
:action="notificationDetail.readStatus === '0' ? (markReadSubmitting ? '正在设置' : '设为已读') : ''"
|
||||
custom-back
|
||||
@action="requestMarkRead"
|
||||
@back="backToMessages"
|
||||
/></view>
|
||||
<view class="page-content">
|
||||
<view v-if="notificationDetailState === 'loading'" class="state-card"
|
||||
><AppLoading text="正在读取消息详情"
|
||||
/></view>
|
||||
<view v-else-if="notificationDetailState === 'error'" class="state-card">
|
||||
<text>暂时无法读取消息详情</text>
|
||||
<text>{{ notificationDetailError || "请稍后重试。" }}</text>
|
||||
<AppButton
|
||||
block
|
||||
type="secondary"
|
||||
label="重新加载"
|
||||
@click="loadDetail"
|
||||
/>
|
||||
<AppButton block label="返回消息中心" @click="backToMessages" />
|
||||
</view>
|
||||
<view v-else-if="notificationDetailState === 'invalid'" class="state-card">
|
||||
<text>消息不存在或链接已失效</text>
|
||||
<text>请返回消息中心查看最新通知。</text>
|
||||
<AppButton block label="返回消息中心" @click="backToMessages" />
|
||||
</view>
|
||||
<view v-else class="detail-card">
|
||||
<text class="detail-title">{{ notificationDetail.noticeTitle || "通知消息" }}</text>
|
||||
<text v-if="notificationDetail.publishTime" class="detail-time">{{
|
||||
notificationDetail.publishTime
|
||||
}}</text>
|
||||
<view class="detail-content"
|
||||
><text>{{ notificationDetail.noticeContent || "暂无通知正文" }}</text></view
|
||||
>
|
||||
<view
|
||||
v-if="
|
||||
notificationDetail.noticeTypeLabel ||
|
||||
notificationDetail.genealogyName ||
|
||||
notificationDetail.senderNickName ||
|
||||
notificationDetail.bizSummary
|
||||
"
|
||||
class="detail-meta"
|
||||
>
|
||||
<view v-if="notificationDetail.noticeTypeLabel" class="detail-meta__row"
|
||||
><text>通知类型</text><text>{{ notificationDetail.noticeTypeLabel }}</text></view
|
||||
>
|
||||
<view v-if="notificationDetail.genealogyName" class="detail-meta__row"
|
||||
><text>所属家谱</text><text>{{ notificationDetail.genealogyName }}</text></view
|
||||
>
|
||||
<view v-if="notificationDetail.senderNickName" class="detail-meta__row"
|
||||
><text>发送人</text><text>{{ notificationDetail.senderNickName }}</text></view
|
||||
>
|
||||
<view v-if="notificationDetail.bizSummary" class="detail-meta__row"
|
||||
><text>关联事项</text><text>{{ notificationDetail.bizSummary }}</text></view
|
||||
>
|
||||
</view>
|
||||
<AppButton v-if="noticeTarget" block :label="noticeTarget.label" @click="openRelatedBusiness" />
|
||||
<text v-if="markReadError" class="detail-operation-error">{{ markReadError }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<AppDialog
|
||||
:visible="markReadVisible"
|
||||
eyebrow="消息状态"
|
||||
title="把这条消息设为已读?"
|
||||
message="这条消息将不再显示为未读。"
|
||||
:confirm-text="markReadSubmitting ? '正在设置' : '确认设为已读'"
|
||||
cancel-text="取消"
|
||||
show-cancel
|
||||
:close-on-mask="false"
|
||||
@confirm="confirmMarkRead"
|
||||
@cancel="markReadVisible = false"
|
||||
/>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref } from "vue";
|
||||
import { onLoad, 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";
|
||||
import {
|
||||
createRequestController,
|
||||
isRequestCancelled
|
||||
} from "@/services/api/request-controller.js";
|
||||
import { notificationApi } from "@/services/api/notification-service.js";
|
||||
import { getRequestErrorMessage } from "@/services/api/request-error-message.js";
|
||||
import { openNoticeTarget, returnTo } from "@/utils/navigation/gateway.js";
|
||||
|
||||
const notificationId = ref("");
|
||||
const notificationDetail = ref({});
|
||||
const notificationDetailState = ref("loading");
|
||||
const notificationDetailError = ref("");
|
||||
const notificationDetailController = createRequestController();
|
||||
const markReadController = createRequestController();
|
||||
const markReadVisible = ref(false);
|
||||
const markReadSubmitting = ref(false);
|
||||
const markReadError = ref("");
|
||||
let isPageActive = true;
|
||||
const noticeTarget = computed(() => {
|
||||
const genealogyId = notificationDetail.value?.genealogyId;
|
||||
const bizId = notificationDetail.value?.bizId;
|
||||
if (notificationDetail.value?.noticeType === "join_apply" && genealogyId) {
|
||||
return { type: "GENEALOGY_REVIEW", params: { genealogyId }, label: "去处理加入申请" };
|
||||
}
|
||||
if (notificationDetail.value?.noticeType === "family_feed" && genealogyId && bizId) {
|
||||
return { type: "FAMILY_FEED", params: { genealogyId, feedId: bizId }, label: "查看相关动态" };
|
||||
}
|
||||
if (notificationDetail.value?.noticeType === "memo_reminder" && genealogyId && bizId) {
|
||||
return { type: "MEMO_REMINDER", params: { genealogyId, memoId: bizId }, label: "查看相关备忘" };
|
||||
}
|
||||
if (notificationDetail.value?.noticeType === "CEREMONY_INVITE") {
|
||||
return { type: "CEREMONY_INVITE", params: {}, label: "查看活动邀请" };
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
const loadDetail = async () => {
|
||||
if (!/^[1-9]\d*$/.test(notificationId.value)) {
|
||||
notificationDetailState.value = "invalid";
|
||||
return;
|
||||
}
|
||||
notificationDetailController.abort();
|
||||
notificationDetailState.value = "loading";
|
||||
notificationDetailError.value = "";
|
||||
try {
|
||||
const loadedNotification = await notificationApi.getNotificationDetail(notificationId.value, {
|
||||
requestController: notificationDetailController,
|
||||
});
|
||||
if (!isPageActive) return;
|
||||
notificationDetail.value = loadedNotification;
|
||||
notificationDetailState.value = "ready";
|
||||
} catch (error) {
|
||||
if (!isPageActive || isRequestCancelled(error)) return;
|
||||
notificationDetailError.value = getRequestErrorMessage(error, "请稍后重试。");
|
||||
notificationDetailState.value = "error";
|
||||
}
|
||||
};
|
||||
|
||||
const backToMessages = () => returnTo("N01");
|
||||
const openRelatedBusiness = () => noticeTarget.value
|
||||
? openNoticeTarget(noticeTarget.value.type, noticeTarget.value.params, "N02")
|
||||
: Promise.resolve(false);
|
||||
const requestMarkRead = () => {
|
||||
if (notificationDetailState.value !== "ready" || notificationDetail.value?.readStatus !== "0" || markReadSubmitting.value)
|
||||
return;
|
||||
markReadError.value = "";
|
||||
markReadVisible.value = true;
|
||||
};
|
||||
const confirmMarkRead = async () => {
|
||||
if (markReadSubmitting.value || notificationDetail.value?.readStatus !== "0") return;
|
||||
markReadSubmitting.value = true;
|
||||
markReadError.value = "";
|
||||
try {
|
||||
await notificationApi.markNotificationRead(notificationId.value, {
|
||||
requestController: markReadController,
|
||||
});
|
||||
if (!isPageActive) return;
|
||||
markReadVisible.value = false;
|
||||
await loadDetail();
|
||||
} catch (error) {
|
||||
if (!isPageActive || isRequestCancelled(error)) return;
|
||||
markReadError.value = getRequestErrorMessage(error, "标记已读失败,请稍后重试。");
|
||||
} finally {
|
||||
if (isPageActive) markReadSubmitting.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
onLoad((options) => {
|
||||
notificationId.value = String(options?.id || "");
|
||||
if (!/^[1-9]\d*$/.test(notificationId.value)) {
|
||||
notificationDetailState.value = "invalid";
|
||||
return;
|
||||
}
|
||||
loadDetail();
|
||||
});
|
||||
onUnload(() => {
|
||||
isPageActive = false;
|
||||
notificationDetailController.abort();
|
||||
markReadController.abort();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@import "../../styles/adaptive-frame-profiles.scss";
|
||||
.message-detail-page {
|
||||
display: flex;
|
||||
min-height: 100vh;
|
||||
flex-direction: column;
|
||||
background: $paper;
|
||||
}
|
||||
.page-header,
|
||||
.page-content {
|
||||
z-index: 1;
|
||||
}
|
||||
.page-content {
|
||||
flex: 1;
|
||||
padding: 18rpx 24rpx 72rpx;
|
||||
}
|
||||
.state-card,
|
||||
.detail-card {
|
||||
box-sizing: border-box;
|
||||
@include adaptive-notification-content;
|
||||
}
|
||||
.state-card {
|
||||
min-height: 340rpx;
|
||||
padding: 78rpx 52rpx 50rpx;
|
||||
text-align: center;
|
||||
}
|
||||
.state-card text {
|
||||
display: block;
|
||||
}
|
||||
.state-card text:first-child,
|
||||
.detail-title {
|
||||
color: $ink;
|
||||
font-family: STKaiti, KaiTi, serif;
|
||||
font-size: clamp(19px, 35rpx, 24px);
|
||||
font-weight: 700;
|
||||
}
|
||||
.state-card text:nth-child(2) {
|
||||
margin-top: 18rpx;
|
||||
color: $ink-muted;
|
||||
font-size: clamp(15px, 24rpx, 18px);
|
||||
line-height: 1.65;
|
||||
}
|
||||
.state-card .app-button {
|
||||
margin-top: 28rpx;
|
||||
}
|
||||
.detail-card {
|
||||
padding: 44rpx 38rpx;
|
||||
}
|
||||
.detail-title,
|
||||
.detail-time {
|
||||
display: block;
|
||||
}
|
||||
.detail-time {
|
||||
margin-top: 16rpx;
|
||||
color: $ink-muted;
|
||||
font-size: clamp(15px, 24rpx, 18px);
|
||||
}
|
||||
.detail-content {
|
||||
margin-top: 36rpx;
|
||||
color: $ink;
|
||||
font-size: clamp(16px, 29rpx, 20px);
|
||||
line-height: 1.8;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
.detail-meta {
|
||||
margin-top: 42rpx;
|
||||
padding-top: 24rpx;
|
||||
border-top: 1rpx solid rgba(91, 70, 42, 0.16);
|
||||
}
|
||||
.detail-meta__row {
|
||||
display: flex;
|
||||
gap: 22rpx;
|
||||
padding: 10rpx 0;
|
||||
color: $ink-muted;
|
||||
font-size: clamp(15px, 24rpx, 18px);
|
||||
line-height: 1.5;
|
||||
}
|
||||
.detail-meta__row text:first-child {
|
||||
flex: 0 0 116rpx;
|
||||
}
|
||||
.detail-meta__row text:last-child {
|
||||
flex: 1;
|
||||
color: $ink;
|
||||
}
|
||||
.detail-operation-error {
|
||||
display: block;
|
||||
margin-top: 22rpx;
|
||||
color: $brand-red;
|
||||
font-size: clamp(14px, 22rpx, 17px);
|
||||
line-height: 1.5;
|
||||
}
|
||||
</style>
|
||||
@@ -1,187 +0,0 @@
|
||||
<!-- 页面编号:N-01;用途:读取当前账号的消息通知。 -->
|
||||
<template>
|
||||
<view class="message-page">
|
||||
<ModulePageBackground module="notification" />
|
||||
<view class="page-header"><PageHeader root title="消息中心" /></view>
|
||||
<view class="page-content">
|
||||
<view v-if="state === 'loading'" class="state-card"
|
||||
><AppLoading text="正在读取消息"
|
||||
/></view>
|
||||
<view v-else-if="state === 'error'" class="state-card"
|
||||
><text>暂时无法读取消息</text
|
||||
><AppButton
|
||||
block
|
||||
type="secondary"
|
||||
label="重新加载"
|
||||
@click="loadNotifications"
|
||||
/></view>
|
||||
<view v-else-if="state === 'empty'" class="state-card"
|
||||
><text>暂无消息</text><text>消息列表会直接从服务端读取。</text
|
||||
><AppButton block label="返回我的" @click="returnToProfile"
|
||||
/></view>
|
||||
<view v-else class="message-list">
|
||||
<text class="message-list__count">共 {{ notifications.length }} 条消息</text>
|
||||
<view
|
||||
v-for="(item, index) in notifications"
|
||||
:key="notificationKey(item, index)"
|
||||
class="message-card"
|
||||
>
|
||||
<view class="message-card__heading"
|
||||
><text>{{ notificationTitle(item) }}</text><text>消息</text></view
|
||||
>
|
||||
<text class="message-card__summary">{{ notificationSummary(item) }}</text>
|
||||
<text v-if="notificationTime(item)" class="message-card__time">{{
|
||||
notificationTime(item)
|
||||
}}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<AppTabbar active="profile" />
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from "vue";
|
||||
import { onLoad, onShow, onUnload } from "@dcloudio/uni-app";
|
||||
import AppButton from "@/components/AppButton.vue";
|
||||
import AppLoading from "@/components/AppLoading.vue";
|
||||
import AppTabbar from "@/components/AppTabbar.vue";
|
||||
import ModulePageBackground from "@/components/ModulePageBackground.vue";
|
||||
import PageHeader from "@/components/PageHeader.vue";
|
||||
import {
|
||||
appApi,
|
||||
createRequestController,
|
||||
isRequestCancelled,
|
||||
} from "@/utils/api.js";
|
||||
import { goRoot } from "@/utils/navigation.js";
|
||||
|
||||
const notifications = ref([]);
|
||||
const state = ref("loading");
|
||||
const controller = createRequestController();
|
||||
let active = true;
|
||||
const notificationKey = (item, index) =>
|
||||
String(item?.notificationId ?? item?.id ?? index);
|
||||
const notificationTitle = (item) =>
|
||||
String(item?.noticeTitle ?? item?.title ?? "通知消息");
|
||||
const notificationSummary = (item) =>
|
||||
String(item?.noticeContent ?? item?.content ?? "点击查看消息详情");
|
||||
const notificationTime = (item) =>
|
||||
String(item?.publishTime ?? item?.time ?? item?.createdAt ?? "");
|
||||
const loadNotifications = async () => {
|
||||
controller.abort();
|
||||
state.value = "loading";
|
||||
try {
|
||||
notifications.value = await appApi.getNotifications({
|
||||
requestController: controller,
|
||||
});
|
||||
if (!active) return;
|
||||
state.value = notifications.value.length ? "ready" : "empty";
|
||||
} catch (error) {
|
||||
if (!active || isRequestCancelled(error)) return;
|
||||
state.value = "error";
|
||||
}
|
||||
};
|
||||
const returnToProfile = () => goRoot("M01");
|
||||
onLoad(loadNotifications);
|
||||
onShow(() => {
|
||||
if (state.value !== "loading") loadNotifications();
|
||||
});
|
||||
onUnload(() => {
|
||||
active = false;
|
||||
controller.abort();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@import "../../styles/adaptive-frame-profiles.scss";
|
||||
.message-page {
|
||||
display: flex;
|
||||
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 {
|
||||
box-sizing: border-box;
|
||||
min-height: 340rpx;
|
||||
padding: 78rpx 52rpx 50rpx;
|
||||
text-align: center;
|
||||
@include adaptive-family-content;
|
||||
}
|
||||
.state-card text {
|
||||
display: block;
|
||||
}
|
||||
.state-card text:first-child {
|
||||
color: $ink;
|
||||
font-family: STKaiti, KaiTi, serif;
|
||||
font-size: clamp(19px, 35rpx, 24px);
|
||||
font-weight: 700;
|
||||
}
|
||||
.state-card text:nth-child(2) {
|
||||
margin-top: 18rpx;
|
||||
color: $ink-muted;
|
||||
font-size: clamp(15px, 24rpx, 18px);
|
||||
line-height: 1.65;
|
||||
}
|
||||
.state-card .app-button {
|
||||
margin-top: 28rpx;
|
||||
}
|
||||
.message-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16rpx;
|
||||
}
|
||||
.message-list__count {
|
||||
display: block;
|
||||
color: $ink-muted;
|
||||
font-size: clamp(13px, 21rpx, 16px);
|
||||
}
|
||||
.message-card {
|
||||
padding: 28rpx 34rpx;
|
||||
@include adaptive-family-content;
|
||||
}
|
||||
.message-card__heading {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
gap: 16rpx;
|
||||
}
|
||||
.message-card__heading text:first-child {
|
||||
color: $ink;
|
||||
font-family: STKaiti, KaiTi, serif;
|
||||
font-size: clamp(17px, 30rpx, 22px);
|
||||
font-weight: 700;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.message-card__heading text:last-child {
|
||||
color: $brand-red;
|
||||
font-size: clamp(13px, 21rpx, 16px);
|
||||
}
|
||||
.message-card__summary,
|
||||
.message-card__time {
|
||||
display: block;
|
||||
}
|
||||
.message-card__summary {
|
||||
display: -webkit-box;
|
||||
margin-top: 12rpx;
|
||||
overflow: hidden;
|
||||
color: $ink-muted;
|
||||
font-size: clamp(14px, 22rpx, 17px);
|
||||
line-height: 1.55;
|
||||
overflow-wrap: anywhere;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 2;
|
||||
}
|
||||
.message-card__time {
|
||||
margin-top: 12rpx;
|
||||
color: #8b7d6c;
|
||||
font-size: clamp(13px, 20rpx, 16px);
|
||||
}
|
||||
</style>
|
||||
@@ -1,205 +0,0 @@
|
||||
<!-- 页面编号:N-02;用途:按通知 ID 读取服务端通知详情。 -->
|
||||
<template>
|
||||
<view class="message-detail-page">
|
||||
<ModulePageBackground module="notification" />
|
||||
<view class="page-header"
|
||||
><PageHeader title="消息详情" custom-back @back="backToMessages"
|
||||
/></view>
|
||||
<view class="page-content">
|
||||
<view v-if="state === 'loading'" class="state-card"
|
||||
><AppLoading text="正在读取消息详情"
|
||||
/></view>
|
||||
<view v-else-if="state === 'error'" class="state-card">
|
||||
<text>暂时无法读取消息详情</text>
|
||||
<text>{{ loadError || "请稍后重试。" }}</text>
|
||||
<AppButton
|
||||
block
|
||||
type="secondary"
|
||||
label="重新加载"
|
||||
@click="loadDetail"
|
||||
/>
|
||||
<AppButton block label="返回消息中心" @click="backToMessages" />
|
||||
</view>
|
||||
<view v-else-if="state === 'invalid'" class="state-card">
|
||||
<text>消息不存在或链接已失效</text>
|
||||
<text>请返回消息中心查看最新通知。</text>
|
||||
<AppButton block label="返回消息中心" @click="backToMessages" />
|
||||
</view>
|
||||
<view v-else class="detail-card">
|
||||
<text class="detail-title">{{ detail.noticeTitle || "通知消息" }}</text>
|
||||
<text v-if="detail.publishTime" class="detail-time">{{
|
||||
detail.publishTime
|
||||
}}</text>
|
||||
<view class="detail-content"
|
||||
><text>{{ detail.noticeContent || "暂无通知正文" }}</text></view
|
||||
>
|
||||
<view
|
||||
v-if="
|
||||
detail.noticeType ||
|
||||
detail.genealogyName ||
|
||||
detail.senderNickName ||
|
||||
detail.bizSummary
|
||||
"
|
||||
class="detail-meta"
|
||||
>
|
||||
<view v-if="detail.noticeType" class="detail-meta__row"
|
||||
><text>通知类型</text><text>{{ detail.noticeType }}</text></view
|
||||
>
|
||||
<view v-if="detail.genealogyName" class="detail-meta__row"
|
||||
><text>所属家谱</text><text>{{ detail.genealogyName }}</text></view
|
||||
>
|
||||
<view v-if="detail.senderNickName" class="detail-meta__row"
|
||||
><text>发送人</text><text>{{ detail.senderNickName }}</text></view
|
||||
>
|
||||
<view v-if="detail.bizSummary" class="detail-meta__row"
|
||||
><text>关联事项</text><text>{{ detail.bizSummary }}</text></view
|
||||
>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from "vue";
|
||||
import { onLoad, onUnload } from "@dcloudio/uni-app";
|
||||
import AppButton from "@/components/AppButton.vue";
|
||||
import AppLoading from "@/components/AppLoading.vue";
|
||||
import ModulePageBackground from "@/components/ModulePageBackground.vue";
|
||||
import PageHeader from "@/components/PageHeader.vue";
|
||||
import {
|
||||
appApi,
|
||||
createRequestController,
|
||||
isRequestCancelled,
|
||||
} from "@/utils/api.js";
|
||||
import { returnTo } from "@/utils/navigation.js";
|
||||
|
||||
const notificationId = ref("");
|
||||
const detail = ref({});
|
||||
const state = ref("loading");
|
||||
const loadError = ref("");
|
||||
const controller = createRequestController();
|
||||
let active = true;
|
||||
|
||||
const loadDetail = async () => {
|
||||
if (!/^[1-9]\d*$/.test(notificationId.value)) {
|
||||
state.value = "invalid";
|
||||
return;
|
||||
}
|
||||
controller.abort();
|
||||
state.value = "loading";
|
||||
loadError.value = "";
|
||||
try {
|
||||
detail.value = await appApi.getNotificationDetail(notificationId.value, {
|
||||
requestController: controller,
|
||||
});
|
||||
if (!active) return;
|
||||
state.value = "ready";
|
||||
} catch (error) {
|
||||
if (!active || isRequestCancelled(error)) return;
|
||||
loadError.value = error?.message || "请稍后重试。";
|
||||
state.value = "error";
|
||||
}
|
||||
};
|
||||
|
||||
const backToMessages = () => returnTo("N01");
|
||||
|
||||
onLoad((options) => {
|
||||
notificationId.value = String(options?.id || "");
|
||||
if (!/^[1-9]\d*$/.test(notificationId.value)) {
|
||||
state.value = "invalid";
|
||||
return;
|
||||
}
|
||||
loadDetail();
|
||||
});
|
||||
onUnload(() => {
|
||||
active = false;
|
||||
controller.abort();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@import "../../styles/adaptive-frame-profiles.scss";
|
||||
.message-detail-page {
|
||||
display: flex;
|
||||
min-height: 100vh;
|
||||
flex-direction: column;
|
||||
background: $paper;
|
||||
}
|
||||
.page-header,
|
||||
.page-content {
|
||||
z-index: 1;
|
||||
}
|
||||
.page-content {
|
||||
flex: 1;
|
||||
padding: 18rpx 24rpx 72rpx;
|
||||
}
|
||||
.state-card,
|
||||
.detail-card {
|
||||
box-sizing: border-box;
|
||||
@include adaptive-family-content;
|
||||
}
|
||||
.state-card {
|
||||
min-height: 340rpx;
|
||||
padding: 78rpx 52rpx 50rpx;
|
||||
text-align: center;
|
||||
}
|
||||
.state-card text {
|
||||
display: block;
|
||||
}
|
||||
.state-card text:first-child,
|
||||
.detail-title {
|
||||
color: $ink;
|
||||
font-family: STKaiti, KaiTi, serif;
|
||||
font-size: clamp(19px, 35rpx, 24px);
|
||||
font-weight: 700;
|
||||
}
|
||||
.state-card text:nth-child(2) {
|
||||
margin-top: 18rpx;
|
||||
color: $ink-muted;
|
||||
font-size: clamp(15px, 24rpx, 18px);
|
||||
line-height: 1.65;
|
||||
}
|
||||
.state-card .app-button {
|
||||
margin-top: 28rpx;
|
||||
}
|
||||
.detail-card {
|
||||
padding: 44rpx 38rpx;
|
||||
}
|
||||
.detail-title,
|
||||
.detail-time {
|
||||
display: block;
|
||||
}
|
||||
.detail-time {
|
||||
margin-top: 16rpx;
|
||||
color: $ink-muted;
|
||||
font-size: clamp(15px, 24rpx, 18px);
|
||||
}
|
||||
.detail-content {
|
||||
margin-top: 36rpx;
|
||||
color: $ink;
|
||||
font-size: clamp(16px, 29rpx, 20px);
|
||||
line-height: 1.8;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
.detail-meta {
|
||||
margin-top: 42rpx;
|
||||
padding-top: 24rpx;
|
||||
border-top: 1rpx solid rgba(91, 70, 42, 0.16);
|
||||
}
|
||||
.detail-meta__row {
|
||||
display: flex;
|
||||
gap: 22rpx;
|
||||
padding: 10rpx 0;
|
||||
color: $ink-muted;
|
||||
font-size: clamp(15px, 24rpx, 18px);
|
||||
line-height: 1.5;
|
||||
}
|
||||
.detail-meta__row text:first-child {
|
||||
flex: 0 0 116rpx;
|
||||
}
|
||||
.detail-meta__row text:last-child {
|
||||
flex: 1;
|
||||
color: $ink;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,301 @@
|
||||
<template>
|
||||
<view class="invitation-page">
|
||||
<ModulePageBackground module="profile" />
|
||||
<view class="page-layer"
|
||||
><PageHeader title="我的活动邀请" custom-back @back="requestBack"
|
||||
/></view>
|
||||
|
||||
<view class="page-content page-layer">
|
||||
<AppLoading v-if="ceremonyInvitationListState === 'loading'" text="正在加载活动邀请" />
|
||||
<view v-else-if="ceremonyInvitationListState === 'error'" class="state-card">
|
||||
<text>暂时无法加载活动邀请</text>
|
||||
<text>请检查网络后重新查看。</text>
|
||||
<AppButton block type="secondary" label="重新查看" @click="loadInvitations" />
|
||||
</view>
|
||||
<view v-else-if="ceremonyInvitationListState === 'empty'" class="state-card">
|
||||
<text>暂时没有活动邀请</text>
|
||||
<text>收到家族礼仪活动邀请后,会在这里显示。</text>
|
||||
<AppButton block type="secondary" label="返回我的" @click="requestBack" />
|
||||
</view>
|
||||
<view v-else class="invitation-list">
|
||||
<text class="page-note">你可以在这里选择接受或拒绝待处理的活动邀请。</text>
|
||||
<text v-if="actionError" class="action-error">{{ actionError }}</text>
|
||||
<view
|
||||
v-for="invitation in invitations"
|
||||
:key="invitation.id"
|
||||
class="invitation-card"
|
||||
>
|
||||
<view class="invitation-card__heading">
|
||||
<text>{{ invitation.ceremonyTitle || "未命名活动" }}</text>
|
||||
<text :class="`status status--${invitation.inviteStatus.toLowerCase()}`">
|
||||
{{ invitationStatusLabel(invitation.inviteStatus) }}
|
||||
</text>
|
||||
</view>
|
||||
<text v-if="invitation.ceremonyTime" class="invitation-card__meta">
|
||||
时间:{{ invitation.ceremonyTime }}
|
||||
</text>
|
||||
<text v-if="invitationLocation(invitation)" class="invitation-card__meta">
|
||||
地点:{{ invitationLocation(invitation) }}
|
||||
</text>
|
||||
<text v-if="invitation.deliveredTime" class="invitation-card__meta">
|
||||
送达:{{ invitation.deliveredTime }}
|
||||
</text>
|
||||
<text v-if="invitation.responseTime" class="invitation-card__meta">
|
||||
处理时间:{{ invitation.responseTime }}
|
||||
</text>
|
||||
<view
|
||||
v-if="invitation.inviteStatus === CEREMONY_INVITATION_STATUS.PENDING"
|
||||
class="invitation-card__actions"
|
||||
>
|
||||
<AppButton
|
||||
compact
|
||||
type="secondary"
|
||||
:disabled="Boolean(respondingId)"
|
||||
label="拒绝"
|
||||
@click="openResponseDialog(invitation, CEREMONY_INVITATION_STATUS.DECLINED)"
|
||||
/>
|
||||
<AppButton
|
||||
compact
|
||||
:disabled="Boolean(respondingId)"
|
||||
:label="respondingId === invitation.id ? '正在提交' : '接受'"
|
||||
@click="openResponseDialog(invitation, CEREMONY_INVITATION_STATUS.ACCEPTED)"
|
||||
/>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<AppDialog
|
||||
:visible="Boolean(responseTarget)"
|
||||
:close-on-mask="false"
|
||||
:title="responseDialogTitle"
|
||||
:message="responseDialogMessage"
|
||||
:confirm-text="responseTarget?.status === CEREMONY_INVITATION_STATUS.ACCEPTED ? '确认接受' : '确认拒绝'"
|
||||
cancel-text="暂不处理"
|
||||
show-cancel
|
||||
@confirm="submitResponse"
|
||||
@cancel="closeResponseDialog"
|
||||
/>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref } from "vue";
|
||||
import { onBackPress, 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";
|
||||
import {
|
||||
CEREMONY_INVITATION_STATUS,
|
||||
CEREMONY_INVITATION_STATUS_LABELS
|
||||
} from "@/services/api/ceremony-contract.js";
|
||||
import {
|
||||
createRequestController,
|
||||
isRequestCancelled
|
||||
} from "@/services/api/request-controller.js";
|
||||
import { ceremonyApi } from "@/services/api/ceremony-service.js";
|
||||
import { getRequestErrorMessage } from "@/services/api/request-error-message.js";
|
||||
import { handleBackPress, returnTo } from "@/utils/navigation/gateway.js";
|
||||
|
||||
const invitations = ref([]);
|
||||
const ceremonyInvitationListState = ref("loading");
|
||||
const actionError = ref("");
|
||||
const responseTarget = ref(null);
|
||||
const respondingId = ref("");
|
||||
const invitationListController = createRequestController();
|
||||
const invitationResponseController = createRequestController();
|
||||
let isPageActive = true;
|
||||
|
||||
const responseDialogTitle = computed(() =>
|
||||
responseTarget.value?.status === CEREMONY_INVITATION_STATUS.ACCEPTED
|
||||
? "接受活动邀请?"
|
||||
: "拒绝活动邀请?",
|
||||
);
|
||||
const responseDialogMessage = computed(() => {
|
||||
const title = responseTarget.value?.item?.ceremonyTitle || "此活动";
|
||||
return responseTarget.value?.status === CEREMONY_INVITATION_STATUS.ACCEPTED
|
||||
? `确认接受「${title}」的邀请?`
|
||||
: `确认拒绝「${title}」的邀请?`;
|
||||
});
|
||||
|
||||
const invitationStatusLabel = (status) =>
|
||||
CEREMONY_INVITATION_STATUS_LABELS[status] || "暂未确认";
|
||||
const invitationLocation = (invitation) =>
|
||||
[invitation.location, invitation.locationAddress].filter(Boolean).join(" · ");
|
||||
|
||||
const loadInvitations = async () => {
|
||||
if (respondingId.value) return;
|
||||
invitationListController.abort();
|
||||
ceremonyInvitationListState.value = "loading";
|
||||
actionError.value = "";
|
||||
try {
|
||||
const rows = await ceremonyApi.getMyCeremonyInvitations({
|
||||
requestController: invitationListController,
|
||||
});
|
||||
if (!isPageActive) return;
|
||||
invitations.value = rows;
|
||||
ceremonyInvitationListState.value = rows.length ? "ready" : "empty";
|
||||
} catch (error) {
|
||||
if (!isPageActive || isRequestCancelled(error)) return;
|
||||
ceremonyInvitationListState.value = "error";
|
||||
}
|
||||
};
|
||||
|
||||
const openResponseDialog = (invitation, status) => {
|
||||
if (
|
||||
respondingId.value ||
|
||||
invitation.inviteStatus !== CEREMONY_INVITATION_STATUS.PENDING
|
||||
) return;
|
||||
responseTarget.value = { item: invitation, status };
|
||||
actionError.value = "";
|
||||
};
|
||||
const closeResponseDialog = () => {
|
||||
if (!respondingId.value) responseTarget.value = null;
|
||||
};
|
||||
const submitResponse = async () => {
|
||||
const target = responseTarget.value;
|
||||
if (!target || respondingId.value) return;
|
||||
respondingId.value = target.item.id;
|
||||
actionError.value = "";
|
||||
try {
|
||||
await ceremonyApi.respondToCeremonyInvitation(
|
||||
target.item.genealogyId,
|
||||
target.item.ceremonyId,
|
||||
{ inviteStatus: target.status },
|
||||
{ requestController: invitationResponseController },
|
||||
);
|
||||
if (!isPageActive) return;
|
||||
responseTarget.value = null;
|
||||
respondingId.value = "";
|
||||
await loadInvitations();
|
||||
} catch (error) {
|
||||
if (!isPageActive || isRequestCancelled(error)) return;
|
||||
responseTarget.value = null;
|
||||
actionError.value = getRequestErrorMessage(error, "处理未完成,请稍后重试。");
|
||||
} finally {
|
||||
if (isPageActive) respondingId.value = "";
|
||||
}
|
||||
};
|
||||
const requestBack = () => returnTo("M01", {});
|
||||
|
||||
onShow(() => {
|
||||
if (!respondingId.value) loadInvitations();
|
||||
});
|
||||
onBackPress((event) => handleBackPress(event, requestBack));
|
||||
onUnload(() => {
|
||||
isPageActive = false;
|
||||
invitationListController.abort();
|
||||
invitationResponseController.abort();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.invitation-page {
|
||||
min-height: 100vh;
|
||||
color: #35251d;
|
||||
}
|
||||
|
||||
.page-layer,
|
||||
.page-content {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.page-content {
|
||||
padding: 36rpx 32rpx 68rpx;
|
||||
}
|
||||
|
||||
.state-card,
|
||||
.invitation-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 18rpx;
|
||||
padding: 34rpx 30rpx;
|
||||
border: 1rpx solid rgba(172, 121, 37, 0.48);
|
||||
border-radius: 16rpx;
|
||||
background: rgba(255, 249, 236, 0.9);
|
||||
box-shadow: 0 8rpx 24rpx rgba(90, 44, 20, 0.08);
|
||||
}
|
||||
|
||||
.state-card {
|
||||
margin-top: 32rpx;
|
||||
text-align: center;
|
||||
color: #76665b;
|
||||
}
|
||||
|
||||
.state-card text:first-child {
|
||||
color: #3d2b20;
|
||||
font-size: clamp(20px, 36rpx, 25px);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.invitation-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 22rpx;
|
||||
}
|
||||
|
||||
.page-note {
|
||||
color: #887467;
|
||||
font-size: clamp(16px, 26rpx, 19px);
|
||||
}
|
||||
|
||||
.action-error {
|
||||
padding: 18rpx 22rpx;
|
||||
border-radius: 10rpx;
|
||||
background: rgba(177, 43, 31, 0.1);
|
||||
color: #a22b20;
|
||||
font-size: clamp(16px, 26rpx, 19px);
|
||||
}
|
||||
|
||||
.invitation-card__heading,
|
||||
.invitation-card__actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 20rpx;
|
||||
}
|
||||
|
||||
.invitation-card__heading text:first-child {
|
||||
flex: 1;
|
||||
color: #40291e;
|
||||
font-size: clamp(19px, 34rpx, 24px);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.invitation-card__meta {
|
||||
color: #746256;
|
||||
font-size: clamp(16px, 27rpx, 20px);
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.status {
|
||||
flex: 0 0 auto;
|
||||
padding: 6rpx 14rpx;
|
||||
border-radius: 999rpx;
|
||||
font-size: clamp(15px, 24rpx, 18px);
|
||||
}
|
||||
|
||||
.status--pending {
|
||||
background: rgba(183, 124, 30, 0.13);
|
||||
color: #9c6412;
|
||||
}
|
||||
|
||||
.status--accepted {
|
||||
background: rgba(47, 113, 71, 0.12);
|
||||
color: #2f7147;
|
||||
}
|
||||
|
||||
.status--declined,
|
||||
.status--canceled {
|
||||
background: rgba(137, 67, 51, 0.1);
|
||||
color: #874333;
|
||||
}
|
||||
|
||||
.invitation-card__actions {
|
||||
justify-content: flex-end;
|
||||
padding-top: 8rpx;
|
||||
}
|
||||
</style>
|
||||
@@ -1,4 +1,3 @@
|
||||
<!-- 页面编号:M-04;用途:修改登录密码。 -->
|
||||
<template>
|
||||
<view
|
||||
class="password-page"
|
||||
@@ -58,7 +57,7 @@
|
||||
:close-on-mask="false"
|
||||
eyebrow="放弃确认"
|
||||
title="放弃密码填写?"
|
||||
message="当前密码和新密码尚未提交服务器。"
|
||||
message="新密码还没有保存。"
|
||||
confirm-text="确认放弃"
|
||||
cancel-text="继续填写"
|
||||
show-cancel
|
||||
@@ -77,17 +76,19 @@ import AppToast from "@/components/AppToast.vue";
|
||||
import ModulePageBackground from "@/components/ModulePageBackground.vue";
|
||||
import PageHeader from "@/components/PageHeader.vue";
|
||||
import {
|
||||
appApi,
|
||||
createRequestController,
|
||||
isRequestCancelled,
|
||||
} from "@/utils/api.js";
|
||||
isRequestCancelled
|
||||
} from "@/services/api/request-controller.js";
|
||||
import { authApi } from "@/services/api/auth-service.js";
|
||||
import { getRequestErrorMessage } from "@/services/api/request-error-message.js";
|
||||
import { createDiscardConfirmation } from "@/utils/discard-confirmation.js";
|
||||
import { calcMD5 } from "@/utils/md5.js";
|
||||
import { handleBackPress, runBackGuard } from "@/utils/navigation.js";
|
||||
import { createNonIdempotentWriteGuard } from "@/utils/non-idempotent-write-guard.js";
|
||||
import { handleBackPress, runBackGuard } from "@/utils/navigation/gateway.js";
|
||||
import {
|
||||
PASSWORD_POLICY_MESSAGE,
|
||||
validatePassword,
|
||||
} from "@/utils/validation.js";
|
||||
} from "@/utils/auth/password-policy.js";
|
||||
|
||||
const passwordForm = reactive({ current: "", next: "", confirm: "" });
|
||||
const passwordVisible = reactive({
|
||||
@@ -106,7 +107,8 @@ const passwordFields = [
|
||||
const toastVisible = ref(false);
|
||||
const toastMessage = ref("");
|
||||
let timer = null;
|
||||
const passwordRequestController = createRequestController();
|
||||
const passwordChangeRequestController = createRequestController();
|
||||
const passwordChangeGuard = createNonIdempotentWriteGuard();
|
||||
let pageActive = true;
|
||||
const formSnapshot = computed(() => JSON.stringify(passwordForm));
|
||||
const baseline = ref(formSnapshot.value);
|
||||
@@ -141,14 +143,22 @@ const validateForm = () => {
|
||||
};
|
||||
const savePassword = async () => {
|
||||
if (!validateForm() || passwordState.value === "saving") return;
|
||||
const passwordChangePayload = {
|
||||
oldPasswordHash: calcMD5(passwordForm.current),
|
||||
newPasswordHash: calcMD5(passwordForm.next),
|
||||
};
|
||||
const passwordChangeAttempt = passwordChangeGuard.begin(passwordChangePayload);
|
||||
if (passwordChangeAttempt === null) {
|
||||
showToast(
|
||||
"上次修改结果暂时无法确认,请重新登录验证新密码,不要重复提交",
|
||||
);
|
||||
return;
|
||||
}
|
||||
passwordState.value = "saving";
|
||||
try {
|
||||
await appApi.changePassword(
|
||||
{
|
||||
oldPasswordHash: calcMD5(passwordForm.current),
|
||||
newPasswordHash: calcMD5(passwordForm.next),
|
||||
},
|
||||
{ requestController: passwordRequestController },
|
||||
await authApi.changePassword(
|
||||
passwordChangePayload,
|
||||
{ requestController: passwordChangeRequestController },
|
||||
);
|
||||
if (!pageActive) return;
|
||||
passwordForm.current = "";
|
||||
@@ -157,9 +167,19 @@ const savePassword = async () => {
|
||||
baseline.value = formSnapshot.value;
|
||||
showToast("密码修改成功");
|
||||
} catch (error) {
|
||||
if (pageActive && !isRequestCancelled(error)) {
|
||||
showToast(error?.message || "密码修改失败,请稍后重试");
|
||||
if (!pageActive) return;
|
||||
if (passwordChangeGuard.recordFailure(passwordChangeAttempt, error)) {
|
||||
showToast(
|
||||
"密码修改结果暂时无法确认,请重新登录验证新密码,不要重复提交",
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (!isRequestCancelled(error))
|
||||
showToast(
|
||||
error?.code
|
||||
? getRequestErrorMessage(error, "密码修改失败,请稍后重试")
|
||||
: error?.message || "密码修改失败,请稍后重试",
|
||||
);
|
||||
} finally {
|
||||
if (pageActive) passwordState.value = "ready";
|
||||
}
|
||||
@@ -176,7 +196,7 @@ const requestBack = () =>
|
||||
onBackPress((event) => handleBackPress(event, requestBack));
|
||||
onUnload(() => {
|
||||
pageActive = false;
|
||||
passwordRequestController.abort();
|
||||
passwordChangeRequestController.abort();
|
||||
clearTimeout(timer);
|
||||
discardConfirmation.dispose();
|
||||
});
|
||||
@@ -1,4 +1,3 @@
|
||||
<!-- 页面编号:M-05;用途:换绑已登录账号的手机号。 -->
|
||||
<template>
|
||||
<view
|
||||
class="phone-page"
|
||||
@@ -14,7 +13,7 @@
|
||||
<view class="page-content page-layer">
|
||||
<view class="security-tip"
|
||||
><text>验证新手机号</text
|
||||
><text>完成行为验证和短信校验后,立即更新当前账号的登录手机号。</text></view
|
||||
><text>完成安全验证和短信校验后,即可更新你的登录手机号。</text></view
|
||||
>
|
||||
<view class="form-panel">
|
||||
<view class="field-block">
|
||||
@@ -73,7 +72,7 @@
|
||||
:close-on-mask="false"
|
||||
eyebrow="放弃确认"
|
||||
title="放弃手机号换绑?"
|
||||
message="新手机号和验证码尚未提交服务器。"
|
||||
message="新手机号还没有保存。"
|
||||
confirm-text="确认放弃"
|
||||
cancel-text="继续填写"
|
||||
show-cancel
|
||||
@@ -91,46 +90,34 @@ 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 TacVerification from "@/components/TacVerification.vue";
|
||||
import TacVerification from "@/components/auth/TacVerification.vue";
|
||||
import { useSmsVerification } from "@/composables/auth/use-sms-verification.js";
|
||||
import {
|
||||
appApi,
|
||||
createRequestController,
|
||||
isRequestCancelled,
|
||||
} from "@/utils/api.js";
|
||||
isRequestCancelled
|
||||
} from "@/services/api/request-controller.js";
|
||||
import { authApi } from "@/services/api/auth-service.js";
|
||||
import { getRequestErrorMessage } from "@/services/api/request-error-message.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";
|
||||
} from "@/utils/auth/verification.js";
|
||||
import { createDiscardConfirmation } from "@/utils/discard-confirmation.js";
|
||||
import { handleBackPress, runBackGuard } from "@/utils/navigation.js";
|
||||
import { createNonIdempotentWriteGuard } from "@/utils/non-idempotent-write-guard.js";
|
||||
import { handleBackPress, runBackGuard } from "@/utils/navigation/gateway.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 submittingPhoneChange = ref(false);
|
||||
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 phoneChangeController = createRequestController();
|
||||
const phoneChangeGuard = createNonIdempotentWriteGuard();
|
||||
const discardConfirmation = createDiscardConfirmation((visible) => {
|
||||
discardVisible.value = visible;
|
||||
});
|
||||
@@ -138,8 +125,7 @@ const requestDiscardConfirmation = discardConfirmation.request;
|
||||
const confirmDiscard = discardConfirmation.confirm;
|
||||
const cancelDiscard = discardConfirmation.cancel;
|
||||
let toastTimer = null;
|
||||
let tacSequence = 0;
|
||||
let pageActive = true;
|
||||
let isPageActive = true;
|
||||
|
||||
const showToast = (message) => {
|
||||
toastMessage.value = message;
|
||||
@@ -150,10 +136,35 @@ const showToast = (message) => {
|
||||
}, 2200);
|
||||
};
|
||||
|
||||
const closeTac = () => {
|
||||
tacVisible.value = false;
|
||||
tacContext.value = null;
|
||||
};
|
||||
const smsVerification = useSmsVerification({
|
||||
operationCode: AUTH_VERIFICATION_OPERATION.PHONE_CHANGE,
|
||||
requestIdPrefix: "phone-change",
|
||||
phone,
|
||||
isActive: () => isPageActive,
|
||||
showFeedback: showToast,
|
||||
getErrorMessage: (error, fallback) =>
|
||||
error?.code
|
||||
? getRequestErrorMessage(error, fallback)
|
||||
: error?.message || fallback,
|
||||
});
|
||||
const {
|
||||
tacVisible,
|
||||
tacContext,
|
||||
sendingCode,
|
||||
cooldownSeconds,
|
||||
sentPhone,
|
||||
closeTac,
|
||||
completeTac,
|
||||
handleTacFailure,
|
||||
handleTacError,
|
||||
} = smsVerification;
|
||||
const phoneState = computed(() =>
|
||||
submittingPhoneChange.value
|
||||
? "submitting"
|
||||
: sendingCode.value
|
||||
? "sending"
|
||||
: "ready",
|
||||
);
|
||||
|
||||
const handlePhoneInput = () => {
|
||||
errors.phone = "";
|
||||
@@ -163,21 +174,6 @@ const handlePhoneInput = () => {
|
||||
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)) {
|
||||
@@ -185,79 +181,7 @@ const prepareGetCode = async () => {
|
||||
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 || "安全验证暂不可用");
|
||||
return smsVerification.requestCode();
|
||||
};
|
||||
|
||||
const validateForm = () => {
|
||||
@@ -273,24 +197,34 @@ const validateForm = () => {
|
||||
|
||||
const submitPhoneChange = async () => {
|
||||
if (phoneState.value !== "ready" || !validateForm()) return;
|
||||
phoneState.value = "submitting";
|
||||
const phoneChangePayload = { phone: phone.value, smsCode: smsCode.value };
|
||||
const phoneChangeAttempt = phoneChangeGuard.begin(phoneChangePayload);
|
||||
if (phoneChangeAttempt === null) {
|
||||
showToast("上次换绑结果暂时无法确认,请重新登录确认手机号,不要重复提交");
|
||||
return;
|
||||
}
|
||||
submittingPhoneChange.value = true;
|
||||
try {
|
||||
await appApi.changePhone(
|
||||
{ phone: phone.value, smsCode: smsCode.value },
|
||||
{ requestController },
|
||||
await authApi.changePhone(
|
||||
phoneChangePayload,
|
||||
{ requestController: phoneChangeController },
|
||||
);
|
||||
if (!pageActive) return;
|
||||
if (!isPageActive) return;
|
||||
phone.value = "";
|
||||
smsCode.value = "";
|
||||
sentPhone.value = "";
|
||||
baseline.value = formSnapshot.value;
|
||||
showToast("手机号换绑成功");
|
||||
} catch (error) {
|
||||
if (pageActive && !isRequestCancelled(error)) {
|
||||
showToast(error?.message || "换绑结果未知,请重新登录确认手机号");
|
||||
if (!isPageActive) return;
|
||||
if (phoneChangeGuard.recordFailure(phoneChangeAttempt, error)) {
|
||||
showToast("换绑结果暂时无法确认,请重新登录确认手机号,不要重复提交");
|
||||
return;
|
||||
}
|
||||
if (!isRequestCancelled(error))
|
||||
showToast(error?.code ? getRequestErrorMessage(error, "换绑未完成,请稍后重试") : error?.message || "换绑未完成,请稍后重试");
|
||||
} finally {
|
||||
if (pageActive) phoneState.value = "ready";
|
||||
if (isPageActive) submittingPhoneChange.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -300,19 +234,16 @@ const requestBack = () =>
|
||||
dirty: isDirty.value,
|
||||
submitting: phoneState.value !== "ready",
|
||||
"close-transient": tacVisible.value ? closeTac : cancelDiscard,
|
||||
"block-submitting": () => {
|
||||
requestController.abort();
|
||||
return true;
|
||||
},
|
||||
"block-submitting": () => true,
|
||||
"confirm-discard": requestDiscardConfirmation,
|
||||
});
|
||||
|
||||
onBackPress((event) => handleBackPress(event, requestBack));
|
||||
onShow(() => smsCooldown.sync());
|
||||
onShow(() => smsVerification.syncCooldown());
|
||||
onUnload(() => {
|
||||
pageActive = false;
|
||||
requestController.abort();
|
||||
smsCooldown.dispose();
|
||||
isPageActive = false;
|
||||
smsVerification.dispose();
|
||||
phoneChangeController.abort();
|
||||
discardConfirmation.dispose();
|
||||
clearTimeout(toastTimer);
|
||||
});
|
||||
@@ -388,6 +319,9 @@ onUnload(() => {
|
||||
font-size: clamp(14px, 23rpx, 17px);
|
||||
}
|
||||
.code-action {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
justify-self: end;
|
||||
width: 198rpx;
|
||||
min-height: 68rpx;
|
||||
@@ -401,7 +335,7 @@ onUnload(() => {
|
||||
color: $brand-red;
|
||||
font-size: clamp(13px, 20rpx, 15px);
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
line-height: 1.2;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.code-action::after {
|
||||
@@ -0,0 +1,222 @@
|
||||
<template>
|
||||
<view class="document-page" :class="`document-state--${complianceDocumentState}`">
|
||||
<ModulePageBackground module="profile" />
|
||||
<view class="page-layer">
|
||||
<PageHeader :title="fallbackTitle" custom-back @back="requestBack" />
|
||||
</view>
|
||||
|
||||
<view class="document-content page-layer">
|
||||
<AppLoading
|
||||
v-if="complianceDocumentState === 'loading'"
|
||||
text="正在读取内容"
|
||||
description="请稍候。"
|
||||
/>
|
||||
|
||||
<view v-else-if="complianceDocumentState === 'error'" class="document-state-card">
|
||||
<text>暂时无法读取内容</text>
|
||||
<text>{{ errorMessage }}</text>
|
||||
<AppButton block type="secondary" label="重新加载" @click="loadDocument" />
|
||||
</view>
|
||||
|
||||
<view v-else class="document-sheet">
|
||||
<view class="document-heading">
|
||||
<text>{{ document.title }}</text>
|
||||
<text>版本 {{ document.versionNo }}</text>
|
||||
<text v-if="document.publishedAt">发布日期:{{ displayDate(document.publishedAt) }}</text>
|
||||
<text v-if="document.effectiveAt">生效日期:{{ displayDate(document.effectiveAt) }}</text>
|
||||
</view>
|
||||
|
||||
<view class="document-body">
|
||||
<template v-for="(block, index) in contentBlocks" :key="`${block.type}-${index}`">
|
||||
<text v-if="block.type === 'title'" class="content-title">{{ block.text }}</text>
|
||||
<text v-else-if="block.type === 'heading'" class="content-heading">{{ block.text }}</text>
|
||||
<view v-else-if="block.type === 'list-item'" class="content-list-item">
|
||||
<text>{{ block.marker }}</text>
|
||||
<text>{{ block.text }}</text>
|
||||
</view>
|
||||
<text v-else class="content-paragraph">{{ block.text }}</text>
|
||||
</template>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, reactive, ref } from "vue";
|
||||
import { onBackPress, onLoad, onUnload } from "@dcloudio/uni-app";
|
||||
import AppButton from "@/components/AppButton.vue";
|
||||
import AppLoading from "@/components/AppLoading.vue";
|
||||
import ModulePageBackground from "@/components/ModulePageBackground.vue";
|
||||
import PageHeader from "@/components/PageHeader.vue";
|
||||
import {
|
||||
createRequestController,
|
||||
isRequestCancelled
|
||||
} from "@/services/api/request-controller.js";
|
||||
import {
|
||||
COMPLIANCE_DOCUMENT_KEY
|
||||
} from "@/services/api/site-content-contract.js";
|
||||
import { siteContentApi } from "@/services/api/site-content-service.js";
|
||||
import { getRequestErrorMessage } from "@/services/api/request-error-message.js";
|
||||
import { formatComplianceContent } from "@/utils/profile/compliance-content.js";
|
||||
import { goBack, handleBackPress } from "@/utils/navigation/gateway.js";
|
||||
|
||||
const documentKey = ref("");
|
||||
const complianceDocumentState = ref("loading");
|
||||
const errorMessage = ref("");
|
||||
const document = reactive({
|
||||
title: "",
|
||||
versionNo: "",
|
||||
content: "",
|
||||
publishedAt: "",
|
||||
effectiveAt: "",
|
||||
});
|
||||
const complianceDocumentRequestController = createRequestController();
|
||||
let pageActive = true;
|
||||
|
||||
const fallbackTitle = computed(() =>
|
||||
documentKey.value === COMPLIANCE_DOCUMENT_KEY.PRIVACY_POLICY
|
||||
? "隐私政策"
|
||||
: "用户协议",
|
||||
);
|
||||
const contentBlocks = computed(() =>
|
||||
formatComplianceContent(document.content, document.title),
|
||||
);
|
||||
const displayDate = (value) => String(value || "").slice(0, 10);
|
||||
const loadDocument = async () => {
|
||||
if (!Object.values(COMPLIANCE_DOCUMENT_KEY).includes(documentKey.value)) {
|
||||
errorMessage.value = "文档类型不正确,请返回后重新进入。";
|
||||
complianceDocumentState.value = "error";
|
||||
return;
|
||||
}
|
||||
complianceDocumentRequestController.abort();
|
||||
complianceDocumentState.value = "loading";
|
||||
errorMessage.value = "";
|
||||
try {
|
||||
const complianceDocument = await siteContentApi.getComplianceDocument(documentKey.value, {
|
||||
requestController: complianceDocumentRequestController,
|
||||
});
|
||||
if (!pageActive) return;
|
||||
Object.assign(document, complianceDocument);
|
||||
complianceDocumentState.value = "ready";
|
||||
} catch (error) {
|
||||
if (!pageActive || isRequestCancelled(error)) return;
|
||||
errorMessage.value = getRequestErrorMessage(error, "请检查网络后重新加载。");
|
||||
complianceDocumentState.value = "error";
|
||||
}
|
||||
};
|
||||
const requestBack = () => goBack();
|
||||
|
||||
onLoad((options = {}) => {
|
||||
documentKey.value = String(options.documentKey || "");
|
||||
loadDocument();
|
||||
});
|
||||
onBackPress((event) => handleBackPress(event, requestBack));
|
||||
onUnload(() => {
|
||||
pageActive = false;
|
||||
complianceDocumentRequestController.abort();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@import "../../styles/adaptive-frame-profiles.scss";
|
||||
.document-page {
|
||||
display: flex;
|
||||
min-height: 100vh;
|
||||
flex-direction: column;
|
||||
background: $paper;
|
||||
}
|
||||
.page-layer {
|
||||
z-index: 1;
|
||||
}
|
||||
.document-content {
|
||||
flex: 1;
|
||||
padding: 24rpx 28rpx 72rpx;
|
||||
}
|
||||
.document-sheet,
|
||||
.document-state-card {
|
||||
@include adaptive-profile-content;
|
||||
}
|
||||
.document-sheet {
|
||||
padding: 34rpx 36rpx 48rpx;
|
||||
background-color: rgba(255, 252, 245, 0.9);
|
||||
}
|
||||
.document-heading {
|
||||
padding-bottom: 24rpx;
|
||||
border-bottom: 1rpx solid rgba(181, 137, 63, 0.42);
|
||||
}
|
||||
.document-heading text {
|
||||
display: block;
|
||||
}
|
||||
.document-heading text:first-child {
|
||||
color: $brand-red;
|
||||
font-family: STKaiti, KaiTi, serif;
|
||||
font-size: clamp(21px, 38rpx, 27px);
|
||||
font-weight: 700;
|
||||
line-height: 1.35;
|
||||
}
|
||||
.document-heading text:not(:first-child) {
|
||||
margin-top: 8rpx;
|
||||
color: $ink-muted;
|
||||
font-size: clamp(13px, 21rpx, 16px);
|
||||
}
|
||||
.document-body {
|
||||
margin-top: 26rpx;
|
||||
}
|
||||
.content-title,
|
||||
.content-heading,
|
||||
.content-paragraph {
|
||||
display: block;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.content-title {
|
||||
color: $ink;
|
||||
font-size: clamp(18px, 31rpx, 23px);
|
||||
font-weight: 700;
|
||||
line-height: 1.45;
|
||||
}
|
||||
.content-heading {
|
||||
margin-top: 30rpx;
|
||||
color: $brand-red;
|
||||
font-size: clamp(17px, 28rpx, 21px);
|
||||
font-weight: 700;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.content-paragraph,
|
||||
.content-list-item {
|
||||
margin-top: 18rpx;
|
||||
color: $ink;
|
||||
font-size: clamp(15px, 25rpx, 18px);
|
||||
line-height: 1.8;
|
||||
}
|
||||
.content-list-item {
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr);
|
||||
gap: 12rpx;
|
||||
}
|
||||
.content-list-item text:last-child {
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.document-state-card {
|
||||
min-height: 320rpx;
|
||||
padding: 58rpx 42rpx 42rpx;
|
||||
text-align: center;
|
||||
}
|
||||
.document-state-card > text {
|
||||
display: block;
|
||||
}
|
||||
.document-state-card > text:first-child {
|
||||
color: $ink;
|
||||
font-size: clamp(19px, 33rpx, 24px);
|
||||
font-weight: 700;
|
||||
}
|
||||
.document-state-card > text:nth-child(2) {
|
||||
margin-top: 16rpx;
|
||||
color: $ink-muted;
|
||||
font-size: clamp(14px, 23rpx, 17px);
|
||||
line-height: 1.6;
|
||||
}
|
||||
.document-state-card .app-button {
|
||||
margin-top: 28rpx;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,445 @@
|
||||
<template>
|
||||
<view class="earnings-page">
|
||||
<ModulePageBackground module="profile" />
|
||||
<view class="page-layer"><PageHeader title="收益与提现" /></view>
|
||||
<view class="earnings-content page-layer">
|
||||
<AppLoading
|
||||
v-if="earningsState === 'loading'"
|
||||
text="正在读取收益"
|
||||
description="请稍候。"
|
||||
/>
|
||||
<view v-else-if="earningsState === 'error'" class="state-card">
|
||||
<text>收益暂时无法读取</text>
|
||||
<text>{{ pageError }}</text>
|
||||
<AppButton block label="重新读取" @click="loadAll" />
|
||||
</view>
|
||||
<template v-else>
|
||||
<text v-if="operationNotice" class="operation-notice" role="status">{{ operationNotice }}</text>
|
||||
<view class="summary-panel">
|
||||
<text>可提现收益</text>
|
||||
<text>¥{{ summary.availableAmount }}</text>
|
||||
<view class="summary-row">
|
||||
<text>处理中</text>
|
||||
<text>¥{{ summary.frozenAmount }}</text>
|
||||
</view>
|
||||
<view class="summary-row">
|
||||
<text>最低提现金额</text>
|
||||
<text>{{ minimumWithdrawalLabel }}</text>
|
||||
</view>
|
||||
<text v-if="!summary.withdrawalEnabled" class="summary-note">当前暂未开放提现,可以先查看收益明细。</text>
|
||||
<AppButton
|
||||
v-else
|
||||
block
|
||||
:disabled="!canStartWithdrawal"
|
||||
:label="canStartWithdrawal ? '申请提现' : '暂未达到最低提现金额'"
|
||||
@click="openWithdrawalForm"
|
||||
/>
|
||||
</view>
|
||||
|
||||
<view class="record-tabs" role="tablist" aria-label="收益记录分类">
|
||||
<button
|
||||
:class="{ active: activeTab === 'ledger' }"
|
||||
role="tab"
|
||||
:aria-selected="activeTab === 'ledger'"
|
||||
@click="activeTab = 'ledger'"
|
||||
>
|
||||
收益明细
|
||||
</button>
|
||||
<button
|
||||
:class="{ active: activeTab === 'withdrawals' }"
|
||||
role="tab"
|
||||
:aria-selected="activeTab === 'withdrawals'"
|
||||
@click="activeTab = 'withdrawals'"
|
||||
>
|
||||
提现记录
|
||||
</button>
|
||||
</view>
|
||||
|
||||
<view v-if="activeTab === 'ledger'" class="record-list">
|
||||
<text v-if="!ledgerRows.length" class="empty-copy">还没有收益明细</text>
|
||||
<view
|
||||
v-for="ledgerEntry in ledgerRows"
|
||||
:key="ledgerEntry.ledgerId"
|
||||
class="record-card"
|
||||
>
|
||||
<view>
|
||||
<text>{{ earningTypeLabel(ledgerEntry.entryType) }}</text>
|
||||
<text>{{ recordTimeLabel(ledgerEntry.createTime) }}</text>
|
||||
</view>
|
||||
<text :class="{ 'amount-negative': ledgerEntry.availableDelta.startsWith('-') }">
|
||||
{{ signedMoney(ledgerEntry.availableDelta) }}
|
||||
</text>
|
||||
<text v-if="ledgerEntry.remark">{{ ledgerEntry.remark }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<view v-else class="record-list">
|
||||
<text v-if="!withdrawalRows.length" class="empty-copy">还没有提现记录</text>
|
||||
<view
|
||||
v-for="withdrawal in withdrawalRows"
|
||||
:key="withdrawal.withdrawalId"
|
||||
class="record-card"
|
||||
>
|
||||
<view>
|
||||
<text>提现 ¥{{ withdrawal.amount }}</text>
|
||||
<text>{{ recordTimeLabel(withdrawal.createTime) }}</text>
|
||||
</view>
|
||||
<text>{{ withdrawalStatusLabel(withdrawal.withdrawalStatus) }}</text>
|
||||
<text>收款人:{{ withdrawal.payoutAccountName || '未填写' }}</text>
|
||||
<text v-if="withdrawal.failureReason">原因:{{ withdrawal.failureReason }}</text>
|
||||
<AppButton
|
||||
v-if="withdrawal.withdrawalStatus === 'PENDING'"
|
||||
compact
|
||||
type="secondary"
|
||||
label="取消提现"
|
||||
@click="requestCancellation(withdrawal)"
|
||||
/>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
</view>
|
||||
|
||||
<EarningWithdrawalDialog
|
||||
ref="earningWithdrawalDialog"
|
||||
:available-amount="summary.availableAmount"
|
||||
:minimum-withdrawal="summary.minimumWithdrawal"
|
||||
:after-submitted="handleWithdrawalSubmitted"
|
||||
/>
|
||||
|
||||
<AppDialog
|
||||
:visible="Boolean(cancelTarget)"
|
||||
title="取消这笔提现吗?"
|
||||
:message="cancelTarget ? `将取消 ¥${cancelTarget.amount} 的提现申请,并由后端解冻对应收益。` : ''"
|
||||
:confirm-text="cancelling ? '正在取消' : '确认取消'"
|
||||
cancel-text="保留申请"
|
||||
show-cancel
|
||||
:close-on-mask="false"
|
||||
@confirm="cancelWithdrawal"
|
||||
@cancel="cancelTarget = null"
|
||||
/>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, reactive, ref } from "vue";
|
||||
import { 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";
|
||||
import EarningWithdrawalDialog from "@/components/profile/EarningWithdrawalDialog.vue";
|
||||
import {
|
||||
EARNING_WITHDRAWAL_STATUS_LABELS as WITHDRAWAL_STATUS_LABELS
|
||||
} from "@/services/api/earning-contract.js";
|
||||
import {
|
||||
createRequestController,
|
||||
isRequestCancelled
|
||||
} from "@/services/api/request-controller.js";
|
||||
import { earningApi } from "@/services/api/earning-service.js";
|
||||
import { getRequestErrorMessage } from "@/services/api/request-error-message.js";
|
||||
import { formatMinuteTimestamp } from "@/utils/display-time.js";
|
||||
import {
|
||||
formatSignedMoney,
|
||||
parseMoneyToCents,
|
||||
} from "@/utils/profile/earning-money.js";
|
||||
import { isWriteOutcomeUnknown } from "@/utils/request-outcome.js";
|
||||
|
||||
const EARNING_TYPE_LABELS = Object.freeze({
|
||||
PAYMENT_REWARD: "邀请家人奖励",
|
||||
PAYMENT_REFUND_REVERSAL: "邀请奖励退款扣回",
|
||||
WITHDRAWAL_FREEZE: "提现金额冻结",
|
||||
WITHDRAWAL_CANCEL: "取消提现退回",
|
||||
WITHDRAWAL_REJECT: "提现未通过退回",
|
||||
WITHDRAWAL_PAID: "提现已到账",
|
||||
WITHDRAWAL_FAILED: "提现失败退回",
|
||||
});
|
||||
const earningsState = ref("loading");
|
||||
const pageError = ref("");
|
||||
const operationNotice = ref("");
|
||||
const activeTab = ref("ledger");
|
||||
const summary = reactive({
|
||||
availableAmount: "0.00",
|
||||
frozenAmount: "0.00",
|
||||
minimumWithdrawal: null,
|
||||
rewardRateBps: null,
|
||||
withdrawalEnabled: false,
|
||||
});
|
||||
const ledgerRows = ref([]);
|
||||
const withdrawalRows = ref([]);
|
||||
const earningWithdrawalDialog = ref(null);
|
||||
const cancelTarget = ref(null);
|
||||
const cancelling = ref(false);
|
||||
const summaryController = createRequestController();
|
||||
const ledgerController = createRequestController();
|
||||
const withdrawalListController = createRequestController();
|
||||
const withdrawalCancellationController = createRequestController();
|
||||
let isPageActive = true;
|
||||
|
||||
const canStartWithdrawal = computed(() => {
|
||||
const available = parseMoneyToCents(summary.availableAmount);
|
||||
const minimum = parseMoneyToCents(summary.minimumWithdrawal || "0.01");
|
||||
return summary.withdrawalEnabled && available !== null && minimum !== null && available >= minimum;
|
||||
});
|
||||
const minimumWithdrawalLabel = computed(() =>
|
||||
summary.minimumWithdrawal
|
||||
? `¥${summary.minimumWithdrawal}`
|
||||
: "以提交时提示为准",
|
||||
);
|
||||
const signedMoney = formatSignedMoney;
|
||||
const recordTimeLabel = (value) =>
|
||||
formatMinuteTimestamp(value) || "未标注时间";
|
||||
const earningTypeLabel = (entryType) =>
|
||||
EARNING_TYPE_LABELS[entryType] || "收益变动";
|
||||
const withdrawalStatusLabel = (withdrawalStatus) =>
|
||||
WITHDRAWAL_STATUS_LABELS[withdrawalStatus] || "处理中";
|
||||
|
||||
const loadAll = async () => {
|
||||
summaryController.abort();
|
||||
ledgerController.abort();
|
||||
withdrawalListController.abort();
|
||||
earningsState.value = "loading";
|
||||
pageError.value = "";
|
||||
try {
|
||||
const [nextSummary, ledger, withdrawals] = await Promise.all([
|
||||
earningApi.getEarningSummary({ requestController: summaryController }),
|
||||
earningApi.getEarningLedgerPage({ pageNum: 1, pageSize: 50 }, { requestController: ledgerController }),
|
||||
earningApi.getEarningWithdrawalPage(
|
||||
{ pageNum: 1, pageSize: 50 },
|
||||
{ requestController: withdrawalListController },
|
||||
),
|
||||
]);
|
||||
if (!isPageActive) return;
|
||||
Object.assign(summary, nextSummary);
|
||||
ledgerRows.value = ledger.rows;
|
||||
withdrawalRows.value = withdrawals.rows;
|
||||
earningsState.value = "ready";
|
||||
} catch (error) {
|
||||
if (!isPageActive || isRequestCancelled(error)) return;
|
||||
earningsState.value = "error";
|
||||
pageError.value = getRequestErrorMessage(error, "请检查网络后重新读取。");
|
||||
}
|
||||
};
|
||||
const openWithdrawalForm = () => {
|
||||
if (!canStartWithdrawal.value) return;
|
||||
earningWithdrawalDialog.value?.open();
|
||||
};
|
||||
const handleWithdrawalSubmitted = async () => {
|
||||
operationNotice.value = "提现申请已提交,可以在提现记录中查看进度。";
|
||||
await loadAll();
|
||||
};
|
||||
const requestCancellation = (withdrawal) => {
|
||||
cancelTarget.value = withdrawal;
|
||||
};
|
||||
const cancelWithdrawal = async () => {
|
||||
if (!cancelTarget.value || cancelling.value) return;
|
||||
cancelling.value = true;
|
||||
try {
|
||||
await earningApi.cancelEarningWithdrawal(cancelTarget.value.withdrawalId, {
|
||||
requestController: withdrawalCancellationController,
|
||||
});
|
||||
cancelTarget.value = null;
|
||||
operationNotice.value = "提现申请已取消,对应收益将按后端结果解冻。";
|
||||
await loadAll();
|
||||
} catch (error) {
|
||||
if (!isPageActive) return;
|
||||
if (isWriteOutcomeUnknown(error)) {
|
||||
cancelTarget.value = null;
|
||||
operationNotice.value =
|
||||
"暂时无法确认是否取消成功,已重新读取提现记录,请勿立即重复操作。";
|
||||
await loadAll();
|
||||
} else if (!isRequestCancelled(error)) {
|
||||
operationNotice.value = getRequestErrorMessage(
|
||||
error,
|
||||
"暂时无法取消,请稍后重试。",
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
if (isPageActive) cancelling.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
onShow(() => {
|
||||
void loadAll();
|
||||
});
|
||||
onUnload(() => {
|
||||
isPageActive = false;
|
||||
summaryController.abort();
|
||||
ledgerController.abort();
|
||||
withdrawalListController.abort();
|
||||
withdrawalCancellationController.abort();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@import "../../styles/adaptive-frame-profiles.scss";
|
||||
.earnings-page {
|
||||
min-height: 100vh;
|
||||
box-sizing: border-box;
|
||||
padding-bottom: 48rpx;
|
||||
background: $paper;
|
||||
}
|
||||
|
||||
.page-layer {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.earnings-content {
|
||||
padding: 20rpx 24rpx 60rpx;
|
||||
}
|
||||
|
||||
.operation-notice {
|
||||
display: block;
|
||||
margin-bottom: 14rpx;
|
||||
padding: 16rpx 20rpx;
|
||||
border: 1rpx solid rgba(159, 35, 35, 0.18);
|
||||
background: rgba(255, 249, 231, 0.94);
|
||||
color: $brand-red;
|
||||
font-size: clamp(14px, 22rpx, 17px);
|
||||
line-height: 1.5;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.summary-panel,
|
||||
.state-card,
|
||||
.record-card {
|
||||
@include adaptive-profile-content;
|
||||
}
|
||||
|
||||
.summary-panel {
|
||||
padding: 28rpx;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.summary-panel > text {
|
||||
display: block;
|
||||
color: $ink-muted;
|
||||
font-size: clamp(14px, 23rpx, 17px);
|
||||
}
|
||||
|
||||
.summary-panel > text:nth-child(2) {
|
||||
margin: 10rpx 0 22rpx;
|
||||
color: $brand-red;
|
||||
font-family: STKaiti, KaiTi, serif;
|
||||
font-size: clamp(30px, 54rpx, 40px);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.summary-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 20rpx;
|
||||
padding: 10rpx 0;
|
||||
color: $ink;
|
||||
font-size: clamp(14px, 23rpx, 17px);
|
||||
}
|
||||
|
||||
.summary-note {
|
||||
margin: 18rpx 0;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.summary-panel .app-button {
|
||||
margin-top: 20rpx;
|
||||
}
|
||||
|
||||
.record-tabs {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
margin-top: 22rpx;
|
||||
border-bottom: 2rpx solid rgba(159, 35, 35, 0.18);
|
||||
}
|
||||
|
||||
.record-tabs button {
|
||||
min-height: 76rpx;
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
color: $ink-muted;
|
||||
font-size: clamp(15px, 25rpx, 18px);
|
||||
}
|
||||
|
||||
.record-tabs button::after {
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.record-tabs button.active {
|
||||
border-bottom: 4rpx solid $brand-red;
|
||||
color: $brand-red;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.record-list {
|
||||
margin-top: 18rpx;
|
||||
}
|
||||
|
||||
.record-card {
|
||||
margin-bottom: 14rpx;
|
||||
padding: 22rpx;
|
||||
}
|
||||
|
||||
.record-card > view:first-child {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 16rpx;
|
||||
}
|
||||
|
||||
.record-card text {
|
||||
display: block;
|
||||
overflow-wrap: anywhere;
|
||||
color: $ink-muted;
|
||||
font-size: clamp(14px, 22rpx, 17px);
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.record-card > view:first-child text:first-child,
|
||||
.record-card > text:nth-child(2) {
|
||||
color: $ink;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.record-card > text:nth-child(2) {
|
||||
margin-top: 8rpx;
|
||||
color: $brand-red;
|
||||
}
|
||||
|
||||
.record-card .amount-negative {
|
||||
color: $ink-muted;
|
||||
}
|
||||
|
||||
.record-card .app-button {
|
||||
margin-top: 14rpx;
|
||||
}
|
||||
|
||||
.empty-copy {
|
||||
display: block;
|
||||
padding: 70rpx 20rpx;
|
||||
color: $ink-muted;
|
||||
font-size: clamp(15px, 24rpx, 18px);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.state-card {
|
||||
margin-top: 20rpx;
|
||||
padding: 70rpx 38rpx 40rpx;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.state-card text {
|
||||
display: block;
|
||||
color: $ink-muted;
|
||||
font-size: clamp(15px, 24rpx, 18px);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.state-card text:first-child {
|
||||
color: $ink;
|
||||
font-size: clamp(19px, 34rpx, 24px);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.state-card .app-button {
|
||||
margin-top: 24rpx;
|
||||
}
|
||||
|
||||
</style>
|
||||
@@ -1,4 +1,3 @@
|
||||
<!-- 页面编号:M-02;用途:编辑已由 Apifox 声明的个人资料字段。 -->
|
||||
<template>
|
||||
<view class="profile-edit-page">
|
||||
<ModulePageBackground module="profile" />
|
||||
@@ -9,9 +8,9 @@
|
||||
<view v-if="loading" class="state-card"
|
||||
><text>正在读取个人资料…</text></view
|
||||
>
|
||||
<view v-else-if="loadError" class="state-card">
|
||||
<view v-else-if="profileLoadError" class="state-card">
|
||||
<text>个人资料暂时无法读取</text>
|
||||
<text>{{ loadError }}</text>
|
||||
<text>{{ profileLoadError }}</text>
|
||||
<AppButton block label="重新读取" @click="loadProfile" />
|
||||
</view>
|
||||
<template v-else>
|
||||
@@ -98,7 +97,7 @@
|
||||
/></view>
|
||||
</view>
|
||||
<text class="form-note"
|
||||
>资料字段均可按需填写;留空不会提交为修改。</text
|
||||
>资料可按需填写;留空内容不会修改。</text
|
||||
>
|
||||
<text v-if="saveError" class="save-error">{{ saveError }}</text>
|
||||
<AppButton
|
||||
@@ -122,16 +121,19 @@ import AppToast from "@/components/AppToast.vue";
|
||||
import ModulePageBackground from "@/components/ModulePageBackground.vue";
|
||||
import PageHeader from "@/components/PageHeader.vue";
|
||||
import {
|
||||
appApi,
|
||||
PROFILE_SEX_OPTIONS
|
||||
} from "@/services/api/profile-contract.js";
|
||||
import {
|
||||
createRequestController,
|
||||
isRequestCancelled,
|
||||
} from "@/utils/api.js";
|
||||
isRequestCancelled
|
||||
} from "@/services/api/request-controller.js";
|
||||
import { profileApi } from "@/services/api/profile-service.js";
|
||||
import { getRequestErrorMessage } from "@/services/api/request-error-message.js";
|
||||
import {
|
||||
isImagePickCancelled,
|
||||
pickAndUploadImage,
|
||||
toConsumerOssId,
|
||||
} from "@/utils/resumable-image-upload.js";
|
||||
import { finishPage, returnTo } from "@/utils/navigation.js";
|
||||
} from "@/utils/media-upload.js";
|
||||
import { finishPage, returnTo } from "@/utils/navigation/gateway.js";
|
||||
|
||||
const form = reactive({
|
||||
nickName: "",
|
||||
@@ -152,22 +154,23 @@ const avatarId = ref(null);
|
||||
const avatarFileName = ref("");
|
||||
const avatarPreviewUrl = ref("");
|
||||
const loading = ref(true);
|
||||
const loadError = ref("");
|
||||
const profileLoadError = ref("");
|
||||
const uploading = ref(false);
|
||||
const avatarError = ref("");
|
||||
const saving = ref(false);
|
||||
const saveError = ref("");
|
||||
const committedProfilePayload = ref("");
|
||||
const feedbackVisible = ref(false);
|
||||
const feedbackMessage = ref("");
|
||||
const requestController = createRequestController();
|
||||
const profileReadController = createRequestController();
|
||||
const avatarUploadController = createRequestController();
|
||||
const profileSaveController = createRequestController();
|
||||
let feedbackTimer = null;
|
||||
let pageActive = true;
|
||||
const sexOptions = [
|
||||
{ value: "", label: "请选择" },
|
||||
{ value: "0", label: "男" },
|
||||
{ value: "1", label: "女" },
|
||||
{ value: "2", label: "未知" },
|
||||
];
|
||||
let isPageActive = true;
|
||||
const sexOptions = Object.freeze([
|
||||
Object.freeze({ value: "", label: "请选择" }),
|
||||
...PROFILE_SEX_OPTIONS,
|
||||
]);
|
||||
|
||||
const sexIndex = computed(() => {
|
||||
const index = sexOptions.findIndex((option) => option.value === form.sex);
|
||||
@@ -176,7 +179,7 @@ const sexIndex = computed(() => {
|
||||
|
||||
const avatarMessage = computed(() => {
|
||||
if (avatarFileName.value) return `已选择 ${avatarFileName.value}`;
|
||||
return avatarId.value ? "当前头像已设置" : "当前使用默认头像";
|
||||
return avatarPreviewUrl.value ? "当前头像已设置" : "当前使用默认头像";
|
||||
});
|
||||
|
||||
const showFeedback = (message) => {
|
||||
@@ -195,10 +198,10 @@ const toBirthdayDate = (value) =>
|
||||
const loadProfile = async () => {
|
||||
if (loading.value === false && (uploading.value || saving.value)) return;
|
||||
loading.value = true;
|
||||
loadError.value = "";
|
||||
profileLoadError.value = "";
|
||||
try {
|
||||
const profile = await appApi.getProfile({ requestController });
|
||||
if (!pageActive) return;
|
||||
const profile = await profileApi.getProfile({ requestController: profileReadController });
|
||||
if (!isPageActive) return;
|
||||
Object.assign(form, {
|
||||
nickName: profile.nickName,
|
||||
realName: profile.realName,
|
||||
@@ -206,15 +209,19 @@ const loadProfile = async () => {
|
||||
birthday: toBirthdayDate(profile.birthday),
|
||||
email: profile.email,
|
||||
});
|
||||
Object.assign(original, { ...form, avatar: profile.avatar });
|
||||
avatarId.value = profile.avatar;
|
||||
Object.assign(original, {
|
||||
...form,
|
||||
avatar: profile.avatarFile?.ossId || null,
|
||||
});
|
||||
committedProfilePayload.value = "";
|
||||
avatarId.value = null;
|
||||
avatarFileName.value = "";
|
||||
avatarPreviewUrl.value = "";
|
||||
avatarPreviewUrl.value = profile.avatarFile?.accessUrl || "";
|
||||
} catch (error) {
|
||||
if (pageActive && !isRequestCancelled(error))
|
||||
loadError.value = error?.message || "请稍后重试";
|
||||
if (isPageActive && !isRequestCancelled(error))
|
||||
profileLoadError.value = getRequestErrorMessage(error, "请稍后重试");
|
||||
} finally {
|
||||
if (pageActive) loading.value = false;
|
||||
if (isPageActive) loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -231,35 +238,40 @@ const uploadAvatar = async () => {
|
||||
uploading.value = true;
|
||||
avatarError.value = "";
|
||||
try {
|
||||
const receipt = await pickAndUploadImage({ requestController });
|
||||
if (!pageActive) return;
|
||||
avatarId.value = toConsumerOssId(receipt.ossId);
|
||||
const receipt = await pickAndUploadImage({
|
||||
requestController: avatarUploadController,
|
||||
});
|
||||
if (!isPageActive) return;
|
||||
avatarId.value = receipt.ossId;
|
||||
avatarFileName.value = receipt.fileName || "新头像";
|
||||
avatarPreviewUrl.value = receipt.thumbnailUrl || receipt.url || "";
|
||||
} catch (error) {
|
||||
if (
|
||||
pageActive &&
|
||||
isPageActive &&
|
||||
!isImagePickCancelled(error) &&
|
||||
!isRequestCancelled(error)
|
||||
) {
|
||||
avatarError.value = error?.message || "头像上传失败,请稍后重试";
|
||||
avatarError.value = getRequestErrorMessage(error, "头像上传失败,请稍后重试");
|
||||
}
|
||||
} finally {
|
||||
if (pageActive) uploading.value = false;
|
||||
if (isPageActive) uploading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const buildUpdatePayload = () => {
|
||||
const data = {};
|
||||
const profileChanges = {};
|
||||
for (const field of ["nickName", "realName", "sex", "email"]) {
|
||||
if (form[field] && form[field] !== original[field])
|
||||
data[field] = form[field];
|
||||
profileChanges[field] = form[field];
|
||||
}
|
||||
if (form.birthday && form.birthday !== original.birthday)
|
||||
data.birthday = form.birthday;
|
||||
if (avatarId.value && avatarId.value !== original.avatar)
|
||||
data.avatar = avatarId.value;
|
||||
return data;
|
||||
profileChanges.birthday = form.birthday;
|
||||
if (
|
||||
avatarId.value &&
|
||||
String(avatarId.value) !== String(original.avatar || "")
|
||||
)
|
||||
profileChanges.avatar = avatarId.value;
|
||||
return profileChanges;
|
||||
};
|
||||
|
||||
const saveProfile = async () => {
|
||||
@@ -271,19 +283,27 @@ const saveProfile = async () => {
|
||||
}
|
||||
saving.value = true;
|
||||
saveError.value = "";
|
||||
const payloadFingerprint = JSON.stringify(payload);
|
||||
try {
|
||||
await appApi.updateProfile(payload, { requestController });
|
||||
if (!pageActive) return;
|
||||
if (committedProfilePayload.value !== payloadFingerprint) {
|
||||
await profileApi.updateProfile(payload, {
|
||||
requestController: profileSaveController,
|
||||
});
|
||||
if (!isPageActive) return;
|
||||
committedProfilePayload.value = payloadFingerprint;
|
||||
}
|
||||
await finishPage(
|
||||
"M01",
|
||||
{},
|
||||
{ operation: "profile-updated", refresh: true },
|
||||
);
|
||||
} catch (error) {
|
||||
if (pageActive && !isRequestCancelled(error))
|
||||
saveError.value = error?.message || "保存失败,请稍后重试";
|
||||
if (isPageActive && !isRequestCancelled(error))
|
||||
saveError.value = committedProfilePayload.value === payloadFingerprint
|
||||
? "资料已经保存,但页面返回失败。请再次点击保存重试返回。"
|
||||
: getRequestErrorMessage(error, "保存失败,请稍后重试");
|
||||
} finally {
|
||||
if (pageActive) saving.value = false;
|
||||
if (isPageActive) saving.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -294,8 +314,10 @@ const backToProfile = () => {
|
||||
|
||||
onLoad(loadProfile);
|
||||
onUnload(() => {
|
||||
pageActive = false;
|
||||
requestController.abort();
|
||||
isPageActive = false;
|
||||
profileReadController.abort();
|
||||
avatarUploadController.abort();
|
||||
profileSaveController.abort();
|
||||
if (feedbackTimer) clearTimeout(feedbackTimer);
|
||||
});
|
||||
</script>
|
||||
@@ -356,6 +378,9 @@ onUnload(() => {
|
||||
gap: 16rpx;
|
||||
border-bottom: 1rpx solid rgba(181, 137, 63, 0.42);
|
||||
}
|
||||
.avatar-row {
|
||||
grid-template-columns: minmax(0, 1fr) 180rpx;
|
||||
}
|
||||
.avatar-summary {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
@@ -477,5 +502,8 @@ onUnload(() => {
|
||||
grid-template-columns: 116rpx minmax(0, 1fr);
|
||||
gap: 12rpx;
|
||||
}
|
||||
.avatar-row {
|
||||
grid-template-columns: minmax(0, 1fr) 160rpx;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,4 +1,3 @@
|
||||
<!-- 页面编号:M-07;用途:提交意见反馈。 -->
|
||||
<template>
|
||||
<view
|
||||
class="feedback-page"
|
||||
@@ -27,19 +26,20 @@
|
||||
>
|
||||
<view class="type-grid">
|
||||
<button
|
||||
v-for="type in feedbackTypes"
|
||||
:key="type.value"
|
||||
v-for="feedbackType in feedbackTypes"
|
||||
:key="feedbackType.value"
|
||||
class="type-chip"
|
||||
:class="{ active: feedbackForm.feedbackType === type.value }"
|
||||
:aria-pressed="feedbackForm.feedbackType === type.value"
|
||||
:class="{ active: feedbackForm.feedbackType === feedbackType.value }"
|
||||
:aria-pressed="feedbackForm.feedbackType === feedbackType.value"
|
||||
:disabled="feedbackState === 'submitting'"
|
||||
@click="selectFeedbackType(type.value)"
|
||||
@click="selectFeedbackType(feedbackType.value)"
|
||||
>
|
||||
{{ type.label }}
|
||||
{{ feedbackType.label }}
|
||||
</button>
|
||||
</view>
|
||||
</view>
|
||||
<view class="form-panel">
|
||||
<text class="form-label">问题描述(必填)</text>
|
||||
<textarea
|
||||
v-model.trim="feedbackForm.feedbackContent"
|
||||
auto-height
|
||||
@@ -100,6 +100,54 @@
|
||||
:label="submitLabel"
|
||||
@click="submitFeedback"
|
||||
/>
|
||||
<view class="history-section">
|
||||
<view class="history-heading">
|
||||
<text class="history-title">我的反馈</text>
|
||||
<text class="history-note">这里会显示处理进度和回复</text>
|
||||
</view>
|
||||
<view v-if="historyState === 'loading'" class="history-state" role="status">
|
||||
正在读取反馈记录…
|
||||
</view>
|
||||
<view v-else-if="historyState === 'empty'" class="history-state">
|
||||
还没有提交过反馈
|
||||
</view>
|
||||
<view v-else-if="historyState === 'error'" class="history-state history-state--error" role="alert">
|
||||
<text>{{ historyError }}</text>
|
||||
<button class="history-retry" @click="loadFeedbackHistory">重新加载</button>
|
||||
</view>
|
||||
<view v-else class="history-list">
|
||||
<view
|
||||
v-for="feedback in feedbackHistory"
|
||||
:key="String(feedback.feedbackId)"
|
||||
class="history-card"
|
||||
>
|
||||
<view class="history-card__meta">
|
||||
<text>{{ feedbackTypeLabel(feedback.feedbackType) }} · {{ feedbackReference(feedback) }}</text>
|
||||
<text class="history-status">{{ feedbackStatusLabel(feedback.handleStatus) }}</text>
|
||||
</view>
|
||||
<text
|
||||
class="history-content"
|
||||
:class="{
|
||||
'history-content--collapsed':
|
||||
isFeedbackLong(feedback) && !isFeedbackExpanded(feedback),
|
||||
}"
|
||||
>{{ feedback.feedbackContent }}</text
|
||||
>
|
||||
<button
|
||||
v-if="isFeedbackLong(feedback)"
|
||||
class="history-expand"
|
||||
:aria-expanded="isFeedbackExpanded(feedback)"
|
||||
@click="toggleFeedbackExpanded(feedback)"
|
||||
>
|
||||
{{ isFeedbackExpanded(feedback) ? "收起" : "展开全文" }}
|
||||
</button>
|
||||
<view v-if="feedback.handleResult" class="history-reply">
|
||||
<text class="history-reply__label">处理回复</text>
|
||||
<text>{{ feedback.handleResult }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<AppDialog
|
||||
:visible="discardVisible"
|
||||
@@ -117,26 +165,37 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, nextTick, reactive, ref, watch } from "vue";
|
||||
import { onBackPress, onUnload } from "@dcloudio/uni-app";
|
||||
import { nextTick, reactive, ref, watch } from "vue";
|
||||
import { onBackPress, onLoad, onUnload } from "@dcloudio/uni-app";
|
||||
import AppButton from "@/components/AppButton.vue";
|
||||
import AppDialog from "@/components/AppDialog.vue";
|
||||
import ModulePageBackground from "@/components/ModulePageBackground.vue";
|
||||
import PageHeader from "@/components/PageHeader.vue";
|
||||
import {
|
||||
appApi,
|
||||
FEEDBACK_TYPE_OPTIONS as feedbackTypes
|
||||
} from "@/services/api/feedback-contract.js";
|
||||
import {
|
||||
createRequestController,
|
||||
isRequestCancelled,
|
||||
} from "@/utils/api.js";
|
||||
isRequestCancelled
|
||||
} from "@/services/api/request-controller.js";
|
||||
import { feedbackApi } from "@/services/api/feedback-service.js";
|
||||
import { getRequestErrorMessage } from "@/services/api/request-error-message.js";
|
||||
import { createDiscardConfirmation } from "@/utils/discard-confirmation.js";
|
||||
import { handleBackPress, runBackGuard } from "@/utils/navigation.js";
|
||||
import { createFeedbackSubmissionSession } from "@/utils/profile/feedback-submission.js";
|
||||
import { handleBackPress, runBackGuard } from "@/utils/navigation/gateway.js";
|
||||
|
||||
const feedbackTypes = [
|
||||
{ value: "bug", label: "功能问题" },
|
||||
{ value: "advice", label: "使用建议" },
|
||||
{ value: "complaint", label: "投诉反馈" },
|
||||
{ value: "other", label: "其他" },
|
||||
];
|
||||
const feedbackStatusLabels = {
|
||||
0: "待处理",
|
||||
1: "处理中",
|
||||
2: "已处理",
|
||||
3: "暂不处理",
|
||||
};
|
||||
const feedbackTypeLabel = (feedbackTypeValue) =>
|
||||
feedbackTypes.find(
|
||||
(feedbackType) => feedbackType.value === feedbackTypeValue,
|
||||
)?.label || "其他";
|
||||
const feedbackStatusLabel = (handleStatus) =>
|
||||
feedbackStatusLabels[String(handleStatus)] || "状态待确认";
|
||||
const feedbackForm = reactive({
|
||||
feedbackType: "",
|
||||
feedbackContent: "",
|
||||
@@ -146,35 +205,62 @@ const errors = reactive({ feedbackContent: "" });
|
||||
const feedbackState = ref("ready");
|
||||
const feedbackResult = ref("");
|
||||
const feedbackResultTone = ref("");
|
||||
const isDirty = ref(false);
|
||||
const submitDisabled = ref(false);
|
||||
const submitLabel = ref("提交反馈");
|
||||
const feedbackContentFocused = ref(false);
|
||||
const discardVisible = ref(false);
|
||||
const lastSubmittedSnapshot = ref("");
|
||||
const lastUncertainSnapshot = ref("");
|
||||
const feedbackHistory = ref([]);
|
||||
const historyState = ref("loading");
|
||||
const historyError = ref("");
|
||||
const expandedFeedbackIds = ref(new Set());
|
||||
const feedbackIdentity = (feedback) => String(feedback?.feedbackId || "");
|
||||
const feedbackReference = (feedback) => {
|
||||
const feedbackId = feedbackIdentity(feedback);
|
||||
return feedbackId ? `反馈编号 ${feedbackId.slice(-8)}` : "反馈记录";
|
||||
};
|
||||
const isFeedbackLong = (feedback) =>
|
||||
String(feedback?.feedbackContent || "").length > 120;
|
||||
const isFeedbackExpanded = (feedback) =>
|
||||
expandedFeedbackIds.value.has(feedbackIdentity(feedback));
|
||||
const toggleFeedbackExpanded = (feedback) => {
|
||||
const feedbackId = feedbackIdentity(feedback);
|
||||
if (!feedbackId) return;
|
||||
const nextExpandedIds = new Set(expandedFeedbackIds.value);
|
||||
if (nextExpandedIds.has(feedbackId)) nextExpandedIds.delete(feedbackId);
|
||||
else nextExpandedIds.add(feedbackId);
|
||||
expandedFeedbackIds.value = nextExpandedIds;
|
||||
};
|
||||
let pageActive = true;
|
||||
const requestController = createRequestController();
|
||||
const normalizedForm = computed(() => ({
|
||||
feedbackType: feedbackForm.feedbackType.trim(),
|
||||
feedbackContent: feedbackForm.feedbackContent.trim(),
|
||||
contactInfo: feedbackForm.contactInfo.trim(),
|
||||
}));
|
||||
const formSnapshot = computed(() => JSON.stringify(normalizedForm.value));
|
||||
const baseline = ref(formSnapshot.value);
|
||||
const isDirty = computed(() => formSnapshot.value !== baseline.value);
|
||||
const submitDisabled = computed(
|
||||
() =>
|
||||
feedbackState.value === "submitting" ||
|
||||
(Boolean(lastSubmittedSnapshot.value) &&
|
||||
formSnapshot.value === lastSubmittedSnapshot.value) ||
|
||||
(Boolean(lastUncertainSnapshot.value) &&
|
||||
formSnapshot.value === lastUncertainSnapshot.value),
|
||||
);
|
||||
const submitLabel = computed(() => {
|
||||
if (feedbackState.value === "submitting") return "正在提交";
|
||||
if (feedbackState.value === "success") return "反馈已提交";
|
||||
if (feedbackState.value === "uncertain") return "提交结果待确认";
|
||||
if (feedbackState.value === "error") return "重新提交";
|
||||
return "提交反馈";
|
||||
});
|
||||
const feedbackHistoryRequestController = createRequestController();
|
||||
const feedbackSubmissionRequestController = createRequestController();
|
||||
const submissionSession = createFeedbackSubmissionSession(feedbackForm);
|
||||
const syncSubmissionView = () => {
|
||||
const submissionView = submissionSession.view(feedbackForm);
|
||||
feedbackState.value = submissionView.phase;
|
||||
feedbackResult.value = submissionView.message;
|
||||
feedbackResultTone.value = submissionView.tone;
|
||||
isDirty.value = submissionView.isDirty;
|
||||
submitDisabled.value = submissionView.isSubmitDisabled;
|
||||
submitLabel.value = submissionView.submitLabel;
|
||||
};
|
||||
const loadFeedbackHistory = async () => {
|
||||
historyState.value = "loading";
|
||||
historyError.value = "";
|
||||
try {
|
||||
const rows = await feedbackApi.getFeedback({ requestController: feedbackHistoryRequestController });
|
||||
if (!pageActive) return;
|
||||
feedbackHistory.value = rows;
|
||||
historyState.value = rows.length ? "ready" : "empty";
|
||||
} catch (error) {
|
||||
if (!pageActive || isRequestCancelled(error)) return;
|
||||
historyState.value = "error";
|
||||
historyError.value = getRequestErrorMessage(
|
||||
error,
|
||||
"暂时无法读取反馈记录,请稍后重试。",
|
||||
);
|
||||
}
|
||||
};
|
||||
const discardConfirmation = createDiscardConfirmation((visible) => {
|
||||
discardVisible.value = visible;
|
||||
});
|
||||
@@ -197,109 +283,38 @@ const selectFeedbackType = (type) => {
|
||||
if (feedbackState.value === "submitting") return;
|
||||
feedbackForm.feedbackType = feedbackForm.feedbackType === type ? "" : type;
|
||||
};
|
||||
const feedbackErrorMessage = (error) => {
|
||||
if (error?.code === "WRITE_UNAVAILABLE") {
|
||||
return "当前为本地预览模式,反馈未提交服务器。";
|
||||
}
|
||||
if (error?.httpStatus === 401) {
|
||||
return "登录状态已失效,服务器未确认提交成功。请重新登录后再处理。";
|
||||
}
|
||||
return error?.message
|
||||
? `服务器未确认提交成功:${error.message}`
|
||||
: "服务器未确认提交成功,请稍后再试。";
|
||||
};
|
||||
const submittedFeedbackMessage =
|
||||
"反馈已提交。以下内容为本次提交记录;修改任一项后可提交新反馈。";
|
||||
const submittedWithPendingEditsMessage = "上一份反馈已提交,当前修改尚未提交。";
|
||||
const uncertainFeedbackMessage =
|
||||
"提交结果可能未知。为避免重复记录,当前内容不能直接重提;请稍后确认或修改后提交新反馈。";
|
||||
const uncertainWithPendingEditsMessage =
|
||||
"上一份反馈的提交结果仍待确认,当前修改尚未提交。";
|
||||
const isFeedbackResultUncertain = (error) => {
|
||||
if (
|
||||
["REQUEST_TIMEOUT", "NETWORK_ERROR", "RESPONSE_INVALID"].includes(
|
||||
error?.code,
|
||||
)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
if (error?.code !== "HTTP_ERROR") return false;
|
||||
const status = error.httpStatus;
|
||||
return (status >= 200 && status < 400) || status === 408 || status >= 500;
|
||||
};
|
||||
const submitFeedback = async () => {
|
||||
if (!validateFeedback() || feedbackState.value === "submitting") return;
|
||||
if (
|
||||
(lastSubmittedSnapshot.value &&
|
||||
formSnapshot.value === lastSubmittedSnapshot.value) ||
|
||||
(lastUncertainSnapshot.value &&
|
||||
formSnapshot.value === lastUncertainSnapshot.value)
|
||||
)
|
||||
return;
|
||||
const submittedSnapshot = formSnapshot.value;
|
||||
const submittedPayload = JSON.parse(submittedSnapshot);
|
||||
feedbackState.value = "submitting";
|
||||
feedbackResult.value = "";
|
||||
feedbackResultTone.value = "";
|
||||
if (!validateFeedback()) return;
|
||||
const submission = submissionSession.begin(feedbackForm);
|
||||
if (!submission) return;
|
||||
syncSubmissionView();
|
||||
try {
|
||||
await appApi.submitFeedback(submittedPayload, { requestController });
|
||||
await feedbackApi.submitFeedback(submission.payload, {
|
||||
requestController: feedbackSubmissionRequestController,
|
||||
});
|
||||
if (!pageActive) return;
|
||||
baseline.value = submittedSnapshot;
|
||||
lastSubmittedSnapshot.value = submittedSnapshot;
|
||||
feedbackResultTone.value = "success";
|
||||
if (formSnapshot.value === submittedSnapshot) {
|
||||
feedbackState.value = "success";
|
||||
feedbackResult.value = submittedFeedbackMessage;
|
||||
} else {
|
||||
feedbackState.value = "ready";
|
||||
feedbackResult.value = submittedWithPendingEditsMessage;
|
||||
}
|
||||
await loadFeedbackHistory();
|
||||
if (!pageActive) return;
|
||||
submissionSession.succeed(submission, feedbackForm);
|
||||
syncSubmissionView();
|
||||
} catch (error) {
|
||||
if (!pageActive || isRequestCancelled(error)) return;
|
||||
if (isFeedbackResultUncertain(error)) {
|
||||
lastUncertainSnapshot.value = submittedSnapshot;
|
||||
feedbackResultTone.value = "uncertain";
|
||||
if (formSnapshot.value === submittedSnapshot) {
|
||||
feedbackState.value = "uncertain";
|
||||
feedbackResult.value = uncertainFeedbackMessage;
|
||||
} else {
|
||||
feedbackState.value = "ready";
|
||||
feedbackResult.value = uncertainWithPendingEditsMessage;
|
||||
}
|
||||
return;
|
||||
}
|
||||
feedbackState.value = "error";
|
||||
feedbackResult.value = feedbackErrorMessage(error);
|
||||
feedbackResultTone.value = "error";
|
||||
submissionSession.fail(submission, error, feedbackForm);
|
||||
syncSubmissionView();
|
||||
}
|
||||
};
|
||||
watch(formSnapshot, (snapshot) => {
|
||||
if (feedbackState.value === "submitting") return;
|
||||
errors.feedbackContent = "";
|
||||
if (lastSubmittedSnapshot.value && snapshot === lastSubmittedSnapshot.value) {
|
||||
feedbackState.value = "success";
|
||||
feedbackResult.value = submittedFeedbackMessage;
|
||||
feedbackResultTone.value = "success";
|
||||
return;
|
||||
}
|
||||
if (lastUncertainSnapshot.value && snapshot === lastUncertainSnapshot.value) {
|
||||
feedbackState.value = "uncertain";
|
||||
feedbackResult.value = uncertainFeedbackMessage;
|
||||
feedbackResultTone.value = "uncertain";
|
||||
return;
|
||||
}
|
||||
feedbackState.value = "ready";
|
||||
if (lastUncertainSnapshot.value) {
|
||||
feedbackResult.value = uncertainWithPendingEditsMessage;
|
||||
feedbackResultTone.value = "uncertain";
|
||||
} else if (lastSubmittedSnapshot.value) {
|
||||
feedbackResult.value = submittedWithPendingEditsMessage;
|
||||
feedbackResultTone.value = "success";
|
||||
} else {
|
||||
feedbackResult.value = "";
|
||||
feedbackResultTone.value = "";
|
||||
}
|
||||
});
|
||||
watch(
|
||||
() => [
|
||||
feedbackForm.feedbackType,
|
||||
feedbackForm.feedbackContent,
|
||||
feedbackForm.contactInfo,
|
||||
],
|
||||
() => {
|
||||
errors.feedbackContent = "";
|
||||
submissionSession.reconcile(feedbackForm);
|
||||
syncSubmissionView();
|
||||
},
|
||||
);
|
||||
const requestBack = () =>
|
||||
runBackGuard({
|
||||
transientOpen: discardVisible.value,
|
||||
@@ -310,9 +325,11 @@ const requestBack = () =>
|
||||
"confirm-discard": requestDiscardConfirmation,
|
||||
});
|
||||
onBackPress((event) => handleBackPress(event, requestBack));
|
||||
onLoad(loadFeedbackHistory);
|
||||
onUnload(() => {
|
||||
pageActive = false;
|
||||
requestController.abort();
|
||||
feedbackHistoryRequestController.abort();
|
||||
feedbackSubmissionRequestController.abort();
|
||||
discardConfirmation.dispose();
|
||||
});
|
||||
</script>
|
||||
@@ -413,6 +430,127 @@ onUnload(() => {
|
||||
margin-top: 16rpx;
|
||||
border-top: 1px solid rgba(181, 137, 63, 0.38);
|
||||
}
|
||||
.form-label {
|
||||
display: block;
|
||||
margin-bottom: 12rpx;
|
||||
color: $ink;
|
||||
font-size: clamp(14px, 22rpx, 17px);
|
||||
font-weight: 700;
|
||||
}
|
||||
.history-section {
|
||||
margin-top: 30rpx;
|
||||
padding-top: 26rpx;
|
||||
border-top: 1px solid rgba(181, 137, 63, 0.42);
|
||||
}
|
||||
.history-heading,
|
||||
.history-card__meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16rpx;
|
||||
}
|
||||
.history-title {
|
||||
color: $ink;
|
||||
font-size: clamp(17px, 28rpx, 21px);
|
||||
font-weight: 700;
|
||||
}
|
||||
.history-note {
|
||||
color: rgba(76, 57, 43, 0.72);
|
||||
font-size: clamp(12px, 19rpx, 14px);
|
||||
}
|
||||
.history-state {
|
||||
margin-top: 18rpx;
|
||||
padding: 28rpx 22rpx;
|
||||
border: 1px solid rgba(181, 137, 63, 0.42);
|
||||
border-radius: 12rpx;
|
||||
background: rgba(255, 252, 245, 0.72);
|
||||
color: rgba(76, 57, 43, 0.78);
|
||||
font-size: clamp(14px, 22rpx, 16px);
|
||||
line-height: 1.6;
|
||||
text-align: center;
|
||||
}
|
||||
.history-state--error {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 16rpx;
|
||||
}
|
||||
.history-retry {
|
||||
min-width: 180rpx;
|
||||
min-height: 72rpx;
|
||||
margin: 0;
|
||||
padding: 0 28rpx;
|
||||
border: 1px solid rgba(164, 41, 36, 0.55);
|
||||
border-radius: 999px;
|
||||
background: rgba(255, 255, 255, 0.82);
|
||||
color: $brand-red;
|
||||
font-size: clamp(14px, 22rpx, 16px);
|
||||
line-height: 72rpx;
|
||||
}
|
||||
.history-list {
|
||||
display: grid;
|
||||
gap: 16rpx;
|
||||
margin-top: 18rpx;
|
||||
}
|
||||
.history-card {
|
||||
padding: 22rpx;
|
||||
border: 1px solid rgba(181, 137, 63, 0.42);
|
||||
border-radius: 12rpx;
|
||||
background: rgba(255, 252, 245, 0.78);
|
||||
box-shadow: 0 8rpx 20rpx rgba(93, 50, 30, 0.06);
|
||||
}
|
||||
.history-card__meta {
|
||||
color: rgba(76, 57, 43, 0.72);
|
||||
font-size: clamp(12px, 19rpx, 14px);
|
||||
}
|
||||
.history-status {
|
||||
color: $brand-red;
|
||||
font-weight: 700;
|
||||
}
|
||||
.history-content {
|
||||
display: block;
|
||||
margin-top: 12rpx;
|
||||
color: $ink;
|
||||
font-size: clamp(14px, 22rpx, 16px);
|
||||
line-height: 1.65;
|
||||
overflow-wrap: anywhere;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
.history-content--collapsed {
|
||||
display: -webkit-box;
|
||||
overflow: hidden;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 4;
|
||||
}
|
||||
.history-expand {
|
||||
min-height: 72rpx;
|
||||
margin: 4rpx 0 0 auto;
|
||||
padding: 0 8rpx;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: $brand-red;
|
||||
font-size: clamp(13px, 20rpx, 15px);
|
||||
line-height: 72rpx;
|
||||
}
|
||||
.history-expand::after {
|
||||
border: 0;
|
||||
}
|
||||
.history-reply {
|
||||
display: grid;
|
||||
gap: 6rpx;
|
||||
margin-top: 16rpx;
|
||||
padding: 16rpx 18rpx;
|
||||
border-left: 4rpx solid rgba(181, 137, 63, 0.72);
|
||||
background: rgba(181, 137, 63, 0.08);
|
||||
color: rgba(76, 57, 43, 0.88);
|
||||
font-size: clamp(13px, 20rpx, 15px);
|
||||
line-height: 1.6;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.history-reply__label {
|
||||
color: $brand-red;
|
||||
font-weight: 700;
|
||||
}
|
||||
.contact-row text {
|
||||
color: $ink;
|
||||
font-size: clamp(14px, 23rpx, 17px);
|
||||
@@ -0,0 +1,353 @@
|
||||
<template>
|
||||
<view class="help-page">
|
||||
<ModulePageBackground module="profile" />
|
||||
<view class="page-layer"
|
||||
><PageHeader title="帮助中心" custom-back @back="requestBack"
|
||||
/></view>
|
||||
<view class="page-content page-layer">
|
||||
<view class="help-hero">
|
||||
<text>常见问题</text>
|
||||
<text>帮助内容会随页面更新。</text>
|
||||
</view>
|
||||
|
||||
<view v-if="helpCenterState === 'loading'" class="help-state-card">
|
||||
<AppLoading text="正在读取帮助内容" />
|
||||
</view>
|
||||
<view v-else-if="helpCenterState === 'error'" class="help-state-card">
|
||||
<text>暂时无法读取帮助内容</text>
|
||||
<text>{{ helpCenterError || "请检查网络后重新加载。" }}</text>
|
||||
<AppButton block type="secondary" label="重新加载" @click="loadHelpArticles" />
|
||||
</view>
|
||||
<template v-else>
|
||||
<view v-if="articles.length" class="help-list-panel">
|
||||
<input
|
||||
v-model.trim="searchText"
|
||||
class="help-search"
|
||||
type="text"
|
||||
maxlength="80"
|
||||
placeholder="搜索问题"
|
||||
placeholder-class="help-search__placeholder"
|
||||
/>
|
||||
<scroll-view class="help-category-list" scroll-x>
|
||||
<button
|
||||
v-for="category in categories"
|
||||
:key="category"
|
||||
class="help-category"
|
||||
:class="{ 'help-category--active': activeCategory === category }"
|
||||
:aria-pressed="activeCategory === category"
|
||||
@click="selectCategory(category)"
|
||||
>{{ categoryLabel(category) }}</button>
|
||||
</scroll-view>
|
||||
|
||||
<view v-if="visibleArticles.length" class="help-article-list">
|
||||
<view v-for="item in visibleArticles" :key="item.key" class="help-article">
|
||||
<button
|
||||
class="help-article__question"
|
||||
:aria-expanded="expandedKey === item.key"
|
||||
:aria-controls="`help-answer-${item.key}`"
|
||||
@click="toggleArticle(item.key)"
|
||||
>
|
||||
<view>
|
||||
<text class="help-article__category">{{ item.categoryLabel }}</text>
|
||||
<text class="help-article__title">{{ item.title }}</text>
|
||||
</view>
|
||||
<text class="help-article__indicator">{{ expandedKey === item.key ? "收起" : "查看" }}</text>
|
||||
</button>
|
||||
<text
|
||||
v-if="expandedKey === item.key"
|
||||
:id="`help-answer-${item.key}`"
|
||||
class="help-article__answer"
|
||||
>{{ item.content }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<view v-else class="help-empty-state">
|
||||
<text>没有符合条件的帮助内容</text>
|
||||
<button v-if="searchText || activeCategory !== '全部'" @click="clearFilters">清除筛选</button>
|
||||
</view>
|
||||
</view>
|
||||
<view v-else class="help-state-card">
|
||||
<text>当前没有帮助内容</text>
|
||||
<text>如有使用问题,可以直接提交反馈。</text>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<view class="help-feedback-card">
|
||||
<text>没有找到答案?</text>
|
||||
<text>把问题提交给我们,我们会尽快处理。</text>
|
||||
<AppButton block label="提交问题反馈" @click="contactSupport" />
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref } from "vue";
|
||||
import { onBackPress, onLoad, onUnload } from "@dcloudio/uni-app";
|
||||
import AppButton from "@/components/AppButton.vue";
|
||||
import AppLoading from "@/components/AppLoading.vue";
|
||||
import ModulePageBackground from "@/components/ModulePageBackground.vue";
|
||||
import PageHeader from "@/components/PageHeader.vue";
|
||||
import {
|
||||
createRequestController,
|
||||
isRequestCancelled
|
||||
} from "@/services/api/request-controller.js";
|
||||
import { siteContentApi } from "@/services/api/site-content-service.js";
|
||||
import { getRequestErrorMessage } from "@/services/api/request-error-message.js";
|
||||
import { goBack, handleBackPress, openPage } from "@/utils/navigation/gateway.js";
|
||||
|
||||
const articles = ref([]);
|
||||
const helpCenterState = ref("loading");
|
||||
const helpCenterError = ref("");
|
||||
const searchText = ref("");
|
||||
const activeCategory = ref("全部");
|
||||
const expandedKey = ref("");
|
||||
const helpArticleRequestController = createRequestController();
|
||||
let pageActive = true;
|
||||
|
||||
const categories = computed(() => ["全部", ...new Set(articles.value.map((item) => item.category))]);
|
||||
const categoryLabel = (category) => (
|
||||
articles.value.find((item) => item.category === category)?.categoryLabel || category
|
||||
);
|
||||
const visibleArticles = computed(() => {
|
||||
const keyword = searchText.value.trim().toLocaleLowerCase();
|
||||
return articles.value.filter((item) => (
|
||||
(activeCategory.value === "全部" || item.category === activeCategory.value)
|
||||
&& (!keyword || `${item.title}\n${item.content}`.toLocaleLowerCase().includes(keyword))
|
||||
));
|
||||
});
|
||||
|
||||
const loadHelpArticles = async () => {
|
||||
helpArticleRequestController.abort();
|
||||
helpCenterState.value = "loading";
|
||||
helpCenterError.value = "";
|
||||
expandedKey.value = "";
|
||||
try {
|
||||
const helpArticles = await siteContentApi.getHelpArticles({
|
||||
requestController: helpArticleRequestController,
|
||||
});
|
||||
if (!pageActive) return;
|
||||
articles.value = helpArticles;
|
||||
if (!categories.value.includes(activeCategory.value)) activeCategory.value = "全部";
|
||||
helpCenterState.value = "ready";
|
||||
} catch (error) {
|
||||
if (!pageActive || isRequestCancelled(error)) return;
|
||||
helpCenterError.value = getRequestErrorMessage(error, "请稍后重试。");
|
||||
helpCenterState.value = "error";
|
||||
}
|
||||
};
|
||||
const selectCategory = (category) => {
|
||||
activeCategory.value = category;
|
||||
expandedKey.value = "";
|
||||
};
|
||||
const toggleArticle = (key) => {
|
||||
expandedKey.value = expandedKey.value === key ? "" : key;
|
||||
};
|
||||
const clearFilters = () => {
|
||||
searchText.value = "";
|
||||
activeCategory.value = "全部";
|
||||
expandedKey.value = "";
|
||||
};
|
||||
const contactSupport = () => openPage("M07", {}, "M06");
|
||||
const requestBack = () => goBack();
|
||||
onLoad(loadHelpArticles);
|
||||
onBackPress((event) => handleBackPress(event, requestBack));
|
||||
onUnload(() => {
|
||||
pageActive = false;
|
||||
helpArticleRequestController.abort();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@import "../../styles/adaptive-frame-profiles.scss";
|
||||
.help-page {
|
||||
display: flex;
|
||||
min-height: 100vh;
|
||||
flex-direction: column;
|
||||
background: $paper;
|
||||
}
|
||||
.page-layer {
|
||||
z-index: 1;
|
||||
}
|
||||
.page-content {
|
||||
flex: 1;
|
||||
padding: 24rpx 28rpx 72rpx;
|
||||
}
|
||||
.help-hero,
|
||||
.help-state-card,
|
||||
.help-list-panel,
|
||||
.help-feedback-card {
|
||||
@include adaptive-profile-content;
|
||||
}
|
||||
.help-hero {
|
||||
padding: 34rpx 36rpx;
|
||||
text-align: center;
|
||||
}
|
||||
.help-hero text {
|
||||
display: block;
|
||||
}
|
||||
.help-hero text:first-child,
|
||||
.help-state-card text:first-child,
|
||||
.help-feedback-card text:first-child {
|
||||
color: $ink;
|
||||
font-family: STKaiti, KaiTi, serif;
|
||||
font-size: clamp(18px, 32rpx, 22px);
|
||||
font-weight: 700;
|
||||
}
|
||||
.help-hero text:last-child,
|
||||
.help-state-card text:last-child,
|
||||
.help-feedback-card text:nth-child(2) {
|
||||
margin-top: 12rpx;
|
||||
color: $ink-muted;
|
||||
font-size: clamp(14px, 22rpx, 17px);
|
||||
line-height: 1.6;
|
||||
}
|
||||
.help-state-card {
|
||||
display: flex;
|
||||
min-height: 210rpx;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-top: 20rpx;
|
||||
padding: 38rpx;
|
||||
text-align: center;
|
||||
}
|
||||
.help-state-card text {
|
||||
display: block;
|
||||
}
|
||||
.help-state-card .app-button,
|
||||
.help-feedback-card .app-button {
|
||||
width: 100%;
|
||||
margin-top: 24rpx;
|
||||
}
|
||||
.help-list-panel {
|
||||
margin-top: 20rpx;
|
||||
padding: 24rpx;
|
||||
}
|
||||
.help-search {
|
||||
width: 100%;
|
||||
min-height: 72rpx;
|
||||
box-sizing: border-box;
|
||||
padding: 0 22rpx;
|
||||
border: 1rpx solid rgba(128, 89, 49, 0.28);
|
||||
border-radius: 12rpx;
|
||||
background: rgba(255, 252, 245, 0.78);
|
||||
color: $ink;
|
||||
font-size: clamp(14px, 24rpx, 18px);
|
||||
}
|
||||
.help-search__placeholder {
|
||||
color: #a49382;
|
||||
}
|
||||
.help-category-list {
|
||||
width: 100%;
|
||||
margin-top: 18rpx;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.help-category {
|
||||
display: inline-flex;
|
||||
min-width: 112rpx;
|
||||
min-height: 58rpx;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-right: 12rpx;
|
||||
padding: 0 20rpx;
|
||||
border: 1rpx solid rgba(128, 89, 49, 0.24);
|
||||
border-radius: 29rpx;
|
||||
background: rgba(255, 252, 245, 0.54);
|
||||
color: $ink-muted;
|
||||
font-size: clamp(13px, 22rpx, 16px);
|
||||
line-height: 1.4;
|
||||
}
|
||||
.help-category::after,
|
||||
.help-article__question::after,
|
||||
.help-empty-state button::after {
|
||||
border: 0;
|
||||
}
|
||||
.help-category--active {
|
||||
border-color: #8d2722;
|
||||
background: #8d2722;
|
||||
color: #fff7e8;
|
||||
}
|
||||
.help-article-list {
|
||||
display: grid;
|
||||
gap: 14rpx;
|
||||
width: 100%;
|
||||
}
|
||||
.help-article {
|
||||
overflow: hidden;
|
||||
border: 1rpx solid rgba(128, 89, 49, 0.22);
|
||||
border-radius: 12rpx;
|
||||
background: rgba(255, 252, 245, 0.66);
|
||||
}
|
||||
.help-article__question {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
min-height: 100rpx;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 18rpx;
|
||||
padding: 20rpx 22rpx;
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
text-align: left;
|
||||
}
|
||||
.help-article__question view {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
.help-article__question text {
|
||||
display: block;
|
||||
}
|
||||
.help-article__category {
|
||||
color: #9a6555;
|
||||
font-size: clamp(12px, 20rpx, 15px);
|
||||
}
|
||||
.help-article__title {
|
||||
margin-top: 5rpx;
|
||||
color: $ink;
|
||||
font-size: clamp(15px, 25rpx, 19px);
|
||||
font-weight: 700;
|
||||
line-height: 1.45;
|
||||
}
|
||||
.help-article__indicator {
|
||||
flex: 0 0 auto;
|
||||
color: #9f170f;
|
||||
font-size: clamp(12px, 20rpx, 15px);
|
||||
}
|
||||
.help-article__answer {
|
||||
display: block;
|
||||
padding: 0 22rpx 24rpx;
|
||||
border-top: 1rpx solid rgba(128, 89, 49, 0.16);
|
||||
color: $ink-muted;
|
||||
font-size: clamp(14px, 23rpx, 17px);
|
||||
line-height: 1.7;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
.help-empty-state {
|
||||
padding: 50rpx 22rpx 28rpx;
|
||||
color: $ink-muted;
|
||||
font-size: clamp(14px, 23rpx, 17px);
|
||||
text-align: center;
|
||||
}
|
||||
.help-empty-state text {
|
||||
display: block;
|
||||
}
|
||||
.help-empty-state button {
|
||||
margin-top: 16rpx;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: #9f170f;
|
||||
font-size: clamp(14px, 23rpx, 17px);
|
||||
}
|
||||
.help-feedback-card {
|
||||
margin-top: 20rpx;
|
||||
padding: 32rpx 34rpx;
|
||||
text-align: center;
|
||||
}
|
||||
@media (max-width: 340px) {
|
||||
.page-content {
|
||||
padding-right: 20rpx;
|
||||
padding-left: 20rpx;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,10 +1,9 @@
|
||||
<!-- 页面编号:M-01;用途:个人中心,显示当前用户资料与服务入口。 -->
|
||||
<template>
|
||||
<view
|
||||
class="profile-page"
|
||||
:class="{
|
||||
'profile-state--ready': !loading && !loadError,
|
||||
'profile-state--error': Boolean(loadError),
|
||||
'profile-state--ready': !loading && !profileHomeError,
|
||||
'profile-state--error': Boolean(profileHomeError),
|
||||
}"
|
||||
>
|
||||
<view v-if="compactHeader" class="profile-compact-header" aria-hidden="true">
|
||||
@@ -22,9 +21,9 @@
|
||||
<text>正在读取个人资料…</text>
|
||||
</view>
|
||||
|
||||
<view v-else-if="loadError" class="profile-state-card">
|
||||
<view v-else-if="profileHomeError" class="profile-state-card">
|
||||
<text>个人资料暂时无法读取</text>
|
||||
<text>{{ loadError }}</text>
|
||||
<text>{{ profileHomeError }}</text>
|
||||
<AppButton block label="重新读取" @click="loadProfile" />
|
||||
</view>
|
||||
|
||||
@@ -32,7 +31,7 @@
|
||||
<view class="profile-hero">
|
||||
<image
|
||||
class="profile-hero__art"
|
||||
src="/static/assets/modules/profile/opaque/m01-profile-archive-hero-v2.png"
|
||||
src="/static/assets/modules/profile/opaque/archive-hero.png"
|
||||
mode="scaleToFill"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
@@ -54,7 +53,7 @@
|
||||
@click="openSettings"
|
||||
><image
|
||||
class="profile-hero__setting-icon"
|
||||
src="/static/assets/modules/profile/transparent/m01-icon-settings.png"
|
||||
src="/static/assets/modules/profile/transparent/icon-settings.png"
|
||||
mode="aspectFit"
|
||||
aria-hidden="true"
|
||||
/></view>
|
||||
@@ -62,7 +61,10 @@
|
||||
|
||||
<view class="profile-hero__identity">
|
||||
<view class="profile-hero__emblem">
|
||||
<AppAvatar :sex="profile.sex" />
|
||||
<AppAvatar
|
||||
:sex="profile.sex"
|
||||
:src="profile.avatarFile?.accessUrl || ''"
|
||||
/>
|
||||
</view>
|
||||
<view class="profile-hero__copy">
|
||||
<text>{{ profile.nickName || "未设置昵称" }}</text>
|
||||
@@ -81,7 +83,7 @@
|
||||
<view class="profile-metadata__item">
|
||||
<image
|
||||
class="profile-metadata__icon"
|
||||
src="/static/assets/modules/profile/transparent/m01-icon-person.png"
|
||||
src="/static/assets/modules/profile/transparent/icon-person.png"
|
||||
mode="aspectFit"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
@@ -90,7 +92,7 @@
|
||||
<view class="profile-metadata__item">
|
||||
<image
|
||||
class="profile-metadata__icon"
|
||||
src="/static/assets/modules/profile/transparent/m01-icon-calendar.png"
|
||||
src="/static/assets/modules/profile/transparent/icon-calendar.png"
|
||||
mode="aspectFit"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
@@ -99,7 +101,7 @@
|
||||
<view class="profile-metadata__item profile-metadata__item--email">
|
||||
<image
|
||||
class="profile-metadata__icon"
|
||||
src="/static/assets/modules/profile/transparent/m01-icon-envelope.png"
|
||||
src="/static/assets/modules/profile/transparent/icon-envelope.png"
|
||||
mode="aspectFit"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
@@ -107,6 +109,21 @@
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="profile-preference" aria-label="个性化推荐设置">
|
||||
<view>
|
||||
<text>个性化推荐</text>
|
||||
<text v-if="preferenceLoading">正在读取设置</text>
|
||||
<text v-else-if="preferenceError">设置暂不可用</text>
|
||||
<text v-else>{{ recommendationPreference.enabled ? "已开启,将优先展示与家谱互动相关的内容" : "已关闭,按常规时间顺序展示内容" }}</text>
|
||||
</view>
|
||||
<switch
|
||||
:checked="recommendationPreference.enabled"
|
||||
:disabled="preferenceLoading || preferenceSaving || Boolean(preferenceError)"
|
||||
color="#9f170f"
|
||||
@change="changeRecommendationPreference"
|
||||
/>
|
||||
</view>
|
||||
|
||||
<view class="profile-services">
|
||||
<view
|
||||
v-for="group in serviceGroups"
|
||||
@@ -130,7 +147,7 @@
|
||||
<image
|
||||
:class="[
|
||||
'profile-service-row__icon',
|
||||
`profile-service-row__icon--${item.routeKey.toLowerCase()}`,
|
||||
`profile-service-row__icon--${item.iconVariant}`,
|
||||
]"
|
||||
:src="item.icon"
|
||||
mode="aspectFit"
|
||||
@@ -150,6 +167,7 @@
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<AppPromotionStrip v-if="!loading && !profileHomeError" placement="profile_bottom" title="为你推荐" />
|
||||
<AppTabbar active="profile" />
|
||||
</view>
|
||||
</template>
|
||||
@@ -159,16 +177,21 @@ import { computed, reactive, ref } from "vue";
|
||||
import { onPageScroll, onShow, onUnload } from "@dcloudio/uni-app";
|
||||
import AppAvatar from "@/components/AppAvatar.vue";
|
||||
import AppButton from "@/components/AppButton.vue";
|
||||
import AppPromotionStrip from "@/components/AppPromotionStrip.vue";
|
||||
import AppTabbar from "@/components/AppTabbar.vue";
|
||||
import {
|
||||
appApi,
|
||||
PROFILE_SEX_OPTIONS
|
||||
} from "@/services/api/profile-contract.js";
|
||||
import {
|
||||
createRequestController,
|
||||
isRequestCancelled,
|
||||
} from "@/utils/api.js";
|
||||
import { openPage } from "@/utils/navigation.js";
|
||||
isRequestCancelled
|
||||
} from "@/services/api/request-controller.js";
|
||||
import { profileApi } from "@/services/api/profile-service.js";
|
||||
import { getRequestErrorMessage } from "@/services/api/request-error-message.js";
|
||||
import { openPage } from "@/utils/navigation/gateway.js";
|
||||
|
||||
const profile = reactive({
|
||||
avatar: null,
|
||||
avatarFile: null,
|
||||
nickName: "",
|
||||
realName: "",
|
||||
phone: "",
|
||||
@@ -177,10 +200,16 @@ const profile = reactive({
|
||||
email: "",
|
||||
});
|
||||
const loading = ref(false);
|
||||
const loadError = ref("");
|
||||
const profileHomeError = ref("");
|
||||
const compactHeader = ref(false);
|
||||
const requestController = createRequestController();
|
||||
let pageActive = true;
|
||||
const profileReadController = createRequestController();
|
||||
const preferenceReadController = createRequestController();
|
||||
const preferenceSaveController = createRequestController();
|
||||
const recommendationPreference = reactive({ enabled: false, version: 0, updatedAt: "" });
|
||||
const preferenceLoading = ref(false);
|
||||
const preferenceSaving = ref(false);
|
||||
const preferenceError = ref("");
|
||||
let isPageActive = true;
|
||||
|
||||
const serviceGroups = [
|
||||
{
|
||||
@@ -189,17 +218,32 @@ const serviceGroups = [
|
||||
{
|
||||
routeKey: "M03",
|
||||
label: "账号与安全",
|
||||
icon: "/static/assets/modules/profile/transparent/m01-icon-security.png",
|
||||
iconVariant: "security",
|
||||
icon: "/static/assets/modules/profile/transparent/icon-security.png",
|
||||
},
|
||||
{
|
||||
routeKey: "N01",
|
||||
label: "消息中心",
|
||||
icon: "/static/assets/modules/profile/transparent/m01-icon-message.png",
|
||||
iconVariant: "notifications",
|
||||
icon: "/static/assets/modules/profile/transparent/icon-message.png",
|
||||
},
|
||||
{
|
||||
routeKey: "M11",
|
||||
label: "活动邀请",
|
||||
iconVariant: "invitations",
|
||||
icon: "/static/assets/modules/profile/transparent/icon-message.png",
|
||||
},
|
||||
{
|
||||
routeKey: "M09",
|
||||
label: "VIP 服务",
|
||||
icon: "/static/assets/modules/profile/transparent/m01-icon-vip.png",
|
||||
iconVariant: "vip",
|
||||
icon: "/static/assets/modules/profile/transparent/icon-vip.png",
|
||||
},
|
||||
{
|
||||
routeKey: "M12",
|
||||
label: "收益与提现",
|
||||
iconVariant: "earnings",
|
||||
icon: "/static/assets/modules/profile/transparent/icon-vip.png",
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -209,31 +253,37 @@ const serviceGroups = [
|
||||
{
|
||||
routeKey: "M06",
|
||||
label: "帮助中心",
|
||||
icon: "/static/assets/modules/profile/transparent/m01-icon-help.png",
|
||||
iconVariant: "help",
|
||||
icon: "/static/assets/modules/profile/transparent/icon-help.png",
|
||||
},
|
||||
{
|
||||
routeKey: "M07",
|
||||
label: "意见反馈",
|
||||
icon: "/static/assets/modules/profile/transparent/m01-icon-feedback.png",
|
||||
iconVariant: "feedback",
|
||||
icon: "/static/assets/modules/profile/transparent/icon-feedback.png",
|
||||
},
|
||||
{
|
||||
routeKey: "M08",
|
||||
label: "应用推广",
|
||||
icon: "/static/assets/modules/profile/transparent/m01-icon-promotion.png",
|
||||
iconVariant: "promotion",
|
||||
icon: "/static/assets/modules/profile/transparent/icon-promotion.png",
|
||||
},
|
||||
{
|
||||
routeKey: "M10",
|
||||
label: "关于与设置",
|
||||
icon: "/static/assets/modules/profile/transparent/m01-icon-settings.png",
|
||||
iconVariant: "settings",
|
||||
icon: "/static/assets/modules/profile/transparent/icon-settings.png",
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const sexText = computed(() => {
|
||||
const labels = { 0: "男", 1: "女", 2: "未知" };
|
||||
return labels[String(profile.sex)] || "未设置";
|
||||
});
|
||||
const profileSexLabels = Object.freeze(
|
||||
Object.fromEntries(PROFILE_SEX_OPTIONS.map(({ value, label }) => [value, label])),
|
||||
);
|
||||
const sexText = computed(
|
||||
() => profileSexLabels[String(profile.sex)] || "未设置",
|
||||
);
|
||||
const birthdayText = computed(() =>
|
||||
/^\d{4}-\d{2}-\d{2}/.test(profile.birthday)
|
||||
? profile.birthday.slice(0, 10)
|
||||
@@ -243,17 +293,51 @@ const birthdayText = computed(() =>
|
||||
const loadProfile = async () => {
|
||||
if (loading.value) return;
|
||||
loading.value = true;
|
||||
loadError.value = "";
|
||||
profileHomeError.value = "";
|
||||
try {
|
||||
const data = await appApi.getProfile({ requestController });
|
||||
if (!pageActive) return;
|
||||
Object.assign(profile, data);
|
||||
const profileResponse = await profileApi.getProfile({ requestController: profileReadController });
|
||||
if (!isPageActive) return;
|
||||
Object.assign(profile, profileResponse);
|
||||
} catch (error) {
|
||||
if (pageActive && !isRequestCancelled(error)) {
|
||||
loadError.value = error?.message || "请稍后重试";
|
||||
if (isPageActive && !isRequestCancelled(error)) {
|
||||
profileHomeError.value = getRequestErrorMessage(error, "请稍后重试");
|
||||
}
|
||||
} finally {
|
||||
if (pageActive) loading.value = false;
|
||||
if (isPageActive) loading.value = false;
|
||||
}
|
||||
};
|
||||
const loadRecommendationPreference = async () => {
|
||||
if (preferenceLoading.value || preferenceSaving.value) return;
|
||||
preferenceLoading.value = true;
|
||||
preferenceError.value = "";
|
||||
try {
|
||||
const preferenceResponse = await profileApi.getRecommendationPreference({
|
||||
requestController: preferenceReadController,
|
||||
});
|
||||
if (isPageActive) Object.assign(recommendationPreference, preferenceResponse);
|
||||
} catch (error) {
|
||||
if (isPageActive && !isRequestCancelled(error)) {
|
||||
preferenceError.value = getRequestErrorMessage(error, "请稍后重试");
|
||||
}
|
||||
} finally {
|
||||
if (isPageActive) preferenceLoading.value = false;
|
||||
}
|
||||
};
|
||||
const changeRecommendationPreference = async (event) => {
|
||||
if (preferenceLoading.value || preferenceSaving.value || preferenceError.value) return;
|
||||
const enabled = Boolean(event?.detail?.value);
|
||||
preferenceSaving.value = true;
|
||||
try {
|
||||
const savedPreference = await profileApi.updateRecommendationPreference(enabled, {
|
||||
requestController: preferenceSaveController,
|
||||
});
|
||||
if (isPageActive) Object.assign(recommendationPreference, savedPreference);
|
||||
} catch (error) {
|
||||
if (isPageActive && !isRequestCancelled(error)) {
|
||||
preferenceError.value = getRequestErrorMessage(error, "设置未保存,请稍后重试");
|
||||
}
|
||||
} finally {
|
||||
if (isPageActive) preferenceSaving.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -261,14 +345,19 @@ const openService = (item) => openPage(item.routeKey, {}, "M01");
|
||||
const openEdit = () => openPage("M02", {}, "M01");
|
||||
const openSettings = () => openPage("M10", {}, "M01");
|
||||
|
||||
onShow(loadProfile);
|
||||
onShow(() => {
|
||||
void loadProfile();
|
||||
void loadRecommendationPreference();
|
||||
});
|
||||
onPageScroll(({ scrollTop = 0 }) => {
|
||||
compactHeader.value = scrollTop > 44;
|
||||
});
|
||||
onUnload(() => {
|
||||
pageActive = false;
|
||||
isPageActive = false;
|
||||
compactHeader.value = false;
|
||||
requestController.abort();
|
||||
profileReadController.abort();
|
||||
preferenceReadController.abort();
|
||||
preferenceSaveController.abort();
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -282,7 +371,7 @@ onUnload(() => {
|
||||
padding-bottom: 166rpx;
|
||||
box-sizing: border-box;
|
||||
overflow-x: hidden;
|
||||
background: #f3eee5 url("/static/assets/modules/profile/opaque/m01-profile-paper-mountains-v1.png") center 360rpx / 100% auto no-repeat;
|
||||
background: #f3eee5 url("/static/assets/modules/profile/opaque/paper-mountains.png") center 360rpx / 100% auto no-repeat;
|
||||
}
|
||||
|
||||
.profile-compact-header {
|
||||
@@ -510,7 +599,7 @@ onUnload(() => {
|
||||
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;
|
||||
background: transparent url("/static/assets/modules/profile/transparent/edit-button.png") center / 100% 100% no-repeat;
|
||||
box-shadow: none;
|
||||
color: #fff6e5;
|
||||
font-size: clamp(14px, 22rpx, 17px);
|
||||
@@ -584,6 +673,46 @@ onUnload(() => {
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.profile-preference {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 20rpx;
|
||||
margin: 18rpx 34rpx 0;
|
||||
padding: 22rpx 24rpx;
|
||||
border: 1rpx solid rgba(117, 83, 52, 0.12);
|
||||
border-radius: 18rpx;
|
||||
background: rgba(239, 234, 224, 0.82);
|
||||
}
|
||||
|
||||
.profile-preference > view {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.profile-preference text {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.profile-preference text:first-child {
|
||||
color: #382c25;
|
||||
font-size: clamp(15px, 26rpx, 19px);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.profile-preference text:last-child {
|
||||
margin-top: 6rpx;
|
||||
color: #857263;
|
||||
font-size: clamp(12px, 20rpx, 15px);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.profile-preference switch {
|
||||
flex: 0 0 auto;
|
||||
transform: scale(0.88);
|
||||
transform-origin: right center;
|
||||
}
|
||||
|
||||
.profile-services {
|
||||
margin: 49rpx 58rpx 0 48rpx;
|
||||
}
|
||||
@@ -641,31 +770,31 @@ onUnload(() => {
|
||||
transform: translate(7rpx, 0) scale(1.8);
|
||||
}
|
||||
|
||||
.profile-service-row__icon--m03 {
|
||||
.profile-service-row__icon--security {
|
||||
transform: translate(9rpx, -9rpx) scale(1.8);
|
||||
}
|
||||
|
||||
.profile-service-row__icon--n01 {
|
||||
.profile-service-row__icon--notifications {
|
||||
transform: translate(10rpx, -10rpx) scale(1.8);
|
||||
}
|
||||
|
||||
.profile-service-row__icon--m09 {
|
||||
.profile-service-row__icon--vip {
|
||||
transform: translate(-5rpx, 5rpx) scale(1.8);
|
||||
}
|
||||
|
||||
.profile-service-row__icon--m06 {
|
||||
.profile-service-row__icon--help {
|
||||
transform: translate(-3rpx, 7rpx) scale(1.8);
|
||||
}
|
||||
|
||||
.profile-service-row__icon--m07 {
|
||||
.profile-service-row__icon--feedback {
|
||||
transform: translate(4rpx, 8rpx) scale(1.8);
|
||||
}
|
||||
|
||||
.profile-service-row__icon--m08 {
|
||||
.profile-service-row__icon--promotion {
|
||||
transform: translate(11rpx, 7rpx) scale(1.8);
|
||||
}
|
||||
|
||||
.profile-service-row__icon--m10 {
|
||||
.profile-service-row__icon--settings {
|
||||
transform: translate(9rpx, 7rpx) scale(1.8);
|
||||
}
|
||||
|
||||
@@ -1,328 +0,0 @@
|
||||
<!-- 页面编号:M-06;用途:帮助搜索、分类与常见问题。 -->
|
||||
<template>
|
||||
<view class="help-page">
|
||||
<ModulePageBackground module="profile" />
|
||||
<view class="page-layer"><PageHeader title="帮助中心" /></view>
|
||||
<view class="page-content page-layer">
|
||||
<view v-if="helpState === 'loading'" class="help-state-card"
|
||||
><AppLoading text="正在读取帮助内容"
|
||||
/></view>
|
||||
<view v-else-if="helpState === 'error'" class="help-state-card">
|
||||
<text>帮助内容暂时无法读取</text>
|
||||
<text>请检查网络后重新加载,或直接提交问题反馈。</text>
|
||||
<AppButton block type="secondary" label="重新加载" @click="loadHelpArticles" />
|
||||
<AppButton block label="提交问题反馈" @click="contactSupport" />
|
||||
</view>
|
||||
<template v-else>
|
||||
<text class="help-source-note">{{ sourceNote }}</text>
|
||||
<view class="search-box"
|
||||
><input
|
||||
v-model.trim="keyword"
|
||||
aria-label="搜索帮助"
|
||||
placeholder="搜索问题关键词"
|
||||
/><text>{{ filteredQuestions.length }} 条</text></view
|
||||
>
|
||||
<scroll-view scroll-x class="category-scroll" :show-scrollbar="false"
|
||||
><view class="category-row"
|
||||
><view
|
||||
v-for="category in helpCategories"
|
||||
:key="category.value"
|
||||
class="category-chip"
|
||||
:class="{ active: activeCategory === category.value }"
|
||||
role="button"
|
||||
@click="activeCategory = category.value"
|
||||
>{{ category.label }}</view
|
||||
></view
|
||||
></scroll-view
|
||||
>
|
||||
<view v-if="filteredQuestions.length" class="question-list">
|
||||
<view
|
||||
v-for="item in filteredQuestions"
|
||||
:key="item.id"
|
||||
class="question-card"
|
||||
>
|
||||
<view
|
||||
class="question-heading"
|
||||
role="button"
|
||||
:aria-label="item.question"
|
||||
@click="toggleQuestion(item.id)"
|
||||
><text>{{ item.question }}</text
|
||||
><text>{{
|
||||
expandedIds.includes(item.id) ? "收起" : "查看"
|
||||
}}</text></view
|
||||
>
|
||||
<text v-if="expandedIds.includes(item.id)" class="answer">{{
|
||||
item.answer
|
||||
}}</text>
|
||||
</view>
|
||||
</view>
|
||||
<view v-else class="empty-card"
|
||||
><text>没有找到相关问题</text
|
||||
><text>换个关键词试试,或直接提交意见反馈。</text></view
|
||||
>
|
||||
<AppButton
|
||||
block
|
||||
type="secondary"
|
||||
label="联系家谱助手"
|
||||
@click="contactSupport"
|
||||
/>
|
||||
</template>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref } from "vue";
|
||||
import { onLoad, onShow, onUnload } from "@dcloudio/uni-app";
|
||||
import AppButton from "@/components/AppButton.vue";
|
||||
import AppLoading from "@/components/AppLoading.vue";
|
||||
import ModulePageBackground from "@/components/ModulePageBackground.vue";
|
||||
import PageHeader from "@/components/PageHeader.vue";
|
||||
import {
|
||||
appApi,
|
||||
createRequestController,
|
||||
isRequestCancelled,
|
||||
} from "@/utils/api.js";
|
||||
import { openPage } from "@/utils/navigation.js";
|
||||
|
||||
const questions = ref([]);
|
||||
const helpState = ref("loading");
|
||||
const controller = createRequestController();
|
||||
let active = true;
|
||||
const categoryLabels = Object.freeze({
|
||||
common: "常见问题",
|
||||
member: "成员管理",
|
||||
lineage: "世系关系",
|
||||
});
|
||||
const helpCategories = computed(() => [
|
||||
{ value: "", label: "全部" },
|
||||
...[
|
||||
...new Set(questions.value.map((item) => item.category).filter(Boolean)),
|
||||
].map((value) => ({
|
||||
value,
|
||||
label: categoryLabels[value.toLowerCase()] || value,
|
||||
})),
|
||||
]);
|
||||
const activeCategory = ref("");
|
||||
const keyword = ref("");
|
||||
const expandedIds = ref([]);
|
||||
const sourceNote = computed(
|
||||
() =>
|
||||
({
|
||||
loading: "正在读取帮助文章…",
|
||||
error: "帮助文章暂时无法读取,请稍后重试。",
|
||||
empty: "暂未收到可展示的帮助文章。",
|
||||
ready: "帮助内容来自服务端,可按分类或关键词查找。",
|
||||
})[helpState.value],
|
||||
);
|
||||
const filteredQuestions = computed(() => {
|
||||
const query = keyword.value.toLowerCase();
|
||||
return questions.value.filter(
|
||||
(item) =>
|
||||
(!activeCategory.value || item.category === activeCategory.value) &&
|
||||
(!query ||
|
||||
`${item.question}${item.answer}`.toLowerCase().includes(query)),
|
||||
);
|
||||
});
|
||||
const toggleQuestion = (id) => {
|
||||
expandedIds.value = expandedIds.value.includes(id)
|
||||
? expandedIds.value.filter((item) => item !== id)
|
||||
: [...expandedIds.value, id];
|
||||
};
|
||||
const contactSupport = () => openPage("M07", {}, "M06");
|
||||
const loadHelpArticles = async () => {
|
||||
controller.abort();
|
||||
helpState.value = "loading";
|
||||
try {
|
||||
const rows = await appApi.getHelpArticles({
|
||||
requestController: controller,
|
||||
});
|
||||
if (!active) return;
|
||||
questions.value = rows.map((item) => ({
|
||||
id: String(item.helpId),
|
||||
category: String(item.helpCategory || "其他"),
|
||||
question: String(item.helpTitle || "未命名帮助文章"),
|
||||
answer: String(item.helpContent || "暂无正文"),
|
||||
}));
|
||||
helpState.value = questions.value.length ? "ready" : "empty";
|
||||
} catch (error) {
|
||||
if (!active || isRequestCancelled(error)) return;
|
||||
helpState.value = "error";
|
||||
}
|
||||
};
|
||||
onLoad(loadHelpArticles);
|
||||
onShow(() => {
|
||||
if (helpState.value !== "loading") loadHelpArticles();
|
||||
});
|
||||
onUnload(() => {
|
||||
active = false;
|
||||
controller.abort();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@import "../../styles/adaptive-frame-profiles.scss";
|
||||
.help-page {
|
||||
display: flex;
|
||||
min-height: 100vh;
|
||||
flex-direction: column;
|
||||
background: $paper;
|
||||
}
|
||||
.page-layer {
|
||||
z-index: 1;
|
||||
}
|
||||
.page-content {
|
||||
flex: 1;
|
||||
padding: 24rpx 28rpx 72rpx;
|
||||
}
|
||||
.help-source-note {
|
||||
display: block;
|
||||
margin: 0 4rpx 16rpx;
|
||||
color: $ink-muted;
|
||||
font-size: clamp(13px, 21rpx, 16px);
|
||||
line-height: 1.55;
|
||||
}
|
||||
.help-state-card {
|
||||
display: flex;
|
||||
min-height: 300rpx;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 52rpx 42rpx;
|
||||
text-align: center;
|
||||
@include adaptive-profile-content;
|
||||
}
|
||||
.help-state-card text {
|
||||
display: block;
|
||||
}
|
||||
.help-state-card text:first-child {
|
||||
color: $ink;
|
||||
font-family: STKaiti, KaiTi, serif;
|
||||
font-size: clamp(18px, 32rpx, 22px);
|
||||
font-weight: 700;
|
||||
}
|
||||
.help-state-card text:nth-child(2) {
|
||||
margin-top: 16rpx;
|
||||
color: $ink-muted;
|
||||
font-size: clamp(14px, 22rpx, 17px);
|
||||
line-height: 1.6;
|
||||
}
|
||||
.help-state-card .app-button {
|
||||
width: 100%;
|
||||
margin-top: 20rpx;
|
||||
}
|
||||
.search-box {
|
||||
@include adaptive-profile-field;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
min-height: 86rpx;
|
||||
align-items: center;
|
||||
gap: 16rpx;
|
||||
padding: 0 30rpx;
|
||||
}
|
||||
.search-box input {
|
||||
width: auto;
|
||||
min-width: 0;
|
||||
min-height: 68rpx;
|
||||
color: $ink;
|
||||
font-size: clamp(14px, 23rpx, 17px);
|
||||
}
|
||||
.search-box text {
|
||||
color: $ink-muted;
|
||||
font-size: clamp(13px, 20rpx, 16px);
|
||||
}
|
||||
.category-scroll {
|
||||
width: 100%;
|
||||
margin-top: 18rpx;
|
||||
}
|
||||
.category-row {
|
||||
display: flex;
|
||||
width: max-content;
|
||||
gap: 12rpx;
|
||||
padding: 2rpx;
|
||||
}
|
||||
.category-chip {
|
||||
display: flex;
|
||||
min-width: 100rpx;
|
||||
min-height: 64rpx;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0 22rpx;
|
||||
box-sizing: border-box;
|
||||
color: $ink-muted;
|
||||
font-size: clamp(14px, 22rpx, 17px);
|
||||
}
|
||||
.category-chip.active {
|
||||
@include adaptive-scroll-button(secondary);
|
||||
color: $brand-red;
|
||||
font-weight: 700;
|
||||
}
|
||||
.question-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14rpx;
|
||||
margin-top: 18rpx;
|
||||
}
|
||||
.question-card,
|
||||
.empty-card {
|
||||
@include adaptive-profile-content;
|
||||
}
|
||||
.question-card {
|
||||
min-height: 104rpx;
|
||||
padding: 24rpx 34rpx;
|
||||
}
|
||||
.question-heading {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
min-height: 58rpx;
|
||||
align-items: center;
|
||||
gap: 18rpx;
|
||||
}
|
||||
.question-heading text:first-child {
|
||||
color: $ink;
|
||||
font-size: clamp(15px, 24rpx, 18px);
|
||||
font-weight: 700;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.question-heading text:last-child {
|
||||
color: $brand-red;
|
||||
font-size: clamp(13px, 20rpx, 16px);
|
||||
}
|
||||
.answer {
|
||||
display: block;
|
||||
padding: 14rpx 4rpx 6rpx;
|
||||
border-top: 1px solid rgba(181, 137, 63, 0.32);
|
||||
color: $ink-muted;
|
||||
font-size: clamp(14px, 22rpx, 17px);
|
||||
line-height: 1.65;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.empty-card {
|
||||
min-height: 220rpx;
|
||||
padding: 58rpx 44rpx;
|
||||
text-align: center;
|
||||
}
|
||||
.empty-card text {
|
||||
display: block;
|
||||
}
|
||||
.empty-card text:first-child {
|
||||
color: $ink;
|
||||
font-size: clamp(16px, 29rpx, 20px);
|
||||
font-weight: 700;
|
||||
}
|
||||
.empty-card text:last-child {
|
||||
margin-top: 12rpx;
|
||||
color: $ink-muted;
|
||||
font-size: clamp(14px, 22rpx, 17px);
|
||||
line-height: 1.5;
|
||||
}
|
||||
.page-content > .app-button {
|
||||
margin-top: 28rpx;
|
||||
}
|
||||
@media (max-width: 340px) {
|
||||
.page-content {
|
||||
padding-right: 20rpx;
|
||||
padding-left: 20rpx;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,212 +0,0 @@
|
||||
<template>
|
||||
<view class="promotion-page">
|
||||
<ModulePageBackground module="profile" />
|
||||
<view class="page-layer"
|
||||
><PageHeader title="推广中心" custom-back @back="requestBack"
|
||||
/></view>
|
||||
<view class="page-content page-layer">
|
||||
<view class="invite-hero">
|
||||
<image
|
||||
src="/static/assets/foundation/transparent/brand-seal.png"
|
||||
mode="aspectFit"
|
||||
/>
|
||||
<text>家谱服务推荐</text>
|
||||
<text>以下推荐内容由服务端配置。</text>
|
||||
</view>
|
||||
<view v-if="state === 'loading'" class="state-card"
|
||||
><AppLoading text="正在读取推广内容"
|
||||
/></view>
|
||||
<view v-else-if="state === 'error'" class="state-card">
|
||||
<text>暂时无法读取推广内容</text
|
||||
><AppButton
|
||||
block
|
||||
type="secondary"
|
||||
label="重新加载"
|
||||
@click="loadPromotions"
|
||||
/>
|
||||
</view>
|
||||
<view v-else-if="state === 'empty'" class="state-card"
|
||||
><text>当前没有可展示的推广内容</text></view
|
||||
>
|
||||
<view v-else class="promotion-list">
|
||||
<view v-for="item in promotions" :key="item.id" class="promotion-card">
|
||||
<text class="promotion-title">{{ item.title }}</text>
|
||||
<text v-if="item.platform" class="promotion-platform">{{
|
||||
item.platform
|
||||
}}</text>
|
||||
<text v-if="item.description" class="promotion-copy">{{
|
||||
item.description
|
||||
}}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from "vue";
|
||||
import { onBackPress, onLoad, onShow, onUnload } from "@dcloudio/uni-app";
|
||||
import AppButton from "@/components/AppButton.vue";
|
||||
import AppLoading from "@/components/AppLoading.vue";
|
||||
import ModulePageBackground from "@/components/ModulePageBackground.vue";
|
||||
import PageHeader from "@/components/PageHeader.vue";
|
||||
import {
|
||||
appApi,
|
||||
createRequestController,
|
||||
isRequestCancelled,
|
||||
} from "@/utils/api.js";
|
||||
import { handleBackPress, runBackGuard } from "@/utils/navigation.js";
|
||||
|
||||
const promotions = ref([]);
|
||||
const state = ref("loading");
|
||||
const controller = createRequestController();
|
||||
let active = true;
|
||||
const platformLabels = Object.freeze({
|
||||
app: "APP 应用",
|
||||
pc: "管理后台",
|
||||
wechat: "微信小程序",
|
||||
});
|
||||
const formatPlatform = (value) =>
|
||||
platformLabels[
|
||||
String(value || "")
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
] || String(value || "").trim();
|
||||
const loadPromotions = async () => {
|
||||
controller.abort();
|
||||
state.value = "loading";
|
||||
try {
|
||||
const rows = await appApi.getPromotions({ requestController: controller });
|
||||
if (!active) return;
|
||||
promotions.value = rows.map((item) => ({
|
||||
id: String(item.promotionId),
|
||||
title: String(item.promotionTitle || "未命名推广"),
|
||||
description: String(item.promotionDesc || ""),
|
||||
platform: formatPlatform(item.platform),
|
||||
}));
|
||||
state.value = promotions.value.length ? "ready" : "empty";
|
||||
} catch (error) {
|
||||
if (!active || isRequestCancelled(error)) return;
|
||||
state.value = "error";
|
||||
}
|
||||
};
|
||||
const requestBack = () => runBackGuard({});
|
||||
onLoad(loadPromotions);
|
||||
onShow(() => {
|
||||
if (state.value !== "loading") loadPromotions();
|
||||
});
|
||||
onUnload(() => {
|
||||
active = false;
|
||||
controller.abort();
|
||||
});
|
||||
onBackPress((event) => handleBackPress(event, requestBack));
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@import "../../styles/adaptive-frame-profiles.scss";
|
||||
.promotion-page {
|
||||
display: flex;
|
||||
min-height: 100vh;
|
||||
flex-direction: column;
|
||||
background: $paper;
|
||||
}
|
||||
.page-layer {
|
||||
z-index: 1;
|
||||
}
|
||||
.page-content {
|
||||
flex: 1;
|
||||
padding: 28rpx 30rpx 72rpx;
|
||||
}
|
||||
.invite-hero,
|
||||
.state-card,
|
||||
.promotion-card {
|
||||
@include adaptive-profile-content;
|
||||
}
|
||||
.invite-hero {
|
||||
display: flex;
|
||||
min-height: 250rpx;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 38rpx 48rpx;
|
||||
text-align: center;
|
||||
}
|
||||
.invite-hero image {
|
||||
width: 90rpx;
|
||||
height: 102rpx;
|
||||
}
|
||||
.invite-hero text {
|
||||
display: block;
|
||||
}
|
||||
.invite-hero text:nth-child(2) {
|
||||
margin-top: 12rpx;
|
||||
color: $ink;
|
||||
font-family: STKaiti, KaiTi, serif;
|
||||
font-size: clamp(19px, 34rpx, 24px);
|
||||
font-weight: 700;
|
||||
}
|
||||
.invite-hero text:last-child {
|
||||
margin-top: 12rpx;
|
||||
color: $ink-muted;
|
||||
font-size: clamp(14px, 22rpx, 17px);
|
||||
line-height: 1.55;
|
||||
}
|
||||
.state-card {
|
||||
display: flex;
|
||||
min-height: 210rpx;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-top: 20rpx;
|
||||
padding: 38rpx;
|
||||
text-align: center;
|
||||
color: $ink;
|
||||
font-size: clamp(16px, 26rpx, 20px);
|
||||
}
|
||||
.state-card .app-button {
|
||||
width: 100%;
|
||||
margin-top: 22rpx;
|
||||
}
|
||||
.promotion-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16rpx;
|
||||
margin-top: 20rpx;
|
||||
}
|
||||
.promotion-card {
|
||||
position: relative;
|
||||
display: flex;
|
||||
min-height: 142rpx;
|
||||
flex-direction: column;
|
||||
padding: 30rpx 34rpx;
|
||||
}
|
||||
.promotion-title {
|
||||
color: $ink;
|
||||
font-size: clamp(16px, 28rpx, 20px);
|
||||
font-weight: 700;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.promotion-platform {
|
||||
align-self: flex-start;
|
||||
margin-top: 10rpx;
|
||||
padding: 3rpx 12rpx;
|
||||
border: 1rpx solid rgba(181, 46, 34, 0.26);
|
||||
border-radius: 999rpx;
|
||||
color: $brand-red;
|
||||
font-size: clamp(13px, 20rpx, 16px);
|
||||
line-height: 1.3;
|
||||
}
|
||||
.promotion-copy {
|
||||
margin-top: 12rpx;
|
||||
color: $ink-muted;
|
||||
font-size: clamp(14px, 22rpx, 17px);
|
||||
line-height: 1.55;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
@media (max-width: 340px) {
|
||||
.page-content {
|
||||
padding-right: 22rpx;
|
||||
padding-left: 22rpx;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,614 +0,0 @@
|
||||
<template>
|
||||
<view class="orders-page">
|
||||
<ModulePageBackground module="profile" />
|
||||
<view class="page-layer"
|
||||
><PageHeader
|
||||
class="orders-header"
|
||||
title="VIP 与订单"
|
||||
custom-back
|
||||
@back="requestBack"
|
||||
/></view>
|
||||
<view class="page-content page-layer">
|
||||
<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>
|
||||
<view v-else-if="state === 'error'" class="state-card"
|
||||
><text>暂时无法读取套餐或订单</text
|
||||
><AppButton block type="secondary" label="重新加载" @click="loadVip"
|
||||
/></view>
|
||||
<template v-else>
|
||||
<view v-if="packages.length" class="package-list"
|
||||
><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"
|
||||
><text>当前没有可展示的服务套餐</text></view
|
||||
>
|
||||
<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"
|
||||
><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, 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";
|
||||
import {
|
||||
appApi,
|
||||
createRequestController,
|
||||
isRequestCancelled,
|
||||
} from "@/utils/api.js";
|
||||
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: "天" });
|
||||
const formatDuration = (value, unit) => {
|
||||
if (value === undefined || value === null || value === "") return "";
|
||||
if (Number.isFinite(Number(value)) && Number(value) <= 0) return "永久有效";
|
||||
const normalizedUnit = String(unit || "")
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
return durationUnits[normalizedUnit]
|
||||
? `${value} ${durationUnits[normalizedUnit]}`
|
||||
: String(value);
|
||||
};
|
||||
const loadVip = async () => {
|
||||
controller.abort();
|
||||
state.value = "loading";
|
||||
try {
|
||||
const packageRows = await appApi.getVipPackages({
|
||||
requestController: controller,
|
||||
});
|
||||
const orderRows = await appApi.getVipOrders({
|
||||
requestController: controller,
|
||||
});
|
||||
if (!active) return;
|
||||
packages.value = packageRows.map((item) => ({
|
||||
id: String(item.packageId),
|
||||
name: String(item.packageName || "未命名套餐"),
|
||||
description: String(item.packageDesc || ""),
|
||||
price: formatPrice(item.price),
|
||||
duration: formatDuration(item.durationValue, item.durationUnit),
|
||||
}));
|
||||
orders.value = orderRows
|
||||
.map((item) => ({
|
||||
id: String(item.orderId || item.id || ""),
|
||||
title: String(item.packageName || item.orderNo || "VIP 订单"),
|
||||
status: String(item.orderStatus || item.status || ""),
|
||||
}))
|
||||
.filter((item) => item.id);
|
||||
state.value = "ready";
|
||||
} catch (error) {
|
||||
if (!active || isRequestCancelled(error)) return;
|
||||
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({});
|
||||
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>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@import "../../styles/adaptive-frame-profiles.scss";
|
||||
.orders-page {
|
||||
display: flex;
|
||||
min-height: 100vh;
|
||||
flex-direction: column;
|
||||
background: #f7f2e9;
|
||||
}
|
||||
.page-layer {
|
||||
z-index: 1;
|
||||
}
|
||||
.page-content {
|
||||
flex: 1;
|
||||
padding: 20rpx 30rpx 72rpx;
|
||||
}
|
||||
.state-card,
|
||||
.order-empty {
|
||||
@include adaptive-profile-content;
|
||||
}
|
||||
.vip-intro {
|
||||
position: relative;
|
||||
min-height: 284rpx;
|
||||
overflow: hidden;
|
||||
margin: -20rpx -30rpx 0;
|
||||
padding: 36rpx 48rpx 30rpx;
|
||||
text-align: center;
|
||||
}
|
||||
.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;
|
||||
}
|
||||
.vip-intro__eyebrow {
|
||||
color: $brand-red;
|
||||
font-size: clamp(13px, 20rpx, 16px);
|
||||
font-weight: 700;
|
||||
letter-spacing: 5rpx;
|
||||
}
|
||||
.vip-intro__title {
|
||||
margin-top: 16rpx;
|
||||
color: #33261d;
|
||||
font-family: STKaiti, KaiTi, serif;
|
||||
font-size: clamp(20px, 38rpx, 26px);
|
||||
font-weight: 700;
|
||||
letter-spacing: 1rpx;
|
||||
}
|
||||
.vip-intro__copy {
|
||||
margin-top: 14rpx;
|
||||
color: $ink-muted;
|
||||
font-size: clamp(14px, 23rpx, 17px);
|
||||
line-height: 1.55;
|
||||
}
|
||||
.state-card {
|
||||
display: flex;
|
||||
min-height: 190rpx;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-top: 18rpx;
|
||||
padding: 36rpx;
|
||||
text-align: center;
|
||||
color: $ink;
|
||||
font-size: clamp(16px, 26rpx, 20px);
|
||||
}
|
||||
.state-card .app-button {
|
||||
width: 100%;
|
||||
margin-top: 22rpx;
|
||||
}
|
||||
.package-list,
|
||||
.order-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 18rpx;
|
||||
}
|
||||
.package-card {
|
||||
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-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: clamp(19px, 36rpx, 24px);
|
||||
font-weight: 700;
|
||||
}
|
||||
.package-heading > view text:last-child {
|
||||
margin-top: 10rpx;
|
||||
color: #77675a;
|
||||
font-size: clamp(14px, 23rpx, 17px);
|
||||
line-height: 1.55;
|
||||
}
|
||||
.package-price {
|
||||
color: $brand-red;
|
||||
font-family: Georgia, STKaiti, serif;
|
||||
font-size: clamp(20px, 38rpx, 26px);
|
||||
font-weight: 700;
|
||||
line-height: 1.1;
|
||||
}
|
||||
.package-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 20rpx;
|
||||
margin-top: 30rpx;
|
||||
padding-top: 22rpx;
|
||||
border-top: 1rpx solid rgba(117, 84, 52, 0.14);
|
||||
}
|
||||
.package-duration {
|
||||
color: #837164;
|
||||
font-size: clamp(14px, 23rpx, 17px);
|
||||
}
|
||||
.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: clamp(15px, 24rpx, 18px);
|
||||
font-weight: 700;
|
||||
letter-spacing: 1rpx;
|
||||
line-height: normal;
|
||||
}
|
||||
.purchase-button::after {
|
||||
border: 0;
|
||||
}
|
||||
.purchase-button[disabled] {
|
||||
opacity: 0.5;
|
||||
}
|
||||
.section-heading {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin: 44rpx 6rpx 16rpx;
|
||||
color: #38281d;
|
||||
font-family: STKaiti, KaiTi, serif;
|
||||
font-size: clamp(17px, 32rpx, 22px);
|
||||
font-weight: 700;
|
||||
}
|
||||
.section-heading text:last-child {
|
||||
color: #8a796b;
|
||||
font-size: clamp(13px, 21rpx, 16px);
|
||||
font-weight: 400;
|
||||
}
|
||||
.order-notice {
|
||||
display: block;
|
||||
margin: 0 6rpx 14rpx;
|
||||
color: #786556;
|
||||
font-size: clamp(13px, 21rpx, 16px);
|
||||
line-height: 1.5;
|
||||
}
|
||||
.order-card {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 18rpx;
|
||||
padding: 26rpx 4rpx;
|
||||
border-bottom: 1rpx solid rgba(117, 84, 52, 0.14);
|
||||
color: #48372a;
|
||||
font-size: clamp(15px, 24rpx, 18px);
|
||||
}
|
||||
.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: clamp(13px, 20rpx, 16px);
|
||||
}
|
||||
.order-card text:last-child {
|
||||
flex: 0 0 auto;
|
||||
color: #7f6959;
|
||||
font-size: clamp(13px, 21rpx, 16px);
|
||||
}
|
||||
.order-empty {
|
||||
padding: 38rpx;
|
||||
text-align: center;
|
||||
color: $ink-muted;
|
||||
font-size: clamp(14px, 23rpx, 17px);
|
||||
}
|
||||
: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: clamp(20px, 37rpx, 26px);
|
||||
}
|
||||
: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: clamp(22px, 42rpx, 28px);
|
||||
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: clamp(15px, 24rpx, 18px);
|
||||
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: clamp(15px, 24rpx, 18px);
|
||||
}
|
||||
.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: clamp(19px, 34rpx, 24px);
|
||||
}
|
||||
.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: clamp(13px, 20rpx, 16px);
|
||||
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>
|
||||
@@ -1,234 +0,0 @@
|
||||
<!-- 页面编号:M-10;用途:协议、隐私、版本与退出登录。 -->
|
||||
<template>
|
||||
<view
|
||||
class="about-page"
|
||||
:class="{ 'about-state--logging-out': logoutSubmitting }"
|
||||
>
|
||||
<ModulePageBackground module="profile" />
|
||||
<view class="page-layer"
|
||||
><PageHeader title="关于家谱" custom-back @back="requestBack"
|
||||
/></view>
|
||||
<view class="page-content page-layer">
|
||||
<view class="brand-card"
|
||||
><image
|
||||
src="/static/assets/foundation/transparent/brand-seal.png"
|
||||
mode="aspectFit"
|
||||
/><text>家谱</text><text>传承每一段值得珍藏的家族记忆</text
|
||||
><text>版本 {{ appVersion }}</text></view
|
||||
>
|
||||
<view class="settings-list">
|
||||
<view
|
||||
v-for="item in agreementItems"
|
||||
:key="item.key"
|
||||
class="settings-row"
|
||||
role="button"
|
||||
:aria-label="item.label"
|
||||
@click="openAgreement(item)"
|
||||
><view
|
||||
><text>{{ item.label }}</text
|
||||
><text>{{ item.note }}</text></view
|
||||
><image
|
||||
src="/static/assets/foundation/transparent/chevron-right.png"
|
||||
mode="aspectFit"
|
||||
/></view>
|
||||
</view>
|
||||
<AppButton
|
||||
block
|
||||
type="secondary"
|
||||
label="退出登录"
|
||||
@click="logoutVisible = true"
|
||||
/>
|
||||
</view>
|
||||
<AppDialog
|
||||
:visible="agreementVisible"
|
||||
:close-on-mask="false"
|
||||
eyebrow="协议与说明"
|
||||
:title="activeAgreement.label"
|
||||
:message="activeAgreement.copy"
|
||||
confirm-text="关闭"
|
||||
@confirm="agreementVisible = false"
|
||||
@close="agreementVisible = false"
|
||||
/>
|
||||
<AppDialog
|
||||
:visible="logoutVisible"
|
||||
eyebrow="账号操作"
|
||||
title="确认退出登录?"
|
||||
message="退出后需要重新验证账号;本机保存的密码不会被保留。"
|
||||
:confirm-text="logoutSubmitting ? '正在退出' : '确认退出'"
|
||||
cancel-text="取消"
|
||||
show-cancel
|
||||
:close-on-mask="false"
|
||||
@confirm="confirmLogout"
|
||||
@cancel="logoutVisible = false"
|
||||
@close="logoutVisible = false"
|
||||
/>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { reactive, ref } from "vue";
|
||||
import { onBackPress, onUnload } from "@dcloudio/uni-app";
|
||||
import AppButton from "@/components/AppButton.vue";
|
||||
import AppDialog from "@/components/AppDialog.vue";
|
||||
import ModulePageBackground from "@/components/ModulePageBackground.vue";
|
||||
import PageHeader from "@/components/PageHeader.vue";
|
||||
import manifest from "@/manifest.json";
|
||||
import { appApi, createRequestController } from "@/utils/api.js";
|
||||
import { goRoot, handleBackPress, runBackGuard } from "@/utils/navigation.js";
|
||||
import { session } from "@/utils/session.js";
|
||||
|
||||
const appVersion = manifest.versionName;
|
||||
const agreementItems = [
|
||||
{
|
||||
key: "terms",
|
||||
label: "用户协议",
|
||||
note: "了解账号与服务使用规则",
|
||||
copy: "用户协议正文将在正式协议服务接入后展示。当前页面不代表最终法律文本。",
|
||||
},
|
||||
{
|
||||
key: "privacy",
|
||||
label: "隐私政策",
|
||||
note: "了解个人资料如何使用与保护",
|
||||
copy: "隐私政策正文将在正式协议服务接入后展示。敏感资料默认按权限与脱敏规则展示。",
|
||||
},
|
||||
{
|
||||
key: "version",
|
||||
label: "版本说明",
|
||||
note: `当前版本 ${appVersion}`,
|
||||
copy: `当前安装版本为 ${appVersion}。基础版本采用浅色国风主题。`,
|
||||
},
|
||||
];
|
||||
const activeAgreement = reactive({ label: "", copy: "" });
|
||||
const agreementVisible = ref(false);
|
||||
const logoutVisible = ref(false);
|
||||
const logoutSubmitting = ref(false);
|
||||
const logoutRequestController = createRequestController();
|
||||
const openAgreement = (item) => {
|
||||
activeAgreement.label = item.label;
|
||||
activeAgreement.copy = item.copy;
|
||||
agreementVisible.value = true;
|
||||
};
|
||||
const confirmLogout = async () => {
|
||||
if (logoutSubmitting.value) return false;
|
||||
logoutSubmitting.value = true;
|
||||
try {
|
||||
await appApi.logout({ requestController: logoutRequestController });
|
||||
} catch {
|
||||
// 退出请求的结果未知时仍必须撤销本机会话,不能保留旧授权或自动重试。
|
||||
} finally {
|
||||
session.clear();
|
||||
logoutVisible.value = false;
|
||||
logoutSubmitting.value = false;
|
||||
}
|
||||
return goRoot("A01");
|
||||
};
|
||||
const closeActiveDialog = () => {
|
||||
if (logoutVisible.value) logoutVisible.value = false;
|
||||
else agreementVisible.value = false;
|
||||
};
|
||||
const requestBack = () =>
|
||||
runBackGuard({
|
||||
transientOpen: agreementVisible.value || logoutVisible.value,
|
||||
"close-transient": closeActiveDialog,
|
||||
});
|
||||
onBackPress((event) => handleBackPress(event, requestBack));
|
||||
onUnload(() => logoutRequestController.abort());
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@import "../../styles/adaptive-frame-profiles.scss";
|
||||
.about-page {
|
||||
display: flex;
|
||||
min-height: 100vh;
|
||||
flex-direction: column;
|
||||
background: $paper;
|
||||
}
|
||||
.page-layer {
|
||||
z-index: 1;
|
||||
}
|
||||
.page-content {
|
||||
flex: 1;
|
||||
padding: 28rpx 30rpx 72rpx;
|
||||
}
|
||||
.brand-card {
|
||||
@include adaptive-profile-summary;
|
||||
display: flex;
|
||||
min-height: 300rpx;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 36rpx 44rpx;
|
||||
text-align: center;
|
||||
}
|
||||
.brand-card image {
|
||||
width: 92rpx;
|
||||
height: 106rpx;
|
||||
}
|
||||
.brand-card text {
|
||||
display: block;
|
||||
}
|
||||
.brand-card text:nth-child(2) {
|
||||
margin-top: 6rpx;
|
||||
color: $ink;
|
||||
font-family: STKaiti, KaiTi, serif;
|
||||
font-size: clamp(20px, 40rpx, 26px);
|
||||
font-weight: 700;
|
||||
letter-spacing: 4rpx;
|
||||
}
|
||||
.brand-card text:nth-child(3) {
|
||||
margin-top: 8rpx;
|
||||
color: $ink-muted;
|
||||
font-size: clamp(14px, 22rpx, 17px);
|
||||
line-height: 1.5;
|
||||
}
|
||||
.brand-card text:last-child {
|
||||
margin-top: 10rpx;
|
||||
color: $ink-muted;
|
||||
font-size: clamp(13px, 20rpx, 16px);
|
||||
}
|
||||
.settings-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12rpx;
|
||||
margin-top: 22rpx;
|
||||
}
|
||||
.settings-row {
|
||||
@include adaptive-profile-field;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 32rpx;
|
||||
min-height: 104rpx;
|
||||
align-items: center;
|
||||
gap: 18rpx;
|
||||
padding: 18rpx 30rpx;
|
||||
}
|
||||
.settings-row view {
|
||||
min-width: 0;
|
||||
}
|
||||
.settings-row text {
|
||||
display: block;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.settings-row text:first-child {
|
||||
color: $ink;
|
||||
font-size: clamp(15px, 24rpx, 18px);
|
||||
font-weight: 700;
|
||||
}
|
||||
.settings-row text:last-child {
|
||||
margin-top: 6rpx;
|
||||
color: $ink-muted;
|
||||
font-size: clamp(13px, 20rpx, 16px);
|
||||
}
|
||||
.settings-row image {
|
||||
width: 30rpx;
|
||||
height: 30rpx;
|
||||
}
|
||||
.page-content > .app-button {
|
||||
margin-top: 28rpx;
|
||||
}
|
||||
@media (max-width: 340px) {
|
||||
.page-content {
|
||||
padding-right: 22rpx;
|
||||
padding-left: 22rpx;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,242 @@
|
||||
<template>
|
||||
<view class="promotion-page">
|
||||
<ModulePageBackground module="profile" />
|
||||
<view class="page-layer"><PageHeader title="推广中心" custom-back @back="requestBack" /></view>
|
||||
<view class="page-content page-layer">
|
||||
<view class="promotion-hero">
|
||||
<image
|
||||
src="/static/assets/foundation/transparent/brand-seal.png"
|
||||
mode="aspectFit"
|
||||
/>
|
||||
<text>家谱服务推荐</text>
|
||||
<text>这里会展示家谱相关服务和活动。</text>
|
||||
</view>
|
||||
<view v-if="promotionListState === 'loading'" class="promotion-state-card">
|
||||
<AppLoading text="正在读取推广内容" />
|
||||
</view>
|
||||
<view v-else-if="promotionListState === 'error'" class="promotion-state-card">
|
||||
<text>暂时无法读取推广内容</text>
|
||||
<text>{{ promotionListError || '请检查网络后重新加载。' }}</text>
|
||||
<AppButton block type="secondary" label="重新加载" @click="loadPromotions" />
|
||||
</view>
|
||||
<view v-else-if="!promotions.length" class="promotion-state-card">
|
||||
<text>当前没有推广内容</text>
|
||||
<text>后续正式发布的家谱服务推荐会在这里展示。</text>
|
||||
</view>
|
||||
<view v-else class="promotion-list">
|
||||
<text v-if="operationFeedback" class="promotion-feedback" role="status">{{ operationFeedback }}</text>
|
||||
<view
|
||||
v-for="item in promotions"
|
||||
:key="item.id"
|
||||
class="promotion-card"
|
||||
:class="{ 'promotion-card--linked': item.targetUrl }"
|
||||
:role="item.targetUrl ? 'button' : undefined"
|
||||
:aria-label="item.targetUrl ? `${item.title},查看详情` : undefined"
|
||||
@click="openPromotion(item)"
|
||||
>
|
||||
<image
|
||||
v-if="item.coverFile?.accessUrl"
|
||||
class="promotion-card__cover"
|
||||
:src="item.coverFile.accessUrl"
|
||||
mode="aspectFill"
|
||||
/>
|
||||
<text class="promotion-card__title">{{ item.title }}</text>
|
||||
<text v-if="item.description" class="promotion-card__description">{{ item.description }}</text>
|
||||
<view v-if="item.targetUrl" class="promotion-card__actions">
|
||||
<AppButton compact type="secondary" label="复制链接" @click.stop="copyPromotionLink(item)" />
|
||||
<AppButton compact label="分享给家人" @click.stop="sharePromotion(item)" />
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from "vue";
|
||||
import { onLoad, onBackPress, onUnload } from "@dcloudio/uni-app";
|
||||
import AppButton from "@/components/AppButton.vue";
|
||||
import AppLoading from "@/components/AppLoading.vue";
|
||||
import ModulePageBackground from "@/components/ModulePageBackground.vue";
|
||||
import PageHeader from "@/components/PageHeader.vue";
|
||||
import {
|
||||
createRequestController,
|
||||
isRequestCancelled
|
||||
} from "@/services/api/request-controller.js";
|
||||
import { siteContentApi } from "@/services/api/site-content-service.js";
|
||||
import { getRequestErrorMessage } from "@/services/api/request-error-message.js";
|
||||
import { goBack, handleBackPress, openSiteContentTarget } from "@/utils/navigation/gateway.js";
|
||||
|
||||
const promotions = ref([]);
|
||||
const promotionListState = ref("loading");
|
||||
const promotionListError = ref("");
|
||||
const operationFeedback = ref("");
|
||||
const promotionListRequestController = createRequestController();
|
||||
let pageActive = true;
|
||||
|
||||
const loadPromotions = async () => {
|
||||
promotionListRequestController.abort();
|
||||
promotionListState.value = "loading";
|
||||
promotionListError.value = "";
|
||||
try {
|
||||
const promotionRows = await siteContentApi.getPromotions({
|
||||
requestController: promotionListRequestController,
|
||||
});
|
||||
if (!pageActive) return;
|
||||
promotions.value = promotionRows;
|
||||
promotionListState.value = "ready";
|
||||
} catch (error) {
|
||||
if (!pageActive || isRequestCancelled(error)) return;
|
||||
promotionListError.value = getRequestErrorMessage(error, "推广内容暂时无法加载,请稍后再试。");
|
||||
promotionListState.value = "error";
|
||||
}
|
||||
};
|
||||
|
||||
const openPromotion = async (item) => {
|
||||
const targetUrl = item?.targetUrl || "";
|
||||
if (!targetUrl) return;
|
||||
const showExternalLinkError = () => {
|
||||
operationFeedback.value = "链接暂时打不开,请稍后再试。";
|
||||
};
|
||||
try {
|
||||
await openSiteContentTarget(targetUrl, showExternalLinkError);
|
||||
} catch {
|
||||
operationFeedback.value = targetUrl.startsWith("/")
|
||||
? "这个页面暂时打不开,请稍后再试。"
|
||||
: "链接暂时打不开,请稍后再试。";
|
||||
}
|
||||
};
|
||||
const copyPromotionLink = (item) => {
|
||||
if (!item?.targetUrl || typeof uni?.setClipboardData !== "function") {
|
||||
operationFeedback.value = "当前设备暂时不能复制链接。";
|
||||
return;
|
||||
}
|
||||
uni.setClipboardData({
|
||||
data: item.targetUrl,
|
||||
success: () => { operationFeedback.value = "推广链接已复制,可以发给家人。"; },
|
||||
fail: () => { operationFeedback.value = "复制失败,请稍后再试。"; },
|
||||
});
|
||||
};
|
||||
const sharePromotion = (item) => {
|
||||
if (!item?.targetUrl) return;
|
||||
const content = [item.title, item.description, item.targetUrl].filter(Boolean).join("\n");
|
||||
// #ifdef APP-PLUS
|
||||
if (typeof plus?.share?.sendWithSystem === "function") {
|
||||
plus.share.sendWithSystem(
|
||||
{ type: "text", content },
|
||||
() => { operationFeedback.value = "已打开系统分享。"; },
|
||||
() => { operationFeedback.value = "已取消分享。"; },
|
||||
);
|
||||
return;
|
||||
}
|
||||
// #endif
|
||||
// #ifdef H5
|
||||
if (typeof navigator?.share === "function") {
|
||||
navigator.share({ title: item.title, text: item.description, url: item.targetUrl })
|
||||
.then(() => { operationFeedback.value = "分享已完成。"; })
|
||||
.catch(() => { operationFeedback.value = "已取消分享。"; });
|
||||
return;
|
||||
}
|
||||
// #endif
|
||||
copyPromotionLink(item);
|
||||
};
|
||||
|
||||
const requestBack = () => goBack();
|
||||
onLoad(loadPromotions);
|
||||
onBackPress((event) => handleBackPress(event, requestBack));
|
||||
onUnload(() => {
|
||||
pageActive = false;
|
||||
promotionListRequestController.abort();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@import "../../styles/adaptive-frame-profiles.scss";
|
||||
.promotion-page {
|
||||
display: flex;
|
||||
min-height: 100vh;
|
||||
flex-direction: column;
|
||||
background: $paper;
|
||||
}
|
||||
.page-layer {
|
||||
z-index: 1;
|
||||
}
|
||||
.page-content {
|
||||
flex: 1;
|
||||
padding: 28rpx 30rpx 72rpx;
|
||||
}
|
||||
.promotion-hero,
|
||||
.promotion-state-card,
|
||||
.promotion-list {
|
||||
@include adaptive-profile-content;
|
||||
}
|
||||
.promotion-hero {
|
||||
display: flex;
|
||||
min-height: 250rpx;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 38rpx 48rpx;
|
||||
text-align: center;
|
||||
}
|
||||
.promotion-hero image {
|
||||
width: 90rpx;
|
||||
height: 102rpx;
|
||||
}
|
||||
.promotion-hero text,
|
||||
.promotion-state-card text {
|
||||
display: block;
|
||||
}
|
||||
.promotion-hero text:nth-child(2),
|
||||
.promotion-state-card text:first-child {
|
||||
color: $ink;
|
||||
font-family: STKaiti, KaiTi, serif;
|
||||
font-size: clamp(19px, 34rpx, 24px);
|
||||
font-weight: 700;
|
||||
}
|
||||
.promotion-hero text:last-child,
|
||||
.promotion-state-card text:last-child {
|
||||
margin-top: 12rpx;
|
||||
color: $ink-muted;
|
||||
font-size: clamp(14px, 22rpx, 17px);
|
||||
line-height: 1.55;
|
||||
}
|
||||
.promotion-state-card {
|
||||
display: flex;
|
||||
min-height: 210rpx;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-top: 20rpx;
|
||||
padding: 38rpx;
|
||||
text-align: center;
|
||||
}
|
||||
.promotion-state-card .app-button { margin-top: 28rpx; }
|
||||
.promotion-list { display: grid; gap: 16rpx; }
|
||||
.promotion-card {
|
||||
overflow: hidden;
|
||||
padding: 26rpx 28rpx;
|
||||
border: 1rpx solid rgba(128, 89, 49, 0.26);
|
||||
border-radius: 12rpx;
|
||||
background: rgba(255, 252, 245, 0.78);
|
||||
}
|
||||
.promotion-card--linked { cursor: pointer; }
|
||||
.promotion-card__cover {
|
||||
display: block;
|
||||
width: calc(100% + 56rpx);
|
||||
height: 260rpx;
|
||||
margin: -26rpx -28rpx 22rpx;
|
||||
}
|
||||
.promotion-card text { display: block; }
|
||||
.promotion-card__title { color: $ink; font-size: clamp(16px, 28rpx, 20px); font-weight: 700; }
|
||||
.promotion-card__description { margin-top: 10rpx; color: $ink-muted; font-size: clamp(14px, 23rpx, 17px); line-height: 1.6; }
|
||||
.promotion-feedback { display: block; padding: 16rpx 20rpx; border: 1rpx solid rgba(66, 107, 88, .32); border-radius: 10rpx; background: rgba(66, 107, 88, .08); color: #426b58; font-size: clamp(14px, 22rpx, 17px); }
|
||||
.promotion-card__actions { display: flex; flex-wrap: wrap; justify-content: flex-end; margin-top: 16rpx; gap: 12rpx; }
|
||||
.promotion-card__actions .app-button { width: auto; min-width: 142rpx; }
|
||||
@media (max-width: 340px) {
|
||||
.page-content {
|
||||
padding-right: 22rpx;
|
||||
padding-left: 22rpx;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,4 +1,3 @@
|
||||
<!-- 页面编号:M-03;用途:展示当前账号可读取的安全概览,不执行安全写入。 -->
|
||||
<template>
|
||||
<view class="security-page">
|
||||
<ModulePageBackground module="profile" />
|
||||
@@ -9,12 +8,12 @@
|
||||
<view v-if="loading" class="state-card"
|
||||
><AppLoading text="正在读取账号安全资料"
|
||||
/></view>
|
||||
<view v-else-if="loadError" class="state-card">
|
||||
<text>账号安全资料暂时无法读取</text><text>{{ loadError }}</text>
|
||||
<view v-else-if="securityProfileError" class="state-card">
|
||||
<text>账号安全资料暂时无法读取</text><text>{{ securityProfileError }}</text>
|
||||
<AppButton block label="重新读取" @click="loadProfile" />
|
||||
</view>
|
||||
<view v-else class="security-card">
|
||||
<text class="security-card__title">当前账号安全概览</text>
|
||||
<text class="security-card__title">账号安全概览</text>
|
||||
<view class="security-field"
|
||||
><text>绑定手机号</text><text>{{ maskedPhone }}</text></view
|
||||
>
|
||||
@@ -23,7 +22,7 @@
|
||||
><text>{{ profile.userNo || "未提供" }}</text></view
|
||||
>
|
||||
<text class="security-note"
|
||||
>当前仅展示可确认的账户信息,其他安全记录暂不可查看。</text
|
||||
>这里显示可查看的账号信息,其他安全信息暂不支持查看。</text
|
||||
>
|
||||
<AppButton block label="修改密码" @click="openPassword" />
|
||||
<AppButton
|
||||
@@ -45,16 +44,17 @@ 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 { openPage, returnTo } from "@/utils/navigation.js";
|
||||
isRequestCancelled
|
||||
} from "@/services/api/request-controller.js";
|
||||
import { profileApi } from "@/services/api/profile-service.js";
|
||||
import { getRequestErrorMessage } from "@/services/api/request-error-message.js";
|
||||
import { openPage, returnTo } from "@/utils/navigation/gateway.js";
|
||||
|
||||
const profile = reactive({ phone: "", userNo: "" });
|
||||
const loading = ref(false);
|
||||
const loadError = ref("");
|
||||
const controller = createRequestController();
|
||||
const securityProfileError = ref("");
|
||||
const securityProfileRequestController = createRequestController();
|
||||
let pageActive = true;
|
||||
const maskedPhone = computed(() =>
|
||||
/^\d{7,}$/.test(profile.phone)
|
||||
@@ -65,13 +65,15 @@ const maskedPhone = computed(() =>
|
||||
const loadProfile = async () => {
|
||||
if (loading.value) return;
|
||||
loading.value = true;
|
||||
loadError.value = "";
|
||||
securityProfileError.value = "";
|
||||
try {
|
||||
const data = await appApi.getProfile({ requestController: controller });
|
||||
if (pageActive) Object.assign(profile, data);
|
||||
const profileResponse = await profileApi.getProfile({
|
||||
requestController: securityProfileRequestController,
|
||||
});
|
||||
if (pageActive) Object.assign(profile, profileResponse);
|
||||
} catch (error) {
|
||||
if (pageActive && !isRequestCancelled(error))
|
||||
loadError.value = error?.message || "请稍后重试";
|
||||
securityProfileError.value = getRequestErrorMessage(error, "请稍后重试");
|
||||
} finally {
|
||||
if (pageActive) loading.value = false;
|
||||
}
|
||||
@@ -82,7 +84,7 @@ const openPhone = () => openPage("M05", {}, "M03");
|
||||
onShow(loadProfile);
|
||||
onUnload(() => {
|
||||
pageActive = false;
|
||||
controller.abort();
|
||||
securityProfileRequestController.abort();
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -106,7 +108,7 @@ onUnload(() => {
|
||||
box-sizing: border-box;
|
||||
min-height: 340rpx;
|
||||
padding: 54rpx 42rpx 44rpx;
|
||||
@include adaptive-family-content;
|
||||
@include adaptive-profile-content;
|
||||
}
|
||||
.state-card {
|
||||
text-align: center;
|
||||
@@ -0,0 +1,615 @@
|
||||
<template>
|
||||
<view
|
||||
class="about-page"
|
||||
:class="{
|
||||
'about-state--logging-out': logoutSubmitting,
|
||||
'about-state--deactivating': deactivationBusy,
|
||||
}"
|
||||
>
|
||||
<ModulePageBackground module="profile" />
|
||||
<view class="page-layer"
|
||||
><PageHeader title="关于家谱" custom-back @back="requestBack"
|
||||
/></view>
|
||||
<view class="page-content page-layer">
|
||||
<view class="brand-card"
|
||||
><image
|
||||
src="/static/assets/foundation/transparent/brand-seal.png"
|
||||
mode="aspectFit"
|
||||
/><text>家谱</text><text>传承每一段值得珍藏的家族记忆</text
|
||||
><text>版本 {{ appVersion }}</text></view
|
||||
>
|
||||
<view class="settings-list">
|
||||
<view
|
||||
v-for="settingsItem in agreementItems"
|
||||
:key="settingsItem.key"
|
||||
class="settings-row"
|
||||
role="button"
|
||||
:aria-label="settingsItem.label"
|
||||
@click="openAgreement(settingsItem)"
|
||||
><view
|
||||
><text>{{ settingsItem.label }}</text
|
||||
><text>{{ settingsItem.note }}</text></view
|
||||
><image
|
||||
src="/static/assets/foundation/transparent/chevron-right.png"
|
||||
mode="aspectFit"
|
||||
/></view>
|
||||
</view>
|
||||
<AppButton
|
||||
block
|
||||
type="secondary"
|
||||
label="退出登录"
|
||||
@click="logoutVisible = true"
|
||||
/>
|
||||
<view class="deactivate-entry">
|
||||
<text>账号注销</text>
|
||||
<text>注销后无法恢复,家谱资料会按平台规则处理。</text>
|
||||
<button class="deactivate-link" @click="deactivationWarningVisible = true"
|
||||
>申请注销账号</button
|
||||
>
|
||||
</view>
|
||||
<view v-if="deactivationFormVisible" class="deactivate-panel">
|
||||
<view class="deactivate-panel__heading">
|
||||
<text>短信确认</text>
|
||||
<text>验证码将发送至 {{ maskedDeactivationPhone }},完成验证后仍需再次确认注销。</text>
|
||||
</view>
|
||||
<view class="form-row form-row--code">
|
||||
<text>短信验证码</text>
|
||||
<input
|
||||
v-model.trim="deactivationSmsCode"
|
||||
type="number"
|
||||
maxlength="4"
|
||||
aria-label="账号注销短信验证码"
|
||||
placeholder="4 位验证码"
|
||||
@input="deactivationFormError = ''"
|
||||
/>
|
||||
<button
|
||||
class="code-action"
|
||||
:disabled="deactivationBusy || cooldownSeconds > 0"
|
||||
hover-class="code-action--pressed"
|
||||
@click="requestDeactivationSms"
|
||||
>{{ cooldownSeconds > 0 ? `${cooldownSeconds}s 后重试` : '获取验证码' }}</button>
|
||||
</view>
|
||||
<text v-if="deactivationFormError" class="field-error">{{ deactivationFormError }}</text>
|
||||
<AppButton
|
||||
block
|
||||
type="secondary"
|
||||
:disabled="deactivationBusy"
|
||||
:label="deactivationPhase === 'submitting' ? '正在注销' : '继续注销'"
|
||||
@click="openFinalDeactivationConfirmation"
|
||||
/>
|
||||
</view>
|
||||
</view>
|
||||
<AppDialog
|
||||
:visible="noticeVisible"
|
||||
:close-on-mask="false"
|
||||
eyebrow="版本说明"
|
||||
:title="activeNotice.label"
|
||||
confirm-text="关闭"
|
||||
@confirm="noticeVisible = false"
|
||||
@close="noticeVisible = false"
|
||||
>
|
||||
<text class="notice-copy">{{ activeNotice.copy }}</text>
|
||||
</AppDialog>
|
||||
<AppDialog
|
||||
:visible="logoutVisible"
|
||||
eyebrow="账号操作"
|
||||
title="确认退出登录?"
|
||||
message="退出后需要重新验证账号;本机保存的密码不会被保留。"
|
||||
:confirm-text="logoutSubmitting ? '正在退出' : '确认退出'"
|
||||
cancel-text="取消"
|
||||
show-cancel
|
||||
:close-on-mask="false"
|
||||
@confirm="confirmLogout"
|
||||
@cancel="logoutVisible = false"
|
||||
@close="logoutVisible = false"
|
||||
/>
|
||||
<AppDialog
|
||||
:visible="deactivationWarningVisible"
|
||||
eyebrow="高风险操作"
|
||||
title="确认申请注销账号?"
|
||||
message="账号注销不可恢复。请先完成当前绑定手机号的短信验证;验证通过后,仍需再次确认才会提交。"
|
||||
confirm-text="继续验证"
|
||||
cancel-text="取消"
|
||||
show-cancel
|
||||
:close-on-mask="false"
|
||||
@confirm="openDeactivationVerification"
|
||||
@cancel="deactivationWarningVisible = false"
|
||||
@close="deactivationWarningVisible = false"
|
||||
/>
|
||||
<AppDialog
|
||||
:visible="finalDeactivationConfirmationVisible"
|
||||
eyebrow="最后确认"
|
||||
title="确定永久注销账号?"
|
||||
message="确认后会注销你的账号,本操作无法撤销。"
|
||||
:confirm-text="deactivationPhase === 'submitting' ? '正在提交' : '确认注销'"
|
||||
cancel-text="取消"
|
||||
show-cancel
|
||||
:close-on-mask="false"
|
||||
@confirm="submitAccountDeactivation"
|
||||
@cancel="finalDeactivationConfirmationVisible = false"
|
||||
@close="finalDeactivationConfirmationVisible = false"
|
||||
/>
|
||||
<TacVerification
|
||||
:visible="tacVisible"
|
||||
:context="tacContext"
|
||||
@success="completeDeactivationTac"
|
||||
@failure="handleTacFailure"
|
||||
@error="handleTacError"
|
||||
@cancel="closeTac"
|
||||
/>
|
||||
<AppToast :visible="toastVisible" :message="toastMessage" />
|
||||
</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 TacVerification from "@/components/auth/TacVerification.vue";
|
||||
import { useSmsVerification } from "@/composables/auth/use-sms-verification.js";
|
||||
import manifest from "@/manifest.json";
|
||||
import {
|
||||
createRequestController,
|
||||
isRequestCancelled
|
||||
} from "@/services/api/request-controller.js";
|
||||
import { authApi } from "@/services/api/auth-service.js";
|
||||
import { profileApi } from "@/services/api/profile-service.js";
|
||||
import { getRequestErrorMessage } from "@/services/api/request-error-message.js";
|
||||
import {
|
||||
AUTH_VERIFICATION_OPERATION,
|
||||
isAuthPhone,
|
||||
} from "@/utils/auth/verification.js";
|
||||
import { createNonIdempotentWriteGuard } from "@/utils/non-idempotent-write-guard.js";
|
||||
import { goRoot, handleBackPress, openPage, runBackGuard } from "@/utils/navigation/gateway.js";
|
||||
import { session } from "@/utils/session.js";
|
||||
|
||||
const appVersion = manifest.versionName;
|
||||
const agreementItems = [
|
||||
{
|
||||
key: "user_agreement",
|
||||
label: "用户协议",
|
||||
note: "了解账号与服务使用规则",
|
||||
},
|
||||
{
|
||||
key: "privacy_policy",
|
||||
label: "隐私政策",
|
||||
note: "了解个人资料如何使用与保护",
|
||||
},
|
||||
{
|
||||
key: "version",
|
||||
label: "版本说明",
|
||||
note: `当前版本 ${appVersion}`,
|
||||
copy: `当前安装版本为 ${appVersion}。`,
|
||||
},
|
||||
];
|
||||
const activeNotice = reactive({ label: "", copy: "" });
|
||||
const noticeVisible = ref(false);
|
||||
const logoutVisible = ref(false);
|
||||
const logoutSubmitting = ref(false);
|
||||
const deactivationWarningVisible = ref(false);
|
||||
const deactivationFormVisible = ref(false);
|
||||
const finalDeactivationConfirmationVisible = ref(false);
|
||||
const deactivationPhase = ref("ready");
|
||||
const deactivationPhone = ref("");
|
||||
const deactivationSmsCode = ref("");
|
||||
const deactivationFormError = ref("");
|
||||
const toastVisible = ref(false);
|
||||
const toastMessage = ref("");
|
||||
const logoutRequestController = createRequestController();
|
||||
// 账号资料读取与最终注销严格串行,共享控制器可准确取消当前阶段;短信验证
|
||||
// 由独立模块拥有,不再与注销提交共用取消槽。
|
||||
const deactivationRequestController = createRequestController();
|
||||
const accountDeactivationGuard = createNonIdempotentWriteGuard();
|
||||
const maskedDeactivationPhone = computed(() =>
|
||||
isAuthPhone(deactivationPhone.value)
|
||||
? `${deactivationPhone.value.slice(0, 3)}****${deactivationPhone.value.slice(-4)}`
|
||||
: "当前绑定手机号",
|
||||
);
|
||||
let toastTimer = null;
|
||||
let pageActive = true;
|
||||
|
||||
const openAgreement = (settingsItem) => {
|
||||
if (settingsItem.key === "version") {
|
||||
activeNotice.label = settingsItem.label;
|
||||
activeNotice.copy = settingsItem.copy;
|
||||
noticeVisible.value = true;
|
||||
return;
|
||||
}
|
||||
return openPage("M13", { documentKey: settingsItem.key }, "M10");
|
||||
};
|
||||
const showToast = (message) => {
|
||||
toastMessage.value = message;
|
||||
toastVisible.value = true;
|
||||
clearTimeout(toastTimer);
|
||||
toastTimer = setTimeout(() => {
|
||||
toastVisible.value = false;
|
||||
}, 2200);
|
||||
};
|
||||
const smsVerification = useSmsVerification({
|
||||
operationCode: AUTH_VERIFICATION_OPERATION.ACCOUNT_DEACTIVATE,
|
||||
requestIdPrefix: "account-deactivation",
|
||||
phone: deactivationPhone,
|
||||
isActive: () => pageActive,
|
||||
showFeedback: showToast,
|
||||
getErrorMessage: (error, fallback) =>
|
||||
error?.code
|
||||
? getRequestErrorMessage(error, fallback)
|
||||
: error?.message || fallback,
|
||||
});
|
||||
const {
|
||||
tacVisible,
|
||||
tacContext,
|
||||
sendingCode: sendingDeactivationCode,
|
||||
cooldownSeconds,
|
||||
sentPhone: smsSentToPhone,
|
||||
closeTac,
|
||||
completeTac: completeDeactivationTac,
|
||||
handleTacFailure,
|
||||
handleTacError,
|
||||
} = smsVerification;
|
||||
const deactivationBusy = computed(
|
||||
() => deactivationPhase.value !== "ready" || sendingDeactivationCode.value,
|
||||
);
|
||||
const deactivationErrorMessage = (error, fallback) =>
|
||||
error?.code
|
||||
? getRequestErrorMessage(error, fallback)
|
||||
: error?.message || fallback;
|
||||
const openDeactivationVerification = async () => {
|
||||
if (deactivationPhase.value !== "ready") return;
|
||||
deactivationPhase.value = "loading";
|
||||
try {
|
||||
const profile = await profileApi.getProfile({
|
||||
requestController: deactivationRequestController,
|
||||
});
|
||||
if (!isAuthPhone(profile.phone)) {
|
||||
throw new Error("你的账号还没有绑定可用手机号,暂不能注销");
|
||||
}
|
||||
if (!pageActive) return;
|
||||
deactivationPhone.value = profile.phone;
|
||||
deactivationWarningVisible.value = false;
|
||||
deactivationFormVisible.value = true;
|
||||
} catch (error) {
|
||||
if (pageActive && !isRequestCancelled(error)) {
|
||||
showToast(deactivationErrorMessage(error, "账号资料暂时无法读取"));
|
||||
}
|
||||
} finally {
|
||||
if (pageActive) deactivationPhase.value = "ready";
|
||||
}
|
||||
};
|
||||
const requestDeactivationSms = async () => {
|
||||
if (deactivationBusy.value || cooldownSeconds.value > 0) return;
|
||||
if (!isAuthPhone(deactivationPhone.value)) {
|
||||
deactivationFormError.value = "你的账号还没有绑定可用手机号";
|
||||
return;
|
||||
}
|
||||
deactivationFormError.value = "";
|
||||
return smsVerification.requestCode();
|
||||
};
|
||||
const openFinalDeactivationConfirmation = () => {
|
||||
deactivationFormError.value =
|
||||
smsSentToPhone.value !== deactivationPhone.value
|
||||
? "请先获取当前手机号的验证码"
|
||||
: /^\d{4}$/.test(deactivationSmsCode.value)
|
||||
? ""
|
||||
: "请输入 4 位验证码";
|
||||
if (deactivationFormError.value) return;
|
||||
finalDeactivationConfirmationVisible.value = true;
|
||||
};
|
||||
const submitAccountDeactivation = async () => {
|
||||
if (deactivationBusy.value) return;
|
||||
const deactivationPayload = { smsCode: deactivationSmsCode.value };
|
||||
const deactivationAttempt = accountDeactivationGuard.begin(deactivationPayload);
|
||||
if (deactivationAttempt === null) {
|
||||
showToast(
|
||||
"上次注销结果暂时无法确认,请重新登录确认账号状态,不要重复提交。",
|
||||
);
|
||||
return;
|
||||
}
|
||||
deactivationPhase.value = "submitting";
|
||||
try {
|
||||
await authApi.deactivateAccount(
|
||||
deactivationPayload,
|
||||
{ requestController: deactivationRequestController },
|
||||
);
|
||||
if (!pageActive) return;
|
||||
// 只有服务端明确确认注销成功后才能清除本机会话;请求结果未知时
|
||||
// 保留登录态,用户仍可重试或联系支持,不能误导为账号已经注销。
|
||||
session.clear();
|
||||
finalDeactivationConfirmationVisible.value = false;
|
||||
deactivationFormVisible.value = false;
|
||||
goRoot("A01");
|
||||
} catch (error) {
|
||||
if (!pageActive) return;
|
||||
if (accountDeactivationGuard.recordFailure(deactivationAttempt, error)) {
|
||||
showToast(
|
||||
"注销结果暂时无法确认,请重新登录确认账号状态,不要重复提交。",
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (!isRequestCancelled(error))
|
||||
showToast(deactivationErrorMessage(error, "注销未完成,请稍后重试"));
|
||||
} finally {
|
||||
if (pageActive) deactivationPhase.value = "ready";
|
||||
}
|
||||
};
|
||||
const confirmLogout = async () => {
|
||||
if (logoutSubmitting.value) return false;
|
||||
logoutSubmitting.value = true;
|
||||
try {
|
||||
await authApi.logout({ requestController: logoutRequestController });
|
||||
} catch {
|
||||
// 退出请求的结果未知时仍必须撤销本机会话,不能保留旧授权或自动重试。
|
||||
} finally {
|
||||
session.clear();
|
||||
logoutVisible.value = false;
|
||||
logoutSubmitting.value = false;
|
||||
}
|
||||
return goRoot("A01");
|
||||
};
|
||||
const closeActiveDialog = () => {
|
||||
if (tacVisible.value) closeTac();
|
||||
else if (finalDeactivationConfirmationVisible.value) {
|
||||
finalDeactivationConfirmationVisible.value = false;
|
||||
} else if (deactivationWarningVisible.value) {
|
||||
deactivationWarningVisible.value = false;
|
||||
}
|
||||
else if (logoutVisible.value) logoutVisible.value = false;
|
||||
else if (noticeVisible.value) noticeVisible.value = false;
|
||||
else deactivationFormVisible.value = false;
|
||||
};
|
||||
const requestBack = () =>
|
||||
runBackGuard({
|
||||
transientOpen:
|
||||
noticeVisible.value ||
|
||||
logoutVisible.value ||
|
||||
deactivationWarningVisible.value ||
|
||||
finalDeactivationConfirmationVisible.value ||
|
||||
deactivationFormVisible.value ||
|
||||
tacVisible.value,
|
||||
submitting:
|
||||
logoutSubmitting.value || deactivationBusy.value,
|
||||
"close-transient": closeActiveDialog,
|
||||
"block-submitting": () => true,
|
||||
});
|
||||
onBackPress((event) => handleBackPress(event, requestBack));
|
||||
onShow(() => smsVerification.syncCooldown());
|
||||
onUnload(() => {
|
||||
pageActive = false;
|
||||
logoutRequestController.abort();
|
||||
deactivationRequestController.abort();
|
||||
smsVerification.dispose();
|
||||
clearTimeout(toastTimer);
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@import "../../styles/adaptive-frame-profiles.scss";
|
||||
.about-page {
|
||||
display: flex;
|
||||
min-height: 100vh;
|
||||
flex-direction: column;
|
||||
background: $paper;
|
||||
}
|
||||
.page-layer {
|
||||
z-index: 1;
|
||||
}
|
||||
.page-content {
|
||||
flex: 1;
|
||||
padding: 28rpx 30rpx 72rpx;
|
||||
}
|
||||
.brand-card {
|
||||
@include adaptive-profile-summary;
|
||||
display: flex;
|
||||
min-height: 300rpx;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 36rpx 44rpx;
|
||||
text-align: center;
|
||||
}
|
||||
.brand-card image {
|
||||
width: 92rpx;
|
||||
height: 106rpx;
|
||||
}
|
||||
.brand-card text {
|
||||
display: block;
|
||||
}
|
||||
.brand-card text:nth-child(2) {
|
||||
margin-top: 6rpx;
|
||||
color: $ink;
|
||||
font-family: STKaiti, KaiTi, serif;
|
||||
font-size: clamp(20px, 40rpx, 26px);
|
||||
font-weight: 700;
|
||||
letter-spacing: 4rpx;
|
||||
}
|
||||
.brand-card text:nth-child(3) {
|
||||
margin-top: 8rpx;
|
||||
color: $ink-muted;
|
||||
font-size: clamp(14px, 22rpx, 17px);
|
||||
line-height: 1.5;
|
||||
}
|
||||
.brand-card text:last-child {
|
||||
margin-top: 10rpx;
|
||||
color: $ink-muted;
|
||||
font-size: clamp(13px, 20rpx, 16px);
|
||||
}
|
||||
.settings-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12rpx;
|
||||
margin-top: 22rpx;
|
||||
}
|
||||
.settings-row {
|
||||
@include adaptive-profile-field;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 32rpx;
|
||||
min-height: 104rpx;
|
||||
align-items: center;
|
||||
gap: 18rpx;
|
||||
padding: 18rpx 30rpx;
|
||||
}
|
||||
.settings-row view {
|
||||
min-width: 0;
|
||||
}
|
||||
.settings-row text {
|
||||
display: block;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.settings-row text:first-child {
|
||||
color: $ink;
|
||||
font-size: clamp(15px, 24rpx, 18px);
|
||||
font-weight: 700;
|
||||
}
|
||||
.settings-row text:last-child {
|
||||
margin-top: 6rpx;
|
||||
color: $ink-muted;
|
||||
font-size: clamp(13px, 20rpx, 16px);
|
||||
}
|
||||
.settings-row image {
|
||||
width: 30rpx;
|
||||
height: 30rpx;
|
||||
}
|
||||
.page-content > .app-button {
|
||||
margin-top: 28rpx;
|
||||
}
|
||||
.notice-copy {
|
||||
display: block;
|
||||
width: 100%;
|
||||
margin-top: 22rpx;
|
||||
color: #513a28;
|
||||
font-size: clamp(15px, 25rpx, 18px);
|
||||
line-height: 1.75;
|
||||
text-align: left;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
.deactivate-entry,
|
||||
.deactivate-panel {
|
||||
@include adaptive-profile-content;
|
||||
margin-top: 28rpx;
|
||||
}
|
||||
.deactivate-entry {
|
||||
padding: 26rpx 34rpx;
|
||||
}
|
||||
.deactivate-entry text,
|
||||
.deactivate-panel__heading text {
|
||||
display: block;
|
||||
}
|
||||
.deactivate-entry text:first-child,
|
||||
.deactivate-panel__heading text:first-child {
|
||||
color: #9f170f;
|
||||
font-size: clamp(15px, 25rpx, 18px);
|
||||
font-weight: 700;
|
||||
}
|
||||
.deactivate-entry text:nth-child(2),
|
||||
.deactivate-panel__heading text:last-child {
|
||||
margin-top: 8rpx;
|
||||
color: $ink-muted;
|
||||
font-size: clamp(13px, 20rpx, 16px);
|
||||
line-height: 1.55;
|
||||
}
|
||||
.deactivate-link {
|
||||
width: auto;
|
||||
min-height: 54rpx;
|
||||
margin: 16rpx 0 0;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: #9f170f;
|
||||
font-size: clamp(13px, 21rpx, 16px);
|
||||
font-weight: 700;
|
||||
line-height: 1.3;
|
||||
text-align: left;
|
||||
}
|
||||
.deactivate-link::after {
|
||||
border: 0;
|
||||
}
|
||||
.deactivate-panel {
|
||||
padding: 26rpx 34rpx 30rpx;
|
||||
}
|
||||
.form-row {
|
||||
display: grid;
|
||||
min-height: 92rpx;
|
||||
align-items: center;
|
||||
gap: 12rpx;
|
||||
border-bottom: 1px solid rgba(181, 137, 63, 0.42);
|
||||
}
|
||||
.form-row--code {
|
||||
grid-template-columns: 170rpx minmax(0, 1fr) 198rpx;
|
||||
margin-top: 16rpx;
|
||||
}
|
||||
.form-row > text {
|
||||
color: $ink;
|
||||
font-size: clamp(14px, 23rpx, 17px);
|
||||
font-weight: 700;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.form-row input {
|
||||
width: auto;
|
||||
min-width: 0;
|
||||
min-height: 68rpx;
|
||||
color: $ink;
|
||||
font-size: clamp(14px, 23rpx, 17px);
|
||||
}
|
||||
.code-action {
|
||||
justify-self: end;
|
||||
width: 198rpx;
|
||||
min-height: 68rpx;
|
||||
margin: 0;
|
||||
padding: 0 14rpx;
|
||||
box-sizing: border-box;
|
||||
border: 1rpx solid rgba(159, 23, 15, 0.64);
|
||||
border-radius: 10rpx;
|
||||
background: rgba(255, 250, 238, 0.96);
|
||||
box-shadow: inset 0 0 0 3rpx rgba(213, 176, 104, 0.18);
|
||||
color: $brand-red;
|
||||
font-size: clamp(13px, 20rpx, 15px);
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.code-action::after,
|
||||
.deactivate-panel .app-button::after {
|
||||
border: 0;
|
||||
}
|
||||
.code-action--pressed {
|
||||
background: rgba(248, 232, 201, 0.96);
|
||||
}
|
||||
.code-action[disabled] {
|
||||
border-color: rgba(128, 89, 49, 0.28);
|
||||
color: #9d8b76;
|
||||
opacity: 1;
|
||||
}
|
||||
.field-error {
|
||||
display: block;
|
||||
padding-top: 7rpx;
|
||||
color: #b42318;
|
||||
font-size: clamp(13px, 20rpx, 16px);
|
||||
line-height: 1.4;
|
||||
text-align: right;
|
||||
}
|
||||
.deactivate-panel .app-button {
|
||||
margin-top: 24rpx;
|
||||
}
|
||||
@media (max-width: 340px) {
|
||||
.page-content {
|
||||
padding-right: 22rpx;
|
||||
padding-left: 22rpx;
|
||||
}
|
||||
.deactivate-entry,
|
||||
.deactivate-panel {
|
||||
padding-right: 26rpx;
|
||||
padding-left: 26rpx;
|
||||
}
|
||||
.form-row--code {
|
||||
grid-template-columns: 170rpx minmax(0, 1fr) 190rpx;
|
||||
gap: 8rpx;
|
||||
}
|
||||
.code-action {
|
||||
width: 190rpx;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,358 @@
|
||||
<template>
|
||||
<view class="orders-page">
|
||||
<ModulePageBackground module="profile" />
|
||||
<view class="page-layer"
|
||||
><PageHeader
|
||||
class="orders-header"
|
||||
title="VIP 与订单"
|
||||
custom-back
|
||||
@back="requestBack"
|
||||
/></view>
|
||||
<view class="page-content page-layer">
|
||||
<view class="vip-intro">
|
||||
<image
|
||||
class="vip-intro__background"
|
||||
src="/static/assets/modules/profile/opaque/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">会员服务</text>
|
||||
<text class="vip-intro__copy">{{ purchaseSummary }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<view v-if="readState === 'loading'" class="state-card">
|
||||
<AppLoading text="正在读取 VIP 服务信息" />
|
||||
</view>
|
||||
<view v-else-if="readState === 'error'" class="state-card">
|
||||
<text>暂时无法读取 VIP 服务信息</text>
|
||||
<text>{{ vipReadError || "请检查网络后重新加载。" }}</text>
|
||||
<AppButton block type="secondary" label="重新加载" @click="loadVipData" />
|
||||
</view>
|
||||
<template v-else>
|
||||
<view class="vip-section">
|
||||
<view class="vip-section__heading">
|
||||
<text>可选套餐</text>
|
||||
<text>{{ capability.enabled ? "购买资格已开放" : "当前仅供查看" }}</text>
|
||||
</view>
|
||||
<view v-if="packages.length" class="vip-package-list">
|
||||
<view v-for="item in packages" :key="item.key" class="vip-package">
|
||||
<view class="vip-package__copy">
|
||||
<text>{{ item.name }}</text>
|
||||
<text v-if="item.description">{{ item.description }}</text>
|
||||
</view>
|
||||
<view class="vip-package__price">
|
||||
<text>¥{{ item.price }}</text>
|
||||
<text v-if="item.originalPrice">原价 ¥{{ item.originalPrice }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<view v-else class="vip-empty-state"><text>当前没有可展示的套餐</text></view>
|
||||
</view>
|
||||
|
||||
<view class="vip-section">
|
||||
<view class="vip-section__heading">
|
||||
<text>订单记录</text>
|
||||
<text>仅供查看</text>
|
||||
</view>
|
||||
<view v-if="orders.length" class="vip-order-list">
|
||||
<view v-for="item in orders" :key="item.key" class="vip-order">
|
||||
<view>
|
||||
<text>{{ item.packageName }}</text>
|
||||
<text v-if="item.paidAt || item.expiresAt">{{ item.paidAt || item.expiresAt }}</text>
|
||||
</view>
|
||||
<view>
|
||||
<text>¥{{ item.amount }}</text>
|
||||
<text>状态:{{ item.statusLabel }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<view v-else class="vip-empty-state"><text>当前没有订单记录</text></view>
|
||||
</view>
|
||||
</template>
|
||||
<AppButton block label="查看购买说明" @click="openServiceNotice" />
|
||||
</view>
|
||||
<AppDialog
|
||||
:visible="serviceNoticeVisible"
|
||||
:title="capability.enabled ? '购买功能说明' : '当前不能在线购买'"
|
||||
confirm-text="我知道了"
|
||||
:close-on-mask="false"
|
||||
@confirm="closeServiceNotice"
|
||||
>
|
||||
<text class="notice-copy">{{ serviceNoticeCopy }}</text>
|
||||
</AppDialog>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, reactive, ref } from "vue";
|
||||
import { onBackPress, onLoad, 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";
|
||||
import {
|
||||
createRequestController,
|
||||
isRequestCancelled
|
||||
} from "@/services/api/request-controller.js";
|
||||
import { vipApi } from "@/services/api/vip-service.js";
|
||||
import { getRequestErrorMessage } from "@/services/api/request-error-message.js";
|
||||
import { handleBackPress, runBackGuard } from "@/utils/navigation/gateway.js";
|
||||
|
||||
const packages = ref([]);
|
||||
const orders = ref([]);
|
||||
const capability = reactive({ enabled: false, disabledReason: "" });
|
||||
const readState = ref("loading");
|
||||
const vipReadError = ref("");
|
||||
const serviceNoticeVisible = ref(false);
|
||||
const packageController = createRequestController();
|
||||
const orderController = createRequestController();
|
||||
const capabilityController = createRequestController();
|
||||
let active = true;
|
||||
const purchaseSummary = computed(() =>
|
||||
capability.enabled
|
||||
? "当前可以购买会员,支付功能正在完成最后确认。"
|
||||
: capability.disabledReason || "当前可查看套餐和订单,暂时不能在线购买。",
|
||||
);
|
||||
const serviceNoticeCopy = computed(() =>
|
||||
capability.enabled
|
||||
? "你的账号目前可以购买会员。为避免重复扣款,支付按钮将在确认完成后开放;现在可以先查看套餐和订单。"
|
||||
: capability.disabledReason || "当前可查看套餐和已有订单,暂时不能在线购买。",
|
||||
);
|
||||
|
||||
const loadVipData = async () => {
|
||||
capabilityController.abort();
|
||||
packageController.abort();
|
||||
orderController.abort();
|
||||
readState.value = "loading";
|
||||
vipReadError.value = "";
|
||||
try {
|
||||
const [capabilityResult, packageRows, orderRows] = await Promise.all([
|
||||
vipApi.getVipCapability({ requestController: capabilityController }),
|
||||
vipApi.getVipPackages({ requestController: packageController }),
|
||||
vipApi.getVipOrders({ requestController: orderController }),
|
||||
]);
|
||||
if (!active) return;
|
||||
capability.enabled = capabilityResult.enabled;
|
||||
capability.disabledReason = capabilityResult.disabledReason;
|
||||
packages.value = packageRows;
|
||||
orders.value = orderRows;
|
||||
readState.value = "ready";
|
||||
} catch (error) {
|
||||
if (!active || isRequestCancelled(error)) return;
|
||||
vipReadError.value = getRequestErrorMessage(error, "请稍后重试。");
|
||||
readState.value = "error";
|
||||
}
|
||||
};
|
||||
const openServiceNotice = () => {
|
||||
serviceNoticeVisible.value = true;
|
||||
};
|
||||
const closeServiceNotice = () => {
|
||||
serviceNoticeVisible.value = false;
|
||||
};
|
||||
const requestBack = () =>
|
||||
runBackGuard({
|
||||
transientOpen: serviceNoticeVisible.value,
|
||||
"close-transient": closeServiceNotice,
|
||||
});
|
||||
|
||||
onBackPress((event) => handleBackPress(event, requestBack));
|
||||
onLoad(loadVipData);
|
||||
onUnload(() => {
|
||||
active = false;
|
||||
capabilityController.abort();
|
||||
packageController.abort();
|
||||
orderController.abort();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@import "../../styles/adaptive-frame-profiles.scss";
|
||||
.orders-page {
|
||||
display: flex;
|
||||
min-height: 100vh;
|
||||
flex-direction: column;
|
||||
background: #f7f2e9;
|
||||
}
|
||||
.page-layer {
|
||||
z-index: 1;
|
||||
}
|
||||
.page-content {
|
||||
flex: 1;
|
||||
padding: 20rpx 30rpx 72rpx;
|
||||
}
|
||||
.vip-intro {
|
||||
position: relative;
|
||||
min-height: 284rpx;
|
||||
overflow: hidden;
|
||||
margin: -20rpx -30rpx 0;
|
||||
padding: 36rpx 48rpx 30rpx;
|
||||
text-align: center;
|
||||
}
|
||||
.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,
|
||||
.state-card text,
|
||||
.vip-package text,
|
||||
.vip-order text,
|
||||
.vip-empty-state text,
|
||||
.notice-copy {
|
||||
display: block;
|
||||
}
|
||||
.vip-intro__eyebrow {
|
||||
color: $brand-red;
|
||||
font-size: clamp(13px, 20rpx, 16px);
|
||||
font-weight: 700;
|
||||
letter-spacing: 5rpx;
|
||||
}
|
||||
.vip-intro__title {
|
||||
margin-top: 16rpx;
|
||||
color: #33261d;
|
||||
font-family: STKaiti, KaiTi, serif;
|
||||
font-size: clamp(20px, 38rpx, 26px);
|
||||
font-weight: 700;
|
||||
letter-spacing: 1rpx;
|
||||
}
|
||||
.vip-intro__copy {
|
||||
margin-top: 14rpx;
|
||||
color: $ink-muted;
|
||||
font-size: clamp(14px, 23rpx, 17px);
|
||||
line-height: 1.55;
|
||||
}
|
||||
.state-card,
|
||||
.vip-section {
|
||||
@include adaptive-profile-content;
|
||||
}
|
||||
.state-card {
|
||||
display: flex;
|
||||
min-height: 184rpx;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-top: 20rpx;
|
||||
padding: 34rpx 42rpx;
|
||||
text-align: center;
|
||||
}
|
||||
.state-card text:first-child {
|
||||
color: $ink;
|
||||
font-family: STKaiti, KaiTi, serif;
|
||||
font-size: clamp(19px, 34rpx, 24px);
|
||||
font-weight: 700;
|
||||
}
|
||||
.state-card text:last-child,
|
||||
.notice-copy {
|
||||
margin-top: 16rpx;
|
||||
color: $ink-muted;
|
||||
font-size: clamp(14px, 22rpx, 17px);
|
||||
line-height: 1.6;
|
||||
}
|
||||
.vip-section {
|
||||
margin-top: 20rpx;
|
||||
padding: 24rpx;
|
||||
}
|
||||
.vip-section__heading {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 16rpx;
|
||||
}
|
||||
.vip-section__heading text:first-child {
|
||||
color: $ink;
|
||||
font-family: STKaiti, KaiTi, serif;
|
||||
font-size: clamp(18px, 30rpx, 22px);
|
||||
font-weight: 700;
|
||||
}
|
||||
.vip-section__heading text:last-child {
|
||||
color: $ink-muted;
|
||||
font-size: clamp(12px, 20rpx, 15px);
|
||||
}
|
||||
.vip-package-list,
|
||||
.vip-order-list {
|
||||
display: grid;
|
||||
gap: 14rpx;
|
||||
margin-top: 18rpx;
|
||||
}
|
||||
.vip-package,
|
||||
.vip-order {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 20rpx;
|
||||
padding: 22rpx;
|
||||
border: 1rpx solid rgba(128, 89, 49, 0.22);
|
||||
border-radius: 12rpx;
|
||||
background: rgba(255, 252, 245, 0.68);
|
||||
}
|
||||
.vip-package__copy,
|
||||
.vip-order > view {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
.vip-package__copy text:first-child,
|
||||
.vip-order > view:first-child text:first-child {
|
||||
color: $ink;
|
||||
font-size: clamp(15px, 25rpx, 19px);
|
||||
font-weight: 700;
|
||||
line-height: 1.45;
|
||||
}
|
||||
.vip-package__copy text:last-child,
|
||||
.vip-order > view:first-child text:last-child,
|
||||
.vip-order > view:last-child text:last-child {
|
||||
margin-top: 7rpx;
|
||||
color: $ink-muted;
|
||||
font-size: clamp(12px, 20rpx, 15px);
|
||||
line-height: 1.45;
|
||||
}
|
||||
.vip-package__price,
|
||||
.vip-order > view:last-child {
|
||||
flex: 0 0 auto;
|
||||
text-align: right;
|
||||
}
|
||||
.vip-package__price text:first-child,
|
||||
.vip-order > view:last-child text:first-child {
|
||||
color: $brand-red;
|
||||
font-size: clamp(16px, 28rpx, 21px);
|
||||
font-weight: 700;
|
||||
}
|
||||
.vip-package__price text:last-child {
|
||||
margin-top: 7rpx;
|
||||
color: #a49382;
|
||||
font-size: clamp(11px, 18rpx, 14px);
|
||||
text-decoration: line-through;
|
||||
}
|
||||
.vip-empty-state {
|
||||
margin-top: 18rpx;
|
||||
padding: 34rpx 20rpx;
|
||||
border: 1rpx dashed rgba(128, 89, 49, 0.24);
|
||||
border-radius: 12rpx;
|
||||
color: $ink-muted;
|
||||
font-size: clamp(14px, 23rpx, 17px);
|
||||
text-align: center;
|
||||
}
|
||||
.page-content > .app-button {
|
||||
width: 100%;
|
||||
margin-top: 26rpx;
|
||||
}
|
||||
.notice-copy {
|
||||
margin: 0;
|
||||
}
|
||||
@media (max-width: 340px) {
|
||||
.page-content {
|
||||
padding-right: 20rpx;
|
||||
padding-left: 20rpx;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -11,13 +11,13 @@
|
||||
/></view>
|
||||
<view class="page-content">
|
||||
<view v-if="!valid" class="state-card"
|
||||
><text>礼仪活动入口无效</text
|
||||
><text>暂时无法打开礼仪活动</text
|
||||
><AppButton block label="返回上一页" @click="returnToFamily"
|
||||
/></view>
|
||||
<view v-else-if="state === 'loading'" class="state-card"
|
||||
<view v-else-if="ceremonyListState === 'loading'" class="state-card"
|
||||
><AppLoading text="正在读取礼仪活动"
|
||||
/></view>
|
||||
<view v-else-if="state === 'error'" class="state-card"
|
||||
<view v-else-if="ceremonyListState === 'error'" class="state-card"
|
||||
><text>暂时无法读取礼仪活动</text
|
||||
><AppButton
|
||||
block
|
||||
@@ -25,7 +25,7 @@
|
||||
label="重新加载"
|
||||
@click="loadCeremonies"
|
||||
/></view>
|
||||
<view v-else-if="state === 'empty'" class="state-card"
|
||||
<view v-else-if="ceremonyListState === 'empty'" class="state-card"
|
||||
><text>还没有礼仪活动</text
|
||||
><AppButton block label="新建礼仪活动" @click="createCeremony"
|
||||
/></view>
|
||||
@@ -37,7 +37,7 @@
|
||||
@click="openCeremony(item)"
|
||||
><view
|
||||
><text>{{ item.title }}</text
|
||||
><text>{{ item.type }}{{ item.time ? ` · ${item.time}` : "" }}</text
|
||||
><text>{{ item.typeLabel }}{{ item.time ? ` · ${item.time}` : "" }}</text
|
||||
><text v-if="item.description" class="ceremony-card__description">{{ item.description }}</text></view
|
||||
><text>{{ item.giftCount }} 笔献礼</text></view
|
||||
></view
|
||||
@@ -54,41 +54,32 @@ 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 { goBack, openPage, returnTo } from "@/utils/navigation.js";
|
||||
isRequestCancelled
|
||||
} from "@/services/api/request-controller.js";
|
||||
import { ceremonyApi } from "@/services/api/ceremony-service.js";
|
||||
import { goBack, openPage, returnTo } from "@/utils/navigation/gateway.js";
|
||||
|
||||
const genealogyId = ref("");
|
||||
const ceremonies = ref([]);
|
||||
const state = ref("loading");
|
||||
const controller = createRequestController();
|
||||
let active = true;
|
||||
const ceremonyListState = ref("loading");
|
||||
const ceremonyListRequestController = createRequestController();
|
||||
let pageActive = true;
|
||||
const valid = computed(() => /^[1-9]\d*$/.test(genealogyId.value));
|
||||
const loadCeremonies = async () => {
|
||||
if (!valid.value) return;
|
||||
controller.abort();
|
||||
state.value = "loading";
|
||||
ceremonyListRequestController.abort();
|
||||
ceremonyListState.value = "loading";
|
||||
try {
|
||||
const rows = await appApi.getCeremonies(genealogyId.value, {
|
||||
requestController: controller,
|
||||
const rows = await ceremonyApi.getCeremonies(genealogyId.value, {
|
||||
requestController: ceremonyListRequestController,
|
||||
});
|
||||
if (!active) return;
|
||||
ceremonies.value = rows
|
||||
.map((item) => ({
|
||||
id: String(item.ceremonyId),
|
||||
title: String(item.ceremonyTitle || "未命名活动"),
|
||||
type: String(item.ceremonyType || ""),
|
||||
time: String(item.ceremonyTime || ""),
|
||||
description: String(item.ceremonyDesc || ""),
|
||||
giftCount: Number.isSafeInteger(item.giftCount) ? item.giftCount : 0,
|
||||
}))
|
||||
.filter((item) => /^[1-9]\d*$/.test(item.id));
|
||||
state.value = ceremonies.value.length ? "ready" : "empty";
|
||||
if (!pageActive) return;
|
||||
ceremonies.value = rows;
|
||||
ceremonyListState.value = ceremonies.value.length ? "ready" : "empty";
|
||||
} catch (error) {
|
||||
if (!active || isRequestCancelled(error)) return;
|
||||
state.value = "error";
|
||||
if (!pageActive || isRequestCancelled(error)) return;
|
||||
ceremonyListState.value = "error";
|
||||
}
|
||||
};
|
||||
const returnToFamily = () =>
|
||||
@@ -108,11 +99,11 @@ onLoad((query) => {
|
||||
if (valid.value) loadCeremonies();
|
||||
});
|
||||
onShow(() => {
|
||||
if (valid.value && state.value !== "loading") loadCeremonies();
|
||||
if (valid.value && ceremonyListState.value !== "loading") loadCeremonies();
|
||||
});
|
||||
onUnload(() => {
|
||||
active = false;
|
||||
controller.abort();
|
||||
pageActive = false;
|
||||
ceremonyListRequestController.abort();
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -0,0 +1,657 @@
|
||||
<template>
|
||||
<view class="ritual-detail-page">
|
||||
<ModulePageBackground module="records" />
|
||||
<view class="page-header">
|
||||
<PageHeader
|
||||
title="礼仪详情"
|
||||
:action="ceremonyDetailState === 'ready' ? (giftFormVisible ? '收起' : '登记献礼') : ''"
|
||||
custom-back
|
||||
@back="requestBack"
|
||||
@action="toggleGiftForm"
|
||||
/>
|
||||
</view>
|
||||
<view class="page-content">
|
||||
<view v-if="!valid" class="state-card">
|
||||
<text>暂时无法打开礼仪活动</text>
|
||||
<text>未找到这项活动,请从活动列表重新进入。</text>
|
||||
<AppButton block label="返回上一页" @click="returnToList" />
|
||||
</view>
|
||||
<view v-else-if="ceremonyDetailState === 'loading'" class="state-card">
|
||||
<AppLoading text="正在加载礼仪详情" />
|
||||
</view>
|
||||
<view v-else-if="ceremonyDetailState === 'error'" class="state-card">
|
||||
<text>暂时无法加载礼仪详情</text>
|
||||
<text>请检查网络后重新加载。</text>
|
||||
<AppButton block type="secondary" label="重新加载" @click="loadDetail" />
|
||||
</view>
|
||||
<view v-else>
|
||||
<view class="detail-card">
|
||||
<text>{{ detail.title }}</text>
|
||||
<text>{{ detail.typeLabel }}{{ detail.time ? ` · ${detail.time}` : "" }}</text>
|
||||
<text v-if="detail.location || detail.locationAddress">地点:{{ detail.location || detail.locationAddress }}</text>
|
||||
<text v-if="detail.description">{{ detail.description }}</text>
|
||||
<text>已有 {{ detail.giftCount }} 笔献礼</text>
|
||||
<view v-if="detail.canEdit || detail.canDelete" class="detail-card__actions">
|
||||
<AppButton v-if="detail.canEdit" compact type="secondary" label="编辑活动" @click="openEditCeremony" />
|
||||
<AppButton v-if="detail.canDelete" compact type="secondary" label="删除活动" @click="requestDeleteCeremony" />
|
||||
</view>
|
||||
<text v-if="deleteError" class="delete-error">{{ deleteError }}</text>
|
||||
</view>
|
||||
<view class="gift-list-card">
|
||||
<view class="gift-list-card__heading">
|
||||
<view>
|
||||
<text>献礼记录</text>
|
||||
<text>仅展示可查看的姓名、金额、留言和时间。</text>
|
||||
</view>
|
||||
<AppButton
|
||||
compact
|
||||
type="secondary"
|
||||
:disabled="giftListState === 'loading'"
|
||||
:label="giftListState === 'loading' ? '加载中' : '查看清单'"
|
||||
@click="loadGiftRecords"
|
||||
/>
|
||||
</view>
|
||||
<AppLoading v-if="giftListState === 'loading'" text="正在加载献礼记录" />
|
||||
<view v-else-if="giftListState === 'ready'" class="gift-list-card__rows">
|
||||
<view v-for="item in giftRows" :key="item.id" class="gift-list-card__row">
|
||||
<view>
|
||||
<text>{{ item.giverName || item.giverNickName || '未署名献礼' }}</text>
|
||||
<text v-if="item.time">{{ item.time }}</text>
|
||||
<text v-if="item.message">{{ item.message }}</text>
|
||||
</view>
|
||||
<view class="gift-list-card__amount">
|
||||
<text>¥{{ item.amount }}</text>
|
||||
<AppButton
|
||||
v-if="item.canDelete"
|
||||
compact
|
||||
type="secondary"
|
||||
label="删除"
|
||||
@click="requestDeleteGift(item)"
|
||||
/>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<view v-else-if="giftListState === 'empty'" class="gift-list-card__empty">
|
||||
<text>暂时没有献礼记录。</text>
|
||||
</view>
|
||||
<view v-else-if="giftListState === 'error'" class="gift-list-card__error">
|
||||
<text>暂时无法加载献礼记录。</text>
|
||||
<AppButton compact type="secondary" label="重新查看" @click="loadGiftRecords" />
|
||||
</view>
|
||||
</view>
|
||||
<CeremonyInvitationManager
|
||||
ref="ceremonyInvitationManager"
|
||||
:genealogy-id="genealogyId"
|
||||
:ceremony-id="ceremonyId"
|
||||
@transient-change="invitationTransientOpen = $event"
|
||||
@busy-change="invitationBusy = $event"
|
||||
/>
|
||||
<view v-if="giftFormVisible" class="gift-form-card">
|
||||
<text class="gift-form-card__title">登记献礼</text>
|
||||
<text class="gift-form-card__note">金额为必填项;赠礼人和留言可按实际情况填写。</text>
|
||||
<view class="gift-field">
|
||||
<text>赠礼人</text>
|
||||
<input
|
||||
v-model.trim="giftForm.giverName"
|
||||
aria-label="赠礼人(选填)"
|
||||
:disabled="giftSubmitting"
|
||||
placeholder="请输入赠礼人姓名(选填)"
|
||||
placeholder-class="gift-placeholder"
|
||||
/>
|
||||
</view>
|
||||
<view class="gift-field">
|
||||
<text><text class="required-mark">*</text>金额</text>
|
||||
<input
|
||||
v-model.trim="giftForm.giftAmount"
|
||||
type="digit"
|
||||
aria-label="献礼金额"
|
||||
:aria-invalid="Boolean(giftError)"
|
||||
:disabled="giftSubmitting"
|
||||
placeholder="请输入献礼金额"
|
||||
placeholder-class="gift-placeholder"
|
||||
@input="giftError = ''"
|
||||
/>
|
||||
</view>
|
||||
<view class="gift-message-field">
|
||||
<text>留言</text>
|
||||
<textarea
|
||||
v-model.trim="giftForm.giftMessage"
|
||||
auto-height
|
||||
aria-label="献礼留言(选填)"
|
||||
:disabled="giftSubmitting"
|
||||
placeholder="写下对家族的祝福或缅怀(选填)"
|
||||
placeholder-class="gift-placeholder"
|
||||
/>
|
||||
</view>
|
||||
<text v-if="giftError" class="gift-error">{{ giftError }}</text>
|
||||
<view class="gift-form-card__actions">
|
||||
<AppButton type="secondary" label="取消" :disabled="giftSubmitting" @click="closeGiftForm" />
|
||||
<AppButton :label="giftSubmitting ? '正在登记' : '确认登记献礼'" :disabled="giftSubmitting" @click="requestGiftSubmit" />
|
||||
</view>
|
||||
</view>
|
||||
<text
|
||||
v-if="giftResult"
|
||||
class="gift-result"
|
||||
:class="`gift-result--${giftResultTone}`"
|
||||
:role="giftResultTone === 'error' ? 'alert' : 'status'"
|
||||
>{{ giftResult }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<AppDialog
|
||||
:visible="giftConfirmVisible"
|
||||
eyebrow="献礼确认"
|
||||
title="确认登记本次献礼?"
|
||||
message="提交后会保存到当前礼仪活动。"
|
||||
:confirm-text="giftSubmitting ? '正在登记' : '确认登记'"
|
||||
cancel-text="继续填写"
|
||||
show-cancel
|
||||
:close-on-mask="false"
|
||||
@confirm="confirmGiftSubmit"
|
||||
@cancel="giftConfirmVisible = false"
|
||||
/>
|
||||
<AppDialog
|
||||
:visible="giftDiscardVisible"
|
||||
eyebrow="放弃确认"
|
||||
title="放弃本次献礼?"
|
||||
message="当前填写内容尚未提交,关闭后将被清除。"
|
||||
confirm-text="确认放弃"
|
||||
cancel-text="继续填写"
|
||||
show-cancel
|
||||
:close-on-mask="false"
|
||||
@confirm="confirmGiftDiscard"
|
||||
@cancel="cancelGiftDiscard"
|
||||
/>
|
||||
<AppDialog
|
||||
:visible="ceremonyDeleteConfirmVisible"
|
||||
:close-on-mask="false"
|
||||
eyebrow="删除确认"
|
||||
title="删除这项礼仪活动?"
|
||||
message="活动和相关内容会一并删除,删除后无法恢复。"
|
||||
confirm-text="确认删除"
|
||||
cancel-text="保留活动"
|
||||
show-cancel
|
||||
@confirm="deleteCeremony"
|
||||
@cancel="closeCeremonyDeleteConfirmation"
|
||||
/>
|
||||
<AppDialog
|
||||
:visible="giftDeleteConfirmVisible"
|
||||
:close-on-mask="false"
|
||||
eyebrow="删除确认"
|
||||
title="删除这笔献礼记录?"
|
||||
message="删除后无法恢复,请确认当前记录不再需要。"
|
||||
confirm-text="确认删除"
|
||||
cancel-text="保留记录"
|
||||
show-cancel
|
||||
@confirm="deleteGift"
|
||||
@cancel="closeGiftDeleteConfirmation"
|
||||
/>
|
||||
</view>
|
||||
</template>
|
||||
<script setup>
|
||||
import { computed, reactive, ref } from "vue";
|
||||
import { onBackPress, onLoad, 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";
|
||||
import CeremonyInvitationManager from "@/components/records/CeremonyInvitationManager.vue";
|
||||
import {
|
||||
createRequestController,
|
||||
isRequestCancelled
|
||||
} from "@/services/api/request-controller.js";
|
||||
import { ceremonyApi } from "@/services/api/ceremony-service.js";
|
||||
import { getRequestErrorMessage } from "@/services/api/request-error-message.js";
|
||||
import { createDiscardConfirmation } from "@/utils/discard-confirmation.js";
|
||||
import { createNonIdempotentWriteGuard } from "@/utils/non-idempotent-write-guard.js";
|
||||
import {
|
||||
goBack,
|
||||
handleBackPress,
|
||||
openPage,
|
||||
returnTo,
|
||||
} from "@/utils/navigation/gateway.js";
|
||||
|
||||
const genealogyId = ref("");
|
||||
const ceremonyId = ref("");
|
||||
const ceremonyDetailState = ref("loading");
|
||||
const detail = ref({
|
||||
title: "",
|
||||
type: "",
|
||||
time: "",
|
||||
location: "",
|
||||
locationAddress: "",
|
||||
description: "",
|
||||
giftCount: 0,
|
||||
});
|
||||
// 详情与献礼可以同时刷新,独立控制器可避免一个区域取消另一区域的读取。
|
||||
const ceremonyDetailRequestController = createRequestController();
|
||||
const giftSubmissionRequestController = createRequestController();
|
||||
const ceremonyGiftCreateGuard = createNonIdempotentWriteGuard();
|
||||
const giftListRequestController = createRequestController();
|
||||
const giftFormVisible = ref(false);
|
||||
const giftConfirmVisible = ref(false);
|
||||
const giftDiscardVisible = ref(false);
|
||||
const giftSubmitting = ref(false);
|
||||
const giftError = ref("");
|
||||
const giftResult = ref("");
|
||||
const giftResultTone = ref("");
|
||||
const giftForm = reactive({
|
||||
giverName: "",
|
||||
giftAmount: "",
|
||||
giftMessage: "",
|
||||
});
|
||||
const giftBaseline = ref("");
|
||||
const giftRows = ref([]);
|
||||
const giftListState = ref("idle");
|
||||
const ceremonyInvitationManager = ref(null);
|
||||
const invitationTransientOpen = ref(false);
|
||||
const invitationBusy = ref(false);
|
||||
const ceremonyDeleteConfirmVisible = ref(false);
|
||||
const giftDeleteConfirmVisible = ref(false);
|
||||
const giftDeleteTarget = ref(null);
|
||||
const deleting = ref(false);
|
||||
const deleteError = ref("");
|
||||
const ceremonyContentDeletionRequestController = createRequestController();
|
||||
let pageActive = true;
|
||||
|
||||
const valid = computed(
|
||||
() =>
|
||||
/^[1-9]\d*$/.test(genealogyId.value) && /^[1-9]\d*$/.test(ceremonyId.value),
|
||||
);
|
||||
const giftSnapshot = computed(() => JSON.stringify(giftForm));
|
||||
const giftDirty = computed(
|
||||
() => giftFormVisible.value && giftSnapshot.value !== giftBaseline.value,
|
||||
);
|
||||
const isPageWriteBusy = computed(
|
||||
() => giftSubmitting.value || invitationBusy.value || deleting.value,
|
||||
);
|
||||
const resetGiftForm = () => {
|
||||
Object.assign(giftForm, { giverName: "", giftAmount: "", giftMessage: "" });
|
||||
giftBaseline.value = JSON.stringify(giftForm);
|
||||
giftError.value = "";
|
||||
};
|
||||
const giftDiscard = createDiscardConfirmation((visible) => {
|
||||
giftDiscardVisible.value = visible;
|
||||
});
|
||||
const requestGiftDiscard = giftDiscard.request;
|
||||
const confirmGiftDiscard = giftDiscard.confirm;
|
||||
const cancelGiftDiscard = giftDiscard.cancel;
|
||||
|
||||
const loadDetail = async ({ preserveCurrent = false } = {}) => {
|
||||
if (!valid.value) return false;
|
||||
ceremonyDetailRequestController.abort();
|
||||
if (!preserveCurrent) ceremonyDetailState.value = "loading";
|
||||
try {
|
||||
const ceremonyDetail = await ceremonyApi.getCeremonyDetail(
|
||||
genealogyId.value,
|
||||
ceremonyId.value,
|
||||
{ requestController: ceremonyDetailRequestController },
|
||||
);
|
||||
if (!pageActive) return false;
|
||||
detail.value = ceremonyDetail;
|
||||
ceremonyDetailState.value = "ready";
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (!pageActive || isRequestCancelled(error)) return false;
|
||||
if (!preserveCurrent) ceremonyDetailState.value = "error";
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const loadGiftRecords = async () => {
|
||||
if (!valid.value || giftListState.value === "loading") return;
|
||||
giftListRequestController.abort();
|
||||
giftListState.value = "loading";
|
||||
try {
|
||||
const rows = await ceremonyApi.getCeremonyGifts(
|
||||
genealogyId.value,
|
||||
ceremonyId.value,
|
||||
{ requestController: giftListRequestController },
|
||||
);
|
||||
if (!pageActive) return;
|
||||
giftRows.value = rows;
|
||||
giftListState.value = rows.length ? "ready" : "empty";
|
||||
} catch (error) {
|
||||
if (!pageActive || isRequestCancelled(error)) return;
|
||||
giftListState.value = "error";
|
||||
}
|
||||
};
|
||||
|
||||
const openGiftForm = () => {
|
||||
resetGiftForm();
|
||||
giftResult.value = "";
|
||||
giftResultTone.value = "";
|
||||
giftFormVisible.value = true;
|
||||
};
|
||||
const closeGiftForm = async () => {
|
||||
if (giftSubmitting.value) return false;
|
||||
if (giftDirty.value && !(await requestGiftDiscard())) return false;
|
||||
giftConfirmVisible.value = false;
|
||||
giftFormVisible.value = false;
|
||||
resetGiftForm();
|
||||
return true;
|
||||
};
|
||||
const toggleGiftForm = async () => {
|
||||
if (giftFormVisible.value) return closeGiftForm();
|
||||
openGiftForm();
|
||||
return true;
|
||||
};
|
||||
const requestGiftSubmit = () => {
|
||||
giftError.value = giftForm.giftAmount.trim() ? "" : "请填写献礼金额";
|
||||
if (!giftError.value && !giftSubmitting.value) giftConfirmVisible.value = true;
|
||||
};
|
||||
const confirmGiftSubmit = async () => {
|
||||
if (giftSubmitting.value || !giftForm.giftAmount.trim()) return;
|
||||
const payload = {
|
||||
giverName: giftForm.giverName,
|
||||
giftAmount: giftForm.giftAmount,
|
||||
giftMessage: giftForm.giftMessage,
|
||||
};
|
||||
const createAttempt = ceremonyGiftCreateGuard.begin(payload);
|
||||
if (createAttempt === null) {
|
||||
giftConfirmVisible.value = false;
|
||||
giftError.value =
|
||||
"上次登记结果暂时无法确认,请先查看献礼记录,避免重复登记。";
|
||||
return;
|
||||
}
|
||||
|
||||
giftSubmitting.value = true;
|
||||
giftError.value = "";
|
||||
giftResult.value = "";
|
||||
try {
|
||||
await ceremonyApi.createCeremonyGift(
|
||||
genealogyId.value,
|
||||
ceremonyId.value,
|
||||
payload,
|
||||
{ requestController: giftSubmissionRequestController },
|
||||
);
|
||||
if (!pageActive) return;
|
||||
giftConfirmVisible.value = false;
|
||||
giftFormVisible.value = false;
|
||||
resetGiftForm();
|
||||
giftResultTone.value = "success";
|
||||
giftResult.value = "献礼已登记。";
|
||||
const refreshed = await loadDetail({ preserveCurrent: true });
|
||||
if (giftListState.value !== "idle") await loadGiftRecords();
|
||||
if (pageActive && !refreshed) {
|
||||
giftResult.value = "献礼已登记,但礼仪详情暂未刷新。";
|
||||
}
|
||||
} catch (error) {
|
||||
if (!pageActive) return;
|
||||
if (ceremonyGiftCreateGuard.recordFailure(createAttempt, error)) {
|
||||
giftError.value =
|
||||
"登记结果暂时无法确认,请先查看献礼记录,避免重复登记。";
|
||||
giftConfirmVisible.value = false;
|
||||
return;
|
||||
}
|
||||
if (isRequestCancelled(error)) return;
|
||||
giftError.value = getRequestErrorMessage(error, "献礼登记失败,请稍后重试。");
|
||||
giftConfirmVisible.value = false;
|
||||
} finally {
|
||||
if (pageActive) giftSubmitting.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const requestDeleteCeremony = () => {
|
||||
if (!detail.value?.canDelete || deleting.value) return;
|
||||
deleteError.value = "";
|
||||
ceremonyDeleteConfirmVisible.value = true;
|
||||
};
|
||||
const openEditCeremony = async () => {
|
||||
if (!detail.value?.canEdit || !valid.value) return;
|
||||
if (!(await closeGiftForm())) return;
|
||||
return openPage(
|
||||
"R07",
|
||||
{ genealogyId: genealogyId.value, ceremonyId: ceremonyId.value, mode: "edit" },
|
||||
"R06",
|
||||
);
|
||||
};
|
||||
const closeCeremonyDeleteConfirmation = () => {
|
||||
if (!deleting.value) ceremonyDeleteConfirmVisible.value = false;
|
||||
};
|
||||
const deleteCeremony = async () => {
|
||||
if (!detail.value?.canDelete || deleting.value) return;
|
||||
deleting.value = true;
|
||||
deleteError.value = "";
|
||||
let deletionCommitted = false;
|
||||
try {
|
||||
await ceremonyApi.deleteCeremony(genealogyId.value, ceremonyId.value, {
|
||||
requestController: ceremonyContentDeletionRequestController,
|
||||
});
|
||||
deletionCommitted = true;
|
||||
if (!pageActive) return;
|
||||
ceremonyDeleteConfirmVisible.value = false;
|
||||
detail.value = { ...detail.value, canDelete: false, canEdit: false };
|
||||
giftConfirmVisible.value = false;
|
||||
giftFormVisible.value = false;
|
||||
resetGiftForm();
|
||||
const returned = await returnToList();
|
||||
if (pageActive && returned === false) {
|
||||
deleteError.value = "礼仪活动已删除,但暂时无法返回活动列表。";
|
||||
}
|
||||
} catch (error) {
|
||||
if (!pageActive) return;
|
||||
if (deletionCommitted) {
|
||||
ceremonyDeleteConfirmVisible.value = false;
|
||||
detail.value = { ...detail.value, canDelete: false, canEdit: false };
|
||||
deleteError.value = "礼仪活动已删除,但暂时无法返回活动列表。";
|
||||
return;
|
||||
}
|
||||
if (isRequestCancelled(error)) return;
|
||||
deleteError.value = getRequestErrorMessage(error, "礼仪活动删除失败,请稍后重试。");
|
||||
ceremonyDeleteConfirmVisible.value = false;
|
||||
} finally {
|
||||
if (pageActive) deleting.value = false;
|
||||
}
|
||||
};
|
||||
const requestDeleteGift = (gift) => {
|
||||
if (!gift?.canDelete || deleting.value) return;
|
||||
deleteError.value = "";
|
||||
giftDeleteTarget.value = gift;
|
||||
giftDeleteConfirmVisible.value = true;
|
||||
};
|
||||
const closeGiftDeleteConfirmation = () => {
|
||||
if (deleting.value) return;
|
||||
giftDeleteConfirmVisible.value = false;
|
||||
giftDeleteTarget.value = null;
|
||||
};
|
||||
const deleteGift = async () => {
|
||||
const gift = giftDeleteTarget.value;
|
||||
if (!gift?.canDelete || deleting.value) return;
|
||||
deleting.value = true;
|
||||
deleteError.value = "";
|
||||
try {
|
||||
await ceremonyApi.deleteCeremonyGift(genealogyId.value, ceremonyId.value, gift.id, {
|
||||
requestController: ceremonyContentDeletionRequestController,
|
||||
});
|
||||
if (!pageActive) return;
|
||||
giftDeleteConfirmVisible.value = false;
|
||||
giftDeleteTarget.value = null;
|
||||
await loadDetail({ preserveCurrent: true });
|
||||
await loadGiftRecords();
|
||||
} catch (error) {
|
||||
if (!pageActive || isRequestCancelled(error)) return;
|
||||
deleteError.value = getRequestErrorMessage(error, "献礼记录删除失败,请稍后重试。");
|
||||
giftDeleteConfirmVisible.value = false;
|
||||
} finally {
|
||||
if (pageActive) deleting.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const closeActiveTransient = () => {
|
||||
if (giftConfirmVisible.value) {
|
||||
giftConfirmVisible.value = false;
|
||||
return true;
|
||||
}
|
||||
if (giftDiscardVisible.value) {
|
||||
cancelGiftDiscard();
|
||||
return true;
|
||||
}
|
||||
if (
|
||||
invitationTransientOpen.value &&
|
||||
ceremonyInvitationManager.value?.closeTransient()
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
if (ceremonyDeleteConfirmVisible.value) {
|
||||
closeCeremonyDeleteConfirmation();
|
||||
return true;
|
||||
}
|
||||
if (giftDeleteConfirmVisible.value) {
|
||||
closeGiftDeleteConfirmation();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
const returnToList = async () => {
|
||||
if (!(await closeGiftForm())) return false;
|
||||
return /^[1-9]\d*$/.test(genealogyId.value)
|
||||
? returnTo("R05", { genealogyId: genealogyId.value })
|
||||
: goBack();
|
||||
};
|
||||
const requestBack = async () => {
|
||||
if (isPageWriteBusy.value) return true;
|
||||
if (closeActiveTransient()) return true;
|
||||
return returnToList();
|
||||
};
|
||||
|
||||
onLoad((query) => {
|
||||
genealogyId.value = String(query?.genealogyId || "");
|
||||
ceremonyId.value = String(query?.ceremonyId || "");
|
||||
if (valid.value) loadDetail();
|
||||
else ceremonyDetailState.value = "invalid";
|
||||
});
|
||||
onBackPress((event) => handleBackPress(event, requestBack));
|
||||
onUnload(() => {
|
||||
pageActive = false;
|
||||
ceremonyDetailRequestController.abort();
|
||||
giftSubmissionRequestController.abort();
|
||||
giftListRequestController.abort();
|
||||
ceremonyContentDeletionRequestController.abort();
|
||||
giftDiscard.dispose();
|
||||
});
|
||||
</script>
|
||||
<style scoped lang="scss">
|
||||
@import "../../styles/adaptive-frame-profiles.scss";
|
||||
.ritual-detail-page {
|
||||
display: flex;
|
||||
min-height: 100vh;
|
||||
flex-direction: column;
|
||||
background: $paper;
|
||||
}
|
||||
.page-header,
|
||||
.page-content {
|
||||
z-index: 1;
|
||||
}
|
||||
.page-content {
|
||||
padding: 18rpx 24rpx 72rpx;
|
||||
}
|
||||
.state-card,
|
||||
.detail-card,
|
||||
.gift-list-card,
|
||||
.gift-form-card,
|
||||
.gift-result {
|
||||
@include adaptive-records-content;
|
||||
}
|
||||
.gift-form-card {
|
||||
margin-top: 18rpx;
|
||||
padding: 34rpx 32rpx;
|
||||
}
|
||||
.gift-form-card__title,
|
||||
.gift-form-card__note,
|
||||
.gift-field > text,
|
||||
.gift-message-field > text,
|
||||
.gift-error,
|
||||
.gift-result {
|
||||
display: block;
|
||||
}
|
||||
.gift-form-card__title {
|
||||
color: $ink;
|
||||
font-family: STKaiti, KaiTi, serif;
|
||||
font-size: clamp(18px, 32rpx, 23px);
|
||||
font-weight: 700;
|
||||
}
|
||||
.gift-form-card__note {
|
||||
margin-top: 10rpx;
|
||||
color: $ink-muted;
|
||||
font-size: clamp(13px, 21rpx, 16px);
|
||||
line-height: 1.55;
|
||||
}
|
||||
.gift-field {
|
||||
display: grid;
|
||||
grid-template-columns: 148rpx minmax(0, 1fr);
|
||||
min-height: 90rpx;
|
||||
align-items: center;
|
||||
gap: 14rpx;
|
||||
margin-top: 18rpx;
|
||||
border-bottom: 1rpx solid rgba(181, 137, 63, 0.38);
|
||||
}
|
||||
.gift-field > text,
|
||||
.gift-message-field > text {
|
||||
color: $ink;
|
||||
font-size: clamp(14px, 23rpx, 17px);
|
||||
font-weight: 700;
|
||||
}
|
||||
.required-mark {
|
||||
color: $brand-red;
|
||||
}
|
||||
.gift-field input,
|
||||
.gift-message-field textarea {
|
||||
box-sizing: border-box;
|
||||
min-width: 0;
|
||||
color: $ink;
|
||||
font-size: clamp(14px, 22rpx, 17px);
|
||||
}
|
||||
.gift-field input {
|
||||
width: auto;
|
||||
min-height: 70rpx;
|
||||
text-align: right;
|
||||
}
|
||||
.gift-message-field {
|
||||
margin-top: 22rpx;
|
||||
}
|
||||
.gift-message-field textarea {
|
||||
display: block;
|
||||
width: 100%;
|
||||
min-height: 154rpx;
|
||||
margin-top: 12rpx;
|
||||
padding: 18rpx 20rpx;
|
||||
border: 1rpx solid rgba(181, 137, 63, 0.42);
|
||||
border-radius: 12rpx;
|
||||
line-height: 1.55;
|
||||
}
|
||||
.gift-placeholder {
|
||||
color: $ink-muted;
|
||||
}
|
||||
.gift-error {
|
||||
margin-top: 14rpx;
|
||||
color: #b42318;
|
||||
font-size: clamp(13px, 20rpx, 16px);
|
||||
line-height: 1.5;
|
||||
}
|
||||
.gift-form-card__actions {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 16rpx;
|
||||
margin-top: 26rpx;
|
||||
}
|
||||
.gift-result {
|
||||
margin-top: 18rpx;
|
||||
padding: 20rpx 24rpx;
|
||||
color: #245f39;
|
||||
font-size: clamp(14px, 22rpx, 17px);
|
||||
line-height: 1.55;
|
||||
}
|
||||
.gift-result--success {
|
||||
border-color: rgba(41, 112, 66, 0.34);
|
||||
background: rgba(234, 246, 237, 0.88);
|
||||
}
|
||||
@media (max-width: 340px) {
|
||||
.gift-form-card {
|
||||
padding: 28rpx 24rpx;
|
||||
}
|
||||
.gift-field {
|
||||
grid-template-columns: 126rpx minmax(0, 1fr);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,29 +1,30 @@
|
||||
<!-- 页面编号:R-07;用途:创建礼仪活动。 -->
|
||||
<template>
|
||||
<view class="ritual-editor-page" :class="`ritual-state--${pageState}`">
|
||||
<ModulePageBackground module="records" />
|
||||
<view class="page-header"
|
||||
><PageHeader title="新建礼仪活动" custom-back @back="requestBack"
|
||||
><PageHeader :title="isEdit ? '编辑礼仪活动' : '新建礼仪活动'" custom-back @back="requestBack"
|
||||
/></view>
|
||||
<view class="page-content">
|
||||
<view v-if="pageState === 'form'" class="editor-card">
|
||||
<text class="editor-card__title">记录一场家族礼仪</text>
|
||||
<view v-if="pageState === 'loading'" class="state-card"><AppLoading text="正在读取礼仪活动" /></view>
|
||||
<view v-else-if="pageState === 'form'" class="editor-card">
|
||||
<text class="editor-card__title">{{ isEdit ? '更新这场家族礼仪' : '记录一场家族礼仪' }}</text>
|
||||
<text class="editor-card__note"
|
||||
>带红色星号的内容不能为空;其余内容可按需要补充。</text
|
||||
>{{ isEdit ? '未重新选择封面时,会原样保留当前已授权封面。' : '带红色星号的内容不能为空;其余内容可按需要补充。' }}</text
|
||||
>
|
||||
|
||||
<view class="field-row">
|
||||
<view class="field-row field-row--picker">
|
||||
<text class="field-row__label"
|
||||
><text class="required-mark">*</text>活动类型</text
|
||||
>
|
||||
<input
|
||||
v-model="form.ceremonyType"
|
||||
maxlength="30"
|
||||
placeholder="例如:祭祖、家宴"
|
||||
placeholder-class="placeholder"
|
||||
@input="clearError"
|
||||
/>
|
||||
<picker
|
||||
:range="ceremonyTypeLabels"
|
||||
:value="ceremonyTypeIndex"
|
||||
:disabled="ceremonyTypeOptionsState !== 'ready' || !ceremonyTypeOptions.length"
|
||||
@change="selectCeremonyType"
|
||||
><view :class="{ placeholder: !form.ceremonyType }">{{ ceremonyTypeLabel }}</view></picker>
|
||||
</view>
|
||||
<text v-if="ceremonyTypeOptionsState === 'loading'" class="field-row__hint">正在获取活动类型</text>
|
||||
<text v-else-if="ceremonyTypeOptionsState === 'error'" class="field-row__hint">活动类型暂不可用,请稍后重试。</text>
|
||||
<view class="field-row">
|
||||
<text class="field-row__label"
|
||||
><text class="required-mark">*</text>活动标题</text
|
||||
@@ -93,7 +94,7 @@
|
||||
<view>
|
||||
<text class="cover-field__label">封面图片</text>
|
||||
<text class="cover-field__hint"
|
||||
>选择图片后会取得真实上传回执,并在保存时关联。</text
|
||||
>图片上传成功后,会作为活动封面保存。</text
|
||||
>
|
||||
</view>
|
||||
<button
|
||||
@@ -108,22 +109,12 @@
|
||||
>
|
||||
</view>
|
||||
<text v-if="uploadError" class="form-error">{{ uploadError }}</text>
|
||||
<view class="field-row">
|
||||
<text class="field-row__label">排序值</text>
|
||||
<input
|
||||
v-model="form.sortOrder"
|
||||
type="number"
|
||||
placeholder="数值越小越靠前"
|
||||
placeholder-class="placeholder"
|
||||
@input="clearError"
|
||||
/>
|
||||
</view>
|
||||
<text v-if="submitError" class="form-error">{{ submitError }}</text>
|
||||
<AppButton
|
||||
block
|
||||
:disabled="uploading || submitting"
|
||||
:label="submitting ? '正在保存…' : '保存活动'"
|
||||
@click="submit"
|
||||
:label="submitting ? '正在保存…' : isEdit ? '保存修改' : '保存活动'"
|
||||
@click="saveCeremony"
|
||||
/>
|
||||
</view>
|
||||
<view v-else class="state-card">
|
||||
@@ -136,7 +127,7 @@
|
||||
<AppDialog
|
||||
:visible="discardVisible"
|
||||
title="放弃活动草稿?"
|
||||
message="当前内容尚未保存到服务端,返回后不会保留。"
|
||||
message="活动还没有保存,返回后将不会保留。"
|
||||
confirm-text="放弃并返回"
|
||||
cancel-text="继续填写"
|
||||
show-cancel
|
||||
@@ -147,31 +138,37 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, onUnmounted, reactive, ref } from "vue";
|
||||
import { onBackPress, onLoad } from "@dcloudio/uni-app";
|
||||
import { computed, reactive, ref } from "vue";
|
||||
import { onBackPress, onLoad, 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";
|
||||
import {
|
||||
appApi,
|
||||
createRequestController,
|
||||
isRequestCancelled,
|
||||
} from "@/utils/api.js";
|
||||
isRequestCancelled
|
||||
} from "@/services/api/request-controller.js";
|
||||
import { businessDictionaryApi } from "@/services/api/business-dictionary-service.js";
|
||||
import { ceremonyApi } from "@/services/api/ceremony-service.js";
|
||||
import { getRequestErrorMessage } from "@/services/api/request-error-message.js";
|
||||
import {
|
||||
isImagePickCancelled,
|
||||
pickAndUploadImage,
|
||||
} from "@/utils/resumable-image-upload.js";
|
||||
} from "@/utils/media-upload.js";
|
||||
import { createDiscardConfirmation } from "@/utils/discard-confirmation.js";
|
||||
import { createNonIdempotentWriteGuard } from "@/utils/non-idempotent-write-guard.js";
|
||||
import {
|
||||
goBack,
|
||||
handleBackPress,
|
||||
returnTo,
|
||||
runBackGuard,
|
||||
} from "@/utils/navigation.js";
|
||||
} from "@/utils/navigation/gateway.js";
|
||||
|
||||
const genealogyId = ref("");
|
||||
const pageState = ref("form");
|
||||
const ceremonyId = ref("");
|
||||
const mode = ref("");
|
||||
const pageState = ref("loading");
|
||||
const form = reactive({
|
||||
ceremonyType: "",
|
||||
ceremonyTitle: "",
|
||||
@@ -180,50 +177,166 @@ const form = reactive({
|
||||
ceremonyClock: "",
|
||||
location: "",
|
||||
locationAddress: "",
|
||||
sortOrder: "",
|
||||
});
|
||||
const coverOssId = ref(null);
|
||||
const coverFileName = ref("");
|
||||
const preservedUpdateFields = ref({
|
||||
longitude: null,
|
||||
latitude: null,
|
||||
sortOrder: null,
|
||||
status: "",
|
||||
});
|
||||
const formBaseline = ref("");
|
||||
const ceremonyTypeOptionsState = ref("loading");
|
||||
const ceremonyTypeOptions = ref([]);
|
||||
const uploadError = ref("");
|
||||
const submitError = ref("");
|
||||
const uploading = ref(false);
|
||||
const submitting = ref(false);
|
||||
const controller = createRequestController();
|
||||
const ceremonyDetailController = createRequestController();
|
||||
const coverUploadController = createRequestController();
|
||||
const ceremonySaveController = createRequestController();
|
||||
const ceremonyTypeRequestController = createRequestController();
|
||||
const ceremonyCreateGuard = createNonIdempotentWriteGuard();
|
||||
let pageActive = true;
|
||||
const discardVisible = ref(false);
|
||||
const confirmation = createDiscardConfirmation((visible) => {
|
||||
discardVisible.value = visible;
|
||||
});
|
||||
|
||||
const hasValidContext = computed(() => /^[1-9]\d*$/.test(genealogyId.value));
|
||||
const isEdit = computed(() => mode.value === "edit");
|
||||
const hasValidContext = computed(
|
||||
() =>
|
||||
/^[1-9]\d*$/.test(genealogyId.value) &&
|
||||
(mode.value === "create" ||
|
||||
(mode.value === "edit" && /^[1-9]\d*$/.test(ceremonyId.value))),
|
||||
);
|
||||
const ceremonyTypeLabels = computed(() => ceremonyTypeOptions.value.map((item) => item.label));
|
||||
const ceremonyTypeIndex = computed(() =>
|
||||
ceremonyTypeOptions.value.findIndex((item) => item.value === form.ceremonyType),
|
||||
);
|
||||
const ceremonyTypeLabel = computed(
|
||||
() => ceremonyTypeOptions.value[ceremonyTypeIndex.value]?.label || "请选择活动类型",
|
||||
);
|
||||
const isDirty = computed(
|
||||
() =>
|
||||
Boolean(coverOssId.value) ||
|
||||
Object.values(form).some((value) => value.trim()),
|
||||
isEdit.value
|
||||
? Boolean(formBaseline.value) && formSnapshot.value !== formBaseline.value
|
||||
: Boolean(coverOssId.value) ||
|
||||
Object.values(form).some((value) => value.trim()),
|
||||
);
|
||||
const ceremonyTime = computed(() =>
|
||||
form.ceremonyDate
|
||||
? `${form.ceremonyDate} ${form.ceremonyClock || "00:00"}:00`
|
||||
: "",
|
||||
);
|
||||
const formSnapshot = computed(() =>
|
||||
JSON.stringify({ ...form, coverOssId: coverOssId.value || "" }),
|
||||
);
|
||||
const stateCopy = computed(() =>
|
||||
pageState.value === "success"
|
||||
? {
|
||||
title: "礼仪活动已提交服务端",
|
||||
copy: "服务端已返回成功结果。",
|
||||
action: "返回礼仪活动",
|
||||
title: isEdit.value ? "礼仪活动已更新" : "礼仪活动已保存",
|
||||
copy: isEdit.value
|
||||
? "已保存,返回详情可查看最新内容。"
|
||||
: "已保存。",
|
||||
action: isEdit.value ? "返回礼仪详情" : "返回礼仪活动",
|
||||
}
|
||||
: pageState.value === "error"
|
||||
? {
|
||||
title: "暂时无法编辑礼仪活动",
|
||||
copy: submitError.value || "请返回详情后重新查看。",
|
||||
action: "返回礼仪详情",
|
||||
}
|
||||
: {
|
||||
title: "礼仪活动入口无效",
|
||||
copy: "没有取得有效家谱标识。",
|
||||
title: "暂时无法编辑礼仪活动",
|
||||
copy: "未找到家谱信息,请返回后重新进入。",
|
||||
action: "返回上一页",
|
||||
},
|
||||
);
|
||||
|
||||
onLoad((query) => {
|
||||
genealogyId.value = String(query?.genealogyId || "");
|
||||
if (!hasValidContext.value || query?.mode !== "create")
|
||||
ceremonyId.value = String(query?.ceremonyId || "");
|
||||
mode.value = String(query?.mode || "");
|
||||
if (!hasValidContext.value) {
|
||||
pageState.value = "invalid";
|
||||
return;
|
||||
}
|
||||
initializeEditor();
|
||||
});
|
||||
const initializeEditor = async () => {
|
||||
pageState.value = "loading";
|
||||
try {
|
||||
await loadCeremonyTypes();
|
||||
if (isEdit.value) await loadCeremonyForEdit();
|
||||
if (!pageActive) return;
|
||||
if (pageState.value === "loading") pageState.value = "form";
|
||||
} catch (error) {
|
||||
if (!pageActive || isRequestCancelled(error)) return;
|
||||
submitError.value = getRequestErrorMessage(error, "礼仪活动详情暂时无法读取,请稍后重试。");
|
||||
pageState.value = "error";
|
||||
}
|
||||
};
|
||||
const loadCeremonyTypes = async () => {
|
||||
ceremonyTypeOptionsState.value = "loading";
|
||||
try {
|
||||
const options = await businessDictionaryApi.getBusinessDictionaryOptions("gen_ceremony_type", {
|
||||
requestController: ceremonyTypeRequestController,
|
||||
});
|
||||
if (!pageActive) return;
|
||||
ceremonyTypeOptions.value = options;
|
||||
ceremonyTypeOptionsState.value = "ready";
|
||||
} catch (error) {
|
||||
if (!pageActive || isRequestCancelled(error)) return;
|
||||
ceremonyTypeOptions.value = [];
|
||||
ceremonyTypeOptionsState.value = "error";
|
||||
}
|
||||
};
|
||||
const splitDateTime = (value) => {
|
||||
if (!value) return { date: "", clock: "" };
|
||||
const matched = String(value).trim().match(/^(\d{4}-\d{2}-\d{2})[ T](\d{2}:\d{2})/);
|
||||
if (!matched) throw new Error("活动时间有误,暂时无法编辑。");
|
||||
return { date: matched[1], clock: matched[2] };
|
||||
};
|
||||
const loadCeremonyForEdit = async () => {
|
||||
if (ceremonyTypeOptionsState.value !== "ready") {
|
||||
throw new Error("暂时无法获取活动类型,未保存修改。");
|
||||
}
|
||||
const detail = await ceremonyApi.getCeremonyDetail(genealogyId.value, ceremonyId.value, {
|
||||
requestController: ceremonyDetailController,
|
||||
});
|
||||
if (!pageActive) return;
|
||||
if (
|
||||
!detail.canEdit ||
|
||||
!detail.type ||
|
||||
!detail.title ||
|
||||
!["0", "1"].includes(detail.status) ||
|
||||
!Number.isSafeInteger(detail.sortOrder) ||
|
||||
!ceremonyTypeOptions.value.some((item) => item.value === detail.type)
|
||||
) {
|
||||
throw new Error("活动信息不完整,暂未保存修改,以免覆盖原内容。");
|
||||
}
|
||||
const timeParts = splitDateTime(detail.time);
|
||||
Object.assign(form, {
|
||||
ceremonyType: detail.type,
|
||||
ceremonyTitle: detail.title,
|
||||
ceremonyDesc: detail.description,
|
||||
ceremonyDate: timeParts.date,
|
||||
ceremonyClock: timeParts.clock,
|
||||
location: detail.location,
|
||||
locationAddress: detail.locationAddress,
|
||||
});
|
||||
coverOssId.value = detail.coverFile?.ossId || null;
|
||||
coverFileName.value = detail.coverFile?.fileName || (detail.coverFile ? "当前活动封面" : "");
|
||||
preservedUpdateFields.value = {
|
||||
longitude: detail.longitude,
|
||||
latitude: detail.latitude,
|
||||
sortOrder: detail.sortOrder,
|
||||
status: detail.status,
|
||||
};
|
||||
formBaseline.value = formSnapshot.value;
|
||||
};
|
||||
const clearError = () => {
|
||||
submitError.value = "";
|
||||
};
|
||||
@@ -235,22 +348,29 @@ const selectCeremonyClock = (event) => {
|
||||
form.ceremonyClock = event.detail.value || "";
|
||||
clearError();
|
||||
};
|
||||
const selectCeremonyType = (event) => {
|
||||
form.ceremonyType = ceremonyTypeOptions.value[Number(event.detail.value)]?.value || "";
|
||||
clearError();
|
||||
};
|
||||
const uploadCover = async () => {
|
||||
if (uploading.value || submitting.value) return;
|
||||
uploading.value = true;
|
||||
uploadError.value = "";
|
||||
try {
|
||||
const receipt = await pickAndUploadImage({ requestController: controller });
|
||||
const receipt = await pickAndUploadImage({
|
||||
requestController: coverUploadController,
|
||||
});
|
||||
if (!pageActive) return;
|
||||
coverOssId.value = receipt.ossId;
|
||||
coverFileName.value = receipt.fileName || "活动封面";
|
||||
} catch (error) {
|
||||
if (!isImagePickCancelled(error) && !isRequestCancelled(error))
|
||||
uploadError.value = error?.message || "封面图片上传失败,请稍后重试";
|
||||
if (pageActive && !isImagePickCancelled(error) && !isRequestCancelled(error))
|
||||
uploadError.value = getRequestErrorMessage(error, "封面图片上传失败,请稍后重试");
|
||||
} finally {
|
||||
uploading.value = false;
|
||||
if (pageActive) uploading.value = false;
|
||||
}
|
||||
};
|
||||
const submit = async () => {
|
||||
const saveCeremony = async () => {
|
||||
if (uploading.value || submitting.value || !hasValidContext.value) return;
|
||||
if (!form.ceremonyType.trim() || !form.ceremonyTitle.trim()) {
|
||||
submitError.value = !form.ceremonyType.trim()
|
||||
@@ -258,30 +378,52 @@ const submit = async () => {
|
||||
: "请填写活动标题";
|
||||
return;
|
||||
}
|
||||
const { ceremonyDate, ceremonyClock, ...ceremonyForm } = form;
|
||||
const payload = {
|
||||
...ceremonyForm,
|
||||
ceremonyTime: ceremonyTime.value,
|
||||
...(coverOssId.value ? { coverOssId: coverOssId.value } : {}),
|
||||
...(isEdit.value ? preservedUpdateFields.value : {}),
|
||||
};
|
||||
const createAttempt = isEdit.value ? null : ceremonyCreateGuard.begin(payload);
|
||||
if (!isEdit.value && createAttempt === null) {
|
||||
submitError.value =
|
||||
"上次保存结果暂时无法确认,请先返回活动列表检查,避免重复创建。";
|
||||
return;
|
||||
}
|
||||
|
||||
submitting.value = true;
|
||||
submitError.value = "";
|
||||
try {
|
||||
const { ceremonyDate, ceremonyClock, ...ceremonyForm } = form;
|
||||
await appApi.createCeremony(
|
||||
genealogyId.value,
|
||||
{
|
||||
...ceremonyForm,
|
||||
ceremonyTime: ceremonyTime.value,
|
||||
...(coverOssId.value ? { coverOssId: coverOssId.value } : {}),
|
||||
},
|
||||
{ requestController: controller },
|
||||
);
|
||||
if (isEdit.value) {
|
||||
await ceremonyApi.updateCeremony(genealogyId.value, ceremonyId.value, payload, {
|
||||
requestController: ceremonySaveController,
|
||||
});
|
||||
} else {
|
||||
await ceremonyApi.createCeremony(genealogyId.value, payload, {
|
||||
requestController: ceremonySaveController,
|
||||
});
|
||||
}
|
||||
if (!pageActive) return;
|
||||
pageState.value = "success";
|
||||
} catch (error) {
|
||||
if (!pageActive) return;
|
||||
if (!isEdit.value && ceremonyCreateGuard.recordFailure(createAttempt, error)) {
|
||||
submitError.value =
|
||||
"保存结果暂时无法确认,请先返回活动列表检查,避免重复创建。";
|
||||
return;
|
||||
}
|
||||
if (!isRequestCancelled(error))
|
||||
submitError.value = error?.message || "礼仪活动保存失败,请稍后重试";
|
||||
submitError.value = getRequestErrorMessage(error, "礼仪活动保存失败,请稍后重试");
|
||||
} finally {
|
||||
submitting.value = false;
|
||||
if (pageActive) submitting.value = false;
|
||||
}
|
||||
};
|
||||
const returnToList = () =>
|
||||
const returnToDestination = () =>
|
||||
hasValidContext.value
|
||||
? returnTo("R05", { genealogyId: genealogyId.value })
|
||||
? isEdit.value
|
||||
? returnTo("R06", { genealogyId: genealogyId.value, ceremonyId: ceremonyId.value })
|
||||
: returnTo("R05", { genealogyId: genealogyId.value })
|
||||
: goBack();
|
||||
const requestBack = () =>
|
||||
runBackGuard({
|
||||
@@ -293,10 +435,16 @@ const requestBack = () =>
|
||||
"confirm-discard": confirmation.request,
|
||||
});
|
||||
const handleStateAction = () =>
|
||||
pageState.value === "success" ? returnToList() : goBack();
|
||||
pageState.value === "success" || pageState.value === "error"
|
||||
? returnToDestination()
|
||||
: goBack();
|
||||
onBackPress((event) => handleBackPress(event, requestBack));
|
||||
onUnmounted(() => {
|
||||
controller.abort();
|
||||
onUnload(() => {
|
||||
pageActive = false;
|
||||
ceremonyDetailController.abort();
|
||||
coverUploadController.abort();
|
||||
ceremonySaveController.abort();
|
||||
ceremonyTypeRequestController.abort();
|
||||
confirmation.dispose();
|
||||
});
|
||||
</script>
|
||||
@@ -329,6 +477,7 @@ onUnmounted(() => {
|
||||
.editor-card__note,
|
||||
.field-row__label,
|
||||
.textarea-field__label,
|
||||
.field-row__hint,
|
||||
.form-error {
|
||||
display: block;
|
||||
}
|
||||
@@ -378,6 +527,12 @@ onUnmounted(() => {
|
||||
color: $ink;
|
||||
font-size: clamp(15px, 25rpx, 18px);
|
||||
}
|
||||
.field-row__hint {
|
||||
margin: 8rpx 8rpx 0;
|
||||
color: $ink-muted;
|
||||
font-size: clamp(13px, 21rpx, 16px);
|
||||
line-height: 1.45;
|
||||
}
|
||||
.required-mark {
|
||||
display: inline-block;
|
||||
margin-right: 4rpx;
|
||||
@@ -0,0 +1,932 @@
|
||||
<template>
|
||||
<view class="record-page">
|
||||
<ModulePageBackground module="records" />
|
||||
<view class="page-header">
|
||||
<PageHeader
|
||||
title="成长日志"
|
||||
:action="valid && view === 'list' ? '新建' : ''"
|
||||
custom-back
|
||||
@back="requestBack"
|
||||
@action="openCreate"
|
||||
/>
|
||||
</view>
|
||||
<view class="page-content">
|
||||
<view v-if="view === 'form'" class="form-card">
|
||||
<text>{{ isEdit ? '编辑成长记录' : '新建成长记录' }}</text>
|
||||
<text class="form-copy">{{ isEdit ? '原有图片和相关设置会保留。' : '红色 * 为必填项,其余内容可按需补充。' }}</text>
|
||||
<view class="field field--picker">
|
||||
<text>关联人物</text>
|
||||
<picker
|
||||
:range="personOptionLabels"
|
||||
:value="personOptionIndex"
|
||||
:disabled="personOptionsState !== 'ready'"
|
||||
@change="selectLineagePerson"
|
||||
>
|
||||
<view :class="{ placeholder: personOptionsState !== 'ready' }">{{
|
||||
personOptionLabel
|
||||
}}</view>
|
||||
</picker>
|
||||
<text v-if="personOptionsState === 'loading'" class="person-option-note"
|
||||
>正在获取家谱成员</text
|
||||
>
|
||||
<text v-else-if="personOptionsState === 'error'" class="person-option-note"
|
||||
>暂时无法选择关联成员,这条记录不会关联成员。</text
|
||||
>
|
||||
</view>
|
||||
<view class="field field--picker">
|
||||
<text>记录类型</text>
|
||||
<picker
|
||||
:range="growthTypeLabels"
|
||||
:value="growthTypeIndex"
|
||||
:disabled="growthTypeOptionsState !== 'ready' || !growthTypeOptions.length"
|
||||
@change="selectGrowthType"
|
||||
><view :class="{ placeholder: !form.recordType }">{{ growthTypeLabel }}</view></picker>
|
||||
</view>
|
||||
<text v-if="growthTypeOptionsState === 'loading'" class="person-option-note">正在获取记录类型</text>
|
||||
<text v-else-if="growthTypeOptionsState === 'error'" class="person-option-note">暂时无法获取记录类型,可不选类型继续填写。</text>
|
||||
<view class="field"
|
||||
><text><text class="required-mark">*</text>记录标题</text
|
||||
><input
|
||||
v-model="form.recordTitle"
|
||||
placeholder="请输入记录标题"
|
||||
@input="error = ''"
|
||||
/></view>
|
||||
<view class="field field--textarea"
|
||||
><text>记录内容</text
|
||||
><textarea
|
||||
v-model="form.recordContent"
|
||||
auto-height
|
||||
placeholder="记录成长片段"
|
||||
@input="error = ''"
|
||||
/>
|
||||
</view>
|
||||
<view class="field field--picker"
|
||||
><text>记录日期</text
|
||||
><picker
|
||||
mode="date"
|
||||
:value="form.recordDate"
|
||||
@change="selectRecordDate"
|
||||
><view :class="{ placeholder: !form.recordDate }">{{
|
||||
form.recordDate || "请选择"
|
||||
}}</view></picker
|
||||
></view
|
||||
>
|
||||
<view class="field field--picker"
|
||||
><text>记录时间</text
|
||||
><picker
|
||||
mode="time"
|
||||
:value="form.recordClock"
|
||||
@change="selectRecordClock"
|
||||
><view :class="{ placeholder: !form.recordClock }">{{
|
||||
form.recordClock || "请选择"
|
||||
}}</view></picker
|
||||
></view
|
||||
>
|
||||
<view class="field field--picker"
|
||||
><text>提醒日期</text
|
||||
><picker
|
||||
mode="date"
|
||||
:value="form.remindDate"
|
||||
@change="selectRemindDate"
|
||||
><view :class="{ placeholder: !form.remindDate }">{{
|
||||
form.remindDate || "请选择"
|
||||
}}</view></picker
|
||||
></view
|
||||
>
|
||||
<view class="field field--picker"
|
||||
><text>提醒时间</text
|
||||
><picker
|
||||
mode="time"
|
||||
:value="form.remindClock"
|
||||
@change="selectRemindClock"
|
||||
><view :class="{ placeholder: !form.remindClock }">{{
|
||||
form.remindClock || "请选择"
|
||||
}}</view></picker
|
||||
></view
|
||||
>
|
||||
<view class="upload-field">
|
||||
<view
|
||||
><text>相关图片</text
|
||||
><text
|
||||
>图片上传成功后,会随这条记录一起保存。</text
|
||||
></view
|
||||
>
|
||||
<button
|
||||
class="upload-button"
|
||||
:disabled="uploading || submitting"
|
||||
@click="uploadImage"
|
||||
>
|
||||
{{ uploading ? "上传中…" : "添加图片" }}
|
||||
</button>
|
||||
<text
|
||||
v-for="(receipt, index) in mediaReceipts"
|
||||
:key="`${receipt.ossId}-${index}`"
|
||||
>已上传:{{ receipt.fileName || "图片" }}</text
|
||||
>
|
||||
<text v-if="uploadError" class="error">{{ uploadError }}</text>
|
||||
</view>
|
||||
<text v-if="error" class="error">{{ error }}</text>
|
||||
<view class="form-actions"
|
||||
><AppButton
|
||||
type="secondary"
|
||||
label="取消"
|
||||
@click="cancelCreate" /><AppButton
|
||||
:disabled="submitting || uploading"
|
||||
:label="submitting ? '正在提交' : '提交成长记录'"
|
||||
@click="saveGrowthRecord"
|
||||
/></view>
|
||||
</view>
|
||||
<view v-else-if="!valid" class="state-card"
|
||||
><text>暂时无法打开成长日志</text
|
||||
><text>未找到家谱信息,请从家族页面重新进入。</text
|
||||
><AppButton block label="返回上一页" @click="requestBack"
|
||||
/></view>
|
||||
<view v-else-if="listState === 'loading'" class="state-card"
|
||||
><AppLoading text="正在读取成长记录"
|
||||
/></view>
|
||||
<view v-else-if="listState === 'error'" class="state-card"
|
||||
><text>暂时无法读取成长记录</text
|
||||
><text>请检查网络后重新加载。</text
|
||||
><AppButton
|
||||
block
|
||||
type="secondary"
|
||||
label="重新加载"
|
||||
@click="loadRecords"
|
||||
/></view>
|
||||
<view v-else-if="listState === 'empty'" class="state-card"
|
||||
><text>还没有成长记录</text
|
||||
><AppButton block label="新建成长记录" @click="openCreate"
|
||||
/></view>
|
||||
<view v-else class="record-list">
|
||||
<text v-if="saveNotice" class="save-notice"
|
||||
>成长记录已保存。</text
|
||||
>
|
||||
<text v-if="deleteError" class="delete-error">{{ deleteError }}</text>
|
||||
<view class="record-filters">
|
||||
<picker :range="filterPersonLabels" :value="filterPersonIndex" @change="selectFilterPerson">
|
||||
<view><text>人物</text><text>{{ filterPersonLabels[filterPersonIndex] }}</text></view>
|
||||
</picker>
|
||||
<picker :range="filterTypeLabels" :value="filterTypeIndex" @change="selectFilterType">
|
||||
<view><text>类型</text><text>{{ filterTypeLabels[filterTypeIndex] }}</text></view>
|
||||
</picker>
|
||||
</view>
|
||||
<text v-if="!filteredRecords.length" class="filter-empty">没有符合当前筛选的成长记录。</text>
|
||||
<view
|
||||
v-for="item in filteredRecords"
|
||||
:key="item.id"
|
||||
class="record-card"
|
||||
role="button"
|
||||
:aria-label="`查看${item.title}详情`"
|
||||
@click="openRecordDetail(item)"
|
||||
>
|
||||
<view
|
||||
><text>{{ item.title }}</text
|
||||
><text
|
||||
>{{ growthRecordTypeLabel(item)
|
||||
}}{{ item.date ? ` · ${item.date}` : "" }}</text
|
||||
><text v-if="item.content">{{ item.content }}</text></view
|
||||
>
|
||||
<AppButton
|
||||
v-if="item.canEdit && personOptionsState === 'ready' && growthTypeOptionsState === 'ready'"
|
||||
compact
|
||||
type="secondary"
|
||||
label="编辑"
|
||||
@click.stop="openEdit(item)"
|
||||
/>
|
||||
<AppButton
|
||||
v-if="item.canDelete"
|
||||
compact
|
||||
type="secondary"
|
||||
label="删除"
|
||||
@click.stop="requestDeleteRecord(item)"
|
||||
/>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<GrowthRecordDetailDialog
|
||||
ref="growthRecordDetailDialog"
|
||||
:genealogy-id="genealogyId"
|
||||
@busy-change="detailBusy = $event"
|
||||
@transient-change="detailTransientOpen = $event"
|
||||
/>
|
||||
<AppDialog
|
||||
:visible="discardVisible"
|
||||
title="放弃成长记录?"
|
||||
message="尚未提交的内容将被清除。"
|
||||
confirm-text="放弃并返回"
|
||||
cancel-text="继续填写"
|
||||
show-cancel
|
||||
:close-on-mask="false"
|
||||
@confirm="confirmDiscard"
|
||||
@cancel="cancelDiscard"
|
||||
/>
|
||||
<AppDialog
|
||||
:visible="deleteConfirmationVisible"
|
||||
title="删除这条成长记录?"
|
||||
message="删除后无法恢复,请确认当前内容不再需要。"
|
||||
confirm-text="确认删除"
|
||||
cancel-text="保留记录"
|
||||
show-cancel
|
||||
:close-on-mask="false"
|
||||
@confirm="deleteRecord"
|
||||
@cancel="closeDeleteConfirmation"
|
||||
/>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, reactive, ref } from "vue";
|
||||
import { onBackPress, onLoad, 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 GrowthRecordDetailDialog from "@/components/records/GrowthRecordDetailDialog.vue";
|
||||
import ModulePageBackground from "@/components/ModulePageBackground.vue";
|
||||
import PageHeader from "@/components/PageHeader.vue";
|
||||
import {
|
||||
createRequestController,
|
||||
isRequestCancelled
|
||||
} from "@/services/api/request-controller.js";
|
||||
import { businessDictionaryApi } from "@/services/api/business-dictionary-service.js";
|
||||
import { lifeRecordApi } from "@/services/api/life-record-service.js";
|
||||
import { lineageApi } from "@/services/api/lineage-service.js";
|
||||
import { getRequestErrorMessage } from "@/services/api/request-error-message.js";
|
||||
import { createDiscardConfirmation } from "@/utils/discard-confirmation.js";
|
||||
import { createNonIdempotentWriteGuard } from "@/utils/non-idempotent-write-guard.js";
|
||||
import {
|
||||
isImagePickCancelled,
|
||||
pickAndUploadImage,
|
||||
} from "@/utils/media-upload.js";
|
||||
import { goBack, handleBackPress, runBackGuard } from "@/utils/navigation/gateway.js";
|
||||
|
||||
const genealogyId = ref("");
|
||||
const personId = ref("");
|
||||
const view = ref("list");
|
||||
const listState = ref("loading");
|
||||
const records = ref([]);
|
||||
const saveNotice = ref(false);
|
||||
const submitting = ref(false);
|
||||
const uploading = ref(false);
|
||||
const error = ref("");
|
||||
const uploadError = ref("");
|
||||
const discardVisible = ref(false);
|
||||
const editingRecord = ref(null);
|
||||
const formBaseline = ref("");
|
||||
const deleteConfirmationVisible = ref(false);
|
||||
const deleteTarget = ref(null);
|
||||
const deleting = ref(false);
|
||||
const deleteError = ref("");
|
||||
const filterPersonId = ref("");
|
||||
const filterType = ref("");
|
||||
const growthRecordDetailDialog = ref(null);
|
||||
const detailTransientOpen = ref(false);
|
||||
const detailBusy = ref(false);
|
||||
const personOptionsState = ref("loading");
|
||||
const personOptions = ref([{ value: "", label: "不关联人物" }]);
|
||||
const defaultLineagePersonId = ref("");
|
||||
const growthTypeOptionsState = ref("loading");
|
||||
const growthTypeOptions = ref([]);
|
||||
const form = reactive({
|
||||
lineagePersonId: "",
|
||||
recordType: "",
|
||||
recordTitle: "",
|
||||
recordContent: "",
|
||||
recordDate: "",
|
||||
recordClock: "",
|
||||
remindDate: "",
|
||||
remindClock: "",
|
||||
});
|
||||
const mediaReceipts = ref([]);
|
||||
const recordListRequestController = createRequestController();
|
||||
const growthEditorDetailRequestController = createRequestController();
|
||||
const growthMediaUploadRequestController = createRequestController();
|
||||
const growthRecordSaveRequestController = createRequestController();
|
||||
const personOptionsController = createRequestController();
|
||||
const growthTypeRequestController = createRequestController();
|
||||
const growthRecordDeleteController = createRequestController();
|
||||
const growthRecordCreateGuard = createNonIdempotentWriteGuard();
|
||||
let pageActive = true;
|
||||
const shouldIgnoreRequestFailure = (cause) =>
|
||||
!pageActive || isRequestCancelled(cause);
|
||||
const valid = computed(() => /^[1-9]\d*$/.test(genealogyId.value));
|
||||
const isEdit = computed(() => Boolean(editingRecord.value));
|
||||
const personOptionLabels = computed(() =>
|
||||
personOptions.value.map((item) => item.label),
|
||||
);
|
||||
const personOptionIndex = computed(() =>
|
||||
Math.max(
|
||||
0,
|
||||
personOptions.value.findIndex((item) => item.value === form.lineagePersonId),
|
||||
),
|
||||
);
|
||||
const personOptionLabel = computed(() =>
|
||||
personOptions.value[personOptionIndex.value]?.label || "不关联人物",
|
||||
);
|
||||
const growthTypeLabels = computed(() => growthTypeOptions.value.map((item) => item.label));
|
||||
const growthTypeIndex = computed(() => Math.max(0, growthTypeOptions.value.findIndex((item) => item.value === form.recordType)));
|
||||
const growthTypeLabel = computed(() => growthTypeOptions.value[growthTypeIndex.value]?.label || "请选择记录类型");
|
||||
const growthRecordTypeLabel = (record) =>
|
||||
growthTypeOptions.value.find((option) => option.value === record.type)?.label ||
|
||||
record.typeLabel ||
|
||||
record.personName;
|
||||
const filterPersonOptions = computed(() => [
|
||||
{ value: "", label: "全部人物" },
|
||||
{ value: "NONE", label: "未关联人物" },
|
||||
...personOptions.value.filter((item) => item.value),
|
||||
]);
|
||||
const filterPersonLabels = computed(() => filterPersonOptions.value.map((item) => item.label));
|
||||
const filterPersonIndex = computed(() => Math.max(0, filterPersonOptions.value.findIndex((item) => item.value === filterPersonId.value)));
|
||||
const filterTypeOptions = computed(() => [{ value: "", label: "全部类型" }, ...growthTypeOptions.value]);
|
||||
const filterTypeLabels = computed(() => filterTypeOptions.value.map((item) => item.label));
|
||||
const filterTypeIndex = computed(() => Math.max(0, filterTypeOptions.value.findIndex((item) => item.value === filterType.value)));
|
||||
const filteredRecords = computed(() => records.value.filter((item) =>
|
||||
(!filterPersonId.value || (filterPersonId.value === "NONE" ? !item.lineagePersonId : item.lineagePersonId === filterPersonId.value)) &&
|
||||
(!filterType.value || item.type === filterType.value),
|
||||
));
|
||||
const mediaOssIds = computed(() =>
|
||||
mediaReceipts.value.map((item) => item.ossId).join(","),
|
||||
);
|
||||
const recordTime = computed(() =>
|
||||
form.recordDate ? `${form.recordDate} ${form.recordClock || "00:00"}:00` : "",
|
||||
);
|
||||
const remindTime = computed(() =>
|
||||
form.remindDate ? `${form.remindDate} ${form.remindClock || "00:00"}:00` : "",
|
||||
);
|
||||
const formSnapshot = computed(() =>
|
||||
JSON.stringify({ ...form, mediaOssIds: mediaOssIds.value }),
|
||||
);
|
||||
const dirty = computed(() =>
|
||||
isEdit.value
|
||||
? Boolean(formBaseline.value) && formSnapshot.value !== formBaseline.value
|
||||
: Object.values(form).some((value) => String(value).trim()) ||
|
||||
mediaReceipts.value.length > 0,
|
||||
);
|
||||
const confirmation = createDiscardConfirmation((visible) => {
|
||||
discardVisible.value = visible;
|
||||
});
|
||||
const confirmDiscard = () => {
|
||||
confirmation.confirm();
|
||||
resetForm();
|
||||
view.value = "list";
|
||||
};
|
||||
const cancelDiscard = confirmation.cancel;
|
||||
|
||||
const resetForm = () => {
|
||||
Object.assign(form, {
|
||||
lineagePersonId: defaultLineagePersonId.value,
|
||||
recordType: "",
|
||||
recordTitle: "",
|
||||
recordContent: "",
|
||||
recordDate: "",
|
||||
recordClock: "",
|
||||
remindDate: "",
|
||||
remindClock: "",
|
||||
});
|
||||
mediaReceipts.value = [];
|
||||
editingRecord.value = null;
|
||||
formBaseline.value = "";
|
||||
error.value = "";
|
||||
uploadError.value = "";
|
||||
};
|
||||
const loadPersonOptions = async () => {
|
||||
if (!valid.value) return;
|
||||
personOptionsState.value = "loading";
|
||||
try {
|
||||
const rows = await lineageApi.getLineagePersonOptions(genealogyId.value, {
|
||||
requestController: personOptionsController,
|
||||
});
|
||||
if (!pageActive) return;
|
||||
const options = rows.map((item) => ({
|
||||
value: item.id,
|
||||
label: item.generation ? `第 ${item.generation} 世 · ${item.name}` : item.name,
|
||||
}));
|
||||
personOptions.value = [{ value: "", label: "不关联人物" }, ...options];
|
||||
defaultLineagePersonId.value = options.some((item) => item.value === personId.value)
|
||||
? personId.value
|
||||
: "";
|
||||
form.lineagePersonId = defaultLineagePersonId.value;
|
||||
personOptionsState.value = "ready";
|
||||
} catch (cause) {
|
||||
if (!pageActive || isRequestCancelled(cause)) return;
|
||||
personOptions.value = [{ value: "", label: "暂时无法选择关联成员" }];
|
||||
defaultLineagePersonId.value = "";
|
||||
form.lineagePersonId = "";
|
||||
personOptionsState.value = "error";
|
||||
}
|
||||
};
|
||||
const loadGrowthTypes = async () => {
|
||||
growthTypeOptionsState.value = "loading";
|
||||
try {
|
||||
const growthTypes = await businessDictionaryApi.getBusinessDictionaryOptions("gen_growth_record_type", {
|
||||
requestController: growthTypeRequestController,
|
||||
});
|
||||
if (!pageActive) return;
|
||||
growthTypeOptions.value = growthTypes;
|
||||
growthTypeOptionsState.value = "ready";
|
||||
} catch (cause) {
|
||||
if (shouldIgnoreRequestFailure(cause)) return;
|
||||
growthTypeOptions.value = [];
|
||||
growthTypeOptionsState.value = "error";
|
||||
}
|
||||
};
|
||||
const loadRecords = async () => {
|
||||
if (!valid.value) return;
|
||||
recordListRequestController.abort();
|
||||
listState.value = "loading";
|
||||
try {
|
||||
const rows = await lifeRecordApi.getGrowthRecords(genealogyId.value, {
|
||||
requestController: recordListRequestController,
|
||||
});
|
||||
if (!pageActive) return;
|
||||
records.value = rows;
|
||||
listState.value = records.value.length ? "ready" : "empty";
|
||||
} catch (cause) {
|
||||
if (!pageActive || isRequestCancelled(cause)) return;
|
||||
listState.value = "error";
|
||||
}
|
||||
};
|
||||
const openCreate = () => {
|
||||
if (!valid.value) return;
|
||||
saveNotice.value = false;
|
||||
resetForm();
|
||||
view.value = "form";
|
||||
};
|
||||
const splitDateTime = (value, label) => {
|
||||
if (!value) return { date: "", clock: "" };
|
||||
const matched = String(value).trim().match(/^(\d{4}-\d{2}-\d{2})[ T](\d{2}:\d{2})/);
|
||||
if (!matched) throw new Error(`${label}有误,暂时无法编辑。`);
|
||||
return { date: matched[1], clock: matched[2] };
|
||||
};
|
||||
const openEdit = async (record) => {
|
||||
if (
|
||||
!record?.canEdit ||
|
||||
!valid.value ||
|
||||
submitting.value ||
|
||||
uploading.value ||
|
||||
personOptionsState.value !== "ready" ||
|
||||
growthTypeOptionsState.value !== "ready"
|
||||
) {
|
||||
return;
|
||||
}
|
||||
growthEditorDetailRequestController.abort();
|
||||
listState.value = "loading";
|
||||
try {
|
||||
const detail = await lifeRecordApi.getGrowthRecordDetail(genealogyId.value, record.id, "", {
|
||||
requestController: growthEditorDetailRequestController,
|
||||
});
|
||||
if (!pageActive) return;
|
||||
if (
|
||||
!detail.canEdit ||
|
||||
!detail.content ||
|
||||
!["0", "1"].includes(detail.status) ||
|
||||
!Number.isSafeInteger(detail.sortOrder) ||
|
||||
(detail.lineagePersonId && !personOptions.value.some((item) => item.value === detail.lineagePersonId)) ||
|
||||
(detail.type && !growthTypeOptions.value.some((item) => item.value === detail.type))
|
||||
) {
|
||||
throw new Error("这条记录的信息不完整,暂未保存修改,以免覆盖原内容。");
|
||||
}
|
||||
const recordTimeParts = splitDateTime(detail.recordDate, "记录时间");
|
||||
const remindTimeParts = splitDateTime(detail.remindTime, "提醒时间");
|
||||
resetForm();
|
||||
Object.assign(form, {
|
||||
lineagePersonId: detail.lineagePersonId || "",
|
||||
recordType: detail.type,
|
||||
recordTitle: detail.title,
|
||||
recordContent: detail.content,
|
||||
recordDate: recordTimeParts.date,
|
||||
recordClock: recordTimeParts.clock,
|
||||
remindDate: remindTimeParts.date,
|
||||
remindClock: remindTimeParts.clock,
|
||||
});
|
||||
mediaReceipts.value = detail.mediaFiles.map((file) => ({
|
||||
ossId: file.ossId,
|
||||
fileName: file.fileName,
|
||||
}));
|
||||
editingRecord.value = {
|
||||
id: detail.id,
|
||||
sortOrder: detail.sortOrder,
|
||||
status: detail.status,
|
||||
};
|
||||
formBaseline.value = formSnapshot.value;
|
||||
view.value = "form";
|
||||
listState.value = "ready";
|
||||
} catch (cause) {
|
||||
if (!pageActive || isRequestCancelled(cause)) return;
|
||||
listState.value = "error";
|
||||
}
|
||||
};
|
||||
const cancelCreate = () => {
|
||||
resetForm();
|
||||
view.value = "list";
|
||||
};
|
||||
const selectRecordDate = (event) => {
|
||||
form.recordDate = event.detail.value || "";
|
||||
error.value = "";
|
||||
};
|
||||
const selectRecordClock = (event) => {
|
||||
form.recordClock = event.detail.value || "";
|
||||
error.value = "";
|
||||
};
|
||||
const selectRemindDate = (event) => {
|
||||
form.remindDate = event.detail.value || "";
|
||||
error.value = "";
|
||||
};
|
||||
const selectRemindClock = (event) => {
|
||||
form.remindClock = event.detail.value || "";
|
||||
error.value = "";
|
||||
};
|
||||
const selectLineagePerson = (event) => {
|
||||
form.lineagePersonId =
|
||||
personOptions.value[Number(event.detail.value)]?.value || "";
|
||||
error.value = "";
|
||||
};
|
||||
const selectGrowthType = (event) => {
|
||||
form.recordType = growthTypeOptions.value[Number(event.detail.value)]?.value || "";
|
||||
error.value = "";
|
||||
};
|
||||
const selectFilterPerson = (event) => {
|
||||
filterPersonId.value = filterPersonOptions.value[Number(event.detail.value)]?.value || "";
|
||||
};
|
||||
const selectFilterType = (event) => {
|
||||
filterType.value = filterTypeOptions.value[Number(event.detail.value)]?.value || "";
|
||||
};
|
||||
const openRecordDetail = (record) =>
|
||||
growthRecordDetailDialog.value?.open({
|
||||
...record,
|
||||
typeLabel: growthRecordTypeLabel(record),
|
||||
});
|
||||
const uploadImage = async () => {
|
||||
if (uploading.value || submitting.value) return;
|
||||
uploading.value = true;
|
||||
uploadError.value = "";
|
||||
try {
|
||||
const uploadReceipt = await pickAndUploadImage({
|
||||
requestController: growthMediaUploadRequestController,
|
||||
});
|
||||
if (!pageActive) return;
|
||||
mediaReceipts.value = [...mediaReceipts.value, uploadReceipt];
|
||||
} catch (cause) {
|
||||
if (pageActive && !isImagePickCancelled(cause) && !isRequestCancelled(cause))
|
||||
uploadError.value = getRequestErrorMessage(cause, "图片上传失败,请稍后重试。");
|
||||
} finally {
|
||||
if (pageActive) uploading.value = false;
|
||||
}
|
||||
};
|
||||
const saveGrowthRecord = async () => {
|
||||
if (submitting.value || uploading.value || !valid.value) return;
|
||||
if (!form.recordTitle.trim()) {
|
||||
error.value = "请填写记录标题";
|
||||
return;
|
||||
}
|
||||
const { recordClock, remindDate, remindClock, ...recordForm } = form;
|
||||
const payload = {
|
||||
...recordForm,
|
||||
recordDate: recordTime.value,
|
||||
remindTime: remindTime.value,
|
||||
mediaOssIds: mediaOssIds.value,
|
||||
...(editingRecord.value
|
||||
? {
|
||||
sortOrder: editingRecord.value.sortOrder,
|
||||
status: editingRecord.value.status,
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
const createAttempt = editingRecord.value
|
||||
? null
|
||||
: growthRecordCreateGuard.begin(payload);
|
||||
if (!editingRecord.value && createAttempt === null) {
|
||||
error.value =
|
||||
"上次提交结果暂时无法确认,请先返回成长记录列表检查,避免重复创建。";
|
||||
return;
|
||||
}
|
||||
|
||||
submitting.value = true;
|
||||
error.value = "";
|
||||
try {
|
||||
if (editingRecord.value) {
|
||||
await lifeRecordApi.updateGrowthRecord(
|
||||
genealogyId.value,
|
||||
editingRecord.value.id,
|
||||
payload,
|
||||
{ requestController: growthRecordSaveRequestController },
|
||||
);
|
||||
} else {
|
||||
await lifeRecordApi.createGrowthRecord(genealogyId.value, payload, {
|
||||
requestController: growthRecordSaveRequestController,
|
||||
});
|
||||
}
|
||||
if (!pageActive) return;
|
||||
resetForm();
|
||||
view.value = "list";
|
||||
saveNotice.value = true;
|
||||
await loadRecords();
|
||||
} catch (cause) {
|
||||
if (!pageActive) return;
|
||||
if (
|
||||
!editingRecord.value &&
|
||||
growthRecordCreateGuard.recordFailure(createAttempt, cause)
|
||||
) {
|
||||
error.value =
|
||||
"提交结果暂时无法确认,请先返回成长记录列表检查,避免重复创建。";
|
||||
return;
|
||||
}
|
||||
if (!isRequestCancelled(cause))
|
||||
error.value = getRequestErrorMessage(cause, "成长记录提交失败,请稍后重试。");
|
||||
} finally {
|
||||
if (pageActive) submitting.value = false;
|
||||
}
|
||||
};
|
||||
const requestDeleteRecord = (record) => {
|
||||
if (!record?.canDelete || deleting.value) return;
|
||||
deleteError.value = "";
|
||||
deleteTarget.value = record;
|
||||
deleteConfirmationVisible.value = true;
|
||||
};
|
||||
const closeDeleteConfirmation = () => {
|
||||
if (deleting.value) return;
|
||||
deleteConfirmationVisible.value = false;
|
||||
deleteTarget.value = null;
|
||||
};
|
||||
const deleteRecord = async () => {
|
||||
const record = deleteTarget.value;
|
||||
if (!record?.canDelete || deleting.value) return;
|
||||
deleting.value = true;
|
||||
deleteError.value = "";
|
||||
try {
|
||||
await lifeRecordApi.deleteGrowthRecord(genealogyId.value, record.id, {
|
||||
requestController: growthRecordDeleteController,
|
||||
});
|
||||
if (!pageActive) return;
|
||||
deleteConfirmationVisible.value = false;
|
||||
deleteTarget.value = null;
|
||||
await loadRecords();
|
||||
} catch (cause) {
|
||||
if (!pageActive || isRequestCancelled(cause)) return;
|
||||
deleteError.value = getRequestErrorMessage(cause, "成长记录删除失败,请稍后重试。");
|
||||
deleteConfirmationVisible.value = false;
|
||||
} finally {
|
||||
if (pageActive) deleting.value = false;
|
||||
}
|
||||
};
|
||||
const requestBack = () =>
|
||||
detailTransientOpen.value
|
||||
? (growthRecordDetailDialog.value?.closeTransient(), true)
|
||||
: view.value !== "form"
|
||||
? goBack()
|
||||
: runBackGuard({
|
||||
transientOpen: discardVisible.value,
|
||||
dirty: dirty.value,
|
||||
submitting: submitting.value || uploading.value || detailBusy.value,
|
||||
"close-transient": cancelDiscard,
|
||||
"block-submitting": () => true,
|
||||
"confirm-discard": confirmation.request,
|
||||
});
|
||||
|
||||
onLoad((query) => {
|
||||
genealogyId.value = String(query?.genealogyId || "");
|
||||
personId.value = /^[1-9]\d*$/.test(String(query?.personId || ""))
|
||||
? String(query.personId)
|
||||
: "";
|
||||
resetForm();
|
||||
if (valid.value) {
|
||||
loadRecords();
|
||||
loadPersonOptions();
|
||||
loadGrowthTypes();
|
||||
}
|
||||
else listState.value = "invalid";
|
||||
});
|
||||
onShow(() => {
|
||||
if (valid.value && view.value === "list" && listState.value !== "loading")
|
||||
loadRecords();
|
||||
});
|
||||
onBackPress((event) => handleBackPress(event, requestBack));
|
||||
onUnload(() => {
|
||||
pageActive = false;
|
||||
recordListRequestController.abort();
|
||||
growthEditorDetailRequestController.abort();
|
||||
growthMediaUploadRequestController.abort();
|
||||
growthRecordSaveRequestController.abort();
|
||||
personOptionsController.abort();
|
||||
growthTypeRequestController.abort();
|
||||
growthRecordDeleteController.abort();
|
||||
confirmation.dispose();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@import "../../styles/adaptive-frame-profiles.scss";
|
||||
.record-page {
|
||||
display: flex;
|
||||
min-height: 100vh;
|
||||
flex-direction: column;
|
||||
background: $paper;
|
||||
}
|
||||
.page-header,
|
||||
.page-content {
|
||||
z-index: 1;
|
||||
}
|
||||
.page-content {
|
||||
padding: 18rpx 24rpx 72rpx;
|
||||
}
|
||||
.form-card,
|
||||
.state-card,
|
||||
.record-card {
|
||||
@include adaptive-records-content;
|
||||
}
|
||||
.form-card {
|
||||
box-sizing: border-box;
|
||||
padding: 46rpx;
|
||||
}
|
||||
.form-card > text:first-child {
|
||||
display: block;
|
||||
color: $ink;
|
||||
font-size: clamp(19px, 34rpx, 24px);
|
||||
font-weight: 700;
|
||||
}
|
||||
.form-copy {
|
||||
display: block;
|
||||
margin-top: 12rpx;
|
||||
color: $ink-muted;
|
||||
font-size: clamp(14px, 22rpx, 17px);
|
||||
line-height: 1.55;
|
||||
}
|
||||
.field {
|
||||
margin-top: 16rpx;
|
||||
}
|
||||
.field > text {
|
||||
display: block;
|
||||
margin: 0 8rpx 8rpx;
|
||||
color: $ink;
|
||||
font-size: clamp(14px, 23rpx, 17px);
|
||||
font-weight: 700;
|
||||
}
|
||||
.required-mark {
|
||||
display: inline-block;
|
||||
margin-right: 4rpx;
|
||||
color: $brand-red;
|
||||
font-size: clamp(16px, 26rpx, 20px);
|
||||
transform: translateY(-3rpx);
|
||||
}
|
||||
.field input,
|
||||
.field textarea,
|
||||
.field--picker picker > view {
|
||||
@include adaptive-records-field;
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
min-height: 76rpx;
|
||||
padding: 16rpx 22rpx;
|
||||
color: $ink;
|
||||
font-size: clamp(14px, 23rpx, 17px);
|
||||
}
|
||||
.field textarea {
|
||||
min-height: 150rpx;
|
||||
}
|
||||
.field--picker picker {
|
||||
display: block;
|
||||
}
|
||||
.field--picker .placeholder {
|
||||
color: $ink-muted;
|
||||
}
|
||||
.person-option-note {
|
||||
display: block;
|
||||
margin: 8rpx 8rpx 0;
|
||||
color: $ink-muted;
|
||||
font-size: clamp(13px, 21rpx, 16px);
|
||||
line-height: 1.45;
|
||||
}
|
||||
.upload-field {
|
||||
display: grid;
|
||||
gap: 12rpx;
|
||||
margin-top: 16rpx;
|
||||
padding: 18rpx 22rpx;
|
||||
@include adaptive-records-field;
|
||||
}
|
||||
.upload-field > view > text {
|
||||
display: block;
|
||||
}
|
||||
.upload-field > view > text:first-child {
|
||||
color: $ink;
|
||||
font-size: clamp(14px, 23rpx, 17px);
|
||||
font-weight: 700;
|
||||
}
|
||||
.upload-field > view > text:last-child {
|
||||
margin-top: 6rpx;
|
||||
color: $ink-muted;
|
||||
font-size: clamp(13px, 20rpx, 16px);
|
||||
line-height: 1.45;
|
||||
}
|
||||
.upload-button {
|
||||
justify-self: start;
|
||||
min-height: 88rpx;
|
||||
margin: 0;
|
||||
padding: 0 20rpx;
|
||||
border: 1rpx solid rgba(159, 23, 15, 0.42);
|
||||
border-radius: 8rpx;
|
||||
background: transparent;
|
||||
color: $brand-red;
|
||||
font-size: clamp(14px, 22rpx, 17px);
|
||||
}
|
||||
.upload-field > text,
|
||||
.error {
|
||||
color: $ink-muted;
|
||||
font-size: clamp(13px, 21rpx, 16px);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.error {
|
||||
display: block;
|
||||
margin-top: 12rpx;
|
||||
color: $brand-red;
|
||||
font-size: clamp(14px, 22rpx, 17px);
|
||||
}
|
||||
.form-actions {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(0, 1.45fr);
|
||||
gap: 16rpx;
|
||||
margin-top: 20rpx;
|
||||
}
|
||||
.form-actions .app-button {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
.state-card {
|
||||
display: flex;
|
||||
min-height: 320rpx;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 42rpx;
|
||||
color: $ink;
|
||||
font-size: clamp(16px, 28rpx, 20px);
|
||||
text-align: center;
|
||||
}
|
||||
.state-card .app-button {
|
||||
width: 100%;
|
||||
margin-top: 24rpx;
|
||||
}
|
||||
.state-card text {
|
||||
display: block;
|
||||
}
|
||||
.state-card text:nth-child(2) {
|
||||
margin-top: 14rpx;
|
||||
color: $ink-muted;
|
||||
font-size: clamp(14px, 22rpx, 17px);
|
||||
line-height: 1.55;
|
||||
}
|
||||
.record-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16rpx;
|
||||
}
|
||||
.save-notice {
|
||||
display: block;
|
||||
color: $brand-red;
|
||||
font-size: clamp(14px, 22rpx, 17px);
|
||||
}
|
||||
.delete-error {
|
||||
display: block;
|
||||
color: $brand-red;
|
||||
font-size: clamp(14px, 22rpx, 17px);
|
||||
}
|
||||
.record-filters { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12rpx; }
|
||||
.record-filters picker > view {
|
||||
@include adaptive-records-field;
|
||||
display: flex;
|
||||
box-sizing: border-box;
|
||||
min-height: 78rpx;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 14rpx 18rpx;
|
||||
gap: 10rpx;
|
||||
}
|
||||
.record-filters text { color: $ink-muted; font-size: clamp(13px, 21rpx, 16px); overflow-wrap: anywhere; }
|
||||
.record-filters text:last-child { color: $brand-red; text-align: right; }
|
||||
.filter-empty { display: block; padding: 34rpx 20rpx; color: $ink-muted; font-size: clamp(14px, 23rpx, 17px); text-align: center; }
|
||||
.record-card {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 18rpx;
|
||||
min-height: 128rpx;
|
||||
padding: 28rpx 32rpx;
|
||||
}
|
||||
.record-card > view {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
.record-card text {
|
||||
display: block;
|
||||
}
|
||||
.record-card text:first-child {
|
||||
color: $ink;
|
||||
font-family: STKaiti, KaiTi, serif;
|
||||
font-size: clamp(17px, 30rpx, 22px);
|
||||
font-weight: 700;
|
||||
}
|
||||
.record-card text:not(:first-child) {
|
||||
margin-top: 7rpx;
|
||||
color: $ink-muted;
|
||||
font-size: clamp(13px, 21rpx, 16px);
|
||||
line-height: 1.45;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,623 @@
|
||||
<template>
|
||||
<view class="life-page">
|
||||
<ModulePageBackground module="records" />
|
||||
<view class="page-header">
|
||||
<PageHeader
|
||||
title="人生大事"
|
||||
:action="valid && view === 'list' ? '新建' : ''"
|
||||
custom-back
|
||||
@back="requestBack"
|
||||
@action="openCreate"
|
||||
/>
|
||||
</view>
|
||||
<view class="page-content">
|
||||
<view v-if="view === 'form'" class="form-card">
|
||||
<text>{{ editingEvent ? "修改人生大事" : "新建人生大事" }}</text>
|
||||
<text class="form-copy">红色 * 为必填项;请按实际情况选择事件类型和日期。</text>
|
||||
<view class="field field--picker">
|
||||
<text><text class="required-mark">*</text>事件类型</text>
|
||||
<picker :range="eventTypeLabels" :value="eventTypeIndex" @change="selectEventType">
|
||||
<view :class="{ placeholder: !form.eventType }">{{ eventTypeLabel || "请选择" }}</view>
|
||||
</picker>
|
||||
</view>
|
||||
<view class="field">
|
||||
<text><text class="required-mark">*</text>事件标题</text>
|
||||
<input v-model="form.eventTitle" placeholder="请输入事件标题" @input="error = ''" />
|
||||
</view>
|
||||
<view class="field field--picker">
|
||||
<text><text class="required-mark">*</text>发生日期</text>
|
||||
<picker mode="date" :value="form.eventDate" @change="selectEventDate">
|
||||
<view :class="{ placeholder: !form.eventDate }">{{ form.eventDate || "请选择" }}</view>
|
||||
</picker>
|
||||
</view>
|
||||
<view class="field field--picker">
|
||||
<text><text class="required-mark">*</text>日期填写方式</text>
|
||||
<picker :range="datePrecisionLabels" :value="datePrecisionIndex" @change="selectDatePrecision">
|
||||
<view :class="{ placeholder: !form.datePrecision }">{{ datePrecisionLabel || "请选择" }}</view>
|
||||
</picker>
|
||||
</view>
|
||||
<view class="field field--textarea">
|
||||
<text>事件内容</text>
|
||||
<textarea v-model="form.eventContent" auto-height placeholder="记录这段家族记忆" @input="error = ''" />
|
||||
</view>
|
||||
<view class="field">
|
||||
<text>发生地点</text>
|
||||
<input v-model="form.eventPlace" placeholder="请输入地点" @input="error = ''" />
|
||||
</view>
|
||||
<view class="field">
|
||||
<text>资料来源</text>
|
||||
<input v-model="form.sourceDescription" placeholder="例如:家谱手稿" @input="error = ''" />
|
||||
</view>
|
||||
<view class="upload-field">
|
||||
<view>
|
||||
<text>相关图片</text>
|
||||
<text>图片上传成功后,会随这条记录一起保存。</text>
|
||||
</view>
|
||||
<button class="upload-button" :disabled="uploading || submitting" @click="uploadImage">
|
||||
{{ uploading ? "上传中…" : "添加图片" }}
|
||||
</button>
|
||||
<text v-for="(receipt, index) in mediaReceipts" :key="`${receipt.ossId}-${index}`">
|
||||
已上传:{{ receipt.fileName || "图片" }}
|
||||
</text>
|
||||
<text v-if="uploadError" class="error">{{ uploadError }}</text>
|
||||
</view>
|
||||
<text v-if="error" class="error">{{ error }}</text>
|
||||
<view class="form-actions">
|
||||
<AppButton type="secondary" label="取消" @click="cancelCreate" />
|
||||
<AppButton :disabled="submitting || uploading" :label="submitting ? '正在保存' : editingEvent ? '保存修改' : '提交人生大事'" @click="saveLifeEvent" />
|
||||
</view>
|
||||
</view>
|
||||
<view v-else-if="!valid" class="state-card">
|
||||
<text>暂时无法打开人生大事</text>
|
||||
<text>未获取到当前人物,请从人物档案重新进入。</text>
|
||||
<AppButton block label="返回上一页" @click="requestBack" />
|
||||
</view>
|
||||
<view v-else-if="listState === 'loading'" class="state-card">
|
||||
<AppLoading text="正在读取人生大事" />
|
||||
</view>
|
||||
<view v-else-if="listState === 'error'" class="state-card">
|
||||
<text>暂时无法读取人生大事</text>
|
||||
<text>请检查网络后重新加载。</text>
|
||||
<AppButton block type="secondary" label="重新加载" @click="loadEvents" />
|
||||
</view>
|
||||
<view v-else-if="listState === 'empty'" class="state-card">
|
||||
<text>还没有人生大事</text>
|
||||
<AppButton block label="新建人生大事" @click="openCreate" />
|
||||
</view>
|
||||
<view v-else class="event-list">
|
||||
<text v-if="saveNotice" class="save-notice">人生大事已保存。</text>
|
||||
<view v-for="item in events" :key="item.id" class="event-card">
|
||||
<text>{{ item.title }}</text>
|
||||
<text>{{ eventTypeText(item.type) }} · {{ formatEventDate(item) }}</text>
|
||||
<text v-if="item.content">{{ item.content }}</text>
|
||||
<text v-if="item.place">地点:{{ item.place }}</text>
|
||||
<text v-if="item.sourceDescription">来源:{{ item.sourceDescription }}</text>
|
||||
<text v-if="item.mediaFiles.length">已关联 {{ item.mediaFiles.length }} 张图片</text>
|
||||
<view v-if="item.canEdit || item.canDelete" class="event-card__actions">
|
||||
<AppButton v-if="item.canEdit" compact type="secondary" label="修改" @click="openEdit(item)" />
|
||||
<AppButton v-if="item.canDelete" compact type="secondary" label="删除" @click="requestDelete(item)" />
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<AppDialog
|
||||
:visible="discardVisible"
|
||||
title="放弃人生大事?"
|
||||
message="尚未提交的内容将被清除。"
|
||||
confirm-text="放弃并返回"
|
||||
cancel-text="继续填写"
|
||||
show-cancel
|
||||
:close-on-mask="false"
|
||||
@confirm="confirmDiscard"
|
||||
@cancel="cancelDiscard"
|
||||
/>
|
||||
<AppDialog
|
||||
:visible="deleteVisible"
|
||||
eyebrow="删除人生大事"
|
||||
title="确认删除这条记录?"
|
||||
message="删除后无法恢复,请确认不再需要这条记录。"
|
||||
:confirm-text="deleting ? '正在删除' : '确认删除'"
|
||||
cancel-text="取消"
|
||||
show-cancel
|
||||
:close-on-mask="false"
|
||||
@confirm="confirmDelete"
|
||||
@cancel="deleteVisible = false"
|
||||
/>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, reactive, ref } from "vue";
|
||||
import { onBackPress, onLoad, 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";
|
||||
import {
|
||||
LIFE_EVENT_DATE_PRECISION_OPTIONS as datePrecisionOptions,
|
||||
LIFE_EVENT_TYPE_OPTIONS as eventTypeOptions
|
||||
} from "@/services/api/life-record-contract.js";
|
||||
import {
|
||||
createRequestController,
|
||||
isRequestCancelled
|
||||
} from "@/services/api/request-controller.js";
|
||||
import { lifeRecordApi } from "@/services/api/life-record-service.js";
|
||||
import { getRequestErrorMessage } from "@/services/api/request-error-message.js";
|
||||
import { createDiscardConfirmation } from "@/utils/discard-confirmation.js";
|
||||
import { createNonIdempotentWriteGuard } from "@/utils/non-idempotent-write-guard.js";
|
||||
import {
|
||||
isImagePickCancelled,
|
||||
pickAndUploadImage,
|
||||
} from "@/utils/media-upload.js";
|
||||
import { goBack, handleBackPress, runBackGuard } from "@/utils/navigation/gateway.js";
|
||||
|
||||
const genealogyId = ref("");
|
||||
const personId = ref("");
|
||||
const view = ref("list");
|
||||
const listState = ref("loading");
|
||||
const events = ref([]);
|
||||
const saveNotice = ref(false);
|
||||
const submitting = ref(false);
|
||||
const uploading = ref(false);
|
||||
const error = ref("");
|
||||
const uploadError = ref("");
|
||||
const discardVisible = ref(false);
|
||||
const mediaReceipts = ref([]);
|
||||
const editingEvent = ref(null);
|
||||
const deleteTarget = ref(null);
|
||||
const deleteVisible = ref(false);
|
||||
const deleting = ref(false);
|
||||
const form = reactive({
|
||||
eventType: "",
|
||||
eventTitle: "",
|
||||
eventDate: "",
|
||||
datePrecision: "",
|
||||
eventContent: "",
|
||||
eventPlace: "",
|
||||
sourceDescription: "",
|
||||
});
|
||||
const eventListController = createRequestController();
|
||||
const eventImageUploadController = createRequestController();
|
||||
const eventSaveController = createRequestController();
|
||||
const eventDeleteController = createRequestController();
|
||||
const lifeEventCreateGuard = createNonIdempotentWriteGuard();
|
||||
let isPageActive = true;
|
||||
const valid = computed(
|
||||
() => /^[1-9]\d*$/.test(genealogyId.value) && /^[1-9]\d*$/.test(personId.value),
|
||||
);
|
||||
const eventTypeLabels = computed(() => eventTypeOptions.map((item) => item.label));
|
||||
const datePrecisionLabels = computed(() => datePrecisionOptions.map((item) => item.label));
|
||||
const eventTypeIndex = computed(() => Math.max(0, eventTypeOptions.findIndex((item) => item.value === form.eventType)));
|
||||
const datePrecisionIndex = computed(() => Math.max(0, datePrecisionOptions.findIndex((item) => item.value === form.datePrecision)));
|
||||
const eventTypeLabel = computed(() => eventTypeText(form.eventType));
|
||||
const datePrecisionLabel = computed(() => datePrecisionText(form.datePrecision));
|
||||
const mediaOssIds = computed(() => mediaReceipts.value.map((item) => item.ossId));
|
||||
const dirty = computed(
|
||||
() => Object.values(form).some((value) => String(value).trim()) || mediaReceipts.value.length > 0,
|
||||
);
|
||||
const confirmation = createDiscardConfirmation((visible) => {
|
||||
discardVisible.value = visible;
|
||||
});
|
||||
|
||||
const eventTypeText = (value) =>
|
||||
eventTypeOptions.find((item) => item.value === value)?.label || "未分类";
|
||||
const datePrecisionText = (value) =>
|
||||
datePrecisionOptions.find((item) => item.value === value)?.label || "";
|
||||
const formatEventDate = (event) => {
|
||||
if (event.datePrecision === "YEAR") return event.date.slice(0, 4);
|
||||
if (event.datePrecision === "MONTH") return event.date.slice(0, 7);
|
||||
return event.date;
|
||||
};
|
||||
const resetForm = () => {
|
||||
Object.assign(form, {
|
||||
eventType: "",
|
||||
eventTitle: "",
|
||||
eventDate: "",
|
||||
datePrecision: "",
|
||||
eventContent: "",
|
||||
eventPlace: "",
|
||||
sourceDescription: "",
|
||||
});
|
||||
mediaReceipts.value = [];
|
||||
editingEvent.value = null;
|
||||
error.value = "";
|
||||
uploadError.value = "";
|
||||
};
|
||||
const confirmDiscard = () => {
|
||||
confirmation.confirm();
|
||||
resetForm();
|
||||
view.value = "list";
|
||||
};
|
||||
const cancelDiscard = confirmation.cancel;
|
||||
const loadEvents = async () => {
|
||||
if (!valid.value) return;
|
||||
eventListController.abort();
|
||||
listState.value = "loading";
|
||||
try {
|
||||
const rows = await lifeRecordApi.getLifeEvents(genealogyId.value, personId.value, {
|
||||
requestController: eventListController,
|
||||
});
|
||||
if (!isPageActive) return;
|
||||
events.value = rows;
|
||||
listState.value = rows.length ? "ready" : "empty";
|
||||
} catch (cause) {
|
||||
if (!isPageActive || isRequestCancelled(cause)) return;
|
||||
listState.value = "error";
|
||||
}
|
||||
};
|
||||
const openCreate = () => {
|
||||
if (!valid.value) return;
|
||||
saveNotice.value = false;
|
||||
resetForm();
|
||||
view.value = "form";
|
||||
};
|
||||
const openEdit = (item) => {
|
||||
if (!item?.canEdit || submitting.value || uploading.value) return;
|
||||
saveNotice.value = false;
|
||||
resetForm();
|
||||
editingEvent.value = item;
|
||||
Object.assign(form, {
|
||||
eventType: item.type,
|
||||
eventTitle: item.title,
|
||||
eventDate: item.date,
|
||||
datePrecision: item.datePrecision,
|
||||
eventContent: item.content,
|
||||
eventPlace: item.place,
|
||||
sourceDescription: item.sourceDescription,
|
||||
});
|
||||
mediaReceipts.value = item.mediaFiles.map((file) => ({ ossId: file.ossId, fileName: file.fileName }));
|
||||
view.value = "form";
|
||||
};
|
||||
const cancelCreate = () => {
|
||||
resetForm();
|
||||
view.value = "list";
|
||||
};
|
||||
const selectEventType = (event) => {
|
||||
form.eventType = eventTypeOptions[Number(event.detail.value)]?.value || "";
|
||||
error.value = "";
|
||||
};
|
||||
const selectEventDate = (event) => {
|
||||
form.eventDate = event.detail.value || "";
|
||||
error.value = "";
|
||||
};
|
||||
const selectDatePrecision = (event) => {
|
||||
form.datePrecision = datePrecisionOptions[Number(event.detail.value)]?.value || "";
|
||||
error.value = "";
|
||||
};
|
||||
const uploadImage = async () => {
|
||||
if (uploading.value || submitting.value) return;
|
||||
uploading.value = true;
|
||||
uploadError.value = "";
|
||||
try {
|
||||
const uploadReceipt = await pickAndUploadImage({
|
||||
requestController: eventImageUploadController,
|
||||
});
|
||||
if (!isPageActive) return;
|
||||
mediaReceipts.value = [...mediaReceipts.value, uploadReceipt];
|
||||
} catch (cause) {
|
||||
if (
|
||||
isPageActive &&
|
||||
!isImagePickCancelled(cause) &&
|
||||
!isRequestCancelled(cause)
|
||||
) {
|
||||
uploadError.value = getRequestErrorMessage(cause, "图片上传失败,请稍后重试。");
|
||||
}
|
||||
} finally {
|
||||
if (isPageActive) uploading.value = false;
|
||||
}
|
||||
};
|
||||
const saveLifeEvent = async () => {
|
||||
if (submitting.value || uploading.value || !valid.value) return;
|
||||
if (!form.eventType || !form.eventTitle.trim() || !form.eventDate || !form.datePrecision) {
|
||||
error.value = "请填写事件类型、标题和发生日期,并选择日期填写方式。";
|
||||
return;
|
||||
}
|
||||
const payload = {
|
||||
eventType: form.eventType,
|
||||
eventTitle: form.eventTitle,
|
||||
eventDate: form.eventDate,
|
||||
datePrecision: form.datePrecision,
|
||||
eventContent: form.eventContent,
|
||||
eventPlace: form.eventPlace,
|
||||
sourceDescription: form.sourceDescription,
|
||||
mediaOssIds: mediaOssIds.value,
|
||||
};
|
||||
const createAttempt = editingEvent.value
|
||||
? null
|
||||
: lifeEventCreateGuard.begin(payload);
|
||||
if (!editingEvent.value && createAttempt === null) {
|
||||
error.value =
|
||||
"上次提交结果暂时无法确认,请先返回人生大事列表检查,避免重复创建。";
|
||||
return;
|
||||
}
|
||||
|
||||
submitting.value = true;
|
||||
error.value = "";
|
||||
try {
|
||||
if (editingEvent.value) {
|
||||
await lifeRecordApi.updateLifeEvent(
|
||||
genealogyId.value,
|
||||
personId.value,
|
||||
editingEvent.value.id,
|
||||
payload,
|
||||
{ requestController: eventSaveController },
|
||||
);
|
||||
} else {
|
||||
await lifeRecordApi.createLifeEvent(genealogyId.value, personId.value, payload, {
|
||||
requestController: eventSaveController,
|
||||
});
|
||||
}
|
||||
if (!isPageActive) return;
|
||||
resetForm();
|
||||
view.value = "list";
|
||||
saveNotice.value = true;
|
||||
await loadEvents();
|
||||
} catch (cause) {
|
||||
if (!isPageActive) return;
|
||||
if (
|
||||
!editingEvent.value &&
|
||||
lifeEventCreateGuard.recordFailure(createAttempt, cause)
|
||||
) {
|
||||
error.value =
|
||||
"提交结果暂时无法确认,请先返回人生大事列表检查,避免重复创建。";
|
||||
return;
|
||||
}
|
||||
if (!isRequestCancelled(cause))
|
||||
error.value = getRequestErrorMessage(cause, "人生大事提交失败,请稍后重试。");
|
||||
} finally {
|
||||
if (isPageActive) submitting.value = false;
|
||||
}
|
||||
};
|
||||
const requestDelete = (item) => {
|
||||
if (!item?.canDelete || deleting.value) return;
|
||||
deleteTarget.value = item;
|
||||
deleteVisible.value = true;
|
||||
};
|
||||
const confirmDelete = async () => {
|
||||
if (!deleteTarget.value?.canDelete || deleting.value) return;
|
||||
const event = deleteTarget.value;
|
||||
deleting.value = true;
|
||||
try {
|
||||
await lifeRecordApi.deleteLifeEvent(
|
||||
genealogyId.value,
|
||||
personId.value,
|
||||
event.id,
|
||||
{ requestController: eventDeleteController },
|
||||
);
|
||||
if (!isPageActive) return;
|
||||
deleteVisible.value = false;
|
||||
deleteTarget.value = null;
|
||||
await loadEvents();
|
||||
} catch (cause) {
|
||||
if (isPageActive && !isRequestCancelled(cause)) {
|
||||
error.value = getRequestErrorMessage(cause, "这条人生大事删除失败,请稍后重试。");
|
||||
deleteVisible.value = false;
|
||||
}
|
||||
} finally {
|
||||
if (isPageActive) deleting.value = false;
|
||||
}
|
||||
};
|
||||
const requestBack = () =>
|
||||
view.value !== "form"
|
||||
? goBack()
|
||||
: runBackGuard({
|
||||
transientOpen: discardVisible.value,
|
||||
dirty: dirty.value,
|
||||
submitting: submitting.value || uploading.value,
|
||||
"close-transient": cancelDiscard,
|
||||
"block-submitting": () => true,
|
||||
"confirm-discard": confirmation.request,
|
||||
});
|
||||
|
||||
onLoad((query) => {
|
||||
genealogyId.value = String(query?.genealogyId || "");
|
||||
personId.value = String(query?.personId || "");
|
||||
resetForm();
|
||||
if (valid.value) loadEvents();
|
||||
else listState.value = "invalid";
|
||||
});
|
||||
onShow(() => {
|
||||
if (valid.value && view.value === "list" && listState.value !== "loading") loadEvents();
|
||||
});
|
||||
onBackPress((event) => handleBackPress(event, requestBack));
|
||||
onUnload(() => {
|
||||
isPageActive = false;
|
||||
eventListController.abort();
|
||||
eventImageUploadController.abort();
|
||||
eventSaveController.abort();
|
||||
eventDeleteController.abort();
|
||||
confirmation.dispose();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@import "../../styles/adaptive-frame-profiles.scss";
|
||||
.life-page {
|
||||
display: flex;
|
||||
min-height: 100vh;
|
||||
flex-direction: column;
|
||||
background: $paper;
|
||||
}
|
||||
.page-header,
|
||||
.page-content {
|
||||
z-index: 1;
|
||||
}
|
||||
.page-content {
|
||||
padding: 18rpx 24rpx 72rpx;
|
||||
}
|
||||
.form-card,
|
||||
.state-card,
|
||||
.event-card {
|
||||
@include adaptive-records-content;
|
||||
}
|
||||
.form-card {
|
||||
box-sizing: border-box;
|
||||
padding: 46rpx;
|
||||
}
|
||||
.form-card > text:first-child {
|
||||
display: block;
|
||||
color: $ink;
|
||||
font-size: clamp(19px, 34rpx, 24px);
|
||||
font-weight: 700;
|
||||
}
|
||||
.form-copy {
|
||||
display: block;
|
||||
margin-top: 12rpx;
|
||||
color: $ink-muted;
|
||||
font-size: clamp(14px, 22rpx, 17px);
|
||||
line-height: 1.55;
|
||||
}
|
||||
.field {
|
||||
margin-top: 16rpx;
|
||||
}
|
||||
.field > text {
|
||||
display: block;
|
||||
margin: 0 8rpx 8rpx;
|
||||
color: $ink;
|
||||
font-size: clamp(14px, 23rpx, 17px);
|
||||
font-weight: 700;
|
||||
}
|
||||
.required-mark {
|
||||
display: inline-block;
|
||||
margin-right: 4rpx;
|
||||
color: $brand-red;
|
||||
font-size: clamp(16px, 26rpx, 20px);
|
||||
transform: translateY(-3rpx);
|
||||
}
|
||||
.field input,
|
||||
.field textarea,
|
||||
.field--picker picker > view {
|
||||
@include adaptive-records-field;
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
min-height: 76rpx;
|
||||
padding: 16rpx 22rpx;
|
||||
color: $ink;
|
||||
font-size: clamp(14px, 23rpx, 17px);
|
||||
}
|
||||
.field textarea {
|
||||
min-height: 150rpx;
|
||||
}
|
||||
.field--picker picker {
|
||||
display: block;
|
||||
}
|
||||
.field--picker .placeholder {
|
||||
color: $ink-muted;
|
||||
}
|
||||
.upload-field {
|
||||
display: grid;
|
||||
gap: 12rpx;
|
||||
margin-top: 16rpx;
|
||||
padding: 18rpx 22rpx;
|
||||
@include adaptive-records-field;
|
||||
}
|
||||
.upload-field > view > text {
|
||||
display: block;
|
||||
}
|
||||
.upload-field > view > text:first-child {
|
||||
color: $ink;
|
||||
font-size: clamp(14px, 23rpx, 17px);
|
||||
font-weight: 700;
|
||||
}
|
||||
.upload-field > view > text:last-child {
|
||||
margin-top: 6rpx;
|
||||
color: $ink-muted;
|
||||
font-size: clamp(13px, 20rpx, 16px);
|
||||
line-height: 1.45;
|
||||
}
|
||||
.upload-button {
|
||||
justify-self: start;
|
||||
min-height: 88rpx;
|
||||
margin: 0;
|
||||
padding: 0 20rpx;
|
||||
border: 1rpx solid rgba(159, 23, 15, 0.42);
|
||||
border-radius: 8rpx;
|
||||
background: transparent;
|
||||
color: $brand-red;
|
||||
font-size: clamp(14px, 22rpx, 17px);
|
||||
}
|
||||
.upload-field > text,
|
||||
.error {
|
||||
color: $ink-muted;
|
||||
font-size: clamp(13px, 21rpx, 16px);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.error {
|
||||
display: block;
|
||||
margin-top: 12rpx;
|
||||
color: $brand-red;
|
||||
font-size: clamp(14px, 22rpx, 17px);
|
||||
}
|
||||
.form-actions {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(0, 1.45fr);
|
||||
gap: 16rpx;
|
||||
margin-top: 20rpx;
|
||||
}
|
||||
.form-actions .app-button {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
.state-card {
|
||||
display: flex;
|
||||
min-height: 320rpx;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 42rpx;
|
||||
color: $ink;
|
||||
font-size: clamp(16px, 28rpx, 20px);
|
||||
text-align: center;
|
||||
}
|
||||
.state-card .app-button {
|
||||
width: 100%;
|
||||
margin-top: 24rpx;
|
||||
}
|
||||
.state-card text {
|
||||
display: block;
|
||||
}
|
||||
.state-card text:nth-child(2) {
|
||||
margin-top: 14rpx;
|
||||
color: $ink-muted;
|
||||
font-size: clamp(14px, 22rpx, 17px);
|
||||
line-height: 1.55;
|
||||
}
|
||||
.event-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16rpx;
|
||||
}
|
||||
.save-notice {
|
||||
display: block;
|
||||
color: $brand-red;
|
||||
font-size: clamp(14px, 22rpx, 17px);
|
||||
}
|
||||
.event-card {
|
||||
min-height: 128rpx;
|
||||
padding: 28rpx 32rpx;
|
||||
}
|
||||
.event-card text {
|
||||
display: block;
|
||||
}
|
||||
.event-card text:first-child {
|
||||
color: $ink;
|
||||
font-family: STKaiti, KaiTi, serif;
|
||||
font-size: clamp(17px, 30rpx, 22px);
|
||||
font-weight: 700;
|
||||
}
|
||||
.event-card text:not(:first-child) {
|
||||
margin-top: 7rpx;
|
||||
color: $ink-muted;
|
||||
font-size: clamp(13px, 21rpx, 16px);
|
||||
line-height: 1.45;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.event-card__actions {
|
||||
display: flex;
|
||||
gap: 12rpx;
|
||||
justify-content: flex-end;
|
||||
margin-top: 18rpx;
|
||||
}
|
||||
.event-card__actions .app-button { width: 154rpx; min-height: 66rpx; }
|
||||
</style>
|
||||
@@ -0,0 +1,734 @@
|
||||
<template>
|
||||
<view class="memo-page">
|
||||
<ModulePageBackground module="records" />
|
||||
<view class="page-header"
|
||||
><PageHeader
|
||||
title="家族备忘"
|
||||
:action="valid && view === 'list' ? '新建' : ''"
|
||||
custom-back
|
||||
@back="requestBack"
|
||||
@action="openCreate"
|
||||
/></view>
|
||||
<view class="page-content">
|
||||
<view v-if="view === 'form'" class="form-card">
|
||||
<text>{{ isEdit ? '编辑家族备忘' : '新建家族备忘' }}</text>
|
||||
<text class="form-copy">{{ isEdit ? '原有关联图片、完成状态和排序会随本次保存保留。' : '红色 * 为必填项,其余内容可按需补充。' }}</text>
|
||||
<view class="field"
|
||||
><text><text class="required-mark">*</text>备忘标题</text
|
||||
><input
|
||||
v-model="form.memoTitle"
|
||||
maxlength="40"
|
||||
placeholder="请输入备忘标题"
|
||||
@input="error = ''"
|
||||
/></view>
|
||||
<picker mode="date" :value="form.remindDate" @change="selectRemindDate"
|
||||
><view class="field field--picker"
|
||||
><text>提醒日期</text
|
||||
><text>{{ form.remindDate || "请选择" }}</text></view
|
||||
></picker
|
||||
>
|
||||
<picker
|
||||
mode="time"
|
||||
:value="form.remindClock"
|
||||
@change="selectRemindClock"
|
||||
><view class="field field--picker"
|
||||
><text>提醒时间</text
|
||||
><text>{{ form.remindClock || "请选择" }}</text></view
|
||||
></picker
|
||||
>
|
||||
<view class="field field--textarea"
|
||||
><text>备忘内容</text
|
||||
><textarea
|
||||
v-model="form.memoContent"
|
||||
auto-height
|
||||
maxlength="1200"
|
||||
placeholder="记录需要提醒的事情"
|
||||
@input="error = ''"
|
||||
/>
|
||||
</view>
|
||||
<view class="upload-field">
|
||||
<view
|
||||
><text>相关图片</text
|
||||
><text
|
||||
>图片上传成功后,会随备忘一起保存。</text
|
||||
></view
|
||||
>
|
||||
<button
|
||||
class="upload-button"
|
||||
:disabled="uploading || submitting"
|
||||
@click="uploadImage"
|
||||
>
|
||||
{{ uploading ? "上传中…" : "添加图片" }}
|
||||
</button>
|
||||
<text
|
||||
v-for="(receipt, index) in mediaReceipts"
|
||||
:key="`${receipt.ossId}-${index}`"
|
||||
>已上传:{{ receipt.fileName || "图片" }}</text
|
||||
>
|
||||
<text v-if="uploadError" class="error">{{ uploadError }}</text>
|
||||
</view>
|
||||
<text v-if="error" class="error">{{ error }}</text>
|
||||
<view class="form-actions"
|
||||
><AppButton
|
||||
type="secondary"
|
||||
label="取消"
|
||||
@click="cancelCreate" /><AppButton
|
||||
:disabled="submitting || uploading"
|
||||
:label="submitting ? '正在提交' : '提交备忘'"
|
||||
@click="saveMemo"
|
||||
/></view>
|
||||
</view>
|
||||
<view v-else-if="!valid" class="state-card"
|
||||
><text>暂时无法打开家族备忘</text
|
||||
><AppButton block label="返回上一页" @click="requestBack"
|
||||
/></view>
|
||||
<view v-else-if="listState === 'loading'" class="state-card"
|
||||
><AppLoading text="正在读取家族备忘"
|
||||
/></view>
|
||||
<view v-else-if="listState === 'error'" class="state-card"
|
||||
><text>暂时无法读取家族备忘</text
|
||||
><AppButton block type="secondary" label="重新加载" @click="loadMemos"
|
||||
/></view>
|
||||
<view v-else-if="listState === 'empty'" class="state-card"
|
||||
><text>还没有家族备忘</text
|
||||
><AppButton block label="新建备忘" @click="openCreate"
|
||||
/></view>
|
||||
<view v-else class="memo-list">
|
||||
<text v-if="saveNotice" class="save-notice"
|
||||
>备忘已保存。</text
|
||||
>
|
||||
<text v-if="deleteError" class="delete-error">{{ deleteError }}</text>
|
||||
<view v-for="item in memos" :key="item.id" class="memo-card" role="button" :aria-label="`查看${item.title}详情`" @click="openMemoDetail(item)">
|
||||
<view class="memo-card__copy">
|
||||
<text>{{ item.title }}</text>
|
||||
<text v-if="item.remindTime">{{ item.remindTime }}</text>
|
||||
<text v-if="item.content" class="memo-card__content">{{ item.content }}</text>
|
||||
</view>
|
||||
<view v-if="item.canEdit || item.canDelete" class="memo-card__actions">
|
||||
<AppButton
|
||||
v-if="item.canEdit"
|
||||
compact
|
||||
type="secondary"
|
||||
label="编辑"
|
||||
@click.stop="openEdit(item)"
|
||||
/>
|
||||
<AppButton
|
||||
v-if="item.canDelete"
|
||||
compact
|
||||
type="secondary"
|
||||
label="删除"
|
||||
@click.stop="requestDeleteMemo(item)"
|
||||
/>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<AppDialog
|
||||
:visible="Boolean(detailTarget)"
|
||||
eyebrow="备忘详情"
|
||||
:title="detailTarget?.title || '家族备忘'"
|
||||
confirm-text="关闭"
|
||||
:close-on-mask="detailState !== 'loading'"
|
||||
@confirm="closeMemoDetail"
|
||||
@cancel="closeMemoDetail"
|
||||
>
|
||||
<view class="detail-content">
|
||||
<AppLoading v-if="detailState === 'loading'" text="正在读取完整备忘" />
|
||||
<text v-else-if="detailState === 'error'" class="detail-error">{{ detailError }}</text>
|
||||
<template v-else>
|
||||
<text>提醒时间:{{ detailTarget?.remindTime || "未设置" }}</text>
|
||||
<text>完成状态:{{ detailTarget?.completed === "1" ? "已完成" : "未完成" }}</text>
|
||||
<text class="detail-content__body">{{ detailTarget?.content || "未填写备忘内容" }}</text>
|
||||
<view v-if="detailTarget?.mediaFiles?.length" class="detail-media">
|
||||
<image v-for="file in detailTarget.mediaFiles" :key="file.fileId" :src="file.accessUrl" mode="aspectFill" role="button" aria-label="查看备忘图片" @click="previewDetailMedia(file)" />
|
||||
</view>
|
||||
</template>
|
||||
</view>
|
||||
</AppDialog>
|
||||
<AppDialog
|
||||
:visible="discardVisible"
|
||||
title="放弃家族备忘?"
|
||||
message="尚未提交的内容将被清除。"
|
||||
confirm-text="放弃并返回"
|
||||
cancel-text="继续填写"
|
||||
show-cancel
|
||||
:close-on-mask="false"
|
||||
@confirm="confirmDiscard"
|
||||
@cancel="cancelDiscard"
|
||||
/>
|
||||
<AppDialog
|
||||
:visible="deleteConfirmationVisible"
|
||||
title="删除这条家族备忘?"
|
||||
message="删除后无法恢复,请确认当前内容不再需要。"
|
||||
confirm-text="确认删除"
|
||||
cancel-text="保留备忘"
|
||||
show-cancel
|
||||
:close-on-mask="false"
|
||||
@confirm="deleteMemo"
|
||||
@cancel="closeDeleteConfirmation"
|
||||
/>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, reactive, ref } from "vue";
|
||||
import { onBackPress, onLoad, 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";
|
||||
import {
|
||||
createRequestController,
|
||||
isRequestCancelled
|
||||
} from "@/services/api/request-controller.js";
|
||||
import { lifeRecordApi } from "@/services/api/life-record-service.js";
|
||||
import { getRequestErrorMessage } from "@/services/api/request-error-message.js";
|
||||
import { createDiscardConfirmation } from "@/utils/discard-confirmation.js";
|
||||
import { createNonIdempotentWriteGuard } from "@/utils/non-idempotent-write-guard.js";
|
||||
import {
|
||||
isImagePickCancelled,
|
||||
pickAndUploadImage,
|
||||
} from "@/utils/media-upload.js";
|
||||
import { goBack, handleBackPress, runBackGuard } from "@/utils/navigation/gateway.js";
|
||||
|
||||
const genealogyId = ref("");
|
||||
const view = ref("list");
|
||||
const listState = ref("loading");
|
||||
const memos = ref([]);
|
||||
const saveNotice = ref(false);
|
||||
const submitting = ref(false);
|
||||
const uploading = ref(false);
|
||||
const error = ref("");
|
||||
const uploadError = ref("");
|
||||
const discardVisible = ref(false);
|
||||
const editingMemo = ref(null);
|
||||
const formBaseline = ref("");
|
||||
const deleteConfirmationVisible = ref(false);
|
||||
const deleteTarget = ref(null);
|
||||
const deleting = ref(false);
|
||||
const deleteError = ref("");
|
||||
const detailTarget = ref(null);
|
||||
const detailState = ref("idle");
|
||||
const detailError = ref("");
|
||||
const pendingMemoId = ref("");
|
||||
const form = reactive({
|
||||
memoTitle: "",
|
||||
remindDate: "",
|
||||
remindClock: "",
|
||||
memoContent: "",
|
||||
});
|
||||
const mediaReceipts = ref([]);
|
||||
const memoListRequestController = createRequestController();
|
||||
const memoEditorDetailRequestController = createRequestController();
|
||||
const memoMediaUploadRequestController = createRequestController();
|
||||
const memoSaveRequestController = createRequestController();
|
||||
const memoDeletionRequestController = createRequestController();
|
||||
const memoDetailRequestController = createRequestController();
|
||||
const memoCreateGuard = createNonIdempotentWriteGuard();
|
||||
let pageActive = true;
|
||||
const valid = computed(() => /^[1-9]\d*$/.test(genealogyId.value));
|
||||
const isEdit = computed(() => Boolean(editingMemo.value));
|
||||
const remindTime = computed(() =>
|
||||
form.remindDate ? `${form.remindDate} ${form.remindClock || "00:00"}:00` : "",
|
||||
);
|
||||
const mediaOssIds = computed(() =>
|
||||
mediaReceipts.value.map((item) => item.ossId).join(","),
|
||||
);
|
||||
const formSnapshot = computed(() =>
|
||||
JSON.stringify({ ...form, mediaOssIds: mediaOssIds.value }),
|
||||
);
|
||||
const dirty = computed(() =>
|
||||
isEdit.value
|
||||
? Boolean(formBaseline.value) && formSnapshot.value !== formBaseline.value
|
||||
: Object.values(form).some((value) => String(value).trim()) ||
|
||||
mediaReceipts.value.length > 0,
|
||||
);
|
||||
const confirmation = createDiscardConfirmation((visible) => {
|
||||
discardVisible.value = visible;
|
||||
});
|
||||
const confirmDiscard = () => {
|
||||
confirmation.confirm();
|
||||
resetForm();
|
||||
view.value = "list";
|
||||
};
|
||||
const cancelDiscard = confirmation.cancel;
|
||||
|
||||
const resetForm = () => {
|
||||
Object.assign(form, {
|
||||
memoTitle: "",
|
||||
remindDate: "",
|
||||
remindClock: "",
|
||||
memoContent: "",
|
||||
});
|
||||
mediaReceipts.value = [];
|
||||
editingMemo.value = null;
|
||||
formBaseline.value = "";
|
||||
error.value = "";
|
||||
uploadError.value = "";
|
||||
};
|
||||
const loadMemos = async () => {
|
||||
if (!valid.value) return;
|
||||
memoListRequestController.abort();
|
||||
listState.value = "loading";
|
||||
try {
|
||||
const rows = await lifeRecordApi.getMemos(genealogyId.value, {
|
||||
requestController: memoListRequestController,
|
||||
});
|
||||
if (!pageActive) return;
|
||||
memos.value = rows;
|
||||
listState.value = memos.value.length ? "ready" : "empty";
|
||||
if (pendingMemoId.value) {
|
||||
const target = memos.value.find((item) => item.id === pendingMemoId.value);
|
||||
pendingMemoId.value = "";
|
||||
if (target) void openMemoDetail(target);
|
||||
}
|
||||
} catch (cause) {
|
||||
if (!pageActive || isRequestCancelled(cause)) return;
|
||||
listState.value = "error";
|
||||
}
|
||||
};
|
||||
const openCreate = () => {
|
||||
if (!valid.value) return;
|
||||
saveNotice.value = false;
|
||||
resetForm();
|
||||
view.value = "form";
|
||||
};
|
||||
const splitReminderTime = (value) => {
|
||||
if (!value) return { date: "", clock: "" };
|
||||
const matched = String(value).trim().match(/^(\d{4}-\d{2}-\d{2})[ T](\d{2}:\d{2})/);
|
||||
if (!matched) throw new Error("提醒时间有误,暂时无法编辑。");
|
||||
return { date: matched[1], clock: matched[2] };
|
||||
};
|
||||
const openEdit = async (memo) => {
|
||||
if (!memo?.canEdit || !valid.value || submitting.value || uploading.value) return;
|
||||
memoEditorDetailRequestController.abort();
|
||||
listState.value = "loading";
|
||||
try {
|
||||
const detail = await lifeRecordApi.getMemoDetail(genealogyId.value, memo.id, {
|
||||
requestController: memoEditorDetailRequestController,
|
||||
});
|
||||
if (!pageActive) return;
|
||||
if (
|
||||
!detail.canEdit ||
|
||||
!["0", "1"].includes(detail.completed) ||
|
||||
!["0", "1"].includes(detail.status) ||
|
||||
!Number.isSafeInteger(detail.sortOrder)
|
||||
) {
|
||||
throw new Error("这条备忘的信息不完整,暂未保存修改,以免覆盖原内容。");
|
||||
}
|
||||
const reminder = splitReminderTime(detail.remindTime);
|
||||
resetForm();
|
||||
Object.assign(form, {
|
||||
memoTitle: detail.title,
|
||||
remindDate: reminder.date,
|
||||
remindClock: reminder.clock,
|
||||
memoContent: detail.content,
|
||||
});
|
||||
mediaReceipts.value = detail.mediaFiles.map((file) => ({
|
||||
ossId: file.ossId,
|
||||
fileName: file.fileName,
|
||||
}));
|
||||
editingMemo.value = {
|
||||
id: detail.id,
|
||||
completed: detail.completed,
|
||||
sortOrder: detail.sortOrder,
|
||||
status: detail.status,
|
||||
};
|
||||
formBaseline.value = formSnapshot.value;
|
||||
view.value = "form";
|
||||
listState.value = "ready";
|
||||
} catch (cause) {
|
||||
if (!pageActive || isRequestCancelled(cause)) return;
|
||||
listState.value = "error";
|
||||
}
|
||||
};
|
||||
const openMemoDetail = async (memo) => {
|
||||
if (!memo?.id || detailState.value === "loading") return;
|
||||
detailTarget.value = memo;
|
||||
detailState.value = "loading";
|
||||
detailError.value = "";
|
||||
memoDetailRequestController.abort();
|
||||
try {
|
||||
const loadedMemo = await lifeRecordApi.getMemoDetail(
|
||||
genealogyId.value,
|
||||
memo.id,
|
||||
{ requestController: memoDetailRequestController },
|
||||
);
|
||||
if (!pageActive) return;
|
||||
detailTarget.value = loadedMemo;
|
||||
detailState.value = "ready";
|
||||
} catch (cause) {
|
||||
if (!pageActive || isRequestCancelled(cause)) return;
|
||||
detailState.value = "error";
|
||||
detailError.value = getRequestErrorMessage(cause, "完整备忘读取失败,请稍后重试。");
|
||||
}
|
||||
};
|
||||
const closeMemoDetail = () => {
|
||||
if (detailState.value === "loading") return;
|
||||
detailTarget.value = null;
|
||||
detailState.value = "idle";
|
||||
detailError.value = "";
|
||||
};
|
||||
const previewDetailMedia = (file) => {
|
||||
const urls = detailTarget.value?.mediaFiles?.map((item) => item.accessUrl).filter(Boolean) || [];
|
||||
if (!file?.accessUrl || !urls.length || typeof uni?.previewImage !== "function") return;
|
||||
uni.previewImage({ current: file.accessUrl, urls });
|
||||
};
|
||||
const cancelCreate = () => {
|
||||
resetForm();
|
||||
view.value = "list";
|
||||
};
|
||||
const selectRemindDate = (event) => {
|
||||
form.remindDate = event.detail.value || "";
|
||||
};
|
||||
const selectRemindClock = (event) => {
|
||||
form.remindClock = event.detail.value || "";
|
||||
};
|
||||
const uploadImage = async () => {
|
||||
if (uploading.value || submitting.value) return;
|
||||
uploading.value = true;
|
||||
uploadError.value = "";
|
||||
try {
|
||||
const uploadReceipt = await pickAndUploadImage({
|
||||
requestController: memoMediaUploadRequestController,
|
||||
});
|
||||
if (!pageActive) return;
|
||||
mediaReceipts.value = [...mediaReceipts.value, uploadReceipt];
|
||||
} catch (cause) {
|
||||
if (
|
||||
pageActive &&
|
||||
!isImagePickCancelled(cause) &&
|
||||
!isRequestCancelled(cause)
|
||||
)
|
||||
uploadError.value = getRequestErrorMessage(cause, "图片上传失败,请稍后重试。");
|
||||
} finally {
|
||||
if (pageActive) uploading.value = false;
|
||||
}
|
||||
};
|
||||
const saveMemo = async () => {
|
||||
if (submitting.value || uploading.value || !valid.value) return;
|
||||
if (!form.memoTitle.trim()) {
|
||||
error.value = "请填写备忘标题";
|
||||
return;
|
||||
}
|
||||
const payload = {
|
||||
memoTitle: form.memoTitle,
|
||||
remindTime: remindTime.value,
|
||||
memoContent: form.memoContent,
|
||||
mediaOssIds: mediaOssIds.value,
|
||||
...(editingMemo.value
|
||||
? {
|
||||
completed: editingMemo.value.completed,
|
||||
sortOrder: editingMemo.value.sortOrder,
|
||||
status: editingMemo.value.status,
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
const createAttempt = editingMemo.value ? null : memoCreateGuard.begin(payload);
|
||||
if (!editingMemo.value && createAttempt === null) {
|
||||
error.value =
|
||||
"上次提交结果暂时无法确认,请先返回备忘列表检查,避免重复创建。";
|
||||
return;
|
||||
}
|
||||
|
||||
submitting.value = true;
|
||||
error.value = "";
|
||||
try {
|
||||
if (editingMemo.value) {
|
||||
await lifeRecordApi.updateMemo(genealogyId.value, editingMemo.value.id, payload, {
|
||||
requestController: memoSaveRequestController,
|
||||
});
|
||||
} else {
|
||||
await lifeRecordApi.createMemo(genealogyId.value, payload, {
|
||||
requestController: memoSaveRequestController,
|
||||
});
|
||||
}
|
||||
if (!pageActive) return;
|
||||
resetForm();
|
||||
view.value = "list";
|
||||
saveNotice.value = true;
|
||||
await loadMemos();
|
||||
} catch (cause) {
|
||||
if (!pageActive) return;
|
||||
if (!editingMemo.value && memoCreateGuard.recordFailure(createAttempt, cause)) {
|
||||
error.value =
|
||||
"提交结果暂时无法确认,请先返回备忘列表检查,避免重复创建。";
|
||||
return;
|
||||
}
|
||||
if (!isRequestCancelled(cause))
|
||||
error.value = getRequestErrorMessage(cause, "备忘提交失败,请稍后重试。");
|
||||
} finally {
|
||||
if (pageActive) submitting.value = false;
|
||||
}
|
||||
};
|
||||
const requestDeleteMemo = (memo) => {
|
||||
if (!memo?.canDelete || deleting.value) return;
|
||||
deleteError.value = "";
|
||||
deleteTarget.value = memo;
|
||||
deleteConfirmationVisible.value = true;
|
||||
};
|
||||
const closeDeleteConfirmation = () => {
|
||||
if (deleting.value) return;
|
||||
deleteConfirmationVisible.value = false;
|
||||
deleteTarget.value = null;
|
||||
};
|
||||
const deleteMemo = async () => {
|
||||
const memo = deleteTarget.value;
|
||||
if (!memo?.canDelete || deleting.value) return;
|
||||
deleting.value = true;
|
||||
deleteError.value = "";
|
||||
try {
|
||||
await lifeRecordApi.deleteMemo(genealogyId.value, memo.id, {
|
||||
requestController: memoDeletionRequestController,
|
||||
});
|
||||
if (!pageActive) return;
|
||||
deleteConfirmationVisible.value = false;
|
||||
deleteTarget.value = null;
|
||||
await loadMemos();
|
||||
} catch (cause) {
|
||||
if (!pageActive || isRequestCancelled(cause)) return;
|
||||
deleteError.value = getRequestErrorMessage(cause, "备忘删除失败,请稍后重试。");
|
||||
deleteConfirmationVisible.value = false;
|
||||
} finally {
|
||||
if (pageActive) deleting.value = false;
|
||||
}
|
||||
};
|
||||
const requestBack = () =>
|
||||
detailTarget.value
|
||||
? (closeMemoDetail(), true)
|
||||
: view.value !== "form"
|
||||
? goBack()
|
||||
: runBackGuard({
|
||||
transientOpen: discardVisible.value,
|
||||
dirty: dirty.value,
|
||||
submitting: submitting.value || uploading.value,
|
||||
"close-transient": cancelDiscard,
|
||||
"block-submitting": () => true,
|
||||
"confirm-discard": confirmation.request,
|
||||
});
|
||||
|
||||
onLoad((query) => {
|
||||
genealogyId.value = String(query?.genealogyId || "");
|
||||
pendingMemoId.value = /^[1-9]\d*$/.test(String(query?.memoId || "")) ? String(query.memoId) : "";
|
||||
if (valid.value) loadMemos();
|
||||
else listState.value = "invalid";
|
||||
});
|
||||
onShow(() => {
|
||||
if (valid.value && view.value === "list" && listState.value !== "loading")
|
||||
loadMemos();
|
||||
});
|
||||
onBackPress((event) => handleBackPress(event, requestBack));
|
||||
onUnload(() => {
|
||||
pageActive = false;
|
||||
memoListRequestController.abort();
|
||||
memoEditorDetailRequestController.abort();
|
||||
memoMediaUploadRequestController.abort();
|
||||
memoSaveRequestController.abort();
|
||||
memoDeletionRequestController.abort();
|
||||
memoDetailRequestController.abort();
|
||||
confirmation.dispose();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@import "../../styles/adaptive-frame-profiles.scss";
|
||||
.memo-page {
|
||||
display: flex;
|
||||
min-height: 100vh;
|
||||
flex-direction: column;
|
||||
background: $paper;
|
||||
}
|
||||
.page-header,
|
||||
.page-content {
|
||||
z-index: 1;
|
||||
}
|
||||
.page-content {
|
||||
padding: 18rpx 24rpx 72rpx;
|
||||
}
|
||||
.form-card,
|
||||
.state-card,
|
||||
.memo-card {
|
||||
@include adaptive-records-content;
|
||||
}
|
||||
.form-card {
|
||||
box-sizing: border-box;
|
||||
padding: 46rpx;
|
||||
}
|
||||
.form-card > text:first-child {
|
||||
display: block;
|
||||
color: $ink;
|
||||
font-size: clamp(19px, 34rpx, 24px);
|
||||
font-weight: 700;
|
||||
}
|
||||
.form-copy {
|
||||
display: block;
|
||||
margin-top: 12rpx;
|
||||
color: $ink-muted;
|
||||
font-size: clamp(14px, 22rpx, 17px);
|
||||
line-height: 1.55;
|
||||
}
|
||||
.field {
|
||||
display: grid;
|
||||
grid-template-columns: 142rpx minmax(0, 1fr);
|
||||
align-items: center;
|
||||
gap: 12rpx 20rpx;
|
||||
min-height: 82rpx;
|
||||
margin-top: 14rpx;
|
||||
padding: 14rpx 24rpx;
|
||||
box-sizing: border-box;
|
||||
@include adaptive-records-field;
|
||||
}
|
||||
.field > text,
|
||||
.upload-field > view > text:first-child {
|
||||
color: $ink;
|
||||
font-size: clamp(14px, 23rpx, 17px);
|
||||
font-weight: 700;
|
||||
}
|
||||
.required-mark {
|
||||
display: inline-block;
|
||||
margin-right: 4rpx;
|
||||
color: $brand-red;
|
||||
font-size: clamp(16px, 26rpx, 20px);
|
||||
transform: translateY(-3rpx);
|
||||
}
|
||||
.field input,
|
||||
.field > text:last-child {
|
||||
min-width: 0;
|
||||
color: $ink;
|
||||
font-size: clamp(14px, 23rpx, 17px);
|
||||
text-align: right;
|
||||
}
|
||||
.field--textarea {
|
||||
align-items: start;
|
||||
}
|
||||
.field textarea {
|
||||
min-height: 116rpx;
|
||||
color: $ink;
|
||||
font-size: clamp(14px, 23rpx, 17px);
|
||||
line-height: 1.55;
|
||||
}
|
||||
.upload-field {
|
||||
display: grid;
|
||||
gap: 12rpx;
|
||||
margin-top: 14rpx;
|
||||
padding: 18rpx 24rpx;
|
||||
@include adaptive-records-field;
|
||||
}
|
||||
.upload-field > view > text {
|
||||
display: block;
|
||||
}
|
||||
.upload-field > view > text:last-child {
|
||||
margin-top: 6rpx;
|
||||
color: $ink-muted;
|
||||
font-size: clamp(13px, 20rpx, 16px);
|
||||
line-height: 1.45;
|
||||
}
|
||||
.upload-button {
|
||||
justify-self: start;
|
||||
min-height: 88rpx;
|
||||
margin: 0;
|
||||
padding: 0 20rpx;
|
||||
border: 1rpx solid rgba(159, 23, 15, 0.42);
|
||||
border-radius: 8rpx;
|
||||
background: transparent;
|
||||
color: $brand-red;
|
||||
font-size: clamp(14px, 22rpx, 17px);
|
||||
}
|
||||
.upload-field > text {
|
||||
color: $ink-muted;
|
||||
font-size: clamp(13px, 21rpx, 16px);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.error {
|
||||
display: block;
|
||||
margin-top: 12rpx;
|
||||
color: $brand-red;
|
||||
font-size: clamp(14px, 22rpx, 17px);
|
||||
}
|
||||
.form-actions {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(0, 1.45fr);
|
||||
gap: 16rpx;
|
||||
margin-top: 20rpx;
|
||||
}
|
||||
.form-actions .app-button {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
.state-card {
|
||||
display: flex;
|
||||
min-height: 320rpx;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 42rpx;
|
||||
color: $ink;
|
||||
font-size: clamp(16px, 28rpx, 20px);
|
||||
text-align: center;
|
||||
}
|
||||
.state-card .app-button {
|
||||
width: 100%;
|
||||
margin-top: 24rpx;
|
||||
}
|
||||
.memo-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16rpx;
|
||||
}
|
||||
.save-notice {
|
||||
display: block;
|
||||
color: $brand-red;
|
||||
font-size: clamp(14px, 22rpx, 17px);
|
||||
}
|
||||
.delete-error {
|
||||
display: block;
|
||||
color: $brand-red;
|
||||
font-size: clamp(14px, 22rpx, 17px);
|
||||
}
|
||||
.memo-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 18rpx;
|
||||
min-height: 128rpx;
|
||||
padding: 28rpx 32rpx;
|
||||
}
|
||||
.memo-card__copy {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
.memo-card__actions {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 10rpx;
|
||||
}
|
||||
.memo-card__actions .app-button {
|
||||
width: 140rpx;
|
||||
min-height: 68rpx;
|
||||
}
|
||||
.memo-card text {
|
||||
display: block;
|
||||
}
|
||||
.memo-card text:first-child {
|
||||
color: $ink;
|
||||
font-family: STKaiti, KaiTi, serif;
|
||||
font-size: clamp(17px, 30rpx, 22px);
|
||||
font-weight: 700;
|
||||
}
|
||||
.memo-card text:not(:first-child) {
|
||||
margin-top: 7rpx;
|
||||
color: $ink-muted;
|
||||
font-size: clamp(13px, 21rpx, 16px);
|
||||
line-height: 1.45;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.detail-content { width: 100%; margin-top: 18rpx; text-align: left; }
|
||||
.detail-content > text { display: block; margin-top: 9rpx; color: $ink-muted; font-size: clamp(14px, 23rpx, 17px); line-height: 1.55; overflow-wrap: anywhere; }
|
||||
.detail-content__body { padding-top: 10rpx; border-top: 1rpx solid rgba(142, 95, 41, .2); color: $ink !important; white-space: pre-wrap; }
|
||||
.detail-error { color: $brand-red !important; }
|
||||
.detail-media { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); margin-top: 16rpx; gap: 10rpx; }
|
||||
.detail-media image { width: 100%; height: 150rpx; border-radius: 8rpx; background: rgba(128, 89, 49, .12); }
|
||||
</style>
|
||||
@@ -0,0 +1,716 @@
|
||||
<template>
|
||||
<view class="merit-page">
|
||||
<ModulePageBackground module="records" />
|
||||
<view class="page-header"
|
||||
><PageHeader
|
||||
title="功德记录"
|
||||
:action="valid && view === 'list' ? '新建' : ''"
|
||||
custom-back
|
||||
@back="requestBack"
|
||||
@action="openCreate"
|
||||
/></view>
|
||||
<view class="page-content">
|
||||
<view v-if="view === 'form'" class="form-card">
|
||||
<text>{{ isEdit ? "编辑功德记录" : "新建功德记录" }}</text>
|
||||
<text class="form-copy">{{ isEdit ? "原有设置会保留。" : "红色 * 为必填项,其余内容可按需补充。" }}</text>
|
||||
<view v-for="field in fields" :key="field.key" class="field"
|
||||
><text
|
||||
><text v-if="field.required" class="required-mark">*</text
|
||||
>{{ field.label }}</text
|
||||
><textarea
|
||||
v-if="field.key === 'content'"
|
||||
v-model="form[field.key]"
|
||||
auto-height
|
||||
:placeholder="`请输入${field.label}`"
|
||||
@input="error = ''" /><input
|
||||
v-else
|
||||
v-model="form[field.key]"
|
||||
:type="field.inputType || 'text'"
|
||||
:placeholder="field.placeholder || `请输入${field.label}`"
|
||||
@input="error = ''"
|
||||
/></view>
|
||||
<view class="field field--picker"
|
||||
><text>功德类型</text
|
||||
><picker
|
||||
:range="meritTypeOptions.map((item) => item.label)"
|
||||
:value="meritTypeIndex"
|
||||
@change="selectMeritType"
|
||||
><view :class="{ placeholder: !form.type }">{{
|
||||
meritTypeLabel || "请选择"
|
||||
}}</view></picker
|
||||
></view
|
||||
>
|
||||
<view class="field field--picker"
|
||||
><text>记录日期</text
|
||||
><picker mode="date" :value="form.meritDate" @change="selectMeritDate"
|
||||
><view :class="{ placeholder: !form.meritDate }">{{
|
||||
form.meritDate || "请选择"
|
||||
}}</view></picker
|
||||
></view
|
||||
>
|
||||
<view class="field field--picker"
|
||||
><text>记录时间</text
|
||||
><picker
|
||||
mode="time"
|
||||
:value="form.meritClock"
|
||||
@change="selectMeritClock"
|
||||
><view :class="{ placeholder: !form.meritClock }">{{
|
||||
form.meritClock || "请选择"
|
||||
}}</view></picker
|
||||
></view
|
||||
>
|
||||
<text v-if="error" class="error">{{ error }}</text>
|
||||
<view class="form-actions"
|
||||
><AppButton
|
||||
type="secondary"
|
||||
label="取消"
|
||||
@click="cancelCreate" /><AppButton
|
||||
:disabled="submitting"
|
||||
:label="submitting ? '正在提交' : isEdit ? '保存修改' : '提交功德记录'"
|
||||
@click="saveMeritRecord"
|
||||
/></view>
|
||||
</view>
|
||||
<view v-else-if="!valid" class="state-card"
|
||||
><text>暂时无法打开功德记录</text
|
||||
><AppButton block label="返回上一页" @click="requestBack"
|
||||
/></view>
|
||||
<view v-else-if="listState === 'loading'" class="state-card"
|
||||
><AppLoading text="正在读取功德记录"
|
||||
/></view>
|
||||
<view v-else-if="listState === 'error'" class="state-card"
|
||||
><text>暂时无法读取功德记录</text
|
||||
><AppButton block type="secondary" label="重新加载" @click="loadMerits"
|
||||
/></view>
|
||||
<view v-else-if="listState === 'empty'" class="state-card"
|
||||
><text>还没有功德记录</text
|
||||
><AppButton block label="新建功德记录" @click="openCreate"
|
||||
/></view>
|
||||
<view v-else class="merit-list">
|
||||
<text v-if="saveNotice" class="save-notice"
|
||||
>{{ saveNotice }}</text
|
||||
>
|
||||
<text class="merit-summary"
|
||||
>共 {{ merits.length }} 笔,金额合计 ¥{{ totalAmount }}</text
|
||||
>
|
||||
<text v-if="deleteError" class="delete-error">{{ deleteError }}</text>
|
||||
<view v-for="item in merits" :key="item.id" class="merit-card" role="button" :aria-label="`查看${item.title}详情`" @click="openMeritDetail(item)">
|
||||
<view>
|
||||
<text>{{ item.title }}</text>
|
||||
<text>{{ item.donor }}{{ item.typeLabel ? ` · ${item.typeLabel}` : "" }}</text>
|
||||
<text v-if="item.time">{{ item.time }}</text>
|
||||
<text v-if="item.content">{{ item.content }}</text>
|
||||
</view>
|
||||
<view class="merit-card__amount">
|
||||
<text>¥{{ item.amount }}</text>
|
||||
<AppButton
|
||||
v-if="item.canEdit"
|
||||
compact
|
||||
type="secondary"
|
||||
label="编辑"
|
||||
@click.stop="openEditMerit(item)"
|
||||
/>
|
||||
<AppButton
|
||||
v-if="item.canDelete"
|
||||
compact
|
||||
type="secondary"
|
||||
label="删除"
|
||||
@click.stop="requestDeleteMerit(item)"
|
||||
/>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<AppDialog
|
||||
:visible="Boolean(detailTarget)"
|
||||
eyebrow="功德详情"
|
||||
:title="detailTarget?.title || '功德记录'"
|
||||
confirm-text="关闭"
|
||||
:close-on-mask="detailState !== 'loading'"
|
||||
@confirm="closeMeritDetail"
|
||||
@cancel="closeMeritDetail"
|
||||
>
|
||||
<view class="detail-content">
|
||||
<AppLoading v-if="detailState === 'loading'" text="正在读取完整记录" />
|
||||
<text v-else-if="detailState === 'error'" class="detail-error">{{ detailError }}</text>
|
||||
<template v-else>
|
||||
<text>捐赠人:{{ detailTarget?.donor || "未署名" }}</text>
|
||||
<text>功德类型:{{ detailTarget?.typeLabel || "未填写" }}</text>
|
||||
<text>记录时间:{{ detailTarget?.time || "未填写" }}</text>
|
||||
<text>金额:¥{{ detailTarget?.amount || "0.00" }}</text>
|
||||
<text class="detail-content__body">{{ detailTarget?.content || "未填写记录内容" }}</text>
|
||||
</template>
|
||||
</view>
|
||||
</AppDialog>
|
||||
<AppDialog
|
||||
:visible="discardVisible"
|
||||
title="放弃功德记录?"
|
||||
message="尚未提交的内容将被清除。"
|
||||
confirm-text="放弃并返回"
|
||||
cancel-text="继续填写"
|
||||
show-cancel
|
||||
:close-on-mask="false"
|
||||
@confirm="confirmDiscard"
|
||||
@cancel="cancelDiscard"
|
||||
/>
|
||||
<AppDialog
|
||||
:visible="deleteConfirmationVisible"
|
||||
title="删除这笔功德记录?"
|
||||
message="删除后无法恢复,请确认当前内容不再需要。"
|
||||
confirm-text="确认删除"
|
||||
cancel-text="保留记录"
|
||||
show-cancel
|
||||
:close-on-mask="false"
|
||||
@confirm="deleteMerit"
|
||||
@cancel="closeDeleteConfirmation"
|
||||
/>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, reactive, ref } from "vue";
|
||||
import { onBackPress, onLoad, 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";
|
||||
import {
|
||||
MERIT_TYPE_OPTIONS as meritTypeOptions
|
||||
} from "@/services/api/life-record-contract.js";
|
||||
import {
|
||||
createRequestController,
|
||||
isRequestCancelled
|
||||
} from "@/services/api/request-controller.js";
|
||||
import { lifeRecordApi } from "@/services/api/life-record-service.js";
|
||||
import { getRequestErrorMessage } from "@/services/api/request-error-message.js";
|
||||
import { createDiscardConfirmation } from "@/utils/discard-confirmation.js";
|
||||
import { createNonIdempotentWriteGuard } from "@/utils/non-idempotent-write-guard.js";
|
||||
import { goBack, handleBackPress, runBackGuard } from "@/utils/navigation/gateway.js";
|
||||
|
||||
const genealogyId = ref("");
|
||||
const view = ref("list");
|
||||
const listState = ref("loading");
|
||||
const merits = ref([]);
|
||||
const saveNotice = ref("");
|
||||
const submitting = ref(false);
|
||||
const error = ref("");
|
||||
const discardVisible = ref(false);
|
||||
const deleteConfirmationVisible = ref(false);
|
||||
const deleteTarget = ref(null);
|
||||
const deleting = ref(false);
|
||||
const deleteError = ref("");
|
||||
const editingMerit = ref(null);
|
||||
const detailTarget = ref(null);
|
||||
const detailState = ref("idle");
|
||||
const detailError = ref("");
|
||||
const formBaseline = ref("");
|
||||
const form = reactive({
|
||||
donor: "",
|
||||
title: "",
|
||||
type: "",
|
||||
amount: "",
|
||||
meritDate: "",
|
||||
meritClock: "",
|
||||
content: "",
|
||||
});
|
||||
const fields = [
|
||||
{ key: "donor", label: "捐赠人", required: true },
|
||||
{ key: "title", label: "功德标题", required: true },
|
||||
{ key: "amount", inputType: "digit", label: "金额" },
|
||||
{ key: "content", label: "记录内容" },
|
||||
];
|
||||
const meritListRequestController = createRequestController();
|
||||
const meritEditorDetailRequestController = createRequestController();
|
||||
const meritSaveRequestController = createRequestController();
|
||||
const meritDeletionRequestController = createRequestController();
|
||||
const meritDetailRequestController = createRequestController();
|
||||
const meritRecordCreateGuard = createNonIdempotentWriteGuard();
|
||||
let pageActive = true;
|
||||
const valid = computed(() => /^[1-9]\d*$/.test(genealogyId.value));
|
||||
const isEdit = computed(() => Boolean(editingMerit.value));
|
||||
const formSnapshot = computed(() => JSON.stringify(form));
|
||||
const dirty = computed(() =>
|
||||
isEdit.value
|
||||
? Boolean(formBaseline.value) && formSnapshot.value !== formBaseline.value
|
||||
: Object.values(form).some((value) => String(value).trim()),
|
||||
);
|
||||
const meritTime = computed(() =>
|
||||
form.meritDate ? `${form.meritDate} ${form.meritClock || "00:00"}:00` : "",
|
||||
);
|
||||
const meritTypeIndex = computed(() =>
|
||||
Math.max(
|
||||
0,
|
||||
meritTypeOptions.findIndex((item) => item.value === form.type),
|
||||
),
|
||||
);
|
||||
const meritTypeLabel = computed(
|
||||
() => meritTypeOptions.find((item) => item.value === form.type)?.label || "",
|
||||
);
|
||||
const addCurrencyAmounts = (left, right) => {
|
||||
const [leftWhole, leftFraction] = left.split(".");
|
||||
const [rightWhole, rightFraction] = right.split(".");
|
||||
let carry = Number(leftFraction) + Number(rightFraction);
|
||||
const fraction = String(carry % 100).padStart(2, "0");
|
||||
carry = Math.floor(carry / 100);
|
||||
const digits = [];
|
||||
let leftIndex = leftWhole.length - 1;
|
||||
let rightIndex = rightWhole.length - 1;
|
||||
while (leftIndex >= 0 || rightIndex >= 0 || carry) {
|
||||
const sum = (Number(leftWhole[leftIndex--] || 0) + Number(rightWhole[rightIndex--] || 0) + carry);
|
||||
digits.push(sum % 10);
|
||||
carry = Math.floor(sum / 10);
|
||||
}
|
||||
return `${digits.reverse().join("")}.${fraction}`;
|
||||
};
|
||||
const totalAmount = computed(() =>
|
||||
merits.value.reduce((total, item) => addCurrencyAmounts(total, item.amount), "0.00"),
|
||||
);
|
||||
const confirmation = createDiscardConfirmation((visible) => {
|
||||
discardVisible.value = visible;
|
||||
});
|
||||
const confirmDiscard = () => {
|
||||
confirmation.confirm();
|
||||
resetForm();
|
||||
view.value = "list";
|
||||
};
|
||||
const cancelDiscard = confirmation.cancel;
|
||||
|
||||
const resetForm = () => {
|
||||
Object.assign(form, {
|
||||
donor: "",
|
||||
title: "",
|
||||
type: "",
|
||||
amount: "",
|
||||
meritDate: "",
|
||||
meritClock: "",
|
||||
content: "",
|
||||
});
|
||||
editingMerit.value = null;
|
||||
formBaseline.value = "";
|
||||
error.value = "";
|
||||
};
|
||||
const loadMerits = async () => {
|
||||
if (!valid.value) return;
|
||||
meritListRequestController.abort();
|
||||
listState.value = "loading";
|
||||
try {
|
||||
const rows = await lifeRecordApi.getMeritRecords(genealogyId.value, {
|
||||
requestController: meritListRequestController,
|
||||
});
|
||||
if (!pageActive) return;
|
||||
merits.value = rows;
|
||||
listState.value = merits.value.length ? "ready" : "empty";
|
||||
} catch (cause) {
|
||||
if (!pageActive || isRequestCancelled(cause)) return;
|
||||
listState.value = "error";
|
||||
}
|
||||
};
|
||||
const openCreate = () => {
|
||||
if (!valid.value) return;
|
||||
saveNotice.value = "";
|
||||
resetForm();
|
||||
view.value = "form";
|
||||
};
|
||||
const splitMeritTime = (value) => {
|
||||
const matched = String(value || "").trim().match(/^(\d{4}-\d{2}-\d{2})[ T](\d{2}:\d{2})/);
|
||||
if (!matched) throw new Error("记录时间有误,暂时无法编辑。");
|
||||
return { date: matched[1], clock: matched[2] };
|
||||
};
|
||||
const openEditMerit = async (merit) => {
|
||||
if (!merit?.canEdit || !valid.value || submitting.value) return;
|
||||
meritEditorDetailRequestController.abort();
|
||||
listState.value = "loading";
|
||||
try {
|
||||
const detail = await lifeRecordApi.getMeritRecordDetail(genealogyId.value, merit.id, {
|
||||
requestController: meritEditorDetailRequestController,
|
||||
});
|
||||
if (!pageActive) return;
|
||||
if (
|
||||
!detail.canEdit ||
|
||||
!detail.donor ||
|
||||
!detail.title ||
|
||||
!meritTypeOptions.some((item) => item.value === detail.type) ||
|
||||
!detail.amount ||
|
||||
!["0", "1"].includes(detail.status) ||
|
||||
!Number.isSafeInteger(detail.sortOrder)
|
||||
) {
|
||||
throw new Error("这条功德记录的信息不完整,暂未保存修改,以免覆盖原内容。");
|
||||
}
|
||||
const timeParts = splitMeritTime(detail.time);
|
||||
resetForm();
|
||||
Object.assign(form, {
|
||||
donor: detail.donor,
|
||||
title: detail.title,
|
||||
type: detail.type,
|
||||
amount: detail.amount,
|
||||
meritDate: timeParts.date,
|
||||
meritClock: timeParts.clock,
|
||||
content: detail.content,
|
||||
});
|
||||
editingMerit.value = {
|
||||
id: detail.id,
|
||||
sortOrder: detail.sortOrder,
|
||||
status: detail.status,
|
||||
};
|
||||
formBaseline.value = formSnapshot.value;
|
||||
listState.value = "ready";
|
||||
view.value = "form";
|
||||
} catch (cause) {
|
||||
if (!pageActive || isRequestCancelled(cause)) return;
|
||||
listState.value = "error";
|
||||
}
|
||||
};
|
||||
const openMeritDetail = async (merit) => {
|
||||
if (!merit?.id || detailState.value === "loading") return;
|
||||
detailTarget.value = merit;
|
||||
detailState.value = "loading";
|
||||
detailError.value = "";
|
||||
meritDetailRequestController.abort();
|
||||
try {
|
||||
const loadedMerit = await lifeRecordApi.getMeritRecordDetail(genealogyId.value, merit.id, {
|
||||
requestController: meritDetailRequestController,
|
||||
});
|
||||
if (!pageActive) return;
|
||||
detailTarget.value = loadedMerit;
|
||||
detailState.value = "ready";
|
||||
} catch (cause) {
|
||||
if (!pageActive || isRequestCancelled(cause)) return;
|
||||
detailState.value = "error";
|
||||
detailError.value = getRequestErrorMessage(cause, "完整记录读取失败,请稍后重试。");
|
||||
}
|
||||
};
|
||||
const closeMeritDetail = () => {
|
||||
if (detailState.value === "loading") return;
|
||||
detailTarget.value = null;
|
||||
detailState.value = "idle";
|
||||
detailError.value = "";
|
||||
};
|
||||
const cancelCreate = () => {
|
||||
resetForm();
|
||||
view.value = "list";
|
||||
};
|
||||
const selectMeritDate = (event) => {
|
||||
form.meritDate = event.detail.value || "";
|
||||
error.value = "";
|
||||
};
|
||||
const selectMeritClock = (event) => {
|
||||
form.meritClock = event.detail.value || "";
|
||||
error.value = "";
|
||||
};
|
||||
const selectMeritType = (event) => {
|
||||
form.type = meritTypeOptions[Number(event.detail.value)]?.value || "";
|
||||
error.value = "";
|
||||
};
|
||||
const saveMeritRecord = async () => {
|
||||
if (submitting.value || !valid.value) return;
|
||||
const donorName = form.donor.trim();
|
||||
const meritTitle = form.title.trim();
|
||||
const amountText = form.amount.trim();
|
||||
if (!donorName || !meritTitle) {
|
||||
error.value = !donorName ? "请填写捐赠人" : "请填写功德标题";
|
||||
return;
|
||||
}
|
||||
if (amountText && !Number.isFinite(Number(amountText))) {
|
||||
error.value = "金额必须是数字";
|
||||
return;
|
||||
}
|
||||
const payload = {
|
||||
donorName,
|
||||
meritTitle,
|
||||
meritType: form.type,
|
||||
meritContent: form.content,
|
||||
meritTime: meritTime.value,
|
||||
...(amountText ? { amount: Number(amountText) } : {}),
|
||||
...(editingMerit.value
|
||||
? {
|
||||
sortOrder: editingMerit.value.sortOrder,
|
||||
status: editingMerit.value.status,
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
const createAttempt = editingMerit.value
|
||||
? null
|
||||
: meritRecordCreateGuard.begin(payload);
|
||||
if (!editingMerit.value && createAttempt === null) {
|
||||
error.value =
|
||||
"上次提交结果暂时无法确认,请先返回功德记录列表检查,避免重复创建。";
|
||||
return;
|
||||
}
|
||||
|
||||
submitting.value = true;
|
||||
error.value = "";
|
||||
try {
|
||||
const wasEditing = Boolean(editingMerit.value);
|
||||
if (editingMerit.value) {
|
||||
await lifeRecordApi.updateMeritRecord(genealogyId.value, editingMerit.value.id, payload, {
|
||||
requestController: meritSaveRequestController,
|
||||
});
|
||||
} else {
|
||||
await lifeRecordApi.createMeritRecord(genealogyId.value, payload, {
|
||||
requestController: meritSaveRequestController,
|
||||
});
|
||||
}
|
||||
if (!pageActive) return;
|
||||
resetForm();
|
||||
view.value = "list";
|
||||
saveNotice.value = wasEditing
|
||||
? "功德记录已更新。"
|
||||
: "功德记录已保存。";
|
||||
await loadMerits();
|
||||
} catch (cause) {
|
||||
if (!pageActive) return;
|
||||
if (
|
||||
!editingMerit.value &&
|
||||
meritRecordCreateGuard.recordFailure(createAttempt, cause)
|
||||
) {
|
||||
error.value =
|
||||
"提交结果暂时无法确认,请先返回功德记录列表检查,避免重复创建。";
|
||||
return;
|
||||
}
|
||||
if (!isRequestCancelled(cause))
|
||||
error.value = getRequestErrorMessage(cause, "功德记录提交失败,请稍后重试。");
|
||||
} finally {
|
||||
if (pageActive) submitting.value = false;
|
||||
}
|
||||
};
|
||||
const requestDeleteMerit = (merit) => {
|
||||
if (!merit?.canDelete || deleting.value) return;
|
||||
deleteError.value = "";
|
||||
deleteTarget.value = merit;
|
||||
deleteConfirmationVisible.value = true;
|
||||
};
|
||||
const closeDeleteConfirmation = () => {
|
||||
if (deleting.value) return;
|
||||
deleteConfirmationVisible.value = false;
|
||||
deleteTarget.value = null;
|
||||
};
|
||||
const deleteMerit = async () => {
|
||||
const merit = deleteTarget.value;
|
||||
if (!merit?.canDelete || deleting.value) return;
|
||||
deleting.value = true;
|
||||
deleteError.value = "";
|
||||
try {
|
||||
await lifeRecordApi.deleteMeritRecord(genealogyId.value, merit.id, {
|
||||
requestController: meritDeletionRequestController,
|
||||
});
|
||||
if (!pageActive) return;
|
||||
deleteConfirmationVisible.value = false;
|
||||
deleteTarget.value = null;
|
||||
await loadMerits();
|
||||
} catch (cause) {
|
||||
if (!pageActive || isRequestCancelled(cause)) return;
|
||||
deleteError.value = getRequestErrorMessage(cause, "功德记录删除失败,请稍后重试。");
|
||||
deleteConfirmationVisible.value = false;
|
||||
} finally {
|
||||
if (pageActive) deleting.value = false;
|
||||
}
|
||||
};
|
||||
const requestBack = () =>
|
||||
detailTarget.value
|
||||
? (closeMeritDetail(), true)
|
||||
: view.value !== "form"
|
||||
? goBack()
|
||||
: runBackGuard({
|
||||
transientOpen: discardVisible.value,
|
||||
dirty: dirty.value,
|
||||
submitting: submitting.value,
|
||||
"close-transient": cancelDiscard,
|
||||
"block-submitting": () => true,
|
||||
"confirm-discard": confirmation.request,
|
||||
});
|
||||
|
||||
onLoad((query) => {
|
||||
genealogyId.value = String(query?.genealogyId || "");
|
||||
if (valid.value) loadMerits();
|
||||
else listState.value = "invalid";
|
||||
});
|
||||
onShow(() => {
|
||||
if (valid.value && view.value === "list" && listState.value !== "loading")
|
||||
loadMerits();
|
||||
});
|
||||
onBackPress((event) => handleBackPress(event, requestBack));
|
||||
onUnload(() => {
|
||||
pageActive = false;
|
||||
meritListRequestController.abort();
|
||||
meritEditorDetailRequestController.abort();
|
||||
meritSaveRequestController.abort();
|
||||
meritDeletionRequestController.abort();
|
||||
meritDetailRequestController.abort();
|
||||
confirmation.dispose();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@import "../../styles/adaptive-frame-profiles.scss";
|
||||
.merit-page {
|
||||
display: flex;
|
||||
min-height: 100vh;
|
||||
flex-direction: column;
|
||||
background: $paper;
|
||||
}
|
||||
.page-header,
|
||||
.page-content {
|
||||
z-index: 1;
|
||||
}
|
||||
.page-content {
|
||||
padding: 18rpx 24rpx 72rpx;
|
||||
}
|
||||
.form-card,
|
||||
.state-card,
|
||||
.merit-card {
|
||||
@include adaptive-records-content;
|
||||
}
|
||||
.form-card {
|
||||
box-sizing: border-box;
|
||||
padding: 46rpx;
|
||||
}
|
||||
.form-card > text:first-child {
|
||||
display: block;
|
||||
color: $ink;
|
||||
font-size: clamp(19px, 34rpx, 24px);
|
||||
font-weight: 700;
|
||||
}
|
||||
.form-copy {
|
||||
display: block;
|
||||
margin-top: 12rpx;
|
||||
color: $ink-muted;
|
||||
font-size: clamp(14px, 22rpx, 17px);
|
||||
line-height: 1.55;
|
||||
}
|
||||
.field {
|
||||
margin-top: 16rpx;
|
||||
}
|
||||
.field > text {
|
||||
display: block;
|
||||
margin: 0 8rpx 8rpx;
|
||||
color: $ink;
|
||||
font-size: clamp(14px, 23rpx, 17px);
|
||||
font-weight: 700;
|
||||
}
|
||||
.required-mark {
|
||||
display: inline-block;
|
||||
margin-right: 4rpx;
|
||||
color: $brand-red;
|
||||
font-size: clamp(16px, 26rpx, 20px);
|
||||
transform: translateY(-3rpx);
|
||||
}
|
||||
.field input,
|
||||
.field textarea,
|
||||
.field--picker picker > view {
|
||||
@include adaptive-records-field;
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
min-height: 76rpx;
|
||||
padding: 16rpx 22rpx;
|
||||
color: $ink;
|
||||
font-size: clamp(14px, 23rpx, 17px);
|
||||
}
|
||||
.field textarea {
|
||||
min-height: 150rpx;
|
||||
}
|
||||
.field--picker picker {
|
||||
display: block;
|
||||
}
|
||||
.field--picker .placeholder {
|
||||
color: $ink-muted;
|
||||
}
|
||||
.error {
|
||||
display: block;
|
||||
margin-top: 12rpx;
|
||||
color: $brand-red;
|
||||
font-size: clamp(14px, 22rpx, 17px);
|
||||
}
|
||||
.form-actions {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(0, 1.45fr);
|
||||
gap: 16rpx;
|
||||
margin-top: 20rpx;
|
||||
}
|
||||
.form-actions .app-button {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
.state-card {
|
||||
display: flex;
|
||||
min-height: 320rpx;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 42rpx;
|
||||
color: $ink;
|
||||
font-size: clamp(16px, 28rpx, 20px);
|
||||
text-align: center;
|
||||
}
|
||||
.state-card .app-button {
|
||||
width: 100%;
|
||||
margin-top: 24rpx;
|
||||
}
|
||||
.merit-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16rpx;
|
||||
}
|
||||
.save-notice {
|
||||
display: block;
|
||||
color: $brand-red;
|
||||
font-size: clamp(14px, 22rpx, 17px);
|
||||
}
|
||||
.merit-summary {
|
||||
display: block;
|
||||
color: $ink-muted;
|
||||
font-size: clamp(14px, 22rpx, 17px);
|
||||
}
|
||||
.delete-error {
|
||||
display: block;
|
||||
color: $brand-red;
|
||||
font-size: clamp(14px, 22rpx, 17px);
|
||||
}
|
||||
.merit-card {
|
||||
display: flex;
|
||||
min-height: 138rpx;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 18rpx;
|
||||
padding: 28rpx 32rpx;
|
||||
}
|
||||
.merit-card > view {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
.merit-card text {
|
||||
display: block;
|
||||
}
|
||||
.merit-card > view text:first-child {
|
||||
color: $ink;
|
||||
font-family: STKaiti, KaiTi, serif;
|
||||
font-size: clamp(17px, 30rpx, 22px);
|
||||
font-weight: 700;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.merit-card > view text:not(:first-child) {
|
||||
margin-top: 7rpx;
|
||||
color: $ink-muted;
|
||||
font-size: clamp(13px, 21rpx, 16px);
|
||||
line-height: 1.45;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.merit-card__amount {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
align-items: center;
|
||||
gap: 10rpx;
|
||||
}
|
||||
.merit-card__amount > text {
|
||||
margin-right: auto;
|
||||
color: $brand-red;
|
||||
font-size: clamp(14px, 22rpx, 17px);
|
||||
}
|
||||
.merit-card__amount .app-button {
|
||||
width: 140rpx;
|
||||
min-height: 68rpx;
|
||||
}
|
||||
.detail-content { width: 100%; margin-top: 18rpx; text-align: left; }
|
||||
.detail-content > text { display: block; margin-top: 9rpx; color: $ink-muted; font-size: clamp(14px, 23rpx, 17px); line-height: 1.55; overflow-wrap: anywhere; }
|
||||
.detail-content__body { padding-top: 10rpx; border-top: 1rpx solid rgba(142, 95, 41, .2); color: $ink !important; white-space: pre-wrap; }
|
||||
.detail-error { color: $brand-red !important; }
|
||||
</style>
|
||||
@@ -1,4 +1,3 @@
|
||||
<!-- 页面编号:R-01;用途:人物录列表、搜索、空态与失败状态。 -->
|
||||
<template>
|
||||
<view
|
||||
class="people-page"
|
||||
@@ -81,14 +80,14 @@
|
||||
peopleState === "empty"
|
||||
? "还没有人物记录"
|
||||
: peopleState === "invalid"
|
||||
? "人物录入口无效"
|
||||
? "暂时无法打开人物录"
|
||||
: "人物录暂不可用"
|
||||
}}</text>
|
||||
<text>{{
|
||||
peopleState === "empty"
|
||||
? "从第一位值得铭记的家人开始建立人物录。"
|
||||
: peopleState === "invalid"
|
||||
? "没有找到可访问的成员家谱,页面不会展示其他家谱人物。"
|
||||
? "未找到可访问的家谱,请返回后重新进入。"
|
||||
: "请稍后重新进入,已有档案不会受到影响。"
|
||||
}}</text>
|
||||
</view>
|
||||
@@ -112,11 +111,11 @@ 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 { goBack, openPage } from "@/utils/navigation.js";
|
||||
isRequestCancelled
|
||||
} from "@/services/api/request-controller.js";
|
||||
import { lineageApi } from "@/services/api/lineage-service.js";
|
||||
import { goBack, openPage } from "@/utils/navigation/gateway.js";
|
||||
|
||||
const genealogyId = ref("");
|
||||
const people = ref([]);
|
||||
@@ -126,7 +125,7 @@ const keyword = ref("");
|
||||
const total = ref(0);
|
||||
const pageNum = ref(1);
|
||||
const loadingMore = ref(false);
|
||||
const peopleRequestController = createRequestController();
|
||||
const peopleListRequestController = createRequestController();
|
||||
let loadSequence = 0;
|
||||
const hasValidContext = computed(() => Boolean(genealogyId.value));
|
||||
const hasMore = computed(() => people.value.length < total.value);
|
||||
@@ -152,14 +151,14 @@ const loadPeople = async ({ append = false } = {}) => {
|
||||
if (append) loadingMore.value = true;
|
||||
else peopleState.value = "loading";
|
||||
try {
|
||||
const result = await appApi.getPersonPage(
|
||||
const personPage = await lineageApi.getPersonPage(
|
||||
genealogyId.value,
|
||||
{ pageNum: pageNum.value, pageSize: 10, keyword: keyword.value },
|
||||
{ requestController: peopleRequestController },
|
||||
{ requestController: peopleListRequestController },
|
||||
);
|
||||
if (activeLoad !== loadSequence) return;
|
||||
people.value = append ? [...people.value, ...result.rows] : result.rows;
|
||||
total.value = result.total;
|
||||
people.value = append ? [...people.value, ...personPage.rows] : personPage.rows;
|
||||
total.value = personPage.total;
|
||||
peopleState.value = people.value.length ? "ready" : "empty";
|
||||
} catch (error) {
|
||||
if (activeLoad !== loadSequence || isRequestCancelled(error)) return;
|
||||
@@ -197,7 +196,7 @@ const handleStateAction = () => {
|
||||
|
||||
onLoad((query) => {
|
||||
genealogyId.value = String(query.genealogyId || "");
|
||||
if (!hasValidContext.value || query.state === "error") {
|
||||
if (!hasValidContext.value) {
|
||||
people.value = [];
|
||||
peopleState.value = "invalid";
|
||||
return;
|
||||
@@ -206,7 +205,7 @@ onLoad((query) => {
|
||||
});
|
||||
onUnload(() => {
|
||||
loadSequence += 1;
|
||||
peopleRequestController.abort();
|
||||
peopleListRequestController.abort();
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -302,7 +301,7 @@ onUnload(() => {
|
||||
.people-result-empty,
|
||||
.people-state-card {
|
||||
margin-top: 38rpx;
|
||||
background: url("/static/assets/modules/records/transparent/r01-person-name-card.png")
|
||||
background: url("/static/assets/modules/records/transparent/person-name-card.png")
|
||||
top center / 100% 220rpx no-repeat;
|
||||
text-align: center;
|
||||
}
|
||||
@@ -1,4 +1,3 @@
|
||||
<!-- 页面编号:R-02;用途:人物录详情与不写库的人物资料预览。 -->
|
||||
<template>
|
||||
<view
|
||||
class="person-detail-page"
|
||||
@@ -50,9 +49,12 @@
|
||||
label="成长日志"
|
||||
@click="toGrowthJournal"
|
||||
/>
|
||||
<view class="person-related-unavailable"
|
||||
><text>人生大事</text><text>暂未开放</text></view
|
||||
>
|
||||
<AppButton
|
||||
type="secondary"
|
||||
block
|
||||
label="人生大事"
|
||||
@click="toLifeEvents"
|
||||
/>
|
||||
</view>
|
||||
<view class="person-edit-action" @click="toMemberProfile"
|
||||
><AppButton block label="查看成员档案"
|
||||
@@ -90,21 +92,21 @@ 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";
|
||||
isRequestCancelled
|
||||
} from "@/services/api/request-controller.js";
|
||||
import { lineageApi } from "@/services/api/lineage-service.js";
|
||||
import {
|
||||
goBack,
|
||||
handleBackPress,
|
||||
openPage,
|
||||
returnTo,
|
||||
} from "@/utils/navigation.js";
|
||||
} from "@/utils/navigation/gateway.js";
|
||||
|
||||
const genealogyId = ref("");
|
||||
const personId = ref("");
|
||||
const personState = ref("loading");
|
||||
const personRequestController = createRequestController();
|
||||
const personDetailRequestController = createRequestController();
|
||||
let loadSequence = 0;
|
||||
const person = reactive({
|
||||
id: "",
|
||||
@@ -114,12 +116,16 @@ const person = reactive({
|
||||
generation: "",
|
||||
aliasName: "",
|
||||
sex: "",
|
||||
sexLabel: "",
|
||||
personStatus: "",
|
||||
personStatusLabel: "",
|
||||
birthDate: "",
|
||||
birthLunar: "",
|
||||
birthLunarLabel: "",
|
||||
birthplace: "",
|
||||
deathDate: "",
|
||||
deathLunar: "",
|
||||
deathLunarLabel: "",
|
||||
deathPlace: "",
|
||||
burialPlace: "",
|
||||
spouseNames: "",
|
||||
@@ -129,13 +135,13 @@ const person = reactive({
|
||||
const detailSections = computed(() => [
|
||||
{ title: "别名", copy: person.aliasName },
|
||||
{ title: "字辈", copy: person.generationName },
|
||||
{ title: "性别(字典值)", copy: person.sex },
|
||||
{ title: "人物状态(字典值)", copy: person.personStatus },
|
||||
{ title: "性别", copy: person.sexLabel },
|
||||
{ title: "人物状态", copy: person.personStatusLabel },
|
||||
{ title: "出生日期", copy: person.birthDate },
|
||||
{ title: "出生农历", copy: person.birthLunar },
|
||||
{ title: "出生农历", copy: person.birthLunarLabel },
|
||||
{ title: "出生地", copy: person.birthplace },
|
||||
{ title: "逝世日期", copy: person.deathDate },
|
||||
{ title: "逝世农历", copy: person.deathLunar },
|
||||
{ title: "逝世农历", copy: person.deathLunarLabel },
|
||||
{ title: "逝世地", copy: person.deathPlace },
|
||||
{ title: "安葬地", copy: person.burialPlace },
|
||||
{ title: "配偶", copy: person.spouseNames },
|
||||
@@ -156,6 +162,14 @@ const toGrowthJournal = () =>
|
||||
"R02",
|
||||
)
|
||||
: Promise.resolve(false);
|
||||
const toLifeEvents = () =>
|
||||
person.id
|
||||
? openPage(
|
||||
"R09",
|
||||
{ genealogyId: genealogyId.value, personId: personId.value },
|
||||
"R02",
|
||||
)
|
||||
: Promise.resolve(false);
|
||||
const toMemberProfile = () =>
|
||||
person.id
|
||||
? openPage(
|
||||
@@ -169,11 +183,11 @@ const loadPerson = async () => {
|
||||
const activeLoad = ++loadSequence;
|
||||
personState.value = "loading";
|
||||
try {
|
||||
const result = await appApi.getPerson(genealogyId.value, personId.value, {
|
||||
requestController: personRequestController,
|
||||
const personDetail = await lineageApi.getPerson(genealogyId.value, personId.value, {
|
||||
requestController: personDetailRequestController,
|
||||
});
|
||||
if (activeLoad !== loadSequence) return;
|
||||
Object.assign(person, result);
|
||||
Object.assign(person, personDetail);
|
||||
personState.value = "detail";
|
||||
} catch (error) {
|
||||
if (activeLoad !== loadSequence || isRequestCancelled(error)) return;
|
||||
@@ -187,8 +201,7 @@ onLoad((query) => {
|
||||
if (
|
||||
query.mode !== "view" ||
|
||||
!genealogyId.value ||
|
||||
!personId.value ||
|
||||
query.state === "error"
|
||||
!personId.value
|
||||
) {
|
||||
personState.value = "error";
|
||||
return;
|
||||
@@ -199,7 +212,7 @@ onLoad((query) => {
|
||||
onBackPress((event) => handleBackPress(event, requestBack));
|
||||
onUnload(() => {
|
||||
loadSequence += 1;
|
||||
personRequestController.abort();
|
||||
personDetailRequestController.abort();
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -288,26 +301,6 @@ onUnload(() => {
|
||||
gap: 14rpx;
|
||||
margin-top: 18rpx;
|
||||
}
|
||||
.person-related-unavailable {
|
||||
display: flex;
|
||||
min-height: 88rpx;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-sizing: border-box;
|
||||
border: 1rpx solid rgba(128, 89, 49, 0.24);
|
||||
border-radius: 8rpx;
|
||||
background: rgba(255, 252, 245, 0.38);
|
||||
color: $ink-muted;
|
||||
}
|
||||
.person-related-unavailable text:first-child {
|
||||
font-size: clamp(15px, 25rpx, 18px);
|
||||
font-weight: 700;
|
||||
}
|
||||
.person-related-unavailable text:last-child {
|
||||
margin-top: 4rpx;
|
||||
font-size: clamp(13px, 20rpx, 16px);
|
||||
}
|
||||
.person-state-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -1,192 +0,0 @@
|
||||
<!-- 页面编号:R-03;用途:读取并展示当前家谱的亲友往来记录。 -->
|
||||
<template>
|
||||
<view class="gift-page">
|
||||
<ModulePageBackground module="records" />
|
||||
<view class="page-header"
|
||||
><PageHeader
|
||||
title="贺礼簿"
|
||||
:action="valid ? '新建' : ''"
|
||||
custom-back
|
||||
@back="returnToFamily"
|
||||
@action="createRelative"
|
||||
/></view>
|
||||
<view class="page-content">
|
||||
<view v-if="!valid" class="state-card"
|
||||
><text>贺礼簿入口无效</text
|
||||
><AppButton block label="返回上一页" @click="returnToFamily"
|
||||
/></view>
|
||||
<view v-else-if="state === 'loading'" class="state-card"
|
||||
><AppLoading text="正在读取亲友往来"
|
||||
/></view>
|
||||
<view v-else-if="state === 'error'" class="state-card"
|
||||
><text>暂时无法读取亲友往来</text
|
||||
><AppButton
|
||||
block
|
||||
type="secondary"
|
||||
label="重新加载"
|
||||
@click="loadRecords"
|
||||
/></view>
|
||||
<view v-else-if="state === 'empty'" class="state-card"
|
||||
><text>还没有亲友往来记录</text
|
||||
><AppButton block label="新建往来记录" @click="createRelative"
|
||||
/></view>
|
||||
<view v-else class="record-list">
|
||||
<view v-for="item in records" :key="item.id" class="record-card">
|
||||
<view
|
||||
><text>{{ item.name }}</text
|
||||
><text
|
||||
>{{ item.relation
|
||||
}}{{ item.event ? ` · ${item.event}` : "" }}</text
|
||||
><text v-if="item.time || item.content">{{
|
||||
item.time || item.content
|
||||
}}</text></view
|
||||
>
|
||||
<text v-if="item.amount">¥{{ item.amount }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref } from "vue";
|
||||
import { onLoad, onShow, onUnload } from "@dcloudio/uni-app";
|
||||
import AppButton from "@/components/AppButton.vue";
|
||||
import AppLoading from "@/components/AppLoading.vue";
|
||||
import ModulePageBackground from "@/components/ModulePageBackground.vue";
|
||||
import PageHeader from "@/components/PageHeader.vue";
|
||||
import {
|
||||
appApi,
|
||||
createRequestController,
|
||||
isRequestCancelled,
|
||||
} from "@/utils/api.js";
|
||||
import { goBack, openPage, returnTo } from "@/utils/navigation.js";
|
||||
|
||||
const genealogyId = ref("");
|
||||
const records = ref([]);
|
||||
const state = ref("loading");
|
||||
const controller = createRequestController();
|
||||
let active = true;
|
||||
const valid = computed(() => /^[1-9]\d*$/.test(genealogyId.value));
|
||||
const loadRecords = async () => {
|
||||
if (!valid.value) return;
|
||||
controller.abort();
|
||||
state.value = "loading";
|
||||
try {
|
||||
const rows = await appApi.getRelativeRecords(genealogyId.value, {
|
||||
requestController: controller,
|
||||
});
|
||||
if (!active) return;
|
||||
records.value = rows
|
||||
.map((item) => ({
|
||||
id: String(item.relativeId || ""),
|
||||
name: String(item.relativeName || "未命名亲友"),
|
||||
relation: String(item.relationName || ""),
|
||||
event: String(item.eventName || ""),
|
||||
time: String(item.eventTime || ""),
|
||||
amount:
|
||||
item.giftAmount == null || item.giftAmount === ""
|
||||
? ""
|
||||
: String(item.giftAmount),
|
||||
content: String(item.recordContent || ""),
|
||||
}))
|
||||
.filter((item) => /^[1-9]\d*$/.test(item.id));
|
||||
state.value = records.value.length ? "ready" : "empty";
|
||||
} catch (error) {
|
||||
if (!active || isRequestCancelled(error)) return;
|
||||
state.value = "error";
|
||||
}
|
||||
};
|
||||
const returnToFamily = () =>
|
||||
valid.value ? returnTo("F01", { genealogyId: genealogyId.value }) : goBack();
|
||||
const createRelative = () =>
|
||||
valid.value
|
||||
? openPage("R04", { genealogyId: genealogyId.value, mode: "create" }, "R03")
|
||||
: Promise.resolve(false);
|
||||
onLoad((query) => {
|
||||
genealogyId.value = String(query?.genealogyId || "");
|
||||
if (valid.value) loadRecords();
|
||||
});
|
||||
onShow(() => {
|
||||
if (valid.value && state.value !== "loading") loadRecords();
|
||||
});
|
||||
onUnload(() => {
|
||||
active = false;
|
||||
controller.abort();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@import "../../styles/adaptive-frame-profiles.scss";
|
||||
.gift-page {
|
||||
display: flex;
|
||||
min-height: 100vh;
|
||||
flex-direction: column;
|
||||
background: $paper;
|
||||
}
|
||||
.page-header,
|
||||
.page-content {
|
||||
z-index: 1;
|
||||
}
|
||||
.page-content {
|
||||
padding: 18rpx 24rpx 72rpx;
|
||||
}
|
||||
.state-card,
|
||||
.record-card {
|
||||
@include adaptive-records-content;
|
||||
}
|
||||
.state-card {
|
||||
display: flex;
|
||||
min-height: 320rpx;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 42rpx;
|
||||
text-align: center;
|
||||
color: $ink;
|
||||
font-size: clamp(16px, 28rpx, 20px);
|
||||
}
|
||||
.state-card .app-button {
|
||||
width: 100%;
|
||||
margin-top: 24rpx;
|
||||
}
|
||||
.record-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16rpx;
|
||||
}
|
||||
.record-card {
|
||||
display: flex;
|
||||
min-height: 138rpx;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 18rpx;
|
||||
padding: 28rpx 32rpx;
|
||||
}
|
||||
.record-card > view {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
.record-card text {
|
||||
display: block;
|
||||
}
|
||||
.record-card > view text:first-child {
|
||||
color: $ink;
|
||||
font-family: STKaiti, KaiTi, serif;
|
||||
font-size: clamp(17px, 30rpx, 22px);
|
||||
font-weight: 700;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.record-card > view text:not(:first-child) {
|
||||
margin-top: 7rpx;
|
||||
color: $ink-muted;
|
||||
font-size: clamp(13px, 21rpx, 16px);
|
||||
line-height: 1.45;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.record-card > text {
|
||||
flex: 0 0 auto;
|
||||
color: $brand-red;
|
||||
font-size: clamp(14px, 22rpx, 17px);
|
||||
}
|
||||
</style>
|
||||
@@ -1,165 +0,0 @@
|
||||
<template>
|
||||
<view class="ritual-detail-page"
|
||||
><ModulePageBackground module="records" /><view class="page-header"
|
||||
><PageHeader title="礼仪详情" custom-back @back="returnToList" /></view
|
||||
><view class="page-content"
|
||||
><view v-if="!valid" class="state-card"
|
||||
><text>礼仪入口无效</text
|
||||
><text>未获取到可查看的礼仪活动标识,请从活动列表重新进入。</text
|
||||
><AppButton block label="返回上一页" @click="returnToList" /></view
|
||||
><view v-else-if="state === 'loading'" class="state-card"
|
||||
><AppLoading text="正在读取礼仪详情" /></view
|
||||
><view v-else-if="state === 'error'" class="state-card"
|
||||
><text>暂时无法读取礼仪详情</text
|
||||
><text>请检查网络后重新加载。</text
|
||||
><AppButton
|
||||
block
|
||||
type="secondary"
|
||||
label="重新加载"
|
||||
@click="loadDetail" /></view
|
||||
><view v-else class="detail-card"
|
||||
><text>{{ detail.title }}</text
|
||||
><text
|
||||
>{{ detail.type }}{{ detail.time ? ` · ${detail.time}` : "" }}</text
|
||||
><text v-if="detail.location">地点:{{ detail.location }}</text
|
||||
><text v-if="detail.description">{{ detail.description }}</text
|
||||
><text>已有 {{ detail.giftCount }} 笔献礼</text></view
|
||||
></view
|
||||
></view
|
||||
>
|
||||
</template>
|
||||
<script setup>
|
||||
import { computed, ref } from "vue";
|
||||
import { onLoad, onUnload } from "@dcloudio/uni-app";
|
||||
import AppButton from "@/components/AppButton.vue";
|
||||
import AppLoading from "@/components/AppLoading.vue";
|
||||
import ModulePageBackground from "@/components/ModulePageBackground.vue";
|
||||
import PageHeader from "@/components/PageHeader.vue";
|
||||
import {
|
||||
appApi,
|
||||
createRequestController,
|
||||
isRequestCancelled,
|
||||
} from "@/utils/api.js";
|
||||
import { goBack, returnTo } from "@/utils/navigation.js";
|
||||
const genealogyId = ref("");
|
||||
const ceremonyId = ref("");
|
||||
const state = ref("loading");
|
||||
const detail = ref({
|
||||
title: "",
|
||||
type: "",
|
||||
time: "",
|
||||
location: "",
|
||||
description: "",
|
||||
giftCount: 0,
|
||||
});
|
||||
const controller = createRequestController();
|
||||
let active = true;
|
||||
const valid = computed(
|
||||
() =>
|
||||
/^[1-9]\d*$/.test(genealogyId.value) && /^[1-9]\d*$/.test(ceremonyId.value),
|
||||
);
|
||||
const loadDetail = async () => {
|
||||
if (!valid.value) return;
|
||||
controller.abort();
|
||||
state.value = "loading";
|
||||
try {
|
||||
const item = await appApi.getCeremonyDetail(
|
||||
genealogyId.value,
|
||||
ceremonyId.value,
|
||||
{ requestController: controller },
|
||||
);
|
||||
if (!active) return;
|
||||
detail.value = {
|
||||
title: String(item.ceremonyTitle || "未命名活动"),
|
||||
type: String(item.ceremonyType || ""),
|
||||
time: String(item.ceremonyTime || ""),
|
||||
location: String(item.location || item.locationAddress || ""),
|
||||
description: String(item.ceremonyDesc || ""),
|
||||
giftCount: Number.isSafeInteger(item.giftCount) ? item.giftCount : 0,
|
||||
};
|
||||
state.value = "ready";
|
||||
} catch (error) {
|
||||
if (!active || isRequestCancelled(error)) return;
|
||||
state.value = "error";
|
||||
}
|
||||
};
|
||||
onLoad((query) => {
|
||||
genealogyId.value = String(query?.genealogyId || "");
|
||||
ceremonyId.value = String(query?.ceremonyId || "");
|
||||
if (valid.value) loadDetail();
|
||||
else state.value = "invalid";
|
||||
});
|
||||
onUnload(() => {
|
||||
active = false;
|
||||
controller.abort();
|
||||
});
|
||||
const returnToList = () =>
|
||||
/^[1-9]\d*$/.test(genealogyId.value)
|
||||
? returnTo("R05", { genealogyId: genealogyId.value })
|
||||
: goBack();
|
||||
</script>
|
||||
<style scoped lang="scss">
|
||||
@import "../../styles/adaptive-frame-profiles.scss";
|
||||
.ritual-detail-page {
|
||||
display: flex;
|
||||
min-height: 100vh;
|
||||
flex-direction: column;
|
||||
background: $paper;
|
||||
}
|
||||
.page-header,
|
||||
.page-content {
|
||||
z-index: 1;
|
||||
}
|
||||
.page-content {
|
||||
padding: 18rpx 24rpx 72rpx;
|
||||
}
|
||||
.state-card,
|
||||
.detail-card {
|
||||
@include adaptive-records-content;
|
||||
}
|
||||
.state-card {
|
||||
display: flex;
|
||||
min-height: 320rpx;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 42rpx;
|
||||
text-align: center;
|
||||
color: $ink;
|
||||
font-size: clamp(16px, 28rpx, 20px);
|
||||
}
|
||||
.state-card .app-button {
|
||||
width: 100%;
|
||||
margin-top: 24rpx;
|
||||
}
|
||||
.state-card text {
|
||||
display: block;
|
||||
}
|
||||
.state-card text:nth-child(2) {
|
||||
margin-top: 14rpx;
|
||||
color: $ink-muted;
|
||||
font-size: clamp(14px, 22rpx, 17px);
|
||||
line-height: 1.55;
|
||||
}
|
||||
.detail-card {
|
||||
display: flex;
|
||||
min-height: 320rpx;
|
||||
flex-direction: column;
|
||||
padding: 42rpx;
|
||||
}
|
||||
.detail-card text {
|
||||
display: block;
|
||||
}
|
||||
.detail-card text:first-child {
|
||||
color: $ink;
|
||||
font-family: STKaiti, KaiTi, serif;
|
||||
font-size: clamp(19px, 36rpx, 24px);
|
||||
font-weight: 700;
|
||||
}
|
||||
.detail-card text:not(:first-child) {
|
||||
margin-top: 14rpx;
|
||||
color: $ink-muted;
|
||||
font-size: clamp(14px, 23rpx, 17px);
|
||||
line-height: 1.6;
|
||||
}
|
||||
</style>
|
||||
@@ -1,602 +0,0 @@
|
||||
<!-- 页面编号:R-08;用途:读取、新建当前家谱的成长记录。 -->
|
||||
<template>
|
||||
<view class="record-page">
|
||||
<ModulePageBackground module="records" />
|
||||
<view class="page-header">
|
||||
<PageHeader
|
||||
title="成长日志"
|
||||
:action="valid && view === 'list' ? '新建' : ''"
|
||||
custom-back
|
||||
@back="requestBack"
|
||||
@action="openCreate"
|
||||
/>
|
||||
</view>
|
||||
<view class="page-content">
|
||||
<view v-if="view === 'form'" class="form-card">
|
||||
<text>新建成长记录</text>
|
||||
<text class="form-copy">红色 * 为必填项,其余内容可按需补充。</text>
|
||||
<view class="field field--context"
|
||||
><text>关联人物</text
|
||||
><view
|
||||
><text>{{ personName }}</text
|
||||
><text>{{ personId ? "已自动关联" : "未关联人物" }}</text></view
|
||||
></view
|
||||
>
|
||||
<view class="field"
|
||||
><text>记录类型</text
|
||||
><input
|
||||
v-model="form.recordType"
|
||||
placeholder="请输入记录类型"
|
||||
@input="error = ''"
|
||||
/></view>
|
||||
<view class="field"
|
||||
><text><text class="required-mark">*</text>记录标题</text
|
||||
><input
|
||||
v-model="form.recordTitle"
|
||||
placeholder="请输入记录标题"
|
||||
@input="error = ''"
|
||||
/></view>
|
||||
<view class="field field--textarea"
|
||||
><text>记录内容</text
|
||||
><textarea
|
||||
v-model="form.recordContent"
|
||||
auto-height
|
||||
placeholder="记录成长片段"
|
||||
@input="error = ''"
|
||||
/>
|
||||
</view>
|
||||
<view class="field field--picker"
|
||||
><text>记录日期</text
|
||||
><picker
|
||||
mode="date"
|
||||
:value="form.recordDate"
|
||||
@change="selectRecordDate"
|
||||
><view :class="{ placeholder: !form.recordDate }">{{
|
||||
form.recordDate || "请选择"
|
||||
}}</view></picker
|
||||
></view
|
||||
>
|
||||
<view class="field field--picker"
|
||||
><text>记录时间</text
|
||||
><picker
|
||||
mode="time"
|
||||
:value="form.recordClock"
|
||||
@change="selectRecordClock"
|
||||
><view :class="{ placeholder: !form.recordClock }">{{
|
||||
form.recordClock || "请选择"
|
||||
}}</view></picker
|
||||
></view
|
||||
>
|
||||
<view class="field field--picker"
|
||||
><text>提醒日期</text
|
||||
><picker
|
||||
mode="date"
|
||||
:value="form.remindDate"
|
||||
@change="selectRemindDate"
|
||||
><view :class="{ placeholder: !form.remindDate }">{{
|
||||
form.remindDate || "请选择"
|
||||
}}</view></picker
|
||||
></view
|
||||
>
|
||||
<view class="field field--picker"
|
||||
><text>提醒时间</text
|
||||
><picker
|
||||
mode="time"
|
||||
:value="form.remindClock"
|
||||
@change="selectRemindClock"
|
||||
><view :class="{ placeholder: !form.remindClock }">{{
|
||||
form.remindClock || "请选择"
|
||||
}}</view></picker
|
||||
></view
|
||||
>
|
||||
<view class="upload-field">
|
||||
<view
|
||||
><text>相关图片</text
|
||||
><text
|
||||
>图片选定后会先取得真实上传回执,并在提交记录时关联。</text
|
||||
></view
|
||||
>
|
||||
<button
|
||||
class="upload-button"
|
||||
:disabled="uploading || submitting"
|
||||
@click="uploadImage"
|
||||
>
|
||||
{{ uploading ? "上传中…" : "添加图片" }}
|
||||
</button>
|
||||
<text
|
||||
v-for="(receipt, index) in mediaReceipts"
|
||||
:key="`${receipt.ossId}-${index}`"
|
||||
>已上传:{{ receipt.fileName || "图片" }}</text
|
||||
>
|
||||
<text v-if="uploadError" class="error">{{ uploadError }}</text>
|
||||
</view>
|
||||
<view class="field"
|
||||
><text>排序值</text
|
||||
><input
|
||||
v-model="form.sortOrder"
|
||||
type="number"
|
||||
placeholder="数值越小越靠前"
|
||||
@input="error = ''"
|
||||
/></view>
|
||||
<text v-if="error" class="error">{{ error }}</text>
|
||||
<view class="form-actions"
|
||||
><AppButton
|
||||
type="secondary"
|
||||
label="取消"
|
||||
@click="cancelCreate" /><AppButton
|
||||
:disabled="submitting || uploading"
|
||||
:label="submitting ? '正在提交' : '提交成长记录'"
|
||||
@click="submit"
|
||||
/></view>
|
||||
</view>
|
||||
<view v-else-if="!valid" class="state-card"
|
||||
><text>成长日志入口无效</text
|
||||
><text>未获取到可查看的家谱标识,请从家族页面重新进入。</text
|
||||
><AppButton block label="返回上一页" @click="requestBack"
|
||||
/></view>
|
||||
<view v-else-if="listState === 'loading'" class="state-card"
|
||||
><AppLoading text="正在读取成长记录"
|
||||
/></view>
|
||||
<view v-else-if="listState === 'error'" class="state-card"
|
||||
><text>暂时无法读取成长记录</text
|
||||
><text>请检查网络后重新加载。</text
|
||||
><AppButton
|
||||
block
|
||||
type="secondary"
|
||||
label="重新加载"
|
||||
@click="loadRecords"
|
||||
/></view>
|
||||
<view v-else-if="listState === 'empty'" class="state-card"
|
||||
><text>还没有成长记录</text
|
||||
><AppButton block label="新建成长记录" @click="openCreate"
|
||||
/></view>
|
||||
<view v-else class="record-list">
|
||||
<text v-if="saveNotice" class="save-notice"
|
||||
>成长记录已提交,并已从服务端重新读取。</text
|
||||
>
|
||||
<view v-for="item in records" :key="item.id" class="record-card">
|
||||
<view
|
||||
><text>{{ item.title }}</text
|
||||
><text
|
||||
>{{ item.type || item.personName
|
||||
}}{{ item.date ? ` · ${item.date}` : "" }}</text
|
||||
><text v-if="item.content">{{ item.content }}</text></view
|
||||
>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<AppDialog
|
||||
:visible="discardVisible"
|
||||
title="放弃成长记录?"
|
||||
message="尚未提交的内容将被清除。"
|
||||
confirm-text="放弃并返回"
|
||||
cancel-text="继续填写"
|
||||
show-cancel
|
||||
:close-on-mask="false"
|
||||
@confirm="confirmDiscard"
|
||||
@cancel="cancelDiscard"
|
||||
/>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, onUnmounted, reactive, ref } from "vue";
|
||||
import { onBackPress, onLoad, onShow } 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";
|
||||
import {
|
||||
appApi,
|
||||
createRequestController,
|
||||
isRequestCancelled,
|
||||
} from "@/utils/api.js";
|
||||
import { createDiscardConfirmation } from "@/utils/discard-confirmation.js";
|
||||
import {
|
||||
isImagePickCancelled,
|
||||
pickAndUploadImage,
|
||||
} from "@/utils/resumable-image-upload.js";
|
||||
import { goBack, handleBackPress, runBackGuard } from "@/utils/navigation.js";
|
||||
|
||||
const genealogyId = ref("");
|
||||
const personId = ref("");
|
||||
const personName = ref("当前家谱");
|
||||
const view = ref("list");
|
||||
const listState = ref("loading");
|
||||
const records = ref([]);
|
||||
const saveNotice = ref(false);
|
||||
const submitting = ref(false);
|
||||
const uploading = ref(false);
|
||||
const error = ref("");
|
||||
const uploadError = ref("");
|
||||
const discardVisible = ref(false);
|
||||
const form = reactive({
|
||||
lineagePersonId: "",
|
||||
recordType: "",
|
||||
recordTitle: "",
|
||||
recordContent: "",
|
||||
recordDate: "",
|
||||
recordClock: "",
|
||||
remindDate: "",
|
||||
remindClock: "",
|
||||
sortOrder: "",
|
||||
});
|
||||
const mediaReceipts = ref([]);
|
||||
const controller = createRequestController();
|
||||
let active = true;
|
||||
const valid = computed(() => /^[1-9]\d*$/.test(genealogyId.value));
|
||||
const mediaOssIds = computed(() =>
|
||||
mediaReceipts.value.map((item) => item.ossId).join(","),
|
||||
);
|
||||
const recordTime = computed(() =>
|
||||
form.recordDate ? `${form.recordDate} ${form.recordClock || "00:00"}:00` : "",
|
||||
);
|
||||
const remindTime = computed(() =>
|
||||
form.remindDate ? `${form.remindDate} ${form.remindClock || "00:00"}:00` : "",
|
||||
);
|
||||
const dirty = computed(
|
||||
() =>
|
||||
Object.values(form).some((value) => String(value).trim()) ||
|
||||
mediaReceipts.value.length > 0,
|
||||
);
|
||||
const confirmation = createDiscardConfirmation((visible) => {
|
||||
discardVisible.value = visible;
|
||||
});
|
||||
const confirmDiscard = () => {
|
||||
confirmation.confirm();
|
||||
resetForm();
|
||||
view.value = "list";
|
||||
};
|
||||
const cancelDiscard = confirmation.cancel;
|
||||
|
||||
const resetForm = () => {
|
||||
Object.assign(form, {
|
||||
lineagePersonId: personId.value,
|
||||
recordType: "",
|
||||
recordTitle: "",
|
||||
recordContent: "",
|
||||
recordDate: "",
|
||||
recordClock: "",
|
||||
remindDate: "",
|
||||
remindClock: "",
|
||||
sortOrder: "",
|
||||
});
|
||||
mediaReceipts.value = [];
|
||||
error.value = "";
|
||||
uploadError.value = "";
|
||||
};
|
||||
const loadRecords = async () => {
|
||||
if (!valid.value) return;
|
||||
controller.abort();
|
||||
listState.value = "loading";
|
||||
try {
|
||||
const rows = await appApi.getGrowthRecords(genealogyId.value, {
|
||||
requestController: controller,
|
||||
});
|
||||
if (!active) return;
|
||||
records.value = rows
|
||||
.map((item) => ({
|
||||
id: String(item.recordId || ""),
|
||||
title: String(item.recordTitle || "未命名记录"),
|
||||
type: String(item.recordType || ""),
|
||||
personName: String(item.lineagePersonName || "未关联人物"),
|
||||
date: String(item.recordDate || item.remindTime || ""),
|
||||
content: String(item.recordContent || ""),
|
||||
}))
|
||||
.filter((item) => /^[1-9]\d*$/.test(item.id));
|
||||
listState.value = records.value.length ? "ready" : "empty";
|
||||
} catch (cause) {
|
||||
if (!active || isRequestCancelled(cause)) return;
|
||||
listState.value = "error";
|
||||
}
|
||||
};
|
||||
const openCreate = () => {
|
||||
if (!valid.value) return;
|
||||
saveNotice.value = false;
|
||||
resetForm();
|
||||
view.value = "form";
|
||||
};
|
||||
const cancelCreate = () => {
|
||||
resetForm();
|
||||
view.value = "list";
|
||||
};
|
||||
const selectRecordDate = (event) => {
|
||||
form.recordDate = event.detail.value || "";
|
||||
error.value = "";
|
||||
};
|
||||
const selectRecordClock = (event) => {
|
||||
form.recordClock = event.detail.value || "";
|
||||
error.value = "";
|
||||
};
|
||||
const selectRemindDate = (event) => {
|
||||
form.remindDate = event.detail.value || "";
|
||||
error.value = "";
|
||||
};
|
||||
const selectRemindClock = (event) => {
|
||||
form.remindClock = event.detail.value || "";
|
||||
error.value = "";
|
||||
};
|
||||
const uploadImage = async () => {
|
||||
if (uploading.value || submitting.value) return;
|
||||
uploading.value = true;
|
||||
uploadError.value = "";
|
||||
try {
|
||||
mediaReceipts.value = [
|
||||
...mediaReceipts.value,
|
||||
await pickAndUploadImage({ requestController: controller }),
|
||||
];
|
||||
} catch (cause) {
|
||||
if (!isImagePickCancelled(cause) && !isRequestCancelled(cause))
|
||||
uploadError.value = cause?.message || "图片上传失败,请稍后重试。";
|
||||
} finally {
|
||||
uploading.value = false;
|
||||
}
|
||||
};
|
||||
const submit = async () => {
|
||||
if (submitting.value || uploading.value || !valid.value) return;
|
||||
if (!form.recordTitle.trim()) {
|
||||
error.value = "请填写记录标题";
|
||||
return;
|
||||
}
|
||||
submitting.value = true;
|
||||
error.value = "";
|
||||
try {
|
||||
const { recordClock, remindDate, remindClock, ...recordForm } = form;
|
||||
await appApi.createGrowthRecord(
|
||||
genealogyId.value,
|
||||
{
|
||||
...recordForm,
|
||||
recordDate: recordTime.value,
|
||||
remindTime: remindTime.value,
|
||||
mediaOssIds: mediaOssIds.value,
|
||||
},
|
||||
{ requestController: controller },
|
||||
);
|
||||
resetForm();
|
||||
view.value = "list";
|
||||
saveNotice.value = true;
|
||||
await loadRecords();
|
||||
} catch (cause) {
|
||||
if (!isRequestCancelled(cause))
|
||||
error.value = cause?.message || "成长记录提交失败,请稍后重试。";
|
||||
} finally {
|
||||
submitting.value = false;
|
||||
}
|
||||
};
|
||||
const requestBack = () =>
|
||||
view.value !== "form"
|
||||
? goBack()
|
||||
: runBackGuard({
|
||||
transientOpen: discardVisible.value,
|
||||
dirty: dirty.value,
|
||||
submitting: submitting.value || uploading.value,
|
||||
"close-transient": cancelDiscard,
|
||||
"block-submitting": () => true,
|
||||
"confirm-discard": confirmation.request,
|
||||
});
|
||||
|
||||
onLoad((query) => {
|
||||
genealogyId.value = String(query?.genealogyId || "");
|
||||
personId.value = /^[1-9]\d*$/.test(String(query?.personId || ""))
|
||||
? String(query.personId)
|
||||
: "";
|
||||
personName.value = String(
|
||||
query?.personName || (personId.value ? "当前查看的人物" : "当前家谱"),
|
||||
);
|
||||
resetForm();
|
||||
if (valid.value) loadRecords();
|
||||
else listState.value = "invalid";
|
||||
});
|
||||
onShow(() => {
|
||||
if (valid.value && view.value === "list" && listState.value !== "loading")
|
||||
loadRecords();
|
||||
});
|
||||
onBackPress((event) => handleBackPress(event, requestBack));
|
||||
onUnmounted(() => {
|
||||
active = false;
|
||||
controller.abort();
|
||||
confirmation.dispose();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@import "../../styles/adaptive-frame-profiles.scss";
|
||||
.record-page {
|
||||
display: flex;
|
||||
min-height: 100vh;
|
||||
flex-direction: column;
|
||||
background: $paper;
|
||||
}
|
||||
.page-header,
|
||||
.page-content {
|
||||
z-index: 1;
|
||||
}
|
||||
.page-content {
|
||||
padding: 18rpx 24rpx 72rpx;
|
||||
}
|
||||
.form-card,
|
||||
.state-card,
|
||||
.record-card {
|
||||
@include adaptive-records-content;
|
||||
}
|
||||
.form-card {
|
||||
box-sizing: border-box;
|
||||
padding: 46rpx;
|
||||
}
|
||||
.form-card > text:first-child {
|
||||
display: block;
|
||||
color: $ink;
|
||||
font-size: clamp(19px, 34rpx, 24px);
|
||||
font-weight: 700;
|
||||
}
|
||||
.form-copy {
|
||||
display: block;
|
||||
margin-top: 12rpx;
|
||||
color: $ink-muted;
|
||||
font-size: clamp(14px, 22rpx, 17px);
|
||||
line-height: 1.55;
|
||||
}
|
||||
.field {
|
||||
margin-top: 16rpx;
|
||||
}
|
||||
.field > text {
|
||||
display: block;
|
||||
margin: 0 8rpx 8rpx;
|
||||
color: $ink;
|
||||
font-size: clamp(14px, 23rpx, 17px);
|
||||
font-weight: 700;
|
||||
}
|
||||
.required-mark {
|
||||
display: inline-block;
|
||||
margin-right: 4rpx;
|
||||
color: $brand-red;
|
||||
font-size: clamp(16px, 26rpx, 20px);
|
||||
transform: translateY(-3rpx);
|
||||
}
|
||||
.field input,
|
||||
.field textarea,
|
||||
.field--picker picker > view {
|
||||
@include adaptive-records-field;
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
min-height: 76rpx;
|
||||
padding: 16rpx 22rpx;
|
||||
color: $ink;
|
||||
font-size: clamp(14px, 23rpx, 17px);
|
||||
}
|
||||
.field textarea {
|
||||
min-height: 150rpx;
|
||||
}
|
||||
.field--picker picker {
|
||||
display: block;
|
||||
}
|
||||
.field--picker .placeholder {
|
||||
color: $ink-muted;
|
||||
}
|
||||
.field--context > view {
|
||||
@include adaptive-records-field;
|
||||
display: flex;
|
||||
min-height: 76rpx;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 16rpx 22rpx;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.field--context > view > text:first-child {
|
||||
color: $ink;
|
||||
font-size: clamp(14px, 23rpx, 17px);
|
||||
}
|
||||
.field--context > view > text:last-child {
|
||||
color: $ink-muted;
|
||||
font-size: clamp(13px, 21rpx, 16px);
|
||||
}
|
||||
.upload-field {
|
||||
display: grid;
|
||||
gap: 12rpx;
|
||||
margin-top: 16rpx;
|
||||
padding: 18rpx 22rpx;
|
||||
@include adaptive-records-field;
|
||||
}
|
||||
.upload-field > view > text {
|
||||
display: block;
|
||||
}
|
||||
.upload-field > view > text:first-child {
|
||||
color: $ink;
|
||||
font-size: clamp(14px, 23rpx, 17px);
|
||||
font-weight: 700;
|
||||
}
|
||||
.upload-field > view > text:last-child {
|
||||
margin-top: 6rpx;
|
||||
color: $ink-muted;
|
||||
font-size: clamp(13px, 20rpx, 16px);
|
||||
line-height: 1.45;
|
||||
}
|
||||
.upload-button {
|
||||
justify-self: start;
|
||||
min-height: 88rpx;
|
||||
margin: 0;
|
||||
padding: 0 20rpx;
|
||||
border: 1rpx solid rgba(159, 23, 15, 0.42);
|
||||
border-radius: 8rpx;
|
||||
background: transparent;
|
||||
color: $brand-red;
|
||||
font-size: clamp(14px, 22rpx, 17px);
|
||||
}
|
||||
.upload-field > text,
|
||||
.error {
|
||||
color: $ink-muted;
|
||||
font-size: clamp(13px, 21rpx, 16px);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.error {
|
||||
display: block;
|
||||
margin-top: 12rpx;
|
||||
color: $brand-red;
|
||||
font-size: clamp(14px, 22rpx, 17px);
|
||||
}
|
||||
.form-actions {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(0, 1.45fr);
|
||||
gap: 16rpx;
|
||||
margin-top: 20rpx;
|
||||
}
|
||||
.form-actions .app-button {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
.state-card {
|
||||
display: flex;
|
||||
min-height: 320rpx;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 42rpx;
|
||||
color: $ink;
|
||||
font-size: clamp(16px, 28rpx, 20px);
|
||||
text-align: center;
|
||||
}
|
||||
.state-card .app-button {
|
||||
width: 100%;
|
||||
margin-top: 24rpx;
|
||||
}
|
||||
.state-card text {
|
||||
display: block;
|
||||
}
|
||||
.state-card text:nth-child(2) {
|
||||
margin-top: 14rpx;
|
||||
color: $ink-muted;
|
||||
font-size: clamp(14px, 22rpx, 17px);
|
||||
line-height: 1.55;
|
||||
}
|
||||
.record-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16rpx;
|
||||
}
|
||||
.save-notice {
|
||||
display: block;
|
||||
color: $brand-red;
|
||||
font-size: clamp(14px, 22rpx, 17px);
|
||||
}
|
||||
.record-card {
|
||||
min-height: 128rpx;
|
||||
padding: 28rpx 32rpx;
|
||||
}
|
||||
.record-card text {
|
||||
display: block;
|
||||
}
|
||||
.record-card text:first-child {
|
||||
color: $ink;
|
||||
font-family: STKaiti, KaiTi, serif;
|
||||
font-size: clamp(17px, 30rpx, 22px);
|
||||
font-weight: 700;
|
||||
}
|
||||
.record-card text:not(:first-child) {
|
||||
margin-top: 7rpx;
|
||||
color: $ink-muted;
|
||||
font-size: clamp(13px, 21rpx, 16px);
|
||||
line-height: 1.45;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
</style>
|
||||
@@ -1,73 +0,0 @@
|
||||
<!-- 页面编号:R-09;用途:人生大事。当前没有独立业务 operation。 -->
|
||||
<template>
|
||||
<view class="life-page"
|
||||
><ModulePageBackground module="records" /><view class="page-header"
|
||||
><PageHeader title="人生大事" custom-back @back="returnToRecords" /></view
|
||||
><view class="page-content"
|
||||
><view class="state-card"
|
||||
><text>人生大事暂未开放</text
|
||||
><text
|
||||
>该功能正在准备中。开放后,重要人生经历会在这里沉淀为家族记忆。</text
|
||||
><AppButton
|
||||
block
|
||||
:label="hasValidContext ? '返回记录首页' : '返回上一页'"
|
||||
@click="returnToRecords" /></view></view
|
||||
></view>
|
||||
</template>
|
||||
<script setup>
|
||||
import { computed, ref } from "vue";
|
||||
import { onLoad } from "@dcloudio/uni-app";
|
||||
import AppButton from "@/components/AppButton.vue";
|
||||
import ModulePageBackground from "@/components/ModulePageBackground.vue";
|
||||
import PageHeader from "@/components/PageHeader.vue";
|
||||
import { goBack, returnTo } from "@/utils/navigation.js";
|
||||
const genealogyId = ref("");
|
||||
const hasValidContext = computed(() => /^[1-9]\d*$/.test(genealogyId.value));
|
||||
onLoad((query) => {
|
||||
genealogyId.value = String(query?.genealogyId || "");
|
||||
});
|
||||
const returnToRecords = () =>
|
||||
hasValidContext.value
|
||||
? returnTo("R01", { genealogyId: genealogyId.value })
|
||||
: goBack();
|
||||
</script>
|
||||
<style scoped lang="scss">
|
||||
@import "../../styles/adaptive-frame-profiles.scss";
|
||||
.life-page {
|
||||
display: flex;
|
||||
min-height: 100vh;
|
||||
flex-direction: column;
|
||||
background: $paper;
|
||||
}
|
||||
.page-header,
|
||||
.page-content {
|
||||
z-index: 1;
|
||||
}
|
||||
.page-content {
|
||||
padding: 18rpx 24rpx 72rpx;
|
||||
}
|
||||
.state-card {
|
||||
box-sizing: border-box;
|
||||
min-height: 340rpx;
|
||||
padding: 78rpx 52rpx 50rpx;
|
||||
text-align: center;
|
||||
@include adaptive-records-content;
|
||||
}
|
||||
.state-card text {
|
||||
display: block;
|
||||
}
|
||||
.state-card text:first-child {
|
||||
color: $ink;
|
||||
font-size: clamp(19px, 35rpx, 24px);
|
||||
font-weight: 700;
|
||||
}
|
||||
.state-card text:nth-child(2) {
|
||||
margin-top: 18rpx;
|
||||
color: $ink-muted;
|
||||
font-size: clamp(15px, 24rpx, 18px);
|
||||
line-height: 1.65;
|
||||
}
|
||||
.state-card .app-button {
|
||||
margin-top: 28rpx;
|
||||
}
|
||||
</style>
|
||||
@@ -1,497 +0,0 @@
|
||||
<!-- 页面编号:R-10;用途:读取、新建当前家谱的家族备忘。 -->
|
||||
<template>
|
||||
<view class="memo-page">
|
||||
<ModulePageBackground module="records" />
|
||||
<view class="page-header"
|
||||
><PageHeader
|
||||
title="家族备忘"
|
||||
:action="valid && view === 'list' ? '新建' : ''"
|
||||
custom-back
|
||||
@back="requestBack"
|
||||
@action="openCreate"
|
||||
/></view>
|
||||
<view class="page-content">
|
||||
<view v-if="view === 'form'" class="form-card">
|
||||
<text>新建家族备忘</text>
|
||||
<text class="form-copy">红色 * 为必填项,其余内容可按需补充。</text>
|
||||
<view class="field"
|
||||
><text><text class="required-mark">*</text>备忘标题</text
|
||||
><input
|
||||
v-model="form.memoTitle"
|
||||
maxlength="40"
|
||||
placeholder="请输入备忘标题"
|
||||
@input="error = ''"
|
||||
/></view>
|
||||
<picker mode="date" :value="form.remindDate" @change="selectRemindDate"
|
||||
><view class="field field--picker"
|
||||
><text>提醒日期</text
|
||||
><text>{{ form.remindDate || "请选择" }}</text></view
|
||||
></picker
|
||||
>
|
||||
<picker
|
||||
mode="time"
|
||||
:value="form.remindClock"
|
||||
@change="selectRemindClock"
|
||||
><view class="field field--picker"
|
||||
><text>提醒时间</text
|
||||
><text>{{ form.remindClock || "请选择" }}</text></view
|
||||
></picker
|
||||
>
|
||||
<view class="field field--textarea"
|
||||
><text>备忘内容</text
|
||||
><textarea
|
||||
v-model="form.memoContent"
|
||||
auto-height
|
||||
maxlength="1200"
|
||||
placeholder="记录需要提醒的事情"
|
||||
@input="error = ''"
|
||||
/>
|
||||
</view>
|
||||
<view class="upload-field">
|
||||
<view
|
||||
><text>相关图片</text
|
||||
><text
|
||||
>图片选定后会先取得真实上传回执,并在提交备忘时关联。</text
|
||||
></view
|
||||
>
|
||||
<button
|
||||
class="upload-button"
|
||||
:disabled="uploading || submitting"
|
||||
@click="uploadImage"
|
||||
>
|
||||
{{ uploading ? "上传中…" : "添加图片" }}
|
||||
</button>
|
||||
<text
|
||||
v-for="(receipt, index) in mediaReceipts"
|
||||
:key="`${receipt.ossId}-${index}`"
|
||||
>已上传:{{ receipt.fileName || "图片" }}</text
|
||||
>
|
||||
<text v-if="uploadError" class="error">{{ uploadError }}</text>
|
||||
</view>
|
||||
<view class="field"
|
||||
><text>排序值</text
|
||||
><input
|
||||
v-model="form.sortOrder"
|
||||
type="number"
|
||||
placeholder="数值越小越靠前"
|
||||
@input="error = ''"
|
||||
/></view>
|
||||
<text v-if="error" class="error">{{ error }}</text>
|
||||
<view class="form-actions"
|
||||
><AppButton
|
||||
type="secondary"
|
||||
label="取消"
|
||||
@click="cancelCreate" /><AppButton
|
||||
:disabled="submitting || uploading"
|
||||
:label="submitting ? '正在提交' : '提交备忘'"
|
||||
@click="submit"
|
||||
/></view>
|
||||
</view>
|
||||
<view v-else-if="!valid" class="state-card"
|
||||
><text>家族备忘入口无效</text
|
||||
><AppButton block label="返回上一页" @click="requestBack"
|
||||
/></view>
|
||||
<view v-else-if="listState === 'loading'" class="state-card"
|
||||
><AppLoading text="正在读取家族备忘"
|
||||
/></view>
|
||||
<view v-else-if="listState === 'error'" class="state-card"
|
||||
><text>暂时无法读取家族备忘</text
|
||||
><AppButton block type="secondary" label="重新加载" @click="loadMemos"
|
||||
/></view>
|
||||
<view v-else-if="listState === 'empty'" class="state-card"
|
||||
><text>还没有家族备忘</text
|
||||
><AppButton block label="新建备忘" @click="openCreate"
|
||||
/></view>
|
||||
<view v-else class="memo-list">
|
||||
<text v-if="saveNotice" class="save-notice"
|
||||
>备忘已提交,并已从服务端重新读取。</text
|
||||
>
|
||||
<view v-for="item in memos" :key="item.id" class="memo-card"
|
||||
><view
|
||||
><text>{{ item.title }}</text
|
||||
><text v-if="item.remindTime">{{ item.remindTime }}</text
|
||||
><text v-if="item.content" class="memo-card__content">{{ item.content }}</text></view
|
||||
></view
|
||||
>
|
||||
</view>
|
||||
</view>
|
||||
<AppDialog
|
||||
:visible="discardVisible"
|
||||
title="放弃家族备忘?"
|
||||
message="尚未提交的内容将被清除。"
|
||||
confirm-text="放弃并返回"
|
||||
cancel-text="继续填写"
|
||||
show-cancel
|
||||
:close-on-mask="false"
|
||||
@confirm="confirmDiscard"
|
||||
@cancel="cancelDiscard"
|
||||
/>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, onUnmounted, reactive, ref } from "vue";
|
||||
import { onBackPress, onLoad, onShow } 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";
|
||||
import {
|
||||
appApi,
|
||||
createRequestController,
|
||||
isRequestCancelled,
|
||||
} from "@/utils/api.js";
|
||||
import { createDiscardConfirmation } from "@/utils/discard-confirmation.js";
|
||||
import {
|
||||
isImagePickCancelled,
|
||||
pickAndUploadImage,
|
||||
} from "@/utils/resumable-image-upload.js";
|
||||
import { goBack, handleBackPress, runBackGuard } from "@/utils/navigation.js";
|
||||
|
||||
const genealogyId = ref("");
|
||||
const view = ref("list");
|
||||
const listState = ref("loading");
|
||||
const memos = ref([]);
|
||||
const saveNotice = ref(false);
|
||||
const submitting = ref(false);
|
||||
const uploading = ref(false);
|
||||
const error = ref("");
|
||||
const uploadError = ref("");
|
||||
const discardVisible = ref(false);
|
||||
const form = reactive({
|
||||
memoTitle: "",
|
||||
remindDate: "",
|
||||
remindClock: "",
|
||||
memoContent: "",
|
||||
sortOrder: "",
|
||||
});
|
||||
const mediaReceipts = ref([]);
|
||||
const controller = createRequestController();
|
||||
let active = true;
|
||||
const valid = computed(() => /^[1-9]\d*$/.test(genealogyId.value));
|
||||
const remindTime = computed(() =>
|
||||
form.remindDate ? `${form.remindDate} ${form.remindClock || "00:00"}:00` : "",
|
||||
);
|
||||
const mediaOssIds = computed(() =>
|
||||
mediaReceipts.value.map((item) => item.ossId).join(","),
|
||||
);
|
||||
const dirty = computed(
|
||||
() =>
|
||||
Object.values(form).some((value) => String(value).trim()) ||
|
||||
mediaReceipts.value.length > 0,
|
||||
);
|
||||
const confirmation = createDiscardConfirmation((visible) => {
|
||||
discardVisible.value = visible;
|
||||
});
|
||||
const confirmDiscard = () => {
|
||||
confirmation.confirm();
|
||||
resetForm();
|
||||
view.value = "list";
|
||||
};
|
||||
const cancelDiscard = confirmation.cancel;
|
||||
|
||||
const resetForm = () => {
|
||||
Object.assign(form, {
|
||||
memoTitle: "",
|
||||
remindDate: "",
|
||||
remindClock: "",
|
||||
memoContent: "",
|
||||
sortOrder: "",
|
||||
});
|
||||
mediaReceipts.value = [];
|
||||
error.value = "";
|
||||
uploadError.value = "";
|
||||
};
|
||||
const loadMemos = async () => {
|
||||
if (!valid.value) return;
|
||||
controller.abort();
|
||||
listState.value = "loading";
|
||||
try {
|
||||
const rows = await appApi.getMemos(genealogyId.value, {
|
||||
requestController: controller,
|
||||
});
|
||||
if (!active) return;
|
||||
memos.value = rows
|
||||
.map((item) => ({
|
||||
id: String(item.memoId || ""),
|
||||
title: String(item.memoTitle || "未命名备忘"),
|
||||
remindTime: String(item.remindTime || ""),
|
||||
content: String(item.memoContent || ""),
|
||||
}))
|
||||
.filter((item) => /^[1-9]\d*$/.test(item.id));
|
||||
listState.value = memos.value.length ? "ready" : "empty";
|
||||
} catch (cause) {
|
||||
if (!active || isRequestCancelled(cause)) return;
|
||||
listState.value = "error";
|
||||
}
|
||||
};
|
||||
const openCreate = () => {
|
||||
if (!valid.value) return;
|
||||
saveNotice.value = false;
|
||||
resetForm();
|
||||
view.value = "form";
|
||||
};
|
||||
const cancelCreate = () => {
|
||||
resetForm();
|
||||
view.value = "list";
|
||||
};
|
||||
const selectRemindDate = (event) => {
|
||||
form.remindDate = event.detail.value || "";
|
||||
};
|
||||
const selectRemindClock = (event) => {
|
||||
form.remindClock = event.detail.value || "";
|
||||
};
|
||||
const uploadImage = async () => {
|
||||
if (uploading.value || submitting.value) return;
|
||||
uploading.value = true;
|
||||
uploadError.value = "";
|
||||
try {
|
||||
mediaReceipts.value = [
|
||||
...mediaReceipts.value,
|
||||
await pickAndUploadImage({ requestController: controller }),
|
||||
];
|
||||
} catch (cause) {
|
||||
if (!isImagePickCancelled(cause) && !isRequestCancelled(cause))
|
||||
uploadError.value = cause?.message || "图片上传失败,请稍后重试。";
|
||||
} finally {
|
||||
uploading.value = false;
|
||||
}
|
||||
};
|
||||
const submit = async () => {
|
||||
if (submitting.value || uploading.value || !valid.value) return;
|
||||
if (!form.memoTitle.trim()) {
|
||||
error.value = "请填写备忘标题";
|
||||
return;
|
||||
}
|
||||
submitting.value = true;
|
||||
error.value = "";
|
||||
try {
|
||||
await appApi.createMemo(
|
||||
genealogyId.value,
|
||||
{
|
||||
memoTitle: form.memoTitle,
|
||||
remindTime: remindTime.value,
|
||||
memoContent: form.memoContent,
|
||||
mediaOssIds: mediaOssIds.value,
|
||||
sortOrder: form.sortOrder,
|
||||
},
|
||||
{ requestController: controller },
|
||||
);
|
||||
resetForm();
|
||||
view.value = "list";
|
||||
saveNotice.value = true;
|
||||
await loadMemos();
|
||||
} catch (cause) {
|
||||
if (!isRequestCancelled(cause))
|
||||
error.value = cause?.message || "备忘提交失败,请稍后重试。";
|
||||
} finally {
|
||||
submitting.value = false;
|
||||
}
|
||||
};
|
||||
const requestBack = () =>
|
||||
view.value !== "form"
|
||||
? goBack()
|
||||
: runBackGuard({
|
||||
transientOpen: discardVisible.value,
|
||||
dirty: dirty.value,
|
||||
submitting: submitting.value || uploading.value,
|
||||
"close-transient": cancelDiscard,
|
||||
"block-submitting": () => true,
|
||||
"confirm-discard": confirmation.request,
|
||||
});
|
||||
|
||||
onLoad((query) => {
|
||||
genealogyId.value = String(query?.genealogyId || "");
|
||||
if (valid.value) loadMemos();
|
||||
else listState.value = "invalid";
|
||||
});
|
||||
onShow(() => {
|
||||
if (valid.value && view.value === "list" && listState.value !== "loading")
|
||||
loadMemos();
|
||||
});
|
||||
onBackPress((event) => handleBackPress(event, requestBack));
|
||||
onUnmounted(() => {
|
||||
active = false;
|
||||
controller.abort();
|
||||
confirmation.dispose();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@import "../../styles/adaptive-frame-profiles.scss";
|
||||
.memo-page {
|
||||
display: flex;
|
||||
min-height: 100vh;
|
||||
flex-direction: column;
|
||||
background: $paper;
|
||||
}
|
||||
.page-header,
|
||||
.page-content {
|
||||
z-index: 1;
|
||||
}
|
||||
.page-content {
|
||||
padding: 18rpx 24rpx 72rpx;
|
||||
}
|
||||
.form-card,
|
||||
.state-card,
|
||||
.memo-card {
|
||||
@include adaptive-records-content;
|
||||
}
|
||||
.form-card {
|
||||
box-sizing: border-box;
|
||||
padding: 46rpx;
|
||||
}
|
||||
.form-card > text:first-child {
|
||||
display: block;
|
||||
color: $ink;
|
||||
font-size: clamp(19px, 34rpx, 24px);
|
||||
font-weight: 700;
|
||||
}
|
||||
.form-copy {
|
||||
display: block;
|
||||
margin-top: 12rpx;
|
||||
color: $ink-muted;
|
||||
font-size: clamp(14px, 22rpx, 17px);
|
||||
line-height: 1.55;
|
||||
}
|
||||
.field {
|
||||
display: grid;
|
||||
grid-template-columns: 142rpx minmax(0, 1fr);
|
||||
align-items: center;
|
||||
gap: 12rpx 20rpx;
|
||||
min-height: 82rpx;
|
||||
margin-top: 14rpx;
|
||||
padding: 14rpx 24rpx;
|
||||
box-sizing: border-box;
|
||||
@include adaptive-records-field;
|
||||
}
|
||||
.field > text,
|
||||
.upload-field > view > text:first-child {
|
||||
color: $ink;
|
||||
font-size: clamp(14px, 23rpx, 17px);
|
||||
font-weight: 700;
|
||||
}
|
||||
.required-mark {
|
||||
display: inline-block;
|
||||
margin-right: 4rpx;
|
||||
color: $brand-red;
|
||||
font-size: clamp(16px, 26rpx, 20px);
|
||||
transform: translateY(-3rpx);
|
||||
}
|
||||
.field input,
|
||||
.field > text:last-child {
|
||||
min-width: 0;
|
||||
color: $ink;
|
||||
font-size: clamp(14px, 23rpx, 17px);
|
||||
text-align: right;
|
||||
}
|
||||
.field--textarea {
|
||||
align-items: start;
|
||||
}
|
||||
.field textarea {
|
||||
min-height: 116rpx;
|
||||
color: $ink;
|
||||
font-size: clamp(14px, 23rpx, 17px);
|
||||
line-height: 1.55;
|
||||
}
|
||||
.upload-field {
|
||||
display: grid;
|
||||
gap: 12rpx;
|
||||
margin-top: 14rpx;
|
||||
padding: 18rpx 24rpx;
|
||||
@include adaptive-records-field;
|
||||
}
|
||||
.upload-field > view > text {
|
||||
display: block;
|
||||
}
|
||||
.upload-field > view > text:last-child {
|
||||
margin-top: 6rpx;
|
||||
color: $ink-muted;
|
||||
font-size: clamp(13px, 20rpx, 16px);
|
||||
line-height: 1.45;
|
||||
}
|
||||
.upload-button {
|
||||
justify-self: start;
|
||||
min-height: 88rpx;
|
||||
margin: 0;
|
||||
padding: 0 20rpx;
|
||||
border: 1rpx solid rgba(159, 23, 15, 0.42);
|
||||
border-radius: 8rpx;
|
||||
background: transparent;
|
||||
color: $brand-red;
|
||||
font-size: clamp(14px, 22rpx, 17px);
|
||||
}
|
||||
.upload-field > text {
|
||||
color: $ink-muted;
|
||||
font-size: clamp(13px, 21rpx, 16px);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.error {
|
||||
display: block;
|
||||
margin-top: 12rpx;
|
||||
color: $brand-red;
|
||||
font-size: clamp(14px, 22rpx, 17px);
|
||||
}
|
||||
.form-actions {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(0, 1.45fr);
|
||||
gap: 16rpx;
|
||||
margin-top: 20rpx;
|
||||
}
|
||||
.form-actions .app-button {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
.state-card {
|
||||
display: flex;
|
||||
min-height: 320rpx;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 42rpx;
|
||||
color: $ink;
|
||||
font-size: clamp(16px, 28rpx, 20px);
|
||||
text-align: center;
|
||||
}
|
||||
.state-card .app-button {
|
||||
width: 100%;
|
||||
margin-top: 24rpx;
|
||||
}
|
||||
.memo-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16rpx;
|
||||
}
|
||||
.save-notice {
|
||||
display: block;
|
||||
color: $brand-red;
|
||||
font-size: clamp(14px, 22rpx, 17px);
|
||||
}
|
||||
.memo-card {
|
||||
min-height: 128rpx;
|
||||
padding: 28rpx 32rpx;
|
||||
}
|
||||
.memo-card text {
|
||||
display: block;
|
||||
}
|
||||
.memo-card text:first-child {
|
||||
color: $ink;
|
||||
font-family: STKaiti, KaiTi, serif;
|
||||
font-size: clamp(17px, 30rpx, 22px);
|
||||
font-weight: 700;
|
||||
}
|
||||
.memo-card text:not(:first-child) {
|
||||
margin-top: 7rpx;
|
||||
color: $ink-muted;
|
||||
font-size: clamp(13px, 21rpx, 16px);
|
||||
line-height: 1.45;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.memo-card__content {
|
||||
display: -webkit-box !important;
|
||||
overflow: hidden;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 2;
|
||||
}
|
||||
</style>
|
||||
@@ -1,489 +0,0 @@
|
||||
<!-- 页面编号:R-11;用途:读取、新建当前家谱的功德记录。 -->
|
||||
<template>
|
||||
<view class="merit-page">
|
||||
<ModulePageBackground module="records" />
|
||||
<view class="page-header"
|
||||
><PageHeader
|
||||
title="功德记录"
|
||||
:action="valid && view === 'list' ? '新建' : ''"
|
||||
custom-back
|
||||
@back="requestBack"
|
||||
@action="openCreate"
|
||||
/></view>
|
||||
<view class="page-content">
|
||||
<view v-if="view === 'form'" class="form-card">
|
||||
<text>新建功德记录</text>
|
||||
<text class="form-copy">红色 * 为必填项,其余内容可按需补充。</text>
|
||||
<view v-for="field in fields" :key="field.key" class="field"
|
||||
><text
|
||||
><text v-if="field.required" class="required-mark">*</text
|
||||
>{{ field.label }}</text
|
||||
><textarea
|
||||
v-if="field.key === 'content'"
|
||||
v-model="form[field.key]"
|
||||
auto-height
|
||||
:placeholder="`请输入${field.label}`"
|
||||
@input="error = ''" /><input
|
||||
v-else
|
||||
v-model="form[field.key]"
|
||||
:type="field.inputType || 'text'"
|
||||
:placeholder="field.placeholder || `请输入${field.label}`"
|
||||
@input="error = ''"
|
||||
/></view>
|
||||
<view class="field field--picker"
|
||||
><text>功德类型</text
|
||||
><picker
|
||||
:range="meritTypeOptions.map((item) => item.label)"
|
||||
:value="meritTypeIndex"
|
||||
@change="selectMeritType"
|
||||
><view :class="{ placeholder: !form.type }">{{
|
||||
meritTypeLabel || "请选择"
|
||||
}}</view></picker
|
||||
></view
|
||||
>
|
||||
<view class="field field--picker"
|
||||
><text>记录日期</text
|
||||
><picker mode="date" :value="form.meritDate" @change="selectMeritDate"
|
||||
><view :class="{ placeholder: !form.meritDate }">{{
|
||||
form.meritDate || "请选择"
|
||||
}}</view></picker
|
||||
></view
|
||||
>
|
||||
<view class="field field--picker"
|
||||
><text>记录时间</text
|
||||
><picker
|
||||
mode="time"
|
||||
:value="form.meritClock"
|
||||
@change="selectMeritClock"
|
||||
><view :class="{ placeholder: !form.meritClock }">{{
|
||||
form.meritClock || "请选择"
|
||||
}}</view></picker
|
||||
></view
|
||||
>
|
||||
<text v-if="error" class="error">{{ error }}</text>
|
||||
<view class="form-actions"
|
||||
><AppButton
|
||||
type="secondary"
|
||||
label="取消"
|
||||
@click="cancelCreate" /><AppButton
|
||||
:disabled="submitting"
|
||||
:label="submitting ? '正在提交' : '提交功德记录'"
|
||||
@click="submit"
|
||||
/></view>
|
||||
</view>
|
||||
<view v-else-if="!valid" class="state-card"
|
||||
><text>功德记录入口无效</text
|
||||
><AppButton block label="返回上一页" @click="requestBack"
|
||||
/></view>
|
||||
<view v-else-if="listState === 'loading'" class="state-card"
|
||||
><AppLoading text="正在读取功德记录"
|
||||
/></view>
|
||||
<view v-else-if="listState === 'error'" class="state-card"
|
||||
><text>暂时无法读取功德记录</text
|
||||
><AppButton block type="secondary" label="重新加载" @click="loadMerits"
|
||||
/></view>
|
||||
<view v-else-if="listState === 'empty'" class="state-card"
|
||||
><text>还没有功德记录</text
|
||||
><AppButton block label="新建功德记录" @click="openCreate"
|
||||
/></view>
|
||||
<view v-else class="merit-list">
|
||||
<text v-if="saveNotice" class="save-notice"
|
||||
>功德记录已提交,并已从服务端重新读取。</text
|
||||
>
|
||||
<text class="merit-summary"
|
||||
>共 {{ merits.length }} 笔,金额合计 ¥{{ totalAmount }}</text
|
||||
>
|
||||
<view v-for="item in merits" :key="item.id" class="merit-card"
|
||||
><view
|
||||
><text>{{ item.title }}</text
|
||||
><text
|
||||
>{{ item.donor }}{{ item.type ? ` · ${item.type}` : "" }}</text
|
||||
><text v-if="item.time || item.content">{{
|
||||
item.time || item.content
|
||||
}}</text></view
|
||||
><text>¥{{ item.amount }}</text></view
|
||||
>
|
||||
</view>
|
||||
</view>
|
||||
<AppDialog
|
||||
:visible="discardVisible"
|
||||
title="放弃功德记录?"
|
||||
message="尚未提交的内容将被清除。"
|
||||
confirm-text="放弃并返回"
|
||||
cancel-text="继续填写"
|
||||
show-cancel
|
||||
:close-on-mask="false"
|
||||
@confirm="confirmDiscard"
|
||||
@cancel="cancelDiscard"
|
||||
/>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, onUnmounted, reactive, ref } from "vue";
|
||||
import { onBackPress, onLoad, onShow } 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";
|
||||
import {
|
||||
appApi,
|
||||
createRequestController,
|
||||
isRequestCancelled,
|
||||
} from "@/utils/api.js";
|
||||
import { createDiscardConfirmation } from "@/utils/discard-confirmation.js";
|
||||
import { goBack, handleBackPress, runBackGuard } from "@/utils/navigation.js";
|
||||
|
||||
const genealogyId = ref("");
|
||||
const view = ref("list");
|
||||
const listState = ref("loading");
|
||||
const merits = ref([]);
|
||||
const saveNotice = ref(false);
|
||||
const submitting = ref(false);
|
||||
const error = ref("");
|
||||
const discardVisible = ref(false);
|
||||
const form = reactive({
|
||||
donor: "",
|
||||
title: "",
|
||||
type: "",
|
||||
amount: "",
|
||||
meritDate: "",
|
||||
meritClock: "",
|
||||
content: "",
|
||||
sortOrder: "",
|
||||
});
|
||||
const fields = [
|
||||
{ key: "donor", label: "捐赠人", required: true },
|
||||
{ key: "title", label: "功德标题", required: true },
|
||||
{ key: "amount", inputType: "digit", label: "金额" },
|
||||
{ key: "content", label: "记录内容" },
|
||||
{
|
||||
key: "sortOrder",
|
||||
label: "排序值",
|
||||
inputType: "number",
|
||||
placeholder: "数值越小越靠前",
|
||||
},
|
||||
];
|
||||
const meritTypeOptions = Object.freeze([
|
||||
{ value: "donation", label: "捐赠" },
|
||||
{ value: "repair", label: "修祠" },
|
||||
{ value: "public", label: "公益" },
|
||||
{ value: "other", label: "其他" },
|
||||
]);
|
||||
const controller = createRequestController();
|
||||
let active = true;
|
||||
const valid = computed(() => /^[1-9]\d*$/.test(genealogyId.value));
|
||||
const dirty = computed(() =>
|
||||
Object.values(form).some((value) => String(value).trim()),
|
||||
);
|
||||
const meritTime = computed(() =>
|
||||
form.meritDate ? `${form.meritDate} ${form.meritClock || "00:00"}:00` : "",
|
||||
);
|
||||
const meritTypeIndex = computed(() =>
|
||||
Math.max(
|
||||
0,
|
||||
meritTypeOptions.findIndex((item) => item.value === form.type),
|
||||
),
|
||||
);
|
||||
const meritTypeLabel = computed(
|
||||
() => meritTypeOptions.find((item) => item.value === form.type)?.label || "",
|
||||
);
|
||||
const totalAmount = computed(() =>
|
||||
merits.value.reduce((total, item) => total + item.amount, 0).toFixed(2),
|
||||
);
|
||||
const confirmation = createDiscardConfirmation((visible) => {
|
||||
discardVisible.value = visible;
|
||||
});
|
||||
const confirmDiscard = () => {
|
||||
confirmation.confirm();
|
||||
resetForm();
|
||||
view.value = "list";
|
||||
};
|
||||
const cancelDiscard = confirmation.cancel;
|
||||
|
||||
const resetForm = () => {
|
||||
Object.assign(form, {
|
||||
donor: "",
|
||||
title: "",
|
||||
type: "",
|
||||
amount: "",
|
||||
meritDate: "",
|
||||
meritClock: "",
|
||||
content: "",
|
||||
sortOrder: "",
|
||||
});
|
||||
error.value = "";
|
||||
};
|
||||
const loadMerits = async () => {
|
||||
if (!valid.value) return;
|
||||
controller.abort();
|
||||
listState.value = "loading";
|
||||
try {
|
||||
const rows = await appApi.getMeritRecords(genealogyId.value, {
|
||||
requestController: controller,
|
||||
});
|
||||
if (!active) return;
|
||||
merits.value = rows
|
||||
.map((item) => ({
|
||||
id: String(item.meritId || ""),
|
||||
donor: String(item.donorName || "未署名"),
|
||||
title: String(item.meritTitle || "未命名功德"),
|
||||
type: String(item.meritType || ""),
|
||||
amount: Number.isFinite(Number(item.amount)) ? Number(item.amount) : 0,
|
||||
time: String(item.meritTime || ""),
|
||||
content: String(item.meritContent || ""),
|
||||
}))
|
||||
.filter((item) => /^[1-9]\d*$/.test(item.id));
|
||||
listState.value = merits.value.length ? "ready" : "empty";
|
||||
} catch (cause) {
|
||||
if (!active || isRequestCancelled(cause)) return;
|
||||
listState.value = "error";
|
||||
}
|
||||
};
|
||||
const openCreate = () => {
|
||||
if (!valid.value) return;
|
||||
saveNotice.value = false;
|
||||
resetForm();
|
||||
view.value = "form";
|
||||
};
|
||||
const cancelCreate = () => {
|
||||
resetForm();
|
||||
view.value = "list";
|
||||
};
|
||||
const selectMeritDate = (event) => {
|
||||
form.meritDate = event.detail.value || "";
|
||||
error.value = "";
|
||||
};
|
||||
const selectMeritClock = (event) => {
|
||||
form.meritClock = event.detail.value || "";
|
||||
error.value = "";
|
||||
};
|
||||
const selectMeritType = (event) => {
|
||||
form.type = meritTypeOptions[Number(event.detail.value)]?.value || "";
|
||||
error.value = "";
|
||||
};
|
||||
const submit = async () => {
|
||||
if (submitting.value || !valid.value) return;
|
||||
const donorName = form.donor.trim();
|
||||
const meritTitle = form.title.trim();
|
||||
const amountText = form.amount.trim();
|
||||
if (!donorName || !meritTitle) {
|
||||
error.value = !donorName ? "请填写捐赠人" : "请填写功德标题";
|
||||
return;
|
||||
}
|
||||
if (amountText && !Number.isFinite(Number(amountText))) {
|
||||
error.value = "金额必须是数字";
|
||||
return;
|
||||
}
|
||||
submitting.value = true;
|
||||
error.value = "";
|
||||
try {
|
||||
await appApi.createMeritRecord(
|
||||
genealogyId.value,
|
||||
{
|
||||
donorName,
|
||||
meritTitle,
|
||||
meritType: form.type,
|
||||
meritContent: form.content,
|
||||
meritTime: meritTime.value,
|
||||
sortOrder: form.sortOrder,
|
||||
...(amountText ? { amount: Number(amountText) } : {}),
|
||||
},
|
||||
{ requestController: controller },
|
||||
);
|
||||
resetForm();
|
||||
view.value = "list";
|
||||
saveNotice.value = true;
|
||||
await loadMerits();
|
||||
} catch (cause) {
|
||||
if (!isRequestCancelled(cause))
|
||||
error.value = cause?.message || "功德记录提交失败,请稍后重试。";
|
||||
} finally {
|
||||
submitting.value = false;
|
||||
}
|
||||
};
|
||||
const requestBack = () =>
|
||||
view.value !== "form"
|
||||
? goBack()
|
||||
: runBackGuard({
|
||||
transientOpen: discardVisible.value,
|
||||
dirty: dirty.value,
|
||||
submitting: submitting.value,
|
||||
"close-transient": cancelDiscard,
|
||||
"block-submitting": () => true,
|
||||
"confirm-discard": confirmation.request,
|
||||
});
|
||||
|
||||
onLoad((query) => {
|
||||
genealogyId.value = String(query?.genealogyId || "");
|
||||
if (valid.value) loadMerits();
|
||||
else listState.value = "invalid";
|
||||
});
|
||||
onShow(() => {
|
||||
if (valid.value && view.value === "list" && listState.value !== "loading")
|
||||
loadMerits();
|
||||
});
|
||||
onBackPress((event) => handleBackPress(event, requestBack));
|
||||
onUnmounted(() => {
|
||||
active = false;
|
||||
controller.abort();
|
||||
confirmation.dispose();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@import "../../styles/adaptive-frame-profiles.scss";
|
||||
.merit-page {
|
||||
display: flex;
|
||||
min-height: 100vh;
|
||||
flex-direction: column;
|
||||
background: $paper;
|
||||
}
|
||||
.page-header,
|
||||
.page-content {
|
||||
z-index: 1;
|
||||
}
|
||||
.page-content {
|
||||
padding: 18rpx 24rpx 72rpx;
|
||||
}
|
||||
.form-card,
|
||||
.state-card,
|
||||
.merit-card {
|
||||
@include adaptive-records-content;
|
||||
}
|
||||
.form-card {
|
||||
box-sizing: border-box;
|
||||
padding: 46rpx;
|
||||
}
|
||||
.form-card > text:first-child {
|
||||
display: block;
|
||||
color: $ink;
|
||||
font-size: clamp(19px, 34rpx, 24px);
|
||||
font-weight: 700;
|
||||
}
|
||||
.form-copy {
|
||||
display: block;
|
||||
margin-top: 12rpx;
|
||||
color: $ink-muted;
|
||||
font-size: clamp(14px, 22rpx, 17px);
|
||||
line-height: 1.55;
|
||||
}
|
||||
.field {
|
||||
margin-top: 16rpx;
|
||||
}
|
||||
.field > text {
|
||||
display: block;
|
||||
margin: 0 8rpx 8rpx;
|
||||
color: $ink;
|
||||
font-size: clamp(14px, 23rpx, 17px);
|
||||
font-weight: 700;
|
||||
}
|
||||
.required-mark {
|
||||
display: inline-block;
|
||||
margin-right: 4rpx;
|
||||
color: $brand-red;
|
||||
font-size: clamp(16px, 26rpx, 20px);
|
||||
transform: translateY(-3rpx);
|
||||
}
|
||||
.field input,
|
||||
.field textarea,
|
||||
.field--picker picker > view {
|
||||
@include adaptive-records-field;
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
min-height: 76rpx;
|
||||
padding: 16rpx 22rpx;
|
||||
color: $ink;
|
||||
font-size: clamp(14px, 23rpx, 17px);
|
||||
}
|
||||
.field textarea {
|
||||
min-height: 150rpx;
|
||||
}
|
||||
.field--picker picker {
|
||||
display: block;
|
||||
}
|
||||
.field--picker .placeholder {
|
||||
color: $ink-muted;
|
||||
}
|
||||
.error {
|
||||
display: block;
|
||||
margin-top: 12rpx;
|
||||
color: $brand-red;
|
||||
font-size: clamp(14px, 22rpx, 17px);
|
||||
}
|
||||
.form-actions {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(0, 1.45fr);
|
||||
gap: 16rpx;
|
||||
margin-top: 20rpx;
|
||||
}
|
||||
.form-actions .app-button {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
.state-card {
|
||||
display: flex;
|
||||
min-height: 320rpx;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 42rpx;
|
||||
color: $ink;
|
||||
font-size: clamp(16px, 28rpx, 20px);
|
||||
text-align: center;
|
||||
}
|
||||
.state-card .app-button {
|
||||
width: 100%;
|
||||
margin-top: 24rpx;
|
||||
}
|
||||
.merit-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16rpx;
|
||||
}
|
||||
.save-notice {
|
||||
display: block;
|
||||
color: $brand-red;
|
||||
font-size: clamp(14px, 22rpx, 17px);
|
||||
}
|
||||
.merit-summary {
|
||||
display: block;
|
||||
color: $ink-muted;
|
||||
font-size: clamp(14px, 22rpx, 17px);
|
||||
}
|
||||
.merit-card {
|
||||
display: flex;
|
||||
min-height: 138rpx;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 18rpx;
|
||||
padding: 28rpx 32rpx;
|
||||
}
|
||||
.merit-card > view {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
.merit-card text {
|
||||
display: block;
|
||||
}
|
||||
.merit-card > view text:first-child {
|
||||
color: $ink;
|
||||
font-family: STKaiti, KaiTi, serif;
|
||||
font-size: clamp(17px, 30rpx, 22px);
|
||||
font-weight: 700;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.merit-card > view text:not(:first-child) {
|
||||
margin-top: 7rpx;
|
||||
color: $ink-muted;
|
||||
font-size: clamp(13px, 21rpx, 16px);
|
||||
line-height: 1.45;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.merit-card > text {
|
||||
flex: 0 0 auto;
|
||||
color: $brand-red;
|
||||
font-size: clamp(14px, 22rpx, 17px);
|
||||
}
|
||||
</style>
|
||||
@@ -1,4 +1,3 @@
|
||||
<!-- 页面编号:R-04;用途:按 Apifox 的完整 AppRelativeRecordBody 创建亲友往来记录。 -->
|
||||
<template>
|
||||
<view
|
||||
class="gift-editor-page"
|
||||
@@ -6,13 +5,13 @@
|
||||
>
|
||||
<ModulePageBackground module="records" />
|
||||
<view class="page-header"
|
||||
><PageHeader title="新建往来记录" custom-back @back="requestBack"
|
||||
><PageHeader :title="isEdit ? '编辑往来记录' : '新建往来记录'" custom-back @back="requestBack"
|
||||
/></view>
|
||||
<view class="page-content">
|
||||
<view v-if="editorState === 'form'" class="form-card">
|
||||
<text>记录一份家人往来</text>
|
||||
<text>{{ isEdit ? '编辑一份家人往来' : '记录一份家人往来' }}</text>
|
||||
<text class="form-copy"
|
||||
>除亲友姓名外,其他字段均可按需填写;留空时不提交该字段。</text
|
||||
>{{ isEdit ? '原有图片和相关设置会保留。' : '除亲友姓名外,其他内容可按需填写。' }}</text
|
||||
>
|
||||
<view class="field-row"
|
||||
><text><text class="required-mark">*</text>亲友姓名</text
|
||||
@@ -71,7 +70,7 @@
|
||||
<view
|
||||
><text>相关图片</text
|
||||
><text
|
||||
>图片选定后会先取得真实上传回执,并在提交记录时关联。</text
|
||||
>图片上传成功后,会随这条记录一起保存。</text
|
||||
></view
|
||||
>
|
||||
<button
|
||||
@@ -88,22 +87,17 @@
|
||||
>
|
||||
<text v-if="uploadError" class="save-error">{{ uploadError }}</text>
|
||||
</view>
|
||||
<view class="field-row"
|
||||
><text>排序值</text
|
||||
><input
|
||||
v-model="form.sortOrder"
|
||||
type="number"
|
||||
placeholder="数值越小越靠前"
|
||||
@input="submitError = ''"
|
||||
/></view>
|
||||
<text v-if="submitError" class="save-error">{{ submitError }}</text>
|
||||
<AppButton
|
||||
block
|
||||
:disabled="isSubmitting || isUploading"
|
||||
:label="isSubmitting ? '正在提交' : '提交往来记录'"
|
||||
:label="isSubmitting ? '正在提交' : isEdit ? '保存往来记录' : '提交往来记录'"
|
||||
@click="saveRelative"
|
||||
/>
|
||||
</view>
|
||||
<view v-else-if="editorState === 'loading'" class="state-card"
|
||||
><AppLoading text="正在读取往来记录"
|
||||
/></view>
|
||||
<view v-else class="state-card"
|
||||
><text>{{ resultCopy.title }}</text
|
||||
><text>{{ resultCopy.copy }}</text
|
||||
@@ -129,36 +123,42 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, onUnmounted, reactive, ref } from "vue";
|
||||
import { onBackPress, onLoad } from "@dcloudio/uni-app";
|
||||
import { computed, reactive, ref } from "vue";
|
||||
import { onBackPress, onLoad, 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";
|
||||
import {
|
||||
appApi,
|
||||
createRequestController,
|
||||
isRequestCancelled,
|
||||
} from "@/utils/api.js";
|
||||
isRequestCancelled
|
||||
} from "@/services/api/request-controller.js";
|
||||
import { lifeRecordApi } from "@/services/api/life-record-service.js";
|
||||
import { getRequestErrorMessage } from "@/services/api/request-error-message.js";
|
||||
import { createDiscardConfirmation } from "@/utils/discard-confirmation.js";
|
||||
import { createNonIdempotentWriteGuard } from "@/utils/non-idempotent-write-guard.js";
|
||||
import {
|
||||
isImagePickCancelled,
|
||||
pickAndUploadImage,
|
||||
} from "@/utils/resumable-image-upload.js";
|
||||
} from "@/utils/media-upload.js";
|
||||
import {
|
||||
goBack,
|
||||
handleBackPress,
|
||||
returnTo,
|
||||
runBackGuard,
|
||||
} from "@/utils/navigation.js";
|
||||
} from "@/utils/navigation/gateway.js";
|
||||
|
||||
const genealogyId = ref("");
|
||||
const editorState = ref("form");
|
||||
const editorState = ref("loading");
|
||||
const isSubmitting = ref(false);
|
||||
const isUploading = ref(false);
|
||||
const discardVisible = ref(false);
|
||||
const submitError = ref("");
|
||||
const uploadError = ref("");
|
||||
const editingRecord = ref(null);
|
||||
const formBaseline = ref("");
|
||||
const submittedEdit = ref(false);
|
||||
const form = reactive({
|
||||
relativeName: "",
|
||||
relationName: "",
|
||||
@@ -167,32 +167,47 @@ const form = reactive({
|
||||
eventClock: "",
|
||||
giftAmount: "",
|
||||
recordContent: "",
|
||||
sortOrder: "",
|
||||
});
|
||||
const mediaReceipts = ref([]);
|
||||
const requestController = createRequestController();
|
||||
const relativeRecordDetailController = createRequestController();
|
||||
const relativeRecordMediaUploadController = createRequestController();
|
||||
const relativeRecordSaveController = createRequestController();
|
||||
const relativeRecordCreateGuard = createNonIdempotentWriteGuard();
|
||||
let pageActive = true;
|
||||
const mediaOssIds = computed(() =>
|
||||
mediaReceipts.value.map((item) => item.ossId).join(","),
|
||||
);
|
||||
const isEdit = computed(() => Boolean(editingRecord.value));
|
||||
const eventTime = computed(() =>
|
||||
form.eventDate ? `${form.eventDate} ${form.eventClock || "00:00"}:00` : "",
|
||||
);
|
||||
const formSnapshot = computed(() =>
|
||||
JSON.stringify({ ...form, mediaOssIds: mediaOssIds.value }),
|
||||
);
|
||||
const isDirty = computed(
|
||||
() =>
|
||||
Object.values(form).some((value) => String(value).trim()) ||
|
||||
mediaReceipts.value.length > 0,
|
||||
isEdit.value
|
||||
? Boolean(formBaseline.value) && formSnapshot.value !== formBaseline.value
|
||||
: Object.values(form).some((value) => String(value).trim()) ||
|
||||
mediaReceipts.value.length > 0,
|
||||
);
|
||||
const hasValidContext = computed(() => /^[1-9]\d*$/.test(genealogyId.value));
|
||||
const resultCopy = computed(() =>
|
||||
editorState.value === "success"
|
||||
? {
|
||||
title: "往来记录已提交服务端",
|
||||
copy: "返回贺礼簿后会重新读取服务端记录。",
|
||||
title: submittedEdit.value ? "往来记录已更新" : "往来记录已保存",
|
||||
copy: "返回贺礼簿后会显示最新内容。",
|
||||
action: "返回贺礼簿",
|
||||
}
|
||||
: editorState.value === "error"
|
||||
? {
|
||||
title: "这条往来记录暂时无法编辑",
|
||||
copy: "这条记录已经变化,暂未保存。",
|
||||
action: "返回贺礼簿",
|
||||
}
|
||||
: {
|
||||
title: "往来记录入口无效",
|
||||
copy: "没有取得有效家谱标识,页面不会创建无归属记录。",
|
||||
title: "暂时无法打开往来记录",
|
||||
copy: "未找到家谱信息,请返回后重新进入。",
|
||||
action: "返回上一页",
|
||||
},
|
||||
);
|
||||
@@ -203,10 +218,87 @@ const requestDiscardConfirmation = discardConfirmation.request;
|
||||
const confirmDiscard = discardConfirmation.confirm;
|
||||
const cancelDiscard = discardConfirmation.cancel;
|
||||
|
||||
const resetForm = () => {
|
||||
Object.assign(form, {
|
||||
relativeName: "",
|
||||
relationName: "",
|
||||
eventName: "",
|
||||
eventDate: "",
|
||||
eventClock: "",
|
||||
giftAmount: "",
|
||||
recordContent: "",
|
||||
});
|
||||
mediaReceipts.value = [];
|
||||
editingRecord.value = null;
|
||||
formBaseline.value = "";
|
||||
submitError.value = "";
|
||||
uploadError.value = "";
|
||||
};
|
||||
const splitEventTime = (value) => {
|
||||
if (!value) return { date: "", clock: "" };
|
||||
const matched = String(value)
|
||||
.trim()
|
||||
.match(/^(\d{4}-\d{2}-\d{2})[ T](\d{2}:\d{2})/);
|
||||
if (!matched) throw new Error("记录时间有误,暂时无法编辑。");
|
||||
return { date: matched[1], clock: matched[2] };
|
||||
};
|
||||
const loadEditRecord = async (relativeId) => {
|
||||
editorState.value = "loading";
|
||||
try {
|
||||
const detail = await lifeRecordApi.getRelativeRecordDetail(
|
||||
genealogyId.value,
|
||||
relativeId,
|
||||
{ requestController: relativeRecordDetailController },
|
||||
);
|
||||
if (!pageActive) return;
|
||||
if (!detail.canEdit ||
|
||||
detail.name === "未命名亲友" ||
|
||||
!["0", "1"].includes(detail.status) ||
|
||||
!Number.isSafeInteger(detail.sortOrder)) {
|
||||
throw new Error("这条记录的信息不完整,暂未保存修改,以免覆盖原内容。");
|
||||
}
|
||||
const event = splitEventTime(detail.eventTime);
|
||||
resetForm();
|
||||
Object.assign(form, {
|
||||
relativeName: detail.name,
|
||||
relationName: detail.relation,
|
||||
eventName: detail.event,
|
||||
eventDate: event.date,
|
||||
eventClock: event.clock,
|
||||
giftAmount: detail.amount,
|
||||
recordContent: detail.content,
|
||||
});
|
||||
mediaReceipts.value = detail.mediaFiles.map((file) => ({
|
||||
ossId: file.ossId,
|
||||
fileName: file.fileName,
|
||||
}));
|
||||
editingRecord.value = {
|
||||
id: detail.id,
|
||||
sortOrder: detail.sortOrder,
|
||||
status: detail.status,
|
||||
};
|
||||
formBaseline.value = formSnapshot.value;
|
||||
editorState.value = "form";
|
||||
} catch (error) {
|
||||
if (pageActive && !isRequestCancelled(error)) editorState.value = "error";
|
||||
}
|
||||
};
|
||||
onLoad((query) => {
|
||||
genealogyId.value = String(query?.genealogyId || "");
|
||||
if (!hasValidContext.value || query?.mode !== "create")
|
||||
if (!hasValidContext.value) {
|
||||
editorState.value = "invalid";
|
||||
return;
|
||||
}
|
||||
if (query?.mode === "create") {
|
||||
editorState.value = "form";
|
||||
return;
|
||||
}
|
||||
const relativeId = String(query?.relativeId || "");
|
||||
if (query?.mode === "edit" && /^[1-9]\d*$/.test(relativeId)) {
|
||||
loadEditRecord(relativeId);
|
||||
return;
|
||||
}
|
||||
editorState.value = "invalid";
|
||||
});
|
||||
const selectEventDate = (event) => {
|
||||
form.eventDate = event.detail.value || "";
|
||||
@@ -221,15 +313,16 @@ const uploadImage = async () => {
|
||||
isUploading.value = true;
|
||||
uploadError.value = "";
|
||||
try {
|
||||
mediaReceipts.value = [
|
||||
...mediaReceipts.value,
|
||||
await pickAndUploadImage({ requestController }),
|
||||
];
|
||||
const receipt = await pickAndUploadImage({
|
||||
requestController: relativeRecordMediaUploadController,
|
||||
});
|
||||
if (!pageActive) return;
|
||||
mediaReceipts.value = [...mediaReceipts.value, receipt];
|
||||
} catch (error) {
|
||||
if (!isImagePickCancelled(error) && !isRequestCancelled(error))
|
||||
uploadError.value = error?.message || "图片上传失败,请稍后重试。";
|
||||
if (pageActive && !isImagePickCancelled(error) && !isRequestCancelled(error))
|
||||
uploadError.value = getRequestErrorMessage(error, "图片上传失败,请稍后重试。");
|
||||
} finally {
|
||||
isUploading.value = false;
|
||||
if (pageActive) isUploading.value = false;
|
||||
}
|
||||
};
|
||||
const saveRelative = async () => {
|
||||
@@ -238,36 +331,60 @@ const saveRelative = async () => {
|
||||
submitError.value = "请填写亲友姓名";
|
||||
return;
|
||||
}
|
||||
const { eventDate, eventClock, ...recordForm } = form;
|
||||
const payload = {
|
||||
...recordForm,
|
||||
eventTime: eventTime.value,
|
||||
mediaOssIds: mediaOssIds.value,
|
||||
...(editingRecord.value
|
||||
? {
|
||||
sortOrder: editingRecord.value.sortOrder,
|
||||
status: editingRecord.value.status,
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
const createAttempt = editingRecord.value
|
||||
? null
|
||||
: relativeRecordCreateGuard.begin(payload);
|
||||
if (!editingRecord.value && createAttempt === null) {
|
||||
submitError.value =
|
||||
"上次提交结果暂时无法确认,请先返回贺礼簿检查,避免重复创建。";
|
||||
return;
|
||||
}
|
||||
|
||||
isSubmitting.value = true;
|
||||
submitError.value = "";
|
||||
try {
|
||||
const { eventDate, eventClock, ...recordForm } = form;
|
||||
await appApi.createRelativeRecord(
|
||||
genealogyId.value,
|
||||
{
|
||||
...recordForm,
|
||||
eventTime: eventTime.value,
|
||||
mediaOssIds: mediaOssIds.value,
|
||||
},
|
||||
{ requestController },
|
||||
);
|
||||
Object.assign(form, {
|
||||
relativeName: "",
|
||||
relationName: "",
|
||||
eventName: "",
|
||||
eventDate: "",
|
||||
eventClock: "",
|
||||
giftAmount: "",
|
||||
recordContent: "",
|
||||
sortOrder: "",
|
||||
});
|
||||
mediaReceipts.value = [];
|
||||
submittedEdit.value = Boolean(editingRecord.value);
|
||||
if (editingRecord.value) {
|
||||
await lifeRecordApi.updateRelativeRecord(
|
||||
genealogyId.value,
|
||||
editingRecord.value.id,
|
||||
payload,
|
||||
{ requestController: relativeRecordSaveController },
|
||||
);
|
||||
} else {
|
||||
await lifeRecordApi.createRelativeRecord(genealogyId.value, payload, {
|
||||
requestController: relativeRecordSaveController,
|
||||
});
|
||||
}
|
||||
if (!pageActive) return;
|
||||
resetForm();
|
||||
editorState.value = "success";
|
||||
} catch (error) {
|
||||
if (!pageActive) return;
|
||||
if (
|
||||
!editingRecord.value &&
|
||||
relativeRecordCreateGuard.recordFailure(createAttempt, error)
|
||||
) {
|
||||
submitError.value =
|
||||
"提交结果暂时无法确认,请先返回贺礼簿检查,避免重复创建。";
|
||||
return;
|
||||
}
|
||||
if (!isRequestCancelled(error))
|
||||
submitError.value = error?.message || "往来记录提交失败,请稍后重试。";
|
||||
submitError.value = getRequestErrorMessage(error, "往来记录提交失败,请稍后重试。");
|
||||
} finally {
|
||||
isSubmitting.value = false;
|
||||
if (pageActive) isSubmitting.value = false;
|
||||
}
|
||||
};
|
||||
const requestBack = () =>
|
||||
@@ -280,12 +397,15 @@ const requestBack = () =>
|
||||
"confirm-discard": requestDiscardConfirmation,
|
||||
});
|
||||
const handleResultAction = () =>
|
||||
editorState.value === "success"
|
||||
["success", "error"].includes(editorState.value)
|
||||
? returnTo("R03", { genealogyId: genealogyId.value })
|
||||
: goBack();
|
||||
onBackPress((event) => handleBackPress(event, requestBack));
|
||||
onUnmounted(() => {
|
||||
requestController.abort();
|
||||
onUnload(() => {
|
||||
pageActive = false;
|
||||
relativeRecordDetailController.abort();
|
||||
relativeRecordMediaUploadController.abort();
|
||||
relativeRecordSaveController.abort();
|
||||
discardConfirmation.dispose();
|
||||
});
|
||||
</script>
|
||||
@@ -0,0 +1,333 @@
|
||||
<template>
|
||||
<view class="gift-page">
|
||||
<ModulePageBackground module="records" />
|
||||
<view class="page-header"
|
||||
><PageHeader
|
||||
title="贺礼簿"
|
||||
:action="valid ? '新建' : ''"
|
||||
custom-back
|
||||
@back="returnToFamily"
|
||||
@action="createRelative"
|
||||
/></view>
|
||||
<view class="page-content">
|
||||
<view v-if="!valid" class="state-card"
|
||||
><text>暂时无法打开贺礼簿</text
|
||||
><AppButton block label="返回上一页" @click="returnToFamily"
|
||||
/></view>
|
||||
<view v-else-if="relativeRecordListState === 'loading'" class="state-card"
|
||||
><AppLoading text="正在读取亲友往来"
|
||||
/></view>
|
||||
<view v-else-if="relativeRecordListState === 'error'" class="state-card"
|
||||
><text>暂时无法读取亲友往来</text
|
||||
><AppButton
|
||||
block
|
||||
type="secondary"
|
||||
label="重新加载"
|
||||
@click="loadRecords"
|
||||
/></view>
|
||||
<view v-else-if="relativeRecordListState === 'empty'" class="state-card"
|
||||
><text>还没有亲友往来记录</text
|
||||
><AppButton block label="新建往来记录" @click="createRelative"
|
||||
/></view>
|
||||
<view v-else class="record-list">
|
||||
<text v-if="deleteError" class="delete-error">{{ deleteError }}</text>
|
||||
<view v-for="item in records" :key="item.id" class="record-card" role="button" :aria-label="`查看${item.name}的往来详情`" @click="openRecordDetail(item)">
|
||||
<view
|
||||
><text>{{ item.name }}</text
|
||||
><text
|
||||
>{{ item.relation
|
||||
}}{{ item.event ? ` · ${item.event}` : "" }}</text
|
||||
><text v-if="item.time">{{ item.time }}</text
|
||||
><text v-if="item.content">{{ item.content }}</text></view
|
||||
>
|
||||
<view class="record-card__amount">
|
||||
<text v-if="item.amount">¥{{ item.amount }}</text>
|
||||
<AppButton
|
||||
v-if="item.canEdit"
|
||||
compact
|
||||
type="secondary"
|
||||
label="编辑"
|
||||
@click.stop="openEditRelative(item)"
|
||||
/>
|
||||
<AppButton
|
||||
v-if="item.canDelete"
|
||||
compact
|
||||
type="secondary"
|
||||
label="删除"
|
||||
@click.stop="requestDeleteRecord(item)"
|
||||
/>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<AppDialog
|
||||
:visible="Boolean(detailTarget)"
|
||||
eyebrow="往来详情"
|
||||
:title="detailTarget?.name || '亲友往来'"
|
||||
confirm-text="关闭"
|
||||
:close-on-mask="detailState !== 'loading'"
|
||||
@confirm="closeRecordDetail"
|
||||
@cancel="closeRecordDetail"
|
||||
>
|
||||
<view class="detail-content">
|
||||
<AppLoading v-if="detailState === 'loading'" text="正在读取完整记录" />
|
||||
<text v-else-if="detailState === 'error'" class="detail-error">{{ detailError }}</text>
|
||||
<template v-else>
|
||||
<text>关系:{{ detailTarget?.relation || "未填写" }}</text>
|
||||
<text>事项:{{ detailTarget?.event || "未填写" }}</text>
|
||||
<text>时间:{{ detailTarget?.time || "未填写" }}</text>
|
||||
<text>金额:{{ detailTarget?.amount ? `¥${detailTarget.amount}` : "未填写" }}</text>
|
||||
<text class="detail-content__body">{{ detailTarget?.content || "未填写记录内容" }}</text>
|
||||
<view v-if="detailTarget?.mediaFiles?.length" class="detail-media">
|
||||
<image v-for="file in detailTarget.mediaFiles" :key="file.fileId" :src="file.accessUrl" mode="aspectFill" role="button" aria-label="查看往来记录图片" @click="previewDetailMedia(file)" />
|
||||
</view>
|
||||
</template>
|
||||
</view>
|
||||
</AppDialog>
|
||||
<AppDialog
|
||||
:visible="deleteConfirmationVisible"
|
||||
title="删除这条亲友往来?"
|
||||
message="删除后无法恢复,请确认当前内容不再需要。"
|
||||
confirm-text="确认删除"
|
||||
cancel-text="保留记录"
|
||||
show-cancel
|
||||
:close-on-mask="false"
|
||||
@confirm="deleteRecord"
|
||||
@cancel="closeDeleteConfirmation"
|
||||
/>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref } from "vue";
|
||||
import { onLoad, 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";
|
||||
import {
|
||||
createRequestController,
|
||||
isRequestCancelled
|
||||
} from "@/services/api/request-controller.js";
|
||||
import { lifeRecordApi } from "@/services/api/life-record-service.js";
|
||||
import { getRequestErrorMessage } from "@/services/api/request-error-message.js";
|
||||
import { goBack, openPage, returnTo } from "@/utils/navigation/gateway.js";
|
||||
|
||||
const genealogyId = ref("");
|
||||
const records = ref([]);
|
||||
const relativeRecordListState = ref("loading");
|
||||
const relativeRecordListController = createRequestController();
|
||||
const relativeRecordDeleteController = createRequestController();
|
||||
const deleteConfirmationVisible = ref(false);
|
||||
const deleteTarget = ref(null);
|
||||
const deleting = ref(false);
|
||||
const deleteError = ref("");
|
||||
const detailTarget = ref(null);
|
||||
const detailState = ref("idle");
|
||||
const detailError = ref("");
|
||||
const relativeRecordDetailController = createRequestController();
|
||||
let isPageActive = true;
|
||||
const valid = computed(() => /^[1-9]\d*$/.test(genealogyId.value));
|
||||
const loadRecords = async () => {
|
||||
if (!valid.value) return;
|
||||
relativeRecordListController.abort();
|
||||
relativeRecordListState.value = "loading";
|
||||
try {
|
||||
const rows = await lifeRecordApi.getRelativeRecords(genealogyId.value, {
|
||||
requestController: relativeRecordListController,
|
||||
});
|
||||
if (!isPageActive) return;
|
||||
records.value = rows;
|
||||
relativeRecordListState.value = records.value.length ? "ready" : "empty";
|
||||
} catch (error) {
|
||||
if (!isPageActive || isRequestCancelled(error)) return;
|
||||
relativeRecordListState.value = "error";
|
||||
}
|
||||
};
|
||||
const returnToFamily = () =>
|
||||
valid.value ? returnTo("F01", { genealogyId: genealogyId.value }) : goBack();
|
||||
const createRelative = () =>
|
||||
valid.value
|
||||
? openPage("R04", { genealogyId: genealogyId.value, mode: "create" }, "R03")
|
||||
: Promise.resolve(false);
|
||||
const openEditRelative = (record) =>
|
||||
record?.canEdit && valid.value
|
||||
? openPage(
|
||||
"R04",
|
||||
{ genealogyId: genealogyId.value, mode: "edit", relativeId: record.id },
|
||||
"R03",
|
||||
)
|
||||
: Promise.resolve(false);
|
||||
const openRecordDetail = async (record) => {
|
||||
if (!record?.id || detailState.value === "loading") return;
|
||||
detailTarget.value = record;
|
||||
detailState.value = "loading";
|
||||
detailError.value = "";
|
||||
relativeRecordDetailController.abort();
|
||||
try {
|
||||
const loadedRecord = await lifeRecordApi.getRelativeRecordDetail(genealogyId.value, record.id, {
|
||||
requestController: relativeRecordDetailController,
|
||||
});
|
||||
if (!isPageActive) return;
|
||||
detailTarget.value = loadedRecord;
|
||||
detailState.value = "ready";
|
||||
} catch (error) {
|
||||
if (!isPageActive || isRequestCancelled(error)) return;
|
||||
detailState.value = "error";
|
||||
detailError.value = getRequestErrorMessage(error, "完整记录读取失败,请稍后重试。");
|
||||
}
|
||||
};
|
||||
const closeRecordDetail = () => {
|
||||
if (detailState.value === "loading") return;
|
||||
detailTarget.value = null;
|
||||
detailState.value = "idle";
|
||||
detailError.value = "";
|
||||
};
|
||||
const previewDetailMedia = (file) => {
|
||||
const urls = detailTarget.value?.mediaFiles?.map((item) => item.accessUrl).filter(Boolean) || [];
|
||||
if (!file?.accessUrl || !urls.length || typeof uni?.previewImage !== "function") return;
|
||||
uni.previewImage({ current: file.accessUrl, urls });
|
||||
};
|
||||
const requestDeleteRecord = (record) => {
|
||||
if (!record?.canDelete || deleting.value) return;
|
||||
deleteError.value = "";
|
||||
deleteTarget.value = record;
|
||||
deleteConfirmationVisible.value = true;
|
||||
};
|
||||
const closeDeleteConfirmation = () => {
|
||||
if (deleting.value) return;
|
||||
deleteConfirmationVisible.value = false;
|
||||
deleteTarget.value = null;
|
||||
};
|
||||
const deleteRecord = async () => {
|
||||
const record = deleteTarget.value;
|
||||
if (!record?.canDelete || deleting.value) return;
|
||||
deleting.value = true;
|
||||
deleteError.value = "";
|
||||
try {
|
||||
await lifeRecordApi.deleteRelativeRecord(genealogyId.value, record.id, {
|
||||
requestController: relativeRecordDeleteController,
|
||||
});
|
||||
if (!isPageActive) return;
|
||||
deleteConfirmationVisible.value = false;
|
||||
deleteTarget.value = null;
|
||||
await loadRecords();
|
||||
} catch (error) {
|
||||
if (!isPageActive || isRequestCancelled(error)) return;
|
||||
deleteError.value = getRequestErrorMessage(error, "亲友往来删除失败,请稍后重试。");
|
||||
deleteConfirmationVisible.value = false;
|
||||
} finally {
|
||||
if (isPageActive) deleting.value = false;
|
||||
}
|
||||
};
|
||||
onLoad((query) => {
|
||||
genealogyId.value = String(query?.genealogyId || "");
|
||||
if (valid.value) loadRecords();
|
||||
});
|
||||
onShow(() => {
|
||||
if (valid.value && relativeRecordListState.value !== "loading") loadRecords();
|
||||
});
|
||||
onUnload(() => {
|
||||
isPageActive = false;
|
||||
relativeRecordListController.abort();
|
||||
relativeRecordDeleteController.abort();
|
||||
relativeRecordDetailController.abort();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@import "../../styles/adaptive-frame-profiles.scss";
|
||||
.gift-page {
|
||||
display: flex;
|
||||
min-height: 100vh;
|
||||
flex-direction: column;
|
||||
background: $paper;
|
||||
}
|
||||
.page-header,
|
||||
.page-content {
|
||||
z-index: 1;
|
||||
}
|
||||
.page-content {
|
||||
padding: 18rpx 24rpx 72rpx;
|
||||
}
|
||||
.state-card,
|
||||
.record-card {
|
||||
@include adaptive-records-content;
|
||||
}
|
||||
.state-card {
|
||||
display: flex;
|
||||
min-height: 320rpx;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 42rpx;
|
||||
text-align: center;
|
||||
color: $ink;
|
||||
font-size: clamp(16px, 28rpx, 20px);
|
||||
}
|
||||
.state-card .app-button {
|
||||
width: 100%;
|
||||
margin-top: 24rpx;
|
||||
}
|
||||
.record-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16rpx;
|
||||
}
|
||||
.record-card {
|
||||
display: flex;
|
||||
min-height: 138rpx;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 18rpx;
|
||||
padding: 28rpx 32rpx;
|
||||
}
|
||||
.record-card > view {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
.record-card text {
|
||||
display: block;
|
||||
}
|
||||
.record-card > view text:first-child {
|
||||
color: $ink;
|
||||
font-family: STKaiti, KaiTi, serif;
|
||||
font-size: clamp(17px, 30rpx, 22px);
|
||||
font-weight: 700;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.record-card > view text:not(:first-child) {
|
||||
margin-top: 7rpx;
|
||||
color: $ink-muted;
|
||||
font-size: clamp(13px, 21rpx, 16px);
|
||||
line-height: 1.45;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.record-card__amount {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
align-items: center;
|
||||
gap: 10rpx;
|
||||
}
|
||||
.record-card__amount > text {
|
||||
margin-right: auto;
|
||||
color: $brand-red;
|
||||
font-size: clamp(14px, 22rpx, 17px);
|
||||
}
|
||||
.record-card__amount .app-button {
|
||||
width: 140rpx;
|
||||
min-height: 68rpx;
|
||||
}
|
||||
.delete-error {
|
||||
display: block;
|
||||
color: $brand-red;
|
||||
font-size: clamp(14px, 22rpx, 17px);
|
||||
}
|
||||
.detail-content { width: 100%; margin-top: 18rpx; text-align: left; }
|
||||
.detail-content > text { display: block; margin-top: 9rpx; color: $ink-muted; font-size: clamp(14px, 23rpx, 17px); line-height: 1.55; overflow-wrap: anywhere; }
|
||||
.detail-content__body { padding-top: 10rpx; border-top: 1rpx solid rgba(142, 95, 41, .2); color: $ink !important; white-space: pre-wrap; }
|
||||
.detail-error { color: $brand-red !important; }
|
||||
.detail-media { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); margin-top: 16rpx; gap: 10rpx; }
|
||||
.detail-media image { width: 100%; height: 150rpx; border-radius: 8rpx; background: rgba(128, 89, 49, .12); }
|
||||
</style>
|
||||
@@ -1,4 +1,3 @@
|
||||
<!-- 页面编号:T-04;用途:录入首位成员或为指定成员新增亲属。 -->
|
||||
<template>
|
||||
<view
|
||||
class="add-relative-page"
|
||||
@@ -78,6 +77,28 @@
|
||||
<text v-if="fieldErrors.bindingMode" class="field-error">{{
|
||||
fieldErrors.bindingMode
|
||||
}}</text>
|
||||
<picker
|
||||
v-if="addForm.bindingMode === 'SPECIFIED'"
|
||||
:range="memberOptionLabels"
|
||||
:value="memberOptionIndex(addForm.appUserId)"
|
||||
:disabled="memberOptionsState !== 'ready' || !memberOptions.length"
|
||||
@change="selectBoundMember"
|
||||
>
|
||||
<view class="form-field form-field--picker">
|
||||
<text><text class="required-mark">*</text>指定成员</text>
|
||||
<text>{{ memberOptionLabel(addForm.appUserId) }}</text>
|
||||
</view>
|
||||
</picker>
|
||||
<text
|
||||
v-if="addForm.bindingMode === 'SPECIFIED' && memberOptionsState === 'loading'"
|
||||
class="form-note"
|
||||
>正在读取可绑定成员</text
|
||||
>
|
||||
<text
|
||||
v-else-if="addForm.bindingMode === 'SPECIFIED' && memberOptionsState === 'error'"
|
||||
class="field-error"
|
||||
>可绑定成员暂不可用,请稍后重试。</text
|
||||
>
|
||||
<view class="form-field form-field--upload">
|
||||
<text>头像</text>
|
||||
<view>
|
||||
@@ -260,7 +281,7 @@
|
||||
<text class="form-note">{{ formNote }}</text>
|
||||
<view class="form-action" @click="submitAdd">
|
||||
<image
|
||||
src="/static/assets/foundation/transparent/a01-scroll-primary-v3.png"
|
||||
src="/static/assets/foundation/transparent/scroll-primary.png"
|
||||
mode="scaleToFill"
|
||||
/>
|
||||
<text>{{ isSubmitting ? "正在保存…" : "保存成员" }}</text>
|
||||
@@ -273,7 +294,7 @@
|
||||
<text class="form-copy">{{ resultCopy.copy }}</text>
|
||||
<view class="form-action" @click="handleResultAction">
|
||||
<image
|
||||
src="/static/assets/foundation/transparent/a01-scroll-primary-v3.png"
|
||||
src="/static/assets/foundation/transparent/scroll-primary.png"
|
||||
mode="scaleToFill"
|
||||
/>
|
||||
<text>{{ resultCopy.action }}</text>
|
||||
@@ -304,16 +325,28 @@ import AppDialog from "@/components/AppDialog.vue";
|
||||
import ModulePageBackground from "@/components/ModulePageBackground.vue";
|
||||
import PageHeader from "@/components/PageHeader.vue";
|
||||
import {
|
||||
appApi,
|
||||
createRequestController,
|
||||
isRequestCancelled,
|
||||
} from "@/utils/api.js";
|
||||
isRequestCancelled
|
||||
} from "@/services/api/request-controller.js";
|
||||
import { genealogyMemberApi } from "@/services/api/genealogy-member-service.js";
|
||||
import { lineageApi } from "@/services/api/lineage-service.js";
|
||||
import { getRequestErrorMessage } from "@/services/api/request-error-message.js";
|
||||
import { createNonIdempotentWriteGuard } from "@/utils/non-idempotent-write-guard.js";
|
||||
import {
|
||||
isImagePickCancelled,
|
||||
pickAndUploadImage,
|
||||
} from "@/utils/resumable-image-upload.js";
|
||||
} from "@/utils/media-upload.js";
|
||||
import { createDiscardConfirmation } from "@/utils/discard-confirmation.js";
|
||||
import { handleBackPress, returnTo, runBackGuard } from "@/utils/navigation.js";
|
||||
import { handleBackPress, returnTo, runBackGuard } from "@/utils/navigation/gateway.js";
|
||||
import {
|
||||
findMemberOptionIndex,
|
||||
findMemberOptionLabel,
|
||||
memberFormOptions,
|
||||
memberRelationOptions,
|
||||
memberRelationTypes,
|
||||
updateMemberBindingMode,
|
||||
updateMemberFormOption,
|
||||
} from "@/utils/tree/member-form.js";
|
||||
|
||||
const addState = ref("loading");
|
||||
const genealogyId = ref("");
|
||||
@@ -321,51 +354,37 @@ const personId = ref("");
|
||||
const mode = ref("relative");
|
||||
const relationType = ref("");
|
||||
const isSubmitting = ref(false);
|
||||
const memberCreationCommitted = ref(false);
|
||||
const memberCreationOutcomeUnknown = ref(false);
|
||||
const isAvatarUploading = ref(false);
|
||||
const avatarFileName = ref("");
|
||||
const avatarUploadError = ref("");
|
||||
const discardDialogVisible = ref(false);
|
||||
const currentMember = ref(null);
|
||||
const errorMessage = ref("");
|
||||
const addRequestController = createRequestController();
|
||||
const memberDetailRequestController = createRequestController();
|
||||
const memberOptionsRequestController = createRequestController();
|
||||
const avatarUploadRequestController = createRequestController();
|
||||
const memberCreationRequestController = createRequestController();
|
||||
const memberCreationGuard = createNonIdempotentWriteGuard();
|
||||
let pageActive = true;
|
||||
let loadSequence = 0;
|
||||
|
||||
const relationIntents = Object.freeze({
|
||||
FATHER: { label: "父亲" },
|
||||
MOTHER: { label: "母亲" },
|
||||
SPOUSE: { label: "配偶" },
|
||||
SIBLING: { label: "兄弟姐妹" },
|
||||
SON: { label: "儿子" },
|
||||
DAUGHTER: { label: "女儿" },
|
||||
});
|
||||
const genericRelationTypes = Object.freeze([
|
||||
"FATHER",
|
||||
"MOTHER",
|
||||
"SPOUSE",
|
||||
"SIBLING",
|
||||
"SON",
|
||||
"DAUGHTER",
|
||||
]);
|
||||
const sexOptions = Object.freeze([
|
||||
{ label: "男", value: "0" },
|
||||
{ label: "女", value: "1" },
|
||||
{ label: "未知", value: "2" },
|
||||
]);
|
||||
const lunarOptions = Object.freeze([
|
||||
{ label: "否", value: "0" },
|
||||
{ label: "是", value: "1" },
|
||||
]);
|
||||
const personStatusOptions = Object.freeze([
|
||||
{ label: "健在", value: "0" },
|
||||
{ label: "已故", value: "1" },
|
||||
{ label: "未知", value: "2" },
|
||||
]);
|
||||
const bindingModeOptions = Object.freeze([
|
||||
{ label: "不绑定账号", value: "NONE" },
|
||||
{ label: "绑定当前账号", value: "SELF" },
|
||||
]);
|
||||
const relationIntents = Object.freeze(
|
||||
Object.fromEntries(
|
||||
memberRelationOptions.map(({ value, label }) => [value, Object.freeze({ label })]),
|
||||
),
|
||||
);
|
||||
const genericRelationTypes = Object.freeze(
|
||||
memberRelationOptions.map(({ value }) => value),
|
||||
);
|
||||
const sexOptions = memberFormOptions.sex;
|
||||
const lunarOptions = memberFormOptions.lunar;
|
||||
const personStatusOptions = memberFormOptions.personStatus;
|
||||
const bindingModeOptions = memberFormOptions.bindingMode;
|
||||
const addForm = reactive({
|
||||
bindingMode: "NONE",
|
||||
appUserId: "",
|
||||
name: "",
|
||||
relation: "",
|
||||
aliasName: "",
|
||||
@@ -386,6 +405,8 @@ const addForm = reactive({
|
||||
sortOrder: "",
|
||||
});
|
||||
const fieldErrors = reactive({ name: "", relation: "", bindingMode: "" });
|
||||
const memberOptionsState = ref("loading");
|
||||
const memberOptions = ref([]);
|
||||
|
||||
const isFirstMember = computed(() => mode.value === "first");
|
||||
const isDeceased = computed(() => addForm.personStatus === "1");
|
||||
@@ -401,6 +422,9 @@ const relationOptions = computed(() => {
|
||||
const relationLabel = computed(
|
||||
() => activeRelationIntent.value?.label || addForm.relation || "亲属关系",
|
||||
);
|
||||
const memberOptionLabels = computed(() =>
|
||||
memberOptions.value.map((item) => item.label),
|
||||
);
|
||||
const hasValidContext = computed(
|
||||
() =>
|
||||
Boolean(genealogyId.value) &&
|
||||
@@ -421,16 +445,36 @@ const formCopy = computed(() =>
|
||||
);
|
||||
const formNote = computed(() => "红色 * 为必填项,其余内容可按家谱记载补充。");
|
||||
const resultCopy = computed(() => {
|
||||
if (memberCreationOutcomeUnknown.value) {
|
||||
return {
|
||||
eyebrow: "结果待确认",
|
||||
title: `${isFirstMember.value ? "首位成员" : relationLabel.value}可能已经保存`,
|
||||
copy:
|
||||
errorMessage.value ||
|
||||
"暂时无法确认成员是否已经写入。请先返回世系树检查,不要重复新增。",
|
||||
action: "返回世系树",
|
||||
};
|
||||
}
|
||||
if (memberCreationCommitted.value) {
|
||||
return {
|
||||
eyebrow: "保存完成",
|
||||
title: `${isFirstMember.value ? "首位成员" : relationLabel.value}已经保存`,
|
||||
copy:
|
||||
errorMessage.value ||
|
||||
"成员已经写入家谱,但页面暂时没有返回世系树。请重试返回,不要重复新增。",
|
||||
action: "返回世系树",
|
||||
};
|
||||
}
|
||||
if (hasValidContext.value) {
|
||||
return {
|
||||
eyebrow: "保存失败",
|
||||
title: `${isFirstMember.value ? "首位成员" : relationLabel.value}尚未保存`,
|
||||
copy: errorMessage.value || "服务未确认本次写入,请检查填写后重试。",
|
||||
copy: errorMessage.value || "成员资料尚未保存,请检查填写后重试。",
|
||||
action: "返回修改",
|
||||
};
|
||||
}
|
||||
return {
|
||||
eyebrow: "成员入口无效",
|
||||
eyebrow: "暂时无法打开成员页面",
|
||||
title: "没有找到要关联的成员",
|
||||
copy: errorMessage.value || "请从世系树重新进入。",
|
||||
action: "返回世系树",
|
||||
@@ -438,7 +482,8 @@ const resultCopy = computed(() => {
|
||||
});
|
||||
const hasDraft = computed(() =>
|
||||
Object.entries(addForm).some(
|
||||
([field, value]) => field !== "bindingMode" && String(value).trim(),
|
||||
([field, value]) =>
|
||||
field !== "bindingMode" && field !== "appUserId" && String(value).trim(),
|
||||
),
|
||||
);
|
||||
const discardConfirmation = createDiscardConfirmation((visible) => {
|
||||
@@ -452,17 +497,39 @@ const loadCurrentMember = async () => {
|
||||
const activeLoad = ++loadSequence;
|
||||
addState.value = "loading";
|
||||
try {
|
||||
const member = await appApi.getPerson(genealogyId.value, personId.value, {
|
||||
requestController: addRequestController,
|
||||
const member = await lineageApi.getPerson(genealogyId.value, personId.value, {
|
||||
requestController: memberDetailRequestController,
|
||||
});
|
||||
if (activeLoad !== loadSequence) return;
|
||||
if (!pageActive || activeLoad !== loadSequence) return;
|
||||
currentMember.value = member;
|
||||
addState.value = "form";
|
||||
} catch (error) {
|
||||
if (activeLoad !== loadSequence || isRequestCancelled(error)) return;
|
||||
if (!pageActive || activeLoad !== loadSequence || isRequestCancelled(error)) return;
|
||||
currentMember.value = null;
|
||||
addState.value = "error";
|
||||
errorMessage.value = error?.message || "当前成员暂不可用。";
|
||||
errorMessage.value = getRequestErrorMessage(error, "当前成员暂不可用。");
|
||||
}
|
||||
};
|
||||
const loadMemberOptions = async () => {
|
||||
memberOptionsState.value = "loading";
|
||||
try {
|
||||
const rows = await genealogyMemberApi.getMemberOptions(genealogyId.value, {
|
||||
requestController: memberOptionsRequestController,
|
||||
});
|
||||
if (!pageActive) return;
|
||||
memberOptions.value = rows
|
||||
.filter((item) => item.eligible)
|
||||
.map((item) => ({
|
||||
value: item.appUserId,
|
||||
label: item.relationName
|
||||
? `${item.memberName} · ${item.relationName}`
|
||||
: item.memberName,
|
||||
}));
|
||||
memberOptionsState.value = "ready";
|
||||
} catch (error) {
|
||||
if (!pageActive || isRequestCancelled(error)) return;
|
||||
memberOptions.value = [];
|
||||
memberOptionsState.value = "error";
|
||||
}
|
||||
};
|
||||
onLoad((query) => {
|
||||
@@ -472,21 +539,21 @@ onLoad((query) => {
|
||||
relationType.value = String(query.relationType || "");
|
||||
if (
|
||||
!genealogyId.value ||
|
||||
query.state === "error" ||
|
||||
(!isFirstMember.value && !personId.value)
|
||||
) {
|
||||
addState.value = "error";
|
||||
errorMessage.value = "当前家谱或成员上下文无效,请从世系树重新进入。";
|
||||
errorMessage.value = "这个页面已经过期,请从世系树重新进入。";
|
||||
return;
|
||||
}
|
||||
if (relationType.value && !activeRelationIntent.value) {
|
||||
addState.value = "error";
|
||||
errorMessage.value = "未知的亲属关系意图,本页不会推断或替换。";
|
||||
errorMessage.value = "暂时无法确定要添加的亲属关系,请从世系树重新选择。";
|
||||
return;
|
||||
}
|
||||
if (activeRelationIntent.value) {
|
||||
addForm.relation = activeRelationIntent.value.label;
|
||||
}
|
||||
void loadMemberOptions();
|
||||
if (isFirstMember.value) {
|
||||
addState.value = "form";
|
||||
return;
|
||||
@@ -494,8 +561,12 @@ onLoad((query) => {
|
||||
void loadCurrentMember();
|
||||
});
|
||||
onUnload(() => {
|
||||
pageActive = false;
|
||||
loadSequence += 1;
|
||||
addRequestController.abort();
|
||||
memberDetailRequestController.abort();
|
||||
memberOptionsRequestController.abort();
|
||||
avatarUploadRequestController.abort();
|
||||
memberCreationRequestController.abort();
|
||||
discardConfirmation.dispose();
|
||||
});
|
||||
|
||||
@@ -518,27 +589,30 @@ const selectRelation = (event) => {
|
||||
addForm.relation = relationOptions.value[Number(event.detail.value)] || "";
|
||||
clearError("relation");
|
||||
};
|
||||
const optionIndex = (options, value) =>
|
||||
Math.max(
|
||||
0,
|
||||
options.findIndex((item) => item.value === value),
|
||||
);
|
||||
const optionLabel = (options, value) =>
|
||||
options.find((item) => item.value === value)?.label || "";
|
||||
const optionIndex = findMemberOptionIndex;
|
||||
const optionLabel = findMemberOptionLabel;
|
||||
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: "",
|
||||
});
|
||||
}
|
||||
updateMemberFormOption(addForm, field, options, event);
|
||||
};
|
||||
const selectBindingMode = (event) => {
|
||||
addForm.bindingMode =
|
||||
bindingModeOptions[Number(event.detail.value)]?.value || "NONE";
|
||||
updateMemberBindingMode(addForm, event);
|
||||
fieldErrors.bindingMode = "";
|
||||
};
|
||||
const memberOptionIndex = (value) =>
|
||||
Math.max(
|
||||
0,
|
||||
memberOptions.value.findIndex((item) => item.value === value),
|
||||
);
|
||||
const memberOptionLabel = (value) =>
|
||||
memberOptions.value.find((item) => item.value === value)?.label ||
|
||||
(memberOptionsState.value === "loading"
|
||||
? "正在读取"
|
||||
: memberOptions.value.length
|
||||
? "请选择可绑定成员"
|
||||
: "暂无可绑定成员");
|
||||
const selectBoundMember = (event) => {
|
||||
addForm.appUserId =
|
||||
memberOptions.value[Number(event.detail.value)]?.value || "";
|
||||
fieldErrors.bindingMode = "";
|
||||
};
|
||||
const uploadAvatar = async () => {
|
||||
@@ -547,15 +621,17 @@ const uploadAvatar = async () => {
|
||||
avatarUploadError.value = "";
|
||||
try {
|
||||
const receipt = await pickAndUploadImage({
|
||||
requestController: addRequestController,
|
||||
requestController: avatarUploadRequestController,
|
||||
});
|
||||
if (!pageActive) return;
|
||||
addForm.avatarOssId = receipt.ossId;
|
||||
avatarFileName.value = receipt.fileName || "头像图片";
|
||||
} catch (error) {
|
||||
if (!pageActive) return;
|
||||
if (!isImagePickCancelled(error) && !isRequestCancelled(error))
|
||||
avatarUploadError.value = error?.message || "头像上传失败,请稍后重试";
|
||||
avatarUploadError.value = getRequestErrorMessage(error, "头像上传失败,请稍后重试");
|
||||
} finally {
|
||||
isAvatarUploading.value = false;
|
||||
if (pageActive) isAvatarUploading.value = false;
|
||||
}
|
||||
};
|
||||
const selectBirthDate = (event) => {
|
||||
@@ -569,94 +645,112 @@ const validateAddForm = () => {
|
||||
fieldErrors.relation =
|
||||
isFirstMember.value || addForm.relation ? "" : "请选择与当前成员的关系";
|
||||
fieldErrors.bindingMode =
|
||||
addForm.bindingMode === "SPECIFIED"
|
||||
? "当前未提供可选业务用户,不能指定认领"
|
||||
addForm.bindingMode === "SPECIFIED" && !addForm.appUserId
|
||||
? memberOptionsState.value === "error"
|
||||
? "可绑定成员暂不可用,请稍后重试"
|
||||
: "请选择可绑定成员"
|
||||
: "";
|
||||
return !fieldErrors.name && !fieldErrors.relation && !fieldErrors.bindingMode;
|
||||
};
|
||||
const submitAdd = async () => {
|
||||
if (isSubmitting.value || isAvatarUploading.value || !validateAddForm())
|
||||
return;
|
||||
const payload = {
|
||||
bindingMode: addForm.bindingMode,
|
||||
...(addForm.bindingMode === "SPECIFIED"
|
||||
? { appUserId: addForm.appUserId }
|
||||
: {}),
|
||||
name: addForm.name,
|
||||
aliasName: addForm.aliasName,
|
||||
sex: addForm.sex,
|
||||
generationName: addForm.generationName,
|
||||
avatarOssId: addForm.avatarOssId,
|
||||
birthDate: addForm.birthDate,
|
||||
birthLunar: addForm.birthLunar,
|
||||
birthPlace: addForm.birthPlace,
|
||||
deathDate: addForm.deathDate,
|
||||
deathLunar: addForm.deathLunar,
|
||||
deathPlace: addForm.deathPlace,
|
||||
burialPlace: addForm.burialPlace,
|
||||
personStatus: addForm.personStatus,
|
||||
biography: addForm.biography,
|
||||
remark: addForm.remark,
|
||||
sortOrder: addForm.sortOrder,
|
||||
...(isFirstMember.value
|
||||
? { generation: 1 }
|
||||
: {
|
||||
...(addForm.generation
|
||||
? { generation: Number(addForm.generation) }
|
||||
: {}),
|
||||
...(relationType.value === memberRelationTypes.SPOUSE
|
||||
? { relationName: relationLabel.value }
|
||||
: {}),
|
||||
}),
|
||||
};
|
||||
const submittedRelationType = isFirstMember.value
|
||||
? ""
|
||||
: activeRelationIntent.value
|
||||
? relationType.value
|
||||
: genericRelationTypes[relationOptions.value.indexOf(addForm.relation)];
|
||||
const createAttempt = memberCreationGuard.begin({
|
||||
genealogyId: genealogyId.value,
|
||||
personId: isFirstMember.value ? "" : personId.value,
|
||||
relationType: submittedRelationType,
|
||||
payload,
|
||||
});
|
||||
if (createAttempt === null) {
|
||||
memberCreationOutcomeUnknown.value = true;
|
||||
errorMessage.value =
|
||||
"上次保存结果暂时无法确认,请先返回世系树检查,避免重复新增。";
|
||||
addState.value = "error";
|
||||
return;
|
||||
}
|
||||
|
||||
isSubmitting.value = true;
|
||||
try {
|
||||
const payload = {
|
||||
bindingMode: addForm.bindingMode,
|
||||
name: addForm.name,
|
||||
aliasName: addForm.aliasName,
|
||||
sex: addForm.sex,
|
||||
generationName: addForm.generationName,
|
||||
avatarOssId: addForm.avatarOssId,
|
||||
birthDate: addForm.birthDate,
|
||||
birthLunar: addForm.birthLunar,
|
||||
birthPlace: addForm.birthPlace,
|
||||
deathDate: addForm.deathDate,
|
||||
deathLunar: addForm.deathLunar,
|
||||
deathPlace: addForm.deathPlace,
|
||||
burialPlace: addForm.burialPlace,
|
||||
personStatus: addForm.personStatus,
|
||||
biography: addForm.biography,
|
||||
remark: addForm.remark,
|
||||
sortOrder: addForm.sortOrder,
|
||||
...(isFirstMember.value
|
||||
? { generation: 1 }
|
||||
: {
|
||||
...(addForm.generation
|
||||
? { generation: Number(addForm.generation) }
|
||||
: {}),
|
||||
...(relationType.value === "SPOUSE"
|
||||
? { relationName: relationLabel.value }
|
||||
: {}),
|
||||
}),
|
||||
};
|
||||
if (isFirstMember.value) {
|
||||
await appApi.createPerson(genealogyId.value, payload, {
|
||||
requestController: addRequestController,
|
||||
await lineageApi.createPerson(genealogyId.value, payload, {
|
||||
requestController: memberCreationRequestController,
|
||||
});
|
||||
} else {
|
||||
const type = activeRelationIntent.value
|
||||
? relationType.value
|
||||
: genericRelationTypes[relationOptions.value.indexOf(addForm.relation)];
|
||||
await appApi.createRelatedPerson(
|
||||
await lineageApi.createRelatedPerson(
|
||||
genealogyId.value,
|
||||
personId.value,
|
||||
type,
|
||||
submittedRelationType,
|
||||
payload,
|
||||
{ requestController: addRequestController },
|
||||
{ requestController: memberCreationRequestController },
|
||||
);
|
||||
}
|
||||
Object.assign(addForm, {
|
||||
bindingMode: "NONE",
|
||||
name: "",
|
||||
relation: "",
|
||||
aliasName: "",
|
||||
sex: "",
|
||||
generation: "",
|
||||
generationName: "",
|
||||
avatarOssId: "",
|
||||
birthDate: "",
|
||||
birthLunar: "",
|
||||
birthPlace: "",
|
||||
deathDate: "",
|
||||
deathLunar: "",
|
||||
deathPlace: "",
|
||||
burialPlace: "",
|
||||
personStatus: "",
|
||||
biography: "",
|
||||
remark: "",
|
||||
sortOrder: "",
|
||||
});
|
||||
avatarFileName.value = "";
|
||||
if (!pageActive) return;
|
||||
memberCreationCommitted.value = true;
|
||||
await returnTo("T01", { genealogyId: genealogyId.value });
|
||||
} catch (error) {
|
||||
if (!pageActive) return;
|
||||
if (
|
||||
!memberCreationCommitted.value &&
|
||||
memberCreationGuard.recordFailure(createAttempt, error)
|
||||
) {
|
||||
memberCreationOutcomeUnknown.value = true;
|
||||
errorMessage.value =
|
||||
"保存结果暂时无法确认,请先返回世系树检查,避免重复新增。";
|
||||
addState.value = "error";
|
||||
return;
|
||||
}
|
||||
if (isRequestCancelled(error)) return;
|
||||
errorMessage.value = error?.message || "服务未确认本次写入。";
|
||||
errorMessage.value = memberCreationCommitted.value
|
||||
? "成员已经保存,但页面返回失败。请重试返回世系树,不要重复新增。"
|
||||
: getRequestErrorMessage(error, "成员资料尚未保存,请稍后重试。");
|
||||
addState.value = "error";
|
||||
} finally {
|
||||
isSubmitting.value = false;
|
||||
if (pageActive) isSubmitting.value = false;
|
||||
}
|
||||
};
|
||||
const handleResultAction = () =>
|
||||
hasValidContext.value ? (addState.value = "form") : returnToTree();
|
||||
memberCreationCommitted.value || memberCreationOutcomeUnknown.value
|
||||
? returnTo("T01", { genealogyId: genealogyId.value })
|
||||
: hasValidContext.value
|
||||
? (addState.value = "form")
|
||||
: returnToTree();
|
||||
const returnToTree = async () => {
|
||||
const confirmed = hasDraft.value ? await requestDiscardConfirmation() : true;
|
||||
if (!confirmed) return false;
|
||||
@@ -1,4 +1,3 @@
|
||||
<!-- 页面编号:T-05;用途:维护指定成员的身份与生平资料。 -->
|
||||
<template>
|
||||
<view
|
||||
class="edit-member-page"
|
||||
@@ -16,7 +15,7 @@
|
||||
<AppLoading
|
||||
v-if="editState === 'loading'"
|
||||
text="正在读取成员资料"
|
||||
description="请稍候,正在确认可编辑字段。"
|
||||
description="请稍候,正在打开编辑资料。"
|
||||
/>
|
||||
|
||||
<view v-else-if="editState === 'form'" class="edit-member-form">
|
||||
@@ -59,14 +58,7 @@
|
||||
}}</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"
|
||||
@@ -81,6 +73,28 @@
|
||||
<text v-if="fieldErrors.bindingMode" class="field-error">{{
|
||||
fieldErrors.bindingMode
|
||||
}}</text>
|
||||
<picker
|
||||
v-if="editForm.bindingMode === 'SPECIFIED'"
|
||||
:range="memberOptionLabels"
|
||||
:value="memberOptionIndex(editForm.appUserId)"
|
||||
:disabled="memberOptionsState !== 'ready' || !memberBindingOptions.length"
|
||||
@change="selectBoundMember"
|
||||
>
|
||||
<view class="form-field form-field--picker">
|
||||
<text><text class="required-mark">*</text>指定成员</text>
|
||||
<text>{{ memberOptionLabel(editForm.appUserId) }}</text>
|
||||
</view>
|
||||
</picker>
|
||||
<text
|
||||
v-if="editForm.bindingMode === 'SPECIFIED' && memberOptionsState === 'loading'"
|
||||
class="form-note"
|
||||
>正在读取可绑定成员</text
|
||||
>
|
||||
<text
|
||||
v-else-if="editForm.bindingMode === 'SPECIFIED' && memberOptionsState === 'error'"
|
||||
class="field-error"
|
||||
>可绑定成员暂不可用,请稍后重试。</text
|
||||
>
|
||||
<view class="form-field form-field--upload">
|
||||
<text>头像</text>
|
||||
<view>
|
||||
@@ -262,11 +276,11 @@
|
||||
}}</text>
|
||||
|
||||
<text class="form-note"
|
||||
>隐私字段只向本人和具备维护权限的家谱管理员展示。</text
|
||||
>私密信息仅你本人和可维护家谱的管理员可见。</text
|
||||
>
|
||||
<view class="form-action" @click="saveMember">
|
||||
<image
|
||||
src="/static/assets/foundation/transparent/a01-scroll-primary-v3.png"
|
||||
src="/static/assets/foundation/transparent/scroll-primary.png"
|
||||
mode="scaleToFill"
|
||||
/>
|
||||
<text>{{ isSubmitting ? "正在保存…" : "保存资料" }}</text>
|
||||
@@ -279,7 +293,7 @@
|
||||
<text class="form-copy">{{ resultCopy.copy }}</text>
|
||||
<view class="form-action" @click="handleResultAction">
|
||||
<image
|
||||
src="/static/assets/foundation/transparent/a01-scroll-primary-v3.png"
|
||||
src="/static/assets/foundation/transparent/scroll-primary.png"
|
||||
mode="scaleToFill"
|
||||
/>
|
||||
<text>{{ resultCopy.action }}</text>
|
||||
@@ -310,21 +324,30 @@ import AppDialog from "@/components/AppDialog.vue";
|
||||
import ModulePageBackground from "@/components/ModulePageBackground.vue";
|
||||
import PageHeader from "@/components/PageHeader.vue";
|
||||
import {
|
||||
appApi,
|
||||
createRequestController,
|
||||
isRequestCancelled,
|
||||
} from "@/utils/api.js";
|
||||
isRequestCancelled
|
||||
} from "@/services/api/request-controller.js";
|
||||
import { genealogyMemberApi } from "@/services/api/genealogy-member-service.js";
|
||||
import { lineageApi } from "@/services/api/lineage-service.js";
|
||||
import { getRequestErrorMessage } from "@/services/api/request-error-message.js";
|
||||
import {
|
||||
isImagePickCancelled,
|
||||
pickAndUploadImage,
|
||||
} from "@/utils/resumable-image-upload.js";
|
||||
} from "@/utils/media-upload.js";
|
||||
import { createDiscardConfirmation } from "@/utils/discard-confirmation.js";
|
||||
import {
|
||||
finishPage,
|
||||
goBack,
|
||||
handleBackPress,
|
||||
runBackGuard,
|
||||
} from "@/utils/navigation.js";
|
||||
} from "@/utils/navigation/gateway.js";
|
||||
import {
|
||||
findMemberOptionIndex,
|
||||
findMemberOptionLabel,
|
||||
memberFormOptions,
|
||||
updateMemberBindingMode,
|
||||
updateMemberFormOption,
|
||||
} from "@/utils/tree/member-form.js";
|
||||
|
||||
const editState = ref("loading");
|
||||
const genealogyId = ref("");
|
||||
@@ -336,12 +359,17 @@ const avatarUploadError = ref("");
|
||||
const discardDialogVisible = ref(false);
|
||||
const errorMessage = ref("");
|
||||
const failedAction = ref("load");
|
||||
const editRequestController = createRequestController();
|
||||
const memberDetailRequestController = createRequestController();
|
||||
const personOptionsRequestController = createRequestController();
|
||||
const memberOptionsRequestController = createRequestController();
|
||||
const avatarUploadRequestController = createRequestController();
|
||||
const memberUpdateRequestController = createRequestController();
|
||||
let pageActive = true;
|
||||
let loadSequence = 0;
|
||||
|
||||
const originalMember = ref(null);
|
||||
const baseline = ref("");
|
||||
const committedMemberSnapshot = ref("");
|
||||
const editForm = reactive({
|
||||
appUserId: "",
|
||||
bindingMode: "NONE",
|
||||
@@ -366,28 +394,30 @@ const editForm = reactive({
|
||||
sortOrder: "",
|
||||
});
|
||||
const fieldErrors = reactive({ name: "", dates: "", bindingMode: "" });
|
||||
const sexOptions = Object.freeze([
|
||||
{ label: "男", value: "0" },
|
||||
{ label: "女", value: "1" },
|
||||
{ label: "未知", value: "2" },
|
||||
]);
|
||||
const lunarOptions = Object.freeze([
|
||||
{ label: "否", value: "0" },
|
||||
{ label: "是", value: "1" },
|
||||
]);
|
||||
const personStatusOptions = Object.freeze([
|
||||
{ label: "健在", value: "0" },
|
||||
{ label: "已故", value: "1" },
|
||||
{ label: "未知", value: "2" },
|
||||
]);
|
||||
const bindingModeOptions = Object.freeze([
|
||||
{ label: "不绑定账号", value: "NONE" },
|
||||
{ label: "绑定当前账号", value: "SELF" },
|
||||
]);
|
||||
const sexOptions = memberFormOptions.sex;
|
||||
const lunarOptions = memberFormOptions.lunar;
|
||||
const personStatusOptions = memberFormOptions.personStatus;
|
||||
const bindingModeOptions = memberFormOptions.bindingMode;
|
||||
const personOptions = ref([{ label: "不选择", value: "" }]);
|
||||
const personOptionLabels = computed(() =>
|
||||
personOptions.value.map((item) => item.label),
|
||||
);
|
||||
const memberOptionsState = ref("loading");
|
||||
const memberOptions = ref([]);
|
||||
const memberBindingOptions = computed(() => {
|
||||
const options = memberOptions.value.slice();
|
||||
if (
|
||||
editForm.bindingMode === "SPECIFIED" &&
|
||||
editForm.appUserId &&
|
||||
!options.some((item) => item.value === editForm.appUserId)
|
||||
) {
|
||||
options.unshift({ value: editForm.appUserId, label: "当前已绑定成员" });
|
||||
}
|
||||
return options;
|
||||
});
|
||||
const memberOptionLabels = computed(() =>
|
||||
memberBindingOptions.value.map((item) => item.label),
|
||||
);
|
||||
const isDeceased = computed(() => editForm.personStatus === "1");
|
||||
|
||||
const formSnapshot = computed(() => JSON.stringify(editForm));
|
||||
@@ -404,12 +434,21 @@ const resultCopy = computed(
|
||||
() =>
|
||||
({
|
||||
error:
|
||||
failedAction.value === "save"
|
||||
failedAction.value === "navigate"
|
||||
? {
|
||||
eyebrow: "保存完成",
|
||||
title: "成员档案已经保存",
|
||||
copy:
|
||||
errorMessage.value ||
|
||||
"资料已保存,但页面暂时没有返回成员档案。",
|
||||
action: "返回成员档案",
|
||||
}
|
||||
: failedAction.value === "save"
|
||||
? {
|
||||
eyebrow: "保存失败",
|
||||
title: "成员档案尚未保存",
|
||||
copy:
|
||||
errorMessage.value || "服务未确认本次修改,请检查填写后重试。",
|
||||
errorMessage.value || "资料尚未保存,请检查填写后重试。",
|
||||
action: "返回修改",
|
||||
}
|
||||
: hasValidContext.value
|
||||
@@ -420,7 +459,7 @@ const resultCopy = computed(
|
||||
action: "重新读取",
|
||||
}
|
||||
: {
|
||||
eyebrow: "成员入口无效",
|
||||
eyebrow: "暂时无法打开成员页面",
|
||||
title: "没有找到要编辑的成员",
|
||||
copy: errorMessage.value || "请从成员档案重新进入。",
|
||||
action: "返回上一页",
|
||||
@@ -439,10 +478,10 @@ const loadMember = async () => {
|
||||
const activeLoad = ++loadSequence;
|
||||
editState.value = "loading";
|
||||
try {
|
||||
const member = await appApi.getPerson(genealogyId.value, personId.value, {
|
||||
requestController: editRequestController,
|
||||
const member = await lineageApi.getPerson(genealogyId.value, personId.value, {
|
||||
requestController: memberDetailRequestController,
|
||||
});
|
||||
if (activeLoad !== loadSequence) return;
|
||||
if (!pageActive || activeLoad !== loadSequence) return;
|
||||
originalMember.value = member;
|
||||
const deceased = member.personStatus === "1";
|
||||
Object.assign(editForm, {
|
||||
@@ -472,54 +511,86 @@ const loadMember = async () => {
|
||||
sortOrder: member.sortOrder ?? "",
|
||||
});
|
||||
baseline.value = formSnapshot.value;
|
||||
committedMemberSnapshot.value = "";
|
||||
errorMessage.value = "";
|
||||
failedAction.value = "load";
|
||||
editState.value = "form";
|
||||
} catch (error) {
|
||||
if (activeLoad !== loadSequence || isRequestCancelled(error)) return;
|
||||
if (!pageActive || activeLoad !== loadSequence || isRequestCancelled(error)) return;
|
||||
originalMember.value = null;
|
||||
failedAction.value = "load";
|
||||
errorMessage.value = error?.message || "成员资料暂不可用。";
|
||||
errorMessage.value = getRequestErrorMessage(error, "成员资料暂不可用。");
|
||||
editState.value = "error";
|
||||
}
|
||||
};
|
||||
const loadPersonOptions = async () => {
|
||||
try {
|
||||
const rows = await appApi.getLineagePersonOptions(genealogyId.value, {
|
||||
const rows = await lineageApi.getLineagePersonOptions(genealogyId.value, {
|
||||
requestController: personOptionsRequestController,
|
||||
});
|
||||
if (!pageActive) return;
|
||||
const options = rows
|
||||
.map((item) => {
|
||||
const value = String(item?.personId ?? item?.id ?? "");
|
||||
if (!/^[1-9]\d*$/.test(value) || value === personId.value) return null;
|
||||
const name = String(
|
||||
item?.name || item?.personName || item?.personNo || "未命名人物",
|
||||
);
|
||||
return { value, label: `${name}(${value})` };
|
||||
const personOptionId = item.id;
|
||||
if (
|
||||
!/^[1-9]\d*$/.test(personOptionId) ||
|
||||
personOptionId === personId.value
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
const name = item.name;
|
||||
return { value: personOptionId, label: `${name}(${personOptionId})` };
|
||||
})
|
||||
.filter(Boolean);
|
||||
personOptions.value = [{ label: "不选择", value: "" }, ...options];
|
||||
} catch (error) {
|
||||
if (!isRequestCancelled(error))
|
||||
if (pageActive && !isRequestCancelled(error))
|
||||
personOptions.value = [{ label: "暂无可选人物", value: "" }];
|
||||
}
|
||||
};
|
||||
const loadMemberOptions = async () => {
|
||||
memberOptionsState.value = "loading";
|
||||
try {
|
||||
const rows = await genealogyMemberApi.getMemberOptions(genealogyId.value, {
|
||||
requestController: memberOptionsRequestController,
|
||||
});
|
||||
if (!pageActive) return;
|
||||
memberOptions.value = rows
|
||||
.filter((item) => item.eligible)
|
||||
.map((item) => ({
|
||||
value: item.appUserId,
|
||||
label: item.relationName
|
||||
? `${item.memberName} · ${item.relationName}`
|
||||
: item.memberName,
|
||||
}));
|
||||
memberOptionsState.value = "ready";
|
||||
} catch (error) {
|
||||
if (!pageActive || isRequestCancelled(error)) return;
|
||||
memberOptions.value = [];
|
||||
memberOptionsState.value = "error";
|
||||
}
|
||||
};
|
||||
|
||||
onLoad((query) => {
|
||||
genealogyId.value = String(query.genealogyId || "");
|
||||
personId.value = String(query.personId || "");
|
||||
if (!genealogyId.value || !personId.value || query.state === "error") {
|
||||
if (!genealogyId.value || !personId.value) {
|
||||
editState.value = "error";
|
||||
errorMessage.value = "当前家谱或成员上下文无效。";
|
||||
errorMessage.value = "这个页面已经过期,请从成员档案重新进入。";
|
||||
return;
|
||||
}
|
||||
void loadMember();
|
||||
void loadPersonOptions();
|
||||
void loadMemberOptions();
|
||||
});
|
||||
onUnload(() => {
|
||||
pageActive = false;
|
||||
loadSequence += 1;
|
||||
editRequestController.abort();
|
||||
memberDetailRequestController.abort();
|
||||
personOptionsRequestController.abort();
|
||||
memberOptionsRequestController.abort();
|
||||
avatarUploadRequestController.abort();
|
||||
memberUpdateRequestController.abort();
|
||||
discardConfirmation.dispose();
|
||||
});
|
||||
|
||||
@@ -538,27 +609,30 @@ onBackPress((event) => handleBackPress(event, requestBack));
|
||||
const clearError = (field) => {
|
||||
fieldErrors[field] = "";
|
||||
};
|
||||
const optionIndex = (options, value) =>
|
||||
Math.max(
|
||||
0,
|
||||
options.findIndex((item) => item.value === value),
|
||||
);
|
||||
const optionLabel = (options, value) =>
|
||||
options.find((item) => item.value === value)?.label || "";
|
||||
const optionIndex = findMemberOptionIndex;
|
||||
const optionLabel = findMemberOptionLabel;
|
||||
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: "",
|
||||
});
|
||||
}
|
||||
updateMemberFormOption(editForm, field, options, event);
|
||||
};
|
||||
const selectBindingMode = (event) => {
|
||||
editForm.bindingMode =
|
||||
bindingModeOptions[Number(event.detail.value)]?.value || "NONE";
|
||||
updateMemberBindingMode(editForm, event);
|
||||
fieldErrors.bindingMode = "";
|
||||
};
|
||||
const memberOptionIndex = (value) =>
|
||||
Math.max(
|
||||
0,
|
||||
memberBindingOptions.value.findIndex((item) => item.value === value),
|
||||
);
|
||||
const memberOptionLabel = (value) =>
|
||||
memberBindingOptions.value.find((item) => item.value === value)?.label ||
|
||||
(memberOptionsState.value === "loading"
|
||||
? "正在读取"
|
||||
: memberBindingOptions.value.length
|
||||
? "请选择可绑定成员"
|
||||
: "暂无可绑定成员");
|
||||
const selectBoundMember = (event) => {
|
||||
editForm.appUserId =
|
||||
memberBindingOptions.value[Number(event.detail.value)]?.value || "";
|
||||
fieldErrors.bindingMode = "";
|
||||
};
|
||||
const personOptionIndex = (value) =>
|
||||
@@ -578,15 +652,17 @@ const uploadAvatar = async () => {
|
||||
avatarUploadError.value = "";
|
||||
try {
|
||||
const receipt = await pickAndUploadImage({
|
||||
requestController: editRequestController,
|
||||
requestController: avatarUploadRequestController,
|
||||
});
|
||||
if (!pageActive) return;
|
||||
editForm.avatarOssId = receipt.ossId;
|
||||
avatarFileName.value = receipt.fileName || "头像图片";
|
||||
} catch (error) {
|
||||
if (!pageActive) return;
|
||||
if (!isImagePickCancelled(error) && !isRequestCancelled(error))
|
||||
avatarUploadError.value = error?.message || "头像上传失败,请稍后重试";
|
||||
avatarUploadError.value = getRequestErrorMessage(error, "头像上传失败,请稍后重试");
|
||||
} finally {
|
||||
isAvatarUploading.value = false;
|
||||
if (pageActive) isAvatarUploading.value = false;
|
||||
}
|
||||
};
|
||||
const selectDate = (field, event) => {
|
||||
@@ -603,7 +679,9 @@ const validateEditForm = () => {
|
||||
: "";
|
||||
fieldErrors.bindingMode =
|
||||
editForm.bindingMode === "SPECIFIED" && !editForm.appUserId
|
||||
? "当前未提供可选业务用户,不能指定认领"
|
||||
? memberOptionsState.value === "error"
|
||||
? "可绑定成员暂不可用,请稍后重试"
|
||||
: "请选择可绑定成员"
|
||||
: "";
|
||||
return !fieldErrors.name && !fieldErrors.dates && !fieldErrors.bindingMode;
|
||||
};
|
||||
@@ -612,43 +690,48 @@ const saveMember = async () => {
|
||||
return;
|
||||
isSubmitting.value = true;
|
||||
try {
|
||||
await appApi.updatePerson(
|
||||
genealogyId.value,
|
||||
personId.value,
|
||||
{
|
||||
bindingMode: editForm.bindingMode,
|
||||
...(editForm.bindingMode === "SPECIFIED"
|
||||
? { appUserId: editForm.appUserId }
|
||||
: {}),
|
||||
name: editForm.name,
|
||||
aliasName: editForm.aliasName,
|
||||
sex: editForm.sex,
|
||||
generation: editForm.generation
|
||||
? Number(editForm.generation)
|
||||
: undefined,
|
||||
generationName: editForm.generationName,
|
||||
fatherId: editForm.fatherId,
|
||||
motherId: editForm.motherId,
|
||||
avatarOssId: editForm.avatarOssId,
|
||||
birthDate: editForm.birthDate,
|
||||
birthLunar: editForm.birthLunar,
|
||||
birthPlace: editForm.birthPlace,
|
||||
...(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,
|
||||
},
|
||||
{ requestController: editRequestController },
|
||||
);
|
||||
baseline.value = formSnapshot.value;
|
||||
const currentSnapshot = formSnapshot.value;
|
||||
if (committedMemberSnapshot.value !== currentSnapshot) {
|
||||
await lineageApi.updatePerson(
|
||||
genealogyId.value,
|
||||
personId.value,
|
||||
{
|
||||
bindingMode: editForm.bindingMode,
|
||||
...(editForm.bindingMode === "SPECIFIED"
|
||||
? { appUserId: editForm.appUserId }
|
||||
: {}),
|
||||
name: editForm.name,
|
||||
aliasName: editForm.aliasName,
|
||||
sex: editForm.sex,
|
||||
generation: editForm.generation
|
||||
? Number(editForm.generation)
|
||||
: undefined,
|
||||
generationName: editForm.generationName,
|
||||
fatherId: editForm.fatherId,
|
||||
motherId: editForm.motherId,
|
||||
avatarOssId: editForm.avatarOssId,
|
||||
birthDate: editForm.birthDate,
|
||||
birthLunar: editForm.birthLunar,
|
||||
birthPlace: editForm.birthPlace,
|
||||
...(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,
|
||||
},
|
||||
{ requestController: memberUpdateRequestController },
|
||||
);
|
||||
if (!pageActive) return;
|
||||
committedMemberSnapshot.value = currentSnapshot;
|
||||
baseline.value = currentSnapshot;
|
||||
}
|
||||
await finishPage(
|
||||
"T03",
|
||||
{
|
||||
@@ -662,12 +745,16 @@ const saveMember = async () => {
|
||||
},
|
||||
);
|
||||
} catch (error) {
|
||||
if (isRequestCancelled(error)) return;
|
||||
failedAction.value = "save";
|
||||
errorMessage.value = error?.message || "服务未确认本次修改。";
|
||||
if (!pageActive || isRequestCancelled(error)) return;
|
||||
const updateCommitted =
|
||||
committedMemberSnapshot.value === formSnapshot.value;
|
||||
failedAction.value = updateCommitted ? "navigate" : "save";
|
||||
errorMessage.value = updateCommitted
|
||||
? "资料已经保存,但页面返回失败。请重试返回成员档案。"
|
||||
: getRequestErrorMessage(error, "资料尚未保存,请稍后重试。");
|
||||
editState.value = "error";
|
||||
} finally {
|
||||
isSubmitting.value = false;
|
||||
if (pageActive) isSubmitting.value = false;
|
||||
}
|
||||
};
|
||||
const returnToMember = async () => {
|
||||
@@ -676,6 +763,7 @@ const returnToMember = async () => {
|
||||
return goBack();
|
||||
};
|
||||
const handleResultAction = () => {
|
||||
if (failedAction.value === "navigate") return saveMember();
|
||||
if (failedAction.value === "save") {
|
||||
editState.value = "form";
|
||||
return;
|
||||
@@ -1,4 +1,3 @@
|
||||
<!-- 页面编号:T-07;用途:成员目录、搜索、空态与失败状态。 -->
|
||||
<template>
|
||||
<view
|
||||
class="directory-page"
|
||||
@@ -98,11 +97,11 @@ 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 { goBack, openPage } from "@/utils/navigation.js";
|
||||
isRequestCancelled
|
||||
} from "@/services/api/request-controller.js";
|
||||
import { lineageApi } from "@/services/api/lineage-service.js";
|
||||
import { goBack, openPage } from "@/utils/navigation/gateway.js";
|
||||
const genealogyId = ref("");
|
||||
const keyword = ref("");
|
||||
const directoryState = ref("loading");
|
||||
@@ -110,19 +109,15 @@ const members = ref([]);
|
||||
const total = ref(0);
|
||||
const pageNum = ref(1);
|
||||
const loadingMore = ref(false);
|
||||
const directoryRequestController = createRequestController();
|
||||
const memberDirectoryRequestController = createRequestController();
|
||||
let loadSequence = 0;
|
||||
const hasValidContext = computed(() => Boolean(genealogyId.value));
|
||||
const memberMeta = (member) =>
|
||||
`第 ${member.generation} 世 · ${member.generationName || "字辈待补"} · ${member.branch}`;
|
||||
const memberStatus = (member) => {
|
||||
const labels = {
|
||||
0: "健在",
|
||||
1: "已故",
|
||||
2: "未知",
|
||||
};
|
||||
const label = labels[String(member.personStatus ?? "").trim()];
|
||||
return label ? `人物状态:${label}` : "人物状态待确认";
|
||||
return member.personStatusLabel
|
||||
? `人物状态:${member.personStatusLabel}`
|
||||
: "人物状态待确认";
|
||||
};
|
||||
const hasMore = computed(() => members.value.length < total.value);
|
||||
|
||||
@@ -132,14 +127,14 @@ const loadMembers = async ({ append = false } = {}) => {
|
||||
if (append) loadingMore.value = true;
|
||||
else directoryState.value = "loading";
|
||||
try {
|
||||
const result = await appApi.getPersonPage(
|
||||
const personPage = await lineageApi.getPersonPage(
|
||||
genealogyId.value,
|
||||
{ pageNum: pageNum.value, pageSize: 10, keyword: keyword.value },
|
||||
{ requestController: directoryRequestController },
|
||||
{ requestController: memberDirectoryRequestController },
|
||||
);
|
||||
if (activeLoad !== loadSequence) return;
|
||||
members.value = append ? [...members.value, ...result.rows] : result.rows;
|
||||
total.value = result.total;
|
||||
members.value = append ? [...members.value, ...personPage.rows] : personPage.rows;
|
||||
total.value = personPage.total;
|
||||
directoryState.value = members.value.length ? "list" : "empty";
|
||||
} catch (error) {
|
||||
if (activeLoad !== loadSequence || isRequestCancelled(error)) return;
|
||||
@@ -151,7 +146,7 @@ const loadMembers = async ({ append = false } = {}) => {
|
||||
};
|
||||
onLoad((query) => {
|
||||
genealogyId.value = String(query.genealogyId || "");
|
||||
if (!hasValidContext.value || query.state === "error") {
|
||||
if (!hasValidContext.value) {
|
||||
directoryState.value = "error";
|
||||
return;
|
||||
}
|
||||
@@ -181,7 +176,7 @@ const openMember = (item) =>
|
||||
: Promise.resolve(false);
|
||||
onUnload(() => {
|
||||
loadSequence += 1;
|
||||
directoryRequestController.abort();
|
||||
memberDirectoryRequestController.abort();
|
||||
});
|
||||
</script>
|
||||
<style scoped lang="scss">
|
||||
@@ -1,4 +1,3 @@
|
||||
<!-- 页面编号:T-03;用途:按 personId 展示成员档案并进入其真实成员状态。 -->
|
||||
<template>
|
||||
<view
|
||||
class="member-page"
|
||||
@@ -15,7 +14,7 @@
|
||||
<view class="member-page__header">
|
||||
<PageHeader
|
||||
title="成员档案"
|
||||
:action="canPreviewEdit ? '编辑预览' : ''"
|
||||
:action="canPreviewEdit ? '编辑成员资料' : ''"
|
||||
custom-back
|
||||
@back="requestBack"
|
||||
@action="toEdit"
|
||||
@@ -57,9 +56,13 @@
|
||||
</view>
|
||||
|
||||
<text class="member-section-title">基本资料</text>
|
||||
<view v-for="item in details" :key="item.label" class="member-info-row">
|
||||
<text>{{ item.label }}</text
|
||||
><text>{{ item.value || "未填写" }}</text>
|
||||
<view
|
||||
v-for="profileField in profileDetails"
|
||||
:key="profileField.label"
|
||||
class="member-info-row"
|
||||
>
|
||||
<text>{{ profileField.label }}</text
|
||||
><text>{{ profileField.value || "未填写" }}</text>
|
||||
</view>
|
||||
|
||||
<text class="member-section-title member-section-title--relation"
|
||||
@@ -84,18 +87,31 @@
|
||||
>
|
||||
<view
|
||||
role="button"
|
||||
aria-label="查看人生事服务状态"
|
||||
aria-label="查看人生大事"
|
||||
@click="toLifeEvents"
|
||||
><text>人生事(待开放)</text></view
|
||||
><text>人生大事</text></view
|
||||
>
|
||||
<view role="button" aria-label="查看重要证件" @click="openDocuments"
|
||||
><text>重要证件</text></view
|
||||
>
|
||||
</view>
|
||||
|
||||
<view
|
||||
v-if="canPreviewEdit"
|
||||
class="member-profile-action"
|
||||
role="button"
|
||||
aria-label="编辑成员资料"
|
||||
@click="toEdit"
|
||||
><text>制作成员编辑预览</text></view
|
||||
><text>编辑成员资料</text></view
|
||||
>
|
||||
<view
|
||||
v-if="member.canDisable"
|
||||
class="member-profile-action member-profile-action--danger"
|
||||
role="button"
|
||||
aria-label="停用这位成员"
|
||||
@click="requestDisableMember"
|
||||
><text>停用这位成员</text></view>
|
||||
<text v-else-if="member.disabledReason" class="member-disable-note">{{ member.disabledReason }}</text>
|
||||
</view>
|
||||
|
||||
<view
|
||||
@@ -114,7 +130,7 @@
|
||||
<text>{{ restrictedCopy.title }}</text>
|
||||
<text>{{ restrictedCopy.copy }}</text>
|
||||
<view class="member-restricted-action" @click="toTree"
|
||||
><text>返回上一位成员</text></view
|
||||
><text>返回上一个成员</text></view
|
||||
>
|
||||
</view>
|
||||
|
||||
@@ -126,6 +142,27 @@
|
||||
>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<PersonDocumentDialog
|
||||
ref="personDocumentDialog"
|
||||
:genealogy-id="genealogyId"
|
||||
:person-id="personId"
|
||||
:member="member"
|
||||
@transient-change="documentTransientOpen = $event"
|
||||
@busy-change="documentBusy = $event"
|
||||
/>
|
||||
<AppDialog
|
||||
:visible="disableDialogVisible"
|
||||
eyebrow="成员状态"
|
||||
title="确认停用这位成员?"
|
||||
message="停用后不会从家谱中消失,但将不能继续作为正常人物参与新增关系。"
|
||||
:confirm-text="disablingMember ? '正在停用' : '确认停用'"
|
||||
cancel-text="取消"
|
||||
show-cancel
|
||||
:close-on-mask="false"
|
||||
@confirm="confirmDisableMember"
|
||||
@cancel="disableDialogVisible = false"
|
||||
/>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
@@ -133,82 +170,79 @@
|
||||
import { computed, reactive, ref } from "vue";
|
||||
import { onBackPress, onLoad, onShow, onUnload } from "@dcloudio/uni-app";
|
||||
import AppAvatar from "@/components/AppAvatar.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";
|
||||
import PersonDocumentDialog from "@/components/tree/PersonDocumentDialog.vue";
|
||||
import {
|
||||
appApi,
|
||||
createRequestController,
|
||||
isRequestCancelled,
|
||||
} from "@/utils/api.js";
|
||||
isRequestCancelled
|
||||
} from "@/services/api/request-controller.js";
|
||||
import { lineageApi } from "@/services/api/lineage-service.js";
|
||||
import { getRequestErrorMessage } from "@/services/api/request-error-message.js";
|
||||
import {
|
||||
consumeNavigationResult,
|
||||
handleBackPress,
|
||||
openPage,
|
||||
runBackGuard,
|
||||
} from "@/utils/navigation.js";
|
||||
} from "@/utils/navigation/gateway.js";
|
||||
|
||||
const genealogyId = ref("");
|
||||
const personId = ref("");
|
||||
const memberState = ref("loading");
|
||||
const member = ref(null);
|
||||
const errorMessage = ref("");
|
||||
const personDocumentDialog = ref(null);
|
||||
const documentTransientOpen = ref(false);
|
||||
const documentBusy = ref(false);
|
||||
const disableDialogVisible = ref(false);
|
||||
const disablingMember = ref(false);
|
||||
const genealogyName = ref("汤氏家谱");
|
||||
const memberTrail = reactive([]);
|
||||
const trailIndex = ref(-1);
|
||||
const memberRequestController = createRequestController();
|
||||
let loadSequence = 0;
|
||||
const memberReadRequestController = createRequestController();
|
||||
const memberDeletionRequestController = createRequestController();
|
||||
let memberLoadGeneration = 0;
|
||||
let pageActive = true;
|
||||
|
||||
const dictionaryLabel = (value, labels) => labels[String(value || "")] || "";
|
||||
const isDeceased = computed(() => member.value?.personStatus === "1");
|
||||
const details = computed(() => {
|
||||
const profileDetails = computed(() => {
|
||||
if (!member.value) return [];
|
||||
const protectedValue = (value) =>
|
||||
member.value.status === "forbidden" ? "按权限隐藏" : value;
|
||||
const visibleProfileValue = (profileValue) =>
|
||||
member.value.status === "forbidden" ? "按权限隐藏" : profileValue;
|
||||
return [
|
||||
{ label: "别名", value: member.value.aliasName },
|
||||
{ label: "字辈", value: member.value.generationName },
|
||||
{
|
||||
label: "性别",
|
||||
value: dictionaryLabel(member.value.sex, {
|
||||
0: "男",
|
||||
1: "女",
|
||||
2: "未知",
|
||||
}),
|
||||
value: member.value.sexLabel,
|
||||
},
|
||||
{
|
||||
label: "人物状态",
|
||||
value: dictionaryLabel(member.value.personStatus, {
|
||||
0: "健在",
|
||||
1: "已故",
|
||||
2: "未知",
|
||||
}),
|
||||
value: member.value.personStatusLabel,
|
||||
},
|
||||
{ label: "出生日期", value: protectedValue(member.value.birthDate) },
|
||||
{ label: "出生日期", value: visibleProfileValue(member.value.birthDate) },
|
||||
{
|
||||
label: "出生农历",
|
||||
value: protectedValue(
|
||||
dictionaryLabel(member.value.birthLunar, { 0: "否", 1: "是" }),
|
||||
),
|
||||
value: visibleProfileValue(member.value.birthLunarLabel),
|
||||
},
|
||||
{ label: "生卒信息", value: member.value.years },
|
||||
{ label: "出生地", value: protectedValue(member.value.birthplace) },
|
||||
{ label: "出生地", value: visibleProfileValue(member.value.birthplace) },
|
||||
...(isDeceased.value
|
||||
? [
|
||||
{ label: "逝世日期", value: protectedValue(member.value.deathDate) },
|
||||
{ label: "逝世日期", value: visibleProfileValue(member.value.deathDate) },
|
||||
{
|
||||
label: "逝世农历",
|
||||
value: protectedValue(
|
||||
dictionaryLabel(member.value.deathLunar, { 0: "否", 1: "是" }),
|
||||
),
|
||||
value: visibleProfileValue(member.value.deathLunarLabel),
|
||||
},
|
||||
{ label: "逝世地", value: protectedValue(member.value.deathPlace) },
|
||||
{ label: "安葬地", value: protectedValue(member.value.burialPlace) },
|
||||
{ label: "逝世地", value: visibleProfileValue(member.value.deathPlace) },
|
||||
{ label: "安葬地", value: visibleProfileValue(member.value.burialPlace) },
|
||||
]
|
||||
: []),
|
||||
{ label: "配偶", value: member.value.spouseNames },
|
||||
{ label: "所属支系", value: member.value.branch },
|
||||
{ label: "生平", value: protectedValue(member.value.biography) },
|
||||
{ label: "生平", value: visibleProfileValue(member.value.biography) },
|
||||
{ label: "备注", value: member.value.remark },
|
||||
];
|
||||
});
|
||||
@@ -235,7 +269,7 @@ const statusDescription = computed(
|
||||
({
|
||||
privacy: "部分资料仅向授权成员展示",
|
||||
deceased: "查看生平保留与纪念资料说明",
|
||||
forbidden: "当前账号只能查看有限身份信息",
|
||||
forbidden: "你只能查看部分身份信息",
|
||||
})[member.value?.status] || "查看成员状态说明",
|
||||
);
|
||||
const memberContextDescription = computed(
|
||||
@@ -247,11 +281,10 @@ const memberContextDescription = computed(
|
||||
error: "请重新选择成员",
|
||||
})[memberState.value] || "请重新选择成员",
|
||||
);
|
||||
|
||||
const loadMember = async (nextPersonId, { preserveCurrent = false } = {}) => {
|
||||
const normalizedPersonId = String(nextPersonId || "");
|
||||
const activeLoad = ++loadSequence;
|
||||
const previous = {
|
||||
const activeLoadGeneration = ++memberLoadGeneration;
|
||||
const previousMemberView = {
|
||||
personId: personId.value,
|
||||
member: member.value,
|
||||
state: memberState.value,
|
||||
@@ -259,30 +292,41 @@ const loadMember = async (nextPersonId, { preserveCurrent = false } = {}) => {
|
||||
genealogyName: genealogyName.value,
|
||||
};
|
||||
const restorePrevious = () => {
|
||||
personId.value = previous.personId;
|
||||
member.value = previous.member;
|
||||
memberState.value = previous.state;
|
||||
errorMessage.value = previous.errorMessage;
|
||||
genealogyName.value = previous.genealogyName;
|
||||
personId.value = previousMemberView.personId;
|
||||
member.value = previousMemberView.member;
|
||||
memberState.value = previousMemberView.state;
|
||||
errorMessage.value = previousMemberView.errorMessage;
|
||||
genealogyName.value = previousMemberView.genealogyName;
|
||||
};
|
||||
|
||||
memberState.value = "loading";
|
||||
errorMessage.value = "";
|
||||
try {
|
||||
const nextMember = await appApi.getPerson(
|
||||
const nextMember = await lineageApi.getPerson(
|
||||
genealogyId.value,
|
||||
normalizedPersonId,
|
||||
{ requestController: memberRequestController },
|
||||
{ requestController: memberReadRequestController },
|
||||
);
|
||||
if (activeLoad !== loadSequence) return false;
|
||||
if (activeLoadGeneration !== memberLoadGeneration) return false;
|
||||
if (personId.value && personId.value !== String(nextMember.id)) {
|
||||
personDocumentDialog.value?.reset();
|
||||
}
|
||||
personId.value = nextMember.id;
|
||||
member.value = nextMember;
|
||||
genealogyName.value = nextMember.genealogyName;
|
||||
memberState.value = "detail";
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (activeLoad !== loadSequence || isRequestCancelled(error)) {
|
||||
if (preserveCurrent && activeLoad === loadSequence) restorePrevious();
|
||||
if (
|
||||
activeLoadGeneration !== memberLoadGeneration ||
|
||||
isRequestCancelled(error)
|
||||
) {
|
||||
if (
|
||||
preserveCurrent &&
|
||||
activeLoadGeneration === memberLoadGeneration
|
||||
) {
|
||||
restorePrevious();
|
||||
}
|
||||
return false;
|
||||
}
|
||||
if (preserveCurrent) {
|
||||
@@ -291,7 +335,7 @@ const loadMember = async (nextPersonId, { preserveCurrent = false } = {}) => {
|
||||
}
|
||||
member.value = null;
|
||||
memberState.value = "error";
|
||||
errorMessage.value = error?.message || "这位成员不存在或已不属于当前家谱。";
|
||||
errorMessage.value = getRequestErrorMessage(error, "这位成员不存在或已不属于当前家谱。");
|
||||
return false;
|
||||
}
|
||||
};
|
||||
@@ -337,53 +381,75 @@ const popMemberTrail = async () => {
|
||||
}
|
||||
|
||||
// 历史成员可能已删除或失去权限。移除失效项后,当前成员在数组中的
|
||||
// 索引同步左移,再继续寻找更早的有效成员,绝不创建新的 T03 页面。
|
||||
// 索引同步左移,再继续寻找更早的有效成员,绝不创建新的成员档案页面实例。
|
||||
memberTrail.splice(targetIndex, 1);
|
||||
trailIndex.value -= 1;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const hasTransientDialog = computed(() =>
|
||||
documentTransientOpen.value || disableDialogVisible.value,
|
||||
);
|
||||
const isMemberPageBusy = computed(() =>
|
||||
documentBusy.value || disablingMember.value,
|
||||
);
|
||||
const closeActiveTransient = () => {
|
||||
if (personDocumentDialog.value?.closeTransient()) return true;
|
||||
if (disableDialogVisible.value) {
|
||||
disableDialogVisible.value = false;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
const requestBack = () =>
|
||||
runBackGuard({
|
||||
internalTrail: trailIndex.value > 0,
|
||||
transientOpen: hasTransientDialog.value && !isMemberPageBusy.value,
|
||||
internalTrail: trailIndex.value > 0 && !isMemberPageBusy.value,
|
||||
submitting: isMemberPageBusy.value,
|
||||
"close-transient": closeActiveTransient,
|
||||
"pop-internal-trail": popMemberTrail,
|
||||
"block-submitting": () => true,
|
||||
});
|
||||
|
||||
onLoad((query) => {
|
||||
genealogyId.value = String(query.genealogyId || "");
|
||||
const initialPersonId = String(query.personId || "");
|
||||
if (query.state === "loading") return;
|
||||
if (query.state === "error" || !genealogyId.value || !initialPersonId) {
|
||||
if (!genealogyId.value || !initialPersonId) {
|
||||
memberState.value = "error";
|
||||
errorMessage.value = !initialPersonId
|
||||
? "没有指定成员,请从世系树重新选择。"
|
||||
: "当前家谱上下文无效,请重新进入。";
|
||||
: "这个页面已经过期,请返回后重新进入。";
|
||||
return;
|
||||
}
|
||||
void initializeMemberTrail(initialPersonId);
|
||||
});
|
||||
|
||||
onShow(() => {
|
||||
const result = consumeNavigationResult("T03");
|
||||
if (result?.operation === "member-open-requested" && result.entityId) {
|
||||
void openRelative(result.entityId);
|
||||
const navigationResult = consumeNavigationResult("T03");
|
||||
if (
|
||||
navigationResult?.operation === "member-open-requested" &&
|
||||
navigationResult.entityId
|
||||
) {
|
||||
void openRelative(navigationResult.entityId);
|
||||
return;
|
||||
}
|
||||
if (
|
||||
result?.operation === "member-updated" &&
|
||||
result.refresh &&
|
||||
result.entityId === String(personId.value)
|
||||
navigationResult?.operation === "member-updated" &&
|
||||
navigationResult.refresh &&
|
||||
navigationResult.entityId === String(personId.value)
|
||||
) {
|
||||
void loadMember(result.entityId, { preserveCurrent: true });
|
||||
void loadMember(navigationResult.entityId, { preserveCurrent: true });
|
||||
}
|
||||
});
|
||||
|
||||
onBackPress((event) => handleBackPress(event, requestBack));
|
||||
|
||||
onUnload(() => {
|
||||
loadSequence += 1;
|
||||
memberRequestController.abort();
|
||||
pageActive = false;
|
||||
memberLoadGeneration += 1;
|
||||
memberReadRequestController.abort();
|
||||
memberDeletionRequestController.abort();
|
||||
});
|
||||
|
||||
const toEdit = () => {
|
||||
@@ -412,6 +478,35 @@ const toLifeEvents = () =>
|
||||
{ genealogyId: genealogyId.value, personId: personId.value },
|
||||
"T03",
|
||||
);
|
||||
const openDocuments = () => personDocumentDialog.value?.open();
|
||||
const requestDisableMember = () => {
|
||||
if (!member.value?.canDisable || disablingMember.value) return;
|
||||
disableDialogVisible.value = true;
|
||||
};
|
||||
const confirmDisableMember = async () => {
|
||||
if (!member.value?.canDisable || disablingMember.value) return;
|
||||
disablingMember.value = true;
|
||||
try {
|
||||
await lineageApi.deletePerson(genealogyId.value, personId.value, {
|
||||
requestController: memberDeletionRequestController,
|
||||
});
|
||||
if (!pageActive) return;
|
||||
disableDialogVisible.value = false;
|
||||
member.value = {
|
||||
...member.value,
|
||||
canDisable: false,
|
||||
disabledReason: "成员已停用,最新档案状态暂时未刷新。",
|
||||
};
|
||||
await loadMember(personId.value, { preserveCurrent: true });
|
||||
} catch (error) {
|
||||
if (pageActive && !isRequestCancelled(error)) {
|
||||
errorMessage.value = getRequestErrorMessage(error, "这位成员暂时无法停用,请稍后重试。");
|
||||
disableDialogVisible.value = false;
|
||||
}
|
||||
} finally {
|
||||
if (pageActive) disablingMember.value = false;
|
||||
}
|
||||
};
|
||||
const toTree = requestBack;
|
||||
</script>
|
||||
|
||||
@@ -591,6 +686,11 @@ const toTree = requestBack;
|
||||
font-size: clamp(15px, 24rpx, 18px);
|
||||
font-weight: 700;
|
||||
}
|
||||
.member-profile-action--danger { margin-top: 16rpx; color: #8f1b14; }
|
||||
.member-disable-note { display: block; margin-top: 14rpx; color: $ink-muted; font-size: clamp(13px, 21rpx, 16px); line-height: 1.5; text-align: center; }
|
||||
.member-record-actions > view:last-child:nth-child(odd) {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
.member-restricted {
|
||||
padding-top: 14rpx;
|
||||
}
|
||||
@@ -0,0 +1,310 @@
|
||||
<template>
|
||||
<view
|
||||
class="rank-page"
|
||||
:class="{
|
||||
'rank-state--loading': rankState === 'loading',
|
||||
'rank-state--form': rankState === 'form',
|
||||
'rank-state--error': rankState === 'error',
|
||||
}"
|
||||
>
|
||||
<ModulePageBackground module="tree" />
|
||||
<view class="rank-page__header"
|
||||
><PageHeader title="调整排行" custom-back @back="requestBack"
|
||||
/></view>
|
||||
<view class="rank-panel">
|
||||
<AppLoading
|
||||
v-if="rankState === 'loading'"
|
||||
text="正在读取成员资料"
|
||||
description="请稍候,正在确认待调整人物。"
|
||||
/>
|
||||
|
||||
<view v-else-if="rankState === 'form' && member" class="rank-form">
|
||||
<text class="form-eyebrow">同辈排行</text>
|
||||
<text class="form-title">调整{{ member.name }}的排行</text>
|
||||
<text class="form-copy"
|
||||
>这里只调整这位成员的排行;同辈成员的整体排序暂不能在这里修改。</text
|
||||
>
|
||||
|
||||
<view class="member-context">
|
||||
<text>当前成员</text
|
||||
><text>第 {{ member.generation }} 世 · {{ member.branch }}</text>
|
||||
</view>
|
||||
<view class="rank-field">
|
||||
<text>排序值</text>
|
||||
<input
|
||||
v-model="sortOrder"
|
||||
type="number"
|
||||
:disabled="savingRank"
|
||||
placeholder="请输入整数,值越小越靠前"
|
||||
placeholder-class="rank-placeholder"
|
||||
@input="rankError = ''"
|
||||
/>
|
||||
</view>
|
||||
<text v-if="rankError" class="rank-error">{{ rankError }}</text>
|
||||
<view
|
||||
class="form-action"
|
||||
:class="{ 'form-action--disabled': savingRank }"
|
||||
@click="saveRank"
|
||||
>
|
||||
<image
|
||||
src="/static/assets/foundation/transparent/scroll-primary.png"
|
||||
mode="scaleToFill"
|
||||
/>
|
||||
<text>{{ savingRank ? "正在保存…" : "保存排行" }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view v-else class="rank-result">
|
||||
<text class="form-eyebrow">暂时无法打开成员页面</text>
|
||||
<text class="form-title">暂时无法读取成员资料</text>
|
||||
<text class="form-copy">{{ errorMessage }}</text>
|
||||
<view class="form-action" @click="goBack">
|
||||
<image
|
||||
src="/static/assets/foundation/transparent/scroll-primary.png"
|
||||
mode="scaleToFill"
|
||||
/>
|
||||
<text>返回世系树</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from "vue";
|
||||
import { onBackPress, onLoad, onUnload } from "@dcloudio/uni-app";
|
||||
import AppLoading from "@/components/AppLoading.vue";
|
||||
import ModulePageBackground from "@/components/ModulePageBackground.vue";
|
||||
import PageHeader from "@/components/PageHeader.vue";
|
||||
import {
|
||||
createRequestController,
|
||||
isRequestCancelled
|
||||
} from "@/services/api/request-controller.js";
|
||||
import { lineageApi } from "@/services/api/lineage-service.js";
|
||||
import { getRequestErrorMessage } from "@/services/api/request-error-message.js";
|
||||
import { goBack, handleBackPress, returnTo } from "@/utils/navigation/gateway.js";
|
||||
|
||||
const rankState = ref("loading");
|
||||
const genealogyId = ref("");
|
||||
const personId = ref("");
|
||||
const member = ref(null);
|
||||
const errorMessage = ref("");
|
||||
const sortOrder = ref("");
|
||||
const savingRank = ref(false);
|
||||
const rankError = ref("");
|
||||
const committedSortOrder = ref("");
|
||||
const memberRankReadRequestController = createRequestController();
|
||||
const memberRankSaveRequestController = createRequestController();
|
||||
let pageActive = true;
|
||||
let loadSequence = 0;
|
||||
|
||||
const loadMember = async () => {
|
||||
const activeLoad = ++loadSequence;
|
||||
rankState.value = "loading";
|
||||
try {
|
||||
const personDetail = await lineageApi.getPerson(genealogyId.value, personId.value, {
|
||||
requestController: memberRankReadRequestController,
|
||||
});
|
||||
if (!pageActive || activeLoad !== loadSequence) return;
|
||||
member.value = personDetail;
|
||||
sortOrder.value = personDetail.sortOrder === null || personDetail.sortOrder === undefined
|
||||
? ""
|
||||
: String(personDetail.sortOrder);
|
||||
committedSortOrder.value = "";
|
||||
errorMessage.value = "";
|
||||
rankState.value = "form";
|
||||
} catch (error) {
|
||||
if (!pageActive || activeLoad !== loadSequence || isRequestCancelled(error)) return;
|
||||
member.value = null;
|
||||
errorMessage.value =
|
||||
getRequestErrorMessage(error, "当前成员资料暂不可用,请返回世系树后重试。");
|
||||
rankState.value = "error";
|
||||
}
|
||||
};
|
||||
|
||||
const saveRank = async () => {
|
||||
if (savingRank.value || !member.value) return;
|
||||
const normalizedSortOrder = String(sortOrder.value).trim();
|
||||
if (!/^-?\d+$/.test(normalizedSortOrder)) {
|
||||
rankError.value = "请填写整数,例如 1、2、3。";
|
||||
return;
|
||||
}
|
||||
const numericSortOrder = Number(normalizedSortOrder);
|
||||
if (!Number.isSafeInteger(numericSortOrder)) {
|
||||
rankError.value = "排序值超出可保存范围";
|
||||
return;
|
||||
}
|
||||
savingRank.value = true;
|
||||
rankError.value = "";
|
||||
try {
|
||||
if (committedSortOrder.value !== normalizedSortOrder) {
|
||||
await lineageApi.updatePersonSortOrder(
|
||||
genealogyId.value,
|
||||
personId.value,
|
||||
numericSortOrder,
|
||||
{ requestController: memberRankSaveRequestController },
|
||||
);
|
||||
if (!pageActive) return;
|
||||
committedSortOrder.value = normalizedSortOrder;
|
||||
}
|
||||
await returnTo("T01", {
|
||||
genealogyId: genealogyId.value,
|
||||
selectedId: personId.value,
|
||||
});
|
||||
} catch (error) {
|
||||
if (pageActive && !isRequestCancelled(error)) {
|
||||
rankError.value = committedSortOrder.value === normalizedSortOrder
|
||||
? "排行已经保存,但页面返回失败。请再次点击保存重试返回。"
|
||||
: getRequestErrorMessage(error, "排行尚未保存,请稍后重试。");
|
||||
}
|
||||
} finally {
|
||||
if (pageActive) savingRank.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const requestBack = () => {
|
||||
if (savingRank.value) return;
|
||||
return goBack();
|
||||
};
|
||||
|
||||
onLoad((query) => {
|
||||
genealogyId.value = String(query.genealogyId || "");
|
||||
personId.value = String(query.personId || "");
|
||||
if (!genealogyId.value || !personId.value || query.mode !== "rank") {
|
||||
rankState.value = "error";
|
||||
errorMessage.value = "请从成员资料页重新进入排行调整。";
|
||||
return;
|
||||
}
|
||||
void loadMember();
|
||||
});
|
||||
|
||||
onUnload(() => {
|
||||
pageActive = false;
|
||||
loadSequence += 1;
|
||||
memberRankReadRequestController.abort();
|
||||
memberRankSaveRequestController.abort();
|
||||
});
|
||||
|
||||
onBackPress((event) => handleBackPress(event, requestBack));
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@import "../../styles/adaptive-frame-profiles.scss";
|
||||
|
||||
.rank-page {
|
||||
display: flex;
|
||||
min-height: 100vh;
|
||||
flex-direction: column;
|
||||
background: $paper;
|
||||
}
|
||||
.rank-page__header {
|
||||
z-index: 3;
|
||||
}
|
||||
.rank-panel {
|
||||
@include adaptive-tree-panel;
|
||||
z-index: 2;
|
||||
width: calc(100% - 32rpx);
|
||||
margin: 18rpx auto 28rpx;
|
||||
padding: 7.5% 8%;
|
||||
}
|
||||
.form-eyebrow {
|
||||
display: block;
|
||||
color: $brand-red;
|
||||
font-size: clamp(14px, 22rpx, 17px);
|
||||
letter-spacing: 3rpx;
|
||||
}
|
||||
.form-title {
|
||||
display: block;
|
||||
margin-top: 10rpx;
|
||||
color: $ink;
|
||||
font-family: "STKaiti", "KaiTi", serif;
|
||||
font-size: clamp(19px, 34rpx, 24px);
|
||||
font-weight: 700;
|
||||
line-height: 1.35;
|
||||
}
|
||||
.form-copy {
|
||||
display: block;
|
||||
margin-top: 10rpx;
|
||||
color: $ink-muted;
|
||||
font-size: clamp(15px, 24rpx, 18px);
|
||||
line-height: 1.5;
|
||||
}
|
||||
.member-context,
|
||||
.rank-field {
|
||||
@include adaptive-tree-field;
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr);
|
||||
min-height: 78rpx;
|
||||
align-items: center;
|
||||
gap: 20rpx;
|
||||
margin-top: 16rpx;
|
||||
padding: 12rpx 22rpx;
|
||||
}
|
||||
.member-context text:first-child,
|
||||
.rank-field text {
|
||||
color: $ink;
|
||||
font-size: clamp(15px, 24rpx, 18px);
|
||||
font-weight: 700;
|
||||
}
|
||||
.member-context text:last-child {
|
||||
min-width: 0;
|
||||
color: $ink-muted;
|
||||
font-size: clamp(14px, 23rpx, 17px);
|
||||
line-height: 1.45;
|
||||
text-align: right;
|
||||
}
|
||||
.rank-field input {
|
||||
min-width: 0;
|
||||
min-height: 56rpx;
|
||||
padding: 0 14rpx;
|
||||
border: 1rpx solid rgba(143, 108, 63, 0.34);
|
||||
border-radius: 8rpx;
|
||||
background: rgba(255, 253, 247, 0.8);
|
||||
color: $ink;
|
||||
font-size: clamp(14px, 23rpx, 17px);
|
||||
text-align: right;
|
||||
}
|
||||
.rank-placeholder { color: $ink-muted; }
|
||||
.rank-error { display: block; margin-top: 12rpx; color: $brand-red; font-size: clamp(14px, 22rpx, 17px); }
|
||||
.form-action {
|
||||
display: grid;
|
||||
width: 100%;
|
||||
min-height: 76rpx;
|
||||
margin-top: 22rpx;
|
||||
}
|
||||
.form-action--disabled { opacity: 0.58; pointer-events: none; }
|
||||
.form-action image,
|
||||
.form-action text {
|
||||
grid-area: 1 / 1;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
.form-action text {
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #fff9ed;
|
||||
font-size: clamp(15px, 24rpx, 18px);
|
||||
font-weight: 700;
|
||||
}
|
||||
.rank-result {
|
||||
margin-top: 30%;
|
||||
text-align: center;
|
||||
}
|
||||
.rank-result .form-eyebrow,
|
||||
.rank-result .form-copy {
|
||||
text-align: center;
|
||||
}
|
||||
.rank-result .form-action {
|
||||
width: 420rpx;
|
||||
max-width: 100%;
|
||||
margin-right: auto;
|
||||
margin-left: auto;
|
||||
}
|
||||
@media (min-width: 400px) {
|
||||
.rank-panel {
|
||||
width: calc(100% - 48rpx);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,4 +1,3 @@
|
||||
<!-- 页面编号:T-08;用途:展示指定成员的隐私、纪念或无权限状态。 -->
|
||||
<template>
|
||||
<view
|
||||
class="member-status-page"
|
||||
@@ -22,7 +21,7 @@
|
||||
<AppLoading
|
||||
v-if="statusState === 'loading'"
|
||||
text="正在读取成员状态"
|
||||
description="请稍候,正在读取人物详情中的状态字段。"
|
||||
description="请稍候,正在读取成员状态。"
|
||||
/>
|
||||
<view v-else class="status-card__copy">
|
||||
<text>{{ activeStatus.eyebrow }}</text>
|
||||
@@ -44,7 +43,7 @@
|
||||
@click="handleAction"
|
||||
>
|
||||
<image
|
||||
src="/static/assets/foundation/transparent/a01-scroll-primary-v3.png"
|
||||
src="/static/assets/foundation/transparent/scroll-primary.png"
|
||||
mode="scaleToFill"
|
||||
/>
|
||||
<text>{{ activeStatus.action }}</text>
|
||||
@@ -59,18 +58,18 @@ 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 { goBack } from "@/utils/navigation.js";
|
||||
isRequestCancelled
|
||||
} from "@/services/api/request-controller.js";
|
||||
import { lineageApi } from "@/services/api/lineage-service.js";
|
||||
import { goBack } from "@/utils/navigation/gateway.js";
|
||||
|
||||
const genealogyId = ref("");
|
||||
const personId = ref("");
|
||||
const statusState = ref("loading");
|
||||
const genealogyName = ref("汤氏家谱");
|
||||
const member = ref(null);
|
||||
const statusRequestController = createRequestController();
|
||||
const memberStatusRequestController = createRequestController();
|
||||
let loadSequence = 0;
|
||||
|
||||
const states = {
|
||||
@@ -84,13 +83,11 @@ const states = {
|
||||
},
|
||||
available: {
|
||||
eyebrow: "人物状态",
|
||||
title: "暂无法展示状态说明",
|
||||
copy: "服务端返回了状态字段,但未提供可读名称或说明;本页不会向家人展示内部状态值。",
|
||||
guideTitle: "当前合同范围",
|
||||
title: "暂时没有可查看的成员状态",
|
||||
copy: "为保护隐私,这里只显示可以公开的信息。",
|
||||
guideTitle: "当前说明",
|
||||
guides: [
|
||||
"状态由服务端维护",
|
||||
"状态展示需要后端提供可读字典",
|
||||
"当前页不执行停用或任何状态写入",
|
||||
"暂时没有更多状态信息",
|
||||
],
|
||||
action: "返回成员档案",
|
||||
},
|
||||
@@ -121,12 +118,12 @@ const loadMember = async () => {
|
||||
const activeLoad = ++loadSequence;
|
||||
statusState.value = "loading";
|
||||
try {
|
||||
const result = await appApi.getPerson(genealogyId.value, personId.value, {
|
||||
requestController: statusRequestController,
|
||||
const personDetail = await lineageApi.getPerson(genealogyId.value, personId.value, {
|
||||
requestController: memberStatusRequestController,
|
||||
});
|
||||
if (activeLoad !== loadSequence) return;
|
||||
member.value = result;
|
||||
genealogyName.value = result.genealogyName;
|
||||
member.value = personDetail;
|
||||
genealogyName.value = personDetail.genealogyName;
|
||||
statusState.value = "available";
|
||||
} catch (error) {
|
||||
if (activeLoad !== loadSequence || isRequestCancelled(error)) return;
|
||||
@@ -138,7 +135,7 @@ const loadMember = async () => {
|
||||
onLoad((query) => {
|
||||
genealogyId.value = String(query.genealogyId || "");
|
||||
personId.value = String(query.personId || "");
|
||||
if (!hasValidContext.value || query.state === "error") {
|
||||
if (!hasValidContext.value) {
|
||||
statusState.value = "error";
|
||||
return;
|
||||
}
|
||||
@@ -147,7 +144,7 @@ onLoad((query) => {
|
||||
|
||||
onUnload(() => {
|
||||
loadSequence += 1;
|
||||
statusRequestController.abort();
|
||||
memberStatusRequestController.abort();
|
||||
});
|
||||
|
||||
const handleAction = () => goBack();
|
||||
@@ -0,0 +1,866 @@
|
||||
<template>
|
||||
<view
|
||||
class="tree-page"
|
||||
:class="{
|
||||
'tree-state--tree': treeState === 'tree',
|
||||
'tree-state--landscape': treeState === 'landscape',
|
||||
'tree-state--empty': treeState === 'empty',
|
||||
'tree-state--error': treeState === 'error',
|
||||
}"
|
||||
>
|
||||
<ModulePageBackground module="tree" />
|
||||
<view class="tree-page__header">
|
||||
<PageHeader title="树状图" action="世系表格" @action="toPedigree" />
|
||||
</view>
|
||||
|
||||
<view v-if="treeState !== 'loading'" class="tree-toolbar">
|
||||
<view class="tree-toolbar__title">
|
||||
<text>世系成员</text>
|
||||
<text>{{ generationRangeLabel }}</text>
|
||||
</view>
|
||||
<view class="tree-toolbar__actions">
|
||||
<text
|
||||
v-if="treeState === 'tree' && treeHasDrifted && selectedMember"
|
||||
class="tree-recenter"
|
||||
role="button"
|
||||
aria-label="回到当前成员"
|
||||
hover-class="tree-action--pressed"
|
||||
@click="recenterSelectedMember"
|
||||
>回到当前</text
|
||||
>
|
||||
<text @click="treeState = 'landscape'">阅读提示</text>
|
||||
<text @click="toRank">调整排行</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view
|
||||
class="tree-stage"
|
||||
:class="{ 'tree-stage--lineage': treeState === 'tree' }"
|
||||
>
|
||||
<view
|
||||
v-if="treeState === 'tree'"
|
||||
class="generation-rail"
|
||||
:style="generationRailStyle"
|
||||
>
|
||||
<view
|
||||
v-for="row in generationRows"
|
||||
:key="row.generation"
|
||||
class="generation-band"
|
||||
:style="generationBandStyle(row)"
|
||||
>
|
||||
<view class="generation-band__line" aria-hidden="true" />
|
||||
<view class="generation-band__copy">
|
||||
<text>{{ row.label }}</text>
|
||||
<text>{{ row.summary }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view v-if="treeState === 'tree'" class="lineage-pan-cue">
|
||||
<text>同代分支可左右查看</text>
|
||||
</view>
|
||||
|
||||
<scroll-view
|
||||
class="tree-scroll"
|
||||
:class="{ 'tree-scroll--lineage': treeState === 'tree' }"
|
||||
scroll-x
|
||||
:scroll-left="treeState === 'tree' ? treeScrollLeft : 0"
|
||||
:show-scrollbar="false"
|
||||
:style="treeScrollStyle"
|
||||
@scroll="handleTreeScroll"
|
||||
>
|
||||
<view
|
||||
class="tree-canvas"
|
||||
:class="{ 'tree-canvas--state': treeState !== 'tree' }"
|
||||
:style="treeState === 'tree' ? treeMetricsStyle : undefined"
|
||||
>
|
||||
<AppLoading
|
||||
v-if="treeState === 'loading'"
|
||||
text="正在整理世系"
|
||||
description="请稍候,正在准备家谱成员关系。"
|
||||
/>
|
||||
|
||||
<template v-else-if="treeState === 'tree'">
|
||||
<view
|
||||
v-for="connector in lineageConnectors"
|
||||
:key="connector.id"
|
||||
class="lineage-connector"
|
||||
:class="{
|
||||
'lineage-connector--spouse': connector.kind === 'spouse',
|
||||
}"
|
||||
:style="connector.style"
|
||||
/>
|
||||
|
||||
<view
|
||||
v-for="member in layoutMembers"
|
||||
:key="member.id"
|
||||
:id="`tree-member-${member.id}`"
|
||||
class="member-node"
|
||||
:class="{
|
||||
'member-node--selected': selectedMember?.id === member.id,
|
||||
'member-node--spouse': Boolean(member.spouseOf),
|
||||
}"
|
||||
:style="nodeGridStyle(member)"
|
||||
@click="openMemberPanel(member)"
|
||||
>
|
||||
<view class="member-node__surface">
|
||||
<view class="member-node__portrait">
|
||||
<AppAvatar :sex="member.sex" />
|
||||
</view>
|
||||
<image
|
||||
class="member-node__divider"
|
||||
src="/static/assets/foundation/transparent/auth-divider-knot.png"
|
||||
mode="aspectFit"
|
||||
/>
|
||||
<view class="member-node__copy">
|
||||
<text class="node-name">{{ member.name }}</text>
|
||||
<text class="node-relation">{{
|
||||
nodeRelationText(member)
|
||||
}}</text>
|
||||
<text class="node-years">{{ member.treeYears || member.years }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<view v-else class="tree-state-card">
|
||||
<image
|
||||
class="tree-state-card__skin"
|
||||
src="/static/assets/modules/tree/transparent/state-panel.png"
|
||||
mode="scaleToFill"
|
||||
/>
|
||||
<view class="tree-state-card__content">
|
||||
<text class="tree-state-card__eyebrow">{{
|
||||
stateCopy.eyebrow
|
||||
}}</text>
|
||||
<text class="tree-state-card__title">{{ stateCopy.title }}</text>
|
||||
<text class="tree-state-card__copy">{{ stateCopy.copy }}</text>
|
||||
<view class="tree-state-card__action" @click="handleStateAction">
|
||||
<image
|
||||
src="/static/assets/foundation/transparent/scroll-primary.png"
|
||||
mode="aspectFit"
|
||||
/>
|
||||
<text>{{ stateCopy.action }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
</view>
|
||||
|
||||
<MemberActionPanel
|
||||
:visible="memberActionPanelVisible"
|
||||
:member="selectedMember"
|
||||
:relation-actions="relationActions"
|
||||
:management-actions="managementActions"
|
||||
@close="closeMemberActionPanel"
|
||||
@view-profile="toMember"
|
||||
@select-action="openMemberAction"
|
||||
/>
|
||||
|
||||
<AppDialog
|
||||
:visible="unavailableActionVisible"
|
||||
eyebrow="服务状态"
|
||||
:title="unavailableAction?.label || '当前操作'"
|
||||
:message="
|
||||
unavailableAction?.unavailableCopy || '这项功能还在准备中,暂时无法使用。'
|
||||
"
|
||||
confirm-text="我知道了"
|
||||
@confirm="unavailableActionVisible = false"
|
||||
@cancel="unavailableActionVisible = false"
|
||||
/>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, nextTick, ref } from "vue";
|
||||
import { onLoad, onShow, onUnload } from "@dcloudio/uni-app";
|
||||
import AppAvatar from "@/components/AppAvatar.vue";
|
||||
import AppDialog from "@/components/AppDialog.vue";
|
||||
import AppLoading from "@/components/AppLoading.vue";
|
||||
import MemberActionPanel from "@/components/tree/MemberActionPanel.vue";
|
||||
import ModulePageBackground from "@/components/ModulePageBackground.vue";
|
||||
import PageHeader from "@/components/PageHeader.vue";
|
||||
import {
|
||||
createRequestController,
|
||||
isRequestCancelled
|
||||
} from "@/services/api/request-controller.js";
|
||||
import { lineageApi } from "@/services/api/lineage-service.js";
|
||||
import { genealogyContext } from "@/utils/genealogy/context.js";
|
||||
import { openPage } from "@/utils/navigation/gateway.js";
|
||||
import {
|
||||
createTreeLayout,
|
||||
treeNodeGridStyle,
|
||||
} from "@/utils/tree/layout.js";
|
||||
import { memberRelationTypes } from "@/utils/tree/member-form.js";
|
||||
|
||||
const genealogyId = ref("");
|
||||
const treeState = ref("loading");
|
||||
const selectedMember = ref(null);
|
||||
const memberActionPanelVisible = ref(false);
|
||||
const unavailableActionVisible = ref(false);
|
||||
const unavailableAction = ref(null);
|
||||
const treeScrollLeft = ref(90);
|
||||
const currentTreeScrollLeft = ref(90);
|
||||
const centeredTreeScrollLeft = ref(90);
|
||||
const treeHasDrifted = ref(false);
|
||||
let ignoreTreeScroll = false;
|
||||
let recenterTimer = null;
|
||||
const treeOverviewRequestController = createRequestController();
|
||||
// abort 负责停止当前任务,generation 负责拦截已经进入回调队列的旧响应;
|
||||
// 两者同时保留,避免快速切换家谱时旧世系覆盖新世系。
|
||||
let treeLoadGeneration = 0;
|
||||
let pageActive = true;
|
||||
let skipNextShowRefresh = true;
|
||||
const members = ref([]);
|
||||
|
||||
const memberActions = Object.freeze([
|
||||
{
|
||||
key: "VIEW_PROFILE",
|
||||
label: "查看资料",
|
||||
group: "MANAGEMENT",
|
||||
routeKey: "T03",
|
||||
},
|
||||
{
|
||||
key: "ADD_FATHER",
|
||||
label: "添加父亲",
|
||||
shortLabel: "父亲",
|
||||
group: "RELATION",
|
||||
slot: "top",
|
||||
routeKey: "T04",
|
||||
relationType: memberRelationTypes.FATHER,
|
||||
},
|
||||
{
|
||||
key: "ADD_MOTHER",
|
||||
label: "添加母亲",
|
||||
shortLabel: "母亲",
|
||||
group: "RELATION",
|
||||
slot: "left-top",
|
||||
routeKey: "T04",
|
||||
relationType: memberRelationTypes.MOTHER,
|
||||
},
|
||||
{
|
||||
key: "ADD_SPOUSE",
|
||||
label: "添加配偶",
|
||||
shortLabel: "配偶",
|
||||
group: "RELATION",
|
||||
slot: "right-top",
|
||||
routeKey: "T04",
|
||||
relationType: memberRelationTypes.SPOUSE,
|
||||
},
|
||||
{
|
||||
key: "ADD_SIBLING",
|
||||
label: "添加兄弟姐妹",
|
||||
shortLabel: "兄弟姐妹",
|
||||
group: "RELATION",
|
||||
slot: "left-bottom",
|
||||
routeKey: "T04",
|
||||
relationType: memberRelationTypes.SIBLING,
|
||||
},
|
||||
{
|
||||
key: "ADJUST_RANK",
|
||||
label: "调整排行",
|
||||
group: "MANAGEMENT",
|
||||
routeKey: "T06",
|
||||
mode: "rank",
|
||||
},
|
||||
{
|
||||
key: "ADD_SON",
|
||||
label: "添加儿子",
|
||||
shortLabel: "儿子",
|
||||
group: "RELATION",
|
||||
slot: "bottom",
|
||||
routeKey: "T04",
|
||||
relationType: memberRelationTypes.SON,
|
||||
},
|
||||
{
|
||||
key: "ADD_DAUGHTER",
|
||||
label: "添加女儿",
|
||||
shortLabel: "女儿",
|
||||
group: "RELATION",
|
||||
slot: "right-bottom",
|
||||
routeKey: "T04",
|
||||
relationType: memberRelationTypes.DAUGHTER,
|
||||
},
|
||||
{
|
||||
key: "BIND_INVITE",
|
||||
label: "邀请绑定",
|
||||
group: "MANAGEMENT",
|
||||
unavailableCopy:
|
||||
"邀请绑定暂未开放,请稍后再试。",
|
||||
},
|
||||
{
|
||||
key: "EDIT_PROFILE",
|
||||
label: "编辑信息",
|
||||
group: "MANAGEMENT",
|
||||
routeKey: "T05",
|
||||
},
|
||||
]);
|
||||
const relationActions = computed(() =>
|
||||
memberActions.filter((action) => action.group === "RELATION"),
|
||||
);
|
||||
const managementActions = computed(() =>
|
||||
memberActions.filter((action) => action.group === "MANAGEMENT"),
|
||||
);
|
||||
|
||||
const nodeRelationText = (member) => {
|
||||
const branch = String(member.branch || "").replace(/字辈$/, "");
|
||||
return [member.relation, branch].filter(Boolean).join(" · ");
|
||||
};
|
||||
const treeLayout = computed(() => createTreeLayout(members.value));
|
||||
const layoutMembers = computed(() => treeLayout.value.members);
|
||||
const treeMetrics = computed(() => treeLayout.value.metrics);
|
||||
const treeMetricsStyle = computed(() => ({
|
||||
width: `${treeMetrics.value.width}rpx`,
|
||||
height: `${treeMetrics.value.height}rpx`,
|
||||
gridTemplateColumns: `repeat(${treeMetrics.value.columns}, ${treeMetrics.value.gridUnit}rpx)`,
|
||||
gridTemplateRows: `repeat(${treeMetrics.value.rows}, ${treeMetrics.value.gridUnit}rpx)`,
|
||||
}));
|
||||
const generationRailStyle = computed(() => ({
|
||||
height: `${treeMetrics.value.height}rpx`,
|
||||
gridTemplateRows: `repeat(${treeMetrics.value.rows}, ${treeMetrics.value.gridUnit}rpx)`,
|
||||
}));
|
||||
const treeScrollStyle = computed(() => ({
|
||||
height: `${treeMetrics.value.height}rpx`,
|
||||
}));
|
||||
const generationRows = computed(() => treeLayout.value.generationRows);
|
||||
const generationRangeLabel = computed(() => {
|
||||
const rows = generationRows.value;
|
||||
if (!rows.length) return "世系关系";
|
||||
const first = rows[0].generation;
|
||||
const last = rows[rows.length - 1].generation;
|
||||
return first === last ? `第 ${first} 世` : `第 ${first}—${last} 世`;
|
||||
});
|
||||
const generationBandStyle = (generationRow) => generationRow.bandStyle;
|
||||
const lineageConnectors = computed(() => treeLayout.value.connectors);
|
||||
|
||||
const stateCopy = computed(
|
||||
() =>
|
||||
({
|
||||
landscape: {
|
||||
eyebrow: "世系阅读提示",
|
||||
title: "上下看世代,左右看同代分支",
|
||||
copy: "始祖位于上方,后代逐世向下展开;同一世代分支较多时,可左右拖动查看。",
|
||||
action: "进入世系树",
|
||||
},
|
||||
empty: {
|
||||
eyebrow: "尚未建立世系",
|
||||
title: "从第一位先祖开始记录",
|
||||
copy: "录入首代成员后,即可继续补充子女、配偶与后代关系。",
|
||||
action: "录入首代成员",
|
||||
},
|
||||
error: {
|
||||
eyebrow: "世系暂不可用",
|
||||
title: "暂时无法读取成员关系",
|
||||
copy: "请从当前家谱重新进入;已经录入的成员资料不会受到影响。",
|
||||
action: "重新查看",
|
||||
},
|
||||
})[treeState.value] || {},
|
||||
);
|
||||
|
||||
const loadTree = async (query = {}) => {
|
||||
const generation = ++treeLoadGeneration;
|
||||
treeOverviewRequestController.abort();
|
||||
if (genealogyContext.isCurrentGenealogyInvalidated()) {
|
||||
genealogyId.value = "";
|
||||
members.value = [];
|
||||
treeState.value = "error";
|
||||
return;
|
||||
}
|
||||
genealogyId.value = String(
|
||||
query.genealogyId || genealogyContext.getCurrentGenealogyId() || "",
|
||||
);
|
||||
if (!genealogyId.value) {
|
||||
members.value = [];
|
||||
treeState.value = "empty";
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const loadedMembers = await lineageApi.getTree(genealogyId.value, {
|
||||
requestController: treeOverviewRequestController,
|
||||
});
|
||||
if (!pageActive || generation !== treeLoadGeneration) return;
|
||||
members.value = loadedMembers;
|
||||
genealogyContext.setCurrentGenealogyId(genealogyId.value);
|
||||
selectedMember.value =
|
||||
layoutMembers.value.find(
|
||||
(member) => String(member.id) === String(query.selectedId),
|
||||
) ||
|
||||
layoutMembers.value[0] ||
|
||||
null;
|
||||
treeState.value = members.value.length ? "tree" : "empty";
|
||||
if (treeState.value === "tree" && selectedMember.value) {
|
||||
await nextTick();
|
||||
recenterSelectedMember();
|
||||
}
|
||||
} catch (error) {
|
||||
if (
|
||||
!pageActive ||
|
||||
generation !== treeLoadGeneration ||
|
||||
isRequestCancelled(error)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
members.value = [];
|
||||
selectedMember.value = null;
|
||||
treeState.value = "error";
|
||||
}
|
||||
};
|
||||
|
||||
onLoad(loadTree);
|
||||
onShow(() => {
|
||||
if (skipNextShowRefresh) {
|
||||
skipNextShowRefresh = false;
|
||||
return;
|
||||
}
|
||||
if (genealogyId.value) loadTree({ genealogyId: genealogyId.value });
|
||||
});
|
||||
|
||||
onUnload(() => {
|
||||
pageActive = false;
|
||||
treeLoadGeneration += 1;
|
||||
treeOverviewRequestController.abort();
|
||||
if (recenterTimer) clearTimeout(recenterTimer);
|
||||
});
|
||||
|
||||
const handleTreeScroll = (event) => {
|
||||
if (ignoreTreeScroll || treeState.value !== "tree") return;
|
||||
const scrollLeft = Number(event?.detail?.scrollLeft || 0);
|
||||
currentTreeScrollLeft.value = scrollLeft;
|
||||
treeHasDrifted.value =
|
||||
Math.abs(scrollLeft - centeredTreeScrollLeft.value) > 80;
|
||||
};
|
||||
|
||||
const getFamilyPartner = (member) => {
|
||||
if (!member) return null;
|
||||
const partnerId =
|
||||
member.spouseOf ||
|
||||
layoutMembers.value.find(
|
||||
(candidate) => String(candidate.spouseOf || "") === String(member.id),
|
||||
)?.id;
|
||||
const partner = layoutMembers.value.find(
|
||||
(candidate) => String(candidate.id) === String(partnerId || ""),
|
||||
);
|
||||
return partner && partner.generation === member.generation ? partner : null;
|
||||
};
|
||||
|
||||
const recenterSelectedMember = async () => {
|
||||
if (!selectedMember.value) return;
|
||||
const spouse = getFamilyPartner(selectedMember.value);
|
||||
ignoreTreeScroll = true;
|
||||
const [scrollRect, nodeRect, spouseRect] = await new Promise((resolve) => {
|
||||
const query = uni.createSelectorQuery();
|
||||
query.select(".tree-scroll").boundingClientRect();
|
||||
query.select(`#tree-member-${selectedMember.value.id}`).boundingClientRect();
|
||||
if (spouse) {
|
||||
query.select(`#tree-member-${spouse.id}`).boundingClientRect();
|
||||
}
|
||||
query.exec(resolve);
|
||||
});
|
||||
if (scrollRect && nodeRect) {
|
||||
const targetLeft = spouseRect
|
||||
? Math.min(nodeRect.left, spouseRect.left)
|
||||
: nodeRect.left;
|
||||
const targetRight = spouseRect
|
||||
? Math.max(
|
||||
nodeRect.left + nodeRect.width,
|
||||
spouseRect.left + spouseRect.width,
|
||||
)
|
||||
: nodeRect.left + nodeRect.width;
|
||||
const centeredScrollLeft = Math.max(
|
||||
0,
|
||||
currentTreeScrollLeft.value +
|
||||
(targetLeft + targetRight) / 2 -
|
||||
(scrollRect.left + scrollRect.width / 2),
|
||||
);
|
||||
treeScrollLeft.value = centeredScrollLeft;
|
||||
currentTreeScrollLeft.value = centeredScrollLeft;
|
||||
centeredTreeScrollLeft.value = centeredScrollLeft;
|
||||
}
|
||||
treeHasDrifted.value = false;
|
||||
if (recenterTimer) clearTimeout(recenterTimer);
|
||||
recenterTimer = setTimeout(() => {
|
||||
ignoreTreeScroll = false;
|
||||
recenterTimer = null;
|
||||
}, 320);
|
||||
};
|
||||
|
||||
const nodeGridStyle = treeNodeGridStyle;
|
||||
const handleStateAction = () => {
|
||||
if (treeState.value === "empty") {
|
||||
if (!genealogyId.value) return Promise.resolve(false);
|
||||
return openPage(
|
||||
"T04",
|
||||
{ genealogyId: genealogyId.value, mode: "first" },
|
||||
"T01",
|
||||
);
|
||||
}
|
||||
if (treeState.value === "error") {
|
||||
return loadTree({ genealogyId: genealogyId.value });
|
||||
}
|
||||
selectedMember.value = layoutMembers.value[0];
|
||||
treeState.value = "tree";
|
||||
};
|
||||
const toMember = () => {
|
||||
if (!selectedMember.value) return Promise.resolve(false);
|
||||
closeMemberActionPanel();
|
||||
return openPage(
|
||||
"T03",
|
||||
{ genealogyId: genealogyId.value, personId: String(selectedMember.value.id) },
|
||||
"T01",
|
||||
);
|
||||
};
|
||||
const toDirectory = () =>
|
||||
openPage("T07", { genealogyId: genealogyId.value }, "T01");
|
||||
const toPedigree = () =>
|
||||
openPage("T02", { genealogyId: genealogyId.value }, "T01");
|
||||
const toRank = () =>
|
||||
selectedMember.value
|
||||
? openPage(
|
||||
"T06",
|
||||
{
|
||||
genealogyId: genealogyId.value,
|
||||
personId: String(selectedMember.value.id),
|
||||
mode: "rank",
|
||||
},
|
||||
"T01",
|
||||
)
|
||||
: Promise.resolve(false);
|
||||
const openMemberPanel = (member) => {
|
||||
selectedMember.value = member;
|
||||
memberActionPanelVisible.value = true;
|
||||
};
|
||||
const closeMemberActionPanel = () => {
|
||||
memberActionPanelVisible.value = false;
|
||||
};
|
||||
const openMemberAction = (action) => {
|
||||
if (!selectedMember.value) return Promise.resolve(false);
|
||||
closeMemberActionPanel();
|
||||
if (!action.routeKey) {
|
||||
unavailableAction.value = action;
|
||||
unavailableActionVisible.value = true;
|
||||
return Promise.resolve(false);
|
||||
}
|
||||
const params = {
|
||||
genealogyId: genealogyId.value,
|
||||
personId: String(selectedMember.value.id),
|
||||
};
|
||||
if (action.routeKey === "T04") {
|
||||
params.relationType = action.relationType;
|
||||
} else if (action.routeKey === "T06") {
|
||||
params.mode = action.mode;
|
||||
}
|
||||
return openPage(action.routeKey, params, "T01");
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.tree-page {
|
||||
display: grid;
|
||||
height: 100vh;
|
||||
grid-template-rows: auto auto minmax(0, 1fr);
|
||||
overflow: hidden;
|
||||
background: $paper;
|
||||
}
|
||||
.tree-page__header,
|
||||
.tree-toolbar,
|
||||
.tree-stage {
|
||||
z-index: 1;
|
||||
}
|
||||
.tree-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 18rpx 28rpx 12rpx;
|
||||
}
|
||||
.tree-toolbar__title text {
|
||||
display: block;
|
||||
}
|
||||
.tree-toolbar__title text:first-child {
|
||||
color: $ink;
|
||||
font-family: "STKaiti", "KaiTi", serif;
|
||||
font-size: clamp(16px, 28rpx, 20px);
|
||||
font-weight: 700;
|
||||
}
|
||||
.tree-toolbar__title text:last-child {
|
||||
margin-top: 5rpx;
|
||||
color: $ink-muted;
|
||||
font-size: clamp(14px, 22rpx, 17px);
|
||||
}
|
||||
.tree-toolbar__actions {
|
||||
display: flex;
|
||||
gap: 24rpx;
|
||||
color: $brand-red;
|
||||
font-size: clamp(14px, 23rpx, 17px);
|
||||
}
|
||||
.tree-stage {
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.tree-action--pressed {
|
||||
opacity: 0.72;
|
||||
}
|
||||
.tree-stage--lineage {
|
||||
display: grid;
|
||||
grid-template-columns: 72rpx minmax(0, 1fr);
|
||||
align-items: start;
|
||||
}
|
||||
.tree-scroll {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.generation-rail {
|
||||
display: grid;
|
||||
z-index: 4;
|
||||
width: 72rpx;
|
||||
height: 100%;
|
||||
box-sizing: border-box;
|
||||
border-right: 1rpx solid rgba(143, 108, 63, 0.2);
|
||||
}
|
||||
.tree-scroll--lineage {
|
||||
grid-area: 1 / 2;
|
||||
min-width: 0;
|
||||
}
|
||||
.tree-canvas {
|
||||
display: grid;
|
||||
margin: 0 12rpx;
|
||||
}
|
||||
.tree-canvas--state {
|
||||
display: block;
|
||||
width: calc(100vw - 32rpx);
|
||||
}
|
||||
.lineage-pan-cue {
|
||||
grid-area: 1 / 2;
|
||||
align-self: start;
|
||||
justify-self: end;
|
||||
z-index: 3;
|
||||
margin: 8rpx 22rpx 0 0;
|
||||
color: #9a7748;
|
||||
font-size: clamp(13px, 20rpx, 16px);
|
||||
letter-spacing: 1rpx;
|
||||
}
|
||||
.generation-band {
|
||||
position: relative;
|
||||
display: grid;
|
||||
justify-self: center;
|
||||
z-index: 3;
|
||||
width: 62rpx;
|
||||
height: 104rpx;
|
||||
color: #8f6c3f;
|
||||
font-size: clamp(12px, 18rpx, 14px);
|
||||
text-align: center;
|
||||
}
|
||||
.generation-band__line,
|
||||
.generation-band__copy {
|
||||
grid-area: 1 / 1;
|
||||
}
|
||||
.generation-band__line {
|
||||
align-self: stretch;
|
||||
justify-self: center;
|
||||
width: 0;
|
||||
margin: 3rpx 0;
|
||||
border-left: 1rpx solid rgba(183, 138, 66, 0.7);
|
||||
}
|
||||
.generation-band__copy {
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0;
|
||||
height: 100%;
|
||||
padding: 0 4rpx;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.generation-band__copy text {
|
||||
writing-mode: vertical-rl;
|
||||
text-orientation: upright;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.generation-band__copy text:last-child {
|
||||
font-size: clamp(11px, 14rpx, 13px);
|
||||
}
|
||||
.lineage-connector {
|
||||
align-self: stretch;
|
||||
justify-self: stretch;
|
||||
z-index: 1;
|
||||
background: #b78a42;
|
||||
}
|
||||
.lineage-connector--spouse {
|
||||
background: rgba(183, 117, 76, 0.82);
|
||||
}
|
||||
.member-node {
|
||||
position: relative;
|
||||
align-self: start;
|
||||
justify-self: start;
|
||||
z-index: 2;
|
||||
width: 160rpx;
|
||||
height: 224rpx;
|
||||
transform: translate(-50%, -50%);
|
||||
text-align: center;
|
||||
}
|
||||
.member-node__surface {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
box-sizing: border-box;
|
||||
padding: 9rpx 8rpx 8rpx;
|
||||
border: 1rpx solid #b78a42;
|
||||
border-radius: 14rpx;
|
||||
background: rgba(255, 252, 244, 0.96);
|
||||
box-shadow: 0 5rpx 12rpx rgba(103, 72, 33, 0.12);
|
||||
}
|
||||
.member-node__portrait {
|
||||
display: flex;
|
||||
width: 62rpx;
|
||||
height: 62rpx;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-sizing: border-box;
|
||||
border: 1rpx solid #c49a57;
|
||||
border-radius: 50%;
|
||||
background: #fffdf7;
|
||||
overflow: hidden;
|
||||
}
|
||||
.member-node__divider {
|
||||
width: 56rpx;
|
||||
height: 14rpx;
|
||||
margin-top: 3rpx;
|
||||
}
|
||||
.member-node__copy {
|
||||
width: 100%;
|
||||
margin-top: 1rpx;
|
||||
padding: 0;
|
||||
}
|
||||
.node-name,
|
||||
.node-relation,
|
||||
.node-years {
|
||||
display: block;
|
||||
}
|
||||
.node-name {
|
||||
display: -webkit-box;
|
||||
color: #6b2419;
|
||||
font-family: "STKaiti", "KaiTi", serif;
|
||||
overflow: hidden;
|
||||
font-size: clamp(15px, 24rpx, 18px);
|
||||
font-weight: 700;
|
||||
line-height: 1.1;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 2;
|
||||
white-space: normal;
|
||||
}
|
||||
.node-relation {
|
||||
overflow: hidden;
|
||||
margin-top: 2rpx;
|
||||
color: #644b2e;
|
||||
font-size: clamp(12px, 18rpx, 14px);
|
||||
line-height: 1.2;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.node-years {
|
||||
overflow: hidden;
|
||||
margin-top: 2rpx;
|
||||
color: #695b4c;
|
||||
font-size: clamp(12px, 17rpx, 14px);
|
||||
line-height: 1.2;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.member-node--spouse .member-node__surface {
|
||||
border-color: #c39a61;
|
||||
background: rgba(255, 250, 241, 0.98);
|
||||
}
|
||||
.member-node--selected {
|
||||
z-index: 3;
|
||||
}
|
||||
.member-node--selected .member-node__surface {
|
||||
border-color: #c55344;
|
||||
box-shadow: 0 6rpx 14rpx rgba(159, 23, 15, 0.16);
|
||||
}
|
||||
.member-node--selected .member-node__portrait {
|
||||
border-color: #c55344;
|
||||
}
|
||||
.member-node--selected .node-name {
|
||||
color: $brand-red;
|
||||
}
|
||||
.tree-state-card {
|
||||
display: grid;
|
||||
z-index: 2;
|
||||
width: calc(100% - 48rpx);
|
||||
min-height: 360rpx;
|
||||
margin: 150rpx 24rpx 0;
|
||||
text-align: center;
|
||||
white-space: normal;
|
||||
}
|
||||
.tree-state-card__skin {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
.tree-state-card__skin,
|
||||
.tree-state-card__content {
|
||||
grid-area: 1 / 1;
|
||||
}
|
||||
.tree-state-card__content {
|
||||
z-index: 1;
|
||||
padding: 62rpx 58rpx 42rpx;
|
||||
}
|
||||
.tree-state-card__eyebrow {
|
||||
display: block;
|
||||
color: $brand-red;
|
||||
font-size: clamp(14px, 22rpx, 17px);
|
||||
letter-spacing: 3rpx;
|
||||
}
|
||||
.tree-state-card__title {
|
||||
display: block;
|
||||
margin-top: 12rpx;
|
||||
color: $ink;
|
||||
font-family: "STKaiti", "KaiTi", serif;
|
||||
font-size: clamp(19px, 34rpx, 24px);
|
||||
font-weight: 700;
|
||||
}
|
||||
.tree-state-card__copy {
|
||||
display: block;
|
||||
margin-top: 16rpx;
|
||||
color: $ink-muted;
|
||||
font-size: clamp(15px, 24rpx, 18px);
|
||||
line-height: 1.65;
|
||||
}
|
||||
.tree-state-card__action {
|
||||
display: grid;
|
||||
width: 360rpx;
|
||||
min-height: 70rpx;
|
||||
margin: 22rpx auto 0;
|
||||
}
|
||||
.tree-state-card__action image {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
.tree-state-card__action image,
|
||||
.tree-state-card__action text {
|
||||
grid-area: 1 / 1;
|
||||
}
|
||||
.tree-state-card__action text {
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
color: #fff9ed;
|
||||
font-size: clamp(15px, 24rpx, 18px);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
@media (max-width: 360px) {
|
||||
.tree-toolbar {
|
||||
padding-right: 20rpx;
|
||||
padding-left: 20rpx;
|
||||
}
|
||||
.tree-toolbar__actions {
|
||||
gap: 14rpx;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,4 +1,3 @@
|
||||
<!-- 页面编号:T-02;用途:世系谱表阅读、同世成员横向滑页。 -->
|
||||
<template>
|
||||
<view class="pedigree-page">
|
||||
<ModulePageBackground module="tree" />
|
||||
@@ -104,7 +103,7 @@
|
||||
<view v-else class="pedigree-state-card">
|
||||
<image
|
||||
class="pedigree-state-card__skin"
|
||||
src="/static/assets/modules/tree/transparent/t01-state-panel.png"
|
||||
src="/static/assets/modules/tree/transparent/state-panel.png"
|
||||
mode="scaleToFill"
|
||||
/>
|
||||
<view class="pedigree-state-card__content">
|
||||
@@ -113,7 +112,7 @@
|
||||
<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"
|
||||
src="/static/assets/foundation/transparent/scroll-primary.png"
|
||||
mode="aspectFit"
|
||||
/>
|
||||
<text>{{ stateCopy.action }}</text>
|
||||
@@ -142,12 +141,13 @@ 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";
|
||||
isRequestCancelled
|
||||
} from "@/services/api/request-controller.js";
|
||||
import { lineageApi } from "@/services/api/lineage-service.js";
|
||||
import { getRequestErrorMessage } from "@/services/api/request-error-message.js";
|
||||
import { genealogyContext } from "@/utils/genealogy/context.js";
|
||||
import { openPage, returnTo } from "@/utils/navigation/gateway.js";
|
||||
|
||||
const PEDIGREE_PAGE_SIZE = 5;
|
||||
const genealogyId = ref("");
|
||||
@@ -160,7 +160,7 @@ const detailVisible = ref(false);
|
||||
const detailMember = ref(null);
|
||||
const detailMessage = ref("");
|
||||
const pedigreeRequestController = createRequestController();
|
||||
const detailRequestController = createRequestController();
|
||||
const pedigreeDetailRequestController = createRequestController();
|
||||
let loadGeneration = 0;
|
||||
let detailLoadGeneration = 0;
|
||||
let pageActive = true;
|
||||
@@ -226,12 +226,6 @@ const pedigreeScrollStyle = computed(() => ({
|
||||
const stateCopy = computed(
|
||||
() =>
|
||||
({
|
||||
landscape: {
|
||||
eyebrow: "世系谱阅读提示",
|
||||
title: "左右滑动查看成员",
|
||||
copy: "同一世代每页展示五位成员,继续向左滑动即可查看下一页。",
|
||||
action: "返回树状图",
|
||||
},
|
||||
empty: {
|
||||
eyebrow: "尚未建立世系",
|
||||
title: "从第一位先祖开始记录",
|
||||
@@ -261,18 +255,18 @@ const openMemberProfile = (member) =>
|
||||
);
|
||||
const closeMemberDetail = () => {
|
||||
detailVisible.value = false;
|
||||
detailRequestController.abort();
|
||||
pedigreeDetailRequestController.abort();
|
||||
};
|
||||
const openMemberDetail = async (member) => {
|
||||
if (!member) return;
|
||||
const generation = ++detailLoadGeneration;
|
||||
detailRequestController.abort();
|
||||
pedigreeDetailRequestController.abort();
|
||||
detailMember.value = member;
|
||||
detailMessage.value = "正在读取人物生平…";
|
||||
detailVisible.value = true;
|
||||
try {
|
||||
const detail = await appApi.getPerson(genealogyId.value, member.id, {
|
||||
requestController: detailRequestController,
|
||||
const detail = await lineageApi.getPerson(genealogyId.value, member.id, {
|
||||
requestController: pedigreeDetailRequestController,
|
||||
});
|
||||
if (generation !== detailLoadGeneration || !detailVisible.value) return;
|
||||
detailMember.value = detail;
|
||||
@@ -281,16 +275,15 @@ const openMemberDetail = async (member) => {
|
||||
"暂未填写生平说明。";
|
||||
} catch (error) {
|
||||
if (isRequestCancelled(error) || generation !== detailLoadGeneration) return;
|
||||
detailMessage.value = error?.message || "人物生平读取失败,请稍后重试。";
|
||||
detailMessage.value = getRequestErrorMessage(error, "人物生平读取失败,请稍后重试。");
|
||||
}
|
||||
};
|
||||
const toTree = () => {
|
||||
const params = { genealogyId: genealogyId.value };
|
||||
if (selectedId.value) params.selectedId = selectedId.value;
|
||||
return openPage("T01", params, "T02");
|
||||
return returnTo("T01", params);
|
||||
};
|
||||
const handleStateAction = () => {
|
||||
if (treeState.value === "landscape") return toTree();
|
||||
if (treeState.value === "empty") {
|
||||
return openPage(
|
||||
"T04",
|
||||
@@ -314,17 +307,13 @@ const loadPedigree = async (query = {}) => {
|
||||
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, {
|
||||
const treeMembers = await lineageApi.getTree(genealogyId.value, {
|
||||
requestController: pedigreeRequestController,
|
||||
});
|
||||
if (!pageActive || generation !== loadGeneration) return;
|
||||
members.value = result;
|
||||
members.value = treeMembers;
|
||||
genealogyContext.setCurrentGenealogyId(genealogyId.value);
|
||||
const selectedPage = pedigreePages.value.findIndex((page) =>
|
||||
page.members.some((member) => String(member?.id) === selectedId.value),
|
||||
@@ -336,7 +325,7 @@ const loadPedigree = async (query = {}) => {
|
||||
return;
|
||||
}
|
||||
members.value = [];
|
||||
treeLoadError.value = error?.message || "世系谱读取失败,请稍后重试。";
|
||||
treeLoadError.value = getRequestErrorMessage(error, "世系谱读取失败,请稍后重试。");
|
||||
treeState.value = "error";
|
||||
}
|
||||
};
|
||||
@@ -354,7 +343,7 @@ onUnload(() => {
|
||||
loadGeneration += 1;
|
||||
detailLoadGeneration += 1;
|
||||
pedigreeRequestController.abort();
|
||||
detailRequestController.abort();
|
||||
pedigreeDetailRequestController.abort();
|
||||
});
|
||||
</script>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,248 +0,0 @@
|
||||
<!-- 页面编号:T-06;用途:展示指定成员的同辈排行调整入口。 -->
|
||||
<template>
|
||||
<view
|
||||
class="rank-page"
|
||||
:class="{
|
||||
'rank-state--loading': rankState === 'loading',
|
||||
'rank-state--form': rankState === 'form',
|
||||
'rank-state--unavailable': rankState === 'unavailable',
|
||||
'rank-state--error': rankState === 'error',
|
||||
}"
|
||||
>
|
||||
<ModulePageBackground module="tree" />
|
||||
<view class="rank-page__header"
|
||||
><PageHeader title="调整排行" custom-back @back="goBack"
|
||||
/></view>
|
||||
<view class="rank-panel">
|
||||
<AppLoading
|
||||
v-if="rankState === 'loading'"
|
||||
text="正在读取成员资料"
|
||||
description="请稍候,正在确认待调整人物。"
|
||||
/>
|
||||
|
||||
<view v-else-if="rankState === 'form' && member" class="rank-form">
|
||||
<text class="form-eyebrow">同辈排行</text>
|
||||
<text class="form-title">调整{{ member.name }}的排行</text>
|
||||
<text class="form-copy"
|
||||
>排行调整必须由服务端以同辈原子操作完成,避免逐人保存造成部分成功。</text
|
||||
>
|
||||
|
||||
<view class="member-context">
|
||||
<text>当前成员</text
|
||||
><text>第 {{ member.generation }} 世 · {{ member.branch }}</text>
|
||||
</view>
|
||||
<view class="rank-notice">
|
||||
<text>当前服务状态</text>
|
||||
<text>尚未提供带版本、冲突处理和完整结果的同辈原子重排合同。</text>
|
||||
</view>
|
||||
<view class="form-action" @click="showRankUnavailable">
|
||||
<image
|
||||
src="/static/assets/foundation/transparent/a01-scroll-primary-v3.png"
|
||||
mode="scaleToFill"
|
||||
/>
|
||||
<text>查看服务状态</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view v-else class="rank-result">
|
||||
<text class="form-eyebrow">{{
|
||||
rankState === "unavailable" ? "服务暂未开放" : "成员入口无效"
|
||||
}}</text>
|
||||
<text class="form-title">{{
|
||||
rankState === "unavailable"
|
||||
? "暂不能提交排行调整"
|
||||
: "暂时无法读取成员资料"
|
||||
}}</text>
|
||||
<text class="form-copy">{{
|
||||
rankState === "unavailable"
|
||||
? "当前人物更新接口只有单人物 sortOrder 候选,不能保证同辈排行原子一致。本页不会逐人写入,也不会显示本地预览成功。"
|
||||
: errorMessage
|
||||
}}</text>
|
||||
<view
|
||||
class="form-action"
|
||||
@click="rankState === 'unavailable' ? (rankState = 'form') : goBack()"
|
||||
>
|
||||
<image
|
||||
src="/static/assets/foundation/transparent/a01-scroll-primary-v3.png"
|
||||
mode="scaleToFill"
|
||||
/>
|
||||
<text>{{
|
||||
rankState === "unavailable" ? "返回查看" : "返回世系树"
|
||||
}}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from "vue";
|
||||
import { onBackPress, onLoad, onUnload } from "@dcloudio/uni-app";
|
||||
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 { goBack, handleBackPress } from "@/utils/navigation.js";
|
||||
|
||||
const rankState = ref("loading");
|
||||
const genealogyId = ref("");
|
||||
const personId = ref("");
|
||||
const member = ref(null);
|
||||
const errorMessage = ref("");
|
||||
const rankRequestController = createRequestController();
|
||||
let loadSequence = 0;
|
||||
|
||||
const loadMember = async () => {
|
||||
const activeLoad = ++loadSequence;
|
||||
rankState.value = "loading";
|
||||
try {
|
||||
const result = await appApi.getPerson(genealogyId.value, personId.value, {
|
||||
requestController: rankRequestController,
|
||||
});
|
||||
if (activeLoad !== loadSequence) return;
|
||||
member.value = result;
|
||||
errorMessage.value = "";
|
||||
rankState.value = "form";
|
||||
} catch (error) {
|
||||
if (activeLoad !== loadSequence || isRequestCancelled(error)) return;
|
||||
member.value = null;
|
||||
errorMessage.value =
|
||||
error?.message || "当前成员资料暂不可用,请返回世系树后重试。";
|
||||
rankState.value = "error";
|
||||
}
|
||||
};
|
||||
|
||||
const showRankUnavailable = () => {
|
||||
rankState.value = "unavailable";
|
||||
};
|
||||
|
||||
onLoad((query) => {
|
||||
genealogyId.value = String(query.genealogyId || "");
|
||||
personId.value = String(query.personId || "");
|
||||
if (!genealogyId.value || !personId.value || query.mode !== "rank") {
|
||||
rankState.value = "error";
|
||||
errorMessage.value = "当前排行入口无效,请从人物操作面板重新进入。";
|
||||
return;
|
||||
}
|
||||
void loadMember();
|
||||
});
|
||||
|
||||
onUnload(() => {
|
||||
loadSequence += 1;
|
||||
rankRequestController.abort();
|
||||
});
|
||||
|
||||
onBackPress((event) => handleBackPress(event, goBack));
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@import "../../styles/adaptive-frame-profiles.scss";
|
||||
|
||||
.rank-page {
|
||||
display: flex;
|
||||
min-height: 100vh;
|
||||
flex-direction: column;
|
||||
background: $paper;
|
||||
}
|
||||
.rank-page__header {
|
||||
z-index: 3;
|
||||
}
|
||||
.rank-panel {
|
||||
@include adaptive-tree-panel;
|
||||
z-index: 2;
|
||||
width: calc(100% - 32rpx);
|
||||
margin: 18rpx auto 28rpx;
|
||||
padding: 7.5% 8%;
|
||||
}
|
||||
.form-eyebrow {
|
||||
display: block;
|
||||
color: $brand-red;
|
||||
font-size: clamp(14px, 22rpx, 17px);
|
||||
letter-spacing: 3rpx;
|
||||
}
|
||||
.form-title {
|
||||
display: block;
|
||||
margin-top: 10rpx;
|
||||
color: $ink;
|
||||
font-family: "STKaiti", "KaiTi", serif;
|
||||
font-size: clamp(19px, 34rpx, 24px);
|
||||
font-weight: 700;
|
||||
line-height: 1.35;
|
||||
}
|
||||
.form-copy {
|
||||
display: block;
|
||||
margin-top: 10rpx;
|
||||
color: $ink-muted;
|
||||
font-size: clamp(15px, 24rpx, 18px);
|
||||
line-height: 1.5;
|
||||
}
|
||||
.member-context,
|
||||
.rank-notice {
|
||||
@include adaptive-tree-field;
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr);
|
||||
min-height: 78rpx;
|
||||
align-items: center;
|
||||
gap: 20rpx;
|
||||
margin-top: 16rpx;
|
||||
padding: 12rpx 22rpx;
|
||||
}
|
||||
.member-context text:first-child,
|
||||
.rank-notice text:first-child {
|
||||
color: $ink;
|
||||
font-size: clamp(15px, 24rpx, 18px);
|
||||
font-weight: 700;
|
||||
}
|
||||
.member-context text:last-child,
|
||||
.rank-notice text:last-child {
|
||||
min-width: 0;
|
||||
color: $ink-muted;
|
||||
font-size: clamp(14px, 23rpx, 17px);
|
||||
line-height: 1.45;
|
||||
text-align: right;
|
||||
}
|
||||
.form-action {
|
||||
display: grid;
|
||||
width: 100%;
|
||||
min-height: 76rpx;
|
||||
margin-top: 22rpx;
|
||||
}
|
||||
.form-action image,
|
||||
.form-action text {
|
||||
grid-area: 1 / 1;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
.form-action text {
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #fff9ed;
|
||||
font-size: clamp(15px, 24rpx, 18px);
|
||||
font-weight: 700;
|
||||
}
|
||||
.rank-result {
|
||||
margin-top: 30%;
|
||||
text-align: center;
|
||||
}
|
||||
.rank-result .form-eyebrow,
|
||||
.rank-result .form-copy {
|
||||
text-align: center;
|
||||
}
|
||||
.rank-result .form-action {
|
||||
width: 420rpx;
|
||||
max-width: 100%;
|
||||
margin-right: auto;
|
||||
margin-left: auto;
|
||||
}
|
||||
@media (min-width: 400px) {
|
||||
.rank-panel {
|
||||
width: calc(100% - 48rpx);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user