feat: 完成前端业务闭环与后端联调

This commit is contained in:
2026-08-24 23:37:56 +08:00
parent c59e36f933
commit 9ad572907b
175 changed files with 10711 additions and 1740 deletions
+3
View File
@@ -59,3 +59,6 @@ sitemap.xml
/.vite/
/design-pipeline/generated/
/tmp-g01-icon-audit.png
/tmp-mumu-current.png
/artifacts/
/docs/audit-*/
+1 -5
View File
@@ -1,9 +1,5 @@
<script>
export default {
onLaunch() {
console.log('家谱 App 已启动')
}
}
export default {}
</script>
<style lang="scss">
+22
View File
@@ -0,0 +1,22 @@
{
"version" : "1",
"prompt" : "template",
"title" : "服务条款和隐私政策",
"message" : "请审慎阅读并充分理解<a href=\"https://app.ddxcjp.cn/h5/#/pages/content/detail?id=62\">《用户协议》</a>和<a href=\"https://app.ddxcjp.cn/h5/#/pages/content/detail?id=63\">《隐私政策》</a>。同意后,我们将按照协议约定为你提供家谱服务。",
"buttonAccept" : "同意并继续",
"buttonRefuse" : "暂不同意",
"hrefLoader" : "default",
"backToExit" : true,
"second" : {
"title" : "确认退出",
"message" : "使用应用前需要同意<a href=\"https://app.ddxcjp.cn/h5/#/pages/content/detail?id=62\">《用户协议》</a>和<a href=\"https://app.ddxcjp.cn/h5/#/pages/content/detail?id=63\">《隐私政策》</a>。不同意将退出应用。",
"buttonAccept" : "同意并继续",
"buttonRefuse" : "退出应用"
},
"disagreeMode" : {
"support" : false,
"loadNativePlugins" : false,
"visitorEntry" : false,
"showAlways" : false
}
}
@@ -0,0 +1,281 @@
<template>
<AppDialog
:visible="visible"
eyebrow="内容密码"
title="找回内容密码"
confirm-text="关闭"
:close-on-mask="!isBusy"
@confirm="requestClose"
@cancel="requestClose"
>
<view class="recovery-dialog">
<AppLoading v-if="capabilityState === 'loading'" text="正在核对找回方式" />
<template v-else-if="capabilityState === 'ready' && capability.enabled">
<text class="recovery-dialog__copy"
>验证码将发送至当前账号绑定手机号 {{ capability.maskedPhone }}验证通过后可设置新的内容密码</text
>
<view class="recovery-dialog__code-row">
<input
v-model.trim="smsCode"
type="number"
maxlength="4"
placeholder="4位短信验证码"
/>
<AppButton
compact
type="secondary"
:disabled="isBusy || cooldownSeconds > 0"
:label="codeButtonLabel"
@click="sendRecoveryCode"
/>
</view>
<input
v-model="newPassword"
password
maxlength="128"
placeholder="新的内容密码(8至128位)"
/>
<input
v-model="confirmedPassword"
password
maxlength="128"
placeholder="再次输入新的内容密码"
/>
<AppButton
block
:disabled="isBusy"
:label="resetting ? '正在重置' : '验证并重置密码'"
@click="resetContentPassword"
/>
</template>
<view v-else class="recovery-dialog__state">
<text>{{ capabilityError || capability.disabledReason || "当前内容暂不支持密码找回。" }}</text>
<AppButton
v-if="capabilityState === 'error'"
compact
type="secondary"
label="重试"
@click="loadCapability"
/>
</view>
<text v-if="formError" class="recovery-dialog__error" role="alert">{{ formError }}</text>
<text v-if="resultNotice" class="recovery-dialog__notice" role="status">{{ resultNotice }}</text>
<text class="recovery-dialog__security"
>服务端必须校验当前账号资源查看权限和短信票据并对发送与重置操作限流审计</text
>
</view>
</AppDialog>
</template>
<script setup>
import { computed, onUnmounted, reactive, ref, watch } from "vue";
import AppButton from "@/components/AppButton.vue";
import AppDialog from "@/components/AppDialog.vue";
import AppLoading from "@/components/AppLoading.vue";
import { contentPasswordRecoveryApi } from "@/services/api/content-password-recovery-service.js";
import {
createRequestController,
isRequestCancelled,
} from "@/services/api/request-controller.js";
import { getRequestErrorMessage } from "@/services/api/request-error-message.js";
import { createNonIdempotentWriteGuard } from "@/utils/non-idempotent-write-guard.js";
const props = defineProps({
visible: Boolean,
genealogyId: { type: String, required: true },
resourceType: { type: String, required: true },
resourceId: { type: String, required: true },
});
const emit = defineEmits(["close", "complete", "busy-change"]);
const capabilityState = ref("idle");
const capability = reactive({
enabled: false,
maskedPhone: "",
disabledReason: "",
});
const capabilityError = ref("");
const formError = ref("");
const resultNotice = ref("");
const smsCode = ref("");
const newPassword = ref("");
const confirmedPassword = ref("");
const cooldownSeconds = ref(0);
const sendingCode = ref(false);
const resetting = ref(false);
const capabilityController = createRequestController();
const codeController = createRequestController();
const resetController = createRequestController();
const resetGuard = createNonIdempotentWriteGuard();
let cooldownTimer = null;
let componentActive = true;
const isBusy = computed(() => sendingCode.value || resetting.value);
const codeButtonLabel = computed(() =>
sendingCode.value
? "正在发送"
: cooldownSeconds.value > 0
? `${cooldownSeconds.value}s 后重发`
: "发送验证码",
);
watch(isBusy, (busy) => emit("busy-change", busy), { immediate: true });
const stopCooldown = () => {
if (cooldownTimer) clearInterval(cooldownTimer);
cooldownTimer = null;
};
const startCooldown = (seconds) => {
stopCooldown();
cooldownSeconds.value = seconds;
if (seconds <= 0) return;
cooldownTimer = setInterval(() => {
cooldownSeconds.value = Math.max(0, cooldownSeconds.value - 1);
if (cooldownSeconds.value === 0) stopCooldown();
}, 1000);
};
const resetForm = () => {
smsCode.value = "";
newPassword.value = "";
confirmedPassword.value = "";
formError.value = "";
resultNotice.value = "";
};
const loadCapability = async () => {
capabilityController.abort();
capabilityState.value = "loading";
capabilityError.value = "";
try {
const result = await contentPasswordRecoveryApi.getCapability(
props.genealogyId,
props.resourceType,
props.resourceId,
{ requestController: capabilityController },
);
if (!componentActive || !props.visible) return;
Object.assign(capability, result);
startCooldown(result.cooldownSeconds);
capabilityState.value = "ready";
} catch (error) {
if (!componentActive || !props.visible || isRequestCancelled(error)) return;
capabilityError.value = getRequestErrorMessage(error, "找回方式暂时无法读取,请稍后重试。");
capabilityState.value = "error";
}
};
const sendRecoveryCode = async () => {
if (!capability.enabled || sendingCode.value || resetting.value || cooldownSeconds.value > 0) return;
sendingCode.value = true;
formError.value = "";
try {
const delivery = await contentPasswordRecoveryApi.sendCode(
props.genealogyId,
props.resourceType,
props.resourceId,
{ requestController: codeController },
);
if (!componentActive || !props.visible) return;
startCooldown(delivery.cooldownSeconds);
resultNotice.value = `验证码已发送至 ${capability.maskedPhone}`;
} catch (error) {
if (componentActive && props.visible && !isRequestCancelled(error)) {
formError.value = getRequestErrorMessage(error, "验证码发送失败,请稍后重试。");
}
} finally {
if (componentActive) sendingCode.value = false;
}
};
const resetContentPassword = async () => {
if (!capability.enabled || isBusy.value) return;
if (!/^\d{4}$/.test(smsCode.value)) {
formError.value = "请输入4位短信验证码。";
return;
}
if (newPassword.value.length < 8 || newPassword.value.length > 128) {
formError.value = "请输入8至128位新的内容密码。";
return;
}
if (newPassword.value !== confirmedPassword.value) {
formError.value = "两次输入的新密码不一致。";
return;
}
const payload = { smsCode: smsCode.value, newPassword: newPassword.value };
const resetAttempt = resetGuard.begin(payload);
if (resetAttempt === null) {
formError.value = "上次重置结果待确认,请先关闭窗口并尝试使用新密码解锁。";
return;
}
resetting.value = true;
formError.value = "";
try {
await contentPasswordRecoveryApi.resetPassword(
props.genealogyId,
props.resourceType,
props.resourceId,
payload,
{ requestController: resetController },
);
if (!componentActive || !props.visible) return;
emit("complete", payload.newPassword);
emit("close");
resetForm();
} catch (error) {
const isOutcomeUnknown = resetGuard.recordFailure(resetAttempt, error);
if (!componentActive || !props.visible) return;
if (isOutcomeUnknown) {
emit("complete", payload.newPassword);
formError.value = "重置结果待确认,请关闭窗口后尝试使用新密码解锁,不要重复提交。";
return;
}
if (isRequestCancelled(error)) return;
formError.value = getRequestErrorMessage(error, "内容密码重置失败,请检查验证码后重试。");
} finally {
if (componentActive) resetting.value = false;
}
};
const requestClose = () => {
if (!isBusy.value) emit("close");
};
watch(
() => props.visible,
(visible) => {
if (!visible) {
capabilityController.abort();
codeController.abort();
resetController.abort();
stopCooldown();
resetForm();
capabilityState.value = "idle";
return;
}
resetForm();
void loadCapability();
},
);
onUnmounted(() => {
componentActive = false;
stopCooldown();
capabilityController.abort();
codeController.abort();
resetController.abort();
});
</script>
<style scoped lang="scss">
.recovery-dialog { width: 100%; margin-top: 16rpx; text-align: left; }
.recovery-dialog__copy,
.recovery-dialog__security,
.recovery-dialog__state text,
.recovery-dialog__error,
.recovery-dialog__notice { display: block; color: $ink-muted; font-size: clamp(13px, 22rpx, 16px); line-height: 1.55; }
.recovery-dialog input { width: 100%; min-height: var(--app-touch-min); margin-top: 16rpx; padding: 0 18rpx; box-sizing: border-box; border: 1rpx solid rgba(142, 95, 41, .3); border-radius: 8rpx; background: rgba(255, 255, 255, .68); }
.recovery-dialog__code-row { display: flex; align-items: center; margin-top: 16rpx; gap: 12rpx; }
.recovery-dialog__code-row input { min-width: 0; flex: 1; margin-top: 0; }
.recovery-dialog__code-row .app-button { width: auto; flex: 0 0 auto; }
.recovery-dialog > .app-button { margin-top: 20rpx; }
.recovery-dialog__state .app-button { width: auto; margin-top: 16rpx; }
.recovery-dialog__error { margin-top: 14rpx; color: $brand-red; }
.recovery-dialog__notice { margin-top: 14rpx; color: #426b58; }
.recovery-dialog__security { margin-top: 18rpx; padding-top: 14rpx; border-top: 1rpx solid rgba(142, 95, 41, .18); font-size: clamp(12px, 19rpx, 14px); }
</style>
+2 -1
View File
@@ -7,7 +7,7 @@
<image
class="module-page-background__image"
:src="source"
mode="widthFix"
mode="aspectFill"
/>
</view>
</template>
@@ -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 {
+208
View File
@@ -0,0 +1,208 @@
<template>
<view v-if="visible" class="vertical-video-viewer">
<view class="vertical-video-viewer__header">
<button aria-label="关闭上下滑动播放" @click="close">返回</button>
<text>{{ title }}</text>
<text>{{ activeIndex + 1 }}/{{ videos.length }}</text>
</view>
<swiper
class="vertical-video-viewer__swiper"
vertical
:current="activeIndex"
:duration="260"
@change="changeVideo"
>
<swiper-item v-for="(video, index) in videos" :key="video.id">
<view class="vertical-video-viewer__slide">
<video
:id="videoElementId(video)"
class="vertical-video-viewer__player"
:src="video.videoFile.accessUrl"
:poster="video.coverFile?.accessUrl || ''"
:autoplay="visible && index === activeIndex"
loop
controls
object-fit="contain"
/>
<view class="vertical-video-viewer__summary">
<text class="vertical-video-viewer__title">{{ video.title }}</text>
<text v-if="video.description" class="vertical-video-viewer__description">{{ video.description }}</text>
<view class="vertical-video-viewer__actions">
<button :disabled="actionBusy" @click="requestLike(video)">
{{ video.likedByCurrentUser ? "已赞" : "点赞" }} {{ video.likeCount || 0 }}
</button>
<button :disabled="actionBusy" @click="requestComments(video)">
评论 {{ video.commentCount || 0 }}
</button>
</view>
</view>
</view>
</swiper-item>
</swiper>
</view>
</template>
<script setup>
import { nextTick, ref, watch } from "vue";
const props = defineProps({
visible: { type: Boolean, default: false },
videos: { type: Array, default: () => [] },
initialVideoId: { type: [String, Number], default: "" },
title: { type: String, default: "视频播放" },
actionBusy: { type: Boolean, default: false },
});
const emit = defineEmits(["close", "like", "comments"]);
const activeIndex = ref(0);
const videoElementId = (video) => `vertical-video-${String(video.id)}`;
const pauseVideo = (index) => {
const video = props.videos[index];
if (!video) return;
uni.createVideoContext(videoElementId(video))?.pause();
};
const playVideo = (index) => {
const video = props.videos[index];
if (!video) return;
uni.createVideoContext(videoElementId(video))?.play();
};
const resolveInitialIndex = () => {
const requestedId = String(props.initialVideoId || "");
const requestedIndex = props.videos.findIndex(
(video) => String(video.id) === requestedId,
);
return requestedIndex >= 0 ? requestedIndex : 0;
};
watch(
() => props.visible,
async (visible) => {
if (!visible) {
pauseVideo(activeIndex.value);
return;
}
activeIndex.value = resolveInitialIndex();
await nextTick();
playVideo(activeIndex.value);
},
);
const changeVideo = async (event) => {
const nextIndex = Number(event?.detail?.current ?? 0);
if (!Number.isInteger(nextIndex) || nextIndex < 0 || nextIndex >= props.videos.length) return;
pauseVideo(activeIndex.value);
activeIndex.value = nextIndex;
await nextTick();
playVideo(activeIndex.value);
};
const close = () => {
pauseVideo(activeIndex.value);
emit("close");
};
const requestLike = (video) => emit("like", video);
const requestComments = (video) => {
pauseVideo(activeIndex.value);
emit("comments", video);
};
</script>
<style scoped lang="scss">
.vertical-video-viewer {
position: fixed;
z-index: 40;
inset: 0;
display: flex;
flex-direction: column;
color: #fff;
background: #080808;
}
.vertical-video-viewer__header {
display: grid;
min-height: 88rpx;
padding: calc(12rpx + env(safe-area-inset-top)) 24rpx 12rpx;
grid-template-columns: 120rpx 1fr 120rpx;
align-items: center;
background: rgba(0, 0, 0, 0.82);
text-align: center;
}
.vertical-video-viewer__header button,
.vertical-video-viewer__actions button {
min-height: 64rpx;
border: 1rpx solid rgba(255, 255, 255, 0.35);
border-radius: 32rpx;
color: #fff;
background: rgba(0, 0, 0, 0.42);
font-size: 26rpx;
line-height: 1;
}
.vertical-video-viewer__header button {
padding: 0 20rpx;
justify-self: start;
}
.vertical-video-viewer__header text:nth-child(2) {
overflow: hidden;
font-weight: 700;
text-overflow: ellipsis;
white-space: nowrap;
}
.vertical-video-viewer__header text:last-child {
justify-self: end;
color: rgba(255, 255, 255, 0.72);
}
.vertical-video-viewer__swiper,
.vertical-video-viewer__slide {
min-height: 0;
flex: 1;
}
.vertical-video-viewer__slide {
position: relative;
display: flex;
height: 100%;
align-items: center;
background: #080808;
}
.vertical-video-viewer__player {
width: 100%;
height: 100%;
}
.vertical-video-viewer__summary {
position: absolute;
right: 0;
bottom: 0;
left: 0;
padding: 80rpx 30rpx calc(28rpx + env(safe-area-inset-bottom));
background: linear-gradient(transparent, rgba(0, 0, 0, 0.86));
pointer-events: none;
}
.vertical-video-viewer__summary text {
display: block;
}
.vertical-video-viewer__title {
font-size: 34rpx;
font-weight: 700;
}
.vertical-video-viewer__description {
display: -webkit-box !important;
overflow: hidden;
margin-top: 12rpx;
color: rgba(255, 255, 255, 0.82);
font-size: 26rpx;
line-height: 1.55;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
}
.vertical-video-viewer__actions {
display: flex;
margin-top: 22rpx;
gap: 16rpx;
pointer-events: auto;
}
.vertical-video-viewer__actions button {
min-width: 150rpx;
padding: 0 24rpx;
}
@media (prefers-reduced-motion: reduce) {
.vertical-video-viewer__swiper { scroll-behavior: auto; }
}
</style>
+7 -1
View File
@@ -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;
+2 -1
View File
@@ -3,7 +3,7 @@
<image
class="genealogy-page-background__art"
src="/static/assets/modules/genealogy/opaque/genealogy-page-background-long.png"
mode="widthFix"
mode="aspectFill"
/>
</view>
</template>
@@ -25,6 +25,7 @@
left: 0;
display: block;
width: 100%;
height: 100%;
opacity: 0.28;
}
@@ -26,6 +26,7 @@
:label="protectionSubmitting ? '正在验证' : '解锁并查看'"
@click="unlockContent"
/>
<button class="detail-recovery-link" @click="passwordRecoveryVisible = true">忘记内容密码</button>
<text v-if="detailError" class="detail-error">{{ detailError }}</text>
</template>
<template v-else>
@@ -33,18 +34,26 @@
<text>关联人物{{ detailRecord?.personName || "未关联人物" }}</text>
<text>记录类型{{ detailRecord?.typeLabel || "未填写" }}</text>
<text>记录日期{{ detailRecord?.recordDate || "未填写" }}</text>
<text v-if="detailRecord?.createTime">创建时间{{ formatMinuteTimestamp(detailRecord.createTime) }}</text>
<text v-if="detailRecord?.remindTime">提醒时间{{ detailRecord.remindTime }}</text>
<text class="detail-content__body">{{ detailRecord?.content || "未填写记录内容" }}</text>
<view v-if="detailRecord?.mediaFiles?.length" class="detail-media">
<template v-for="file in detailRecord.mediaFiles" :key="file.fileId">
<video
v-if="isVideoFile(file)"
:src="file.accessUrl"
controls
:aria-label="file.fileName || '播放成长记录视频'"
/>
<image
v-for="file in detailRecord.mediaFiles"
:key="file.fileId"
v-else
:src="file.accessUrl"
mode="aspectFill"
role="button"
aria-label="查看成长记录图片"
@click="previewMedia(file)"
/>
</template>
</view>
<view v-if="detailRecord?.canManageProtection" class="detail-protection-actions">
<AppButton
@@ -87,6 +96,15 @@
/>
<text v-if="detailError" class="detail-error">{{ detailError }}</text>
</AppDialog>
<ContentPasswordRecoveryDialog
:visible="passwordRecoveryVisible"
:genealogy-id="genealogyId"
resource-type="GROWTH_RECORD"
:resource-id="String(detailRecord?.id || '')"
@close="passwordRecoveryVisible = false"
@complete="completePasswordRecovery"
@busy-change="passwordRecoveryBusy = $event"
/>
</template>
<script setup>
@@ -94,6 +112,8 @@ import { computed, onUnmounted, ref, watch } from "vue";
import AppButton from "@/components/AppButton.vue";
import AppDialog from "@/components/AppDialog.vue";
import AppLoading from "@/components/AppLoading.vue";
import { formatMinuteTimestamp } from "@/utils/display-time.js";
import ContentPasswordRecoveryDialog from "@/components/ContentPasswordRecoveryDialog.vue";
import {
createRequestController,
isRequestCancelled
@@ -117,6 +137,8 @@ const contentPassword = ref("");
const protectionSubmitting = ref(false);
const protectionVisible = ref(false);
const protectionMode = ref("set");
const passwordRecoveryVisible = ref(false);
const passwordRecoveryBusy = ref(false);
const growthDetailReadRequestController = createRequestController();
const growthDetailUnlockRequestController = createRequestController();
const growthProtectionMutationRequestController = createRequestController();
@@ -136,7 +158,7 @@ const protectionConfirmText = computed(() => {
return protectionMode.value === "disable" ? "确认关闭" : "确认保存";
});
const isBusy = computed(() =>
detailState.value === "loading" || protectionSubmitting.value,
detailState.value === "loading" || protectionSubmitting.value || passwordRecoveryBusy.value,
);
const hasTransient = computed(() =>
Boolean(detailRecord.value) || protectionVisible.value,
@@ -155,6 +177,7 @@ const clearDetailState = () => {
detailError.value = "";
contentPassword.value = "";
protectionVisible.value = false;
passwordRecoveryVisible.value = false;
};
const applyRecordDetail = (recordDetail) => {
detailRecord.value = {
@@ -231,6 +254,10 @@ const unlockContent = async () => {
if (isCurrentRecord(recordId)) protectionSubmitting.value = false;
}
};
const completePasswordRecovery = (newPassword) => {
contentPassword.value = newPassword;
detailError.value = "内容密码已重置,请点击“解锁并查看”确认。";
};
const openProtection = (mode) => {
if (!detailRecord.value?.canManageProtection || protectionSubmitting.value) return;
@@ -315,6 +342,11 @@ const close = () => {
return true;
};
const closeTransient = () => {
if (passwordRecoveryVisible.value) {
if (passwordRecoveryBusy.value) return false;
passwordRecoveryVisible.value = false;
return true;
}
if (protectionVisible.value) {
closeProtection();
return !protectionSubmitting.value;
@@ -323,11 +355,17 @@ const closeTransient = () => {
};
const previewMedia = (file) => {
const urls = detailRecord.value?.mediaFiles
?.filter((mediaFile) => !isVideoFile(mediaFile))
?.map((mediaFile) => mediaFile.accessUrl)
.filter(Boolean) || [];
if (!file?.accessUrl || !urls.length || typeof uni?.previewImage !== "function") return;
uni.previewImage({ current: file.accessUrl, urls });
};
const isVideoFile = (file) => {
const mediaType = String(file?.mediaType || "").toLowerCase();
const fileName = String(file?.fileName || "");
return mediaType === "video" || mediaType.startsWith("video/") || /\.(mp4|mov|m4v|webm|avi|mkv)$/i.test(fileName);
};
defineExpose({ closeTransient, open });
@@ -365,7 +403,8 @@ onUnmounted(() => {
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 10rpx;
}
.detail-media image {
.detail-media image,
.detail-media video {
width: 100%;
height: 150rpx;
border-radius: 8rpx;
@@ -383,6 +422,8 @@ onUnmounted(() => {
color: $ink;
font-size: clamp(14px, 22rpx, 17px);
}
.detail-recovery-link { display: block; min-height: var(--app-touch-min); margin: 4rpx auto 0; padding: 0 16rpx; border: 0; background: transparent; color: $brand-red; font-size: clamp(13px, 21rpx, 16px); }
.detail-recovery-link::after { border: 0; }
.detail-protection-actions {
display: flex;
flex-wrap: wrap;
+77 -36
View File
@@ -22,7 +22,7 @@
label="新建证件档案"
@click="openDocumentCreate"
/>
<text v-if="!documents.length" class="document-dialog-empty">这位成员还没有可查看的重要证件</text>
<text v-if="!documents.length" class="document-dialog-empty">{{ personId ? "这位成员还没有可查看的重要证件" : "当前家谱还没有可查看的重要证件" }}</text>
<view
v-for="documentSummary in documents"
:key="documentSummary.documentId"
@@ -34,7 +34,7 @@
<view>
<text>{{ documentSummary.documentTitle }}</text>
<text
>{{ documentTypeLabel(documentSummary.documentType) }}{{
>{{ documentSummary.lineagePersonName ? `${documentSummary.lineagePersonName} · ` : "" }}{{ documentTypeLabel(documentSummary.documentType) }}{{
documentSummary.maskedIdentifier
? ` · ${documentSummary.maskedIdentifier}`
: ""
@@ -56,6 +56,7 @@
<view class="document-unlock-action" role="button" aria-label="解锁重要证件" @click="unlockDocument">
<text>{{ documentState === 'submitting' ? '正在解锁…' : '解锁并查看' }}</text>
</view>
<button class="document-recovery-link" @click="passwordRecoveryVisible = true">忘记内容密码</button>
</template>
<template v-else-if="documentDialogMode === 'detail' && selectedDocument">
<view class="document-detail-row"><text>证件类型</text><text>{{ documentTypeLabel(selectedDocument.documentType) }}</text></view>
@@ -71,12 +72,12 @@
<text>{{ resourceUsageLabel(resource.usageType) }}</text>
<view class="document-resource-actions">
<button @click="openDocumentResource(resource)">查看</button>
<button v-if="selectedDocument.canEdit && member?.canManageDocuments" @click="replaceDocumentResource(resource)">替换</button>
<button v-if="selectedDocument.canEdit && member?.canManageDocuments" @click="requestDeleteDocumentResource(resource)">删除</button>
<button v-if="selectedDocument.canEdit" @click="replaceDocumentResource(resource)">替换</button>
<button v-if="selectedDocument.canEdit" @click="requestDeleteDocumentResource(resource)">删除</button>
</view>
</view>
<text v-if="documentError" class="document-dialog-error">{{ documentError }}</text>
<view v-if="member?.canManageDocuments" class="document-manage-actions">
<view v-if="selectedDocument.canEdit || selectedDocument.canDelete" class="document-manage-actions">
<AppButton v-if="selectedDocument.canEdit" compact label="编辑资料" @click="openDocumentEdit" />
<AppButton v-if="selectedDocument.canEdit" compact type="secondary" label="添加文件" @click="addDocumentResource" />
<AppButton
@@ -164,6 +165,15 @@
@confirm="disableDocumentPassword"
@cancel="documentPasswordDisableVisible = false"
/>
<ContentPasswordRecoveryDialog
:visible="passwordRecoveryVisible"
:genealogy-id="genealogyId"
resource-type="PERSON_DOCUMENT"
:resource-id="String(selectedDocument?.documentId || '')"
@close="passwordRecoveryVisible = false"
@complete="completePasswordRecovery"
@busy-change="passwordRecoveryBusy = $event"
/>
</view>
</template>
@@ -172,11 +182,12 @@ import { computed, onUnmounted, reactive, ref, watch } from "vue";
import AppButton from "@/components/AppButton.vue";
import AppDialog from "@/components/AppDialog.vue";
import AppLoading from "@/components/AppLoading.vue";
import ContentPasswordRecoveryDialog from "@/components/ContentPasswordRecoveryDialog.vue";
import {
PERSON_DOCUMENT_RESOURCE_USAGE,
PERSON_DOCUMENT_RESOURCE_USAGE_LABELS,
PERSON_DOCUMENT_TYPE_OPTIONS as documentTypeOptions
PERSON_DOCUMENT_RESOURCE_USAGE_LABELS
} from "@/services/api/person-document-contract.js";
import { businessDictionaryApi } from "@/services/api/business-dictionary-service.js";
import {
createRequestController,
isRequestCancelled
@@ -188,7 +199,7 @@ import { createNonIdempotentWriteGuard } from "@/utils/non-idempotent-write-guar
const props = defineProps({
genealogyId: { type: String, required: true },
personId: { type: String, required: true },
personId: { type: String, default: "" },
member: { type: Object, default: null },
});
const emit = defineEmits(["busy-change", "transient-change"]);
@@ -197,6 +208,7 @@ const documentDialogVisible = ref(false);
const documentDialogMode = ref("list");
const documentState = ref("idle");
const documents = ref([]);
const documentTypeOptions = ref([]);
const selectedDocument = ref(null);
const documentPassword = ref("");
const documentPasswordConfirm = ref("");
@@ -205,6 +217,8 @@ const documentError = ref("");
const documentDeleteVisible = ref(false);
const documentResourceDeleteTarget = ref(null);
const documentPasswordDisableVisible = ref(false);
const passwordRecoveryVisible = ref(false);
const passwordRecoveryBusy = ref(false);
const documentUploadReceipt = ref(null);
const documentForm = reactive({
documentType: "id_card",
@@ -213,6 +227,7 @@ const documentForm = reactive({
description: "",
});
const documentListRequestController = createRequestController();
const documentTypeRequestController = createRequestController();
const documentDetailRequestController = createRequestController();
const documentDraftUploadRequestController = createRequestController();
const documentSaveRequestController = createRequestController();
@@ -239,7 +254,9 @@ const documentDialogTitle = computed(() => {
case "detail":
return selectedDocument.value?.documentTitle || "证件详情";
default:
return `${member.value?.name || "成员"}的重要证件`;
return props.personId
? `${member.value?.name || "成员"}的重要证件`
: "家谱重要证件";
}
});
const documentDialogMessage = computed(() => {
@@ -255,20 +272,16 @@ const documentDialogMessage = computed(() => {
}
});
const documentTypeLabel = (documentType) =>
({
id_card: "身份证",
household_register: "户口簿",
birth_certificate: "出生证明",
marriage_certificate: "结婚证",
other: "其他证件",
})[documentType] || "重要证件";
documentTypeOptions.value.find((option) => option.value === documentType)?.label ||
documents.value.find((document) => document.documentType === documentType)?.documentTypeLabel ||
"重要证件";
const documentTypeLabels = computed(() =>
documentTypeOptions.map((documentType) => documentType.label),
documentTypeOptions.value.map((documentType) => documentType.label),
);
const documentTypeIndex = computed(() =>
Math.max(
0,
documentTypeOptions.findIndex(
documentTypeOptions.value.findIndex(
(documentType) => documentType.value === documentForm.documentType,
),
),
@@ -280,11 +293,12 @@ const hasTransient = computed(() =>
documentDialogVisible.value ||
documentDeleteVisible.value ||
documentResourceDeleteTarget.value ||
documentPasswordDisableVisible.value,
documentPasswordDisableVisible.value ||
passwordRecoveryVisible.value,
),
);
const isBusy = computed(() =>
documentState.value === "loading" || documentState.value === "submitting",
documentState.value === "loading" || documentState.value === "submitting" || passwordRecoveryBusy.value,
);
const shouldIgnoreDocumentFailure = (error) => !isActive || isRequestCancelled(error);
const isCurrentPersonContext = (personId, activeWorkflow) =>
@@ -301,6 +315,7 @@ watch(isBusy, (value) => emit("busy-change", value), { immediate: true });
const resetDocumentWorkflow = () => {
workflowVersion += 1;
documentListRequestController.abort();
documentTypeRequestController.abort();
documentDetailRequestController.abort();
documentDraftUploadRequestController.abort();
documentSaveRequestController.abort();
@@ -315,6 +330,7 @@ const resetDocumentWorkflow = () => {
documentDeleteVisible.value = false;
documentResourceDeleteTarget.value = null;
documentPasswordDisableVisible.value = false;
passwordRecoveryVisible.value = false;
documentDialogMode.value = "list";
documentState.value = "idle";
documents.value = [];
@@ -336,13 +352,22 @@ const open = async () => {
selectedDocument.value = null;
documentAccessToken.value = "";
try {
const documentRows = await personDocumentApi.getPersonDocuments(
const documentQuery = props.personId
? { lineagePersonId: props.personId }
: {};
const [documentRows, typeOptions] = await Promise.all([
personDocumentApi.getPersonDocuments(
props.genealogyId,
{ lineagePersonId: props.personId },
documentQuery,
{ requestController: documentListRequestController },
);
),
businessDictionaryApi.getBusinessDictionaryOptions("gen_person_document_type", {
requestController: documentTypeRequestController,
}),
]);
if (!isCurrentPersonContext(documentPersonId, activeWorkflow)) return;
documents.value = documentRows;
documentTypeOptions.value = typeOptions;
documentState.value = "ready";
} catch (error) {
if (
@@ -363,9 +388,9 @@ const showDocumentList = () => {
documentError.value = "";
};
const openDocumentCreate = () => {
if (!member.value?.canCreateDocument) return;
if (!member.value?.canCreateDocument || !documentTypeOptions.value.length) return;
Object.assign(documentForm, {
documentType: "id_card",
documentType: documentTypeOptions.value.find((option) => option.default)?.value || documentTypeOptions.value[0].value,
documentTitle: "",
maskedIdentifier: "",
description: "",
@@ -376,19 +401,23 @@ const openDocumentCreate = () => {
documentError.value = "";
};
const openDocumentEdit = () => {
if (!selectedDocument.value?.canEdit || !member.value?.canManageDocuments) return;
if (!selectedDocument.value?.canEdit) return;
const currentTypeAvailable = documentTypeOptions.value.some(
(option) => option.value === selectedDocument.value.documentType,
);
Object.assign(documentForm, {
documentType: selectedDocument.value.documentType,
documentType: currentTypeAvailable ? selectedDocument.value.documentType : "",
documentTitle: selectedDocument.value.documentTitle,
maskedIdentifier: selectedDocument.value.maskedIdentifier,
description: selectedDocument.value.description,
});
documentDialogMode.value = "edit";
documentState.value = "ready";
documentError.value = "";
documentError.value = currentTypeAvailable ? "" : "原证件类型已停用,请重新选择。";
};
const selectDocumentType = (event) => {
documentForm.documentType = documentTypeOptions[Number(event.detail.value)]?.value || "id_card";
documentForm.documentType = documentTypeOptions.value[Number(event.detail.value)]?.value || "";
documentError.value = "";
};
const uploadDocumentImage = async () => {
if (documentState.value === "submitting") return;
@@ -527,7 +556,7 @@ const loadDocumentDetail = async (documentId, accessToken) => {
}
};
const updateDocument = async () => {
if (!selectedDocument.value?.canEdit || !member.value?.canManageDocuments || documentState.value === "submitting") return;
if (!selectedDocument.value?.canEdit || documentState.value === "submitting") return;
if (!documentForm.documentTitle.trim()) {
documentError.value = "请填写证件名称。";
return;
@@ -535,13 +564,14 @@ const updateDocument = async () => {
documentState.value = "submitting";
documentError.value = "";
const documentPersonId = props.personId;
const targetPersonId = props.personId || selectedDocument.value.lineagePersonId;
const documentId = selectedDocument.value.documentId;
const activeWorkflow = workflowVersion;
try {
const updatedDocument = await personDocumentApi.updatePersonDocument(
props.genealogyId,
documentId,
{ lineagePersonId: documentPersonId, ...documentForm },
{ lineagePersonId: targetPersonId, ...documentForm },
{ requestController: documentSaveRequestController },
);
if (!isCurrentDocumentContext(documentPersonId, documentId, activeWorkflow)) return;
@@ -600,6 +630,10 @@ const unlockDocument = async () => {
documentError.value = getRequestErrorMessage(error, "密码不正确,请重新输入。");
}
};
const completePasswordRecovery = (newPassword) => {
documentPassword.value = newPassword;
documentError.value = "内容密码已重置,请点击“解锁并查看”确认。";
};
const openDocumentResource = async (resource) => {
if (!selectedDocument.value) return;
const documentPersonId = props.personId;
@@ -651,7 +685,7 @@ const openDocumentResource = async (resource) => {
}
};
const addDocumentResource = async () => {
if (!selectedDocument.value?.canEdit || !member.value?.canManageDocuments || documentState.value === "submitting") return;
if (!selectedDocument.value?.canEdit || documentState.value === "submitting") return;
const documentPersonId = props.personId;
const documentId = selectedDocument.value.documentId;
const activeWorkflow = workflowVersion;
@@ -711,7 +745,7 @@ const addDocumentResource = async () => {
}
};
const replaceDocumentResource = async (resource) => {
if (!selectedDocument.value?.canEdit || !member.value?.canManageDocuments || documentState.value === "submitting") return;
if (!selectedDocument.value?.canEdit || documentState.value === "submitting") return;
documentState.value = "submitting";
documentError.value = "";
const documentPersonId = props.personId;
@@ -753,7 +787,7 @@ const replaceDocumentResource = async (resource) => {
}
};
const requestDeleteDocumentResource = (resource) => {
if (!selectedDocument.value?.canEdit || !member.value?.canManageDocuments) return;
if (!selectedDocument.value?.canEdit) return;
documentResourceDeleteTarget.value = resource;
};
const confirmDeleteDocumentResource = async () => {
@@ -785,7 +819,7 @@ const confirmDeleteDocumentResource = async () => {
}
};
const openDocumentPassword = () => {
if (!selectedDocument.value?.canEdit || !member.value?.canManageDocuments) return;
if (!selectedDocument.value?.canEdit) return;
documentPassword.value = "";
documentPasswordConfirm.value = "";
documentError.value = "";
@@ -861,7 +895,7 @@ const handleDocumentConfirm = () => {
resetDocumentWorkflow();
};
const requestDeleteDocument = () => {
if (!selectedDocument.value?.canDelete || !member.value?.canManageDocuments) return;
if (!selectedDocument.value?.canDelete) return;
documentDeleteVisible.value = true;
};
const confirmDeleteDocument = async () => {
@@ -891,6 +925,11 @@ const confirmDeleteDocument = async () => {
}
};
const closeTransient = () => {
if (passwordRecoveryVisible.value) {
if (passwordRecoveryBusy.value) return false;
passwordRecoveryVisible.value = false;
return true;
}
if (documentResourceDeleteTarget.value) {
documentResourceDeleteTarget.value = null;
return true;
@@ -973,6 +1012,8 @@ onUnmounted(() => {
font-size: clamp(14px, 22rpx, 17px);
}
.document-upload-button::after { border: 0; }
.document-recovery-link { display: block; min-height: var(--app-touch-min); margin: 6rpx auto 0; padding: 0 16rpx; border: 0; background: transparent; color: $brand-red; font-size: clamp(13px, 21rpx, 16px); }
.document-recovery-link::after { border: 0; }
.document-manage-actions {
display: flex;
flex-wrap: wrap;
-98
View File
@@ -1,98 +0,0 @@
const familyFeeds = [
{
feedId: '1', genealogyId: '1001', feedType: '团圆记忆', createTime: '今天 10:24',
feedContent: '今年端午全家相聚,长辈讲起祖居旧事,孩子们也为大家拍下了新的全家福。饭后我们把照片和口述片段整理进家族档案,让这份热闹成为往后仍能翻看的共同记忆。',
publisherNickName: '汤正国', mediaOssIds: '', likedByMe: false, likeCount: 0, commentCount: 2, pinned: '0'
},
{
feedId: '2', genealogyId: '1001', feedType: '家族通知', createTime: '昨天 18:02',
feedContent: '请家人补充老照片中的人物姓名、拍摄时间和地点。无法确认的信息也可以先写下线索,由熟悉往事的长辈共同核对。',
publisherNickName: '谱主', mediaOssIds: '', likedByMe: false, likeCount: 0, commentCount: 0, pinned: '0'
}
]
const familyArticles = [
{
id: '101', genealogyId: '1001', category: '家风家训', title: '孝友传家的日常',
summary: '从敬老、睦亲与守信的小事里,看见家风如何代代相传。', author: '汤文正',
updatedAt: '2024 年 5 月 12 日',
paragraphs: [
'孝友传家,不只在族谱序言里,也在一家人每日的言行中。长辈以宽厚待晚辈,晚辈以耐心照料长辈,亲友之间守信互助,便是最朴素也最长久的家风。',
'勤俭并非一味节省,而是珍惜所得、量入为出,也愿意在家人需要时伸出援手。家中每一代人都可以用自己的方式,把这份分寸与担当继续传下去。',
'敬祖睦宗,最终是为了让今天的家人彼此认识、彼此关心。记录姓名与世代之外,也应留下真实的生活、共同经历和温暖记忆。'
]
},
{
id: '102', genealogyId: '1001', category: '家族往事', title: '祖居门前的那棵桂花树',
summary: '长辈口述的旧居记忆,以及每年中秋一家人相聚的故事。', author: '汤淑华',
updatedAt: '2024 年 5 月 10 日',
paragraphs: [
'祖居门前曾有一棵桂花树。每到中秋,院里都是清甜的香气,远道回来的家人也总能循着那股味道找到家门。',
'后来房屋几经修缮,桂花树仍被大家小心保留下来。它见过孩子长大,也见过长辈把往事一遍遍讲给后来人。'
]
},
{
id: '103', genealogyId: '1001', category: '族谱序言', title: '续修族谱序',
summary: '说明本次续修的缘起、资料来源与共同参与的家人。', author: '谱主',
updatedAt: '2024 年 5 月 8 日',
paragraphs: [
'本次续修以旧谱、碑记、户籍资料和长辈口述为基础,由家人共同核对补充。凡暂不能确认之处,均保留来源和疑问,留待后续查证。',
'愿这份记录不仅理清世系,也能保存家风、人物与共同记忆。'
]
}
]
const familyAlbums = [
{
id: '201', genealogyId: '1001', name: '2024 春节团圆', updatedAt: '今天更新',
description: '三代家人的团圆饭与院前合影', cover: '/static/assets/modules/family/albums/reunion-hero.png',
photos: [
{ id: '20101', src: '/static/assets/modules/family/albums/reunion-hero.png', alt: '春节团圆时三代家人的合影', caption: '除夕团圆 · 2024' },
{ id: '20102', src: '/static/assets/modules/family/albums/family-portrait.png', alt: '家人在院落前的春节合影', caption: '院前合影 · 2024' },
{ id: '20103', src: '/static/assets/modules/family/albums/reunion-table.png', alt: '家人围坐吃年夜饭', caption: '围桌守岁 · 2024' },
{ id: '20104', src: '/static/assets/modules/family/albums/ancestral-home.png', alt: '祖居院落的复古旧照', caption: '祖居旧影 · 1968' },
{ id: '20105', src: '/static/assets/modules/family/albums/ancestral-portrait.png', alt: '老一辈家人在祖居门前的合影', caption: '门前合影 · 1972' }
]
},
{
id: '202', genealogyId: '1001', name: '祖居旧影', updatedAt: '5 月 10 日更新',
description: '祖居、旧物与长辈珍藏的老照片', cover: '/static/assets/modules/family/albums/ancestral-home.png',
photos: [
{ id: '20201', src: '/static/assets/modules/family/albums/ancestral-home.png', alt: '祖居院落的复古旧照', caption: '祖居旧影 · 1968' },
{ id: '20202', src: '/static/assets/modules/family/albums/ancestral-portrait.png', alt: '老一辈家人在祖居门前的合影', caption: '门前合影 · 1972' }
]
},
{
id: '203', genealogyId: '1001', name: '儿童成长', updatedAt: '持续更新',
description: '记录孩子们每一个值得珍藏的瞬间', cover: '/static/assets/modules/family/albums/family-portrait.png',
photos: [
{ id: '20301', src: '/static/assets/modules/family/albums/family-portrait.png', alt: '家人在院落前的春节合影', caption: '院前合影 · 2024' }
]
}
]
const clonePreviewFamilyFeed = (feed) => ({ ...feed })
const clonePreviewFamilyArticle = (article) => ({
...article,
paragraphs: [...article.paragraphs]
})
const clonePreviewFamilyAlbum = (album) => ({
...album,
photoCount: album.photos.length,
photos: album.photos.map((photo) => ({ ...photo }))
})
const listScopedPreviewFamilyContent = (items, clone, genealogyId) => {
const normalizedGenealogyId = String(genealogyId || '')
if (!normalizedGenealogyId) return []
return items
.filter((item) => item.genealogyId === normalizedGenealogyId)
.map(clone)
}
export const listPreviewFamilyFeeds = (genealogyId) =>
listScopedPreviewFamilyContent(familyFeeds, clonePreviewFamilyFeed, genealogyId)
export const listPreviewFamilyArticles = (genealogyId) =>
listScopedPreviewFamilyContent(familyArticles, clonePreviewFamilyArticle, genealogyId)
export const listPreviewFamilyAlbums = (genealogyId) =>
listScopedPreviewFamilyContent(familyAlbums, clonePreviewFamilyAlbum, genealogyId)
-128
View File
@@ -1,128 +0,0 @@
import {
GENEALOGY_ACCESS_PRESET,
} from '../../utils/genealogy/access-policy.js'
const genealogies = [
{
id: '1001',
surname: '汤',
name: '汤氏家谱',
hall: '敦睦堂',
location: '河南·洛阳',
memberCount: 158,
updatedAt: '2024-05-12',
activeCount: 8,
accessPreset: GENEALOGY_ACCESS_PRESET.MEMBER_ONLY,
membership: 'created',
motto: '敦亲睦族,敬祖传家。',
ancestorName: '汤文远',
parentName: '汤氏中华总谱',
branchName: '洛阳主支',
source: '由洛阳汤氏族人整理并维护',
publicDescription: '公开展示家谱身份、地区、堂号与支系信息。'
},
{
id: '1002',
surname: '汤',
name: '汤氏宗谱',
hall: '承志堂',
location: '山东·济宁',
memberCount: 286,
updatedAt: '2024-04-28',
activeCount: 15,
accessPreset: GENEALOGY_ACCESS_PRESET.PUBLIC_APPLY,
membership: 'joined',
motto: '继往开来,世守家风。',
ancestorName: '汤正明',
parentName: '汤氏鲁西总谱',
branchName: '济宁主支',
source: '由济宁汤氏族人整理并维护',
publicDescription: '公开展示家谱身份、地区、堂号与支系信息。'
}
]
// 家谱列表与公开搜索共用预览数据。成员关系只存在于 genealogies
// 公开搜索投影不能携带该字段,避免本地预览误授予管理能力。
const toPublicProjection = ({ membership: _membership, ...genealogy }) => genealogy
const previewPublicGenealogies = [
{
id: '2001',
surname: '汤',
name: '汤氏南阳宗谱',
hall: '敦睦堂',
location: '河南·南阳',
parentName: '汤氏中华总谱',
branchName: '南阳主支',
manager: '管理员 汤文礼',
certification: '资料已认证',
memberCount: 428,
activeCount: 316,
updatedAt: '2026-07-12',
relation: 'available',
source: '由南阳汤氏族人整理并维护',
publicDescription: '公开展示家谱身份、地区、堂号与支系信息;成员资料和世系详情仅向已加入成员开放。',
motto: '敦亲睦族,敬祖传家。',
accessPreset: GENEALOGY_ACCESS_PRESET.PUBLIC_APPLY,
ancestorName: '汤文远'
},
{
...toPublicProjection(genealogies[1]),
manager: '管理员 汤文远',
certification: '资料已认证',
relation: 'joined'
},
{
id: '2003',
surname: '汤',
name: '汤氏济宁宗谱',
hall: '敬宗堂',
location: '山东·济宁',
parentName: '汤氏鲁西总谱',
branchName: '济宁主支',
manager: '管理员 汤正明',
certification: '管理员已实名',
memberCount: 286,
updatedAt: '2026-07-08',
relation: 'pending',
accessPreset: GENEALOGY_ACCESS_PRESET.PUBLIC_APPLY,
},
{
id: '2004',
surname: '汤',
name: '汤氏清河家谱',
hall: '思源堂',
location: '山东·临清',
parentName: '汤氏鲁西总谱',
branchName: '清河支系',
manager: '管理员 汤志成',
certification: '资料已认证',
memberCount: 96,
updatedAt: '2026-07-05',
relation: 'rejected',
accessPreset: GENEALOGY_ACCESS_PRESET.PUBLIC_APPLY,
},
{
id: '2005',
surname: '汤',
name: '汤氏汝南支谱',
hall: '崇本堂',
location: '河南·驻马店',
parentName: '汤氏中原总谱',
branchName: '汝南三支',
manager: '管理员 汤国安',
certification: '管理员已实名',
memberCount: 72,
updatedAt: '2026-07-02',
relation: 'removed',
accessPreset: GENEALOGY_ACCESS_PRESET.PUBLIC_APPLY,
},
{
...toPublicProjection(genealogies[0]),
manager: '创建者 当前用户',
certification: '资料待完善',
relation: 'owned'
}
]
export const listPreviewPublicGenealogies = () =>
previewPublicGenealogies.map((genealogy) => ({ ...genealogy }))
-16
View File
@@ -1,16 +0,0 @@
export const previewCurrentUser = Object.freeze({
id: 1,
name: '汤文远',
phone: '138****1024',
role: '创建者'
})
const previewPendingJoinApplications = [
{ id: '1', genealogyId: '1001', name: '汤志成', phone: '139****6421', relation: '自述为汤正华堂侄', appliedAt: '今天 10:24', status: '0' },
{ id: '2', genealogyId: '1001', name: '汤雨薇', phone: '136****2798', relation: '自述为汤正国之女', appliedAt: '昨天 18:02', status: '0' }
]
export const createPreviewPendingJoinApplications = () =>
previewPendingJoinApplications.map((application) => ({ ...application }))
export const createPreviewMyJoinApplications = () => []
-60
View File
@@ -1,60 +0,0 @@
// 这些数据只用于本地视觉预览,不模拟 OpenAPI 响应。每个实体都携带
// genealogyId,详情必须由家谱与实体 ID 共同定位,调用方只能取得副本。
const previewCeremonies = [
{
ceremonyId: '501', genealogyId: '1001', ceremonyType: '祭祖', ceremonyTitle: '清明祭祖',
ceremonyTime: '2025-04-04', location: '汤氏宗祠',
ceremonyDesc: '缅怀先祖,整理祭扫礼序,并由长辈讲述家族往事。',
invitees: [
{ inviteeUserId: '2001', inviteStatus: '1' },
{ inviteeUserId: '2002', inviteStatus: '0' }
]
},
{
ceremonyId: '502', genealogyId: '1001', ceremonyType: '家宴', ceremonyTitle: '中秋家宴',
ceremonyTime: '2025-09-17', location: '祖居院落',
ceremonyDesc: '家人团聚,共叙近况并整理年度家族影像。',
invitees: [{ inviteeUserId: '2003', inviteStatus: '1' }]
},
{
ceremonyId: '503', genealogyId: '1001', ceremonyType: '团拜', ceremonyTitle: '新春团拜',
ceremonyTime: '2025-01-29', location: '家族礼堂',
ceremonyDesc: '新春相聚,向长辈问安并记录家族近况。', invitees: []
}
]
const previewGrowthRecords = [
{
recordId: '701', genealogyId: '1001', lineagePersonId: '101', recordTitle: '整理第一册旧谱',
recordDate: '1988-03', recordContent: '第一次独立整理家中保存的旧谱与口述线索。'
},
{
recordId: '702', genealogyId: '1001', lineagePersonId: '104', recordTitle: '第一次参与修谱',
recordDate: '2024-06', recordContent: '协助长辈核对照片人物与出生年份。'
}
]
const clonePreviewRecord = (record) => ({
...record,
...(Array.isArray(record.invitees)
? { invitees: record.invitees.map((invitee) => ({ ...invitee })) }
: {})
})
const listScopedPreviewRecords = (records, genealogyId) => {
const normalizedGenealogyId = String(genealogyId || '')
if (!normalizedGenealogyId) return []
return records
.filter((record) => record.genealogyId === normalizedGenealogyId)
.map(clonePreviewRecord)
}
export const listPreviewCeremonies = (genealogyId) =>
listScopedPreviewRecords(previewCeremonies, genealogyId)
export const listPreviewGrowthRecords = (genealogyId, lineagePersonId = '') => {
const records = listScopedPreviewRecords(previewGrowthRecords, genealogyId)
const normalizedPersonId = String(lineagePersonId || '')
return normalizedPersonId
? records.filter((record) => record.lineagePersonId === normalizedPersonId)
: records
}
@@ -17,11 +17,6 @@
{ "id": "app-foundation-transparent-tab-genealogy", "output": "static/assets/foundation/transparent/tab-genealogy.png", "width": 96, "height": 96, "alpha": true, "bytes": 3141, "sha256": "88d5653db7521b846ef81736355e9ccbbb59daf6dfe92c9c2748bef6456af646", "provenance": "committed-binary", "rebuildable": false },
{ "id": "app-foundation-transparent-tab-profile-active", "output": "static/assets/foundation/transparent/tab-profile-active.png", "width": 96, "height": 96, "alpha": true, "bytes": 1973, "sha256": "5ff15db3b4cc64934e4ae9602e604345501c1f86160da10d30d9c0a80ef95d36", "provenance": "committed-binary", "rebuildable": false },
{ "id": "app-foundation-transparent-tab-profile", "output": "static/assets/foundation/transparent/tab-profile.png", "width": 96, "height": 96, "alpha": true, "bytes": 1973, "sha256": "45fc22b367807bdc465e6e17230a10276a4d7e453e27b7cbc064af7fdc255470", "provenance": "committed-binary", "rebuildable": false },
{ "id": "app-modules-family-albums-ancestral-home", "output": "static/assets/modules/family/albums/ancestral-home.png", "width": 1448, "height": 1086, "alpha": true, "bytes": 3370777, "sha256": "b9339cac4fc6e2fe466ae64944140d8b332e019e10985fe900e06c06668391f1", "provenance": "committed-binary", "rebuildable": false },
{ "id": "app-modules-family-albums-ancestral-portrait", "output": "static/assets/modules/family/albums/ancestral-portrait.png", "width": 1448, "height": 1086, "alpha": true, "bytes": 3227971, "sha256": "4b541960557d0caab15084348f1ecde5a9f93f7f791e1a7e881deb7a74e8496e", "provenance": "committed-binary", "rebuildable": false },
{ "id": "app-modules-family-albums-family-portrait", "output": "static/assets/modules/family/albums/family-portrait.png", "width": 1448, "height": 1086, "alpha": true, "bytes": 4129091, "sha256": "dd221049eb552c098dfe8177e9ac8d4185b9865d7390f745ca91742b700bc68b", "provenance": "committed-binary", "rebuildable": false },
{ "id": "app-modules-family-albums-reunion-hero", "output": "static/assets/modules/family/albums/reunion-hero.png", "width": 1672, "height": 940, "alpha": true, "bytes": 4110597, "sha256": "5b296d9254580b3565539813daacf74edaa5471495a3b30b06b512a5b2153dc9", "provenance": "committed-binary", "rebuildable": false },
{ "id": "app-modules-family-albums-reunion-table", "output": "static/assets/modules/family/albums/reunion-table.png", "width": 1448, "height": 1086, "alpha": true, "bytes": 3765758, "sha256": "b241176cd3080e7b038b6b84e7cd3cb30f423c175df8cf2a78dc0cff1709e6eb", "provenance": "committed-binary", "rebuildable": false },
{ "id": "app-modules-family-transparent-family-letter-card", "output": "static/assets/modules/family/transparent/family-letter-card.png", "width": 2003, "height": 581, "alpha": true, "bytes": 1513491, "sha256": "a3686f99e32cdc255c0fc130294aab1974392d66bc8a7991daeb8f67849015ee", "provenance": "committed-binary", "rebuildable": false },
{ "id": "app-modules-family-transparent-module-content-frame", "output": "static/assets/modules/family/transparent/module-content-frame.png", "width": 2003, "height": 581, "alpha": true, "bytes": 1513491, "sha256": "a3686f99e32cdc255c0fc130294aab1974392d66bc8a7991daeb8f67849015ee", "provenance": "committed-binary", "rebuildable": false },
{ "id": "app-modules-family-transparent-module-field-frame", "output": "static/assets/modules/family/transparent/module-field-frame.png", "width": 2132, "height": 443, "alpha": true, "bytes": 1065770, "sha256": "bdc65f33e1dbe05ae75562519b0080e2e5cdbe2dba720a6ac49b56b99be75ceb", "provenance": "committed-binary", "rebuildable": false },
@@ -8,12 +8,12 @@
{ "id": "shared-scroll-secondary-v3", "output": "static/assets/foundation/transparent/scroll-secondary.png", "width": 1866, "height": 300, "alpha": true, "bytes": 661030, "sha256": "52b8c16968e96cf457b227a3026ea9aed2b2a7b3d01ad56d409eb6b7504fc195", "provenance": "committed-binary", "rebuildable": false },
{ "id": "shared-scroll-toast-v3", "output": "static/assets/foundation/transparent/scroll-toast.png", "width": 1770, "height": 246, "alpha": true, "bytes": 468063, "sha256": "e08bdcb1ab3707762306bc23386f91562798d4efd5a5a917c3e9958fcfa7ddfc", "provenance": "committed-binary", "rebuildable": false },
{ "id": "shared-scroll-dialog-v3", "output": "static/assets/modules/auth/transparent/dialog-scroll.png", "width": 1860, "height": 1560, "alpha": true, "bytes": 355747, "sha256": "dfd0b122447e3a33737a4788efd7be35a2fca937ce600b1e8b14d12c7a9b77fa", "provenance": "committed-binary", "rebuildable": false },
{ "id": "genealogy-page-background-long", "output": "static/assets/modules/genealogy/opaque/genealogy-page-background-long.png", "width": 1440, "height": 3600, "alpha": false, "bytes": 5756868, "sha256": "61b0d1324467e44a8eaaf9fc9609058bae153ef51a91f0be9f9a6ea95175760b", "provenance": "committed-binary", "rebuildable": false },
{ "id": "tree-page-background-long", "output": "static/assets/modules/tree/opaque/tree-page-background-long.png", "width": 1440, "height": 3600, "alpha": false, "bytes": 5298030, "sha256": "de71ce6640a9d67c76d187a82a93ba12a2dbe4c6e1c5aa7eee2b617085967e5f", "provenance": "committed-binary", "rebuildable": false },
{ "id": "family-page-background-long", "output": "static/assets/modules/family/opaque/family-page-background-long.png", "width": 1440, "height": 3600, "alpha": false, "bytes": 5387684, "sha256": "a27d12319ec7adad117ab6536c4f0dd38420dfd4c01fd1dee9ea106a2fd1a971", "provenance": "committed-binary", "rebuildable": false },
{ "id": "records-page-background-long", "output": "static/assets/modules/records/opaque/records-page-background-long.png", "width": 1440, "height": 3600, "alpha": false, "bytes": 5295616, "sha256": "ee3455372b3754fcc19b409ee682efcce07ab7628bca382bb8b6ce87f38e7ec4", "provenance": "committed-binary", "rebuildable": false },
{ "id": "notification-page-background-long", "output": "static/assets/modules/notification/opaque/notification-page-background-long.png", "width": 1440, "height": 3600, "alpha": false, "bytes": 4802510, "sha256": "b7bd37b23141b30c4921690d8156576de314f1a53750ae06c0b72fe55dfa6427", "provenance": "committed-binary", "rebuildable": false },
{ "id": "profile-page-background-long", "output": "static/assets/modules/profile/opaque/profile-page-background-long.png", "width": 1440, "height": 3600, "alpha": false, "bytes": 5373757, "sha256": "6b344014aad20f1ecaa14e236fc2aa9febb2ce9f3206fa53eea2523fc5e2094b", "provenance": "committed-binary", "rebuildable": false },
{ "id": "genealogy-page-background-long", "output": "static/assets/modules/genealogy/opaque/genealogy-page-background-long.png", "width": 720, "height": 1800, "alpha": false, "bytes": 2347415, "sha256": "47e0711bced564190e7c5a28989a99f71677785f8e2d8d6cc52bcb014cba190d", "provenance": "committed-binary", "rebuildable": false },
{ "id": "tree-page-background-long", "output": "static/assets/modules/tree/opaque/tree-page-background-long.png", "width": 720, "height": 1800, "alpha": false, "bytes": 2320979, "sha256": "708523b34eb0fdb8a48125bda8f6502bd327b0ebf3bf8da874fa922e084f3b15", "provenance": "committed-binary", "rebuildable": false },
{ "id": "family-page-background-long", "output": "static/assets/modules/family/opaque/family-page-background-long.png", "width": 720, "height": 1800, "alpha": false, "bytes": 2330732, "sha256": "68e8b25335a30e44ec1208ee5db167c059f08309b2c6ec4e51a195190e3a5cee", "provenance": "committed-binary", "rebuildable": false },
{ "id": "records-page-background-long", "output": "static/assets/modules/records/opaque/records-page-background-long.png", "width": 720, "height": 1800, "alpha": false, "bytes": 2366470, "sha256": "232f9fa853041c4af0d55734b35185a43cec9856ba6277ffc8180ea09fb26040", "provenance": "committed-binary", "rebuildable": false },
{ "id": "notification-page-background-long", "output": "static/assets/modules/notification/opaque/notification-page-background-long.png", "width": 720, "height": 1800, "alpha": false, "bytes": 2159835, "sha256": "4519966d0a3c5a31f1e57aa716f810c97914e4cc81796e6b41931496edb0d05e", "provenance": "committed-binary", "rebuildable": false },
{ "id": "profile-page-background-long", "output": "static/assets/modules/profile/opaque/profile-page-background-long.png", "width": 720, "height": 1800, "alpha": false, "bytes": 2375398, "sha256": "e96a696a65a515417f5763592c48e23f75cca32fb651b76d495457c94ddb4f80", "provenance": "committed-binary", "rebuildable": false },
{ "id": "g01-empty-panel-frame", "output": "static/assets/modules/genealogy/transparent/empty-panel-frame.png", "width": 1122, "height": 1402, "alpha": true, "bytes": 74974, "sha256": "cce62602f9dc25cb9ff1ebf555efe20489513db55e2c5d0e3f762a6f11d7aa48", "provenance": "committed-binary", "rebuildable": false }
]
}
@@ -0,0 +1,168 @@
# APP 前后端联调结果与后端处理单
更新时间:2026-08-22
收件人:后端开发、接口维护、测试与部署人员
## 结论
本轮已按后端最新源码 `C:\Users\Rain\Desktop\job\Genealogy`(核对提交 `16600afb57e79569907d673ce6742595a27dfecc`)重新接入前端,并在已登录的 MuMu 模拟器中完成真实点击验证。
微信登录/绑定、VIP 多支付契约、推荐关系、动态权限、内容密码找回、家谱永久删除、动态业务字典和族人敏感资料等能力在最新后端源码中已经存在。2026-08-17 文档中将这些能力标为“后端缺失”的描述已过期,不应继续据此重复开发。
当前已确认 1 个阻断正常功能的后端源码缺陷、2 个既有 OpenAPI 错误、5 项新增业务契约缺口,以及 1 项宣传视频投放数据待配置。此前联调发现的编译、并发取消、VIP capability 字段读取、家谱总览和宣传视频入口问题已处理;参考项目复核发现的订单展示等纯前端问题不列为后端任务。
## MuMu 点击验收表
| 用户路径 | 结果 | 实际表现 | 责任/下一步 |
| --- | --- | --- | --- |
| 我的 → 账号与安全 | 通过 | 登录资料、改密、换绑手机、绑定微信入口正常显示 | 正式微信能力仍需正式签名包和开放平台参数验证 |
| 我的 → 应用推广 | 部分通过 | 推荐码、邀请人数、复制推荐码和系统分享文字正常显示 | 参考项目还有注册链接二维码/复制链接;当前后端未返回可信 `shareUrl` |
| 我的 → 意见反馈 | 通过 | 动态类型“建议/故障/投诉/其他”和历史记录正常显示 | 未提交测试数据,避免污染线上数据 |
| 我的 → VIP 服务 | 部分通过 | 套餐和订单正常显示;服务端禁用购买时显示“VIP购买暂未开放” | 购买成功路径需开启渠道后再测;订单号与双时间缺失属于前端展示问题 |
| 家谱 → 我的家谱 | 通过 | 多个家谱可加载、切换,当前家谱和成员数正常显示 | 无 |
| 家谱 → 家谱总览 | 通过(前端绕开缺陷) | 谱名、地区、成员数、世系数、加入日期正常显示 | 当前临时从 `mine` 列表取得总览资料;后端详情缺陷修复后应恢复详情单一来源 |
| 家谱总览 → 申请审核 | 通过 | 空状态正常显示 | 无 |
| 家谱总览 → 世系树 | 通过 | 3 位人物和关系图正常渲染;人物操作面板可打开 | 无 |
| 世系树 → 人物资料/编辑 | 通过 | 详情和编辑页正常打开 | 未保存修改,避免污染线上数据 |
| 编辑成员 → 学历分类 | 通过 | MuMu 中选择器显示“文盲、私塾、幼儿园、小学……”等动态字典项 | 前端已修复并发请求互相取消问题 |
| 家谱首页 → 宣传视频 | 部分通过 | 首页宣传视频区域正常显示,“查看更多”可进入宣传视频页,不再被路由拦截;`home_featured``video_center` 均为空 | 后端/运营需按下述投放要求配置可用视频和封面后再测播放 |
| 家谱总览 → 家谱设置 | 阻断 | 前端不再无限加载,能进入明确的读取失败状态 | 后端需修复下述 P0 源码缺陷 |
| 家谱首页 → 功德记录图片 | 阻断 | MuMu 选择图片并创建记录成功,但重新打开详情没有图片;测试记录随后已删除 | `AppMeritRecordBody/Vo` 缺媒体字段,后端需补文件关联契约 |
本轮只执行读取、导航、打开选择器等非破坏性点击;没有提交反馈、修改成员、发验证码、绑定微信、购买 VIP、归档或永久删除。
## P0:普通家谱所有者无法读取家谱详情和设置
### 复现
使用普通生命周期 `NORMAL` 的家谱所有者请求:
- `GET /genealogy/app/genealogies/{genealogyId}`
- `GET /genealogy/app/genealogies/{genealogyId}/permanent-deletion/capability`
服务端返回业务错误:`家谱必须先归档`。MuMu 中“家谱设置”因此无法读取。
### 源码原因
1. `AppGenealogyServiceImpl.detail()` 为谱主拼装永久删除能力时调用 `permanentDeletionService.capability()`
2. `GenealogyPermanentDeletionService.capability()` 调用 `requireOwner()`
3. `requireOwner()` 不只校验所有者,还强制生命周期必须为 `ARCHIVED`,否则直接抛错。
4.`GenealogyDeletionEligibilityService` 本身已经能用 `GENEALOGY_NOT_ARCHIVED` 表达“当前不可永久删除”。生命周期不满足应是 capability 的禁用原因,不应让详情和 capability 查询失败。
### 后端修复要求
- 将“所有者鉴权”和“已归档资格”拆开。
- capability 查询:谱主 + 普通家谱应成功返回 `canDeletePermanently=false``disabledReasons` 包含 `GENEALOGY_NOT_ARCHIVED`
- 发码和提交永久删除:继续通过 eligibility 严格拒绝未归档家谱。
- `AppGenealogyServiceImpl.detail()` 对普通谱主必须成功,不能因附加删除能力投影而失败。
- 增加自动化测试:普通谱主详情、普通谱主 capability、归档谱主 capability、非谱主 capability、未归档发码/提交拒绝。
相关源码:
- `ruoyi-modules/ruoyi-genealogy/src/main/java/cn/ddxcjp/genealogy/service/impl/AppGenealogyServiceImpl.java:220`
- `ruoyi-modules/ruoyi-genealogy/src/main/java/cn/ddxcjp/genealogy/service/GenealogyPermanentDeletionService.java:29`
- `ruoyi-modules/ruoyi-genealogy/src/main/java/cn/ddxcjp/genealogy/service/GenealogyPermanentDeletionService.java:72`
## P1:后端 OpenAPI 与 Java 返回对象不一致
### VIP capability 字段
实际 Java VO `VipPaymentMethodCapabilityVo` 和线上响应均返回:
```json
{ "method": "WECHAT", "enabled": false, "disabledReason": "VIP购买暂未开放" }
```
后端自带 `doc/apifox/genealogy-app-openapi.yaml` 却声明 `paymentMethod`,相关 OpenAPI 契约测试也按 `paymentMethod` 断言。前端已按真实 Java 契约统一使用 `method`,仓库内 OpenAPI 副本也已同步。
后端需要把 canonical OpenAPI 和 `FrontendHandoffFinalOpenApiContractTest` 改为 `method`;不要同时返回两个别名。
### 谱文分类写接口响应
后端 OpenAPI 中以下接口的 `200` 响应误写成“APP 微信支付下单参数”并引用 `PaymentOrderVo`
- `POST /genealogy/app/genealogies/{genealogyId}/article-categories`
- `PUT /genealogy/app/genealogies/{genealogyId}/article-categories/{categoryId}`
应改为真实的谱文分类结果 `AppArticleCategoryResult`,并同步契约测试。前端仓库内 OpenAPI 副本已纠正。
## P1:宣传视频投放位当前没有可验收数据
### MuMu 实测
- `GET /genealogy/app/platform-videos?placement=home_featured` 返回空列表,首页只能显示“暂时没有推荐视频”。
- `GET /genealogy/app/platform-videos?placement=video_center` 返回空列表,点击“查看更多”后页面显示“暂时没有可观看的平台视频”。
- 请求成功且不是错误响应,说明前端入口和读取契约已生效,当前缺的是处于有效发布时间范围内的投放数据。
### 后端/运营处理要求
- 至少配置一条 `home_featured` 和一条 `video_center` 数据;同一视频如需同时出现,应按后端投放模型明确配置,不能要求客户端跨投放位猜测。
- 每条数据必须返回可访问的 `videoFile`;建议同时提供 `coverFile`,首页和列表会先显示封面,点击后播放指定视频。
- 确认数据状态、`startAt``endAt` 与当前服务器时间满足可见条件,业务文件访问地址可在 App 端读取。
- 当前 `PlatformVideoVo` 是“视频 + 可选封面”模型,不支持独立的纯图片宣传项。如果产品要求图片也作为可点击宣传内容,需要后端另行定义混合媒体类型、目标行为和唯一响应契约,前端不应把封面伪装成独立图片内容。
### 验收
1. 首页显示最多两条封面,点任一封面直接打开对应视频。
2. “查看更多”显示 `video_center` 封面列表,点封面进入纵向播放器。
3. 视频可播放、上下切换、点赞、评论和返回;过期或停用内容不返回。
## P1:第四轮对比新增的后端契约任务
| 事项 | 当前源码/契约事实 | 后端处理要求 | 联调通过标准 |
| --- | --- | --- | --- |
| 功德记录图片 | `AppMeritRecordBody``AppMeritRecordVo` 没有 `mediaOssIds/mediaFiles`;MuMu 已复现上传后不回显 | 复用业务文件引用,创建/更新接收媒体 ID 集合,列表和详情返回授权文件;明确空数组为清空 | 新增、编辑保留、移除、列表首图、详情预览、回收站权限全部通过 |
| 创建家谱始迁祖 | 前端创建请求已有 `firstAncestorName``AppGenealogyCreateBody` 和创建事务没有该字段 | 在创建家谱事务内原子创建第一代人物;失败整体回滚,避免只建家谱未建人物 | 创建完成后世系树立即出现同名第一代人物;重复提交不产生重复人物 |
| 推广注册链接 | `ReferralMeVo` 只有推荐码、人数、标题和文案,没有可用于二维码/复制的可信链接 | 增加由服务端配置并生成的 `shareUrl`,不要要求前端拼接旧 H5 域名或暴露内部用户 ID | App 可复制链接、生成二维码;扫码进入注册后推荐关系只绑定一次 |
| 封面清空语义 | Java 更新服务可把 `coverOssId` 设为 `null` 并替换文件引用,但 OpenAPI 未声明 nullable,前端规范化会丢弃显式空值 | 统一谱文、礼仪、视频更新契约:明确 `null` 表示移除封面并释放旧引用;同步 OpenAPI 和契约测试 | 有封面的记录执行移除后,详情返回 `coverFile=null`,旧文件引用释放,其他字段不变 |
| 列表记录创建时间 | 参考相册、礼仪、功德、成长记录、贺礼簿和家族恩人列表均显示 `create_time`;当前对应 APP VO 没有 `createTime`,现有业务时间字段不是同一语义 | 先为已确认映射的 `AppAlbumVo/AppCeremonyVo/AppMeritRecordVo/AppGrowthRecordVo/AppRelativeRecordVo` 和 OpenAPI 增加只读 `createTime`;家族恩人确认存储方案后,复用 Memo 时再补 `AppMemoVo.createTime`,独立建模时由唯一新 VO 持有;不要要求客户端提交,也不要用业务时间回填 | 列表和详情均返回稳定时间;新增后非空;编辑业务日期不改变创建时间;客户端可同时显示创建时间和业务时间 |
提现记录已有 `auditRemark/payoutReference/paidAt`,前端会先展示这些现有字段。只有产品明确要求区分“审核时间”和“到账时间”时,后端才需要新增独立 `reviewedAt`;不得把 `paidAt` 改名或冒充审核时间。
## 前端本轮已完成
- 修复 `ceremony-service.js` 导入不存在导出导致 HBuilderX 编译失败。
- VIP capability 按真实后端 VO 的 `method` 字段读取,MuMu 已验证套餐和订单恢复显示。
- 家谱基础列表不再读取由独立永久删除 capability 接口拥有的字段。
- 家谱总览暂时从 `GET /genealogies/mine` 读取当前家谱,避免被后端详情缺陷连带阻断。
- 家谱设置的两条并发读取使用独立取消控制器,修复无限加载。
- 编辑成员的 3 条、添加亲属的 5 条动态字典请求分别使用独立取消控制器,修复选择器空白。
- 族人资料已使用 `zodiacCode``educationCode``deathExpressionCode``relationVariantCode`,遗传病史等敏感资料使用独立 `/sensitive-profile` 契约。
- 微信绑定、推荐资料、权限目录、视频分页评论、内容密码找回、VIP 多支付和永久删除页面已接入最新接口。
- 家谱首页已接入 `home_featured` 两条封面预览,宣传视频列表已改为封面优先展示并支持 `videoId` 直达播放。
## 后端回传验收材料
修复后请提供:
1. 上述 P0 场景的自动化测试结果。
2. 更新后的 canonical APP OpenAPI。
3. 已部署环境版本号或提交号。
4. 普通谱主详情和永久删除 capability 的实际响应样例。
5. `home_featured``video_center` 各至少一条可用宣传视频及封面,由测试环境实际接口返回。
6. 功德图片、始迁祖、推广 `shareUrl`、封面清空语义和五类已确认映射记录 `createTime` 的更新后契约与自动化测试结果;家族恩人契约等待产品选型后另行确认。
收到部署确认后,前端只需再次在 MuMu 点击“家谱设置”,并回归详情、归档、恢复和永久删除能力状态;不会再补旧字段兼容。
## 2026-08-23 运行时复验补充
本轮在用户已登录的 MuMu 中重新实点“家谱总览 → 家谱设置”,首次读取仍进入“家谱设置暂时无法读取”;点击“重新读取”后截图哈希完全相同,说明当前部署环境的 P0 阻断仍然存在。证据见 [当前设置失败](audit-2026-08-23/27-current-settings.png) 和 [重试后状态](audit-2026-08-23/28-current-settings-retry.png)。
同时在参考项目浏览器中确认了以下后端交接需求的真实产品用途:
- 推广 `shareUrl` 用于页面二维码与注册链接分享,不是用推荐码文本可以完全替代的字段。
- 参考列表的 `createTime` 是记录创建时间,不能用礼仪时间、功德时间、提醒时间等业务发生时间冒充;五类已确认映射记录先补,家族恩人随选定契约补。
- 当前重要证件查询能力已足够支持家谱级聚合页,不需要为入口另造接口。
- VIP 的 `orderNo/payTime/expireTime` 均是参考购买记录直接显示的独立字段,前端修复后需要后端继续稳定返回。
### 暂不交给后端开发的产品确认项
参考项目 `pages/index/memorandum/*` 的真实页面名称是“家族恩人”,不是普通“备忘录”;MuMu 实点确认当前入口和表单都是提醒型“家族备忘”,最新后端主业务代码也只有 `Memo`,没有恩人类型。因此业务语义缺口已确认,但后端实现需先由产品选择以下二选一:
1. “家族恩人”是独立家族档案:再由前后端共同定义唯一数据契约、权限和迁移方式。
2. “家族恩人”只是备忘录的一种分类:由 `Memo` 契约增加明确且受校验的业务类型,前端按类型提供入口和文案。
确认前请勿仅按路由英文名把两者合并,也不要先增加猜测字段。参考“贺礼簿”则已确认对应当前更结构化的 `RelativeRecord`/“往来记录”,不需要另建一套后端接口。
完整截图与前后端责任拆分见 [2026-08-23 点击对比审查](click-comparison-audit-2026-08-23.md)。
@@ -0,0 +1,142 @@
# 后端开发对接任务单
> 本文件是 2026-08-17 的历史任务单。最新后端源码已实现其中多项当时缺失的能力;当前有效结论和剩余任务以 [《APP 前后端联调结果与后端处理单(2026-08-22)》](./backend-integration-report-2026-08-22.md) 为准。
更新时间:2026-08-17
收件人:家谱项目后端开发、测试及接口维护人员
## 后端执行结论
请按本任务单完成缺失接口、数据库字段、权限校验和自动化测试。前端页面及调用逻辑已经完成,不需要后端等待前端再次开发;接口实现后可直接联调。
建议执行顺序:
1. P0:族人档案字段、微信登录、VIP 多支付、内容密码找回、家谱永久注销。
2. P1:推荐关系、动态权限目录、平台评论契约收紧。
3. 联调后端已存在的公开家谱搜索、视频评论、平台视频和回收站接口。
接口路径、字段、枚举、必填规则及响应 schema 只以随文提供的 `genealogy-app-openapi.yaml` 为准。后端实现与 OpenAPI 不一致时,应同步修正实现或契约,不能要求前端增加旧字段、snake_case 别名或猜测式兼容代码。
## 1. 结论与契约归属
前端已补齐本轮确认保留的能力。后端尚未提供的能力没有使用假数据或旧接口兼容:页面、入口、表单、提交状态、失败提示、取消请求和严格响应校验均已完成,接口可用后直接进入联调。
`genealogy-app-openapi.yaml` 是本项目 APP 接口的唯一契约所有者。本文件只记录前后端差距、责任和验收方式,不重复定义请求或响应结构;实现字段与枚举一律以 OpenAPI 为准。
核对基线:
- 参考前端:`C:\Users\Rain\Desktop\job\Jiapu-App`
- 当前前端:`C:\Users\Rain\Desktop\job\jiapuapp`
- 当前后端源码:`C:\Users\Rain\Desktop\job\Genealogy`
- 参考项目注册了 78 个活动路由;`video2.nvue``video3.nvue``video4.nvue` 均为有效且可达的视频页,已纳入前端比对。`ancestorsOrder.vue` 是 67 字节空壳,不作为正式功能。
- 当前项目有 59 个有效路由。旧版多页面流程已按当前产品职责合并,因此验收按业务能力和用户路径,不按旧文件数量一一复制。
> “后端源码现状”来自 2026-08-17 静态源码核对。前端侧没有修改、构建或运行后端工程,后端开发完成后需自行执行后端构建与自动化测试。
## 2. 总体差距表
| 优先级 | 业务能力 | 当前前端状态 | 后端源码现状 | 后端下一步 | 联调通过标准 |
| --- | --- | --- | --- | --- | --- |
| P0 | 微信快捷登录 | 已完成授权码登录入口、取消/失败状态、重复提交保护;前端不接收 AppSecret | `AppAuthController` 只有注册、密码登录和短信登录,没有 `/auth/login/wechat` | 按 OpenAPI 新增授权码交换、账号匹配/绑定与冲突响应;正式开放平台参数只放服务端和打包配置 | Android/iOS 正式包完成首次授权、已有账号登录、取消授权、重复登录和账号冲突路径 |
| P0 | VIP 多支付方式 | 已完成微信、支付宝、余额选项;选项完全由 capability 下发;三类支付结果严格分支校验 | capability 只返回 `enabled/disabledReason`;下单体没有 `paymentMethod`;支付响应仍是微信单一形态 | capability 下发支付方式;下单接收 `paymentMethod`;按渠道返回互斥参数;余额支付在服务端原子扣款并开通会员 | 三种支付各走通成功、取消、失败、超时查询;同一订单不能跨渠道重复支付或重复开通 |
| P0 | 族人完整档案 | 新增、编辑、详情展示及校验均完成;遗传病史按服务端能力字段控制 | `LineagePerson/Bo/Vo` 仅已有 `aliasName`,其余新增字段缺失 | 增加数据库列、实体、请求体、VO、映射与服务校验;敏感字段必须服务端鉴权后才返回 | 新增和编辑可回显全部字段;无权限响应不包含遗传病史;旧数据读取不报错 |
| P0 | 内容密码找回 | 谱文、成长记录、重要证件已接入短信验证找回;有发送倒计时、重置状态和未知结果防重 | 已有内容密码保护/解锁/移除和通用认证验证,但没有 recovery 三个 APP 端点 | 复用服务端短信验证能力,实现 capability、发码、重置;只允许资源所有者或获授权管理员操作 | 三类资源均覆盖发码限频、错码、过期码、无权限、成功重置;全链路有审计记录 |
| P0 | 家谱永久注销 | 设置页已完成归档前置、不可用原因、脱敏手机号、家谱名+短信双确认和非幂等未知结果处理 | 后端已有管理员删除引擎与资格检查,但没有 APP 谱主入口;`AppGenealogyVo` 没有注销能力字段 | 用现有删除引擎增加 owner-only APP 包装;投影 capability;成功提交后撤销所有成员家谱上下文 | 非谱主、未归档、名称不符、错码、资金/任务阻塞均拒绝;成功后不可再进入并异步完成清理 |
| P0 | 创建家谱始迁祖 | 创建表单已增加“始迁祖”,请求字段为 `firstAncestorName` | 当前创建家谱请求体和服务没有该字段,也不会原子创建首位世系人物 | 按 OpenAPI 接收字段,并在创建家谱事务内创建对应第一代人物;失败时家谱和人物一起回滚 | 填写始迁祖创建后,世系树立即出现同名第一代人物;重复提交不产生重复人物 |
| P1 | 功德记录图片 | 功德表单已支持图片上传、逐项移除、编辑保留和详情预览;请求 `mediaOssIds`,响应 `mediaFiles` | 当前功德记录请求体和 APP VO 没有媒体字段 | 按 OpenAPI 复用业务文件关联;更新按传入 ID 集合替换关联,空字符串表示清空;列表和详情返回授权后的 `BusinessFileAccess[]` | 新增、编辑、移除和详情均能正确回显;越权和失效文件不可访问;移入回收站后附件权限同步失效 |
| P1 | 推广关系 | 注册页可填写推荐码;个人中心有“我的推荐”、复制和分享入口;全部使用正式响应,不伪造收益 | 注册体没有 `referralCode`,没有推荐资料接口或推荐关系服务 | 注册支持一次性绑定推荐人;新增 `/referrals/me`;落实防自邀、防重复绑定和收益归属幂等 | 首次绑定、无推荐码、自邀、重复绑定、并发注册和推荐资料查询均有自动化测试 |
| P1 | 动态权限项 | 成员管理可从服务端目录渲染分组权限、读取和保存成员授权;前端不硬编码 `auth_str` | 成员权限 GET/PUT 已存在;缺少 `/permission-catalog` | 基于后端唯一权限定义输出当前家谱可授权目录、名称、分组和禁用原因;保存响应返回最终授权集合 | 后端新增权限无需发版即可显示;越权勾选被服务端拒绝;保存后回显与实际鉴权一致 |
| P1 | 家谱视频评论 | 已完成一级评论、一级回复展示/发表、本人或管理员删除;回复入口只允许根评论 | 根评论、回复列表、发表、删除均已存在,字段与前端契约基本一致 | 按 OpenAPI 联调并补自动化契约测试;保持只允许一层回复 | 根评论和直属回复顺序正确;删除权限可信;弱网重复提交不会静默生成多条 |
| P1 | 平台宣传视频 | 已完成列表、播放、点赞、一级评论和删除;链接统一通过业务文件访问层处理 | 列表/详情/点赞/评论均已存在;评论请求仍允许 `parentCommentId` 并在服务内支持回复 | 本期产品决定为平台视频只保留一级评论:删除 `parentCommentId` 输入并清理/迁移已有回复数据 | APP 位置筛选正确;过期内容不返回;点赞幂等;平台评论响应中不存在子回复 |
| P1 | 公开家谱搜索 | 已完成关键词输入、清空、加载/空/错状态及本地二次过滤 | `/genealogies/public` 已支持可选 `keyword` | 无新增接口,按 OpenAPI 联调并确认匿名/登录策略 | 姓氏、谱名、堂号等后端约定字段可查;空关键词恢复列表;分页/数量限制明确 |
| P1 | 相册批量管理 | 已完成批量选择、全选、逐张移至回收站、部分失败保留选择及结果提示 | 单张删除和回收站机制已存在,资源引用可恢复 | 不阻塞上线;如后续数据量需要,再单独设计服务端批量接口和部分成功语义 | 选中项逐张处理可见;失败项仍被选中;成功项可从回收站恢复 |
| P1 | 内容删除与回收站 | 前端所有相关文案统一为“移至回收站”,不再错误声称立即永久删除 | `ContentRecycleBinService` 及恢复链路已存在 | 按现有接口联调,确认各资源类型映射完整 | 删除后列表移除、回收站出现、恢复后关系与文件引用完整 |
## 3. 族人档案字段表
以下键名已经写入前端契约和 OpenAPI。后端需要把 OpenAPI 作为唯一字段来源,不增加 snake_case 别名或双读兼容分支。
| 字段 | 含义 | 前端行为 | 后端要求 |
| --- | --- | --- | --- |
| `courtesyName` | 字 | 新增、编辑、详情显示;文本长度校验 | 新增数据库字段并原样回显 |
| `aliasName` | 别名 | 已接入;空值不显示 | 后端已有,核对映射与长度即可 |
| `zodiac` | 生肖 | 选择并显示 | 校验 OpenAPI 枚举,不接受任意文本 |
| `currentAddress` | 现居住地 | 文本输入和详情显示 | 长度校验,空值保持为空而非虚构默认值 |
| `mobile` | 手机号 | 格式校验,详情显示 | 格式和权限由服务端再次校验 |
| `email` | 邮箱 | 格式校验,详情显示 | 规范化大小写规则并回显 |
| `education` | 学历 | 文本输入和显示 | 按 OpenAPI 长度保存,不自行映射未知字典 |
| `occupation` | 职业 | 文本输入和显示 | 按 OpenAPI长度保存 |
| `deathAge` | 享年 | 数字输入;仅逝者相关资料使用 | 使用明确整数范围;不能以真假判断吞掉 `0` |
| `deathType` | 去世原因/类型 | 文本输入和显示 | 依 OpenAPI长度保存;不要与生存状态混成同一字段 |
| `burialDate` | 安葬日期 | 日期选择和格式化显示 | 使用 OpenAPI 日期格式,避免时区转换导致日期偏移 |
| `hereditaryMedicalHistory` | 遗传病史 | 仅 `canManageSensitiveMedicalHistory=true` 时编辑/显示 | 服务端强制鉴权;无权限时响应不得泄露字段内容;需审计访问与修改 |
| `canManageSensitiveMedicalHistory` | 敏感病史能力 | 决定表单和详情是否出现敏感字段 | 由当前用户、家谱和成员关系实时计算,客户端提交不能覆盖 |
建议后端改动顺序:数据库迁移 → Entity/Bo/请求 DTO/Vo → Mapper → Service 校验和鉴权 → Controller 契约测试。不能只扩 DTO 而遗漏数据库、详情 VO 或树节点回显。
## 4. 后端已存在、直接进入联调的接口
| 能力 | OpenAPI 路径 | 源码核对结论 |
| --- | --- | --- |
| 公开家谱搜索 | `GET /genealogy/app/genealogies/public?keyword=` | 已支持可选关键词 |
| 家谱视频根评论 | `GET/POST /genealogy/app/genealogies/{genealogyId}/videos/{videoId}/comments` | 已存在 |
| 家谱视频回复 | `GET /genealogy/app/genealogies/{genealogyId}/videos/{videoId}/comments/{commentId}/replies` | 已存在 |
| 家谱视频评论删除 | `DELETE /genealogy/app/genealogies/{genealogyId}/videos/{videoId}/comments/{commentId}` | 已存在 |
| 平台视频 | `GET /genealogy/app/platform-videos?placement=` | 已存在,当前 Controller 要求 placement |
| 平台视频点赞/评论 | `/genealogy/app/platform-videos/{videoId}/likes``/comments` | 已存在;评论需收紧为一级 |
| 成员权限读取/保存 | `GET/PUT /genealogy/app/genealogies/{genealogyId}/members/{memberId}/permissions` | 已存在;保存响应需严格返回最终集合 |
| 内容回收站 | `/genealogy/app/genealogies/{genealogyId}/recycle-bin/...` | 已有查询与恢复服务 |
## 5. 后端需要新增或调整的契约
具体 schema、required、枚举和响应包装见 `genealogy-app-openapi.yaml`,这里仅列责任边界。
| 端点/契约 | 类型 | 后端责任 |
| --- | --- | --- |
| `POST /genealogy/app/auth/login/wechat` | 新增 | 只接收微信一次性授权码;服务端换取身份并处理账号冲突 |
| `POST /genealogy/app/auth/register``referralCode` | 调整 | 注册事务内一次绑定,防自邀、防重复与并发覆盖 |
| `GET /genealogy/app/referrals/me` | 新增 | 返回稳定推荐码和服务端统计;无数据也返回合法空统计 |
| `GET /genealogy/app/vip/capability``paymentMethods` | 调整 | 返回当前租户、平台、用户可用渠道及禁用原因 |
| `POST /genealogy/app/vip/orders``paymentMethod` 与响应 | 调整 | 按微信/支付宝/余额返回互斥结果;响应必须带渠道判别字段 |
| `/content-password-recovery/{resourceType}/{resourceId}` | 新增三步接口 | 查询能力、发送验证码、验证并重置;服务端掌握手机号与权限 |
| `GET /genealogy/app/genealogies/{genealogyId}/permission-catalog` | 新增 | 输出动态权限目录,权限编码只由后端唯一权限定义产生 |
| `AppGenealogyVo` 注销能力字段 | 调整 | 返回 `canDeletePermanently`、禁用原因和已验证手机号脱敏值 |
| 家谱永久注销发码与提交 | 新增 | owner-only,校验归档、阻塞任务、精确家谱名、短信码并调用现有删除引擎 |
| 族人档案字段 | 调整 | 数据库到请求/响应全链路一致;敏感病史单独服务端鉴权 |
| 平台视频评论请求 | 收紧 | 去掉 `parentCommentId`,拒绝并清理不符合一级评论契约的数据 |
## 6. 本轮明确的产品取舍
| 参考项目做法 | 当前实现 | 原因 |
| --- | --- | --- |
| 安全问题找回内容密码 | 已验证手机号短信找回 | 安全问题答案弱且容易被猜测,不能作为敏感内容的正式凭据 |
| 直接点击永久删除家谱 | 归档前置 + 家谱名 + 短信双确认 + 后台异步任务 | 家谱关联数据多,必须由服务端做资格检查和可审计的高风险操作 |
| 固定 `auth_str` 权限字符串 | 服务端动态权限目录 + 成员授权集合 | 权限语义必须由服务端唯一拥有,避免客户端版本与鉴权漂移 |
| 删除提示为永久删除 | 内容先进入回收站 | 与当前后端实际生命周期一致,避免误导用户 |
| 宣传视频直接信任旧 URL | 平台视频资源走统一文件访问层 | 避免 HTTP、过期或未授权资源地址绕过现有文件契约 |
| 客户端保存微信密钥或信任用户资料 | 客户端仅提交一次性授权码 | AppSecret 必须只在服务端;展示资料不能作为登录身份凭据 |
## 7. 后端验收与安全底线
- 所有写接口继续在服务端校验家谱成员关系、角色和具体权限,不能以页面按钮是否显示作为安全边界。
- 微信登录、短信发码、内容密码重置和永久注销必须有限频、过期、一次性消费、失败次数限制和审计记录。
- 遗传病史属于敏感字段:无权限时不只是禁止修改,也不得从列表、详情、树节点或日志中返回原文。
- 余额购买 VIP 必须在一个服务端事务中完成余额校验、扣款、订单成功和权益开通,并使用稳定幂等键防重复扣款。
- 支付宝仅返回 APP 支付所需订单字符串;微信返回完整 APP 预支付签名参数;不同渠道字段不能混合猜测。
- 永久注销成功提交后应立即让所有成员端失去该家谱操作上下文;异步删除失败要可追踪、可重试,但不能把家谱恢复成可写状态。
- 运行时响应必须通过 OpenAPI 定义;不要长期保留旧字段、snake_case 别名或“缺字段时客户端猜测”的兼容路径。
## 8. 前端验收状态与待联调项
前端静态契约、59 个页面注册、导航、隐私审计、设计回归和资源引用均已通过项目检查。以下事项只能在后端完成后验证:
- 真机微信登录和微信/支付宝支付回跳;
- 余额真实扣款、订单查询和重复支付防护;
- 短信发送、限频、过期和服务端审计;
- 族人字段数据库持久化与敏感病史服务端脱敏;
- 永久注销任务、成员上下文撤销和关联数据清理;
- 推广关系的注册事务、归属和收益统计;
- 权限目录与各业务接口实际判权的一致性。
联调时若运行时响应与 OpenAPI 不一致,应优先修正后端实现或 OpenAPI 的唯一契约,不应在前端增加第二套字段读取逻辑。
@@ -0,0 +1,145 @@
# 剩余九项后端与产品任务及参考项目证据
日期:2026-08-23
收件人:后端开发、接口维护、测试及产品负责人
## 结论
前端能够独立完成的十三项已经处理。剩余九项中:
- 六项在参考项目中有直接页面或字段证据;
- 两项有等价流程或部分实现证据,不能逐字段照搬;
- 一项在参考项目前端中没有实现证据,但属于当前项目必须独立收口的安全要求。
后端可以直接排期八项;“家族恩人”必须先由产品确定是独立档案还是备忘录分类,再确定唯一契约。
参考项目只用于确认产品行为,不作为接口字段命名、安全设计或数据模型的权威来源。当前项目最终契约仍以 `genealogy-app-openapi.yaml` 和后端实现共同确认的单一版本为准。
## 九项核对表
| 序号 | 剩余事项 | 参考项目是否存在 | 证据与判断 | 当前责任 |
| --- | --- | --- | --- | --- |
| 1 | 功德记录图片保存和回显 | 是 | `pages/index/meritsVirtues/add.vue` 可上传多图,列表使用 `item.imgs[0]`,详情遍历 `datas.imgs` | 后端直接开发 |
| 2 | 推广注册链接和二维码 | 是 | `pages/mine/fenxiang.vue` 生成带推荐人参数的注册链接二维码 | 后端直接开发;链接必须由服务端生成,不能照抄旧域名或直接暴露用户 ID |
| 3 | 谱文、礼仪、视频封面清空 | 部分存在 | 谱文共用图片组件支持删除,礼仪编辑页明确可清空 `cover`;视频封面删除后的父表单同步不完整,不能作为可靠契约 | 后端按当前模型统一清空语义 |
| 4 | 多类内容的创建时间 | 是 | 参考相册、礼仪、功德、成长记录、贺礼簿列表直接显示 `create_time` | 后端直接开发 |
| 5 | 家族恩人业务语义 | 是 | `pages/index/memorandum/index.vue``add.vue``details.vue` 均明确使用“家族恩人”名称,并支持图片和创建时间 | 产品先选模型,随后后端开发 |
| 6 | 创建家谱时落库始迁祖 | 是 | `pages/index/createGenealogy.vue``first_ancestor_name` 作为必填项,与家谱资料一同提交 | 后端直接开发 |
| 7 | 谱主与世系人物绑定闭环 | 有等价流程 | 参考创建请求同时携带当前 `user_id``first_ancestor_name`,树编辑也提供“绑定账号”;更合理的闭环是创建家谱时原子绑定谱主,而不是开放谱主角色编辑 | 后端直接开发 |
| 8 | 换绑手机号前重新验证当前身份 | 是 | `pages/mine/changemobile.vue` 要求 `oldPassword + newMobile` | 后端直接开发,但建议保留当前新手机号短信验证,形成双重验证 |
| 9 | 改密、换绑后的既有会话失效 | 未发现 | 参考改密和换绑成功后直接返回个人中心,没有清理令牌或重新登录逻辑;仅凭参考前端无法证明服务端是否失效旧令牌 | 当前项目独立安全任务,后端直接开发 |
## 后端接口任务
### 1. 功德记录媒体
当前 `AppMeritRecordBody``AppMeritRecordVo` 没有媒体请求和响应字段。
处理要求:
- 创建和更新接收 `mediaOssIds`,由后端维护业务文件引用;
- 列表和详情返回授权后的 `mediaFiles`
- 明确空集合表示清空全部图片;
- 回收站、恢复和越权访问同步处理文件权限。
验收:新增、编辑保留、逐项移除、列表首图、详情预览和回收站恢复均通过。
### 2. 推广分享链接
当前推荐资料只有推荐码、邀请人数和文案,没有可信 `shareUrl`
处理要求:
- 在推荐资料响应中增加服务端生成的 HTTPS `shareUrl`
- 链接中的推荐凭据使用可校验、可控生命周期的业务标识,不能直接拼接内部用户 ID;
- 注册时继续执行防自邀、一次性绑定和并发幂等校验。
验收:前端可以复制链接、生成二维码和系统分享;扫码注册后推荐关系只绑定一次。
### 3. 封面清空契约
谱文、礼仪和视频已有 `coverOssId`,但 OpenAPI 没有统一声明显式清空语义。
处理要求:
- 更新请求中的 `coverOssId: null` 统一表示移除封面;
- 字段未出现表示保持原封面不变;
- 同步释放旧业务文件引用;
- Java DTO、更新服务、OpenAPI 和契约测试保持一致,不保留空字符串等第二套清空方式。
验收:移除后详情返回 `coverFile=null`,其他字段不变,旧文件不再保留业务引用。
### 4. 只读创建时间
处理要求:
-`AppAlbumVo``AppCeremonyVo``AppMeritRecordVo``AppGrowthRecordVo``AppRelativeRecordVo` 增加只读 `createTime`
- 家族恩人选定模型后,由对应唯一 VO 持有 `createTime`
- 不允许客户端提交或修改创建时间,也不能使用礼仪时间、记录日期、提醒时间等业务字段代替。
验收:新增后创建时间非空;编辑业务内容或业务日期不会改变创建时间。
### 5. 家族恩人契约
产品必须二选一:
1. 独立家族档案:定义独立实体、身份或类别、说明、图片、创建时间、权限和回收站类型;
2. 备忘录分类:由 `Memo` 的唯一契约增加受校验的业务类型,并明确恩人专属字段、提醒字段是否适用以及旧数据迁移规则。
确认前不要仅把“家族备忘”改标题,也不要先加入无法验证的猜测字段。
### 6. 始迁祖与谱主绑定
这两项应在同一创建事务中完成:
- `AppGenealogyCreateBody` 接收必填或按产品规则校验的 `firstAncestorName`
- 创建家谱后创建同名第一代世系人物;
- 将当前谱主成员记录绑定到该人物,或按产品确认的关系建立明确绑定;
- 任一步失败时家谱、人物、成员关系整体回滚;
- 重放同一创建请求不能产生重复人物或重复绑定。
如果业务允许谱主后续改绑,应新增只允许谱主修改“本人世系人物绑定”的窄接口。该接口不得同时开放角色修改、谱主移除或任意成员资料编辑。
验收:新建家谱后世系树立即出现始迁祖,唯一谱主成员具有明确人物绑定;旧家谱谱主也有受控补绑路径。
### 7. 换绑手机号的重新认证
当前 `AppPhoneChangeBody` 只有 `phone + smsCode`,只证明操作者控制新手机号。
建议唯一流程:
1. 校验当前登录密码,或校验近期完成的重新认证票据;
2. 校验新手机号短信票据;
3. 在同一服务端操作中更新手机号和密码登录标识;
4. 记录安全审计事件。
不要用行为验证码代替当前身份验证。行为验证码只能降低自动化攻击,不能证明操作者仍掌握账号凭据。
验收:旧密码错误、重新认证过期、新手机号错码、新手机号已占用均拒绝;全部验证通过后才换绑。
### 8. 安全操作后的会话失效
当前 `AppAuthServiceImpl.changePassword()``changePhone()` 中没有发现令牌注销或其他会话踢除逻辑。
处理要求:
- 修改密码后使该用户的其他既有令牌失效;
- 换绑手机号后建议使全部令牌失效,并要求使用新手机号重新登录;
- 如果保留当前设备会话,必须明确区分当前令牌与其他令牌,并通过自动化测试证明;
- 失效必须由服务端执行,不能只让前端删除本地缓存。
验收:安全操作前签发的旧令牌再次访问受保护接口时返回未登录;新凭据可以重新登录。
## 另行保留的既有 P0
普通谱主读取永久注销 capability 时,后端仍会因家谱未归档而报错。前端已经把基础设置读取与永久注销资格读取拆开,避免整个设置页被连带阻断,但后端仍需让普通家谱成功返回 `canDeletePermanently=false` 和明确禁用原因。该问题已经记录在 `backend-integration-report-2026-08-22.md`,不重复计入以上九项。
## 后端回传材料
完成后请提供:
1. 更新后的 canonical APP OpenAPI
2. 对应后端提交号和部署环境版本;
3. 新增或更新的接口自动化测试结果;
4. 功德媒体、分享链接、封面清空、创建时间、始迁祖和谱主绑定的真实响应样例;
5. 换绑重新认证及旧令牌失效的安全测试结果。
+153
View File
@@ -0,0 +1,153 @@
# 当前项目与参考项目点击对比审查
审查日期:2026-08-23
## 结论
本轮同时完成了两类核对:
- 代码全量核对:参考项目 78 条活动路由逐条映射到当前项目 59 条活动路由,合并页面按入口、操作、字段和状态判断,不按页面数量机械判缺。
- 运行时点击核对:参考项目使用用户已登录的浏览器,当前项目使用用户已登录的 MuMu;第一轮对首页、家谱总览、家族视频、相册、个人中心、推广、收益提现、重要证件、VIP 和家谱设置进行了实际点击与截图。继续复核时,参考项目又实点礼仪、谱文、功德、家族恩人、贺礼簿、成长记录、字辈谱、世系谱、家族动态、管理员、消息、创建和加入家谱;MuMu 重新登录后,补点了礼仪、功德、贺礼簿、家族备忘、人物详情和成长日志的列表、空状态及新建表单。
继续复核没有发现需要推翻既有“融合覆盖”判断的新页面,但纠正了两处业务名称:参考 `favor` 是“贺礼簿”,当前也以“贺礼簿”作为入口并升级为结构化往来记录;参考 `memorandum` 的产品名称是“家族恩人”,当前运行态明确是“家族备忘”,没有恩人身份或分类,不能判定已经融合。当前仍确认 14 项确定缺口,另有实现方案待产品选择和数据不足待验项。最紧急问题是当前项目的家谱设置仍被接口错误整体阻断。
## 点击链路结果
| 链路 | 参考项目实点结果 | 当前项目实点结果 | 判定 |
| --- | --- | --- | --- |
| 首页 → 家谱总览 | 进入 15 宫格式功能总览 | 进入纵向家谱总览;谱文、相册、视频等内容合并到“家族”主标签 | 已融合,不按页面布局判缺 |
| 家谱总览 → 家族视频 | 卡片先显示封面,点击进入独立播放页;浏览器播放页为黑屏,不能据此确认视频源可播放 | 家族视频入口可进入,但测试家谱没有视频数据;列表源码仍直接渲染播放器 | 确认列表交互缺口;实际播放待有数据再验 |
| 家族 → 相册 → 相册详情 | 列表显示封面、名称、说明、照片数、创建时间;详情可进入 | 列表与详情可进入;当前测试相册为空,列表不显示创建时间 | 创建时间缺口确认;有图预览因数据不对等暂不能下结论 |
| 我的 → 分享变现/应用推广 | 页面直接显示注册链接二维码,可点击链接分享 | 显示推荐码、邀请人数、复制推荐码、系统分享和推广内容,没有页面二维码或注册链接 | 确认前后端缺口 |
| 我的 → 余额/收益与提现 | 始终提供提现记录、申请提现;申请页有金额和收款码上传 | 当前显示收益与提现页,但接口未给最低金额时直接显示“暂未开放提现” | 可用性差异确认;记录字段缺口由代码契约进一步确认 |
| 家谱总览 → 重要证件 | 独立家谱级页面按证件类型集中显示多图,并有管理、上传 | 家谱总览没有聚合入口,仅人物资料内提供证件管理 | 确认前端聚合入口缺口,现有接口可复用 |
| 我的/总览 → VIP 购买记录 | 每条显示订单号、套餐、状态、支付时间、到期时间 | 当前显示套餐、金额和状态,不显示订单号,支付/到期时间也未完整分开展示 | 确认前端字段消费缺口 |
| 家谱总览 → 家谱设置 → 重新读取 | 参考项目基础资料可查看和维护 | 首次进入显示“家谱设置暂时无法读取”;点击重试后仍为完全相同错误画面 | P0 阻断,确认仍未修复 |
## 继续点击复核(编号步骤)
1. 点击参考“礼仪”列表并进入详情:列表和详情都显示活动封面,详情另显示分类、时间和地点。当前代码已有 `coverFile`,但列表与详情没有消费,原“礼仪封面缺口”结论成立。
2. 点击参考“谱文”分类和列表:可进入分类列表;详情被内容密码弹窗拦住,本轮没有绕过密码,因此只保留已有列表与源码证据,不声称详情已验证。
3. 点击参考“功德”列表并进入详情:列表有首图和创建时间,详情可显示多张图片。当前前端虽有上传与预览代码,但后端媒体契约仍未完整闭环,原缺口成立。
4. 点击参考 `memorandum` 列表并进入详情:页面实际名称是“家族恩人”,显示标题、说明、多图和创建时间。当前只有通用“家族备忘”,代码与最新后端主业务源码均没有恩人类型;是否用备忘录扩展类型或保留独立模块,必须先由产品确认。
5. 点击参考 `favor` 列表并进入详情:页面实际名称是“贺礼簿”,显示标题、说明、多图和创建时间。当前“往来记录”已升级为姓名、关系、事项、日期、金额、备注和多图,核心能力已融合,只保留列表首图与创建时间差异。
6. 依次点击参考“成长记录 → 人物 → 成长阶段 → 具体记录”:人物层不显示时间,但具体记录列表确实显示 `create_time`,因此“六类记录创建时间”仍包含成长记录,不能把业务日期代替创建时间。
7. 点击参考“字辈谱”和“世系谱”:当前分别有字辈管理和图形化谱系,属于同能力融合/升级,不补重复页面。
8. 点击参考“家族普”:实际内容是家族动态流,对应当前“家族圈”,不是另一套缺失的家谱模块。
9. 点击参考“管理员”:当前成员与角色管理覆盖且权限表达更细,判定为升级,不补页面。
10. 点击参考“VIP 购买”和“消息”:支付方式能力当前已按后端能力动态展示;消息中的申请、文档和联系入口已被当前消息中心、帮助与合规页面拆分覆盖。
11. 点击参考“创建家谱”和“加入家谱”:当前创建字段覆盖并更完整,仅始迁祖后端落库仍是已确认缺口;当前申请、邀请码和搜索加入属于安全流程升级。
12. 准备继续点击当前 MuMu 时,应用登录态已失效并停在“登录家谱”。本轮拒绝把登录页截图作为功能证据;礼仪、功德、家族恩人/备忘、贺礼簿/往来记录和成长记录的当前端再次实点,需恢复登录后补测。
## MuMu 重新登录后的补点结果
13. 从当前“家族动态”进入“礼仪”:列表和空状态正常,新建表单显示活动类型、标题、说明、日期、时间、地点、详细地址和封面图片。测试家谱没有礼仪数据,因此不能用运行态证明列表/详情会显示封面;源码不消费 `coverFile` 的缺口仍成立。
14. 从当前“家族动态”进入“功德录”:列表和空状态正常,新建表单显示捐赠人、标题、金额、内容、类型、日期、时间及相关图片。最新后端 `AppMeritRecordBody/AppMeritRecordVo` 仍没有媒体字段,所以前端上传入口存在,但保存回传链路未闭环。
15. 从当前“家族动态”进入“贺礼簿”:当前页面标题就是“贺礼簿”,新建表单将参考自由文本升级为亲友姓名、关系称谓、礼仪事项、日期、时间、礼金金额、备注和图片。确认属于融合升级,不新增独立页面;空列表仍无法验证首图和创建时间显示。
16. 从当前“家族动态”进入“家族备忘”:新建表单只有备忘标题、提醒日期/时间、备忘内容和图片,没有恩人姓名、身份、类别或专属文案。参考“家族恩人”的产品语义在当前端确实不可发现,确认是业务入口/分类缺口;可复用现有 Memo 模块扩类型,但不能只改标题冒充完成。
17. 从当前“人物录 → 人物详情 → 成长日志”进入成长记录:链路可走通,新建表单支持人物、成长阶段、标题、内容、记录时间、提醒时间、图片和视频。核心能力已融合且更丰富;当前入口比参考多两层,是否提升到家族首页属于信息架构选择。测试人物没有成长数据,仍不能用运行态验证列表创建时间。
### MuMu 补点截图
| 当前礼仪 | 当前功德 |
| --- | --- |
| ![当前礼仪空状态](audit-2026-08-23/61-current-ceremonies.png)<br>![当前礼仪新建表单](audit-2026-08-23/62-current-ceremony-create.png) | ![当前功德空状态](audit-2026-08-23/63-current-merits.png)<br>![当前功德图片字段](audit-2026-08-23/65-current-merit-create-media.png) |
| 当前贺礼簿 | 当前家族备忘 | 当前成长日志 |
| --- | --- | --- |
| ![当前贺礼簿](audit-2026-08-23/66-current-relative-records.png)<br>![当前结构化往来表单](audit-2026-08-23/67-current-relative-create.png) | ![当前家族备忘](audit-2026-08-23/68-current-memos.png)<br>![当前备忘表单](audit-2026-08-23/69-current-memo-create.png) | ![当前人物详情入口](audit-2026-08-23/72-current-person-detail-lower.png)<br>![当前成长媒体字段](audit-2026-08-23/75-current-growth-create-media.png) |
### 继续复核截图
| 礼仪列表与详情 | 功德列表与详情 |
| --- | --- |
| ![参考礼仪列表](audit-2026-08-23/34-reference-ceremonies.png)<br>![参考礼仪详情](audit-2026-08-23/36-reference-ceremony-detail.png) | ![参考功德列表](audit-2026-08-23/40-reference-merits.png)<br>![参考功德详情](audit-2026-08-23/41-reference-merit-detail.png) |
| 家族恩人 | 贺礼簿 | 成长具体记录 |
| --- | --- | --- |
| ![参考家族恩人详情](audit-2026-08-23/43-reference-favor-detail.png) | ![参考贺礼簿详情](audit-2026-08-23/45-reference-gift-ledger-detail.png) | ![参考成长记录](audit-2026-08-23/48-reference-growth-entries.png) |
| 字辈谱/世系谱 | 家族动态 | 创建/加入家谱 |
| --- | --- | --- |
| ![参考字辈谱](audit-2026-08-23/50-reference-generation-poems.png)<br>![参考世系谱](audit-2026-08-23/51-reference-pedigree.png) | ![参考家族动态](audit-2026-08-23/52-reference-family-genealogy.png) | ![参考创建家谱](audit-2026-08-23/57-reference-create-genealogy.png)<br>![参考加入家谱](audit-2026-08-23/58-reference-join-genealogy.png) |
## 截图证据
### 视频列表与播放入口
| 参考项目 | 当前项目 |
| --- | --- |
| ![参考家族视频列表](audit-2026-08-23/06-reference-videos.png) | ![当前家族视频空状态](audit-2026-08-23/09-current-videos.png) |
参考卡片点击后确实进入独立播放器,但浏览器中显示黑屏,因此本轮只能确认“封面卡片 → 播放页”的交互,不能声称视频成功播放。
### 推广邀请
| 参考项目 | 当前项目 |
| --- | --- |
| ![参考二维码分享](audit-2026-08-23/18-reference-referral.png) | ![当前推广中心](audit-2026-08-23/19-current-referral.png) |
### 重要证件与家谱设置
| 参考项目重要证件 | 当前项目家谱设置阻断 |
| --- | --- |
| ![参考重要证件汇总](audit-2026-08-23/24-reference-documents.png) | ![当前设置读取失败](audit-2026-08-23/27-current-settings.png) |
### VIP 订单
| 参考项目 | 当前项目 |
| --- | --- |
| ![参考购买记录](audit-2026-08-23/31-reference-vip-orders.png) | ![当前 VIP 与订单](audit-2026-08-23/30-current-vip.png) |
## 确定需要补的项目
| 优先级 | 差异 | 当前是否已有相近能力 | 责任与完成条件 |
| --- | --- | --- | --- |
| P0 | 家谱设置整体读取失败 | 页面和表单均已有,但基础详情与永久删除资格被同一个 `Promise.all` 绑定 | 前端把删除资格改为非关键独立状态;后端让未归档谱主正常读取详情和 capability 的禁用原因 |
| P0 | 家族视频列表仍直接铺 `<video controls>` | 已有封面字段和纵向播放器 | 前端改成封面优先卡片,点击打开指定视频;有真实数据后验播放、暂停、滑动和返回 |
| P0 | 功德图片提交后无法稳定回显 | 前端已有上传和详情预览 | 后端补媒体请求/响应与文件引用;前端补列表首图并完成增删改回归 |
| P1 | 礼仪封面未在列表和详情显示 | 编辑页和后端 `coverFile` 已有 | 前端消费现有封面并支持预览 |
| P1 | 谱文封面未在列表和详情显示 | 编辑页和后端单封面已存在 | 前端先显示现有单封面;是否扩成参考项目多图正文另行决策 |
| P1 | 家谱级重要证件聚合入口缺失 | 人物内证件维护完整,接口允许不传人物 ID | 前端增加家谱级列表/入口,复用现有查看、编辑和安全访问能力 |
| P1 | VIP 订单号、支付时间、到期时间缺失 | 后端字段已有 | 前端契约保留 `orderNo`,页面分别显示三项 |
| P1 | 提现记录处理字段未展示 | 契约已有提现单号、审核备注、打款参考号、到账时间 | 前端按状态展示已有字段;若产品还要独立审核时间,后端再补 `reviewedAt` |
| P1 | 推广缺注册链接和二维码 | 推荐码、邀请人数、复制和系统分享已有 | 后端返回可信 `shareUrl`;前端生成二维码并提供复制/分享链接 |
| P1 | 谱文、礼仪、视频封面不能显式移除 | 可上传或替换 | 后端/OpenAPI 统一 `coverOssId: null` 清空语义,前端增加移除并保留显式空值 |
| P1 | 参考六类列表均有记录创建时间 | 部分页显示业务发生时间,但语义不同 | 五类已确认映射 VO 先增加只读 `createTime`;家族恩人按选定契约持有创建时间;前端与业务时间分别显示 |
| P1 | 贺礼簿对应的往来记录列表缺首图 | 详情已有多图预览 | 前端列表显示 `mediaFiles[0]` 缩略图 |
| P1 | “家族恩人”入口与业务类型缺失 | 当前家族备忘有相近的标题、内容和图片,但运行态只有提醒语义,没有恩人身份/分类 | 产品确认复用 Memo 还是独立档案;复用时增加受校验的业务类型、独立入口/文案、列表首图和创建时间,不能只改页面标题 |
| P1 | 创建家谱的始迁祖未由后端落库 | 前端表单和请求字段已有 | 后端在创建家谱事务中接收 `firstAncestorName` 并原子创建第一代人物 |
## 已融合或升级,不应重复补页面
| 参考能力 | 当前实现 | 判定 |
| --- | --- | --- |
| 谱文、相册、视频、功德、礼仪、成长、贺礼簿等宫格入口 | 合并到“家族”主标签和内容模块 | 合并覆盖,不需要复制参考总览宫格;“家族恩人”是否映射“家族备忘”单独待确认 |
| 注册真实姓名、性别 | 注册后在个人资料维护 | 流程重分配;是否改回注册必填属于产品决策 |
| 编号加入、直接硬删除、安全问题找回、任意视频 URL | 申请/一次性邀请码、回收站/归档、短信找回、受控文件上传 | 安全与数据完整性升级,不回退 |
| 人物证件查看与维护 | 人物详情中的多文件证件弹窗、安全票据和删除能力 | 人物级功能已覆盖;只缺家谱级聚合入口 |
| 视频点赞、评论、纵向播放器 | 当前已有独立组件和路由 | 播放能力代码存在;家族视频列表入口和真实数据验收仍待补 |
| 个人中心广告/推广内容 | 当前个人中心底部推广条和推广中心内容 | 已融合;缺的是邀请注册链接二维码,不是整个推广模块 |
## 不能直接判定为缺口的项目
- 首页搜索、加入、排序和宣传内容的位置与参考项目不同,但能力已存在;是否提升到首页首屏是产品信息架构选择。
- 当前家谱编号和自有家谱卡片的姓氏/简介显示位置不同,字段并非完全不存在;是否强制同位置展示需产品确认。
- 参考项目多处批量管理,当前除相册照片外大多逐条删除。要补之前需先定义回收站下的批量部分成功语义。
- 宣传视频接口本轮仍为空,当前只能验证入口和空状态,不能验证原生播放、上下滑动、横竖屏和弱网恢复。
- “纯图片宣传并点击放大”不能拿视频封面代替;若确有该产品需求,后端要先定义混合媒体类型和目标行为。
- 参考“家族恩人”不是仅靠路由名可以判定的普通备忘录。MuMu 补点已确认当前只有“家族备忘”,因此“恩人”业务语义缺失计入确定缺口;具体采用独立档案还是 Memo 类型仍需产品选择,在此之前不虚构后端字段。
## 可用性与可访问性观察
- 参考“世系谱”在窄屏中使用密集竖排小字,人物较多时可读性和点击命中范围有明显风险;当前图形化谱系方向更适合移动端,但仍应在真机数据量较大时复测缩放、聚焦和文字截断。
- 参考多个列表把“管理”、删除态和普通卡片操作放在相近位置,操作模式不够一致;当前使用明确按钮和回收站语义更安全,不建议为了视觉一致而回退。
- 本轮只能根据截图和可见控件判断层级、字号与触控风险;没有执行屏幕阅读器、外接键盘焦点顺序、对比度测量,因此不声称已完成完整无障碍验收。
## 本轮验证边界
- 两端使用的是不同账号和不同数据集,因此内容条数、姓名和图片本身不作一致性判断,只比较可执行路径、字段与状态。
- 参考项目运行在 440×960 浏览器视口,当前项目运行在 900×1600 MuMu;本轮是功能与信息架构审查,不是逐像素视觉还原。
- 未执行购买、提现提交、删除、归档、资料保存、验证码发送等会写入数据或触发外部动作的操作。
- 全量路由脚本只证明 78 条参考路由已登记和映射;没有真实数据的播放器、支付、微信和写操作仍需专项联调。
- 继续复核期间 MuMu 曾短暂失去登录态;截图 `35-current-ceremonies.png``49-current-login-check.png` 仅用于证明当时的阻断,不作为任何当前功能的通过证据。用户重新登录后,步骤 13~17 已补测完成。
@@ -0,0 +1,54 @@
# 第五轮真机点击回归记录
- 测试日期:2026-08-23
- 测试端:MuMu 模拟器,`io.dcloud.HBuilder`
- 测试账号:已登录测试账号
- 测试家谱:`MANUALTEST20260809`
- 原则:仅执行可逆点击和本地表单校验;未发送验证码、未提交申请、未新增内容、未执行删除。
## 结论
本轮覆盖 13 个前端差异项。6 项已通过真机点击验证,1 项部分通过,6 项因后端没有可验证数据或基础接口失败而受阻。本轮点击中发现的 2 个前端问题均已修正,并在重新编译后通过最终真机复验。
## 逐项结果
| 序号 | 验证项 | 状态 | 点击结果与证据 | 后续处理 |
| --- | --- | --- | --- | --- |
| 1 | 家谱设置与永久删除能力解耦 | 受阻 | 从家谱总览可进入设置,但基础设置接口直接进入“家谱设置暂时无法读取”。见 [03-settings.png](./audit-2026-08-23-round-5/03-settings.png)。 | 先确认家谱设置基础接口的失败原因;本轮无法进入表单验证删除能力是否独立加载。 |
| 2 | 宣传视频封面点击播放 | 受阻 | “宣传视频”入口和列表页可进入,但后端返回“暂时没有可观看的平台视频”。见 [06-video-list.png](./audit-2026-08-23-round-5/06-video-list.png)。 | 后端准备至少一条带视频地址和封面地址的可见样本后复测。 |
| 3 | 礼仪活动封面 | 受阻 | 礼仪活动入口可进入,当前家谱没有礼仪活动。见 [21-ceremony.png](./audit-2026-08-23-round-5/21-ceremony.png)。 | 准备一条带封面的礼仪活动样本后复测列表卡片。 |
| 4 | 谱文封面 | 受阻 | 谱文入口可进入,当前家谱没有谱文。见 [20-articles.png](./audit-2026-08-23-round-5/20-articles.png)。 | 准备一条带封面的谱文样本后复测列表卡片。 |
| 5 | 家谱重要证件汇总入口 | 通过 | 家谱总览“重要证件”可进入新汇总页,并自动打开“家谱重要证件”列表;当前结果为空。见 [04-documents.png](./audit-2026-08-23-round-5/04-documents.png)。 | 无前端阻塞;有数据后补充内容态检查。 |
| 6 | VIP 订单字段 | 部分通过 | 订单卡已显示套餐、金额、订单号和状态。见 [17-vip-orders.png](./audit-2026-08-23-round-5/17-vip-orders.png)。当前样本未返回可展示的支付方式、支付时间等扩展字段。 | 后端确认订单详情是否提供扩展字段;有值后复测。 |
| 7 | 提现记录字段 | 受阻 | 收益页可进入,但收益明细和提现记录均为空,提现能力当前未开放。见 [18-earnings.png](./audit-2026-08-23-round-5/18-earnings.png)。 | 后端提供至少一条提现记录样本后复测金额、状态、账户和时间。 |
| 8 | 亲属记录首张缩略图 | 受阻 | 人物基础资料显示配偶 `MANUALSPOUSE01`,但“亲属关系”区域返回“尚未记录可查看的亲属”。见 [24-person-actions.png](./audit-2026-08-23-round-5/24-person-actions.png)。 | 后端确认人物亲属列表接口为何未返回已存在的配偶关系,并准备带图片的亲属记录。 |
| 9 | 成长记录表单脏状态 | 通过 | 新建成长记录填写标题后返回,正确弹出“放弃成长记录”确认框,未提交内容不会静默丢失。见 [26-growth-discard.png](./audit-2026-08-23-round-5/26-growth-discard.png)。 | 无。 |
| 10 | 公开家谱申请加入导航 | 通过 | “搜索家谱 → 申请加入”可进入表单,重新编译后家谱名称已正确显示为“彭氏家谱”,不再出现 URL 编码串。见 [28-join-name-retest.png](./audit-2026-08-23-round-5/28-join-name-retest.png)。 | 无。 |
| 11 | 消息中心来源感知返回 | 通过 | 从“家谱”页铃铛进入消息中心,底部仍高亮“家谱”,空状态提供“返回上一页”。见 [12-message-from-genealogy.png](./audit-2026-08-23-round-5/12-message-from-genealogy.png)。 | 无。 |
| 12 | 换绑手机号校验文案 | 通过 | 输入新手机号但未获取验证码时,显示“请先获取新手机号的验证码”,错误位置和语义正确。见 [14-phone-validation.png](./audit-2026-08-23-round-5/14-phone-validation.png)。 | 无。 |
| 13 | 密码显示按钮可访问性 | 通过 | 三个显隐控件均被 Android 无障碍树识别为 `ToggleButton`,具备可读名称、`checkable=true``clickable=true``focusable=true`。点击当前密码显隐控件后,名称切换为“隐藏当前密码”,`checked` 同步变为 `true`。见 [29-password-a11y-retest.png](./audit-2026-08-23-round-5/29-password-a11y-retest.png)。 | 无。 |
## 本轮新增前端修正
1. 申请加入页对 `genealogyName` 查询参数执行一次安全解码,避免显示 URL 编码串。
2. 修改密码页的三个密码显隐控件补充显式 `button` 角色和 `tabindex="0"`,保留动态 `aria-label``aria-pressed`
## 代码验证
执行 `npm.cmd run check`,结果通过:
- 项目检查:60 个页面、60 个路由、173 个源码文件;
- 审计回归检查、导航恢复检查、前端对齐检查均通过;
- 设计资产测试 12 项全部通过;
- 运行时资产清单验证通过,4 个清单、98 个资产。
- 使用 HBuilderX 内置 Vue 编译器单独编译本轮修改的两个 SFC 模板,均通过。
## 仍需后端提供的复测条件
1. 可正常读取的家谱设置基础数据。
2. 至少一条带封面的平台视频和可播放视频地址。
3. 至少一条带封面的礼仪活动。
4. 至少一条带封面的谱文。
5. 至少一条包含完整扩展字段的 VIP 订单。
6. 至少一条提现记录。
7. 可被人物详情接口返回、且带图片的亲属记录。
@@ -0,0 +1,154 @@
# 当前项目与参考项目第四轮安全及操作流程复审
审查日期:2026-08-23
参考项目:`C:\Users\Rain\Desktop\job\Jiapu-App`(浏览器,已登录)
当前项目:`C:\Users\Rain\Desktop\job\jiapuapp`MuMu,已登录)
后端项目:`C:\Users\Rain\Desktop\job\Genealogy`
## 结论
第四轮聚焦前三轮容易遗漏的安全分支:修改密码、换绑手机号、密码找回、未保存返回、账号注销、验证码归属、会话失效和动态错误提示。
- 用户记忆中的“需要输入密码”不仅存在于内容查看,也存在于参考项目的更换手机号流程:参考项目要求登录密码和新手机号。
- 当前项目换绑手机号只验证新手机号收到的短信码;行为验证码只证明操作像真人,不证明操作者仍掌握旧密码或旧手机号。后端最新接口也只有 `phone + smsCode`,这是已确认的安全契约差异,不是页面漏放一个输入框。
- 当前后端修改密码和换绑手机号成功后均未发现注销或踢除既有会话的逻辑;前端也继续保持登录。旧会话是否仍有效不能依赖界面推断,但当前项目全局搜索没有发现对应失效钩子,应按安全缺口处理。
- 新增确认 4 项:换绑缺少当前身份二次验证、改密/换绑后既有会话未失效、验证码归属提示写成“当前手机号”、密码切换与动态错误的无障碍关联不足。
- 前三轮的 14 项参考差异和 4 项当前流程缺陷继续成立。加上本轮 4 项,当前未决合计为 14 项参考差异和 8 项当前流程/安全/无障碍缺陷,共 22 项。
- 本轮没有发送短信、修改密码、换绑手机号、注销账号或写入业务数据。
## 编号流程与健康度
| 步骤 | 操作流程 | 健康度 | 第四轮结论 |
| --- | --- | --- | --- |
| 1 | 当前家谱切换 | 健康 | 从 MANUALTEST 切换到“真机联调10159371”后,首页立即更新;进入家族页再返回,选择仍保持 |
| 2 | 我的页面进入账户安全 | 健康 | 账户与安全入口清楚,手机号脱敏、账号编号、修改密码、换绑手机和微信绑定集中展示 |
| 3 | 修改密码初始页 | 健康 | 当前密码、新密码、确认密码齐全;明确要求 8–32 位且包含字母和数字,比参考项目提示更完整 |
| 4 | 修改密码空提交 | 基本健康 | 三项错误均就近显示,没有发起请求;但错误没有 `role=alert/aria-live`,也未通过 `aria-describedby` 关联输入框 |
| 5 | 修改密码显示/隐藏 | 有无障碍风险 | 使用可点击 `view role=button`,没有 `aria-pressed`,Android 结构中不可聚焦;触摸可用但键盘/辅助技术语义不完整 |
| 6 | 换绑手机号初始页 | 不健康 | 只有新手机号和新手机号短信码;缺少旧密码、旧手机号验证码或近期重新认证 |
| 7 | 新手机号已填但未取码后提交 | 有瑕疵 | 页面提示“请先获取当前手机号的验证码”,实际短信发送目标是新手机号,文案与契约相反 |
| 8 | 换绑表单填写后返回 | 健康 | 会弹出放弃确认,继续填写和确认放弃两个出口清楚;本轮确认放弃后未保留测试号码 |
| 9 | 关于与设置、退出和账号注销 | 基本健康 | 当前项目比参考项目多一层入口,但注销具有短信验证、再次确认和不可恢复说明,明显强于参考项目的一次确认 |
| 10 | 参考项目修改密码 | 基本健康 | 旧密码、新密码、重复新密码齐全,但没有展示密码强度规则 |
| 11 | 参考项目更换手机号 | 安全基线较明确 | 明确要求登录密码和新手机号;未展示短信验证,整体安全性仍需后端保证 |
| 12 | 参考项目谱文查看密码 | 可用 | 点击谱文先输入查看密码,可进入忘记密码流程 |
| 13 | 参考项目内容密码找回 | 可用但较弱 | 回答任意一组自定义密保即可重设;当前项目改用实名手机号短信找回,身份凭据不同 |
## 新增发现
### 1. P0:换绑手机号缺少当前身份二次验证
参考项目要求“登录密码 + 新手机号”;当前页面只要求“新手机号 + 发给新手机号的短信验证码”。当前后端 `AppPhoneChangeBody` 也只有 `phone``smsCode`,服务端先校验新手机号短信票据,再直接更新用户手机号和密码登录标识。
风险场景是登录态或设备被他人临时取得后,对方只需掌握自己的新手机号,就可以把账号登录标识换到该号码。行为验证码只能降低自动化滥用,不能替代旧密码、旧手机号验证码或近期重新登录。
建议由后端先确定唯一安全契约,至少选择一种:
1. 旧密码验证后,再验证新手机号短信;
2. 旧手机号短信验证后,再验证新手机号短信;
3. 接受近期完成的重新认证票据,再验证新手机号短信。
前端应分步骤明确显示“验证当前身份”和“验证新手机号”,不能把两个目标写成一个模糊的“安全验证”。
| 参考项目 | 当前项目 |
| --- | --- |
| ![参考项目更换手机号要求登录密码](audit-2026-08-23-round-4/17-reference-change-phone.png) | ![当前项目仅验证新手机号短信](audit-2026-08-23-round-4/09-current-change-phone.png) |
### 2. P1:修改密码、换绑手机号后既有会话没有明确失效
后端 `changePassword` 更新凭据后直接返回;`changePhone` 更新手机号和密码登录标识后直接返回。全后端项目只发现账号注销、显式退出和其他独立流程调用 `StpUtil.logout()`,没有发现改密/换绑后的会话踢除或安全版本号失效机制。前端成功后也只显示提示,继续保留当前登录态。
完成条件:
- 明确当前会话是否保留;
- 至少让其他设备和旧令牌失效;
- 若无法区分当前与其他会话,成功后统一退出并要求使用新凭据重新登录;
- 增加“旧令牌访问受保护接口必须失败”的后端集成测试。
### 3. P2:换绑验证码提示把“新手机号”写成“当前手机号”
输入合法新手机号但未获取验证码,点击提交后显示“请先获取当前手机号的验证码”。代码实际把输入的新手机号作为短信接收号码,后端测试名称也明确为 `changePhoneVerifiesNewPhoneTicket...`
这会直接影响用户判断短信应该去哪里查收。应改为“请先获取新手机号的验证码”,并让标题、说明、字段错误、短信反馈统一使用“当前手机号/新手机号”两个固定术语。
![当前项目错误提示把新手机号写成当前手机号](audit-2026-08-23-round-4/12-current-phone-no-code-error.png)
### 4. P2:密码页的动态错误和显示开关缺少完整无障碍语义
从截图可确认错误是就近显示的,这是优点;从实现和 Android 无障碍结构可确认:
- 动态错误文本没有 `role="alert"``aria-live`
- 输入框没有 `aria-describedby` 指向对应错误;
- 显示/隐藏密码使用 `view role="button"`,没有 `aria-pressed`Android 结构中不可聚焦;
- 换绑手机号的字段错误存在相同的动态播报与关联问题。
![当前修改密码的字段级错误](audit-2026-08-23-round-4/08-current-password-validation.png)
截图不能证明读屏器实际播报结果,完成前仍需 TalkBack、外接键盘和焦点顺序真机测试。
## 已确认健康或优于参考项目的部分
- 修改密码明确展示强度规则,并校验新旧密码不能相同、两次新密码必须一致。
- 修改密码和换绑手机号都具备未保存返回保护。
- 当前账号注销不是参考项目的一次确认后立即清理本地登录,而是先提示不可恢复、验证当前绑定手机号短信,再次确认后提交。
- 当前内容密码支持设置、修改、关闭和短信找回;参考项目内容密码找回依赖用户自定义密保问题。
- 当前账户安全页对手机号做了脱敏展示。
![当前账号注销的第一层高风险确认](audit-2026-08-23-round-4/21-current-deactivate-warning.png)
## 密码相关操作的最终判定
| 密码场景 | 是否缺失 | 第四轮判定 |
| --- | --- | --- |
| 登录密码 | 否 | 当前支持密码登录、修改和短信找回 |
| 谱文查看密码 | 否 | 当前按单篇谱文可选设置,参考项目创建时强制设置 |
| 重要证件查看密码 | 否 | 当前按单份证件可选设置,参考项目创建时强制设置 |
| 成长记录查看密码 | 否 | 当前按单条记录可选设置,参考项目创建时强制设置 |
| 内容密码找回 | 否 | 当前实名手机号短信,参考项目密保问题 |
| 换绑手机号前输入当前密码 | 是 | 参考项目有;当前前端与后端契约均没有,应作为安全任务处理 |
| 安全操作后会话失效 | 未闭环 | 当前代码没有发现改密/换绑后的旧会话失效机制 |
参考项目内容密码证据:
| 查看密码 | 忘记密码 |
| --- | --- |
| ![参考项目查看谱文需要密码](audit-2026-08-23-round-4/14-reference-article-password.png) | ![参考项目通过密保找回内容密码](audit-2026-08-23-round-4/15-reference-password-recovery.png) |
## 本轮完整截图顺序
| 步骤 1:切换前 | 步骤 2:切换后 | 步骤 3:进入家族页 |
| --- | --- | --- |
| ![当前家谱切换前](audit-2026-08-23-round-4/01-current-home-before-switch.png) | ![当前家谱切换后](audit-2026-08-23-round-4/02-current-after-switch.png) | ![切换后进入家族页](audit-2026-08-23-round-4/03-current-family-after-switch.png) |
| 步骤 4:返回仍保持 | 步骤 5:我的 | 步骤 6:账户安全 |
| --- | --- | --- |
| ![返回家谱页后仍保持选择](audit-2026-08-23-round-4/04-current-home-after-family.png) | ![当前我的页面](audit-2026-08-23-round-4/05-current-profile.png) | ![当前账户与安全](audit-2026-08-23-round-4/06-current-account-security.png) |
| 步骤 7:修改密码 | 步骤 8:字段校验 | 步骤 9:换绑手机号 |
| --- | --- | --- |
| ![当前修改密码](audit-2026-08-23-round-4/07-current-change-password.png) | ![当前密码字段校验](audit-2026-08-23-round-4/08-current-password-validation.png) | ![当前换绑手机号](audit-2026-08-23-round-4/09-current-change-phone.png) |
| 步骤 10:验证码归属错误 | 步骤 11:放弃保护 | 步骤 12:个人中心下半段 |
| --- | --- | --- |
| ![新手机号验证码被写成当前手机号](audit-2026-08-23-round-4/12-current-phone-no-code-error.png) | ![换绑手机号放弃确认](audit-2026-08-23-round-4/13-current-phone-discard.png) | ![当前个人中心下半段](audit-2026-08-23-round-4/19-current-profile-bottom.png) |
| 步骤 13:关于与设置 | 步骤 14:注销警告 | 步骤 15:参考项目我的 |
| --- | --- | --- |
| ![当前关于与设置](audit-2026-08-23-round-4/20-current-about-settings.png) | ![当前注销账号警告](audit-2026-08-23-round-4/21-current-deactivate-warning.png) | ![参考项目我的](audit-2026-08-23-round-4/18-reference-profile.png) |
| 步骤 16:参考查看密码 | 步骤 17:参考密码找回 | 步骤 18:参考修改密码 |
| --- | --- | --- |
| ![参考项目查看密码](audit-2026-08-23-round-4/14-reference-article-password.png) | ![参考项目密码找回](audit-2026-08-23-round-4/15-reference-password-recovery.png) | ![参考项目修改密码](audit-2026-08-23-round-4/16-reference-change-password.png) |
| 步骤 19:参考更换手机号 |
| --- |
| ![参考项目更换手机号](audit-2026-08-23-round-4/17-reference-change-phone.png) |
## 验证范围与限制
- 当前项目实际点击:家谱切换、家族页往返、我的、账户与安全、修改密码、空提交校验、换绑手机号、合法手机号未取码校验、填写后返回、放弃确认、关于与设置、账号注销第一层确认。
- 参考项目实际进入:谱文查看密码、内容密码找回、修改密码、更换手机号、我的页面。
- 输入的 `13800138000` 只用于前端本地校验;没有点击获取验证码,随后通过放弃确认清空。
- 没有提交密码、短信、手机号、注销、支付、提现、审批或其他写操作。
- 没有受保护的当前项目真实内容样本,因此本轮仍未执行当前内容密码错误、成功、找回和 15 分钟授权过期的端到端测试。
- 没有执行 TalkBack、外接键盘或多设备旧令牌访问测试;无障碍和会话失效结论分别来自截图加结构检查、前后端代码检查,仍需专项真机/集成测试收口。
@@ -0,0 +1,309 @@
# 当前前端与参考前端完整对比总表
更新时间:2026-08-22
## 核对范围与判定方法
- 参考项目:`C:\Users\Rain\Desktop\job\Jiapu-App`
- 当前项目:`C:\Users\Rain\Desktop\job\jiapuapp`
- 比对对象以两个前端项目为主;需要后端数据才能完成的显示和播放会单独标出,不能把“有页面”写成“功能已可用”。
- 参考项目 `pages.json` 有 78 条活动路由。每条路由同时按 `.vue``.nvue` 解析,不能只检查 `.vue`
- 当前项目有 59 条活动路由。多个参考页面合并到一个当前页面或组件时,只要入口、操作、字段和状态完整,判定为“合并覆盖”,不要求文件数量相同。
- 静态空壳、无入口演示页和未实现按钮会明确标记,不能当成当前项目必须复制的正式功能。
> 2026-08-22 第二轮按入口和点击链路复核。此前的 78/78 只表示参考路由已逐项登记,不代表每个入口都完成过真机点击。此次发现“宣传视频”虽有页面映射,但首页没有封面预览且 `G01 → F11` 被路由白名单拦截,原结论不准确,现已修正并加入回归检查。
> 2026-08-22 第三轮严格复核:再次逐项检查 78 条参考路由的模板、点击事件、表单字段、图片/视频显示和当前后端 DTO。此前把“前端已写媒体控件”直接判为“功能覆盖”的结论仍不严谨。功德图片实际上会被当前后端丢弃;谱文多图、礼仪封面显示、家族视频封面优先列表及多个列表的缩略图/批量操作也未完全对齐。下方“严格复核后的真实差异”是当前最终结论,优先于旧的“本轮补齐”措辞。
> 2026-08-22 第四轮融合复核:不再把“能从别处操作”直接等同于完整覆盖,而是继续核对聚合入口、字段是否真正显示、同一记录的生命周期信息是否完整。新增确认:家谱级重要证件总入口缺失;VIP 订单号及支付/到期时间未完整展示;提现单号、处理时间和审核/打款信息未展示;推广中心缺参考项目真实可用的注册链接二维码和复制链接。家谱编号、首页姓氏位置和参考项目更严格的必填规则属于产品取舍,单列待确认,不直接开发。
> 2026-08-22 第五轮字段消费复核:逐项区分“业务发生时间”和“记录创建时间”。参考相册、礼仪、功德、成长记录、贺礼簿、家族恩人列表均显示 `create_time`;当前除相册外显示的是 `ceremonyTime/meritTime/recordDate/eventTime/remindTime` 等业务时间,不能视为同一个字段。最新后端五个已确认映射 VO 及相近的 `AppMemoVo` 均未返回 `createTime`,因此这不是只改模板即可完成的前端项。家族视频使用 `publishTime`、家族圈直接返回 `createTime`,已有等价字段,不重复列入。
状态说明:
- **覆盖**:当前项目有等价或更完整的可执行路径。
- **合并覆盖**:能力存在,但被合并到当前页面、弹窗或组件。
- **升级替代**:参考做法存在安全、契约或产品问题,当前使用更可靠的流程完成同一用户目标。
- **非产品页**:空壳、静态原型、插件演示或没有业务入口,不迁移。
- **本轮补齐**:静态比对发现过差距,本轮已补入当前前端并进入后端联调清单。
## 严格复核后的真实差异
| 优先级 | 模块/参考路由 | 参考项目实际行为 | 当前项目实际行为 | 责任与完成条件 |
| --- | --- | --- | --- | --- |
| P0 | 家族视频 `video/index``video2.nvue` | 列表先显示 `item.imgs` 封面,点击后进入播放器/纵向播放 | `platform-videos.vue` 已是封面优先,但 `videos.vue` 的家族视频列表仍直接渲染 `<video controls>`;封面不能作为主要点击入口 | **前端**:家族视频卡片改为有封面先显示图片和播放标识,点击进入纵向播放器;无封面再使用视频占位或首帧 |
| P0 | 功德录 `meritsVirtues/index/add/details` | 列表显示第一张图,新增可传多图,详情显示全部图片 | 前端表单和详情虽使用 `mediaOssIds/mediaFiles`,但最新后端 `AppMeritRecordBody/Vo` 没有媒体字段;MuMu 实测创建成功后详情无图,列表也没有缩略图 | **前后端**:后端增加媒体请求/响应和文件引用;前端补列表首图。以新增、编辑保留、移除、列表首图、详情预览全部实测通过为完成 |
| P0 | 家谱设置 `genealogyList/index` | 谱主可进入资料维护和删除操作 | 当前设置页把基础详情和永久删除资格放进同一个 `Promise.all`;后端资格接口对未归档家谱抛“家谱必须先归档”,导致整个设置页失败 | **前后端**:前端将删除资格作为非关键独立状态,后端拆分 owner 鉴权与 archived 资格;普通谱主必须可读、可改基础设置 |
| P1 | 创建家谱 `createGenealogy` | `first_ancestor_name` 随创建提交 | 当前表单已有 `firstAncestorName`,最新后端 `AppGenealogyCreateBody` 无该字段,也不会在事务内建立首位人物 | **后端**:增加字段并原子创建第一代人物;失败时家谱和人物一起回滚 |
| P1 | 谱文 `puwen/add/genealogy` | 新增维护 `imgs` 数组,详情逐张显示 | 当前仅上传单个 `coverOssId`;列表和详情均未渲染 `coverFile`,后端也只有单封面契约 | **产品+前后端**:先决定“多图正文”还是“单封面”。至少应由前端显示现有封面;若保持参考多图则扩展后端媒体契约 |
| P1 | 礼仪 `gift/index/details/add` | 列表显示封面,详情显示封面,新建上传封面 | 当前编辑页可上传且后端返回 `coverFile`,但列表和详情未渲染图片 | **前端**:列表增加封面缩略图,详情显示可预览封面 |
| P1 | 重要证件 `document/index` | 从家谱总览直接进入全家谱证件列表,再选择查看或维护 | 当前只在单个人物详情内打开证件弹窗,没有家谱级总入口和跨人物列表;现有接口已允许不传 `lineagePersonId` 查询 | **前端**:增加家谱级重要证件入口/列表,复用现有证件弹窗与安全访问能力;无需新增后端接口 |
| P1 | VIP 订单 `mine/vip_success` | 每条显示订单号、套餐、状态、支付时间、到期时间 | 当前订单列表显示套餐、金额、状态,并把支付/到期时间二选一显示;契约还丢弃后端已有 `orderNo` | **前端**:保留 `orderNo`,分别显示订单号、支付时间和到期时间 |
| P1 | 提现记录 `mine/tixian_log` | 每条显示提现单号、金额、状态、申请时间、审核时间和备注 | 当前显示金额、申请时间、状态、收款人和失败原因;契约已有 `withdrawalNo/auditRemark/payoutReference/paidAt` 但页面未展示 | **前端**:显示提现单号,并按状态展示审核备注、打款参考号和到账时间;后端没有通用审核时间字段,不能把 `paidAt` 冒充审核时间 |
| P1 | 推广邀请码 `mine/fenxiang` | 根据注册链接生成二维码,并可复制注册链接给朋友;“下载 App”代码实际已注释 | 当前可复制推荐码和系统分享推荐码文字,但没有二维码或可复制注册链接;最新 `ReferralMeVo` 也没有 `shareUrl` | **前后端**:后端返回可信、可配置的注册链接(携带推荐码或服务端短链),前端再生成二维码、复制和分享;不能在前端重新硬编码旧域名 |
| P1 | 谱文/礼仪/视频编辑时移除封面 | 参考上传组件可删除已有图片,礼仪和视频表单也有明确移除按钮 | 当前三处只能上传或替换封面,没有移除按钮;前端请求规范化会省略 `null/''`,OpenAPI 也未声明封面可置空 | **前后端契约+前端**:更新契约明确 `coverOssId: null` 表示解除引用,规范化保留显式空值,编辑器增加移除并验证旧文件引用释放 |
| P1 | 相册/礼仪/功德/成长记录/贺礼簿/家族恩人列表创建时间 | 六类参考列表均显示记录的 `create_time` | 当前相册列表没有时间;其余页面显示活动/功德/记录/事件/提醒等业务时间,但它们不等于创建时间;五个已确认映射 VO 也没有 `createTime` | **后端+前端**:五类现有 VO/OpenAPI 先增加只读 `createTime`;家族恩人由选定契约持有;现有业务时间继续保留,不能互相冒充 |
| P1 | 贺礼簿 `favor/index/details` | 列表显示 `imgs[0]`,详情显示全部图片 | 当前“往来记录”详情可预览 `mediaFiles`,列表只有文字 | **前端**:列表补第一张图片缩略图 |
| P1 | 家族恩人 `memorandum/index/details` | 独立入口、列表和详情显示恩人记录、`imgs[0]` 与全部图片 | MuMu 实点确认当前只有通用“家族备忘”,表单是提醒语义,没有恩人身份/分类 | **确认缺口**:产品选择复用 Memo 类型或独立档案;前端补入口、文案、首图和创建时间,后端按选定单一契约落库 |
| P1 | 谱文、证件、视频、功德、礼仪、成长日志、贺礼簿、家族恩人列表 | 多数页面有“管理→勾选→批量删除” | 当前相册照片支持批量删除,其余大多只有逐条删除 | **产品决策**:若要求操作一致,先定义回收站下的批量部分成功语义,再由前端补选择态;不应仅因文件已合并就标“完整覆盖” |
| P1 | 首页 `index/index` | 搜索框、创建、加入、排序和两条宣传封面直接露出 | 当前创建/加入/排序主要收在弹窗或二级页,宣传区位于较长家谱列表之后;当前家谱还会在顶部和列表重复显示 | **前端产品体验**:能力存在,但点击层级和信息优先级不同;需按最终产品方向决定是否提升搜索、加入和媒体入口 |
| P1 | 宣传视频 `xcindex/video3/video4` | 有真实封面数据时点击播放 | 当前代码已完成封面优先和指定视频播放,但测试环境 `home_featured/video_center` 均为空 | **后端/运营+真机**:各配置至少一条有效视频和封面后,验证播放、滑动、暂停、返回、点赞和评论 |
| P2 | 注册 `login/login``login/register` | 注册时填写实名和性别 | 当前注册填写昵称,实名和性别移到个人资料 | **责任重分配,非缺失**:除非产品要求注册即实名,否则保留当前较短流程 |
| P2 | 加入、找回、删除、权限、视频来源 | 编号直接加入、安全问题找回、直接硬删除、硬编码 `auth_str`、任意视频 URL | 当前使用申请/一次性邀请码、短信找回、回收站/归档、动态权限目录、受控文件上传 | **升级替代,不能回退**:这些是安全和数据完整性改进,不按参考旧实现复制 |
| P2 | `render``invite``relationship/index``tree/add``ancestorsOrder``mine/share` | 日历演示、空页、静态原型或错误跳转 | 当前不提供对应独立页面,正式能力已在其他页面实现 | **非产品页**:不迁移;其中 `mine/share` 的“立即分享”实际跳创建家谱,不能当作分享功能 |
严格结论:78/78 表示参考路由没有漏登记;不表示当前功能已全部完成。差异必须继续经过“当前项目是否已在其他页面或升级流程中实现”的融合判定,不能直接等同于开发任务。
## 融合判定后的处理清单
| 判定 | 项目 | 当前能力核对 | 是否需要补 |
| --- | --- | --- | --- |
| 确认断链 | 家族视频封面式列表 | 家族视频已经支持封面上传、纵向播放器、点赞和评论,但列表仍直接铺播放器;只有平台视频列表实现了封面优先 | **需要前端补**,这是用户实际看到的交互缺口 |
| 确认断链 | 礼仪活动封面 | 编辑页和后端 `coverFile` 已存在,列表、详情都没有消费该字段 | **需要前端补**,属于已有契约未展示 |
| 确认断链 | 谱文单封面显示 | 编辑器和后端 `coverFile` 已存在,列表、详情没有显示 | **需要前端补**,属于已有契约未展示 |
| 确认断链 | 家谱设置 | 页面和全部设置功能都存在,但附加的永久删除资格请求失败会拖垮基础设置 | **前端需要隔离非关键失败,后端需要修资格接口** |
| 确认断链 | 家谱级重要证件入口 | 当前人物详情内的证件管理能力完整,但没有从家谱模块查看全部人物证件的入口;服务层和 OpenAPI 已支持不带人物条件查询 | **需要前端补**家谱级列表/入口,内部复用现有详情与编辑能力 |
| 确认断链 | VIP 订单关键信息 | 后端已有 `orderNo/payTime/expireTime`,当前契约丢弃订单号,页面只显示支付时间或到期时间其中一个 | **需要前端补**契约字段和三项独立展示 |
| 确认断链 | 提现记录处理信息 | 当前契约已保留提现单号、审核备注、打款参考号、到账时间,页面却没有显示 | **需要前端补**按状态展示;若产品必须显示“审核时间”,后端还需提供明确字段 |
| 确认断链 | 推广注册链接/二维码 | 当前推荐关系、推荐码和系统分享均可用,但只有文字推荐码;参考项目可扫码或复制注册链接 | **需要后端补 `shareUrl`,前端补二维码和复制链接**;旧项目硬编码域名不能直接迁移 |
| 确认断链 | 已有封面无法移除 | 谱文、礼仪、视频都能替换封面,但当前 UI 和前端严格契约不能表达“清空封面” | **需要统一契约后补前端**;不能只在本地把预览清空而不释放后端文件引用 |
| 确认断链 | 六类记录创建时间 | 参考相册、礼仪、功德、成长、贺礼簿、家族恩人列表显示创建时间;当前后端 APP VO 不返回该字段,现有页面展示的是缺失或不同语义的业务时间 | **需要后端补只读 `createTime`,前端再展示**;保留现有业务时间,不能拿它们代替创建时间 |
| 确认断链 | 功德图片 | 当前前端已有上传、编辑保留和详情预览,但最新后端请求/响应 DTO 没有媒体字段,MuMu 实测无法回显 | **需要后端补契约**;后端完成后前端再补列表首图并实测 |
| 确认断链 | 始迁祖 | 当前创建表单和前端严格契约已经有 `firstAncestorName`,最新后端创建 DTO 没有 | **只需要后端补**,前端无需重复开发 |
| 已融合 | 贺礼簿图片 | 当前“往来记录”创建/编辑支持上传,详情支持全部图片预览;仅列表没有参考项目的第一张缩略图 | 核心能力**不需要补**;列表首图需补 |
| 确认差异 | 家族恩人 | MuMu 实点确认“家族备忘”支持提醒和图片,但没有“家族恩人”名称、身份或类型 | 恩人业务没有被当前界面表达;产品只需选择复用 Memo 类型还是独立档案,不能继续判为已融合 |
| 部分融合 | 重要证件详情维护 | 当前按人物集中管理多份证件,支持解锁、查看、替换、添加和逐项删除 | 人物内维护无需重做;但家谱级聚合入口仍需补,不能整体判为已融合 |
| 已融合 | 多个列表的批量删除 | 当前都有逐条删除和回收站,相册照片另有完整批量选择与部分失败处理 | 用户目标已覆盖,**不自动补批量模式**;需产品明确后再做 |
| 已融合 | 首页搜索、创建、加入、排序 | 当前在“我的家谱”弹窗、搜索页、申请页和排序弹窗完成,且加入与排序规则更安全 | **不需要重复入口或重复页面**;只属于信息层级优化 |
| 已融合 | 注册实名/性别 | 当前注册保持短流程,实名和性别在个人资料维护 | **不需要补回注册页**,除非业务要求注册即实名 |
| 升级替代 | 加入、找回、删除、权限、视频来源 | 已分别升级为申请/一次性邀请码、短信找回、回收站/归档、动态权限目录、受控文件 | **不按参考旧实现补** |
| 能力已有,待数据 | 平台宣传视频 | 首页封面位、视频中心、指定视频纵向播放均已实现,测试环境列表为空 | **前端不需要补**;后端/运营投放数据后点击验收 |
| 产品待定 | 谱文多图 | 参考项目是 `imgs` 多图;当前产品模型是单个 `coverFile`,正文没有媒体数组 | 不直接判缺失;先确认产品是“单封面谱文”还是“正文多图谱文” |
| 产品待定 | 家谱编号与姓氏展示位置 | 参考总览直接显示家谱编号和姓氏;当前后端有 `genealogyNo`,但前端规范化时丢弃且自有家谱卡片不显示真实姓氏,姓氏只在搜索/设置可见 | 数据能力存在但展示目标不同;确认编号是否仍承担用户识别用途后再补,不能因参考项目有就自动恢复“编号加入” |
最终确认断链共 12 项。前端可独立处理的是家族视频列表、礼仪封面、谱文单封面、设置页失败隔离、家谱级重要证件入口、VIP 订单字段和提现记录字段;功德图片、始迁祖、推广注册链接、封面清空语义及六类记录创建时间需要后端契约配合。其余差异已融合、属于升级替代、需要运营数据或等待产品选择,不应重复开发。
## 逐路由映射表(78/78
| 参考路由 | 参考功能 | 当前页面/组件 | 状态 | 核对结论 |
| --- | --- | --- | --- | --- |
| `pages/index/index` | 首页公开家谱搜索、创建、加入、排序、两条宣传视频封面 | `pages/genealogy/my-genealogies``search``platform-videos` | 本轮补齐 | 首页读取 `home_featured` 并显示至多两条封面;点封面直达指定视频,点“查看更多”进入视频中心;当前后端未配置投放数据,MuMu 只能验证空状态和入口 |
| `pages/login/login` | 密码登录、旧式找回、注册、协议、微信登录 | `pages/auth/sign-in``register``reset-password` | 升级替代 | 密码/短信/微信、协议均覆盖;实名生日找回改为短信验证 |
| `pages/render/render` | 六个阳历/农历日历插件演示按钮 | 无 | 非产品页 | 无任何业务页面入口,仅为组件演示,不迁移 |
| `pages/index/addGenealogy` | 输入家谱编号加入 | `pages/genealogy/search``join-application` | 升级替代 | 改为一次性邀请码预览/兑换或公开家谱申请,避免盲加入 |
| `pages/index/createGenealogy` | 姓氏、谱名、堂号、始迁祖、地区、简介、访问权限 | `pages/genealogy/create` | 本轮补齐 | “始迁祖”已加入创建表单和严格请求契约;等待后端事务内创建首位世系人物 |
| `pages/index/invite` | 仅标题栏 | `components/genealogy/InvitationManager` | 非产品页 | 参考页为空壳;当前反而已有生成、复制、记录和撤销 |
| `pages/index/genealogyList/index` | 家谱总览、编号、姓氏、模块入口、邀请、删除 | `pages/genealogy/overview``settings` | 部分合并 | 主要基础信息、邀请、归档/注销均有入口;缺家谱级重要证件入口,且总览未显示后端已有的家谱编号,是否恢复编号展示待产品确认 |
| `pages/index/genealogyList/characterTable` | 字辈列表、人数、编辑入口 | `pages/genealogy/generation-poems` | 覆盖 | 当前另有批量预览、停用、恢复和排序 |
| `pages/index/genealogyList/add` | 新增、修改、删除字辈 | `pages/genealogy/generation-poems` | 合并覆盖 | 在同页完成批量维护,删除改为停用 |
| `pages/index/puwen/classList` | 文献分类筛选、无效的管理图标 | `pages/family/articles` | 覆盖 | 分类筛选已覆盖;参考页管理按钮没有完整写接口 |
| `pages/index/puwen/index` | 谱文列表、管理、密码解锁 | `pages/family/articles``article-detail` | 合并覆盖 | 列表、筛选、解锁、删除均覆盖 |
| `pages/index/puwen/genealogy` | 谱文标题、正文、落款、时间、图片及编辑 | `pages/family/article-detail` | 部分覆盖 | 正文、作者和权限已覆盖,但当前详情不显示现有单封面,也不支持参考项目的多图正文 |
| `pages/index/puwen/add` | 标题、正文、落款、安全问题、查看密码、多图及移除图片 | `pages/family/article-editor``article-detail` | 部分升级 | 安全问题已升级为短信找回;当前仅支持一个 `coverOssId`,没有参考项目的 `imgs` 多图契约,已有封面也不能移除 |
| `pages/index/puwen/wjmm` | 安全问题找回谱文/证件/日志密码 | `components/ContentPasswordRecoveryDialog` | 升级替代 | 改为已验证手机号短信找回,覆盖三类资源 |
| `pages/index/album/index` | 相册列表、名称、描述、数量、时间、新建 | `pages/family/albums` | 部分覆盖 | 加载、空、错、创建、编辑、回收站状态完整;当前列表和 APP VO 缺参考项目显示的创建时间 |
| `pages/index/album/add` | 相册名称、描述、创建 | `pages/family/albums` | 合并覆盖 | 当前使用同页表单弹层完成 |
| `pages/index/album/details` | 图片预览、管理勾选 | `pages/family/album-detail` | 覆盖 | 当前支持全选、批量删除和部分失败保留选择 |
| `pages/index/album/edit` | 修改名称/描述、删除相册 | `pages/family/albums` | 合并覆盖 | 编辑和移至回收站在列表页完成 |
| `pages/index/document/index` | 家谱级重要证件列表、批量管理、密码解锁 | `components/tree/PersonDocumentDialog` | 部分合并 | 单人物证件、多资源、解锁、编辑和逐项删除已覆盖;缺家谱级入口及跨人物列表。现有接口支持无 `lineagePersonId` 查询,前端可直接补齐 |
| `pages/index/document/add` | 人物关系、文件、安全问题、查看密码 | `components/tree/PersonDocumentDialog` | 升级替代 | 人物归属、文件和密码覆盖;安全问题改为短信找回 |
| `pages/index/video/index` | 封面式家族视频列表、管理、全屏播放入口 | `pages/family/videos` | 部分覆盖 | 发布、编辑、回收站、播放、点赞和评论已覆盖;列表仍直接铺 `<video>`,未按参考项目先显示封面后点击播放,也没有批量管理 |
| `pages/index/video/add` | 标题、链接、描述、封面/视频上传及移除 | `pages/family/videos` | 部分升级 | 当前只接受受控文件上传,不信任任意外链;但已有封面只能替换不能移除 |
| `pages/index/video/xcindex` | 宣传视频封面列表和详情入口 | `pages/family/platform-videos` | 本轮补齐 | 列表改为有封面时先显示封面,点击后进入指定视频纵向播放;无封面时显示原生视频控件 |
| `pages/index/video/details` | 单视频 controls 播放 | `pages/family/videos``platform-videos` | 合并覆盖 | 播放器直接嵌入对应业务页,避免依赖全局 store URL |
| `pages/index/meritsVirtues/index` | 功德录列表首图、姓名、内容、时间、批量管理 | `pages/records/merit-records` | 部分覆盖 | 文字增删改查可用;列表无首图和批量管理,图片会被当前后端契约丢弃 |
| `pages/index/meritsVirtues/add` | 姓名、内容、图片 | `pages/records/merit-records` | 前端待后端 | 前端已写上传控件,但最新 `AppMeritRecordBody``mediaOssIds`,不能判为功能完成 |
| `pages/index/meritsVirtues/details` | 姓名、内容、图片、时间、编辑 | `pages/records/merit-records` | 前端待后端 | 前端已写图片展示,最新 `AppMeritRecordVo``mediaFiles`MuMu 实测新增后详情无图 |
| `pages/index/gift/index` | 贺礼邀请分类、封面列表、批量管理 | `pages/records/ceremonies` | 部分升级 | 当前统一为活动礼仪并增加正式受邀人管理,但列表未显示已有封面且无批量管理 |
| `pages/index/gift/details` | 标题、类别、时间、地点、封面、备注 | `pages/records/ceremony-detail` | 部分覆盖 | 业务字段、礼簿和邀请状态已覆盖,但详情未显示后端已经返回的 `coverFile` |
| `pages/index/gift/add` | 类型、标题、时间、地点、封面、备注及移除封面 | `pages/records/ceremony-editor` | 部分覆盖 | 创建和编辑字段均覆盖,类型来自服务端字典;已有封面只能替换不能移除 |
| `pages/index/log/index` | 按人物进入成长日志 | `pages/records/growth-journal` | 合并覆盖 | 当前在同页选择人物和分类 |
| `pages/index/log/details` | 成长日志详情及编辑 | `components/records/GrowthRecordDetailDialog` | 合并覆盖 | 详情、密码解锁、密码管理和编辑完整 |
| `pages/index/log/add` | 人物、类型、内容、密码、图片/视频 | `pages/records/growth-journal` | 本轮补齐 | 已补视频选择、上传和详情播放;安全问题改短信找回 |
| `pages/index/favor/index` | 带首图的贺礼簿列表和批量管理 | `pages/records/relative-records` | 部分覆盖 | 当前升级为结构化往来记录,增删改查和详情图片已覆盖;列表未显示首图且无批量管理 |
| `pages/index/favor/details` | 标题、内容及编辑 | `pages/records/relative-records` | 合并覆盖 | 详情在当前列表页弹层展示 |
| `pages/index/favor/add` | 标题、内容 | `pages/records/relative-record-editor` | 覆盖 | 当前增加往来类型、对象、金额、日期等结构化字段 |
| `pages/index/memorandum/index` | 带首图的家族恩人列表和批量管理 | `pages/records/memos` | 部分覆盖 | 当前家族备忘具备相近的提醒、状态、增删改查和详情图片,但实点与代码均没有恩人名称/类型;确认存在语义缺口,具体存储方案待产品选择 |
| `pages/index/memorandum/add` | 标题、内容 | `pages/records/memos` | 合并覆盖 | 当前同页表单完成 |
| `pages/index/memorandum/details` | 标题、内容、时间及编辑 | `pages/records/memos` | 合并覆盖 | 当前同页详情/编辑完成 |
| `pages/index/admin/index` | 管理员列表、删除 | `pages/genealogy/members` | 覆盖 | 当前同时管理角色、人物绑定、退出、移除和转让谱主 |
| `pages/index/admin/add` | 勾选成员成为管理员 | `pages/genealogy/members` | 合并覆盖 | 当前在成员操作面板内修改角色 |
| `pages/index/admin/power` | `auth_str` 逐项权限 | `pages/genealogy/members` | 升级替代 | 改为服务端动态权限目录,不硬编码旧权限串 |
| `pages/index/familyCircle/index` | 动态、媒体、评论、回复、删除 | `pages/family/feed``feed-detail` | 覆盖 | 当前另有点赞、分页评论、回复、删除权限和失败重试 |
| `pages/index/familyCircle/add` | 内容、图片、发布 | `pages/family/feed-editor` | 覆盖 | 当前支持创建、编辑、媒体上传和草稿离开确认 |
| `pages/index/tree/index` | 表格世系、关系、状态、人物入口 | `pages/tree/overview``member-directory` | 合并覆盖 | 树、目录和人物状态拆分为清晰入口 |
| `pages/index/tree/tree` | 树谱、父母/配偶/兄弟/子女新增、编辑、删除、绑定 | `pages/tree/pedigree``add-relative``member-profile` | 覆盖 | 亲属新增、身份绑定、排序、编辑和停用均覆盖 |
| `pages/message/index` | 通知、加入审核、广告消息、邀请弹窗 | `pages/notification/message-center``message-detail` | 合并覆盖 | 通知目标可进入审核、动态、备忘和活动邀请;广告由推广组件处理 |
| `pages/message/details` | 通知详情 | `pages/notification/message-detail` | 覆盖 | 当前增加类型、家谱、发送人、关联事项和已读状态 |
| `pages/mine/index` | 个人资料、余额、VIP、帮助、设置、推广、退出 | `pages/profile/home``earnings``vip``settings` | 合并覆盖 | 入口全部覆盖并增加推荐偏好和合规文档 |
| `pages/mine/help` | 帮助分类和搜索框 | `pages/profile/help` | 覆盖 | 当前提供真实关键词过滤、加载、空和错误状态 |
| `pages/mine/setting` | 关于、联系、协议、退出 | `pages/profile/settings``compliance-document` | 合并覆盖 | 当前增加账号注销、版本化协议和服务端正文 |
| `pages/mine/password` | 旧密码、新密码、确认 | `pages/profile/change-password` | 升级替代 | 当前使用服务端验证策略,不把旧式表单当唯一安全边界 |
| `pages/mine/opinion` | 联系方式、反馈描述 | `pages/profile/feedback` | 覆盖 | 当前增加反馈历史、提交状态和失败提示 |
| `pages/mine/share` | 静态奖励图;“立即分享”错误跳创建家谱 | `pages/profile/promotions` | 非产品页 | 参考按钮没有分享实现,不复制错误跳转 |
| `pages/mine/vip_xf` | 套餐、协议、微信/支付宝/余额选择、支付 | `pages/profile/vip` | 覆盖 | 当前按 capability 动态展示渠道并严格校验支付结果 |
| `pages/mine/vip_success` | VIP 订单号、套餐、状态、支付/到期时间 | `pages/profile/vip` | 部分合并 | 当前已有订单记录、套餐、金额和状态;缺订单号,且支付时间与到期时间被合并为二选一显示 |
| `pages/mine/helpDetails` | 帮助正文 | `pages/profile/help``compliance-document` | 合并覆盖 | 普通帮助内联详情,协议使用独立版本化页面 |
| `pages/index/relationship/index` | 写死的“资料/亲属”静态原型 | `pages/tree/member-profile` | 非产品页 | 无真实数据或提交逻辑;当前人物详情已有真实亲属数据 |
| `pages/index/tree/personalData` | 人物基础、生卒、教育、职业资料 | `pages/tree/member-profile` | 覆盖 | 当前按服务端契约展示并保护敏感病史 |
| `pages/index/tree/add` | 大量未绑定输入框的静态人物原型 | `pages/tree/add-relative` | 非产品页 | 参考输入多数没有 v-model/提交实现;当前有真实表单 |
| `pages/mine/userInfo` | 个人/人物编辑及亲属新增的主实现 | `edit-profile``add-relative``edit-member` | 合并覆盖 | 账号资料和家谱人物资料按责任拆分,字段见下表 |
| `pages/index/log/selectUser` | 为日志或管理员选择成员 | 人物选择器、成员权限弹层 | 合并覆盖 | 当前从服务端候选项选择,不通过跨页临时存储回传 |
| `pages/index/genealogyList/ancestorsOrder` | 空模板 | `pages/tree/member-rank` | 非产品页 | 参考页仅 67 字节;当前已有真实同辈排序 |
| `pages/mine/helpList` | 帮助文章列表 | `pages/profile/help` | 合并覆盖 | 分类、列表、筛选和详情合并在一个页面 |
| `pages/content/detail` | 登录协议或普通文章正文 | `pages/profile/compliance-document` | 升级替代 | 当前按文档 key、版本和生效日期读取,不使用固定文章 ID |
| `pages/mine/fenxiang` | 注册链接二维码、复制注册链接;下载 App 入口已被注释 | `pages/profile/promotions` | 部分覆盖 | 推荐码、邀请人数和系统分享已覆盖;缺二维码和注册链接,当前后端仅返回推荐码及分享文案 |
| `pages/login/register` | 另一套短信注册页面 | `pages/auth/register` | 合并覆盖 | 删除重复注册入口,统一为一个严格契约页面 |
| `pages/mine/changemobile` | 密码验证后修改手机号 | `pages/profile/change-phone` | 升级替代 | 当前按服务端验证策略和短信码修改手机号 |
| `pages/index/sortGenealogy` | 手填排序序号 | `components/genealogy/OrderDialog` | 升级替代 | 改为上移/下移并一次保存稳定 ID 顺序 |
| `pages/index/log/list` | 指定人物和分类的成长日志、密码解锁 | `pages/records/growth-journal` | 合并覆盖 | 筛选、列表、详情、解锁均在同页完成 |
| `pages/index/log/class` | 成长日志分类 | `pages/records/growth-journal` | 合并覆盖 | 分类由服务端字典渲染,无需额外页面 |
| `pages/index/video/video2` | 家族视频 `.nvue` 上下滑动、自动播放、评论 | `pages/family/videos``components/family/VerticalVideoViewer` | 本轮补齐 | 已有纵向分页观看,滑走暂停、当前项播放,并保留点赞和评论入口 |
| `pages/index/video/video3` | 首页宣传视频 `.nvue` 上下滑动、自动播放 | `pages/family/platform-videos``components/family/VerticalVideoViewer` | 本轮补齐 | `G01 → F11` 路由已放行,首页封面可带 `videoId` 进入宣传视频纵向观看 |
| `pages/index/video/video4` | 指定宣传视频 `.nvue` 上下滑动、自动播放、评论 | `pages/family/platform-videos``components/family/VerticalVideoViewer` | 本轮补齐 | 页面加载后按 `videoId` 定位并打开指定内容,保留点赞和评论入口;需后端真实视频数据完成播放实测 |
| `pages/message/ad_detail` | 广告标题、图片、内容详情 | `components/AppPromotionStrip``pages/profile/promotions` | 升级替代 | 按推广目标类型安全打开,不复制独立旧广告页 |
| `pages/mine/withdrawal` | 余额、收益流水、申请提现、提现记录 | `pages/profile/earnings` | 覆盖 | 当前增加可用/冻结金额、最低金额、分页和取消申请 |
| `pages/mine/tixian` | 输入提现金额并提交 | `components/profile/EarningWithdrawalDialog` | 合并覆盖 | 当前增加收款方式、账户、姓名、幂等键和金额精度校验 |
| `pages/mine/tixian_log` | 提现单号、金额、状态、审核时间、备注 | `pages/profile/earnings` | 部分合并 | 当前已有金额、申请时间、状态、收款人和失败原因;契约已保留但页面未显示提现单号、审核备注、打款参考号和到账时间 |
## 旧轮补齐记录(严格复核结果以上文为准)
| 优先级 | 原差距 | 参考证据 | 本轮结果 |
| --- | --- | --- | --- |
| P0 | 创建家谱缺“始迁祖”字段 | `createGenealogy.vue``first_ancestor_name` | 创建表单增加“始迁祖”,写入 camelCase 严格契约并等待后端 |
| P1 | 宣传视频没有首页直接入口 | 参考首页直接进入宣传视频列表/全屏页 | “我的家谱”有谱和空状态都提供宣传视频入口 |
| P1 | 首页宣传视频只有文字行,没有参考项目的两条封面,且点击被路由来源限制拦截 | `pages/index/index.vue` 读取两条宣传视频封面,点击进入指定视频 | 首页接入 `home_featured` 封面预览;放行 `G01 → F11`;封面携带 `videoId` 直达播放器 |
| P1 | 宣传视频列表直接铺开全部播放器,没有“先看封面、点后播放”的层级 | `xcindex.vue` 先显示封面,点击进入 `video3/video4.nvue` | 有封面时先显示图片与播放标识,点击进入纵向播放器;缺封面才显示原生视频控件 |
| P1 | 家族/宣传视频缺上下滑动观看 | `video2.nvue``video3.nvue``video4.nvue` 均使用纵向分页列表 | 当前增加可复用的纵向视频浏览器;滑走暂停、当前项播放 |
| P1 | 成长日志缺视频上传和播放 | `log/add.vue``accept="video"``log/details.vue``video_urls` | 成长记录表单已支持图片/视频,详情按媒体类型显示图片或视频 |
| P1 | 功德录缺图片上传和详情展示 | `meritsVirtues/add.vue``imgs` 与详情图片列表 | 仅完成前端控件;最新后端 DTO 没有媒体字段,MuMu 实测图片未回显,仍未完成 |
## 主要用户路径逐步对比
| 用户目标 | 参考项目步骤 | 当前项目步骤 | 结论 |
| --- | --- | --- | --- |
| 登录/找回 | 登录页输入手机号和密码;找回时填写实名、生日等旧身份信息 | 登录页选择密码、短信或微信;忘记密码进入短信验证和重置 | 目标覆盖,找回方式升级 |
| 注册账号 | 手机号 → 验证码 → 密码/确认 → 真实姓名/性别 → 邀请码 → 勾选协议 | 手机号 → 验证码 → 密码/确认 → 昵称/推荐码 → 勾选协议;实名和性别在资料页补充 | 目标覆盖,账号字段责任重新划分 |
| 创建家谱 | 首页创建 → 姓氏/谱名/堂号/始迁祖/地区/简介/权限 → 提交 | 我的家谱 → 创建 → 同等字段及封面/加入模式 → 提交 → 返回家谱列表 | 前端完整;始迁祖等待后端原子建人 |
| 查找并加入家谱 | 首页搜索或输入家谱编号 → 直接加入 | 搜索公开家谱 → 查看状态 → 提交申请,或输入一次性邀请码 → 预览 → 兑换 | 目标覆盖,加入权限更严格 |
| 管理家谱 | 进入家谱 → 邀请/排序/资料/删除 | 进入总览 → 邀请、成员、权限、排序、设置 → 归档或永久注销 | 当前覆盖并细分高风险操作 |
| 维护字辈 | 字辈列表 → 新增/编辑/删除 | 字辈页 → 批量编辑 → 预览 → 保存;缺失项可停用/恢复 | 当前覆盖并保留历史 |
| 浏览和维护世系 | 树谱 → 点人物 → 新增亲属/编辑/删除/绑定 | 世系树或成员目录 → 人物详情 → 新增亲属/编辑/身份绑定/同辈排序/停用 | 当前完整覆盖 |
| 管理人物证件 | 家谱证件总表 → 解锁 → 新增/编辑/批量删除 | 人物详情 → 证件弹窗 → 解锁 → 新增资源/编辑/单项删除 | 人物内维护已覆盖且文件授权粒度更细;缺家谱级聚合入口 |
| 管理谱文 | 分类 → 列表 → 解锁 → 详情/新增/编辑/删除 | 谱文列表筛选 → 详情解锁 → 编辑器 → 回收站删除 | 当前覆盖,密码找回改为短信 |
| 管理相册 | 相册列表 → 新建/编辑 → 图片管理 | 相册列表 → 新建/编辑 → 相册详情 → 上传/预览/全选/批量删除 | 当前完整覆盖 |
| 观看家族/宣传视频 | 首页两条封面或视频列表 → 点击指定内容 → `.nvue` 上下滑动 → 点赞/评论 | 首页推荐封面 → 指定视频纵向播放,或“查看更多” → 封面列表 → 点击播放 → 点赞/评论 → 关闭返回 | 前端链路已补齐;MuMu 已验证入口和空状态,播放仍等待后端投放真实数据 |
| 发布家族圈 | 家族圈 → 发布文字/图片 → 评论/回复/删除 | 动态列表 → 编辑器发布 → 详情分页评论/回复/点赞/删除 | 当前完整覆盖 |
| 记录成长日志 | 选择人物/分类 → 填内容/图片/视频/密码 → 详情解锁 | 成长日志筛选 → 新建/编辑 → 图片或视频上传 → 详情图片预览/视频播放 → 密码管理 | 本轮补齐视频后完整覆盖 |
| 记录功德 | 功德录 → 姓名/内容/图片 → 详情/编辑 | 功德记录 → 姓名/标题/类型/金额/内容/时间/图片 → 详情/编辑/删除 | 文字和结构化字段已覆盖;图片等待后端媒体契约,列表首图也待补 |
| 创建礼仪活动 | 贺礼分类 → 新建 → 标题/时间/地点/封面/备注 → 详情 | 活动列表 → 新建/编辑 → 同等字段 → 详情 → 邀请对象/回复/礼簿 | 当前完整覆盖 |
| 处理通知 | 消息列表 → 通知详情/加入审核/邀请 | 消息中心 → 详情 → 按目标进入审核、动态、备忘或活动邀请 | 当前目标路由更明确 |
| 维护个人资料与安全 | 我的 → 资料/设置/改密码/改手机号/反馈 | 个人中心 → 资料、安全、设置、协议、反馈 → 对应独立流程 | 当前完整覆盖 |
| 分享推荐关系 | 推广页 → 展示注册链接二维码或复制链接 | 推广中心 → 查看推荐码/人数 → 复制推荐码或系统分享文字 | 推荐关系已覆盖;扫码和注册链接渠道未覆盖,等待后端可信 `shareUrl` |
| 开通 VIP | 套餐 → 协议 → 支付方式 → 支付 → 成功页/完整订单信息 | VIP 页 → 套餐/协议 → capability 支持的渠道 → 支付 → 订单列表 | 支付链路已覆盖;订单号、支付时间、到期时间的独立展示仍待前端补齐 |
| 查看收益和提现 | 收益 → 提现金额 → 提交 → 提现记录 | 收益页 → 提现弹窗 → 金额/方式/账户/姓名 → 提交 → 分页记录/取消 | 提交和取消链路已覆盖;记录页缺提现单号及处理结果字段 |
## 操作、显示和状态覆盖表
| 模块 | 入口与操作 | 主要显示字段 | 表单/提交字段 | 当前状态处理 |
| --- | --- | --- | --- | --- |
| 认证 | 登录、注册、发码、重置、微信登录、查看协议 | 手机号、登录方式、协议标题/版本 | 手机号、验证码、密码、确认密码、昵称、推荐码、协议同意 | 发码倒计时、错误、提交中、协议未同意、登录取消 |
| 家谱列表/搜索 | 创建、搜索、申请加入、邀请码兑换、排序、宣传视频 | 谱名、姓氏、简介、地区、成员数、角色、加入状态 | 关键词、邀请码、申请说明、稳定 ID 顺序 | 加载、空、错误、申请中、已加入、已申请、无权限 |
| 家谱设置 | 编辑资料、邀请、归档、恢复、注销 | 编号、谱名、堂号、地区、简介、封面、可见性、加入模式 | 谱名、堂号、地区编码、详细地址、简介、封面 OSS ID、访问/加入策略 | 未保存确认、上传中、归档限制、注销资格、短信双确认 |
| 世系人物 | 树/目录查看、新增亲属、编辑、绑定、排序、停用 | 人物全名、世代、排行、父母、配偶、兄弟、子女、在世状态及完整档案 | 关系类型、人物资料、配偶关系、同辈顺序、身份绑定 | 无权限、敏感字段隐藏、关系冲突、加载/空/错、删除确认 |
| 字辈 | 查看、批量编辑、预览、停用、恢复、排序 | 世代、字辈、说明、人数、状态 | 多行字辈文本、是否停用缺失世代 | 非法重复、空行预览、提交中、历史保留 |
| 谱文 | 分类筛选、详情、创建、编辑、解锁、删除 | 分类、标题、摘要、正文、封面、作者、发布时间、密码状态 | 分类 ID、标题、摘要、正文、作者、封面 OSS ID | 锁定/解锁、短信找回、无权限、回收站、加载/空/错 |
| 相册 | 创建、编辑、进入相册、上传、预览、批量删除 | 名称、描述、封面、照片数、创建时间、照片信息 | 相册名、描述、照片标题/描述/拍摄人/时间、图片 OSS ID | 上传中、选择态、全选、部分删除失败、回收站 |
| 视频 | 发布、编辑、普通播放、纵向观看、点赞、评论、删除 | 标题、描述、封面、视频、发布人、时间、播放/点赞/评论数 | 标题、描述、封面 OSS ID、视频 OSS ID、时长 | 自动播放/暂停、上下滑动、评论分页、无权限、回收站 |
| 家族圈 | 发布、编辑、详情、点赞、评论、回复、删除 | 发布人、头像、正文、图片、时间、点赞/评论数 | 动态类型、正文、媒体 OSS ID | 草稿离开确认、评论分页、失败重试、重复提交保护 |
| 功德记录 | 列表、创建、编辑、详情、图片预览、删除 | 姓名、标题、类型、金额、内容、时间、图片、合计 | 捐赠人、标题、类型、金额、内容、时间、图片 OSS ID | 上传中、移除附件、金额精度、加载/空/错、回收站 |
| 成长记录 | 人物/分类筛选、创建、编辑、详情、解锁、删除 | 人物、分类、标题、内容、记录/提醒时间、图片、视频 | 人物 ID、类型、标题、内容、记录/提醒时间、媒体 OSS ID | 图片/视频上传、移除附件、媒体播放、密码保护/找回、回收站 |
| 人情记录 | 列表、创建、编辑、详情、图片预览、删除 | 对象、关系、事项、时间、金额、内容、图片 | 对象、关系、事项、时间、礼金、内容、媒体 OSS ID | 金额校验、上传中、加载/空/错、回收站 |
| 家族恩人/家族备忘 | 参考:家族恩人列表、创建、编辑、详情、删除;当前:备忘完成切换 | 参考显示标题、说明、图片、创建时间;当前另有提醒时间和完成状态 | 当前仅有备忘标题、内容、提醒时间、完成状态、媒体 OSS ID | 已确认恩人语义未覆盖;产品选择在 Memo 中增加独立类型或新建档案契约 |
| 礼仪活动 | 分类筛选、创建、编辑、详情、邀请、回复、记礼 | 类型、标题、时间、地点、地址、备注、封面、邀请/礼簿状态 | 类型、标题、时间、地点、地址、备注、封面 OSS ID、受邀人、礼金/留言 | 字典加载、上传中、邀请接受/拒绝、重复记礼保护 |
| 通知 | 列表、已读、详情、跳转业务目标 | 类型、标题、摘要、正文、发送人、家谱、时间、已读状态 | 通知目标参数 | 加载/空/错、目标失效、权限不足 |
| 个人中心 | 编辑资料、改密码、改手机号、帮助、反馈、推广、退出 | 头像、昵称、实名、性别、生日、邮箱、手机号、版本和协议 | 头像 OSS ID、资料字段、旧/新密码、短信码、反馈、推荐码 | 上传、短信倒计时、未保存确认、退出、账号注销 |
| VIP/收益 | 选套餐、支付、查订单、查流水、申请/取消提现 | 套餐、价格、渠道、订单状态;可用/冻结余额、流水、提现状态。订单号/双时间及提现处理信息待补 | 套餐 ID、渠道、金额、收款方式、账户、姓名、幂等键 | 渠道 capability、支付取消/未知、分页、金额上下限、取消资格 |
| 推广 | 查看推荐码/邀请人数、复制、系统分享、查看推广内容 | 推荐码、邀请人数、分享标题/文案、推广封面/链接;缺注册链接与二维码 | 无前端提交;注册时提交推荐码 | 推荐功能关闭、读取失败、系统分享取消、推广链接失效 |
## 关键字段对比
| 模块 | 参考字段/显示项 | 当前结果 |
| --- | --- | --- |
| 注册 | 手机号、验证码、密码、确认密码、真实姓名、性别、邀请码、协议 | 手机号、验证码、密码、确认密码、昵称、推荐码、协议已覆盖;真实姓名和性别移到注册后的账号资料页,避免注册契约重复 |
| 创建家谱 | 姓氏、谱名、堂号、始迁祖、地区、简介、访问权限 | 全部覆盖;当前另有所在地、详细地址、封面、加入模式,始迁祖等待后端落库联调 |
| 家谱卡片/总览 | 姓氏、谱名、简介、成员数、家谱编号 | 公开搜索显示姓氏、简介和成员数;自有家谱卡片不显示真实姓氏/简介,总览显示简介但不显示 `genealogyNo`。属于展示位置差异,编号是否恢复待产品确认 |
| 谱文 | 分类、标题、正文、落款、时间、查看密码 | 当前覆盖分类、标题、摘要、正文、封面、作者、时间、密码状态和权限 |
| 相册 | 名称、描述、照片数、创建时间、照片 | 名称、描述、封面、照片数和照片管理已覆盖;列表及最新 APP VO 缺创建时间,另有批量选择、部分失败和回收站恢复 |
| 重要证件 | 家谱级入口、归属人物、证件类型、图片、查看密码 | 人物内字段和维护全部覆盖,另有多文件资源、资源编辑、单资源删除和安全访问票据;缺家谱级聚合入口 |
| 家族视频 | 标题、视频链接/文件、描述、封面、创建人、评论 | 内容字段、互动和纵向观看全部覆盖;任意 URL 改为受控业务文件 |
| 功德录 | 姓名、内容、图片、时间 | 文字字段覆盖;图片未完成,后端无媒体字段且当前列表无首图 |
| 贺礼/活动 | 类型、标题、时间、地点、封面、备注 | 全部覆盖,另有受邀人、接受/拒绝状态和礼簿 |
| 成长日志 | 人物、分类、内容、图片/视频、查看密码 | 全部覆盖,安全问题改为短信找回 |
| 贺礼簿 | 标题、内容、时间 | 当前往来记录已覆盖并结构化为对象、关系、事项、金额、日期和备注 |
| 家族恩人 | 标题、内容、时间 | 当前家族备忘有相近字段并增加提醒时间和完成状态,但没有恩人身份/类型,属于确认语义缺口 |
| 家族圈 | 发布人、正文、图片、时间、评论、回复 | 全部覆盖并增加点赞、分页、删除权限、错误和弱网状态 |
| 账号资料 | 头像、昵称、真实姓名、性别、生日、邮箱、手机号 | 当前账号资料覆盖;手机号由独立安全流程维护 |
| 人物资料 | 姓名、昵称、字、别名、性别、亲属、同辈顺序、配偶、出生/农历、生肖、地址、手机、邮箱、学历、职业、生平、状态、享年、病史、逝世日期/地点/类型、安葬日期/地点 | 当前人物新增、编辑、详情契约均覆盖;病史由服务端能力控制,无权限不得返回 |
| VIP | 套餐、价格、协议、微信/支付宝/余额、订单号、订单状态、支付时间和到期时间 | 支付能力已覆盖;后端已有字段,但当前缺订单号,支付/到期时间也未分别显示 |
| 收益提现 | 余额、流水、金额、提现记录、状态、审核/打款信息 | 提交、取消、冻结金额和幂等保护已覆盖;页面未消费契约已有的提现单号、审核备注、打款参考号和到账时间 |
| 推广邀请 | 推荐码、注册链接、二维码、复制/分享 | 推荐码、邀请人数、复制推荐码和系统分享文字已覆盖;注册链接和二维码缺失,后端也未返回可信 `shareUrl` |
## 表单必填规则差异(产品确认项)
这些差异是提交规则不同,不等于当前项目漏字段。除非后端业务约束或产品明确要求,不按参考项目机械收紧。
| 表单 | 参考项目提交校验 | 当前项目提交校验 | 结论 |
| --- | --- | --- | --- |
| 创建家谱 | 谱名、姓氏、堂号、始迁祖、地区、简介都必填 | 姓氏、谱名、地区必填;堂号、始迁祖、简介可选 | 字段均已有(始迁祖待后端落库),必填强度不同,需产品确认 |
| 谱文 | 标题、正文、落款、查看密码和两组安全问答必填 | 标题、正文必填;作者、摘要、封面可选;密码找回改短信 | 当前是安全流程升级与较短表单,不回退旧安全问答 |
| 家族视频 | 参考提交只强制视频 URL | 当前强制已上传视频和标题 | 当前规则更完整,不按参考放宽 |
| 功德录 | 姓名和内容必填 | 捐赠人和标题必填,内容可选 | 业务语义不同;需确认“内容”是否必须,媒体缺口另行处理 |
| 贺礼簿 | 标题和内容必填 | 当前往来记录为亲友姓名必填,其余结构化字段和备注可选 | 当前已从自由文本升级为结构化记录;是否要求事项/备注需产品确认 |
| 家族恩人 | 标题和内容必填 | 当前家族备忘为标题必填,内容可选 | 先确认是否复用备忘录;若复用,再确认是否允许仅标题记录 |
| 礼仪活动 | 标题、备注、地点、时间必填 | 类型和标题必填,时间、地点、备注可选 | 当前允许先建草稿式活动;若活动发布必须完整,需定义状态后再收紧 |
| 成长日志 | 人物、内容、查看密码和两组安全问答必填 | 标题必填,人物来自筛选上下文;内容、媒体可选;密码按当前保护机制设置 | 当前流程已融合人物上下文并升级找回方式,不复制旧问答校验 |
## 第四轮复核后仍待处理或验证
| 优先级 | 项目 | 当前事实 | 责任与完成条件 |
| --- | --- | --- | --- |
| P0 | 普通谱主进入家谱设置 | MuMu 仍收到后端“家谱必须先归档”,与参考项目可进入设置的目标不一致 | 后端拆分所有者鉴权和永久删除资格;修复后前端回归详情、设置、归档和恢复 |
| P1 | 家谱级重要证件 | 当前只有人物详情入口;服务端查询条件允许不传人物 ID | 前端增加家谱级入口和跨人物列表,复用现有证件详情/编辑能力 |
| P1 | VIP 订单完整字段 | 当前未显示订单号,支付时间和到期时间只显示其一 | 前端修正订单规范化契约并独立展示三项字段 |
| P1 | 提现记录处理字段 | 页面未显示契约已有的提现单号、审核备注、打款参考号和到账时间 | 前端按提现状态展示现有字段;若必须显示独立审核时间,再由后端增加字段 |
| P1 | 推广注册链接和二维码 | 参考项目能展示二维码并复制注册链接;当前只能分享推荐码文字,后端没有链接字段 | 后端提供可信 `shareUrl`,前端生成二维码并提供复制/分享;不迁移旧硬编码域名 |
| P1 | 六类列表创建时间 | 参考相册、礼仪、功德、成长记录、贺礼簿和家族恩人列表显示 `create_time`;当前除相册外只显示不同语义的业务时间,五个已确认映射 VO 均无 `createTime` | 后端先为五个已确认映射 VO/OpenAPI 增加只读创建时间;家族恩人按选定契约增加;前端同时保留业务时间 |
| P1 | 宣传视频真实内容 | 前端入口、封面态、指定视频直达和播放器已完成;测试环境 `home_featured``video_center` 均返回空列表 | 后端/运营各配置至少一条有效期内、含 `videoFile` 和建议含 `coverFile` 的数据后,在 MuMu 完成播放点击验收 |
| P1 | 独立图片宣传内容 | 最新后端 `PlatformVideoVo` 只表达视频及可选封面,不能表达“只有图片、点击放大”的独立内容 | 若产品确认需要纯图片,后端先定义混合媒体类型、图片文件和点击行为;前端再增加图片预览,不能猜字段 |
| P1 | 原生视频最终行为 | 代码和路由检查通过,空数据环境无法验证首帧、自动播放、上下滑动、横竖屏和弱网恢复 | 有真实视频数据后在 MuMu 和至少一台 Android 真机走完播放、切换、暂停、返回和评论 |
| P1 | 其余参考路径的交互级验收 | 78/78 路由已逐项登记,但本表不是 78 条路径的全部真机点击记录 | 按高频主路径逐页执行点击用例并记录结果;未点击的项不能再仅凭页面映射声称完全一致 |
## 交互与状态核对
当前项目对所有正式业务页统一补充了参考项目普遍缺失的状态:加载、空数据、错误重试、提交中、按钮禁用、离开未保存确认、删除二次确认、权限不足、请求取消和非幂等结果未知。它们属于当前项目正式验收范围,不因为参考项目没有实现而删除。
本表由 `scripts/check-frontend-parity.mjs` 校验参考 78 条活动路由是否逐条且只出现一次。它只能证明静态清单无遗漏;真实后端数据、真机微信/支付和原生视频行为仍需联调或真机验证。
## 第六轮:浏览器与 MuMu 实际点击复核(2026-08-23
本轮按用户指定环境区分执行:参考项目在已登录浏览器中点击,当前项目在已登录 MuMu 中点击,同时保留代码全量路由核对。详细步骤、截图和分组结论见 [点击对比审查](click-comparison-audit-2026-08-23.md)。
新增运行时证据没有推翻上文的融合判定,重点确认如下:
- 家族内容模块确实合并到当前“家族”主标签,不是缺页。
- 参考家族视频采用封面卡片进入独立播放页;当前测试家谱为空,无法验真实播放,但当前家族视频列表源码直接渲染播放器的差异仍成立。
- 参考推广页直接显示注册链接二维码;当前只有推荐码、复制和系统分享,没有页面二维码或注册链接。
- 参考家谱级“重要证件”可集中显示多张证件图片并提供管理/上传;当前只有人物内证件入口。
- 参考 VIP 记录显示订单号、支付时间和到期时间;当前订单卡仍缺这些完整字段。
- 当前家谱设置进入即读取失败,点击“重新读取”后画面完全相同,P0 阻断仍未修复。
## 第七轮:剩余核心入口继续点击(2026-08-23)
继续实点参考礼仪、谱文、功德、家族恩人、贺礼簿、成长记录、字辈谱、世系谱、家族动态、管理员、VIP 购买、消息、创建和加入家谱,并再次核对当前页面与最新后端源码。
- 礼仪封面、功德多图、成长具体记录的 `create_time` 均由运行态确认,既有三项结论成立。
- 参考 `favor` 的真实产品名称是“贺礼簿”;MuMu 当前入口也直接显示“贺礼簿”,新建页是字段更完整的往来记录,确认不缺独立重复页面。
- 参考 `memorandum` 的真实产品名称是“家族恩人”;MuMu 当前入口和新建页明确是提醒型“家族备忘”,最新前后端主业务代码也没有恩人类型。确认存在业务语义缺口;产品只需选择复用 Memo 类型还是独立档案。
- 参考“家族普”实际是动态流,对应当前“家族圈”;字辈谱、世系谱、管理员、消息和加入家谱均已有融合或更安全的升级实现。
- MuMu 登录态恢复后已补点礼仪、功德、贺礼簿、家族备忘、人物详情和成长日志。五组页面均可进入,空状态和新建表单稳定;因测试家谱对应列表为空,首图、详情媒体和创建时间仍只能依据契约/模板判断,不能声称有数据链路已通过。
+148
View File
@@ -0,0 +1,148 @@
# 上线前待处理事项
> 2026-08-22:下方关于后端能力“尚未实现”的描述属于历史快照。最新源码与 MuMu 点击联调结果、当前真实阻塞项以 [《APP 前后端联调结果与后端处理单(2026-08-22)》](./backend-integration-report-2026-08-22.md) 为准。
更新时间:2026-08-17
> 2026-08-17 更新:需要直接交给后端执行的缺口、源码核对结果和接口验收标准统一记录在 [《后端开发对接任务单》](./backend-integration-tasks-2026-08-17.md)。本文件继续保留发布、安全和环境阻塞;下方较早的参考项目差距描述如与新任务单冲突,以任务单为准。
本文只记录当前前端仓库无法独立闭环的事项。完成后应删除对应条目,不能将本文当作长期豁免。
## 已核对范围
- 参考项目 `C:\Users\Rain\Desktop\job\Jiapu-App` 共注册 78 个路由;`pages/index/video/video2.nvue``video3.nvue``video4.nvue` 均为有效视频页,已纳入逐项对比并在当前项目补齐纵向观看。`pages/index/genealogyList/ancestorsOrder.vue` 仅有 67 字节空壳,不作为待迁移功能。全部参考路由已按业务能力映射到当前 59 个页面。
- 后端目录 `C:\Users\Rain\Desktop\job\Genealogy` 已可读取完整源码;2026-08-17 已完成 APP 认证、VIP、族人档案、视频评论、权限、回收站与删除引擎的静态核对。该目录当前不是可识别的 Git 工作树,本轮没有修改或运行后端;前端契约仍以仓库内唯一的 `genealogy-app-openapi.yaml` 为准。
## P0:Android 原生隐私弹窗链接指向错误或过期协议
- 现状:`androidPrivacy.json` 中“用户协议”仍指向旧 H5 内容 `id=62`。2026-08-13 实测该公开页面标题为“会员服务协议”,正文生效日期为 2021-08-08,与 App 内合规接口返回的《代代相传家谱用户协议》(版本 1.0,生效日期 2026-08-10)不是同一文档;`id=63` 的隐私政策也仍是旧内容源。原生弹窗发生在 WebView 和 App 内页面加载前,不能直接复用当前 Vue 合规页。
- 责任端:后端合规内容维护者 / H5 发布负责人 / Android 发布负责人。
- 处理要求:为当前 `user_agreement``privacy_policy` 提供无需登录、可在系统 WebView 打开的稳定 HTTPS 正文地址,再替换 `androidPrivacy.json` 两处首轮提示和两处二次确认链接。页面正文、版本号、生效日期、主体和联系方式必须与当前合规接口一致;不得继续用会员协议冒充用户协议。
- 验收:全新安装后点击原生弹窗的两个链接,分别打开 2026-08-10 生效的正式用户协议与隐私政策;拒绝、二次确认、同意路径均正常;抓包确认正文地址无需用户令牌且全程 HTTPS。
## P0:正式隐私政策缺少 uni-app/DCloud 运行时披露
- 现状:当前线上 `privacy_policy` 正文详细列出了阿里云 OSS、阿里云短信和微信支付,但没有说明产品基于 DCloud uni-app5+ App/Wap2App)开发,也没有披露相应运行时为统计分析、启动与异常日志所处理的设备标识信息。DCloud 的 Android 应用市场合规自查明确要求在 App 隐私政策中补充这部分说明;该正文由后端合规内容管理,前端不能自行改写线上法律文本。
- 责任端:隐私合规负责人 / 后端合规内容维护者。
- 处理要求:由合规负责人按正式包实际启用的 DCloud 模块、统计配置和 SDK 清单核实处理目的、信息类型、共享对象、隐私政策链接及关闭方式,再更新 `privacy_policy` 新版本并重新发布;不能照抄超出实际能力的模板字段。
- 验收:正式隐私政策逐项覆盖安装包实际 SDK 与权限;DCloud 隐私合规检测和目标应用商店人工审核通过;App 内正文与 Android 原生弹窗链接打开的是同一生效版本。
## P0:认证契约将无盐 MD5 摘要直接作为密码凭据
- 现状:唯一 OpenAPI 的注册、密码登录、修改密码和找回密码统一要求客户端提交 32 位 MD5,当前前端只能按该契约调用。无盐 MD5 是快速、可离线猜测且可重放的固定凭据;它不能替代 TLS,也不符合现代密码存储应使用逐用户盐值和有成本密码派生算法的要求。前端不能单方面改算法,否则现有账号与后端认证会全部失配。
- 责任端:后端认证负责人 / 安全负责人 / OpenAPI 维护者。
- 处理要求:先确认服务端数据库是否还会对收到的 MD5 再使用 Argon2id、scrypt、bcrypt 或 PBKDF2 等带盐算法存储;若没有,必须制定密码凭据迁移。新契约应明确传输只依赖 HTTPS,服务端保存逐用户盐值的慢哈希;如需兼容旧账号,应在一次成功登录后升级存储,并定义旧契约下线时间,不能长期接受两套等价入口。
- 验收:数据库泄露场景下不存在可直接重放登录的客户端 MD5 凭据;新注册与改密只产生带盐慢哈希;旧账号迁移、并发登录、忘记密码和回滚均有服务端自动测试;OpenAPI 与客户端在同一版本切换。
## P0:会话令牌仍存放在普通应用缓存
- 现状:当前 `utils/session.js` 通过 `uni.setStorageSync` 持久化 Bearer `access_token`。该接口只提供应用本地缓存能力,仓库中没有使用 Android Keystore、iOS Keychain 或由其保护的加密封装,也没有可验证的令牌备份排除策略。令牌被提取后可直接代表用户访问家谱及重要证件等敏感数据,前端 JavaScript 内置固定加密密钥不能解决此问题。
- 责任端:App 原生安全负责人 / 认证后端负责人 / 发布负责人。
- 处理要求:正式 App 使用经过审查的原生安全存储插件,以 Android Keystore 和 iOS Keychain 保护会话材料;禁止把密钥硬编码进前端资源。后端同时提供短期访问令牌、可撤销的刷新令牌、设备/会话管理和异常吊销能力,并明确备份恢复、换机、卸载和设备锁屏后的行为。
- 验收:正式签名包的动态与静态安全测试确认普通缓存、备份和日志中没有明文会话令牌;退出登录、注销、改密和服务端吊销后旧令牌立即失效;新安装、换机和备份恢复不会继承可用会话。
## P0:VIP 后端仍是微信单渠道契约
- 现状:前端已经按 OpenAPI 接入微信、支付宝和余额三种方式,并根据 capability 动态展示可用渠道。后端 `PaymentOrderVo` 已有微信 `prepayId` 和签名字段,但 `VipPurchaseCapabilityVo` 没有 `paymentMethods``AppVipOrderBody` 没有 `paymentMethod`,支付结果也没有渠道判别字段与支付宝/余额结果。
- 责任端:`C:\Users\Rain\Desktop\job\Genealogy` 后端支付模块。
- 处理要求:按唯一 OpenAPI 同步 capability、下单体和支付结果;微信继续返回完整预支付参数,支付宝返回 APP 支付订单字符串,余额支付在服务端事务内完成扣款与会员开通。
- 验收:三种渠道分别覆盖成功、取消、失败和超时查询;同一订单不能重复支付或重复开通,余额不足不能返回伪成功。
## P0:微信开放平台和云打包参数未提供
- 现状:`manifest.json` 已启用 Payment 模块,但仓库没有可确认的正式微信 AppID、Android 包名/签名对应关系、iOS Universal Links 和发布签名材料。
- 责任端:发布负责人 / 微信开放平台管理员。
- 处理要求:在 HBuilderX 云打包配置中填写正式参数并完成微信开放平台校验。密钥、证书密码和 AppSecret 不得提交到仓库。
- 验收:Android 与 iOS 正式签名包都能拉起微信,支付后能返回应用并由服务端确认订单状态。
## P1:推广绑定前端已完成,等待后端实现
- 现状:注册页已支持推荐码,个人中心已提供“我的推荐”、复制和分享入口;后端注册体仍没有 `referralCode`,也没有推荐资料、归属或分佣服务。
- 责任端:产品负责人 / 后端负责人。
- 处理要求:按唯一 OpenAPI实现注册时一次绑定和 `/referrals/me`;服务端落实防自邀、防重复绑定、并发幂等和收益归属,前端不接受本地伪造统计。
- 验收:分享、首次绑定、无推荐码、自邀、重复绑定、并发注册和收益入账均有服务端测试。
## P1:微信快捷登录前端已完成,等待后端实现和正式配置
- 现状:前端已启用 OAuth 模块并完成 `uni.login({ provider: "weixin" })` 授权码登录、取消和失败状态;后端 `AppAuthController` 仍没有 `/auth/login/wechat`,正式微信开放平台参数也未提供。
- 责任端:产品负责人 / 后端认证负责人 / 微信开放平台管理员。
- 处理要求:按唯一 OpenAPI 实现一次性授权码交换、已有账号匹配/绑定和冲突响应,并配置正式微信开放平台参数;AppSecret 不得进入前端仓库。
- 验收:Android/iOS 正式包都能完成首次授权、已有账号登录、取消授权、重复登录和账号冲突,客户端不直接信任微信展示资料。
## P1:宣传视频前后端主体已存在,等待收紧评论契约和联调
- 现状:当前前端已有独立平台视频页,支持播放、点赞、一级评论和删除;后端 `AppPlatformVideoController` 已有列表、详情、点赞和评论。后端评论请求仍允许 `parentCommentId`,与本期平台视频只保留一级评论的契约冲突。
- 责任端:产品负责人 / 后端内容负责人。
- 处理要求:后端去掉平台评论 `parentCommentId` 输入并清理或迁移已有回复数据;按 placement、platform 和上下线时间返回有效视频。
- 验收:首页入口、列表播放、空状态、失效视频、点赞幂等和一级评论可在正式环境验证。
## P1:细粒度权限前端已完成,后端缺动态权限目录
- 现状:前端成员管理已按动态目录渲染分组权限并读取/保存成员授权。后端成员权限 GET/PUT 已存在,但没有 `/permission-catalog`;前端不会重新硬编码参考项目的 `auth_str`
- 责任端:产品负责人 / 后端权限负责人 / OpenAPI 维护者。
- 处理要求:基于后端唯一权限定义实现 `/permission-catalog`,返回权限编码、名称、分组和禁用原因;保存接口返回最终权限集合并由服务端统一判权。
- 验收:新增权限无需前端发版即可出现;所有页面显示能力与服务端鉴权一致,越权请求被拒绝。
## P1:永久注销前端已完成,后端缺 APP 谱主入口
- 现状:前端设置页已完成归档前置、不可用原因、脱敏手机号、精确家谱名和短信双确认。后端已有管理员使用的删除资格检查、任务和执行引擎,但没有 APP 谱主发码/提交入口,`AppGenealogyVo` 也没有能力投影字段。
- 责任端:产品负责人 / 后端家谱负责人 / 数据合规负责人。
- 处理要求:按唯一 OpenAPI 为现有删除引擎增加 owner-only APP 包装,校验已归档、阻塞任务、精确名称和短信码;成功后立即撤销所有成员端的家谱上下文。
- 验收:非谱主、未归档、名称不符、错码和阻塞任务均拒绝;成功提交可审计、任务失败可追踪重试,成员不能继续访问该家谱。
## P1:真实写操作与商店发布仍需发布环境验收
- 现状:本轮 MuMu 点击测试使用已登录账号,避免对现有家谱执行删除、撤销邀请、上传、提现等不可逆或会产生真实数据的操作;也未持有应用商店账号和正式签名材料。
- 责任端:测试负责人 / 发布负责人。
- 处理要求:使用专用测试租户走通创建、编辑、上传、邀请、撤销、注销和提现审核等流程,再进行 Android/iOS 正式构建与商店隐私合规检查。
- 验收:测试数据可清理,关键写操作、失败重试和权限边界均有记录;正式包安装、升级、冷启动和回退流程通过。
## P1:多数创建接口缺少跨会话幂等契约
- 现状:前端已对创建家谱、成员、谱文、动态、相册、视频、祭祀和成长记录等写操作增加当前页面生命周期内的重复提交保护,但应用重启、页面重载、请求超时后重试会丢失这层状态。唯一 OpenAPI 目前只有提现请求提供稳定的 `requestId`,其余创建接口没有定义可跨会话重放的幂等键,因此客户端无法保证弱网重试只产生一条业务数据。
- 责任端:各业务后端负责人 / OpenAPI 维护者。
- 处理要求:由服务端统一定义幂等键的所有者、作用域、有效期和重放响应语义,并通过租户、用户、操作类型和幂等键建立持久化唯一约束;不能只依赖单进程内存锁或前端按钮禁用。相同幂等键携带不同业务参数时应明确拒绝。
- 验收:双击、请求超时、连接中断、应用重启后使用同一幂等键重试,服务端只创建一次并返回同一业务结果;同键不同参数被拒绝;并发请求有自动化覆盖。
## P1:大文件上传没有真实分片和明确大小契约
- 现状:当前上传初始化、分片、完成三段接口已经接入,文件上传已与普通 API 的 15 秒超时分离,独立放宽为 120 秒。但客户端仍固定 `totalChunks: 1``chunkSize: 文件总大小`,视频会作为一个完整请求上传。唯一 OpenAPI 没有声明图片/视频最大大小、服务端允许的分片大小和分片数量上限,因此前端无法据此安全拆分或给出准确限制。普通小图片不受此项影响,较大视频仍可能受内存和弱网影响。
- 责任端:文件服务负责人 / OpenAPI 维护者 / 前端负责人。
- 处理要求:由文件服务先确定最大文件大小、推荐分片大小、并发数、断点续传和过期上传清理规则,并写入唯一 OpenAPI;随后客户端按字节范围计算每片 MD5,逐片上传,不能继续把完整视频称作分片。
- 验收:在弱网环境上传接近上限的视频,中断后可继续或明确重新开始;任一分片大小、MD5 或顺序错误均被服务端拒绝;客户端不会一次性把完整大视频读入内存,上传超时后也不会留下不可识别状态。
## P1:隐私政策声明与当前客户端能力不完全一致
- 现状:当前客户端只在用户主动选择上传内容时读取相册或媒体库,没有调用相机,也没有接入系统推送通知;线上隐私政策的“设备权限”章节仍声明可能申请相机和通知权限。前端已只配置实际需要的相册读取权限,没有为了匹配文案增加无用权限。
- 责任端:隐私合规负责人 / 后端内容运营。
- 处理要求:按最终正式包逐项核对权限清单。若本期不接入拍摄和系统推送,应从线上隐私政策删除相机、通知相关声明;若本期确实接入,则需先完成对应产品功能、运行时授权时机和拒绝授权后的降级流程,再更新正式包权限。
- 验收:Android 与 iOS 正式包的权限清单、运行时弹窗、应用商店隐私标签及线上隐私政策逐项一致,普通浏览页面不会提前申请媒体权限。
## P1:公共内容接口的线上匿名访问不符合 OpenAPI
- 现状:唯一 OpenAPI 将官网文章、官网单页、帮助列表/详情和推广列表标记为 `security: []`。2026-08-13 使用必需的 `clientid``tenantId` 且不带 Authorization 抽查线上环境时,`GET /genealogy/app/site/articles``GET /genealogy/app/site/pages/{pageKey}``GET /genealogy/app/help-articles``GET /genealogy/app/help-articles/{helpId}``GET /genealogy/app/promotions` 均返回业务 401;同样声明匿名的行政区划和合规文档接口可以正常读取,说明不是公共请求头缺失。`GET /genealogy/app/genealogies/public` 虽然名称包含 public,但没有覆盖全局 `SaToken`,线上返回 401 与当前 OpenAPI 一致,不属于本项偏差。当前帮助与推广页面只从登录后的个人中心进入,前端暂时沿用会话请求以保持线上功能。
- 责任端:后端鉴权配置维护者。
- 处理要求:按 OpenAPI 对上述公共内容只读接口放行匿名访问;公共请求仍应保留租户与客户端头,不应依赖用户令牌。若产品决定必须登录,则应先修改 OpenAPI 和产品入口,再由前端统一收口,不能让线上行为与唯一契约长期分叉。
- 验收:无 Authorization 请求上述接口均返回业务成功;携带过期令牌不会导致公共页面跳转登录;帮助文章、推广位和官网公共内容能在退出登录状态正常读取。
## P2MuMu 的 Chromium 图块内存警告仍存在
- 现状:六张通用长背景已从 1440×3600 缩至 720×1800,并改为按视口裁切,图片体积由约 31.7 MB 降至约 13.9 MB;页面实测没有缺图。MuMu 中的 HBuilder 调试基座冷启动仍会输出 8 条 `tile memory limits exceeded`PSS 约 159 MB。
- 已确认边界:警告来自调试基座的 Chromium 渲染进程,当前没有对应的 Vue 异常或页面缺失;仅靠继续压缩单张背景无法证明可以消除。
- 责任端:测试负责人 / 前端负责人。
- 处理要求:使用正式签名包在至少一台中端 Android 真机复测首页、个人中心和世系页;通过 Android Studio Profiler 确认是调试基座开销还是页面合成层问题。
- 验收:正式包连续浏览核心页面不出现缺图、闪白或崩溃;若仍有警告,取得可定位到具体图层的 trace 后再调整对应页面,不能无依据继续降画质。
## P2:推广封面素材清晰度不足
- 现状:MuMu 实测“我的 → 应用推广”时,服务端返回的“数字家谱,从今天开始”封面被放大后明显模糊;同页文字、边框和本地品牌图均清晰,可排除整个页面缩放异常。
- 责任端:推广内容运营 / 后端文件管理。
- 处理要求:在推广管理端替换为适合横向卡片的高清封面,建议有效宽度不低于 1200 px,并保留合理宽高比;不要让客户端对低分辨率缩略图进行放大。
- 验收:Android 设备 1×、2×、3× 密度下查看推广中心和首页推荐位,图片无明显锯齿、马赛克或拉伸变形。
## P2HBuilder 调试基座重启时漏部署运行时文件
- 现状:首次启用 `androidPrivacy.json` 后,HBuilder 调试基座重启曾缺少 `__uniappview.html``uni-app-view.umd.js`,显示 `ERR_FILE_NOT_FOUND`。本轮测试通过把本地编译产物重新部署到 MuMu 后恢复,项目编译产物本身包含这两个文件。
- 责任端:测试环境维护者。
- 处理要求:下次运行前由 HBuilderX 重新执行“运行到 Android App 基座”,不要依赖本轮模拟器内的临时部署结果。
- 验收:清理并重建调试基座后可连续冷启动三次,且不再出现 `ERR_FILE_NOT_FOUND`
@@ -0,0 +1,110 @@
# 当前项目与参考项目第二轮独立复审
审查日期:2026-08-23
参考项目:`C:\Users\Rain\Desktop\job\Jiapu-App`(浏览器,已登录)
当前项目:`C:\Users\Rain\Desktop\job\jiapuapp`MuMu,已登录)
后端项目:`C:\Users\Rain\Desktop\job\Genealogy`
## 结论
第二轮没有沿用第一轮截图直接下结论,而是重新抓取参考项目与 MuMu 当前页面、重新点击关键入口,并再次核对当前前端源码、OpenAPI 与后端 Java 契约。
- 78 条参考路由仍全部有映射,当前项目 59 条路由和 172 个源码文件的完整性检查通过。页面数量不同主要来自当前项目的融合设计,不能按数量机械判缺。
- 第一轮确认的 14 项差异,经第二轮逐项复查后仍然成立;部分前端契约已经接入,但对应页面未消费,或后端契约尚未闭环。
- 第二轮新增确认 1 个当前项目自身的交互缺陷:成长记录新建表单未手动填写时,因自动带入人物仍被判定为“已修改”,返回会错误弹出放弃确认。
- 合计:14 项参考对比差异 + 1 项当前交互缺陷。最高风险仍是“家谱设置完全无法读取”。
- 本轮只做审查和文档整理,没有修改业务代码、接口契约或数据。
## 编号复审步骤与健康度
| 步骤 | 重新检查的范围 | 健康度 | 第二轮结论 |
| --- | --- | --- | --- |
| 1 | 路由与源码完整性 | 健康 | 参考 78 条路由均有登记与融合映射;当前 59 条路由、172 个源码文件检查通过。 |
| 2 | 首页与入口层级 | 基本健康 | 搜索、创建、加入、排序、宣传内容在当前项目均可找到,但位置比参考项目更深;属于信息架构差异,不计缺页。 |
| 3 | 家谱总览与家族主入口 | 基本健康 | 字辈、世系、成员、审核、谱文、相册、礼仪、备忘、人物录、贺礼簿、功德和视频均已有融合入口。 |
| 4 | 谱文、相册、礼仪、备忘、贺礼簿、功德、视频逐项进入 | 部分健康 | 7 个入口都可进入且空状态正常;由于测试家谱缺少对应内容,只能确认入口和状态,列表媒体与播放仍结合源码判断。 |
| 5 | 家族视频展示与播放入口 | 不健康 | 参考项目先显示封面卡片,点击后播放;当前列表仍直接铺设 `<video controls>`,未使用已有 `coverFile` 做封面优先卡片。 |
| 6 | 谱文与礼仪封面 | 不健康 | 两类响应契约均已保留 `coverFile`,编辑器也能上传,但列表和详情仍未渲染,属于前端字段消费缺口。 |
| 7 | 功德记录媒体闭环 | 不健康 | 当前前端已有上传、详情预览和 `mediaOssIds/mediaFiles` 处理;最新后端 `AppMeritRecordBody/AppMeritRecordVo` 仍无媒体字段,保存回显链路未闭环。 |
| 8 | 推广、收益提现与 VIP | 不健康 | 推广推荐码与系统分享可用,但无注册链接/二维码;提现契约字段未完整展示;VIP 未显示订单号且支付/到期时间没有独立展示。 |
| 9 | 重要证件与家族恩人语义 | 不健康 | 重要证件只存在于人物详情,没有参考项目的家谱级聚合入口;当前“家族备忘”也没有“家族恩人”的身份、分类和档案语义。 |
| 10 | 家谱设置首次读取与重试 | 阻断 | 首次进入显示“家谱设置暂时无法读取”,点击“重新读取”后画面完全相同。源码仍把基础设置与永久删除资格放在同一个 `Promise.all`。 |
| 11 | 创建家谱始迁祖 | 不健康 | 前端有 `firstAncestorName` 表单与请求字段;最新后端业务源码未找到接收并原子创建第一代人物的实现。 |
| 12 | 成长日志新建后返回 | 不健康(新增) | 空表单自动带入 `lineagePersonId` 后即被 `dirty` 判定为真,未编辑也弹出“放弃成长记录?”。 |
## 第二轮确认仍需处理的差异
| 优先级 | 差异 | 当前项目已有能力 | 责任与完成条件 |
| --- | --- | --- | --- |
| P0 | 家谱设置整体读取失败 | 设置页面和全部表单已存在 | 前端将基础设置读取与永久删除资格拆成独立状态;后端确认普通家谱的详情及删除资格响应。首次读取和重试均须成功。 |
| P0 | 家族视频列表不是“封面卡片 → 点击播放” | `coverFile`、视频文件、纵向播放器均已有 | 前端以封面卡片作为列表主展示,点击打开指定视频;有真实数据后验证播放、暂停、返回和上下滑动。 |
| P0 | 功德图片无法由后端稳定保存和回显 | 前端上传、移除、详情预览已存在 | 后端为功德请求/响应增加媒体字段及文件引用维护;前后端完成新增、编辑、删除、回显联调。 |
| P0 | 成长日志空表单误报未保存修改 | 已有返回保护机制 | 前端的脏状态基线应包含系统自动带入的人物,只有用户实际修改后才弹放弃确认。 |
| P1 | 礼仪封面未展示 | 后端和前端契约已有 `coverFile`,编辑器可上传 | 前端列表和详情消费封面,支持预览。 |
| P1 | 谱文封面未展示 | 后端和前端契约已有 `coverFile`,编辑器可上传 | 前端列表和详情消费封面;正文是否扩展为多图另行决定。 |
| P1 | 家谱级重要证件聚合入口缺失 | 人物详情已有证件管理与安全访问 | 前端新增家谱级入口和聚合列表,复用现有查看、编辑及安全能力。 |
| P1 | VIP 订单号、支付时间、到期时间显示不完整 | 后端已有 `orderNo/payTime/expireTime` | 前端契约保留 `orderNo`,页面将三项分别标注展示。 |
| P1 | 提现处理字段未展示 | 前端契约已有 `withdrawalNo/auditRemark/payoutReference/paidAt` | 按状态显示提现单号、审核备注、打款参考号和到账时间。 |
| P1 | 推广注册链接与二维码缺失 | 推荐码、邀请人数、复制和系统分享已存在 | 后端返回可信 `shareUrl`;前端展示二维码,并支持复制/分享注册链接。 |
| P1 | 谱文、礼仪、视频封面不能显式移除 | 三类编辑器可上传和替换 | 统一 `coverOssId: null` 的清空契约,前端增加移除操作并验证保存回显。 |
| P1 | 参考内容列表的创建时间未覆盖 | 当前已有业务发生时间 | 后端为谱文、礼仪、相册、成长、往来和功德等响应补只读 `createTime`,前端与业务时间分开显示。 |
| P1 | 贺礼簿列表缺少首图 | 详情可预览 `mediaFiles` | 前端列表显示 `mediaFiles[0]` 缩略图。 |
| P1 | “家族恩人”业务语义缺失 | 通用家族备忘有标题、内容、提醒和图片 | 产品先确定扩展 Memo 类型或建立独立档案;随后补身份、分类、独立入口、首图和创建时间。 |
| P1 | 创建家谱的始迁祖未由后端落库 | 前端已提交 `firstAncestorName` | 后端在创建家谱事务中接收该字段,并原子创建第一代人物。 |
## 已融合或已升级,不应重复补页
| 参考能力 | 当前项目对应能力 | 判定 |
| --- | --- | --- |
| 世系谱 | 图形化世系树 | 已融合升级,不另建重复页面。 |
| 字辈谱 | 字辈管理 | 已融合升级。 |
| 管理员 | 成员、角色与权限管理 | 已覆盖且权限更细。 |
| 家族普/家族动态 | 家族圈、动态详情、点赞和评论 | 已融合升级。 |
| 贺礼簿 | 结构化亲友往来记录 | 核心能力已融合,保留列表首图和创建时间差异。 |
| 成长记录 | 人物详情下的成长日志 | 核心字段和图片/视频能力更丰富;入口深度属于产品选择。 |
| 个人中心推广内容 | 推广中心和个人中心推广条 | 已融合;缺的是注册链接和二维码,不是整个推广模块。 |
## 关键截图证据
### 首页和总览
| 参考项目 | 当前项目 |
| --- | --- |
| ![参考首页](audit-2026-08-23-round-2/01-reference-home.png) | ![当前首页](audit-2026-08-23-round-2/04-current-home.png) |
| ![参考总览](audit-2026-08-23-round-2/06-reference-overview.png) | ![当前家谱总览](audit-2026-08-23-round-2/28-current-genealogy-overview.png) |
### 家族内容入口
| 当前谱文 | 当前相册 | 当前家族视频 |
| --- | --- | --- |
| ![当前谱文](audit-2026-08-23-round-2/31-current-articles.png) | ![当前相册](audit-2026-08-23-round-2/32-current-albums.png) | ![当前家族视频](audit-2026-08-23-round-2/37-current-videos.png) |
| 参考谱文 | 参考相册 | 参考家族视频 |
| --- | --- | --- |
| ![参考谱文](audit-2026-08-23-round-2/08-reference-articles.png) | ![参考相册](audit-2026-08-23-round-2/09-reference-albums.png) | ![参考家族视频](audit-2026-08-23-round-2/10-reference-videos.png) |
### 财务、推广与阻断项
| 参考推广二维码 | 当前推广中心 |
| --- | --- |
| ![参考推广二维码](audit-2026-08-23-round-2/20-reference-referral.png) | ![当前推广中心](audit-2026-08-23-round-2/27-current-referral.png) |
| 参考 VIP 订单 | 当前 VIP 订单 |
| --- | --- |
| ![参考 VIP 订单](audit-2026-08-23-round-2/22-reference-vip-orders.png) | ![当前 VIP](audit-2026-08-23-round-2/24-current-vip.png) |
| 当前设置首次失败 | 点击重试后 |
| --- | --- |
| ![设置失败](audit-2026-08-23-round-2/29-current-settings.png) | ![设置重试仍失败](audit-2026-08-23-round-2/30-current-settings-retry.png) |
### 第二轮新增缺陷
![成长记录空表单误弹放弃确认](audit-2026-08-23-round-2/02-current-state.png)
## 验证边界
- 两端登录的是不同账号、使用不同数据集,内容条数、姓名和图片本身不作为一致性判断;比较的是入口、操作、字段、状态和交互。
- 参考项目浏览器视口与 MuMu 分辨率不同,本轮不是逐像素视觉还原审查。
- 测试家谱的谱文、礼仪、功德、贺礼簿和视频大多为空,空状态只能证明入口可达,不能证明有数据时的封面、首图、时间和播放正常;这些结论由当前源码和后端契约补证。
- 没有执行购买、提现提交、删除、归档、保存或验证码发送等写操作。
- 没有执行屏幕阅读器、外接键盘焦点顺序和自动对比度测试,因此不声明已完成完整无障碍验收。
@@ -0,0 +1,138 @@
# 当前项目与参考项目第三轮操作流程复审
审查日期:2026-08-23
参考项目:`C:\Users\Rain\Desktop\job\Jiapu-App`(浏览器,已登录)
当前项目:`C:\Users\Rain\Desktop\job\jiapuapp`MuMu,已登录)
后端项目:`C:\Users\Rain\Desktop\job\Genealogy`
## 结论
本轮不再按页面数量判断缺失,而是按“从哪里进入、填写什么、提交前校验、成功或失败后去哪里、返回是否丢状态、是否需要密码”复审操作链路。
- 第二轮确认的 14 项参考对比差异仍成立。
- 当前项目原有 1 项成长记录误报未保存修改的问题仍成立。
- 本轮新增确认 3 项当前流程问题:公开家谱的“申请加入”完全点不进去、谱主无法绑定自己的世系人物、消息中心从家谱首页进入后可见返回出口却固定去“我的”。
- 当前未决问题合计为 14 项参考对比差异和 4 项当前流程缺陷,共 18 项。
- 密码能力并未缺失,但参考项目与当前项目采用了不同契约:参考项目在创建谱文、重要证件、成长日志时强制设置查看密码和两组密保;当前项目创建时不要求密码,创建后可按单条内容选择设置 8 至 128 位密码,并通过实名手机号短信找回。是否必须恢复参考项目的“创建即强制加密”,需要产品与后端共同确认,不能仅由前端自行改成一样。
- 本轮只做审查、点击验证与文档整理,没有修改业务代码、接口契约或测试数据。
## 操作流程复审表
| 编号 | 用户流程 | 参考项目 | 当前项目 | 健康度 | 结论 |
| --- | --- | --- | --- | --- | --- |
| 1 | 登录与找回登录密码 | 账号密码登录;忘记密码使用账号、姓名、生日和新密码 | 支持密码/短信登录;忘记密码使用手机号短信码和新密码 | 基本健康 | 能力已覆盖,身份校验方式不同,不算缺页 |
| 2 | 修改登录密码 | 旧密码、新密码、重复新密码 | 旧密码、新密码、重复新密码 | 健康 | 核心步骤一致 |
| 3 | 更换手机号 | 输入登录密码和新手机号 | 新手机号加短信验证码 | 基本健康 | 安全契约不同;当前以后端最新短信换绑接口为准 |
| 4 | 家谱首页与切换当前家谱 | 首页选择家谱进入 | 首页卡片和“切换当前家谱”弹层均可用 | 健康 | 本轮已点击切换并恢复到“真机联调10159371” |
| 5 | 添加家谱入口 | 搜索/邀请码加入和创建家谱 | “搜索家谱”与“继续创建”分流 | 健康 | 当前是融合入口,不需要复制参考页面数量 |
| 6 | 创建家谱 | 姓氏、谱名、堂号、始迁祖、地区、简介、访问规则 | 在此基础上还有祖籍地、详细地址、封面;空提交有字段级提示 | 基本健康 | 当前字段更完整;`firstAncestorName` 后端落库仍是既有差异 |
| 7 | 未修改创建页直接返回 | 直接返回 | 直接返回,不弹放弃确认 | 健康 | 本轮已点击验证 |
| 8 | 邀请码加入 | 输入邀请码后确认 | 搜索页提供邀请码输入、查询和加入结果 | 基本健康 | 入口与校验链已存在;未兑换真实邀请码,避免写入成员数据 |
| 9 | 搜索公开家谱并申请加入 | 搜索结果可进入申请流程 | 搜索结果可显示,但“申请加入”点击无任何跳转或提示 | 阻断 | 新增 P0 前端缺陷,详见发现 1 |
| 10 | 管理邀请 | 可生成和使用邀请 | 可查看有效期、失效状态并生成新邀请码 | 基本健康 | 本轮看到失效邀请码与“生成新邀请码”;未点击生成,避免写操作 |
| 11 | 审核加入申请 | 管理员在消息/管理入口处理 | 独立“申请审核”页,当前数据为空时空状态正常 | 基本健康 | 没有待审样本,未执行同意/拒绝写操作 |
| 12 | 成员编辑、角色与人物绑定 | 管理员列表管理成员 | 普通目标成员具备编辑、解绑、移除、转让能力投影 | 不健康 | 当前唯一谱主记录显示未绑定人物且只读,没有另一条自绑定路径,详见发现 2 |
| 13 | 世系与成员资料 | 参考项目包含世系、成员和管理员入口 | 当前融合为世系图、成员目录、人物详情、成员状态和证件档案 | 基本健康 | 属于融合升级,不按参考页面数补页 |
| 14 | 发布家族动态 | 填写内容并发布 | 发布编辑器可进入;空白未修改时返回不会误弹确认 | 健康 | 本轮点击进入并直接返回验证通过;未提交写操作 |
| 15 | 谱文、证件、成长记录的密码查看 | 三类内容创建时强制密码和两组密保,查看前输入密码 | 三类内容均支持单条内容的设置、修改、关闭、解锁和找回 | 待产品确认 | 能力存在,但保护时机、保护粒度和找回方式不同,详见密码矩阵 |
| 16 | 图片和视频查看 | 图片展示;视频以封面/条目进入后播放 | 图片预览入口存在;家族视频列表仍直接铺设播放器 | 不健康 | 既有 P0:未达到“先显示封面,点击后播放” |
| 17 | 消息中心 | 独立消息导航,含服务入口和申请消息 | 从首页铃铛进入后,页头无返回,底栏高亮“我的”,按钮固定“返回我的” | 有瑕疵 | Android 系统返回可回退,但可见导航与来源不一致,详见发现 3 |
| 18 | 家谱设置 | 可进入并编辑 | 首次读取失败,重试仍失败 | 阻断 | 既有 P0,基础设置和永久删除资格仍被同一聚合请求共同阻断 |
| 19 | 删除、归档、转让、审批、购买、提现 | 有对应确认或提交步骤 | 前端均有确认与错误状态设计 | 仅代码复审 | 为避免修改真实数据,本轮没有执行不可逆或财务写操作 |
## 本轮新增确认的问题
### 1. P0:公开家谱“申请加入”被路由参数校验直接拦截
点击证据:公开家谱列表正常显示,点击“申请加入”后页面完全不变,也没有错误提示。
代码原因:
- `pages/genealogy/search.vue` 打开 `G08` 时传入 `genealogyId``genealogyName`
- `utils/navigation/routes.js``G08` 只声明必填 `genealogyId` 和可选 `source`
- 导航网关会拒绝所有未声明参数,因此 `genealogyName` 在调用 `uni.navigateTo` 前就触发异常。
- 本轮对所有可静态识别的 `openPage("路由键", { ... })` 调用做了路由字段扫描,只发现这一处字面量参数不匹配。
完成条件:删除多余的 `genealogyName` 传参,或由 `G08` 契约明确声明并消费该字段;随后加入一条自动化检查,点击公开家谱的“申请加入”必须进入申请表单,返回后仍回到搜索结果。
证据:
![当前公开家谱列表,申请加入按钮点击后未跳转](audit-2026-08-23-round-3/24-current-public-genealogies.png)
### 2. P1:谱主自己的成员记录无法绑定世系人物
运行时证据:当前测试家谱只有 1 位成员,该成员角色为谱主,亲属关系未填写、世系人物未绑定,页面只显示“当前账号只能查看这位成员”。
前后端交叉确认:
- 前端仅在 `member.capabilities.canEdit` 为真时开放编辑器。
- 后端能力投影明确令所有谱主目标 `canEdit=false`
- 后端更新接口还会对谱主目标抛出“不能直接修改谱主成员”。
- 当前前端、OpenAPI 和后端项目中没有发现单独的“谱主绑定本人世系人物”入口。
这会造成首次创建家谱后的闭环断点:唯一成员正是谱主,但谱主无法把自己绑定到创建出的始祖或其他世系人物。若产品确实禁止管理员接口编辑谱主,应另设只允许修改本人关系和人物绑定的窄接口,不能开放角色和成员移除能力。
证据:
![当前谱主成员只读且未绑定人物](audit-2026-08-23-round-3/20-current-members.png)
### 3. P2:消息中心的可见返回方向与实际来源不一致
消息中心可从家谱首页铃铛进入,路由也允许来源为 `G01`;但页面使用根页头、底栏固定高亮“我的”,空状态和错误状态按钮都写死为“返回我的”。系统返回键仍可回到上一页,所以不是完全阻断,但用户会被可见操作带离原流程。
完成条件:消息中心保留来源并显示返回;或明确把消息中心定义为独立根入口并统一首页、个人中心和底栏导航语义。
证据:
![当前消息中心从家谱首页进入后仍固定指向我的](audit-2026-08-23-round-3/22-current-messages.png)
## 密码流程专项对比
| 场景 | 参考项目 | 当前项目 | 判定 |
| --- | --- | --- | --- |
| 密码登录 | 账号加密码 | 手机号加密码,并可切换短信登录 | 已覆盖 |
| 忘记登录密码 | 账号、姓名、生日、新密码、确认密码 | 手机号、短信验证码、新密码、确认密码 | 已覆盖,找回凭据不同 |
| 修改登录密码 | 旧密码、新密码、确认密码 | 旧密码、新密码、确认密码 | 已覆盖 |
| 更换手机号 | 登录密码、新手机号 | 新手机号、当前绑定手机号短信验证 | 已覆盖,后端契约不同 |
| 新建谱文 | 查看密码必填且至少 6 位;两组密保问题和答案必填 | 创建时无密码字段;保存后可选择设置 8 至 128 位密码 | 流程差异,待确认 |
| 新建重要证件 | 查看密码必填且至少 6 位;两组密保问题和答案必填 | 创建时无密码字段;保存后可选择设置 8 至 128 位密码 | 流程差异,待确认 |
| 新建成长日志 | 查看密码必填且至少 6 位;两组密保问题和答案必填 | 创建时无密码字段;保存后可选择设置 8 至 128 位密码 | 流程差异,待确认 |
| 查看受保护内容 | 输入该内容的查看密码 | 输入单条内容密码,成功后取得 15 分钟短时访问授权 | 已覆盖,当前授权边界更明确 |
| 忘记内容密码 | 回答任意一组密保后重设密码 | 查询实名手机号找回资格、发送短信码、校验后重设 | 已覆盖,但无实名手机号时不可用 |
| 修改/关闭内容密码 | 编辑时通过密保修改;参考页面未发现独立关闭操作 | 有权限者可设置、修改或关闭 | 当前能力更完整 |
参考项目密码弹层证据:
![参考项目进入受保护内容时要求输入查看密码](audit-2026-08-23-round-3/16-reference-password.png)
当前测试家谱没有受保护的真实内容,因此本轮没有伪造数据,也没有声称已经在 MuMu 中完成错误密码、短信发送和解锁成功的端到端验证。当前侧结论来自页面实现、请求契约、OpenAPI 和后端接口的四方核对;在修复前应准备一条受保护谱文、一条受保护成长记录和一份受保护证件做真机回归。
## 与第二轮 14 项差异的关系
第三轮没有推翻第二轮清单,以下差异仍需处理:
1. 家谱设置整体读取失败。
2. 家族视频不是封面卡片进入播放。
3. 功德记录媒体后端契约未闭环。
4. 礼仪封面未显示。
5. 谱文封面未显示。
6. 家谱级重要证件聚合入口缺失。
7. VIP 订单号、支付时间、到期时间显示不完整。
8. 提现单号、审核备注、打款参考号、到账时间显示不完整。
9. 推广注册链接和二维码缺失。
10. 谱文、礼仪、视频封面缺少明确移除契约。
11. 多类参考内容的创建时间字段未覆盖。
12. 贺礼簿列表缺少首图。
13. “家族恩人”业务语义缺失。
14. 创建家谱的始迁祖未由后端落库。
具体字段、责任方和完成条件继续以 [第二轮独立复审](second-pass-comparison-audit-2026-08-23.md) 为准;本报告补充操作链路和密码差异,不重复制造另一套业务清单。
## 验证范围与限制
- 本轮重新点击了家谱切换、添加家谱分流、搜索家谱、创建家谱、创建页空提交校验、创建页返回、家谱总览、邀请码管理、成员列表、申请审核、消息中心、家族动态发布页与返回。
- 参考项目重新进入了加入家谱、创建家谱、管理员、消息、世系和受保护内容密码弹层。
- 没有执行生成邀请码、提交加入申请、审批、保存、删除、归档、谱主转让、短信发送、支付或提现等写操作。
- 两端账号和数据集不同,因此只比较入口、步骤、字段、状态、权限与返回关系,不比较具体数据条数。
- 未执行屏幕阅读器、外接键盘焦点顺序和自动对比度测试;本轮不声明完整无障碍验收通过。
- 当前受保护内容缺少真机样本;密码错误、短信找回、15 分钟授权过期和三类内容解锁回归仍需在测试数据准备后完成。
@@ -0,0 +1,244 @@
# 后端待处理事项与验收标准
> 本文件保留前期任务和验收历史。2026-08-24 最新线上复测结论请发送《[后端线上联调故障与数据准备清单](./后端线上联调故障与数据准备清单.md)》,不要再把下文已完成的旧任务整体当作当前待办发送。
收件人:后端开发、接口维护、测试负责人
整理日期:2026-08-23
适用项目:家谱 App
## 2026-08-24 联调状态更新
- 已确认后端提交 `de4cc9a`(“完成旧版 APP 业务契约闭环”)已进入当前后端 `main` 分支,后端交接单所列八项契约均已落库到正式 App OpenAPI。
- 前端已完成对应适配:建谱幂等与始迁祖、封面三态清空、功德图片、家族恩人分类、创建时间、推广链接、换绑当前密码校验以及安全操作后重新登录。
- 前端自动检查已通过;MuMu 已点击确认“家族恩人”入口/新建表单、“换绑手机号”的当前密码字段、“应用推广”的复制链接和系统分享入口。
- 2026-08-24 21:33 复测:正式域名和 MuMu 网络均已恢复,地区接口返回 `code=200`,登录验证策略接口也返回 `code=200`;但家谱首页、推荐卡仍在 MuMu 读取失败,`user_agreement``privacy_policy` 两个免登录合规接口均返回 `code=500``发生未知异常,请联系管理员`。因此接口数据写入、旧令牌失效及服务端回读仍未完成线上联调验收。请后端核对线上部署提交、迁移数据库与 postcheck 结果,不能把“域名可访问”或“代码已提交”视为“线上联调已通过”。
下文保留为原始后端任务与回归基线;其中“待处理”表示本轮交接前的状态,最终以本节联调状态为准。
## 一、总体结论
前端当前能够独立完成的差异项已经处理并通过代码检查。真机点击回归后,仍有以下后端事项需要处理:
1. 一项现有接口故障需要修复;
2. 八项后端能力或契约需要补齐;
3. 七类联调数据需要在测试环境准备;
4. 所有完成后的接口契约必须同步回写到 `genealogy-app-openapi.yaml`,不能只改实现、不改文档。
接口路径和英文字段名属于技术契约,下面保留在括号中;任务名称、业务说明和验收标准均使用中文。
## 二、最高优先级:修复家谱设置读取
### 任务名称:家谱基础设置正常读取
当前现象:进入“家谱设置”后直接显示“家谱设置暂时无法读取”。
涉及接口:
- 查询家谱基础资料:`GET /genealogy/app/genealogies/{家谱编号}`
- 查询永久注销资格:`GET /genealogy/app/genealogies/{家谱编号}/permanent-deletion/capability`
处理要求:
1. 已加入且有查看权限的普通家谱成员必须能够正常读取家谱基础资料。
2. 家谱基础资料接口不得依赖永久注销资格接口成功后才返回。
3. 普通家谱不满足永久注销条件时,资格接口应正常返回“不可永久注销”和明确原因,不能因为家谱未归档直接抛系统错误。
4. 家谱基础资料至少稳定返回:家谱编号、家谱名称、姓氏、地区编码、公开范围、简介、成员数量、世系人物数量、归档状态。
5. 权限不足、家谱不存在、参数错误和系统异常必须使用不同的业务错误码。
验收标准:
- 测试家谱 `MANUALTEST20260809` 可以正常进入设置表单;
- 普通家谱返回“不可永久注销”时,设置表单仍然可以读取和编辑;
- 无权限账号被明确拒绝,但不能返回模糊的系统错误。
## 三、需要补齐的后端能力
### 任务一:功德记录支持图片
处理要求:
- 创建和更新功德记录时接收图片文件编号集合(`mediaOssIds`);
- 列表和详情返回有权访问的图片文件集合(`mediaFiles`);
- 空集合表示清空全部图片;
- 删除、回收站恢复和权限校验必须同步处理文件引用。
验收标准:新增多图、编辑保留图片、逐张移除、列表显示首图、详情预览全部图片和回收站恢复均正常。
### 任务二:推广注册链接和二维码
处理要求:
- 推广资料接口返回由服务端生成的 HTTPS 注册链接(`shareUrl`);
- 链接只能携带可校验、可控制生命周期的推荐凭据,不能直接暴露内部用户编号;
- 注册绑定需要防止自我邀请、重复绑定和并发重复写入。
验收标准:前端可直接复制链接、生成二维码和调用系统分享;扫码注册后推荐关系只绑定一次。
### 任务三:统一封面清空规则
适用内容:谱文、礼仪活动、家族视频。
处理要求:
- 更新请求中的封面文件编号(`coverOssId`)为 `null` 时,表示删除现有封面;
- 请求中不包含该字段时,表示保持原封面不变;
- 删除封面后需要释放旧业务文件引用;
- 不再使用空字符串、零或其他第二套清空方式。
验收标准:清空后详情返回的封面文件(`coverFile`)为 `null`,其他业务字段保持不变,旧文件不再保留业务引用。
### 任务四:统一返回创建时间
至少需要补齐以下内容的只读创建时间(`createTime`):
- 相册;
- 礼仪活动;
- 功德记录;
- 成长记录;
- 亲友往来记录;
- 产品确认后的家族恩人记录。
处理要求:创建时间由服务端生成,客户端不能提交或修改;业务日期、活动日期和提醒日期不能代替创建时间。
验收标准:新增后创建时间非空;编辑业务内容不会改变创建时间。
### 任务五:确定并实现“家族恩人”模型
产品和后端需要在以下方案中确定唯一方案:
1. 独立家族恩人档案;
2. 家族备忘录中的受控业务分类。
无论选择哪种方案,都需要明确:身份或类别、说明、图片、创建时间、访问权限、删除与恢复规则。不能只把现有“家族备忘”标题改成“家族恩人”。
验收标准:OpenAPI、数据库模型、服务实现和前端使用的字段属于同一套契约,不保留两套并行模型。
### 任务六:创建家谱时建立始迁祖和谱主绑定
处理要求:
- 创建家谱请求接收始迁祖姓名(`firstAncestorName`);
- 创建家谱、创建第一代世系人物、绑定当前谱主成员必须在同一事务中完成;
- 任一步骤失败时整体回滚;
- 重复请求不能生成重复人物或重复绑定;
- 旧家谱需要提供受权限控制的补绑定路径。
验收标准:新建家谱后,世系树立即出现始迁祖,且唯一谱主成员具有明确的人物绑定。
### 任务七:换绑手机号前重新验证当前身份
当前换绑请求只验证新手机号,不能证明操作人仍掌握当前账号凭据。
处理要求:
1. 验证当前登录密码,或者验证近期完成的重新认证票据;
2. 验证新手机号短信票据;
3. 两项验证通过后才能更新手机号;
4. 记录安全审计事件;
5. 旧密码错误、认证票据过期、验证码错误和手机号被占用必须分别拒绝。
验收标准:仅掌握当前登录会话、但不知道密码或没有重新认证票据时,无法换绑手机号。
### 任务八:修改密码和换绑手机号后使旧会话失效
处理要求:
- 修改密码后,使该用户其他既有令牌失效;
- 换绑手机号后,建议使全部既有令牌失效,并要求使用新手机号重新登录;
- 如果保留当前设备会话,必须明确区分当前令牌和其他令牌;
- 会话失效必须由服务端执行,不能只依赖前端清理本地缓存。
验收标准:安全操作前签发的旧令牌再次访问受保护接口时返回未登录;使用新凭据可以重新登录。
## 四、测试环境需要准备的联调数据
以下接口目前能够访问,但返回空列表或缺少关键字段,导致前端无法完成内容态点击验收。请只在测试环境准备数据。
### 一、平台宣传视频
接口:`GET /genealogy/app/platform-videos`
至少准备一条当前时间可见的数据,包含:
- 视频编号;
- 视频标题;
- 展示位置;
- 平台范围;
- 封面文件;
- 可播放视频文件;
- 视频时长;
- 点赞、播放、评论数量;
- 当前用户是否点赞。
验收目标:列表显示真实封面,点击封面后能够打开播放器并播放视频。
### 二、礼仪活动
接口:`GET /genealogy/app/genealogies/{家谱编号}/ceremonies`
为测试家谱准备至少一条带封面的礼仪活动,封面文件必须具有当前用户可访问的临时地址。
验收目标:礼仪活动列表显示封面,点击卡片可进入详情。
### 三、谱文
接口:`GET /genealogy/app/genealogies/{家谱编号}/articles`
为测试家谱准备至少一条带封面的谱文,并保证分类、标题、摘要、封面文件和权限字段完整。
验收目标:谱文列表显示封面,点击卡片可进入详情。
### 四、完整的会员订单
接口:`GET /genealogy/app/vip/orders`
当前样本只有套餐、金额、订单号和状态。请准备至少一条完整订单,包含:
- 订单编号(`orderNo`);
- 套餐名称(`packageName`);
- 支付金额(`payAmount`);
- 支付方式(`payType`);
- 支付状态(`payStatus`);
- 支付时间(`payTime`);
- 失效时间(`expireTime`)。
验收目标:订单卡完整显示已有字段,不再只能显示基础四项。
### 五、提现记录
接口:`GET /genealogy/app/earnings/withdrawals`
至少准备一条提现记录,包含:提现编号、提现单号、金额、状态、收款人、创建时间;根据状态补充审核备注、打款参考号、到账时间或失败原因。
验收目标:提现记录页能够验证金额、状态、账户、时间以及各状态的扩展信息。
### 六、带图片的亲友往来记录
接口:`GET /genealogy/app/genealogies/{家谱编号}/relative-records`
至少准备一条当前用户可查看的亲友往来记录,包含:亲友姓名、关系名称、事件、时间、内容和图片文件集合(`mediaFiles`)。
验收目标:列表使用第一张图片作为缩略图,详情可以查看全部图片。
### 七、重要证件内容数据
当前“家谱重要证件”汇总入口已经可以打开,但列表为空。请为测试家谱至少准备一条当前账号有权查看的重要证件,并保证文件授权地址有效。
验收目标:汇总页能展示证件所属人物、证件名称和文件预览;无权限证件不能泄露。
## 五、后端完成后必须回传
请后端完成后统一提供以下材料,便于前端直接复测:
1. 更新后的正式 App OpenAPI 文件;
2. 对应后端提交编号和测试环境部署版本;
3. 新增或更新接口的自动化测试结果;
4. 上述测试数据对应的测试家谱编号、账号权限和数据编号;
5. 家谱设置、平台视频、谱文、礼仪活动、会员订单、提现记录、亲友往来和重要证件的真实响应示例;
6. 换绑重新认证和旧令牌失效的安全测试结果。
## 六、前后端边界
- 前端已经完成页面入口、空状态、封面展示、播放器入口、订单字段展示、提现字段展示、亲友首图展示和重要证件汇总页。
- 后端未完成前,前端不会伪造线上数据,也不会增加第二套临时接口。
- 最终字段、状态和错误码以双方确认后的唯一 OpenAPI 契约为准。
@@ -0,0 +1,120 @@
# 后端线上联调故障与数据准备清单
收件人:后端开发、运维、测试负责人
整理日期:2026-08-24
联调环境:正式接口 `https://backend-api.ddxcjp.cn`,租户 `000000`
## 一、结论
前端已按最新 App OpenAPI 和后端提交 `de4cc9a` 完成适配,并已移除预览数据回退。当前页面中的“无内容”分为两类:
| 类型 | 数量 | 结论 |
| --- | ---: | --- |
| 线上接口或配置故障 | 3 项 | 需要后端、运维处理 |
| 接口成功但测试数据为空 | 8 类 | 不是前端故障,需要后端测试环境准备数据 |
| 本轮确认的前端问题 | 2 项 | 已修复并通过自动检查 |
本轮前端检查结果:60 个页面与 60 条路由一致;参考项目 78 条功能路由均已有对应关系;导航恢复、契约回归和 12 项运行时资产测试全部通过。
## 二、必须处理的线上问题
### 问题一:我的家谱接口无法完成首页读取
接口:`GET /genealogy/app/genealogies/mine`
现象:2026-08-24 22:17 将本轮新构建运行到 MuMu 后,已登录账号点击“重新加载”,家谱首页仍稳定进入“暂时无法读取家谱”状态,具体提示为“家谱服务暂时无法读取,请稍后重试”。该登录态访问个人资料和其他家族业务接口能够成功,因此不能归因于模拟器断网或整体登录失效。
前端已确认:
- 正式包请求地址和接口路径正确;
- 请求失败和成功空数组使用不同页面状态;
- 前端没有家谱预览数据或假数据回退;
- 本轮已增加错误类型显示。重新编译后,若后端响应结构不符合契约,会显示“服务返回的家谱数据不完整”;网络、登录、权限和服务异常也会分别提示。
本次实际命中的是服务异常提示,不是网络异常、登录失效、无权限或前端响应结构校验异常。因此排查重点应放在线上接口业务异常、SQL 异常和部署版本,不需要前端放宽字段校验来掩盖故障。
后端最新代码中,该接口会读取家谱实体并返回 `AppGenealogyVo`。2026-08-24 迁移又为 `gen_genealogy` 增加了 `create_request_id``first_ancestor_name``root_person_id`。因此请优先排查以下三项:
1. 线上是否确实部署了提交 `de4cc9a`,而不是只合并到代码仓库;
2. 是否按顺序执行 `2026-08-24-app-legacy-parity-closure-precheck.sql`、迁移脚本和后置检查;
3. 后置检查的 `app_legacy_parity_postcheck_blocking_count``blocking_count` 是否都为 `0`
同时请用当前测试账号直接调用接口并保存完整响应及服务端异常日志,重点核对每条家谱是否稳定返回:
- `genealogyId`:正整数;
- `genealogyName`:非空;
- `memberCount`:非负整数;
- `personCount`:空或非负整数;
- `visibility``joinMode``lifecycleStatus`:合法枚举;
- `canManage``canEditContent``canArchive``canRestore`:布尔值。
验收标准:同一账号连续调用三次均返回 `code=200``data` 为合法数组;MuMu 点击“重新加载”后正常显示家谱卡片或真实空状态。
### 问题二:用户协议和隐私政策免登录接口返回 500
接口:
- `GET /genealogy/app/compliance/documents/user_agreement`
- `GET /genealogy/app/compliance/documents/privacy_policy`
2026-08-24 复测原始响应,两条接口均为:
```json
{"code":500,"msg":"发生未知异常,请联系管理员","data":null}
```
这是不携带登录令牌即可复现的后端问题,与前端页面、登录态和 MuMu 无关。后端当前实现预期在未发布时返回“合规文档尚未发布”,线上却变成未知异常,说明仍需检查线上表结构、发布数据、租户数据隔离或异常处理。
请执行并提供以下检查结果:
1. 合规文档首次发布及换行修复 SQL 的后置检查结果,所有 `blocking_count` 必须为 `0`
2. 租户 `000000` 下两份文档、当前版本、版本状态和内容摘要的查询结果;
3. 两条免登录请求对应的后端异常堆栈;
4. 确认请求只需要正确的 `clientid` 和租户头,不应依赖登录令牌。
验收标准:两条接口均返回 `code=200`、非空标题、版本号、正文和内容摘要;不携带 Authorization 仍可读取。
### 问题三:应用推广资料读取失败
接口:`GET /genealogy/app/referrals/me`
现象:2026-08-24 22:21 MuMu 点击进入“应用推广”,推广内容列表能够正常展示,但顶部推荐资料卡明确显示“推荐码暂时无法读取”。这说明页面和推广内容接口正常,故障集中在推荐资料接口。前端已使用后端返回的 `shareUrl`,没有自行拼接内部用户编号。
后端 `ReferralService.buildShareUrl` 明确依赖当前租户品牌配置中的 `h5Domain`,配置为空、格式错误或不是 HTTPS 都会直接抛出业务异常。请核对租户 `000000` 的启用品牌配置,并确保 `h5Domain` 是可访问的 HTTPS H5 注册地址。
验收标准:接口返回 `code=200`,包含稳定推荐码、推荐人数、分享标题、分享文案和 HTTPS `shareUrl`;链接不暴露内部用户编号,打开后能进入注册流程并携带推荐凭据。
## 三、需要准备的联调数据
以下页面已确认能够区分“读取失败”和“成功但为空”。当前显示无内容,是接口成功返回空数组或零条记录,不属于前端渲染故障。请在测试环境为当前测试家谱准备最小可点击数据:
| 数据类别 | 当前结果 | 最小验收数据 |
| --- | --- | --- |
| 家族动态 | 成功,空列表 | 1 条带图片动态、1 条纯文字动态,可进入详情 |
| 谱文 | 分类接口可读,谱文为空列表 | 1 篇带封面谱文、1 篇设有内容密码的谱文 |
| 礼仪活动 | 成功,空列表 | 1 条带封面活动,可进入详情并读取献礼列表 |
| 家族备忘 | 成功,空列表 | `general``benefactor` 各 1 条 |
| 亲友往来 | 成功,空列表 | 1 条带图片记录,可查看完整详情 |
| 功德记录 | 成功,空列表 | 1 条带金额和图片记录 |
| 家族视频与平台宣传视频 | 家族视频为空;首页平台视频因家谱首页故障暂不能完成内容态验收 | 各 1 条带封面且视频地址可播放的数据,并正确配置平台视频投放位 |
| 相册照片 | 已有相册 `CHECKDELETE01`,照片数为 0 | 在该相册中加入至少 2 张当前账号可访问的图片 |
这些数据的文件字段必须返回当前账号可访问的 HTTPS 地址,不能只返回 OSS 文件编号。
## 四、本轮前端已处理
1. 家谱首页不再把所有异常统一显示成“网络或服务不可用”,现在会区分网络、登录、权限、服务失败和响应结构不完整。
2. 谱文分类接口失败时不再静默转换为空分类;页面会明确提示“分类读取失败”,同时保留已成功读取的全部谱文。
3. 已增加对应回归检查,防止以后再次把请求失败伪装为空数据。
## 五、后端回传材料
完成后请一次性提供:
1. 线上实际部署提交编号和部署时间;
2. 2026-08-24 迁移及所有相关后置检查结果,最终 `blocking_count=0`
3. 上述三项故障接口的完整响应示例和对应服务端日志结论;
4. 测试数据所属家谱编号、数据编号和账号权限;
5. 更新后的正式 App OpenAPI(如接口字段、枚举或错误码发生变化)。
仅提供“代码已提交”“自动测试通过”或“域名可以访问”不能作为线上联调完成依据,最终以同一部署环境中的接口响应和 MuMu 点击回归为准。
File diff suppressed because it is too large Load Diff
+35 -25
View File
@@ -1,5 +1,5 @@
{
"name" : "家谱",
"name" : "代代相传家谱",
"appid" : "__UNI__2E0520B",
"description" : "家祠卷轴风格的家谱共建应用",
"versionName" : "1.0.0",
@@ -7,6 +7,11 @@
"transformPx" : false,
"app-plus" : {
"usingComponents" : true,
"modules" : {
"Payment" : {},
"Share" : {},
"OAuth" : {}
},
"nvueStyleCompiler" : "uni-app",
"compilerVersion" : 3,
"statusbar" : {
@@ -25,41 +30,46 @@
"permissions" : [
"<uses-permission android:name=\"android.permission.ACCESS_NETWORK_STATE\"/>",
"<uses-permission android:name=\"android.permission.INTERNET\"/>",
"<uses-permission android:name=\"android.permission.READ_MEDIA_IMAGES\"/>"
"<uses-permission android:name=\"android.permission.READ_EXTERNAL_STORAGE\" android:maxSdkVersion=\"32\"/>",
"<uses-permission android:name=\"android.permission.READ_MEDIA_IMAGES\"/>",
"<uses-permission android:name=\"android.permission.READ_MEDIA_VIDEO\"/>"
]
},
"ios" : {
"dSYMs" : false
"dSYMs" : false,
"privacyDescription" : {
"NSPhotoLibraryUsageDescription" : "用于选择并上传您主动添加的家谱头像、照片和视频"
}
},
"icons" : {
"android" : {
"hdpi" : "unpackage/res/icons/72x72.png",
"xhdpi" : "unpackage/res/icons/96x96.png",
"xxhdpi" : "unpackage/res/icons/144x144.png",
"xxxhdpi" : "unpackage/res/icons/192x192.png"
"hdpi" : "static/app-icons/72x72.png",
"xhdpi" : "static/app-icons/96x96.png",
"xxhdpi" : "static/app-icons/144x144.png",
"xxxhdpi" : "static/app-icons/192x192.png"
},
"ios" : {
"appstore" : "unpackage/res/icons/1024x1024.png",
"appstore" : "static/app-icons/1024x1024.png",
"ipad" : {
"app" : "unpackage/res/icons/76x76.png",
"app@2x" : "unpackage/res/icons/152x152.png",
"notification" : "unpackage/res/icons/20x20.png",
"notification@2x" : "unpackage/res/icons/40x40.png",
"proapp@2x" : "unpackage/res/icons/167x167.png",
"settings" : "unpackage/res/icons/29x29.png",
"settings@2x" : "unpackage/res/icons/58x58.png",
"spotlight" : "unpackage/res/icons/40x40.png",
"spotlight@2x" : "unpackage/res/icons/80x80.png"
"app" : "static/app-icons/76x76.png",
"app@2x" : "static/app-icons/152x152.png",
"notification" : "static/app-icons/20x20.png",
"notification@2x" : "static/app-icons/40x40.png",
"proapp@2x" : "static/app-icons/167x167.png",
"settings" : "static/app-icons/29x29.png",
"settings@2x" : "static/app-icons/58x58.png",
"spotlight" : "static/app-icons/40x40.png",
"spotlight@2x" : "static/app-icons/80x80.png"
},
"iphone" : {
"app@2x" : "unpackage/res/icons/120x120.png",
"app@3x" : "unpackage/res/icons/180x180.png",
"notification@2x" : "unpackage/res/icons/40x40.png",
"notification@3x" : "unpackage/res/icons/60x60.png",
"settings@2x" : "unpackage/res/icons/58x58.png",
"settings@3x" : "unpackage/res/icons/87x87.png",
"spotlight@2x" : "unpackage/res/icons/80x80.png",
"spotlight@3x" : "unpackage/res/icons/120x120.png"
"app@2x" : "static/app-icons/120x120.png",
"app@3x" : "static/app-icons/180x180.png",
"notification@2x" : "static/app-icons/40x40.png",
"notification@3x" : "static/app-icons/60x60.png",
"settings@2x" : "static/app-icons/58x58.png",
"settings@3x" : "static/app-icons/87x87.png",
"spotlight@2x" : "static/app-icons/80x80.png",
"spotlight@3x" : "static/app-icons/120x120.png"
}
}
}
+3 -1
View File
@@ -2,8 +2,10 @@
"name": "jiapuapp",
"private": true,
"scripts": {
"check": "node scripts/check-project.mjs && npm --prefix design-pipeline run check",
"check": "node scripts/check-project.mjs && node scripts/check-audit-regressions.mjs && node scripts/check-navigation-recovery.mjs && node scripts/check-frontend-parity.mjs && npm --prefix design-pipeline run check",
"check:project": "node scripts/check-project.mjs",
"check:regressions": "node scripts/check-audit-regressions.mjs",
"check:parity": "node scripts/check-frontend-parity.mjs",
"check:assets": "node design-pipeline/scripts/validate-runtime-asset-inventory.mjs design-pipeline/manifests/runtime-assets.json"
},
"devDependencies": {
+18
View File
@@ -80,6 +80,12 @@
"navigationStyle": "custom"
}
},
{
"path": "pages/genealogy/capability-center",
"style": {
"navigationStyle": "custom"
}
},
{
"path": "pages/tree/overview",
"style": {
@@ -190,6 +196,12 @@
"navigationStyle": "custom"
}
},
{
"path": "pages/family/platform-videos",
"style": {
"navigationStyle": "custom"
}
},
{
"path": "pages/records/people",
"style": {
@@ -256,6 +268,12 @@
"navigationStyle": "custom"
}
},
{
"path": "pages/records/person-documents",
"style": {
"navigationStyle": "custom"
}
},
{
"path": "pages/notification/message-center",
"style": {
+58 -7
View File
@@ -78,6 +78,34 @@
</view>
</view>
<view
class="field-block"
:class="{ 'field-block--error': fieldErrors.referralCode }"
>
<view class="input-row">
<label class="input-label" for="register-referral-code">推荐码</label>
<input
id="register-referral-code"
v-model.trim="referralCode"
class="auth-input"
maxlength="64"
:disabled="submitting || registrationCommitted"
placeholder="选填,来自邀请人的推荐码"
placeholder-class="placeholder"
:aria-invalid="Boolean(fieldErrors.referralCode)"
:aria-describedby="fieldErrors.referralCode ? 'register-referral-code-error' : undefined"
@input="clearFieldError('referralCode')"
/>
</view>
<text
v-if="fieldErrors.referralCode"
id="register-referral-code-error"
class="field-error"
role="alert"
>{{ fieldErrors.referralCode }}</text
>
</view>
<view
class="field-block"
:class="{ 'field-block--error': fieldErrors.verificationCode }"
@@ -259,14 +287,14 @@
<text>我已阅读并同意</text>
<button
class="auth-plain-button agreement-link"
@click="prepareAgreement"
@click="openComplianceDocument('user_agreement')"
>
用户协议
</button>
<text></text>
<button
class="auth-plain-button agreement-link"
@click="prepareAgreement"
@click="openComplianceDocument('privacy_policy')"
>
隐私政策
</button>
@@ -318,7 +346,7 @@
<script setup>
import { computed, ref } from "vue";
import { onBackPress, onShow, onUnload } from "@dcloudio/uni-app";
import { onBackPress, onLoad, onShow, onUnload } from "@dcloudio/uni-app";
import AppDialog from "@/components/AppDialog.vue";
import AppToast from "@/components/AppToast.vue";
import AuthPageShell from "@/components/auth/PageShell.vue";
@@ -336,7 +364,12 @@ import {
import { createDiscardConfirmation } from "@/utils/discard-confirmation.js";
import { calcMD5 } from "@/utils/md5.js";
import { createNonIdempotentWriteGuard } from "@/utils/non-idempotent-write-guard.js";
import { goRoot, handleBackPress, runBackGuard } from "@/utils/navigation/gateway.js";
import {
goRoot,
handleBackPress,
openPage,
runBackGuard,
} from "@/utils/navigation/gateway.js";
import {
PASSWORD_POLICY_MESSAGE,
validatePassword,
@@ -344,6 +377,7 @@ import {
const phone = ref("");
const nickName = ref("");
const referralCode = ref("");
const verificationCode = ref("");
const password = ref("");
const confirmPassword = ref("");
@@ -351,6 +385,7 @@ const agreed = ref(false);
const agreementError = ref(false);
const fieldErrors = ref({
phone: "",
referralCode: "",
verificationCode: "",
password: "",
confirmPassword: "",
@@ -366,6 +401,7 @@ const isDirty = computed(
Boolean(
phone.value ||
nickName.value ||
referralCode.value ||
verificationCode.value ||
password.value ||
confirmPassword.value ||
@@ -374,6 +410,10 @@ const isDirty = computed(
);
let feedbackTimer = null;
let pageActive = true;
onLoad((options = {}) => {
referralCode.value = String(options.referralCode || "").trim().slice(0, 64);
});
const registrationNavigationFailure =
"注册已完成,但暂时无法进入家谱,请再次点击进入";
const registrationSubmissionRequestController = createRequestController();
@@ -452,7 +492,11 @@ const enterAuthenticatedRoot = async () => {
}
};
const prepareAgreement = () => showFeedback("协议页面准备中");
const openComplianceDocument = (documentKey) => {
if (sendingCode.value || submitting.value || registrationCommitted.value)
return;
return openPage("M13", { documentKey }, "A04");
};
const toggleAgreement = () => {
if (sendingCode.value || submitting.value || registrationCommitted.value)
@@ -490,11 +534,15 @@ const prepareGetCode = async () => {
const validateForm = () => {
const nextErrors = {
phone: "",
referralCode: "",
verificationCode: "",
password: "",
confirmPassword: "",
};
if (!isAuthPhone(phone.value)) nextErrors.phone = "请输入正确手机号";
if (referralCode.value && !/^[A-Za-z0-9_-]{4,64}$/.test(referralCode.value)) {
nextErrors.referralCode = "推荐码应为 4 至 64 位字母、数字、短横线或下划线";
}
if (sentPhone.value !== phone.value)
nextErrors.verificationCode = "请先获取当前手机号的验证码";
else if (!/^\d{4}$/.test(verificationCode.value))
@@ -517,6 +565,7 @@ const submitRegister = async () => {
const registrationPayload = {
phone: phone.value,
nickName: nickName.value,
referralCode: referralCode.value,
passwordHash: calcMD5(password.value),
smsCode: verificationCode.value,
};
@@ -732,7 +781,7 @@ const submitRegister = async () => {
.agreement-row {
display: flex;
align-items: center;
flex-wrap: nowrap;
flex-wrap: wrap;
justify-content: center;
min-height: var(--app-touch-min);
font-size: clamp(12px, 22rpx, 14px);
@@ -761,12 +810,14 @@ const submitRegister = async () => {
.agreement-copy {
display: flex;
flex: 0 0 auto;
flex-wrap: wrap;
align-items: center;
justify-content: center;
min-width: 0;
padding-top: 2rpx;
}
.agreement-copy text {
white-space: nowrap;
white-space: normal;
}
.agreement-link {
+138 -6
View File
@@ -175,6 +175,28 @@
}}</text>
</button>
<!-- #ifdef APP-PLUS -->
<view class="third-party-login">
<text class="third-party-login__divider">其他登录方式</text>
<button
class="auth-plain-button wechat-login"
:disabled="submitting || sendingCode || tacVisible || wechatProviderState !== 'ready'"
:aria-busy="submitting"
hover-class="tap-fade"
@click="submitWechatLogin"
>
<text class="wechat-login__mark"></text>
<text>{{
wechatProviderState === "checking"
? "正在检查微信登录"
: wechatProviderState === "ready"
? "微信登录"
: "微信登录尚未配置"
}}</text>
</button>
</view>
<!-- #endif -->
<view class="register-entry">
<text>还没有账号</text>
<button
@@ -218,14 +240,14 @@
<text>我已阅读并同意</text>
<button
class="auth-plain-button agreement-link"
@click="prepareAgreement"
@click="openComplianceDocument('user_agreement')"
>
用户协议
</button>
<text></text>
<button
class="auth-plain-button agreement-link"
@click="prepareAgreement"
@click="openComplianceDocument('privacy_policy')"
>
隐私政策
</button>
@@ -294,6 +316,7 @@ const tacVisible = ref(false);
const tacContext = ref(null);
const sendingCode = ref(false);
const submitting = ref(false);
const wechatProviderState = ref("unknown");
const authenticationCommitted = ref(false);
const cooldownSeconds = ref(0);
const sentPhone = ref("");
@@ -363,7 +386,36 @@ const restoreAuthenticatedSession = () => {
void enterAuthenticatedRoot();
};
onShow(restoreAuthenticatedSession);
const detectWechatProvider = () => {
if (wechatProviderState.value !== "unknown") return;
// #ifdef APP-PLUS
if (typeof uni?.getProvider !== "function") {
wechatProviderState.value = "unavailable";
return;
}
wechatProviderState.value = "checking";
uni.getProvider({
service: "oauth",
success: ({ provider = [] } = {}) => {
if (!pageActive) return;
wechatProviderState.value = provider.includes("weixin")
? "ready"
: "unavailable";
},
fail: () => {
if (pageActive) wechatProviderState.value = "unavailable";
},
});
// #endif
// #ifndef APP-PLUS
wechatProviderState.value = "unavailable";
// #endif
};
onShow(() => {
restoreAuthenticatedSession();
detectWechatProvider();
});
const blockBusyAction = () => {
if (!sendingCode.value && !submitting.value) return false;
@@ -685,7 +737,43 @@ const prepareRegister = () => {
return openPage("A04", {}, "A01");
};
const prepareAgreement = () => showFeedback("协议页面准备中");
const openComplianceDocument = (documentKey) => {
if (blockBusyAction()) return;
return openPage("M13", { documentKey }, "A01");
};
const requestWechatAuthorizationCode = () =>
new Promise((resolve, reject) => {
uni.login({
provider: "weixin",
onlyAuthorize: true,
success: ({ code } = {}) => resolve(code),
fail: reject,
});
});
const submitWechatLogin = async () => {
if (submitting.value || wechatProviderState.value !== "ready") return;
if (authenticationCommitted.value) return enterAuthenticatedRoot();
if (!requireAgreement()) return;
submitting.value = true;
try {
const code = await requestWechatAuthorizationCode();
await authApi.loginWithWechat(
{ code },
{ requestController: signInSubmissionRequestController },
);
if (!pageActive) return;
authenticationCommitted.value = true;
await enterAuthenticatedRoot();
} catch (error) {
if (!pageActive || isRequestCancelled(error)) return;
const errorText = String(error?.errMsg || error?.message || "");
showFeedback(/cancel|取消/i.test(errorText) ? "已取消微信登录" : errorText || "微信登录失败,请稍后重试");
} finally {
if (pageActive) submitting.value = false;
}
};
</script>
<style scoped lang="scss">
@@ -906,7 +994,7 @@ const prepareAgreement = () => showFeedback("协议页面准备中");
.agreement-row {
display: flex;
align-items: center;
flex-wrap: nowrap;
flex-wrap: wrap;
justify-content: center;
min-height: var(--app-touch-min);
font-size: clamp(12px, 22rpx, 14px);
@@ -934,12 +1022,56 @@ const prepareAgreement = () => showFeedback("协议页面准备中");
.agreement-copy {
display: flex;
flex: 0 0 auto;
flex-wrap: wrap;
align-items: center;
justify-content: center;
min-width: 0;
padding-top: 2rpx;
}
.agreement-copy text {
white-space: nowrap;
white-space: normal;
}
.third-party-login {
display: flex;
flex-direction: column;
align-items: center;
margin-top: 22rpx;
gap: 16rpx;
}
.third-party-login__divider {
color: rgba(76, 57, 41, 0.62);
font-size: clamp(12px, 21rpx, 15px);
}
.wechat-login {
display: inline-flex;
min-height: 72rpx;
align-items: center;
justify-content: center;
padding: 0 30rpx;
border: 1rpx solid rgba(66, 107, 88, 0.38);
border-radius: 999rpx;
color: #315943;
font-size: clamp(14px, 23rpx, 17px);
gap: 12rpx;
}
.wechat-login[disabled] {
opacity: 0.52;
}
.wechat-login__mark {
display: inline-flex;
width: 38rpx;
height: 38rpx;
align-items: center;
justify-content: center;
border-radius: 50%;
background: #2f9e57;
color: #fff;
font-size: clamp(11px, 18rpx, 14px);
}
.agreement-link {
+4 -3
View File
@@ -308,6 +308,7 @@ onUnload(() => {
.media-state-card {
@include adaptive-family-panel;
box-sizing: border-box;
background-color: rgba($paper, 0.82);
}
.media-panel {
padding: 38rpx 32rpx 42rpx;
@@ -357,16 +358,16 @@ onUnload(() => {
font-size: clamp(15px, 24rpx, 18px);
}
.media-field input {
min-height: 78rpx;
min-height: 80rpx;
padding: 0 22rpx;
}
.media-field--picker picker {
display: block;
min-height: 78rpx;
min-height: 80rpx;
}
.media-field--picker picker > view {
display: flex;
min-height: 78rpx;
min-height: 80rpx;
align-items: center;
padding: 0 22rpx;
border: 1rpx solid rgba(128, 89, 49, 0.34);
+324 -22
View File
@@ -4,10 +4,10 @@
<view class="album-detail-header">
<PageHeader
title="相册详情"
:action="valid ? '添加' : ''"
:action="headerAction"
custom-back
@back="returnToAlbums"
@action="addPhoto"
@back="requestBack"
@action="handleHeaderAction"
/>
</view>
<view class="album-detail-content">
@@ -28,20 +28,67 @@
<AppButton block label="添加照片" @click="addPhoto" />
</view>
<view v-else class="photo-list">
<view v-if="deletablePhotos.length" class="photo-management">
<template v-if="selectionMode">
<view class="photo-management__summary">
<text>已选择 {{ selectedPhotoIds.length }} </text>
<text>仅可选择有删除权限的照片</text>
</view>
<view class="photo-management__actions">
<AppButton
compact
type="secondary"
:disabled="batchDeleting"
:label="allDeletableSelected ? '取消全选' : '全选'"
@click="toggleAllDeletablePhotos"
/>
<AppButton
compact
:disabled="!selectedPhotoIds.length || batchDeleting"
:label="batchDeleteButtonLabel"
@click="requestBatchDelete"
/>
</view>
</template>
<template v-else>
<view class="photo-management__summary">
<text>批量管理照片</text>
<text>可一次选择多张照片删除</text>
</view>
<AppButton compact type="secondary" label="管理照片" @click="enterSelectionMode" />
</template>
</view>
<text v-if="deleteNotice" class="photo-list__notice" role="status">{{ deleteNotice }}</text>
<text v-if="deleteError" class="photo-list__error">{{ deleteError }}</text>
<view v-for="item in photos" :key="item.id" class="photo-card">
<view
v-for="item in photos"
:key="item.id"
class="photo-card"
:class="{ 'photo-card--selected': isPhotoSelected(item) }"
>
<view
v-if="selectionMode && item.canDelete"
class="photo-card__selection"
role="checkbox"
:aria-checked="isPhotoSelected(item)"
:aria-label="`${isPhotoSelected(item) ? '取消选择' : '选择'}照片:${item.title}`"
@click="togglePhotoSelection(item)"
>
<text>{{ isPhotoSelected(item) ? "已选择" : "选择" }}</text>
</view>
<image
class="photo-card__image"
:src="item.photoFile.accessUrl"
mode="widthFix"
role="button"
:aria-label="`查看大图:${item.title}`"
@click="previewPhoto(item)"
:role="selectionMode && item.canDelete ? 'checkbox' : 'button'"
:aria-checked="selectionMode && item.canDelete ? isPhotoSelected(item) : undefined"
:aria-label="selectionMode && item.canDelete ? `${isPhotoSelected(item) ? '取消选择' : '选择'}照片:${item.title}` : `查看大图:${item.title}`"
@click="handlePhotoClick(item)"
/>
<text>{{ item.title }}</text>
<text v-if="item.description">{{ item.description }}</text>
<text v-if="item.meta">{{ item.meta }}</text>
<view v-if="item.canDelete" class="photo-card__actions">
<text class="photo-card__title">{{ item.title }}</text>
<text v-if="item.description" class="photo-card__copy">{{ item.description }}</text>
<text v-if="item.meta" class="photo-card__meta">{{ item.meta }}</text>
<view v-if="item.canDelete && !selectionMode" class="photo-card__actions">
<AppButton compact type="secondary" label="删除照片" @click.stop="requestDeletePhoto(item)" />
</view>
</view>
@@ -51,20 +98,32 @@
:visible="deleteConfirmationVisible"
:close-on-mask="false"
eyebrow="删除确认"
title="删除这张照片?"
message="删除后无法恢复,请确认影像已另行保存。"
confirm-text="确认删除"
title="这张照片移至回收站"
message="移入回收站后不再展示,家谱管理员可在保留期内恢复。"
confirm-text="移至回收站"
cancel-text="保留照片"
show-cancel
@confirm="deletePhoto"
@cancel="closeDeleteConfirmation"
/>
<AppDialog
:visible="batchDeleteConfirmationVisible"
:close-on-mask="false"
eyebrow="批量移除"
title="将选中的照片移至回收站?"
:message="batchDeleteConfirmationMessage"
:confirm-text="batchDeleting ? '正在移除' : '移至回收站'"
cancel-text="继续选择"
show-cancel
@confirm="deleteSelectedPhotos"
@cancel="closeBatchDeleteConfirmation"
/>
</view>
</template>
<script setup>
import { computed, ref } from "vue";
import { onLoad, onShow, onUnload } from "@dcloudio/uni-app";
import { onBackPress, onLoad, onShow, onUnload } from "@dcloudio/uni-app";
import AppButton from "@/components/AppButton.vue";
import AppDialog from "@/components/AppDialog.vue";
import AppLoading from "@/components/AppLoading.vue";
@@ -76,7 +135,7 @@ import {
} from "@/services/api/request-controller.js";
import { familyMediaApi } from "@/services/api/family-media-service.js";
import { getRequestErrorMessage } from "@/services/api/request-error-message.js";
import { goBack, openPage, returnTo } from "@/utils/navigation/gateway.js";
import { goBack, handleBackPress, openPage, returnTo } from "@/utils/navigation/gateway.js";
const genealogyId = ref("");
const albumId = ref("");
@@ -86,6 +145,11 @@ const deleteTarget = ref(null);
const deleteConfirmationVisible = ref(false);
const deletingPhotoId = ref("");
const deleteError = ref("");
const deleteNotice = ref("");
const selectionMode = ref(false);
const selectedPhotoIds = ref([]);
const batchDeleteConfirmationVisible = ref(false);
const batchDeleting = ref(false);
const albumPhotoListController = createRequestController();
const albumPhotoDeleteController = createRequestController();
let isPageActive = true;
@@ -93,6 +157,34 @@ const valid = computed(
() =>
/^[1-9]\d*$/.test(genealogyId.value) && /^[1-9]\d*$/.test(albumId.value),
);
const headerAction = computed(() => {
if (!valid.value) return "";
return selectionMode.value ? "完成" : "添加";
});
const deletablePhotos = computed(() => photos.value.filter((photo) => photo.canDelete));
const allDeletableSelected = computed(
() =>
deletablePhotos.value.length > 0 &&
deletablePhotos.value.every((photo) => selectedPhotoIds.value.includes(photo.id)),
);
const batchDeleteConfirmationMessage = computed(() =>
`将选中的 ${selectedPhotoIds.value.length} 张照片移入回收站;家谱管理员可在保留期内恢复。`,
);
const batchDeleteButtonLabel = computed(() =>
batchDeleting.value
? "正在删除"
: selectedPhotoIds.value.length
? `删除 ${selectedPhotoIds.value.length}`
: "删除",
);
const deleteInProgress = computed(
() => Boolean(deletingPhotoId.value) || batchDeleting.value,
);
const resetPhotoSelection = () => {
selectionMode.value = false;
selectedPhotoIds.value = [];
batchDeleteConfirmationVisible.value = false;
};
const loadPhotos = async () => {
if (!valid.value) return;
albumPhotoListController.abort();
@@ -106,6 +198,7 @@ const loadPhotos = async () => {
...photo,
meta: [photo.photographer, photo.shootTime].filter(Boolean).join(" · "),
}));
resetPhotoSelection();
albumPhotoListState.value = photos.value.length ? "ready" : "empty";
} catch (error) {
if (!isPageActive || isRequestCancelled(error)) return;
@@ -124,15 +217,54 @@ const addPhoto = () =>
"F08",
)
: Promise.resolve(false);
const handleHeaderAction = () => {
if (selectionMode.value) {
resetPhotoSelection();
return;
}
addPhoto();
};
const enterSelectionMode = () => {
if (!deletablePhotos.value.length || deleteInProgress.value) return;
deleteError.value = "";
deleteNotice.value = "";
selectedPhotoIds.value = [];
selectionMode.value = true;
};
const isPhotoSelected = (photo) => selectedPhotoIds.value.includes(photo?.id);
const togglePhotoSelection = (photo) => {
if (!selectionMode.value || !photo?.canDelete || batchDeleting.value) return;
selectedPhotoIds.value = isPhotoSelected(photo)
? selectedPhotoIds.value.filter((photoId) => photoId !== photo.id)
: [...selectedPhotoIds.value, photo.id];
deleteError.value = "";
deleteNotice.value = "";
};
const toggleAllDeletablePhotos = () => {
if (!selectionMode.value || batchDeleting.value) return;
selectedPhotoIds.value = allDeletableSelected.value
? []
: deletablePhotos.value.map((photo) => photo.id);
deleteError.value = "";
deleteNotice.value = "";
};
const previewPhoto = (photo) => {
const urls = photos.value.map((item) => item.photoFile?.accessUrl).filter(Boolean);
const current = photo?.photoFile?.accessUrl;
if (!current || !urls.length || typeof uni?.previewImage !== "function") return;
uni.previewImage({ current, urls });
};
const handlePhotoClick = (photo) => {
if (selectionMode.value && photo?.canDelete) {
togglePhotoSelection(photo);
return;
}
previewPhoto(photo);
};
const requestDeletePhoto = (photo) => {
if (!photo?.canDelete || deletingPhotoId.value) return;
if (!photo?.canDelete || deleteInProgress.value) return;
deleteError.value = "";
deleteNotice.value = "";
deleteTarget.value = photo;
deleteConfirmationVisible.value = true;
};
@@ -142,6 +274,22 @@ const closeDeleteConfirmation = () => {
deleteTarget.value = null;
}
};
const requestBack = () => {
if (deleteInProgress.value) return true;
if (batchDeleteConfirmationVisible.value) {
closeBatchDeleteConfirmation();
return true;
}
if (deleteConfirmationVisible.value) {
closeDeleteConfirmation();
return true;
}
if (selectionMode.value) {
resetPhotoSelection();
return true;
}
return returnToAlbums();
};
const deletePhoto = async () => {
const photo = deleteTarget.value;
if (!photo?.canDelete || deletingPhotoId.value) return;
@@ -163,6 +311,68 @@ const deletePhoto = async () => {
if (isPageActive) deletingPhotoId.value = "";
}
};
const requestBatchDelete = () => {
if (!selectionMode.value || !selectedPhotoIds.value.length || deleteInProgress.value) return;
deleteError.value = "";
deleteNotice.value = "";
batchDeleteConfirmationVisible.value = true;
};
const closeBatchDeleteConfirmation = () => {
if (!batchDeleting.value) batchDeleteConfirmationVisible.value = false;
};
const deleteSelectedPhotos = async () => {
if (!selectionMode.value || !selectedPhotoIds.value.length || deleteInProgress.value) return;
const photoIds = selectedPhotoIds.value.slice();
batchDeleting.value = true;
deleteError.value = "";
deleteNotice.value = "";
let deletedCount = 0;
let failedRequest = null;
try {
for (const photoId of photoIds) {
const photo = photos.value.find((item) => item.id === photoId);
if (!photo?.canDelete) continue;
try {
await familyMediaApi.deleteAlbumPhoto(
genealogyId.value,
albumId.value,
photoId,
{ requestController: albumPhotoDeleteController },
);
} catch (error) {
failedRequest = error;
break;
}
if (!isPageActive) return;
deletedCount += 1;
photos.value = photos.value.filter((item) => item.id !== photoId);
selectedPhotoIds.value = selectedPhotoIds.value.filter(
(selectedPhotoId) => selectedPhotoId !== photoId,
);
}
if (!isPageActive) return;
batchDeleteConfirmationVisible.value = false;
if (failedRequest && !isRequestCancelled(failedRequest)) {
const failureCopy = getRequestErrorMessage(
failedRequest,
"剩余照片删除失败,请稍后重试。",
);
deleteError.value = deletedCount
? `已删除 ${deletedCount} 张;${failureCopy}`
: failureCopy;
} else if (deletedCount) {
deleteNotice.value = `已删除 ${deletedCount} 张照片。`;
}
if (!photos.value.length) {
albumPhotoListState.value = "empty";
resetPhotoSelection();
} else if (!selectedPhotoIds.value.length) {
selectionMode.value = false;
}
} finally {
if (isPageActive) batchDeleting.value = false;
}
};
onLoad((query) => {
genealogyId.value = String(query?.genealogyId || "");
albumId.value = String(query?.albumId || "");
@@ -176,6 +386,7 @@ onUnload(() => {
albumPhotoListController.abort();
albumPhotoDeleteController.abort();
});
onBackPress((event) => handleBackPress(event, requestBack));
</script>
<style scoped lang="scss">
@@ -196,6 +407,7 @@ onUnload(() => {
.album-state-card,
.photo-card {
@include adaptive-family-content;
background-color: rgba($paper, 0.82);
}
.album-state-card {
width: 100%;
@@ -225,17 +437,80 @@ onUnload(() => {
.photo-list {
display: flex;
flex-direction: column;
gap: 16rpx;
gap: 20rpx;
}
.photo-management {
display: flex;
align-items: center;
justify-content: space-between;
gap: 18rpx;
padding: 22rpx 24rpx;
border: 1rpx solid rgba(128, 89, 49, 0.28);
border-radius: 12rpx;
background: rgba($paper, 0.9);
}
.photo-management__summary {
min-width: 0;
flex: 1;
}
.photo-management__summary text {
display: block;
}
.photo-management__summary text:first-child {
color: $ink;
font-size: clamp(15px, 24rpx, 18px);
font-weight: 700;
}
.photo-management__summary text:last-child {
margin-top: 6rpx;
color: $ink-muted;
font-size: clamp(13px, 21rpx, 16px);
}
.photo-management__actions {
display: flex;
flex: 0 0 auto;
gap: 12rpx;
}
.photo-card {
position: relative;
padding: 28rpx 32rpx;
}
.photo-card--selected {
border-color: rgba($brand-red, 0.62);
box-shadow: inset 0 0 0 2rpx rgba($brand-red, 0.12);
}
.photo-card__selection {
position: absolute;
z-index: 2;
top: 42rpx;
right: 46rpx;
min-width: 104rpx;
min-height: 64rpx;
box-sizing: border-box;
padding: 0 18rpx;
border: 1rpx solid rgba($brand-red, 0.48);
border-radius: 32rpx;
background: rgba($paper, 0.94);
color: $brand-red;
text-align: center;
line-height: 62rpx;
}
.photo-card--selected .photo-card__selection {
background: $brand-red;
color: #fff;
}
.photo-list__notice,
.photo-list__error {
display: block;
color: $brand-red;
font-size: clamp(13px, 21rpx, 16px);
line-height: 1.5;
}
.photo-list__notice {
color: #426b58;
}
.photo-list__error {
color: $brand-red;
}
.photo-card__image {
display: block;
width: 100%;
@@ -248,19 +523,46 @@ onUnload(() => {
justify-content: flex-end;
margin-top: 16rpx;
}
.photo-card text {
.photo-card__title,
.photo-card__copy,
.photo-card__meta {
display: block;
}
.photo-card text:first-child {
.photo-card__title {
color: $ink;
font-family: STKaiti, KaiTi, serif;
font-size: clamp(17px, 30rpx, 22px);
font-weight: 700;
overflow-wrap: anywhere;
}
.photo-card text:not(:first-child) {
.photo-card__copy,
.photo-card__meta {
margin-top: 8rpx;
color: $ink-muted;
font-size: clamp(14px, 22rpx, 17px);
line-height: 1.5;
overflow-wrap: anywhere;
}
.photo-card .photo-card__selection text {
display: inline;
margin: 0;
color: inherit;
font-family: inherit;
font-size: clamp(13px, 21rpx, 16px);
font-weight: 700;
line-height: inherit;
}
@media (max-width: 380px) {
.photo-management {
align-items: stretch;
flex-direction: column;
}
.photo-management__actions,
.photo-management > .app-button {
width: 100%;
}
.photo-management__actions .app-button {
flex: 1;
}
}
</style>
+9 -6
View File
@@ -42,6 +42,7 @@
<text>{{ item.name }}</text>
<text v-if="item.description">{{ item.description }}</text>
<text>{{ item.photoCount }} 张照片</text>
<text v-if="item.createTime">创建于 {{ formatMinuteTimestamp(item.createTime) }}</text>
<view
v-if="item.canEdit || item.canDelete"
class="album-card__actions"
@@ -118,9 +119,9 @@
:visible="deleteConfirmationVisible"
:close-on-mask="false"
eyebrow="删除确认"
title="删除这个相册?"
message="相册中的照片也可能无法恢复,请确认已另行保存。"
confirm-text="确认删除"
title="这个相册移至回收站"
message="相册和其中照片将不再展示,家谱管理员可在保留期内恢复。"
confirm-text="移至回收站"
cancel-text="保留相册"
show-cancel
@confirm="deleteAlbum"
@@ -143,6 +144,7 @@ import {
} from "@/services/api/request-controller.js";
import { familyMediaApi } from "@/services/api/family-media-service.js";
import { getRequestErrorMessage } from "@/services/api/request-error-message.js";
import { formatMinuteTimestamp } from "@/utils/display-time.js";
import {
isImagePickCancelled,
pickAndUploadImage,
@@ -387,11 +389,12 @@ onUnload(() => {
z-index: 1;
}
.album-list-content {
padding: 18rpx 24rpx 72rpx;
padding: 18rpx 24rpx calc(72rpx + env(safe-area-inset-bottom));
}
.album-state-card,
.album-card {
@include adaptive-family-content;
background-color: rgba($paper, 0.82);
}
.album-state-card {
min-height: 340rpx;
@@ -434,7 +437,7 @@ onUnload(() => {
.album-list {
display: flex;
flex-direction: column;
gap: 16rpx;
gap: 20rpx;
}
.album-card {
padding: 28rpx 30rpx;
@@ -477,7 +480,7 @@ onUnload(() => {
font-size: clamp(15px, 24rpx, 18px);
}
.album-dialog-field input {
min-height: 76rpx;
min-height: 80rpx;
padding-top: 0;
padding-bottom: 0;
}
+72 -9
View File
@@ -2,7 +2,7 @@
<view class="article-detail-page" :class="`article-state--${articleState}`">
<ModulePageBackground module="family" />
<view class="article-detail-header"
><PageHeader title="谱文详情" custom-back @back="backToArticles"
><PageHeader title="谱文详情" custom-back @back="requestBack"
/></view>
<view class="article-detail-content">
@@ -12,6 +12,15 @@
description="请稍候,正在同步谱文正文。"
/>
<view v-else-if="articleState === 'ready'" class="article-card">
<image
v-if="article.coverFile?.accessUrl"
class="article-card__cover"
:src="article.coverFile.accessUrl"
mode="aspectFill"
role="button"
aria-label="查看谱文封面"
@click="previewCover"
/>
<text v-if="article.category" class="article-card__category">{{
article.category
}}</text>
@@ -27,13 +36,17 @@
<text>这篇谱文已设置内容密码</text>
<input v-model="protectionPassword" password maxlength="128" placeholder="请输入8至128位内容密码" />
<AppButton block :disabled="protectionSubmitting" :label="protectionSubmitting ? '正在验证' : '解锁并查看'" @click="unlockArticle" />
<button class="article-lock-card__recovery" @click="passwordRecoveryVisible = true">忘记内容密码</button>
<text v-if="protectionError">{{ protectionError }}</text>
</view>
<text class="article-card__content">{{
article.contentProtected && !article.contentUnlocked ? "" : article.content || "作者暂未填写正文。"
}}</text>
<text class="article-card__views">阅读 {{ article.viewCount }} </text>
<view v-if="article.canEdit || article.canDelete" class="article-card__actions">
<view
v-if="article.canEdit || article.canDelete || article.canManageProtection"
class="article-card__actions"
>
<AppButton
v-if="article.canEdit && article.content"
compact
@@ -75,9 +88,9 @@
:visible="deleteConfirmationVisible"
:close-on-mask="false"
eyebrow="删除确认"
title="删除这篇谱文?"
message="删除后无法恢复,请确认当前内容不再需要。"
confirm-text="确认删除"
title="这篇谱文移至回收站"
message="移入回收站后不再展示,家谱管理员可在保留期内恢复。"
confirm-text="移至回收站"
cancel-text="保留谱文"
show-cancel
@confirm="deleteArticle"
@@ -98,15 +111,25 @@
<input v-if="protectionMode === 'set'" v-model="protectionPassword" class="protection-dialog-input" password maxlength="128" placeholder="请输入8至128位内容密码" />
<text v-if="protectionError" class="article-card__error">{{ protectionError }}</text>
</AppDialog>
<ContentPasswordRecoveryDialog
:visible="passwordRecoveryVisible"
:genealogy-id="genealogyId"
resource-type="ARTICLE"
:resource-id="articleId"
@close="passwordRecoveryVisible = false"
@complete="completePasswordRecovery"
@busy-change="passwordRecoveryBusy = $event"
/>
</view>
</template>
<script setup>
import { computed, ref } from "vue";
import { onLoad, onShow, onUnload } from "@dcloudio/uni-app";
import { onBackPress, onLoad, onShow, onUnload } from "@dcloudio/uni-app";
import AppButton from "@/components/AppButton.vue";
import AppDialog from "@/components/AppDialog.vue";
import AppLoading from "@/components/AppLoading.vue";
import ContentPasswordRecoveryDialog from "@/components/ContentPasswordRecoveryDialog.vue";
import ModulePageBackground from "@/components/ModulePageBackground.vue";
import PageHeader from "@/components/PageHeader.vue";
import {
@@ -115,7 +138,7 @@ import {
} from "@/services/api/request-controller.js";
import { familyArticleApi } from "@/services/api/family-article-service.js";
import { getRequestErrorMessage } from "@/services/api/request-error-message.js";
import { goBack, openPage, returnTo } from "@/utils/navigation/gateway.js";
import { goBack, handleBackPress, openPage, returnTo } from "@/utils/navigation/gateway.js";
const genealogyId = ref("");
const articleId = ref("");
@@ -130,6 +153,8 @@ const protectionError = ref("");
const protectionDialogVisible = ref(false);
const protectionMode = ref("set");
const protectionSubmitting = ref(false);
const passwordRecoveryVisible = ref(false);
const passwordRecoveryBusy = ref(false);
const articleReadController = createRequestController();
const articleProtectionController = createRequestController();
const articleDeleteController = createRequestController();
@@ -140,6 +165,11 @@ const hasValidContext = computed(
() =>
/^[1-9]\d*$/.test(genealogyId.value) && /^[1-9]\d*$/.test(articleId.value),
);
const previewCover = () => {
const url = article.value?.coverFile?.accessUrl;
if (!url || typeof uni?.previewImage !== "function") return;
uni.previewImage({ current: url, urls: [url] });
};
const stateCopy = computed(() => {
if (!hasValidContext.value) {
return {
@@ -241,6 +271,10 @@ const unlockArticle = async () => {
protectionSubmitting.value = false;
}
};
const completePasswordRecovery = (newPassword) => {
protectionPassword.value = newPassword;
protectionError.value = "内容密码已重置,请点击“解锁并查看”确认。";
};
const openProtectionDialog = (mode) => {
if (!article.value?.canManageProtection || protectionSubmitting.value) return;
protectionMode.value = mode;
@@ -305,6 +339,23 @@ const editArticle = () =>
const closeDeleteConfirmation = () => {
if (!deleting.value) deleteConfirmationVisible.value = false;
};
const requestBack = () => {
if (deleting.value || protectionSubmitting.value || passwordRecoveryBusy.value) return true;
if (passwordRecoveryVisible.value) {
passwordRecoveryVisible.value = false;
return true;
}
if (protectionDialogVisible.value) {
closeProtectionDialog();
return true;
}
if (deleteConfirmationVisible.value) {
closeDeleteConfirmation();
return true;
}
return backToArticles();
};
onBackPress((event) => handleBackPress(event, requestBack));
const deleteArticle = async () => {
if (!article.value?.canDelete || deleting.value) return;
deleting.value = true;
@@ -352,12 +403,13 @@ const deleteArticle = async () => {
}
.article-detail-content {
flex: 1;
padding: 18rpx 24rpx 72rpx;
padding: 18rpx 24rpx calc(72rpx + env(safe-area-inset-bottom));
}
.article-card,
.article-state-card {
@include adaptive-family-content;
box-sizing: border-box;
background-color: rgba($paper, 0.82);
}
.article-card {
margin-top: 18rpx;
@@ -383,6 +435,7 @@ const deleteArticle = async () => {
font-size: clamp(20px, 40rpx, 26px);
font-weight: 700;
line-height: 1.32;
overflow-wrap: anywhere;
}
.article-card__meta {
margin-top: 16rpx;
@@ -404,6 +457,7 @@ const deleteArticle = async () => {
color: $ink;
font-size: clamp(16px, 28rpx, 20px);
line-height: 1.85;
overflow-wrap: anywhere;
white-space: pre-wrap;
}
.article-card__views {
@@ -412,6 +466,13 @@ const deleteArticle = async () => {
font-size: clamp(13px, 21rpx, 16px);
text-align: right;
}
.article-card__cover {
width: 100%;
height: 360rpx;
margin-bottom: 22rpx;
border-radius: 10rpx;
background: rgba(128, 89, 49, 0.12);
}
.article-card__actions {
display: flex;
flex-wrap: wrap;
@@ -421,8 +482,10 @@ const deleteArticle = async () => {
}
.article-lock-card { margin: 16rpx 0; padding: 24rpx; border: 1rpx solid rgba(159, 23, 15, 0.3); border-radius: 10rpx; background: rgba(159, 23, 15, 0.05); }
.article-lock-card > text { display: block; color: $ink; font-size: clamp(14px, 22rpx, 17px); }
.article-lock-card__recovery { min-height: var(--app-touch-min); margin: 4rpx auto 0; padding: 0 16rpx; border: 0; background: transparent; color: #9e251b; font-size: clamp(13px, 22rpx, 16px); }
.article-lock-card__recovery::after { border: 0; }
.article-lock-card input,
.protection-dialog-input { box-sizing: border-box; width: 100%; min-height: 76rpx; margin: 16rpx 0; padding: 14rpx 18rpx; border: 1rpx solid rgba(128, 89, 49, 0.32); border-radius: 8rpx; background: #fffdf8; color: $ink; font-size: clamp(14px, 22rpx, 17px); }
.protection-dialog-input { box-sizing: border-box; width: 100%; min-height: 80rpx; margin: 16rpx 0; padding: 14rpx 18rpx; border: 1rpx solid rgba(128, 89, 49, 0.32); border-radius: 8rpx; background: #fffdf8; color: $ink; font-size: clamp(14px, 22rpx, 17px); }
.article-card__error {
margin-top: 12rpx;
color: $brand-red;
+22
View File
@@ -73,6 +73,7 @@
<text v-if="coverFileName" class="upload-receipt"
>已上传{{ coverFileName }}</text
>
<button v-if="coverOssId" class="remove-cover-button" :disabled="uploading || isSubmitting" @click="clearCover">移除封面</button>
<text v-if="uploadError" class="editor-save-error">{{
uploadError
}}</text>
@@ -344,6 +345,13 @@ const uploadCover = async () => {
}
};
const clearCover = () => {
if (uploading.value || isSubmitting.value) return;
coverOssId.value = null;
coverFileName.value = "";
uploadError.value = "";
};
const saveArticle = async () => {
if (isSubmitting.value || uploading.value || !hasValidContext.value) return;
if (!form.articleTitle.trim() || !form.articleContent.trim()) {
@@ -440,6 +448,7 @@ onUnload(() => {
.editor-result-card {
@include adaptive-family-panel;
width: 100%;
background-color: rgba($paper, 0.82);
}
.editor-panel__body {
padding: 38rpx 34rpx 42rpx;
@@ -459,6 +468,7 @@ onUnload(() => {
font-size: clamp(19px, 34rpx, 24px);
font-weight: 700;
text-align: center;
overflow-wrap: anywhere;
}
.editor-intro {
display: block;
@@ -555,6 +565,18 @@ onUnload(() => {
font-size: clamp(13px, 21rpx, 16px);
overflow-wrap: anywhere;
}
.remove-cover-button {
justify-self: start;
min-height: 72rpx;
margin: 0;
padding: 0 18rpx;
border: 1rpx solid rgba($brand-red, 0.38);
border-radius: 8rpx;
background: transparent;
color: $brand-red;
font-size: clamp(13px, 21rpx, 16px);
}
.remove-cover-button::after { border: 0; }
.editor-placeholder {
color: #9e8e79;
}
+41 -3
View File
@@ -8,6 +8,9 @@
@action="createArticle"
/></view>
<view class="article-list-content">
<view v-if="articleCategoryError" class="article-category-error" role="alert">
<text>{{ articleCategoryError }}</text>
</view>
<view v-if="categoryOptions.length > 1" class="article-filter">
<text>文章分类</text>
<picker :range="categoryLabels" :value="categoryIndex" @change="selectCategory">
@@ -21,6 +24,13 @@
class="article-card"
@click="openArticle(item)"
>
<image
v-if="item.coverFile?.accessUrl"
class="article-card__cover"
:src="item.coverFile.accessUrl"
mode="aspectFill"
aria-hidden="true"
/>
<text class="article-card__title">{{ item.title }}</text>
<text class="article-card__summary">{{
item.summary || item.content
@@ -59,6 +69,7 @@ import {
createRequestController,
isRequestCancelled
} from "@/services/api/request-controller.js";
import { getRequestErrorMessage } from "@/services/api/request-error-message.js";
import { familyArticleApi } from "@/services/api/family-article-service.js";
import { goBack, openPage } from "@/utils/navigation/gateway.js";
@@ -67,6 +78,7 @@ const hasValidContext = ref(false);
const listState = ref("loading");
const articles = ref([]);
const categories = ref([]);
const articleCategoryError = ref("");
const selectedCategoryId = ref("");
const articleListRequestController = createRequestController();
const articleCategoryRequestController = createRequestController();
@@ -130,13 +142,18 @@ onUnload(() => {
articleCategoryRequestController.abort();
});
const loadArticleCategories = async () => {
articleCategoryError.value = "";
try {
return await familyArticleApi.getArticleCategories(genealogyId.value, {
requestController: articleCategoryRequestController,
});
} catch (error) {
if (isRequestCancelled(error)) throw error;
return [];
articleCategoryError.value = getRequestErrorMessage(
error,
"谱文分类暂时无法读取,当前仍可查看全部谱文。",
);
return null;
}
};
const loadArticles = async () => {
@@ -152,7 +169,7 @@ const loadArticles = async () => {
]);
if (!pageActive) return;
articles.value = rows;
categories.value = categoryRows;
if (categoryRows) categories.value = categoryRows;
if (!categoryOptions.value.some((item) => item.id === selectedCategoryId.value)) {
selectedCategoryId.value = "";
}
@@ -192,11 +209,20 @@ const handleStateAction = () => {
z-index: 1;
}
.article-list-content {
padding: 18rpx 24rpx 72rpx;
padding: 18rpx 24rpx calc(72rpx + env(safe-area-inset-bottom));
}
.article-list-items {
margin-top: 24rpx;
}
.article-category-error {
margin-top: 20rpx;
padding: 18rpx 22rpx;
border: 1rpx solid rgba(159, 35, 35, 0.22);
background: rgba(255, 247, 233, 0.94);
color: $brand-red;
font-size: clamp(13px, 22rpx, 16px);
line-height: 1.6;
}
.article-filter {
display: flex;
align-items: center;
@@ -207,6 +233,8 @@ const handleStateAction = () => {
background: rgba(255, 252, 242, 0.92);
color: $ink;
font-size: clamp(14px, 24rpx, 17px);
min-height: 80rpx;
box-sizing: border-box;
}
.article-filter__value {
color: $brand-red;
@@ -235,6 +263,14 @@ const handleStateAction = () => {
box-sizing: border-box;
margin-bottom: 16rpx;
padding: 28rpx;
background-color: rgba($paper, 0.82);
}
.article-card__cover {
width: 100%;
height: 280rpx;
margin-bottom: 20rpx;
border-radius: 10rpx;
background: rgba(128, 89, 49, 0.12);
}
.article-card text {
display: block;
@@ -244,6 +280,7 @@ const handleStateAction = () => {
font-family: STKaiti, KaiTi, serif;
font-size: clamp(17px, 31rpx, 22px);
font-weight: 700;
overflow-wrap: anywhere;
}
.article-card__summary {
display: -webkit-box;
@@ -263,6 +300,7 @@ const handleStateAction = () => {
}
.article-list-state-card {
@include adaptive-family-content;
background-color: rgba($paper, 0.82);
width: 100%;
min-height: 340rpx;
margin-top: 30rpx;
+7 -4
View File
@@ -51,6 +51,7 @@
@click="openEditFeed"
/>
<AppButton
v-if="feed.canDelete"
compact
type="secondary"
label="删除动态"
@@ -86,9 +87,9 @@
:visible="deleteConfirmationVisible"
:close-on-mask="false"
eyebrow="删除确认"
title="删除这条动态?"
message="删除后无法恢复,请确认当前内容不再需要。"
confirm-text="确认删除"
title="这条动态移至回收站"
message="移入回收站后不再展示,家谱管理员可在保留期内恢复。"
confirm-text="移至回收站"
cancel-text="保留动态"
show-cancel
@confirm="deleteFeed"
@@ -313,7 +314,7 @@ onBackPress((event) => handleBackPress(event, requestBack));
}
.feed-detail-content {
flex: 1;
padding: 18rpx 24rpx 72rpx;
padding: 18rpx 24rpx calc(72rpx + env(safe-area-inset-bottom));
}
.feed-detail-body {
margin-top: 18rpx;
@@ -322,6 +323,7 @@ onBackPress((event) => handleBackPress(event, requestBack));
.feed-state-card {
@include adaptive-family-content;
box-sizing: border-box;
background-color: rgba($paper, 0.82);
}
.feed-card {
padding: 30rpx;
@@ -348,6 +350,7 @@ onBackPress((event) => handleBackPress(event, requestBack));
color: $ink;
font-size: clamp(16px, 29rpx, 20px);
line-height: 1.7;
overflow-wrap: anywhere;
white-space: pre-wrap;
}
.feed-card__meta {
+6 -4
View File
@@ -365,14 +365,15 @@ onUnload(() => {
}
.publish-page__header,
.publish-panel {
@include adaptive-family-content;
z-index: 1;
}
.publish-panel {
@include adaptive-family-content;
width: calc(100% - 32rpx);
margin: 18rpx auto 0;
margin: 18rpx auto calc(48rpx + env(safe-area-inset-bottom));
padding: 9%;
box-sizing: border-box;
background-color: rgba($paper, 0.82);
}
.publish-form > text,
.publish-result > text {
@@ -421,11 +422,11 @@ onUnload(() => {
font-size: clamp(15px, 24rpx, 18px);
}
.publish-field input {
min-height: 64rpx;
min-height: 80rpx;
}
.publish-field__value--readonly {
display: flex;
min-height: 76rpx;
min-height: 80rpx;
align-items: center;
justify-content: space-between;
margin-top: 12rpx;
@@ -459,6 +460,7 @@ onUnload(() => {
}
.upload-button {
justify-self: start;
min-height: 80rpx;
margin: 0;
padding: 0 20rpx;
border: 1rpx solid rgba(159, 23, 15, 0.42);
+18 -8
View File
@@ -113,6 +113,7 @@ const shortcuts = [
{ key: "albums", label: "相册" },
{ key: "rituals", label: "礼仪" },
{ key: "memos", label: "备忘" },
{ key: "benefactors", label: "家族恩人" },
{ key: "people", label: "人物录" },
{ key: "gifts", label: "贺礼簿" },
{ key: "merits", label: "功德录" },
@@ -239,12 +240,20 @@ const openSection = (key) => {
albums: "F07",
rituals: "R05",
memos: "R10",
benefactors: "R10",
people: "R01",
gifts: "R03",
merits: "R11",
videos: "F10",
};
return openPage(routes[key], { genealogyId: genealogyId.value }, "F01");
return openPage(
routes[key],
{
genealogyId: genealogyId.value,
...(key === "benefactors" ? { memoType: "benefactor" } : {}),
},
"F01",
);
};
const handlePrimaryAction = () =>
feedState.value === "error" ? loadFeeds() : toPublish();
@@ -264,7 +273,7 @@ const handlePrimaryAction = () =>
}
.feed-content {
flex: 1;
padding: 24rpx 24rpx 190rpx;
padding: 24rpx 24rpx calc(190rpx + env(safe-area-inset-bottom));
}
.feed-heading text {
display: block;
@@ -283,8 +292,8 @@ const handlePrimaryAction = () =>
.feed-shortcuts {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 8rpx;
margin-top: 15rpx;
gap: 12rpx;
margin-top: 18rpx;
}
.feed-shortcut {
box-sizing: border-box;
@@ -312,6 +321,7 @@ const handlePrimaryAction = () =>
box-sizing: border-box;
margin-top: 16rpx;
padding: 30rpx;
background-color: rgba($paper, 0.82);
}
.feed-card text,
.feed-state-card text {
@@ -353,14 +363,14 @@ const handlePrimaryAction = () =>
display: block;
width: 420rpx;
max-width: 100%;
min-height: 76rpx;
min-height: 80rpx;
margin: 22rpx auto 0;
padding: 0 24rpx;
border: 0;
color: $ink;
font-size: clamp(14px, 22rpx, 17px);
font-weight: 700;
line-height: 76rpx;
line-height: 80rpx;
}
.feed-more::after {
border: 0;
@@ -390,12 +400,12 @@ const handlePrimaryAction = () =>
@include adaptive-scroll-button(primary);
width: 514rpx;
max-width: 100%;
min-height: 76rpx;
min-height: 80rpx;
margin: 19rpx auto;
}
.feed-action text {
display: flex;
min-height: 76rpx;
min-height: 80rpx;
align-items: center;
justify-content: center;
color: #fff9ed;
+550
View File
@@ -0,0 +1,550 @@
<template>
<view class="platform-video-page">
<ModulePageBackground module="family" />
<view class="page-header">
<PageHeader title="宣传视频" custom-back @back="requestBack" />
</view>
<view class="page-content">
<view v-if="pageState === 'loading'" class="state-card">
<AppLoading text="正在加载平台视频" />
</view>
<view v-else-if="pageState === 'error'" class="state-card">
<text>{{ pageError }}</text>
<AppButton block label="重新加载" @click="loadPlatformVideos" />
</view>
<view v-else-if="!videos.length" class="state-card">
<text>暂时没有可观看的平台视频</text>
</view>
<view v-else class="video-list">
<AppButton
block
type="secondary"
label="上下滑动观看"
@click="openVerticalViewer(videos[0])"
/>
<view v-for="video in videos" :key="video.id" class="video-card">
<view
v-if="video.coverFile?.accessUrl"
class="video-card__cover-button"
role="button"
:aria-label="`播放${video.title}`"
hover-class="action-hover"
@click="openVerticalViewer(video)"
>
<image
:src="video.coverFile.accessUrl"
mode="aspectFill"
class="video-card__cover"
/>
<view class="video-card__play" aria-hidden="true"></view>
</view>
<video
v-else
:src="video.videoFile.accessUrl"
controls
class="video-card__player"
/>
<text class="video-card__title">{{ video.title }}</text>
<text v-if="video.description" class="video-card__copy">{{ video.description }}</text>
<text v-if="video.startAt" class="video-card__meta">发布时间{{ video.startAt }}</text>
<view class="video-card__actions">
<AppButton
compact
type="secondary"
:disabled="Boolean(actionKey)"
label="沉浸观看"
@click="openVerticalViewer(video)"
/>
<AppButton
compact
type="secondary"
:disabled="Boolean(actionKey)"
:label="video.likedByCurrentUser ? `已赞 ${video.likeCount}` : `点赞 ${video.likeCount}`"
@click="togglePlatformVideoLike(video)"
/>
<AppButton
compact
type="secondary"
:disabled="Boolean(actionKey)"
:label="`评论 ${video.commentCount}`"
@click="openPlatformVideoComments(video)"
/>
</view>
<text
v-if="actionErrorVideoId === video.id"
class="action-error"
role="alert"
>{{ actionError }}</text>
</view>
</view>
</view>
<AppDialog
:visible="Boolean(commentTarget)"
eyebrow="视频评论"
:title="commentTarget?.title || '平台视频'"
:confirm-text="actionKey === 'send-comment' ? '正在发送' : '发表评论'"
cancel-text="关闭"
show-cancel
:close-on-mask="false"
@confirm="sendPlatformVideoComment"
@cancel="closePlatformVideoComments"
>
<view v-if="comments.length" class="comment-list">
<view v-for="comment in comments" :key="comment.id" class="comment-row">
<text>{{ comment.author }}{{ comment.content }}</text>
<text v-if="comment.time" class="comment-row__time">{{ comment.time }}</text>
<button
v-if="comment.canDelete"
class="comment-row__delete"
:disabled="Boolean(actionKey)"
@click="requestDeletePlatformVideoComment(comment)"
>删除</button>
</view>
</view>
<text v-else class="comments-empty">还没有评论可以先说说你的看法</text>
<text v-if="commentError" class="action-error" role="alert">{{ commentError }}</text>
<textarea
v-model="commentText"
maxlength="1000"
placeholder="说说你的看法"
class="comment-input"
@input="commentError = ''"
/>
</AppDialog>
<VerticalVideoViewer
:visible="verticalViewerVisible"
:videos="videos"
:initial-video-id="verticalViewerInitialId"
title="宣传视频"
:action-busy="Boolean(actionKey)"
@close="closeVerticalViewer"
@like="togglePlatformVideoLike"
@comments="openVerticalViewerComments"
/>
<AppDialog
:visible="Boolean(commentDeleteTarget)"
eyebrow="评论管理"
title="删除这条评论?"
message="删除后将按服务端规则保留占位或移除内容。"
:confirm-text="actionKey === 'delete-comment' ? '正在删除' : '确认删除'"
cancel-text="保留评论"
show-cancel
:close-on-mask="false"
@confirm="deletePlatformVideoComment"
@cancel="closePlatformVideoCommentDelete"
/>
</view>
</template>
<script setup>
import { ref } from "vue";
import { onBackPress, onLoad, onUnload } from "@dcloudio/uni-app";
import AppButton from "@/components/AppButton.vue";
import AppDialog from "@/components/AppDialog.vue";
import AppLoading from "@/components/AppLoading.vue";
import ModulePageBackground from "@/components/ModulePageBackground.vue";
import PageHeader from "@/components/PageHeader.vue";
import VerticalVideoViewer from "@/components/family/VerticalVideoViewer.vue";
import { PLATFORM_VIDEO_PLACEMENT } from "@/services/api/family-media-contract.js";
import { genealogyCapabilityApi } from "@/services/api/genealogy-capability-service.js";
import {
createRequestController,
isRequestCancelled,
} from "@/services/api/request-controller.js";
import { getRequestErrorMessage } from "@/services/api/request-error-message.js";
import { createNonIdempotentWriteGuard } from "@/utils/non-idempotent-write-guard.js";
import { goBack, handleBackPress } from "@/utils/navigation/gateway.js";
const placement = ref(PLATFORM_VIDEO_PLACEMENT.VIDEO_CENTER);
const requestedVideoId = ref("");
const videos = ref([]);
const verticalViewerVisible = ref(false);
const verticalViewerInitialId = ref("");
const pageState = ref("loading");
const pageError = ref("");
const actionKey = ref("");
const actionErrorVideoId = ref("");
const actionError = ref("");
const commentTarget = ref(null);
const comments = ref([]);
const commentText = ref("");
const commentError = ref("");
const commentDeleteTarget = ref(null);
const platformVideoListController = createRequestController();
const platformVideoLikeController = createRequestController();
const platformVideoCommentListController = createRequestController();
const platformVideoCommentWriteController = createRequestController();
const platformVideoCommentDeleteController = createRequestController();
const platformVideoCommentGuard = createNonIdempotentWriteGuard();
let pageActive = true;
const openVerticalViewer = (video) => {
if (!video || actionKey.value) return;
verticalViewerInitialId.value = String(video.id);
verticalViewerVisible.value = true;
};
const closeVerticalViewer = () => {
verticalViewerVisible.value = false;
};
const openVerticalViewerComments = (video) => {
closeVerticalViewer();
return openPlatformVideoComments(video);
};
const loadPlatformVideos = async () => {
platformVideoListController.abort();
pageState.value = "loading";
pageError.value = "";
try {
const rows = await genealogyCapabilityApi.getPlatformVideos(
placement.value,
{ requestController: platformVideoListController },
);
if (!pageActive) return;
videos.value = rows;
pageState.value = "ready";
if (requestedVideoId.value) {
const requestedVideo = rows.find(
(video) => String(video.id) === requestedVideoId.value,
);
requestedVideoId.value = "";
if (requestedVideo) openVerticalViewer(requestedVideo);
}
} catch (error) {
if (!pageActive || isRequestCancelled(error)) return;
pageError.value = getRequestErrorMessage(error, "平台视频暂时无法读取。");
pageState.value = "error";
}
};
const togglePlatformVideoLike = async (video) => {
if (actionKey.value) return;
const liked = !video.likedByCurrentUser;
actionKey.value = `like-${video.id}`;
actionErrorVideoId.value = "";
actionError.value = "";
try {
await genealogyCapabilityApi.setPlatformVideoLike(
video.id,
liked,
{ requestController: platformVideoLikeController },
);
if (!pageActive) return;
video.likedByCurrentUser = liked;
video.likeCount = Math.max(0, video.likeCount + (liked ? 1 : -1));
} catch (error) {
if (!pageActive || isRequestCancelled(error)) return;
actionErrorVideoId.value = video.id;
actionError.value = getRequestErrorMessage(error, "点赞失败,请稍后重试。");
} finally {
if (pageActive) actionKey.value = "";
}
};
const openPlatformVideoComments = async (video) => {
if (actionKey.value) return;
platformVideoCommentListController.abort();
actionKey.value = `comments-${video.id}`;
actionErrorVideoId.value = "";
actionError.value = "";
try {
const rows = await genealogyCapabilityApi.getPlatformVideoComments(
video.id,
{ requestController: platformVideoCommentListController },
);
if (!pageActive) return;
comments.value = rows;
commentTarget.value = video;
commentText.value = "";
commentError.value = "";
} catch (error) {
if (!pageActive || isRequestCancelled(error)) return;
actionErrorVideoId.value = video.id;
actionError.value = getRequestErrorMessage(error, "评论暂时无法读取。");
} finally {
if (pageActive) actionKey.value = "";
}
};
const closePlatformVideoComments = () => {
if (["send-comment", "delete-comment"].includes(actionKey.value)) return;
commentTarget.value = null;
commentDeleteTarget.value = null;
comments.value = [];
commentText.value = "";
commentError.value = "";
};
const sendPlatformVideoComment = async () => {
const commentContent = commentText.value.trim();
if (!commentContent || !commentTarget.value || actionKey.value) return;
const commentPayload = {
videoId: commentTarget.value.id,
commentContent,
};
const commentAttempt = platformVideoCommentGuard.begin(commentPayload);
if (commentAttempt === null) {
commentError.value = "上次评论发送结果待确认,请重新打开评论列表检查,避免重复发布。";
return;
}
actionKey.value = "send-comment";
commentError.value = "";
try {
const createdComment = await genealogyCapabilityApi.createPlatformVideoComment(
commentPayload.videoId,
commentPayload.commentContent,
{ requestController: platformVideoCommentWriteController },
);
if (!pageActive || !commentTarget.value) return;
comments.value.push(createdComment);
commentTarget.value.commentCount += 1;
commentText.value = "";
} catch (error) {
const isOutcomeUnknown = platformVideoCommentGuard.recordFailure(commentAttempt, error);
if (!pageActive || !commentTarget.value) return;
commentError.value = isOutcomeUnknown
? "评论发送结果待确认,请重新打开评论列表检查,避免重复发布。"
: getRequestErrorMessage(error, "评论发送失败,请稍后重试。");
} finally {
if (pageActive) actionKey.value = "";
}
};
const requestDeletePlatformVideoComment = (comment) => {
if (!comment?.canDelete || actionKey.value) return;
commentError.value = "";
commentDeleteTarget.value = comment;
};
const closePlatformVideoCommentDelete = () => {
if (actionKey.value !== "delete-comment") commentDeleteTarget.value = null;
};
const deletePlatformVideoComment = async () => {
if (!commentTarget.value || !commentDeleteTarget.value?.canDelete || actionKey.value) return;
const target = commentDeleteTarget.value;
actionKey.value = "delete-comment";
commentError.value = "";
try {
await genealogyCapabilityApi.deletePlatformVideoComment(
commentTarget.value.id,
target.id,
{ requestController: platformVideoCommentDeleteController },
);
if (!pageActive || !commentTarget.value) return;
comments.value = await genealogyCapabilityApi.getPlatformVideoComments(
commentTarget.value.id,
{ requestController: platformVideoCommentListController },
);
commentTarget.value.commentCount = comments.value.length;
commentDeleteTarget.value = null;
} catch (error) {
if (!pageActive || isRequestCancelled(error)) return;
commentError.value = getRequestErrorMessage(error, "评论删除失败,请稍后重试。");
commentDeleteTarget.value = null;
} finally {
if (pageActive) actionKey.value = "";
}
};
const requestBack = () => {
if (verticalViewerVisible.value) {
closeVerticalViewer();
return true;
}
if (commentDeleteTarget.value) {
closePlatformVideoCommentDelete();
return true;
}
if (commentTarget.value) {
closePlatformVideoComments();
return true;
}
return goBack();
};
onLoad((query) => {
if (Object.values(PLATFORM_VIDEO_PLACEMENT).includes(query?.placement)) {
placement.value = query.placement;
}
requestedVideoId.value = String(query?.videoId || "");
void loadPlatformVideos();
});
onBackPress((event) => handleBackPress(event, requestBack));
onUnload(() => {
pageActive = false;
platformVideoListController.abort();
platformVideoLikeController.abort();
platformVideoCommentListController.abort();
platformVideoCommentWriteController.abort();
platformVideoCommentDeleteController.abort();
});
</script>
<style lang="scss" scoped>
.platform-video-page {
min-height: 100vh;
color: $ink;
}
.page-header,
.page-content {
position: relative;
z-index: 1;
}
.page-content {
padding: 24rpx 28rpx calc(72rpx + env(safe-area-inset-bottom));
}
.state-card,
.video-card {
border: 1rpx solid rgba($gold, .38);
border-radius: 16rpx;
background: rgba(255, 252, 245, .92);
}
.state-card {
padding: 48rpx 28rpx;
text-align: center;
}
.state-card text {
display: block;
color: $ink-muted;
}
.state-card .app-button {
margin-top: 20rpx;
}
.video-list {
display: flex;
flex-direction: column;
gap: 20rpx;
}
.video-card {
padding: 28rpx;
}
.video-card__player {
width: 100%;
height: 360rpx;
border-radius: 10rpx;
background: #1f1b17;
}
.video-card__cover-button {
position: relative;
width: 100%;
height: 360rpx;
border-radius: 10rpx;
background: #1f1b17;
overflow: hidden;
}
.video-card__cover {
width: 100%;
height: 100%;
}
.video-card__play {
position: absolute;
top: 50%;
left: 50%;
display: flex;
width: 76rpx;
height: 76rpx;
align-items: center;
justify-content: center;
border: 2rpx solid rgba(255, 255, 255, .9);
border-radius: 50%;
background: rgba(31, 27, 23, .64);
transform: translate(-50%, -50%);
}
.video-card__play::after {
width: 0;
height: 0;
margin-left: 6rpx;
border-top: 13rpx solid transparent;
border-bottom: 13rpx solid transparent;
border-left: 20rpx solid #fff;
content: "";
}
.video-card__title,
.video-card__copy,
.video-card__meta,
.comments-empty,
.action-error,
.comment-row text {
display: block;
}
.video-card__title {
margin-top: 16rpx;
font-size: clamp(18px, 30rpx, 22px);
font-weight: 700;
}
.video-card__copy,
.video-card__meta {
margin-top: 10rpx;
color: $ink-muted;
line-height: 1.55;
}
.video-card__meta {
font-size: clamp(12px, 21rpx, 15px);
}
.video-card__actions {
display: flex;
gap: 16rpx;
margin-top: 20rpx;
}
.comment-list {
width: 100%;
max-height: 440rpx;
overflow-y: auto;
text-align: left;
}
.comment-row {
position: relative;
padding: 14rpx 96rpx 14rpx 0;
border-bottom: 1rpx solid rgba($gold, .2);
color: $ink;
line-height: 1.55;
}
.comment-row__time {
margin-top: 4rpx;
color: $ink-muted;
font-size: clamp(12px, 20rpx, 14px);
}
.comment-row__delete {
position: absolute;
top: 8rpx;
right: 0;
min-height: var(--app-touch-min);
margin: 0;
padding: 0 10rpx;
border: 0;
background: transparent;
color: $brand-red;
font-size: clamp(12px, 20rpx, 14px);
line-height: var(--app-touch-min);
}
.comment-row__delete::after {
border: 0;
}
.comments-empty {
width: 100%;
margin-top: 18rpx;
color: $ink-muted;
text-align: left;
}
.action-error {
margin-top: 14rpx;
color: $brand-red-dark;
font-size: clamp(13px, 22rpx, 16px);
line-height: 1.5;
}
.comment-input {
width: 100%;
min-height: 120rpx;
margin-top: 18rpx;
padding: 16rpx;
border: 1rpx solid rgba($gold, .38);
border-radius: 10rpx;
box-sizing: border-box;
text-align: left;
}
</style>
+532 -18
View File
@@ -6,7 +6,7 @@
title="家族视频"
:action="pageState === 'list' ? '发布' : ''"
custom-back
@back="returnToFamily"
@back="requestBack"
@action="openPublishForm"
/>
</view>
@@ -64,6 +64,7 @@
<text v-if="coverReceipt" class="upload-receipt"
>已上传{{ coverReceipt.fileName || "视频封面" }}</text
>
<button v-if="coverReceipt" class="remove-cover-button" :disabled="coverUploading || submitting" @click="clearCover">移除封面</button>
</view>
<text v-if="coverUploadError" class="field-error">{{
coverUploadError
@@ -106,6 +107,7 @@
</view>
<view v-else-if="pageState === 'list'" class="video-list-panel">
<AppButton block type="secondary" label="观看平台视频" @click="openPlatformVideos" />
<view v-if="videoListState === 'loading'" class="video-state-card">
<AppLoading text="正在读取家族视频" />
</view>
@@ -127,12 +129,25 @@
<AppButton block label="发布视频" @click="openPublishForm" />
</view>
<view v-else class="video-card-list">
<view v-for="video in videos" :key="video.id" class="video-card">
<video
class="video-card__player"
:src="video.videoFile.accessUrl"
controls
<AppButton
block
type="secondary"
label="上下滑动观看"
@click="openVerticalViewer(videos[0])"
/>
<view v-for="video in videos" :key="video.id" class="video-card">
<button
class="video-card__cover-action"
:aria-label="`播放${video.title}`"
@click="openVerticalViewer(video)"
>
<image
class="video-card__cover"
:src="video.coverFile?.accessUrl || '/static/assets/modules/genealogy/transparent/empty-panel-frame.png'"
mode="aspectFill"
/>
<text class="video-card__play-copy">点击播放</text>
</button>
<text class="video-card__title">{{ video.title }}</text>
<text v-if="video.description" class="video-card__copy">{{
video.description
@@ -152,6 +167,7 @@
@click="openEditVideo(video)"
/>
<AppButton
v-if="video.canDelete"
compact
type="secondary"
:disabled="deletingVideoId === video.id"
@@ -159,6 +175,11 @@
@click="requestDeleteVideo(video)"
/>
</view>
<view class="video-card__actions">
<AppButton compact type="secondary" label="沉浸观看" @click="openVerticalViewer(video)" />
<AppButton compact type="secondary" :disabled="videoActionKey === `like-${video.id}`" :label="video.likedByCurrentUser ? `已赞 ${video.likeCount || 0}` : `点赞 ${video.likeCount || 0}`" @click="toggleVideoLike(video)" />
<AppButton compact type="secondary" :disabled="videoActionKey === `comments-${video.id}`" label="查看评论" @click="openVideoComments(video)" />
</view>
</view>
</view>
<text v-if="videoActionError" class="field-error">{{
@@ -174,31 +195,125 @@
<AppDialog
:visible="deleteConfirmVisible"
eyebrow="删除确认"
title="删除这段家族视频?"
:message="deleteTarget ? `《${deleteTarget.title}》删除后不可恢复。` : ''"
:confirm-text="deletingVideoId ? '正在除' : '确认删除'"
title="这段家族视频移至回收站"
:message="deleteTarget ? `《${deleteTarget.title}》移入回收站后不再展示,管理员可在保留期内恢复。` : ''"
:confirm-text="deletingVideoId ? '正在除' : '移至回收站'"
cancel-text="保留视频"
show-cancel
:close-on-mask="false"
@confirm="confirmDeleteVideo"
@cancel="deleteConfirmVisible = false"
/>
<AppDialog
:visible="discardVisible"
eyebrow="未保存修改"
title="放弃视频修改?"
message="当前修改还没有保存。"
confirm-text="确认放弃"
cancel-text="继续编辑"
show-cancel
:close-on-mask="false"
@confirm="confirmDiscard"
@cancel="cancelDiscard"
/>
<AppDialog
:visible="Boolean(commentTarget)"
eyebrow="视频评论"
:title="commentTarget?.title || '家族视频'"
:confirm-text="videoActionKey === 'send-comment' ? '正在发送' : replyTarget ? '发送回复' : '发表评论'"
cancel-text="关闭"
show-cancel
:close-on-mask="false"
@confirm="sendVideoComment"
@cancel="closeVideoComments"
>
<view v-if="replyTarget" class="video-reply-target">
<text>正在回复 {{ replyTarget.author }}</text>
<button class="video-comment-action" @click="cancelVideoReply">取消回复</button>
</view>
<view v-if="videoComments.length" class="video-comments">
<view
v-for="comment in videoComments"
:key="comment.id"
class="video-comment"
:class="{ 'video-comment--reply': comment.level === 'reply' }"
>
<view class="video-comment__heading">
<text>{{ comment.author }}</text>
<text>{{ comment.time }}</text>
</view>
<text v-if="comment.parentAuthor" class="video-comment__context"
>回复 {{ comment.parentAuthor }}</text
>
<text class="video-comment__content">{{ comment.content }}</text>
<view v-if="!comment.userDeleted || comment.canDelete" class="video-comment__actions">
<button
v-if="!comment.userDeleted && comment.level === 'root'"
class="video-comment-action"
@click="startVideoReply(comment)"
>
回复
</button>
<button
v-if="comment.canDelete"
class="video-comment-action video-comment-action--danger"
@click="requestDeleteVideoComment(comment)"
>
删除
</button>
</view>
</view>
</view>
<text v-else class="video-comments__empty">还没有评论可以先说说你的看法</text>
<text v-if="videoCommentError" class="field-error">{{ videoCommentError }}</text>
<textarea
v-model="videoCommentText"
maxlength="1000"
:placeholder="videoCommentPlaceholder"
class="video-comment-input"
@input="videoCommentError = ''"
/>
</AppDialog>
<VerticalVideoViewer
:visible="verticalViewerVisible"
:videos="videos"
:initial-video-id="verticalViewerInitialId"
title="家族视频"
:action-busy="Boolean(videoActionKey)"
@close="closeVerticalViewer"
@like="toggleVideoLike"
@comments="openVerticalViewerComments"
/>
<AppDialog
:visible="Boolean(commentDeleteTarget)"
:close-on-mask="false"
eyebrow="评论管理"
title="删除这条评论?"
message="删除后将按服务端规则保留占位或移除内容。"
:confirm-text="videoActionKey === 'delete-comment' ? '正在删除' : '确认删除'"
cancel-text="保留评论"
show-cancel
@confirm="deleteVideoComment"
@cancel="closeVideoCommentDelete"
/>
</view>
</template>
<script setup>
import { computed, reactive, ref } from "vue";
import { onLoad, onUnload } from "@dcloudio/uni-app";
import { onBackPress, onLoad, onUnload } from "@dcloudio/uni-app";
import AppLoading from "@/components/AppLoading.vue";
import AppButton from "@/components/AppButton.vue";
import AppDialog from "@/components/AppDialog.vue";
import ModulePageBackground from "@/components/ModulePageBackground.vue";
import PageHeader from "@/components/PageHeader.vue";
import VerticalVideoViewer from "@/components/family/VerticalVideoViewer.vue";
import {
createRequestController,
isRequestCancelled
} from "@/services/api/request-controller.js";
import { familyMediaApi } from "@/services/api/family-media-service.js";
import { genealogyCapabilityApi } from "@/services/api/genealogy-capability-service.js";
import { getRequestErrorMessage } from "@/services/api/request-error-message.js";
import {
isImagePickCancelled,
@@ -207,12 +322,15 @@ import {
pickAndUploadVideo,
} from "@/utils/media-upload.js";
import { createNonIdempotentWriteGuard } from "@/utils/non-idempotent-write-guard.js";
import { goBack, returnTo } from "@/utils/navigation/gateway.js";
import { createDiscardConfirmation } from "@/utils/discard-confirmation.js";
import { goBack, handleBackPress, openPage, returnTo } from "@/utils/navigation/gateway.js";
const genealogyId = ref("");
const pageState = ref("list");
const videoListState = ref("loading");
const videos = ref([]);
const verticalViewerVisible = ref(false);
const verticalViewerInitialId = ref("");
const receipt = ref(null);
const coverReceipt = ref(null);
const uploading = ref(false);
@@ -223,20 +341,222 @@ const coverUploadError = ref("");
const submitError = ref("");
const form = reactive({ videoTitle: "", videoDesc: "" });
const editingVideo = ref(null);
const formBaseline = ref("");
const discardVisible = ref(false);
const videoListRequestController = createRequestController();
const videoDetailRequestController = createRequestController();
const videoUploadRequestController = createRequestController();
const coverUploadRequestController = createRequestController();
const videoSaveRequestController = createRequestController();
const videoDeletionRequestController = createRequestController();
const videoCommentListController = createRequestController();
const videoCommentWriteController = createRequestController();
const videoCommentDeleteController = createRequestController();
const videoCreateGuard = createNonIdempotentWriteGuard();
const videoCommentCreateGuard = createNonIdempotentWriteGuard();
const deleteTarget = ref(null);
const deleteConfirmVisible = ref(false);
const deletingVideoId = ref("");
const videoActionError = ref("");
const videoActionKey = ref("");
const commentTarget = ref(null);
const videoComments = ref([]);
const videoCommentText = ref("");
const videoCommentError = ref("");
const replyTarget = ref(null);
const commentDeleteTarget = ref(null);
let pageActive = true;
const discardConfirmation = createDiscardConfirmation((visible) => {
discardVisible.value = visible;
});
const confirmDiscard = discardConfirmation.confirm;
const cancelDiscard = discardConfirmation.cancel;
const hasValidContext = computed(() => /^[1-9]\d*$/.test(genealogyId.value));
const videoCommentPlaceholder = computed(() =>
replyTarget.value ? `回复 ${replyTarget.value.author}` : "说说你的看法",
);
const openPlatformVideos = () => openPage("F11", { placement: "video_center" }, "F10");
const openVerticalViewer = (video) => {
if (!video || videoActionKey.value) return;
verticalViewerInitialId.value = String(video.id);
verticalViewerVisible.value = true;
};
const closeVerticalViewer = () => {
verticalViewerVisible.value = false;
};
const openVerticalViewerComments = (video) => {
closeVerticalViewer();
return openVideoComments(video);
};
const toggleVideoLike = async (video) => {
if (videoActionKey.value) return;
videoActionKey.value = `like-${video.id}`;
videoActionError.value = "";
const liked = !video.likedByCurrentUser;
try { await genealogyCapabilityApi.setVideoLike(genealogyId.value, video.id, liked); video.likedByCurrentUser = liked; video.likeCount = Math.max(0, Number(video.likeCount || 0) + (liked ? 1 : -1)); }
catch (error) { videoActionError.value = getRequestErrorMessage(error, "点赞失败,请稍后重试。"); }
finally { videoActionKey.value = ""; }
};
const loadVideoCommentThread = async (video) => {
const rootComments = await genealogyCapabilityApi.getVideoComments(
genealogyId.value,
video.id,
{ requestController: videoCommentListController },
);
const thread = [];
for (const rootComment of rootComments) {
thread.push(rootComment);
if (rootComment.replyCount <= 0) continue;
const replies = await genealogyCapabilityApi.getVideoCommentReplies(
genealogyId.value,
video.id,
rootComment.id,
{ requestController: videoCommentListController },
);
thread.push(...replies.map((reply) => ({
...reply,
parentAuthor: rootComment.author,
})));
}
return thread;
};
const openVideoComments = async (video) => {
if (videoActionKey.value) return;
videoActionKey.value = `comments-${video.id}`;
videoActionError.value = "";
videoCommentListController.abort();
try {
videoComments.value = await loadVideoCommentThread(video);
commentTarget.value = video;
videoCommentText.value = "";
videoCommentError.value = "";
replyTarget.value = null;
}
catch (error) { videoActionError.value = getRequestErrorMessage(error, "评论暂时无法读取。"); }
finally { videoActionKey.value = ""; }
};
const closeVideoComments = () => {
if (videoActionKey.value === "send-comment" || videoActionKey.value === "delete-comment") return;
commentTarget.value = null;
videoComments.value = [];
videoCommentText.value = "";
videoCommentError.value = "";
replyTarget.value = null;
commentDeleteTarget.value = null;
};
const startVideoReply = (comment) => {
if (!comment || comment.level !== "root" || comment.userDeleted || videoActionKey.value) return;
replyTarget.value = comment;
videoCommentText.value = "";
videoCommentError.value = "";
};
const cancelVideoReply = () => {
if (videoActionKey.value) return;
replyTarget.value = null;
videoCommentText.value = "";
videoCommentError.value = "";
};
const sendVideoComment = async () => {
if (!commentTarget.value || !videoCommentText.value.trim() || videoActionKey.value) return;
const commentPayload = {
genealogyId: genealogyId.value,
videoId: commentTarget.value.id,
commentContent: videoCommentText.value.trim(),
parentCommentId: replyTarget.value?.id || null,
};
const commentAttempt = videoCommentCreateGuard.begin(commentPayload);
if (commentAttempt === null) {
videoCommentError.value = "上次评论发送结果待确认,请先重新打开评论列表,避免重复发布。";
return;
}
videoActionKey.value = "send-comment";
videoCommentError.value = "";
try {
const comment = await genealogyCapabilityApi.createVideoComment(
commentPayload.genealogyId,
commentPayload.videoId,
commentPayload.commentContent,
commentPayload.parentCommentId,
{ requestController: videoCommentWriteController },
);
if (!pageActive) return;
if (commentPayload.parentCommentId) {
const rootComment = videoComments.value.find(
(currentComment) => currentComment.id === commentPayload.parentCommentId,
);
const insertedReply = {
...comment,
parentAuthor: rootComment?.author || replyTarget.value?.author || "",
};
let insertionIndex = videoComments.value.findIndex(
(currentComment) => currentComment.id === commentPayload.parentCommentId,
);
for (let index = insertionIndex + 1; index < videoComments.value.length; index += 1) {
if (videoComments.value[index].parentCommentId !== commentPayload.parentCommentId) break;
insertionIndex = index;
}
videoComments.value.splice(insertionIndex + 1, 0, insertedReply);
if (rootComment) rootComment.replyCount += 1;
} else {
videoComments.value.push(comment);
}
videoCommentText.value = "";
replyTarget.value = null;
}
catch (error) {
const isOutcomeUnknown = videoCommentCreateGuard.recordFailure(commentAttempt, error);
if (!pageActive) return;
videoCommentError.value = isOutcomeUnknown
? "评论发送结果待确认,请重新打开评论列表检查,避免重复发布。"
: getRequestErrorMessage(error, "评论发送失败,请稍后重试。");
}
finally { if (pageActive) videoActionKey.value = ""; }
};
const requestDeleteVideoComment = (comment) => {
if (!comment?.canDelete || videoActionKey.value) return;
videoCommentError.value = "";
commentDeleteTarget.value = comment;
};
const closeVideoCommentDelete = () => {
if (videoActionKey.value !== "delete-comment") commentDeleteTarget.value = null;
};
const deleteVideoComment = async () => {
if (!commentTarget.value || !commentDeleteTarget.value?.canDelete || videoActionKey.value) return;
const target = commentDeleteTarget.value;
videoActionKey.value = "delete-comment";
videoCommentError.value = "";
try {
await genealogyCapabilityApi.deleteVideoComment(
genealogyId.value,
commentTarget.value.id,
target.id,
{ requestController: videoCommentDeleteController },
);
if (!pageActive) return;
videoComments.value = await loadVideoCommentThread(commentTarget.value);
if (replyTarget.value?.id === target.id) replyTarget.value = null;
commentDeleteTarget.value = null;
} catch (error) {
if (!pageActive || isRequestCancelled(error)) return;
videoCommentError.value = getRequestErrorMessage(error, "评论删除失败,请稍后重试。");
commentDeleteTarget.value = null;
} finally {
if (pageActive) videoActionKey.value = "";
}
};
const isEdit = computed(() => Boolean(editingVideo.value));
const formSnapshot = computed(() => JSON.stringify({
videoTitle: form.videoTitle,
videoDesc: form.videoDesc,
videoOssId: receipt.value?.ossId || null,
coverOssId: coverReceipt.value?.ossId || null,
editingVideoId: editingVideo.value?.id || null,
}));
const isDirty = computed(() =>
pageState.value === "form" &&
Boolean(formBaseline.value) &&
formSnapshot.value !== formBaseline.value,
);
const stateCopy = computed(() =>
pageState.value === "success"
? {
@@ -264,6 +584,10 @@ onUnload(() => {
coverUploadRequestController.abort();
videoSaveRequestController.abort();
videoDeletionRequestController.abort();
videoCommentListController.abort();
videoCommentWriteController.abort();
videoCommentDeleteController.abort();
discardConfirmation.dispose();
});
const loadVideos = async () => {
@@ -292,6 +616,7 @@ const openPublishForm = () => {
coverUploadError.value = "";
submitError.value = "";
pageState.value = "form";
formBaseline.value = formSnapshot.value;
};
const openEditVideo = async (video) => {
if (
@@ -335,6 +660,7 @@ const openEditVideo = async (video) => {
coverUploadError.value = "";
submitError.value = "";
pageState.value = "form";
formBaseline.value = formSnapshot.value;
} catch (error) {
if (!isRequestCancelled(error)) {
videoActionError.value = "视频信息不完整,暂未保存修改,以免覆盖原内容。";
@@ -418,6 +744,11 @@ const selectCover = async () => {
if (pageActive) coverUploading.value = false;
}
};
const clearCover = () => {
if (coverUploading.value || submitting.value) return;
coverReceipt.value = null;
coverUploadError.value = "";
};
const submitVideo = async () => {
if (
uploading.value ||
@@ -439,7 +770,7 @@ const submitVideo = async () => {
videoTitle,
videoDesc: form.videoDesc.trim(),
videoOssId: receipt.value.ossId,
...(coverReceipt.value ? { coverOssId: coverReceipt.value.ossId } : {}),
coverOssId: coverReceipt.value?.ossId ?? null,
...(editingVideo.value
? {
durationSeconds: editingVideo.value.durationSeconds,
@@ -493,7 +824,50 @@ const returnToFamily = () =>
hasValidContext.value
? returnTo("F01", { genealogyId: genealogyId.value })
: goBack();
const returnToVideoList = () => {
pageState.value = "list";
formBaseline.value = "";
receipt.value = null;
coverReceipt.value = null;
editingVideo.value = null;
return true;
};
const requestBack = async () => {
if (verticalViewerVisible.value) {
closeVerticalViewer();
return true;
}
if (commentDeleteTarget.value) {
closeVideoCommentDelete();
return true;
}
if (commentTarget.value) {
closeVideoComments();
return true;
}
if (deleteConfirmVisible.value) {
deleteConfirmVisible.value = false;
return true;
}
if (discardVisible.value) {
cancelDiscard();
return true;
}
if (uploading.value || coverUploading.value || submitting.value || deletingVideoId.value) {
return true;
}
if (pageState.value === "form") {
if (isDirty.value && !(await discardConfirmation.request())) return false;
return returnToVideoList();
}
if (pageState.value === "form-loading") {
videoDetailRequestController.abort();
return returnToVideoList();
}
return returnToFamily();
};
const handleStateAction = () => returnToFamily();
onBackPress((event) => handleBackPress(event, requestBack));
</script>
<style scoped lang="scss">
@@ -510,15 +884,19 @@ const handleStateAction = () => returnToFamily();
}
.page-content {
flex: 1;
padding: 18rpx 24rpx 72rpx;
padding: 18rpx 24rpx calc(72rpx + env(safe-area-inset-bottom));
}
.video-panel,
.video-state-card {
box-sizing: border-box;
@include adaptive-family-content;
background-color: rgba($paper, 0.82);
}
.video-list-panel {
@include adaptive-family-content;
box-sizing: border-box;
padding: 28rpx 24rpx;
background-color: rgba($paper, 0.82);
}
.video-panel {
padding: 30rpx;
@@ -563,7 +941,7 @@ const handleStateAction = () => returnToFamily();
font-size: clamp(15px, 24rpx, 18px);
}
.video-field input {
min-height: 76rpx;
min-height: 80rpx;
padding: 0 18rpx;
}
.video-field textarea {
@@ -580,6 +958,7 @@ const handleStateAction = () => returnToFamily();
width: 100%;
}
.upload-button {
min-height: 80rpx;
margin: 0;
padding: 0 26rpx;
border: 1rpx solid #b78a42;
@@ -587,12 +966,24 @@ const handleStateAction = () => returnToFamily();
background: #fffaf0;
color: #805723;
font-size: clamp(14px, 23rpx, 17px);
line-height: 64rpx;
line-height: 78rpx;
}
.upload-receipt {
color: $ink-muted;
font-size: clamp(13px, 21rpx, 16px);
}
.remove-cover-button {
justify-self: start;
min-height: 72rpx;
margin: 0;
padding: 0 18rpx;
border: 1rpx solid rgba($brand-red, 0.38);
border-radius: 8rpx;
background: transparent;
color: $brand-red;
font-size: clamp(13px, 21rpx, 16px);
}
.remove-cover-button::after { border: 0; }
.required-mark,
.field-error {
color: $brand-red;
@@ -621,14 +1012,38 @@ const handleStateAction = () => returnToFamily();
padding: 22rpx;
border: 1rpx solid rgba(128, 89, 49, 0.28);
border-radius: 12rpx;
background: rgba(255, 252, 245, 0.78);
background: rgba($paper, 0.9);
}
.video-card__player {
.video-card__cover-action {
position: relative;
display: block;
width: 100%;
min-height: 340rpx;
height: 340rpx;
overflow: hidden;
margin: 0;
padding: 0;
border: 0;
border-radius: 8rpx;
background: #161616;
line-height: 1;
}
.video-card__cover-action::after {
border: 0;
}
.video-card__cover {
width: 100%;
height: 100%;
}
.video-card__play-copy {
position: absolute;
right: 20rpx;
bottom: 18rpx;
padding: 10rpx 16rpx;
border-radius: 8rpx;
background: rgba(22, 22, 22, 0.78);
color: #fff9ed;
font-size: clamp(13px, 21rpx, 16px);
font-weight: 700;
}
.video-card__title,
.video-card__copy,
@@ -640,12 +1055,14 @@ const handleStateAction = () => returnToFamily();
color: $ink;
font-size: clamp(17px, 28rpx, 21px);
font-weight: 700;
overflow-wrap: anywhere;
}
.video-card__copy {
margin-top: 8rpx;
color: $ink-muted;
font-size: clamp(14px, 23rpx, 17px);
line-height: 1.55;
overflow-wrap: anywhere;
}
.video-card__meta {
margin-top: 10rpx;
@@ -657,4 +1074,101 @@ const handleStateAction = () => returnToFamily();
justify-content: flex-end;
margin-top: 14rpx;
}
.video-reply-target,
.video-comment__heading,
.video-comment__actions {
display: flex;
align-items: center;
justify-content: space-between;
gap: 14rpx;
}
.video-reply-target {
width: 100%;
margin-top: 18rpx;
padding: 14rpx 16rpx;
box-sizing: border-box;
border-radius: 8rpx;
background: rgba($gold, 0.1);
color: $ink;
font-size: clamp(13px, 21rpx, 16px);
}
.video-comments {
width: 100%;
max-height: 440rpx;
margin-top: 16rpx;
overflow-y: auto;
text-align: left;
}
.video-comment {
padding: 18rpx 4rpx;
border-bottom: 1rpx solid rgba($gold, 0.2);
}
.video-comment--reply {
margin-left: 32rpx;
padding-left: 18rpx;
border-left: 3rpx solid rgba($gold, 0.34);
}
.video-comment__heading text:first-child {
color: $ink;
font-size: clamp(14px, 23rpx, 17px);
font-weight: 700;
}
.video-comment__heading text:last-child,
.video-comment__context {
color: $ink-muted;
font-size: clamp(12px, 20rpx, 15px);
}
.video-comment__context,
.video-comment__content,
.video-comments__empty {
display: block;
}
.video-comment__context {
margin-top: 6rpx;
}
.video-comment__content {
margin-top: 8rpx;
color: $ink;
font-size: clamp(14px, 22rpx, 17px);
line-height: 1.55;
overflow-wrap: anywhere;
}
.video-comment__actions {
justify-content: flex-end;
margin-top: 8rpx;
}
.video-comment-action {
min-width: 88rpx;
min-height: 58rpx;
margin: 0;
padding: 0 14rpx;
border: 0;
background: transparent;
color: #805723;
font-size: clamp(13px, 21rpx, 16px);
line-height: 58rpx;
}
.video-comment-action::after {
border: 0;
}
.video-comment-action--danger {
color: $brand-red;
}
.video-comments__empty {
width: 100%;
margin-top: 20rpx;
color: $ink-muted;
font-size: clamp(14px, 22rpx, 17px);
}
.video-comment-input {
width: 100%;
min-height: 120rpx;
margin-top: 18rpx;
padding: 16rpx;
box-sizing: border-box;
border: 1rpx solid rgba($gold, 0.38);
border-radius: 10rpx;
color: $ink;
text-align: left;
}
</style>
+2 -2
View File
@@ -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; }
+114
View File
@@ -0,0 +1,114 @@
<template>
<view class="capability-page">
<GenealogyPageBackground />
<view class="page-header"><PageHeader title="资料与回收站" custom-back @back="goBack" /></view>
<view class="page-content">
<view class="tab-row">
<button :class="['tab', { active: tab === 'completeness' }]" @click="tab = 'completeness'">资料完整度</button>
<button :class="['tab', { active: tab === 'recycle' }]" @click="tab = 'recycle'">内容回收站</button>
</view>
<view v-if="loading" class="state-card"><AppLoading text="正在读取家谱资料" /></view>
<view v-else-if="error" class="state-card"><text>{{ error }}</text><AppButton block label="重新加载" @click="load" /></view>
<view v-else-if="tab === 'completeness' && !hasValidCompleteness" class="state-card"><text>{{ completenessError || '资料完整度暂时无法读取。' }}</text><AppButton block label="重新加载" @click="load" /></view>
<view v-else-if="tab === 'completeness'" class="panel">
<text class="panel-title">资料完成度 {{ completeness?.completionRate || 0 }}%</text>
<text class="panel-copy">已完成 {{ completeness?.completedCount || 0 }} / {{ completeness?.totalCount || 0 }} </text>
<view v-if="!completeness?.missingItems?.length" class="empty-copy">这部家谱的基础资料已补充完整</view>
<view v-for="item in completeness?.missingItems || []" :key="item.code" class="row"><text>{{ item.displayText }}</text><text>待补充</text></view>
</view>
<view v-else-if="recycleError" class="state-card"><text>{{ recycleError }}</text><AppButton block label="重新加载" @click="load" /></view>
<view v-else class="panel">
<text class="panel-title">已删除内容</text>
<text class="panel-copy">可恢复的内容会显示恢复按钮</text>
<view v-if="!recycle.rows.length" class="empty-copy">回收站暂时没有内容</view>
<view v-for="item in recycle.rows" :key="`${item.resourceType}-${item.resourceId}`" class="row row--recycle">
<view><text>{{ item.resourceTitle || '未命名内容' }}</text><text class="row-meta">{{ item.resourceType }} · {{ item.deletedAt || '删除时间未知' }}</text></view>
<AppButton v-if="item.canRestore" class="restore-button" compact type="secondary" :disabled="restoringKey === `${item.resourceType}-${item.resourceId}`" label="恢复" @click="restore(item)" />
<text v-else class="row-meta">{{ recycleDisabledReason(item.disabledReason) }}</text>
</view>
</view>
</view>
</view>
</template>
<script setup>
import { computed, ref } from 'vue'
import { onLoad, onUnload } from '@dcloudio/uni-app'
import AppButton from '@/components/AppButton.vue'
import AppLoading from '@/components/AppLoading.vue'
import PageHeader from '@/components/PageHeader.vue'
import GenealogyPageBackground from '@/components/genealogy/PageBackground.vue'
import { genealogyCapabilityApi } from '@/services/api/genealogy-capability-service.js'
import { createRequestController, isRequestCancelled } from '@/services/api/request-controller.js'
import { getRequestErrorMessage } from '@/services/api/request-error-message.js'
import { goBack } from '@/utils/navigation/gateway.js'
const genealogyId = ref('')
const tab = ref('completeness')
const loading = ref(true)
const error = ref('')
const completenessError = ref('')
const recycleError = ref('')
const completeness = ref(null)
const recycle = ref({ rows: [], total: 0 })
const restoringKey = ref('')
const controller = createRequestController()
const recycleDisabledReason = (reason) => reason === 'ALREADY_RESTORED' ? '已恢复' : '不可恢复'
const hasValidCompleteness = computed(() => (
Number.isSafeInteger(completeness.value?.totalCount) &&
completeness.value.totalCount > 0 &&
Number.isSafeInteger(completeness.value?.completedCount) &&
completeness.value.completedCount >= 0 &&
completeness.value.completedCount <= completeness.value.totalCount &&
Number.isSafeInteger(completeness.value?.completionRate) &&
completeness.value.completionRate >= 0 &&
completeness.value.completionRate <= 100 &&
Array.isArray(completeness.value?.missingItems)
))
const load = async () => {
if (!/^[1-9]\d*$/.test(genealogyId.value)) { error.value = '缺少有效家谱信息'; loading.value = false; return }
controller.abort(); loading.value = true; error.value = ''; completenessError.value = ''; recycleError.value = ''
const [completenessResult, recycleResult] = await Promise.allSettled([
genealogyCapabilityApi.getCompleteness(genealogyId.value, { requestController: controller }),
genealogyCapabilityApi.getRecyclePage(genealogyId.value, { pageNum: 1, pageSize: 50, recycleStatus: 'OPEN' }, { requestController: controller })
])
if (completenessResult.status === 'fulfilled') completeness.value = completenessResult.value
else if (!isRequestCancelled(completenessResult.reason)) {
completeness.value = null
completenessError.value = getRequestErrorMessage(completenessResult.reason, '资料完整度暂时无法读取。')
}
if (recycleResult.status === 'fulfilled') recycle.value = recycleResult.value
else if (!isRequestCancelled(recycleResult.reason)) {
recycle.value = { rows: [], total: 0 }
recycleError.value = getRequestErrorMessage(recycleResult.reason, '回收站暂时无法读取。')
}
loading.value = false
}
const restore = async (item) => {
const key = `${item.resourceType}-${item.resourceId}`
if (restoringKey.value) return
restoringKey.value = key
try { await genealogyCapabilityApi.restoreRecycleItem(genealogyId.value, item.resourceType, item.resourceId); await load() }
catch (requestError) { recycleError.value = getRequestErrorMessage(requestError, '恢复失败,请稍后重试。') }
finally { restoringKey.value = '' }
}
onLoad((query) => { genealogyId.value = String(query?.genealogyId || ''); void load() })
onUnload(() => controller.abort())
</script>
<style lang="scss" scoped>
.capability-page { min-height: 100vh; color: $ink; }
.page-header { position: relative; z-index: 1; }
.page-content { position: relative; z-index: 1; padding: 24rpx 28rpx calc(72rpx + env(safe-area-inset-bottom)); }
.tab-row { display: flex; gap: 16rpx; margin-bottom: 20rpx; }
.tab { flex: 1; min-height: 80rpx; border: 1rpx solid rgba($gold, .38); border-radius: 12rpx; background: rgba(255,252,245,.88); color: $ink-muted; font-size: 26rpx; }
.tab.active { border-color: $brand-red; background: $brand-red; color: #fff; }
.panel,.state-card { padding: 28rpx; border: 1rpx solid rgba($gold, .38); border-radius: 16rpx; background: rgba(255,252,245,.92); }
.panel-title,.panel-copy,.row text,.empty-copy { display: block; }
.panel-title { font-size: 32rpx; font-weight: 700; }.panel-copy,.row-meta,.empty-copy { margin-top: 12rpx; color: $ink-muted; font-size: 25rpx; }
.row { display:flex; justify-content:space-between; align-items:center; gap:16rpx; padding:24rpx 0; border-top:1rpx solid rgba($gold, .38); }.row:first-of-type { margin-top:20rpx; }.row--recycle > view { flex:1; min-width:0; }
.restore-button { flex: 0 0 176rpx; width: 176rpx; }
</style>
+83 -20
View File
@@ -8,7 +8,7 @@
<text class="create-card__eyebrow">立谱信息</text>
<text class="create-card__title">为家族创建一部家谱</text>
<text class="create-card__note"
>创建成功后会回到我的家谱可在世系树中录入首位成员</text
>创建时会同步建立始迁祖人物创建成功后会回到我的家谱</text
>
<view class="field-row">
@@ -84,6 +84,37 @@
/>
</view>
<view class="field-row">
<text class="field-row__label"
><text class="required-mark">*</text>始迁祖</text
>
<input
v-model="form.firstAncestorName"
maxlength="30"
placeholder="请输入始迁祖姓名"
placeholder-class="placeholder"
@input="clearFieldError('firstAncestorName')"
/>
</view>
<text v-if="fieldErrors.firstAncestorName" class="field-error">{{
fieldErrors.firstAncestorName
}}</text>
<view class="owner-ancestor-field">
<view>
<text class="owner-ancestor-field__label">谱主本人就是始迁祖</text>
<text class="owner-ancestor-field__hint"
>仅本人确为始迁祖时开启开启后账号会绑定到该人物</text
>
</view>
<switch
:checked="form.ownerIsFirstAncestor"
color="#9f170f"
aria-label="谱主本人就是始迁祖"
@change="form.ownerIsFirstAncestor = $event.detail.value"
/>
</view>
<view class="field-row">
<text class="field-row__label">所在地</text>
<input
@@ -208,7 +239,6 @@ import {
import { genealogyApi } from "@/services/api/genealogy-service.js";
import { getRequestErrorMessage } from "@/services/api/request-error-message.js";
import { createDiscardConfirmation } from "@/utils/discard-confirmation.js";
import { createNonIdempotentWriteGuard } from "@/utils/non-idempotent-write-guard.js";
import {
GENEALOGY_ACCESS_PRESET,
GENEALOGY_ACCESS_PRESET_OPTIONS,
@@ -229,6 +259,8 @@ const form = reactive({
surname: "",
genealogyName: "",
ancestralHall: "",
firstAncestorName: "",
ownerIsFirstAncestor: false,
originPlace: "",
addressDetail: "",
intro: "",
@@ -237,6 +269,7 @@ const form = reactive({
const fieldErrors = reactive({
surname: "",
genealogyName: "",
firstAncestorName: "",
regionCode: "",
});
const submitError = ref("");
@@ -248,7 +281,11 @@ const coverOssId = ref(null);
const coverFileName = ref("");
const coverUploadRequestController = createRequestController();
const genealogyCreateRequestController = createRequestController();
const genealogyCreateGuard = createNonIdempotentWriteGuard();
const createGenealogyRequestId = () =>
typeof globalThis.crypto?.randomUUID === "function"
? `app-genealogy-${globalThis.crypto.randomUUID()}`
: `app-genealogy-${Date.now()}-${Math.random().toString(16).slice(2)}`;
const genealogyCreateRequestId = ref(createGenealogyRequestId());
const selectedRegion = ref(null);
const regionPickerTrail = ref([]);
const regionPickerDialog = ref(null);
@@ -262,6 +299,8 @@ const hasDraft = computed(
Object.entries(form).some(([key, value]) =>
key === "accessPreset"
? value !== GENEALOGY_ACCESS_PRESET.MEMBER_ONLY
: key === "ownerIsFirstAncestor"
? value === true
: String(value).trim(),
) ||
Boolean(selectedRegion.value?.regionCode) ||
@@ -282,12 +321,16 @@ const clearFieldError = (field) => {
const validate = () => {
fieldErrors.surname = form.surname.trim() ? "" : "请填写姓氏";
fieldErrors.genealogyName = form.genealogyName.trim() ? "" : "请填写谱名";
fieldErrors.firstAncestorName = form.firstAncestorName.trim()
? ""
: "请填写始迁祖姓名";
fieldErrors.regionCode = selectedRegion.value?.regionCode
? ""
: "请选择所在地区";
return (
!fieldErrors.surname &&
!fieldErrors.genealogyName &&
!fieldErrors.firstAncestorName &&
!fieldErrors.regionCode
);
};
@@ -354,7 +397,6 @@ const submitCreate = async () => {
if (!createdGenealogyId.value && !validate()) return;
let createPayload = null;
let createAttempt = null;
if (!createdGenealogyId.value) {
const access = toApiGenealogyAccess(form.accessPreset);
if (!access) {
@@ -366,18 +408,15 @@ const submitCreate = async () => {
genealogyName: form.genealogyName,
regionCode: selectedRegion.value.regionCode,
ancestralHall: form.ancestralHall,
firstAncestorName: form.firstAncestorName,
ownerIsFirstAncestor: form.ownerIsFirstAncestor,
requestId: genealogyCreateRequestId.value,
originPlace: form.originPlace,
addressDetail: form.addressDetail,
intro: form.intro,
coverOssId: coverOssId.value,
...access,
};
createAttempt = genealogyCreateGuard.begin(createPayload);
if (createAttempt === null) {
submitError.value =
"上次创建结果暂时无法确认,请先返回“我的家谱”检查,避免重复创建。";
return;
}
}
isSubmitting.value = true;
@@ -402,15 +441,6 @@ const submitCreate = async () => {
);
} catch (error) {
if (!pageActive) return;
if (
!createdGenealogyId.value &&
createAttempt &&
genealogyCreateGuard.recordFailure(createAttempt, error)
) {
submitError.value =
"创建结果暂时无法确认,请先返回“我的家谱”检查,避免重复创建。";
return;
}
if (isRequestCancelled(error)) return;
submitError.value = createdGenealogyId.value
? "家谱已经创建,但页面返回失败。请再次点击“返回我的家谱”,不要重复创建。"
@@ -439,7 +469,7 @@ onUnload(() => {
}
.page-content {
z-index: 1;
padding: 24rpx 32rpx 72rpx;
padding: 24rpx 32rpx calc(72rpx + env(safe-area-inset-bottom));
}
.create-card {
@include adaptive-genealogy-state-panel;
@@ -500,6 +530,7 @@ onUnload(() => {
}
.field-row input {
min-width: 0;
min-height: var(--app-touch-min);
flex: 1;
color: $ink;
font-size: clamp(16px, 28rpx, 20px);
@@ -552,6 +583,38 @@ onUnload(() => {
font-size: clamp(16px, 27rpx, 20px);
line-height: 1.55;
}
.owner-ancestor-field {
display: flex;
min-height: 112rpx;
align-items: center;
justify-content: space-between;
gap: 24rpx;
margin-top: 22rpx;
padding: 18rpx 20rpx;
border: 1rpx solid rgba(128, 89, 49, 0.34);
border-radius: 12rpx;
background: rgba(255, 252, 245, 0.7);
}
.owner-ancestor-field > view {
min-width: 0;
flex: 1;
}
.owner-ancestor-field__label,
.owner-ancestor-field__hint {
display: block;
}
.owner-ancestor-field__label {
color: $ink;
font-family: STKaiti, KaiTi, serif;
font-size: clamp(17px, 30rpx, 22px);
font-weight: 700;
}
.owner-ancestor-field__hint {
margin-top: 6rpx;
color: $ink-muted;
font-size: clamp(13px, 21rpx, 16px);
line-height: 1.45;
}
.cover-field {
display: grid;
gap: 12rpx;
+11 -3
View File
@@ -294,7 +294,7 @@ const applyRows = (rows) => {
genealogyName.value = named?.genealogyName || "当前家谱";
};
const loadPoems = async ({ management = false } = {}) => {
if (!genealogyId.value) {
if (!/^[1-9]\d*$/.test(genealogyId.value)) {
poemState.value = "error";
return false;
}
@@ -473,7 +473,7 @@ onUnload(() => {
@include adaptive-genealogy-state-panel;
z-index: 2;
width: calc(100% - 32rpx);
margin: 18rpx auto 0;
margin: 18rpx auto calc(34rpx + env(safe-area-inset-bottom));
padding: 76rpx 8%;
box-sizing: border-box;
}
@@ -526,7 +526,7 @@ onUnload(() => {
.poem-row {
@include adaptive-genealogy-form-field;
display: grid;
min-height: 72rpx;
min-height: var(--app-touch-min);
margin-top: 10rpx;
grid-template-columns: minmax(116rpx, 38%) minmax(0, 1fr) auto;
align-items: center;
@@ -726,4 +726,12 @@ onUnload(() => {
width: calc(100% - 48rpx);
}
}
@media (max-width: 340px) {
.poem-editor__actions {
flex-direction: column;
}
.poem-editor__actions .poem-action {
width: 100%;
}
}
</style>
+12 -3
View File
@@ -135,9 +135,18 @@ const confirmation = createDiscardConfirmation((visible) => {
const confirmDiscard = confirmation.confirm;
const cancelDiscard = confirmation.cancel;
const decodeQueryText = (value) => {
const text = String(value || "");
try {
return decodeURIComponent(text);
} catch {
return text;
}
};
onLoad((query) => {
genealogyId.value = String(query?.genealogyId || "");
genealogyName.value = String(query?.genealogyName || "");
genealogyName.value = decodeQueryText(query?.genealogyName);
});
const backToSearch = () => returnTo("G06");
@@ -204,7 +213,7 @@ onUnload(() => {
z-index: 1;
}
.page-content {
padding: 18rpx 24rpx 72rpx;
padding: 18rpx 24rpx calc(72rpx + env(safe-area-inset-bottom));
}
.state-card,
.join-form {
@@ -277,7 +286,7 @@ onUnload(() => {
line-height: 1.5;
}
.form-field input {
min-height: 76rpx;
min-height: var(--app-touch-min);
padding-top: 0;
padding-bottom: 0;
}
+120 -4
View File
@@ -89,6 +89,13 @@
label="移出家谱"
@click="openConfirmation('remove', member)"
/>
<AppButton
v-if="isOwnerViewer && member.roleType === GENEALOGY_MEMBER_ROLE.ADMIN"
compact
type="secondary"
label="权限设置"
@click="openPermissionEditor(member)"
/>
<AppButton
v-if="member.capabilities.canLeave"
compact
@@ -159,6 +166,18 @@
>
<text v-if="operationError" class="edit-error" role="alert">{{ operationError }}</text>
</AppDialog>
<AppDialog :visible="Boolean(permissionTarget)" eyebrow="管理员权限" :title="`设置${permissionTarget?.memberName || '成员'}的权限`" :confirm-text="permissionSaving ? '正在保存' : '保存权限'" cancel-text="取消" show-cancel :close-on-mask="!permissionSaving" @confirm="savePermissions" @cancel="closePermissionEditor">
<AppLoading v-if="permissionLoading" text="正在读取权限目录" />
<view v-else-if="permissionOptions.length" class="permission-list">
<label v-for="option in permissionOptions" :key="option.code" class="permission-item" :class="{ 'permission-item--disabled': !option.enabled }">
<checkbox :checked="selectedPermissionCodes.includes(option.code)" :disabled="!option.enabled || permissionSaving" @click="togglePermission(option)" />
<view><text>{{ option.label }}</text><text v-if="option.description">{{ option.description }}</text></view>
</label>
</view>
<text v-else-if="!permissionError" class="edit-hint">当前没有可授权的权限项</text>
<textarea v-model.trim="permissionReason" maxlength="200" placeholder="授权原因(可选)" class="permission-reason" />
<text v-if="permissionError" class="edit-error">{{ permissionError }}</text>
</AppDialog>
</view>
</template>
@@ -179,6 +198,7 @@ import {
isRequestCancelled
} from "@/services/api/request-controller.js";
import { genealogyMemberApi } from "@/services/api/genealogy-member-service.js";
import { genealogyCapabilityApi } from "@/services/api/genealogy-capability-service.js";
import { lineageApi } from "@/services/api/lineage-service.js";
import { getRequestErrorMessage } from "@/services/api/request-error-message.js";
import { formatMinuteTimestamp } from "@/utils/display-time.js";
@@ -198,6 +218,17 @@ const hasLeftGenealogy = ref(false);
const personOptions = ref([]);
const personOptionsState = ref("idle");
const memberListController = createRequestController();
const permissionTarget = ref(null);
const selectedPermissionCodes = ref([]);
const permissionReason = ref("");
const permissionSaving = ref(false);
const permissionLoading = ref(false);
const permissionError = ref("");
const permissionOptions = ref([]);
const permissionCatalogController = createRequestController();
const memberPermissionController = createRequestController();
const memberPermissionSaveController = createRequestController();
let permissionLoadSequence = 0;
const personOptionsController = createRequestController();
const memberUpdateController = createRequestController();
const membershipOperationController = createRequestController();
@@ -470,12 +501,86 @@ const returnToOverview = () =>
hasLeftGenealogy.value
? returnTo("G01")
: returnTo("G05", { genealogyId: genealogyId.value });
const openPermissionEditor = async (member) => {
const activeLoad = ++permissionLoadSequence;
permissionCatalogController.abort();
memberPermissionController.abort();
permissionTarget.value = member;
permissionOptions.value = [];
selectedPermissionCodes.value = [];
permissionReason.value = "";
permissionError.value = "";
permissionLoading.value = true;
try {
const catalog = await genealogyCapabilityApi.getPermissionCatalog(
genealogyId.value,
{ requestController: permissionCatalogController },
);
const enabledCodes = catalog.filter((option) => option.enabled).map((option) => option.code);
const permissions = await genealogyCapabilityApi.getMemberPermissions(
genealogyId.value,
member.memberId,
enabledCodes,
{ requestController: memberPermissionController },
);
if (!isPageActive || activeLoad !== permissionLoadSequence) return;
permissionOptions.value = catalog;
selectedPermissionCodes.value = permissions.permissionCodes;
} catch (error) {
if (!isPageActive || activeLoad !== permissionLoadSequence || isRequestCancelled(error)) return;
permissionError.value = getRequestErrorMessage(error, "权限目录暂时无法读取,当前不会保存任何变更。");
} finally {
if (isPageActive && activeLoad === permissionLoadSequence) permissionLoading.value = false;
}
};
const closePermissionEditor = () => {
if (permissionSaving.value) return;
permissionLoadSequence += 1;
permissionCatalogController.abort();
memberPermissionController.abort();
permissionTarget.value = null;
permissionLoading.value = false;
};
const togglePermission = (option) => {
if (!option?.enabled || permissionSaving.value || permissionLoading.value) return;
selectedPermissionCodes.value = selectedPermissionCodes.value.includes(option.code)
? selectedPermissionCodes.value.filter((code) => code !== option.code)
: [...selectedPermissionCodes.value, option.code];
};
const savePermissions = async () => {
if (!permissionTarget.value || permissionSaving.value || permissionLoading.value || permissionError.value) return;
const enabledCodes = permissionOptions.value.filter((option) => option.enabled).map((option) => option.code);
permissionSaving.value = true;
permissionError.value = "";
try {
await genealogyCapabilityApi.saveMemberPermissions(
genealogyId.value,
permissionTarget.value.memberId,
selectedPermissionCodes.value,
permissionReason.value,
enabledCodes,
{ requestController: memberPermissionSaveController },
);
if (!isPageActive) return;
feedbackMessage.value = "管理员权限已保存";
permissionTarget.value = null;
} catch (error) {
if (!isPageActive || isRequestCancelled(error)) return;
permissionError.value = getRequestErrorMessage(error, "权限保存失败,请稍后重试。");
} finally {
if (isPageActive) permissionSaving.value = false;
}
};
const requestBack = () =>
runBackGuard({
transientOpen: Boolean(editTarget.value || confirmTarget.value),
submitting: operationPending.value,
transientOpen: Boolean(editTarget.value || confirmTarget.value || permissionTarget.value),
submitting: operationPending.value || permissionSaving.value,
"close-transient": () =>
editTarget.value ? closeEdit() : closeConfirmation(),
editTarget.value
? closeEdit()
: confirmTarget.value
? closeConfirmation()
: closePermissionEditor(),
"block-submitting": () => true,
});
@@ -500,6 +605,9 @@ onUnload(() => {
personOptionsController.abort();
memberUpdateController.abort();
membershipOperationController.abort();
permissionCatalogController.abort();
memberPermissionController.abort();
memberPermissionSaveController.abort();
});
</script>
@@ -518,7 +626,7 @@ onUnload(() => {
}
.page-content {
padding: 18rpx 24rpx 72rpx;
padding: 18rpx 24rpx calc(72rpx + env(safe-area-inset-bottom));
}
.state-card,
@@ -725,4 +833,12 @@ onUnload(() => {
.edit-error {
color: $brand-red;
}
.permission-list { margin-top: 18rpx; }
.permission-item { display: flex; min-height: 76rpx; align-items: flex-start; padding: 18rpx 0; border-bottom: 1rpx solid rgba($gold, 0.38); color: $ink; font-size: 26rpx; }
.permission-item checkbox { margin-right: 16rpx; }
.permission-item view { display: flex; min-width: 0; flex: 1; flex-direction: column; gap: 6rpx; }
.permission-item view text:last-child:not(:first-child) { color: $ink-muted; font-size: 22rpx; line-height: 1.45; }
.permission-item--disabled { opacity: 0.54; }
.permission-reason { width: 100%; min-height: 100rpx; margin-top: 18rpx; padding: 16rpx; box-sizing: border-box; border: 1rpx solid rgba($gold, 0.38); border-radius: 10rpx; }
</style>
+2 -2
View File
@@ -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; }
+270 -4
View File
@@ -28,7 +28,7 @@
mode="aspectFit"
/>
<text class="state-title">暂时无法读取家谱</text>
<text class="state-copy">网络或服务暂不可用请稍后重新查看</text>
<text class="state-copy">{{ genealogyListError }}</text>
<image
class="error-panel__divider"
src="/static/assets/modules/genealogy/transparent/section-divider.png"
@@ -171,6 +171,64 @@
/>
</view>
<view class="featured-media-section">
<view class="featured-media-heading">
<view>
<text class="featured-media-heading__title">宣传视频</text>
<text class="featured-media-heading__copy">家谱文化与使用介绍</text>
</view>
<button class="featured-media-more" @click="openPlatformVideos">
查看更多
</button>
</view>
<view v-if="featuredVideoState === 'loading'" class="featured-media-state">
<AppLoading text="正在读取宣传视频" />
</view>
<view v-else-if="featuredVideoState === 'error'" class="featured-media-state">
<text>宣传视频暂时无法显示</text>
<button class="featured-media-retry" @click="loadFeaturedVideos">重新加载</button>
</view>
<view v-else-if="featuredVideos.length" class="featured-media-grid">
<view
v-for="video in featuredVideos"
:key="video.id"
class="featured-media-card"
>
<view
v-if="video.coverFile?.accessUrl"
class="featured-media-cover-button"
role="button"
:aria-label="`播放${video.title}`"
hover-class="action-hover"
@click="openFeaturedVideo(video)"
>
<image
class="featured-media-cover"
:src="video.coverFile.accessUrl"
mode="aspectFill"
/>
<view class="featured-media-play" aria-hidden="true"></view>
</view>
<video
v-else
class="featured-media-video"
:src="video.videoFile.accessUrl"
controls
object-fit="cover"
/>
<button class="featured-media-title" @click="openFeaturedVideo(video)">
{{ video.title }}
</button>
</view>
</view>
<view v-else class="featured-media-state featured-media-state--empty">
<text>暂时没有推荐视频</text>
<button class="featured-media-more featured-media-more--empty" @click="openPlatformVideos">
查看全部视频
</button>
</view>
</view>
<view class="create-action" @click="openAddDialog">
<image
class="create-cloud"
@@ -226,6 +284,12 @@
>
<text class="empty-create-action__copy">创建家谱</text>
</view>
<AppButton
block
type="secondary"
label="观看宣传视频"
@click="openPlatformVideos"
/>
<text class="empty-create-note"
>确认没有现有家谱后再创建避免重复建谱</text
>
@@ -279,7 +343,10 @@ import {
createRequestController,
isRequestCancelled
} from "@/services/api/request-controller.js";
import { getRequestErrorMessage } from "@/services/api/request-error-message.js";
import { genealogyApi } from "@/services/api/genealogy-service.js";
import { PLATFORM_VIDEO_PLACEMENT } from "@/services/api/family-media-contract.js";
import { genealogyCapabilityApi } from "@/services/api/genealogy-capability-service.js";
import { notificationApi } from "@/services/api/notification-service.js";
import { genealogyContext } from "@/utils/genealogy/context.js";
import {
@@ -291,6 +358,7 @@ import {
const isLoading = ref(false);
const hasError = ref(false);
const genealogyListError = ref("");
const genealogies = ref([]);
const contextInvalidated = ref(false);
const contextReconcileFailed = ref(false);
@@ -304,14 +372,18 @@ const listScrollCommand = ref(0);
const currentListScrollTop = ref(0);
const unreadCount = ref(0);
const creationQuota = ref(null);
const featuredVideos = ref([]);
const featuredVideoState = ref("loading");
const genealogyListRequestController = createRequestController();
const unreadRequestController = createRequestController();
const quotaRequestController = createRequestController();
const featuredVideoRequestController = createRequestController();
// uni-app abort
// generation
let genealogyLoadGeneration = 0;
let unreadLoadGeneration = 0;
let quotaLoadGeneration = 0;
let featuredVideoLoadGeneration = 0;
let pageActive = true;
let skipNextShowRefresh = true;
@@ -340,6 +412,7 @@ const reconcilePageGenealogyContext = () => {
selectedGenealogyId.value = null;
contextReconcileFailed.value = true;
contextInvalidated.value = false;
genealogyListError.value = "本机家谱选择状态异常,请重新加载。";
hasError.value = true;
return false;
}
@@ -350,6 +423,7 @@ const loadGenealogies = async () => {
genealogyListRequestController.abort();
isLoading.value = true;
hasError.value = false;
genealogyListError.value = "";
try {
const loadedGenealogies = await genealogyApi.getMyGenealogies({
requestController: genealogyListRequestController,
@@ -365,6 +439,10 @@ const loadGenealogies = async () => {
) {
return;
}
genealogyListError.value =
error?.code === "GENEALOGY_RESPONSE_INVALID"
? "服务返回的家谱数据不完整,请联系管理员处理。"
: getRequestErrorMessage(error, "家谱服务暂时无法读取,请稍后重试。");
hasError.value = true;
} finally {
if (pageActive && generation === genealogyLoadGeneration) {
@@ -407,11 +485,37 @@ const loadQuota = async () => {
}
};
const loadFeaturedVideos = async () => {
const generation = ++featuredVideoLoadGeneration;
featuredVideoRequestController.abort();
featuredVideoState.value = "loading";
try {
const rows = await genealogyCapabilityApi.getPlatformVideos(
PLATFORM_VIDEO_PLACEMENT.HOME_FEATURED,
{ requestController: featuredVideoRequestController },
);
if (!pageActive || generation !== featuredVideoLoadGeneration) return;
featuredVideos.value = rows.slice(0, 2);
featuredVideoState.value = "ready";
} catch (error) {
if (
!pageActive ||
generation !== featuredVideoLoadGeneration ||
isRequestCancelled(error)
) {
return;
}
featuredVideos.value = [];
featuredVideoState.value = "error";
}
};
onLoad((query) => {
requestedGenealogyId.value = String(query?.genealogyId || "");
loadGenealogies();
loadUnreadCount();
loadQuota();
loadFeaturedVideos();
});
onShow(() => {
@@ -423,6 +527,7 @@ onShow(() => {
) {
requestedGenealogyId.value = navigationResult.entityId;
loadGenealogies();
loadFeaturedVideos();
return;
}
if (skipNextShowRefresh) {
@@ -432,6 +537,7 @@ onShow(() => {
loadGenealogies();
loadUnreadCount();
loadQuota();
loadFeaturedVideos();
});
onUnload(() => {
@@ -439,9 +545,11 @@ onUnload(() => {
genealogyLoadGeneration += 1;
unreadLoadGeneration += 1;
quotaLoadGeneration += 1;
featuredVideoLoadGeneration += 1;
genealogyListRequestController.abort();
unreadRequestController.abort();
quotaRequestController.abort();
featuredVideoRequestController.abort();
});
const hasGenealogies = computed(() => genealogies.value.length > 0);
@@ -536,6 +644,21 @@ const openSwitcher = () => {
const closeSwitcher = () => {
switcherVisible.value = false;
};
const openPlatformVideos = () =>
openPage(
"F11",
{ placement: PLATFORM_VIDEO_PLACEMENT.VIDEO_CENTER },
"G01",
);
const openFeaturedVideo = (video) =>
openPage(
"F11",
{
placement: PLATFORM_VIDEO_PLACEMENT.HOME_FEATURED,
videoId: String(video.id),
},
"G01",
);
const openOrderDialog = () => {
if (genealogies.value.length < 2) return;
orderDialogVisible.value = true;
@@ -596,7 +719,7 @@ const openShortcut = (shortcutKey) => {
const genealogyId = String(currentGenealogy.value.id);
const actions = {
tree: () => openPage("T01", { genealogyId }, "G01"),
members: () => openPage("G05", { genealogyId }, "G01"),
members: () => openPage("G13", { genealogyId }, "G01"),
poem: () => openPage("G12", { genealogyId }, "G01"),
applications: () => openPage("G10", { genealogyId }, "G01"),
};
@@ -617,7 +740,7 @@ const openShortcut = (shortcutKey) => {
.genealogy-content {
z-index: 1;
padding: 24rpx 32rpx 176rpx;
padding: 24rpx 32rpx calc(176rpx + env(safe-area-inset-bottom));
}
.genealogy-index--split {
@@ -855,6 +978,149 @@ const openShortcut = (shortcutKey) => {
flex: 0 0 auto;
}
.featured-media-section {
margin: 0 0 24rpx;
padding: 26rpx 24rpx;
border: 1rpx solid rgba(149, 103, 49, 0.24);
border-radius: 14rpx;
background: rgba(255, 250, 240, 0.72);
}
.featured-media-heading {
display: flex;
align-items: center;
justify-content: space-between;
gap: 20rpx;
}
.featured-media-heading__title,
.featured-media-heading__copy {
display: block;
}
.featured-media-heading__title {
color: #5c4330;
font-size: clamp(16px, 28rpx, 20px);
font-weight: 700;
}
.featured-media-heading__copy {
margin-top: 8rpx;
color: #8a7564;
font-size: clamp(14px, 23rpx, 17px);
}
.featured-media-more,
.featured-media-retry,
.featured-media-title {
min-height: var(--app-touch-min);
margin: 0;
padding: 0;
border: 0;
background: transparent;
line-height: 1.4;
}
.featured-media-more::after,
.featured-media-retry::after,
.featured-media-title::after {
border: 0;
}
.featured-media-more,
.featured-media-retry {
flex: 0 0 auto;
color: $brand-red;
font-size: clamp(13px, 22rpx, 16px);
}
.featured-media-state {
display: flex;
min-height: 176rpx;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 10rpx;
color: $ink-muted;
font-size: clamp(14px, 23rpx, 17px);
text-align: center;
}
.featured-media-state--empty {
min-height: 138rpx;
}
.featured-media-more--empty {
margin-top: 4rpx;
}
.featured-media-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 16rpx;
margin-top: 22rpx;
}
.featured-media-card {
min-width: 0;
}
.featured-media-cover-button,
.featured-media-video {
width: 100%;
height: 176rpx;
border-radius: 10rpx;
background: #1f1b17;
overflow: hidden;
}
.featured-media-cover-button {
position: relative;
}
.featured-media-cover {
width: 100%;
height: 100%;
}
.featured-media-play {
position: absolute;
top: 50%;
left: 50%;
display: flex;
width: 58rpx;
height: 58rpx;
align-items: center;
justify-content: center;
border: 2rpx solid rgba(255, 255, 255, 0.9);
border-radius: 50%;
background: rgba(45, 29, 19, 0.62);
transform: translate(-50%, -50%);
}
.featured-media-play::after {
width: 0;
height: 0;
margin-left: 5rpx;
border-top: 10rpx solid transparent;
border-bottom: 10rpx solid transparent;
border-left: 16rpx solid #fff;
content: "";
}
.featured-media-title {
display: block;
width: 100%;
margin-top: 8rpx;
color: $ink;
font-size: clamp(14px, 24rpx, 17px);
font-weight: 600;
text-align: left;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.create-action {
display: flex;
min-height: 96rpx;
@@ -1076,7 +1342,7 @@ const openShortcut = (shortcutKey) => {
display: flex;
align-items: center;
justify-content: center;
min-height: 76rpx;
min-height: var(--app-touch-min);
margin-top: 14rpx;
color: $brand-red;
}
+28 -1
View File
@@ -160,6 +160,30 @@
mode="aspectFit"
/>
</view>
<view class="overview-action" @click="toPersonDocuments">
<image
class="overview-action__icon"
src="/static/assets/modules/genealogy/transparent/shortcut-members.png"
mode="aspectFit"
/>
<view class="overview-action__copy">
<text class="overview-action__title">重要证件</text>
<text>集中查看家谱证件档案</text>
</view>
<image
class="overview-action__chevron"
src="/static/assets/foundation/transparent/chevron-right.png"
mode="aspectFit"
/>
</view>
<view v-if="canManageGenealogy" class="overview-action" @click="toCapabilityCenter">
<image class="overview-action__icon" src="/static/assets/modules/genealogy/transparent/settings-gear.png" mode="aspectFit" />
<view class="overview-action__copy">
<text class="overview-action__title">资料与回收站</text>
<text>查看资料完整度恢复误删的家谱内容</text>
</view>
<image class="overview-action__chevron" src="/static/assets/foundation/transparent/chevron-right.png" mode="aspectFit" />
</view>
<template v-else>
<view class="overview-summary">
<text class="overview-action__title">成员身份</text>
@@ -308,7 +332,7 @@ const loadGenealogy = async (query = {}) => {
genealogyId.value = String(query.genealogyId || genealogyId.value || "");
canManageGenealogy.value = false;
if (!genealogyId.value) {
if (!/^[1-9]\d*$/.test(genealogyId.value)) {
overviewState.value = "empty";
return;
}
@@ -363,10 +387,13 @@ const toGenerationPoems = () =>
openPage("G12", { genealogyId: genealogyId.value }, "G05");
const toMembers = () =>
openPage("G13", { genealogyId: genealogyId.value }, "G05");
const toPersonDocuments = () =>
openPage("R12", { genealogyId: genealogyId.value }, "G05");
const openInvitationManager = () => {
if (overviewState.value !== "ready" || !genealogyId.value) return;
invitationManager.value?.open();
};
const toCapabilityCenter = () => openPage("G14", { genealogyId: genealogyId.value }, "G05");
const requestBack = () => {
if (invitationBusy.value || invitationTransientOpen.value) {
invitationManager.value?.closeTransient();
+152 -15
View File
@@ -2,7 +2,7 @@
<view class="search-page">
<GenealogyPageBackground />
<view class="page-header"
><PageHeader title="搜索家谱" custom-back @back="backToGenealogies"
><PageHeader title="搜索家谱" custom-back @back="requestBack"
/></view>
<view class="page-content">
<view class="invite-card">
@@ -45,8 +45,41 @@
</view>
<view class="search-note"
><text>公开家谱</text
><text>以下是可加入的公开家谱</text></view
><text>输入谱名或姓氏查找可以申请加入的公开家谱</text></view
>
<view class="public-search-card">
<text class="public-search-card__label">查找公开家谱</text>
<view class="public-search-card__control">
<input
v-model="searchKeyword"
maxlength="50"
confirm-type="search"
placeholder="请输入谱名或姓氏"
aria-label="公开家谱搜索关键词"
@confirm="searchGenealogies"
/>
<button
v-if="searchKeyword"
class="public-search-card__clear"
aria-label="清除家谱搜索关键词"
@click="clearSearchKeyword"
>
清除
</button>
</view>
<text
v-if="genealogySearchState === 'ready'"
class="public-search-card__result"
role="status"
>{{ searchResultCopy }}</text>
<AppButton
block
type="secondary"
:disabled="genealogySearchState === 'loading'"
:label="genealogySearchState === 'loading' ? '正在搜索' : '搜索公开家谱'"
@click="searchGenealogies"
/>
</view>
<view v-if="genealogySearchState === 'loading'" class="state-card"
><AppLoading
text="正在读取公开家谱"
@@ -64,8 +97,13 @@
<view v-else-if="genealogySearchState === 'empty'" class="state-card"
><text>暂未找到公开家谱</text></view
>
<view v-else-if="!filteredRows.length" class="state-card">
<text>没有找到匹配的公开家谱</text>
<text class="state-card__copy">可尝试缩短关键词或改用姓氏查找</text>
<AppButton block type="secondary" label="清除关键词" @click="clearSearchKeyword" />
</view>
<view v-else class="result-list">
<view v-for="item in rows" :key="item.id" class="genealogy-card">
<view v-for="item in filteredRows" :key="item.id" class="genealogy-card">
<view class="card-heading"
><text>{{ item.name }}</text
><text v-if="item.surname">{{ item.surname }}</text></view
@@ -77,8 +115,8 @@
<view class="card-footer"
><text>{{ item.memberCount }} 位成员</text
><AppButton
:label="item.canManage ? '已在我的家谱' : '申请加入'"
:disabled="item.canManage"
:label="item.hasMembership ? '已在我的家谱' : '申请加入'"
:disabled="item.hasMembership"
@click="applyToJoin(item)"
/></view>
</view>
@@ -101,7 +139,7 @@
<script setup>
import { computed, ref } from "vue";
import { onLoad, onShow, onUnload } from "@dcloudio/uni-app";
import { onBackPress, onLoad, onShow, onUnload } from "@dcloudio/uni-app";
import AppButton from "@/components/AppButton.vue";
import AppDialog from "@/components/AppDialog.vue";
import AppLoading from "@/components/AppLoading.vue";
@@ -115,10 +153,12 @@ import { genealogyMembershipApi } from "@/services/api/genealogy-membership-serv
import { genealogyApi } from "@/services/api/genealogy-service.js";
import { getRequestErrorMessage } from "@/services/api/request-error-message.js";
import { createNonIdempotentWriteGuard } from "@/utils/non-idempotent-write-guard.js";
import { openPage, returnTo } from "@/utils/navigation/gateway.js";
import { handleBackPress, openPage, returnTo } from "@/utils/navigation/gateway.js";
const rows = ref([]);
const genealogySearchState = ref("loading");
const searchKeyword = ref("");
const loadedSearchKeyword = ref("");
const inviteToken = ref("");
const inviteState = ref("idle");
const inviteError = ref("");
@@ -130,15 +170,40 @@ const invitationPreviewController = createRequestController();
const invitationRedemptionController = createRequestController();
const invitationRedemptionGuard = createNonIdempotentWriteGuard();
let isPageActive = true;
const loadGenealogies = async () => {
const normalizedSearchKeyword = computed(() =>
searchKeyword.value.trim().toLocaleLowerCase(),
);
const filteredRows = computed(() => {
if (!normalizedSearchKeyword.value) return rows.value;
return rows.value.filter((genealogy) =>
[genealogy.name, genealogy.surname].some((fieldValue) =>
String(fieldValue || "")
.toLocaleLowerCase()
.includes(normalizedSearchKeyword.value),
),
);
});
const searchResultCopy = computed(() =>
normalizedSearchKeyword.value
? `找到 ${filteredRows.value.length} 部匹配家谱`
: `${rows.value.length} 部公开家谱`,
);
const clearSearchKeyword = () => {
searchKeyword.value = "";
if (loadedSearchKeyword.value) void loadGenealogies("");
};
const loadGenealogies = async (keyword = searchKeyword.value) => {
publicGenealogyListController.abort();
genealogySearchState.value = "loading";
try {
const publicGenealogies = await genealogyApi.getPublicGenealogies({
requestController: publicGenealogyListController,
});
const normalizedKeyword = String(keyword || "").trim();
const publicGenealogies = await genealogyApi.getPublicGenealogies(
{ keyword: normalizedKeyword },
{ requestController: publicGenealogyListController },
);
if (!isPageActive) return;
rows.value = publicGenealogies;
loadedSearchKeyword.value = normalizedKeyword;
genealogySearchState.value = rows.value.length ? "ready" : "empty";
} catch (error) {
if (!isPageActive || isRequestCancelled(error)) return;
@@ -196,6 +261,7 @@ const previewInvite = async () => {
inviteError.value = getRequestErrorMessage(error, "邀请码暂时无法查看,请检查后重试。");
}
};
const searchGenealogies = () => loadGenealogies(searchKeyword.value);
const openRedeemConfirmation = () => {
if (inviteState.value === "ready" && invitePreview.value)
redeemConfirmationVisible.value = true;
@@ -203,6 +269,14 @@ const openRedeemConfirmation = () => {
const closeRedeemConfirmation = () => {
if (inviteState.value !== "redeeming") redeemConfirmationVisible.value = false;
};
const requestBack = () => {
if (inviteState.value === "redeeming") return true;
if (redeemConfirmationVisible.value) {
closeRedeemConfirmation();
return true;
}
return backToGenealogies();
};
const redeemInvite = async () => {
if (inviteState.value !== "ready" || !invitePreview.value) return;
const redemptionPayload = { token: inviteToken.value };
@@ -242,9 +316,9 @@ const handleInviteResult = () =>
inviteResult.value?.redemptionResult === "DIRECT_MEMBER"
? returnTo("G01")
: openPage("G09", {}, "G06");
onLoad(loadGenealogies);
onLoad(() => loadGenealogies(""));
onShow(() => {
if (genealogySearchState.value !== "loading") loadGenealogies();
if (genealogySearchState.value !== "loading") loadGenealogies(searchKeyword.value);
});
onUnload(() => {
isPageActive = false;
@@ -252,6 +326,7 @@ onUnload(() => {
invitationPreviewController.abort();
invitationRedemptionController.abort();
});
onBackPress((event) => handleBackPress(event, requestBack));
</script>
<style scoped lang="scss">
@@ -267,9 +342,10 @@ onUnload(() => {
z-index: 1;
}
.page-content {
padding: 18rpx 24rpx 72rpx;
padding: 18rpx 24rpx calc(72rpx + env(safe-area-inset-bottom));
}
.search-note,
.public-search-card,
.state-card,
.genealogy-card,
.invite-card {
@@ -303,7 +379,7 @@ onUnload(() => {
@include adaptive-genealogy-form-field;
display: block;
width: 100%;
min-height: 78rpx;
min-height: var(--app-touch-min);
margin-top: 18rpx;
padding: 0 22rpx;
box-sizing: border-box;
@@ -349,6 +425,58 @@ onUnload(() => {
color: $ink-muted;
font-size: clamp(13px, 21rpx, 16px);
}
.public-search-card {
margin-top: 18rpx;
padding: 24rpx 30rpx;
}
.public-search-card__label,
.public-search-card__result {
display: block;
}
.public-search-card__label {
color: $ink;
font-size: clamp(15px, 24rpx, 18px);
font-weight: 700;
}
.public-search-card__control {
@include adaptive-genealogy-form-field;
display: flex;
min-height: var(--app-touch-min);
align-items: center;
margin-top: 12rpx;
padding: 0 10rpx 0 20rpx;
box-sizing: border-box;
}
.public-search-card__control input {
min-width: 0;
min-height: var(--app-touch-min);
flex: 1;
color: $ink;
font-size: clamp(15px, 24rpx, 18px);
}
.public-search-card__clear {
min-width: 96rpx;
min-height: calc(var(--app-touch-min) - 16rpx);
margin: 0;
padding: 0 14rpx;
border: 0;
border-radius: 8rpx;
background: transparent;
color: $brand-red;
font-size: clamp(13px, 21rpx, 16px);
line-height: calc(var(--app-touch-min) - 16rpx);
}
.public-search-card__clear::after {
border: 0;
}
.public-search-card__result {
margin-top: 10rpx;
color: $ink-muted;
font-size: clamp(13px, 21rpx, 16px);
}
.public-search-card > .app-button {
margin-top: 16rpx;
}
.state-card {
display: flex;
min-height: 310rpx;
@@ -361,6 +489,13 @@ onUnload(() => {
color: $ink;
font-size: clamp(16px, 28rpx, 20px);
}
.state-card__copy {
display: block;
margin-top: 10rpx;
color: $ink-muted;
font-size: clamp(14px, 22rpx, 17px);
line-height: 1.55;
}
.state-card .app-button {
width: 100%;
margin-top: 22rpx;
@@ -381,6 +516,8 @@ onUnload(() => {
gap: 18rpx;
}
.card-heading text:first-child {
min-width: 0;
flex: 1;
color: $ink;
font-family: STKaiti, KaiTi, serif;
font-size: clamp(17px, 31rpx, 22px);
+283 -5
View File
@@ -2,7 +2,7 @@
<view class="settings-page" :class="`settings-state--${pageState}`">
<GenealogyPageBackground />
<view class="page-header"
><PageHeader title="家谱设置" custom-back @back="returnToOverview"
><PageHeader title="家谱设置" custom-back @back="requestBack"
/></view>
<view class="page-content">
@@ -142,6 +142,7 @@
<text v-if="coverFileName" class="upload-receipt"
>已上传{{ coverFileName }}</text
>
<button v-if="coverOssId" class="remove-cover-button" :disabled="isUploading || isSubmitting" @click="clearCover">移除封面</button>
</view>
<text v-if="uploadError" class="field-error">{{ uploadError }}</text>
@@ -186,6 +187,24 @@
@click="requestLifecycleChange"
/>
</view>
<view
v-if="canDeletePermanently || permanentDeletionDisabledReason"
class="permanent-deletion"
>
<text>永久删除家谱</text>
<text>{{
canDeletePermanently
? "该操作会永久删除家谱及其业务内容,无法从回收站恢复。"
: permanentDeletionDisabledReason
}}</text>
<AppButton
block
type="secondary"
:disabled="!canDeletePermanently || permanentDeletionSubmitting"
label="永久删除家谱"
@click="openPermanentDeletion"
/>
</view>
</view>
<view v-else class="state-card">
@@ -206,6 +225,54 @@
@confirm="confirmLifecycleChange"
@cancel="lifecycleDialogVisible = false"
/>
<AppDialog
:visible="discardVisible"
eyebrow="未保存修改"
title="放弃家谱设置修改?"
message="当前修改还没有保存。"
confirm-text="确认放弃"
cancel-text="继续编辑"
show-cancel
:close-on-mask="false"
@confirm="confirmDiscard"
@cancel="cancelDiscard"
/>
<AppDialog
:visible="permanentDeletionVisible"
eyebrow="高风险操作"
title="永久删除这部家谱?"
:message="`请输入完整谱名“${form.genealogyName}”,并通过账号短信验证。删除后无法恢复。`"
:confirm-text="permanentDeletionSubmitting ? '正在删除' : '确认永久删除'"
cancel-text="取消"
show-cancel
:close-on-mask="false"
@confirm="confirmPermanentDeletion"
@cancel="closePermanentDeletion"
>
<input
v-model="permanentDeletionConfirmationName"
class="deletion-dialog-input"
maxlength="24"
:placeholder="`输入谱名:${form.genealogyName}`"
/>
<view class="deletion-code-row">
<input
v-model.trim="permanentDeletionSmsCode"
class="deletion-dialog-input"
type="number"
maxlength="4"
:placeholder="`验证码将发送至 ${deletionVerificationPhoneMasked}`"
/>
<AppButton
compact
type="secondary"
:disabled="permanentDeletionSubmitting || deletionCodeSending || deletionCodeCooldown > 0"
:label="deletionCodeButtonLabel"
@click="sendPermanentDeletionCode"
/>
</view>
<text v-if="permanentDeletionError" class="submit-error">{{ permanentDeletionError }}</text>
</AppDialog>
<RegionPickerDialog
ref="regionPickerDialog"
@@ -213,13 +280,14 @@
@select="selectRegion"
@loading-change="regionLoading = $event"
@error-change="regionPickerError = $event"
@transient-change="regionPickerVisible = $event"
/>
</view>
</template>
<script setup>
import { computed, onMounted, reactive, ref } from "vue";
import { onLoad, onUnload } from "@dcloudio/uni-app";
import { onBackPress, onLoad, onUnload } from "@dcloudio/uni-app";
import AppButton from "@/components/AppButton.vue";
import AppDialog from "@/components/AppDialog.vue";
import AppLoading from "@/components/AppLoading.vue";
@@ -245,7 +313,10 @@ import {
isImagePickCancelled,
pickAndUploadImage,
} from "@/utils/media-upload.js";
import { goBack, returnTo } from "@/utils/navigation/gateway.js";
import { createDiscardConfirmation } from "@/utils/discard-confirmation.js";
import { genealogyContext } from "@/utils/genealogy/context.js";
import { createNonIdempotentWriteGuard } from "@/utils/non-idempotent-write-guard.js";
import { goBack, handleBackPress, returnTo } from "@/utils/navigation/gateway.js";
const genealogyId = ref("");
const pageState = ref("loading");
@@ -272,8 +343,18 @@ const coverFileName = ref("");
const lifecycleStatus = ref(GENEALOGY_LIFECYCLE_STATUS.NORMAL);
const canArchive = ref(false);
const canRestore = ref(false);
const canDeletePermanently = ref(false);
const permanentDeletionDisabledReason = ref("");
const deletionVerificationPhoneMasked = ref("");
const lifecycleDialogVisible = ref(false);
const lifecycleSubmitting = ref(false);
const permanentDeletionVisible = ref(false);
const permanentDeletionConfirmationName = ref("");
const permanentDeletionSmsCode = ref("");
const permanentDeletionError = ref("");
const permanentDeletionSubmitting = ref(false);
const deletionCodeSending = ref(false);
const deletionCodeCooldown = ref(0);
const currentRegionCode = ref("");
const currentRegionDisplay = ref("");
const selectedRegion = ref(null);
@@ -281,13 +362,28 @@ const regionPickerTrail = ref([]);
const regionPickerDialog = ref(null);
const regionPickerError = ref("");
const regionLoading = ref(false);
const regionPickerVisible = ref(false);
const settingsBaseline = ref("");
const discardVisible = ref(false);
const settingsReadRequestController = createRequestController();
const deletionCapabilityReadRequestController = createRequestController();
const genealogyLifecycleRequestController = createRequestController();
const deletionCodeRequestController = createRequestController();
const permanentDeletionRequestController = createRequestController();
const coverUploadRequestController = createRequestController();
const settingsSaveRequestController = createRequestController();
//
//
let pageActive = true;
let deletionCodeTimer = null;
const permanentDeletionGuard = createNonIdempotentWriteGuard();
const discardConfirmation = createDiscardConfirmation((visible) => {
discardVisible.value = visible;
});
const requestDiscardConfirmation = discardConfirmation.request;
const confirmDiscard = discardConfirmation.confirm;
const cancelDiscard = discardConfirmation.cancel;
const hasValidContext = computed(() => /^[1-9]\d*$/.test(genealogyId.value));
const regionDisplay = computed(() =>
@@ -295,6 +391,23 @@ const regionDisplay = computed(() =>
? regionPickerTrail.value.map((regionNode) => regionNode.label).join(" / ")
: currentRegionDisplay.value,
);
const settingsSnapshot = computed(() => JSON.stringify({
...form,
regionCode: selectedRegion.value?.regionCode || currentRegionCode.value,
coverOssId: coverOssId.value || null,
}));
const isDirty = computed(() =>
pageState.value === "form" &&
Boolean(settingsBaseline.value) &&
settingsSnapshot.value !== settingsBaseline.value,
);
const deletionCodeButtonLabel = computed(() =>
deletionCodeSending.value
? "正在发送"
: deletionCodeCooldown.value > 0
? `${deletionCodeCooldown.value}s 后重发`
: "发送验证码",
);
const stateCopy = computed(() => {
if (pageState.value === "success") {
return {
@@ -379,6 +492,22 @@ const loadSettings = async () => {
canArchive.value = settings.canArchive;
canRestore.value = settings.canRestore;
pageState.value = "form";
settingsBaseline.value = settingsSnapshot.value;
try {
const deletionCapability = await genealogyApi.getPermanentDeletionCapability(
genealogyId.value,
{ requestController: deletionCapabilityReadRequestController },
);
if (!pageActive) return;
canDeletePermanently.value = deletionCapability.canDeletePermanently;
permanentDeletionDisabledReason.value = deletionCapability.disabledReasons.join("");
deletionVerificationPhoneMasked.value = deletionCapability.phoneMasked;
} catch (error) {
if (!pageActive || isRequestCancelled(error)) return;
canDeletePermanently.value = false;
permanentDeletionDisabledReason.value = "永久注销资格暂时无法读取,请稍后重试。";
deletionVerificationPhoneMasked.value = "";
}
} catch (error) {
if (!pageActive || isRequestCancelled(error)) return;
pageState.value = "error";
@@ -404,6 +533,13 @@ const confirmLifecycleChange = async () => {
lifecycleStatus.value = lifecycleResult.lifecycleStatus;
canArchive.value = lifecycleResult.canArchive;
canRestore.value = lifecycleResult.canRestore;
const deletionCapability = await genealogyApi.getPermanentDeletionCapability(
genealogyId.value,
{ requestController: genealogyLifecycleRequestController },
);
canDeletePermanently.value = deletionCapability.canDeletePermanently;
permanentDeletionDisabledReason.value = deletionCapability.disabledReasons.join("");
deletionVerificationPhoneMasked.value = deletionCapability.phoneMasked;
lifecycleDialogVisible.value = false;
} catch (error) {
if (!pageActive || isRequestCancelled(error)) return;
@@ -414,6 +550,92 @@ const confirmLifecycleChange = async () => {
}
};
const stopDeletionCodeCooldown = () => {
if (deletionCodeTimer) clearInterval(deletionCodeTimer);
deletionCodeTimer = null;
};
const startDeletionCodeCooldown = (seconds) => {
stopDeletionCodeCooldown();
deletionCodeCooldown.value = seconds;
deletionCodeTimer = setInterval(() => {
deletionCodeCooldown.value = Math.max(0, deletionCodeCooldown.value - 1);
if (deletionCodeCooldown.value === 0) stopDeletionCodeCooldown();
}, 1000);
};
const openPermanentDeletion = () => {
if (!canDeletePermanently.value || permanentDeletionSubmitting.value) return;
permanentDeletionConfirmationName.value = "";
permanentDeletionSmsCode.value = "";
permanentDeletionError.value = "";
permanentDeletionVisible.value = true;
};
const closePermanentDeletion = () => {
if (permanentDeletionSubmitting.value || deletionCodeSending.value) return;
permanentDeletionVisible.value = false;
permanentDeletionError.value = "";
};
const sendPermanentDeletionCode = async () => {
if (!canDeletePermanently.value || deletionCodeSending.value || permanentDeletionSubmitting.value || deletionCodeCooldown.value > 0) return;
deletionCodeSending.value = true;
permanentDeletionError.value = "";
try {
await genealogyApi.sendPermanentDeletionCode(
genealogyId.value,
{ requestController: deletionCodeRequestController },
);
if (!pageActive || !permanentDeletionVisible.value) return;
startDeletionCodeCooldown(60);
} catch (error) {
if (pageActive && permanentDeletionVisible.value && !isRequestCancelled(error)) {
permanentDeletionError.value = getRequestErrorMessage(error, "删除验证码发送失败,请稍后重试。");
}
} finally {
if (pageActive) deletionCodeSending.value = false;
}
};
const confirmPermanentDeletion = async () => {
if (!canDeletePermanently.value || permanentDeletionSubmitting.value) return;
if (permanentDeletionConfirmationName.value.trim() !== form.genealogyName.trim()) {
permanentDeletionError.value = "输入的谱名与当前家谱不一致。";
return;
}
if (!/^\d{4}$/.test(permanentDeletionSmsCode.value)) {
permanentDeletionError.value = "请输入4位短信验证码。";
return;
}
const payload = {
confirmationName: permanentDeletionConfirmationName.value,
smsCode: permanentDeletionSmsCode.value,
};
const deletionAttempt = permanentDeletionGuard.begin(payload);
if (deletionAttempt === null) {
permanentDeletionError.value = "上次删除结果待确认,请先返回家谱列表刷新,不要重复提交。";
return;
}
permanentDeletionSubmitting.value = true;
permanentDeletionError.value = "";
try {
await genealogyApi.deleteGenealogyPermanently(
genealogyId.value,
payload,
{ requestController: permanentDeletionRequestController },
);
if (!pageActive) return;
genealogyContext.invalidateCurrentGenealogyId();
permanentDeletionVisible.value = false;
await returnTo("G01");
} catch (error) {
const isOutcomeUnknown = permanentDeletionGuard.recordFailure(deletionAttempt, error);
if (!pageActive) return;
permanentDeletionError.value = isOutcomeUnknown
? "删除结果待确认,请返回家谱列表刷新,不要重复提交。"
: getRequestErrorMessage(error, "永久删除失败,请核对验证码后重试。");
if (!isOutcomeUnknown && isRequestCancelled(error)) permanentDeletionError.value = "";
} finally {
if (pageActive) permanentDeletionSubmitting.value = false;
}
};
const uploadCover = async () => {
if (isUploading.value || isSubmitting.value) return;
isUploading.value = true;
@@ -438,6 +660,13 @@ const uploadCover = async () => {
}
};
const clearCover = () => {
if (isUploading.value || isSubmitting.value) return;
coverOssId.value = null;
coverFileName.value = "";
uploadError.value = "";
};
const submitUpdate = async () => {
if (isSubmitting.value || isUploading.value || !validate()) return;
const access = toApiGenealogyAccess(form.accessPreset);
@@ -458,7 +687,7 @@ const submitUpdate = async () => {
originPlace: form.originPlace,
addressDetail: form.addressDetail,
intro: form.intro,
...(coverOssId.value ? { coverOssId: coverOssId.value } : {}),
coverOssId: coverOssId.value,
...access,
},
{ requestController: settingsSaveRequestController },
@@ -477,6 +706,27 @@ const returnToOverview = () =>
hasValidContext.value
? returnTo("G05", { genealogyId: genealogyId.value })
: goBack();
const requestBack = async () => {
if (regionPickerVisible.value) {
regionPickerDialog.value?.close();
return true;
}
if (lifecycleDialogVisible.value) {
lifecycleDialogVisible.value = false;
return true;
}
if (permanentDeletionVisible.value) {
closePermanentDeletion();
return true;
}
if (discardVisible.value) {
cancelDiscard();
return true;
}
if (isSubmitting.value || isUploading.value || lifecycleSubmitting.value || permanentDeletionSubmitting.value || deletionCodeSending.value) return true;
if (isDirty.value && !(await requestDiscardConfirmation())) return false;
return returnToOverview();
};
const handleStateAction = () =>
pageState.value === "success" ? returnToOverview() : loadSettings();
@@ -486,12 +736,18 @@ onLoad((query) => {
onMounted(() => {
void loadSettings();
});
onBackPress((event) => handleBackPress(event, requestBack));
onUnload(() => {
pageActive = false;
settingsReadRequestController.abort();
deletionCapabilityReadRequestController.abort();
genealogyLifecycleRequestController.abort();
deletionCodeRequestController.abort();
permanentDeletionRequestController.abort();
coverUploadRequestController.abort();
settingsSaveRequestController.abort();
stopDeletionCodeCooldown();
discardConfirmation.dispose();
});
</script>
@@ -510,7 +766,7 @@ onUnload(() => {
}
.page-content {
flex: 1;
padding: 24rpx 32rpx 72rpx;
padding: 24rpx 32rpx calc(72rpx + env(safe-area-inset-bottom));
}
.settings-card,
.state-card {
@@ -567,6 +823,7 @@ onUnload(() => {
}
.field-row input {
min-width: 0;
min-height: var(--app-touch-min);
flex: 1;
color: $ink;
font-size: clamp(16px, 28rpx, 20px);
@@ -583,6 +840,15 @@ onUnload(() => {
text-align: right;
overflow-wrap: anywhere;
}
.permanent-deletion { margin-top: 30rpx; padding: 24rpx; border: 1rpx solid rgba(158, 37, 27, .34); border-radius: 10rpx; background: rgba(158, 37, 27, .05); }
.permanent-deletion > text { display: block; color: $ink-muted; font-size: clamp(13px, 22rpx, 16px); line-height: 1.55; }
.permanent-deletion > text:first-child { color: $brand-red; font-size: clamp(16px, 27rpx, 20px); font-weight: 700; }
.permanent-deletion > text + text { margin-top: 8rpx; }
.permanent-deletion .app-button { margin-top: 18rpx; }
.deletion-dialog-input { width: 100%; min-height: var(--app-touch-min); margin-top: 14rpx; padding: 0 18rpx; box-sizing: border-box; border: 1rpx solid rgba(158, 37, 27, .3); border-radius: 8rpx; background: rgba(255, 255, 255, .7); }
.deletion-code-row { display: flex; align-items: center; gap: 12rpx; }
.deletion-code-row .deletion-dialog-input { min-width: 0; flex: 1; }
.deletion-code-row .app-button { width: auto; flex: 0 0 auto; margin-top: 14rpx; }
.lifecycle-note,
.lifecycle-actions {
margin-top: 20rpx;
@@ -658,6 +924,18 @@ onUnload(() => {
font-size: clamp(13px, 21rpx, 16px);
overflow-wrap: anywhere;
}
.remove-cover-button {
justify-self: start;
min-height: 72rpx;
margin: 0;
padding: 0 18rpx;
border: 1rpx solid rgba($brand-red, 0.38);
border-radius: 8rpx;
background: transparent;
color: $brand-red;
font-size: clamp(13px, 21rpx, 16px);
}
.remove-cover-button::after { border: 0; }
.field-error,
.submit-error {
margin-top: 10rpx;
+29 -12
View File
@@ -1,20 +1,20 @@
<template>
<view class="message-page">
<ModulePageBackground module="notification" />
<view class="page-header"><PageHeader root title="消息中心" :action="unreadCount > 0 ? '设为已读' : ''" @action="requestMarkAllRead" /></view>
<view class="page-header"><PageHeader :root="!sourceKey" :custom-back="Boolean(sourceKey)" title="消息中心" :action="unreadCount > 0 ? '设为已读' : ''" @back="returnToSource" @action="requestMarkAllRead" /></view>
<view class="page-content">
<view v-if="notificationListState === 'loading'" class="state-card"><AppLoading text="正在读取消息通知" /></view>
<view v-else-if="notificationListState === 'error'" class="state-card">
<text>暂时无法读取消息状态</text>
<text>{{ notificationListError || "请检查网络后重新加载。" }}</text>
<AppButton block type="secondary" label="重新加载" @click="loadNotifications" />
<AppButton block label="返回我的" @click="returnToProfile" />
<AppButton block :label="returnLabel" @click="returnToSource" />
</view>
<view v-else-if="!notifications.length" class="state-card message-status-card">
<text>当前没有通知</text>
<text>新的家谱动态审核和活动消息会在这里展示</text>
<text v-if="markAllReadError" class="message-operation-error">{{ markAllReadError }}</text>
<AppButton block type="secondary" label="返回我的" @click="returnToProfile" />
<AppButton block type="secondary" :label="returnLabel" @click="returnToSource" />
</view>
<view v-else class="message-list">
<view
@@ -39,7 +39,7 @@
</view>
</view>
<AppPromotionStrip placement="message_bottom" title="消息页推荐" />
<AppTabbar active="profile" />
<AppTabbar :active="sourceTab" />
<AppDialog
:visible="markAllVisible"
eyebrow="消息状态"
@@ -56,8 +56,8 @@
</template>
<script setup>
import { ref } from "vue";
import { onLoad, onShow, onUnload } from "@dcloudio/uni-app";
import { computed, ref } from "vue";
import { onBackPress, onLoad, onShow, onUnload } from "@dcloudio/uni-app";
import AppButton from "@/components/AppButton.vue";
import AppDialog from "@/components/AppDialog.vue";
import AppLoading from "@/components/AppLoading.vue";
@@ -71,7 +71,7 @@ import {
} from "@/services/api/request-controller.js";
import { notificationApi } from "@/services/api/notification-service.js";
import { getRequestErrorMessage } from "@/services/api/request-error-message.js";
import { goRoot, openPage } from "@/utils/navigation/gateway.js";
import { goBack, goRoot, handleBackPress, openPage } from "@/utils/navigation/gateway.js";
const unreadCount = ref(0);
const notifications = ref([]);
@@ -82,8 +82,12 @@ const markAllReadController = createRequestController();
const markAllVisible = ref(false);
const markAllSubmitting = ref(false);
const markAllReadError = ref("");
const sourceKey = ref("");
let isPageActive = true;
const returnLabel = computed(() => (sourceKey.value ? "返回上一页" : "返回我的"));
const sourceTab = computed(() => (sourceKey.value === "G01" ? "genealogy" : "profile"));
const loadNotifications = async () => {
notificationListController.abort();
notificationListState.value = "loading";
@@ -126,12 +130,23 @@ const confirmMarkAllRead = async () => {
}
};
const returnToProfile = () => goRoot("M01");
const returnToSource = () => (sourceKey.value ? goBack() : goRoot("M01"));
const closeMarkAllDialog = () => {
if (markAllSubmitting.value) return false;
markAllVisible.value = false;
return true;
};
const openNotification = (item) => openPage("N02", { id: item.notificationId });
onLoad(loadNotifications);
onLoad((query) => {
sourceKey.value = String(query?.sourceKey || "");
loadNotifications();
});
onShow(() => {
if (notificationListState.value !== "loading") loadNotifications();
});
onBackPress((event) =>
markAllVisible.value ? handleBackPress(event, closeMarkAllDialog) : false,
);
onUnload(() => {
isPageActive = false;
notificationListController.abort();
@@ -153,7 +168,7 @@ onUnload(() => {
}
.page-content {
flex: 1;
padding: 18rpx 24rpx 190rpx;
padding: 18rpx 24rpx calc(190rpx + env(safe-area-inset-bottom));
}
.state-card {
box-sizing: border-box;
@@ -182,7 +197,9 @@ onUnload(() => {
}
.message-list {
display: grid;
gap: 16rpx;
gap: 20rpx;
box-sizing: border-box;
padding: 28rpx 24rpx;
@include adaptive-notification-content;
}
.message-row {
@@ -192,7 +209,7 @@ onUnload(() => {
padding: 24rpx;
border: 1rpx solid rgba(128, 89, 49, 0.26);
border-radius: 12rpx;
background: rgba(255, 252, 245, 0.78);
background: rgba($paper, 0.9);
}
.message-row__body { min-width: 0; flex: 1; }
.message-row__title-line { display: flex; gap: 12rpx; align-items: center; }
+19 -4
View File
@@ -7,7 +7,7 @@
:action="notificationDetail.readStatus === '0' ? (markReadSubmitting ? '正在设置' : '设为已读') : ''"
custom-back
@action="requestMarkRead"
@back="backToMessages"
@back="requestBack"
/></view>
<view class="page-content">
<view v-if="notificationDetailState === 'loading'" class="state-card"
@@ -80,7 +80,7 @@
<script setup>
import { computed, ref } from "vue";
import { onLoad, onUnload } from "@dcloudio/uni-app";
import { onBackPress, onLoad, onUnload } from "@dcloudio/uni-app";
import AppButton from "@/components/AppButton.vue";
import AppDialog from "@/components/AppDialog.vue";
import AppLoading from "@/components/AppLoading.vue";
@@ -92,7 +92,7 @@ import {
} from "@/services/api/request-controller.js";
import { notificationApi } from "@/services/api/notification-service.js";
import { getRequestErrorMessage } from "@/services/api/request-error-message.js";
import { openNoticeTarget, returnTo } from "@/utils/navigation/gateway.js";
import { handleBackPress, openNoticeTarget, returnTo } from "@/utils/navigation/gateway.js";
const notificationId = ref("");
const notificationDetail = ref({});
@@ -172,6 +172,14 @@ const confirmMarkRead = async () => {
if (isPageActive) markReadSubmitting.value = false;
}
};
const requestBack = () => {
if (markReadSubmitting.value) return true;
if (markReadVisible.value) {
markReadVisible.value = false;
return true;
}
return backToMessages();
};
onLoad((options) => {
notificationId.value = String(options?.id || "");
@@ -186,6 +194,7 @@ onUnload(() => {
notificationDetailController.abort();
markReadController.abort();
});
onBackPress((event) => handleBackPress(event, requestBack));
</script>
<style scoped lang="scss">
@@ -202,12 +211,13 @@ onUnload(() => {
}
.page-content {
flex: 1;
padding: 18rpx 24rpx 72rpx;
padding: 18rpx 24rpx calc(72rpx + env(safe-area-inset-bottom));
}
.state-card,
.detail-card {
box-sizing: border-box;
@include adaptive-notification-content;
background-color: rgba($paper, 0.82);
}
.state-card {
min-height: 340rpx;
@@ -239,6 +249,8 @@ onUnload(() => {
.detail-title,
.detail-time {
display: block;
min-width: 0;
overflow-wrap: anywhere;
}
.detail-time {
margin-top: 16rpx;
@@ -250,6 +262,7 @@ onUnload(() => {
color: $ink;
font-size: clamp(16px, 29rpx, 20px);
line-height: 1.8;
overflow-wrap: anywhere;
white-space: pre-wrap;
}
.detail-meta {
@@ -269,8 +282,10 @@ onUnload(() => {
flex: 0 0 116rpx;
}
.detail-meta__row text:last-child {
min-width: 0;
flex: 1;
color: $ink;
overflow-wrap: anywhere;
}
.detail-operation-error {
display: block;
+23 -1
View File
@@ -204,7 +204,7 @@ onUnload(() => {
}
.page-content {
padding: 36rpx 32rpx 68rpx;
padding: 36rpx 32rpx calc(68rpx + env(safe-area-inset-bottom));
}
.state-card,
@@ -258,6 +258,10 @@ onUnload(() => {
gap: 20rpx;
}
.invitation-card__heading {
align-items: flex-start;
}
.invitation-card__heading text:first-child {
flex: 1;
color: #40291e;
@@ -295,7 +299,25 @@ onUnload(() => {
}
.invitation-card__actions {
flex-wrap: wrap;
justify-content: flex-end;
padding-top: 8rpx;
}
.invitation-card__actions .app-button {
min-width: 152rpx;
}
@media (max-width: 340px) {
.page-content {
padding-right: 22rpx;
padding-left: 22rpx;
}
.state-card,
.invitation-card {
padding-right: 24rpx;
padding-left: 24rpx;
}
}
</style>
+23 -8
View File
@@ -28,18 +28,22 @@
:password="!passwordVisible[field.key]"
maxlength="32"
:aria-label="field.label"
:aria-invalid="Boolean(errors[field.key])"
:aria-describedby="errors[field.key] ? `${field.key}-password-error` : undefined"
:placeholder="field.placeholder"
@input="errors[field.key] = ''"
/>
<view
<button
class="password-toggle"
role="button"
tabindex="0"
:aria-label="`${passwordVisible[field.key] ? '隐藏' : '显示'}${field.label}`"
:aria-pressed="passwordVisible[field.key]"
@click="togglePassword(field.key)"
>{{ passwordVisible[field.key] ? "隐藏" : "显示" }}</view
>{{ passwordVisible[field.key] ? "隐藏" : "显示" }}</button
>
</view>
<text v-if="errors[field.key]" class="field-error">{{
<text v-if="errors[field.key]" :id="`${field.key}-password-error`" class="field-error" role="alert">{{
errors[field.key]
}}</text>
</view>
@@ -84,7 +88,8 @@ import { getRequestErrorMessage } from "@/services/api/request-error-message.js"
import { createDiscardConfirmation } from "@/utils/discard-confirmation.js";
import { calcMD5 } from "@/utils/md5.js";
import { createNonIdempotentWriteGuard } from "@/utils/non-idempotent-write-guard.js";
import { handleBackPress, runBackGuard } from "@/utils/navigation/gateway.js";
import { goRoot, handleBackPress, runBackGuard } from "@/utils/navigation/gateway.js";
import { session } from "@/utils/session.js";
import {
PASSWORD_POLICY_MESSAGE,
validatePassword,
@@ -161,11 +166,13 @@ const savePassword = async () => {
{ requestController: passwordChangeRequestController },
);
if (!pageActive) return;
session.clear();
await goRoot("A01");
if (!pageActive) return;
passwordForm.current = "";
passwordForm.next = "";
passwordForm.confirm = "";
baseline.value = formSnapshot.value;
showToast("密码修改成功");
} catch (error) {
if (!pageActive) return;
if (passwordChangeGuard.recordFailure(passwordChangeAttempt, error)) {
@@ -215,7 +222,7 @@ onUnload(() => {
}
.page-content {
flex: 1;
padding: 28rpx 30rpx 72rpx;
padding: 28rpx 30rpx calc(72rpx + env(safe-area-inset-bottom));
}
.security-tip,
.form-panel {
@@ -264,18 +271,26 @@ onUnload(() => {
.form-row input {
width: auto;
min-width: 0;
min-height: 68rpx;
min-height: 80rpx;
color: $ink;
font-size: clamp(14px, 23rpx, 17px);
}
.password-toggle {
display: flex;
min-height: 68rpx;
min-height: 80rpx;
align-items: center;
justify-content: flex-end;
width: auto;
margin: 0;
padding: 0;
border: 0;
background: transparent;
color: $brand-red;
font-size: clamp(13px, 21rpx, 16px);
}
.password-toggle::after {
border: 0;
}
.field-error {
display: block;
padding-top: 7rpx;
+43 -12
View File
@@ -16,6 +16,22 @@
><text>完成安全验证和短信校验后即可更新你的登录手机号</text></view
>
<view class="form-panel">
<view class="field-block">
<view class="form-row">
<text>当前密码</text>
<input
v-model="currentPassword"
password
maxlength="32"
aria-label="当前密码"
:aria-invalid="Boolean(errors.currentPassword)"
:aria-describedby="errors.currentPassword ? 'change-phone-password-error' : undefined"
placeholder="请输入当前登录密码"
@input="errors.currentPassword = ''"
/>
</view>
<text v-if="errors.currentPassword" id="change-phone-password-error" class="field-error" role="alert">{{ errors.currentPassword }}</text>
</view>
<view class="field-block">
<view class="form-row">
<text>新手机号</text>
@@ -24,11 +40,13 @@
type="number"
maxlength="11"
aria-label="新手机号"
:aria-invalid="Boolean(errors.phone)"
:aria-describedby="errors.phone ? 'change-phone-error' : undefined"
placeholder="请输入新手机号"
@input="handlePhoneInput"
/>
</view>
<text v-if="errors.phone" class="field-error">{{ errors.phone }}</text>
<text v-if="errors.phone" id="change-phone-error" class="field-error" role="alert">{{ errors.phone }}</text>
</view>
<view class="field-block">
<view class="form-row form-row--code">
@@ -38,6 +56,8 @@
type="number"
maxlength="4"
aria-label="短信验证码"
:aria-invalid="Boolean(errors.smsCode)"
:aria-describedby="errors.smsCode ? 'change-phone-code-error' : undefined"
placeholder="4 位验证码"
@input="errors.smsCode = ''"
/>
@@ -48,7 +68,7 @@
@click="prepareGetCode"
>{{ cooldownSeconds > 0 ? `${cooldownSeconds}s 后重试` : '获取验证码' }}</button>
</view>
<text v-if="errors.smsCode" class="field-error">{{ errors.smsCode }}</text>
<text v-if="errors.smsCode" id="change-phone-code-error" class="field-error" role="alert">{{ errors.smsCode }}</text>
</view>
</view>
<AppButton
@@ -104,16 +124,19 @@ import {
} from "@/utils/auth/verification.js";
import { createDiscardConfirmation } from "@/utils/discard-confirmation.js";
import { createNonIdempotentWriteGuard } from "@/utils/non-idempotent-write-guard.js";
import { handleBackPress, runBackGuard } from "@/utils/navigation/gateway.js";
import { goRoot, handleBackPress, runBackGuard } from "@/utils/navigation/gateway.js";
import { calcMD5 } from "@/utils/md5.js";
import { session } from "@/utils/session.js";
const currentPassword = ref("");
const phone = ref("");
const smsCode = ref("");
const errors = reactive({ phone: "", smsCode: "" });
const errors = reactive({ currentPassword: "", phone: "", smsCode: "" });
const submittingPhoneChange = ref(false);
const discardVisible = ref(false);
const toastVisible = ref(false);
const toastMessage = ref("");
const formSnapshot = computed(() => JSON.stringify({ phone: phone.value, smsCode: smsCode.value }));
const formSnapshot = computed(() => JSON.stringify({ currentPassword: currentPassword.value, phone: phone.value, smsCode: smsCode.value }));
const baseline = ref(formSnapshot.value);
const isDirty = computed(() => formSnapshot.value !== baseline.value);
const phoneChangeController = createRequestController();
@@ -185,19 +208,24 @@ const prepareGetCode = async () => {
};
const validateForm = () => {
errors.currentPassword = currentPassword.value ? "" : "请输入当前密码";
errors.phone = isAuthPhone(phone.value) ? "" : "请输入正确手机号";
errors.smsCode =
sentPhone.value !== phone.value
? "请先获取当前手机号的验证码"
? "请先获取手机号的验证码"
: /^\d{4}$/.test(smsCode.value)
? ""
: "请输入 4 位验证码";
return !errors.phone && !errors.smsCode;
return !errors.currentPassword && !errors.phone && !errors.smsCode;
};
const submitPhoneChange = async () => {
if (phoneState.value !== "ready" || !validateForm()) return;
const phoneChangePayload = { phone: phone.value, smsCode: smsCode.value };
const phoneChangePayload = {
phone: phone.value,
smsCode: smsCode.value,
currentPasswordHash: calcMD5(currentPassword.value),
};
const phoneChangeAttempt = phoneChangeGuard.begin(phoneChangePayload);
if (phoneChangeAttempt === null) {
showToast("上次换绑结果暂时无法确认,请重新登录确认手机号,不要重复提交");
@@ -210,11 +238,14 @@ const submitPhoneChange = async () => {
{ requestController: phoneChangeController },
);
if (!isPageActive) return;
session.clear();
await goRoot("A01");
if (!isPageActive) return;
currentPassword.value = "";
phone.value = "";
smsCode.value = "";
sentPhone.value = "";
baseline.value = formSnapshot.value;
showToast("手机号换绑成功");
} catch (error) {
if (!isPageActive) return;
if (phoneChangeGuard.recordFailure(phoneChangeAttempt, error)) {
@@ -262,7 +293,7 @@ onUnload(() => {
}
.page-content {
flex: 1;
padding: 28rpx 30rpx 72rpx;
padding: 28rpx 30rpx calc(72rpx + env(safe-area-inset-bottom));
}
.security-tip,
.form-panel {
@@ -314,7 +345,7 @@ onUnload(() => {
.form-row input {
width: auto;
min-width: 0;
min-height: 68rpx;
min-height: 80rpx;
color: $ink;
font-size: clamp(14px, 23rpx, 17px);
}
@@ -324,7 +355,7 @@ onUnload(() => {
justify-content: center;
justify-self: end;
width: 198rpx;
min-height: 68rpx;
min-height: 80rpx;
margin: 0;
padding: 0 14rpx;
box-sizing: border-box;
+13 -1
View File
@@ -131,7 +131,7 @@ onUnload(() => {
}
.document-content {
flex: 1;
padding: 24rpx 28rpx 72rpx;
padding: 24rpx 28rpx calc(72rpx + env(safe-area-inset-bottom));
}
.document-sheet,
.document-state-card {
@@ -219,4 +219,16 @@ onUnload(() => {
.document-state-card .app-button {
margin-top: 28rpx;
}
@media (max-width: 340px) {
.document-content {
padding-right: 22rpx;
padding-left: 22rpx;
}
.document-sheet {
padding-right: 28rpx;
padding-left: 28rpx;
}
}
</style>
+33 -2
View File
@@ -84,7 +84,11 @@
<text>{{ recordTimeLabel(withdrawal.createTime) }}</text>
</view>
<text>{{ withdrawalStatusLabel(withdrawal.withdrawalStatus) }}</text>
<text v-if="withdrawal.withdrawalNo">提现单号{{ withdrawal.withdrawalNo }}</text>
<text>收款人{{ withdrawal.payoutAccountName || '未填写' }}</text>
<text v-if="withdrawal.auditRemark">审核备注{{ withdrawal.auditRemark }}</text>
<text v-if="withdrawal.payoutReference">打款参考号{{ withdrawal.payoutReference }}</text>
<text v-if="withdrawal.paidAt">到账时间{{ recordTimeLabel(withdrawal.paidAt) }}</text>
<text v-if="withdrawal.failureReason">原因{{ withdrawal.failureReason }}</text>
<AppButton
v-if="withdrawal.withdrawalStatus === 'PENDING'"
@@ -121,7 +125,7 @@
<script setup>
import { computed, reactive, ref } from "vue";
import { onShow, onUnload } from "@dcloudio/uni-app";
import { onBackPress, onShow, onUnload } from "@dcloudio/uni-app";
import AppButton from "@/components/AppButton.vue";
import AppDialog from "@/components/AppDialog.vue";
import AppLoading from "@/components/AppLoading.vue";
@@ -137,6 +141,7 @@ import {
} from "@/services/api/request-controller.js";
import { earningApi } from "@/services/api/earning-service.js";
import { getRequestErrorMessage } from "@/services/api/request-error-message.js";
import { handleBackPress } from "@/utils/navigation/gateway.js";
import { formatMinuteTimestamp } from "@/utils/display-time.js";
import {
formatSignedMoney,
@@ -257,10 +262,18 @@ const cancelWithdrawal = async () => {
if (isPageActive) cancelling.value = false;
}
};
const closeCancellationDialog = () => {
if (cancelling.value) return false;
cancelTarget.value = null;
return true;
};
onShow(() => {
void loadAll();
});
onBackPress((event) =>
cancelTarget.value ? handleBackPress(event, closeCancellationDialog) : false,
);
onUnload(() => {
isPageActive = false;
summaryController.abort();
@@ -275,7 +288,7 @@ onUnload(() => {
.earnings-page {
min-height: 100vh;
box-sizing: border-box;
padding-bottom: 48rpx;
padding-bottom: env(safe-area-inset-bottom);
background: $paper;
}
@@ -334,6 +347,11 @@ onUnload(() => {
font-size: clamp(14px, 23rpx, 17px);
}
.summary-row text {
min-width: 0;
overflow-wrap: anywhere;
}
.summary-note {
margin: 18rpx 0;
line-height: 1.6;
@@ -442,4 +460,17 @@ onUnload(() => {
margin-top: 24rpx;
}
@media (max-width: 340px) {
.earnings-content {
padding-right: 20rpx;
padding-left: 20rpx;
}
.summary-panel,
.record-card {
padding-right: 20rpx;
padding-left: 20rpx;
}
}
</style>
+58 -8
View File
@@ -109,14 +109,27 @@
</template>
</view>
<AppToast :visible="feedbackVisible" :message="feedbackMessage" />
<AppDialog
:visible="discardVisible"
:close-on-mask="false"
eyebrow="放弃确认"
title="放弃资料修改?"
message="当前修改还没有保存。"
confirm-text="确认放弃"
cancel-text="继续编辑"
show-cancel
@confirm="confirmDiscard"
@cancel="cancelDiscard"
/>
</view>
</template>
<script setup>
import { computed, reactive, ref } from "vue";
import { onLoad, onUnload } from "@dcloudio/uni-app";
import { onBackPress, onLoad, onUnload } from "@dcloudio/uni-app";
import AppAvatar from "@/components/AppAvatar.vue";
import AppButton from "@/components/AppButton.vue";
import AppDialog from "@/components/AppDialog.vue";
import AppToast from "@/components/AppToast.vue";
import ModulePageBackground from "@/components/ModulePageBackground.vue";
import PageHeader from "@/components/PageHeader.vue";
@@ -129,11 +142,16 @@ import {
} from "@/services/api/request-controller.js";
import { profileApi } from "@/services/api/profile-service.js";
import { getRequestErrorMessage } from "@/services/api/request-error-message.js";
import { createDiscardConfirmation } from "@/utils/discard-confirmation.js";
import {
isImagePickCancelled,
pickAndUploadImage,
} from "@/utils/media-upload.js";
import { finishPage, returnTo } from "@/utils/navigation/gateway.js";
import {
finishPage,
handleBackPress,
runBackGuard,
} from "@/utils/navigation/gateway.js";
const form = reactive({
nickName: "",
@@ -162,11 +180,18 @@ const saveError = ref("");
const committedProfilePayload = ref("");
const feedbackVisible = ref(false);
const feedbackMessage = ref("");
const discardVisible = ref(false);
const profileReadController = createRequestController();
const avatarUploadController = createRequestController();
const profileSaveController = createRequestController();
let feedbackTimer = null;
let isPageActive = true;
const discardConfirmation = createDiscardConfirmation((visible) => {
discardVisible.value = visible;
});
const requestDiscardConfirmation = discardConfirmation.request;
const confirmDiscard = discardConfirmation.confirm;
const cancelDiscard = discardConfirmation.cancel;
const sexOptions = Object.freeze([
Object.freeze({ value: "", label: "请选择" }),
...PROFILE_SEX_OPTIONS,
@@ -181,6 +206,16 @@ const avatarMessage = computed(() => {
if (avatarFileName.value) return `已选择 ${avatarFileName.value}`;
return avatarPreviewUrl.value ? "当前头像已设置" : "当前使用默认头像";
});
const profileSnapshot = computed(() => JSON.stringify({
nickName: form.nickName,
realName: form.realName,
sex: form.sex,
birthday: form.birthday,
email: form.email,
avatar: avatarId.value || original.avatar || null,
}));
const originalSnapshot = computed(() => JSON.stringify(original));
const isDirty = computed(() => profileSnapshot.value !== originalSnapshot.value);
const showFeedback = (message) => {
feedbackMessage.value = message;
@@ -291,6 +326,15 @@ const saveProfile = async () => {
});
if (!isPageActive) return;
committedProfilePayload.value = payloadFingerprint;
for (const field of ["nickName", "realName", "sex", "birthday", "email"]) {
if (Object.prototype.hasOwnProperty.call(payload, field)) {
original[field] = payload[field];
}
}
if (Object.prototype.hasOwnProperty.call(payload, "avatar")) {
original.avatar = payload.avatar;
avatarId.value = null;
}
}
await finishPage(
"M01",
@@ -307,18 +351,24 @@ const saveProfile = async () => {
}
};
const backToProfile = () => {
if (saving.value || uploading.value) return;
return returnTo("M01");
};
const backToProfile = () => runBackGuard({
transientOpen: discardVisible.value,
dirty: isDirty.value,
submitting: saving.value || uploading.value,
"close-transient": cancelDiscard,
"block-submitting": () => true,
"confirm-discard": requestDiscardConfirmation,
});
onLoad(loadProfile);
onBackPress((event) => handleBackPress(event, backToProfile));
onUnload(() => {
isPageActive = false;
profileReadController.abort();
avatarUploadController.abort();
profileSaveController.abort();
if (feedbackTimer) clearTimeout(feedbackTimer);
discardConfirmation.dispose();
});
</script>
@@ -336,7 +386,7 @@ onUnload(() => {
}
.page-content {
flex: 1;
padding: 24rpx 30rpx 72rpx;
padding: 24rpx 30rpx calc(72rpx + env(safe-area-inset-bottom));
}
.state-card,
.form-panel {
@@ -435,7 +485,7 @@ onUnload(() => {
.picker-value {
width: auto;
min-width: 0;
min-height: 68rpx;
min-height: 80rpx;
color: $ink;
font-size: clamp(14px, 23rpx, 17px);
text-align: right;
+26 -14
View File
@@ -122,7 +122,7 @@
class="history-card"
>
<view class="history-card__meta">
<text>{{ feedbackTypeLabel(feedback.feedbackType) }} · {{ feedbackReference(feedback) }}</text>
<text>{{ feedbackTypeLabel(feedback) }} · {{ feedbackReference(feedback) }}</text>
<text class="history-status">{{ feedbackStatusLabel(feedback.handleStatus) }}</text>
</view>
<text
@@ -171,9 +171,7 @@ import AppButton from "@/components/AppButton.vue";
import AppDialog from "@/components/AppDialog.vue";
import ModulePageBackground from "@/components/ModulePageBackground.vue";
import PageHeader from "@/components/PageHeader.vue";
import {
FEEDBACK_TYPE_OPTIONS as feedbackTypes
} from "@/services/api/feedback-contract.js";
import { businessDictionaryApi } from "@/services/api/business-dictionary-service.js";
import {
createRequestController,
isRequestCancelled
@@ -190,9 +188,11 @@ const feedbackStatusLabels = {
2: "已处理",
3: "暂不处理",
};
const feedbackTypeLabel = (feedbackTypeValue) =>
feedbackTypes.find(
(feedbackType) => feedbackType.value === feedbackTypeValue,
const feedbackTypes = ref([]);
const feedbackTypeLabel = (feedback) =>
String(feedback?.feedbackTypeLabel || "").trim() ||
feedbackTypes.value.find(
(feedbackType) => feedbackType.value === feedback?.feedbackType,
)?.label || "其他";
const feedbackStatusLabel = (handleStatus) =>
feedbackStatusLabels[String(handleStatus)] || "状态待确认";
@@ -234,6 +234,7 @@ const toggleFeedbackExpanded = (feedback) => {
let pageActive = true;
const feedbackHistoryRequestController = createRequestController();
const feedbackSubmissionRequestController = createRequestController();
const feedbackTypeRequestController = createRequestController();
const submissionSession = createFeedbackSubmissionSession(feedbackForm);
const syncSubmissionView = () => {
const submissionView = submissionSession.view(feedbackForm);
@@ -261,6 +262,13 @@ const loadFeedbackHistory = async () => {
);
}
};
const loadFeedbackTypes = async () => {
feedbackTypeRequestController.abort();
feedbackTypes.value = await businessDictionaryApi.getBusinessDictionaryOptions(
"gen_feedback_type",
{ requestController: feedbackTypeRequestController },
);
};
const discardConfirmation = createDiscardConfirmation((visible) => {
discardVisible.value = visible;
});
@@ -325,11 +333,15 @@ const requestBack = () =>
"confirm-discard": requestDiscardConfirmation,
});
onBackPress((event) => handleBackPress(event, requestBack));
onLoad(loadFeedbackHistory);
onLoad(() => {
void loadFeedbackTypes().catch(() => { feedbackTypes.value = []; });
void loadFeedbackHistory();
});
onUnload(() => {
pageActive = false;
feedbackHistoryRequestController.abort();
feedbackSubmissionRequestController.abort();
feedbackTypeRequestController.abort();
discardConfirmation.dispose();
});
</script>
@@ -347,7 +359,7 @@ onUnload(() => {
}
.page-content {
flex: 1;
padding: 26rpx 30rpx 72rpx;
padding: 26rpx 30rpx calc(72rpx + env(safe-area-inset-bottom));
}
.lead {
display: block;
@@ -477,7 +489,7 @@ onUnload(() => {
}
.history-retry {
min-width: 180rpx;
min-height: 72rpx;
min-height: 80rpx;
margin: 0;
padding: 0 28rpx;
border: 1px solid rgba(164, 41, 36, 0.55);
@@ -485,7 +497,7 @@ onUnload(() => {
background: rgba(255, 255, 255, 0.82);
color: $brand-red;
font-size: clamp(14px, 22rpx, 16px);
line-height: 72rpx;
line-height: 80rpx;
}
.history-list {
display: grid;
@@ -523,14 +535,14 @@ onUnload(() => {
-webkit-line-clamp: 4;
}
.history-expand {
min-height: 72rpx;
min-height: 80rpx;
margin: 4rpx 0 0 auto;
padding: 0 8rpx;
border: 0;
background: transparent;
color: $brand-red;
font-size: clamp(13px, 20rpx, 15px);
line-height: 72rpx;
line-height: 80rpx;
}
.history-expand::after {
border: 0;
@@ -559,7 +571,7 @@ onUnload(() => {
.contact-row input {
width: auto;
min-width: 0;
min-height: 68rpx;
min-height: 80rpx;
color: $ink;
font-size: clamp(14px, 22rpx, 17px);
text-align: right;
+17 -10
View File
@@ -170,7 +170,7 @@ onUnload(() => {
}
.page-content {
flex: 1;
padding: 24rpx 28rpx 72rpx;
padding: 24rpx 28rpx calc(72rpx + env(safe-area-inset-bottom));
}
.help-hero,
.help-state-card,
@@ -225,7 +225,7 @@ onUnload(() => {
}
.help-search {
width: 100%;
min-height: 72rpx;
min-height: 80rpx;
box-sizing: border-box;
padding: 0 22rpx;
border: 1rpx solid rgba(128, 89, 49, 0.28);
@@ -245,13 +245,13 @@ onUnload(() => {
.help-category {
display: inline-flex;
min-width: 112rpx;
min-height: 58rpx;
min-height: 80rpx;
align-items: center;
justify-content: center;
margin-right: 12rpx;
padding: 0 20rpx;
border: 1rpx solid rgba(128, 89, 49, 0.24);
border-radius: 29rpx;
border-radius: 40rpx;
background: rgba(255, 252, 245, 0.54);
color: $ink-muted;
font-size: clamp(13px, 22rpx, 16px);
@@ -263,20 +263,21 @@ onUnload(() => {
border: 0;
}
.help-category--active {
border-color: #8d2722;
background: #8d2722;
border-color: $brand-red;
background: $brand-red;
color: #fff7e8;
}
.help-article-list {
display: grid;
gap: 14rpx;
width: 100%;
margin-top: 18rpx;
}
.help-article {
overflow: hidden;
border: 1rpx solid rgba(128, 89, 49, 0.22);
border-radius: 12rpx;
background: rgba(255, 252, 245, 0.66);
background: rgba(255, 252, 245, 0.88);
}
.help-article__question {
display: flex;
@@ -311,12 +312,12 @@ onUnload(() => {
}
.help-article__indicator {
flex: 0 0 auto;
color: #9f170f;
color: $brand-red-dark;
font-size: clamp(12px, 20rpx, 15px);
}
.help-article__answer {
display: block;
padding: 0 22rpx 24rpx;
padding: 16rpx 22rpx 24rpx;
border-top: 1rpx solid rgba(128, 89, 49, 0.16);
color: $ink-muted;
font-size: clamp(14px, 23rpx, 17px);
@@ -336,7 +337,7 @@ onUnload(() => {
margin-top: 16rpx;
border: 0;
background: transparent;
color: #9f170f;
color: $brand-red-dark;
font-size: clamp(14px, 23rpx, 17px);
}
.help-feedback-card {
@@ -344,6 +345,12 @@ onUnload(() => {
padding: 32rpx 34rpx;
text-align: center;
}
.help-feedback-card text {
display: block;
}
.help-feedback-card text:first-child {
font-size: clamp(17px, 28rpx, 20px);
}
@media (max-width: 340px) {
.page-content {
padding-right: 20rpx;
+34 -1
View File
@@ -594,7 +594,7 @@ onUnload(() => {
top: 337rpx;
right: 61rpx;
width: 154rpx;
min-height: 52rpx;
min-height: 72rpx;
margin: 0;
padding: 0;
border: 0;
@@ -814,4 +814,37 @@ onUnload(() => {
opacity: 0.68;
}
@media (max-width: 340px) {
.profile-hero__content {
padding-right: 42rpx;
padding-left: 42rpx;
}
.profile-hero__emblem {
width: 176rpx;
height: 176rpx;
}
.profile-metadata {
grid-template-columns: minmax(0, 1fr);
}
.profile-metadata__item:first-child {
border-right: 0;
}
.profile-metadata__item + .profile-metadata__item {
border-top: 1rpx solid rgba(117, 83, 52, 0.16);
}
.profile-metadata__item--email {
grid-column: auto;
}
.profile-services {
margin-right: 38rpx;
margin-left: 38rpx;
}
}
</style>
+121 -6
View File
@@ -11,6 +11,31 @@
<text>家谱服务推荐</text>
<text>这里会展示家谱相关服务和活动</text>
</view>
<text v-if="operationFeedback" class="promotion-feedback" role="status">{{ operationFeedback }}</text>
<view class="referral-card">
<view class="referral-card__heading">
<text>邀请家人注册</text>
<text>推荐关系由注册接口一次性确认前端不会在注册后补绑</text>
</view>
<AppLoading v-if="referralState === 'loading'" text="正在读取我的推荐码" />
<view v-else-if="referralState === 'error'" class="referral-card__state">
<text>{{ referralError || "推荐码暂时无法读取。" }}</text>
<AppButton compact type="secondary" label="重试" @click="loadReferralProfile" />
</view>
<view v-else-if="referralProfile.enabled" class="referral-card__content">
<text class="referral-card__code">{{ referralProfile.referralCode }}</text>
<text>已成功邀请 {{ referralProfile.referredUserCount }} </text>
<text>{{ referralProfile.shareDescription || "家人通过此链接注册后,系统会记录推荐关系。" }}</text>
<view class="referral-card__actions">
<AppButton compact type="secondary" label="复制推荐码" @click="copyReferralCode" />
<AppButton compact type="secondary" label="复制推荐链接" @click="copyReferralLink" />
<AppButton compact label="分享给家人" @click="shareReferral" />
</view>
</view>
<view v-else class="referral-card__state">
<text>{{ referralProfile.disabledReason || "推荐功能暂未开放。" }}</text>
</view>
</view>
<view v-if="promotionListState === 'loading'" class="promotion-state-card">
<AppLoading text="正在读取推广内容" />
</view>
@@ -24,7 +49,6 @@
<text>后续正式发布的家谱服务推荐会在这里展示</text>
</view>
<view v-else class="promotion-list">
<text v-if="operationFeedback" class="promotion-feedback" role="status">{{ operationFeedback }}</text>
<view
v-for="item in promotions"
:key="item.id"
@@ -42,7 +66,7 @@
/>
<text class="promotion-card__title">{{ item.title }}</text>
<text v-if="item.description" class="promotion-card__description">{{ item.description }}</text>
<view v-if="item.targetUrl" class="promotion-card__actions">
<view v-if="item.targetUrl" class="promotion-card__actions" @click.stop>
<AppButton compact type="secondary" label="复制链接" @click.stop="copyPromotionLink(item)" />
<AppButton compact label="分享给家人" @click.stop="sharePromotion(item)" />
</view>
@@ -63,6 +87,7 @@ import {
createRequestController,
isRequestCancelled
} from "@/services/api/request-controller.js";
import { referralApi } from "@/services/api/referral-service.js";
import { siteContentApi } from "@/services/api/site-content-service.js";
import { getRequestErrorMessage } from "@/services/api/request-error-message.js";
import { goBack, handleBackPress, openSiteContentTarget } from "@/utils/navigation/gateway.js";
@@ -71,9 +96,39 @@ const promotions = ref([]);
const promotionListState = ref("loading");
const promotionListError = ref("");
const operationFeedback = ref("");
const referralState = ref("loading");
const referralError = ref("");
const referralProfile = ref({
enabled: false,
disabledReason: "",
referralCode: "",
shareTitle: "",
shareDescription: "",
shareUrl: "",
referredUserCount: 0,
});
const promotionListRequestController = createRequestController();
const referralRequestController = createRequestController();
let pageActive = true;
const loadReferralProfile = async () => {
referralRequestController.abort();
referralState.value = "loading";
referralError.value = "";
try {
const profile = await referralApi.getMyReferralProfile({
requestController: referralRequestController,
});
if (!pageActive) return;
referralProfile.value = profile;
referralState.value = "ready";
} catch (error) {
if (!pageActive || isRequestCancelled(error)) return;
referralError.value = getRequestErrorMessage(error, "推荐码暂时无法读取,请稍后重试。");
referralState.value = "error";
}
};
const loadPromotions = async () => {
promotionListRequestController.abort();
promotionListState.value = "loading";
@@ -117,6 +172,39 @@ const copyPromotionLink = (item) => {
fail: () => { operationFeedback.value = "复制失败,请稍后再试。"; },
});
};
const copyText = (content, successMessage) => {
if (!content || typeof uni?.setClipboardData !== "function") {
operationFeedback.value = "当前设备暂时不能复制。";
return;
}
uni.setClipboardData({
data: content,
success: () => { operationFeedback.value = successMessage; },
fail: () => { operationFeedback.value = "复制失败,请稍后再试。"; },
});
};
const copyReferralCode = () =>
copyText(referralProfile.value.referralCode, "推荐码已复制,可以发给家人。");
const copyReferralLink = () =>
copyText(referralProfile.value.shareUrl, "推荐链接已复制,可以发给家人。");
const shareReferral = () => {
const profile = referralProfile.value;
if (!profile.enabled || !profile.shareUrl) return;
const content = [profile.shareTitle, profile.shareDescription, profile.shareUrl]
.filter(Boolean)
.join("\n");
// #ifdef APP-PLUS
if (typeof plus?.share?.sendWithSystem === "function") {
plus.share.sendWithSystem(
{ type: "text", content },
() => { operationFeedback.value = "已打开系统分享。"; },
() => { operationFeedback.value = "已取消分享。"; },
);
return;
}
// #endif
copyReferralLink();
};
const sharePromotion = (item) => {
if (!item?.targetUrl) return;
const content = [item.title, item.description, item.targetUrl].filter(Boolean).join("\n");
@@ -142,11 +230,15 @@ const sharePromotion = (item) => {
};
const requestBack = () => goBack();
onLoad(loadPromotions);
onLoad(() => {
void loadReferralProfile();
void loadPromotions();
});
onBackPress((event) => handleBackPress(event, requestBack));
onUnload(() => {
pageActive = false;
promotionListRequestController.abort();
referralRequestController.abort();
});
</script>
@@ -163,9 +255,10 @@ onUnload(() => {
}
.page-content {
flex: 1;
padding: 28rpx 30rpx 72rpx;
padding: 28rpx 30rpx calc(72rpx + env(safe-area-inset-bottom));
}
.promotion-hero,
.referral-card,
.promotion-state-card,
.promotion-list {
@include adaptive-profile-content;
@@ -211,8 +304,25 @@ onUnload(() => {
padding: 38rpx;
text-align: center;
}
.referral-card {
margin-top: 20rpx;
padding: 28rpx;
border: 1rpx solid rgba(128, 89, 49, 0.26);
border-radius: 12rpx;
background: rgba(255, 252, 245, 0.82);
}
.referral-card text { display: block; }
.referral-card__heading text:first-child { color: $ink; font-size: clamp(17px, 29rpx, 21px); font-weight: 700; }
.referral-card__heading text:last-child,
.referral-card__content > text:last-of-type,
.referral-card__state > text { margin-top: 8rpx; color: $ink-muted; font-size: clamp(13px, 22rpx, 16px); line-height: 1.55; }
.referral-card__content,
.referral-card__state { margin-top: 22rpx; }
.referral-card__code { color: #9e251b; font-family: ui-monospace, SFMono-Regular, Consolas, monospace; font-size: clamp(22px, 40rpx, 30px); font-weight: 700; letter-spacing: 3rpx; }
.referral-card__actions { display: flex; flex-wrap: wrap; justify-content: flex-end; margin-top: 22rpx; gap: 12rpx; }
.referral-card__actions .app-button { width: auto; min-width: 150rpx; }
.promotion-state-card .app-button { margin-top: 28rpx; }
.promotion-list { display: grid; gap: 16rpx; }
.promotion-list { display: grid; gap: 18rpx; margin-top: 20rpx; }
.promotion-card {
overflow: hidden;
padding: 26rpx 28rpx;
@@ -230,7 +340,7 @@ onUnload(() => {
.promotion-card text { display: block; }
.promotion-card__title { color: $ink; font-size: clamp(16px, 28rpx, 20px); font-weight: 700; }
.promotion-card__description { margin-top: 10rpx; color: $ink-muted; font-size: clamp(14px, 23rpx, 17px); line-height: 1.6; }
.promotion-feedback { display: block; padding: 16rpx 20rpx; border: 1rpx solid rgba(66, 107, 88, .32); border-radius: 10rpx; background: rgba(66, 107, 88, .08); color: #426b58; font-size: clamp(14px, 22rpx, 17px); }
.promotion-feedback { display: block; margin-top: 16rpx; padding: 16rpx 20rpx; border: 1rpx solid rgba(66, 107, 88, .32); border-radius: 10rpx; background: rgba(66, 107, 88, .08); color: #426b58; font-size: clamp(14px, 22rpx, 17px); line-height: 1.5; overflow-wrap: anywhere; }
.promotion-card__actions { display: flex; flex-wrap: wrap; justify-content: flex-end; margin-top: 16rpx; gap: 12rpx; }
.promotion-card__actions .app-button { width: auto; min-width: 142rpx; }
@media (max-width: 340px) {
@@ -238,5 +348,10 @@ onUnload(() => {
padding-right: 22rpx;
padding-left: 22rpx;
}
.promotion-card__actions .app-button {
min-width: 0;
flex: 1 1 220rpx;
}
}
</style>
+98 -11
View File
@@ -21,9 +21,6 @@
><text>账号编号</text
><text>{{ profile.userNo || "未提供" }}</text></view
>
<text class="security-note"
>这里显示可查看的账号信息其他安全信息暂不支持查看</text
>
<AppButton block label="修改密码" @click="openPassword" />
<AppButton
type="secondary"
@@ -31,6 +28,14 @@
label="换绑手机号"
@click="openPhone"
/>
<AppButton
type="secondary"
block
:disabled="wechatProviderState !== 'ready' || bindingWechat"
:label="bindingWechat ? '正在绑定微信…' : '绑定微信'"
@click="bindWechat"
/>
<text v-if="wechatBindingMessage" class="security-message">{{ wechatBindingMessage }}</text>
</view>
</view>
</view>
@@ -48,6 +53,7 @@ import {
isRequestCancelled
} from "@/services/api/request-controller.js";
import { profileApi } from "@/services/api/profile-service.js";
import { authApi } from "@/services/api/auth-service.js";
import { getRequestErrorMessage } from "@/services/api/request-error-message.js";
import { openPage, returnTo } from "@/utils/navigation/gateway.js";
@@ -55,6 +61,10 @@ const profile = reactive({ phone: "", userNo: "" });
const loading = ref(false);
const securityProfileError = ref("");
const securityProfileRequestController = createRequestController();
const wechatBindingRequestController = createRequestController();
const wechatProviderState = ref("unknown");
const bindingWechat = ref(false);
const wechatBindingMessage = ref("");
let pageActive = true;
const maskedPhone = computed(() =>
/^\d{7,}$/.test(profile.phone)
@@ -81,10 +91,72 @@ const loadProfile = async () => {
const backToProfile = () => returnTo("M01");
const openPassword = () => openPage("M04", {}, "M03");
const openPhone = () => openPage("M05", {}, "M03");
onShow(loadProfile);
const detectWechatProvider = () => {
// #ifdef APP-PLUS
if (typeof uni?.getProvider !== "function") {
wechatProviderState.value = "unavailable";
wechatBindingMessage.value = "当前设备不支持微信授权";
return;
}
uni.getProvider({
service: "oauth",
success: ({ provider = [] } = {}) => {
if (!pageActive) return;
wechatProviderState.value = provider.includes("weixin") ? "ready" : "unavailable";
if (wechatProviderState.value === "unavailable") {
wechatBindingMessage.value = "当前设备未安装或未配置微信授权";
}
},
fail: () => {
if (!pageActive) return;
wechatProviderState.value = "unavailable";
wechatBindingMessage.value = "暂时无法使用微信授权";
},
});
// #endif
// #ifndef APP-PLUS
wechatProviderState.value = "unavailable";
wechatBindingMessage.value = "请在 App 中绑定微信";
// #endif
};
const requestWechatAuthorizationCode = () =>
new Promise((resolve, reject) => {
uni.login({
provider: "weixin",
onlyAuthorize: true,
success: ({ code } = {}) => resolve(code),
fail: reject,
});
});
const bindWechat = async () => {
if (bindingWechat.value || wechatProviderState.value !== "ready") return;
bindingWechat.value = true;
wechatBindingMessage.value = "";
try {
const code = await requestWechatAuthorizationCode();
await authApi.bindWechat(
{ code },
{ requestController: wechatBindingRequestController },
);
if (pageActive) wechatBindingMessage.value = "微信绑定成功";
} catch (error) {
if (!pageActive || isRequestCancelled(error)) return;
const errorText = String(error?.errMsg || error?.message || "");
wechatBindingMessage.value = /cancel|取消/i.test(errorText)
? "已取消微信绑定"
: getRequestErrorMessage(error, "微信绑定失败,请稍后重试");
} finally {
if (pageActive) bindingWechat.value = false;
}
};
onShow(() => {
void loadProfile();
detectWechatProvider();
});
onUnload(() => {
pageActive = false;
securityProfileRequestController.abort();
wechatBindingRequestController.abort();
});
</script>
@@ -101,7 +173,8 @@ onUnload(() => {
z-index: 1;
}
.page-content {
padding: 18rpx 24rpx 72rpx;
flex: 1;
padding: 18rpx 24rpx calc(72rpx + env(safe-area-inset-bottom));
}
.state-card,
.security-card {
@@ -155,13 +228,27 @@ onUnload(() => {
text-align: right;
overflow-wrap: anywhere;
}
.security-note {
margin-top: 22rpx;
color: $ink-muted;
font-size: clamp(14px, 22rpx, 17px);
line-height: 1.6;
}
.security-card .app-button {
margin-top: 20rpx;
}
.security-message {
margin-top: 16rpx;
color: $ink-muted;
font-size: clamp(14px, 22rpx, 17px);
line-height: 1.5;
text-align: center;
}
@media (max-width: 340px) {
.page-content {
padding-right: 20rpx;
padding-left: 20rpx;
}
.state-card,
.security-card {
padding-right: 30rpx;
padding-left: 30rpx;
}
}
</style>
+4 -4
View File
@@ -399,7 +399,7 @@ onUnload(() => {
}
.page-content {
flex: 1;
padding: 28rpx 30rpx 72rpx;
padding: 28rpx 30rpx calc(72rpx + env(safe-area-inset-bottom));
}
.brand-card {
@include adaptive-profile-summary;
@@ -513,7 +513,7 @@ onUnload(() => {
}
.deactivate-link {
width: auto;
min-height: 54rpx;
min-height: 80rpx;
margin: 16rpx 0 0;
padding: 0;
border: 0;
@@ -550,14 +550,14 @@ onUnload(() => {
.form-row input {
width: auto;
min-width: 0;
min-height: 68rpx;
min-height: 80rpx;
color: $ink;
font-size: clamp(14px, 23rpx, 17px);
}
.code-action {
justify-self: end;
width: 198rpx;
min-height: 68rpx;
min-height: 80rpx;
margin: 0;
padding: 0 14rpx;
box-sizing: border-box;
+235 -6
View File
@@ -36,6 +36,27 @@
<text>可选套餐</text>
<text>{{ capability.enabled ? "购买资格已开放" : "当前仅供查看" }}</text>
</view>
<view v-if="capability.enabled" class="payment-methods">
<text class="payment-methods__title">支付方式</text>
<view class="payment-methods__options" role="radiogroup" aria-label="支付方式">
<button
v-for="method in paymentMethodOptions"
:key="method.method"
class="payment-method"
:class="{ 'payment-method--selected': selectedPaymentMethod === method.method }"
:disabled="!method.available || paymentState === 'submitting'"
role="radio"
:aria-checked="selectedPaymentMethod === method.method"
@click="selectPaymentMethod(method)"
>
<text>{{ method.label }}</text>
<text v-if="!method.available">{{ method.unavailableReason }}</text>
</button>
</view>
<text v-if="!selectedPaymentMethod" class="payment-methods__error"
>当前安装包没有可用支付通道请完成打包配置后再购买</text
>
</view>
<view v-if="packages.length" class="vip-package-list">
<view v-for="item in packages" :key="item.key" class="vip-package">
<view class="vip-package__copy">
@@ -46,6 +67,13 @@
<text>{{ item.price }}</text>
<text v-if="item.originalPrice">原价 {{ item.originalPrice }}</text>
</view>
<AppButton
v-if="capability.enabled"
compact
label="立即购买"
:disabled="paymentState === 'submitting' || !selectedPaymentMethod"
@click="purchasePackage(item)"
/>
</view>
</view>
<view v-else class="vip-empty-state"><text>当前没有可展示的套餐</text></view>
@@ -60,7 +88,9 @@
<view v-for="item in orders" :key="item.key" class="vip-order">
<view>
<text>{{ item.packageName }}</text>
<text v-if="item.paidAt || item.expiresAt">{{ item.paidAt || item.expiresAt }}</text>
<text v-if="item.orderNo">订单号{{ item.orderNo }}</text>
<text v-if="item.paidAt">支付时间{{ item.paidAt }}</text>
<text v-if="item.expiresAt">到期时间{{ item.expiresAt }}</text>
</view>
<view>
<text>{{ item.amount }}</text>
@@ -98,27 +128,67 @@ import {
isRequestCancelled
} from "@/services/api/request-controller.js";
import { vipApi } from "@/services/api/vip-service.js";
import { VIP_PAYMENT_METHOD } from "@/services/api/vip-contract.js";
import { getRequestErrorMessage } from "@/services/api/request-error-message.js";
import { genealogyContext } from "@/utils/genealogy/context.js";
import { createNonIdempotentWriteGuard } from "@/utils/non-idempotent-write-guard.js";
import { handleBackPress, runBackGuard } from "@/utils/navigation/gateway.js";
const packages = ref([]);
const orders = ref([]);
const capability = reactive({ enabled: false, disabledReason: "" });
const capability = reactive({ enabled: false, disabledReason: "", paymentMethods: [] });
const nativePaymentProviders = ref([]);
const selectedPaymentMethod = ref("");
const readState = ref("loading");
const vipReadError = ref("");
const serviceNoticeVisible = ref(false);
const paymentState = ref("idle");
const packageController = createRequestController();
const orderController = createRequestController();
const capabilityController = createRequestController();
const paymentController = createRequestController();
const orderCreationGuard = createNonIdempotentWriteGuard();
let active = true;
const createVipRequestId = () => {
if (typeof globalThis.crypto?.randomUUID === "function") {
return `app-vip-${globalThis.crypto.randomUUID()}`;
}
return `app-vip-${Date.now()}-${Math.random().toString(16).slice(2)}`;
};
const paymentProviderByMethod = Object.freeze({
[VIP_PAYMENT_METHOD.WECHAT]: "wxpay",
[VIP_PAYMENT_METHOD.ALIPAY]: "alipay",
});
const paymentMethodOptions = computed(() =>
capability.paymentMethods.map((method) => {
const requiredProvider = paymentProviderByMethod[method.method];
const clientAvailable =
!requiredProvider || nativePaymentProviders.value.includes(requiredProvider);
return {
...method,
available: method.enabled && clientAvailable,
unavailableReason: method.enabled
? "当前安装包未配置此通道"
: method.disabledReason || "服务端暂未开放",
};
}),
);
const selectedPaymentMethodLabel = computed(
() =>
paymentMethodOptions.value.find(
(method) => method.method === selectedPaymentMethod.value,
)?.label || "",
);
const purchaseSummary = computed(() =>
capability.enabled
? "当前可以购买会员,支付功能正在完成最后确认。"
? selectedPaymentMethodLabel.value
? `当前使用${selectedPaymentMethodLabel.value},支付结果以服务端订单状态为准。`
: "购买资格已开放,但当前安装包没有可用支付通道。"
: capability.disabledReason || "当前可查看套餐和订单,暂时不能在线购买。",
);
const serviceNoticeCopy = computed(() =>
capability.enabled
? "你的账号目前可以购买会员。为避免重复扣款,支付按钮将在确认完成后开放;现在可以先查看套餐和订单。"
? "支付完成后会向服务端核对订单状态;若渠道已扣款但状态仍在处理中,请勿重复下单。余额支付由服务端在同一事务内完成扣款和开通。"
: capability.disabledReason || "当前可查看套餐和已有订单,暂时不能在线购买。",
);
@@ -129,14 +199,22 @@ const loadVipData = async () => {
readState.value = "loading";
vipReadError.value = "";
try {
const [capabilityResult, packageRows, orderRows] = await Promise.all([
const [capabilityResult, packageRows, orderRows, providerIds] = await Promise.all([
vipApi.getVipCapability({ requestController: capabilityController }),
vipApi.getVipPackages({ requestController: packageController }),
vipApi.getVipOrders({ requestController: orderController }),
getNativePaymentProviders(),
]);
if (!active) return;
capability.enabled = capabilityResult.enabled;
capability.disabledReason = capabilityResult.disabledReason;
capability.paymentMethods = capabilityResult.paymentMethods;
nativePaymentProviders.value = providerIds;
const currentSelection = paymentMethodOptions.value.find(
(method) => method.method === selectedPaymentMethod.value && method.available,
);
selectedPaymentMethod.value = currentSelection?.method ||
paymentMethodOptions.value.find((method) => method.available)?.method || "";
packages.value = packageRows;
orders.value = orderRows;
readState.value = "ready";
@@ -146,12 +224,107 @@ const loadVipData = async () => {
readState.value = "error";
}
};
const getNativePaymentProviders = () =>
new Promise((resolve) => {
if (typeof uni?.getProvider !== "function") {
resolve([]);
return;
}
uni.getProvider({
service: "payment",
success: ({ provider = [] } = {}) => resolve(provider),
fail: () => resolve([]),
});
});
const selectPaymentMethod = (method) => {
if (!method.available || paymentState.value === "submitting") return;
selectedPaymentMethod.value = method.method;
};
const openServiceNotice = () => {
serviceNoticeVisible.value = true;
};
const closeServiceNotice = () => {
serviceNoticeVisible.value = false;
};
const requestNativePayment = (paymentOrder) =>
new Promise((resolve, reject) => {
if (!paymentOrder.nativePaymentRequired) {
resolve();
return;
}
uni.requestPayment({
provider: paymentProviderByMethod[paymentOrder.paymentMethod],
orderInfo: paymentOrder.orderInfo,
success: resolve,
fail: reject,
});
});
const isPaymentCancellation = (error) =>
/cancel/i.test(String(error?.errMsg || error?.message || ""));
const purchasePackage = async (vipPackage) => {
if (!capability.enabled || !selectedPaymentMethod.value || paymentState.value === "submitting") return;
const genealogyId = genealogyContext.getCurrentGenealogyId();
const orderPayload = {
packageId: String(vipPackage.id),
genealogyId,
paymentMethod: selectedPaymentMethod.value,
requestId: createVipRequestId(),
};
const orderAttempt = orderCreationGuard.begin(orderPayload);
if (orderAttempt === null) {
uni.showToast({
title: "上次下单结果待确认,请先查看订单,不要重复购买",
icon: "none",
});
return;
}
paymentState.value = "submitting";
let transactionId = "";
try {
const paymentOrder = await vipApi.createVipOrder(
vipPackage.id,
genealogyId,
selectedPaymentMethod.value,
orderPayload.requestId,
{ requestController: paymentController },
);
transactionId = paymentOrder.transactionId;
await requestNativePayment(paymentOrder);
const paymentStatus = await vipApi.getVipPaymentStatus(
paymentOrder.transactionId,
{ requestController: paymentController },
);
uni.showToast({
title: paymentStatus.status === "SUCCESS" ? "购买成功" : "支付结果确认中,请勿重复下单",
icon: paymentStatus.status === "SUCCESS" ? "success" : "none",
});
await loadVipData();
} catch (error) {
if (!active || isRequestCancelled(error)) return;
let feedback = getRequestErrorMessage(error, "支付未完成,请稍后重试。");
if (!transactionId && orderCreationGuard.recordFailure(orderAttempt, error)) {
feedback = "下单结果暂时无法确认,请先查看订单,不要重复购买";
}
if (transactionId && isPaymentCancellation(error)) {
try {
await vipApi.closeVipPayment(transactionId, {
requestController: paymentController,
});
feedback = "已取消支付";
await loadVipData();
} catch (closeError) {
if (isRequestCancelled(closeError)) return;
feedback = "支付已取消,订单关闭状态待确认";
}
}
uni.showToast({
title: feedback,
icon: "none",
});
} finally {
if (active) paymentState.value = "idle";
}
};
const requestBack = () =>
runBackGuard({
transientOpen: serviceNoticeVisible.value,
@@ -165,6 +338,7 @@ onUnload(() => {
capabilityController.abort();
packageController.abort();
orderController.abort();
paymentController.abort();
});
</script>
@@ -181,7 +355,7 @@ onUnload(() => {
}
.page-content {
flex: 1;
padding: 20rpx 30rpx 72rpx;
padding: 20rpx 30rpx calc(72rpx + env(safe-area-inset-bottom));
}
.vip-intro {
position: relative;
@@ -269,6 +443,11 @@ onUnload(() => {
justify-content: space-between;
gap: 16rpx;
}
.vip-section__heading text {
min-width: 0;
overflow-wrap: anywhere;
}
.vip-section__heading text:first-child {
color: $ink;
font-family: STKaiti, KaiTi, serif;
@@ -285,6 +464,36 @@ onUnload(() => {
gap: 14rpx;
margin-top: 18rpx;
}
.payment-methods {
margin-bottom: 24rpx;
padding: 22rpx;
border: 1rpx solid rgba(128, 89, 49, 0.2);
border-radius: 10rpx;
background: rgba(255, 252, 245, 0.7);
}
.payment-methods__title { display: block; color: $ink; font-size: clamp(14px, 24rpx, 17px); font-weight: 700; }
.payment-methods__options { display: flex; flex-wrap: wrap; margin-top: 14rpx; gap: 12rpx; }
.payment-method {
display: flex;
min-height: var(--app-touch-min);
flex: 1 1 180rpx;
flex-direction: column;
align-items: flex-start;
justify-content: center;
padding: 12rpx 18rpx;
border: 1rpx solid rgba(128, 89, 49, 0.28);
border-radius: 8rpx;
background: rgba(255, 255, 255, 0.58);
color: $ink;
text-align: left;
}
.payment-method::after { border: 0; }
.payment-method--selected { border-color: #9e251b; background: rgba(158, 37, 27, 0.08); color: #9e251b; }
.payment-method[disabled] { opacity: 0.56; }
.payment-method text:first-child { font-size: clamp(14px, 23rpx, 17px); font-weight: 700; }
.payment-method text:last-child:not(:first-child),
.payment-methods__error { margin-top: 6rpx; color: $ink-muted; font-size: clamp(12px, 19rpx, 14px); line-height: 1.4; }
.payment-methods__error { display: block; margin-top: 14rpx; color: #9e251b; }
.vip-package,
.vip-order {
display: flex;
@@ -321,6 +530,11 @@ onUnload(() => {
flex: 0 0 auto;
text-align: right;
}
.vip-package > .app-button {
width: auto;
min-width: 132rpx;
flex: 0 0 auto;
}
.vip-package__price text:first-child,
.vip-order > view:last-child text:first-child {
color: $brand-red;
@@ -354,5 +568,20 @@ onUnload(() => {
padding-right: 20rpx;
padding-left: 20rpx;
}
.vip-package {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 14rpx 18rpx;
}
.vip-package > .app-button {
width: 100%;
grid-column: 1 / -1;
}
.vip-order {
align-items: flex-start;
}
}
</style>
+20 -3
View File
@@ -35,9 +35,16 @@
:key="item.id"
class="ceremony-card"
@click="openCeremony(item)"
><view
><image
v-if="item.coverFile?.accessUrl"
class="ceremony-card__cover"
:src="item.coverFile.accessUrl"
mode="aspectFill"
aria-hidden="true"
/><view
><text>{{ item.title }}</text
><text>{{ item.typeLabel }}{{ item.time ? ` · ${item.time}` : "" }}</text
><text v-if="item.createTime">创建于 {{ formatMinuteTimestamp(item.createTime) }}</text
><text v-if="item.description" class="ceremony-card__description">{{ item.description }}</text></view
><text>{{ item.giftCount }} 笔献礼</text></view
></view
@@ -58,6 +65,7 @@ import {
isRequestCancelled
} from "@/services/api/request-controller.js";
import { ceremonyApi } from "@/services/api/ceremony-service.js";
import { formatMinuteTimestamp } from "@/utils/display-time.js";
import { goBack, openPage, returnTo } from "@/utils/navigation/gateway.js";
const genealogyId = ref("");
@@ -120,11 +128,13 @@ onUnload(() => {
z-index: 1;
}
.page-content {
padding: 18rpx 24rpx 72rpx;
flex: 1;
padding: 18rpx 24rpx calc(72rpx + env(safe-area-inset-bottom));
}
.state-card,
.ceremony-card {
@include adaptive-records-content;
background-color: rgba($paper, 0.82);
}
.state-card {
display: flex;
@@ -144,7 +154,7 @@ onUnload(() => {
.ceremony-list {
display: flex;
flex-direction: column;
gap: 16rpx;
gap: 20rpx;
}
.ceremony-card {
display: flex;
@@ -158,6 +168,13 @@ onUnload(() => {
min-width: 0;
flex: 1;
}
.ceremony-card__cover {
width: 150rpx;
height: 118rpx;
flex: 0 0 auto;
border-radius: 10rpx;
background: rgba(128, 89, 49, 0.12);
}
.ceremony-card text {
display: block;
}
+34 -6
View File
@@ -26,6 +26,15 @@
</view>
<view v-else>
<view class="detail-card">
<image
v-if="detail.coverFile?.accessUrl"
class="detail-card__cover"
:src="detail.coverFile.accessUrl"
mode="aspectFill"
role="button"
aria-label="查看礼仪活动封面"
@click="previewCover"
/>
<text>{{ detail.title }}</text>
<text>{{ detail.typeLabel }}{{ detail.time ? ` · ${detail.time}` : "" }}</text>
<text v-if="detail.location || detail.locationAddress">地点{{ detail.location || detail.locationAddress }}</text>
@@ -165,9 +174,9 @@
:visible="ceremonyDeleteConfirmVisible"
:close-on-mask="false"
eyebrow="删除确认"
title="删除这项礼仪活动?"
message="活动和相关内容会一并删除,删除后无法恢复。"
confirm-text="确认删除"
title="这项礼仪活动移至回收站"
message="活动和相关内容将不再展示,家谱管理员可在保留期内恢复。"
confirm-text="移至回收站"
cancel-text="保留活动"
show-cancel
@confirm="deleteCeremony"
@@ -258,6 +267,11 @@ const valid = computed(
() =>
/^[1-9]\d*$/.test(genealogyId.value) && /^[1-9]\d*$/.test(ceremonyId.value),
);
const previewCover = () => {
const url = detail.value?.coverFile?.accessUrl;
if (!url || typeof uni?.previewImage !== "function") return;
uni.previewImage({ current: url, urls: [url] });
};
const giftSnapshot = computed(() => JSON.stringify(giftForm));
const giftDirty = computed(
() => giftFormVisible.value && giftSnapshot.value !== giftBaseline.value,
@@ -337,7 +351,12 @@ const toggleGiftForm = async () => {
return true;
};
const requestGiftSubmit = () => {
giftError.value = giftForm.giftAmount.trim() ? "" : "请填写献礼金额";
const giftAmount = giftForm.giftAmount.trim();
giftError.value = !giftAmount
? "请填写献礼金额"
: /^(?:0|[1-9]\d{0,9})(?:\.\d{1,2})?$/.test(giftAmount)
? ""
: "献礼金额应为 0 至 9999999999.99,最多保留两位小数";
if (!giftError.value && !giftSubmitting.value) giftConfirmVisible.value = true;
};
const confirmGiftSubmit = async () => {
@@ -544,7 +563,8 @@ onUnload(() => {
z-index: 1;
}
.page-content {
padding: 18rpx 24rpx 72rpx;
flex: 1;
padding: 18rpx 24rpx calc(72rpx + env(safe-area-inset-bottom));
}
.state-card,
.detail-card,
@@ -552,11 +572,19 @@ onUnload(() => {
.gift-form-card,
.gift-result {
@include adaptive-records-content;
background-color: rgba($paper, 0.82);
}
.gift-form-card {
margin-top: 18rpx;
padding: 34rpx 32rpx;
}
.detail-card__cover {
width: 100%;
height: 320rpx;
margin-bottom: 22rpx;
border-radius: 10rpx;
background: rgba(128, 89, 49, 0.12);
}
.gift-form-card__title,
.gift-form-card__note,
.gift-field > text,
@@ -604,7 +632,7 @@ onUnload(() => {
}
.gift-field input {
width: auto;
min-height: 70rpx;
min-height: 80rpx;
text-align: right;
}
.gift-message-field {
+22 -2
View File
@@ -107,6 +107,7 @@
<text v-if="coverFileName" class="upload-receipt"
>已上传{{ coverFileName }}</text
>
<button v-if="coverOssId" class="remove-cover-button" :disabled="uploading || submitting" @click="clearCover">移除封面</button>
</view>
<text v-if="uploadError" class="form-error">{{ uploadError }}</text>
<text v-if="submitError" class="form-error">{{ submitError }}</text>
@@ -370,6 +371,12 @@ const uploadCover = async () => {
if (pageActive) uploading.value = false;
}
};
const clearCover = () => {
if (uploading.value || submitting.value) return;
coverOssId.value = null;
coverFileName.value = "";
uploadError.value = "";
};
const saveCeremony = async () => {
if (uploading.value || submitting.value || !hasValidContext.value) return;
if (!form.ceremonyType.trim() || !form.ceremonyTitle.trim()) {
@@ -382,7 +389,7 @@ const saveCeremony = async () => {
const payload = {
...ceremonyForm,
ceremonyTime: ceremonyTime.value,
...(coverOssId.value ? { coverOssId: coverOssId.value } : {}),
coverOssId: coverOssId.value,
...(isEdit.value ? preservedUpdateFields.value : {}),
};
const createAttempt = isEdit.value ? null : ceremonyCreateGuard.begin(payload);
@@ -463,12 +470,13 @@ onUnload(() => {
}
.page-content {
flex: 1;
padding: 22rpx 28rpx 72rpx;
padding: 22rpx 28rpx calc(72rpx + env(safe-area-inset-bottom));
}
.editor-card,
.state-card {
@include adaptive-records-content;
box-sizing: border-box;
background-color: rgba($paper, 0.82);
}
.editor-card {
padding: 38rpx 32rpx 42rpx;
@@ -593,6 +601,18 @@ onUnload(() => {
font-size: clamp(13px, 21rpx, 16px);
overflow-wrap: anywhere;
}
.remove-cover-button {
justify-self: start;
min-height: 72rpx;
margin: 0;
padding: 0 18rpx;
border: 1rpx solid rgba($brand-red, 0.38);
border-radius: 8rpx;
background: transparent;
color: $brand-red;
font-size: clamp(13px, 21rpx, 16px);
}
.remove-cover-button::after { border: 0; }
.form-error {
margin-top: 10rpx;
color: $brand-red;
+87 -19
View File
@@ -13,7 +13,7 @@
<view class="page-content">
<view v-if="view === 'form'" class="form-card">
<text>{{ isEdit ? '编辑成长记录' : '新建成长记录' }}</text>
<text class="form-copy">{{ isEdit ? '原有图片和相关设置会保留。' : '红色 * 为必填项,其余内容可按需补充。' }}</text>
<text class="form-copy">{{ isEdit ? '原有图片、视频和相关设置会保留,也可以逐项移除。' : '红色 * 为必填项,其余内容可按需补充。' }}</text>
<view class="field field--picker">
<text>关联人物</text>
<picker
@@ -106,23 +106,32 @@
>
<view class="upload-field">
<view
><text>相关图片</text
><text>相关图片或视频</text
><text
>图片上传成功后会随这条记录一起保存</text
>媒体上传成功后会随这条记录一起保存</text
></view
>
<view class="upload-actions">
<button
class="upload-button"
:disabled="uploading || submitting"
@click="uploadImage"
>
{{ uploading ? "上传中…" : "添加图片" }}
</button>
<text
>添加图片</button>
<button
class="upload-button"
:disabled="uploading || submitting"
@click="uploadVideo"
>添加视频</button>
</view>
<text v-if="uploading">正在上传媒体</text>
<view
v-for="(receipt, index) in mediaReceipts"
:key="`${receipt.ossId}-${index}`"
>已上传{{ receipt.fileName || "图片" }}</text
class="upload-receipt"
>
<text>已上传{{ receipt.fileName || "媒体文件" }}</text>
<button :disabled="uploading || submitting" @click="removeMedia(index)">移除</button>
</view>
<text v-if="uploadError" class="error">{{ uploadError }}</text>
</view>
<text v-if="error" class="error">{{ error }}</text>
@@ -184,6 +193,7 @@
><text
>{{ growthRecordTypeLabel(item)
}}{{ item.date ? ` · ${item.date}` : "" }}</text
><text v-if="item.createTime">创建于 {{ formatMinuteTimestamp(item.createTime) }}</text
><text v-if="item.content">{{ item.content }}</text></view
>
<AppButton
@@ -222,9 +232,9 @@
/>
<AppDialog
:visible="deleteConfirmationVisible"
title="删除这条成长记录?"
message="删除后无法恢复,请确认当前内容不再需要。"
confirm-text="确认删除"
title="这条成长记录移至回收站"
message="移入回收站后不再展示,家谱管理员可在保留期内恢复。"
confirm-text="移至回收站"
cancel-text="保留记录"
show-cancel
:close-on-mask="false"
@@ -252,10 +262,13 @@ import { lifeRecordApi } from "@/services/api/life-record-service.js";
import { lineageApi } from "@/services/api/lineage-service.js";
import { getRequestErrorMessage } from "@/services/api/request-error-message.js";
import { createDiscardConfirmation } from "@/utils/discard-confirmation.js";
import { formatMinuteTimestamp } from "@/utils/display-time.js";
import { createNonIdempotentWriteGuard } from "@/utils/non-idempotent-write-guard.js";
import {
isImagePickCancelled,
isVideoPickCancelled,
pickAndUploadImage,
pickAndUploadVideo,
} from "@/utils/media-upload.js";
import { goBack, handleBackPress, runBackGuard } from "@/utils/navigation/gateway.js";
@@ -358,7 +371,11 @@ const formSnapshot = computed(() =>
const dirty = computed(() =>
isEdit.value
? Boolean(formBaseline.value) && formSnapshot.value !== formBaseline.value
: Object.values(form).some((value) => String(value).trim()) ||
: Object.entries(form).some(
([field, value]) =>
field !== "lineagePersonId" && String(value).trim(),
) ||
form.lineagePersonId !== defaultLineagePersonId.value ||
mediaReceipts.value.length > 0,
);
const confirmation = createDiscardConfirmation((visible) => {
@@ -636,6 +653,28 @@ const saveGrowthRecord = async () => {
if (pageActive) submitting.value = false;
}
};
const uploadVideo = async () => {
if (uploading.value || submitting.value) return;
uploading.value = true;
uploadError.value = "";
try {
const uploadReceipt = await pickAndUploadVideo({
requestController: growthMediaUploadRequestController,
});
if (!pageActive) return;
mediaReceipts.value = [...mediaReceipts.value, uploadReceipt];
} catch (cause) {
if (pageActive && !isVideoPickCancelled(cause) && !isRequestCancelled(cause))
uploadError.value = getRequestErrorMessage(cause, "视频上传失败,请稍后重试。");
} finally {
if (pageActive) uploading.value = false;
}
};
const removeMedia = (index) => {
if (uploading.value || submitting.value) return;
mediaReceipts.value = mediaReceipts.value.filter((_, receiptIndex) => receiptIndex !== index);
uploadError.value = "";
};
const requestDeleteRecord = (record) => {
if (!record?.canDelete || deleting.value) return;
deleteError.value = "";
@@ -726,12 +765,14 @@ onUnload(() => {
z-index: 1;
}
.page-content {
padding: 18rpx 24rpx 72rpx;
flex: 1;
padding: 18rpx 24rpx calc(72rpx + env(safe-area-inset-bottom));
}
.form-card,
.state-card,
.record-card {
@include adaptive-records-content;
background-color: rgba($paper, 0.82);
}
.form-card {
box-sizing: border-box;
@@ -773,7 +814,7 @@ onUnload(() => {
@include adaptive-records-field;
box-sizing: border-box;
width: 100%;
min-height: 76rpx;
min-height: 80rpx;
padding: 16rpx 22rpx;
color: $ink;
font-size: clamp(14px, 23rpx, 17px);
@@ -801,15 +842,15 @@ onUnload(() => {
padding: 18rpx 22rpx;
@include adaptive-records-field;
}
.upload-field > view > text {
.upload-field > view:first-child > text {
display: block;
}
.upload-field > view > text:first-child {
.upload-field > view:first-child > text:first-child {
color: $ink;
font-size: clamp(14px, 23rpx, 17px);
font-weight: 700;
}
.upload-field > view > text:last-child {
.upload-field > view:first-child > text:last-child {
margin-top: 6rpx;
color: $ink-muted;
font-size: clamp(13px, 20rpx, 16px);
@@ -875,13 +916,40 @@ onUnload(() => {
.record-list {
display: flex;
flex-direction: column;
gap: 16rpx;
gap: 20rpx;
}
.save-notice {
display: block;
color: $brand-red;
font-size: clamp(14px, 22rpx, 17px);
}
.upload-actions {
display: flex;
flex-wrap: wrap;
gap: 12rpx;
}
.upload-receipt {
display: flex;
align-items: center;
gap: 12rpx;
}
.upload-receipt > text {
min-width: 0;
flex: 1;
color: $ink-muted;
font-size: clamp(13px, 21rpx, 16px);
overflow-wrap: anywhere;
}
.upload-receipt > button {
min-height: 72rpx;
margin: 0;
padding: 0 16rpx;
border: 0;
background: transparent;
color: $brand-red;
font-size: clamp(13px, 20rpx, 16px);
}
.upload-receipt > button::after { border: 0; }
.delete-error {
display: block;
color: $brand-red;
@@ -892,7 +960,7 @@ onUnload(() => {
@include adaptive-records-field;
display: flex;
box-sizing: border-box;
min-height: 78rpx;
min-height: 80rpx;
align-items: center;
justify-content: space-between;
padding: 14rpx 18rpx;
+6 -4
View File
@@ -444,12 +444,14 @@ onUnload(() => {
z-index: 1;
}
.page-content {
padding: 18rpx 24rpx 72rpx;
flex: 1;
padding: 18rpx 24rpx calc(72rpx + env(safe-area-inset-bottom));
}
.form-card,
.state-card,
.event-card {
@include adaptive-records-content;
background-color: rgba($paper, 0.82);
}
.form-card {
box-sizing: border-box;
@@ -491,7 +493,7 @@ onUnload(() => {
@include adaptive-records-field;
box-sizing: border-box;
width: 100%;
min-height: 76rpx;
min-height: 80rpx;
padding: 16rpx 22rpx;
color: $ink;
font-size: clamp(14px, 23rpx, 17px);
@@ -586,7 +588,7 @@ onUnload(() => {
.event-list {
display: flex;
flex-direction: column;
gap: 16rpx;
gap: 20rpx;
}
.save-notice {
display: block;
@@ -619,5 +621,5 @@ onUnload(() => {
justify-content: flex-end;
margin-top: 18rpx;
}
.event-card__actions .app-button { width: 154rpx; min-height: 66rpx; }
.event-card__actions .app-button { width: 154rpx; min-height: 80rpx; }
</style>
+42 -28
View File
@@ -3,7 +3,7 @@
<ModulePageBackground module="records" />
<view class="page-header"
><PageHeader
title="家族备忘"
:title="pageTitle"
:action="valid && view === 'list' ? '新建' : ''"
custom-back
@back="requestBack"
@@ -11,14 +11,14 @@
/></view>
<view class="page-content">
<view v-if="view === 'form'" class="form-card">
<text>{{ isEdit ? '编辑家族备忘' : '新建家族备忘' }}</text>
<text>{{ isEdit ? `编辑${recordName}` : `新建${recordName}` }}</text>
<text class="form-copy">{{ isEdit ? '原有关联图片、完成状态和排序会随本次保存保留。' : '红色 * 为必填项,其余内容可按需补充。' }}</text>
<view class="field"
><text><text class="required-mark">*</text>备忘标题</text
><text><text class="required-mark">*</text>{{ isBenefactorMode ? "恩人姓名" : "备忘标题" }}</text
><input
v-model="form.memoTitle"
maxlength="40"
placeholder="请输入备忘标题"
:placeholder="isBenefactorMode ? '请输入恩人姓名' : '请输入备忘标题'"
@input="error = ''"
/></view>
<picker mode="date" :value="form.remindDate" @change="selectRemindDate"
@@ -37,12 +37,12 @@
></picker
>
<view class="field field--textarea"
><text>备忘内容</text
><text>{{ isBenefactorMode ? "恩人事迹" : "备忘内容" }}</text
><textarea
v-model="form.memoContent"
auto-height
maxlength="1200"
placeholder="记录需要提醒的事情"
:placeholder="isBenefactorMode ? '记录恩人事迹与家族渊源' : '记录需要提醒的事情'"
@input="error = ''"
/>
</view>
@@ -50,7 +50,7 @@
<view
><text>相关图片</text
><text
>图片上传成功后会随备忘一起保存</text
>图片上传成功后会随{{ recordName }}一起保存</text
></view
>
<button
@@ -74,34 +74,35 @@
label="取消"
@click="cancelCreate" /><AppButton
:disabled="submitting || uploading"
:label="submitting ? '正在提交' : '提交备忘'"
:label="submitting ? '正在提交' : `提交${recordName}`"
@click="saveMemo"
/></view>
</view>
<view v-else-if="!valid" class="state-card"
><text>暂时无法打开家族备忘</text
><text>暂时无法打开{{ recordName }}</text
><AppButton block label="返回上一页" @click="requestBack"
/></view>
<view v-else-if="listState === 'loading'" class="state-card"
><AppLoading text="正在读取家族备忘"
><AppLoading :text="`正在读取${recordName}`"
/></view>
<view v-else-if="listState === 'error'" class="state-card"
><text>暂时无法读取家族备忘</text
><text>暂时无法读取{{ recordName }}</text
><AppButton block type="secondary" label="重新加载" @click="loadMemos"
/></view>
<view v-else-if="listState === 'empty'" class="state-card"
><text>还没有家族备忘</text
><AppButton block label="新建备忘" @click="openCreate"
><text>还没有{{ recordName }}</text
><AppButton block :label="`新建${recordName}`" @click="openCreate"
/></view>
<view v-else class="memo-list">
<text v-if="saveNotice" class="save-notice"
>备忘已保存</text
>{{ recordName }}已保存</text
>
<text v-if="deleteError" class="delete-error">{{ deleteError }}</text>
<view v-for="item in memos" :key="item.id" class="memo-card" role="button" :aria-label="`查看${item.title}详情`" @click="openMemoDetail(item)">
<view class="memo-card__copy">
<text>{{ item.title }}</text>
<text v-if="item.remindTime">{{ item.remindTime }}</text>
<text v-if="item.createTime">创建于 {{ formatMinuteTimestamp(item.createTime) }}</text>
<text v-if="item.content" class="memo-card__content">{{ item.content }}</text>
</view>
<view v-if="item.canEdit || item.canDelete" class="memo-card__actions">
@@ -125,29 +126,30 @@
</view>
<AppDialog
:visible="Boolean(detailTarget)"
eyebrow="备忘详情"
:title="detailTarget?.title || '家族备忘'"
:eyebrow="`${recordName}详情`"
:title="detailTarget?.title || recordName"
confirm-text="关闭"
:close-on-mask="detailState !== 'loading'"
@confirm="closeMemoDetail"
@cancel="closeMemoDetail"
>
<view class="detail-content">
<AppLoading v-if="detailState === 'loading'" text="正在读取完整备忘" />
<AppLoading v-if="detailState === 'loading'" :text="`正在读取完整${recordName}`" />
<text v-else-if="detailState === 'error'" class="detail-error">{{ detailError }}</text>
<template v-else>
<text>提醒时间{{ detailTarget?.remindTime || "未设置" }}</text>
<text>完成状态{{ detailTarget?.completed === "1" ? "已完成" : "未完成" }}</text>
<text class="detail-content__body">{{ detailTarget?.content || "未填写备忘内容" }}</text>
<text v-if="detailTarget?.createTime">创建时间{{ formatMinuteTimestamp(detailTarget.createTime) }}</text>
<text class="detail-content__body">{{ detailTarget?.content || `未填写${recordName}内容` }}</text>
<view v-if="detailTarget?.mediaFiles?.length" class="detail-media">
<image v-for="file in detailTarget.mediaFiles" :key="file.fileId" :src="file.accessUrl" mode="aspectFill" role="button" aria-label="查看备忘图片" @click="previewDetailMedia(file)" />
<image v-for="file in detailTarget.mediaFiles" :key="file.fileId" :src="file.accessUrl" mode="aspectFill" role="button" :aria-label="`查看${recordName}图片`" @click="previewDetailMedia(file)" />
</view>
</template>
</view>
</AppDialog>
<AppDialog
:visible="discardVisible"
title="放弃家族备忘?"
:title="`放弃${recordName}`"
message="尚未提交的内容将被清除。"
confirm-text="放弃并返回"
cancel-text="继续填写"
@@ -158,10 +160,10 @@
/>
<AppDialog
:visible="deleteConfirmationVisible"
title="删除这条家族备忘?"
message="删除后无法恢复,请确认当前内容不再需要。"
confirm-text="确认删除"
cancel-text="保留备忘"
:title="`将这条${recordName}移至回收站?`"
message="移入回收站后不再展示,家谱管理员可在保留期内恢复。"
confirm-text="移至回收站"
:cancel-text="`保留${recordName}`"
show-cancel
:close-on-mask="false"
@confirm="deleteMemo"
@@ -182,9 +184,11 @@ import {
createRequestController,
isRequestCancelled
} from "@/services/api/request-controller.js";
import { MEMO_TYPE } from "@/services/api/life-record-contract.js";
import { lifeRecordApi } from "@/services/api/life-record-service.js";
import { getRequestErrorMessage } from "@/services/api/request-error-message.js";
import { createDiscardConfirmation } from "@/utils/discard-confirmation.js";
import { formatMinuteTimestamp } from "@/utils/display-time.js";
import { createNonIdempotentWriteGuard } from "@/utils/non-idempotent-write-guard.js";
import {
isImagePickCancelled,
@@ -193,6 +197,7 @@ import {
import { goBack, handleBackPress, runBackGuard } from "@/utils/navigation/gateway.js";
const genealogyId = ref("");
const memoType = ref(MEMO_TYPE.GENERAL);
const view = ref("list");
const listState = ref("loading");
const memos = ref([]);
@@ -228,6 +233,9 @@ const memoDetailRequestController = createRequestController();
const memoCreateGuard = createNonIdempotentWriteGuard();
let pageActive = true;
const valid = computed(() => /^[1-9]\d*$/.test(genealogyId.value));
const isBenefactorMode = computed(() => memoType.value === MEMO_TYPE.BENEFACTOR);
const recordName = computed(() => isBenefactorMode.value ? "家族恩人" : "家族备忘");
const pageTitle = computed(() => recordName.value);
const isEdit = computed(() => Boolean(editingMemo.value));
const remindTime = computed(() =>
form.remindDate ? `${form.remindDate} ${form.remindClock || "00:00"}:00` : "",
@@ -276,7 +284,7 @@ const loadMemos = async () => {
requestController: memoListRequestController,
});
if (!pageActive) return;
memos.value = rows;
memos.value = rows.filter((memo) => memo.memoType === memoType.value);
listState.value = memos.value.length ? "ready" : "empty";
if (pendingMemoId.value) {
const target = memos.value.find((item) => item.id === pendingMemoId.value);
@@ -413,6 +421,7 @@ const saveMemo = async () => {
return;
}
const payload = {
memoType: memoType.value,
memoTitle: form.memoTitle,
remindTime: remindTime.value,
memoContent: form.memoContent,
@@ -510,6 +519,9 @@ const requestBack = () =>
onLoad((query) => {
genealogyId.value = String(query?.genealogyId || "");
memoType.value = query?.memoType === MEMO_TYPE.BENEFACTOR
? MEMO_TYPE.BENEFACTOR
: MEMO_TYPE.GENERAL;
pendingMemoId.value = /^[1-9]\d*$/.test(String(query?.memoId || "")) ? String(query.memoId) : "";
if (valid.value) loadMemos();
else listState.value = "invalid";
@@ -544,12 +556,14 @@ onUnload(() => {
z-index: 1;
}
.page-content {
padding: 18rpx 24rpx 72rpx;
flex: 1;
padding: 18rpx 24rpx calc(72rpx + env(safe-area-inset-bottom));
}
.form-card,
.state-card,
.memo-card {
@include adaptive-records-content;
background-color: rgba($paper, 0.82);
}
.form-card {
box-sizing: border-box;
@@ -674,7 +688,7 @@ onUnload(() => {
.memo-list {
display: flex;
flex-direction: column;
gap: 16rpx;
gap: 20rpx;
}
.save-notice {
display: block;
@@ -707,7 +721,7 @@ onUnload(() => {
}
.memo-card__actions .app-button {
width: 140rpx;
min-height: 68rpx;
min-height: 80rpx;
}
.memo-card text {
display: block;
+133 -22
View File
@@ -59,13 +59,27 @@
}}</view></picker
></view
>
<view class="upload-field">
<view>
<text>相关图片</text>
<text>图片上传成功后会随这条功德记录一起保存</text>
</view>
<button class="upload-button" :disabled="uploading || submitting" @click="uploadImage">
{{ uploading ? "上传中…" : "添加图片" }}
</button>
<view v-for="(receipt, index) in mediaReceipts" :key="`${receipt.ossId}-${index}`" class="upload-receipt">
<text>已上传{{ receipt.fileName || "图片" }}</text>
<button :disabled="uploading || submitting" @click="removeImage(index)">移除</button>
</view>
<text v-if="uploadError" class="error">{{ uploadError }}</text>
</view>
<text v-if="error" class="error">{{ error }}</text>
<view class="form-actions"
><AppButton
type="secondary"
label="取消"
@click="cancelCreate" /><AppButton
:disabled="submitting"
:disabled="submitting || uploading"
:label="submitting ? '正在提交' : isEdit ? '保存修改' : '提交功德记录'"
@click="saveMeritRecord"
/></view>
@@ -98,6 +112,7 @@
<text>{{ item.title }}</text>
<text>{{ item.donor }}{{ item.typeLabel ? ` · ${item.typeLabel}` : "" }}</text>
<text v-if="item.time">{{ item.time }}</text>
<text v-if="item.createTime">创建于 {{ formatMinuteTimestamp(item.createTime) }}</text>
<text v-if="item.content">{{ item.content }}</text>
</view>
<view class="merit-card__amount">
@@ -136,8 +151,20 @@
<text>捐赠人{{ detailTarget?.donor || "未署名" }}</text>
<text>功德类型{{ detailTarget?.typeLabel || "未填写" }}</text>
<text>记录时间{{ detailTarget?.time || "未填写" }}</text>
<text v-if="detailTarget?.createTime">创建时间{{ formatMinuteTimestamp(detailTarget.createTime) }}</text>
<text>金额¥{{ detailTarget?.amount || "0.00" }}</text>
<text class="detail-content__body">{{ detailTarget?.content || "未填写记录内容" }}</text>
<view v-if="detailTarget?.mediaFiles?.length" class="detail-media">
<image
v-for="file in detailTarget.mediaFiles"
:key="file.fileId"
:src="file.accessUrl"
mode="aspectFill"
role="button"
:aria-label="file.fileName || '查看功德记录图片'"
@click="previewMeritImage(file)"
/>
</view>
</template>
</view>
</AppDialog>
@@ -154,9 +181,9 @@
/>
<AppDialog
:visible="deleteConfirmationVisible"
title="删除这笔功德记录?"
message="删除后无法恢复,请确认当前内容不再需要。"
confirm-text="确认删除"
title="这笔功德记录移至回收站"
message="移入回收站后不再展示,家谱管理员可在保留期内恢复。"
confirm-text="移至回收站"
cancel-text="保留记录"
show-cancel
:close-on-mask="false"
@@ -174,9 +201,7 @@ import AppDialog from "@/components/AppDialog.vue";
import AppLoading from "@/components/AppLoading.vue";
import ModulePageBackground from "@/components/ModulePageBackground.vue";
import PageHeader from "@/components/PageHeader.vue";
import {
MERIT_TYPE_OPTIONS as meritTypeOptions
} from "@/services/api/life-record-contract.js";
import { businessDictionaryApi } from "@/services/api/business-dictionary-service.js";
import {
createRequestController,
isRequestCancelled
@@ -184,16 +209,25 @@ import {
import { lifeRecordApi } from "@/services/api/life-record-service.js";
import { getRequestErrorMessage } from "@/services/api/request-error-message.js";
import { createDiscardConfirmation } from "@/utils/discard-confirmation.js";
import { formatMinuteTimestamp } from "@/utils/display-time.js";
import { createNonIdempotentWriteGuard } from "@/utils/non-idempotent-write-guard.js";
import {
isImagePickCancelled,
pickAndUploadImage,
} from "@/utils/media-upload.js";
import { goBack, handleBackPress, runBackGuard } from "@/utils/navigation/gateway.js";
const genealogyId = ref("");
const meritTypeOptions = ref([]);
const view = ref("list");
const listState = ref("loading");
const merits = ref([]);
const saveNotice = ref("");
const submitting = ref(false);
const uploading = ref(false);
const error = ref("");
const uploadError = ref("");
const mediaReceipts = ref([]);
const discardVisible = ref(false);
const deleteConfirmationVisible = ref(false);
const deleteTarget = ref(null);
@@ -224,15 +258,18 @@ const meritEditorDetailRequestController = createRequestController();
const meritSaveRequestController = createRequestController();
const meritDeletionRequestController = createRequestController();
const meritDetailRequestController = createRequestController();
const meritImageUploadRequestController = createRequestController();
const meritTypeRequestController = createRequestController();
const meritRecordCreateGuard = createNonIdempotentWriteGuard();
let pageActive = true;
const valid = computed(() => /^[1-9]\d*$/.test(genealogyId.value));
const isEdit = computed(() => Boolean(editingMerit.value));
const formSnapshot = computed(() => JSON.stringify(form));
const mediaOssIds = computed(() => mediaReceipts.value.map((item) => item.ossId).join(","));
const formSnapshot = computed(() => JSON.stringify({ ...form, mediaOssIds: mediaOssIds.value }));
const dirty = computed(() =>
isEdit.value
? Boolean(formBaseline.value) && formSnapshot.value !== formBaseline.value
: Object.values(form).some((value) => String(value).trim()),
: Object.values(form).some((value) => String(value).trim()) || mediaReceipts.value.length > 0,
);
const meritTime = computed(() =>
form.meritDate ? `${form.meritDate} ${form.meritClock || "00:00"}:00` : "",
@@ -240,11 +277,11 @@ const meritTime = computed(() =>
const meritTypeIndex = computed(() =>
Math.max(
0,
meritTypeOptions.findIndex((item) => item.value === form.type),
meritTypeOptions.value.findIndex((item) => item.value === form.type),
),
);
const meritTypeLabel = computed(
() => meritTypeOptions.find((item) => item.value === form.type)?.label || "",
() => meritTypeOptions.value.find((item) => item.value === form.type)?.label || "",
);
const addCurrencyAmounts = (left, right) => {
const [leftWhole, leftFraction] = left.split(".");
@@ -285,9 +322,11 @@ const resetForm = () => {
meritClock: "",
content: "",
});
mediaReceipts.value = [];
editingMerit.value = null;
formBaseline.value = "";
error.value = "";
uploadError.value = "";
};
const loadMerits = async () => {
if (!valid.value) return;
@@ -305,8 +344,23 @@ const loadMerits = async () => {
listState.value = "error";
}
};
const openCreate = () => {
const loadMeritTypes = async () => {
meritTypeRequestController.abort();
meritTypeOptions.value = await businessDictionaryApi.getBusinessDictionaryOptions(
"gen_merit_type",
{ requestController: meritTypeRequestController },
);
};
const openCreate = async () => {
if (!valid.value) return;
if (!meritTypeOptions.value.length) {
try {
await loadMeritTypes();
} catch (cause) {
if (!isRequestCancelled(cause)) error.value = getRequestErrorMessage(cause, "功德类型暂时无法读取。");
return;
}
}
saveNotice.value = "";
resetForm();
view.value = "form";
@@ -329,7 +383,7 @@ const openEditMerit = async (merit) => {
!detail.canEdit ||
!detail.donor ||
!detail.title ||
!meritTypeOptions.some((item) => item.value === detail.type) ||
!meritTypeOptions.value.some((item) => item.value === detail.type) ||
!detail.amount ||
!["0", "1"].includes(detail.status) ||
!Number.isSafeInteger(detail.sortOrder)
@@ -347,6 +401,10 @@ const openEditMerit = async (merit) => {
meritClock: timeParts.clock,
content: detail.content,
});
mediaReceipts.value = detail.mediaFiles.map((file) => ({
ossId: String(file.ossId),
fileName: file.fileName,
}));
editingMerit.value = {
id: detail.id,
sortOrder: detail.sortOrder,
@@ -385,6 +443,11 @@ const closeMeritDetail = () => {
detailState.value = "idle";
detailError.value = "";
};
const previewMeritImage = (file) => {
const urls = detailTarget.value?.mediaFiles?.map((mediaFile) => mediaFile.accessUrl).filter(Boolean) || [];
if (!file?.accessUrl || !urls.length || typeof uni?.previewImage !== "function") return;
uni.previewImage({ current: file.accessUrl, urls });
};
const cancelCreate = () => {
resetForm();
view.value = "list";
@@ -398,11 +461,33 @@ const selectMeritClock = (event) => {
error.value = "";
};
const selectMeritType = (event) => {
form.type = meritTypeOptions[Number(event.detail.value)]?.value || "";
form.type = meritTypeOptions.value[Number(event.detail.value)]?.value || "";
error.value = "";
};
const uploadImage = async () => {
if (uploading.value || submitting.value) return;
uploading.value = true;
uploadError.value = "";
try {
const receipt = await pickAndUploadImage({
requestController: meritImageUploadRequestController,
});
if (!pageActive) return;
mediaReceipts.value = [...mediaReceipts.value, receipt];
} catch (cause) {
if (pageActive && !isImagePickCancelled(cause) && !isRequestCancelled(cause))
uploadError.value = getRequestErrorMessage(cause, "图片上传失败,请稍后重试。");
} finally {
if (pageActive) uploading.value = false;
}
};
const removeImage = (index) => {
if (uploading.value || submitting.value) return;
mediaReceipts.value = mediaReceipts.value.filter((_, receiptIndex) => receiptIndex !== index);
uploadError.value = "";
};
const saveMeritRecord = async () => {
if (submitting.value || !valid.value) return;
if (submitting.value || uploading.value || !valid.value) return;
const donorName = form.donor.trim();
const meritTitle = form.title.trim();
const amountText = form.amount.trim();
@@ -410,8 +495,8 @@ const saveMeritRecord = async () => {
error.value = !donorName ? "请填写捐赠人" : "请填写功德标题";
return;
}
if (amountText && !Number.isFinite(Number(amountText))) {
error.value = "金额必须是数字";
if (amountText && !/^(?:0|[1-9]\d{0,9})(?:\.\d{1,2})?$/.test(amountText)) {
error.value = "金额应为 0 至 9999999999.99,最多保留两位小数";
return;
}
const payload = {
@@ -420,6 +505,7 @@ const saveMeritRecord = async () => {
meritType: form.type,
meritContent: form.content,
meritTime: meritTime.value,
mediaOssIds: mediaOssIds.value,
...(amountText ? { amount: Number(amountText) } : {}),
...(editingMerit.value
? {
@@ -521,7 +607,10 @@ const requestBack = () =>
onLoad((query) => {
genealogyId.value = String(query?.genealogyId || "");
if (valid.value) loadMerits();
if (valid.value) {
void loadMeritTypes().catch(() => {});
loadMerits();
}
else listState.value = "invalid";
});
onShow(() => {
@@ -536,6 +625,8 @@ onUnload(() => {
meritSaveRequestController.abort();
meritDeletionRequestController.abort();
meritDetailRequestController.abort();
meritImageUploadRequestController.abort();
meritTypeRequestController.abort();
confirmation.dispose();
});
</script>
@@ -553,12 +644,14 @@ onUnload(() => {
z-index: 1;
}
.page-content {
padding: 18rpx 24rpx 72rpx;
flex: 1;
padding: 18rpx 24rpx calc(72rpx + env(safe-area-inset-bottom));
}
.form-card,
.state-card,
.merit-card {
@include adaptive-records-content;
background-color: rgba($paper, 0.82);
}
.form-card {
box-sizing: border-box;
@@ -600,7 +693,7 @@ onUnload(() => {
@include adaptive-records-field;
box-sizing: border-box;
width: 100%;
min-height: 76rpx;
min-height: 80rpx;
padding: 16rpx 22rpx;
color: $ink;
font-size: clamp(14px, 23rpx, 17px);
@@ -614,6 +707,22 @@ onUnload(() => {
.field--picker .placeholder {
color: $ink-muted;
}
.upload-field {
display: grid;
gap: 12rpx;
margin-top: 16rpx;
padding: 18rpx 22rpx;
@include adaptive-records-field;
}
.upload-field > view:first-child > text { display: block; }
.upload-field > view:first-child > text:first-child { color: $ink; font-size: clamp(14px, 23rpx, 17px); font-weight: 700; }
.upload-field > view:first-child > text:last-child { margin-top: 6rpx; color: $ink-muted; font-size: clamp(13px, 20rpx, 16px); line-height: 1.45; }
.upload-button { justify-self: start; min-height: 88rpx; margin: 0; padding: 0 20rpx; border: 1rpx solid rgba(184, 35, 35, .38); border-radius: 8rpx; background: transparent; color: $brand-red; font-size: clamp(14px, 22rpx, 17px); }
.upload-field > text { color: $ink-muted; font-size: clamp(13px, 21rpx, 16px); overflow-wrap: anywhere; }
.upload-receipt { display: flex; align-items: center; gap: 12rpx; }
.upload-receipt > text { min-width: 0; flex: 1; color: $ink-muted; font-size: clamp(13px, 21rpx, 16px); overflow-wrap: anywhere; }
.upload-receipt > button { min-height: 72rpx; margin: 0; padding: 0 16rpx; border: 0; background: transparent; color: $brand-red; font-size: clamp(13px, 20rpx, 16px); }
.upload-receipt > button::after { border: 0; }
.error {
display: block;
margin-top: 12rpx;
@@ -648,7 +757,7 @@ onUnload(() => {
.merit-list {
display: flex;
flex-direction: column;
gap: 16rpx;
gap: 20rpx;
}
.save-notice {
display: block;
@@ -707,9 +816,11 @@ onUnload(() => {
}
.merit-card__amount .app-button {
width: 140rpx;
min-height: 68rpx;
min-height: 80rpx;
}
.detail-content { width: 100%; margin-top: 18rpx; text-align: left; }
.detail-media { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); margin-top: 16rpx; gap: 10rpx; }
.detail-media image { width: 100%; height: 150rpx; border-radius: 8rpx; background: rgba(128, 89, 49, .12); }
.detail-content > text { display: block; margin-top: 9rpx; color: $ink-muted; font-size: clamp(14px, 23rpx, 17px); line-height: 1.55; overflow-wrap: anywhere; }
.detail-content__body { padding-top: 10rpx; border-top: 1rpx solid rgba(142, 95, 41, .2); color: $ink !important; white-space: pre-wrap; }
.detail-error { color: $brand-red !important; }
+21 -10
View File
@@ -55,7 +55,7 @@
class="people-primary-action"
type="secondary"
block
:label="loadingMore ? '正在加载…' : '加载更多人物'"
:label="loadingMore ? '正在加载…' : loadMoreError ? '加载失败,重新加载' : '加载更多人物'"
@click="loadMore"
/>
</view>
@@ -125,9 +125,10 @@ const keyword = ref("");
const total = ref(0);
const pageNum = ref(1);
const loadingMore = ref(false);
const loadMoreError = ref(false);
const peopleListRequestController = createRequestController();
let loadSequence = 0;
const hasValidContext = computed(() => Boolean(genealogyId.value));
const hasValidContext = computed(() => /^[1-9]\d*$/.test(genealogyId.value));
const hasMore = computed(() => people.value.length < total.value);
const applySearch = () => {
@@ -136,41 +137,48 @@ const applySearch = () => {
return;
}
keyword.value = keywordInput.value.trim();
pageNum.value = 1;
loadMoreError.value = false;
void loadPeople();
};
const clearSearch = () => {
keywordInput.value = "";
keyword.value = "";
pageNum.value = 1;
loadMoreError.value = false;
void loadPeople();
};
const loadPeople = async ({ append = false } = {}) => {
if (!hasValidContext.value) return;
const activeLoad = ++loadSequence;
if (append) loadingMore.value = true;
const requestedPage = append ? pageNum.value + 1 : 1;
if (append) {
loadingMore.value = true;
loadMoreError.value = false;
}
else peopleState.value = "loading";
try {
const personPage = await lineageApi.getPersonPage(
genealogyId.value,
{ pageNum: pageNum.value, pageSize: 10, keyword: keyword.value },
{ pageNum: requestedPage, pageSize: 10, keyword: keyword.value },
{ requestController: peopleListRequestController },
);
if (activeLoad !== loadSequence) return;
people.value = append ? [...people.value, ...personPage.rows] : personPage.rows;
pageNum.value = requestedPage;
total.value = personPage.total;
peopleState.value = people.value.length ? "ready" : "empty";
} catch (error) {
if (activeLoad !== loadSequence || isRequestCancelled(error)) return;
if (!append) people.value = [];
if (append) loadMoreError.value = true;
else {
people.value = [];
peopleState.value = "error";
}
} finally {
if (activeLoad === loadSequence) loadingMore.value = false;
}
};
const loadMore = () => {
if (loadingMore.value || !hasMore.value) return;
pageNum.value += 1;
void loadPeople({ append: true });
};
const openPerson = (person) =>
@@ -188,7 +196,7 @@ const openPerson = (person) =>
const handleStateAction = () => {
if (peopleState.value === "invalid") return goBack();
if (peopleState.value === "error") {
pageNum.value = 1;
loadMoreError.value = false;
return loadPeople();
}
return goBack();
@@ -224,7 +232,7 @@ onUnload(() => {
}
.people-content {
flex: 1;
padding: 22rpx 24rpx 100rpx;
padding: 22rpx 24rpx calc(100rpx + env(safe-area-inset-bottom));
}
.people-search {
@include adaptive-records-field;
@@ -269,6 +277,7 @@ onUnload(() => {
min-height: clamp(92px, 190rpx, 108px);
align-items: center;
margin-top: 12px;
background-color: rgba($paper, 0.82);
}
.person-card:first-child {
margin-top: 0;
@@ -284,11 +293,13 @@ onUnload(() => {
font-family: STKaiti, KaiTi, serif;
font-size: clamp(17px, 31rpx, 22px);
font-weight: 700;
overflow-wrap: anywhere;
}
.person-card__meta {
margin-top: 7rpx;
color: $ink-muted;
font-size: clamp(14px, 23rpx, 17px);
overflow-wrap: anywhere;
}
.person-card__hint {
margin-top: 8rpx;
+32 -15
View File
@@ -32,15 +32,15 @@
</template>
<template v-if="personState === 'detail'">
<view class="person-archive-card">
<view
v-for="item in detailSections"
:key="item.title"
class="person-archive-card"
>
<view
><text>{{ item.title }}</text
><text>{{ item.copy || "未填写" }}</text></view
class="person-archive-row"
>
<text>{{ item.title }}</text>
<text>{{ item.copy || "未填写" }}</text>
</view>
</view>
<view class="person-related-actions">
<AppButton
@@ -200,8 +200,8 @@ onLoad((query) => {
personId.value = String(query.personId || "");
if (
query.mode !== "view" ||
!genealogyId.value ||
!personId.value
!/^[1-9]\d*$/.test(genealogyId.value) ||
!/^[1-9]\d*$/.test(personId.value)
) {
personState.value = "error";
return;
@@ -234,13 +234,14 @@ onUnload(() => {
min-height: calc(100vh - 100rpx);
}
.person-detail-content {
padding: 18rpx 24rpx 72rpx;
padding: 18rpx 24rpx calc(72rpx + env(safe-area-inset-bottom));
}
.person-identity-card {
@include adaptive-records-person;
display: flex;
min-height: 190rpx;
padding: 30rpx 10%;
background-color: rgba($paper, 0.82);
}
.person-identity-card__copy {
display: flex;
@@ -269,28 +270,41 @@ onUnload(() => {
font-size: clamp(13px, 21rpx, 16px);
}
.person-archive-card {
min-height: 154rpx;
margin-top: 14rpx;
padding: 32rpx 42rpx;
padding: 22rpx 42rpx;
box-sizing: border-box;
}
.person-archive-card,
.person-state-card {
@include adaptive-records-content;
background-color: rgba($paper, 0.82);
}
.person-archive-card text {
display: block;
.person-archive-row {
display: grid;
min-height: 68rpx;
align-items: start;
padding: 15rpx 0;
border-bottom: 1rpx solid rgba($gold, 0.24);
grid-template-columns: 176rpx minmax(0, 1fr);
column-gap: 20rpx;
box-sizing: border-box;
}
.person-archive-card text:first-child {
.person-archive-row:last-child {
border-bottom: 0;
}
.person-archive-row text {
min-width: 0;
}
.person-archive-row text:first-child {
color: $brand-red;
font-size: clamp(15px, 24rpx, 18px);
font-weight: 700;
}
.person-archive-card text:last-child {
margin-top: 11rpx;
.person-archive-row text:last-child {
color: $ink;
font-size: clamp(15px, 24rpx, 18px);
line-height: 1.55;
overflow-wrap: anywhere;
}
.person-edit-action {
margin-top: 20rpx;
@@ -342,5 +356,8 @@ onUnload(() => {
.person-identity-card__name {
font-size: clamp(19px, 35rpx, 24px);
}
.person-archive-row {
grid-template-columns: 146rpx minmax(0, 1fr);
}
}
</style>
+96
View File
@@ -0,0 +1,96 @@
<template>
<view class="documents-page">
<ModulePageBackground module="records" />
<view class="page-layer">
<PageHeader title="重要证件" custom-back @back="requestBack" />
</view>
<view class="documents-content page-layer">
<view v-if="!valid" class="documents-card">
<text>暂时无法打开重要证件</text>
<text>未找到家谱信息请返回后重新进入</text>
</view>
<view v-else class="documents-card">
<text>家谱证件档案</text>
<text>集中查看当前家谱中有权访问的重要证件新增证件仍从对应人物资料进入</text>
<AppButton block label="查看全部证件" :disabled="documentBusy" @click="openDocuments" />
</view>
</view>
<PersonDocumentDialog
ref="documentDialog"
:genealogy-id="genealogyId"
@busy-change="documentBusy = $event"
@transient-change="documentTransientOpen = $event"
/>
</view>
</template>
<script setup>
import { computed, ref } from "vue";
import { onBackPress, onLoad, onReady } from "@dcloudio/uni-app";
import AppButton from "@/components/AppButton.vue";
import ModulePageBackground from "@/components/ModulePageBackground.vue";
import PageHeader from "@/components/PageHeader.vue";
import PersonDocumentDialog from "@/components/tree/PersonDocumentDialog.vue";
import { goBack, handleBackPress } from "@/utils/navigation/gateway.js";
const genealogyId = ref("");
const documentDialog = ref(null);
const documentBusy = ref(false);
const documentTransientOpen = ref(false);
const valid = computed(() => /^[1-9]\d*$/.test(genealogyId.value));
const openDocuments = () => {
if (!valid.value || documentBusy.value) return;
documentDialog.value?.open();
};
const requestBack = () => {
if (documentTransientOpen.value) return documentDialog.value?.closeTransient() ?? true;
return goBack();
};
onLoad((query) => {
genealogyId.value = String(query?.genealogyId || "");
});
onReady(() => {
if (valid.value) openDocuments();
});
onBackPress((event) => handleBackPress(event, requestBack));
</script>
<style scoped lang="scss">
@import "../../styles/adaptive-frame-profiles.scss";
.documents-page {
display: flex;
min-height: 100vh;
flex-direction: column;
background: $paper;
}
.page-layer {
z-index: 1;
}
.documents-content {
flex: 1;
padding: 24rpx 28rpx calc(72rpx + env(safe-area-inset-bottom));
}
.documents-card {
@include adaptive-records-content;
padding: 48rpx 36rpx;
text-align: center;
}
.documents-card text {
display: block;
color: $ink-muted;
font-size: clamp(14px, 23rpx, 17px);
line-height: 1.65;
}
.documents-card text:first-child {
color: $ink;
font-family: STKaiti, KaiTi, serif;
font-size: clamp(19px, 34rpx, 24px);
font-weight: 700;
}
.documents-card text + text,
.documents-card .app-button {
margin-top: 20rpx;
}
</style>
+11 -3
View File
@@ -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);
+40 -9
View File
@@ -6,7 +6,7 @@
title="贺礼簿"
:action="valid ? '新建' : ''"
custom-back
@back="returnToFamily"
@back="requestBack"
@action="createRelative"
/></view>
<view class="page-content">
@@ -32,12 +32,20 @@
<view v-else class="record-list">
<text v-if="deleteError" class="delete-error">{{ deleteError }}</text>
<view v-for="item in records" :key="item.id" class="record-card" role="button" :aria-label="`查看${item.name}的往来详情`" @click="openRecordDetail(item)">
<image
v-if="item.mediaFiles?.[0]?.accessUrl"
class="record-card__cover"
:src="item.mediaFiles[0].accessUrl"
mode="aspectFill"
aria-hidden="true"
/>
<view
><text>{{ item.name }}</text
><text
>{{ item.relation
}}{{ item.event ? ` · ${item.event}` : "" }}</text
><text v-if="item.time">{{ item.time }}</text
><text v-if="item.createTime">创建于 {{ formatMinuteTimestamp(item.createTime) }}</text
><text v-if="item.content">{{ item.content }}</text></view
>
<view class="record-card__amount">
@@ -76,6 +84,7 @@
<text>关系{{ detailTarget?.relation || "未填写" }}</text>
<text>事项{{ detailTarget?.event || "未填写" }}</text>
<text>时间{{ detailTarget?.time || "未填写" }}</text>
<text v-if="detailTarget?.createTime">创建时间{{ formatMinuteTimestamp(detailTarget.createTime) }}</text>
<text>金额{{ detailTarget?.amount ? `¥${detailTarget.amount}` : "未填写" }}</text>
<text class="detail-content__body">{{ detailTarget?.content || "未填写记录内容" }}</text>
<view v-if="detailTarget?.mediaFiles?.length" class="detail-media">
@@ -86,9 +95,9 @@
</AppDialog>
<AppDialog
:visible="deleteConfirmationVisible"
title="删除这条亲友往来?"
message="删除后无法恢复,请确认当前内容不再需要。"
confirm-text="确认删除"
title="这条亲友往来移至回收站"
message="移入回收站后不再展示,家谱管理员可在保留期内恢复。"
confirm-text="移至回收站"
cancel-text="保留记录"
show-cancel
:close-on-mask="false"
@@ -100,7 +109,7 @@
<script setup>
import { computed, ref } from "vue";
import { onLoad, onShow, onUnload } from "@dcloudio/uni-app";
import { onBackPress, onLoad, onShow, onUnload } from "@dcloudio/uni-app";
import AppButton from "@/components/AppButton.vue";
import AppDialog from "@/components/AppDialog.vue";
import AppLoading from "@/components/AppLoading.vue";
@@ -112,7 +121,8 @@ import {
} from "@/services/api/request-controller.js";
import { lifeRecordApi } from "@/services/api/life-record-service.js";
import { getRequestErrorMessage } from "@/services/api/request-error-message.js";
import { goBack, openPage, returnTo } from "@/utils/navigation/gateway.js";
import { formatMinuteTimestamp } from "@/utils/display-time.js";
import { goBack, handleBackPress, openPage, returnTo } from "@/utils/navigation/gateway.js";
const genealogyId = ref("");
const records = ref([]);
@@ -200,6 +210,18 @@ const closeDeleteConfirmation = () => {
deleteConfirmationVisible.value = false;
deleteTarget.value = null;
};
const requestBack = () => {
if (deleting.value || detailState.value === "loading") return true;
if (deleteConfirmationVisible.value) {
closeDeleteConfirmation();
return true;
}
if (detailTarget.value) {
closeRecordDetail();
return true;
}
return returnToFamily();
};
const deleteRecord = async () => {
const record = deleteTarget.value;
if (!record?.canDelete || deleting.value) return;
@@ -234,6 +256,7 @@ onUnload(() => {
relativeRecordDeleteController.abort();
relativeRecordDetailController.abort();
});
onBackPress((event) => handleBackPress(event, requestBack));
</script>
<style scoped lang="scss">
@@ -249,11 +272,13 @@ onUnload(() => {
z-index: 1;
}
.page-content {
padding: 18rpx 24rpx 72rpx;
flex: 1;
padding: 18rpx 24rpx calc(72rpx + env(safe-area-inset-bottom));
}
.state-card,
.record-card {
@include adaptive-records-content;
background-color: rgba($paper, 0.82);
}
.state-card {
display: flex;
@@ -273,7 +298,7 @@ onUnload(() => {
.record-list {
display: flex;
flex-direction: column;
gap: 16rpx;
gap: 20rpx;
}
.record-card {
display: flex;
@@ -310,6 +335,12 @@ onUnload(() => {
align-items: center;
gap: 10rpx;
}
.record-card__cover {
width: 100%;
height: 240rpx;
border-radius: 10rpx;
background: rgba(128, 89, 49, 0.12);
}
.record-card__amount > text {
margin-right: auto;
color: $brand-red;
@@ -317,7 +348,7 @@ onUnload(() => {
}
.record-card__amount .app-button {
width: 140rpx;
min-height: 68rpx;
min-height: 80rpx;
}
.delete-error {
display: block;
+266 -13
View File
@@ -134,11 +134,20 @@
}}</text>
<view class="form-field">
<text>别名</text>
<text>表字</text>
<input
v-model="addForm.courtesyName"
maxlength="40"
placeholder="如族谱有记载可填写"
placeholder-class="form-placeholder"
/>
</view>
<view class="form-field">
<text>别号</text>
<input
v-model="addForm.aliasName"
maxlength="40"
placeholder="按家谱记载填写"
placeholder="别号、昵称或曾用名"
placeholder-class="form-placeholder"
/>
</view>
@@ -193,6 +202,63 @@
placeholder-class="form-placeholder"
/>
</view>
<picker
:range="zodiacOptions.map((item) => item.label)"
:value="optionIndex(zodiacOptions, addForm.zodiac)"
@change="selectOption('zodiac', zodiacOptions, $event)"
>
<view class="form-field form-field--picker">
<text>生肖</text><text>{{ optionLabel(zodiacOptions, addForm.zodiac) || "请选择" }}</text>
</view>
</picker>
<view class="form-field">
<text>现居地</text>
<input
v-model="addForm.currentAddress"
maxlength="120"
placeholder="填写当前常住地区"
placeholder-class="form-placeholder"
/>
</view>
<view class="form-field">
<text>联系电话</text>
<input
v-model="addForm.mobile"
maxlength="30"
placeholder="仅授权成员可见"
placeholder-class="form-placeholder"
/>
</view>
<view class="form-field">
<text>电子邮箱</text>
<input
v-model="addForm.email"
maxlength="254"
placeholder="仅授权成员可见"
placeholder-class="form-placeholder"
/>
</view>
<text v-if="fieldErrors.email" class="field-error">{{
fieldErrors.email
}}</text>
<picker
:range="educationOptions.map((item) => item.label)"
:value="optionIndex(educationOptions, addForm.education)"
@change="selectOption('education', educationOptions, $event)"
>
<view class="form-field form-field--picker">
<text>学历分类</text><text>{{ optionLabel(educationOptions, addForm.education) || "请选择" }}</text>
</view>
</picker>
<view class="form-field">
<text>职业</text>
<input
v-model="addForm.occupation"
maxlength="80"
placeholder="填写主要职业"
placeholder-class="form-placeholder"
/>
</view>
<picker
v-if="isDeceased"
@@ -218,6 +284,18 @@
}}</text>
</view>
</picker>
<view v-if="isDeceased" class="form-field">
<text>享年</text>
<input
v-model="addForm.deathAge"
type="number"
placeholder="0 至 200 的整数"
placeholder-class="form-placeholder"
/>
</view>
<text v-if="fieldErrors.deathAge" class="field-error">{{
fieldErrors.deathAge
}}</text>
<view v-if="isDeceased" class="form-field">
<text>逝世地</text>
<input
@@ -227,6 +305,50 @@
placeholder-class="form-placeholder"
/>
</view>
<picker
v-if="isDeceased"
:range="deathExpressionOptions.map((item) => item.label)"
:value="optionIndex(deathExpressionOptions, addForm.deathType)"
@change="selectOption('deathType', deathExpressionOptions, $event)"
>
<view class="form-field form-field--picker">
<text>逝世表述</text><text>{{ optionLabel(deathExpressionOptions, addForm.deathType) || "请选择" }}</text>
</view>
</picker>
<view v-if="isDeceased" class="form-field form-field--summary">
<text>遗传病史</text>
<textarea
v-model="addForm.hereditaryMedicalHistory"
auto-height
maxlength="500"
placeholder="敏感信息,无明确依据可不填写"
placeholder-class="form-placeholder"
/>
</view>
<text v-if="isDeceased" class="form-note"
>遗传病史属于敏感健康信息后端必须按权限返回并记录访问审计</text
>
<picker
v-if="relationVariantOptions.length"
:range="relationVariantOptions.map((item) => item.label)"
:value="optionIndex(relationVariantOptions, addForm.relationVariantCode)"
@change="selectOption('relationVariantCode', relationVariantOptions, $event)"
>
<view class="form-field form-field--picker">
<text>关系称谓</text><text>{{ optionLabel(relationVariantOptions, addForm.relationVariantCode) || "请选择" }}</text>
</view>
</picker>
<picker
v-if="isDeceased"
mode="date"
:value="addForm.burialDate"
@change="selectBurialDate"
>
<view class="form-field form-field--picker">
<text>安葬日期</text
><text>{{ addForm.burialDate || "请选择" }}</text>
</view>
</picker>
<view v-if="isDeceased" class="form-field">
<text>安葬地</text>
<input
@@ -236,6 +358,9 @@
placeholder-class="form-placeholder"
/>
</view>
<text v-if="fieldErrors.dates" class="field-error">{{
fieldErrors.dates
}}</text>
<picker
:range="personStatusOptions.map((item) => item.label)"
:value="optionIndex(personStatusOptions, addForm.personStatus)"
@@ -329,6 +454,7 @@ import {
isRequestCancelled
} from "@/services/api/request-controller.js";
import { genealogyMemberApi } from "@/services/api/genealogy-member-service.js";
import { businessDictionaryApi } from "@/services/api/business-dictionary-service.js";
import { lineageApi } from "@/services/api/lineage-service.js";
import { getRequestErrorMessage } from "@/services/api/request-error-message.js";
import { createNonIdempotentWriteGuard } from "@/utils/non-idempotent-write-guard.js";
@@ -366,6 +492,14 @@ const memberDetailRequestController = createRequestController();
const memberOptionsRequestController = createRequestController();
const avatarUploadRequestController = createRequestController();
const memberCreationRequestController = createRequestController();
const sensitiveProfileRequestController = createRequestController();
const dictionaryRequestControllers = {
zodiac: createRequestController(),
education: createRequestController(),
deathExpression: createRequestController(),
parentVariant: createRequestController(),
spouseVariant: createRequestController(),
};
const memberCreationGuard = createNonIdempotentWriteGuard();
let pageActive = true;
let loadSequence = 0;
@@ -387,6 +521,7 @@ const addForm = reactive({
appUserId: "",
name: "",
relation: "",
courtesyName: "",
aliasName: "",
sex: "",
generation: "",
@@ -395,18 +530,41 @@ const addForm = reactive({
birthDate: "",
birthLunar: "",
birthPlace: "",
zodiac: "",
currentAddress: "",
mobile: "",
email: "",
education: "",
occupation: "",
deathDate: "",
deathLunar: "",
deathAge: "",
deathPlace: "",
deathType: "",
hereditaryMedicalHistory: "",
relationVariantCode: "",
burialDate: "",
burialPlace: "",
personStatus: "",
biography: "",
remark: "",
sortOrder: "",
});
const fieldErrors = reactive({ name: "", relation: "", bindingMode: "" });
const fieldErrors = reactive({
name: "",
relation: "",
bindingMode: "",
email: "",
dates: "",
deathAge: "",
});
const memberOptionsState = ref("loading");
const memberOptions = ref([]);
const zodiacOptions = ref([]);
const educationOptions = ref([]);
const deathExpressionOptions = ref([]);
const parentRelationVariantOptions = ref([]);
const spouseRelationVariantOptions = ref([]);
const isFirstMember = computed(() => mode.value === "first");
const isDeceased = computed(() => addForm.personStatus === "1");
@@ -422,12 +580,27 @@ const relationOptions = computed(() => {
const relationLabel = computed(
() => activeRelationIntent.value?.label || addForm.relation || "亲属关系",
);
const selectedRelationType = computed(() =>
isFirstMember.value
? ""
: activeRelationIntent.value
? relationType.value
: genericRelationTypes[relationOptions.value.indexOf(addForm.relation)] || "",
);
const relationVariantOptions = computed(() => {
if ([memberRelationTypes.FATHER, memberRelationTypes.MOTHER].includes(selectedRelationType.value)) {
return parentRelationVariantOptions.value;
}
return selectedRelationType.value === memberRelationTypes.SPOUSE
? spouseRelationVariantOptions.value
: [];
});
const memberOptionLabels = computed(() =>
memberOptions.value.map((item) => item.label),
);
const hasValidContext = computed(
() =>
Boolean(genealogyId.value) &&
/^[1-9]\d*$/.test(genealogyId.value) &&
(isFirstMember.value ? !personId.value : Boolean(currentMember.value)),
);
const relationIndex = computed(() =>
@@ -532,14 +705,38 @@ const loadMemberOptions = async () => {
memberOptionsState.value = "error";
}
};
const loadBusinessOptions = async () => {
try {
const [zodiacRows, educationRows, deathRows, parentRows, spouseRows] = await Promise.all([
businessDictionaryApi.getBusinessDictionaryOptions("gen_zodiac", { requestController: dictionaryRequestControllers.zodiac }),
businessDictionaryApi.getBusinessDictionaryOptions("gen_education_type", { requestController: dictionaryRequestControllers.education }),
businessDictionaryApi.getBusinessDictionaryOptions("gen_death_expression", { requestController: dictionaryRequestControllers.deathExpression }),
businessDictionaryApi.getBusinessDictionaryOptions("gen_parent_relationship_variant", { requestController: dictionaryRequestControllers.parentVariant }),
businessDictionaryApi.getBusinessDictionaryOptions("gen_spouse_relationship_variant", { requestController: dictionaryRequestControllers.spouseVariant }),
]);
if (!pageActive) return;
zodiacOptions.value = zodiacRows;
educationOptions.value = educationRows;
deathExpressionOptions.value = deathRows;
parentRelationVariantOptions.value = parentRows;
spouseRelationVariantOptions.value = spouseRows;
} catch (error) {
if (!pageActive || isRequestCancelled(error)) return;
zodiacOptions.value = [];
educationOptions.value = [];
deathExpressionOptions.value = [];
parentRelationVariantOptions.value = [];
spouseRelationVariantOptions.value = [];
}
};
onLoad((query) => {
genealogyId.value = String(query.genealogyId || "");
personId.value = String(query.personId || "");
mode.value = query.mode === "first" ? "first" : "relative";
relationType.value = String(query.relationType || "");
if (
!genealogyId.value ||
(!isFirstMember.value && !personId.value)
!/^[1-9]\d*$/.test(genealogyId.value) ||
(!isFirstMember.value && !/^[1-9]\d*$/.test(personId.value))
) {
addState.value = "error";
errorMessage.value = "这个页面已经过期,请从世系树重新进入。";
@@ -554,6 +751,7 @@ onLoad((query) => {
addForm.relation = activeRelationIntent.value.label;
}
void loadMemberOptions();
void loadBusinessOptions();
if (isFirstMember.value) {
addState.value = "form";
return;
@@ -567,6 +765,8 @@ onUnload(() => {
memberOptionsRequestController.abort();
avatarUploadRequestController.abort();
memberCreationRequestController.abort();
sensitiveProfileRequestController.abort();
Object.values(dictionaryRequestControllers).forEach((controller) => controller.abort());
discardConfirmation.dispose();
});
@@ -587,6 +787,7 @@ const clearError = (field) => {
};
const selectRelation = (event) => {
addForm.relation = relationOptions.value[Number(event.detail.value)] || "";
addForm.relationVariantCode = "";
clearError("relation");
};
const optionIndex = findMemberOptionIndex;
@@ -640,6 +841,9 @@ const selectBirthDate = (event) => {
const selectDeathDate = (event) => {
addForm.deathDate = event.detail.value || "";
};
const selectBurialDate = (event) => {
addForm.burialDate = event.detail.value || "";
};
const validateAddForm = () => {
fieldErrors.name = addForm.name.trim() ? "" : "请填写成员姓名";
fieldErrors.relation =
@@ -650,7 +854,22 @@ const validateAddForm = () => {
? "可绑定成员暂不可用,请稍后重试"
: "请选择可绑定成员"
: "";
return !fieldErrors.name && !fieldErrors.relation && !fieldErrors.bindingMode;
fieldErrors.email =
addForm.email.trim() && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(addForm.email.trim())
? "请填写有效的电子邮箱"
: "";
fieldErrors.dates =
addForm.birthDate && addForm.deathDate && addForm.deathDate < addForm.birthDate
? "逝世日期不能早于出生日期"
: addForm.deathDate && addForm.burialDate && addForm.burialDate < addForm.deathDate
? "安葬日期不能早于逝世日期"
: "";
const deathAge = addForm.deathAge === "" ? null : Number(addForm.deathAge);
fieldErrors.deathAge =
deathAge !== null && (!Number.isSafeInteger(deathAge) || deathAge < 0 || deathAge > 200)
? "享年必须是 0 至 200 的整数"
: "";
return !Object.values(fieldErrors).some(Boolean);
};
const submitAdd = async () => {
if (isSubmitting.value || isAvatarUploading.value || !validateAddForm())
@@ -661,6 +880,7 @@ const submitAdd = async () => {
? { appUserId: addForm.appUserId }
: {}),
name: addForm.name,
courtesyName: addForm.courtesyName,
aliasName: addForm.aliasName,
sex: addForm.sex,
generationName: addForm.generationName,
@@ -668,10 +888,23 @@ const submitAdd = async () => {
birthDate: addForm.birthDate,
birthLunar: addForm.birthLunar,
birthPlace: addForm.birthPlace,
zodiacCode: addForm.zodiac,
currentAddress: addForm.currentAddress,
mobile: addForm.mobile,
email: addForm.email,
educationCode: addForm.education,
occupation: addForm.occupation,
...(isDeceased.value
? {
deathDate: addForm.deathDate,
deathLunar: addForm.deathLunar,
deathAge: addForm.deathAge,
deathPlace: addForm.deathPlace,
deathExpressionCode: addForm.deathType,
burialDate: addForm.burialDate,
burialPlace: addForm.burialPlace,
}
: {}),
personStatus: addForm.personStatus,
biography: addForm.biography,
remark: addForm.remark,
@@ -685,6 +918,9 @@ const submitAdd = async () => {
...(relationType.value === memberRelationTypes.SPOUSE
? { relationName: relationLabel.value }
: {}),
...(addForm.relationVariantCode
? { relationVariantCode: addForm.relationVariantCode }
: {}),
}),
};
const submittedRelationType = isFirstMember.value
@@ -708,12 +944,13 @@ const submitAdd = async () => {
isSubmitting.value = true;
try {
let createdPerson;
if (isFirstMember.value) {
await lineageApi.createPerson(genealogyId.value, payload, {
createdPerson = await lineageApi.createPerson(genealogyId.value, payload, {
requestController: memberCreationRequestController,
});
} else {
await lineageApi.createRelatedPerson(
createdPerson = await lineageApi.createRelatedPerson(
genealogyId.value,
personId.value,
submittedRelationType,
@@ -723,6 +960,19 @@ const submitAdd = async () => {
}
if (!pageActive) return;
memberCreationCommitted.value = true;
const sensitiveHistory = addForm.hereditaryMedicalHistory.trim();
if (sensitiveHistory) {
const createdPersonId = String(createdPerson?.personId || "");
if (!/^[1-9]\d*$/.test(createdPersonId)) {
throw new Error("成员已经保存,但响应缺少人物标识,敏感健康资料尚未保存。请返回成员档案补充。");
}
await lineageApi.saveSensitiveProfile(
genealogyId.value,
createdPersonId,
sensitiveHistory,
{ requestController: sensitiveProfileRequestController },
);
}
await returnTo("T01", { genealogyId: genealogyId.value });
} catch (error) {
if (!pageActive) return;
@@ -774,7 +1024,7 @@ const returnToTree = async () => {
@include adaptive-tree-panel;
z-index: 2;
width: calc(100% - 32rpx);
margin: 18rpx auto 28rpx;
margin: 18rpx auto calc(28rpx + env(safe-area-inset-bottom));
padding: 7.5% 8%;
}
.form-eyebrow {
@@ -837,9 +1087,12 @@ const returnToTree = async () => {
}
.form-field textarea {
width: auto;
min-height: 54rpx;
min-height: var(--app-touch-min);
text-align: left;
}
.form-field input {
min-height: var(--app-touch-min);
}
.form-field__hint,
.upload-receipt {
color: $ink-muted;
@@ -853,7 +1106,7 @@ const returnToTree = async () => {
gap: 6rpx;
}
.upload-button {
min-height: 54rpx;
min-height: var(--app-touch-min);
margin: 0;
padding: 0 16rpx;
border: 1rpx solid rgba(159, 23, 15, 0.42);
@@ -882,7 +1135,7 @@ const returnToTree = async () => {
.form-action {
display: grid;
width: 100%;
min-height: 76rpx;
min-height: var(--app-touch-min);
margin-top: 18rpx;
}
.form-action image,
+273 -12
View File
@@ -115,10 +115,20 @@
}}</text>
<view class="form-field">
<text>别名</text>
<text>表字</text>
<input
v-model="editForm.courtesyName"
maxlength="40"
placeholder="如族谱有记载可填写"
placeholder-class="form-placeholder"
/>
</view>
<view class="form-field">
<text>别号</text>
<input
v-model="editForm.aliasName"
placeholder="别名或曾用名"
maxlength="40"
placeholder="别号、昵称或曾用名"
placeholder-class="form-placeholder"
/>
</view>
@@ -190,6 +200,63 @@
placeholder-class="form-placeholder"
/>
</view>
<picker
:range="zodiacOptions.map((item) => item.label)"
:value="optionIndex(zodiacOptions, editForm.zodiac)"
@change="selectOption('zodiac', zodiacOptions, $event)"
>
<view class="form-field form-field--picker">
<text>生肖</text><text>{{ optionLabel(zodiacOptions, editForm.zodiac) || "请选择" }}</text>
</view>
</picker>
<view class="form-field">
<text>现居地</text>
<input
v-model="editForm.currentAddress"
maxlength="120"
placeholder="填写当前常住地区"
placeholder-class="form-placeholder"
/>
</view>
<view class="form-field">
<text>联系电话</text>
<input
v-model="editForm.mobile"
maxlength="30"
placeholder="仅授权成员可见"
placeholder-class="form-placeholder"
/>
</view>
<view class="form-field">
<text>电子邮箱</text>
<input
v-model="editForm.email"
maxlength="254"
placeholder="仅授权成员可见"
placeholder-class="form-placeholder"
/>
</view>
<text v-if="fieldErrors.email" class="field-error">{{
fieldErrors.email
}}</text>
<picker
:range="educationOptions.map((item) => item.label)"
:value="optionIndex(educationOptions, editForm.education)"
@change="selectOption('education', educationOptions, $event)"
>
<view class="form-field form-field--picker">
<text>学历分类</text><text>{{ optionLabel(educationOptions, editForm.education) || "请选择" }}</text>
</view>
</picker>
<view class="form-field">
<text>职业</text>
<input
v-model="editForm.occupation"
maxlength="80"
placeholder="填写主要职业"
placeholder-class="form-placeholder"
/>
</view>
<picker
v-if="isDeceased"
mode="date"
@@ -214,6 +281,18 @@
}}</text>
</view>
</picker>
<view v-if="isDeceased" class="form-field">
<text>享年</text>
<input
v-model="editForm.deathAge"
type="number"
placeholder="0 至 200 的整数"
placeholder-class="form-placeholder"
/>
</view>
<text v-if="fieldErrors.deathAge" class="field-error">{{
fieldErrors.deathAge
}}</text>
<view v-if="isDeceased" class="form-field">
<text>逝世地</text>
<input
@@ -222,6 +301,45 @@
placeholder-class="form-placeholder"
/>
</view>
<picker
v-if="isDeceased"
:range="deathExpressionOptions.map((item) => item.label)"
:value="optionIndex(deathExpressionOptions, editForm.deathType)"
@change="selectOption('deathType', deathExpressionOptions, $event)"
>
<view class="form-field form-field--picker">
<text>逝世表述</text><text>{{ optionLabel(deathExpressionOptions, editForm.deathType) || "请选择" }}</text>
</view>
</picker>
<view
v-if="isDeceased && originalMember.canManageSensitiveMedicalHistory"
class="form-field form-field--summary"
>
<text>遗传病史</text>
<textarea
v-model="editForm.hereditaryMedicalHistory"
auto-height
maxlength="500"
placeholder="敏感信息,无明确依据可不填写"
placeholder-class="form-placeholder"
/>
</view>
<text
v-if="isDeceased && originalMember.canManageSensitiveMedicalHistory"
class="form-note"
>遗传病史属于敏感健康信息仅在已授权时读取和保存</text
>
<picker
v-if="isDeceased"
mode="date"
:value="editForm.burialDate"
@change="selectDate('burialDate', $event)"
>
<view class="form-field form-field--picker"
><text>安葬日期</text
><text>{{ editForm.burialDate || "未填写" }}</text></view
>
</picker>
<view v-if="isDeceased" class="form-field">
<text>安葬地</text>
<input
@@ -328,6 +446,7 @@ import {
isRequestCancelled
} from "@/services/api/request-controller.js";
import { genealogyMemberApi } from "@/services/api/genealogy-member-service.js";
import { businessDictionaryApi } from "@/services/api/business-dictionary-service.js";
import { lineageApi } from "@/services/api/lineage-service.js";
import { getRequestErrorMessage } from "@/services/api/request-error-message.js";
import {
@@ -364,6 +483,12 @@ const personOptionsRequestController = createRequestController();
const memberOptionsRequestController = createRequestController();
const avatarUploadRequestController = createRequestController();
const memberUpdateRequestController = createRequestController();
const sensitiveProfileRequestController = createRequestController();
const dictionaryRequestControllers = {
zodiac: createRequestController(),
education: createRequestController(),
deathExpression: createRequestController(),
};
let pageActive = true;
let loadSequence = 0;
@@ -374,6 +499,7 @@ const editForm = reactive({
appUserId: "",
bindingMode: "NONE",
name: "",
courtesyName: "",
aliasName: "",
sex: "",
generation: "",
@@ -384,16 +510,32 @@ const editForm = reactive({
birthDate: "",
birthLunar: "",
birthPlace: "",
zodiac: "",
currentAddress: "",
mobile: "",
email: "",
education: "",
occupation: "",
deathDate: "",
deathLunar: "",
deathAge: "",
deathPlace: "",
deathType: "",
hereditaryMedicalHistory: "",
burialDate: "",
burialPlace: "",
personStatus: "",
summary: "",
remark: "",
sortOrder: "",
});
const fieldErrors = reactive({ name: "", dates: "", bindingMode: "" });
const fieldErrors = reactive({
name: "",
dates: "",
bindingMode: "",
email: "",
deathAge: "",
});
const sexOptions = memberFormOptions.sex;
const lunarOptions = memberFormOptions.lunar;
const personStatusOptions = memberFormOptions.personStatus;
@@ -404,6 +546,10 @@ const personOptionLabels = computed(() =>
);
const memberOptionsState = ref("loading");
const memberOptions = ref([]);
const zodiacOptions = ref([]);
const educationOptions = ref([]);
const deathExpressionOptions = ref([]);
const originalSensitiveProfilePresent = ref(false);
const memberBindingOptions = computed(() => {
const options = memberOptions.value.slice();
if (
@@ -422,7 +568,11 @@ const isDeceased = computed(() => editForm.personStatus === "1");
const formSnapshot = computed(() => JSON.stringify(editForm));
const hasValidContext = computed(() =>
Boolean(genealogyId.value && personId.value && originalMember.value),
Boolean(
/^[1-9]\d*$/.test(genealogyId.value) &&
/^[1-9]\d*$/.test(personId.value) &&
originalMember.value,
),
);
const isDirty = computed(
() =>
@@ -484,11 +634,22 @@ const loadMember = async () => {
if (!pageActive || activeLoad !== loadSequence) return;
originalMember.value = member;
const deceased = member.personStatus === "1";
let sensitiveProfile = null;
if (member.canManageSensitiveMedicalHistory) {
sensitiveProfile = await lineageApi.getSensitiveProfile(
genealogyId.value,
personId.value,
{ requestController: sensitiveProfileRequestController },
);
if (!pageActive || activeLoad !== loadSequence) return;
}
originalSensitiveProfilePresent.value = sensitiveProfile?.present === true;
Object.assign(editForm, {
appUserId: member.appUserId || "",
bindingMode:
member.bindingMode || (member.appUserId ? "SPECIFIED" : "NONE"),
name: member.name,
courtesyName: member.courtesyName || "",
aliasName: member.aliasName || "",
sex: member.sex || "",
generation: member.generation || "",
@@ -501,15 +662,35 @@ const loadMember = async () => {
birthDate: datePart(member.birthDate),
birthLunar: member.birthLunar || "",
birthPlace: member.birthplace || "",
zodiac: member.zodiacCode || "",
currentAddress: member.currentAddress || "",
mobile: member.mobile || "",
email: member.email || "",
education: member.educationCode || "",
occupation: member.occupation || "",
deathDate: deceased ? datePart(member.deathDate) : "",
deathLunar: deceased ? member.deathLunar || "" : "",
deathAge: deceased ? member.deathAge ?? "" : "",
deathPlace: deceased ? member.deathPlace || "" : "",
deathType: deceased ? member.deathExpressionCode || "" : "",
hereditaryMedicalHistory:
deceased && member.canManageSensitiveMedicalHistory
? sensitiveProfile?.hereditaryMedicalHistory || ""
: "",
burialDate: deceased ? datePart(member.burialDate) : "",
burialPlace: deceased ? member.burialPlace || "" : "",
personStatus: member.personStatus || "",
summary: member.biography || "",
remark: member.remark || "",
sortOrder: member.sortOrder ?? "",
});
zodiacOptions.value = preserveHistoricalOption(zodiacOptions.value, member.zodiacCode, member.zodiac);
educationOptions.value = preserveHistoricalOption(educationOptions.value, member.educationCode, member.education);
deathExpressionOptions.value = preserveHistoricalOption(
deathExpressionOptions.value,
member.deathExpressionCode,
member.deathType,
);
baseline.value = formSnapshot.value;
committedMemberSnapshot.value = "";
errorMessage.value = "";
@@ -548,6 +729,34 @@ const loadPersonOptions = async () => {
personOptions.value = [{ label: "暂无可选人物", value: "" }];
}
};
const preserveHistoricalOption = (options, value, label) => {
if (!value || options.some((item) => item.value === value)) return options;
return [{ value, label: label || value }, ...options];
};
const loadBusinessOptions = async () => {
try {
const [zodiacRows, educationRows, deathExpressionRows] = await Promise.all([
businessDictionaryApi.getBusinessDictionaryOptions("gen_zodiac", {
requestController: dictionaryRequestControllers.zodiac,
}),
businessDictionaryApi.getBusinessDictionaryOptions("gen_education_type", {
requestController: dictionaryRequestControllers.education,
}),
businessDictionaryApi.getBusinessDictionaryOptions("gen_death_expression", {
requestController: dictionaryRequestControllers.deathExpression,
}),
]);
if (!pageActive) return;
zodiacOptions.value = preserveHistoricalOption(zodiacRows, originalMember.value?.zodiacCode, originalMember.value?.zodiac);
educationOptions.value = preserveHistoricalOption(educationRows, originalMember.value?.educationCode, originalMember.value?.education);
deathExpressionOptions.value = preserveHistoricalOption(deathExpressionRows, originalMember.value?.deathExpressionCode, originalMember.value?.deathType);
} catch (error) {
if (!pageActive || isRequestCancelled(error)) return;
zodiacOptions.value = preserveHistoricalOption([], originalMember.value?.zodiacCode, originalMember.value?.zodiac);
educationOptions.value = preserveHistoricalOption([], originalMember.value?.educationCode, originalMember.value?.education);
deathExpressionOptions.value = preserveHistoricalOption([], originalMember.value?.deathExpressionCode, originalMember.value?.deathType);
}
};
const loadMemberOptions = async () => {
memberOptionsState.value = "loading";
try {
@@ -572,14 +781,18 @@ const loadMemberOptions = async () => {
};
onLoad((query) => {
genealogyId.value = String(query.genealogyId || "");
personId.value = String(query.personId || "");
if (!genealogyId.value || !personId.value) {
genealogyId.value = String(query?.genealogyId || "");
personId.value = String(query?.personId || "");
if (
!/^[1-9]\d*$/.test(genealogyId.value) ||
!/^[1-9]\d*$/.test(personId.value)
) {
editState.value = "error";
errorMessage.value = "这个页面已经过期,请从成员档案重新进入。";
return;
}
void loadMember();
void loadBusinessOptions();
void loadPersonOptions();
void loadMemberOptions();
});
@@ -591,6 +804,8 @@ onUnload(() => {
memberOptionsRequestController.abort();
avatarUploadRequestController.abort();
memberUpdateRequestController.abort();
sensitiveProfileRequestController.abort();
Object.values(dictionaryRequestControllers).forEach((controller) => controller.abort());
discardConfirmation.dispose();
});
@@ -676,6 +891,19 @@ const validateEditForm = () => {
editForm.deathDate &&
editForm.deathDate < editForm.birthDate
? "离世日期不能早于出生日期"
: editForm.deathDate &&
editForm.burialDate &&
editForm.burialDate < editForm.deathDate
? "安葬日期不能早于离世日期"
: "";
fieldErrors.email =
editForm.email.trim() && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(editForm.email.trim())
? "请填写有效的电子邮箱"
: "";
const deathAge = editForm.deathAge === "" ? null : Number(editForm.deathAge);
fieldErrors.deathAge =
deathAge !== null && (!Number.isSafeInteger(deathAge) || deathAge < 0 || deathAge > 200)
? "享年必须是 0 至 200 的整数"
: "";
fieldErrors.bindingMode =
editForm.bindingMode === "SPECIFIED" && !editForm.appUserId
@@ -683,7 +911,7 @@ const validateEditForm = () => {
? "可绑定成员暂不可用,请稍后重试"
: "请选择可绑定成员"
: "";
return !fieldErrors.name && !fieldErrors.dates && !fieldErrors.bindingMode;
return !Object.values(fieldErrors).some(Boolean);
};
const saveMember = async () => {
if (isSubmitting.value || isAvatarUploading.value || !validateEditForm())
@@ -701,6 +929,7 @@ const saveMember = async () => {
? { appUserId: editForm.appUserId }
: {}),
name: editForm.name,
courtesyName: editForm.courtesyName,
aliasName: editForm.aliasName,
sex: editForm.sex,
generation: editForm.generation
@@ -713,11 +942,20 @@ const saveMember = async () => {
birthDate: editForm.birthDate,
birthLunar: editForm.birthLunar,
birthPlace: editForm.birthPlace,
zodiacCode: editForm.zodiac,
currentAddress: editForm.currentAddress,
mobile: editForm.mobile,
email: editForm.email,
educationCode: editForm.education,
occupation: editForm.occupation,
...(isDeceased.value
? {
deathDate: editForm.deathDate,
deathLunar: editForm.deathLunar,
deathAge: editForm.deathAge,
deathPlace: editForm.deathPlace,
deathExpressionCode: editForm.deathType,
burialDate: editForm.burialDate,
burialPlace: editForm.burialPlace,
}
: {}),
@@ -729,6 +967,26 @@ const saveMember = async () => {
{ requestController: memberUpdateRequestController },
);
if (!pageActive) return;
if (originalMember.value.canManageSensitiveMedicalHistory) {
const sensitiveHistory = editForm.hereditaryMedicalHistory.trim();
if (sensitiveHistory) {
await lineageApi.saveSensitiveProfile(
genealogyId.value,
personId.value,
sensitiveHistory,
{ requestController: sensitiveProfileRequestController },
);
originalSensitiveProfilePresent.value = true;
} else if (originalSensitiveProfilePresent.value) {
await lineageApi.clearSensitiveProfile(
genealogyId.value,
personId.value,
{ requestController: sensitiveProfileRequestController },
);
originalSensitiveProfilePresent.value = false;
}
}
if (!pageActive) return;
committedMemberSnapshot.value = currentSnapshot;
baseline.value = currentSnapshot;
}
@@ -792,7 +1050,7 @@ const handleResultAction = () => {
@include adaptive-tree-panel;
z-index: 2;
width: calc(100% - 32rpx);
margin: 18rpx auto 28rpx;
margin: 18rpx auto calc(28rpx + env(safe-area-inset-bottom));
padding: 7.5% 8%;
}
.form-eyebrow {
@@ -847,9 +1105,12 @@ const handleResultAction = () => {
}
.form-field textarea {
width: auto;
min-height: 54rpx;
min-height: var(--app-touch-min);
text-align: left;
}
.form-field input {
min-height: var(--app-touch-min);
}
.form-field__hint,
.upload-receipt {
color: $ink-muted;
@@ -863,7 +1124,7 @@ const handleResultAction = () => {
gap: 6rpx;
}
.upload-button {
min-height: 54rpx;
min-height: var(--app-touch-min);
margin: 0;
padding: 0 16rpx;
border: 1rpx solid rgba(159, 23, 15, 0.42);
@@ -892,7 +1153,7 @@ const handleResultAction = () => {
.form-action {
display: grid;
width: 100%;
min-height: 76rpx;
min-height: var(--app-touch-min);
margin-top: 18rpx;
}
.form-action image,
+22 -9
View File
@@ -63,7 +63,7 @@
v-if="hasMore"
block
type="secondary"
:label="loadingMore ? '正在加载…' : '加载更多成员'"
:label="loadingMore ? '正在加载…' : loadMoreError ? '加载失败,重新加载' : '加载更多成员'"
@click="loadMore"
/>
</template>
@@ -109,9 +109,10 @@ const members = ref([]);
const total = ref(0);
const pageNum = ref(1);
const loadingMore = ref(false);
const loadMoreError = ref(false);
const memberDirectoryRequestController = createRequestController();
let loadSequence = 0;
const hasValidContext = computed(() => Boolean(genealogyId.value));
const hasValidContext = computed(() => /^[1-9]\d*$/.test(genealogyId.value));
const memberMeta = (member) =>
`${member.generation} 世 · ${member.generationName || "字辈待补"} · ${member.branch}`;
const memberStatus = (member) => {
@@ -124,22 +125,30 @@ const hasMore = computed(() => members.value.length < total.value);
const loadMembers = async ({ append = false } = {}) => {
if (!hasValidContext.value) return;
const activeLoad = ++loadSequence;
if (append) loadingMore.value = true;
const requestedPage = append ? pageNum.value + 1 : 1;
if (append) {
loadingMore.value = true;
loadMoreError.value = false;
}
else directoryState.value = "loading";
try {
const personPage = await lineageApi.getPersonPage(
genealogyId.value,
{ pageNum: pageNum.value, pageSize: 10, keyword: keyword.value },
{ pageNum: requestedPage, pageSize: 10, keyword: keyword.value },
{ requestController: memberDirectoryRequestController },
);
if (activeLoad !== loadSequence) return;
members.value = append ? [...members.value, ...personPage.rows] : personPage.rows;
pageNum.value = requestedPage;
total.value = personPage.total;
directoryState.value = members.value.length ? "list" : "empty";
} catch (error) {
if (activeLoad !== loadSequence || isRequestCancelled(error)) return;
if (!append) members.value = [];
if (append) loadMoreError.value = true;
else {
members.value = [];
directoryState.value = "error";
}
} finally {
if (activeLoad === loadSequence) loadingMore.value = false;
}
@@ -153,17 +162,16 @@ onLoad((query) => {
void loadMembers();
});
const searchMembers = () => {
pageNum.value = 1;
loadMoreError.value = false;
void loadMembers();
};
const loadMore = () => {
if (loadingMore.value || !hasMore.value) return;
pageNum.value += 1;
void loadMembers({ append: true });
};
const retryDirectory = () => {
if (!hasValidContext.value) return goBack();
pageNum.value = 1;
loadMoreError.value = false;
return loadMembers();
};
const openMember = (item) =>
@@ -199,16 +207,21 @@ onUnload(() => {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 20rpx;
box-sizing: border-box;
padding: 22rpx 32rpx 0;
}
.directory-context__name {
min-width: 0;
flex: 1;
color: $ink;
font-family: "STKaiti", "KaiTi", serif;
font-size: clamp(17px, 32rpx, 22px);
font-weight: 700;
overflow-wrap: anywhere;
}
.directory-context__meta {
flex: 0 0 auto;
color: #62584c;
font-size: clamp(15px, 24rpx, 18px);
font-weight: 500;
@@ -248,7 +261,7 @@ onUnload(() => {
color: #776956;
}
.directory-content {
padding: 22rpx 24rpx 50rpx;
padding: 22rpx 24rpx calc(50rpx + env(safe-area-inset-bottom));
}
.directory-summary {
display: flex;
+69 -6
View File
@@ -202,7 +202,9 @@ const genealogyName = ref("汤氏家谱");
const memberTrail = reactive([]);
const trailIndex = ref(-1);
const memberReadRequestController = createRequestController();
const sensitiveProfileRequestController = createRequestController();
const memberDeletionRequestController = createRequestController();
const sensitiveProfileError = ref("");
let memberLoadGeneration = 0;
let pageActive = true;
@@ -212,7 +214,8 @@ const profileDetails = computed(() => {
const visibleProfileValue = (profileValue) =>
member.value.status === "forbidden" ? "按权限隐藏" : profileValue;
return [
{ label: "别名", value: member.value.aliasName },
{ label: "表字", value: member.value.courtesyName },
{ label: "别号", value: member.value.aliasName },
{ label: "字辈", value: member.value.generationName },
{
label: "性别",
@@ -227,8 +230,14 @@ const profileDetails = computed(() => {
label: "出生农历",
value: visibleProfileValue(member.value.birthLunarLabel),
},
{ label: "生肖", value: visibleProfileValue(member.value.zodiac) },
{ label: "生卒信息", value: member.value.years },
{ label: "出生地", value: visibleProfileValue(member.value.birthplace) },
{ label: "现居地", value: visibleProfileValue(member.value.currentAddress) },
{ label: "联系电话", value: visibleProfileValue(member.value.mobile) },
{ label: "电子邮箱", value: visibleProfileValue(member.value.email) },
{ label: "教育经历", value: visibleProfileValue(member.value.education) },
{ label: "职业", value: visibleProfileValue(member.value.occupation) },
...(isDeceased.value
? [
{ label: "逝世日期", value: visibleProfileValue(member.value.deathDate) },
@@ -236,7 +245,29 @@ const profileDetails = computed(() => {
label: "逝世农历",
value: visibleProfileValue(member.value.deathLunarLabel),
},
{
label: "享年",
value:
member.value.deathAge === null
? ""
: visibleProfileValue(`${member.value.deathAge}`),
},
{ label: "逝世地", value: visibleProfileValue(member.value.deathPlace) },
{
label: "逝世原因或类型",
value: visibleProfileValue(member.value.deathType),
},
...(member.value.canManageSensitiveMedicalHistory
? [
{
label: "遗传病史",
value: sensitiveProfileError.value
? "敏感资料暂时无法读取"
: visibleProfileValue(member.value.hereditaryMedicalHistory),
},
]
: []),
{ label: "安葬日期", value: visibleProfileValue(member.value.burialDate) },
{ label: "安葬地", value: visibleProfileValue(member.value.burialPlace) },
]
: []),
@@ -307,6 +338,27 @@ const loadMember = async (nextPersonId, { preserveCurrent = false } = {}) => {
normalizedPersonId,
{ requestController: memberReadRequestController },
);
if (nextMember.canManageSensitiveMedicalHistory) {
try {
const sensitiveProfile = await lineageApi.getSensitiveProfile(
genealogyId.value,
normalizedPersonId,
{ requestController: sensitiveProfileRequestController },
);
nextMember.hereditaryMedicalHistory =
sensitiveProfile.hereditaryMedicalHistory;
sensitiveProfileError.value = "";
} catch (error) {
if (isRequestCancelled(error)) return false;
nextMember.hereditaryMedicalHistory = "";
sensitiveProfileError.value = getRequestErrorMessage(
error,
"敏感资料暂时无法读取。",
);
}
} else {
sensitiveProfileError.value = "";
}
if (activeLoadGeneration !== memberLoadGeneration) return false;
if (personId.value && personId.value !== String(nextMember.id)) {
personDocumentDialog.value?.reset();
@@ -415,7 +467,10 @@ const requestBack = () =>
onLoad((query) => {
genealogyId.value = String(query.genealogyId || "");
const initialPersonId = String(query.personId || "");
if (!genealogyId.value || !initialPersonId) {
if (
!/^[1-9]\d*$/.test(genealogyId.value) ||
!/^[1-9]\d*$/.test(initialPersonId)
) {
memberState.value = "error";
errorMessage.value = !initialPersonId
? "没有指定成员,请从世系树重新选择。"
@@ -449,6 +504,7 @@ onUnload(() => {
pageActive = false;
memberLoadGeneration += 1;
memberReadRequestController.abort();
sensitiveProfileRequestController.abort();
memberDeletionRequestController.abort();
});
@@ -517,7 +573,7 @@ const toTree = requestBack;
display: flex;
min-height: 100vh;
flex-direction: column;
padding-bottom: 28rpx;
padding-bottom: calc(28rpx + env(safe-area-inset-bottom));
box-sizing: border-box;
background: $paper;
}
@@ -571,6 +627,10 @@ const toTree = requestBack;
box-shadow: 0 3rpx 7rpx rgba(100, 65, 29, 0.14);
overflow: hidden;
}
.member-heading > view:last-child {
min-width: 0;
overflow-wrap: anywhere;
}
.member-heading > view:last-child text {
display: block;
color: $ink-muted;
@@ -592,6 +652,7 @@ const toTree = requestBack;
grid-template-columns: auto minmax(0, 1fr) auto;
align-items: center;
gap: 14rpx;
min-height: var(--app-touch-min);
margin-top: 18rpx;
padding: 18rpx 22rpx;
}
@@ -637,6 +698,8 @@ const toTree = requestBack;
}
.member-relatives > view {
display: flex;
min-height: var(--app-touch-min);
align-items: center;
justify-content: space-between;
gap: 20rpx;
padding: 12rpx 18rpx;
@@ -661,7 +724,7 @@ const toTree = requestBack;
.member-record-actions > view {
@include adaptive-scroll-button(secondary);
display: flex;
min-height: 72rpx;
min-height: var(--app-touch-min);
align-items: center;
justify-content: center;
padding: 8rpx 14rpx;
@@ -676,7 +739,7 @@ const toTree = requestBack;
.member-profile-action {
@include adaptive-scroll-button(primary);
display: flex;
min-height: 76rpx;
min-height: var(--app-touch-min);
align-items: center;
justify-content: center;
margin-top: 22rpx;
@@ -714,7 +777,7 @@ const toTree = requestBack;
.member-restricted-action {
@include adaptive-scroll-button(secondary);
display: flex;
min-height: 76rpx;
min-height: var(--app-touch-min);
align-items: center;
justify-content: center;
margin-top: 22rpx;
+46 -10
View File
@@ -67,12 +67,25 @@
</view>
</view>
</view>
<AppDialog
:visible="discardVisible"
eyebrow="未保存修改"
title="放弃排行修改?"
message="当前排序值还没有保存。"
confirm-text="确认放弃"
cancel-text="继续编辑"
show-cancel
:close-on-mask="false"
@confirm="confirmDiscard"
@cancel="cancelDiscard"
/>
</view>
</template>
<script setup>
import { ref } from "vue";
import { computed, ref } from "vue";
import { onBackPress, onLoad, onUnload } from "@dcloudio/uni-app";
import AppDialog from "@/components/AppDialog.vue";
import AppLoading from "@/components/AppLoading.vue";
import ModulePageBackground from "@/components/ModulePageBackground.vue";
import PageHeader from "@/components/PageHeader.vue";
@@ -82,7 +95,8 @@ import {
} from "@/services/api/request-controller.js";
import { lineageApi } from "@/services/api/lineage-service.js";
import { getRequestErrorMessage } from "@/services/api/request-error-message.js";
import { goBack, handleBackPress, returnTo } from "@/utils/navigation/gateway.js";
import { createDiscardConfirmation } from "@/utils/discard-confirmation.js";
import { goBack, handleBackPress, returnTo, runBackGuard } from "@/utils/navigation/gateway.js";
const rankState = ref("loading");
const genealogyId = ref("");
@@ -93,10 +107,21 @@ const sortOrder = ref("");
const savingRank = ref(false);
const rankError = ref("");
const committedSortOrder = ref("");
const sortOrderBaseline = ref("");
const discardVisible = ref(false);
const memberRankReadRequestController = createRequestController();
const memberRankSaveRequestController = createRequestController();
let pageActive = true;
let loadSequence = 0;
const discardConfirmation = createDiscardConfirmation((visible) => {
discardVisible.value = visible;
});
const requestDiscardConfirmation = discardConfirmation.request;
const confirmDiscard = discardConfirmation.confirm;
const cancelDiscard = discardConfirmation.cancel;
const isDirty = computed(() =>
rankState.value === "form" && sortOrder.value !== sortOrderBaseline.value,
);
const loadMember = async () => {
const activeLoad = ++loadSequence;
@@ -110,6 +135,7 @@ const loadMember = async () => {
sortOrder.value = personDetail.sortOrder === null || personDetail.sortOrder === undefined
? ""
: String(personDetail.sortOrder);
sortOrderBaseline.value = sortOrder.value;
committedSortOrder.value = "";
errorMessage.value = "";
rankState.value = "form";
@@ -146,6 +172,7 @@ const saveRank = async () => {
);
if (!pageActive) return;
committedSortOrder.value = normalizedSortOrder;
sortOrderBaseline.value = normalizedSortOrder;
}
await returnTo("T01", {
genealogyId: genealogyId.value,
@@ -162,15 +189,23 @@ const saveRank = async () => {
}
};
const requestBack = () => {
if (savingRank.value) return;
return goBack();
};
const requestBack = () => runBackGuard({
transientOpen: discardVisible.value,
dirty: isDirty.value,
submitting: savingRank.value,
"close-transient": cancelDiscard,
"block-submitting": () => true,
"confirm-discard": requestDiscardConfirmation,
});
onLoad((query) => {
genealogyId.value = String(query.genealogyId || "");
personId.value = String(query.personId || "");
if (!genealogyId.value || !personId.value || query.mode !== "rank") {
if (
!/^[1-9]\d*$/.test(genealogyId.value) ||
!/^[1-9]\d*$/.test(personId.value) ||
query.mode !== "rank"
) {
rankState.value = "error";
errorMessage.value = "请从成员资料页重新进入排行调整。";
return;
@@ -183,6 +218,7 @@ onUnload(() => {
loadSequence += 1;
memberRankReadRequestController.abort();
memberRankSaveRequestController.abort();
discardConfirmation.dispose();
});
onBackPress((event) => handleBackPress(event, requestBack));
@@ -204,7 +240,7 @@ onBackPress((event) => handleBackPress(event, requestBack));
@include adaptive-tree-panel;
z-index: 2;
width: calc(100% - 32rpx);
margin: 18rpx auto 28rpx;
margin: 18rpx auto calc(28rpx + env(safe-area-inset-bottom));
padding: 7.5% 8%;
}
.form-eyebrow {
@@ -255,7 +291,7 @@ onBackPress((event) => handleBackPress(event, requestBack));
}
.rank-field input {
min-width: 0;
min-height: 56rpx;
min-height: var(--app-touch-min);
padding: 0 14rpx;
border: 1rpx solid rgba(143, 108, 63, 0.34);
border-radius: 8rpx;
@@ -269,7 +305,7 @@ onBackPress((event) => handleBackPress(event, requestBack));
.form-action {
display: grid;
width: 100%;
min-height: 76rpx;
min-height: var(--app-touch-min);
margin-top: 22rpx;
}
.form-action--disabled { opacity: 0.58; pointer-events: none; }
+3 -3
View File
@@ -107,7 +107,7 @@ const states = {
const activeStatus = computed(() => states[statusState.value] || states.error);
const pageTitle = computed(() => "成员状态");
const hasValidContext = computed(() =>
Boolean(genealogyId.value && personId.value),
/^[1-9]\d*$/.test(genealogyId.value) && /^[1-9]\d*$/.test(personId.value),
);
const memberIdentityCopy = computed(() => {
if (!member.value) return "";
@@ -157,7 +157,7 @@ const handleAction = () => goBack();
display: flex;
min-height: 100vh;
flex-direction: column;
padding-bottom: 34rpx;
padding-bottom: calc(34rpx + env(safe-area-inset-bottom));
box-sizing: border-box;
background: $paper;
}
@@ -236,7 +236,7 @@ const handleAction = () => goBack();
.status-action {
display: grid;
width: calc(100% - 72rpx);
min-height: 76rpx;
min-height: var(--app-touch-min);
margin: 22rpx auto 0;
}
.status-action image,
+12 -30
View File
@@ -158,17 +158,6 @@
@select-action="openMemberAction"
/>
<AppDialog
:visible="unavailableActionVisible"
eyebrow="服务状态"
:title="unavailableAction?.label || '当前操作'"
:message="
unavailableAction?.unavailableCopy || '这项功能还在准备中,暂时无法使用。'
"
confirm-text="我知道了"
@confirm="unavailableActionVisible = false"
@cancel="unavailableActionVisible = false"
/>
</view>
</template>
@@ -176,7 +165,6 @@
import { computed, nextTick, ref } from "vue";
import { onLoad, onShow, onUnload } from "@dcloudio/uni-app";
import AppAvatar from "@/components/AppAvatar.vue";
import AppDialog from "@/components/AppDialog.vue";
import AppLoading from "@/components/AppLoading.vue";
import MemberActionPanel from "@/components/tree/MemberActionPanel.vue";
import ModulePageBackground from "@/components/ModulePageBackground.vue";
@@ -198,8 +186,6 @@ const genealogyId = ref("");
const treeState = ref("loading");
const selectedMember = ref(null);
const memberActionPanelVisible = ref(false);
const unavailableActionVisible = ref(false);
const unavailableAction = ref(null);
const treeScrollLeft = ref(90);
const currentTreeScrollLeft = ref(90);
const centeredTreeScrollLeft = ref(90);
@@ -282,13 +268,6 @@ const memberActions = Object.freeze([
routeKey: "T04",
relationType: memberRelationTypes.DAUGHTER,
},
{
key: "BIND_INVITE",
label: "邀请绑定",
group: "MANAGEMENT",
unavailableCopy:
"邀请绑定暂未开放,请稍后再试。",
},
{
key: "EDIT_PROFILE",
label: "编辑信息",
@@ -370,7 +349,7 @@ const loadTree = async (query = {}) => {
genealogyId.value = String(
query.genealogyId || genealogyContext.getCurrentGenealogyId() || "",
);
if (!genealogyId.value) {
if (!/^[1-9]\d*$/.test(genealogyId.value)) {
members.value = [];
treeState.value = "empty";
return;
@@ -488,7 +467,7 @@ const recenterSelectedMember = async () => {
const nodeGridStyle = treeNodeGridStyle;
const handleStateAction = () => {
if (treeState.value === "empty") {
if (!genealogyId.value) return Promise.resolve(false);
if (!/^[1-9]\d*$/.test(genealogyId.value)) return Promise.resolve(false);
return openPage(
"T04",
{ genealogyId: genealogyId.value, mode: "first" },
@@ -534,13 +513,8 @@ const closeMemberActionPanel = () => {
memberActionPanelVisible.value = false;
};
const openMemberAction = (action) => {
if (!selectedMember.value) return Promise.resolve(false);
if (!selectedMember.value || !action?.routeKey) return Promise.resolve(false);
closeMemberActionPanel();
if (!action.routeKey) {
unavailableAction.value = action;
unavailableActionVisible.value = true;
return Promise.resolve(false);
}
const params = {
genealogyId: genealogyId.value,
personId: String(selectedMember.value.id),
@@ -589,12 +563,20 @@ const openMemberAction = (action) => {
}
.tree-toolbar__actions {
display: flex;
align-items: center;
gap: 24rpx;
color: $brand-red;
font-size: clamp(14px, 23rpx, 17px);
}
.tree-toolbar__actions > text {
display: flex;
min-height: var(--app-touch-min);
align-items: center;
}
.tree-stage {
min-height: 0;
padding-bottom: env(safe-area-inset-bottom);
box-sizing: border-box;
overflow-y: auto;
}
.tree-action--pressed {
@@ -832,7 +814,7 @@ const openMemberAction = (action) => {
.tree-state-card__action {
display: grid;
width: 360rpx;
min-height: 70rpx;
min-height: var(--app-touch-min);
margin: 22rpx auto 0;
}
.tree-state-card__action image {
+24 -4
View File
@@ -52,6 +52,9 @@
<view class="member-node__relation"><text>{{ memberRelation(member) }}</text></view>
<view
class="member-node__name"
:class="{
'member-node__name--horizontal': !isVerticalPedigreeText(member.name),
}"
role="button"
:aria-label="`查看${member.name}的资料`"
@click.stop="openMemberProfile(member)"
@@ -135,7 +138,7 @@
<script setup>
import { computed, ref } from "vue";
import { onLoad, onShow, onUnload } from "@dcloudio/uni-app";
import { onBackPress, onLoad, onShow, onUnload } from "@dcloudio/uni-app";
import AppDialog from "@/components/AppDialog.vue";
import AppLoading from "@/components/AppLoading.vue";
import ModulePageBackground from "@/components/ModulePageBackground.vue";
@@ -147,7 +150,7 @@ import {
import { lineageApi } from "@/services/api/lineage-service.js";
import { getRequestErrorMessage } from "@/services/api/request-error-message.js";
import { genealogyContext } from "@/utils/genealogy/context.js";
import { openPage, returnTo } from "@/utils/navigation/gateway.js";
import { handleBackPress, openPage, returnTo } from "@/utils/navigation/gateway.js";
const PEDIGREE_PAGE_SIZE = 5;
const genealogyId = ref("");
@@ -169,6 +172,10 @@ let skipNextShowRefresh = true;
const compareMembers = (left, right) =>
Number(left.generation) - Number(right.generation) ||
String(left.id).localeCompare(String(right.id));
const isVerticalPedigreeText = (value) =>
/^[\u3400-\u4dbf\u4e00-\u9fff\uf900-\ufaff·〇零一二三四五六七八九十百千万]+$/.test(
String(value || ""),
);
const generationRows = computed(() => {
const groups = new Map();
members.value.forEach((member) => {
@@ -283,6 +290,9 @@ const toTree = () => {
if (selectedId.value) params.selectedId = selectedId.value;
return returnTo("T01", params);
};
onBackPress((event) =>
detailVisible.value ? handleBackPress(event, closeMemberDetail) : false,
);
const handleStateAction = () => {
if (treeState.value === "empty") {
return openPage(
@@ -302,7 +312,7 @@ const loadPedigree = async (query = {}) => {
query.genealogyId || genealogyContext.getCurrentGenealogyId() || "",
);
selectedId.value = String(query.selectedId || "");
if (!genealogyId.value) {
if (!/^[1-9]\d*$/.test(genealogyId.value)) {
members.value = [];
treeState.value = "empty";
return;
@@ -361,6 +371,8 @@ onUnload(() => {
}
.pedigree-stage {
min-height: 0;
padding-bottom: env(safe-area-inset-bottom);
box-sizing: border-box;
}
.pedigree-layout {
display: grid;
@@ -428,6 +440,14 @@ onUnload(() => {
font-size: clamp(19px, 34rpx, 24px);
font-weight: 700;
}
.member-node__name--horizontal {
padding: 12rpx 8rpx;
line-height: 1.2;
overflow-wrap: anywhere;
text-align: center;
text-orientation: mixed;
writing-mode: horizontal-tb;
}
.pedigree-column__detail,
.member-node__copy {
align-items: flex-start;
@@ -550,7 +570,7 @@ onUnload(() => {
.pedigree-state-card__action {
display: grid;
width: 360rpx;
min-height: 70rpx;
min-height: var(--app-touch-min);
margin: 22rpx auto 0;
}
.pedigree-state-card__action image,
+474
View File
@@ -0,0 +1,474 @@
import fs from 'node:fs'
import path from 'node:path'
const workspace = process.cwd()
const failures = []
const read = (filePath) => fs.readFileSync(path.join(workspace, filePath), 'utf8')
const expect = (condition, message) => {
if (!condition) failures.push(message)
}
const listPageSources = (directory = 'pages') => fs.readdirSync(path.join(workspace, directory), { withFileTypes: true })
.flatMap((entry) => {
const childPath = path.join(directory, entry.name)
return entry.isDirectory()
? listPageSources(childPath)
: entry.isFile() && entry.name.endsWith('.vue') ? [childPath] : []
})
const signIn = read('pages/auth/sign-in.vue')
const register = read('pages/auth/register.vue')
const routes = read('utils/navigation/routes.js')
const genealogyHome = read('pages/genealogy/my-genealogies.vue')
const invitationManager = read('components/genealogy/InvitationManager.vue')
const pedigree = read('pages/tree/pedigree.vue')
const treeOverview = read('pages/tree/overview.vue')
const moduleBackground = read('components/ModulePageBackground.vue')
const genealogyBackground = read('components/genealogy/PageBackground.vue')
const vipPage = read('pages/profile/vip.vue')
const promotionsPage = read('pages/profile/promotions.vue')
const helpPage = read('pages/profile/help.vue')
const editProfilePage = read('pages/profile/edit-profile.vue')
const genealogySettingsPage = read('pages/genealogy/settings.vue')
const genealogyCreatePage = read('pages/genealogy/create.vue')
const familyVideosPage = read('pages/family/videos.vue')
const articleEditorPage = read('pages/family/article-editor.vue')
const ceremonyEditorPage = read('pages/records/ceremony-editor.vue')
const ceremonyListPage = read('pages/records/ceremonies.vue')
const articleListPage = read('pages/family/articles.vue')
const personDocumentsPage = read('pages/records/person-documents.vue')
const personDocumentDialog = read('components/tree/PersonDocumentDialog.vue')
const earningsPage = read('pages/profile/earnings.vue')
const relativeRecordsPage = read('pages/records/relative-records.vue')
const growthJournalPage = read('pages/records/growth-journal.vue')
const messageCenterPage = read('pages/notification/message-center.vue')
const changePhonePage = read('pages/profile/change-phone.vue')
const changePasswordPage = read('pages/profile/change-password.vue')
const platformVideosPage = read('pages/family/platform-videos.vue')
const familyMediaContract = read('services/api/family-media-contract.js')
const dialogPages = [
'pages/tree/pedigree.vue',
'pages/family/album-detail.vue',
'pages/records/relative-records.vue',
'pages/genealogy/search.vue',
'pages/family/article-detail.vue',
'pages/notification/message-detail.vue',
'pages/notification/message-center.vue',
'pages/profile/earnings.vue',
]
const vipService = read('services/api/vip-service.js')
const siteContentService = read('services/api/site-content-service.js')
const requestClient = read('services/api/request-client.js')
const requestErrorMessage = read('services/api/request-error-message.js')
const memberDirectoryPage = read('pages/tree/member-directory.vue')
const peoplePage = read('pages/records/people.vue')
const idValidatedPages = [
'pages/tree/add-relative.vue',
'pages/tree/member-rank.vue',
'pages/tree/member-profile.vue',
'pages/tree/member-states.vue',
'pages/genealogy/generation-poems.vue',
'pages/genealogy/overview.vue',
'pages/records/person-detail.vue',
]
const memberRankPage = read('pages/tree/member-rank.vue')
const meritRecordsPage = read('pages/records/merit-records.vue')
const memoPage = read('pages/records/memos.vue')
const lifeRecordContract = read('services/api/life-record-contract.js')
const ceremonyDetailPage = read('pages/records/ceremony-detail.vue')
const relativeRecordEditorPage = read('pages/records/relative-record-editor.vue')
const requestNormalizers = read('services/api/request-normalizers.js')
const profileContract = read('services/api/profile-contract.js')
const genealogyContext = read('utils/genealogy/context.js')
const editMemberPage = read('pages/tree/edit-member.vue')
const addRelativePage = read('pages/tree/add-relative.vue')
const securityPage = read('pages/profile/security.vue')
const lineageService = read('services/api/lineage-service.js')
const lineageWriteContract = read('services/api/lineage-write-contract.js')
const lineagePersonContract = read('services/api/lineage-person-contract.js')
const feedDetailPage = read('pages/family/feed-detail.vue')
const articleDetailPage = read('pages/family/article-detail.vue')
const familyVideosPageSource = read('pages/family/videos.vue')
const genealogyContract = read('services/api/genealogy-contract.js')
const genealogySearchPage = read('pages/genealogy/search.vue')
const generationPoemService = read('services/api/generation-poem-service.js')
const genealogyMemberService = read('services/api/genealogy-member-service.js')
const lifeRecordService = read('services/api/life-record-service.js')
const personDocumentService = read('services/api/person-document-service.js')
const mediaUpload = read('utils/media-upload.js')
const businessFileContract = read('services/api/business-file-contract.js')
const authContract = read('services/api/auth-contract.js')
const authService = read('services/api/auth-service.js')
const contentRecoveryContract = read('services/api/content-password-recovery-contract.js')
const contentRecoveryService = read('services/api/content-password-recovery-service.js')
const referralContract = read('services/api/referral-contract.js')
const permissionContract = read('services/api/genealogy-permission-contract.js')
const genealogyCapabilityService = read('services/api/genealogy-capability-service.js')
const businessDictionaryContract = read('services/api/business-dictionary-contract.js')
const vipContract = read('services/api/vip-contract.js')
const tacLibrary = read('static/tac/js/tac.min.js')
const openApi = read('genealogy-app-openapi.yaml')
const manifest = JSON.parse(read('manifest.json'))
for (const pagePath of listPageSources()) {
expect(!/暂不支持|开发中|敬请期待|准备中/.test(read(pagePath)), `${pagePath} 仍展示占位功能提示`)
}
expect(manifest.name === '代代相传家谱', 'manifest 应用名称与正式产品名称不一致')
expect(!signIn.includes('协议页面准备中'), '登录页仍使用协议占位提示')
expect(!register.includes('协议页面准备中'), '注册页仍使用协议占位提示')
expect(signIn.includes('openComplianceDocument'), '登录页没有接入协议正文导航')
expect(register.includes('openComplianceDocument'), '注册页没有接入协议正文导航')
expect(
/getComplianceDocument[\s\S]*?authenticated:\s*false/.test(siteContentService),
'协议正文请求仍依赖登录状态',
)
const complianceRoute = routes.match(/M13: defineRoute\(\{[\s\S]*?^\s{2}\}\),/m)?.[0] || ''
expect(complianceRoute.includes('"A01"'), '协议正文路由不允许从登录页进入')
expect(complianceRoute.includes('"A04"'), '协议正文路由不允许从注册页进入')
const membersRoute = routes.match(/G13: defineRoute\(\{[\s\S]*?^\s{2}\}\),/m)?.[0] || ''
expect(membersRoute.includes('"G01"'), '成员页路由不允许从家谱首页进入')
expect(
/members:\s*\(\)\s*=>\s*openPage\("G13"/.test(genealogyHome),
'家谱首页“成员”没有直达成员列表',
)
const platformVideosRoute = routes.match(/F11: defineRoute\(\{[\s\S]*?^\s{2}\}\),/m)?.[0] || ''
expect(platformVideosRoute.includes('"G01"'), '宣传视频路由不允许从家谱首页进入')
expect(platformVideosRoute.includes('"videoId"'), '宣传视频路由不能携带指定视频 ID')
expect(
familyMediaContract.includes("HOME_FEATURED: 'home_featured'") &&
familyMediaContract.includes("VIDEO_CENTER: 'video_center'") &&
familyMediaContract.includes("PROFILE_FEATURED: 'profile_featured'"),
'平台视频契约没有完整覆盖后端三个投放位',
)
expect(genealogyHome.includes('featured-media-grid'), '家谱首页没有宣传视频封面预览')
expect(
genealogyHome.includes('PLATFORM_VIDEO_PLACEMENT.HOME_FEATURED'),
'家谱首页没有读取首页推荐视频投放位',
)
expect(
platformVideosPage.includes('video-card__cover-button') &&
platformVideosPage.includes('requestedVideoId'),
'宣传视频列表没有按封面进入指定视频播放',
)
expect(
genealogyHome.includes('genealogyListError') &&
genealogyHome.includes('getRequestErrorMessage'),
'家谱首页仍会隐藏家谱列表的真实失败类型',
)
expect(
articleListPage.includes('articleCategoryError') &&
articleListPage.includes('getRequestErrorMessage'),
'谱文分类读取失败仍被静默伪装为空分类',
)
expect(
!/loadArticleCategories[\s\S]*?catch\s*\([^)]*\)[\s\S]*?return\s+\[\]/.test(articleListPage),
'谱文分类读取失败仍直接返回空数组',
)
expect(
invitationManager.includes('.invitation-manager__row .app-button'),
'邀请记录按钮没有独立收缩规则',
)
expect(
!/invitation-manager__row text:first-child\s*\{[^}]*overflow-wrap:\s*anywhere/.test(invitationManager),
'邀请记录名称仍允许逐字断行',
)
expect(pedigree.includes('isVerticalPedigreeText'), '世系表格没有区分中英文排版')
expect(pedigree.includes('member-node__name--horizontal'), '世系表格缺少英文姓名横排样式')
expect(!pedigree.includes('\\p{Script=Han}'), '世系表格仍使用 Android WebView 不兼容的正则')
expect(!treeOverview.includes('邀请绑定暂未开放'), '世系操作仍展示没有实现的占位入口')
expect(moduleBackground.includes('mode="aspectFill"'), '通用长背景仍按原图高度绘制')
expect(genealogyBackground.includes('mode="aspectFill"'), '家谱长背景仍按原图高度绘制')
expect(vipService.includes('async createVipOrder'), 'VIP service 缺少创建订单接口')
expect(vipService.includes('async getVipPaymentStatus'), 'VIP service 缺少支付状态查询接口')
expect(vipService.includes('async closeVipPayment'), 'VIP service 缺少关闭支付接口')
expect(vipPage.includes('uni.requestPayment'), 'VIP 页面没有接入 App 支付')
expect(vipPage.includes('createVipOrder'), 'VIP 页面没有调用创建订单接口')
expect(vipPage.includes('createNonIdempotentWriteGuard'), 'VIP 创建订单缺少重复下单保护')
expect(vipPage.includes('orderCreationGuard.recordFailure'), 'VIP 下单结果未知时仍允许重复购买')
expect(Boolean(manifest['app-plus']?.modules?.Payment), 'manifest 未启用 Payment 模块')
expect(Boolean(manifest['app-plus']?.modules?.Share), 'manifest 未启用推广页所需的 Share 模块')
expect(
/saveGenerationPoemBatch[\s\S]*?requireData:\s*false/.test(generationPoemService),
'字辈批量保存仍把 VoidResult 当作必须包含 data 的响应',
)
expect(
/transferGenealogyOwner[\s\S]*?requireData:\s*false/.test(genealogyMemberService),
'谱主转让仍把 VoidResult 当作必须包含 data 的响应',
)
expect(
/setGrowthRecordPassword[\s\S]*?requireData:\s*false/.test(lifeRecordService),
'成长记录密码设置仍把无 data 的成功响应当作失败',
)
expect(
/setPersonDocumentPassword[\s\S]*?requireData:\s*false/.test(personDocumentService),
'证件密码设置仍把 VoidResult 当作必须包含 data 的响应',
)
expect(mediaUpload.includes('IMAGE_TYPE_INVALID'), '图片上传缺少选择结果类型复核')
expect(mediaUpload.includes('readNativeFile(await pickNativeVideo(), "视频")'), '视频读取错误仍误称为图片错误')
expect(
businessFileContract.includes("!/^https:\\/\\/[^\\s]+$/.test(accessUrl)"),
'业务文件访问地址没有限制为 HTTPS',
)
expect(
authContract.includes("typeof token !== 'string' || !token || token.trim() !== token"),
'登录响应没有严格校验 access_token',
)
expect(!tacLibrary.includes('res.code'), '行为验证码 SDK 异常分支仍引用未定义的 res.code')
expect(!tacLibrary.includes('validFail(res'), '行为验证码 SDK 异常分支仍传递未定义的 res')
expect(
manifest['app-plus']?.distribute?.android?.permissions?.some((permission) =>
permission.includes('android.permission.READ_MEDIA_VIDEO')),
'Android 视频相册选择缺少 READ_MEDIA_VIDEO 权限',
)
expect(
manifest['app-plus']?.distribute?.android?.permissions?.some((permission) =>
permission.includes('android.permission.READ_EXTERNAL_STORAGE') &&
permission.includes('android:maxSdkVersion="32"')),
'Android 12L 及以下相册选择缺少受限的存储读取权限',
)
expect(
manifest['app-plus']?.distribute?.ios?.privacyDescription?.NSPhotoLibraryUsageDescription,
'iOS 相册选择缺少 NSPhotoLibraryUsageDescription 用途说明',
)
expect(
/promotion-card__actions[^>]*@click\.stop/.test(promotionsPage),
'推广操作按钮会继续触发卡片跳转',
)
expect(
/\.help-article-list\s*\{[^}]*margin-top:\s*18rpx/.test(helpPage),
'帮助中心分类栏与首条问答之间缺少间距',
)
expect(
/\.help-feedback-card text\s*\{[^}]*display:\s*block/.test(helpPage),
'帮助中心反馈标题和说明仍挤在同一行',
)
expect(editProfilePage.includes('createDiscardConfirmation'), '编辑资料页缺少未保存修改确认')
expect(editProfilePage.includes('onBackPress'), '编辑资料页系统返回未接入返回守卫')
expect(editProfilePage.includes('dirty: isDirty.value'), '编辑资料页返回守卫未检查完整资料快照')
expect(genealogySettingsPage.includes('createDiscardConfirmation'), '家谱设置页缺少未保存修改确认')
expect(genealogySettingsPage.includes('onBackPress'), '家谱设置页系统返回未接入返回守卫')
expect(genealogySettingsPage.includes('if (isDirty.value'), '家谱设置页返回守卫未检查设置快照')
expect(familyVideosPage.includes('createDiscardConfirmation'), '家族视频表单缺少未保存修改确认')
expect(familyVideosPage.includes('onBackPress'), '家族视频页系统返回未接入返回守卫')
expect(familyVideosPage.includes('formSnapshot.value !== formBaseline.value'), '家族视频表单返回未检查视频草稿')
expect(!familyVideosPage.includes('video-card__player'), '家族视频列表仍直接铺设播放器')
expect(familyVideosPage.includes('video-card__cover-action'), '家族视频列表没有封面点击播放入口')
expect(ceremonyListPage.includes('item.coverFile?.accessUrl'), '礼仪列表没有消费封面字段')
expect(ceremonyDetailPage.includes('detail.coverFile?.accessUrl'), '礼仪详情没有显示封面')
expect(articleListPage.includes('item.coverFile?.accessUrl'), '谱文列表没有消费封面字段')
expect(articleDetailPage.includes('article.coverFile?.accessUrl'), '谱文详情没有显示封面')
expect(routes.includes('path: "/pages/records/person-documents"'), '缺少家谱级重要证件路由')
expect(personDocumentsPage.includes('<PersonDocumentDialog'), '家谱级重要证件页没有复用证件工作流')
expect(personDocumentDialog.includes('const documentQuery = props.personId'), '重要证件弹窗不支持家谱级聚合查询')
expect(vipContract.includes("orderNo: normalizeVipText(item.orderNo, 'orderNo')"), 'VIP 订单契约没有保留订单号')
expect(vipPage.includes('订单号:{{ item.orderNo }}'), 'VIP 订单页没有显示订单号')
expect(vipPage.includes('支付时间:{{ item.paidAt }}') && vipPage.includes('到期时间:{{ item.expiresAt }}'), 'VIP 订单页没有区分支付和到期时间')
for (const field of ['withdrawalNo', 'auditRemark', 'payoutReference', 'paidAt']) {
expect(earningsPage.includes(`withdrawal.${field}`), `提现记录没有显示 ${field}`)
}
expect(relativeRecordsPage.includes('item.mediaFiles?.[0]?.accessUrl'), '贺礼簿列表没有显示首图')
expect(growthJournalPage.includes('field !== "lineagePersonId"'), '成长记录仍把自动带入人物直接判为用户修改')
const joinApplicationRoute = routes.match(/G08: defineRoute\(\{[\s\S]*?^\s{2}\}\),/m)?.[0] || ''
expect(joinApplicationRoute.includes('"genealogyName"'), '公开家谱申请页路由仍拒绝谱名参数')
expect(messageCenterPage.includes(':active="sourceTab"') && messageCenterPage.includes('returnToSource'), '消息中心没有按真实来源恢复导航')
expect(changePhonePage.includes('请先获取新手机号的验证码'), '换绑手机号仍错误提示当前手机号验证码')
expect(!changePhonePage.includes('请先获取当前手机号的验证码'), '换绑手机号保留了错误的验证码归属文案')
expect(changePhonePage.includes('role="alert"') && changePhonePage.includes(':aria-describedby'), '换绑手机号字段错误缺少无障碍关联')
expect(changePasswordPage.includes('<button') && changePasswordPage.includes(':aria-pressed'), '密码显示开关缺少按钮和状态语义')
expect(changePasswordPage.includes('role="alert"') && changePasswordPage.includes(':aria-describedby'), '修改密码字段错误缺少无障碍关联')
expect(
genealogySettingsPage.indexOf('pageState.value = "form"') < genealogySettingsPage.indexOf('getPermanentDeletionCapability'),
'家谱设置仍被永久注销资格读取阻断',
)
for (const dialogPagePath of dialogPages) {
expect(read(dialogPagePath).includes('onBackPress'), `${dialogPagePath} 的弹窗未接入系统返回`)
}
const iconPaths = []
const collectStrings = (value) => {
if (typeof value === 'string') iconPaths.push(value)
else if (value && typeof value === 'object') Object.values(value).forEach(collectStrings)
}
collectStrings(manifest['app-plus']?.distribute?.icons)
const uniqueIconPaths = [...new Set(iconPaths)]
expect(uniqueIconPaths.length === 17, `App 图标路径数量异常:${uniqueIconPaths.length}`)
for (const iconPath of uniqueIconPaths) {
expect(fs.existsSync(path.join(workspace, iconPath)), `App 图标不存在:${iconPath}`)
}
const privacyPath = path.join(workspace, 'androidPrivacy.json')
expect(fs.existsSync(privacyPath), '缺少 androidPrivacy.json')
if (fs.existsSync(privacyPath)) {
const privacy = JSON.parse(fs.readFileSync(privacyPath, 'utf8'))
expect(privacy.prompt === 'template', 'Android 原生隐私提示未使用 template 模式')
expect(/https:\/\//.test(privacy.message || ''), 'Android 原生隐私提示没有 HTTPS 协议链接')
expect(typeof privacy.backToExit === 'boolean', 'Android 原生隐私提示 backToExit 必须是布尔值')
}
expect(!read('App.vue').includes("console.log('家谱 App 已启动')"), 'App.vue 仍保留启动调试日志')
expect(
/HTTP_ERROR'[\s\S]*?httpStatus\) === 401/.test(requestClient),
'请求层没有把 HTTP 401 识别为会话失效',
)
expect(
/statusCode < 200[\s\S]*?recoverExpiredAuthenticatedSession/.test(requestClient),
'普通请求收到 HTTP 401 时没有恢复登录状态',
)
expect(
/expectedStatus !== null[\s\S]*?recoverExpiredAuthenticatedSession/.test(requestClient),
'严格请求收到 HTTP 401 时没有恢复登录状态',
)
expect(requestClient.includes('const FILE_UPLOAD_TIMEOUT_MS = 120000'), '文件上传没有独立的超时配置')
expect(
(requestClient.match(/timeout: FILE_UPLOAD_TIMEOUT_MS|}, FILE_UPLOAD_TIMEOUT_MS\)/g) || []).length === 2,
'原生和浏览器文件上传没有统一使用独立超时配置',
)
expect(
requestClient.includes('rejectAuthenticatedUploadStatus'),
'文件上传收到 HTTP 401 时没有恢复登录状态',
)
expect(
/unwrapAuthenticatedUploadResponse[\s\S]*?isAuthenticatedSessionRejected[\s\S]*?recoverExpiredAuthenticatedSession/.test(requestClient),
'文件上传收到业务 401 时没有恢复登录状态',
)
expect(
/HTTP_ERROR'[\s\S]*?httpStatus\) === 403/.test(requestErrorMessage),
'HTTP 403 没有使用无权限提示',
)
for (const [pageName, pageSource] of [
['成员目录', memberDirectoryPage],
['人物列表', peoplePage],
]) {
expect(pageSource.includes('const requestedPage = append ? pageNum.value + 1 : 1'), `${pageName} 没有延迟提交分页页码`)
expect(!pageSource.includes('pageNum.value += 1'), `${pageName} 仍会在请求成功前递增页码`)
expect(pageSource.includes('if (append) loadMoreError.value = true'), `${pageName} 加载更多失败会破坏现有列表`)
expect(pageSource.includes('/^[1-9]\\d*$/.test(genealogyId.value)'), `${pageName} 没有校验家谱参数`)
}
for (const pagePath of idValidatedPages) {
expect(read(pagePath).includes('/^[1-9]\\d*$/.test(genealogyId.value)'), `${pagePath} 没有校验家谱 ID`)
}
expect(memberRankPage.includes('createDiscardConfirmation'), '成员排行页缺少未保存修改确认')
expect(memberRankPage.includes('dirty: isDirty.value'), '成员排行页返回守卫未检查排序修改')
expect(memberRankPage.includes('sortOrderBaseline.value = normalizedSortOrder'), '成员排行保存成功后没有更新草稿基准')
expect(editProfilePage.includes('original[field] = payload[field]'), '编辑资料保存成功后没有更新草稿基准')
expect(meritRecordsPage.includes('/^(?:0|[1-9]\\d{0,9})(?:\\.\\d{1,2})?$/'), '功德记录页面没有限制金额精度和范围')
expect(lifeRecordContract.includes("normalizeOptionalCurrencyNumber(payload.amount, '功德金额')"), '功德记录 API 契约没有限制金额精度和范围')
expect(requestNormalizers.includes('normalizeOptionalCurrencyNumber'), '请求契约缺少统一金额校验')
expect(ceremonyDetailPage.includes('献礼金额应为 0 至 9999999999.99'), '礼仪献礼页面没有限制金额精度和范围')
expect(relativeRecordEditorPage.includes('礼金金额应为 0 至 9999999999.99'), '亲友往来页面没有限制金额精度和范围')
expect(profileContract.includes('const isCalendarDate'), '个人资料契约没有验证真实日期')
expect(profileContract.includes("birthday && !isCalendarDate(birthday.slice(0, 10))"), '个人资料响应没有验证生日')
expect(genealogyContext.includes("/^[1-9]\\d*$/.test(genealogyId)"), '家谱上下文允许缓存无效家谱 ID')
expect(
editMemberPage.match(/\/\^\[1-9\]\\d\*\$\/.test/g)?.length >= 4,
'修改成员页没有完整校验家谱和人物 ID',
)
expect(
/v-if="feed\.canDelete"[\s\S]*?label="删除动态"/.test(feedDetailPage),
'动态详情向无删除权限用户显示删除按钮',
)
expect(
articleDetailPage.includes('article.canEdit || article.canDelete || article.canManageProtection'),
'谱文仅有内容密码管理权限时不显示密码操作',
)
expect(
/v-if="video\.canDelete"[\s\S]*?:label="deletingVideoId/.test(familyVideosPageSource),
'视频列表向无删除权限用户显示删除按钮',
)
expect(genealogyContract.includes('hasMembership: item.canManage === true || Boolean(memberStatus)'), '公开家谱契约用管理权限代替成员关系')
expect(genealogySearchPage.includes(':disabled="item.hasMembership"'), '公开家谱对普通成员仍显示申请加入')
expect(treeOverview.includes('/^[1-9]\\d*$/.test(genealogyId.value)'), '树状图没有校验家谱 ID')
expect(pedigree.includes('/^[1-9]\\d*$/.test(genealogyId.value)'), '世系谱没有校验家谱 ID')
const articleCategoryContract = openApi.slice(
openApi.indexOf(' /genealogy/app/genealogies/{genealogyId}/article-categories:'),
openApi.indexOf(' /genealogy/app/genealogies/{genealogyId}/articles:'),
)
expect(articleCategoryContract.includes('AppArticleCategoryResult'), '谱文分类写接口没有返回明确的分类对象')
expect(!articleCategoryContract.includes('PaymentOrderVo'), '谱文分类接口仍错误引用支付下单模型')
expect(/loginWithWechat[\s\S]*?data:\s*\{\s*code:/.test(authService), '微信登录没有按最新后端契约只提交一次性 code')
expect(authService.includes('ACCOUNT_BINDING_REQUIRED'), '微信登录没有处理后端要求的账号绑定状态')
expect(securityPage.includes('authApi.bindWechat'), '账号与安全页没有提供微信绑定入口')
expect(contentRecoveryService.includes('/capability`'), '内容密码找回能力仍调用旧路径')
expect(/resetPassword[\s\S]*?method:\s*'POST'/.test(contentRecoveryService), '内容密码重置仍使用旧 HTTP 方法')
expect(/sendCode[\s\S]*?requireData:\s*false/.test(contentRecoveryService), '内容密码验证码发送仍要求后端返回旧版 delivery 对象')
expect(contentRecoveryContract.includes('value.available'), '内容密码找回能力仍读取旧版 enabled 字段')
expect(contentRecoveryContract.includes('value.mobileMasked'), '内容密码找回能力仍读取旧版 maskedPhone 字段')
expect(referralContract.includes('referredUserCount'), '推广资料没有接入后端返回的推荐人数')
expect(referralContract.includes('value.shareUrl'), '推广资料没有读取后端提供的 HTTPS 分享链接')
expect(promotionsPage.includes('profile.shareUrl'), '推广中心分享内容没有使用后端提供的分享链接')
expect(profileContract.includes('currentPassword: payload.currentPasswordHash.toLowerCase()'), '手机号换绑没有提交当前密码摘要')
expect(changePhonePage.includes('calcMD5(currentPassword.value)'), '手机号换绑没有按现有认证契约处理当前密码')
expect(changePhonePage.includes('session.clear()') && changePhonePage.includes('goRoot("A01")'), '手机号换绑成功后没有清理登录态并返回登录页')
expect(changePasswordPage.includes('session.clear()') && changePasswordPage.includes('goRoot("A01")'), '密码修改成功后没有清理已失效登录态')
expect(genealogyContract.includes("'requestId'") && genealogyContract.includes("'ownerIsFirstAncestor'"), '建谱契约缺少幂等请求号或谱主始迁祖标记')
expect(genealogyCreatePage.includes('genealogyCreateRequestId') && genealogyCreatePage.includes('ownerIsFirstAncestor'), '建谱页面没有提交稳定请求号或谱主始迁祖选择')
expect(genealogyContract.includes('normalizedPayload.coverOssId = null'), '家谱封面契约不能发送 null 清空')
for (const [pageSource, pageName] of [[genealogySettingsPage, '家谱'], [articleEditorPage, '谱文'], [ceremonyEditorPage, '礼仪'], [familyVideosPage, '视频']]) {
expect(pageSource.includes('移除封面'), `${pageName}编辑页没有提供移除封面操作`)
}
expect(lifeRecordContract.includes("BENEFACTOR: 'benefactor'"), '备忘契约缺少家族恩人类型')
expect(memoPage.includes('memoType: memoType.value'), '家族恩人页面没有提交 benefactor 类型')
expect(memoPage.includes('memo.memoType === memoType.value'), '家族恩人列表没有按类型过滤')
for (const contractSource of [lifeRecordContract, familyMediaContract, read('services/api/ceremony-contract.js')]) {
expect(contractSource.includes('createTime:'), '资源响应契约没有保留后端 createTime')
}
expect(permissionContract.includes('item.code'), '权限目录仍读取旧版 permissionCode 字段')
expect(permissionContract.includes('item.groupName'), '权限目录没有接入后端权限分组')
expect(genealogyCapabilityService.includes('/comments/page`'), '家族视频根评论仍调用旧列表路径')
expect(genealogyCapabilityService.includes('/replies/page`'), '家族视频回复仍调用旧列表路径')
expect(/getVideoComments[\s\S]*?assertPage/.test(genealogyCapabilityService), '家族视频评论没有读取后端分页响应')
expect(genealogyContract.includes('genealogyName: payload.confirmationName.trim()'), '家谱永久删除仍提交旧版 confirmationName 字段')
expect(/getPermanentDeletionCapability[\s\S]*?permanent-deletion\/capability/.test(vipService + genealogyCapabilityService + read('services/api/genealogy-service.js')), '家谱设置没有接入永久删除能力接口')
expect(vipContract.includes('item.method'), 'VIP 支付能力没有读取后端 VO 的 method 字段')
expect(!vipContract.includes('item.paymentMethod'), 'VIP 支付能力仍读取与后端 VO 不一致的 paymentMethod 字段')
expect(vipContract.includes('value.orderString'), '支付宝下单仍读取旧版 alipayOrderInfo 字段')
expect(vipContract.includes('value.completed !== true'), '余额支付没有校验后端 completed 字段')
expect(/createVipOrder[\s\S]*?requestId/.test(vipService), 'VIP 下单没有提交后端必填幂等 requestId')
for (const dictionaryType of [
'gen_parent_relationship_variant',
'gen_education_type',
'gen_death_expression',
'gen_spouse_relationship_variant',
'gen_ceremony_type',
'gen_person_document_type',
'gen_growth_record_type',
'gen_merit_type',
'gen_feedback_type',
'gen_zodiac',
]) {
expect(businessDictionaryContract.includes(`'${dictionaryType}'`), `前端业务字典白名单缺少 ${dictionaryType}`)
}
for (const latestLineageField of ['zodiacCode', 'educationCode', 'deathExpressionCode', 'relationVariantCode']) {
expect(lineageWriteContract.includes(`'${latestLineageField}'`), `成员写契约缺少最新字段 ${latestLineageField}`)
}
expect(!lineageWriteContract.includes("'hereditaryMedicalHistory'"), '成员主档仍混入遗传病史敏感字段')
expect(lineageService.includes('/sensitive-profile`'), '成员敏感健康资料没有接入独立接口')
expect(lineagePersonContract.includes('value.zodiacCode'), '成员详情仍读取旧版生肖字段')
expect(lineagePersonContract.includes('value.educationCode'), '成员详情仍读取旧版学历字段')
expect(lineagePersonContract.includes('value.deathExpressionCode'), '成员详情仍读取旧版逝世表述字段')
for (const pageSource of [addRelativePage, editMemberPage]) {
expect(pageSource.includes('gen_zodiac'), '成员表单没有读取生肖动态字典')
expect(pageSource.includes('gen_education_type'), '成员表单没有读取学历动态字典')
expect(pageSource.includes('gen_death_expression'), '成员表单没有读取逝世表述动态字典')
expect(pageSource.includes('dictionaryRequestControllers'), '成员表单的并发字典请求仍共用一个取消控制器')
}
expect(
genealogySettingsPage.includes('deletionCapabilityReadRequestController'),
'家谱设置和永久删除能力的并发读取仍共用一个取消控制器',
)
if (failures.length) {
console.error(`AUDIT REGRESSION CHECK FAILED (${failures.length})`)
failures.forEach((failure) => console.error(`- ${failure}`))
process.exit(1)
}
console.log(`AUDIT REGRESSION CHECK PASS icons=${uniqueIconPaths.length}`)
+123
View File
@@ -0,0 +1,123 @@
import fs from 'node:fs'
import path from 'node:path'
const workspaceRoot = process.cwd()
const referenceRoot = path.resolve(workspaceRoot, '..', 'Jiapu-App')
const referencePagesPath = path.join(referenceRoot, 'pages.json')
const parityDocumentPath = path.join(
workspaceRoot,
'docs',
'frontend-reference-parity-2026-08-17.md'
)
const stripJsonLineComments = (source) =>
source
.split(/\r?\n/u)
.map((line) => {
let inString = false
let escaped = false
for (let index = 0; index < line.length - 1; index += 1) {
const character = line[index]
if (escaped) {
escaped = false
continue
}
if (character === '\\' && inString) {
escaped = true
continue
}
if (character === '"') {
inString = !inString
continue
}
if (!inString && character === '/' && line[index + 1] === '/') {
return line.slice(0, index)
}
}
return line
})
.join('\n')
const fail = (message) => {
console.error(`FRONTEND PARITY CHECK FAIL: ${message}`)
process.exit(1)
}
if (!fs.existsSync(referencePagesPath)) {
fail(`参考项目不存在:${referencePagesPath}`)
}
if (!fs.existsSync(parityDocumentPath)) {
fail(`对比总表不存在:${parityDocumentPath}`)
}
const referenceConfig = JSON.parse(
stripJsonLineComments(fs.readFileSync(referencePagesPath, 'utf8'))
)
const referenceRoutes = referenceConfig.pages.map(({ path: routePath }) => routePath)
if (referenceRoutes.length !== 78) {
fail(`参考项目活动路由应为 78 条,实际为 ${referenceRoutes.length}`)
}
if (new Set(referenceRoutes).size !== referenceRoutes.length) {
fail('参考项目 pages.json 存在重复活动路由')
}
for (const routePath of referenceRoutes) {
const vuePath = path.join(referenceRoot, `${routePath}.vue`)
const nvuePath = path.join(referenceRoot, `${routePath}.nvue`)
if (!fs.existsSync(vuePath) && !fs.existsSync(nvuePath)) {
fail(`参考路由没有 .vue 或 .nvue 页面文件:${routePath}`)
}
}
const parityDocument = fs.readFileSync(parityDocumentPath, 'utf8')
const documentedRouteRows = parityDocument
.split(/\r?\n/u)
.filter((line) => /^\| `[^`]+` \|/u.test(line))
.map((line) => line.split('|').slice(1, -1).map((column) => column.trim()))
const documentedRoutes = documentedRouteRows.map(([routeCell]) => routeCell.slice(1, -1))
const allowedStatuses = new Set([
'覆盖',
'合并覆盖',
'升级替代',
'非产品页',
'本轮补齐',
'部分覆盖',
'部分合并',
'部分升级',
'前端待后端'
])
for (const columns of documentedRouteRows) {
if (columns.length !== 5 || columns.some((column) => !column)) {
fail(`对比总表路由行字段不完整:${columns[0] || '未知路由'}`)
}
if (!allowedStatuses.has(columns[3])) {
fail(`对比总表路由状态无效:${columns[0]} -> ${columns[3]}`)
}
}
const documentedRouteCounts = new Map()
for (const routePath of documentedRoutes) {
documentedRouteCounts.set(
routePath,
(documentedRouteCounts.get(routePath) || 0) + 1
)
}
const missingRoutes = referenceRoutes.filter(
(routePath) => !documentedRouteCounts.has(routePath)
)
const duplicateRoutes = [...documentedRouteCounts.entries()]
.filter(([, count]) => count !== 1)
.map(([routePath]) => routePath)
const unknownRoutes = documentedRoutes.filter(
(routePath) => !referenceRoutes.includes(routePath)
)
if (missingRoutes.length) fail(`对比总表遗漏路由:${missingRoutes.join(', ')}`)
if (duplicateRoutes.length) fail(`对比总表重复路由:${duplicateRoutes.join(', ')}`)
if (unknownRoutes.length) fail(`对比总表包含未知路由:${unknownRoutes.join(', ')}`)
console.log(
`FRONTEND PARITY CHECK PASS referenceRoutes=${referenceRoutes.length} documentedRoutes=${documentedRoutes.length}`
)
+40
View File
@@ -0,0 +1,40 @@
import assert from 'node:assert/strict'
import fs from 'node:fs'
const toModuleUrl = (source) =>
`data:text/javascript;base64,${Buffer.from(source).toString('base64')}`
const routesSource = fs.readFileSync('utils/navigation/routes.js', 'utf8')
const gatewaySource = fs.readFileSync('utils/navigation/gateway.js', 'utf8')
.replace('"./routes.js"', JSON.stringify(toModuleUrl(routesSource)))
let currentPage = { route: 'pages/profile/home' }
const relaunchCalls = []
globalThis.getCurrentPages = () => [currentPage]
globalThis.uni = {
reLaunch(options) {
relaunchCalls.push(options)
},
}
const { goRoot, recoverToAuthRoot } = await import(toModuleUrl(gatewaySource))
const ordinaryNavigation = goRoot('G01')
const sessionRecovery = recoverToAuthRoot()
assert.equal(relaunchCalls.length, 1, '登录恢复应等待已有转场完成')
assert.equal(relaunchCalls[0].url, '/pages/genealogy/my-genealogies')
currentPage = { route: 'pages/genealogy/my-genealogies' }
relaunchCalls[0].success()
await ordinaryNavigation
await Promise.resolve()
assert.equal(relaunchCalls.length, 2, '已有转场完成后必须继续登录恢复')
assert.equal(relaunchCalls[1].url, '/pages/auth/sign-in')
currentPage = { route: 'pages/auth/sign-in' }
relaunchCalls[1].success()
assert.equal(await sessionRecovery, true)
console.log('NAVIGATION RECOVERY CHECK PASS')
+145 -2
View File
@@ -40,6 +40,26 @@ parseJson('manifest.json')
parseJson('package.json')
parseJson('package-lock.json')
const runtimeConfigSource = readText('utils/runtime-config.js')
if (runtimeConfigSource.includes('import.meta.env')) {
fail('运行时配置直接读取 import.meta.env,会把构建机环境写入生产资源')
}
if (/\bmock\b|isMockMode|resolveRuntimeMode/.test(runtimeConfigSource)) {
fail('正式运行时配置仍保留不可达的 mock 双轨逻辑')
}
for (const productionValue of [
"const configuredBaseUrl = 'https://backend-api.ddxcjp.cn'",
"const configuredClientId = '428a8310cd442757ae699df5d894f051'",
"const configuredTenantId = '000000'",
]) {
if (!runtimeConfigSource.includes(productionValue)) {
fail(`运行时正式配置不完整:${productionValue}`)
}
}
if (fs.existsSync(path.join(workspace, 'data', 'preview'))) {
fail('正式项目仍保留不可达的本地预览数据目录 data/preview')
}
const routeSource = readText('utils/navigation/routes.js')
const routeEntries = [...routeSource.matchAll(/^\s{2}([A-Z]\d{2}): defineRoute\(\{[\s\S]*?^\s{2}\}\),/gm)]
const routeKeys = routeEntries.map((match) => match[1])
@@ -60,9 +80,17 @@ const sourceFiles = listFiles(
['App.vue', 'main.js', 'pages', 'components', 'composables', 'services', 'utils'],
new Set(['.js', '.vue']),
)
if (/hasRemoteConfig|REMOTE_(?:READ|WRITE)_REQUIRED|WRITE_UNAVAILABLE|本地预览/.test(
sourceFiles.map((filePath) => readText(filePath)).join('\n')
)) {
fail('正式源码仍保留已经不可达的本地预览守卫或错误码')
}
const importPattern = /(?:from\s*|import\s*)["']([^"']+)["']/g
for (const filePath of sourceFiles) {
const source = readText(filePath)
if (source.includes('@/data/preview')) {
fail(`${filePath} 仍引用本地预览数据`)
}
if (filePath.endsWith('.vue') && !/<(?:template|script)(?:\s|>)/.test(source)) {
fail(`${filePath} 缺少 <template> 或 <script>`)
}
@@ -101,27 +129,142 @@ if (fs.existsSync(path.join(workspace, '家谱.openapi.json'))) {
fail('检测到旧 OpenAPI:家谱.openapi.jsongenealogy-app-openapi.yaml 必须是唯一所有者')
}
const resolveLocalOpenApiRef = (reference) => {
if (!openApi || typeof reference !== 'string' || !reference.startsWith('#/')) return undefined
return reference
.slice(2)
.split('/')
.map((segment) => segment.replace(/~1/g, '/').replace(/~0/g, '~'))
.reduce((value, segment) => value?.[segment], openApi)
}
const visitOpenApiNode = (value, location = '$') => {
if (Array.isArray(value)) {
value.forEach((entry, index) => visitOpenApiNode(entry, `${location}[${index}]`))
return
}
if (!value || typeof value !== 'object') return
for (const [key, child] of Object.entries(value)) {
if (key === '$ref' && typeof child === 'string' && child.startsWith('#/') && resolveLocalOpenApiRef(child) === undefined) {
fail(`OpenAPI 引用了不存在的本地定义:${child}${location}`)
continue
}
visitOpenApiNode(child, `${location}.${key}`)
}
}
visitOpenApiNode(openApi)
if (openApi?.servers?.[0]?.url !== 'https://backend-api.ddxcjp.cn') {
fail('OpenAPI 首选服务地址必须与正式运行时 HTTPS 接口一致')
}
for (const [endpointPath, pathItem] of Object.entries(openApi?.paths || {})) {
const requiredPathParams = [...endpointPath.matchAll(/\{([^}]+)\}/g)].map((match) => match[1])
for (const method of ['get', 'post', 'put', 'patch', 'delete']) {
const operation = pathItem[method]
if (!operation) continue
const declaredPathParams = [...(pathItem.parameters || []), ...(operation.parameters || [])]
.map((parameter) => parameter?.$ref ? resolveLocalOpenApiRef(parameter.$ref) : parameter)
.filter((parameter) => parameter?.in === 'path')
.map((parameter) => parameter.name)
for (const name of requiredPathParams) {
if (!declaredPathParams.includes(name)) {
fail(`OpenAPI 路径参数未声明:${method.toUpperCase()} ${endpointPath} 缺少 ${name}`)
}
}
}
}
for (const compliancePath of [
'/genealogy/app/compliance/documents/{documentKey}',
'/genealogy/app/compliance/documents/{documentKey}/versions/{versionNo}',
]) {
const security = openApi?.paths?.[compliancePath]?.get?.security
if (!Array.isArray(security) || security.length !== 0) {
fail(`OpenAPI 合规文档必须允许登录前读取:GET ${compliancePath}`)
}
}
for (const contentProtectionPath of [
'/genealogy/app/genealogies/{genealogyId}/articles/{articleId}/content-protection',
'/genealogy/app/genealogies/{genealogyId}/growth-records/{recordId}/content-protection',
]) {
for (const method of ['put', 'delete']) {
const schemaRef = openApi?.paths?.[contentProtectionPath]?.[method]
?.responses?.['200']?.content?.['application/json']?.schema?.$ref
if (schemaRef !== '#/components/schemas/RVoid') {
fail(`OpenAPI 内容密码写操作必须返回 RVoid:${method.toUpperCase()} ${contentProtectionPath}`)
}
}
}
for (const [schemaName, expectedGrantType] of [
['PasswordRegisterBody', 'password'],
['PasswordLoginBody', 'password'],
['SmsLoginBody', 'sms'],
['SmsCodeBody', 'sms'],
['PasswordResetBody', 'password'],
]) {
const grantType = openApi?.components?.schemas?.[schemaName]?.properties?.grantType
if (!Array.isArray(grantType?.enum) || grantType.enum.length !== 1 || grantType.enum[0] !== expectedGrantType) {
fail(`OpenAPI ${schemaName}.grantType 必须固定为 ${expectedGrantType}`)
}
}
if (openApi?.components?.schemas?.PasswordChangeBody?.additionalProperties !== false) {
fail('OpenAPI PasswordChangeBody 必须拒绝未知字段')
}
const loginSchema = openApi?.components?.schemas?.LoginVo
if (
loginSchema?.additionalProperties !== false ||
!Array.isArray(loginSchema?.required) ||
loginSchema.required.length !== 1 ||
loginSchema.required[0] !== 'access_token' ||
!loginSchema?.properties?.access_token ||
['token', 'accessToken', 'tokenValue'].some((field) => loginSchema?.properties?.[field])
) {
fail('OpenAPI LoginVo 必须只以 access_token 作为会话令牌契约')
}
const normalizeEndpointPath = (endpointPath) => endpointPath
.split('?')[0]
.replace(/\$\{[^}]+\}/g, '{}')
.replace(/\{[^}]+\}/g, '{}')
const documentedPaths = new Set(Object.keys(openApi?.paths || {}).map(normalizeEndpointPath))
const documentedOperations = new Set(
Object.entries(openApi?.paths || {}).flatMap(([endpointPath, pathItem]) =>
['get', 'post', 'put', 'patch', 'delete']
.filter((method) => pathItem?.[method])
.map((method) => `${method.toUpperCase()} ${normalizeEndpointPath(endpointPath)}`),
),
)
const dynamicEndpointBuilders = new Set([
'/genealogy/app/genealogies/{}/{}',
'/genealogy/app/genealogies/{}/lineage/persons/{}/{}',
])
const servicePathBuilders = new Set([
'/genealogy/app/genealogies/{}/content-password-recovery/{}/{}',
])
const apiFiles = listFiles(['services/api'], new Set(['.js']))
.filter((filePath) => filePath.endsWith('-service.js') || filePath.endsWith('request-client.js'))
const endpointPattern = /([`'"])(\/(?:genealogy|captcha|auth)[\s\S]*?)\1/g
const serviceOperationPattern = /url:\s*([`'"])(\/(?:genealogy|captcha|auth)[^`'"\r\n]*)\1,\s*method:\s*(['"])(GET|POST|PUT|PATCH|DELETE)\3/g
for (const filePath of apiFiles) {
for (const match of readText(filePath).matchAll(endpointPattern)) {
const source = readText(filePath)
for (const match of source.matchAll(endpointPattern)) {
const endpoint = match[2]
if (endpoint.includes('\n')) continue
const normalizedPath = normalizeEndpointPath(endpoint)
if (!documentedPaths.has(normalizedPath) && !dynamicEndpointBuilders.has(normalizedPath)) {
if (!documentedPaths.has(normalizedPath) && !dynamicEndpointBuilders.has(normalizedPath) && !servicePathBuilders.has(normalizedPath)) {
fail(`${filePath} 使用了 OpenAPI 未声明的路径:${endpoint}`)
}
}
for (const match of source.matchAll(serviceOperationPattern)) {
const endpoint = normalizeEndpointPath(match[2])
if (dynamicEndpointBuilders.has(endpoint)) continue
const operation = `${match[4]} ${endpoint}`
if (!documentedOperations.has(operation)) {
fail(`${filePath} 使用了 OpenAPI 未声明的操作:${operation}`)
}
}
}
if (failures.length > 0) {
+20 -10
View File
@@ -1,15 +1,6 @@
import { resolveRuntimeMode } from '@/utils/runtime-config.js'
import { AUTH_VERIFICATION_OPERATION } from '@/utils/auth/verification.js'
import { session } from '@/utils/session.js'
export const requireRemoteAuth = () => {
if (resolveRuntimeMode() !== 'remote') {
const error = new Error('当前为本地预览模式,真实认证服务未启用')
error.code = 'AUTH_REMOTE_REQUIRED'
throw error
}
}
export const assertAuthVerificationOperation = (operationCode) => {
if (!Object.values(AUTH_VERIFICATION_OPERATION).includes(operationCode)) {
throw new TypeError('认证动作不属于当前认证合同')
@@ -34,9 +25,28 @@ export const assertPasswordHash = (passwordHash) => {
return passwordHash
}
export const normalizeOptionalReferralCode = (referralCode) => {
if (referralCode === undefined || referralCode === null || referralCode === '') return ''
if (typeof referralCode !== 'string') throw new TypeError('推荐码必须是字符串')
const normalized = referralCode.trim()
if (!/^[A-Za-z0-9_-]{4,64}$/.test(normalized)) {
throw new TypeError('推荐码格式无效')
}
return normalized
}
export const assertWechatAuthorizationCode = (authorizationCode) => {
if (typeof authorizationCode !== 'string' || !authorizationCode.trim()) {
throw new TypeError('微信授权未返回有效临时票据')
}
return authorizationCode.trim()
}
export const saveLogin = (loginResult) => {
const token = loginResult?.access_token
if (!token) throw new Error('登录响应未包含会话令牌')
if (typeof token !== 'string' || !token || token.trim() !== token) {
throw new Error('登录响应未包含有效的会话令牌')
}
session.saveToken(token)
return loginResult
}
+38 -14
View File
@@ -1,15 +1,17 @@
import { assertSmsCode } from '@/utils/auth/verification.js'
import { resolveRuntimeMode, runtimeConfig } from '@/utils/runtime-config.js'
import { runtimeConfig } from '@/utils/runtime-config.js'
import {
assertAuthVerificationOperation,
assertPasswordHash,
assertWechatAuthorizationCode,
normalizeOptionalReferralCode,
normalizeOptionalValidToken,
requireRemoteAuth,
saveLogin
} from './auth-contract.js'
import { normalizeOptionalText } from './request-normalizers.js'
import { normalizePhoneChangePayload } from './profile-contract.js'
import {
createRequestError,
requestAuth,
requestAuthVoid,
requestStrict
@@ -17,7 +19,6 @@ import {
export const authApi = {
async getCaptchaRequirement({ operationCode, subject }, requestOptions = {}) {
requireRemoteAuth()
return requestAuth({
url: `/genealogy/app/auth/verification/${encodeURIComponent(assertAuthVerificationOperation(operationCode))}/require`,
method: 'GET',
@@ -26,7 +27,6 @@ export const authApi = {
},
async sendSmsCode({ operationCode, phone, validToken }, requestOptions = {}) {
requireRemoteAuth()
const normalizedValidToken = normalizeOptionalValidToken(validToken)
return requestAuthVoid({
url: `/genealogy/app/auth/sms/${encodeURIComponent(assertAuthVerificationOperation(operationCode))}/code`,
@@ -41,7 +41,6 @@ export const authApi = {
},
async loginWithPassword({ phone, passwordHash, validToken }, requestOptions = {}) {
requireRemoteAuth()
const normalizedValidToken = normalizeOptionalValidToken(validToken)
const loginSession = await requestAuth({
url: '/genealogy/app/auth/login',
@@ -58,7 +57,6 @@ export const authApi = {
},
async loginWithSms({ phone, smsCode }, requestOptions = {}) {
requireRemoteAuth()
const loginSession = await requestAuth({
url: '/genealogy/app/auth/login/sms',
method: 'POST',
@@ -72,9 +70,39 @@ export const authApi = {
return saveLogin(loginSession)
},
async registerWithPassword({ phone, passwordHash, smsCode, nickName }, requestOptions = {}) {
requireRemoteAuth()
async loginWithWechat({ code }, requestOptions = {}) {
const loginResult = await requestAuth({
url: '/genealogy/app/auth/login/wechat',
method: 'POST',
data: { code: assertWechatAuthorizationCode(code) }
}, requestOptions)
if (loginResult?.status === 'ACCOUNT_BINDING_REQUIRED') {
throw createRequestError(
'该微信尚未绑定账号,请先用手机号登录,再到“账号与安全”绑定微信',
'ACCOUNT_BINDING_REQUIRED'
)
}
if (loginResult?.status !== 'AUTHENTICATED' || !loginResult.login) {
throw createRequestError('微信登录响应无效', 'AUTH_RESPONSE_INVALID')
}
return saveLogin(loginResult.login)
},
async bindWechat({ code }, requestOptions = {}) {
await requestStrict({
url: '/genealogy/app/auth/wechat/bind',
method: 'POST',
data: { code: assertWechatAuthorizationCode(code) }
}, {
requireData: false,
requestController: requestOptions.requestController ?? null
})
return null
},
async registerWithPassword({ phone, passwordHash, smsCode, nickName, referralCode }, requestOptions = {}) {
const normalizedNickName = normalizeOptionalText(nickName, '昵称')
const normalizedReferralCode = normalizeOptionalReferralCode(referralCode)
const loginSession = await requestAuth({
url: '/genealogy/app/auth/register',
method: 'POST',
@@ -84,14 +112,14 @@ export const authApi = {
phone,
password: assertPasswordHash(passwordHash),
smsCode: assertSmsCode(smsCode),
...(normalizedNickName ? { nickName: normalizedNickName } : {})
...(normalizedNickName ? { nickName: normalizedNickName } : {}),
...(normalizedReferralCode ? { referralCode: normalizedReferralCode } : {})
}
}, requestOptions)
return saveLogin(loginSession)
},
async resetPassword({ phone, passwordHash, smsCode }, requestOptions = {}) {
requireRemoteAuth()
return requestAuthVoid({
url: '/genealogy/app/auth/password/reset',
method: 'PUT',
@@ -106,7 +134,6 @@ export const authApi = {
},
async changePassword({ oldPasswordHash, newPasswordHash }, requestOptions = {}) {
requireRemoteAuth()
await requestStrict({
url: '/genealogy/app/auth/password',
method: 'PUT',
@@ -122,7 +149,6 @@ export const authApi = {
},
async deactivateAccount({ smsCode }, requestOptions = {}) {
requireRemoteAuth()
await requestStrict({
url: '/genealogy/app/auth/account/deactivate',
method: 'POST',
@@ -135,7 +161,6 @@ export const authApi = {
},
async logout(requestOptions = {}) {
if (resolveRuntimeMode() !== 'remote') return null
await requestStrict({
url: '/genealogy/app/auth/logout',
method: 'DELETE'
@@ -147,7 +172,6 @@ export const authApi = {
},
async changePhone(payload, requestOptions = {}) {
requireRemoteAuth()
await requestStrict({
url: '/genealogy/app/auth/phone',
method: 'PUT',
+30 -2
View File
@@ -6,10 +6,34 @@ import {
} from './response-normalizers.js'
const supportedDictionaryTypes = new Set([
'gen_parent_relationship_variant',
'gen_education_type',
'gen_death_expression',
'gen_spouse_relationship_variant',
'gen_ceremony_type',
'gen_growth_record_type'
'gen_person_document_type',
'gen_growth_record_type',
'gen_merit_type',
'gen_feedback_type',
'gen_zodiac'
])
const optionStates = new Set(['ACTIVE', 'DISABLED', 'UNKNOWN'])
export const normalizeBusinessOptionProjection = (value, label, state, subject, errorCode) => {
const optionValue = normalizeResponseText(value, `${subject}编码`)
const optionLabel = normalizeResponseText(label, `${subject}名称`)
const optionState = normalizeResponseText(state, `${subject}状态`)
if (optionValue && !optionStates.has(optionState)) {
throw createRequestError(`${subject}历史值状态无效`, errorCode)
}
return {
value: optionValue,
label: optionLabel || optionValue,
state: optionValue ? optionState : ''
}
}
export const normalizeBusinessDictionaryType = (dictType) => {
if (!supportedDictionaryTypes.has(dictType)) {
throw new TypeError('当前页面不支持该业务字典')
@@ -27,6 +51,9 @@ export const normalizeBusinessDictionaryOptions = (dictType, value) => {
if (!item || typeof item !== 'object' || Array.isArray(item)) {
throw createRequestError('业务字典响应包含无效条目', 'BUSINESS_DICTIONARY_RESPONSE_INVALID')
}
if (item.enabled !== true) {
throw createRequestError('业务字典选择接口返回了不可选项', 'BUSINESS_DICTIONARY_RESPONSE_INVALID')
}
const optionValue = normalizeResponseText(item.value, 'value')
const label = normalizeResponseText(item.label, 'label')
if (!optionValue || !label || values.has(optionValue)) {
@@ -38,7 +65,8 @@ export const normalizeBusinessDictionaryOptions = (dictType, value) => {
value: optionValue,
label,
sort: normalizeOptionalNonnegativeInteger(item.sort, '业务字典排序值', 'BUSINESS_DICTIONARY_RESPONSE_INVALID'),
default: item.default === true
default: item.default === true,
enabled: true
}
})
}
+1 -8
View File
@@ -1,19 +1,12 @@
import { hasRemoteConfig } from '@/utils/runtime-config.js'
import {
normalizeBusinessDictionaryOptions,
normalizeBusinessDictionaryType
} from './business-dictionary-contract.js'
import { createRequestError, requestStrict } from './request-client.js'
import { requestStrict } from './request-client.js'
export const businessDictionaryApi = {
async getBusinessDictionaryOptions(dictType, requestOptions = {}) {
const normalizedType = normalizeBusinessDictionaryType(dictType)
if (!hasRemoteConfig()) {
throw createRequestError(
'业务字典读取需要真实服务,当前本地预览不会伪造结果',
'REMOTE_READ_REQUIRED'
)
}
const dictionaryOptions = await requestStrict({
url: `/genealogy/app/dictionaries/${encodeURIComponent(normalizedType)}`,
method: 'GET'
+3
View File
@@ -45,6 +45,9 @@ export const normalizeBusinessFileAccess = (value, label, code, { required = fal
}
const accessUrl = normalizeText('accessUrl')
if (required && !accessUrl) throw createRequestError(`${label}缺少授权访问地址`, code)
if (accessUrl && !/^https:\/\/[^\s]+$/.test(accessUrl)) {
throw createRequestError(`${label}授权访问地址必须使用 HTTPS`, code)
}
return {
fileId,
ossId,

Some files were not shown because too many files have changed in this diff Show More