feat: 完成前端业务闭环与后端联调
This commit is contained in:
+58
-7
@@ -78,6 +78,34 @@
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view
|
||||
class="field-block"
|
||||
:class="{ 'field-block--error': fieldErrors.referralCode }"
|
||||
>
|
||||
<view class="input-row">
|
||||
<label class="input-label" for="register-referral-code">推荐码</label>
|
||||
<input
|
||||
id="register-referral-code"
|
||||
v-model.trim="referralCode"
|
||||
class="auth-input"
|
||||
maxlength="64"
|
||||
:disabled="submitting || registrationCommitted"
|
||||
placeholder="选填,来自邀请人的推荐码"
|
||||
placeholder-class="placeholder"
|
||||
:aria-invalid="Boolean(fieldErrors.referralCode)"
|
||||
:aria-describedby="fieldErrors.referralCode ? 'register-referral-code-error' : undefined"
|
||||
@input="clearFieldError('referralCode')"
|
||||
/>
|
||||
</view>
|
||||
<text
|
||||
v-if="fieldErrors.referralCode"
|
||||
id="register-referral-code-error"
|
||||
class="field-error"
|
||||
role="alert"
|
||||
>{{ fieldErrors.referralCode }}</text
|
||||
>
|
||||
</view>
|
||||
|
||||
<view
|
||||
class="field-block"
|
||||
:class="{ 'field-block--error': fieldErrors.verificationCode }"
|
||||
@@ -259,14 +287,14 @@
|
||||
<text>我已阅读并同意</text>
|
||||
<button
|
||||
class="auth-plain-button agreement-link"
|
||||
@click="prepareAgreement"
|
||||
@click="openComplianceDocument('user_agreement')"
|
||||
>
|
||||
《用户协议》
|
||||
</button>
|
||||
<text>与</text>
|
||||
<button
|
||||
class="auth-plain-button agreement-link"
|
||||
@click="prepareAgreement"
|
||||
@click="openComplianceDocument('privacy_policy')"
|
||||
>
|
||||
《隐私政策》
|
||||
</button>
|
||||
@@ -318,7 +346,7 @@
|
||||
|
||||
<script setup>
|
||||
import { computed, ref } from "vue";
|
||||
import { onBackPress, onShow, onUnload } from "@dcloudio/uni-app";
|
||||
import { onBackPress, onLoad, onShow, onUnload } from "@dcloudio/uni-app";
|
||||
import AppDialog from "@/components/AppDialog.vue";
|
||||
import AppToast from "@/components/AppToast.vue";
|
||||
import AuthPageShell from "@/components/auth/PageShell.vue";
|
||||
@@ -336,7 +364,12 @@ import {
|
||||
import { createDiscardConfirmation } from "@/utils/discard-confirmation.js";
|
||||
import { calcMD5 } from "@/utils/md5.js";
|
||||
import { createNonIdempotentWriteGuard } from "@/utils/non-idempotent-write-guard.js";
|
||||
import { goRoot, handleBackPress, runBackGuard } from "@/utils/navigation/gateway.js";
|
||||
import {
|
||||
goRoot,
|
||||
handleBackPress,
|
||||
openPage,
|
||||
runBackGuard,
|
||||
} from "@/utils/navigation/gateway.js";
|
||||
import {
|
||||
PASSWORD_POLICY_MESSAGE,
|
||||
validatePassword,
|
||||
@@ -344,6 +377,7 @@ import {
|
||||
|
||||
const phone = ref("");
|
||||
const nickName = ref("");
|
||||
const referralCode = ref("");
|
||||
const verificationCode = ref("");
|
||||
const password = ref("");
|
||||
const confirmPassword = ref("");
|
||||
@@ -351,6 +385,7 @@ const agreed = ref(false);
|
||||
const agreementError = ref(false);
|
||||
const fieldErrors = ref({
|
||||
phone: "",
|
||||
referralCode: "",
|
||||
verificationCode: "",
|
||||
password: "",
|
||||
confirmPassword: "",
|
||||
@@ -366,6 +401,7 @@ const isDirty = computed(
|
||||
Boolean(
|
||||
phone.value ||
|
||||
nickName.value ||
|
||||
referralCode.value ||
|
||||
verificationCode.value ||
|
||||
password.value ||
|
||||
confirmPassword.value ||
|
||||
@@ -374,6 +410,10 @@ const isDirty = computed(
|
||||
);
|
||||
let feedbackTimer = null;
|
||||
let pageActive = true;
|
||||
|
||||
onLoad((options = {}) => {
|
||||
referralCode.value = String(options.referralCode || "").trim().slice(0, 64);
|
||||
});
|
||||
const registrationNavigationFailure =
|
||||
"注册已完成,但暂时无法进入家谱,请再次点击进入";
|
||||
const registrationSubmissionRequestController = createRequestController();
|
||||
@@ -452,7 +492,11 @@ const enterAuthenticatedRoot = async () => {
|
||||
}
|
||||
};
|
||||
|
||||
const prepareAgreement = () => showFeedback("协议页面准备中");
|
||||
const openComplianceDocument = (documentKey) => {
|
||||
if (sendingCode.value || submitting.value || registrationCommitted.value)
|
||||
return;
|
||||
return openPage("M13", { documentKey }, "A04");
|
||||
};
|
||||
|
||||
const toggleAgreement = () => {
|
||||
if (sendingCode.value || submitting.value || registrationCommitted.value)
|
||||
@@ -490,11 +534,15 @@ const prepareGetCode = async () => {
|
||||
const validateForm = () => {
|
||||
const nextErrors = {
|
||||
phone: "",
|
||||
referralCode: "",
|
||||
verificationCode: "",
|
||||
password: "",
|
||||
confirmPassword: "",
|
||||
};
|
||||
if (!isAuthPhone(phone.value)) nextErrors.phone = "请输入正确手机号";
|
||||
if (referralCode.value && !/^[A-Za-z0-9_-]{4,64}$/.test(referralCode.value)) {
|
||||
nextErrors.referralCode = "推荐码应为 4 至 64 位字母、数字、短横线或下划线";
|
||||
}
|
||||
if (sentPhone.value !== phone.value)
|
||||
nextErrors.verificationCode = "请先获取当前手机号的验证码";
|
||||
else if (!/^\d{4}$/.test(verificationCode.value))
|
||||
@@ -517,6 +565,7 @@ const submitRegister = async () => {
|
||||
const registrationPayload = {
|
||||
phone: phone.value,
|
||||
nickName: nickName.value,
|
||||
referralCode: referralCode.value,
|
||||
passwordHash: calcMD5(password.value),
|
||||
smsCode: verificationCode.value,
|
||||
};
|
||||
@@ -732,7 +781,7 @@ const submitRegister = async () => {
|
||||
.agreement-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: nowrap;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
min-height: var(--app-touch-min);
|
||||
font-size: clamp(12px, 22rpx, 14px);
|
||||
@@ -761,12 +810,14 @@ const submitRegister = async () => {
|
||||
.agreement-copy {
|
||||
display: flex;
|
||||
flex: 0 0 auto;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 0;
|
||||
padding-top: 2rpx;
|
||||
}
|
||||
.agreement-copy text {
|
||||
white-space: nowrap;
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.agreement-link {
|
||||
|
||||
+138
-6
@@ -175,6 +175,28 @@
|
||||
}}</text>
|
||||
</button>
|
||||
|
||||
<!-- #ifdef APP-PLUS -->
|
||||
<view class="third-party-login">
|
||||
<text class="third-party-login__divider">其他登录方式</text>
|
||||
<button
|
||||
class="auth-plain-button wechat-login"
|
||||
:disabled="submitting || sendingCode || tacVisible || wechatProviderState !== 'ready'"
|
||||
:aria-busy="submitting"
|
||||
hover-class="tap-fade"
|
||||
@click="submitWechatLogin"
|
||||
>
|
||||
<text class="wechat-login__mark">微</text>
|
||||
<text>{{
|
||||
wechatProviderState === "checking"
|
||||
? "正在检查微信登录"
|
||||
: wechatProviderState === "ready"
|
||||
? "微信登录"
|
||||
: "微信登录尚未配置"
|
||||
}}</text>
|
||||
</button>
|
||||
</view>
|
||||
<!-- #endif -->
|
||||
|
||||
<view class="register-entry">
|
||||
<text>还没有账号?</text>
|
||||
<button
|
||||
@@ -218,14 +240,14 @@
|
||||
<text>我已阅读并同意</text>
|
||||
<button
|
||||
class="auth-plain-button agreement-link"
|
||||
@click="prepareAgreement"
|
||||
@click="openComplianceDocument('user_agreement')"
|
||||
>
|
||||
《用户协议》
|
||||
</button>
|
||||
<text>与</text>
|
||||
<button
|
||||
class="auth-plain-button agreement-link"
|
||||
@click="prepareAgreement"
|
||||
@click="openComplianceDocument('privacy_policy')"
|
||||
>
|
||||
《隐私政策》
|
||||
</button>
|
||||
@@ -294,6 +316,7 @@ const tacVisible = ref(false);
|
||||
const tacContext = ref(null);
|
||||
const sendingCode = ref(false);
|
||||
const submitting = ref(false);
|
||||
const wechatProviderState = ref("unknown");
|
||||
const authenticationCommitted = ref(false);
|
||||
const cooldownSeconds = ref(0);
|
||||
const sentPhone = ref("");
|
||||
@@ -363,7 +386,36 @@ const restoreAuthenticatedSession = () => {
|
||||
void enterAuthenticatedRoot();
|
||||
};
|
||||
|
||||
onShow(restoreAuthenticatedSession);
|
||||
const detectWechatProvider = () => {
|
||||
if (wechatProviderState.value !== "unknown") return;
|
||||
// #ifdef APP-PLUS
|
||||
if (typeof uni?.getProvider !== "function") {
|
||||
wechatProviderState.value = "unavailable";
|
||||
return;
|
||||
}
|
||||
wechatProviderState.value = "checking";
|
||||
uni.getProvider({
|
||||
service: "oauth",
|
||||
success: ({ provider = [] } = {}) => {
|
||||
if (!pageActive) return;
|
||||
wechatProviderState.value = provider.includes("weixin")
|
||||
? "ready"
|
||||
: "unavailable";
|
||||
},
|
||||
fail: () => {
|
||||
if (pageActive) wechatProviderState.value = "unavailable";
|
||||
},
|
||||
});
|
||||
// #endif
|
||||
// #ifndef APP-PLUS
|
||||
wechatProviderState.value = "unavailable";
|
||||
// #endif
|
||||
};
|
||||
|
||||
onShow(() => {
|
||||
restoreAuthenticatedSession();
|
||||
detectWechatProvider();
|
||||
});
|
||||
|
||||
const blockBusyAction = () => {
|
||||
if (!sendingCode.value && !submitting.value) return false;
|
||||
@@ -685,7 +737,43 @@ const prepareRegister = () => {
|
||||
return openPage("A04", {}, "A01");
|
||||
};
|
||||
|
||||
const prepareAgreement = () => showFeedback("协议页面准备中");
|
||||
const openComplianceDocument = (documentKey) => {
|
||||
if (blockBusyAction()) return;
|
||||
return openPage("M13", { documentKey }, "A01");
|
||||
};
|
||||
|
||||
const requestWechatAuthorizationCode = () =>
|
||||
new Promise((resolve, reject) => {
|
||||
uni.login({
|
||||
provider: "weixin",
|
||||
onlyAuthorize: true,
|
||||
success: ({ code } = {}) => resolve(code),
|
||||
fail: reject,
|
||||
});
|
||||
});
|
||||
|
||||
const submitWechatLogin = async () => {
|
||||
if (submitting.value || wechatProviderState.value !== "ready") return;
|
||||
if (authenticationCommitted.value) return enterAuthenticatedRoot();
|
||||
if (!requireAgreement()) return;
|
||||
submitting.value = true;
|
||||
try {
|
||||
const code = await requestWechatAuthorizationCode();
|
||||
await authApi.loginWithWechat(
|
||||
{ code },
|
||||
{ requestController: signInSubmissionRequestController },
|
||||
);
|
||||
if (!pageActive) return;
|
||||
authenticationCommitted.value = true;
|
||||
await enterAuthenticatedRoot();
|
||||
} catch (error) {
|
||||
if (!pageActive || isRequestCancelled(error)) return;
|
||||
const errorText = String(error?.errMsg || error?.message || "");
|
||||
showFeedback(/cancel|取消/i.test(errorText) ? "已取消微信登录" : errorText || "微信登录失败,请稍后重试");
|
||||
} finally {
|
||||
if (pageActive) submitting.value = false;
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@@ -906,7 +994,7 @@ const prepareAgreement = () => showFeedback("协议页面准备中");
|
||||
.agreement-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: nowrap;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
min-height: var(--app-touch-min);
|
||||
font-size: clamp(12px, 22rpx, 14px);
|
||||
@@ -934,12 +1022,56 @@ const prepareAgreement = () => showFeedback("协议页面准备中");
|
||||
.agreement-copy {
|
||||
display: flex;
|
||||
flex: 0 0 auto;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 0;
|
||||
padding-top: 2rpx;
|
||||
}
|
||||
.agreement-copy text {
|
||||
white-space: nowrap;
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.third-party-login {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
margin-top: 22rpx;
|
||||
gap: 16rpx;
|
||||
}
|
||||
|
||||
.third-party-login__divider {
|
||||
color: rgba(76, 57, 41, 0.62);
|
||||
font-size: clamp(12px, 21rpx, 15px);
|
||||
}
|
||||
|
||||
.wechat-login {
|
||||
display: inline-flex;
|
||||
min-height: 72rpx;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0 30rpx;
|
||||
border: 1rpx solid rgba(66, 107, 88, 0.38);
|
||||
border-radius: 999rpx;
|
||||
color: #315943;
|
||||
font-size: clamp(14px, 23rpx, 17px);
|
||||
gap: 12rpx;
|
||||
}
|
||||
|
||||
.wechat-login[disabled] {
|
||||
opacity: 0.52;
|
||||
}
|
||||
|
||||
.wechat-login__mark {
|
||||
display: inline-flex;
|
||||
width: 38rpx;
|
||||
height: 38rpx;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 50%;
|
||||
background: #2f9e57;
|
||||
color: #fff;
|
||||
font-size: clamp(11px, 18rpx, 14px);
|
||||
}
|
||||
|
||||
.agreement-link {
|
||||
|
||||
@@ -308,6 +308,7 @@ onUnload(() => {
|
||||
.media-state-card {
|
||||
@include adaptive-family-panel;
|
||||
box-sizing: border-box;
|
||||
background-color: rgba($paper, 0.82);
|
||||
}
|
||||
.media-panel {
|
||||
padding: 38rpx 32rpx 42rpx;
|
||||
@@ -357,16 +358,16 @@ onUnload(() => {
|
||||
font-size: clamp(15px, 24rpx, 18px);
|
||||
}
|
||||
.media-field input {
|
||||
min-height: 78rpx;
|
||||
min-height: 80rpx;
|
||||
padding: 0 22rpx;
|
||||
}
|
||||
.media-field--picker picker {
|
||||
display: block;
|
||||
min-height: 78rpx;
|
||||
min-height: 80rpx;
|
||||
}
|
||||
.media-field--picker picker > view {
|
||||
display: flex;
|
||||
min-height: 78rpx;
|
||||
min-height: 80rpx;
|
||||
align-items: center;
|
||||
padding: 0 22rpx;
|
||||
border: 1rpx solid rgba(128, 89, 49, 0.34);
|
||||
|
||||
+324
-22
@@ -4,10 +4,10 @@
|
||||
<view class="album-detail-header">
|
||||
<PageHeader
|
||||
title="相册详情"
|
||||
:action="valid ? '添加' : ''"
|
||||
:action="headerAction"
|
||||
custom-back
|
||||
@back="returnToAlbums"
|
||||
@action="addPhoto"
|
||||
@back="requestBack"
|
||||
@action="handleHeaderAction"
|
||||
/>
|
||||
</view>
|
||||
<view class="album-detail-content">
|
||||
@@ -28,20 +28,67 @@
|
||||
<AppButton block label="添加照片" @click="addPhoto" />
|
||||
</view>
|
||||
<view v-else class="photo-list">
|
||||
<view v-if="deletablePhotos.length" class="photo-management">
|
||||
<template v-if="selectionMode">
|
||||
<view class="photo-management__summary">
|
||||
<text>已选择 {{ selectedPhotoIds.length }} 张</text>
|
||||
<text>仅可选择有删除权限的照片</text>
|
||||
</view>
|
||||
<view class="photo-management__actions">
|
||||
<AppButton
|
||||
compact
|
||||
type="secondary"
|
||||
:disabled="batchDeleting"
|
||||
:label="allDeletableSelected ? '取消全选' : '全选'"
|
||||
@click="toggleAllDeletablePhotos"
|
||||
/>
|
||||
<AppButton
|
||||
compact
|
||||
:disabled="!selectedPhotoIds.length || batchDeleting"
|
||||
:label="batchDeleteButtonLabel"
|
||||
@click="requestBatchDelete"
|
||||
/>
|
||||
</view>
|
||||
</template>
|
||||
<template v-else>
|
||||
<view class="photo-management__summary">
|
||||
<text>批量管理照片</text>
|
||||
<text>可一次选择多张照片删除</text>
|
||||
</view>
|
||||
<AppButton compact type="secondary" label="管理照片" @click="enterSelectionMode" />
|
||||
</template>
|
||||
</view>
|
||||
<text v-if="deleteNotice" class="photo-list__notice" role="status">{{ deleteNotice }}</text>
|
||||
<text v-if="deleteError" class="photo-list__error">{{ deleteError }}</text>
|
||||
<view v-for="item in photos" :key="item.id" class="photo-card">
|
||||
<view
|
||||
v-for="item in photos"
|
||||
:key="item.id"
|
||||
class="photo-card"
|
||||
:class="{ 'photo-card--selected': isPhotoSelected(item) }"
|
||||
>
|
||||
<view
|
||||
v-if="selectionMode && item.canDelete"
|
||||
class="photo-card__selection"
|
||||
role="checkbox"
|
||||
:aria-checked="isPhotoSelected(item)"
|
||||
:aria-label="`${isPhotoSelected(item) ? '取消选择' : '选择'}照片:${item.title}`"
|
||||
@click="togglePhotoSelection(item)"
|
||||
>
|
||||
<text>{{ isPhotoSelected(item) ? "已选择" : "选择" }}</text>
|
||||
</view>
|
||||
<image
|
||||
class="photo-card__image"
|
||||
:src="item.photoFile.accessUrl"
|
||||
mode="widthFix"
|
||||
role="button"
|
||||
:aria-label="`查看大图:${item.title}`"
|
||||
@click="previewPhoto(item)"
|
||||
:role="selectionMode && item.canDelete ? 'checkbox' : 'button'"
|
||||
:aria-checked="selectionMode && item.canDelete ? isPhotoSelected(item) : undefined"
|
||||
:aria-label="selectionMode && item.canDelete ? `${isPhotoSelected(item) ? '取消选择' : '选择'}照片:${item.title}` : `查看大图:${item.title}`"
|
||||
@click="handlePhotoClick(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">
|
||||
<text class="photo-card__title">{{ item.title }}</text>
|
||||
<text v-if="item.description" class="photo-card__copy">{{ item.description }}</text>
|
||||
<text v-if="item.meta" class="photo-card__meta">{{ item.meta }}</text>
|
||||
<view v-if="item.canDelete && !selectionMode" class="photo-card__actions">
|
||||
<AppButton compact type="secondary" label="删除照片" @click.stop="requestDeletePhoto(item)" />
|
||||
</view>
|
||||
</view>
|
||||
@@ -51,20 +98,32 @@
|
||||
:visible="deleteConfirmationVisible"
|
||||
:close-on-mask="false"
|
||||
eyebrow="删除确认"
|
||||
title="删除这张照片?"
|
||||
message="删除后无法恢复,请确认影像已另行保存。"
|
||||
confirm-text="确认删除"
|
||||
title="将这张照片移至回收站?"
|
||||
message="移入回收站后不再展示,家谱管理员可在保留期内恢复。"
|
||||
confirm-text="移至回收站"
|
||||
cancel-text="保留照片"
|
||||
show-cancel
|
||||
@confirm="deletePhoto"
|
||||
@cancel="closeDeleteConfirmation"
|
||||
/>
|
||||
<AppDialog
|
||||
:visible="batchDeleteConfirmationVisible"
|
||||
:close-on-mask="false"
|
||||
eyebrow="批量移除"
|
||||
title="将选中的照片移至回收站?"
|
||||
:message="batchDeleteConfirmationMessage"
|
||||
:confirm-text="batchDeleting ? '正在移除' : '移至回收站'"
|
||||
cancel-text="继续选择"
|
||||
show-cancel
|
||||
@confirm="deleteSelectedPhotos"
|
||||
@cancel="closeBatchDeleteConfirmation"
|
||||
/>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref } from "vue";
|
||||
import { onLoad, onShow, onUnload } from "@dcloudio/uni-app";
|
||||
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";
|
||||
@@ -76,7 +135,7 @@ import {
|
||||
} 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";
|
||||
import { goBack, handleBackPress, openPage, returnTo } from "@/utils/navigation/gateway.js";
|
||||
|
||||
const genealogyId = ref("");
|
||||
const albumId = ref("");
|
||||
@@ -86,6 +145,11 @@ const deleteTarget = ref(null);
|
||||
const deleteConfirmationVisible = ref(false);
|
||||
const deletingPhotoId = ref("");
|
||||
const deleteError = ref("");
|
||||
const deleteNotice = ref("");
|
||||
const selectionMode = ref(false);
|
||||
const selectedPhotoIds = ref([]);
|
||||
const batchDeleteConfirmationVisible = ref(false);
|
||||
const batchDeleting = ref(false);
|
||||
const albumPhotoListController = createRequestController();
|
||||
const albumPhotoDeleteController = createRequestController();
|
||||
let isPageActive = true;
|
||||
@@ -93,6 +157,34 @@ const valid = computed(
|
||||
() =>
|
||||
/^[1-9]\d*$/.test(genealogyId.value) && /^[1-9]\d*$/.test(albumId.value),
|
||||
);
|
||||
const headerAction = computed(() => {
|
||||
if (!valid.value) return "";
|
||||
return selectionMode.value ? "完成" : "添加";
|
||||
});
|
||||
const deletablePhotos = computed(() => photos.value.filter((photo) => photo.canDelete));
|
||||
const allDeletableSelected = computed(
|
||||
() =>
|
||||
deletablePhotos.value.length > 0 &&
|
||||
deletablePhotos.value.every((photo) => selectedPhotoIds.value.includes(photo.id)),
|
||||
);
|
||||
const batchDeleteConfirmationMessage = computed(() =>
|
||||
`将选中的 ${selectedPhotoIds.value.length} 张照片移入回收站;家谱管理员可在保留期内恢复。`,
|
||||
);
|
||||
const batchDeleteButtonLabel = computed(() =>
|
||||
batchDeleting.value
|
||||
? "正在删除"
|
||||
: selectedPhotoIds.value.length
|
||||
? `删除 ${selectedPhotoIds.value.length}`
|
||||
: "删除",
|
||||
);
|
||||
const deleteInProgress = computed(
|
||||
() => Boolean(deletingPhotoId.value) || batchDeleting.value,
|
||||
);
|
||||
const resetPhotoSelection = () => {
|
||||
selectionMode.value = false;
|
||||
selectedPhotoIds.value = [];
|
||||
batchDeleteConfirmationVisible.value = false;
|
||||
};
|
||||
const loadPhotos = async () => {
|
||||
if (!valid.value) return;
|
||||
albumPhotoListController.abort();
|
||||
@@ -106,6 +198,7 @@ const loadPhotos = async () => {
|
||||
...photo,
|
||||
meta: [photo.photographer, photo.shootTime].filter(Boolean).join(" · "),
|
||||
}));
|
||||
resetPhotoSelection();
|
||||
albumPhotoListState.value = photos.value.length ? "ready" : "empty";
|
||||
} catch (error) {
|
||||
if (!isPageActive || isRequestCancelled(error)) return;
|
||||
@@ -124,15 +217,54 @@ const addPhoto = () =>
|
||||
"F08",
|
||||
)
|
||||
: Promise.resolve(false);
|
||||
const handleHeaderAction = () => {
|
||||
if (selectionMode.value) {
|
||||
resetPhotoSelection();
|
||||
return;
|
||||
}
|
||||
addPhoto();
|
||||
};
|
||||
const enterSelectionMode = () => {
|
||||
if (!deletablePhotos.value.length || deleteInProgress.value) return;
|
||||
deleteError.value = "";
|
||||
deleteNotice.value = "";
|
||||
selectedPhotoIds.value = [];
|
||||
selectionMode.value = true;
|
||||
};
|
||||
const isPhotoSelected = (photo) => selectedPhotoIds.value.includes(photo?.id);
|
||||
const togglePhotoSelection = (photo) => {
|
||||
if (!selectionMode.value || !photo?.canDelete || batchDeleting.value) return;
|
||||
selectedPhotoIds.value = isPhotoSelected(photo)
|
||||
? selectedPhotoIds.value.filter((photoId) => photoId !== photo.id)
|
||||
: [...selectedPhotoIds.value, photo.id];
|
||||
deleteError.value = "";
|
||||
deleteNotice.value = "";
|
||||
};
|
||||
const toggleAllDeletablePhotos = () => {
|
||||
if (!selectionMode.value || batchDeleting.value) return;
|
||||
selectedPhotoIds.value = allDeletableSelected.value
|
||||
? []
|
||||
: deletablePhotos.value.map((photo) => photo.id);
|
||||
deleteError.value = "";
|
||||
deleteNotice.value = "";
|
||||
};
|
||||
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 handlePhotoClick = (photo) => {
|
||||
if (selectionMode.value && photo?.canDelete) {
|
||||
togglePhotoSelection(photo);
|
||||
return;
|
||||
}
|
||||
previewPhoto(photo);
|
||||
};
|
||||
const requestDeletePhoto = (photo) => {
|
||||
if (!photo?.canDelete || deletingPhotoId.value) return;
|
||||
if (!photo?.canDelete || deleteInProgress.value) return;
|
||||
deleteError.value = "";
|
||||
deleteNotice.value = "";
|
||||
deleteTarget.value = photo;
|
||||
deleteConfirmationVisible.value = true;
|
||||
};
|
||||
@@ -142,6 +274,22 @@ const closeDeleteConfirmation = () => {
|
||||
deleteTarget.value = null;
|
||||
}
|
||||
};
|
||||
const requestBack = () => {
|
||||
if (deleteInProgress.value) return true;
|
||||
if (batchDeleteConfirmationVisible.value) {
|
||||
closeBatchDeleteConfirmation();
|
||||
return true;
|
||||
}
|
||||
if (deleteConfirmationVisible.value) {
|
||||
closeDeleteConfirmation();
|
||||
return true;
|
||||
}
|
||||
if (selectionMode.value) {
|
||||
resetPhotoSelection();
|
||||
return true;
|
||||
}
|
||||
return returnToAlbums();
|
||||
};
|
||||
const deletePhoto = async () => {
|
||||
const photo = deleteTarget.value;
|
||||
if (!photo?.canDelete || deletingPhotoId.value) return;
|
||||
@@ -163,6 +311,68 @@ const deletePhoto = async () => {
|
||||
if (isPageActive) deletingPhotoId.value = "";
|
||||
}
|
||||
};
|
||||
const requestBatchDelete = () => {
|
||||
if (!selectionMode.value || !selectedPhotoIds.value.length || deleteInProgress.value) return;
|
||||
deleteError.value = "";
|
||||
deleteNotice.value = "";
|
||||
batchDeleteConfirmationVisible.value = true;
|
||||
};
|
||||
const closeBatchDeleteConfirmation = () => {
|
||||
if (!batchDeleting.value) batchDeleteConfirmationVisible.value = false;
|
||||
};
|
||||
const deleteSelectedPhotos = async () => {
|
||||
if (!selectionMode.value || !selectedPhotoIds.value.length || deleteInProgress.value) return;
|
||||
const photoIds = selectedPhotoIds.value.slice();
|
||||
batchDeleting.value = true;
|
||||
deleteError.value = "";
|
||||
deleteNotice.value = "";
|
||||
let deletedCount = 0;
|
||||
let failedRequest = null;
|
||||
try {
|
||||
for (const photoId of photoIds) {
|
||||
const photo = photos.value.find((item) => item.id === photoId);
|
||||
if (!photo?.canDelete) continue;
|
||||
try {
|
||||
await familyMediaApi.deleteAlbumPhoto(
|
||||
genealogyId.value,
|
||||
albumId.value,
|
||||
photoId,
|
||||
{ requestController: albumPhotoDeleteController },
|
||||
);
|
||||
} catch (error) {
|
||||
failedRequest = error;
|
||||
break;
|
||||
}
|
||||
if (!isPageActive) return;
|
||||
deletedCount += 1;
|
||||
photos.value = photos.value.filter((item) => item.id !== photoId);
|
||||
selectedPhotoIds.value = selectedPhotoIds.value.filter(
|
||||
(selectedPhotoId) => selectedPhotoId !== photoId,
|
||||
);
|
||||
}
|
||||
if (!isPageActive) return;
|
||||
batchDeleteConfirmationVisible.value = false;
|
||||
if (failedRequest && !isRequestCancelled(failedRequest)) {
|
||||
const failureCopy = getRequestErrorMessage(
|
||||
failedRequest,
|
||||
"剩余照片删除失败,请稍后重试。",
|
||||
);
|
||||
deleteError.value = deletedCount
|
||||
? `已删除 ${deletedCount} 张;${failureCopy}`
|
||||
: failureCopy;
|
||||
} else if (deletedCount) {
|
||||
deleteNotice.value = `已删除 ${deletedCount} 张照片。`;
|
||||
}
|
||||
if (!photos.value.length) {
|
||||
albumPhotoListState.value = "empty";
|
||||
resetPhotoSelection();
|
||||
} else if (!selectedPhotoIds.value.length) {
|
||||
selectionMode.value = false;
|
||||
}
|
||||
} finally {
|
||||
if (isPageActive) batchDeleting.value = false;
|
||||
}
|
||||
};
|
||||
onLoad((query) => {
|
||||
genealogyId.value = String(query?.genealogyId || "");
|
||||
albumId.value = String(query?.albumId || "");
|
||||
@@ -176,6 +386,7 @@ onUnload(() => {
|
||||
albumPhotoListController.abort();
|
||||
albumPhotoDeleteController.abort();
|
||||
});
|
||||
onBackPress((event) => handleBackPress(event, requestBack));
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@@ -196,6 +407,7 @@ onUnload(() => {
|
||||
.album-state-card,
|
||||
.photo-card {
|
||||
@include adaptive-family-content;
|
||||
background-color: rgba($paper, 0.82);
|
||||
}
|
||||
.album-state-card {
|
||||
width: 100%;
|
||||
@@ -225,17 +437,80 @@ onUnload(() => {
|
||||
.photo-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16rpx;
|
||||
gap: 20rpx;
|
||||
}
|
||||
.photo-management {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 18rpx;
|
||||
padding: 22rpx 24rpx;
|
||||
border: 1rpx solid rgba(128, 89, 49, 0.28);
|
||||
border-radius: 12rpx;
|
||||
background: rgba($paper, 0.9);
|
||||
}
|
||||
.photo-management__summary {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
.photo-management__summary text {
|
||||
display: block;
|
||||
}
|
||||
.photo-management__summary text:first-child {
|
||||
color: $ink;
|
||||
font-size: clamp(15px, 24rpx, 18px);
|
||||
font-weight: 700;
|
||||
}
|
||||
.photo-management__summary text:last-child {
|
||||
margin-top: 6rpx;
|
||||
color: $ink-muted;
|
||||
font-size: clamp(13px, 21rpx, 16px);
|
||||
}
|
||||
.photo-management__actions {
|
||||
display: flex;
|
||||
flex: 0 0 auto;
|
||||
gap: 12rpx;
|
||||
}
|
||||
.photo-card {
|
||||
position: relative;
|
||||
padding: 28rpx 32rpx;
|
||||
}
|
||||
.photo-card--selected {
|
||||
border-color: rgba($brand-red, 0.62);
|
||||
box-shadow: inset 0 0 0 2rpx rgba($brand-red, 0.12);
|
||||
}
|
||||
.photo-card__selection {
|
||||
position: absolute;
|
||||
z-index: 2;
|
||||
top: 42rpx;
|
||||
right: 46rpx;
|
||||
min-width: 104rpx;
|
||||
min-height: 64rpx;
|
||||
box-sizing: border-box;
|
||||
padding: 0 18rpx;
|
||||
border: 1rpx solid rgba($brand-red, 0.48);
|
||||
border-radius: 32rpx;
|
||||
background: rgba($paper, 0.94);
|
||||
color: $brand-red;
|
||||
text-align: center;
|
||||
line-height: 62rpx;
|
||||
}
|
||||
.photo-card--selected .photo-card__selection {
|
||||
background: $brand-red;
|
||||
color: #fff;
|
||||
}
|
||||
.photo-list__notice,
|
||||
.photo-list__error {
|
||||
display: block;
|
||||
color: $brand-red;
|
||||
font-size: clamp(13px, 21rpx, 16px);
|
||||
line-height: 1.5;
|
||||
}
|
||||
.photo-list__notice {
|
||||
color: #426b58;
|
||||
}
|
||||
.photo-list__error {
|
||||
color: $brand-red;
|
||||
}
|
||||
.photo-card__image {
|
||||
display: block;
|
||||
width: 100%;
|
||||
@@ -248,19 +523,46 @@ onUnload(() => {
|
||||
justify-content: flex-end;
|
||||
margin-top: 16rpx;
|
||||
}
|
||||
.photo-card text {
|
||||
.photo-card__title,
|
||||
.photo-card__copy,
|
||||
.photo-card__meta {
|
||||
display: block;
|
||||
}
|
||||
.photo-card text:first-child {
|
||||
.photo-card__title {
|
||||
color: $ink;
|
||||
font-family: STKaiti, KaiTi, serif;
|
||||
font-size: clamp(17px, 30rpx, 22px);
|
||||
font-weight: 700;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.photo-card text:not(:first-child) {
|
||||
.photo-card__copy,
|
||||
.photo-card__meta {
|
||||
margin-top: 8rpx;
|
||||
color: $ink-muted;
|
||||
font-size: clamp(14px, 22rpx, 17px);
|
||||
line-height: 1.5;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.photo-card .photo-card__selection text {
|
||||
display: inline;
|
||||
margin: 0;
|
||||
color: inherit;
|
||||
font-family: inherit;
|
||||
font-size: clamp(13px, 21rpx, 16px);
|
||||
font-weight: 700;
|
||||
line-height: inherit;
|
||||
}
|
||||
@media (max-width: 380px) {
|
||||
.photo-management {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
}
|
||||
.photo-management__actions,
|
||||
.photo-management > .app-button {
|
||||
width: 100%;
|
||||
}
|
||||
.photo-management__actions .app-button {
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -42,6 +42,7 @@
|
||||
<text>{{ item.name }}</text>
|
||||
<text v-if="item.description">{{ item.description }}</text>
|
||||
<text>{{ item.photoCount }} 张照片</text>
|
||||
<text v-if="item.createTime">创建于 {{ formatMinuteTimestamp(item.createTime) }}</text>
|
||||
<view
|
||||
v-if="item.canEdit || item.canDelete"
|
||||
class="album-card__actions"
|
||||
@@ -118,9 +119,9 @@
|
||||
:visible="deleteConfirmationVisible"
|
||||
:close-on-mask="false"
|
||||
eyebrow="删除确认"
|
||||
title="删除这个相册?"
|
||||
message="相册中的照片也可能无法恢复,请确认已另行保存。"
|
||||
confirm-text="确认删除"
|
||||
title="将这个相册移至回收站?"
|
||||
message="相册和其中照片将不再展示,家谱管理员可在保留期内恢复。"
|
||||
confirm-text="移至回收站"
|
||||
cancel-text="保留相册"
|
||||
show-cancel
|
||||
@confirm="deleteAlbum"
|
||||
@@ -143,6 +144,7 @@ import {
|
||||
} 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 { formatMinuteTimestamp } from "@/utils/display-time.js";
|
||||
import {
|
||||
isImagePickCancelled,
|
||||
pickAndUploadImage,
|
||||
@@ -387,11 +389,12 @@ onUnload(() => {
|
||||
z-index: 1;
|
||||
}
|
||||
.album-list-content {
|
||||
padding: 18rpx 24rpx 72rpx;
|
||||
padding: 18rpx 24rpx calc(72rpx + env(safe-area-inset-bottom));
|
||||
}
|
||||
.album-state-card,
|
||||
.album-card {
|
||||
@include adaptive-family-content;
|
||||
background-color: rgba($paper, 0.82);
|
||||
}
|
||||
.album-state-card {
|
||||
min-height: 340rpx;
|
||||
@@ -434,7 +437,7 @@ onUnload(() => {
|
||||
.album-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16rpx;
|
||||
gap: 20rpx;
|
||||
}
|
||||
.album-card {
|
||||
padding: 28rpx 30rpx;
|
||||
@@ -477,7 +480,7 @@ onUnload(() => {
|
||||
font-size: clamp(15px, 24rpx, 18px);
|
||||
}
|
||||
.album-dialog-field input {
|
||||
min-height: 76rpx;
|
||||
min-height: 80rpx;
|
||||
padding-top: 0;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<view class="article-detail-page" :class="`article-state--${articleState}`">
|
||||
<ModulePageBackground module="family" />
|
||||
<view class="article-detail-header"
|
||||
><PageHeader title="谱文详情" custom-back @back="backToArticles"
|
||||
><PageHeader title="谱文详情" custom-back @back="requestBack"
|
||||
/></view>
|
||||
|
||||
<view class="article-detail-content">
|
||||
@@ -12,6 +12,15 @@
|
||||
description="请稍候,正在同步谱文正文。"
|
||||
/>
|
||||
<view v-else-if="articleState === 'ready'" class="article-card">
|
||||
<image
|
||||
v-if="article.coverFile?.accessUrl"
|
||||
class="article-card__cover"
|
||||
:src="article.coverFile.accessUrl"
|
||||
mode="aspectFill"
|
||||
role="button"
|
||||
aria-label="查看谱文封面"
|
||||
@click="previewCover"
|
||||
/>
|
||||
<text v-if="article.category" class="article-card__category">{{
|
||||
article.category
|
||||
}}</text>
|
||||
@@ -27,13 +36,17 @@
|
||||
<text>这篇谱文已设置内容密码</text>
|
||||
<input v-model="protectionPassword" password maxlength="128" placeholder="请输入8至128位内容密码" />
|
||||
<AppButton block :disabled="protectionSubmitting" :label="protectionSubmitting ? '正在验证' : '解锁并查看'" @click="unlockArticle" />
|
||||
<button class="article-lock-card__recovery" @click="passwordRecoveryVisible = true">忘记内容密码?</button>
|
||||
<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">
|
||||
<view
|
||||
v-if="article.canEdit || article.canDelete || article.canManageProtection"
|
||||
class="article-card__actions"
|
||||
>
|
||||
<AppButton
|
||||
v-if="article.canEdit && article.content"
|
||||
compact
|
||||
@@ -75,9 +88,9 @@
|
||||
:visible="deleteConfirmationVisible"
|
||||
:close-on-mask="false"
|
||||
eyebrow="删除确认"
|
||||
title="删除这篇谱文?"
|
||||
message="删除后无法恢复,请确认当前内容不再需要。"
|
||||
confirm-text="确认删除"
|
||||
title="将这篇谱文移至回收站?"
|
||||
message="移入回收站后不再展示,家谱管理员可在保留期内恢复。"
|
||||
confirm-text="移至回收站"
|
||||
cancel-text="保留谱文"
|
||||
show-cancel
|
||||
@confirm="deleteArticle"
|
||||
@@ -98,15 +111,25 @@
|
||||
<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>
|
||||
<ContentPasswordRecoveryDialog
|
||||
:visible="passwordRecoveryVisible"
|
||||
:genealogy-id="genealogyId"
|
||||
resource-type="ARTICLE"
|
||||
:resource-id="articleId"
|
||||
@close="passwordRecoveryVisible = false"
|
||||
@complete="completePasswordRecovery"
|
||||
@busy-change="passwordRecoveryBusy = $event"
|
||||
/>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref } from "vue";
|
||||
import { onLoad, onShow, onUnload } from "@dcloudio/uni-app";
|
||||
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 ContentPasswordRecoveryDialog from "@/components/ContentPasswordRecoveryDialog.vue";
|
||||
import ModulePageBackground from "@/components/ModulePageBackground.vue";
|
||||
import PageHeader from "@/components/PageHeader.vue";
|
||||
import {
|
||||
@@ -115,7 +138,7 @@ import {
|
||||
} 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";
|
||||
import { goBack, handleBackPress, openPage, returnTo } from "@/utils/navigation/gateway.js";
|
||||
|
||||
const genealogyId = ref("");
|
||||
const articleId = ref("");
|
||||
@@ -130,6 +153,8 @@ const protectionError = ref("");
|
||||
const protectionDialogVisible = ref(false);
|
||||
const protectionMode = ref("set");
|
||||
const protectionSubmitting = ref(false);
|
||||
const passwordRecoveryVisible = ref(false);
|
||||
const passwordRecoveryBusy = ref(false);
|
||||
const articleReadController = createRequestController();
|
||||
const articleProtectionController = createRequestController();
|
||||
const articleDeleteController = createRequestController();
|
||||
@@ -140,6 +165,11 @@ const hasValidContext = computed(
|
||||
() =>
|
||||
/^[1-9]\d*$/.test(genealogyId.value) && /^[1-9]\d*$/.test(articleId.value),
|
||||
);
|
||||
const previewCover = () => {
|
||||
const url = article.value?.coverFile?.accessUrl;
|
||||
if (!url || typeof uni?.previewImage !== "function") return;
|
||||
uni.previewImage({ current: url, urls: [url] });
|
||||
};
|
||||
const stateCopy = computed(() => {
|
||||
if (!hasValidContext.value) {
|
||||
return {
|
||||
@@ -241,6 +271,10 @@ const unlockArticle = async () => {
|
||||
protectionSubmitting.value = false;
|
||||
}
|
||||
};
|
||||
const completePasswordRecovery = (newPassword) => {
|
||||
protectionPassword.value = newPassword;
|
||||
protectionError.value = "内容密码已重置,请点击“解锁并查看”确认。";
|
||||
};
|
||||
const openProtectionDialog = (mode) => {
|
||||
if (!article.value?.canManageProtection || protectionSubmitting.value) return;
|
||||
protectionMode.value = mode;
|
||||
@@ -305,6 +339,23 @@ const editArticle = () =>
|
||||
const closeDeleteConfirmation = () => {
|
||||
if (!deleting.value) deleteConfirmationVisible.value = false;
|
||||
};
|
||||
const requestBack = () => {
|
||||
if (deleting.value || protectionSubmitting.value || passwordRecoveryBusy.value) return true;
|
||||
if (passwordRecoveryVisible.value) {
|
||||
passwordRecoveryVisible.value = false;
|
||||
return true;
|
||||
}
|
||||
if (protectionDialogVisible.value) {
|
||||
closeProtectionDialog();
|
||||
return true;
|
||||
}
|
||||
if (deleteConfirmationVisible.value) {
|
||||
closeDeleteConfirmation();
|
||||
return true;
|
||||
}
|
||||
return backToArticles();
|
||||
};
|
||||
onBackPress((event) => handleBackPress(event, requestBack));
|
||||
const deleteArticle = async () => {
|
||||
if (!article.value?.canDelete || deleting.value) return;
|
||||
deleting.value = true;
|
||||
@@ -352,12 +403,13 @@ const deleteArticle = async () => {
|
||||
}
|
||||
.article-detail-content {
|
||||
flex: 1;
|
||||
padding: 18rpx 24rpx 72rpx;
|
||||
padding: 18rpx 24rpx calc(72rpx + env(safe-area-inset-bottom));
|
||||
}
|
||||
.article-card,
|
||||
.article-state-card {
|
||||
@include adaptive-family-content;
|
||||
box-sizing: border-box;
|
||||
background-color: rgba($paper, 0.82);
|
||||
}
|
||||
.article-card {
|
||||
margin-top: 18rpx;
|
||||
@@ -383,6 +435,7 @@ const deleteArticle = async () => {
|
||||
font-size: clamp(20px, 40rpx, 26px);
|
||||
font-weight: 700;
|
||||
line-height: 1.32;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.article-card__meta {
|
||||
margin-top: 16rpx;
|
||||
@@ -404,6 +457,7 @@ const deleteArticle = async () => {
|
||||
color: $ink;
|
||||
font-size: clamp(16px, 28rpx, 20px);
|
||||
line-height: 1.85;
|
||||
overflow-wrap: anywhere;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
.article-card__views {
|
||||
@@ -412,6 +466,13 @@ const deleteArticle = async () => {
|
||||
font-size: clamp(13px, 21rpx, 16px);
|
||||
text-align: right;
|
||||
}
|
||||
.article-card__cover {
|
||||
width: 100%;
|
||||
height: 360rpx;
|
||||
margin-bottom: 22rpx;
|
||||
border-radius: 10rpx;
|
||||
background: rgba(128, 89, 49, 0.12);
|
||||
}
|
||||
.article-card__actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
@@ -421,8 +482,10 @@ const deleteArticle = async () => {
|
||||
}
|
||||
.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__recovery { min-height: var(--app-touch-min); margin: 4rpx auto 0; padding: 0 16rpx; border: 0; background: transparent; color: #9e251b; font-size: clamp(13px, 22rpx, 16px); }
|
||||
.article-lock-card__recovery::after { border: 0; }
|
||||
.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); }
|
||||
.protection-dialog-input { box-sizing: border-box; width: 100%; min-height: 80rpx; 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;
|
||||
|
||||
@@ -73,6 +73,7 @@
|
||||
<text v-if="coverFileName" class="upload-receipt"
|
||||
>已上传:{{ coverFileName }}</text
|
||||
>
|
||||
<button v-if="coverOssId" class="remove-cover-button" :disabled="uploading || isSubmitting" @click="clearCover">移除封面</button>
|
||||
<text v-if="uploadError" class="editor-save-error">{{
|
||||
uploadError
|
||||
}}</text>
|
||||
@@ -344,6 +345,13 @@ const uploadCover = async () => {
|
||||
}
|
||||
};
|
||||
|
||||
const clearCover = () => {
|
||||
if (uploading.value || isSubmitting.value) return;
|
||||
coverOssId.value = null;
|
||||
coverFileName.value = "";
|
||||
uploadError.value = "";
|
||||
};
|
||||
|
||||
const saveArticle = async () => {
|
||||
if (isSubmitting.value || uploading.value || !hasValidContext.value) return;
|
||||
if (!form.articleTitle.trim() || !form.articleContent.trim()) {
|
||||
@@ -440,6 +448,7 @@ onUnload(() => {
|
||||
.editor-result-card {
|
||||
@include adaptive-family-panel;
|
||||
width: 100%;
|
||||
background-color: rgba($paper, 0.82);
|
||||
}
|
||||
.editor-panel__body {
|
||||
padding: 38rpx 34rpx 42rpx;
|
||||
@@ -459,6 +468,7 @@ onUnload(() => {
|
||||
font-size: clamp(19px, 34rpx, 24px);
|
||||
font-weight: 700;
|
||||
text-align: center;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.editor-intro {
|
||||
display: block;
|
||||
@@ -555,6 +565,18 @@ onUnload(() => {
|
||||
font-size: clamp(13px, 21rpx, 16px);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.remove-cover-button {
|
||||
justify-self: start;
|
||||
min-height: 72rpx;
|
||||
margin: 0;
|
||||
padding: 0 18rpx;
|
||||
border: 1rpx solid rgba($brand-red, 0.38);
|
||||
border-radius: 8rpx;
|
||||
background: transparent;
|
||||
color: $brand-red;
|
||||
font-size: clamp(13px, 21rpx, 16px);
|
||||
}
|
||||
.remove-cover-button::after { border: 0; }
|
||||
.editor-placeholder {
|
||||
color: #9e8e79;
|
||||
}
|
||||
|
||||
@@ -8,6 +8,9 @@
|
||||
@action="createArticle"
|
||||
/></view>
|
||||
<view class="article-list-content">
|
||||
<view v-if="articleCategoryError" class="article-category-error" role="alert">
|
||||
<text>{{ articleCategoryError }}</text>
|
||||
</view>
|
||||
<view v-if="categoryOptions.length > 1" class="article-filter">
|
||||
<text>文章分类</text>
|
||||
<picker :range="categoryLabels" :value="categoryIndex" @change="selectCategory">
|
||||
@@ -21,6 +24,13 @@
|
||||
class="article-card"
|
||||
@click="openArticle(item)"
|
||||
>
|
||||
<image
|
||||
v-if="item.coverFile?.accessUrl"
|
||||
class="article-card__cover"
|
||||
:src="item.coverFile.accessUrl"
|
||||
mode="aspectFill"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<text class="article-card__title">{{ item.title }}</text>
|
||||
<text class="article-card__summary">{{
|
||||
item.summary || item.content
|
||||
@@ -59,6 +69,7 @@ import {
|
||||
createRequestController,
|
||||
isRequestCancelled
|
||||
} from "@/services/api/request-controller.js";
|
||||
import { getRequestErrorMessage } from "@/services/api/request-error-message.js";
|
||||
import { familyArticleApi } from "@/services/api/family-article-service.js";
|
||||
import { goBack, openPage } from "@/utils/navigation/gateway.js";
|
||||
|
||||
@@ -67,6 +78,7 @@ const hasValidContext = ref(false);
|
||||
const listState = ref("loading");
|
||||
const articles = ref([]);
|
||||
const categories = ref([]);
|
||||
const articleCategoryError = ref("");
|
||||
const selectedCategoryId = ref("");
|
||||
const articleListRequestController = createRequestController();
|
||||
const articleCategoryRequestController = createRequestController();
|
||||
@@ -130,13 +142,18 @@ onUnload(() => {
|
||||
articleCategoryRequestController.abort();
|
||||
});
|
||||
const loadArticleCategories = async () => {
|
||||
articleCategoryError.value = "";
|
||||
try {
|
||||
return await familyArticleApi.getArticleCategories(genealogyId.value, {
|
||||
requestController: articleCategoryRequestController,
|
||||
});
|
||||
} catch (error) {
|
||||
if (isRequestCancelled(error)) throw error;
|
||||
return [];
|
||||
articleCategoryError.value = getRequestErrorMessage(
|
||||
error,
|
||||
"谱文分类暂时无法读取,当前仍可查看全部谱文。",
|
||||
);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
const loadArticles = async () => {
|
||||
@@ -152,7 +169,7 @@ const loadArticles = async () => {
|
||||
]);
|
||||
if (!pageActive) return;
|
||||
articles.value = rows;
|
||||
categories.value = categoryRows;
|
||||
if (categoryRows) categories.value = categoryRows;
|
||||
if (!categoryOptions.value.some((item) => item.id === selectedCategoryId.value)) {
|
||||
selectedCategoryId.value = "";
|
||||
}
|
||||
@@ -192,11 +209,20 @@ const handleStateAction = () => {
|
||||
z-index: 1;
|
||||
}
|
||||
.article-list-content {
|
||||
padding: 18rpx 24rpx 72rpx;
|
||||
padding: 18rpx 24rpx calc(72rpx + env(safe-area-inset-bottom));
|
||||
}
|
||||
.article-list-items {
|
||||
margin-top: 24rpx;
|
||||
}
|
||||
.article-category-error {
|
||||
margin-top: 20rpx;
|
||||
padding: 18rpx 22rpx;
|
||||
border: 1rpx solid rgba(159, 35, 35, 0.22);
|
||||
background: rgba(255, 247, 233, 0.94);
|
||||
color: $brand-red;
|
||||
font-size: clamp(13px, 22rpx, 16px);
|
||||
line-height: 1.6;
|
||||
}
|
||||
.article-filter {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -207,6 +233,8 @@ const handleStateAction = () => {
|
||||
background: rgba(255, 252, 242, 0.92);
|
||||
color: $ink;
|
||||
font-size: clamp(14px, 24rpx, 17px);
|
||||
min-height: 80rpx;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.article-filter__value {
|
||||
color: $brand-red;
|
||||
@@ -235,6 +263,14 @@ const handleStateAction = () => {
|
||||
box-sizing: border-box;
|
||||
margin-bottom: 16rpx;
|
||||
padding: 28rpx;
|
||||
background-color: rgba($paper, 0.82);
|
||||
}
|
||||
.article-card__cover {
|
||||
width: 100%;
|
||||
height: 280rpx;
|
||||
margin-bottom: 20rpx;
|
||||
border-radius: 10rpx;
|
||||
background: rgba(128, 89, 49, 0.12);
|
||||
}
|
||||
.article-card text {
|
||||
display: block;
|
||||
@@ -244,6 +280,7 @@ const handleStateAction = () => {
|
||||
font-family: STKaiti, KaiTi, serif;
|
||||
font-size: clamp(17px, 31rpx, 22px);
|
||||
font-weight: 700;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.article-card__summary {
|
||||
display: -webkit-box;
|
||||
@@ -263,6 +300,7 @@ const handleStateAction = () => {
|
||||
}
|
||||
.article-list-state-card {
|
||||
@include adaptive-family-content;
|
||||
background-color: rgba($paper, 0.82);
|
||||
width: 100%;
|
||||
min-height: 340rpx;
|
||||
margin-top: 30rpx;
|
||||
|
||||
@@ -51,6 +51,7 @@
|
||||
@click="openEditFeed"
|
||||
/>
|
||||
<AppButton
|
||||
v-if="feed.canDelete"
|
||||
compact
|
||||
type="secondary"
|
||||
label="删除动态"
|
||||
@@ -86,9 +87,9 @@
|
||||
:visible="deleteConfirmationVisible"
|
||||
:close-on-mask="false"
|
||||
eyebrow="删除确认"
|
||||
title="删除这条动态?"
|
||||
message="删除后无法恢复,请确认当前内容不再需要。"
|
||||
confirm-text="确认删除"
|
||||
title="将这条动态移至回收站?"
|
||||
message="移入回收站后不再展示,家谱管理员可在保留期内恢复。"
|
||||
confirm-text="移至回收站"
|
||||
cancel-text="保留动态"
|
||||
show-cancel
|
||||
@confirm="deleteFeed"
|
||||
@@ -313,7 +314,7 @@ onBackPress((event) => handleBackPress(event, requestBack));
|
||||
}
|
||||
.feed-detail-content {
|
||||
flex: 1;
|
||||
padding: 18rpx 24rpx 72rpx;
|
||||
padding: 18rpx 24rpx calc(72rpx + env(safe-area-inset-bottom));
|
||||
}
|
||||
.feed-detail-body {
|
||||
margin-top: 18rpx;
|
||||
@@ -322,6 +323,7 @@ onBackPress((event) => handleBackPress(event, requestBack));
|
||||
.feed-state-card {
|
||||
@include adaptive-family-content;
|
||||
box-sizing: border-box;
|
||||
background-color: rgba($paper, 0.82);
|
||||
}
|
||||
.feed-card {
|
||||
padding: 30rpx;
|
||||
@@ -348,6 +350,7 @@ onBackPress((event) => handleBackPress(event, requestBack));
|
||||
color: $ink;
|
||||
font-size: clamp(16px, 29rpx, 20px);
|
||||
line-height: 1.7;
|
||||
overflow-wrap: anywhere;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
.feed-card__meta {
|
||||
|
||||
@@ -365,14 +365,15 @@ onUnload(() => {
|
||||
}
|
||||
.publish-page__header,
|
||||
.publish-panel {
|
||||
@include adaptive-family-content;
|
||||
z-index: 1;
|
||||
}
|
||||
.publish-panel {
|
||||
@include adaptive-family-content;
|
||||
width: calc(100% - 32rpx);
|
||||
margin: 18rpx auto 0;
|
||||
margin: 18rpx auto calc(48rpx + env(safe-area-inset-bottom));
|
||||
padding: 9%;
|
||||
box-sizing: border-box;
|
||||
background-color: rgba($paper, 0.82);
|
||||
}
|
||||
.publish-form > text,
|
||||
.publish-result > text {
|
||||
@@ -421,11 +422,11 @@ onUnload(() => {
|
||||
font-size: clamp(15px, 24rpx, 18px);
|
||||
}
|
||||
.publish-field input {
|
||||
min-height: 64rpx;
|
||||
min-height: 80rpx;
|
||||
}
|
||||
.publish-field__value--readonly {
|
||||
display: flex;
|
||||
min-height: 76rpx;
|
||||
min-height: 80rpx;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-top: 12rpx;
|
||||
@@ -459,6 +460,7 @@ onUnload(() => {
|
||||
}
|
||||
.upload-button {
|
||||
justify-self: start;
|
||||
min-height: 80rpx;
|
||||
margin: 0;
|
||||
padding: 0 20rpx;
|
||||
border: 1rpx solid rgba(159, 23, 15, 0.42);
|
||||
|
||||
+18
-8
@@ -113,6 +113,7 @@ const shortcuts = [
|
||||
{ key: "albums", label: "相册" },
|
||||
{ key: "rituals", label: "礼仪" },
|
||||
{ key: "memos", label: "备忘" },
|
||||
{ key: "benefactors", label: "家族恩人" },
|
||||
{ key: "people", label: "人物录" },
|
||||
{ key: "gifts", label: "贺礼簿" },
|
||||
{ key: "merits", label: "功德录" },
|
||||
@@ -239,12 +240,20 @@ const openSection = (key) => {
|
||||
albums: "F07",
|
||||
rituals: "R05",
|
||||
memos: "R10",
|
||||
benefactors: "R10",
|
||||
people: "R01",
|
||||
gifts: "R03",
|
||||
merits: "R11",
|
||||
videos: "F10",
|
||||
};
|
||||
return openPage(routes[key], { genealogyId: genealogyId.value }, "F01");
|
||||
return openPage(
|
||||
routes[key],
|
||||
{
|
||||
genealogyId: genealogyId.value,
|
||||
...(key === "benefactors" ? { memoType: "benefactor" } : {}),
|
||||
},
|
||||
"F01",
|
||||
);
|
||||
};
|
||||
const handlePrimaryAction = () =>
|
||||
feedState.value === "error" ? loadFeeds() : toPublish();
|
||||
@@ -264,7 +273,7 @@ const handlePrimaryAction = () =>
|
||||
}
|
||||
.feed-content {
|
||||
flex: 1;
|
||||
padding: 24rpx 24rpx 190rpx;
|
||||
padding: 24rpx 24rpx calc(190rpx + env(safe-area-inset-bottom));
|
||||
}
|
||||
.feed-heading text {
|
||||
display: block;
|
||||
@@ -283,8 +292,8 @@ const handlePrimaryAction = () =>
|
||||
.feed-shortcuts {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 8rpx;
|
||||
margin-top: 15rpx;
|
||||
gap: 12rpx;
|
||||
margin-top: 18rpx;
|
||||
}
|
||||
.feed-shortcut {
|
||||
box-sizing: border-box;
|
||||
@@ -312,6 +321,7 @@ const handlePrimaryAction = () =>
|
||||
box-sizing: border-box;
|
||||
margin-top: 16rpx;
|
||||
padding: 30rpx;
|
||||
background-color: rgba($paper, 0.82);
|
||||
}
|
||||
.feed-card text,
|
||||
.feed-state-card text {
|
||||
@@ -353,14 +363,14 @@ const handlePrimaryAction = () =>
|
||||
display: block;
|
||||
width: 420rpx;
|
||||
max-width: 100%;
|
||||
min-height: 76rpx;
|
||||
min-height: 80rpx;
|
||||
margin: 22rpx auto 0;
|
||||
padding: 0 24rpx;
|
||||
border: 0;
|
||||
color: $ink;
|
||||
font-size: clamp(14px, 22rpx, 17px);
|
||||
font-weight: 700;
|
||||
line-height: 76rpx;
|
||||
line-height: 80rpx;
|
||||
}
|
||||
.feed-more::after {
|
||||
border: 0;
|
||||
@@ -390,12 +400,12 @@ const handlePrimaryAction = () =>
|
||||
@include adaptive-scroll-button(primary);
|
||||
width: 514rpx;
|
||||
max-width: 100%;
|
||||
min-height: 76rpx;
|
||||
min-height: 80rpx;
|
||||
margin: 19rpx auto;
|
||||
}
|
||||
.feed-action text {
|
||||
display: flex;
|
||||
min-height: 76rpx;
|
||||
min-height: 80rpx;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #fff9ed;
|
||||
|
||||
@@ -0,0 +1,550 @@
|
||||
<template>
|
||||
<view class="platform-video-page">
|
||||
<ModulePageBackground module="family" />
|
||||
<view class="page-header">
|
||||
<PageHeader title="宣传视频" custom-back @back="requestBack" />
|
||||
</view>
|
||||
|
||||
<view class="page-content">
|
||||
<view v-if="pageState === 'loading'" class="state-card">
|
||||
<AppLoading text="正在加载平台视频" />
|
||||
</view>
|
||||
<view v-else-if="pageState === 'error'" class="state-card">
|
||||
<text>{{ pageError }}</text>
|
||||
<AppButton block label="重新加载" @click="loadPlatformVideos" />
|
||||
</view>
|
||||
<view v-else-if="!videos.length" class="state-card">
|
||||
<text>暂时没有可观看的平台视频</text>
|
||||
</view>
|
||||
|
||||
<view v-else class="video-list">
|
||||
<AppButton
|
||||
block
|
||||
type="secondary"
|
||||
label="上下滑动观看"
|
||||
@click="openVerticalViewer(videos[0])"
|
||||
/>
|
||||
<view v-for="video in videos" :key="video.id" class="video-card">
|
||||
<view
|
||||
v-if="video.coverFile?.accessUrl"
|
||||
class="video-card__cover-button"
|
||||
role="button"
|
||||
:aria-label="`播放${video.title}`"
|
||||
hover-class="action-hover"
|
||||
@click="openVerticalViewer(video)"
|
||||
>
|
||||
<image
|
||||
:src="video.coverFile.accessUrl"
|
||||
mode="aspectFill"
|
||||
class="video-card__cover"
|
||||
/>
|
||||
<view class="video-card__play" aria-hidden="true"></view>
|
||||
</view>
|
||||
<video
|
||||
v-else
|
||||
:src="video.videoFile.accessUrl"
|
||||
controls
|
||||
class="video-card__player"
|
||||
/>
|
||||
<text class="video-card__title">{{ video.title }}</text>
|
||||
<text v-if="video.description" class="video-card__copy">{{ video.description }}</text>
|
||||
<text v-if="video.startAt" class="video-card__meta">发布时间:{{ video.startAt }}</text>
|
||||
<view class="video-card__actions">
|
||||
<AppButton
|
||||
compact
|
||||
type="secondary"
|
||||
:disabled="Boolean(actionKey)"
|
||||
label="沉浸观看"
|
||||
@click="openVerticalViewer(video)"
|
||||
/>
|
||||
<AppButton
|
||||
compact
|
||||
type="secondary"
|
||||
:disabled="Boolean(actionKey)"
|
||||
:label="video.likedByCurrentUser ? `已赞 ${video.likeCount}` : `点赞 ${video.likeCount}`"
|
||||
@click="togglePlatformVideoLike(video)"
|
||||
/>
|
||||
<AppButton
|
||||
compact
|
||||
type="secondary"
|
||||
:disabled="Boolean(actionKey)"
|
||||
:label="`评论 ${video.commentCount}`"
|
||||
@click="openPlatformVideoComments(video)"
|
||||
/>
|
||||
</view>
|
||||
<text
|
||||
v-if="actionErrorVideoId === video.id"
|
||||
class="action-error"
|
||||
role="alert"
|
||||
>{{ actionError }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<AppDialog
|
||||
:visible="Boolean(commentTarget)"
|
||||
eyebrow="视频评论"
|
||||
:title="commentTarget?.title || '平台视频'"
|
||||
:confirm-text="actionKey === 'send-comment' ? '正在发送' : '发表评论'"
|
||||
cancel-text="关闭"
|
||||
show-cancel
|
||||
:close-on-mask="false"
|
||||
@confirm="sendPlatformVideoComment"
|
||||
@cancel="closePlatformVideoComments"
|
||||
>
|
||||
<view v-if="comments.length" class="comment-list">
|
||||
<view v-for="comment in comments" :key="comment.id" class="comment-row">
|
||||
<text>{{ comment.author }}:{{ comment.content }}</text>
|
||||
<text v-if="comment.time" class="comment-row__time">{{ comment.time }}</text>
|
||||
<button
|
||||
v-if="comment.canDelete"
|
||||
class="comment-row__delete"
|
||||
:disabled="Boolean(actionKey)"
|
||||
@click="requestDeletePlatformVideoComment(comment)"
|
||||
>删除</button>
|
||||
</view>
|
||||
</view>
|
||||
<text v-else class="comments-empty">还没有评论,可以先说说你的看法。</text>
|
||||
<text v-if="commentError" class="action-error" role="alert">{{ commentError }}</text>
|
||||
<textarea
|
||||
v-model="commentText"
|
||||
maxlength="1000"
|
||||
placeholder="说说你的看法"
|
||||
class="comment-input"
|
||||
@input="commentError = ''"
|
||||
/>
|
||||
</AppDialog>
|
||||
<VerticalVideoViewer
|
||||
:visible="verticalViewerVisible"
|
||||
:videos="videos"
|
||||
:initial-video-id="verticalViewerInitialId"
|
||||
title="宣传视频"
|
||||
:action-busy="Boolean(actionKey)"
|
||||
@close="closeVerticalViewer"
|
||||
@like="togglePlatformVideoLike"
|
||||
@comments="openVerticalViewerComments"
|
||||
/>
|
||||
|
||||
<AppDialog
|
||||
:visible="Boolean(commentDeleteTarget)"
|
||||
eyebrow="评论管理"
|
||||
title="删除这条评论?"
|
||||
message="删除后将按服务端规则保留占位或移除内容。"
|
||||
:confirm-text="actionKey === 'delete-comment' ? '正在删除' : '确认删除'"
|
||||
cancel-text="保留评论"
|
||||
show-cancel
|
||||
:close-on-mask="false"
|
||||
@confirm="deletePlatformVideoComment"
|
||||
@cancel="closePlatformVideoCommentDelete"
|
||||
/>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { 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 VerticalVideoViewer from "@/components/family/VerticalVideoViewer.vue";
|
||||
import { PLATFORM_VIDEO_PLACEMENT } from "@/services/api/family-media-contract.js";
|
||||
import { genealogyCapabilityApi } from "@/services/api/genealogy-capability-service.js";
|
||||
import {
|
||||
createRequestController,
|
||||
isRequestCancelled,
|
||||
} from "@/services/api/request-controller.js";
|
||||
import { getRequestErrorMessage } from "@/services/api/request-error-message.js";
|
||||
import { createNonIdempotentWriteGuard } from "@/utils/non-idempotent-write-guard.js";
|
||||
import { goBack, handleBackPress } from "@/utils/navigation/gateway.js";
|
||||
|
||||
const placement = ref(PLATFORM_VIDEO_PLACEMENT.VIDEO_CENTER);
|
||||
const requestedVideoId = ref("");
|
||||
const videos = ref([]);
|
||||
const verticalViewerVisible = ref(false);
|
||||
const verticalViewerInitialId = ref("");
|
||||
const pageState = ref("loading");
|
||||
const pageError = ref("");
|
||||
const actionKey = ref("");
|
||||
const actionErrorVideoId = ref("");
|
||||
const actionError = ref("");
|
||||
const commentTarget = ref(null);
|
||||
const comments = ref([]);
|
||||
const commentText = ref("");
|
||||
const commentError = ref("");
|
||||
const commentDeleteTarget = ref(null);
|
||||
const platformVideoListController = createRequestController();
|
||||
const platformVideoLikeController = createRequestController();
|
||||
const platformVideoCommentListController = createRequestController();
|
||||
const platformVideoCommentWriteController = createRequestController();
|
||||
const platformVideoCommentDeleteController = createRequestController();
|
||||
const platformVideoCommentGuard = createNonIdempotentWriteGuard();
|
||||
let pageActive = true;
|
||||
|
||||
const openVerticalViewer = (video) => {
|
||||
if (!video || actionKey.value) return;
|
||||
verticalViewerInitialId.value = String(video.id);
|
||||
verticalViewerVisible.value = true;
|
||||
};
|
||||
const closeVerticalViewer = () => {
|
||||
verticalViewerVisible.value = false;
|
||||
};
|
||||
const openVerticalViewerComments = (video) => {
|
||||
closeVerticalViewer();
|
||||
return openPlatformVideoComments(video);
|
||||
};
|
||||
|
||||
const loadPlatformVideos = async () => {
|
||||
platformVideoListController.abort();
|
||||
pageState.value = "loading";
|
||||
pageError.value = "";
|
||||
try {
|
||||
const rows = await genealogyCapabilityApi.getPlatformVideos(
|
||||
placement.value,
|
||||
{ requestController: platformVideoListController },
|
||||
);
|
||||
if (!pageActive) return;
|
||||
videos.value = rows;
|
||||
pageState.value = "ready";
|
||||
if (requestedVideoId.value) {
|
||||
const requestedVideo = rows.find(
|
||||
(video) => String(video.id) === requestedVideoId.value,
|
||||
);
|
||||
requestedVideoId.value = "";
|
||||
if (requestedVideo) openVerticalViewer(requestedVideo);
|
||||
}
|
||||
} catch (error) {
|
||||
if (!pageActive || isRequestCancelled(error)) return;
|
||||
pageError.value = getRequestErrorMessage(error, "平台视频暂时无法读取。");
|
||||
pageState.value = "error";
|
||||
}
|
||||
};
|
||||
|
||||
const togglePlatformVideoLike = async (video) => {
|
||||
if (actionKey.value) return;
|
||||
const liked = !video.likedByCurrentUser;
|
||||
actionKey.value = `like-${video.id}`;
|
||||
actionErrorVideoId.value = "";
|
||||
actionError.value = "";
|
||||
try {
|
||||
await genealogyCapabilityApi.setPlatformVideoLike(
|
||||
video.id,
|
||||
liked,
|
||||
{ requestController: platformVideoLikeController },
|
||||
);
|
||||
if (!pageActive) return;
|
||||
video.likedByCurrentUser = liked;
|
||||
video.likeCount = Math.max(0, video.likeCount + (liked ? 1 : -1));
|
||||
} catch (error) {
|
||||
if (!pageActive || isRequestCancelled(error)) return;
|
||||
actionErrorVideoId.value = video.id;
|
||||
actionError.value = getRequestErrorMessage(error, "点赞失败,请稍后重试。");
|
||||
} finally {
|
||||
if (pageActive) actionKey.value = "";
|
||||
}
|
||||
};
|
||||
|
||||
const openPlatformVideoComments = async (video) => {
|
||||
if (actionKey.value) return;
|
||||
platformVideoCommentListController.abort();
|
||||
actionKey.value = `comments-${video.id}`;
|
||||
actionErrorVideoId.value = "";
|
||||
actionError.value = "";
|
||||
try {
|
||||
const rows = await genealogyCapabilityApi.getPlatformVideoComments(
|
||||
video.id,
|
||||
{ requestController: platformVideoCommentListController },
|
||||
);
|
||||
if (!pageActive) return;
|
||||
comments.value = rows;
|
||||
commentTarget.value = video;
|
||||
commentText.value = "";
|
||||
commentError.value = "";
|
||||
} catch (error) {
|
||||
if (!pageActive || isRequestCancelled(error)) return;
|
||||
actionErrorVideoId.value = video.id;
|
||||
actionError.value = getRequestErrorMessage(error, "评论暂时无法读取。");
|
||||
} finally {
|
||||
if (pageActive) actionKey.value = "";
|
||||
}
|
||||
};
|
||||
|
||||
const closePlatformVideoComments = () => {
|
||||
if (["send-comment", "delete-comment"].includes(actionKey.value)) return;
|
||||
commentTarget.value = null;
|
||||
commentDeleteTarget.value = null;
|
||||
comments.value = [];
|
||||
commentText.value = "";
|
||||
commentError.value = "";
|
||||
};
|
||||
|
||||
const sendPlatformVideoComment = async () => {
|
||||
const commentContent = commentText.value.trim();
|
||||
if (!commentContent || !commentTarget.value || actionKey.value) return;
|
||||
const commentPayload = {
|
||||
videoId: commentTarget.value.id,
|
||||
commentContent,
|
||||
};
|
||||
const commentAttempt = platformVideoCommentGuard.begin(commentPayload);
|
||||
if (commentAttempt === null) {
|
||||
commentError.value = "上次评论发送结果待确认,请重新打开评论列表检查,避免重复发布。";
|
||||
return;
|
||||
}
|
||||
actionKey.value = "send-comment";
|
||||
commentError.value = "";
|
||||
try {
|
||||
const createdComment = await genealogyCapabilityApi.createPlatformVideoComment(
|
||||
commentPayload.videoId,
|
||||
commentPayload.commentContent,
|
||||
{ requestController: platformVideoCommentWriteController },
|
||||
);
|
||||
if (!pageActive || !commentTarget.value) return;
|
||||
comments.value.push(createdComment);
|
||||
commentTarget.value.commentCount += 1;
|
||||
commentText.value = "";
|
||||
} catch (error) {
|
||||
const isOutcomeUnknown = platformVideoCommentGuard.recordFailure(commentAttempt, error);
|
||||
if (!pageActive || !commentTarget.value) return;
|
||||
commentError.value = isOutcomeUnknown
|
||||
? "评论发送结果待确认,请重新打开评论列表检查,避免重复发布。"
|
||||
: getRequestErrorMessage(error, "评论发送失败,请稍后重试。");
|
||||
} finally {
|
||||
if (pageActive) actionKey.value = "";
|
||||
}
|
||||
};
|
||||
|
||||
const requestDeletePlatformVideoComment = (comment) => {
|
||||
if (!comment?.canDelete || actionKey.value) return;
|
||||
commentError.value = "";
|
||||
commentDeleteTarget.value = comment;
|
||||
};
|
||||
|
||||
const closePlatformVideoCommentDelete = () => {
|
||||
if (actionKey.value !== "delete-comment") commentDeleteTarget.value = null;
|
||||
};
|
||||
|
||||
const deletePlatformVideoComment = async () => {
|
||||
if (!commentTarget.value || !commentDeleteTarget.value?.canDelete || actionKey.value) return;
|
||||
const target = commentDeleteTarget.value;
|
||||
actionKey.value = "delete-comment";
|
||||
commentError.value = "";
|
||||
try {
|
||||
await genealogyCapabilityApi.deletePlatformVideoComment(
|
||||
commentTarget.value.id,
|
||||
target.id,
|
||||
{ requestController: platformVideoCommentDeleteController },
|
||||
);
|
||||
if (!pageActive || !commentTarget.value) return;
|
||||
comments.value = await genealogyCapabilityApi.getPlatformVideoComments(
|
||||
commentTarget.value.id,
|
||||
{ requestController: platformVideoCommentListController },
|
||||
);
|
||||
commentTarget.value.commentCount = comments.value.length;
|
||||
commentDeleteTarget.value = null;
|
||||
} catch (error) {
|
||||
if (!pageActive || isRequestCancelled(error)) return;
|
||||
commentError.value = getRequestErrorMessage(error, "评论删除失败,请稍后重试。");
|
||||
commentDeleteTarget.value = null;
|
||||
} finally {
|
||||
if (pageActive) actionKey.value = "";
|
||||
}
|
||||
};
|
||||
|
||||
const requestBack = () => {
|
||||
if (verticalViewerVisible.value) {
|
||||
closeVerticalViewer();
|
||||
return true;
|
||||
}
|
||||
if (commentDeleteTarget.value) {
|
||||
closePlatformVideoCommentDelete();
|
||||
return true;
|
||||
}
|
||||
if (commentTarget.value) {
|
||||
closePlatformVideoComments();
|
||||
return true;
|
||||
}
|
||||
return goBack();
|
||||
};
|
||||
|
||||
onLoad((query) => {
|
||||
if (Object.values(PLATFORM_VIDEO_PLACEMENT).includes(query?.placement)) {
|
||||
placement.value = query.placement;
|
||||
}
|
||||
requestedVideoId.value = String(query?.videoId || "");
|
||||
void loadPlatformVideos();
|
||||
});
|
||||
onBackPress((event) => handleBackPress(event, requestBack));
|
||||
onUnload(() => {
|
||||
pageActive = false;
|
||||
platformVideoListController.abort();
|
||||
platformVideoLikeController.abort();
|
||||
platformVideoCommentListController.abort();
|
||||
platformVideoCommentWriteController.abort();
|
||||
platformVideoCommentDeleteController.abort();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.platform-video-page {
|
||||
min-height: 100vh;
|
||||
color: $ink;
|
||||
}
|
||||
.page-header,
|
||||
.page-content {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
.page-content {
|
||||
padding: 24rpx 28rpx calc(72rpx + env(safe-area-inset-bottom));
|
||||
}
|
||||
.state-card,
|
||||
.video-card {
|
||||
border: 1rpx solid rgba($gold, .38);
|
||||
border-radius: 16rpx;
|
||||
background: rgba(255, 252, 245, .92);
|
||||
}
|
||||
.state-card {
|
||||
padding: 48rpx 28rpx;
|
||||
text-align: center;
|
||||
}
|
||||
.state-card text {
|
||||
display: block;
|
||||
color: $ink-muted;
|
||||
}
|
||||
.state-card .app-button {
|
||||
margin-top: 20rpx;
|
||||
}
|
||||
.video-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20rpx;
|
||||
}
|
||||
.video-card {
|
||||
padding: 28rpx;
|
||||
}
|
||||
.video-card__player {
|
||||
width: 100%;
|
||||
height: 360rpx;
|
||||
border-radius: 10rpx;
|
||||
background: #1f1b17;
|
||||
}
|
||||
.video-card__cover-button {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 360rpx;
|
||||
border-radius: 10rpx;
|
||||
background: #1f1b17;
|
||||
overflow: hidden;
|
||||
}
|
||||
.video-card__cover {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
.video-card__play {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
display: flex;
|
||||
width: 76rpx;
|
||||
height: 76rpx;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 2rpx solid rgba(255, 255, 255, .9);
|
||||
border-radius: 50%;
|
||||
background: rgba(31, 27, 23, .64);
|
||||
transform: translate(-50%, -50%);
|
||||
}
|
||||
.video-card__play::after {
|
||||
width: 0;
|
||||
height: 0;
|
||||
margin-left: 6rpx;
|
||||
border-top: 13rpx solid transparent;
|
||||
border-bottom: 13rpx solid transparent;
|
||||
border-left: 20rpx solid #fff;
|
||||
content: "";
|
||||
}
|
||||
.video-card__title,
|
||||
.video-card__copy,
|
||||
.video-card__meta,
|
||||
.comments-empty,
|
||||
.action-error,
|
||||
.comment-row text {
|
||||
display: block;
|
||||
}
|
||||
.video-card__title {
|
||||
margin-top: 16rpx;
|
||||
font-size: clamp(18px, 30rpx, 22px);
|
||||
font-weight: 700;
|
||||
}
|
||||
.video-card__copy,
|
||||
.video-card__meta {
|
||||
margin-top: 10rpx;
|
||||
color: $ink-muted;
|
||||
line-height: 1.55;
|
||||
}
|
||||
.video-card__meta {
|
||||
font-size: clamp(12px, 21rpx, 15px);
|
||||
}
|
||||
.video-card__actions {
|
||||
display: flex;
|
||||
gap: 16rpx;
|
||||
margin-top: 20rpx;
|
||||
}
|
||||
.comment-list {
|
||||
width: 100%;
|
||||
max-height: 440rpx;
|
||||
overflow-y: auto;
|
||||
text-align: left;
|
||||
}
|
||||
.comment-row {
|
||||
position: relative;
|
||||
padding: 14rpx 96rpx 14rpx 0;
|
||||
border-bottom: 1rpx solid rgba($gold, .2);
|
||||
color: $ink;
|
||||
line-height: 1.55;
|
||||
}
|
||||
.comment-row__time {
|
||||
margin-top: 4rpx;
|
||||
color: $ink-muted;
|
||||
font-size: clamp(12px, 20rpx, 14px);
|
||||
}
|
||||
.comment-row__delete {
|
||||
position: absolute;
|
||||
top: 8rpx;
|
||||
right: 0;
|
||||
min-height: var(--app-touch-min);
|
||||
margin: 0;
|
||||
padding: 0 10rpx;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: $brand-red;
|
||||
font-size: clamp(12px, 20rpx, 14px);
|
||||
line-height: var(--app-touch-min);
|
||||
}
|
||||
.comment-row__delete::after {
|
||||
border: 0;
|
||||
}
|
||||
.comments-empty {
|
||||
width: 100%;
|
||||
margin-top: 18rpx;
|
||||
color: $ink-muted;
|
||||
text-align: left;
|
||||
}
|
||||
.action-error {
|
||||
margin-top: 14rpx;
|
||||
color: $brand-red-dark;
|
||||
font-size: clamp(13px, 22rpx, 16px);
|
||||
line-height: 1.5;
|
||||
}
|
||||
.comment-input {
|
||||
width: 100%;
|
||||
min-height: 120rpx;
|
||||
margin-top: 18rpx;
|
||||
padding: 16rpx;
|
||||
border: 1rpx solid rgba($gold, .38);
|
||||
border-radius: 10rpx;
|
||||
box-sizing: border-box;
|
||||
text-align: left;
|
||||
}
|
||||
</style>
|
||||
+532
-18
@@ -6,7 +6,7 @@
|
||||
title="家族视频"
|
||||
:action="pageState === 'list' ? '发布' : ''"
|
||||
custom-back
|
||||
@back="returnToFamily"
|
||||
@back="requestBack"
|
||||
@action="openPublishForm"
|
||||
/>
|
||||
</view>
|
||||
@@ -64,6 +64,7 @@
|
||||
<text v-if="coverReceipt" class="upload-receipt"
|
||||
>已上传:{{ coverReceipt.fileName || "视频封面" }}</text
|
||||
>
|
||||
<button v-if="coverReceipt" class="remove-cover-button" :disabled="coverUploading || submitting" @click="clearCover">移除封面</button>
|
||||
</view>
|
||||
<text v-if="coverUploadError" class="field-error">{{
|
||||
coverUploadError
|
||||
@@ -106,6 +107,7 @@
|
||||
</view>
|
||||
|
||||
<view v-else-if="pageState === 'list'" class="video-list-panel">
|
||||
<AppButton block type="secondary" label="观看平台视频" @click="openPlatformVideos" />
|
||||
<view v-if="videoListState === 'loading'" class="video-state-card">
|
||||
<AppLoading text="正在读取家族视频" />
|
||||
</view>
|
||||
@@ -127,12 +129,25 @@
|
||||
<AppButton block label="发布视频" @click="openPublishForm" />
|
||||
</view>
|
||||
<view v-else class="video-card-list">
|
||||
<AppButton
|
||||
block
|
||||
type="secondary"
|
||||
label="上下滑动观看"
|
||||
@click="openVerticalViewer(videos[0])"
|
||||
/>
|
||||
<view v-for="video in videos" :key="video.id" class="video-card">
|
||||
<video
|
||||
class="video-card__player"
|
||||
:src="video.videoFile.accessUrl"
|
||||
controls
|
||||
/>
|
||||
<button
|
||||
class="video-card__cover-action"
|
||||
:aria-label="`播放${video.title}`"
|
||||
@click="openVerticalViewer(video)"
|
||||
>
|
||||
<image
|
||||
class="video-card__cover"
|
||||
:src="video.coverFile?.accessUrl || '/static/assets/modules/genealogy/transparent/empty-panel-frame.png'"
|
||||
mode="aspectFill"
|
||||
/>
|
||||
<text class="video-card__play-copy">点击播放</text>
|
||||
</button>
|
||||
<text class="video-card__title">{{ video.title }}</text>
|
||||
<text v-if="video.description" class="video-card__copy">{{
|
||||
video.description
|
||||
@@ -152,6 +167,7 @@
|
||||
@click="openEditVideo(video)"
|
||||
/>
|
||||
<AppButton
|
||||
v-if="video.canDelete"
|
||||
compact
|
||||
type="secondary"
|
||||
:disabled="deletingVideoId === video.id"
|
||||
@@ -159,6 +175,11 @@
|
||||
@click="requestDeleteVideo(video)"
|
||||
/>
|
||||
</view>
|
||||
<view class="video-card__actions">
|
||||
<AppButton compact type="secondary" label="沉浸观看" @click="openVerticalViewer(video)" />
|
||||
<AppButton compact type="secondary" :disabled="videoActionKey === `like-${video.id}`" :label="video.likedByCurrentUser ? `已赞 ${video.likeCount || 0}` : `点赞 ${video.likeCount || 0}`" @click="toggleVideoLike(video)" />
|
||||
<AppButton compact type="secondary" :disabled="videoActionKey === `comments-${video.id}`" label="查看评论" @click="openVideoComments(video)" />
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<text v-if="videoActionError" class="field-error">{{
|
||||
@@ -174,31 +195,125 @@
|
||||
<AppDialog
|
||||
:visible="deleteConfirmVisible"
|
||||
eyebrow="删除确认"
|
||||
title="删除这段家族视频?"
|
||||
:message="deleteTarget ? `《${deleteTarget.title}》删除后不可恢复。` : ''"
|
||||
:confirm-text="deletingVideoId ? '正在删除' : '确认删除'"
|
||||
title="将这段家族视频移至回收站?"
|
||||
:message="deleteTarget ? `《${deleteTarget.title}》移入回收站后不再展示,管理员可在保留期内恢复。` : ''"
|
||||
:confirm-text="deletingVideoId ? '正在移除' : '移至回收站'"
|
||||
cancel-text="保留视频"
|
||||
show-cancel
|
||||
:close-on-mask="false"
|
||||
@confirm="confirmDeleteVideo"
|
||||
@cancel="deleteConfirmVisible = false"
|
||||
/>
|
||||
<AppDialog
|
||||
:visible="discardVisible"
|
||||
eyebrow="未保存修改"
|
||||
title="放弃视频修改?"
|
||||
message="当前修改还没有保存。"
|
||||
confirm-text="确认放弃"
|
||||
cancel-text="继续编辑"
|
||||
show-cancel
|
||||
:close-on-mask="false"
|
||||
@confirm="confirmDiscard"
|
||||
@cancel="cancelDiscard"
|
||||
/>
|
||||
<AppDialog
|
||||
:visible="Boolean(commentTarget)"
|
||||
eyebrow="视频评论"
|
||||
:title="commentTarget?.title || '家族视频'"
|
||||
:confirm-text="videoActionKey === 'send-comment' ? '正在发送' : replyTarget ? '发送回复' : '发表评论'"
|
||||
cancel-text="关闭"
|
||||
show-cancel
|
||||
:close-on-mask="false"
|
||||
@confirm="sendVideoComment"
|
||||
@cancel="closeVideoComments"
|
||||
>
|
||||
<view v-if="replyTarget" class="video-reply-target">
|
||||
<text>正在回复 {{ replyTarget.author }}</text>
|
||||
<button class="video-comment-action" @click="cancelVideoReply">取消回复</button>
|
||||
</view>
|
||||
<view v-if="videoComments.length" class="video-comments">
|
||||
<view
|
||||
v-for="comment in videoComments"
|
||||
:key="comment.id"
|
||||
class="video-comment"
|
||||
:class="{ 'video-comment--reply': comment.level === 'reply' }"
|
||||
>
|
||||
<view class="video-comment__heading">
|
||||
<text>{{ comment.author }}</text>
|
||||
<text>{{ comment.time }}</text>
|
||||
</view>
|
||||
<text v-if="comment.parentAuthor" class="video-comment__context"
|
||||
>回复 {{ comment.parentAuthor }}</text
|
||||
>
|
||||
<text class="video-comment__content">{{ comment.content }}</text>
|
||||
<view v-if="!comment.userDeleted || comment.canDelete" class="video-comment__actions">
|
||||
<button
|
||||
v-if="!comment.userDeleted && comment.level === 'root'"
|
||||
class="video-comment-action"
|
||||
@click="startVideoReply(comment)"
|
||||
>
|
||||
回复
|
||||
</button>
|
||||
<button
|
||||
v-if="comment.canDelete"
|
||||
class="video-comment-action video-comment-action--danger"
|
||||
@click="requestDeleteVideoComment(comment)"
|
||||
>
|
||||
删除
|
||||
</button>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<text v-else class="video-comments__empty">还没有评论,可以先说说你的看法。</text>
|
||||
<text v-if="videoCommentError" class="field-error">{{ videoCommentError }}</text>
|
||||
<textarea
|
||||
v-model="videoCommentText"
|
||||
maxlength="1000"
|
||||
:placeholder="videoCommentPlaceholder"
|
||||
class="video-comment-input"
|
||||
@input="videoCommentError = ''"
|
||||
/>
|
||||
</AppDialog>
|
||||
<VerticalVideoViewer
|
||||
:visible="verticalViewerVisible"
|
||||
:videos="videos"
|
||||
:initial-video-id="verticalViewerInitialId"
|
||||
title="家族视频"
|
||||
:action-busy="Boolean(videoActionKey)"
|
||||
@close="closeVerticalViewer"
|
||||
@like="toggleVideoLike"
|
||||
@comments="openVerticalViewerComments"
|
||||
/>
|
||||
<AppDialog
|
||||
:visible="Boolean(commentDeleteTarget)"
|
||||
:close-on-mask="false"
|
||||
eyebrow="评论管理"
|
||||
title="删除这条评论?"
|
||||
message="删除后将按服务端规则保留占位或移除内容。"
|
||||
:confirm-text="videoActionKey === 'delete-comment' ? '正在删除' : '确认删除'"
|
||||
cancel-text="保留评论"
|
||||
show-cancel
|
||||
@confirm="deleteVideoComment"
|
||||
@cancel="closeVideoCommentDelete"
|
||||
/>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, reactive, ref } from "vue";
|
||||
import { onLoad, onUnload } from "@dcloudio/uni-app";
|
||||
import { onBackPress, 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 VerticalVideoViewer from "@/components/family/VerticalVideoViewer.vue";
|
||||
import {
|
||||
createRequestController,
|
||||
isRequestCancelled
|
||||
} from "@/services/api/request-controller.js";
|
||||
import { familyMediaApi } from "@/services/api/family-media-service.js";
|
||||
import { genealogyCapabilityApi } from "@/services/api/genealogy-capability-service.js";
|
||||
import { getRequestErrorMessage } from "@/services/api/request-error-message.js";
|
||||
import {
|
||||
isImagePickCancelled,
|
||||
@@ -207,12 +322,15 @@ import {
|
||||
pickAndUploadVideo,
|
||||
} from "@/utils/media-upload.js";
|
||||
import { createNonIdempotentWriteGuard } from "@/utils/non-idempotent-write-guard.js";
|
||||
import { goBack, returnTo } from "@/utils/navigation/gateway.js";
|
||||
import { createDiscardConfirmation } from "@/utils/discard-confirmation.js";
|
||||
import { goBack, handleBackPress, openPage, returnTo } from "@/utils/navigation/gateway.js";
|
||||
|
||||
const genealogyId = ref("");
|
||||
const pageState = ref("list");
|
||||
const videoListState = ref("loading");
|
||||
const videos = ref([]);
|
||||
const verticalViewerVisible = ref(false);
|
||||
const verticalViewerInitialId = ref("");
|
||||
const receipt = ref(null);
|
||||
const coverReceipt = ref(null);
|
||||
const uploading = ref(false);
|
||||
@@ -223,20 +341,222 @@ const coverUploadError = ref("");
|
||||
const submitError = ref("");
|
||||
const form = reactive({ videoTitle: "", videoDesc: "" });
|
||||
const editingVideo = ref(null);
|
||||
const formBaseline = ref("");
|
||||
const discardVisible = ref(false);
|
||||
const videoListRequestController = createRequestController();
|
||||
const videoDetailRequestController = createRequestController();
|
||||
const videoUploadRequestController = createRequestController();
|
||||
const coverUploadRequestController = createRequestController();
|
||||
const videoSaveRequestController = createRequestController();
|
||||
const videoDeletionRequestController = createRequestController();
|
||||
const videoCommentListController = createRequestController();
|
||||
const videoCommentWriteController = createRequestController();
|
||||
const videoCommentDeleteController = createRequestController();
|
||||
const videoCreateGuard = createNonIdempotentWriteGuard();
|
||||
const videoCommentCreateGuard = createNonIdempotentWriteGuard();
|
||||
const deleteTarget = ref(null);
|
||||
const deleteConfirmVisible = ref(false);
|
||||
const deletingVideoId = ref("");
|
||||
const videoActionError = ref("");
|
||||
const videoActionKey = ref("");
|
||||
const commentTarget = ref(null);
|
||||
const videoComments = ref([]);
|
||||
const videoCommentText = ref("");
|
||||
const videoCommentError = ref("");
|
||||
const replyTarget = ref(null);
|
||||
const commentDeleteTarget = ref(null);
|
||||
let pageActive = true;
|
||||
const discardConfirmation = createDiscardConfirmation((visible) => {
|
||||
discardVisible.value = visible;
|
||||
});
|
||||
const confirmDiscard = discardConfirmation.confirm;
|
||||
const cancelDiscard = discardConfirmation.cancel;
|
||||
const hasValidContext = computed(() => /^[1-9]\d*$/.test(genealogyId.value));
|
||||
const videoCommentPlaceholder = computed(() =>
|
||||
replyTarget.value ? `回复 ${replyTarget.value.author}` : "说说你的看法",
|
||||
);
|
||||
const openPlatformVideos = () => openPage("F11", { placement: "video_center" }, "F10");
|
||||
const openVerticalViewer = (video) => {
|
||||
if (!video || videoActionKey.value) return;
|
||||
verticalViewerInitialId.value = String(video.id);
|
||||
verticalViewerVisible.value = true;
|
||||
};
|
||||
const closeVerticalViewer = () => {
|
||||
verticalViewerVisible.value = false;
|
||||
};
|
||||
const openVerticalViewerComments = (video) => {
|
||||
closeVerticalViewer();
|
||||
return openVideoComments(video);
|
||||
};
|
||||
const toggleVideoLike = async (video) => {
|
||||
if (videoActionKey.value) return;
|
||||
videoActionKey.value = `like-${video.id}`;
|
||||
videoActionError.value = "";
|
||||
const liked = !video.likedByCurrentUser;
|
||||
try { await genealogyCapabilityApi.setVideoLike(genealogyId.value, video.id, liked); video.likedByCurrentUser = liked; video.likeCount = Math.max(0, Number(video.likeCount || 0) + (liked ? 1 : -1)); }
|
||||
catch (error) { videoActionError.value = getRequestErrorMessage(error, "点赞失败,请稍后重试。"); }
|
||||
finally { videoActionKey.value = ""; }
|
||||
};
|
||||
const loadVideoCommentThread = async (video) => {
|
||||
const rootComments = await genealogyCapabilityApi.getVideoComments(
|
||||
genealogyId.value,
|
||||
video.id,
|
||||
{ requestController: videoCommentListController },
|
||||
);
|
||||
const thread = [];
|
||||
for (const rootComment of rootComments) {
|
||||
thread.push(rootComment);
|
||||
if (rootComment.replyCount <= 0) continue;
|
||||
const replies = await genealogyCapabilityApi.getVideoCommentReplies(
|
||||
genealogyId.value,
|
||||
video.id,
|
||||
rootComment.id,
|
||||
{ requestController: videoCommentListController },
|
||||
);
|
||||
thread.push(...replies.map((reply) => ({
|
||||
...reply,
|
||||
parentAuthor: rootComment.author,
|
||||
})));
|
||||
}
|
||||
return thread;
|
||||
};
|
||||
const openVideoComments = async (video) => {
|
||||
if (videoActionKey.value) return;
|
||||
videoActionKey.value = `comments-${video.id}`;
|
||||
videoActionError.value = "";
|
||||
videoCommentListController.abort();
|
||||
try {
|
||||
videoComments.value = await loadVideoCommentThread(video);
|
||||
commentTarget.value = video;
|
||||
videoCommentText.value = "";
|
||||
videoCommentError.value = "";
|
||||
replyTarget.value = null;
|
||||
}
|
||||
catch (error) { videoActionError.value = getRequestErrorMessage(error, "评论暂时无法读取。"); }
|
||||
finally { videoActionKey.value = ""; }
|
||||
};
|
||||
const closeVideoComments = () => {
|
||||
if (videoActionKey.value === "send-comment" || videoActionKey.value === "delete-comment") return;
|
||||
commentTarget.value = null;
|
||||
videoComments.value = [];
|
||||
videoCommentText.value = "";
|
||||
videoCommentError.value = "";
|
||||
replyTarget.value = null;
|
||||
commentDeleteTarget.value = null;
|
||||
};
|
||||
const startVideoReply = (comment) => {
|
||||
if (!comment || comment.level !== "root" || comment.userDeleted || videoActionKey.value) return;
|
||||
replyTarget.value = comment;
|
||||
videoCommentText.value = "";
|
||||
videoCommentError.value = "";
|
||||
};
|
||||
const cancelVideoReply = () => {
|
||||
if (videoActionKey.value) return;
|
||||
replyTarget.value = null;
|
||||
videoCommentText.value = "";
|
||||
videoCommentError.value = "";
|
||||
};
|
||||
const sendVideoComment = async () => {
|
||||
if (!commentTarget.value || !videoCommentText.value.trim() || videoActionKey.value) return;
|
||||
const commentPayload = {
|
||||
genealogyId: genealogyId.value,
|
||||
videoId: commentTarget.value.id,
|
||||
commentContent: videoCommentText.value.trim(),
|
||||
parentCommentId: replyTarget.value?.id || null,
|
||||
};
|
||||
const commentAttempt = videoCommentCreateGuard.begin(commentPayload);
|
||||
if (commentAttempt === null) {
|
||||
videoCommentError.value = "上次评论发送结果待确认,请先重新打开评论列表,避免重复发布。";
|
||||
return;
|
||||
}
|
||||
videoActionKey.value = "send-comment";
|
||||
videoCommentError.value = "";
|
||||
try {
|
||||
const comment = await genealogyCapabilityApi.createVideoComment(
|
||||
commentPayload.genealogyId,
|
||||
commentPayload.videoId,
|
||||
commentPayload.commentContent,
|
||||
commentPayload.parentCommentId,
|
||||
{ requestController: videoCommentWriteController },
|
||||
);
|
||||
if (!pageActive) return;
|
||||
if (commentPayload.parentCommentId) {
|
||||
const rootComment = videoComments.value.find(
|
||||
(currentComment) => currentComment.id === commentPayload.parentCommentId,
|
||||
);
|
||||
const insertedReply = {
|
||||
...comment,
|
||||
parentAuthor: rootComment?.author || replyTarget.value?.author || "",
|
||||
};
|
||||
let insertionIndex = videoComments.value.findIndex(
|
||||
(currentComment) => currentComment.id === commentPayload.parentCommentId,
|
||||
);
|
||||
for (let index = insertionIndex + 1; index < videoComments.value.length; index += 1) {
|
||||
if (videoComments.value[index].parentCommentId !== commentPayload.parentCommentId) break;
|
||||
insertionIndex = index;
|
||||
}
|
||||
videoComments.value.splice(insertionIndex + 1, 0, insertedReply);
|
||||
if (rootComment) rootComment.replyCount += 1;
|
||||
} else {
|
||||
videoComments.value.push(comment);
|
||||
}
|
||||
videoCommentText.value = "";
|
||||
replyTarget.value = null;
|
||||
}
|
||||
catch (error) {
|
||||
const isOutcomeUnknown = videoCommentCreateGuard.recordFailure(commentAttempt, error);
|
||||
if (!pageActive) return;
|
||||
videoCommentError.value = isOutcomeUnknown
|
||||
? "评论发送结果待确认,请重新打开评论列表检查,避免重复发布。"
|
||||
: getRequestErrorMessage(error, "评论发送失败,请稍后重试。");
|
||||
}
|
||||
finally { if (pageActive) videoActionKey.value = ""; }
|
||||
};
|
||||
const requestDeleteVideoComment = (comment) => {
|
||||
if (!comment?.canDelete || videoActionKey.value) return;
|
||||
videoCommentError.value = "";
|
||||
commentDeleteTarget.value = comment;
|
||||
};
|
||||
const closeVideoCommentDelete = () => {
|
||||
if (videoActionKey.value !== "delete-comment") commentDeleteTarget.value = null;
|
||||
};
|
||||
const deleteVideoComment = async () => {
|
||||
if (!commentTarget.value || !commentDeleteTarget.value?.canDelete || videoActionKey.value) return;
|
||||
const target = commentDeleteTarget.value;
|
||||
videoActionKey.value = "delete-comment";
|
||||
videoCommentError.value = "";
|
||||
try {
|
||||
await genealogyCapabilityApi.deleteVideoComment(
|
||||
genealogyId.value,
|
||||
commentTarget.value.id,
|
||||
target.id,
|
||||
{ requestController: videoCommentDeleteController },
|
||||
);
|
||||
if (!pageActive) return;
|
||||
videoComments.value = await loadVideoCommentThread(commentTarget.value);
|
||||
if (replyTarget.value?.id === target.id) replyTarget.value = null;
|
||||
commentDeleteTarget.value = null;
|
||||
} catch (error) {
|
||||
if (!pageActive || isRequestCancelled(error)) return;
|
||||
videoCommentError.value = getRequestErrorMessage(error, "评论删除失败,请稍后重试。");
|
||||
commentDeleteTarget.value = null;
|
||||
} finally {
|
||||
if (pageActive) videoActionKey.value = "";
|
||||
}
|
||||
};
|
||||
const isEdit = computed(() => Boolean(editingVideo.value));
|
||||
const formSnapshot = computed(() => JSON.stringify({
|
||||
videoTitle: form.videoTitle,
|
||||
videoDesc: form.videoDesc,
|
||||
videoOssId: receipt.value?.ossId || null,
|
||||
coverOssId: coverReceipt.value?.ossId || null,
|
||||
editingVideoId: editingVideo.value?.id || null,
|
||||
}));
|
||||
const isDirty = computed(() =>
|
||||
pageState.value === "form" &&
|
||||
Boolean(formBaseline.value) &&
|
||||
formSnapshot.value !== formBaseline.value,
|
||||
);
|
||||
const stateCopy = computed(() =>
|
||||
pageState.value === "success"
|
||||
? {
|
||||
@@ -264,6 +584,10 @@ onUnload(() => {
|
||||
coverUploadRequestController.abort();
|
||||
videoSaveRequestController.abort();
|
||||
videoDeletionRequestController.abort();
|
||||
videoCommentListController.abort();
|
||||
videoCommentWriteController.abort();
|
||||
videoCommentDeleteController.abort();
|
||||
discardConfirmation.dispose();
|
||||
});
|
||||
|
||||
const loadVideos = async () => {
|
||||
@@ -292,6 +616,7 @@ const openPublishForm = () => {
|
||||
coverUploadError.value = "";
|
||||
submitError.value = "";
|
||||
pageState.value = "form";
|
||||
formBaseline.value = formSnapshot.value;
|
||||
};
|
||||
const openEditVideo = async (video) => {
|
||||
if (
|
||||
@@ -335,6 +660,7 @@ const openEditVideo = async (video) => {
|
||||
coverUploadError.value = "";
|
||||
submitError.value = "";
|
||||
pageState.value = "form";
|
||||
formBaseline.value = formSnapshot.value;
|
||||
} catch (error) {
|
||||
if (!isRequestCancelled(error)) {
|
||||
videoActionError.value = "视频信息不完整,暂未保存修改,以免覆盖原内容。";
|
||||
@@ -418,6 +744,11 @@ const selectCover = async () => {
|
||||
if (pageActive) coverUploading.value = false;
|
||||
}
|
||||
};
|
||||
const clearCover = () => {
|
||||
if (coverUploading.value || submitting.value) return;
|
||||
coverReceipt.value = null;
|
||||
coverUploadError.value = "";
|
||||
};
|
||||
const submitVideo = async () => {
|
||||
if (
|
||||
uploading.value ||
|
||||
@@ -439,7 +770,7 @@ const submitVideo = async () => {
|
||||
videoTitle,
|
||||
videoDesc: form.videoDesc.trim(),
|
||||
videoOssId: receipt.value.ossId,
|
||||
...(coverReceipt.value ? { coverOssId: coverReceipt.value.ossId } : {}),
|
||||
coverOssId: coverReceipt.value?.ossId ?? null,
|
||||
...(editingVideo.value
|
||||
? {
|
||||
durationSeconds: editingVideo.value.durationSeconds,
|
||||
@@ -493,7 +824,50 @@ const returnToFamily = () =>
|
||||
hasValidContext.value
|
||||
? returnTo("F01", { genealogyId: genealogyId.value })
|
||||
: goBack();
|
||||
const returnToVideoList = () => {
|
||||
pageState.value = "list";
|
||||
formBaseline.value = "";
|
||||
receipt.value = null;
|
||||
coverReceipt.value = null;
|
||||
editingVideo.value = null;
|
||||
return true;
|
||||
};
|
||||
const requestBack = async () => {
|
||||
if (verticalViewerVisible.value) {
|
||||
closeVerticalViewer();
|
||||
return true;
|
||||
}
|
||||
if (commentDeleteTarget.value) {
|
||||
closeVideoCommentDelete();
|
||||
return true;
|
||||
}
|
||||
if (commentTarget.value) {
|
||||
closeVideoComments();
|
||||
return true;
|
||||
}
|
||||
if (deleteConfirmVisible.value) {
|
||||
deleteConfirmVisible.value = false;
|
||||
return true;
|
||||
}
|
||||
if (discardVisible.value) {
|
||||
cancelDiscard();
|
||||
return true;
|
||||
}
|
||||
if (uploading.value || coverUploading.value || submitting.value || deletingVideoId.value) {
|
||||
return true;
|
||||
}
|
||||
if (pageState.value === "form") {
|
||||
if (isDirty.value && !(await discardConfirmation.request())) return false;
|
||||
return returnToVideoList();
|
||||
}
|
||||
if (pageState.value === "form-loading") {
|
||||
videoDetailRequestController.abort();
|
||||
return returnToVideoList();
|
||||
}
|
||||
return returnToFamily();
|
||||
};
|
||||
const handleStateAction = () => returnToFamily();
|
||||
onBackPress((event) => handleBackPress(event, requestBack));
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@@ -510,15 +884,19 @@ const handleStateAction = () => returnToFamily();
|
||||
}
|
||||
.page-content {
|
||||
flex: 1;
|
||||
padding: 18rpx 24rpx 72rpx;
|
||||
padding: 18rpx 24rpx calc(72rpx + env(safe-area-inset-bottom));
|
||||
}
|
||||
.video-panel,
|
||||
.video-state-card {
|
||||
box-sizing: border-box;
|
||||
@include adaptive-family-content;
|
||||
background-color: rgba($paper, 0.82);
|
||||
}
|
||||
.video-list-panel {
|
||||
@include adaptive-family-content;
|
||||
box-sizing: border-box;
|
||||
padding: 28rpx 24rpx;
|
||||
background-color: rgba($paper, 0.82);
|
||||
}
|
||||
.video-panel {
|
||||
padding: 30rpx;
|
||||
@@ -563,7 +941,7 @@ const handleStateAction = () => returnToFamily();
|
||||
font-size: clamp(15px, 24rpx, 18px);
|
||||
}
|
||||
.video-field input {
|
||||
min-height: 76rpx;
|
||||
min-height: 80rpx;
|
||||
padding: 0 18rpx;
|
||||
}
|
||||
.video-field textarea {
|
||||
@@ -580,6 +958,7 @@ const handleStateAction = () => returnToFamily();
|
||||
width: 100%;
|
||||
}
|
||||
.upload-button {
|
||||
min-height: 80rpx;
|
||||
margin: 0;
|
||||
padding: 0 26rpx;
|
||||
border: 1rpx solid #b78a42;
|
||||
@@ -587,12 +966,24 @@ const handleStateAction = () => returnToFamily();
|
||||
background: #fffaf0;
|
||||
color: #805723;
|
||||
font-size: clamp(14px, 23rpx, 17px);
|
||||
line-height: 64rpx;
|
||||
line-height: 78rpx;
|
||||
}
|
||||
.upload-receipt {
|
||||
color: $ink-muted;
|
||||
font-size: clamp(13px, 21rpx, 16px);
|
||||
}
|
||||
.remove-cover-button {
|
||||
justify-self: start;
|
||||
min-height: 72rpx;
|
||||
margin: 0;
|
||||
padding: 0 18rpx;
|
||||
border: 1rpx solid rgba($brand-red, 0.38);
|
||||
border-radius: 8rpx;
|
||||
background: transparent;
|
||||
color: $brand-red;
|
||||
font-size: clamp(13px, 21rpx, 16px);
|
||||
}
|
||||
.remove-cover-button::after { border: 0; }
|
||||
.required-mark,
|
||||
.field-error {
|
||||
color: $brand-red;
|
||||
@@ -621,14 +1012,38 @@ const handleStateAction = () => returnToFamily();
|
||||
padding: 22rpx;
|
||||
border: 1rpx solid rgba(128, 89, 49, 0.28);
|
||||
border-radius: 12rpx;
|
||||
background: rgba(255, 252, 245, 0.78);
|
||||
background: rgba($paper, 0.9);
|
||||
}
|
||||
.video-card__player {
|
||||
.video-card__cover-action {
|
||||
position: relative;
|
||||
display: block;
|
||||
width: 100%;
|
||||
min-height: 340rpx;
|
||||
height: 340rpx;
|
||||
overflow: hidden;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-radius: 8rpx;
|
||||
background: #161616;
|
||||
line-height: 1;
|
||||
}
|
||||
.video-card__cover-action::after {
|
||||
border: 0;
|
||||
}
|
||||
.video-card__cover {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
.video-card__play-copy {
|
||||
position: absolute;
|
||||
right: 20rpx;
|
||||
bottom: 18rpx;
|
||||
padding: 10rpx 16rpx;
|
||||
border-radius: 8rpx;
|
||||
background: rgba(22, 22, 22, 0.78);
|
||||
color: #fff9ed;
|
||||
font-size: clamp(13px, 21rpx, 16px);
|
||||
font-weight: 700;
|
||||
}
|
||||
.video-card__title,
|
||||
.video-card__copy,
|
||||
@@ -640,12 +1055,14 @@ const handleStateAction = () => returnToFamily();
|
||||
color: $ink;
|
||||
font-size: clamp(17px, 28rpx, 21px);
|
||||
font-weight: 700;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.video-card__copy {
|
||||
margin-top: 8rpx;
|
||||
color: $ink-muted;
|
||||
font-size: clamp(14px, 23rpx, 17px);
|
||||
line-height: 1.55;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.video-card__meta {
|
||||
margin-top: 10rpx;
|
||||
@@ -657,4 +1074,101 @@ const handleStateAction = () => returnToFamily();
|
||||
justify-content: flex-end;
|
||||
margin-top: 14rpx;
|
||||
}
|
||||
.video-reply-target,
|
||||
.video-comment__heading,
|
||||
.video-comment__actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 14rpx;
|
||||
}
|
||||
.video-reply-target {
|
||||
width: 100%;
|
||||
margin-top: 18rpx;
|
||||
padding: 14rpx 16rpx;
|
||||
box-sizing: border-box;
|
||||
border-radius: 8rpx;
|
||||
background: rgba($gold, 0.1);
|
||||
color: $ink;
|
||||
font-size: clamp(13px, 21rpx, 16px);
|
||||
}
|
||||
.video-comments {
|
||||
width: 100%;
|
||||
max-height: 440rpx;
|
||||
margin-top: 16rpx;
|
||||
overflow-y: auto;
|
||||
text-align: left;
|
||||
}
|
||||
.video-comment {
|
||||
padding: 18rpx 4rpx;
|
||||
border-bottom: 1rpx solid rgba($gold, 0.2);
|
||||
}
|
||||
.video-comment--reply {
|
||||
margin-left: 32rpx;
|
||||
padding-left: 18rpx;
|
||||
border-left: 3rpx solid rgba($gold, 0.34);
|
||||
}
|
||||
.video-comment__heading text:first-child {
|
||||
color: $ink;
|
||||
font-size: clamp(14px, 23rpx, 17px);
|
||||
font-weight: 700;
|
||||
}
|
||||
.video-comment__heading text:last-child,
|
||||
.video-comment__context {
|
||||
color: $ink-muted;
|
||||
font-size: clamp(12px, 20rpx, 15px);
|
||||
}
|
||||
.video-comment__context,
|
||||
.video-comment__content,
|
||||
.video-comments__empty {
|
||||
display: block;
|
||||
}
|
||||
.video-comment__context {
|
||||
margin-top: 6rpx;
|
||||
}
|
||||
.video-comment__content {
|
||||
margin-top: 8rpx;
|
||||
color: $ink;
|
||||
font-size: clamp(14px, 22rpx, 17px);
|
||||
line-height: 1.55;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.video-comment__actions {
|
||||
justify-content: flex-end;
|
||||
margin-top: 8rpx;
|
||||
}
|
||||
.video-comment-action {
|
||||
min-width: 88rpx;
|
||||
min-height: 58rpx;
|
||||
margin: 0;
|
||||
padding: 0 14rpx;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: #805723;
|
||||
font-size: clamp(13px, 21rpx, 16px);
|
||||
line-height: 58rpx;
|
||||
}
|
||||
.video-comment-action::after {
|
||||
border: 0;
|
||||
}
|
||||
.video-comment-action--danger {
|
||||
color: $brand-red;
|
||||
}
|
||||
.video-comments__empty {
|
||||
width: 100%;
|
||||
margin-top: 20rpx;
|
||||
color: $ink-muted;
|
||||
font-size: clamp(14px, 22rpx, 17px);
|
||||
}
|
||||
.video-comment-input {
|
||||
width: 100%;
|
||||
min-height: 120rpx;
|
||||
margin-top: 18rpx;
|
||||
padding: 16rpx;
|
||||
box-sizing: border-box;
|
||||
border: 1rpx solid rgba($gold, 0.38);
|
||||
border-radius: 10rpx;
|
||||
color: $ink;
|
||||
text-align: left;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -233,7 +233,7 @@ onUnload(() => {
|
||||
@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; }
|
||||
.page-content { padding: 18rpx 24rpx calc(72rpx + env(safe-area-inset-bottom)); }
|
||||
.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; }
|
||||
@@ -245,7 +245,7 @@ onUnload(() => {
|
||||
.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:first-child { min-width: 0; flex: 1; 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; }
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
<template>
|
||||
<view class="capability-page">
|
||||
<GenealogyPageBackground />
|
||||
<view class="page-header"><PageHeader title="资料与回收站" custom-back @back="goBack" /></view>
|
||||
<view class="page-content">
|
||||
<view class="tab-row">
|
||||
<button :class="['tab', { active: tab === 'completeness' }]" @click="tab = 'completeness'">资料完整度</button>
|
||||
<button :class="['tab', { active: tab === 'recycle' }]" @click="tab = 'recycle'">内容回收站</button>
|
||||
</view>
|
||||
<view v-if="loading" class="state-card"><AppLoading text="正在读取家谱资料" /></view>
|
||||
<view v-else-if="error" class="state-card"><text>{{ error }}</text><AppButton block label="重新加载" @click="load" /></view>
|
||||
<view v-else-if="tab === 'completeness' && !hasValidCompleteness" class="state-card"><text>{{ completenessError || '资料完整度暂时无法读取。' }}</text><AppButton block label="重新加载" @click="load" /></view>
|
||||
<view v-else-if="tab === 'completeness'" class="panel">
|
||||
<text class="panel-title">资料完成度 {{ completeness?.completionRate || 0 }}%</text>
|
||||
<text class="panel-copy">已完成 {{ completeness?.completedCount || 0 }} / {{ completeness?.totalCount || 0 }} 项</text>
|
||||
<view v-if="!completeness?.missingItems?.length" class="empty-copy">这部家谱的基础资料已补充完整。</view>
|
||||
<view v-for="item in completeness?.missingItems || []" :key="item.code" class="row"><text>{{ item.displayText }}</text><text>待补充</text></view>
|
||||
</view>
|
||||
<view v-else-if="recycleError" class="state-card"><text>{{ recycleError }}</text><AppButton block label="重新加载" @click="load" /></view>
|
||||
<view v-else class="panel">
|
||||
<text class="panel-title">已删除内容</text>
|
||||
<text class="panel-copy">可恢复的内容会显示“恢复”按钮。</text>
|
||||
<view v-if="!recycle.rows.length" class="empty-copy">回收站暂时没有内容。</view>
|
||||
<view v-for="item in recycle.rows" :key="`${item.resourceType}-${item.resourceId}`" class="row row--recycle">
|
||||
<view><text>{{ item.resourceTitle || '未命名内容' }}</text><text class="row-meta">{{ item.resourceType }} · {{ item.deletedAt || '删除时间未知' }}</text></view>
|
||||
<AppButton v-if="item.canRestore" class="restore-button" compact type="secondary" :disabled="restoringKey === `${item.resourceType}-${item.resourceId}`" label="恢复" @click="restore(item)" />
|
||||
<text v-else class="row-meta">{{ recycleDisabledReason(item.disabledReason) }}</text>
|
||||
</view>
|
||||
</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 PageHeader from '@/components/PageHeader.vue'
|
||||
import GenealogyPageBackground from '@/components/genealogy/PageBackground.vue'
|
||||
import { genealogyCapabilityApi } from '@/services/api/genealogy-capability-service.js'
|
||||
import { createRequestController, isRequestCancelled } from '@/services/api/request-controller.js'
|
||||
import { getRequestErrorMessage } from '@/services/api/request-error-message.js'
|
||||
import { goBack } from '@/utils/navigation/gateway.js'
|
||||
|
||||
const genealogyId = ref('')
|
||||
const tab = ref('completeness')
|
||||
const loading = ref(true)
|
||||
const error = ref('')
|
||||
const completenessError = ref('')
|
||||
const recycleError = ref('')
|
||||
const completeness = ref(null)
|
||||
const recycle = ref({ rows: [], total: 0 })
|
||||
const restoringKey = ref('')
|
||||
const controller = createRequestController()
|
||||
const recycleDisabledReason = (reason) => reason === 'ALREADY_RESTORED' ? '已恢复' : '不可恢复'
|
||||
const hasValidCompleteness = computed(() => (
|
||||
Number.isSafeInteger(completeness.value?.totalCount) &&
|
||||
completeness.value.totalCount > 0 &&
|
||||
Number.isSafeInteger(completeness.value?.completedCount) &&
|
||||
completeness.value.completedCount >= 0 &&
|
||||
completeness.value.completedCount <= completeness.value.totalCount &&
|
||||
Number.isSafeInteger(completeness.value?.completionRate) &&
|
||||
completeness.value.completionRate >= 0 &&
|
||||
completeness.value.completionRate <= 100 &&
|
||||
Array.isArray(completeness.value?.missingItems)
|
||||
))
|
||||
|
||||
const load = async () => {
|
||||
if (!/^[1-9]\d*$/.test(genealogyId.value)) { error.value = '缺少有效家谱信息'; loading.value = false; return }
|
||||
controller.abort(); loading.value = true; error.value = ''; completenessError.value = ''; recycleError.value = ''
|
||||
const [completenessResult, recycleResult] = await Promise.allSettled([
|
||||
genealogyCapabilityApi.getCompleteness(genealogyId.value, { requestController: controller }),
|
||||
genealogyCapabilityApi.getRecyclePage(genealogyId.value, { pageNum: 1, pageSize: 50, recycleStatus: 'OPEN' }, { requestController: controller })
|
||||
])
|
||||
if (completenessResult.status === 'fulfilled') completeness.value = completenessResult.value
|
||||
else if (!isRequestCancelled(completenessResult.reason)) {
|
||||
completeness.value = null
|
||||
completenessError.value = getRequestErrorMessage(completenessResult.reason, '资料完整度暂时无法读取。')
|
||||
}
|
||||
if (recycleResult.status === 'fulfilled') recycle.value = recycleResult.value
|
||||
else if (!isRequestCancelled(recycleResult.reason)) {
|
||||
recycle.value = { rows: [], total: 0 }
|
||||
recycleError.value = getRequestErrorMessage(recycleResult.reason, '回收站暂时无法读取。')
|
||||
}
|
||||
loading.value = false
|
||||
}
|
||||
|
||||
const restore = async (item) => {
|
||||
const key = `${item.resourceType}-${item.resourceId}`
|
||||
if (restoringKey.value) return
|
||||
restoringKey.value = key
|
||||
try { await genealogyCapabilityApi.restoreRecycleItem(genealogyId.value, item.resourceType, item.resourceId); await load() }
|
||||
catch (requestError) { recycleError.value = getRequestErrorMessage(requestError, '恢复失败,请稍后重试。') }
|
||||
finally { restoringKey.value = '' }
|
||||
}
|
||||
|
||||
onLoad((query) => { genealogyId.value = String(query?.genealogyId || ''); void load() })
|
||||
onUnload(() => controller.abort())
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.capability-page { min-height: 100vh; color: $ink; }
|
||||
.page-header { position: relative; z-index: 1; }
|
||||
.page-content { position: relative; z-index: 1; padding: 24rpx 28rpx calc(72rpx + env(safe-area-inset-bottom)); }
|
||||
.tab-row { display: flex; gap: 16rpx; margin-bottom: 20rpx; }
|
||||
.tab { flex: 1; min-height: 80rpx; border: 1rpx solid rgba($gold, .38); border-radius: 12rpx; background: rgba(255,252,245,.88); color: $ink-muted; font-size: 26rpx; }
|
||||
.tab.active { border-color: $brand-red; background: $brand-red; color: #fff; }
|
||||
.panel,.state-card { padding: 28rpx; border: 1rpx solid rgba($gold, .38); border-radius: 16rpx; background: rgba(255,252,245,.92); }
|
||||
.panel-title,.panel-copy,.row text,.empty-copy { display: block; }
|
||||
.panel-title { font-size: 32rpx; font-weight: 700; }.panel-copy,.row-meta,.empty-copy { margin-top: 12rpx; color: $ink-muted; font-size: 25rpx; }
|
||||
.row { display:flex; justify-content:space-between; align-items:center; gap:16rpx; padding:24rpx 0; border-top:1rpx solid rgba($gold, .38); }.row:first-of-type { margin-top:20rpx; }.row--recycle > view { flex:1; min-width:0; }
|
||||
.restore-button { flex: 0 0 176rpx; width: 176rpx; }
|
||||
</style>
|
||||
+83
-20
@@ -8,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">
|
||||
@@ -84,6 +84,37 @@
|
||||
/>
|
||||
</view>
|
||||
|
||||
<view class="field-row">
|
||||
<text class="field-row__label"
|
||||
><text class="required-mark">*</text>始迁祖</text
|
||||
>
|
||||
<input
|
||||
v-model="form.firstAncestorName"
|
||||
maxlength="30"
|
||||
placeholder="请输入始迁祖姓名"
|
||||
placeholder-class="placeholder"
|
||||
@input="clearFieldError('firstAncestorName')"
|
||||
/>
|
||||
</view>
|
||||
<text v-if="fieldErrors.firstAncestorName" class="field-error">{{
|
||||
fieldErrors.firstAncestorName
|
||||
}}</text>
|
||||
|
||||
<view class="owner-ancestor-field">
|
||||
<view>
|
||||
<text class="owner-ancestor-field__label">谱主本人就是始迁祖</text>
|
||||
<text class="owner-ancestor-field__hint"
|
||||
>仅本人确为始迁祖时开启,开启后账号会绑定到该人物。</text
|
||||
>
|
||||
</view>
|
||||
<switch
|
||||
:checked="form.ownerIsFirstAncestor"
|
||||
color="#9f170f"
|
||||
aria-label="谱主本人就是始迁祖"
|
||||
@change="form.ownerIsFirstAncestor = $event.detail.value"
|
||||
/>
|
||||
</view>
|
||||
|
||||
<view class="field-row">
|
||||
<text class="field-row__label">所在地</text>
|
||||
<input
|
||||
@@ -208,7 +239,6 @@ import {
|
||||
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,
|
||||
@@ -229,6 +259,8 @@ const form = reactive({
|
||||
surname: "",
|
||||
genealogyName: "",
|
||||
ancestralHall: "",
|
||||
firstAncestorName: "",
|
||||
ownerIsFirstAncestor: false,
|
||||
originPlace: "",
|
||||
addressDetail: "",
|
||||
intro: "",
|
||||
@@ -237,6 +269,7 @@ const form = reactive({
|
||||
const fieldErrors = reactive({
|
||||
surname: "",
|
||||
genealogyName: "",
|
||||
firstAncestorName: "",
|
||||
regionCode: "",
|
||||
});
|
||||
const submitError = ref("");
|
||||
@@ -248,7 +281,11 @@ const coverOssId = ref(null);
|
||||
const coverFileName = ref("");
|
||||
const coverUploadRequestController = createRequestController();
|
||||
const genealogyCreateRequestController = createRequestController();
|
||||
const genealogyCreateGuard = createNonIdempotentWriteGuard();
|
||||
const createGenealogyRequestId = () =>
|
||||
typeof globalThis.crypto?.randomUUID === "function"
|
||||
? `app-genealogy-${globalThis.crypto.randomUUID()}`
|
||||
: `app-genealogy-${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
||||
const genealogyCreateRequestId = ref(createGenealogyRequestId());
|
||||
const selectedRegion = ref(null);
|
||||
const regionPickerTrail = ref([]);
|
||||
const regionPickerDialog = ref(null);
|
||||
@@ -262,6 +299,8 @@ const hasDraft = computed(
|
||||
Object.entries(form).some(([key, value]) =>
|
||||
key === "accessPreset"
|
||||
? value !== GENEALOGY_ACCESS_PRESET.MEMBER_ONLY
|
||||
: key === "ownerIsFirstAncestor"
|
||||
? value === true
|
||||
: String(value).trim(),
|
||||
) ||
|
||||
Boolean(selectedRegion.value?.regionCode) ||
|
||||
@@ -282,12 +321,16 @@ const clearFieldError = (field) => {
|
||||
const validate = () => {
|
||||
fieldErrors.surname = form.surname.trim() ? "" : "请填写姓氏";
|
||||
fieldErrors.genealogyName = form.genealogyName.trim() ? "" : "请填写谱名";
|
||||
fieldErrors.firstAncestorName = form.firstAncestorName.trim()
|
||||
? ""
|
||||
: "请填写始迁祖姓名";
|
||||
fieldErrors.regionCode = selectedRegion.value?.regionCode
|
||||
? ""
|
||||
: "请选择所在地区";
|
||||
return (
|
||||
!fieldErrors.surname &&
|
||||
!fieldErrors.genealogyName &&
|
||||
!fieldErrors.firstAncestorName &&
|
||||
!fieldErrors.regionCode
|
||||
);
|
||||
};
|
||||
@@ -354,7 +397,6 @@ const submitCreate = async () => {
|
||||
if (!createdGenealogyId.value && !validate()) return;
|
||||
|
||||
let createPayload = null;
|
||||
let createAttempt = null;
|
||||
if (!createdGenealogyId.value) {
|
||||
const access = toApiGenealogyAccess(form.accessPreset);
|
||||
if (!access) {
|
||||
@@ -366,18 +408,15 @@ const submitCreate = async () => {
|
||||
genealogyName: form.genealogyName,
|
||||
regionCode: selectedRegion.value.regionCode,
|
||||
ancestralHall: form.ancestralHall,
|
||||
firstAncestorName: form.firstAncestorName,
|
||||
ownerIsFirstAncestor: form.ownerIsFirstAncestor,
|
||||
requestId: genealogyCreateRequestId.value,
|
||||
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;
|
||||
@@ -402,15 +441,6 @@ const submitCreate = async () => {
|
||||
);
|
||||
} catch (error) {
|
||||
if (!pageActive) return;
|
||||
if (
|
||||
!createdGenealogyId.value &&
|
||||
createAttempt &&
|
||||
genealogyCreateGuard.recordFailure(createAttempt, error)
|
||||
) {
|
||||
submitError.value =
|
||||
"创建结果暂时无法确认,请先返回“我的家谱”检查,避免重复创建。";
|
||||
return;
|
||||
}
|
||||
if (isRequestCancelled(error)) return;
|
||||
submitError.value = createdGenealogyId.value
|
||||
? "家谱已经创建,但页面返回失败。请再次点击“返回我的家谱”,不要重复创建。"
|
||||
@@ -439,7 +469,7 @@ onUnload(() => {
|
||||
}
|
||||
.page-content {
|
||||
z-index: 1;
|
||||
padding: 24rpx 32rpx 72rpx;
|
||||
padding: 24rpx 32rpx calc(72rpx + env(safe-area-inset-bottom));
|
||||
}
|
||||
.create-card {
|
||||
@include adaptive-genealogy-state-panel;
|
||||
@@ -500,6 +530,7 @@ onUnload(() => {
|
||||
}
|
||||
.field-row input {
|
||||
min-width: 0;
|
||||
min-height: var(--app-touch-min);
|
||||
flex: 1;
|
||||
color: $ink;
|
||||
font-size: clamp(16px, 28rpx, 20px);
|
||||
@@ -552,6 +583,38 @@ onUnload(() => {
|
||||
font-size: clamp(16px, 27rpx, 20px);
|
||||
line-height: 1.55;
|
||||
}
|
||||
.owner-ancestor-field {
|
||||
display: flex;
|
||||
min-height: 112rpx;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 24rpx;
|
||||
margin-top: 22rpx;
|
||||
padding: 18rpx 20rpx;
|
||||
border: 1rpx solid rgba(128, 89, 49, 0.34);
|
||||
border-radius: 12rpx;
|
||||
background: rgba(255, 252, 245, 0.7);
|
||||
}
|
||||
.owner-ancestor-field > view {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
.owner-ancestor-field__label,
|
||||
.owner-ancestor-field__hint {
|
||||
display: block;
|
||||
}
|
||||
.owner-ancestor-field__label {
|
||||
color: $ink;
|
||||
font-family: STKaiti, KaiTi, serif;
|
||||
font-size: clamp(17px, 30rpx, 22px);
|
||||
font-weight: 700;
|
||||
}
|
||||
.owner-ancestor-field__hint {
|
||||
margin-top: 6rpx;
|
||||
color: $ink-muted;
|
||||
font-size: clamp(13px, 21rpx, 16px);
|
||||
line-height: 1.45;
|
||||
}
|
||||
.cover-field {
|
||||
display: grid;
|
||||
gap: 12rpx;
|
||||
|
||||
@@ -294,7 +294,7 @@ const applyRows = (rows) => {
|
||||
genealogyName.value = named?.genealogyName || "当前家谱";
|
||||
};
|
||||
const loadPoems = async ({ management = false } = {}) => {
|
||||
if (!genealogyId.value) {
|
||||
if (!/^[1-9]\d*$/.test(genealogyId.value)) {
|
||||
poemState.value = "error";
|
||||
return false;
|
||||
}
|
||||
@@ -473,7 +473,7 @@ onUnload(() => {
|
||||
@include adaptive-genealogy-state-panel;
|
||||
z-index: 2;
|
||||
width: calc(100% - 32rpx);
|
||||
margin: 18rpx auto 0;
|
||||
margin: 18rpx auto calc(34rpx + env(safe-area-inset-bottom));
|
||||
padding: 76rpx 8%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
@@ -526,7 +526,7 @@ onUnload(() => {
|
||||
.poem-row {
|
||||
@include adaptive-genealogy-form-field;
|
||||
display: grid;
|
||||
min-height: 72rpx;
|
||||
min-height: var(--app-touch-min);
|
||||
margin-top: 10rpx;
|
||||
grid-template-columns: minmax(116rpx, 38%) minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
@@ -726,4 +726,12 @@ onUnload(() => {
|
||||
width: calc(100% - 48rpx);
|
||||
}
|
||||
}
|
||||
@media (max-width: 340px) {
|
||||
.poem-editor__actions {
|
||||
flex-direction: column;
|
||||
}
|
||||
.poem-editor__actions .poem-action {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -135,9 +135,18 @@ const confirmation = createDiscardConfirmation((visible) => {
|
||||
const confirmDiscard = confirmation.confirm;
|
||||
const cancelDiscard = confirmation.cancel;
|
||||
|
||||
const decodeQueryText = (value) => {
|
||||
const text = String(value || "");
|
||||
try {
|
||||
return decodeURIComponent(text);
|
||||
} catch {
|
||||
return text;
|
||||
}
|
||||
};
|
||||
|
||||
onLoad((query) => {
|
||||
genealogyId.value = String(query?.genealogyId || "");
|
||||
genealogyName.value = String(query?.genealogyName || "");
|
||||
genealogyName.value = decodeQueryText(query?.genealogyName);
|
||||
});
|
||||
|
||||
const backToSearch = () => returnTo("G06");
|
||||
@@ -204,7 +213,7 @@ onUnload(() => {
|
||||
z-index: 1;
|
||||
}
|
||||
.page-content {
|
||||
padding: 18rpx 24rpx 72rpx;
|
||||
padding: 18rpx 24rpx calc(72rpx + env(safe-area-inset-bottom));
|
||||
}
|
||||
.state-card,
|
||||
.join-form {
|
||||
@@ -277,7 +286,7 @@ onUnload(() => {
|
||||
line-height: 1.5;
|
||||
}
|
||||
.form-field input {
|
||||
min-height: 76rpx;
|
||||
min-height: var(--app-touch-min);
|
||||
padding-top: 0;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
+120
-4
@@ -89,6 +89,13 @@
|
||||
label="移出家谱"
|
||||
@click="openConfirmation('remove', member)"
|
||||
/>
|
||||
<AppButton
|
||||
v-if="isOwnerViewer && member.roleType === GENEALOGY_MEMBER_ROLE.ADMIN"
|
||||
compact
|
||||
type="secondary"
|
||||
label="权限设置"
|
||||
@click="openPermissionEditor(member)"
|
||||
/>
|
||||
<AppButton
|
||||
v-if="member.capabilities.canLeave"
|
||||
compact
|
||||
@@ -159,6 +166,18 @@
|
||||
>
|
||||
<text v-if="operationError" class="edit-error" role="alert">{{ operationError }}</text>
|
||||
</AppDialog>
|
||||
<AppDialog :visible="Boolean(permissionTarget)" eyebrow="管理员权限" :title="`设置${permissionTarget?.memberName || '成员'}的权限`" :confirm-text="permissionSaving ? '正在保存' : '保存权限'" cancel-text="取消" show-cancel :close-on-mask="!permissionSaving" @confirm="savePermissions" @cancel="closePermissionEditor">
|
||||
<AppLoading v-if="permissionLoading" text="正在读取权限目录" />
|
||||
<view v-else-if="permissionOptions.length" class="permission-list">
|
||||
<label v-for="option in permissionOptions" :key="option.code" class="permission-item" :class="{ 'permission-item--disabled': !option.enabled }">
|
||||
<checkbox :checked="selectedPermissionCodes.includes(option.code)" :disabled="!option.enabled || permissionSaving" @click="togglePermission(option)" />
|
||||
<view><text>{{ option.label }}</text><text v-if="option.description">{{ option.description }}</text></view>
|
||||
</label>
|
||||
</view>
|
||||
<text v-else-if="!permissionError" class="edit-hint">当前没有可授权的权限项。</text>
|
||||
<textarea v-model.trim="permissionReason" maxlength="200" placeholder="授权原因(可选)" class="permission-reason" />
|
||||
<text v-if="permissionError" class="edit-error">{{ permissionError }}</text>
|
||||
</AppDialog>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
@@ -179,6 +198,7 @@ import {
|
||||
isRequestCancelled
|
||||
} from "@/services/api/request-controller.js";
|
||||
import { genealogyMemberApi } from "@/services/api/genealogy-member-service.js";
|
||||
import { genealogyCapabilityApi } from "@/services/api/genealogy-capability-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";
|
||||
@@ -198,6 +218,17 @@ const hasLeftGenealogy = ref(false);
|
||||
const personOptions = ref([]);
|
||||
const personOptionsState = ref("idle");
|
||||
const memberListController = createRequestController();
|
||||
const permissionTarget = ref(null);
|
||||
const selectedPermissionCodes = ref([]);
|
||||
const permissionReason = ref("");
|
||||
const permissionSaving = ref(false);
|
||||
const permissionLoading = ref(false);
|
||||
const permissionError = ref("");
|
||||
const permissionOptions = ref([]);
|
||||
const permissionCatalogController = createRequestController();
|
||||
const memberPermissionController = createRequestController();
|
||||
const memberPermissionSaveController = createRequestController();
|
||||
let permissionLoadSequence = 0;
|
||||
const personOptionsController = createRequestController();
|
||||
const memberUpdateController = createRequestController();
|
||||
const membershipOperationController = createRequestController();
|
||||
@@ -470,12 +501,86 @@ const returnToOverview = () =>
|
||||
hasLeftGenealogy.value
|
||||
? returnTo("G01")
|
||||
: returnTo("G05", { genealogyId: genealogyId.value });
|
||||
const openPermissionEditor = async (member) => {
|
||||
const activeLoad = ++permissionLoadSequence;
|
||||
permissionCatalogController.abort();
|
||||
memberPermissionController.abort();
|
||||
permissionTarget.value = member;
|
||||
permissionOptions.value = [];
|
||||
selectedPermissionCodes.value = [];
|
||||
permissionReason.value = "";
|
||||
permissionError.value = "";
|
||||
permissionLoading.value = true;
|
||||
try {
|
||||
const catalog = await genealogyCapabilityApi.getPermissionCatalog(
|
||||
genealogyId.value,
|
||||
{ requestController: permissionCatalogController },
|
||||
);
|
||||
const enabledCodes = catalog.filter((option) => option.enabled).map((option) => option.code);
|
||||
const permissions = await genealogyCapabilityApi.getMemberPermissions(
|
||||
genealogyId.value,
|
||||
member.memberId,
|
||||
enabledCodes,
|
||||
{ requestController: memberPermissionController },
|
||||
);
|
||||
if (!isPageActive || activeLoad !== permissionLoadSequence) return;
|
||||
permissionOptions.value = catalog;
|
||||
selectedPermissionCodes.value = permissions.permissionCodes;
|
||||
} catch (error) {
|
||||
if (!isPageActive || activeLoad !== permissionLoadSequence || isRequestCancelled(error)) return;
|
||||
permissionError.value = getRequestErrorMessage(error, "权限目录暂时无法读取,当前不会保存任何变更。");
|
||||
} finally {
|
||||
if (isPageActive && activeLoad === permissionLoadSequence) permissionLoading.value = false;
|
||||
}
|
||||
};
|
||||
const closePermissionEditor = () => {
|
||||
if (permissionSaving.value) return;
|
||||
permissionLoadSequence += 1;
|
||||
permissionCatalogController.abort();
|
||||
memberPermissionController.abort();
|
||||
permissionTarget.value = null;
|
||||
permissionLoading.value = false;
|
||||
};
|
||||
const togglePermission = (option) => {
|
||||
if (!option?.enabled || permissionSaving.value || permissionLoading.value) return;
|
||||
selectedPermissionCodes.value = selectedPermissionCodes.value.includes(option.code)
|
||||
? selectedPermissionCodes.value.filter((code) => code !== option.code)
|
||||
: [...selectedPermissionCodes.value, option.code];
|
||||
};
|
||||
const savePermissions = async () => {
|
||||
if (!permissionTarget.value || permissionSaving.value || permissionLoading.value || permissionError.value) return;
|
||||
const enabledCodes = permissionOptions.value.filter((option) => option.enabled).map((option) => option.code);
|
||||
permissionSaving.value = true;
|
||||
permissionError.value = "";
|
||||
try {
|
||||
await genealogyCapabilityApi.saveMemberPermissions(
|
||||
genealogyId.value,
|
||||
permissionTarget.value.memberId,
|
||||
selectedPermissionCodes.value,
|
||||
permissionReason.value,
|
||||
enabledCodes,
|
||||
{ requestController: memberPermissionSaveController },
|
||||
);
|
||||
if (!isPageActive) return;
|
||||
feedbackMessage.value = "管理员权限已保存";
|
||||
permissionTarget.value = null;
|
||||
} catch (error) {
|
||||
if (!isPageActive || isRequestCancelled(error)) return;
|
||||
permissionError.value = getRequestErrorMessage(error, "权限保存失败,请稍后重试。");
|
||||
} finally {
|
||||
if (isPageActive) permissionSaving.value = false;
|
||||
}
|
||||
};
|
||||
const requestBack = () =>
|
||||
runBackGuard({
|
||||
transientOpen: Boolean(editTarget.value || confirmTarget.value),
|
||||
submitting: operationPending.value,
|
||||
transientOpen: Boolean(editTarget.value || confirmTarget.value || permissionTarget.value),
|
||||
submitting: operationPending.value || permissionSaving.value,
|
||||
"close-transient": () =>
|
||||
editTarget.value ? closeEdit() : closeConfirmation(),
|
||||
editTarget.value
|
||||
? closeEdit()
|
||||
: confirmTarget.value
|
||||
? closeConfirmation()
|
||||
: closePermissionEditor(),
|
||||
"block-submitting": () => true,
|
||||
});
|
||||
|
||||
@@ -500,6 +605,9 @@ onUnload(() => {
|
||||
personOptionsController.abort();
|
||||
memberUpdateController.abort();
|
||||
membershipOperationController.abort();
|
||||
permissionCatalogController.abort();
|
||||
memberPermissionController.abort();
|
||||
memberPermissionSaveController.abort();
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -518,7 +626,7 @@ onUnload(() => {
|
||||
}
|
||||
|
||||
.page-content {
|
||||
padding: 18rpx 24rpx 72rpx;
|
||||
padding: 18rpx 24rpx calc(72rpx + env(safe-area-inset-bottom));
|
||||
}
|
||||
|
||||
.state-card,
|
||||
@@ -725,4 +833,12 @@ onUnload(() => {
|
||||
.edit-error {
|
||||
color: $brand-red;
|
||||
}
|
||||
|
||||
.permission-list { margin-top: 18rpx; }
|
||||
.permission-item { display: flex; min-height: 76rpx; align-items: flex-start; padding: 18rpx 0; border-bottom: 1rpx solid rgba($gold, 0.38); color: $ink; font-size: 26rpx; }
|
||||
.permission-item checkbox { margin-right: 16rpx; }
|
||||
.permission-item view { display: flex; min-width: 0; flex: 1; flex-direction: column; gap: 6rpx; }
|
||||
.permission-item view text:last-child:not(:first-child) { color: $ink-muted; font-size: 22rpx; line-height: 1.45; }
|
||||
.permission-item--disabled { opacity: 0.54; }
|
||||
.permission-reason { width: 100%; min-height: 100rpx; margin-top: 18rpx; padding: 16rpx; box-sizing: border-box; border: 1rpx solid rgba($gold, 0.38); border-radius: 10rpx; }
|
||||
</style>
|
||||
|
||||
@@ -239,7 +239,7 @@ onUnload(() => {
|
||||
@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; }
|
||||
.page-content { padding: 18rpx 24rpx calc(72rpx + env(safe-area-inset-bottom)); }
|
||||
.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; }
|
||||
@@ -251,7 +251,7 @@ onUnload(() => {
|
||||
.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:first-child { min-width: 0; flex: 1; 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; }
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
mode="aspectFit"
|
||||
/>
|
||||
<text class="state-title">暂时无法读取家谱</text>
|
||||
<text class="state-copy">网络或服务暂不可用,请稍后重新查看。</text>
|
||||
<text class="state-copy">{{ genealogyListError }}</text>
|
||||
<image
|
||||
class="error-panel__divider"
|
||||
src="/static/assets/modules/genealogy/transparent/section-divider.png"
|
||||
@@ -171,6 +171,64 @@
|
||||
/>
|
||||
</view>
|
||||
|
||||
<view class="featured-media-section">
|
||||
<view class="featured-media-heading">
|
||||
<view>
|
||||
<text class="featured-media-heading__title">宣传视频</text>
|
||||
<text class="featured-media-heading__copy">家谱文化与使用介绍</text>
|
||||
</view>
|
||||
<button class="featured-media-more" @click="openPlatformVideos">
|
||||
查看更多
|
||||
</button>
|
||||
</view>
|
||||
<view v-if="featuredVideoState === 'loading'" class="featured-media-state">
|
||||
<AppLoading text="正在读取宣传视频" />
|
||||
</view>
|
||||
<view v-else-if="featuredVideoState === 'error'" class="featured-media-state">
|
||||
<text>宣传视频暂时无法显示</text>
|
||||
<button class="featured-media-retry" @click="loadFeaturedVideos">重新加载</button>
|
||||
</view>
|
||||
<view v-else-if="featuredVideos.length" class="featured-media-grid">
|
||||
<view
|
||||
v-for="video in featuredVideos"
|
||||
:key="video.id"
|
||||
class="featured-media-card"
|
||||
>
|
||||
<view
|
||||
v-if="video.coverFile?.accessUrl"
|
||||
class="featured-media-cover-button"
|
||||
role="button"
|
||||
:aria-label="`播放${video.title}`"
|
||||
hover-class="action-hover"
|
||||
@click="openFeaturedVideo(video)"
|
||||
>
|
||||
<image
|
||||
class="featured-media-cover"
|
||||
:src="video.coverFile.accessUrl"
|
||||
mode="aspectFill"
|
||||
/>
|
||||
<view class="featured-media-play" aria-hidden="true"></view>
|
||||
</view>
|
||||
<video
|
||||
v-else
|
||||
class="featured-media-video"
|
||||
:src="video.videoFile.accessUrl"
|
||||
controls
|
||||
object-fit="cover"
|
||||
/>
|
||||
<button class="featured-media-title" @click="openFeaturedVideo(video)">
|
||||
{{ video.title }}
|
||||
</button>
|
||||
</view>
|
||||
</view>
|
||||
<view v-else class="featured-media-state featured-media-state--empty">
|
||||
<text>暂时没有推荐视频</text>
|
||||
<button class="featured-media-more featured-media-more--empty" @click="openPlatformVideos">
|
||||
查看全部视频
|
||||
</button>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="create-action" @click="openAddDialog">
|
||||
<image
|
||||
class="create-cloud"
|
||||
@@ -226,6 +284,12 @@
|
||||
>
|
||||
<text class="empty-create-action__copy">创建家谱</text>
|
||||
</view>
|
||||
<AppButton
|
||||
block
|
||||
type="secondary"
|
||||
label="观看宣传视频"
|
||||
@click="openPlatformVideos"
|
||||
/>
|
||||
<text class="empty-create-note"
|
||||
>确认没有现有家谱后再创建,避免重复建谱</text
|
||||
>
|
||||
@@ -279,7 +343,10 @@ import {
|
||||
createRequestController,
|
||||
isRequestCancelled
|
||||
} from "@/services/api/request-controller.js";
|
||||
import { getRequestErrorMessage } from "@/services/api/request-error-message.js";
|
||||
import { genealogyApi } from "@/services/api/genealogy-service.js";
|
||||
import { PLATFORM_VIDEO_PLACEMENT } from "@/services/api/family-media-contract.js";
|
||||
import { genealogyCapabilityApi } from "@/services/api/genealogy-capability-service.js";
|
||||
import { notificationApi } from "@/services/api/notification-service.js";
|
||||
import { genealogyContext } from "@/utils/genealogy/context.js";
|
||||
import {
|
||||
@@ -291,6 +358,7 @@ import {
|
||||
|
||||
const isLoading = ref(false);
|
||||
const hasError = ref(false);
|
||||
const genealogyListError = ref("");
|
||||
const genealogies = ref([]);
|
||||
const contextInvalidated = ref(false);
|
||||
const contextReconcileFailed = ref(false);
|
||||
@@ -304,14 +372,18 @@ const listScrollCommand = ref(0);
|
||||
const currentListScrollTop = ref(0);
|
||||
const unreadCount = ref(0);
|
||||
const creationQuota = ref(null);
|
||||
const featuredVideos = ref([]);
|
||||
const featuredVideoState = ref("loading");
|
||||
const genealogyListRequestController = createRequestController();
|
||||
const unreadRequestController = createRequestController();
|
||||
const quotaRequestController = createRequestController();
|
||||
const featuredVideoRequestController = createRequestController();
|
||||
// uni-app 的 abort 与成功回调可能在同一事件循环竞争。控制器负责取消任务,
|
||||
// generation 再阻止已经迟到的旧响应覆盖新页面状态,两层保护不能互相替代。
|
||||
let genealogyLoadGeneration = 0;
|
||||
let unreadLoadGeneration = 0;
|
||||
let quotaLoadGeneration = 0;
|
||||
let featuredVideoLoadGeneration = 0;
|
||||
let pageActive = true;
|
||||
let skipNextShowRefresh = true;
|
||||
|
||||
@@ -340,6 +412,7 @@ const reconcilePageGenealogyContext = () => {
|
||||
selectedGenealogyId.value = null;
|
||||
contextReconcileFailed.value = true;
|
||||
contextInvalidated.value = false;
|
||||
genealogyListError.value = "本机家谱选择状态异常,请重新加载。";
|
||||
hasError.value = true;
|
||||
return false;
|
||||
}
|
||||
@@ -350,6 +423,7 @@ const loadGenealogies = async () => {
|
||||
genealogyListRequestController.abort();
|
||||
isLoading.value = true;
|
||||
hasError.value = false;
|
||||
genealogyListError.value = "";
|
||||
try {
|
||||
const loadedGenealogies = await genealogyApi.getMyGenealogies({
|
||||
requestController: genealogyListRequestController,
|
||||
@@ -365,6 +439,10 @@ const loadGenealogies = async () => {
|
||||
) {
|
||||
return;
|
||||
}
|
||||
genealogyListError.value =
|
||||
error?.code === "GENEALOGY_RESPONSE_INVALID"
|
||||
? "服务返回的家谱数据不完整,请联系管理员处理。"
|
||||
: getRequestErrorMessage(error, "家谱服务暂时无法读取,请稍后重试。");
|
||||
hasError.value = true;
|
||||
} finally {
|
||||
if (pageActive && generation === genealogyLoadGeneration) {
|
||||
@@ -407,11 +485,37 @@ const loadQuota = async () => {
|
||||
}
|
||||
};
|
||||
|
||||
const loadFeaturedVideos = async () => {
|
||||
const generation = ++featuredVideoLoadGeneration;
|
||||
featuredVideoRequestController.abort();
|
||||
featuredVideoState.value = "loading";
|
||||
try {
|
||||
const rows = await genealogyCapabilityApi.getPlatformVideos(
|
||||
PLATFORM_VIDEO_PLACEMENT.HOME_FEATURED,
|
||||
{ requestController: featuredVideoRequestController },
|
||||
);
|
||||
if (!pageActive || generation !== featuredVideoLoadGeneration) return;
|
||||
featuredVideos.value = rows.slice(0, 2);
|
||||
featuredVideoState.value = "ready";
|
||||
} catch (error) {
|
||||
if (
|
||||
!pageActive ||
|
||||
generation !== featuredVideoLoadGeneration ||
|
||||
isRequestCancelled(error)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
featuredVideos.value = [];
|
||||
featuredVideoState.value = "error";
|
||||
}
|
||||
};
|
||||
|
||||
onLoad((query) => {
|
||||
requestedGenealogyId.value = String(query?.genealogyId || "");
|
||||
loadGenealogies();
|
||||
loadUnreadCount();
|
||||
loadQuota();
|
||||
loadFeaturedVideos();
|
||||
});
|
||||
|
||||
onShow(() => {
|
||||
@@ -423,6 +527,7 @@ onShow(() => {
|
||||
) {
|
||||
requestedGenealogyId.value = navigationResult.entityId;
|
||||
loadGenealogies();
|
||||
loadFeaturedVideos();
|
||||
return;
|
||||
}
|
||||
if (skipNextShowRefresh) {
|
||||
@@ -432,6 +537,7 @@ onShow(() => {
|
||||
loadGenealogies();
|
||||
loadUnreadCount();
|
||||
loadQuota();
|
||||
loadFeaturedVideos();
|
||||
});
|
||||
|
||||
onUnload(() => {
|
||||
@@ -439,9 +545,11 @@ onUnload(() => {
|
||||
genealogyLoadGeneration += 1;
|
||||
unreadLoadGeneration += 1;
|
||||
quotaLoadGeneration += 1;
|
||||
featuredVideoLoadGeneration += 1;
|
||||
genealogyListRequestController.abort();
|
||||
unreadRequestController.abort();
|
||||
quotaRequestController.abort();
|
||||
featuredVideoRequestController.abort();
|
||||
});
|
||||
|
||||
const hasGenealogies = computed(() => genealogies.value.length > 0);
|
||||
@@ -536,6 +644,21 @@ const openSwitcher = () => {
|
||||
const closeSwitcher = () => {
|
||||
switcherVisible.value = false;
|
||||
};
|
||||
const openPlatformVideos = () =>
|
||||
openPage(
|
||||
"F11",
|
||||
{ placement: PLATFORM_VIDEO_PLACEMENT.VIDEO_CENTER },
|
||||
"G01",
|
||||
);
|
||||
const openFeaturedVideo = (video) =>
|
||||
openPage(
|
||||
"F11",
|
||||
{
|
||||
placement: PLATFORM_VIDEO_PLACEMENT.HOME_FEATURED,
|
||||
videoId: String(video.id),
|
||||
},
|
||||
"G01",
|
||||
);
|
||||
const openOrderDialog = () => {
|
||||
if (genealogies.value.length < 2) return;
|
||||
orderDialogVisible.value = true;
|
||||
@@ -596,7 +719,7 @@ const openShortcut = (shortcutKey) => {
|
||||
const genealogyId = String(currentGenealogy.value.id);
|
||||
const actions = {
|
||||
tree: () => openPage("T01", { genealogyId }, "G01"),
|
||||
members: () => openPage("G05", { genealogyId }, "G01"),
|
||||
members: () => openPage("G13", { genealogyId }, "G01"),
|
||||
poem: () => openPage("G12", { genealogyId }, "G01"),
|
||||
applications: () => openPage("G10", { genealogyId }, "G01"),
|
||||
};
|
||||
@@ -617,7 +740,7 @@ const openShortcut = (shortcutKey) => {
|
||||
|
||||
.genealogy-content {
|
||||
z-index: 1;
|
||||
padding: 24rpx 32rpx 176rpx;
|
||||
padding: 24rpx 32rpx calc(176rpx + env(safe-area-inset-bottom));
|
||||
}
|
||||
|
||||
.genealogy-index--split {
|
||||
@@ -855,6 +978,149 @@ const openShortcut = (shortcutKey) => {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.featured-media-section {
|
||||
margin: 0 0 24rpx;
|
||||
padding: 26rpx 24rpx;
|
||||
border: 1rpx solid rgba(149, 103, 49, 0.24);
|
||||
border-radius: 14rpx;
|
||||
background: rgba(255, 250, 240, 0.72);
|
||||
}
|
||||
|
||||
.featured-media-heading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 20rpx;
|
||||
}
|
||||
|
||||
.featured-media-heading__title,
|
||||
.featured-media-heading__copy {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.featured-media-heading__title {
|
||||
color: #5c4330;
|
||||
font-size: clamp(16px, 28rpx, 20px);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.featured-media-heading__copy {
|
||||
margin-top: 8rpx;
|
||||
color: #8a7564;
|
||||
font-size: clamp(14px, 23rpx, 17px);
|
||||
}
|
||||
|
||||
.featured-media-more,
|
||||
.featured-media-retry,
|
||||
.featured-media-title {
|
||||
min-height: var(--app-touch-min);
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.featured-media-more::after,
|
||||
.featured-media-retry::after,
|
||||
.featured-media-title::after {
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.featured-media-more,
|
||||
.featured-media-retry {
|
||||
flex: 0 0 auto;
|
||||
color: $brand-red;
|
||||
font-size: clamp(13px, 22rpx, 16px);
|
||||
}
|
||||
|
||||
.featured-media-state {
|
||||
display: flex;
|
||||
min-height: 176rpx;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 10rpx;
|
||||
color: $ink-muted;
|
||||
font-size: clamp(14px, 23rpx, 17px);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.featured-media-state--empty {
|
||||
min-height: 138rpx;
|
||||
}
|
||||
|
||||
.featured-media-more--empty {
|
||||
margin-top: 4rpx;
|
||||
}
|
||||
|
||||
.featured-media-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 16rpx;
|
||||
margin-top: 22rpx;
|
||||
}
|
||||
|
||||
.featured-media-card {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.featured-media-cover-button,
|
||||
.featured-media-video {
|
||||
width: 100%;
|
||||
height: 176rpx;
|
||||
border-radius: 10rpx;
|
||||
background: #1f1b17;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.featured-media-cover-button {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.featured-media-cover {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.featured-media-play {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
display: flex;
|
||||
width: 58rpx;
|
||||
height: 58rpx;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 2rpx solid rgba(255, 255, 255, 0.9);
|
||||
border-radius: 50%;
|
||||
background: rgba(45, 29, 19, 0.62);
|
||||
transform: translate(-50%, -50%);
|
||||
}
|
||||
|
||||
.featured-media-play::after {
|
||||
width: 0;
|
||||
height: 0;
|
||||
margin-left: 5rpx;
|
||||
border-top: 10rpx solid transparent;
|
||||
border-bottom: 10rpx solid transparent;
|
||||
border-left: 16rpx solid #fff;
|
||||
content: "";
|
||||
}
|
||||
|
||||
.featured-media-title {
|
||||
display: block;
|
||||
width: 100%;
|
||||
margin-top: 8rpx;
|
||||
color: $ink;
|
||||
font-size: clamp(14px, 24rpx, 17px);
|
||||
font-weight: 600;
|
||||
text-align: left;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.create-action {
|
||||
display: flex;
|
||||
min-height: 96rpx;
|
||||
@@ -1076,7 +1342,7 @@ const openShortcut = (shortcutKey) => {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 76rpx;
|
||||
min-height: var(--app-touch-min);
|
||||
margin-top: 14rpx;
|
||||
color: $brand-red;
|
||||
}
|
||||
|
||||
@@ -160,6 +160,30 @@
|
||||
mode="aspectFit"
|
||||
/>
|
||||
</view>
|
||||
<view class="overview-action" @click="toPersonDocuments">
|
||||
<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="canManageGenealogy" class="overview-action" @click="toCapabilityCenter">
|
||||
<image class="overview-action__icon" src="/static/assets/modules/genealogy/transparent/settings-gear.png" mode="aspectFit" />
|
||||
<view class="overview-action__copy">
|
||||
<text class="overview-action__title">资料与回收站</text>
|
||||
<text>查看资料完整度,恢复误删的家谱内容</text>
|
||||
</view>
|
||||
<image class="overview-action__chevron" src="/static/assets/foundation/transparent/chevron-right.png" mode="aspectFit" />
|
||||
</view>
|
||||
<template v-else>
|
||||
<view class="overview-summary">
|
||||
<text class="overview-action__title">成员身份</text>
|
||||
@@ -308,7 +332,7 @@ const loadGenealogy = async (query = {}) => {
|
||||
genealogyId.value = String(query.genealogyId || genealogyId.value || "");
|
||||
canManageGenealogy.value = false;
|
||||
|
||||
if (!genealogyId.value) {
|
||||
if (!/^[1-9]\d*$/.test(genealogyId.value)) {
|
||||
overviewState.value = "empty";
|
||||
return;
|
||||
}
|
||||
@@ -363,10 +387,13 @@ const toGenerationPoems = () =>
|
||||
openPage("G12", { genealogyId: genealogyId.value }, "G05");
|
||||
const toMembers = () =>
|
||||
openPage("G13", { genealogyId: genealogyId.value }, "G05");
|
||||
const toPersonDocuments = () =>
|
||||
openPage("R12", { genealogyId: genealogyId.value }, "G05");
|
||||
const openInvitationManager = () => {
|
||||
if (overviewState.value !== "ready" || !genealogyId.value) return;
|
||||
invitationManager.value?.open();
|
||||
};
|
||||
const toCapabilityCenter = () => openPage("G14", { genealogyId: genealogyId.value }, "G05");
|
||||
const requestBack = () => {
|
||||
if (invitationBusy.value || invitationTransientOpen.value) {
|
||||
invitationManager.value?.closeTransient();
|
||||
|
||||
+152
-15
@@ -2,7 +2,7 @@
|
||||
<view class="search-page">
|
||||
<GenealogyPageBackground />
|
||||
<view class="page-header"
|
||||
><PageHeader title="搜索家谱" custom-back @back="backToGenealogies"
|
||||
><PageHeader title="搜索家谱" custom-back @back="requestBack"
|
||||
/></view>
|
||||
<view class="page-content">
|
||||
<view class="invite-card">
|
||||
@@ -45,8 +45,41 @@
|
||||
</view>
|
||||
<view class="search-note"
|
||||
><text>公开家谱</text
|
||||
><text>以下是可加入的公开家谱。</text></view
|
||||
><text>输入谱名或姓氏,查找可以申请加入的公开家谱。</text></view
|
||||
>
|
||||
<view class="public-search-card">
|
||||
<text class="public-search-card__label">查找公开家谱</text>
|
||||
<view class="public-search-card__control">
|
||||
<input
|
||||
v-model="searchKeyword"
|
||||
maxlength="50"
|
||||
confirm-type="search"
|
||||
placeholder="请输入谱名或姓氏"
|
||||
aria-label="公开家谱搜索关键词"
|
||||
@confirm="searchGenealogies"
|
||||
/>
|
||||
<button
|
||||
v-if="searchKeyword"
|
||||
class="public-search-card__clear"
|
||||
aria-label="清除家谱搜索关键词"
|
||||
@click="clearSearchKeyword"
|
||||
>
|
||||
清除
|
||||
</button>
|
||||
</view>
|
||||
<text
|
||||
v-if="genealogySearchState === 'ready'"
|
||||
class="public-search-card__result"
|
||||
role="status"
|
||||
>{{ searchResultCopy }}</text>
|
||||
<AppButton
|
||||
block
|
||||
type="secondary"
|
||||
:disabled="genealogySearchState === 'loading'"
|
||||
:label="genealogySearchState === 'loading' ? '正在搜索' : '搜索公开家谱'"
|
||||
@click="searchGenealogies"
|
||||
/>
|
||||
</view>
|
||||
<view v-if="genealogySearchState === 'loading'" class="state-card"
|
||||
><AppLoading
|
||||
text="正在读取公开家谱"
|
||||
@@ -64,8 +97,13 @@
|
||||
<view v-else-if="genealogySearchState === 'empty'" class="state-card"
|
||||
><text>暂未找到公开家谱</text></view
|
||||
>
|
||||
<view v-else-if="!filteredRows.length" class="state-card">
|
||||
<text>没有找到匹配的公开家谱</text>
|
||||
<text class="state-card__copy">可尝试缩短关键词,或改用姓氏查找。</text>
|
||||
<AppButton block type="secondary" label="清除关键词" @click="clearSearchKeyword" />
|
||||
</view>
|
||||
<view v-else class="result-list">
|
||||
<view v-for="item in rows" :key="item.id" class="genealogy-card">
|
||||
<view v-for="item in filteredRows" :key="item.id" class="genealogy-card">
|
||||
<view class="card-heading"
|
||||
><text>{{ item.name }}</text
|
||||
><text v-if="item.surname">{{ item.surname }}氏</text></view
|
||||
@@ -77,8 +115,8 @@
|
||||
<view class="card-footer"
|
||||
><text>{{ item.memberCount }} 位成员</text
|
||||
><AppButton
|
||||
:label="item.canManage ? '已在我的家谱' : '申请加入'"
|
||||
:disabled="item.canManage"
|
||||
:label="item.hasMembership ? '已在我的家谱' : '申请加入'"
|
||||
:disabled="item.hasMembership"
|
||||
@click="applyToJoin(item)"
|
||||
/></view>
|
||||
</view>
|
||||
@@ -101,7 +139,7 @@
|
||||
|
||||
<script setup>
|
||||
import { computed, ref } from "vue";
|
||||
import { onLoad, onShow, onUnload } from "@dcloudio/uni-app";
|
||||
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";
|
||||
@@ -115,10 +153,12 @@ import { genealogyMembershipApi } from "@/services/api/genealogy-membership-serv
|
||||
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";
|
||||
import { handleBackPress, openPage, returnTo } from "@/utils/navigation/gateway.js";
|
||||
|
||||
const rows = ref([]);
|
||||
const genealogySearchState = ref("loading");
|
||||
const searchKeyword = ref("");
|
||||
const loadedSearchKeyword = ref("");
|
||||
const inviteToken = ref("");
|
||||
const inviteState = ref("idle");
|
||||
const inviteError = ref("");
|
||||
@@ -130,15 +170,40 @@ const invitationPreviewController = createRequestController();
|
||||
const invitationRedemptionController = createRequestController();
|
||||
const invitationRedemptionGuard = createNonIdempotentWriteGuard();
|
||||
let isPageActive = true;
|
||||
const loadGenealogies = async () => {
|
||||
const normalizedSearchKeyword = computed(() =>
|
||||
searchKeyword.value.trim().toLocaleLowerCase(),
|
||||
);
|
||||
const filteredRows = computed(() => {
|
||||
if (!normalizedSearchKeyword.value) return rows.value;
|
||||
return rows.value.filter((genealogy) =>
|
||||
[genealogy.name, genealogy.surname].some((fieldValue) =>
|
||||
String(fieldValue || "")
|
||||
.toLocaleLowerCase()
|
||||
.includes(normalizedSearchKeyword.value),
|
||||
),
|
||||
);
|
||||
});
|
||||
const searchResultCopy = computed(() =>
|
||||
normalizedSearchKeyword.value
|
||||
? `找到 ${filteredRows.value.length} 部匹配家谱`
|
||||
: `共 ${rows.value.length} 部公开家谱`,
|
||||
);
|
||||
const clearSearchKeyword = () => {
|
||||
searchKeyword.value = "";
|
||||
if (loadedSearchKeyword.value) void loadGenealogies("");
|
||||
};
|
||||
const loadGenealogies = async (keyword = searchKeyword.value) => {
|
||||
publicGenealogyListController.abort();
|
||||
genealogySearchState.value = "loading";
|
||||
try {
|
||||
const publicGenealogies = await genealogyApi.getPublicGenealogies({
|
||||
requestController: publicGenealogyListController,
|
||||
});
|
||||
const normalizedKeyword = String(keyword || "").trim();
|
||||
const publicGenealogies = await genealogyApi.getPublicGenealogies(
|
||||
{ keyword: normalizedKeyword },
|
||||
{ requestController: publicGenealogyListController },
|
||||
);
|
||||
if (!isPageActive) return;
|
||||
rows.value = publicGenealogies;
|
||||
loadedSearchKeyword.value = normalizedKeyword;
|
||||
genealogySearchState.value = rows.value.length ? "ready" : "empty";
|
||||
} catch (error) {
|
||||
if (!isPageActive || isRequestCancelled(error)) return;
|
||||
@@ -196,6 +261,7 @@ const previewInvite = async () => {
|
||||
inviteError.value = getRequestErrorMessage(error, "邀请码暂时无法查看,请检查后重试。");
|
||||
}
|
||||
};
|
||||
const searchGenealogies = () => loadGenealogies(searchKeyword.value);
|
||||
const openRedeemConfirmation = () => {
|
||||
if (inviteState.value === "ready" && invitePreview.value)
|
||||
redeemConfirmationVisible.value = true;
|
||||
@@ -203,6 +269,14 @@ const openRedeemConfirmation = () => {
|
||||
const closeRedeemConfirmation = () => {
|
||||
if (inviteState.value !== "redeeming") redeemConfirmationVisible.value = false;
|
||||
};
|
||||
const requestBack = () => {
|
||||
if (inviteState.value === "redeeming") return true;
|
||||
if (redeemConfirmationVisible.value) {
|
||||
closeRedeemConfirmation();
|
||||
return true;
|
||||
}
|
||||
return backToGenealogies();
|
||||
};
|
||||
const redeemInvite = async () => {
|
||||
if (inviteState.value !== "ready" || !invitePreview.value) return;
|
||||
const redemptionPayload = { token: inviteToken.value };
|
||||
@@ -242,9 +316,9 @@ const handleInviteResult = () =>
|
||||
inviteResult.value?.redemptionResult === "DIRECT_MEMBER"
|
||||
? returnTo("G01")
|
||||
: openPage("G09", {}, "G06");
|
||||
onLoad(loadGenealogies);
|
||||
onLoad(() => loadGenealogies(""));
|
||||
onShow(() => {
|
||||
if (genealogySearchState.value !== "loading") loadGenealogies();
|
||||
if (genealogySearchState.value !== "loading") loadGenealogies(searchKeyword.value);
|
||||
});
|
||||
onUnload(() => {
|
||||
isPageActive = false;
|
||||
@@ -252,6 +326,7 @@ onUnload(() => {
|
||||
invitationPreviewController.abort();
|
||||
invitationRedemptionController.abort();
|
||||
});
|
||||
onBackPress((event) => handleBackPress(event, requestBack));
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@@ -267,9 +342,10 @@ onUnload(() => {
|
||||
z-index: 1;
|
||||
}
|
||||
.page-content {
|
||||
padding: 18rpx 24rpx 72rpx;
|
||||
padding: 18rpx 24rpx calc(72rpx + env(safe-area-inset-bottom));
|
||||
}
|
||||
.search-note,
|
||||
.public-search-card,
|
||||
.state-card,
|
||||
.genealogy-card,
|
||||
.invite-card {
|
||||
@@ -303,7 +379,7 @@ onUnload(() => {
|
||||
@include adaptive-genealogy-form-field;
|
||||
display: block;
|
||||
width: 100%;
|
||||
min-height: 78rpx;
|
||||
min-height: var(--app-touch-min);
|
||||
margin-top: 18rpx;
|
||||
padding: 0 22rpx;
|
||||
box-sizing: border-box;
|
||||
@@ -349,6 +425,58 @@ onUnload(() => {
|
||||
color: $ink-muted;
|
||||
font-size: clamp(13px, 21rpx, 16px);
|
||||
}
|
||||
.public-search-card {
|
||||
margin-top: 18rpx;
|
||||
padding: 24rpx 30rpx;
|
||||
}
|
||||
.public-search-card__label,
|
||||
.public-search-card__result {
|
||||
display: block;
|
||||
}
|
||||
.public-search-card__label {
|
||||
color: $ink;
|
||||
font-size: clamp(15px, 24rpx, 18px);
|
||||
font-weight: 700;
|
||||
}
|
||||
.public-search-card__control {
|
||||
@include adaptive-genealogy-form-field;
|
||||
display: flex;
|
||||
min-height: var(--app-touch-min);
|
||||
align-items: center;
|
||||
margin-top: 12rpx;
|
||||
padding: 0 10rpx 0 20rpx;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.public-search-card__control input {
|
||||
min-width: 0;
|
||||
min-height: var(--app-touch-min);
|
||||
flex: 1;
|
||||
color: $ink;
|
||||
font-size: clamp(15px, 24rpx, 18px);
|
||||
}
|
||||
.public-search-card__clear {
|
||||
min-width: 96rpx;
|
||||
min-height: calc(var(--app-touch-min) - 16rpx);
|
||||
margin: 0;
|
||||
padding: 0 14rpx;
|
||||
border: 0;
|
||||
border-radius: 8rpx;
|
||||
background: transparent;
|
||||
color: $brand-red;
|
||||
font-size: clamp(13px, 21rpx, 16px);
|
||||
line-height: calc(var(--app-touch-min) - 16rpx);
|
||||
}
|
||||
.public-search-card__clear::after {
|
||||
border: 0;
|
||||
}
|
||||
.public-search-card__result {
|
||||
margin-top: 10rpx;
|
||||
color: $ink-muted;
|
||||
font-size: clamp(13px, 21rpx, 16px);
|
||||
}
|
||||
.public-search-card > .app-button {
|
||||
margin-top: 16rpx;
|
||||
}
|
||||
.state-card {
|
||||
display: flex;
|
||||
min-height: 310rpx;
|
||||
@@ -361,6 +489,13 @@ onUnload(() => {
|
||||
color: $ink;
|
||||
font-size: clamp(16px, 28rpx, 20px);
|
||||
}
|
||||
.state-card__copy {
|
||||
display: block;
|
||||
margin-top: 10rpx;
|
||||
color: $ink-muted;
|
||||
font-size: clamp(14px, 22rpx, 17px);
|
||||
line-height: 1.55;
|
||||
}
|
||||
.state-card .app-button {
|
||||
width: 100%;
|
||||
margin-top: 22rpx;
|
||||
@@ -381,6 +516,8 @@ onUnload(() => {
|
||||
gap: 18rpx;
|
||||
}
|
||||
.card-heading text:first-child {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
color: $ink;
|
||||
font-family: STKaiti, KaiTi, serif;
|
||||
font-size: clamp(17px, 31rpx, 22px);
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<view class="settings-page" :class="`settings-state--${pageState}`">
|
||||
<GenealogyPageBackground />
|
||||
<view class="page-header"
|
||||
><PageHeader title="家谱设置" custom-back @back="returnToOverview"
|
||||
><PageHeader title="家谱设置" custom-back @back="requestBack"
|
||||
/></view>
|
||||
|
||||
<view class="page-content">
|
||||
@@ -142,6 +142,7 @@
|
||||
<text v-if="coverFileName" class="upload-receipt"
|
||||
>已上传:{{ coverFileName }}</text
|
||||
>
|
||||
<button v-if="coverOssId" class="remove-cover-button" :disabled="isUploading || isSubmitting" @click="clearCover">移除封面</button>
|
||||
</view>
|
||||
<text v-if="uploadError" class="field-error">{{ uploadError }}</text>
|
||||
|
||||
@@ -186,6 +187,24 @@
|
||||
@click="requestLifecycleChange"
|
||||
/>
|
||||
</view>
|
||||
<view
|
||||
v-if="canDeletePermanently || permanentDeletionDisabledReason"
|
||||
class="permanent-deletion"
|
||||
>
|
||||
<text>永久删除家谱</text>
|
||||
<text>{{
|
||||
canDeletePermanently
|
||||
? "该操作会永久删除家谱及其业务内容,无法从回收站恢复。"
|
||||
: permanentDeletionDisabledReason
|
||||
}}</text>
|
||||
<AppButton
|
||||
block
|
||||
type="secondary"
|
||||
:disabled="!canDeletePermanently || permanentDeletionSubmitting"
|
||||
label="永久删除家谱"
|
||||
@click="openPermanentDeletion"
|
||||
/>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view v-else class="state-card">
|
||||
@@ -206,6 +225,54 @@
|
||||
@confirm="confirmLifecycleChange"
|
||||
@cancel="lifecycleDialogVisible = false"
|
||||
/>
|
||||
<AppDialog
|
||||
:visible="discardVisible"
|
||||
eyebrow="未保存修改"
|
||||
title="放弃家谱设置修改?"
|
||||
message="当前修改还没有保存。"
|
||||
confirm-text="确认放弃"
|
||||
cancel-text="继续编辑"
|
||||
show-cancel
|
||||
:close-on-mask="false"
|
||||
@confirm="confirmDiscard"
|
||||
@cancel="cancelDiscard"
|
||||
/>
|
||||
<AppDialog
|
||||
:visible="permanentDeletionVisible"
|
||||
eyebrow="高风险操作"
|
||||
title="永久删除这部家谱?"
|
||||
:message="`请输入完整谱名“${form.genealogyName}”,并通过账号短信验证。删除后无法恢复。`"
|
||||
:confirm-text="permanentDeletionSubmitting ? '正在删除' : '确认永久删除'"
|
||||
cancel-text="取消"
|
||||
show-cancel
|
||||
:close-on-mask="false"
|
||||
@confirm="confirmPermanentDeletion"
|
||||
@cancel="closePermanentDeletion"
|
||||
>
|
||||
<input
|
||||
v-model="permanentDeletionConfirmationName"
|
||||
class="deletion-dialog-input"
|
||||
maxlength="24"
|
||||
:placeholder="`输入谱名:${form.genealogyName}`"
|
||||
/>
|
||||
<view class="deletion-code-row">
|
||||
<input
|
||||
v-model.trim="permanentDeletionSmsCode"
|
||||
class="deletion-dialog-input"
|
||||
type="number"
|
||||
maxlength="4"
|
||||
:placeholder="`验证码将发送至 ${deletionVerificationPhoneMasked}`"
|
||||
/>
|
||||
<AppButton
|
||||
compact
|
||||
type="secondary"
|
||||
:disabled="permanentDeletionSubmitting || deletionCodeSending || deletionCodeCooldown > 0"
|
||||
:label="deletionCodeButtonLabel"
|
||||
@click="sendPermanentDeletionCode"
|
||||
/>
|
||||
</view>
|
||||
<text v-if="permanentDeletionError" class="submit-error">{{ permanentDeletionError }}</text>
|
||||
</AppDialog>
|
||||
|
||||
<RegionPickerDialog
|
||||
ref="regionPickerDialog"
|
||||
@@ -213,13 +280,14 @@
|
||||
@select="selectRegion"
|
||||
@loading-change="regionLoading = $event"
|
||||
@error-change="regionPickerError = $event"
|
||||
@transient-change="regionPickerVisible = $event"
|
||||
/>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, onMounted, reactive, ref } from "vue";
|
||||
import { onLoad, onUnload } from "@dcloudio/uni-app";
|
||||
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";
|
||||
@@ -245,7 +313,10 @@ import {
|
||||
isImagePickCancelled,
|
||||
pickAndUploadImage,
|
||||
} from "@/utils/media-upload.js";
|
||||
import { goBack, returnTo } from "@/utils/navigation/gateway.js";
|
||||
import { createDiscardConfirmation } from "@/utils/discard-confirmation.js";
|
||||
import { genealogyContext } from "@/utils/genealogy/context.js";
|
||||
import { createNonIdempotentWriteGuard } from "@/utils/non-idempotent-write-guard.js";
|
||||
import { goBack, handleBackPress, returnTo } from "@/utils/navigation/gateway.js";
|
||||
|
||||
const genealogyId = ref("");
|
||||
const pageState = ref("loading");
|
||||
@@ -272,8 +343,18 @@ const coverFileName = ref("");
|
||||
const lifecycleStatus = ref(GENEALOGY_LIFECYCLE_STATUS.NORMAL);
|
||||
const canArchive = ref(false);
|
||||
const canRestore = ref(false);
|
||||
const canDeletePermanently = ref(false);
|
||||
const permanentDeletionDisabledReason = ref("");
|
||||
const deletionVerificationPhoneMasked = ref("");
|
||||
const lifecycleDialogVisible = ref(false);
|
||||
const lifecycleSubmitting = ref(false);
|
||||
const permanentDeletionVisible = ref(false);
|
||||
const permanentDeletionConfirmationName = ref("");
|
||||
const permanentDeletionSmsCode = ref("");
|
||||
const permanentDeletionError = ref("");
|
||||
const permanentDeletionSubmitting = ref(false);
|
||||
const deletionCodeSending = ref(false);
|
||||
const deletionCodeCooldown = ref(0);
|
||||
const currentRegionCode = ref("");
|
||||
const currentRegionDisplay = ref("");
|
||||
const selectedRegion = ref(null);
|
||||
@@ -281,13 +362,28 @@ const regionPickerTrail = ref([]);
|
||||
const regionPickerDialog = ref(null);
|
||||
const regionPickerError = ref("");
|
||||
const regionLoading = ref(false);
|
||||
const regionPickerVisible = ref(false);
|
||||
const settingsBaseline = ref("");
|
||||
const discardVisible = ref(false);
|
||||
const settingsReadRequestController = createRequestController();
|
||||
const deletionCapabilityReadRequestController = createRequestController();
|
||||
const genealogyLifecycleRequestController = createRequestController();
|
||||
const deletionCodeRequestController = createRequestController();
|
||||
const permanentDeletionRequestController = createRequestController();
|
||||
const coverUploadRequestController = createRequestController();
|
||||
const settingsSaveRequestController = createRequestController();
|
||||
// 封面上传、归档和设置保存属于独立操作。分别持有取消槽,避免后发操作
|
||||
// 取消前一条仍在收敛的请求,留下无法解释的加载状态。
|
||||
let pageActive = true;
|
||||
let deletionCodeTimer = null;
|
||||
const permanentDeletionGuard = createNonIdempotentWriteGuard();
|
||||
|
||||
const discardConfirmation = createDiscardConfirmation((visible) => {
|
||||
discardVisible.value = visible;
|
||||
});
|
||||
const requestDiscardConfirmation = discardConfirmation.request;
|
||||
const confirmDiscard = discardConfirmation.confirm;
|
||||
const cancelDiscard = discardConfirmation.cancel;
|
||||
|
||||
const hasValidContext = computed(() => /^[1-9]\d*$/.test(genealogyId.value));
|
||||
const regionDisplay = computed(() =>
|
||||
@@ -295,6 +391,23 @@ const regionDisplay = computed(() =>
|
||||
? regionPickerTrail.value.map((regionNode) => regionNode.label).join(" / ")
|
||||
: currentRegionDisplay.value,
|
||||
);
|
||||
const settingsSnapshot = computed(() => JSON.stringify({
|
||||
...form,
|
||||
regionCode: selectedRegion.value?.regionCode || currentRegionCode.value,
|
||||
coverOssId: coverOssId.value || null,
|
||||
}));
|
||||
const isDirty = computed(() =>
|
||||
pageState.value === "form" &&
|
||||
Boolean(settingsBaseline.value) &&
|
||||
settingsSnapshot.value !== settingsBaseline.value,
|
||||
);
|
||||
const deletionCodeButtonLabel = computed(() =>
|
||||
deletionCodeSending.value
|
||||
? "正在发送"
|
||||
: deletionCodeCooldown.value > 0
|
||||
? `${deletionCodeCooldown.value}s 后重发`
|
||||
: "发送验证码",
|
||||
);
|
||||
const stateCopy = computed(() => {
|
||||
if (pageState.value === "success") {
|
||||
return {
|
||||
@@ -379,6 +492,22 @@ const loadSettings = async () => {
|
||||
canArchive.value = settings.canArchive;
|
||||
canRestore.value = settings.canRestore;
|
||||
pageState.value = "form";
|
||||
settingsBaseline.value = settingsSnapshot.value;
|
||||
try {
|
||||
const deletionCapability = await genealogyApi.getPermanentDeletionCapability(
|
||||
genealogyId.value,
|
||||
{ requestController: deletionCapabilityReadRequestController },
|
||||
);
|
||||
if (!pageActive) return;
|
||||
canDeletePermanently.value = deletionCapability.canDeletePermanently;
|
||||
permanentDeletionDisabledReason.value = deletionCapability.disabledReasons.join(";");
|
||||
deletionVerificationPhoneMasked.value = deletionCapability.phoneMasked;
|
||||
} catch (error) {
|
||||
if (!pageActive || isRequestCancelled(error)) return;
|
||||
canDeletePermanently.value = false;
|
||||
permanentDeletionDisabledReason.value = "永久注销资格暂时无法读取,请稍后重试。";
|
||||
deletionVerificationPhoneMasked.value = "";
|
||||
}
|
||||
} catch (error) {
|
||||
if (!pageActive || isRequestCancelled(error)) return;
|
||||
pageState.value = "error";
|
||||
@@ -404,6 +533,13 @@ const confirmLifecycleChange = async () => {
|
||||
lifecycleStatus.value = lifecycleResult.lifecycleStatus;
|
||||
canArchive.value = lifecycleResult.canArchive;
|
||||
canRestore.value = lifecycleResult.canRestore;
|
||||
const deletionCapability = await genealogyApi.getPermanentDeletionCapability(
|
||||
genealogyId.value,
|
||||
{ requestController: genealogyLifecycleRequestController },
|
||||
);
|
||||
canDeletePermanently.value = deletionCapability.canDeletePermanently;
|
||||
permanentDeletionDisabledReason.value = deletionCapability.disabledReasons.join(";");
|
||||
deletionVerificationPhoneMasked.value = deletionCapability.phoneMasked;
|
||||
lifecycleDialogVisible.value = false;
|
||||
} catch (error) {
|
||||
if (!pageActive || isRequestCancelled(error)) return;
|
||||
@@ -414,6 +550,92 @@ const confirmLifecycleChange = async () => {
|
||||
}
|
||||
};
|
||||
|
||||
const stopDeletionCodeCooldown = () => {
|
||||
if (deletionCodeTimer) clearInterval(deletionCodeTimer);
|
||||
deletionCodeTimer = null;
|
||||
};
|
||||
const startDeletionCodeCooldown = (seconds) => {
|
||||
stopDeletionCodeCooldown();
|
||||
deletionCodeCooldown.value = seconds;
|
||||
deletionCodeTimer = setInterval(() => {
|
||||
deletionCodeCooldown.value = Math.max(0, deletionCodeCooldown.value - 1);
|
||||
if (deletionCodeCooldown.value === 0) stopDeletionCodeCooldown();
|
||||
}, 1000);
|
||||
};
|
||||
const openPermanentDeletion = () => {
|
||||
if (!canDeletePermanently.value || permanentDeletionSubmitting.value) return;
|
||||
permanentDeletionConfirmationName.value = "";
|
||||
permanentDeletionSmsCode.value = "";
|
||||
permanentDeletionError.value = "";
|
||||
permanentDeletionVisible.value = true;
|
||||
};
|
||||
const closePermanentDeletion = () => {
|
||||
if (permanentDeletionSubmitting.value || deletionCodeSending.value) return;
|
||||
permanentDeletionVisible.value = false;
|
||||
permanentDeletionError.value = "";
|
||||
};
|
||||
const sendPermanentDeletionCode = async () => {
|
||||
if (!canDeletePermanently.value || deletionCodeSending.value || permanentDeletionSubmitting.value || deletionCodeCooldown.value > 0) return;
|
||||
deletionCodeSending.value = true;
|
||||
permanentDeletionError.value = "";
|
||||
try {
|
||||
await genealogyApi.sendPermanentDeletionCode(
|
||||
genealogyId.value,
|
||||
{ requestController: deletionCodeRequestController },
|
||||
);
|
||||
if (!pageActive || !permanentDeletionVisible.value) return;
|
||||
startDeletionCodeCooldown(60);
|
||||
} catch (error) {
|
||||
if (pageActive && permanentDeletionVisible.value && !isRequestCancelled(error)) {
|
||||
permanentDeletionError.value = getRequestErrorMessage(error, "删除验证码发送失败,请稍后重试。");
|
||||
}
|
||||
} finally {
|
||||
if (pageActive) deletionCodeSending.value = false;
|
||||
}
|
||||
};
|
||||
const confirmPermanentDeletion = async () => {
|
||||
if (!canDeletePermanently.value || permanentDeletionSubmitting.value) return;
|
||||
if (permanentDeletionConfirmationName.value.trim() !== form.genealogyName.trim()) {
|
||||
permanentDeletionError.value = "输入的谱名与当前家谱不一致。";
|
||||
return;
|
||||
}
|
||||
if (!/^\d{4}$/.test(permanentDeletionSmsCode.value)) {
|
||||
permanentDeletionError.value = "请输入4位短信验证码。";
|
||||
return;
|
||||
}
|
||||
const payload = {
|
||||
confirmationName: permanentDeletionConfirmationName.value,
|
||||
smsCode: permanentDeletionSmsCode.value,
|
||||
};
|
||||
const deletionAttempt = permanentDeletionGuard.begin(payload);
|
||||
if (deletionAttempt === null) {
|
||||
permanentDeletionError.value = "上次删除结果待确认,请先返回家谱列表刷新,不要重复提交。";
|
||||
return;
|
||||
}
|
||||
permanentDeletionSubmitting.value = true;
|
||||
permanentDeletionError.value = "";
|
||||
try {
|
||||
await genealogyApi.deleteGenealogyPermanently(
|
||||
genealogyId.value,
|
||||
payload,
|
||||
{ requestController: permanentDeletionRequestController },
|
||||
);
|
||||
if (!pageActive) return;
|
||||
genealogyContext.invalidateCurrentGenealogyId();
|
||||
permanentDeletionVisible.value = false;
|
||||
await returnTo("G01");
|
||||
} catch (error) {
|
||||
const isOutcomeUnknown = permanentDeletionGuard.recordFailure(deletionAttempt, error);
|
||||
if (!pageActive) return;
|
||||
permanentDeletionError.value = isOutcomeUnknown
|
||||
? "删除结果待确认,请返回家谱列表刷新,不要重复提交。"
|
||||
: getRequestErrorMessage(error, "永久删除失败,请核对验证码后重试。");
|
||||
if (!isOutcomeUnknown && isRequestCancelled(error)) permanentDeletionError.value = "";
|
||||
} finally {
|
||||
if (pageActive) permanentDeletionSubmitting.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const uploadCover = async () => {
|
||||
if (isUploading.value || isSubmitting.value) return;
|
||||
isUploading.value = true;
|
||||
@@ -438,6 +660,13 @@ const uploadCover = async () => {
|
||||
}
|
||||
};
|
||||
|
||||
const clearCover = () => {
|
||||
if (isUploading.value || isSubmitting.value) return;
|
||||
coverOssId.value = null;
|
||||
coverFileName.value = "";
|
||||
uploadError.value = "";
|
||||
};
|
||||
|
||||
const submitUpdate = async () => {
|
||||
if (isSubmitting.value || isUploading.value || !validate()) return;
|
||||
const access = toApiGenealogyAccess(form.accessPreset);
|
||||
@@ -458,7 +687,7 @@ const submitUpdate = async () => {
|
||||
originPlace: form.originPlace,
|
||||
addressDetail: form.addressDetail,
|
||||
intro: form.intro,
|
||||
...(coverOssId.value ? { coverOssId: coverOssId.value } : {}),
|
||||
coverOssId: coverOssId.value,
|
||||
...access,
|
||||
},
|
||||
{ requestController: settingsSaveRequestController },
|
||||
@@ -477,6 +706,27 @@ const returnToOverview = () =>
|
||||
hasValidContext.value
|
||||
? returnTo("G05", { genealogyId: genealogyId.value })
|
||||
: goBack();
|
||||
const requestBack = async () => {
|
||||
if (regionPickerVisible.value) {
|
||||
regionPickerDialog.value?.close();
|
||||
return true;
|
||||
}
|
||||
if (lifecycleDialogVisible.value) {
|
||||
lifecycleDialogVisible.value = false;
|
||||
return true;
|
||||
}
|
||||
if (permanentDeletionVisible.value) {
|
||||
closePermanentDeletion();
|
||||
return true;
|
||||
}
|
||||
if (discardVisible.value) {
|
||||
cancelDiscard();
|
||||
return true;
|
||||
}
|
||||
if (isSubmitting.value || isUploading.value || lifecycleSubmitting.value || permanentDeletionSubmitting.value || deletionCodeSending.value) return true;
|
||||
if (isDirty.value && !(await requestDiscardConfirmation())) return false;
|
||||
return returnToOverview();
|
||||
};
|
||||
const handleStateAction = () =>
|
||||
pageState.value === "success" ? returnToOverview() : loadSettings();
|
||||
|
||||
@@ -486,12 +736,18 @@ onLoad((query) => {
|
||||
onMounted(() => {
|
||||
void loadSettings();
|
||||
});
|
||||
onBackPress((event) => handleBackPress(event, requestBack));
|
||||
onUnload(() => {
|
||||
pageActive = false;
|
||||
settingsReadRequestController.abort();
|
||||
deletionCapabilityReadRequestController.abort();
|
||||
genealogyLifecycleRequestController.abort();
|
||||
deletionCodeRequestController.abort();
|
||||
permanentDeletionRequestController.abort();
|
||||
coverUploadRequestController.abort();
|
||||
settingsSaveRequestController.abort();
|
||||
stopDeletionCodeCooldown();
|
||||
discardConfirmation.dispose();
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -510,7 +766,7 @@ onUnload(() => {
|
||||
}
|
||||
.page-content {
|
||||
flex: 1;
|
||||
padding: 24rpx 32rpx 72rpx;
|
||||
padding: 24rpx 32rpx calc(72rpx + env(safe-area-inset-bottom));
|
||||
}
|
||||
.settings-card,
|
||||
.state-card {
|
||||
@@ -567,6 +823,7 @@ onUnload(() => {
|
||||
}
|
||||
.field-row input {
|
||||
min-width: 0;
|
||||
min-height: var(--app-touch-min);
|
||||
flex: 1;
|
||||
color: $ink;
|
||||
font-size: clamp(16px, 28rpx, 20px);
|
||||
@@ -583,6 +840,15 @@ onUnload(() => {
|
||||
text-align: right;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.permanent-deletion { margin-top: 30rpx; padding: 24rpx; border: 1rpx solid rgba(158, 37, 27, .34); border-radius: 10rpx; background: rgba(158, 37, 27, .05); }
|
||||
.permanent-deletion > text { display: block; color: $ink-muted; font-size: clamp(13px, 22rpx, 16px); line-height: 1.55; }
|
||||
.permanent-deletion > text:first-child { color: $brand-red; font-size: clamp(16px, 27rpx, 20px); font-weight: 700; }
|
||||
.permanent-deletion > text + text { margin-top: 8rpx; }
|
||||
.permanent-deletion .app-button { margin-top: 18rpx; }
|
||||
.deletion-dialog-input { width: 100%; min-height: var(--app-touch-min); margin-top: 14rpx; padding: 0 18rpx; box-sizing: border-box; border: 1rpx solid rgba(158, 37, 27, .3); border-radius: 8rpx; background: rgba(255, 255, 255, .7); }
|
||||
.deletion-code-row { display: flex; align-items: center; gap: 12rpx; }
|
||||
.deletion-code-row .deletion-dialog-input { min-width: 0; flex: 1; }
|
||||
.deletion-code-row .app-button { width: auto; flex: 0 0 auto; margin-top: 14rpx; }
|
||||
.lifecycle-note,
|
||||
.lifecycle-actions {
|
||||
margin-top: 20rpx;
|
||||
@@ -658,6 +924,18 @@ onUnload(() => {
|
||||
font-size: clamp(13px, 21rpx, 16px);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.remove-cover-button {
|
||||
justify-self: start;
|
||||
min-height: 72rpx;
|
||||
margin: 0;
|
||||
padding: 0 18rpx;
|
||||
border: 1rpx solid rgba($brand-red, 0.38);
|
||||
border-radius: 8rpx;
|
||||
background: transparent;
|
||||
color: $brand-red;
|
||||
font-size: clamp(13px, 21rpx, 16px);
|
||||
}
|
||||
.remove-cover-button::after { border: 0; }
|
||||
.field-error,
|
||||
.submit-error {
|
||||
margin-top: 10rpx;
|
||||
|
||||
@@ -1,20 +1,20 @@
|
||||
<template>
|
||||
<view class="message-page">
|
||||
<ModulePageBackground module="notification" />
|
||||
<view class="page-header"><PageHeader root title="消息中心" :action="unreadCount > 0 ? '设为已读' : ''" @action="requestMarkAllRead" /></view>
|
||||
<view class="page-header"><PageHeader :root="!sourceKey" :custom-back="Boolean(sourceKey)" title="消息中心" :action="unreadCount > 0 ? '设为已读' : ''" @back="returnToSource" @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" />
|
||||
<AppButton block :label="returnLabel" @click="returnToSource" />
|
||||
</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" />
|
||||
<AppButton block type="secondary" :label="returnLabel" @click="returnToSource" />
|
||||
</view>
|
||||
<view v-else class="message-list">
|
||||
<view
|
||||
@@ -39,7 +39,7 @@
|
||||
</view>
|
||||
</view>
|
||||
<AppPromotionStrip placement="message_bottom" title="消息页推荐" />
|
||||
<AppTabbar active="profile" />
|
||||
<AppTabbar :active="sourceTab" />
|
||||
<AppDialog
|
||||
:visible="markAllVisible"
|
||||
eyebrow="消息状态"
|
||||
@@ -56,8 +56,8 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from "vue";
|
||||
import { onLoad, onShow, onUnload } from "@dcloudio/uni-app";
|
||||
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";
|
||||
@@ -71,7 +71,7 @@ import {
|
||||
} 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";
|
||||
import { goBack, goRoot, handleBackPress, openPage } from "@/utils/navigation/gateway.js";
|
||||
|
||||
const unreadCount = ref(0);
|
||||
const notifications = ref([]);
|
||||
@@ -82,8 +82,12 @@ const markAllReadController = createRequestController();
|
||||
const markAllVisible = ref(false);
|
||||
const markAllSubmitting = ref(false);
|
||||
const markAllReadError = ref("");
|
||||
const sourceKey = ref("");
|
||||
let isPageActive = true;
|
||||
|
||||
const returnLabel = computed(() => (sourceKey.value ? "返回上一页" : "返回我的"));
|
||||
const sourceTab = computed(() => (sourceKey.value === "G01" ? "genealogy" : "profile"));
|
||||
|
||||
const loadNotifications = async () => {
|
||||
notificationListController.abort();
|
||||
notificationListState.value = "loading";
|
||||
@@ -126,12 +130,23 @@ const confirmMarkAllRead = async () => {
|
||||
}
|
||||
};
|
||||
|
||||
const returnToProfile = () => goRoot("M01");
|
||||
const returnToSource = () => (sourceKey.value ? goBack() : goRoot("M01"));
|
||||
const closeMarkAllDialog = () => {
|
||||
if (markAllSubmitting.value) return false;
|
||||
markAllVisible.value = false;
|
||||
return true;
|
||||
};
|
||||
const openNotification = (item) => openPage("N02", { id: item.notificationId });
|
||||
onLoad(loadNotifications);
|
||||
onLoad((query) => {
|
||||
sourceKey.value = String(query?.sourceKey || "");
|
||||
loadNotifications();
|
||||
});
|
||||
onShow(() => {
|
||||
if (notificationListState.value !== "loading") loadNotifications();
|
||||
});
|
||||
onBackPress((event) =>
|
||||
markAllVisible.value ? handleBackPress(event, closeMarkAllDialog) : false,
|
||||
);
|
||||
onUnload(() => {
|
||||
isPageActive = false;
|
||||
notificationListController.abort();
|
||||
@@ -153,7 +168,7 @@ onUnload(() => {
|
||||
}
|
||||
.page-content {
|
||||
flex: 1;
|
||||
padding: 18rpx 24rpx 190rpx;
|
||||
padding: 18rpx 24rpx calc(190rpx + env(safe-area-inset-bottom));
|
||||
}
|
||||
.state-card {
|
||||
box-sizing: border-box;
|
||||
@@ -182,7 +197,9 @@ onUnload(() => {
|
||||
}
|
||||
.message-list {
|
||||
display: grid;
|
||||
gap: 16rpx;
|
||||
gap: 20rpx;
|
||||
box-sizing: border-box;
|
||||
padding: 28rpx 24rpx;
|
||||
@include adaptive-notification-content;
|
||||
}
|
||||
.message-row {
|
||||
@@ -192,7 +209,7 @@ onUnload(() => {
|
||||
padding: 24rpx;
|
||||
border: 1rpx solid rgba(128, 89, 49, 0.26);
|
||||
border-radius: 12rpx;
|
||||
background: rgba(255, 252, 245, 0.78);
|
||||
background: rgba($paper, 0.9);
|
||||
}
|
||||
.message-row__body { min-width: 0; flex: 1; }
|
||||
.message-row__title-line { display: flex; gap: 12rpx; align-items: center; }
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
:action="notificationDetail.readStatus === '0' ? (markReadSubmitting ? '正在设置' : '设为已读') : ''"
|
||||
custom-back
|
||||
@action="requestMarkRead"
|
||||
@back="backToMessages"
|
||||
@back="requestBack"
|
||||
/></view>
|
||||
<view class="page-content">
|
||||
<view v-if="notificationDetailState === 'loading'" class="state-card"
|
||||
@@ -80,7 +80,7 @@
|
||||
|
||||
<script setup>
|
||||
import { computed, ref } from "vue";
|
||||
import { onLoad, onUnload } from "@dcloudio/uni-app";
|
||||
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";
|
||||
@@ -92,7 +92,7 @@ import {
|
||||
} 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";
|
||||
import { handleBackPress, openNoticeTarget, returnTo } from "@/utils/navigation/gateway.js";
|
||||
|
||||
const notificationId = ref("");
|
||||
const notificationDetail = ref({});
|
||||
@@ -172,6 +172,14 @@ const confirmMarkRead = async () => {
|
||||
if (isPageActive) markReadSubmitting.value = false;
|
||||
}
|
||||
};
|
||||
const requestBack = () => {
|
||||
if (markReadSubmitting.value) return true;
|
||||
if (markReadVisible.value) {
|
||||
markReadVisible.value = false;
|
||||
return true;
|
||||
}
|
||||
return backToMessages();
|
||||
};
|
||||
|
||||
onLoad((options) => {
|
||||
notificationId.value = String(options?.id || "");
|
||||
@@ -186,6 +194,7 @@ onUnload(() => {
|
||||
notificationDetailController.abort();
|
||||
markReadController.abort();
|
||||
});
|
||||
onBackPress((event) => handleBackPress(event, requestBack));
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@@ -202,12 +211,13 @@ onUnload(() => {
|
||||
}
|
||||
.page-content {
|
||||
flex: 1;
|
||||
padding: 18rpx 24rpx 72rpx;
|
||||
padding: 18rpx 24rpx calc(72rpx + env(safe-area-inset-bottom));
|
||||
}
|
||||
.state-card,
|
||||
.detail-card {
|
||||
box-sizing: border-box;
|
||||
@include adaptive-notification-content;
|
||||
background-color: rgba($paper, 0.82);
|
||||
}
|
||||
.state-card {
|
||||
min-height: 340rpx;
|
||||
@@ -239,6 +249,8 @@ onUnload(() => {
|
||||
.detail-title,
|
||||
.detail-time {
|
||||
display: block;
|
||||
min-width: 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.detail-time {
|
||||
margin-top: 16rpx;
|
||||
@@ -250,6 +262,7 @@ onUnload(() => {
|
||||
color: $ink;
|
||||
font-size: clamp(16px, 29rpx, 20px);
|
||||
line-height: 1.8;
|
||||
overflow-wrap: anywhere;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
.detail-meta {
|
||||
@@ -269,8 +282,10 @@ onUnload(() => {
|
||||
flex: 0 0 116rpx;
|
||||
}
|
||||
.detail-meta__row text:last-child {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
color: $ink;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.detail-operation-error {
|
||||
display: block;
|
||||
|
||||
@@ -204,7 +204,7 @@ onUnload(() => {
|
||||
}
|
||||
|
||||
.page-content {
|
||||
padding: 36rpx 32rpx 68rpx;
|
||||
padding: 36rpx 32rpx calc(68rpx + env(safe-area-inset-bottom));
|
||||
}
|
||||
|
||||
.state-card,
|
||||
@@ -258,6 +258,10 @@ onUnload(() => {
|
||||
gap: 20rpx;
|
||||
}
|
||||
|
||||
.invitation-card__heading {
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.invitation-card__heading text:first-child {
|
||||
flex: 1;
|
||||
color: #40291e;
|
||||
@@ -295,7 +299,25 @@ onUnload(() => {
|
||||
}
|
||||
|
||||
.invitation-card__actions {
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
padding-top: 8rpx;
|
||||
}
|
||||
|
||||
.invitation-card__actions .app-button {
|
||||
min-width: 152rpx;
|
||||
}
|
||||
|
||||
@media (max-width: 340px) {
|
||||
.page-content {
|
||||
padding-right: 22rpx;
|
||||
padding-left: 22rpx;
|
||||
}
|
||||
|
||||
.state-card,
|
||||
.invitation-card {
|
||||
padding-right: 24rpx;
|
||||
padding-left: 24rpx;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -28,18 +28,22 @@
|
||||
:password="!passwordVisible[field.key]"
|
||||
maxlength="32"
|
||||
:aria-label="field.label"
|
||||
:aria-invalid="Boolean(errors[field.key])"
|
||||
:aria-describedby="errors[field.key] ? `${field.key}-password-error` : undefined"
|
||||
:placeholder="field.placeholder"
|
||||
@input="errors[field.key] = ''"
|
||||
/>
|
||||
<view
|
||||
<button
|
||||
class="password-toggle"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
:aria-label="`${passwordVisible[field.key] ? '隐藏' : '显示'}${field.label}`"
|
||||
:aria-pressed="passwordVisible[field.key]"
|
||||
@click="togglePassword(field.key)"
|
||||
>{{ passwordVisible[field.key] ? "隐藏" : "显示" }}</view
|
||||
>{{ passwordVisible[field.key] ? "隐藏" : "显示" }}</button
|
||||
>
|
||||
</view>
|
||||
<text v-if="errors[field.key]" class="field-error">{{
|
||||
<text v-if="errors[field.key]" :id="`${field.key}-password-error`" class="field-error" role="alert">{{
|
||||
errors[field.key]
|
||||
}}</text>
|
||||
</view>
|
||||
@@ -84,7 +88,8 @@ import { getRequestErrorMessage } from "@/services/api/request-error-message.js"
|
||||
import { createDiscardConfirmation } from "@/utils/discard-confirmation.js";
|
||||
import { calcMD5 } from "@/utils/md5.js";
|
||||
import { createNonIdempotentWriteGuard } from "@/utils/non-idempotent-write-guard.js";
|
||||
import { handleBackPress, runBackGuard } from "@/utils/navigation/gateway.js";
|
||||
import { goRoot, handleBackPress, runBackGuard } from "@/utils/navigation/gateway.js";
|
||||
import { session } from "@/utils/session.js";
|
||||
import {
|
||||
PASSWORD_POLICY_MESSAGE,
|
||||
validatePassword,
|
||||
@@ -161,11 +166,13 @@ const savePassword = async () => {
|
||||
{ requestController: passwordChangeRequestController },
|
||||
);
|
||||
if (!pageActive) return;
|
||||
session.clear();
|
||||
await goRoot("A01");
|
||||
if (!pageActive) return;
|
||||
passwordForm.current = "";
|
||||
passwordForm.next = "";
|
||||
passwordForm.confirm = "";
|
||||
baseline.value = formSnapshot.value;
|
||||
showToast("密码修改成功");
|
||||
} catch (error) {
|
||||
if (!pageActive) return;
|
||||
if (passwordChangeGuard.recordFailure(passwordChangeAttempt, error)) {
|
||||
@@ -215,7 +222,7 @@ onUnload(() => {
|
||||
}
|
||||
.page-content {
|
||||
flex: 1;
|
||||
padding: 28rpx 30rpx 72rpx;
|
||||
padding: 28rpx 30rpx calc(72rpx + env(safe-area-inset-bottom));
|
||||
}
|
||||
.security-tip,
|
||||
.form-panel {
|
||||
@@ -264,18 +271,26 @@ onUnload(() => {
|
||||
.form-row input {
|
||||
width: auto;
|
||||
min-width: 0;
|
||||
min-height: 68rpx;
|
||||
min-height: 80rpx;
|
||||
color: $ink;
|
||||
font-size: clamp(14px, 23rpx, 17px);
|
||||
}
|
||||
.password-toggle {
|
||||
display: flex;
|
||||
min-height: 68rpx;
|
||||
min-height: 80rpx;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
width: auto;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: $brand-red;
|
||||
font-size: clamp(13px, 21rpx, 16px);
|
||||
}
|
||||
.password-toggle::after {
|
||||
border: 0;
|
||||
}
|
||||
.field-error {
|
||||
display: block;
|
||||
padding-top: 7rpx;
|
||||
|
||||
@@ -16,6 +16,22 @@
|
||||
><text>完成安全验证和短信校验后,即可更新你的登录手机号。</text></view
|
||||
>
|
||||
<view class="form-panel">
|
||||
<view class="field-block">
|
||||
<view class="form-row">
|
||||
<text>当前密码</text>
|
||||
<input
|
||||
v-model="currentPassword"
|
||||
password
|
||||
maxlength="32"
|
||||
aria-label="当前密码"
|
||||
:aria-invalid="Boolean(errors.currentPassword)"
|
||||
:aria-describedby="errors.currentPassword ? 'change-phone-password-error' : undefined"
|
||||
placeholder="请输入当前登录密码"
|
||||
@input="errors.currentPassword = ''"
|
||||
/>
|
||||
</view>
|
||||
<text v-if="errors.currentPassword" id="change-phone-password-error" class="field-error" role="alert">{{ errors.currentPassword }}</text>
|
||||
</view>
|
||||
<view class="field-block">
|
||||
<view class="form-row">
|
||||
<text>新手机号</text>
|
||||
@@ -24,11 +40,13 @@
|
||||
type="number"
|
||||
maxlength="11"
|
||||
aria-label="新手机号"
|
||||
:aria-invalid="Boolean(errors.phone)"
|
||||
:aria-describedby="errors.phone ? 'change-phone-error' : undefined"
|
||||
placeholder="请输入新手机号"
|
||||
@input="handlePhoneInput"
|
||||
/>
|
||||
</view>
|
||||
<text v-if="errors.phone" class="field-error">{{ errors.phone }}</text>
|
||||
<text v-if="errors.phone" id="change-phone-error" class="field-error" role="alert">{{ errors.phone }}</text>
|
||||
</view>
|
||||
<view class="field-block">
|
||||
<view class="form-row form-row--code">
|
||||
@@ -38,6 +56,8 @@
|
||||
type="number"
|
||||
maxlength="4"
|
||||
aria-label="短信验证码"
|
||||
:aria-invalid="Boolean(errors.smsCode)"
|
||||
:aria-describedby="errors.smsCode ? 'change-phone-code-error' : undefined"
|
||||
placeholder="4 位验证码"
|
||||
@input="errors.smsCode = ''"
|
||||
/>
|
||||
@@ -48,7 +68,7 @@
|
||||
@click="prepareGetCode"
|
||||
>{{ cooldownSeconds > 0 ? `${cooldownSeconds}s 后重试` : '获取验证码' }}</button>
|
||||
</view>
|
||||
<text v-if="errors.smsCode" class="field-error">{{ errors.smsCode }}</text>
|
||||
<text v-if="errors.smsCode" id="change-phone-code-error" class="field-error" role="alert">{{ errors.smsCode }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<AppButton
|
||||
@@ -104,16 +124,19 @@ import {
|
||||
} from "@/utils/auth/verification.js";
|
||||
import { createDiscardConfirmation } from "@/utils/discard-confirmation.js";
|
||||
import { createNonIdempotentWriteGuard } from "@/utils/non-idempotent-write-guard.js";
|
||||
import { handleBackPress, runBackGuard } from "@/utils/navigation/gateway.js";
|
||||
import { goRoot, handleBackPress, runBackGuard } from "@/utils/navigation/gateway.js";
|
||||
import { calcMD5 } from "@/utils/md5.js";
|
||||
import { session } from "@/utils/session.js";
|
||||
|
||||
const currentPassword = ref("");
|
||||
const phone = ref("");
|
||||
const smsCode = ref("");
|
||||
const errors = reactive({ phone: "", smsCode: "" });
|
||||
const errors = reactive({ currentPassword: "", phone: "", smsCode: "" });
|
||||
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 formSnapshot = computed(() => JSON.stringify({ currentPassword: currentPassword.value, phone: phone.value, smsCode: smsCode.value }));
|
||||
const baseline = ref(formSnapshot.value);
|
||||
const isDirty = computed(() => formSnapshot.value !== baseline.value);
|
||||
const phoneChangeController = createRequestController();
|
||||
@@ -185,19 +208,24 @@ const prepareGetCode = async () => {
|
||||
};
|
||||
|
||||
const validateForm = () => {
|
||||
errors.currentPassword = currentPassword.value ? "" : "请输入当前密码";
|
||||
errors.phone = isAuthPhone(phone.value) ? "" : "请输入正确手机号";
|
||||
errors.smsCode =
|
||||
sentPhone.value !== phone.value
|
||||
? "请先获取当前手机号的验证码"
|
||||
? "请先获取新手机号的验证码"
|
||||
: /^\d{4}$/.test(smsCode.value)
|
||||
? ""
|
||||
: "请输入 4 位验证码";
|
||||
return !errors.phone && !errors.smsCode;
|
||||
return !errors.currentPassword && !errors.phone && !errors.smsCode;
|
||||
};
|
||||
|
||||
const submitPhoneChange = async () => {
|
||||
if (phoneState.value !== "ready" || !validateForm()) return;
|
||||
const phoneChangePayload = { phone: phone.value, smsCode: smsCode.value };
|
||||
const phoneChangePayload = {
|
||||
phone: phone.value,
|
||||
smsCode: smsCode.value,
|
||||
currentPasswordHash: calcMD5(currentPassword.value),
|
||||
};
|
||||
const phoneChangeAttempt = phoneChangeGuard.begin(phoneChangePayload);
|
||||
if (phoneChangeAttempt === null) {
|
||||
showToast("上次换绑结果暂时无法确认,请重新登录确认手机号,不要重复提交");
|
||||
@@ -210,11 +238,14 @@ const submitPhoneChange = async () => {
|
||||
{ requestController: phoneChangeController },
|
||||
);
|
||||
if (!isPageActive) return;
|
||||
session.clear();
|
||||
await goRoot("A01");
|
||||
if (!isPageActive) return;
|
||||
currentPassword.value = "";
|
||||
phone.value = "";
|
||||
smsCode.value = "";
|
||||
sentPhone.value = "";
|
||||
baseline.value = formSnapshot.value;
|
||||
showToast("手机号换绑成功");
|
||||
} catch (error) {
|
||||
if (!isPageActive) return;
|
||||
if (phoneChangeGuard.recordFailure(phoneChangeAttempt, error)) {
|
||||
@@ -262,7 +293,7 @@ onUnload(() => {
|
||||
}
|
||||
.page-content {
|
||||
flex: 1;
|
||||
padding: 28rpx 30rpx 72rpx;
|
||||
padding: 28rpx 30rpx calc(72rpx + env(safe-area-inset-bottom));
|
||||
}
|
||||
.security-tip,
|
||||
.form-panel {
|
||||
@@ -314,7 +345,7 @@ onUnload(() => {
|
||||
.form-row input {
|
||||
width: auto;
|
||||
min-width: 0;
|
||||
min-height: 68rpx;
|
||||
min-height: 80rpx;
|
||||
color: $ink;
|
||||
font-size: clamp(14px, 23rpx, 17px);
|
||||
}
|
||||
@@ -324,7 +355,7 @@ onUnload(() => {
|
||||
justify-content: center;
|
||||
justify-self: end;
|
||||
width: 198rpx;
|
||||
min-height: 68rpx;
|
||||
min-height: 80rpx;
|
||||
margin: 0;
|
||||
padding: 0 14rpx;
|
||||
box-sizing: border-box;
|
||||
|
||||
@@ -131,7 +131,7 @@ onUnload(() => {
|
||||
}
|
||||
.document-content {
|
||||
flex: 1;
|
||||
padding: 24rpx 28rpx 72rpx;
|
||||
padding: 24rpx 28rpx calc(72rpx + env(safe-area-inset-bottom));
|
||||
}
|
||||
.document-sheet,
|
||||
.document-state-card {
|
||||
@@ -219,4 +219,16 @@ onUnload(() => {
|
||||
.document-state-card .app-button {
|
||||
margin-top: 28rpx;
|
||||
}
|
||||
|
||||
@media (max-width: 340px) {
|
||||
.document-content {
|
||||
padding-right: 22rpx;
|
||||
padding-left: 22rpx;
|
||||
}
|
||||
|
||||
.document-sheet {
|
||||
padding-right: 28rpx;
|
||||
padding-left: 28rpx;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -84,7 +84,11 @@
|
||||
<text>{{ recordTimeLabel(withdrawal.createTime) }}</text>
|
||||
</view>
|
||||
<text>{{ withdrawalStatusLabel(withdrawal.withdrawalStatus) }}</text>
|
||||
<text v-if="withdrawal.withdrawalNo">提现单号:{{ withdrawal.withdrawalNo }}</text>
|
||||
<text>收款人:{{ withdrawal.payoutAccountName || '未填写' }}</text>
|
||||
<text v-if="withdrawal.auditRemark">审核备注:{{ withdrawal.auditRemark }}</text>
|
||||
<text v-if="withdrawal.payoutReference">打款参考号:{{ withdrawal.payoutReference }}</text>
|
||||
<text v-if="withdrawal.paidAt">到账时间:{{ recordTimeLabel(withdrawal.paidAt) }}</text>
|
||||
<text v-if="withdrawal.failureReason">原因:{{ withdrawal.failureReason }}</text>
|
||||
<AppButton
|
||||
v-if="withdrawal.withdrawalStatus === 'PENDING'"
|
||||
@@ -121,7 +125,7 @@
|
||||
|
||||
<script setup>
|
||||
import { computed, reactive, ref } from "vue";
|
||||
import { onShow, onUnload } from "@dcloudio/uni-app";
|
||||
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";
|
||||
@@ -137,6 +141,7 @@ import {
|
||||
} from "@/services/api/request-controller.js";
|
||||
import { earningApi } from "@/services/api/earning-service.js";
|
||||
import { getRequestErrorMessage } from "@/services/api/request-error-message.js";
|
||||
import { handleBackPress } from "@/utils/navigation/gateway.js";
|
||||
import { formatMinuteTimestamp } from "@/utils/display-time.js";
|
||||
import {
|
||||
formatSignedMoney,
|
||||
@@ -257,10 +262,18 @@ const cancelWithdrawal = async () => {
|
||||
if (isPageActive) cancelling.value = false;
|
||||
}
|
||||
};
|
||||
const closeCancellationDialog = () => {
|
||||
if (cancelling.value) return false;
|
||||
cancelTarget.value = null;
|
||||
return true;
|
||||
};
|
||||
|
||||
onShow(() => {
|
||||
void loadAll();
|
||||
});
|
||||
onBackPress((event) =>
|
||||
cancelTarget.value ? handleBackPress(event, closeCancellationDialog) : false,
|
||||
);
|
||||
onUnload(() => {
|
||||
isPageActive = false;
|
||||
summaryController.abort();
|
||||
@@ -275,7 +288,7 @@ onUnload(() => {
|
||||
.earnings-page {
|
||||
min-height: 100vh;
|
||||
box-sizing: border-box;
|
||||
padding-bottom: 48rpx;
|
||||
padding-bottom: env(safe-area-inset-bottom);
|
||||
background: $paper;
|
||||
}
|
||||
|
||||
@@ -334,6 +347,11 @@ onUnload(() => {
|
||||
font-size: clamp(14px, 23rpx, 17px);
|
||||
}
|
||||
|
||||
.summary-row text {
|
||||
min-width: 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.summary-note {
|
||||
margin: 18rpx 0;
|
||||
line-height: 1.6;
|
||||
@@ -442,4 +460,17 @@ onUnload(() => {
|
||||
margin-top: 24rpx;
|
||||
}
|
||||
|
||||
@media (max-width: 340px) {
|
||||
.earnings-content {
|
||||
padding-right: 20rpx;
|
||||
padding-left: 20rpx;
|
||||
}
|
||||
|
||||
.summary-panel,
|
||||
.record-card {
|
||||
padding-right: 20rpx;
|
||||
padding-left: 20rpx;
|
||||
}
|
||||
}
|
||||
|
||||
</style>
|
||||
|
||||
@@ -109,14 +109,27 @@
|
||||
</template>
|
||||
</view>
|
||||
<AppToast :visible="feedbackVisible" :message="feedbackMessage" />
|
||||
<AppDialog
|
||||
:visible="discardVisible"
|
||||
:close-on-mask="false"
|
||||
eyebrow="放弃确认"
|
||||
title="放弃资料修改?"
|
||||
message="当前修改还没有保存。"
|
||||
confirm-text="确认放弃"
|
||||
cancel-text="继续编辑"
|
||||
show-cancel
|
||||
@confirm="confirmDiscard"
|
||||
@cancel="cancelDiscard"
|
||||
/>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, reactive, ref } from "vue";
|
||||
import { onLoad, onUnload } from "@dcloudio/uni-app";
|
||||
import { onBackPress, onLoad, onUnload } from "@dcloudio/uni-app";
|
||||
import AppAvatar from "@/components/AppAvatar.vue";
|
||||
import AppButton from "@/components/AppButton.vue";
|
||||
import AppDialog from "@/components/AppDialog.vue";
|
||||
import AppToast from "@/components/AppToast.vue";
|
||||
import ModulePageBackground from "@/components/ModulePageBackground.vue";
|
||||
import PageHeader from "@/components/PageHeader.vue";
|
||||
@@ -129,11 +142,16 @@ import {
|
||||
} from "@/services/api/request-controller.js";
|
||||
import { profileApi } from "@/services/api/profile-service.js";
|
||||
import { getRequestErrorMessage } from "@/services/api/request-error-message.js";
|
||||
import { createDiscardConfirmation } from "@/utils/discard-confirmation.js";
|
||||
import {
|
||||
isImagePickCancelled,
|
||||
pickAndUploadImage,
|
||||
} from "@/utils/media-upload.js";
|
||||
import { finishPage, returnTo } from "@/utils/navigation/gateway.js";
|
||||
import {
|
||||
finishPage,
|
||||
handleBackPress,
|
||||
runBackGuard,
|
||||
} from "@/utils/navigation/gateway.js";
|
||||
|
||||
const form = reactive({
|
||||
nickName: "",
|
||||
@@ -162,11 +180,18 @@ const saveError = ref("");
|
||||
const committedProfilePayload = ref("");
|
||||
const feedbackVisible = ref(false);
|
||||
const feedbackMessage = ref("");
|
||||
const discardVisible = ref(false);
|
||||
const profileReadController = createRequestController();
|
||||
const avatarUploadController = createRequestController();
|
||||
const profileSaveController = createRequestController();
|
||||
let feedbackTimer = null;
|
||||
let isPageActive = true;
|
||||
const discardConfirmation = createDiscardConfirmation((visible) => {
|
||||
discardVisible.value = visible;
|
||||
});
|
||||
const requestDiscardConfirmation = discardConfirmation.request;
|
||||
const confirmDiscard = discardConfirmation.confirm;
|
||||
const cancelDiscard = discardConfirmation.cancel;
|
||||
const sexOptions = Object.freeze([
|
||||
Object.freeze({ value: "", label: "请选择" }),
|
||||
...PROFILE_SEX_OPTIONS,
|
||||
@@ -181,6 +206,16 @@ const avatarMessage = computed(() => {
|
||||
if (avatarFileName.value) return `已选择 ${avatarFileName.value}`;
|
||||
return avatarPreviewUrl.value ? "当前头像已设置" : "当前使用默认头像";
|
||||
});
|
||||
const profileSnapshot = computed(() => JSON.stringify({
|
||||
nickName: form.nickName,
|
||||
realName: form.realName,
|
||||
sex: form.sex,
|
||||
birthday: form.birthday,
|
||||
email: form.email,
|
||||
avatar: avatarId.value || original.avatar || null,
|
||||
}));
|
||||
const originalSnapshot = computed(() => JSON.stringify(original));
|
||||
const isDirty = computed(() => profileSnapshot.value !== originalSnapshot.value);
|
||||
|
||||
const showFeedback = (message) => {
|
||||
feedbackMessage.value = message;
|
||||
@@ -291,6 +326,15 @@ const saveProfile = async () => {
|
||||
});
|
||||
if (!isPageActive) return;
|
||||
committedProfilePayload.value = payloadFingerprint;
|
||||
for (const field of ["nickName", "realName", "sex", "birthday", "email"]) {
|
||||
if (Object.prototype.hasOwnProperty.call(payload, field)) {
|
||||
original[field] = payload[field];
|
||||
}
|
||||
}
|
||||
if (Object.prototype.hasOwnProperty.call(payload, "avatar")) {
|
||||
original.avatar = payload.avatar;
|
||||
avatarId.value = null;
|
||||
}
|
||||
}
|
||||
await finishPage(
|
||||
"M01",
|
||||
@@ -307,18 +351,24 @@ const saveProfile = async () => {
|
||||
}
|
||||
};
|
||||
|
||||
const backToProfile = () => {
|
||||
if (saving.value || uploading.value) return;
|
||||
return returnTo("M01");
|
||||
};
|
||||
const backToProfile = () => runBackGuard({
|
||||
transientOpen: discardVisible.value,
|
||||
dirty: isDirty.value,
|
||||
submitting: saving.value || uploading.value,
|
||||
"close-transient": cancelDiscard,
|
||||
"block-submitting": () => true,
|
||||
"confirm-discard": requestDiscardConfirmation,
|
||||
});
|
||||
|
||||
onLoad(loadProfile);
|
||||
onBackPress((event) => handleBackPress(event, backToProfile));
|
||||
onUnload(() => {
|
||||
isPageActive = false;
|
||||
profileReadController.abort();
|
||||
avatarUploadController.abort();
|
||||
profileSaveController.abort();
|
||||
if (feedbackTimer) clearTimeout(feedbackTimer);
|
||||
discardConfirmation.dispose();
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -336,7 +386,7 @@ onUnload(() => {
|
||||
}
|
||||
.page-content {
|
||||
flex: 1;
|
||||
padding: 24rpx 30rpx 72rpx;
|
||||
padding: 24rpx 30rpx calc(72rpx + env(safe-area-inset-bottom));
|
||||
}
|
||||
.state-card,
|
||||
.form-panel {
|
||||
@@ -435,7 +485,7 @@ onUnload(() => {
|
||||
.picker-value {
|
||||
width: auto;
|
||||
min-width: 0;
|
||||
min-height: 68rpx;
|
||||
min-height: 80rpx;
|
||||
color: $ink;
|
||||
font-size: clamp(14px, 23rpx, 17px);
|
||||
text-align: right;
|
||||
|
||||
+26
-14
@@ -122,7 +122,7 @@
|
||||
class="history-card"
|
||||
>
|
||||
<view class="history-card__meta">
|
||||
<text>{{ feedbackTypeLabel(feedback.feedbackType) }} · {{ feedbackReference(feedback) }}</text>
|
||||
<text>{{ feedbackTypeLabel(feedback) }} · {{ feedbackReference(feedback) }}</text>
|
||||
<text class="history-status">{{ feedbackStatusLabel(feedback.handleStatus) }}</text>
|
||||
</view>
|
||||
<text
|
||||
@@ -171,9 +171,7 @@ 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 {
|
||||
FEEDBACK_TYPE_OPTIONS as feedbackTypes
|
||||
} from "@/services/api/feedback-contract.js";
|
||||
import { businessDictionaryApi } from "@/services/api/business-dictionary-service.js";
|
||||
import {
|
||||
createRequestController,
|
||||
isRequestCancelled
|
||||
@@ -190,9 +188,11 @@ const feedbackStatusLabels = {
|
||||
2: "已处理",
|
||||
3: "暂不处理",
|
||||
};
|
||||
const feedbackTypeLabel = (feedbackTypeValue) =>
|
||||
feedbackTypes.find(
|
||||
(feedbackType) => feedbackType.value === feedbackTypeValue,
|
||||
const feedbackTypes = ref([]);
|
||||
const feedbackTypeLabel = (feedback) =>
|
||||
String(feedback?.feedbackTypeLabel || "").trim() ||
|
||||
feedbackTypes.value.find(
|
||||
(feedbackType) => feedbackType.value === feedback?.feedbackType,
|
||||
)?.label || "其他";
|
||||
const feedbackStatusLabel = (handleStatus) =>
|
||||
feedbackStatusLabels[String(handleStatus)] || "状态待确认";
|
||||
@@ -234,6 +234,7 @@ const toggleFeedbackExpanded = (feedback) => {
|
||||
let pageActive = true;
|
||||
const feedbackHistoryRequestController = createRequestController();
|
||||
const feedbackSubmissionRequestController = createRequestController();
|
||||
const feedbackTypeRequestController = createRequestController();
|
||||
const submissionSession = createFeedbackSubmissionSession(feedbackForm);
|
||||
const syncSubmissionView = () => {
|
||||
const submissionView = submissionSession.view(feedbackForm);
|
||||
@@ -261,6 +262,13 @@ const loadFeedbackHistory = async () => {
|
||||
);
|
||||
}
|
||||
};
|
||||
const loadFeedbackTypes = async () => {
|
||||
feedbackTypeRequestController.abort();
|
||||
feedbackTypes.value = await businessDictionaryApi.getBusinessDictionaryOptions(
|
||||
"gen_feedback_type",
|
||||
{ requestController: feedbackTypeRequestController },
|
||||
);
|
||||
};
|
||||
const discardConfirmation = createDiscardConfirmation((visible) => {
|
||||
discardVisible.value = visible;
|
||||
});
|
||||
@@ -325,11 +333,15 @@ const requestBack = () =>
|
||||
"confirm-discard": requestDiscardConfirmation,
|
||||
});
|
||||
onBackPress((event) => handleBackPress(event, requestBack));
|
||||
onLoad(loadFeedbackHistory);
|
||||
onLoad(() => {
|
||||
void loadFeedbackTypes().catch(() => { feedbackTypes.value = []; });
|
||||
void loadFeedbackHistory();
|
||||
});
|
||||
onUnload(() => {
|
||||
pageActive = false;
|
||||
feedbackHistoryRequestController.abort();
|
||||
feedbackSubmissionRequestController.abort();
|
||||
feedbackTypeRequestController.abort();
|
||||
discardConfirmation.dispose();
|
||||
});
|
||||
</script>
|
||||
@@ -347,7 +359,7 @@ onUnload(() => {
|
||||
}
|
||||
.page-content {
|
||||
flex: 1;
|
||||
padding: 26rpx 30rpx 72rpx;
|
||||
padding: 26rpx 30rpx calc(72rpx + env(safe-area-inset-bottom));
|
||||
}
|
||||
.lead {
|
||||
display: block;
|
||||
@@ -477,7 +489,7 @@ onUnload(() => {
|
||||
}
|
||||
.history-retry {
|
||||
min-width: 180rpx;
|
||||
min-height: 72rpx;
|
||||
min-height: 80rpx;
|
||||
margin: 0;
|
||||
padding: 0 28rpx;
|
||||
border: 1px solid rgba(164, 41, 36, 0.55);
|
||||
@@ -485,7 +497,7 @@ onUnload(() => {
|
||||
background: rgba(255, 255, 255, 0.82);
|
||||
color: $brand-red;
|
||||
font-size: clamp(14px, 22rpx, 16px);
|
||||
line-height: 72rpx;
|
||||
line-height: 80rpx;
|
||||
}
|
||||
.history-list {
|
||||
display: grid;
|
||||
@@ -523,14 +535,14 @@ onUnload(() => {
|
||||
-webkit-line-clamp: 4;
|
||||
}
|
||||
.history-expand {
|
||||
min-height: 72rpx;
|
||||
min-height: 80rpx;
|
||||
margin: 4rpx 0 0 auto;
|
||||
padding: 0 8rpx;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: $brand-red;
|
||||
font-size: clamp(13px, 20rpx, 15px);
|
||||
line-height: 72rpx;
|
||||
line-height: 80rpx;
|
||||
}
|
||||
.history-expand::after {
|
||||
border: 0;
|
||||
@@ -559,7 +571,7 @@ onUnload(() => {
|
||||
.contact-row input {
|
||||
width: auto;
|
||||
min-width: 0;
|
||||
min-height: 68rpx;
|
||||
min-height: 80rpx;
|
||||
color: $ink;
|
||||
font-size: clamp(14px, 22rpx, 17px);
|
||||
text-align: right;
|
||||
|
||||
+17
-10
@@ -170,7 +170,7 @@ onUnload(() => {
|
||||
}
|
||||
.page-content {
|
||||
flex: 1;
|
||||
padding: 24rpx 28rpx 72rpx;
|
||||
padding: 24rpx 28rpx calc(72rpx + env(safe-area-inset-bottom));
|
||||
}
|
||||
.help-hero,
|
||||
.help-state-card,
|
||||
@@ -225,7 +225,7 @@ onUnload(() => {
|
||||
}
|
||||
.help-search {
|
||||
width: 100%;
|
||||
min-height: 72rpx;
|
||||
min-height: 80rpx;
|
||||
box-sizing: border-box;
|
||||
padding: 0 22rpx;
|
||||
border: 1rpx solid rgba(128, 89, 49, 0.28);
|
||||
@@ -245,13 +245,13 @@ onUnload(() => {
|
||||
.help-category {
|
||||
display: inline-flex;
|
||||
min-width: 112rpx;
|
||||
min-height: 58rpx;
|
||||
min-height: 80rpx;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-right: 12rpx;
|
||||
padding: 0 20rpx;
|
||||
border: 1rpx solid rgba(128, 89, 49, 0.24);
|
||||
border-radius: 29rpx;
|
||||
border-radius: 40rpx;
|
||||
background: rgba(255, 252, 245, 0.54);
|
||||
color: $ink-muted;
|
||||
font-size: clamp(13px, 22rpx, 16px);
|
||||
@@ -263,20 +263,21 @@ onUnload(() => {
|
||||
border: 0;
|
||||
}
|
||||
.help-category--active {
|
||||
border-color: #8d2722;
|
||||
background: #8d2722;
|
||||
border-color: $brand-red;
|
||||
background: $brand-red;
|
||||
color: #fff7e8;
|
||||
}
|
||||
.help-article-list {
|
||||
display: grid;
|
||||
gap: 14rpx;
|
||||
width: 100%;
|
||||
margin-top: 18rpx;
|
||||
}
|
||||
.help-article {
|
||||
overflow: hidden;
|
||||
border: 1rpx solid rgba(128, 89, 49, 0.22);
|
||||
border-radius: 12rpx;
|
||||
background: rgba(255, 252, 245, 0.66);
|
||||
background: rgba(255, 252, 245, 0.88);
|
||||
}
|
||||
.help-article__question {
|
||||
display: flex;
|
||||
@@ -311,12 +312,12 @@ onUnload(() => {
|
||||
}
|
||||
.help-article__indicator {
|
||||
flex: 0 0 auto;
|
||||
color: #9f170f;
|
||||
color: $brand-red-dark;
|
||||
font-size: clamp(12px, 20rpx, 15px);
|
||||
}
|
||||
.help-article__answer {
|
||||
display: block;
|
||||
padding: 0 22rpx 24rpx;
|
||||
padding: 16rpx 22rpx 24rpx;
|
||||
border-top: 1rpx solid rgba(128, 89, 49, 0.16);
|
||||
color: $ink-muted;
|
||||
font-size: clamp(14px, 23rpx, 17px);
|
||||
@@ -336,7 +337,7 @@ onUnload(() => {
|
||||
margin-top: 16rpx;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: #9f170f;
|
||||
color: $brand-red-dark;
|
||||
font-size: clamp(14px, 23rpx, 17px);
|
||||
}
|
||||
.help-feedback-card {
|
||||
@@ -344,6 +345,12 @@ onUnload(() => {
|
||||
padding: 32rpx 34rpx;
|
||||
text-align: center;
|
||||
}
|
||||
.help-feedback-card text {
|
||||
display: block;
|
||||
}
|
||||
.help-feedback-card text:first-child {
|
||||
font-size: clamp(17px, 28rpx, 20px);
|
||||
}
|
||||
@media (max-width: 340px) {
|
||||
.page-content {
|
||||
padding-right: 20rpx;
|
||||
|
||||
+34
-1
@@ -594,7 +594,7 @@ onUnload(() => {
|
||||
top: 337rpx;
|
||||
right: 61rpx;
|
||||
width: 154rpx;
|
||||
min-height: 52rpx;
|
||||
min-height: 72rpx;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
@@ -814,4 +814,37 @@ onUnload(() => {
|
||||
opacity: 0.68;
|
||||
}
|
||||
|
||||
@media (max-width: 340px) {
|
||||
.profile-hero__content {
|
||||
padding-right: 42rpx;
|
||||
padding-left: 42rpx;
|
||||
}
|
||||
|
||||
.profile-hero__emblem {
|
||||
width: 176rpx;
|
||||
height: 176rpx;
|
||||
}
|
||||
|
||||
.profile-metadata {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.profile-metadata__item:first-child {
|
||||
border-right: 0;
|
||||
}
|
||||
|
||||
.profile-metadata__item + .profile-metadata__item {
|
||||
border-top: 1rpx solid rgba(117, 83, 52, 0.16);
|
||||
}
|
||||
|
||||
.profile-metadata__item--email {
|
||||
grid-column: auto;
|
||||
}
|
||||
|
||||
.profile-services {
|
||||
margin-right: 38rpx;
|
||||
margin-left: 38rpx;
|
||||
}
|
||||
}
|
||||
|
||||
</style>
|
||||
|
||||
@@ -11,6 +11,31 @@
|
||||
<text>家谱服务推荐</text>
|
||||
<text>这里会展示家谱相关服务和活动。</text>
|
||||
</view>
|
||||
<text v-if="operationFeedback" class="promotion-feedback" role="status">{{ operationFeedback }}</text>
|
||||
<view class="referral-card">
|
||||
<view class="referral-card__heading">
|
||||
<text>邀请家人注册</text>
|
||||
<text>推荐关系由注册接口一次性确认,前端不会在注册后补绑。</text>
|
||||
</view>
|
||||
<AppLoading v-if="referralState === 'loading'" text="正在读取我的推荐码" />
|
||||
<view v-else-if="referralState === 'error'" class="referral-card__state">
|
||||
<text>{{ referralError || "推荐码暂时无法读取。" }}</text>
|
||||
<AppButton compact type="secondary" label="重试" @click="loadReferralProfile" />
|
||||
</view>
|
||||
<view v-else-if="referralProfile.enabled" class="referral-card__content">
|
||||
<text class="referral-card__code">{{ referralProfile.referralCode }}</text>
|
||||
<text>已成功邀请 {{ referralProfile.referredUserCount }} 人</text>
|
||||
<text>{{ referralProfile.shareDescription || "家人通过此链接注册后,系统会记录推荐关系。" }}</text>
|
||||
<view class="referral-card__actions">
|
||||
<AppButton compact type="secondary" label="复制推荐码" @click="copyReferralCode" />
|
||||
<AppButton compact type="secondary" label="复制推荐链接" @click="copyReferralLink" />
|
||||
<AppButton compact label="分享给家人" @click="shareReferral" />
|
||||
</view>
|
||||
</view>
|
||||
<view v-else class="referral-card__state">
|
||||
<text>{{ referralProfile.disabledReason || "推荐功能暂未开放。" }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<view v-if="promotionListState === 'loading'" class="promotion-state-card">
|
||||
<AppLoading text="正在读取推广内容" />
|
||||
</view>
|
||||
@@ -24,7 +49,6 @@
|
||||
<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"
|
||||
@@ -42,7 +66,7 @@
|
||||
/>
|
||||
<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">
|
||||
<view v-if="item.targetUrl" class="promotion-card__actions" @click.stop>
|
||||
<AppButton compact type="secondary" label="复制链接" @click.stop="copyPromotionLink(item)" />
|
||||
<AppButton compact label="分享给家人" @click.stop="sharePromotion(item)" />
|
||||
</view>
|
||||
@@ -63,6 +87,7 @@ import {
|
||||
createRequestController,
|
||||
isRequestCancelled
|
||||
} from "@/services/api/request-controller.js";
|
||||
import { referralApi } from "@/services/api/referral-service.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";
|
||||
@@ -71,9 +96,39 @@ const promotions = ref([]);
|
||||
const promotionListState = ref("loading");
|
||||
const promotionListError = ref("");
|
||||
const operationFeedback = ref("");
|
||||
const referralState = ref("loading");
|
||||
const referralError = ref("");
|
||||
const referralProfile = ref({
|
||||
enabled: false,
|
||||
disabledReason: "",
|
||||
referralCode: "",
|
||||
shareTitle: "",
|
||||
shareDescription: "",
|
||||
shareUrl: "",
|
||||
referredUserCount: 0,
|
||||
});
|
||||
const promotionListRequestController = createRequestController();
|
||||
const referralRequestController = createRequestController();
|
||||
let pageActive = true;
|
||||
|
||||
const loadReferralProfile = async () => {
|
||||
referralRequestController.abort();
|
||||
referralState.value = "loading";
|
||||
referralError.value = "";
|
||||
try {
|
||||
const profile = await referralApi.getMyReferralProfile({
|
||||
requestController: referralRequestController,
|
||||
});
|
||||
if (!pageActive) return;
|
||||
referralProfile.value = profile;
|
||||
referralState.value = "ready";
|
||||
} catch (error) {
|
||||
if (!pageActive || isRequestCancelled(error)) return;
|
||||
referralError.value = getRequestErrorMessage(error, "推荐码暂时无法读取,请稍后重试。");
|
||||
referralState.value = "error";
|
||||
}
|
||||
};
|
||||
|
||||
const loadPromotions = async () => {
|
||||
promotionListRequestController.abort();
|
||||
promotionListState.value = "loading";
|
||||
@@ -117,6 +172,39 @@ const copyPromotionLink = (item) => {
|
||||
fail: () => { operationFeedback.value = "复制失败,请稍后再试。"; },
|
||||
});
|
||||
};
|
||||
const copyText = (content, successMessage) => {
|
||||
if (!content || typeof uni?.setClipboardData !== "function") {
|
||||
operationFeedback.value = "当前设备暂时不能复制。";
|
||||
return;
|
||||
}
|
||||
uni.setClipboardData({
|
||||
data: content,
|
||||
success: () => { operationFeedback.value = successMessage; },
|
||||
fail: () => { operationFeedback.value = "复制失败,请稍后再试。"; },
|
||||
});
|
||||
};
|
||||
const copyReferralCode = () =>
|
||||
copyText(referralProfile.value.referralCode, "推荐码已复制,可以发给家人。");
|
||||
const copyReferralLink = () =>
|
||||
copyText(referralProfile.value.shareUrl, "推荐链接已复制,可以发给家人。");
|
||||
const shareReferral = () => {
|
||||
const profile = referralProfile.value;
|
||||
if (!profile.enabled || !profile.shareUrl) return;
|
||||
const content = [profile.shareTitle, profile.shareDescription, profile.shareUrl]
|
||||
.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
|
||||
copyReferralLink();
|
||||
};
|
||||
const sharePromotion = (item) => {
|
||||
if (!item?.targetUrl) return;
|
||||
const content = [item.title, item.description, item.targetUrl].filter(Boolean).join("\n");
|
||||
@@ -142,11 +230,15 @@ const sharePromotion = (item) => {
|
||||
};
|
||||
|
||||
const requestBack = () => goBack();
|
||||
onLoad(loadPromotions);
|
||||
onLoad(() => {
|
||||
void loadReferralProfile();
|
||||
void loadPromotions();
|
||||
});
|
||||
onBackPress((event) => handleBackPress(event, requestBack));
|
||||
onUnload(() => {
|
||||
pageActive = false;
|
||||
promotionListRequestController.abort();
|
||||
referralRequestController.abort();
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -163,9 +255,10 @@ onUnload(() => {
|
||||
}
|
||||
.page-content {
|
||||
flex: 1;
|
||||
padding: 28rpx 30rpx 72rpx;
|
||||
padding: 28rpx 30rpx calc(72rpx + env(safe-area-inset-bottom));
|
||||
}
|
||||
.promotion-hero,
|
||||
.referral-card,
|
||||
.promotion-state-card,
|
||||
.promotion-list {
|
||||
@include adaptive-profile-content;
|
||||
@@ -211,8 +304,25 @@ onUnload(() => {
|
||||
padding: 38rpx;
|
||||
text-align: center;
|
||||
}
|
||||
.referral-card {
|
||||
margin-top: 20rpx;
|
||||
padding: 28rpx;
|
||||
border: 1rpx solid rgba(128, 89, 49, 0.26);
|
||||
border-radius: 12rpx;
|
||||
background: rgba(255, 252, 245, 0.82);
|
||||
}
|
||||
.referral-card text { display: block; }
|
||||
.referral-card__heading text:first-child { color: $ink; font-size: clamp(17px, 29rpx, 21px); font-weight: 700; }
|
||||
.referral-card__heading text:last-child,
|
||||
.referral-card__content > text:last-of-type,
|
||||
.referral-card__state > text { margin-top: 8rpx; color: $ink-muted; font-size: clamp(13px, 22rpx, 16px); line-height: 1.55; }
|
||||
.referral-card__content,
|
||||
.referral-card__state { margin-top: 22rpx; }
|
||||
.referral-card__code { color: #9e251b; font-family: ui-monospace, SFMono-Regular, Consolas, monospace; font-size: clamp(22px, 40rpx, 30px); font-weight: 700; letter-spacing: 3rpx; }
|
||||
.referral-card__actions { display: flex; flex-wrap: wrap; justify-content: flex-end; margin-top: 22rpx; gap: 12rpx; }
|
||||
.referral-card__actions .app-button { width: auto; min-width: 150rpx; }
|
||||
.promotion-state-card .app-button { margin-top: 28rpx; }
|
||||
.promotion-list { display: grid; gap: 16rpx; }
|
||||
.promotion-list { display: grid; gap: 18rpx; margin-top: 20rpx; }
|
||||
.promotion-card {
|
||||
overflow: hidden;
|
||||
padding: 26rpx 28rpx;
|
||||
@@ -230,7 +340,7 @@ onUnload(() => {
|
||||
.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-feedback { display: block; margin-top: 16rpx; 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); line-height: 1.5; overflow-wrap: anywhere; }
|
||||
.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) {
|
||||
@@ -238,5 +348,10 @@ onUnload(() => {
|
||||
padding-right: 22rpx;
|
||||
padding-left: 22rpx;
|
||||
}
|
||||
|
||||
.promotion-card__actions .app-button {
|
||||
min-width: 0;
|
||||
flex: 1 1 220rpx;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
+98
-11
@@ -21,9 +21,6 @@
|
||||
><text>账号编号</text
|
||||
><text>{{ profile.userNo || "未提供" }}</text></view
|
||||
>
|
||||
<text class="security-note"
|
||||
>这里显示可查看的账号信息,其他安全信息暂不支持查看。</text
|
||||
>
|
||||
<AppButton block label="修改密码" @click="openPassword" />
|
||||
<AppButton
|
||||
type="secondary"
|
||||
@@ -31,6 +28,14 @@
|
||||
label="换绑手机号"
|
||||
@click="openPhone"
|
||||
/>
|
||||
<AppButton
|
||||
type="secondary"
|
||||
block
|
||||
:disabled="wechatProviderState !== 'ready' || bindingWechat"
|
||||
:label="bindingWechat ? '正在绑定微信…' : '绑定微信'"
|
||||
@click="bindWechat"
|
||||
/>
|
||||
<text v-if="wechatBindingMessage" class="security-message">{{ wechatBindingMessage }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
@@ -48,6 +53,7 @@ import {
|
||||
isRequestCancelled
|
||||
} from "@/services/api/request-controller.js";
|
||||
import { profileApi } from "@/services/api/profile-service.js";
|
||||
import { authApi } from "@/services/api/auth-service.js";
|
||||
import { getRequestErrorMessage } from "@/services/api/request-error-message.js";
|
||||
import { openPage, returnTo } from "@/utils/navigation/gateway.js";
|
||||
|
||||
@@ -55,6 +61,10 @@ const profile = reactive({ phone: "", userNo: "" });
|
||||
const loading = ref(false);
|
||||
const securityProfileError = ref("");
|
||||
const securityProfileRequestController = createRequestController();
|
||||
const wechatBindingRequestController = createRequestController();
|
||||
const wechatProviderState = ref("unknown");
|
||||
const bindingWechat = ref(false);
|
||||
const wechatBindingMessage = ref("");
|
||||
let pageActive = true;
|
||||
const maskedPhone = computed(() =>
|
||||
/^\d{7,}$/.test(profile.phone)
|
||||
@@ -81,10 +91,72 @@ const loadProfile = async () => {
|
||||
const backToProfile = () => returnTo("M01");
|
||||
const openPassword = () => openPage("M04", {}, "M03");
|
||||
const openPhone = () => openPage("M05", {}, "M03");
|
||||
onShow(loadProfile);
|
||||
const detectWechatProvider = () => {
|
||||
// #ifdef APP-PLUS
|
||||
if (typeof uni?.getProvider !== "function") {
|
||||
wechatProviderState.value = "unavailable";
|
||||
wechatBindingMessage.value = "当前设备不支持微信授权";
|
||||
return;
|
||||
}
|
||||
uni.getProvider({
|
||||
service: "oauth",
|
||||
success: ({ provider = [] } = {}) => {
|
||||
if (!pageActive) return;
|
||||
wechatProviderState.value = provider.includes("weixin") ? "ready" : "unavailable";
|
||||
if (wechatProviderState.value === "unavailable") {
|
||||
wechatBindingMessage.value = "当前设备未安装或未配置微信授权";
|
||||
}
|
||||
},
|
||||
fail: () => {
|
||||
if (!pageActive) return;
|
||||
wechatProviderState.value = "unavailable";
|
||||
wechatBindingMessage.value = "暂时无法使用微信授权";
|
||||
},
|
||||
});
|
||||
// #endif
|
||||
// #ifndef APP-PLUS
|
||||
wechatProviderState.value = "unavailable";
|
||||
wechatBindingMessage.value = "请在 App 中绑定微信";
|
||||
// #endif
|
||||
};
|
||||
const requestWechatAuthorizationCode = () =>
|
||||
new Promise((resolve, reject) => {
|
||||
uni.login({
|
||||
provider: "weixin",
|
||||
onlyAuthorize: true,
|
||||
success: ({ code } = {}) => resolve(code),
|
||||
fail: reject,
|
||||
});
|
||||
});
|
||||
const bindWechat = async () => {
|
||||
if (bindingWechat.value || wechatProviderState.value !== "ready") return;
|
||||
bindingWechat.value = true;
|
||||
wechatBindingMessage.value = "";
|
||||
try {
|
||||
const code = await requestWechatAuthorizationCode();
|
||||
await authApi.bindWechat(
|
||||
{ code },
|
||||
{ requestController: wechatBindingRequestController },
|
||||
);
|
||||
if (pageActive) wechatBindingMessage.value = "微信绑定成功";
|
||||
} catch (error) {
|
||||
if (!pageActive || isRequestCancelled(error)) return;
|
||||
const errorText = String(error?.errMsg || error?.message || "");
|
||||
wechatBindingMessage.value = /cancel|取消/i.test(errorText)
|
||||
? "已取消微信绑定"
|
||||
: getRequestErrorMessage(error, "微信绑定失败,请稍后重试");
|
||||
} finally {
|
||||
if (pageActive) bindingWechat.value = false;
|
||||
}
|
||||
};
|
||||
onShow(() => {
|
||||
void loadProfile();
|
||||
detectWechatProvider();
|
||||
});
|
||||
onUnload(() => {
|
||||
pageActive = false;
|
||||
securityProfileRequestController.abort();
|
||||
wechatBindingRequestController.abort();
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -101,7 +173,8 @@ onUnload(() => {
|
||||
z-index: 1;
|
||||
}
|
||||
.page-content {
|
||||
padding: 18rpx 24rpx 72rpx;
|
||||
flex: 1;
|
||||
padding: 18rpx 24rpx calc(72rpx + env(safe-area-inset-bottom));
|
||||
}
|
||||
.state-card,
|
||||
.security-card {
|
||||
@@ -155,13 +228,27 @@ onUnload(() => {
|
||||
text-align: right;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.security-note {
|
||||
margin-top: 22rpx;
|
||||
color: $ink-muted;
|
||||
font-size: clamp(14px, 22rpx, 17px);
|
||||
line-height: 1.6;
|
||||
}
|
||||
.security-card .app-button {
|
||||
margin-top: 20rpx;
|
||||
}
|
||||
.security-message {
|
||||
margin-top: 16rpx;
|
||||
color: $ink-muted;
|
||||
font-size: clamp(14px, 22rpx, 17px);
|
||||
line-height: 1.5;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
@media (max-width: 340px) {
|
||||
.page-content {
|
||||
padding-right: 20rpx;
|
||||
padding-left: 20rpx;
|
||||
}
|
||||
|
||||
.state-card,
|
||||
.security-card {
|
||||
padding-right: 30rpx;
|
||||
padding-left: 30rpx;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -399,7 +399,7 @@ onUnload(() => {
|
||||
}
|
||||
.page-content {
|
||||
flex: 1;
|
||||
padding: 28rpx 30rpx 72rpx;
|
||||
padding: 28rpx 30rpx calc(72rpx + env(safe-area-inset-bottom));
|
||||
}
|
||||
.brand-card {
|
||||
@include adaptive-profile-summary;
|
||||
@@ -513,7 +513,7 @@ onUnload(() => {
|
||||
}
|
||||
.deactivate-link {
|
||||
width: auto;
|
||||
min-height: 54rpx;
|
||||
min-height: 80rpx;
|
||||
margin: 16rpx 0 0;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
@@ -550,14 +550,14 @@ onUnload(() => {
|
||||
.form-row input {
|
||||
width: auto;
|
||||
min-width: 0;
|
||||
min-height: 68rpx;
|
||||
min-height: 80rpx;
|
||||
color: $ink;
|
||||
font-size: clamp(14px, 23rpx, 17px);
|
||||
}
|
||||
.code-action {
|
||||
justify-self: end;
|
||||
width: 198rpx;
|
||||
min-height: 68rpx;
|
||||
min-height: 80rpx;
|
||||
margin: 0;
|
||||
padding: 0 14rpx;
|
||||
box-sizing: border-box;
|
||||
|
||||
+235
-6
@@ -36,6 +36,27 @@
|
||||
<text>可选套餐</text>
|
||||
<text>{{ capability.enabled ? "购买资格已开放" : "当前仅供查看" }}</text>
|
||||
</view>
|
||||
<view v-if="capability.enabled" class="payment-methods">
|
||||
<text class="payment-methods__title">支付方式</text>
|
||||
<view class="payment-methods__options" role="radiogroup" aria-label="支付方式">
|
||||
<button
|
||||
v-for="method in paymentMethodOptions"
|
||||
:key="method.method"
|
||||
class="payment-method"
|
||||
:class="{ 'payment-method--selected': selectedPaymentMethod === method.method }"
|
||||
:disabled="!method.available || paymentState === 'submitting'"
|
||||
role="radio"
|
||||
:aria-checked="selectedPaymentMethod === method.method"
|
||||
@click="selectPaymentMethod(method)"
|
||||
>
|
||||
<text>{{ method.label }}</text>
|
||||
<text v-if="!method.available">{{ method.unavailableReason }}</text>
|
||||
</button>
|
||||
</view>
|
||||
<text v-if="!selectedPaymentMethod" class="payment-methods__error"
|
||||
>当前安装包没有可用支付通道,请完成打包配置后再购买。</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">
|
||||
@@ -46,6 +67,13 @@
|
||||
<text>¥{{ item.price }}</text>
|
||||
<text v-if="item.originalPrice">原价 ¥{{ item.originalPrice }}</text>
|
||||
</view>
|
||||
<AppButton
|
||||
v-if="capability.enabled"
|
||||
compact
|
||||
label="立即购买"
|
||||
:disabled="paymentState === 'submitting' || !selectedPaymentMethod"
|
||||
@click="purchasePackage(item)"
|
||||
/>
|
||||
</view>
|
||||
</view>
|
||||
<view v-else class="vip-empty-state"><text>当前没有可展示的套餐</text></view>
|
||||
@@ -60,7 +88,9 @@
|
||||
<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>
|
||||
<text v-if="item.orderNo">订单号:{{ item.orderNo }}</text>
|
||||
<text v-if="item.paidAt">支付时间:{{ item.paidAt }}</text>
|
||||
<text v-if="item.expiresAt">到期时间:{{ item.expiresAt }}</text>
|
||||
</view>
|
||||
<view>
|
||||
<text>¥{{ item.amount }}</text>
|
||||
@@ -98,27 +128,67 @@ import {
|
||||
isRequestCancelled
|
||||
} from "@/services/api/request-controller.js";
|
||||
import { vipApi } from "@/services/api/vip-service.js";
|
||||
import { VIP_PAYMENT_METHOD } from "@/services/api/vip-contract.js";
|
||||
import { getRequestErrorMessage } from "@/services/api/request-error-message.js";
|
||||
import { genealogyContext } from "@/utils/genealogy/context.js";
|
||||
import { createNonIdempotentWriteGuard } from "@/utils/non-idempotent-write-guard.js";
|
||||
import { handleBackPress, runBackGuard } from "@/utils/navigation/gateway.js";
|
||||
|
||||
const packages = ref([]);
|
||||
const orders = ref([]);
|
||||
const capability = reactive({ enabled: false, disabledReason: "" });
|
||||
const capability = reactive({ enabled: false, disabledReason: "", paymentMethods: [] });
|
||||
const nativePaymentProviders = ref([]);
|
||||
const selectedPaymentMethod = ref("");
|
||||
const readState = ref("loading");
|
||||
const vipReadError = ref("");
|
||||
const serviceNoticeVisible = ref(false);
|
||||
const paymentState = ref("idle");
|
||||
const packageController = createRequestController();
|
||||
const orderController = createRequestController();
|
||||
const capabilityController = createRequestController();
|
||||
const paymentController = createRequestController();
|
||||
const orderCreationGuard = createNonIdempotentWriteGuard();
|
||||
let active = true;
|
||||
const createVipRequestId = () => {
|
||||
if (typeof globalThis.crypto?.randomUUID === "function") {
|
||||
return `app-vip-${globalThis.crypto.randomUUID()}`;
|
||||
}
|
||||
return `app-vip-${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
||||
};
|
||||
const paymentProviderByMethod = Object.freeze({
|
||||
[VIP_PAYMENT_METHOD.WECHAT]: "wxpay",
|
||||
[VIP_PAYMENT_METHOD.ALIPAY]: "alipay",
|
||||
});
|
||||
const paymentMethodOptions = computed(() =>
|
||||
capability.paymentMethods.map((method) => {
|
||||
const requiredProvider = paymentProviderByMethod[method.method];
|
||||
const clientAvailable =
|
||||
!requiredProvider || nativePaymentProviders.value.includes(requiredProvider);
|
||||
return {
|
||||
...method,
|
||||
available: method.enabled && clientAvailable,
|
||||
unavailableReason: method.enabled
|
||||
? "当前安装包未配置此通道"
|
||||
: method.disabledReason || "服务端暂未开放",
|
||||
};
|
||||
}),
|
||||
);
|
||||
const selectedPaymentMethodLabel = computed(
|
||||
() =>
|
||||
paymentMethodOptions.value.find(
|
||||
(method) => method.method === selectedPaymentMethod.value,
|
||||
)?.label || "",
|
||||
);
|
||||
const purchaseSummary = computed(() =>
|
||||
capability.enabled
|
||||
? "当前可以购买会员,支付功能正在完成最后确认。"
|
||||
? selectedPaymentMethodLabel.value
|
||||
? `当前使用${selectedPaymentMethodLabel.value},支付结果以服务端订单状态为准。`
|
||||
: "购买资格已开放,但当前安装包没有可用支付通道。"
|
||||
: capability.disabledReason || "当前可查看套餐和订单,暂时不能在线购买。",
|
||||
);
|
||||
const serviceNoticeCopy = computed(() =>
|
||||
capability.enabled
|
||||
? "你的账号目前可以购买会员。为避免重复扣款,支付按钮将在确认完成后开放;现在可以先查看套餐和订单。"
|
||||
? "支付完成后会向服务端核对订单状态;若渠道已扣款但状态仍在处理中,请勿重复下单。余额支付由服务端在同一事务内完成扣款和开通。"
|
||||
: capability.disabledReason || "当前可查看套餐和已有订单,暂时不能在线购买。",
|
||||
);
|
||||
|
||||
@@ -129,14 +199,22 @@ const loadVipData = async () => {
|
||||
readState.value = "loading";
|
||||
vipReadError.value = "";
|
||||
try {
|
||||
const [capabilityResult, packageRows, orderRows] = await Promise.all([
|
||||
const [capabilityResult, packageRows, orderRows, providerIds] = await Promise.all([
|
||||
vipApi.getVipCapability({ requestController: capabilityController }),
|
||||
vipApi.getVipPackages({ requestController: packageController }),
|
||||
vipApi.getVipOrders({ requestController: orderController }),
|
||||
getNativePaymentProviders(),
|
||||
]);
|
||||
if (!active) return;
|
||||
capability.enabled = capabilityResult.enabled;
|
||||
capability.disabledReason = capabilityResult.disabledReason;
|
||||
capability.paymentMethods = capabilityResult.paymentMethods;
|
||||
nativePaymentProviders.value = providerIds;
|
||||
const currentSelection = paymentMethodOptions.value.find(
|
||||
(method) => method.method === selectedPaymentMethod.value && method.available,
|
||||
);
|
||||
selectedPaymentMethod.value = currentSelection?.method ||
|
||||
paymentMethodOptions.value.find((method) => method.available)?.method || "";
|
||||
packages.value = packageRows;
|
||||
orders.value = orderRows;
|
||||
readState.value = "ready";
|
||||
@@ -146,12 +224,107 @@ const loadVipData = async () => {
|
||||
readState.value = "error";
|
||||
}
|
||||
};
|
||||
const getNativePaymentProviders = () =>
|
||||
new Promise((resolve) => {
|
||||
if (typeof uni?.getProvider !== "function") {
|
||||
resolve([]);
|
||||
return;
|
||||
}
|
||||
uni.getProvider({
|
||||
service: "payment",
|
||||
success: ({ provider = [] } = {}) => resolve(provider),
|
||||
fail: () => resolve([]),
|
||||
});
|
||||
});
|
||||
const selectPaymentMethod = (method) => {
|
||||
if (!method.available || paymentState.value === "submitting") return;
|
||||
selectedPaymentMethod.value = method.method;
|
||||
};
|
||||
const openServiceNotice = () => {
|
||||
serviceNoticeVisible.value = true;
|
||||
};
|
||||
const closeServiceNotice = () => {
|
||||
serviceNoticeVisible.value = false;
|
||||
};
|
||||
const requestNativePayment = (paymentOrder) =>
|
||||
new Promise((resolve, reject) => {
|
||||
if (!paymentOrder.nativePaymentRequired) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
uni.requestPayment({
|
||||
provider: paymentProviderByMethod[paymentOrder.paymentMethod],
|
||||
orderInfo: paymentOrder.orderInfo,
|
||||
success: resolve,
|
||||
fail: reject,
|
||||
});
|
||||
});
|
||||
const isPaymentCancellation = (error) =>
|
||||
/cancel/i.test(String(error?.errMsg || error?.message || ""));
|
||||
const purchasePackage = async (vipPackage) => {
|
||||
if (!capability.enabled || !selectedPaymentMethod.value || paymentState.value === "submitting") return;
|
||||
const genealogyId = genealogyContext.getCurrentGenealogyId();
|
||||
const orderPayload = {
|
||||
packageId: String(vipPackage.id),
|
||||
genealogyId,
|
||||
paymentMethod: selectedPaymentMethod.value,
|
||||
requestId: createVipRequestId(),
|
||||
};
|
||||
const orderAttempt = orderCreationGuard.begin(orderPayload);
|
||||
if (orderAttempt === null) {
|
||||
uni.showToast({
|
||||
title: "上次下单结果待确认,请先查看订单,不要重复购买",
|
||||
icon: "none",
|
||||
});
|
||||
return;
|
||||
}
|
||||
paymentState.value = "submitting";
|
||||
let transactionId = "";
|
||||
try {
|
||||
const paymentOrder = await vipApi.createVipOrder(
|
||||
vipPackage.id,
|
||||
genealogyId,
|
||||
selectedPaymentMethod.value,
|
||||
orderPayload.requestId,
|
||||
{ requestController: paymentController },
|
||||
);
|
||||
transactionId = paymentOrder.transactionId;
|
||||
await requestNativePayment(paymentOrder);
|
||||
const paymentStatus = await vipApi.getVipPaymentStatus(
|
||||
paymentOrder.transactionId,
|
||||
{ requestController: paymentController },
|
||||
);
|
||||
uni.showToast({
|
||||
title: paymentStatus.status === "SUCCESS" ? "购买成功" : "支付结果确认中,请勿重复下单",
|
||||
icon: paymentStatus.status === "SUCCESS" ? "success" : "none",
|
||||
});
|
||||
await loadVipData();
|
||||
} catch (error) {
|
||||
if (!active || isRequestCancelled(error)) return;
|
||||
let feedback = getRequestErrorMessage(error, "支付未完成,请稍后重试。");
|
||||
if (!transactionId && orderCreationGuard.recordFailure(orderAttempt, error)) {
|
||||
feedback = "下单结果暂时无法确认,请先查看订单,不要重复购买";
|
||||
}
|
||||
if (transactionId && isPaymentCancellation(error)) {
|
||||
try {
|
||||
await vipApi.closeVipPayment(transactionId, {
|
||||
requestController: paymentController,
|
||||
});
|
||||
feedback = "已取消支付";
|
||||
await loadVipData();
|
||||
} catch (closeError) {
|
||||
if (isRequestCancelled(closeError)) return;
|
||||
feedback = "支付已取消,订单关闭状态待确认";
|
||||
}
|
||||
}
|
||||
uni.showToast({
|
||||
title: feedback,
|
||||
icon: "none",
|
||||
});
|
||||
} finally {
|
||||
if (active) paymentState.value = "idle";
|
||||
}
|
||||
};
|
||||
const requestBack = () =>
|
||||
runBackGuard({
|
||||
transientOpen: serviceNoticeVisible.value,
|
||||
@@ -165,6 +338,7 @@ onUnload(() => {
|
||||
capabilityController.abort();
|
||||
packageController.abort();
|
||||
orderController.abort();
|
||||
paymentController.abort();
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -181,7 +355,7 @@ onUnload(() => {
|
||||
}
|
||||
.page-content {
|
||||
flex: 1;
|
||||
padding: 20rpx 30rpx 72rpx;
|
||||
padding: 20rpx 30rpx calc(72rpx + env(safe-area-inset-bottom));
|
||||
}
|
||||
.vip-intro {
|
||||
position: relative;
|
||||
@@ -269,6 +443,11 @@ onUnload(() => {
|
||||
justify-content: space-between;
|
||||
gap: 16rpx;
|
||||
}
|
||||
|
||||
.vip-section__heading text {
|
||||
min-width: 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.vip-section__heading text:first-child {
|
||||
color: $ink;
|
||||
font-family: STKaiti, KaiTi, serif;
|
||||
@@ -285,6 +464,36 @@ onUnload(() => {
|
||||
gap: 14rpx;
|
||||
margin-top: 18rpx;
|
||||
}
|
||||
.payment-methods {
|
||||
margin-bottom: 24rpx;
|
||||
padding: 22rpx;
|
||||
border: 1rpx solid rgba(128, 89, 49, 0.2);
|
||||
border-radius: 10rpx;
|
||||
background: rgba(255, 252, 245, 0.7);
|
||||
}
|
||||
.payment-methods__title { display: block; color: $ink; font-size: clamp(14px, 24rpx, 17px); font-weight: 700; }
|
||||
.payment-methods__options { display: flex; flex-wrap: wrap; margin-top: 14rpx; gap: 12rpx; }
|
||||
.payment-method {
|
||||
display: flex;
|
||||
min-height: var(--app-touch-min);
|
||||
flex: 1 1 180rpx;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
justify-content: center;
|
||||
padding: 12rpx 18rpx;
|
||||
border: 1rpx solid rgba(128, 89, 49, 0.28);
|
||||
border-radius: 8rpx;
|
||||
background: rgba(255, 255, 255, 0.58);
|
||||
color: $ink;
|
||||
text-align: left;
|
||||
}
|
||||
.payment-method::after { border: 0; }
|
||||
.payment-method--selected { border-color: #9e251b; background: rgba(158, 37, 27, 0.08); color: #9e251b; }
|
||||
.payment-method[disabled] { opacity: 0.56; }
|
||||
.payment-method text:first-child { font-size: clamp(14px, 23rpx, 17px); font-weight: 700; }
|
||||
.payment-method text:last-child:not(:first-child),
|
||||
.payment-methods__error { margin-top: 6rpx; color: $ink-muted; font-size: clamp(12px, 19rpx, 14px); line-height: 1.4; }
|
||||
.payment-methods__error { display: block; margin-top: 14rpx; color: #9e251b; }
|
||||
.vip-package,
|
||||
.vip-order {
|
||||
display: flex;
|
||||
@@ -321,6 +530,11 @@ onUnload(() => {
|
||||
flex: 0 0 auto;
|
||||
text-align: right;
|
||||
}
|
||||
.vip-package > .app-button {
|
||||
width: auto;
|
||||
min-width: 132rpx;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
.vip-package__price text:first-child,
|
||||
.vip-order > view:last-child text:first-child {
|
||||
color: $brand-red;
|
||||
@@ -354,5 +568,20 @@ onUnload(() => {
|
||||
padding-right: 20rpx;
|
||||
padding-left: 20rpx;
|
||||
}
|
||||
|
||||
.vip-package {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
gap: 14rpx 18rpx;
|
||||
}
|
||||
|
||||
.vip-package > .app-button {
|
||||
width: 100%;
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.vip-order {
|
||||
align-items: flex-start;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -35,9 +35,16 @@
|
||||
:key="item.id"
|
||||
class="ceremony-card"
|
||||
@click="openCeremony(item)"
|
||||
><view
|
||||
><image
|
||||
v-if="item.coverFile?.accessUrl"
|
||||
class="ceremony-card__cover"
|
||||
:src="item.coverFile.accessUrl"
|
||||
mode="aspectFill"
|
||||
aria-hidden="true"
|
||||
/><view
|
||||
><text>{{ item.title }}</text
|
||||
><text>{{ item.typeLabel }}{{ item.time ? ` · ${item.time}` : "" }}</text
|
||||
><text v-if="item.createTime">创建于 {{ formatMinuteTimestamp(item.createTime) }}</text
|
||||
><text v-if="item.description" class="ceremony-card__description">{{ item.description }}</text></view
|
||||
><text>{{ item.giftCount }} 笔献礼</text></view
|
||||
></view
|
||||
@@ -58,6 +65,7 @@ import {
|
||||
isRequestCancelled
|
||||
} from "@/services/api/request-controller.js";
|
||||
import { ceremonyApi } from "@/services/api/ceremony-service.js";
|
||||
import { formatMinuteTimestamp } from "@/utils/display-time.js";
|
||||
import { goBack, openPage, returnTo } from "@/utils/navigation/gateway.js";
|
||||
|
||||
const genealogyId = ref("");
|
||||
@@ -120,11 +128,13 @@ onUnload(() => {
|
||||
z-index: 1;
|
||||
}
|
||||
.page-content {
|
||||
padding: 18rpx 24rpx 72rpx;
|
||||
flex: 1;
|
||||
padding: 18rpx 24rpx calc(72rpx + env(safe-area-inset-bottom));
|
||||
}
|
||||
.state-card,
|
||||
.ceremony-card {
|
||||
@include adaptive-records-content;
|
||||
background-color: rgba($paper, 0.82);
|
||||
}
|
||||
.state-card {
|
||||
display: flex;
|
||||
@@ -144,7 +154,7 @@ onUnload(() => {
|
||||
.ceremony-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16rpx;
|
||||
gap: 20rpx;
|
||||
}
|
||||
.ceremony-card {
|
||||
display: flex;
|
||||
@@ -158,6 +168,13 @@ onUnload(() => {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
.ceremony-card__cover {
|
||||
width: 150rpx;
|
||||
height: 118rpx;
|
||||
flex: 0 0 auto;
|
||||
border-radius: 10rpx;
|
||||
background: rgba(128, 89, 49, 0.12);
|
||||
}
|
||||
.ceremony-card text {
|
||||
display: block;
|
||||
}
|
||||
|
||||
@@ -26,6 +26,15 @@
|
||||
</view>
|
||||
<view v-else>
|
||||
<view class="detail-card">
|
||||
<image
|
||||
v-if="detail.coverFile?.accessUrl"
|
||||
class="detail-card__cover"
|
||||
:src="detail.coverFile.accessUrl"
|
||||
mode="aspectFill"
|
||||
role="button"
|
||||
aria-label="查看礼仪活动封面"
|
||||
@click="previewCover"
|
||||
/>
|
||||
<text>{{ detail.title }}</text>
|
||||
<text>{{ detail.typeLabel }}{{ detail.time ? ` · ${detail.time}` : "" }}</text>
|
||||
<text v-if="detail.location || detail.locationAddress">地点:{{ detail.location || detail.locationAddress }}</text>
|
||||
@@ -165,9 +174,9 @@
|
||||
:visible="ceremonyDeleteConfirmVisible"
|
||||
:close-on-mask="false"
|
||||
eyebrow="删除确认"
|
||||
title="删除这项礼仪活动?"
|
||||
message="活动和相关内容会一并删除,删除后无法恢复。"
|
||||
confirm-text="确认删除"
|
||||
title="将这项礼仪活动移至回收站?"
|
||||
message="活动和相关内容将不再展示,家谱管理员可在保留期内恢复。"
|
||||
confirm-text="移至回收站"
|
||||
cancel-text="保留活动"
|
||||
show-cancel
|
||||
@confirm="deleteCeremony"
|
||||
@@ -258,6 +267,11 @@ const valid = computed(
|
||||
() =>
|
||||
/^[1-9]\d*$/.test(genealogyId.value) && /^[1-9]\d*$/.test(ceremonyId.value),
|
||||
);
|
||||
const previewCover = () => {
|
||||
const url = detail.value?.coverFile?.accessUrl;
|
||||
if (!url || typeof uni?.previewImage !== "function") return;
|
||||
uni.previewImage({ current: url, urls: [url] });
|
||||
};
|
||||
const giftSnapshot = computed(() => JSON.stringify(giftForm));
|
||||
const giftDirty = computed(
|
||||
() => giftFormVisible.value && giftSnapshot.value !== giftBaseline.value,
|
||||
@@ -337,7 +351,12 @@ const toggleGiftForm = async () => {
|
||||
return true;
|
||||
};
|
||||
const requestGiftSubmit = () => {
|
||||
giftError.value = giftForm.giftAmount.trim() ? "" : "请填写献礼金额";
|
||||
const giftAmount = giftForm.giftAmount.trim();
|
||||
giftError.value = !giftAmount
|
||||
? "请填写献礼金额"
|
||||
: /^(?:0|[1-9]\d{0,9})(?:\.\d{1,2})?$/.test(giftAmount)
|
||||
? ""
|
||||
: "献礼金额应为 0 至 9999999999.99,最多保留两位小数";
|
||||
if (!giftError.value && !giftSubmitting.value) giftConfirmVisible.value = true;
|
||||
};
|
||||
const confirmGiftSubmit = async () => {
|
||||
@@ -544,7 +563,8 @@ onUnload(() => {
|
||||
z-index: 1;
|
||||
}
|
||||
.page-content {
|
||||
padding: 18rpx 24rpx 72rpx;
|
||||
flex: 1;
|
||||
padding: 18rpx 24rpx calc(72rpx + env(safe-area-inset-bottom));
|
||||
}
|
||||
.state-card,
|
||||
.detail-card,
|
||||
@@ -552,11 +572,19 @@ onUnload(() => {
|
||||
.gift-form-card,
|
||||
.gift-result {
|
||||
@include adaptive-records-content;
|
||||
background-color: rgba($paper, 0.82);
|
||||
}
|
||||
.gift-form-card {
|
||||
margin-top: 18rpx;
|
||||
padding: 34rpx 32rpx;
|
||||
}
|
||||
.detail-card__cover {
|
||||
width: 100%;
|
||||
height: 320rpx;
|
||||
margin-bottom: 22rpx;
|
||||
border-radius: 10rpx;
|
||||
background: rgba(128, 89, 49, 0.12);
|
||||
}
|
||||
.gift-form-card__title,
|
||||
.gift-form-card__note,
|
||||
.gift-field > text,
|
||||
@@ -604,7 +632,7 @@ onUnload(() => {
|
||||
}
|
||||
.gift-field input {
|
||||
width: auto;
|
||||
min-height: 70rpx;
|
||||
min-height: 80rpx;
|
||||
text-align: right;
|
||||
}
|
||||
.gift-message-field {
|
||||
|
||||
@@ -107,6 +107,7 @@
|
||||
<text v-if="coverFileName" class="upload-receipt"
|
||||
>已上传:{{ coverFileName }}</text
|
||||
>
|
||||
<button v-if="coverOssId" class="remove-cover-button" :disabled="uploading || submitting" @click="clearCover">移除封面</button>
|
||||
</view>
|
||||
<text v-if="uploadError" class="form-error">{{ uploadError }}</text>
|
||||
<text v-if="submitError" class="form-error">{{ submitError }}</text>
|
||||
@@ -370,6 +371,12 @@ const uploadCover = async () => {
|
||||
if (pageActive) uploading.value = false;
|
||||
}
|
||||
};
|
||||
const clearCover = () => {
|
||||
if (uploading.value || submitting.value) return;
|
||||
coverOssId.value = null;
|
||||
coverFileName.value = "";
|
||||
uploadError.value = "";
|
||||
};
|
||||
const saveCeremony = async () => {
|
||||
if (uploading.value || submitting.value || !hasValidContext.value) return;
|
||||
if (!form.ceremonyType.trim() || !form.ceremonyTitle.trim()) {
|
||||
@@ -382,7 +389,7 @@ const saveCeremony = async () => {
|
||||
const payload = {
|
||||
...ceremonyForm,
|
||||
ceremonyTime: ceremonyTime.value,
|
||||
...(coverOssId.value ? { coverOssId: coverOssId.value } : {}),
|
||||
coverOssId: coverOssId.value,
|
||||
...(isEdit.value ? preservedUpdateFields.value : {}),
|
||||
};
|
||||
const createAttempt = isEdit.value ? null : ceremonyCreateGuard.begin(payload);
|
||||
@@ -463,12 +470,13 @@ onUnload(() => {
|
||||
}
|
||||
.page-content {
|
||||
flex: 1;
|
||||
padding: 22rpx 28rpx 72rpx;
|
||||
padding: 22rpx 28rpx calc(72rpx + env(safe-area-inset-bottom));
|
||||
}
|
||||
.editor-card,
|
||||
.state-card {
|
||||
@include adaptive-records-content;
|
||||
box-sizing: border-box;
|
||||
background-color: rgba($paper, 0.82);
|
||||
}
|
||||
.editor-card {
|
||||
padding: 38rpx 32rpx 42rpx;
|
||||
@@ -593,6 +601,18 @@ onUnload(() => {
|
||||
font-size: clamp(13px, 21rpx, 16px);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.remove-cover-button {
|
||||
justify-self: start;
|
||||
min-height: 72rpx;
|
||||
margin: 0;
|
||||
padding: 0 18rpx;
|
||||
border: 1rpx solid rgba($brand-red, 0.38);
|
||||
border-radius: 8rpx;
|
||||
background: transparent;
|
||||
color: $brand-red;
|
||||
font-size: clamp(13px, 21rpx, 16px);
|
||||
}
|
||||
.remove-cover-button::after { border: 0; }
|
||||
.form-error {
|
||||
margin-top: 10rpx;
|
||||
color: $brand-red;
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
<view class="page-content">
|
||||
<view v-if="view === 'form'" class="form-card">
|
||||
<text>{{ isEdit ? '编辑成长记录' : '新建成长记录' }}</text>
|
||||
<text class="form-copy">{{ isEdit ? '原有图片和相关设置会保留。' : '红色 * 为必填项,其余内容可按需补充。' }}</text>
|
||||
<text class="form-copy">{{ isEdit ? '原有图片、视频和相关设置会保留,也可以逐项移除。' : '红色 * 为必填项,其余内容可按需补充。' }}</text>
|
||||
<view class="field field--picker">
|
||||
<text>关联人物</text>
|
||||
<picker
|
||||
@@ -106,23 +106,32 @@
|
||||
>
|
||||
<view class="upload-field">
|
||||
<view
|
||||
><text>相关图片</text
|
||||
><text>相关图片或视频</text
|
||||
><text
|
||||
>图片上传成功后,会随这条记录一起保存。</text
|
||||
>媒体上传成功后,会随这条记录一起保存。</text
|
||||
></view
|
||||
>
|
||||
<button
|
||||
class="upload-button"
|
||||
:disabled="uploading || submitting"
|
||||
@click="uploadImage"
|
||||
>
|
||||
{{ uploading ? "上传中…" : "添加图片" }}
|
||||
</button>
|
||||
<text
|
||||
<view class="upload-actions">
|
||||
<button
|
||||
class="upload-button"
|
||||
:disabled="uploading || submitting"
|
||||
@click="uploadImage"
|
||||
>添加图片</button>
|
||||
<button
|
||||
class="upload-button"
|
||||
:disabled="uploading || submitting"
|
||||
@click="uploadVideo"
|
||||
>添加视频</button>
|
||||
</view>
|
||||
<text v-if="uploading">正在上传媒体…</text>
|
||||
<view
|
||||
v-for="(receipt, index) in mediaReceipts"
|
||||
:key="`${receipt.ossId}-${index}`"
|
||||
>已上传:{{ receipt.fileName || "图片" }}</text
|
||||
class="upload-receipt"
|
||||
>
|
||||
<text>已上传:{{ receipt.fileName || "媒体文件" }}</text>
|
||||
<button :disabled="uploading || submitting" @click="removeMedia(index)">移除</button>
|
||||
</view>
|
||||
<text v-if="uploadError" class="error">{{ uploadError }}</text>
|
||||
</view>
|
||||
<text v-if="error" class="error">{{ error }}</text>
|
||||
@@ -184,6 +193,7 @@
|
||||
><text
|
||||
>{{ growthRecordTypeLabel(item)
|
||||
}}{{ item.date ? ` · ${item.date}` : "" }}</text
|
||||
><text v-if="item.createTime">创建于 {{ formatMinuteTimestamp(item.createTime) }}</text
|
||||
><text v-if="item.content">{{ item.content }}</text></view
|
||||
>
|
||||
<AppButton
|
||||
@@ -222,9 +232,9 @@
|
||||
/>
|
||||
<AppDialog
|
||||
:visible="deleteConfirmationVisible"
|
||||
title="删除这条成长记录?"
|
||||
message="删除后无法恢复,请确认当前内容不再需要。"
|
||||
confirm-text="确认删除"
|
||||
title="将这条成长记录移至回收站?"
|
||||
message="移入回收站后不再展示,家谱管理员可在保留期内恢复。"
|
||||
confirm-text="移至回收站"
|
||||
cancel-text="保留记录"
|
||||
show-cancel
|
||||
:close-on-mask="false"
|
||||
@@ -252,10 +262,13 @@ 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 { formatMinuteTimestamp } from "@/utils/display-time.js";
|
||||
import { createNonIdempotentWriteGuard } from "@/utils/non-idempotent-write-guard.js";
|
||||
import {
|
||||
isImagePickCancelled,
|
||||
isVideoPickCancelled,
|
||||
pickAndUploadImage,
|
||||
pickAndUploadVideo,
|
||||
} from "@/utils/media-upload.js";
|
||||
import { goBack, handleBackPress, runBackGuard } from "@/utils/navigation/gateway.js";
|
||||
|
||||
@@ -358,7 +371,11 @@ const formSnapshot = computed(() =>
|
||||
const dirty = computed(() =>
|
||||
isEdit.value
|
||||
? Boolean(formBaseline.value) && formSnapshot.value !== formBaseline.value
|
||||
: Object.values(form).some((value) => String(value).trim()) ||
|
||||
: Object.entries(form).some(
|
||||
([field, value]) =>
|
||||
field !== "lineagePersonId" && String(value).trim(),
|
||||
) ||
|
||||
form.lineagePersonId !== defaultLineagePersonId.value ||
|
||||
mediaReceipts.value.length > 0,
|
||||
);
|
||||
const confirmation = createDiscardConfirmation((visible) => {
|
||||
@@ -636,6 +653,28 @@ const saveGrowthRecord = async () => {
|
||||
if (pageActive) submitting.value = false;
|
||||
}
|
||||
};
|
||||
const uploadVideo = async () => {
|
||||
if (uploading.value || submitting.value) return;
|
||||
uploading.value = true;
|
||||
uploadError.value = "";
|
||||
try {
|
||||
const uploadReceipt = await pickAndUploadVideo({
|
||||
requestController: growthMediaUploadRequestController,
|
||||
});
|
||||
if (!pageActive) return;
|
||||
mediaReceipts.value = [...mediaReceipts.value, uploadReceipt];
|
||||
} catch (cause) {
|
||||
if (pageActive && !isVideoPickCancelled(cause) && !isRequestCancelled(cause))
|
||||
uploadError.value = getRequestErrorMessage(cause, "视频上传失败,请稍后重试。");
|
||||
} finally {
|
||||
if (pageActive) uploading.value = false;
|
||||
}
|
||||
};
|
||||
const removeMedia = (index) => {
|
||||
if (uploading.value || submitting.value) return;
|
||||
mediaReceipts.value = mediaReceipts.value.filter((_, receiptIndex) => receiptIndex !== index);
|
||||
uploadError.value = "";
|
||||
};
|
||||
const requestDeleteRecord = (record) => {
|
||||
if (!record?.canDelete || deleting.value) return;
|
||||
deleteError.value = "";
|
||||
@@ -726,12 +765,14 @@ onUnload(() => {
|
||||
z-index: 1;
|
||||
}
|
||||
.page-content {
|
||||
padding: 18rpx 24rpx 72rpx;
|
||||
flex: 1;
|
||||
padding: 18rpx 24rpx calc(72rpx + env(safe-area-inset-bottom));
|
||||
}
|
||||
.form-card,
|
||||
.state-card,
|
||||
.record-card {
|
||||
@include adaptive-records-content;
|
||||
background-color: rgba($paper, 0.82);
|
||||
}
|
||||
.form-card {
|
||||
box-sizing: border-box;
|
||||
@@ -773,7 +814,7 @@ onUnload(() => {
|
||||
@include adaptive-records-field;
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
min-height: 76rpx;
|
||||
min-height: 80rpx;
|
||||
padding: 16rpx 22rpx;
|
||||
color: $ink;
|
||||
font-size: clamp(14px, 23rpx, 17px);
|
||||
@@ -801,15 +842,15 @@ onUnload(() => {
|
||||
padding: 18rpx 22rpx;
|
||||
@include adaptive-records-field;
|
||||
}
|
||||
.upload-field > view > text {
|
||||
.upload-field > view:first-child > text {
|
||||
display: block;
|
||||
}
|
||||
.upload-field > view > text:first-child {
|
||||
.upload-field > view:first-child > text:first-child {
|
||||
color: $ink;
|
||||
font-size: clamp(14px, 23rpx, 17px);
|
||||
font-weight: 700;
|
||||
}
|
||||
.upload-field > view > text:last-child {
|
||||
.upload-field > view:first-child > text:last-child {
|
||||
margin-top: 6rpx;
|
||||
color: $ink-muted;
|
||||
font-size: clamp(13px, 20rpx, 16px);
|
||||
@@ -875,13 +916,40 @@ onUnload(() => {
|
||||
.record-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16rpx;
|
||||
gap: 20rpx;
|
||||
}
|
||||
.save-notice {
|
||||
display: block;
|
||||
color: $brand-red;
|
||||
font-size: clamp(14px, 22rpx, 17px);
|
||||
}
|
||||
.upload-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12rpx;
|
||||
}
|
||||
.upload-receipt {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12rpx;
|
||||
}
|
||||
.upload-receipt > text {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
color: $ink-muted;
|
||||
font-size: clamp(13px, 21rpx, 16px);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.upload-receipt > button {
|
||||
min-height: 72rpx;
|
||||
margin: 0;
|
||||
padding: 0 16rpx;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: $brand-red;
|
||||
font-size: clamp(13px, 20rpx, 16px);
|
||||
}
|
||||
.upload-receipt > button::after { border: 0; }
|
||||
.delete-error {
|
||||
display: block;
|
||||
color: $brand-red;
|
||||
@@ -892,7 +960,7 @@ onUnload(() => {
|
||||
@include adaptive-records-field;
|
||||
display: flex;
|
||||
box-sizing: border-box;
|
||||
min-height: 78rpx;
|
||||
min-height: 80rpx;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 14rpx 18rpx;
|
||||
|
||||
@@ -444,12 +444,14 @@ onUnload(() => {
|
||||
z-index: 1;
|
||||
}
|
||||
.page-content {
|
||||
padding: 18rpx 24rpx 72rpx;
|
||||
flex: 1;
|
||||
padding: 18rpx 24rpx calc(72rpx + env(safe-area-inset-bottom));
|
||||
}
|
||||
.form-card,
|
||||
.state-card,
|
||||
.event-card {
|
||||
@include adaptive-records-content;
|
||||
background-color: rgba($paper, 0.82);
|
||||
}
|
||||
.form-card {
|
||||
box-sizing: border-box;
|
||||
@@ -491,7 +493,7 @@ onUnload(() => {
|
||||
@include adaptive-records-field;
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
min-height: 76rpx;
|
||||
min-height: 80rpx;
|
||||
padding: 16rpx 22rpx;
|
||||
color: $ink;
|
||||
font-size: clamp(14px, 23rpx, 17px);
|
||||
@@ -586,7 +588,7 @@ onUnload(() => {
|
||||
.event-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16rpx;
|
||||
gap: 20rpx;
|
||||
}
|
||||
.save-notice {
|
||||
display: block;
|
||||
@@ -619,5 +621,5 @@ onUnload(() => {
|
||||
justify-content: flex-end;
|
||||
margin-top: 18rpx;
|
||||
}
|
||||
.event-card__actions .app-button { width: 154rpx; min-height: 66rpx; }
|
||||
.event-card__actions .app-button { width: 154rpx; min-height: 80rpx; }
|
||||
</style>
|
||||
|
||||
+42
-28
@@ -3,7 +3,7 @@
|
||||
<ModulePageBackground module="records" />
|
||||
<view class="page-header"
|
||||
><PageHeader
|
||||
title="家族备忘"
|
||||
:title="pageTitle"
|
||||
:action="valid && view === 'list' ? '新建' : ''"
|
||||
custom-back
|
||||
@back="requestBack"
|
||||
@@ -11,14 +11,14 @@
|
||||
/></view>
|
||||
<view class="page-content">
|
||||
<view v-if="view === 'form'" class="form-card">
|
||||
<text>{{ isEdit ? '编辑家族备忘' : '新建家族备忘' }}</text>
|
||||
<text>{{ isEdit ? `编辑${recordName}` : `新建${recordName}` }}</text>
|
||||
<text class="form-copy">{{ isEdit ? '原有关联图片、完成状态和排序会随本次保存保留。' : '红色 * 为必填项,其余内容可按需补充。' }}</text>
|
||||
<view class="field"
|
||||
><text><text class="required-mark">*</text>备忘标题</text
|
||||
><text><text class="required-mark">*</text>{{ isBenefactorMode ? "恩人姓名" : "备忘标题" }}</text
|
||||
><input
|
||||
v-model="form.memoTitle"
|
||||
maxlength="40"
|
||||
placeholder="请输入备忘标题"
|
||||
:placeholder="isBenefactorMode ? '请输入恩人姓名' : '请输入备忘标题'"
|
||||
@input="error = ''"
|
||||
/></view>
|
||||
<picker mode="date" :value="form.remindDate" @change="selectRemindDate"
|
||||
@@ -37,12 +37,12 @@
|
||||
></picker
|
||||
>
|
||||
<view class="field field--textarea"
|
||||
><text>备忘内容</text
|
||||
><text>{{ isBenefactorMode ? "恩人事迹" : "备忘内容" }}</text
|
||||
><textarea
|
||||
v-model="form.memoContent"
|
||||
auto-height
|
||||
maxlength="1200"
|
||||
placeholder="记录需要提醒的事情"
|
||||
:placeholder="isBenefactorMode ? '记录恩人事迹与家族渊源' : '记录需要提醒的事情'"
|
||||
@input="error = ''"
|
||||
/>
|
||||
</view>
|
||||
@@ -50,7 +50,7 @@
|
||||
<view
|
||||
><text>相关图片</text
|
||||
><text
|
||||
>图片上传成功后,会随备忘一起保存。</text
|
||||
>图片上传成功后,会随{{ recordName }}一起保存。</text
|
||||
></view
|
||||
>
|
||||
<button
|
||||
@@ -74,34 +74,35 @@
|
||||
label="取消"
|
||||
@click="cancelCreate" /><AppButton
|
||||
:disabled="submitting || uploading"
|
||||
:label="submitting ? '正在提交' : '提交备忘'"
|
||||
:label="submitting ? '正在提交' : `提交${recordName}`"
|
||||
@click="saveMemo"
|
||||
/></view>
|
||||
</view>
|
||||
<view v-else-if="!valid" class="state-card"
|
||||
><text>暂时无法打开家族备忘</text
|
||||
><text>暂时无法打开{{ recordName }}</text
|
||||
><AppButton block label="返回上一页" @click="requestBack"
|
||||
/></view>
|
||||
<view v-else-if="listState === 'loading'" class="state-card"
|
||||
><AppLoading text="正在读取家族备忘"
|
||||
><AppLoading :text="`正在读取${recordName}`"
|
||||
/></view>
|
||||
<view v-else-if="listState === 'error'" class="state-card"
|
||||
><text>暂时无法读取家族备忘</text
|
||||
><text>暂时无法读取{{ recordName }}</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"
|
||||
><text>还没有{{ recordName }}</text
|
||||
><AppButton block :label="`新建${recordName}`" @click="openCreate"
|
||||
/></view>
|
||||
<view v-else class="memo-list">
|
||||
<text v-if="saveNotice" class="save-notice"
|
||||
>备忘已保存。</text
|
||||
>{{ recordName }}已保存。</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.createTime">创建于 {{ formatMinuteTimestamp(item.createTime) }}</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">
|
||||
@@ -125,29 +126,30 @@
|
||||
</view>
|
||||
<AppDialog
|
||||
:visible="Boolean(detailTarget)"
|
||||
eyebrow="备忘详情"
|
||||
:title="detailTarget?.title || '家族备忘'"
|
||||
:eyebrow="`${recordName}详情`"
|
||||
:title="detailTarget?.title || recordName"
|
||||
confirm-text="关闭"
|
||||
:close-on-mask="detailState !== 'loading'"
|
||||
@confirm="closeMemoDetail"
|
||||
@cancel="closeMemoDetail"
|
||||
>
|
||||
<view class="detail-content">
|
||||
<AppLoading v-if="detailState === 'loading'" text="正在读取完整备忘" />
|
||||
<AppLoading v-if="detailState === 'loading'" :text="`正在读取完整${recordName}`" />
|
||||
<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>
|
||||
<text v-if="detailTarget?.createTime">创建时间:{{ formatMinuteTimestamp(detailTarget.createTime) }}</text>
|
||||
<text class="detail-content__body">{{ detailTarget?.content || `未填写${recordName}内容` }}</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)" />
|
||||
<image v-for="file in detailTarget.mediaFiles" :key="file.fileId" :src="file.accessUrl" mode="aspectFill" role="button" :aria-label="`查看${recordName}图片`" @click="previewDetailMedia(file)" />
|
||||
</view>
|
||||
</template>
|
||||
</view>
|
||||
</AppDialog>
|
||||
<AppDialog
|
||||
:visible="discardVisible"
|
||||
title="放弃家族备忘?"
|
||||
:title="`放弃${recordName}?`"
|
||||
message="尚未提交的内容将被清除。"
|
||||
confirm-text="放弃并返回"
|
||||
cancel-text="继续填写"
|
||||
@@ -158,10 +160,10 @@
|
||||
/>
|
||||
<AppDialog
|
||||
:visible="deleteConfirmationVisible"
|
||||
title="删除这条家族备忘?"
|
||||
message="删除后无法恢复,请确认当前内容不再需要。"
|
||||
confirm-text="确认删除"
|
||||
cancel-text="保留备忘"
|
||||
:title="`将这条${recordName}移至回收站?`"
|
||||
message="移入回收站后不再展示,家谱管理员可在保留期内恢复。"
|
||||
confirm-text="移至回收站"
|
||||
:cancel-text="`保留${recordName}`"
|
||||
show-cancel
|
||||
:close-on-mask="false"
|
||||
@confirm="deleteMemo"
|
||||
@@ -182,9 +184,11 @@ import {
|
||||
createRequestController,
|
||||
isRequestCancelled
|
||||
} from "@/services/api/request-controller.js";
|
||||
import { MEMO_TYPE } from "@/services/api/life-record-contract.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 { formatMinuteTimestamp } from "@/utils/display-time.js";
|
||||
import { createNonIdempotentWriteGuard } from "@/utils/non-idempotent-write-guard.js";
|
||||
import {
|
||||
isImagePickCancelled,
|
||||
@@ -193,6 +197,7 @@ import {
|
||||
import { goBack, handleBackPress, runBackGuard } from "@/utils/navigation/gateway.js";
|
||||
|
||||
const genealogyId = ref("");
|
||||
const memoType = ref(MEMO_TYPE.GENERAL);
|
||||
const view = ref("list");
|
||||
const listState = ref("loading");
|
||||
const memos = ref([]);
|
||||
@@ -228,6 +233,9 @@ const memoDetailRequestController = createRequestController();
|
||||
const memoCreateGuard = createNonIdempotentWriteGuard();
|
||||
let pageActive = true;
|
||||
const valid = computed(() => /^[1-9]\d*$/.test(genealogyId.value));
|
||||
const isBenefactorMode = computed(() => memoType.value === MEMO_TYPE.BENEFACTOR);
|
||||
const recordName = computed(() => isBenefactorMode.value ? "家族恩人" : "家族备忘");
|
||||
const pageTitle = computed(() => recordName.value);
|
||||
const isEdit = computed(() => Boolean(editingMemo.value));
|
||||
const remindTime = computed(() =>
|
||||
form.remindDate ? `${form.remindDate} ${form.remindClock || "00:00"}:00` : "",
|
||||
@@ -276,7 +284,7 @@ const loadMemos = async () => {
|
||||
requestController: memoListRequestController,
|
||||
});
|
||||
if (!pageActive) return;
|
||||
memos.value = rows;
|
||||
memos.value = rows.filter((memo) => memo.memoType === memoType.value);
|
||||
listState.value = memos.value.length ? "ready" : "empty";
|
||||
if (pendingMemoId.value) {
|
||||
const target = memos.value.find((item) => item.id === pendingMemoId.value);
|
||||
@@ -413,6 +421,7 @@ const saveMemo = async () => {
|
||||
return;
|
||||
}
|
||||
const payload = {
|
||||
memoType: memoType.value,
|
||||
memoTitle: form.memoTitle,
|
||||
remindTime: remindTime.value,
|
||||
memoContent: form.memoContent,
|
||||
@@ -510,6 +519,9 @@ const requestBack = () =>
|
||||
|
||||
onLoad((query) => {
|
||||
genealogyId.value = String(query?.genealogyId || "");
|
||||
memoType.value = query?.memoType === MEMO_TYPE.BENEFACTOR
|
||||
? MEMO_TYPE.BENEFACTOR
|
||||
: MEMO_TYPE.GENERAL;
|
||||
pendingMemoId.value = /^[1-9]\d*$/.test(String(query?.memoId || "")) ? String(query.memoId) : "";
|
||||
if (valid.value) loadMemos();
|
||||
else listState.value = "invalid";
|
||||
@@ -544,12 +556,14 @@ onUnload(() => {
|
||||
z-index: 1;
|
||||
}
|
||||
.page-content {
|
||||
padding: 18rpx 24rpx 72rpx;
|
||||
flex: 1;
|
||||
padding: 18rpx 24rpx calc(72rpx + env(safe-area-inset-bottom));
|
||||
}
|
||||
.form-card,
|
||||
.state-card,
|
||||
.memo-card {
|
||||
@include adaptive-records-content;
|
||||
background-color: rgba($paper, 0.82);
|
||||
}
|
||||
.form-card {
|
||||
box-sizing: border-box;
|
||||
@@ -674,7 +688,7 @@ onUnload(() => {
|
||||
.memo-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16rpx;
|
||||
gap: 20rpx;
|
||||
}
|
||||
.save-notice {
|
||||
display: block;
|
||||
@@ -707,7 +721,7 @@ onUnload(() => {
|
||||
}
|
||||
.memo-card__actions .app-button {
|
||||
width: 140rpx;
|
||||
min-height: 68rpx;
|
||||
min-height: 80rpx;
|
||||
}
|
||||
.memo-card text {
|
||||
display: block;
|
||||
|
||||
+133
-22
@@ -59,13 +59,27 @@
|
||||
}}</view></picker
|
||||
></view
|
||||
>
|
||||
<view class="upload-field">
|
||||
<view>
|
||||
<text>相关图片</text>
|
||||
<text>图片上传成功后,会随这条功德记录一起保存。</text>
|
||||
</view>
|
||||
<button class="upload-button" :disabled="uploading || submitting" @click="uploadImage">
|
||||
{{ uploading ? "上传中…" : "添加图片" }}
|
||||
</button>
|
||||
<view v-for="(receipt, index) in mediaReceipts" :key="`${receipt.ossId}-${index}`" class="upload-receipt">
|
||||
<text>已上传:{{ receipt.fileName || "图片" }}</text>
|
||||
<button :disabled="uploading || submitting" @click="removeImage(index)">移除</button>
|
||||
</view>
|
||||
<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"
|
||||
:disabled="submitting || uploading"
|
||||
:label="submitting ? '正在提交' : isEdit ? '保存修改' : '提交功德记录'"
|
||||
@click="saveMeritRecord"
|
||||
/></view>
|
||||
@@ -98,6 +112,7 @@
|
||||
<text>{{ item.title }}</text>
|
||||
<text>{{ item.donor }}{{ item.typeLabel ? ` · ${item.typeLabel}` : "" }}</text>
|
||||
<text v-if="item.time">{{ item.time }}</text>
|
||||
<text v-if="item.createTime">创建于 {{ formatMinuteTimestamp(item.createTime) }}</text>
|
||||
<text v-if="item.content">{{ item.content }}</text>
|
||||
</view>
|
||||
<view class="merit-card__amount">
|
||||
@@ -136,8 +151,20 @@
|
||||
<text>捐赠人:{{ detailTarget?.donor || "未署名" }}</text>
|
||||
<text>功德类型:{{ detailTarget?.typeLabel || "未填写" }}</text>
|
||||
<text>记录时间:{{ detailTarget?.time || "未填写" }}</text>
|
||||
<text v-if="detailTarget?.createTime">创建时间:{{ formatMinuteTimestamp(detailTarget.createTime) }}</text>
|
||||
<text>金额:¥{{ detailTarget?.amount || "0.00" }}</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="file.fileName || '查看功德记录图片'"
|
||||
@click="previewMeritImage(file)"
|
||||
/>
|
||||
</view>
|
||||
</template>
|
||||
</view>
|
||||
</AppDialog>
|
||||
@@ -154,9 +181,9 @@
|
||||
/>
|
||||
<AppDialog
|
||||
:visible="deleteConfirmationVisible"
|
||||
title="删除这笔功德记录?"
|
||||
message="删除后无法恢复,请确认当前内容不再需要。"
|
||||
confirm-text="确认删除"
|
||||
title="将这笔功德记录移至回收站?"
|
||||
message="移入回收站后不再展示,家谱管理员可在保留期内恢复。"
|
||||
confirm-text="移至回收站"
|
||||
cancel-text="保留记录"
|
||||
show-cancel
|
||||
:close-on-mask="false"
|
||||
@@ -174,9 +201,7 @@ 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 { businessDictionaryApi } from "@/services/api/business-dictionary-service.js";
|
||||
import {
|
||||
createRequestController,
|
||||
isRequestCancelled
|
||||
@@ -184,16 +209,25 @@ import {
|
||||
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 { formatMinuteTimestamp } from "@/utils/display-time.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 meritTypeOptions = ref([]);
|
||||
const view = ref("list");
|
||||
const listState = ref("loading");
|
||||
const merits = ref([]);
|
||||
const saveNotice = ref("");
|
||||
const submitting = ref(false);
|
||||
const uploading = ref(false);
|
||||
const error = ref("");
|
||||
const uploadError = ref("");
|
||||
const mediaReceipts = ref([]);
|
||||
const discardVisible = ref(false);
|
||||
const deleteConfirmationVisible = ref(false);
|
||||
const deleteTarget = ref(null);
|
||||
@@ -224,15 +258,18 @@ const meritEditorDetailRequestController = createRequestController();
|
||||
const meritSaveRequestController = createRequestController();
|
||||
const meritDeletionRequestController = createRequestController();
|
||||
const meritDetailRequestController = createRequestController();
|
||||
const meritImageUploadRequestController = createRequestController();
|
||||
const meritTypeRequestController = 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 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()),
|
||||
: Object.values(form).some((value) => String(value).trim()) || mediaReceipts.value.length > 0,
|
||||
);
|
||||
const meritTime = computed(() =>
|
||||
form.meritDate ? `${form.meritDate} ${form.meritClock || "00:00"}:00` : "",
|
||||
@@ -240,11 +277,11 @@ const meritTime = computed(() =>
|
||||
const meritTypeIndex = computed(() =>
|
||||
Math.max(
|
||||
0,
|
||||
meritTypeOptions.findIndex((item) => item.value === form.type),
|
||||
meritTypeOptions.value.findIndex((item) => item.value === form.type),
|
||||
),
|
||||
);
|
||||
const meritTypeLabel = computed(
|
||||
() => meritTypeOptions.find((item) => item.value === form.type)?.label || "",
|
||||
() => meritTypeOptions.value.find((item) => item.value === form.type)?.label || "",
|
||||
);
|
||||
const addCurrencyAmounts = (left, right) => {
|
||||
const [leftWhole, leftFraction] = left.split(".");
|
||||
@@ -285,9 +322,11 @@ const resetForm = () => {
|
||||
meritClock: "",
|
||||
content: "",
|
||||
});
|
||||
mediaReceipts.value = [];
|
||||
editingMerit.value = null;
|
||||
formBaseline.value = "";
|
||||
error.value = "";
|
||||
uploadError.value = "";
|
||||
};
|
||||
const loadMerits = async () => {
|
||||
if (!valid.value) return;
|
||||
@@ -305,8 +344,23 @@ const loadMerits = async () => {
|
||||
listState.value = "error";
|
||||
}
|
||||
};
|
||||
const openCreate = () => {
|
||||
const loadMeritTypes = async () => {
|
||||
meritTypeRequestController.abort();
|
||||
meritTypeOptions.value = await businessDictionaryApi.getBusinessDictionaryOptions(
|
||||
"gen_merit_type",
|
||||
{ requestController: meritTypeRequestController },
|
||||
);
|
||||
};
|
||||
const openCreate = async () => {
|
||||
if (!valid.value) return;
|
||||
if (!meritTypeOptions.value.length) {
|
||||
try {
|
||||
await loadMeritTypes();
|
||||
} catch (cause) {
|
||||
if (!isRequestCancelled(cause)) error.value = getRequestErrorMessage(cause, "功德类型暂时无法读取。");
|
||||
return;
|
||||
}
|
||||
}
|
||||
saveNotice.value = "";
|
||||
resetForm();
|
||||
view.value = "form";
|
||||
@@ -329,7 +383,7 @@ const openEditMerit = async (merit) => {
|
||||
!detail.canEdit ||
|
||||
!detail.donor ||
|
||||
!detail.title ||
|
||||
!meritTypeOptions.some((item) => item.value === detail.type) ||
|
||||
!meritTypeOptions.value.some((item) => item.value === detail.type) ||
|
||||
!detail.amount ||
|
||||
!["0", "1"].includes(detail.status) ||
|
||||
!Number.isSafeInteger(detail.sortOrder)
|
||||
@@ -347,6 +401,10 @@ const openEditMerit = async (merit) => {
|
||||
meritClock: timeParts.clock,
|
||||
content: detail.content,
|
||||
});
|
||||
mediaReceipts.value = detail.mediaFiles.map((file) => ({
|
||||
ossId: String(file.ossId),
|
||||
fileName: file.fileName,
|
||||
}));
|
||||
editingMerit.value = {
|
||||
id: detail.id,
|
||||
sortOrder: detail.sortOrder,
|
||||
@@ -385,6 +443,11 @@ const closeMeritDetail = () => {
|
||||
detailState.value = "idle";
|
||||
detailError.value = "";
|
||||
};
|
||||
const previewMeritImage = (file) => {
|
||||
const urls = detailTarget.value?.mediaFiles?.map((mediaFile) => mediaFile.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";
|
||||
@@ -398,11 +461,33 @@ const selectMeritClock = (event) => {
|
||||
error.value = "";
|
||||
};
|
||||
const selectMeritType = (event) => {
|
||||
form.type = meritTypeOptions[Number(event.detail.value)]?.value || "";
|
||||
form.type = meritTypeOptions.value[Number(event.detail.value)]?.value || "";
|
||||
error.value = "";
|
||||
};
|
||||
const uploadImage = async () => {
|
||||
if (uploading.value || submitting.value) return;
|
||||
uploading.value = true;
|
||||
uploadError.value = "";
|
||||
try {
|
||||
const receipt = await pickAndUploadImage({
|
||||
requestController: meritImageUploadRequestController,
|
||||
});
|
||||
if (!pageActive) return;
|
||||
mediaReceipts.value = [...mediaReceipts.value, receipt];
|
||||
} catch (cause) {
|
||||
if (pageActive && !isImagePickCancelled(cause) && !isRequestCancelled(cause))
|
||||
uploadError.value = getRequestErrorMessage(cause, "图片上传失败,请稍后重试。");
|
||||
} finally {
|
||||
if (pageActive) uploading.value = false;
|
||||
}
|
||||
};
|
||||
const removeImage = (index) => {
|
||||
if (uploading.value || submitting.value) return;
|
||||
mediaReceipts.value = mediaReceipts.value.filter((_, receiptIndex) => receiptIndex !== index);
|
||||
uploadError.value = "";
|
||||
};
|
||||
const saveMeritRecord = async () => {
|
||||
if (submitting.value || !valid.value) return;
|
||||
if (submitting.value || uploading.value || !valid.value) return;
|
||||
const donorName = form.donor.trim();
|
||||
const meritTitle = form.title.trim();
|
||||
const amountText = form.amount.trim();
|
||||
@@ -410,8 +495,8 @@ const saveMeritRecord = async () => {
|
||||
error.value = !donorName ? "请填写捐赠人" : "请填写功德标题";
|
||||
return;
|
||||
}
|
||||
if (amountText && !Number.isFinite(Number(amountText))) {
|
||||
error.value = "金额必须是数字";
|
||||
if (amountText && !/^(?:0|[1-9]\d{0,9})(?:\.\d{1,2})?$/.test(amountText)) {
|
||||
error.value = "金额应为 0 至 9999999999.99,最多保留两位小数";
|
||||
return;
|
||||
}
|
||||
const payload = {
|
||||
@@ -420,6 +505,7 @@ const saveMeritRecord = async () => {
|
||||
meritType: form.type,
|
||||
meritContent: form.content,
|
||||
meritTime: meritTime.value,
|
||||
mediaOssIds: mediaOssIds.value,
|
||||
...(amountText ? { amount: Number(amountText) } : {}),
|
||||
...(editingMerit.value
|
||||
? {
|
||||
@@ -521,7 +607,10 @@ const requestBack = () =>
|
||||
|
||||
onLoad((query) => {
|
||||
genealogyId.value = String(query?.genealogyId || "");
|
||||
if (valid.value) loadMerits();
|
||||
if (valid.value) {
|
||||
void loadMeritTypes().catch(() => {});
|
||||
loadMerits();
|
||||
}
|
||||
else listState.value = "invalid";
|
||||
});
|
||||
onShow(() => {
|
||||
@@ -536,6 +625,8 @@ onUnload(() => {
|
||||
meritSaveRequestController.abort();
|
||||
meritDeletionRequestController.abort();
|
||||
meritDetailRequestController.abort();
|
||||
meritImageUploadRequestController.abort();
|
||||
meritTypeRequestController.abort();
|
||||
confirmation.dispose();
|
||||
});
|
||||
</script>
|
||||
@@ -553,12 +644,14 @@ onUnload(() => {
|
||||
z-index: 1;
|
||||
}
|
||||
.page-content {
|
||||
padding: 18rpx 24rpx 72rpx;
|
||||
flex: 1;
|
||||
padding: 18rpx 24rpx calc(72rpx + env(safe-area-inset-bottom));
|
||||
}
|
||||
.form-card,
|
||||
.state-card,
|
||||
.merit-card {
|
||||
@include adaptive-records-content;
|
||||
background-color: rgba($paper, 0.82);
|
||||
}
|
||||
.form-card {
|
||||
box-sizing: border-box;
|
||||
@@ -600,7 +693,7 @@ onUnload(() => {
|
||||
@include adaptive-records-field;
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
min-height: 76rpx;
|
||||
min-height: 80rpx;
|
||||
padding: 16rpx 22rpx;
|
||||
color: $ink;
|
||||
font-size: clamp(14px, 23rpx, 17px);
|
||||
@@ -614,6 +707,22 @@ onUnload(() => {
|
||||
.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:first-child > text { display: block; }
|
||||
.upload-field > view:first-child > text:first-child { color: $ink; font-size: clamp(14px, 23rpx, 17px); font-weight: 700; }
|
||||
.upload-field > view:first-child > 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(184, 35, 35, .38); 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; }
|
||||
.upload-receipt { display: flex; align-items: center; gap: 12rpx; }
|
||||
.upload-receipt > text { min-width: 0; flex: 1; color: $ink-muted; font-size: clamp(13px, 21rpx, 16px); overflow-wrap: anywhere; }
|
||||
.upload-receipt > button { min-height: 72rpx; margin: 0; padding: 0 16rpx; border: 0; background: transparent; color: $brand-red; font-size: clamp(13px, 20rpx, 16px); }
|
||||
.upload-receipt > button::after { border: 0; }
|
||||
.error {
|
||||
display: block;
|
||||
margin-top: 12rpx;
|
||||
@@ -648,7 +757,7 @@ onUnload(() => {
|
||||
.merit-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16rpx;
|
||||
gap: 20rpx;
|
||||
}
|
||||
.save-notice {
|
||||
display: block;
|
||||
@@ -707,9 +816,11 @@ onUnload(() => {
|
||||
}
|
||||
.merit-card__amount .app-button {
|
||||
width: 140rpx;
|
||||
min-height: 68rpx;
|
||||
min-height: 80rpx;
|
||||
}
|
||||
.detail-content { width: 100%; margin-top: 18rpx; text-align: left; }
|
||||
.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); }
|
||||
.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; }
|
||||
|
||||
+22
-11
@@ -55,7 +55,7 @@
|
||||
class="people-primary-action"
|
||||
type="secondary"
|
||||
block
|
||||
:label="loadingMore ? '正在加载…' : '加载更多人物'"
|
||||
:label="loadingMore ? '正在加载…' : loadMoreError ? '加载失败,重新加载' : '加载更多人物'"
|
||||
@click="loadMore"
|
||||
/>
|
||||
</view>
|
||||
@@ -125,9 +125,10 @@ const keyword = ref("");
|
||||
const total = ref(0);
|
||||
const pageNum = ref(1);
|
||||
const loadingMore = ref(false);
|
||||
const loadMoreError = ref(false);
|
||||
const peopleListRequestController = createRequestController();
|
||||
let loadSequence = 0;
|
||||
const hasValidContext = computed(() => Boolean(genealogyId.value));
|
||||
const hasValidContext = computed(() => /^[1-9]\d*$/.test(genealogyId.value));
|
||||
const hasMore = computed(() => people.value.length < total.value);
|
||||
|
||||
const applySearch = () => {
|
||||
@@ -136,41 +137,48 @@ const applySearch = () => {
|
||||
return;
|
||||
}
|
||||
keyword.value = keywordInput.value.trim();
|
||||
pageNum.value = 1;
|
||||
loadMoreError.value = false;
|
||||
void loadPeople();
|
||||
};
|
||||
const clearSearch = () => {
|
||||
keywordInput.value = "";
|
||||
keyword.value = "";
|
||||
pageNum.value = 1;
|
||||
loadMoreError.value = false;
|
||||
void loadPeople();
|
||||
};
|
||||
const loadPeople = async ({ append = false } = {}) => {
|
||||
if (!hasValidContext.value) return;
|
||||
const activeLoad = ++loadSequence;
|
||||
if (append) loadingMore.value = true;
|
||||
const requestedPage = append ? pageNum.value + 1 : 1;
|
||||
if (append) {
|
||||
loadingMore.value = true;
|
||||
loadMoreError.value = false;
|
||||
}
|
||||
else peopleState.value = "loading";
|
||||
try {
|
||||
const personPage = await lineageApi.getPersonPage(
|
||||
genealogyId.value,
|
||||
{ pageNum: pageNum.value, pageSize: 10, keyword: keyword.value },
|
||||
{ pageNum: requestedPage, pageSize: 10, keyword: keyword.value },
|
||||
{ requestController: peopleListRequestController },
|
||||
);
|
||||
if (activeLoad !== loadSequence) return;
|
||||
people.value = append ? [...people.value, ...personPage.rows] : personPage.rows;
|
||||
pageNum.value = requestedPage;
|
||||
total.value = personPage.total;
|
||||
peopleState.value = people.value.length ? "ready" : "empty";
|
||||
} catch (error) {
|
||||
if (activeLoad !== loadSequence || isRequestCancelled(error)) return;
|
||||
if (!append) people.value = [];
|
||||
peopleState.value = "error";
|
||||
if (append) loadMoreError.value = true;
|
||||
else {
|
||||
people.value = [];
|
||||
peopleState.value = "error";
|
||||
}
|
||||
} finally {
|
||||
if (activeLoad === loadSequence) loadingMore.value = false;
|
||||
}
|
||||
};
|
||||
const loadMore = () => {
|
||||
if (loadingMore.value || !hasMore.value) return;
|
||||
pageNum.value += 1;
|
||||
void loadPeople({ append: true });
|
||||
};
|
||||
const openPerson = (person) =>
|
||||
@@ -188,7 +196,7 @@ const openPerson = (person) =>
|
||||
const handleStateAction = () => {
|
||||
if (peopleState.value === "invalid") return goBack();
|
||||
if (peopleState.value === "error") {
|
||||
pageNum.value = 1;
|
||||
loadMoreError.value = false;
|
||||
return loadPeople();
|
||||
}
|
||||
return goBack();
|
||||
@@ -224,7 +232,7 @@ onUnload(() => {
|
||||
}
|
||||
.people-content {
|
||||
flex: 1;
|
||||
padding: 22rpx 24rpx 100rpx;
|
||||
padding: 22rpx 24rpx calc(100rpx + env(safe-area-inset-bottom));
|
||||
}
|
||||
.people-search {
|
||||
@include adaptive-records-field;
|
||||
@@ -269,6 +277,7 @@ onUnload(() => {
|
||||
min-height: clamp(92px, 190rpx, 108px);
|
||||
align-items: center;
|
||||
margin-top: 12px;
|
||||
background-color: rgba($paper, 0.82);
|
||||
}
|
||||
.person-card:first-child {
|
||||
margin-top: 0;
|
||||
@@ -284,11 +293,13 @@ onUnload(() => {
|
||||
font-family: STKaiti, KaiTi, serif;
|
||||
font-size: clamp(17px, 31rpx, 22px);
|
||||
font-weight: 700;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.person-card__meta {
|
||||
margin-top: 7rpx;
|
||||
color: $ink-muted;
|
||||
font-size: clamp(14px, 23rpx, 17px);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.person-card__hint {
|
||||
margin-top: 8rpx;
|
||||
|
||||
@@ -32,15 +32,15 @@
|
||||
</template>
|
||||
|
||||
<template v-if="personState === 'detail'">
|
||||
<view
|
||||
v-for="item in detailSections"
|
||||
:key="item.title"
|
||||
class="person-archive-card"
|
||||
>
|
||||
<view class="person-archive-card">
|
||||
<view
|
||||
><text>{{ item.title }}</text
|
||||
><text>{{ item.copy || "未填写" }}</text></view
|
||||
v-for="item in detailSections"
|
||||
:key="item.title"
|
||||
class="person-archive-row"
|
||||
>
|
||||
<text>{{ item.title }}</text>
|
||||
<text>{{ item.copy || "未填写" }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="person-related-actions">
|
||||
<AppButton
|
||||
@@ -200,8 +200,8 @@ onLoad((query) => {
|
||||
personId.value = String(query.personId || "");
|
||||
if (
|
||||
query.mode !== "view" ||
|
||||
!genealogyId.value ||
|
||||
!personId.value
|
||||
!/^[1-9]\d*$/.test(genealogyId.value) ||
|
||||
!/^[1-9]\d*$/.test(personId.value)
|
||||
) {
|
||||
personState.value = "error";
|
||||
return;
|
||||
@@ -234,13 +234,14 @@ onUnload(() => {
|
||||
min-height: calc(100vh - 100rpx);
|
||||
}
|
||||
.person-detail-content {
|
||||
padding: 18rpx 24rpx 72rpx;
|
||||
padding: 18rpx 24rpx calc(72rpx + env(safe-area-inset-bottom));
|
||||
}
|
||||
.person-identity-card {
|
||||
@include adaptive-records-person;
|
||||
display: flex;
|
||||
min-height: 190rpx;
|
||||
padding: 30rpx 10%;
|
||||
background-color: rgba($paper, 0.82);
|
||||
}
|
||||
.person-identity-card__copy {
|
||||
display: flex;
|
||||
@@ -269,28 +270,41 @@ onUnload(() => {
|
||||
font-size: clamp(13px, 21rpx, 16px);
|
||||
}
|
||||
.person-archive-card {
|
||||
min-height: 154rpx;
|
||||
margin-top: 14rpx;
|
||||
padding: 32rpx 42rpx;
|
||||
padding: 22rpx 42rpx;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.person-archive-card,
|
||||
.person-state-card {
|
||||
@include adaptive-records-content;
|
||||
background-color: rgba($paper, 0.82);
|
||||
}
|
||||
.person-archive-card text {
|
||||
display: block;
|
||||
.person-archive-row {
|
||||
display: grid;
|
||||
min-height: 68rpx;
|
||||
align-items: start;
|
||||
padding: 15rpx 0;
|
||||
border-bottom: 1rpx solid rgba($gold, 0.24);
|
||||
grid-template-columns: 176rpx minmax(0, 1fr);
|
||||
column-gap: 20rpx;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.person-archive-card text:first-child {
|
||||
.person-archive-row:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
.person-archive-row text {
|
||||
min-width: 0;
|
||||
}
|
||||
.person-archive-row text:first-child {
|
||||
color: $brand-red;
|
||||
font-size: clamp(15px, 24rpx, 18px);
|
||||
font-weight: 700;
|
||||
}
|
||||
.person-archive-card text:last-child {
|
||||
margin-top: 11rpx;
|
||||
.person-archive-row text:last-child {
|
||||
color: $ink;
|
||||
font-size: clamp(15px, 24rpx, 18px);
|
||||
line-height: 1.55;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.person-edit-action {
|
||||
margin-top: 20rpx;
|
||||
@@ -342,5 +356,8 @@ onUnload(() => {
|
||||
.person-identity-card__name {
|
||||
font-size: clamp(19px, 35rpx, 24px);
|
||||
}
|
||||
.person-archive-row {
|
||||
grid-template-columns: 146rpx minmax(0, 1fr);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
<template>
|
||||
<view class="documents-page">
|
||||
<ModulePageBackground module="records" />
|
||||
<view class="page-layer">
|
||||
<PageHeader title="重要证件" custom-back @back="requestBack" />
|
||||
</view>
|
||||
<view class="documents-content page-layer">
|
||||
<view v-if="!valid" class="documents-card">
|
||||
<text>暂时无法打开重要证件</text>
|
||||
<text>未找到家谱信息,请返回后重新进入。</text>
|
||||
</view>
|
||||
<view v-else class="documents-card">
|
||||
<text>家谱证件档案</text>
|
||||
<text>集中查看当前家谱中有权访问的重要证件;新增证件仍从对应人物资料进入。</text>
|
||||
<AppButton block label="查看全部证件" :disabled="documentBusy" @click="openDocuments" />
|
||||
</view>
|
||||
</view>
|
||||
<PersonDocumentDialog
|
||||
ref="documentDialog"
|
||||
:genealogy-id="genealogyId"
|
||||
@busy-change="documentBusy = $event"
|
||||
@transient-change="documentTransientOpen = $event"
|
||||
/>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref } from "vue";
|
||||
import { onBackPress, onLoad, onReady } from "@dcloudio/uni-app";
|
||||
import AppButton from "@/components/AppButton.vue";
|
||||
import ModulePageBackground from "@/components/ModulePageBackground.vue";
|
||||
import PageHeader from "@/components/PageHeader.vue";
|
||||
import PersonDocumentDialog from "@/components/tree/PersonDocumentDialog.vue";
|
||||
import { goBack, handleBackPress } from "@/utils/navigation/gateway.js";
|
||||
|
||||
const genealogyId = ref("");
|
||||
const documentDialog = ref(null);
|
||||
const documentBusy = ref(false);
|
||||
const documentTransientOpen = ref(false);
|
||||
const valid = computed(() => /^[1-9]\d*$/.test(genealogyId.value));
|
||||
|
||||
const openDocuments = () => {
|
||||
if (!valid.value || documentBusy.value) return;
|
||||
documentDialog.value?.open();
|
||||
};
|
||||
const requestBack = () => {
|
||||
if (documentTransientOpen.value) return documentDialog.value?.closeTransient() ?? true;
|
||||
return goBack();
|
||||
};
|
||||
|
||||
onLoad((query) => {
|
||||
genealogyId.value = String(query?.genealogyId || "");
|
||||
});
|
||||
onReady(() => {
|
||||
if (valid.value) openDocuments();
|
||||
});
|
||||
onBackPress((event) => handleBackPress(event, requestBack));
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@import "../../styles/adaptive-frame-profiles.scss";
|
||||
.documents-page {
|
||||
display: flex;
|
||||
min-height: 100vh;
|
||||
flex-direction: column;
|
||||
background: $paper;
|
||||
}
|
||||
.page-layer {
|
||||
z-index: 1;
|
||||
}
|
||||
.documents-content {
|
||||
flex: 1;
|
||||
padding: 24rpx 28rpx calc(72rpx + env(safe-area-inset-bottom));
|
||||
}
|
||||
.documents-card {
|
||||
@include adaptive-records-content;
|
||||
padding: 48rpx 36rpx;
|
||||
text-align: center;
|
||||
}
|
||||
.documents-card text {
|
||||
display: block;
|
||||
color: $ink-muted;
|
||||
font-size: clamp(14px, 23rpx, 17px);
|
||||
line-height: 1.65;
|
||||
}
|
||||
.documents-card text:first-child {
|
||||
color: $ink;
|
||||
font-family: STKaiti, KaiTi, serif;
|
||||
font-size: clamp(19px, 34rpx, 24px);
|
||||
font-weight: 700;
|
||||
}
|
||||
.documents-card text + text,
|
||||
.documents-card .app-button {
|
||||
margin-top: 20rpx;
|
||||
}
|
||||
</style>
|
||||
@@ -351,6 +351,11 @@ const saveRelative = async () => {
|
||||
"上次提交结果暂时无法确认,请先返回贺礼簿检查,避免重复创建。";
|
||||
return;
|
||||
}
|
||||
const giftAmount = form.giftAmount.trim();
|
||||
if (giftAmount && !/^(?:0|[1-9]\d{0,9})(?:\.\d{1,2})?$/.test(giftAmount)) {
|
||||
submitError.value = "礼金金额应为 0 至 9999999999.99,最多保留两位小数";
|
||||
return;
|
||||
}
|
||||
|
||||
isSubmitting.value = true;
|
||||
submitError.value = "";
|
||||
@@ -423,13 +428,15 @@ onUnload(() => {
|
||||
z-index: 1;
|
||||
}
|
||||
.page-content {
|
||||
padding: 18rpx 24rpx 72rpx;
|
||||
flex: 1;
|
||||
padding: 18rpx 24rpx calc(72rpx + env(safe-area-inset-bottom));
|
||||
}
|
||||
.form-card,
|
||||
.state-card {
|
||||
box-sizing: border-box;
|
||||
padding: 46rpx;
|
||||
@include adaptive-records-content;
|
||||
background-color: rgba($paper, 0.82);
|
||||
}
|
||||
.form-card > text:first-child,
|
||||
.state-card > text:first-child {
|
||||
@@ -485,9 +492,10 @@ onUnload(() => {
|
||||
}
|
||||
.field-row--picker picker {
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
}
|
||||
.field-row--picker picker > view {
|
||||
min-height: 48rpx;
|
||||
min-height: 80rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
@@ -512,7 +520,7 @@ onUnload(() => {
|
||||
}
|
||||
.upload-button {
|
||||
justify-self: start;
|
||||
min-height: 60rpx;
|
||||
min-height: 80rpx;
|
||||
margin: 0;
|
||||
padding: 0 20rpx;
|
||||
border: 1rpx solid rgba(159, 23, 15, 0.42);
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
title="贺礼簿"
|
||||
:action="valid ? '新建' : ''"
|
||||
custom-back
|
||||
@back="returnToFamily"
|
||||
@back="requestBack"
|
||||
@action="createRelative"
|
||||
/></view>
|
||||
<view class="page-content">
|
||||
@@ -32,12 +32,20 @@
|
||||
<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)">
|
||||
<image
|
||||
v-if="item.mediaFiles?.[0]?.accessUrl"
|
||||
class="record-card__cover"
|
||||
:src="item.mediaFiles[0].accessUrl"
|
||||
mode="aspectFill"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<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.createTime">创建于 {{ formatMinuteTimestamp(item.createTime) }}</text
|
||||
><text v-if="item.content">{{ item.content }}</text></view
|
||||
>
|
||||
<view class="record-card__amount">
|
||||
@@ -76,6 +84,7 @@
|
||||
<text>关系:{{ detailTarget?.relation || "未填写" }}</text>
|
||||
<text>事项:{{ detailTarget?.event || "未填写" }}</text>
|
||||
<text>时间:{{ detailTarget?.time || "未填写" }}</text>
|
||||
<text v-if="detailTarget?.createTime">创建时间:{{ formatMinuteTimestamp(detailTarget.createTime) }}</text>
|
||||
<text>金额:{{ detailTarget?.amount ? `¥${detailTarget.amount}` : "未填写" }}</text>
|
||||
<text class="detail-content__body">{{ detailTarget?.content || "未填写记录内容" }}</text>
|
||||
<view v-if="detailTarget?.mediaFiles?.length" class="detail-media">
|
||||
@@ -86,9 +95,9 @@
|
||||
</AppDialog>
|
||||
<AppDialog
|
||||
:visible="deleteConfirmationVisible"
|
||||
title="删除这条亲友往来?"
|
||||
message="删除后无法恢复,请确认当前内容不再需要。"
|
||||
confirm-text="确认删除"
|
||||
title="将这条亲友往来移至回收站?"
|
||||
message="移入回收站后不再展示,家谱管理员可在保留期内恢复。"
|
||||
confirm-text="移至回收站"
|
||||
cancel-text="保留记录"
|
||||
show-cancel
|
||||
:close-on-mask="false"
|
||||
@@ -100,7 +109,7 @@
|
||||
|
||||
<script setup>
|
||||
import { computed, ref } from "vue";
|
||||
import { onLoad, onShow, onUnload } from "@dcloudio/uni-app";
|
||||
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";
|
||||
@@ -112,7 +121,8 @@ import {
|
||||
} 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";
|
||||
import { formatMinuteTimestamp } from "@/utils/display-time.js";
|
||||
import { goBack, handleBackPress, openPage, returnTo } from "@/utils/navigation/gateway.js";
|
||||
|
||||
const genealogyId = ref("");
|
||||
const records = ref([]);
|
||||
@@ -200,6 +210,18 @@ const closeDeleteConfirmation = () => {
|
||||
deleteConfirmationVisible.value = false;
|
||||
deleteTarget.value = null;
|
||||
};
|
||||
const requestBack = () => {
|
||||
if (deleting.value || detailState.value === "loading") return true;
|
||||
if (deleteConfirmationVisible.value) {
|
||||
closeDeleteConfirmation();
|
||||
return true;
|
||||
}
|
||||
if (detailTarget.value) {
|
||||
closeRecordDetail();
|
||||
return true;
|
||||
}
|
||||
return returnToFamily();
|
||||
};
|
||||
const deleteRecord = async () => {
|
||||
const record = deleteTarget.value;
|
||||
if (!record?.canDelete || deleting.value) return;
|
||||
@@ -234,6 +256,7 @@ onUnload(() => {
|
||||
relativeRecordDeleteController.abort();
|
||||
relativeRecordDetailController.abort();
|
||||
});
|
||||
onBackPress((event) => handleBackPress(event, requestBack));
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@@ -249,11 +272,13 @@ onUnload(() => {
|
||||
z-index: 1;
|
||||
}
|
||||
.page-content {
|
||||
padding: 18rpx 24rpx 72rpx;
|
||||
flex: 1;
|
||||
padding: 18rpx 24rpx calc(72rpx + env(safe-area-inset-bottom));
|
||||
}
|
||||
.state-card,
|
||||
.record-card {
|
||||
@include adaptive-records-content;
|
||||
background-color: rgba($paper, 0.82);
|
||||
}
|
||||
.state-card {
|
||||
display: flex;
|
||||
@@ -273,7 +298,7 @@ onUnload(() => {
|
||||
.record-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16rpx;
|
||||
gap: 20rpx;
|
||||
}
|
||||
.record-card {
|
||||
display: flex;
|
||||
@@ -310,6 +335,12 @@ onUnload(() => {
|
||||
align-items: center;
|
||||
gap: 10rpx;
|
||||
}
|
||||
.record-card__cover {
|
||||
width: 100%;
|
||||
height: 240rpx;
|
||||
border-radius: 10rpx;
|
||||
background: rgba(128, 89, 49, 0.12);
|
||||
}
|
||||
.record-card__amount > text {
|
||||
margin-right: auto;
|
||||
color: $brand-red;
|
||||
@@ -317,7 +348,7 @@ onUnload(() => {
|
||||
}
|
||||
.record-card__amount .app-button {
|
||||
width: 140rpx;
|
||||
min-height: 68rpx;
|
||||
min-height: 80rpx;
|
||||
}
|
||||
.delete-error {
|
||||
display: block;
|
||||
|
||||
+270
-17
@@ -134,11 +134,20 @@
|
||||
}}</text>
|
||||
|
||||
<view class="form-field">
|
||||
<text>别名</text>
|
||||
<text>表字</text>
|
||||
<input
|
||||
v-model="addForm.courtesyName"
|
||||
maxlength="40"
|
||||
placeholder="如族谱有记载可填写"
|
||||
placeholder-class="form-placeholder"
|
||||
/>
|
||||
</view>
|
||||
<view class="form-field">
|
||||
<text>别号</text>
|
||||
<input
|
||||
v-model="addForm.aliasName"
|
||||
maxlength="40"
|
||||
placeholder="按家谱记载填写"
|
||||
placeholder="别号、昵称或曾用名"
|
||||
placeholder-class="form-placeholder"
|
||||
/>
|
||||
</view>
|
||||
@@ -193,6 +202,63 @@
|
||||
placeholder-class="form-placeholder"
|
||||
/>
|
||||
</view>
|
||||
<picker
|
||||
:range="zodiacOptions.map((item) => item.label)"
|
||||
:value="optionIndex(zodiacOptions, addForm.zodiac)"
|
||||
@change="selectOption('zodiac', zodiacOptions, $event)"
|
||||
>
|
||||
<view class="form-field form-field--picker">
|
||||
<text>生肖</text><text>{{ optionLabel(zodiacOptions, addForm.zodiac) || "请选择" }}</text>
|
||||
</view>
|
||||
</picker>
|
||||
<view class="form-field">
|
||||
<text>现居地</text>
|
||||
<input
|
||||
v-model="addForm.currentAddress"
|
||||
maxlength="120"
|
||||
placeholder="填写当前常住地区"
|
||||
placeholder-class="form-placeholder"
|
||||
/>
|
||||
</view>
|
||||
<view class="form-field">
|
||||
<text>联系电话</text>
|
||||
<input
|
||||
v-model="addForm.mobile"
|
||||
maxlength="30"
|
||||
placeholder="仅授权成员可见"
|
||||
placeholder-class="form-placeholder"
|
||||
/>
|
||||
</view>
|
||||
<view class="form-field">
|
||||
<text>电子邮箱</text>
|
||||
<input
|
||||
v-model="addForm.email"
|
||||
maxlength="254"
|
||||
placeholder="仅授权成员可见"
|
||||
placeholder-class="form-placeholder"
|
||||
/>
|
||||
</view>
|
||||
<text v-if="fieldErrors.email" class="field-error">{{
|
||||
fieldErrors.email
|
||||
}}</text>
|
||||
<picker
|
||||
:range="educationOptions.map((item) => item.label)"
|
||||
:value="optionIndex(educationOptions, addForm.education)"
|
||||
@change="selectOption('education', educationOptions, $event)"
|
||||
>
|
||||
<view class="form-field form-field--picker">
|
||||
<text>学历分类</text><text>{{ optionLabel(educationOptions, addForm.education) || "请选择" }}</text>
|
||||
</view>
|
||||
</picker>
|
||||
<view class="form-field">
|
||||
<text>职业</text>
|
||||
<input
|
||||
v-model="addForm.occupation"
|
||||
maxlength="80"
|
||||
placeholder="填写主要职业"
|
||||
placeholder-class="form-placeholder"
|
||||
/>
|
||||
</view>
|
||||
|
||||
<picker
|
||||
v-if="isDeceased"
|
||||
@@ -218,6 +284,18 @@
|
||||
}}</text>
|
||||
</view>
|
||||
</picker>
|
||||
<view v-if="isDeceased" class="form-field">
|
||||
<text>享年</text>
|
||||
<input
|
||||
v-model="addForm.deathAge"
|
||||
type="number"
|
||||
placeholder="0 至 200 的整数"
|
||||
placeholder-class="form-placeholder"
|
||||
/>
|
||||
</view>
|
||||
<text v-if="fieldErrors.deathAge" class="field-error">{{
|
||||
fieldErrors.deathAge
|
||||
}}</text>
|
||||
<view v-if="isDeceased" class="form-field">
|
||||
<text>逝世地</text>
|
||||
<input
|
||||
@@ -227,6 +305,50 @@
|
||||
placeholder-class="form-placeholder"
|
||||
/>
|
||||
</view>
|
||||
<picker
|
||||
v-if="isDeceased"
|
||||
:range="deathExpressionOptions.map((item) => item.label)"
|
||||
:value="optionIndex(deathExpressionOptions, addForm.deathType)"
|
||||
@change="selectOption('deathType', deathExpressionOptions, $event)"
|
||||
>
|
||||
<view class="form-field form-field--picker">
|
||||
<text>逝世表述</text><text>{{ optionLabel(deathExpressionOptions, addForm.deathType) || "请选择" }}</text>
|
||||
</view>
|
||||
</picker>
|
||||
<view v-if="isDeceased" class="form-field form-field--summary">
|
||||
<text>遗传病史</text>
|
||||
<textarea
|
||||
v-model="addForm.hereditaryMedicalHistory"
|
||||
auto-height
|
||||
maxlength="500"
|
||||
placeholder="敏感信息,无明确依据可不填写"
|
||||
placeholder-class="form-placeholder"
|
||||
/>
|
||||
</view>
|
||||
<text v-if="isDeceased" class="form-note"
|
||||
>遗传病史属于敏感健康信息,后端必须按权限返回并记录访问审计。</text
|
||||
>
|
||||
<picker
|
||||
v-if="relationVariantOptions.length"
|
||||
:range="relationVariantOptions.map((item) => item.label)"
|
||||
:value="optionIndex(relationVariantOptions, addForm.relationVariantCode)"
|
||||
@change="selectOption('relationVariantCode', relationVariantOptions, $event)"
|
||||
>
|
||||
<view class="form-field form-field--picker">
|
||||
<text>关系称谓</text><text>{{ optionLabel(relationVariantOptions, addForm.relationVariantCode) || "请选择" }}</text>
|
||||
</view>
|
||||
</picker>
|
||||
<picker
|
||||
v-if="isDeceased"
|
||||
mode="date"
|
||||
:value="addForm.burialDate"
|
||||
@change="selectBurialDate"
|
||||
>
|
||||
<view class="form-field form-field--picker">
|
||||
<text>安葬日期</text
|
||||
><text>{{ addForm.burialDate || "请选择" }}</text>
|
||||
</view>
|
||||
</picker>
|
||||
<view v-if="isDeceased" class="form-field">
|
||||
<text>安葬地</text>
|
||||
<input
|
||||
@@ -236,6 +358,9 @@
|
||||
placeholder-class="form-placeholder"
|
||||
/>
|
||||
</view>
|
||||
<text v-if="fieldErrors.dates" class="field-error">{{
|
||||
fieldErrors.dates
|
||||
}}</text>
|
||||
<picker
|
||||
:range="personStatusOptions.map((item) => item.label)"
|
||||
:value="optionIndex(personStatusOptions, addForm.personStatus)"
|
||||
@@ -329,6 +454,7 @@ import {
|
||||
isRequestCancelled
|
||||
} from "@/services/api/request-controller.js";
|
||||
import { genealogyMemberApi } from "@/services/api/genealogy-member-service.js";
|
||||
import { businessDictionaryApi } from "@/services/api/business-dictionary-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";
|
||||
@@ -366,6 +492,14 @@ const memberDetailRequestController = createRequestController();
|
||||
const memberOptionsRequestController = createRequestController();
|
||||
const avatarUploadRequestController = createRequestController();
|
||||
const memberCreationRequestController = createRequestController();
|
||||
const sensitiveProfileRequestController = createRequestController();
|
||||
const dictionaryRequestControllers = {
|
||||
zodiac: createRequestController(),
|
||||
education: createRequestController(),
|
||||
deathExpression: createRequestController(),
|
||||
parentVariant: createRequestController(),
|
||||
spouseVariant: createRequestController(),
|
||||
};
|
||||
const memberCreationGuard = createNonIdempotentWriteGuard();
|
||||
let pageActive = true;
|
||||
let loadSequence = 0;
|
||||
@@ -387,6 +521,7 @@ const addForm = reactive({
|
||||
appUserId: "",
|
||||
name: "",
|
||||
relation: "",
|
||||
courtesyName: "",
|
||||
aliasName: "",
|
||||
sex: "",
|
||||
generation: "",
|
||||
@@ -395,18 +530,41 @@ const addForm = reactive({
|
||||
birthDate: "",
|
||||
birthLunar: "",
|
||||
birthPlace: "",
|
||||
zodiac: "",
|
||||
currentAddress: "",
|
||||
mobile: "",
|
||||
email: "",
|
||||
education: "",
|
||||
occupation: "",
|
||||
deathDate: "",
|
||||
deathLunar: "",
|
||||
deathAge: "",
|
||||
deathPlace: "",
|
||||
deathType: "",
|
||||
hereditaryMedicalHistory: "",
|
||||
relationVariantCode: "",
|
||||
burialDate: "",
|
||||
burialPlace: "",
|
||||
personStatus: "",
|
||||
biography: "",
|
||||
remark: "",
|
||||
sortOrder: "",
|
||||
});
|
||||
const fieldErrors = reactive({ name: "", relation: "", bindingMode: "" });
|
||||
const fieldErrors = reactive({
|
||||
name: "",
|
||||
relation: "",
|
||||
bindingMode: "",
|
||||
email: "",
|
||||
dates: "",
|
||||
deathAge: "",
|
||||
});
|
||||
const memberOptionsState = ref("loading");
|
||||
const memberOptions = ref([]);
|
||||
const zodiacOptions = ref([]);
|
||||
const educationOptions = ref([]);
|
||||
const deathExpressionOptions = ref([]);
|
||||
const parentRelationVariantOptions = ref([]);
|
||||
const spouseRelationVariantOptions = ref([]);
|
||||
|
||||
const isFirstMember = computed(() => mode.value === "first");
|
||||
const isDeceased = computed(() => addForm.personStatus === "1");
|
||||
@@ -422,12 +580,27 @@ const relationOptions = computed(() => {
|
||||
const relationLabel = computed(
|
||||
() => activeRelationIntent.value?.label || addForm.relation || "亲属关系",
|
||||
);
|
||||
const selectedRelationType = computed(() =>
|
||||
isFirstMember.value
|
||||
? ""
|
||||
: activeRelationIntent.value
|
||||
? relationType.value
|
||||
: genericRelationTypes[relationOptions.value.indexOf(addForm.relation)] || "",
|
||||
);
|
||||
const relationVariantOptions = computed(() => {
|
||||
if ([memberRelationTypes.FATHER, memberRelationTypes.MOTHER].includes(selectedRelationType.value)) {
|
||||
return parentRelationVariantOptions.value;
|
||||
}
|
||||
return selectedRelationType.value === memberRelationTypes.SPOUSE
|
||||
? spouseRelationVariantOptions.value
|
||||
: [];
|
||||
});
|
||||
const memberOptionLabels = computed(() =>
|
||||
memberOptions.value.map((item) => item.label),
|
||||
);
|
||||
const hasValidContext = computed(
|
||||
() =>
|
||||
Boolean(genealogyId.value) &&
|
||||
/^[1-9]\d*$/.test(genealogyId.value) &&
|
||||
(isFirstMember.value ? !personId.value : Boolean(currentMember.value)),
|
||||
);
|
||||
const relationIndex = computed(() =>
|
||||
@@ -532,14 +705,38 @@ const loadMemberOptions = async () => {
|
||||
memberOptionsState.value = "error";
|
||||
}
|
||||
};
|
||||
const loadBusinessOptions = async () => {
|
||||
try {
|
||||
const [zodiacRows, educationRows, deathRows, parentRows, spouseRows] = await Promise.all([
|
||||
businessDictionaryApi.getBusinessDictionaryOptions("gen_zodiac", { requestController: dictionaryRequestControllers.zodiac }),
|
||||
businessDictionaryApi.getBusinessDictionaryOptions("gen_education_type", { requestController: dictionaryRequestControllers.education }),
|
||||
businessDictionaryApi.getBusinessDictionaryOptions("gen_death_expression", { requestController: dictionaryRequestControllers.deathExpression }),
|
||||
businessDictionaryApi.getBusinessDictionaryOptions("gen_parent_relationship_variant", { requestController: dictionaryRequestControllers.parentVariant }),
|
||||
businessDictionaryApi.getBusinessDictionaryOptions("gen_spouse_relationship_variant", { requestController: dictionaryRequestControllers.spouseVariant }),
|
||||
]);
|
||||
if (!pageActive) return;
|
||||
zodiacOptions.value = zodiacRows;
|
||||
educationOptions.value = educationRows;
|
||||
deathExpressionOptions.value = deathRows;
|
||||
parentRelationVariantOptions.value = parentRows;
|
||||
spouseRelationVariantOptions.value = spouseRows;
|
||||
} catch (error) {
|
||||
if (!pageActive || isRequestCancelled(error)) return;
|
||||
zodiacOptions.value = [];
|
||||
educationOptions.value = [];
|
||||
deathExpressionOptions.value = [];
|
||||
parentRelationVariantOptions.value = [];
|
||||
spouseRelationVariantOptions.value = [];
|
||||
}
|
||||
};
|
||||
onLoad((query) => {
|
||||
genealogyId.value = String(query.genealogyId || "");
|
||||
personId.value = String(query.personId || "");
|
||||
mode.value = query.mode === "first" ? "first" : "relative";
|
||||
relationType.value = String(query.relationType || "");
|
||||
if (
|
||||
!genealogyId.value ||
|
||||
(!isFirstMember.value && !personId.value)
|
||||
!/^[1-9]\d*$/.test(genealogyId.value) ||
|
||||
(!isFirstMember.value && !/^[1-9]\d*$/.test(personId.value))
|
||||
) {
|
||||
addState.value = "error";
|
||||
errorMessage.value = "这个页面已经过期,请从世系树重新进入。";
|
||||
@@ -554,6 +751,7 @@ onLoad((query) => {
|
||||
addForm.relation = activeRelationIntent.value.label;
|
||||
}
|
||||
void loadMemberOptions();
|
||||
void loadBusinessOptions();
|
||||
if (isFirstMember.value) {
|
||||
addState.value = "form";
|
||||
return;
|
||||
@@ -567,6 +765,8 @@ onUnload(() => {
|
||||
memberOptionsRequestController.abort();
|
||||
avatarUploadRequestController.abort();
|
||||
memberCreationRequestController.abort();
|
||||
sensitiveProfileRequestController.abort();
|
||||
Object.values(dictionaryRequestControllers).forEach((controller) => controller.abort());
|
||||
discardConfirmation.dispose();
|
||||
});
|
||||
|
||||
@@ -587,6 +787,7 @@ const clearError = (field) => {
|
||||
};
|
||||
const selectRelation = (event) => {
|
||||
addForm.relation = relationOptions.value[Number(event.detail.value)] || "";
|
||||
addForm.relationVariantCode = "";
|
||||
clearError("relation");
|
||||
};
|
||||
const optionIndex = findMemberOptionIndex;
|
||||
@@ -640,6 +841,9 @@ const selectBirthDate = (event) => {
|
||||
const selectDeathDate = (event) => {
|
||||
addForm.deathDate = event.detail.value || "";
|
||||
};
|
||||
const selectBurialDate = (event) => {
|
||||
addForm.burialDate = event.detail.value || "";
|
||||
};
|
||||
const validateAddForm = () => {
|
||||
fieldErrors.name = addForm.name.trim() ? "" : "请填写成员姓名";
|
||||
fieldErrors.relation =
|
||||
@@ -650,7 +854,22 @@ const validateAddForm = () => {
|
||||
? "可绑定成员暂不可用,请稍后重试"
|
||||
: "请选择可绑定成员"
|
||||
: "";
|
||||
return !fieldErrors.name && !fieldErrors.relation && !fieldErrors.bindingMode;
|
||||
fieldErrors.email =
|
||||
addForm.email.trim() && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(addForm.email.trim())
|
||||
? "请填写有效的电子邮箱"
|
||||
: "";
|
||||
fieldErrors.dates =
|
||||
addForm.birthDate && addForm.deathDate && addForm.deathDate < addForm.birthDate
|
||||
? "逝世日期不能早于出生日期"
|
||||
: addForm.deathDate && addForm.burialDate && addForm.burialDate < addForm.deathDate
|
||||
? "安葬日期不能早于逝世日期"
|
||||
: "";
|
||||
const deathAge = addForm.deathAge === "" ? null : Number(addForm.deathAge);
|
||||
fieldErrors.deathAge =
|
||||
deathAge !== null && (!Number.isSafeInteger(deathAge) || deathAge < 0 || deathAge > 200)
|
||||
? "享年必须是 0 至 200 的整数"
|
||||
: "";
|
||||
return !Object.values(fieldErrors).some(Boolean);
|
||||
};
|
||||
const submitAdd = async () => {
|
||||
if (isSubmitting.value || isAvatarUploading.value || !validateAddForm())
|
||||
@@ -661,6 +880,7 @@ const submitAdd = async () => {
|
||||
? { appUserId: addForm.appUserId }
|
||||
: {}),
|
||||
name: addForm.name,
|
||||
courtesyName: addForm.courtesyName,
|
||||
aliasName: addForm.aliasName,
|
||||
sex: addForm.sex,
|
||||
generationName: addForm.generationName,
|
||||
@@ -668,10 +888,23 @@ const submitAdd = async () => {
|
||||
birthDate: addForm.birthDate,
|
||||
birthLunar: addForm.birthLunar,
|
||||
birthPlace: addForm.birthPlace,
|
||||
deathDate: addForm.deathDate,
|
||||
deathLunar: addForm.deathLunar,
|
||||
deathPlace: addForm.deathPlace,
|
||||
burialPlace: addForm.burialPlace,
|
||||
zodiacCode: addForm.zodiac,
|
||||
currentAddress: addForm.currentAddress,
|
||||
mobile: addForm.mobile,
|
||||
email: addForm.email,
|
||||
educationCode: addForm.education,
|
||||
occupation: addForm.occupation,
|
||||
...(isDeceased.value
|
||||
? {
|
||||
deathDate: addForm.deathDate,
|
||||
deathLunar: addForm.deathLunar,
|
||||
deathAge: addForm.deathAge,
|
||||
deathPlace: addForm.deathPlace,
|
||||
deathExpressionCode: addForm.deathType,
|
||||
burialDate: addForm.burialDate,
|
||||
burialPlace: addForm.burialPlace,
|
||||
}
|
||||
: {}),
|
||||
personStatus: addForm.personStatus,
|
||||
biography: addForm.biography,
|
||||
remark: addForm.remark,
|
||||
@@ -685,6 +918,9 @@ const submitAdd = async () => {
|
||||
...(relationType.value === memberRelationTypes.SPOUSE
|
||||
? { relationName: relationLabel.value }
|
||||
: {}),
|
||||
...(addForm.relationVariantCode
|
||||
? { relationVariantCode: addForm.relationVariantCode }
|
||||
: {}),
|
||||
}),
|
||||
};
|
||||
const submittedRelationType = isFirstMember.value
|
||||
@@ -708,12 +944,13 @@ const submitAdd = async () => {
|
||||
|
||||
isSubmitting.value = true;
|
||||
try {
|
||||
let createdPerson;
|
||||
if (isFirstMember.value) {
|
||||
await lineageApi.createPerson(genealogyId.value, payload, {
|
||||
createdPerson = await lineageApi.createPerson(genealogyId.value, payload, {
|
||||
requestController: memberCreationRequestController,
|
||||
});
|
||||
} else {
|
||||
await lineageApi.createRelatedPerson(
|
||||
createdPerson = await lineageApi.createRelatedPerson(
|
||||
genealogyId.value,
|
||||
personId.value,
|
||||
submittedRelationType,
|
||||
@@ -723,6 +960,19 @@ const submitAdd = async () => {
|
||||
}
|
||||
if (!pageActive) return;
|
||||
memberCreationCommitted.value = true;
|
||||
const sensitiveHistory = addForm.hereditaryMedicalHistory.trim();
|
||||
if (sensitiveHistory) {
|
||||
const createdPersonId = String(createdPerson?.personId || "");
|
||||
if (!/^[1-9]\d*$/.test(createdPersonId)) {
|
||||
throw new Error("成员已经保存,但响应缺少人物标识,敏感健康资料尚未保存。请返回成员档案补充。");
|
||||
}
|
||||
await lineageApi.saveSensitiveProfile(
|
||||
genealogyId.value,
|
||||
createdPersonId,
|
||||
sensitiveHistory,
|
||||
{ requestController: sensitiveProfileRequestController },
|
||||
);
|
||||
}
|
||||
await returnTo("T01", { genealogyId: genealogyId.value });
|
||||
} catch (error) {
|
||||
if (!pageActive) return;
|
||||
@@ -774,7 +1024,7 @@ const returnToTree = async () => {
|
||||
@include adaptive-tree-panel;
|
||||
z-index: 2;
|
||||
width: calc(100% - 32rpx);
|
||||
margin: 18rpx auto 28rpx;
|
||||
margin: 18rpx auto calc(28rpx + env(safe-area-inset-bottom));
|
||||
padding: 7.5% 8%;
|
||||
}
|
||||
.form-eyebrow {
|
||||
@@ -837,9 +1087,12 @@ const returnToTree = async () => {
|
||||
}
|
||||
.form-field textarea {
|
||||
width: auto;
|
||||
min-height: 54rpx;
|
||||
min-height: var(--app-touch-min);
|
||||
text-align: left;
|
||||
}
|
||||
.form-field input {
|
||||
min-height: var(--app-touch-min);
|
||||
}
|
||||
.form-field__hint,
|
||||
.upload-receipt {
|
||||
color: $ink-muted;
|
||||
@@ -853,7 +1106,7 @@ const returnToTree = async () => {
|
||||
gap: 6rpx;
|
||||
}
|
||||
.upload-button {
|
||||
min-height: 54rpx;
|
||||
min-height: var(--app-touch-min);
|
||||
margin: 0;
|
||||
padding: 0 16rpx;
|
||||
border: 1rpx solid rgba(159, 23, 15, 0.42);
|
||||
@@ -882,7 +1135,7 @@ const returnToTree = async () => {
|
||||
.form-action {
|
||||
display: grid;
|
||||
width: 100%;
|
||||
min-height: 76rpx;
|
||||
min-height: var(--app-touch-min);
|
||||
margin-top: 18rpx;
|
||||
}
|
||||
.form-action image,
|
||||
|
||||
+273
-12
@@ -115,10 +115,20 @@
|
||||
}}</text>
|
||||
|
||||
<view class="form-field">
|
||||
<text>别名</text>
|
||||
<text>表字</text>
|
||||
<input
|
||||
v-model="editForm.courtesyName"
|
||||
maxlength="40"
|
||||
placeholder="如族谱有记载可填写"
|
||||
placeholder-class="form-placeholder"
|
||||
/>
|
||||
</view>
|
||||
<view class="form-field">
|
||||
<text>别号</text>
|
||||
<input
|
||||
v-model="editForm.aliasName"
|
||||
placeholder="别名或曾用名"
|
||||
maxlength="40"
|
||||
placeholder="别号、昵称或曾用名"
|
||||
placeholder-class="form-placeholder"
|
||||
/>
|
||||
</view>
|
||||
@@ -190,6 +200,63 @@
|
||||
placeholder-class="form-placeholder"
|
||||
/>
|
||||
</view>
|
||||
<picker
|
||||
:range="zodiacOptions.map((item) => item.label)"
|
||||
:value="optionIndex(zodiacOptions, editForm.zodiac)"
|
||||
@change="selectOption('zodiac', zodiacOptions, $event)"
|
||||
>
|
||||
<view class="form-field form-field--picker">
|
||||
<text>生肖</text><text>{{ optionLabel(zodiacOptions, editForm.zodiac) || "请选择" }}</text>
|
||||
</view>
|
||||
</picker>
|
||||
<view class="form-field">
|
||||
<text>现居地</text>
|
||||
<input
|
||||
v-model="editForm.currentAddress"
|
||||
maxlength="120"
|
||||
placeholder="填写当前常住地区"
|
||||
placeholder-class="form-placeholder"
|
||||
/>
|
||||
</view>
|
||||
<view class="form-field">
|
||||
<text>联系电话</text>
|
||||
<input
|
||||
v-model="editForm.mobile"
|
||||
maxlength="30"
|
||||
placeholder="仅授权成员可见"
|
||||
placeholder-class="form-placeholder"
|
||||
/>
|
||||
</view>
|
||||
<view class="form-field">
|
||||
<text>电子邮箱</text>
|
||||
<input
|
||||
v-model="editForm.email"
|
||||
maxlength="254"
|
||||
placeholder="仅授权成员可见"
|
||||
placeholder-class="form-placeholder"
|
||||
/>
|
||||
</view>
|
||||
<text v-if="fieldErrors.email" class="field-error">{{
|
||||
fieldErrors.email
|
||||
}}</text>
|
||||
<picker
|
||||
:range="educationOptions.map((item) => item.label)"
|
||||
:value="optionIndex(educationOptions, editForm.education)"
|
||||
@change="selectOption('education', educationOptions, $event)"
|
||||
>
|
||||
<view class="form-field form-field--picker">
|
||||
<text>学历分类</text><text>{{ optionLabel(educationOptions, editForm.education) || "请选择" }}</text>
|
||||
</view>
|
||||
</picker>
|
||||
<view class="form-field">
|
||||
<text>职业</text>
|
||||
<input
|
||||
v-model="editForm.occupation"
|
||||
maxlength="80"
|
||||
placeholder="填写主要职业"
|
||||
placeholder-class="form-placeholder"
|
||||
/>
|
||||
</view>
|
||||
<picker
|
||||
v-if="isDeceased"
|
||||
mode="date"
|
||||
@@ -214,6 +281,18 @@
|
||||
}}</text>
|
||||
</view>
|
||||
</picker>
|
||||
<view v-if="isDeceased" class="form-field">
|
||||
<text>享年</text>
|
||||
<input
|
||||
v-model="editForm.deathAge"
|
||||
type="number"
|
||||
placeholder="0 至 200 的整数"
|
||||
placeholder-class="form-placeholder"
|
||||
/>
|
||||
</view>
|
||||
<text v-if="fieldErrors.deathAge" class="field-error">{{
|
||||
fieldErrors.deathAge
|
||||
}}</text>
|
||||
<view v-if="isDeceased" class="form-field">
|
||||
<text>逝世地</text>
|
||||
<input
|
||||
@@ -222,6 +301,45 @@
|
||||
placeholder-class="form-placeholder"
|
||||
/>
|
||||
</view>
|
||||
<picker
|
||||
v-if="isDeceased"
|
||||
:range="deathExpressionOptions.map((item) => item.label)"
|
||||
:value="optionIndex(deathExpressionOptions, editForm.deathType)"
|
||||
@change="selectOption('deathType', deathExpressionOptions, $event)"
|
||||
>
|
||||
<view class="form-field form-field--picker">
|
||||
<text>逝世表述</text><text>{{ optionLabel(deathExpressionOptions, editForm.deathType) || "请选择" }}</text>
|
||||
</view>
|
||||
</picker>
|
||||
<view
|
||||
v-if="isDeceased && originalMember.canManageSensitiveMedicalHistory"
|
||||
class="form-field form-field--summary"
|
||||
>
|
||||
<text>遗传病史</text>
|
||||
<textarea
|
||||
v-model="editForm.hereditaryMedicalHistory"
|
||||
auto-height
|
||||
maxlength="500"
|
||||
placeholder="敏感信息,无明确依据可不填写"
|
||||
placeholder-class="form-placeholder"
|
||||
/>
|
||||
</view>
|
||||
<text
|
||||
v-if="isDeceased && originalMember.canManageSensitiveMedicalHistory"
|
||||
class="form-note"
|
||||
>遗传病史属于敏感健康信息,仅在已授权时读取和保存。</text
|
||||
>
|
||||
<picker
|
||||
v-if="isDeceased"
|
||||
mode="date"
|
||||
:value="editForm.burialDate"
|
||||
@change="selectDate('burialDate', $event)"
|
||||
>
|
||||
<view class="form-field form-field--picker"
|
||||
><text>安葬日期</text
|
||||
><text>{{ editForm.burialDate || "未填写" }}</text></view
|
||||
>
|
||||
</picker>
|
||||
<view v-if="isDeceased" class="form-field">
|
||||
<text>安葬地</text>
|
||||
<input
|
||||
@@ -328,6 +446,7 @@ import {
|
||||
isRequestCancelled
|
||||
} from "@/services/api/request-controller.js";
|
||||
import { genealogyMemberApi } from "@/services/api/genealogy-member-service.js";
|
||||
import { businessDictionaryApi } from "@/services/api/business-dictionary-service.js";
|
||||
import { lineageApi } from "@/services/api/lineage-service.js";
|
||||
import { getRequestErrorMessage } from "@/services/api/request-error-message.js";
|
||||
import {
|
||||
@@ -364,6 +483,12 @@ const personOptionsRequestController = createRequestController();
|
||||
const memberOptionsRequestController = createRequestController();
|
||||
const avatarUploadRequestController = createRequestController();
|
||||
const memberUpdateRequestController = createRequestController();
|
||||
const sensitiveProfileRequestController = createRequestController();
|
||||
const dictionaryRequestControllers = {
|
||||
zodiac: createRequestController(),
|
||||
education: createRequestController(),
|
||||
deathExpression: createRequestController(),
|
||||
};
|
||||
let pageActive = true;
|
||||
let loadSequence = 0;
|
||||
|
||||
@@ -374,6 +499,7 @@ const editForm = reactive({
|
||||
appUserId: "",
|
||||
bindingMode: "NONE",
|
||||
name: "",
|
||||
courtesyName: "",
|
||||
aliasName: "",
|
||||
sex: "",
|
||||
generation: "",
|
||||
@@ -384,16 +510,32 @@ const editForm = reactive({
|
||||
birthDate: "",
|
||||
birthLunar: "",
|
||||
birthPlace: "",
|
||||
zodiac: "",
|
||||
currentAddress: "",
|
||||
mobile: "",
|
||||
email: "",
|
||||
education: "",
|
||||
occupation: "",
|
||||
deathDate: "",
|
||||
deathLunar: "",
|
||||
deathAge: "",
|
||||
deathPlace: "",
|
||||
deathType: "",
|
||||
hereditaryMedicalHistory: "",
|
||||
burialDate: "",
|
||||
burialPlace: "",
|
||||
personStatus: "",
|
||||
summary: "",
|
||||
remark: "",
|
||||
sortOrder: "",
|
||||
});
|
||||
const fieldErrors = reactive({ name: "", dates: "", bindingMode: "" });
|
||||
const fieldErrors = reactive({
|
||||
name: "",
|
||||
dates: "",
|
||||
bindingMode: "",
|
||||
email: "",
|
||||
deathAge: "",
|
||||
});
|
||||
const sexOptions = memberFormOptions.sex;
|
||||
const lunarOptions = memberFormOptions.lunar;
|
||||
const personStatusOptions = memberFormOptions.personStatus;
|
||||
@@ -404,6 +546,10 @@ const personOptionLabels = computed(() =>
|
||||
);
|
||||
const memberOptionsState = ref("loading");
|
||||
const memberOptions = ref([]);
|
||||
const zodiacOptions = ref([]);
|
||||
const educationOptions = ref([]);
|
||||
const deathExpressionOptions = ref([]);
|
||||
const originalSensitiveProfilePresent = ref(false);
|
||||
const memberBindingOptions = computed(() => {
|
||||
const options = memberOptions.value.slice();
|
||||
if (
|
||||
@@ -422,7 +568,11 @@ const isDeceased = computed(() => editForm.personStatus === "1");
|
||||
|
||||
const formSnapshot = computed(() => JSON.stringify(editForm));
|
||||
const hasValidContext = computed(() =>
|
||||
Boolean(genealogyId.value && personId.value && originalMember.value),
|
||||
Boolean(
|
||||
/^[1-9]\d*$/.test(genealogyId.value) &&
|
||||
/^[1-9]\d*$/.test(personId.value) &&
|
||||
originalMember.value,
|
||||
),
|
||||
);
|
||||
const isDirty = computed(
|
||||
() =>
|
||||
@@ -484,11 +634,22 @@ const loadMember = async () => {
|
||||
if (!pageActive || activeLoad !== loadSequence) return;
|
||||
originalMember.value = member;
|
||||
const deceased = member.personStatus === "1";
|
||||
let sensitiveProfile = null;
|
||||
if (member.canManageSensitiveMedicalHistory) {
|
||||
sensitiveProfile = await lineageApi.getSensitiveProfile(
|
||||
genealogyId.value,
|
||||
personId.value,
|
||||
{ requestController: sensitiveProfileRequestController },
|
||||
);
|
||||
if (!pageActive || activeLoad !== loadSequence) return;
|
||||
}
|
||||
originalSensitiveProfilePresent.value = sensitiveProfile?.present === true;
|
||||
Object.assign(editForm, {
|
||||
appUserId: member.appUserId || "",
|
||||
bindingMode:
|
||||
member.bindingMode || (member.appUserId ? "SPECIFIED" : "NONE"),
|
||||
name: member.name,
|
||||
courtesyName: member.courtesyName || "",
|
||||
aliasName: member.aliasName || "",
|
||||
sex: member.sex || "",
|
||||
generation: member.generation || "",
|
||||
@@ -501,15 +662,35 @@ const loadMember = async () => {
|
||||
birthDate: datePart(member.birthDate),
|
||||
birthLunar: member.birthLunar || "",
|
||||
birthPlace: member.birthplace || "",
|
||||
zodiac: member.zodiacCode || "",
|
||||
currentAddress: member.currentAddress || "",
|
||||
mobile: member.mobile || "",
|
||||
email: member.email || "",
|
||||
education: member.educationCode || "",
|
||||
occupation: member.occupation || "",
|
||||
deathDate: deceased ? datePart(member.deathDate) : "",
|
||||
deathLunar: deceased ? member.deathLunar || "" : "",
|
||||
deathAge: deceased ? member.deathAge ?? "" : "",
|
||||
deathPlace: deceased ? member.deathPlace || "" : "",
|
||||
deathType: deceased ? member.deathExpressionCode || "" : "",
|
||||
hereditaryMedicalHistory:
|
||||
deceased && member.canManageSensitiveMedicalHistory
|
||||
? sensitiveProfile?.hereditaryMedicalHistory || ""
|
||||
: "",
|
||||
burialDate: deceased ? datePart(member.burialDate) : "",
|
||||
burialPlace: deceased ? member.burialPlace || "" : "",
|
||||
personStatus: member.personStatus || "",
|
||||
summary: member.biography || "",
|
||||
remark: member.remark || "",
|
||||
sortOrder: member.sortOrder ?? "",
|
||||
});
|
||||
zodiacOptions.value = preserveHistoricalOption(zodiacOptions.value, member.zodiacCode, member.zodiac);
|
||||
educationOptions.value = preserveHistoricalOption(educationOptions.value, member.educationCode, member.education);
|
||||
deathExpressionOptions.value = preserveHistoricalOption(
|
||||
deathExpressionOptions.value,
|
||||
member.deathExpressionCode,
|
||||
member.deathType,
|
||||
);
|
||||
baseline.value = formSnapshot.value;
|
||||
committedMemberSnapshot.value = "";
|
||||
errorMessage.value = "";
|
||||
@@ -548,6 +729,34 @@ const loadPersonOptions = async () => {
|
||||
personOptions.value = [{ label: "暂无可选人物", value: "" }];
|
||||
}
|
||||
};
|
||||
const preserveHistoricalOption = (options, value, label) => {
|
||||
if (!value || options.some((item) => item.value === value)) return options;
|
||||
return [{ value, label: label || value }, ...options];
|
||||
};
|
||||
const loadBusinessOptions = async () => {
|
||||
try {
|
||||
const [zodiacRows, educationRows, deathExpressionRows] = await Promise.all([
|
||||
businessDictionaryApi.getBusinessDictionaryOptions("gen_zodiac", {
|
||||
requestController: dictionaryRequestControllers.zodiac,
|
||||
}),
|
||||
businessDictionaryApi.getBusinessDictionaryOptions("gen_education_type", {
|
||||
requestController: dictionaryRequestControllers.education,
|
||||
}),
|
||||
businessDictionaryApi.getBusinessDictionaryOptions("gen_death_expression", {
|
||||
requestController: dictionaryRequestControllers.deathExpression,
|
||||
}),
|
||||
]);
|
||||
if (!pageActive) return;
|
||||
zodiacOptions.value = preserveHistoricalOption(zodiacRows, originalMember.value?.zodiacCode, originalMember.value?.zodiac);
|
||||
educationOptions.value = preserveHistoricalOption(educationRows, originalMember.value?.educationCode, originalMember.value?.education);
|
||||
deathExpressionOptions.value = preserveHistoricalOption(deathExpressionRows, originalMember.value?.deathExpressionCode, originalMember.value?.deathType);
|
||||
} catch (error) {
|
||||
if (!pageActive || isRequestCancelled(error)) return;
|
||||
zodiacOptions.value = preserveHistoricalOption([], originalMember.value?.zodiacCode, originalMember.value?.zodiac);
|
||||
educationOptions.value = preserveHistoricalOption([], originalMember.value?.educationCode, originalMember.value?.education);
|
||||
deathExpressionOptions.value = preserveHistoricalOption([], originalMember.value?.deathExpressionCode, originalMember.value?.deathType);
|
||||
}
|
||||
};
|
||||
const loadMemberOptions = async () => {
|
||||
memberOptionsState.value = "loading";
|
||||
try {
|
||||
@@ -572,14 +781,18 @@ const loadMemberOptions = async () => {
|
||||
};
|
||||
|
||||
onLoad((query) => {
|
||||
genealogyId.value = String(query.genealogyId || "");
|
||||
personId.value = String(query.personId || "");
|
||||
if (!genealogyId.value || !personId.value) {
|
||||
genealogyId.value = String(query?.genealogyId || "");
|
||||
personId.value = String(query?.personId || "");
|
||||
if (
|
||||
!/^[1-9]\d*$/.test(genealogyId.value) ||
|
||||
!/^[1-9]\d*$/.test(personId.value)
|
||||
) {
|
||||
editState.value = "error";
|
||||
errorMessage.value = "这个页面已经过期,请从成员档案重新进入。";
|
||||
return;
|
||||
}
|
||||
void loadMember();
|
||||
void loadBusinessOptions();
|
||||
void loadPersonOptions();
|
||||
void loadMemberOptions();
|
||||
});
|
||||
@@ -591,6 +804,8 @@ onUnload(() => {
|
||||
memberOptionsRequestController.abort();
|
||||
avatarUploadRequestController.abort();
|
||||
memberUpdateRequestController.abort();
|
||||
sensitiveProfileRequestController.abort();
|
||||
Object.values(dictionaryRequestControllers).forEach((controller) => controller.abort());
|
||||
discardConfirmation.dispose();
|
||||
});
|
||||
|
||||
@@ -676,6 +891,19 @@ const validateEditForm = () => {
|
||||
editForm.deathDate &&
|
||||
editForm.deathDate < editForm.birthDate
|
||||
? "离世日期不能早于出生日期"
|
||||
: editForm.deathDate &&
|
||||
editForm.burialDate &&
|
||||
editForm.burialDate < editForm.deathDate
|
||||
? "安葬日期不能早于离世日期"
|
||||
: "";
|
||||
fieldErrors.email =
|
||||
editForm.email.trim() && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(editForm.email.trim())
|
||||
? "请填写有效的电子邮箱"
|
||||
: "";
|
||||
const deathAge = editForm.deathAge === "" ? null : Number(editForm.deathAge);
|
||||
fieldErrors.deathAge =
|
||||
deathAge !== null && (!Number.isSafeInteger(deathAge) || deathAge < 0 || deathAge > 200)
|
||||
? "享年必须是 0 至 200 的整数"
|
||||
: "";
|
||||
fieldErrors.bindingMode =
|
||||
editForm.bindingMode === "SPECIFIED" && !editForm.appUserId
|
||||
@@ -683,7 +911,7 @@ const validateEditForm = () => {
|
||||
? "可绑定成员暂不可用,请稍后重试"
|
||||
: "请选择可绑定成员"
|
||||
: "";
|
||||
return !fieldErrors.name && !fieldErrors.dates && !fieldErrors.bindingMode;
|
||||
return !Object.values(fieldErrors).some(Boolean);
|
||||
};
|
||||
const saveMember = async () => {
|
||||
if (isSubmitting.value || isAvatarUploading.value || !validateEditForm())
|
||||
@@ -701,6 +929,7 @@ const saveMember = async () => {
|
||||
? { appUserId: editForm.appUserId }
|
||||
: {}),
|
||||
name: editForm.name,
|
||||
courtesyName: editForm.courtesyName,
|
||||
aliasName: editForm.aliasName,
|
||||
sex: editForm.sex,
|
||||
generation: editForm.generation
|
||||
@@ -713,11 +942,20 @@ const saveMember = async () => {
|
||||
birthDate: editForm.birthDate,
|
||||
birthLunar: editForm.birthLunar,
|
||||
birthPlace: editForm.birthPlace,
|
||||
zodiacCode: editForm.zodiac,
|
||||
currentAddress: editForm.currentAddress,
|
||||
mobile: editForm.mobile,
|
||||
email: editForm.email,
|
||||
educationCode: editForm.education,
|
||||
occupation: editForm.occupation,
|
||||
...(isDeceased.value
|
||||
? {
|
||||
deathDate: editForm.deathDate,
|
||||
deathLunar: editForm.deathLunar,
|
||||
deathAge: editForm.deathAge,
|
||||
deathPlace: editForm.deathPlace,
|
||||
deathExpressionCode: editForm.deathType,
|
||||
burialDate: editForm.burialDate,
|
||||
burialPlace: editForm.burialPlace,
|
||||
}
|
||||
: {}),
|
||||
@@ -729,6 +967,26 @@ const saveMember = async () => {
|
||||
{ requestController: memberUpdateRequestController },
|
||||
);
|
||||
if (!pageActive) return;
|
||||
if (originalMember.value.canManageSensitiveMedicalHistory) {
|
||||
const sensitiveHistory = editForm.hereditaryMedicalHistory.trim();
|
||||
if (sensitiveHistory) {
|
||||
await lineageApi.saveSensitiveProfile(
|
||||
genealogyId.value,
|
||||
personId.value,
|
||||
sensitiveHistory,
|
||||
{ requestController: sensitiveProfileRequestController },
|
||||
);
|
||||
originalSensitiveProfilePresent.value = true;
|
||||
} else if (originalSensitiveProfilePresent.value) {
|
||||
await lineageApi.clearSensitiveProfile(
|
||||
genealogyId.value,
|
||||
personId.value,
|
||||
{ requestController: sensitiveProfileRequestController },
|
||||
);
|
||||
originalSensitiveProfilePresent.value = false;
|
||||
}
|
||||
}
|
||||
if (!pageActive) return;
|
||||
committedMemberSnapshot.value = currentSnapshot;
|
||||
baseline.value = currentSnapshot;
|
||||
}
|
||||
@@ -792,7 +1050,7 @@ const handleResultAction = () => {
|
||||
@include adaptive-tree-panel;
|
||||
z-index: 2;
|
||||
width: calc(100% - 32rpx);
|
||||
margin: 18rpx auto 28rpx;
|
||||
margin: 18rpx auto calc(28rpx + env(safe-area-inset-bottom));
|
||||
padding: 7.5% 8%;
|
||||
}
|
||||
.form-eyebrow {
|
||||
@@ -847,9 +1105,12 @@ const handleResultAction = () => {
|
||||
}
|
||||
.form-field textarea {
|
||||
width: auto;
|
||||
min-height: 54rpx;
|
||||
min-height: var(--app-touch-min);
|
||||
text-align: left;
|
||||
}
|
||||
.form-field input {
|
||||
min-height: var(--app-touch-min);
|
||||
}
|
||||
.form-field__hint,
|
||||
.upload-receipt {
|
||||
color: $ink-muted;
|
||||
@@ -863,7 +1124,7 @@ const handleResultAction = () => {
|
||||
gap: 6rpx;
|
||||
}
|
||||
.upload-button {
|
||||
min-height: 54rpx;
|
||||
min-height: var(--app-touch-min);
|
||||
margin: 0;
|
||||
padding: 0 16rpx;
|
||||
border: 1rpx solid rgba(159, 23, 15, 0.42);
|
||||
@@ -892,7 +1153,7 @@ const handleResultAction = () => {
|
||||
.form-action {
|
||||
display: grid;
|
||||
width: 100%;
|
||||
min-height: 76rpx;
|
||||
min-height: var(--app-touch-min);
|
||||
margin-top: 18rpx;
|
||||
}
|
||||
.form-action image,
|
||||
|
||||
@@ -63,7 +63,7 @@
|
||||
v-if="hasMore"
|
||||
block
|
||||
type="secondary"
|
||||
:label="loadingMore ? '正在加载…' : '加载更多成员'"
|
||||
:label="loadingMore ? '正在加载…' : loadMoreError ? '加载失败,重新加载' : '加载更多成员'"
|
||||
@click="loadMore"
|
||||
/>
|
||||
</template>
|
||||
@@ -109,9 +109,10 @@ const members = ref([]);
|
||||
const total = ref(0);
|
||||
const pageNum = ref(1);
|
||||
const loadingMore = ref(false);
|
||||
const loadMoreError = ref(false);
|
||||
const memberDirectoryRequestController = createRequestController();
|
||||
let loadSequence = 0;
|
||||
const hasValidContext = computed(() => Boolean(genealogyId.value));
|
||||
const hasValidContext = computed(() => /^[1-9]\d*$/.test(genealogyId.value));
|
||||
const memberMeta = (member) =>
|
||||
`第 ${member.generation} 世 · ${member.generationName || "字辈待补"} · ${member.branch}`;
|
||||
const memberStatus = (member) => {
|
||||
@@ -124,22 +125,30 @@ const hasMore = computed(() => members.value.length < total.value);
|
||||
const loadMembers = async ({ append = false } = {}) => {
|
||||
if (!hasValidContext.value) return;
|
||||
const activeLoad = ++loadSequence;
|
||||
if (append) loadingMore.value = true;
|
||||
const requestedPage = append ? pageNum.value + 1 : 1;
|
||||
if (append) {
|
||||
loadingMore.value = true;
|
||||
loadMoreError.value = false;
|
||||
}
|
||||
else directoryState.value = "loading";
|
||||
try {
|
||||
const personPage = await lineageApi.getPersonPage(
|
||||
genealogyId.value,
|
||||
{ pageNum: pageNum.value, pageSize: 10, keyword: keyword.value },
|
||||
{ pageNum: requestedPage, pageSize: 10, keyword: keyword.value },
|
||||
{ requestController: memberDirectoryRequestController },
|
||||
);
|
||||
if (activeLoad !== loadSequence) return;
|
||||
members.value = append ? [...members.value, ...personPage.rows] : personPage.rows;
|
||||
pageNum.value = requestedPage;
|
||||
total.value = personPage.total;
|
||||
directoryState.value = members.value.length ? "list" : "empty";
|
||||
} catch (error) {
|
||||
if (activeLoad !== loadSequence || isRequestCancelled(error)) return;
|
||||
if (!append) members.value = [];
|
||||
directoryState.value = "error";
|
||||
if (append) loadMoreError.value = true;
|
||||
else {
|
||||
members.value = [];
|
||||
directoryState.value = "error";
|
||||
}
|
||||
} finally {
|
||||
if (activeLoad === loadSequence) loadingMore.value = false;
|
||||
}
|
||||
@@ -153,17 +162,16 @@ onLoad((query) => {
|
||||
void loadMembers();
|
||||
});
|
||||
const searchMembers = () => {
|
||||
pageNum.value = 1;
|
||||
loadMoreError.value = false;
|
||||
void loadMembers();
|
||||
};
|
||||
const loadMore = () => {
|
||||
if (loadingMore.value || !hasMore.value) return;
|
||||
pageNum.value += 1;
|
||||
void loadMembers({ append: true });
|
||||
};
|
||||
const retryDirectory = () => {
|
||||
if (!hasValidContext.value) return goBack();
|
||||
pageNum.value = 1;
|
||||
loadMoreError.value = false;
|
||||
return loadMembers();
|
||||
};
|
||||
const openMember = (item) =>
|
||||
@@ -199,16 +207,21 @@ onUnload(() => {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 20rpx;
|
||||
box-sizing: border-box;
|
||||
padding: 22rpx 32rpx 0;
|
||||
}
|
||||
.directory-context__name {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
color: $ink;
|
||||
font-family: "STKaiti", "KaiTi", serif;
|
||||
font-size: clamp(17px, 32rpx, 22px);
|
||||
font-weight: 700;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.directory-context__meta {
|
||||
flex: 0 0 auto;
|
||||
color: #62584c;
|
||||
font-size: clamp(15px, 24rpx, 18px);
|
||||
font-weight: 500;
|
||||
@@ -248,7 +261,7 @@ onUnload(() => {
|
||||
color: #776956;
|
||||
}
|
||||
.directory-content {
|
||||
padding: 22rpx 24rpx 50rpx;
|
||||
padding: 22rpx 24rpx calc(50rpx + env(safe-area-inset-bottom));
|
||||
}
|
||||
.directory-summary {
|
||||
display: flex;
|
||||
|
||||
@@ -202,7 +202,9 @@ const genealogyName = ref("汤氏家谱");
|
||||
const memberTrail = reactive([]);
|
||||
const trailIndex = ref(-1);
|
||||
const memberReadRequestController = createRequestController();
|
||||
const sensitiveProfileRequestController = createRequestController();
|
||||
const memberDeletionRequestController = createRequestController();
|
||||
const sensitiveProfileError = ref("");
|
||||
let memberLoadGeneration = 0;
|
||||
let pageActive = true;
|
||||
|
||||
@@ -212,7 +214,8 @@ const profileDetails = computed(() => {
|
||||
const visibleProfileValue = (profileValue) =>
|
||||
member.value.status === "forbidden" ? "按权限隐藏" : profileValue;
|
||||
return [
|
||||
{ label: "别名", value: member.value.aliasName },
|
||||
{ label: "表字", value: member.value.courtesyName },
|
||||
{ label: "别号", value: member.value.aliasName },
|
||||
{ label: "字辈", value: member.value.generationName },
|
||||
{
|
||||
label: "性别",
|
||||
@@ -227,8 +230,14 @@ const profileDetails = computed(() => {
|
||||
label: "出生农历",
|
||||
value: visibleProfileValue(member.value.birthLunarLabel),
|
||||
},
|
||||
{ label: "生肖", value: visibleProfileValue(member.value.zodiac) },
|
||||
{ label: "生卒信息", value: member.value.years },
|
||||
{ label: "出生地", value: visibleProfileValue(member.value.birthplace) },
|
||||
{ label: "现居地", value: visibleProfileValue(member.value.currentAddress) },
|
||||
{ label: "联系电话", value: visibleProfileValue(member.value.mobile) },
|
||||
{ label: "电子邮箱", value: visibleProfileValue(member.value.email) },
|
||||
{ label: "教育经历", value: visibleProfileValue(member.value.education) },
|
||||
{ label: "职业", value: visibleProfileValue(member.value.occupation) },
|
||||
...(isDeceased.value
|
||||
? [
|
||||
{ label: "逝世日期", value: visibleProfileValue(member.value.deathDate) },
|
||||
@@ -236,7 +245,29 @@ const profileDetails = computed(() => {
|
||||
label: "逝世农历",
|
||||
value: visibleProfileValue(member.value.deathLunarLabel),
|
||||
},
|
||||
{
|
||||
label: "享年",
|
||||
value:
|
||||
member.value.deathAge === null
|
||||
? ""
|
||||
: visibleProfileValue(`${member.value.deathAge} 岁`),
|
||||
},
|
||||
{ label: "逝世地", value: visibleProfileValue(member.value.deathPlace) },
|
||||
{
|
||||
label: "逝世原因或类型",
|
||||
value: visibleProfileValue(member.value.deathType),
|
||||
},
|
||||
...(member.value.canManageSensitiveMedicalHistory
|
||||
? [
|
||||
{
|
||||
label: "遗传病史",
|
||||
value: sensitiveProfileError.value
|
||||
? "敏感资料暂时无法读取"
|
||||
: visibleProfileValue(member.value.hereditaryMedicalHistory),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{ label: "安葬日期", value: visibleProfileValue(member.value.burialDate) },
|
||||
{ label: "安葬地", value: visibleProfileValue(member.value.burialPlace) },
|
||||
]
|
||||
: []),
|
||||
@@ -307,6 +338,27 @@ const loadMember = async (nextPersonId, { preserveCurrent = false } = {}) => {
|
||||
normalizedPersonId,
|
||||
{ requestController: memberReadRequestController },
|
||||
);
|
||||
if (nextMember.canManageSensitiveMedicalHistory) {
|
||||
try {
|
||||
const sensitiveProfile = await lineageApi.getSensitiveProfile(
|
||||
genealogyId.value,
|
||||
normalizedPersonId,
|
||||
{ requestController: sensitiveProfileRequestController },
|
||||
);
|
||||
nextMember.hereditaryMedicalHistory =
|
||||
sensitiveProfile.hereditaryMedicalHistory;
|
||||
sensitiveProfileError.value = "";
|
||||
} catch (error) {
|
||||
if (isRequestCancelled(error)) return false;
|
||||
nextMember.hereditaryMedicalHistory = "";
|
||||
sensitiveProfileError.value = getRequestErrorMessage(
|
||||
error,
|
||||
"敏感资料暂时无法读取。",
|
||||
);
|
||||
}
|
||||
} else {
|
||||
sensitiveProfileError.value = "";
|
||||
}
|
||||
if (activeLoadGeneration !== memberLoadGeneration) return false;
|
||||
if (personId.value && personId.value !== String(nextMember.id)) {
|
||||
personDocumentDialog.value?.reset();
|
||||
@@ -415,7 +467,10 @@ const requestBack = () =>
|
||||
onLoad((query) => {
|
||||
genealogyId.value = String(query.genealogyId || "");
|
||||
const initialPersonId = String(query.personId || "");
|
||||
if (!genealogyId.value || !initialPersonId) {
|
||||
if (
|
||||
!/^[1-9]\d*$/.test(genealogyId.value) ||
|
||||
!/^[1-9]\d*$/.test(initialPersonId)
|
||||
) {
|
||||
memberState.value = "error";
|
||||
errorMessage.value = !initialPersonId
|
||||
? "没有指定成员,请从世系树重新选择。"
|
||||
@@ -449,6 +504,7 @@ onUnload(() => {
|
||||
pageActive = false;
|
||||
memberLoadGeneration += 1;
|
||||
memberReadRequestController.abort();
|
||||
sensitiveProfileRequestController.abort();
|
||||
memberDeletionRequestController.abort();
|
||||
});
|
||||
|
||||
@@ -517,7 +573,7 @@ const toTree = requestBack;
|
||||
display: flex;
|
||||
min-height: 100vh;
|
||||
flex-direction: column;
|
||||
padding-bottom: 28rpx;
|
||||
padding-bottom: calc(28rpx + env(safe-area-inset-bottom));
|
||||
box-sizing: border-box;
|
||||
background: $paper;
|
||||
}
|
||||
@@ -571,6 +627,10 @@ const toTree = requestBack;
|
||||
box-shadow: 0 3rpx 7rpx rgba(100, 65, 29, 0.14);
|
||||
overflow: hidden;
|
||||
}
|
||||
.member-heading > view:last-child {
|
||||
min-width: 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.member-heading > view:last-child text {
|
||||
display: block;
|
||||
color: $ink-muted;
|
||||
@@ -592,6 +652,7 @@ const toTree = requestBack;
|
||||
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
gap: 14rpx;
|
||||
min-height: var(--app-touch-min);
|
||||
margin-top: 18rpx;
|
||||
padding: 18rpx 22rpx;
|
||||
}
|
||||
@@ -637,6 +698,8 @@ const toTree = requestBack;
|
||||
}
|
||||
.member-relatives > view {
|
||||
display: flex;
|
||||
min-height: var(--app-touch-min);
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 20rpx;
|
||||
padding: 12rpx 18rpx;
|
||||
@@ -661,7 +724,7 @@ const toTree = requestBack;
|
||||
.member-record-actions > view {
|
||||
@include adaptive-scroll-button(secondary);
|
||||
display: flex;
|
||||
min-height: 72rpx;
|
||||
min-height: var(--app-touch-min);
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 8rpx 14rpx;
|
||||
@@ -676,7 +739,7 @@ const toTree = requestBack;
|
||||
.member-profile-action {
|
||||
@include adaptive-scroll-button(primary);
|
||||
display: flex;
|
||||
min-height: 76rpx;
|
||||
min-height: var(--app-touch-min);
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-top: 22rpx;
|
||||
@@ -714,7 +777,7 @@ const toTree = requestBack;
|
||||
.member-restricted-action {
|
||||
@include adaptive-scroll-button(secondary);
|
||||
display: flex;
|
||||
min-height: 76rpx;
|
||||
min-height: var(--app-touch-min);
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-top: 22rpx;
|
||||
|
||||
+46
-10
@@ -67,12 +67,25 @@
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<AppDialog
|
||||
:visible="discardVisible"
|
||||
eyebrow="未保存修改"
|
||||
title="放弃排行修改?"
|
||||
message="当前排序值还没有保存。"
|
||||
confirm-text="确认放弃"
|
||||
cancel-text="继续编辑"
|
||||
show-cancel
|
||||
:close-on-mask="false"
|
||||
@confirm="confirmDiscard"
|
||||
@cancel="cancelDiscard"
|
||||
/>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from "vue";
|
||||
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 ModulePageBackground from "@/components/ModulePageBackground.vue";
|
||||
import PageHeader from "@/components/PageHeader.vue";
|
||||
@@ -82,7 +95,8 @@ import {
|
||||
} 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";
|
||||
import { createDiscardConfirmation } from "@/utils/discard-confirmation.js";
|
||||
import { goBack, handleBackPress, returnTo, runBackGuard } from "@/utils/navigation/gateway.js";
|
||||
|
||||
const rankState = ref("loading");
|
||||
const genealogyId = ref("");
|
||||
@@ -93,10 +107,21 @@ const sortOrder = ref("");
|
||||
const savingRank = ref(false);
|
||||
const rankError = ref("");
|
||||
const committedSortOrder = ref("");
|
||||
const sortOrderBaseline = ref("");
|
||||
const discardVisible = ref(false);
|
||||
const memberRankReadRequestController = createRequestController();
|
||||
const memberRankSaveRequestController = createRequestController();
|
||||
let pageActive = true;
|
||||
let loadSequence = 0;
|
||||
const discardConfirmation = createDiscardConfirmation((visible) => {
|
||||
discardVisible.value = visible;
|
||||
});
|
||||
const requestDiscardConfirmation = discardConfirmation.request;
|
||||
const confirmDiscard = discardConfirmation.confirm;
|
||||
const cancelDiscard = discardConfirmation.cancel;
|
||||
const isDirty = computed(() =>
|
||||
rankState.value === "form" && sortOrder.value !== sortOrderBaseline.value,
|
||||
);
|
||||
|
||||
const loadMember = async () => {
|
||||
const activeLoad = ++loadSequence;
|
||||
@@ -110,6 +135,7 @@ const loadMember = async () => {
|
||||
sortOrder.value = personDetail.sortOrder === null || personDetail.sortOrder === undefined
|
||||
? ""
|
||||
: String(personDetail.sortOrder);
|
||||
sortOrderBaseline.value = sortOrder.value;
|
||||
committedSortOrder.value = "";
|
||||
errorMessage.value = "";
|
||||
rankState.value = "form";
|
||||
@@ -146,6 +172,7 @@ const saveRank = async () => {
|
||||
);
|
||||
if (!pageActive) return;
|
||||
committedSortOrder.value = normalizedSortOrder;
|
||||
sortOrderBaseline.value = normalizedSortOrder;
|
||||
}
|
||||
await returnTo("T01", {
|
||||
genealogyId: genealogyId.value,
|
||||
@@ -162,15 +189,23 @@ const saveRank = async () => {
|
||||
}
|
||||
};
|
||||
|
||||
const requestBack = () => {
|
||||
if (savingRank.value) return;
|
||||
return goBack();
|
||||
};
|
||||
const requestBack = () => runBackGuard({
|
||||
transientOpen: discardVisible.value,
|
||||
dirty: isDirty.value,
|
||||
submitting: savingRank.value,
|
||||
"close-transient": cancelDiscard,
|
||||
"block-submitting": () => true,
|
||||
"confirm-discard": requestDiscardConfirmation,
|
||||
});
|
||||
|
||||
onLoad((query) => {
|
||||
genealogyId.value = String(query.genealogyId || "");
|
||||
personId.value = String(query.personId || "");
|
||||
if (!genealogyId.value || !personId.value || query.mode !== "rank") {
|
||||
if (
|
||||
!/^[1-9]\d*$/.test(genealogyId.value) ||
|
||||
!/^[1-9]\d*$/.test(personId.value) ||
|
||||
query.mode !== "rank"
|
||||
) {
|
||||
rankState.value = "error";
|
||||
errorMessage.value = "请从成员资料页重新进入排行调整。";
|
||||
return;
|
||||
@@ -183,6 +218,7 @@ onUnload(() => {
|
||||
loadSequence += 1;
|
||||
memberRankReadRequestController.abort();
|
||||
memberRankSaveRequestController.abort();
|
||||
discardConfirmation.dispose();
|
||||
});
|
||||
|
||||
onBackPress((event) => handleBackPress(event, requestBack));
|
||||
@@ -204,7 +240,7 @@ onBackPress((event) => handleBackPress(event, requestBack));
|
||||
@include adaptive-tree-panel;
|
||||
z-index: 2;
|
||||
width: calc(100% - 32rpx);
|
||||
margin: 18rpx auto 28rpx;
|
||||
margin: 18rpx auto calc(28rpx + env(safe-area-inset-bottom));
|
||||
padding: 7.5% 8%;
|
||||
}
|
||||
.form-eyebrow {
|
||||
@@ -255,7 +291,7 @@ onBackPress((event) => handleBackPress(event, requestBack));
|
||||
}
|
||||
.rank-field input {
|
||||
min-width: 0;
|
||||
min-height: 56rpx;
|
||||
min-height: var(--app-touch-min);
|
||||
padding: 0 14rpx;
|
||||
border: 1rpx solid rgba(143, 108, 63, 0.34);
|
||||
border-radius: 8rpx;
|
||||
@@ -269,7 +305,7 @@ onBackPress((event) => handleBackPress(event, requestBack));
|
||||
.form-action {
|
||||
display: grid;
|
||||
width: 100%;
|
||||
min-height: 76rpx;
|
||||
min-height: var(--app-touch-min);
|
||||
margin-top: 22rpx;
|
||||
}
|
||||
.form-action--disabled { opacity: 0.58; pointer-events: none; }
|
||||
|
||||
@@ -107,7 +107,7 @@ const states = {
|
||||
const activeStatus = computed(() => states[statusState.value] || states.error);
|
||||
const pageTitle = computed(() => "成员状态");
|
||||
const hasValidContext = computed(() =>
|
||||
Boolean(genealogyId.value && personId.value),
|
||||
/^[1-9]\d*$/.test(genealogyId.value) && /^[1-9]\d*$/.test(personId.value),
|
||||
);
|
||||
const memberIdentityCopy = computed(() => {
|
||||
if (!member.value) return "";
|
||||
@@ -157,7 +157,7 @@ const handleAction = () => goBack();
|
||||
display: flex;
|
||||
min-height: 100vh;
|
||||
flex-direction: column;
|
||||
padding-bottom: 34rpx;
|
||||
padding-bottom: calc(34rpx + env(safe-area-inset-bottom));
|
||||
box-sizing: border-box;
|
||||
background: $paper;
|
||||
}
|
||||
@@ -236,7 +236,7 @@ const handleAction = () => goBack();
|
||||
.status-action {
|
||||
display: grid;
|
||||
width: calc(100% - 72rpx);
|
||||
min-height: 76rpx;
|
||||
min-height: var(--app-touch-min);
|
||||
margin: 22rpx auto 0;
|
||||
}
|
||||
.status-action image,
|
||||
|
||||
+12
-30
@@ -158,17 +158,6 @@
|
||||
@select-action="openMemberAction"
|
||||
/>
|
||||
|
||||
<AppDialog
|
||||
:visible="unavailableActionVisible"
|
||||
eyebrow="服务状态"
|
||||
:title="unavailableAction?.label || '当前操作'"
|
||||
:message="
|
||||
unavailableAction?.unavailableCopy || '这项功能还在准备中,暂时无法使用。'
|
||||
"
|
||||
confirm-text="我知道了"
|
||||
@confirm="unavailableActionVisible = false"
|
||||
@cancel="unavailableActionVisible = false"
|
||||
/>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
@@ -176,7 +165,6 @@
|
||||
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";
|
||||
@@ -198,8 +186,6 @@ 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);
|
||||
@@ -282,13 +268,6 @@ const memberActions = Object.freeze([
|
||||
routeKey: "T04",
|
||||
relationType: memberRelationTypes.DAUGHTER,
|
||||
},
|
||||
{
|
||||
key: "BIND_INVITE",
|
||||
label: "邀请绑定",
|
||||
group: "MANAGEMENT",
|
||||
unavailableCopy:
|
||||
"邀请绑定暂未开放,请稍后再试。",
|
||||
},
|
||||
{
|
||||
key: "EDIT_PROFILE",
|
||||
label: "编辑信息",
|
||||
@@ -370,7 +349,7 @@ const loadTree = async (query = {}) => {
|
||||
genealogyId.value = String(
|
||||
query.genealogyId || genealogyContext.getCurrentGenealogyId() || "",
|
||||
);
|
||||
if (!genealogyId.value) {
|
||||
if (!/^[1-9]\d*$/.test(genealogyId.value)) {
|
||||
members.value = [];
|
||||
treeState.value = "empty";
|
||||
return;
|
||||
@@ -488,7 +467,7 @@ const recenterSelectedMember = async () => {
|
||||
const nodeGridStyle = treeNodeGridStyle;
|
||||
const handleStateAction = () => {
|
||||
if (treeState.value === "empty") {
|
||||
if (!genealogyId.value) return Promise.resolve(false);
|
||||
if (!/^[1-9]\d*$/.test(genealogyId.value)) return Promise.resolve(false);
|
||||
return openPage(
|
||||
"T04",
|
||||
{ genealogyId: genealogyId.value, mode: "first" },
|
||||
@@ -534,13 +513,8 @@ const closeMemberActionPanel = () => {
|
||||
memberActionPanelVisible.value = false;
|
||||
};
|
||||
const openMemberAction = (action) => {
|
||||
if (!selectedMember.value) return Promise.resolve(false);
|
||||
if (!selectedMember.value || !action?.routeKey) 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),
|
||||
@@ -589,12 +563,20 @@ const openMemberAction = (action) => {
|
||||
}
|
||||
.tree-toolbar__actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 24rpx;
|
||||
color: $brand-red;
|
||||
font-size: clamp(14px, 23rpx, 17px);
|
||||
}
|
||||
.tree-toolbar__actions > text {
|
||||
display: flex;
|
||||
min-height: var(--app-touch-min);
|
||||
align-items: center;
|
||||
}
|
||||
.tree-stage {
|
||||
min-height: 0;
|
||||
padding-bottom: env(safe-area-inset-bottom);
|
||||
box-sizing: border-box;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.tree-action--pressed {
|
||||
@@ -832,7 +814,7 @@ const openMemberAction = (action) => {
|
||||
.tree-state-card__action {
|
||||
display: grid;
|
||||
width: 360rpx;
|
||||
min-height: 70rpx;
|
||||
min-height: var(--app-touch-min);
|
||||
margin: 22rpx auto 0;
|
||||
}
|
||||
.tree-state-card__action image {
|
||||
|
||||
+24
-4
@@ -52,6 +52,9 @@
|
||||
<view class="member-node__relation"><text>{{ memberRelation(member) }}</text></view>
|
||||
<view
|
||||
class="member-node__name"
|
||||
:class="{
|
||||
'member-node__name--horizontal': !isVerticalPedigreeText(member.name),
|
||||
}"
|
||||
role="button"
|
||||
:aria-label="`查看${member.name}的资料`"
|
||||
@click.stop="openMemberProfile(member)"
|
||||
@@ -135,7 +138,7 @@
|
||||
|
||||
<script setup>
|
||||
import { computed, ref } from "vue";
|
||||
import { onLoad, onShow, onUnload } from "@dcloudio/uni-app";
|
||||
import { onBackPress, onLoad, onShow, onUnload } from "@dcloudio/uni-app";
|
||||
import AppDialog from "@/components/AppDialog.vue";
|
||||
import AppLoading from "@/components/AppLoading.vue";
|
||||
import ModulePageBackground from "@/components/ModulePageBackground.vue";
|
||||
@@ -147,7 +150,7 @@ import {
|
||||
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";
|
||||
import { handleBackPress, openPage, returnTo } from "@/utils/navigation/gateway.js";
|
||||
|
||||
const PEDIGREE_PAGE_SIZE = 5;
|
||||
const genealogyId = ref("");
|
||||
@@ -169,6 +172,10 @@ let skipNextShowRefresh = true;
|
||||
const compareMembers = (left, right) =>
|
||||
Number(left.generation) - Number(right.generation) ||
|
||||
String(left.id).localeCompare(String(right.id));
|
||||
const isVerticalPedigreeText = (value) =>
|
||||
/^[\u3400-\u4dbf\u4e00-\u9fff\uf900-\ufaff·〇零一二三四五六七八九十百千万]+$/.test(
|
||||
String(value || ""),
|
||||
);
|
||||
const generationRows = computed(() => {
|
||||
const groups = new Map();
|
||||
members.value.forEach((member) => {
|
||||
@@ -283,6 +290,9 @@ const toTree = () => {
|
||||
if (selectedId.value) params.selectedId = selectedId.value;
|
||||
return returnTo("T01", params);
|
||||
};
|
||||
onBackPress((event) =>
|
||||
detailVisible.value ? handleBackPress(event, closeMemberDetail) : false,
|
||||
);
|
||||
const handleStateAction = () => {
|
||||
if (treeState.value === "empty") {
|
||||
return openPage(
|
||||
@@ -302,7 +312,7 @@ const loadPedigree = async (query = {}) => {
|
||||
query.genealogyId || genealogyContext.getCurrentGenealogyId() || "",
|
||||
);
|
||||
selectedId.value = String(query.selectedId || "");
|
||||
if (!genealogyId.value) {
|
||||
if (!/^[1-9]\d*$/.test(genealogyId.value)) {
|
||||
members.value = [];
|
||||
treeState.value = "empty";
|
||||
return;
|
||||
@@ -361,6 +371,8 @@ onUnload(() => {
|
||||
}
|
||||
.pedigree-stage {
|
||||
min-height: 0;
|
||||
padding-bottom: env(safe-area-inset-bottom);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.pedigree-layout {
|
||||
display: grid;
|
||||
@@ -428,6 +440,14 @@ onUnload(() => {
|
||||
font-size: clamp(19px, 34rpx, 24px);
|
||||
font-weight: 700;
|
||||
}
|
||||
.member-node__name--horizontal {
|
||||
padding: 12rpx 8rpx;
|
||||
line-height: 1.2;
|
||||
overflow-wrap: anywhere;
|
||||
text-align: center;
|
||||
text-orientation: mixed;
|
||||
writing-mode: horizontal-tb;
|
||||
}
|
||||
.pedigree-column__detail,
|
||||
.member-node__copy {
|
||||
align-items: flex-start;
|
||||
@@ -550,7 +570,7 @@ onUnload(() => {
|
||||
.pedigree-state-card__action {
|
||||
display: grid;
|
||||
width: 360rpx;
|
||||
min-height: 70rpx;
|
||||
min-height: var(--app-touch-min);
|
||||
margin: 22rpx auto 0;
|
||||
}
|
||||
.pedigree-state-card__action image,
|
||||
|
||||
Reference in New Issue
Block a user