Compare commits
2 Commits
9ad572907b
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| f53a2dbc87 | |||
| bbc024f7f2 |
@@ -57,8 +57,11 @@ sitemap.xml
|
|||||||
/tmp/
|
/tmp/
|
||||||
/unpackage/
|
/unpackage/
|
||||||
/.vite/
|
/.vite/
|
||||||
|
/.audit/
|
||||||
/design-pipeline/generated/
|
/design-pipeline/generated/
|
||||||
/tmp-g01-icon-audit.png
|
/tmp-g01-icon-audit.png
|
||||||
/tmp-mumu-current.png
|
/tmp-mumu-current.png
|
||||||
/artifacts/
|
/artifacts/
|
||||||
/docs/audit-*/
|
/docs/audit-*/
|
||||||
|
/docs/audits/
|
||||||
|
/book-style-*.png
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<template>
|
<template>
|
||||||
<view
|
<view
|
||||||
v-if="promotionState === 'error' || promotions.length"
|
v-if="showEmpty || promotionState === 'error' || promotions.length"
|
||||||
class="promotion-strip"
|
class="promotion-strip"
|
||||||
:aria-label="title"
|
:aria-label="title"
|
||||||
>
|
>
|
||||||
@@ -8,10 +8,16 @@
|
|||||||
<view class="promotion-strip__mark" aria-hidden="true"></view>
|
<view class="promotion-strip__mark" aria-hidden="true"></view>
|
||||||
<text>{{ title }}</text>
|
<text>{{ title }}</text>
|
||||||
</view>
|
</view>
|
||||||
<view v-if="promotionState === 'error'" class="promotion-strip__error">
|
<view v-if="promotionState === 'loading'" class="promotion-strip__state">
|
||||||
|
<text>正在加载广告内容…</text>
|
||||||
|
</view>
|
||||||
|
<view v-else-if="promotionState === 'error'" class="promotion-strip__error">
|
||||||
<text>推荐内容暂时没有显示</text>
|
<text>推荐内容暂时没有显示</text>
|
||||||
<button @click="loadPromotions">重新加载</button>
|
<button @click="loadPromotions">重新加载</button>
|
||||||
</view>
|
</view>
|
||||||
|
<view v-else-if="!promotions.length" class="promotion-strip__state">
|
||||||
|
<text>暂无广告内容</text>
|
||||||
|
</view>
|
||||||
<scroll-view v-else class="promotion-strip__scroll" scroll-x :show-scrollbar="false">
|
<scroll-view v-else class="promotion-strip__scroll" scroll-x :show-scrollbar="false">
|
||||||
<view class="promotion-strip__list">
|
<view class="promotion-strip__list">
|
||||||
<view
|
<view
|
||||||
@@ -63,6 +69,7 @@ import { openSiteContentTarget } from "@/utils/navigation/gateway.js";
|
|||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
placement: { type: String, required: true },
|
placement: { type: String, required: true },
|
||||||
title: { type: String, default: "推荐内容" },
|
title: { type: String, default: "推荐内容" },
|
||||||
|
showEmpty: { type: Boolean, default: false },
|
||||||
});
|
});
|
||||||
|
|
||||||
const promotions = ref([]);
|
const promotions = ref([]);
|
||||||
@@ -216,6 +223,13 @@ onBeforeUnmount(() => {
|
|||||||
font-size: clamp(13px, 21rpx, 16px);
|
font-size: clamp(13px, 21rpx, 16px);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.promotion-strip__state {
|
||||||
|
padding: 12rpx 22rpx 18rpx;
|
||||||
|
color: #766252;
|
||||||
|
font-size: clamp(13px, 21rpx, 16px);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
.promotion-strip__error button {
|
.promotion-strip__error button {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 8rpx 18rpx;
|
padding: 8rpx 18rpx;
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
<template>
|
<template>
|
||||||
<view class="app-tabbar" role="tablist" aria-label="主要导航">
|
<view
|
||||||
|
class="app-tabbar"
|
||||||
|
:class="{ 'app-tabbar--light': tone === 'light' }"
|
||||||
|
role="tablist"
|
||||||
|
aria-label="主要导航"
|
||||||
|
>
|
||||||
<button
|
<button
|
||||||
v-for="item in items"
|
v-for="item in items"
|
||||||
:key="item.key"
|
:key="item.key"
|
||||||
@@ -26,7 +31,10 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { goRoot } from "@/utils/navigation/gateway.js";
|
import { goRoot } from "@/utils/navigation/gateway.js";
|
||||||
|
|
||||||
const props = defineProps({ active: { type: String, required: true } });
|
const props = defineProps({
|
||||||
|
active: { type: String, required: true },
|
||||||
|
tone: { type: String, default: "default" },
|
||||||
|
});
|
||||||
|
|
||||||
const items = [
|
const items = [
|
||||||
{
|
{
|
||||||
@@ -39,7 +47,7 @@ const items = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "family",
|
key: "family",
|
||||||
label: "家族",
|
label: "消息",
|
||||||
routeKey: "F01",
|
routeKey: "F01",
|
||||||
icon: "/static/assets/foundation/transparent/tab-family.png",
|
icon: "/static/assets/foundation/transparent/tab-family.png",
|
||||||
activeIcon: "/static/assets/foundation/transparent/tab-family-active.png",
|
activeIcon: "/static/assets/foundation/transparent/tab-family-active.png",
|
||||||
@@ -74,6 +82,11 @@ const switchRoot = (item) => {
|
|||||||
background: $paper-white;
|
background: $paper-white;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.app-tabbar--light {
|
||||||
|
border-top-color: #e5dac4;
|
||||||
|
background: #fffdf9;
|
||||||
|
}
|
||||||
|
|
||||||
.tab-item {
|
.tab-item {
|
||||||
display: flex;
|
display: flex;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
<template>
|
||||||
|
<view class="batch-management">
|
||||||
|
<view class="batch-management__copy">
|
||||||
|
<text>{{ active ? `已选择 ${selectedCount} 条` : `批量管理${resourceName}` }}</text>
|
||||||
|
<text>{{ active ? "仅可选择有删除权限的内容" : "可一次选择多条内容移入回收站" }}</text>
|
||||||
|
</view>
|
||||||
|
<view class="batch-management__actions">
|
||||||
|
<template v-if="active">
|
||||||
|
<AppButton compact type="secondary" :disabled="busy" :label="allSelected ? '取消全选' : '全选'" @click="$emit('toggle-all')" />
|
||||||
|
<AppButton compact :disabled="!selectedCount || busy" :label="busy ? '正在删除' : `删除 ${selectedCount || ''}`.trim()" @click="$emit('delete')" />
|
||||||
|
<AppButton compact type="secondary" :disabled="busy" label="完成" @click="$emit('finish')" />
|
||||||
|
</template>
|
||||||
|
<AppButton v-else compact type="secondary" :label="`管理${resourceName}`" @click="$emit('start')" />
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import AppButton from "@/components/AppButton.vue";
|
||||||
|
|
||||||
|
defineProps({
|
||||||
|
active: { type: Boolean, default: false },
|
||||||
|
selectedCount: { type: Number, default: 0 },
|
||||||
|
allSelected: { type: Boolean, default: false },
|
||||||
|
busy: { type: Boolean, default: false },
|
||||||
|
resourceName: { type: String, required: true },
|
||||||
|
});
|
||||||
|
defineEmits(["start", "finish", "toggle-all", "delete"]);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.batch-management {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 18rpx;
|
||||||
|
margin-bottom: 20rpx;
|
||||||
|
padding: 20rpx 22rpx;
|
||||||
|
border: 1rpx solid rgba(159, 35, 35, 0.18);
|
||||||
|
background: rgba(255, 252, 242, 0.92);
|
||||||
|
}
|
||||||
|
.batch-management__copy {
|
||||||
|
min-width: 0;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
.batch-management__copy text {
|
||||||
|
display: block;
|
||||||
|
color: $ink-muted;
|
||||||
|
font-size: clamp(12px, 20rpx, 15px);
|
||||||
|
line-height: 1.45;
|
||||||
|
}
|
||||||
|
.batch-management__copy text:first-child {
|
||||||
|
color: $ink;
|
||||||
|
font-size: clamp(14px, 23rpx, 17px);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
.batch-management__actions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 10rpx;
|
||||||
|
}
|
||||||
|
@media (max-width: 420px) {
|
||||||
|
.batch-management {
|
||||||
|
align-items: stretch;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
.batch-management__actions {
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
<template>
|
||||||
|
<button
|
||||||
|
class="batch-selection-mark"
|
||||||
|
:class="{ 'batch-selection-mark--selected': selected }"
|
||||||
|
role="checkbox"
|
||||||
|
:aria-checked="selected"
|
||||||
|
:aria-label="`${selected ? '取消选择' : '选择'}${label}`"
|
||||||
|
@click.stop="$emit('toggle')"
|
||||||
|
>
|
||||||
|
{{ selected ? "已选择" : "选择" }}
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
defineProps({
|
||||||
|
selected: { type: Boolean, default: false },
|
||||||
|
label: { type: String, required: true },
|
||||||
|
});
|
||||||
|
defineEmits(["toggle"]);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.batch-selection-mark {
|
||||||
|
min-width: 100rpx;
|
||||||
|
min-height: 64rpx;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0 16rpx;
|
||||||
|
border: 1rpx solid rgba(159, 35, 35, 0.34);
|
||||||
|
border-radius: 8rpx;
|
||||||
|
background: rgba(255, 255, 255, 0.78);
|
||||||
|
color: $brand-red;
|
||||||
|
font-size: clamp(12px, 20rpx, 15px);
|
||||||
|
line-height: 64rpx;
|
||||||
|
}
|
||||||
|
.batch-selection-mark::after {
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
|
.batch-selection-mark--selected {
|
||||||
|
background: $brand-red;
|
||||||
|
color: #fffaf0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -123,7 +123,7 @@ const handleBack = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.page-header-slot--root {
|
.page-header-slot--root {
|
||||||
height: calc(124rpx + var(--status-bar-height, 0px));
|
height: calc(88rpx + var(--status-bar-height, 0px));
|
||||||
}
|
}
|
||||||
|
|
||||||
.page-header {
|
.page-header {
|
||||||
@@ -144,7 +144,7 @@ const handleBack = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.page-header--root {
|
.page-header--root {
|
||||||
height: calc(124rpx + var(--status-bar-height, 0px));
|
height: calc(88rpx + var(--status-bar-height, 0px));
|
||||||
padding-top: var(--status-bar-height, 0px);
|
padding-top: var(--status-bar-height, 0px);
|
||||||
background-color: #b52e22;
|
background-color: #b52e22;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
@@ -167,8 +167,8 @@ const handleBack = () => {
|
|||||||
bottom: -2rpx;
|
bottom: -2rpx;
|
||||||
left: -48rpx;
|
left: -48rpx;
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
width: 476rpx;
|
width: 408rpx;
|
||||||
height: 166rpx;
|
height: 126rpx;
|
||||||
opacity: 0.29;
|
opacity: 0.29;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
@@ -313,8 +313,8 @@ const handleBack = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.header-project-lockup--root {
|
.header-project-lockup--root {
|
||||||
top: calc(var(--status-bar-height, 0px) + 62rpx);
|
top: calc(var(--status-bar-height, 0px) + 44rpx);
|
||||||
left: 116rpx;
|
left: 102rpx;
|
||||||
}
|
}
|
||||||
|
|
||||||
.header-project-lockup__logo {
|
.header-project-lockup__logo {
|
||||||
@@ -349,18 +349,17 @@ const handleBack = () => {
|
|||||||
.page-header--root .header-title {
|
.page-header--root .header-title {
|
||||||
color: #ffe3a7;
|
color: #ffe3a7;
|
||||||
font-family: "STKaiti", "KaiTi", serif;
|
font-family: "STKaiti", "KaiTi", serif;
|
||||||
font-size: clamp(24px, 48rpx, 30px);
|
font-size: clamp(21px, 40rpx, 25px);
|
||||||
letter-spacing: 5rpx;
|
letter-spacing: 4rpx;
|
||||||
transform: translateY(2rpx);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.page-header--root .header-logo {
|
.page-header--root .header-logo {
|
||||||
width: 80rpx;
|
width: 64rpx;
|
||||||
height: 80rpx;
|
height: 64rpx;
|
||||||
transform: translate(-6rpx, -6rpx);
|
transform: translate(-2rpx, -2rpx);
|
||||||
}
|
}
|
||||||
|
|
||||||
.page-header--root .header-notice {
|
.page-header--root .header-notice {
|
||||||
transform: translateY(4rpx);
|
transform: none;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
<template>
|
||||||
|
<view class="referral-qr-code" aria-label="推荐注册链接二维码">
|
||||||
|
<image v-if="imageSource" :src="imageSource" mode="aspectFit" />
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed } from "vue";
|
||||||
|
import createQrCode from "qrcode-generator";
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
value: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const imageSource = computed(() => {
|
||||||
|
const value = props.value.trim();
|
||||||
|
if (!value) return "";
|
||||||
|
const qrCode = createQrCode(0, "M");
|
||||||
|
qrCode.addData(value, "Byte");
|
||||||
|
qrCode.make();
|
||||||
|
return qrCode.createDataURL(8, 16);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.referral-qr-code {
|
||||||
|
display: flex;
|
||||||
|
width: 280rpx;
|
||||||
|
height: 280rpx;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 14rpx;
|
||||||
|
border: 1rpx solid rgba(128, 89, 49, 0.24);
|
||||||
|
border-radius: 8rpx;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.referral-qr-code image {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
<template>
|
||||||
|
<view
|
||||||
|
class="login-advertisement"
|
||||||
|
:class="{ 'login-advertisement--linked': advertisement.targetUrl }"
|
||||||
|
:role="advertisement.targetUrl ? 'button' : 'img'"
|
||||||
|
:aria-label="advertisement.targetUrl
|
||||||
|
? `${advertisement.title},查看详情`
|
||||||
|
: advertisement.title"
|
||||||
|
@click="openAdvertisement"
|
||||||
|
>
|
||||||
|
<image :src="advertisement.image" mode="aspectFill" />
|
||||||
|
</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 fallbackAdvertisement = Object.freeze({
|
||||||
|
title: "寻根问祖",
|
||||||
|
image: "/static/assets/modules/genealogy/opaque/home-ad-heritage-hall.png",
|
||||||
|
targetUrl: "",
|
||||||
|
});
|
||||||
|
|
||||||
|
const advertisement = ref(fallbackAdvertisement);
|
||||||
|
const promotionRequestController = createRequestController();
|
||||||
|
let isMounted = true;
|
||||||
|
|
||||||
|
const loadAdvertisement = async () => {
|
||||||
|
try {
|
||||||
|
const promotions = await siteContentApi.getPromotions({
|
||||||
|
placement: "home_banner",
|
||||||
|
requestController: promotionRequestController,
|
||||||
|
});
|
||||||
|
if (!isMounted) return;
|
||||||
|
const promotion = promotions.find((entry) => entry.coverFile?.accessUrl);
|
||||||
|
if (!promotion) return;
|
||||||
|
advertisement.value = {
|
||||||
|
title: promotion.title,
|
||||||
|
image: promotion.coverFile.accessUrl,
|
||||||
|
targetUrl: promotion.targetUrl,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
if (!isMounted || isRequestCancelled(error)) return;
|
||||||
|
advertisement.value = fallbackAdvertisement;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const openAdvertisement = async () => {
|
||||||
|
if (!advertisement.value.targetUrl) return;
|
||||||
|
try {
|
||||||
|
await openSiteContentTarget(advertisement.value.targetUrl, () => {
|
||||||
|
uni.showToast({ title: "广告内容暂时打不开", icon: "none" });
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
uni.showToast({ title: "广告内容暂时打不开", icon: "none" });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
onMounted(loadAdvertisement);
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
isMounted = false;
|
||||||
|
promotionRequestController.abort();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.login-advertisement {
|
||||||
|
width: 100%;
|
||||||
|
height: 128rpx;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
overflow: hidden;
|
||||||
|
box-sizing: border-box;
|
||||||
|
margin-top: 22rpx;
|
||||||
|
border: 1rpx solid rgba(142, 96, 44, 0.38);
|
||||||
|
border-radius: 12rpx;
|
||||||
|
background: #f1ece2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-advertisement--linked:active {
|
||||||
|
opacity: 0.78;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-advertisement image {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -11,7 +11,7 @@
|
|||||||
:src="file.accessUrl"
|
:src="file.accessUrl"
|
||||||
mode="aspectFill"
|
mode="aspectFill"
|
||||||
role="button"
|
role="button"
|
||||||
aria-label="查看动态图片"
|
:aria-label="imageLabel"
|
||||||
@click.stop="preview(file)"
|
@click.stop="preview(file)"
|
||||||
/>
|
/>
|
||||||
</view>
|
</view>
|
||||||
@@ -22,6 +22,7 @@ import { computed } from "vue";
|
|||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
files: { type: Array, default: () => [] },
|
files: { type: Array, default: () => [] },
|
||||||
|
imageLabel: { type: String, default: "查看动态图片" },
|
||||||
});
|
});
|
||||||
|
|
||||||
const visibleFiles = computed(() =>
|
const visibleFiles = computed(() =>
|
||||||
|
|||||||
@@ -12,14 +12,21 @@
|
|||||||
src="/static/assets/modules/genealogy/transparent/list-slip-frame.png"
|
src="/static/assets/modules/genealogy/transparent/list-slip-frame.png"
|
||||||
mode="scaleToFill"
|
mode="scaleToFill"
|
||||||
/>
|
/>
|
||||||
<view class="surname-seal">
|
<view class="genealogy-book" aria-hidden="true">
|
||||||
<image
|
<image
|
||||||
class="surname-seal-frame"
|
class="genealogy-book__cover"
|
||||||
src="/static/assets/modules/genealogy/transparent/row-seal-frame.png"
|
src="/static/assets/foundation/opaque/book-blank.png"
|
||||||
mode="scaleToFill"
|
mode="aspectFit"
|
||||||
/>
|
/>
|
||||||
<view class="surname-seal-copy">
|
<view
|
||||||
<text class="seal-title">家谱</text>
|
v-if="genealogy.surname"
|
||||||
|
class="genealogy-book__spine-title"
|
||||||
|
>
|
||||||
|
<text
|
||||||
|
v-for="(character, index) in getBookSpineTitle(genealogy.surname)"
|
||||||
|
:key="`${index}-${character}`"
|
||||||
|
class="genealogy-book__spine-character"
|
||||||
|
>{{ character }}</text>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
@@ -51,8 +58,8 @@
|
|||||||
</view>
|
</view>
|
||||||
<view class="card-trailing">
|
<view class="card-trailing">
|
||||||
<text class="card-role app-single-line">{{ role }}</text>
|
<text class="card-role app-single-line">{{ role }}</text>
|
||||||
<text v-if="genealogy.updatedAt" class="card-updated app-single-line"
|
<text v-if="genealogy.createTime" class="card-updated app-single-line"
|
||||||
>更新于 {{ genealogy.updatedAt }}</text
|
>创建于 {{ formatGenealogyDate(genealogy.createTime) }}</text
|
||||||
>
|
>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
@@ -70,9 +77,16 @@ const props = defineProps({
|
|||||||
});
|
});
|
||||||
defineEmits(["select"]);
|
defineEmits(["select"]);
|
||||||
|
|
||||||
|
const formatGenealogyDate = (value) => {
|
||||||
|
const text = String(value || "").trim();
|
||||||
|
return text.length >= 10 ? text.slice(0, 10) : text;
|
||||||
|
};
|
||||||
|
const getBookSpineTitle = (value) =>
|
||||||
|
Array.from(`${String(value || "").trim()}氏家谱`);
|
||||||
|
|
||||||
const accessibleLabel = computed(
|
const accessibleLabel = computed(
|
||||||
() =>
|
() =>
|
||||||
`${props.genealogy.name},${props.role}${props.selected ? ',当前家谱' : ''},${props.genealogy.location},${props.genealogy.memberCount} 位成员`,
|
`${props.genealogy.name},${props.role}${props.selected ? ',当前家谱' : ''},${props.genealogy.location},${props.genealogy.memberCount} 位成员${props.genealogy.createTime ? `,创建于 ${formatGenealogyDate(props.genealogy.createTime)}` : ''}`,
|
||||||
);
|
);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -81,7 +95,7 @@ const accessibleLabel = computed(
|
|||||||
--card-padding-x: 24rpx;
|
--card-padding-x: 24rpx;
|
||||||
--card-padding-y: 20rpx;
|
--card-padding-y: 20rpx;
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 68rpx minmax(0, 1fr);
|
grid-template-columns: 112rpx minmax(0, 1fr);
|
||||||
min-height: 204rpx;
|
min-height: 204rpx;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
column-gap: 20rpx;
|
column-gap: 20rpx;
|
||||||
@@ -106,47 +120,37 @@ const accessibleLabel = computed(
|
|||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.surname-seal {
|
.genealogy-book {
|
||||||
display: grid;
|
position: relative;
|
||||||
grid-column: 1;
|
grid-column: 1;
|
||||||
grid-row: 1;
|
grid-row: 1;
|
||||||
z-index: 2;
|
z-index: 2;
|
||||||
width: 68rpx;
|
width: 112rpx;
|
||||||
height: 106rpx;
|
height: 153rpx;
|
||||||
place-items: center;
|
|
||||||
color: #fff5df;
|
|
||||||
}
|
}
|
||||||
|
.genealogy-book__cover {
|
||||||
.surname-seal-frame {
|
display: block;
|
||||||
z-index: 0;
|
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
}
|
}
|
||||||
.surname-seal-frame,
|
.genealogy-book__spine-title {
|
||||||
.surname-seal-copy {
|
position: absolute;
|
||||||
grid-area: 1 / 1;
|
top: 5rpx;
|
||||||
}
|
left: 7rpx;
|
||||||
.surname-seal-copy {
|
|
||||||
z-index: 1;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
height: 78rpx;
|
width: 23rpx;
|
||||||
|
height: 91rpx;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
flex-direction: column;
|
||||||
|
color: #2f2925;
|
||||||
|
font-family: "STKaiti", "KaiTi", "SimSun", serif;
|
||||||
}
|
}
|
||||||
.seal-title {
|
.genealogy-book__spine-character {
|
||||||
color: #fff5df;
|
display: block;
|
||||||
font-family: "STKaiti", "KaiTi", serif;
|
font-size: clamp(9.5px, 17rpx, 12px);
|
||||||
font-size: clamp(17px, 30rpx, 22px);
|
font-weight: 400;
|
||||||
font-weight: 700;
|
line-height: 1.05;
|
||||||
letter-spacing: 2rpx;
|
|
||||||
line-height: 1;
|
|
||||||
writing-mode: vertical-rl;
|
|
||||||
}
|
|
||||||
.genealogy-card:not(.genealogy-card--current) .surname-seal-frame {
|
|
||||||
opacity: 0.32;
|
|
||||||
}
|
|
||||||
.genealogy-card:not(.genealogy-card--current) .seal-title {
|
|
||||||
color: $ink-muted;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.card-main {
|
.card-main {
|
||||||
@@ -258,15 +262,21 @@ const accessibleLabel = computed(
|
|||||||
@media screen and (max-width: 340px) {
|
@media screen and (max-width: 340px) {
|
||||||
.genealogy-card {
|
.genealogy-card {
|
||||||
--card-padding-x: 18rpx;
|
--card-padding-x: 18rpx;
|
||||||
grid-template-columns: 62rpx minmax(0, 1fr);
|
grid-template-columns: 100rpx minmax(0, 1fr);
|
||||||
column-gap: 16rpx;
|
column-gap: 16rpx;
|
||||||
min-height: 176rpx;
|
min-height: 176rpx;
|
||||||
padding-right: 18rpx;
|
padding-right: 18rpx;
|
||||||
padding-left: 18rpx;
|
padding-left: 18rpx;
|
||||||
}
|
}
|
||||||
.surname-seal {
|
.genealogy-book {
|
||||||
width: 62rpx;
|
width: 100rpx;
|
||||||
height: 96rpx;
|
height: 136rpx;
|
||||||
|
}
|
||||||
|
.genealogy-book__spine-title {
|
||||||
|
top: 4rpx;
|
||||||
|
left: 6rpx;
|
||||||
|
width: 21rpx;
|
||||||
|
height: 81rpx;
|
||||||
}
|
}
|
||||||
.card-name {
|
.card-name {
|
||||||
font-size: clamp(17px, 32rpx, 22px);
|
font-size: clamp(17px, 32rpx, 22px);
|
||||||
|
|||||||
@@ -0,0 +1,89 @@
|
|||||||
|
<template>
|
||||||
|
<view class="home-advertisement-panel">
|
||||||
|
<view class="home-advertisement-panel__grid">
|
||||||
|
<button
|
||||||
|
v-for="advertisement in advertisements"
|
||||||
|
:key="advertisement.image"
|
||||||
|
class="home-advertisement-panel__card"
|
||||||
|
:aria-label="`${advertisement.title},查看更多内容`"
|
||||||
|
@click="emit('open')"
|
||||||
|
>
|
||||||
|
<image :src="advertisement.image" mode="aspectFill" />
|
||||||
|
</button>
|
||||||
|
</view>
|
||||||
|
<button class="home-advertisement-panel__more" @click="emit('open')">
|
||||||
|
点击查看更多内容
|
||||||
|
</button>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
const emit = defineEmits(["open"]);
|
||||||
|
|
||||||
|
const advertisements = Object.freeze([
|
||||||
|
{
|
||||||
|
title: "寻根问祖",
|
||||||
|
image: "/static/assets/modules/genealogy/opaque/home-ad-heritage-hall.png",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "家族传承",
|
||||||
|
image: "/static/assets/modules/genealogy/opaque/home-ad-family-tree.png",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.home-advertisement-panel {
|
||||||
|
min-height: 176rpx;
|
||||||
|
padding: 10rpx 8rpx 4rpx;
|
||||||
|
border-top: 1rpx solid rgba(154, 111, 56, 0.34);
|
||||||
|
background: rgba(255, 253, 249, 0.96);
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-advertisement-panel__grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 12rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-advertisement-panel__card,
|
||||||
|
.home-advertisement-panel__more {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-advertisement-panel__card::after,
|
||||||
|
.home-advertisement-panel__more::after {
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-advertisement-panel__card {
|
||||||
|
height: 112rpx;
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1rpx solid rgba(142, 96, 44, 0.3);
|
||||||
|
border-radius: 10rpx;
|
||||||
|
background: #f1ece2;
|
||||||
|
line-height: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-advertisement-panel__card image {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-advertisement-panel__more {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
height: 40rpx;
|
||||||
|
color: $brand-red;
|
||||||
|
font-family: "STKaiti", "KaiTi", serif;
|
||||||
|
font-size: clamp(13px, 22rpx, 16px);
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 40rpx;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,5 +1,8 @@
|
|||||||
<template>
|
<template>
|
||||||
<view class="genealogy-page-background">
|
<view
|
||||||
|
class="genealogy-page-background"
|
||||||
|
:class="{ 'genealogy-page-background--light': tone === 'light' }"
|
||||||
|
>
|
||||||
<image
|
<image
|
||||||
class="genealogy-page-background__art"
|
class="genealogy-page-background__art"
|
||||||
src="/static/assets/modules/genealogy/opaque/genealogy-page-background-long.png"
|
src="/static/assets/modules/genealogy/opaque/genealogy-page-background-long.png"
|
||||||
@@ -8,6 +11,12 @@
|
|||||||
</view>
|
</view>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
defineProps({
|
||||||
|
tone: { type: String, default: "default" },
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.genealogy-page-background {
|
.genealogy-page-background {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
@@ -29,4 +38,12 @@
|
|||||||
opacity: 0.28;
|
opacity: 0.28;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.genealogy-page-background--light {
|
||||||
|
background: #faf8f3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.genealogy-page-background--light .genealogy-page-background__art {
|
||||||
|
opacity: 0.15;
|
||||||
|
}
|
||||||
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -0,0 +1,423 @@
|
|||||||
|
<template>
|
||||||
|
<view v-if="visible" class="member-date-picker" role="dialog" :aria-label="title">
|
||||||
|
<view class="member-date-picker__mask" @click="$emit('cancel')" />
|
||||||
|
<view class="member-date-picker__panel">
|
||||||
|
<view class="member-date-picker__heading">
|
||||||
|
<text class="member-date-picker__title">{{ title }}</text>
|
||||||
|
<text class="member-date-picker__hint">点击年份可直接切换,左右按钮切换月份</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="member-date-picker__toolbar">
|
||||||
|
<button
|
||||||
|
class="member-date-picker__month-button"
|
||||||
|
aria-label="上个月"
|
||||||
|
:disabled="!canSelectPreviousMonth"
|
||||||
|
@click="changeMonth(-1)"
|
||||||
|
>
|
||||||
|
<image
|
||||||
|
class="member-date-picker__month-icon member-date-picker__month-icon--previous"
|
||||||
|
src="/static/assets/foundation/transparent/chevron-right.png"
|
||||||
|
mode="aspectFit"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
<view class="member-date-picker__current-month">
|
||||||
|
<picker
|
||||||
|
:range="yearLabels"
|
||||||
|
:value="selectedYearIndex"
|
||||||
|
@change="selectYear"
|
||||||
|
>
|
||||||
|
<button class="member-date-picker__year-button">
|
||||||
|
<text>选择年份</text>
|
||||||
|
<text>{{ selectedYear }}年</text>
|
||||||
|
</button>
|
||||||
|
</picker>
|
||||||
|
<picker
|
||||||
|
:range="monthLabels"
|
||||||
|
:value="selectedMonthIndex"
|
||||||
|
@change="selectMonth"
|
||||||
|
>
|
||||||
|
<button class="member-date-picker__month-button-direct" aria-label="选择月份">
|
||||||
|
<text>选择月份</text>
|
||||||
|
<text>{{ selectedMonth }}月</text>
|
||||||
|
</button>
|
||||||
|
</picker>
|
||||||
|
</view>
|
||||||
|
<button
|
||||||
|
class="member-date-picker__month-button"
|
||||||
|
aria-label="下个月"
|
||||||
|
:disabled="!canSelectNextMonth"
|
||||||
|
@click="changeMonth(1)"
|
||||||
|
>
|
||||||
|
<image
|
||||||
|
class="member-date-picker__month-icon"
|
||||||
|
src="/static/assets/foundation/transparent/chevron-right.png"
|
||||||
|
mode="aspectFit"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="member-date-picker__weekdays">
|
||||||
|
<text v-for="weekday in weekdays" :key="weekday">{{ weekday }}</text>
|
||||||
|
</view>
|
||||||
|
<view class="member-date-picker__calendar">
|
||||||
|
<view
|
||||||
|
v-for="calendarDay in calendarDays"
|
||||||
|
:key="calendarDay.key"
|
||||||
|
class="member-date-picker__day-slot"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
v-if="calendarDay.day"
|
||||||
|
class="member-date-picker__day"
|
||||||
|
:class="{
|
||||||
|
'member-date-picker__day--selected': calendarDay.day === selectedDay,
|
||||||
|
}"
|
||||||
|
:disabled="calendarDay.disabled"
|
||||||
|
:aria-label="`${selectedYear}年${selectedMonth}月${calendarDay.day}日`"
|
||||||
|
@click="selectedDay = calendarDay.day"
|
||||||
|
>{{ calendarDay.day }}</button>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="member-date-picker__footer">
|
||||||
|
<button class="member-date-picker__cancel" @click="$emit('cancel')">取消</button>
|
||||||
|
<button class="member-date-picker__confirm" @click="confirmSelection">确定</button>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed, ref, watch } from "vue";
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
visible: { type: Boolean, default: false },
|
||||||
|
title: { type: String, default: "选择日期" },
|
||||||
|
value: { type: String, default: "" },
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits(["cancel", "confirm"]);
|
||||||
|
const MIN_YEAR = 1900;
|
||||||
|
const today = new Date();
|
||||||
|
const CURRENT_YEAR = today.getFullYear();
|
||||||
|
const CURRENT_MONTH = today.getMonth() + 1;
|
||||||
|
const CURRENT_DAY = today.getDate();
|
||||||
|
const years = Array.from(
|
||||||
|
{ length: CURRENT_YEAR - MIN_YEAR + 1 },
|
||||||
|
(_, index) => MIN_YEAR + index,
|
||||||
|
);
|
||||||
|
const yearLabels = years.map((year) => `${year}年`);
|
||||||
|
const weekdays = Object.freeze(["日", "一", "二", "三", "四", "五", "六"]);
|
||||||
|
const selectedYear = ref(new Date().getFullYear());
|
||||||
|
const selectedMonth = ref(new Date().getMonth() + 1);
|
||||||
|
const selectedDay = ref(new Date().getDate());
|
||||||
|
|
||||||
|
const selectedYearIndex = computed(() => selectedYear.value - MIN_YEAR);
|
||||||
|
const availableMonths = computed(() =>
|
||||||
|
Array.from(
|
||||||
|
{ length: selectedYear.value === CURRENT_YEAR ? CURRENT_MONTH : 12 },
|
||||||
|
(_, index) => index + 1,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const monthLabels = computed(() =>
|
||||||
|
availableMonths.value.map((month) => `${month}月`),
|
||||||
|
);
|
||||||
|
const selectedMonthIndex = computed(() =>
|
||||||
|
Math.max(0, availableMonths.value.indexOf(selectedMonth.value)),
|
||||||
|
);
|
||||||
|
const daysInSelectedMonth = computed(() =>
|
||||||
|
new Date(selectedYear.value, selectedMonth.value, 0).getDate(),
|
||||||
|
);
|
||||||
|
const selectedMonthKey = computed(
|
||||||
|
() => selectedYear.value * 12 + selectedMonth.value - 1,
|
||||||
|
);
|
||||||
|
const firstMonthKey = MIN_YEAR * 12;
|
||||||
|
const currentMonthKey = CURRENT_YEAR * 12 + CURRENT_MONTH - 1;
|
||||||
|
const canSelectPreviousMonth = computed(() => selectedMonthKey.value > firstMonthKey);
|
||||||
|
const canSelectNextMonth = computed(() => selectedMonthKey.value < currentMonthKey);
|
||||||
|
const calendarDays = computed(() => {
|
||||||
|
const leadingDays = new Date(selectedYear.value, selectedMonth.value - 1, 1).getDay();
|
||||||
|
const slots = Array.from({ length: leadingDays }, (_, index) => ({
|
||||||
|
key: `empty-${index}`,
|
||||||
|
day: 0,
|
||||||
|
}));
|
||||||
|
for (let day = 1; day <= daysInSelectedMonth.value; day += 1) {
|
||||||
|
const disabled =
|
||||||
|
selectedYear.value === CURRENT_YEAR &&
|
||||||
|
selectedMonth.value === CURRENT_MONTH &&
|
||||||
|
day > CURRENT_DAY;
|
||||||
|
slots.push({ key: `day-${day}`, day, disabled });
|
||||||
|
}
|
||||||
|
return slots;
|
||||||
|
});
|
||||||
|
|
||||||
|
const clampSelectedDay = () => {
|
||||||
|
const latestDay =
|
||||||
|
selectedYear.value === CURRENT_YEAR && selectedMonth.value === CURRENT_MONTH
|
||||||
|
? CURRENT_DAY
|
||||||
|
: daysInSelectedMonth.value;
|
||||||
|
selectedDay.value = Math.min(selectedDay.value, latestDay);
|
||||||
|
};
|
||||||
|
const parseInitialDate = () => {
|
||||||
|
const matched = /^(\d{4})-(\d{2})-(\d{2})$/.exec(props.value);
|
||||||
|
const fallback = new Date();
|
||||||
|
const isFutureDate = matched
|
||||||
|
? new Date(Number(matched[1]), Number(matched[2]) - 1, Number(matched[3])) > today
|
||||||
|
: false;
|
||||||
|
if (isFutureDate) {
|
||||||
|
selectedYear.value = CURRENT_YEAR;
|
||||||
|
selectedMonth.value = CURRENT_MONTH;
|
||||||
|
selectedDay.value = CURRENT_DAY;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
selectedYear.value = matched
|
||||||
|
? Math.max(MIN_YEAR, Math.min(CURRENT_YEAR, Number(matched[1])))
|
||||||
|
: fallback.getFullYear();
|
||||||
|
selectedMonth.value = matched
|
||||||
|
? Math.max(1, Math.min(12, Number(matched[2])))
|
||||||
|
: fallback.getMonth() + 1;
|
||||||
|
selectedDay.value = matched ? Math.max(1, Number(matched[3])) : fallback.getDate();
|
||||||
|
if (selectedMonthKey.value > currentMonthKey) {
|
||||||
|
selectedMonth.value = CURRENT_MONTH;
|
||||||
|
}
|
||||||
|
clampSelectedDay();
|
||||||
|
};
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.visible,
|
||||||
|
(visible) => {
|
||||||
|
if (visible) parseInitialDate();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const selectYear = (event) => {
|
||||||
|
selectedYear.value = years[Number(event.detail.value)] || selectedYear.value;
|
||||||
|
if (selectedMonthKey.value > currentMonthKey) {
|
||||||
|
selectedMonth.value = CURRENT_MONTH;
|
||||||
|
}
|
||||||
|
clampSelectedDay();
|
||||||
|
};
|
||||||
|
const selectMonth = (event) => {
|
||||||
|
const nextMonth = availableMonths.value[Number(event.detail.value)];
|
||||||
|
if (!nextMonth) return;
|
||||||
|
selectedMonth.value = nextMonth;
|
||||||
|
clampSelectedDay();
|
||||||
|
};
|
||||||
|
const changeMonth = (offset) => {
|
||||||
|
const nextDate = new Date(selectedYear.value, selectedMonth.value - 1 + offset, 1);
|
||||||
|
const nextYear = nextDate.getFullYear();
|
||||||
|
const nextMonth = nextDate.getMonth() + 1;
|
||||||
|
const nextMonthKey = nextYear * 12 + nextMonth - 1;
|
||||||
|
if (nextMonthKey < firstMonthKey || nextMonthKey > currentMonthKey) return;
|
||||||
|
selectedYear.value = nextYear;
|
||||||
|
selectedMonth.value = nextMonth;
|
||||||
|
clampSelectedDay();
|
||||||
|
};
|
||||||
|
const padDatePart = (value) => String(value).padStart(2, "0");
|
||||||
|
const confirmSelection = () => {
|
||||||
|
if (selectedMonthKey.value > currentMonthKey) return;
|
||||||
|
clampSelectedDay();
|
||||||
|
emit(
|
||||||
|
"confirm",
|
||||||
|
`${selectedYear.value}-${padDatePart(selectedMonth.value)}-${padDatePart(
|
||||||
|
selectedDay.value,
|
||||||
|
)}`,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.member-date-picker {
|
||||||
|
position: fixed;
|
||||||
|
z-index: 90;
|
||||||
|
inset: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
}
|
||||||
|
.member-date-picker__mask {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(43, 30, 20, 0.5);
|
||||||
|
}
|
||||||
|
.member-date-picker__panel {
|
||||||
|
position: relative;
|
||||||
|
box-sizing: border-box;
|
||||||
|
width: 100%;
|
||||||
|
padding: 28rpx 30rpx calc(28rpx + env(safe-area-inset-bottom));
|
||||||
|
border-radius: 28rpx 28rpx 0 0;
|
||||||
|
background: #fdf8ed;
|
||||||
|
box-shadow: 0 -12rpx 36rpx rgba(43, 30, 20, 0.2);
|
||||||
|
}
|
||||||
|
.member-date-picker__heading {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.member-date-picker__title,
|
||||||
|
.member-date-picker__hint {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
.member-date-picker__title {
|
||||||
|
color: $ink;
|
||||||
|
font-family: STKaiti, KaiTi, serif;
|
||||||
|
font-size: clamp(20px, 36rpx, 25px);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
.member-date-picker__hint {
|
||||||
|
margin-top: 6rpx;
|
||||||
|
color: $ink-muted;
|
||||||
|
font-size: clamp(13px, 21rpx, 16px);
|
||||||
|
}
|
||||||
|
.member-date-picker__toolbar {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 76rpx minmax(0, 1fr) 76rpx;
|
||||||
|
align-items: center;
|
||||||
|
gap: 14rpx;
|
||||||
|
margin-top: 20rpx;
|
||||||
|
}
|
||||||
|
.member-date-picker__month-button {
|
||||||
|
display: flex;
|
||||||
|
width: 76rpx;
|
||||||
|
min-height: 76rpx;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
border: 1rpx solid rgba(181, 137, 63, 0.34);
|
||||||
|
border-radius: 10rpx;
|
||||||
|
background: rgba(247, 237, 218, 0.7);
|
||||||
|
}
|
||||||
|
.member-date-picker__month-button[disabled],
|
||||||
|
.member-date-picker__day[disabled] {
|
||||||
|
opacity: 0.32;
|
||||||
|
}
|
||||||
|
.member-date-picker__month-button::after,
|
||||||
|
.member-date-picker__year-button::after,
|
||||||
|
.member-date-picker__month-button-direct::after,
|
||||||
|
.member-date-picker__day::after,
|
||||||
|
.member-date-picker__cancel::after,
|
||||||
|
.member-date-picker__confirm::after {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.member-date-picker__month-icon {
|
||||||
|
width: 28rpx;
|
||||||
|
height: 28rpx;
|
||||||
|
}
|
||||||
|
.member-date-picker__month-icon--previous {
|
||||||
|
transform: rotate(180deg);
|
||||||
|
}
|
||||||
|
.member-date-picker__current-month {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) auto;
|
||||||
|
align-items: center;
|
||||||
|
gap: 14rpx;
|
||||||
|
}
|
||||||
|
.member-date-picker__year-button {
|
||||||
|
display: grid;
|
||||||
|
width: 100%;
|
||||||
|
min-height: 76rpx;
|
||||||
|
align-content: center;
|
||||||
|
margin: 0;
|
||||||
|
padding: 6rpx 18rpx;
|
||||||
|
border: 1rpx solid rgba(159, 23, 15, 0.38);
|
||||||
|
border-radius: 10rpx;
|
||||||
|
background: rgba(255, 250, 240, 0.92);
|
||||||
|
color: $brand-red;
|
||||||
|
line-height: 1.25;
|
||||||
|
}
|
||||||
|
.member-date-picker__year-button text:first-child {
|
||||||
|
font-size: clamp(12px, 19rpx, 15px);
|
||||||
|
}
|
||||||
|
.member-date-picker__year-button text:last-child {
|
||||||
|
font-size: clamp(17px, 29rpx, 21px);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
.member-date-picker__month-button-direct {
|
||||||
|
display: grid;
|
||||||
|
min-width: 116rpx;
|
||||||
|
min-height: 76rpx;
|
||||||
|
align-content: center;
|
||||||
|
margin: 0;
|
||||||
|
padding: 6rpx 12rpx;
|
||||||
|
border: 1rpx solid rgba(181, 137, 63, 0.34);
|
||||||
|
border-radius: 10rpx;
|
||||||
|
background: rgba(247, 237, 218, 0.7);
|
||||||
|
color: $ink;
|
||||||
|
line-height: 1.25;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.member-date-picker__month-button-direct text:first-child {
|
||||||
|
color: $ink-muted;
|
||||||
|
font-size: clamp(12px, 19rpx, 15px);
|
||||||
|
}
|
||||||
|
.member-date-picker__month-button-direct text:last-child {
|
||||||
|
font-family: STKaiti, KaiTi, serif;
|
||||||
|
font-size: clamp(17px, 29rpx, 21px);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
.member-date-picker__weekdays,
|
||||||
|
.member-date-picker__calendar {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(7, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
.member-date-picker__weekdays {
|
||||||
|
margin-top: 20rpx;
|
||||||
|
color: $ink-muted;
|
||||||
|
font-size: clamp(13px, 21rpx, 16px);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.member-date-picker__calendar {
|
||||||
|
margin-top: 8rpx;
|
||||||
|
padding: 8rpx;
|
||||||
|
border: 1rpx solid rgba(181, 137, 63, 0.3);
|
||||||
|
border-radius: 12rpx;
|
||||||
|
background: rgba(255, 252, 245, 0.76);
|
||||||
|
}
|
||||||
|
.member-date-picker__day-slot {
|
||||||
|
display: flex;
|
||||||
|
min-height: 70rpx;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
.member-date-picker__day {
|
||||||
|
display: flex;
|
||||||
|
width: 58rpx;
|
||||||
|
min-height: 58rpx;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: transparent;
|
||||||
|
color: $ink;
|
||||||
|
font-size: clamp(14px, 23rpx, 18px);
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
.member-date-picker__day--selected {
|
||||||
|
background: $brand-red;
|
||||||
|
color: #fff9ed;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
.member-date-picker__footer {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 16rpx;
|
||||||
|
margin-top: 20rpx;
|
||||||
|
}
|
||||||
|
.member-date-picker__cancel,
|
||||||
|
.member-date-picker__confirm {
|
||||||
|
min-height: 82rpx;
|
||||||
|
margin: 0;
|
||||||
|
border-radius: 10rpx;
|
||||||
|
font-size: clamp(16px, 28rpx, 20px);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
.member-date-picker__cancel {
|
||||||
|
border: 1rpx solid rgba(159, 23, 15, 0.34);
|
||||||
|
background: transparent;
|
||||||
|
color: $brand-red;
|
||||||
|
}
|
||||||
|
.member-date-picker__confirm {
|
||||||
|
border: 0;
|
||||||
|
background: $brand-red;
|
||||||
|
color: #fff9ed;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -22,15 +22,35 @@
|
|||||||
label="新建证件档案"
|
label="新建证件档案"
|
||||||
@click="openDocumentCreate"
|
@click="openDocumentCreate"
|
||||||
/>
|
/>
|
||||||
|
<BatchManagementBar
|
||||||
|
v-if="deletableDocuments.length"
|
||||||
|
resource-name="证件档案"
|
||||||
|
:active="documentBatch.selectionMode.value"
|
||||||
|
:selected-count="documentBatch.selectedCount.value"
|
||||||
|
:all-selected="documentBatch.allSelected.value"
|
||||||
|
:busy="documentBatch.deleting.value"
|
||||||
|
@start="documentBatch.enterSelectionMode"
|
||||||
|
@finish="documentBatch.exitSelectionMode"
|
||||||
|
@toggle-all="documentBatch.toggleAll"
|
||||||
|
@delete="documentBatch.requestDelete"
|
||||||
|
/>
|
||||||
|
<text v-if="documentBatch.notice.value" class="document-batch-notice" role="status">{{ documentBatch.notice.value }}</text>
|
||||||
|
<text v-if="documentBatch.error.value" class="document-dialog-error" role="alert">{{ documentBatch.error.value }}</text>
|
||||||
<text v-if="!documents.length" class="document-dialog-empty">{{ personId ? "这位成员还没有可查看的重要证件" : "当前家谱还没有可查看的重要证件" }}</text>
|
<text v-if="!documents.length" class="document-dialog-empty">{{ personId ? "这位成员还没有可查看的重要证件" : "当前家谱还没有可查看的重要证件" }}</text>
|
||||||
<view
|
<view
|
||||||
v-for="documentSummary in documents"
|
v-for="documentSummary in documents"
|
||||||
:key="documentSummary.documentId"
|
:key="documentSummary.documentId"
|
||||||
class="document-list-item"
|
class="document-list-item"
|
||||||
role="button"
|
role="button"
|
||||||
:aria-label="`查看${documentSummary.documentTitle}`"
|
:aria-label="documentBatch.selectionMode.value && documentSummary.canDelete ? `${documentBatch.isSelected(documentSummary) ? '取消选择' : '选择'}${documentSummary.documentTitle}` : `查看${documentSummary.documentTitle}`"
|
||||||
@click="openDocument(documentSummary)"
|
@click="handleDocumentSummaryClick(documentSummary)"
|
||||||
>
|
>
|
||||||
|
<BatchSelectionMark
|
||||||
|
v-if="documentBatch.selectionMode.value && documentSummary.canDelete"
|
||||||
|
:selected="documentBatch.isSelected(documentSummary)"
|
||||||
|
:label="`证件档案:${documentSummary.documentTitle}`"
|
||||||
|
@toggle="documentBatch.toggleSelection(documentSummary)"
|
||||||
|
/>
|
||||||
<view>
|
<view>
|
||||||
<text>{{ documentSummary.documentTitle }}</text>
|
<text>{{ documentSummary.documentTitle }}</text>
|
||||||
<text
|
<text
|
||||||
@@ -113,6 +133,11 @@
|
|||||||
<button v-if="documentDialogMode === 'create'" class="document-upload-button" :disabled="documentState === 'submitting'" @click="uploadDocumentImage">
|
<button v-if="documentDialogMode === 'create'" class="document-upload-button" :disabled="documentState === 'submitting'" @click="uploadDocumentImage">
|
||||||
{{ documentUploadReceipt ? `已选择:${documentUploadReceipt.fileName || '证件图片'}` : '添加证件图片(选填)' }}
|
{{ documentUploadReceipt ? `已选择:${documentUploadReceipt.fileName || '证件图片'}` : '添加证件图片(选填)' }}
|
||||||
</button>
|
</button>
|
||||||
|
<template v-if="documentDialogMode === 'create'">
|
||||||
|
<text class="document-form-hint">内容密码选填;填写后会先创建档案,再立即启用密码保护。</text>
|
||||||
|
<input v-model="documentPassword" class="document-form-field" password maxlength="128" placeholder="请输入8至128位内容密码" />
|
||||||
|
<input v-model="documentPasswordConfirm" class="document-form-field" password maxlength="128" placeholder="请再次输入内容密码" />
|
||||||
|
</template>
|
||||||
<text v-if="documentError" class="document-dialog-error">{{ documentError }}</text>
|
<text v-if="documentError" class="document-dialog-error">{{ documentError }}</text>
|
||||||
<AppButton block :disabled="documentState === 'submitting'" :label="documentState === 'submitting' ? '正在保存' : '保存证件档案'" @click="saveDocument" />
|
<AppButton block :disabled="documentState === 'submitting'" :label="documentState === 'submitting' ? '正在保存' : '保存证件档案'" @click="saveDocument" />
|
||||||
</template>
|
</template>
|
||||||
@@ -129,6 +154,18 @@
|
|||||||
</template>
|
</template>
|
||||||
</view>
|
</view>
|
||||||
</AppDialog>
|
</AppDialog>
|
||||||
|
<AppDialog
|
||||||
|
:visible="documentBatch.confirmationVisible.value"
|
||||||
|
eyebrow="批量删除"
|
||||||
|
title="将选中的证件档案删除?"
|
||||||
|
message="删除后,档案和已关联的证件文件都无法恢复。"
|
||||||
|
:confirm-text="documentBatch.deleting.value ? '正在删除' : '确认删除'"
|
||||||
|
cancel-text="继续选择"
|
||||||
|
show-cancel
|
||||||
|
:close-on-mask="false"
|
||||||
|
@confirm="documentBatch.confirmDelete"
|
||||||
|
@cancel="documentBatch.cancelDelete"
|
||||||
|
/>
|
||||||
<AppDialog
|
<AppDialog
|
||||||
:visible="documentDeleteVisible"
|
:visible="documentDeleteVisible"
|
||||||
eyebrow="重要证件"
|
eyebrow="重要证件"
|
||||||
@@ -182,6 +219,8 @@ import { computed, onUnmounted, reactive, ref, watch } from "vue";
|
|||||||
import AppButton from "@/components/AppButton.vue";
|
import AppButton from "@/components/AppButton.vue";
|
||||||
import AppDialog from "@/components/AppDialog.vue";
|
import AppDialog from "@/components/AppDialog.vue";
|
||||||
import AppLoading from "@/components/AppLoading.vue";
|
import AppLoading from "@/components/AppLoading.vue";
|
||||||
|
import BatchManagementBar from "@/components/BatchManagementBar.vue";
|
||||||
|
import BatchSelectionMark from "@/components/BatchSelectionMark.vue";
|
||||||
import ContentPasswordRecoveryDialog from "@/components/ContentPasswordRecoveryDialog.vue";
|
import ContentPasswordRecoveryDialog from "@/components/ContentPasswordRecoveryDialog.vue";
|
||||||
import {
|
import {
|
||||||
PERSON_DOCUMENT_RESOURCE_USAGE,
|
PERSON_DOCUMENT_RESOURCE_USAGE,
|
||||||
@@ -194,6 +233,7 @@ import {
|
|||||||
} from "@/services/api/request-controller.js";
|
} from "@/services/api/request-controller.js";
|
||||||
import { personDocumentApi } from "@/services/api/person-document-service.js";
|
import { personDocumentApi } from "@/services/api/person-document-service.js";
|
||||||
import { getRequestErrorMessage } from "@/services/api/request-error-message.js";
|
import { getRequestErrorMessage } from "@/services/api/request-error-message.js";
|
||||||
|
import { useBatchDeletion } from "@/composables/use-batch-deletion.js";
|
||||||
import { isImagePickCancelled, pickAndUploadImage } from "@/utils/media-upload.js";
|
import { isImagePickCancelled, pickAndUploadImage } from "@/utils/media-upload.js";
|
||||||
import { createNonIdempotentWriteGuard } from "@/utils/non-idempotent-write-guard.js";
|
import { createNonIdempotentWriteGuard } from "@/utils/non-idempotent-write-guard.js";
|
||||||
|
|
||||||
@@ -240,6 +280,17 @@ let documentCreateGuard = createNonIdempotentWriteGuard();
|
|||||||
let documentResourceCreateGuard = createNonIdempotentWriteGuard();
|
let documentResourceCreateGuard = createNonIdempotentWriteGuard();
|
||||||
let isActive = true;
|
let isActive = true;
|
||||||
let workflowVersion = 0;
|
let workflowVersion = 0;
|
||||||
|
const documentBatch = useBatchDeletion({
|
||||||
|
items: documents,
|
||||||
|
getId: (document) => document?.documentId,
|
||||||
|
deleteOne: (document) =>
|
||||||
|
personDocumentApi.deletePersonDocument(props.genealogyId, document.documentId, {
|
||||||
|
requestController: documentDeletionRequestController,
|
||||||
|
}),
|
||||||
|
resourceName: "证件档案",
|
||||||
|
isActive: () => isActive && documentDialogVisible.value,
|
||||||
|
});
|
||||||
|
const deletableDocuments = documentBatch.deletableItems;
|
||||||
|
|
||||||
const documentDialogTitle = computed(() => {
|
const documentDialogTitle = computed(() => {
|
||||||
switch (documentDialogMode.value) {
|
switch (documentDialogMode.value) {
|
||||||
@@ -340,6 +391,7 @@ const resetDocumentWorkflow = () => {
|
|||||||
documentAccessToken.value = "";
|
documentAccessToken.value = "";
|
||||||
documentError.value = "";
|
documentError.value = "";
|
||||||
documentUploadReceipt.value = null;
|
documentUploadReceipt.value = null;
|
||||||
|
documentBatch.exitSelectionMode();
|
||||||
};
|
};
|
||||||
|
|
||||||
const open = async () => {
|
const open = async () => {
|
||||||
@@ -367,6 +419,7 @@ const open = async () => {
|
|||||||
]);
|
]);
|
||||||
if (!isCurrentPersonContext(documentPersonId, activeWorkflow)) return;
|
if (!isCurrentPersonContext(documentPersonId, activeWorkflow)) return;
|
||||||
documents.value = documentRows;
|
documents.value = documentRows;
|
||||||
|
documentBatch.exitSelectionMode();
|
||||||
documentTypeOptions.value = typeOptions;
|
documentTypeOptions.value = typeOptions;
|
||||||
documentState.value = "ready";
|
documentState.value = "ready";
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -396,6 +449,8 @@ const openDocumentCreate = () => {
|
|||||||
description: "",
|
description: "",
|
||||||
});
|
});
|
||||||
documentUploadReceipt.value = null;
|
documentUploadReceipt.value = null;
|
||||||
|
documentPassword.value = "";
|
||||||
|
documentPasswordConfirm.value = "";
|
||||||
documentDialogMode.value = "create";
|
documentDialogMode.value = "create";
|
||||||
documentState.value = "ready";
|
documentState.value = "ready";
|
||||||
documentError.value = "";
|
documentError.value = "";
|
||||||
@@ -454,6 +509,16 @@ const createDocument = async () => {
|
|||||||
documentError.value = "请填写证件名称。";
|
documentError.value = "请填写证件名称。";
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (documentPassword.value || documentPasswordConfirm.value) {
|
||||||
|
if (documentPassword.value.length < 8 || documentPassword.value.length > 128) {
|
||||||
|
documentError.value = "内容密码必须为8至128位。";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (documentPassword.value !== documentPasswordConfirm.value) {
|
||||||
|
documentError.value = "两次输入的内容密码不一致。";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
const documentPersonId = props.personId;
|
const documentPersonId = props.personId;
|
||||||
const createPayload = { lineagePersonId: documentPersonId, ...documentForm };
|
const createPayload = { lineagePersonId: documentPersonId, ...documentForm };
|
||||||
const createAttempt = documentCreateGuard.begin(createPayload);
|
const createAttempt = documentCreateGuard.begin(createPayload);
|
||||||
@@ -467,6 +532,7 @@ const createDocument = async () => {
|
|||||||
const activeWorkflow = workflowVersion;
|
const activeWorkflow = workflowVersion;
|
||||||
const uploadReceipt = documentUploadReceipt.value;
|
const uploadReceipt = documentUploadReceipt.value;
|
||||||
let createdDocumentId = "";
|
let createdDocumentId = "";
|
||||||
|
let postCreateStage = "creating";
|
||||||
try {
|
try {
|
||||||
const createdDocument = await personDocumentApi.createPersonDocument(
|
const createdDocument = await personDocumentApi.createPersonDocument(
|
||||||
props.genealogyId,
|
props.genealogyId,
|
||||||
@@ -475,6 +541,7 @@ const createDocument = async () => {
|
|||||||
);
|
);
|
||||||
if (!isCurrentPersonContext(documentPersonId, activeWorkflow)) return;
|
if (!isCurrentPersonContext(documentPersonId, activeWorkflow)) return;
|
||||||
createdDocumentId = createdDocument.documentId;
|
createdDocumentId = createdDocument.documentId;
|
||||||
|
postCreateStage = "linking-file";
|
||||||
if (uploadReceipt?.ossId) {
|
if (uploadReceipt?.ossId) {
|
||||||
await personDocumentApi.addPersonDocumentResource(
|
await personDocumentApi.addPersonDocumentResource(
|
||||||
props.genealogyId,
|
props.genealogyId,
|
||||||
@@ -487,6 +554,19 @@ const createDocument = async () => {
|
|||||||
);
|
);
|
||||||
if (!isCurrentPersonContext(documentPersonId, activeWorkflow)) return;
|
if (!isCurrentPersonContext(documentPersonId, activeWorkflow)) return;
|
||||||
}
|
}
|
||||||
|
postCreateStage = "setting-password";
|
||||||
|
if (documentPassword.value) {
|
||||||
|
await personDocumentApi.setPersonDocumentPassword(
|
||||||
|
props.genealogyId,
|
||||||
|
createdDocument.documentId,
|
||||||
|
documentPassword.value,
|
||||||
|
{ requestController: documentSaveRequestController },
|
||||||
|
);
|
||||||
|
if (!isCurrentPersonContext(documentPersonId, activeWorkflow)) return;
|
||||||
|
}
|
||||||
|
postCreateStage = "complete";
|
||||||
|
documentPassword.value = "";
|
||||||
|
documentPasswordConfirm.value = "";
|
||||||
await open();
|
await open();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (
|
if (
|
||||||
@@ -494,6 +574,8 @@ const createDocument = async () => {
|
|||||||
isCurrentPersonContext(documentPersonId, activeWorkflow) &&
|
isCurrentPersonContext(documentPersonId, activeWorkflow) &&
|
||||||
!isRequestCancelled(error)
|
!isRequestCancelled(error)
|
||||||
) {
|
) {
|
||||||
|
documentPassword.value = "";
|
||||||
|
documentPasswordConfirm.value = "";
|
||||||
await loadDocumentDetail(createdDocumentId, "");
|
await loadDocumentDetail(createdDocumentId, "");
|
||||||
if (
|
if (
|
||||||
isCurrentDocumentContext(
|
isCurrentDocumentContext(
|
||||||
@@ -502,12 +584,14 @@ const createDocument = async () => {
|
|||||||
activeWorkflow,
|
activeWorkflow,
|
||||||
)
|
)
|
||||||
) {
|
) {
|
||||||
documentError.value =
|
documentError.value = postCreateStage === "setting-password"
|
||||||
"证件档案已创建,但图片关联结果暂时无法确认。请检查文件列表后再添加,避免重复创建档案。";
|
? "证件档案已创建,图片也已处理,但内容密码设置结果暂时无法确认。请在详情中重新设置,当前档案可能尚未受到密码保护。"
|
||||||
|
: "证件档案已创建,但图片关联结果暂时无法确认。请检查文件列表后再添加,避免重复创建档案。";
|
||||||
} else if (isCurrentPersonContext(documentPersonId, activeWorkflow)) {
|
} else if (isCurrentPersonContext(documentPersonId, activeWorkflow)) {
|
||||||
documentState.value = "error";
|
documentState.value = "error";
|
||||||
documentError.value =
|
documentError.value = postCreateStage === "setting-password"
|
||||||
"证件档案已创建,但暂时无法确认图片是否关联。请关闭后重新打开证件列表,避免重复创建档案。";
|
? "证件档案已创建,但内容密码设置结果暂时无法确认。请重新打开详情检查,避免重复创建档案。"
|
||||||
|
: "证件档案已创建,但暂时无法确认图片是否关联。请关闭后重新打开证件列表,避免重复创建档案。";
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -600,6 +684,13 @@ const openDocument = async (documentSummary) => {
|
|||||||
}
|
}
|
||||||
await loadDocumentDetail(documentSummary.documentId, "");
|
await loadDocumentDetail(documentSummary.documentId, "");
|
||||||
};
|
};
|
||||||
|
const handleDocumentSummaryClick = (documentSummary) => {
|
||||||
|
if (documentBatch.selectionMode.value && documentSummary?.canDelete) {
|
||||||
|
documentBatch.toggleSelection(documentSummary);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
openDocument(documentSummary);
|
||||||
|
};
|
||||||
const unlockDocument = async () => {
|
const unlockDocument = async () => {
|
||||||
if (documentState.value === "submitting" || !selectedDocument.value) return;
|
if (documentState.value === "submitting" || !selectedDocument.value) return;
|
||||||
documentError.value = "";
|
documentError.value = "";
|
||||||
@@ -1001,6 +1092,7 @@ onUnmounted(() => {
|
|||||||
font-size: clamp(14px, 22rpx, 17px);
|
font-size: clamp(14px, 22rpx, 17px);
|
||||||
}
|
}
|
||||||
.document-form-field--textarea { min-height: 132rpx; }
|
.document-form-field--textarea { min-height: 132rpx; }
|
||||||
|
.document-form-hint { display: block; margin-top: 16rpx; color: $ink-muted; font-size: clamp(13px, 21rpx, 16px); line-height: 1.5; }
|
||||||
.document-upload-button {
|
.document-upload-button {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
min-height: 76rpx;
|
min-height: 76rpx;
|
||||||
@@ -1068,4 +1160,5 @@ onUnmounted(() => {
|
|||||||
.document-dialog-empty,
|
.document-dialog-empty,
|
||||||
.document-dialog-error { padding: 24rpx 10rpx; text-align: center; }
|
.document-dialog-error { padding: 24rpx 10rpx; text-align: center; }
|
||||||
.document-dialog-error { color: $brand-red; }
|
.document-dialog-error { color: $brand-red; }
|
||||||
|
.document-batch-notice { display: block; padding: 14rpx 10rpx; color: #426538; font-size: clamp(13px, 21rpx, 16px); line-height: 1.55; }
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -0,0 +1,135 @@
|
|||||||
|
import { computed, ref } from "vue";
|
||||||
|
import { isRequestCancelled } from "@/services/api/request-controller.js";
|
||||||
|
import { getRequestErrorMessage } from "@/services/api/request-error-message.js";
|
||||||
|
|
||||||
|
export const useBatchDeletion = ({
|
||||||
|
items,
|
||||||
|
visibleItems = items,
|
||||||
|
getId = (item) => item?.id,
|
||||||
|
canDelete = (item) => item?.canDelete === true,
|
||||||
|
deleteOne,
|
||||||
|
resourceName,
|
||||||
|
isActive = () => true,
|
||||||
|
onEmpty = () => {},
|
||||||
|
}) => {
|
||||||
|
const selectionMode = ref(false);
|
||||||
|
const selectedIds = ref([]);
|
||||||
|
const confirmationVisible = ref(false);
|
||||||
|
const deleting = ref(false);
|
||||||
|
const notice = ref("");
|
||||||
|
const error = ref("");
|
||||||
|
|
||||||
|
const deletableItems = computed(() => visibleItems.value.filter(canDelete));
|
||||||
|
const allSelected = computed(
|
||||||
|
() =>
|
||||||
|
deletableItems.value.length > 0 &&
|
||||||
|
deletableItems.value.every((item) => selectedIds.value.includes(getId(item))),
|
||||||
|
);
|
||||||
|
const selectedCount = computed(() => selectedIds.value.length);
|
||||||
|
const confirmationMessage = computed(
|
||||||
|
() =>
|
||||||
|
`将选中的 ${selectedCount.value} 条${resourceName}移入回收站;管理员可在保留期内恢复。`,
|
||||||
|
);
|
||||||
|
|
||||||
|
const clearFeedback = () => {
|
||||||
|
notice.value = "";
|
||||||
|
error.value = "";
|
||||||
|
};
|
||||||
|
const exitSelectionMode = () => {
|
||||||
|
selectionMode.value = false;
|
||||||
|
selectedIds.value = [];
|
||||||
|
confirmationVisible.value = false;
|
||||||
|
};
|
||||||
|
const enterSelectionMode = () => {
|
||||||
|
if (!deletableItems.value.length || deleting.value) return;
|
||||||
|
clearFeedback();
|
||||||
|
selectedIds.value = [];
|
||||||
|
selectionMode.value = true;
|
||||||
|
};
|
||||||
|
const isSelected = (item) => selectedIds.value.includes(getId(item));
|
||||||
|
const toggleSelection = (item) => {
|
||||||
|
if (!selectionMode.value || !canDelete(item) || deleting.value) return;
|
||||||
|
const id = getId(item);
|
||||||
|
selectedIds.value = isSelected(item)
|
||||||
|
? selectedIds.value.filter((selectedId) => selectedId !== id)
|
||||||
|
: [...selectedIds.value, id];
|
||||||
|
clearFeedback();
|
||||||
|
};
|
||||||
|
const toggleAll = () => {
|
||||||
|
if (!selectionMode.value || deleting.value) return;
|
||||||
|
selectedIds.value = allSelected.value ? [] : deletableItems.value.map(getId);
|
||||||
|
clearFeedback();
|
||||||
|
};
|
||||||
|
const requestDelete = () => {
|
||||||
|
if (!selectionMode.value || !selectedIds.value.length || deleting.value) return;
|
||||||
|
clearFeedback();
|
||||||
|
confirmationVisible.value = true;
|
||||||
|
};
|
||||||
|
const cancelDelete = () => {
|
||||||
|
if (!deleting.value) confirmationVisible.value = false;
|
||||||
|
};
|
||||||
|
const confirmDelete = async () => {
|
||||||
|
if (!selectionMode.value || !selectedIds.value.length || deleting.value) return;
|
||||||
|
const pendingIds = selectedIds.value.slice();
|
||||||
|
deleting.value = true;
|
||||||
|
clearFeedback();
|
||||||
|
let deletedCount = 0;
|
||||||
|
let failedRequest = null;
|
||||||
|
try {
|
||||||
|
for (const id of pendingIds) {
|
||||||
|
const target = items.value.find((item) => getId(item) === id);
|
||||||
|
if (!target || !canDelete(target)) continue;
|
||||||
|
try {
|
||||||
|
await deleteOne(target);
|
||||||
|
} catch (cause) {
|
||||||
|
failedRequest = cause;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (!isActive()) return;
|
||||||
|
deletedCount += 1;
|
||||||
|
items.value = items.value.filter((item) => getId(item) !== id);
|
||||||
|
selectedIds.value = selectedIds.value.filter((selectedId) => selectedId !== id);
|
||||||
|
}
|
||||||
|
if (!isActive()) return;
|
||||||
|
confirmationVisible.value = false;
|
||||||
|
if (failedRequest && !isRequestCancelled(failedRequest)) {
|
||||||
|
const failureCopy = getRequestErrorMessage(
|
||||||
|
failedRequest,
|
||||||
|
`剩余${resourceName}删除失败,请稍后重试。`,
|
||||||
|
);
|
||||||
|
error.value = deletedCount ? `已删除 ${deletedCount} 条;${failureCopy}` : failureCopy;
|
||||||
|
} else if (deletedCount) {
|
||||||
|
notice.value = `已删除 ${deletedCount} 条${resourceName}。`;
|
||||||
|
}
|
||||||
|
if (!items.value.length) {
|
||||||
|
exitSelectionMode();
|
||||||
|
onEmpty();
|
||||||
|
} else if (!selectedIds.value.length) {
|
||||||
|
selectionMode.value = false;
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (isActive()) deleting.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
selectionMode,
|
||||||
|
selectedIds,
|
||||||
|
selectedCount,
|
||||||
|
deletableItems,
|
||||||
|
allSelected,
|
||||||
|
confirmationVisible,
|
||||||
|
confirmationMessage,
|
||||||
|
deleting,
|
||||||
|
notice,
|
||||||
|
error,
|
||||||
|
enterSelectionMode,
|
||||||
|
exitSelectionMode,
|
||||||
|
isSelected,
|
||||||
|
toggleSelection,
|
||||||
|
toggleAll,
|
||||||
|
requestDelete,
|
||||||
|
cancelDelete,
|
||||||
|
confirmDelete,
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -20,6 +20,8 @@
|
|||||||
{ "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-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-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-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-home-ad-family-tree", "output": "static/assets/modules/genealogy/opaque/home-ad-family-tree.png", "width": 1862, "height": 845, "alpha": false, "bytes": 2701295, "sha256": "d333924d07569491809211508302c064bba6eee52b4af489a526bf53ba066be4", "provenance": "committed-binary", "rebuildable": false },
|
||||||
|
{ "id": "app-modules-genealogy-opaque-home-ad-heritage-hall", "output": "static/assets/modules/genealogy/opaque/home-ad-heritage-hall.png", "width": 1862, "height": 845, "alpha": false, "bytes": 2554759, "sha256": "c64fbb4fc965a084a7b1184c924673f6df25a3504858df3a790973664d886b6e", "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-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-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-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 },
|
||||||
@@ -46,9 +48,11 @@
|
|||||||
{ "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-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-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-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-member-node-portrait", "output": "static/assets/modules/tree/transparent/member-node-portrait.png", "width": 81, "height": 105, "alpha": true, "bytes": 1141, "sha256": "e0e1609ae3f70ef9ab0fec82f2ef9bdb8af1ad7966a2cfefa3aae41d9684212f", "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-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-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-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-opaque-book-blank", "output": "static/assets/foundation/opaque/book-blank.png", "width": 154, "height": 210, "alpha": false, "bytes": 46607, "sha256": "500441f7631b45e1f4367a0b4f7f27031d1585097a458af599e8c7b922314454", "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-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-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-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 },
|
||||||
|
|||||||
@@ -3,8 +3,7 @@
|
|||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"test": "node --test tests/*.test.mjs",
|
|
||||||
"validate:runtime-assets": "node scripts/validate-runtime-asset-inventory.mjs design-pipeline/manifests/runtime-assets.json",
|
"validate:runtime-assets": "node scripts/validate-runtime-asset-inventory.mjs design-pipeline/manifests/runtime-assets.json",
|
||||||
"check": "npm test && npm run validate:runtime-assets"
|
"check": "npm run validate:runtime-assets"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,142 +0,0 @@
|
|||||||
import test from 'node:test'
|
|
||||||
import assert from 'node:assert/strict'
|
|
||||||
import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises'
|
|
||||||
import os from 'node:os'
|
|
||||||
import path from 'node:path'
|
|
||||||
|
|
||||||
import { expandRuntimeAssetInventory } from '../scripts/runtime-asset-inventory.mjs'
|
|
||||||
|
|
||||||
const createWorkspace = async () => {
|
|
||||||
const workspace = await mkdtemp(path.join(os.tmpdir(), 'jiapu-runtime-assets-'))
|
|
||||||
await mkdir(path.join(workspace, 'design-pipeline', 'manifests'), { recursive: true })
|
|
||||||
return workspace
|
|
||||||
}
|
|
||||||
|
|
||||||
const writeManifest = async (workspace, name, value) => {
|
|
||||||
const filePath = path.join(workspace, 'design-pipeline', 'manifests', name)
|
|
||||||
await writeFile(filePath, `${JSON.stringify(value, null, 2)}\n`, 'utf8')
|
|
||||||
return filePath
|
|
||||||
}
|
|
||||||
|
|
||||||
const directAsset = (id, output) => ({
|
|
||||||
id,
|
|
||||||
output,
|
|
||||||
width: 96,
|
|
||||||
height: 96,
|
|
||||||
alpha: true,
|
|
||||||
bytes: 100,
|
|
||||||
sha256: 'a'.repeat(64),
|
|
||||||
provenance: 'committed-binary',
|
|
||||||
rebuildable: false,
|
|
||||||
})
|
|
||||||
|
|
||||||
const runtimeManifest = (imports = [], assets = []) => ({
|
|
||||||
schemaVersion: 1,
|
|
||||||
kind: 'runtime-asset-inventory',
|
|
||||||
scope: 'auth',
|
|
||||||
imports,
|
|
||||||
assets,
|
|
||||||
})
|
|
||||||
|
|
||||||
test('展开直接资产与单一导入且不复制物理规格', async (t) => {
|
|
||||||
const workspace = await createWorkspace()
|
|
||||||
t.after(() => rm(workspace, { recursive: true, force: true }))
|
|
||||||
const imported = await writeManifest(
|
|
||||||
workspace,
|
|
||||||
'shared.json',
|
|
||||||
runtimeManifest([], [directAsset('shared', 'static/assets/shared.png')]),
|
|
||||||
)
|
|
||||||
const root = await writeManifest(
|
|
||||||
workspace,
|
|
||||||
'auth.json',
|
|
||||||
runtimeManifest(['design-pipeline/manifests/shared.json'], [directAsset('auth', 'static/assets/auth.png')]),
|
|
||||||
)
|
|
||||||
|
|
||||||
const result = await expandRuntimeAssetInventory(root, workspace)
|
|
||||||
assert.deepEqual(result.assets.map(({ id }) => id).sort(), ['auth', 'shared'])
|
|
||||||
assert(result.manifests.includes(imported))
|
|
||||||
})
|
|
||||||
|
|
||||||
test('拒绝缺失的导入文件', async (t) => {
|
|
||||||
const workspace = await createWorkspace()
|
|
||||||
t.after(() => rm(workspace, { recursive: true, force: true }))
|
|
||||||
const root = await writeManifest(workspace, 'auth.json', runtimeManifest(['design-pipeline/manifests/missing.json']))
|
|
||||||
await assert.rejects(() => expandRuntimeAssetInventory(root, workspace), /missing manifest/i)
|
|
||||||
})
|
|
||||||
|
|
||||||
test('拒绝循环导入', async (t) => {
|
|
||||||
const workspace = await createWorkspace()
|
|
||||||
t.after(() => rm(workspace, { recursive: true, force: true }))
|
|
||||||
const first = await writeManifest(workspace, 'first.json', runtimeManifest(['design-pipeline/manifests/second.json']))
|
|
||||||
await writeManifest(workspace, 'second.json', runtimeManifest(['design-pipeline/manifests/first.json']))
|
|
||||||
await assert.rejects(() => expandRuntimeAssetInventory(first, workspace), /import cycle/i)
|
|
||||||
})
|
|
||||||
|
|
||||||
test('拒绝同一清单被重复导入', async (t) => {
|
|
||||||
const workspace = await createWorkspace()
|
|
||||||
t.after(() => rm(workspace, { recursive: true, force: true }))
|
|
||||||
await writeManifest(workspace, 'shared.json', runtimeManifest([], [directAsset('shared', 'static/assets/shared.png')]))
|
|
||||||
const root = await writeManifest(
|
|
||||||
workspace,
|
|
||||||
'auth.json',
|
|
||||||
runtimeManifest([
|
|
||||||
'design-pipeline/manifests/shared.json',
|
|
||||||
'design-pipeline/manifests/shared.json',
|
|
||||||
]),
|
|
||||||
)
|
|
||||||
await assert.rejects(() => expandRuntimeAssetInventory(root, workspace), /duplicate import/i)
|
|
||||||
})
|
|
||||||
|
|
||||||
test('拒绝不同清单拥有同一正式输出', async (t) => {
|
|
||||||
const workspace = await createWorkspace()
|
|
||||||
t.after(() => rm(workspace, { recursive: true, force: true }))
|
|
||||||
await writeManifest(workspace, 'shared.json', runtimeManifest([], [directAsset('shared', 'static/assets/same.png')]))
|
|
||||||
const root = await writeManifest(
|
|
||||||
workspace,
|
|
||||||
'auth.json',
|
|
||||||
runtimeManifest(
|
|
||||||
['design-pipeline/manifests/shared.json'],
|
|
||||||
[directAsset('auth', 'static/assets/same.png')],
|
|
||||||
),
|
|
||||||
)
|
|
||||||
await assert.rejects(() => expandRuntimeAssetInventory(root, workspace), /duplicate output/i)
|
|
||||||
})
|
|
||||||
|
|
||||||
test('拒绝展开图中的重复资产 id', async (t) => {
|
|
||||||
const workspace = await createWorkspace()
|
|
||||||
t.after(() => rm(workspace, { recursive: true, force: true }))
|
|
||||||
await writeManifest(workspace, 'shared.json', runtimeManifest([], [directAsset('same-id', 'static/assets/shared.png')]))
|
|
||||||
const root = await writeManifest(
|
|
||||||
workspace,
|
|
||||||
'auth.json',
|
|
||||||
runtimeManifest(
|
|
||||||
['design-pipeline/manifests/shared.json'],
|
|
||||||
[directAsset('same-id', 'static/assets/auth.png')],
|
|
||||||
),
|
|
||||||
)
|
|
||||||
await assert.rejects(() => expandRuntimeAssetInventory(root, workspace), /duplicate asset id/i)
|
|
||||||
})
|
|
||||||
|
|
||||||
test('直接资产只接受 committed-binary 且拒绝未知字段', async (t) => {
|
|
||||||
const workspace = await createWorkspace()
|
|
||||||
t.after(() => rm(workspace, { recursive: true, force: true }))
|
|
||||||
|
|
||||||
const wrongProvenance = directAsset('wrong-provenance', 'static/assets/wrong.png')
|
|
||||||
wrongProvenance.provenance = 'manual-copy'
|
|
||||||
const wrongRoot = await writeManifest(workspace, 'wrong.json', runtimeManifest([], [wrongProvenance]))
|
|
||||||
await assert.rejects(() => expandRuntimeAssetInventory(wrongRoot, workspace), /committed-binary/i)
|
|
||||||
|
|
||||||
const unknownField = directAsset('unknown-field', 'static/assets/unknown.png')
|
|
||||||
unknownField.runtimeSelector = '.page'
|
|
||||||
const unknownRoot = await writeManifest(workspace, 'unknown.json', runtimeManifest([], [unknownField]))
|
|
||||||
await assert.rejects(() => expandRuntimeAssetInventory(unknownRoot, workspace), /unknown field/i)
|
|
||||||
})
|
|
||||||
|
|
||||||
test('运行时清单本身拒绝未知字段', async (t) => {
|
|
||||||
const workspace = await createWorkspace()
|
|
||||||
t.after(() => rm(workspace, { recursive: true, force: true }))
|
|
||||||
const manifest = runtimeManifest()
|
|
||||||
manifest.consumerList = []
|
|
||||||
const root = await writeManifest(workspace, 'auth.json', manifest)
|
|
||||||
await assert.rejects(() => expandRuntimeAssetInventory(root, workspace), /unknown field/i)
|
|
||||||
})
|
|
||||||
@@ -1,96 +0,0 @@
|
|||||||
import assert from 'node:assert/strict'
|
|
||||||
import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises'
|
|
||||||
import os from 'node:os'
|
|
||||||
import path from 'node:path'
|
|
||||||
import test from 'node:test'
|
|
||||||
import { fileURLToPath } from 'node:url'
|
|
||||||
|
|
||||||
import { validateRuntimeAssetRegistry } from '../scripts/runtime-asset-inventory.mjs'
|
|
||||||
|
|
||||||
const runtimeManifest = (scope, imports = []) => ({
|
|
||||||
schemaVersion: 1,
|
|
||||||
kind: 'runtime-asset-inventory',
|
|
||||||
scope,
|
|
||||||
imports,
|
|
||||||
assets: [],
|
|
||||||
})
|
|
||||||
|
|
||||||
const testDirectory = path.dirname(fileURLToPath(import.meta.url))
|
|
||||||
const pipelineDirectory = path.resolve(testDirectory, '..')
|
|
||||||
const workspaceDirectory = path.resolve(pipelineDirectory, '..')
|
|
||||||
const realManifestsDirectory = path.join(pipelineDirectory, 'manifests')
|
|
||||||
|
|
||||||
test('顶层注册表拒绝未进入导入闭包的正式 owner', async (t) => {
|
|
||||||
const workspace = await mkdtemp(path.join(os.tmpdir(), 'jiapu-runtime-registry-'))
|
|
||||||
t.after(() => rm(workspace, { recursive: true, force: true }))
|
|
||||||
const manifestsDirectory = path.join(workspace, 'design-pipeline', 'manifests')
|
|
||||||
await mkdir(manifestsDirectory, { recursive: true })
|
|
||||||
const write = (name, value) => writeFile(path.join(manifestsDirectory, name), `${JSON.stringify(value)}\n`, 'utf8')
|
|
||||||
|
|
||||||
await write('runtime-assets.json', runtimeManifest('schema-v3'))
|
|
||||||
await write('orphan.json', runtimeManifest('orphan'))
|
|
||||||
|
|
||||||
await assert.rejects(
|
|
||||||
() => validateRuntimeAssetRegistry(
|
|
||||||
path.join(manifestsDirectory, 'runtime-assets.json'),
|
|
||||||
workspace,
|
|
||||||
manifestsDirectory,
|
|
||||||
),
|
|
||||||
/unregistered manifest/i,
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
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/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,
|
|
||||||
realManifestsDirectory,
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
test('注册表根 scope 不是 schema-v3 时拒绝验证', async (t) => {
|
|
||||||
const workspace = await mkdtemp(path.join(os.tmpdir(), 'jiapu-runtime-registry-scope-'))
|
|
||||||
t.after(() => rm(workspace, { recursive: true, force: true }))
|
|
||||||
const manifestsDirectory = path.join(workspace, 'design-pipeline', 'manifests')
|
|
||||||
await mkdir(manifestsDirectory, { recursive: true })
|
|
||||||
const root = path.join(manifestsDirectory, 'runtime-assets.json')
|
|
||||||
await writeFile(root, `${JSON.stringify(runtimeManifest('auth'))}\n`, 'utf8')
|
|
||||||
|
|
||||||
await assert.rejects(
|
|
||||||
() => validateRuntimeAssetRegistry(root, workspace, manifestsDirectory),
|
|
||||||
/root scope must be schema-v3/i,
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
test('未声明的旧格式或未知 kind 清单不能被静默跳过', async (t) => {
|
|
||||||
const workspace = await mkdtemp(path.join(os.tmpdir(), 'jiapu-runtime-registry-unknown-'))
|
|
||||||
t.after(() => rm(workspace, { recursive: true, force: true }))
|
|
||||||
const manifestsDirectory = path.join(workspace, 'design-pipeline', 'manifests')
|
|
||||||
await mkdir(manifestsDirectory, { recursive: true })
|
|
||||||
const root = path.join(manifestsDirectory, 'runtime-assets.json')
|
|
||||||
await writeFile(root, `${JSON.stringify(runtimeManifest('schema-v3'))}\n`, 'utf8')
|
|
||||||
await writeFile(
|
|
||||||
path.join(manifestsDirectory, 'unknown.json'),
|
|
||||||
`${JSON.stringify({ schemaVersion: 1, kind: 'legacy-owner', outputs: [] })}\n`,
|
|
||||||
'utf8',
|
|
||||||
)
|
|
||||||
|
|
||||||
await assert.rejects(
|
|
||||||
() => validateRuntimeAssetRegistry(root, workspace, manifestsDirectory),
|
|
||||||
/undeclared legacy manifest/i,
|
|
||||||
)
|
|
||||||
})
|
|
||||||
@@ -1,168 +0,0 @@
|
|||||||
# 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)。
|
|
||||||
@@ -1,142 +0,0 @@
|
|||||||
# 后端开发对接任务单
|
|
||||||
|
|
||||||
> 本文件是 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 的唯一契约,不应在前端增加第二套字段读取逻辑。
|
|
||||||
@@ -1,145 +0,0 @@
|
|||||||
# 剩余九项后端与产品任务及参考项目证据
|
|
||||||
|
|
||||||
日期: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. 换绑重新认证及旧令牌失效的安全测试结果。
|
|
||||||
@@ -1,153 +0,0 @@
|
|||||||
# 当前项目与参考项目点击对比审查
|
|
||||||
|
|
||||||
审查日期: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 已补测完成。
|
|
||||||
@@ -1,54 +0,0 @@
|
|||||||
# 第五轮真机点击回归记录
|
|
||||||
|
|
||||||
- 测试日期:2026-08-23
|
|
||||||
- 测试端:MuMu 模拟器,`io.dcloud.HBuilder`
|
|
||||||
- 测试账号:已登录测试账号
|
|
||||||
- 测试家谱:`MANUALTEST20260809`
|
|
||||||
- 原则:仅执行可逆点击和本地表单校验;未发送验证码、未提交申请、未新增内容、未执行删除。
|
|
||||||
|
|
||||||
## 结论
|
|
||||||
|
|
||||||
本轮覆盖 13 个前端差异项。6 项已通过真机点击验证,1 项部分通过,6 项因后端没有可验证数据或基础接口失败而受阻。本轮点击中发现的 2 个前端问题均已修正,并在重新编译后通过最终真机复验。
|
|
||||||
|
|
||||||
## 逐项结果
|
|
||||||
|
|
||||||
| 序号 | 验证项 | 状态 | 点击结果与证据 | 后续处理 |
|
|
||||||
| --- | --- | --- | --- | --- |
|
|
||||||
| 1 | 家谱设置与永久删除能力解耦 | 受阻 | 从家谱总览可进入设置,但基础设置接口直接进入“家谱设置暂时无法读取”。见 [03-settings.png](./audit-2026-08-23-round-5/03-settings.png)。 | 先确认家谱设置基础接口的失败原因;本轮无法进入表单验证删除能力是否独立加载。 |
|
|
||||||
| 2 | 宣传视频封面点击播放 | 受阻 | “宣传视频”入口和列表页可进入,但后端返回“暂时没有可观看的平台视频”。见 [06-video-list.png](./audit-2026-08-23-round-5/06-video-list.png)。 | 后端准备至少一条带视频地址和封面地址的可见样本后复测。 |
|
|
||||||
| 3 | 礼仪活动封面 | 受阻 | 礼仪活动入口可进入,当前家谱没有礼仪活动。见 [21-ceremony.png](./audit-2026-08-23-round-5/21-ceremony.png)。 | 准备一条带封面的礼仪活动样本后复测列表卡片。 |
|
|
||||||
| 4 | 谱文封面 | 受阻 | 谱文入口可进入,当前家谱没有谱文。见 [20-articles.png](./audit-2026-08-23-round-5/20-articles.png)。 | 准备一条带封面的谱文样本后复测列表卡片。 |
|
|
||||||
| 5 | 家谱重要证件汇总入口 | 通过 | 家谱总览“重要证件”可进入新汇总页,并自动打开“家谱重要证件”列表;当前结果为空。见 [04-documents.png](./audit-2026-08-23-round-5/04-documents.png)。 | 无前端阻塞;有数据后补充内容态检查。 |
|
|
||||||
| 6 | VIP 订单字段 | 部分通过 | 订单卡已显示套餐、金额、订单号和状态。见 [17-vip-orders.png](./audit-2026-08-23-round-5/17-vip-orders.png)。当前样本未返回可展示的支付方式、支付时间等扩展字段。 | 后端确认订单详情是否提供扩展字段;有值后复测。 |
|
|
||||||
| 7 | 提现记录字段 | 受阻 | 收益页可进入,但收益明细和提现记录均为空,提现能力当前未开放。见 [18-earnings.png](./audit-2026-08-23-round-5/18-earnings.png)。 | 后端提供至少一条提现记录样本后复测金额、状态、账户和时间。 |
|
|
||||||
| 8 | 亲属记录首张缩略图 | 受阻 | 人物基础资料显示配偶 `MANUALSPOUSE01`,但“亲属关系”区域返回“尚未记录可查看的亲属”。见 [24-person-actions.png](./audit-2026-08-23-round-5/24-person-actions.png)。 | 后端确认人物亲属列表接口为何未返回已存在的配偶关系,并准备带图片的亲属记录。 |
|
|
||||||
| 9 | 成长记录表单脏状态 | 通过 | 新建成长记录填写标题后返回,正确弹出“放弃成长记录”确认框,未提交内容不会静默丢失。见 [26-growth-discard.png](./audit-2026-08-23-round-5/26-growth-discard.png)。 | 无。 |
|
|
||||||
| 10 | 公开家谱申请加入导航 | 通过 | “搜索家谱 → 申请加入”可进入表单,重新编译后家谱名称已正确显示为“彭氏家谱”,不再出现 URL 编码串。见 [28-join-name-retest.png](./audit-2026-08-23-round-5/28-join-name-retest.png)。 | 无。 |
|
|
||||||
| 11 | 消息中心来源感知返回 | 通过 | 从“家谱”页铃铛进入消息中心,底部仍高亮“家谱”,空状态提供“返回上一页”。见 [12-message-from-genealogy.png](./audit-2026-08-23-round-5/12-message-from-genealogy.png)。 | 无。 |
|
|
||||||
| 12 | 换绑手机号校验文案 | 通过 | 输入新手机号但未获取验证码时,显示“请先获取新手机号的验证码”,错误位置和语义正确。见 [14-phone-validation.png](./audit-2026-08-23-round-5/14-phone-validation.png)。 | 无。 |
|
|
||||||
| 13 | 密码显示按钮可访问性 | 通过 | 三个显隐控件均被 Android 无障碍树识别为 `ToggleButton`,具备可读名称、`checkable=true`、`clickable=true` 和 `focusable=true`。点击当前密码显隐控件后,名称切换为“隐藏当前密码”,`checked` 同步变为 `true`。见 [29-password-a11y-retest.png](./audit-2026-08-23-round-5/29-password-a11y-retest.png)。 | 无。 |
|
|
||||||
|
|
||||||
## 本轮新增前端修正
|
|
||||||
|
|
||||||
1. 申请加入页对 `genealogyName` 查询参数执行一次安全解码,避免显示 URL 编码串。
|
|
||||||
2. 修改密码页的三个密码显隐控件补充显式 `button` 角色和 `tabindex="0"`,保留动态 `aria-label` 与 `aria-pressed`。
|
|
||||||
|
|
||||||
## 代码验证
|
|
||||||
|
|
||||||
执行 `npm.cmd run check`,结果通过:
|
|
||||||
|
|
||||||
- 项目检查:60 个页面、60 个路由、173 个源码文件;
|
|
||||||
- 审计回归检查、导航恢复检查、前端对齐检查均通过;
|
|
||||||
- 设计资产测试 12 项全部通过;
|
|
||||||
- 运行时资产清单验证通过,4 个清单、98 个资产。
|
|
||||||
- 使用 HBuilderX 内置 Vue 编译器单独编译本轮修改的两个 SFC 模板,均通过。
|
|
||||||
|
|
||||||
## 仍需后端提供的复测条件
|
|
||||||
|
|
||||||
1. 可正常读取的家谱设置基础数据。
|
|
||||||
2. 至少一条带封面的平台视频和可播放视频地址。
|
|
||||||
3. 至少一条带封面的礼仪活动。
|
|
||||||
4. 至少一条带封面的谱文。
|
|
||||||
5. 至少一条包含完整扩展字段的 VIP 订单。
|
|
||||||
6. 至少一条提现记录。
|
|
||||||
7. 可被人物详情接口返回、且带图片的亲属记录。
|
|
||||||
@@ -1,154 +0,0 @@
|
|||||||
# 当前项目与参考项目第四轮安全及操作流程复审
|
|
||||||
|
|
||||||
审查日期:2026-08-23
|
|
||||||
参考项目:`C:\Users\Rain\Desktop\job\Jiapu-App`(浏览器,已登录)
|
|
||||||
当前项目:`C:\Users\Rain\Desktop\job\jiapuapp`(MuMu,已登录)
|
|
||||||
后端项目:`C:\Users\Rain\Desktop\job\Genealogy`
|
|
||||||
|
|
||||||
## 结论
|
|
||||||
|
|
||||||
第四轮聚焦前三轮容易遗漏的安全分支:修改密码、换绑手机号、密码找回、未保存返回、账号注销、验证码归属、会话失效和动态错误提示。
|
|
||||||
|
|
||||||
- 用户记忆中的“需要输入密码”不仅存在于内容查看,也存在于参考项目的更换手机号流程:参考项目要求登录密码和新手机号。
|
|
||||||
- 当前项目换绑手机号只验证新手机号收到的短信码;行为验证码只证明操作像真人,不证明操作者仍掌握旧密码或旧手机号。后端最新接口也只有 `phone + smsCode`,这是已确认的安全契约差异,不是页面漏放一个输入框。
|
|
||||||
- 当前后端修改密码和换绑手机号成功后均未发现注销或踢除既有会话的逻辑;前端也继续保持登录。旧会话是否仍有效不能依赖界面推断,但当前项目全局搜索没有发现对应失效钩子,应按安全缺口处理。
|
|
||||||
- 新增确认 4 项:换绑缺少当前身份二次验证、改密/换绑后既有会话未失效、验证码归属提示写成“当前手机号”、密码切换与动态错误的无障碍关联不足。
|
|
||||||
- 前三轮的 14 项参考差异和 4 项当前流程缺陷继续成立。加上本轮 4 项,当前未决合计为 14 项参考差异和 8 项当前流程/安全/无障碍缺陷,共 22 项。
|
|
||||||
- 本轮没有发送短信、修改密码、换绑手机号、注销账号或写入业务数据。
|
|
||||||
|
|
||||||
## 编号流程与健康度
|
|
||||||
|
|
||||||
| 步骤 | 操作流程 | 健康度 | 第四轮结论 |
|
|
||||||
| --- | --- | --- | --- |
|
|
||||||
| 1 | 当前家谱切换 | 健康 | 从 MANUALTEST 切换到“真机联调10159371”后,首页立即更新;进入家族页再返回,选择仍保持 |
|
|
||||||
| 2 | 我的页面进入账户安全 | 健康 | 账户与安全入口清楚,手机号脱敏、账号编号、修改密码、换绑手机和微信绑定集中展示 |
|
|
||||||
| 3 | 修改密码初始页 | 健康 | 当前密码、新密码、确认密码齐全;明确要求 8–32 位且包含字母和数字,比参考项目提示更完整 |
|
|
||||||
| 4 | 修改密码空提交 | 基本健康 | 三项错误均就近显示,没有发起请求;但错误没有 `role=alert/aria-live`,也未通过 `aria-describedby` 关联输入框 |
|
|
||||||
| 5 | 修改密码显示/隐藏 | 有无障碍风险 | 使用可点击 `view role=button`,没有 `aria-pressed`,Android 结构中不可聚焦;触摸可用但键盘/辅助技术语义不完整 |
|
|
||||||
| 6 | 换绑手机号初始页 | 不健康 | 只有新手机号和新手机号短信码;缺少旧密码、旧手机号验证码或近期重新认证 |
|
|
||||||
| 7 | 新手机号已填但未取码后提交 | 有瑕疵 | 页面提示“请先获取当前手机号的验证码”,实际短信发送目标是新手机号,文案与契约相反 |
|
|
||||||
| 8 | 换绑表单填写后返回 | 健康 | 会弹出放弃确认,继续填写和确认放弃两个出口清楚;本轮确认放弃后未保留测试号码 |
|
|
||||||
| 9 | 关于与设置、退出和账号注销 | 基本健康 | 当前项目比参考项目多一层入口,但注销具有短信验证、再次确认和不可恢复说明,明显强于参考项目的一次确认 |
|
|
||||||
| 10 | 参考项目修改密码 | 基本健康 | 旧密码、新密码、重复新密码齐全,但没有展示密码强度规则 |
|
|
||||||
| 11 | 参考项目更换手机号 | 安全基线较明确 | 明确要求登录密码和新手机号;未展示短信验证,整体安全性仍需后端保证 |
|
|
||||||
| 12 | 参考项目谱文查看密码 | 可用 | 点击谱文先输入查看密码,可进入忘记密码流程 |
|
|
||||||
| 13 | 参考项目内容密码找回 | 可用但较弱 | 回答任意一组自定义密保即可重设;当前项目改用实名手机号短信找回,身份凭据不同 |
|
|
||||||
|
|
||||||
## 新增发现
|
|
||||||
|
|
||||||
### 1. P0:换绑手机号缺少当前身份二次验证
|
|
||||||
|
|
||||||
参考项目要求“登录密码 + 新手机号”;当前页面只要求“新手机号 + 发给新手机号的短信验证码”。当前后端 `AppPhoneChangeBody` 也只有 `phone` 和 `smsCode`,服务端先校验新手机号短信票据,再直接更新用户手机号和密码登录标识。
|
|
||||||
|
|
||||||
风险场景是登录态或设备被他人临时取得后,对方只需掌握自己的新手机号,就可以把账号登录标识换到该号码。行为验证码只能降低自动化滥用,不能替代旧密码、旧手机号验证码或近期重新登录。
|
|
||||||
|
|
||||||
建议由后端先确定唯一安全契约,至少选择一种:
|
|
||||||
|
|
||||||
1. 旧密码验证后,再验证新手机号短信;
|
|
||||||
2. 旧手机号短信验证后,再验证新手机号短信;
|
|
||||||
3. 接受近期完成的重新认证票据,再验证新手机号短信。
|
|
||||||
|
|
||||||
前端应分步骤明确显示“验证当前身份”和“验证新手机号”,不能把两个目标写成一个模糊的“安全验证”。
|
|
||||||
|
|
||||||
| 参考项目 | 当前项目 |
|
|
||||||
| --- | --- |
|
|
||||||
|  |  |
|
|
||||||
|
|
||||||
### 2. P1:修改密码、换绑手机号后既有会话没有明确失效
|
|
||||||
|
|
||||||
后端 `changePassword` 更新凭据后直接返回;`changePhone` 更新手机号和密码登录标识后直接返回。全后端项目只发现账号注销、显式退出和其他独立流程调用 `StpUtil.logout()`,没有发现改密/换绑后的会话踢除或安全版本号失效机制。前端成功后也只显示提示,继续保留当前登录态。
|
|
||||||
|
|
||||||
完成条件:
|
|
||||||
|
|
||||||
- 明确当前会话是否保留;
|
|
||||||
- 至少让其他设备和旧令牌失效;
|
|
||||||
- 若无法区分当前与其他会话,成功后统一退出并要求使用新凭据重新登录;
|
|
||||||
- 增加“旧令牌访问受保护接口必须失败”的后端集成测试。
|
|
||||||
|
|
||||||
### 3. P2:换绑验证码提示把“新手机号”写成“当前手机号”
|
|
||||||
|
|
||||||
输入合法新手机号但未获取验证码,点击提交后显示“请先获取当前手机号的验证码”。代码实际把输入的新手机号作为短信接收号码,后端测试名称也明确为 `changePhoneVerifiesNewPhoneTicket...`。
|
|
||||||
|
|
||||||
这会直接影响用户判断短信应该去哪里查收。应改为“请先获取新手机号的验证码”,并让标题、说明、字段错误、短信反馈统一使用“当前手机号/新手机号”两个固定术语。
|
|
||||||
|
|
||||||

|
|
||||||
|
|
||||||
### 4. P2:密码页的动态错误和显示开关缺少完整无障碍语义
|
|
||||||
|
|
||||||
从截图可确认错误是就近显示的,这是优点;从实现和 Android 无障碍结构可确认:
|
|
||||||
|
|
||||||
- 动态错误文本没有 `role="alert"` 或 `aria-live`;
|
|
||||||
- 输入框没有 `aria-describedby` 指向对应错误;
|
|
||||||
- 显示/隐藏密码使用 `view role="button"`,没有 `aria-pressed`,Android 结构中不可聚焦;
|
|
||||||
- 换绑手机号的字段错误存在相同的动态播报与关联问题。
|
|
||||||
|
|
||||||

|
|
||||||
|
|
||||||
截图不能证明读屏器实际播报结果,完成前仍需 TalkBack、外接键盘和焦点顺序真机测试。
|
|
||||||
|
|
||||||
## 已确认健康或优于参考项目的部分
|
|
||||||
|
|
||||||
- 修改密码明确展示强度规则,并校验新旧密码不能相同、两次新密码必须一致。
|
|
||||||
- 修改密码和换绑手机号都具备未保存返回保护。
|
|
||||||
- 当前账号注销不是参考项目的一次确认后立即清理本地登录,而是先提示不可恢复、验证当前绑定手机号短信,再次确认后提交。
|
|
||||||
- 当前内容密码支持设置、修改、关闭和短信找回;参考项目内容密码找回依赖用户自定义密保问题。
|
|
||||||
- 当前账户安全页对手机号做了脱敏展示。
|
|
||||||
|
|
||||||

|
|
||||||
|
|
||||||
## 密码相关操作的最终判定
|
|
||||||
|
|
||||||
| 密码场景 | 是否缺失 | 第四轮判定 |
|
|
||||||
| --- | --- | --- |
|
|
||||||
| 登录密码 | 否 | 当前支持密码登录、修改和短信找回 |
|
|
||||||
| 谱文查看密码 | 否 | 当前按单篇谱文可选设置,参考项目创建时强制设置 |
|
|
||||||
| 重要证件查看密码 | 否 | 当前按单份证件可选设置,参考项目创建时强制设置 |
|
|
||||||
| 成长记录查看密码 | 否 | 当前按单条记录可选设置,参考项目创建时强制设置 |
|
|
||||||
| 内容密码找回 | 否 | 当前实名手机号短信,参考项目密保问题 |
|
|
||||||
| 换绑手机号前输入当前密码 | 是 | 参考项目有;当前前端与后端契约均没有,应作为安全任务处理 |
|
|
||||||
| 安全操作后会话失效 | 未闭环 | 当前代码没有发现改密/换绑后的旧会话失效机制 |
|
|
||||||
|
|
||||||
参考项目内容密码证据:
|
|
||||||
|
|
||||||
| 查看密码 | 忘记密码 |
|
|
||||||
| --- | --- |
|
|
||||||
|  |  |
|
|
||||||
|
|
||||||
## 本轮完整截图顺序
|
|
||||||
|
|
||||||
| 步骤 1:切换前 | 步骤 2:切换后 | 步骤 3:进入家族页 |
|
|
||||||
| --- | --- | --- |
|
|
||||||
|  |  |  |
|
|
||||||
|
|
||||||
| 步骤 4:返回仍保持 | 步骤 5:我的 | 步骤 6:账户安全 |
|
|
||||||
| --- | --- | --- |
|
|
||||||
|  |  |  |
|
|
||||||
|
|
||||||
| 步骤 7:修改密码 | 步骤 8:字段校验 | 步骤 9:换绑手机号 |
|
|
||||||
| --- | --- | --- |
|
|
||||||
|  |  |  |
|
|
||||||
|
|
||||||
| 步骤 10:验证码归属错误 | 步骤 11:放弃保护 | 步骤 12:个人中心下半段 |
|
|
||||||
| --- | --- | --- |
|
|
||||||
|  |  |  |
|
|
||||||
|
|
||||||
| 步骤 13:关于与设置 | 步骤 14:注销警告 | 步骤 15:参考项目我的 |
|
|
||||||
| --- | --- | --- |
|
|
||||||
|  |  |  |
|
|
||||||
|
|
||||||
| 步骤 16:参考查看密码 | 步骤 17:参考密码找回 | 步骤 18:参考修改密码 |
|
|
||||||
| --- | --- | --- |
|
|
||||||
|  |  |  |
|
|
||||||
|
|
||||||
| 步骤 19:参考更换手机号 |
|
|
||||||
| --- |
|
|
||||||
|  |
|
|
||||||
|
|
||||||
## 验证范围与限制
|
|
||||||
|
|
||||||
- 当前项目实际点击:家谱切换、家族页往返、我的、账户与安全、修改密码、空提交校验、换绑手机号、合法手机号未取码校验、填写后返回、放弃确认、关于与设置、账号注销第一层确认。
|
|
||||||
- 参考项目实际进入:谱文查看密码、内容密码找回、修改密码、更换手机号、我的页面。
|
|
||||||
- 输入的 `13800138000` 只用于前端本地校验;没有点击获取验证码,随后通过放弃确认清空。
|
|
||||||
- 没有提交密码、短信、手机号、注销、支付、提现、审批或其他写操作。
|
|
||||||
- 没有受保护的当前项目真实内容样本,因此本轮仍未执行当前内容密码错误、成功、找回和 15 分钟授权过期的端到端测试。
|
|
||||||
- 没有执行 TalkBack、外接键盘或多设备旧令牌访问测试;无障碍和会话失效结论分别来自截图加结构检查、前后端代码检查,仍需专项真机/集成测试收口。
|
|
||||||
@@ -1,309 +0,0 @@
|
|||||||
# 当前前端与参考前端完整对比总表
|
|
||||||
|
|
||||||
更新时间:2026-08-22
|
|
||||||
|
|
||||||
## 核对范围与判定方法
|
|
||||||
|
|
||||||
- 参考项目:`C:\Users\Rain\Desktop\job\Jiapu-App`
|
|
||||||
- 当前项目:`C:\Users\Rain\Desktop\job\jiapuapp`
|
|
||||||
- 比对对象以两个前端项目为主;需要后端数据才能完成的显示和播放会单独标出,不能把“有页面”写成“功能已可用”。
|
|
||||||
- 参考项目 `pages.json` 有 78 条活动路由。每条路由同时按 `.vue` 和 `.nvue` 解析,不能只检查 `.vue`。
|
|
||||||
- 当前项目有 59 条活动路由。多个参考页面合并到一个当前页面或组件时,只要入口、操作、字段和状态完整,判定为“合并覆盖”,不要求文件数量相同。
|
|
||||||
- 静态空壳、无入口演示页和未实现按钮会明确标记,不能当成当前项目必须复制的正式功能。
|
|
||||||
|
|
||||||
> 2026-08-22 第二轮按入口和点击链路复核。此前的 78/78 只表示参考路由已逐项登记,不代表每个入口都完成过真机点击。此次发现“宣传视频”虽有页面映射,但首页没有封面预览且 `G01 → F11` 被路由白名单拦截,原结论不准确,现已修正并加入回归检查。
|
|
||||||
|
|
||||||
> 2026-08-22 第三轮严格复核:再次逐项检查 78 条参考路由的模板、点击事件、表单字段、图片/视频显示和当前后端 DTO。此前把“前端已写媒体控件”直接判为“功能覆盖”的结论仍不严谨。功德图片实际上会被当前后端丢弃;谱文多图、礼仪封面显示、家族视频封面优先列表及多个列表的缩略图/批量操作也未完全对齐。下方“严格复核后的真实差异”是当前最终结论,优先于旧的“本轮补齐”措辞。
|
|
||||||
|
|
||||||
> 2026-08-22 第四轮融合复核:不再把“能从别处操作”直接等同于完整覆盖,而是继续核对聚合入口、字段是否真正显示、同一记录的生命周期信息是否完整。新增确认:家谱级重要证件总入口缺失;VIP 订单号及支付/到期时间未完整展示;提现单号、处理时间和审核/打款信息未展示;推广中心缺参考项目真实可用的注册链接二维码和复制链接。家谱编号、首页姓氏位置和参考项目更严格的必填规则属于产品取舍,单列待确认,不直接开发。
|
|
||||||
|
|
||||||
> 2026-08-22 第五轮字段消费复核:逐项区分“业务发生时间”和“记录创建时间”。参考相册、礼仪、功德、成长记录、贺礼簿、家族恩人列表均显示 `create_time`;当前除相册外显示的是 `ceremonyTime/meritTime/recordDate/eventTime/remindTime` 等业务时间,不能视为同一个字段。最新后端五个已确认映射 VO 及相近的 `AppMemoVo` 均未返回 `createTime`,因此这不是只改模板即可完成的前端项。家族视频使用 `publishTime`、家族圈直接返回 `createTime`,已有等价字段,不重复列入。
|
|
||||||
|
|
||||||
状态说明:
|
|
||||||
|
|
||||||
- **覆盖**:当前项目有等价或更完整的可执行路径。
|
|
||||||
- **合并覆盖**:能力存在,但被合并到当前页面、弹窗或组件。
|
|
||||||
- **升级替代**:参考做法存在安全、契约或产品问题,当前使用更可靠的流程完成同一用户目标。
|
|
||||||
- **非产品页**:空壳、静态原型、插件演示或没有业务入口,不迁移。
|
|
||||||
- **本轮补齐**:静态比对发现过差距,本轮已补入当前前端并进入后端联调清单。
|
|
||||||
|
|
||||||
## 严格复核后的真实差异
|
|
||||||
|
|
||||||
| 优先级 | 模块/参考路由 | 参考项目实际行为 | 当前项目实际行为 | 责任与完成条件 |
|
|
||||||
| --- | --- | --- | --- | --- |
|
|
||||||
| P0 | 家族视频 `video/index`、`video2.nvue` | 列表先显示 `item.imgs` 封面,点击后进入播放器/纵向播放 | `platform-videos.vue` 已是封面优先,但 `videos.vue` 的家族视频列表仍直接渲染 `<video controls>`;封面不能作为主要点击入口 | **前端**:家族视频卡片改为有封面先显示图片和播放标识,点击进入纵向播放器;无封面再使用视频占位或首帧 |
|
|
||||||
| P0 | 功德录 `meritsVirtues/index/add/details` | 列表显示第一张图,新增可传多图,详情显示全部图片 | 前端表单和详情虽使用 `mediaOssIds/mediaFiles`,但最新后端 `AppMeritRecordBody/Vo` 没有媒体字段;MuMu 实测创建成功后详情无图,列表也没有缩略图 | **前后端**:后端增加媒体请求/响应和文件引用;前端补列表首图。以新增、编辑保留、移除、列表首图、详情预览全部实测通过为完成 |
|
|
||||||
| P0 | 家谱设置 `genealogyList/index` | 谱主可进入资料维护和删除操作 | 当前设置页把基础详情和永久删除资格放进同一个 `Promise.all`;后端资格接口对未归档家谱抛“家谱必须先归档”,导致整个设置页失败 | **前后端**:前端将删除资格作为非关键独立状态,后端拆分 owner 鉴权与 archived 资格;普通谱主必须可读、可改基础设置 |
|
|
||||||
| P1 | 创建家谱 `createGenealogy` | `first_ancestor_name` 随创建提交 | 当前表单已有 `firstAncestorName`,最新后端 `AppGenealogyCreateBody` 无该字段,也不会在事务内建立首位人物 | **后端**:增加字段并原子创建第一代人物;失败时家谱和人物一起回滚 |
|
|
||||||
| P1 | 谱文 `puwen/add/genealogy` | 新增维护 `imgs` 数组,详情逐张显示 | 当前仅上传单个 `coverOssId`;列表和详情均未渲染 `coverFile`,后端也只有单封面契约 | **产品+前后端**:先决定“多图正文”还是“单封面”。至少应由前端显示现有封面;若保持参考多图则扩展后端媒体契约 |
|
|
||||||
| P1 | 礼仪 `gift/index/details/add` | 列表显示封面,详情显示封面,新建上传封面 | 当前编辑页可上传且后端返回 `coverFile`,但列表和详情未渲染图片 | **前端**:列表增加封面缩略图,详情显示可预览封面 |
|
|
||||||
| P1 | 重要证件 `document/index` | 从家谱总览直接进入全家谱证件列表,再选择查看或维护 | 当前只在单个人物详情内打开证件弹窗,没有家谱级总入口和跨人物列表;现有接口已允许不传 `lineagePersonId` 查询 | **前端**:增加家谱级重要证件入口/列表,复用现有证件弹窗与安全访问能力;无需新增后端接口 |
|
|
||||||
| P1 | VIP 订单 `mine/vip_success` | 每条显示订单号、套餐、状态、支付时间、到期时间 | 当前订单列表显示套餐、金额、状态,并把支付/到期时间二选一显示;契约还丢弃后端已有 `orderNo` | **前端**:保留 `orderNo`,分别显示订单号、支付时间和到期时间 |
|
|
||||||
| P1 | 提现记录 `mine/tixian_log` | 每条显示提现单号、金额、状态、申请时间、审核时间和备注 | 当前显示金额、申请时间、状态、收款人和失败原因;契约已有 `withdrawalNo/auditRemark/payoutReference/paidAt` 但页面未展示 | **前端**:显示提现单号,并按状态展示审核备注、打款参考号和到账时间;后端没有通用审核时间字段,不能把 `paidAt` 冒充审核时间 |
|
|
||||||
| P1 | 推广邀请码 `mine/fenxiang` | 根据注册链接生成二维码,并可复制注册链接给朋友;“下载 App”代码实际已注释 | 当前可复制推荐码和系统分享推荐码文字,但没有二维码或可复制注册链接;最新 `ReferralMeVo` 也没有 `shareUrl` | **前后端**:后端返回可信、可配置的注册链接(携带推荐码或服务端短链),前端再生成二维码、复制和分享;不能在前端重新硬编码旧域名 |
|
|
||||||
| P1 | 谱文/礼仪/视频编辑时移除封面 | 参考上传组件可删除已有图片,礼仪和视频表单也有明确移除按钮 | 当前三处只能上传或替换封面,没有移除按钮;前端请求规范化会省略 `null/''`,OpenAPI 也未声明封面可置空 | **前后端契约+前端**:更新契约明确 `coverOssId: null` 表示解除引用,规范化保留显式空值,编辑器增加移除并验证旧文件引用释放 |
|
|
||||||
| P1 | 相册/礼仪/功德/成长记录/贺礼簿/家族恩人列表创建时间 | 六类参考列表均显示记录的 `create_time` | 当前相册列表没有时间;其余页面显示活动/功德/记录/事件/提醒等业务时间,但它们不等于创建时间;五个已确认映射 VO 也没有 `createTime` | **后端+前端**:五类现有 VO/OpenAPI 先增加只读 `createTime`;家族恩人由选定契约持有;现有业务时间继续保留,不能互相冒充 |
|
|
||||||
| P1 | 贺礼簿 `favor/index/details` | 列表显示 `imgs[0]`,详情显示全部图片 | 当前“往来记录”详情可预览 `mediaFiles`,列表只有文字 | **前端**:列表补第一张图片缩略图 |
|
|
||||||
| P1 | 家族恩人 `memorandum/index/details` | 独立入口、列表和详情显示恩人记录、`imgs[0]` 与全部图片 | MuMu 实点确认当前只有通用“家族备忘”,表单是提醒语义,没有恩人身份/分类 | **确认缺口**:产品选择复用 Memo 类型或独立档案;前端补入口、文案、首图和创建时间,后端按选定单一契约落库 |
|
|
||||||
| P1 | 谱文、证件、视频、功德、礼仪、成长日志、贺礼簿、家族恩人列表 | 多数页面有“管理→勾选→批量删除” | 当前相册照片支持批量删除,其余大多只有逐条删除 | **产品决策**:若要求操作一致,先定义回收站下的批量部分成功语义,再由前端补选择态;不应仅因文件已合并就标“完整覆盖” |
|
|
||||||
| P1 | 首页 `index/index` | 搜索框、创建、加入、排序和两条宣传封面直接露出 | 当前创建/加入/排序主要收在弹窗或二级页,宣传区位于较长家谱列表之后;当前家谱还会在顶部和列表重复显示 | **前端产品体验**:能力存在,但点击层级和信息优先级不同;需按最终产品方向决定是否提升搜索、加入和媒体入口 |
|
|
||||||
| P1 | 宣传视频 `xcindex/video3/video4` | 有真实封面数据时点击播放 | 当前代码已完成封面优先和指定视频播放,但测试环境 `home_featured/video_center` 均为空 | **后端/运营+真机**:各配置至少一条有效视频和封面后,验证播放、滑动、暂停、返回、点赞和评论 |
|
|
||||||
| P2 | 注册 `login/login`、`login/register` | 注册时填写实名和性别 | 当前注册填写昵称,实名和性别移到个人资料 | **责任重分配,非缺失**:除非产品要求注册即实名,否则保留当前较短流程 |
|
|
||||||
| P2 | 加入、找回、删除、权限、视频来源 | 编号直接加入、安全问题找回、直接硬删除、硬编码 `auth_str`、任意视频 URL | 当前使用申请/一次性邀请码、短信找回、回收站/归档、动态权限目录、受控文件上传 | **升级替代,不能回退**:这些是安全和数据完整性改进,不按参考旧实现复制 |
|
|
||||||
| P2 | `render`、`invite`、`relationship/index`、`tree/add`、`ancestorsOrder`、`mine/share` | 日历演示、空页、静态原型或错误跳转 | 当前不提供对应独立页面,正式能力已在其他页面实现 | **非产品页**:不迁移;其中 `mine/share` 的“立即分享”实际跳创建家谱,不能当作分享功能 |
|
|
||||||
|
|
||||||
严格结论:78/78 表示参考路由没有漏登记;不表示当前功能已全部完成。差异必须继续经过“当前项目是否已在其他页面或升级流程中实现”的融合判定,不能直接等同于开发任务。
|
|
||||||
|
|
||||||
## 融合判定后的处理清单
|
|
||||||
|
|
||||||
| 判定 | 项目 | 当前能力核对 | 是否需要补 |
|
|
||||||
| --- | --- | --- | --- |
|
|
||||||
| 确认断链 | 家族视频封面式列表 | 家族视频已经支持封面上传、纵向播放器、点赞和评论,但列表仍直接铺播放器;只有平台视频列表实现了封面优先 | **需要前端补**,这是用户实际看到的交互缺口 |
|
|
||||||
| 确认断链 | 礼仪活动封面 | 编辑页和后端 `coverFile` 已存在,列表、详情都没有消费该字段 | **需要前端补**,属于已有契约未展示 |
|
|
||||||
| 确认断链 | 谱文单封面显示 | 编辑器和后端 `coverFile` 已存在,列表、详情没有显示 | **需要前端补**,属于已有契约未展示 |
|
|
||||||
| 确认断链 | 家谱设置 | 页面和全部设置功能都存在,但附加的永久删除资格请求失败会拖垮基础设置 | **前端需要隔离非关键失败,后端需要修资格接口** |
|
|
||||||
| 确认断链 | 家谱级重要证件入口 | 当前人物详情内的证件管理能力完整,但没有从家谱模块查看全部人物证件的入口;服务层和 OpenAPI 已支持不带人物条件查询 | **需要前端补**家谱级列表/入口,内部复用现有详情与编辑能力 |
|
|
||||||
| 确认断链 | VIP 订单关键信息 | 后端已有 `orderNo/payTime/expireTime`,当前契约丢弃订单号,页面只显示支付时间或到期时间其中一个 | **需要前端补**契约字段和三项独立展示 |
|
|
||||||
| 确认断链 | 提现记录处理信息 | 当前契约已保留提现单号、审核备注、打款参考号、到账时间,页面却没有显示 | **需要前端补**按状态展示;若产品必须显示“审核时间”,后端还需提供明确字段 |
|
|
||||||
| 确认断链 | 推广注册链接/二维码 | 当前推荐关系、推荐码和系统分享均可用,但只有文字推荐码;参考项目可扫码或复制注册链接 | **需要后端补 `shareUrl`,前端补二维码和复制链接**;旧项目硬编码域名不能直接迁移 |
|
|
||||||
| 确认断链 | 已有封面无法移除 | 谱文、礼仪、视频都能替换封面,但当前 UI 和前端严格契约不能表达“清空封面” | **需要统一契约后补前端**;不能只在本地把预览清空而不释放后端文件引用 |
|
|
||||||
| 确认断链 | 六类记录创建时间 | 参考相册、礼仪、功德、成长、贺礼簿、家族恩人列表显示创建时间;当前后端 APP VO 不返回该字段,现有页面展示的是缺失或不同语义的业务时间 | **需要后端补只读 `createTime`,前端再展示**;保留现有业务时间,不能拿它们代替创建时间 |
|
|
||||||
| 确认断链 | 功德图片 | 当前前端已有上传、编辑保留和详情预览,但最新后端请求/响应 DTO 没有媒体字段,MuMu 实测无法回显 | **需要后端补契约**;后端完成后前端再补列表首图并实测 |
|
|
||||||
| 确认断链 | 始迁祖 | 当前创建表单和前端严格契约已经有 `firstAncestorName`,最新后端创建 DTO 没有 | **只需要后端补**,前端无需重复开发 |
|
|
||||||
| 已融合 | 贺礼簿图片 | 当前“往来记录”创建/编辑支持上传,详情支持全部图片预览;仅列表没有参考项目的第一张缩略图 | 核心能力**不需要补**;列表首图需补 |
|
|
||||||
| 确认差异 | 家族恩人 | MuMu 实点确认“家族备忘”支持提醒和图片,但没有“家族恩人”名称、身份或类型 | 恩人业务没有被当前界面表达;产品只需选择复用 Memo 类型还是独立档案,不能继续判为已融合 |
|
|
||||||
| 部分融合 | 重要证件详情维护 | 当前按人物集中管理多份证件,支持解锁、查看、替换、添加和逐项删除 | 人物内维护无需重做;但家谱级聚合入口仍需补,不能整体判为已融合 |
|
|
||||||
| 已融合 | 多个列表的批量删除 | 当前都有逐条删除和回收站,相册照片另有完整批量选择与部分失败处理 | 用户目标已覆盖,**不自动补批量模式**;需产品明确后再做 |
|
|
||||||
| 已融合 | 首页搜索、创建、加入、排序 | 当前在“我的家谱”弹窗、搜索页、申请页和排序弹窗完成,且加入与排序规则更安全 | **不需要重复入口或重复页面**;只属于信息层级优化 |
|
|
||||||
| 已融合 | 注册实名/性别 | 当前注册保持短流程,实名和性别在个人资料维护 | **不需要补回注册页**,除非业务要求注册即实名 |
|
|
||||||
| 升级替代 | 加入、找回、删除、权限、视频来源 | 已分别升级为申请/一次性邀请码、短信找回、回收站/归档、动态权限目录、受控文件 | **不按参考旧实现补** |
|
|
||||||
| 能力已有,待数据 | 平台宣传视频 | 首页封面位、视频中心、指定视频纵向播放均已实现,测试环境列表为空 | **前端不需要补**;后端/运营投放数据后点击验收 |
|
|
||||||
| 产品待定 | 谱文多图 | 参考项目是 `imgs` 多图;当前产品模型是单个 `coverFile`,正文没有媒体数组 | 不直接判缺失;先确认产品是“单封面谱文”还是“正文多图谱文” |
|
|
||||||
| 产品待定 | 家谱编号与姓氏展示位置 | 参考总览直接显示家谱编号和姓氏;当前后端有 `genealogyNo`,但前端规范化时丢弃且自有家谱卡片不显示真实姓氏,姓氏只在搜索/设置可见 | 数据能力存在但展示目标不同;确认编号是否仍承担用户识别用途后再补,不能因参考项目有就自动恢复“编号加入” |
|
|
||||||
|
|
||||||
最终确认断链共 12 项。前端可独立处理的是家族视频列表、礼仪封面、谱文单封面、设置页失败隔离、家谱级重要证件入口、VIP 订单字段和提现记录字段;功德图片、始迁祖、推广注册链接、封面清空语义及六类记录创建时间需要后端契约配合。其余差异已融合、属于升级替代、需要运营数据或等待产品选择,不应重复开发。
|
|
||||||
|
|
||||||
## 逐路由映射表(78/78)
|
|
||||||
|
|
||||||
| 参考路由 | 参考功能 | 当前页面/组件 | 状态 | 核对结论 |
|
|
||||||
| --- | --- | --- | --- | --- |
|
|
||||||
| `pages/index/index` | 首页公开家谱搜索、创建、加入、排序、两条宣传视频封面 | `pages/genealogy/my-genealogies`、`search`、`platform-videos` | 本轮补齐 | 首页读取 `home_featured` 并显示至多两条封面;点封面直达指定视频,点“查看更多”进入视频中心;当前后端未配置投放数据,MuMu 只能验证空状态和入口 |
|
|
||||||
| `pages/login/login` | 密码登录、旧式找回、注册、协议、微信登录 | `pages/auth/sign-in`、`register`、`reset-password` | 升级替代 | 密码/短信/微信、协议均覆盖;实名生日找回改为短信验证 |
|
|
||||||
| `pages/render/render` | 六个阳历/农历日历插件演示按钮 | 无 | 非产品页 | 无任何业务页面入口,仅为组件演示,不迁移 |
|
|
||||||
| `pages/index/addGenealogy` | 输入家谱编号加入 | `pages/genealogy/search`、`join-application` | 升级替代 | 改为一次性邀请码预览/兑换或公开家谱申请,避免盲加入 |
|
|
||||||
| `pages/index/createGenealogy` | 姓氏、谱名、堂号、始迁祖、地区、简介、访问权限 | `pages/genealogy/create` | 本轮补齐 | “始迁祖”已加入创建表单和严格请求契约;等待后端事务内创建首位世系人物 |
|
|
||||||
| `pages/index/invite` | 仅标题栏 | `components/genealogy/InvitationManager` | 非产品页 | 参考页为空壳;当前反而已有生成、复制、记录和撤销 |
|
|
||||||
| `pages/index/genealogyList/index` | 家谱总览、编号、姓氏、模块入口、邀请、删除 | `pages/genealogy/overview`、`settings` | 部分合并 | 主要基础信息、邀请、归档/注销均有入口;缺家谱级重要证件入口,且总览未显示后端已有的家谱编号,是否恢复编号展示待产品确认 |
|
|
||||||
| `pages/index/genealogyList/characterTable` | 字辈列表、人数、编辑入口 | `pages/genealogy/generation-poems` | 覆盖 | 当前另有批量预览、停用、恢复和排序 |
|
|
||||||
| `pages/index/genealogyList/add` | 新增、修改、删除字辈 | `pages/genealogy/generation-poems` | 合并覆盖 | 在同页完成批量维护,删除改为停用 |
|
|
||||||
| `pages/index/puwen/classList` | 文献分类筛选、无效的管理图标 | `pages/family/articles` | 覆盖 | 分类筛选已覆盖;参考页管理按钮没有完整写接口 |
|
|
||||||
| `pages/index/puwen/index` | 谱文列表、管理、密码解锁 | `pages/family/articles`、`article-detail` | 合并覆盖 | 列表、筛选、解锁、删除均覆盖 |
|
|
||||||
| `pages/index/puwen/genealogy` | 谱文标题、正文、落款、时间、图片及编辑 | `pages/family/article-detail` | 部分覆盖 | 正文、作者和权限已覆盖,但当前详情不显示现有单封面,也不支持参考项目的多图正文 |
|
|
||||||
| `pages/index/puwen/add` | 标题、正文、落款、安全问题、查看密码、多图及移除图片 | `pages/family/article-editor`、`article-detail` | 部分升级 | 安全问题已升级为短信找回;当前仅支持一个 `coverOssId`,没有参考项目的 `imgs` 多图契约,已有封面也不能移除 |
|
|
||||||
| `pages/index/puwen/wjmm` | 安全问题找回谱文/证件/日志密码 | `components/ContentPasswordRecoveryDialog` | 升级替代 | 改为已验证手机号短信找回,覆盖三类资源 |
|
|
||||||
| `pages/index/album/index` | 相册列表、名称、描述、数量、时间、新建 | `pages/family/albums` | 部分覆盖 | 加载、空、错、创建、编辑、回收站状态完整;当前列表和 APP VO 缺参考项目显示的创建时间 |
|
|
||||||
| `pages/index/album/add` | 相册名称、描述、创建 | `pages/family/albums` | 合并覆盖 | 当前使用同页表单弹层完成 |
|
|
||||||
| `pages/index/album/details` | 图片预览、管理勾选 | `pages/family/album-detail` | 覆盖 | 当前支持全选、批量删除和部分失败保留选择 |
|
|
||||||
| `pages/index/album/edit` | 修改名称/描述、删除相册 | `pages/family/albums` | 合并覆盖 | 编辑和移至回收站在列表页完成 |
|
|
||||||
| `pages/index/document/index` | 家谱级重要证件列表、批量管理、密码解锁 | `components/tree/PersonDocumentDialog` | 部分合并 | 单人物证件、多资源、解锁、编辑和逐项删除已覆盖;缺家谱级入口及跨人物列表。现有接口支持无 `lineagePersonId` 查询,前端可直接补齐 |
|
|
||||||
| `pages/index/document/add` | 人物关系、文件、安全问题、查看密码 | `components/tree/PersonDocumentDialog` | 升级替代 | 人物归属、文件和密码覆盖;安全问题改为短信找回 |
|
|
||||||
| `pages/index/video/index` | 封面式家族视频列表、管理、全屏播放入口 | `pages/family/videos` | 部分覆盖 | 发布、编辑、回收站、播放、点赞和评论已覆盖;列表仍直接铺 `<video>`,未按参考项目先显示封面后点击播放,也没有批量管理 |
|
|
||||||
| `pages/index/video/add` | 标题、链接、描述、封面/视频上传及移除 | `pages/family/videos` | 部分升级 | 当前只接受受控文件上传,不信任任意外链;但已有封面只能替换不能移除 |
|
|
||||||
| `pages/index/video/xcindex` | 宣传视频封面列表和详情入口 | `pages/family/platform-videos` | 本轮补齐 | 列表改为有封面时先显示封面,点击后进入指定视频纵向播放;无封面时显示原生视频控件 |
|
|
||||||
| `pages/index/video/details` | 单视频 controls 播放 | `pages/family/videos`、`platform-videos` | 合并覆盖 | 播放器直接嵌入对应业务页,避免依赖全局 store URL |
|
|
||||||
| `pages/index/meritsVirtues/index` | 功德录列表首图、姓名、内容、时间、批量管理 | `pages/records/merit-records` | 部分覆盖 | 文字增删改查可用;列表无首图和批量管理,图片会被当前后端契约丢弃 |
|
|
||||||
| `pages/index/meritsVirtues/add` | 姓名、内容、图片 | `pages/records/merit-records` | 前端待后端 | 前端已写上传控件,但最新 `AppMeritRecordBody` 无 `mediaOssIds`,不能判为功能完成 |
|
|
||||||
| `pages/index/meritsVirtues/details` | 姓名、内容、图片、时间、编辑 | `pages/records/merit-records` | 前端待后端 | 前端已写图片展示,最新 `AppMeritRecordVo` 无 `mediaFiles`;MuMu 实测新增后详情无图 |
|
|
||||||
| `pages/index/gift/index` | 贺礼邀请分类、封面列表、批量管理 | `pages/records/ceremonies` | 部分升级 | 当前统一为活动礼仪并增加正式受邀人管理,但列表未显示已有封面且无批量管理 |
|
|
||||||
| `pages/index/gift/details` | 标题、类别、时间、地点、封面、备注 | `pages/records/ceremony-detail` | 部分覆盖 | 业务字段、礼簿和邀请状态已覆盖,但详情未显示后端已经返回的 `coverFile` |
|
|
||||||
| `pages/index/gift/add` | 类型、标题、时间、地点、封面、备注及移除封面 | `pages/records/ceremony-editor` | 部分覆盖 | 创建和编辑字段均覆盖,类型来自服务端字典;已有封面只能替换不能移除 |
|
|
||||||
| `pages/index/log/index` | 按人物进入成长日志 | `pages/records/growth-journal` | 合并覆盖 | 当前在同页选择人物和分类 |
|
|
||||||
| `pages/index/log/details` | 成长日志详情及编辑 | `components/records/GrowthRecordDetailDialog` | 合并覆盖 | 详情、密码解锁、密码管理和编辑完整 |
|
|
||||||
| `pages/index/log/add` | 人物、类型、内容、密码、图片/视频 | `pages/records/growth-journal` | 本轮补齐 | 已补视频选择、上传和详情播放;安全问题改短信找回 |
|
|
||||||
| `pages/index/favor/index` | 带首图的贺礼簿列表和批量管理 | `pages/records/relative-records` | 部分覆盖 | 当前升级为结构化往来记录,增删改查和详情图片已覆盖;列表未显示首图且无批量管理 |
|
|
||||||
| `pages/index/favor/details` | 标题、内容及编辑 | `pages/records/relative-records` | 合并覆盖 | 详情在当前列表页弹层展示 |
|
|
||||||
| `pages/index/favor/add` | 标题、内容 | `pages/records/relative-record-editor` | 覆盖 | 当前增加往来类型、对象、金额、日期等结构化字段 |
|
|
||||||
| `pages/index/memorandum/index` | 带首图的家族恩人列表和批量管理 | `pages/records/memos` | 部分覆盖 | 当前家族备忘具备相近的提醒、状态、增删改查和详情图片,但实点与代码均没有恩人名称/类型;确认存在语义缺口,具体存储方案待产品选择 |
|
|
||||||
| `pages/index/memorandum/add` | 标题、内容 | `pages/records/memos` | 合并覆盖 | 当前同页表单完成 |
|
|
||||||
| `pages/index/memorandum/details` | 标题、内容、时间及编辑 | `pages/records/memos` | 合并覆盖 | 当前同页详情/编辑完成 |
|
|
||||||
| `pages/index/admin/index` | 管理员列表、删除 | `pages/genealogy/members` | 覆盖 | 当前同时管理角色、人物绑定、退出、移除和转让谱主 |
|
|
||||||
| `pages/index/admin/add` | 勾选成员成为管理员 | `pages/genealogy/members` | 合并覆盖 | 当前在成员操作面板内修改角色 |
|
|
||||||
| `pages/index/admin/power` | `auth_str` 逐项权限 | `pages/genealogy/members` | 升级替代 | 改为服务端动态权限目录,不硬编码旧权限串 |
|
|
||||||
| `pages/index/familyCircle/index` | 动态、媒体、评论、回复、删除 | `pages/family/feed`、`feed-detail` | 覆盖 | 当前另有点赞、分页评论、回复、删除权限和失败重试 |
|
|
||||||
| `pages/index/familyCircle/add` | 内容、图片、发布 | `pages/family/feed-editor` | 覆盖 | 当前支持创建、编辑、媒体上传和草稿离开确认 |
|
|
||||||
| `pages/index/tree/index` | 表格世系、关系、状态、人物入口 | `pages/tree/overview`、`member-directory` | 合并覆盖 | 树、目录和人物状态拆分为清晰入口 |
|
|
||||||
| `pages/index/tree/tree` | 树谱、父母/配偶/兄弟/子女新增、编辑、删除、绑定 | `pages/tree/pedigree`、`add-relative`、`member-profile` | 覆盖 | 亲属新增、身份绑定、排序、编辑和停用均覆盖 |
|
|
||||||
| `pages/message/index` | 通知、加入审核、广告消息、邀请弹窗 | `pages/notification/message-center`、`message-detail` | 合并覆盖 | 通知目标可进入审核、动态、备忘和活动邀请;广告由推广组件处理 |
|
|
||||||
| `pages/message/details` | 通知详情 | `pages/notification/message-detail` | 覆盖 | 当前增加类型、家谱、发送人、关联事项和已读状态 |
|
|
||||||
| `pages/mine/index` | 个人资料、余额、VIP、帮助、设置、推广、退出 | `pages/profile/home`、`earnings`、`vip`、`settings` | 合并覆盖 | 入口全部覆盖并增加推荐偏好和合规文档 |
|
|
||||||
| `pages/mine/help` | 帮助分类和搜索框 | `pages/profile/help` | 覆盖 | 当前提供真实关键词过滤、加载、空和错误状态 |
|
|
||||||
| `pages/mine/setting` | 关于、联系、协议、退出 | `pages/profile/settings`、`compliance-document` | 合并覆盖 | 当前增加账号注销、版本化协议和服务端正文 |
|
|
||||||
| `pages/mine/password` | 旧密码、新密码、确认 | `pages/profile/change-password` | 升级替代 | 当前使用服务端验证策略,不把旧式表单当唯一安全边界 |
|
|
||||||
| `pages/mine/opinion` | 联系方式、反馈描述 | `pages/profile/feedback` | 覆盖 | 当前增加反馈历史、提交状态和失败提示 |
|
|
||||||
| `pages/mine/share` | 静态奖励图;“立即分享”错误跳创建家谱 | `pages/profile/promotions` | 非产品页 | 参考按钮没有分享实现,不复制错误跳转 |
|
|
||||||
| `pages/mine/vip_xf` | 套餐、协议、微信/支付宝/余额选择、支付 | `pages/profile/vip` | 覆盖 | 当前按 capability 动态展示渠道并严格校验支付结果 |
|
|
||||||
| `pages/mine/vip_success` | VIP 订单号、套餐、状态、支付/到期时间 | `pages/profile/vip` | 部分合并 | 当前已有订单记录、套餐、金额和状态;缺订单号,且支付时间与到期时间被合并为二选一显示 |
|
|
||||||
| `pages/mine/helpDetails` | 帮助正文 | `pages/profile/help`、`compliance-document` | 合并覆盖 | 普通帮助内联详情,协议使用独立版本化页面 |
|
|
||||||
| `pages/index/relationship/index` | 写死的“资料/亲属”静态原型 | `pages/tree/member-profile` | 非产品页 | 无真实数据或提交逻辑;当前人物详情已有真实亲属数据 |
|
|
||||||
| `pages/index/tree/personalData` | 人物基础、生卒、教育、职业资料 | `pages/tree/member-profile` | 覆盖 | 当前按服务端契约展示并保护敏感病史 |
|
|
||||||
| `pages/index/tree/add` | 大量未绑定输入框的静态人物原型 | `pages/tree/add-relative` | 非产品页 | 参考输入多数没有 v-model/提交实现;当前有真实表单 |
|
|
||||||
| `pages/mine/userInfo` | 个人/人物编辑及亲属新增的主实现 | `edit-profile`、`add-relative`、`edit-member` | 合并覆盖 | 账号资料和家谱人物资料按责任拆分,字段见下表 |
|
|
||||||
| `pages/index/log/selectUser` | 为日志或管理员选择成员 | 人物选择器、成员权限弹层 | 合并覆盖 | 当前从服务端候选项选择,不通过跨页临时存储回传 |
|
|
||||||
| `pages/index/genealogyList/ancestorsOrder` | 空模板 | `pages/tree/member-rank` | 非产品页 | 参考页仅 67 字节;当前已有真实同辈排序 |
|
|
||||||
| `pages/mine/helpList` | 帮助文章列表 | `pages/profile/help` | 合并覆盖 | 分类、列表、筛选和详情合并在一个页面 |
|
|
||||||
| `pages/content/detail` | 登录协议或普通文章正文 | `pages/profile/compliance-document` | 升级替代 | 当前按文档 key、版本和生效日期读取,不使用固定文章 ID |
|
|
||||||
| `pages/mine/fenxiang` | 注册链接二维码、复制注册链接;下载 App 入口已被注释 | `pages/profile/promotions` | 部分覆盖 | 推荐码、邀请人数和系统分享已覆盖;缺二维码和注册链接,当前后端仅返回推荐码及分享文案 |
|
|
||||||
| `pages/login/register` | 另一套短信注册页面 | `pages/auth/register` | 合并覆盖 | 删除重复注册入口,统一为一个严格契约页面 |
|
|
||||||
| `pages/mine/changemobile` | 密码验证后修改手机号 | `pages/profile/change-phone` | 升级替代 | 当前按服务端验证策略和短信码修改手机号 |
|
|
||||||
| `pages/index/sortGenealogy` | 手填排序序号 | `components/genealogy/OrderDialog` | 升级替代 | 改为上移/下移并一次保存稳定 ID 顺序 |
|
|
||||||
| `pages/index/log/list` | 指定人物和分类的成长日志、密码解锁 | `pages/records/growth-journal` | 合并覆盖 | 筛选、列表、详情、解锁均在同页完成 |
|
|
||||||
| `pages/index/log/class` | 成长日志分类 | `pages/records/growth-journal` | 合并覆盖 | 分类由服务端字典渲染,无需额外页面 |
|
|
||||||
| `pages/index/video/video2` | 家族视频 `.nvue` 上下滑动、自动播放、评论 | `pages/family/videos`、`components/family/VerticalVideoViewer` | 本轮补齐 | 已有纵向分页观看,滑走暂停、当前项播放,并保留点赞和评论入口 |
|
|
||||||
| `pages/index/video/video3` | 首页宣传视频 `.nvue` 上下滑动、自动播放 | `pages/family/platform-videos`、`components/family/VerticalVideoViewer` | 本轮补齐 | `G01 → F11` 路由已放行,首页封面可带 `videoId` 进入宣传视频纵向观看 |
|
|
||||||
| `pages/index/video/video4` | 指定宣传视频 `.nvue` 上下滑动、自动播放、评论 | `pages/family/platform-videos`、`components/family/VerticalVideoViewer` | 本轮补齐 | 页面加载后按 `videoId` 定位并打开指定内容,保留点赞和评论入口;需后端真实视频数据完成播放实测 |
|
|
||||||
| `pages/message/ad_detail` | 广告标题、图片、内容详情 | `components/AppPromotionStrip`、`pages/profile/promotions` | 升级替代 | 按推广目标类型安全打开,不复制独立旧广告页 |
|
|
||||||
| `pages/mine/withdrawal` | 余额、收益流水、申请提现、提现记录 | `pages/profile/earnings` | 覆盖 | 当前增加可用/冻结金额、最低金额、分页和取消申请 |
|
|
||||||
| `pages/mine/tixian` | 输入提现金额并提交 | `components/profile/EarningWithdrawalDialog` | 合并覆盖 | 当前增加收款方式、账户、姓名、幂等键和金额精度校验 |
|
|
||||||
| `pages/mine/tixian_log` | 提现单号、金额、状态、审核时间、备注 | `pages/profile/earnings` | 部分合并 | 当前已有金额、申请时间、状态、收款人和失败原因;契约已保留但页面未显示提现单号、审核备注、打款参考号和到账时间 |
|
|
||||||
|
|
||||||
## 旧轮补齐记录(严格复核结果以上文为准)
|
|
||||||
|
|
||||||
| 优先级 | 原差距 | 参考证据 | 本轮结果 |
|
|
||||||
| --- | --- | --- | --- |
|
|
||||||
| P0 | 创建家谱缺“始迁祖”字段 | `createGenealogy.vue` 的 `first_ancestor_name` | 创建表单增加“始迁祖”,写入 camelCase 严格契约并等待后端 |
|
|
||||||
| P1 | 宣传视频没有首页直接入口 | 参考首页直接进入宣传视频列表/全屏页 | “我的家谱”有谱和空状态都提供宣传视频入口 |
|
|
||||||
| P1 | 首页宣传视频只有文字行,没有参考项目的两条封面,且点击被路由来源限制拦截 | `pages/index/index.vue` 读取两条宣传视频封面,点击进入指定视频 | 首页接入 `home_featured` 封面预览;放行 `G01 → F11`;封面携带 `videoId` 直达播放器 |
|
|
||||||
| P1 | 宣传视频列表直接铺开全部播放器,没有“先看封面、点后播放”的层级 | `xcindex.vue` 先显示封面,点击进入 `video3/video4.nvue` | 有封面时先显示图片与播放标识,点击进入纵向播放器;缺封面才显示原生视频控件 |
|
|
||||||
| P1 | 家族/宣传视频缺上下滑动观看 | `video2.nvue`、`video3.nvue`、`video4.nvue` 均使用纵向分页列表 | 当前增加可复用的纵向视频浏览器;滑走暂停、当前项播放 |
|
|
||||||
| P1 | 成长日志缺视频上传和播放 | `log/add.vue` 的 `accept="video"` 与 `log/details.vue` 的 `video_urls` | 成长记录表单已支持图片/视频,详情按媒体类型显示图片或视频 |
|
|
||||||
| P1 | 功德录缺图片上传和详情展示 | `meritsVirtues/add.vue` 的 `imgs` 与详情图片列表 | 仅完成前端控件;最新后端 DTO 没有媒体字段,MuMu 实测图片未回显,仍未完成 |
|
|
||||||
|
|
||||||
## 主要用户路径逐步对比
|
|
||||||
|
|
||||||
| 用户目标 | 参考项目步骤 | 当前项目步骤 | 结论 |
|
|
||||||
| --- | --- | --- | --- |
|
|
||||||
| 登录/找回 | 登录页输入手机号和密码;找回时填写实名、生日等旧身份信息 | 登录页选择密码、短信或微信;忘记密码进入短信验证和重置 | 目标覆盖,找回方式升级 |
|
|
||||||
| 注册账号 | 手机号 → 验证码 → 密码/确认 → 真实姓名/性别 → 邀请码 → 勾选协议 | 手机号 → 验证码 → 密码/确认 → 昵称/推荐码 → 勾选协议;实名和性别在资料页补充 | 目标覆盖,账号字段责任重新划分 |
|
|
||||||
| 创建家谱 | 首页创建 → 姓氏/谱名/堂号/始迁祖/地区/简介/权限 → 提交 | 我的家谱 → 创建 → 同等字段及封面/加入模式 → 提交 → 返回家谱列表 | 前端完整;始迁祖等待后端原子建人 |
|
|
||||||
| 查找并加入家谱 | 首页搜索或输入家谱编号 → 直接加入 | 搜索公开家谱 → 查看状态 → 提交申请,或输入一次性邀请码 → 预览 → 兑换 | 目标覆盖,加入权限更严格 |
|
|
||||||
| 管理家谱 | 进入家谱 → 邀请/排序/资料/删除 | 进入总览 → 邀请、成员、权限、排序、设置 → 归档或永久注销 | 当前覆盖并细分高风险操作 |
|
|
||||||
| 维护字辈 | 字辈列表 → 新增/编辑/删除 | 字辈页 → 批量编辑 → 预览 → 保存;缺失项可停用/恢复 | 当前覆盖并保留历史 |
|
|
||||||
| 浏览和维护世系 | 树谱 → 点人物 → 新增亲属/编辑/删除/绑定 | 世系树或成员目录 → 人物详情 → 新增亲属/编辑/身份绑定/同辈排序/停用 | 当前完整覆盖 |
|
|
||||||
| 管理人物证件 | 家谱证件总表 → 解锁 → 新增/编辑/批量删除 | 人物详情 → 证件弹窗 → 解锁 → 新增资源/编辑/单项删除 | 人物内维护已覆盖且文件授权粒度更细;缺家谱级聚合入口 |
|
|
||||||
| 管理谱文 | 分类 → 列表 → 解锁 → 详情/新增/编辑/删除 | 谱文列表筛选 → 详情解锁 → 编辑器 → 回收站删除 | 当前覆盖,密码找回改为短信 |
|
|
||||||
| 管理相册 | 相册列表 → 新建/编辑 → 图片管理 | 相册列表 → 新建/编辑 → 相册详情 → 上传/预览/全选/批量删除 | 当前完整覆盖 |
|
|
||||||
| 观看家族/宣传视频 | 首页两条封面或视频列表 → 点击指定内容 → `.nvue` 上下滑动 → 点赞/评论 | 首页推荐封面 → 指定视频纵向播放,或“查看更多” → 封面列表 → 点击播放 → 点赞/评论 → 关闭返回 | 前端链路已补齐;MuMu 已验证入口和空状态,播放仍等待后端投放真实数据 |
|
|
||||||
| 发布家族圈 | 家族圈 → 发布文字/图片 → 评论/回复/删除 | 动态列表 → 编辑器发布 → 详情分页评论/回复/点赞/删除 | 当前完整覆盖 |
|
|
||||||
| 记录成长日志 | 选择人物/分类 → 填内容/图片/视频/密码 → 详情解锁 | 成长日志筛选 → 新建/编辑 → 图片或视频上传 → 详情图片预览/视频播放 → 密码管理 | 本轮补齐视频后完整覆盖 |
|
|
||||||
| 记录功德 | 功德录 → 姓名/内容/图片 → 详情/编辑 | 功德记录 → 姓名/标题/类型/金额/内容/时间/图片 → 详情/编辑/删除 | 文字和结构化字段已覆盖;图片等待后端媒体契约,列表首图也待补 |
|
|
||||||
| 创建礼仪活动 | 贺礼分类 → 新建 → 标题/时间/地点/封面/备注 → 详情 | 活动列表 → 新建/编辑 → 同等字段 → 详情 → 邀请对象/回复/礼簿 | 当前完整覆盖 |
|
|
||||||
| 处理通知 | 消息列表 → 通知详情/加入审核/邀请 | 消息中心 → 详情 → 按目标进入审核、动态、备忘或活动邀请 | 当前目标路由更明确 |
|
|
||||||
| 维护个人资料与安全 | 我的 → 资料/设置/改密码/改手机号/反馈 | 个人中心 → 资料、安全、设置、协议、反馈 → 对应独立流程 | 当前完整覆盖 |
|
|
||||||
| 分享推荐关系 | 推广页 → 展示注册链接二维码或复制链接 | 推广中心 → 查看推荐码/人数 → 复制推荐码或系统分享文字 | 推荐关系已覆盖;扫码和注册链接渠道未覆盖,等待后端可信 `shareUrl` |
|
|
||||||
| 开通 VIP | 套餐 → 协议 → 支付方式 → 支付 → 成功页/完整订单信息 | VIP 页 → 套餐/协议 → capability 支持的渠道 → 支付 → 订单列表 | 支付链路已覆盖;订单号、支付时间、到期时间的独立展示仍待前端补齐 |
|
|
||||||
| 查看收益和提现 | 收益 → 提现金额 → 提交 → 提现记录 | 收益页 → 提现弹窗 → 金额/方式/账户/姓名 → 提交 → 分页记录/取消 | 提交和取消链路已覆盖;记录页缺提现单号及处理结果字段 |
|
|
||||||
|
|
||||||
## 操作、显示和状态覆盖表
|
|
||||||
|
|
||||||
| 模块 | 入口与操作 | 主要显示字段 | 表单/提交字段 | 当前状态处理 |
|
|
||||||
| --- | --- | --- | --- | --- |
|
|
||||||
| 认证 | 登录、注册、发码、重置、微信登录、查看协议 | 手机号、登录方式、协议标题/版本 | 手机号、验证码、密码、确认密码、昵称、推荐码、协议同意 | 发码倒计时、错误、提交中、协议未同意、登录取消 |
|
|
||||||
| 家谱列表/搜索 | 创建、搜索、申请加入、邀请码兑换、排序、宣传视频 | 谱名、姓氏、简介、地区、成员数、角色、加入状态 | 关键词、邀请码、申请说明、稳定 ID 顺序 | 加载、空、错误、申请中、已加入、已申请、无权限 |
|
|
||||||
| 家谱设置 | 编辑资料、邀请、归档、恢复、注销 | 编号、谱名、堂号、地区、简介、封面、可见性、加入模式 | 谱名、堂号、地区编码、详细地址、简介、封面 OSS ID、访问/加入策略 | 未保存确认、上传中、归档限制、注销资格、短信双确认 |
|
|
||||||
| 世系人物 | 树/目录查看、新增亲属、编辑、绑定、排序、停用 | 人物全名、世代、排行、父母、配偶、兄弟、子女、在世状态及完整档案 | 关系类型、人物资料、配偶关系、同辈顺序、身份绑定 | 无权限、敏感字段隐藏、关系冲突、加载/空/错、删除确认 |
|
|
||||||
| 字辈 | 查看、批量编辑、预览、停用、恢复、排序 | 世代、字辈、说明、人数、状态 | 多行字辈文本、是否停用缺失世代 | 非法重复、空行预览、提交中、历史保留 |
|
|
||||||
| 谱文 | 分类筛选、详情、创建、编辑、解锁、删除 | 分类、标题、摘要、正文、封面、作者、发布时间、密码状态 | 分类 ID、标题、摘要、正文、作者、封面 OSS ID | 锁定/解锁、短信找回、无权限、回收站、加载/空/错 |
|
|
||||||
| 相册 | 创建、编辑、进入相册、上传、预览、批量删除 | 名称、描述、封面、照片数、创建时间、照片信息 | 相册名、描述、照片标题/描述/拍摄人/时间、图片 OSS ID | 上传中、选择态、全选、部分删除失败、回收站 |
|
|
||||||
| 视频 | 发布、编辑、普通播放、纵向观看、点赞、评论、删除 | 标题、描述、封面、视频、发布人、时间、播放/点赞/评论数 | 标题、描述、封面 OSS ID、视频 OSS ID、时长 | 自动播放/暂停、上下滑动、评论分页、无权限、回收站 |
|
|
||||||
| 家族圈 | 发布、编辑、详情、点赞、评论、回复、删除 | 发布人、头像、正文、图片、时间、点赞/评论数 | 动态类型、正文、媒体 OSS ID | 草稿离开确认、评论分页、失败重试、重复提交保护 |
|
|
||||||
| 功德记录 | 列表、创建、编辑、详情、图片预览、删除 | 姓名、标题、类型、金额、内容、时间、图片、合计 | 捐赠人、标题、类型、金额、内容、时间、图片 OSS ID | 上传中、移除附件、金额精度、加载/空/错、回收站 |
|
|
||||||
| 成长记录 | 人物/分类筛选、创建、编辑、详情、解锁、删除 | 人物、分类、标题、内容、记录/提醒时间、图片、视频 | 人物 ID、类型、标题、内容、记录/提醒时间、媒体 OSS ID | 图片/视频上传、移除附件、媒体播放、密码保护/找回、回收站 |
|
|
||||||
| 人情记录 | 列表、创建、编辑、详情、图片预览、删除 | 对象、关系、事项、时间、金额、内容、图片 | 对象、关系、事项、时间、礼金、内容、媒体 OSS ID | 金额校验、上传中、加载/空/错、回收站 |
|
|
||||||
| 家族恩人/家族备忘 | 参考:家族恩人列表、创建、编辑、详情、删除;当前:备忘完成切换 | 参考显示标题、说明、图片、创建时间;当前另有提醒时间和完成状态 | 当前仅有备忘标题、内容、提醒时间、完成状态、媒体 OSS ID | 已确认恩人语义未覆盖;产品选择在 Memo 中增加独立类型或新建档案契约 |
|
|
||||||
| 礼仪活动 | 分类筛选、创建、编辑、详情、邀请、回复、记礼 | 类型、标题、时间、地点、地址、备注、封面、邀请/礼簿状态 | 类型、标题、时间、地点、地址、备注、封面 OSS ID、受邀人、礼金/留言 | 字典加载、上传中、邀请接受/拒绝、重复记礼保护 |
|
|
||||||
| 通知 | 列表、已读、详情、跳转业务目标 | 类型、标题、摘要、正文、发送人、家谱、时间、已读状态 | 通知目标参数 | 加载/空/错、目标失效、权限不足 |
|
|
||||||
| 个人中心 | 编辑资料、改密码、改手机号、帮助、反馈、推广、退出 | 头像、昵称、实名、性别、生日、邮箱、手机号、版本和协议 | 头像 OSS ID、资料字段、旧/新密码、短信码、反馈、推荐码 | 上传、短信倒计时、未保存确认、退出、账号注销 |
|
|
||||||
| VIP/收益 | 选套餐、支付、查订单、查流水、申请/取消提现 | 套餐、价格、渠道、订单状态;可用/冻结余额、流水、提现状态。订单号/双时间及提现处理信息待补 | 套餐 ID、渠道、金额、收款方式、账户、姓名、幂等键 | 渠道 capability、支付取消/未知、分页、金额上下限、取消资格 |
|
|
||||||
| 推广 | 查看推荐码/邀请人数、复制、系统分享、查看推广内容 | 推荐码、邀请人数、分享标题/文案、推广封面/链接;缺注册链接与二维码 | 无前端提交;注册时提交推荐码 | 推荐功能关闭、读取失败、系统分享取消、推广链接失效 |
|
|
||||||
|
|
||||||
## 关键字段对比
|
|
||||||
|
|
||||||
| 模块 | 参考字段/显示项 | 当前结果 |
|
|
||||||
| --- | --- | --- |
|
|
||||||
| 注册 | 手机号、验证码、密码、确认密码、真实姓名、性别、邀请码、协议 | 手机号、验证码、密码、确认密码、昵称、推荐码、协议已覆盖;真实姓名和性别移到注册后的账号资料页,避免注册契约重复 |
|
|
||||||
| 创建家谱 | 姓氏、谱名、堂号、始迁祖、地区、简介、访问权限 | 全部覆盖;当前另有所在地、详细地址、封面、加入模式,始迁祖等待后端落库联调 |
|
|
||||||
| 家谱卡片/总览 | 姓氏、谱名、简介、成员数、家谱编号 | 公开搜索显示姓氏、简介和成员数;自有家谱卡片不显示真实姓氏/简介,总览显示简介但不显示 `genealogyNo`。属于展示位置差异,编号是否恢复待产品确认 |
|
|
||||||
| 谱文 | 分类、标题、正文、落款、时间、查看密码 | 当前覆盖分类、标题、摘要、正文、封面、作者、时间、密码状态和权限 |
|
|
||||||
| 相册 | 名称、描述、照片数、创建时间、照片 | 名称、描述、封面、照片数和照片管理已覆盖;列表及最新 APP VO 缺创建时间,另有批量选择、部分失败和回收站恢复 |
|
|
||||||
| 重要证件 | 家谱级入口、归属人物、证件类型、图片、查看密码 | 人物内字段和维护全部覆盖,另有多文件资源、资源编辑、单资源删除和安全访问票据;缺家谱级聚合入口 |
|
|
||||||
| 家族视频 | 标题、视频链接/文件、描述、封面、创建人、评论 | 内容字段、互动和纵向观看全部覆盖;任意 URL 改为受控业务文件 |
|
|
||||||
| 功德录 | 姓名、内容、图片、时间 | 文字字段覆盖;图片未完成,后端无媒体字段且当前列表无首图 |
|
|
||||||
| 贺礼/活动 | 类型、标题、时间、地点、封面、备注 | 全部覆盖,另有受邀人、接受/拒绝状态和礼簿 |
|
|
||||||
| 成长日志 | 人物、分类、内容、图片/视频、查看密码 | 全部覆盖,安全问题改为短信找回 |
|
|
||||||
| 贺礼簿 | 标题、内容、时间 | 当前往来记录已覆盖并结构化为对象、关系、事项、金额、日期和备注 |
|
|
||||||
| 家族恩人 | 标题、内容、时间 | 当前家族备忘有相近字段并增加提醒时间和完成状态,但没有恩人身份/类型,属于确认语义缺口 |
|
|
||||||
| 家族圈 | 发布人、正文、图片、时间、评论、回复 | 全部覆盖并增加点赞、分页、删除权限、错误和弱网状态 |
|
|
||||||
| 账号资料 | 头像、昵称、真实姓名、性别、生日、邮箱、手机号 | 当前账号资料覆盖;手机号由独立安全流程维护 |
|
|
||||||
| 人物资料 | 姓名、昵称、字、别名、性别、亲属、同辈顺序、配偶、出生/农历、生肖、地址、手机、邮箱、学历、职业、生平、状态、享年、病史、逝世日期/地点/类型、安葬日期/地点 | 当前人物新增、编辑、详情契约均覆盖;病史由服务端能力控制,无权限不得返回 |
|
|
||||||
| VIP | 套餐、价格、协议、微信/支付宝/余额、订单号、订单状态、支付时间和到期时间 | 支付能力已覆盖;后端已有字段,但当前缺订单号,支付/到期时间也未分别显示 |
|
|
||||||
| 收益提现 | 余额、流水、金额、提现记录、状态、审核/打款信息 | 提交、取消、冻结金额和幂等保护已覆盖;页面未消费契约已有的提现单号、审核备注、打款参考号和到账时间 |
|
|
||||||
| 推广邀请 | 推荐码、注册链接、二维码、复制/分享 | 推荐码、邀请人数、复制推荐码和系统分享文字已覆盖;注册链接和二维码缺失,后端也未返回可信 `shareUrl` |
|
|
||||||
|
|
||||||
## 表单必填规则差异(产品确认项)
|
|
||||||
|
|
||||||
这些差异是提交规则不同,不等于当前项目漏字段。除非后端业务约束或产品明确要求,不按参考项目机械收紧。
|
|
||||||
|
|
||||||
| 表单 | 参考项目提交校验 | 当前项目提交校验 | 结论 |
|
|
||||||
| --- | --- | --- | --- |
|
|
||||||
| 创建家谱 | 谱名、姓氏、堂号、始迁祖、地区、简介都必填 | 姓氏、谱名、地区必填;堂号、始迁祖、简介可选 | 字段均已有(始迁祖待后端落库),必填强度不同,需产品确认 |
|
|
||||||
| 谱文 | 标题、正文、落款、查看密码和两组安全问答必填 | 标题、正文必填;作者、摘要、封面可选;密码找回改短信 | 当前是安全流程升级与较短表单,不回退旧安全问答 |
|
|
||||||
| 家族视频 | 参考提交只强制视频 URL | 当前强制已上传视频和标题 | 当前规则更完整,不按参考放宽 |
|
|
||||||
| 功德录 | 姓名和内容必填 | 捐赠人和标题必填,内容可选 | 业务语义不同;需确认“内容”是否必须,媒体缺口另行处理 |
|
|
||||||
| 贺礼簿 | 标题和内容必填 | 当前往来记录为亲友姓名必填,其余结构化字段和备注可选 | 当前已从自由文本升级为结构化记录;是否要求事项/备注需产品确认 |
|
|
||||||
| 家族恩人 | 标题和内容必填 | 当前家族备忘为标题必填,内容可选 | 先确认是否复用备忘录;若复用,再确认是否允许仅标题记录 |
|
|
||||||
| 礼仪活动 | 标题、备注、地点、时间必填 | 类型和标题必填,时间、地点、备注可选 | 当前允许先建草稿式活动;若活动发布必须完整,需定义状态后再收紧 |
|
|
||||||
| 成长日志 | 人物、内容、查看密码和两组安全问答必填 | 标题必填,人物来自筛选上下文;内容、媒体可选;密码按当前保护机制设置 | 当前流程已融合人物上下文并升级找回方式,不复制旧问答校验 |
|
|
||||||
|
|
||||||
## 第四轮复核后仍待处理或验证
|
|
||||||
|
|
||||||
| 优先级 | 项目 | 当前事实 | 责任与完成条件 |
|
|
||||||
| --- | --- | --- | --- |
|
|
||||||
| P0 | 普通谱主进入家谱设置 | MuMu 仍收到后端“家谱必须先归档”,与参考项目可进入设置的目标不一致 | 后端拆分所有者鉴权和永久删除资格;修复后前端回归详情、设置、归档和恢复 |
|
|
||||||
| P1 | 家谱级重要证件 | 当前只有人物详情入口;服务端查询条件允许不传人物 ID | 前端增加家谱级入口和跨人物列表,复用现有证件详情/编辑能力 |
|
|
||||||
| P1 | VIP 订单完整字段 | 当前未显示订单号,支付时间和到期时间只显示其一 | 前端修正订单规范化契约并独立展示三项字段 |
|
|
||||||
| P1 | 提现记录处理字段 | 页面未显示契约已有的提现单号、审核备注、打款参考号和到账时间 | 前端按提现状态展示现有字段;若必须显示独立审核时间,再由后端增加字段 |
|
|
||||||
| P1 | 推广注册链接和二维码 | 参考项目能展示二维码并复制注册链接;当前只能分享推荐码文字,后端没有链接字段 | 后端提供可信 `shareUrl`,前端生成二维码并提供复制/分享;不迁移旧硬编码域名 |
|
|
||||||
| P1 | 六类列表创建时间 | 参考相册、礼仪、功德、成长记录、贺礼簿和家族恩人列表显示 `create_time`;当前除相册外只显示不同语义的业务时间,五个已确认映射 VO 均无 `createTime` | 后端先为五个已确认映射 VO/OpenAPI 增加只读创建时间;家族恩人按选定契约增加;前端同时保留业务时间 |
|
|
||||||
| P1 | 宣传视频真实内容 | 前端入口、封面态、指定视频直达和播放器已完成;测试环境 `home_featured`、`video_center` 均返回空列表 | 后端/运营各配置至少一条有效期内、含 `videoFile` 和建议含 `coverFile` 的数据后,在 MuMu 完成播放点击验收 |
|
|
||||||
| P1 | 独立图片宣传内容 | 最新后端 `PlatformVideoVo` 只表达视频及可选封面,不能表达“只有图片、点击放大”的独立内容 | 若产品确认需要纯图片,后端先定义混合媒体类型、图片文件和点击行为;前端再增加图片预览,不能猜字段 |
|
|
||||||
| P1 | 原生视频最终行为 | 代码和路由检查通过,空数据环境无法验证首帧、自动播放、上下滑动、横竖屏和弱网恢复 | 有真实视频数据后在 MuMu 和至少一台 Android 真机走完播放、切换、暂停、返回和评论 |
|
|
||||||
| P1 | 其余参考路径的交互级验收 | 78/78 路由已逐项登记,但本表不是 78 条路径的全部真机点击记录 | 按高频主路径逐页执行点击用例并记录结果;未点击的项不能再仅凭页面映射声称完全一致 |
|
|
||||||
|
|
||||||
## 交互与状态核对
|
|
||||||
|
|
||||||
当前项目对所有正式业务页统一补充了参考项目普遍缺失的状态:加载、空数据、错误重试、提交中、按钮禁用、离开未保存确认、删除二次确认、权限不足、请求取消和非幂等结果未知。它们属于当前项目正式验收范围,不因为参考项目没有实现而删除。
|
|
||||||
|
|
||||||
本表由 `scripts/check-frontend-parity.mjs` 校验参考 78 条活动路由是否逐条且只出现一次。它只能证明静态清单无遗漏;真实后端数据、真机微信/支付和原生视频行为仍需联调或真机验证。
|
|
||||||
|
|
||||||
## 第六轮:浏览器与 MuMu 实际点击复核(2026-08-23)
|
|
||||||
|
|
||||||
本轮按用户指定环境区分执行:参考项目在已登录浏览器中点击,当前项目在已登录 MuMu 中点击,同时保留代码全量路由核对。详细步骤、截图和分组结论见 [点击对比审查](click-comparison-audit-2026-08-23.md)。
|
|
||||||
|
|
||||||
新增运行时证据没有推翻上文的融合判定,重点确认如下:
|
|
||||||
|
|
||||||
- 家族内容模块确实合并到当前“家族”主标签,不是缺页。
|
|
||||||
- 参考家族视频采用封面卡片进入独立播放页;当前测试家谱为空,无法验真实播放,但当前家族视频列表源码直接渲染播放器的差异仍成立。
|
|
||||||
- 参考推广页直接显示注册链接二维码;当前只有推荐码、复制和系统分享,没有页面二维码或注册链接。
|
|
||||||
- 参考家谱级“重要证件”可集中显示多张证件图片并提供管理/上传;当前只有人物内证件入口。
|
|
||||||
- 参考 VIP 记录显示订单号、支付时间和到期时间;当前订单卡仍缺这些完整字段。
|
|
||||||
- 当前家谱设置进入即读取失败,点击“重新读取”后画面完全相同,P0 阻断仍未修复。
|
|
||||||
|
|
||||||
## 第七轮:剩余核心入口继续点击(2026-08-23)
|
|
||||||
|
|
||||||
继续实点参考礼仪、谱文、功德、家族恩人、贺礼簿、成长记录、字辈谱、世系谱、家族动态、管理员、VIP 购买、消息、创建和加入家谱,并再次核对当前页面与最新后端源码。
|
|
||||||
|
|
||||||
- 礼仪封面、功德多图、成长具体记录的 `create_time` 均由运行态确认,既有三项结论成立。
|
|
||||||
- 参考 `favor` 的真实产品名称是“贺礼簿”;MuMu 当前入口也直接显示“贺礼簿”,新建页是字段更完整的往来记录,确认不缺独立重复页面。
|
|
||||||
- 参考 `memorandum` 的真实产品名称是“家族恩人”;MuMu 当前入口和新建页明确是提醒型“家族备忘”,最新前后端主业务代码也没有恩人类型。确认存在业务语义缺口;产品只需选择复用 Memo 类型还是独立档案。
|
|
||||||
- 参考“家族普”实际是动态流,对应当前“家族圈”;字辈谱、世系谱、管理员、消息和加入家谱均已有融合或更安全的升级实现。
|
|
||||||
- MuMu 登录态恢复后已补点礼仪、功德、贺礼簿、家族备忘、人物详情和成长日志。五组页面均可进入,空状态和新建表单稳定;因测试家谱对应列表为空,首图、详情媒体和创建时间仍只能依据契约/模板判断,不能声称有数据链路已通过。
|
|
||||||
@@ -1,148 +0,0 @@
|
|||||||
# 上线前待处理事项
|
|
||||||
|
|
||||||
> 2026-08-22:下方关于后端能力“尚未实现”的描述属于历史快照。最新源码与 MuMu 点击联调结果、当前真实阻塞项以 [《APP 前后端联调结果与后端处理单(2026-08-22)》](./backend-integration-report-2026-08-22.md) 为准。
|
|
||||||
|
|
||||||
更新时间:2026-08-17
|
|
||||||
|
|
||||||
> 2026-08-17 更新:需要直接交给后端执行的缺口、源码核对结果和接口验收标准统一记录在 [《后端开发对接任务单》](./backend-integration-tasks-2026-08-17.md)。本文件继续保留发布、安全和环境阻塞;下方较早的参考项目差距描述如与新任务单冲突,以任务单为准。
|
|
||||||
|
|
||||||
本文只记录当前前端仓库无法独立闭环的事项。完成后应删除对应条目,不能将本文当作长期豁免。
|
|
||||||
|
|
||||||
## 已核对范围
|
|
||||||
|
|
||||||
- 参考项目 `C:\Users\Rain\Desktop\job\Jiapu-App` 共注册 78 个路由;`pages/index/video/video2.nvue`、`video3.nvue`、`video4.nvue` 均为有效视频页,已纳入逐项对比并在当前项目补齐纵向观看。`pages/index/genealogyList/ancestorsOrder.vue` 仅有 67 字节空壳,不作为待迁移功能。全部参考路由已按业务能力映射到当前 59 个页面。
|
|
||||||
- 后端目录 `C:\Users\Rain\Desktop\job\Genealogy` 已可读取完整源码;2026-08-17 已完成 APP 认证、VIP、族人档案、视频评论、权限、回收站与删除引擎的静态核对。该目录当前不是可识别的 Git 工作树,本轮没有修改或运行后端;前端契约仍以仓库内唯一的 `genealogy-app-openapi.yaml` 为准。
|
|
||||||
|
|
||||||
## P0:Android 原生隐私弹窗链接指向错误或过期协议
|
|
||||||
|
|
||||||
- 现状:`androidPrivacy.json` 中“用户协议”仍指向旧 H5 内容 `id=62`。2026-08-13 实测该公开页面标题为“会员服务协议”,正文生效日期为 2021-08-08,与 App 内合规接口返回的《代代相传家谱用户协议》(版本 1.0,生效日期 2026-08-10)不是同一文档;`id=63` 的隐私政策也仍是旧内容源。原生弹窗发生在 WebView 和 App 内页面加载前,不能直接复用当前 Vue 合规页。
|
|
||||||
- 责任端:后端合规内容维护者 / H5 发布负责人 / Android 发布负责人。
|
|
||||||
- 处理要求:为当前 `user_agreement` 和 `privacy_policy` 提供无需登录、可在系统 WebView 打开的稳定 HTTPS 正文地址,再替换 `androidPrivacy.json` 两处首轮提示和两处二次确认链接。页面正文、版本号、生效日期、主体和联系方式必须与当前合规接口一致;不得继续用会员协议冒充用户协议。
|
|
||||||
- 验收:全新安装后点击原生弹窗的两个链接,分别打开 2026-08-10 生效的正式用户协议与隐私政策;拒绝、二次确认、同意路径均正常;抓包确认正文地址无需用户令牌且全程 HTTPS。
|
|
||||||
|
|
||||||
## P0:正式隐私政策缺少 uni-app/DCloud 运行时披露
|
|
||||||
|
|
||||||
- 现状:当前线上 `privacy_policy` 正文详细列出了阿里云 OSS、阿里云短信和微信支付,但没有说明产品基于 DCloud uni-app(5+ App/Wap2App)开发,也没有披露相应运行时为统计分析、启动与异常日志所处理的设备标识信息。DCloud 的 Android 应用市场合规自查明确要求在 App 隐私政策中补充这部分说明;该正文由后端合规内容管理,前端不能自行改写线上法律文本。
|
|
||||||
- 责任端:隐私合规负责人 / 后端合规内容维护者。
|
|
||||||
- 处理要求:由合规负责人按正式包实际启用的 DCloud 模块、统计配置和 SDK 清单核实处理目的、信息类型、共享对象、隐私政策链接及关闭方式,再更新 `privacy_policy` 新版本并重新发布;不能照抄超出实际能力的模板字段。
|
|
||||||
- 验收:正式隐私政策逐项覆盖安装包实际 SDK 与权限;DCloud 隐私合规检测和目标应用商店人工审核通过;App 内正文与 Android 原生弹窗链接打开的是同一生效版本。
|
|
||||||
|
|
||||||
## P0:认证契约将无盐 MD5 摘要直接作为密码凭据
|
|
||||||
|
|
||||||
- 现状:唯一 OpenAPI 的注册、密码登录、修改密码和找回密码统一要求客户端提交 32 位 MD5,当前前端只能按该契约调用。无盐 MD5 是快速、可离线猜测且可重放的固定凭据;它不能替代 TLS,也不符合现代密码存储应使用逐用户盐值和有成本密码派生算法的要求。前端不能单方面改算法,否则现有账号与后端认证会全部失配。
|
|
||||||
- 责任端:后端认证负责人 / 安全负责人 / OpenAPI 维护者。
|
|
||||||
- 处理要求:先确认服务端数据库是否还会对收到的 MD5 再使用 Argon2id、scrypt、bcrypt 或 PBKDF2 等带盐算法存储;若没有,必须制定密码凭据迁移。新契约应明确传输只依赖 HTTPS,服务端保存逐用户盐值的慢哈希;如需兼容旧账号,应在一次成功登录后升级存储,并定义旧契约下线时间,不能长期接受两套等价入口。
|
|
||||||
- 验收:数据库泄露场景下不存在可直接重放登录的客户端 MD5 凭据;新注册与改密只产生带盐慢哈希;旧账号迁移、并发登录、忘记密码和回滚均有服务端自动测试;OpenAPI 与客户端在同一版本切换。
|
|
||||||
|
|
||||||
## P0:会话令牌仍存放在普通应用缓存
|
|
||||||
|
|
||||||
- 现状:当前 `utils/session.js` 通过 `uni.setStorageSync` 持久化 Bearer `access_token`。该接口只提供应用本地缓存能力,仓库中没有使用 Android Keystore、iOS Keychain 或由其保护的加密封装,也没有可验证的令牌备份排除策略。令牌被提取后可直接代表用户访问家谱及重要证件等敏感数据,前端 JavaScript 内置固定加密密钥不能解决此问题。
|
|
||||||
- 责任端:App 原生安全负责人 / 认证后端负责人 / 发布负责人。
|
|
||||||
- 处理要求:正式 App 使用经过审查的原生安全存储插件,以 Android Keystore 和 iOS Keychain 保护会话材料;禁止把密钥硬编码进前端资源。后端同时提供短期访问令牌、可撤销的刷新令牌、设备/会话管理和异常吊销能力,并明确备份恢复、换机、卸载和设备锁屏后的行为。
|
|
||||||
- 验收:正式签名包的动态与静态安全测试确认普通缓存、备份和日志中没有明文会话令牌;退出登录、注销、改密和服务端吊销后旧令牌立即失效;新安装、换机和备份恢复不会继承可用会话。
|
|
||||||
|
|
||||||
## P0:VIP 后端仍是微信单渠道契约
|
|
||||||
|
|
||||||
- 现状:前端已经按 OpenAPI 接入微信、支付宝和余额三种方式,并根据 capability 动态展示可用渠道。后端 `PaymentOrderVo` 已有微信 `prepayId` 和签名字段,但 `VipPurchaseCapabilityVo` 没有 `paymentMethods`,`AppVipOrderBody` 没有 `paymentMethod`,支付结果也没有渠道判别字段与支付宝/余额结果。
|
|
||||||
- 责任端:`C:\Users\Rain\Desktop\job\Genealogy` 后端支付模块。
|
|
||||||
- 处理要求:按唯一 OpenAPI 同步 capability、下单体和支付结果;微信继续返回完整预支付参数,支付宝返回 APP 支付订单字符串,余额支付在服务端事务内完成扣款与会员开通。
|
|
||||||
- 验收:三种渠道分别覆盖成功、取消、失败和超时查询;同一订单不能重复支付或重复开通,余额不足不能返回伪成功。
|
|
||||||
|
|
||||||
## P0:微信开放平台和云打包参数未提供
|
|
||||||
|
|
||||||
- 现状:`manifest.json` 已启用 Payment 模块,但仓库没有可确认的正式微信 AppID、Android 包名/签名对应关系、iOS Universal Links 和发布签名材料。
|
|
||||||
- 责任端:发布负责人 / 微信开放平台管理员。
|
|
||||||
- 处理要求:在 HBuilderX 云打包配置中填写正式参数并完成微信开放平台校验。密钥、证书密码和 AppSecret 不得提交到仓库。
|
|
||||||
- 验收:Android 与 iOS 正式签名包都能拉起微信,支付后能返回应用并由服务端确认订单状态。
|
|
||||||
|
|
||||||
## P1:推广绑定前端已完成,等待后端实现
|
|
||||||
|
|
||||||
- 现状:注册页已支持推荐码,个人中心已提供“我的推荐”、复制和分享入口;后端注册体仍没有 `referralCode`,也没有推荐资料、归属或分佣服务。
|
|
||||||
- 责任端:产品负责人 / 后端负责人。
|
|
||||||
- 处理要求:按唯一 OpenAPI实现注册时一次绑定和 `/referrals/me`;服务端落实防自邀、防重复绑定、并发幂等和收益归属,前端不接受本地伪造统计。
|
|
||||||
- 验收:分享、首次绑定、无推荐码、自邀、重复绑定、并发注册和收益入账均有服务端测试。
|
|
||||||
|
|
||||||
## P1:微信快捷登录前端已完成,等待后端实现和正式配置
|
|
||||||
|
|
||||||
- 现状:前端已启用 OAuth 模块并完成 `uni.login({ provider: "weixin" })` 授权码登录、取消和失败状态;后端 `AppAuthController` 仍没有 `/auth/login/wechat`,正式微信开放平台参数也未提供。
|
|
||||||
- 责任端:产品负责人 / 后端认证负责人 / 微信开放平台管理员。
|
|
||||||
- 处理要求:按唯一 OpenAPI 实现一次性授权码交换、已有账号匹配/绑定和冲突响应,并配置正式微信开放平台参数;AppSecret 不得进入前端仓库。
|
|
||||||
- 验收:Android/iOS 正式包都能完成首次授权、已有账号登录、取消授权、重复登录和账号冲突,客户端不直接信任微信展示资料。
|
|
||||||
|
|
||||||
## P1:宣传视频前后端主体已存在,等待收紧评论契约和联调
|
|
||||||
|
|
||||||
- 现状:当前前端已有独立平台视频页,支持播放、点赞、一级评论和删除;后端 `AppPlatformVideoController` 已有列表、详情、点赞和评论。后端评论请求仍允许 `parentCommentId`,与本期平台视频只保留一级评论的契约冲突。
|
|
||||||
- 责任端:产品负责人 / 后端内容负责人。
|
|
||||||
- 处理要求:后端去掉平台评论 `parentCommentId` 输入并清理或迁移已有回复数据;按 placement、platform 和上下线时间返回有效视频。
|
|
||||||
- 验收:首页入口、列表播放、空状态、失效视频、点赞幂等和一级评论可在正式环境验证。
|
|
||||||
|
|
||||||
## P1:细粒度权限前端已完成,后端缺动态权限目录
|
|
||||||
|
|
||||||
- 现状:前端成员管理已按动态目录渲染分组权限并读取/保存成员授权。后端成员权限 GET/PUT 已存在,但没有 `/permission-catalog`;前端不会重新硬编码参考项目的 `auth_str`。
|
|
||||||
- 责任端:产品负责人 / 后端权限负责人 / OpenAPI 维护者。
|
|
||||||
- 处理要求:基于后端唯一权限定义实现 `/permission-catalog`,返回权限编码、名称、分组和禁用原因;保存接口返回最终权限集合并由服务端统一判权。
|
|
||||||
- 验收:新增权限无需前端发版即可出现;所有页面显示能力与服务端鉴权一致,越权请求被拒绝。
|
|
||||||
|
|
||||||
## P1:永久注销前端已完成,后端缺 APP 谱主入口
|
|
||||||
|
|
||||||
- 现状:前端设置页已完成归档前置、不可用原因、脱敏手机号、精确家谱名和短信双确认。后端已有管理员使用的删除资格检查、任务和执行引擎,但没有 APP 谱主发码/提交入口,`AppGenealogyVo` 也没有能力投影字段。
|
|
||||||
- 责任端:产品负责人 / 后端家谱负责人 / 数据合规负责人。
|
|
||||||
- 处理要求:按唯一 OpenAPI 为现有删除引擎增加 owner-only APP 包装,校验已归档、阻塞任务、精确名称和短信码;成功后立即撤销所有成员端的家谱上下文。
|
|
||||||
- 验收:非谱主、未归档、名称不符、错码和阻塞任务均拒绝;成功提交可审计、任务失败可追踪重试,成员不能继续访问该家谱。
|
|
||||||
|
|
||||||
## P1:真实写操作与商店发布仍需发布环境验收
|
|
||||||
|
|
||||||
- 现状:本轮 MuMu 点击测试使用已登录账号,避免对现有家谱执行删除、撤销邀请、上传、提现等不可逆或会产生真实数据的操作;也未持有应用商店账号和正式签名材料。
|
|
||||||
- 责任端:测试负责人 / 发布负责人。
|
|
||||||
- 处理要求:使用专用测试租户走通创建、编辑、上传、邀请、撤销、注销和提现审核等流程,再进行 Android/iOS 正式构建与商店隐私合规检查。
|
|
||||||
- 验收:测试数据可清理,关键写操作、失败重试和权限边界均有记录;正式包安装、升级、冷启动和回退流程通过。
|
|
||||||
|
|
||||||
## P1:多数创建接口缺少跨会话幂等契约
|
|
||||||
|
|
||||||
- 现状:前端已对创建家谱、成员、谱文、动态、相册、视频、祭祀和成长记录等写操作增加当前页面生命周期内的重复提交保护,但应用重启、页面重载、请求超时后重试会丢失这层状态。唯一 OpenAPI 目前只有提现请求提供稳定的 `requestId`,其余创建接口没有定义可跨会话重放的幂等键,因此客户端无法保证弱网重试只产生一条业务数据。
|
|
||||||
- 责任端:各业务后端负责人 / OpenAPI 维护者。
|
|
||||||
- 处理要求:由服务端统一定义幂等键的所有者、作用域、有效期和重放响应语义,并通过租户、用户、操作类型和幂等键建立持久化唯一约束;不能只依赖单进程内存锁或前端按钮禁用。相同幂等键携带不同业务参数时应明确拒绝。
|
|
||||||
- 验收:双击、请求超时、连接中断、应用重启后使用同一幂等键重试,服务端只创建一次并返回同一业务结果;同键不同参数被拒绝;并发请求有自动化覆盖。
|
|
||||||
|
|
||||||
## P1:大文件上传没有真实分片和明确大小契约
|
|
||||||
|
|
||||||
- 现状:当前上传初始化、分片、完成三段接口已经接入,文件上传已与普通 API 的 15 秒超时分离,独立放宽为 120 秒。但客户端仍固定 `totalChunks: 1`、`chunkSize: 文件总大小`,视频会作为一个完整请求上传。唯一 OpenAPI 没有声明图片/视频最大大小、服务端允许的分片大小和分片数量上限,因此前端无法据此安全拆分或给出准确限制。普通小图片不受此项影响,较大视频仍可能受内存和弱网影响。
|
|
||||||
- 责任端:文件服务负责人 / OpenAPI 维护者 / 前端负责人。
|
|
||||||
- 处理要求:由文件服务先确定最大文件大小、推荐分片大小、并发数、断点续传和过期上传清理规则,并写入唯一 OpenAPI;随后客户端按字节范围计算每片 MD5,逐片上传,不能继续把完整视频称作分片。
|
|
||||||
- 验收:在弱网环境上传接近上限的视频,中断后可继续或明确重新开始;任一分片大小、MD5 或顺序错误均被服务端拒绝;客户端不会一次性把完整大视频读入内存,上传超时后也不会留下不可识别状态。
|
|
||||||
|
|
||||||
## P1:隐私政策声明与当前客户端能力不完全一致
|
|
||||||
|
|
||||||
- 现状:当前客户端只在用户主动选择上传内容时读取相册或媒体库,没有调用相机,也没有接入系统推送通知;线上隐私政策的“设备权限”章节仍声明可能申请相机和通知权限。前端已只配置实际需要的相册读取权限,没有为了匹配文案增加无用权限。
|
|
||||||
- 责任端:隐私合规负责人 / 后端内容运营。
|
|
||||||
- 处理要求:按最终正式包逐项核对权限清单。若本期不接入拍摄和系统推送,应从线上隐私政策删除相机、通知相关声明;若本期确实接入,则需先完成对应产品功能、运行时授权时机和拒绝授权后的降级流程,再更新正式包权限。
|
|
||||||
- 验收:Android 与 iOS 正式包的权限清单、运行时弹窗、应用商店隐私标签及线上隐私政策逐项一致,普通浏览页面不会提前申请媒体权限。
|
|
||||||
|
|
||||||
## P1:公共内容接口的线上匿名访问不符合 OpenAPI
|
|
||||||
|
|
||||||
- 现状:唯一 OpenAPI 将官网文章、官网单页、帮助列表/详情和推广列表标记为 `security: []`。2026-08-13 使用必需的 `clientid`、`tenantId` 且不带 Authorization 抽查线上环境时,`GET /genealogy/app/site/articles`、`GET /genealogy/app/site/pages/{pageKey}`、`GET /genealogy/app/help-articles`、`GET /genealogy/app/help-articles/{helpId}` 和 `GET /genealogy/app/promotions` 均返回业务 401;同样声明匿名的行政区划和合规文档接口可以正常读取,说明不是公共请求头缺失。`GET /genealogy/app/genealogies/public` 虽然名称包含 public,但没有覆盖全局 `SaToken`,线上返回 401 与当前 OpenAPI 一致,不属于本项偏差。当前帮助与推广页面只从登录后的个人中心进入,前端暂时沿用会话请求以保持线上功能。
|
|
||||||
- 责任端:后端鉴权配置维护者。
|
|
||||||
- 处理要求:按 OpenAPI 对上述公共内容只读接口放行匿名访问;公共请求仍应保留租户与客户端头,不应依赖用户令牌。若产品决定必须登录,则应先修改 OpenAPI 和产品入口,再由前端统一收口,不能让线上行为与唯一契约长期分叉。
|
|
||||||
- 验收:无 Authorization 请求上述接口均返回业务成功;携带过期令牌不会导致公共页面跳转登录;帮助文章、推广位和官网公共内容能在退出登录状态正常读取。
|
|
||||||
|
|
||||||
## P2:MuMu 的 Chromium 图块内存警告仍存在
|
|
||||||
|
|
||||||
- 现状:六张通用长背景已从 1440×3600 缩至 720×1800,并改为按视口裁切,图片体积由约 31.7 MB 降至约 13.9 MB;页面实测没有缺图。MuMu 中的 HBuilder 调试基座冷启动仍会输出 8 条 `tile memory limits exceeded`,PSS 约 159 MB。
|
|
||||||
- 已确认边界:警告来自调试基座的 Chromium 渲染进程,当前没有对应的 Vue 异常或页面缺失;仅靠继续压缩单张背景无法证明可以消除。
|
|
||||||
- 责任端:测试负责人 / 前端负责人。
|
|
||||||
- 处理要求:使用正式签名包在至少一台中端 Android 真机复测首页、个人中心和世系页;通过 Android Studio Profiler 确认是调试基座开销还是页面合成层问题。
|
|
||||||
- 验收:正式包连续浏览核心页面不出现缺图、闪白或崩溃;若仍有警告,取得可定位到具体图层的 trace 后再调整对应页面,不能无依据继续降画质。
|
|
||||||
|
|
||||||
## P2:推广封面素材清晰度不足
|
|
||||||
|
|
||||||
- 现状:MuMu 实测“我的 → 应用推广”时,服务端返回的“数字家谱,从今天开始”封面被放大后明显模糊;同页文字、边框和本地品牌图均清晰,可排除整个页面缩放异常。
|
|
||||||
- 责任端:推广内容运营 / 后端文件管理。
|
|
||||||
- 处理要求:在推广管理端替换为适合横向卡片的高清封面,建议有效宽度不低于 1200 px,并保留合理宽高比;不要让客户端对低分辨率缩略图进行放大。
|
|
||||||
- 验收:Android 设备 1×、2×、3× 密度下查看推广中心和首页推荐位,图片无明显锯齿、马赛克或拉伸变形。
|
|
||||||
|
|
||||||
## P2:HBuilder 调试基座重启时漏部署运行时文件
|
|
||||||
|
|
||||||
- 现状:首次启用 `androidPrivacy.json` 后,HBuilder 调试基座重启曾缺少 `__uniappview.html` 和 `uni-app-view.umd.js`,显示 `ERR_FILE_NOT_FOUND`。本轮测试通过把本地编译产物重新部署到 MuMu 后恢复,项目编译产物本身包含这两个文件。
|
|
||||||
- 责任端:测试环境维护者。
|
|
||||||
- 处理要求:下次运行前由 HBuilderX 重新执行“运行到 Android App 基座”,不要依赖本轮模拟器内的临时部署结果。
|
|
||||||
- 验收:清理并重建调试基座后可连续冷启动三次,且不再出现 `ERR_FILE_NOT_FOUND`。
|
|
||||||
@@ -1,110 +0,0 @@
|
|||||||
# 当前项目与参考项目第二轮独立复审
|
|
||||||
|
|
||||||
审查日期:2026-08-23
|
|
||||||
参考项目:`C:\Users\Rain\Desktop\job\Jiapu-App`(浏览器,已登录)
|
|
||||||
当前项目:`C:\Users\Rain\Desktop\job\jiapuapp`(MuMu,已登录)
|
|
||||||
后端项目:`C:\Users\Rain\Desktop\job\Genealogy`
|
|
||||||
|
|
||||||
## 结论
|
|
||||||
|
|
||||||
第二轮没有沿用第一轮截图直接下结论,而是重新抓取参考项目与 MuMu 当前页面、重新点击关键入口,并再次核对当前前端源码、OpenAPI 与后端 Java 契约。
|
|
||||||
|
|
||||||
- 78 条参考路由仍全部有映射,当前项目 59 条路由和 172 个源码文件的完整性检查通过。页面数量不同主要来自当前项目的融合设计,不能按数量机械判缺。
|
|
||||||
- 第一轮确认的 14 项差异,经第二轮逐项复查后仍然成立;部分前端契约已经接入,但对应页面未消费,或后端契约尚未闭环。
|
|
||||||
- 第二轮新增确认 1 个当前项目自身的交互缺陷:成长记录新建表单未手动填写时,因自动带入人物仍被判定为“已修改”,返回会错误弹出放弃确认。
|
|
||||||
- 合计:14 项参考对比差异 + 1 项当前交互缺陷。最高风险仍是“家谱设置完全无法读取”。
|
|
||||||
- 本轮只做审查和文档整理,没有修改业务代码、接口契约或数据。
|
|
||||||
|
|
||||||
## 编号复审步骤与健康度
|
|
||||||
|
|
||||||
| 步骤 | 重新检查的范围 | 健康度 | 第二轮结论 |
|
|
||||||
| --- | --- | --- | --- |
|
|
||||||
| 1 | 路由与源码完整性 | 健康 | 参考 78 条路由均有登记与融合映射;当前 59 条路由、172 个源码文件检查通过。 |
|
|
||||||
| 2 | 首页与入口层级 | 基本健康 | 搜索、创建、加入、排序、宣传内容在当前项目均可找到,但位置比参考项目更深;属于信息架构差异,不计缺页。 |
|
|
||||||
| 3 | 家谱总览与家族主入口 | 基本健康 | 字辈、世系、成员、审核、谱文、相册、礼仪、备忘、人物录、贺礼簿、功德和视频均已有融合入口。 |
|
|
||||||
| 4 | 谱文、相册、礼仪、备忘、贺礼簿、功德、视频逐项进入 | 部分健康 | 7 个入口都可进入且空状态正常;由于测试家谱缺少对应内容,只能确认入口和状态,列表媒体与播放仍结合源码判断。 |
|
|
||||||
| 5 | 家族视频展示与播放入口 | 不健康 | 参考项目先显示封面卡片,点击后播放;当前列表仍直接铺设 `<video controls>`,未使用已有 `coverFile` 做封面优先卡片。 |
|
|
||||||
| 6 | 谱文与礼仪封面 | 不健康 | 两类响应契约均已保留 `coverFile`,编辑器也能上传,但列表和详情仍未渲染,属于前端字段消费缺口。 |
|
|
||||||
| 7 | 功德记录媒体闭环 | 不健康 | 当前前端已有上传、详情预览和 `mediaOssIds/mediaFiles` 处理;最新后端 `AppMeritRecordBody/AppMeritRecordVo` 仍无媒体字段,保存回显链路未闭环。 |
|
|
||||||
| 8 | 推广、收益提现与 VIP | 不健康 | 推广推荐码与系统分享可用,但无注册链接/二维码;提现契约字段未完整展示;VIP 未显示订单号且支付/到期时间没有独立展示。 |
|
|
||||||
| 9 | 重要证件与家族恩人语义 | 不健康 | 重要证件只存在于人物详情,没有参考项目的家谱级聚合入口;当前“家族备忘”也没有“家族恩人”的身份、分类和档案语义。 |
|
|
||||||
| 10 | 家谱设置首次读取与重试 | 阻断 | 首次进入显示“家谱设置暂时无法读取”,点击“重新读取”后画面完全相同。源码仍把基础设置与永久删除资格放在同一个 `Promise.all`。 |
|
|
||||||
| 11 | 创建家谱始迁祖 | 不健康 | 前端有 `firstAncestorName` 表单与请求字段;最新后端业务源码未找到接收并原子创建第一代人物的实现。 |
|
|
||||||
| 12 | 成长日志新建后返回 | 不健康(新增) | 空表单自动带入 `lineagePersonId` 后即被 `dirty` 判定为真,未编辑也弹出“放弃成长记录?”。 |
|
|
||||||
|
|
||||||
## 第二轮确认仍需处理的差异
|
|
||||||
|
|
||||||
| 优先级 | 差异 | 当前项目已有能力 | 责任与完成条件 |
|
|
||||||
| --- | --- | --- | --- |
|
|
||||||
| P0 | 家谱设置整体读取失败 | 设置页面和全部表单已存在 | 前端将基础设置读取与永久删除资格拆成独立状态;后端确认普通家谱的详情及删除资格响应。首次读取和重试均须成功。 |
|
|
||||||
| P0 | 家族视频列表不是“封面卡片 → 点击播放” | `coverFile`、视频文件、纵向播放器均已有 | 前端以封面卡片作为列表主展示,点击打开指定视频;有真实数据后验证播放、暂停、返回和上下滑动。 |
|
|
||||||
| P0 | 功德图片无法由后端稳定保存和回显 | 前端上传、移除、详情预览已存在 | 后端为功德请求/响应增加媒体字段及文件引用维护;前后端完成新增、编辑、删除、回显联调。 |
|
|
||||||
| P0 | 成长日志空表单误报未保存修改 | 已有返回保护机制 | 前端的脏状态基线应包含系统自动带入的人物,只有用户实际修改后才弹放弃确认。 |
|
|
||||||
| P1 | 礼仪封面未展示 | 后端和前端契约已有 `coverFile`,编辑器可上传 | 前端列表和详情消费封面,支持预览。 |
|
|
||||||
| P1 | 谱文封面未展示 | 后端和前端契约已有 `coverFile`,编辑器可上传 | 前端列表和详情消费封面;正文是否扩展为多图另行决定。 |
|
|
||||||
| P1 | 家谱级重要证件聚合入口缺失 | 人物详情已有证件管理与安全访问 | 前端新增家谱级入口和聚合列表,复用现有查看、编辑及安全能力。 |
|
|
||||||
| P1 | VIP 订单号、支付时间、到期时间显示不完整 | 后端已有 `orderNo/payTime/expireTime` | 前端契约保留 `orderNo`,页面将三项分别标注展示。 |
|
|
||||||
| P1 | 提现处理字段未展示 | 前端契约已有 `withdrawalNo/auditRemark/payoutReference/paidAt` | 按状态显示提现单号、审核备注、打款参考号和到账时间。 |
|
|
||||||
| P1 | 推广注册链接与二维码缺失 | 推荐码、邀请人数、复制和系统分享已存在 | 后端返回可信 `shareUrl`;前端展示二维码,并支持复制/分享注册链接。 |
|
|
||||||
| P1 | 谱文、礼仪、视频封面不能显式移除 | 三类编辑器可上传和替换 | 统一 `coverOssId: null` 的清空契约,前端增加移除操作并验证保存回显。 |
|
|
||||||
| P1 | 参考内容列表的创建时间未覆盖 | 当前已有业务发生时间 | 后端为谱文、礼仪、相册、成长、往来和功德等响应补只读 `createTime`,前端与业务时间分开显示。 |
|
|
||||||
| P1 | 贺礼簿列表缺少首图 | 详情可预览 `mediaFiles` | 前端列表显示 `mediaFiles[0]` 缩略图。 |
|
|
||||||
| P1 | “家族恩人”业务语义缺失 | 通用家族备忘有标题、内容、提醒和图片 | 产品先确定扩展 Memo 类型或建立独立档案;随后补身份、分类、独立入口、首图和创建时间。 |
|
|
||||||
| P1 | 创建家谱的始迁祖未由后端落库 | 前端已提交 `firstAncestorName` | 后端在创建家谱事务中接收该字段,并原子创建第一代人物。 |
|
|
||||||
|
|
||||||
## 已融合或已升级,不应重复补页
|
|
||||||
|
|
||||||
| 参考能力 | 当前项目对应能力 | 判定 |
|
|
||||||
| --- | --- | --- |
|
|
||||||
| 世系谱 | 图形化世系树 | 已融合升级,不另建重复页面。 |
|
|
||||||
| 字辈谱 | 字辈管理 | 已融合升级。 |
|
|
||||||
| 管理员 | 成员、角色与权限管理 | 已覆盖且权限更细。 |
|
|
||||||
| 家族普/家族动态 | 家族圈、动态详情、点赞和评论 | 已融合升级。 |
|
|
||||||
| 贺礼簿 | 结构化亲友往来记录 | 核心能力已融合,保留列表首图和创建时间差异。 |
|
|
||||||
| 成长记录 | 人物详情下的成长日志 | 核心字段和图片/视频能力更丰富;入口深度属于产品选择。 |
|
|
||||||
| 个人中心推广内容 | 推广中心和个人中心推广条 | 已融合;缺的是注册链接和二维码,不是整个推广模块。 |
|
|
||||||
|
|
||||||
## 关键截图证据
|
|
||||||
|
|
||||||
### 首页和总览
|
|
||||||
|
|
||||||
| 参考项目 | 当前项目 |
|
|
||||||
| --- | --- |
|
|
||||||
|  |  |
|
|
||||||
|  |  |
|
|
||||||
|
|
||||||
### 家族内容入口
|
|
||||||
|
|
||||||
| 当前谱文 | 当前相册 | 当前家族视频 |
|
|
||||||
| --- | --- | --- |
|
|
||||||
|  |  |  |
|
|
||||||
|
|
||||||
| 参考谱文 | 参考相册 | 参考家族视频 |
|
|
||||||
| --- | --- | --- |
|
|
||||||
|  |  |  |
|
|
||||||
|
|
||||||
### 财务、推广与阻断项
|
|
||||||
|
|
||||||
| 参考推广二维码 | 当前推广中心 |
|
|
||||||
| --- | --- |
|
|
||||||
|  |  |
|
|
||||||
|
|
||||||
| 参考 VIP 订单 | 当前 VIP 订单 |
|
|
||||||
| --- | --- |
|
|
||||||
|  |  |
|
|
||||||
|
|
||||||
| 当前设置首次失败 | 点击重试后 |
|
|
||||||
| --- | --- |
|
|
||||||
|  |  |
|
|
||||||
|
|
||||||
### 第二轮新增缺陷
|
|
||||||
|
|
||||||

|
|
||||||
|
|
||||||
## 验证边界
|
|
||||||
|
|
||||||
- 两端登录的是不同账号、使用不同数据集,内容条数、姓名和图片本身不作为一致性判断;比较的是入口、操作、字段、状态和交互。
|
|
||||||
- 参考项目浏览器视口与 MuMu 分辨率不同,本轮不是逐像素视觉还原审查。
|
|
||||||
- 测试家谱的谱文、礼仪、功德、贺礼簿和视频大多为空,空状态只能证明入口可达,不能证明有数据时的封面、首图、时间和播放正常;这些结论由当前源码和后端契约补证。
|
|
||||||
- 没有执行购买、提现提交、删除、归档、保存或验证码发送等写操作。
|
|
||||||
- 没有执行屏幕阅读器、外接键盘焦点顺序和自动对比度测试,因此不声明已完成完整无障碍验收。
|
|
||||||
@@ -1,138 +0,0 @@
|
|||||||
# 当前项目与参考项目第三轮操作流程复审
|
|
||||||
|
|
||||||
审查日期:2026-08-23
|
|
||||||
参考项目:`C:\Users\Rain\Desktop\job\Jiapu-App`(浏览器,已登录)
|
|
||||||
当前项目:`C:\Users\Rain\Desktop\job\jiapuapp`(MuMu,已登录)
|
|
||||||
后端项目:`C:\Users\Rain\Desktop\job\Genealogy`
|
|
||||||
|
|
||||||
## 结论
|
|
||||||
|
|
||||||
本轮不再按页面数量判断缺失,而是按“从哪里进入、填写什么、提交前校验、成功或失败后去哪里、返回是否丢状态、是否需要密码”复审操作链路。
|
|
||||||
|
|
||||||
- 第二轮确认的 14 项参考对比差异仍成立。
|
|
||||||
- 当前项目原有 1 项成长记录误报未保存修改的问题仍成立。
|
|
||||||
- 本轮新增确认 3 项当前流程问题:公开家谱的“申请加入”完全点不进去、谱主无法绑定自己的世系人物、消息中心从家谱首页进入后可见返回出口却固定去“我的”。
|
|
||||||
- 当前未决问题合计为 14 项参考对比差异和 4 项当前流程缺陷,共 18 项。
|
|
||||||
- 密码能力并未缺失,但参考项目与当前项目采用了不同契约:参考项目在创建谱文、重要证件、成长日志时强制设置查看密码和两组密保;当前项目创建时不要求密码,创建后可按单条内容选择设置 8 至 128 位密码,并通过实名手机号短信找回。是否必须恢复参考项目的“创建即强制加密”,需要产品与后端共同确认,不能仅由前端自行改成一样。
|
|
||||||
- 本轮只做审查、点击验证与文档整理,没有修改业务代码、接口契约或测试数据。
|
|
||||||
|
|
||||||
## 操作流程复审表
|
|
||||||
|
|
||||||
| 编号 | 用户流程 | 参考项目 | 当前项目 | 健康度 | 结论 |
|
|
||||||
| --- | --- | --- | --- | --- | --- |
|
|
||||||
| 1 | 登录与找回登录密码 | 账号密码登录;忘记密码使用账号、姓名、生日和新密码 | 支持密码/短信登录;忘记密码使用手机号短信码和新密码 | 基本健康 | 能力已覆盖,身份校验方式不同,不算缺页 |
|
|
||||||
| 2 | 修改登录密码 | 旧密码、新密码、重复新密码 | 旧密码、新密码、重复新密码 | 健康 | 核心步骤一致 |
|
|
||||||
| 3 | 更换手机号 | 输入登录密码和新手机号 | 新手机号加短信验证码 | 基本健康 | 安全契约不同;当前以后端最新短信换绑接口为准 |
|
|
||||||
| 4 | 家谱首页与切换当前家谱 | 首页选择家谱进入 | 首页卡片和“切换当前家谱”弹层均可用 | 健康 | 本轮已点击切换并恢复到“真机联调10159371” |
|
|
||||||
| 5 | 添加家谱入口 | 搜索/邀请码加入和创建家谱 | “搜索家谱”与“继续创建”分流 | 健康 | 当前是融合入口,不需要复制参考页面数量 |
|
|
||||||
| 6 | 创建家谱 | 姓氏、谱名、堂号、始迁祖、地区、简介、访问规则 | 在此基础上还有祖籍地、详细地址、封面;空提交有字段级提示 | 基本健康 | 当前字段更完整;`firstAncestorName` 后端落库仍是既有差异 |
|
|
||||||
| 7 | 未修改创建页直接返回 | 直接返回 | 直接返回,不弹放弃确认 | 健康 | 本轮已点击验证 |
|
|
||||||
| 8 | 邀请码加入 | 输入邀请码后确认 | 搜索页提供邀请码输入、查询和加入结果 | 基本健康 | 入口与校验链已存在;未兑换真实邀请码,避免写入成员数据 |
|
|
||||||
| 9 | 搜索公开家谱并申请加入 | 搜索结果可进入申请流程 | 搜索结果可显示,但“申请加入”点击无任何跳转或提示 | 阻断 | 新增 P0 前端缺陷,详见发现 1 |
|
|
||||||
| 10 | 管理邀请 | 可生成和使用邀请 | 可查看有效期、失效状态并生成新邀请码 | 基本健康 | 本轮看到失效邀请码与“生成新邀请码”;未点击生成,避免写操作 |
|
|
||||||
| 11 | 审核加入申请 | 管理员在消息/管理入口处理 | 独立“申请审核”页,当前数据为空时空状态正常 | 基本健康 | 没有待审样本,未执行同意/拒绝写操作 |
|
|
||||||
| 12 | 成员编辑、角色与人物绑定 | 管理员列表管理成员 | 普通目标成员具备编辑、解绑、移除、转让能力投影 | 不健康 | 当前唯一谱主记录显示未绑定人物且只读,没有另一条自绑定路径,详见发现 2 |
|
|
||||||
| 13 | 世系与成员资料 | 参考项目包含世系、成员和管理员入口 | 当前融合为世系图、成员目录、人物详情、成员状态和证件档案 | 基本健康 | 属于融合升级,不按参考页面数补页 |
|
|
||||||
| 14 | 发布家族动态 | 填写内容并发布 | 发布编辑器可进入;空白未修改时返回不会误弹确认 | 健康 | 本轮点击进入并直接返回验证通过;未提交写操作 |
|
|
||||||
| 15 | 谱文、证件、成长记录的密码查看 | 三类内容创建时强制密码和两组密保,查看前输入密码 | 三类内容均支持单条内容的设置、修改、关闭、解锁和找回 | 待产品确认 | 能力存在,但保护时机、保护粒度和找回方式不同,详见密码矩阵 |
|
|
||||||
| 16 | 图片和视频查看 | 图片展示;视频以封面/条目进入后播放 | 图片预览入口存在;家族视频列表仍直接铺设播放器 | 不健康 | 既有 P0:未达到“先显示封面,点击后播放” |
|
|
||||||
| 17 | 消息中心 | 独立消息导航,含服务入口和申请消息 | 从首页铃铛进入后,页头无返回,底栏高亮“我的”,按钮固定“返回我的” | 有瑕疵 | Android 系统返回可回退,但可见导航与来源不一致,详见发现 3 |
|
|
||||||
| 18 | 家谱设置 | 可进入并编辑 | 首次读取失败,重试仍失败 | 阻断 | 既有 P0,基础设置和永久删除资格仍被同一聚合请求共同阻断 |
|
|
||||||
| 19 | 删除、归档、转让、审批、购买、提现 | 有对应确认或提交步骤 | 前端均有确认与错误状态设计 | 仅代码复审 | 为避免修改真实数据,本轮没有执行不可逆或财务写操作 |
|
|
||||||
|
|
||||||
## 本轮新增确认的问题
|
|
||||||
|
|
||||||
### 1. P0:公开家谱“申请加入”被路由参数校验直接拦截
|
|
||||||
|
|
||||||
点击证据:公开家谱列表正常显示,点击“申请加入”后页面完全不变,也没有错误提示。
|
|
||||||
|
|
||||||
代码原因:
|
|
||||||
|
|
||||||
- `pages/genealogy/search.vue` 打开 `G08` 时传入 `genealogyId` 和 `genealogyName`。
|
|
||||||
- `utils/navigation/routes.js` 的 `G08` 只声明必填 `genealogyId` 和可选 `source`。
|
|
||||||
- 导航网关会拒绝所有未声明参数,因此 `genealogyName` 在调用 `uni.navigateTo` 前就触发异常。
|
|
||||||
- 本轮对所有可静态识别的 `openPage("路由键", { ... })` 调用做了路由字段扫描,只发现这一处字面量参数不匹配。
|
|
||||||
|
|
||||||
完成条件:删除多余的 `genealogyName` 传参,或由 `G08` 契约明确声明并消费该字段;随后加入一条自动化检查,点击公开家谱的“申请加入”必须进入申请表单,返回后仍回到搜索结果。
|
|
||||||
|
|
||||||
证据:
|
|
||||||
|
|
||||||

|
|
||||||
|
|
||||||
### 2. P1:谱主自己的成员记录无法绑定世系人物
|
|
||||||
|
|
||||||
运行时证据:当前测试家谱只有 1 位成员,该成员角色为谱主,亲属关系未填写、世系人物未绑定,页面只显示“当前账号只能查看这位成员”。
|
|
||||||
|
|
||||||
前后端交叉确认:
|
|
||||||
|
|
||||||
- 前端仅在 `member.capabilities.canEdit` 为真时开放编辑器。
|
|
||||||
- 后端能力投影明确令所有谱主目标 `canEdit=false`。
|
|
||||||
- 后端更新接口还会对谱主目标抛出“不能直接修改谱主成员”。
|
|
||||||
- 当前前端、OpenAPI 和后端项目中没有发现单独的“谱主绑定本人世系人物”入口。
|
|
||||||
|
|
||||||
这会造成首次创建家谱后的闭环断点:唯一成员正是谱主,但谱主无法把自己绑定到创建出的始祖或其他世系人物。若产品确实禁止管理员接口编辑谱主,应另设只允许修改本人关系和人物绑定的窄接口,不能开放角色和成员移除能力。
|
|
||||||
|
|
||||||
证据:
|
|
||||||
|
|
||||||

|
|
||||||
|
|
||||||
### 3. P2:消息中心的可见返回方向与实际来源不一致
|
|
||||||
|
|
||||||
消息中心可从家谱首页铃铛进入,路由也允许来源为 `G01`;但页面使用根页头、底栏固定高亮“我的”,空状态和错误状态按钮都写死为“返回我的”。系统返回键仍可回到上一页,所以不是完全阻断,但用户会被可见操作带离原流程。
|
|
||||||
|
|
||||||
完成条件:消息中心保留来源并显示返回;或明确把消息中心定义为独立根入口并统一首页、个人中心和底栏导航语义。
|
|
||||||
|
|
||||||
证据:
|
|
||||||
|
|
||||||

|
|
||||||
|
|
||||||
## 密码流程专项对比
|
|
||||||
|
|
||||||
| 场景 | 参考项目 | 当前项目 | 判定 |
|
|
||||||
| --- | --- | --- | --- |
|
|
||||||
| 密码登录 | 账号加密码 | 手机号加密码,并可切换短信登录 | 已覆盖 |
|
|
||||||
| 忘记登录密码 | 账号、姓名、生日、新密码、确认密码 | 手机号、短信验证码、新密码、确认密码 | 已覆盖,找回凭据不同 |
|
|
||||||
| 修改登录密码 | 旧密码、新密码、确认密码 | 旧密码、新密码、确认密码 | 已覆盖 |
|
|
||||||
| 更换手机号 | 登录密码、新手机号 | 新手机号、当前绑定手机号短信验证 | 已覆盖,后端契约不同 |
|
|
||||||
| 新建谱文 | 查看密码必填且至少 6 位;两组密保问题和答案必填 | 创建时无密码字段;保存后可选择设置 8 至 128 位密码 | 流程差异,待确认 |
|
|
||||||
| 新建重要证件 | 查看密码必填且至少 6 位;两组密保问题和答案必填 | 创建时无密码字段;保存后可选择设置 8 至 128 位密码 | 流程差异,待确认 |
|
|
||||||
| 新建成长日志 | 查看密码必填且至少 6 位;两组密保问题和答案必填 | 创建时无密码字段;保存后可选择设置 8 至 128 位密码 | 流程差异,待确认 |
|
|
||||||
| 查看受保护内容 | 输入该内容的查看密码 | 输入单条内容密码,成功后取得 15 分钟短时访问授权 | 已覆盖,当前授权边界更明确 |
|
|
||||||
| 忘记内容密码 | 回答任意一组密保后重设密码 | 查询实名手机号找回资格、发送短信码、校验后重设 | 已覆盖,但无实名手机号时不可用 |
|
|
||||||
| 修改/关闭内容密码 | 编辑时通过密保修改;参考页面未发现独立关闭操作 | 有权限者可设置、修改或关闭 | 当前能力更完整 |
|
|
||||||
|
|
||||||
参考项目密码弹层证据:
|
|
||||||
|
|
||||||

|
|
||||||
|
|
||||||
当前测试家谱没有受保护的真实内容,因此本轮没有伪造数据,也没有声称已经在 MuMu 中完成错误密码、短信发送和解锁成功的端到端验证。当前侧结论来自页面实现、请求契约、OpenAPI 和后端接口的四方核对;在修复前应准备一条受保护谱文、一条受保护成长记录和一份受保护证件做真机回归。
|
|
||||||
|
|
||||||
## 与第二轮 14 项差异的关系
|
|
||||||
|
|
||||||
第三轮没有推翻第二轮清单,以下差异仍需处理:
|
|
||||||
|
|
||||||
1. 家谱设置整体读取失败。
|
|
||||||
2. 家族视频不是封面卡片进入播放。
|
|
||||||
3. 功德记录媒体后端契约未闭环。
|
|
||||||
4. 礼仪封面未显示。
|
|
||||||
5. 谱文封面未显示。
|
|
||||||
6. 家谱级重要证件聚合入口缺失。
|
|
||||||
7. VIP 订单号、支付时间、到期时间显示不完整。
|
|
||||||
8. 提现单号、审核备注、打款参考号、到账时间显示不完整。
|
|
||||||
9. 推广注册链接和二维码缺失。
|
|
||||||
10. 谱文、礼仪、视频封面缺少明确移除契约。
|
|
||||||
11. 多类参考内容的创建时间字段未覆盖。
|
|
||||||
12. 贺礼簿列表缺少首图。
|
|
||||||
13. “家族恩人”业务语义缺失。
|
|
||||||
14. 创建家谱的始迁祖未由后端落库。
|
|
||||||
|
|
||||||
具体字段、责任方和完成条件继续以 [第二轮独立复审](second-pass-comparison-audit-2026-08-23.md) 为准;本报告补充操作链路和密码差异,不重复制造另一套业务清单。
|
|
||||||
|
|
||||||
## 验证范围与限制
|
|
||||||
|
|
||||||
- 本轮重新点击了家谱切换、添加家谱分流、搜索家谱、创建家谱、创建页空提交校验、创建页返回、家谱总览、邀请码管理、成员列表、申请审核、消息中心、家族动态发布页与返回。
|
|
||||||
- 参考项目重新进入了加入家谱、创建家谱、管理员、消息、世系和受保护内容密码弹层。
|
|
||||||
- 没有执行生成邀请码、提交加入申请、审批、保存、删除、归档、谱主转让、短信发送、支付或提现等写操作。
|
|
||||||
- 两端账号和数据集不同,因此只比较入口、步骤、字段、状态、权限与返回关系,不比较具体数据条数。
|
|
||||||
- 未执行屏幕阅读器、外接键盘焦点顺序和自动对比度测试;本轮不声明完整无障碍验收通过。
|
|
||||||
- 当前受保护内容缺少真机样本;密码错误、短信找回、15 分钟授权过期和三类内容解锁回归仍需在测试数据准备后完成。
|
|
||||||
@@ -1,244 +0,0 @@
|
|||||||
# 后端待处理事项与验收标准
|
|
||||||
|
|
||||||
> 本文件保留前期任务和验收历史。2026-08-24 最新线上复测结论请发送《[后端线上联调故障与数据准备清单](./后端线上联调故障与数据准备清单.md)》,不要再把下文已完成的旧任务整体当作当前待办发送。
|
|
||||||
|
|
||||||
收件人:后端开发、接口维护、测试负责人
|
|
||||||
整理日期:2026-08-23
|
|
||||||
适用项目:家谱 App
|
|
||||||
|
|
||||||
## 2026-08-24 联调状态更新
|
|
||||||
|
|
||||||
- 已确认后端提交 `de4cc9a`(“完成旧版 APP 业务契约闭环”)已进入当前后端 `main` 分支,后端交接单所列八项契约均已落库到正式 App OpenAPI。
|
|
||||||
- 前端已完成对应适配:建谱幂等与始迁祖、封面三态清空、功德图片、家族恩人分类、创建时间、推广链接、换绑当前密码校验以及安全操作后重新登录。
|
|
||||||
- 前端自动检查已通过;MuMu 已点击确认“家族恩人”入口/新建表单、“换绑手机号”的当前密码字段、“应用推广”的复制链接和系统分享入口。
|
|
||||||
- 2026-08-24 21:33 复测:正式域名和 MuMu 网络均已恢复,地区接口返回 `code=200`,登录验证策略接口也返回 `code=200`;但家谱首页、推荐卡仍在 MuMu 读取失败,`user_agreement` 与 `privacy_policy` 两个免登录合规接口均返回 `code=500`、`发生未知异常,请联系管理员`。因此接口数据写入、旧令牌失效及服务端回读仍未完成线上联调验收。请后端核对线上部署提交、迁移数据库与 postcheck 结果,不能把“域名可访问”或“代码已提交”视为“线上联调已通过”。
|
|
||||||
|
|
||||||
下文保留为原始后端任务与回归基线;其中“待处理”表示本轮交接前的状态,最终以本节联调状态为准。
|
|
||||||
|
|
||||||
## 一、总体结论
|
|
||||||
|
|
||||||
前端当前能够独立完成的差异项已经处理并通过代码检查。真机点击回归后,仍有以下后端事项需要处理:
|
|
||||||
|
|
||||||
1. 一项现有接口故障需要修复;
|
|
||||||
2. 八项后端能力或契约需要补齐;
|
|
||||||
3. 七类联调数据需要在测试环境准备;
|
|
||||||
4. 所有完成后的接口契约必须同步回写到 `genealogy-app-openapi.yaml`,不能只改实现、不改文档。
|
|
||||||
|
|
||||||
接口路径和英文字段名属于技术契约,下面保留在括号中;任务名称、业务说明和验收标准均使用中文。
|
|
||||||
|
|
||||||
## 二、最高优先级:修复家谱设置读取
|
|
||||||
|
|
||||||
### 任务名称:家谱基础设置正常读取
|
|
||||||
|
|
||||||
当前现象:进入“家谱设置”后直接显示“家谱设置暂时无法读取”。
|
|
||||||
|
|
||||||
涉及接口:
|
|
||||||
|
|
||||||
- 查询家谱基础资料:`GET /genealogy/app/genealogies/{家谱编号}`
|
|
||||||
- 查询永久注销资格:`GET /genealogy/app/genealogies/{家谱编号}/permanent-deletion/capability`
|
|
||||||
|
|
||||||
处理要求:
|
|
||||||
|
|
||||||
1. 已加入且有查看权限的普通家谱成员必须能够正常读取家谱基础资料。
|
|
||||||
2. 家谱基础资料接口不得依赖永久注销资格接口成功后才返回。
|
|
||||||
3. 普通家谱不满足永久注销条件时,资格接口应正常返回“不可永久注销”和明确原因,不能因为家谱未归档直接抛系统错误。
|
|
||||||
4. 家谱基础资料至少稳定返回:家谱编号、家谱名称、姓氏、地区编码、公开范围、简介、成员数量、世系人物数量、归档状态。
|
|
||||||
5. 权限不足、家谱不存在、参数错误和系统异常必须使用不同的业务错误码。
|
|
||||||
|
|
||||||
验收标准:
|
|
||||||
|
|
||||||
- 测试家谱 `MANUALTEST20260809` 可以正常进入设置表单;
|
|
||||||
- 普通家谱返回“不可永久注销”时,设置表单仍然可以读取和编辑;
|
|
||||||
- 无权限账号被明确拒绝,但不能返回模糊的系统错误。
|
|
||||||
|
|
||||||
## 三、需要补齐的后端能力
|
|
||||||
|
|
||||||
### 任务一:功德记录支持图片
|
|
||||||
|
|
||||||
处理要求:
|
|
||||||
|
|
||||||
- 创建和更新功德记录时接收图片文件编号集合(`mediaOssIds`);
|
|
||||||
- 列表和详情返回有权访问的图片文件集合(`mediaFiles`);
|
|
||||||
- 空集合表示清空全部图片;
|
|
||||||
- 删除、回收站恢复和权限校验必须同步处理文件引用。
|
|
||||||
|
|
||||||
验收标准:新增多图、编辑保留图片、逐张移除、列表显示首图、详情预览全部图片和回收站恢复均正常。
|
|
||||||
|
|
||||||
### 任务二:推广注册链接和二维码
|
|
||||||
|
|
||||||
处理要求:
|
|
||||||
|
|
||||||
- 推广资料接口返回由服务端生成的 HTTPS 注册链接(`shareUrl`);
|
|
||||||
- 链接只能携带可校验、可控制生命周期的推荐凭据,不能直接暴露内部用户编号;
|
|
||||||
- 注册绑定需要防止自我邀请、重复绑定和并发重复写入。
|
|
||||||
|
|
||||||
验收标准:前端可直接复制链接、生成二维码和调用系统分享;扫码注册后推荐关系只绑定一次。
|
|
||||||
|
|
||||||
### 任务三:统一封面清空规则
|
|
||||||
|
|
||||||
适用内容:谱文、礼仪活动、家族视频。
|
|
||||||
|
|
||||||
处理要求:
|
|
||||||
|
|
||||||
- 更新请求中的封面文件编号(`coverOssId`)为 `null` 时,表示删除现有封面;
|
|
||||||
- 请求中不包含该字段时,表示保持原封面不变;
|
|
||||||
- 删除封面后需要释放旧业务文件引用;
|
|
||||||
- 不再使用空字符串、零或其他第二套清空方式。
|
|
||||||
|
|
||||||
验收标准:清空后详情返回的封面文件(`coverFile`)为 `null`,其他业务字段保持不变,旧文件不再保留业务引用。
|
|
||||||
|
|
||||||
### 任务四:统一返回创建时间
|
|
||||||
|
|
||||||
至少需要补齐以下内容的只读创建时间(`createTime`):
|
|
||||||
|
|
||||||
- 相册;
|
|
||||||
- 礼仪活动;
|
|
||||||
- 功德记录;
|
|
||||||
- 成长记录;
|
|
||||||
- 亲友往来记录;
|
|
||||||
- 产品确认后的家族恩人记录。
|
|
||||||
|
|
||||||
处理要求:创建时间由服务端生成,客户端不能提交或修改;业务日期、活动日期和提醒日期不能代替创建时间。
|
|
||||||
|
|
||||||
验收标准:新增后创建时间非空;编辑业务内容不会改变创建时间。
|
|
||||||
|
|
||||||
### 任务五:确定并实现“家族恩人”模型
|
|
||||||
|
|
||||||
产品和后端需要在以下方案中确定唯一方案:
|
|
||||||
|
|
||||||
1. 独立家族恩人档案;
|
|
||||||
2. 家族备忘录中的受控业务分类。
|
|
||||||
|
|
||||||
无论选择哪种方案,都需要明确:身份或类别、说明、图片、创建时间、访问权限、删除与恢复规则。不能只把现有“家族备忘”标题改成“家族恩人”。
|
|
||||||
|
|
||||||
验收标准:OpenAPI、数据库模型、服务实现和前端使用的字段属于同一套契约,不保留两套并行模型。
|
|
||||||
|
|
||||||
### 任务六:创建家谱时建立始迁祖和谱主绑定
|
|
||||||
|
|
||||||
处理要求:
|
|
||||||
|
|
||||||
- 创建家谱请求接收始迁祖姓名(`firstAncestorName`);
|
|
||||||
- 创建家谱、创建第一代世系人物、绑定当前谱主成员必须在同一事务中完成;
|
|
||||||
- 任一步骤失败时整体回滚;
|
|
||||||
- 重复请求不能生成重复人物或重复绑定;
|
|
||||||
- 旧家谱需要提供受权限控制的补绑定路径。
|
|
||||||
|
|
||||||
验收标准:新建家谱后,世系树立即出现始迁祖,且唯一谱主成员具有明确的人物绑定。
|
|
||||||
|
|
||||||
### 任务七:换绑手机号前重新验证当前身份
|
|
||||||
|
|
||||||
当前换绑请求只验证新手机号,不能证明操作人仍掌握当前账号凭据。
|
|
||||||
|
|
||||||
处理要求:
|
|
||||||
|
|
||||||
1. 验证当前登录密码,或者验证近期完成的重新认证票据;
|
|
||||||
2. 验证新手机号短信票据;
|
|
||||||
3. 两项验证通过后才能更新手机号;
|
|
||||||
4. 记录安全审计事件;
|
|
||||||
5. 旧密码错误、认证票据过期、验证码错误和手机号被占用必须分别拒绝。
|
|
||||||
|
|
||||||
验收标准:仅掌握当前登录会话、但不知道密码或没有重新认证票据时,无法换绑手机号。
|
|
||||||
|
|
||||||
### 任务八:修改密码和换绑手机号后使旧会话失效
|
|
||||||
|
|
||||||
处理要求:
|
|
||||||
|
|
||||||
- 修改密码后,使该用户其他既有令牌失效;
|
|
||||||
- 换绑手机号后,建议使全部既有令牌失效,并要求使用新手机号重新登录;
|
|
||||||
- 如果保留当前设备会话,必须明确区分当前令牌和其他令牌;
|
|
||||||
- 会话失效必须由服务端执行,不能只依赖前端清理本地缓存。
|
|
||||||
|
|
||||||
验收标准:安全操作前签发的旧令牌再次访问受保护接口时返回未登录;使用新凭据可以重新登录。
|
|
||||||
|
|
||||||
## 四、测试环境需要准备的联调数据
|
|
||||||
|
|
||||||
以下接口目前能够访问,但返回空列表或缺少关键字段,导致前端无法完成内容态点击验收。请只在测试环境准备数据。
|
|
||||||
|
|
||||||
### 一、平台宣传视频
|
|
||||||
|
|
||||||
接口:`GET /genealogy/app/platform-videos`
|
|
||||||
|
|
||||||
至少准备一条当前时间可见的数据,包含:
|
|
||||||
|
|
||||||
- 视频编号;
|
|
||||||
- 视频标题;
|
|
||||||
- 展示位置;
|
|
||||||
- 平台范围;
|
|
||||||
- 封面文件;
|
|
||||||
- 可播放视频文件;
|
|
||||||
- 视频时长;
|
|
||||||
- 点赞、播放、评论数量;
|
|
||||||
- 当前用户是否点赞。
|
|
||||||
|
|
||||||
验收目标:列表显示真实封面,点击封面后能够打开播放器并播放视频。
|
|
||||||
|
|
||||||
### 二、礼仪活动
|
|
||||||
|
|
||||||
接口:`GET /genealogy/app/genealogies/{家谱编号}/ceremonies`
|
|
||||||
|
|
||||||
为测试家谱准备至少一条带封面的礼仪活动,封面文件必须具有当前用户可访问的临时地址。
|
|
||||||
|
|
||||||
验收目标:礼仪活动列表显示封面,点击卡片可进入详情。
|
|
||||||
|
|
||||||
### 三、谱文
|
|
||||||
|
|
||||||
接口:`GET /genealogy/app/genealogies/{家谱编号}/articles`
|
|
||||||
|
|
||||||
为测试家谱准备至少一条带封面的谱文,并保证分类、标题、摘要、封面文件和权限字段完整。
|
|
||||||
|
|
||||||
验收目标:谱文列表显示封面,点击卡片可进入详情。
|
|
||||||
|
|
||||||
### 四、完整的会员订单
|
|
||||||
|
|
||||||
接口:`GET /genealogy/app/vip/orders`
|
|
||||||
|
|
||||||
当前样本只有套餐、金额、订单号和状态。请准备至少一条完整订单,包含:
|
|
||||||
|
|
||||||
- 订单编号(`orderNo`);
|
|
||||||
- 套餐名称(`packageName`);
|
|
||||||
- 支付金额(`payAmount`);
|
|
||||||
- 支付方式(`payType`);
|
|
||||||
- 支付状态(`payStatus`);
|
|
||||||
- 支付时间(`payTime`);
|
|
||||||
- 失效时间(`expireTime`)。
|
|
||||||
|
|
||||||
验收目标:订单卡完整显示已有字段,不再只能显示基础四项。
|
|
||||||
|
|
||||||
### 五、提现记录
|
|
||||||
|
|
||||||
接口:`GET /genealogy/app/earnings/withdrawals`
|
|
||||||
|
|
||||||
至少准备一条提现记录,包含:提现编号、提现单号、金额、状态、收款人、创建时间;根据状态补充审核备注、打款参考号、到账时间或失败原因。
|
|
||||||
|
|
||||||
验收目标:提现记录页能够验证金额、状态、账户、时间以及各状态的扩展信息。
|
|
||||||
|
|
||||||
### 六、带图片的亲友往来记录
|
|
||||||
|
|
||||||
接口:`GET /genealogy/app/genealogies/{家谱编号}/relative-records`
|
|
||||||
|
|
||||||
至少准备一条当前用户可查看的亲友往来记录,包含:亲友姓名、关系名称、事件、时间、内容和图片文件集合(`mediaFiles`)。
|
|
||||||
|
|
||||||
验收目标:列表使用第一张图片作为缩略图,详情可以查看全部图片。
|
|
||||||
|
|
||||||
### 七、重要证件内容数据
|
|
||||||
|
|
||||||
当前“家谱重要证件”汇总入口已经可以打开,但列表为空。请为测试家谱至少准备一条当前账号有权查看的重要证件,并保证文件授权地址有效。
|
|
||||||
|
|
||||||
验收目标:汇总页能展示证件所属人物、证件名称和文件预览;无权限证件不能泄露。
|
|
||||||
|
|
||||||
## 五、后端完成后必须回传
|
|
||||||
|
|
||||||
请后端完成后统一提供以下材料,便于前端直接复测:
|
|
||||||
|
|
||||||
1. 更新后的正式 App OpenAPI 文件;
|
|
||||||
2. 对应后端提交编号和测试环境部署版本;
|
|
||||||
3. 新增或更新接口的自动化测试结果;
|
|
||||||
4. 上述测试数据对应的测试家谱编号、账号权限和数据编号;
|
|
||||||
5. 家谱设置、平台视频、谱文、礼仪活动、会员订单、提现记录、亲友往来和重要证件的真实响应示例;
|
|
||||||
6. 换绑重新认证和旧令牌失效的安全测试结果。
|
|
||||||
|
|
||||||
## 六、前后端边界
|
|
||||||
|
|
||||||
- 前端已经完成页面入口、空状态、封面展示、播放器入口、订单字段展示、提现字段展示、亲友首图展示和重要证件汇总页。
|
|
||||||
- 后端未完成前,前端不会伪造线上数据,也不会增加第二套临时接口。
|
|
||||||
- 最终字段、状态和错误码以双方确认后的唯一 OpenAPI 契约为准。
|
|
||||||
@@ -1,120 +0,0 @@
|
|||||||
# 后端线上联调故障与数据准备清单
|
|
||||||
|
|
||||||
收件人:后端开发、运维、测试负责人
|
|
||||||
整理日期:2026-08-24
|
|
||||||
联调环境:正式接口 `https://backend-api.ddxcjp.cn`,租户 `000000`
|
|
||||||
|
|
||||||
## 一、结论
|
|
||||||
|
|
||||||
前端已按最新 App OpenAPI 和后端提交 `de4cc9a` 完成适配,并已移除预览数据回退。当前页面中的“无内容”分为两类:
|
|
||||||
|
|
||||||
| 类型 | 数量 | 结论 |
|
|
||||||
| --- | ---: | --- |
|
|
||||||
| 线上接口或配置故障 | 3 项 | 需要后端、运维处理 |
|
|
||||||
| 接口成功但测试数据为空 | 8 类 | 不是前端故障,需要后端测试环境准备数据 |
|
|
||||||
| 本轮确认的前端问题 | 2 项 | 已修复并通过自动检查 |
|
|
||||||
|
|
||||||
本轮前端检查结果:60 个页面与 60 条路由一致;参考项目 78 条功能路由均已有对应关系;导航恢复、契约回归和 12 项运行时资产测试全部通过。
|
|
||||||
|
|
||||||
## 二、必须处理的线上问题
|
|
||||||
|
|
||||||
### 问题一:我的家谱接口无法完成首页读取
|
|
||||||
|
|
||||||
接口:`GET /genealogy/app/genealogies/mine`
|
|
||||||
|
|
||||||
现象:2026-08-24 22:17 将本轮新构建运行到 MuMu 后,已登录账号点击“重新加载”,家谱首页仍稳定进入“暂时无法读取家谱”状态,具体提示为“家谱服务暂时无法读取,请稍后重试”。该登录态访问个人资料和其他家族业务接口能够成功,因此不能归因于模拟器断网或整体登录失效。
|
|
||||||
|
|
||||||
前端已确认:
|
|
||||||
|
|
||||||
- 正式包请求地址和接口路径正确;
|
|
||||||
- 请求失败和成功空数组使用不同页面状态;
|
|
||||||
- 前端没有家谱预览数据或假数据回退;
|
|
||||||
- 本轮已增加错误类型显示。重新编译后,若后端响应结构不符合契约,会显示“服务返回的家谱数据不完整”;网络、登录、权限和服务异常也会分别提示。
|
|
||||||
|
|
||||||
本次实际命中的是服务异常提示,不是网络异常、登录失效、无权限或前端响应结构校验异常。因此排查重点应放在线上接口业务异常、SQL 异常和部署版本,不需要前端放宽字段校验来掩盖故障。
|
|
||||||
|
|
||||||
后端最新代码中,该接口会读取家谱实体并返回 `AppGenealogyVo`。2026-08-24 迁移又为 `gen_genealogy` 增加了 `create_request_id`、`first_ancestor_name`、`root_person_id`。因此请优先排查以下三项:
|
|
||||||
|
|
||||||
1. 线上是否确实部署了提交 `de4cc9a`,而不是只合并到代码仓库;
|
|
||||||
2. 是否按顺序执行 `2026-08-24-app-legacy-parity-closure-precheck.sql`、迁移脚本和后置检查;
|
|
||||||
3. 后置检查的 `app_legacy_parity_postcheck_blocking_count` 和 `blocking_count` 是否都为 `0`。
|
|
||||||
|
|
||||||
同时请用当前测试账号直接调用接口并保存完整响应及服务端异常日志,重点核对每条家谱是否稳定返回:
|
|
||||||
|
|
||||||
- `genealogyId`:正整数;
|
|
||||||
- `genealogyName`:非空;
|
|
||||||
- `memberCount`:非负整数;
|
|
||||||
- `personCount`:空或非负整数;
|
|
||||||
- `visibility`、`joinMode`、`lifecycleStatus`:合法枚举;
|
|
||||||
- `canManage`、`canEditContent`、`canArchive`、`canRestore`:布尔值。
|
|
||||||
|
|
||||||
验收标准:同一账号连续调用三次均返回 `code=200`,`data` 为合法数组;MuMu 点击“重新加载”后正常显示家谱卡片或真实空状态。
|
|
||||||
|
|
||||||
### 问题二:用户协议和隐私政策免登录接口返回 500
|
|
||||||
|
|
||||||
接口:
|
|
||||||
|
|
||||||
- `GET /genealogy/app/compliance/documents/user_agreement`
|
|
||||||
- `GET /genealogy/app/compliance/documents/privacy_policy`
|
|
||||||
|
|
||||||
2026-08-24 复测原始响应,两条接口均为:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{"code":500,"msg":"发生未知异常,请联系管理员","data":null}
|
|
||||||
```
|
|
||||||
|
|
||||||
这是不携带登录令牌即可复现的后端问题,与前端页面、登录态和 MuMu 无关。后端当前实现预期在未发布时返回“合规文档尚未发布”,线上却变成未知异常,说明仍需检查线上表结构、发布数据、租户数据隔离或异常处理。
|
|
||||||
|
|
||||||
请执行并提供以下检查结果:
|
|
||||||
|
|
||||||
1. 合规文档首次发布及换行修复 SQL 的后置检查结果,所有 `blocking_count` 必须为 `0`;
|
|
||||||
2. 租户 `000000` 下两份文档、当前版本、版本状态和内容摘要的查询结果;
|
|
||||||
3. 两条免登录请求对应的后端异常堆栈;
|
|
||||||
4. 确认请求只需要正确的 `clientid` 和租户头,不应依赖登录令牌。
|
|
||||||
|
|
||||||
验收标准:两条接口均返回 `code=200`、非空标题、版本号、正文和内容摘要;不携带 Authorization 仍可读取。
|
|
||||||
|
|
||||||
### 问题三:应用推广资料读取失败
|
|
||||||
|
|
||||||
接口:`GET /genealogy/app/referrals/me`
|
|
||||||
|
|
||||||
现象:2026-08-24 22:21 MuMu 点击进入“应用推广”,推广内容列表能够正常展示,但顶部推荐资料卡明确显示“推荐码暂时无法读取”。这说明页面和推广内容接口正常,故障集中在推荐资料接口。前端已使用后端返回的 `shareUrl`,没有自行拼接内部用户编号。
|
|
||||||
|
|
||||||
后端 `ReferralService.buildShareUrl` 明确依赖当前租户品牌配置中的 `h5Domain`,配置为空、格式错误或不是 HTTPS 都会直接抛出业务异常。请核对租户 `000000` 的启用品牌配置,并确保 `h5Domain` 是可访问的 HTTPS H5 注册地址。
|
|
||||||
|
|
||||||
验收标准:接口返回 `code=200`,包含稳定推荐码、推荐人数、分享标题、分享文案和 HTTPS `shareUrl`;链接不暴露内部用户编号,打开后能进入注册流程并携带推荐凭据。
|
|
||||||
|
|
||||||
## 三、需要准备的联调数据
|
|
||||||
|
|
||||||
以下页面已确认能够区分“读取失败”和“成功但为空”。当前显示无内容,是接口成功返回空数组或零条记录,不属于前端渲染故障。请在测试环境为当前测试家谱准备最小可点击数据:
|
|
||||||
|
|
||||||
| 数据类别 | 当前结果 | 最小验收数据 |
|
|
||||||
| --- | --- | --- |
|
|
||||||
| 家族动态 | 成功,空列表 | 1 条带图片动态、1 条纯文字动态,可进入详情 |
|
|
||||||
| 谱文 | 分类接口可读,谱文为空列表 | 1 篇带封面谱文、1 篇设有内容密码的谱文 |
|
|
||||||
| 礼仪活动 | 成功,空列表 | 1 条带封面活动,可进入详情并读取献礼列表 |
|
|
||||||
| 家族备忘 | 成功,空列表 | `general` 和 `benefactor` 各 1 条 |
|
|
||||||
| 亲友往来 | 成功,空列表 | 1 条带图片记录,可查看完整详情 |
|
|
||||||
| 功德记录 | 成功,空列表 | 1 条带金额和图片记录 |
|
|
||||||
| 家族视频与平台宣传视频 | 家族视频为空;首页平台视频因家谱首页故障暂不能完成内容态验收 | 各 1 条带封面且视频地址可播放的数据,并正确配置平台视频投放位 |
|
|
||||||
| 相册照片 | 已有相册 `CHECKDELETE01`,照片数为 0 | 在该相册中加入至少 2 张当前账号可访问的图片 |
|
|
||||||
|
|
||||||
这些数据的文件字段必须返回当前账号可访问的 HTTPS 地址,不能只返回 OSS 文件编号。
|
|
||||||
|
|
||||||
## 四、本轮前端已处理
|
|
||||||
|
|
||||||
1. 家谱首页不再把所有异常统一显示成“网络或服务不可用”,现在会区分网络、登录、权限、服务失败和响应结构不完整。
|
|
||||||
2. 谱文分类接口失败时不再静默转换为空分类;页面会明确提示“分类读取失败”,同时保留已成功读取的全部谱文。
|
|
||||||
3. 已增加对应回归检查,防止以后再次把请求失败伪装为空数据。
|
|
||||||
|
|
||||||
## 五、后端回传材料
|
|
||||||
|
|
||||||
完成后请一次性提供:
|
|
||||||
|
|
||||||
1. 线上实际部署提交编号和部署时间;
|
|
||||||
2. 2026-08-24 迁移及所有相关后置检查结果,最终 `blocking_count=0`;
|
|
||||||
3. 上述三项故障接口的完整响应示例和对应服务端日志结论;
|
|
||||||
4. 测试数据所属家谱编号、数据编号和账号权限;
|
|
||||||
5. 更新后的正式 App OpenAPI(如接口字段、枚举或错误码发生变化)。
|
|
||||||
|
|
||||||
仅提供“代码已提交”“自动测试通过”或“域名可以访问”不能作为线上联调完成依据,最终以同一部署环境中的接口响应和 MuMu 点击回归为准。
|
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
# 项目交接说明
|
||||||
|
|
||||||
|
更新日期:2026-09-17
|
||||||
|
|
||||||
|
## 当前状态
|
||||||
|
|
||||||
|
这是一个 uni-app / Vue 3 家谱应用,现有 62 个页面。登录、家谱、成员树、家族内容、人物档案、消息和个人中心均已接入页面路由。
|
||||||
|
|
||||||
|
这次清理移除了此前留下的联调、审计、回归和页面对比记录,也删除了资源清单的两份单元测试。项目保留了运行时检查,当前工作区校验通过。
|
||||||
|
|
||||||
|
## 从哪里开始看
|
||||||
|
|
||||||
|
- `pages.json`:页面注册和页面级配置。
|
||||||
|
- `utils/navigation/routes.js`:业务跳转使用的路由定义。新增或删改页面时,这里要和 `pages.json` 一起改。
|
||||||
|
- `services/api/`:接口调用和接口数据整理。一般一个业务模块对应一组 `*-service.js`、`*-contract.js` 文件。
|
||||||
|
- `services/api/request-client.js`:请求入口,处理登录失效、响应格式和上传请求。
|
||||||
|
- `utils/runtime-config.js`:线上接口地址、客户端标识和租户标识。当前写的是正式环境配置。
|
||||||
|
- `static/assets/`:页面实际使用的图片资源;`design-pipeline/manifests/` 保存这些资源的校验清单。
|
||||||
|
|
||||||
|
## 常用检查
|
||||||
|
|
||||||
|
在根目录运行:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm.cmd run check
|
||||||
|
```
|
||||||
|
|
||||||
|
PowerShell 的执行策略可能会拦截 `npm`,所以这里固定使用 `npm.cmd`。该命令目前会检查:
|
||||||
|
|
||||||
|
- 页面文件与路由注册是否匹配;
|
||||||
|
- 几处关键页面和导航恢复逻辑是否被误改;
|
||||||
|
- 静态资源文件是否和资源清单一致。
|
||||||
|
|
||||||
|
2026-09-17 的执行结果:
|
||||||
|
|
||||||
|
```text
|
||||||
|
PROJECT CHECK PASS pages=62 routes=62 sources=182
|
||||||
|
AUDIT REGRESSION CHECK PASS icons=17
|
||||||
|
NAVIGATION RECOVERY CHECK PASS
|
||||||
|
RUNTIME ASSET CHECK PASS manifests=4 assets=102
|
||||||
|
```
|
||||||
|
|
||||||
|
页面交互、上传或端能力相关的修改,仍需要在 HBuilderX 和目标端实际走一遍;这里的检查不能代替真机验证。
|
||||||
|
|
||||||
|
## 这次留下的改动
|
||||||
|
|
||||||
|
- 原 `docs/` 中的 12 份过程记录已删除。
|
||||||
|
- `design-pipeline/tests/` 下的两份测试已删除,`design-pipeline` 的 `check` 现在只做资源清单校验。
|
||||||
|
- 根目录已移除 `check:parity`,因为它依赖已删掉的页面对比记录。
|
||||||
|
- `genealogy-app-openapi.yaml` 仍保留。它记录接口描述,改服务层前先核对它和现有 `services/api/` 实现是否一致。
|
||||||
|
|
||||||
|
提交前先看一次 `git status`。当前工作区包含上述删除和命令调整,确认无误后再提交。
|
||||||
@@ -4857,6 +4857,7 @@ components:
|
|||||||
deletePermanentlyDisabledReasons: { type: array, items: { type: string } }
|
deletePermanentlyDisabledReasons: { type: array, items: { type: string } }
|
||||||
verifiedMobileMasked: { type: string, nullable: true }
|
verifiedMobileMasked: { type: string, nullable: true }
|
||||||
memberStatus: { type: string }
|
memberStatus: { type: string }
|
||||||
|
createTime: { type: string, format: date-time, nullable: true, readOnly: true, description: 家谱记录的真实创建时间;不同成员读取同一家谱时一致 }
|
||||||
joinTime: { type: string, format: date-time, nullable: true }
|
joinTime: { type: string, format: date-time, nullable: true }
|
||||||
AppGrowthRecord:
|
AppGrowthRecord:
|
||||||
type: object
|
type: object
|
||||||
@@ -5000,7 +5001,8 @@ components:
|
|||||||
articleTitle: { type: string }
|
articleTitle: { type: string }
|
||||||
articleSummary: { type: string }
|
articleSummary: { type: string }
|
||||||
coverFile: { $ref: '#/components/schemas/BusinessFileAccess' }
|
coverFile: { $ref: '#/components/schemas/BusinessFileAccess' }
|
||||||
articleContent: { type: string }
|
mediaFiles: { type: array, description: 有序正文图片;未解锁时为空数组;不含永久公开地址, items: { $ref: '#/components/schemas/BusinessFileAccess' } }
|
||||||
|
articleContent: { type: string, nullable: true, description: 未解锁时为 null }
|
||||||
authorName: { type: string }
|
authorName: { type: string }
|
||||||
publishTime: { type: string, format: date-time, nullable: true }
|
publishTime: { type: string, format: date-time, nullable: true }
|
||||||
viewCount: { type: integer, format: int64 }
|
viewCount: { type: integer, format: int64 }
|
||||||
@@ -6277,6 +6279,11 @@ components:
|
|||||||
articleContent:
|
articleContent:
|
||||||
description: 文章内容
|
description: 文章内容
|
||||||
type: string
|
type: string
|
||||||
|
mediaOssIds:
|
||||||
|
type: string
|
||||||
|
nullable: true
|
||||||
|
description: 正文图片OSS ID用英文逗号分隔;创建缺失或空值为无图;更新省略保持,null或空白清空;非空整体替换,去除两侧空格并按首次出现顺序去重;仅允许正整数且规范值总长度不超过1000字符
|
||||||
|
example: '2060000000000000002,2060000000000000003'
|
||||||
authorName:
|
authorName:
|
||||||
description: 作者名称
|
description: 作者名称
|
||||||
type: string
|
type: string
|
||||||
|
|||||||
@@ -5,10 +5,19 @@
|
|||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "jiapuapp",
|
"name": "jiapuapp",
|
||||||
|
"dependencies": {
|
||||||
|
"qrcode-generator": "^1.4.4"
|
||||||
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"yaml": "^2.8.1"
|
"yaml": "^2.8.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/qrcode-generator": {
|
||||||
|
"version": "1.4.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/qrcode-generator/-/qrcode-generator-1.4.4.tgz",
|
||||||
|
"integrity": "sha512-HM7yY8O2ilqhmULxGMpcHSF1EhJJ9yBj8gvDEuZ6M+KGJ0YY2hKpnXvRD+hZPLrDVck3ExIGhmPtSdcjC+guuw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/yaml": {
|
"node_modules/yaml": {
|
||||||
"version": "2.8.1",
|
"version": "2.8.1",
|
||||||
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.1.tgz",
|
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.1.tgz",
|
||||||
|
|||||||
@@ -2,13 +2,15 @@
|
|||||||
"name": "jiapuapp",
|
"name": "jiapuapp",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"check": "node scripts/check-project.mjs && node scripts/check-audit-regressions.mjs && node scripts/check-navigation-recovery.mjs && node scripts/check-frontend-parity.mjs && npm --prefix design-pipeline run check",
|
"check": "node scripts/check-project.mjs && node scripts/check-audit-regressions.mjs && node scripts/check-navigation-recovery.mjs && npm --prefix design-pipeline run check",
|
||||||
"check:project": "node scripts/check-project.mjs",
|
"check:project": "node scripts/check-project.mjs",
|
||||||
"check:regressions": "node scripts/check-audit-regressions.mjs",
|
"check:regressions": "node scripts/check-audit-regressions.mjs",
|
||||||
"check:parity": "node scripts/check-frontend-parity.mjs",
|
|
||||||
"check:assets": "node design-pipeline/scripts/validate-runtime-asset-inventory.mjs design-pipeline/manifests/runtime-assets.json"
|
"check:assets": "node design-pipeline/scripts/validate-runtime-asset-inventory.mjs design-pipeline/manifests/runtime-assets.json"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"yaml": "^2.8.1"
|
"yaml": "^2.8.1"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"qrcode-generator": "^1.4.4"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -136,6 +136,18 @@
|
|||||||
"navigationStyle": "custom"
|
"navigationStyle": "custom"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"path": "pages/family/site-home",
|
||||||
|
"style": {
|
||||||
|
"navigationStyle": "custom"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "pages/family/site-article-list",
|
||||||
|
"style": {
|
||||||
|
"navigationStyle": "custom"
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"path": "pages/family/feed",
|
"path": "pages/family/feed",
|
||||||
"style": {
|
"style": {
|
||||||
|
|||||||
@@ -197,6 +197,8 @@
|
|||||||
</view>
|
</view>
|
||||||
<!-- #endif -->
|
<!-- #endif -->
|
||||||
|
|
||||||
|
<LoginAdvertisement />
|
||||||
|
|
||||||
<view class="register-entry">
|
<view class="register-entry">
|
||||||
<text>还没有账号?</text>
|
<text>还没有账号?</text>
|
||||||
<button
|
<button
|
||||||
@@ -278,6 +280,7 @@
|
|||||||
import { ref } from "vue";
|
import { ref } from "vue";
|
||||||
import { onBackPress, onShow, onUnload } from "@dcloudio/uni-app";
|
import { onBackPress, onShow, onUnload } from "@dcloudio/uni-app";
|
||||||
import AuthPageShell from "@/components/auth/PageShell.vue";
|
import AuthPageShell from "@/components/auth/PageShell.vue";
|
||||||
|
import LoginAdvertisement from "@/components/auth/LoginAdvertisement.vue";
|
||||||
import AppToast from "@/components/AppToast.vue";
|
import AppToast from "@/components/AppToast.vue";
|
||||||
import TacVerification from "@/components/auth/TacVerification.vue";
|
import TacVerification from "@/components/auth/TacVerification.vue";
|
||||||
import {
|
import {
|
||||||
@@ -777,11 +780,23 @@ const submitWechatLogin = async () => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped lang="scss">
|
<style scoped lang="scss">
|
||||||
|
.login-page {
|
||||||
|
height: var(--app-viewport-height);
|
||||||
|
min-height: 0;
|
||||||
|
max-height: var(--app-viewport-height);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-page :deep(.auth-shell__paper) {
|
||||||
|
min-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
.login-content {
|
.login-content {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
justify-content: space-between;
|
justify-content: flex-start;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-width: 480px;
|
max-width: 480px;
|
||||||
@@ -817,6 +832,7 @@ const submitWechatLogin = async () => {
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: flex-end;
|
align-items: flex-end;
|
||||||
min-height: var(--app-touch-min);
|
min-height: var(--app-touch-min);
|
||||||
|
margin-top: 6rpx;
|
||||||
}
|
}
|
||||||
|
|
||||||
.login-tab {
|
.login-tab {
|
||||||
@@ -825,7 +841,7 @@ const submitWechatLogin = async () => {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
min-height: 88rpx;
|
min-height: 88rpx;
|
||||||
color: #80684f;
|
color: #5d493b;
|
||||||
font-size: clamp(19px, 34rpx, 24px);
|
font-size: clamp(19px, 34rpx, 24px);
|
||||||
letter-spacing: 2rpx;
|
letter-spacing: 2rpx;
|
||||||
}
|
}
|
||||||
@@ -893,7 +909,7 @@ const submitWechatLogin = async () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.input-placeholder {
|
.input-placeholder {
|
||||||
color: #9f968d;
|
color: #766c63;
|
||||||
}
|
}
|
||||||
|
|
||||||
.password-toggle {
|
.password-toggle {
|
||||||
@@ -925,7 +941,7 @@ const submitWechatLogin = async () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.get-code--disabled {
|
.get-code--disabled {
|
||||||
color: #9f968d;
|
color: #766c63;
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-secondary-row {
|
.form-secondary-row {
|
||||||
@@ -945,6 +961,7 @@ const submitWechatLogin = async () => {
|
|||||||
|
|
||||||
.login-submit {
|
.login-submit {
|
||||||
display: grid;
|
display: grid;
|
||||||
|
margin-top: 26rpx;
|
||||||
place-items: center;
|
place-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -973,6 +990,7 @@ const submitWechatLogin = async () => {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
min-height: var(--app-touch-min);
|
min-height: var(--app-touch-min);
|
||||||
|
margin-top: 8rpx;
|
||||||
color: #493323;
|
color: #493323;
|
||||||
font-size: clamp(16px, 28rpx, 20px);
|
font-size: clamp(16px, 28rpx, 20px);
|
||||||
}
|
}
|
||||||
@@ -987,8 +1005,9 @@ const submitWechatLogin = async () => {
|
|||||||
|
|
||||||
.agreement-area {
|
.agreement-area {
|
||||||
position: relative;
|
position: relative;
|
||||||
|
margin-top: 2rpx;
|
||||||
margin-bottom: 48rpx;
|
margin-bottom: 48rpx;
|
||||||
color: #58483c;
|
color: #493323;
|
||||||
}
|
}
|
||||||
|
|
||||||
.agreement-row {
|
.agreement-row {
|
||||||
@@ -1041,7 +1060,7 @@ const submitWechatLogin = async () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.third-party-login__divider {
|
.third-party-login__divider {
|
||||||
color: rgba(76, 57, 41, 0.62);
|
color: #5f4b3c;
|
||||||
font-size: clamp(12px, 21rpx, 15px);
|
font-size: clamp(12px, 21rpx, 15px);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1059,7 +1078,7 @@ const submitWechatLogin = async () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.wechat-login[disabled] {
|
.wechat-login[disabled] {
|
||||||
opacity: 0.52;
|
opacity: 0.68;
|
||||||
}
|
}
|
||||||
|
|
||||||
.wechat-login__mark {
|
.wechat-login__mark {
|
||||||
|
|||||||
@@ -42,6 +42,11 @@
|
|||||||
<text class="article-card__content">{{
|
<text class="article-card__content">{{
|
||||||
article.contentProtected && !article.contentUnlocked ? "" : article.content || "作者暂未填写正文。"
|
article.contentProtected && !article.contentUnlocked ? "" : article.content || "作者暂未填写正文。"
|
||||||
}}</text>
|
}}</text>
|
||||||
|
<FamilyFeedMedia
|
||||||
|
v-if="!article.contentProtected || article.contentUnlocked"
|
||||||
|
:files="article.mediaFiles"
|
||||||
|
image-label="查看谱文正文图片"
|
||||||
|
/>
|
||||||
<text class="article-card__views">阅读 {{ article.viewCount }} 次</text>
|
<text class="article-card__views">阅读 {{ article.viewCount }} 次</text>
|
||||||
<view
|
<view
|
||||||
v-if="article.canEdit || article.canDelete || article.canManageProtection"
|
v-if="article.canEdit || article.canDelete || article.canManageProtection"
|
||||||
@@ -130,6 +135,7 @@ import AppButton from "@/components/AppButton.vue";
|
|||||||
import AppDialog from "@/components/AppDialog.vue";
|
import AppDialog from "@/components/AppDialog.vue";
|
||||||
import AppLoading from "@/components/AppLoading.vue";
|
import AppLoading from "@/components/AppLoading.vue";
|
||||||
import ContentPasswordRecoveryDialog from "@/components/ContentPasswordRecoveryDialog.vue";
|
import ContentPasswordRecoveryDialog from "@/components/ContentPasswordRecoveryDialog.vue";
|
||||||
|
import FamilyFeedMedia from "@/components/family/FeedMedia.vue";
|
||||||
import ModulePageBackground from "@/components/ModulePageBackground.vue";
|
import ModulePageBackground from "@/components/ModulePageBackground.vue";
|
||||||
import PageHeader from "@/components/PageHeader.vue";
|
import PageHeader from "@/components/PageHeader.vue";
|
||||||
import {
|
import {
|
||||||
|
|||||||
@@ -94,6 +94,35 @@
|
|||||||
/>
|
/>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
<view class="editor-field editor-field--media">
|
||||||
|
<view>
|
||||||
|
<text class="editor-field__label">正文图片</text>
|
||||||
|
<text class="editor-field__hint">图片按添加顺序显示,可单独移除。</text>
|
||||||
|
</view>
|
||||||
|
<button
|
||||||
|
class="upload-button"
|
||||||
|
:disabled="uploading || isSubmitting"
|
||||||
|
@click="uploadArticleMedia"
|
||||||
|
>
|
||||||
|
{{ uploading ? "上传中…" : "添加正文图片" }}
|
||||||
|
</button>
|
||||||
|
<view v-if="mediaReceipts.length" class="body-media-list">
|
||||||
|
<view
|
||||||
|
v-for="(receipt, index) in mediaReceipts"
|
||||||
|
:key="`${receipt.ossId}-${index}`"
|
||||||
|
class="body-media-item"
|
||||||
|
>
|
||||||
|
<text>{{ index + 1 }}. {{ receipt.fileName || "正文图片" }}</text>
|
||||||
|
<button
|
||||||
|
class="remove-media-button"
|
||||||
|
:disabled="uploading || isSubmitting"
|
||||||
|
:aria-label="`移除第${index + 1}张正文图片`"
|
||||||
|
@click="removeArticleMedia(index)"
|
||||||
|
>移除</button>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
<text v-if="mediaUploadError" class="editor-save-error">{{ mediaUploadError }}</text>
|
||||||
|
</view>
|
||||||
<view class="editor-field">
|
<view class="editor-field">
|
||||||
<text class="editor-field__label">作者名称</text>
|
<text class="editor-field__label">作者名称</text>
|
||||||
<view class="editor-control"
|
<view class="editor-control"
|
||||||
@@ -104,6 +133,30 @@
|
|||||||
@input="submitError = ''"
|
@input="submitError = ''"
|
||||||
/></view>
|
/></view>
|
||||||
</view>
|
</view>
|
||||||
|
<view v-if="!isEdit" class="editor-field">
|
||||||
|
<text class="editor-field__label">内容密码(选填)</text>
|
||||||
|
<text class="editor-field__hint">填写后会先创建谱文,再立即启用密码保护;密码设置失败时会明确提示。</text>
|
||||||
|
<view class="editor-control">
|
||||||
|
<input
|
||||||
|
v-model="contentPassword"
|
||||||
|
password
|
||||||
|
maxlength="128"
|
||||||
|
placeholder="请输入8至128位内容密码"
|
||||||
|
placeholder-class="editor-placeholder"
|
||||||
|
@input="submitError = ''"
|
||||||
|
/>
|
||||||
|
</view>
|
||||||
|
<view class="editor-control editor-control--password-confirm">
|
||||||
|
<input
|
||||||
|
v-model="contentPasswordConfirm"
|
||||||
|
password
|
||||||
|
maxlength="128"
|
||||||
|
placeholder="请再次输入内容密码"
|
||||||
|
placeholder-class="editor-placeholder"
|
||||||
|
@input="submitError = ''"
|
||||||
|
/>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
<text v-if="submitError" class="editor-save-error">{{
|
<text v-if="submitError" class="editor-save-error">{{
|
||||||
submitError
|
submitError
|
||||||
}}</text>
|
}}</text>
|
||||||
@@ -178,8 +231,14 @@ const uploading = ref(false);
|
|||||||
const discardVisible = ref(false);
|
const discardVisible = ref(false);
|
||||||
const submitError = ref("");
|
const submitError = ref("");
|
||||||
const uploadError = ref("");
|
const uploadError = ref("");
|
||||||
|
const mediaUploadError = ref("");
|
||||||
const coverOssId = ref(null);
|
const coverOssId = ref(null);
|
||||||
const coverFileName = ref("");
|
const coverFileName = ref("");
|
||||||
|
const mediaReceipts = ref([]);
|
||||||
|
const contentPassword = ref("");
|
||||||
|
const contentPasswordConfirm = ref("");
|
||||||
|
const passwordSetupError = ref("");
|
||||||
|
const passwordProtectionEnabled = ref(false);
|
||||||
const categoryOptionsState = ref("loading");
|
const categoryOptionsState = ref("loading");
|
||||||
const categoryOptions = ref([]);
|
const categoryOptions = ref([]);
|
||||||
const preservedUpdateFields = ref({ sortOrder: null, status: "" });
|
const preservedUpdateFields = ref({ sortOrder: null, status: "" });
|
||||||
@@ -194,7 +253,9 @@ const form = reactive({
|
|||||||
const articleDetailController = createRequestController();
|
const articleDetailController = createRequestController();
|
||||||
const articleCategoryController = createRequestController();
|
const articleCategoryController = createRequestController();
|
||||||
const articleCoverUploadController = createRequestController();
|
const articleCoverUploadController = createRequestController();
|
||||||
|
const articleMediaUploadController = createRequestController();
|
||||||
const articleSaveController = createRequestController();
|
const articleSaveController = createRequestController();
|
||||||
|
const articlePasswordController = createRequestController();
|
||||||
const articleCreateGuard = createNonIdempotentWriteGuard();
|
const articleCreateGuard = createNonIdempotentWriteGuard();
|
||||||
let pageActive = true;
|
let pageActive = true;
|
||||||
const categoryOptionLabels = computed(() => ["不设置分类", ...categoryOptions.value.map((item) => item.name)]);
|
const categoryOptionLabels = computed(() => ["不设置分类", ...categoryOptions.value.map((item) => item.name)]);
|
||||||
@@ -204,13 +265,20 @@ const categoryOptionIndex = computed(() => {
|
|||||||
});
|
});
|
||||||
const categoryOptionLabel = computed(() => categoryOptionLabels.value[categoryOptionIndex.value] || "不设置分类");
|
const categoryOptionLabel = computed(() => categoryOptionLabels.value[categoryOptionIndex.value] || "不设置分类");
|
||||||
const isEdit = computed(() => mode.value === "edit");
|
const isEdit = computed(() => mode.value === "edit");
|
||||||
|
const mediaOssIds = computed(() => mediaReceipts.value.map((item) => item.ossId).join(","));
|
||||||
const formSnapshot = computed(() =>
|
const formSnapshot = computed(() =>
|
||||||
JSON.stringify({ ...form, coverOssId: coverOssId.value || "" }),
|
JSON.stringify({ ...form, coverOssId: coverOssId.value || "", mediaOssIds: mediaOssIds.value }),
|
||||||
);
|
);
|
||||||
const isDirty = computed(() =>
|
const isDirty = computed(() =>
|
||||||
isEdit.value
|
isEdit.value
|
||||||
? Boolean(formBaseline.value) && formSnapshot.value !== formBaseline.value
|
? Boolean(formBaseline.value) && formSnapshot.value !== formBaseline.value
|
||||||
: Boolean(Object.values(form).some((value) => value.trim()) || coverOssId.value),
|
: Boolean(
|
||||||
|
Object.values(form).some((value) => value.trim()) ||
|
||||||
|
coverOssId.value ||
|
||||||
|
mediaReceipts.value.length ||
|
||||||
|
contentPassword.value ||
|
||||||
|
contentPasswordConfirm.value
|
||||||
|
),
|
||||||
);
|
);
|
||||||
const hasValidContext = computed(
|
const hasValidContext = computed(
|
||||||
() =>
|
() =>
|
||||||
@@ -219,10 +287,19 @@ const hasValidContext = computed(
|
|||||||
);
|
);
|
||||||
const resultCopy = computed(() =>
|
const resultCopy = computed(() =>
|
||||||
editorState.value === "success"
|
editorState.value === "success"
|
||||||
|
? passwordSetupError.value
|
||||||
? {
|
? {
|
||||||
|
eyebrow: "谱文已创建",
|
||||||
|
title: "密码保护尚未确认",
|
||||||
|
copy: passwordSetupError.value,
|
||||||
|
action: "打开谱文详情",
|
||||||
|
}
|
||||||
|
: {
|
||||||
eyebrow: "保存成功",
|
eyebrow: "保存成功",
|
||||||
title: isEdit.value ? "谱文已更新" : "谱文已提交",
|
title: isEdit.value ? "谱文已更新" : "谱文已提交",
|
||||||
copy: "已保存,返回后会显示最新内容。",
|
copy: !isEdit.value && passwordProtectionEnabled.value
|
||||||
|
? "谱文已保存,并已启用内容密码。"
|
||||||
|
: "已保存,返回后会显示最新内容。",
|
||||||
action: isEdit.value ? "返回谱文详情" : "返回谱文列表",
|
action: isEdit.value ? "返回谱文详情" : "返回谱文列表",
|
||||||
}
|
}
|
||||||
: editorState.value === "error"
|
: editorState.value === "error"
|
||||||
@@ -313,6 +390,10 @@ const loadArticleForEdit = async () => {
|
|||||||
coverFileName.value = article.coverFile
|
coverFileName.value = article.coverFile
|
||||||
? article.coverFile.fileName || "当前封面图片"
|
? article.coverFile.fileName || "当前封面图片"
|
||||||
: "";
|
: "";
|
||||||
|
mediaReceipts.value = article.mediaFiles.map((file) => ({
|
||||||
|
ossId: file.ossId,
|
||||||
|
fileName: file.fileName || "正文图片",
|
||||||
|
}));
|
||||||
preservedUpdateFields.value = {
|
preservedUpdateFields.value = {
|
||||||
sortOrder: article.sortOrder,
|
sortOrder: article.sortOrder,
|
||||||
status: article.status,
|
status: article.status,
|
||||||
@@ -352,6 +433,35 @@ const clearCover = () => {
|
|||||||
uploadError.value = "";
|
uploadError.value = "";
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const uploadArticleMedia = async () => {
|
||||||
|
if (uploading.value || isSubmitting.value) return;
|
||||||
|
uploading.value = true;
|
||||||
|
mediaUploadError.value = "";
|
||||||
|
try {
|
||||||
|
const receipt = await pickAndUploadImage({
|
||||||
|
requestController: articleMediaUploadController,
|
||||||
|
});
|
||||||
|
if (!pageActive) return;
|
||||||
|
if (mediaReceipts.value.some((item) => item.ossId === receipt.ossId)) {
|
||||||
|
mediaUploadError.value = "这张图片已经添加。";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
mediaReceipts.value = [...mediaReceipts.value, receipt];
|
||||||
|
} catch (error) {
|
||||||
|
if (pageActive && !isImagePickCancelled(error) && !isRequestCancelled(error)) {
|
||||||
|
mediaUploadError.value = getRequestErrorMessage(error, "正文图片上传失败,请稍后重试。");
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (pageActive) uploading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeArticleMedia = (index) => {
|
||||||
|
if (uploading.value || isSubmitting.value || !Number.isInteger(index)) return;
|
||||||
|
mediaReceipts.value = mediaReceipts.value.filter((_, mediaIndex) => mediaIndex !== index);
|
||||||
|
mediaUploadError.value = "";
|
||||||
|
};
|
||||||
|
|
||||||
const saveArticle = async () => {
|
const saveArticle = async () => {
|
||||||
if (isSubmitting.value || uploading.value || !hasValidContext.value) return;
|
if (isSubmitting.value || uploading.value || !hasValidContext.value) return;
|
||||||
if (!form.articleTitle.trim() || !form.articleContent.trim()) {
|
if (!form.articleTitle.trim() || !form.articleContent.trim()) {
|
||||||
@@ -363,6 +473,7 @@ const saveArticle = async () => {
|
|||||||
const payload = {
|
const payload = {
|
||||||
...form,
|
...form,
|
||||||
coverOssId: coverOssId.value,
|
coverOssId: coverOssId.value,
|
||||||
|
mediaOssIds: mediaOssIds.value,
|
||||||
...(isEdit.value ? preservedUpdateFields.value : {}),
|
...(isEdit.value ? preservedUpdateFields.value : {}),
|
||||||
};
|
};
|
||||||
const createAttempt = isEdit.value ? null : articleCreateGuard.begin(payload);
|
const createAttempt = isEdit.value ? null : articleCreateGuard.begin(payload);
|
||||||
@@ -371,18 +482,48 @@ const saveArticle = async () => {
|
|||||||
"上次提交结果暂时无法确认,请先返回谱文列表检查,避免重复创建。";
|
"上次提交结果暂时无法确认,请先返回谱文列表检查,避免重复创建。";
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (contentPassword.value || contentPasswordConfirm.value) {
|
||||||
|
if (contentPassword.value.length < 8 || contentPassword.value.length > 128) {
|
||||||
|
submitError.value = "内容密码必须为8至128位。";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (contentPassword.value !== contentPasswordConfirm.value) {
|
||||||
|
submitError.value = "两次输入的内容密码不一致。";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
isSubmitting.value = true;
|
isSubmitting.value = true;
|
||||||
submitError.value = "";
|
submitError.value = "";
|
||||||
|
passwordSetupError.value = "";
|
||||||
try {
|
try {
|
||||||
if (isEdit.value) {
|
if (isEdit.value) {
|
||||||
await familyArticleApi.updateArticle(genealogyId.value, articleId.value, payload, {
|
await familyArticleApi.updateArticle(genealogyId.value, articleId.value, payload, {
|
||||||
requestController: articleSaveController,
|
requestController: articleSaveController,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
await familyArticleApi.createArticle(genealogyId.value, payload, {
|
const createdArticle = await familyArticleApi.createArticle(genealogyId.value, payload, {
|
||||||
requestController: articleSaveController,
|
requestController: articleSaveController,
|
||||||
});
|
});
|
||||||
|
if (!pageActive) return;
|
||||||
|
articleId.value = createdArticle.id;
|
||||||
|
if (contentPassword.value) {
|
||||||
|
try {
|
||||||
|
await familyArticleApi.setArticlePassword(
|
||||||
|
genealogyId.value,
|
||||||
|
createdArticle.id,
|
||||||
|
contentPassword.value,
|
||||||
|
{ requestController: articlePasswordController },
|
||||||
|
);
|
||||||
|
passwordProtectionEnabled.value = true;
|
||||||
|
} catch (passwordError) {
|
||||||
|
if (!pageActive || isRequestCancelled(passwordError)) return;
|
||||||
|
const failureCopy = getRequestErrorMessage(passwordError, "内容密码设置失败");
|
||||||
|
passwordSetupError.value = `谱文已经创建,但${failureCopy}。请进入详情重新设置;当前内容可能尚未受到密码保护。`;
|
||||||
|
}
|
||||||
|
contentPassword.value = "";
|
||||||
|
contentPasswordConfirm.value = "";
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (!pageActive) return;
|
if (!pageActive) return;
|
||||||
editorState.value = "success";
|
editorState.value = "success";
|
||||||
@@ -412,7 +553,7 @@ const requestBack = () =>
|
|||||||
});
|
});
|
||||||
const handleResultAction = () =>
|
const handleResultAction = () =>
|
||||||
editorState.value === "success"
|
editorState.value === "success"
|
||||||
? isEdit.value
|
? isEdit.value || passwordSetupError.value
|
||||||
? returnTo("F05", { genealogyId: genealogyId.value, articleId: articleId.value })
|
? returnTo("F05", { genealogyId: genealogyId.value, articleId: articleId.value })
|
||||||
: returnTo("F04", { genealogyId: genealogyId.value })
|
: returnTo("F04", { genealogyId: genealogyId.value })
|
||||||
: isEdit.value
|
: isEdit.value
|
||||||
@@ -424,7 +565,9 @@ onUnload(() => {
|
|||||||
articleDetailController.abort();
|
articleDetailController.abort();
|
||||||
articleCategoryController.abort();
|
articleCategoryController.abort();
|
||||||
articleCoverUploadController.abort();
|
articleCoverUploadController.abort();
|
||||||
|
articleMediaUploadController.abort();
|
||||||
articleSaveController.abort();
|
articleSaveController.abort();
|
||||||
|
articlePasswordController.abort();
|
||||||
discardConfirmation.dispose();
|
discardConfirmation.dispose();
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
@@ -532,7 +675,8 @@ onUnload(() => {
|
|||||||
.editor-control--article textarea {
|
.editor-control--article textarea {
|
||||||
min-height: 180rpx;
|
min-height: 180rpx;
|
||||||
}
|
}
|
||||||
.editor-field--cover {
|
.editor-field--cover,
|
||||||
|
.editor-field--media {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 12rpx;
|
gap: 12rpx;
|
||||||
padding: 18rpx 22rpx;
|
padding: 18rpx 22rpx;
|
||||||
@@ -540,7 +684,8 @@ onUnload(() => {
|
|||||||
border-radius: 12rpx;
|
border-radius: 12rpx;
|
||||||
background: rgba(255, 252, 245, 0.7);
|
background: rgba(255, 252, 245, 0.7);
|
||||||
}
|
}
|
||||||
.editor-field--cover .editor-field__label {
|
.editor-field--cover .editor-field__label,
|
||||||
|
.editor-field--media .editor-field__label {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
.editor-field__hint {
|
.editor-field__hint {
|
||||||
@@ -565,6 +710,7 @@ onUnload(() => {
|
|||||||
font-size: clamp(13px, 21rpx, 16px);
|
font-size: clamp(13px, 21rpx, 16px);
|
||||||
overflow-wrap: anywhere;
|
overflow-wrap: anywhere;
|
||||||
}
|
}
|
||||||
|
.editor-control--password-confirm { margin-top: 14rpx; }
|
||||||
.remove-cover-button {
|
.remove-cover-button {
|
||||||
justify-self: start;
|
justify-self: start;
|
||||||
min-height: 72rpx;
|
min-height: 72rpx;
|
||||||
@@ -577,6 +723,39 @@ onUnload(() => {
|
|||||||
font-size: clamp(13px, 21rpx, 16px);
|
font-size: clamp(13px, 21rpx, 16px);
|
||||||
}
|
}
|
||||||
.remove-cover-button::after { border: 0; }
|
.remove-cover-button::after { border: 0; }
|
||||||
|
.body-media-list {
|
||||||
|
display: grid;
|
||||||
|
gap: 8rpx;
|
||||||
|
}
|
||||||
|
.body-media-item {
|
||||||
|
display: flex;
|
||||||
|
min-height: 66rpx;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16rpx;
|
||||||
|
padding: 8rpx 12rpx 8rpx 18rpx;
|
||||||
|
border: 1rpx solid rgba(128, 89, 49, 0.22);
|
||||||
|
border-radius: 8rpx;
|
||||||
|
background: rgba(255, 253, 248, 0.58);
|
||||||
|
}
|
||||||
|
.body-media-item > text {
|
||||||
|
min-width: 0;
|
||||||
|
flex: 1;
|
||||||
|
color: $ink-muted;
|
||||||
|
font-size: clamp(13px, 21rpx, 16px);
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
.remove-media-button {
|
||||||
|
min-width: var(--app-touch-min);
|
||||||
|
min-height: var(--app-touch-min);
|
||||||
|
margin: 0;
|
||||||
|
padding: 0 14rpx;
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
color: $brand-red;
|
||||||
|
font-size: clamp(13px, 20rpx, 16px);
|
||||||
|
}
|
||||||
|
.remove-media-button::after { border: 0; }
|
||||||
.editor-placeholder {
|
.editor-placeholder {
|
||||||
color: #9e8e79;
|
color: #9e8e79;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,12 +18,32 @@
|
|||||||
</picker>
|
</picker>
|
||||||
</view>
|
</view>
|
||||||
<view v-if="listState === 'list'" class="article-list-items">
|
<view v-if="listState === 'list'" class="article-list-items">
|
||||||
|
<BatchManagementBar
|
||||||
|
v-if="deletableArticles.length"
|
||||||
|
resource-name="谱文"
|
||||||
|
:active="articleBatch.selectionMode.value"
|
||||||
|
:selected-count="articleBatch.selectedCount.value"
|
||||||
|
:all-selected="articleBatch.allSelected.value"
|
||||||
|
:busy="articleBatch.deleting.value"
|
||||||
|
@start="articleBatch.enterSelectionMode"
|
||||||
|
@finish="articleBatch.exitSelectionMode"
|
||||||
|
@toggle-all="articleBatch.toggleAll"
|
||||||
|
@delete="articleBatch.requestDelete"
|
||||||
|
/>
|
||||||
|
<text v-if="articleBatch.notice.value" class="article-batch-notice" role="status">{{ articleBatch.notice.value }}</text>
|
||||||
|
<text v-if="articleBatch.error.value" class="article-batch-error" role="alert">{{ articleBatch.error.value }}</text>
|
||||||
<view
|
<view
|
||||||
v-for="item in filteredArticles"
|
v-for="item in filteredArticles"
|
||||||
:key="item.id"
|
:key="item.id"
|
||||||
class="article-card"
|
class="article-card"
|
||||||
@click="openArticle(item)"
|
@click="handleArticleClick(item)"
|
||||||
>
|
>
|
||||||
|
<BatchSelectionMark
|
||||||
|
v-if="articleBatch.selectionMode.value && item.canDelete"
|
||||||
|
:selected="articleBatch.isSelected(item)"
|
||||||
|
:label="`谱文:${item.title}`"
|
||||||
|
@toggle="articleBatch.toggleSelection(item)"
|
||||||
|
/>
|
||||||
<image
|
<image
|
||||||
v-if="item.coverFile?.accessUrl"
|
v-if="item.coverFile?.accessUrl"
|
||||||
class="article-card__cover"
|
class="article-card__cover"
|
||||||
@@ -56,6 +76,18 @@
|
|||||||
/>
|
/>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
<AppDialog
|
||||||
|
:visible="articleBatch.confirmationVisible.value"
|
||||||
|
:close-on-mask="false"
|
||||||
|
eyebrow="批量删除"
|
||||||
|
title="将选中的谱文移入回收站?"
|
||||||
|
:message="articleBatch.confirmationMessage.value"
|
||||||
|
:confirm-text="articleBatch.deleting.value ? '正在删除' : '移入回收站'"
|
||||||
|
cancel-text="继续选择"
|
||||||
|
show-cancel
|
||||||
|
@confirm="articleBatch.confirmDelete"
|
||||||
|
@cancel="articleBatch.cancelDelete"
|
||||||
|
/>
|
||||||
</view>
|
</view>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -63,6 +95,9 @@
|
|||||||
import { computed, ref } from "vue";
|
import { computed, ref } from "vue";
|
||||||
import { onLoad, onShow, onUnload } from "@dcloudio/uni-app";
|
import { onLoad, onShow, onUnload } from "@dcloudio/uni-app";
|
||||||
import AppButton from "@/components/AppButton.vue";
|
import AppButton from "@/components/AppButton.vue";
|
||||||
|
import AppDialog from "@/components/AppDialog.vue";
|
||||||
|
import BatchManagementBar from "@/components/BatchManagementBar.vue";
|
||||||
|
import BatchSelectionMark from "@/components/BatchSelectionMark.vue";
|
||||||
import ModulePageBackground from "@/components/ModulePageBackground.vue";
|
import ModulePageBackground from "@/components/ModulePageBackground.vue";
|
||||||
import PageHeader from "@/components/PageHeader.vue";
|
import PageHeader from "@/components/PageHeader.vue";
|
||||||
import {
|
import {
|
||||||
@@ -71,6 +106,7 @@ import {
|
|||||||
} from "@/services/api/request-controller.js";
|
} from "@/services/api/request-controller.js";
|
||||||
import { getRequestErrorMessage } from "@/services/api/request-error-message.js";
|
import { getRequestErrorMessage } from "@/services/api/request-error-message.js";
|
||||||
import { familyArticleApi } from "@/services/api/family-article-service.js";
|
import { familyArticleApi } from "@/services/api/family-article-service.js";
|
||||||
|
import { useBatchDeletion } from "@/composables/use-batch-deletion.js";
|
||||||
import { goBack, openPage } from "@/utils/navigation/gateway.js";
|
import { goBack, openPage } from "@/utils/navigation/gateway.js";
|
||||||
|
|
||||||
const genealogyId = ref("");
|
const genealogyId = ref("");
|
||||||
@@ -82,6 +118,7 @@ const articleCategoryError = ref("");
|
|||||||
const selectedCategoryId = ref("");
|
const selectedCategoryId = ref("");
|
||||||
const articleListRequestController = createRequestController();
|
const articleListRequestController = createRequestController();
|
||||||
const articleCategoryRequestController = createRequestController();
|
const articleCategoryRequestController = createRequestController();
|
||||||
|
const articleDeleteRequestController = createRequestController();
|
||||||
let pageActive = true;
|
let pageActive = true;
|
||||||
let skipInitialShowRefresh = true;
|
let skipInitialShowRefresh = true;
|
||||||
const categoryOptions = computed(() => [
|
const categoryOptions = computed(() => [
|
||||||
@@ -98,6 +135,20 @@ const filteredArticles = computed(() =>
|
|||||||
? articles.value.filter((item) => item.categoryId === selectedCategoryId.value)
|
? articles.value.filter((item) => item.categoryId === selectedCategoryId.value)
|
||||||
: articles.value,
|
: articles.value,
|
||||||
);
|
);
|
||||||
|
const articleBatch = useBatchDeletion({
|
||||||
|
items: articles,
|
||||||
|
visibleItems: filteredArticles,
|
||||||
|
deleteOne: (article) =>
|
||||||
|
familyArticleApi.deleteArticle(genealogyId.value, article.id, {
|
||||||
|
requestController: articleDeleteRequestController,
|
||||||
|
}),
|
||||||
|
resourceName: "谱文",
|
||||||
|
isActive: () => pageActive,
|
||||||
|
onEmpty: () => {
|
||||||
|
listState.value = "empty";
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const deletableArticles = articleBatch.deletableItems;
|
||||||
const stateCopy = computed(() =>
|
const stateCopy = computed(() =>
|
||||||
hasValidContext.value
|
hasValidContext.value
|
||||||
? listState.value === "empty"
|
? listState.value === "empty"
|
||||||
@@ -140,6 +191,7 @@ onUnload(() => {
|
|||||||
pageActive = false;
|
pageActive = false;
|
||||||
articleListRequestController.abort();
|
articleListRequestController.abort();
|
||||||
articleCategoryRequestController.abort();
|
articleCategoryRequestController.abort();
|
||||||
|
articleDeleteRequestController.abort();
|
||||||
});
|
});
|
||||||
const loadArticleCategories = async () => {
|
const loadArticleCategories = async () => {
|
||||||
articleCategoryError.value = "";
|
articleCategoryError.value = "";
|
||||||
@@ -169,6 +221,7 @@ const loadArticles = async () => {
|
|||||||
]);
|
]);
|
||||||
if (!pageActive) return;
|
if (!pageActive) return;
|
||||||
articles.value = rows;
|
articles.value = rows;
|
||||||
|
articleBatch.exitSelectionMode();
|
||||||
if (categoryRows) categories.value = categoryRows;
|
if (categoryRows) categories.value = categoryRows;
|
||||||
if (!categoryOptions.value.some((item) => item.id === selectedCategoryId.value)) {
|
if (!categoryOptions.value.some((item) => item.id === selectedCategoryId.value)) {
|
||||||
selectedCategoryId.value = "";
|
selectedCategoryId.value = "";
|
||||||
@@ -181,6 +234,7 @@ const loadArticles = async () => {
|
|||||||
};
|
};
|
||||||
const selectCategory = (event) => {
|
const selectCategory = (event) => {
|
||||||
selectedCategoryId.value = categoryOptions.value[Number(event.detail.value)]?.id || "";
|
selectedCategoryId.value = categoryOptions.value[Number(event.detail.value)]?.id || "";
|
||||||
|
articleBatch.exitSelectionMode();
|
||||||
};
|
};
|
||||||
const createArticle = () =>
|
const createArticle = () =>
|
||||||
hasValidContext.value
|
hasValidContext.value
|
||||||
@@ -188,6 +242,13 @@ const createArticle = () =>
|
|||||||
: Promise.resolve(false);
|
: Promise.resolve(false);
|
||||||
const openArticle = (item) =>
|
const openArticle = (item) =>
|
||||||
openPage("F05", { genealogyId: genealogyId.value, articleId: item.id }, "F04");
|
openPage("F05", { genealogyId: genealogyId.value, articleId: item.id }, "F04");
|
||||||
|
const handleArticleClick = (item) => {
|
||||||
|
if (articleBatch.selectionMode.value && item?.canDelete) {
|
||||||
|
articleBatch.toggleSelection(item);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
openArticle(item);
|
||||||
|
};
|
||||||
const handleStateAction = () => {
|
const handleStateAction = () => {
|
||||||
if (!hasValidContext.value) return goBack();
|
if (!hasValidContext.value) return goBack();
|
||||||
if (listState.value === "error") return loadArticles();
|
if (listState.value === "error") return loadArticles();
|
||||||
@@ -214,6 +275,15 @@ const handleStateAction = () => {
|
|||||||
.article-list-items {
|
.article-list-items {
|
||||||
margin-top: 24rpx;
|
margin-top: 24rpx;
|
||||||
}
|
}
|
||||||
|
.article-batch-notice,
|
||||||
|
.article-batch-error {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 16rpx;
|
||||||
|
font-size: clamp(13px, 21rpx, 16px);
|
||||||
|
line-height: 1.55;
|
||||||
|
}
|
||||||
|
.article-batch-notice { color: #426538; }
|
||||||
|
.article-batch-error { color: $brand-red; }
|
||||||
.article-category-error {
|
.article-category-error {
|
||||||
margin-top: 20rpx;
|
margin-top: 20rpx;
|
||||||
padding: 18rpx 22rpx;
|
padding: 18rpx 22rpx;
|
||||||
|
|||||||
@@ -291,7 +291,7 @@ onUnload(() => {
|
|||||||
|
|
||||||
const backToFamily = () =>
|
const backToFamily = () =>
|
||||||
hasValidContext.value
|
hasValidContext.value
|
||||||
? returnTo("F01", { genealogyId: genealogyId.value })
|
? returnTo("F12", { genealogyId: genealogyId.value })
|
||||||
: goBack();
|
: goBack();
|
||||||
const requestBack = () => backToFamily();
|
const requestBack = () => backToFamily();
|
||||||
const handleStateAction = () =>
|
const handleStateAction = () =>
|
||||||
|
|||||||
@@ -32,7 +32,7 @@
|
|||||||
<view>
|
<view>
|
||||||
<text class="publish-field__label">动态配图</text>
|
<text class="publish-field__label">动态配图</text>
|
||||||
<text class="publish-field__hint"
|
<text class="publish-field__hint"
|
||||||
>图片上传成功后,会随动态一起发布。</text
|
>{{ isEdit ? "可新增、预览或移除图片,保存后生效。" : "图片上传成功后,会随动态一起发布。" }}</text
|
||||||
>
|
>
|
||||||
</view>
|
</view>
|
||||||
<button
|
<button
|
||||||
@@ -42,12 +42,49 @@
|
|||||||
>
|
>
|
||||||
{{ isUploading ? "上传中…" : "添加图片" }}
|
{{ isUploading ? "上传中…" : "添加图片" }}
|
||||||
</button>
|
</button>
|
||||||
|
<template v-if="isEdit">
|
||||||
|
<view v-if="mediaReceipts.length" class="media-preview-grid">
|
||||||
|
<view
|
||||||
|
v-for="(receipt, index) in mediaReceipts"
|
||||||
|
:key="`${receipt.ossId}-${index}`"
|
||||||
|
class="media-preview-card"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
class="media-preview-card__preview"
|
||||||
|
:disabled="isUploading || isSubmitting || !getMediaPreviewUrl(receipt)"
|
||||||
|
:aria-label="`预览第${index + 1}张动态配图`"
|
||||||
|
@click="previewMedia(receipt)"
|
||||||
|
>
|
||||||
|
<image
|
||||||
|
v-if="getMediaPreviewUrl(receipt)"
|
||||||
|
class="media-preview-card__image"
|
||||||
|
:src="getMediaPreviewUrl(receipt)"
|
||||||
|
mode="aspectFill"
|
||||||
|
/>
|
||||||
|
<view v-else class="media-preview-card__fallback">
|
||||||
|
<text>图片暂不可预览</text>
|
||||||
|
</view>
|
||||||
|
</button>
|
||||||
|
<view class="media-preview-card__meta">
|
||||||
|
<text>{{ receipt.fileName || `动态配图${index + 1}` }}</text>
|
||||||
|
<button
|
||||||
|
class="media-preview-card__remove"
|
||||||
|
:disabled="isUploading || isSubmitting"
|
||||||
|
:aria-label="`移除第${index + 1}张动态配图`"
|
||||||
|
@click="removeMedia(index)"
|
||||||
|
>移除</button>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
<text
|
<text
|
||||||
v-for="(receipt, index) in mediaReceipts"
|
v-for="(receipt, index) in mediaReceipts"
|
||||||
:key="`${receipt.ossId}-${index}`"
|
:key="`${receipt.ossId}-${index}`"
|
||||||
class="upload-receipt"
|
class="upload-receipt"
|
||||||
>已上传:{{ receipt.fileName || "图片" }}</text
|
>已上传:{{ receipt.fileName || "图片" }}</text
|
||||||
>
|
>
|
||||||
|
</template>
|
||||||
<text v-if="uploadError" class="publish-error">{{
|
<text v-if="uploadError" class="publish-error">{{
|
||||||
uploadError
|
uploadError
|
||||||
}}</text>
|
}}</text>
|
||||||
@@ -204,6 +241,7 @@ const loadEditFeed = async (feedId) => {
|
|||||||
mediaReceipts.value = detail.mediaFiles.map((file) => ({
|
mediaReceipts.value = detail.mediaFiles.map((file) => ({
|
||||||
ossId: file.ossId,
|
ossId: file.ossId,
|
||||||
fileName: file.fileName,
|
fileName: file.fileName,
|
||||||
|
accessUrl: file.accessUrl,
|
||||||
}));
|
}));
|
||||||
editingFeed.value = {
|
editingFeed.value = {
|
||||||
id: detail.id,
|
id: detail.id,
|
||||||
@@ -254,6 +292,22 @@ const uploadImage = async () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const getMediaPreviewUrl = (receipt) =>
|
||||||
|
String(receipt?.thumbnailUrl || receipt?.accessUrl || receipt?.url || "").trim();
|
||||||
|
|
||||||
|
const previewMedia = (receipt) => {
|
||||||
|
const current = getMediaPreviewUrl(receipt);
|
||||||
|
if (!current) return;
|
||||||
|
const urls = mediaReceipts.value.map(getMediaPreviewUrl).filter(Boolean);
|
||||||
|
uni.previewImage({ current, urls });
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeMedia = (index) => {
|
||||||
|
if (isUploading.value || isSubmitting.value || !Number.isInteger(index)) return;
|
||||||
|
mediaReceipts.value = mediaReceipts.value.filter((_, mediaIndex) => mediaIndex !== index);
|
||||||
|
uploadError.value = "";
|
||||||
|
};
|
||||||
|
|
||||||
const saveFeed = async () => {
|
const saveFeed = async () => {
|
||||||
if (isSubmitting.value || isUploading.value || !hasValidContext.value) return;
|
if (isSubmitting.value || isUploading.value || !hasValidContext.value) return;
|
||||||
if (!form.feedContent.trim()) {
|
if (!form.feedContent.trim()) {
|
||||||
@@ -322,7 +376,7 @@ const requestBack = () =>
|
|||||||
const returnToFamily = async () => {
|
const returnToFamily = async () => {
|
||||||
const confirmed = isDirty.value ? await requestDiscardConfirmation() : true;
|
const confirmed = isDirty.value ? await requestDiscardConfirmation() : true;
|
||||||
if (!confirmed) return false;
|
if (!confirmed) return false;
|
||||||
return returnTo("F01", { genealogyId: genealogyId.value });
|
return returnTo("F12", { genealogyId: genealogyId.value });
|
||||||
};
|
};
|
||||||
const handleResultAction = () => {
|
const handleResultAction = () => {
|
||||||
if (publishState.value === "success") {
|
if (publishState.value === "success") {
|
||||||
@@ -474,6 +528,89 @@ onUnload(() => {
|
|||||||
font-size: clamp(13px, 21rpx, 16px);
|
font-size: clamp(13px, 21rpx, 16px);
|
||||||
overflow-wrap: anywhere;
|
overflow-wrap: anywhere;
|
||||||
}
|
}
|
||||||
|
.media-preview-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 14rpx;
|
||||||
|
}
|
||||||
|
.media-preview-card {
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1rpx solid rgba(128, 89, 49, 0.24);
|
||||||
|
border-radius: 10rpx;
|
||||||
|
background: rgba(255, 253, 248, 0.62);
|
||||||
|
}
|
||||||
|
.media-preview-card__preview {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
height: 176rpx;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 0;
|
||||||
|
background: rgba(128, 89, 49, 0.1);
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
.media-preview-card__preview::after,
|
||||||
|
.media-preview-card__remove::after {
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
|
.media-preview-card__preview:focus-visible,
|
||||||
|
.media-preview-card__remove:focus-visible {
|
||||||
|
outline: 2rpx solid $brand-red;
|
||||||
|
outline-offset: -2rpx;
|
||||||
|
}
|
||||||
|
.media-preview-card__preview:active:not([disabled]) {
|
||||||
|
background: rgba(128, 89, 49, 0.16);
|
||||||
|
}
|
||||||
|
.media-preview-card__preview[disabled],
|
||||||
|
.media-preview-card__remove[disabled] {
|
||||||
|
opacity: 0.55;
|
||||||
|
}
|
||||||
|
.media-preview-card__image,
|
||||||
|
.media-preview-card__fallback {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
.media-preview-card__fallback {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: $ink-muted;
|
||||||
|
font-size: clamp(12px, 19rpx, 15px);
|
||||||
|
}
|
||||||
|
.media-preview-card__meta {
|
||||||
|
display: flex;
|
||||||
|
min-height: 72rpx;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8rpx;
|
||||||
|
padding: 6rpx 6rpx 6rpx 14rpx;
|
||||||
|
}
|
||||||
|
.media-preview-card__meta > text {
|
||||||
|
min-width: 0;
|
||||||
|
flex: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
color: $ink-muted;
|
||||||
|
font-size: clamp(12px, 19rpx, 15px);
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.media-preview-card__remove {
|
||||||
|
min-width: var(--app-touch-min);
|
||||||
|
min-height: var(--app-touch-min);
|
||||||
|
margin: 0;
|
||||||
|
padding: 0 10rpx;
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
color: $brand-red;
|
||||||
|
font-size: clamp(13px, 20rpx, 16px);
|
||||||
|
line-height: 1;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.media-preview-card__remove:active:not([disabled]) {
|
||||||
|
background: rgba(159, 23, 15, 0.08);
|
||||||
|
}
|
||||||
.publish-placeholder {
|
.publish-placeholder {
|
||||||
color: #8e806e;
|
color: #8e806e;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,25 +3,16 @@
|
|||||||
<ModulePageBackground module="family" />
|
<ModulePageBackground module="family" />
|
||||||
<view class="family-page__header"
|
<view class="family-page__header"
|
||||||
><PageHeader
|
><PageHeader
|
||||||
root
|
|
||||||
title="家族动态"
|
title="家族动态"
|
||||||
|
custom-back
|
||||||
:action="hasValidContext ? '发布' : ''"
|
:action="hasValidContext ? '发布' : ''"
|
||||||
|
@back="requestBack"
|
||||||
@action="toPublish"
|
@action="toPublish"
|
||||||
/></view>
|
/></view>
|
||||||
<view class="feed-content">
|
<view class="feed-content">
|
||||||
<view class="feed-heading"
|
<view class="feed-heading"
|
||||||
><text>家族圈</text><text>家宴、通知与共同记忆</text></view
|
><text>家族圈</text><text>家宴、通知与共同记忆</text></view
|
||||||
>
|
>
|
||||||
<view v-if="hasValidContext" class="feed-shortcuts"
|
|
||||||
><button
|
|
||||||
v-for="item in shortcuts"
|
|
||||||
:key="item.key"
|
|
||||||
class="feed-shortcut"
|
|
||||||
:aria-label="`打开${item.label}`"
|
|
||||||
@click="openSection(item.key)"
|
|
||||||
><text>{{ item.label }}</text></button
|
|
||||||
></view
|
|
||||||
>
|
|
||||||
<AppLoading
|
<AppLoading
|
||||||
v-if="feedState === 'loading'"
|
v-if="feedState === 'loading'"
|
||||||
text="正在读取家族动态"
|
text="正在读取家族动态"
|
||||||
@@ -57,15 +48,13 @@
|
|||||||
><text>{{ stateCopy.action }}</text></view
|
><text>{{ stateCopy.action }}</text></view
|
||||||
>
|
>
|
||||||
</view>
|
</view>
|
||||||
<AppTabbar active="family" />
|
|
||||||
</view>
|
</view>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { computed, ref } from "vue";
|
import { computed, ref } from "vue";
|
||||||
import { onLoad, onShow, onUnload } from "@dcloudio/uni-app";
|
import { onBackPress, onLoad, onShow, onUnload } from "@dcloudio/uni-app";
|
||||||
import AppLoading from "@/components/AppLoading.vue";
|
import AppLoading from "@/components/AppLoading.vue";
|
||||||
import AppTabbar from "@/components/AppTabbar.vue";
|
|
||||||
import FamilyFeedMedia from "@/components/family/FeedMedia.vue";
|
import FamilyFeedMedia from "@/components/family/FeedMedia.vue";
|
||||||
import ModulePageBackground from "@/components/ModulePageBackground.vue";
|
import ModulePageBackground from "@/components/ModulePageBackground.vue";
|
||||||
import PageHeader from "@/components/PageHeader.vue";
|
import PageHeader from "@/components/PageHeader.vue";
|
||||||
@@ -75,8 +64,7 @@ import {
|
|||||||
} from "@/services/api/request-controller.js";
|
} from "@/services/api/request-controller.js";
|
||||||
import { familyFeedApi } from "@/services/api/family-feed-service.js";
|
import { familyFeedApi } from "@/services/api/family-feed-service.js";
|
||||||
import { formatMinuteTimestamp } from "@/utils/display-time.js";
|
import { formatMinuteTimestamp } from "@/utils/display-time.js";
|
||||||
import { genealogyContext } from "@/utils/genealogy/context.js";
|
import { goBack, handleBackPress, openPage } from "@/utils/navigation/gateway.js";
|
||||||
import { goRoot, openPage } from "@/utils/navigation/gateway.js";
|
|
||||||
|
|
||||||
const genealogyId = ref("");
|
const genealogyId = ref("");
|
||||||
const hasValidContext = ref(false);
|
const hasValidContext = ref(false);
|
||||||
@@ -108,17 +96,6 @@ const feedPreview = (content) => {
|
|||||||
const text = String(content || "").trim();
|
const text = String(content || "").trim();
|
||||||
return text.length > 96 ? `${text.slice(0, 96).trimEnd()}…` : text;
|
return text.length > 96 ? `${text.slice(0, 96).trimEnd()}…` : text;
|
||||||
};
|
};
|
||||||
const shortcuts = [
|
|
||||||
{ key: "articles", label: "谱文" },
|
|
||||||
{ key: "albums", label: "相册" },
|
|
||||||
{ key: "rituals", label: "礼仪" },
|
|
||||||
{ key: "memos", label: "备忘" },
|
|
||||||
{ key: "benefactors", label: "家族恩人" },
|
|
||||||
{ key: "people", label: "人物录" },
|
|
||||||
{ key: "gifts", label: "贺礼簿" },
|
|
||||||
{ key: "merits", label: "功德录" },
|
|
||||||
{ key: "videos", label: "家族视频" },
|
|
||||||
];
|
|
||||||
const stateCopy = computed(() =>
|
const stateCopy = computed(() =>
|
||||||
!hasValidContext.value
|
!hasValidContext.value
|
||||||
? {
|
? {
|
||||||
@@ -205,13 +182,7 @@ const loadMoreFeeds = async () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
onLoad((query) => {
|
onLoad((query) => {
|
||||||
const supplied = Object.prototype.hasOwnProperty.call(
|
genealogyId.value = String(query?.genealogyId || "");
|
||||||
query || {},
|
|
||||||
"genealogyId",
|
|
||||||
);
|
|
||||||
genealogyId.value = supplied
|
|
||||||
? String(query.genealogyId || "")
|
|
||||||
: String(genealogyContext.getCurrentGenealogyId() || "");
|
|
||||||
hasValidContext.value = /^[1-9]\d*$/.test(genealogyId.value);
|
hasValidContext.value = /^[1-9]\d*$/.test(genealogyId.value);
|
||||||
if (hasValidContext.value) void loadFeeds();
|
if (hasValidContext.value) void loadFeeds();
|
||||||
else feedState.value = "error";
|
else feedState.value = "error";
|
||||||
@@ -230,33 +201,16 @@ onUnload(() => {
|
|||||||
});
|
});
|
||||||
const toPublish = () =>
|
const toPublish = () =>
|
||||||
hasValidContext.value
|
hasValidContext.value
|
||||||
? openPage("F02", { genealogyId: genealogyId.value, mode: "create" }, "F01")
|
? openPage("F02", { genealogyId: genealogyId.value, mode: "create" }, "F12")
|
||||||
: goRoot("G01");
|
: goBack();
|
||||||
const openFeed = (item) =>
|
const openFeed = (item) =>
|
||||||
openPage("F03", { genealogyId: genealogyId.value, feedId: item.id }, "F01");
|
openPage("F03", { genealogyId: genealogyId.value, feedId: item.id }, "F12");
|
||||||
const openSection = (key) => {
|
const handlePrimaryAction = () => {
|
||||||
const routes = {
|
if (!hasValidContext.value) return goBack();
|
||||||
articles: "F04",
|
return feedState.value === "error" ? loadFeeds() : toPublish();
|
||||||
albums: "F07",
|
|
||||||
rituals: "R05",
|
|
||||||
memos: "R10",
|
|
||||||
benefactors: "R10",
|
|
||||||
people: "R01",
|
|
||||||
gifts: "R03",
|
|
||||||
merits: "R11",
|
|
||||||
videos: "F10",
|
|
||||||
};
|
};
|
||||||
return openPage(
|
const requestBack = () => goBack();
|
||||||
routes[key],
|
onBackPress((event) => handleBackPress(event, requestBack));
|
||||||
{
|
|
||||||
genealogyId: genealogyId.value,
|
|
||||||
...(key === "benefactors" ? { memoType: "benefactor" } : {}),
|
|
||||||
},
|
|
||||||
"F01",
|
|
||||||
);
|
|
||||||
};
|
|
||||||
const handlePrimaryAction = () =>
|
|
||||||
feedState.value === "error" ? loadFeeds() : toPublish();
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped lang="scss">
|
<style scoped lang="scss">
|
||||||
@@ -273,7 +227,7 @@ const handlePrimaryAction = () =>
|
|||||||
}
|
}
|
||||||
.feed-content {
|
.feed-content {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
padding: 24rpx 24rpx calc(190rpx + env(safe-area-inset-bottom));
|
padding: 24rpx 24rpx calc(48rpx + env(safe-area-inset-bottom));
|
||||||
}
|
}
|
||||||
.feed-heading text {
|
.feed-heading text {
|
||||||
display: block;
|
display: block;
|
||||||
@@ -289,29 +243,6 @@ const handlePrimaryAction = () =>
|
|||||||
color: $ink-muted;
|
color: $ink-muted;
|
||||||
font-size: clamp(14px, 23rpx, 17px);
|
font-size: clamp(14px, 23rpx, 17px);
|
||||||
}
|
}
|
||||||
.feed-shortcuts {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
|
||||||
gap: 12rpx;
|
|
||||||
margin-top: 18rpx;
|
|
||||||
}
|
|
||||||
.feed-shortcut {
|
|
||||||
box-sizing: border-box;
|
|
||||||
min-height: 48px;
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
border: 1rpx solid rgba(181, 137, 63, 0.45);
|
|
||||||
border-radius: 8rpx;
|
|
||||||
background: rgba(255, 252, 244, 0.58);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
.feed-shortcut text {
|
|
||||||
color: $ink;
|
|
||||||
font-size: clamp(14px, 22rpx, 17px);
|
|
||||||
font-weight: 700;
|
|
||||||
}
|
|
||||||
.feed-list {
|
.feed-list {
|
||||||
margin-top: 22rpx;
|
margin-top: 22rpx;
|
||||||
}
|
}
|
||||||
@@ -347,12 +278,6 @@ const handlePrimaryAction = () =>
|
|||||||
color: $ink-muted;
|
color: $ink-muted;
|
||||||
font-size: clamp(13px, 21rpx, 16px);
|
font-size: clamp(13px, 21rpx, 16px);
|
||||||
}
|
}
|
||||||
.feed-shortcut::after {
|
|
||||||
border: 0;
|
|
||||||
}
|
|
||||||
.feed-shortcut:active {
|
|
||||||
background: rgba(181, 137, 63, 0.1);
|
|
||||||
}
|
|
||||||
.feed-card__reason {
|
.feed-card__reason {
|
||||||
margin-top: 8rpx;
|
margin-top: 8rpx;
|
||||||
color: #9a6555;
|
color: #9a6555;
|
||||||
|
|||||||
@@ -40,12 +40,21 @@
|
|||||||
/>
|
/>
|
||||||
<view class="video-card__play" aria-hidden="true"></view>
|
<view class="video-card__play" aria-hidden="true"></view>
|
||||||
</view>
|
</view>
|
||||||
<video
|
<view
|
||||||
v-else
|
v-else
|
||||||
:src="video.videoFile.accessUrl"
|
class="video-card__cover-button video-card__cover-placeholder"
|
||||||
controls
|
role="button"
|
||||||
class="video-card__player"
|
:aria-label="`播放${video.title}`"
|
||||||
|
hover-class="action-hover"
|
||||||
|
@click="openVerticalViewer(video)"
|
||||||
|
>
|
||||||
|
<image
|
||||||
|
class="video-card__placeholder-seal"
|
||||||
|
src="/static/assets/foundation/transparent/brand-seal.png"
|
||||||
|
mode="aspectFit"
|
||||||
/>
|
/>
|
||||||
|
<view class="video-card__play" aria-hidden="true"></view>
|
||||||
|
</view>
|
||||||
<text class="video-card__title">{{ video.title }}</text>
|
<text class="video-card__title">{{ video.title }}</text>
|
||||||
<text v-if="video.description" class="video-card__copy">{{ video.description }}</text>
|
<text v-if="video.description" class="video-card__copy">{{ video.description }}</text>
|
||||||
<text v-if="video.startAt" class="video-card__meta">发布时间:{{ video.startAt }}</text>
|
<text v-if="video.startAt" class="video-card__meta">发布时间:{{ video.startAt }}</text>
|
||||||
@@ -423,12 +432,6 @@ onUnload(() => {
|
|||||||
.video-card {
|
.video-card {
|
||||||
padding: 28rpx;
|
padding: 28rpx;
|
||||||
}
|
}
|
||||||
.video-card__player {
|
|
||||||
width: 100%;
|
|
||||||
height: 360rpx;
|
|
||||||
border-radius: 10rpx;
|
|
||||||
background: #1f1b17;
|
|
||||||
}
|
|
||||||
.video-card__cover-button {
|
.video-card__cover-button {
|
||||||
position: relative;
|
position: relative;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
@@ -437,6 +440,17 @@ onUnload(() => {
|
|||||||
background: #1f1b17;
|
background: #1f1b17;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
.video-card__cover-placeholder {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: linear-gradient(145deg, #f4e5cd, #d8b77c);
|
||||||
|
}
|
||||||
|
.video-card__placeholder-seal {
|
||||||
|
width: 132rpx;
|
||||||
|
height: 152rpx;
|
||||||
|
opacity: .72;
|
||||||
|
}
|
||||||
.video-card__cover {
|
.video-card__cover {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
<template>
|
||||||
|
<SiteHome subpage-mode />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import SiteHome from "@/pages/family/site-home.vue";
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,354 @@
|
|||||||
|
<template>
|
||||||
|
<view class="site-home-page">
|
||||||
|
<ModulePageBackground module="family" />
|
||||||
|
<PageHeader :root="!subpageMode" :custom-back="subpageMode" :title="headerTitle" />
|
||||||
|
|
||||||
|
<scroll-view class="site-home-scroll" scroll-y>
|
||||||
|
<view class="site-home-content" :class="{ 'site-home-content--root': !subpageMode }">
|
||||||
|
<view class="site-home-heading">
|
||||||
|
<text>{{ pageTitle }}</text>
|
||||||
|
<text>{{ pageDescription }}</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<AppLoading
|
||||||
|
v-if="articleState === 'loading'"
|
||||||
|
text="正在整理资讯"
|
||||||
|
description="请稍候,正在读取最新内容。"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<view v-else-if="articleState === 'list'" class="site-article-list">
|
||||||
|
<button
|
||||||
|
v-for="article in articles"
|
||||||
|
:key="article.id"
|
||||||
|
class="site-article-card"
|
||||||
|
:aria-label="`${article.title},查看文章`"
|
||||||
|
@click="openArticle(article)"
|
||||||
|
>
|
||||||
|
<view class="site-article-card__meta">
|
||||||
|
<text>{{ article.typeLabel }}</text>
|
||||||
|
<text v-if="article.publishTime">{{ formatArticleDate(article.publishTime) }}</text>
|
||||||
|
</view>
|
||||||
|
<text class="site-article-card__title">{{ article.title }}</text>
|
||||||
|
<text v-if="article.summary" class="site-article-card__summary">
|
||||||
|
{{ article.summary }}
|
||||||
|
</text>
|
||||||
|
<text class="site-article-card__action">
|
||||||
|
{{ article.externalUrl ? "前往阅读" : "阅读全文" }} ›
|
||||||
|
</text>
|
||||||
|
</button>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view v-else class="site-home-state">
|
||||||
|
<text>{{ articleState === "empty" ? "暂时没有资讯" : "资讯暂时无法读取" }}</text>
|
||||||
|
<text>{{ articleState === "empty" ? "新内容发布后会在这里展示。" : articleError }}</text>
|
||||||
|
<button v-if="articleState === 'error'" @click="loadArticles">重新加载</button>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<text v-if="openError" class="site-home-error" role="alert">{{ openError }}</text>
|
||||||
|
<AppPromotionStrip placement="home_bottom" title="更多内容" />
|
||||||
|
</view>
|
||||||
|
</scroll-view>
|
||||||
|
|
||||||
|
<AppDialog
|
||||||
|
:visible="Boolean(selectedArticle)"
|
||||||
|
:eyebrow="selectedArticle?.typeLabel || '传承资讯'"
|
||||||
|
:title="selectedArticle?.title || '文章详情'"
|
||||||
|
:message="selectedArticleContent"
|
||||||
|
confirm-text="关闭"
|
||||||
|
@confirm="closeArticle"
|
||||||
|
@close="closeArticle"
|
||||||
|
/>
|
||||||
|
<AppTabbar v-if="!subpageMode" active="family" />
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed, ref } from "vue";
|
||||||
|
import { onLoad, onUnload } from "@dcloudio/uni-app";
|
||||||
|
import AppDialog from "@/components/AppDialog.vue";
|
||||||
|
import AppLoading from "@/components/AppLoading.vue";
|
||||||
|
import AppPromotionStrip from "@/components/AppPromotionStrip.vue";
|
||||||
|
import AppTabbar from "@/components/AppTabbar.vue";
|
||||||
|
import ModulePageBackground from "@/components/ModulePageBackground.vue";
|
||||||
|
import PageHeader from "@/components/PageHeader.vue";
|
||||||
|
import {
|
||||||
|
createRequestController,
|
||||||
|
isRequestCancelled,
|
||||||
|
} from "@/services/api/request-controller.js";
|
||||||
|
import { getRequestErrorMessage } from "@/services/api/request-error-message.js";
|
||||||
|
import { siteContentApi } from "@/services/api/site-content-service.js";
|
||||||
|
import { openSiteContentTarget } from "@/utils/navigation/gateway.js";
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
subpageMode: { type: Boolean, default: false },
|
||||||
|
});
|
||||||
|
|
||||||
|
const subpageMode = computed(() => props.subpageMode);
|
||||||
|
|
||||||
|
const articleState = ref("loading");
|
||||||
|
const articleType = ref("");
|
||||||
|
const pageTitle = ref("传承资讯");
|
||||||
|
const articleError = ref("");
|
||||||
|
const openError = ref("");
|
||||||
|
const articles = ref([]);
|
||||||
|
const selectedArticle = ref(null);
|
||||||
|
const articleRequestController = createRequestController();
|
||||||
|
let pageActive = true;
|
||||||
|
|
||||||
|
const decodeRouteText = (value) => {
|
||||||
|
const text = String(value || "").trim();
|
||||||
|
if (!text) return "";
|
||||||
|
try {
|
||||||
|
return decodeURIComponent(text);
|
||||||
|
} catch {
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const headerTitle = computed(() => (subpageMode.value ? pageTitle.value : "代代相传"));
|
||||||
|
|
||||||
|
const pageDescription = computed(() =>
|
||||||
|
articleType.value === "notice"
|
||||||
|
? "平台发布的网站公告与服务通知"
|
||||||
|
: articleType.value === "news"
|
||||||
|
? "家谱文化、平台动态与最新资讯"
|
||||||
|
: "家谱文化、平台动态与实用文章",
|
||||||
|
);
|
||||||
|
|
||||||
|
const decodeArticleEntities = (value) =>
|
||||||
|
value
|
||||||
|
.replace(/ /gi, " ")
|
||||||
|
.replace(/&/gi, "&")
|
||||||
|
.replace(/</gi, "<")
|
||||||
|
.replace(/>/gi, ">")
|
||||||
|
.replace(/"/gi, '"')
|
||||||
|
.replace(/'/gi, "'");
|
||||||
|
|
||||||
|
const articlePlainText = (value) =>
|
||||||
|
decodeArticleEntities(
|
||||||
|
String(value || "")
|
||||||
|
.replace(/<br\s*\/?>/gi, "\n")
|
||||||
|
.replace(/<\/(?:p|div|li|h[1-6])>/gi, "\n")
|
||||||
|
.replace(/<[^>]*>/g, ""),
|
||||||
|
)
|
||||||
|
.replace(/\n{3,}/g, "\n\n")
|
||||||
|
.trim();
|
||||||
|
|
||||||
|
const selectedArticleContent = computed(() =>
|
||||||
|
articlePlainText(selectedArticle.value?.content || selectedArticle.value?.summary) ||
|
||||||
|
"这篇文章暂时没有正文。",
|
||||||
|
);
|
||||||
|
|
||||||
|
const formatArticleDate = (value) => {
|
||||||
|
const text = String(value || "").trim();
|
||||||
|
return text.length >= 10 ? text.slice(0, 10) : text;
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadArticles = async () => {
|
||||||
|
articleRequestController.abort();
|
||||||
|
articleState.value = "loading";
|
||||||
|
articleError.value = "";
|
||||||
|
try {
|
||||||
|
const rows = await siteContentApi.getSiteArticles({
|
||||||
|
limit: 20,
|
||||||
|
...(articleType.value ? { articleType: articleType.value } : {}),
|
||||||
|
requestController: articleRequestController,
|
||||||
|
});
|
||||||
|
if (!pageActive) return;
|
||||||
|
articles.value = rows;
|
||||||
|
articleState.value = rows.length ? "list" : "empty";
|
||||||
|
} catch (error) {
|
||||||
|
if (!pageActive || isRequestCancelled(error)) return;
|
||||||
|
articleError.value = getRequestErrorMessage(error, "请稍后重新读取。");
|
||||||
|
articleState.value = "error";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const openArticle = async (article) => {
|
||||||
|
openError.value = "";
|
||||||
|
if (!article.externalUrl) {
|
||||||
|
selectedArticle.value = article;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await openSiteContentTarget(article.externalUrl, () => {
|
||||||
|
if (pageActive) openError.value = "外部文章暂时打不开,请稍后再试。";
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
if (pageActive) openError.value = "外部文章暂时打不开,请稍后再试。";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeArticle = () => {
|
||||||
|
selectedArticle.value = null;
|
||||||
|
};
|
||||||
|
|
||||||
|
onLoad((query) => {
|
||||||
|
articleType.value = subpageMode.value && ["news", "notice"].includes(String(query?.articleType || ""))
|
||||||
|
? String(query.articleType)
|
||||||
|
: "";
|
||||||
|
pageTitle.value = subpageMode.value
|
||||||
|
? decodeRouteText(query?.title) ||
|
||||||
|
(articleType.value === "notice" ? "网站公告" : articleType.value === "news" ? "家谱新闻" : "文化传承文章")
|
||||||
|
: "传承资讯";
|
||||||
|
void loadArticles();
|
||||||
|
});
|
||||||
|
onUnload(() => {
|
||||||
|
pageActive = false;
|
||||||
|
articleRequestController.abort();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
@use "../../styles/adaptive-frame-profiles.scss" as adaptive;
|
||||||
|
|
||||||
|
.site-home-page {
|
||||||
|
display: flex;
|
||||||
|
height: 100vh;
|
||||||
|
min-height: 0;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
background: $paper;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-home-scroll {
|
||||||
|
z-index: 1;
|
||||||
|
width: 100%;
|
||||||
|
height: 0;
|
||||||
|
min-height: 0;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-home-content {
|
||||||
|
padding: 22rpx 24rpx calc(72rpx + env(safe-area-inset-bottom));
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-home-content--root {
|
||||||
|
padding-bottom: calc(166rpx + env(safe-area-inset-bottom));
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-home-heading text,
|
||||||
|
.site-article-card text,
|
||||||
|
.site-home-state text {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-home-heading text:first-child {
|
||||||
|
color: $ink;
|
||||||
|
font-family: "STKaiti", "KaiTi", serif;
|
||||||
|
font-size: clamp(20px, 36rpx, 25px);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-home-heading text:last-child {
|
||||||
|
margin-top: 6rpx;
|
||||||
|
color: $ink-muted;
|
||||||
|
font-size: clamp(14px, 22rpx, 17px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-article-list {
|
||||||
|
margin-top: 22rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-article-card {
|
||||||
|
@include adaptive.adaptive-family-letter;
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
min-height: 210rpx;
|
||||||
|
margin: 0 0 18rpx;
|
||||||
|
padding: 28rpx 30rpx;
|
||||||
|
border: 0;
|
||||||
|
background-color: rgba($paper, 0.86);
|
||||||
|
box-sizing: border-box;
|
||||||
|
color: $ink;
|
||||||
|
line-height: 1.5;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-article-card::after {
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-article-card__meta {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 20rpx;
|
||||||
|
color: #946c48;
|
||||||
|
font-size: clamp(12px, 20rpx, 15px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-article-card__title {
|
||||||
|
margin-top: 10rpx;
|
||||||
|
color: $ink;
|
||||||
|
font-family: "STKaiti", "KaiTi", serif;
|
||||||
|
font-size: clamp(18px, 31rpx, 22px);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-article-card__summary {
|
||||||
|
display: -webkit-box;
|
||||||
|
margin-top: 9rpx;
|
||||||
|
overflow: hidden;
|
||||||
|
color: $ink-muted;
|
||||||
|
font-size: clamp(14px, 22rpx, 17px);
|
||||||
|
line-height: 1.55;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
-webkit-line-clamp: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-article-card__action {
|
||||||
|
margin-top: 14rpx;
|
||||||
|
color: $brand-red;
|
||||||
|
font-size: clamp(13px, 21rpx, 16px);
|
||||||
|
font-weight: 700;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-home-state {
|
||||||
|
display: flex;
|
||||||
|
min-height: 420rpx;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: $ink-muted;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-home-state text:first-child {
|
||||||
|
color: $ink;
|
||||||
|
font-size: clamp(18px, 32rpx, 23px);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-home-state text + text {
|
||||||
|
margin-top: 12rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-home-state button {
|
||||||
|
min-height: 72rpx;
|
||||||
|
margin-top: 24rpx;
|
||||||
|
padding: 0 30rpx;
|
||||||
|
border: 1rpx solid rgba(159, 23, 15, 0.42);
|
||||||
|
border-radius: 36rpx;
|
||||||
|
background: rgba(255, 250, 240, 0.8);
|
||||||
|
color: $brand-red;
|
||||||
|
font-size: clamp(14px, 22rpx, 17px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-home-state button::after {
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-home-error {
|
||||||
|
display: block;
|
||||||
|
margin-top: 18rpx;
|
||||||
|
color: $brand-red;
|
||||||
|
font-size: clamp(13px, 21rpx, 16px);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-home-content :deep(.promotion-strip) {
|
||||||
|
margin-right: 0;
|
||||||
|
margin-left: 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -129,6 +129,20 @@
|
|||||||
<AppButton block label="发布视频" @click="openPublishForm" />
|
<AppButton block label="发布视频" @click="openPublishForm" />
|
||||||
</view>
|
</view>
|
||||||
<view v-else class="video-card-list">
|
<view v-else class="video-card-list">
|
||||||
|
<BatchManagementBar
|
||||||
|
v-if="deletableVideos.length"
|
||||||
|
resource-name="视频"
|
||||||
|
:active="videoBatch.selectionMode.value"
|
||||||
|
:selected-count="videoBatch.selectedCount.value"
|
||||||
|
:all-selected="videoBatch.allSelected.value"
|
||||||
|
:busy="videoBatch.deleting.value"
|
||||||
|
@start="videoBatch.enterSelectionMode"
|
||||||
|
@finish="videoBatch.exitSelectionMode"
|
||||||
|
@toggle-all="videoBatch.toggleAll"
|
||||||
|
@delete="videoBatch.requestDelete"
|
||||||
|
/>
|
||||||
|
<text v-if="videoBatch.notice.value" class="batch-notice" role="status">{{ videoBatch.notice.value }}</text>
|
||||||
|
<text v-if="videoBatch.error.value" class="field-error" role="alert">{{ videoBatch.error.value }}</text>
|
||||||
<AppButton
|
<AppButton
|
||||||
block
|
block
|
||||||
type="secondary"
|
type="secondary"
|
||||||
@@ -136,10 +150,16 @@
|
|||||||
@click="openVerticalViewer(videos[0])"
|
@click="openVerticalViewer(videos[0])"
|
||||||
/>
|
/>
|
||||||
<view v-for="video in videos" :key="video.id" class="video-card">
|
<view v-for="video in videos" :key="video.id" class="video-card">
|
||||||
|
<BatchSelectionMark
|
||||||
|
v-if="videoBatch.selectionMode.value && video.canDelete"
|
||||||
|
:selected="videoBatch.isSelected(video)"
|
||||||
|
:label="`视频:${video.title}`"
|
||||||
|
@toggle="videoBatch.toggleSelection(video)"
|
||||||
|
/>
|
||||||
<button
|
<button
|
||||||
class="video-card__cover-action"
|
class="video-card__cover-action"
|
||||||
:aria-label="`播放${video.title}`"
|
:aria-label="`播放${video.title}`"
|
||||||
@click="openVerticalViewer(video)"
|
@click="videoBatch.selectionMode.value && video.canDelete ? videoBatch.toggleSelection(video) : openVerticalViewer(video)"
|
||||||
>
|
>
|
||||||
<image
|
<image
|
||||||
class="video-card__cover"
|
class="video-card__cover"
|
||||||
@@ -156,7 +176,7 @@
|
|||||||
video.publishTime || "刚刚发布"
|
video.publishTime || "刚刚发布"
|
||||||
}}</text>
|
}}</text>
|
||||||
<view
|
<view
|
||||||
v-if="video.canEdit || video.canDelete"
|
v-if="!videoBatch.selectionMode.value && (video.canEdit || video.canDelete)"
|
||||||
class="video-card__actions"
|
class="video-card__actions"
|
||||||
>
|
>
|
||||||
<AppButton
|
<AppButton
|
||||||
@@ -175,7 +195,7 @@
|
|||||||
@click="requestDeleteVideo(video)"
|
@click="requestDeleteVideo(video)"
|
||||||
/>
|
/>
|
||||||
</view>
|
</view>
|
||||||
<view class="video-card__actions">
|
<view v-if="!videoBatch.selectionMode.value" class="video-card__actions">
|
||||||
<AppButton compact type="secondary" label="沉浸观看" @click="openVerticalViewer(video)" />
|
<AppButton compact type="secondary" label="沉浸观看" @click="openVerticalViewer(video)" />
|
||||||
<AppButton compact type="secondary" :disabled="videoActionKey === `like-${video.id}`" :label="video.likedByCurrentUser ? `已赞 ${video.likeCount || 0}` : `点赞 ${video.likeCount || 0}`" @click="toggleVideoLike(video)" />
|
<AppButton compact type="secondary" :disabled="videoActionKey === `like-${video.id}`" :label="video.likedByCurrentUser ? `已赞 ${video.likeCount || 0}` : `点赞 ${video.likeCount || 0}`" @click="toggleVideoLike(video)" />
|
||||||
<AppButton compact type="secondary" :disabled="videoActionKey === `comments-${video.id}`" label="查看评论" @click="openVideoComments(video)" />
|
<AppButton compact type="secondary" :disabled="videoActionKey === `comments-${video.id}`" label="查看评论" @click="openVideoComments(video)" />
|
||||||
@@ -192,6 +212,18 @@
|
|||||||
<AppButton block :label="stateCopy.action" @click="handleStateAction" />
|
<AppButton block :label="stateCopy.action" @click="handleStateAction" />
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
<AppDialog
|
||||||
|
:visible="videoBatch.confirmationVisible.value"
|
||||||
|
eyebrow="批量删除"
|
||||||
|
title="将选中的视频移入回收站?"
|
||||||
|
:message="videoBatch.confirmationMessage.value"
|
||||||
|
:confirm-text="videoBatch.deleting.value ? '正在删除' : '移入回收站'"
|
||||||
|
cancel-text="继续选择"
|
||||||
|
show-cancel
|
||||||
|
:close-on-mask="false"
|
||||||
|
@confirm="videoBatch.confirmDelete"
|
||||||
|
@cancel="videoBatch.cancelDelete"
|
||||||
|
/>
|
||||||
<AppDialog
|
<AppDialog
|
||||||
:visible="deleteConfirmVisible"
|
:visible="deleteConfirmVisible"
|
||||||
eyebrow="删除确认"
|
eyebrow="删除确认"
|
||||||
@@ -305,6 +337,8 @@ import { onBackPress, onLoad, onUnload } from "@dcloudio/uni-app";
|
|||||||
import AppLoading from "@/components/AppLoading.vue";
|
import AppLoading from "@/components/AppLoading.vue";
|
||||||
import AppButton from "@/components/AppButton.vue";
|
import AppButton from "@/components/AppButton.vue";
|
||||||
import AppDialog from "@/components/AppDialog.vue";
|
import AppDialog from "@/components/AppDialog.vue";
|
||||||
|
import BatchManagementBar from "@/components/BatchManagementBar.vue";
|
||||||
|
import BatchSelectionMark from "@/components/BatchSelectionMark.vue";
|
||||||
import ModulePageBackground from "@/components/ModulePageBackground.vue";
|
import ModulePageBackground from "@/components/ModulePageBackground.vue";
|
||||||
import PageHeader from "@/components/PageHeader.vue";
|
import PageHeader from "@/components/PageHeader.vue";
|
||||||
import VerticalVideoViewer from "@/components/family/VerticalVideoViewer.vue";
|
import VerticalVideoViewer from "@/components/family/VerticalVideoViewer.vue";
|
||||||
@@ -315,6 +349,7 @@ import {
|
|||||||
import { familyMediaApi } from "@/services/api/family-media-service.js";
|
import { familyMediaApi } from "@/services/api/family-media-service.js";
|
||||||
import { genealogyCapabilityApi } from "@/services/api/genealogy-capability-service.js";
|
import { genealogyCapabilityApi } from "@/services/api/genealogy-capability-service.js";
|
||||||
import { getRequestErrorMessage } from "@/services/api/request-error-message.js";
|
import { getRequestErrorMessage } from "@/services/api/request-error-message.js";
|
||||||
|
import { useBatchDeletion } from "@/composables/use-batch-deletion.js";
|
||||||
import {
|
import {
|
||||||
isImagePickCancelled,
|
isImagePickCancelled,
|
||||||
isVideoPickCancelled,
|
isVideoPickCancelled,
|
||||||
@@ -372,6 +407,19 @@ const discardConfirmation = createDiscardConfirmation((visible) => {
|
|||||||
const confirmDiscard = discardConfirmation.confirm;
|
const confirmDiscard = discardConfirmation.confirm;
|
||||||
const cancelDiscard = discardConfirmation.cancel;
|
const cancelDiscard = discardConfirmation.cancel;
|
||||||
const hasValidContext = computed(() => /^[1-9]\d*$/.test(genealogyId.value));
|
const hasValidContext = computed(() => /^[1-9]\d*$/.test(genealogyId.value));
|
||||||
|
const videoBatch = useBatchDeletion({
|
||||||
|
items: videos,
|
||||||
|
deleteOne: (video) =>
|
||||||
|
familyMediaApi.deleteVideo(genealogyId.value, video.id, {
|
||||||
|
requestController: videoDeletionRequestController,
|
||||||
|
}),
|
||||||
|
resourceName: "视频",
|
||||||
|
isActive: () => pageActive,
|
||||||
|
onEmpty: () => {
|
||||||
|
videoListState.value = "ready";
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const deletableVideos = videoBatch.deletableItems;
|
||||||
const videoCommentPlaceholder = computed(() =>
|
const videoCommentPlaceholder = computed(() =>
|
||||||
replyTarget.value ? `回复 ${replyTarget.value.author}` : "说说你的看法",
|
replyTarget.value ? `回复 ${replyTarget.value.author}` : "说说你的看法",
|
||||||
);
|
);
|
||||||
@@ -599,6 +647,7 @@ const loadVideos = async () => {
|
|||||||
});
|
});
|
||||||
if (!pageActive) return;
|
if (!pageActive) return;
|
||||||
videos.value = videoRows;
|
videos.value = videoRows;
|
||||||
|
videoBatch.exitSelectionMode();
|
||||||
videoListState.value = "ready";
|
videoListState.value = "ready";
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (!pageActive || isRequestCancelled(error)) return;
|
if (!pageActive || isRequestCancelled(error)) return;
|
||||||
@@ -822,7 +871,7 @@ const submitVideo = async () => {
|
|||||||
};
|
};
|
||||||
const returnToFamily = () =>
|
const returnToFamily = () =>
|
||||||
hasValidContext.value
|
hasValidContext.value
|
||||||
? returnTo("F01", { genealogyId: genealogyId.value })
|
? returnTo("G05", { genealogyId: genealogyId.value })
|
||||||
: goBack();
|
: goBack();
|
||||||
const returnToVideoList = () => {
|
const returnToVideoList = () => {
|
||||||
pageState.value = "list";
|
pageState.value = "list";
|
||||||
@@ -833,6 +882,15 @@ const returnToVideoList = () => {
|
|||||||
return true;
|
return true;
|
||||||
};
|
};
|
||||||
const requestBack = async () => {
|
const requestBack = async () => {
|
||||||
|
if (videoBatch.deleting.value) return true;
|
||||||
|
if (videoBatch.confirmationVisible.value) {
|
||||||
|
videoBatch.cancelDelete();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (videoBatch.selectionMode.value) {
|
||||||
|
videoBatch.exitSelectionMode();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
if (verticalViewerVisible.value) {
|
if (verticalViewerVisible.value) {
|
||||||
closeVerticalViewer();
|
closeVerticalViewer();
|
||||||
return true;
|
return true;
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
class="page-shell genealogy-index"
|
class="page-shell genealogy-index"
|
||||||
:class="{ 'genealogy-index--split': isListLayout }"
|
:class="{ 'genealogy-index--split': isListLayout }"
|
||||||
>
|
>
|
||||||
<GenealogyPageBackground />
|
<GenealogyPageBackground tone="light" />
|
||||||
<PageHeader
|
<PageHeader
|
||||||
root
|
root
|
||||||
notice
|
notice
|
||||||
@@ -56,69 +56,6 @@
|
|||||||
</view>
|
</view>
|
||||||
|
|
||||||
<template v-else-if="hasGenealogies">
|
<template v-else-if="hasGenealogies">
|
||||||
<view class="genealogy-fixed-zone">
|
|
||||||
<view class="current-slip" @click="openSwitcher">
|
|
||||||
<view class="current-summary">
|
|
||||||
<view class="current-seal">
|
|
||||||
<text class="current-seal-title">家谱</text>
|
|
||||||
</view>
|
|
||||||
<text class="current-name">{{ currentGenealogy.name }}</text>
|
|
||||||
<text class="current-switch-copy">切换</text>
|
|
||||||
</view>
|
|
||||||
<view class="current-info-divider"></view>
|
|
||||||
<view class="current-meta">
|
|
||||||
<view class="current-meta-item current-meta-item--location">
|
|
||||||
<image
|
|
||||||
class="current-meta-icon"
|
|
||||||
src="/static/assets/foundation/transparent/meta-location.png"
|
|
||||||
mode="aspectFit"
|
|
||||||
/>
|
|
||||||
<text class="current-meta-item-text">{{
|
|
||||||
currentGenealogy.location
|
|
||||||
}}</text>
|
|
||||||
</view>
|
|
||||||
<view class="current-meta-item current-meta-item--members">
|
|
||||||
<image
|
|
||||||
class="current-meta-icon"
|
|
||||||
src="/static/assets/foundation/transparent/meta-member.png"
|
|
||||||
mode="aspectFit"
|
|
||||||
/>
|
|
||||||
<text class="current-meta-item-text"
|
|
||||||
>{{ currentGenealogy.memberCount }} 位成员</text
|
|
||||||
>
|
|
||||||
</view>
|
|
||||||
<view class="current-meta-item current-meta-item--role">
|
|
||||||
<image
|
|
||||||
class="current-meta-icon"
|
|
||||||
src="/static/assets/foundation/transparent/meta-admin.png"
|
|
||||||
mode="aspectFit"
|
|
||||||
/>
|
|
||||||
<text class="current-meta-item-text">{{
|
|
||||||
currentRoleLabel
|
|
||||||
}}</text>
|
|
||||||
</view>
|
|
||||||
</view>
|
|
||||||
</view>
|
|
||||||
|
|
||||||
<view class="shortcut-grid">
|
|
||||||
<view
|
|
||||||
v-for="shortcut in visibleShortcuts"
|
|
||||||
:key="shortcut.key"
|
|
||||||
class="shortcut-item"
|
|
||||||
@click="openShortcut(shortcut.key)"
|
|
||||||
>
|
|
||||||
<image class="shortcut-icon" :src="shortcut.icon" mode="aspectFit" />
|
|
||||||
<text class="shortcut-label">{{ shortcut.label }}</text>
|
|
||||||
</view>
|
|
||||||
</view>
|
|
||||||
|
|
||||||
<image
|
|
||||||
class="section-divider"
|
|
||||||
src="/static/assets/modules/genealogy/transparent/section-divider.png"
|
|
||||||
mode="scaleToFill"
|
|
||||||
/>
|
|
||||||
</view>
|
|
||||||
|
|
||||||
<scroll-view
|
<scroll-view
|
||||||
class="genealogy-list-scroll"
|
class="genealogy-list-scroll"
|
||||||
scroll-y
|
scroll-y
|
||||||
@@ -126,8 +63,13 @@
|
|||||||
@scroll="handleListScroll"
|
@scroll="handleListScroll"
|
||||||
>
|
>
|
||||||
<view class="genealogy-lower">
|
<view class="genealogy-lower">
|
||||||
|
<view class="genealogy-list-toolbar">
|
||||||
|
<view class="section-heading genealogy-list-heading">
|
||||||
|
<text>{{ managedGenealogies.length ? "我管理的" : "我加入的" }}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
<view v-if="managedGenealogies.length" class="list-section">
|
<view v-if="managedGenealogies.length" class="list-section">
|
||||||
<view class="section-heading"><text>我管理的</text></view>
|
|
||||||
<GenealogyCard
|
<GenealogyCard
|
||||||
v-for="genealogy in managedGenealogies"
|
v-for="genealogy in managedGenealogies"
|
||||||
:key="genealogy.id"
|
:key="genealogy.id"
|
||||||
@@ -139,7 +81,7 @@
|
|||||||
</view>
|
</view>
|
||||||
|
|
||||||
<view v-if="memberGenealogies.length" class="list-section">
|
<view v-if="memberGenealogies.length" class="list-section">
|
||||||
<view class="section-heading"><text>我加入的</text></view>
|
<view v-if="managedGenealogies.length" class="section-heading"><text>我加入的</text></view>
|
||||||
<GenealogyCard
|
<GenealogyCard
|
||||||
v-for="genealogy in memberGenealogies"
|
v-for="genealogy in memberGenealogies"
|
||||||
:key="genealogy.id"
|
:key="genealogy.id"
|
||||||
@@ -171,85 +113,33 @@
|
|||||||
/>
|
/>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<view class="featured-media-section">
|
<view class="create-action" hover-class="action-hover" @click="openAddDialog">
|
||||||
<view class="featured-media-heading">
|
|
||||||
<view>
|
|
||||||
<text class="featured-media-heading__title">宣传视频</text>
|
|
||||||
<text class="featured-media-heading__copy">家谱文化与使用介绍</text>
|
|
||||||
</view>
|
|
||||||
<button class="featured-media-more" @click="openPlatformVideos">
|
|
||||||
查看更多
|
|
||||||
</button>
|
|
||||||
</view>
|
|
||||||
<view v-if="featuredVideoState === 'loading'" class="featured-media-state">
|
|
||||||
<AppLoading text="正在读取宣传视频" />
|
|
||||||
</view>
|
|
||||||
<view v-else-if="featuredVideoState === 'error'" class="featured-media-state">
|
|
||||||
<text>宣传视频暂时无法显示</text>
|
|
||||||
<button class="featured-media-retry" @click="loadFeaturedVideos">重新加载</button>
|
|
||||||
</view>
|
|
||||||
<view v-else-if="featuredVideos.length" class="featured-media-grid">
|
|
||||||
<view
|
|
||||||
v-for="video in featuredVideos"
|
|
||||||
:key="video.id"
|
|
||||||
class="featured-media-card"
|
|
||||||
>
|
|
||||||
<view
|
|
||||||
v-if="video.coverFile?.accessUrl"
|
|
||||||
class="featured-media-cover-button"
|
|
||||||
role="button"
|
|
||||||
:aria-label="`播放${video.title}`"
|
|
||||||
hover-class="action-hover"
|
|
||||||
@click="openFeaturedVideo(video)"
|
|
||||||
>
|
|
||||||
<image
|
|
||||||
class="featured-media-cover"
|
|
||||||
:src="video.coverFile.accessUrl"
|
|
||||||
mode="aspectFill"
|
|
||||||
/>
|
|
||||||
<view class="featured-media-play" aria-hidden="true"></view>
|
|
||||||
</view>
|
|
||||||
<video
|
|
||||||
v-else
|
|
||||||
class="featured-media-video"
|
|
||||||
:src="video.videoFile.accessUrl"
|
|
||||||
controls
|
|
||||||
object-fit="cover"
|
|
||||||
/>
|
|
||||||
<button class="featured-media-title" @click="openFeaturedVideo(video)">
|
|
||||||
{{ video.title }}
|
|
||||||
</button>
|
|
||||||
</view>
|
|
||||||
</view>
|
|
||||||
<view v-else class="featured-media-state featured-media-state--empty">
|
|
||||||
<text>暂时没有推荐视频</text>
|
|
||||||
<button class="featured-media-more featured-media-more--empty" @click="openPlatformVideos">
|
|
||||||
查看全部视频
|
|
||||||
</button>
|
|
||||||
</view>
|
|
||||||
</view>
|
|
||||||
|
|
||||||
<view class="create-action" @click="openAddDialog">
|
|
||||||
<image
|
<image
|
||||||
class="create-cloud"
|
class="create-cloud"
|
||||||
src="/static/assets/modules/genealogy/transparent/create-cloud.png"
|
src="/static/assets/modules/genealogy/transparent/create-cloud.png"
|
||||||
mode="aspectFit"
|
mode="aspectFit"
|
||||||
|
aria-hidden="true"
|
||||||
/>
|
/>
|
||||||
<image
|
<image
|
||||||
class="create-icon"
|
class="create-icon"
|
||||||
src="/static/assets/modules/genealogy/transparent/add.png"
|
src="/static/assets/modules/genealogy/transparent/add.png"
|
||||||
mode="aspectFit"
|
mode="aspectFit"
|
||||||
|
aria-hidden="true"
|
||||||
/>
|
/>
|
||||||
<text>添加家谱</text>
|
<text>添加家谱</text>
|
||||||
<image
|
<image
|
||||||
class="create-cloud create-cloud--right"
|
class="create-cloud create-cloud--right"
|
||||||
src="/static/assets/modules/genealogy/transparent/create-cloud.png"
|
src="/static/assets/modules/genealogy/transparent/create-cloud.png"
|
||||||
mode="aspectFit"
|
mode="aspectFit"
|
||||||
|
aria-hidden="true"
|
||||||
/>
|
/>
|
||||||
</view>
|
</view>
|
||||||
<AppPromotionStrip placement="home_bottom" title="家谱服务推荐" />
|
|
||||||
</view>
|
</view>
|
||||||
</scroll-view>
|
</scroll-view>
|
||||||
|
<HomeAdvertisementPanel
|
||||||
|
class="home-content-fixed"
|
||||||
|
@open="openContentHome"
|
||||||
|
/>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<view v-else class="genealogy-empty-state">
|
<view v-else class="genealogy-empty-state">
|
||||||
@@ -284,12 +174,6 @@
|
|||||||
>
|
>
|
||||||
<text class="empty-create-action__copy">创建家谱</text>
|
<text class="empty-create-action__copy">创建家谱</text>
|
||||||
</view>
|
</view>
|
||||||
<AppButton
|
|
||||||
block
|
|
||||||
type="secondary"
|
|
||||||
label="观看宣传视频"
|
|
||||||
@click="openPlatformVideos"
|
|
||||||
/>
|
|
||||||
<text class="empty-create-note"
|
<text class="empty-create-note"
|
||||||
>确认没有现有家谱后再创建,避免重复建谱</text
|
>确认没有现有家谱后再创建,避免重复建谱</text
|
||||||
>
|
>
|
||||||
@@ -322,7 +206,7 @@
|
|||||||
@saved="applySavedGenealogyOrder"
|
@saved="applySavedGenealogyOrder"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<AppTabbar active="genealogy" />
|
<AppTabbar active="genealogy" tone="light" />
|
||||||
</view>
|
</view>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -332,9 +216,9 @@ import { onBackPress, onLoad, onShow, onUnload } from "@dcloudio/uni-app";
|
|||||||
import AppLoading from "@/components/AppLoading.vue";
|
import AppLoading from "@/components/AppLoading.vue";
|
||||||
import AppTabbar from "@/components/AppTabbar.vue";
|
import AppTabbar from "@/components/AppTabbar.vue";
|
||||||
import AppButton from "@/components/AppButton.vue";
|
import AppButton from "@/components/AppButton.vue";
|
||||||
import AppPromotionStrip from "@/components/AppPromotionStrip.vue";
|
|
||||||
import GenealogyAddDialog from "@/components/genealogy/AddDialog.vue";
|
import GenealogyAddDialog from "@/components/genealogy/AddDialog.vue";
|
||||||
import GenealogyCard from "@/components/genealogy/Card.vue";
|
import GenealogyCard from "@/components/genealogy/Card.vue";
|
||||||
|
import HomeAdvertisementPanel from "@/components/genealogy/HomeAdvertisementPanel.vue";
|
||||||
import GenealogyOrderDialog from "@/components/genealogy/OrderDialog.vue";
|
import GenealogyOrderDialog from "@/components/genealogy/OrderDialog.vue";
|
||||||
import GenealogyPageBackground from "@/components/genealogy/PageBackground.vue";
|
import GenealogyPageBackground from "@/components/genealogy/PageBackground.vue";
|
||||||
import GenealogySwitcherDialog from "@/components/genealogy/SwitcherDialog.vue";
|
import GenealogySwitcherDialog from "@/components/genealogy/SwitcherDialog.vue";
|
||||||
@@ -345,12 +229,11 @@ import {
|
|||||||
} from "@/services/api/request-controller.js";
|
} from "@/services/api/request-controller.js";
|
||||||
import { getRequestErrorMessage } from "@/services/api/request-error-message.js";
|
import { getRequestErrorMessage } from "@/services/api/request-error-message.js";
|
||||||
import { genealogyApi } from "@/services/api/genealogy-service.js";
|
import { genealogyApi } from "@/services/api/genealogy-service.js";
|
||||||
import { PLATFORM_VIDEO_PLACEMENT } from "@/services/api/family-media-contract.js";
|
|
||||||
import { genealogyCapabilityApi } from "@/services/api/genealogy-capability-service.js";
|
|
||||||
import { notificationApi } from "@/services/api/notification-service.js";
|
import { notificationApi } from "@/services/api/notification-service.js";
|
||||||
import { genealogyContext } from "@/utils/genealogy/context.js";
|
import { genealogyContext } from "@/utils/genealogy/context.js";
|
||||||
import {
|
import {
|
||||||
consumeNavigationResult,
|
consumeNavigationResult,
|
||||||
|
goRoot,
|
||||||
handleBackPress,
|
handleBackPress,
|
||||||
openPage,
|
openPage,
|
||||||
runBackGuard,
|
runBackGuard,
|
||||||
@@ -372,18 +255,14 @@ const listScrollCommand = ref(0);
|
|||||||
const currentListScrollTop = ref(0);
|
const currentListScrollTop = ref(0);
|
||||||
const unreadCount = ref(0);
|
const unreadCount = ref(0);
|
||||||
const creationQuota = ref(null);
|
const creationQuota = ref(null);
|
||||||
const featuredVideos = ref([]);
|
|
||||||
const featuredVideoState = ref("loading");
|
|
||||||
const genealogyListRequestController = createRequestController();
|
const genealogyListRequestController = createRequestController();
|
||||||
const unreadRequestController = createRequestController();
|
const unreadRequestController = createRequestController();
|
||||||
const quotaRequestController = createRequestController();
|
const quotaRequestController = createRequestController();
|
||||||
const featuredVideoRequestController = createRequestController();
|
|
||||||
// uni-app 的 abort 与成功回调可能在同一事件循环竞争。控制器负责取消任务,
|
// uni-app 的 abort 与成功回调可能在同一事件循环竞争。控制器负责取消任务,
|
||||||
// generation 再阻止已经迟到的旧响应覆盖新页面状态,两层保护不能互相替代。
|
// generation 再阻止已经迟到的旧响应覆盖新页面状态,两层保护不能互相替代。
|
||||||
let genealogyLoadGeneration = 0;
|
let genealogyLoadGeneration = 0;
|
||||||
let unreadLoadGeneration = 0;
|
let unreadLoadGeneration = 0;
|
||||||
let quotaLoadGeneration = 0;
|
let quotaLoadGeneration = 0;
|
||||||
let featuredVideoLoadGeneration = 0;
|
|
||||||
let pageActive = true;
|
let pageActive = true;
|
||||||
let skipNextShowRefresh = true;
|
let skipNextShowRefresh = true;
|
||||||
|
|
||||||
@@ -485,37 +364,11 @@ const loadQuota = async () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const loadFeaturedVideos = async () => {
|
|
||||||
const generation = ++featuredVideoLoadGeneration;
|
|
||||||
featuredVideoRequestController.abort();
|
|
||||||
featuredVideoState.value = "loading";
|
|
||||||
try {
|
|
||||||
const rows = await genealogyCapabilityApi.getPlatformVideos(
|
|
||||||
PLATFORM_VIDEO_PLACEMENT.HOME_FEATURED,
|
|
||||||
{ requestController: featuredVideoRequestController },
|
|
||||||
);
|
|
||||||
if (!pageActive || generation !== featuredVideoLoadGeneration) return;
|
|
||||||
featuredVideos.value = rows.slice(0, 2);
|
|
||||||
featuredVideoState.value = "ready";
|
|
||||||
} catch (error) {
|
|
||||||
if (
|
|
||||||
!pageActive ||
|
|
||||||
generation !== featuredVideoLoadGeneration ||
|
|
||||||
isRequestCancelled(error)
|
|
||||||
) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
featuredVideos.value = [];
|
|
||||||
featuredVideoState.value = "error";
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
onLoad((query) => {
|
onLoad((query) => {
|
||||||
requestedGenealogyId.value = String(query?.genealogyId || "");
|
requestedGenealogyId.value = String(query?.genealogyId || "");
|
||||||
loadGenealogies();
|
loadGenealogies();
|
||||||
loadUnreadCount();
|
loadUnreadCount();
|
||||||
loadQuota();
|
loadQuota();
|
||||||
loadFeaturedVideos();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
onShow(() => {
|
onShow(() => {
|
||||||
@@ -527,7 +380,6 @@ onShow(() => {
|
|||||||
) {
|
) {
|
||||||
requestedGenealogyId.value = navigationResult.entityId;
|
requestedGenealogyId.value = navigationResult.entityId;
|
||||||
loadGenealogies();
|
loadGenealogies();
|
||||||
loadFeaturedVideos();
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (skipNextShowRefresh) {
|
if (skipNextShowRefresh) {
|
||||||
@@ -537,7 +389,6 @@ onShow(() => {
|
|||||||
loadGenealogies();
|
loadGenealogies();
|
||||||
loadUnreadCount();
|
loadUnreadCount();
|
||||||
loadQuota();
|
loadQuota();
|
||||||
loadFeaturedVideos();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
onUnload(() => {
|
onUnload(() => {
|
||||||
@@ -545,11 +396,9 @@ onUnload(() => {
|
|||||||
genealogyLoadGeneration += 1;
|
genealogyLoadGeneration += 1;
|
||||||
unreadLoadGeneration += 1;
|
unreadLoadGeneration += 1;
|
||||||
quotaLoadGeneration += 1;
|
quotaLoadGeneration += 1;
|
||||||
featuredVideoLoadGeneration += 1;
|
|
||||||
genealogyListRequestController.abort();
|
genealogyListRequestController.abort();
|
||||||
unreadRequestController.abort();
|
unreadRequestController.abort();
|
||||||
quotaRequestController.abort();
|
quotaRequestController.abort();
|
||||||
featuredVideoRequestController.abort();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const hasGenealogies = computed(() => genealogies.value.length > 0);
|
const hasGenealogies = computed(() => genealogies.value.length > 0);
|
||||||
@@ -572,40 +421,6 @@ const currentGenealogy = computed(
|
|||||||
(genealogy) => genealogy.id === selectedGenealogyId.value,
|
(genealogy) => genealogy.id === selectedGenealogyId.value,
|
||||||
) || null,
|
) || null,
|
||||||
);
|
);
|
||||||
const canManageCurrentGenealogy = computed(
|
|
||||||
() => currentGenealogy.value?.canManage === true,
|
|
||||||
);
|
|
||||||
const currentRoleLabel = computed(() =>
|
|
||||||
canManageCurrentGenealogy.value ? "管理员" : "成员",
|
|
||||||
);
|
|
||||||
|
|
||||||
const shortcuts = [
|
|
||||||
{
|
|
||||||
key: "tree",
|
|
||||||
label: "世系图",
|
|
||||||
icon: "/static/assets/modules/genealogy/transparent/shortcut-tree.png",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "members",
|
|
||||||
label: "成员",
|
|
||||||
icon: "/static/assets/modules/genealogy/transparent/shortcut-members.png",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "poem",
|
|
||||||
label: "字辈诗",
|
|
||||||
icon: "/static/assets/modules/genealogy/transparent/shortcut-generation-poem.png",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "applications",
|
|
||||||
label: "申请审核",
|
|
||||||
icon: "/static/assets/modules/genealogy/transparent/shortcut-application.png",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
const visibleShortcuts = computed(() =>
|
|
||||||
canManageCurrentGenealogy.value
|
|
||||||
? shortcuts
|
|
||||||
: shortcuts.filter((shortcut) => shortcut.key !== "applications"),
|
|
||||||
);
|
|
||||||
|
|
||||||
const openGenealogy = (genealogy) =>
|
const openGenealogy = (genealogy) =>
|
||||||
openPage("G05", { genealogyId: String(genealogy.id) }, "G01").then(
|
openPage("G05", { genealogyId: String(genealogy.id) }, "G01").then(
|
||||||
@@ -644,21 +459,7 @@ const openSwitcher = () => {
|
|||||||
const closeSwitcher = () => {
|
const closeSwitcher = () => {
|
||||||
switcherVisible.value = false;
|
switcherVisible.value = false;
|
||||||
};
|
};
|
||||||
const openPlatformVideos = () =>
|
const openContentHome = () => goRoot("F01");
|
||||||
openPage(
|
|
||||||
"F11",
|
|
||||||
{ placement: PLATFORM_VIDEO_PLACEMENT.VIDEO_CENTER },
|
|
||||||
"G01",
|
|
||||||
);
|
|
||||||
const openFeaturedVideo = (video) =>
|
|
||||||
openPage(
|
|
||||||
"F11",
|
|
||||||
{
|
|
||||||
placement: PLATFORM_VIDEO_PLACEMENT.HOME_FEATURED,
|
|
||||||
videoId: String(video.id),
|
|
||||||
},
|
|
||||||
"G01",
|
|
||||||
);
|
|
||||||
const openOrderDialog = () => {
|
const openOrderDialog = () => {
|
||||||
if (genealogies.value.length < 2) return;
|
if (genealogies.value.length < 2) return;
|
||||||
orderDialogVisible.value = true;
|
orderDialogVisible.value = true;
|
||||||
@@ -714,17 +515,6 @@ const retryGenealogyLoad = () => {
|
|||||||
loadGenealogies();
|
loadGenealogies();
|
||||||
};
|
};
|
||||||
|
|
||||||
const openShortcut = (shortcutKey) => {
|
|
||||||
if (!currentGenealogy.value) return;
|
|
||||||
const genealogyId = String(currentGenealogy.value.id);
|
|
||||||
const actions = {
|
|
||||||
tree: () => openPage("T01", { genealogyId }, "G01"),
|
|
||||||
members: () => openPage("G13", { genealogyId }, "G01"),
|
|
||||||
poem: () => openPage("G12", { genealogyId }, "G01"),
|
|
||||||
applications: () => openPage("G10", { genealogyId }, "G01"),
|
|
||||||
};
|
|
||||||
return actions[shortcutKey]?.();
|
|
||||||
};
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped lang="scss">
|
<style scoped lang="scss">
|
||||||
@@ -735,12 +525,12 @@ const openShortcut = (shortcutKey) => {
|
|||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
background: #f9f6ef;
|
background: #faf8f3;
|
||||||
}
|
}
|
||||||
|
|
||||||
.genealogy-content {
|
.genealogy-content {
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
padding: 24rpx 32rpx calc(176rpx + env(safe-area-inset-bottom));
|
padding: 0 32rpx calc(176rpx + env(safe-area-inset-bottom));
|
||||||
}
|
}
|
||||||
|
|
||||||
.genealogy-index--split {
|
.genealogy-index--split {
|
||||||
@@ -760,10 +550,6 @@ const openShortcut = (shortcutKey) => {
|
|||||||
padding-bottom: 0;
|
padding-bottom: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.genealogy-fixed-zone {
|
|
||||||
flex: 0 0 auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.genealogy-list-scroll {
|
.genealogy-list-scroll {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 0;
|
height: 0;
|
||||||
@@ -771,151 +557,25 @@ const openShortcut = (shortcutKey) => {
|
|||||||
flex: 1;
|
flex: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.current-slip {
|
.genealogy-list-toolbar {
|
||||||
@include adaptive-genealogy-current-slip;
|
display: flex;
|
||||||
display: block;
|
align-items: center;
|
||||||
min-height: 300rpx;
|
min-height: 70rpx;
|
||||||
padding: 30rpx 34rpx 28rpx;
|
padding: 0 4rpx 2rpx;
|
||||||
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
.current-seal {
|
.genealogy-list-heading {
|
||||||
display: flex;
|
|
||||||
width: 80rpx;
|
|
||||||
height: 128rpx;
|
|
||||||
flex: 0 0 auto;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
margin-right: 24rpx;
|
|
||||||
background: url("/static/assets/modules/genealogy/transparent/current-seal-frame.png")
|
|
||||||
center / contain no-repeat;
|
|
||||||
color: #fff7e7;
|
|
||||||
}
|
|
||||||
.current-seal-title {
|
|
||||||
color: #fff7e7;
|
|
||||||
font-family: "STKaiti", "KaiTi", serif;
|
|
||||||
font-size: clamp(17px, 32rpx, 22px);
|
|
||||||
font-weight: 700;
|
|
||||||
letter-spacing: 2rpx;
|
|
||||||
writing-mode: vertical-rl;
|
|
||||||
}
|
|
||||||
.current-summary {
|
|
||||||
display: flex;
|
|
||||||
min-width: 0;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
.current-name {
|
|
||||||
min-width: 0;
|
|
||||||
flex: 1;
|
flex: 1;
|
||||||
color: $ink;
|
margin-bottom: 0;
|
||||||
font-family: "STKaiti", "KaiTi", serif;
|
|
||||||
font-size: clamp(18px, 34rpx, 24px);
|
|
||||||
font-weight: 700;
|
|
||||||
line-height: 1.25;
|
|
||||||
overflow: hidden;
|
|
||||||
overflow-wrap: anywhere;
|
|
||||||
display: -webkit-box;
|
|
||||||
-webkit-box-orient: vertical;
|
|
||||||
-webkit-line-clamp: 2;
|
|
||||||
}
|
|
||||||
.current-switch-copy {
|
|
||||||
flex: 0 0 auto;
|
|
||||||
margin-left: 16rpx;
|
|
||||||
padding: 4rpx 12rpx;
|
|
||||||
border: 1rpx solid rgba(159, 23, 15, 0.34);
|
|
||||||
border-radius: 999rpx;
|
|
||||||
color: $brand-red;
|
|
||||||
font-size: clamp(15px, 25rpx, 18px);
|
|
||||||
font-weight: 600;
|
|
||||||
line-height: 1.3;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
.current-info-divider {
|
|
||||||
width: 100%;
|
|
||||||
height: 1rpx;
|
|
||||||
margin: 18rpx 0 16rpx;
|
|
||||||
background: rgba(181, 138, 75, 0.48);
|
|
||||||
}
|
|
||||||
|
|
||||||
.current-meta {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: minmax(0, 1fr) auto;
|
|
||||||
column-gap: 28rpx;
|
|
||||||
row-gap: 10rpx;
|
|
||||||
margin-top: 0;
|
|
||||||
color: #62584c;
|
|
||||||
font-size: clamp(15px, 25rpx, 18px);
|
|
||||||
font-weight: 500;
|
|
||||||
line-height: 1.4;
|
|
||||||
}
|
|
||||||
|
|
||||||
.current-meta-item {
|
|
||||||
display: flex;
|
|
||||||
min-width: 0;
|
|
||||||
align-items: center;
|
|
||||||
margin: 0;
|
|
||||||
|
|
||||||
.current-meta-item-text {
|
|
||||||
min-width: 0;
|
|
||||||
overflow-wrap: anywhere;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.current-meta-item--location {
|
|
||||||
grid-column: 1 / -1;
|
|
||||||
}
|
|
||||||
.current-meta-item--role {
|
|
||||||
justify-self: end;
|
|
||||||
}
|
|
||||||
.current-meta-icon {
|
|
||||||
width: 34rpx;
|
|
||||||
height: 34rpx;
|
|
||||||
flex: 0 0 auto;
|
|
||||||
margin-right: 8rpx;
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.shortcut-grid {
|
|
||||||
display: flex;
|
|
||||||
min-height: 168rpx;
|
|
||||||
align-items: center;
|
|
||||||
margin-top: 18rpx;
|
|
||||||
padding: 8rpx 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.shortcut-item {
|
|
||||||
display: flex;
|
|
||||||
min-width: 0;
|
|
||||||
flex: 1;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
.shortcut-icon {
|
|
||||||
width: 76rpx;
|
|
||||||
height: 76rpx;
|
|
||||||
}
|
|
||||||
.shortcut-label {
|
|
||||||
width: 100%;
|
|
||||||
margin-top: 10rpx;
|
|
||||||
color: $ink;
|
|
||||||
font-family: "STKaiti", "KaiTi", serif;
|
|
||||||
font-size: clamp(16px, 27rpx, 19px);
|
|
||||||
font-weight: 700;
|
|
||||||
text-align: center;
|
|
||||||
overflow-wrap: anywhere;
|
|
||||||
}
|
|
||||||
|
|
||||||
.section-divider {
|
|
||||||
display: block;
|
|
||||||
width: 100%;
|
|
||||||
height: 30rpx;
|
|
||||||
margin: 4rpx 0 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.genealogy-lower {
|
.genealogy-lower {
|
||||||
padding: 12rpx 0 36rpx;
|
padding: 0 0 24rpx;
|
||||||
}
|
}
|
||||||
|
|
||||||
.list-section {
|
.list-section {
|
||||||
margin-top: 16rpx;
|
margin-top: 6rpx;
|
||||||
}
|
}
|
||||||
|
|
||||||
.section-heading {
|
.section-heading {
|
||||||
@@ -951,7 +611,7 @@ const openShortcut = (shortcutKey) => {
|
|||||||
padding: 24rpx 28rpx;
|
padding: 24rpx 28rpx;
|
||||||
border: 1rpx solid rgba(149, 103, 49, 0.24);
|
border: 1rpx solid rgba(149, 103, 49, 0.24);
|
||||||
border-radius: 14rpx;
|
border-radius: 14rpx;
|
||||||
background: rgba(255, 250, 240, 0.72);
|
background: rgba(255, 253, 249, 0.84);
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -978,164 +638,17 @@ const openShortcut = (shortcutKey) => {
|
|||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.featured-media-section {
|
|
||||||
margin: 0 0 24rpx;
|
|
||||||
padding: 26rpx 24rpx;
|
|
||||||
border: 1rpx solid rgba(149, 103, 49, 0.24);
|
|
||||||
border-radius: 14rpx;
|
|
||||||
background: rgba(255, 250, 240, 0.72);
|
|
||||||
}
|
|
||||||
|
|
||||||
.featured-media-heading {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 20rpx;
|
|
||||||
}
|
|
||||||
|
|
||||||
.featured-media-heading__title,
|
|
||||||
.featured-media-heading__copy {
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
|
|
||||||
.featured-media-heading__title {
|
|
||||||
color: #5c4330;
|
|
||||||
font-size: clamp(16px, 28rpx, 20px);
|
|
||||||
font-weight: 700;
|
|
||||||
}
|
|
||||||
|
|
||||||
.featured-media-heading__copy {
|
|
||||||
margin-top: 8rpx;
|
|
||||||
color: #8a7564;
|
|
||||||
font-size: clamp(14px, 23rpx, 17px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.featured-media-more,
|
|
||||||
.featured-media-retry,
|
|
||||||
.featured-media-title {
|
|
||||||
min-height: var(--app-touch-min);
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
border: 0;
|
|
||||||
background: transparent;
|
|
||||||
line-height: 1.4;
|
|
||||||
}
|
|
||||||
|
|
||||||
.featured-media-more::after,
|
|
||||||
.featured-media-retry::after,
|
|
||||||
.featured-media-title::after {
|
|
||||||
border: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.featured-media-more,
|
|
||||||
.featured-media-retry {
|
|
||||||
flex: 0 0 auto;
|
|
||||||
color: $brand-red;
|
|
||||||
font-size: clamp(13px, 22rpx, 16px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.featured-media-state {
|
|
||||||
display: flex;
|
|
||||||
min-height: 176rpx;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
gap: 10rpx;
|
|
||||||
color: $ink-muted;
|
|
||||||
font-size: clamp(14px, 23rpx, 17px);
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.featured-media-state--empty {
|
|
||||||
min-height: 138rpx;
|
|
||||||
}
|
|
||||||
|
|
||||||
.featured-media-more--empty {
|
|
||||||
margin-top: 4rpx;
|
|
||||||
}
|
|
||||||
|
|
||||||
.featured-media-grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
|
||||||
gap: 16rpx;
|
|
||||||
margin-top: 22rpx;
|
|
||||||
}
|
|
||||||
|
|
||||||
.featured-media-card {
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.featured-media-cover-button,
|
|
||||||
.featured-media-video {
|
|
||||||
width: 100%;
|
|
||||||
height: 176rpx;
|
|
||||||
border-radius: 10rpx;
|
|
||||||
background: #1f1b17;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.featured-media-cover-button {
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
|
|
||||||
.featured-media-cover {
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.featured-media-play {
|
|
||||||
position: absolute;
|
|
||||||
top: 50%;
|
|
||||||
left: 50%;
|
|
||||||
display: flex;
|
|
||||||
width: 58rpx;
|
|
||||||
height: 58rpx;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
border: 2rpx solid rgba(255, 255, 255, 0.9);
|
|
||||||
border-radius: 50%;
|
|
||||||
background: rgba(45, 29, 19, 0.62);
|
|
||||||
transform: translate(-50%, -50%);
|
|
||||||
}
|
|
||||||
|
|
||||||
.featured-media-play::after {
|
|
||||||
width: 0;
|
|
||||||
height: 0;
|
|
||||||
margin-left: 5rpx;
|
|
||||||
border-top: 10rpx solid transparent;
|
|
||||||
border-bottom: 10rpx solid transparent;
|
|
||||||
border-left: 16rpx solid #fff;
|
|
||||||
content: "";
|
|
||||||
}
|
|
||||||
|
|
||||||
.featured-media-title {
|
|
||||||
display: block;
|
|
||||||
width: 100%;
|
|
||||||
margin-top: 8rpx;
|
|
||||||
color: $ink;
|
|
||||||
font-size: clamp(14px, 24rpx, 17px);
|
|
||||||
font-weight: 600;
|
|
||||||
text-align: left;
|
|
||||||
white-space: nowrap;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
}
|
|
||||||
|
|
||||||
.create-action {
|
.create-action {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
width: 350rpx;
|
||||||
min-height: 96rpx;
|
min-height: 96rpx;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
box-sizing: border-box;
|
margin: 8rpx auto 24rpx;
|
||||||
font-size: clamp(17px, 30rpx, 22px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.create-action {
|
|
||||||
width: 350rpx;
|
|
||||||
margin: 32rpx auto 0;
|
|
||||||
border: 2rpx solid $brand-red;
|
border: 2rpx solid $brand-red;
|
||||||
border-radius: 48rpx;
|
border-radius: 48rpx;
|
||||||
background: rgba(255, 249, 238, 0.66);
|
background: rgba(255, 253, 249, 0.8);
|
||||||
|
box-sizing: border-box;
|
||||||
color: $brand-red;
|
color: $brand-red;
|
||||||
font-family: "STKaiti", "KaiTi", serif;
|
font-family: "STKaiti", "KaiTi", serif;
|
||||||
font-size: clamp(17px, 32rpx, 22px);
|
font-size: clamp(17px, 32rpx, 22px);
|
||||||
@@ -1144,28 +657,31 @@ const openShortcut = (shortcutKey) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.create-icon {
|
.create-icon {
|
||||||
width: 38rpx;
|
|
||||||
height: 38rpx;
|
|
||||||
margin-right: 12rpx;
|
|
||||||
}
|
|
||||||
.create-action .create-icon {
|
|
||||||
width: 40rpx;
|
width: 40rpx;
|
||||||
height: 40rpx;
|
height: 40rpx;
|
||||||
margin: 0 8rpx;
|
margin: 0 8rpx;
|
||||||
}
|
}
|
||||||
|
|
||||||
.create-cloud {
|
.create-cloud {
|
||||||
width: 54rpx;
|
width: 54rpx;
|
||||||
height: 28rpx;
|
height: 28rpx;
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.create-action > text {
|
.create-action > text {
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.create-cloud--right {
|
.create-cloud--right {
|
||||||
transform: scaleX(-1);
|
transform: scaleX(-1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.home-content-fixed {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
margin-top: 8rpx;
|
||||||
|
}
|
||||||
|
|
||||||
.state-panel {
|
.state-panel {
|
||||||
display: flex;
|
display: flex;
|
||||||
min-height: 540rpx;
|
min-height: 540rpx;
|
||||||
@@ -1382,16 +898,6 @@ const openShortcut = (shortcutKey) => {
|
|||||||
padding-right: 20rpx;
|
padding-right: 20rpx;
|
||||||
padding-left: 20rpx;
|
padding-left: 20rpx;
|
||||||
}
|
}
|
||||||
.current-name {
|
|
||||||
font-size: clamp(20px, 40rpx, 26px);
|
|
||||||
}
|
|
||||||
.shortcut-label {
|
|
||||||
font-size: clamp(14px, 23rpx, 17px);
|
|
||||||
}
|
|
||||||
.current-meta-icon {
|
|
||||||
width: 32rpx;
|
|
||||||
height: 32rpx;
|
|
||||||
}
|
|
||||||
.create-action {
|
.create-action {
|
||||||
width: 320rpx;
|
width: 320rpx;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -198,15 +198,26 @@
|
|||||||
</template>
|
</template>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<view class="overview-family" @click="toFamily">
|
<view class="overview-family-content">
|
||||||
<view>
|
<view class="overview-family-content__heading">
|
||||||
<text>家族近况</text>
|
<text>家族内容</text>
|
||||||
<text>进入家族圈查看动态</text>
|
<text>按用途进入动态、影像与家族事务</text>
|
||||||
|
</view>
|
||||||
|
<view
|
||||||
|
v-for="group in familyContentGroups"
|
||||||
|
:key="group.key"
|
||||||
|
class="overview-family-group"
|
||||||
|
>
|
||||||
|
<text class="overview-family-group__title">{{ group.title }}</text>
|
||||||
|
<view class="overview-family-grid">
|
||||||
|
<button
|
||||||
|
v-for="item in group.items"
|
||||||
|
:key="item.key"
|
||||||
|
class="overview-family-link"
|
||||||
|
@click="openFamilySection(item.key)"
|
||||||
|
>{{ item.label }}</button>
|
||||||
|
</view>
|
||||||
</view>
|
</view>
|
||||||
<image
|
|
||||||
src="/static/assets/foundation/transparent/chevron-right.png"
|
|
||||||
mode="aspectFit"
|
|
||||||
/>
|
|
||||||
</view>
|
</view>
|
||||||
<view class="overview-note">
|
<view class="overview-note">
|
||||||
<text class="overview-note__title">成员资料按家谱访问规则保护</text>
|
<text class="overview-note__title">成员资料按家谱访问规则保护</text>
|
||||||
@@ -280,7 +291,6 @@ import { getRequestErrorMessage } from "@/services/api/request-error-message.js"
|
|||||||
import { getGenealogyAccessPresetLabel } from "@/utils/genealogy/access-policy.js";
|
import { getGenealogyAccessPresetLabel } from "@/utils/genealogy/access-policy.js";
|
||||||
import {
|
import {
|
||||||
goBack,
|
goBack,
|
||||||
goRoot,
|
|
||||||
handleBackPress,
|
handleBackPress,
|
||||||
openPage,
|
openPage,
|
||||||
returnTo,
|
returnTo,
|
||||||
@@ -378,7 +388,53 @@ onUnload(() => {
|
|||||||
const reloadOverview = () => loadGenealogy({ genealogyId: genealogyId.value });
|
const reloadOverview = () => loadGenealogy({ genealogyId: genealogyId.value });
|
||||||
const toGenealogies = () => returnTo("G01", {});
|
const toGenealogies = () => returnTo("G01", {});
|
||||||
const toTree = () => openPage("T01", { genealogyId: genealogyId.value }, "G05");
|
const toTree = () => openPage("T01", { genealogyId: genealogyId.value }, "G05");
|
||||||
const toFamily = () => goRoot("F01", { genealogyId: genealogyId.value });
|
const familyContentGroups = [
|
||||||
|
{
|
||||||
|
key: "memories",
|
||||||
|
title: "家族记录",
|
||||||
|
items: [
|
||||||
|
{ key: "feed", label: "家族动态" },
|
||||||
|
{ key: "articles", label: "谱文" },
|
||||||
|
{ key: "albums", label: "相册" },
|
||||||
|
{ key: "videos", label: "家族视频" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "affairs",
|
||||||
|
title: "家族事务",
|
||||||
|
items: [
|
||||||
|
{ key: "rituals", label: "礼仪" },
|
||||||
|
{ key: "memos", label: "备忘" },
|
||||||
|
{ key: "benefactors", label: "家族恩人" },
|
||||||
|
{ key: "people", label: "人物录" },
|
||||||
|
{ key: "gifts", label: "贺礼簿" },
|
||||||
|
{ key: "merits", label: "功德录" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
const openFamilySection = (key) => {
|
||||||
|
if (!genealogyId.value) return;
|
||||||
|
if (key === "feed") return openPage("F12", { genealogyId: genealogyId.value }, "G05");
|
||||||
|
const routes = {
|
||||||
|
articles: "F04",
|
||||||
|
albums: "F07",
|
||||||
|
rituals: "R05",
|
||||||
|
memos: "R10",
|
||||||
|
benefactors: "R10",
|
||||||
|
people: "R01",
|
||||||
|
gifts: "R03",
|
||||||
|
merits: "R11",
|
||||||
|
videos: "F10",
|
||||||
|
};
|
||||||
|
return openPage(
|
||||||
|
routes[key],
|
||||||
|
{
|
||||||
|
genealogyId: genealogyId.value,
|
||||||
|
...(key === "benefactors" ? { memoType: "benefactor" } : {}),
|
||||||
|
},
|
||||||
|
"G05",
|
||||||
|
);
|
||||||
|
};
|
||||||
const toApplications = () =>
|
const toApplications = () =>
|
||||||
openPage("G10", { genealogyId: genealogyId.value }, "G05");
|
openPage("G10", { genealogyId: genealogyId.value }, "G05");
|
||||||
const toSettings = () =>
|
const toSettings = () =>
|
||||||
@@ -573,8 +629,7 @@ onBackPress((event) => handleBackPress(event, requestBack));
|
|||||||
.overview-summary text {
|
.overview-summary text {
|
||||||
display: block;
|
display: block;
|
||||||
}
|
}
|
||||||
.overview-action__chevron,
|
.overview-action__chevron {
|
||||||
.overview-family image {
|
|
||||||
width: 26rpx;
|
width: 26rpx;
|
||||||
height: 26rpx;
|
height: 26rpx;
|
||||||
opacity: 0.68;
|
opacity: 0.68;
|
||||||
@@ -593,27 +648,67 @@ onBackPress((event) => handleBackPress(event, requestBack));
|
|||||||
grid-template-columns: minmax(0, 1fr);
|
grid-template-columns: minmax(0, 1fr);
|
||||||
gap: 0;
|
gap: 0;
|
||||||
}
|
}
|
||||||
.overview-family {
|
.overview-family-content {
|
||||||
display: grid;
|
|
||||||
grid-template-columns: minmax(0, 1fr) 26rpx;
|
|
||||||
align-items: center;
|
|
||||||
gap: 20rpx;
|
|
||||||
margin: 14rpx 42rpx 0;
|
margin: 14rpx 42rpx 0;
|
||||||
padding: 20rpx 0;
|
padding: 24rpx 0 0;
|
||||||
border-top: 1rpx solid rgba(152, 119, 72, 0.28);
|
border-top: 1rpx solid rgba(152, 119, 72, 0.28);
|
||||||
|
}
|
||||||
|
|
||||||
|
.overview-family-content__heading text {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.overview-family-content__heading text:first-child {
|
||||||
|
color: $ink;
|
||||||
|
font-family: "STKaiti", "KaiTi", serif;
|
||||||
|
font-size: clamp(18px, 31rpx, 22px);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.overview-family-content__heading text:last-child {
|
||||||
|
margin-top: 5rpx;
|
||||||
color: $ink-muted;
|
color: $ink-muted;
|
||||||
font-size: clamp(13px, 20rpx, 16px);
|
font-size: clamp(13px, 20rpx, 16px);
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
}
|
}
|
||||||
.overview-family text {
|
|
||||||
display: block;
|
.overview-family-group {
|
||||||
|
margin-top: 22rpx;
|
||||||
}
|
}
|
||||||
.overview-family text:first-child {
|
|
||||||
margin-bottom: 3rpx;
|
.overview-family-group__title {
|
||||||
|
display: block;
|
||||||
|
color: #7f4f16;
|
||||||
|
font-size: clamp(14px, 22rpx, 17px);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.overview-family-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
gap: 12rpx;
|
||||||
|
margin-top: 12rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.overview-family-link {
|
||||||
|
display: flex;
|
||||||
|
min-height: 76rpx;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
margin: 0;
|
||||||
|
padding: 8rpx 10rpx;
|
||||||
|
border: 1rpx solid rgba(181, 137, 63, 0.34);
|
||||||
|
border-radius: 8rpx;
|
||||||
|
background: rgba(247, 237, 218, 0.76);
|
||||||
color: $ink;
|
color: $ink;
|
||||||
font-family: "STKaiti", "KaiTi", serif;
|
font-family: "STKaiti", "KaiTi", serif;
|
||||||
font-size: clamp(16px, 27rpx, 20px);
|
font-size: clamp(14px, 22rpx, 17px);
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
|
line-height: 1.35;
|
||||||
|
}
|
||||||
|
|
||||||
|
.overview-family-link::after {
|
||||||
|
border: 0;
|
||||||
}
|
}
|
||||||
.overview-note {
|
.overview-note {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -79,36 +79,6 @@
|
|||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<view class="profile-metadata" aria-label="个人资料摘要">
|
|
||||||
<view class="profile-metadata__item">
|
|
||||||
<image
|
|
||||||
class="profile-metadata__icon"
|
|
||||||
src="/static/assets/modules/profile/transparent/icon-person.png"
|
|
||||||
mode="aspectFit"
|
|
||||||
aria-hidden="true"
|
|
||||||
/>
|
|
||||||
<view><text>性别</text><text class="app-single-line">{{ sexText }}</text></view>
|
|
||||||
</view>
|
|
||||||
<view class="profile-metadata__item">
|
|
||||||
<image
|
|
||||||
class="profile-metadata__icon"
|
|
||||||
src="/static/assets/modules/profile/transparent/icon-calendar.png"
|
|
||||||
mode="aspectFit"
|
|
||||||
aria-hidden="true"
|
|
||||||
/>
|
|
||||||
<view><text>生日</text><text class="app-single-line">{{ birthdayText }}</text></view>
|
|
||||||
</view>
|
|
||||||
<view class="profile-metadata__item profile-metadata__item--email">
|
|
||||||
<image
|
|
||||||
class="profile-metadata__icon"
|
|
||||||
src="/static/assets/modules/profile/transparent/icon-envelope.png"
|
|
||||||
mode="aspectFit"
|
|
||||||
aria-hidden="true"
|
|
||||||
/>
|
|
||||||
<view><text>邮箱</text><text class="app-long-value">{{ profile.email || "未设置" }}</text></view>
|
|
||||||
</view>
|
|
||||||
</view>
|
|
||||||
|
|
||||||
<view class="profile-preference" aria-label="个性化推荐设置">
|
<view class="profile-preference" aria-label="个性化推荐设置">
|
||||||
<view>
|
<view>
|
||||||
<text>个性化推荐</text>
|
<text>个性化推荐</text>
|
||||||
@@ -165,9 +135,12 @@
|
|||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
<HomeAdvertisementPanel
|
||||||
|
class="profile-home-advertisements"
|
||||||
|
@open="openContentHome"
|
||||||
|
/>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<AppPromotionStrip v-if="!loading && !profileHomeError" placement="profile_bottom" title="为你推荐" />
|
|
||||||
<AppTabbar active="profile" />
|
<AppTabbar active="profile" />
|
||||||
</view>
|
</view>
|
||||||
</template>
|
</template>
|
||||||
@@ -177,18 +150,16 @@ import { computed, reactive, ref } from "vue";
|
|||||||
import { onPageScroll, onShow, onUnload } from "@dcloudio/uni-app";
|
import { onPageScroll, onShow, onUnload } from "@dcloudio/uni-app";
|
||||||
import AppAvatar from "@/components/AppAvatar.vue";
|
import AppAvatar from "@/components/AppAvatar.vue";
|
||||||
import AppButton from "@/components/AppButton.vue";
|
import AppButton from "@/components/AppButton.vue";
|
||||||
import AppPromotionStrip from "@/components/AppPromotionStrip.vue";
|
|
||||||
import AppTabbar from "@/components/AppTabbar.vue";
|
import AppTabbar from "@/components/AppTabbar.vue";
|
||||||
import {
|
import HomeAdvertisementPanel from "@/components/genealogy/HomeAdvertisementPanel.vue";
|
||||||
PROFILE_SEX_OPTIONS
|
|
||||||
} from "@/services/api/profile-contract.js";
|
|
||||||
import {
|
import {
|
||||||
createRequestController,
|
createRequestController,
|
||||||
isRequestCancelled
|
isRequestCancelled
|
||||||
} from "@/services/api/request-controller.js";
|
} from "@/services/api/request-controller.js";
|
||||||
import { profileApi } from "@/services/api/profile-service.js";
|
import { profileApi } from "@/services/api/profile-service.js";
|
||||||
import { getRequestErrorMessage } from "@/services/api/request-error-message.js";
|
import { getRequestErrorMessage } from "@/services/api/request-error-message.js";
|
||||||
import { openPage } from "@/utils/navigation/gateway.js";
|
import { goRoot, openPage } from "@/utils/navigation/gateway.js";
|
||||||
|
import { genealogyContext } from "@/utils/genealogy/context.js";
|
||||||
|
|
||||||
const profile = reactive({
|
const profile = reactive({
|
||||||
avatarFile: null,
|
avatarFile: null,
|
||||||
@@ -211,84 +182,46 @@ const preferenceSaving = ref(false);
|
|||||||
const preferenceError = ref("");
|
const preferenceError = ref("");
|
||||||
let isPageActive = true;
|
let isPageActive = true;
|
||||||
|
|
||||||
const serviceGroups = [
|
const profileIcons = Object.freeze({
|
||||||
{
|
security: "/static/assets/modules/profile/transparent/icon-security.png",
|
||||||
title: "账户与服务",
|
notifications: "/static/assets/modules/profile/transparent/icon-message.png",
|
||||||
items: [
|
vip: "/static/assets/modules/profile/transparent/icon-vip.png",
|
||||||
{
|
help: "/static/assets/modules/profile/transparent/icon-help.png",
|
||||||
routeKey: "M03",
|
feedback: "/static/assets/modules/profile/transparent/icon-feedback.png",
|
||||||
label: "账号与安全",
|
settings: "/static/assets/modules/profile/transparent/icon-settings.png",
|
||||||
iconVariant: "security",
|
});
|
||||||
icon: "/static/assets/modules/profile/transparent/icon-security.png",
|
|
||||||
},
|
const serviceItem = (routeKey, label, iconVariant, params = null) => ({
|
||||||
{
|
routeKey,
|
||||||
routeKey: "N01",
|
label,
|
||||||
label: "消息中心",
|
iconVariant,
|
||||||
iconVariant: "notifications",
|
icon: profileIcons[iconVariant],
|
||||||
icon: "/static/assets/modules/profile/transparent/icon-message.png",
|
...(params ? { params } : {}),
|
||||||
},
|
});
|
||||||
{
|
|
||||||
routeKey: "M11",
|
const serviceGroups = Object.freeze([
|
||||||
label: "活动邀请",
|
|
||||||
iconVariant: "invitations",
|
|
||||||
icon: "/static/assets/modules/profile/transparent/icon-message.png",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
routeKey: "M09",
|
|
||||||
label: "VIP 服务",
|
|
||||||
iconVariant: "vip",
|
|
||||||
icon: "/static/assets/modules/profile/transparent/icon-vip.png",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
routeKey: "M12",
|
|
||||||
label: "收益与提现",
|
|
||||||
iconVariant: "earnings",
|
|
||||||
icon: "/static/assets/modules/profile/transparent/icon-vip.png",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
title: "支持与其他",
|
title: "支持与其他",
|
||||||
items: [
|
items: [
|
||||||
|
serviceItem("M06", "帮助中心", "help"),
|
||||||
|
serviceItem("M07", "意见反馈", "feedback"),
|
||||||
|
serviceItem("M03", "账号与安全", "security"),
|
||||||
|
serviceItem("M10", "关于与设置", "settings"),
|
||||||
|
serviceItem("F13", "网站公告", "notifications", {
|
||||||
|
title: "网站公告",
|
||||||
|
articleType: "notice",
|
||||||
|
}),
|
||||||
|
serviceItem("F13", "家谱新闻列表(图文)", "feedback", {
|
||||||
|
title: "家谱新闻",
|
||||||
|
articleType: "news",
|
||||||
|
}),
|
||||||
{
|
{
|
||||||
routeKey: "M06",
|
...serviceItem("F04", "家谱系列文章(图文)", "feedback"),
|
||||||
label: "帮助中心",
|
requiresGenealogy: true,
|
||||||
iconVariant: "help",
|
|
||||||
icon: "/static/assets/modules/profile/transparent/icon-help.png",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
routeKey: "M07",
|
|
||||||
label: "意见反馈",
|
|
||||||
iconVariant: "feedback",
|
|
||||||
icon: "/static/assets/modules/profile/transparent/icon-feedback.png",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
routeKey: "M08",
|
|
||||||
label: "应用推广",
|
|
||||||
iconVariant: "promotion",
|
|
||||||
icon: "/static/assets/modules/profile/transparent/icon-promotion.png",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
routeKey: "M10",
|
|
||||||
label: "关于与设置",
|
|
||||||
iconVariant: "settings",
|
|
||||||
icon: "/static/assets/modules/profile/transparent/icon-settings.png",
|
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
];
|
]);
|
||||||
|
|
||||||
const profileSexLabels = Object.freeze(
|
|
||||||
Object.fromEntries(PROFILE_SEX_OPTIONS.map(({ value, label }) => [value, label])),
|
|
||||||
);
|
|
||||||
const sexText = computed(
|
|
||||||
() => profileSexLabels[String(profile.sex)] || "未设置",
|
|
||||||
);
|
|
||||||
const birthdayText = computed(() =>
|
|
||||||
/^\d{4}-\d{2}-\d{2}/.test(profile.birthday)
|
|
||||||
? profile.birthday.slice(0, 10)
|
|
||||||
: "未设置",
|
|
||||||
);
|
|
||||||
|
|
||||||
const loadProfile = async () => {
|
const loadProfile = async () => {
|
||||||
if (loading.value) return;
|
if (loading.value) return;
|
||||||
@@ -341,9 +274,21 @@ const changeRecommendationPreference = async (event) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const openService = (item) => openPage(item.routeKey, {}, "M01");
|
const openService = (item) => {
|
||||||
|
const params = { ...(item.params || {}) };
|
||||||
|
if (item.requiresGenealogy) {
|
||||||
|
const genealogyId = genealogyContext.getCurrentGenealogyId();
|
||||||
|
if (!genealogyId) {
|
||||||
|
uni.showToast({ title: "请先在家谱页选择家谱", icon: "none" });
|
||||||
|
return Promise.resolve(false);
|
||||||
|
}
|
||||||
|
params.genealogyId = genealogyId;
|
||||||
|
}
|
||||||
|
return openPage(item.routeKey, params, "M01");
|
||||||
|
};
|
||||||
const openEdit = () => openPage("M02", {}, "M01");
|
const openEdit = () => openPage("M02", {}, "M01");
|
||||||
const openSettings = () => openPage("M10", {}, "M01");
|
const openSettings = () => openPage("M10", {}, "M01");
|
||||||
|
const openContentHome = () => goRoot("F01");
|
||||||
|
|
||||||
onShow(() => {
|
onShow(() => {
|
||||||
void loadProfile();
|
void loadProfile();
|
||||||
@@ -352,13 +297,14 @@ onShow(() => {
|
|||||||
onPageScroll(({ scrollTop = 0 }) => {
|
onPageScroll(({ scrollTop = 0 }) => {
|
||||||
compactHeader.value = scrollTop > 44;
|
compactHeader.value = scrollTop > 44;
|
||||||
});
|
});
|
||||||
onUnload(() => {
|
const disposeProfilePortal = () => {
|
||||||
isPageActive = false;
|
isPageActive = false;
|
||||||
compactHeader.value = false;
|
compactHeader.value = false;
|
||||||
profileReadController.abort();
|
profileReadController.abort();
|
||||||
preferenceReadController.abort();
|
preferenceReadController.abort();
|
||||||
preferenceSaveController.abort();
|
preferenceSaveController.abort();
|
||||||
});
|
};
|
||||||
|
onUnload(disposeProfilePortal);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped lang="scss">
|
<style scoped lang="scss">
|
||||||
@@ -611,68 +557,6 @@ onUnload(() => {
|
|||||||
border: 0;
|
border: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.profile-metadata {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
|
||||||
z-index: 1;
|
|
||||||
margin: -36rpx 34rpx 0;
|
|
||||||
padding: 10rpx 12rpx 12rpx;
|
|
||||||
border: 1rpx solid rgba(117, 83, 52, 0.09);
|
|
||||||
border-radius: 18rpx;
|
|
||||||
background: rgba(239, 234, 224, 0.92);
|
|
||||||
}
|
|
||||||
|
|
||||||
.profile-metadata__item {
|
|
||||||
display: flex;
|
|
||||||
min-width: 0;
|
|
||||||
align-items: flex-start;
|
|
||||||
gap: 12rpx;
|
|
||||||
padding: 14rpx 12rpx;
|
|
||||||
border-right: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.profile-metadata__item:first-child {
|
|
||||||
border-right: 1rpx solid rgba(117, 83, 52, 0.16);
|
|
||||||
}
|
|
||||||
|
|
||||||
.profile-metadata__item--email {
|
|
||||||
grid-column: 1 / -1;
|
|
||||||
border-top: 1rpx solid rgba(117, 83, 52, 0.16);
|
|
||||||
}
|
|
||||||
|
|
||||||
.profile-metadata__icon {
|
|
||||||
width: 56rpx;
|
|
||||||
height: 56rpx;
|
|
||||||
flex: 0 0 auto;
|
|
||||||
filter: sepia(0.7) saturate(0.8) hue-rotate(330deg) brightness(0.82);
|
|
||||||
}
|
|
||||||
|
|
||||||
.profile-metadata__item view {
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.profile-metadata__item text {
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
|
|
||||||
.profile-metadata__item text:first-child {
|
|
||||||
color: #857263;
|
|
||||||
font-size: clamp(13px, 20rpx, 16px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.profile-metadata__item text:last-child {
|
|
||||||
margin-top: 4rpx;
|
|
||||||
color: #382c25;
|
|
||||||
font-size: clamp(15px, 24rpx, 18px);
|
|
||||||
line-height: 1.25;
|
|
||||||
overflow-wrap: anywhere;
|
|
||||||
}
|
|
||||||
|
|
||||||
.profile-metadata__item--email text:last-child {
|
|
||||||
font-size: clamp(13px, 20rpx, 16px);
|
|
||||||
line-height: 1.35;
|
|
||||||
}
|
|
||||||
|
|
||||||
.profile-preference {
|
.profile-preference {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -717,6 +601,10 @@ onUnload(() => {
|
|||||||
margin: 49rpx 58rpx 0 48rpx;
|
margin: 49rpx 58rpx 0 48rpx;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.profile-home-advertisements {
|
||||||
|
margin: 28rpx 24rpx 0;
|
||||||
|
}
|
||||||
|
|
||||||
.profile-service-group + .profile-service-group {
|
.profile-service-group + .profile-service-group {
|
||||||
margin-top: 51rpx;
|
margin-top: 51rpx;
|
||||||
}
|
}
|
||||||
@@ -825,22 +713,6 @@ onUnload(() => {
|
|||||||
height: 176rpx;
|
height: 176rpx;
|
||||||
}
|
}
|
||||||
|
|
||||||
.profile-metadata {
|
|
||||||
grid-template-columns: minmax(0, 1fr);
|
|
||||||
}
|
|
||||||
|
|
||||||
.profile-metadata__item:first-child {
|
|
||||||
border-right: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.profile-metadata__item + .profile-metadata__item {
|
|
||||||
border-top: 1rpx solid rgba(117, 83, 52, 0.16);
|
|
||||||
}
|
|
||||||
|
|
||||||
.profile-metadata__item--email {
|
|
||||||
grid-column: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.profile-services {
|
.profile-services {
|
||||||
margin-right: 38rpx;
|
margin-right: 38rpx;
|
||||||
margin-left: 38rpx;
|
margin-left: 38rpx;
|
||||||
|
|||||||
@@ -23,9 +23,15 @@
|
|||||||
<AppButton compact type="secondary" label="重试" @click="loadReferralProfile" />
|
<AppButton compact type="secondary" label="重试" @click="loadReferralProfile" />
|
||||||
</view>
|
</view>
|
||||||
<view v-else-if="referralProfile.enabled" class="referral-card__content">
|
<view v-else-if="referralProfile.enabled" class="referral-card__content">
|
||||||
|
<view class="referral-card__details">
|
||||||
|
<ReferralQrCode :value="referralProfile.shareUrl" />
|
||||||
|
<view class="referral-card__copy">
|
||||||
<text class="referral-card__code">{{ referralProfile.referralCode }}</text>
|
<text class="referral-card__code">{{ referralProfile.referralCode }}</text>
|
||||||
<text>已成功邀请 {{ referralProfile.referredUserCount }} 人</text>
|
<text>已成功邀请 {{ referralProfile.referredUserCount }} 人</text>
|
||||||
<text>{{ referralProfile.shareDescription || "家人通过此链接注册后,系统会记录推荐关系。" }}</text>
|
<text>{{ referralProfile.shareDescription || "家人通过此链接注册后,系统会记录推荐关系。" }}</text>
|
||||||
|
<text class="referral-card__qr-hint">请家人扫码打开注册链接</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
<view class="referral-card__actions">
|
<view class="referral-card__actions">
|
||||||
<AppButton compact type="secondary" label="复制推荐码" @click="copyReferralCode" />
|
<AppButton compact type="secondary" label="复制推荐码" @click="copyReferralCode" />
|
||||||
<AppButton compact type="secondary" label="复制推荐链接" @click="copyReferralLink" />
|
<AppButton compact type="secondary" label="复制推荐链接" @click="copyReferralLink" />
|
||||||
@@ -83,6 +89,7 @@ import AppButton from "@/components/AppButton.vue";
|
|||||||
import AppLoading from "@/components/AppLoading.vue";
|
import AppLoading from "@/components/AppLoading.vue";
|
||||||
import ModulePageBackground from "@/components/ModulePageBackground.vue";
|
import ModulePageBackground from "@/components/ModulePageBackground.vue";
|
||||||
import PageHeader from "@/components/PageHeader.vue";
|
import PageHeader from "@/components/PageHeader.vue";
|
||||||
|
import ReferralQrCode from "@/components/ReferralQrCode.vue";
|
||||||
import {
|
import {
|
||||||
createRequestController,
|
createRequestController,
|
||||||
isRequestCancelled
|
isRequestCancelled
|
||||||
@@ -318,7 +325,12 @@ onUnload(() => {
|
|||||||
.referral-card__state > text { margin-top: 8rpx; color: $ink-muted; font-size: clamp(13px, 22rpx, 16px); line-height: 1.55; }
|
.referral-card__state > text { margin-top: 8rpx; color: $ink-muted; font-size: clamp(13px, 22rpx, 16px); line-height: 1.55; }
|
||||||
.referral-card__content,
|
.referral-card__content,
|
||||||
.referral-card__state { margin-top: 22rpx; }
|
.referral-card__state { margin-top: 22rpx; }
|
||||||
|
.referral-card__details { display: flex; align-items: center; gap: 24rpx; }
|
||||||
|
.referral-card__copy { min-width: 0; flex: 1; }
|
||||||
|
.referral-card__copy > text { display: block; }
|
||||||
|
.referral-card__copy > text:not(:first-child) { margin-top: 8rpx; }
|
||||||
.referral-card__code { color: #9e251b; font-family: ui-monospace, SFMono-Regular, Consolas, monospace; font-size: clamp(22px, 40rpx, 30px); font-weight: 700; letter-spacing: 3rpx; }
|
.referral-card__code { color: #9e251b; font-family: ui-monospace, SFMono-Regular, Consolas, monospace; font-size: clamp(22px, 40rpx, 30px); font-weight: 700; letter-spacing: 3rpx; }
|
||||||
|
.referral-card__qr-hint { color: $brand-red; font-size: clamp(13px, 21rpx, 16px); }
|
||||||
.referral-card__actions { display: flex; flex-wrap: wrap; justify-content: flex-end; margin-top: 22rpx; gap: 12rpx; }
|
.referral-card__actions { display: flex; flex-wrap: wrap; justify-content: flex-end; margin-top: 22rpx; gap: 12rpx; }
|
||||||
.referral-card__actions .app-button { width: auto; min-width: 150rpx; }
|
.referral-card__actions .app-button { width: auto; min-width: 150rpx; }
|
||||||
.promotion-state-card .app-button { margin-top: 28rpx; }
|
.promotion-state-card .app-button { margin-top: 28rpx; }
|
||||||
@@ -353,5 +365,10 @@ onUnload(() => {
|
|||||||
min-width: 0;
|
min-width: 0;
|
||||||
flex: 1 1 220rpx;
|
flex: 1 1 220rpx;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.referral-card__details {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -30,12 +30,32 @@
|
|||||||
><AppButton block label="新建礼仪活动" @click="createCeremony"
|
><AppButton block label="新建礼仪活动" @click="createCeremony"
|
||||||
/></view>
|
/></view>
|
||||||
<view v-else class="ceremony-list"
|
<view v-else class="ceremony-list"
|
||||||
><view
|
><BatchManagementBar
|
||||||
|
v-if="deletableCeremonies.length"
|
||||||
|
resource-name="礼仪活动"
|
||||||
|
:active="ceremonyBatch.selectionMode.value"
|
||||||
|
:selected-count="ceremonyBatch.selectedCount.value"
|
||||||
|
:all-selected="ceremonyBatch.allSelected.value"
|
||||||
|
:busy="ceremonyBatch.deleting.value"
|
||||||
|
@start="ceremonyBatch.enterSelectionMode"
|
||||||
|
@finish="ceremonyBatch.exitSelectionMode"
|
||||||
|
@toggle-all="ceremonyBatch.toggleAll"
|
||||||
|
@delete="ceremonyBatch.requestDelete"
|
||||||
|
/>
|
||||||
|
<text v-if="ceremonyBatch.notice.value" class="batch-notice" role="status">{{ ceremonyBatch.notice.value }}</text>
|
||||||
|
<text v-if="ceremonyBatch.error.value" class="batch-error" role="alert">{{ ceremonyBatch.error.value }}</text>
|
||||||
|
<view
|
||||||
v-for="item in ceremonies"
|
v-for="item in ceremonies"
|
||||||
:key="item.id"
|
:key="item.id"
|
||||||
class="ceremony-card"
|
class="ceremony-card"
|
||||||
@click="openCeremony(item)"
|
@click="handleCeremonyClick(item)"
|
||||||
><image
|
><BatchSelectionMark
|
||||||
|
v-if="ceremonyBatch.selectionMode.value && item.canDelete"
|
||||||
|
:selected="ceremonyBatch.isSelected(item)"
|
||||||
|
:label="`礼仪活动:${item.title}`"
|
||||||
|
@toggle="ceremonyBatch.toggleSelection(item)"
|
||||||
|
/>
|
||||||
|
<image
|
||||||
v-if="item.coverFile?.accessUrl"
|
v-if="item.coverFile?.accessUrl"
|
||||||
class="ceremony-card__cover"
|
class="ceremony-card__cover"
|
||||||
:src="item.coverFile.accessUrl"
|
:src="item.coverFile.accessUrl"
|
||||||
@@ -50,6 +70,18 @@
|
|||||||
></view
|
></view
|
||||||
>
|
>
|
||||||
</view>
|
</view>
|
||||||
|
<AppDialog
|
||||||
|
:visible="ceremonyBatch.confirmationVisible.value"
|
||||||
|
:close-on-mask="false"
|
||||||
|
eyebrow="批量删除"
|
||||||
|
title="将选中的礼仪活动移入回收站?"
|
||||||
|
:message="ceremonyBatch.confirmationMessage.value"
|
||||||
|
:confirm-text="ceremonyBatch.deleting.value ? '正在删除' : '移入回收站'"
|
||||||
|
cancel-text="继续选择"
|
||||||
|
show-cancel
|
||||||
|
@confirm="ceremonyBatch.confirmDelete"
|
||||||
|
@cancel="ceremonyBatch.cancelDelete"
|
||||||
|
/>
|
||||||
</view>
|
</view>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -57,7 +89,10 @@
|
|||||||
import { computed, ref } from "vue";
|
import { computed, ref } from "vue";
|
||||||
import { onLoad, onShow, onUnload } from "@dcloudio/uni-app";
|
import { onLoad, onShow, onUnload } from "@dcloudio/uni-app";
|
||||||
import AppButton from "@/components/AppButton.vue";
|
import AppButton from "@/components/AppButton.vue";
|
||||||
|
import AppDialog from "@/components/AppDialog.vue";
|
||||||
import AppLoading from "@/components/AppLoading.vue";
|
import AppLoading from "@/components/AppLoading.vue";
|
||||||
|
import BatchManagementBar from "@/components/BatchManagementBar.vue";
|
||||||
|
import BatchSelectionMark from "@/components/BatchSelectionMark.vue";
|
||||||
import ModulePageBackground from "@/components/ModulePageBackground.vue";
|
import ModulePageBackground from "@/components/ModulePageBackground.vue";
|
||||||
import PageHeader from "@/components/PageHeader.vue";
|
import PageHeader from "@/components/PageHeader.vue";
|
||||||
import {
|
import {
|
||||||
@@ -65,6 +100,7 @@ import {
|
|||||||
isRequestCancelled
|
isRequestCancelled
|
||||||
} from "@/services/api/request-controller.js";
|
} from "@/services/api/request-controller.js";
|
||||||
import { ceremonyApi } from "@/services/api/ceremony-service.js";
|
import { ceremonyApi } from "@/services/api/ceremony-service.js";
|
||||||
|
import { useBatchDeletion } from "@/composables/use-batch-deletion.js";
|
||||||
import { formatMinuteTimestamp } from "@/utils/display-time.js";
|
import { formatMinuteTimestamp } from "@/utils/display-time.js";
|
||||||
import { goBack, openPage, returnTo } from "@/utils/navigation/gateway.js";
|
import { goBack, openPage, returnTo } from "@/utils/navigation/gateway.js";
|
||||||
|
|
||||||
@@ -72,8 +108,22 @@ const genealogyId = ref("");
|
|||||||
const ceremonies = ref([]);
|
const ceremonies = ref([]);
|
||||||
const ceremonyListState = ref("loading");
|
const ceremonyListState = ref("loading");
|
||||||
const ceremonyListRequestController = createRequestController();
|
const ceremonyListRequestController = createRequestController();
|
||||||
|
const ceremonyDeleteRequestController = createRequestController();
|
||||||
let pageActive = true;
|
let pageActive = true;
|
||||||
const valid = computed(() => /^[1-9]\d*$/.test(genealogyId.value));
|
const valid = computed(() => /^[1-9]\d*$/.test(genealogyId.value));
|
||||||
|
const ceremonyBatch = useBatchDeletion({
|
||||||
|
items: ceremonies,
|
||||||
|
deleteOne: (ceremony) =>
|
||||||
|
ceremonyApi.deleteCeremony(genealogyId.value, ceremony.id, {
|
||||||
|
requestController: ceremonyDeleteRequestController,
|
||||||
|
}),
|
||||||
|
resourceName: "礼仪活动",
|
||||||
|
isActive: () => pageActive,
|
||||||
|
onEmpty: () => {
|
||||||
|
ceremonyListState.value = "empty";
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const deletableCeremonies = ceremonyBatch.deletableItems;
|
||||||
const loadCeremonies = async () => {
|
const loadCeremonies = async () => {
|
||||||
if (!valid.value) return;
|
if (!valid.value) return;
|
||||||
ceremonyListRequestController.abort();
|
ceremonyListRequestController.abort();
|
||||||
@@ -84,6 +134,7 @@ const loadCeremonies = async () => {
|
|||||||
});
|
});
|
||||||
if (!pageActive) return;
|
if (!pageActive) return;
|
||||||
ceremonies.value = rows;
|
ceremonies.value = rows;
|
||||||
|
ceremonyBatch.exitSelectionMode();
|
||||||
ceremonyListState.value = ceremonies.value.length ? "ready" : "empty";
|
ceremonyListState.value = ceremonies.value.length ? "ready" : "empty";
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (!pageActive || isRequestCancelled(error)) return;
|
if (!pageActive || isRequestCancelled(error)) return;
|
||||||
@@ -91,7 +142,7 @@ const loadCeremonies = async () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
const returnToFamily = () =>
|
const returnToFamily = () =>
|
||||||
valid.value ? returnTo("F01", { genealogyId: genealogyId.value }) : goBack();
|
valid.value ? returnTo("G05", { genealogyId: genealogyId.value }) : goBack();
|
||||||
const createCeremony = () =>
|
const createCeremony = () =>
|
||||||
valid.value
|
valid.value
|
||||||
? openPage("R07", { genealogyId: genealogyId.value, mode: "create" }, "R05")
|
? openPage("R07", { genealogyId: genealogyId.value, mode: "create" }, "R05")
|
||||||
@@ -102,6 +153,13 @@ const openCeremony = (item) =>
|
|||||||
{ genealogyId: genealogyId.value, ceremonyId: item.id },
|
{ genealogyId: genealogyId.value, ceremonyId: item.id },
|
||||||
"R05",
|
"R05",
|
||||||
);
|
);
|
||||||
|
const handleCeremonyClick = (item) => {
|
||||||
|
if (ceremonyBatch.selectionMode.value && item?.canDelete) {
|
||||||
|
ceremonyBatch.toggleSelection(item);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
openCeremony(item);
|
||||||
|
};
|
||||||
onLoad((query) => {
|
onLoad((query) => {
|
||||||
genealogyId.value = String(query?.genealogyId || "");
|
genealogyId.value = String(query?.genealogyId || "");
|
||||||
if (valid.value) loadCeremonies();
|
if (valid.value) loadCeremonies();
|
||||||
@@ -112,6 +170,7 @@ onShow(() => {
|
|||||||
onUnload(() => {
|
onUnload(() => {
|
||||||
pageActive = false;
|
pageActive = false;
|
||||||
ceremonyListRequestController.abort();
|
ceremonyListRequestController.abort();
|
||||||
|
ceremonyDeleteRequestController.abort();
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -156,6 +215,14 @@ onUnload(() => {
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 20rpx;
|
gap: 20rpx;
|
||||||
}
|
}
|
||||||
|
.batch-notice,
|
||||||
|
.batch-error {
|
||||||
|
display: block;
|
||||||
|
font-size: clamp(13px, 21rpx, 16px);
|
||||||
|
line-height: 1.55;
|
||||||
|
}
|
||||||
|
.batch-notice { color: #426538; }
|
||||||
|
.batch-error { color: $brand-red; }
|
||||||
.ceremony-card {
|
.ceremony-card {
|
||||||
display: flex;
|
display: flex;
|
||||||
min-height: 138rpx;
|
min-height: 138rpx;
|
||||||
|
|||||||
@@ -134,6 +134,24 @@
|
|||||||
</view>
|
</view>
|
||||||
<text v-if="uploadError" class="error">{{ uploadError }}</text>
|
<text v-if="uploadError" class="error">{{ uploadError }}</text>
|
||||||
</view>
|
</view>
|
||||||
|
<view v-if="!isEdit" class="field field--password">
|
||||||
|
<text>内容密码(选填)</text>
|
||||||
|
<text class="person-option-note">填写后会先创建记录,再立即启用密码保护;设置失败时会保留记录并明确提示。</text>
|
||||||
|
<input
|
||||||
|
v-model="contentPassword"
|
||||||
|
password
|
||||||
|
maxlength="128"
|
||||||
|
placeholder="请输入8至128位内容密码"
|
||||||
|
@input="error = ''"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
v-model="contentPasswordConfirm"
|
||||||
|
password
|
||||||
|
maxlength="128"
|
||||||
|
placeholder="请再次输入内容密码"
|
||||||
|
@input="error = ''"
|
||||||
|
/>
|
||||||
|
</view>
|
||||||
<text v-if="error" class="error">{{ error }}</text>
|
<text v-if="error" class="error">{{ error }}</text>
|
||||||
<view class="form-actions"
|
<view class="form-actions"
|
||||||
><AppButton
|
><AppButton
|
||||||
@@ -167,9 +185,21 @@
|
|||||||
><AppButton block label="新建成长记录" @click="openCreate"
|
><AppButton block label="新建成长记录" @click="openCreate"
|
||||||
/></view>
|
/></view>
|
||||||
<view v-else class="record-list">
|
<view v-else class="record-list">
|
||||||
<text v-if="saveNotice" class="save-notice"
|
<BatchManagementBar
|
||||||
>成长记录已保存。</text
|
v-if="deletableGrowthRecords.length"
|
||||||
>
|
resource-name="成长记录"
|
||||||
|
:active="growthBatch.selectionMode.value"
|
||||||
|
:selected-count="growthBatch.selectedCount.value"
|
||||||
|
:all-selected="growthBatch.allSelected.value"
|
||||||
|
:busy="growthBatch.deleting.value"
|
||||||
|
@start="growthBatch.enterSelectionMode"
|
||||||
|
@finish="growthBatch.exitSelectionMode"
|
||||||
|
@toggle-all="growthBatch.toggleAll"
|
||||||
|
@delete="growthBatch.requestDelete"
|
||||||
|
/>
|
||||||
|
<text v-if="growthBatch.notice.value" class="save-notice" role="status">{{ growthBatch.notice.value }}</text>
|
||||||
|
<text v-if="growthBatch.error.value" class="delete-error" role="alert">{{ growthBatch.error.value }}</text>
|
||||||
|
<text v-if="saveNotice" class="save-notice">{{ saveNotice }}</text>
|
||||||
<text v-if="deleteError" class="delete-error">{{ deleteError }}</text>
|
<text v-if="deleteError" class="delete-error">{{ deleteError }}</text>
|
||||||
<view class="record-filters">
|
<view class="record-filters">
|
||||||
<picker :range="filterPersonLabels" :value="filterPersonIndex" @change="selectFilterPerson">
|
<picker :range="filterPersonLabels" :value="filterPersonIndex" @change="selectFilterPerson">
|
||||||
@@ -185,9 +215,15 @@
|
|||||||
:key="item.id"
|
:key="item.id"
|
||||||
class="record-card"
|
class="record-card"
|
||||||
role="button"
|
role="button"
|
||||||
:aria-label="`查看${item.title}详情`"
|
:aria-label="growthBatch.selectionMode.value && item.canDelete ? `${growthBatch.isSelected(item) ? '取消选择' : '选择'}${item.title}` : `查看${item.title}详情`"
|
||||||
@click="openRecordDetail(item)"
|
@click="handleGrowthRecordClick(item)"
|
||||||
>
|
>
|
||||||
|
<BatchSelectionMark
|
||||||
|
v-if="growthBatch.selectionMode.value && item.canDelete"
|
||||||
|
:selected="growthBatch.isSelected(item)"
|
||||||
|
:label="`成长记录:${item.title}`"
|
||||||
|
@toggle="growthBatch.toggleSelection(item)"
|
||||||
|
/>
|
||||||
<view
|
<view
|
||||||
><text>{{ item.title }}</text
|
><text>{{ item.title }}</text
|
||||||
><text
|
><text
|
||||||
@@ -197,14 +233,14 @@
|
|||||||
><text v-if="item.content">{{ item.content }}</text></view
|
><text v-if="item.content">{{ item.content }}</text></view
|
||||||
>
|
>
|
||||||
<AppButton
|
<AppButton
|
||||||
v-if="item.canEdit && personOptionsState === 'ready' && growthTypeOptionsState === 'ready'"
|
v-if="!growthBatch.selectionMode.value && item.canEdit && personOptionsState === 'ready' && growthTypeOptionsState === 'ready'"
|
||||||
compact
|
compact
|
||||||
type="secondary"
|
type="secondary"
|
||||||
label="编辑"
|
label="编辑"
|
||||||
@click.stop="openEdit(item)"
|
@click.stop="openEdit(item)"
|
||||||
/>
|
/>
|
||||||
<AppButton
|
<AppButton
|
||||||
v-if="item.canDelete"
|
v-if="!growthBatch.selectionMode.value && item.canDelete"
|
||||||
compact
|
compact
|
||||||
type="secondary"
|
type="secondary"
|
||||||
label="删除"
|
label="删除"
|
||||||
@@ -219,6 +255,17 @@
|
|||||||
@busy-change="detailBusy = $event"
|
@busy-change="detailBusy = $event"
|
||||||
@transient-change="detailTransientOpen = $event"
|
@transient-change="detailTransientOpen = $event"
|
||||||
/>
|
/>
|
||||||
|
<AppDialog
|
||||||
|
:visible="growthBatch.confirmationVisible.value"
|
||||||
|
title="将选中的成长记录移入回收站?"
|
||||||
|
:message="growthBatch.confirmationMessage.value"
|
||||||
|
:confirm-text="growthBatch.deleting.value ? '正在删除' : '移入回收站'"
|
||||||
|
cancel-text="继续选择"
|
||||||
|
show-cancel
|
||||||
|
:close-on-mask="false"
|
||||||
|
@confirm="growthBatch.confirmDelete"
|
||||||
|
@cancel="growthBatch.cancelDelete"
|
||||||
|
/>
|
||||||
<AppDialog
|
<AppDialog
|
||||||
:visible="discardVisible"
|
:visible="discardVisible"
|
||||||
title="放弃成长记录?"
|
title="放弃成长记录?"
|
||||||
@@ -250,6 +297,8 @@ import { onBackPress, onLoad, onShow, onUnload } from "@dcloudio/uni-app";
|
|||||||
import AppButton from "@/components/AppButton.vue";
|
import AppButton from "@/components/AppButton.vue";
|
||||||
import AppDialog from "@/components/AppDialog.vue";
|
import AppDialog from "@/components/AppDialog.vue";
|
||||||
import AppLoading from "@/components/AppLoading.vue";
|
import AppLoading from "@/components/AppLoading.vue";
|
||||||
|
import BatchManagementBar from "@/components/BatchManagementBar.vue";
|
||||||
|
import BatchSelectionMark from "@/components/BatchSelectionMark.vue";
|
||||||
import GrowthRecordDetailDialog from "@/components/records/GrowthRecordDetailDialog.vue";
|
import GrowthRecordDetailDialog from "@/components/records/GrowthRecordDetailDialog.vue";
|
||||||
import ModulePageBackground from "@/components/ModulePageBackground.vue";
|
import ModulePageBackground from "@/components/ModulePageBackground.vue";
|
||||||
import PageHeader from "@/components/PageHeader.vue";
|
import PageHeader from "@/components/PageHeader.vue";
|
||||||
@@ -261,6 +310,7 @@ import { businessDictionaryApi } from "@/services/api/business-dictionary-servic
|
|||||||
import { lifeRecordApi } from "@/services/api/life-record-service.js";
|
import { lifeRecordApi } from "@/services/api/life-record-service.js";
|
||||||
import { lineageApi } from "@/services/api/lineage-service.js";
|
import { lineageApi } from "@/services/api/lineage-service.js";
|
||||||
import { getRequestErrorMessage } from "@/services/api/request-error-message.js";
|
import { getRequestErrorMessage } from "@/services/api/request-error-message.js";
|
||||||
|
import { useBatchDeletion } from "@/composables/use-batch-deletion.js";
|
||||||
import { createDiscardConfirmation } from "@/utils/discard-confirmation.js";
|
import { createDiscardConfirmation } from "@/utils/discard-confirmation.js";
|
||||||
import { formatMinuteTimestamp } from "@/utils/display-time.js";
|
import { formatMinuteTimestamp } from "@/utils/display-time.js";
|
||||||
import { createNonIdempotentWriteGuard } from "@/utils/non-idempotent-write-guard.js";
|
import { createNonIdempotentWriteGuard } from "@/utils/non-idempotent-write-guard.js";
|
||||||
@@ -277,7 +327,7 @@ const personId = ref("");
|
|||||||
const view = ref("list");
|
const view = ref("list");
|
||||||
const listState = ref("loading");
|
const listState = ref("loading");
|
||||||
const records = ref([]);
|
const records = ref([]);
|
||||||
const saveNotice = ref(false);
|
const saveNotice = ref("");
|
||||||
const submitting = ref(false);
|
const submitting = ref(false);
|
||||||
const uploading = ref(false);
|
const uploading = ref(false);
|
||||||
const error = ref("");
|
const error = ref("");
|
||||||
@@ -299,6 +349,8 @@ const personOptions = ref([{ value: "", label: "不关联人物" }]);
|
|||||||
const defaultLineagePersonId = ref("");
|
const defaultLineagePersonId = ref("");
|
||||||
const growthTypeOptionsState = ref("loading");
|
const growthTypeOptionsState = ref("loading");
|
||||||
const growthTypeOptions = ref([]);
|
const growthTypeOptions = ref([]);
|
||||||
|
const contentPassword = ref("");
|
||||||
|
const contentPasswordConfirm = ref("");
|
||||||
const form = reactive({
|
const form = reactive({
|
||||||
lineagePersonId: "",
|
lineagePersonId: "",
|
||||||
recordType: "",
|
recordType: "",
|
||||||
@@ -314,6 +366,7 @@ const recordListRequestController = createRequestController();
|
|||||||
const growthEditorDetailRequestController = createRequestController();
|
const growthEditorDetailRequestController = createRequestController();
|
||||||
const growthMediaUploadRequestController = createRequestController();
|
const growthMediaUploadRequestController = createRequestController();
|
||||||
const growthRecordSaveRequestController = createRequestController();
|
const growthRecordSaveRequestController = createRequestController();
|
||||||
|
const growthPasswordRequestController = createRequestController();
|
||||||
const personOptionsController = createRequestController();
|
const personOptionsController = createRequestController();
|
||||||
const growthTypeRequestController = createRequestController();
|
const growthTypeRequestController = createRequestController();
|
||||||
const growthRecordDeleteController = createRequestController();
|
const growthRecordDeleteController = createRequestController();
|
||||||
@@ -356,6 +409,20 @@ const filteredRecords = computed(() => records.value.filter((item) =>
|
|||||||
(!filterPersonId.value || (filterPersonId.value === "NONE" ? !item.lineagePersonId : item.lineagePersonId === filterPersonId.value)) &&
|
(!filterPersonId.value || (filterPersonId.value === "NONE" ? !item.lineagePersonId : item.lineagePersonId === filterPersonId.value)) &&
|
||||||
(!filterType.value || item.type === filterType.value),
|
(!filterType.value || item.type === filterType.value),
|
||||||
));
|
));
|
||||||
|
const growthBatch = useBatchDeletion({
|
||||||
|
items: records,
|
||||||
|
visibleItems: filteredRecords,
|
||||||
|
deleteOne: (record) =>
|
||||||
|
lifeRecordApi.deleteGrowthRecord(genealogyId.value, record.id, {
|
||||||
|
requestController: growthRecordDeleteController,
|
||||||
|
}),
|
||||||
|
resourceName: "成长记录",
|
||||||
|
isActive: () => pageActive,
|
||||||
|
onEmpty: () => {
|
||||||
|
listState.value = "empty";
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const deletableGrowthRecords = growthBatch.deletableItems;
|
||||||
const mediaOssIds = computed(() =>
|
const mediaOssIds = computed(() =>
|
||||||
mediaReceipts.value.map((item) => item.ossId).join(","),
|
mediaReceipts.value.map((item) => item.ossId).join(","),
|
||||||
);
|
);
|
||||||
@@ -376,7 +443,9 @@ const dirty = computed(() =>
|
|||||||
field !== "lineagePersonId" && String(value).trim(),
|
field !== "lineagePersonId" && String(value).trim(),
|
||||||
) ||
|
) ||
|
||||||
form.lineagePersonId !== defaultLineagePersonId.value ||
|
form.lineagePersonId !== defaultLineagePersonId.value ||
|
||||||
mediaReceipts.value.length > 0,
|
mediaReceipts.value.length > 0 ||
|
||||||
|
contentPassword.value ||
|
||||||
|
contentPasswordConfirm.value,
|
||||||
);
|
);
|
||||||
const confirmation = createDiscardConfirmation((visible) => {
|
const confirmation = createDiscardConfirmation((visible) => {
|
||||||
discardVisible.value = visible;
|
discardVisible.value = visible;
|
||||||
@@ -400,6 +469,8 @@ const resetForm = () => {
|
|||||||
remindClock: "",
|
remindClock: "",
|
||||||
});
|
});
|
||||||
mediaReceipts.value = [];
|
mediaReceipts.value = [];
|
||||||
|
contentPassword.value = "";
|
||||||
|
contentPasswordConfirm.value = "";
|
||||||
editingRecord.value = null;
|
editingRecord.value = null;
|
||||||
formBaseline.value = "";
|
formBaseline.value = "";
|
||||||
error.value = "";
|
error.value = "";
|
||||||
@@ -456,6 +527,7 @@ const loadRecords = async () => {
|
|||||||
});
|
});
|
||||||
if (!pageActive) return;
|
if (!pageActive) return;
|
||||||
records.value = rows;
|
records.value = rows;
|
||||||
|
growthBatch.exitSelectionMode();
|
||||||
listState.value = records.value.length ? "ready" : "empty";
|
listState.value = records.value.length ? "ready" : "empty";
|
||||||
} catch (cause) {
|
} catch (cause) {
|
||||||
if (!pageActive || isRequestCancelled(cause)) return;
|
if (!pageActive || isRequestCancelled(cause)) return;
|
||||||
@@ -464,7 +536,7 @@ const loadRecords = async () => {
|
|||||||
};
|
};
|
||||||
const openCreate = () => {
|
const openCreate = () => {
|
||||||
if (!valid.value) return;
|
if (!valid.value) return;
|
||||||
saveNotice.value = false;
|
saveNotice.value = "";
|
||||||
resetForm();
|
resetForm();
|
||||||
view.value = "form";
|
view.value = "form";
|
||||||
};
|
};
|
||||||
@@ -563,15 +635,24 @@ const selectGrowthType = (event) => {
|
|||||||
};
|
};
|
||||||
const selectFilterPerson = (event) => {
|
const selectFilterPerson = (event) => {
|
||||||
filterPersonId.value = filterPersonOptions.value[Number(event.detail.value)]?.value || "";
|
filterPersonId.value = filterPersonOptions.value[Number(event.detail.value)]?.value || "";
|
||||||
|
growthBatch.exitSelectionMode();
|
||||||
};
|
};
|
||||||
const selectFilterType = (event) => {
|
const selectFilterType = (event) => {
|
||||||
filterType.value = filterTypeOptions.value[Number(event.detail.value)]?.value || "";
|
filterType.value = filterTypeOptions.value[Number(event.detail.value)]?.value || "";
|
||||||
|
growthBatch.exitSelectionMode();
|
||||||
};
|
};
|
||||||
const openRecordDetail = (record) =>
|
const openRecordDetail = (record) =>
|
||||||
growthRecordDetailDialog.value?.open({
|
growthRecordDetailDialog.value?.open({
|
||||||
...record,
|
...record,
|
||||||
typeLabel: growthRecordTypeLabel(record),
|
typeLabel: growthRecordTypeLabel(record),
|
||||||
});
|
});
|
||||||
|
const handleGrowthRecordClick = (record) => {
|
||||||
|
if (growthBatch.selectionMode.value && record?.canDelete) {
|
||||||
|
growthBatch.toggleSelection(record);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
openRecordDetail(record);
|
||||||
|
};
|
||||||
const uploadImage = async () => {
|
const uploadImage = async () => {
|
||||||
if (uploading.value || submitting.value) return;
|
if (uploading.value || submitting.value) return;
|
||||||
uploading.value = true;
|
uploading.value = true;
|
||||||
@@ -595,6 +676,16 @@ const saveGrowthRecord = async () => {
|
|||||||
error.value = "请填写记录标题";
|
error.value = "请填写记录标题";
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (contentPassword.value || contentPasswordConfirm.value) {
|
||||||
|
if (contentPassword.value.length < 8 || contentPassword.value.length > 128) {
|
||||||
|
error.value = "内容密码必须为8至128位。";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (contentPassword.value !== contentPasswordConfirm.value) {
|
||||||
|
error.value = "两次输入的内容密码不一致。";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
const { recordClock, remindDate, remindClock, ...recordForm } = form;
|
const { recordClock, remindDate, remindClock, ...recordForm } = form;
|
||||||
const payload = {
|
const payload = {
|
||||||
...recordForm,
|
...recordForm,
|
||||||
@@ -619,6 +710,7 @@ const saveGrowthRecord = async () => {
|
|||||||
|
|
||||||
submitting.value = true;
|
submitting.value = true;
|
||||||
error.value = "";
|
error.value = "";
|
||||||
|
let passwordSetupNotice = "";
|
||||||
try {
|
try {
|
||||||
if (editingRecord.value) {
|
if (editingRecord.value) {
|
||||||
await lifeRecordApi.updateGrowthRecord(
|
await lifeRecordApi.updateGrowthRecord(
|
||||||
@@ -628,14 +720,30 @@ const saveGrowthRecord = async () => {
|
|||||||
{ requestController: growthRecordSaveRequestController },
|
{ requestController: growthRecordSaveRequestController },
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
await lifeRecordApi.createGrowthRecord(genealogyId.value, payload, {
|
const createdRecord = await lifeRecordApi.createGrowthRecord(genealogyId.value, payload, {
|
||||||
requestController: growthRecordSaveRequestController,
|
requestController: growthRecordSaveRequestController,
|
||||||
});
|
});
|
||||||
|
if (!pageActive) return;
|
||||||
|
if (contentPassword.value) {
|
||||||
|
try {
|
||||||
|
await lifeRecordApi.setGrowthRecordPassword(
|
||||||
|
genealogyId.value,
|
||||||
|
createdRecord.id,
|
||||||
|
contentPassword.value,
|
||||||
|
{ requestController: growthPasswordRequestController },
|
||||||
|
);
|
||||||
|
passwordSetupNotice = "成长记录已保存,并已启用内容密码。";
|
||||||
|
} catch (passwordError) {
|
||||||
|
if (!pageActive || isRequestCancelled(passwordError)) return;
|
||||||
|
const failureCopy = getRequestErrorMessage(passwordError, "内容密码设置失败");
|
||||||
|
passwordSetupNotice = `成长记录已经创建,但${failureCopy}。请打开详情重新设置;当前内容可能尚未受到密码保护。`;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (!pageActive) return;
|
if (!pageActive) return;
|
||||||
resetForm();
|
resetForm();
|
||||||
view.value = "list";
|
view.value = "list";
|
||||||
saveNotice.value = true;
|
saveNotice.value = passwordSetupNotice || "成长记录已保存。";
|
||||||
await loadRecords();
|
await loadRecords();
|
||||||
} catch (cause) {
|
} catch (cause) {
|
||||||
if (!pageActive) return;
|
if (!pageActive) return;
|
||||||
@@ -708,7 +816,13 @@ const deleteRecord = async () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
const requestBack = () =>
|
const requestBack = () =>
|
||||||
detailTransientOpen.value
|
growthBatch.deleting.value
|
||||||
|
? true
|
||||||
|
: growthBatch.confirmationVisible.value
|
||||||
|
? (growthBatch.cancelDelete(), true)
|
||||||
|
: growthBatch.selectionMode.value
|
||||||
|
? (growthBatch.exitSelectionMode(), true)
|
||||||
|
: detailTransientOpen.value
|
||||||
? (growthRecordDetailDialog.value?.closeTransient(), true)
|
? (growthRecordDetailDialog.value?.closeTransient(), true)
|
||||||
: view.value !== "form"
|
: view.value !== "form"
|
||||||
? goBack()
|
? goBack()
|
||||||
@@ -745,6 +859,7 @@ onUnload(() => {
|
|||||||
growthEditorDetailRequestController.abort();
|
growthEditorDetailRequestController.abort();
|
||||||
growthMediaUploadRequestController.abort();
|
growthMediaUploadRequestController.abort();
|
||||||
growthRecordSaveRequestController.abort();
|
growthRecordSaveRequestController.abort();
|
||||||
|
growthPasswordRequestController.abort();
|
||||||
personOptionsController.abort();
|
personOptionsController.abort();
|
||||||
growthTypeRequestController.abort();
|
growthTypeRequestController.abort();
|
||||||
growthRecordDeleteController.abort();
|
growthRecordDeleteController.abort();
|
||||||
@@ -822,6 +937,9 @@ onUnload(() => {
|
|||||||
.field textarea {
|
.field textarea {
|
||||||
min-height: 150rpx;
|
min-height: 150rpx;
|
||||||
}
|
}
|
||||||
|
.field--password input + input {
|
||||||
|
margin-top: 14rpx;
|
||||||
|
}
|
||||||
.field--picker picker {
|
.field--picker picker {
|
||||||
display: block;
|
display: block;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -94,18 +94,46 @@
|
|||||||
><AppButton block :label="`新建${recordName}`" @click="openCreate"
|
><AppButton block :label="`新建${recordName}`" @click="openCreate"
|
||||||
/></view>
|
/></view>
|
||||||
<view v-else class="memo-list">
|
<view v-else class="memo-list">
|
||||||
|
<BatchManagementBar
|
||||||
|
v-if="deletableMemos.length"
|
||||||
|
:resource-name="recordName"
|
||||||
|
:active="memoBatch.selectionMode.value"
|
||||||
|
:selected-count="memoBatch.selectedCount.value"
|
||||||
|
:all-selected="memoBatch.allSelected.value"
|
||||||
|
:busy="memoBatch.deleting.value"
|
||||||
|
@start="memoBatch.enterSelectionMode"
|
||||||
|
@finish="memoBatch.exitSelectionMode"
|
||||||
|
@toggle-all="memoBatch.toggleAll"
|
||||||
|
@delete="memoBatch.requestDelete"
|
||||||
|
/>
|
||||||
|
<text v-if="memoBatch.notice.value" class="save-notice" role="status">{{ memoBatch.notice.value }}</text>
|
||||||
|
<text v-if="memoBatch.error.value" class="delete-error" role="alert">{{ memoBatch.error.value }}</text>
|
||||||
<text v-if="saveNotice" class="save-notice"
|
<text v-if="saveNotice" class="save-notice"
|
||||||
>{{ recordName }}已保存。</text
|
>{{ recordName }}已保存。</text
|
||||||
>
|
>
|
||||||
<text v-if="deleteError" class="delete-error">{{ deleteError }}</text>
|
<text v-if="deleteError" class="delete-error">{{ deleteError }}</text>
|
||||||
<view v-for="item in memos" :key="item.id" class="memo-card" role="button" :aria-label="`查看${item.title}详情`" @click="openMemoDetail(item)">
|
<view v-for="item in memos" :key="item.id" class="memo-card" role="button" :aria-label="memoBatch.selectionMode.value && item.canDelete ? `${memoBatch.isSelected(item) ? '取消选择' : '选择'}${item.title}` : `查看${item.title}详情`" @click="handleMemoClick(item)">
|
||||||
|
<BatchSelectionMark
|
||||||
|
v-if="memoBatch.selectionMode.value && item.canDelete"
|
||||||
|
:selected="memoBatch.isSelected(item)"
|
||||||
|
:label="`${recordName}:${item.title}`"
|
||||||
|
@toggle="memoBatch.toggleSelection(item)"
|
||||||
|
/>
|
||||||
|
<view class="memo-card__main">
|
||||||
|
<image
|
||||||
|
v-if="item.mediaFiles?.[0]?.accessUrl"
|
||||||
|
class="memo-card__cover"
|
||||||
|
:src="item.mediaFiles[0].accessUrl"
|
||||||
|
mode="aspectFill"
|
||||||
|
/>
|
||||||
<view class="memo-card__copy">
|
<view class="memo-card__copy">
|
||||||
<text>{{ item.title }}</text>
|
<text>{{ item.title }}</text>
|
||||||
<text v-if="item.remindTime">{{ item.remindTime }}</text>
|
<text v-if="item.remindTime">{{ item.remindTime }}</text>
|
||||||
<text v-if="item.createTime">创建于 {{ formatMinuteTimestamp(item.createTime) }}</text>
|
<text v-if="item.createTime">创建于 {{ formatMinuteTimestamp(item.createTime) }}</text>
|
||||||
<text v-if="item.content" class="memo-card__content">{{ item.content }}</text>
|
<text v-if="item.content" class="memo-card__content">{{ item.content }}</text>
|
||||||
</view>
|
</view>
|
||||||
<view v-if="item.canEdit || item.canDelete" class="memo-card__actions">
|
</view>
|
||||||
|
<view v-if="!memoBatch.selectionMode.value && (item.canEdit || item.canDelete)" class="memo-card__actions">
|
||||||
<AppButton
|
<AppButton
|
||||||
v-if="item.canEdit"
|
v-if="item.canEdit"
|
||||||
compact
|
compact
|
||||||
@@ -124,6 +152,17 @@
|
|||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
<AppDialog
|
||||||
|
:visible="memoBatch.confirmationVisible.value"
|
||||||
|
:title="`将选中的${recordName}移入回收站?`"
|
||||||
|
:message="memoBatch.confirmationMessage.value"
|
||||||
|
:confirm-text="memoBatch.deleting.value ? '正在删除' : '移入回收站'"
|
||||||
|
cancel-text="继续选择"
|
||||||
|
show-cancel
|
||||||
|
:close-on-mask="false"
|
||||||
|
@confirm="memoBatch.confirmDelete"
|
||||||
|
@cancel="memoBatch.cancelDelete"
|
||||||
|
/>
|
||||||
<AppDialog
|
<AppDialog
|
||||||
:visible="Boolean(detailTarget)"
|
:visible="Boolean(detailTarget)"
|
||||||
:eyebrow="`${recordName}详情`"
|
:eyebrow="`${recordName}详情`"
|
||||||
@@ -178,8 +217,11 @@ import { onBackPress, onLoad, onShow, onUnload } from "@dcloudio/uni-app";
|
|||||||
import AppButton from "@/components/AppButton.vue";
|
import AppButton from "@/components/AppButton.vue";
|
||||||
import AppDialog from "@/components/AppDialog.vue";
|
import AppDialog from "@/components/AppDialog.vue";
|
||||||
import AppLoading from "@/components/AppLoading.vue";
|
import AppLoading from "@/components/AppLoading.vue";
|
||||||
|
import BatchManagementBar from "@/components/BatchManagementBar.vue";
|
||||||
|
import BatchSelectionMark from "@/components/BatchSelectionMark.vue";
|
||||||
import ModulePageBackground from "@/components/ModulePageBackground.vue";
|
import ModulePageBackground from "@/components/ModulePageBackground.vue";
|
||||||
import PageHeader from "@/components/PageHeader.vue";
|
import PageHeader from "@/components/PageHeader.vue";
|
||||||
|
import { useBatchDeletion } from "@/composables/use-batch-deletion.js";
|
||||||
import {
|
import {
|
||||||
createRequestController,
|
createRequestController,
|
||||||
isRequestCancelled
|
isRequestCancelled
|
||||||
@@ -233,6 +275,19 @@ const memoDetailRequestController = createRequestController();
|
|||||||
const memoCreateGuard = createNonIdempotentWriteGuard();
|
const memoCreateGuard = createNonIdempotentWriteGuard();
|
||||||
let pageActive = true;
|
let pageActive = true;
|
||||||
const valid = computed(() => /^[1-9]\d*$/.test(genealogyId.value));
|
const valid = computed(() => /^[1-9]\d*$/.test(genealogyId.value));
|
||||||
|
const memoBatch = useBatchDeletion({
|
||||||
|
items: memos,
|
||||||
|
deleteOne: (memo) =>
|
||||||
|
lifeRecordApi.deleteMemo(genealogyId.value, memo.id, {
|
||||||
|
requestController: memoDeletionRequestController,
|
||||||
|
}),
|
||||||
|
resourceName: "记录",
|
||||||
|
isActive: () => pageActive,
|
||||||
|
onEmpty: () => {
|
||||||
|
listState.value = "empty";
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const deletableMemos = memoBatch.deletableItems;
|
||||||
const isBenefactorMode = computed(() => memoType.value === MEMO_TYPE.BENEFACTOR);
|
const isBenefactorMode = computed(() => memoType.value === MEMO_TYPE.BENEFACTOR);
|
||||||
const recordName = computed(() => isBenefactorMode.value ? "家族恩人" : "家族备忘");
|
const recordName = computed(() => isBenefactorMode.value ? "家族恩人" : "家族备忘");
|
||||||
const pageTitle = computed(() => recordName.value);
|
const pageTitle = computed(() => recordName.value);
|
||||||
@@ -285,6 +340,7 @@ const loadMemos = async () => {
|
|||||||
});
|
});
|
||||||
if (!pageActive) return;
|
if (!pageActive) return;
|
||||||
memos.value = rows.filter((memo) => memo.memoType === memoType.value);
|
memos.value = rows.filter((memo) => memo.memoType === memoType.value);
|
||||||
|
memoBatch.exitSelectionMode();
|
||||||
listState.value = memos.value.length ? "ready" : "empty";
|
listState.value = memos.value.length ? "ready" : "empty";
|
||||||
if (pendingMemoId.value) {
|
if (pendingMemoId.value) {
|
||||||
const target = memos.value.find((item) => item.id === pendingMemoId.value);
|
const target = memos.value.find((item) => item.id === pendingMemoId.value);
|
||||||
@@ -372,6 +428,13 @@ const openMemoDetail = async (memo) => {
|
|||||||
detailError.value = getRequestErrorMessage(cause, "完整备忘读取失败,请稍后重试。");
|
detailError.value = getRequestErrorMessage(cause, "完整备忘读取失败,请稍后重试。");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
const handleMemoClick = (memo) => {
|
||||||
|
if (memoBatch.selectionMode.value && memo?.canDelete) {
|
||||||
|
memoBatch.toggleSelection(memo);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
openMemoDetail(memo);
|
||||||
|
};
|
||||||
const closeMemoDetail = () => {
|
const closeMemoDetail = () => {
|
||||||
if (detailState.value === "loading") return;
|
if (detailState.value === "loading") return;
|
||||||
detailTarget.value = null;
|
detailTarget.value = null;
|
||||||
@@ -503,12 +566,22 @@ const deleteMemo = async () => {
|
|||||||
if (pageActive) deleting.value = false;
|
if (pageActive) deleting.value = false;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
const requestBack = () =>
|
const requestBack = () => {
|
||||||
detailTarget.value
|
if (memoBatch.deleting.value) return true;
|
||||||
? (closeMemoDetail(), true)
|
if (memoBatch.confirmationVisible.value) {
|
||||||
: view.value !== "form"
|
memoBatch.cancelDelete();
|
||||||
? goBack()
|
return true;
|
||||||
: runBackGuard({
|
}
|
||||||
|
if (memoBatch.selectionMode.value) {
|
||||||
|
memoBatch.exitSelectionMode();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (detailTarget.value) {
|
||||||
|
closeMemoDetail();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (view.value !== "form") return goBack();
|
||||||
|
return runBackGuard({
|
||||||
transientOpen: discardVisible.value,
|
transientOpen: discardVisible.value,
|
||||||
dirty: dirty.value,
|
dirty: dirty.value,
|
||||||
submitting: submitting.value || uploading.value,
|
submitting: submitting.value || uploading.value,
|
||||||
@@ -516,6 +589,7 @@ const requestBack = () =>
|
|||||||
"block-submitting": () => true,
|
"block-submitting": () => true,
|
||||||
"confirm-discard": confirmation.request,
|
"confirm-discard": confirmation.request,
|
||||||
});
|
});
|
||||||
|
};
|
||||||
|
|
||||||
onLoad((query) => {
|
onLoad((query) => {
|
||||||
genealogyId.value = String(query?.genealogyId || "");
|
genealogyId.value = String(query?.genealogyId || "");
|
||||||
@@ -708,10 +782,22 @@ onUnload(() => {
|
|||||||
min-height: 128rpx;
|
min-height: 128rpx;
|
||||||
padding: 28rpx 32rpx;
|
padding: 28rpx 32rpx;
|
||||||
}
|
}
|
||||||
|
.memo-card__main {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 18rpx;
|
||||||
|
}
|
||||||
.memo-card__copy {
|
.memo-card__copy {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
}
|
}
|
||||||
|
.memo-card__cover {
|
||||||
|
width: 148rpx;
|
||||||
|
height: 148rpx;
|
||||||
|
flex: 0 0 148rpx;
|
||||||
|
border-radius: 8rpx;
|
||||||
|
background: rgba(128, 89, 49, .12);
|
||||||
|
}
|
||||||
.memo-card__actions {
|
.memo-card__actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
|||||||
@@ -100,6 +100,20 @@
|
|||||||
><AppButton block label="新建功德记录" @click="openCreate"
|
><AppButton block label="新建功德记录" @click="openCreate"
|
||||||
/></view>
|
/></view>
|
||||||
<view v-else class="merit-list">
|
<view v-else class="merit-list">
|
||||||
|
<BatchManagementBar
|
||||||
|
v-if="deletableMerits.length"
|
||||||
|
resource-name="功德记录"
|
||||||
|
:active="meritBatch.selectionMode.value"
|
||||||
|
:selected-count="meritBatch.selectedCount.value"
|
||||||
|
:all-selected="meritBatch.allSelected.value"
|
||||||
|
:busy="meritBatch.deleting.value"
|
||||||
|
@start="meritBatch.enterSelectionMode"
|
||||||
|
@finish="meritBatch.exitSelectionMode"
|
||||||
|
@toggle-all="meritBatch.toggleAll"
|
||||||
|
@delete="meritBatch.requestDelete"
|
||||||
|
/>
|
||||||
|
<text v-if="meritBatch.notice.value" class="save-notice" role="status">{{ meritBatch.notice.value }}</text>
|
||||||
|
<text v-if="meritBatch.error.value" class="delete-error" role="alert">{{ meritBatch.error.value }}</text>
|
||||||
<text v-if="saveNotice" class="save-notice"
|
<text v-if="saveNotice" class="save-notice"
|
||||||
>{{ saveNotice }}</text
|
>{{ saveNotice }}</text
|
||||||
>
|
>
|
||||||
@@ -107,15 +121,29 @@
|
|||||||
>共 {{ merits.length }} 笔,金额合计 ¥{{ totalAmount }}</text
|
>共 {{ merits.length }} 笔,金额合计 ¥{{ totalAmount }}</text
|
||||||
>
|
>
|
||||||
<text v-if="deleteError" class="delete-error">{{ deleteError }}</text>
|
<text v-if="deleteError" class="delete-error">{{ deleteError }}</text>
|
||||||
<view v-for="item in merits" :key="item.id" class="merit-card" role="button" :aria-label="`查看${item.title}详情`" @click="openMeritDetail(item)">
|
<view v-for="item in merits" :key="item.id" class="merit-card" role="button" :aria-label="meritBatch.selectionMode.value && item.canDelete ? `${meritBatch.isSelected(item) ? '取消选择' : '选择'}${item.title}` : `查看${item.title}详情`" @click="handleMeritClick(item)">
|
||||||
<view>
|
<BatchSelectionMark
|
||||||
|
v-if="meritBatch.selectionMode.value && item.canDelete"
|
||||||
|
:selected="meritBatch.isSelected(item)"
|
||||||
|
:label="`功德记录:${item.title}`"
|
||||||
|
@toggle="meritBatch.toggleSelection(item)"
|
||||||
|
/>
|
||||||
|
<view class="merit-card__main">
|
||||||
|
<image
|
||||||
|
v-if="item.mediaFiles?.[0]?.accessUrl"
|
||||||
|
class="merit-card__cover"
|
||||||
|
:src="item.mediaFiles[0].accessUrl"
|
||||||
|
mode="aspectFill"
|
||||||
|
/>
|
||||||
|
<view class="merit-card__copy">
|
||||||
<text>{{ item.title }}</text>
|
<text>{{ item.title }}</text>
|
||||||
<text>{{ item.donor }}{{ item.typeLabel ? ` · ${item.typeLabel}` : "" }}</text>
|
<text>{{ item.donor }}{{ item.typeLabel ? ` · ${item.typeLabel}` : "" }}</text>
|
||||||
<text v-if="item.time">{{ item.time }}</text>
|
<text v-if="item.time">{{ item.time }}</text>
|
||||||
<text v-if="item.createTime">创建于 {{ formatMinuteTimestamp(item.createTime) }}</text>
|
<text v-if="item.createTime">创建于 {{ formatMinuteTimestamp(item.createTime) }}</text>
|
||||||
<text v-if="item.content">{{ item.content }}</text>
|
<text v-if="item.content">{{ item.content }}</text>
|
||||||
</view>
|
</view>
|
||||||
<view class="merit-card__amount">
|
</view>
|
||||||
|
<view v-if="!meritBatch.selectionMode.value" class="merit-card__amount">
|
||||||
<text>¥{{ item.amount }}</text>
|
<text>¥{{ item.amount }}</text>
|
||||||
<AppButton
|
<AppButton
|
||||||
v-if="item.canEdit"
|
v-if="item.canEdit"
|
||||||
@@ -135,6 +163,17 @@
|
|||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
<AppDialog
|
||||||
|
:visible="meritBatch.confirmationVisible.value"
|
||||||
|
title="将选中的功德记录移入回收站?"
|
||||||
|
:message="meritBatch.confirmationMessage.value"
|
||||||
|
:confirm-text="meritBatch.deleting.value ? '正在删除' : '移入回收站'"
|
||||||
|
cancel-text="继续选择"
|
||||||
|
show-cancel
|
||||||
|
:close-on-mask="false"
|
||||||
|
@confirm="meritBatch.confirmDelete"
|
||||||
|
@cancel="meritBatch.cancelDelete"
|
||||||
|
/>
|
||||||
<AppDialog
|
<AppDialog
|
||||||
:visible="Boolean(detailTarget)"
|
:visible="Boolean(detailTarget)"
|
||||||
eyebrow="功德详情"
|
eyebrow="功德详情"
|
||||||
@@ -199,9 +238,12 @@ import { onBackPress, onLoad, onShow, onUnload } from "@dcloudio/uni-app";
|
|||||||
import AppButton from "@/components/AppButton.vue";
|
import AppButton from "@/components/AppButton.vue";
|
||||||
import AppDialog from "@/components/AppDialog.vue";
|
import AppDialog from "@/components/AppDialog.vue";
|
||||||
import AppLoading from "@/components/AppLoading.vue";
|
import AppLoading from "@/components/AppLoading.vue";
|
||||||
|
import BatchManagementBar from "@/components/BatchManagementBar.vue";
|
||||||
|
import BatchSelectionMark from "@/components/BatchSelectionMark.vue";
|
||||||
import ModulePageBackground from "@/components/ModulePageBackground.vue";
|
import ModulePageBackground from "@/components/ModulePageBackground.vue";
|
||||||
import PageHeader from "@/components/PageHeader.vue";
|
import PageHeader from "@/components/PageHeader.vue";
|
||||||
import { businessDictionaryApi } from "@/services/api/business-dictionary-service.js";
|
import { businessDictionaryApi } from "@/services/api/business-dictionary-service.js";
|
||||||
|
import { useBatchDeletion } from "@/composables/use-batch-deletion.js";
|
||||||
import {
|
import {
|
||||||
createRequestController,
|
createRequestController,
|
||||||
isRequestCancelled
|
isRequestCancelled
|
||||||
@@ -263,6 +305,19 @@ const meritTypeRequestController = createRequestController();
|
|||||||
const meritRecordCreateGuard = createNonIdempotentWriteGuard();
|
const meritRecordCreateGuard = createNonIdempotentWriteGuard();
|
||||||
let pageActive = true;
|
let pageActive = true;
|
||||||
const valid = computed(() => /^[1-9]\d*$/.test(genealogyId.value));
|
const valid = computed(() => /^[1-9]\d*$/.test(genealogyId.value));
|
||||||
|
const meritBatch = useBatchDeletion({
|
||||||
|
items: merits,
|
||||||
|
deleteOne: (merit) =>
|
||||||
|
lifeRecordApi.deleteMeritRecord(genealogyId.value, merit.id, {
|
||||||
|
requestController: meritDeletionRequestController,
|
||||||
|
}),
|
||||||
|
resourceName: "功德记录",
|
||||||
|
isActive: () => pageActive,
|
||||||
|
onEmpty: () => {
|
||||||
|
listState.value = "empty";
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const deletableMerits = meritBatch.deletableItems;
|
||||||
const isEdit = computed(() => Boolean(editingMerit.value));
|
const isEdit = computed(() => Boolean(editingMerit.value));
|
||||||
const mediaOssIds = computed(() => mediaReceipts.value.map((item) => item.ossId).join(","));
|
const mediaOssIds = computed(() => mediaReceipts.value.map((item) => item.ossId).join(","));
|
||||||
const formSnapshot = computed(() => JSON.stringify({ ...form, mediaOssIds: mediaOssIds.value }));
|
const formSnapshot = computed(() => JSON.stringify({ ...form, mediaOssIds: mediaOssIds.value }));
|
||||||
@@ -338,6 +393,7 @@ const loadMerits = async () => {
|
|||||||
});
|
});
|
||||||
if (!pageActive) return;
|
if (!pageActive) return;
|
||||||
merits.value = rows;
|
merits.value = rows;
|
||||||
|
meritBatch.exitSelectionMode();
|
||||||
listState.value = merits.value.length ? "ready" : "empty";
|
listState.value = merits.value.length ? "ready" : "empty";
|
||||||
} catch (cause) {
|
} catch (cause) {
|
||||||
if (!pageActive || isRequestCancelled(cause)) return;
|
if (!pageActive || isRequestCancelled(cause)) return;
|
||||||
@@ -437,6 +493,13 @@ const openMeritDetail = async (merit) => {
|
|||||||
detailError.value = getRequestErrorMessage(cause, "完整记录读取失败,请稍后重试。");
|
detailError.value = getRequestErrorMessage(cause, "完整记录读取失败,请稍后重试。");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
const handleMeritClick = (merit) => {
|
||||||
|
if (meritBatch.selectionMode.value && merit?.canDelete) {
|
||||||
|
meritBatch.toggleSelection(merit);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
openMeritDetail(merit);
|
||||||
|
};
|
||||||
const closeMeritDetail = () => {
|
const closeMeritDetail = () => {
|
||||||
if (detailState.value === "loading") return;
|
if (detailState.value === "loading") return;
|
||||||
detailTarget.value = null;
|
detailTarget.value = null;
|
||||||
@@ -591,12 +654,22 @@ const deleteMerit = async () => {
|
|||||||
if (pageActive) deleting.value = false;
|
if (pageActive) deleting.value = false;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
const requestBack = () =>
|
const requestBack = () => {
|
||||||
detailTarget.value
|
if (meritBatch.deleting.value) return true;
|
||||||
? (closeMeritDetail(), true)
|
if (meritBatch.confirmationVisible.value) {
|
||||||
: view.value !== "form"
|
meritBatch.cancelDelete();
|
||||||
? goBack()
|
return true;
|
||||||
: runBackGuard({
|
}
|
||||||
|
if (meritBatch.selectionMode.value) {
|
||||||
|
meritBatch.exitSelectionMode();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (detailTarget.value) {
|
||||||
|
closeMeritDetail();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (view.value !== "form") return goBack();
|
||||||
|
return runBackGuard({
|
||||||
transientOpen: discardVisible.value,
|
transientOpen: discardVisible.value,
|
||||||
dirty: dirty.value,
|
dirty: dirty.value,
|
||||||
submitting: submitting.value,
|
submitting: submitting.value,
|
||||||
@@ -604,6 +677,7 @@ const requestBack = () =>
|
|||||||
"block-submitting": () => true,
|
"block-submitting": () => true,
|
||||||
"confirm-discard": confirmation.request,
|
"confirm-discard": confirmation.request,
|
||||||
});
|
});
|
||||||
|
};
|
||||||
|
|
||||||
onLoad((query) => {
|
onLoad((query) => {
|
||||||
genealogyId.value = String(query?.genealogyId || "");
|
genealogyId.value = String(query?.genealogyId || "");
|
||||||
@@ -782,21 +856,33 @@ onUnload(() => {
|
|||||||
gap: 18rpx;
|
gap: 18rpx;
|
||||||
padding: 28rpx 32rpx;
|
padding: 28rpx 32rpx;
|
||||||
}
|
}
|
||||||
.merit-card > view {
|
.merit-card__main {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 18rpx;
|
||||||
|
}
|
||||||
|
.merit-card__copy {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
}
|
}
|
||||||
|
.merit-card__cover {
|
||||||
|
width: 148rpx;
|
||||||
|
height: 148rpx;
|
||||||
|
flex: 0 0 148rpx;
|
||||||
|
border-radius: 8rpx;
|
||||||
|
background: rgba(128, 89, 49, .12);
|
||||||
|
}
|
||||||
.merit-card text {
|
.merit-card text {
|
||||||
display: block;
|
display: block;
|
||||||
}
|
}
|
||||||
.merit-card > view text:first-child {
|
.merit-card__copy text:first-child {
|
||||||
color: $ink;
|
color: $ink;
|
||||||
font-family: STKaiti, KaiTi, serif;
|
font-family: STKaiti, KaiTi, serif;
|
||||||
font-size: clamp(17px, 30rpx, 22px);
|
font-size: clamp(17px, 30rpx, 22px);
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
overflow-wrap: anywhere;
|
overflow-wrap: anywhere;
|
||||||
}
|
}
|
||||||
.merit-card > view text:not(:first-child) {
|
.merit-card__copy text:not(:first-child) {
|
||||||
margin-top: 7rpx;
|
margin-top: 7rpx;
|
||||||
color: $ink-muted;
|
color: $ink-muted;
|
||||||
font-size: clamp(13px, 21rpx, 16px);
|
font-size: clamp(13px, 21rpx, 16px);
|
||||||
|
|||||||
@@ -30,8 +30,28 @@
|
|||||||
><AppButton block label="新建往来记录" @click="createRelative"
|
><AppButton block label="新建往来记录" @click="createRelative"
|
||||||
/></view>
|
/></view>
|
||||||
<view v-else class="record-list">
|
<view v-else class="record-list">
|
||||||
|
<BatchManagementBar
|
||||||
|
v-if="deletableRecords.length"
|
||||||
|
resource-name="往来记录"
|
||||||
|
:active="recordBatch.selectionMode.value"
|
||||||
|
:selected-count="recordBatch.selectedCount.value"
|
||||||
|
:all-selected="recordBatch.allSelected.value"
|
||||||
|
:busy="recordBatch.deleting.value"
|
||||||
|
@start="recordBatch.enterSelectionMode"
|
||||||
|
@finish="recordBatch.exitSelectionMode"
|
||||||
|
@toggle-all="recordBatch.toggleAll"
|
||||||
|
@delete="recordBatch.requestDelete"
|
||||||
|
/>
|
||||||
|
<text v-if="recordBatch.notice.value" class="batch-notice" role="status">{{ recordBatch.notice.value }}</text>
|
||||||
|
<text v-if="recordBatch.error.value" class="delete-error" role="alert">{{ recordBatch.error.value }}</text>
|
||||||
<text v-if="deleteError" class="delete-error">{{ deleteError }}</text>
|
<text v-if="deleteError" class="delete-error">{{ deleteError }}</text>
|
||||||
<view v-for="item in records" :key="item.id" class="record-card" role="button" :aria-label="`查看${item.name}的往来详情`" @click="openRecordDetail(item)">
|
<view v-for="item in records" :key="item.id" class="record-card" role="button" :aria-label="recordBatch.selectionMode.value && item.canDelete ? `${recordBatch.isSelected(item) ? '取消选择' : '选择'}${item.name}` : `查看${item.name}的往来详情`" @click="handleRecordClick(item)">
|
||||||
|
<BatchSelectionMark
|
||||||
|
v-if="recordBatch.selectionMode.value && item.canDelete"
|
||||||
|
:selected="recordBatch.isSelected(item)"
|
||||||
|
:label="`往来记录:${item.name}`"
|
||||||
|
@toggle="recordBatch.toggleSelection(item)"
|
||||||
|
/>
|
||||||
<image
|
<image
|
||||||
v-if="item.mediaFiles?.[0]?.accessUrl"
|
v-if="item.mediaFiles?.[0]?.accessUrl"
|
||||||
class="record-card__cover"
|
class="record-card__cover"
|
||||||
@@ -48,7 +68,7 @@
|
|||||||
><text v-if="item.createTime">创建于 {{ formatMinuteTimestamp(item.createTime) }}</text
|
><text v-if="item.createTime">创建于 {{ formatMinuteTimestamp(item.createTime) }}</text
|
||||||
><text v-if="item.content">{{ item.content }}</text></view
|
><text v-if="item.content">{{ item.content }}</text></view
|
||||||
>
|
>
|
||||||
<view class="record-card__amount">
|
<view v-if="!recordBatch.selectionMode.value" class="record-card__amount">
|
||||||
<text v-if="item.amount">¥{{ item.amount }}</text>
|
<text v-if="item.amount">¥{{ item.amount }}</text>
|
||||||
<AppButton
|
<AppButton
|
||||||
v-if="item.canEdit"
|
v-if="item.canEdit"
|
||||||
@@ -68,6 +88,17 @@
|
|||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
<AppDialog
|
||||||
|
:visible="recordBatch.confirmationVisible.value"
|
||||||
|
title="将选中的往来记录移入回收站?"
|
||||||
|
:message="recordBatch.confirmationMessage.value"
|
||||||
|
:confirm-text="recordBatch.deleting.value ? '正在删除' : '移入回收站'"
|
||||||
|
cancel-text="继续选择"
|
||||||
|
show-cancel
|
||||||
|
:close-on-mask="false"
|
||||||
|
@confirm="recordBatch.confirmDelete"
|
||||||
|
@cancel="recordBatch.cancelDelete"
|
||||||
|
/>
|
||||||
<AppDialog
|
<AppDialog
|
||||||
:visible="Boolean(detailTarget)"
|
:visible="Boolean(detailTarget)"
|
||||||
eyebrow="往来详情"
|
eyebrow="往来详情"
|
||||||
@@ -113,8 +144,11 @@ import { onBackPress, onLoad, onShow, onUnload } from "@dcloudio/uni-app";
|
|||||||
import AppButton from "@/components/AppButton.vue";
|
import AppButton from "@/components/AppButton.vue";
|
||||||
import AppDialog from "@/components/AppDialog.vue";
|
import AppDialog from "@/components/AppDialog.vue";
|
||||||
import AppLoading from "@/components/AppLoading.vue";
|
import AppLoading from "@/components/AppLoading.vue";
|
||||||
|
import BatchManagementBar from "@/components/BatchManagementBar.vue";
|
||||||
|
import BatchSelectionMark from "@/components/BatchSelectionMark.vue";
|
||||||
import ModulePageBackground from "@/components/ModulePageBackground.vue";
|
import ModulePageBackground from "@/components/ModulePageBackground.vue";
|
||||||
import PageHeader from "@/components/PageHeader.vue";
|
import PageHeader from "@/components/PageHeader.vue";
|
||||||
|
import { useBatchDeletion } from "@/composables/use-batch-deletion.js";
|
||||||
import {
|
import {
|
||||||
createRequestController,
|
createRequestController,
|
||||||
isRequestCancelled
|
isRequestCancelled
|
||||||
@@ -139,6 +173,19 @@ const detailError = ref("");
|
|||||||
const relativeRecordDetailController = createRequestController();
|
const relativeRecordDetailController = createRequestController();
|
||||||
let isPageActive = true;
|
let isPageActive = true;
|
||||||
const valid = computed(() => /^[1-9]\d*$/.test(genealogyId.value));
|
const valid = computed(() => /^[1-9]\d*$/.test(genealogyId.value));
|
||||||
|
const recordBatch = useBatchDeletion({
|
||||||
|
items: records,
|
||||||
|
deleteOne: (record) =>
|
||||||
|
lifeRecordApi.deleteRelativeRecord(genealogyId.value, record.id, {
|
||||||
|
requestController: relativeRecordDeleteController,
|
||||||
|
}),
|
||||||
|
resourceName: "往来记录",
|
||||||
|
isActive: () => isPageActive,
|
||||||
|
onEmpty: () => {
|
||||||
|
relativeRecordListState.value = "empty";
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const deletableRecords = recordBatch.deletableItems;
|
||||||
const loadRecords = async () => {
|
const loadRecords = async () => {
|
||||||
if (!valid.value) return;
|
if (!valid.value) return;
|
||||||
relativeRecordListController.abort();
|
relativeRecordListController.abort();
|
||||||
@@ -149,6 +196,7 @@ const loadRecords = async () => {
|
|||||||
});
|
});
|
||||||
if (!isPageActive) return;
|
if (!isPageActive) return;
|
||||||
records.value = rows;
|
records.value = rows;
|
||||||
|
recordBatch.exitSelectionMode();
|
||||||
relativeRecordListState.value = records.value.length ? "ready" : "empty";
|
relativeRecordListState.value = records.value.length ? "ready" : "empty";
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (!isPageActive || isRequestCancelled(error)) return;
|
if (!isPageActive || isRequestCancelled(error)) return;
|
||||||
@@ -156,7 +204,7 @@ const loadRecords = async () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
const returnToFamily = () =>
|
const returnToFamily = () =>
|
||||||
valid.value ? returnTo("F01", { genealogyId: genealogyId.value }) : goBack();
|
valid.value ? returnTo("G05", { genealogyId: genealogyId.value }) : goBack();
|
||||||
const createRelative = () =>
|
const createRelative = () =>
|
||||||
valid.value
|
valid.value
|
||||||
? openPage("R04", { genealogyId: genealogyId.value, mode: "create" }, "R03")
|
? openPage("R04", { genealogyId: genealogyId.value, mode: "create" }, "R03")
|
||||||
@@ -188,6 +236,13 @@ const openRecordDetail = async (record) => {
|
|||||||
detailError.value = getRequestErrorMessage(error, "完整记录读取失败,请稍后重试。");
|
detailError.value = getRequestErrorMessage(error, "完整记录读取失败,请稍后重试。");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
const handleRecordClick = (record) => {
|
||||||
|
if (recordBatch.selectionMode.value && record?.canDelete) {
|
||||||
|
recordBatch.toggleSelection(record);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
openRecordDetail(record);
|
||||||
|
};
|
||||||
const closeRecordDetail = () => {
|
const closeRecordDetail = () => {
|
||||||
if (detailState.value === "loading") return;
|
if (detailState.value === "loading") return;
|
||||||
detailTarget.value = null;
|
detailTarget.value = null;
|
||||||
@@ -211,7 +266,15 @@ const closeDeleteConfirmation = () => {
|
|||||||
deleteTarget.value = null;
|
deleteTarget.value = null;
|
||||||
};
|
};
|
||||||
const requestBack = () => {
|
const requestBack = () => {
|
||||||
if (deleting.value || detailState.value === "loading") return true;
|
if (deleting.value || recordBatch.deleting.value || detailState.value === "loading") return true;
|
||||||
|
if (recordBatch.confirmationVisible.value) {
|
||||||
|
recordBatch.cancelDelete();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (recordBatch.selectionMode.value) {
|
||||||
|
recordBatch.exitSelectionMode();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
if (deleteConfirmationVisible.value) {
|
if (deleteConfirmationVisible.value) {
|
||||||
closeDeleteConfirmation();
|
closeDeleteConfirmation();
|
||||||
return true;
|
return true;
|
||||||
|
|||||||
@@ -55,7 +55,7 @@
|
|||||||
<picker
|
<picker
|
||||||
:range="sexOptions.map((item) => item.label)"
|
:range="sexOptions.map((item) => item.label)"
|
||||||
:value="optionIndex(sexOptions, addForm.sex)"
|
:value="optionIndex(sexOptions, addForm.sex)"
|
||||||
@change="selectOption('sex', sexOptions, $event)"
|
@change="selectSex"
|
||||||
>
|
>
|
||||||
<view class="form-field form-field--picker">
|
<view class="form-field form-field--picker">
|
||||||
<text>性别</text
|
<text>性别</text
|
||||||
@@ -168,19 +168,35 @@
|
|||||||
type="number"
|
type="number"
|
||||||
placeholder="可选正整数"
|
placeholder="可选正整数"
|
||||||
placeholder-class="form-placeholder"
|
placeholder-class="form-placeholder"
|
||||||
|
@blur="loadRankOptions"
|
||||||
/>
|
/>
|
||||||
<text v-else>首位成员固定为 1</text>
|
<text v-else>首位成员固定为 1</text>
|
||||||
</view>
|
</view>
|
||||||
<picker
|
<picker
|
||||||
mode="date"
|
v-if="rankOptions.length"
|
||||||
:value="addForm.birthDate"
|
:range="rankPickerLabels"
|
||||||
@change="selectBirthDate"
|
:value="rankOptionIndex"
|
||||||
|
:disabled="rankOptionsState !== 'ready' || isSubmitting"
|
||||||
|
@change="selectRank"
|
||||||
>
|
>
|
||||||
<view class="form-field form-field--picker">
|
<view class="form-field form-field--picker">
|
||||||
<text>出生日期</text
|
<text>排行称谓</text><text>{{ selectedRankName || "请选择" }}</text>
|
||||||
><text>{{ addForm.birthDate || "请选择" }}</text>
|
|
||||||
</view>
|
</view>
|
||||||
</picker>
|
</picker>
|
||||||
|
<view v-else class="form-field form-field--disabled">
|
||||||
|
<text>排行称谓</text><text>{{ rankOptionStateLabel }}</text>
|
||||||
|
</view>
|
||||||
|
<text v-if="rankOptionsState === 'error'" class="field-error"
|
||||||
|
>排行配置暂未就绪,请稍后重试。</text
|
||||||
|
>
|
||||||
|
<view
|
||||||
|
class="form-field form-field--picker"
|
||||||
|
role="button"
|
||||||
|
aria-label="选择出生日期"
|
||||||
|
@click="openDatePicker('birthDate', '选择出生日期')"
|
||||||
|
>
|
||||||
|
<text>出生日期</text><text>{{ addForm.birthDate || "请选择" }}</text>
|
||||||
|
</view>
|
||||||
<picker
|
<picker
|
||||||
:range="lunarOptions.map((item) => item.label)"
|
:range="lunarOptions.map((item) => item.label)"
|
||||||
:value="optionIndex(lunarOptions, addForm.birthLunar)"
|
:value="optionIndex(lunarOptions, addForm.birthLunar)"
|
||||||
@@ -260,17 +276,15 @@
|
|||||||
/>
|
/>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<picker
|
<view
|
||||||
v-if="isDeceased"
|
v-if="isDeceased"
|
||||||
mode="date"
|
class="form-field form-field--picker"
|
||||||
:value="addForm.deathDate"
|
role="button"
|
||||||
@change="selectDeathDate"
|
aria-label="选择逝世日期"
|
||||||
|
@click="openDatePicker('deathDate', '选择逝世日期')"
|
||||||
>
|
>
|
||||||
<view class="form-field form-field--picker">
|
<text>逝世日期</text><text>{{ addForm.deathDate || "请选择" }}</text>
|
||||||
<text>逝世日期</text
|
|
||||||
><text>{{ addForm.deathDate || "请选择" }}</text>
|
|
||||||
</view>
|
</view>
|
||||||
</picker>
|
|
||||||
<picker
|
<picker
|
||||||
v-if="isDeceased"
|
v-if="isDeceased"
|
||||||
:range="lunarOptions.map((item) => item.label)"
|
:range="lunarOptions.map((item) => item.label)"
|
||||||
@@ -338,17 +352,15 @@
|
|||||||
<text>关系称谓</text><text>{{ optionLabel(relationVariantOptions, addForm.relationVariantCode) || "请选择" }}</text>
|
<text>关系称谓</text><text>{{ optionLabel(relationVariantOptions, addForm.relationVariantCode) || "请选择" }}</text>
|
||||||
</view>
|
</view>
|
||||||
</picker>
|
</picker>
|
||||||
<picker
|
<view
|
||||||
v-if="isDeceased"
|
v-if="isDeceased"
|
||||||
mode="date"
|
class="form-field form-field--picker"
|
||||||
:value="addForm.burialDate"
|
role="button"
|
||||||
@change="selectBurialDate"
|
aria-label="选择安葬日期"
|
||||||
|
@click="openDatePicker('burialDate', '选择安葬日期')"
|
||||||
>
|
>
|
||||||
<view class="form-field form-field--picker">
|
<text>安葬日期</text><text>{{ addForm.burialDate || "请选择" }}</text>
|
||||||
<text>安葬日期</text
|
|
||||||
><text>{{ addForm.burialDate || "请选择" }}</text>
|
|
||||||
</view>
|
</view>
|
||||||
</picker>
|
|
||||||
<view v-if="isDeceased" class="form-field">
|
<view v-if="isDeceased" class="form-field">
|
||||||
<text>安葬地</text>
|
<text>安葬地</text>
|
||||||
<input
|
<input
|
||||||
@@ -373,15 +385,6 @@
|
|||||||
}}</text>
|
}}</text>
|
||||||
</view>
|
</view>
|
||||||
</picker>
|
</picker>
|
||||||
<view class="form-field">
|
|
||||||
<text>排序值</text>
|
|
||||||
<input
|
|
||||||
v-model="addForm.sortOrder"
|
|
||||||
type="number"
|
|
||||||
placeholder="可选整数,值越小越靠前"
|
|
||||||
placeholder-class="form-placeholder"
|
|
||||||
/>
|
|
||||||
</view>
|
|
||||||
<view class="form-field form-field--summary">
|
<view class="form-field form-field--summary">
|
||||||
<text>人物简介</text>
|
<text>人物简介</text>
|
||||||
<textarea
|
<textarea
|
||||||
@@ -427,6 +430,14 @@
|
|||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
|
<MemberDatePickerSheet
|
||||||
|
:visible="datePickerVisible"
|
||||||
|
:title="datePickerTitle"
|
||||||
|
:value="datePickerValue"
|
||||||
|
@cancel="closeDatePicker"
|
||||||
|
@confirm="confirmDatePicker"
|
||||||
|
/>
|
||||||
|
|
||||||
<AppDialog
|
<AppDialog
|
||||||
:visible="discardDialogVisible"
|
:visible="discardDialogVisible"
|
||||||
eyebrow="尚未保存"
|
eyebrow="尚未保存"
|
||||||
@@ -447,6 +458,7 @@ import { computed, reactive, ref } from "vue";
|
|||||||
import { onBackPress, onLoad, onUnload } from "@dcloudio/uni-app";
|
import { onBackPress, onLoad, onUnload } from "@dcloudio/uni-app";
|
||||||
import AppLoading from "@/components/AppLoading.vue";
|
import AppLoading from "@/components/AppLoading.vue";
|
||||||
import AppDialog from "@/components/AppDialog.vue";
|
import AppDialog from "@/components/AppDialog.vue";
|
||||||
|
import MemberDatePickerSheet from "@/components/tree/MemberDatePickerSheet.vue";
|
||||||
import ModulePageBackground from "@/components/ModulePageBackground.vue";
|
import ModulePageBackground from "@/components/ModulePageBackground.vue";
|
||||||
import PageHeader from "@/components/PageHeader.vue";
|
import PageHeader from "@/components/PageHeader.vue";
|
||||||
import {
|
import {
|
||||||
@@ -486,10 +498,14 @@ const isAvatarUploading = ref(false);
|
|||||||
const avatarFileName = ref("");
|
const avatarFileName = ref("");
|
||||||
const avatarUploadError = ref("");
|
const avatarUploadError = ref("");
|
||||||
const discardDialogVisible = ref(false);
|
const discardDialogVisible = ref(false);
|
||||||
|
const datePickerVisible = ref(false);
|
||||||
|
const datePickerField = ref("");
|
||||||
|
const datePickerTitle = ref("选择日期");
|
||||||
const currentMember = ref(null);
|
const currentMember = ref(null);
|
||||||
const errorMessage = ref("");
|
const errorMessage = ref("");
|
||||||
const memberDetailRequestController = createRequestController();
|
const memberDetailRequestController = createRequestController();
|
||||||
const memberOptionsRequestController = createRequestController();
|
const memberOptionsRequestController = createRequestController();
|
||||||
|
const rankOptionsRequestController = createRequestController();
|
||||||
const avatarUploadRequestController = createRequestController();
|
const avatarUploadRequestController = createRequestController();
|
||||||
const memberCreationRequestController = createRequestController();
|
const memberCreationRequestController = createRequestController();
|
||||||
const sensitiveProfileRequestController = createRequestController();
|
const sensitiveProfileRequestController = createRequestController();
|
||||||
@@ -503,6 +519,8 @@ const dictionaryRequestControllers = {
|
|||||||
const memberCreationGuard = createNonIdempotentWriteGuard();
|
const memberCreationGuard = createNonIdempotentWriteGuard();
|
||||||
let pageActive = true;
|
let pageActive = true;
|
||||||
let loadSequence = 0;
|
let loadSequence = 0;
|
||||||
|
let rankLoadSequence = 0;
|
||||||
|
let rankOptionsContext = "";
|
||||||
|
|
||||||
const relationIntents = Object.freeze(
|
const relationIntents = Object.freeze(
|
||||||
Object.fromEntries(
|
Object.fromEntries(
|
||||||
@@ -526,6 +544,7 @@ const addForm = reactive({
|
|||||||
sex: "",
|
sex: "",
|
||||||
generation: "",
|
generation: "",
|
||||||
generationName: "",
|
generationName: "",
|
||||||
|
rankId: "",
|
||||||
avatarOssId: "",
|
avatarOssId: "",
|
||||||
birthDate: "",
|
birthDate: "",
|
||||||
birthLunar: "",
|
birthLunar: "",
|
||||||
@@ -548,7 +567,6 @@ const addForm = reactive({
|
|||||||
personStatus: "",
|
personStatus: "",
|
||||||
biography: "",
|
biography: "",
|
||||||
remark: "",
|
remark: "",
|
||||||
sortOrder: "",
|
|
||||||
});
|
});
|
||||||
const fieldErrors = reactive({
|
const fieldErrors = reactive({
|
||||||
name: "",
|
name: "",
|
||||||
@@ -560,6 +578,8 @@ const fieldErrors = reactive({
|
|||||||
});
|
});
|
||||||
const memberOptionsState = ref("loading");
|
const memberOptionsState = ref("loading");
|
||||||
const memberOptions = ref([]);
|
const memberOptions = ref([]);
|
||||||
|
const rankOptionsState = ref("idle");
|
||||||
|
const rankOptions = ref([]);
|
||||||
const zodiacOptions = ref([]);
|
const zodiacOptions = ref([]);
|
||||||
const educationOptions = ref([]);
|
const educationOptions = ref([]);
|
||||||
const deathExpressionOptions = ref([]);
|
const deathExpressionOptions = ref([]);
|
||||||
@@ -568,6 +588,7 @@ const spouseRelationVariantOptions = ref([]);
|
|||||||
|
|
||||||
const isFirstMember = computed(() => mode.value === "first");
|
const isFirstMember = computed(() => mode.value === "first");
|
||||||
const isDeceased = computed(() => addForm.personStatus === "1");
|
const isDeceased = computed(() => addForm.personStatus === "1");
|
||||||
|
const datePickerValue = computed(() => addForm[datePickerField.value] || "");
|
||||||
const activeRelationIntent = computed(
|
const activeRelationIntent = computed(
|
||||||
() => relationIntents[relationType.value] || null,
|
() => relationIntents[relationType.value] || null,
|
||||||
);
|
);
|
||||||
@@ -617,6 +638,23 @@ const formCopy = computed(() =>
|
|||||||
: "先确认新成员与当前成员的关系,再填写可核实的身份信息。",
|
: "先确认新成员与当前成员的关系,再填写可核实的身份信息。",
|
||||||
);
|
);
|
||||||
const formNote = computed(() => "红色 * 为必填项,其余内容可按家谱记载补充。");
|
const formNote = computed(() => "红色 * 为必填项,其余内容可按家谱记载补充。");
|
||||||
|
const rankPickerLabels = computed(() => [
|
||||||
|
"请选择排行称谓",
|
||||||
|
...rankOptions.value.map((item) => item.rankName),
|
||||||
|
]);
|
||||||
|
const rankOptionIndex = computed(() => {
|
||||||
|
const index = rankOptions.value.findIndex((item) => item.rankId === addForm.rankId);
|
||||||
|
return index < 0 ? 0 : index + 1;
|
||||||
|
});
|
||||||
|
const selectedRankName = computed(
|
||||||
|
() => rankOptions.value.find((item) => item.rankId === addForm.rankId)?.rankName || "",
|
||||||
|
);
|
||||||
|
const rankOptionStateLabel = computed(() => {
|
||||||
|
if (rankOptionsState.value === "loading") return "正在读取";
|
||||||
|
if (rankOptionsState.value === "error") return "暂时无法读取";
|
||||||
|
if (rankOptionsState.value === "idle") return "请先选择性别和世代";
|
||||||
|
return "当前条件暂无可选排行";
|
||||||
|
});
|
||||||
const resultCopy = computed(() => {
|
const resultCopy = computed(() => {
|
||||||
if (memberCreationOutcomeUnknown.value) {
|
if (memberCreationOutcomeUnknown.value) {
|
||||||
return {
|
return {
|
||||||
@@ -675,6 +713,7 @@ const loadCurrentMember = async () => {
|
|||||||
});
|
});
|
||||||
if (!pageActive || activeLoad !== loadSequence) return;
|
if (!pageActive || activeLoad !== loadSequence) return;
|
||||||
currentMember.value = member;
|
currentMember.value = member;
|
||||||
|
applyRelationDefaults();
|
||||||
addState.value = "form";
|
addState.value = "form";
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (!pageActive || activeLoad !== loadSequence || isRequestCancelled(error)) return;
|
if (!pageActive || activeLoad !== loadSequence || isRequestCancelled(error)) return;
|
||||||
@@ -753,6 +792,7 @@ onLoad((query) => {
|
|||||||
void loadMemberOptions();
|
void loadMemberOptions();
|
||||||
void loadBusinessOptions();
|
void loadBusinessOptions();
|
||||||
if (isFirstMember.value) {
|
if (isFirstMember.value) {
|
||||||
|
applyRelationDefaults();
|
||||||
addState.value = "form";
|
addState.value = "form";
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -763,6 +803,7 @@ onUnload(() => {
|
|||||||
loadSequence += 1;
|
loadSequence += 1;
|
||||||
memberDetailRequestController.abort();
|
memberDetailRequestController.abort();
|
||||||
memberOptionsRequestController.abort();
|
memberOptionsRequestController.abort();
|
||||||
|
rankOptionsRequestController.abort();
|
||||||
avatarUploadRequestController.abort();
|
avatarUploadRequestController.abort();
|
||||||
memberCreationRequestController.abort();
|
memberCreationRequestController.abort();
|
||||||
sensitiveProfileRequestController.abort();
|
sensitiveProfileRequestController.abort();
|
||||||
@@ -772,10 +813,13 @@ onUnload(() => {
|
|||||||
|
|
||||||
const requestBack = () =>
|
const requestBack = () =>
|
||||||
runBackGuard({
|
runBackGuard({
|
||||||
transientOpen: discardDialogVisible.value,
|
transientOpen: datePickerVisible.value || discardDialogVisible.value,
|
||||||
dirty: addState.value === "form" && hasDraft.value,
|
dirty: addState.value === "form" && hasDraft.value,
|
||||||
submitting: isSubmitting.value || isAvatarUploading.value,
|
submitting: isSubmitting.value || isAvatarUploading.value,
|
||||||
"close-transient": cancelDiscard,
|
"close-transient": () => {
|
||||||
|
if (datePickerVisible.value) return closeDatePicker();
|
||||||
|
return cancelDiscard();
|
||||||
|
},
|
||||||
"block-submitting": () => true,
|
"block-submitting": () => true,
|
||||||
"confirm-discard": requestDiscardConfirmation,
|
"confirm-discard": requestDiscardConfirmation,
|
||||||
});
|
});
|
||||||
@@ -788,6 +832,7 @@ const clearError = (field) => {
|
|||||||
const selectRelation = (event) => {
|
const selectRelation = (event) => {
|
||||||
addForm.relation = relationOptions.value[Number(event.detail.value)] || "";
|
addForm.relation = relationOptions.value[Number(event.detail.value)] || "";
|
||||||
addForm.relationVariantCode = "";
|
addForm.relationVariantCode = "";
|
||||||
|
applyRelationDefaults();
|
||||||
clearError("relation");
|
clearError("relation");
|
||||||
};
|
};
|
||||||
const optionIndex = findMemberOptionIndex;
|
const optionIndex = findMemberOptionIndex;
|
||||||
@@ -795,6 +840,64 @@ const optionLabel = findMemberOptionLabel;
|
|||||||
const selectOption = (field, options, event) => {
|
const selectOption = (field, options, event) => {
|
||||||
updateMemberFormOption(addForm, field, options, event);
|
updateMemberFormOption(addForm, field, options, event);
|
||||||
};
|
};
|
||||||
|
const relationGeneration = () => {
|
||||||
|
const currentGeneration = Number(currentMember.value?.generation);
|
||||||
|
if (!Number.isSafeInteger(currentGeneration) || currentGeneration < 1) return "";
|
||||||
|
if ([memberRelationTypes.FATHER, memberRelationTypes.MOTHER].includes(selectedRelationType.value)) {
|
||||||
|
return String(Math.max(1, currentGeneration - 1));
|
||||||
|
}
|
||||||
|
if ([memberRelationTypes.SON, memberRelationTypes.DAUGHTER].includes(selectedRelationType.value)) {
|
||||||
|
return String(currentGeneration + 1);
|
||||||
|
}
|
||||||
|
return String(currentGeneration);
|
||||||
|
};
|
||||||
|
const applyRelationDefaults = () => {
|
||||||
|
addForm.generation = isFirstMember.value ? "1" : relationGeneration();
|
||||||
|
if ([memberRelationTypes.FATHER, memberRelationTypes.SON].includes(selectedRelationType.value)) {
|
||||||
|
addForm.sex = "0";
|
||||||
|
} else if ([memberRelationTypes.MOTHER, memberRelationTypes.DAUGHTER].includes(selectedRelationType.value)) {
|
||||||
|
addForm.sex = "1";
|
||||||
|
}
|
||||||
|
rankOptionsContext = "";
|
||||||
|
addForm.rankId = "";
|
||||||
|
void loadRankOptions();
|
||||||
|
};
|
||||||
|
const loadRankOptions = async () => {
|
||||||
|
const generation = Number(isFirstMember.value ? 1 : addForm.generation);
|
||||||
|
const sex = String(addForm.sex || "");
|
||||||
|
const context = `${generation}:${sex}`;
|
||||||
|
const activeLoad = ++rankLoadSequence;
|
||||||
|
if (!Number.isSafeInteger(generation) || generation < 1 || !["0", "1", "2"].includes(sex)) {
|
||||||
|
rankOptionsContext = "";
|
||||||
|
rankOptions.value = [];
|
||||||
|
rankOptionsState.value = "idle";
|
||||||
|
addForm.rankId = "";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (context !== rankOptionsContext) addForm.rankId = "";
|
||||||
|
rankOptionsContext = context;
|
||||||
|
rankOptionsState.value = "loading";
|
||||||
|
try {
|
||||||
|
const options = await lineageApi.getRankOptions(genealogyId.value, generation, sex, {
|
||||||
|
requestController: rankOptionsRequestController,
|
||||||
|
});
|
||||||
|
if (!pageActive || activeLoad !== rankLoadSequence) return;
|
||||||
|
rankOptions.value = options;
|
||||||
|
rankOptionsState.value = "ready";
|
||||||
|
} catch (error) {
|
||||||
|
if (!pageActive || activeLoad !== rankLoadSequence || isRequestCancelled(error)) return;
|
||||||
|
rankOptions.value = [];
|
||||||
|
rankOptionsState.value = "error";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const selectSex = (event) => {
|
||||||
|
selectOption("sex", sexOptions, event);
|
||||||
|
void loadRankOptions();
|
||||||
|
};
|
||||||
|
const selectRank = (event) => {
|
||||||
|
const selectedIndex = Number(event.detail.value) - 1;
|
||||||
|
addForm.rankId = rankOptions.value[selectedIndex]?.rankId || "";
|
||||||
|
};
|
||||||
const selectBindingMode = (event) => {
|
const selectBindingMode = (event) => {
|
||||||
updateMemberBindingMode(addForm, event);
|
updateMemberBindingMode(addForm, event);
|
||||||
fieldErrors.bindingMode = "";
|
fieldErrors.bindingMode = "";
|
||||||
@@ -835,14 +938,26 @@ const uploadAvatar = async () => {
|
|||||||
if (pageActive) isAvatarUploading.value = false;
|
if (pageActive) isAvatarUploading.value = false;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
const selectBirthDate = (event) => {
|
const openDatePicker = (field, title) => {
|
||||||
addForm.birthDate = event.detail.value || "";
|
datePickerField.value = field;
|
||||||
|
datePickerTitle.value = title;
|
||||||
|
datePickerVisible.value = true;
|
||||||
};
|
};
|
||||||
const selectDeathDate = (event) => {
|
const closeDatePicker = () => {
|
||||||
addForm.deathDate = event.detail.value || "";
|
datePickerVisible.value = false;
|
||||||
};
|
};
|
||||||
const selectBurialDate = (event) => {
|
const confirmDatePicker = (value) => {
|
||||||
addForm.burialDate = event.detail.value || "";
|
if (!datePickerField.value) return closeDatePicker();
|
||||||
|
addForm[datePickerField.value] = value;
|
||||||
|
fieldErrors.dates = "";
|
||||||
|
closeDatePicker();
|
||||||
|
};
|
||||||
|
const localToday = () => {
|
||||||
|
const today = new Date();
|
||||||
|
const year = today.getFullYear();
|
||||||
|
const month = String(today.getMonth() + 1).padStart(2, "0");
|
||||||
|
const day = String(today.getDate()).padStart(2, "0");
|
||||||
|
return `${year}-${month}-${day}`;
|
||||||
};
|
};
|
||||||
const validateAddForm = () => {
|
const validateAddForm = () => {
|
||||||
fieldErrors.name = addForm.name.trim() ? "" : "请填写成员姓名";
|
fieldErrors.name = addForm.name.trim() ? "" : "请填写成员姓名";
|
||||||
@@ -858,8 +973,15 @@ const validateAddForm = () => {
|
|||||||
addForm.email.trim() && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(addForm.email.trim())
|
addForm.email.trim() && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(addForm.email.trim())
|
||||||
? "请填写有效的电子邮箱"
|
? "请填写有效的电子邮箱"
|
||||||
: "";
|
: "";
|
||||||
|
const today = localToday();
|
||||||
fieldErrors.dates =
|
fieldErrors.dates =
|
||||||
addForm.birthDate && addForm.deathDate && addForm.deathDate < addForm.birthDate
|
addForm.birthDate && addForm.birthDate > today
|
||||||
|
? "出生日期不能晚于今天"
|
||||||
|
: addForm.deathDate && addForm.deathDate > today
|
||||||
|
? "逝世日期不能晚于今天"
|
||||||
|
: addForm.burialDate && addForm.burialDate > today
|
||||||
|
? "安葬日期不能晚于今天"
|
||||||
|
: addForm.birthDate && addForm.deathDate && addForm.deathDate < addForm.birthDate
|
||||||
? "逝世日期不能早于出生日期"
|
? "逝世日期不能早于出生日期"
|
||||||
: addForm.deathDate && addForm.burialDate && addForm.burialDate < addForm.deathDate
|
: addForm.deathDate && addForm.burialDate && addForm.burialDate < addForm.deathDate
|
||||||
? "安葬日期不能早于逝世日期"
|
? "安葬日期不能早于逝世日期"
|
||||||
@@ -884,6 +1006,7 @@ const submitAdd = async () => {
|
|||||||
aliasName: addForm.aliasName,
|
aliasName: addForm.aliasName,
|
||||||
sex: addForm.sex,
|
sex: addForm.sex,
|
||||||
generationName: addForm.generationName,
|
generationName: addForm.generationName,
|
||||||
|
...(addForm.rankId ? { rankId: addForm.rankId } : {}),
|
||||||
avatarOssId: addForm.avatarOssId,
|
avatarOssId: addForm.avatarOssId,
|
||||||
birthDate: addForm.birthDate,
|
birthDate: addForm.birthDate,
|
||||||
birthLunar: addForm.birthLunar,
|
birthLunar: addForm.birthLunar,
|
||||||
@@ -908,7 +1031,6 @@ const submitAdd = async () => {
|
|||||||
personStatus: addForm.personStatus,
|
personStatus: addForm.personStatus,
|
||||||
biography: addForm.biography,
|
biography: addForm.biography,
|
||||||
remark: addForm.remark,
|
remark: addForm.remark,
|
||||||
sortOrder: addForm.sortOrder,
|
|
||||||
...(isFirstMember.value
|
...(isFirstMember.value
|
||||||
? { generation: 1 }
|
? { generation: 1 }
|
||||||
: {
|
: {
|
||||||
|
|||||||
@@ -49,7 +49,7 @@
|
|||||||
<picker
|
<picker
|
||||||
:range="sexOptions.map((item) => item.label)"
|
:range="sexOptions.map((item) => item.label)"
|
||||||
:value="optionIndex(sexOptions, editForm.sex)"
|
:value="optionIndex(sexOptions, editForm.sex)"
|
||||||
@change="selectOption('sex', sexOptions, $event)"
|
@change="selectSex"
|
||||||
>
|
>
|
||||||
<view class="form-field form-field--picker">
|
<view class="form-field form-field--picker">
|
||||||
<text>性别</text
|
<text>性别</text
|
||||||
@@ -148,8 +148,28 @@
|
|||||||
type="number"
|
type="number"
|
||||||
placeholder="正整数"
|
placeholder="正整数"
|
||||||
placeholder-class="form-placeholder"
|
placeholder-class="form-placeholder"
|
||||||
|
@blur="loadRankOptions"
|
||||||
/>
|
/>
|
||||||
</view>
|
</view>
|
||||||
|
<picker
|
||||||
|
v-if="rankOptions.length"
|
||||||
|
:range="rankPickerLabels"
|
||||||
|
:value="rankOptionIndex"
|
||||||
|
:disabled="rankOptionsState !== 'ready'"
|
||||||
|
@change="selectRank"
|
||||||
|
>
|
||||||
|
<view class="form-field form-field--picker">
|
||||||
|
<text>排行称谓</text>
|
||||||
|
<text>{{ selectedRankName || "请选择" }}</text>
|
||||||
|
</view>
|
||||||
|
</picker>
|
||||||
|
<view v-else class="form-field form-field--disabled">
|
||||||
|
<text>排行称谓</text>
|
||||||
|
<text>{{ rankOptionStateLabel }}</text>
|
||||||
|
</view>
|
||||||
|
<text v-if="rankOptionsState === 'error'" class="field-error"
|
||||||
|
>排行配置暂未就绪,请稍后重试。</text
|
||||||
|
>
|
||||||
<picker
|
<picker
|
||||||
:range="personOptionLabels"
|
:range="personOptionLabels"
|
||||||
:value="personOptionIndex(editForm.fatherId)"
|
:value="personOptionIndex(editForm.fatherId)"
|
||||||
@@ -170,16 +190,15 @@
|
|||||||
><text>{{ personOptionLabel(editForm.motherId) }}</text></view
|
><text>{{ personOptionLabel(editForm.motherId) }}</text></view
|
||||||
>
|
>
|
||||||
</picker>
|
</picker>
|
||||||
<picker
|
<view
|
||||||
mode="date"
|
class="form-field form-field--picker"
|
||||||
:value="editForm.birthDate"
|
role="button"
|
||||||
@change="selectDate('birthDate', $event)"
|
aria-label="选择出生日期"
|
||||||
|
@click="openDatePicker('birthDate', '选择出生日期')"
|
||||||
>
|
>
|
||||||
<view class="form-field form-field--picker"
|
<text>出生日期</text>
|
||||||
><text>出生日期</text
|
<text>{{ editForm.birthDate || "未填写" }}</text>
|
||||||
><text>{{ editForm.birthDate || "未填写" }}</text></view
|
</view>
|
||||||
>
|
|
||||||
</picker>
|
|
||||||
<picker
|
<picker
|
||||||
:range="lunarOptions.map((item) => item.label)"
|
:range="lunarOptions.map((item) => item.label)"
|
||||||
:value="optionIndex(lunarOptions, editForm.birthLunar)"
|
:value="optionIndex(lunarOptions, editForm.birthLunar)"
|
||||||
@@ -257,17 +276,16 @@
|
|||||||
placeholder-class="form-placeholder"
|
placeholder-class="form-placeholder"
|
||||||
/>
|
/>
|
||||||
</view>
|
</view>
|
||||||
<picker
|
<view
|
||||||
v-if="isDeceased"
|
v-if="isDeceased"
|
||||||
mode="date"
|
class="form-field form-field--picker"
|
||||||
:value="editForm.deathDate"
|
role="button"
|
||||||
@change="selectDate('deathDate', $event)"
|
aria-label="选择离世日期"
|
||||||
|
@click="openDatePicker('deathDate', '选择离世日期')"
|
||||||
>
|
>
|
||||||
<view class="form-field form-field--picker"
|
<text>离世日期</text>
|
||||||
><text>离世日期</text
|
<text>{{ editForm.deathDate || "在世或未填写" }}</text>
|
||||||
><text>{{ editForm.deathDate || "在世或未填写" }}</text></view
|
</view>
|
||||||
>
|
|
||||||
</picker>
|
|
||||||
<picker
|
<picker
|
||||||
v-if="isDeceased"
|
v-if="isDeceased"
|
||||||
:range="lunarOptions.map((item) => item.label)"
|
:range="lunarOptions.map((item) => item.label)"
|
||||||
@@ -329,17 +347,16 @@
|
|||||||
class="form-note"
|
class="form-note"
|
||||||
>遗传病史属于敏感健康信息,仅在已授权时读取和保存。</text
|
>遗传病史属于敏感健康信息,仅在已授权时读取和保存。</text
|
||||||
>
|
>
|
||||||
<picker
|
<view
|
||||||
v-if="isDeceased"
|
v-if="isDeceased"
|
||||||
mode="date"
|
class="form-field form-field--picker"
|
||||||
:value="editForm.burialDate"
|
role="button"
|
||||||
@change="selectDate('burialDate', $event)"
|
aria-label="选择安葬日期"
|
||||||
|
@click="openDatePicker('burialDate', '选择安葬日期')"
|
||||||
>
|
>
|
||||||
<view class="form-field form-field--picker"
|
<text>安葬日期</text>
|
||||||
><text>安葬日期</text
|
<text>{{ editForm.burialDate || "未填写" }}</text>
|
||||||
><text>{{ editForm.burialDate || "未填写" }}</text></view
|
</view>
|
||||||
>
|
|
||||||
</picker>
|
|
||||||
<view v-if="isDeceased" class="form-field">
|
<view v-if="isDeceased" class="form-field">
|
||||||
<text>安葬地</text>
|
<text>安葬地</text>
|
||||||
<input
|
<input
|
||||||
@@ -361,15 +378,6 @@
|
|||||||
}}</text>
|
}}</text>
|
||||||
</view>
|
</view>
|
||||||
</picker>
|
</picker>
|
||||||
<view class="form-field">
|
|
||||||
<text>排序值</text>
|
|
||||||
<input
|
|
||||||
v-model="editForm.sortOrder"
|
|
||||||
type="number"
|
|
||||||
placeholder="可选整数,值越小越靠前"
|
|
||||||
placeholder-class="form-placeholder"
|
|
||||||
/>
|
|
||||||
</view>
|
|
||||||
<view class="form-field form-field--summary">
|
<view class="form-field form-field--summary">
|
||||||
<text>人物简介</text>
|
<text>人物简介</text>
|
||||||
<textarea
|
<textarea
|
||||||
@@ -419,6 +427,14 @@
|
|||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
|
<MemberDatePickerSheet
|
||||||
|
:visible="datePickerVisible"
|
||||||
|
:title="datePickerTitle"
|
||||||
|
:value="datePickerValue"
|
||||||
|
@cancel="closeDatePicker"
|
||||||
|
@confirm="confirmDatePicker"
|
||||||
|
/>
|
||||||
|
|
||||||
<AppDialog
|
<AppDialog
|
||||||
:visible="discardDialogVisible"
|
:visible="discardDialogVisible"
|
||||||
eyebrow="资料尚未保存"
|
eyebrow="资料尚未保存"
|
||||||
@@ -439,6 +455,7 @@ import { computed, reactive, ref } from "vue";
|
|||||||
import { onBackPress, onLoad, onUnload } from "@dcloudio/uni-app";
|
import { onBackPress, onLoad, onUnload } from "@dcloudio/uni-app";
|
||||||
import AppLoading from "@/components/AppLoading.vue";
|
import AppLoading from "@/components/AppLoading.vue";
|
||||||
import AppDialog from "@/components/AppDialog.vue";
|
import AppDialog from "@/components/AppDialog.vue";
|
||||||
|
import MemberDatePickerSheet from "@/components/tree/MemberDatePickerSheet.vue";
|
||||||
import ModulePageBackground from "@/components/ModulePageBackground.vue";
|
import ModulePageBackground from "@/components/ModulePageBackground.vue";
|
||||||
import PageHeader from "@/components/PageHeader.vue";
|
import PageHeader from "@/components/PageHeader.vue";
|
||||||
import {
|
import {
|
||||||
@@ -476,11 +493,15 @@ const isAvatarUploading = ref(false);
|
|||||||
const avatarFileName = ref("");
|
const avatarFileName = ref("");
|
||||||
const avatarUploadError = ref("");
|
const avatarUploadError = ref("");
|
||||||
const discardDialogVisible = ref(false);
|
const discardDialogVisible = ref(false);
|
||||||
|
const datePickerVisible = ref(false);
|
||||||
|
const datePickerField = ref("");
|
||||||
|
const datePickerTitle = ref("选择日期");
|
||||||
const errorMessage = ref("");
|
const errorMessage = ref("");
|
||||||
const failedAction = ref("load");
|
const failedAction = ref("load");
|
||||||
const memberDetailRequestController = createRequestController();
|
const memberDetailRequestController = createRequestController();
|
||||||
const personOptionsRequestController = createRequestController();
|
const personOptionsRequestController = createRequestController();
|
||||||
const memberOptionsRequestController = createRequestController();
|
const memberOptionsRequestController = createRequestController();
|
||||||
|
const rankOptionsRequestController = createRequestController();
|
||||||
const avatarUploadRequestController = createRequestController();
|
const avatarUploadRequestController = createRequestController();
|
||||||
const memberUpdateRequestController = createRequestController();
|
const memberUpdateRequestController = createRequestController();
|
||||||
const sensitiveProfileRequestController = createRequestController();
|
const sensitiveProfileRequestController = createRequestController();
|
||||||
@@ -491,6 +512,8 @@ const dictionaryRequestControllers = {
|
|||||||
};
|
};
|
||||||
let pageActive = true;
|
let pageActive = true;
|
||||||
let loadSequence = 0;
|
let loadSequence = 0;
|
||||||
|
let rankLoadSequence = 0;
|
||||||
|
let rankOptionsContext = "";
|
||||||
|
|
||||||
const originalMember = ref(null);
|
const originalMember = ref(null);
|
||||||
const baseline = ref("");
|
const baseline = ref("");
|
||||||
@@ -504,6 +527,7 @@ const editForm = reactive({
|
|||||||
sex: "",
|
sex: "",
|
||||||
generation: "",
|
generation: "",
|
||||||
generationName: "",
|
generationName: "",
|
||||||
|
rankId: "",
|
||||||
fatherId: "",
|
fatherId: "",
|
||||||
motherId: "",
|
motherId: "",
|
||||||
avatarOssId: "",
|
avatarOssId: "",
|
||||||
@@ -541,6 +565,8 @@ const lunarOptions = memberFormOptions.lunar;
|
|||||||
const personStatusOptions = memberFormOptions.personStatus;
|
const personStatusOptions = memberFormOptions.personStatus;
|
||||||
const bindingModeOptions = memberFormOptions.bindingMode;
|
const bindingModeOptions = memberFormOptions.bindingMode;
|
||||||
const personOptions = ref([{ label: "不选择", value: "" }]);
|
const personOptions = ref([{ label: "不选择", value: "" }]);
|
||||||
|
const rankOptionsState = ref("idle");
|
||||||
|
const rankOptions = ref([]);
|
||||||
const personOptionLabels = computed(() =>
|
const personOptionLabels = computed(() =>
|
||||||
personOptions.value.map((item) => item.label),
|
personOptions.value.map((item) => item.label),
|
||||||
);
|
);
|
||||||
@@ -565,6 +591,26 @@ const memberOptionLabels = computed(() =>
|
|||||||
memberBindingOptions.value.map((item) => item.label),
|
memberBindingOptions.value.map((item) => item.label),
|
||||||
);
|
);
|
||||||
const isDeceased = computed(() => editForm.personStatus === "1");
|
const isDeceased = computed(() => editForm.personStatus === "1");
|
||||||
|
const datePickerValue = computed(() => editForm[datePickerField.value] || "");
|
||||||
|
const rankOptionIndex = computed(() => {
|
||||||
|
const index = rankOptions.value.findIndex((item) => item.rankId === editForm.rankId);
|
||||||
|
return index < 0 ? 0 : index + 1;
|
||||||
|
});
|
||||||
|
const rankPickerLabels = computed(() => [
|
||||||
|
"请选择排行称谓",
|
||||||
|
...rankOptions.value.map((item) => item.rankName),
|
||||||
|
]);
|
||||||
|
const selectedRankName = computed(
|
||||||
|
() =>
|
||||||
|
rankOptions.value.find((item) => item.rankId === editForm.rankId)?.rankName ||
|
||||||
|
(editForm.rankId === originalMember.value?.rankId ? originalMember.value?.rankName : "") ||
|
||||||
|
"",
|
||||||
|
);
|
||||||
|
const rankOptionStateLabel = computed(() => {
|
||||||
|
if (rankOptionsState.value === "loading") return "正在读取";
|
||||||
|
if (rankOptionsState.value === "error") return "暂时无法读取";
|
||||||
|
return selectedRankName.value || "暂无可选排行";
|
||||||
|
});
|
||||||
|
|
||||||
const formSnapshot = computed(() => JSON.stringify(editForm));
|
const formSnapshot = computed(() => JSON.stringify(editForm));
|
||||||
const hasValidContext = computed(() =>
|
const hasValidContext = computed(() =>
|
||||||
@@ -654,6 +700,7 @@ const loadMember = async () => {
|
|||||||
sex: member.sex || "",
|
sex: member.sex || "",
|
||||||
generation: member.generation || "",
|
generation: member.generation || "",
|
||||||
generationName: member.generationName || "",
|
generationName: member.generationName || "",
|
||||||
|
rankId: member.rankId || "",
|
||||||
fatherId:
|
fatherId:
|
||||||
member.relatives.find((item) => item.relation === "父亲")?.id || "",
|
member.relatives.find((item) => item.relation === "父亲")?.id || "",
|
||||||
motherId:
|
motherId:
|
||||||
@@ -696,6 +743,8 @@ const loadMember = async () => {
|
|||||||
errorMessage.value = "";
|
errorMessage.value = "";
|
||||||
failedAction.value = "load";
|
failedAction.value = "load";
|
||||||
editState.value = "form";
|
editState.value = "form";
|
||||||
|
rankOptionsContext = `${Number(editForm.generation)}:${String(editForm.sex || "")}`;
|
||||||
|
void loadRankOptions();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (!pageActive || activeLoad !== loadSequence || isRequestCancelled(error)) return;
|
if (!pageActive || activeLoad !== loadSequence || isRequestCancelled(error)) return;
|
||||||
originalMember.value = null;
|
originalMember.value = null;
|
||||||
@@ -720,7 +769,7 @@ const loadPersonOptions = async () => {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const name = item.name;
|
const name = item.name;
|
||||||
return { value: personOptionId, label: `${name}(${personOptionId})` };
|
return { value: personOptionId, label: name };
|
||||||
})
|
})
|
||||||
.filter(Boolean);
|
.filter(Boolean);
|
||||||
personOptions.value = [{ label: "不选择", value: "" }, ...options];
|
personOptions.value = [{ label: "不选择", value: "" }, ...options];
|
||||||
@@ -779,6 +828,32 @@ const loadMemberOptions = async () => {
|
|||||||
memberOptionsState.value = "error";
|
memberOptionsState.value = "error";
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
const loadRankOptions = async () => {
|
||||||
|
const generation = Number(editForm.generation);
|
||||||
|
const sex = String(editForm.sex || "");
|
||||||
|
const context = `${generation}:${sex}`;
|
||||||
|
const activeLoad = ++rankLoadSequence;
|
||||||
|
if (!Number.isSafeInteger(generation) || generation < 1 || !["0", "1", "2"].includes(sex)) {
|
||||||
|
rankOptions.value = [];
|
||||||
|
rankOptionsState.value = "idle";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (context !== rankOptionsContext) editForm.rankId = "";
|
||||||
|
rankOptionsContext = context;
|
||||||
|
rankOptionsState.value = "loading";
|
||||||
|
try {
|
||||||
|
const options = await lineageApi.getRankOptions(genealogyId.value, generation, sex, {
|
||||||
|
requestController: rankOptionsRequestController,
|
||||||
|
});
|
||||||
|
if (!pageActive || activeLoad !== rankLoadSequence) return;
|
||||||
|
rankOptions.value = options;
|
||||||
|
rankOptionsState.value = "ready";
|
||||||
|
} catch (error) {
|
||||||
|
if (!pageActive || activeLoad !== rankLoadSequence || isRequestCancelled(error)) return;
|
||||||
|
rankOptions.value = [];
|
||||||
|
rankOptionsState.value = "error";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
onLoad((query) => {
|
onLoad((query) => {
|
||||||
genealogyId.value = String(query?.genealogyId || "");
|
genealogyId.value = String(query?.genealogyId || "");
|
||||||
@@ -802,6 +877,7 @@ onUnload(() => {
|
|||||||
memberDetailRequestController.abort();
|
memberDetailRequestController.abort();
|
||||||
personOptionsRequestController.abort();
|
personOptionsRequestController.abort();
|
||||||
memberOptionsRequestController.abort();
|
memberOptionsRequestController.abort();
|
||||||
|
rankOptionsRequestController.abort();
|
||||||
avatarUploadRequestController.abort();
|
avatarUploadRequestController.abort();
|
||||||
memberUpdateRequestController.abort();
|
memberUpdateRequestController.abort();
|
||||||
sensitiveProfileRequestController.abort();
|
sensitiveProfileRequestController.abort();
|
||||||
@@ -811,10 +887,13 @@ onUnload(() => {
|
|||||||
|
|
||||||
const requestBack = () =>
|
const requestBack = () =>
|
||||||
runBackGuard({
|
runBackGuard({
|
||||||
transientOpen: discardDialogVisible.value,
|
transientOpen: datePickerVisible.value || discardDialogVisible.value,
|
||||||
dirty: isDirty.value,
|
dirty: isDirty.value,
|
||||||
submitting: isSubmitting.value || isAvatarUploading.value,
|
submitting: isSubmitting.value || isAvatarUploading.value,
|
||||||
"close-transient": cancelDiscard,
|
"close-transient": () => {
|
||||||
|
if (datePickerVisible.value) return closeDatePicker();
|
||||||
|
return cancelDiscard();
|
||||||
|
},
|
||||||
"block-submitting": () => true,
|
"block-submitting": () => true,
|
||||||
"confirm-discard": requestDiscardConfirmation,
|
"confirm-discard": requestDiscardConfirmation,
|
||||||
});
|
});
|
||||||
@@ -829,6 +908,16 @@ const optionLabel = findMemberOptionLabel;
|
|||||||
const selectOption = (field, options, event) => {
|
const selectOption = (field, options, event) => {
|
||||||
updateMemberFormOption(editForm, field, options, event);
|
updateMemberFormOption(editForm, field, options, event);
|
||||||
};
|
};
|
||||||
|
const selectSex = (event) => {
|
||||||
|
const previousSex = editForm.sex;
|
||||||
|
selectOption("sex", sexOptions, event);
|
||||||
|
if (editForm.sex !== previousSex) editForm.rankId = "";
|
||||||
|
void loadRankOptions();
|
||||||
|
};
|
||||||
|
const selectRank = (event) => {
|
||||||
|
const selectedIndex = Number(event.detail.value) - 1;
|
||||||
|
editForm.rankId = rankOptions.value[selectedIndex]?.rankId || "";
|
||||||
|
};
|
||||||
const selectBindingMode = (event) => {
|
const selectBindingMode = (event) => {
|
||||||
updateMemberBindingMode(editForm, event);
|
updateMemberBindingMode(editForm, event);
|
||||||
fieldErrors.bindingMode = "";
|
fieldErrors.bindingMode = "";
|
||||||
@@ -880,9 +969,19 @@ const uploadAvatar = async () => {
|
|||||||
if (pageActive) isAvatarUploading.value = false;
|
if (pageActive) isAvatarUploading.value = false;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
const selectDate = (field, event) => {
|
const openDatePicker = (field, title) => {
|
||||||
editForm[field] = event.detail.value || "";
|
datePickerField.value = field;
|
||||||
|
datePickerTitle.value = title;
|
||||||
|
datePickerVisible.value = true;
|
||||||
|
};
|
||||||
|
const closeDatePicker = () => {
|
||||||
|
datePickerVisible.value = false;
|
||||||
|
};
|
||||||
|
const confirmDatePicker = (value) => {
|
||||||
|
if (!datePickerField.value) return closeDatePicker();
|
||||||
|
editForm[datePickerField.value] = value;
|
||||||
fieldErrors.dates = "";
|
fieldErrors.dates = "";
|
||||||
|
closeDatePicker();
|
||||||
};
|
};
|
||||||
const validateEditForm = () => {
|
const validateEditForm = () => {
|
||||||
fieldErrors.name = editForm.name.trim() ? "" : "请填写成员姓名";
|
fieldErrors.name = editForm.name.trim() ? "" : "请填写成员姓名";
|
||||||
@@ -936,6 +1035,7 @@ const saveMember = async () => {
|
|||||||
? Number(editForm.generation)
|
? Number(editForm.generation)
|
||||||
: undefined,
|
: undefined,
|
||||||
generationName: editForm.generationName,
|
generationName: editForm.generationName,
|
||||||
|
rankId: editForm.rankId,
|
||||||
fatherId: editForm.fatherId,
|
fatherId: editForm.fatherId,
|
||||||
motherId: editForm.motherId,
|
motherId: editForm.motherId,
|
||||||
avatarOssId: editForm.avatarOssId,
|
avatarOssId: editForm.avatarOssId,
|
||||||
|
|||||||
@@ -1,346 +1,92 @@
|
|||||||
<template>
|
<template>
|
||||||
<view
|
<view class="rank-redirect-page">
|
||||||
class="rank-page"
|
|
||||||
:class="{
|
|
||||||
'rank-state--loading': rankState === 'loading',
|
|
||||||
'rank-state--form': rankState === 'form',
|
|
||||||
'rank-state--error': rankState === 'error',
|
|
||||||
}"
|
|
||||||
>
|
|
||||||
<ModulePageBackground module="tree" />
|
<ModulePageBackground module="tree" />
|
||||||
<view class="rank-page__header"
|
<PageHeader title="编辑成员" />
|
||||||
><PageHeader title="调整排行" custom-back @back="requestBack"
|
<view class="rank-redirect-panel">
|
||||||
/></view>
|
|
||||||
<view class="rank-panel">
|
|
||||||
<AppLoading
|
<AppLoading
|
||||||
v-if="rankState === 'loading'"
|
v-if="redirectState === 'loading'"
|
||||||
text="正在读取成员资料"
|
text="正在打开成员资料"
|
||||||
description="请稍候,正在确认待调整人物。"
|
description="排行称谓已合并到成员编辑页面。"
|
||||||
/>
|
/>
|
||||||
|
<view v-else class="rank-redirect-error">
|
||||||
<view v-else-if="rankState === 'form' && member" class="rank-form">
|
<text class="rank-redirect-error__title">暂时无法打开成员资料</text>
|
||||||
<text class="form-eyebrow">同辈排行</text>
|
<text class="rank-redirect-error__copy">{{ errorMessage }}</text>
|
||||||
<text class="form-title">调整{{ member.name }}的排行</text>
|
<view class="rank-redirect-error__action" @click="goBack">
|
||||||
<text class="form-copy"
|
<text>返回上一页</text>
|
||||||
>这里只调整这位成员的排行;同辈成员的整体排序暂不能在这里修改。</text
|
|
||||||
>
|
|
||||||
|
|
||||||
<view class="member-context">
|
|
||||||
<text>当前成员</text
|
|
||||||
><text>第 {{ member.generation }} 世 · {{ member.branch }}</text>
|
|
||||||
</view>
|
|
||||||
<view class="rank-field">
|
|
||||||
<text>排序值</text>
|
|
||||||
<input
|
|
||||||
v-model="sortOrder"
|
|
||||||
type="number"
|
|
||||||
:disabled="savingRank"
|
|
||||||
placeholder="请输入整数,值越小越靠前"
|
|
||||||
placeholder-class="rank-placeholder"
|
|
||||||
@input="rankError = ''"
|
|
||||||
/>
|
|
||||||
</view>
|
|
||||||
<text v-if="rankError" class="rank-error">{{ rankError }}</text>
|
|
||||||
<view
|
|
||||||
class="form-action"
|
|
||||||
:class="{ 'form-action--disabled': savingRank }"
|
|
||||||
@click="saveRank"
|
|
||||||
>
|
|
||||||
<image
|
|
||||||
src="/static/assets/foundation/transparent/scroll-primary.png"
|
|
||||||
mode="scaleToFill"
|
|
||||||
/>
|
|
||||||
<text>{{ savingRank ? "正在保存…" : "保存排行" }}</text>
|
|
||||||
</view>
|
|
||||||
</view>
|
|
||||||
|
|
||||||
<view v-else class="rank-result">
|
|
||||||
<text class="form-eyebrow">暂时无法打开成员页面</text>
|
|
||||||
<text class="form-title">暂时无法读取成员资料</text>
|
|
||||||
<text class="form-copy">{{ errorMessage }}</text>
|
|
||||||
<view class="form-action" @click="goBack">
|
|
||||||
<image
|
|
||||||
src="/static/assets/foundation/transparent/scroll-primary.png"
|
|
||||||
mode="scaleToFill"
|
|
||||||
/>
|
|
||||||
<text>返回世系树</text>
|
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
<AppDialog
|
|
||||||
:visible="discardVisible"
|
|
||||||
eyebrow="未保存修改"
|
|
||||||
title="放弃排行修改?"
|
|
||||||
message="当前排序值还没有保存。"
|
|
||||||
confirm-text="确认放弃"
|
|
||||||
cancel-text="继续编辑"
|
|
||||||
show-cancel
|
|
||||||
:close-on-mask="false"
|
|
||||||
@confirm="confirmDiscard"
|
|
||||||
@cancel="cancelDiscard"
|
|
||||||
/>
|
|
||||||
</view>
|
</view>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { computed, ref } from "vue";
|
import { ref } from "vue";
|
||||||
import { onBackPress, onLoad, onUnload } from "@dcloudio/uni-app";
|
import { onLoad } from "@dcloudio/uni-app";
|
||||||
import AppDialog from "@/components/AppDialog.vue";
|
|
||||||
import AppLoading from "@/components/AppLoading.vue";
|
import AppLoading from "@/components/AppLoading.vue";
|
||||||
import ModulePageBackground from "@/components/ModulePageBackground.vue";
|
import ModulePageBackground from "@/components/ModulePageBackground.vue";
|
||||||
import PageHeader from "@/components/PageHeader.vue";
|
import PageHeader from "@/components/PageHeader.vue";
|
||||||
import {
|
import { goBack, returnTo } from "@/utils/navigation/gateway.js";
|
||||||
createRequestController,
|
|
||||||
isRequestCancelled
|
|
||||||
} from "@/services/api/request-controller.js";
|
|
||||||
import { lineageApi } from "@/services/api/lineage-service.js";
|
|
||||||
import { getRequestErrorMessage } from "@/services/api/request-error-message.js";
|
|
||||||
import { createDiscardConfirmation } from "@/utils/discard-confirmation.js";
|
|
||||||
import { goBack, handleBackPress, returnTo, runBackGuard } from "@/utils/navigation/gateway.js";
|
|
||||||
|
|
||||||
const rankState = ref("loading");
|
const redirectState = ref("loading");
|
||||||
const genealogyId = ref("");
|
|
||||||
const personId = ref("");
|
|
||||||
const member = ref(null);
|
|
||||||
const errorMessage = ref("");
|
const errorMessage = ref("");
|
||||||
const sortOrder = ref("");
|
|
||||||
const savingRank = ref(false);
|
|
||||||
const rankError = ref("");
|
|
||||||
const committedSortOrder = ref("");
|
|
||||||
const sortOrderBaseline = ref("");
|
|
||||||
const discardVisible = ref(false);
|
|
||||||
const memberRankReadRequestController = createRequestController();
|
|
||||||
const memberRankSaveRequestController = createRequestController();
|
|
||||||
let pageActive = true;
|
|
||||||
let loadSequence = 0;
|
|
||||||
const discardConfirmation = createDiscardConfirmation((visible) => {
|
|
||||||
discardVisible.value = visible;
|
|
||||||
});
|
|
||||||
const requestDiscardConfirmation = discardConfirmation.request;
|
|
||||||
const confirmDiscard = discardConfirmation.confirm;
|
|
||||||
const cancelDiscard = discardConfirmation.cancel;
|
|
||||||
const isDirty = computed(() =>
|
|
||||||
rankState.value === "form" && sortOrder.value !== sortOrderBaseline.value,
|
|
||||||
);
|
|
||||||
|
|
||||||
const loadMember = async () => {
|
onLoad(async (query) => {
|
||||||
const activeLoad = ++loadSequence;
|
const genealogyId = String(query?.genealogyId || "");
|
||||||
rankState.value = "loading";
|
const personId = String(query?.personId || "");
|
||||||
|
if (!/^[1-9]\d*$/.test(genealogyId) || !/^[1-9]\d*$/.test(personId)) {
|
||||||
|
redirectState.value = "error";
|
||||||
|
errorMessage.value = "页面参数已经失效,请从成员资料重新进入。";
|
||||||
|
return;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
const personDetail = await lineageApi.getPerson(genealogyId.value, personId.value, {
|
await returnTo("T05", { genealogyId, personId });
|
||||||
requestController: memberRankReadRequestController,
|
|
||||||
});
|
|
||||||
if (!pageActive || activeLoad !== loadSequence) return;
|
|
||||||
member.value = personDetail;
|
|
||||||
sortOrder.value = personDetail.sortOrder === null || personDetail.sortOrder === undefined
|
|
||||||
? ""
|
|
||||||
: String(personDetail.sortOrder);
|
|
||||||
sortOrderBaseline.value = sortOrder.value;
|
|
||||||
committedSortOrder.value = "";
|
|
||||||
errorMessage.value = "";
|
|
||||||
rankState.value = "form";
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (!pageActive || activeLoad !== loadSequence || isRequestCancelled(error)) return;
|
redirectState.value = "error";
|
||||||
member.value = null;
|
errorMessage.value = error?.message || "请返回后重新打开成员资料。";
|
||||||
errorMessage.value =
|
|
||||||
getRequestErrorMessage(error, "当前成员资料暂不可用,请返回世系树后重试。");
|
|
||||||
rankState.value = "error";
|
|
||||||
}
|
}
|
||||||
};
|
|
||||||
|
|
||||||
const saveRank = async () => {
|
|
||||||
if (savingRank.value || !member.value) return;
|
|
||||||
const normalizedSortOrder = String(sortOrder.value).trim();
|
|
||||||
if (!/^-?\d+$/.test(normalizedSortOrder)) {
|
|
||||||
rankError.value = "请填写整数,例如 1、2、3。";
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const numericSortOrder = Number(normalizedSortOrder);
|
|
||||||
if (!Number.isSafeInteger(numericSortOrder)) {
|
|
||||||
rankError.value = "排序值超出可保存范围";
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
savingRank.value = true;
|
|
||||||
rankError.value = "";
|
|
||||||
try {
|
|
||||||
if (committedSortOrder.value !== normalizedSortOrder) {
|
|
||||||
await lineageApi.updatePersonSortOrder(
|
|
||||||
genealogyId.value,
|
|
||||||
personId.value,
|
|
||||||
numericSortOrder,
|
|
||||||
{ requestController: memberRankSaveRequestController },
|
|
||||||
);
|
|
||||||
if (!pageActive) return;
|
|
||||||
committedSortOrder.value = normalizedSortOrder;
|
|
||||||
sortOrderBaseline.value = normalizedSortOrder;
|
|
||||||
}
|
|
||||||
await returnTo("T01", {
|
|
||||||
genealogyId: genealogyId.value,
|
|
||||||
selectedId: personId.value,
|
|
||||||
});
|
});
|
||||||
} catch (error) {
|
|
||||||
if (pageActive && !isRequestCancelled(error)) {
|
|
||||||
rankError.value = committedSortOrder.value === normalizedSortOrder
|
|
||||||
? "排行已经保存,但页面返回失败。请再次点击保存重试返回。"
|
|
||||||
: getRequestErrorMessage(error, "排行尚未保存,请稍后重试。");
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
if (pageActive) savingRank.value = false;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const requestBack = () => runBackGuard({
|
|
||||||
transientOpen: discardVisible.value,
|
|
||||||
dirty: isDirty.value,
|
|
||||||
submitting: savingRank.value,
|
|
||||||
"close-transient": cancelDiscard,
|
|
||||||
"block-submitting": () => true,
|
|
||||||
"confirm-discard": requestDiscardConfirmation,
|
|
||||||
});
|
|
||||||
|
|
||||||
onLoad((query) => {
|
|
||||||
genealogyId.value = String(query.genealogyId || "");
|
|
||||||
personId.value = String(query.personId || "");
|
|
||||||
if (
|
|
||||||
!/^[1-9]\d*$/.test(genealogyId.value) ||
|
|
||||||
!/^[1-9]\d*$/.test(personId.value) ||
|
|
||||||
query.mode !== "rank"
|
|
||||||
) {
|
|
||||||
rankState.value = "error";
|
|
||||||
errorMessage.value = "请从成员资料页重新进入排行调整。";
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
void loadMember();
|
|
||||||
});
|
|
||||||
|
|
||||||
onUnload(() => {
|
|
||||||
pageActive = false;
|
|
||||||
loadSequence += 1;
|
|
||||||
memberRankReadRequestController.abort();
|
|
||||||
memberRankSaveRequestController.abort();
|
|
||||||
discardConfirmation.dispose();
|
|
||||||
});
|
|
||||||
|
|
||||||
onBackPress((event) => handleBackPress(event, requestBack));
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped lang="scss">
|
<style scoped lang="scss">
|
||||||
@import "../../styles/adaptive-frame-profiles.scss";
|
@import "../../styles/adaptive-frame-profiles.scss";
|
||||||
|
|
||||||
.rank-page {
|
.rank-redirect-page {
|
||||||
display: flex;
|
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
flex-direction: column;
|
|
||||||
background: $paper;
|
background: $paper;
|
||||||
}
|
}
|
||||||
.rank-page__header {
|
|
||||||
z-index: 3;
|
.rank-redirect-panel {
|
||||||
}
|
position: relative;
|
||||||
.rank-panel {
|
|
||||||
@include adaptive-tree-panel;
|
|
||||||
z-index: 2;
|
|
||||||
width: calc(100% - 32rpx);
|
|
||||||
margin: 18rpx auto calc(28rpx + env(safe-area-inset-bottom));
|
|
||||||
padding: 7.5% 8%;
|
|
||||||
}
|
|
||||||
.form-eyebrow {
|
|
||||||
display: block;
|
|
||||||
color: $brand-red;
|
|
||||||
font-size: clamp(14px, 22rpx, 17px);
|
|
||||||
letter-spacing: 3rpx;
|
|
||||||
}
|
|
||||||
.form-title {
|
|
||||||
display: block;
|
|
||||||
margin-top: 10rpx;
|
|
||||||
color: $ink;
|
|
||||||
font-family: "STKaiti", "KaiTi", serif;
|
|
||||||
font-size: clamp(19px, 34rpx, 24px);
|
|
||||||
font-weight: 700;
|
|
||||||
line-height: 1.35;
|
|
||||||
}
|
|
||||||
.form-copy {
|
|
||||||
display: block;
|
|
||||||
margin-top: 10rpx;
|
|
||||||
color: $ink-muted;
|
|
||||||
font-size: clamp(15px, 24rpx, 18px);
|
|
||||||
line-height: 1.5;
|
|
||||||
}
|
|
||||||
.member-context,
|
|
||||||
.rank-field {
|
|
||||||
@include adaptive-tree-field;
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: auto minmax(0, 1fr);
|
|
||||||
min-height: 78rpx;
|
|
||||||
align-items: center;
|
|
||||||
gap: 20rpx;
|
|
||||||
margin-top: 16rpx;
|
|
||||||
padding: 12rpx 22rpx;
|
|
||||||
}
|
|
||||||
.member-context text:first-child,
|
|
||||||
.rank-field text {
|
|
||||||
color: $ink;
|
|
||||||
font-size: clamp(15px, 24rpx, 18px);
|
|
||||||
font-weight: 700;
|
|
||||||
}
|
|
||||||
.member-context text:last-child {
|
|
||||||
min-width: 0;
|
|
||||||
color: $ink-muted;
|
|
||||||
font-size: clamp(14px, 23rpx, 17px);
|
|
||||||
line-height: 1.45;
|
|
||||||
text-align: right;
|
|
||||||
}
|
|
||||||
.rank-field input {
|
|
||||||
min-width: 0;
|
|
||||||
min-height: var(--app-touch-min);
|
|
||||||
padding: 0 14rpx;
|
|
||||||
border: 1rpx solid rgba(143, 108, 63, 0.34);
|
|
||||||
border-radius: 8rpx;
|
|
||||||
background: rgba(255, 253, 247, 0.8);
|
|
||||||
color: $ink;
|
|
||||||
font-size: clamp(14px, 23rpx, 17px);
|
|
||||||
text-align: right;
|
|
||||||
}
|
|
||||||
.rank-placeholder { color: $ink-muted; }
|
|
||||||
.rank-error { display: block; margin-top: 12rpx; color: $brand-red; font-size: clamp(14px, 22rpx, 17px); }
|
|
||||||
.form-action {
|
|
||||||
display: grid;
|
|
||||||
width: 100%;
|
|
||||||
min-height: var(--app-touch-min);
|
|
||||||
margin-top: 22rpx;
|
|
||||||
}
|
|
||||||
.form-action--disabled { opacity: 0.58; pointer-events: none; }
|
|
||||||
.form-action image,
|
|
||||||
.form-action text {
|
|
||||||
grid-area: 1 / 1;
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
}
|
|
||||||
.form-action text {
|
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
|
padding: 48rpx 32rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rank-redirect-error {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
flex-direction: column;
|
||||||
color: #fff9ed;
|
gap: 24rpx;
|
||||||
font-size: clamp(15px, 24rpx, 18px);
|
padding: 64rpx 32rpx;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rank-redirect-error__title {
|
||||||
|
color: $ink;
|
||||||
|
font-size: 36rpx;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
}
|
}
|
||||||
.rank-result {
|
|
||||||
margin-top: 30%;
|
.rank-redirect-error__copy {
|
||||||
text-align: center;
|
color: $ink-muted;
|
||||||
}
|
font-size: 28rpx;
|
||||||
.rank-result .form-eyebrow,
|
line-height: 1.7;
|
||||||
.rank-result .form-copy {
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
.rank-result .form-action {
|
|
||||||
width: 420rpx;
|
|
||||||
max-width: 100%;
|
|
||||||
margin-right: auto;
|
|
||||||
margin-left: auto;
|
|
||||||
}
|
|
||||||
@media (min-width: 400px) {
|
|
||||||
.rank-panel {
|
|
||||||
width: calc(100% - 48rpx);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.rank-redirect-error__action {
|
||||||
|
min-width: 240rpx;
|
||||||
|
padding: 22rpx 36rpx;
|
||||||
|
border: 1rpx solid rgba($brand-red, 0.4);
|
||||||
|
color: $brand-red;
|
||||||
|
font-size: 30rpx;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -29,7 +29,6 @@
|
|||||||
>回到当前</text
|
>回到当前</text
|
||||||
>
|
>
|
||||||
<text @click="treeState = 'landscape'">阅读提示</text>
|
<text @click="treeState = 'landscape'">阅读提示</text>
|
||||||
<text @click="toRank">调整排行</text>
|
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
@@ -105,19 +104,14 @@
|
|||||||
>
|
>
|
||||||
<view class="member-node__surface">
|
<view class="member-node__surface">
|
||||||
<view class="member-node__portrait">
|
<view class="member-node__portrait">
|
||||||
<AppAvatar :sex="member.sex" />
|
<AppAvatar
|
||||||
</view>
|
:sex="member.sex"
|
||||||
<image
|
:src="member.avatarFile?.accessUrl || ''"
|
||||||
class="member-node__divider"
|
|
||||||
src="/static/assets/foundation/transparent/auth-divider-knot.png"
|
|
||||||
mode="aspectFit"
|
|
||||||
/>
|
/>
|
||||||
|
<text class="node-relation">{{ member.relation }}</text>
|
||||||
|
</view>
|
||||||
<view class="member-node__copy">
|
<view class="member-node__copy">
|
||||||
<text class="node-name">{{ member.name }}</text>
|
<text class="node-name">{{ member.name }}</text>
|
||||||
<text class="node-relation">{{
|
|
||||||
nodeRelationText(member)
|
|
||||||
}}</text>
|
|
||||||
<text class="node-years">{{ member.treeYears || member.years }}</text>
|
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
@@ -243,13 +237,6 @@ const memberActions = Object.freeze([
|
|||||||
routeKey: "T04",
|
routeKey: "T04",
|
||||||
relationType: memberRelationTypes.SIBLING,
|
relationType: memberRelationTypes.SIBLING,
|
||||||
},
|
},
|
||||||
{
|
|
||||||
key: "ADJUST_RANK",
|
|
||||||
label: "调整排行",
|
|
||||||
group: "MANAGEMENT",
|
|
||||||
routeKey: "T06",
|
|
||||||
mode: "rank",
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
key: "ADD_SON",
|
key: "ADD_SON",
|
||||||
label: "添加儿子",
|
label: "添加儿子",
|
||||||
@@ -282,10 +269,6 @@ const managementActions = computed(() =>
|
|||||||
memberActions.filter((action) => action.group === "MANAGEMENT"),
|
memberActions.filter((action) => action.group === "MANAGEMENT"),
|
||||||
);
|
);
|
||||||
|
|
||||||
const nodeRelationText = (member) => {
|
|
||||||
const branch = String(member.branch || "").replace(/字辈$/, "");
|
|
||||||
return [member.relation, branch].filter(Boolean).join(" · ");
|
|
||||||
};
|
|
||||||
const treeLayout = computed(() => createTreeLayout(members.value));
|
const treeLayout = computed(() => createTreeLayout(members.value));
|
||||||
const layoutMembers = computed(() => treeLayout.value.members);
|
const layoutMembers = computed(() => treeLayout.value.members);
|
||||||
const treeMetrics = computed(() => treeLayout.value.metrics);
|
const treeMetrics = computed(() => treeLayout.value.metrics);
|
||||||
@@ -493,18 +476,6 @@ const toDirectory = () =>
|
|||||||
openPage("T07", { genealogyId: genealogyId.value }, "T01");
|
openPage("T07", { genealogyId: genealogyId.value }, "T01");
|
||||||
const toPedigree = () =>
|
const toPedigree = () =>
|
||||||
openPage("T02", { genealogyId: genealogyId.value }, "T01");
|
openPage("T02", { genealogyId: genealogyId.value }, "T01");
|
||||||
const toRank = () =>
|
|
||||||
selectedMember.value
|
|
||||||
? openPage(
|
|
||||||
"T06",
|
|
||||||
{
|
|
||||||
genealogyId: genealogyId.value,
|
|
||||||
personId: String(selectedMember.value.id),
|
|
||||||
mode: "rank",
|
|
||||||
},
|
|
||||||
"T01",
|
|
||||||
)
|
|
||||||
: Promise.resolve(false);
|
|
||||||
const openMemberPanel = (member) => {
|
const openMemberPanel = (member) => {
|
||||||
selectedMember.value = member;
|
selectedMember.value = member;
|
||||||
memberActionPanelVisible.value = true;
|
memberActionPanelVisible.value = true;
|
||||||
@@ -521,8 +492,6 @@ const openMemberAction = (action) => {
|
|||||||
};
|
};
|
||||||
if (action.routeKey === "T04") {
|
if (action.routeKey === "T04") {
|
||||||
params.relationType = action.relationType;
|
params.relationType = action.relationType;
|
||||||
} else if (action.routeKey === "T06") {
|
|
||||||
params.mode = action.mode;
|
|
||||||
}
|
}
|
||||||
return openPage(action.routeKey, params, "T01");
|
return openPage(action.routeKey, params, "T01");
|
||||||
};
|
};
|
||||||
@@ -584,7 +553,7 @@ const openMemberAction = (action) => {
|
|||||||
}
|
}
|
||||||
.tree-stage--lineage {
|
.tree-stage--lineage {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 72rpx minmax(0, 1fr);
|
grid-template-columns: 112rpx minmax(0, 1fr);
|
||||||
align-items: start;
|
align-items: start;
|
||||||
}
|
}
|
||||||
.tree-scroll {
|
.tree-scroll {
|
||||||
@@ -595,7 +564,7 @@ const openMemberAction = (action) => {
|
|||||||
.generation-rail {
|
.generation-rail {
|
||||||
display: grid;
|
display: grid;
|
||||||
z-index: 4;
|
z-index: 4;
|
||||||
width: 72rpx;
|
width: 112rpx;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
border-right: 1rpx solid rgba(143, 108, 63, 0.2);
|
border-right: 1rpx solid rgba(143, 108, 63, 0.2);
|
||||||
@@ -627,7 +596,7 @@ const openMemberAction = (action) => {
|
|||||||
display: grid;
|
display: grid;
|
||||||
justify-self: center;
|
justify-self: center;
|
||||||
z-index: 3;
|
z-index: 3;
|
||||||
width: 62rpx;
|
width: 100rpx;
|
||||||
height: 104rpx;
|
height: 104rpx;
|
||||||
color: #8f6c3f;
|
color: #8f6c3f;
|
||||||
font-size: clamp(12px, 18rpx, 14px);
|
font-size: clamp(12px, 18rpx, 14px);
|
||||||
@@ -647,17 +616,17 @@ const openMemberAction = (action) => {
|
|||||||
.generation-band__copy {
|
.generation-band__copy {
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: row;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: center;
|
||||||
gap: 0;
|
gap: 4rpx;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
padding: 0 4rpx;
|
padding: 8rpx 4rpx;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
|
background: rgba(255, 252, 244, 0.9);
|
||||||
}
|
}
|
||||||
.generation-band__copy text {
|
.generation-band__copy text {
|
||||||
writing-mode: vertical-rl;
|
line-height: 1.35;
|
||||||
text-orientation: upright;
|
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
.generation-band__copy text:last-child {
|
.generation-band__copy text:last-child {
|
||||||
@@ -677,8 +646,8 @@ const openMemberAction = (action) => {
|
|||||||
align-self: start;
|
align-self: start;
|
||||||
justify-self: start;
|
justify-self: start;
|
||||||
z-index: 2;
|
z-index: 2;
|
||||||
width: 160rpx;
|
width: 154rpx;
|
||||||
height: 224rpx;
|
height: 200rpx;
|
||||||
transform: translate(-50%, -50%);
|
transform: translate(-50%, -50%);
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
@@ -689,85 +658,81 @@ const openMemberAction = (action) => {
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
padding: 9rpx 8rpx 8rpx;
|
padding: 10rpx;
|
||||||
border: 1rpx solid #b78a42;
|
background: url("/static/assets/modules/tree/transparent/member-node-portrait.png") center / 100% 100% no-repeat;
|
||||||
border-radius: 14rpx;
|
|
||||||
background: rgba(255, 252, 244, 0.96);
|
|
||||||
box-shadow: 0 5rpx 12rpx rgba(103, 72, 33, 0.12);
|
|
||||||
}
|
}
|
||||||
.member-node__portrait {
|
.member-node__portrait {
|
||||||
|
position: relative;
|
||||||
display: flex;
|
display: flex;
|
||||||
width: 62rpx;
|
width: 134rpx;
|
||||||
height: 62rpx;
|
height: 126rpx;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
border: 1rpx solid #c49a57;
|
border: 1rpx solid #c49a57;
|
||||||
border-radius: 50%;
|
border-radius: 2rpx;
|
||||||
background: #fffdf7;
|
background: #fffdf7;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
.member-node__divider {
|
|
||||||
width: 56rpx;
|
|
||||||
height: 14rpx;
|
|
||||||
margin-top: 3rpx;
|
|
||||||
}
|
|
||||||
.member-node__copy {
|
.member-node__copy {
|
||||||
|
display: flex;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
margin-top: 1rpx;
|
min-height: 52rpx;
|
||||||
|
margin-top: 4rpx;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
}
|
}
|
||||||
.node-name,
|
.node-name,
|
||||||
.node-relation,
|
.node-relation {
|
||||||
.node-years {
|
|
||||||
display: block;
|
display: block;
|
||||||
}
|
}
|
||||||
.node-name {
|
.node-name {
|
||||||
display: -webkit-box;
|
display: -webkit-box;
|
||||||
color: #6b2419;
|
|
||||||
font-family: "STKaiti", "KaiTi", serif;
|
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
font-size: clamp(15px, 24rpx, 18px);
|
color: #d96529;
|
||||||
font-weight: 700;
|
font-family: "STKaiti", "KaiTi", serif;
|
||||||
line-height: 1.1;
|
font-size: clamp(14px, 24rpx, 18px);
|
||||||
|
font-weight: 500;
|
||||||
|
line-height: 1.15;
|
||||||
|
word-break: break-all;
|
||||||
|
white-space: normal;
|
||||||
-webkit-box-orient: vertical;
|
-webkit-box-orient: vertical;
|
||||||
-webkit-line-clamp: 2;
|
-webkit-line-clamp: 2;
|
||||||
white-space: normal;
|
|
||||||
}
|
}
|
||||||
.node-relation {
|
.node-relation {
|
||||||
|
position: absolute;
|
||||||
|
right: 50%;
|
||||||
|
bottom: 5rpx;
|
||||||
|
z-index: 1;
|
||||||
|
min-width: 54rpx;
|
||||||
|
max-width: 116rpx;
|
||||||
|
box-sizing: border-box;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
margin-top: 2rpx;
|
padding: 1rpx 6rpx;
|
||||||
color: #644b2e;
|
transform: translateX(50%);
|
||||||
font-size: clamp(12px, 18rpx, 14px);
|
border: 1rpx solid #e66d24;
|
||||||
line-height: 1.2;
|
background: rgba(255, 255, 255, 0.96);
|
||||||
text-overflow: ellipsis;
|
color: #e66d24;
|
||||||
white-space: nowrap;
|
font-size: clamp(12px, 22rpx, 15px);
|
||||||
}
|
line-height: 1.35;
|
||||||
.node-years {
|
|
||||||
overflow: hidden;
|
|
||||||
margin-top: 2rpx;
|
|
||||||
color: #695b4c;
|
|
||||||
font-size: clamp(12px, 17rpx, 14px);
|
|
||||||
line-height: 1.2;
|
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
.member-node--spouse .member-node__surface {
|
.member-node--spouse .member-node__surface {
|
||||||
border-color: #c39a61;
|
background-image: url("/static/assets/modules/tree/transparent/member-node-portrait.png");
|
||||||
background: rgba(255, 250, 241, 0.98);
|
|
||||||
}
|
}
|
||||||
.member-node--selected {
|
.member-node--selected {
|
||||||
z-index: 3;
|
z-index: 3;
|
||||||
}
|
}
|
||||||
.member-node--selected .member-node__surface {
|
.member-node--selected .member-node__surface {
|
||||||
border-color: #c55344;
|
filter: drop-shadow(0 4rpx 5rpx rgba(159, 23, 15, 0.2));
|
||||||
box-shadow: 0 6rpx 14rpx rgba(159, 23, 15, 0.16);
|
|
||||||
}
|
}
|
||||||
.member-node--selected .member-node__portrait {
|
.member-node--selected .member-node__portrait {
|
||||||
border-color: #c55344;
|
border-color: #c55344;
|
||||||
}
|
}
|
||||||
.member-node--selected .node-name {
|
.member-node--selected .node-name {
|
||||||
color: $brand-red;
|
color: #d96529;
|
||||||
}
|
}
|
||||||
.tree-state-card {
|
.tree-state-card {
|
||||||
display: grid;
|
display: grid;
|
||||||
|
|||||||
@@ -31,7 +31,7 @@
|
|||||||
:show-scrollbar="false"
|
:show-scrollbar="false"
|
||||||
:style="pedigreeScrollStyle"
|
:style="pedigreeScrollStyle"
|
||||||
>
|
>
|
||||||
<view class="pedigree-sheet" :style="pedigreeSheetStyle">
|
<view class="pedigree-sheet" :style="getPedigreeSheetStyle(page)">
|
||||||
<view class="pedigree-column pedigree-column--legend">
|
<view class="pedigree-column pedigree-column--legend">
|
||||||
<view class="pedigree-column__relation"><text>身份</text></view>
|
<view class="pedigree-column__relation"><text>身份</text></view>
|
||||||
<view class="pedigree-column__name"><text>{{ page.label }}</text></view>
|
<view class="pedigree-column__name"><text>{{ page.label }}</text></view>
|
||||||
@@ -40,15 +40,14 @@
|
|||||||
|
|
||||||
<view
|
<view
|
||||||
v-for="(member, memberIndex) in page.members"
|
v-for="(member, memberIndex) in page.members"
|
||||||
:key="member?.id || `empty-${pageIndex}-${memberIndex}`"
|
:key="member.id || `member-${pageIndex}-${memberIndex}`"
|
||||||
class="member-node"
|
class="member-node"
|
||||||
:class="{
|
:class="{
|
||||||
'member-node--blank': !member,
|
'member-node--selected': selectedId === String(member.id),
|
||||||
'member-node--selected': selectedId === String(member?.id || ''),
|
'member-node--spouse': Boolean(member.spouseOf),
|
||||||
'member-node--spouse': Boolean(member?.spouseOf),
|
|
||||||
}"
|
}"
|
||||||
>
|
>
|
||||||
<view v-if="member" class="member-node__surface">
|
<view class="member-node__surface">
|
||||||
<view class="member-node__relation"><text>{{ memberRelation(member) }}</text></view>
|
<view class="member-node__relation"><text>{{ memberRelation(member) }}</text></view>
|
||||||
<view
|
<view
|
||||||
class="member-node__name"
|
class="member-node__name"
|
||||||
@@ -65,22 +64,16 @@
|
|||||||
:aria-label="`查看${member.name}的生平详情`"
|
:aria-label="`查看${member.name}的生平详情`"
|
||||||
@click.stop="openMemberDetail(member)"
|
@click.stop="openMemberDetail(member)"
|
||||||
>
|
>
|
||||||
<text class="node-relation">{{ member.branch }}</text>
|
<text>{{ memberDetailText(member) }}</text>
|
||||||
<text class="node-years">{{ memberDetail(member) }}</text>
|
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
<view v-else class="member-node__surface">
|
|
||||||
<view class="member-node__relation"></view>
|
|
||||||
<view class="member-node__name"></view>
|
|
||||||
<view class="member-node__copy"></view>
|
|
||||||
</view>
|
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</scroll-view>
|
</scroll-view>
|
||||||
</swiper-item>
|
</swiper-item>
|
||||||
</swiper>
|
</swiper>
|
||||||
|
|
||||||
<view class="generation-rail" :style="pedigreeSheetStyle">
|
<view class="generation-rail" :style="pedigreeHeightStyle">
|
||||||
<view class="generation-band">
|
<view class="generation-band">
|
||||||
<image
|
<image
|
||||||
class="generation-band__arrow generation-band__arrow--previous"
|
class="generation-band__arrow generation-band__arrow--previous"
|
||||||
@@ -152,7 +145,7 @@ import { getRequestErrorMessage } from "@/services/api/request-error-message.js"
|
|||||||
import { genealogyContext } from "@/utils/genealogy/context.js";
|
import { genealogyContext } from "@/utils/genealogy/context.js";
|
||||||
import { handleBackPress, openPage, returnTo } from "@/utils/navigation/gateway.js";
|
import { handleBackPress, openPage, returnTo } from "@/utils/navigation/gateway.js";
|
||||||
|
|
||||||
const PEDIGREE_PAGE_SIZE = 5;
|
const PEDIGREE_PAGE_SIZE = 3;
|
||||||
const genealogyId = ref("");
|
const genealogyId = ref("");
|
||||||
const treeState = ref("loading");
|
const treeState = ref("loading");
|
||||||
const treeLoadError = ref("");
|
const treeLoadError = ref("");
|
||||||
@@ -198,7 +191,6 @@ const pedigreePages = computed(() => {
|
|||||||
const branch = row.members.find((member) => member.branch)?.branch;
|
const branch = row.members.find((member) => member.branch)?.branch;
|
||||||
for (let index = 0; index < row.members.length; index += PEDIGREE_PAGE_SIZE) {
|
for (let index = 0; index < row.members.length; index += PEDIGREE_PAGE_SIZE) {
|
||||||
const pageMembers = row.members.slice(index, index + PEDIGREE_PAGE_SIZE);
|
const pageMembers = row.members.slice(index, index + PEDIGREE_PAGE_SIZE);
|
||||||
while (pageMembers.length < PEDIGREE_PAGE_SIZE) pageMembers.push(null);
|
|
||||||
pages.push({
|
pages.push({
|
||||||
generation: row.generation,
|
generation: row.generation,
|
||||||
label: row.label,
|
label: row.label,
|
||||||
@@ -219,14 +211,18 @@ const detailLength = computed(() =>
|
|||||||
Math.max(
|
Math.max(
|
||||||
0,
|
0,
|
||||||
...members.value.map((member) =>
|
...members.value.map((member) =>
|
||||||
Array.from(`${member.branch || ""}${memberDetail(member)}`).length,
|
Array.from(memberDetailText(member)).length,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
const sheetHeight = computed(() => Math.max(1100, 720 + detailLength.value * 20));
|
const sheetHeight = computed(() => Math.max(1100, 720 + detailLength.value * 20));
|
||||||
const pedigreeSheetStyle = computed(() => ({
|
const pedigreeHeightStyle = computed(() => ({
|
||||||
height: `${sheetHeight.value}rpx`,
|
height: `${sheetHeight.value}rpx`,
|
||||||
}));
|
}));
|
||||||
|
const getPedigreeSheetStyle = (page) => ({
|
||||||
|
...pedigreeHeightStyle.value,
|
||||||
|
gridTemplateColumns: `104rpx repeat(${page.members.length}, minmax(0, 1fr))`,
|
||||||
|
});
|
||||||
const pedigreeScrollStyle = computed(() => ({
|
const pedigreeScrollStyle = computed(() => ({
|
||||||
height: `${sheetHeight.value}rpx`,
|
height: `${sheetHeight.value}rpx`,
|
||||||
}));
|
}));
|
||||||
@@ -254,6 +250,8 @@ const handlePedigreePageChange = (event) => {
|
|||||||
const memberRelation = (member) =>
|
const memberRelation = (member) =>
|
||||||
member.spouseOf ? "配偶" : member.relation || "家谱成员";
|
member.spouseOf ? "配偶" : member.relation || "家谱成员";
|
||||||
const memberDetail = (member) => member.treeYears || member.years || "生卒待补";
|
const memberDetail = (member) => member.treeYears || member.years || "生卒待补";
|
||||||
|
const memberDetailText = (member) =>
|
||||||
|
[member.branch, memberDetail(member)].filter(Boolean).join("·");
|
||||||
const openMemberProfile = (member) =>
|
const openMemberProfile = (member) =>
|
||||||
openPage(
|
openPage(
|
||||||
"T03",
|
"T03",
|
||||||
@@ -463,9 +461,6 @@ onUnload(() => {
|
|||||||
.member-node__surface {
|
.member-node__surface {
|
||||||
display: contents;
|
display: contents;
|
||||||
}
|
}
|
||||||
.member-node--blank {
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
.member-node--selected .member-node__relation,
|
.member-node--selected .member-node__relation,
|
||||||
.member-node--selected .member-node__name,
|
.member-node--selected .member-node__name,
|
||||||
.member-node--selected .member-node__copy {
|
.member-node--selected .member-node__copy {
|
||||||
@@ -477,13 +472,6 @@ onUnload(() => {
|
|||||||
.member-node--spouse .member-node__relation {
|
.member-node--spouse .member-node__relation {
|
||||||
color: #7a4d43;
|
color: #7a4d43;
|
||||||
}
|
}
|
||||||
.node-relation {
|
|
||||||
color: #675f57;
|
|
||||||
}
|
|
||||||
.node-years {
|
|
||||||
color: #3f3934;
|
|
||||||
text-combine-upright: all;
|
|
||||||
}
|
|
||||||
.generation-rail {
|
.generation-rail {
|
||||||
width: 92rpx;
|
width: 92rpx;
|
||||||
border-left: 1rpx solid #e2e2e2;
|
border-left: 1rpx solid #e2e2e2;
|
||||||
|
|||||||
@@ -16,9 +16,17 @@ const listPageSources = (directory = 'pages') => fs.readdirSync(path.join(worksp
|
|||||||
})
|
})
|
||||||
|
|
||||||
const signIn = read('pages/auth/sign-in.vue')
|
const signIn = read('pages/auth/sign-in.vue')
|
||||||
|
const loginAdvertisement = read('components/auth/LoginAdvertisement.vue')
|
||||||
const register = read('pages/auth/register.vue')
|
const register = read('pages/auth/register.vue')
|
||||||
const routes = read('utils/navigation/routes.js')
|
const routes = read('utils/navigation/routes.js')
|
||||||
const genealogyHome = read('pages/genealogy/my-genealogies.vue')
|
const genealogyHome = read('pages/genealogy/my-genealogies.vue')
|
||||||
|
const profileHomePage = read('pages/profile/home.vue')
|
||||||
|
const homeAdvertisementPanel = read('components/genealogy/HomeAdvertisementPanel.vue')
|
||||||
|
const genealogyOverview = read('pages/genealogy/overview.vue')
|
||||||
|
const genealogyCard = read('components/genealogy/Card.vue')
|
||||||
|
const familyFeedPage = read('pages/family/feed.vue')
|
||||||
|
const siteHomePage = read('pages/family/site-home.vue')
|
||||||
|
const siteContentContract = read('services/api/site-content-contract.js')
|
||||||
const invitationManager = read('components/genealogy/InvitationManager.vue')
|
const invitationManager = read('components/genealogy/InvitationManager.vue')
|
||||||
const pedigree = read('pages/tree/pedigree.vue')
|
const pedigree = read('pages/tree/pedigree.vue')
|
||||||
const treeOverview = read('pages/tree/overview.vue')
|
const treeOverview = read('pages/tree/overview.vue')
|
||||||
@@ -26,6 +34,7 @@ const moduleBackground = read('components/ModulePageBackground.vue')
|
|||||||
const genealogyBackground = read('components/genealogy/PageBackground.vue')
|
const genealogyBackground = read('components/genealogy/PageBackground.vue')
|
||||||
const vipPage = read('pages/profile/vip.vue')
|
const vipPage = read('pages/profile/vip.vue')
|
||||||
const promotionsPage = read('pages/profile/promotions.vue')
|
const promotionsPage = read('pages/profile/promotions.vue')
|
||||||
|
const referralQrCode = read('components/ReferralQrCode.vue')
|
||||||
const helpPage = read('pages/profile/help.vue')
|
const helpPage = read('pages/profile/help.vue')
|
||||||
const editProfilePage = read('pages/profile/edit-profile.vue')
|
const editProfilePage = read('pages/profile/edit-profile.vue')
|
||||||
const genealogySettingsPage = read('pages/genealogy/settings.vue')
|
const genealogySettingsPage = read('pages/genealogy/settings.vue')
|
||||||
@@ -45,6 +54,12 @@ const changePhonePage = read('pages/profile/change-phone.vue')
|
|||||||
const changePasswordPage = read('pages/profile/change-password.vue')
|
const changePasswordPage = read('pages/profile/change-password.vue')
|
||||||
const platformVideosPage = read('pages/family/platform-videos.vue')
|
const platformVideosPage = read('pages/family/platform-videos.vue')
|
||||||
const familyMediaContract = read('services/api/family-media-contract.js')
|
const familyMediaContract = read('services/api/family-media-contract.js')
|
||||||
|
const batchDeletionComposable = read('composables/use-batch-deletion.js')
|
||||||
|
const batchManagementBar = read('components/BatchManagementBar.vue')
|
||||||
|
const batchSelectionMark = read('components/BatchSelectionMark.vue')
|
||||||
|
const familyArticleService = read('services/api/family-article-service.js')
|
||||||
|
const familyArticleContract = read('services/api/family-article-contract.js')
|
||||||
|
const lifeRecordService = read('services/api/life-record-service.js')
|
||||||
const dialogPages = [
|
const dialogPages = [
|
||||||
'pages/tree/pedigree.vue',
|
'pages/tree/pedigree.vue',
|
||||||
'pages/family/album-detail.vue',
|
'pages/family/album-detail.vue',
|
||||||
@@ -63,7 +78,6 @@ const memberDirectoryPage = read('pages/tree/member-directory.vue')
|
|||||||
const peoplePage = read('pages/records/people.vue')
|
const peoplePage = read('pages/records/people.vue')
|
||||||
const idValidatedPages = [
|
const idValidatedPages = [
|
||||||
'pages/tree/add-relative.vue',
|
'pages/tree/add-relative.vue',
|
||||||
'pages/tree/member-rank.vue',
|
|
||||||
'pages/tree/member-profile.vue',
|
'pages/tree/member-profile.vue',
|
||||||
'pages/tree/member-states.vue',
|
'pages/tree/member-states.vue',
|
||||||
'pages/genealogy/generation-poems.vue',
|
'pages/genealogy/generation-poems.vue',
|
||||||
@@ -92,7 +106,6 @@ const genealogyContract = read('services/api/genealogy-contract.js')
|
|||||||
const genealogySearchPage = read('pages/genealogy/search.vue')
|
const genealogySearchPage = read('pages/genealogy/search.vue')
|
||||||
const generationPoemService = read('services/api/generation-poem-service.js')
|
const generationPoemService = read('services/api/generation-poem-service.js')
|
||||||
const genealogyMemberService = read('services/api/genealogy-member-service.js')
|
const genealogyMemberService = read('services/api/genealogy-member-service.js')
|
||||||
const lifeRecordService = read('services/api/life-record-service.js')
|
|
||||||
const personDocumentService = read('services/api/person-document-service.js')
|
const personDocumentService = read('services/api/person-document-service.js')
|
||||||
const mediaUpload = read('utils/media-upload.js')
|
const mediaUpload = read('utils/media-upload.js')
|
||||||
const businessFileContract = read('services/api/business-file-contract.js')
|
const businessFileContract = read('services/api/business-file-contract.js')
|
||||||
@@ -119,6 +132,29 @@ expect(!signIn.includes('协议页面准备中'), '登录页仍使用协议占
|
|||||||
expect(!register.includes('协议页面准备中'), '注册页仍使用协议占位提示')
|
expect(!register.includes('协议页面准备中'), '注册页仍使用协议占位提示')
|
||||||
expect(signIn.includes('openComplianceDocument'), '登录页没有接入协议正文导航')
|
expect(signIn.includes('openComplianceDocument'), '登录页没有接入协议正文导航')
|
||||||
expect(register.includes('openComplianceDocument'), '注册页没有接入协议正文导航')
|
expect(register.includes('openComplianceDocument'), '注册页没有接入协议正文导航')
|
||||||
|
expect(
|
||||||
|
signIn.includes('<LoginAdvertisement') &&
|
||||||
|
loginAdvertisement.includes('placement: "home_banner"') &&
|
||||||
|
loginAdvertisement.includes('promotion.coverFile.accessUrl') &&
|
||||||
|
loginAdvertisement.includes('home-ad-heritage-hall.png') &&
|
||||||
|
!loginAdvertisement.includes('点击查看更多内容'),
|
||||||
|
'登录页没有在注册入口上方显示单张祠堂山水图,或仍带有查看更多文案',
|
||||||
|
)
|
||||||
|
expect(
|
||||||
|
signIn.includes('justify-content: flex-start') &&
|
||||||
|
signIn.includes('color: #5d493b') &&
|
||||||
|
signIn.includes('color: #766c63'),
|
||||||
|
'登录页仍使用自动撑开的旧布局,或次要文字颜色没有加深',
|
||||||
|
)
|
||||||
|
expect(
|
||||||
|
/\.login-page\s*\{[^}]*height:\s*var\(--app-viewport-height\)[^}]*max-height:\s*var\(--app-viewport-height\)[^}]*overflow:\s*hidden/.test(signIn) &&
|
||||||
|
/\.login-page\s+:deep\(\.auth-shell__paper\)\s*\{[^}]*min-height:\s*0[^}]*overflow:\s*hidden/.test(signIn),
|
||||||
|
'登录页没有锁定动态 100vh,新增广告后仍可能产生页面级滚动',
|
||||||
|
)
|
||||||
|
expect(
|
||||||
|
/getPromotions[\s\S]*?authenticated:\s*false/.test(siteContentService),
|
||||||
|
'公开广告接口仍会携带登录态,登录页无法安全读取 home_banner',
|
||||||
|
)
|
||||||
expect(
|
expect(
|
||||||
/getComplianceDocument[\s\S]*?authenticated:\s*false/.test(siteContentService),
|
/getComplianceDocument[\s\S]*?authenticated:\s*false/.test(siteContentService),
|
||||||
'协议正文请求仍依赖登录状态',
|
'协议正文请求仍依赖登录状态',
|
||||||
@@ -128,13 +164,15 @@ const complianceRoute = routes.match(/M13: defineRoute\(\{[\s\S]*?^\s{2}\}\),/m)
|
|||||||
expect(complianceRoute.includes('"A01"'), '协议正文路由不允许从登录页进入')
|
expect(complianceRoute.includes('"A01"'), '协议正文路由不允许从登录页进入')
|
||||||
expect(complianceRoute.includes('"A04"'), '协议正文路由不允许从注册页进入')
|
expect(complianceRoute.includes('"A04"'), '协议正文路由不允许从注册页进入')
|
||||||
const membersRoute = routes.match(/G13: defineRoute\(\{[\s\S]*?^\s{2}\}\),/m)?.[0] || ''
|
const membersRoute = routes.match(/G13: defineRoute\(\{[\s\S]*?^\s{2}\}\),/m)?.[0] || ''
|
||||||
expect(membersRoute.includes('"G01"'), '成员页路由不允许从家谱首页进入')
|
expect(!membersRoute.includes('"G01"'), '成员页仍允许从家谱首页旧入口进入')
|
||||||
expect(
|
expect(
|
||||||
/members:\s*\(\)\s*=>\s*openPage\("G13"/.test(genealogyHome),
|
genealogyOverview.includes('openPage("G13"') &&
|
||||||
'家谱首页“成员”没有直达成员列表',
|
!genealogyHome.includes('shortcut-grid') &&
|
||||||
|
!genealogyHome.includes('openShortcut'),
|
||||||
|
'家谱快捷导航没有完整迁移到家谱详情页',
|
||||||
)
|
)
|
||||||
const platformVideosRoute = routes.match(/F11: defineRoute\(\{[\s\S]*?^\s{2}\}\),/m)?.[0] || ''
|
const platformVideosRoute = routes.match(/F11: defineRoute\(\{[\s\S]*?^\s{2}\}\),/m)?.[0] || ''
|
||||||
expect(platformVideosRoute.includes('"G01"'), '宣传视频路由不允许从家谱首页进入')
|
expect(!platformVideosRoute.includes('"G01"') && platformVideosRoute.includes('"F10"'), '宣传视频没有迁回原视频导航')
|
||||||
expect(platformVideosRoute.includes('"videoId"'), '宣传视频路由不能携带指定视频 ID')
|
expect(platformVideosRoute.includes('"videoId"'), '宣传视频路由不能携带指定视频 ID')
|
||||||
expect(
|
expect(
|
||||||
familyMediaContract.includes("HOME_FEATURED: 'home_featured'") &&
|
familyMediaContract.includes("HOME_FEATURED: 'home_featured'") &&
|
||||||
@@ -142,10 +180,72 @@ expect(
|
|||||||
familyMediaContract.includes("PROFILE_FEATURED: 'profile_featured'"),
|
familyMediaContract.includes("PROFILE_FEATURED: 'profile_featured'"),
|
||||||
'平台视频契约没有完整覆盖后端三个投放位',
|
'平台视频契约没有完整覆盖后端三个投放位',
|
||||||
)
|
)
|
||||||
expect(genealogyHome.includes('featured-media-grid'), '家谱首页没有宣传视频封面预览')
|
|
||||||
expect(
|
expect(
|
||||||
genealogyHome.includes('PLATFORM_VIDEO_PLACEMENT.HOME_FEATURED'),
|
!genealogyHome.includes('featured-media-grid') &&
|
||||||
'家谱首页没有读取首页推荐视频投放位',
|
!genealogyHome.includes('PLATFORM_VIDEO_PLACEMENT.HOME_FEATURED'),
|
||||||
|
'家谱首页仍保留已经迁出的宣传视频',
|
||||||
|
)
|
||||||
|
expect(
|
||||||
|
!genealogyHome.includes('genealogy-create-button') &&
|
||||||
|
!genealogyHome.includes('新建家谱') &&
|
||||||
|
genealogyHome.includes('class="create-action"') &&
|
||||||
|
genealogyHome.includes('添加家谱'),
|
||||||
|
'家谱首页仍在顶部显示误加的新建按钮,或列表末尾缺少原有添加入口',
|
||||||
|
)
|
||||||
|
expect(
|
||||||
|
genealogyHome.indexOf('class="genealogy-list-scroll"') <
|
||||||
|
genealogyHome.indexOf('class="genealogy-list-toolbar"') &&
|
||||||
|
!genealogyHome.includes('genealogy-fixed-zone'),
|
||||||
|
'家谱分组标题仍被固定在第 7 条标注的滚动区域之外',
|
||||||
|
)
|
||||||
|
expect(
|
||||||
|
siteContentContract.includes("news: '资讯'") &&
|
||||||
|
siteContentContract.includes("notice: '公告'") &&
|
||||||
|
siteHomePage.includes('article.typeLabel'),
|
||||||
|
'官网文章类型仍直接暴露后端英文编码',
|
||||||
|
)
|
||||||
|
expect(
|
||||||
|
genealogyHome.includes('<HomeAdvertisementPanel') &&
|
||||||
|
homeAdvertisementPanel.includes('home-ad-heritage-hall.png') &&
|
||||||
|
homeAdvertisementPanel.includes('home-ad-family-tree.png') &&
|
||||||
|
homeAdvertisementPanel.includes('点击查看更多内容') &&
|
||||||
|
!genealogyHome.includes('home-news-card') &&
|
||||||
|
!genealogyHome.includes('<AppPromotionStrip'),
|
||||||
|
'首页底部固定区域没有按参考图显示两张并排图片,或仍使用错误的文字/横向广告卡',
|
||||||
|
)
|
||||||
|
expect(
|
||||||
|
genealogyCard.includes('创建于 {{ formatGenealogyDate(genealogy.createTime) }}') &&
|
||||||
|
!genealogyCard.includes('加入于 {{ formatGenealogyDate(genealogy.joinTime) }}'),
|
||||||
|
'家谱卡片没有按后端真实 createTime 显示创建日期',
|
||||||
|
)
|
||||||
|
expect(
|
||||||
|
genealogyOverview.includes('familyContentGroups') &&
|
||||||
|
genealogyOverview.includes('openFamilySection') &&
|
||||||
|
!familyFeedPage.includes('feed-shortcuts'),
|
||||||
|
'家族内容导航没有迁入家谱详情并完成归类',
|
||||||
|
)
|
||||||
|
expect(
|
||||||
|
siteHomePage.includes('siteContentApi.getSiteArticles') &&
|
||||||
|
siteHomePage.includes('<AppTabbar v-if="!subpageMode" active="family"') &&
|
||||||
|
!familyFeedPage.includes('<AppTabbar'),
|
||||||
|
'消息根页没有继续承载公共新闻资讯,或家谱动态仍占用根导航',
|
||||||
|
)
|
||||||
|
expect(read('components/AppTabbar.vue').includes('label: "消息"'), '底部中间导航没有改为消息')
|
||||||
|
expect(
|
||||||
|
fs.existsSync(path.join(workspace, 'pages/family/site-article-list.vue')) &&
|
||||||
|
read('pages/family/site-article-list.vue').includes('subpage-mode'),
|
||||||
|
'公共资讯子列表没有与消息根页复用同一实现',
|
||||||
|
)
|
||||||
|
expect(routes.includes('path: "/pages/family/site-home"') && !routes.includes('path: "/pages/notification/message-home"') && !routes.includes('path: "/pages/profile/message-home"'), '消息根页没有指向原新闻资讯页面')
|
||||||
|
expect(!profileHomePage.includes('isMessagePortal'), '我的页面仍混入消息页变体逻辑')
|
||||||
|
expect(!profileHomePage.includes('class="profile-metadata"'), '我的页面仍显示应删除的性别、生日和邮箱摘要卡')
|
||||||
|
expect(profileHomePage.includes('家谱新闻列表(图文)') && profileHomePage.includes('家谱系列文章(图文)'), '我的菜单没有按标注图补齐')
|
||||||
|
expect(
|
||||||
|
profileHomePage.includes('<HomeAdvertisementPanel') &&
|
||||||
|
profileHomePage.includes('class="profile-home-advertisements"') &&
|
||||||
|
!profileHomePage.includes('广告位列表') &&
|
||||||
|
!routes.includes('F14: defineRoute'),
|
||||||
|
'我的页面底部没有直接复用首页图片广告,或仍保留错误的广告位列表入口',
|
||||||
)
|
)
|
||||||
expect(
|
expect(
|
||||||
platformVideosPage.includes('video-card__cover-button') &&
|
platformVideosPage.includes('video-card__cover-button') &&
|
||||||
@@ -157,6 +257,10 @@ expect(
|
|||||||
genealogyHome.includes('getRequestErrorMessage'),
|
genealogyHome.includes('getRequestErrorMessage'),
|
||||||
'家谱首页仍会隐藏家谱列表的真实失败类型',
|
'家谱首页仍会隐藏家谱列表的真实失败类型',
|
||||||
)
|
)
|
||||||
|
expect(
|
||||||
|
/import\s*\{[^}]*normalizeOptionalNumericId[^}]*\}\s*from '\.\/response-normalizers\.js'/.test(genealogyContract),
|
||||||
|
'家谱响应契约使用 normalizeOptionalNumericId 时没有从响应归一化模块导入',
|
||||||
|
)
|
||||||
expect(
|
expect(
|
||||||
articleListPage.includes('articleCategoryError') &&
|
articleListPage.includes('articleCategoryError') &&
|
||||||
articleListPage.includes('getRequestErrorMessage'),
|
articleListPage.includes('getRequestErrorMessage'),
|
||||||
@@ -240,6 +344,11 @@ expect(
|
|||||||
/promotion-card__actions[^>]*@click\.stop/.test(promotionsPage),
|
/promotion-card__actions[^>]*@click\.stop/.test(promotionsPage),
|
||||||
'推广操作按钮会继续触发卡片跳转',
|
'推广操作按钮会继续触发卡片跳转',
|
||||||
)
|
)
|
||||||
|
expect(
|
||||||
|
promotionsPage.includes('<ReferralQrCode :value="referralProfile.shareUrl"') &&
|
||||||
|
referralQrCode.includes('class="referral-qr-code"'),
|
||||||
|
'推广页没有根据后端 shareUrl 展示推荐二维码',
|
||||||
|
)
|
||||||
expect(
|
expect(
|
||||||
/\.help-article-list\s*\{[^}]*margin-top:\s*18rpx/.test(helpPage),
|
/\.help-article-list\s*\{[^}]*margin-top:\s*18rpx/.test(helpPage),
|
||||||
'帮助中心分类栏与首条问答之间缺少间距',
|
'帮助中心分类栏与首条问答之间缺少间距',
|
||||||
@@ -273,6 +382,13 @@ for (const field of ['withdrawalNo', 'auditRemark', 'payoutReference', 'paidAt']
|
|||||||
expect(earningsPage.includes(`withdrawal.${field}`), `提现记录没有显示 ${field}`)
|
expect(earningsPage.includes(`withdrawal.${field}`), `提现记录没有显示 ${field}`)
|
||||||
}
|
}
|
||||||
expect(relativeRecordsPage.includes('item.mediaFiles?.[0]?.accessUrl'), '贺礼簿列表没有显示首图')
|
expect(relativeRecordsPage.includes('item.mediaFiles?.[0]?.accessUrl'), '贺礼簿列表没有显示首图')
|
||||||
|
expect(meritRecordsPage.includes('item.mediaFiles?.[0]?.accessUrl'), '功德记录列表没有显示首图')
|
||||||
|
expect(memoPage.includes('item.mediaFiles?.[0]?.accessUrl'), '备忘录和恩人录列表没有显示首图')
|
||||||
|
expect(
|
||||||
|
platformVideosPage.includes('video-card__cover-placeholder') &&
|
||||||
|
!/<video\s+v-else[\s\S]*?class="video-card__player"/.test(platformVideosPage),
|
||||||
|
'宣传视频列表无封面时仍直接铺设播放器',
|
||||||
|
)
|
||||||
expect(growthJournalPage.includes('field !== "lineagePersonId"'), '成长记录仍把自动带入人物直接判为用户修改')
|
expect(growthJournalPage.includes('field !== "lineagePersonId"'), '成长记录仍把自动带入人物直接判为用户修改')
|
||||||
const joinApplicationRoute = routes.match(/G08: defineRoute\(\{[\s\S]*?^\s{2}\}\),/m)?.[0] || ''
|
const joinApplicationRoute = routes.match(/G08: defineRoute\(\{[\s\S]*?^\s{2}\}\),/m)?.[0] || ''
|
||||||
expect(joinApplicationRoute.includes('"genealogyName"'), '公开家谱申请页路由仍拒绝谱名参数')
|
expect(joinApplicationRoute.includes('"genealogyName"'), '公开家谱申请页路由仍拒绝谱名参数')
|
||||||
@@ -353,9 +469,10 @@ for (const [pageName, pageSource] of [
|
|||||||
for (const pagePath of idValidatedPages) {
|
for (const pagePath of idValidatedPages) {
|
||||||
expect(read(pagePath).includes('/^[1-9]\\d*$/.test(genealogyId.value)'), `${pagePath} 没有校验家谱 ID`)
|
expect(read(pagePath).includes('/^[1-9]\\d*$/.test(genealogyId.value)'), `${pagePath} 没有校验家谱 ID`)
|
||||||
}
|
}
|
||||||
expect(memberRankPage.includes('createDiscardConfirmation'), '成员排行页缺少未保存修改确认')
|
expect(memberRankPage.includes('returnTo("T05", { genealogyId, personId })'), '旧成员排行页没有统一转入成员编辑页')
|
||||||
expect(memberRankPage.includes('dirty: isDirty.value'), '成员排行页返回守卫未检查排序修改')
|
expect(!memberRankPage.includes('lineageApi.updatePerson'), '旧成员排行页仍保留独立保存逻辑')
|
||||||
expect(memberRankPage.includes('sortOrderBaseline.value = normalizedSortOrder'), '成员排行保存成功后没有更新草稿基准')
|
expect(!memberRankPage.includes('<text>排序值</text>'), '独立排行页仍要求普通用户手填排序值')
|
||||||
|
expect(!memberRankPage.includes('updatePersonSortOrder'), '独立排行页仍会提交手工排序值')
|
||||||
expect(editProfilePage.includes('original[field] = payload[field]'), '编辑资料保存成功后没有更新草稿基准')
|
expect(editProfilePage.includes('original[field] = payload[field]'), '编辑资料保存成功后没有更新草稿基准')
|
||||||
expect(meritRecordsPage.includes('/^(?:0|[1-9]\\d{0,9})(?:\\.\\d{1,2})?$/'), '功德记录页面没有限制金额精度和范围')
|
expect(meritRecordsPage.includes('/^(?:0|[1-9]\\d{0,9})(?:\\.\\d{1,2})?$/'), '功德记录页面没有限制金额精度和范围')
|
||||||
expect(lifeRecordContract.includes("normalizeOptionalCurrencyNumber(payload.amount, '功德金额')"), '功德记录 API 契约没有限制金额精度和范围')
|
expect(lifeRecordContract.includes("normalizeOptionalCurrencyNumber(payload.amount, '功德金额')"), '功德记录 API 契约没有限制金额精度和范围')
|
||||||
@@ -369,6 +486,21 @@ expect(
|
|||||||
editMemberPage.match(/\/\^\[1-9\]\\d\*\$\/.test/g)?.length >= 4,
|
editMemberPage.match(/\/\^\[1-9\]\\d\*\$\/.test/g)?.length >= 4,
|
||||||
'修改成员页没有完整校验家谱和人物 ID',
|
'修改成员页没有完整校验家谱和人物 ID',
|
||||||
)
|
)
|
||||||
|
expect(editMemberPage.includes('lineageApi.getRankOptions'), '修改成员页没有读取排行称谓选项')
|
||||||
|
expect(editMemberPage.includes('rankId: editForm.rankId'), '修改成员页没有提交所选排行称谓')
|
||||||
|
expect(editMemberPage.includes('暂无可选排行'), '修改成员页没有展示排行配置缺失状态')
|
||||||
|
expect(!editMemberPage.includes('<text>排序值</text>'), '修改成员页仍要求普通用户手填排序值')
|
||||||
|
expect(addRelativePage.includes('lineageApi.getRankOptions'), '新增亲属页没有读取排行称谓选项')
|
||||||
|
expect(addRelativePage.includes('{ rankId: addForm.rankId }'), '新增亲属页没有提交所选排行称谓')
|
||||||
|
expect(!addRelativePage.includes('<text>排序值</text>'), '新增亲属页仍要求普通用户手填排序值')
|
||||||
|
expect(addRelativePage.includes('MemberDatePickerSheet'), '新增亲属页没有复用成员日期选择器')
|
||||||
|
expect(!addRelativePage.includes('mode="date"'), '新增亲属页仍在使用可选择未来年份的系统日期控件')
|
||||||
|
expect(addRelativePage.includes('不能晚于今天'), '新增亲属页没有在提交前拦截未来日期')
|
||||||
|
expect(!treeOverview.includes('writing-mode: vertical-rl'), '树状图世代信息仍被强制逐字竖排')
|
||||||
|
expect(treeOverview.includes('-webkit-line-clamp: 2'), '树状图长姓名仍会被单行省略号截断')
|
||||||
|
expect(!editMemberPage.includes('`${name}(${personOptionId})`'), '修改成员页仍向用户暴露人物内部 ID')
|
||||||
|
expect(editMemberPage.includes('sortOrder: editForm.sortOrder'), '隐藏排序值后修改成员会把原顺序重置为零')
|
||||||
|
expect(!treeOverview.includes('@click="toRank"') && !treeOverview.includes('ADJUST_RANK'), '树状图仍保留重复的独立排行入口')
|
||||||
expect(
|
expect(
|
||||||
/v-if="feed\.canDelete"[\s\S]*?label="删除动态"/.test(feedDetailPage),
|
/v-if="feed\.canDelete"[\s\S]*?label="删除动态"/.test(feedDetailPage),
|
||||||
'动态详情向无删除权限用户显示删除按钮',
|
'动态详情向无删除权限用户显示删除按钮',
|
||||||
@@ -420,6 +552,38 @@ expect(memoPage.includes('memo.memoType === memoType.value'), '家族恩人列
|
|||||||
for (const contractSource of [lifeRecordContract, familyMediaContract, read('services/api/ceremony-contract.js')]) {
|
for (const contractSource of [lifeRecordContract, familyMediaContract, read('services/api/ceremony-contract.js')]) {
|
||||||
expect(contractSource.includes('createTime:'), '资源响应契约没有保留后端 createTime')
|
expect(contractSource.includes('createTime:'), '资源响应契约没有保留后端 createTime')
|
||||||
}
|
}
|
||||||
|
expect(batchDeletionComposable.includes('for (const id of pendingIds)') && batchDeletionComposable.includes('break'), '批量删除没有按顺序执行或遇到失败后停止')
|
||||||
|
expect(batchDeletionComposable.includes('selectedIds.value = selectedIds.value.filter'), '批量删除成功后没有移除已完成选择')
|
||||||
|
expect(batchManagementBar.includes('仅可选择有删除权限的内容'), '批量管理没有说明权限边界')
|
||||||
|
expect(batchSelectionMark.includes('role="checkbox"') && batchSelectionMark.includes(':aria-checked="selected"'), '批量选择控件缺少复选语义')
|
||||||
|
for (const [pageSource, resourceName] of [
|
||||||
|
[articleListPage, '谱文'],
|
||||||
|
[familyVideosPage, '视频'],
|
||||||
|
[ceremonyListPage, '礼仪活动'],
|
||||||
|
[meritRecordsPage, '功德记录'],
|
||||||
|
[relativeRecordsPage, '往来记录'],
|
||||||
|
[memoPage, '家族备忘/恩人'],
|
||||||
|
[growthJournalPage, '成长记录'],
|
||||||
|
[personDocumentDialog, '重要证件'],
|
||||||
|
]) {
|
||||||
|
expect(pageSource.includes('useBatchDeletion'), `${resourceName}没有接入统一批量删除状态`)
|
||||||
|
expect(pageSource.includes('BatchManagementBar') && pageSource.includes('BatchSelectionMark'), `${resourceName}缺少批量管理入口或选择控件`)
|
||||||
|
}
|
||||||
|
expect(/createArticle[\s\S]*?normalizeAppArticle\(article, normalizedGenealogyId\)/.test(familyArticleService), '谱文创建响应没有规范化,无法可靠继续设置密码')
|
||||||
|
expect(/createGrowthRecord[\s\S]*?normalizeAppGrowthRecord\(growthRecord, normalizedGenealogyId\)/.test(lifeRecordService), '成长记录创建响应没有规范化,无法可靠继续设置密码')
|
||||||
|
expect(/genealogyNo:\s*normalizeGenealogyResponseText\(item\.genealogyNo/.test(genealogyContract), '我的家谱响应没有保留后端返回的稳定家谱编号')
|
||||||
|
expect(genealogyContract.includes("createTime: normalizeGenealogyResponseText(item.createTime, 'createTime')"), '我的家谱响应没有保留后端真实创建时间')
|
||||||
|
expect(genealogyContract.includes('createTime: genealogy.createTime'), '我的家谱列表规范化时丢弃了真实创建时间')
|
||||||
|
expect(familyArticleContract.includes("'mediaOssIds'") && familyArticleContract.includes('normalizedPayload.mediaOssIds'), '谱文请求契约没有提交正文图片标识')
|
||||||
|
expect(familyArticleContract.includes('mediaFiles: normalizeBusinessFileAccessRows'), '谱文响应契约没有保留正文图片')
|
||||||
|
expect(articleEditorPage.includes('mediaReceipts') && articleEditorPage.includes('mediaOssIds') && articleEditorPage.includes('removeArticleMedia'), '谱文编辑页没有完整支持正文多图新增、回显和移除')
|
||||||
|
expect(articleDetailPage.includes(':files="article.mediaFiles"'), '谱文详情没有按后端顺序显示正文图片')
|
||||||
|
expect(/createArticle[\s\S]*?setArticlePassword/.test(articleEditorPage), '新建谱文没有在创建成功后继续设置内容密码')
|
||||||
|
expect(/createGrowthRecord[\s\S]*?setGrowthRecordPassword/.test(growthJournalPage), '新建成长记录没有在创建成功后继续设置内容密码')
|
||||||
|
expect(/createPersonDocument[\s\S]*?setPersonDocumentPassword/.test(personDocumentDialog), '新建重要证件没有在创建成功后继续设置内容密码')
|
||||||
|
for (const protectedCreateSource of [articleEditorPage, growthJournalPage, personDocumentDialog]) {
|
||||||
|
expect(protectedCreateSource.includes('可能尚未受到密码保护'), '两阶段创建没有明确提示密码设置失败后的未保护风险')
|
||||||
|
}
|
||||||
expect(permissionContract.includes('item.code'), '权限目录仍读取旧版 permissionCode 字段')
|
expect(permissionContract.includes('item.code'), '权限目录仍读取旧版 permissionCode 字段')
|
||||||
expect(permissionContract.includes('item.groupName'), '权限目录没有接入后端权限分组')
|
expect(permissionContract.includes('item.groupName'), '权限目录没有接入后端权限分组')
|
||||||
expect(genealogyCapabilityService.includes('/comments/page`'), '家族视频根评论仍调用旧列表路径')
|
expect(genealogyCapabilityService.includes('/comments/page`'), '家族视频根评论仍调用旧列表路径')
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
import {
|
import {
|
||||||
assertPlainPayload,
|
assertPlainPayload,
|
||||||
normalizeOptionalNormalDisableStatus,
|
normalizeOptionalNormalDisableStatus,
|
||||||
|
normalizeOptionalOssIdList,
|
||||||
normalizeOptionalSafeInteger,
|
normalizeOptionalSafeInteger,
|
||||||
normalizeOptionalText,
|
normalizeOptionalText,
|
||||||
normalizeOssIdString,
|
normalizeOssIdString,
|
||||||
@@ -15,6 +16,7 @@ import {
|
|||||||
} from './request-normalizers.js'
|
} from './request-normalizers.js'
|
||||||
import {
|
import {
|
||||||
normalizeBusinessFileAccess,
|
normalizeBusinessFileAccess,
|
||||||
|
normalizeBusinessFileAccessRows,
|
||||||
normalizeContentProtectionCapabilities,
|
normalizeContentProtectionCapabilities,
|
||||||
normalizeResourceCapabilities
|
normalizeResourceCapabilities
|
||||||
} from './business-file-contract.js'
|
} from './business-file-contract.js'
|
||||||
@@ -67,6 +69,7 @@ export const normalizeAppArticle = (value, expectedGenealogyId, expectedArticleI
|
|||||||
categoryId: normalizeOptionalNumericId(value.categoryId, '谱文分类标识', 'ARTICLE_RESPONSE_INVALID'),
|
categoryId: normalizeOptionalNumericId(value.categoryId, '谱文分类标识', 'ARTICLE_RESPONSE_INVALID'),
|
||||||
category: normalizeResponseText(value.categoryName, 'categoryName'),
|
category: normalizeResponseText(value.categoryName, 'categoryName'),
|
||||||
coverFile: normalizeBusinessFileAccess(value.coverFile, '谱文封面', 'ARTICLE_RESPONSE_INVALID'),
|
coverFile: normalizeBusinessFileAccess(value.coverFile, '谱文封面', 'ARTICLE_RESPONSE_INVALID'),
|
||||||
|
mediaFiles: normalizeBusinessFileAccessRows(value.mediaFiles, '谱文正文图片', 'ARTICLE_RESPONSE_INVALID'),
|
||||||
viewCount: normalizeOptionalNonnegativeInteger(value.viewCount, '谱文阅读数', 'ARTICLE_RESPONSE_INVALID'),
|
viewCount: normalizeOptionalNonnegativeInteger(value.viewCount, '谱文阅读数', 'ARTICLE_RESPONSE_INVALID'),
|
||||||
sortOrder: normalizeOptionalNonnegativeInteger(value.sortOrder, '谱文排序值', 'ARTICLE_RESPONSE_INVALID'),
|
sortOrder: normalizeOptionalNonnegativeInteger(value.sortOrder, '谱文排序值', 'ARTICLE_RESPONSE_INVALID'),
|
||||||
status: normalizeNormalDisableResponseStatus(value.status, '谱文状态', 'ARTICLE_RESPONSE_INVALID'),
|
status: normalizeNormalDisableResponseStatus(value.status, '谱文状态', 'ARTICLE_RESPONSE_INVALID'),
|
||||||
@@ -90,6 +93,7 @@ export const normalizeArticleCreatePayload = (payload) => {
|
|||||||
'articleTitle',
|
'articleTitle',
|
||||||
'articleSummary',
|
'articleSummary',
|
||||||
'coverOssId',
|
'coverOssId',
|
||||||
|
'mediaOssIds',
|
||||||
'articleContent',
|
'articleContent',
|
||||||
'authorName',
|
'authorName',
|
||||||
'sortOrder',
|
'sortOrder',
|
||||||
@@ -116,5 +120,12 @@ export const normalizeArticleCreatePayload = (payload) => {
|
|||||||
if (payload.coverOssId === null) normalizedPayload.coverOssId = null
|
if (payload.coverOssId === null) normalizedPayload.coverOssId = null
|
||||||
else if (payload.coverOssId !== '') normalizedPayload.coverOssId = normalizeOssIdString(payload.coverOssId, 'coverOssId')
|
else if (payload.coverOssId !== '') normalizedPayload.coverOssId = normalizeOssIdString(payload.coverOssId, 'coverOssId')
|
||||||
}
|
}
|
||||||
|
if (Object.prototype.hasOwnProperty.call(payload, 'mediaOssIds')) {
|
||||||
|
const mediaOssIds = normalizeOptionalOssIdList(payload.mediaOssIds)
|
||||||
|
if (mediaOssIds && mediaOssIds.length > 1000) {
|
||||||
|
throw new TypeError('谱文正文图片标识总长度不能超过1000个字符')
|
||||||
|
}
|
||||||
|
normalizedPayload.mediaOssIds = mediaOssIds || ''
|
||||||
|
}
|
||||||
return normalizedPayload
|
return normalizedPayload
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,13 +33,14 @@ const requestArticleContentProtection = async ({
|
|||||||
export const familyArticleApi = {
|
export const familyArticleApi = {
|
||||||
async createArticle(genealogyId, payload, requestOptions = {}) {
|
async createArticle(genealogyId, payload, requestOptions = {}) {
|
||||||
const normalizedGenealogyId = normalizeGenealogyPathId(genealogyId)
|
const normalizedGenealogyId = normalizeGenealogyPathId(genealogyId)
|
||||||
return requestStrict({
|
const article = await requestStrict({
|
||||||
url: `/genealogy/app/genealogies/${normalizedGenealogyId}/articles`,
|
url: `/genealogy/app/genealogies/${normalizedGenealogyId}/articles`,
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
data: normalizeArticleCreatePayload(payload)
|
data: normalizeArticleCreatePayload(payload)
|
||||||
}, {
|
}, {
|
||||||
requestController: requestOptions.requestController ?? null
|
requestController: requestOptions.requestController ?? null
|
||||||
})
|
})
|
||||||
|
return normalizeAppArticle(article, normalizedGenealogyId)
|
||||||
},
|
},
|
||||||
|
|
||||||
async getArticles(genealogyId, requestOptions = {}) {
|
async getArticles(genealogyId, requestOptions = {}) {
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { createRequestError } from './request-client.js'
|
|||||||
import { assertPlainPayload, normalizeOssIdString } from './request-normalizers.js'
|
import { assertPlainPayload, normalizeOssIdString } from './request-normalizers.js'
|
||||||
import {
|
import {
|
||||||
normalizeOptionalNonnegativeInteger,
|
normalizeOptionalNonnegativeInteger,
|
||||||
|
normalizeOptionalNumericId,
|
||||||
normalizeResponseText
|
normalizeResponseText
|
||||||
} from './response-normalizers.js'
|
} from './response-normalizers.js'
|
||||||
|
|
||||||
@@ -227,6 +228,7 @@ export const normalizeAppGenealogy = (item) => {
|
|||||||
)
|
)
|
||||||
return {
|
return {
|
||||||
id: normalizeMyGenealogyId(item.genealogyId),
|
id: normalizeMyGenealogyId(item.genealogyId),
|
||||||
|
genealogyNo: normalizeGenealogyResponseText(item.genealogyNo, 'genealogyNo'),
|
||||||
name,
|
name,
|
||||||
firstAncestorName: normalizeGenealogyResponseText(item.firstAncestorName, 'firstAncestorName'),
|
firstAncestorName: normalizeGenealogyResponseText(item.firstAncestorName, 'firstAncestorName'),
|
||||||
rootPersonId: normalizeOptionalNumericId(item.rootPersonId, '始迁祖人物标识', 'GENEALOGY_RESPONSE_INVALID'),
|
rootPersonId: normalizeOptionalNumericId(item.rootPersonId, '始迁祖人物标识', 'GENEALOGY_RESPONSE_INVALID'),
|
||||||
@@ -248,6 +250,7 @@ export const normalizeAppGenealogy = (item) => {
|
|||||||
),
|
),
|
||||||
archivedAt: normalizeGenealogyResponseText(item.archivedAt, 'archivedAt'),
|
archivedAt: normalizeGenealogyResponseText(item.archivedAt, 'archivedAt'),
|
||||||
intro: normalizeGenealogyResponseText(item.intro, 'intro'),
|
intro: normalizeGenealogyResponseText(item.intro, 'intro'),
|
||||||
|
createTime: normalizeGenealogyResponseText(item.createTime, 'createTime'),
|
||||||
joinTime: normalizeGenealogyResponseText(item.joinTime, 'joinTime')
|
joinTime: normalizeGenealogyResponseText(item.joinTime, 'joinTime')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -267,6 +270,8 @@ export const normalizeMyGenealogies = (value) => {
|
|||||||
memberCount: genealogy.memberCount,
|
memberCount: genealogy.memberCount,
|
||||||
canManage: genealogy.canManage,
|
canManage: genealogy.canManage,
|
||||||
canEditContent: genealogy.canEditContent,
|
canEditContent: genealogy.canEditContent,
|
||||||
|
createTime: genealogy.createTime,
|
||||||
|
joinTime: genealogy.joinTime,
|
||||||
lifecycleStatus: genealogy.lifecycleStatus,
|
lifecycleStatus: genealogy.lifecycleStatus,
|
||||||
archivedAt: genealogy.archivedAt,
|
archivedAt: genealogy.archivedAt,
|
||||||
canArchive: genealogy.canArchive,
|
canArchive: genealogy.canArchive,
|
||||||
|
|||||||
@@ -89,11 +89,12 @@ export const lifeRecordApi = {
|
|||||||
|
|
||||||
async createGrowthRecord(genealogyId, payload, requestOptions = {}) {
|
async createGrowthRecord(genealogyId, payload, requestOptions = {}) {
|
||||||
const normalizedGenealogyId = normalizeGenealogyPathId(genealogyId)
|
const normalizedGenealogyId = normalizeGenealogyPathId(genealogyId)
|
||||||
return requestStrict({
|
const growthRecord = await requestStrict({
|
||||||
url: `/genealogy/app/genealogies/${normalizedGenealogyId}/growth-records`,
|
url: `/genealogy/app/genealogies/${normalizedGenealogyId}/growth-records`,
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
data: normalizeGrowthRecordCreatePayload(payload)
|
data: normalizeGrowthRecordCreatePayload(payload)
|
||||||
}, { requestController: requestOptions.requestController ?? null })
|
}, { requestController: requestOptions.requestController ?? null })
|
||||||
|
return normalizeAppGrowthRecord(growthRecord, normalizedGenealogyId)
|
||||||
},
|
},
|
||||||
|
|
||||||
async updateGrowthRecord(genealogyId, recordId, payload, requestOptions = {}) {
|
async updateGrowthRecord(genealogyId, recordId, payload, requestOptions = {}) {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { normalizeOptionalSafeInteger } from './request-normalizers.js'
|
import { normalizeOptionalSafeInteger } from './request-normalizers.js'
|
||||||
import { createRequestError } from './request-client.js'
|
import { createRequestError } from './request-client.js'
|
||||||
|
import { normalizeOptionalNumericId } from './response-normalizers.js'
|
||||||
import { LINEAGE_PERSON_OPTIONS } from './lineage-person-options.js'
|
import { LINEAGE_PERSON_OPTIONS } from './lineage-person-options.js'
|
||||||
import { normalizeBusinessOptionProjection } from './business-dictionary-contract.js'
|
import { normalizeBusinessOptionProjection } from './business-dictionary-contract.js'
|
||||||
|
|
||||||
@@ -10,6 +11,16 @@ const optionLabels = (options) => Object.freeze(
|
|||||||
const lineagePersonError = (message) =>
|
const lineagePersonError = (message) =>
|
||||||
createRequestError(message, 'LINEAGE_PERSON_RESPONSE_INVALID')
|
createRequestError(message, 'LINEAGE_PERSON_RESPONSE_INVALID')
|
||||||
|
|
||||||
|
const lineageRankError = (message) =>
|
||||||
|
createRequestError(message, 'LINEAGE_RANK_RESPONSE_INVALID')
|
||||||
|
|
||||||
|
const normalizeLineageRankText = (value, label) => {
|
||||||
|
if (typeof value !== 'string' || !value.trim()) {
|
||||||
|
throw lineageRankError(`世系排行选项缺少${label}`)
|
||||||
|
}
|
||||||
|
return value.trim()
|
||||||
|
}
|
||||||
|
|
||||||
export const normalizeLineagePersonIdentity = (value, label) => {
|
export const normalizeLineagePersonIdentity = (value, label) => {
|
||||||
if (typeof value === 'string' && /^[1-9]\d*$/.test(value)) return value
|
if (typeof value === 'string' && /^[1-9]\d*$/.test(value)) return value
|
||||||
if (typeof value === 'number' && Number.isSafeInteger(value) && value > 0) return String(value)
|
if (typeof value === 'number' && Number.isSafeInteger(value) && value > 0) return String(value)
|
||||||
@@ -146,6 +157,12 @@ export const normalizeLineagePersonDetail = (value, expectedGenealogyId, expecte
|
|||||||
throw lineagePersonError('成员详情敏感病史权限无效')
|
throw lineagePersonError('成员详情敏感病史权限无效')
|
||||||
}
|
}
|
||||||
const relationName = normalizeLineagePersonText(value.relationName, '关系显示名称')
|
const relationName = normalizeLineagePersonText(value.relationName, '关系显示名称')
|
||||||
|
const rankId = normalizeOptionalNumericId(
|
||||||
|
value.rankId,
|
||||||
|
'成员排行标识',
|
||||||
|
'LINEAGE_PERSON_RESPONSE_INVALID'
|
||||||
|
)
|
||||||
|
const rankName = normalizeLineagePersonText(value.rankName, '排行名称')
|
||||||
for (const field of ['canDisable', 'canCreateDocument', 'canManageDocuments']) {
|
for (const field of ['canDisable', 'canCreateDocument', 'canManageDocuments']) {
|
||||||
if (value[field] !== undefined && typeof value[field] !== 'boolean') {
|
if (value[field] !== undefined && typeof value[field] !== 'boolean') {
|
||||||
throw lineagePersonError(`成员详情权限字段 ${field} 无效`)
|
throw lineagePersonError(`成员详情权限字段 ${field} 无效`)
|
||||||
@@ -221,6 +238,8 @@ export const normalizeLineagePersonDetail = (value, expectedGenealogyId, expecte
|
|||||||
remark,
|
remark,
|
||||||
canManageSensitiveMedicalHistory,
|
canManageSensitiveMedicalHistory,
|
||||||
relationName,
|
relationName,
|
||||||
|
rankId,
|
||||||
|
rankName,
|
||||||
sortOrder,
|
sortOrder,
|
||||||
status: personStatus === '1' ? 'deceased' : 'normal',
|
status: personStatus === '1' ? 'deceased' : 'normal',
|
||||||
canDisable: value.canDisable === true,
|
canDisable: value.canDisable === true,
|
||||||
@@ -231,6 +250,40 @@ export const normalizeLineagePersonDetail = (value, expectedGenealogyId, expecte
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const normalizeLineageRankOptions = (value) => {
|
||||||
|
if (!Array.isArray(value)) throw lineageRankError('世系排行选项响应不是列表')
|
||||||
|
const rankIds = new Set()
|
||||||
|
return value.map((option) => {
|
||||||
|
if (!option || typeof option !== 'object' || Array.isArray(option)) {
|
||||||
|
throw lineageRankError('世系排行选项包含无效记录')
|
||||||
|
}
|
||||||
|
const rankId = normalizeOptionalNumericId(
|
||||||
|
option.rankId,
|
||||||
|
'世系排行标识',
|
||||||
|
'LINEAGE_RANK_RESPONSE_INVALID'
|
||||||
|
)
|
||||||
|
if (!rankId || rankIds.has(rankId)) {
|
||||||
|
throw lineageRankError(rankId ? '世系排行选项包含重复标识' : '世系排行选项缺少标识')
|
||||||
|
}
|
||||||
|
rankIds.add(rankId)
|
||||||
|
const rankCode = normalizeLineageRankText(option.rankCode, '排行编码')
|
||||||
|
const rankName = normalizeLineageRankText(option.rankName, '排行名称')
|
||||||
|
const rankType = normalizeLineageRankText(option.rankType, '排行类型')
|
||||||
|
const genderScope = normalizeLineageRankText(option.genderScope, '排行适用性别')
|
||||||
|
if (!['ANCESTOR', 'GENERATION'].includes(rankType)) {
|
||||||
|
throw lineageRankError('世系排行选项类型无效')
|
||||||
|
}
|
||||||
|
if (!['0', '1', '2'].includes(genderScope)) {
|
||||||
|
throw lineageRankError('世系排行选项适用性别无效')
|
||||||
|
}
|
||||||
|
const rankOrder = normalizeOptionalSafeInteger(option.rankOrder, '排行顺序')
|
||||||
|
if (rankOrder !== undefined && rankOrder < 0) {
|
||||||
|
throw lineageRankError('世系排行选项顺序无效')
|
||||||
|
}
|
||||||
|
return { rankId, rankCode, rankName, rankType, genderScope, rankOrder }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
export const normalizeLineageSensitiveProfile = (value, expectedPersonId) => {
|
export const normalizeLineageSensitiveProfile = (value, expectedPersonId) => {
|
||||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||||
throw lineagePersonError('成员敏感健康资料响应不是对象')
|
throw lineagePersonError('成员敏感健康资料响应不是对象')
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
normalizeLineagePersonIdentity,
|
normalizeLineagePersonIdentity,
|
||||||
normalizeLineagePersonOptions,
|
normalizeLineagePersonOptions,
|
||||||
normalizeLineagePersonPage,
|
normalizeLineagePersonPage,
|
||||||
|
normalizeLineageRankOptions,
|
||||||
normalizeLineageSensitiveProfile
|
normalizeLineageSensitiveProfile
|
||||||
} from './lineage-person-contract.js'
|
} from './lineage-person-contract.js'
|
||||||
import { normalizeLineageTree } from './lineage-tree-contract.js'
|
import { normalizeLineageTree } from './lineage-tree-contract.js'
|
||||||
@@ -26,6 +27,24 @@ export const lineageApi = {
|
|||||||
return normalizeLineageTree(tree)
|
return normalizeLineageTree(tree)
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async getRankOptions(genealogyId, generation, sex, requestOptions = {}) {
|
||||||
|
const normalizedGenealogyId = normalizeGenealogyPathId(genealogyId)
|
||||||
|
if (!Number.isSafeInteger(generation) || generation < 1) {
|
||||||
|
throw new TypeError('排行选项世代必须是正安全整数')
|
||||||
|
}
|
||||||
|
if (!['0', '1', '2'].includes(sex)) {
|
||||||
|
throw new TypeError('排行选项性别必须是 0、1 或 2')
|
||||||
|
}
|
||||||
|
const options = await requestStrict({
|
||||||
|
url: `/genealogy/app/genealogies/${normalizedGenealogyId}/lineage/ranks`,
|
||||||
|
method: 'GET',
|
||||||
|
data: { generation, sex }
|
||||||
|
}, {
|
||||||
|
requestController: requestOptions.requestController ?? null
|
||||||
|
})
|
||||||
|
return normalizeLineageRankOptions(options)
|
||||||
|
},
|
||||||
|
|
||||||
async getPerson(genealogyId, personId, requestOptions = {}) {
|
async getPerson(genealogyId, personId, requestOptions = {}) {
|
||||||
const normalizedGenealogyId = normalizeGenealogyPathId(genealogyId)
|
const normalizedGenealogyId = normalizeGenealogyPathId(genealogyId)
|
||||||
const normalizedPersonId = normalizeLineagePersonIdentity(personId, '人物标识')
|
const normalizedPersonId = normalizeLineagePersonIdentity(personId, '人物标识')
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { createRequestError } from './request-client.js'
|
import { createRequestError } from './request-client.js'
|
||||||
|
import { normalizeBusinessFileAccess } from './business-file-contract.js'
|
||||||
|
|
||||||
const lineageTreeError = (message) =>
|
const lineageTreeError = (message) =>
|
||||||
createRequestError(message, 'LINEAGE_TREE_RESPONSE_INVALID')
|
createRequestError(message, 'LINEAGE_TREE_RESPONSE_INVALID')
|
||||||
@@ -60,6 +61,7 @@ export const normalizeLineageTree = (value) => {
|
|||||||
throw lineageTreeError('世系树人物世代无效')
|
throw lineageTreeError('世系树人物世代无效')
|
||||||
}
|
}
|
||||||
const generationName = normalizeLineageText(node.generationName, '字辈')
|
const generationName = normalizeLineageText(node.generationName, '字辈')
|
||||||
|
const rankName = normalizeLineageText(node.rankName, '排行名称')
|
||||||
const relationName = normalizeLineageText(node.relationName, '人物关系')
|
const relationName = normalizeLineageText(node.relationName, '人物关系')
|
||||||
const birthDate = lineageDatePart(node.birthDate, '出生日期')
|
const birthDate = lineageDatePart(node.birthDate, '出生日期')
|
||||||
const deathDate = lineageDatePart(node.deathDate, '逝世日期')
|
const deathDate = lineageDatePart(node.deathDate, '逝世日期')
|
||||||
@@ -70,15 +72,22 @@ export const normalizeLineageTree = (value) => {
|
|||||||
name: normalizeLineageText(node.name, '人物姓名', { required: true }),
|
name: normalizeLineageText(node.name, '人物姓名', { required: true }),
|
||||||
relation:
|
relation:
|
||||||
relationOverride ||
|
relationOverride ||
|
||||||
|
rankName ||
|
||||||
(parentId
|
(parentId
|
||||||
? (relationName && relationName !== '配偶' ? relationName : '后代')
|
? (relationName && relationName !== '配偶' ? relationName : '排行待补')
|
||||||
: relationName || '始祖'),
|
: relationName || '始祖'),
|
||||||
|
rankName,
|
||||||
generation: node.generation,
|
generation: node.generation,
|
||||||
branch: generationName
|
branch: generationName
|
||||||
? (generationName.endsWith('字辈') ? generationName : `${generationName}字辈`)
|
? (generationName.endsWith('字辈') ? generationName : `${generationName}字辈`)
|
||||||
: '字辈待补',
|
: '字辈待补',
|
||||||
years: birthDate || deathDate ? `${birthDate}—${deathDate}` : '生卒待补',
|
years: birthDate || deathDate ? `${birthDate}—${deathDate}` : '生卒待补',
|
||||||
treeYears: compactLineageYears(birthDate, deathDate),
|
treeYears: compactLineageYears(birthDate, deathDate),
|
||||||
|
avatarFile: normalizeBusinessFileAccess(
|
||||||
|
node.avatarFile,
|
||||||
|
'世系人物头像',
|
||||||
|
'LINEAGE_TREE_RESPONSE_INVALID'
|
||||||
|
),
|
||||||
sex: normalizeLineageText(node.sex, '性别'),
|
sex: normalizeLineageText(node.sex, '性别'),
|
||||||
personStatus: normalizeLineageText(node.personStatus, '人物状态')
|
personStatus: normalizeLineageText(node.personStatus, '人物状态')
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ export const normalizeLineageWritePayload = (payload) => {
|
|||||||
'aliasName',
|
'aliasName',
|
||||||
'sex',
|
'sex',
|
||||||
'generationName',
|
'generationName',
|
||||||
|
'rankId',
|
||||||
'fatherId',
|
'fatherId',
|
||||||
'motherId',
|
'motherId',
|
||||||
'avatarOssId',
|
'avatarOssId',
|
||||||
@@ -77,6 +78,11 @@ export const normalizeLineageWritePayload = (payload) => {
|
|||||||
if (payload[field] === undefined || payload[field] === null || payload[field] === '') continue
|
if (payload[field] === undefined || payload[field] === null || payload[field] === '') continue
|
||||||
normalizedPayload[field] = normalizeLineagePersonIdentity(payload[field], label)
|
normalizedPayload[field] = normalizeLineagePersonIdentity(payload[field], label)
|
||||||
}
|
}
|
||||||
|
if (Object.prototype.hasOwnProperty.call(payload, 'rankId')) {
|
||||||
|
normalizedPayload.rankId = payload.rankId === null || payload.rankId === ''
|
||||||
|
? null
|
||||||
|
: normalizeLineagePersonIdentity(payload.rankId, '排行标识')
|
||||||
|
}
|
||||||
if (payload.avatarOssId !== undefined && payload.avatarOssId !== null && payload.avatarOssId !== '') {
|
if (payload.avatarOssId !== undefined && payload.avatarOssId !== null && payload.avatarOssId !== '') {
|
||||||
normalizedPayload.avatarOssId = normalizeOssIdString(payload.avatarOssId, '头像文件标识')
|
normalizedPayload.avatarOssId = normalizeOssIdString(payload.avatarOssId, '头像文件标识')
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,16 @@
|
|||||||
import { createRequestError } from './request-client.js'
|
import { createRequestError } from './request-client.js'
|
||||||
import {
|
import {
|
||||||
|
normalizeOptionalNonnegativeInteger,
|
||||||
normalizeOptionalNumericId,
|
normalizeOptionalNumericId,
|
||||||
normalizeResponseText
|
normalizeResponseText
|
||||||
} from './response-normalizers.js'
|
} from './response-normalizers.js'
|
||||||
import { normalizeBusinessFileAccess } from './business-file-contract.js'
|
import { normalizeBusinessFileAccess } from './business-file-contract.js'
|
||||||
|
|
||||||
const promotionPlacements = new Set(['home_banner', 'home_bottom', 'message_bottom', 'profile_bottom'])
|
const promotionPlacements = new Set(['home_banner', 'home_bottom', 'message_bottom', 'profile_bottom'])
|
||||||
|
const siteArticleTypeLabels = Object.freeze({
|
||||||
|
news: '资讯',
|
||||||
|
notice: '公告'
|
||||||
|
})
|
||||||
|
|
||||||
export const normalizePromotionPlacement = (value) => {
|
export const normalizePromotionPlacement = (value) => {
|
||||||
if (!promotionPlacements.has(value)) throw new TypeError('推广位无效')
|
if (!promotionPlacements.has(value)) throw new TypeError('推广位无效')
|
||||||
@@ -50,6 +55,64 @@ export const normalizeAppPromotions = (value, expectedPlacement) => {
|
|||||||
return promotions
|
return promotions
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const normalizeSiteArticles = (value) => {
|
||||||
|
if (!Array.isArray(value)) {
|
||||||
|
throw createRequestError('官网文章响应不是列表', 'SITE_ARTICLE_RESPONSE_INVALID')
|
||||||
|
}
|
||||||
|
const articles = value.map((item) => {
|
||||||
|
if (!item || typeof item !== 'object' || Array.isArray(item)) {
|
||||||
|
throw createRequestError('官网文章响应包含无效条目', 'SITE_ARTICLE_RESPONSE_INVALID')
|
||||||
|
}
|
||||||
|
const id = normalizeOptionalNumericId(item.articleId, '官网文章标识', 'SITE_ARTICLE_RESPONSE_INVALID')
|
||||||
|
const title = normalizeResponseText(item.articleTitle, 'articleTitle', {
|
||||||
|
code: 'SITE_ARTICLE_RESPONSE_INVALID',
|
||||||
|
subject: '官网文章响应'
|
||||||
|
})
|
||||||
|
if (!id || !title) {
|
||||||
|
throw createRequestError('官网文章响应缺少稳定字段', 'SITE_ARTICLE_RESPONSE_INVALID')
|
||||||
|
}
|
||||||
|
const externalUrl = normalizeResponseText(item.externalUrl, 'externalUrl', {
|
||||||
|
code: 'SITE_ARTICLE_RESPONSE_INVALID',
|
||||||
|
subject: '官网文章响应'
|
||||||
|
})
|
||||||
|
if (externalUrl && !/^https:\/\/[^\s/?#]+(?:[/?#][^\s]*)?$/.test(externalUrl)) {
|
||||||
|
throw createRequestError('官网文章外部链接无效', 'SITE_ARTICLE_RESPONSE_INVALID')
|
||||||
|
}
|
||||||
|
const type = normalizeResponseText(item.articleType, 'articleType', {
|
||||||
|
code: 'SITE_ARTICLE_RESPONSE_INVALID',
|
||||||
|
subject: '官网文章响应'
|
||||||
|
})
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
title,
|
||||||
|
type,
|
||||||
|
typeLabel: siteArticleTypeLabels[type] || '资讯',
|
||||||
|
summary: normalizeResponseText(item.articleSummary, 'articleSummary', {
|
||||||
|
code: 'SITE_ARTICLE_RESPONSE_INVALID',
|
||||||
|
subject: '官网文章响应'
|
||||||
|
}),
|
||||||
|
content: normalizeResponseText(item.articleContent, 'articleContent', {
|
||||||
|
code: 'SITE_ARTICLE_RESPONSE_INVALID',
|
||||||
|
subject: '官网文章响应'
|
||||||
|
}),
|
||||||
|
publishTime: normalizeResponseText(item.publishTime, 'publishTime', {
|
||||||
|
code: 'SITE_ARTICLE_RESPONSE_INVALID',
|
||||||
|
subject: '官网文章响应'
|
||||||
|
}),
|
||||||
|
externalUrl,
|
||||||
|
sortOrder: normalizeOptionalNonnegativeInteger(
|
||||||
|
item.sortOrder,
|
||||||
|
'官网文章排序值',
|
||||||
|
'SITE_ARTICLE_RESPONSE_INVALID'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
if (new Set(articles.map((article) => article.id)).size !== articles.length) {
|
||||||
|
throw createRequestError('官网文章响应包含重复标识', 'SITE_ARTICLE_RESPONSE_INVALID')
|
||||||
|
}
|
||||||
|
return articles
|
||||||
|
}
|
||||||
|
|
||||||
const normalizeHelpArticleText = (value, field) => {
|
const normalizeHelpArticleText = (value, field) => {
|
||||||
if (typeof value !== 'string' || !value.trim()) {
|
if (typeof value !== 'string' || !value.trim()) {
|
||||||
throw createRequestError(`帮助文章缺少 ${field}`, 'HELP_ARTICLE_RESPONSE_INVALID')
|
throw createRequestError(`帮助文章缺少 ${field}`, 'HELP_ARTICLE_RESPONSE_INVALID')
|
||||||
|
|||||||
@@ -3,11 +3,33 @@ import {
|
|||||||
normalizeComplianceDocument,
|
normalizeComplianceDocument,
|
||||||
normalizeComplianceDocumentKey,
|
normalizeComplianceDocumentKey,
|
||||||
normalizeHelpArticles,
|
normalizeHelpArticles,
|
||||||
normalizePromotionPlacement
|
normalizePromotionPlacement,
|
||||||
|
normalizeSiteArticles
|
||||||
} from './site-content-contract.js'
|
} from './site-content-contract.js'
|
||||||
import { requestStrict } from './request-client.js'
|
import { requestStrict } from './request-client.js'
|
||||||
|
|
||||||
export const siteContentApi = {
|
export const siteContentApi = {
|
||||||
|
async getSiteArticles(requestOptions = {}) {
|
||||||
|
const limit = requestOptions.limit ?? 20
|
||||||
|
if (!Number.isSafeInteger(limit) || limit < 1 || limit > 100) {
|
||||||
|
throw new TypeError('官网文章数量上限无效')
|
||||||
|
}
|
||||||
|
const articleType = typeof requestOptions.articleType === 'string'
|
||||||
|
? requestOptions.articleType.trim()
|
||||||
|
: ''
|
||||||
|
const articles = await requestStrict({
|
||||||
|
url: '/genealogy/app/site/articles',
|
||||||
|
method: 'GET',
|
||||||
|
data: {
|
||||||
|
limit,
|
||||||
|
...(articleType ? { articleType } : {})
|
||||||
|
}
|
||||||
|
}, {
|
||||||
|
requestController: requestOptions.requestController ?? null
|
||||||
|
})
|
||||||
|
return normalizeSiteArticles(articles)
|
||||||
|
},
|
||||||
|
|
||||||
async getHelpArticles(requestOptions = {}) {
|
async getHelpArticles(requestOptions = {}) {
|
||||||
const helpArticles = await requestStrict({
|
const helpArticles = await requestStrict({
|
||||||
url: '/genealogy/app/help-articles',
|
url: '/genealogy/app/help-articles',
|
||||||
@@ -25,6 +47,7 @@ export const siteContentApi = {
|
|||||||
method: 'GET',
|
method: 'GET',
|
||||||
data: { platform: 'app', placement }
|
data: { platform: 'app', placement }
|
||||||
}, {
|
}, {
|
||||||
|
authenticated: false,
|
||||||
requestController: requestOptions.requestController ?? null
|
requestController: requestOptions.requestController ?? null
|
||||||
})
|
})
|
||||||
return normalizeAppPromotions(promotions, placement)
|
return normalizeAppPromotions(promotions, placement)
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 707 KiB After Width: | Height: | Size: 1.6 MiB |
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 37 KiB |
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 41 KiB |
|
Before Width: | Height: | Size: 24 KiB After Width: | Height: | Size: 49 KiB |
|
Before Width: | Height: | Size: 27 KiB After Width: | Height: | Size: 57 KiB |
|
Before Width: | Height: | Size: 30 KiB After Width: | Height: | Size: 64 KiB |
|
Before Width: | Height: | Size: 636 B After Width: | Height: | Size: 1.2 KiB |
|
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 2.1 KiB |
|
Before Width: | Height: | Size: 1.9 KiB After Width: | Height: | Size: 3.7 KiB |
|
Before Width: | Height: | Size: 3.6 KiB After Width: | Height: | Size: 7.0 KiB |
|
Before Width: | Height: | Size: 3.8 KiB After Width: | Height: | Size: 7.4 KiB |
|
Before Width: | Height: | Size: 5.1 KiB After Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 5.6 KiB After Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 6.3 KiB After Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 7.2 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 8.7 KiB After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 46 KiB |
|
After Width: | Height: | Size: 2.6 MiB |
|
After Width: | Height: | Size: 2.4 MiB |
|
After Width: | Height: | Size: 1.1 KiB |
@@ -87,7 +87,7 @@ export const ROUTES = Object.freeze({
|
|||||||
kind: "page",
|
kind: "page",
|
||||||
parent: "G01",
|
parent: "G01",
|
||||||
requiredParams: ["genealogyId"],
|
requiredParams: ["genealogyId"],
|
||||||
allowedSources: ["G01", "G05", "N01", "N02"],
|
allowedSources: ["G05", "N01", "N02"],
|
||||||
}),
|
}),
|
||||||
G11: defineRoute({
|
G11: defineRoute({
|
||||||
path: "/pages/genealogy/settings",
|
path: "/pages/genealogy/settings",
|
||||||
@@ -101,14 +101,14 @@ export const ROUTES = Object.freeze({
|
|||||||
kind: "flow",
|
kind: "flow",
|
||||||
parent: "G05",
|
parent: "G05",
|
||||||
requiredParams: ["genealogyId"],
|
requiredParams: ["genealogyId"],
|
||||||
allowedSources: ["G01", "G05"],
|
allowedSources: ["G05"],
|
||||||
}),
|
}),
|
||||||
G13: defineRoute({
|
G13: defineRoute({
|
||||||
path: "/pages/genealogy/members",
|
path: "/pages/genealogy/members",
|
||||||
kind: "page",
|
kind: "page",
|
||||||
parent: "G05",
|
parent: "G05",
|
||||||
requiredParams: ["genealogyId"],
|
requiredParams: ["genealogyId"],
|
||||||
allowedSources: ["G01", "G05"],
|
allowedSources: ["G05"],
|
||||||
}),
|
}),
|
||||||
G14: defineRoute({
|
G14: defineRoute({
|
||||||
path: "/pages/genealogy/capability-center",
|
path: "/pages/genealogy/capability-center",
|
||||||
@@ -123,7 +123,7 @@ export const ROUTES = Object.freeze({
|
|||||||
parent: "G05",
|
parent: "G05",
|
||||||
requiredParams: ["genealogyId"],
|
requiredParams: ["genealogyId"],
|
||||||
optionalParams: ["selectedId"],
|
optionalParams: ["selectedId"],
|
||||||
allowedSources: ["G01", "G05", "T02", "T04", "T06", "T07"],
|
allowedSources: ["G05", "T02", "T04", "T06", "T07"],
|
||||||
}),
|
}),
|
||||||
T02: defineRoute({
|
T02: defineRoute({
|
||||||
path: "/pages/tree/pedigree",
|
path: "/pages/tree/pedigree",
|
||||||
@@ -131,7 +131,7 @@ export const ROUTES = Object.freeze({
|
|||||||
parent: "G05",
|
parent: "G05",
|
||||||
requiredParams: ["genealogyId"],
|
requiredParams: ["genealogyId"],
|
||||||
optionalParams: ["selectedId"],
|
optionalParams: ["selectedId"],
|
||||||
allowedSources: ["G01", "G05", "T01"],
|
allowedSources: ["G05", "T01"],
|
||||||
}),
|
}),
|
||||||
T03: defineRoute({
|
T03: defineRoute({
|
||||||
path: "/pages/tree/member-profile",
|
path: "/pages/tree/member-profile",
|
||||||
@@ -182,32 +182,38 @@ export const ROUTES = Object.freeze({
|
|||||||
allowedSources: ["T03"],
|
allowedSources: ["T03"],
|
||||||
}),
|
}),
|
||||||
F01: defineRoute({
|
F01: defineRoute({
|
||||||
path: "/pages/family/feed",
|
path: "/pages/family/site-home",
|
||||||
kind: "root",
|
kind: "root",
|
||||||
parent: null,
|
parent: null,
|
||||||
optionalParams: ["genealogyId"],
|
|
||||||
}),
|
}),
|
||||||
F02: defineRoute({
|
F02: defineRoute({
|
||||||
path: "/pages/family/feed-editor",
|
path: "/pages/family/feed-editor",
|
||||||
kind: "flow",
|
kind: "flow",
|
||||||
parent: "F01",
|
parent: "F12",
|
||||||
requiredParams: ["genealogyId", "mode"],
|
requiredParams: ["genealogyId", "mode"],
|
||||||
optionalParams: ["feedId"],
|
optionalParams: ["feedId"],
|
||||||
allowedSources: ["F01", "F03"],
|
allowedSources: ["F12", "F03"],
|
||||||
}),
|
}),
|
||||||
F03: defineRoute({
|
F03: defineRoute({
|
||||||
path: "/pages/family/feed-detail",
|
path: "/pages/family/feed-detail",
|
||||||
kind: "page",
|
kind: "page",
|
||||||
parent: "F01",
|
parent: "F12",
|
||||||
requiredParams: ["genealogyId", "feedId"],
|
requiredParams: ["genealogyId", "feedId"],
|
||||||
allowedSources: ["F01", "F02", "N02"],
|
allowedSources: ["F12", "F02", "N02"],
|
||||||
|
}),
|
||||||
|
F12: defineRoute({
|
||||||
|
path: "/pages/family/feed",
|
||||||
|
kind: "page",
|
||||||
|
parent: "G05",
|
||||||
|
requiredParams: ["genealogyId"],
|
||||||
|
allowedSources: ["G05"],
|
||||||
}),
|
}),
|
||||||
F04: defineRoute({
|
F04: defineRoute({
|
||||||
path: "/pages/family/articles",
|
path: "/pages/family/articles",
|
||||||
kind: "page",
|
kind: "page",
|
||||||
parent: "F01",
|
parent: "G05",
|
||||||
requiredParams: ["genealogyId"],
|
requiredParams: ["genealogyId"],
|
||||||
allowedSources: ["F01"],
|
allowedSources: ["G05", "M01"],
|
||||||
}),
|
}),
|
||||||
F05: defineRoute({
|
F05: defineRoute({
|
||||||
path: "/pages/family/article-detail",
|
path: "/pages/family/article-detail",
|
||||||
@@ -227,9 +233,9 @@ export const ROUTES = Object.freeze({
|
|||||||
F07: defineRoute({
|
F07: defineRoute({
|
||||||
path: "/pages/family/albums",
|
path: "/pages/family/albums",
|
||||||
kind: "page",
|
kind: "page",
|
||||||
parent: "F01",
|
parent: "G05",
|
||||||
requiredParams: ["genealogyId"],
|
requiredParams: ["genealogyId"],
|
||||||
allowedSources: ["F01"],
|
allowedSources: ["G05"],
|
||||||
}),
|
}),
|
||||||
F08: defineRoute({
|
F08: defineRoute({
|
||||||
path: "/pages/family/album-detail",
|
path: "/pages/family/album-detail",
|
||||||
@@ -248,16 +254,23 @@ export const ROUTES = Object.freeze({
|
|||||||
F10: defineRoute({
|
F10: defineRoute({
|
||||||
path: "/pages/family/videos",
|
path: "/pages/family/videos",
|
||||||
kind: "page",
|
kind: "page",
|
||||||
parent: "F01",
|
parent: "G05",
|
||||||
requiredParams: ["genealogyId"],
|
requiredParams: ["genealogyId"],
|
||||||
allowedSources: ["F01"],
|
allowedSources: ["G05"],
|
||||||
|
}),
|
||||||
|
F13: defineRoute({
|
||||||
|
path: "/pages/family/site-article-list",
|
||||||
|
kind: "page",
|
||||||
|
parent: "F01",
|
||||||
|
optionalParams: ["articleType", "title"],
|
||||||
|
allowedSources: ["F01", "M01"],
|
||||||
}),
|
}),
|
||||||
R01: defineRoute({
|
R01: defineRoute({
|
||||||
path: "/pages/records/people",
|
path: "/pages/records/people",
|
||||||
kind: "page",
|
kind: "page",
|
||||||
parent: "F01",
|
parent: "G05",
|
||||||
requiredParams: ["genealogyId"],
|
requiredParams: ["genealogyId"],
|
||||||
allowedSources: ["F01"],
|
allowedSources: ["G05"],
|
||||||
}),
|
}),
|
||||||
R02: defineRoute({
|
R02: defineRoute({
|
||||||
path: "/pages/records/person-detail",
|
path: "/pages/records/person-detail",
|
||||||
@@ -270,9 +283,9 @@ export const ROUTES = Object.freeze({
|
|||||||
R03: defineRoute({
|
R03: defineRoute({
|
||||||
path: "/pages/records/relative-records",
|
path: "/pages/records/relative-records",
|
||||||
kind: "page",
|
kind: "page",
|
||||||
parent: "F01",
|
parent: "G05",
|
||||||
requiredParams: ["genealogyId"],
|
requiredParams: ["genealogyId"],
|
||||||
allowedSources: ["F01"],
|
allowedSources: ["G05"],
|
||||||
}),
|
}),
|
||||||
R04: defineRoute({
|
R04: defineRoute({
|
||||||
path: "/pages/records/relative-record-editor",
|
path: "/pages/records/relative-record-editor",
|
||||||
@@ -285,9 +298,9 @@ export const ROUTES = Object.freeze({
|
|||||||
R05: defineRoute({
|
R05: defineRoute({
|
||||||
path: "/pages/records/ceremonies",
|
path: "/pages/records/ceremonies",
|
||||||
kind: "page",
|
kind: "page",
|
||||||
parent: "F01",
|
parent: "G05",
|
||||||
requiredParams: ["genealogyId"],
|
requiredParams: ["genealogyId"],
|
||||||
allowedSources: ["F01"],
|
allowedSources: ["G05"],
|
||||||
}),
|
}),
|
||||||
R06: defineRoute({
|
R06: defineRoute({
|
||||||
path: "/pages/records/ceremony-detail",
|
path: "/pages/records/ceremony-detail",
|
||||||
@@ -321,17 +334,17 @@ export const ROUTES = Object.freeze({
|
|||||||
R10: defineRoute({
|
R10: defineRoute({
|
||||||
path: "/pages/records/memos",
|
path: "/pages/records/memos",
|
||||||
kind: "page",
|
kind: "page",
|
||||||
parent: "F01",
|
parent: "G05",
|
||||||
requiredParams: ["genealogyId"],
|
requiredParams: ["genealogyId"],
|
||||||
optionalParams: ["memoId", "memoType"],
|
optionalParams: ["memoId", "memoType"],
|
||||||
allowedSources: ["F01", "N02"],
|
allowedSources: ["G05", "N02"],
|
||||||
}),
|
}),
|
||||||
R11: defineRoute({
|
R11: defineRoute({
|
||||||
path: "/pages/records/merit-records",
|
path: "/pages/records/merit-records",
|
||||||
kind: "page",
|
kind: "page",
|
||||||
parent: "F01",
|
parent: "G05",
|
||||||
requiredParams: ["genealogyId"],
|
requiredParams: ["genealogyId"],
|
||||||
allowedSources: ["F01"],
|
allowedSources: ["G05"],
|
||||||
}),
|
}),
|
||||||
R12: defineRoute({
|
R12: defineRoute({
|
||||||
path: "/pages/records/person-documents",
|
path: "/pages/records/person-documents",
|
||||||
@@ -345,7 +358,7 @@ export const ROUTES = Object.freeze({
|
|||||||
kind: "page",
|
kind: "page",
|
||||||
parent: "G01",
|
parent: "G01",
|
||||||
optionalParams: ["genealogyId"],
|
optionalParams: ["genealogyId"],
|
||||||
allowedSources: ["G01", "M01"],
|
allowedSources: ["G01", "F01", "M01"],
|
||||||
}),
|
}),
|
||||||
N02: defineRoute({
|
N02: defineRoute({
|
||||||
path: "/pages/notification/message-detail",
|
path: "/pages/notification/message-detail",
|
||||||
@@ -363,7 +376,7 @@ export const ROUTES = Object.freeze({
|
|||||||
path: "/pages/profile/edit-profile",
|
path: "/pages/profile/edit-profile",
|
||||||
kind: "flow",
|
kind: "flow",
|
||||||
parent: "M01",
|
parent: "M01",
|
||||||
allowedSources: ["M01"],
|
allowedSources: ["F01", "M01"],
|
||||||
}),
|
}),
|
||||||
M03: defineRoute({
|
M03: defineRoute({
|
||||||
path: "/pages/profile/security",
|
path: "/pages/profile/security",
|
||||||
@@ -387,7 +400,7 @@ export const ROUTES = Object.freeze({
|
|||||||
path: "/pages/profile/help",
|
path: "/pages/profile/help",
|
||||||
kind: "page",
|
kind: "page",
|
||||||
parent: "M01",
|
parent: "M01",
|
||||||
allowedSources: ["M01"],
|
allowedSources: ["F01", "M01"],
|
||||||
}),
|
}),
|
||||||
M07: defineRoute({
|
M07: defineRoute({
|
||||||
path: "/pages/profile/feedback",
|
path: "/pages/profile/feedback",
|
||||||
@@ -399,38 +412,38 @@ export const ROUTES = Object.freeze({
|
|||||||
path: "/pages/profile/promotions",
|
path: "/pages/profile/promotions",
|
||||||
kind: "page",
|
kind: "page",
|
||||||
parent: "M01",
|
parent: "M01",
|
||||||
allowedSources: ["M01"],
|
allowedSources: ["F01", "M01"],
|
||||||
}),
|
}),
|
||||||
M09: defineRoute({
|
M09: defineRoute({
|
||||||
path: "/pages/profile/vip",
|
path: "/pages/profile/vip",
|
||||||
kind: "page",
|
kind: "page",
|
||||||
parent: "M01",
|
parent: "M01",
|
||||||
allowedSources: ["M01"],
|
allowedSources: ["F01", "M01"],
|
||||||
}),
|
}),
|
||||||
M10: defineRoute({
|
M10: defineRoute({
|
||||||
path: "/pages/profile/settings",
|
path: "/pages/profile/settings",
|
||||||
kind: "page",
|
kind: "page",
|
||||||
parent: "M01",
|
parent: "M01",
|
||||||
allowedSources: ["M01"],
|
allowedSources: ["F01", "M01"],
|
||||||
}),
|
}),
|
||||||
F11: defineRoute({
|
F11: defineRoute({
|
||||||
path: "/pages/family/platform-videos",
|
path: "/pages/family/platform-videos",
|
||||||
kind: "page",
|
kind: "page",
|
||||||
parent: "F01",
|
parent: "F10",
|
||||||
optionalParams: ["placement", "videoId"],
|
optionalParams: ["placement", "videoId"],
|
||||||
allowedSources: ["F10", "G01"],
|
allowedSources: ["F10"],
|
||||||
}),
|
}),
|
||||||
M11: defineRoute({
|
M11: defineRoute({
|
||||||
path: "/pages/profile/ceremony-invitations",
|
path: "/pages/profile/ceremony-invitations",
|
||||||
kind: "page",
|
kind: "page",
|
||||||
parent: "M01",
|
parent: "M01",
|
||||||
allowedSources: ["M01", "N02"],
|
allowedSources: ["F01", "M01", "N02"],
|
||||||
}),
|
}),
|
||||||
M12: defineRoute({
|
M12: defineRoute({
|
||||||
path: "/pages/profile/earnings",
|
path: "/pages/profile/earnings",
|
||||||
kind: "page",
|
kind: "page",
|
||||||
parent: "M01",
|
parent: "M01",
|
||||||
allowedSources: ["M01"],
|
allowedSources: ["F01", "M01"],
|
||||||
}),
|
}),
|
||||||
M13: defineRoute({
|
M13: defineRoute({
|
||||||
path: "/pages/profile/compliance-document",
|
path: "/pages/profile/compliance-document",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
const GRID_UNIT = 5;
|
const GRID_UNIT = 5;
|
||||||
const NODE_HALF_WIDTH = 80;
|
const NODE_HALF_WIDTH = 77;
|
||||||
const NODE_HALF_HEIGHT = 112;
|
const NODE_HALF_HEIGHT = 100;
|
||||||
const FAMILY_LINK_OFFSET = 18;
|
const FAMILY_LINK_OFFSET = 18;
|
||||||
const MEMBER_GAP = 178;
|
const MEMBER_GAP = 178;
|
||||||
const GENERATION_GAP = 296;
|
const GENERATION_GAP = 296;
|
||||||
|
|||||||