diff --git a/.gitignore b/.gitignore
index febdb18..99a1ebe 100644
--- a/.gitignore
+++ b/.gitignore
@@ -59,3 +59,6 @@ sitemap.xml
/.vite/
/design-pipeline/generated/
/tmp-g01-icon-audit.png
+/tmp-mumu-current.png
+/artifacts/
+/docs/audit-*/
diff --git a/App.vue b/App.vue
index febd78f..fb2fadf 100644
--- a/App.vue
+++ b/App.vue
@@ -1,9 +1,5 @@
diff --git a/components/ModulePageBackground.vue b/components/ModulePageBackground.vue
index 76ca72e..fab7f4a 100644
--- a/components/ModulePageBackground.vue
+++ b/components/ModulePageBackground.vue
@@ -7,7 +7,7 @@
@@ -48,6 +48,7 @@ const source = computed(() => sources[props.module] || sources.profile);
bottom: 0;
left: 0;
width: 100%;
+ height: 100%;
opacity: 0.36;
}
.module-page-background--family .module-page-background__image {
diff --git a/components/family/VerticalVideoViewer.vue b/components/family/VerticalVideoViewer.vue
new file mode 100644
index 0000000..f059969
--- /dev/null
+++ b/components/family/VerticalVideoViewer.vue
@@ -0,0 +1,208 @@
+
+
+
+
+
+
+
+
+
+ {{ video.title }}
+ {{ video.description }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/components/genealogy/InvitationManager.vue b/components/genealogy/InvitationManager.vue
index 4683aec..f892a22 100644
--- a/components/genealogy/InvitationManager.vue
+++ b/components/genealogy/InvitationManager.vue
@@ -374,12 +374,18 @@ onUnmounted(() => {
min-width: 0;
flex: 1;
}
+.invitation-manager__row .app-button {
+ width: auto;
+ min-width: 120rpx;
+ flex: 0 0 auto;
+}
.invitation-manager__row text:first-child {
color: $ink;
font-family: "STKaiti", "KaiTi", serif;
font-size: clamp(16px, 28rpx, 21px);
font-weight: 700;
- overflow-wrap: anywhere;
+ overflow-wrap: break-word;
+ word-break: break-word;
}
.issued-invitation__token {
box-sizing: border-box;
diff --git a/components/genealogy/PageBackground.vue b/components/genealogy/PageBackground.vue
index aaf1dc0..4870cd7 100644
--- a/components/genealogy/PageBackground.vue
+++ b/components/genealogy/PageBackground.vue
@@ -3,7 +3,7 @@
@@ -25,6 +25,7 @@
left: 0;
display: block;
width: 100%;
+ height: 100%;
opacity: 0.28;
}
diff --git a/components/records/GrowthRecordDetailDialog.vue b/components/records/GrowthRecordDetailDialog.vue
index c2bd108..f3886fe 100644
--- a/components/records/GrowthRecordDetailDialog.vue
+++ b/components/records/GrowthRecordDetailDialog.vue
@@ -26,6 +26,7 @@
:label="protectionSubmitting ? '正在验证' : '解锁并查看'"
@click="unlockContent"
/>
+
{{ detailError }}
@@ -33,18 +34,26 @@
关联人物:{{ detailRecord?.personName || "未关联人物" }}
记录类型:{{ detailRecord?.typeLabel || "未填写" }}
记录日期:{{ detailRecord?.recordDate || "未填写" }}
+ 创建时间:{{ formatMinuteTimestamp(detailRecord.createTime) }}
提醒时间:{{ detailRecord.remindTime }}
{{ detailRecord?.content || "未填写记录内容" }}
-
+
+
+
+
{{ detailError }}
+
diff --git a/pages/family/albums.vue b/pages/family/albums.vue
index a68d514..8b1ce11 100644
--- a/pages/family/albums.vue
+++ b/pages/family/albums.vue
@@ -42,6 +42,7 @@
{{ item.name }}
{{ item.description }}
{{ item.photoCount }} 张照片
+ 创建于 {{ formatMinuteTimestamp(item.createTime) }}
{
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;
}
diff --git a/pages/family/article-detail.vue b/pages/family/article-detail.vue
index 4745761..af49156 100644
--- a/pages/family/article-detail.vue
+++ b/pages/family/article-detail.vue
@@ -2,7 +2,7 @@
@@ -12,6 +12,15 @@
description="请稍候,正在同步谱文正文。"
/>
+
{{
article.category
}}
@@ -27,13 +36,17 @@
这篇谱文已设置内容密码
+
{{ protectionError }}
{{
article.contentProtected && !article.contentUnlocked ? "" : article.content || "作者暂未填写正文。"
}}
阅读 {{ article.viewCount }} 次
-
+
{{ protectionError }}
+
+
+
diff --git a/pages/family/videos.vue b/pages/family/videos.vue
index 8ad2922..4a46486 100644
--- a/pages/family/videos.vue
+++ b/pages/family/videos.vue
@@ -6,7 +6,7 @@
title="家族视频"
:action="pageState === 'list' ? '发布' : ''"
custom-back
- @back="returnToFamily"
+ @back="requestBack"
@action="openPublishForm"
/>
@@ -64,6 +64,7 @@
已上传:{{ coverReceipt.fileName || "视频封面" }}
+
{{
coverUploadError
@@ -106,6 +107,7 @@
+
@@ -127,12 +129,25 @@
+
-
+
{{ video.title }}
{{
video.description
@@ -152,6 +167,7 @@
@click="openEditVideo(video)"
/>
+
+
+
+
+
{{
@@ -174,31 +195,125 @@
+
+
+
+ 正在回复 {{ replyTarget.author }}
+
+
+
+
+ {{ videoCommentError }}
+
+
+
+
diff --git a/pages/genealogy/application-review.vue b/pages/genealogy/application-review.vue
index 7021dd9..9c9ed88 100644
--- a/pages/genealogy/application-review.vue
+++ b/pages/genealogy/application-review.vue
@@ -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; }
diff --git a/pages/genealogy/capability-center.vue b/pages/genealogy/capability-center.vue
new file mode 100644
index 0000000..b8624ad
--- /dev/null
+++ b/pages/genealogy/capability-center.vue
@@ -0,0 +1,114 @@
+
+
+
+
+
+
+
+
+
+
+ {{ error }}
+ {{ completenessError || '资料完整度暂时无法读取。' }}
+
+ 资料完成度 {{ completeness?.completionRate || 0 }}%
+ 已完成 {{ completeness?.completedCount || 0 }} / {{ completeness?.totalCount || 0 }} 项
+ 这部家谱的基础资料已补充完整。
+ {{ item.displayText }}待补充
+
+ {{ recycleError }}
+
+ 已删除内容
+ 可恢复的内容会显示“恢复”按钮。
+ 回收站暂时没有内容。
+
+ {{ item.resourceTitle || '未命名内容' }}{{ item.resourceType }} · {{ item.deletedAt || '删除时间未知' }}
+
+ {{ recycleDisabledReason(item.disabledReason) }}
+
+
+
+
+
+
+
+
+
diff --git a/pages/genealogy/create.vue b/pages/genealogy/create.vue
index 700741c..ce0a49a 100644
--- a/pages/genealogy/create.vue
+++ b/pages/genealogy/create.vue
@@ -8,7 +8,7 @@
立谱信息
为家族创建一部家谱
创建成功后会回到“我的家谱”;可在世系树中录入首位成员。创建时会同步建立始迁祖人物;创建成功后会回到“我的家谱”。
@@ -84,6 +84,37 @@
/>
+
+ *始迁祖
+
+
+ {{
+ fieldErrors.firstAncestorName
+ }}
+
+
+
+ 谱主本人就是始迁祖
+ 仅本人确为始迁祖时开启,开启后账号会绑定到该人物。
+
+
+
+
所在地
+ 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;
diff --git a/pages/genealogy/generation-poems.vue b/pages/genealogy/generation-poems.vue
index 13f2a76..e13c0f6 100644
--- a/pages/genealogy/generation-poems.vue
+++ b/pages/genealogy/generation-poems.vue
@@ -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%;
+ }
+}
diff --git a/pages/genealogy/join-application.vue b/pages/genealogy/join-application.vue
index 5b2cf25..18a97fb 100644
--- a/pages/genealogy/join-application.vue
+++ b/pages/genealogy/join-application.vue
@@ -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;
}
diff --git a/pages/genealogy/members.vue b/pages/genealogy/members.vue
index 920d1f1..b2c4f81 100644
--- a/pages/genealogy/members.vue
+++ b/pages/genealogy/members.vue
@@ -89,6 +89,13 @@
label="移出家谱"
@click="openConfirmation('remove', member)"
/>
+
{{ operationError }}
+
+
+
+
+
+ 当前没有可授权的权限项。
+
+ {{ permissionError }}
+
@@ -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();
});
@@ -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; }
diff --git a/pages/genealogy/my-applications.vue b/pages/genealogy/my-applications.vue
index 850fd1f..7e97070 100644
--- a/pages/genealogy/my-applications.vue
+++ b/pages/genealogy/my-applications.vue
@@ -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; }
diff --git a/pages/genealogy/my-genealogies.vue b/pages/genealogy/my-genealogies.vue
index 459ec90..b80b8d5 100644
--- a/pages/genealogy/my-genealogies.vue
+++ b/pages/genealogy/my-genealogies.vue
@@ -28,7 +28,7 @@
mode="aspectFit"
/>
暂时无法读取家谱
- 网络或服务暂不可用,请稍后重新查看。
+ {{ genealogyListError }}
+
+
+
+ 宣传视频
+ 家谱文化与使用介绍
+
+
+
+
+
+
+
+ 宣传视频暂时无法显示
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 暂时没有推荐视频
+
+
+
+
创建家谱
+
确认没有现有家谱后再创建,避免重复建谱
@@ -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;
}
diff --git a/pages/genealogy/overview.vue b/pages/genealogy/overview.vue
index fde2f0e..7716fbc 100644
--- a/pages/genealogy/overview.vue
+++ b/pages/genealogy/overview.vue
@@ -160,6 +160,30 @@
mode="aspectFit"
/>
+
+
+
+ 重要证件
+ 集中查看家谱证件档案
+
+
+
+
+
+
+ 资料与回收站
+ 查看资料完整度,恢复误删的家谱内容
+
+
+
成员身份
@@ -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();
diff --git a/pages/genealogy/search.vue b/pages/genealogy/search.vue
index 8c8afee..d29d282 100644
--- a/pages/genealogy/search.vue
+++ b/pages/genealogy/search.vue
@@ -2,7 +2,7 @@
@@ -45,8 +45,41 @@
公开家谱以下是可加入的公开家谱。输入谱名或姓氏,查找可以申请加入的公开家谱。
+
+ 查找公开家谱
+
+
+
+
+ {{ searchResultCopy }}
+
+
暂未找到公开家谱
+
+ 没有找到匹配的公开家谱
+ 可尝试缩短关键词,或改用姓氏查找。
+
+
-
+
{{ item.name }}{{ item.surname }}氏{{ item.memberCount }} 位成员
@@ -101,7 +139,7 @@
diff --git a/pages/profile/change-password.vue b/pages/profile/change-password.vue
index bbe49bd..a246c6a 100644
--- a/pages/profile/change-password.vue
+++ b/pages/profile/change-password.vue
@@ -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] = ''"
/>
- {{ passwordVisible[field.key] ? "隐藏" : "显示" }}{{ passwordVisible[field.key] ? "隐藏" : "显示" }}
- {{
+ {{
errors[field.key]
}}
@@ -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;
diff --git a/pages/profile/change-phone.vue b/pages/profile/change-phone.vue
index 55f16fb..9bc70de 100644
--- a/pages/profile/change-phone.vue
+++ b/pages/profile/change-phone.vue
@@ -16,6 +16,22 @@
>完成安全验证和短信校验后,即可更新你的登录手机号。
+
+
+ 当前密码
+
+
+ {{ errors.currentPassword }}
+
新手机号
@@ -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"
/>
- {{ errors.phone }}
+ {{ errors.phone }}
@@ -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 后重试` : '获取验证码' }}
- {{ errors.smsCode }}
+ {{ errors.smsCode }}
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;
diff --git a/pages/profile/compliance-document.vue b/pages/profile/compliance-document.vue
index 3ad5140..01717b0 100644
--- a/pages/profile/compliance-document.vue
+++ b/pages/profile/compliance-document.vue
@@ -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;
+ }
+}
diff --git a/pages/profile/earnings.vue b/pages/profile/earnings.vue
index 18ce51c..9e59bd7 100644
--- a/pages/profile/earnings.vue
+++ b/pages/profile/earnings.vue
@@ -84,7 +84,11 @@
{{ recordTimeLabel(withdrawal.createTime) }}
{{ withdrawalStatusLabel(withdrawal.withdrawalStatus) }}
+ 提现单号:{{ withdrawal.withdrawalNo }}
收款人:{{ withdrawal.payoutAccountName || '未填写' }}
+ 审核备注:{{ withdrawal.auditRemark }}
+ 打款参考号:{{ withdrawal.payoutReference }}
+ 到账时间:{{ recordTimeLabel(withdrawal.paidAt) }}
原因:{{ withdrawal.failureReason }}
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;
+ }
+}
+
diff --git a/pages/profile/edit-profile.vue b/pages/profile/edit-profile.vue
index 1b8a5b5..5761e00 100644
--- a/pages/profile/edit-profile.vue
+++ b/pages/profile/edit-profile.vue
@@ -109,14 +109,27 @@
+
@@ -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;
diff --git a/pages/profile/feedback.vue b/pages/profile/feedback.vue
index f7d3aa5..fd977ab 100644
--- a/pages/profile/feedback.vue
+++ b/pages/profile/feedback.vue
@@ -122,7 +122,7 @@
class="history-card"
>
- {{ feedbackTypeLabel(feedback.feedbackType) }} · {{ feedbackReference(feedback) }}
+ {{ feedbackTypeLabel(feedback) }} · {{ feedbackReference(feedback) }}
{{ feedbackStatusLabel(feedback.handleStatus) }}
- 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();
});
@@ -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;
diff --git a/pages/profile/help.vue b/pages/profile/help.vue
index 8a9bb22..b48bdae 100644
--- a/pages/profile/help.vue
+++ b/pages/profile/help.vue
@@ -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;
diff --git a/pages/profile/home.vue b/pages/profile/home.vue
index a806981..fcc01d5 100644
--- a/pages/profile/home.vue
+++ b/pages/profile/home.vue
@@ -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;
+ }
+}
+
diff --git a/pages/profile/promotions.vue b/pages/profile/promotions.vue
index a42e5d2..ba11b29 100644
--- a/pages/profile/promotions.vue
+++ b/pages/profile/promotions.vue
@@ -11,6 +11,31 @@
家谱服务推荐
这里会展示家谱相关服务和活动。
+ {{ operationFeedback }}
+
+
+ 邀请家人注册
+ 推荐关系由注册接口一次性确认,前端不会在注册后补绑。
+
+
+
+ {{ referralError || "推荐码暂时无法读取。" }}
+
+
+
+ {{ referralProfile.referralCode }}
+ 已成功邀请 {{ referralProfile.referredUserCount }} 人
+ {{ referralProfile.shareDescription || "家人通过此链接注册后,系统会记录推荐关系。" }}
+
+
+
+
+
+
+
+ {{ referralProfile.disabledReason || "推荐功能暂未开放。" }}
+
+
@@ -24,7 +49,6 @@
后续正式发布的家谱服务推荐会在这里展示。
- {{ operationFeedback }}
{{ item.title }}
{{ item.description }}
-
+
@@ -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();
});
@@ -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;
+ }
}
diff --git a/pages/profile/security.vue b/pages/profile/security.vue
index 3abb568..0e6db9b 100644
--- a/pages/profile/security.vue
+++ b/pages/profile/security.vue
@@ -21,9 +21,6 @@
>账号编号{{ profile.userNo || "未提供" }}
- 这里显示可查看的账号信息,其他安全信息暂不支持查看。
+
+ {{ wechatBindingMessage }}
@@ -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();
});
@@ -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;
+ }
+}
diff --git a/pages/profile/settings.vue b/pages/profile/settings.vue
index 41615fc..8886599 100644
--- a/pages/profile/settings.vue
+++ b/pages/profile/settings.vue
@@ -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;
diff --git a/pages/profile/vip.vue b/pages/profile/vip.vue
index e862d9d..c1ff0a5 100644
--- a/pages/profile/vip.vue
+++ b/pages/profile/vip.vue
@@ -36,6 +36,27 @@
可选套餐
{{ capability.enabled ? "购买资格已开放" : "当前仅供查看" }}
+
+ 支付方式
+
+
+
+ 当前安装包没有可用支付通道,请完成打包配置后再购买。
+
@@ -46,6 +67,13 @@
¥{{ item.price }}
原价 ¥{{ item.originalPrice }}
+
当前没有可展示的套餐
@@ -60,7 +88,9 @@
{{ item.packageName }}
- {{ item.paidAt || item.expiresAt }}
+ 订单号:{{ item.orderNo }}
+ 支付时间:{{ item.paidAt }}
+ 到期时间:{{ item.expiresAt }}
¥{{ item.amount }}
@@ -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();
});
@@ -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;
+ }
}
diff --git a/pages/records/ceremonies.vue b/pages/records/ceremonies.vue
index ce70840..4fc50b3 100644
--- a/pages/records/ceremonies.vue
+++ b/pages/records/ceremonies.vue
@@ -35,9 +35,16 @@
:key="item.id"
class="ceremony-card"
@click="openCeremony(item)"
- >{{ item.title }}{{ item.typeLabel }}{{ item.time ? ` · ${item.time}` : "" }}创建于 {{ formatMinuteTimestamp(item.createTime) }}{{ item.description }}{{ item.giftCount }} 笔献礼 {
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;
}
diff --git a/pages/records/ceremony-detail.vue b/pages/records/ceremony-detail.vue
index 5503fb5..6252bf6 100644
--- a/pages/records/ceremony-detail.vue
+++ b/pages/records/ceremony-detail.vue
@@ -26,6 +26,15 @@
+
{{ detail.title }}
{{ detail.typeLabel }}{{ detail.time ? ` · ${detail.time}` : "" }}
地点:{{ detail.location || detail.locationAddress }}
@@ -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 {
diff --git a/pages/records/ceremony-editor.vue b/pages/records/ceremony-editor.vue
index 176b42c..a416f5a 100644
--- a/pages/records/ceremony-editor.vue
+++ b/pages/records/ceremony-editor.vue
@@ -107,6 +107,7 @@
已上传:{{ coverFileName }}
+
{{ uploadError }}
{{ submitError }}
@@ -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;
diff --git a/pages/records/growth-journal.vue b/pages/records/growth-journal.vue
index d6f66ea..5f52db7 100644
--- a/pages/records/growth-journal.vue
+++ b/pages/records/growth-journal.vue
@@ -13,7 +13,7 @@
{{ isEdit ? '编辑成长记录' : '新建成长记录' }}
- {{ isEdit ? '原有图片和相关设置会保留。' : '红色 * 为必填项,其余内容可按需补充。' }}
+ {{ isEdit ? '原有图片、视频和相关设置会保留,也可以逐项移除。' : '红色 * 为必填项,其余内容可按需补充。' }}
关联人物
相关图片相关图片或视频图片上传成功后,会随这条记录一起保存。媒体上传成功后,会随这条记录一起保存。
-
-
+
+
+
+ 正在上传媒体…
+ 已上传:{{ receipt.fileName || "图片" }}
+ 已上传:{{ receipt.fileName || "媒体文件" }}
+
+
{{ uploadError }}
{{ error }}
@@ -184,6 +193,7 @@
>{{ growthRecordTypeLabel(item)
}}{{ item.date ? ` · ${item.date}` : "" }}创建于 {{ formatMinuteTimestamp(item.createTime) }}{{ item.content }}
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;
diff --git a/pages/records/life-events.vue b/pages/records/life-events.vue
index 0d07df7..f055b51 100644
--- a/pages/records/life-events.vue
+++ b/pages/records/life-events.vue
@@ -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; }
diff --git a/pages/records/memos.vue b/pages/records/memos.vue
index 3f3ac80..66c70f8 100644
--- a/pages/records/memos.vue
+++ b/pages/records/memos.vue
@@ -3,7 +3,7 @@
- {{ isEdit ? '编辑家族备忘' : '新建家族备忘' }}
+ {{ isEdit ? `编辑${recordName}` : `新建${recordName}` }}
{{ isEdit ? '原有关联图片、完成状态和排序会随本次保存保留。' : '红色 * 为必填项,其余内容可按需补充。' }}
*备忘标题*{{ isBenefactorMode ? "恩人姓名" : "备忘标题" }}
备忘内容{{ isBenefactorMode ? "恩人事迹" : "备忘内容" }}
@@ -50,7 +50,7 @@
相关图片图片上传成功后,会随备忘一起保存。图片上传成功后,会随{{ recordName }}一起保存。
暂时无法打开家族备忘暂时无法打开{{ recordName }}
暂时无法读取家族备忘暂时无法读取{{ recordName }}
还没有家族备忘还没有{{ recordName }}
备忘已保存。{{ recordName }}已保存。
{{ deleteError }}
{{ item.title }}
{{ item.remindTime }}
+ 创建于 {{ formatMinuteTimestamp(item.createTime) }}
{{ item.content }}
@@ -125,29 +126,30 @@
-
+
{{ detailError }}
提醒时间:{{ detailTarget?.remindTime || "未设置" }}
完成状态:{{ detailTarget?.completed === "1" ? "已完成" : "未完成" }}
- {{ detailTarget?.content || "未填写备忘内容" }}
+ 创建时间:{{ formatMinuteTimestamp(detailTarget.createTime) }}
+ {{ detailTarget?.content || `未填写${recordName}内容` }}
-
+
/^[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;
diff --git a/pages/records/merit-records.vue b/pages/records/merit-records.vue
index a740667..d6d966f 100644
--- a/pages/records/merit-records.vue
+++ b/pages/records/merit-records.vue
@@ -59,13 +59,27 @@
}}
+
+
+ 相关图片
+ 图片上传成功后,会随这条功德记录一起保存。
+
+
+
+ 已上传:{{ receipt.fileName || "图片" }}
+
+
+ {{ uploadError }}
+
{{ error }}
@@ -98,6 +112,7 @@
{{ item.title }}
{{ item.donor }}{{ item.typeLabel ? ` · ${item.typeLabel}` : "" }}
{{ item.time }}
+ 创建于 {{ formatMinuteTimestamp(item.createTime) }}
{{ item.content }}
@@ -136,8 +151,20 @@
捐赠人:{{ detailTarget?.donor || "未署名" }}
功德类型:{{ detailTarget?.typeLabel || "未填写" }}
记录时间:{{ detailTarget?.time || "未填写" }}
+ 创建时间:{{ formatMinuteTimestamp(detailTarget.createTime) }}
金额:¥{{ detailTarget?.amount || "0.00" }}
{{ detailTarget?.content || "未填写记录内容" }}
+
+
+
@@ -154,9 +181,9 @@
/>
/^[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();
});
@@ -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; }
diff --git a/pages/records/people.vue b/pages/records/people.vue
index 721f7d2..3310276 100644
--- a/pages/records/people.vue
+++ b/pages/records/people.vue
@@ -55,7 +55,7 @@
class="people-primary-action"
type="secondary"
block
- :label="loadingMore ? '正在加载…' : '加载更多人物'"
+ :label="loadingMore ? '正在加载…' : loadMoreError ? '加载失败,重新加载' : '加载更多人物'"
@click="loadMore"
/>
@@ -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;
diff --git a/pages/records/person-detail.vue b/pages/records/person-detail.vue
index 1ad66b3..304e66f 100644
--- a/pages/records/person-detail.vue
+++ b/pages/records/person-detail.vue
@@ -32,15 +32,15 @@
-
+
{{ item.title }}{{ item.copy || "未填写" }}
+ {{ item.title }}
+ {{ item.copy || "未填写" }}
+
{
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);
+ }
}
diff --git a/pages/records/person-documents.vue b/pages/records/person-documents.vue
new file mode 100644
index 0000000..49c3bbd
--- /dev/null
+++ b/pages/records/person-documents.vue
@@ -0,0 +1,96 @@
+
+
+
+
+
+
+
+
+ 暂时无法打开重要证件
+ 未找到家谱信息,请返回后重新进入。
+
+
+ 家谱证件档案
+ 集中查看当前家谱中有权访问的重要证件;新增证件仍从对应人物资料进入。
+
+
+
+
+
+
+
+
+
+
diff --git a/pages/records/relative-record-editor.vue b/pages/records/relative-record-editor.vue
index 1be0fa5..e79e45e 100644
--- a/pages/records/relative-record-editor.vue
+++ b/pages/records/relative-record-editor.vue
@@ -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);
diff --git a/pages/records/relative-records.vue b/pages/records/relative-records.vue
index 6bc02c5..0708351 100644
--- a/pages/records/relative-records.vue
+++ b/pages/records/relative-records.vue
@@ -6,7 +6,7 @@
title="贺礼簿"
:action="valid ? '新建' : ''"
custom-back
- @back="returnToFamily"
+ @back="requestBack"
@action="createRelative"
/>
@@ -32,12 +32,20 @@
{{ deleteError }}
+
{{ item.name }}{{ item.relation
}}{{ item.event ? ` · ${item.event}` : "" }}{{ item.time }}创建于 {{ formatMinuteTimestamp(item.createTime) }}{{ item.content }}
@@ -76,6 +84,7 @@
关系:{{ detailTarget?.relation || "未填写" }}
事项:{{ detailTarget?.event || "未填写" }}
时间:{{ detailTarget?.time || "未填写" }}
+ 创建时间:{{ formatMinuteTimestamp(detailTarget.createTime) }}
金额:{{ detailTarget?.amount ? `¥${detailTarget.amount}` : "未填写" }}
{{ detailTarget?.content || "未填写记录内容" }}
@@ -86,9 +95,9 @@
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));