Compare commits
7 Commits
555aa00043
...
9ad572907b
| Author | SHA1 | Date | |
|---|---|---|---|
| 9ad572907b | |||
| c59e36f933 | |||
| ab90ab488b | |||
| 5dabcd175e | |||
| 964262f693 | |||
| 8207965754 | |||
| cc706378c2 |
@@ -59,3 +59,6 @@ sitemap.xml
|
||||
/.vite/
|
||||
/design-pipeline/generated/
|
||||
/tmp-g01-icon-audit.png
|
||||
/tmp-mumu-current.png
|
||||
/artifacts/
|
||||
/docs/audit-*/
|
||||
|
||||
@@ -1,9 +1,5 @@
|
||||
<script>
|
||||
export default {
|
||||
onLaunch() {
|
||||
console.log('家谱 App 已启动')
|
||||
}
|
||||
}
|
||||
export default {}
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -35,8 +35,8 @@ const emit = defineEmits(["click"]);
|
||||
|
||||
const skin = computed(() =>
|
||||
props.type === "secondary"
|
||||
? "/static/assets/foundation/transparent/a01-scroll-secondary-v3.png"
|
||||
: "/static/assets/foundation/transparent/a01-scroll-primary-v3.png",
|
||||
? "/static/assets/foundation/transparent/scroll-secondary.png"
|
||||
: "/static/assets/foundation/transparent/scroll-primary.png",
|
||||
);
|
||||
|
||||
const handleClick = (event) => {
|
||||
@@ -45,7 +45,7 @@ const handleClick = (event) => {
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@import "../styles/adaptive-frame-profiles.scss";
|
||||
@use "../styles/adaptive-frame-profiles.scss" as adaptive;
|
||||
|
||||
.app-button {
|
||||
display: inline-grid;
|
||||
@@ -70,10 +70,10 @@ const handleClick = (event) => {
|
||||
.app-button--compact {
|
||||
}
|
||||
.app-button--compact.app-button--primary {
|
||||
@include adaptive-scroll-button(primary);
|
||||
@include adaptive.adaptive-scroll-button(primary);
|
||||
}
|
||||
.app-button--compact.app-button--secondary {
|
||||
@include adaptive-scroll-button(secondary);
|
||||
@include adaptive.adaptive-scroll-button(secondary);
|
||||
}
|
||||
.app-button--compact .app-button__skin {
|
||||
display: none;
|
||||
|
||||
@@ -116,7 +116,7 @@ const cancel = () => {
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@import "../styles/adaptive-frame-profiles.scss";
|
||||
@use "../styles/adaptive-frame-profiles.scss" as adaptive;
|
||||
|
||||
.app-dialog-layer {
|
||||
position: fixed;
|
||||
@@ -136,7 +136,7 @@ const cancel = () => {
|
||||
max-height: calc(var(--app-viewport-height, 100vh) - 80rpx - env(safe-area-inset-top) - env(safe-area-inset-bottom));
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
@include adaptive-auth-dialog;
|
||||
@include adaptive.adaptive-auth-dialog;
|
||||
}
|
||||
.app-dialog__content {
|
||||
z-index: 1;
|
||||
|
||||
@@ -0,0 +1,240 @@
|
||||
<template>
|
||||
<view
|
||||
v-if="promotionState === 'error' || promotions.length"
|
||||
class="promotion-strip"
|
||||
:aria-label="title"
|
||||
>
|
||||
<view class="promotion-strip__heading">
|
||||
<view class="promotion-strip__mark" aria-hidden="true"></view>
|
||||
<text>{{ title }}</text>
|
||||
</view>
|
||||
<view v-if="promotionState === 'error'" class="promotion-strip__error">
|
||||
<text>推荐内容暂时没有显示</text>
|
||||
<button @click="loadPromotions">重新加载</button>
|
||||
</view>
|
||||
<scroll-view v-else class="promotion-strip__scroll" scroll-x :show-scrollbar="false">
|
||||
<view class="promotion-strip__list">
|
||||
<view
|
||||
v-for="promotion in promotions"
|
||||
:key="promotion.id"
|
||||
class="promotion-strip__card"
|
||||
:class="{ 'promotion-strip__card--linked': promotion.targetUrl }"
|
||||
:role="promotion.targetUrl ? 'button' : undefined"
|
||||
:aria-label="promotion.targetUrl ? `${promotion.title},查看详情` : promotion.title"
|
||||
@click="openPromotion(promotion)"
|
||||
>
|
||||
<image
|
||||
v-if="promotion.coverFile?.accessUrl"
|
||||
class="promotion-strip__cover"
|
||||
:src="promotion.coverFile.accessUrl"
|
||||
mode="aspectFill"
|
||||
/>
|
||||
<view class="promotion-strip__copy">
|
||||
<text class="promotion-strip__title">{{ promotion.title }}</text>
|
||||
<text v-if="promotion.description" class="promotion-strip__description">
|
||||
{{ promotion.description }}
|
||||
</text>
|
||||
<text v-if="promotion.targetUrl" class="promotion-strip__action">
|
||||
查看详情 ›
|
||||
</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
<text
|
||||
v-if="operationFeedback"
|
||||
class="promotion-strip__feedback"
|
||||
role="status"
|
||||
>
|
||||
{{ operationFeedback }}
|
||||
</text>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onBeforeUnmount, onMounted, ref } from "vue";
|
||||
import {
|
||||
createRequestController,
|
||||
isRequestCancelled
|
||||
} from "@/services/api/request-controller.js";
|
||||
import { siteContentApi } from "@/services/api/site-content-service.js";
|
||||
import { openSiteContentTarget } from "@/utils/navigation/gateway.js";
|
||||
|
||||
const props = defineProps({
|
||||
placement: { type: String, required: true },
|
||||
title: { type: String, default: "推荐内容" },
|
||||
});
|
||||
|
||||
const promotions = ref([]);
|
||||
const promotionState = ref("loading");
|
||||
const operationFeedback = ref("");
|
||||
const promotionListController = createRequestController();
|
||||
let isMounted = true;
|
||||
|
||||
const loadPromotions = async () => {
|
||||
promotionListController.abort();
|
||||
promotionState.value = "loading";
|
||||
operationFeedback.value = "";
|
||||
try {
|
||||
const promotionRows = await siteContentApi.getPromotions({
|
||||
placement: props.placement,
|
||||
requestController: promotionListController,
|
||||
});
|
||||
if (!isMounted) return;
|
||||
promotions.value = promotionRows;
|
||||
promotionState.value = "ready";
|
||||
} catch (error) {
|
||||
if (!isMounted || isRequestCancelled(error)) return;
|
||||
promotionState.value = "error";
|
||||
}
|
||||
};
|
||||
|
||||
const openPromotion = async (promotion) => {
|
||||
const targetUrl = promotion?.targetUrl || "";
|
||||
if (!targetUrl) return;
|
||||
const showExternalLinkError = () => {
|
||||
if (!isMounted) return;
|
||||
operationFeedback.value = "链接暂时打不开,请稍后再试。";
|
||||
};
|
||||
try {
|
||||
await openSiteContentTarget(targetUrl, showExternalLinkError);
|
||||
} catch {
|
||||
if (!isMounted) return;
|
||||
operationFeedback.value = targetUrl.startsWith("/")
|
||||
? "这个页面暂时打不开,请稍后再试。"
|
||||
: "链接暂时打不开,请稍后再试。";
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(loadPromotions);
|
||||
onBeforeUnmount(() => {
|
||||
isMounted = false;
|
||||
promotionListController.abort();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.promotion-strip {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
margin: 20rpx 24rpx 28rpx;
|
||||
padding: 22rpx 0 20rpx;
|
||||
border: 1rpx solid rgba(145, 89, 36, 0.34);
|
||||
border-radius: 14rpx;
|
||||
background: rgba(255, 250, 239, 0.9);
|
||||
box-shadow: 0 7rpx 20rpx rgba(74, 37, 18, 0.08);
|
||||
}
|
||||
.promotion-strip__heading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12rpx;
|
||||
padding: 0 22rpx 18rpx;
|
||||
color: #6f1a14;
|
||||
font-family: STKaiti, KaiTi, serif;
|
||||
font-size: clamp(16px, 27rpx, 20px);
|
||||
font-weight: 700;
|
||||
}
|
||||
.promotion-strip__mark {
|
||||
width: 7rpx;
|
||||
height: 30rpx;
|
||||
border-radius: 8rpx;
|
||||
background: linear-gradient(#c89b4b, #8f1b14);
|
||||
}
|
||||
.promotion-strip__scroll {
|
||||
width: 100%;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.promotion-strip__list {
|
||||
display: inline-flex;
|
||||
gap: 16rpx;
|
||||
padding: 0 22rpx;
|
||||
}
|
||||
.promotion-strip__card {
|
||||
display: flex;
|
||||
width: 500rpx;
|
||||
min-height: 142rpx;
|
||||
overflow: hidden;
|
||||
border: 1rpx solid rgba(125, 83, 45, 0.23);
|
||||
border-radius: 12rpx;
|
||||
background: #fffdf8;
|
||||
white-space: normal;
|
||||
}
|
||||
.promotion-strip__card--linked:active {
|
||||
opacity: 0.78;
|
||||
}
|
||||
|
||||
.promotion-strip__cover {
|
||||
width: 176rpx;
|
||||
min-height: 142rpx;
|
||||
flex: 0 0 auto;
|
||||
background: #eee4d4;
|
||||
}
|
||||
|
||||
.promotion-strip__copy {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
padding: 18rpx 20rpx;
|
||||
}
|
||||
|
||||
.promotion-strip__title {
|
||||
overflow: hidden;
|
||||
color: #402c20;
|
||||
font-size: clamp(15px, 25rpx, 18px);
|
||||
font-weight: 700;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.promotion-strip__description {
|
||||
display: -webkit-box;
|
||||
overflow: hidden;
|
||||
margin-top: 8rpx;
|
||||
color: #766252;
|
||||
font-size: clamp(13px, 21rpx, 16px);
|
||||
line-height: 1.45;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 2;
|
||||
}
|
||||
|
||||
.promotion-strip__action {
|
||||
margin-top: auto;
|
||||
padding-top: 8rpx;
|
||||
color: #9f170f;
|
||||
font-size: clamp(13px, 21rpx, 16px);
|
||||
}
|
||||
|
||||
.promotion-strip__error {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 18rpx;
|
||||
padding: 0 22rpx;
|
||||
color: #766252;
|
||||
font-size: clamp(13px, 21rpx, 16px);
|
||||
}
|
||||
|
||||
.promotion-strip__error button {
|
||||
margin: 0;
|
||||
padding: 8rpx 18rpx;
|
||||
border: 1rpx solid rgba(159, 23, 15, 0.36);
|
||||
border-radius: 999rpx;
|
||||
background: transparent;
|
||||
color: #9f170f;
|
||||
font-size: clamp(13px, 21rpx, 16px);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.promotion-strip__error button::after {
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.promotion-strip__feedback {
|
||||
display: block;
|
||||
padding: 14rpx 22rpx 0;
|
||||
color: #9f170f;
|
||||
font-size: clamp(13px, 21rpx, 16px);
|
||||
}
|
||||
</style>
|
||||
@@ -1,4 +1,3 @@
|
||||
<!-- 公共组件:根页面底部导航;仅维护家谱、家族、我的三项已确认入口及其透明图标。 -->
|
||||
<template>
|
||||
<view class="app-tabbar" role="tablist" aria-label="主要导航">
|
||||
<button
|
||||
@@ -25,7 +24,7 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { goRoot } from "@/utils/navigation.js";
|
||||
import { goRoot } from "@/utils/navigation/gateway.js";
|
||||
|
||||
const props = defineProps({ active: { type: String, required: true } });
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ defineProps({
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@import "../styles/adaptive-frame-profiles.scss";
|
||||
@use "../styles/adaptive-frame-profiles.scss" as adaptive;
|
||||
|
||||
.app-toast {
|
||||
position: fixed;
|
||||
@@ -31,7 +31,7 @@ defineProps({
|
||||
min-height: 82rpx;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
@include adaptive-feedback-toast;
|
||||
@include adaptive.adaptive-feedback-toast;
|
||||
transform: translateX(-50%);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
@@ -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 {
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
<!-- 公共组件:根页朱砂页头与普通返回页头;根页背景图层由统一基础资产提供。 -->
|
||||
<template>
|
||||
<view class="page-header-slot" :class="{ 'page-header-slot--root': root }">
|
||||
<view class="page-header" :class="{ 'page-header--root': root }">
|
||||
@@ -95,7 +94,7 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { goBack } from "@/utils/navigation.js";
|
||||
import { goBack } from "@/utils/navigation/gateway.js";
|
||||
|
||||
const props = defineProps({
|
||||
title: { type: String, required: true },
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<view class="auth-shell__header">
|
||||
<image
|
||||
class="auth-shell__header-image"
|
||||
src="/static/assets/modules/auth/opaque/a01-vnext-header-v1.png"
|
||||
src="/static/assets/modules/auth/opaque/auth-header.png"
|
||||
mode="widthFix"
|
||||
/>
|
||||
<view class="auth-shell__brand-lockup">
|
||||
@@ -78,7 +78,7 @@
|
||||
padding-bottom: var(--app-safe-bottom);
|
||||
background-color: #f7f0e5;
|
||||
background-image:
|
||||
url("/static/assets/modules/auth/opaque/a01-red-hall-ink-backdrop-v1.png"),
|
||||
url("/static/assets/modules/auth/opaque/sign-in-backdrop.png"),
|
||||
url("/static/assets/foundation/opaque/auth-page-paper.jpg");
|
||||
background-position: center calc(-48.18vw), center top;
|
||||
background-size: 100% auto, 100% auto;
|
||||
@@ -192,13 +192,13 @@ export default {
|
||||
this.completionSent = true;
|
||||
this.generation += 1;
|
||||
this.abortActiveRequest();
|
||||
const data = response && response.data;
|
||||
const verificationData = response && response.data;
|
||||
tac.destroyWindow();
|
||||
this.tac = null;
|
||||
this.$ownerInstance.callMethod("handleTacSuccess", {
|
||||
requestId: requestContext.requestId,
|
||||
validToken: data && data.validToken,
|
||||
expireSeconds: data && data.expireSeconds,
|
||||
validToken: verificationData && verificationData.validToken,
|
||||
expireSeconds: verificationData && verificationData.expireSeconds,
|
||||
});
|
||||
},
|
||||
validFail: (response, captcha, tac) => {
|
||||
@@ -298,8 +298,12 @@ export default {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const data = JSON.parse(xhr.responseText);
|
||||
settle(data && typeof data === "object" ? data : { code: 502, msg: "安全验证服务返回无效数据" });
|
||||
const parsedResponse = JSON.parse(xhr.responseText);
|
||||
settle(
|
||||
parsedResponse && typeof parsedResponse === "object"
|
||||
? parsedResponse
|
||||
: { code: 502, msg: "安全验证服务返回无效数据" },
|
||||
);
|
||||
} catch (error) {
|
||||
settle({ code: 502, msg: "安全验证服务返回了非 JSON 数据" });
|
||||
}
|
||||
@@ -0,0 +1,718 @@
|
||||
<template>
|
||||
<view class="comment-section">
|
||||
<view class="comment-section__heading">
|
||||
<text class="section-title">评论</text>
|
||||
<button class="comment-entry" @click="focusCommentEditor">写评论</button>
|
||||
</view>
|
||||
|
||||
<AppLoading v-if="commentState === 'loading'" text="正在读取评论" />
|
||||
<view v-else-if="commentState === 'list'" class="comment-list">
|
||||
<view v-for="comment in comments" :key="comment.id" class="comment-card">
|
||||
<view class="comment-card__heading">
|
||||
<text>{{ comment.author }}</text>
|
||||
<text>{{ formatMinuteTimestamp(comment.time) || "刚刚" }}</text>
|
||||
</view>
|
||||
<text class="comment-card__content">{{ comment.content }}</text>
|
||||
<view class="comment-card__actions">
|
||||
<AppButton
|
||||
compact
|
||||
type="secondary"
|
||||
label="回复"
|
||||
@click="startReply(comment)"
|
||||
/>
|
||||
<AppButton
|
||||
v-if="comment.canDelete && !comment.userDeleted"
|
||||
compact
|
||||
type="secondary"
|
||||
label="删除"
|
||||
@click="requestDeleteComment(comment)"
|
||||
/>
|
||||
<AppButton
|
||||
v-if="comment.replyCount"
|
||||
compact
|
||||
type="secondary"
|
||||
:label="replyState(comment.id) === 'ready' ? '收起回复' : `查看 ${comment.replyCount} 条回复`"
|
||||
@click="toggleReplies(comment)"
|
||||
/>
|
||||
</view>
|
||||
|
||||
<view
|
||||
v-if="replyState(comment.id) !== 'closed'"
|
||||
class="comment-replies"
|
||||
>
|
||||
<text v-if="replyState(comment.id) === 'loading'" class="reply-state">
|
||||
正在读取回复
|
||||
</text>
|
||||
<view v-else-if="replyState(comment.id) === 'ready'">
|
||||
<text v-if="!replyRows(comment.id).length" class="reply-state">
|
||||
暂未读取到回复
|
||||
</text>
|
||||
<view
|
||||
v-for="reply in replyRows(comment.id)"
|
||||
:key="reply.id"
|
||||
class="reply-card"
|
||||
>
|
||||
<view class="reply-card__heading">
|
||||
<text>{{ reply.author }}</text>
|
||||
<text>{{ formatMinuteTimestamp(reply.time) || "刚刚" }}</text>
|
||||
</view>
|
||||
<text v-if="reply.parentAuthor" class="reply-card__target">
|
||||
回复 {{ reply.parentAuthor }}
|
||||
</text>
|
||||
<text class="reply-card__content">{{ reply.content }}</text>
|
||||
<view class="reply-card__actions">
|
||||
<AppButton
|
||||
compact
|
||||
type="secondary"
|
||||
label="回复"
|
||||
@click="startReply(reply, comment)"
|
||||
/>
|
||||
<AppButton
|
||||
v-if="reply.canDelete && !reply.userDeleted"
|
||||
compact
|
||||
type="secondary"
|
||||
label="删除回复"
|
||||
@click="requestDeleteComment(reply, comment)"
|
||||
/>
|
||||
</view>
|
||||
</view>
|
||||
<AppButton
|
||||
v-if="shouldShowReplyMore(comment.id)"
|
||||
compact
|
||||
type="secondary"
|
||||
:disabled="replyMoreState(comment.id) === 'loading'"
|
||||
:label="replyMoreLabel(comment.id)"
|
||||
@click="loadMoreReplies(comment)"
|
||||
/>
|
||||
</view>
|
||||
<text v-else class="reply-state reply-state--error">
|
||||
回复暂时无法读取,
|
||||
<text role="button" @click="loadReplies(comment)">重新读取</text>
|
||||
</text>
|
||||
</view>
|
||||
</view>
|
||||
<AppButton
|
||||
v-if="shouldShowCommentMore"
|
||||
compact
|
||||
type="secondary"
|
||||
:disabled="commentMoreState === 'loading'"
|
||||
:label="commentMoreLabel"
|
||||
@click="loadMoreComments"
|
||||
/>
|
||||
</view>
|
||||
<view v-else class="comment-state-copy">
|
||||
<text>{{ commentStateCopy }}</text>
|
||||
<AppButton
|
||||
v-if="commentState === 'error'"
|
||||
compact
|
||||
type="secondary"
|
||||
label="重新加载评论"
|
||||
@click="reload"
|
||||
/>
|
||||
</view>
|
||||
|
||||
<view class="comment-editor">
|
||||
<view v-if="replyTarget" class="reply-target">
|
||||
<text>正在回复 {{ replyTarget.author }}</text>
|
||||
<text role="button" @click="clearReplyTarget">取消回复</text>
|
||||
</view>
|
||||
<textarea
|
||||
v-model="commentDraft"
|
||||
auto-height
|
||||
maxlength="1000"
|
||||
:placeholder="replyTarget ? `回复 ${replyTarget.author}` : '写下你的评论'"
|
||||
placeholder-class="comment-editor__placeholder"
|
||||
:focus="commentFocused"
|
||||
@input="commentError = ''"
|
||||
@blur="commentFocused = false"
|
||||
/>
|
||||
<text v-if="commentError" class="comment-error">{{ commentError }}</text>
|
||||
<AppButton
|
||||
block
|
||||
:disabled="isSubmittingComment"
|
||||
:label="isSubmittingComment ? '正在提交' : replyTarget ? '发表回复' : '发表评论'"
|
||||
@click="submitComment"
|
||||
/>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<AppDialog
|
||||
:visible="commentDeleteVisible"
|
||||
:close-on-mask="false"
|
||||
eyebrow="删除评论"
|
||||
title="确认删除这条评论?"
|
||||
message="删除后评论内容将不再显示。"
|
||||
:confirm-text="deletingComment ? '正在删除' : '确认删除'"
|
||||
cancel-text="取消"
|
||||
show-cancel
|
||||
@confirm="confirmDeleteComment"
|
||||
@cancel="closeDeleteConfirmation"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, nextTick, onMounted, onUnmounted, ref } from "vue";
|
||||
import AppButton from "@/components/AppButton.vue";
|
||||
import AppDialog from "@/components/AppDialog.vue";
|
||||
import AppLoading from "@/components/AppLoading.vue";
|
||||
import {
|
||||
createRequestController,
|
||||
isRequestCancelled
|
||||
} from "@/services/api/request-controller.js";
|
||||
import { familyFeedApi } from "@/services/api/family-feed-service.js";
|
||||
import { getRequestErrorMessage } from "@/services/api/request-error-message.js";
|
||||
import { formatMinuteTimestamp } from "@/utils/display-time.js";
|
||||
import { createNonIdempotentWriteGuard } from "@/utils/non-idempotent-write-guard.js";
|
||||
|
||||
const props = defineProps({
|
||||
genealogyId: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
feedId: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
refreshFeedSummary: {
|
||||
type: Function,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const COMMENT_PAGE_SIZE = 20;
|
||||
const commentState = ref("loading");
|
||||
const comments = ref([]);
|
||||
const currentCommentPage = ref(1);
|
||||
const totalCommentCount = ref(0);
|
||||
const commentMoreState = ref("idle");
|
||||
const replyThreadsByCommentId = ref({});
|
||||
const replyTarget = ref(null);
|
||||
const commentDraft = ref("");
|
||||
const commentFocused = ref(false);
|
||||
const commentError = ref("");
|
||||
const isSubmittingComment = ref(false);
|
||||
const commentDeleteVisible = ref(false);
|
||||
const deletingComment = ref(false);
|
||||
const commentDeleteTarget = ref(null);
|
||||
const commentReadRequestController = createRequestController();
|
||||
const commentSubmissionRequestController = createRequestController();
|
||||
const commentDeletionRequestController = createRequestController();
|
||||
const commentCreateGuard = createNonIdempotentWriteGuard();
|
||||
const replyRequestControllers = new Map();
|
||||
let componentActive = true;
|
||||
|
||||
const hasValidContext = computed(() =>
|
||||
/^[1-9]\d*$/.test(props.genealogyId) && /^[1-9]\d*$/.test(props.feedId),
|
||||
);
|
||||
const hasMoreComments = computed(() =>
|
||||
comments.value.length < totalCommentCount.value,
|
||||
);
|
||||
const shouldShowCommentMore = computed(() =>
|
||||
hasMoreComments.value || ["loading", "error"].includes(commentMoreState.value),
|
||||
);
|
||||
const commentMoreLabel = computed(() => {
|
||||
if (commentMoreState.value === "loading") return "正在加载评论";
|
||||
if (commentMoreState.value === "error") return "加载失败,重新加载";
|
||||
return "继续加载评论";
|
||||
});
|
||||
const commentStateCopy = computed(() =>
|
||||
commentState.value === "empty"
|
||||
? "还没有评论,欢迎留下第一句话。"
|
||||
: "评论暂时无法读取,稍后可重新进入本页查看。",
|
||||
);
|
||||
|
||||
const replyRequestController = (commentId) => {
|
||||
const controllerKey = String(commentId);
|
||||
if (!replyRequestControllers.has(controllerKey)) {
|
||||
replyRequestControllers.set(controllerKey, createRequestController());
|
||||
}
|
||||
return replyRequestControllers.get(controllerKey);
|
||||
};
|
||||
const emptyReplyThread = () => ({
|
||||
state: "closed",
|
||||
rows: [],
|
||||
page: 1,
|
||||
total: 0,
|
||||
moreState: "idle",
|
||||
});
|
||||
const replyThread = (commentId) =>
|
||||
replyThreadsByCommentId.value[String(commentId)] || emptyReplyThread();
|
||||
const updateReplyThread = (commentId, changes) => {
|
||||
const threadId = String(commentId);
|
||||
replyThreadsByCommentId.value = {
|
||||
...replyThreadsByCommentId.value,
|
||||
[threadId]: { ...replyThread(threadId), ...changes },
|
||||
};
|
||||
};
|
||||
const replyState = (commentId) => replyThread(commentId).state;
|
||||
const replyRows = (commentId) => replyThread(commentId).rows;
|
||||
const replyHasMore = (commentId) => {
|
||||
const thread = replyThread(commentId);
|
||||
return thread.rows.length < thread.total;
|
||||
};
|
||||
const replyMoreState = (commentId) => replyThread(commentId).moreState;
|
||||
const shouldShowReplyMore = (commentId) =>
|
||||
replyHasMore(commentId) || ["loading", "error"].includes(replyMoreState(commentId));
|
||||
const replyMoreLabel = (commentId) => {
|
||||
const state = replyMoreState(commentId);
|
||||
if (state === "loading") return "正在加载回复";
|
||||
if (state === "error") return "加载失败,重新加载";
|
||||
return "继续加载回复";
|
||||
};
|
||||
|
||||
const reload = async () => {
|
||||
if (!hasValidContext.value) return;
|
||||
commentReadRequestController.abort();
|
||||
commentState.value = "loading";
|
||||
currentCommentPage.value = 1;
|
||||
commentMoreState.value = "idle";
|
||||
try {
|
||||
const commentPage = await familyFeedApi.getFeedCommentPage(
|
||||
props.genealogyId,
|
||||
props.feedId,
|
||||
{ pageNum: 1, pageSize: COMMENT_PAGE_SIZE },
|
||||
{ requestController: commentReadRequestController },
|
||||
);
|
||||
if (!componentActive) return;
|
||||
comments.value = commentPage.rows;
|
||||
totalCommentCount.value = commentPage.total;
|
||||
commentMoreState.value =
|
||||
comments.value.length < totalCommentCount.value ? "idle" : "done";
|
||||
commentState.value = commentPage.rows.length ? "list" : "empty";
|
||||
} catch (error) {
|
||||
if (!componentActive || isRequestCancelled(error)) return;
|
||||
commentState.value = "error";
|
||||
}
|
||||
};
|
||||
|
||||
const loadMoreComments = async () => {
|
||||
if (!hasMoreComments.value || commentMoreState.value === "loading") return;
|
||||
commentMoreState.value = "loading";
|
||||
try {
|
||||
const nextPage = currentCommentPage.value + 1;
|
||||
const commentPage = await familyFeedApi.getFeedCommentPage(
|
||||
props.genealogyId,
|
||||
props.feedId,
|
||||
{ pageNum: nextPage, pageSize: COMMENT_PAGE_SIZE },
|
||||
{ requestController: commentReadRequestController },
|
||||
);
|
||||
if (!componentActive) return;
|
||||
const knownCommentIds = new Set(
|
||||
comments.value.map((comment) => String(comment.id)),
|
||||
);
|
||||
comments.value = comments.value.concat(
|
||||
commentPage.rows.filter(
|
||||
(comment) => !knownCommentIds.has(String(comment.id)),
|
||||
),
|
||||
);
|
||||
currentCommentPage.value = nextPage;
|
||||
totalCommentCount.value = commentPage.total;
|
||||
commentMoreState.value =
|
||||
comments.value.length < totalCommentCount.value ? "idle" : "done";
|
||||
} catch (error) {
|
||||
if (!componentActive || isRequestCancelled(error)) return;
|
||||
commentMoreState.value = "error";
|
||||
}
|
||||
};
|
||||
|
||||
const loadReplies = async (comment) => {
|
||||
const commentId = String(comment?.id || "");
|
||||
if (!hasValidContext.value || !/^[1-9]\d*$/.test(commentId)) return;
|
||||
updateReplyThread(commentId, { state: "loading" });
|
||||
try {
|
||||
const replyPage = await familyFeedApi.getCommentReplyPage(
|
||||
props.genealogyId,
|
||||
props.feedId,
|
||||
commentId,
|
||||
{ pageNum: 1, pageSize: COMMENT_PAGE_SIZE },
|
||||
{ requestController: replyRequestController(commentId) },
|
||||
);
|
||||
if (!componentActive) return;
|
||||
updateReplyThread(commentId, {
|
||||
state: "ready",
|
||||
rows: replyPage.rows,
|
||||
page: 1,
|
||||
total: replyPage.total,
|
||||
moreState: replyPage.rows.length < replyPage.total ? "idle" : "done",
|
||||
});
|
||||
} catch (error) {
|
||||
if (!componentActive || isRequestCancelled(error)) return;
|
||||
updateReplyThread(commentId, { state: "error" });
|
||||
}
|
||||
};
|
||||
|
||||
const loadMoreReplies = async (comment) => {
|
||||
const commentId = String(comment?.id || "");
|
||||
const thread = replyThread(commentId);
|
||||
if (!replyHasMore(commentId) || thread.moreState === "loading") return;
|
||||
updateReplyThread(commentId, { moreState: "loading" });
|
||||
try {
|
||||
const nextPage = thread.page + 1;
|
||||
const replyPage = await familyFeedApi.getCommentReplyPage(
|
||||
props.genealogyId,
|
||||
props.feedId,
|
||||
commentId,
|
||||
{ pageNum: nextPage, pageSize: COMMENT_PAGE_SIZE },
|
||||
{ requestController: replyRequestController(commentId) },
|
||||
);
|
||||
if (!componentActive) return;
|
||||
const currentReplies = replyRows(commentId);
|
||||
const knownReplyIds = new Set(
|
||||
currentReplies.map((reply) => String(reply.id)),
|
||||
);
|
||||
const replies = currentReplies.concat(
|
||||
replyPage.rows.filter((reply) => !knownReplyIds.has(String(reply.id))),
|
||||
);
|
||||
updateReplyThread(commentId, {
|
||||
rows: replies,
|
||||
page: nextPage,
|
||||
total: replyPage.total,
|
||||
moreState: replies.length < replyPage.total ? "idle" : "done",
|
||||
});
|
||||
} catch (error) {
|
||||
if (!componentActive || isRequestCancelled(error)) return;
|
||||
updateReplyThread(commentId, { moreState: "error" });
|
||||
}
|
||||
};
|
||||
|
||||
const toggleReplies = (comment) => {
|
||||
const commentId = String(comment?.id || "");
|
||||
if (replyState(commentId) === "ready") {
|
||||
updateReplyThread(commentId, { state: "closed" });
|
||||
return;
|
||||
}
|
||||
void loadReplies(comment);
|
||||
};
|
||||
const startReply = (comment, rootComment = comment) => {
|
||||
// 写入使用实际父评论,刷新则使用所属一级评论;两者不能合并成同一个 ID。
|
||||
replyTarget.value = {
|
||||
id: comment.id,
|
||||
author: comment.author,
|
||||
rootComment,
|
||||
};
|
||||
commentError.value = "";
|
||||
};
|
||||
const clearReplyTarget = () => {
|
||||
replyTarget.value = null;
|
||||
commentError.value = "";
|
||||
};
|
||||
const focusCommentEditor = () => {
|
||||
if (typeof uni?.pageScrollTo !== "function") return;
|
||||
uni.pageScrollTo({
|
||||
selector: ".comment-editor",
|
||||
duration: 240,
|
||||
complete: () => {
|
||||
commentFocused.value = false;
|
||||
nextTick(() => {
|
||||
if (componentActive) commentFocused.value = true;
|
||||
});
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const requestDeleteComment = (comment, parent = null) => {
|
||||
if (!comment?.canDelete || comment.userDeleted || deletingComment.value) return;
|
||||
commentDeleteTarget.value = { comment, parent };
|
||||
commentDeleteVisible.value = true;
|
||||
commentError.value = "";
|
||||
};
|
||||
const closeDeleteConfirmation = () => {
|
||||
if (deletingComment.value) return;
|
||||
commentDeleteVisible.value = false;
|
||||
commentDeleteTarget.value = null;
|
||||
};
|
||||
const confirmDeleteComment = async () => {
|
||||
const deletion = commentDeleteTarget.value;
|
||||
if (!deletion?.comment?.canDelete || deletingComment.value) return;
|
||||
deletingComment.value = true;
|
||||
let deletionCommitted = false;
|
||||
try {
|
||||
await familyFeedApi.deleteFeedComment(
|
||||
props.genealogyId,
|
||||
props.feedId,
|
||||
deletion.comment.id,
|
||||
{ requestController: commentDeletionRequestController },
|
||||
);
|
||||
deletionCommitted = true;
|
||||
if (!componentActive) return;
|
||||
commentDeleteVisible.value = false;
|
||||
commentDeleteTarget.value = null;
|
||||
await reload();
|
||||
if (deletion.parent) await loadReplies(deletion.parent);
|
||||
const feedRefreshed = await props.refreshFeedSummary();
|
||||
if (componentActive && feedRefreshed === false) {
|
||||
commentError.value = "评论已删除,动态统计暂时未更新。";
|
||||
}
|
||||
} catch (error) {
|
||||
if (!componentActive) return;
|
||||
if (deletionCommitted) {
|
||||
commentDeleteVisible.value = false;
|
||||
commentDeleteTarget.value = null;
|
||||
commentError.value = "评论已删除,动态统计暂时未更新。";
|
||||
return;
|
||||
}
|
||||
if (isRequestCancelled(error)) return;
|
||||
commentError.value = getRequestErrorMessage(
|
||||
error,
|
||||
"这条评论删除失败,请稍后重试。",
|
||||
);
|
||||
commentDeleteVisible.value = false;
|
||||
} finally {
|
||||
if (componentActive) deletingComment.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const submitComment = async () => {
|
||||
if (isSubmittingComment.value || !hasValidContext.value) return;
|
||||
const commentContent = commentDraft.value.trim();
|
||||
if (!commentContent) {
|
||||
commentError.value = "请填写评论内容";
|
||||
return;
|
||||
}
|
||||
const target = replyTarget.value;
|
||||
const payload = {
|
||||
commentContent,
|
||||
...(target ? { parentCommentId: target.id } : {}),
|
||||
};
|
||||
const createAttempt = commentCreateGuard.begin(payload);
|
||||
if (createAttempt === null) {
|
||||
commentError.value =
|
||||
"上次评论结果暂时无法确认,请先刷新评论列表,避免重复发表。";
|
||||
return;
|
||||
}
|
||||
|
||||
isSubmittingComment.value = true;
|
||||
commentError.value = "";
|
||||
let commentCommitted = false;
|
||||
try {
|
||||
await familyFeedApi.createFeedComment(
|
||||
props.genealogyId,
|
||||
props.feedId,
|
||||
payload,
|
||||
{ requestController: commentSubmissionRequestController },
|
||||
);
|
||||
commentCommitted = true;
|
||||
if (!componentActive) return;
|
||||
commentDraft.value = "";
|
||||
replyTarget.value = null;
|
||||
await reload();
|
||||
if (target) {
|
||||
await loadReplies(target.rootComment);
|
||||
}
|
||||
const feedRefreshed = await props.refreshFeedSummary();
|
||||
if (componentActive && feedRefreshed === false) {
|
||||
commentError.value = "评论已发布,动态统计暂时未更新。";
|
||||
}
|
||||
} catch (error) {
|
||||
if (!componentActive) return;
|
||||
if (commentCommitted) {
|
||||
commentError.value = "评论已发布,但动态统计暂时未更新。";
|
||||
return;
|
||||
}
|
||||
if (commentCreateGuard.recordFailure(createAttempt, error)) {
|
||||
commentError.value =
|
||||
"评论结果暂时无法确认,请先刷新评论列表,避免重复发表。";
|
||||
return;
|
||||
}
|
||||
if (isRequestCancelled(error)) return;
|
||||
commentError.value = getRequestErrorMessage(
|
||||
error,
|
||||
"评论提交失败,请稍后重试",
|
||||
);
|
||||
} finally {
|
||||
if (componentActive) isSubmittingComment.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
defineExpose({ reload });
|
||||
|
||||
onMounted(() => {
|
||||
void reload();
|
||||
});
|
||||
onUnmounted(() => {
|
||||
componentActive = false;
|
||||
commentReadRequestController.abort();
|
||||
commentSubmissionRequestController.abort();
|
||||
commentDeletionRequestController.abort();
|
||||
replyRequestControllers.forEach((requestController) => requestController.abort());
|
||||
replyRequestControllers.clear();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@import "../../styles/adaptive-frame-profiles.scss";
|
||||
|
||||
.comment-section {
|
||||
@include adaptive-family-content;
|
||||
box-sizing: border-box;
|
||||
margin-top: 18rpx;
|
||||
padding: 28rpx;
|
||||
}
|
||||
.comment-section__heading,
|
||||
.comment-card__heading,
|
||||
.reply-card__heading,
|
||||
.reply-target {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 18rpx;
|
||||
}
|
||||
.comment-section__heading {
|
||||
align-items: center;
|
||||
}
|
||||
.section-title {
|
||||
display: block;
|
||||
color: $ink;
|
||||
font-family: STKaiti, KaiTi, serif;
|
||||
font-size: clamp(17px, 32rpx, 22px);
|
||||
font-weight: 700;
|
||||
}
|
||||
.comment-entry {
|
||||
min-width: 144rpx;
|
||||
min-height: 72rpx;
|
||||
margin: 0;
|
||||
padding: 0 18rpx;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: $brand-red;
|
||||
font-size: clamp(14px, 22rpx, 17px);
|
||||
line-height: 72rpx;
|
||||
}
|
||||
.comment-entry::after {
|
||||
border: 0;
|
||||
}
|
||||
.comment-list,
|
||||
.comment-state-copy {
|
||||
margin-top: 18rpx;
|
||||
}
|
||||
.comment-card {
|
||||
padding: 18rpx 0;
|
||||
border-bottom: 1rpx solid rgba(128, 89, 49, 0.16);
|
||||
}
|
||||
.comment-card__heading text:first-child {
|
||||
color: $brand-red;
|
||||
font-size: clamp(14px, 23rpx, 17px);
|
||||
font-weight: 700;
|
||||
}
|
||||
.reply-card__heading text:first-child,
|
||||
.reply-target text:first-child {
|
||||
color: $brand-red;
|
||||
font-size: clamp(14px, 22rpx, 17px);
|
||||
font-weight: 700;
|
||||
}
|
||||
.comment-card__heading text:last-child,
|
||||
.reply-card__heading text:last-child,
|
||||
.reply-card__target,
|
||||
.reply-state {
|
||||
color: $ink-muted;
|
||||
font-size: clamp(13px, 21rpx, 16px);
|
||||
}
|
||||
.comment-card__content,
|
||||
.reply-card__content {
|
||||
display: block;
|
||||
color: $ink;
|
||||
line-height: 1.55;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
.comment-card__content {
|
||||
margin-top: 10rpx;
|
||||
font-size: clamp(15px, 25rpx, 18px);
|
||||
}
|
||||
.comment-card__actions,
|
||||
.reply-card__actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10rpx;
|
||||
}
|
||||
.comment-card__actions {
|
||||
margin-top: 14rpx;
|
||||
}
|
||||
.reply-card__actions {
|
||||
margin-top: 10rpx;
|
||||
}
|
||||
.comment-card__actions .app-button,
|
||||
.reply-card__actions .app-button {
|
||||
width: auto;
|
||||
min-width: 132rpx;
|
||||
}
|
||||
.comment-card__actions .app-button {
|
||||
min-height: 58rpx;
|
||||
padding: 0 16rpx;
|
||||
}
|
||||
.comment-replies {
|
||||
margin-top: 14rpx;
|
||||
padding: 14rpx 18rpx;
|
||||
border-left: 4rpx solid rgba(159, 23, 15, 0.3);
|
||||
background: rgba(135, 94, 52, 0.045);
|
||||
}
|
||||
.reply-card + .reply-card {
|
||||
margin-top: 14rpx;
|
||||
padding-top: 14rpx;
|
||||
border-top: 1rpx solid rgba(128, 89, 49, 0.13);
|
||||
}
|
||||
.reply-card__target,
|
||||
.reply-card__content,
|
||||
.reply-state {
|
||||
display: block;
|
||||
margin-top: 6rpx;
|
||||
}
|
||||
.reply-card__content {
|
||||
font-size: clamp(14px, 23rpx, 17px);
|
||||
}
|
||||
.reply-state--error {
|
||||
color: $brand-red;
|
||||
}
|
||||
.reply-state--error text {
|
||||
text-decoration: underline;
|
||||
}
|
||||
.comment-state-copy {
|
||||
display: block;
|
||||
color: $ink-muted;
|
||||
font-size: clamp(15px, 24rpx, 18px);
|
||||
line-height: 1.5;
|
||||
}
|
||||
.comment-state-copy .app-button {
|
||||
width: 260rpx;
|
||||
max-width: 100%;
|
||||
margin-top: 16rpx;
|
||||
}
|
||||
.comment-editor {
|
||||
margin-top: 24rpx;
|
||||
padding-top: 22rpx;
|
||||
border-top: 1rpx solid rgba(128, 89, 49, 0.18);
|
||||
}
|
||||
.reply-target {
|
||||
margin-bottom: 12rpx;
|
||||
padding: 12rpx 16rpx;
|
||||
border: 1rpx solid rgba(159, 23, 15, 0.22);
|
||||
border-radius: 10rpx;
|
||||
background: rgba(159, 23, 15, 0.05);
|
||||
}
|
||||
.reply-target text:last-child {
|
||||
color: $brand-red;
|
||||
font-size: clamp(13px, 21rpx, 16px);
|
||||
}
|
||||
.comment-editor textarea {
|
||||
display: block;
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
min-height: 130rpx;
|
||||
padding: 18rpx;
|
||||
border: 1rpx solid rgba(128, 89, 49, 0.3);
|
||||
border-radius: 12rpx;
|
||||
color: $ink;
|
||||
font-size: clamp(15px, 25rpx, 18px);
|
||||
line-height: 1.55;
|
||||
}
|
||||
.comment-editor__placeholder {
|
||||
color: #ab9a86;
|
||||
}
|
||||
.comment-editor .app-button {
|
||||
margin-top: 18rpx;
|
||||
}
|
||||
.comment-error {
|
||||
display: block;
|
||||
margin-top: 10rpx;
|
||||
color: $brand-red;
|
||||
font-size: clamp(14px, 22rpx, 17px);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,68 @@
|
||||
<template>
|
||||
<view
|
||||
v-if="visibleFiles.length"
|
||||
class="feed-media"
|
||||
:class="`feed-media--${layout}`"
|
||||
>
|
||||
<image
|
||||
v-for="file in visibleFiles"
|
||||
:key="file.fileId || file.ossId || file.accessUrl"
|
||||
class="feed-media__image"
|
||||
:src="file.accessUrl"
|
||||
mode="aspectFill"
|
||||
role="button"
|
||||
aria-label="查看动态图片"
|
||||
@click.stop="preview(file)"
|
||||
/>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from "vue";
|
||||
|
||||
const props = defineProps({
|
||||
files: { type: Array, default: () => [] },
|
||||
});
|
||||
|
||||
const visibleFiles = computed(() =>
|
||||
props.files.filter((file) => typeof file?.accessUrl === "string" && file.accessUrl),
|
||||
);
|
||||
const layout = computed(() => {
|
||||
if (visibleFiles.value.length === 1) return "single";
|
||||
if (visibleFiles.value.length === 2) return "double";
|
||||
return "grid";
|
||||
});
|
||||
const preview = (file) => {
|
||||
const urls = visibleFiles.value.map((item) => item.accessUrl);
|
||||
if (!file?.accessUrl || !urls.length || typeof uni?.previewImage !== "function") return;
|
||||
uni.previewImage({ current: file.accessUrl, urls });
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.feed-media {
|
||||
display: grid;
|
||||
gap: 8rpx;
|
||||
margin-top: 16rpx;
|
||||
overflow: hidden;
|
||||
border-radius: 10rpx;
|
||||
}
|
||||
.feed-media--single {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
.feed-media--double {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
.feed-media--grid {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
.feed-media__image {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 190rpx;
|
||||
background: rgba(120, 84, 48, 0.08);
|
||||
}
|
||||
.feed-media--single .feed-media__image {
|
||||
height: 360rpx;
|
||||
}
|
||||
</style>
|
||||
@@ -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>
|
||||
@@ -0,0 +1,156 @@
|
||||
<template>
|
||||
<view v-if="visible" class="add-dialog-layer" @click="emit('close')">
|
||||
<view class="add-dialog" @click.stop>
|
||||
<view class="add-dialog__content">
|
||||
<view class="add-dialog__body">
|
||||
<view class="add-dialog__heading">
|
||||
<text class="dialog-title">添加家谱</text>
|
||||
<text class="dialog-copy">建议先搜索已有家谱,避免重复创建</text>
|
||||
<text v-if="creationQuota" class="dialog-copy">
|
||||
{{ creationQuota.createRemaining === -1
|
||||
? "当前可继续创建家谱"
|
||||
: `还可创建 ${creationQuota.createRemaining} 部家谱` }}
|
||||
</text>
|
||||
<view
|
||||
class="add-dialog__close"
|
||||
role="button"
|
||||
aria-label="关闭"
|
||||
hover-class="action-hover"
|
||||
@click="emit('close')"
|
||||
>
|
||||
<image
|
||||
class="add-dialog__close-icon"
|
||||
src="/static/assets/modules/genealogy/transparent/dialog-close.png"
|
||||
mode="aspectFit"
|
||||
/>
|
||||
</view>
|
||||
</view>
|
||||
<view class="add-dialog__actions">
|
||||
<AppButton block label="搜索家谱" @click="emit('search')" />
|
||||
<AppButton
|
||||
block
|
||||
type="secondary"
|
||||
label="继续创建家谱"
|
||||
:disabled="creationQuota?.canCreate === false"
|
||||
@click="emit('create')"
|
||||
/>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import AppButton from "@/components/AppButton.vue";
|
||||
|
||||
defineProps({
|
||||
visible: { type: Boolean, required: true },
|
||||
creationQuota: { type: Object, default: null },
|
||||
});
|
||||
|
||||
const emit = defineEmits(["close", "search", "create"]);
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@use "../../styles/adaptive-frame-profiles.scss" as adaptive;
|
||||
|
||||
.add-dialog-layer {
|
||||
position: fixed;
|
||||
z-index: 40;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: center;
|
||||
box-sizing: border-box;
|
||||
background: rgba(34, 20, 12, 0.68);
|
||||
}
|
||||
|
||||
.add-dialog {
|
||||
@include adaptive.adaptive-genealogy-add-sheet;
|
||||
width: 100%;
|
||||
min-height: 780rpx;
|
||||
max-height: calc(100vh - 80rpx);
|
||||
}
|
||||
|
||||
.add-dialog__content {
|
||||
display: flex;
|
||||
min-height: 780rpx;
|
||||
max-height: calc(100vh - 80rpx);
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
box-sizing: border-box;
|
||||
padding: 96rpx 52rpx calc(96rpx + env(safe-area-inset-bottom));
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.add-dialog__body {
|
||||
margin: auto 0;
|
||||
}
|
||||
|
||||
.add-dialog__heading {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 96rpx;
|
||||
}
|
||||
|
||||
.add-dialog .dialog-title,
|
||||
.add-dialog .dialog-copy {
|
||||
display: block;
|
||||
grid-column: 1;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.dialog-title {
|
||||
color: $brand-red;
|
||||
font-family: "STKaiti", "KaiTi", serif;
|
||||
font-size: clamp(22px, 42rpx, 28px);
|
||||
font-weight: 700;
|
||||
letter-spacing: 4rpx;
|
||||
}
|
||||
|
||||
.dialog-copy {
|
||||
margin-top: 12rpx;
|
||||
color: $ink-muted;
|
||||
font-size: clamp(15px, 25rpx, 18px);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.add-dialog__close {
|
||||
display: flex;
|
||||
grid-column: 2;
|
||||
grid-row: 1 / span 2;
|
||||
align-self: start;
|
||||
justify-self: end;
|
||||
width: 80rpx;
|
||||
height: 80rpx;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-right: -22rpx;
|
||||
}
|
||||
|
||||
.add-dialog__close-icon {
|
||||
width: 80rpx;
|
||||
height: 80rpx;
|
||||
}
|
||||
|
||||
.add-dialog__actions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
margin: 62rpx -32rpx 0;
|
||||
}
|
||||
|
||||
.add-dialog__actions > .app-button {
|
||||
width: 595rpx;
|
||||
max-width: 100%;
|
||||
min-height: 96rpx;
|
||||
}
|
||||
|
||||
.add-dialog__actions > .app-button + .app-button {
|
||||
margin-top: 24rpx;
|
||||
}
|
||||
|
||||
.action-hover {
|
||||
opacity: 0.82;
|
||||
}
|
||||
</style>
|
||||
@@ -1,4 +1,3 @@
|
||||
<!-- 公共组件:G01 已创建/已加入家谱的题签列表项;保持透明题签框叠在纸纹底图上。 -->
|
||||
<template>
|
||||
<view
|
||||
class="genealogy-card"
|
||||
@@ -0,0 +1,410 @@
|
||||
<template>
|
||||
<AppDialog
|
||||
:visible="managerVisible"
|
||||
:close-on-mask="false"
|
||||
eyebrow="家谱邀请"
|
||||
title="邀请家人加入"
|
||||
confirm-text="生成新邀请码"
|
||||
cancel-text="关闭"
|
||||
show-cancel
|
||||
@confirm="requestIssueInvitation"
|
||||
@cancel="closeManager"
|
||||
>
|
||||
<text class="invitation-manager__note">
|
||||
邀请码只会显示一次,请及时发送给家人;有效期和使用状态以页面提示为准。
|
||||
</text>
|
||||
<AppLoading v-if="invitationState === 'loading'" text="正在加载邀请记录" />
|
||||
<view v-else-if="invitationState === 'error'" class="invitation-manager__state">
|
||||
<text>{{ invitationError || "邀请记录暂时无法加载。" }}</text>
|
||||
<AppButton compact type="secondary" label="重新加载" @click="loadInvitations" />
|
||||
</view>
|
||||
<view v-else-if="invitations.length" class="invitation-manager__list">
|
||||
<view
|
||||
v-for="invitation in invitations"
|
||||
:key="invitation.id"
|
||||
class="invitation-manager__row"
|
||||
>
|
||||
<view>
|
||||
<text>{{ invitation.genealogyName }}</text>
|
||||
<text>有效至 {{ formatInvitationTime(invitation.expiresAt) }}</text>
|
||||
<text>{{ invitationStatusLabel(invitation.status) }}</text>
|
||||
</view>
|
||||
<AppButton
|
||||
v-if="invitation.status === 'ACTIVE'"
|
||||
compact
|
||||
type="secondary"
|
||||
label="撤销"
|
||||
:disabled="revokingInvitationId === invitation.id"
|
||||
@click="requestRevokeInvitation(invitation)"
|
||||
/>
|
||||
</view>
|
||||
</view>
|
||||
<view v-else class="invitation-manager__state">
|
||||
<text>还没有发出过邀请码。</text>
|
||||
</view>
|
||||
</AppDialog>
|
||||
|
||||
<AppDialog
|
||||
:visible="issueConfirmationVisible"
|
||||
:close-on-mask="false"
|
||||
eyebrow="生成邀请码"
|
||||
title="生成新的邀请码?"
|
||||
message="生成后请只发送给要邀请的家人。邀请码是否有效,以页面提示为准。"
|
||||
:confirm-text="issuingInvitation ? '正在生成' : '确认生成'"
|
||||
cancel-text="暂不生成"
|
||||
show-cancel
|
||||
@confirm="issueInvitation"
|
||||
@cancel="closeIssueConfirmation"
|
||||
/>
|
||||
|
||||
<AppDialog
|
||||
:visible="issuedInvitationVisible"
|
||||
:close-on-mask="false"
|
||||
eyebrow="邀请码已生成"
|
||||
title="请立即保存并发送"
|
||||
:message="issuedInvitationMessage"
|
||||
confirm-text="我已保存"
|
||||
:show-cancel="false"
|
||||
@confirm="closeIssuedInvitation"
|
||||
@cancel="closeIssuedInvitation"
|
||||
>
|
||||
<text v-if="issuedInvitation" class="issued-invitation__token" selectable>
|
||||
{{ issuedInvitation.token }}
|
||||
</text>
|
||||
<text v-if="copyNotice" class="issued-invitation__notice">{{ copyNotice }}</text>
|
||||
<AppButton
|
||||
v-if="issuedInvitation"
|
||||
block
|
||||
type="secondary"
|
||||
label="复制邀请码"
|
||||
@click="copyIssuedInvitation"
|
||||
/>
|
||||
</AppDialog>
|
||||
|
||||
<AppDialog
|
||||
:visible="revokeConfirmationVisible"
|
||||
:close-on-mask="false"
|
||||
eyebrow="撤销确认"
|
||||
title="撤销这个邀请码?"
|
||||
:message="revokeConfirmationMessage"
|
||||
:confirm-text="revokingInvitationId ? '正在撤销' : '确认撤销'"
|
||||
cancel-text="暂不撤销"
|
||||
show-cancel
|
||||
@confirm="revokeInvitation"
|
||||
@cancel="closeRevokeConfirmation"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
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 {
|
||||
createRequestController,
|
||||
isRequestCancelled
|
||||
} from "@/services/api/request-controller.js";
|
||||
import { genealogyMembershipApi } from "@/services/api/genealogy-membership-service.js";
|
||||
import { getRequestErrorMessage } from "@/services/api/request-error-message.js";
|
||||
import { createNonIdempotentWriteGuard } from "@/utils/non-idempotent-write-guard.js";
|
||||
|
||||
const props = defineProps({
|
||||
genealogyId: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(["busy-change", "transient-change"]);
|
||||
|
||||
const managerVisible = ref(false);
|
||||
const invitationState = ref("idle");
|
||||
const invitationError = ref("");
|
||||
const invitations = ref([]);
|
||||
const issueConfirmationVisible = ref(false);
|
||||
const issuingInvitation = ref(false);
|
||||
const issuedInvitationVisible = ref(false);
|
||||
const issuedInvitation = ref(null);
|
||||
const copyNotice = ref("");
|
||||
const revokeConfirmationVisible = ref(false);
|
||||
const revokeTarget = ref(null);
|
||||
const revokingInvitationId = ref("");
|
||||
const invitationListRequestController = createRequestController();
|
||||
const invitationIssuanceRequestController = createRequestController();
|
||||
const invitationRevocationRequestController = createRequestController();
|
||||
const invitationIssuanceGuard = createNonIdempotentWriteGuard();
|
||||
let componentActive = true;
|
||||
|
||||
const isBusy = computed(() =>
|
||||
issuingInvitation.value || Boolean(revokingInvitationId.value),
|
||||
);
|
||||
const hasTransient = computed(() =>
|
||||
managerVisible.value ||
|
||||
issueConfirmationVisible.value ||
|
||||
issuedInvitationVisible.value ||
|
||||
revokeConfirmationVisible.value,
|
||||
);
|
||||
const issuedInvitationMessage = computed(() =>
|
||||
issuedInvitation.value
|
||||
? `适用于「${issuedInvitation.value.genealogyName}」,有效至 ${formatInvitationTime(
|
||||
issuedInvitation.value.expiresAt,
|
||||
)}。关闭后无法再次查看原始邀请码。`
|
||||
: "",
|
||||
);
|
||||
const revokeConfirmationMessage = computed(() =>
|
||||
revokeTarget.value
|
||||
? `撤销后「${revokeTarget.value.genealogyName}」的邀请码将不能再被使用。`
|
||||
: "",
|
||||
);
|
||||
|
||||
watch(isBusy, (busy) => emit("busy-change", busy), { immediate: true });
|
||||
watch(hasTransient, (visible) => emit("transient-change", visible), {
|
||||
immediate: true,
|
||||
});
|
||||
|
||||
const formatInvitationTime = (value) =>
|
||||
typeof value === "string" && value.length >= 10 ? value.slice(0, 10) : value;
|
||||
const invitationStatusLabel = (status) =>
|
||||
({
|
||||
ACTIVE: "等待使用",
|
||||
REDEEMED: "已被使用",
|
||||
REVOKED: "已撤销",
|
||||
EXPIRED: "已过期",
|
||||
})[status] || "状态已更新";
|
||||
|
||||
const loadInvitations = async () => {
|
||||
if (!props.genealogyId) return;
|
||||
invitationListRequestController.abort();
|
||||
invitationState.value = "loading";
|
||||
invitationError.value = "";
|
||||
try {
|
||||
const invitationRows = await genealogyMembershipApi.getMyGenealogyInvitations({
|
||||
requestController: invitationListRequestController,
|
||||
});
|
||||
if (!componentActive) return;
|
||||
invitations.value = invitationRows.filter(
|
||||
(invitation) => invitation.genealogyId === props.genealogyId,
|
||||
);
|
||||
invitationState.value = "ready";
|
||||
} catch (error) {
|
||||
if (!componentActive || isRequestCancelled(error)) return;
|
||||
invitationState.value = "error";
|
||||
invitationError.value = getRequestErrorMessage(
|
||||
error,
|
||||
"邀请记录加载失败,请稍后重试。",
|
||||
);
|
||||
}
|
||||
};
|
||||
const open = () => {
|
||||
if (!props.genealogyId) return false;
|
||||
managerVisible.value = true;
|
||||
void loadInvitations();
|
||||
return true;
|
||||
};
|
||||
const closeManager = () => {
|
||||
if (isBusy.value) return false;
|
||||
managerVisible.value = false;
|
||||
return true;
|
||||
};
|
||||
const requestIssueInvitation = () => {
|
||||
if (invitationState.value === "loading" || isBusy.value) return;
|
||||
issueConfirmationVisible.value = true;
|
||||
};
|
||||
const closeIssueConfirmation = () => {
|
||||
if (isBusy.value) return false;
|
||||
issueConfirmationVisible.value = false;
|
||||
return true;
|
||||
};
|
||||
const issueInvitation = async () => {
|
||||
if (!props.genealogyId || isBusy.value) return;
|
||||
const issueAttempt = invitationIssuanceGuard.begin({
|
||||
genealogyId: props.genealogyId,
|
||||
});
|
||||
if (issueAttempt === null) {
|
||||
managerVisible.value = true;
|
||||
invitationError.value =
|
||||
"上次生成结果暂时无法确认,请先检查邀请记录,避免重复生成。";
|
||||
return;
|
||||
}
|
||||
issuingInvitation.value = true;
|
||||
issueConfirmationVisible.value = false;
|
||||
managerVisible.value = false;
|
||||
copyNotice.value = "";
|
||||
try {
|
||||
const invitation = await genealogyMembershipApi.issueGenealogyInvitation(
|
||||
props.genealogyId,
|
||||
{ requestController: invitationIssuanceRequestController },
|
||||
);
|
||||
if (!componentActive) return;
|
||||
issuedInvitation.value = invitation;
|
||||
issuedInvitationVisible.value = true;
|
||||
} catch (error) {
|
||||
if (!componentActive) return;
|
||||
if (invitationIssuanceGuard.recordFailure(issueAttempt, error)) {
|
||||
managerVisible.value = true;
|
||||
invitationError.value =
|
||||
"邀请码生成结果暂时无法确认,请先检查邀请记录,避免重复生成。";
|
||||
return;
|
||||
}
|
||||
if (isRequestCancelled(error)) return;
|
||||
invitationError.value = getRequestErrorMessage(
|
||||
error,
|
||||
"邀请码生成失败,请稍后重试。",
|
||||
);
|
||||
invitationState.value = "error";
|
||||
managerVisible.value = true;
|
||||
} finally {
|
||||
if (componentActive) issuingInvitation.value = false;
|
||||
}
|
||||
};
|
||||
const closeIssuedInvitation = () => {
|
||||
if (isBusy.value) return false;
|
||||
issuedInvitationVisible.value = false;
|
||||
issuedInvitation.value = null;
|
||||
copyNotice.value = "";
|
||||
return true;
|
||||
};
|
||||
const copyIssuedInvitation = () => {
|
||||
const invitationToken = issuedInvitation.value?.token;
|
||||
if (!invitationToken) return;
|
||||
if (typeof uni === "undefined" || typeof uni.setClipboardData !== "function") {
|
||||
copyNotice.value = "暂时无法自动复制,请手动保存邀请码。";
|
||||
return;
|
||||
}
|
||||
uni.setClipboardData({
|
||||
data: invitationToken,
|
||||
success: () => {
|
||||
copyNotice.value = "邀请码已复制,请发送给家人。";
|
||||
},
|
||||
fail: () => {
|
||||
copyNotice.value = "复制失败,请手动保存邀请码。";
|
||||
},
|
||||
});
|
||||
};
|
||||
const requestRevokeInvitation = (invitation) => {
|
||||
if (!invitation || invitation.status !== "ACTIVE" || isBusy.value) return;
|
||||
revokeTarget.value = invitation;
|
||||
revokeConfirmationVisible.value = true;
|
||||
};
|
||||
const closeRevokeConfirmation = () => {
|
||||
if (isBusy.value) return false;
|
||||
revokeConfirmationVisible.value = false;
|
||||
revokeTarget.value = null;
|
||||
return true;
|
||||
};
|
||||
const revokeInvitation = async () => {
|
||||
const invitation = revokeTarget.value;
|
||||
if (!invitation || invitation.status !== "ACTIVE" || isBusy.value) return;
|
||||
revokingInvitationId.value = invitation.id;
|
||||
invitationError.value = "";
|
||||
try {
|
||||
await genealogyMembershipApi.revokeGenealogyInvitation(invitation.id, {
|
||||
requestController: invitationRevocationRequestController,
|
||||
});
|
||||
if (!componentActive) return;
|
||||
revokeConfirmationVisible.value = false;
|
||||
revokeTarget.value = null;
|
||||
await loadInvitations();
|
||||
} catch (error) {
|
||||
if (!componentActive || isRequestCancelled(error)) return;
|
||||
invitationError.value = getRequestErrorMessage(
|
||||
error,
|
||||
"邀请码撤销失败,请稍后重试。",
|
||||
);
|
||||
revokeConfirmationVisible.value = false;
|
||||
revokeTarget.value = null;
|
||||
} finally {
|
||||
if (componentActive) revokingInvitationId.value = "";
|
||||
}
|
||||
};
|
||||
const closeTransient = () => {
|
||||
if (isBusy.value) return true;
|
||||
if (revokeConfirmationVisible.value) return closeRevokeConfirmation();
|
||||
if (issuedInvitationVisible.value) return closeIssuedInvitation();
|
||||
if (issueConfirmationVisible.value) return closeIssueConfirmation();
|
||||
if (managerVisible.value) return closeManager();
|
||||
return false;
|
||||
};
|
||||
|
||||
defineExpose({ closeTransient, open });
|
||||
|
||||
onUnmounted(() => {
|
||||
componentActive = false;
|
||||
invitationListRequestController.abort();
|
||||
invitationIssuanceRequestController.abort();
|
||||
invitationRevocationRequestController.abort();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.invitation-manager__note,
|
||||
.invitation-manager__state text,
|
||||
.invitation-manager__row text,
|
||||
.issued-invitation__token,
|
||||
.issued-invitation__notice {
|
||||
display: block;
|
||||
}
|
||||
.invitation-manager__note,
|
||||
.invitation-manager__state text,
|
||||
.invitation-manager__row text:not(:first-child),
|
||||
.issued-invitation__notice {
|
||||
color: $ink-muted;
|
||||
font-size: clamp(14px, 22rpx, 17px);
|
||||
line-height: 1.55;
|
||||
}
|
||||
.invitation-manager__state,
|
||||
.invitation-manager__list {
|
||||
width: 100%;
|
||||
margin-top: 20rpx;
|
||||
}
|
||||
.invitation-manager__state .app-button {
|
||||
margin-top: 14rpx;
|
||||
}
|
||||
.invitation-manager__row {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 16rpx 0;
|
||||
border-top: 1rpx solid rgba(152, 119, 72, 0.28);
|
||||
text-align: left;
|
||||
gap: 18rpx;
|
||||
}
|
||||
.invitation-manager__row > view {
|
||||
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: break-word;
|
||||
word-break: break-word;
|
||||
}
|
||||
.issued-invitation__token {
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
margin-top: 18rpx;
|
||||
padding: 18rpx;
|
||||
border: 1rpx dashed rgba(159, 23, 15, 0.48);
|
||||
border-radius: 8rpx;
|
||||
color: $brand-red;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
font-size: clamp(14px, 22rpx, 17px);
|
||||
line-height: 1.5;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.issued-invitation__notice {
|
||||
margin-top: 12rpx;
|
||||
text-align: center;
|
||||
}
|
||||
.issued-invitation__token + .app-button {
|
||||
margin-top: 18rpx;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,339 @@
|
||||
<template>
|
||||
<view v-if="visible" class="genealogy-order-layer" @click="requestClose">
|
||||
<view
|
||||
class="genealogy-order-dialog"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="调整家谱排序"
|
||||
@click.stop
|
||||
>
|
||||
<view class="genealogy-order-dialog__head">
|
||||
<view>
|
||||
<text class="dialog-title">调整家谱排序</text>
|
||||
<text class="genealogy-order-dialog__copy">保存后将按此顺序显示我的家谱</text>
|
||||
</view>
|
||||
<view
|
||||
class="genealogy-order-dialog__close"
|
||||
role="button"
|
||||
aria-label="关闭排序"
|
||||
hover-class="action-hover"
|
||||
@click="requestClose"
|
||||
>
|
||||
<image
|
||||
class="genealogy-order-dialog__close-icon"
|
||||
src="/static/assets/modules/genealogy/transparent/dialog-close.png"
|
||||
mode="aspectFit"
|
||||
/>
|
||||
</view>
|
||||
</view>
|
||||
<scroll-view class="genealogy-order-list" scroll-y>
|
||||
<view
|
||||
v-for="(genealogy, index) in orderDraft"
|
||||
:key="genealogy.id"
|
||||
class="genealogy-order-item"
|
||||
>
|
||||
<view class="genealogy-order-item__main">
|
||||
<text class="genealogy-order-item__position">{{ index + 1 }}</text>
|
||||
<view>
|
||||
<text class="genealogy-order-item__name">{{ genealogy.name }}</text>
|
||||
<text class="genealogy-order-item__role">
|
||||
{{ genealogy.canManage ? "管理员" : "成员" }}
|
||||
</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="genealogy-order-item__actions">
|
||||
<button
|
||||
class="genealogy-order-item__move"
|
||||
:disabled="saving || index === 0"
|
||||
@click="moveOrderItem(index, -1)"
|
||||
>上移</button>
|
||||
<button
|
||||
class="genealogy-order-item__move"
|
||||
:disabled="saving || index === orderDraft.length - 1"
|
||||
@click="moveOrderItem(index, 1)"
|
||||
>下移</button>
|
||||
</view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
<text v-if="orderError" class="genealogy-order-error">{{ orderError }}</text>
|
||||
<view class="genealogy-order-dialog__actions">
|
||||
<AppButton type="secondary" :disabled="saving" label="取消" @click="requestClose" />
|
||||
<AppButton
|
||||
:disabled="saving || !isOrderDirty"
|
||||
:label="saving ? '正在保存' : '保存排序'"
|
||||
@click="saveOrder"
|
||||
/>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, onUnmounted, ref, watch } from "vue";
|
||||
import AppButton from "@/components/AppButton.vue";
|
||||
import {
|
||||
createRequestController,
|
||||
isRequestCancelled
|
||||
} from "@/services/api/request-controller.js";
|
||||
import { genealogyApi } from "@/services/api/genealogy-service.js";
|
||||
import { getRequestErrorMessage } from "@/services/api/request-error-message.js";
|
||||
|
||||
const props = defineProps({
|
||||
visible: { type: Boolean, required: true },
|
||||
genealogies: { type: Array, required: true },
|
||||
});
|
||||
|
||||
const emit = defineEmits(["close", "saved"]);
|
||||
const orderDraft = ref([]);
|
||||
const saving = ref(false);
|
||||
const orderError = ref("");
|
||||
const orderSaveRequestController = createRequestController();
|
||||
let componentActive = true;
|
||||
|
||||
const isOrderDirty = computed(() =>
|
||||
orderDraft.value.some(
|
||||
(genealogy, index) => genealogy.id !== props.genealogies[index]?.id,
|
||||
),
|
||||
);
|
||||
|
||||
watch(
|
||||
() => props.visible,
|
||||
(visible) => {
|
||||
if (!visible) return;
|
||||
orderDraft.value = props.genealogies.map((genealogy) => ({ ...genealogy }));
|
||||
orderError.value = "";
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
const requestClose = () => {
|
||||
if (saving.value) return false;
|
||||
orderError.value = "";
|
||||
emit("close");
|
||||
return true;
|
||||
};
|
||||
|
||||
const moveOrderItem = (sourceIndex, direction) => {
|
||||
const targetIndex = sourceIndex + direction;
|
||||
if (
|
||||
saving.value ||
|
||||
targetIndex < 0 ||
|
||||
targetIndex >= orderDraft.value.length
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const nextOrder = [...orderDraft.value];
|
||||
[nextOrder[sourceIndex], nextOrder[targetIndex]] = [
|
||||
nextOrder[targetIndex],
|
||||
nextOrder[sourceIndex],
|
||||
];
|
||||
orderDraft.value = nextOrder;
|
||||
orderError.value = "";
|
||||
};
|
||||
|
||||
const saveOrder = async () => {
|
||||
if (saving.value || !isOrderDirty.value) return;
|
||||
const draftIds = orderDraft.value.map((genealogy) => String(genealogy.id));
|
||||
const currentIds = props.genealogies.map((genealogy) => String(genealogy.id));
|
||||
if (
|
||||
draftIds.length !== currentIds.length ||
|
||||
new Set(draftIds).size !== draftIds.length ||
|
||||
draftIds.some((id) => !currentIds.includes(id))
|
||||
) {
|
||||
orderError.value = "当前家谱列表已变化,请关闭后重新调整。";
|
||||
return;
|
||||
}
|
||||
|
||||
saving.value = true;
|
||||
orderError.value = "";
|
||||
try {
|
||||
const confirmedGenealogies = await genealogyApi.saveMyGenealogyOrder(draftIds, {
|
||||
requestController: orderSaveRequestController,
|
||||
});
|
||||
if (!componentActive) return;
|
||||
emit("saved", confirmedGenealogies);
|
||||
} catch (error) {
|
||||
if (componentActive && !isRequestCancelled(error)) {
|
||||
orderError.value = getRequestErrorMessage(error, "保存排序失败,请稍后重试。");
|
||||
}
|
||||
} finally {
|
||||
if (componentActive) saving.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
onUnmounted(() => {
|
||||
componentActive = false;
|
||||
orderSaveRequestController.abort();
|
||||
});
|
||||
|
||||
defineExpose({ requestClose });
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.genealogy-order-layer {
|
||||
position: fixed;
|
||||
z-index: 40;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: center;
|
||||
padding: 32rpx;
|
||||
background: rgba(36, 24, 16, 0.54);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.genealogy-order-dialog {
|
||||
width: 100%;
|
||||
max-width: 680rpx;
|
||||
max-height: 80vh;
|
||||
padding: 32rpx;
|
||||
border: 2rpx solid rgba(128, 78, 29, 0.28);
|
||||
border-radius: 24rpx;
|
||||
background: #f9f6ef;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.genealogy-order-dialog__head {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 20rpx;
|
||||
}
|
||||
|
||||
.dialog-title {
|
||||
color: $brand-red;
|
||||
font-family: "STKaiti", "KaiTi", serif;
|
||||
font-size: clamp(22px, 42rpx, 28px);
|
||||
font-weight: 700;
|
||||
letter-spacing: 4rpx;
|
||||
}
|
||||
|
||||
.genealogy-order-dialog__copy,
|
||||
.genealogy-order-item__name,
|
||||
.genealogy-order-item__role,
|
||||
.genealogy-order-error {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.genealogy-order-dialog__copy,
|
||||
.genealogy-order-item__role {
|
||||
margin-top: 8rpx;
|
||||
color: #8a7564;
|
||||
font-size: clamp(14px, 23rpx, 17px);
|
||||
}
|
||||
|
||||
.genealogy-order-dialog__close {
|
||||
display: flex;
|
||||
width: 80rpx;
|
||||
height: 80rpx;
|
||||
flex: 0 0 auto;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-top: -42rpx;
|
||||
margin-right: -24rpx;
|
||||
}
|
||||
|
||||
.genealogy-order-dialog__close-icon {
|
||||
width: 80rpx;
|
||||
height: 80rpx;
|
||||
}
|
||||
|
||||
.genealogy-order-list {
|
||||
max-height: 720rpx;
|
||||
margin-top: 28rpx;
|
||||
}
|
||||
|
||||
.genealogy-order-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16rpx;
|
||||
min-height: 108rpx;
|
||||
padding: 16rpx 0;
|
||||
border-bottom: 1rpx solid rgba(149, 103, 49, 0.16);
|
||||
}
|
||||
|
||||
.genealogy-order-item__main,
|
||||
.genealogy-order-item__actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.genealogy-order-item__main {
|
||||
min-width: 0;
|
||||
gap: 18rpx;
|
||||
}
|
||||
|
||||
.genealogy-order-item__main > view {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.genealogy-order-item__position {
|
||||
display: grid;
|
||||
width: 42rpx;
|
||||
height: 42rpx;
|
||||
flex: 0 0 auto;
|
||||
place-items: center;
|
||||
border-radius: 50%;
|
||||
background: #7b4024;
|
||||
color: #fffaf0;
|
||||
font-size: clamp(14px, 23rpx, 17px);
|
||||
}
|
||||
|
||||
.genealogy-order-item__name {
|
||||
color: #3e2b20;
|
||||
font-size: clamp(16px, 27rpx, 20px);
|
||||
font-weight: 700;
|
||||
line-height: 1.35;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.genealogy-order-item__actions {
|
||||
flex: 0 0 auto;
|
||||
gap: 10rpx;
|
||||
}
|
||||
|
||||
.genealogy-order-item__move {
|
||||
min-width: 82rpx;
|
||||
height: 54rpx;
|
||||
margin: 0;
|
||||
padding: 0 14rpx;
|
||||
border: 1rpx solid rgba(123, 64, 36, 0.46);
|
||||
border-radius: 8rpx;
|
||||
background: transparent;
|
||||
color: #7b4024;
|
||||
font-size: clamp(14px, 23rpx, 17px);
|
||||
line-height: 52rpx;
|
||||
}
|
||||
|
||||
.genealogy-order-item__move::after {
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.genealogy-order-item__move[disabled] {
|
||||
opacity: 0.36;
|
||||
}
|
||||
|
||||
.genealogy-order-error {
|
||||
margin-top: 20rpx;
|
||||
color: #b3432f;
|
||||
font-size: clamp(15px, 24rpx, 18px);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.genealogy-order-dialog__actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 18rpx;
|
||||
margin-top: 28rpx;
|
||||
}
|
||||
|
||||
.genealogy-order-dialog__actions .app-button {
|
||||
width: 220rpx;
|
||||
}
|
||||
|
||||
.action-hover {
|
||||
opacity: 0.82;
|
||||
}
|
||||
</style>
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,208 @@
|
||||
<template>
|
||||
<RegionPickerSheet
|
||||
:visible="visible"
|
||||
:columns="columns"
|
||||
:indexes="indexes"
|
||||
:indicator-style="indicatorStyle"
|
||||
:close-on-mask="closeOnMask"
|
||||
@change="changeSelection"
|
||||
@cancel="close"
|
||||
@confirm="confirmSelection"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onUnmounted, ref } from "vue";
|
||||
import RegionPickerSheet from "@/components/genealogy/RegionPickerSheet.vue";
|
||||
import {
|
||||
createRequestController,
|
||||
isRequestCancelled
|
||||
} from "@/services/api/request-controller.js";
|
||||
import { regionApi } from "@/services/api/region-service.js";
|
||||
import { getRequestErrorMessage } from "@/services/api/request-error-message.js";
|
||||
|
||||
const props = defineProps({
|
||||
closeOnMask: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
maxLevels: {
|
||||
type: Number,
|
||||
default: 5,
|
||||
validator: (levelCount) => Number.isInteger(levelCount) && levelCount > 0,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits([
|
||||
"error-change",
|
||||
"loading-change",
|
||||
"select",
|
||||
"transient-change",
|
||||
]);
|
||||
|
||||
const indicatorStyle =
|
||||
"height: 104rpx; border-top: 1px solid rgba(159, 23, 15, .46); border-bottom: 1px solid rgba(159, 23, 15, .46); background: rgba(159, 23, 15, .08);";
|
||||
const visible = ref(false);
|
||||
const columns = ref([]);
|
||||
const indexes = ref([0]);
|
||||
const selectedTrail = ref([]);
|
||||
const preparedRegionCode = ref("");
|
||||
const regionRequestController = createRequestController();
|
||||
let componentActive = true;
|
||||
let loading = false;
|
||||
|
||||
const setLoading = (nextLoading) => {
|
||||
loading = nextLoading;
|
||||
emit("loading-change", nextLoading);
|
||||
};
|
||||
const setError = (message = "") => {
|
||||
emit("error-change", message);
|
||||
};
|
||||
const fetchRegionChildren = (parentCode) =>
|
||||
regionApi.getRegionChildren(parentCode, {
|
||||
requestController: regionRequestController,
|
||||
});
|
||||
|
||||
const loadColumns = async (requestedIndexes = []) => {
|
||||
const rootOptions = columns.value[0] || (await fetchRegionChildren("0"));
|
||||
if (!rootOptions.length) return false;
|
||||
const nextColumns = [rootOptions];
|
||||
const nextIndexes = [];
|
||||
let options = rootOptions;
|
||||
for (let level = 0; level < props.maxLevels; level += 1) {
|
||||
const requestedIndex = Number(requestedIndexes[level]);
|
||||
const selectedIndex = Number.isInteger(requestedIndex)
|
||||
? Math.min(Math.max(requestedIndex, 0), options.length - 1)
|
||||
: 0;
|
||||
const selectedOption = options[selectedIndex];
|
||||
if (!selectedOption) break;
|
||||
nextIndexes.push(selectedIndex);
|
||||
if (level === props.maxLevels - 1) break;
|
||||
const childOptions = await fetchRegionChildren(selectedOption.regionCode);
|
||||
if (!childOptions.length) break;
|
||||
nextColumns.push(childOptions);
|
||||
options = childOptions;
|
||||
}
|
||||
if (!componentActive) return false;
|
||||
columns.value = nextColumns;
|
||||
indexes.value = nextIndexes;
|
||||
selectedTrail.value = nextColumns
|
||||
.map((column, level) => column[nextIndexes[level]])
|
||||
.filter(Boolean);
|
||||
return Boolean(selectedTrail.value.length);
|
||||
};
|
||||
|
||||
const loadRegionPath = async (regionCode) => {
|
||||
const regionPath = (await regionApi.getRegionPath(regionCode, {
|
||||
requestController: regionRequestController,
|
||||
})).filter((regionNode) => regionNode?.regionCode);
|
||||
if (
|
||||
!regionPath.length ||
|
||||
regionPath[regionPath.length - 1].regionCode !== regionCode
|
||||
) {
|
||||
throw new Error("REGION_PATH_MISMATCH");
|
||||
}
|
||||
const nextColumns = [];
|
||||
const nextIndexes = [];
|
||||
const nextTrail = [];
|
||||
let parentCode = "0";
|
||||
for (const regionPathNode of regionPath.slice(0, props.maxLevels)) {
|
||||
const options = await fetchRegionChildren(parentCode);
|
||||
const selectedIndex = options.findIndex(
|
||||
(option) => option.regionCode === regionPathNode.regionCode,
|
||||
);
|
||||
if (selectedIndex < 0) throw new Error("REGION_PATH_MISMATCH");
|
||||
nextColumns.push(options);
|
||||
nextIndexes.push(selectedIndex);
|
||||
nextTrail.push(options[selectedIndex]);
|
||||
parentCode = regionPathNode.regionCode;
|
||||
}
|
||||
if (!componentActive) return false;
|
||||
columns.value = nextColumns;
|
||||
indexes.value = nextIndexes;
|
||||
selectedTrail.value = nextTrail;
|
||||
return Boolean(nextTrail.length);
|
||||
};
|
||||
|
||||
const prepare = async (initialRegionCode = "") => {
|
||||
const normalizedRegionCode = String(initialRegionCode || "");
|
||||
if (loading) return false;
|
||||
if (
|
||||
columns.value.length &&
|
||||
preparedRegionCode.value === normalizedRegionCode
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
regionRequestController.abort();
|
||||
setLoading(true);
|
||||
setError();
|
||||
try {
|
||||
const ready = normalizedRegionCode
|
||||
? await loadRegionPath(normalizedRegionCode)
|
||||
: await loadColumns([0]);
|
||||
if (!componentActive || !ready) return false;
|
||||
preparedRegionCode.value = normalizedRegionCode;
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (!componentActive || isRequestCancelled(error)) return false;
|
||||
const fallback = normalizedRegionCode
|
||||
? "暂时无法定位当前地区,请稍后重试。"
|
||||
: "地区列表加载失败,请重试";
|
||||
setError(getRequestErrorMessage(error, fallback));
|
||||
return false;
|
||||
} finally {
|
||||
if (componentActive) setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const open = async (initialRegionCode = "") => {
|
||||
if (!(await prepare(initialRegionCode))) return false;
|
||||
visible.value = true;
|
||||
emit("transient-change", true);
|
||||
return true;
|
||||
};
|
||||
const close = () => {
|
||||
visible.value = false;
|
||||
emit("transient-change", false);
|
||||
};
|
||||
const changeSelection = async (event) => {
|
||||
if (loading) return;
|
||||
const requestedIndexes = (event?.detail?.value || []).map(
|
||||
(index) => Number(index) || 0,
|
||||
);
|
||||
const changedLevel = requestedIndexes.findIndex(
|
||||
(index, level) => index !== (indexes.value[level] || 0),
|
||||
);
|
||||
if (changedLevel < 0) return;
|
||||
setLoading(true);
|
||||
setError();
|
||||
try {
|
||||
const loaded = await loadColumns(requestedIndexes.slice(0, changedLevel + 1));
|
||||
if (!componentActive || !loaded) return;
|
||||
preparedRegionCode.value = "";
|
||||
} catch (error) {
|
||||
if (!componentActive || isRequestCancelled(error)) return;
|
||||
setError(getRequestErrorMessage(error, "地区列表加载失败,请重试"));
|
||||
} finally {
|
||||
if (componentActive) setLoading(false);
|
||||
}
|
||||
};
|
||||
const confirmSelection = () => {
|
||||
const trail = columns.value
|
||||
.map((column, level) => column[Number(indexes.value[level])])
|
||||
.filter(Boolean);
|
||||
const region = trail[trail.length - 1];
|
||||
if (!region) return;
|
||||
selectedTrail.value = trail;
|
||||
preparedRegionCode.value = region.regionCode;
|
||||
emit("select", { region, trail });
|
||||
close();
|
||||
};
|
||||
|
||||
defineExpose({ close, open, prepare });
|
||||
|
||||
onUnmounted(() => {
|
||||
componentActive = false;
|
||||
regionRequestController.abort();
|
||||
});
|
||||
</script>
|
||||
@@ -0,0 +1,187 @@
|
||||
<template>
|
||||
<view v-if="visible" class="region-sheet">
|
||||
<view class="region-sheet__mask" @click="requestMaskClose" />
|
||||
<view class="region-sheet__panel">
|
||||
<view class="region-sheet__intro">
|
||||
<text class="region-sheet__title">选择地区</text>
|
||||
</view>
|
||||
<view class="region-sheet__picker">
|
||||
<view class="region-sheet__column-headings">
|
||||
<text
|
||||
v-for="label in columnLabels"
|
||||
:key="label"
|
||||
class="region-sheet__column-heading"
|
||||
>
|
||||
{{ label }}
|
||||
</text>
|
||||
</view>
|
||||
<picker-view
|
||||
class="region-sheet__picker-view"
|
||||
:indicator-style="indicatorStyle"
|
||||
:value="indexes"
|
||||
@change="$emit('change', $event)"
|
||||
>
|
||||
<picker-view-column
|
||||
v-for="(column, columnIndex) in columns"
|
||||
:key="columnIndex"
|
||||
>
|
||||
<view
|
||||
v-for="(option, optionIndex) in column"
|
||||
:key="option.regionCode"
|
||||
class="region-sheet__picker-item"
|
||||
:class="{
|
||||
'region-sheet__picker-item--selected':
|
||||
indexes[columnIndex] === optionIndex,
|
||||
}"
|
||||
>
|
||||
{{ option.label }}
|
||||
</view>
|
||||
</picker-view-column>
|
||||
</picker-view>
|
||||
</view>
|
||||
<view class="region-sheet__footer">
|
||||
<view class="region-sheet__cancel" @click="$emit('cancel')">取消</view>
|
||||
<button class="region-sheet__confirm" @click="$emit('confirm')">
|
||||
确认选择
|
||||
</button>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from "vue";
|
||||
|
||||
const props = defineProps({
|
||||
visible: { type: Boolean, default: false },
|
||||
columns: { type: Array, default: () => [] },
|
||||
indexes: { type: Array, default: () => [] },
|
||||
indicatorStyle: { type: String, required: true },
|
||||
closeOnMask: { type: Boolean, default: false },
|
||||
});
|
||||
|
||||
const emit = defineEmits(["change", "cancel", "confirm"]);
|
||||
|
||||
const regionLevelLabels = Object.freeze([
|
||||
"省份",
|
||||
"城市",
|
||||
"区县",
|
||||
"乡镇街道",
|
||||
"村社区",
|
||||
]);
|
||||
const columnLabels = computed(() =>
|
||||
regionLevelLabels.slice(0, props.columns.length),
|
||||
);
|
||||
|
||||
const requestMaskClose = () => {
|
||||
if (props.closeOnMask) emit("cancel");
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.region-sheet {
|
||||
position: fixed;
|
||||
z-index: 10;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
}
|
||||
.region-sheet__mask {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: rgba(43, 30, 20, 0.42);
|
||||
}
|
||||
.region-sheet__panel {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
padding: 22rpx 28rpx calc(24rpx + env(safe-area-inset-bottom));
|
||||
border-radius: 30rpx 30rpx 0 0;
|
||||
background: #fdf9ef;
|
||||
box-shadow: 0 -12rpx 36rpx rgba(43, 30, 20, 0.2);
|
||||
}
|
||||
.region-sheet__intro {
|
||||
padding: 0 10rpx 16rpx;
|
||||
text-align: center;
|
||||
}
|
||||
.region-sheet__title {
|
||||
display: block;
|
||||
color: $ink;
|
||||
font-family: STKaiti, KaiTi, serif;
|
||||
font-size: clamp(19px, 36rpx, 24px);
|
||||
font-weight: 700;
|
||||
}
|
||||
.region-sheet__picker {
|
||||
overflow: hidden;
|
||||
border: 1rpx solid rgba(128, 89, 49, 0.28);
|
||||
border-radius: 16rpx;
|
||||
background: rgba(255, 252, 245, 0.8);
|
||||
}
|
||||
.region-sheet__column-headings {
|
||||
display: flex;
|
||||
height: 84rpx;
|
||||
border-bottom: 1rpx solid rgba(128, 89, 49, 0.18);
|
||||
}
|
||||
.region-sheet__column-heading {
|
||||
box-sizing: border-box;
|
||||
width: 33.333%;
|
||||
padding: 24rpx 12rpx;
|
||||
color: $ink-muted;
|
||||
font-size: clamp(16px, 26rpx, 20px);
|
||||
text-align: center;
|
||||
}
|
||||
.region-sheet__column-heading + .region-sheet__column-heading {
|
||||
border-left: 1rpx solid rgba(128, 89, 49, 0.16);
|
||||
}
|
||||
.region-sheet__picker-view {
|
||||
width: 100%;
|
||||
height: 520rpx;
|
||||
}
|
||||
.region-sheet__picker-item {
|
||||
box-sizing: border-box;
|
||||
height: 104rpx;
|
||||
overflow: hidden;
|
||||
padding: 0 6rpx;
|
||||
color: $ink;
|
||||
font-size: clamp(16px, 28rpx, 20px);
|
||||
line-height: 104rpx;
|
||||
text-align: center;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.region-sheet__picker-item--selected {
|
||||
color: $brand-red;
|
||||
font-weight: 700;
|
||||
}
|
||||
.region-sheet__footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-top: 22rpx;
|
||||
gap: 12rpx;
|
||||
}
|
||||
.region-sheet__cancel {
|
||||
min-width: 116rpx;
|
||||
padding: 20rpx 10rpx;
|
||||
color: $ink-muted;
|
||||
font-size: clamp(16px, 28rpx, 20px);
|
||||
text-align: center;
|
||||
}
|
||||
.region-sheet__confirm {
|
||||
display: flex;
|
||||
min-height: 82rpx;
|
||||
flex: 1;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-radius: 10rpx;
|
||||
background: $brand-red;
|
||||
color: #fff;
|
||||
font-size: clamp(16px, 29rpx, 20px);
|
||||
font-weight: 700;
|
||||
line-height: 1.4;
|
||||
}
|
||||
.region-sheet__confirm::after {
|
||||
display: none;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,189 @@
|
||||
<template>
|
||||
<view v-if="visible" class="genealogy-switcher-layer" @click="emit('close')">
|
||||
<view
|
||||
class="genealogy-switcher"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="切换当前家谱"
|
||||
@click.stop
|
||||
>
|
||||
<view class="genealogy-switcher__content">
|
||||
<text class="dialog-title">切换当前家谱</text>
|
||||
<view
|
||||
class="genealogy-switcher__close"
|
||||
role="button"
|
||||
aria-label="关闭"
|
||||
hover-class="action-hover"
|
||||
@click="emit('close')"
|
||||
>
|
||||
<image
|
||||
class="genealogy-switcher__close-icon"
|
||||
src="/static/assets/modules/genealogy/transparent/dialog-close.png"
|
||||
mode="aspectFit"
|
||||
/>
|
||||
</view>
|
||||
<scroll-view class="genealogy-switcher__list" scroll-y>
|
||||
<button
|
||||
v-for="genealogy in genealogies"
|
||||
:key="genealogy.id"
|
||||
class="switcher-item"
|
||||
:class="{ 'switcher-item--active': genealogy.id === selectedGenealogyId }"
|
||||
:aria-pressed="genealogy.id === selectedGenealogyId"
|
||||
:aria-label="`${genealogy.name},${genealogy.location},${genealogy.memberCount} 位成员`"
|
||||
@click="emit('select', genealogy)"
|
||||
>
|
||||
<view>
|
||||
<text class="switcher-item__name">{{ genealogy.name }}</text>
|
||||
<text class="switcher-item__meta">
|
||||
{{ genealogy.location }} · {{ genealogy.memberCount }} 位成员
|
||||
</text>
|
||||
</view>
|
||||
<text class="switcher-item__state">
|
||||
{{ genealogy.id === selectedGenealogyId ? "当前" : "选择" }}
|
||||
</text>
|
||||
</button>
|
||||
</scroll-view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
defineProps({
|
||||
visible: { type: Boolean, required: true },
|
||||
genealogies: { type: Array, required: true },
|
||||
selectedGenealogyId: { type: [String, Number], default: null },
|
||||
});
|
||||
|
||||
const emit = defineEmits(["close", "select"]);
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@use "../../styles/adaptive-frame-profiles.scss" as adaptive;
|
||||
|
||||
.genealogy-switcher-layer {
|
||||
position: fixed;
|
||||
z-index: 40;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-sizing: border-box;
|
||||
padding: 40rpx;
|
||||
background: rgba(34, 20, 12, 0.58);
|
||||
}
|
||||
|
||||
.genealogy-switcher {
|
||||
@include adaptive.adaptive-genealogy-switcher;
|
||||
width: 670rpx;
|
||||
max-width: 100%;
|
||||
min-height: 600rpx;
|
||||
max-height: calc(100vh - 120rpx);
|
||||
}
|
||||
|
||||
.genealogy-switcher__content {
|
||||
display: grid;
|
||||
min-height: 600rpx;
|
||||
max-height: calc(100vh - 120rpx);
|
||||
grid-template-rows: auto minmax(0, 1fr);
|
||||
align-items: center;
|
||||
box-sizing: border-box;
|
||||
padding: 120rpx 58rpx 140rpx;
|
||||
}
|
||||
|
||||
.genealogy-switcher__content > .dialog-title {
|
||||
grid-area: 1 / 1;
|
||||
}
|
||||
|
||||
.dialog-title {
|
||||
color: $brand-red;
|
||||
font-family: "STKaiti", "KaiTi", serif;
|
||||
font-size: clamp(22px, 42rpx, 28px);
|
||||
font-weight: 700;
|
||||
letter-spacing: 4rpx;
|
||||
}
|
||||
|
||||
.genealogy-switcher__close {
|
||||
display: flex;
|
||||
grid-area: 1 / 1;
|
||||
align-self: start;
|
||||
justify-self: end;
|
||||
width: 80rpx;
|
||||
height: 80rpx;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-top: -42rpx;
|
||||
margin-right: -24rpx;
|
||||
}
|
||||
|
||||
.genealogy-switcher__close-icon {
|
||||
width: 80rpx;
|
||||
height: 80rpx;
|
||||
}
|
||||
|
||||
.genealogy-switcher__list {
|
||||
grid-area: 2 / 1;
|
||||
width: 100%;
|
||||
min-height: 0;
|
||||
max-height: calc(100vh - 456rpx);
|
||||
margin-top: 24rpx;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.switcher-item {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
min-height: 112rpx;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 18rpx 16rpx;
|
||||
border: 1rpx solid transparent;
|
||||
border-bottom-color: rgba(181, 138, 75, 0.42);
|
||||
background: transparent;
|
||||
line-height: normal;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.switcher-item::after {
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.switcher-item__name,
|
||||
.switcher-item__meta {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.switcher-item__name {
|
||||
color: $ink;
|
||||
font-family: "STKaiti", "KaiTi", serif;
|
||||
font-size: clamp(17px, 32rpx, 22px);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.switcher-item__meta {
|
||||
margin-top: 6rpx;
|
||||
color: #62584c;
|
||||
font-size: clamp(15px, 24rpx, 18px);
|
||||
}
|
||||
|
||||
.switcher-item__state {
|
||||
color: $brand-red;
|
||||
font-size: clamp(15px, 25rpx, 18px);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.switcher-item--active {
|
||||
border-color: rgba(159, 23, 15, 0.22);
|
||||
background: rgba(159, 23, 15, 0.055);
|
||||
}
|
||||
|
||||
.switcher-item--active .switcher-item__name {
|
||||
color: $brand-red;
|
||||
}
|
||||
|
||||
.action-hover {
|
||||
opacity: 0.82;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,304 @@
|
||||
<template>
|
||||
<AppDialog
|
||||
:visible="formVisible"
|
||||
eyebrow="提现申请"
|
||||
title="填写收款信息"
|
||||
message="提交后将冻结对应收益,审核通过后按收款码转账。"
|
||||
confirm-text="核对并提交"
|
||||
cancel-text="暂不提现"
|
||||
show-cancel
|
||||
:close-on-mask="!submitting && !uploading"
|
||||
@confirm="reviewWithdrawal"
|
||||
@cancel="close"
|
||||
>
|
||||
<view class="withdrawal-form">
|
||||
<text>提现金额(元)</text>
|
||||
<input
|
||||
v-model.trim="withdrawalForm.amount"
|
||||
type="digit"
|
||||
maxlength="19"
|
||||
placeholder="请输入提现金额"
|
||||
/>
|
||||
<text>收款人姓名</text>
|
||||
<input
|
||||
v-model.trim="withdrawalForm.payoutAccountName"
|
||||
maxlength="64"
|
||||
placeholder="请输入收款码对应姓名"
|
||||
/>
|
||||
<view
|
||||
class="qr-picker"
|
||||
role="button"
|
||||
aria-label="选择收款码图片"
|
||||
@click="choosePayoutQr"
|
||||
>
|
||||
<image
|
||||
v-if="withdrawalForm.qrPreview"
|
||||
:src="withdrawalForm.qrPreview"
|
||||
mode="aspectFit"
|
||||
/>
|
||||
<text>{{ payoutQrPrompt }}</text>
|
||||
</view>
|
||||
<text v-if="withdrawalError" class="form-error">{{ withdrawalError }}</text>
|
||||
</view>
|
||||
</AppDialog>
|
||||
|
||||
<AppDialog
|
||||
:visible="confirmationVisible"
|
||||
title="确认申请提现吗?"
|
||||
:message="confirmationMessage"
|
||||
:confirm-text="submitting ? '正在提交' : '确认提交'"
|
||||
cancel-text="返回修改"
|
||||
show-cancel
|
||||
:close-on-mask="false"
|
||||
@confirm="submitWithdrawal"
|
||||
@cancel="confirmationVisible = false"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, onUnmounted, reactive, ref, watch } from "vue";
|
||||
import AppDialog from "@/components/AppDialog.vue";
|
||||
import {
|
||||
createRequestController,
|
||||
isRequestCancelled
|
||||
} from "@/services/api/request-controller.js";
|
||||
import { earningApi } from "@/services/api/earning-service.js";
|
||||
import { getRequestErrorMessage } from "@/services/api/request-error-message.js";
|
||||
import {
|
||||
isImagePickCancelled,
|
||||
pickAndUploadImage,
|
||||
} from "@/utils/media-upload.js";
|
||||
import { parseMoneyToCents } from "@/utils/profile/earning-money.js";
|
||||
import { isWriteOutcomeUnknown } from "@/utils/request-outcome.js";
|
||||
|
||||
const props = defineProps({
|
||||
availableAmount: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
minimumWithdrawal: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
afterSubmitted: {
|
||||
type: Function,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(["busy-change", "transient-change"]);
|
||||
|
||||
const formVisible = ref(false);
|
||||
const confirmationVisible = ref(false);
|
||||
const withdrawalError = ref("");
|
||||
const uploading = ref(false);
|
||||
const submitting = ref(false);
|
||||
const withdrawalForm = reactive({
|
||||
requestId: "",
|
||||
amount: "",
|
||||
payoutAccountName: "",
|
||||
payoutQrOssId: null,
|
||||
qrPreview: "",
|
||||
});
|
||||
const payoutQrUploadRequestController = createRequestController();
|
||||
const withdrawalSubmissionRequestController = createRequestController();
|
||||
let componentActive = true;
|
||||
|
||||
const isBusy = computed(() => uploading.value || submitting.value);
|
||||
const hasTransient = computed(() =>
|
||||
formVisible.value || confirmationVisible.value,
|
||||
);
|
||||
const payoutQrPrompt = computed(() => {
|
||||
if (uploading.value) return "正在上传收款码…";
|
||||
return withdrawalForm.payoutQrOssId
|
||||
? "重新选择收款码"
|
||||
: "选择微信或支付宝收款码";
|
||||
});
|
||||
const confirmationMessage = computed(() =>
|
||||
`本次申请 ¥${withdrawalForm.amount || "0.00"},收款人 ${
|
||||
withdrawalForm.payoutAccountName || "未填写"
|
||||
}。请确认金额和收款码无误。`,
|
||||
);
|
||||
|
||||
watch(isBusy, (busy) => emit("busy-change", busy), { immediate: true });
|
||||
watch(hasTransient, (visible) => emit("transient-change", visible), {
|
||||
immediate: true,
|
||||
});
|
||||
|
||||
const createWithdrawalRequestId = () =>
|
||||
typeof globalThis.crypto?.randomUUID === "function"
|
||||
? globalThis.crypto.randomUUID()
|
||||
: `withdrawal-${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
||||
const validateWithdrawal = () => {
|
||||
const amountInCents = parseMoneyToCents(withdrawalForm.amount);
|
||||
const availableInCents = parseMoneyToCents(props.availableAmount);
|
||||
const minimumInCents = parseMoneyToCents(props.minimumWithdrawal || "0.01");
|
||||
if (amountInCents === null || amountInCents <= 0n) {
|
||||
return "请输入正确的提现金额,最多保留两位小数。";
|
||||
}
|
||||
if (minimumInCents !== null && amountInCents < minimumInCents) {
|
||||
return `提现金额不能低于 ¥${props.minimumWithdrawal || "0.01"}。`;
|
||||
}
|
||||
if (availableInCents !== null && amountInCents > availableInCents) {
|
||||
return "提现金额不能超过当前可用收益。";
|
||||
}
|
||||
if (!withdrawalForm.payoutAccountName.trim()) return "请填写收款人姓名。";
|
||||
if (!withdrawalForm.payoutQrOssId) return "请选择收款码图片。";
|
||||
return "";
|
||||
};
|
||||
|
||||
const open = () => {
|
||||
if (isBusy.value) return false;
|
||||
Object.assign(withdrawalForm, {
|
||||
requestId: createWithdrawalRequestId(),
|
||||
amount: props.minimumWithdrawal || "",
|
||||
payoutAccountName: "",
|
||||
payoutQrOssId: null,
|
||||
qrPreview: "",
|
||||
});
|
||||
withdrawalError.value = "";
|
||||
formVisible.value = true;
|
||||
return true;
|
||||
};
|
||||
const close = () => {
|
||||
if (isBusy.value) return false;
|
||||
formVisible.value = false;
|
||||
confirmationVisible.value = false;
|
||||
return true;
|
||||
};
|
||||
const closeTransient = () => {
|
||||
if (confirmationVisible.value && !isBusy.value) {
|
||||
confirmationVisible.value = false;
|
||||
return true;
|
||||
}
|
||||
return formVisible.value ? close() : false;
|
||||
};
|
||||
const choosePayoutQr = async () => {
|
||||
if (isBusy.value) return;
|
||||
uploading.value = true;
|
||||
withdrawalError.value = "";
|
||||
try {
|
||||
const payoutQrUpload = await pickAndUploadImage({
|
||||
requestController: payoutQrUploadRequestController,
|
||||
});
|
||||
if (!componentActive) return;
|
||||
withdrawalForm.payoutQrOssId = payoutQrUpload.ossId;
|
||||
withdrawalForm.qrPreview =
|
||||
payoutQrUpload.thumbnailUrl || payoutQrUpload.url;
|
||||
} catch (error) {
|
||||
if (
|
||||
componentActive &&
|
||||
!isImagePickCancelled(error) &&
|
||||
!isRequestCancelled(error)
|
||||
) {
|
||||
withdrawalError.value = getRequestErrorMessage(
|
||||
error,
|
||||
"收款码上传失败,请重新选择。",
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
if (componentActive) uploading.value = false;
|
||||
}
|
||||
};
|
||||
const reviewWithdrawal = () => {
|
||||
if (isBusy.value) return;
|
||||
withdrawalError.value = validateWithdrawal();
|
||||
if (!withdrawalError.value) confirmationVisible.value = true;
|
||||
};
|
||||
const submitWithdrawal = async () => {
|
||||
if (submitting.value) return;
|
||||
withdrawalError.value = validateWithdrawal();
|
||||
if (withdrawalError.value) {
|
||||
confirmationVisible.value = false;
|
||||
return;
|
||||
}
|
||||
submitting.value = true;
|
||||
let withdrawalCommitted = false;
|
||||
try {
|
||||
await earningApi.requestEarningWithdrawal(
|
||||
{
|
||||
requestId: withdrawalForm.requestId,
|
||||
amount: withdrawalForm.amount,
|
||||
payoutQrOssId: withdrawalForm.payoutQrOssId,
|
||||
payoutAccountName: withdrawalForm.payoutAccountName,
|
||||
},
|
||||
{ requestController: withdrawalSubmissionRequestController },
|
||||
);
|
||||
withdrawalCommitted = true;
|
||||
if (!componentActive) return;
|
||||
confirmationVisible.value = false;
|
||||
formVisible.value = false;
|
||||
await props.afterSubmitted();
|
||||
} catch (error) {
|
||||
if (!componentActive) return;
|
||||
confirmationVisible.value = false;
|
||||
if (withdrawalCommitted) {
|
||||
formVisible.value = false;
|
||||
return;
|
||||
}
|
||||
if (isWriteOutcomeUnknown(error)) {
|
||||
withdrawalError.value =
|
||||
"暂时无法确认是否提交成功,请先关闭窗口查看提现记录,不要重复提交。";
|
||||
return;
|
||||
}
|
||||
if (!isRequestCancelled(error))
|
||||
withdrawalError.value = getRequestErrorMessage(
|
||||
error,
|
||||
"提现申请未提交,请核对后重试。",
|
||||
);
|
||||
} finally {
|
||||
if (componentActive) submitting.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
defineExpose({ closeTransient, open });
|
||||
|
||||
onUnmounted(() => {
|
||||
componentActive = false;
|
||||
payoutQrUploadRequestController.abort();
|
||||
withdrawalSubmissionRequestController.abort();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.withdrawal-form {
|
||||
width: 100%;
|
||||
margin-top: 14rpx;
|
||||
text-align: left;
|
||||
}
|
||||
.withdrawal-form > text {
|
||||
display: block;
|
||||
margin: 14rpx 0 7rpx;
|
||||
color: $ink;
|
||||
font-size: clamp(14px, 22rpx, 17px);
|
||||
}
|
||||
.withdrawal-form input {
|
||||
box-sizing: border-box;
|
||||
min-height: 76rpx;
|
||||
padding: 0 18rpx;
|
||||
border: 1rpx solid rgba(159, 35, 35, 0.25);
|
||||
background: #fffdf7;
|
||||
color: $ink;
|
||||
}
|
||||
.qr-picker {
|
||||
display: flex;
|
||||
min-height: 90rpx;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-top: 18rpx;
|
||||
padding: 12rpx;
|
||||
border: 1rpx dashed rgba(159, 35, 35, 0.45);
|
||||
color: $brand-red;
|
||||
text-align: center;
|
||||
gap: 16rpx;
|
||||
}
|
||||
.qr-picker image {
|
||||
width: 80rpx;
|
||||
height: 80rpx;
|
||||
}
|
||||
.form-error {
|
||||
color: $brand-red !important;
|
||||
line-height: 1.5;
|
||||
text-align: center;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,397 @@
|
||||
<template>
|
||||
<view class="invitation-summary-card">
|
||||
<view class="invitation-summary-card__heading">
|
||||
<view>
|
||||
<text>活动邀请</text>
|
||||
<text>仅活动管理员可管理;为保护隐私,不展示受邀人的个人信息。</text>
|
||||
</view>
|
||||
<view class="invitation-summary-card__actions">
|
||||
<AppButton
|
||||
compact
|
||||
type="secondary"
|
||||
:disabled="invitationState === 'loading'"
|
||||
:label="invitationState === 'loading' ? '加载中' : '查看概览'"
|
||||
@click="loadInvitationSummary"
|
||||
/>
|
||||
<AppButton
|
||||
compact
|
||||
type="secondary"
|
||||
:disabled="inviteeManagerState === 'loading'"
|
||||
:label="inviteeManagerState === 'loading' ? '加载中' : '管理受邀人'"
|
||||
@click="openInviteeManager"
|
||||
/>
|
||||
</view>
|
||||
</view>
|
||||
<AppLoading v-if="invitationState === 'loading'" text="正在加载活动邀请" />
|
||||
<view v-else-if="invitationState === 'ready'" class="invitation-summary-card__stats">
|
||||
<text>共 {{ invitationRows.length }} 条邀请</text>
|
||||
<text v-for="item in invitationSummary" :key="item.status">
|
||||
{{ item.label }} {{ item.count }} 条
|
||||
</text>
|
||||
</view>
|
||||
<view v-else-if="invitationState === 'empty'" class="invitation-summary-card__empty">
|
||||
<text>暂时没有活动邀请。</text>
|
||||
</view>
|
||||
<view v-else-if="invitationState === 'error'" class="invitation-summary-card__error">
|
||||
<text>暂时无法加载活动邀请。</text>
|
||||
<AppButton compact type="secondary" label="重新查看" @click="loadInvitationSummary" />
|
||||
</view>
|
||||
<view v-if="inviteeManagerVisible" class="invitee-manager">
|
||||
<text class="invitee-manager__title">选择受邀成员</text>
|
||||
<text class="invitee-manager__note"
|
||||
>仅展示可以邀请的成员;保存后会更新尚未处理的活动邀请,已接受或拒绝的邀请不受影响。</text
|
||||
>
|
||||
<AppLoading
|
||||
v-if="inviteeManagerState === 'loading'"
|
||||
text="正在加载可邀请成员"
|
||||
/>
|
||||
<view v-else-if="inviteeManagerState === 'ready'" class="invitee-manager__options">
|
||||
<view
|
||||
v-for="item in inviteeOptions"
|
||||
:key="item.appUserId"
|
||||
class="invitee-manager__option"
|
||||
:class="{ 'invitee-manager__option--selected': selectedInviteeUserIds.includes(item.appUserId) }"
|
||||
role="checkbox"
|
||||
:aria-checked="selectedInviteeUserIds.includes(item.appUserId)"
|
||||
@click="toggleInvitee(item.appUserId)"
|
||||
>
|
||||
<text>{{ item.displayName }}</text>
|
||||
<text v-if="item.memberRole">{{ item.memberRole }}</text>
|
||||
</view>
|
||||
<text v-if="unresolvedPendingInviteeCount" class="invitee-manager__error"
|
||||
>有 {{ unresolvedPendingInviteeCount }} 位受邀人暂未显示在此列表中,原有邀请不会受影响。</text
|
||||
>
|
||||
<view class="invitee-manager__actions">
|
||||
<AppButton
|
||||
type="secondary"
|
||||
label="取消"
|
||||
:disabled="inviteeSubmitting"
|
||||
@click="closeInviteeManager"
|
||||
/>
|
||||
<AppButton
|
||||
:label="inviteeSubmitting ? '正在保存' : '保存受邀人'"
|
||||
:disabled="inviteeSubmitting || unresolvedPendingInviteeCount > 0"
|
||||
@click="requestInviteeSave"
|
||||
/>
|
||||
</view>
|
||||
</view>
|
||||
<view v-else-if="inviteeManagerState === 'empty'" class="invitee-manager__empty">
|
||||
<text>当前没有可邀请成员。</text>
|
||||
<AppButton type="secondary" label="关闭" @click="closeInviteeManager" />
|
||||
</view>
|
||||
<view v-else-if="inviteeManagerState === 'error'" class="invitee-manager__error">
|
||||
<text>暂时无法加载可邀请成员。</text>
|
||||
<AppButton compact type="secondary" label="重新查看" @click="openInviteeManager" />
|
||||
</view>
|
||||
</view>
|
||||
<AppDialog
|
||||
:visible="inviteeConfirmVisible"
|
||||
eyebrow="邀请确认"
|
||||
title="保存本次受邀人调整?"
|
||||
message="保存后会更新尚未处理的活动邀请;已接受或拒绝的邀请不会受影响。"
|
||||
:confirm-text="inviteeSubmitting ? '正在保存' : '确认保存'"
|
||||
cancel-text="继续选择"
|
||||
show-cancel
|
||||
:close-on-mask="false"
|
||||
@confirm="confirmInviteeSave"
|
||||
@cancel="inviteeConfirmVisible = false"
|
||||
/>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
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 {
|
||||
CEREMONY_INVITATION_STATUS,
|
||||
CEREMONY_INVITATION_STATUS_LABELS
|
||||
} from "@/services/api/ceremony-contract.js";
|
||||
import {
|
||||
createRequestController,
|
||||
isRequestCancelled
|
||||
} from "@/services/api/request-controller.js";
|
||||
import { ceremonyApi } from "@/services/api/ceremony-service.js";
|
||||
|
||||
const props = defineProps({
|
||||
genealogyId: { type: String, required: true },
|
||||
ceremonyId: { type: String, required: true },
|
||||
});
|
||||
const emit = defineEmits(["busy-change", "transient-change"]);
|
||||
const invitationRows = ref([]);
|
||||
const invitationState = ref("idle");
|
||||
const inviteeManagerVisible = ref(false);
|
||||
const inviteeManagerState = ref("idle");
|
||||
const inviteeOptions = ref([]);
|
||||
const selectedInviteeUserIds = ref([]);
|
||||
const inviteeSubmitting = ref(false);
|
||||
const inviteeConfirmVisible = ref(false);
|
||||
const invitationListRequestController = createRequestController();
|
||||
const inviteeOptionsRequestController = createRequestController();
|
||||
const inviteeSaveRequestController = createRequestController();
|
||||
let isActive = true;
|
||||
|
||||
const hasValidContext = computed(
|
||||
() => /^[1-9]\d*$/.test(props.genealogyId) && /^[1-9]\d*$/.test(props.ceremonyId),
|
||||
);
|
||||
const invitationSummary = computed(() => {
|
||||
const statusCounts = invitationRows.value.reduce((counts, invitation) => {
|
||||
counts[invitation.inviteStatus] = (counts[invitation.inviteStatus] || 0) + 1;
|
||||
return counts;
|
||||
}, {});
|
||||
return Object.entries(CEREMONY_INVITATION_STATUS_LABELS)
|
||||
.map(([status, label]) => ({ status, label, count: statusCounts[status] || 0 }))
|
||||
.filter((summary) => summary.count > 0);
|
||||
});
|
||||
const pendingInviteeUserIds = computed(() =>
|
||||
invitationRows.value
|
||||
.filter(
|
||||
(invitation) =>
|
||||
invitation.inviteStatus === CEREMONY_INVITATION_STATUS.PENDING,
|
||||
)
|
||||
.map((invitation) => invitation.userKey)
|
||||
.filter(Boolean),
|
||||
);
|
||||
const unresolvedPendingInviteeCount = computed(
|
||||
() =>
|
||||
pendingInviteeUserIds.value.filter(
|
||||
(userId) => !inviteeOptions.value.some((option) => option.appUserId === userId),
|
||||
).length,
|
||||
);
|
||||
const hasTransient = computed(
|
||||
() => inviteeConfirmVisible.value || inviteeManagerVisible.value,
|
||||
);
|
||||
watch(hasTransient, (value) => emit("transient-change", value), { immediate: true });
|
||||
watch(inviteeSubmitting, (value) => emit("busy-change", value), { immediate: true });
|
||||
|
||||
const loadInvitationSummary = async () => {
|
||||
if (!hasValidContext.value || invitationState.value === "loading") return;
|
||||
invitationListRequestController.abort();
|
||||
invitationState.value = "loading";
|
||||
try {
|
||||
const invitations = await ceremonyApi.getCeremonyInvitations(
|
||||
props.genealogyId,
|
||||
props.ceremonyId,
|
||||
{ requestController: invitationListRequestController },
|
||||
);
|
||||
if (!isActive) return;
|
||||
invitationRows.value = invitations;
|
||||
invitationState.value = invitations.length ? "ready" : "empty";
|
||||
} catch (error) {
|
||||
if (!isActive || isRequestCancelled(error)) return;
|
||||
invitationState.value = "error";
|
||||
}
|
||||
};
|
||||
const closeInviteeManager = () => {
|
||||
if (inviteeSubmitting.value) return;
|
||||
inviteeConfirmVisible.value = false;
|
||||
inviteeManagerVisible.value = false;
|
||||
};
|
||||
const openInviteeManager = async () => {
|
||||
if (!hasValidContext.value || inviteeManagerState.value === "loading") return;
|
||||
inviteeManagerVisible.value = true;
|
||||
inviteeManagerState.value = "loading";
|
||||
invitationListRequestController.abort();
|
||||
inviteeOptionsRequestController.abort();
|
||||
try {
|
||||
const [invitations, options] = await Promise.all([
|
||||
ceremonyApi.getCeremonyInvitations(props.genealogyId, props.ceremonyId, {
|
||||
requestController: invitationListRequestController,
|
||||
}),
|
||||
ceremonyApi.getCeremonyInviteeOptions(props.genealogyId, props.ceremonyId, {
|
||||
requestController: inviteeOptionsRequestController,
|
||||
}),
|
||||
]);
|
||||
if (!isActive) return;
|
||||
invitationRows.value = invitations;
|
||||
invitationState.value = invitations.length ? "ready" : "empty";
|
||||
inviteeOptions.value = options.filter((option) => option.eligible);
|
||||
selectedInviteeUserIds.value = pendingInviteeUserIds.value.slice();
|
||||
inviteeManagerState.value = inviteeOptions.value.length ? "ready" : "empty";
|
||||
} catch (error) {
|
||||
if (!isActive || isRequestCancelled(error)) return;
|
||||
inviteeOptions.value = [];
|
||||
selectedInviteeUserIds.value = [];
|
||||
inviteeManagerState.value = "error";
|
||||
}
|
||||
};
|
||||
const toggleInvitee = (appUserId) => {
|
||||
if (inviteeSubmitting.value) return;
|
||||
selectedInviteeUserIds.value = selectedInviteeUserIds.value.includes(appUserId)
|
||||
? selectedInviteeUserIds.value.filter((selectedUserId) => selectedUserId !== appUserId)
|
||||
: [...selectedInviteeUserIds.value, appUserId];
|
||||
};
|
||||
const requestInviteeSave = () => {
|
||||
if (inviteeSubmitting.value || unresolvedPendingInviteeCount.value) return;
|
||||
inviteeConfirmVisible.value = true;
|
||||
};
|
||||
const confirmInviteeSave = async () => {
|
||||
if (inviteeSubmitting.value || unresolvedPendingInviteeCount.value) return;
|
||||
inviteeSubmitting.value = true;
|
||||
try {
|
||||
const invitations = await ceremonyApi.replaceCeremonyInvitees(
|
||||
props.genealogyId,
|
||||
props.ceremonyId,
|
||||
{ inviteeUserIds: selectedInviteeUserIds.value },
|
||||
{ requestController: inviteeSaveRequestController },
|
||||
);
|
||||
if (!isActive) return;
|
||||
invitationRows.value = invitations;
|
||||
invitationState.value = invitations.length ? "ready" : "empty";
|
||||
inviteeConfirmVisible.value = false;
|
||||
inviteeManagerVisible.value = false;
|
||||
} catch (error) {
|
||||
if (!isActive || isRequestCancelled(error)) return;
|
||||
inviteeManagerState.value = "error";
|
||||
inviteeConfirmVisible.value = false;
|
||||
} finally {
|
||||
if (isActive) inviteeSubmitting.value = false;
|
||||
}
|
||||
};
|
||||
const closeTransient = () => {
|
||||
if (inviteeConfirmVisible.value) {
|
||||
inviteeConfirmVisible.value = false;
|
||||
return true;
|
||||
}
|
||||
if (inviteeManagerVisible.value) {
|
||||
closeInviteeManager();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
defineExpose({ closeTransient });
|
||||
onUnmounted(() => {
|
||||
isActive = false;
|
||||
invitationListRequestController.abort();
|
||||
inviteeOptionsRequestController.abort();
|
||||
inviteeSaveRequestController.abort();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@import "../../styles/adaptive-frame-profiles.scss";
|
||||
|
||||
.invitation-summary-card {
|
||||
@include adaptive-records-content;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16rpx;
|
||||
margin-top: 18rpx;
|
||||
padding: 28rpx 32rpx;
|
||||
}
|
||||
.invitation-summary-card__heading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 18rpx;
|
||||
}
|
||||
.invitation-summary-card__heading > view { min-width: 0; flex: 1; }
|
||||
.invitation-summary-card__actions {
|
||||
display: flex;
|
||||
flex: 0 0 auto;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
gap: 10rpx;
|
||||
}
|
||||
.invitation-summary-card__heading text,
|
||||
.invitation-summary-card__stats text,
|
||||
.invitation-summary-card__empty text,
|
||||
.invitation-summary-card__error > text { display: block; }
|
||||
.invitation-summary-card__heading text:first-child {
|
||||
color: $ink;
|
||||
font-family: STKaiti, KaiTi, serif;
|
||||
font-size: clamp(17px, 30rpx, 22px);
|
||||
font-weight: 700;
|
||||
}
|
||||
.invitation-summary-card__heading text:last-child,
|
||||
.invitation-summary-card__empty,
|
||||
.invitation-summary-card__error {
|
||||
margin-top: 7rpx;
|
||||
color: $ink-muted;
|
||||
font-size: clamp(13px, 21rpx, 16px);
|
||||
line-height: 1.5;
|
||||
}
|
||||
.invitation-summary-card__stats { display: flex; flex-wrap: wrap; gap: 12rpx; }
|
||||
.invitation-summary-card__stats text {
|
||||
padding: 8rpx 14rpx;
|
||||
border-radius: 999rpx;
|
||||
background: rgba(181, 137, 63, 0.1);
|
||||
color: $ink;
|
||||
font-size: clamp(13px, 21rpx, 16px);
|
||||
}
|
||||
.invitation-summary-card__error {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16rpx;
|
||||
color: #a22b20;
|
||||
}
|
||||
.invitee-manager {
|
||||
padding-top: 18rpx;
|
||||
border-top: 1rpx solid rgba(181, 137, 63, 0.32);
|
||||
}
|
||||
.invitee-manager__title,
|
||||
.invitee-manager__note,
|
||||
.invitee-manager__option text,
|
||||
.invitee-manager__empty text,
|
||||
.invitee-manager__error > text { display: block; }
|
||||
.invitee-manager__title {
|
||||
color: $ink;
|
||||
font-family: STKaiti, KaiTi, serif;
|
||||
font-size: clamp(16px, 28rpx, 20px);
|
||||
font-weight: 700;
|
||||
}
|
||||
.invitee-manager__note,
|
||||
.invitee-manager__empty,
|
||||
.invitee-manager__error {
|
||||
margin-top: 8rpx;
|
||||
color: $ink-muted;
|
||||
font-size: clamp(13px, 21rpx, 16px);
|
||||
line-height: 1.5;
|
||||
}
|
||||
.invitee-manager__options {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12rpx;
|
||||
margin-top: 16rpx;
|
||||
}
|
||||
.invitee-manager__option {
|
||||
display: flex;
|
||||
min-height: 72rpx;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 14rpx;
|
||||
padding: 14rpx 18rpx;
|
||||
border: 1rpx solid rgba(181, 137, 63, 0.42);
|
||||
border-radius: 10rpx;
|
||||
background: rgba(255, 252, 244, 0.56);
|
||||
}
|
||||
.invitee-manager__option--selected {
|
||||
border-color: rgba(159, 23, 15, 0.72);
|
||||
background: rgba(159, 23, 15, 0.08);
|
||||
}
|
||||
.invitee-manager__option text:first-child {
|
||||
color: $ink;
|
||||
font-size: clamp(14px, 23rpx, 17px);
|
||||
font-weight: 700;
|
||||
}
|
||||
.invitee-manager__option text:last-child {
|
||||
flex: 0 0 auto;
|
||||
color: $ink-muted;
|
||||
font-size: clamp(12px, 20rpx, 15px);
|
||||
}
|
||||
.invitee-manager__error { color: #a22b20; }
|
||||
.invitee-manager__actions {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 16rpx;
|
||||
margin-top: 8rpx;
|
||||
}
|
||||
.invitee-manager__empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14rpx;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,440 @@
|
||||
<template>
|
||||
<AppDialog
|
||||
:visible="Boolean(detailRecord)"
|
||||
eyebrow="成长详情"
|
||||
:title="detailRecord?.title || '成长记录'"
|
||||
confirm-text="关闭"
|
||||
:close-on-mask="detailState !== 'loading'"
|
||||
@confirm="close"
|
||||
@cancel="close"
|
||||
>
|
||||
<view class="detail-content">
|
||||
<AppLoading v-if="detailState === 'loading'" text="正在读取完整记录" />
|
||||
<text v-else-if="detailState === 'error'" class="detail-error">{{ detailError }}</text>
|
||||
<template v-else-if="detailRecord?.contentProtected && !detailRecord?.contentUnlocked">
|
||||
<text>这条成长记录已设置内容密码。</text>
|
||||
<input
|
||||
v-model="contentPassword"
|
||||
class="detail-password-input"
|
||||
password
|
||||
maxlength="128"
|
||||
placeholder="请输入8至128位内容密码"
|
||||
/>
|
||||
<AppButton
|
||||
block
|
||||
:disabled="protectionSubmitting"
|
||||
: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>
|
||||
<text v-if="detailError" class="detail-error">{{ detailError }}</text>
|
||||
<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-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
|
||||
compact
|
||||
type="secondary"
|
||||
:label="detailRecord.contentProtected ? '修改内容密码' : '设置内容密码'"
|
||||
@click="openProtection('set')"
|
||||
/>
|
||||
<AppButton
|
||||
v-if="detailRecord.contentProtected"
|
||||
compact
|
||||
type="secondary"
|
||||
label="关闭内容密码"
|
||||
@click="openProtection('disable')"
|
||||
/>
|
||||
</view>
|
||||
</template>
|
||||
</view>
|
||||
</AppDialog>
|
||||
|
||||
<AppDialog
|
||||
:visible="protectionVisible"
|
||||
eyebrow="内容密码"
|
||||
:title="protectionTitle"
|
||||
:message="protectionMessage"
|
||||
:confirm-text="protectionConfirmText"
|
||||
cancel-text="取消"
|
||||
show-cancel
|
||||
:close-on-mask="false"
|
||||
@confirm="saveProtection"
|
||||
@cancel="closeProtection"
|
||||
>
|
||||
<input
|
||||
v-if="protectionMode === 'set'"
|
||||
v-model="contentPassword"
|
||||
class="detail-password-input"
|
||||
password
|
||||
maxlength="128"
|
||||
placeholder="请输入8至128位内容密码"
|
||||
/>
|
||||
<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>
|
||||
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
|
||||
} from "@/services/api/request-controller.js";
|
||||
import { lifeRecordApi } from "@/services/api/life-record-service.js";
|
||||
import { getRequestErrorMessage } from "@/services/api/request-error-message.js";
|
||||
|
||||
const props = defineProps({
|
||||
genealogyId: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(["busy-change", "transient-change"]);
|
||||
|
||||
const detailRecord = ref(null);
|
||||
const detailState = ref("idle");
|
||||
const detailError = ref("");
|
||||
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();
|
||||
let componentActive = true;
|
||||
|
||||
const protectionTitle = computed(() => {
|
||||
if (protectionMode.value === "disable") return "关闭内容密码?";
|
||||
return detailRecord.value?.contentProtected ? "修改内容密码" : "设置内容密码";
|
||||
});
|
||||
const protectionMessage = computed(() =>
|
||||
protectionMode.value === "disable"
|
||||
? "关闭后,有权查看记录的成员无需密码即可阅读。"
|
||||
: "设置8至128位密码,之后查看完整内容需要先验证。",
|
||||
);
|
||||
const protectionConfirmText = computed(() => {
|
||||
if (protectionSubmitting.value) return "正在保存";
|
||||
return protectionMode.value === "disable" ? "确认关闭" : "确认保存";
|
||||
});
|
||||
const isBusy = computed(() =>
|
||||
detailState.value === "loading" || protectionSubmitting.value || passwordRecoveryBusy.value,
|
||||
);
|
||||
const hasTransient = computed(() =>
|
||||
Boolean(detailRecord.value) || protectionVisible.value,
|
||||
);
|
||||
|
||||
watch(isBusy, (busy) => emit("busy-change", busy), { immediate: true });
|
||||
watch(hasTransient, (visible) => emit("transient-change", visible), { immediate: true });
|
||||
|
||||
const shouldIgnoreFailure = (cause) =>
|
||||
!componentActive || isRequestCancelled(cause);
|
||||
const isCurrentRecord = (recordId) =>
|
||||
componentActive && String(detailRecord.value?.id || "") === String(recordId);
|
||||
const clearDetailState = () => {
|
||||
detailRecord.value = null;
|
||||
detailState.value = "idle";
|
||||
detailError.value = "";
|
||||
contentPassword.value = "";
|
||||
protectionVisible.value = false;
|
||||
passwordRecoveryVisible.value = false;
|
||||
};
|
||||
const applyRecordDetail = (recordDetail) => {
|
||||
detailRecord.value = {
|
||||
...recordDetail,
|
||||
typeLabel:
|
||||
recordDetail.typeLabel || detailRecord.value?.typeLabel || "",
|
||||
};
|
||||
};
|
||||
|
||||
const open = async (record) => {
|
||||
if (!record?.id || isBusy.value) return;
|
||||
const recordId = String(record.id);
|
||||
growthDetailReadRequestController.abort();
|
||||
detailRecord.value = record;
|
||||
detailState.value = "loading";
|
||||
detailError.value = "";
|
||||
contentPassword.value = "";
|
||||
try {
|
||||
const recordDetail = await lifeRecordApi.getGrowthRecordDetail(
|
||||
props.genealogyId,
|
||||
recordId,
|
||||
"",
|
||||
{ requestController: growthDetailReadRequestController },
|
||||
);
|
||||
if (!isCurrentRecord(recordId)) return;
|
||||
applyRecordDetail(recordDetail);
|
||||
detailState.value = "ready";
|
||||
} catch (cause) {
|
||||
if (shouldIgnoreFailure(cause) || !isCurrentRecord(recordId)) return;
|
||||
detailState.value = "error";
|
||||
detailError.value = getRequestErrorMessage(cause, "完整记录读取失败,请稍后重试。");
|
||||
}
|
||||
};
|
||||
|
||||
const unlockContent = async () => {
|
||||
if (!detailRecord.value?.id || protectionSubmitting.value) return;
|
||||
if (contentPassword.value.length < 8 || contentPassword.value.length > 128) {
|
||||
detailError.value = "请输入8至128位内容密码。";
|
||||
return;
|
||||
}
|
||||
protectionSubmitting.value = true;
|
||||
detailError.value = "";
|
||||
const recordId = String(detailRecord.value.id);
|
||||
let passwordAccepted = false;
|
||||
try {
|
||||
const grant = await lifeRecordApi.unlockGrowthRecord(
|
||||
props.genealogyId,
|
||||
recordId,
|
||||
contentPassword.value,
|
||||
{ requestController: growthDetailUnlockRequestController },
|
||||
);
|
||||
if (!isCurrentRecord(recordId)) return;
|
||||
passwordAccepted = true;
|
||||
contentPassword.value = "";
|
||||
const recordDetail = await lifeRecordApi.getGrowthRecordDetail(
|
||||
props.genealogyId,
|
||||
recordId,
|
||||
grant.accessToken,
|
||||
{ requestController: growthDetailReadRequestController },
|
||||
);
|
||||
if (!isCurrentRecord(recordId)) return;
|
||||
applyRecordDetail(recordDetail);
|
||||
detailState.value = "ready";
|
||||
} catch (cause) {
|
||||
if (!shouldIgnoreFailure(cause) && isCurrentRecord(recordId)) {
|
||||
detailError.value = getRequestErrorMessage(
|
||||
cause,
|
||||
passwordAccepted
|
||||
? "密码已验证,但完整内容暂时无法读取,请稍后重试。"
|
||||
: "密码不正确,请重新输入。",
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
if (isCurrentRecord(recordId)) protectionSubmitting.value = false;
|
||||
}
|
||||
};
|
||||
const completePasswordRecovery = (newPassword) => {
|
||||
contentPassword.value = newPassword;
|
||||
detailError.value = "内容密码已重置,请点击“解锁并查看”确认。";
|
||||
};
|
||||
|
||||
const openProtection = (mode) => {
|
||||
if (!detailRecord.value?.canManageProtection || protectionSubmitting.value) return;
|
||||
protectionMode.value = mode;
|
||||
contentPassword.value = "";
|
||||
detailError.value = "";
|
||||
protectionVisible.value = true;
|
||||
};
|
||||
const closeProtection = () => {
|
||||
if (protectionSubmitting.value) return;
|
||||
protectionVisible.value = false;
|
||||
contentPassword.value = "";
|
||||
detailError.value = "";
|
||||
};
|
||||
const saveProtection = async () => {
|
||||
if (!detailRecord.value?.canManageProtection || protectionSubmitting.value) return;
|
||||
if (
|
||||
protectionMode.value === "set" &&
|
||||
(contentPassword.value.length < 8 || contentPassword.value.length > 128)
|
||||
) {
|
||||
detailError.value = "请输入8至128位内容密码。";
|
||||
return;
|
||||
}
|
||||
protectionSubmitting.value = true;
|
||||
detailError.value = "";
|
||||
const recordId = String(detailRecord.value.id);
|
||||
const contentWillBeProtected = protectionMode.value !== "disable";
|
||||
let protectionCommitted = false;
|
||||
try {
|
||||
if (protectionMode.value === "disable") {
|
||||
await lifeRecordApi.disableGrowthRecordPassword(
|
||||
props.genealogyId,
|
||||
recordId,
|
||||
{ requestController: growthProtectionMutationRequestController },
|
||||
);
|
||||
} else {
|
||||
await lifeRecordApi.setGrowthRecordPassword(
|
||||
props.genealogyId,
|
||||
recordId,
|
||||
contentPassword.value,
|
||||
{ requestController: growthProtectionMutationRequestController },
|
||||
);
|
||||
}
|
||||
if (!isCurrentRecord(recordId)) return;
|
||||
protectionCommitted = true;
|
||||
protectionVisible.value = false;
|
||||
contentPassword.value = "";
|
||||
detailRecord.value = {
|
||||
...detailRecord.value,
|
||||
contentProtected: contentWillBeProtected,
|
||||
contentUnlocked: !contentWillBeProtected,
|
||||
};
|
||||
const recordDetail = await lifeRecordApi.getGrowthRecordDetail(
|
||||
props.genealogyId,
|
||||
recordId,
|
||||
"",
|
||||
{ requestController: growthDetailReadRequestController },
|
||||
);
|
||||
if (!isCurrentRecord(recordId)) return;
|
||||
applyRecordDetail(recordDetail);
|
||||
} catch (cause) {
|
||||
if (!shouldIgnoreFailure(cause) && isCurrentRecord(recordId)) {
|
||||
detailError.value = getRequestErrorMessage(
|
||||
cause,
|
||||
protectionCommitted
|
||||
? "内容密码已保存,但详情暂时没有刷新,请稍后重新打开。"
|
||||
: "内容密码设置没有保存,请稍后重试。",
|
||||
);
|
||||
if (protectionCommitted) detailState.value = "ready";
|
||||
}
|
||||
} finally {
|
||||
if (isCurrentRecord(recordId)) protectionSubmitting.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const close = () => {
|
||||
if (isBusy.value) return false;
|
||||
growthDetailReadRequestController.abort();
|
||||
growthDetailUnlockRequestController.abort();
|
||||
growthProtectionMutationRequestController.abort();
|
||||
clearDetailState();
|
||||
return true;
|
||||
};
|
||||
const closeTransient = () => {
|
||||
if (passwordRecoveryVisible.value) {
|
||||
if (passwordRecoveryBusy.value) return false;
|
||||
passwordRecoveryVisible.value = false;
|
||||
return true;
|
||||
}
|
||||
if (protectionVisible.value) {
|
||||
closeProtection();
|
||||
return !protectionSubmitting.value;
|
||||
}
|
||||
return detailRecord.value ? close() : false;
|
||||
};
|
||||
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 });
|
||||
|
||||
onUnmounted(() => {
|
||||
componentActive = false;
|
||||
growthDetailReadRequestController.abort();
|
||||
growthDetailUnlockRequestController.abort();
|
||||
growthProtectionMutationRequestController.abort();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.detail-content {
|
||||
width: 100%;
|
||||
margin-top: 18rpx;
|
||||
text-align: left;
|
||||
}
|
||||
.detail-content > text {
|
||||
display: block;
|
||||
margin-top: 9rpx;
|
||||
color: $ink-muted;
|
||||
font-size: clamp(14px, 23rpx, 17px);
|
||||
line-height: 1.55;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.detail-content__body {
|
||||
padding-top: 10rpx;
|
||||
border-top: 1rpx solid rgba(142, 95, 41, .2);
|
||||
color: $ink !important;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
.detail-media {
|
||||
display: grid;
|
||||
margin-top: 16rpx;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 10rpx;
|
||||
}
|
||||
.detail-media image,
|
||||
.detail-media video {
|
||||
width: 100%;
|
||||
height: 150rpx;
|
||||
border-radius: 8rpx;
|
||||
background: rgba(128, 89, 49, .12);
|
||||
}
|
||||
.detail-password-input {
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
min-height: 76rpx;
|
||||
margin: 16rpx 0;
|
||||
padding: 14rpx 18rpx;
|
||||
border: 1rpx solid rgba(128, 89, 49, .32);
|
||||
border-radius: 8rpx;
|
||||
background: #fffdf8;
|
||||
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;
|
||||
margin-top: 18rpx;
|
||||
gap: 10rpx;
|
||||
}
|
||||
.detail-error {
|
||||
display: block;
|
||||
margin-top: 10rpx;
|
||||
color: $brand-red !important;
|
||||
font-size: clamp(13px, 21rpx, 16px);
|
||||
line-height: 1.5;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,374 @@
|
||||
<template>
|
||||
<view v-if="visible" class="member-action-panel-layer" @click="$emit('close')">
|
||||
<view
|
||||
class="member-action-panel"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
:aria-label="member ? `管理${member.name}` : '管理人物'"
|
||||
@click.stop
|
||||
>
|
||||
<image
|
||||
class="member-action-panel__paper"
|
||||
src="/static/assets/modules/tree/transparent/action-panel-paper.png"
|
||||
mode="scaleToFill"
|
||||
/>
|
||||
<view class="member-action-panel__head">
|
||||
<view
|
||||
v-if="member"
|
||||
class="member-action-profile"
|
||||
role="button"
|
||||
:aria-label="`查看${member.name}的资料`"
|
||||
@click="$emit('view-profile')"
|
||||
>
|
||||
<view class="member-action-profile__avatar">
|
||||
<AppAvatar :sex="member.sex" />
|
||||
</view>
|
||||
<view class="member-action-profile__copy">
|
||||
<text>{{ member.name }}</text>
|
||||
<text>第 {{ member.generation }} 世 · {{ member.relation }}</text>
|
||||
<text>点击查看人物资料</text>
|
||||
</view>
|
||||
</view>
|
||||
<view
|
||||
class="member-action-panel__close-button"
|
||||
role="button"
|
||||
aria-label="关闭人物操作"
|
||||
@click="$emit('close')"
|
||||
>
|
||||
<image
|
||||
class="member-action-panel__close"
|
||||
src="/static/assets/modules/tree/transparent/action-close.png"
|
||||
mode="aspectFit"
|
||||
/>
|
||||
</view>
|
||||
</view>
|
||||
<scroll-view class="member-action-panel__content" scroll-y>
|
||||
<view class="member-action-panel__scroll-body">
|
||||
<image
|
||||
class="member-action-panel__relationship-divider"
|
||||
src="/static/assets/modules/genealogy/transparent/section-divider.png"
|
||||
mode="scaleToFill"
|
||||
/>
|
||||
<view class="member-action-map">
|
||||
<image
|
||||
class="member-action-map__graph"
|
||||
src="/static/assets/modules/tree/transparent/relation-map.png"
|
||||
mode="scaleToFill"
|
||||
/>
|
||||
<view
|
||||
v-for="action in relationActions"
|
||||
:key="action.key"
|
||||
class="member-action-map__item"
|
||||
:class="`member-action-map__item--${action.slot}`"
|
||||
role="button"
|
||||
:aria-label="action.label"
|
||||
@click="$emit('select-action', action)"
|
||||
>
|
||||
<image
|
||||
class="member-action-map__item-frame"
|
||||
src="/static/assets/modules/tree/transparent/relation-button-frame.png"
|
||||
mode="scaleToFill"
|
||||
/>
|
||||
<image
|
||||
src="/static/assets/modules/tree/transparent/relation-marker.png"
|
||||
mode="aspectFit"
|
||||
/>
|
||||
<text>{{ action.shortLabel }}</text>
|
||||
<image
|
||||
class="member-action-map__arrow"
|
||||
src="/static/assets/modules/tree/transparent/action-arrow.png"
|
||||
mode="aspectFit"
|
||||
/>
|
||||
</view>
|
||||
</view>
|
||||
<view class="member-action-grid">
|
||||
<view
|
||||
v-for="action in managementActions"
|
||||
:key="action.key"
|
||||
class="member-action-grid__item"
|
||||
role="button"
|
||||
:aria-label="action.label"
|
||||
@click="$emit('select-action', action)"
|
||||
>
|
||||
<image
|
||||
src="/static/assets/modules/tree/transparent/management-marker.png"
|
||||
mode="aspectFit"
|
||||
/>
|
||||
<text>{{ action.label }}</text>
|
||||
<image
|
||||
class="member-action-grid__arrow"
|
||||
src="/static/assets/modules/tree/transparent/action-arrow.png"
|
||||
mode="aspectFit"
|
||||
/>
|
||||
</view>
|
||||
</view>
|
||||
<text class="member-action-panel__hint">所有操作都将作用于当前人物</text>
|
||||
</view>
|
||||
</scroll-view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import AppAvatar from "@/components/AppAvatar.vue";
|
||||
|
||||
defineProps({
|
||||
visible: { type: Boolean, default: false },
|
||||
member: { type: Object, default: null },
|
||||
relationActions: { type: Array, default: () => [] },
|
||||
managementActions: { type: Array, default: () => [] },
|
||||
});
|
||||
|
||||
defineEmits(["close", "view-profile", "select-action"]);
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.member-action-panel-layer {
|
||||
position: fixed;
|
||||
z-index: 80;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: center;
|
||||
box-sizing: border-box;
|
||||
padding: calc(24rpx + env(safe-area-inset-top)) 0 0;
|
||||
background: rgba(35, 18, 10, 0.62);
|
||||
}
|
||||
.member-action-panel {
|
||||
position: relative;
|
||||
display: flex;
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
height: 900rpx;
|
||||
min-height: 0;
|
||||
max-height: calc(
|
||||
var(--app-viewport-height, 100vh) - 80rpx - env(safe-area-inset-top) -
|
||||
env(safe-area-inset-bottom)
|
||||
);
|
||||
flex-direction: column;
|
||||
border-radius: 34rpx 34rpx 0 0;
|
||||
background: #f8edda;
|
||||
overflow: hidden;
|
||||
}
|
||||
.member-action-panel__paper {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
pointer-events: none;
|
||||
}
|
||||
.member-action-panel__head {
|
||||
position: absolute;
|
||||
top: 52rpx;
|
||||
right: 36rpx;
|
||||
left: 36rpx;
|
||||
z-index: 2;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-sizing: border-box;
|
||||
padding: 0;
|
||||
}
|
||||
.member-action-panel__close-button {
|
||||
position: absolute;
|
||||
top: 8rpx;
|
||||
right: 0;
|
||||
display: flex;
|
||||
width: 72rpx;
|
||||
height: 72rpx;
|
||||
flex: 0 0 72rpx;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.member-action-panel__close {
|
||||
width: 62rpx;
|
||||
height: 62rpx;
|
||||
}
|
||||
.member-action-panel__content {
|
||||
z-index: 1;
|
||||
min-height: 0;
|
||||
flex: 1;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.member-action-panel__scroll-body {
|
||||
padding: 160rpx 56rpx calc(24rpx + env(safe-area-inset-bottom));
|
||||
}
|
||||
.member-action-panel__relationship-divider {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 18rpx;
|
||||
margin-bottom: 20rpx;
|
||||
}
|
||||
.member-action-profile {
|
||||
display: flex;
|
||||
width: auto;
|
||||
flex: 0 1 auto;
|
||||
align-items: center;
|
||||
gap: 16rpx;
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.member-action-profile__avatar {
|
||||
display: block;
|
||||
width: 92rpx;
|
||||
height: 92rpx;
|
||||
aspect-ratio: 1;
|
||||
flex: 0 0 92rpx;
|
||||
box-sizing: border-box;
|
||||
border: 3rpx solid #d0a65d;
|
||||
border-radius: 50%;
|
||||
background: #fff8e8;
|
||||
box-shadow: 0 3rpx 8rpx rgba(105, 65, 29, 0.18);
|
||||
overflow: hidden;
|
||||
}
|
||||
.member-action-profile__copy {
|
||||
min-width: 0;
|
||||
text-align: center;
|
||||
}
|
||||
.member-action-profile__copy text {
|
||||
display: block;
|
||||
}
|
||||
.member-action-profile__copy text:first-child {
|
||||
color: #70261f;
|
||||
font-family: "STKaiti", "KaiTi", serif;
|
||||
font-size: clamp(20px, 38rpx, 26px);
|
||||
font-weight: 700;
|
||||
}
|
||||
.member-action-profile__copy text:nth-child(2) {
|
||||
margin-top: 4rpx;
|
||||
color: #74533a;
|
||||
font-size: clamp(14px, 22rpx, 17px);
|
||||
}
|
||||
.member-action-profile__copy text:last-child {
|
||||
display: none;
|
||||
}
|
||||
.member-action-map {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 350rpx;
|
||||
}
|
||||
.member-action-map__graph {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
pointer-events: none;
|
||||
}
|
||||
.member-action-map__item {
|
||||
z-index: 1;
|
||||
position: absolute;
|
||||
display: flex;
|
||||
width: 190rpx;
|
||||
min-width: 0;
|
||||
min-height: 60rpx;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 4rpx;
|
||||
padding: 0 10rpx;
|
||||
box-sizing: border-box;
|
||||
border: 1rpx solid rgba(177, 126, 64, 0.48);
|
||||
border-radius: 16rpx;
|
||||
background: rgba(250, 236, 211, 0.9);
|
||||
box-shadow: 0 3rpx 7rpx rgba(94, 56, 24, 0.08);
|
||||
}
|
||||
.member-action-map__item-frame {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
filter: drop-shadow(0 3rpx 3rpx rgba(99, 57, 21, 0.12));
|
||||
pointer-events: none;
|
||||
}
|
||||
.member-action-map__item > image:not(.member-action-map__item-frame) {
|
||||
z-index: 1;
|
||||
width: 36rpx;
|
||||
height: 36rpx;
|
||||
}
|
||||
.member-action-map__item .member-action-map__arrow {
|
||||
z-index: 1;
|
||||
width: 18rpx;
|
||||
height: 18rpx;
|
||||
}
|
||||
.member-action-map__item text {
|
||||
z-index: 1;
|
||||
color: #672820;
|
||||
font-size: clamp(14px, 22rpx, 17px);
|
||||
font-weight: 700;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.member-action-map__item--top {
|
||||
top: 0;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
.member-action-map__item--left-top {
|
||||
top: 66rpx;
|
||||
left: 0;
|
||||
}
|
||||
.member-action-map__item--right-top {
|
||||
top: 66rpx;
|
||||
right: 0;
|
||||
}
|
||||
.member-action-map__item--left-bottom {
|
||||
bottom: 106rpx;
|
||||
left: 0;
|
||||
}
|
||||
.member-action-map__item--right-bottom {
|
||||
right: 0;
|
||||
bottom: 106rpx;
|
||||
}
|
||||
.member-action-map__item--bottom {
|
||||
right: 50%;
|
||||
bottom: 0;
|
||||
transform: translateX(50%);
|
||||
}
|
||||
.member-action-grid {
|
||||
display: grid;
|
||||
width: 100%;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 18rpx;
|
||||
margin-top: 34rpx;
|
||||
padding-top: 30rpx;
|
||||
border-top: 2rpx solid rgba(169, 117, 55, 0.68);
|
||||
}
|
||||
.member-action-grid__item {
|
||||
display: flex;
|
||||
min-height: 96rpx;
|
||||
align-items: center;
|
||||
gap: 14rpx;
|
||||
padding: 0 20rpx;
|
||||
box-sizing: border-box;
|
||||
border: 2rpx solid rgba(160, 102, 47, 0.62);
|
||||
border-radius: 10rpx;
|
||||
background: rgba(249, 234, 204, 0.92);
|
||||
}
|
||||
.member-action-grid__item image {
|
||||
width: 38rpx;
|
||||
height: 38rpx;
|
||||
}
|
||||
.member-action-grid__item .member-action-grid__arrow {
|
||||
width: 22rpx;
|
||||
height: 22rpx;
|
||||
margin-left: auto;
|
||||
}
|
||||
.member-action-grid__item text {
|
||||
color: #672820;
|
||||
font-size: clamp(15px, 25rpx, 18px);
|
||||
font-weight: 700;
|
||||
}
|
||||
.member-action-panel__hint {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media (max-width: 360px) {
|
||||
.member-action-profile {
|
||||
gap: 14rpx;
|
||||
padding-right: 14rpx;
|
||||
padding-left: 14rpx;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,196 @@
|
||||
import { ref } from "vue";
|
||||
import {
|
||||
createRequestController,
|
||||
isRequestCancelled
|
||||
} from "@/services/api/request-controller.js";
|
||||
import { authApi } from "@/services/api/auth-service.js";
|
||||
import { createAuthSmsCooldown } from "@/utils/auth/sms-cooldown.js";
|
||||
import {
|
||||
createTacRenderContext,
|
||||
isAuthPhone,
|
||||
isSmsDeliveryOutcomeUnknown,
|
||||
normalizeCaptchaRequirement,
|
||||
normalizeTacSuccess,
|
||||
} from "@/utils/auth/verification.js";
|
||||
import { runtimeConfig } from "@/utils/runtime-config.js";
|
||||
|
||||
export const useSmsVerification = ({
|
||||
operationCode,
|
||||
requestIdPrefix,
|
||||
phone,
|
||||
isActive,
|
||||
showFeedback,
|
||||
getErrorMessage = (error, fallback) => error?.message || fallback,
|
||||
}) => {
|
||||
if (!phone || typeof phone !== "object" || !("value" in phone)) {
|
||||
throw new TypeError("phone must be a Vue ref");
|
||||
}
|
||||
if (
|
||||
typeof isActive !== "function" ||
|
||||
typeof showFeedback !== "function" ||
|
||||
typeof getErrorMessage !== "function"
|
||||
) {
|
||||
throw new TypeError("isActive, showFeedback and getErrorMessage must be functions");
|
||||
}
|
||||
|
||||
const tacVisible = ref(false);
|
||||
const tacContext = ref(null);
|
||||
const sendingCode = ref(false);
|
||||
const cooldownSeconds = ref(0);
|
||||
const sentPhone = ref("");
|
||||
const captchaRequirementRequestController = createRequestController();
|
||||
const smsDeliveryRequestController = createRequestController();
|
||||
const smsCooldown = createAuthSmsCooldown({
|
||||
operationCode,
|
||||
onChange: (seconds) => {
|
||||
cooldownSeconds.value = seconds;
|
||||
},
|
||||
});
|
||||
let requestSequence = 0;
|
||||
let disposed = false;
|
||||
|
||||
const canCommit = () => !disposed && isActive();
|
||||
|
||||
const closeTac = () => {
|
||||
tacVisible.value = false;
|
||||
tacContext.value = null;
|
||||
};
|
||||
|
||||
const markSmsSent = (requestedPhone, message = "验证码已发送") => {
|
||||
if (!canCommit()) return;
|
||||
sentPhone.value = requestedPhone;
|
||||
smsCooldown.start();
|
||||
showFeedback(message);
|
||||
};
|
||||
|
||||
const requestCode = async () => {
|
||||
if (sendingCode.value || cooldownSeconds.value > 0) return false;
|
||||
if (!isAuthPhone(phone.value)) return false;
|
||||
|
||||
const requestedPhone = phone.value;
|
||||
let smsDeliveryStarted = false;
|
||||
sendingCode.value = true;
|
||||
try {
|
||||
const requirementResponse = await authApi.getCaptchaRequirement(
|
||||
{ operationCode, subject: requestedPhone },
|
||||
{ requestController: captchaRequirementRequestController },
|
||||
);
|
||||
if (!canCommit()) return false;
|
||||
if (phone.value !== requestedPhone) {
|
||||
throw new Error("手机号已变化,请重新获取验证码");
|
||||
}
|
||||
const requirement = normalizeCaptchaRequirement(requirementResponse);
|
||||
if (!requirement.required) {
|
||||
smsDeliveryStarted = true;
|
||||
await authApi.sendSmsCode(
|
||||
{ operationCode, phone: requestedPhone },
|
||||
{ requestController: smsDeliveryRequestController },
|
||||
);
|
||||
markSmsSent(requestedPhone);
|
||||
return true;
|
||||
}
|
||||
|
||||
requestSequence += 1;
|
||||
tacContext.value = createTacRenderContext({
|
||||
requestId: `${requestIdPrefix}-${requestSequence}`,
|
||||
baseUrl: runtimeConfig.baseUrl,
|
||||
clientId: runtimeConfig.clientId,
|
||||
tenantId: runtimeConfig.tenantId,
|
||||
operationCode,
|
||||
subject: requestedPhone,
|
||||
requirement,
|
||||
});
|
||||
tacVisible.value = true;
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (canCommit()) {
|
||||
if (smsDeliveryStarted && isSmsDeliveryOutcomeUnknown(error)) {
|
||||
markSmsSent(
|
||||
requestedPhone,
|
||||
"发送结果未知,如收到短信可直接填写;60 秒后可重试",
|
||||
);
|
||||
} else if (!isRequestCancelled(error)) {
|
||||
showFeedback(getErrorMessage(error, "安全验证暂不可用"));
|
||||
}
|
||||
}
|
||||
return false;
|
||||
} finally {
|
||||
if (canCommit()) sendingCode.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const completeTac = async (result) => {
|
||||
const expectedContext = tacContext.value;
|
||||
if (!expectedContext) return false;
|
||||
try {
|
||||
const ticket = normalizeTacSuccess(result, expectedContext.requestId);
|
||||
if (phone.value !== expectedContext.subject) {
|
||||
throw new Error("手机号已变化,请重新验证");
|
||||
}
|
||||
closeTac();
|
||||
sendingCode.value = true;
|
||||
await authApi.sendSmsCode(
|
||||
{
|
||||
operationCode,
|
||||
phone: expectedContext.subject,
|
||||
validToken: ticket.validToken,
|
||||
},
|
||||
{ requestController: smsDeliveryRequestController },
|
||||
);
|
||||
markSmsSent(expectedContext.subject);
|
||||
return true;
|
||||
} catch (error) {
|
||||
closeTac();
|
||||
if (canCommit()) {
|
||||
if (isSmsDeliveryOutcomeUnknown(error)) {
|
||||
markSmsSent(
|
||||
expectedContext.subject,
|
||||
"发送结果未知,如收到短信可直接填写;60 秒后可重试",
|
||||
);
|
||||
} else if (!isRequestCancelled(error)) {
|
||||
showFeedback(getErrorMessage(error, "验证码发送失败"));
|
||||
}
|
||||
}
|
||||
return false;
|
||||
} finally {
|
||||
if (canCommit()) sendingCode.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleTacFailure = ({ message } = {}) =>
|
||||
showFeedback(message || "行为验证未通过,请重试");
|
||||
|
||||
const handleTacError = ({ message } = {}) => {
|
||||
closeTac();
|
||||
showFeedback(message || "安全验证暂不可用");
|
||||
};
|
||||
|
||||
const cancel = () => {
|
||||
captchaRequirementRequestController.abort();
|
||||
smsDeliveryRequestController.abort();
|
||||
sendingCode.value = false;
|
||||
};
|
||||
|
||||
const dispose = () => {
|
||||
disposed = true;
|
||||
cancel();
|
||||
closeTac();
|
||||
smsCooldown.dispose();
|
||||
};
|
||||
|
||||
return {
|
||||
tacVisible,
|
||||
tacContext,
|
||||
sendingCode,
|
||||
cooldownSeconds,
|
||||
sentPhone,
|
||||
requestCode,
|
||||
completeTac,
|
||||
closeTac,
|
||||
handleTacFailure,
|
||||
handleTacError,
|
||||
cancel,
|
||||
dispose,
|
||||
syncCooldown: smsCooldown.sync,
|
||||
};
|
||||
};
|
||||
@@ -1,695 +0,0 @@
|
||||
import {
|
||||
GENEALOGY_ACCESS_PRESET,
|
||||
getGenealogyAccessPresetLabel,
|
||||
} from '../utils/genealogy-contracts.js'
|
||||
|
||||
export const currentUser = {
|
||||
id: 1,
|
||||
name: '汤文远',
|
||||
phone: '138****1024',
|
||||
role: '创建者'
|
||||
}
|
||||
|
||||
export 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: '公开展示家谱身份、地区、堂号与支系信息。'
|
||||
}
|
||||
]
|
||||
|
||||
// G01、G05、G06 与 G08 共用这一份家谱展示夹具。成员关系只在
|
||||
// genealogies 和本地创建预览中出现;公开搜索结果绝不能据此获得管理权限。
|
||||
const toPublicProjection = ({ membership: _membership, ...genealogy }) => genealogy
|
||||
export const publicGenealogies = [
|
||||
{
|
||||
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'
|
||||
}
|
||||
]
|
||||
|
||||
const localCreatedGenealogy = {
|
||||
id: 'local-created-genealogy',
|
||||
surname: '汤',
|
||||
name: '本地创建预览',
|
||||
hall: '堂号待补',
|
||||
location: '所在地待补',
|
||||
memberCount: 1,
|
||||
activeCount: 1,
|
||||
updatedAt: '尚未同步',
|
||||
accessPreset: GENEALOGY_ACCESS_PRESET.MEMBER_ONLY,
|
||||
localPreview: true,
|
||||
motto: '当前仅为本地流程预览,尚未提交服务器。',
|
||||
ancestorName: '待补',
|
||||
parentName: '无上级谱',
|
||||
branchName: '主支',
|
||||
source: '本地创建流程预览',
|
||||
publicDescription: '尚未同步到服务器。'
|
||||
}
|
||||
|
||||
const localGenealogyPreviews = new Map([
|
||||
[localCreatedGenealogy.id, localCreatedGenealogy]
|
||||
])
|
||||
let localPreviewSequence = 0
|
||||
|
||||
const buildLocalGenealogyPreview = (id, draft, previous = {}) => {
|
||||
const accessPreset = Object.values(GENEALOGY_ACCESS_PRESET).includes(draft.accessPreset)
|
||||
? draft.accessPreset
|
||||
: GENEALOGY_ACCESS_PRESET.MEMBER_ONLY
|
||||
return {
|
||||
...previous,
|
||||
id,
|
||||
surname: String(draft.surname || '').trim(),
|
||||
name: String(draft.name || '').trim(),
|
||||
hall: String(draft.hall || '').trim() || '堂号待补',
|
||||
location: String(draft.location || '').trim(),
|
||||
memberCount: 1,
|
||||
activeCount: 1,
|
||||
updatedAt: '尚未同步',
|
||||
accessPreset,
|
||||
localPreview: true,
|
||||
motto: '当前仅为本地流程预览,尚未提交服务器。',
|
||||
ancestorName: previous.ancestorName || '待补',
|
||||
parentName: '无上级谱',
|
||||
branchName: '主支',
|
||||
source: '本地创建流程预览',
|
||||
publicDescription: `尚未同步到服务器;访问规则为“${getGenealogyAccessPresetLabel(accessPreset)}”。`
|
||||
}
|
||||
}
|
||||
|
||||
// G03 与 G05 共用这一份临时预览 owner。它只在当前运行实例中保存用户刚提交的
|
||||
// 快照,不建立成员关系、不写持久存储,也不冒充后端创建结果。
|
||||
export const createLocalGenealogyPreview = (draft) => {
|
||||
localPreviewSequence += 1
|
||||
const id = `local-created-${Date.now().toString(36)}-${localPreviewSequence}`
|
||||
localGenealogyPreviews.set(id, buildLocalGenealogyPreview(id, draft))
|
||||
return id
|
||||
}
|
||||
|
||||
export const updateLocalGenealogyPreview = (genealogyId, draft) => {
|
||||
const normalizedId = String(genealogyId || '')
|
||||
const preview = localGenealogyPreviews.get(normalizedId)
|
||||
if (!preview) return null
|
||||
localGenealogyPreviews.set(
|
||||
normalizedId,
|
||||
buildLocalGenealogyPreview(normalizedId, draft, preview)
|
||||
)
|
||||
return normalizedId
|
||||
}
|
||||
|
||||
export const updateLocalGenealogyPreviewAncestor = (genealogyId, draft) => {
|
||||
const normalizedId = String(genealogyId || '')
|
||||
const preview = localGenealogyPreviews.get(normalizedId)
|
||||
if (!preview) return null
|
||||
const updated = {
|
||||
...preview,
|
||||
ancestorName: String(draft.personName || '').trim() || '待补',
|
||||
ancestor: Object.freeze({ ...draft })
|
||||
}
|
||||
localGenealogyPreviews.set(normalizedId, updated)
|
||||
return updated
|
||||
}
|
||||
|
||||
export const removeLocalGenealogyPreview = (genealogyId) => {
|
||||
const normalizedId = String(genealogyId || '')
|
||||
if (!normalizedId || normalizedId === localCreatedGenealogy.id) return false
|
||||
return localGenealogyPreviews.delete(normalizedId)
|
||||
}
|
||||
|
||||
export const findGenealogyFixture = (genealogyId) => {
|
||||
const normalizedId = String(genealogyId || '')
|
||||
return [...genealogies, ...publicGenealogies]
|
||||
.find((item) => String(item.id) === normalizedId) ||
|
||||
localGenealogyPreviews.get(normalizedId) || null
|
||||
}
|
||||
|
||||
// 这里只解析本地视觉夹具的显示角色,不代表后端授权。成员关系仍由
|
||||
// genealogies[*].membership 唯一持有;localPreview 只能进入无业务入口的预览态。
|
||||
export const getGenealogyFixtureAccess = (genealogyId) => {
|
||||
const normalizedId = String(genealogyId || '')
|
||||
const fixture = findGenealogyFixture(normalizedId)
|
||||
const memberFixture = genealogies.find((item) => item.id === normalizedId)
|
||||
const publicFixture = publicGenealogies.find((item) => item.id === normalizedId)
|
||||
const access = fixture?.localPreview
|
||||
? 'preview'
|
||||
: memberFixture?.membership === 'created'
|
||||
? 'owner'
|
||||
: memberFixture?.membership === 'joined'
|
||||
? 'member'
|
||||
: 'public'
|
||||
const relation = fixture?.localPreview
|
||||
? 'preview'
|
||||
: memberFixture?.membership === 'created'
|
||||
? 'owned'
|
||||
: memberFixture?.membership === 'joined'
|
||||
? 'joined'
|
||||
: publicFixture?.relation || 'unknown'
|
||||
return Object.freeze({
|
||||
viewMode: access === 'public' || access === 'preview' ? access : 'member',
|
||||
accessRole: access === 'owner' || access === 'member' ? access : 'guest',
|
||||
relation,
|
||||
canView:
|
||||
Boolean(fixture?.localPreview) ||
|
||||
access === 'owner' ||
|
||||
access === 'member' ||
|
||||
fixture?.accessPreset === GENEALOGY_ACCESS_PRESET.PUBLIC_APPLY,
|
||||
canApply:
|
||||
fixture?.accessPreset === GENEALOGY_ACCESS_PRESET.PUBLIC_APPLY &&
|
||||
['available', 'rejected', 'removed'].includes(relation)
|
||||
})
|
||||
}
|
||||
|
||||
// 搜索结果的公开性与操作资格消费同一访问预设。成员可看到自己已经加入或
|
||||
// 创建的私密家谱;陌生账号只能看到明确为 PUBLIC_APPLY 的投影。
|
||||
export const isGenealogySearchVisible = (genealogyId) => {
|
||||
const fixture = findGenealogyFixture(genealogyId)
|
||||
if (!fixture) return false
|
||||
const access = getGenealogyFixtureAccess(genealogyId)
|
||||
return (
|
||||
access.accessRole !== 'guest' ||
|
||||
fixture.accessPreset === GENEALOGY_ACCESS_PRESET.PUBLIC_APPLY
|
||||
)
|
||||
}
|
||||
|
||||
// T01、T03—T08 与旧 mock API 共用这一份成员夹具。utils/api.js 是当前阶段
|
||||
// 唯一允许写入裸数组的模块;页面必须通过下方查询函数取得深拷贝,避免页面
|
||||
// 表单或关系投影反向污染世系树和其他页面。
|
||||
export const treeMembers = [
|
||||
{
|
||||
id: '101', appUserId: '2001', genealogyId: '1001', parentId: '', name: '汤文远', relation: '始祖',
|
||||
generation: 12, generationName: '文字辈', branch: '主支', years: '1940—2012',
|
||||
birthDate: '1940-03-01', deathDate: '2012-08-16', birthplace: '河南南阳',
|
||||
status: 'deceased', summary: '一生敦亲睦族,参与整理家族旧谱。',
|
||||
note: '始祖 · 档案完整',
|
||||
relatives: [
|
||||
{ personId: '102', relation: '长子' },
|
||||
{ personId: '103', relation: '次子' }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: '102', appUserId: '2002', genealogyId: '1001', parentId: '101', name: '汤正国', relation: '长子',
|
||||
generation: 13, generationName: '正字辈', branch: '长房', years: '1965—',
|
||||
birthDate: '1965-05-12', deathDate: '', birthplace: '河南洛阳',
|
||||
status: 'privacy', summary: '负责长房资料核对。',
|
||||
note: '家谱管理员',
|
||||
relatives: [
|
||||
{ personId: '101', relation: '父亲' },
|
||||
{ personId: '104', relation: '长子' },
|
||||
{ personId: '105', relation: '女儿' }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: '103', appUserId: '2003', genealogyId: '1001', parentId: '101', name: '汤正华', relation: '次子',
|
||||
generation: 13, generationName: '正字辈', branch: '二房', years: '资料受限',
|
||||
birthDate: '1968-09-03', deathDate: '', birthplace: '河南洛阳',
|
||||
status: 'forbidden', summary: '资料仍在补充。',
|
||||
note: '资料待补充',
|
||||
relatives: [
|
||||
{ personId: '101', relation: '父亲' },
|
||||
{ personId: '106', relation: '长子' }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: '104', appUserId: '2004', genealogyId: '1001', parentId: '102', name: '汤凯', relation: '长孙',
|
||||
generation: 14, generationName: '凯字辈', branch: '长房', years: '1992—',
|
||||
birthDate: '1992-06-01', deathDate: '', birthplace: '河南洛阳',
|
||||
status: 'privacy', summary: '协助整理年轻一代成员资料。',
|
||||
note: '档案已核对',
|
||||
relatives: [{ personId: '102', relation: '父亲' }]
|
||||
},
|
||||
{
|
||||
id: '105', appUserId: '2005', genealogyId: '1001', parentId: '102', name: '汤悦', relation: '长孙女',
|
||||
generation: 14, generationName: '凯字辈', branch: '长房', years: '1995—',
|
||||
birthDate: '1995-04-18', deathDate: '', birthplace: '河南洛阳',
|
||||
status: 'privacy', summary: '参与家族影像与口述资料整理。',
|
||||
note: '档案已核对',
|
||||
relatives: [{ personId: '102', relation: '父亲' }]
|
||||
},
|
||||
{
|
||||
id: '106', appUserId: '2006', genealogyId: '1001', parentId: '103', name: '汤晨', relation: '次孙',
|
||||
generation: 14, generationName: '凯字辈', branch: '二房', years: '1998—',
|
||||
birthDate: '1998-11-09', deathDate: '', birthplace: '河南洛阳',
|
||||
status: 'privacy', summary: '二房成员资料已完成初步核对。',
|
||||
note: '档案已核对',
|
||||
relatives: [{ personId: '103', relation: '父亲' }]
|
||||
}
|
||||
]
|
||||
|
||||
const cloneTreeMemberFixture = (member) => ({
|
||||
...member,
|
||||
relatives: member.relatives.map((relative) => ({ ...relative }))
|
||||
})
|
||||
|
||||
export const listTreeMemberFixtures = (genealogyId) => {
|
||||
const normalizedGenealogyId = String(genealogyId || '')
|
||||
if (!normalizedGenealogyId) return []
|
||||
return treeMembers
|
||||
.filter((member) => member.genealogyId === normalizedGenealogyId)
|
||||
.map(cloneTreeMemberFixture)
|
||||
}
|
||||
|
||||
export const findTreeMemberFixture = (genealogyId, personId) => {
|
||||
const normalizedGenealogyId = String(genealogyId || '')
|
||||
const normalizedPersonId = String(personId || '')
|
||||
if (!normalizedGenealogyId || !normalizedPersonId) return null
|
||||
const member = treeMembers.find(
|
||||
(item) => item.genealogyId === normalizedGenealogyId && item.id === normalizedPersonId
|
||||
)
|
||||
return member ? cloneTreeMemberFixture(member) : null
|
||||
}
|
||||
|
||||
// 页面展示只能消费这个受控投影。privacy/forbidden 成员在 owner 边界即删除
|
||||
// 生平、居住地、支系、亲属等字段,避免组件先取得完整对象再依赖模板隐藏。
|
||||
const projectTreeMemberPresentation = (member) => {
|
||||
if (!['privacy', 'forbidden'].includes(member.status)) {
|
||||
return cloneTreeMemberFixture(member)
|
||||
}
|
||||
return {
|
||||
id: member.id,
|
||||
appUserId: member.appUserId,
|
||||
genealogyId: member.genealogyId,
|
||||
name: member.name,
|
||||
relation: member.relation,
|
||||
generation: member.generation,
|
||||
status: member.status
|
||||
}
|
||||
}
|
||||
|
||||
export const listTreeMemberPresentationFixtures = (genealogyId) => {
|
||||
const normalizedGenealogyId = String(genealogyId || '')
|
||||
if (!normalizedGenealogyId) return []
|
||||
return treeMembers
|
||||
.filter((member) => member.genealogyId === normalizedGenealogyId)
|
||||
.map(projectTreeMemberPresentation)
|
||||
}
|
||||
|
||||
export const findTreeMemberPresentationFixture = (genealogyId, personId) => {
|
||||
const normalizedGenealogyId = String(genealogyId || '')
|
||||
const normalizedPersonId = String(personId || '')
|
||||
if (!normalizedGenealogyId || !normalizedPersonId) return null
|
||||
const member = treeMembers.find(
|
||||
(item) => item.genealogyId === normalizedGenealogyId && item.id === normalizedPersonId
|
||||
)
|
||||
return member ? projectTreeMemberPresentation(member) : null
|
||||
}
|
||||
|
||||
// F01—F09 共用这一组只读内容夹具。家谱 ID 与实体 ID 共同构成身份;页面和
|
||||
// mock API 只能通过下方 list/find 查询取得深拷贝,不能把页面草稿写回正式列表。
|
||||
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/f08/f08-reunion-hero.png',
|
||||
photos: [
|
||||
{ id: '20101', src: '/static/assets/modules/family/f08/f08-reunion-hero.png', alt: '春节团圆时三代家人的合影', caption: '除夕团圆 · 2024' },
|
||||
{ id: '20102', src: '/static/assets/modules/family/f08/f08-family-portrait.png', alt: '家人在院落前的春节合影', caption: '院前合影 · 2024' },
|
||||
{ id: '20103', src: '/static/assets/modules/family/f08/f08-reunion-table.png', alt: '家人围坐吃年夜饭', caption: '围桌守岁 · 2024' },
|
||||
{ id: '20104', src: '/static/assets/modules/family/f08/f08-ancestral-home.png', alt: '祖居院落的复古旧照', caption: '祖居旧影 · 1968' },
|
||||
{ id: '20105', src: '/static/assets/modules/family/f08/f08-ancestral-portrait.png', alt: '老一辈家人在祖居门前的合影', caption: '门前合影 · 1972' }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: '202', genealogyId: '1001', name: '祖居旧影', updatedAt: '5 月 10 日更新',
|
||||
description: '祖居、旧物与长辈珍藏的老照片', cover: '/static/assets/modules/family/f08/f08-ancestral-home.png',
|
||||
photos: [
|
||||
{ id: '20201', src: '/static/assets/modules/family/f08/f08-ancestral-home.png', alt: '祖居院落的复古旧照', caption: '祖居旧影 · 1968' },
|
||||
{ id: '20202', src: '/static/assets/modules/family/f08/f08-ancestral-portrait.png', alt: '老一辈家人在祖居门前的合影', caption: '门前合影 · 1972' }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: '203', genealogyId: '1001', name: '儿童成长', updatedAt: '持续更新',
|
||||
description: '记录孩子们每一个值得珍藏的瞬间', cover: '/static/assets/modules/family/f08/f08-family-portrait.png',
|
||||
photos: [
|
||||
{ id: '20301', src: '/static/assets/modules/family/f08/f08-family-portrait.png', alt: '家人在院落前的春节合影', caption: '院前合影 · 2024' }
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
const cloneFamilyFeedFixture = (feed) => ({ ...feed })
|
||||
const cloneFamilyArticleFixture = (article) => ({
|
||||
...article,
|
||||
paragraphs: [...article.paragraphs]
|
||||
})
|
||||
const cloneFamilyAlbumFixture = (album) => ({
|
||||
...album,
|
||||
photoCount: album.photos.length,
|
||||
photos: album.photos.map((photo) => ({ ...photo }))
|
||||
})
|
||||
|
||||
const listScopedFamilyFixtures = (items, clone, genealogyId) => {
|
||||
const normalizedGenealogyId = String(genealogyId || '')
|
||||
if (!normalizedGenealogyId) return []
|
||||
return items
|
||||
.filter((item) => item.genealogyId === normalizedGenealogyId)
|
||||
.map(clone)
|
||||
}
|
||||
|
||||
const findScopedFamilyFixture = (items, clone, genealogyId, entityId) => {
|
||||
const normalizedGenealogyId = String(genealogyId || '')
|
||||
const normalizedEntityId = String(entityId || '')
|
||||
if (!normalizedGenealogyId || !normalizedEntityId) return null
|
||||
const item = items.find(
|
||||
(candidate) => candidate.genealogyId === normalizedGenealogyId && candidate.id === normalizedEntityId
|
||||
)
|
||||
return item ? clone(item) : null
|
||||
}
|
||||
|
||||
export const listFamilyFeedFixtures = (genealogyId) =>
|
||||
listScopedFamilyFixtures(familyFeeds, cloneFamilyFeedFixture, genealogyId)
|
||||
export const findFamilyFeedFixture = (genealogyId, feedId) => {
|
||||
const normalizedGenealogyId = String(genealogyId || '')
|
||||
const normalizedFeedId = String(feedId || '')
|
||||
if (!normalizedGenealogyId || !normalizedFeedId) return null
|
||||
const feed = familyFeeds.find(
|
||||
(candidate) => candidate.genealogyId === normalizedGenealogyId && candidate.feedId === normalizedFeedId
|
||||
)
|
||||
return feed ? cloneFamilyFeedFixture(feed) : null
|
||||
}
|
||||
export const listFamilyArticleFixtures = (genealogyId) =>
|
||||
listScopedFamilyFixtures(familyArticles, cloneFamilyArticleFixture, genealogyId)
|
||||
export const findFamilyArticleFixture = (genealogyId, articleId) =>
|
||||
findScopedFamilyFixture(familyArticles, cloneFamilyArticleFixture, genealogyId, articleId)
|
||||
export const listFamilyAlbumFixtures = (genealogyId) =>
|
||||
listScopedFamilyFixtures(familyAlbums, cloneFamilyAlbumFixture, genealogyId)
|
||||
export const findFamilyAlbumFixture = (genealogyId, albumId) =>
|
||||
findScopedFamilyFixture(familyAlbums, cloneFamilyAlbumFixture, genealogyId, albumId)
|
||||
|
||||
// R03—R11 共用这一组只读记录夹具。它们只描述本地视觉预览,不冒充
|
||||
// OpenAPI 响应;每个实体都显式携带 genealogyId,详情必须由家谱与实体 ID
|
||||
// 共同定位。页面只能通过下方 list/find 选择器取得深拷贝,不能把草稿、完成
|
||||
// 状态或新增记录写回这里。
|
||||
const relativeRecordFixtures = [
|
||||
{
|
||||
relativeId: '301', genealogyId: '1001', relativeName: '汤文正一家', relationName: '族亲',
|
||||
eventName: '新春贺礼', eventTime: '2024-02-10', giftAmount: 600,
|
||||
recordContent: '新春团拜时赠予长辈的心意'
|
||||
},
|
||||
{
|
||||
relativeId: '302', genealogyId: '1001', relativeName: '汤淑华', relationName: '家族长辈',
|
||||
eventName: '寿宴礼单', eventTime: '2024-04-18', giftAmount: 1000,
|
||||
recordContent: '汤老先生八十寿辰'
|
||||
}
|
||||
]
|
||||
|
||||
const ceremonyFixtures = [
|
||||
{
|
||||
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 growthRecordFixtures = [
|
||||
{
|
||||
recordId: '701', genealogyId: '1001', lineagePersonId: '101', recordTitle: '整理第一册旧谱',
|
||||
recordDate: '1988-03', recordContent: '第一次独立整理家中保存的旧谱与口述线索。'
|
||||
},
|
||||
{
|
||||
recordId: '702', genealogyId: '1001', lineagePersonId: '104', recordTitle: '第一次参与修谱',
|
||||
recordDate: '2024-06', recordContent: '协助长辈核对照片人物与出生年份。'
|
||||
}
|
||||
]
|
||||
|
||||
const memoFixtures = [
|
||||
{
|
||||
memoId: '801', genealogyId: '1001', memoTitle: '修谱资料整理', remindTime: '本月底前',
|
||||
memoContent: '补充老照片中的人物姓名和拍摄时间。', completedLabel: '待办理'
|
||||
},
|
||||
{
|
||||
memoId: '802', genealogyId: '1001', memoTitle: '重阳敬老活动', remindTime: '10 月 11 日上午',
|
||||
memoContent: '在祠堂集合,并确认接送长辈的车辆。', completedLabel: '已完成'
|
||||
}
|
||||
]
|
||||
|
||||
const meritRecordFixtures = [
|
||||
{
|
||||
meritId: '901', genealogyId: '1001', meritTypeLabel: '共同修缮', meritTitle: '修缮祠堂',
|
||||
donorName: '汤氏家人共同参与', meritTime: '2024 年春', amount: null,
|
||||
meritContent: '协助整理院落、修补门窗并登记旧物。'
|
||||
},
|
||||
{
|
||||
meritId: '902', genealogyId: '1001', meritTypeLabel: '奖学助学', meritTitle: '支持后辈勤学',
|
||||
donorName: '家族教育小组', meritTime: '2024 年夏', amount: null,
|
||||
meritContent: '为家族中努力求学的孩子提供书籍与经验分享。'
|
||||
}
|
||||
]
|
||||
|
||||
const cloneRecordFixture = (record) => ({
|
||||
...record,
|
||||
...(Array.isArray(record.invitees)
|
||||
? { invitees: record.invitees.map((invitee) => ({ ...invitee })) }
|
||||
: {})
|
||||
})
|
||||
|
||||
const listScopedRecordFixtures = (records, genealogyId) => {
|
||||
const normalizedGenealogyId = String(genealogyId || '')
|
||||
if (!normalizedGenealogyId) return []
|
||||
return records
|
||||
.filter((record) => record.genealogyId === normalizedGenealogyId)
|
||||
.map(cloneRecordFixture)
|
||||
}
|
||||
|
||||
const findScopedRecordFixture = (records, idField, genealogyId, entityId) => {
|
||||
const normalizedGenealogyId = String(genealogyId || '')
|
||||
const normalizedEntityId = String(entityId || '')
|
||||
if (!normalizedGenealogyId || !normalizedEntityId) return null
|
||||
const record = records.find(
|
||||
(item) => item.genealogyId === normalizedGenealogyId && item[idField] === normalizedEntityId
|
||||
)
|
||||
return record ? cloneRecordFixture(record) : null
|
||||
}
|
||||
|
||||
export const listRelativeRecordFixtures = (genealogyId) =>
|
||||
listScopedRecordFixtures(relativeRecordFixtures, genealogyId)
|
||||
export const findRelativeRecordFixture = (genealogyId, relativeId) =>
|
||||
findScopedRecordFixture(relativeRecordFixtures, 'relativeId', genealogyId, relativeId)
|
||||
export const listCeremonyFixtures = (genealogyId) =>
|
||||
listScopedRecordFixtures(ceremonyFixtures, genealogyId)
|
||||
export const findCeremonyFixture = (genealogyId, ceremonyId) =>
|
||||
findScopedRecordFixture(ceremonyFixtures, 'ceremonyId', genealogyId, ceremonyId)
|
||||
export const listGrowthRecordFixtures = (genealogyId, lineagePersonId = '') => {
|
||||
const records = listScopedRecordFixtures(growthRecordFixtures, genealogyId)
|
||||
const normalizedPersonId = String(lineagePersonId || '')
|
||||
return normalizedPersonId
|
||||
? records.filter((record) => record.lineagePersonId === normalizedPersonId)
|
||||
: records
|
||||
}
|
||||
export const findGrowthRecordFixture = (genealogyId, recordId) =>
|
||||
findScopedRecordFixture(growthRecordFixtures, 'recordId', genealogyId, recordId)
|
||||
export const listMemoFixtures = (genealogyId) =>
|
||||
listScopedRecordFixtures(memoFixtures, genealogyId)
|
||||
export const findMemoFixture = (genealogyId, memoId) =>
|
||||
findScopedRecordFixture(memoFixtures, 'memoId', genealogyId, memoId)
|
||||
export const listMeritRecordFixtures = (genealogyId) =>
|
||||
listScopedRecordFixtures(meritRecordFixtures, genealogyId)
|
||||
export const findMeritRecordFixture = (genealogyId, meritId) =>
|
||||
findScopedRecordFixture(meritRecordFixtures, 'meritId', genealogyId, meritId)
|
||||
|
||||
export const notifications = [
|
||||
{
|
||||
id: 'review-1',
|
||||
title: '申请待审核',
|
||||
content: '汤志成申请加入汤氏家谱,请核实亲属关系。',
|
||||
body: '汤志成申请加入汤氏家谱,请核实申请人的亲属关系与世代信息后完成审核。',
|
||||
time: '今天 10:28',
|
||||
source: '汝南汤氏家谱',
|
||||
unread: true,
|
||||
targetType: 'GENEALOGY_REVIEW',
|
||||
targetParams: { genealogyId: '1001' },
|
||||
targetLabel: '前往入谱审核'
|
||||
},
|
||||
{
|
||||
id: 'approved',
|
||||
title: '入谱申请已通过',
|
||||
content: '你申请加入汝南汤氏家谱的请求已通过。',
|
||||
body: '你申请加入汝南汤氏家谱的请求已通过,现在可以查看家谱与家族动态。',
|
||||
time: '昨天 18:10',
|
||||
source: '汝南汤氏家谱',
|
||||
unread: false,
|
||||
targetType: 'GENEALOGY_HOME',
|
||||
targetParams: { genealogyId: '1001' },
|
||||
targetLabel: '查看我的家谱'
|
||||
}
|
||||
]
|
||||
|
||||
const cloneNotificationFixture = (notification) => ({
|
||||
...notification,
|
||||
targetParams: { ...notification.targetParams }
|
||||
})
|
||||
|
||||
export const listNotificationFixtures = () =>
|
||||
notifications.map(cloneNotificationFixture)
|
||||
|
||||
export const findNotificationFixture = (notificationId) => {
|
||||
const normalizedId = String(notificationId || '')
|
||||
if (!normalizedId) return null
|
||||
const notification = notifications.find((item) => item.id === normalizedId)
|
||||
return notification ? cloneNotificationFixture(notification) : null
|
||||
}
|
||||
|
||||
export const joinApplications = [
|
||||
{ id: '1', name: '汤志成', phone: '139****6421', relation: '自述为汤正华堂侄', appliedAt: '今天 10:24', status: 'PENDING' },
|
||||
{ id: '2', name: '汤雨薇', phone: '136****2798', relation: '自述为汤正国之女', appliedAt: '昨天 18:02', status: 'PENDING' }
|
||||
]
|
||||
@@ -17,24 +17,19 @@
|
||||
{ "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-f08-f08-ancestral-home", "output": "static/assets/modules/family/f08/f08-ancestral-home.png", "width": 1448, "height": 1086, "alpha": true, "bytes": 3370777, "sha256": "b9339cac4fc6e2fe466ae64944140d8b332e019e10985fe900e06c06668391f1", "provenance": "committed-binary", "rebuildable": false },
|
||||
{ "id": "app-modules-family-f08-f08-ancestral-portrait", "output": "static/assets/modules/family/f08/f08-ancestral-portrait.png", "width": 1448, "height": 1086, "alpha": true, "bytes": 3227971, "sha256": "4b541960557d0caab15084348f1ecde5a9f93f7f791e1a7e881deb7a74e8496e", "provenance": "committed-binary", "rebuildable": false },
|
||||
{ "id": "app-modules-family-f08-f08-family-portrait", "output": "static/assets/modules/family/f08/f08-family-portrait.png", "width": 1448, "height": 1086, "alpha": true, "bytes": 4129091, "sha256": "dd221049eb552c098dfe8177e9ac8d4185b9865d7390f745ca91742b700bc68b", "provenance": "committed-binary", "rebuildable": false },
|
||||
{ "id": "app-modules-family-f08-f08-reunion-hero", "output": "static/assets/modules/family/f08/f08-reunion-hero.png", "width": 1672, "height": 940, "alpha": true, "bytes": 4110597, "sha256": "5b296d9254580b3565539813daacf74edaa5471495a3b30b06b512a5b2153dc9", "provenance": "committed-binary", "rebuildable": false },
|
||||
{ "id": "app-modules-family-f08-f08-reunion-table", "output": "static/assets/modules/family/f08/f08-reunion-table.png", "width": 1448, "height": 1086, "alpha": true, "bytes": 3765758, "sha256": "b241176cd3080e7b038b6b84e7cd3cb30f423c175df8cf2a78dc0cff1709e6eb", "provenance": "committed-binary", "rebuildable": false },
|
||||
{ "id": "app-modules-family-transparent-f01-family-letter-card", "output": "static/assets/modules/family/transparent/f01-family-letter-card.png", "width": 2003, "height": 581, "alpha": true, "bytes": 1513491, "sha256": "a3686f99e32cdc255c0fc130294aab1974392d66bc8a7991daeb8f67849015ee", "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 },
|
||||
{ "id": "app-modules-genealogy-opaque-g05-overview-surface", "output": "static/assets/modules/genealogy/opaque/g05-overview-surface.png", "width": 1122, "height": 1506, "alpha": false, "bytes": 2537499, "sha256": "2d908f53c7edb637c7ced7500822bd33d354410ad8e671871d1da3a4cbd0f4db", "provenance": "committed-binary", "rebuildable": false },
|
||||
{ "id": "app-modules-genealogy-opaque-g06-search-button", "output": "static/assets/modules/genealogy/opaque/g06-search-button.png", "width": 300, "height": 132, "alpha": false, "bytes": 74664, "sha256": "bf0abc0a036c07322dd834fe5a9797413afd453b69d2ff8a895f3999fb8846cd", "provenance": "committed-binary", "rebuildable": false },
|
||||
{ "id": "app-modules-genealogy-opaque-g06-search-input-wide", "output": "static/assets/modules/genealogy/opaque/g06-search-input-wide.png", "width": 1120, "height": 248, "alpha": false, "bytes": 319364, "sha256": "2a575eeb5582c8458cbdd04cd328afbbecbdaabf4db46e8f2133bfe2614faa80", "provenance": "committed-binary", "rebuildable": false },
|
||||
{ "id": "app-modules-genealogy-opaque-overview-surface", "output": "static/assets/modules/genealogy/opaque/overview-surface.png", "width": 1122, "height": 1506, "alpha": false, "bytes": 2537499, "sha256": "2d908f53c7edb637c7ced7500822bd33d354410ad8e671871d1da3a4cbd0f4db", "provenance": "committed-binary", "rebuildable": false },
|
||||
{ "id": "app-modules-genealogy-opaque-search-button", "output": "static/assets/modules/genealogy/opaque/search-button.png", "width": 300, "height": 132, "alpha": false, "bytes": 74664, "sha256": "bf0abc0a036c07322dd834fe5a9797413afd453b69d2ff8a895f3999fb8846cd", "provenance": "committed-binary", "rebuildable": false },
|
||||
{ "id": "app-modules-genealogy-opaque-search-input-wide", "output": "static/assets/modules/genealogy/opaque/search-input-wide.png", "width": 1120, "height": 248, "alpha": false, "bytes": 319364, "sha256": "2a575eeb5582c8458cbdd04cd328afbbecbdaabf4db46e8f2133bfe2614faa80", "provenance": "committed-binary", "rebuildable": false },
|
||||
{ "id": "app-modules-genealogy-transparent-add", "output": "static/assets/modules/genealogy/transparent/add.png", "width": 96, "height": 96, "alpha": true, "bytes": 5226, "sha256": "671846ee23f8df701e2e1dfc34e3520dfe9f719f106efce56e30d78b84a47a0b", "provenance": "committed-binary", "rebuildable": false },
|
||||
{ "id": "app-modules-genealogy-transparent-create-cloud", "output": "static/assets/modules/genealogy/transparent/create-cloud.png", "width": 192, "height": 96, "alpha": true, "bytes": 10538, "sha256": "be01c1322181d91264641879162f6371d21ffda1feb971f5f31a791f32d02206", "provenance": "committed-binary", "rebuildable": false },
|
||||
{ "id": "app-modules-genealogy-transparent-current-seal-frame", "output": "static/assets/modules/genealogy/transparent/current-seal-frame.png", "width": 144, "height": 208, "alpha": true, "bytes": 1214, "sha256": "6d9aa61d766eec077cd91fbd5b71079990e67bcdc213d631a1185714dfb09328", "provenance": "committed-binary", "rebuildable": false },
|
||||
{ "id": "app-modules-genealogy-transparent-current-slip-frame", "output": "static/assets/modules/genealogy/transparent/current-slip-frame.png", "width": 720, "height": 272, "alpha": true, "bytes": 18568, "sha256": "47e519768830062a2363bd4bd10fb9ffc82c6a94638d3400a1e4c943a0ca6e66", "provenance": "committed-binary", "rebuildable": false },
|
||||
{ "id": "app-modules-genealogy-transparent-g-form-field-frame", "output": "static/assets/modules/genealogy/transparent/g-form-field-frame.png", "width": 2132, "height": 443, "alpha": true, "bytes": 1065770, "sha256": "bdc65f33e1dbe05ae75562519b0080e2e5cdbe2dba720a6ac49b56b99be75ceb", "provenance": "committed-binary", "rebuildable": false },
|
||||
{ "id": "app-modules-genealogy-transparent-g01-add-sheet-background-v3", "output": "static/assets/modules/genealogy/transparent/g01-add-sheet-background-v3.png", "width": 1536, "height": 1024, "alpha": true, "bytes": 1423794, "sha256": "181b9d9c95f0e8cce284dc6640adb4fa6c4fa9691306052c9e33558abf930e85", "provenance": "committed-binary", "rebuildable": false },
|
||||
{ "id": "app-modules-genealogy-transparent-g01-dialog-close", "output": "static/assets/modules/genealogy/transparent/g01-dialog-close.png", "width": 1254, "height": 1254, "alpha": true, "bytes": 105163, "sha256": "909fe4badccbfad1ddcfe4a21292ba73fecde32efb914f1c7fcb717f32f9db3f", "provenance": "committed-binary", "rebuildable": false },
|
||||
{ "id": "app-modules-genealogy-transparent-form-field-frame", "output": "static/assets/modules/genealogy/transparent/form-field-frame.png", "width": 2132, "height": 443, "alpha": true, "bytes": 1065770, "sha256": "bdc65f33e1dbe05ae75562519b0080e2e5cdbe2dba720a6ac49b56b99be75ceb", "provenance": "committed-binary", "rebuildable": false },
|
||||
{ "id": "app-modules-genealogy-transparent-add-sheet-background", "output": "static/assets/modules/genealogy/transparent/add-sheet-background.png", "width": 1536, "height": 1024, "alpha": true, "bytes": 1423794, "sha256": "181b9d9c95f0e8cce284dc6640adb4fa6c4fa9691306052c9e33558abf930e85", "provenance": "committed-binary", "rebuildable": false },
|
||||
{ "id": "app-modules-genealogy-transparent-dialog-close", "output": "static/assets/modules/genealogy/transparent/dialog-close.png", "width": 1254, "height": 1254, "alpha": true, "bytes": 105163, "sha256": "909fe4badccbfad1ddcfe4a21292ba73fecde32efb914f1c7fcb717f32f9db3f", "provenance": "committed-binary", "rebuildable": false },
|
||||
{ "id": "app-modules-genealogy-transparent-list-slip-frame", "output": "static/assets/modules/genealogy/transparent/list-slip-frame.png", "width": 720, "height": 144, "alpha": true, "bytes": 2992, "sha256": "1e39ed72c000e569340049833b1afca9e6aaeb077618a91e0c0f4f2758113005", "provenance": "committed-binary", "rebuildable": false },
|
||||
{ "id": "app-modules-genealogy-transparent-row-seal-frame", "output": "static/assets/modules/genealogy/transparent/row-seal-frame.png", "width": 112, "height": 160, "alpha": true, "bytes": 875, "sha256": "f2f2b0260fe54d738a70168f28d3960622d4b3e57c4ef95a032716008de6626b", "provenance": "committed-binary", "rebuildable": false },
|
||||
{ "id": "app-modules-genealogy-transparent-section-divider", "output": "static/assets/modules/genealogy/transparent/section-divider.png", "width": 720, "height": 30, "alpha": true, "bytes": 6645, "sha256": "925241a6cd2bd9897edad60e53eb255033603bef1028afbc189e87a29fddc6cd", "provenance": "committed-binary", "rebuildable": false },
|
||||
@@ -42,16 +37,44 @@
|
||||
{ "id": "app-modules-genealogy-transparent-shortcut-generation-poem", "output": "static/assets/modules/genealogy/transparent/shortcut-generation-poem.png", "width": 96, "height": 96, "alpha": true, "bytes": 8169, "sha256": "688e593fa88925ea00acf37570852a2dca9e1a505ba3b8107e0ddd3d146a6f4e", "provenance": "committed-binary", "rebuildable": false },
|
||||
{ "id": "app-modules-genealogy-transparent-shortcut-members", "output": "static/assets/modules/genealogy/transparent/shortcut-members.png", "width": 96, "height": 96, "alpha": true, "bytes": 8931, "sha256": "c89824e8e19210dd746f56019a48c9a833abe4a210b222758a56c3d25dec9d0c", "provenance": "committed-binary", "rebuildable": false },
|
||||
{ "id": "app-modules-genealogy-transparent-shortcut-tree", "output": "static/assets/modules/genealogy/transparent/shortcut-tree.png", "width": 96, "height": 96, "alpha": true, "bytes": 6636, "sha256": "cafe12a3fa800ca79d6c559baa533d7e26124ec8ac0072482ddb89ac2167422d", "provenance": "committed-binary", "rebuildable": false },
|
||||
{ "id": "app-modules-notification-transparent-n01-notice-card", "output": "static/assets/modules/notification/transparent/n01-notice-card.png", "width": 2139, "height": 675, "alpha": true, "bytes": 254885, "sha256": "01cf572ed3f20d99e10a21fee6834dced385e71d6b14693b04c0cb49560a6fd3", "provenance": "committed-binary", "rebuildable": false },
|
||||
{ "id": "app-modules-profile-transparent-m01-profile-summary-card", "output": "static/assets/modules/profile/transparent/m01-profile-summary-card.png", "width": 2139, "height": 675, "alpha": true, "bytes": 254885, "sha256": "01cf572ed3f20d99e10a21fee6834dced385e71d6b14693b04c0cb49560a6fd3", "provenance": "committed-binary", "rebuildable": false },
|
||||
{ "id": "app-modules-notification-transparent-notice-card", "output": "static/assets/modules/notification/transparent/notice-card.png", "width": 2139, "height": 675, "alpha": true, "bytes": 254885, "sha256": "01cf572ed3f20d99e10a21fee6834dced385e71d6b14693b04c0cb49560a6fd3", "provenance": "committed-binary", "rebuildable": false },
|
||||
{ "id": "app-modules-profile-transparent-summary-card", "output": "static/assets/modules/profile/transparent/summary-card.png", "width": 2139, "height": 675, "alpha": true, "bytes": 254885, "sha256": "01cf572ed3f20d99e10a21fee6834dced385e71d6b14693b04c0cb49560a6fd3", "provenance": "committed-binary", "rebuildable": false },
|
||||
{ "id": "app-modules-profile-transparent-module-content-frame", "output": "static/assets/modules/profile/transparent/module-content-frame.png", "width": 2139, "height": 675, "alpha": true, "bytes": 254885, "sha256": "01cf572ed3f20d99e10a21fee6834dced385e71d6b14693b04c0cb49560a6fd3", "provenance": "committed-binary", "rebuildable": false },
|
||||
{ "id": "app-modules-profile-transparent-module-field-frame", "output": "static/assets/modules/profile/transparent/module-field-frame.png", "width": 2132, "height": 443, "alpha": true, "bytes": 1065770, "sha256": "bdc65f33e1dbe05ae75562519b0080e2e5cdbe2dba720a6ac49b56b99be75ceb", "provenance": "committed-binary", "rebuildable": false },
|
||||
{ "id": "app-modules-records-transparent-module-content-frame", "output": "static/assets/modules/records/transparent/module-content-frame.png", "width": 2139, "height": 675, "alpha": true, "bytes": 254885, "sha256": "01cf572ed3f20d99e10a21fee6834dced385e71d6b14693b04c0cb49560a6fd3", "provenance": "committed-binary", "rebuildable": false },
|
||||
{ "id": "app-modules-records-transparent-module-field-frame", "output": "static/assets/modules/records/transparent/module-field-frame.png", "width": 2132, "height": 443, "alpha": true, "bytes": 1065770, "sha256": "bdc65f33e1dbe05ae75562519b0080e2e5cdbe2dba720a6ac49b56b99be75ceb", "provenance": "committed-binary", "rebuildable": false },
|
||||
{ "id": "app-modules-records-transparent-r01-person-name-card", "output": "static/assets/modules/records/transparent/r01-person-name-card.png", "width": 2139, "height": 675, "alpha": true, "bytes": 254885, "sha256": "01cf572ed3f20d99e10a21fee6834dced385e71d6b14693b04c0cb49560a6fd3", "provenance": "committed-binary", "rebuildable": false },
|
||||
{ "id": "app-modules-tree-transparent-t01-member-node-selected", "output": "static/assets/modules/tree/transparent/t01-member-node-selected.png", "width": 720, "height": 272, "alpha": true, "bytes": 18568, "sha256": "47e519768830062a2363bd4bd10fb9ffc82c6a94638d3400a1e4c943a0ca6e66", "provenance": "committed-binary", "rebuildable": false },
|
||||
{ "id": "app-modules-tree-transparent-t01-member-node-standard", "output": "static/assets/modules/tree/transparent/t01-member-node-standard.png", "width": 720, "height": 272, "alpha": true, "bytes": 18568, "sha256": "47e519768830062a2363bd4bd10fb9ffc82c6a94638d3400a1e4c943a0ca6e66", "provenance": "committed-binary", "rebuildable": false },
|
||||
{ "id": "app-modules-tree-transparent-t01-state-panel", "output": "static/assets/modules/tree/transparent/t01-state-panel.png", "width": 2139, "height": 675, "alpha": true, "bytes": 254885, "sha256": "01cf572ed3f20d99e10a21fee6834dced385e71d6b14693b04c0cb49560a6fd3", "provenance": "committed-binary", "rebuildable": false },
|
||||
{ "id": "app-modules-tree-transparent-t07-search-input-frame", "output": "static/assets/modules/tree/transparent/t07-search-input-frame.png", "width": 2132, "height": 443, "alpha": true, "bytes": 1065770, "sha256": "bdc65f33e1dbe05ae75562519b0080e2e5cdbe2dba720a6ac49b56b99be75ceb", "provenance": "committed-binary", "rebuildable": false }
|
||||
{ "id": "app-modules-records-transparent-person-name-card", "output": "static/assets/modules/records/transparent/person-name-card.png", "width": 2139, "height": 675, "alpha": true, "bytes": 254885, "sha256": "01cf572ed3f20d99e10a21fee6834dced385e71d6b14693b04c0cb49560a6fd3", "provenance": "committed-binary", "rebuildable": false },
|
||||
{ "id": "app-modules-tree-transparent-member-node-selected", "output": "static/assets/modules/tree/transparent/member-node-selected.png", "width": 720, "height": 272, "alpha": true, "bytes": 18568, "sha256": "47e519768830062a2363bd4bd10fb9ffc82c6a94638d3400a1e4c943a0ca6e66", "provenance": "committed-binary", "rebuildable": false },
|
||||
{ "id": "app-modules-tree-transparent-member-node-standard", "output": "static/assets/modules/tree/transparent/member-node-standard.png", "width": 720, "height": 272, "alpha": true, "bytes": 18568, "sha256": "47e519768830062a2363bd4bd10fb9ffc82c6a94638d3400a1e4c943a0ca6e66", "provenance": "committed-binary", "rebuildable": false },
|
||||
{ "id": "app-modules-tree-transparent-state-panel", "output": "static/assets/modules/tree/transparent/state-panel.png", "width": 2139, "height": 675, "alpha": true, "bytes": 254885, "sha256": "01cf572ed3f20d99e10a21fee6834dced385e71d6b14693b04c0cb49560a6fd3", "provenance": "committed-binary", "rebuildable": false },
|
||||
{ "id": "app-modules-tree-transparent-search-input-frame", "output": "static/assets/modules/tree/transparent/search-input-frame.png", "width": 2132, "height": 443, "alpha": true, "bytes": 1065770, "sha256": "bdc65f33e1dbe05ae75562519b0080e2e5cdbe2dba720a6ac49b56b99be75ceb", "provenance": "committed-binary", "rebuildable": false },
|
||||
{ "id": "app-foundation-transparent-book-png", "output": "static/assets/foundation/transparent/book.png", "width": 154, "height": 210, "alpha": false, "bytes": 41283, "sha256": "d565a2ad4f90c07046e9c60ebd6658426cacdd1f2af79e0a28ce9fe59d670630", "provenance": "committed-binary", "rebuildable": false },
|
||||
{ "id": "app-foundation-transparent-default-avatar-female", "output": "static/assets/foundation/transparent/default-avatar-female.png", "width": 128, "height": 128, "alpha": true, "bytes": 38629, "sha256": "a85cf941e531e84217f37b42fe8e8aa9ab6a32457480d45aa3d6fa33da360d2c", "provenance": "committed-binary", "rebuildable": false },
|
||||
{ "id": "app-foundation-transparent-default-avatar-male", "output": "static/assets/foundation/transparent/default-avatar-male.png", "width": 128, "height": 128, "alpha": true, "bytes": 43055, "sha256": "b00cb59f440aa87215432afc64f1463a61f0d9806ba1655ed6f8888a9d04c100", "provenance": "committed-binary", "rebuildable": false },
|
||||
{ "id": "app-modules-genealogy-transparent-settings-gear", "output": "static/assets/modules/genealogy/transparent/settings-gear.png", "width": 1254, "height": 1254, "alpha": true, "bytes": 1305800, "sha256": "0c3f064878a865af57ea3e7de450703a07bbb59b81ef0c44c4a041fa35a5dd1c", "provenance": "committed-binary", "rebuildable": false },
|
||||
{ "id": "app-modules-profile-opaque-archive-hero", "output": "static/assets/modules/profile/opaque/archive-hero.png", "width": 2172, "height": 724, "alpha": false, "bytes": 1965431, "sha256": "eda978404614f2ae14e200d8cf7bb05851cc7bccf15b515655b9387dc39cb829", "provenance": "committed-binary", "rebuildable": false },
|
||||
{ "id": "app-modules-profile-opaque-paper-mountains", "output": "static/assets/modules/profile/opaque/paper-mountains.png", "width": 941, "height": 1672, "alpha": false, "bytes": 1862070, "sha256": "60b54ef2550a2709e9ccbe7eab1cb4fa22c08ebace4be0930f746df1a1d607b2", "provenance": "committed-binary", "rebuildable": false },
|
||||
{ "id": "app-modules-profile-opaque-featured-courtyard", "output": "static/assets/modules/profile/opaque/featured-courtyard.png", "width": 1536, "height": 1024, "alpha": false, "bytes": 2577643, "sha256": "3431f3e84f1521bb73a21407d48e127d9f7d20d99805b11c38b8383f660808c7", "provenance": "committed-binary", "rebuildable": false },
|
||||
{ "id": "app-modules-profile-opaque-vip-hero-landscape", "output": "static/assets/modules/profile/opaque/vip-hero-landscape.png", "width": 2172, "height": 724, "alpha": false, "bytes": 2194380, "sha256": "a286f7b685a5b9f91cdeaf99f3454e5b10c0712d7a062fca5f39f0c4a9028491", "provenance": "committed-binary", "rebuildable": false },
|
||||
{ "id": "app-modules-profile-transparent-icon-calendar", "output": "static/assets/modules/profile/transparent/icon-calendar.png", "width": 256, "height": 256, "alpha": true, "bytes": 78155, "sha256": "bec3a1bce56c0bb31cac4a474f2bde9c8d904fbbc22c017e09d733364b014ed8", "provenance": "committed-binary", "rebuildable": false },
|
||||
{ "id": "app-modules-profile-transparent-icon-envelope", "output": "static/assets/modules/profile/transparent/icon-envelope.png", "width": 256, "height": 256, "alpha": true, "bytes": 73975, "sha256": "2462b34ef6b48d24be4cd2ce92c7931a057a254a666d7351f77a79263d61c486", "provenance": "committed-binary", "rebuildable": false },
|
||||
{ "id": "app-modules-profile-transparent-icon-feedback", "output": "static/assets/modules/profile/transparent/icon-feedback.png", "width": 256, "height": 256, "alpha": true, "bytes": 80060, "sha256": "3d194d983a2b6bea1a398d0055d68b643c2d4d91ecb3857ea967944b0900b352", "provenance": "committed-binary", "rebuildable": false },
|
||||
{ "id": "app-modules-profile-transparent-icon-help", "output": "static/assets/modules/profile/transparent/icon-help.png", "width": 256, "height": 256, "alpha": true, "bytes": 79859, "sha256": "a63f9d9756b152d3eed5e2bf5ecd1797204260511bb575b5d20082db2d735cd8", "provenance": "committed-binary", "rebuildable": false },
|
||||
{ "id": "app-modules-profile-transparent-icon-message", "output": "static/assets/modules/profile/transparent/icon-message.png", "width": 256, "height": 256, "alpha": true, "bytes": 75063, "sha256": "71a290aa4f76b4d786ab97e9808994636eb5f6ef252b3feaf6ac9c7e3d4c90f9", "provenance": "committed-binary", "rebuildable": false },
|
||||
{ "id": "app-modules-profile-transparent-icon-person", "output": "static/assets/modules/profile/transparent/icon-person.png", "width": 256, "height": 256, "alpha": true, "bytes": 76220, "sha256": "e2cb0d86614a1ae4c8da7b56426c1399d47c6b6c198d22fb11e9e21e0f4ab32d", "provenance": "committed-binary", "rebuildable": false },
|
||||
{ "id": "app-modules-profile-transparent-icon-promotion", "output": "static/assets/modules/profile/transparent/icon-promotion.png", "width": 256, "height": 256, "alpha": true, "bytes": 77714, "sha256": "81fa5248ce378a0f4b33dc5396a8b1b753c74f1c55feb19c874b43d632c2ae41", "provenance": "committed-binary", "rebuildable": false },
|
||||
{ "id": "app-modules-profile-transparent-icon-security", "output": "static/assets/modules/profile/transparent/icon-security.png", "width": 256, "height": 256, "alpha": true, "bytes": 83638, "sha256": "fe7bc7cbda827cba6d2e53add1e601f3296dcf0c7a42973878ea4c23a7a8ad74", "provenance": "committed-binary", "rebuildable": false },
|
||||
{ "id": "app-modules-profile-transparent-icon-settings", "output": "static/assets/modules/profile/transparent/icon-settings.png", "width": 256, "height": 256, "alpha": true, "bytes": 84885, "sha256": "f10c83e75135b2cc80c97963be5b9a8552fbe767c0a518f0106ede41cf441ccb", "provenance": "committed-binary", "rebuildable": false },
|
||||
{ "id": "app-modules-profile-transparent-icon-vip", "output": "static/assets/modules/profile/transparent/icon-vip.png", "width": 256, "height": 256, "alpha": true, "bytes": 80349, "sha256": "66b08710a87d174cb38d24d6db75883a4819a074b848bbb5e7232bf9389e68ab", "provenance": "committed-binary", "rebuildable": false },
|
||||
{ "id": "app-modules-profile-transparent-edit-button", "output": "static/assets/modules/profile/transparent/edit-button.png", "width": 1713, "height": 395, "alpha": true, "bytes": 1215863, "sha256": "58d9d8b2d57e2f7922f608122d1cdb5075e6a9e3b171ba2ce4100817b87d37e8", "provenance": "committed-binary", "rebuildable": false },
|
||||
{ "id": "app-modules-profile-transparent-tree-medallion", "output": "static/assets/modules/profile/transparent/tree-medallion.png", "width": 1254, "height": 1254, "alpha": true, "bytes": 2998737, "sha256": "831ffb68e4d2f0a0c44a9ddabd299e0209fc89fd007c5a1c1d4b709c6859b83d", "provenance": "committed-binary", "rebuildable": false },
|
||||
{ "id": "app-modules-tree-transparent-action-arrow", "output": "static/assets/modules/tree/transparent/action-arrow.png", "width": 96, "height": 96, "alpha": true, "bytes": 2353, "sha256": "d7d80ce66b959ff29be31fb21e9b53f32289339bcdc98c13b883693754eedaf9", "provenance": "committed-binary", "rebuildable": false },
|
||||
{ "id": "app-modules-tree-transparent-action-close", "output": "static/assets/modules/tree/transparent/action-close.png", "width": 128, "height": 128, "alpha": true, "bytes": 5660, "sha256": "c72e937ef06bf89a42b7958f48f3b38eecea66c16013c703ca4bc76c1b7a02e7", "provenance": "committed-binary", "rebuildable": false },
|
||||
{ "id": "app-modules-tree-transparent-action-panel-paper", "output": "static/assets/modules/tree/transparent/action-panel-paper.png", "width": 1034, "height": 1520, "alpha": true, "bytes": 2456802, "sha256": "85d219e828ecbe8042f47fc39587f05134d1bed3300a0ccc201426075849c4ae", "provenance": "committed-binary", "rebuildable": false },
|
||||
{ "id": "app-modules-tree-transparent-management-marker", "output": "static/assets/modules/tree/transparent/management-marker.png", "width": 128, "height": 128, "alpha": true, "bytes": 14014, "sha256": "389c4bb4964c0a7f8ef37cd650f6eda8ed50952eb0cce5f86d9643a30bf93e31", "provenance": "committed-binary", "rebuildable": false },
|
||||
{ "id": "app-modules-tree-transparent-member-medallion", "output": "static/assets/modules/tree/transparent/member-medallion.png", "width": 192, "height": 192, "alpha": true, "bytes": 37801, "sha256": "9f3765f9ce055b95a84fd0c6f7e4844793caf1ac27c7ac2ac628beccbd5883ec", "provenance": "committed-binary", "rebuildable": false },
|
||||
{ "id": "app-modules-tree-transparent-relation-button-frame", "output": "static/assets/modules/tree/transparent/relation-button-frame.png", "width": 1591, "height": 406, "alpha": true, "bytes": 1148107, "sha256": "bffdc12e8d0179fc3c3f38eeb7d5529c1274266205d3f78258887e961f1f8fbb", "provenance": "committed-binary", "rebuildable": false },
|
||||
{ "id": "app-modules-tree-transparent-relation-map", "output": "static/assets/modules/tree/transparent/relation-map.png", "width": 1536, "height": 1024, "alpha": true, "bytes": 156562, "sha256": "550f20ea224ca90be6e5a4ee5ff4bf37716bf4d7f0c77f699e788e3a2c07f8f3", "provenance": "committed-binary", "rebuildable": false },
|
||||
{ "id": "app-modules-tree-transparent-relation-marker", "output": "static/assets/modules/tree/transparent/relation-marker.png", "width": 128, "height": 128, "alpha": true, "bytes": 9268, "sha256": "a5afafa89435f5df5205a6e0537d9982e713fe97b7deb6e80754c71e2a86db31", "provenance": "committed-binary", "rebuildable": false }
|
||||
]
|
||||
}
|
||||
|
||||
@@ -6,19 +6,18 @@
|
||||
"assets": [
|
||||
{ "id": "auth-page-paper", "output": "static/assets/foundation/opaque/auth-page-paper.jpg", "width": 750, "height": 1334, "alpha": false, "bytes": 60355, "sha256": "3eea1eefa815c306f0f4a95ce79878ca53f8f64a7c9b44144c99ca683a38e7b8", "provenance": "committed-binary", "rebuildable": false },
|
||||
{ "id": "auth-divider-knot", "output": "static/assets/foundation/transparent/auth-divider-knot.png", "width": 160, "height": 96, "alpha": true, "bytes": 6751, "sha256": "d45dc052c8eb2ba214c7d4d5846f5b53f01686e00e671bfe0582ed7d9d4a2419", "provenance": "committed-binary", "rebuildable": false },
|
||||
{ "id": "auth-login-outline", "output": "static/assets/foundation/transparent/auth-login-outline.png", "width": 96, "height": 96, "alpha": true, "bytes": 1973, "sha256": "45fc22b367807bdc465e6e17230a10276a4d7e453e27b7cbc064af7fdc255470", "provenance": "committed-binary", "rebuildable": false },
|
||||
{ "id": "auth-wechat", "output": "static/assets/foundation/transparent/auth-wechat.png", "width": 96, "height": 96, "alpha": true, "bytes": 2215, "sha256": "5c69addac53afcd34064e48fffc19ef56a5d105c92f6130cd6045dd1591221d1", "provenance": "committed-binary", "rebuildable": false },
|
||||
{ "id": "brand-seal", "output": "static/assets/foundation/transparent/brand-seal.png", "width": 240, "height": 288, "alpha": true, "bytes": 101898, "sha256": "865e15edd6a1b50aa298ccdff5babf0f3140906026ce76d3a64631027820a7ae", "provenance": "committed-binary", "rebuildable": false },
|
||||
{ "id": "chevron-right", "output": "static/assets/foundation/transparent/chevron-right.png", "width": 96, "height": 96, "alpha": true, "bytes": 2106, "sha256": "82f4996e118832108dba4aa33d320131fee300970679f6ce8cf4b09bda62b700", "provenance": "committed-binary", "rebuildable": false },
|
||||
{ "id": "auth-backdrop-v1", "output": "static/assets/modules/auth/opaque/a01-red-hall-ink-backdrop-v1.png", "width": 824, "height": 1830, "alpha": false, "bytes": 669577, "sha256": "478d0f56d669ac1597cfc2a1082740e73550f07d655286762f3306410c19eb11", "provenance": "committed-binary", "rebuildable": false },
|
||||
{ "id": "auth-header-v1", "output": "static/assets/modules/auth/opaque/a01-vnext-header-v1.png", "width": 824, "height": 340, "alpha": false, "bytes": 504001, "sha256": "6ce03f4962e8bdd0272ed00a82efded21becdf350a1fb0880c595d7d9c15200d", "provenance": "committed-binary", "rebuildable": false },
|
||||
{ "id": "auth-eye-closed-v2", "output": "static/assets/modules/auth/transparent/a01-icon-eye-closed-pupil-v2.png", "width": 96, "height": 96, "alpha": true, "bytes": 3906, "sha256": "b9304de963cacaa5ecd133a9835174f0c77761d9f264afec900460c9aa983b4f", "provenance": "committed-binary", "rebuildable": false },
|
||||
{ "id": "auth-eye-open-v1", "output": "static/assets/modules/auth/transparent/a01-icon-eye-open-v1.png", "width": 96, "height": 96, "alpha": true, "bytes": 2868, "sha256": "aa6a78b3f0ecb2d47963b9aa04c98db1b2981497f52f58ff8c602cff80fab21a", "provenance": "committed-binary", "rebuildable": false },
|
||||
{ "id": "auth-lock-v1", "output": "static/assets/modules/auth/transparent/a01-icon-lock-v1.png", "width": 96, "height": 96, "alpha": true, "bytes": 2649, "sha256": "c1b13de86ffee9a9f2e7534d58ddc3bf155d5341eeddbd5998ef3747d57fdea8", "provenance": "committed-binary", "rebuildable": false },
|
||||
{ "id": "auth-phone-v1", "output": "static/assets/modules/auth/transparent/a01-icon-phone-v1.png", "width": 96, "height": 96, "alpha": true, "bytes": 925, "sha256": "5110cfb1cc84b00145506b3cda3fc35860400d16404e606428902dfe0fa7ec2e", "provenance": "committed-binary", "rebuildable": false },
|
||||
{ "id": "auth-sms-code-v2", "output": "static/assets/modules/auth/transparent/a01-icon-sms-code-v2.png", "width": 1254, "height": 1254, "alpha": true, "bytes": 114061, "sha256": "e932900fa8ab0edcfeed0de0d52c637890e28f58f867aabfeacbb18df060a556", "provenance": "committed-binary", "rebuildable": false },
|
||||
{ "id": "auth-divider-v1", "output": "static/assets/modules/auth/transparent/a01-vnext-divider-v1.png", "width": 540, "height": 90, "alpha": true, "bytes": 6278, "sha256": "10a3281723abfadcc2cec7b8e4437fe50ecdc36d885d54ebce11f46c46ed4af4", "provenance": "committed-binary", "rebuildable": false },
|
||||
{ "id": "agreement-checked", "output": "static/assets/modules/auth/transparent/a02-agreement-checked.png", "width": 96, "height": 96, "alpha": true, "bytes": 10602, "sha256": "825fcb945c81330429abc1d3ec1d0c20df86a9634416e244896e452cb08644f4", "provenance": "committed-binary", "rebuildable": false },
|
||||
{ "id": "agreement-unchecked", "output": "static/assets/modules/auth/transparent/a02-agreement-unchecked.png", "width": 96, "height": 96, "alpha": true, "bytes": 6014, "sha256": "d9843d1c3644272f6eeff9fb44e763c857f409f404551a5e1f032ddf1b07b69f", "provenance": "committed-binary", "rebuildable": false }
|
||||
{ "id": "auth-backdrop", "output": "static/assets/modules/auth/opaque/sign-in-backdrop.png", "width": 824, "height": 1830, "alpha": false, "bytes": 669577, "sha256": "478d0f56d669ac1597cfc2a1082740e73550f07d655286762f3306410c19eb11", "provenance": "committed-binary", "rebuildable": false },
|
||||
{ "id": "auth-header", "output": "static/assets/modules/auth/opaque/auth-header.png", "width": 824, "height": 340, "alpha": false, "bytes": 504001, "sha256": "6ce03f4962e8bdd0272ed00a82efded21becdf350a1fb0880c595d7d9c15200d", "provenance": "committed-binary", "rebuildable": false },
|
||||
{ "id": "auth-password-hidden", "output": "static/assets/modules/auth/transparent/password-hidden.png", "width": 96, "height": 96, "alpha": true, "bytes": 3906, "sha256": "b9304de963cacaa5ecd133a9835174f0c77761d9f264afec900460c9aa983b4f", "provenance": "committed-binary", "rebuildable": false },
|
||||
{ "id": "auth-password-visible", "output": "static/assets/modules/auth/transparent/password-visible.png", "width": 96, "height": 96, "alpha": true, "bytes": 2868, "sha256": "aa6a78b3f0ecb2d47963b9aa04c98db1b2981497f52f58ff8c602cff80fab21a", "provenance": "committed-binary", "rebuildable": false },
|
||||
{ "id": "auth-icon-password", "output": "static/assets/modules/auth/transparent/icon-password.png", "width": 96, "height": 96, "alpha": true, "bytes": 2649, "sha256": "c1b13de86ffee9a9f2e7534d58ddc3bf155d5341eeddbd5998ef3747d57fdea8", "provenance": "committed-binary", "rebuildable": false },
|
||||
{ "id": "auth-icon-phone", "output": "static/assets/modules/auth/transparent/icon-phone.png", "width": 96, "height": 96, "alpha": true, "bytes": 925, "sha256": "5110cfb1cc84b00145506b3cda3fc35860400d16404e606428902dfe0fa7ec2e", "provenance": "committed-binary", "rebuildable": false },
|
||||
{ "id": "auth-icon-verification-code", "output": "static/assets/modules/auth/transparent/icon-verification-code.png", "width": 1254, "height": 1254, "alpha": true, "bytes": 114061, "sha256": "e932900fa8ab0edcfeed0de0d52c637890e28f58f867aabfeacbb18df060a556", "provenance": "committed-binary", "rebuildable": false },
|
||||
{ "id": "auth-title-divider", "output": "static/assets/modules/auth/transparent/title-divider.png", "width": 540, "height": 90, "alpha": true, "bytes": 6278, "sha256": "10a3281723abfadcc2cec7b8e4437fe50ecdc36d885d54ebce11f46c46ed4af4", "provenance": "committed-binary", "rebuildable": false },
|
||||
{ "id": "agreement-checked", "output": "static/assets/modules/auth/transparent/agreement-checked.png", "width": 96, "height": 96, "alpha": true, "bytes": 10602, "sha256": "825fcb945c81330429abc1d3ec1d0c20df86a9634416e244896e452cb08644f4", "provenance": "committed-binary", "rebuildable": false },
|
||||
{ "id": "agreement-unchecked", "output": "static/assets/modules/auth/transparent/agreement-unchecked.png", "width": 96, "height": 96, "alpha": true, "bytes": 6014, "sha256": "d9843d1c3644272f6eeff9fb44e763c857f409f404551a5e1f032ddf1b07b69f", "provenance": "committed-binary", "rebuildable": false }
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
{
|
||||
"schemaVersion": 3,
|
||||
"kind": "asset-build-manifest",
|
||||
"family": "g01-state-frame-v3",
|
||||
"assets": [
|
||||
{
|
||||
"id": "g01-empty-panel-frame",
|
||||
"source": "docs/design/assets/g01-state/masters/g01-empty-panel-master.png",
|
||||
"output": "static/assets/modules/genealogy/transparent/g01-empty-panel-frame.png",
|
||||
"sourcePixels": { "width": 1122, "height": 1402 },
|
||||
"outputPixels": { "width": 1122, "height": 1402 },
|
||||
"alpha": { "required": true, "transparentOuterPadding": 0, "cornerMaxAlpha": 0 },
|
||||
"edge": { "forbidChromaResidue": false, "forbidLightFringe": false, "premultipliedAlphaCheck": true },
|
||||
"quality": { "maxBytes": 2200000, "colorSpace": "sRGB" },
|
||||
"processing": {
|
||||
"mode": "warm-gold-frame-extract",
|
||||
"borderBand": 110,
|
||||
"redGreenMin": 15,
|
||||
"greenBlueMin": 12,
|
||||
"redBlueMin": 35,
|
||||
"redMaxExclusive": 245,
|
||||
"blueMaxExclusive": 180,
|
||||
"alphaOffset": 25,
|
||||
"alphaScale": 6,
|
||||
"outputMode": "RGBA"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,61 +0,0 @@
|
||||
{
|
||||
"schemaVersion": 3,
|
||||
"kind": "asset-build-manifest",
|
||||
"family": "page-backgrounds-v3",
|
||||
"assets": [
|
||||
{
|
||||
"id": "genealogy-page-background-long",
|
||||
"source": "docs/design/assets/g01-background/masters/genealogy-page-background-long-flagship-master.png",
|
||||
"output": "static/assets/modules/genealogy/opaque/genealogy-page-background-long.png",
|
||||
"sourcePixels": { "width": 1536, "height": 3840 },
|
||||
"outputPixels": { "width": 1440, "height": 3600 },
|
||||
"quality": { "maxBytes": 7000000, "colorSpace": "sRGB" },
|
||||
"processing": { "mode": "opaque-resize", "resample": "lanczos", "outputMode": "RGB" }
|
||||
},
|
||||
{
|
||||
"id": "tree-page-background-long",
|
||||
"source": "docs/design/assets/module-backgrounds/masters/tree-page-background-imagegen-source.png",
|
||||
"output": "static/assets/modules/tree/opaque/tree-page-background-long.png",
|
||||
"sourcePixels": { "width": 793, "height": 1983 },
|
||||
"outputPixels": { "width": 1440, "height": 3600 },
|
||||
"quality": { "maxBytes": 7000000, "colorSpace": "sRGB" },
|
||||
"processing": { "mode": "opaque-cover-crop", "resample": "lanczos", "anchor": "center", "outputMode": "RGB" }
|
||||
},
|
||||
{
|
||||
"id": "family-page-background-long",
|
||||
"source": "docs/design/assets/module-backgrounds/masters/family-page-background-imagegen-source.png",
|
||||
"output": "static/assets/modules/family/opaque/family-page-background-long.png",
|
||||
"sourcePixels": { "width": 793, "height": 1983 },
|
||||
"outputPixels": { "width": 1440, "height": 3600 },
|
||||
"quality": { "maxBytes": 7000000, "colorSpace": "sRGB" },
|
||||
"processing": { "mode": "opaque-cover-crop", "resample": "lanczos", "anchor": "center", "outputMode": "RGB" }
|
||||
},
|
||||
{
|
||||
"id": "records-page-background-long",
|
||||
"source": "docs/design/assets/module-backgrounds/masters/records-page-background-imagegen-source.png",
|
||||
"output": "static/assets/modules/records/opaque/records-page-background-long.png",
|
||||
"sourcePixels": { "width": 793, "height": 1983 },
|
||||
"outputPixels": { "width": 1440, "height": 3600 },
|
||||
"quality": { "maxBytes": 7000000, "colorSpace": "sRGB" },
|
||||
"processing": { "mode": "opaque-cover-crop", "resample": "lanczos", "anchor": "center", "outputMode": "RGB" }
|
||||
},
|
||||
{
|
||||
"id": "notification-page-background-long",
|
||||
"source": "docs/design/assets/module-backgrounds/masters/notification-page-background-imagegen-source.png",
|
||||
"output": "static/assets/modules/notification/opaque/notification-page-background-long.png",
|
||||
"sourcePixels": { "width": 793, "height": 1983 },
|
||||
"outputPixels": { "width": 1440, "height": 3600 },
|
||||
"quality": { "maxBytes": 7000000, "colorSpace": "sRGB" },
|
||||
"processing": { "mode": "opaque-cover-crop", "resample": "lanczos", "anchor": "center", "outputMode": "RGB" }
|
||||
},
|
||||
{
|
||||
"id": "profile-page-background-long",
|
||||
"source": "docs/design/assets/module-backgrounds/masters/profile-page-background-imagegen-source.png",
|
||||
"output": "static/assets/modules/profile/opaque/profile-page-background-long.png",
|
||||
"sourcePixels": { "width": 793, "height": 1983 },
|
||||
"outputPixels": { "width": 1440, "height": 3600 },
|
||||
"quality": { "maxBytes": 7000000, "colorSpace": "sRGB" },
|
||||
"processing": { "mode": "opaque-cover-crop", "resample": "lanczos", "anchor": "center", "outputMode": "RGB" }
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"kind": "runtime-asset-inventory",
|
||||
"scope": "retained-generated-assets",
|
||||
"imports": [],
|
||||
"assets": [
|
||||
{ "id": "shared-scroll-primary-v3", "output": "static/assets/foundation/transparent/scroll-primary.png", "width": 1866, "height": 276, "alpha": true, "bytes": 576876, "sha256": "2181fece4d1743aa55b919c38ed1cb31662d8d2588f16e2dabb236e24d094040", "provenance": "committed-binary", "rebuildable": false },
|
||||
{ "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": 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 }
|
||||
]
|
||||
}
|
||||
@@ -5,9 +5,7 @@
|
||||
"imports": [
|
||||
"design-pipeline/manifests/auth-runtime-assets.json",
|
||||
"design-pipeline/manifests/application-runtime-assets.json",
|
||||
"design-pipeline/manifests/shared-scroll-skins-v3.json",
|
||||
"design-pipeline/manifests/page-backgrounds-v3.json",
|
||||
"design-pipeline/manifests/g01-state-frame-v3.json"
|
||||
"design-pipeline/manifests/retained-generated-runtime-assets.json"
|
||||
],
|
||||
"assets": []
|
||||
}
|
||||
|
||||
@@ -1,51 +0,0 @@
|
||||
{
|
||||
"schemaVersion": 3,
|
||||
"kind": "asset-build-manifest",
|
||||
"family": "shared-scroll-skins-v3",
|
||||
"assets": [
|
||||
{
|
||||
"id": "shared-scroll-primary-v3",
|
||||
"source": "docs/design/assets/a01-vnext/masters/a01-scroll-primary-master-v3.png",
|
||||
"output": "static/assets/foundation/transparent/a01-scroll-primary-v3.png",
|
||||
"sourcePixels": { "width": 2172, "height": 724 },
|
||||
"outputPixels": { "width": 1866, "height": 276 },
|
||||
"alpha": { "required": true, "transparentOuterPadding": 6, "cornerMaxAlpha": 0 },
|
||||
"edge": { "forbidChromaResidue": true, "forbidLightFringe": true, "premultipliedAlphaCheck": true },
|
||||
"quality": { "maxBytes": 1800000, "colorSpace": "sRGB" },
|
||||
"processing": { "mode": "chroma-stretch", "capWidth": 380, "keyColor": "#00FF00", "keyTolerance": 96 }
|
||||
},
|
||||
{
|
||||
"id": "shared-scroll-secondary-v3",
|
||||
"source": "docs/design/assets/a01-vnext/masters/a01-scroll-secondary-master-v3.png",
|
||||
"output": "static/assets/foundation/transparent/a01-scroll-secondary-v3.png",
|
||||
"sourcePixels": { "width": 2172, "height": 724 },
|
||||
"outputPixels": { "width": 1866, "height": 300 },
|
||||
"alpha": { "required": true, "transparentOuterPadding": 6, "cornerMaxAlpha": 0 },
|
||||
"edge": { "forbidChromaResidue": true, "forbidLightFringe": true, "premultipliedAlphaCheck": true },
|
||||
"quality": { "maxBytes": 1800000, "colorSpace": "sRGB" },
|
||||
"processing": { "mode": "chroma-stretch", "capWidth": 380, "keyColor": "#00FF00", "keyTolerance": 96 }
|
||||
},
|
||||
{
|
||||
"id": "shared-scroll-toast-v3",
|
||||
"source": "docs/design/assets/a01-vnext/masters/a01-scroll-toast-master-v3.png",
|
||||
"output": "static/assets/foundation/transparent/a01-scroll-toast-v3.png",
|
||||
"sourcePixels": { "width": 2172, "height": 724 },
|
||||
"outputPixels": { "width": 1770, "height": 246 },
|
||||
"alpha": { "required": true, "transparentOuterPadding": 6, "cornerMaxAlpha": 0 },
|
||||
"edge": { "forbidChromaResidue": true, "forbidLightFringe": true, "premultipliedAlphaCheck": true },
|
||||
"quality": { "maxBytes": 1600000, "colorSpace": "sRGB" },
|
||||
"processing": { "mode": "chroma-stretch", "capWidth": 340, "keyColor": "#00FF00", "keyTolerance": 96 }
|
||||
},
|
||||
{
|
||||
"id": "shared-scroll-dialog-v3",
|
||||
"source": "docs/design/assets/a01-vnext/masters/a01-scroll-dialog-master-v3-r2.png",
|
||||
"output": "static/assets/modules/auth/transparent/a01-scroll-dialog-v3.png",
|
||||
"sourcePixels": { "width": 1370, "height": 1148 },
|
||||
"outputPixels": { "width": 1860, "height": 1560 },
|
||||
"alpha": { "required": true, "transparentOuterPadding": 6, "cornerMaxAlpha": 0 },
|
||||
"edge": { "forbidChromaResidue": true, "forbidLightFringe": true, "premultipliedAlphaCheck": true },
|
||||
"quality": { "maxBytes": 3000000, "colorSpace": "sRGB" },
|
||||
"processing": { "mode": "chroma-stretch", "capWidth": 260, "keyColor": "#00FF00", "keyTolerance": 96, "paletteColors": 128, "indexedPng": true }
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -3,16 +3,8 @@
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"test": "npm run test:node && npm run test:python",
|
||||
"test:node": "node --test tests/*.test.mjs",
|
||||
"test:python": "node scripts/run-python-tests.mjs",
|
||||
"validate:shared-scroll-skins": "node scripts/validate-asset-build-manifest.mjs design-pipeline/manifests/shared-scroll-skins-v3.json",
|
||||
"validate:page-backgrounds": "node scripts/validate-asset-build-manifest.mjs design-pipeline/manifests/page-backgrounds-v3.json",
|
||||
"validate:g01-state-frame": "node scripts/validate-asset-build-manifest.mjs design-pipeline/manifests/g01-state-frame-v3.json",
|
||||
"test": "node --test tests/*.test.mjs",
|
||||
"validate:runtime-assets": "node scripts/validate-runtime-asset-inventory.mjs design-pipeline/manifests/runtime-assets.json",
|
||||
"build:shared-scroll-skins": "node scripts/build-shared-scroll-skins.mjs",
|
||||
"verify:shared-scroll-skins": "node scripts/verify-shared-scroll-skins.mjs",
|
||||
"build:page-backgrounds": "node scripts/build-raster-assets.mjs design-pipeline/manifests/page-backgrounds-v3.json",
|
||||
"build:g01-state-frame": "node scripts/build-raster-assets.mjs design-pipeline/manifests/g01-state-frame-v3.json"
|
||||
"check": "npm test && npm run validate:runtime-assets"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
Pillow==12.3.0
|
||||
@@ -1,196 +0,0 @@
|
||||
import path from 'node:path'
|
||||
|
||||
const assertOnlyFields = (value, fields, label) => {
|
||||
for (const field of Object.keys(value)) {
|
||||
if (!fields.has(field)) throw new Error(`${label} contains unknown field: ${field}`)
|
||||
}
|
||||
}
|
||||
|
||||
const requireObject = (value, label) => {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
throw new Error(`${label} must be an object`)
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
const requireString = (value, label) => {
|
||||
if (typeof value !== 'string' || value.trim() === '') {
|
||||
throw new Error(`${label} must be a non-empty string`)
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
const requirePositiveInteger = (value, label) => {
|
||||
if (!Number.isInteger(value) || value <= 0) throw new Error(`${label} must be a positive integer`)
|
||||
return value
|
||||
}
|
||||
|
||||
const requireIntegerInRange = (value, minimum, maximum, label) => {
|
||||
if (!Number.isInteger(value) || value < minimum || value > maximum) {
|
||||
throw new Error(`${label} must be an integer from ${minimum} to ${maximum}`)
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
const requireBoolean = (value, label) => {
|
||||
if (typeof value !== 'boolean') throw new Error(`${label} must be boolean`)
|
||||
return value
|
||||
}
|
||||
|
||||
const requireExact = (value, expected, label) => {
|
||||
if (value !== expected) throw new Error(`${label} must be ${expected}`)
|
||||
return value
|
||||
}
|
||||
|
||||
const resolveInsideWorkspace = (workspace, relativePath, label) => {
|
||||
requireString(relativePath, label)
|
||||
const absolutePath = path.resolve(workspace, relativePath)
|
||||
const relative = path.relative(workspace, absolutePath)
|
||||
if (relative.startsWith('..') || path.isAbsolute(relative)) {
|
||||
throw new Error(`${label} escapes workspace: ${relativePath}`)
|
||||
}
|
||||
return absolutePath
|
||||
}
|
||||
|
||||
const validatePixels = (value, label) => {
|
||||
const pixels = requireObject(value, label)
|
||||
assertOnlyFields(pixels, new Set(['width', 'height']), label)
|
||||
requirePositiveInteger(pixels.width, `${label}.width`)
|
||||
requirePositiveInteger(pixels.height, `${label}.height`)
|
||||
}
|
||||
|
||||
const validateAlphaAndEdge = (asset, id) => {
|
||||
const alpha = requireObject(asset.alpha, `${id}.alpha`)
|
||||
assertOnlyFields(alpha, new Set(['required', 'transparentOuterPadding', 'cornerMaxAlpha']), `${id}.alpha`)
|
||||
requireExact(alpha.required, true, `${id}.alpha.required`)
|
||||
requireIntegerInRange(alpha.transparentOuterPadding, 0, 4096, `${id}.alpha.transparentOuterPadding`)
|
||||
requireIntegerInRange(alpha.cornerMaxAlpha, 0, 255, `${id}.alpha.cornerMaxAlpha`)
|
||||
|
||||
const edge = requireObject(asset.edge, `${id}.edge`)
|
||||
assertOnlyFields(
|
||||
edge,
|
||||
new Set(['forbidChromaResidue', 'forbidLightFringe', 'premultipliedAlphaCheck']),
|
||||
`${id}.edge`,
|
||||
)
|
||||
requireBoolean(edge.forbidChromaResidue, `${id}.edge.forbidChromaResidue`)
|
||||
requireBoolean(edge.forbidLightFringe, `${id}.edge.forbidLightFringe`)
|
||||
requireBoolean(edge.premultipliedAlphaCheck, `${id}.edge.premultipliedAlphaCheck`)
|
||||
}
|
||||
|
||||
const validateProcessing = (processing, id) => {
|
||||
requireObject(processing, `${id}.processing`)
|
||||
const mode = requireString(processing.mode, `${id}.processing.mode`)
|
||||
|
||||
if (mode === 'chroma-stretch') {
|
||||
assertOnlyFields(
|
||||
processing,
|
||||
new Set(['mode', 'capWidth', 'keyColor', 'keyTolerance', 'paletteColors', 'indexedPng']),
|
||||
`${id}.processing`,
|
||||
)
|
||||
requirePositiveInteger(processing.capWidth, `${id}.processing.capWidth`)
|
||||
if (!/^#[A-Fa-f0-9]{6}$/.test(processing.keyColor)) {
|
||||
throw new Error(`${id}.processing.keyColor must be a six-digit RGB color`)
|
||||
}
|
||||
requireIntegerInRange(processing.keyTolerance, 0, 441, `${id}.processing.keyTolerance`)
|
||||
if (processing.paletteColors !== undefined) {
|
||||
requireIntegerInRange(processing.paletteColors, 2, 256, `${id}.processing.paletteColors`)
|
||||
}
|
||||
if (processing.indexedPng !== undefined) {
|
||||
requireBoolean(processing.indexedPng, `${id}.processing.indexedPng`)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
if (mode === 'opaque-resize') {
|
||||
assertOnlyFields(processing, new Set(['mode', 'resample', 'outputMode']), `${id}.processing`)
|
||||
requireExact(processing.resample, 'lanczos', `${id}.processing.resample`)
|
||||
requireExact(processing.outputMode, 'RGB', `${id}.processing.outputMode`)
|
||||
return false
|
||||
}
|
||||
|
||||
if (mode === 'opaque-cover-crop') {
|
||||
assertOnlyFields(processing, new Set(['mode', 'resample', 'anchor', 'outputMode']), `${id}.processing`)
|
||||
requireExact(processing.resample, 'lanczos', `${id}.processing.resample`)
|
||||
requireExact(processing.anchor, 'center', `${id}.processing.anchor`)
|
||||
requireExact(processing.outputMode, 'RGB', `${id}.processing.outputMode`)
|
||||
return false
|
||||
}
|
||||
|
||||
if (mode === 'warm-gold-frame-extract') {
|
||||
assertOnlyFields(
|
||||
processing,
|
||||
new Set([
|
||||
'mode',
|
||||
'borderBand',
|
||||
'redGreenMin',
|
||||
'greenBlueMin',
|
||||
'redBlueMin',
|
||||
'redMaxExclusive',
|
||||
'blueMaxExclusive',
|
||||
'alphaOffset',
|
||||
'alphaScale',
|
||||
'outputMode',
|
||||
]),
|
||||
`${id}.processing`,
|
||||
)
|
||||
requirePositiveInteger(processing.borderBand, `${id}.processing.borderBand`)
|
||||
for (const field of ['redGreenMin', 'greenBlueMin', 'redBlueMin', 'alphaOffset']) {
|
||||
requireIntegerInRange(processing[field], 0, 255, `${id}.processing.${field}`)
|
||||
}
|
||||
for (const field of ['redMaxExclusive', 'blueMaxExclusive']) {
|
||||
requireIntegerInRange(processing[field], 1, 256, `${id}.processing.${field}`)
|
||||
}
|
||||
requirePositiveInteger(processing.alphaScale, `${id}.processing.alphaScale`)
|
||||
requireExact(processing.outputMode, 'RGBA', `${id}.processing.outputMode`)
|
||||
return true
|
||||
}
|
||||
|
||||
throw new Error(`${id} has unsupported processing.mode: ${mode}`)
|
||||
}
|
||||
|
||||
export const validateAssetBuildManifest = (manifest, workspace) => {
|
||||
requireObject(manifest, 'manifest')
|
||||
assertOnlyFields(manifest, new Set(['schemaVersion', 'kind', 'family', 'assets']), 'manifest')
|
||||
if (manifest.schemaVersion !== 3) throw new Error('schemaVersion must be 3')
|
||||
if (manifest.kind !== 'asset-build-manifest') throw new Error('kind must be asset-build-manifest')
|
||||
requireString(manifest.family, 'family')
|
||||
if (!Array.isArray(manifest.assets) || manifest.assets.length === 0) {
|
||||
throw new Error('assets must be a non-empty array')
|
||||
}
|
||||
|
||||
const ids = new Set()
|
||||
const outputs = new Set()
|
||||
for (const asset of manifest.assets) {
|
||||
requireObject(asset, 'asset')
|
||||
const processing = requireObject(asset.processing, 'asset.processing')
|
||||
const mode = requireString(processing.mode, 'asset.processing.mode')
|
||||
const transparent = ['chroma-stretch', 'warm-gold-frame-extract'].includes(mode)
|
||||
const fields = new Set(['id', 'source', 'output', 'sourcePixels', 'outputPixels', 'quality', 'processing'])
|
||||
if (transparent) {
|
||||
fields.add('alpha')
|
||||
fields.add('edge')
|
||||
}
|
||||
assertOnlyFields(asset, fields, 'asset')
|
||||
|
||||
const id = requireString(asset.id, 'asset.id')
|
||||
if (ids.has(id)) throw new Error(`duplicate asset id: ${id}`)
|
||||
ids.add(id)
|
||||
|
||||
resolveInsideWorkspace(workspace, asset.source, `${id}.source`)
|
||||
resolveInsideWorkspace(workspace, asset.output, `${id}.output`)
|
||||
if (outputs.has(asset.output)) throw new Error(`duplicate output: ${asset.output}`)
|
||||
outputs.add(asset.output)
|
||||
|
||||
validatePixels(asset.sourcePixels, `${id}.sourcePixels`)
|
||||
validatePixels(asset.outputPixels, `${id}.outputPixels`)
|
||||
|
||||
const processingNeedsAlpha = validateProcessing(processing, id)
|
||||
if (processingNeedsAlpha) validateAlphaAndEdge(asset, id)
|
||||
|
||||
const quality = requireObject(asset.quality, `${id}.quality`)
|
||||
assertOnlyFields(quality, new Set(['maxBytes', 'colorSpace']), `${id}.quality`)
|
||||
requirePositiveInteger(quality.maxBytes, `${id}.quality.maxBytes`)
|
||||
requireExact(quality.colorSpace, 'sRGB', `${id}.quality.colorSpace`)
|
||||
}
|
||||
return manifest
|
||||
}
|
||||
@@ -1,127 +0,0 @@
|
||||
"""Deterministic PNG quality checks for generated design assets."""
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from PIL import Image
|
||||
|
||||
|
||||
def _outer_ring_pixels(image: Image.Image, thickness: int):
|
||||
width, height = image.size
|
||||
thickness = max(0, min(thickness, width // 2, height // 2))
|
||||
for y in range(height):
|
||||
for x in range(width):
|
||||
if x < thickness or x >= width - thickness or y < thickness or y >= height - thickness:
|
||||
yield image.getpixel((x, y))
|
||||
|
||||
|
||||
def analyze_asset(path: Path, spec: dict[str, Any], workspace: Path) -> dict[str, Any]:
|
||||
"""按 schema v3 物理规格分析单张 PNG,并只输出工作区相对路径。"""
|
||||
workspace = Path(workspace).resolve()
|
||||
path = Path(path).resolve()
|
||||
try:
|
||||
report_path = path.relative_to(workspace).as_posix()
|
||||
except ValueError as error:
|
||||
raise ValueError(f"asset path escapes workspace: {path}") from error
|
||||
errors: list[str] = []
|
||||
warnings: list[str] = []
|
||||
metrics: dict[str, int] = {}
|
||||
|
||||
with Image.open(path) as opened:
|
||||
width, height = opened.size
|
||||
mode = opened.mode
|
||||
info = dict(opened.info)
|
||||
has_alpha = "A" in opened.getbands() or "transparency" in opened.info
|
||||
image = opened.convert("RGBA")
|
||||
|
||||
expected = spec["outputPixels"]
|
||||
if (width, height) != (expected["width"], expected["height"]):
|
||||
errors.append(
|
||||
f"dimensions expected {expected['width']}x{expected['height']}, got {width}x{height}"
|
||||
)
|
||||
|
||||
alpha = spec.get("alpha", {})
|
||||
if alpha.get("required") and not has_alpha:
|
||||
errors.append(f"alpha-required asset has no alpha channel or transparency table, got {mode}")
|
||||
|
||||
padding = int(alpha.get("transparentOuterPadding", 0))
|
||||
max_alpha = int(alpha.get("cornerMaxAlpha", 255))
|
||||
corners = (
|
||||
image.getpixel((0, 0))[3],
|
||||
image.getpixel((width - 1, 0))[3],
|
||||
image.getpixel((0, height - 1))[3],
|
||||
image.getpixel((width - 1, height - 1))[3],
|
||||
)
|
||||
corner_violations = sum(1 for value in corners if value > max_alpha)
|
||||
metrics["cornerAlphaViolations"] = corner_violations
|
||||
if corner_violations:
|
||||
errors.append(f"corners contain {corner_violations} pixels above alpha {max_alpha}")
|
||||
|
||||
# 不透明长背景没有边缘/透明度扫描需求;避免无意义地把每张 1440×3600 图
|
||||
# 展开成数百万个 Python 元组。透明资产仍完整执行原有像素级质量合同。
|
||||
opaque_padding = 0
|
||||
if padding > 0:
|
||||
opaque_padding = sum(1 for pixel in _outer_ring_pixels(image, padding) if pixel[3] > max_alpha)
|
||||
metrics["outerPaddingViolations"] = opaque_padding
|
||||
if opaque_padding:
|
||||
errors.append(f"outer padding contains {opaque_padding} pixels above alpha {max_alpha}")
|
||||
|
||||
edge = spec.get("edge", {})
|
||||
needs_edge_pixels = any(edge.get(field) for field in (
|
||||
"forbidChromaResidue",
|
||||
"forbidLightFringe",
|
||||
"premultipliedAlphaCheck",
|
||||
))
|
||||
pixels = list(image.get_flattened_data()) if needs_edge_pixels else []
|
||||
|
||||
chroma_residue = sum(
|
||||
1
|
||||
for red, green, blue, pixel_alpha in pixels
|
||||
if pixel_alpha > 8 and green > 120 and green - max(red, blue) > 80
|
||||
)
|
||||
metrics["chromaResiduePixels"] = chroma_residue
|
||||
if edge.get("forbidChromaResidue") and chroma_residue:
|
||||
errors.append(f"chroma residue detected in {chroma_residue} visible pixels")
|
||||
|
||||
light_fringe = sum(
|
||||
1
|
||||
for red, green, blue, pixel_alpha in pixels
|
||||
if 0 < pixel_alpha < 255 and red > 235 and green > 235 and blue > 235
|
||||
)
|
||||
metrics["lightFringePixels"] = light_fringe
|
||||
if edge.get("forbidLightFringe") and light_fringe:
|
||||
errors.append(f"light fringe detected in {light_fringe} partially transparent pixels")
|
||||
|
||||
transparent_rgb = sum(
|
||||
1
|
||||
for red, green, blue, pixel_alpha in pixels
|
||||
if pixel_alpha == 0 and (red != 0 or green != 0 or blue != 0)
|
||||
)
|
||||
metrics["transparentRgbPixels"] = transparent_rgb
|
||||
if edge.get("premultipliedAlphaCheck") and transparent_rgb:
|
||||
errors.append(f"transparent RGB detected in {transparent_rgb} fully transparent pixels")
|
||||
|
||||
byte_size = path.stat().st_size
|
||||
max_bytes = int(spec.get("quality", {}).get("maxBytes", 0))
|
||||
if max_bytes and byte_size > max_bytes:
|
||||
errors.append(f"maxBytes {max_bytes} exceeded by file size {byte_size}")
|
||||
|
||||
expected_color_space = spec.get("quality", {}).get("colorSpace")
|
||||
if expected_color_space == "sRGB" and not ("srgb" in info or "icc_profile" in info):
|
||||
errors.append("PNG does not declare an sRGB chunk or ICC profile")
|
||||
|
||||
expected_mode = spec.get("processing", {}).get("outputMode")
|
||||
if expected_mode and mode != expected_mode:
|
||||
errors.append(f"output mode expected {expected_mode}, got {mode}")
|
||||
|
||||
return {
|
||||
"id": spec.get("id", path.stem),
|
||||
"path": report_path,
|
||||
"width": width,
|
||||
"height": height,
|
||||
"mode": mode,
|
||||
"bytes": byte_size,
|
||||
"errors": errors,
|
||||
"warnings": warnings,
|
||||
"metrics": metrics,
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
import { validateAssetBuildManifest } from './asset-build-manifest.mjs'
|
||||
import { resolvePythonExecutable, runPythonCommand } from './python-runtime.mjs'
|
||||
|
||||
const scriptDirectory = path.dirname(fileURLToPath(import.meta.url))
|
||||
const pipelineDirectory = path.resolve(scriptDirectory, '..')
|
||||
const workspace = path.resolve(pipelineDirectory, '..')
|
||||
const argument = process.argv[2]
|
||||
if (!argument) throw new Error('用法:node build-raster-assets.mjs <仓库相对清单路径>')
|
||||
|
||||
// 清单参数本身也必须留在工作区;source/output 的边界由 schema validator 逐项负责。
|
||||
const manifestPath = path.resolve(workspace, argument)
|
||||
const relativeManifest = path.relative(workspace, manifestPath)
|
||||
if (relativeManifest.startsWith('..') || path.isAbsolute(relativeManifest)) {
|
||||
throw new Error(`构建清单越出工作区:${argument}`)
|
||||
}
|
||||
|
||||
const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'))
|
||||
validateAssetBuildManifest(manifest, workspace)
|
||||
const supportedModes = new Set(['opaque-resize', 'opaque-cover-crop', 'warm-gold-frame-extract'])
|
||||
for (const asset of manifest.assets) {
|
||||
if (!supportedModes.has(asset.processing.mode)) {
|
||||
throw new Error(`通用 raster 构建器不支持模式:${asset.processing.mode}`)
|
||||
}
|
||||
}
|
||||
|
||||
const python = resolvePythonExecutable({ pipelineDirectory })
|
||||
const reportPath = path.join(pipelineDirectory, 'generated', manifest.family, 'quality-report.json')
|
||||
|
||||
// Node 只编排严格 schema、锁定的 Python 入口和质量报告;像素算法只存在于 Python。
|
||||
runPythonCommand({
|
||||
executable: python,
|
||||
args: [path.join(scriptDirectory, 'build_raster_assets.py'), manifestPath, '--workspace', workspace],
|
||||
cwd: workspace,
|
||||
})
|
||||
runPythonCommand({
|
||||
executable: python,
|
||||
args: [path.join(scriptDirectory, 'verify-assets.py'), manifestPath, '--workspace', workspace, '--report', reportPath],
|
||||
cwd: workspace,
|
||||
})
|
||||
@@ -1,28 +0,0 @@
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
import { validateAssetBuildManifest } from './asset-build-manifest.mjs'
|
||||
import { resolvePythonExecutable, runPythonCommand } from './python-runtime.mjs'
|
||||
|
||||
const scriptDirectory = path.dirname(fileURLToPath(import.meta.url))
|
||||
const pipelineDirectory = path.resolve(scriptDirectory, '..')
|
||||
const workspace = path.resolve(pipelineDirectory, '..')
|
||||
const manifestPath = path.join(pipelineDirectory, 'manifests', 'shared-scroll-skins-v3.json')
|
||||
const reportPath = path.join(pipelineDirectory, 'generated', 'shared-scroll-skins-v3', 'quality-report.json')
|
||||
const python = resolvePythonExecutable({ pipelineDirectory })
|
||||
const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'))
|
||||
|
||||
validateAssetBuildManifest(manifest, workspace)
|
||||
|
||||
// Node 只负责编排锁定的清单和 Python 工具;像素处理与质量分析各自只有一个实现。
|
||||
function run(script, args) {
|
||||
runPythonCommand({
|
||||
executable: python,
|
||||
args: [path.join(scriptDirectory, script), ...args],
|
||||
cwd: workspace,
|
||||
})
|
||||
}
|
||||
|
||||
run('build_scroll_skins.py', [manifestPath, '--workspace', workspace])
|
||||
run('verify-assets.py', [manifestPath, '--workspace', workspace, '--report', reportPath])
|
||||
@@ -1,132 +0,0 @@
|
||||
"""按 schema v3 清单确定性生成不透明长背景与 G01 暖金边框。"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from PIL import Image, PngImagePlugin
|
||||
|
||||
|
||||
def _resolve_inside(workspace: Path, relative_path: str) -> Path:
|
||||
"""解析仓库相对路径,并在任何读写发生前拒绝目录逃逸。"""
|
||||
absolute = (workspace / relative_path).resolve()
|
||||
try:
|
||||
absolute.relative_to(workspace.resolve())
|
||||
except ValueError as error:
|
||||
raise ValueError(f"path escapes workspace: {relative_path}") from error
|
||||
return absolute
|
||||
|
||||
|
||||
def build_opaque_resize(source: Image.Image, output_size: tuple[int, int]) -> Image.Image:
|
||||
"""把同宽高比母版按 LANCZOS 直接缩放为无透明通道的 RGB 正式图。"""
|
||||
image = source.convert("RGB")
|
||||
if image.size != output_size:
|
||||
image = image.resize(output_size, Image.Resampling.LANCZOS)
|
||||
return image
|
||||
|
||||
|
||||
def build_opaque_cover_crop(source: Image.Image, output_size: tuple[int, int]) -> Image.Image:
|
||||
"""等比 cover 后从中心裁切;算法与旧五模块构建器的可见像素完全一致。"""
|
||||
width, height = output_size
|
||||
image = source.convert("RGB")
|
||||
scale = max(width / image.width, height / image.height)
|
||||
resized = image.resize(
|
||||
(round(image.width * scale), round(image.height * scale)),
|
||||
Image.Resampling.LANCZOS,
|
||||
)
|
||||
left = max(0, (resized.width - width) // 2)
|
||||
top = max(0, (resized.height - height) // 2)
|
||||
return resized.crop((left, top, left + width, top + height))
|
||||
|
||||
|
||||
def extract_warm_gold_frame(source: Image.Image, config: dict[str, Any]) -> Image.Image:
|
||||
"""复刻旧 Sharp 暖金边框阈值,同时清零全透明像素的隐藏 RGB。"""
|
||||
image = source.convert("RGBA")
|
||||
width, height = image.size
|
||||
border_band = int(config["borderBand"])
|
||||
output = Image.new("RGBA", image.size, (0, 0, 0, 0))
|
||||
result: list[tuple[int, int, int, int]] = []
|
||||
|
||||
for index, (red, green, blue, source_alpha) in enumerate(image.get_flattened_data()):
|
||||
x = index % width
|
||||
y = index // width
|
||||
distance_to_edge = min(x, y, width - 1 - x, height - 1 - y)
|
||||
warm_gold = (
|
||||
red > green > blue
|
||||
and red - green >= int(config["redGreenMin"])
|
||||
and green - blue >= int(config["greenBlueMin"])
|
||||
and red - blue >= int(config["redBlueMin"])
|
||||
and red < int(config["redMaxExclusive"])
|
||||
and blue < int(config["blueMaxExclusive"])
|
||||
)
|
||||
if distance_to_edge >= border_band or not warm_gold:
|
||||
result.append((0, 0, 0, 0))
|
||||
continue
|
||||
|
||||
edge_alpha = max(
|
||||
0,
|
||||
min(
|
||||
255,
|
||||
(red - blue - int(config["alphaOffset"])) * int(config["alphaScale"]),
|
||||
),
|
||||
)
|
||||
alpha = min(source_alpha, edge_alpha)
|
||||
result.append((red, green, blue, alpha) if alpha else (0, 0, 0, 0))
|
||||
|
||||
output.putdata(result)
|
||||
return output
|
||||
|
||||
|
||||
def save_png(image: Image.Image, path: Path) -> None:
|
||||
"""以固定压缩参数和标准 sRGB chunk 保存,保证同输入得到同字节。"""
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
png_info = PngImagePlugin.PngInfo()
|
||||
png_info.add(b"sRGB", b"\x00")
|
||||
image.save(path, format="PNG", optimize=True, compress_level=9, pnginfo=png_info)
|
||||
|
||||
|
||||
def build_manifest(manifest_path: Path, workspace: Path) -> None:
|
||||
"""执行已经由 Node 严格校验的清单,并再次核对真实母版像素尺寸。"""
|
||||
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
|
||||
for asset in manifest["assets"]:
|
||||
source_path = _resolve_inside(workspace, asset["source"])
|
||||
output_path = _resolve_inside(workspace, asset["output"])
|
||||
expected_source = asset["sourcePixels"]
|
||||
output_pixels = asset["outputPixels"]
|
||||
output_size = (int(output_pixels["width"]), int(output_pixels["height"]))
|
||||
|
||||
with Image.open(source_path) as source:
|
||||
expected_size = (int(expected_source["width"]), int(expected_source["height"]))
|
||||
if source.size != expected_size:
|
||||
raise ValueError(
|
||||
f"{asset['id']} source expected {expected_size[0]}x{expected_size[1]}, "
|
||||
f"got {source.width}x{source.height}"
|
||||
)
|
||||
|
||||
mode = asset["processing"]["mode"]
|
||||
if mode == "opaque-resize":
|
||||
output = build_opaque_resize(source, output_size)
|
||||
elif mode == "opaque-cover-crop":
|
||||
output = build_opaque_cover_crop(source, output_size)
|
||||
elif mode == "warm-gold-frame-extract":
|
||||
output = extract_warm_gold_frame(source, asset["processing"])
|
||||
else:
|
||||
raise ValueError(f"unsupported raster processing mode: {mode}")
|
||||
|
||||
if output.size != output_size:
|
||||
raise ValueError(f"{asset['id']} produced unexpected output size: {output.size}")
|
||||
save_png(output, output_path)
|
||||
print(f"BUILT {asset['id']} -> {asset['output']}")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("manifest", type=Path)
|
||||
parser.add_argument("--workspace", type=Path, required=True)
|
||||
args = parser.parse_args()
|
||||
build_manifest(args.manifest.resolve(), args.workspace.resolve())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,185 +0,0 @@
|
||||
"""从锁定的色键母版确定性生成项目共享卷轴资产。"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import math
|
||||
from pathlib import Path
|
||||
|
||||
from PIL import Image, PngImagePlugin
|
||||
|
||||
|
||||
def remove_chroma_background(
|
||||
image: Image.Image,
|
||||
key: tuple[int, int, int],
|
||||
tolerance: int,
|
||||
feather: int = 72,
|
||||
) -> Image.Image:
|
||||
source = image.convert("RGBA")
|
||||
cleaned = Image.new("RGBA", source.size, (0, 0, 0, 0))
|
||||
result = []
|
||||
key_red, key_green, key_blue = key
|
||||
|
||||
for red, green, blue, source_alpha in source.get_flattened_data():
|
||||
distance = math.sqrt(
|
||||
(red - key_red) ** 2 + (green - key_green) ** 2 + (blue - key_blue) ** 2
|
||||
)
|
||||
alpha_fraction = max(0.0, min(1.0, (distance - tolerance) / feather))
|
||||
alpha_fraction *= source_alpha / 255
|
||||
if alpha_fraction <= 0:
|
||||
result.append((0, 0, 0, 0))
|
||||
continue
|
||||
|
||||
recovered_red = round((red - (1 - alpha_fraction) * key_red) / alpha_fraction)
|
||||
recovered_green = round((green - (1 - alpha_fraction) * key_green) / alpha_fraction)
|
||||
recovered_blue = round((blue - (1 - alpha_fraction) * key_blue) / alpha_fraction)
|
||||
result.append(
|
||||
(
|
||||
max(0, min(255, recovered_red)),
|
||||
max(0, min(255, recovered_green)),
|
||||
max(0, min(255, recovered_blue)),
|
||||
round(alpha_fraction * 255),
|
||||
)
|
||||
)
|
||||
|
||||
cleaned.putdata(result)
|
||||
return cleaned
|
||||
|
||||
|
||||
def trim_transparent(image: Image.Image) -> Image.Image:
|
||||
alpha = image.getchannel("A")
|
||||
bounds = alpha.getbbox()
|
||||
if bounds is None:
|
||||
raise ValueError("chroma removal left no visible artwork")
|
||||
return image.crop(bounds)
|
||||
|
||||
|
||||
def stretch_safe_center(
|
||||
image: Image.Image,
|
||||
*,
|
||||
output_size: tuple[int, int],
|
||||
cap_width: int,
|
||||
padding: int,
|
||||
) -> Image.Image:
|
||||
output_width, output_height = output_size
|
||||
inner_width = output_width - padding * 2
|
||||
inner_height = output_height - padding * 2
|
||||
if inner_width <= 0 or inner_height <= 0:
|
||||
raise ValueError("transparent padding leaves no drawable area")
|
||||
if cap_width <= 0 or cap_width * 2 >= image.width:
|
||||
raise ValueError(f"invalid capWidth {cap_width} for artwork width {image.width}")
|
||||
|
||||
target_cap_width = max(1, round(cap_width * inner_height / image.height))
|
||||
center_width = inner_width - target_cap_width * 2
|
||||
if center_width <= 0:
|
||||
raise ValueError("scaled caps leave no room for the stretch-safe center")
|
||||
|
||||
left = image.crop((0, 0, cap_width, image.height))
|
||||
center = image.crop((cap_width, 0, image.width - cap_width, image.height))
|
||||
right = image.crop((image.width - cap_width, 0, image.width, image.height))
|
||||
resampling = Image.Resampling.LANCZOS
|
||||
left = left.resize((target_cap_width, inner_height), resampling)
|
||||
center = center.resize((center_width, inner_height), resampling)
|
||||
right = right.resize((target_cap_width, inner_height), resampling)
|
||||
|
||||
output = Image.new("RGBA", output_size, (0, 0, 0, 0))
|
||||
output.alpha_composite(left, (padding, padding))
|
||||
output.alpha_composite(center, (padding + target_cap_width, padding))
|
||||
output.alpha_composite(right, (output_width - padding - target_cap_width, padding))
|
||||
return output
|
||||
|
||||
|
||||
def sanitize_output_edges(image: Image.Image) -> Image.Image:
|
||||
cleaned = []
|
||||
for red, green, blue, alpha in image.convert("RGBA").get_flattened_data():
|
||||
chroma_residue = alpha > 0 and green > 120 and green - max(red, blue) > 80
|
||||
light_fringe = 0 < alpha < 255 and red > 235 and green > 235 and blue > 235
|
||||
if alpha == 0 or chroma_residue or light_fringe:
|
||||
cleaned.append((0, 0, 0, 0))
|
||||
else:
|
||||
cleaned.append((red, green, blue, alpha))
|
||||
output = Image.new("RGBA", image.size, (0, 0, 0, 0))
|
||||
output.putdata(cleaned)
|
||||
return output
|
||||
|
||||
|
||||
def reduce_png_palette(image: Image.Image, colors: int = 192) -> Image.Image:
|
||||
alpha = image.getchannel("A")
|
||||
rgb = image.convert("RGB").quantize(
|
||||
colors=colors,
|
||||
method=Image.Quantize.MEDIANCUT,
|
||||
dither=Image.Dither.NONE,
|
||||
).convert("RGB")
|
||||
output = rgb.convert("RGBA")
|
||||
output.putalpha(alpha)
|
||||
return sanitize_output_edges(output)
|
||||
|
||||
|
||||
def parse_hex_color(value: str) -> tuple[int, int, int]:
|
||||
if len(value) != 7 or not value.startswith("#"):
|
||||
raise ValueError(f"invalid RGB hex color: {value}")
|
||||
return tuple(int(value[index:index + 2], 16) for index in (1, 3, 5))
|
||||
|
||||
|
||||
def build_asset(asset: dict, workspace: Path) -> None:
|
||||
processing = asset["processing"]
|
||||
alpha = asset["alpha"]
|
||||
pixels = asset["outputPixels"]
|
||||
source_path = workspace / asset["source"]
|
||||
output_path = workspace / asset["output"]
|
||||
|
||||
if processing["mode"] != "chroma-stretch":
|
||||
raise ValueError(f"unsupported scroll processing mode: {processing['mode']}")
|
||||
|
||||
with Image.open(source_path) as source:
|
||||
expected = asset["sourcePixels"]
|
||||
if source.size != (expected["width"], expected["height"]):
|
||||
raise ValueError(
|
||||
f"{asset['id']} source expected {expected['width']}x{expected['height']}, "
|
||||
f"got {source.width}x{source.height}"
|
||||
)
|
||||
cleaned = remove_chroma_background(
|
||||
source,
|
||||
parse_hex_color(processing["keyColor"]),
|
||||
int(processing["keyTolerance"]),
|
||||
)
|
||||
artwork = trim_transparent(cleaned)
|
||||
output = stretch_safe_center(
|
||||
artwork,
|
||||
output_size=(pixels["width"], pixels["height"]),
|
||||
cap_width=int(processing["capWidth"]),
|
||||
padding=int(alpha["transparentOuterPadding"]),
|
||||
)
|
||||
palette_colors = int(processing.get("paletteColors", 192))
|
||||
output = sanitize_output_edges(output)
|
||||
if processing.get("indexedPng"):
|
||||
output = output.quantize(
|
||||
colors=palette_colors,
|
||||
method=Image.Quantize.FASTOCTREE,
|
||||
dither=Image.Dither.NONE,
|
||||
)
|
||||
else:
|
||||
output = reduce_png_palette(output, colors=palette_colors)
|
||||
|
||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
png_info = PngImagePlugin.PngInfo()
|
||||
png_info.add(b"sRGB", b"\x00")
|
||||
output.save(output_path, format="PNG", optimize=True, pnginfo=png_info)
|
||||
print(f"BUILT {asset['id']} -> {asset['output']}")
|
||||
|
||||
|
||||
def build_manifest(manifest_path: Path, workspace: Path) -> None:
|
||||
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
|
||||
for asset in manifest["assets"]:
|
||||
build_asset(asset, workspace)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("manifest", type=Path)
|
||||
parser.add_argument("--workspace", type=Path, required=True)
|
||||
args = parser.parse_args()
|
||||
build_manifest(args.manifest.resolve(), args.workspace.resolve())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,74 +0,0 @@
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { spawnSync } from 'node:child_process'
|
||||
|
||||
function canRunPython(executable) {
|
||||
const result = spawnSync(executable, ['--version'], {
|
||||
encoding: 'utf8',
|
||||
stdio: 'ignore',
|
||||
})
|
||||
return !result.error && result.status === 0
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析设计构建管线唯一可用的 Python 入口。
|
||||
*
|
||||
* 顺序必须保持稳定:显式配置 > 项目虚拟环境 > Windows Python Manager >
|
||||
* 系统命令。这样既尊重调用方选择,也不会把 WindowsApps 的零字节执行别名
|
||||
* 误判为真实解释器。每个候选都必须实际执行 `--version`,文件存在本身不算可用。
|
||||
*/
|
||||
export function resolvePythonExecutable({
|
||||
pipelineDirectory,
|
||||
environment = process.env,
|
||||
platform = process.platform,
|
||||
pathExists = fs.existsSync,
|
||||
canRun = canRunPython,
|
||||
} = {}) {
|
||||
if (!pipelineDirectory) throw new Error('解析 Python 入口时缺少 design-pipeline 目录')
|
||||
|
||||
const configured = environment.PYTHON?.trim()
|
||||
if (configured) {
|
||||
if (canRun(configured)) return configured
|
||||
throw new Error(`PYTHON 指定的解释器不可用:${configured}`)
|
||||
}
|
||||
|
||||
const pathApi = platform === 'win32' ? path.win32 : path.posix
|
||||
const candidates = []
|
||||
const virtualEnvironment = platform === 'win32'
|
||||
? pathApi.join(pipelineDirectory, '.venv', 'Scripts', 'python.exe')
|
||||
: pathApi.join(pipelineDirectory, '.venv', 'bin', 'python')
|
||||
if (pathExists(virtualEnvironment)) candidates.push(virtualEnvironment)
|
||||
|
||||
if (platform === 'win32' && environment.LOCALAPPDATA) {
|
||||
const managerPython = pathApi.join(environment.LOCALAPPDATA, 'Python', 'bin', 'python.exe')
|
||||
if (pathExists(managerPython)) candidates.push(managerPython)
|
||||
}
|
||||
|
||||
candidates.push(platform === 'win32' ? 'python' : 'python3', 'python')
|
||||
for (const candidate of new Set(candidates)) {
|
||||
if (canRun(candidate)) return candidate
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
'未找到可用的 Python。请设置 PYTHON 为真实解释器路径,或在 design-pipeline/.venv 中安装项目虚拟环境。',
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 通过统一入口执行 Python,保证任何构建和测试都不会向源码目录写入 `.pyc`。
|
||||
* 调用方只提供业务参数;`-B`、进程错误和退出码转换由这里集中负责。
|
||||
*/
|
||||
export function runPythonCommand({ executable, args, cwd, spawn = spawnSync } = {}) {
|
||||
if (typeof executable !== 'string' || executable.trim() === '') throw new Error('执行 Python 时缺少解释器')
|
||||
if (!Array.isArray(args)) throw new Error('执行 Python 时 args 必须为数组')
|
||||
if (typeof cwd !== 'string' || cwd.trim() === '') throw new Error('执行 Python 时缺少工作目录')
|
||||
|
||||
const result = spawn(executable, ['-B', ...args], {
|
||||
cwd,
|
||||
encoding: 'utf8',
|
||||
stdio: 'inherit',
|
||||
})
|
||||
if (result.error) throw new Error(`无法启动 Python(${executable}):${result.error.message}`)
|
||||
if (result.status !== 0) throw new Error(`Python 命令执行失败,退出码:${result.status ?? 'unknown'}`)
|
||||
return result
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
import { resolvePythonExecutable, runPythonCommand } from './python-runtime.mjs'
|
||||
|
||||
const scriptDirectory = path.dirname(fileURLToPath(import.meta.url))
|
||||
const pipelineDirectory = path.resolve(scriptDirectory, '..')
|
||||
const python = resolvePythonExecutable({ pipelineDirectory })
|
||||
|
||||
runPythonCommand({
|
||||
executable: python,
|
||||
args: ['-m', 'unittest', 'discover', '-s', 'tests', '-p', 'test_*.py'],
|
||||
cwd: pipelineDirectory,
|
||||
})
|
||||
@@ -1,9 +1,8 @@
|
||||
import { createHash } from 'node:crypto'
|
||||
import { readFile, readdir } from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
|
||||
import { validateAssetBuildManifest } from './asset-build-manifest.mjs'
|
||||
|
||||
const formalManifestKinds = new Set(['asset-build-manifest', 'runtime-asset-inventory'])
|
||||
const formalManifestKinds = new Set(['runtime-asset-inventory'])
|
||||
const assertOnlyFields = (value, fields, label) => {
|
||||
for (const field of Object.keys(value)) {
|
||||
if (!fields.has(field)) throw new Error(`${label} contains unknown field: ${field}`)
|
||||
@@ -88,25 +87,7 @@ export const expandRuntimeAssetInventory = async (rootManifestPath, workspace) =
|
||||
manifests.push(absolutePath)
|
||||
const manifest = await readManifest(absolutePath)
|
||||
|
||||
if (manifest.kind === 'asset-build-manifest') {
|
||||
validateAssetBuildManifest(manifest, workspacePath)
|
||||
for (const asset of manifest.assets) {
|
||||
addAsset(
|
||||
{
|
||||
id: asset.id,
|
||||
output: asset.output,
|
||||
width: asset.outputPixels.width,
|
||||
height: asset.outputPixels.height,
|
||||
// 不透明构建模式依法不声明 alpha;只有透明模式存在该对象,避免把“字段缺失”误判为清单损坏。
|
||||
alpha: asset.alpha?.required ?? false,
|
||||
maxBytes: asset.quality.maxBytes,
|
||||
provenance: 'generated-from-manifest',
|
||||
rebuildable: true,
|
||||
},
|
||||
absolutePath,
|
||||
)
|
||||
}
|
||||
} else if (manifest.kind === 'runtime-asset-inventory') {
|
||||
if (manifest.kind === 'runtime-asset-inventory') {
|
||||
assertOnlyFields(manifest, new Set(['schemaVersion', 'kind', 'scope', 'imports', 'assets']), 'runtime manifest')
|
||||
if (manifest.schemaVersion !== 1) throw new Error('runtime inventory schemaVersion must be 1')
|
||||
if (typeof manifest.scope !== 'string' || manifest.scope.trim() === '') throw new Error('runtime inventory scope is required')
|
||||
@@ -136,6 +117,23 @@ export const validateRuntimeAssetRegistry = async (rootManifestPath, workspace,
|
||||
const inventory = await expandRuntimeAssetInventory(rootManifestPath, workspacePath)
|
||||
const registered = new Set(inventory.manifests.map((manifest) => path.resolve(manifest)))
|
||||
|
||||
for (const asset of inventory.assets) {
|
||||
let binary
|
||||
try {
|
||||
binary = await readFile(resolveInsideWorkspace(workspacePath, asset.output, `${asset.id}.output`))
|
||||
} catch (error) {
|
||||
if (error.code === 'ENOENT') throw new Error(`missing runtime asset: ${asset.output}`)
|
||||
throw error
|
||||
}
|
||||
if (binary.length !== asset.bytes) {
|
||||
throw new Error(`${asset.id}.bytes does not match ${asset.output}`)
|
||||
}
|
||||
const digest = createHash('sha256').update(binary).digest('hex')
|
||||
if (digest !== asset.sha256) {
|
||||
throw new Error(`${asset.id}.sha256 does not match ${asset.output}`)
|
||||
}
|
||||
}
|
||||
|
||||
// 顶层注册表必须覆盖目录内每一份正式 owner。这样新增清单若没有接入全局图会立即失败,
|
||||
// output 与 id 的唯一性也就不再局限于某个业务域的 imports 闭包。
|
||||
for (const entry of await readdir(directory, { withFileTypes: true })) {
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
import { validateAssetBuildManifest } from './asset-build-manifest.mjs'
|
||||
|
||||
const scriptDirectory = path.dirname(fileURLToPath(import.meta.url))
|
||||
const workspace = path.resolve(scriptDirectory, '..', '..')
|
||||
const manifestArgument = process.argv[2]
|
||||
if (!manifestArgument) throw new Error('Usage: node validate-asset-build-manifest.mjs <workspace-relative-manifest>')
|
||||
|
||||
const manifestPath = path.resolve(workspace, manifestArgument)
|
||||
const manifest = JSON.parse(await readFile(manifestPath, 'utf8'))
|
||||
validateAssetBuildManifest(manifest, workspace)
|
||||
process.stdout.write(`ASSET-BUILD-MANIFEST PASS ${path.relative(workspace, manifestPath).replaceAll('\\', '/')}\n`)
|
||||
@@ -13,4 +13,4 @@ const inventory = await validateRuntimeAssetRegistry(
|
||||
workspace,
|
||||
path.join(workspace, 'design-pipeline', 'manifests'),
|
||||
)
|
||||
process.stdout.write(`${JSON.stringify(inventory)}\n`)
|
||||
process.stdout.write(`RUNTIME ASSET CHECK PASS manifests=${inventory.manifests.length} assets=${inventory.assets.length}\n`)
|
||||
|
||||
@@ -1,47 +0,0 @@
|
||||
"""校验 schema v3 生成型资产,并写出不含机器绝对路径的确定性报告。"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from asset_quality import analyze_asset
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("manifest", type=Path)
|
||||
parser.add_argument("--workspace", type=Path, required=True)
|
||||
parser.add_argument("--report", type=Path, required=True)
|
||||
args = parser.parse_args()
|
||||
|
||||
manifest = json.loads(args.manifest.read_text(encoding="utf-8"))
|
||||
reports = []
|
||||
failed = False
|
||||
for asset in manifest["assets"]:
|
||||
output = args.workspace / asset["output"]
|
||||
if not output.is_file():
|
||||
report = {
|
||||
"id": asset["id"],
|
||||
"path": Path(asset["output"]).as_posix(),
|
||||
"errors": ["output file is missing"],
|
||||
"warnings": [],
|
||||
"metrics": {},
|
||||
}
|
||||
else:
|
||||
report = analyze_asset(output, asset, args.workspace)
|
||||
reports.append(report)
|
||||
if report["errors"]:
|
||||
failed = True
|
||||
print(f"ASSET-QUALITY FAIL {asset['id']}: {'; '.join(report['errors'])}")
|
||||
else:
|
||||
print(f"ASSET-QUALITY PASS {asset['id']}")
|
||||
|
||||
args.report.parent.mkdir(parents=True, exist_ok=True)
|
||||
args.report.write_text(json.dumps(reports, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
if failed:
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,21 +0,0 @@
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
import { validateAssetBuildManifest } from './asset-build-manifest.mjs'
|
||||
import { resolvePythonExecutable, runPythonCommand } from './python-runtime.mjs'
|
||||
|
||||
const scriptDirectory = path.dirname(fileURLToPath(import.meta.url))
|
||||
const pipelineDirectory = path.resolve(scriptDirectory, '..')
|
||||
const workspace = path.resolve(pipelineDirectory, '..')
|
||||
const manifestPath = path.join(pipelineDirectory, 'manifests', 'shared-scroll-skins-v3.json')
|
||||
const reportPath = path.join(pipelineDirectory, 'generated', 'shared-scroll-skins-v3', 'quality-report.json')
|
||||
const python = resolvePythonExecutable({ pipelineDirectory })
|
||||
const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'))
|
||||
|
||||
validateAssetBuildManifest(manifest, workspace)
|
||||
runPythonCommand({
|
||||
executable: python,
|
||||
args: [path.join(scriptDirectory, 'verify-assets.py'), manifestPath, '--workspace', workspace, '--report', reportPath],
|
||||
cwd: workspace,
|
||||
})
|
||||
@@ -1,131 +0,0 @@
|
||||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
import { validateAssetBuildManifest } from '../scripts/asset-build-manifest.mjs'
|
||||
|
||||
const testDirectory = path.dirname(fileURLToPath(import.meta.url))
|
||||
const workspace = path.resolve(testDirectory, '..', '..')
|
||||
|
||||
const baseAsset = () => ({
|
||||
id: 'test-asset',
|
||||
source: 'docs/design/assets/source.png',
|
||||
output: 'static/assets/output.png',
|
||||
sourcePixels: { width: 1536, height: 3840 },
|
||||
outputPixels: { width: 1440, height: 3600 },
|
||||
quality: { maxBytes: 7000000, colorSpace: 'sRGB' },
|
||||
})
|
||||
|
||||
// schema v3 以 processing.mode 为严格判别字段。每个分支只允许自身真正消费的
|
||||
// 参数,避免给不透明背景伪造透明边、色键或切片字段来“凑齐”旧结构。
|
||||
const chromaAsset = () => ({
|
||||
...baseAsset(),
|
||||
alpha: { required: true, transparentOuterPadding: 6, cornerMaxAlpha: 0 },
|
||||
edge: { forbidChromaResidue: true, forbidLightFringe: true, premultipliedAlphaCheck: true },
|
||||
processing: {
|
||||
mode: 'chroma-stretch',
|
||||
capWidth: 380,
|
||||
keyColor: '#00FF00',
|
||||
keyTolerance: 96,
|
||||
},
|
||||
})
|
||||
|
||||
const opaqueResizeAsset = () => ({
|
||||
...baseAsset(),
|
||||
processing: { mode: 'opaque-resize', resample: 'lanczos', outputMode: 'RGB' },
|
||||
})
|
||||
|
||||
const opaqueCoverAsset = () => ({
|
||||
...baseAsset(),
|
||||
processing: {
|
||||
mode: 'opaque-cover-crop',
|
||||
resample: 'lanczos',
|
||||
anchor: 'center',
|
||||
outputMode: 'RGB',
|
||||
},
|
||||
})
|
||||
|
||||
const warmFrameAsset = () => ({
|
||||
...baseAsset(),
|
||||
alpha: { required: true, transparentOuterPadding: 0, cornerMaxAlpha: 0 },
|
||||
edge: { forbidChromaResidue: false, forbidLightFringe: false, premultipliedAlphaCheck: true },
|
||||
processing: {
|
||||
mode: 'warm-gold-frame-extract',
|
||||
borderBand: 110,
|
||||
redGreenMin: 15,
|
||||
greenBlueMin: 12,
|
||||
redBlueMin: 35,
|
||||
redMaxExclusive: 245,
|
||||
blueMaxExclusive: 180,
|
||||
alphaOffset: 25,
|
||||
alphaScale: 6,
|
||||
outputMode: 'RGBA',
|
||||
},
|
||||
})
|
||||
|
||||
const manifestWith = (asset) => ({
|
||||
schemaVersion: 3,
|
||||
kind: 'asset-build-manifest',
|
||||
family: 'test-family-v3',
|
||||
assets: [asset],
|
||||
})
|
||||
|
||||
test('接受四种职责严格分离的 schema v3 处理模式', () => {
|
||||
for (const factory of [chromaAsset, opaqueResizeAsset, opaqueCoverAsset, warmFrameAsset]) {
|
||||
const manifest = manifestWith(factory())
|
||||
assert.equal(validateAssetBuildManifest(manifest, workspace), manifest)
|
||||
}
|
||||
})
|
||||
|
||||
test('拒绝没有 processing.mode 的旧 schema v3 形状', () => {
|
||||
const asset = chromaAsset()
|
||||
delete asset.processing.mode
|
||||
assert.throws(() => validateAssetBuildManifest(manifestWith(asset), workspace), /processing\.mode/i)
|
||||
})
|
||||
|
||||
test('不透明分支拒绝透明度、边缘和其他模式的参数', () => {
|
||||
const withAlpha = opaqueResizeAsset()
|
||||
withAlpha.alpha = chromaAsset().alpha
|
||||
assert.throws(() => validateAssetBuildManifest(manifestWith(withAlpha), workspace), /unknown field/i)
|
||||
|
||||
const withAnchor = opaqueResizeAsset()
|
||||
withAnchor.processing.anchor = 'center'
|
||||
assert.throws(() => validateAssetBuildManifest(manifestWith(withAnchor), workspace), /unknown field/i)
|
||||
|
||||
const withColorKey = opaqueCoverAsset()
|
||||
withColorKey.processing.keyColor = '#00FF00'
|
||||
assert.throws(() => validateAssetBuildManifest(manifestWith(withColorKey), workspace), /unknown field/i)
|
||||
})
|
||||
|
||||
test('透明分支拒绝缺失 alpha/edge 及不属于自身的处理字段', () => {
|
||||
const missingAlpha = chromaAsset()
|
||||
delete missingAlpha.alpha
|
||||
assert.throws(() => validateAssetBuildManifest(manifestWith(missingAlpha), workspace), /alpha/i)
|
||||
|
||||
const missingEdge = warmFrameAsset()
|
||||
delete missingEdge.edge
|
||||
assert.throws(() => validateAssetBuildManifest(manifestWith(missingEdge), workspace), /edge/i)
|
||||
|
||||
const wrongProcessing = warmFrameAsset()
|
||||
wrongProcessing.processing.capWidth = 380
|
||||
assert.throws(() => validateAssetBuildManifest(manifestWith(wrongProcessing), workspace), /unknown field/i)
|
||||
})
|
||||
|
||||
test('拒绝重复输出、越界路径、未知字段和未知模式', () => {
|
||||
const duplicate = manifestWith(chromaAsset())
|
||||
duplicate.assets.push({ ...chromaAsset(), id: 'duplicate-output' })
|
||||
assert.throws(() => validateAssetBuildManifest(duplicate, workspace), /duplicate output/i)
|
||||
|
||||
const escaped = manifestWith(opaqueResizeAsset())
|
||||
escaped.assets[0].output = '../outside.png'
|
||||
assert.throws(() => validateAssetBuildManifest(escaped, workspace), /escapes workspace/i)
|
||||
|
||||
const unknownField = manifestWith(opaqueResizeAsset())
|
||||
unknownField.assets[0].logicalSlot = 'page'
|
||||
assert.throws(() => validateAssetBuildManifest(unknownField, workspace), /unknown field/i)
|
||||
|
||||
const unknownMode = manifestWith(opaqueResizeAsset())
|
||||
unknownMode.assets[0].processing.mode = 'future-magic'
|
||||
assert.throws(() => validateAssetBuildManifest(unknownMode, workspace), /unsupported processing\.mode/i)
|
||||
})
|
||||
@@ -1,68 +0,0 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import path from 'node:path'
|
||||
import test from 'node:test'
|
||||
|
||||
import { resolvePythonExecutable, runPythonCommand } from '../scripts/python-runtime.mjs'
|
||||
|
||||
test('显式 PYTHON 配置优先于所有自动发现路径', () => {
|
||||
const selected = resolvePythonExecutable({
|
||||
pipelineDirectory: 'C:\\repo\\design-pipeline',
|
||||
environment: {
|
||||
PYTHON: 'D:\\tools\\python.exe',
|
||||
LOCALAPPDATA: 'C:\\Users\\tester\\AppData\\Local',
|
||||
},
|
||||
platform: 'win32',
|
||||
pathExists: () => true,
|
||||
canRun: candidate => candidate === 'D:\\tools\\python.exe',
|
||||
})
|
||||
|
||||
assert.equal(selected, 'D:\\tools\\python.exe')
|
||||
})
|
||||
|
||||
test('Windows 环境在虚拟环境缺失时选择 Python Manager 的真实入口', () => {
|
||||
const managerPython = path.win32.join(
|
||||
'C:\\Users\\tester\\AppData\\Local',
|
||||
'Python',
|
||||
'bin',
|
||||
'python.exe',
|
||||
)
|
||||
const selected = resolvePythonExecutable({
|
||||
pipelineDirectory: 'C:\\repo\\design-pipeline',
|
||||
environment: { LOCALAPPDATA: 'C:\\Users\\tester\\AppData\\Local' },
|
||||
platform: 'win32',
|
||||
pathExists: candidate => candidate === managerPython,
|
||||
canRun: candidate => candidate === managerPython,
|
||||
})
|
||||
|
||||
assert.equal(selected, managerPython)
|
||||
})
|
||||
|
||||
test('所有候选解释器均不可用时给出可执行的中文修复指引', () => {
|
||||
assert.throws(
|
||||
() => resolvePythonExecutable({
|
||||
pipelineDirectory: 'C:\\repo\\design-pipeline',
|
||||
environment: {},
|
||||
platform: 'win32',
|
||||
pathExists: () => false,
|
||||
canRun: () => false,
|
||||
}),
|
||||
/未找到可用的 Python.*PYTHON.*\.venv/s,
|
||||
)
|
||||
})
|
||||
|
||||
test('统一执行器始终把禁止字节码缓存参数放在首位', () => {
|
||||
let invocation
|
||||
runPythonCommand({
|
||||
executable: 'python-test',
|
||||
args: ['script.py', '--flag'],
|
||||
cwd: 'C:\\repo',
|
||||
spawn: (command, args, options) => {
|
||||
invocation = { command, args, options }
|
||||
return { status: 0 }
|
||||
},
|
||||
})
|
||||
|
||||
assert.equal(invocation.command, 'python-test')
|
||||
assert.deepEqual(invocation.args, ['-B', 'script.py', '--flag'])
|
||||
assert.equal(invocation.options.cwd, 'C:\\repo')
|
||||
})
|
||||
@@ -1,65 +0,0 @@
|
||||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
import { validateAssetBuildManifest } from '../scripts/asset-build-manifest.mjs'
|
||||
|
||||
const testDirectory = path.dirname(fileURLToPath(import.meta.url))
|
||||
const pipelineDirectory = path.resolve(testDirectory, '..')
|
||||
const workspace = path.resolve(pipelineDirectory, '..')
|
||||
|
||||
const readManifest = async (name) => JSON.parse(await readFile(
|
||||
path.join(pipelineDirectory, 'manifests', name),
|
||||
'utf8',
|
||||
))
|
||||
|
||||
test('长页面背景只由一份清单拥有六张正式输出', async () => {
|
||||
const manifest = await readManifest('page-backgrounds-v3.json')
|
||||
validateAssetBuildManifest(manifest, workspace)
|
||||
|
||||
assert.equal(manifest.family, 'page-backgrounds-v3')
|
||||
assert.deepEqual(
|
||||
manifest.assets.map(({ id }) => id),
|
||||
[
|
||||
'genealogy-page-background-long',
|
||||
'tree-page-background-long',
|
||||
'family-page-background-long',
|
||||
'records-page-background-long',
|
||||
'notification-page-background-long',
|
||||
'profile-page-background-long',
|
||||
],
|
||||
)
|
||||
assert.equal(manifest.assets[0].processing.mode, 'opaque-resize')
|
||||
for (const asset of manifest.assets.slice(1)) {
|
||||
assert.equal(asset.processing.mode, 'opaque-cover-crop')
|
||||
}
|
||||
})
|
||||
|
||||
test('G01 空态边框拥有独立的暖金边框提取清单', async () => {
|
||||
const manifest = await readManifest('g01-state-frame-v3.json')
|
||||
validateAssetBuildManifest(manifest, workspace)
|
||||
|
||||
assert.equal(manifest.family, 'g01-state-frame-v3')
|
||||
assert.equal(manifest.assets.length, 1)
|
||||
assert.equal(manifest.assets[0].id, 'g01-empty-panel-frame')
|
||||
assert.equal(manifest.assets[0].processing.mode, 'warm-gold-frame-extract')
|
||||
assert.equal(manifest.assets[0].processing.borderBand, 110)
|
||||
})
|
||||
|
||||
test('新构建入口不再保留候选构建和 Sharp 专用命令', async () => {
|
||||
const packageJson = JSON.parse(await readFile(path.join(pipelineDirectory, 'package.json'), 'utf8'))
|
||||
assert.equal(
|
||||
packageJson.scripts['build:page-backgrounds'],
|
||||
'node scripts/build-raster-assets.mjs design-pipeline/manifests/page-backgrounds-v3.json',
|
||||
)
|
||||
assert.equal(
|
||||
packageJson.scripts['build:g01-state-frame'],
|
||||
'node scripts/build-raster-assets.mjs design-pipeline/manifests/g01-state-frame-v3.json',
|
||||
)
|
||||
assert.equal(packageJson.scripts['build:g01-background-candidates'], undefined)
|
||||
assert.equal(packageJson.scripts['build:module-page-backgrounds'], undefined)
|
||||
assert.equal(packageJson.scripts['build:g01-empty-frame'], undefined)
|
||||
assert.equal(packageJson.dependencies?.sharp, undefined)
|
||||
})
|
||||
@@ -40,19 +40,21 @@ test('顶层注册表拒绝未进入导入闭包的正式 owner', async (t) => {
|
||||
)
|
||||
})
|
||||
|
||||
test('真实 schema v3 注册表覆盖直接资产与三类正式生成 owner', async () => {
|
||||
test('真实 schema v3 注册表覆盖应用资产与保留生成资产', async () => {
|
||||
const registry = JSON.parse(await readFile(path.join(realManifestsDirectory, 'runtime-assets.json'), 'utf8'))
|
||||
const auth = JSON.parse(await readFile(path.join(realManifestsDirectory, 'auth-runtime-assets.json'), 'utf8'))
|
||||
const retained = JSON.parse(await readFile(path.join(realManifestsDirectory, 'retained-generated-runtime-assets.json'), 'utf8'))
|
||||
|
||||
assert.equal(registry.scope, 'schema-v3')
|
||||
assert.deepEqual(registry.imports, [
|
||||
'design-pipeline/manifests/auth-runtime-assets.json',
|
||||
'design-pipeline/manifests/application-runtime-assets.json',
|
||||
'design-pipeline/manifests/shared-scroll-skins-v3.json',
|
||||
'design-pipeline/manifests/page-backgrounds-v3.json',
|
||||
'design-pipeline/manifests/g01-state-frame-v3.json',
|
||||
'design-pipeline/manifests/retained-generated-runtime-assets.json',
|
||||
])
|
||||
assert.deepEqual(auth.imports, [])
|
||||
assert.equal(retained.assets.length, 11)
|
||||
assert(retained.assets.every((asset) => asset.provenance === 'committed-binary'))
|
||||
assert(retained.assets.every((asset) => asset.rebuildable === false))
|
||||
await validateRuntimeAssetRegistry(
|
||||
path.join(realManifestsDirectory, 'runtime-assets.json'),
|
||||
workspaceDirectory,
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
import { validateAssetBuildManifest } from '../scripts/asset-build-manifest.mjs'
|
||||
|
||||
const testDirectory = path.dirname(fileURLToPath(import.meta.url))
|
||||
const pipelineDirectory = path.resolve(testDirectory, '..')
|
||||
const workspace = path.resolve(pipelineDirectory, '..')
|
||||
const manifestPath = path.join(pipelineDirectory, 'manifests', 'shared-scroll-skins-v3.json')
|
||||
|
||||
test('共享卷轴清单只维护四张正式生成资产', async () => {
|
||||
const manifest = JSON.parse(await readFile(manifestPath, 'utf8'))
|
||||
validateAssetBuildManifest(manifest, workspace)
|
||||
|
||||
assert.equal(manifest.kind, 'asset-build-manifest')
|
||||
assert.equal(manifest.family, 'shared-scroll-skins-v3')
|
||||
assert.deepEqual(
|
||||
manifest.assets.map(({ id }) => id),
|
||||
['shared-scroll-primary-v3', 'shared-scroll-secondary-v3', 'shared-scroll-toast-v3', 'shared-scroll-dialog-v3'],
|
||||
)
|
||||
for (const asset of manifest.assets) {
|
||||
assert.deepEqual(
|
||||
Object.keys(asset).sort(),
|
||||
['alpha', 'edge', 'id', 'output', 'outputPixels', 'processing', 'quality', 'source', 'sourcePixels'],
|
||||
)
|
||||
assert.equal(asset.processing.mode, 'chroma-stretch')
|
||||
}
|
||||
})
|
||||
@@ -1,109 +0,0 @@
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from PIL import Image, PngImagePlugin
|
||||
|
||||
SCRIPTS = Path(__file__).resolve().parents[1] / "scripts"
|
||||
sys.path.insert(0, str(SCRIPTS))
|
||||
|
||||
from asset_quality import analyze_asset
|
||||
|
||||
|
||||
class AssetQualityTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.directory = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self.directory.cleanup)
|
||||
self.root = Path(self.directory.name)
|
||||
|
||||
def spec(self):
|
||||
return {
|
||||
"id": "test-button",
|
||||
"outputPixels": {"width": 32, "height": 16},
|
||||
"alpha": {"required": True, "transparentOuterPadding": 2, "cornerMaxAlpha": 0},
|
||||
"edge": {
|
||||
"forbidChromaResidue": True,
|
||||
"forbidLightFringe": True,
|
||||
"premultipliedAlphaCheck": True,
|
||||
},
|
||||
"quality": {"maxBytes": 10000, "colorSpace": "sRGB"},
|
||||
}
|
||||
|
||||
def clean_image(self):
|
||||
image = Image.new("RGBA", (32, 16), (0, 0, 0, 0))
|
||||
for y in range(2, 14):
|
||||
for x in range(2, 30):
|
||||
image.putpixel((x, y), (150, 20, 12, 255))
|
||||
return image
|
||||
|
||||
def save(self, image, name="asset.png", include_srgb=True):
|
||||
path = self.root / name
|
||||
png_info = PngImagePlugin.PngInfo()
|
||||
if include_srgb:
|
||||
png_info.add(b"sRGB", b"\x00")
|
||||
image.save(path, format="PNG", pnginfo=png_info)
|
||||
return path
|
||||
|
||||
def error_text(self, report):
|
||||
return " ".join(report["errors"])
|
||||
|
||||
def test_accepts_clean_rgba_asset(self):
|
||||
report = analyze_asset(self.save(self.clean_image()), self.spec(), self.root)
|
||||
self.assertEqual([], report["errors"])
|
||||
self.assertEqual(32, report["width"])
|
||||
self.assertEqual(16, report["height"])
|
||||
self.assertEqual("asset.png", report["path"])
|
||||
|
||||
def test_accepts_indexed_png_with_real_transparency(self):
|
||||
indexed = self.clean_image().quantize(
|
||||
colors=16,
|
||||
method=Image.Quantize.FASTOCTREE,
|
||||
dither=Image.Dither.NONE,
|
||||
)
|
||||
report = analyze_asset(self.save(indexed), self.spec(), self.root)
|
||||
self.assertEqual([], report["errors"])
|
||||
self.assertEqual("P", report["mode"])
|
||||
|
||||
def test_rejects_wrong_dimensions(self):
|
||||
report = analyze_asset(self.save(Image.new("RGBA", (31, 16), (0, 0, 0, 0))), self.spec(), self.root)
|
||||
self.assertIn("dimensions", self.error_text(report))
|
||||
|
||||
def test_rejects_opaque_outer_padding(self):
|
||||
image = self.clean_image()
|
||||
image.putpixel((0, 0), (120, 30, 20, 255))
|
||||
report = analyze_asset(self.save(image), self.spec(), self.root)
|
||||
self.assertIn("outer padding", self.error_text(report))
|
||||
|
||||
def test_rejects_visible_green_residue(self):
|
||||
image = self.clean_image()
|
||||
image.putpixel((16, 8), (0, 255, 0, 255))
|
||||
report = analyze_asset(self.save(image), self.spec(), self.root)
|
||||
self.assertIn("chroma residue", self.error_text(report))
|
||||
|
||||
def test_rejects_light_partially_transparent_fringe(self):
|
||||
image = self.clean_image()
|
||||
image.putpixel((2, 8), (250, 250, 250, 128))
|
||||
report = analyze_asset(self.save(image), self.spec(), self.root)
|
||||
self.assertIn("light fringe", self.error_text(report))
|
||||
|
||||
def test_rejects_hidden_rgb_in_fully_transparent_pixels(self):
|
||||
image = self.clean_image()
|
||||
image.putpixel((0, 0), (255, 255, 255, 0))
|
||||
report = analyze_asset(self.save(image), self.spec(), self.root)
|
||||
self.assertIn("transparent RGB", self.error_text(report))
|
||||
|
||||
def test_rejects_file_over_max_bytes(self):
|
||||
spec = self.spec()
|
||||
spec["quality"]["maxBytes"] = 8
|
||||
report = analyze_asset(self.save(self.clean_image()), spec, self.root)
|
||||
self.assertIn("maxBytes", self.error_text(report))
|
||||
|
||||
def test_rejects_missing_declared_srgb_metadata(self):
|
||||
path = self.save(self.clean_image(), include_srgb=False)
|
||||
report = analyze_asset(path, self.spec(), self.root)
|
||||
self.assertIn("sRGB", self.error_text(report))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,85 +0,0 @@
|
||||
import hashlib
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from PIL import Image
|
||||
|
||||
SCRIPTS = Path(__file__).resolve().parents[1] / "scripts"
|
||||
sys.path.insert(0, str(SCRIPTS))
|
||||
|
||||
from build_raster_assets import (
|
||||
build_opaque_cover_crop,
|
||||
build_opaque_resize,
|
||||
extract_warm_gold_frame,
|
||||
save_png,
|
||||
)
|
||||
|
||||
|
||||
class RasterAssetBuilderTests(unittest.TestCase):
|
||||
"""锁定迁移后的两类背景处理与 G01 暖金边框提取语义。"""
|
||||
|
||||
def test_opaque_resize_uses_locked_output_size_and_rgb(self):
|
||||
source = Image.new("RGBA", (2, 3), (120, 80, 40, 128))
|
||||
output = build_opaque_resize(source, (4, 6))
|
||||
self.assertEqual((4, 6), output.size)
|
||||
self.assertEqual("RGB", output.mode)
|
||||
|
||||
def test_cover_crop_centers_the_scaled_source(self):
|
||||
source = Image.new("RGB", (2, 1), (255, 0, 0))
|
||||
source.putpixel((1, 0), (0, 0, 255))
|
||||
output = build_opaque_cover_crop(source, (2, 2))
|
||||
self.assertEqual((2, 2), output.size)
|
||||
self.assertEqual("RGB", output.mode)
|
||||
self.assertNotEqual(output.getpixel((0, 0)), output.getpixel((1, 0)))
|
||||
|
||||
def test_warm_gold_frame_keeps_only_the_edge_band(self):
|
||||
source = Image.new("RGBA", (5, 5), (180, 140, 80, 255))
|
||||
config = {
|
||||
"borderBand": 1,
|
||||
"redGreenMin": 15,
|
||||
"greenBlueMin": 12,
|
||||
"redBlueMin": 35,
|
||||
"redMaxExclusive": 245,
|
||||
"blueMaxExclusive": 180,
|
||||
"alphaOffset": 25,
|
||||
"alphaScale": 6,
|
||||
}
|
||||
output = extract_warm_gold_frame(source, config)
|
||||
self.assertGreater(output.getpixel((0, 2))[3], 0)
|
||||
self.assertEqual((0, 0, 0, 0), output.getpixel((2, 2)))
|
||||
|
||||
def test_warm_gold_frame_rejects_non_gold_and_clears_hidden_rgb(self):
|
||||
source = Image.new("RGBA", (1, 1), (100, 150, 100, 255))
|
||||
config = {
|
||||
"borderBand": 1,
|
||||
"redGreenMin": 15,
|
||||
"greenBlueMin": 12,
|
||||
"redBlueMin": 35,
|
||||
"redMaxExclusive": 245,
|
||||
"blueMaxExclusive": 180,
|
||||
"alphaOffset": 25,
|
||||
"alphaScale": 6,
|
||||
}
|
||||
output = extract_warm_gold_frame(source, config)
|
||||
self.assertEqual((0, 0, 0, 0), output.getpixel((0, 0)))
|
||||
|
||||
def test_png_save_is_srgb_and_byte_deterministic(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
first = Path(directory) / "first.png"
|
||||
second = Path(directory) / "second.png"
|
||||
image = Image.new("RGB", (4, 4), (230, 220, 200))
|
||||
save_png(image, first)
|
||||
save_png(image, second)
|
||||
self.assertEqual(first.read_bytes(), second.read_bytes())
|
||||
self.assertEqual(
|
||||
hashlib.sha256(first.read_bytes()).hexdigest(),
|
||||
hashlib.sha256(second.read_bytes()).hexdigest(),
|
||||
)
|
||||
with Image.open(first) as opened:
|
||||
self.assertIn("srgb", opened.info)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,115 +0,0 @@
|
||||
import hashlib
|
||||
import json
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from PIL import Image
|
||||
|
||||
SCRIPTS = Path(__file__).resolve().parents[1] / "scripts"
|
||||
sys.path.insert(0, str(SCRIPTS))
|
||||
|
||||
from asset_quality import analyze_asset
|
||||
from build_scroll_skins import (
|
||||
build_asset,
|
||||
remove_chroma_background,
|
||||
sanitize_output_edges,
|
||||
stretch_safe_center,
|
||||
)
|
||||
|
||||
|
||||
class BuildScrollSkinsTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.directory = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self.directory.cleanup)
|
||||
self.root = Path(self.directory.name)
|
||||
|
||||
def test_chroma_background_becomes_transparent_black(self):
|
||||
image = Image.new("RGB", (4, 2), (0, 255, 0))
|
||||
image.putpixel((1, 0), (180, 30, 20))
|
||||
cleaned = remove_chroma_background(image, (0, 255, 0), tolerance=80)
|
||||
self.assertEqual((0, 0, 0, 0), cleaned.getpixel((0, 0)))
|
||||
self.assertEqual((180, 30, 20, 255), cleaned.getpixel((1, 0)))
|
||||
|
||||
def test_safe_center_stretch_preserves_caps_and_exact_size(self):
|
||||
image = Image.new("RGBA", (30, 10), (150, 20, 10, 255))
|
||||
for y in range(10):
|
||||
for x in range(5):
|
||||
image.putpixel((x, y), (220, 170, 40, 255))
|
||||
image.putpixel((29 - x, y), (220, 170, 40, 255))
|
||||
|
||||
output = stretch_safe_center(image, output_size=(60, 20), cap_width=5, padding=2)
|
||||
self.assertEqual((60, 20), output.size)
|
||||
self.assertEqual((0, 0, 0, 0), output.getpixel((0, 0)))
|
||||
self.assertEqual((220, 170, 40, 255), output.getpixel((3, 10)))
|
||||
self.assertEqual((150, 20, 10, 255), output.getpixel((30, 10)))
|
||||
self.assertEqual((220, 170, 40, 255), output.getpixel((56, 10)))
|
||||
|
||||
def test_sanitizes_only_forbidden_edge_artifacts(self):
|
||||
image = Image.new("RGBA", (3, 1), (248, 240, 220, 255))
|
||||
image.putpixel((0, 0), (0, 255, 0, 128))
|
||||
image.putpixel((1, 0), (250, 250, 250, 96))
|
||||
cleaned = sanitize_output_edges(image)
|
||||
self.assertEqual((0, 0, 0, 0), cleaned.getpixel((0, 0)))
|
||||
self.assertEqual((0, 0, 0, 0), cleaned.getpixel((1, 0)))
|
||||
self.assertEqual((248, 240, 220, 255), cleaned.getpixel((2, 0)))
|
||||
|
||||
def asset(self, asset_id):
|
||||
"""返回与现行判别式 schema 一致的最小透明卷轴测试资产。"""
|
||||
return {
|
||||
"id": asset_id,
|
||||
"source": "source.png",
|
||||
"output": "output.png",
|
||||
"sourcePixels": {"width": 32, "height": 16},
|
||||
"outputPixels": {"width": 60, "height": 30},
|
||||
"alpha": {"required": True, "transparentOuterPadding": 1, "cornerMaxAlpha": 0},
|
||||
"edge": {
|
||||
"forbidChromaResidue": True,
|
||||
"forbidLightFringe": True,
|
||||
"premultipliedAlphaCheck": True,
|
||||
},
|
||||
"quality": {"maxBytes": 10000, "colorSpace": "sRGB"},
|
||||
"processing": {
|
||||
"mode": "chroma-stretch",
|
||||
"capWidth": 4,
|
||||
"keyColor": "#00FF00",
|
||||
"keyTolerance": 80,
|
||||
"paletteColors": 8,
|
||||
},
|
||||
}
|
||||
|
||||
def save_test_source(self):
|
||||
source = Image.new("RGB", (32, 16), (0, 255, 0))
|
||||
for y in range(1, 15):
|
||||
for x in range(1, 31):
|
||||
source.putpixel((x, y), (120 + (x % 30) * 4, 20 + y, 10))
|
||||
source.save(self.root / "source.png")
|
||||
|
||||
def test_build_asset_honors_declared_palette_size(self):
|
||||
self.save_test_source()
|
||||
build_asset(self.asset("palette-test"), self.root)
|
||||
with Image.open(self.root / "output.png") as output:
|
||||
visible_colors = {
|
||||
pixel[:3] for pixel in output.convert("RGBA").get_flattened_data() if pixel[3] > 0
|
||||
}
|
||||
self.assertLessEqual(len(visible_colors), 8)
|
||||
|
||||
def test_same_input_produces_identical_bytes_and_report_twice(self):
|
||||
self.save_test_source()
|
||||
asset = self.asset("deterministic-test")
|
||||
snapshots = []
|
||||
for _ in range(2):
|
||||
build_asset(asset, self.root)
|
||||
output = self.root / "output.png"
|
||||
snapshots.append(
|
||||
(
|
||||
hashlib.sha256(output.read_bytes()).hexdigest(),
|
||||
json.dumps(analyze_asset(output, asset, self.root), ensure_ascii=False, sort_keys=True),
|
||||
)
|
||||
)
|
||||
self.assertEqual(snapshots[0], snapshots[1])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,175 +0,0 @@
|
||||
# M09 VIP 与订单:设计核验
|
||||
|
||||
## 对比对象
|
||||
|
||||
- Source visual truth: `C:\Users\Administrator\.codex\generated_images\019fa647-03b4-7901-9b6a-175d07d56512\exec-f3e0331f-59c3-4f01-a48f-d069bdab2d58.png`
|
||||
- 同状态真机截图:`tmp/vip-redesign-audit/20-uniform-footer-cards.png`
|
||||
- 下单面板真机截图:`tmp/vip-redesign-audit/21-uniform-footer-sheet.png`
|
||||
- 并排对比:`tmp/vip-redesign-audit/22-uniform-main-reference-and-implementation.png`
|
||||
- 设备:MuMu Android 基座,720 × 1280(360 × 640 CSS px,device scale factor 2)。参考图裁去底部已露出的面板后,按同一高度归一化进行并排检查。
|
||||
|
||||
## 视觉验收
|
||||
|
||||
- 顶部:水墨山景、竹影、留白与居中的标题层级已落地;新增背景资产为 `static/assets/modules/profile/opaque/m09-vip-hero-landscape.png`。
|
||||
- 套餐:基础版、宗族版、永久纪念版使用同一主推卡片规格,均有同样的朱砂描边、庭院水墨资产、底部价格/期限栏与圆角“立即开通”按钮。
|
||||
- 卡片资产:使用 `static/assets/modules/profile/opaque/m09-featured-courtyard.png`,在三张卡上保持统一的透明度和裁切方式,文字与按钮在真实窄屏上保持可读。
|
||||
- 下单:原居中装饰弹窗替换为底部确认单;套餐、有效期、应付金额、微信支付方式和取消入口均清晰可见。
|
||||
|
||||
## 交互验收
|
||||
|
||||
- MuMu 真机已点击“立即开通”,成功打开“确认下单”底部面板。
|
||||
- 已点击“暂不购买”安全关闭面板;关闭后确认页面仍保留 3 个“立即开通”入口,未由这次视觉验收新增订单。
|
||||
- 套餐和订单数据仍由现有接口读取;本次只改页面结构、样式与图片资产,不改接口字段或订单创建逻辑。
|
||||
|
||||
## 已知合同边界
|
||||
|
||||
- 当前 OpenAPI 未提供第三方支付调起参数或支付结果回调,因此主操作保持“创建订单”,不虚假宣称已完成微信支付。
|
||||
|
||||
## 结果
|
||||
|
||||
final result: passed
|
||||
|
||||
---
|
||||
|
||||
# T01 世系谱:设计核验
|
||||
|
||||
## 对比对象
|
||||
|
||||
- Source visual truth:用户在本次会话提供的 `Jiapu-App` 世系谱截图(408 × 936 px),并已核对参考源码 `C:\Users\Administrator\Desktop\job\Jiapu-App\pages\index\tree\index.vue`。
|
||||
- Implementation screenshot:`tmp/t01-pedigree-paged-order-final2.png`(720 × 1280 px,MuMu Android;360 × 640 CSS px,density 2)。
|
||||
- 当前真实数据状态:已授权的“真机联调10159371”仅有 1 位成员;页面已显示一列始祖资料与右侧世代切换,不能据此核验多人横向滚动。
|
||||
|
||||
## Findings
|
||||
|
||||
- [P1] 当前授权家谱只有一位成员,无法以真实数据复核参考图的“多位同世成员横向展开”状态。
|
||||
- 影响:实际单列、世代切换和纵向文字已验证,但多人列宽、横向滚动和同代排序仍缺少真机证据。
|
||||
- 修复:使用至少 5 位同世成员的测试家谱再次打开 T01;不应以本地伪造数据替代真实家谱数据。
|
||||
|
||||
## 已完成的源码核验
|
||||
|
||||
- T01 使用现有 `appApi.getTree` 单一数据 owner,按 `generation` 分组后每 5 人补齐空列并使用 `swiper` 横向分页;不引入参考项目的旧 `getGenealogyUserByParentsId` 接口。
|
||||
- 列结构为关系、姓名、资料三段纵向阅读;同世人员位于横向滚动容器,右侧切换上一世/下一世。
|
||||
- 静态合同、关系布局合同、人物操作合同和树接口运行时 smoke 均已通过。
|
||||
|
||||
final result: blocked
|
||||
|
||||
---
|
||||
|
||||
# T01 relationship-compass redesign: MuMu comparison rerun
|
||||
|
||||
## Evidence
|
||||
|
||||
- Source visual truth: `C:\Users\Rain\.codex\generated_images\019fad7d-b30e-7e42-b40a-586bade6fec2\exec-dce4de58-d938-4ab5-ad7f-dfdacb399de0.png` (853 × 1844 px).
|
||||
- Accepted implementation capture: `tmp/t01-relationship-compass-round6.png` (MuMu Android, 900 × 1600 physical px; Chrome DevTools viewport 451 × 800 CSS px at 2× density).
|
||||
- Full-view comparison: `tmp/t01-relationship-compass-round6-comparison.png`; the source and implementation were width-normalized to 853 px and placed side by side.
|
||||
- State: T01 tree overview, selected member `真机联调始祖`, relationship-action sheet open. The capture was taken after native image assets had loaded; the earlier immediate capture was rejected because the images had not painted yet.
|
||||
|
||||
## Comparison history
|
||||
|
||||
1. Earlier comparison (`tmp/t01-relationship-compass-audit-compare.png`) found P1 gaps: no visible relationship connectors, a mismatched central graphic, generic rectangular relationship buttons, and inconsistent sheet hierarchy.
|
||||
- Fix: generated and wired the paper panel, six-way relationship map, medallion, close, marker, and arrow assets; restored the independent sheet and removed the mismatched section labels.
|
||||
2. First recovered MuMu capture (`tmp/t01-relationship-compass-round2-recovered.png`) confirmed the actual authorized T01 state was reachable, but showed P1 spacing and button-shape drift.
|
||||
- Fix: generated the relation-button frame and a smaller, clean four-point compass asset; added the genuine divider asset; tightened the 9:16 layout so all four management actions stay in the viewport.
|
||||
3. Final MuMu capture (`tmp/t01-relationship-compass-round6.png`) was compared in the same composite input. No actionable P0/P1/P2 mismatch remains.
|
||||
|
||||
## Fidelity review
|
||||
|
||||
- Typography and copy: the live member name and generation remain readable; relationship and management labels do not wrap. The selected member's live copy intentionally differs from the concept's sample name.
|
||||
- Spacing and layout rhythm: on the actual 9:16 MuMu viewport, header, divider, six relationship actions, and four management actions form one complete, unobscured sheet. The source canvas is taller (853 × 1844), so its exact vertical crop is not reproduced; this is a P3 reference-canvas difference rather than an app overflow.
|
||||
- Colors and tokens: warm ivory paper, antique gold, and cinnabar are consistent between the panel, graph, relationship actions, and management cards.
|
||||
- Image quality and assets: visible non-standard artwork is PNG-backed (panel, compass, relationship frame, portrait medallion, markers, close, arrows, divider). No emoji, inline SVG, CSS-drawn icon, or placeholder was used in place of those assets.
|
||||
- Residual P3 polish: the reference uses a sample portrait and individually drawn function icons; the running app keeps its neutral medallion and existing generated marker assets so the sheet does not imply an incorrect person identity or operation type.
|
||||
|
||||
## Verification
|
||||
|
||||
- Opened the selected T01 member in MuMu and accepted the delayed native capture.
|
||||
- Passed: `tests/t01-person-action-panel-contract.ps1`, `tests/t01-relation-layout-contract.ps1`, `tests/t01-all-states-visual-contract.ps1`, and `tests/t01-tree-state-contract.ps1`.
|
||||
- Passed: `git diff --check` (only existing CRLF conversion warnings).
|
||||
|
||||
final result: passed
|
||||
|
||||
---
|
||||
|
||||
# T01 人物操作面板:关系罗盘重设计核验
|
||||
|
||||
## 对比对象
|
||||
|
||||
- Source visual truth:本次会话选择的第 2 张「关系罗盘」设计图,`C:\Users\Rain\.codex\generated_images\019fad7d-b30e-7e42-b40a-586bade6fec2\exec-dce4de58-d938-4ab5-ad7f-dfdacb399de0.png`。
|
||||
- Implementation screenshot:`tmp/jiapu-t01-panel-mumu-final.png`,MuMu Android 真机运行时的面板打开状态(900 × 1600 px)。
|
||||
- 同画布对比:`tmp/t01-relationship-compass-reference-vs-mumu-final.png`;左侧为设计稿面板裁切,右侧为 MuMu 实现面板裁切,并将二者归一至 853 × 1254 px 的同一面板画布。
|
||||
|
||||
## 真机视觉对比
|
||||
|
||||
- 信息层级:实机的成员头部、6 个围绕罗盘的亲属入口及下方 2 × 2 人物管理区,与设计稿的阅读顺序一致;四个管理入口在 900 × 1600 实机截图中均完整可见。
|
||||
- 资产与对比:实机使用纸张底图、人物徽章、关系标记、管理标记、关闭图标、箭头及关系罗盘等生成 PNG 资产;罗盘和边框在真机上没有空白、拉伸或替代图形。
|
||||
- 有意保留的 P3 差异:设计稿为贴边并带拖拽把手的轻量面板;实现保留当前家谱页面的左右留白、纸张边框及更细密的罗盘插画,以和已存在的朱红/宣纸视觉系统一致。这不影响层级、可读性或操作入口。
|
||||
|
||||
## 真机交互验收
|
||||
|
||||
- 初次实机核验发现 `scroll-view` 与头部同层,关闭按钮会被内容层遮挡。已将 `.member-action-panel__head` 提升至 `z-index: 2`;MuMu WebView 点击关闭按钮后,面板节点已消失。
|
||||
- MuMu 点击第一个关系入口“父亲”后,已进入“新增亲属 / 为真机联调始祖添加父亲”页面,见 `tmp/jiapu-t01-father-flow.png`;未填写任何字段,并已放弃草稿返回世系图。
|
||||
|
||||
## Implementation Checklist
|
||||
|
||||
- [x] 已落地选中设计的关系罗盘信息架构。
|
||||
- [x] 已生成并引用对应 PNG 资产,未用 emoji、内联 SVG 或 CSS 图形替代。
|
||||
- [x] 已捕获 MuMu 实现截图,并与源设计在同一画布中并排对比。
|
||||
- [x] 已验证关闭和“父亲”关系入口。
|
||||
|
||||
## 第二轮保真收敛(待真机复核)
|
||||
|
||||
- 根据 `tmp/t01-relationship-compass-audit-compare.png` 的 P1 差异,已更换为生成的 `t01-action-panel-paper-v3.png` 宣纸面板和 `t01-relation-map-v3.png` 六向连接罗盘;同时移除与设计稿不符的两段区块标题,收紧头部,并将关系按钮与管理卡片改为米纸/朱红配色。
|
||||
- 已通过 T01 人物操作面板、文档流、全状态视觉、关系布局、树状态合同及编译审计;生成的透明罗盘与面板资产均已检查 alpha。
|
||||
- 复核阻塞:HBuilderX 热更新后 MuMu 返回 A01 登录页,`tmp/t01-relationship-compass-round2.png` 不是人物操作面板,不能用于设计比较。为避免访问或猜测测试账号凭据,本轮没有重新登录。
|
||||
- 解除条件:在 MuMu 恢复已授权测试会话后,打开 T01 人物操作面板并重新截图;随后必须与同一张源设计同画布比较,处理任何残留 P1/P2 后才能改回通过。
|
||||
|
||||
final result: blocked
|
||||
|
||||
---
|
||||
|
||||
# T01 树状图与 T02 世系谱:真机回归
|
||||
|
||||
## 对比对象
|
||||
|
||||
- Source visual truth:本次会话中用户提供的参考项目“世系谱”截图(408 × 936 px)及 `C:\Users\Administrator\Desktop\job\Jiapu-App\pages\index\tree\index.vue` 的交互实现。
|
||||
- Implementation captures:`C:\Users\Administrator\AppData\Local\Temp\t01-entry-verify.png`、`C:\Users\Administrator\AppData\Local\Temp\t02-entry-verify.png`、`C:\Users\Administrator\AppData\Local\Temp\t02-swipe-verify.png`、`C:\Users\Administrator\AppData\Local\Temp\t02-return-after-fix.png`。
|
||||
- Device/state:Android 模拟器 720 × 1280 px;真实家谱“界面大数据回归谱0726”;T01 树状图、T02 第 1/2 世。
|
||||
|
||||
## 真机交互验收
|
||||
|
||||
- T01 右上角“世系谱”可进入独立 T02 表格页;T02 右上角“树状图”可返回 T01 图谱页。
|
||||
- T02 从第 1 世直接左滑可切换到第 2 世,未使用点击箭头替代横向手势。
|
||||
- 点击姓名打开“成员档案”;点击下方生平文字读取人物详情并打开“人物生平”弹窗,两个命中区域和结果不同。
|
||||
- 返回失败的路由根因已修复:可选 `selectedId` 为空时不再传入 `undefined`,以符合导航参数必须为非空字符串的校验。
|
||||
|
||||
## 视觉核验状态
|
||||
|
||||
- 字体与排版、列格、纵向文字、朱红页头、右侧世代标识和背景纹理已在真机截图中检查。
|
||||
- 当前会话里的参考截图没有可供本地重新打开的原始文件路径,因此无法按同一画布把参考图与实现图合成后做逐像素并排对比;不对像素级一致性作虚假通过结论。
|
||||
|
||||
## Implementation Checklist
|
||||
|
||||
- [x] 树状图与世系谱拆分为两个路由。
|
||||
- [x] 双向页头跳转与原生横向滑动。
|
||||
- [x] 姓名资料页与生平弹窗的分离点击行为。
|
||||
- [x] HBuilderX 页面诊断、静态路由合约和 Android 真机回归。
|
||||
|
||||
final result: blocked
|
||||
|
||||
---
|
||||
|
||||
# T01 树状图与 T02 世系谱:设计核验
|
||||
|
||||
## 对比对象
|
||||
|
||||
- Source visual truth:用户本次会话提供的参考项目“世系谱”截图,以及 `C:\Users\Administrator\Desktop\job\Jiapu-App\pages\index\tree\index.vue` 的 `swiper` 实现。
|
||||
- Implementation capture:未生成。当前 HBuilderX CLI 未识别已连接模拟器作为可部署设备,无法取得包含新 T01/T02 路由的截图。
|
||||
- 目标状态:从“世系图”进入 T01 树状图;T01 顶部进入 T02 世系谱;T02 顶部返回 T01。
|
||||
|
||||
## 阻塞项
|
||||
|
||||
- [P0] 无法取得新版本的真机或浏览器实现截图,因此不能与参考截图进行同视口视觉比较,也不能把直接左右滑动标为已验收。
|
||||
- 已完成代码核验:T01 恢复横向树状图;T02 使用原生 `swiper` 和页内纵向 `scroll-view`,每页五位成员。
|
||||
- 需要:让 HBuilderX 识别任一已连接模拟器,或提供可运行的 H5 预览,以捕获两页入口和横滑状态。
|
||||
|
||||
final result: blocked
|
||||
@@ -1,84 +0,0 @@
|
||||
# APP 136 接口功能归属与重复关系
|
||||
|
||||
> 历史快照(2026-07-26,136 条 operation),不再作为当前实施依据。当前根目录导出与桌面 Apifox 均为 149 条 operation,请使用 [APP-149接口页面归属与表单字段审计-2026-07-27.md](APP-149接口页面归属与表单字段审计-2026-07-27.md)。
|
||||
|
||||
更新时间:2026-07-26
|
||||
|
||||
## 结论
|
||||
|
||||
Apifox 当前目录共 136 条:128 条 APP 接口、4 条 APP/PC 共用行政区划接口、4 条旧系统兼容验证接口。
|
||||
|
||||
“136 条”不等于 136 个独立页面功能。真正功能重复的只有旧兼容验证/发码链路;列表、分页、详情、选项、管理态接口是同一资源在不同交互下的不同合同,不能擅自删掉或混用。
|
||||
|
||||
字段、必填、枚举、请求/响应 DTO 以桌面 Apifox 为准。根目录 `家谱.openapi.json` 只用于核对目录数量和路径,不用其缺失字段推断前端 DTO。
|
||||
|
||||
## 一、唯一入口与功能重复
|
||||
|
||||
| 分类 | 接口 | 当前唯一使用策略 | 结论 |
|
||||
| --- | --- | --- | --- |
|
||||
| APP 人机验证 | `GET /genealogy/app/auth/verification/{operationCode}/require`、`POST .../challenge`、`POST .../verify` | A01 密码/短信登录、A04 注册、A05 找回密码,通过 `TacVerification` 与 `utils/auth-verification.js` | 当前 APP 唯一验证链路 |
|
||||
| 旧验证码兼容 | `GET /captcha/require`、`POST /captcha/challenge`、`POST /captcha/verify`、`GET /auth/code` | 当前 APP 不调用 | 与 APP 人机验证功能重叠,保留 API 目录记录但不能再接回 A01/A04/A05,避免重复验证 |
|
||||
| APP 场景发码 | `POST /genealogy/app/auth/sms/{operationCode}/code` | A01/A04/A05;`operationCode` 是 `password-login`、`sms-login`、`register`、`forgot-password` | 当前 APP 唯一发码链路 |
|
||||
| 旧短信发码兼容 | `POST /genealogy/app/auth/sms/code` | 仅 `appApi.sendLegacySmsCode` API 层 owner;没有页面入口 | 与上项发码功能重叠。保留兼容方法供后端指定旧调用方,不得在现有认证页重复发短信 |
|
||||
| 行政区划(共享) | `GET /genealogy/region/children`、`/path/{regionCode}`、`/search`、`/{regionCode}` | APP 与 PC 共用;当前 G03/G11 使用 children | 不是旧接口、不是重复接口。其余三条在对应 UI 出现“回填路径/搜索/单项校验”交互后接入 |
|
||||
|
||||
## 二、136 条按功能模块归属
|
||||
|
||||
| Apifox 模块 | 数量 | 应归属页面/组件 | 备注 |
|
||||
| --- | ---: | --- | --- |
|
||||
| 验证中心 | 7 | A01、A04、A05、`TacVerification` | 其中 4 条为旧兼容,见上表 |
|
||||
| 认证登录 | 12 | A01、A04、A05、M01、M02、M04、M05、M10 | 改密、换绑、注销、退出属于敏感写操作,测试不执行 |
|
||||
| 文件上传 | 6 | G03、G11、M02、F02、F06、F07、F09、R04、R07、R08、R10,通过 `resumable-image-upload.js` | 单文件、分片 init/chunk/complete、业务引用绑定/释放各自用途不同 |
|
||||
| 行政区划 | 4 | G03 创建家谱、G11 家谱设置 | APP/PC 共用基础查询 |
|
||||
| 家谱 | 13 | G01、G03、G05、G06、G08、G09、G10、G11 | 配额、options、详情、审核/撤销等要与具体页面按钮逐项接线,不因已有列表接口视为完成 |
|
||||
| 家谱成员 | 6 | 当前没有“家谱成员(账号成员)管理”页面;不要误接到世系人物 T03–T08 | `memberId` 与 `personId` 不是同一 ID。成员管理页/候选 DTO 缺失需单列 |
|
||||
| 字辈谱 | 6 | G12 | 正常列表、维护列表、批量预览、批量保存、单条新增/修改是不同操作 |
|
||||
| 世系人物 | 12 | T01、T03–T08、R01、R02 | 首位成员创建后端 `code:500` 阻塞;不能伪造 personId |
|
||||
| 家族圈 | 14 | F01、F02、F03 | 列表与分页、评论与评论分页、回复与回复分页均非重复;编辑/删除/点赞/回复 UI 需逐项确认 |
|
||||
| 内容文章 | 9 | F04、F05、F06、M06、M08 | 谱文分类、帮助详情目前没有已确认页面入口,不能靠列表代替 |
|
||||
| 相册 | 7 | F07、F08、F09 | 相册本身与相片记录是两级资源;编辑/删除动作需遵守测试禁止删除边界 |
|
||||
| 祭祀 | 8 | R05、R06、R07 | 活动、献礼分别有列表和写入合同 |
|
||||
| 族务记录 | 18 | R03、R04、R08、R10、R11 | 成长/备忘/亲友的详情、修改、删除需有明确详情或编辑入口;R09 人生事件没有独立资源合同 |
|
||||
| 消息通知 | 4 | N01、G01 未读数 | N02 没有单条详情读取接口;标已读/全部已读不能在未授权测试中触发 |
|
||||
| 意见反馈 | 2 | M07 | 列表与提交分别接线 |
|
||||
| VIP | 3 | M09 | 套餐、订单列表可读取;创建订单属于支付链路,不执行 |
|
||||
| 视频 | 1 | F10 | 仅有删除视频接口,缺少视频列表/详情/上传/播放合同,因此 F10 不能假装可用 |
|
||||
| 贺礼邀约 | 4 | R06(活动详情)及“我的活动邀请”入口待产品页面 | 受邀人需要真实 `appUserId`;当前成员/世系 DTO 没有可确认候选来源,不能拿 `memberId` 或 `personId` 猜代 |
|
||||
|
||||
以上数量相加为 136。
|
||||
|
||||
## 三、共享行政区划的实测合同
|
||||
|
||||
桌面 Apifox 已核对:
|
||||
|
||||
| 接口 | 必填 | 可选 | 当前 API owner | 页面状态 |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| `GET /genealogy/region/children` | 无 | `parentCode`(不传或 `0` 为省级) | `appApi.getRegionChildren` | G03/G11 已真实读取 |
|
||||
| `GET /genealogy/region/path/{regionCode}` | path `regionCode:string` | `clientid` header | `appApi.getRegionPath` | 暂无“按已存地区回填层级路径”控件 |
|
||||
| `GET /genealogy/region/search` | query `keyword:string` | `level: 1..5`、`limit:integer`、`clientid` header | `appApi.searchRegions` | 暂无地区关键字搜索控件 |
|
||||
| `GET /genealogy/region/{regionCode}` | path `regionCode:string` | `clientid` header | `appApi.getRegion` | 暂无单项详情校验控件 |
|
||||
|
||||
后三条返回 DTO 在桌面文档当前显示为通用对象/列表对象,API 层只校验 envelope 与最外层数组/对象,不擅自猜测内部字段。等页面要消费具体字段时,先在桌面 Apifox 展开响应模型并补严字段校验。
|
||||
|
||||
## 四、接线判定标准
|
||||
|
||||
一条接口只有同时满足以下三项才标记“页面完成”:
|
||||
|
||||
1. `utils/api.js` 有唯一方法 owner,按桌面 Apifox 的必填/可选参数组装请求;
|
||||
2. 明确页面按钮、页面加载或组件行为调用该方法,且参数来源不是猜测 ID;
|
||||
3. 用浏览器在测试账号和测试数据上做真实请求验证;写入再做列表或详情回读。
|
||||
|
||||
仅有 API 方法、或仅页面能打开、或只有导出文件字段,均不算页面完成。
|
||||
|
||||
## 五、2026-07-26 浏览器复测结论
|
||||
|
||||
- 从 A01 密码登录进入测试账号后,以真实 `genealogyId`、动态、谱文和相册标识运行 `tests/all-page-route-runtime-smoke.js`,52/52 页面均通过;认证态访问 A01 被守卫重定向到 G01 属于预期行为,测试已明确校验该分支。
|
||||
- G01 的“世系图、成员、字辈诗、申请审核”四个快捷入口均通过浏览器页面点击复测,分别到达 T01、G05、G12、G10,未捕获运行时异常。
|
||||
- T04 再次经页面输入首位成员姓名并提交。请求仍未获服务端确认,页面显示“保存失败 / 发生未知异常,请联系管理员”;未写入本地成员、未生成假 `personId`。该结果继续受“首位成员创建 code:500”阻塞。
|
||||
- 该路由复测只验证真实读取、页面状态和既有测试数据;未执行短信、改密、换绑、退出、删除、审核或支付。
|
||||
|
||||
## 六、桌面 Apifox 与导出目录核对
|
||||
|
||||
桌面 Apifox 本地接口树(2026-07-26 16:56 更新)包含根目录 `家谱.openapi.json` 的全部 136 个 `HTTP method + path`;没有只存在于导出而桌面目录不存在的 operation。导出文件可用于路径、方法和数量的完整审计。
|
||||
|
||||
该核对不扩大为 DTO 字段已完整:页面要消费的 body/response 字段、必填、枚举和示例仍以桌面 Apifox 的接口详情为准。若桌面详情未声明条目 DTO 或枚举,前端继续把该字段标为阻塞,而不从导出占位结构猜测。
|
||||
@@ -1,75 +0,0 @@
|
||||
# APP 136 请求参数字段核对表
|
||||
|
||||
更新时间:2026-07-26
|
||||
|
||||
## 使用规则
|
||||
|
||||
- 路径和 operation 数量以桌面 Apifox 当前 APP 目录为准;本机已核对根目录 `家谱.openapi.json` 的 136 个 `method + path` 均存在于桌面目录。
|
||||
- 下表字段来自该次导出快照,用于逐项核对前端 consumer。桌面 Apifox 若显示更严格的 required、枚举、长度、oneOf 或条目 DTO,以桌面详情覆盖本表并重新导出。
|
||||
- `int64` 在 H5 JSON 中不得强制转换为 JavaScript `number`。真实雪花 ID 超过安全整数范围时,必须由后端改为十进制字符串合同,不能截断或猜测。
|
||||
- “阻塞”表示前端没有安全的参数来源、DTO、operation 或服务端成功响应;不是用本地默认值补齐的许可。
|
||||
|
||||
## 公共路径与查询参数
|
||||
|
||||
| operation 类别 | 必填 | 可选 | 当前页面/状态 |
|
||||
| --- | --- | --- | --- |
|
||||
| 绝大多数 `/genealogy/app/**` | Header `clientid` | 当前登录 bearer | API 请求层统一注入;实际浏览器验证 |
|
||||
| 资源路径 `/{genealogyId}` | path `genealogyId` | — | 所有家谱内页面从真实当前家谱上下文取得 |
|
||||
| 世系资源 `/{personId}` | path `personId` | — | T03–T08、R01–R02 被首位成员 `code:500` 阻塞 |
|
||||
| 动态资源 `/{feedId}` | path `feedId` | — | F01/F03 实际读取;点赞状态 DTO 阻塞 |
|
||||
| 谱文/相册/礼仪/记录资源 ID | 对应 path ID | — | 已有真实测试数据的列表/详情/创建按页面验证;编辑/删除需要产品入口或受测试边界限制 |
|
||||
| 分页 | `pageNum`、`pageSize` 在 Apifox 均为可选 | keyword、generation、status 等按各 operation | 没有页面分页交互时不以默认假分页替代 |
|
||||
| 行政区划搜索 | query `keyword` | `level` 1–5、`limit` | `children` 已在 G03/G11 实读;其余三个尚无对应交互控件 |
|
||||
|
||||
## 写入 body 字段
|
||||
|
||||
星号为当前导出中的 required 字段。没有写入页面、稳定候选 ID 或枚举的字段均明确列为阻塞,不能手填内部 ID。
|
||||
|
||||
| Schema / operation | 页面 | 必填字段 | 可选字段 | 当前消费结论 |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| `VerificationChallengeBody`(challenge) | A01/A04/A05 | `tenantId`、`subject` | — | 已接验证组件 |
|
||||
| `VerificationCheckBody`(verify) | A01/A04/A05 | `tenantId`、`subject`、`challengeId` | `providerCode`、`captchaType`、`payload` | provider 细节以桌面 TAC DTO 为准 |
|
||||
| `PasswordRegisterBody` | A04 | `grantType`、`tenantId`、`phone`、`password`、`smsCode` | `nickName`、`registerSource` | 短信写入未执行(测试边界) |
|
||||
| `PasswordLoginBody` | A01 | `grantType`、`tenantId`、`phone`、`password` | `validToken` | 已真实登录 |
|
||||
| `SmsLoginBody` | A01 | `grantType`、`tenantId`、`phone`、`smsCode` | — | 短信写入未执行 |
|
||||
| `SmsCodeBody` | A01/A04/A05 | `grantType`、`tenantId`、`phone` | `validToken` | 场景路径已接;未发短信 |
|
||||
| `ProfileUpdateBody` | M02 | — | `nickName`、`avatarOssId`、`sex`、`birthday`、`provinceCode`、`cityCode`、`districtCode` | 头像 `ossId` 类型冲突;字典/地区回填 DTO 需桌面详情确认 |
|
||||
| `PasswordChangeBody` | M04 | `oldPassword`、`newPassword` | — | 敏感操作,不执行 |
|
||||
| `PasswordResetBody` | A05 | `grantType`、`tenantId`、`phone`、`smsCode`、`newPassword` | — | 短信/改密不执行 |
|
||||
| `PhoneChangeBody` | M05 | `phone`、`smsCode` | — | 敏感操作,不执行 |
|
||||
| `AccountDeactivateBody` | M10 | `smsCode` | — | 敏感操作,不执行 |
|
||||
| `ResumableInitBody` | 上传组件 | 实际服务端:`uploadId`、`fileName`、`fileMd5`、`totalSize`、`chunkSize`、`totalChunks` | `contentType` | 导出快照写为 `fileSize` 且漏 `uploadId`;真实请求省略 `uploadId` 返回“上传ID不能为空”,改用 `fileSize` 返回“文件大小不能为空”。前端只按已验证的实际契约发送,等待 Apifox 统一(B17) |
|
||||
| `ResumableCompleteBody` | 上传组件 | 实际已验证链路:`uploadId`、`fileName`、`fileMd5`、`totalSize`、`totalChunks` | — | 导出快照与实际字段冲突待后端统一;回执 `ossId` 仍为不安全 19 位字符串 |
|
||||
| `FileReferenceBody` | 业务附件 | `bizType`、`bizTable`、`bizId`、`bizField` | `bizName`、`ossId`、`ossIds`、`usageScene`、`usageName` | 缺稳定业务表/字段 owner,且 `ossId` 类型冲突;不能猜绑定 |
|
||||
| `GenealogyCreateBody` | G03 | `genealogyName`、`surname`、`regionCode` | `ancestralHall`、`originPlace`、`addressDetail`、`coverOssId`、`intro`、`visibility`、`joinMode` | 已真实创建/回读;封面受 `ossId` 阻塞 |
|
||||
| `GenealogyUpdateBody` | G11 | — | 与创建同名字段 | 读取已接;写入需页面确切 dirty/权限合同 |
|
||||
| `GenealogyJoinApplyBody` | G08 | — | `applicantName`、`phone`、`relationDesc`、`applyReason`、`inviterUserId` | 已真实申请列表回读;内部邀请人 ID 无候选来源 |
|
||||
| `GenealogyJoinAuditBody` | G10 | `status` | `auditRemark` | 审核为敏感操作,不执行 |
|
||||
| `GenealogyMemberUpdateBody` | 无独立成员管理页 | — | `memberName`、`relationName`、`roleType`、`lineagePersonId` | `memberId` 非 `personId`;不得误接世系页 |
|
||||
| `GenealogyOwnerTransferBody` | 无 | `targetMemberId` | — | 缺成员候选与产品入口,不能手填 ID |
|
||||
| `GenerationPoemBody` | G12 | `generationNo`、`generationText` | `description`、`sortOrder`、`status` | 已真实维护/回读 |
|
||||
| `GenerationPoemBatchBody` | G12 | `poemText` | `disableMissing` | 已真实预览/保存回读 |
|
||||
| `LineagePersonBody` | T04/T05/T06、R02 | `name`、`bindingMode` | `appUserId`、`personNo`、`aliasName`、`sex`、`generation`、`generationName`、`fatherId`、`motherId`、`avatarOssId`、`birthDate`、`birthLunar`、`birthPlace`、`deathDate`、`deathLunar`、`deathPlace`、`burialPlace`、`personStatus`、`biography`、`sortOrder`、`remark`、`relationName` | `bindingMode` 固定为 `NONE` / `SELF` / `SPECIFIED`:前两者禁止提交 `appUserId`,后者必须提交;`SELF` 后端从 Token 获取当前用户。页面默认 `NONE`;`SPECIFIED` 仍缺可信候选接口。实测 `NONE` 无 `appUserId` 的新增子女仍返回业务 `code:500`,等待后端排查新版写接口 |
|
||||
| `FamilyFeedBody` | F02/F03 编辑入口缺失 | `feedContent` | `feedType`、`mediaOssIds`、`sortOrder`、`status` | 文本动态已真实创建/回读;媒体受 `ossId` 阻塞;编辑无产品入口 |
|
||||
| `FamilyFeedCommentBody` | F03 | `commentContent` | `parentCommentId` | 一级评论已真实创建/回读;回复 UI 未设计 |
|
||||
| `ArticleBody` | F06 | `articleTitle`、`articleContent` | `categoryId`、`articleSummary`、`coverOssId`、`authorName`、`sortOrder`、`status` | 已真实创建/回读;分类条目 DTO、封面 `ossId` 阻塞 |
|
||||
| `AlbumBody` | F07 | `albumName` | `albumDesc`、`coverOssId`、`sortOrder`、`status` | 已真实创建/回读;封面 `ossId` 阻塞 |
|
||||
| `AlbumPhotoBody` | F09 | `ossId` | `photoTitle`、`photoDesc`、`photographer`、`shootTime`、`sortOrder`、`status` | 上传回执成功,但 `ossId` JSON 类型冲突,不能创建照片记录 |
|
||||
| `CeremonyBody` | R07 | `ceremonyType`、`ceremonyTitle` | `ceremonyDesc`、`ceremonyTime`、`location`、`locationAddress`、`longitude`、`latitude`、`coverOssId`、`sortOrder`、`status` | 已真实创建/回读;封面受 `ossId` 阻塞 |
|
||||
| `CeremonyGiftBody` | R06 | `giftAmount` | `giverName`、`giftMessage` | 礼仪详情/献礼读取已接;写入需从真实礼仪 ID 进入 |
|
||||
| `GrowthRecordBody` | R08 | `recordTitle` | `lineagePersonId`、`recordType`、`recordContent`、`recordDate`、`remindTime`、`mediaOssIds`、`sortOrder`、`status` | 无真实 personId 时人物归属不能填;非人物字段已真实创建/回读 |
|
||||
| `MemoBody` | R10 | `memoTitle` | `memoContent`、`remindTime`、`completed`、`mediaOssIds`、`sortOrder`、`status` | 已真实创建/回读;附件受 `ossId` 阻塞 |
|
||||
| `RelativeRecordBody` | R04 | `relativeName` | `relationName`、`eventName`、`eventTime`、`giftAmount`、`recordContent`、`mediaOssIds`、`sortOrder`、`status` | 已真实创建/回读;附件受 `ossId` 阻塞 |
|
||||
| `MeritRecordBody` | R11 | `donorName`、`meritTitle` | `meritType`、`meritContent`、`amount`、`meritTime`、`sortOrder`、`status` | 已真实创建/回读 |
|
||||
| `FeedbackBody` | M07 | `feedbackContent` | `feedbackType`、`contactInfo` | 已真实提交/读取 |
|
||||
| `VipOrderBody` | M09 | `packageId` | `genealogyId`、`payType` | 创建订单属于支付,不执行 |
|
||||
| `CeremonyInviteesBody` | R07/R06 | `inviteeUserIds` | — | 缺 `appUserId` 候选列表;不能用 member/person ID 猜代 |
|
||||
| `CeremonyInvitationResponseBody` | 我的活动邀请页缺失 | `inviteStatus` | — | 枚举仅 `ACCEPTED`/`DECLINED`;需要产品入口和真实邀请 |
|
||||
|
||||
## 后端需要优先确认的统一规则
|
||||
|
||||
1. 所有 `int64` 在 APP JSON 请求/响应中是否统一为十进制字符串;至少要覆盖 `ossId`、所有资源 ID、`appUserId`、`memberId`、`personId`、`categoryId` 和 `packageId`。
|
||||
2. 所有“建议使用字典值”的字段必须在 Apifox 给出 enum 或独立 options operation,特别是 `sex`、`personStatus`、`roleType`、`status`、`visibility`、`joinMode`、`payType`。
|
||||
3. 任何需要选择内部用户/成员/人物的操作必须返回明确且同类型的候选 ID,禁止要求客户端用不同资源的 ID 猜代。
|
||||
4. 上传完成回执和所有消费 `ossId` 的 DTO 必须同版修复;否则上传成功并不等于业务图片创建成功。
|
||||
5. 分片上传 init/complete 的 Apifox 导出字段必须与实际服务端校验一致:当前实测要求 `uploadId` 与 `totalSize`,而导出写为 `fileSize` 且未声明 `uploadId`;不得要求客户端同时发送两套互斥字段。
|
||||
@@ -1,84 +0,0 @@
|
||||
# APP 149 接口页面归属与表单字段审计
|
||||
|
||||
更新时间:2026-07-27
|
||||
|
||||
## 一、唯一依据与结论
|
||||
|
||||
- 唯一接口源:根目录 `家谱.openapi.json`;桌面 Apifox `APP` 概览同步显示 **149** 条 operation、88 个数据模型。
|
||||
- 这 149 条不是 149 个页面。页面、认证/上传底层流程、管理操作、兼容旧接口共同构成目录,不能因为没有对应按钮就假称“未对接”,也不能把内部 ID、验证码或支付参数渲染成普通表单。
|
||||
- 当前核对结果:已存在页面和 API owner 的资源继续按现有页面操作验证;本次已修复 M02、M07、G08、R07 四个确定的字段控件/适配错误。视频发布已接入 F10;视频和官网内容的读取响应仍没有可安全消费 DTO,因此不能伪造列表或详情卡片。
|
||||
- 所有真实写入验证只允许在页面中选择、输入、提交并做列表/详情回读;不使用后台请求构造数据。短信、改密、换绑、退出、删除、审核、支付、注销不做测试写操作。
|
||||
|
||||
## 二、149 条按 Apifox 模块归属
|
||||
|
||||
| 模块 | 条数 | 页面或唯一 owner | 当前判定 |
|
||||
| --- | ---: | --- | --- |
|
||||
| 验证中心 | 7 | A01/A04/A05,`utils/auth-verification.js` | APP 行为验证为认证流程内部参数;旧 `/captcha/*` 与 `/auth/code` 是兼容链路,无新页面入口。 |
|
||||
| 认证登录 | 12 | A01/A04/A05、M01、M02、M04/M05/M10 | 登录、注册、资料读取/更新有页面;敏感账号写操作保留但不测。 |
|
||||
| 文件上传 | 6 | `utils/resumable-image-upload.js`、各图片选择页 | 上传初始化、分片、完成、引用绑定/释放是技术流程,`ossId` 由回执拥有,不是手填字段。 |
|
||||
| 行政区划 | 8 | G03/G11,`appApi.getRegion*` | `/genealogy/region/*` 是 APP/PC 共享 4 条;`/genealogy/app/region/*` 为同能力 APP 副本,当前统一用共享 owner,不能双接线。 |
|
||||
| 家谱 | 13 | G01、G03、G05–G11 | 列表、详情、公开搜索、申请、审核、配额、options 分属不同交互,不能互相替代。 |
|
||||
| 家谱成员 | 6 | API owner 已有;暂无独立账号成员管理页 | `memberId` 不等于世系 `personId`;成员管理/转让/移除不映射到 T 系页面。 |
|
||||
| 字辈谱 | 6 | G12 | 列表、管理、单条维护、批量预览和批量保存均保留。 |
|
||||
| 世系人物 | 12 | T01、T03–T08、R01/R02 | 首位成员创建仍须以页面实测为准;未得到真实 `personId` 前不伪造成员或关系。 |
|
||||
| 家族圈 | 14 | F01–F03 | 动态、评论、回复、点赞、分页是不同资源动作。删除不测试。 |
|
||||
| 内容文章 | 9 | F04–F06、M06、M08 | 谱文已有创建/读取;分类列表条目 DTO 未声明,分类 ID 不能手输或猜字段。 |
|
||||
| 相册 | 7 | F07–F09 | 相册和照片是两级资源;上传回执后写入,删除不测试。 |
|
||||
| 祭祀 | 8 | R05–R07 | 活动及献礼分别由列表、详情、编辑页拥有。 |
|
||||
| 族务记录 | 20 | R03/R04、R08、R10、R11 | 亲友、成长、备忘、功德是四类独立资源;人生事件没有单独写资源时不伪造。 |
|
||||
| 消息通知 | 5 | N01、G01 未读数 | 读取有 owner;标读为状态写,不在本轮测试。 |
|
||||
| 意见反馈 | 2 | M07 | 已修正为接口枚举 value 提交。 |
|
||||
| VIP | 3 | M09 | 套餐/订单读取可用;下单属于支付链路,不测试。 |
|
||||
| 视频 | 5 | F10 | 已接入上传、创建及 API owner 的 CRUD 路径;列表/详情响应未声明 DTO,页面不假造卡片字段。 |
|
||||
| 贺礼邀约 | 4 | R06 及未来“我的邀请”页 | 受邀人数组必须来自业务用户候选;不能用成员/人物 ID 冒充。 |
|
||||
| 官网内容 | 2 | M10 的协议/说明未来可用 | 见“阻塞”B05:响应体未声明,不能将当前静态说明误报为已接线。 |
|
||||
|
||||
## 三、表单字段的统一处理规则
|
||||
|
||||
| 字段类别 | 页面处理 | 传输规则 |
|
||||
| --- | --- | --- |
|
||||
| `required` 文本/多行文本 | 显示必填标识并做空值校验 | 只传去空白后的真实输入。 |
|
||||
| 有 `enum` 的 string | 中文标签的单选/下拉 | 只提交 enum value,绝不提交中文标签。 |
|
||||
| `date` 或写例 `yyyy-MM-dd` | 日期选择器 | 提交 `yyyy-MM-dd`。 |
|
||||
| 日期时间字符串 | 日期 + 时间选择器 | 只有用户已选择日期时组装 `yyyy-MM-dd HH:mm:ss`。 |
|
||||
| 候选 ID | 由 options/list 返回项选择 | 不显示内部 ID 自由输入;没有候选 DTO/接口则隐藏该字段并记录阻塞。 |
|
||||
| `*OssId`、`mediaOssIds` | 统一上传控件 | 只使用上传回执;不允许键盘输入。 |
|
||||
| `status`、`completed` 等管理/字典字段 | 创建表单默认省略,除非当前页面有明确管理权限且文档给出枚举 | 不把“停用”“完成”猜成前台默认开关。 |
|
||||
| token、`clientid`、`tenantId`、`grantType`、验证码票据、分片 hash | 永不显示 | 由 session、运行时配置、验证或上传 owner 自动生成。 |
|
||||
|
||||
## 四、已有页面的重点 body 字段矩阵
|
||||
|
||||
| 页面/接口 | 必填字段 | 可填写字段 | 选择/自动/隐藏字段 | 当前实现结论 |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| M02 `PUT /auth/profile` | 无 | `nickName`、`realName`、`email` | `sex`:男 `0`/女 `1`/未知 `2`;`birthday`:日期;`avatar`:上传回执 | 已修正性别选择器,适配器拒绝非 `0/1/2`。 |
|
||||
| M07 `POST /feedback` | `feedbackContent` | `contactInfo` | `feedbackType`:建议 `advice`/功能问题 `bug`/投诉 `complaint`/其他 `other` | 已修正为中文显示、枚举 value 提交。 |
|
||||
| G03/G11 家谱创建/修改 | 创建:`genealogyName`、`surname`、`regionCode` | 堂号、籍贯、地址、简介 | 地区:三级行政区划选择;封面:上传;可见性/加入方式:枚举选择 | 保留页面已有 owner。 |
|
||||
| G08 `POST .../join-applies` | 无 | 申请人姓名、手机号、关系说明、申请理由 | `inviterUserId`:只有业务用户候选时才可选;当前隐藏 | 已删除“邀请人编号”手输框;适配器保留字段以供未来真实候选使用。 |
|
||||
| T04/T05 与亲属写入 `LineagePersonBody` | `name`、`bindingMode` | 姓名、别名、编号、字辈、地点、生平、备注、排序 | 性别 `0/1/2`、农历 `0/1`、人物状态 `0/1/2`、父母为人物 options、头像上传;`SELF`/`NONE` 不传 `appUserId`,`SPECIFIED` 必须由可信业务用户候选选择 | 页面已有选择器与白名单;首位成员后端业务 `code:500` 曾阻塞,必须用页面重试和回读确认。 |
|
||||
| F02 动态 | `feedContent` | 内容、排序 | 类型固定自动 `text`;多媒体由上传回执;状态不在普通创建页展示 | 页面显示“文字动态”。 |
|
||||
| F06 谱文 | `articleTitle`、`articleContent` | 摘要、作者、正文、排序 | 封面上传;`categoryId` 必须分类候选选择 | 分类列表没有条目 DTO,不能猜 `id/label`,暂不展示分类选择。 |
|
||||
| F07/F09 相册与照片 | 相册名;照片 `ossId` | 描述、标题、摄影者、拍摄时间、排序 | OSS 均来自上传;状态默认省略 | 已有创建后回读路径。 |
|
||||
| R04 亲友往来 | `relativeName` | 关系、事件、时间、金额、正文、排序 | 多媒体上传;状态默认省略 | 可选字段不被前端强制必填。 |
|
||||
| R07 礼仪活动 | `ceremonyType`、`ceremonyTitle` | 说明、日期时间、地点、详细地址、排序 | 封面上传;经纬度仅地图组件成对回填,当前隐藏;状态默认省略 | 适配器已接收 `locationAddress`,若未来传坐标会校验经纬度成对。 |
|
||||
| R08 成长记录 | `recordTitle` | 类型、正文、日期、提醒、排序 | `lineagePersonId` 只可选真实人物;媒体上传;状态默认省略 | 无真实人物前保留为空。 |
|
||||
| R10 备忘 | `memoTitle` | 正文、提醒、排序 | `completed` 无 enum,默认省略;媒体上传 | 不伪造完成开关。 |
|
||||
| R11 功德 | `donorName`、`meritTitle` | 正文、金额、时间、排序 | 类型:`donation`/`repair`/`public`/`other`;状态默认省略 | 现有选择器使用接口 value。 |
|
||||
| F10 视频 | `videoTitle`、`videoOssId` | 视频标题、说明 | 视频文件:上传回执自动填充;封面、时长、排序、状态:创建页隐藏 | 已接入视频选择、分片上传和创建;不允许手填 OSS ID。 |
|
||||
|
||||
## 五、不能直接补成页面的阻塞项
|
||||
|
||||
| 编号 | 接口/字段 | 已知请求或返回事实 | 前端处理与需要后端补充 |
|
||||
| --- | --- | --- | --- |
|
||||
| B01 | 首位成员 `POST .../lineage/persons` | 页面最小合法 body 曾返回 HTTP 200、envelope `code:500`、`发生未知异常,请联系管理员`;没有成功返回 `personId` | 后端修复后,必须从 T04 页面提交并回读 T01/T03,不能后台造成员。 |
|
||||
| B02 | `LineagePersonBody.bindingMode=SPECIFIED` | `appUserId` 条件必填,但现有 APP 没有“可信业务用户候选”读取合同 | 保留 NONE/SELF;补业务用户 options DTO 后才开放指定绑定。 |
|
||||
| B03 | 谱文分类 `GET .../article-categories` | operation 有列表路径,但当前导出没有可消费的条目字段模型 | 后端给出分类项的 `id`、显示名及响应 schema;前端再做选择器。 |
|
||||
| B04 | 视频 GET/GET detail | `VideoBody` 请求要求 `videoTitle`、`videoOssId`;F10 已可选择视频、上传并创建,当前 200 读取响应仍未声明字段 | 后端补 `VideoView`/列表 rows 的完整响应 DTO,并确认播放地址与封面读取合同;前端再展示视频列表、详情和播放。 |
|
||||
| B05 | 官网内容 GET `/site/articles`、`/site/pages/{pageKey}` | 当前 200 响应 schema 未声明 | 后端补文章/页面 key、标题、正文、更新时间等 DTO;M10 才能替换静态说明。 |
|
||||
| B06 | 礼仪坐标 | `longitude`、`latitude` 可选且必须成对;没有地图选点或坐标来源 operation | 提供地图/地理编码集成合同,或明确允许何种受控坐标来源;不让用户手输。 |
|
||||
| B07 | 贺礼邀约 `inviteeUserIds` | 必填业务用户 ID 数组;成员和世系人物 options 不能证明等同业务用户 | 发布受邀业务用户候选接口与 DTO。 |
|
||||
| B08 | `completed`、`payType`、审核 status | 当前 schema 未给可安全映射的全部 value-label 语义 | 后端补 enum 或字典 options;前端保持隐藏/不测。 |
|
||||
|
||||
## 六、验证记录
|
||||
|
||||
- 本轮代码静态/适配器验证通过:`lineage-openapi-contract`、`feedback-openapi-contract`、`profile-pages-contract`、`m07-feedback-submit-contract`、`g08-g10-application-flow-contract`、`r-business-flow-contract`、`form-enum-api-runtime-smoke`、`oss-id-payload-api-runtime-smoke`、`compile-audit`。
|
||||
- 这些验证不替代真实写入。下一阶段在浏览器完成接口详情核对后,仅在测试账号的页面内输入、选择、提交和回读;敏感操作继续跳过。
|
||||
@@ -1,118 +0,0 @@
|
||||
# APP-150 项目接口覆盖与功能缺口总清单
|
||||
|
||||
更新时间:2026-07-28
|
||||
唯一接口依据:仓库根目录 `家谱.openapi.json`(149 个 operation)。
|
||||
|
||||
## 1. 阅读方式与结论
|
||||
|
||||
“没有接口”“接口已接但没功能”“接口不应有单独页面”是三件不同的事。本清单将其分开记录:
|
||||
|
||||
- **后端合同缺口**:OpenAPI 没有足够的请求/响应字段,前端不能猜测实现。
|
||||
- **前端功能缺口**:`utils/api.js` 已有请求 owner,但没有页面入口或用户动作调用它。
|
||||
- **技术/敏感操作**:由登录、上传、会话、确认弹窗等 owner 自动调用;不做独立表单。
|
||||
|
||||
当前结论:149 个 operation 均已完成归属;视频发布已接通。项目仍有 9 项后端合同缺口、14 组 API 已有但页面功能未接通的缺口,详见第 3、4 节。
|
||||
|
||||
## 2. 全量模块归属(149 个 operation)
|
||||
|
||||
| 模块 | 数量 | 当前 owner | 状态 | 说明 |
|
||||
| --- | ---: | --- | --- | --- |
|
||||
| 验证中心 | 7 | A01/A04/A05、`auth-verification.js` | 已归属 | APP 验证是认证内部流程;`/captcha/*`、`/auth/code` 为兼容接口。 |
|
||||
| 认证登录 | 12 | A01/A04/A05、M01/M02/M04/M05/M10 | 部分功能缺口 | 登录、注册、资料、改密、退出有 owner;换绑/注销见第 4 节。 |
|
||||
| 文件上传 | 6 | `resumable-image-upload.js` | 已归属 | 初始化、分片、完成、引用释放均是上传 owner;OSS ID 不可手填。 |
|
||||
| 行政区划 | 8 | G03/G11、`appApi.getRegion*` | 已归属 | APP 与共享版本各 4 条,当前只消费一套,不能双接。 |
|
||||
| 家谱 | 13 | G01/G03/G05–G12 | 部分功能缺口 | 创建、读取、设置、申请流程有 owner;申请审核/撤销需补实际动作。 |
|
||||
| 家谱成员 | 6 | `appApi` | 无页面 owner | `memberId` 是账号成员,不是世系 `personId`;不能放进 T 系页面。 |
|
||||
| 字辈谱 | 6 | G12 | 部分功能缺口 | 读取、创建、批量预览/保存有 owner;单条修改/停用需补页面动作。 |
|
||||
| 世系人物 | 12 | T01、T03–T08、R01/R02 | 后端阻塞 | 页面/API 已有;首位人物创建、指定绑定候选仍受合同阻塞。 |
|
||||
| 家族圈 | 14 | F01–F03 | 部分功能缺口 | 发布、读取、点赞、评论有 owner;编辑/删除与回复发布尚未形成页面流程。 |
|
||||
| 内容文章 | 9 | F04–F06、M06/M08 | 部分功能缺口 | 读取/新增有 owner;分类 DTO、编辑/删除动作待补。 |
|
||||
| 相册 | 7 | F07–F09 | 部分功能缺口 | 创建、读取、上传照片有 owner;相册编辑/删除、照片删除待补。 |
|
||||
| 祭祀 | 8 | R05–R07 | 部分功能缺口 | 创建、读取、献礼新增有 owner;修改/删除、地图与邀请待补。 |
|
||||
| 族务记录 | 20 | R03/R04、R08、R10/R11 | 部分功能缺口 | 新增/读取有 owner;多项编辑、删除、媒体入口待补。 |
|
||||
| 消息通知 | 5 | N01/N02、G01 | 部分功能缺口 | 列表、详情、未读数有 owner;标记已读动作待补。 |
|
||||
| 意见反馈 | 2 | M07 | 部分功能缺口 | 提交有 owner;“我的反馈”列表无页面。 |
|
||||
| VIP | 3 | M09 | 部分功能缺口 | 套餐/订单读取有 owner;创建订单未形成受控支付流程。 |
|
||||
| 视频 | 5 | F10、`appApi` | 后端合同缺口 | 上传、创建和 CRUD API owner 已接;列表/详情 DTO 缺失。 |
|
||||
| 贺礼邀约 | 4 | R06、`appApi` | 后端合同缺口 | API owner 有;缺业务用户候选与“我的邀请”页面。 |
|
||||
| 官网内容 | 2 | M10 | 后端合同缺口 | 响应无 DTO,不能替换当前静态说明。 |
|
||||
|
||||
## 3. 后端 / OpenAPI 必须补充的合同(不能由前端猜)
|
||||
|
||||
| ID | 相关接口/字段 | 现状 | 缺少的合同 | 前端当前处理 | 优先级 |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| C01 | `GET /genealogies/{genealogyId}/videos`、`GET .../videos/{videoId}` | 只有通用响应,无可消费视频字段 | `VideoView`:`videoId`、标题、说明、视频播放 URL、封面 URL、时长、创建时间、状态 | F10 可上传并创建,不渲染列表/播放器 | P0 |
|
||||
| C02 | `POST /lineage/persons` 首位成员 | 页面最小合法请求曾收到业务 `code:500` | 后端修复创建逻辑,并返回可回读的 `personId` | T04 不伪造首位人物 | P0 |
|
||||
| C03 | `LineagePersonBody.bindingMode=SPECIFIED` | `appUserId` 条件必填 | 可信业务用户候选接口及 DTO,明确用户 ID 与人物关系 | 仅开放 `NONE` / `SELF` | P1 |
|
||||
| C04 | `GET .../article-categories` | 列表 response 未定义条目字段 | 分类 `id`、显示名、状态、排序 DTO | F06 隐藏分类选择,不能手输 `categoryId` | P1 |
|
||||
| C05 | `GET /site/articles`、`GET /site/pages/{pageKey}` | response 未定义 | 文章/页面 key、标题、正文、更新时间、链接 DTO | M10 保留静态内容 | P1 |
|
||||
| C06 | 礼仪 `longitude`、`latitude` | 两字段须成对,但无位置来源 | 地图选点或地理编码合同;坐标精度/坐标系说明 | R07 隐藏坐标手输 | P2 |
|
||||
| C07 | 活动 `inviteeUserIds` | 需要业务用户 ID 数组 | 可邀请业务用户候选接口、显示名、可邀请条件 DTO | R06 不提交猜测 ID | P1 |
|
||||
| C08 | `completed`、`payType`、部分审核/管理状态 | 字典含义或可用范围不足 | 完整 enum/value-label 与权限规则 | 创建页默认不显示管理开关 | P2 |
|
||||
| C09 | 家族圈评论回复 | 只有回复列表接口,没有声明创建回复的操作或父评论字段 | 回复创建 endpoint 与请求体(至少评论内容、父评论 ID) | F03 仅展示现有评论,不提供假回复提交 | P1 |
|
||||
|
||||
## 4. API 已有,但页面功能尚未接通
|
||||
|
||||
以下项的 `appApi` 已有对应请求 owner;静态检索未发现页面调用,或页面只展示静态/只读状态。实现前需确认权限与交互,不应直接暴露内部 ID。
|
||||
|
||||
| ID | 操作 | 当前缺少的页面功能 | 建议页面/入口 | 前置条件 |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| F01 | `PUT/DELETE .../feeds/{feedId}` | 编辑、删除本人动态 | F03 动态详情 | 必须仅显示本人有权操作的按钮。 |
|
||||
| F02 | `DELETE .../comments/{commentId}` | 删除本人评论/删除占位展示 | F03 评论区 | 有回复时按后端返回的删除占位渲染。 |
|
||||
| F03 | `PUT/DELETE .../articles/{articleId}` | 谱文编辑、删除 | F05/F06 | 分类 DTO 到位后,编辑页才开放分类选择。 |
|
||||
| F04 | `PUT/DELETE .../albums/{albumId}` | 相册编辑、删除 | F07/F08 | 删除需二次确认并遵循后端权限。 |
|
||||
| F05 | `DELETE .../albums/{albumId}/photos/{photoId}` | 删除照片 | F08 | 仅作者/管理者显示。 |
|
||||
| F06 | `PUT/DELETE .../ceremonies/{ceremonyId}` | 礼仪编辑、删除 | R06/R07 | 地图字段仍按 C06 处理。 |
|
||||
| F07 | `DELETE .../ceremonies/{ceremonyId}/gifts/{giftId}` | 删除献礼 | R06 | 需明确献礼人/管理员权限。 |
|
||||
| F08 | 成长、备忘、亲友、功德的 `PUT/DELETE` | 记录编辑、删除 | R08/R10/R04/R11 | 目前新建/读取与编辑动作未闭环。 |
|
||||
| F09 | `PUT .../generation-poems/{poemId}` | 修改、停用、恢复单条字辈 | G12 | 需按 `status` 枚举和管理权限显示。 |
|
||||
| F10 | 家谱成员 6 条 | 成员列表、成员资料、移除、退出、转让 | 新建“成员管理”页 | 严格使用 `memberId`,绝不能复用世系人物 UI。 |
|
||||
| F11 | 加入申请审核、撤销 | 审核/撤销提交动作与结果回读 | G09/G10 | 需确认当前用户角色,敏感写操作必须二次确认。 |
|
||||
| F12 | 通知标已读、全部已读 | 单条/全部已读行为 | N01/N02 | 标读应静默回写列表,不弹假成功。 |
|
||||
| F13 | `GET /feedback` | “我的反馈”列表 | M07 或新建 M11 | response DTO 足够时展示,不开放删除。 |
|
||||
| F14 | 换绑、注销、VIP 创建订单 | 安全表单/支付确认链路 | M05/M10/M09 | 短信验证、支付回调、二次确认不可省略。 |
|
||||
|
||||
## 5. 没有可新增接口的页面功能
|
||||
|
||||
| 页面 | 原因 | 需要的后端资源 |
|
||||
| --- | --- | --- |
|
||||
| R09 人生大事 | OpenAPI 149 条中没有人生事件资源的 CRUD | 人生事件列表、详情、新增、修改、删除;需定义归属人物、事件日期、事件类型、内容、媒体字段。 |
|
||||
| F10 视频列表/播放 | 已有路径但读取 DTO 不完整,等同不可安全实现 | 见 C01。 |
|
||||
| “我的活动邀请” | 读取接口有 owner,但没有页面和邀请候选数据 | 邀请页面、C07 候选 DTO;现有 `GET .../ceremony-invitations/mine` 可作为入口。 |
|
||||
| 家谱成员管理 | 后端路径完整但无页面信息架构 | 成员管理页面及权限/成员 DTO展示规则。 |
|
||||
|
||||
## 6. 不是“少页面”的接口(保持自动或受控)
|
||||
|
||||
| 接口类别 | 正确 owner | 不应暴露的字段/原因 |
|
||||
| --- | --- | --- |
|
||||
| 验证挑战、验证校验、短信发送 | `auth-verification.js`、认证页 | `validToken`、challenge、验证码票据由认证流程持有。 |
|
||||
| 文件初始化、分片、完成、引用 | 上传工具 | `uploadId`、MD5、chunkIndex、OSS ID 都是上传回执/技术参数。 |
|
||||
| `clientid`、token、tenantId | `config.js`、session | 运行时认证信息,绝不能做表单字段。 |
|
||||
| 行政区划 APP/共享重复路径 | `appApi.getRegion*` | 只选择一个 owner,避免同一功能双请求。 |
|
||||
| 删除、审核、退出、注销、支付 | 现有页面的明确二次确认流程 | 不应为“接口覆盖率”而自动触发真实写操作。 |
|
||||
|
||||
## 7. 表单字段总规则
|
||||
|
||||
| 字段类型 | 表单处理 |
|
||||
| --- | --- |
|
||||
| OpenAPI `required` 文本 | 显示必填标识,提交前 trim 校验。 |
|
||||
| `enum` 字符串 | 使用中文标签的选择器,仅提交 enum value。 |
|
||||
| 日期/日期时间 | 使用日期或日期+时间选择器;不能自由输入格式。 |
|
||||
| 候选 ID | 必须由 options/list 选择;缺候选 DTO 时隐藏,不允许手输。 |
|
||||
| `*OssId`、`mediaOssIds` | 只从上传回执获取;视频同样适用。 |
|
||||
| `status`、`completed`、排序、审核、支付字段 | 默认不出现在普通创建表单;只在明确管理权限和完整字典合同下出现。 |
|
||||
| 认证、分片、路径 ID | 自动生成/路由携带;不可显示或编辑。 |
|
||||
|
||||
## 8. 推荐实施顺序
|
||||
|
||||
1. 后端先完成 C01、C02:视频可展示播放、世系首位人物可创建,才能闭合两个主要入口。
|
||||
2. 补 C03、C04、C07:所有候选 ID 都能选择而不是手填。
|
||||
3. 实施 F11(成员管理)和 F12(申请审核/撤销),先补清晰的权限和确认流程。
|
||||
4. 实施 F01–F10 的编辑/删除闭环;每一项均先做“当前用户是否有权”的详情回读。
|
||||
5. 处理 C05、C06、C08 与 R09,完成官网内容、地图、字典和人生事件的新增合同。
|
||||
|
||||
## 9. 验证边界
|
||||
|
||||
- 本文是接口和代码静态审计,不代表所有写接口已经在生产环境执行。
|
||||
- 真实写入必须在测试账号、对应页面中操作,再通过列表/详情回读验证;不得用脚本绕过页面构造业务数据。
|
||||
- 当前视频测试文件为 `C:\Users\Rain\Desktop\9d063f4536624f6b1ccb4d2cb9e9786c.mp4`;已确认存在且为非空 MP4。真实上传需在登录态下从 F10 的系统文件选择器选择它。
|
||||
@@ -1,63 +0,0 @@
|
||||
# APP 表单控件与接口字段审计
|
||||
|
||||
更新日期:2026-07-26
|
||||
依据:桌面 Apifox APP 合同、`家谱.openapi.json`、当前页面真实表单代码。
|
||||
|
||||
## 判定规则
|
||||
|
||||
| 字段性质 | 页面控件 |
|
||||
| --- | --- |
|
||||
| 自由文本、标题、说明、地点、姓名 | 文本输入或多行输入 |
|
||||
| `date`、明确日期 | 日期选择器 |
|
||||
| 明确日期时间 | 日期与时间选择器,页面组合后提交标准时间字符串 |
|
||||
| 金额、排序 | 数字输入 |
|
||||
| 地区、分类、人物、业务用户、成员等资源 ID | 从真实候选列表选择;禁止手填内部 ID |
|
||||
| `sex`、`status`、是否农历等字典值 | value-label 选择器;必须有 Apifox enum 或字典 options 来源 |
|
||||
| `ossId`、封面、头像、媒体 | 真实上传组件;禁止手填文件 ID |
|
||||
|
||||
## 已确认需要调整的表单
|
||||
|
||||
| 页面 | 字段 | 当前控件/状态 | 正确控件 | 依赖或阻塞 |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| T04、T05 | `birthLunar`、`deathLunar` | 文本输入,且提示为“按家谱记载填写” | “是否农历”选择器 | B15:Apifox 只有字典值描述,无 value-label enum |
|
||||
| T04、T05 | `sex`、`personStatus` | 页面缺入口 | 字典选择器 | B15 |
|
||||
| T04、T05 | `fatherId`、`motherId`、`appUserId` | 页面不能从候选选择 | 人物/业务用户候选选择器 | B06、B09/B13;首位成员不能填父母 |
|
||||
| T04、T05 | `avatarOssId` | 页面未接头像上传 | 真实上传组件 | B08:19 位 `ossId` 类型冲突 |
|
||||
| M02 | `sex` | 文本输入 | 字典选择器 | B15 |
|
||||
| M02 | `provinceCode`、`cityCode`、`districtCode` | 页面缺入口 | 行政区划级联选择器 | B12 的接口 DTO 已可读;需接入页面 |
|
||||
| G08 | `inviterUserId` | 数字输入,要求用户手填内部 ID | 真实邀请人候选选择器 | B09:没有业务用户候选/映射,不能手填 |
|
||||
| F06 | `categoryId` | 页面缺入口 | 谱文分类选择器 | B11:测试谱分类为空,合同没有条目字段定义 |
|
||||
| F02 | `feedType` | 自由文本输入 | 动态类型选择器或固定默认类型 | 合同仅给默认 `text`,没有完整类型 enum;不能让用户随意输入未声明类型 |
|
||||
| F09 | `shootTime` | 手工文本时间 | 日期与时间选择器 | 合同字段是拍摄时间;可直接改页面控件 |
|
||||
| R04 | `eventTime` | 手工文本时间 | 日期与时间选择器 | 合同字段是事项时间;可直接改页面控件 |
|
||||
| R07 | `ceremonyTime` | 手工文本时间 | 日期与时间选择器 | 合同字段是活动时间;可直接改页面控件 |
|
||||
| R08 | `recordDate`、`remindTime` | 手工文本时间 | 日期选择器;提醒用日期与时间选择器 | 合同字段分别是记录日期、提醒时间 |
|
||||
| R11 | `meritTime` | 手工文本时间 | 日期与时间选择器 | 合同字段是功德时间 |
|
||||
|
||||
## 已经正确的控件
|
||||
|
||||
| 页面 | 字段 | 当前控件 |
|
||||
| --- | --- | --- |
|
||||
| G03、G11 | `regionCode` | 行政区划级联选择器 |
|
||||
| G03、G11 | `visibility`、`joinMode` | 已封装为访问规则选择 |
|
||||
| M02 | `birthday` | 日期选择器 |
|
||||
| R10 | `remindTime` | 日期和时间选择器组合 |
|
||||
| 所有上传表单 | `coverOssId`、`mediaOssIds`、`ossId` | 真实上传回执,不提供手填文件 ID |
|
||||
| R04、R11 | 金额 | 数字输入 |
|
||||
| 各编辑页 | `sortOrder` | 数字输入 |
|
||||
|
||||
## 不应为了“字段齐全”强行显示的字段
|
||||
|
||||
| 字段 | 原因 |
|
||||
| --- | --- |
|
||||
| 首位成员的 `fatherId`、`motherId`、`relationName` | 与“首位成员/世系起点”语义冲突;不能伪造已有成员 ID |
|
||||
| `personNo` | 合同明确不传时服务端生成,不应要求用户填写 |
|
||||
| `avatarOssId`、`coverOssId`、`mediaOssIds` | 必须由上传组件产生,不允许文本输入 |
|
||||
| `status`、`completed` 等服务端有默认值的可选字段 | 无业务需求和明确字典时不新增裸代码控件 |
|
||||
|
||||
## 实施顺序
|
||||
|
||||
1. 先把 F09、R04、R07、R08、R11 的明确时间字段改为选择器并进行浏览器真实提交回读。
|
||||
2. 再接入 M02 的地区级联选择。
|
||||
3. B15、B09、B11、B06/B08 由后端补齐枚举、候选或 ID 合同后,再补世系、性别、分类、邀请人等选择器。
|
||||
|
||||
@@ -1,197 +0,0 @@
|
||||
# Apifox 写接口字段—表单控件映射
|
||||
|
||||
> 当前有效的 149 operation 总表、页面 owner 与阻塞项见 [APP-149接口页面归属与表单字段审计-2026-07-27.md](APP-149接口页面归属与表单字段审计-2026-07-27.md)。本文件保留为字段控件速查表;其中以下修订以根目录最新 `家谱.openapi.json` 为准。
|
||||
|
||||
## 口径与来源
|
||||
|
||||
- 核对时间:2026-07-27。
|
||||
- 主源:桌面 Apifox 当前 `APP` 项目(149 条 operation)及同一时刻导出的根目录 `家谱.openapi.json`;两者的 operation 数量一致。
|
||||
- `必填` 以 Apifox `必需` 标记为准;没有 `必需` 的字段均应允许不填,提交时传空值或省略由当前 API 适配器统一处理。
|
||||
- `枚举选择` 只能提交下表列出的 value,不能把中文标签提交给后端。
|
||||
- `候选选择` 只能从对应选项接口选取,页面不得暴露裸露的内部 ID 输入框。
|
||||
- `上传` 必须先走统一上传,提交上传回执中的 `ossId`;不能让用户手输 OSS ID。
|
||||
- 所有 `sortOrder` 是整数数字框;所有 `amount/giftAmount` 是金额数字框;示例为 `yyyy-MM-dd HH:mm:ss` 的时间字段用“日期 + 时间”控件拼接后提交。
|
||||
|
||||
## 已在 Apifox 字段详情中确认的字典
|
||||
|
||||
| 字段/字典 | value → 中文 | 页面控件 |
|
||||
| --- | --- | --- |
|
||||
| `sys_user_sex` | `0` 男;`1` 女;`2` 未知 | 单选/下拉选择 |
|
||||
| `gen_number_yes_no` | `0` 否;`1` 是 | 单选/下拉选择 |
|
||||
| `gen_lineage_person_status` | `0` 健在;`1` 已故;`2` 未知 | 单选/下拉选择 |
|
||||
| `sys_normal_disable` | `0` 正常;`1` 停用 | 单选/开关;默认 `0` |
|
||||
| `gen_genealogy_visibility` | `0` 私密;`1` 公开;`2` 成员可见 | 单选/下拉选择 |
|
||||
| `gen_genealogy_join_mode` | `0` 关闭加入;`1` 申请审核;`2` 邀请加入 | 单选/下拉选择 |
|
||||
| `gen_merit_type` | `donation` 捐赠;`repair` 修祠;`public` 公益;`other` 其他 | 单选/下拉选择 |
|
||||
| 活动邀请响应 | `ACCEPTED` 接受;`DECLINED` 拒绝 | 二选一 |
|
||||
|
||||
`completed` 在 Apifox 当前详情中是 `string`,只给出示例 `0`,没有挂字典/允许值;因此不能伪造“完成/未完成”的值表。当前页面应默认省略,待后端给出该字段的字典定义后再开启开关。
|
||||
|
||||
## 已有页面:字段到控件映射
|
||||
|
||||
### M02 修改用户资料 — `PUT /genealogy/app/auth/profile`
|
||||
|
||||
| 字段 | 必填 | Apifox 详情 | 正确控件与提交 |
|
||||
| --- | --- | --- | --- |
|
||||
| `nickName` | 否 | string,用户昵称,最多 30 字 | 文本框,最多 30 字 |
|
||||
| `realName` | 否 | string,真实姓名,最多 30 字 | 文本框,最多 30 字 |
|
||||
| `avatar` | 否 | int64,头像文件 OSS ID | 图片上传;仅提交可安全表示的数值 OSS ID |
|
||||
| `sex` | 否 | `sys_user_sex` | 枚举选择 `0/1/2` |
|
||||
| `birthday` | 否 | date,生日,格式 `yyyy-MM-dd` | 日期选择器 |
|
||||
| `email` | 否 | email,最多 100 字 | email 文本框 |
|
||||
|
||||
### G03/G11 创建、修改家谱 — `GenealogyCreateBody` / `GenealogyUpdateBody`
|
||||
|
||||
| 字段 | 创建必填 | 正确控件与提交 |
|
||||
| --- | --- | --- |
|
||||
| `genealogyName` | 是 | 文本框 |
|
||||
| `surname` | 是 | 文本框 |
|
||||
| `ancestralHall` | 否 | 文本框 |
|
||||
| `originPlace` | 否 | 文本框 |
|
||||
| `regionCode` | 是 | 省市区级联候选选择,提交行政区划 code |
|
||||
| `addressDetail` | 否 | 文本框 |
|
||||
| `coverOssId` | 否 | 图片上传,提交字符串 OSS ID |
|
||||
| `intro` | 否 | 多行文本 |
|
||||
| `visibility` | 否 | 枚举选择:私密 `0` / 公开 `1` / 成员可见 `2` |
|
||||
| `joinMode` | 否 | 枚举选择:关闭加入 `0` / 申请审核 `1` / 邀请加入 `2` |
|
||||
|
||||
### T04/T05 录入、修改世系人物 — `LineagePersonBody`
|
||||
|
||||
同一 body 还用于“添加子女、父母、兄弟姐妹、配偶”。`name` 是唯一 body 必填字段;关系路径由 URL 决定,不能把中文关系标签当成接口字段替代。
|
||||
|
||||
| 字段 | 必填 | Apifox 详情/限制 | 正确控件与提交 |
|
||||
| --- | --- | --- | --- |
|
||||
| `bindingMode` | 是 | 身份认领方式:`NONE` / `SELF` / `SPECIFIED` | 固定英文枚举选择:不绑定账号 / 绑定当前账号 / 绑定指定用户;不发送中文值 |
|
||||
| `appUserId` | 视 `bindingMode` 而定 | `NONE` 不绑定账号且不传;`SELF` 绑定当前登录账号且不传,后端从 Token 获取;`SPECIFIED` 绑定指定业务用户且必须传 | 仅 `SPECIFIED` 由管理员选择可信业务用户候选后提交;当前没有候选接口时禁止该选项保存,绝不手输 ID |
|
||||
| `personNo` | 否 | 人物编号;不传由服务端生成 | 可选文本框;留空时省略 |
|
||||
| `name` | 是 | 姓名 | 文本框 + 必填校验 |
|
||||
| `aliasName` | 否 | 别名或曾用名 | 文本框 |
|
||||
| `sex` | 否 | `sys_user_sex` | 枚举选择:男 `0` / 女 `1` / 未知 `2` |
|
||||
| `generation` | 否 | int64,世代序号 | 正整数数字框;首位成员固定为 `1` |
|
||||
| `generationName` | 否 | 字辈或辈分 | 文本框 |
|
||||
| `fatherId` | 否 | 父亲人物 ID,必须属于当前家谱 | 从 `/lineage/persons/options` 选择父亲,提交人物 ID |
|
||||
| `motherId` | 否 | 母亲人物 ID,必须属于当前家谱 | 从 `/lineage/persons/options` 选择母亲,提交人物 ID |
|
||||
| `avatarOssId` | 否 | string/null,头像文件 OSS ID;说明明确要求统一上传组件取得,不允许手工录入 | 图片上传,提交字符串 OSS ID |
|
||||
| `birthDate` | 否 | date-time/null(写接口示例为 `yyyy-MM-dd`) | 日期选择;未录入不传 |
|
||||
| `birthLunar` | 否 | `gen_number_yes_no` | 枚举选择:否 `0` / 是 `1` |
|
||||
| `birthPlace` | 否 | 出生地 | 文本框 |
|
||||
| `deathDate` | 否 | date-time/null(写接口示例为 `yyyy-MM-dd`) | 日期选择;未录入不传 |
|
||||
| `deathLunar` | 否 | `gen_number_yes_no` | 枚举选择:否 `0` / 是 `1` |
|
||||
| `deathPlace` | 否 | 逝世地 | 文本框 |
|
||||
| `burialPlace` | 否 | 安葬地 | 文本框 |
|
||||
| `personStatus` | 否 | `gen_lineage_person_status` | 枚举选择:健在 `0` / 已故 `1` / 未知 `2` |
|
||||
| `biography` | 否 | 人物简介 | 多行文本 |
|
||||
| `sortOrder` | 否 | int64,排序值 | 整数数字框 |
|
||||
| `remark` | 否 | 备注 | 多行文本 |
|
||||
| `relationName` | 否 | 关系名称 | 新增亲属时由关系选择派生;编辑时可作为文本修订 |
|
||||
|
||||
#### 页面实测(2026-07-27)
|
||||
|
||||
- 此接口字段标为 `date-time`,但四个写接口示例使用 `yyyy-MM-dd`。页面此前把日期追加为 `yyyy-MM-dd HH:mm:ss`,会在客户端 `normalizeLineagePersonDate` 校验阶段被拒绝,尚未发起请求;现已改为只传日期。
|
||||
- 后端于 2026-07-27 明确补齐身份认领合同:`NONE`、`SELF` 禁止提交 `appUserId`;`SELF` 由后端从 Token 取当前 APP 用户;仅 `SPECIFIED` 必须提交 `appUserId`。页面默认 `NONE`,不再自动读取或提交 `profile.userId`。
|
||||
- 同日页面实测 `POST .../children`:请求体为 `bindingMode: "NONE"` 且不存在 `appUserId`,后端仍返回 HTTP 200 / envelope `code:500`、`发生未知异常,请联系管理员`;三世数据未落库,需后端确认新版写接口是否已部署并排查该业务异常。
|
||||
|
||||
### F02 家族圈动态 — `FamilyFeedBody`
|
||||
|
||||
| 字段 | 必填 | Apifox 详情 | 正确控件与提交 |
|
||||
| --- | --- | --- | --- |
|
||||
| `feedType` | 否 | 动态类型;未传默认 `text` | 当前页面固定传 `text`,不让用户输入代码 |
|
||||
| `feedContent` | 是 | 动态内容,不允许为空 | 多行文本 + 必填校验 |
|
||||
| `mediaOssIds` | 否 | 多个文件 OSS ID 用英文逗号分隔 | 多图上传;回执 ID 以 `,` 拼接 |
|
||||
| `sortOrder` | 否 | int64;未传默认 `0` | 整数数字框 |
|
||||
| `status` | 否 | `sys_normal_disable` | 正常 `0` / 停用 `1`;创建页默认 `0`,非管理页不暴露停用操作 |
|
||||
|
||||
### F06 谱文、F07 相册、F09 相册照片
|
||||
|
||||
| body.字段 | 必填 | 正确控件与提交 |
|
||||
| --- | --- | --- |
|
||||
| `ArticleBody.categoryId` | 否 | 从“谱文分类”接口候选选择;不手填分类 ID |
|
||||
| `articleTitle` / `articleSummary` / `authorName` | 标题是 | 分别为文本、多行摘要、文本 |
|
||||
| `articleContent` | 是 | 富文本/多行内容编辑 |
|
||||
| `coverOssId` | 否 | 图片上传,字符串 OSS ID |
|
||||
| `ArticleBody.sortOrder` | 否 | 整数数字框 |
|
||||
| `ArticleBody.status` | 否 | `sys_normal_disable` 选择,默认 `0` |
|
||||
| `AlbumBody.albumName` | 是 | 文本框 |
|
||||
| `albumDesc` | 否 | 多行文本 |
|
||||
| `coverOssId` | 否 | 图片上传,字符串 OSS ID |
|
||||
| `AlbumBody.sortOrder` | 否 | 整数数字框 |
|
||||
| `AlbumBody.status` | 否 | `sys_normal_disable` 选择,默认 `0` |
|
||||
| `AlbumPhotoBody.ossId` | 是 | 图片上传;提交字符串 OSS ID |
|
||||
| `photoTitle` / `photoDesc` / `photographer` | 否 | 文本、多行文本、文本 |
|
||||
| `shootTime` | 否 | 拍摄时间;示例按标准时间字符串 | 日期 + 时间选择 |
|
||||
| `AlbumPhotoBody.sortOrder` | 否 | 整数数字框 |
|
||||
| `AlbumPhotoBody.status` | 否 | `sys_normal_disable` 选择,默认 `0` |
|
||||
|
||||
### R07 祭祀活动、R04 献礼
|
||||
|
||||
| body.字段 | 必填 | 正确控件与提交 |
|
||||
| --- | --- | --- |
|
||||
| `CeremonyBody.ceremonyType` | 是 | Apifox 当前为普通 string;文本框,不能自行伪造枚举 |
|
||||
| `ceremonyTitle` | 是 | 文本框 |
|
||||
| `ceremonyDesc` | 否 | 多行文本 |
|
||||
| `ceremonyTime` | 否 | 示例为 `yyyy-MM-dd HH:mm:ss`;日期 + 时间选择 |
|
||||
| `location` / `locationAddress` | 否 | 地点名称、详细地址文本框;两项都应保留 |
|
||||
| `longitude` / `latitude` | 否 | 仅可由地图选点组件成对回填;当前没有地图候选/选点合同,R07 不暴露手输框,也不传这两个字段。API 适配层只接受成对有限数字,供后续真实地图组件使用。 |
|
||||
| `coverOssId` | 否 | 图片上传,字符串 OSS ID |
|
||||
| `sortOrder` | 否 | 整数数字框 |
|
||||
| `status` | 否 | `sys_normal_disable` 选择,默认 `0` |
|
||||
| `CeremonyGiftBody.giverName` | 否 | 文本框 |
|
||||
| `giftAmount` | 是 | 金额数字框 |
|
||||
| `giftMessage` | 否 | 多行文本 |
|
||||
|
||||
### R08 成长记录、R09 亲友记录、R10 备忘、R11 功德
|
||||
|
||||
| body.字段 | 必填 | Apifox 详情/正确控件 |
|
||||
| --- | --- | --- |
|
||||
| `GrowthRecordBody.lineagePersonId` | 否 | 世系人物候选选择,提交人物 ID |
|
||||
| `recordType` | 否 | 当前详情为普通 string,文本框 |
|
||||
| `recordTitle` | 是 | 文本框 |
|
||||
| `recordContent` | 否 | 多行文本 |
|
||||
| `recordDate` / `remindTime` | 否 | 示例均为 `yyyy-MM-dd HH:mm:ss`;日期 + 时间选择 |
|
||||
| `mediaOssIds` | 否 | 多图上传,逗号分隔 OSS ID |
|
||||
| `GrowthRecordBody.sortOrder` / `status` | 否 | 整数数字框;`sys_normal_disable` 选择 |
|
||||
| `MemoBody.memoTitle` | 是 | 文本框 |
|
||||
| `memoContent` | 否 | 多行文本 |
|
||||
| `remindTime` | 否 | 示例为 `yyyy-MM-dd HH:mm:ss`;日期 + 时间选择 |
|
||||
| `completed` | 否 | 当前仅 string 示例 `0`,无字典;默认省略,不能私设枚举 |
|
||||
| `MemoBody.mediaOssIds` / `sortOrder` / `status` | 否 | 多图上传;整数数字框;`sys_normal_disable` 选择 |
|
||||
| `RelativeRecordBody.relativeName` | 是 | 文本框 |
|
||||
| `relationName` / `eventName` | 否 | 文本框 |
|
||||
| `eventTime` | 否 | 示例为 `yyyy-MM-dd HH:mm:ss`;日期 + 时间选择 |
|
||||
| `giftAmount` | 否 | 金额数字框 |
|
||||
| `recordContent` | 否 | 多行文本 |
|
||||
| `RelativeRecordBody.mediaOssIds` / `sortOrder` / `status` | 否 | 多图上传;整数数字框;`sys_normal_disable` 选择 |
|
||||
| `MeritRecordBody.donorName` / `meritTitle` | 是 | 文本框 |
|
||||
| `meritType` | 否 | 枚举选择:捐赠 `donation` / 修祠 `repair` / 公益 `public` / 其他 `other` |
|
||||
| `meritContent` | 否 | 多行文本 |
|
||||
| `amount` | 否 | 金额数字框 |
|
||||
| `meritTime` | 否 | 示例为 `yyyy-MM-dd HH:mm:ss`;日期 + 时间选择 |
|
||||
| `MeritRecordBody.sortOrder` / `status` | 否 | 整数数字框;`sys_normal_disable` 选择 |
|
||||
|
||||
## 其余写接口:同样必须按字段类型建控件
|
||||
|
||||
| 接口 body | 字段映射 |
|
||||
| --- | --- |
|
||||
| `GenealogyJoinApplyBody` | `applicantName`、`phone`、`relationDesc`、`applyReason` 为文本/多行文本;`inviterUserId` 必须是业务用户候选选择,当前缺候选接口时不暴露裸 ID 输入。 |
|
||||
| `GenealogyJoinAuditBody` | `status` 是审核结果,必须由 Apifox 的审核状态字典提供选项后才做选择器;`auditRemark` 多行文本。 |
|
||||
| `GenealogyMemberUpdateBody` | `memberName`、`relationName`、`roleType` 文本;`lineagePersonId` 为世系人物候选选择。 |
|
||||
| `GenealogyOwnerTransferBody` | `targetMemberId` 必填,家谱成员候选选择。 |
|
||||
| `GenerationPoemBody` | `generationNo` 必填正整数;`generationText` 必填文本;`description` 多行文本;`sortOrder` 数字;`status` 用 `sys_normal_disable`。 |
|
||||
| `GenerationPoemBatchBody` | `poemText` 必填多行文本;`disableMissing` 布尔开关。 |
|
||||
| `FamilyFeedCommentBody` | `parentCommentId` 为评论候选/回复上下文,不能输入 ID;`commentContent` 必填多行文本。 |
|
||||
| `FeedbackBody` | `feedbackType` 选填枚举选择:`advice` 建议、`bug` 功能问题、`complaint` 投诉反馈、`other` 其他;界面显示中文标签,提交 value。`feedbackContent` 必填多行文本;`contactInfo` 文本。 |
|
||||
| `VipOrderBody` | `packageId` 必填,VIP 套餐候选选择;`genealogyId` 为当前家谱上下文选择;`payType` 只能使用支付方式字典,不能手填代码。 |
|
||||
| `VideoBody` | `videoTitle` 必填文本,`videoDesc` 多行文本,`coverOssId`/`videoOssId` 为上传,`durationSeconds`/`sortOrder` 为数字,`status` 为 `sys_normal_disable`。 |
|
||||
| `CeremonyInviteesBody` | `inviteeUserIds` 必填多选业务用户候选;无候选接口不能用逗号文本代替数组。 |
|
||||
| `CeremonyInvitationResponseBody` | `inviteStatus` 必填二选一:接受 `ACCEPTED`、拒绝 `DECLINED`。 |
|
||||
|
||||
## 非页面直接填写的底层接口
|
||||
|
||||
认证挑战、验证码、分片上传初始化/完成、文件引用等 body 由登录/上传流程生成,不应渲染为业务表单。`grantType`、`tenantId`、`challengeId`、`validToken`、文件 hash/分片大小等由对应流程拥有,不能让用户在页面中编辑。
|
||||
|
||||
## 当前改造判定
|
||||
|
||||
1. 已经可以立即改为选择控件的字段:`sex`、出生/逝世农历、`personStatus`、`visibility`、`joinMode`、所有 `status`、`meritType`、邀请响应。
|
||||
2. 必须改为候选选择的字段:所有家谱/成员/世系人物/分类/套餐/受邀用户的 ID 字段。
|
||||
3. 必须改为上传的字段:所有 `*OssId`、`mediaOssIds`、头像、封面、照片、视频文件。
|
||||
4. `completed`、`payType`、审核 `status` 等尚未在当前 Apifox 字段详情给出允许值;在后端未提供字典或候选接口前,不增加猜测性选择项。`feedbackType` 已有四个枚举值,已改为选择控件。
|
||||
@@ -1,237 +0,0 @@
|
||||
# Apifox 逐页业务接口与页面展示核对台账
|
||||
|
||||
> 权威取证顺序:用户已打开的 Apifox 桌面端文档页 → 同一部署的脱敏只读响应(仅在获准时)→ 导出文档交叉核验。不得以导出文档缺项否定 Apifox 中已存在的 operation,也不得以 operation 存在推定页面已经完成。
|
||||
>
|
||||
> 记录规则:每一页必须同时给出业务动作、请求合同、响应字段、页面展示字段和完成状态。`已发布`是 Apifox 文档状态,不是客户端完成状态;`DECLARED_UNVERIFIED` 不得写成完成。
|
||||
|
||||
## F 家族内容
|
||||
|
||||
### F01 家族动态列表
|
||||
|
||||
| 核对项 | Apifox 桌面端实读 | 当前页面实情 | 结论 |
|
||||
| --- | --- | --- | --- |
|
||||
| 读取列表 | `GET /genealogy/app/genealogies/{genealogyId}/feeds`;鉴权 `Authorization`;路径 `genealogyId:int64` 必填;Header `clientid:string` 必填 | 页面已删除 `listFamilyFeedFixtures`,不再调用缺 DTO 的列表响应来填充动态卡片 | 读取 owner 存在但展示未接线,等待可消费条目 DTO |
|
||||
| 响应字段 | `200 ListResult` 仅实读到通用 `code`、`msg`、`data[]`,`data` 元素未声明动态 DTO 字段 | 页面不再展示 `id/tag/time/title/content/author` 等本地字段;只提示缺失的字段合同 | 不能建立真实字段映射;不得猜测字段名 |
|
||||
| 页面状态 | 进入发布页与跨模块入口均保留 | 有效家谱下明确显示“动态列表待后端字段合同” | **未完成 / DECLARED_UNVERIFIED**:待 Apifox 补充动态条目 DTO,或在获准的登录只读窗口取得脱敏真实响应后再恢复列表与详情入口 |
|
||||
|
||||
### F02 发布家族动态
|
||||
|
||||
| 核对项 | Apifox 桌面端实读 | 当前页面实情 | 结论 |
|
||||
| --- | --- | --- | --- |
|
||||
| 发布动作 | `POST /genealogy/app/genealogies/{genealogyId}/feeds`;鉴权 `Authorization`;路径 `genealogyId:int64`、Header `clientid:string` 均必填 | `appApi.createFeed` 使用严格请求和离页取消;页面不再生成本地预览 | 已接线,等待真实写入响应核验 |
|
||||
| 请求体 | `application/json`:`feedContent:string` 必填且不能为空;`feedType:string` 可选,未传默认 `text`;`mediaOssIds:string` 可选,多个 OSS ID 用英文逗号分隔;`sortOrder:int64` 可选,未传默认 `0`;`status:string` 可选,未传默认正常状态 `0` | 表单收集并提交内容、动态类型、通过上传回执取得的媒体和排序值;`status` 使用服务端已声明的默认值,不暴露“0”之类状态码输入 | 已接线;真实写入仍待人工响应核验 |
|
||||
| 响应字段 | `200 ObjectResult` 只声明通用 `code`、`msg`、`data:object`,未声明新动态 DTO | 仅在严格成功信封后显示“已提交服务端”;不会在 F01 生成本地列表项 | **未完成 / DECLARED_UNVERIFIED**:接线不等于已验证;真实写入保留人工可观察窗口 |
|
||||
|
||||
### F03 动态详情与评论
|
||||
|
||||
| 核对项 | Apifox 桌面端实读 | 当前页面实情 | 结论 |
|
||||
| --- | --- | --- | --- |
|
||||
| 动态详情 | `GET /genealogy/app/genealogies/{genealogyId}/feeds/{feedId}`;鉴权 `Authorization`;`genealogyId:int64`、`feedId:int64`、`clientid:string` 必填 | 页面已删除 `findFamilyFeedFixture`;因详情响应仍未声明动态本体 DTO,不读取并展示猜测字段 | 正文展示仍未接线,等待可消费响应字段 |
|
||||
| 详情响应 | `200 ObjectResult` 只有通用 `code`、`msg`、`data:object`,没有动态本体 DTO | 页面不展示 `tag/time/title/content/author` 等正文 fixture 字段,只显示字段合同缺口 | 动态本体字段仍不能映射,不能猜测 |
|
||||
| 一级评论读取 | `GET /genealogy/app/genealogies/{genealogyId}/feeds/{feedId}/comments`;同样要求鉴权、两个路径 ID 和 `clientid`。接口说明:仅返回正常展示的一级评论,`replyCount` 为直属回复数 | `appApi.getFeedComments` 真实读取;无远端配置或响应不合同时显示错误,不回退 fixture | 已接线,等待真实响应核验 |
|
||||
| 评论响应字段 | `data: FamilyFeedCommentView[]`:`commentId`、`genealogyId`、`feedId`、`parentCommentId`、`appUserId`、`appUserNickName`、`appUserAvatar`、`parentAppUserId`、`parentAppUserNickName`、`commentContent`、`userDeleted`、`replyCount`、`commentLevel`、`status`、`createTime` | 展示 `id ← commentId`、`author ← appUserNickName`、`time ← createTime`、`content ← commentContent`、`replyCount ← replyCount`;归属 ID 与重复 ID 在 API 边界校验 | 已接线,等待真实响应字段核验 |
|
||||
| 发表评论 | `POST /genealogy/app/genealogies/{genealogyId}/feeds/{feedId}/comments`;请求体 `parentCommentId:int64|null` 可选(不传或 `null` 为一级评论)、`commentContent:string` 必填,最大 1000 字符 | 提交 `commentContent`,限制 1000 字;仅在服务端请求成功并刷新评论列表后提示提交成功 | **未完成 / DECLARED_UNVERIFIED**:接口调用已接线,但未在无人值守时发起写入,也没有真实成功响应证据;动态本体仍缺 DTO |
|
||||
|
||||
### F04—F06 谱文列表、详情与编辑
|
||||
|
||||
| 页面/动作 | Apifox 桌面端实读 | 当前页面展示或输入 | 结论 |
|
||||
| --- | --- | --- | --- |
|
||||
| F04 列表 | `GET /genealogy/app/genealogies/{genealogyId}/articles`;鉴权、`genealogyId:int64`、`clientid:string` 必填;`200 ListResult` 仅通用 `code/msg/data[]`,条目未声明 DTO | 已删除 fixture、分类和本地搜索;页面明确提示缺失文章 ID、分类、标题、摘要、作者和更新时间投影 | **未完成 / DECLARED_UNVERIFIED**:没有可审计的条目字段映射,不能接线或把本地筛选误称服务端能力 |
|
||||
| F05 详情 | `GET /genealogy/app/genealogies/{genealogyId}/articles/{articleId}`;鉴权、`genealogyId:int64`、`articleId:int64`、`clientid:string` 必填;`200 ObjectResult` 仅通用对象 DTO | 已删除 fixture 正文与编辑跳转;只显示正文 DTO 缺口 | **未完成 / DECLARED_UNVERIFIED**:正文、作者、时间投影均未由详情响应声明 |
|
||||
| F06 新建 | `POST /genealogy/app/genealogies/{genealogyId}/articles`;鉴权、`genealogyId:int64`、`clientid:string` 必填 | `appApi.createArticle` 严格提交标题、摘要、封面上传回执、正文、作者和排序;成功仅表示服务端成功信封,不生成本地文章 | 已接线,等待真实写入响应核验 |
|
||||
| F06 修改 | `PUT /genealogy/app/genealogies/{genealogyId}/articles/{articleId}`;鉴权、两个路径 ID、`clientid:string` 必填 | 已移除 fixture 编辑预填;没有可靠详情 DTO 和文章 ID 列表来源时,编辑入口关闭 | **未完成 / DECLARED_UNVERIFIED** |
|
||||
| 新建/修改请求体 | `categoryId:int64` 可选;`articleTitle:string` 必填;`articleSummary:string` 可选;`coverOssId:int64` 可选;`articleContent:string` 必填;`authorName:string`、`sortOrder:int64`、`status:string` 均可选。返回均为通用 `ObjectResult` | 新建页收集可读字段;封面只接收真实上传回执。`categoryId` 需要分类列表条目 DTO,`status` 需要状态字典,二者均不让用户填写内部 ID/码 | 可写字段已接线;分类与状态选择仍 **DECLARED_UNVERIFIED**,编辑仍需可靠 articleId/详情 owner |
|
||||
|
||||
### F07—F09 相册、照片墙与上传
|
||||
|
||||
| 页面/动作 | Apifox 桌面端实读 | 当前页面展示或输入 | 结论 |
|
||||
| --- | --- | --- | --- |
|
||||
| F07 相册列表 | `GET /genealogy/app/genealogies/{genealogyId}/albums`;鉴权、`genealogyId:int64`、`clientid:string` 必填;`200 ListResult` 仅通用数组 DTO | 已删除 fixture 相册卡片和本地预览,页面明确提示缺失相册 ID、封面、名称、照片数、描述和更新时间字段 | **未完成 / DECLARED_UNVERIFIED**:相册条目、封面 URL、照片数和更新时间没有响应字段来源 |
|
||||
| F07 新建相册 | `POST /genealogy/app/genealogies/{genealogyId}/albums`;请求体 `albumName:string` 必填,`albumDesc:string`、`coverOssId:int64`、`sortOrder:int64`、`status:string` 可选;返回通用 `ObjectResult` | 表单提交名称、说明、真实封面上传回执和排序;`status` 缺可读字典,不向用户暴露码值 | 已接线,等待真实写入响应核验 |
|
||||
| F08 照片墙读取 | `GET /genealogy/app/genealogies/{genealogyId}/albums/{albumId}/photos`;鉴权、`genealogyId:int64`、`albumId:int64`、`clientid:string` 必填;`200 ListResult` 仅通用数组 DTO | 已删除 fixture 相册和照片墙,只显示缺失照片展示字段的状态 | **未完成 / DECLARED_UNVERIFIED**:缺相册与照片展示 DTO,不能猜 OSS URL、标题或说明字段 |
|
||||
| F09 写入照片记录 | `POST /genealogy/app/genealogies/{genealogyId}/albums/{albumId}/photos`;路径两个 ID、鉴权、`clientid` 必填;`ossId:int64` 必填,`photoTitle/photoDesc/photographer/shootTime/sortOrder/status` 可选 | 已删除 mock 图片库、说明表单和本地预览 | **未完成 / BLOCKED_BY_MEDIA_OWNER**:接口接受的是既有 `ossId`,当前页面没有已核实的文件上传 owner 和真实 OSS 回执,不能把本地图片冒充上传成功 |
|
||||
|
||||
### F10 短视频
|
||||
|
||||
| 核对项 | Apifox 桌面端实读 | 当前页面/结论 |
|
||||
| --- | --- | --- |
|
||||
| 目录检索 | 以 `video` 检索,APP 目录仅返回“删除视频”;未返回视频列表、详情、发布、修改、播放地址、评论、点赞或分享 operation | F10 所需浏览和互动链路没有业务 owner,不能以参考项目或相册接口补造 |
|
||||
| 唯一命中动作 | `DELETE /genealogy/app/genealogies/{genealogyId}/videos/{videoId}`;接口说明为逻辑删除并释放视频文件和封面文件引用;鉴权、`genealogyId:int64`、`videoId:int64`、`clientid:string` 必填,`200 VoidResult` | 单一删除动作不能证明视频页面能读取、播放或发布;**F10 未完成 / MISSING_OPERATION**。不发起删除请求 |
|
||||
|
||||
## G 家谱工作区
|
||||
|
||||
### G01、G03、G05—G11
|
||||
|
||||
| 页面/动作 | Apifox 桌面端实读 | 当前页面状态与结论 |
|
||||
| --- | --- | --- |
|
||||
| G01 我的家谱 | `GET /genealogy/app/genealogies/mine`;鉴权、`clientid:string` 必填;`200 ListResult` 仅通用 `code/msg/data[]` | 页面要展示当前家谱、可切换家谱、角色与快捷入口;当前 DTO 没有这些字段。**未完成 / DECLARED_UNVERIFIED** |
|
||||
| G03 创建家谱 | `POST /genealogy/app/genealogies`;`genealogyName`、`surname`、`regionCode` 必填;`ancestralHall/originPlace/addressDetail/coverOssId/intro/visibility/joinMode` 可选。`visibility`:`0` 私密、`1` 公开、`2` 成员可见;`joinMode`:`0` 关闭、`1` 审核、`2` 邀请码 | 表单提供全部可读资料字段;地区为三级级联选择器,封面只由真实上传回执产生,访问规则由可读选项映射为 `visibility/joinMode`。已删除本地家谱/首位人物预览。创建后必须从真实响应取得 `genealogyId` 再创建首位人物;当前没有可恢复查询 owner,不能从泛型 mine 列表按名称猜 ID | **未完成 / MISSING_OPERATION**:两阶段创建结果恢复链未闭合;写入与上传已接线,仍待人工真实响应核验 |
|
||||
| G05 家谱概览 | `GET /genealogy/app/genealogies/{genealogyId}/overview`;鉴权、`genealogyId:int64`、`clientid` 必填;`200 ObjectResult` 通用对象 | 页面需要家谱资料、成员/人物等概览显示;响应无 DTO。**未完成 / DECLARED_UNVERIFIED** |
|
||||
| G06 搜索公开家谱 | `GET /genealogy/app/genealogies/public` 已在 Apifox 目录确认;读取结果仍为通用 `ListResult` | 已删除本地搜索结果和申请跳转;名称、籍贯、简介、可加入状态、稳定 genealogyId 均未获声明。**未完成 / DECLARED_UNVERIFIED** |
|
||||
| G08 申请加入 | `POST /genealogy/app/genealogies/{genealogyId}/join-applies`;路径 `genealogyId:int64`、鉴权、`clientid` 必填;body `applicantName/phone/relationDesc/applyReason:string`、`inviterUserId:int64` 均可选 | 已删除本地填写预览;没有公开家谱详情、可申请权限或稳定 ID 投影时,不凭“可选”字段虚构申请上下文。**未完成 / DECLARED_UNVERIFIED**,不发送申请 |
|
||||
| G09 我的申请 | `GET /genealogy/app/genealogies/join-applies/mine`;鉴权、`clientid` 必填;`200 ListResult` 通用数组 DTO | 已删除 fixture 申请列表;申请名称、状态、原因、时间等展示字段无映射。**未完成 / DECLARED_UNVERIFIED** |
|
||||
| G10 审核申请 | `PUT /genealogy/app/genealogies/{genealogyId}/join-applies/{applyId}/audit`;路径两个 ID、鉴权、`clientid` 必填;`status:string` 必填,`auditRemark:string` 可选,返回 `VoidResult` | 已删除本地审核流程;待审核列表 DTO 和稳定 applyId 未声明。**未完成 / DECLARED_UNVERIFIED**,不执行审核写入 |
|
||||
| G11 家谱设置 | `PUT /genealogy/app/genealogies/{genealogyId}`;同一组字段为 `genealogyName/surname/ancestralHall/originPlace/regionCode/addressDetail/coverOssId/intro/visibility/joinMode`,文档均列可选 | 已删除 fixture 预填和本地预览;概览 DTO 不能安全预填,封面仍受上传 owner 阻塞。**未完成 / DECLARED_UNVERIFIED** |
|
||||
|
||||
### G12 字辈谱
|
||||
|
||||
| 核对项 | Apifox 桌面端实读 | 当前页面实情 | 结论 |
|
||||
| --- | --- | --- | --- |
|
||||
| 正常字辈读取 | `GET /genealogy/app/genealogies/{genealogyId}/generation-poems`;鉴权 `Authorization`、路径 `genealogyId:int64`、Header `clientid:string` 均必填。说明为仅返回正常状态字辈,供世系人物录入和展示使用 | 页面通过 `appApi.getGenerationPoems` 真实读取,展示 `generationNo/generationText/status`;响应归属、重复 poemId 和重复世代在 API 边界校验 | 已接线,等待真实响应核验;响应没有“当前世代”字段,页面已删除固定当前世代推断 |
|
||||
| 维护列表读取 | `GET /genealogy/app/genealogies/{genealogyId}/generation-poems/management`;同一鉴权、路径和 `clientid` 要求。说明为家谱内容编辑者访问,返回正常与停用字辈,供恢复、纠错和排序调整 | 点击维护先真实请求 `appApi.getGenerationPoemManagement`,成功才进入编辑;不再用 fixture 或角色推断权限 | 已接线,等待真实响应/权限核验;维护读取失败不伪造“无权限”或本地编辑状态 |
|
||||
| 单条新增/修改/停用恢复 | `POST /genealogy/app/genealogies/{genealogyId}/generation-poems`;`PUT /genealogy/app/genealogies/{genealogyId}/generation-poems/{poemId}`。后者路径另有 `poemId:int64` 必填;两者 body 均为:`generationNo:int64` 必填、`generationText:string` 必填且最大 50 字、`description:string` 可选且最大 500 字、`sortOrder:int64` 可选、`status:string` 可选(`0` 正常、`1` 停用) | 当前编辑器以一段本地文本拆分生成字辈行;没有单条 request mapper 或服务端返回处理 | 单条合同已明确,当前页面交互是批量维护模型;不能把本地状态切换写成停用/恢复成功,**未完成 / DECLARED_UNVERIFIED** |
|
||||
| 批量预览 | `POST /genealogy/app/genealogies/{genealogyId}/generation-poems/batch/preview`;body `poemText:string` 必填、最大 26000 字,最多 500 世,可用空格、逗号、分号、顿号、斜杠或换行分隔;`disableMissing:boolean` 可选 | `appApi.previewGenerationPoemBatch` 只提交 `poemText/disableMissing`;页面展示服务端 `createCount/updateCount/keepCount/disableCount`,草稿或策略变化即使旧预览失效 | 响应 `GenerationPoemBatchPreviewView` 的计数字段和家谱归属已校验,等待真实响应核验;不再使用本地差异作为保存依据 |
|
||||
| 批量保存 | `POST /genealogy/app/genealogies/{genealogyId}/generation-poems/batch/save`;请求体与批量预览相同;接口说明为按当前数据生成差异,停用不删除历史字辈记录;返回 `VoidResult` | 保存仅在当前草稿已有同签名服务端预览时调用,严格成功后读取维护列表;本地不再更新或宣称保存成功 | **未完成 / DECLARED_UNVERIFIED**:写入已接线,但无人值守未触发真实保存;需人工可观察结果和真实回读才能升级状态 |
|
||||
|
||||
## T 世系树与成员
|
||||
|
||||
### T01 世系树与人物操作面板
|
||||
|
||||
| 核对项 | Apifox 桌面端实读 | 当前页面实情 | 结论 |
|
||||
| --- | --- | --- | --- |
|
||||
| 世系树读取 | `GET /genealogy/app/genealogies/{genealogyId}/lineage/tree`;鉴权 `Authorization`、路径 `genealogyId:int64`、Header `clientid:string` 均必填;`200 LineagePersonTreeResult` | `pages/tree/t01-tree-overview.vue` 已调用 `appApi.getTree`,但尚未作真实响应验证 | 不是“无接口”,但不能因代码存在而宣称页面已完成 |
|
||||
| 树节点响应字段 | `data: LineagePersonTreeView[]`:`personId/genealogyId/genealogyName/genealogyNo/appUserId/appUserNickName/personNo/name/aliasName/sex/generation/generationName/fatherId/fatherName/motherId/motherName/spouseNames/avatarOssId/birthDate/birthLunar/birthPlace/deathDate/deathLunar/deathPlace/burialPlace/personStatus/biography/sortOrder/status/remark/relationType/relationName/spouses[]/children[]`;`spouses` 为配偶节点、`children` 为递归子女节点 | 当前 mapper 只投影树布局所需 `id/parentId/name/relation/generation/branch/years/sex/personStatus`;人物卡和操作面板头像固定使用本地占位图,未消费 `avatarOssId`;父母、配偶、子女等可用响应关系也未完整展示 | **未完成 / DECLARED_UNVERIFIED**:必须补头像文件取址与字段投影,并在真实只读响应下核验树形关系,才能满足人物卡要求 |
|
||||
| 点击人物后的动作 | 页面已有“查看资料、添加父亲/母亲/配偶/兄弟姐妹/儿子/女儿、调整排行、编辑信息”动作入口,分别路由 T03/T04/T06/T05 | 父母、子女的 HTTP 动作实际分别共享 `/parents`、`/children`,页面未发送 `sex` 或 `relationName`,因此不能区分“父亲/母亲”“儿子/女儿”;邀请绑定在页面中明确标作不可用 | **未完成**:操作面板存在不等于每个业务动作闭环;性别语义和邀请绑定仍缺合同闭环 |
|
||||
| 邀请绑定 | 在 Apifox APP 目录分别以 `invite`、`bind` 全文检索,均未命中任何邀请签发、受邀人查询、人物绑定、绑定结果查询 operation | 页面也未伪造该流程 | **MISSING_OPERATION**:不以入谱申请或普通人物修改替代“邀请绑定” |
|
||||
|
||||
### T03 成员资料
|
||||
|
||||
| 核对项 | Apifox 桌面端实读 | 当前页面实情 | 结论 |
|
||||
| --- | --- | --- | --- |
|
||||
| 读取详情 | `GET /genealogy/app/genealogies/{genealogyId}/lineage/persons/{personId}`;鉴权、`genealogyId:int64`、`personId:int64`、`clientid` 均必填;`200 LineagePersonResult` | 页面调用 `appApi.getPerson` | 读取路径存在,但不是完成依据 |
|
||||
| 详情响应字段 | `data: LineagePersonView`:`personId/genealogyId/genealogyName/genealogyNo/appUserId/appUserNickName/personNo/name/aliasName/sex/generation/generationName/fatherId/fatherName/motherId/motherName/spouseNames/avatarOssId/birthDate/birthLunar/birthPlace/deathDate/deathLunar/deathPlace/burialPlace/personStatus/biography/sortOrder/status/remark` | 已把别名、性别字典值、人物状态字典值、出生/逝世农历、出生/逝世地点、安葬地、配偶名、生平和备注纳入 T03 mapper 与展示;仍未展示头像(缺 `avatarOssId` 取址)、`appUserNickName`、排序/状态原值,亲属仍只可由父母 ID 跳转。 | **未完成 / DECLARED_UNVERIFIED**:T03 仍是半成品,不能计入完成;字段已接线但未用真实只读响应核验,头像、完整亲属投影和字典语义仍未闭环。 |
|
||||
|
||||
### T04 添加亲属、T05 编辑、T06 排行
|
||||
|
||||
| 页面/动作 | Apifox 桌面端实读 | 当前页面实情与结论 |
|
||||
| --- | --- | --- |
|
||||
| T04 首位成员/新增人物 | `POST /genealogy/app/genealogies/{genealogyId}/lineage/persons`;鉴权、路径 `genealogyId`、`clientid` 必填;body 只有 `name:string` 必填。可选字段为 `appUserId/personNo/aliasName/sex/generation/generationName/fatherId/motherId/avatarOssId/birthDate/birthLunar/birthPlace/deathDate/deathLunar/deathPlace/burialPlace/personStatus/biography/sortOrder/remark/relationName`;其中 `personNo` 由服务端生成,`avatarOssId` 须来自文件上传组件 | 表单已提供并提交当前可安全映射的 `aliasName/generationName/birthDate/birthLunar/birthPlace/deathDate/deathLunar/deathPlace/burialPlace/biography/remark`;非首位成员同时提交关系选择产生的 `relationName`。性别、人物状态缺字典 owner;关联账号、父母、头像与排行需要独立对象选择、上传或原子排序 owner,不能让用户填写内部 ID 或猜码。真实写入未验证。**未完成 / DECLARED_UNVERIFIED** |
|
||||
| T04 添加父母、子女、兄弟姐妹、配偶 | 分别为 `POST .../lineage/persons/{personId}/parents`、`.../children`、`.../siblings`、`.../spouses`;路径 `genealogyId/personId`、鉴权、`clientid` 均必填,均返回 `LineagePersonResult`,body 与新增人物同合同。实读 `sex:string` 仅写“建议使用系统字典值”,示例为 `"0"`;以“字典/dict/性别”检索 APP/PC 目录均未找到该字典读取 owner 或男/女码值映射。 | 页面现会提交所选亲属的 `relationName`,不再把路由意图丢掉;父亲/母亲共用 `/parents`,儿子/女儿共用 `/children` 仍不能仅凭该显示名获得可靠性别语义。头像仍缺文件上传回执。**未完成 / DECLARED_UNVERIFIED**,不得拿参考项目的旧 `0/1` 码值猜填,也不能执行无人值守写入。 |
|
||||
| T05 修改人物 | `PUT /genealogy/app/genealogies/{genealogyId}/lineage/persons/{personId}`;两个路径 ID、鉴权、`clientid` 必填,body 与新增人物同合同,返回 `LineagePersonResult` | 已读写 `name/aliasName/generationName/birthDate/birthLunar/birthPlace/deathDate/deathLunar/deathPlace/burialPlace/biography/remark`;静态校验确认表单字段与请求白名单一致。头像仍缺上传回执;性别、人物状态、排行因字典或原子 owner 缺失未写入。 | **未完成 / DECLARED_UNVERIFIED**:已扩展为已声明的安全字段子集,但没有真实保存后的响应/回读,不能称完整人物编辑。 |
|
||||
| T06 调整排行 | Apifox 只有单人物 `PUT .../persons/{personId}` 中的可选 `sortOrder:int64`,没有同辈排行列表、批量重排、原子提交或冲突回显 operation | 页面已明确显示“服务暂未开放”,不逐人写入 | **未完成 / MISSING_OPERATION**:不能以单人 `sortOrder` 伪造同辈原子排行调整 |
|
||||
|
||||
### T07 成员目录、T08 成员状态
|
||||
|
||||
| 页面/动作 | Apifox 桌面端实读 | 当前页面实情与结论 |
|
||||
| --- | --- | --- |
|
||||
| T07 成员目录分页与搜索 | `GET /genealogy/app/genealogies/{genealogyId}/lineage/persons/page`;query 可选 `pageNum`(默认 1)、`pageSize`(默认 10)、`keyword`(姓名/别名/人物编号)、`generation:int64`、`personStatus:string`;响应 `LineagePersonPageResult` 为 `rows: LineagePersonView[]` 与 `total`。另有 `GET .../lineage/persons/options?keyword=` 供人物选项读取 | 已移除 `listTreeMemberPresentationFixtures`;页面使用实际 `pageNum/pageSize/keyword` 请求、消费 `rows/total`,支持服务端搜索与继续加载;本地预览明确报真实读取不可用,不伪造目录数据。 | **未完成 / DECLARED_UNVERIFIED**:读取合同已接线并经静态检查,尚未用登录态获得一次真实 `rows/total` 响应;世代/人物状态筛选尚未增加页面控件。 |
|
||||
| T08 成员状态说明 | 人物详情、列表和分页都提供 `personStatus`、`status`;停用人物为 `DELETE /genealogy/app/genealogies/{genealogyId}/lineage/persons/{personId}`,接口说明为逻辑停用且在正常子女时拒绝停用,返回 `VoidResult` | 已移除 `findTreeMemberPresentationFixture` 和 `privacy/deceased/forbidden` 推断;页面读取人物详情,但在无状态字典时不展示原始 `personStatus` 值,也不提供停用写入。 | **未完成 / DECLARED_UNVERIFIED**:读取已接线并经静态检查,仍未用真实只读响应核验;没有字典映射时不得展示内部码或生成隐私/受限/纪念文案。 |
|
||||
|
||||
## R 记录模块
|
||||
|
||||
### R01 人物录、R02 人物档案
|
||||
|
||||
| 页面/动作 | Apifox 桌面端实读 | 当前页面展示或输入 | 结论 |
|
||||
| --- | --- | --- | --- |
|
||||
| R01 人物录列表、搜索 | 正确读取 owner 是 `GET /genealogy/app/genealogies/{genealogyId}/lineage/persons/page`;query 为 `pageNum/pageSize/keyword/generation/personStatus`,响应为 `rows: LineagePersonView[]/total`。人物选项另有 `GET .../lineage/persons/options?keyword=` | 已移除 `listTreeMemberPresentationFixtures` 和本地“新增预览”;页面用 `pageNum/pageSize/keyword` 请求、消费 `rows/total`,支持服务端搜索和继续加载。新增人物保持从 T01 亲属关系入口进入。 | **未完成 / DECLARED_UNVERIFIED**:读取合同已接线并经静态检查,尚未用登录态获得一次真实响应;世代/人物状态筛选尚未增加页面控件。 |
|
||||
| R02 人物档案读取 | `GET /genealogy/app/genealogies/{genealogyId}/lineage/persons/{personId}` 返回已在 T03 实读的 `LineagePersonView`,含 `name/generation/generationName/biography/remark`,以及头像、性别、别名、亲属名、地点、生卒、状态等 | 已移除 `findTreeMemberPresentationFixture`、本地预览/编辑;页面读取详情并展示已声明的资料字段,编辑入口改为跳转 T03 的成员档案,再由 T05 完成可写字段维护。成长日志仍跳 R08,人生事仍为待开放。 | **未完成 / DECLARED_UNVERIFIED**:读取已接线并经静态检查,尚未取得真实详情响应;头像取址、性别/状态字典和完整亲属投影仍未闭环。 |
|
||||
| R02 新建/编辑人物 | `POST /lineage/persons`、`PUT /lineage/persons/{personId}` 的完整人物请求合同已在 T04/T05 实读,`name` 必填,其余有世代、头像、亲属、状态、排序、传记、备注等字段 | 表单只收 `name/generationName/generation/biography/remark`,保存仅变成本地预览 | **未完成 / DECLARED_UNVERIFIED**:是可辨认的字段子集但没有远端读写闭环;不得把“生成本地预览”说成新建或修改成功 |
|
||||
|
||||
### R03 贺礼簿、R04 往来详情与编辑
|
||||
|
||||
| 页面/动作 | Apifox 桌面端实读 | 当前页面展示或输入 | 结论 |
|
||||
| --- | --- | --- | --- |
|
||||
| R03 列表、R04 详情 | `GET /genealogy/app/genealogies/{genealogyId}/relative-records`、`GET .../relative-records/{relativeId}`;均需鉴权、`genealogyId`、`clientid`,分别返回通用 `ListResult`、`ObjectResult`,未声明条目 DTO | 已删除 R03/R04 fixture 列表、详情和本地编辑预填;页面明确说明缺记录 ID、关系、事项、时间、金额和备注的响应映射 | **未完成 / DECLARED_UNVERIFIED**:接口并非缺失,但响应没有声明 `relativeId` 等展示字段,不能猜字段映射 |
|
||||
| R04 新增/修改 | `POST /genealogy/app/genealogies/{genealogyId}/relative-records`、`PUT .../relative-records/{relativeId}`;body 为 `relativeName:string` 必填,`relationName/eventName/eventTime/giftAmount:number/recordContent/mediaOssIds/sortOrder/status` 可选,返回通用 `ObjectResult` | 创建页提交姓名、关系、事项、时间、金额、备注、真实媒体回执和排序;`status` 无可读字典,不向用户暴露状态码。详情/修改入口因无记录 DTO/ID 来源关闭 | 创建已接线,等待真实写入响应核验;修改仍 **DECLARED_UNVERIFIED** |
|
||||
| R04 删除 | `DELETE .../relative-records/{relativeId}` 已在 Apifox 同一资源目录确认 | 页面明确显示“删除暂未开放” | **未完成 / DECLARED_UNVERIFIED**:不执行删除;存在删除 operation 也不改变其他读写未接线的事实 |
|
||||
|
||||
### R05—R07 礼仪活动与献礼
|
||||
|
||||
| 页面/动作 | Apifox 桌面端实读 | 当前页面展示或输入 | 结论 |
|
||||
| --- | --- | --- | --- |
|
||||
| R05 礼仪活动列表、R07 新建 | 在 APP 目录以 `ceremony` 实读到该资源共六个动作:详情、修改、活动献礼列表、新增献礼、删除活动、删除献礼;没有活动列表或新建活动 operation | 已删除 `listCeremonyFixtures`、新建和编辑预览;R05/R07 显示缺 operation 状态并保留返回路径 | **未完成 / MISSING_OPERATION**:不得用详情或修改接口冒充活动列表/新建;R05、R07 的主业务 owner 缺失 |
|
||||
| R06 礼仪详情 | `GET /genealogy/app/genealogies/{genealogyId}/ceremonies/{ceremonyId}`;需鉴权、路径 `genealogyId/ceremonyId`、`clientid`,返回通用 `ObjectResult`;未声明活动 DTO | 已删除 fixture、受邀人拼装和编辑入口,只提示详情字段缺口 | **未完成 / DECLARED_UNVERIFIED**:详情 operation 存在,但这些展示字段、受邀人及其关系没有响应字段依据 |
|
||||
| R07 修改礼仪 | `PUT .../ceremonies/{ceremonyId}`;body `ceremonyType:string`、`ceremonyTitle:string` 必填,`ceremonyDesc/ceremonyTime/location/coverOssId/sortOrder/status` 可选,返回通用 `ObjectResult` | 无可靠详情 DTO 和活动 ID 来源时关闭修改,且封面另缺上传 owner | 表单字段虽可对应,但没有远端读取、写入和回读;**未完成 / DECLARED_UNVERIFIED** |
|
||||
| R06 献礼 | `GET .../ceremonies/{ceremonyId}/gifts` 返回通用 `ListResult`;`POST .../gifts` 的 body 为 `giverName:string` 可选、`giftAmount:number` 必填、`giftMessage:string` 可选,返回通用对象 | 页面有受邀人展示,不是献礼条目展示或写入 | **未完成 / DECLARED_UNVERIFIED**:献礼接口不能替代受邀信息,且没有条目 DTO 可映射 |
|
||||
|
||||
### R08 成长日志、R09 人生事
|
||||
|
||||
| 页面/动作 | Apifox 桌面端实读 | 当前页面展示或输入 | 结论 |
|
||||
| --- | --- | --- | --- |
|
||||
| R08 列表与详情 | `GET /genealogy/app/genealogies/{genealogyId}/growth-records`、`GET .../growth-records/{recordId}`;同一 APP 资源另有新增、修改、删除,共五个动作。列表为通用 `ListResult`、详情为通用 `ObjectResult`,均未声明记录 DTO | 已删除 `listGrowthRecordFixtures` 和本地列表预览;页面只保留创建表单 | **未完成 / DECLARED_UNVERIFIED**:`recordId`、标题、日期、内容没有响应字段声明,不能恢复列表或详情 |
|
||||
| R08 新增/修改 | `POST .../growth-records`、`PUT .../growth-records/{recordId}`;新增 body 已实读:`lineagePersonId/recordType/recordContent/recordDate/remindTime/mediaOssIds/sortOrder/status` 可选,`recordTitle:string` 必填 | 从人物档案进入时自动绑定当前人物,表单提交类型、标题、内容、日期、提醒、真实媒体回执和排序;`status` 无可读字典,不向用户暴露状态码。修改没有记录 ID 来源而关闭 | 创建已接线,等待真实写入响应核验;修改 **DECLARED_UNVERIFIED** |
|
||||
| R09 人生事 | 分别以 `life` 与“人生”在 APP 接口目录检索,均未命中独立人生事件资源;当前文档中不能用成长、备忘或人物资料替代 | 页面已明确提示接口未开放且不展示/提交数据 | **未完成 / MISSING_OPERATION**:保持关闭是正确的,不虚构读写链路 |
|
||||
|
||||
### R10 家族备忘、R11 功德记录
|
||||
|
||||
| 页面/动作 | Apifox 桌面端实读 | 当前页面展示或输入 | 结论 |
|
||||
| --- | --- | --- | --- |
|
||||
| R10 备忘列表与详情 | `GET /genealogy/app/genealogies/{genealogyId}/memos`、`GET .../memos/{memoId}`;同一资源另有新增、修改、删除,共五个动作。列表 `ListResult`、详情 `ObjectResult` 都只声明通用包装字段 | 已删除 fixture 列表和本地预览,只保留创建表单 | **未完成 / DECLARED_UNVERIFIED**:不能从泛型响应推导 `memoId` 或 `completedLabel`,状态文案也没有字典依据 |
|
||||
| R10 新增/修改 | `POST .../memos`、`PUT .../memos/{memoId}`;body `memoTitle:string` 必填,`memoContent/remindTime/completed/mediaOssIds/sortOrder/status` 可选 | 表单提交标题、日期与时间选择器生成的提醒时间、内容、真实媒体上传回执和排序。`completed/status` 未声明可读值或字典,均不向用户暴露 `0/1` 码。修改缺 ID 来源关闭 | 创建已接线,等待真实写入响应核验;修改 **DECLARED_UNVERIFIED** |
|
||||
| R11 功德列表 | `GET /genealogy/app/genealogies/{genealogyId}/merit-records`;同资源仅另有新增、删除,共三个动作;列表返回通用 `ListResult`,未声明条目 DTO | 已删除 fixture 列表和本地预览,只保留创建表单 | **未完成 / DECLARED_UNVERIFIED**:列表字段没有合同映射,页面不猜条目字段 |
|
||||
| R11 新增/修改 | `POST .../merit-records`;body `donorName:string`、`meritTitle:string` 必填,`meritType/meritContent/amount:number/meritTime/sortOrder/status` 可选;当前 APP 目录未见修改 operation | 表单提交捐赠人、标题、类型、金额、时间、内容和排序;`status` 缺可读字典,不向用户暴露状态码 | 新增已接线,等待真实写入响应核验;编辑 **MISSING_OPERATION** |
|
||||
|
||||
### 2026-07-24 实际服务端回读补充(覆盖上述 R03—R11 的旧“未验证”结论)
|
||||
|
||||
已用当前测试账号在家谱 2080557121112465409 完成真实创建和列表回读;未执行删除、退出、短信或改密操作。
|
||||
|
||||
| 资源 | 实际返回字段(已回读) | 页面闭环 |
|
||||
| --- | --- | --- |
|
||||
| 亲友往来 | relativeId、relativeName、relationName、eventName、eventTime、giftAmount、recordContent、mediaOssIds、sortOrder | R04 创建后由 R03 列表回读 |
|
||||
| 礼仪活动 | ceremonyId、ceremonyType、ceremonyTitle、ceremonyDesc、ceremonyTime、location、giftCount | R07 创建后由 R05 列表回读 |
|
||||
| 成长记录 | recordId、lineagePersonId、lineagePersonName、recordType、recordTitle、recordContent、recordDate、remindTime、mediaOssIds、sortOrder | R08 先读列表,创建后重新读取 |
|
||||
| 家族备忘 | memoId、memoTitle、memoContent、remindTime、completed、mediaOssIds、sortOrder | R10 先读列表,创建后重新读取;completed 未展示为业务状态 |
|
||||
| 功德记录 | meritId、donorName、meritType、meritTitle、meritContent、amount、meritTime、sortOrder | R11 先读列表,创建后重新读取并计算金额合计 |
|
||||
|
||||
以上五组的列表与创建状态均为 VERIFIED。单条详情、修改和删除仍按页面当前入口及测试范围分别保留,不把未测试能力标记为通过。
|
||||
|
||||
世系首位成员创建于同一测试家谱进行了页面提交和合同最小请求复核:POST lineage/persons 的合法最小 body(name、generation=1)均返回 HTTP 200、业务 code 500、消息“发生未知异常,请联系管理员”;随后 GET 世系树仍为空。该项为服务端业务异常,前端未猜测额外字段或伪造创建成功;T04 首位成员失败文案已明确为“首位成员尚未保存”。
|
||||
|
||||
## N 消息通知
|
||||
|
||||
| 页面/动作 | Apifox 桌面端实读 | 当前页面展示或输入 | 结论 |
|
||||
| --- | --- | --- | --- |
|
||||
| N01 消息中心列表 | `GET /genealogy/app/notifications`;鉴权 `Authorization`、Header `clientid:string` 必填;`200 ListResult` 只声明通用 `code/msg/data[]`,条目仍为泛型对象 | 已删除 `listNotificationFixtures`、未读计数和本地业务跳转,只显示缺失通知字段合同 | **未完成 / DECLARED_UNVERIFIED**:没有消息 ID、已读、标题、时间、正文、目标类型和目标参数的响应字段合同;不能把 fixture 的跳转当作通知接口返回能力 |
|
||||
| N02 消息详情 | 在 APP 消息通知目录实读到的仅有列表、单条标已读、全部标已读三个 operation;没有详情读取 operation | 已删除详情 fixture,只显示缺详情 owner 状态 | **未完成 / MISSING_OPERATION**:不能以列表泛型或本地 fixture 冒充单条详情;详情所需正文、来源和跳转字段均无接口 owner |
|
||||
| N01/N02 单条标已读 | `POST /genealogy/app/notifications/{notificationId}/read`;鉴权、`notificationId:int64`、`clientid` 必填,返回 `VoidResult` | 无可消费通知 ID 时页面不显示单条标已读,已删除本地 `unread` 修改 | 正确写入 owner 存在但没有可回读 item/ID,**未完成 / DECLARED_UNVERIFIED** |
|
||||
| N01 全部标已读 | `POST /genealogy/app/notifications/read-all`;鉴权、`clientid` 必填,返回 `VoidResult` | 已删除“全部已读”本地 fixture 修改 | **未完成 / DECLARED_UNVERIFIED**:无列表回读时不把本地状态改动当服务端写入成功 |
|
||||
|
||||
## M 个人中心与账号
|
||||
|
||||
### M01 个人中心、M02 个人资料
|
||||
|
||||
| 页面/动作 | Apifox 桌面端实读 | 当前页面展示或输入 | 结论 |
|
||||
| --- | --- | --- | --- |
|
||||
| M01 当前用户资料 | `GET /genealogy/app/auth/profile`;鉴权、`clientid` 必填,返回通用 `ObjectResult`,`data` 未声明用户 DTO | 已删除 `currentUser.name/role/phone` 和 fixture 未读数展示,保留各模块入口 | **未完成 / DECLARED_UNVERIFIED**:用户名、角色、手机号及其脱敏规则没有响应字段合同;不可把 mock 当前用户当作已登录资料 |
|
||||
| M02 读取与修改资料 | 读取为同一 `GET /auth/profile`;修改为 `PUT /genealogy/app/auth/profile`。修改 body 已实读:`nickName/avatarOssId/sex/birthday/provinceCode/cityCode/districtCode/addressDetail` 均可选,返回通用 `ObjectResult` | 已删除 mock 预填和本地保存;当前 UI 的真实姓名/邮箱与更新合同不相交,页面明确关闭编辑 | `nickName` 可对应,但 `realName/email` 不在修改合同;后端的头像、性别、生日、地区、地址未有页面输入或 mapper。**未完成 / DECLARED_UNVERIFIED**;头像另受上传 owner 阻塞 |
|
||||
|
||||
### M03—M05 安全设置、改密、换绑手机
|
||||
|
||||
| 页面/动作 | Apifox 桌面端实读 | 当前页面展示或输入 | 结论 |
|
||||
| --- | --- | --- | --- |
|
||||
| M03 账号与安全概览 | 资料读取、改密、换绑均有各自 APP operation,未见独立“安全概览/设备/登录记录”读取 operation | 已删除本地账号摘要,保留改密与换绑入口 | **未完成 / DECLARED_UNVERIFIED**:入口可以保留,但安全状态、设备、会话等没有 owner,不能凭本地提示宣称已核验 |
|
||||
| M04 修改密码 | `PUT /genealogy/app/auth/password`;鉴权、`clientid` 必填;body `oldPassword:string`、`newPassword:string` 均必填且均为 32 位 MD5;返回 `VoidResult` | 页面将当前/新密码 MD5 后以 `oldPasswordHash/newPasswordHash` 传给 api 层,最终字段名映射为 `oldPassword/newPassword` | 请求字段、摘要格式和页面动作可对齐;但尚未在真实账号下接受响应验证,且不得无人值守改密。**未完成 / DECLARED_UNVERIFIED** |
|
||||
| M05 换绑手机号 | `PUT /genealogy/app/auth/phone`;鉴权、`clientid` 必填;body `clientId:string`、`phone:string`、`smsCode:string` 均必填,验证码模式为 4 位;响应为 `ObjectResult`(含 400/200) | 已删除 mock 当前手机号、输入和本地校验,页面明确提示需人工 TAC/短信与资料 DTO | 号码和四位码输入可对应,但缺实际滑动验证、短信发送、`clientId` 来源、写入与回读。**未完成 / DECLARED_UNVERIFIED**;不代用户发验证码或换绑 |
|
||||
|
||||
### M06 帮助、M07 反馈、M08 推广、M09 VIP、M10 关于
|
||||
|
||||
| 页面/动作 | Apifox 桌面端实读 | 当前页面展示或输入 | 结论 |
|
||||
| --- | --- | --- | --- |
|
||||
| M06 帮助中心 | `GET /genealogy/app/help-articles`、`GET /genealogy/app/help-articles/{articleId}`;列表为通用 `ListResult`、详情为通用对象,未声明文章 DTO | 页面本地内置分类、问题、答案和搜索 | **未完成 / DECLARED_UNVERIFIED**:帮助读取 owner 存在,但不能从泛型响应推导问题、答案、分类或文章 ID;当前本地说明不是服务端帮助 |
|
||||
| M07 提交反馈 | `POST /genealogy/app/feedback`;鉴权、`clientid` 必填;body `feedbackType:string` 可选、`feedbackContent:string` 必填、`contactInfo:string` 可选,返回通用 `ObjectResult` | 表单与 api 层正好提交这三字段,页面包含成功、失败、结果不确定的提示 | 请求合同已对应;未经真实接受响应验证,不能把 UI 成功态视为后端成功。**未完成 / DECLARED_UNVERIFIED**,不代用户提交反馈 |
|
||||
| M08 应用推广/邀请 | `GET /genealogy/app/promotions` 已存在,但仅为“应用推广列表”,返回通用 `ListResult`;全文检索未发现邀请码签发、归因、奖励、受邀绑定或分享回执 operation | M08 正确保持“推广能力未开放”,没有伪造邀请 | **未完成 / MISSING_OPERATION**:普通推广内容列表不能替代邀请推广业务闭环 |
|
||||
| M09 VIP 与订单 | APP 目录有 `GET /genealogy/app/vip/packages`、`GET /genealogy/app/vip/orders`、`POST /genealogy/app/vip/orders`;前两者列表响应为泛型。创建订单 body 为 `packageId:int64` 必填,`genealogyId:int64`、`payType:string` 可选,返回通用对象 | 页面当前不读取、不会创建订单或扣费 | **未完成 / DECLARED_UNVERIFIED**:套餐与订单 owner 存在但 DTO 未声明、页面未接线;支付调起、支付结果、取消/退款等动作在当前 APP 目录未形成可审计合同,故继续禁用付费流程 |
|
||||
| M10 关于与退出 | 协议、版本为本地静态内容;退出为 `DELETE /genealogy/app/auth/logout`,鉴权、`clientid` 必填,返回 `VoidResult` | M10 调用 api 层退出并且无论远端结果如何都会清本机会话 | 登出路径与合同一致,但未在真实请求下验证;协议/版本没有远端 owner 的需求。退出动作仍标 **DECLARED_UNVERIFIED**,不在无人值守状态触发 |
|
||||
|
||||
## A 认证
|
||||
|
||||
| 页面/动作 | Apifox 桌面端实读:业务接口、请求/响应字段 | 当前页面展示或输入字段 | 完成状态 |
|
||||
| --- | --- | --- | --- |
|
||||
| A01 登录:验证前置与发送短信 | `GET /captcha/require`:查询 `tenantId/clientId/sceneCode/subject`,其中 `sceneCode` 必填;响应 `VerificationRequireResult` 已声明 `required/providerCode/captchaType/sceneCode/ttlSeconds`。`POST /genealogy/app/auth/sms/code`:Header `clientid` 必填;Body `clientId/grantType/tenantId/sceneCode/phone/validToken` 均必填,`sceneCode` 含 `APP_SMS_LOGIN`;响应 `VoidResult`。 | `a01-entry.vue` 以手机号、密码或四位短信码登录;取码先查验证要求,再由内嵌验证组件提交 `validToken`。滑动验证采用服务商组件本身,不增加页面自定义样式。 | **未完成 / DECLARED_UNVERIFIED**:前置响应字段与发送短信字段已逐项对上,但未发送短信;`required=false` 时如何签发可消费票据也未由 Apifox 合同说明,不能把页面本地倒计时当发送成功。 |
|
||||
| A01 账号密码登录 | `POST /genealogy/app/auth/login`:Header `clientid` 必填;Body `clientId/grantType/tenantId/phone/password` 均必填,`grantType=password`,`password` 为 32 位 MD5;响应组件为 `LoginResult`。 | 页面将手机号和 MD5 密码传至 API 层;当前 API 层读取响应 `access_token` 保存会话。登录接口请求体没有 `validToken` 字段,页面仅把滑动验证作为前端完成条件。 | **未完成 / DECLARED_UNVERIFIED**:请求字段对齐;Apifox 当前只标出 `LoginResult` 组件,未在该 operation 展开可核的会话字段,且尚未以测试账号获得一次被接受的响应,不能声明登录已完成。 |
|
||||
| A01 短信登录 | `POST /genealogy/app/auth/login/sms`:Header `clientid` 必填;Body `clientId/grantType/tenantId/phone/smsCode` 均必填,`grantType=sms`,`smsCode` 为四位短信码;响应组件为 `LoginResult`。 | 页面字段为手机号、四位验证码;API 层同样依赖返回的 `access_token` 建立会话。 | **未完成 / DECLARED_UNVERIFIED**:请求合同对齐,但该动作依赖真人收到短信;未发送、未登录,不把页面登录成功提示当成远端成功。 |
|
||||
| A04 注册 | 短信链路同上但 `sceneCode=APP_REGISTER`。`POST /genealogy/app/auth/register`:Header `clientid` 必填;Body 已实读 `clientId/grantType/tenantId/phone/password/smsCode`,`grantType=password`、密码为 32 位 MD5、验证码为四位;响应 `LoginResult`。 | 页面输入手机号、验证码、密码、确认密码和协议勾选;提交时传手机号、MD5 密码、验证码。 | **未完成 / DECLARED_UNVERIFIED**:字段链路可对照,但注册会创建真实账号,按约定不在无人值守时触发;`LoginResult` 的完整展示字段仍待接受响应核实。 |
|
||||
| A05 找回密码 | 短信链路同上但 `sceneCode=APP_FORGOT_PASSWORD`。`PUT /genealogy/app/auth/password/reset`:Header `clientid` 必填;Body `clientId/grantType/tenantId/phone/newPassword/smsCode` 均必填,`grantType=password`、`newPassword` 为 32 位 MD5、验证码为四位;响应 `VoidResult`。 | 页面输入手机号、验证码、新密码、确认密码;提交参数为手机号、MD5 新密码、验证码。 | **未完成 / DECLARED_UNVERIFIED**:请求字段对齐;找回会真实改密,未触发,不能以本地“修改成功”状态当接口完成。 |
|
||||
| A06 账号状态/恢复 | 在 APP 认证目录按 `status`、`frozen`、`disabled`、`risk`、`appeal`、`recovery` 检索,未找到账号状态读取、限制原因、申诉或恢复的独立 operation。 | 页面只读路由参数 `status`,并用本地 `frozen/disabled/risk` 文案展示限制原因和恢复说明;“查看恢复方式”仅打开本地弹层;该文件也未注册进 `pages.json` 的 52 条路由。 | **未完成 / MISSING_OPERATION**:没有后端 owner 提供状态、原因、可恢复路径或申诉结果,不能把静态文案当真实账号状态;未注册时也不能由正常路由到达。 |
|
||||
|
||||
## 本轮累计
|
||||
|
||||
| 范围 | 已逐页实读 | 可实施映射 | 未完成原因 |
|
||||
| --- | ---: | --- | --- |
|
||||
| F01—F10 | 10/10 | F02 发布、F03 评论读取/提交、F06 谱文创建、F07 相册创建已按声明字段接线;F01/F04/F05/F08/F09 已关闭无 DTO 或上传 owner 的 fixture 展示 | F02/F03/F06/F07 等待真实响应核验;动态、谱文和相册展示仍缺 DTO;F09 缺文件上传 owner,F10 缺读取/发布 owner;写入不得在无人值守时触发 |
|
||||
| G01、G03、G05—G12 | 9/9 | G12 正常列表、维护列表、批量预览/保存已按声明字段接线;G03/G06/G08—G11 已删除 fixture 或本地预览 | G12 待真实读取/写入响应核验,当前世代字段仍未声明;G03 两阶段结果恢复链、G06/G08—G11 的 DTO/ID/权限缺口仍未闭环 |
|
||||
| T01、T03—T08 | 7/7 | 树、详情、人物分页/选项、人物与亲属写入合同均已逐项实读;T03、T05、T07、T08 的已声明读取/字段子集已接线 | T03 明确为未完成;T01 头像与邀请绑定未闭环;T04 关系性别语义不完整;T06 缺原子排行 operation;T07/T08 均待真实读取响应核验 |
|
||||
| R01—R11 | 11/11 | R03/R04、R08、R10、R11 创建已按声明字段接线;R05—R07/R09 已删除本地流程 | 所有 R 列表/详情仍缺 DTO;创建待真实响应核验;R05/R07、R09 另有明确 `MISSING_OPERATION` |
|
||||
| N01—N02 | 2/2 | 消息列表、单条标已读、全部标已读 owner 已实读;页面已删除 fixture 消息和本地已读 | 列表条目 DTO 未声明、消息详情 operation 明确缺失,无稳定 ID 时不发送已读 mutation |
|
||||
| M01—M10 | 10/10 | M01—M03/M05 已删除 mock 资料和本地资料流程;改密、反馈、退出已有独立接线 | M02 字段与合同不一致;读取 DTO 多为泛型;M08 缺邀请业务 owner;VIP 还缺可审计支付闭环;敏感写入均未实测 |
|
||||
| A01、A04—A06 | 4/4 | 验证要求、短信发送、密码/短信登录、注册、找回密码的请求合同已逐项实读 | 无人值守不发送短信、不注册、不找回、不真实登录;`LoginResult` 仅见响应组件名,完整会话字段待接受响应;A06 明确缺状态/恢复 owner |
|
||||
@@ -1,57 +0,0 @@
|
||||
# MuMu 全页样式复核(2026-07-26)
|
||||
|
||||
## 范围与方法
|
||||
|
||||
- 设备:MuMu Android(SM-A5560),实际运行中的 APP WebView,900 × 1600 截图。
|
||||
- 范围:`pages.json` 的 52 个页面路由,以及测试账号可进入的列表、空态、错误态、新建表单和原生选择器状态。
|
||||
- 评审:三份独立结论——MuMu 实机逐页评审 1 份、核心表单代码/交互评审 1 份、家族与记录模块代码/交互评审 1 份。只采纳能由真实页面或已声明接口契约证实的结论。
|
||||
- 不使用 mock、fixture 或本地伪造成功。全部截图在 `tmp/mumu-visual-audit-20260726/`。
|
||||
|
||||
## 逐页复核结果
|
||||
|
||||
已逐页打开并复核以下 52 条路由:
|
||||
|
||||
- 认证:A01、A04、A05。
|
||||
- 家谱:G01、G03、G05、G06、G08、G09、G10、G11、G12。
|
||||
- 世系:T01、T03、T04、T05、T06、T07、T08。
|
||||
- 家族:F01、F02、F03、F04、F05、F06、F07、F08、F09、F10。
|
||||
- 记录:R01、R02、R03、R04、R05、R06、R07、R08、R09、R10、R11。
|
||||
- 消息:N01、N02。
|
||||
- 我的:M01、M02、M03、M04、M05、M06、M07、M08、M09、M10。
|
||||
|
||||
结论:已进入的页面均保持朱红、金线、宣纸背景和卷轴按钮这套既有视觉系统;未发现横向页面滚动、标题遮挡、内容与底部导航重叠。长内容、空态、接口错误态和可进入的新建表单均已按实际数据复核。
|
||||
|
||||
## 已落地且在 MuMu 复验的改动
|
||||
|
||||
1. G01 当前家谱的超长名称改为单行省略,避免末尾数字孤立换行。
|
||||
2. G05 长简介限制为两行,恢复被遮挡的世系树、字辈谱、审核、设置四张功能卡;家谱统计信息收回深色头图区域。
|
||||
3. F02 动态类型固定显示为“文字动态”,不再把接口枚举值暴露成可编辑输入框;上传按钮增至可触控尺寸。
|
||||
4. F03 未返回点赞状态时使用中性说明,不再把未知状态渲染成失败样式。
|
||||
5. F10、R09、N02、M03、M05、G12、M08 移除面向用户的接口、operation、Mock 等内部术语,改为可理解的功能状态说明。
|
||||
6. M01 默认头像使用项目真实 `auth-login-outline` 图;性别代码不再直接展示。M02 在没有服务端选项字典时显示“待确认 / 选项待提供”,不伪造选择项。
|
||||
7. R02 的“人生大事”改为明确的“暂未开放”状态,不再提供无效可点击入口。
|
||||
8. F02、F06、F07、F09、R07、R08、R10 的图片上传入口最小高度统一到 88rpx。
|
||||
9. R08 成长日志的记录、提醒时间,R11 功德记录的记录时间,全部改为系统日期/时间选择器;提交时仍使用接口既有 `YYYY-MM-DD HH:mm:ss` 文本字段。
|
||||
10. R08、R10、R11 的表单底部“取消 / 提交”按钮改为容器内的可收缩网格。修复了 MuMu 窄屏上右侧提交按钮被裁切的问题;三页都已实机复验。
|
||||
|
||||
## 选择器与输入原则
|
||||
|
||||
- 日期、时间:使用实际 Android 原生选择器,未改变后端字段名或格式。
|
||||
- 有确定自由文本语义的字段仍使用输入框,例如姓名、标题、地点、人物简介、备注。
|
||||
- 性别、人物状态、农历等需要枚举/字典的字段,当前接口未提供可用选项来源;未猜测中文标签或构造假选项。
|
||||
|
||||
## 不能通过前端补齐的状态
|
||||
|
||||
| 范围 | 真实结果 | 前端处理 |
|
||||
| --- | --- | --- |
|
||||
| 首位成员创建 | `POST /genealogy/app/genealogies/{genealogyId}/lineage/persons` 使用最小合法 body 仍返回业务 `code:500` | 不伪造成员;T03–T08、R01–R02 的真实成员读取/写入闭环仍需后端修复或提供可读测试成员。 |
|
||||
| 性别、人物状态、农历等选择项 | 接口契约未提供枚举/字典来源 | 保留真实已返回值;未知值显示“待确认”,不将代码当中文含义解释。 |
|
||||
| 消息详情 | 当前测试数据没有可读的消息详情标识 | N02 复核了无详情状态和返回路径,未伪造详情内容。 |
|
||||
|
||||
## 本轮验证
|
||||
|
||||
- `powershell -NoProfile -ExecutionPolicy Bypass -File tests/compile-audit.ps1`:通过。
|
||||
- `powershell -NoProfile -ExecutionPolicy Bypass -File tests/r-business-flow-contract.ps1`:通过。
|
||||
- `powershell -NoProfile -ExecutionPolicy Bypass -File tests/active-page-business-ownership-contract.ps1`:通过。
|
||||
- MuMu 实机复验:R08、R10、R11 新建表单及其底部按钮;R08 日期选择器;G01、G05、F02、F03、F10、G12、N02、M01、M02、M03、M05、M08 的改动后状态。
|
||||
- 最后一轮 MuMu 路由复测:52/52 条路由均已重新截图完成,结果位于 `tmp/mumu-visual-audit-20260726/final-routes/`。
|
||||
@@ -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. 换绑重新认证及旧令牌失效的安全测试结果。
|
||||
@@ -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 补点截图
|
||||
|
||||
| 当前礼仪 | 当前功德 |
|
||||
| --- | --- |
|
||||
| <br> | <br> |
|
||||
|
||||
| 当前贺礼簿 | 当前家族备忘 | 当前成长日志 |
|
||||
| --- | --- | --- |
|
||||
| <br> | <br> | <br> |
|
||||
|
||||
### 继续复核截图
|
||||
|
||||
| 礼仪列表与详情 | 功德列表与详情 |
|
||||
| --- | --- |
|
||||
| <br> | <br> |
|
||||
|
||||
| 家族恩人 | 贺礼簿 | 成长具体记录 |
|
||||
| --- | --- | --- |
|
||||
|  |  |  |
|
||||
|
||||
| 字辈谱/世系谱 | 家族动态 | 创建/加入家谱 |
|
||||
| --- | --- | --- |
|
||||
| <br> |  | <br> |
|
||||
|
||||
## 截图证据
|
||||
|
||||
### 视频列表与播放入口
|
||||
|
||||
| 参考项目 | 当前项目 |
|
||||
| --- | --- |
|
||||
|  |  |
|
||||
|
||||
参考卡片点击后确实进入独立播放器,但浏览器中显示黑屏,因此本轮只能确认“封面卡片 → 播放页”的交互,不能声称视频成功播放。
|
||||
|
||||
### 推广邀请
|
||||
|
||||
| 参考项目 | 当前项目 |
|
||||
| --- | --- |
|
||||
|  |  |
|
||||
|
||||
### 重要证件与家谱设置
|
||||
|
||||
| 参考项目重要证件 | 当前项目家谱设置阻断 |
|
||||
| --- | --- |
|
||||
|  |  |
|
||||
|
||||
### VIP 订单
|
||||
|
||||
| 参考项目 | 当前项目 |
|
||||
| --- | --- |
|
||||
|  |  |
|
||||
|
||||
## 确定需要补的项目
|
||||
|
||||
| 优先级 | 差异 | 当前是否已有相近能力 | 责任与完成条件 |
|
||||
| --- | --- | --- | --- |
|
||||
| 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 已补测完成。
|
||||
|
Before Width: | Height: | Size: 2.0 MiB |
|
Before Width: | Height: | Size: 1.9 MiB |
|
Before Width: | Height: | Size: 1.9 MiB |
|
Before Width: | Height: | Size: 1.3 MiB |
|
Before Width: | Height: | Size: 1.4 MiB |
|
Before Width: | Height: | Size: 1.2 MiB |
|
Before Width: | Height: | Size: 6.5 MiB |
|
Before Width: | Height: | Size: 1.6 MiB |
|
Before Width: | Height: | Size: 2.2 MiB |
|
Before Width: | Height: | Size: 2.0 MiB |
|
Before Width: | Height: | Size: 2.2 MiB |
|
Before Width: | Height: | Size: 2.1 MiB |
|
Before Width: | Height: | Size: 2.2 MiB |
|
Before Width: | Height: | Size: 1.5 MiB |
|
Before Width: | Height: | Size: 1.8 MiB |
|
Before Width: | Height: | Size: 208 KiB |
|
Before Width: | Height: | Size: 354 KiB |
|
Before Width: | Height: | Size: 362 KiB |
|
Before Width: | Height: | Size: 61 KiB |
|
Before Width: | Height: | Size: 77 KiB |
|
Before Width: | Height: | Size: 58 KiB |
|
Before Width: | Height: | Size: 1.2 MiB |
|
Before Width: | Height: | Size: 635 KiB |
|
Before Width: | Height: | Size: 387 KiB |
|
Before Width: | Height: | Size: 283 KiB |