feat: migrate app routes and business modules

This commit is contained in:
2026-08-12 18:22:59 +08:00
parent 555aa00043
commit cc706378c2
247 changed files with 28623 additions and 14988 deletions
-414
View File
@@ -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>
+266
View File
@@ -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;
+457
View File
@@ -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;
-232
View File
@@ -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>
-180
View File
@@ -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>
-150
View File
@@ -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;
+660
View File
@@ -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>
+258
View File
@@ -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>
-232
View File
@@ -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>
-116
View File
@@ -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>
-128
View File
@@ -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;
+728
View File
@@ -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>
+265
View File
@@ -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;
+433
View File
@@ -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>
+211
View File
@@ -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>
+282
View File
@@ -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>
-187
View File
@@ -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>
-205
View File
@@ -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>
+301
View File
@@ -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 {
+222
View File
@@ -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>
+445
View File
@@ -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);
+353
View File
@@ -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);
}
-328
View File
@@ -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>
-212
View File
@@ -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>
-614
View File
@@ -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>
-234
View File
@@ -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>
+242
View File
@@ -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;
+615
View File
@@ -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>
+358
View File
@@ -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>
+657
View File
@@ -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;
+932
View File
@@ -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>
+623
View File
@@ -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>
+734
View File
@@ -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>
+716
View File
@@ -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;
-192
View File
@@ -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>
-165
View File
@@ -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>
-602
View File
@@ -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>
-73
View File
@@ -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>
-497
View File
@@ -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>
-489
View File
@@ -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>
+333
View File
@@ -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;
}
+310
View File
@@ -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();
+866
View File
@@ -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
-248
View File
@@ -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>