修改完成待测试

This commit is contained in:
2026-09-13 17:45:47 +08:00
parent 9ad572907b
commit bbc024f7f2
80 changed files with 3844 additions and 1672 deletions
+3
View File
@@ -57,8 +57,11 @@ sitemap.xml
/tmp/
/unpackage/
/.vite/
/.audit/
/design-pipeline/generated/
/tmp-g01-icon-audit.png
/tmp-mumu-current.png
/artifacts/
/docs/audit-*/
/docs/audits/
/book-style-*.png
+16 -2
View File
@@ -1,6 +1,6 @@
<template>
<view
v-if="promotionState === 'error' || promotions.length"
v-if="showEmpty || promotionState === 'error' || promotions.length"
class="promotion-strip"
:aria-label="title"
>
@@ -8,10 +8,16 @@
<view class="promotion-strip__mark" aria-hidden="true"></view>
<text>{{ title }}</text>
</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>
<button @click="loadPromotions">重新加载</button>
</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">
<view class="promotion-strip__list">
<view
@@ -63,6 +69,7 @@ import { openSiteContentTarget } from "@/utils/navigation/gateway.js";
const props = defineProps({
placement: { type: String, required: true },
title: { type: String, default: "推荐内容" },
showEmpty: { type: Boolean, default: false },
});
const promotions = ref([]);
@@ -216,6 +223,13 @@ onBeforeUnmount(() => {
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 {
margin: 0;
padding: 8rpx 18rpx;
+16 -3
View File
@@ -1,5 +1,10 @@
<template>
<view class="app-tabbar" role="tablist" aria-label="主要导航">
<view
class="app-tabbar"
:class="{ 'app-tabbar--light': tone === 'light' }"
role="tablist"
aria-label="主要导航"
>
<button
v-for="item in items"
:key="item.key"
@@ -26,7 +31,10 @@
<script setup>
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 = [
{
@@ -39,7 +47,7 @@ const items = [
},
{
key: "family",
label: "家族",
label: "消息",
routeKey: "F01",
icon: "/static/assets/foundation/transparent/tab-family.png",
activeIcon: "/static/assets/foundation/transparent/tab-family-active.png",
@@ -74,6 +82,11 @@ const switchRoot = (item) => {
background: $paper-white;
}
.app-tabbar--light {
border-top-color: #e5dac4;
background: #fffdf9;
}
.tab-item {
display: flex;
min-width: 0;
+72
View File
@@ -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>
+42
View File
@@ -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>
+12 -13
View File
@@ -123,7 +123,7 @@ const handleBack = () => {
}
.page-header-slot--root {
height: calc(124rpx + var(--status-bar-height, 0px));
height: calc(88rpx + var(--status-bar-height, 0px));
}
.page-header {
@@ -144,7 +144,7 @@ const handleBack = () => {
}
.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);
background-color: #b52e22;
overflow: hidden;
@@ -167,8 +167,8 @@ const handleBack = () => {
bottom: -2rpx;
left: -48rpx;
z-index: 1;
width: 476rpx;
height: 166rpx;
width: 408rpx;
height: 126rpx;
opacity: 0.29;
pointer-events: none;
}
@@ -313,8 +313,8 @@ const handleBack = () => {
}
.header-project-lockup--root {
top: calc(var(--status-bar-height, 0px) + 62rpx);
left: 116rpx;
top: calc(var(--status-bar-height, 0px) + 44rpx);
left: 102rpx;
}
.header-project-lockup__logo {
@@ -349,18 +349,17 @@ const handleBack = () => {
.page-header--root .header-title {
color: #ffe3a7;
font-family: "STKaiti", "KaiTi", serif;
font-size: clamp(24px, 48rpx, 30px);
letter-spacing: 5rpx;
transform: translateY(2rpx);
font-size: clamp(21px, 40rpx, 25px);
letter-spacing: 4rpx;
}
.page-header--root .header-logo {
width: 80rpx;
height: 80rpx;
transform: translate(-6rpx, -6rpx);
width: 64rpx;
height: 64rpx;
transform: translate(-2rpx, -2rpx);
}
.page-header--root .header-notice {
transform: translateY(4rpx);
transform: none;
}
</style>
+45
View File
@@ -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>
+94
View File
@@ -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>
+2 -1
View File
@@ -11,7 +11,7 @@
:src="file.accessUrl"
mode="aspectFill"
role="button"
aria-label="查看动态图片"
:aria-label="imageLabel"
@click.stop="preview(file)"
/>
</view>
@@ -22,6 +22,7 @@ import { computed } from "vue";
const props = defineProps({
files: { type: Array, default: () => [] },
imageLabel: { type: String, default: "查看动态图片" },
});
const visibleFiles = computed(() =>
+54 -44
View File
@@ -12,14 +12,21 @@
src="/static/assets/modules/genealogy/transparent/list-slip-frame.png"
mode="scaleToFill"
/>
<view class="surname-seal">
<view class="genealogy-book" aria-hidden="true">
<image
class="surname-seal-frame"
src="/static/assets/modules/genealogy/transparent/row-seal-frame.png"
mode="scaleToFill"
class="genealogy-book__cover"
src="/static/assets/foundation/opaque/book-blank.png"
mode="aspectFit"
/>
<view class="surname-seal-copy">
<text class="seal-title">家谱</text>
<view
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>
@@ -51,8 +58,8 @@
</view>
<view class="card-trailing">
<text class="card-role app-single-line">{{ role }}</text>
<text v-if="genealogy.updatedAt" class="card-updated app-single-line"
>更新 {{ genealogy.updatedAt }}</text
<text v-if="genealogy.createTime" class="card-updated app-single-line"
>创建 {{ formatGenealogyDate(genealogy.createTime) }}</text
>
</view>
</view>
@@ -70,9 +77,16 @@ const props = defineProps({
});
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(
() =>
`${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>
@@ -81,7 +95,7 @@ const accessibleLabel = computed(
--card-padding-x: 24rpx;
--card-padding-y: 20rpx;
display: grid;
grid-template-columns: 68rpx minmax(0, 1fr);
grid-template-columns: 112rpx minmax(0, 1fr);
min-height: 204rpx;
align-items: center;
column-gap: 20rpx;
@@ -106,47 +120,37 @@ const accessibleLabel = computed(
pointer-events: none;
}
.surname-seal {
display: grid;
.genealogy-book {
position: relative;
grid-column: 1;
grid-row: 1;
z-index: 2;
width: 68rpx;
height: 106rpx;
place-items: center;
color: #fff5df;
width: 112rpx;
height: 153rpx;
}
.surname-seal-frame {
z-index: 0;
.genealogy-book__cover {
display: block;
width: 100%;
height: 100%;
}
.surname-seal-frame,
.surname-seal-copy {
grid-area: 1 / 1;
}
.surname-seal-copy {
z-index: 1;
.genealogy-book__spine-title {
position: absolute;
top: 5rpx;
left: 7rpx;
display: flex;
height: 78rpx;
width: 23rpx;
height: 91rpx;
align-items: center;
justify-content: center;
flex-direction: column;
color: #2f2925;
font-family: "STKaiti", "KaiTi", "SimSun", serif;
}
.seal-title {
color: #fff5df;
font-family: "STKaiti", "KaiTi", serif;
font-size: clamp(17px, 30rpx, 22px);
font-weight: 700;
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;
.genealogy-book__spine-character {
display: block;
font-size: clamp(9.5px, 17rpx, 12px);
font-weight: 400;
line-height: 1.05;
}
.card-main {
@@ -258,15 +262,21 @@ const accessibleLabel = computed(
@media screen and (max-width: 340px) {
.genealogy-card {
--card-padding-x: 18rpx;
grid-template-columns: 62rpx minmax(0, 1fr);
grid-template-columns: 100rpx minmax(0, 1fr);
column-gap: 16rpx;
min-height: 176rpx;
padding-right: 18rpx;
padding-left: 18rpx;
}
.surname-seal {
width: 62rpx;
height: 96rpx;
.genealogy-book {
width: 100rpx;
height: 136rpx;
}
.genealogy-book__spine-title {
top: 4rpx;
left: 6rpx;
width: 21rpx;
height: 81rpx;
}
.card-name {
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>
+18 -1
View File
@@ -1,5 +1,8 @@
<template>
<view class="genealogy-page-background">
<view
class="genealogy-page-background"
:class="{ 'genealogy-page-background--light': tone === 'light' }"
>
<image
class="genealogy-page-background__art"
src="/static/assets/modules/genealogy/opaque/genealogy-page-background-long.png"
@@ -8,6 +11,12 @@
</view>
</template>
<script setup>
defineProps({
tone: { type: String, default: "default" },
});
</script>
<style scoped>
.genealogy-page-background {
position: fixed;
@@ -29,4 +38,12 @@
opacity: 0.28;
}
.genealogy-page-background--light {
background: #faf8f3;
}
.genealogy-page-background--light .genealogy-page-background__art {
opacity: 0.15;
}
</style>
+423
View File
@@ -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>
+99 -6
View File
@@ -22,15 +22,35 @@
label="新建证件档案"
@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>
<view
v-for="documentSummary in documents"
:key="documentSummary.documentId"
class="document-list-item"
role="button"
:aria-label="`查看${documentSummary.documentTitle}`"
@click="openDocument(documentSummary)"
:aria-label="documentBatch.selectionMode.value && documentSummary.canDelete ? `${documentBatch.isSelected(documentSummary) ? '取消选择' : '选择'}${documentSummary.documentTitle}` : `查看${documentSummary.documentTitle}`"
@click="handleDocumentSummaryClick(documentSummary)"
>
<BatchSelectionMark
v-if="documentBatch.selectionMode.value && documentSummary.canDelete"
:selected="documentBatch.isSelected(documentSummary)"
:label="`证件档案:${documentSummary.documentTitle}`"
@toggle="documentBatch.toggleSelection(documentSummary)"
/>
<view>
<text>{{ documentSummary.documentTitle }}</text>
<text
@@ -113,6 +133,11 @@
<button v-if="documentDialogMode === 'create'" class="document-upload-button" :disabled="documentState === 'submitting'" @click="uploadDocumentImage">
{{ documentUploadReceipt ? `已选择${documentUploadReceipt.fileName || '证件图片'}` : '添加证件图片选填' }}
</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>
<AppButton block :disabled="documentState === 'submitting'" :label="documentState === 'submitting' ? '正在保存' : '保存证件档案'" @click="saveDocument" />
</template>
@@ -129,6 +154,18 @@
</template>
</view>
</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
:visible="documentDeleteVisible"
eyebrow="重要证件"
@@ -182,6 +219,8 @@ import { computed, onUnmounted, reactive, ref, watch } from "vue";
import AppButton from "@/components/AppButton.vue";
import AppDialog from "@/components/AppDialog.vue";
import AppLoading from "@/components/AppLoading.vue";
import BatchManagementBar from "@/components/BatchManagementBar.vue";
import BatchSelectionMark from "@/components/BatchSelectionMark.vue";
import ContentPasswordRecoveryDialog from "@/components/ContentPasswordRecoveryDialog.vue";
import {
PERSON_DOCUMENT_RESOURCE_USAGE,
@@ -194,6 +233,7 @@ import {
} from "@/services/api/request-controller.js";
import { personDocumentApi } from "@/services/api/person-document-service.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 { createNonIdempotentWriteGuard } from "@/utils/non-idempotent-write-guard.js";
@@ -240,6 +280,17 @@ let documentCreateGuard = createNonIdempotentWriteGuard();
let documentResourceCreateGuard = createNonIdempotentWriteGuard();
let isActive = true;
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(() => {
switch (documentDialogMode.value) {
@@ -340,6 +391,7 @@ const resetDocumentWorkflow = () => {
documentAccessToken.value = "";
documentError.value = "";
documentUploadReceipt.value = null;
documentBatch.exitSelectionMode();
};
const open = async () => {
@@ -367,6 +419,7 @@ const open = async () => {
]);
if (!isCurrentPersonContext(documentPersonId, activeWorkflow)) return;
documents.value = documentRows;
documentBatch.exitSelectionMode();
documentTypeOptions.value = typeOptions;
documentState.value = "ready";
} catch (error) {
@@ -396,6 +449,8 @@ const openDocumentCreate = () => {
description: "",
});
documentUploadReceipt.value = null;
documentPassword.value = "";
documentPasswordConfirm.value = "";
documentDialogMode.value = "create";
documentState.value = "ready";
documentError.value = "";
@@ -454,6 +509,16 @@ const createDocument = async () => {
documentError.value = "请填写证件名称。";
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 createPayload = { lineagePersonId: documentPersonId, ...documentForm };
const createAttempt = documentCreateGuard.begin(createPayload);
@@ -467,6 +532,7 @@ const createDocument = async () => {
const activeWorkflow = workflowVersion;
const uploadReceipt = documentUploadReceipt.value;
let createdDocumentId = "";
let postCreateStage = "creating";
try {
const createdDocument = await personDocumentApi.createPersonDocument(
props.genealogyId,
@@ -475,6 +541,7 @@ const createDocument = async () => {
);
if (!isCurrentPersonContext(documentPersonId, activeWorkflow)) return;
createdDocumentId = createdDocument.documentId;
postCreateStage = "linking-file";
if (uploadReceipt?.ossId) {
await personDocumentApi.addPersonDocumentResource(
props.genealogyId,
@@ -487,6 +554,19 @@ const createDocument = async () => {
);
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();
} catch (error) {
if (
@@ -494,6 +574,8 @@ const createDocument = async () => {
isCurrentPersonContext(documentPersonId, activeWorkflow) &&
!isRequestCancelled(error)
) {
documentPassword.value = "";
documentPasswordConfirm.value = "";
await loadDocumentDetail(createdDocumentId, "");
if (
isCurrentDocumentContext(
@@ -502,12 +584,14 @@ const createDocument = async () => {
activeWorkflow,
)
) {
documentError.value =
"证件档案已创建,图片关联结果暂时无法确认。请检查文件列表后再添加,避免重复创建档案。";
documentError.value = postCreateStage === "setting-password"
? "证件档案已创建,图片也已处理,但内容密码设置结果暂时无法确认。请在详情中重新设置,当前档案可能尚未受到密码保护。"
: "证件档案已创建,但图片关联结果暂时无法确认。请检查文件列表后再添加,避免重复创建档案。";
} else if (isCurrentPersonContext(documentPersonId, activeWorkflow)) {
documentState.value = "error";
documentError.value =
"证件档案已创建,但暂时无法确认图片是否关联。请关闭后重新打开证件列表,避免重复创建档案。";
documentError.value = postCreateStage === "setting-password"
? "证件档案已创建,但内容密码设置结果暂时无法确认。请重新打开详情检查,避免重复创建档案。"
: "证件档案已创建,但暂时无法确认图片是否关联。请关闭后重新打开证件列表,避免重复创建档案。";
}
return;
}
@@ -600,6 +684,13 @@ const openDocument = async (documentSummary) => {
}
await loadDocumentDetail(documentSummary.documentId, "");
};
const handleDocumentSummaryClick = (documentSummary) => {
if (documentBatch.selectionMode.value && documentSummary?.canDelete) {
documentBatch.toggleSelection(documentSummary);
return;
}
openDocument(documentSummary);
};
const unlockDocument = async () => {
if (documentState.value === "submitting" || !selectedDocument.value) return;
documentError.value = "";
@@ -1001,6 +1092,7 @@ onUnmounted(() => {
font-size: clamp(14px, 22rpx, 17px);
}
.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 {
width: 100%;
min-height: 76rpx;
@@ -1068,4 +1160,5 @@ onUnmounted(() => {
.document-dialog-empty,
.document-dialog-error { padding: 24rpx 10rpx; text-align: center; }
.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>
+135
View File
@@ -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-module-content-frame", "output": "static/assets/modules/family/transparent/module-content-frame.png", "width": 2003, "height": 581, "alpha": true, "bytes": 1513491, "sha256": "a3686f99e32cdc255c0fc130294aab1974392d66bc8a7991daeb8f67849015ee", "provenance": "committed-binary", "rebuildable": false },
{ "id": "app-modules-family-transparent-module-field-frame", "output": "static/assets/modules/family/transparent/module-field-frame.png", "width": 2132, "height": 443, "alpha": true, "bytes": 1065770, "sha256": "bdc65f33e1dbe05ae75562519b0080e2e5cdbe2dba720a6ac49b56b99be75ceb", "provenance": "committed-binary", "rebuildable": false },
{ "id": "app-modules-genealogy-opaque-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-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 },
@@ -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-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-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-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-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-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 },
@@ -1,120 +1,239 @@
# 后端线上联调故障与数据准备清单
收件人:后端开发、运维、测试负责人
整理日期:2026-08-24
联调环境:正式接口 `https://backend-api.ddxcjp.cn`,租户 `000000`
收件人:后端开发、运维、测试负责人
## 一、结论
整理日期:2026-09-09
前端已按最新 App OpenAPI 和后端提交 `de4cc9a` 完成适配,并已移除预览数据回退。当前页面中的“无内容”分为两类:
前端项目:`jiapuapp`
| 类型 | 数量 | 结论 |
| --- | ---: | --- |
| 线上接口或配置故障 | 3 项 | 需要后端、运维处理 |
| 接口成功但测试数据为空 | 8 类 | 不是前端故障,需要后端测试环境准备数据 |
| 本轮确认的前端问题 | 2 项 | 已修复并通过自动检查 |
后端源码:`C:\Users\Rain\Desktop\job\Genealogy`
本轮前端检查结果:60 个页面与 60 条路由一致;参考项目 78 条功能路由均已有对应关系;导航恢复、契约回归和 12 项运行时资产测试全部通过。
参考项目:`C:\Users\Rain\Desktop\job\Jiapu-App`
## 二、必须处理的线上问题
本文只汇总当前仍需要后端、运维或测试环境处理的有效问题。已经由前端完成的问题不再重复要求后端开发,历史文档与本文冲突时以本文为准。
### 问题一:我的家谱接口无法完成首页读取
## 问题一(P0):线上世系排行接口成功但返回空数组
接口:`GET /genealogy/app/genealogies/mine`
### 2026-09-09 后端更新后复验
现象:2026-08-24 22:17 将本轮新构建运行到 MuMu 后,已登录账号点击“重新加载”,家谱首页仍稳定进入“暂时无法读取家谱”状态,具体提示为“家谱服务暂时无法读取,请稍后重试”。该登录态访问个人资料和其他家族业务接口能够成功,因此不能归因于模拟器断网或整体登录失效。
前端拉取后端最新更新、重新编译并同步到 MuMu 后,该问题仍未解决:
前端已确认:
- 测试成员:`MANUALCHILD01`
- 成员条件:第 2 世、男性(前端请求参数为 `generation=2&sex=0`);
- 编辑成员页“排行称谓”显示“暂无可选排行”;
- 返回树状图后,该成员节点仍显示“排行待补”;
- 同一页面的成员详情、父亲关系等数据均能正常读取,因此不是整个成员详情页或登录会话失效。
- 正式包请求地址和接口路径正确;
- 请求失败和成功空数组使用不同页面状态;
- 前端没有家谱预览数据或假数据回退;
- 本轮已增加错误类型显示。重新编译后,若后端响应结构不符合契约,会显示“服务返回的家谱数据不完整”;网络、登录、权限和服务异常也会分别提示。
前端状态逻辑会把接口异常显示为“暂时无法读取”,把成功返回的空列表显示为“暂无可选排行”。本次 MuMu 实际显示的是后者,说明请求流程已经完成,但当前条件没有取得任何可用排行记录。该判断来自页面状态;下方 2026-09-04 的四组 JSON 是此前直接请求接口取得的响应证据。
本次实际命中的是服务异常提示,不是网络异常、登录失效、无权限或前端响应结构校验异常。因此排查重点应放在线上接口业务异常、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}
```text
GET /genealogy/app/genealogies/{genealogyId}/lineage/ranks?generation=1&sex=0
GET /genealogy/app/genealogies/{genealogyId}/lineage/ranks?generation=2&sex=0
GET /genealogy/app/genealogies/{genealogyId}/lineage/ranks?generation=2&sex=1
```
这是不携带登录令牌即可复现的后端问题,与前端页面、登录态和 MuMu 无关。后端当前实现预期在未发布时返回“合规文档尚未发布”,线上却变成未知异常,说明仍需检查线上表结构、发布数据、租户数据隔离或异常处理
其中第二世男性结果必须包含“长子、次子”等,第二世女性结果必须包含“长女、次女”等;只返回 `code=200``data=[]` 仍视为未解决
请执行并提供以下检查结果:
### 当前现象
1. 合规文档首次发布及换行修复 SQL 的后置检查结果,所有 `blocking_count` 必须为 `0`
2. 租户 `000000` 下两份文档、当前版本、版本状态和内容摘要的查询结果;
3. 两条免登录请求对应的后端异常堆栈;
4. 确认请求只需要正确的 `clientid` 和租户头,不应依赖登录令牌。
前端已接入正式接口:
验收标准:两条接口均返回 `code=200`、非空标题、版本号、正文和内容摘要;不携带 Authorization 仍可读取。
`GET /genealogy/app/genealogies/{genealogyId}/lineage/ranks?generation={generation}&sex={sex}`
### 问题三:应用推广资料读取失败
2026-09-04 使用 MuMu 当前有效登录会话,针对联调家谱 `2086296260134936577`、租户 `000000` 直接请求线上环境。接口没有报错,所有请求均返回业务成功,但 `data` 是空数组:
接口:`GET /genealogy/app/referrals/me`
```text
generation=1&sex=0 -> {"code":200,"msg":"操作成功","data":[]}
generation=2&sex=0 -> {"code":200,"msg":"操作成功","data":[]}
generation=2&sex=1 -> {"code":200,"msg":"操作成功","data":[]}
generation=2&sex=2 -> {"code":200,"msg":"操作成功","data":[]}
```
现象:2026-08-24 22:21 MuMu 点击进入“应用推广”,推广内容列表能够正常展示,但顶部推荐资料卡明确显示“推荐码暂时无法读取”。这说明页面和推广内容接口正常,故障集中在推荐资料接口。前端已使用后端返回的 `shareUrl`,没有自行拼接内部用户编号
这不是前端未发请求、鉴权失败或响应解析失败。普通成员因此无法选择“长子、次子、三子”等排行,人物保存后也没有可回显的 `rankId``rankName`。世系树只能显示“排行待补”,无法准确显示其与上一辈的排行关系。配偶不使用排行选项,仍由世系树的 `spouses` 结构显示“配偶”
后端 `ReferralService.buildShareUrl` 明确依赖当前租户品牌配置中的 `h5Domain`,配置为空、格式错误或不是 HTTPS 都会直接抛出业务异常。请核对租户 `000000` 的启用品牌配置,并确保 `h5Domain` 是可访问的 HTTPS H5 注册地址。
### 已核对的后端逻辑
验收标准:接口返回 `code=200`,包含稳定推荐码、推荐人数、分享标题、分享文案和 HTTPS `shareUrl`;链接不暴露内部用户编号,打开后能进入注册流程并携带推荐凭据。
后端接口与查询实现均已存在:
## 三、需要准备的联调数据
- `AppLineagePersonController.rankOptions` 接收 `genealogyId``generation``sex`
- `AppLineagePersonServiceImpl.rankOptions` 完成家谱查看权限校验后查询排行配置;
- `LineageRankConfigServiceImpl.queryOptions` 只返回 `status=0`、性别适用且排行类型匹配的数据;
- 第一世查询 `rank_type=ANCESTOR`,其他世代查询 `rank_type=GENERATION`
- 查询表为 `gen_lineage_rank_config`
以下页面已确认能够区分“读取失败”和“成功但为空”。当前显示无内容,是接口成功返回空数组或零条记录,不属于前端渲染故障。请在测试环境为当前测试家谱准备最小可点击数据:
当前代码中的 `genealogyId` 只用于权限校验,实际排行查询没有按家谱 ID 过滤,而是读取当前租户下的公共排行配置。因此本问题优先检查租户 `000000` 的全局排行配置和租户拦截条件,不要只检查联调家谱本身。
| 数据类别 | 当前结果 | 最小验收数据 |
| --- | --- | --- |
| 家族动态 | 成功,空列表 | 1 条带图片动态、1 条纯文字动态,可进入详情 |
| 谱文 | 分类接口可读,谱文为空列表 | 1 篇带封面谱文、1 篇设有内容密码的谱文 |
| 礼仪活动 | 成功,空列表 | 1 条带封面活动,可进入详情并读取献礼列表 |
| 家族备忘 | 成功,空列表 | `general``benefactor` 各 1 条 |
| 亲友往来 | 成功,空列表 | 1 条带图片记录,可查看完整详情 |
| 功德记录 | 成功,空列表 | 1 条带金额和图片记录 |
| 家族视频与平台宣传视频 | 家族视频为空;首页平台视频因家谱首页故障暂不能完成内容态验收 | 各 1 条带封面且视频地址可播放的数据,并正确配置平台视频投放位 |
| 相册照片 | 已有相册 `CHECKDELETE01`,照片数为 0 | 在该相册中加入至少 2 张当前账号可访问的图片 |
仓库中的 `script/sql/update/2026-07-29-genealogy-lineage-generation-rank.sql` 已定义 37 条默认数据:1 条“始祖”、18 条男性排行、18 条女性排行。现有 2026-08-03 数据库备份中该表结构存在,但记录区为空。这与线上接口返回空数组一致,需由后端或运维核对线上库,不能由前端硬编码排行数据。
这些数据的文件字段必须返回当前账号可访问的 HTTPS 地址,不能只返回 OSS 文件编号。
### 后端/测试数据处理要求
## 四、本轮前端已处理
1. 先在**线上实际租户库**执行以下只读检查,确认数据是缺失、停用还是被逻辑删除:
1. 家谱首页不再把所有异常统一显示成“网络或服务不可用”,现在会区分网络、登录、权限、服务失败和响应结构不完整。
2. 谱文分类接口失败时不再静默转换为空分类;页面会明确提示“分类读取失败”,同时保留已成功读取的全部谱文。
3. 已增加对应回归检查,防止以后再次把请求失败伪装为空数据。
```sql
select rank_id, tenant_id, rank_code, rank_name, rank_type,
gender_scope, rank_order, sort_order, status, del_flag
from gen_lineage_rank_config
where tenant_id = '000000'
order by rank_type, gender_scope, rank_order;
```
## 五、后端回传材料
2. 若记录不存在,先备份线上表,再按发布流程执行并核验 `2026-07-29-genealogy-lineage-generation-rank.sql`;不要直接在前端写死“长子、次子”等数据;
3. 若记录存在,检查 `status='0'`、`del_flag='0'`、`rank_type` 和 `gender_scope` 是否满足查询条件,并检查租户隔离是否错误过滤 `000000` 数据;
4. 第一世至少返回“始祖”,其他世代按性别返回“长子、次子、长女、次女”等可用选项;
5. 保存人物 `rankId` 后,人物详情和世系树必须回显一致的 `rankId`、`rankName`
6. 已停用、已删除或不适用当前性别的排行不得出现在可选列表中;
7. 增加线上同结构数据库的集成测试,防止只验证 Mock 数据而没有验证迁移结果。
完成后请一次性提供:
### 验收标准
1. 线上实际部署提交编号和部署时间
2. 2026-08-24 迁移及所有相关后置检查结果,最终 `blocking_count=0`
3. 上述三项故障接口的完整响应示例和对应服务端日志结论
4. 测试数据所属家谱编号、数据编号和账号权限;
5. 更新后的正式 App OpenAPI(如接口字段、枚举或错误码发生变化)。
- 第二世男性请求返回至少一个男性排行选项,且包含稳定的 `rankId` 和非空 `rankName`
- 第一世请求返回“始祖”,第二世女性请求返回“长女、次女”等女性排行
- 前端选择并保存后,重新进入调整排行页面仍选中原值
- 返回世系树后节点显示保存的排行名称,不再出现“后代”兜底文案。
仅提供“代码已提交”“自动测试通过”或“域名可以访问”不能作为线上联调完成依据,最终以同一部署环境中的接口响应和 MuMu 点击回归为准。
## 问题二(待部署验收):我的家谱真实创建时间
### 当前现象
新版家谱首页需要在家谱卡片中展示“创建于”。此前以下两个正式接口只返回当前用户的加入时间 `joinTime`,没有返回家谱创建时间 `createTime`
- `GET /genealogy/app/genealogies/mine`
- `GET /genealogy/app/genealogies/{genealogyId}`
`joinTime` 表示用户加入家谱的时间,不能替代家谱创建时间。
2026-09-05 更新:后端提交 `0e8d094` 已增加只读 `createTime`,前端也已完成响应保留和“创建于”展示。当前仅等待后端部署后进行真实接口验收;线上网关现阶段返回 502,尚不能判定线上完成。
### 后端处理要求
1. 由家谱响应模型统一拥有只读字段 `createTime`,我的家谱列表和家谱详情使用同一字段定义;
2. 字段取家谱记录的真实创建时间,不取成员关系创建时间或 `joinTime`
3. 创建时间由服务端生成,创建后不可被客户端修改;
4. 已有家谱需要从现有家谱数据的创建时间完整回读,不允许只对新建家谱生效;
5. 同步更新正式 App OpenAPI、响应示例和接口契约测试。
### 验收标准
- 两个接口均稳定返回非空 `createTime`
- 同一家谱在列表和详情中的 `createTime` 完全一致;
- 不同时间加入同一家谱的成员读取到相同的 `createTime`,但各自的 `joinTime` 可以不同;
- 编辑家谱名称、地区或简介后,`createTime` 不发生变化;
- 前端接入后将卡片文案由“加入于”调整为“创建于”。
## 当前待办总览
本清单已按后端最新源码重新核对,旧文档中的以下事项已经不再列为后端待办:
- `AppGenealogyVo` 已有稳定家谱编号 `genealogyNo`,前端已接入保留;
- 功德记录已经具备请求字段 `mediaOssIds` 和响应字段 `mediaFiles`
- 谱文、成长记录、重要证件已经分别具备内容密码设置、解除、验证和短信找回接口;
- 前端可以用现有单条删除接口完成批量管理,不要求后端新增批量删除接口;
- 先前的“我的家谱无法读取”和合规文档接口异常已不再作为本轮待办。
当前需要后端、运维或测试环境处理的事项共五类:
| 优先级 | 事项 | 责任方 | 是否阻塞对应功能验收 |
| --- | --- | --- | --- |
| P0 | 世系排行接口返回空数组 | 后端/运维 | 是 |
| 待部署 | 家谱真实创建时间 | 后端/运维 | 是 |
| 待部署 | 谱文正文多图片保存与回显 | 后端/运维 | 是 |
| P1 | 应用推广 HTTPS 分享地址配置 | 后端/运维 | 是 |
| P1 | 联调环境缺少可点击业务数据 | 后端/测试/运营 | 是 |
## 问题三(待部署验收):谱文正文多图片保存与回显
### 1. 参考项目行为
参考项目 `pages/index/puwen/add.vue` 的谱文新增和编辑表单使用图片上传组件,维护 `form.imgs` 图片数组;谱文不仅有文字正文,也允许上传、删除和回显多张正文图片。
### 2. 最新实现状态
2026-09-05 更新:后端提交 `0e8d094` 已增加 `mediaOssIds`、`mediaFiles` 及文件引用和回收站处理;前端已完成正文多图添加、编辑回显、单图移除、顺序提交和详情预览。当前剩余事项是执行后端三段迁移、部署并使用真实 OSS 数据验收。
后端其他内容类型已经采用统一媒体契约,可直接沿用该方式。例如 `AppMeritRecordBody.mediaOssIds` 使用英文逗号分隔的正整数 OSS 编号,`AppMeritRecordVo.mediaFiles` 返回授权后的文件访问对象列表。
### 3. 后端处理要求
1. 数据库为谱文增加正文媒体 OSS 编号字段,名称和类型与现有内容媒体模型保持一致;
2. `Article`、创建/更新 DTO、查询 VO、Mapper 和 Service 同步增加对应字段;
3. 请求字段使用 `mediaOssIds`,格式与现有内容接口保持一致:空字符串表示清空,多项为英文逗号分隔的正整数 OSS 编号;
4. 响应字段使用 `mediaFiles`,类型为 `List<BusinessFileAccessVo>`,不得把数据库中的 OSS 编号直接暴露成可访问地址;
5. 封面 `coverFile` 与正文图片 `mediaFiles` 分开管理:封面用于列表缩略图,正文图片用于详情展示;
6. 创建、编辑、移入回收站、恢复和永久删除时,同步维护业务文件引用,避免文件被误删或形成无主引用;
7. 同步更新正式 App OpenAPI、字段格式校验、HTTP 契约测试和数据库事务测试。
涉及接口:
- `POST /genealogy/app/genealogies/{genealogyId}/articles`
- `PUT /genealogy/app/genealogies/{genealogyId}/articles/{articleId}`
- `GET /genealogy/app/genealogies/{genealogyId}/articles`
- `GET /genealogy/app/genealogies/{genealogyId}/articles/{articleId}`
### 4. 验收标准
- 新建谱文上传至少三张正文图片,保存后列表封面正常,详情按提交顺序显示三张正文图片;
- 编辑时删除一张、保留一张、新增一张,重新读取结果准确且顺序稳定;
- 提交 `mediaOssIds=""` 后正文图片清空,但 `coverFile` 不受影响;
- 无文件访问权限时不得返回可用下载地址;
- 谱文进入回收站和恢复后,正文图片引用保持一致;永久删除后按现有文件生命周期规则清理引用。
## 问题四(P1):应用推广缺少可用的 HTTPS 分享地址
涉及接口:`GET /genealogy/app/referrals/me`
后端 `ReferralService.buildShareUrl` 依赖当前租户启用品牌配置中的 `h5Domain`。请核对联调租户的品牌配置,确保:
- `h5Domain` 非空;
- 使用可公开访问的 HTTPS 地址;
- 地址指向真实 H5 注册流程;
- 服务端生成的 `shareUrl` 携带推荐凭据,但不直接暴露内部用户编号。
验收标准:接口返回 `code=200`,并包含稳定的 `referralCode`、邀请人数、分享标题、分享文案和 HTTPS `shareUrl`;前端可据此生成二维码、复制链接并进入带推荐关系的注册流程。
## 问题五(P1):联调环境缺少完整可点击业务数据
以下项目已有前端页面和接口调用,但仅有空数据时无法完成图片预览、视频播放、内容密码、编辑和删除等点击回归。请为同一测试账号、同一家谱准备最小数据:
| 数据类型 | 最小验收数据 |
| --- | --- |
| 家族动态 | 一条纯文字动态、一条带图片动态 |
| 谱文 | 一篇带封面和正文多图的普通谱文、一篇已设置内容密码的谱文 |
| 礼仪活动 | 一条带封面活动,并包含可查看的献礼记录 |
| 家族视频 | 一条带封面且视频文件可播放的数据 |
| 平台宣传视频 | `home_featured`、`video_center` 各至少一条带封面且可播放的数据 |
| 功德记录 | 一条带金额和至少两张图片的数据 |
| 家族备忘 | `general`、`benefactor` 各至少一条带图片的数据 |
| 亲友往来 | 一条带图片的记录 |
| 成长记录 | 一条带图片的普通记录、一条已设置内容密码的记录 |
| 重要证件 | 一份带图片的普通证件、一份已设置内容密码的证件 |
| 家族相册 | 一个至少包含两张可预览图片的相册 |
所有文件响应必须通过 `BusinessFileAccessVo` 返回当前账号可访问的 HTTPS 地址,不能只返回 OSS 编号。
## 前端已经完成,不需要后端重复开发
1. 谱文、成长记录、重要证件的新建表单已支持选填 8 至 128 位内容密码;前端先创建内容,再调用现有内容保护接口。第二步失败时会明确提示“内容已创建、当前可能尚未受密码保护”,不会重复创建;
2. 谱文、家族视频、礼仪、功德记录、亲友往来、家族备忘、成长记录和重要证件列表已补批量管理;只允许选择具有删除权限的内容,逐条调用现有删除接口,失败项保留选择;
3. 我的家谱响应已保留后端返回的 `genealogyNo`
4. 宣传视频无封面时使用可点击占位封面,点击后进入播放器,不再在首页直接铺开播放器;
5. 首页结构、浅色页面底色、固定双图内容区、底部导航名称和家族内容归位均已在前端完成;
6. 出生日期选择器已经限制未来年份和未来月份,不需要后端修改日期接口;
7. 世系树头像框、姓名、排行标签、配偶标签和节点布局已经按参考项目调整;后端只需解决排行选项及 `rankId`、`rankName` 回显;
8. 前端已通过项目页面、审计回归、导航恢复、参考功能映射和运行时资产检查。
## 后端完成后请回传
1. 后端提交编号和联调环境部署时间;
2. `gen_lineage_rank_config` 的线上检查结果、补数或迁移记录,以及四组排行请求的脱敏响应;
3. 家谱列表和详情接口新增 `createTime` 后的脱敏响应;
4. 谱文媒体字段的数据库迁移名称及后置检查结果;
5. 更新后的正式 App OpenAPI
6. 谱文多图创建、更新、清空、回收站和恢复的自动化测试结果;
7. `/referrals/me` 的脱敏响应示例;
8. 测试数据所属家谱编号、各资源编号和测试账号权限说明。
建议后端按“问题一 → 问题二 → 问题三 → 问题四 → 问题五”的顺序处理。问题一是当前世系树排行功能的直接阻塞项,补齐数据后即可由前端在 MuMu 上复验;问题二和问题三涉及正式响应契约,需要同时更新运行时代码、OpenAPI 和测试;问题四、问题五主要是线上配置与联调数据准备。
最终以同一部署环境中的实际接口响应和 MuMu 点击回归为准,不能只以“代码已提交”或“自动测试通过”作为联调完成依据。
+8 -1
View File
@@ -4857,6 +4857,7 @@ components:
deletePermanentlyDisabledReasons: { type: array, items: { type: string } }
verifiedMobileMasked: { type: string, nullable: true }
memberStatus: { type: string }
createTime: { type: string, format: date-time, nullable: true, readOnly: true, description: 家谱记录的真实创建时间;不同成员读取同一家谱时一致 }
joinTime: { type: string, format: date-time, nullable: true }
AppGrowthRecord:
type: object
@@ -5000,7 +5001,8 @@ components:
articleTitle: { type: string }
articleSummary: { type: string }
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 }
publishTime: { type: string, format: date-time, nullable: true }
viewCount: { type: integer, format: int64 }
@@ -6277,6 +6279,11 @@ components:
articleContent:
description: 文章内容
type: string
mediaOssIds:
type: string
nullable: true
description: 正文图片OSS ID用英文逗号分隔;创建缺失或空值为无图;更新省略保持,null或空白清空;非空整体替换,去除两侧空格并按首次出现顺序去重;仅允许正整数且规范值总长度不超过1000字符
example: '2060000000000000002,2060000000000000003'
authorName:
description: 作者名称
type: string
+9
View File
@@ -5,10 +5,19 @@
"packages": {
"": {
"name": "jiapuapp",
"dependencies": {
"qrcode-generator": "^1.4.4"
},
"devDependencies": {
"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": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.1.tgz",
+3
View File
@@ -10,5 +10,8 @@
},
"devDependencies": {
"yaml": "^2.8.1"
},
"dependencies": {
"qrcode-generator": "^1.4.4"
}
}
+12
View File
@@ -136,6 +136,18 @@
"navigationStyle": "custom"
}
},
{
"path": "pages/family/site-home",
"style": {
"navigationStyle": "custom"
}
},
{
"path": "pages/family/site-article-list",
"style": {
"navigationStyle": "custom"
}
},
{
"path": "pages/family/feed",
"style": {
+26 -7
View File
@@ -197,6 +197,8 @@
</view>
<!-- #endif -->
<LoginAdvertisement />
<view class="register-entry">
<text>还没有账号</text>
<button
@@ -278,6 +280,7 @@
import { ref } from "vue";
import { onBackPress, onShow, onUnload } from "@dcloudio/uni-app";
import AuthPageShell from "@/components/auth/PageShell.vue";
import LoginAdvertisement from "@/components/auth/LoginAdvertisement.vue";
import AppToast from "@/components/AppToast.vue";
import TacVerification from "@/components/auth/TacVerification.vue";
import {
@@ -777,11 +780,23 @@ const submitWechatLogin = async () => {
</script>
<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 {
display: flex;
flex: 1;
flex-direction: column;
justify-content: space-between;
justify-content: flex-start;
box-sizing: border-box;
width: 100%;
max-width: 480px;
@@ -817,6 +832,7 @@ const submitWechatLogin = async () => {
display: flex;
align-items: flex-end;
min-height: var(--app-touch-min);
margin-top: 6rpx;
}
.login-tab {
@@ -825,7 +841,7 @@ const submitWechatLogin = async () => {
align-items: center;
justify-content: center;
min-height: 88rpx;
color: #80684f;
color: #5d493b;
font-size: clamp(19px, 34rpx, 24px);
letter-spacing: 2rpx;
}
@@ -893,7 +909,7 @@ const submitWechatLogin = async () => {
}
.input-placeholder {
color: #9f968d;
color: #766c63;
}
.password-toggle {
@@ -925,7 +941,7 @@ const submitWechatLogin = async () => {
}
.get-code--disabled {
color: #9f968d;
color: #766c63;
}
.form-secondary-row {
@@ -945,6 +961,7 @@ const submitWechatLogin = async () => {
.login-submit {
display: grid;
margin-top: 26rpx;
place-items: center;
}
@@ -973,6 +990,7 @@ const submitWechatLogin = async () => {
align-items: center;
justify-content: center;
min-height: var(--app-touch-min);
margin-top: 8rpx;
color: #493323;
font-size: clamp(16px, 28rpx, 20px);
}
@@ -987,8 +1005,9 @@ const submitWechatLogin = async () => {
.agreement-area {
position: relative;
margin-top: 2rpx;
margin-bottom: 48rpx;
color: #58483c;
color: #493323;
}
.agreement-row {
@@ -1041,7 +1060,7 @@ const submitWechatLogin = async () => {
}
.third-party-login__divider {
color: rgba(76, 57, 41, 0.62);
color: #5f4b3c;
font-size: clamp(12px, 21rpx, 15px);
}
@@ -1059,7 +1078,7 @@ const submitWechatLogin = async () => {
}
.wechat-login[disabled] {
opacity: 0.52;
opacity: 0.68;
}
.wechat-login__mark {
+6
View File
@@ -42,6 +42,11 @@
<text class="article-card__content">{{
article.contentProtected && !article.contentUnlocked ? "" : article.content || "作者暂未填写正文。"
}}</text>
<FamilyFeedMedia
v-if="!article.contentProtected || article.contentUnlocked"
:files="article.mediaFiles"
image-label="查看谱文正文图片"
/>
<text class="article-card__views">阅读 {{ article.viewCount }} </text>
<view
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 AppLoading from "@/components/AppLoading.vue";
import ContentPasswordRecoveryDialog from "@/components/ContentPasswordRecoveryDialog.vue";
import FamilyFeedMedia from "@/components/family/FeedMedia.vue";
import ModulePageBackground from "@/components/ModulePageBackground.vue";
import PageHeader from "@/components/PageHeader.vue";
import {
+187 -8
View File
@@ -94,6 +94,35 @@
/>
</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">
<text class="editor-field__label">作者名称</text>
<view class="editor-control"
@@ -104,6 +133,30 @@
@input="submitError = ''"
/></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">{{
submitError
}}</text>
@@ -178,8 +231,14 @@ const uploading = ref(false);
const discardVisible = ref(false);
const submitError = ref("");
const uploadError = ref("");
const mediaUploadError = ref("");
const coverOssId = ref(null);
const coverFileName = ref("");
const mediaReceipts = ref([]);
const contentPassword = ref("");
const contentPasswordConfirm = ref("");
const passwordSetupError = ref("");
const passwordProtectionEnabled = ref(false);
const categoryOptionsState = ref("loading");
const categoryOptions = ref([]);
const preservedUpdateFields = ref({ sortOrder: null, status: "" });
@@ -194,7 +253,9 @@ const form = reactive({
const articleDetailController = createRequestController();
const articleCategoryController = createRequestController();
const articleCoverUploadController = createRequestController();
const articleMediaUploadController = createRequestController();
const articleSaveController = createRequestController();
const articlePasswordController = createRequestController();
const articleCreateGuard = createNonIdempotentWriteGuard();
let pageActive = true;
const categoryOptionLabels = computed(() => ["不设置分类", ...categoryOptions.value.map((item) => item.name)]);
@@ -204,13 +265,20 @@ const categoryOptionIndex = computed(() => {
});
const categoryOptionLabel = computed(() => categoryOptionLabels.value[categoryOptionIndex.value] || "不设置分类");
const isEdit = computed(() => mode.value === "edit");
const mediaOssIds = computed(() => mediaReceipts.value.map((item) => item.ossId).join(","));
const formSnapshot = computed(() =>
JSON.stringify({ ...form, coverOssId: coverOssId.value || "" }),
JSON.stringify({ ...form, coverOssId: coverOssId.value || "", mediaOssIds: mediaOssIds.value }),
);
const isDirty = computed(() =>
isEdit.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(
() =>
@@ -219,10 +287,19 @@ const hasValidContext = computed(
);
const resultCopy = computed(() =>
editorState.value === "success"
? {
? passwordSetupError.value
? {
eyebrow: "谱文已创建",
title: "密码保护尚未确认",
copy: passwordSetupError.value,
action: "打开谱文详情",
}
: {
eyebrow: "保存成功",
title: isEdit.value ? "谱文已更新" : "谱文已提交",
copy: "已保存,返回后会显示最新内容。",
copy: !isEdit.value && passwordProtectionEnabled.value
? "谱文已保存,并已启用内容密码。"
: "已保存,返回后会显示最新内容。",
action: isEdit.value ? "返回谱文详情" : "返回谱文列表",
}
: editorState.value === "error"
@@ -313,6 +390,10 @@ const loadArticleForEdit = async () => {
coverFileName.value = article.coverFile
? article.coverFile.fileName || "当前封面图片"
: "";
mediaReceipts.value = article.mediaFiles.map((file) => ({
ossId: file.ossId,
fileName: file.fileName || "正文图片",
}));
preservedUpdateFields.value = {
sortOrder: article.sortOrder,
status: article.status,
@@ -352,6 +433,35 @@ const clearCover = () => {
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 () => {
if (isSubmitting.value || uploading.value || !hasValidContext.value) return;
if (!form.articleTitle.trim() || !form.articleContent.trim()) {
@@ -363,6 +473,7 @@ const saveArticle = async () => {
const payload = {
...form,
coverOssId: coverOssId.value,
mediaOssIds: mediaOssIds.value,
...(isEdit.value ? preservedUpdateFields.value : {}),
};
const createAttempt = isEdit.value ? null : articleCreateGuard.begin(payload);
@@ -371,18 +482,48 @@ const saveArticle = async () => {
"上次提交结果暂时无法确认,请先返回谱文列表检查,避免重复创建。";
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;
submitError.value = "";
passwordSetupError.value = "";
try {
if (isEdit.value) {
await familyArticleApi.updateArticle(genealogyId.value, articleId.value, payload, {
requestController: articleSaveController,
});
} else {
await familyArticleApi.createArticle(genealogyId.value, payload, {
const createdArticle = await familyArticleApi.createArticle(genealogyId.value, payload, {
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;
editorState.value = "success";
@@ -412,7 +553,7 @@ const requestBack = () =>
});
const handleResultAction = () =>
editorState.value === "success"
? isEdit.value
? isEdit.value || passwordSetupError.value
? returnTo("F05", { genealogyId: genealogyId.value, articleId: articleId.value })
: returnTo("F04", { genealogyId: genealogyId.value })
: isEdit.value
@@ -424,7 +565,9 @@ onUnload(() => {
articleDetailController.abort();
articleCategoryController.abort();
articleCoverUploadController.abort();
articleMediaUploadController.abort();
articleSaveController.abort();
articlePasswordController.abort();
discardConfirmation.dispose();
});
</script>
@@ -532,7 +675,8 @@ onUnload(() => {
.editor-control--article textarea {
min-height: 180rpx;
}
.editor-field--cover {
.editor-field--cover,
.editor-field--media {
display: grid;
gap: 12rpx;
padding: 18rpx 22rpx;
@@ -540,7 +684,8 @@ onUnload(() => {
border-radius: 12rpx;
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;
}
.editor-field__hint {
@@ -565,6 +710,7 @@ onUnload(() => {
font-size: clamp(13px, 21rpx, 16px);
overflow-wrap: anywhere;
}
.editor-control--password-confirm { margin-top: 14rpx; }
.remove-cover-button {
justify-self: start;
min-height: 72rpx;
@@ -577,6 +723,39 @@ onUnload(() => {
font-size: clamp(13px, 21rpx, 16px);
}
.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 {
color: #9e8e79;
}
+71 -1
View File
@@ -18,12 +18,32 @@
</picker>
</view>
<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
v-for="item in filteredArticles"
:key="item.id"
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
v-if="item.coverFile?.accessUrl"
class="article-card__cover"
@@ -56,6 +76,18 @@
/>
</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>
</template>
@@ -63,6 +95,9 @@
import { computed, ref } from "vue";
import { onLoad, onShow, onUnload } from "@dcloudio/uni-app";
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 PageHeader from "@/components/PageHeader.vue";
import {
@@ -71,6 +106,7 @@ import {
} from "@/services/api/request-controller.js";
import { getRequestErrorMessage } from "@/services/api/request-error-message.js";
import { familyArticleApi } from "@/services/api/family-article-service.js";
import { useBatchDeletion } from "@/composables/use-batch-deletion.js";
import { goBack, openPage } from "@/utils/navigation/gateway.js";
const genealogyId = ref("");
@@ -82,6 +118,7 @@ const articleCategoryError = ref("");
const selectedCategoryId = ref("");
const articleListRequestController = createRequestController();
const articleCategoryRequestController = createRequestController();
const articleDeleteRequestController = createRequestController();
let pageActive = true;
let skipInitialShowRefresh = true;
const categoryOptions = computed(() => [
@@ -98,6 +135,20 @@ const filteredArticles = computed(() =>
? articles.value.filter((item) => item.categoryId === selectedCategoryId.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(() =>
hasValidContext.value
? listState.value === "empty"
@@ -140,6 +191,7 @@ onUnload(() => {
pageActive = false;
articleListRequestController.abort();
articleCategoryRequestController.abort();
articleDeleteRequestController.abort();
});
const loadArticleCategories = async () => {
articleCategoryError.value = "";
@@ -169,6 +221,7 @@ const loadArticles = async () => {
]);
if (!pageActive) return;
articles.value = rows;
articleBatch.exitSelectionMode();
if (categoryRows) categories.value = categoryRows;
if (!categoryOptions.value.some((item) => item.id === selectedCategoryId.value)) {
selectedCategoryId.value = "";
@@ -181,6 +234,7 @@ const loadArticles = async () => {
};
const selectCategory = (event) => {
selectedCategoryId.value = categoryOptions.value[Number(event.detail.value)]?.id || "";
articleBatch.exitSelectionMode();
};
const createArticle = () =>
hasValidContext.value
@@ -188,6 +242,13 @@ const createArticle = () =>
: Promise.resolve(false);
const openArticle = (item) =>
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 = () => {
if (!hasValidContext.value) return goBack();
if (listState.value === "error") return loadArticles();
@@ -214,6 +275,15 @@ const handleStateAction = () => {
.article-list-items {
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 {
margin-top: 20rpx;
padding: 18rpx 22rpx;
+1 -1
View File
@@ -291,7 +291,7 @@ onUnload(() => {
const backToFamily = () =>
hasValidContext.value
? returnTo("F01", { genealogyId: genealogyId.value })
? returnTo("F12", { genealogyId: genealogyId.value })
: goBack();
const requestBack = () => backToFamily();
const handleStateAction = () =>
+145 -8
View File
@@ -32,7 +32,7 @@
<view>
<text class="publish-field__label">动态配图</text>
<text class="publish-field__hint"
>图片上传成功后会随动态一起发布</text
>{{ isEdit ? "可新增、预览或移除图片,保存后生效。" : "图片上传成功后会随动态一起发布。" }}</text
>
</view>
<button
@@ -42,12 +42,49 @@
>
{{ isUploading ? "上传中…" : "添加图片" }}
</button>
<text
v-for="(receipt, index) in mediaReceipts"
:key="`${receipt.ossId}-${index}`"
class="upload-receipt"
>已上传{{ receipt.fileName || "图片" }}</text
>
<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
v-for="(receipt, index) in mediaReceipts"
:key="`${receipt.ossId}-${index}`"
class="upload-receipt"
>已上传{{ receipt.fileName || "图片" }}</text
>
</template>
<text v-if="uploadError" class="publish-error">{{
uploadError
}}</text>
@@ -204,6 +241,7 @@ const loadEditFeed = async (feedId) => {
mediaReceipts.value = detail.mediaFiles.map((file) => ({
ossId: file.ossId,
fileName: file.fileName,
accessUrl: file.accessUrl,
}));
editingFeed.value = {
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 () => {
if (isSubmitting.value || isUploading.value || !hasValidContext.value) return;
if (!form.feedContent.trim()) {
@@ -322,7 +376,7 @@ const requestBack = () =>
const returnToFamily = async () => {
const confirmed = isDirty.value ? await requestDiscardConfirmation() : true;
if (!confirmed) return false;
return returnTo("F01", { genealogyId: genealogyId.value });
return returnTo("F12", { genealogyId: genealogyId.value });
};
const handleResultAction = () => {
if (publishState.value === "success") {
@@ -474,6 +528,89 @@ onUnload(() => {
font-size: clamp(13px, 21rpx, 16px);
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 {
color: #8e806e;
}
+14 -89
View File
@@ -3,25 +3,16 @@
<ModulePageBackground module="family" />
<view class="family-page__header"
><PageHeader
root
title="家族动态"
custom-back
:action="hasValidContext ? '发布' : ''"
@back="requestBack"
@action="toPublish"
/></view>
<view class="feed-content">
<view class="feed-heading"
><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
v-if="feedState === 'loading'"
text="正在读取家族动态"
@@ -57,15 +48,13 @@
><text>{{ stateCopy.action }}</text></view
>
</view>
<AppTabbar active="family" />
</view>
</template>
<script setup>
import { computed, ref } from "vue";
import { onLoad, onShow, onUnload } from "@dcloudio/uni-app";
import { onBackPress, onLoad, onShow, onUnload } from "@dcloudio/uni-app";
import AppLoading from "@/components/AppLoading.vue";
import AppTabbar from "@/components/AppTabbar.vue";
import FamilyFeedMedia from "@/components/family/FeedMedia.vue";
import ModulePageBackground from "@/components/ModulePageBackground.vue";
import PageHeader from "@/components/PageHeader.vue";
@@ -75,8 +64,7 @@ import {
} from "@/services/api/request-controller.js";
import { familyFeedApi } from "@/services/api/family-feed-service.js";
import { formatMinuteTimestamp } from "@/utils/display-time.js";
import { genealogyContext } from "@/utils/genealogy/context.js";
import { goRoot, openPage } from "@/utils/navigation/gateway.js";
import { goBack, handleBackPress, openPage } from "@/utils/navigation/gateway.js";
const genealogyId = ref("");
const hasValidContext = ref(false);
@@ -108,17 +96,6 @@ const feedPreview = (content) => {
const text = String(content || "").trim();
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(() =>
!hasValidContext.value
? {
@@ -205,13 +182,7 @@ const loadMoreFeeds = async () => {
}
};
onLoad((query) => {
const supplied = Object.prototype.hasOwnProperty.call(
query || {},
"genealogyId",
);
genealogyId.value = supplied
? String(query.genealogyId || "")
: String(genealogyContext.getCurrentGenealogyId() || "");
genealogyId.value = String(query?.genealogyId || "");
hasValidContext.value = /^[1-9]\d*$/.test(genealogyId.value);
if (hasValidContext.value) void loadFeeds();
else feedState.value = "error";
@@ -230,33 +201,16 @@ onUnload(() => {
});
const toPublish = () =>
hasValidContext.value
? openPage("F02", { genealogyId: genealogyId.value, mode: "create" }, "F01")
: goRoot("G01");
? openPage("F02", { genealogyId: genealogyId.value, mode: "create" }, "F12")
: goBack();
const openFeed = (item) =>
openPage("F03", { genealogyId: genealogyId.value, feedId: item.id }, "F01");
const openSection = (key) => {
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" } : {}),
},
"F01",
);
openPage("F03", { genealogyId: genealogyId.value, feedId: item.id }, "F12");
const handlePrimaryAction = () => {
if (!hasValidContext.value) return goBack();
return feedState.value === "error" ? loadFeeds() : toPublish();
};
const handlePrimaryAction = () =>
feedState.value === "error" ? loadFeeds() : toPublish();
const requestBack = () => goBack();
onBackPress((event) => handleBackPress(event, requestBack));
</script>
<style scoped lang="scss">
@@ -273,7 +227,7 @@ const handlePrimaryAction = () =>
}
.feed-content {
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 {
display: block;
@@ -289,29 +243,6 @@ const handlePrimaryAction = () =>
color: $ink-muted;
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 {
margin-top: 22rpx;
}
@@ -347,12 +278,6 @@ const handlePrimaryAction = () =>
color: $ink-muted;
font-size: clamp(13px, 21rpx, 16px);
}
.feed-shortcut::after {
border: 0;
}
.feed-shortcut:active {
background: rgba(181, 137, 63, 0.1);
}
.feed-card__reason {
margin-top: 8rpx;
color: #9a6555;
+25 -11
View File
@@ -40,12 +40,21 @@
/>
<view class="video-card__play" aria-hidden="true"></view>
</view>
<video
<view
v-else
:src="video.videoFile.accessUrl"
controls
class="video-card__player"
/>
class="video-card__cover-button video-card__cover-placeholder"
role="button"
: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 v-if="video.description" class="video-card__copy">{{ video.description }}</text>
<text v-if="video.startAt" class="video-card__meta">发布时间{{ video.startAt }}</text>
@@ -423,12 +432,6 @@ onUnload(() => {
.video-card {
padding: 28rpx;
}
.video-card__player {
width: 100%;
height: 360rpx;
border-radius: 10rpx;
background: #1f1b17;
}
.video-card__cover-button {
position: relative;
width: 100%;
@@ -437,6 +440,17 @@ onUnload(() => {
background: #1f1b17;
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 {
width: 100%;
height: 100%;
+7
View File
@@ -0,0 +1,7 @@
<template>
<SiteHome subpage-mode />
</template>
<script setup>
import SiteHome from "@/pages/family/site-home.vue";
</script>
+354
View File
@@ -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(/&nbsp;/gi, " ")
.replace(/&amp;/gi, "&")
.replace(/&lt;/gi, "<")
.replace(/&gt;/gi, ">")
.replace(/&quot;/gi, '"')
.replace(/&#39;/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>
+62 -4
View File
@@ -129,6 +129,20 @@
<AppButton block label="发布视频" @click="openPublishForm" />
</view>
<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
block
type="secondary"
@@ -136,10 +150,16 @@
@click="openVerticalViewer(videos[0])"
/>
<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
class="video-card__cover-action"
:aria-label="`播放${video.title}`"
@click="openVerticalViewer(video)"
@click="videoBatch.selectionMode.value && video.canDelete ? videoBatch.toggleSelection(video) : openVerticalViewer(video)"
>
<image
class="video-card__cover"
@@ -156,7 +176,7 @@
video.publishTime || "刚刚发布"
}}</text>
<view
v-if="video.canEdit || video.canDelete"
v-if="!videoBatch.selectionMode.value && (video.canEdit || video.canDelete)"
class="video-card__actions"
>
<AppButton
@@ -175,7 +195,7 @@
@click="requestDeleteVideo(video)"
/>
</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" :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)" />
@@ -192,6 +212,18 @@
<AppButton block :label="stateCopy.action" @click="handleStateAction" />
</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
:visible="deleteConfirmVisible"
eyebrow="删除确认"
@@ -305,6 +337,8 @@ import { onBackPress, onLoad, onUnload } from "@dcloudio/uni-app";
import AppLoading from "@/components/AppLoading.vue";
import AppButton from "@/components/AppButton.vue";
import AppDialog from "@/components/AppDialog.vue";
import BatchManagementBar from "@/components/BatchManagementBar.vue";
import BatchSelectionMark from "@/components/BatchSelectionMark.vue";
import ModulePageBackground from "@/components/ModulePageBackground.vue";
import PageHeader from "@/components/PageHeader.vue";
import VerticalVideoViewer from "@/components/family/VerticalVideoViewer.vue";
@@ -315,6 +349,7 @@ import {
import { familyMediaApi } from "@/services/api/family-media-service.js";
import { genealogyCapabilityApi } from "@/services/api/genealogy-capability-service.js";
import { getRequestErrorMessage } from "@/services/api/request-error-message.js";
import { useBatchDeletion } from "@/composables/use-batch-deletion.js";
import {
isImagePickCancelled,
isVideoPickCancelled,
@@ -372,6 +407,19 @@ const discardConfirmation = createDiscardConfirmation((visible) => {
const confirmDiscard = discardConfirmation.confirm;
const cancelDiscard = discardConfirmation.cancel;
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(() =>
replyTarget.value ? `回复 ${replyTarget.value.author}` : "说说你的看法",
);
@@ -599,6 +647,7 @@ const loadVideos = async () => {
});
if (!pageActive) return;
videos.value = videoRows;
videoBatch.exitSelectionMode();
videoListState.value = "ready";
} catch (error) {
if (!pageActive || isRequestCancelled(error)) return;
@@ -822,7 +871,7 @@ const submitVideo = async () => {
};
const returnToFamily = () =>
hasValidContext.value
? returnTo("F01", { genealogyId: genealogyId.value })
? returnTo("G05", { genealogyId: genealogyId.value })
: goBack();
const returnToVideoList = () => {
pageState.value = "list";
@@ -833,6 +882,15 @@ const returnToVideoList = () => {
return true;
};
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) {
closeVerticalViewer();
return true;
+45 -539
View File
@@ -3,7 +3,7 @@
class="page-shell genealogy-index"
:class="{ 'genealogy-index--split': isListLayout }"
>
<GenealogyPageBackground />
<GenealogyPageBackground tone="light" />
<PageHeader
root
notice
@@ -56,69 +56,6 @@
</view>
<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
class="genealogy-list-scroll"
scroll-y
@@ -126,8 +63,13 @@
@scroll="handleListScroll"
>
<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 class="section-heading"><text>我管理的</text></view>
<GenealogyCard
v-for="genealogy in managedGenealogies"
:key="genealogy.id"
@@ -139,7 +81,7 @@
</view>
<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
v-for="genealogy in memberGenealogies"
:key="genealogy.id"
@@ -171,85 +113,33 @@
/>
</view>
<view class="featured-media-section">
<view class="featured-media-heading">
<view>
<text class="featured-media-heading__title">宣传视频</text>
<text class="featured-media-heading__copy">家谱文化与使用介绍</text>
</view>
<button class="featured-media-more" @click="openPlatformVideos">
查看更多
</button>
</view>
<view v-if="featuredVideoState === 'loading'" class="featured-media-state">
<AppLoading text="正在读取宣传视频" />
</view>
<view v-else-if="featuredVideoState === 'error'" class="featured-media-state">
<text>宣传视频暂时无法显示</text>
<button class="featured-media-retry" @click="loadFeaturedVideos">重新加载</button>
</view>
<view v-else-if="featuredVideos.length" class="featured-media-grid">
<view
v-for="video in featuredVideos"
:key="video.id"
class="featured-media-card"
>
<view
v-if="video.coverFile?.accessUrl"
class="featured-media-cover-button"
role="button"
:aria-label="`播放${video.title}`"
hover-class="action-hover"
@click="openFeaturedVideo(video)"
>
<image
class="featured-media-cover"
:src="video.coverFile.accessUrl"
mode="aspectFill"
/>
<view class="featured-media-play" aria-hidden="true"></view>
</view>
<video
v-else
class="featured-media-video"
:src="video.videoFile.accessUrl"
controls
object-fit="cover"
/>
<button class="featured-media-title" @click="openFeaturedVideo(video)">
{{ video.title }}
</button>
</view>
</view>
<view v-else class="featured-media-state featured-media-state--empty">
<text>暂时没有推荐视频</text>
<button class="featured-media-more featured-media-more--empty" @click="openPlatformVideos">
查看全部视频
</button>
</view>
</view>
<view class="create-action" @click="openAddDialog">
<view class="create-action" hover-class="action-hover" @click="openAddDialog">
<image
class="create-cloud"
src="/static/assets/modules/genealogy/transparent/create-cloud.png"
mode="aspectFit"
aria-hidden="true"
/>
<image
class="create-icon"
src="/static/assets/modules/genealogy/transparent/add.png"
mode="aspectFit"
aria-hidden="true"
/>
<text>添加家谱</text>
<image
class="create-cloud create-cloud--right"
src="/static/assets/modules/genealogy/transparent/create-cloud.png"
mode="aspectFit"
aria-hidden="true"
/>
</view>
<AppPromotionStrip placement="home_bottom" title="家谱服务推荐" />
</view>
</scroll-view>
<HomeAdvertisementPanel
class="home-content-fixed"
@open="openContentHome"
/>
</template>
<view v-else class="genealogy-empty-state">
@@ -284,12 +174,6 @@
>
<text class="empty-create-action__copy">创建家谱</text>
</view>
<AppButton
block
type="secondary"
label="观看宣传视频"
@click="openPlatformVideos"
/>
<text class="empty-create-note"
>确认没有现有家谱后再创建避免重复建谱</text
>
@@ -322,7 +206,7 @@
@saved="applySavedGenealogyOrder"
/>
<AppTabbar active="genealogy" />
<AppTabbar active="genealogy" tone="light" />
</view>
</template>
@@ -332,9 +216,9 @@ import { onBackPress, onLoad, onShow, onUnload } from "@dcloudio/uni-app";
import AppLoading from "@/components/AppLoading.vue";
import AppTabbar from "@/components/AppTabbar.vue";
import AppButton from "@/components/AppButton.vue";
import AppPromotionStrip from "@/components/AppPromotionStrip.vue";
import GenealogyAddDialog from "@/components/genealogy/AddDialog.vue";
import GenealogyCard from "@/components/genealogy/Card.vue";
import HomeAdvertisementPanel from "@/components/genealogy/HomeAdvertisementPanel.vue";
import GenealogyOrderDialog from "@/components/genealogy/OrderDialog.vue";
import GenealogyPageBackground from "@/components/genealogy/PageBackground.vue";
import GenealogySwitcherDialog from "@/components/genealogy/SwitcherDialog.vue";
@@ -345,12 +229,11 @@ import {
} from "@/services/api/request-controller.js";
import { getRequestErrorMessage } from "@/services/api/request-error-message.js";
import { genealogyApi } from "@/services/api/genealogy-service.js";
import { PLATFORM_VIDEO_PLACEMENT } from "@/services/api/family-media-contract.js";
import { genealogyCapabilityApi } from "@/services/api/genealogy-capability-service.js";
import { notificationApi } from "@/services/api/notification-service.js";
import { genealogyContext } from "@/utils/genealogy/context.js";
import {
consumeNavigationResult,
goRoot,
handleBackPress,
openPage,
runBackGuard,
@@ -372,18 +255,14 @@ const listScrollCommand = ref(0);
const currentListScrollTop = ref(0);
const unreadCount = ref(0);
const creationQuota = ref(null);
const featuredVideos = ref([]);
const featuredVideoState = ref("loading");
const genealogyListRequestController = createRequestController();
const unreadRequestController = createRequestController();
const quotaRequestController = createRequestController();
const featuredVideoRequestController = createRequestController();
// uni-app 的 abort 与成功回调可能在同一事件循环竞争。控制器负责取消任务,
// generation 再阻止已经迟到的旧响应覆盖新页面状态,两层保护不能互相替代。
let genealogyLoadGeneration = 0;
let unreadLoadGeneration = 0;
let quotaLoadGeneration = 0;
let featuredVideoLoadGeneration = 0;
let pageActive = true;
let skipNextShowRefresh = true;
@@ -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) => {
requestedGenealogyId.value = String(query?.genealogyId || "");
loadGenealogies();
loadUnreadCount();
loadQuota();
loadFeaturedVideos();
});
onShow(() => {
@@ -527,7 +380,6 @@ onShow(() => {
) {
requestedGenealogyId.value = navigationResult.entityId;
loadGenealogies();
loadFeaturedVideos();
return;
}
if (skipNextShowRefresh) {
@@ -537,7 +389,6 @@ onShow(() => {
loadGenealogies();
loadUnreadCount();
loadQuota();
loadFeaturedVideos();
});
onUnload(() => {
@@ -545,11 +396,9 @@ onUnload(() => {
genealogyLoadGeneration += 1;
unreadLoadGeneration += 1;
quotaLoadGeneration += 1;
featuredVideoLoadGeneration += 1;
genealogyListRequestController.abort();
unreadRequestController.abort();
quotaRequestController.abort();
featuredVideoRequestController.abort();
});
const hasGenealogies = computed(() => genealogies.value.length > 0);
@@ -572,40 +421,6 @@ const currentGenealogy = computed(
(genealogy) => genealogy.id === selectedGenealogyId.value,
) || 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) =>
openPage("G05", { genealogyId: String(genealogy.id) }, "G01").then(
@@ -644,21 +459,7 @@ const openSwitcher = () => {
const closeSwitcher = () => {
switcherVisible.value = false;
};
const openPlatformVideos = () =>
openPage(
"F11",
{ placement: PLATFORM_VIDEO_PLACEMENT.VIDEO_CENTER },
"G01",
);
const openFeaturedVideo = (video) =>
openPage(
"F11",
{
placement: PLATFORM_VIDEO_PLACEMENT.HOME_FEATURED,
videoId: String(video.id),
},
"G01",
);
const openContentHome = () => goRoot("F01");
const openOrderDialog = () => {
if (genealogies.value.length < 2) return;
orderDialogVisible.value = true;
@@ -714,17 +515,6 @@ const retryGenealogyLoad = () => {
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>
<style scoped lang="scss">
@@ -735,12 +525,12 @@ const openShortcut = (shortcutKey) => {
min-height: 100vh;
flex-direction: column;
overflow: hidden;
background: #f9f6ef;
background: #faf8f3;
}
.genealogy-content {
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 {
@@ -760,10 +550,6 @@ const openShortcut = (shortcutKey) => {
padding-bottom: 0;
}
.genealogy-fixed-zone {
flex: 0 0 auto;
}
.genealogy-list-scroll {
width: 100%;
height: 0;
@@ -771,151 +557,25 @@ const openShortcut = (shortcutKey) => {
flex: 1;
}
.current-slip {
@include adaptive-genealogy-current-slip;
display: block;
min-height: 300rpx;
padding: 30rpx 34rpx 28rpx;
.genealogy-list-toolbar {
display: flex;
align-items: center;
min-height: 70rpx;
padding: 0 4rpx 2rpx;
box-sizing: border-box;
}
.current-seal {
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;
.genealogy-list-heading {
flex: 1;
color: $ink;
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;
margin-bottom: 0;
}
.genealogy-lower {
padding: 12rpx 0 36rpx;
padding: 0 0 24rpx;
}
.list-section {
margin-top: 16rpx;
margin-top: 6rpx;
}
.section-heading {
@@ -951,7 +611,7 @@ const openShortcut = (shortcutKey) => {
padding: 24rpx 28rpx;
border: 1rpx solid rgba(149, 103, 49, 0.24);
border-radius: 14rpx;
background: rgba(255, 250, 240, 0.72);
background: rgba(255, 253, 249, 0.84);
box-sizing: border-box;
}
@@ -978,164 +638,17 @@ const openShortcut = (shortcutKey) => {
flex: 0 0 auto;
}
.featured-media-section {
margin: 0 0 24rpx;
padding: 26rpx 24rpx;
border: 1rpx solid rgba(149, 103, 49, 0.24);
border-radius: 14rpx;
background: rgba(255, 250, 240, 0.72);
}
.featured-media-heading {
display: flex;
align-items: center;
justify-content: space-between;
gap: 20rpx;
}
.featured-media-heading__title,
.featured-media-heading__copy {
display: block;
}
.featured-media-heading__title {
color: #5c4330;
font-size: clamp(16px, 28rpx, 20px);
font-weight: 700;
}
.featured-media-heading__copy {
margin-top: 8rpx;
color: #8a7564;
font-size: clamp(14px, 23rpx, 17px);
}
.featured-media-more,
.featured-media-retry,
.featured-media-title {
min-height: var(--app-touch-min);
margin: 0;
padding: 0;
border: 0;
background: transparent;
line-height: 1.4;
}
.featured-media-more::after,
.featured-media-retry::after,
.featured-media-title::after {
border: 0;
}
.featured-media-more,
.featured-media-retry {
flex: 0 0 auto;
color: $brand-red;
font-size: clamp(13px, 22rpx, 16px);
}
.featured-media-state {
display: flex;
min-height: 176rpx;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 10rpx;
color: $ink-muted;
font-size: clamp(14px, 23rpx, 17px);
text-align: center;
}
.featured-media-state--empty {
min-height: 138rpx;
}
.featured-media-more--empty {
margin-top: 4rpx;
}
.featured-media-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 16rpx;
margin-top: 22rpx;
}
.featured-media-card {
min-width: 0;
}
.featured-media-cover-button,
.featured-media-video {
width: 100%;
height: 176rpx;
border-radius: 10rpx;
background: #1f1b17;
overflow: hidden;
}
.featured-media-cover-button {
position: relative;
}
.featured-media-cover {
width: 100%;
height: 100%;
}
.featured-media-play {
position: absolute;
top: 50%;
left: 50%;
display: flex;
width: 58rpx;
height: 58rpx;
align-items: center;
justify-content: center;
border: 2rpx solid rgba(255, 255, 255, 0.9);
border-radius: 50%;
background: rgba(45, 29, 19, 0.62);
transform: translate(-50%, -50%);
}
.featured-media-play::after {
width: 0;
height: 0;
margin-left: 5rpx;
border-top: 10rpx solid transparent;
border-bottom: 10rpx solid transparent;
border-left: 16rpx solid #fff;
content: "";
}
.featured-media-title {
display: block;
width: 100%;
margin-top: 8rpx;
color: $ink;
font-size: clamp(14px, 24rpx, 17px);
font-weight: 600;
text-align: left;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.create-action {
display: flex;
width: 350rpx;
min-height: 96rpx;
align-items: center;
justify-content: center;
box-sizing: border-box;
font-size: clamp(17px, 30rpx, 22px);
}
.create-action {
width: 350rpx;
margin: 32rpx auto 0;
margin: 8rpx auto 24rpx;
border: 2rpx solid $brand-red;
border-radius: 48rpx;
background: rgba(255, 249, 238, 0.66);
background: rgba(255, 253, 249, 0.8);
box-sizing: border-box;
color: $brand-red;
font-family: "STKaiti", "KaiTi", serif;
font-size: clamp(17px, 32rpx, 22px);
@@ -1144,28 +657,31 @@ const openShortcut = (shortcutKey) => {
}
.create-icon {
width: 38rpx;
height: 38rpx;
margin-right: 12rpx;
}
.create-action .create-icon {
width: 40rpx;
height: 40rpx;
margin: 0 8rpx;
}
.create-cloud {
width: 54rpx;
height: 28rpx;
flex: 0 0 auto;
}
.create-action > text {
flex: 0 0 auto;
text-align: center;
}
.create-cloud--right {
transform: scaleX(-1);
}
.home-content-fixed {
flex: 0 0 auto;
margin-top: 8rpx;
}
.state-panel {
display: flex;
min-height: 540rpx;
@@ -1382,16 +898,6 @@ const openShortcut = (shortcutKey) => {
padding-right: 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 {
width: 320rpx;
}
+118 -23
View File
@@ -198,15 +198,26 @@
</template>
</view>
<view class="overview-family" @click="toFamily">
<view>
<text>家族近况</text>
<text>进入家族圈查看动态</text>
<view class="overview-family-content">
<view class="overview-family-content__heading">
<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>
<image
src="/static/assets/foundation/transparent/chevron-right.png"
mode="aspectFit"
/>
</view>
<view class="overview-note">
<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 {
goBack,
goRoot,
handleBackPress,
openPage,
returnTo,
@@ -378,7 +388,53 @@ onUnload(() => {
const reloadOverview = () => loadGenealogy({ genealogyId: genealogyId.value });
const toGenealogies = () => returnTo("G01", {});
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 = () =>
openPage("G10", { genealogyId: genealogyId.value }, "G05");
const toSettings = () =>
@@ -573,8 +629,7 @@ onBackPress((event) => handleBackPress(event, requestBack));
.overview-summary text {
display: block;
}
.overview-action__chevron,
.overview-family image {
.overview-action__chevron {
width: 26rpx;
height: 26rpx;
opacity: 0.68;
@@ -593,27 +648,67 @@ onBackPress((event) => handleBackPress(event, requestBack));
grid-template-columns: minmax(0, 1fr);
gap: 0;
}
.overview-family {
display: grid;
grid-template-columns: minmax(0, 1fr) 26rpx;
align-items: center;
gap: 20rpx;
.overview-family-content {
margin: 14rpx 42rpx 0;
padding: 20rpx 0;
padding: 24rpx 0 0;
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;
font-size: clamp(13px, 20rpx, 16px);
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;
font-family: "STKaiti", "KaiTi", serif;
font-size: clamp(16px, 27rpx, 20px);
font-size: clamp(14px, 22rpx, 17px);
font-weight: 700;
line-height: 1.35;
}
.overview-family-link::after {
border: 0;
}
.overview-note {
display: flex;
+60 -188
View File
@@ -79,36 +79,6 @@
</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>
<text>个性化推荐</text>
@@ -165,9 +135,12 @@
</view>
</view>
</view>
<HomeAdvertisementPanel
class="profile-home-advertisements"
@open="openContentHome"
/>
</template>
<AppPromotionStrip v-if="!loading && !profileHomeError" placement="profile_bottom" title="为你推荐" />
<AppTabbar active="profile" />
</view>
</template>
@@ -177,18 +150,16 @@ import { computed, reactive, ref } from "vue";
import { onPageScroll, onShow, onUnload } from "@dcloudio/uni-app";
import AppAvatar from "@/components/AppAvatar.vue";
import AppButton from "@/components/AppButton.vue";
import AppPromotionStrip from "@/components/AppPromotionStrip.vue";
import AppTabbar from "@/components/AppTabbar.vue";
import {
PROFILE_SEX_OPTIONS
} from "@/services/api/profile-contract.js";
import HomeAdvertisementPanel from "@/components/genealogy/HomeAdvertisementPanel.vue";
import {
createRequestController,
isRequestCancelled
} from "@/services/api/request-controller.js";
import { profileApi } from "@/services/api/profile-service.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({
avatarFile: null,
@@ -211,84 +182,46 @@ const preferenceSaving = ref(false);
const preferenceError = ref("");
let isPageActive = true;
const serviceGroups = [
{
title: "账户与服务",
items: [
{
routeKey: "M03",
label: "账号与安全",
iconVariant: "security",
icon: "/static/assets/modules/profile/transparent/icon-security.png",
},
{
routeKey: "N01",
label: "消息中心",
iconVariant: "notifications",
icon: "/static/assets/modules/profile/transparent/icon-message.png",
},
{
routeKey: "M11",
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",
},
],
},
const profileIcons = Object.freeze({
security: "/static/assets/modules/profile/transparent/icon-security.png",
notifications: "/static/assets/modules/profile/transparent/icon-message.png",
vip: "/static/assets/modules/profile/transparent/icon-vip.png",
help: "/static/assets/modules/profile/transparent/icon-help.png",
feedback: "/static/assets/modules/profile/transparent/icon-feedback.png",
settings: "/static/assets/modules/profile/transparent/icon-settings.png",
});
const serviceItem = (routeKey, label, iconVariant, params = null) => ({
routeKey,
label,
iconVariant,
icon: profileIcons[iconVariant],
...(params ? { params } : {}),
});
const serviceGroups = Object.freeze([
{
title: "支持与其他",
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",
label: "帮助中心",
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",
...serviceItem("F04", "家谱系列文章(图文)", "feedback"),
requiresGenealogy: true,
},
],
},
];
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 () => {
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 openSettings = () => openPage("M10", {}, "M01");
const openContentHome = () => goRoot("F01");
onShow(() => {
void loadProfile();
@@ -352,13 +297,14 @@ onShow(() => {
onPageScroll(({ scrollTop = 0 }) => {
compactHeader.value = scrollTop > 44;
});
onUnload(() => {
const disposeProfilePortal = () => {
isPageActive = false;
compactHeader.value = false;
profileReadController.abort();
preferenceReadController.abort();
preferenceSaveController.abort();
});
};
onUnload(disposeProfilePortal);
</script>
<style scoped lang="scss">
@@ -611,68 +557,6 @@ onUnload(() => {
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 {
display: flex;
align-items: center;
@@ -717,6 +601,10 @@ onUnload(() => {
margin: 49rpx 58rpx 0 48rpx;
}
.profile-home-advertisements {
margin: 28rpx 24rpx 0;
}
.profile-service-group + .profile-service-group {
margin-top: 51rpx;
}
@@ -825,22 +713,6 @@ onUnload(() => {
height: 176rpx;
}
.profile-metadata {
grid-template-columns: minmax(0, 1fr);
}
.profile-metadata__item:first-child {
border-right: 0;
}
.profile-metadata__item + .profile-metadata__item {
border-top: 1rpx solid rgba(117, 83, 52, 0.16);
}
.profile-metadata__item--email {
grid-column: auto;
}
.profile-services {
margin-right: 38rpx;
margin-left: 38rpx;
+20 -3
View File
@@ -23,9 +23,15 @@
<AppButton compact type="secondary" label="重试" @click="loadReferralProfile" />
</view>
<view v-else-if="referralProfile.enabled" class="referral-card__content">
<text class="referral-card__code">{{ referralProfile.referralCode }}</text>
<text>已成功邀请 {{ referralProfile.referredUserCount }} </text>
<text>{{ referralProfile.shareDescription || "家人通过此链接注册后,系统会记录推荐关系。" }}</text>
<view class="referral-card__details">
<ReferralQrCode :value="referralProfile.shareUrl" />
<view class="referral-card__copy">
<text class="referral-card__code">{{ referralProfile.referralCode }}</text>
<text>已成功邀请 {{ referralProfile.referredUserCount }} </text>
<text>{{ referralProfile.shareDescription || "家人通过此链接注册后,系统会记录推荐关系。" }}</text>
<text class="referral-card__qr-hint">请家人扫码打开注册链接</text>
</view>
</view>
<view class="referral-card__actions">
<AppButton compact type="secondary" label="复制推荐码" @click="copyReferralCode" />
<AppButton compact type="secondary" label="复制推荐链接" @click="copyReferralLink" />
@@ -83,6 +89,7 @@ import AppButton from "@/components/AppButton.vue";
import AppLoading from "@/components/AppLoading.vue";
import ModulePageBackground from "@/components/ModulePageBackground.vue";
import PageHeader from "@/components/PageHeader.vue";
import ReferralQrCode from "@/components/ReferralQrCode.vue";
import {
createRequestController,
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__content,
.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__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 .app-button { width: auto; min-width: 150rpx; }
.promotion-state-card .app-button { margin-top: 28rpx; }
@@ -353,5 +365,10 @@ onUnload(() => {
min-width: 0;
flex: 1 1 220rpx;
}
.referral-card__details {
flex-direction: column;
align-items: flex-start;
}
}
</style>
+71 -4
View File
@@ -30,12 +30,32 @@
><AppButton block label="新建礼仪活动" @click="createCeremony"
/></view>
<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"
:key="item.id"
class="ceremony-card"
@click="openCeremony(item)"
><image
@click="handleCeremonyClick(item)"
><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"
class="ceremony-card__cover"
:src="item.coverFile.accessUrl"
@@ -50,6 +70,18 @@
></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>
</template>
@@ -57,7 +89,10 @@
import { computed, ref } from "vue";
import { onLoad, onShow, onUnload } from "@dcloudio/uni-app";
import AppButton from "@/components/AppButton.vue";
import AppDialog from "@/components/AppDialog.vue";
import AppLoading from "@/components/AppLoading.vue";
import BatchManagementBar from "@/components/BatchManagementBar.vue";
import BatchSelectionMark from "@/components/BatchSelectionMark.vue";
import ModulePageBackground from "@/components/ModulePageBackground.vue";
import PageHeader from "@/components/PageHeader.vue";
import {
@@ -65,6 +100,7 @@ import {
isRequestCancelled
} from "@/services/api/request-controller.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 { goBack, openPage, returnTo } from "@/utils/navigation/gateway.js";
@@ -72,8 +108,22 @@ const genealogyId = ref("");
const ceremonies = ref([]);
const ceremonyListState = ref("loading");
const ceremonyListRequestController = createRequestController();
const ceremonyDeleteRequestController = createRequestController();
let pageActive = true;
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 () => {
if (!valid.value) return;
ceremonyListRequestController.abort();
@@ -84,6 +134,7 @@ const loadCeremonies = async () => {
});
if (!pageActive) return;
ceremonies.value = rows;
ceremonyBatch.exitSelectionMode();
ceremonyListState.value = ceremonies.value.length ? "ready" : "empty";
} catch (error) {
if (!pageActive || isRequestCancelled(error)) return;
@@ -91,7 +142,7 @@ const loadCeremonies = async () => {
}
};
const returnToFamily = () =>
valid.value ? returnTo("F01", { genealogyId: genealogyId.value }) : goBack();
valid.value ? returnTo("G05", { genealogyId: genealogyId.value }) : goBack();
const createCeremony = () =>
valid.value
? openPage("R07", { genealogyId: genealogyId.value, mode: "create" }, "R05")
@@ -102,6 +153,13 @@ const openCeremony = (item) =>
{ genealogyId: genealogyId.value, ceremonyId: item.id },
"R05",
);
const handleCeremonyClick = (item) => {
if (ceremonyBatch.selectionMode.value && item?.canDelete) {
ceremonyBatch.toggleSelection(item);
return;
}
openCeremony(item);
};
onLoad((query) => {
genealogyId.value = String(query?.genealogyId || "");
if (valid.value) loadCeremonies();
@@ -112,6 +170,7 @@ onShow(() => {
onUnload(() => {
pageActive = false;
ceremonyListRequestController.abort();
ceremonyDeleteRequestController.abort();
});
</script>
@@ -156,6 +215,14 @@ onUnload(() => {
flex-direction: column;
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 {
display: flex;
min-height: 138rpx;
+131 -13
View File
@@ -134,6 +134,24 @@
</view>
<text v-if="uploadError" class="error">{{ uploadError }}</text>
</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>
<view class="form-actions"
><AppButton
@@ -167,9 +185,21 @@
><AppButton block label="新建成长记录" @click="openCreate"
/></view>
<view v-else class="record-list">
<text v-if="saveNotice" class="save-notice"
>成长记录已保存</text
>
<BatchManagementBar
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>
<view class="record-filters">
<picker :range="filterPersonLabels" :value="filterPersonIndex" @change="selectFilterPerson">
@@ -185,9 +215,15 @@
:key="item.id"
class="record-card"
role="button"
:aria-label="`查看${item.title}详情`"
@click="openRecordDetail(item)"
:aria-label="growthBatch.selectionMode.value && item.canDelete ? `${growthBatch.isSelected(item) ? '取消选择' : '选择'}${item.title}` : `查看${item.title}详情`"
@click="handleGrowthRecordClick(item)"
>
<BatchSelectionMark
v-if="growthBatch.selectionMode.value && item.canDelete"
:selected="growthBatch.isSelected(item)"
:label="`成长记录:${item.title}`"
@toggle="growthBatch.toggleSelection(item)"
/>
<view
><text>{{ item.title }}</text
><text
@@ -197,14 +233,14 @@
><text v-if="item.content">{{ item.content }}</text></view
>
<AppButton
v-if="item.canEdit && personOptionsState === 'ready' && growthTypeOptionsState === 'ready'"
v-if="!growthBatch.selectionMode.value && item.canEdit && personOptionsState === 'ready' && growthTypeOptionsState === 'ready'"
compact
type="secondary"
label="编辑"
@click.stop="openEdit(item)"
/>
<AppButton
v-if="item.canDelete"
v-if="!growthBatch.selectionMode.value && item.canDelete"
compact
type="secondary"
label="删除"
@@ -219,6 +255,17 @@
@busy-change="detailBusy = $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
:visible="discardVisible"
title="放弃成长记录?"
@@ -250,6 +297,8 @@ import { onBackPress, onLoad, onShow, onUnload } from "@dcloudio/uni-app";
import AppButton from "@/components/AppButton.vue";
import AppDialog from "@/components/AppDialog.vue";
import AppLoading from "@/components/AppLoading.vue";
import BatchManagementBar from "@/components/BatchManagementBar.vue";
import BatchSelectionMark from "@/components/BatchSelectionMark.vue";
import GrowthRecordDetailDialog from "@/components/records/GrowthRecordDetailDialog.vue";
import ModulePageBackground from "@/components/ModulePageBackground.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 { lineageApi } from "@/services/api/lineage-service.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 { formatMinuteTimestamp } from "@/utils/display-time.js";
import { createNonIdempotentWriteGuard } from "@/utils/non-idempotent-write-guard.js";
@@ -277,7 +327,7 @@ const personId = ref("");
const view = ref("list");
const listState = ref("loading");
const records = ref([]);
const saveNotice = ref(false);
const saveNotice = ref("");
const submitting = ref(false);
const uploading = ref(false);
const error = ref("");
@@ -299,6 +349,8 @@ const personOptions = ref([{ value: "", label: "不关联人物" }]);
const defaultLineagePersonId = ref("");
const growthTypeOptionsState = ref("loading");
const growthTypeOptions = ref([]);
const contentPassword = ref("");
const contentPasswordConfirm = ref("");
const form = reactive({
lineagePersonId: "",
recordType: "",
@@ -314,6 +366,7 @@ const recordListRequestController = createRequestController();
const growthEditorDetailRequestController = createRequestController();
const growthMediaUploadRequestController = createRequestController();
const growthRecordSaveRequestController = createRequestController();
const growthPasswordRequestController = createRequestController();
const personOptionsController = createRequestController();
const growthTypeRequestController = 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)) &&
(!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(() =>
mediaReceipts.value.map((item) => item.ossId).join(","),
);
@@ -376,7 +443,9 @@ const dirty = computed(() =>
field !== "lineagePersonId" && String(value).trim(),
) ||
form.lineagePersonId !== defaultLineagePersonId.value ||
mediaReceipts.value.length > 0,
mediaReceipts.value.length > 0 ||
contentPassword.value ||
contentPasswordConfirm.value,
);
const confirmation = createDiscardConfirmation((visible) => {
discardVisible.value = visible;
@@ -400,6 +469,8 @@ const resetForm = () => {
remindClock: "",
});
mediaReceipts.value = [];
contentPassword.value = "";
contentPasswordConfirm.value = "";
editingRecord.value = null;
formBaseline.value = "";
error.value = "";
@@ -456,6 +527,7 @@ const loadRecords = async () => {
});
if (!pageActive) return;
records.value = rows;
growthBatch.exitSelectionMode();
listState.value = records.value.length ? "ready" : "empty";
} catch (cause) {
if (!pageActive || isRequestCancelled(cause)) return;
@@ -464,7 +536,7 @@ const loadRecords = async () => {
};
const openCreate = () => {
if (!valid.value) return;
saveNotice.value = false;
saveNotice.value = "";
resetForm();
view.value = "form";
};
@@ -563,15 +635,24 @@ const selectGrowthType = (event) => {
};
const selectFilterPerson = (event) => {
filterPersonId.value = filterPersonOptions.value[Number(event.detail.value)]?.value || "";
growthBatch.exitSelectionMode();
};
const selectFilterType = (event) => {
filterType.value = filterTypeOptions.value[Number(event.detail.value)]?.value || "";
growthBatch.exitSelectionMode();
};
const openRecordDetail = (record) =>
growthRecordDetailDialog.value?.open({
...record,
typeLabel: growthRecordTypeLabel(record),
});
const handleGrowthRecordClick = (record) => {
if (growthBatch.selectionMode.value && record?.canDelete) {
growthBatch.toggleSelection(record);
return;
}
openRecordDetail(record);
};
const uploadImage = async () => {
if (uploading.value || submitting.value) return;
uploading.value = true;
@@ -595,6 +676,16 @@ const saveGrowthRecord = async () => {
error.value = "请填写记录标题";
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 payload = {
...recordForm,
@@ -619,6 +710,7 @@ const saveGrowthRecord = async () => {
submitting.value = true;
error.value = "";
let passwordSetupNotice = "";
try {
if (editingRecord.value) {
await lifeRecordApi.updateGrowthRecord(
@@ -628,14 +720,30 @@ const saveGrowthRecord = async () => {
{ requestController: growthRecordSaveRequestController },
);
} else {
await lifeRecordApi.createGrowthRecord(genealogyId.value, payload, {
const createdRecord = await lifeRecordApi.createGrowthRecord(genealogyId.value, payload, {
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;
resetForm();
view.value = "list";
saveNotice.value = true;
saveNotice.value = passwordSetupNotice || "成长记录已保存。";
await loadRecords();
} catch (cause) {
if (!pageActive) return;
@@ -708,7 +816,13 @@ const deleteRecord = async () => {
}
};
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)
: view.value !== "form"
? goBack()
@@ -745,6 +859,7 @@ onUnload(() => {
growthEditorDetailRequestController.abort();
growthMediaUploadRequestController.abort();
growthRecordSaveRequestController.abort();
growthPasswordRequestController.abort();
personOptionsController.abort();
growthTypeRequestController.abort();
growthRecordDeleteController.abort();
@@ -822,6 +937,9 @@ onUnload(() => {
.field textarea {
min-height: 150rpx;
}
.field--password input + input {
margin-top: 14rpx;
}
.field--picker picker {
display: block;
}
+106 -20
View File
@@ -94,18 +94,46 @@
><AppButton block :label="`新建${recordName}`" @click="openCreate"
/></view>
<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"
>{{ recordName }}已保存</text
>
<text v-if="deleteError" class="delete-error">{{ deleteError }}</text>
<view v-for="item in memos" :key="item.id" class="memo-card" role="button" :aria-label="`查看${item.title}详情`" @click="openMemoDetail(item)">
<view class="memo-card__copy">
<text>{{ item.title }}</text>
<text v-if="item.remindTime">{{ item.remindTime }}</text>
<text v-if="item.createTime">创建于 {{ formatMinuteTimestamp(item.createTime) }}</text>
<text v-if="item.content" class="memo-card__content">{{ item.content }}</text>
<view 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">
<text>{{ item.title }}</text>
<text v-if="item.remindTime">{{ item.remindTime }}</text>
<text v-if="item.createTime">创建于 {{ formatMinuteTimestamp(item.createTime) }}</text>
<text v-if="item.content" class="memo-card__content">{{ item.content }}</text>
</view>
</view>
<view v-if="item.canEdit || item.canDelete" class="memo-card__actions">
<view v-if="!memoBatch.selectionMode.value && (item.canEdit || item.canDelete)" class="memo-card__actions">
<AppButton
v-if="item.canEdit"
compact
@@ -124,6 +152,17 @@
</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
:visible="Boolean(detailTarget)"
:eyebrow="`${recordName}详情`"
@@ -178,8 +217,11 @@ import { onBackPress, onLoad, onShow, onUnload } from "@dcloudio/uni-app";
import AppButton from "@/components/AppButton.vue";
import AppDialog from "@/components/AppDialog.vue";
import AppLoading from "@/components/AppLoading.vue";
import BatchManagementBar from "@/components/BatchManagementBar.vue";
import BatchSelectionMark from "@/components/BatchSelectionMark.vue";
import ModulePageBackground from "@/components/ModulePageBackground.vue";
import PageHeader from "@/components/PageHeader.vue";
import { useBatchDeletion } from "@/composables/use-batch-deletion.js";
import {
createRequestController,
isRequestCancelled
@@ -233,6 +275,19 @@ const memoDetailRequestController = createRequestController();
const memoCreateGuard = createNonIdempotentWriteGuard();
let pageActive = true;
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 recordName = computed(() => isBenefactorMode.value ? "家族恩人" : "家族备忘");
const pageTitle = computed(() => recordName.value);
@@ -285,6 +340,7 @@ const loadMemos = async () => {
});
if (!pageActive) return;
memos.value = rows.filter((memo) => memo.memoType === memoType.value);
memoBatch.exitSelectionMode();
listState.value = memos.value.length ? "ready" : "empty";
if (pendingMemoId.value) {
const target = memos.value.find((item) => item.id === pendingMemoId.value);
@@ -372,6 +428,13 @@ const openMemoDetail = async (memo) => {
detailError.value = getRequestErrorMessage(cause, "完整备忘读取失败,请稍后重试。");
}
};
const handleMemoClick = (memo) => {
if (memoBatch.selectionMode.value && memo?.canDelete) {
memoBatch.toggleSelection(memo);
return;
}
openMemoDetail(memo);
};
const closeMemoDetail = () => {
if (detailState.value === "loading") return;
detailTarget.value = null;
@@ -503,19 +566,30 @@ const deleteMemo = async () => {
if (pageActive) deleting.value = false;
}
};
const requestBack = () =>
detailTarget.value
? (closeMemoDetail(), true)
: view.value !== "form"
? goBack()
: runBackGuard({
transientOpen: discardVisible.value,
dirty: dirty.value,
submitting: submitting.value || uploading.value,
"close-transient": cancelDiscard,
"block-submitting": () => true,
"confirm-discard": confirmation.request,
});
const requestBack = () => {
if (memoBatch.deleting.value) return true;
if (memoBatch.confirmationVisible.value) {
memoBatch.cancelDelete();
return true;
}
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,
dirty: dirty.value,
submitting: submitting.value || uploading.value,
"close-transient": cancelDiscard,
"block-submitting": () => true,
"confirm-discard": confirmation.request,
});
};
onLoad((query) => {
genealogyId.value = String(query?.genealogyId || "");
@@ -708,10 +782,22 @@ onUnload(() => {
min-height: 128rpx;
padding: 28rpx 32rpx;
}
.memo-card__main {
display: flex;
align-items: flex-start;
gap: 18rpx;
}
.memo-card__copy {
min-width: 0;
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 {
display: flex;
width: 100%;
+110 -24
View File
@@ -100,6 +100,20 @@
><AppButton block label="新建功德记录" @click="openCreate"
/></view>
<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"
>{{ saveNotice }}</text
>
@@ -107,15 +121,29 @@
> {{ merits.length }} 金额合计 ¥{{ totalAmount }}</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>
<text>{{ item.title }}</text>
<text>{{ item.donor }}{{ item.typeLabel ? ` · ${item.typeLabel}` : "" }}</text>
<text v-if="item.time">{{ item.time }}</text>
<text v-if="item.createTime">创建于 {{ formatMinuteTimestamp(item.createTime) }}</text>
<text v-if="item.content">{{ item.content }}</text>
<view 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)">
<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.donor }}{{ item.typeLabel ? ` · ${item.typeLabel}` : "" }}</text>
<text v-if="item.time">{{ item.time }}</text>
<text v-if="item.createTime">创建于 {{ formatMinuteTimestamp(item.createTime) }}</text>
<text v-if="item.content">{{ item.content }}</text>
</view>
</view>
<view class="merit-card__amount">
<view v-if="!meritBatch.selectionMode.value" class="merit-card__amount">
<text>¥{{ item.amount }}</text>
<AppButton
v-if="item.canEdit"
@@ -135,6 +163,17 @@
</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
:visible="Boolean(detailTarget)"
eyebrow="功德详情"
@@ -199,9 +238,12 @@ import { onBackPress, onLoad, onShow, onUnload } from "@dcloudio/uni-app";
import AppButton from "@/components/AppButton.vue";
import AppDialog from "@/components/AppDialog.vue";
import AppLoading from "@/components/AppLoading.vue";
import BatchManagementBar from "@/components/BatchManagementBar.vue";
import BatchSelectionMark from "@/components/BatchSelectionMark.vue";
import ModulePageBackground from "@/components/ModulePageBackground.vue";
import PageHeader from "@/components/PageHeader.vue";
import { businessDictionaryApi } from "@/services/api/business-dictionary-service.js";
import { useBatchDeletion } from "@/composables/use-batch-deletion.js";
import {
createRequestController,
isRequestCancelled
@@ -263,6 +305,19 @@ const meritTypeRequestController = createRequestController();
const meritRecordCreateGuard = createNonIdempotentWriteGuard();
let pageActive = true;
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 mediaOssIds = computed(() => mediaReceipts.value.map((item) => item.ossId).join(","));
const formSnapshot = computed(() => JSON.stringify({ ...form, mediaOssIds: mediaOssIds.value }));
@@ -338,6 +393,7 @@ const loadMerits = async () => {
});
if (!pageActive) return;
merits.value = rows;
meritBatch.exitSelectionMode();
listState.value = merits.value.length ? "ready" : "empty";
} catch (cause) {
if (!pageActive || isRequestCancelled(cause)) return;
@@ -437,6 +493,13 @@ const openMeritDetail = async (merit) => {
detailError.value = getRequestErrorMessage(cause, "完整记录读取失败,请稍后重试。");
}
};
const handleMeritClick = (merit) => {
if (meritBatch.selectionMode.value && merit?.canDelete) {
meritBatch.toggleSelection(merit);
return;
}
openMeritDetail(merit);
};
const closeMeritDetail = () => {
if (detailState.value === "loading") return;
detailTarget.value = null;
@@ -591,19 +654,30 @@ const deleteMerit = async () => {
if (pageActive) deleting.value = false;
}
};
const requestBack = () =>
detailTarget.value
? (closeMeritDetail(), true)
: view.value !== "form"
? goBack()
: runBackGuard({
transientOpen: discardVisible.value,
dirty: dirty.value,
submitting: submitting.value,
"close-transient": cancelDiscard,
"block-submitting": () => true,
"confirm-discard": confirmation.request,
});
const requestBack = () => {
if (meritBatch.deleting.value) return true;
if (meritBatch.confirmationVisible.value) {
meritBatch.cancelDelete();
return true;
}
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,
dirty: dirty.value,
submitting: submitting.value,
"close-transient": cancelDiscard,
"block-submitting": () => true,
"confirm-discard": confirmation.request,
});
};
onLoad((query) => {
genealogyId.value = String(query?.genealogyId || "");
@@ -782,21 +856,33 @@ onUnload(() => {
gap: 18rpx;
padding: 28rpx 32rpx;
}
.merit-card > view {
.merit-card__main {
display: flex;
align-items: flex-start;
gap: 18rpx;
}
.merit-card__copy {
min-width: 0;
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 {
display: block;
}
.merit-card > view text:first-child {
.merit-card__copy text:first-child {
color: $ink;
font-family: STKaiti, KaiTi, serif;
font-size: clamp(17px, 30rpx, 22px);
font-weight: 700;
overflow-wrap: anywhere;
}
.merit-card > view text:not(:first-child) {
.merit-card__copy text:not(:first-child) {
margin-top: 7rpx;
color: $ink-muted;
font-size: clamp(13px, 21rpx, 16px);
+67 -4
View File
@@ -30,8 +30,28 @@
><AppButton block label="新建往来记录" @click="createRelative"
/></view>
<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>
<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
v-if="item.mediaFiles?.[0]?.accessUrl"
class="record-card__cover"
@@ -48,7 +68,7 @@
><text v-if="item.createTime">创建于 {{ formatMinuteTimestamp(item.createTime) }}</text
><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>
<AppButton
v-if="item.canEdit"
@@ -68,6 +88,17 @@
</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
:visible="Boolean(detailTarget)"
eyebrow="往来详情"
@@ -113,8 +144,11 @@ import { onBackPress, onLoad, onShow, onUnload } from "@dcloudio/uni-app";
import AppButton from "@/components/AppButton.vue";
import AppDialog from "@/components/AppDialog.vue";
import AppLoading from "@/components/AppLoading.vue";
import BatchManagementBar from "@/components/BatchManagementBar.vue";
import BatchSelectionMark from "@/components/BatchSelectionMark.vue";
import ModulePageBackground from "@/components/ModulePageBackground.vue";
import PageHeader from "@/components/PageHeader.vue";
import { useBatchDeletion } from "@/composables/use-batch-deletion.js";
import {
createRequestController,
isRequestCancelled
@@ -139,6 +173,19 @@ const detailError = ref("");
const relativeRecordDetailController = createRequestController();
let isPageActive = true;
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 () => {
if (!valid.value) return;
relativeRecordListController.abort();
@@ -149,6 +196,7 @@ const loadRecords = async () => {
});
if (!isPageActive) return;
records.value = rows;
recordBatch.exitSelectionMode();
relativeRecordListState.value = records.value.length ? "ready" : "empty";
} catch (error) {
if (!isPageActive || isRequestCancelled(error)) return;
@@ -156,7 +204,7 @@ const loadRecords = async () => {
}
};
const returnToFamily = () =>
valid.value ? returnTo("F01", { genealogyId: genealogyId.value }) : goBack();
valid.value ? returnTo("G05", { genealogyId: genealogyId.value }) : goBack();
const createRelative = () =>
valid.value
? openPage("R04", { genealogyId: genealogyId.value, mode: "create" }, "R03")
@@ -188,6 +236,13 @@ const openRecordDetail = async (record) => {
detailError.value = getRequestErrorMessage(error, "完整记录读取失败,请稍后重试。");
}
};
const handleRecordClick = (record) => {
if (recordBatch.selectionMode.value && record?.canDelete) {
recordBatch.toggleSelection(record);
return;
}
openRecordDetail(record);
};
const closeRecordDetail = () => {
if (detailState.value === "loading") return;
detailTarget.value = null;
@@ -211,7 +266,15 @@ const closeDeleteConfirmation = () => {
deleteTarget.value = null;
};
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) {
closeDeleteConfirmation();
return true;
+166 -44
View File
@@ -55,7 +55,7 @@
<picker
:range="sexOptions.map((item) => item.label)"
:value="optionIndex(sexOptions, addForm.sex)"
@change="selectOption('sex', sexOptions, $event)"
@change="selectSex"
>
<view class="form-field form-field--picker">
<text>性别</text
@@ -168,19 +168,35 @@
type="number"
placeholder="可选正整数"
placeholder-class="form-placeholder"
@blur="loadRankOptions"
/>
<text v-else>首位成员固定为 1</text>
</view>
<picker
mode="date"
:value="addForm.birthDate"
@change="selectBirthDate"
v-if="rankOptions.length"
:range="rankPickerLabels"
:value="rankOptionIndex"
:disabled="rankOptionsState !== 'ready' || isSubmitting"
@change="selectRank"
>
<view class="form-field form-field--picker">
<text>出生日期</text
><text>{{ addForm.birthDate || "请选择" }}</text>
<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
>
<view
class="form-field form-field--picker"
role="button"
aria-label="选择出生日期"
@click="openDatePicker('birthDate', '选择出生日期')"
>
<text>出生日期</text><text>{{ addForm.birthDate || "请选择" }}</text>
</view>
<picker
:range="lunarOptions.map((item) => item.label)"
:value="optionIndex(lunarOptions, addForm.birthLunar)"
@@ -260,17 +276,15 @@
/>
</view>
<picker
<view
v-if="isDeceased"
mode="date"
:value="addForm.deathDate"
@change="selectDeathDate"
class="form-field form-field--picker"
role="button"
aria-label="选择逝世日期"
@click="openDatePicker('deathDate', '选择逝世日期')"
>
<view class="form-field form-field--picker">
<text>逝世日期</text
><text>{{ addForm.deathDate || "请选择" }}</text>
</view>
</picker>
<text>逝世日期</text><text>{{ addForm.deathDate || "请选择" }}</text>
</view>
<picker
v-if="isDeceased"
:range="lunarOptions.map((item) => item.label)"
@@ -338,17 +352,15 @@
<text>关系称谓</text><text>{{ optionLabel(relationVariantOptions, addForm.relationVariantCode) || "请选择" }}</text>
</view>
</picker>
<picker
<view
v-if="isDeceased"
mode="date"
:value="addForm.burialDate"
@change="selectBurialDate"
class="form-field form-field--picker"
role="button"
aria-label="选择安葬日期"
@click="openDatePicker('burialDate', '选择安葬日期')"
>
<view class="form-field form-field--picker">
<text>安葬日期</text
><text>{{ addForm.burialDate || "请选择" }}</text>
</view>
</picker>
<text>安葬日期</text><text>{{ addForm.burialDate || "请选择" }}</text>
</view>
<view v-if="isDeceased" class="form-field">
<text>安葬地</text>
<input
@@ -373,15 +385,6 @@
}}</text>
</view>
</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">
<text>人物简介</text>
<textarea
@@ -427,6 +430,14 @@
</view>
</view>
<MemberDatePickerSheet
:visible="datePickerVisible"
:title="datePickerTitle"
:value="datePickerValue"
@cancel="closeDatePicker"
@confirm="confirmDatePicker"
/>
<AppDialog
:visible="discardDialogVisible"
eyebrow="尚未保存"
@@ -447,6 +458,7 @@ import { computed, reactive, ref } from "vue";
import { onBackPress, onLoad, onUnload } from "@dcloudio/uni-app";
import AppLoading from "@/components/AppLoading.vue";
import AppDialog from "@/components/AppDialog.vue";
import MemberDatePickerSheet from "@/components/tree/MemberDatePickerSheet.vue";
import ModulePageBackground from "@/components/ModulePageBackground.vue";
import PageHeader from "@/components/PageHeader.vue";
import {
@@ -486,10 +498,14 @@ const isAvatarUploading = ref(false);
const avatarFileName = ref("");
const avatarUploadError = ref("");
const discardDialogVisible = ref(false);
const datePickerVisible = ref(false);
const datePickerField = ref("");
const datePickerTitle = ref("选择日期");
const currentMember = ref(null);
const errorMessage = ref("");
const memberDetailRequestController = createRequestController();
const memberOptionsRequestController = createRequestController();
const rankOptionsRequestController = createRequestController();
const avatarUploadRequestController = createRequestController();
const memberCreationRequestController = createRequestController();
const sensitiveProfileRequestController = createRequestController();
@@ -503,6 +519,8 @@ const dictionaryRequestControllers = {
const memberCreationGuard = createNonIdempotentWriteGuard();
let pageActive = true;
let loadSequence = 0;
let rankLoadSequence = 0;
let rankOptionsContext = "";
const relationIntents = Object.freeze(
Object.fromEntries(
@@ -526,6 +544,7 @@ const addForm = reactive({
sex: "",
generation: "",
generationName: "",
rankId: "",
avatarOssId: "",
birthDate: "",
birthLunar: "",
@@ -548,7 +567,6 @@ const addForm = reactive({
personStatus: "",
biography: "",
remark: "",
sortOrder: "",
});
const fieldErrors = reactive({
name: "",
@@ -560,6 +578,8 @@ const fieldErrors = reactive({
});
const memberOptionsState = ref("loading");
const memberOptions = ref([]);
const rankOptionsState = ref("idle");
const rankOptions = ref([]);
const zodiacOptions = ref([]);
const educationOptions = ref([]);
const deathExpressionOptions = ref([]);
@@ -568,6 +588,7 @@ const spouseRelationVariantOptions = ref([]);
const isFirstMember = computed(() => mode.value === "first");
const isDeceased = computed(() => addForm.personStatus === "1");
const datePickerValue = computed(() => addForm[datePickerField.value] || "");
const activeRelationIntent = computed(
() => relationIntents[relationType.value] || null,
);
@@ -617,6 +638,23 @@ const formCopy = 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(() => {
if (memberCreationOutcomeUnknown.value) {
return {
@@ -675,6 +713,7 @@ const loadCurrentMember = async () => {
});
if (!pageActive || activeLoad !== loadSequence) return;
currentMember.value = member;
applyRelationDefaults();
addState.value = "form";
} catch (error) {
if (!pageActive || activeLoad !== loadSequence || isRequestCancelled(error)) return;
@@ -753,6 +792,7 @@ onLoad((query) => {
void loadMemberOptions();
void loadBusinessOptions();
if (isFirstMember.value) {
applyRelationDefaults();
addState.value = "form";
return;
}
@@ -763,6 +803,7 @@ onUnload(() => {
loadSequence += 1;
memberDetailRequestController.abort();
memberOptionsRequestController.abort();
rankOptionsRequestController.abort();
avatarUploadRequestController.abort();
memberCreationRequestController.abort();
sensitiveProfileRequestController.abort();
@@ -772,10 +813,13 @@ onUnload(() => {
const requestBack = () =>
runBackGuard({
transientOpen: discardDialogVisible.value,
transientOpen: datePickerVisible.value || discardDialogVisible.value,
dirty: addState.value === "form" && hasDraft.value,
submitting: isSubmitting.value || isAvatarUploading.value,
"close-transient": cancelDiscard,
"close-transient": () => {
if (datePickerVisible.value) return closeDatePicker();
return cancelDiscard();
},
"block-submitting": () => true,
"confirm-discard": requestDiscardConfirmation,
});
@@ -788,6 +832,7 @@ const clearError = (field) => {
const selectRelation = (event) => {
addForm.relation = relationOptions.value[Number(event.detail.value)] || "";
addForm.relationVariantCode = "";
applyRelationDefaults();
clearError("relation");
};
const optionIndex = findMemberOptionIndex;
@@ -795,6 +840,64 @@ const optionLabel = findMemberOptionLabel;
const selectOption = (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) => {
updateMemberBindingMode(addForm, event);
fieldErrors.bindingMode = "";
@@ -835,14 +938,26 @@ const uploadAvatar = async () => {
if (pageActive) isAvatarUploading.value = false;
}
};
const selectBirthDate = (event) => {
addForm.birthDate = event.detail.value || "";
const openDatePicker = (field, title) => {
datePickerField.value = field;
datePickerTitle.value = title;
datePickerVisible.value = true;
};
const selectDeathDate = (event) => {
addForm.deathDate = event.detail.value || "";
const closeDatePicker = () => {
datePickerVisible.value = false;
};
const selectBurialDate = (event) => {
addForm.burialDate = event.detail.value || "";
const confirmDatePicker = (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 = () => {
fieldErrors.name = addForm.name.trim() ? "" : "请填写成员姓名";
@@ -858,8 +973,15 @@ const validateAddForm = () => {
addForm.email.trim() && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(addForm.email.trim())
? "请填写有效的电子邮箱"
: "";
const today = localToday();
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
? "安葬日期不能早于逝世日期"
@@ -884,6 +1006,7 @@ const submitAdd = async () => {
aliasName: addForm.aliasName,
sex: addForm.sex,
generationName: addForm.generationName,
...(addForm.rankId ? { rankId: addForm.rankId } : {}),
avatarOssId: addForm.avatarOssId,
birthDate: addForm.birthDate,
birthLunar: addForm.birthLunar,
@@ -908,7 +1031,6 @@ const submitAdd = async () => {
personStatus: addForm.personStatus,
biography: addForm.biography,
remark: addForm.remark,
sortOrder: addForm.sortOrder,
...(isFirstMember.value
? { generation: 1 }
: {
+142 -42
View File
@@ -49,7 +49,7 @@
<picker
:range="sexOptions.map((item) => item.label)"
:value="optionIndex(sexOptions, editForm.sex)"
@change="selectOption('sex', sexOptions, $event)"
@change="selectSex"
>
<view class="form-field form-field--picker">
<text>性别</text
@@ -148,8 +148,28 @@
type="number"
placeholder="正整数"
placeholder-class="form-placeholder"
@blur="loadRankOptions"
/>
</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
:range="personOptionLabels"
:value="personOptionIndex(editForm.fatherId)"
@@ -170,16 +190,15 @@
><text>{{ personOptionLabel(editForm.motherId) }}</text></view
>
</picker>
<picker
mode="date"
:value="editForm.birthDate"
@change="selectDate('birthDate', $event)"
<view
class="form-field form-field--picker"
role="button"
aria-label="选择出生日期"
@click="openDatePicker('birthDate', '选择出生日期')"
>
<view class="form-field form-field--picker"
><text>出生日期</text
><text>{{ editForm.birthDate || "未填写" }}</text></view
>
</picker>
<text>出生日期</text>
<text>{{ editForm.birthDate || "未填写" }}</text>
</view>
<picker
:range="lunarOptions.map((item) => item.label)"
:value="optionIndex(lunarOptions, editForm.birthLunar)"
@@ -257,17 +276,16 @@
placeholder-class="form-placeholder"
/>
</view>
<picker
<view
v-if="isDeceased"
mode="date"
:value="editForm.deathDate"
@change="selectDate('deathDate', $event)"
class="form-field form-field--picker"
role="button"
aria-label="选择离世日期"
@click="openDatePicker('deathDate', '选择离世日期')"
>
<view class="form-field form-field--picker"
><text>离世日期</text
><text>{{ editForm.deathDate || "在世或未填写" }}</text></view
>
</picker>
<text>离世日期</text>
<text>{{ editForm.deathDate || "在世或未填写" }}</text>
</view>
<picker
v-if="isDeceased"
:range="lunarOptions.map((item) => item.label)"
@@ -329,17 +347,16 @@
class="form-note"
>遗传病史属于敏感健康信息仅在已授权时读取和保存</text
>
<picker
<view
v-if="isDeceased"
mode="date"
:value="editForm.burialDate"
@change="selectDate('burialDate', $event)"
class="form-field form-field--picker"
role="button"
aria-label="选择安葬日期"
@click="openDatePicker('burialDate', '选择安葬日期')"
>
<view class="form-field form-field--picker"
><text>安葬日期</text
><text>{{ editForm.burialDate || "未填写" }}</text></view
>
</picker>
<text>安葬日期</text>
<text>{{ editForm.burialDate || "未填写" }}</text>
</view>
<view v-if="isDeceased" class="form-field">
<text>安葬地</text>
<input
@@ -361,15 +378,6 @@
}}</text>
</view>
</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">
<text>人物简介</text>
<textarea
@@ -419,6 +427,14 @@
</view>
</view>
<MemberDatePickerSheet
:visible="datePickerVisible"
:title="datePickerTitle"
:value="datePickerValue"
@cancel="closeDatePicker"
@confirm="confirmDatePicker"
/>
<AppDialog
:visible="discardDialogVisible"
eyebrow="资料尚未保存"
@@ -439,6 +455,7 @@ import { computed, reactive, ref } from "vue";
import { onBackPress, onLoad, onUnload } from "@dcloudio/uni-app";
import AppLoading from "@/components/AppLoading.vue";
import AppDialog from "@/components/AppDialog.vue";
import MemberDatePickerSheet from "@/components/tree/MemberDatePickerSheet.vue";
import ModulePageBackground from "@/components/ModulePageBackground.vue";
import PageHeader from "@/components/PageHeader.vue";
import {
@@ -476,11 +493,15 @@ const isAvatarUploading = ref(false);
const avatarFileName = ref("");
const avatarUploadError = ref("");
const discardDialogVisible = ref(false);
const datePickerVisible = ref(false);
const datePickerField = ref("");
const datePickerTitle = ref("选择日期");
const errorMessage = ref("");
const failedAction = ref("load");
const memberDetailRequestController = createRequestController();
const personOptionsRequestController = createRequestController();
const memberOptionsRequestController = createRequestController();
const rankOptionsRequestController = createRequestController();
const avatarUploadRequestController = createRequestController();
const memberUpdateRequestController = createRequestController();
const sensitiveProfileRequestController = createRequestController();
@@ -491,6 +512,8 @@ const dictionaryRequestControllers = {
};
let pageActive = true;
let loadSequence = 0;
let rankLoadSequence = 0;
let rankOptionsContext = "";
const originalMember = ref(null);
const baseline = ref("");
@@ -504,6 +527,7 @@ const editForm = reactive({
sex: "",
generation: "",
generationName: "",
rankId: "",
fatherId: "",
motherId: "",
avatarOssId: "",
@@ -541,6 +565,8 @@ const lunarOptions = memberFormOptions.lunar;
const personStatusOptions = memberFormOptions.personStatus;
const bindingModeOptions = memberFormOptions.bindingMode;
const personOptions = ref([{ label: "不选择", value: "" }]);
const rankOptionsState = ref("idle");
const rankOptions = ref([]);
const personOptionLabels = computed(() =>
personOptions.value.map((item) => item.label),
);
@@ -565,6 +591,26 @@ const memberOptionLabels = computed(() =>
memberBindingOptions.value.map((item) => item.label),
);
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 hasValidContext = computed(() =>
@@ -654,6 +700,7 @@ const loadMember = async () => {
sex: member.sex || "",
generation: member.generation || "",
generationName: member.generationName || "",
rankId: member.rankId || "",
fatherId:
member.relatives.find((item) => item.relation === "父亲")?.id || "",
motherId:
@@ -696,6 +743,8 @@ const loadMember = async () => {
errorMessage.value = "";
failedAction.value = "load";
editState.value = "form";
rankOptionsContext = `${Number(editForm.generation)}:${String(editForm.sex || "")}`;
void loadRankOptions();
} catch (error) {
if (!pageActive || activeLoad !== loadSequence || isRequestCancelled(error)) return;
originalMember.value = null;
@@ -720,7 +769,7 @@ const loadPersonOptions = async () => {
return null;
}
const name = item.name;
return { value: personOptionId, label: `${name}${personOptionId}` };
return { value: personOptionId, label: name };
})
.filter(Boolean);
personOptions.value = [{ label: "不选择", value: "" }, ...options];
@@ -779,6 +828,32 @@ const loadMemberOptions = async () => {
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) => {
genealogyId.value = String(query?.genealogyId || "");
@@ -802,6 +877,7 @@ onUnload(() => {
memberDetailRequestController.abort();
personOptionsRequestController.abort();
memberOptionsRequestController.abort();
rankOptionsRequestController.abort();
avatarUploadRequestController.abort();
memberUpdateRequestController.abort();
sensitiveProfileRequestController.abort();
@@ -811,10 +887,13 @@ onUnload(() => {
const requestBack = () =>
runBackGuard({
transientOpen: discardDialogVisible.value,
transientOpen: datePickerVisible.value || discardDialogVisible.value,
dirty: isDirty.value,
submitting: isSubmitting.value || isAvatarUploading.value,
"close-transient": cancelDiscard,
"close-transient": () => {
if (datePickerVisible.value) return closeDatePicker();
return cancelDiscard();
},
"block-submitting": () => true,
"confirm-discard": requestDiscardConfirmation,
});
@@ -829,6 +908,16 @@ const optionLabel = findMemberOptionLabel;
const selectOption = (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) => {
updateMemberBindingMode(editForm, event);
fieldErrors.bindingMode = "";
@@ -880,9 +969,19 @@ const uploadAvatar = async () => {
if (pageActive) isAvatarUploading.value = false;
}
};
const selectDate = (field, event) => {
editForm[field] = event.detail.value || "";
const openDatePicker = (field, title) => {
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 = "";
closeDatePicker();
};
const validateEditForm = () => {
fieldErrors.name = editForm.name.trim() ? "" : "请填写成员姓名";
@@ -936,6 +1035,7 @@ const saveMember = async () => {
? Number(editForm.generation)
: undefined,
generationName: editForm.generationName,
rankId: editForm.rankId,
fatherId: editForm.fatherId,
motherId: editForm.motherId,
avatarOssId: editForm.avatarOssId,
+55 -309
View File
@@ -1,346 +1,92 @@
<template>
<view
class="rank-page"
:class="{
'rank-state--loading': rankState === 'loading',
'rank-state--form': rankState === 'form',
'rank-state--error': rankState === 'error',
}"
>
<view class="rank-redirect-page">
<ModulePageBackground module="tree" />
<view class="rank-page__header"
><PageHeader title="调整排行" custom-back @back="requestBack"
/></view>
<view class="rank-panel">
<PageHeader title="编辑成员" />
<view class="rank-redirect-panel">
<AppLoading
v-if="rankState === 'loading'"
text="正在读取成员资料"
description="请稍候,正在确认待调整人物。"
v-if="redirectState === 'loading'"
text="正在打开成员资料"
description="排行称谓已合并到成员编辑页面。"
/>
<view v-else-if="rankState === 'form' && member" class="rank-form">
<text class="form-eyebrow">同辈排行</text>
<text class="form-title">调整{{ member.name }}的排行</text>
<text class="form-copy"
>这里只调整这位成员的排行同辈成员的整体排序暂不能在这里修改</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 v-else class="rank-redirect-error">
<text class="rank-redirect-error__title">暂时无法打开成员资料</text>
<text class="rank-redirect-error__copy">{{ errorMessage }}</text>
<view class="rank-redirect-error__action" @click="goBack">
<text>返回上一页</text>
</view>
</view>
</view>
<AppDialog
:visible="discardVisible"
eyebrow="未保存修改"
title="放弃排行修改?"
message="当前排序值还没有保存。"
confirm-text="确认放弃"
cancel-text="继续编辑"
show-cancel
:close-on-mask="false"
@confirm="confirmDiscard"
@cancel="cancelDiscard"
/>
</view>
</template>
<script setup>
import { computed, ref } from "vue";
import { onBackPress, onLoad, onUnload } from "@dcloudio/uni-app";
import AppDialog from "@/components/AppDialog.vue";
import { ref } from "vue";
import { onLoad } from "@dcloudio/uni-app";
import AppLoading from "@/components/AppLoading.vue";
import ModulePageBackground from "@/components/ModulePageBackground.vue";
import PageHeader from "@/components/PageHeader.vue";
import {
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";
import { goBack, returnTo } from "@/utils/navigation/gateway.js";
const rankState = ref("loading");
const genealogyId = ref("");
const personId = ref("");
const member = ref(null);
const redirectState = ref("loading");
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 () => {
const activeLoad = ++loadSequence;
rankState.value = "loading";
onLoad(async (query) => {
const genealogyId = String(query?.genealogyId || "");
const personId = String(query?.personId || "");
if (!/^[1-9]\d*$/.test(genealogyId) || !/^[1-9]\d*$/.test(personId)) {
redirectState.value = "error";
errorMessage.value = "页面参数已经失效,请从成员资料重新进入。";
return;
}
try {
const personDetail = await lineageApi.getPerson(genealogyId.value, personId.value, {
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";
await returnTo("T05", { genealogyId, personId });
} catch (error) {
if (!pageActive || activeLoad !== loadSequence || isRequestCancelled(error)) return;
member.value = null;
errorMessage.value =
getRequestErrorMessage(error, "当前成员资料暂不可用,请返回世系树后重试。");
rankState.value = "error";
redirectState.value = "error";
errorMessage.value = error?.message || "请返回后重新打开成员资料。";
}
};
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>
<style scoped lang="scss">
@import "../../styles/adaptive-frame-profiles.scss";
.rank-page {
display: flex;
.rank-redirect-page {
min-height: 100vh;
flex-direction: column;
background: $paper;
}
.rank-page__header {
z-index: 3;
}
.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 {
.rank-redirect-panel {
position: relative;
z-index: 1;
padding: 48rpx 32rpx;
}
.rank-redirect-error {
display: flex;
align-items: center;
justify-content: center;
color: #fff9ed;
font-size: clamp(15px, 24rpx, 18px);
flex-direction: column;
gap: 24rpx;
padding: 64rpx 32rpx;
text-align: center;
}
.rank-redirect-error__title {
color: $ink;
font-size: 36rpx;
font-weight: 700;
}
.rank-result {
margin-top: 30%;
text-align: center;
.rank-redirect-error__copy {
color: $ink-muted;
font-size: 28rpx;
line-height: 1.7;
}
.rank-result .form-eyebrow,
.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>
+53 -88
View File
@@ -26,10 +26,9 @@
aria-label="回到当前成员"
hover-class="tree-action--pressed"
@click="recenterSelectedMember"
>回到当前</text
>回到当前</text
>
<text @click="treeState = 'landscape'">阅读提示</text>
<text @click="toRank">调整排行</text>
</view>
</view>
@@ -105,19 +104,14 @@
>
<view class="member-node__surface">
<view class="member-node__portrait">
<AppAvatar :sex="member.sex" />
<AppAvatar
:sex="member.sex"
:src="member.avatarFile?.accessUrl || ''"
/>
<text class="node-relation">{{ member.relation }}</text>
</view>
<image
class="member-node__divider"
src="/static/assets/foundation/transparent/auth-divider-knot.png"
mode="aspectFit"
/>
<view class="member-node__copy">
<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>
@@ -243,13 +237,6 @@ const memberActions = Object.freeze([
routeKey: "T04",
relationType: memberRelationTypes.SIBLING,
},
{
key: "ADJUST_RANK",
label: "调整排行",
group: "MANAGEMENT",
routeKey: "T06",
mode: "rank",
},
{
key: "ADD_SON",
label: "添加儿子",
@@ -282,10 +269,6 @@ const managementActions = computed(() =>
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 layoutMembers = computed(() => treeLayout.value.members);
const treeMetrics = computed(() => treeLayout.value.metrics);
@@ -493,18 +476,6 @@ const toDirectory = () =>
openPage("T07", { genealogyId: genealogyId.value }, "T01");
const toPedigree = () =>
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) => {
selectedMember.value = member;
memberActionPanelVisible.value = true;
@@ -521,8 +492,6 @@ const openMemberAction = (action) => {
};
if (action.routeKey === "T04") {
params.relationType = action.relationType;
} else if (action.routeKey === "T06") {
params.mode = action.mode;
}
return openPage(action.routeKey, params, "T01");
};
@@ -584,7 +553,7 @@ const openMemberAction = (action) => {
}
.tree-stage--lineage {
display: grid;
grid-template-columns: 72rpx minmax(0, 1fr);
grid-template-columns: 112rpx minmax(0, 1fr);
align-items: start;
}
.tree-scroll {
@@ -595,7 +564,7 @@ const openMemberAction = (action) => {
.generation-rail {
display: grid;
z-index: 4;
width: 72rpx;
width: 112rpx;
height: 100%;
box-sizing: border-box;
border-right: 1rpx solid rgba(143, 108, 63, 0.2);
@@ -627,7 +596,7 @@ const openMemberAction = (action) => {
display: grid;
justify-self: center;
z-index: 3;
width: 62rpx;
width: 100rpx;
height: 104rpx;
color: #8f6c3f;
font-size: clamp(12px, 18rpx, 14px);
@@ -647,17 +616,17 @@ const openMemberAction = (action) => {
.generation-band__copy {
z-index: 1;
display: flex;
flex-direction: row;
flex-direction: column;
align-items: center;
justify-content: space-between;
gap: 0;
justify-content: center;
gap: 4rpx;
height: 100%;
padding: 0 4rpx;
padding: 8rpx 4rpx;
box-sizing: border-box;
background: rgba(255, 252, 244, 0.9);
}
.generation-band__copy text {
writing-mode: vertical-rl;
text-orientation: upright;
line-height: 1.35;
white-space: nowrap;
}
.generation-band__copy text:last-child {
@@ -677,8 +646,8 @@ const openMemberAction = (action) => {
align-self: start;
justify-self: start;
z-index: 2;
width: 160rpx;
height: 224rpx;
width: 154rpx;
height: 200rpx;
transform: translate(-50%, -50%);
text-align: center;
}
@@ -689,85 +658,81 @@ const openMemberAction = (action) => {
flex-direction: column;
align-items: center;
box-sizing: border-box;
padding: 9rpx 8rpx 8rpx;
border: 1rpx solid #b78a42;
border-radius: 14rpx;
background: rgba(255, 252, 244, 0.96);
box-shadow: 0 5rpx 12rpx rgba(103, 72, 33, 0.12);
padding: 10rpx;
background: url("/static/assets/modules/tree/transparent/member-node-portrait.png") center / 100% 100% no-repeat;
}
.member-node__portrait {
position: relative;
display: flex;
width: 62rpx;
height: 62rpx;
width: 134rpx;
height: 126rpx;
align-items: center;
justify-content: center;
box-sizing: border-box;
border: 1rpx solid #c49a57;
border-radius: 50%;
border-radius: 2rpx;
background: #fffdf7;
overflow: hidden;
}
.member-node__divider {
width: 56rpx;
height: 14rpx;
margin-top: 3rpx;
}
.member-node__copy {
display: flex;
width: 100%;
margin-top: 1rpx;
min-height: 52rpx;
margin-top: 4rpx;
padding: 0;
align-items: center;
justify-content: center;
}
.node-name,
.node-relation,
.node-years {
.node-relation {
display: block;
}
.node-name {
display: -webkit-box;
color: #6b2419;
font-family: "STKaiti", "KaiTi", serif;
overflow: hidden;
font-size: clamp(15px, 24rpx, 18px);
font-weight: 700;
line-height: 1.1;
color: #d96529;
font-family: "STKaiti", "KaiTi", serif;
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-line-clamp: 2;
white-space: normal;
}
.node-relation {
position: absolute;
right: 50%;
bottom: 5rpx;
z-index: 1;
min-width: 54rpx;
max-width: 116rpx;
box-sizing: border-box;
overflow: hidden;
margin-top: 2rpx;
color: #644b2e;
font-size: clamp(12px, 18rpx, 14px);
line-height: 1.2;
text-overflow: ellipsis;
white-space: nowrap;
}
.node-years {
overflow: hidden;
margin-top: 2rpx;
color: #695b4c;
font-size: clamp(12px, 17rpx, 14px);
line-height: 1.2;
padding: 1rpx 6rpx;
transform: translateX(50%);
border: 1rpx solid #e66d24;
background: rgba(255, 255, 255, 0.96);
color: #e66d24;
font-size: clamp(12px, 22rpx, 15px);
line-height: 1.35;
text-overflow: ellipsis;
white-space: nowrap;
}
.member-node--spouse .member-node__surface {
border-color: #c39a61;
background: rgba(255, 250, 241, 0.98);
background-image: url("/static/assets/modules/tree/transparent/member-node-portrait.png");
}
.member-node--selected {
z-index: 3;
}
.member-node--selected .member-node__surface {
border-color: #c55344;
box-shadow: 0 6rpx 14rpx rgba(159, 23, 15, 0.16);
filter: drop-shadow(0 4rpx 5rpx rgba(159, 23, 15, 0.2));
}
.member-node--selected .member-node__portrait {
border-color: #c55344;
}
.member-node--selected .node-name {
color: $brand-red;
color: #d96529;
}
.tree-state-card {
display: grid;
+18 -30
View File
@@ -31,7 +31,7 @@
:show-scrollbar="false"
: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__relation"><text>身份</text></view>
<view class="pedigree-column__name"><text>{{ page.label }}</text></view>
@@ -40,15 +40,14 @@
<view
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--blank': !member,
'member-node--selected': selectedId === String(member?.id || ''),
'member-node--spouse': Boolean(member?.spouseOf),
'member-node--selected': selectedId === String(member.id),
'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__name"
@@ -65,22 +64,16 @@
:aria-label="`查看${member.name}的生平详情`"
@click.stop="openMemberDetail(member)"
>
<text class="node-relation">{{ member.branch }}</text>
<text class="node-years">{{ memberDetail(member) }}</text>
<text>{{ memberDetailText(member) }}</text>
</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>
</scroll-view>
</swiper-item>
</swiper>
<view class="generation-rail" :style="pedigreeSheetStyle">
<view class="generation-rail" :style="pedigreeHeightStyle">
<view class="generation-band">
<image
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 { handleBackPress, openPage, returnTo } from "@/utils/navigation/gateway.js";
const PEDIGREE_PAGE_SIZE = 5;
const PEDIGREE_PAGE_SIZE = 3;
const genealogyId = ref("");
const treeState = ref("loading");
const treeLoadError = ref("");
@@ -198,14 +191,13 @@ const pedigreePages = computed(() => {
const branch = row.members.find((member) => member.branch)?.branch;
for (let index = 0; index < row.members.length; 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({
generation: row.generation,
label: row.label,
memberCount: row.members.length,
summary: [branch, `${row.members.length} 位成员`]
summary: [branch, `${row.members.length}位成员`]
.filter(Boolean)
.join(" · "),
.join("·"),
members: pageMembers,
});
}
@@ -219,14 +211,18 @@ const detailLength = computed(() =>
Math.max(
0,
...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 pedigreeSheetStyle = computed(() => ({
const pedigreeHeightStyle = computed(() => ({
height: `${sheetHeight.value}rpx`,
}));
const getPedigreeSheetStyle = (page) => ({
...pedigreeHeightStyle.value,
gridTemplateColumns: `104rpx repeat(${page.members.length}, minmax(0, 1fr))`,
});
const pedigreeScrollStyle = computed(() => ({
height: `${sheetHeight.value}rpx`,
}));
@@ -254,6 +250,8 @@ const handlePedigreePageChange = (event) => {
const memberRelation = (member) =>
member.spouseOf ? "配偶" : member.relation || "家谱成员";
const memberDetail = (member) => member.treeYears || member.years || "生卒待补";
const memberDetailText = (member) =>
[member.branch, memberDetail(member)].filter(Boolean).join("·");
const openMemberProfile = (member) =>
openPage(
"T03",
@@ -463,9 +461,6 @@ onUnload(() => {
.member-node__surface {
display: contents;
}
.member-node--blank {
pointer-events: none;
}
.member-node--selected .member-node__relation,
.member-node--selected .member-node__name,
.member-node--selected .member-node__copy {
@@ -477,13 +472,6 @@ onUnload(() => {
.member-node--spouse .member-node__relation {
color: #7a4d43;
}
.node-relation {
color: #675f57;
}
.node-years {
color: #3f3934;
text-combine-upright: all;
}
.generation-rail {
width: 92rpx;
border-left: 1rpx solid #e2e2e2;
+176 -12
View File
@@ -16,9 +16,17 @@ const listPageSources = (directory = 'pages') => fs.readdirSync(path.join(worksp
})
const signIn = read('pages/auth/sign-in.vue')
const loginAdvertisement = read('components/auth/LoginAdvertisement.vue')
const register = read('pages/auth/register.vue')
const routes = read('utils/navigation/routes.js')
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 pedigree = read('pages/tree/pedigree.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 vipPage = read('pages/profile/vip.vue')
const promotionsPage = read('pages/profile/promotions.vue')
const referralQrCode = read('components/ReferralQrCode.vue')
const helpPage = read('pages/profile/help.vue')
const editProfilePage = read('pages/profile/edit-profile.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 platformVideosPage = read('pages/family/platform-videos.vue')
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 = [
'pages/tree/pedigree.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 idValidatedPages = [
'pages/tree/add-relative.vue',
'pages/tree/member-rank.vue',
'pages/tree/member-profile.vue',
'pages/tree/member-states.vue',
'pages/genealogy/generation-poems.vue',
@@ -92,7 +106,6 @@ const genealogyContract = read('services/api/genealogy-contract.js')
const genealogySearchPage = read('pages/genealogy/search.vue')
const generationPoemService = read('services/api/generation-poem-service.js')
const genealogyMemberService = read('services/api/genealogy-member-service.js')
const lifeRecordService = read('services/api/life-record-service.js')
const personDocumentService = read('services/api/person-document-service.js')
const mediaUpload = read('utils/media-upload.js')
const businessFileContract = read('services/api/business-file-contract.js')
@@ -119,6 +132,29 @@ expect(!signIn.includes('协议页面准备中'), '登录页仍使用协议占
expect(!register.includes('协议页面准备中'), '注册页仍使用协议占位提示')
expect(signIn.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(
/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('"A04"'), '协议正文路由不允许从注册页进入')
const membersRoute = routes.match(/G13: defineRoute\(\{[\s\S]*?^\s{2}\}\),/m)?.[0] || ''
expect(membersRoute.includes('"G01"'), '成员页路由不允许从家谱首页进入')
expect(!membersRoute.includes('"G01"'), '成员页允许从家谱首页旧入口进入')
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] || ''
expect(platformVideosRoute.includes('"G01"'), '宣传视频路由不允许从家谱首页进入')
expect(!platformVideosRoute.includes('"G01"') && platformVideosRoute.includes('"F10"'), '宣传视频没有迁回原视频导航')
expect(platformVideosRoute.includes('"videoId"'), '宣传视频路由不能携带指定视频 ID')
expect(
familyMediaContract.includes("HOME_FEATURED: 'home_featured'") &&
@@ -142,10 +180,72 @@ expect(
familyMediaContract.includes("PROFILE_FEATURED: 'profile_featured'"),
'平台视频契约没有完整覆盖后端三个投放位',
)
expect(genealogyHome.includes('featured-media-grid'), '家谱首页没有宣传视频封面预览')
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(
platformVideosPage.includes('video-card__cover-button') &&
@@ -157,6 +257,10 @@ expect(
genealogyHome.includes('getRequestErrorMessage'),
'家谱首页仍会隐藏家谱列表的真实失败类型',
)
expect(
/import\s*\{[^}]*normalizeOptionalNumericId[^}]*\}\s*from '\.\/response-normalizers\.js'/.test(genealogyContract),
'家谱响应契约使用 normalizeOptionalNumericId 时没有从响应归一化模块导入',
)
expect(
articleListPage.includes('articleCategoryError') &&
articleListPage.includes('getRequestErrorMessage'),
@@ -240,6 +344,11 @@ expect(
/promotion-card__actions[^>]*@click\.stop/.test(promotionsPage),
'推广操作按钮会继续触发卡片跳转',
)
expect(
promotionsPage.includes('<ReferralQrCode :value="referralProfile.shareUrl"') &&
referralQrCode.includes('class="referral-qr-code"'),
'推广页没有根据后端 shareUrl 展示推荐二维码',
)
expect(
/\.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(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"'), '成长记录仍把自动带入人物直接判为用户修改')
const joinApplicationRoute = routes.match(/G08: defineRoute\(\{[\s\S]*?^\s{2}\}\),/m)?.[0] || ''
expect(joinApplicationRoute.includes('"genealogyName"'), '公开家谱申请页路由仍拒绝谱名参数')
@@ -353,9 +469,10 @@ for (const [pageName, pageSource] of [
for (const pagePath of idValidatedPages) {
expect(read(pagePath).includes('/^[1-9]\\d*$/.test(genealogyId.value)'), `${pagePath} 没有校验家谱 ID`)
}
expect(memberRankPage.includes('createDiscardConfirmation'), '成员排行页缺少未保存修改确认')
expect(memberRankPage.includes('dirty: isDirty.value'), '成员排行页返回守卫未检查排序修改')
expect(memberRankPage.includes('sortOrderBaseline.value = normalizedSortOrder'), '成员排行保存成功后没有更新草稿基准')
expect(memberRankPage.includes('returnTo("T05", { genealogyId, personId })'), '成员排行页没有统一转入成员编辑页')
expect(!memberRankPage.includes('lineageApi.updatePerson'), '成员排行页仍保留独立保存逻辑')
expect(!memberRankPage.includes('<text>排序值</text>'), '独立排行页仍要求普通用户手填排序值')
expect(!memberRankPage.includes('updatePersonSortOrder'), '独立排行页仍会提交手工排序值')
expect(editProfilePage.includes('original[field] = payload[field]'), '编辑资料保存成功后没有更新草稿基准')
expect(meritRecordsPage.includes('/^(?:0|[1-9]\\d{0,9})(?:\\.\\d{1,2})?$/'), '功德记录页面没有限制金额精度和范围')
expect(lifeRecordContract.includes("normalizeOptionalCurrencyNumber(payload.amount, '功德金额')"), '功德记录 API 契约没有限制金额精度和范围')
@@ -369,6 +486,21 @@ expect(
editMemberPage.match(/\/\^\[1-9\]\\d\*\$\/.test/g)?.length >= 4,
'修改成员页没有完整校验家谱和人物 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(
/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')]) {
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.groupName'), '权限目录没有接入后端权限分组')
expect(genealogyCapabilityService.includes('/comments/page`'), '家族视频根评论仍调用旧列表路径')
+11
View File
@@ -8,6 +8,7 @@ import {
import {
assertPlainPayload,
normalizeOptionalNormalDisableStatus,
normalizeOptionalOssIdList,
normalizeOptionalSafeInteger,
normalizeOptionalText,
normalizeOssIdString,
@@ -15,6 +16,7 @@ import {
} from './request-normalizers.js'
import {
normalizeBusinessFileAccess,
normalizeBusinessFileAccessRows,
normalizeContentProtectionCapabilities,
normalizeResourceCapabilities
} from './business-file-contract.js'
@@ -67,6 +69,7 @@ export const normalizeAppArticle = (value, expectedGenealogyId, expectedArticleI
categoryId: normalizeOptionalNumericId(value.categoryId, '谱文分类标识', 'ARTICLE_RESPONSE_INVALID'),
category: normalizeResponseText(value.categoryName, 'categoryName'),
coverFile: normalizeBusinessFileAccess(value.coverFile, '谱文封面', 'ARTICLE_RESPONSE_INVALID'),
mediaFiles: normalizeBusinessFileAccessRows(value.mediaFiles, '谱文正文图片', 'ARTICLE_RESPONSE_INVALID'),
viewCount: normalizeOptionalNonnegativeInteger(value.viewCount, '谱文阅读数', 'ARTICLE_RESPONSE_INVALID'),
sortOrder: normalizeOptionalNonnegativeInteger(value.sortOrder, '谱文排序值', 'ARTICLE_RESPONSE_INVALID'),
status: normalizeNormalDisableResponseStatus(value.status, '谱文状态', 'ARTICLE_RESPONSE_INVALID'),
@@ -90,6 +93,7 @@ export const normalizeArticleCreatePayload = (payload) => {
'articleTitle',
'articleSummary',
'coverOssId',
'mediaOssIds',
'articleContent',
'authorName',
'sortOrder',
@@ -116,5 +120,12 @@ export const normalizeArticleCreatePayload = (payload) => {
if (payload.coverOssId === null) normalizedPayload.coverOssId = null
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
}
+2 -1
View File
@@ -33,13 +33,14 @@ const requestArticleContentProtection = async ({
export const familyArticleApi = {
async createArticle(genealogyId, payload, requestOptions = {}) {
const normalizedGenealogyId = normalizeGenealogyPathId(genealogyId)
return requestStrict({
const article = await requestStrict({
url: `/genealogy/app/genealogies/${normalizedGenealogyId}/articles`,
method: 'POST',
data: normalizeArticleCreatePayload(payload)
}, {
requestController: requestOptions.requestController ?? null
})
return normalizeAppArticle(article, normalizedGenealogyId)
},
async getArticles(genealogyId, requestOptions = {}) {
+5
View File
@@ -8,6 +8,7 @@ import { createRequestError } from './request-client.js'
import { assertPlainPayload, normalizeOssIdString } from './request-normalizers.js'
import {
normalizeOptionalNonnegativeInteger,
normalizeOptionalNumericId,
normalizeResponseText
} from './response-normalizers.js'
@@ -227,6 +228,7 @@ export const normalizeAppGenealogy = (item) => {
)
return {
id: normalizeMyGenealogyId(item.genealogyId),
genealogyNo: normalizeGenealogyResponseText(item.genealogyNo, 'genealogyNo'),
name,
firstAncestorName: normalizeGenealogyResponseText(item.firstAncestorName, 'firstAncestorName'),
rootPersonId: normalizeOptionalNumericId(item.rootPersonId, '始迁祖人物标识', 'GENEALOGY_RESPONSE_INVALID'),
@@ -248,6 +250,7 @@ export const normalizeAppGenealogy = (item) => {
),
archivedAt: normalizeGenealogyResponseText(item.archivedAt, 'archivedAt'),
intro: normalizeGenealogyResponseText(item.intro, 'intro'),
createTime: normalizeGenealogyResponseText(item.createTime, 'createTime'),
joinTime: normalizeGenealogyResponseText(item.joinTime, 'joinTime')
}
}
@@ -267,6 +270,8 @@ export const normalizeMyGenealogies = (value) => {
memberCount: genealogy.memberCount,
canManage: genealogy.canManage,
canEditContent: genealogy.canEditContent,
createTime: genealogy.createTime,
joinTime: genealogy.joinTime,
lifecycleStatus: genealogy.lifecycleStatus,
archivedAt: genealogy.archivedAt,
canArchive: genealogy.canArchive,
+2 -1
View File
@@ -89,11 +89,12 @@ export const lifeRecordApi = {
async createGrowthRecord(genealogyId, payload, requestOptions = {}) {
const normalizedGenealogyId = normalizeGenealogyPathId(genealogyId)
return requestStrict({
const growthRecord = await requestStrict({
url: `/genealogy/app/genealogies/${normalizedGenealogyId}/growth-records`,
method: 'POST',
data: normalizeGrowthRecordCreatePayload(payload)
}, { requestController: requestOptions.requestController ?? null })
return normalizeAppGrowthRecord(growthRecord, normalizedGenealogyId)
},
async updateGrowthRecord(genealogyId, recordId, payload, requestOptions = {}) {
+53
View File
@@ -1,5 +1,6 @@
import { normalizeOptionalSafeInteger } from './request-normalizers.js'
import { createRequestError } from './request-client.js'
import { normalizeOptionalNumericId } from './response-normalizers.js'
import { LINEAGE_PERSON_OPTIONS } from './lineage-person-options.js'
import { normalizeBusinessOptionProjection } from './business-dictionary-contract.js'
@@ -10,6 +11,16 @@ const optionLabels = (options) => Object.freeze(
const lineagePersonError = (message) =>
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) => {
if (typeof value === 'string' && /^[1-9]\d*$/.test(value)) return 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('成员详情敏感病史权限无效')
}
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']) {
if (value[field] !== undefined && typeof value[field] !== 'boolean') {
throw lineagePersonError(`成员详情权限字段 ${field} 无效`)
@@ -221,6 +238,8 @@ export const normalizeLineagePersonDetail = (value, expectedGenealogyId, expecte
remark,
canManageSensitiveMedicalHistory,
relationName,
rankId,
rankName,
sortOrder,
status: personStatus === '1' ? 'deceased' : 'normal',
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) => {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
throw lineagePersonError('成员敏感健康资料响应不是对象')
+19
View File
@@ -4,6 +4,7 @@ import {
normalizeLineagePersonIdentity,
normalizeLineagePersonOptions,
normalizeLineagePersonPage,
normalizeLineageRankOptions,
normalizeLineageSensitiveProfile
} from './lineage-person-contract.js'
import { normalizeLineageTree } from './lineage-tree-contract.js'
@@ -26,6 +27,24 @@ export const lineageApi = {
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 = {}) {
const normalizedGenealogyId = normalizeGenealogyPathId(genealogyId)
const normalizedPersonId = normalizeLineagePersonIdentity(personId, '人物标识')
+10 -1
View File
@@ -1,4 +1,5 @@
import { createRequestError } from './request-client.js'
import { normalizeBusinessFileAccess } from './business-file-contract.js'
const lineageTreeError = (message) =>
createRequestError(message, 'LINEAGE_TREE_RESPONSE_INVALID')
@@ -60,6 +61,7 @@ export const normalizeLineageTree = (value) => {
throw lineageTreeError('世系树人物世代无效')
}
const generationName = normalizeLineageText(node.generationName, '字辈')
const rankName = normalizeLineageText(node.rankName, '排行名称')
const relationName = normalizeLineageText(node.relationName, '人物关系')
const birthDate = lineageDatePart(node.birthDate, '出生日期')
const deathDate = lineageDatePart(node.deathDate, '逝世日期')
@@ -70,15 +72,22 @@ export const normalizeLineageTree = (value) => {
name: normalizeLineageText(node.name, '人物姓名', { required: true }),
relation:
relationOverride ||
rankName ||
(parentId
? (relationName && relationName !== '配偶' ? relationName : '后代')
? (relationName && relationName !== '配偶' ? relationName : '排行待补')
: relationName || '始祖'),
rankName,
generation: node.generation,
branch: generationName
? (generationName.endsWith('字辈') ? generationName : `${generationName}字辈`)
: '字辈待补',
years: birthDate || deathDate ? `${birthDate}${deathDate}` : '生卒待补',
treeYears: compactLineageYears(birthDate, deathDate),
avatarFile: normalizeBusinessFileAccess(
node.avatarFile,
'世系人物头像',
'LINEAGE_TREE_RESPONSE_INVALID'
),
sex: normalizeLineageText(node.sex, '性别'),
personStatus: normalizeLineageText(node.personStatus, '人物状态')
}
+6
View File
@@ -27,6 +27,7 @@ export const normalizeLineageWritePayload = (payload) => {
'aliasName',
'sex',
'generationName',
'rankId',
'fatherId',
'motherId',
'avatarOssId',
@@ -77,6 +78,11 @@ export const normalizeLineageWritePayload = (payload) => {
if (payload[field] === undefined || payload[field] === null || payload[field] === '') continue
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 !== '') {
normalizedPayload.avatarOssId = normalizeOssIdString(payload.avatarOssId, '头像文件标识')
}
+63
View File
@@ -1,11 +1,16 @@
import { createRequestError } from './request-client.js'
import {
normalizeOptionalNonnegativeInteger,
normalizeOptionalNumericId,
normalizeResponseText
} from './response-normalizers.js'
import { normalizeBusinessFileAccess } from './business-file-contract.js'
const promotionPlacements = new Set(['home_banner', 'home_bottom', 'message_bottom', 'profile_bottom'])
const siteArticleTypeLabels = Object.freeze({
news: '资讯',
notice: '公告'
})
export const normalizePromotionPlacement = (value) => {
if (!promotionPlacements.has(value)) throw new TypeError('推广位无效')
@@ -50,6 +55,64 @@ export const normalizeAppPromotions = (value, expectedPlacement) => {
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) => {
if (typeof value !== 'string' || !value.trim()) {
throw createRequestError(`帮助文章缺少 ${field}`, 'HELP_ARTICLE_RESPONSE_INVALID')
+24 -1
View File
@@ -3,11 +3,33 @@ import {
normalizeComplianceDocument,
normalizeComplianceDocumentKey,
normalizeHelpArticles,
normalizePromotionPlacement
normalizePromotionPlacement,
normalizeSiteArticles
} from './site-content-contract.js'
import { requestStrict } from './request-client.js'
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 = {}) {
const helpArticles = await requestStrict({
url: '/genealogy/app/help-articles',
@@ -25,6 +47,7 @@ export const siteContentApi = {
method: 'GET',
data: { platform: 'app', placement }
}, {
authenticated: false,
requestController: requestOptions.requestController ?? null
})
return normalizeAppPromotions(promotions, placement)
Binary file not shown.

Before

Width:  |  Height:  |  Size: 707 KiB

After

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 636 B

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.6 KiB

After

Width:  |  Height:  |  Size: 7.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 KiB

After

Width:  |  Height:  |  Size: 7.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.1 KiB

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.6 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.3 KiB

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.2 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.7 KiB

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

+50 -37
View File
@@ -87,7 +87,7 @@ export const ROUTES = Object.freeze({
kind: "page",
parent: "G01",
requiredParams: ["genealogyId"],
allowedSources: ["G01", "G05", "N01", "N02"],
allowedSources: ["G05", "N01", "N02"],
}),
G11: defineRoute({
path: "/pages/genealogy/settings",
@@ -101,14 +101,14 @@ export const ROUTES = Object.freeze({
kind: "flow",
parent: "G05",
requiredParams: ["genealogyId"],
allowedSources: ["G01", "G05"],
allowedSources: ["G05"],
}),
G13: defineRoute({
path: "/pages/genealogy/members",
kind: "page",
parent: "G05",
requiredParams: ["genealogyId"],
allowedSources: ["G01", "G05"],
allowedSources: ["G05"],
}),
G14: defineRoute({
path: "/pages/genealogy/capability-center",
@@ -123,7 +123,7 @@ export const ROUTES = Object.freeze({
parent: "G05",
requiredParams: ["genealogyId"],
optionalParams: ["selectedId"],
allowedSources: ["G01", "G05", "T02", "T04", "T06", "T07"],
allowedSources: ["G05", "T02", "T04", "T06", "T07"],
}),
T02: defineRoute({
path: "/pages/tree/pedigree",
@@ -131,7 +131,7 @@ export const ROUTES = Object.freeze({
parent: "G05",
requiredParams: ["genealogyId"],
optionalParams: ["selectedId"],
allowedSources: ["G01", "G05", "T01"],
allowedSources: ["G05", "T01"],
}),
T03: defineRoute({
path: "/pages/tree/member-profile",
@@ -182,32 +182,38 @@ export const ROUTES = Object.freeze({
allowedSources: ["T03"],
}),
F01: defineRoute({
path: "/pages/family/feed",
path: "/pages/family/site-home",
kind: "root",
parent: null,
optionalParams: ["genealogyId"],
}),
F02: defineRoute({
path: "/pages/family/feed-editor",
kind: "flow",
parent: "F01",
parent: "F12",
requiredParams: ["genealogyId", "mode"],
optionalParams: ["feedId"],
allowedSources: ["F01", "F03"],
allowedSources: ["F12", "F03"],
}),
F03: defineRoute({
path: "/pages/family/feed-detail",
kind: "page",
parent: "F01",
parent: "F12",
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({
path: "/pages/family/articles",
kind: "page",
parent: "F01",
parent: "G05",
requiredParams: ["genealogyId"],
allowedSources: ["F01"],
allowedSources: ["G05", "M01"],
}),
F05: defineRoute({
path: "/pages/family/article-detail",
@@ -227,9 +233,9 @@ export const ROUTES = Object.freeze({
F07: defineRoute({
path: "/pages/family/albums",
kind: "page",
parent: "F01",
parent: "G05",
requiredParams: ["genealogyId"],
allowedSources: ["F01"],
allowedSources: ["G05"],
}),
F08: defineRoute({
path: "/pages/family/album-detail",
@@ -248,16 +254,23 @@ export const ROUTES = Object.freeze({
F10: defineRoute({
path: "/pages/family/videos",
kind: "page",
parent: "F01",
parent: "G05",
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({
path: "/pages/records/people",
kind: "page",
parent: "F01",
parent: "G05",
requiredParams: ["genealogyId"],
allowedSources: ["F01"],
allowedSources: ["G05"],
}),
R02: defineRoute({
path: "/pages/records/person-detail",
@@ -270,9 +283,9 @@ export const ROUTES = Object.freeze({
R03: defineRoute({
path: "/pages/records/relative-records",
kind: "page",
parent: "F01",
parent: "G05",
requiredParams: ["genealogyId"],
allowedSources: ["F01"],
allowedSources: ["G05"],
}),
R04: defineRoute({
path: "/pages/records/relative-record-editor",
@@ -285,9 +298,9 @@ export const ROUTES = Object.freeze({
R05: defineRoute({
path: "/pages/records/ceremonies",
kind: "page",
parent: "F01",
parent: "G05",
requiredParams: ["genealogyId"],
allowedSources: ["F01"],
allowedSources: ["G05"],
}),
R06: defineRoute({
path: "/pages/records/ceremony-detail",
@@ -321,17 +334,17 @@ export const ROUTES = Object.freeze({
R10: defineRoute({
path: "/pages/records/memos",
kind: "page",
parent: "F01",
parent: "G05",
requiredParams: ["genealogyId"],
optionalParams: ["memoId", "memoType"],
allowedSources: ["F01", "N02"],
allowedSources: ["G05", "N02"],
}),
R11: defineRoute({
path: "/pages/records/merit-records",
kind: "page",
parent: "F01",
parent: "G05",
requiredParams: ["genealogyId"],
allowedSources: ["F01"],
allowedSources: ["G05"],
}),
R12: defineRoute({
path: "/pages/records/person-documents",
@@ -345,7 +358,7 @@ export const ROUTES = Object.freeze({
kind: "page",
parent: "G01",
optionalParams: ["genealogyId"],
allowedSources: ["G01", "M01"],
allowedSources: ["G01", "F01", "M01"],
}),
N02: defineRoute({
path: "/pages/notification/message-detail",
@@ -363,7 +376,7 @@ export const ROUTES = Object.freeze({
path: "/pages/profile/edit-profile",
kind: "flow",
parent: "M01",
allowedSources: ["M01"],
allowedSources: ["F01", "M01"],
}),
M03: defineRoute({
path: "/pages/profile/security",
@@ -387,7 +400,7 @@ export const ROUTES = Object.freeze({
path: "/pages/profile/help",
kind: "page",
parent: "M01",
allowedSources: ["M01"],
allowedSources: ["F01", "M01"],
}),
M07: defineRoute({
path: "/pages/profile/feedback",
@@ -399,38 +412,38 @@ export const ROUTES = Object.freeze({
path: "/pages/profile/promotions",
kind: "page",
parent: "M01",
allowedSources: ["M01"],
allowedSources: ["F01", "M01"],
}),
M09: defineRoute({
path: "/pages/profile/vip",
kind: "page",
parent: "M01",
allowedSources: ["M01"],
allowedSources: ["F01", "M01"],
}),
M10: defineRoute({
path: "/pages/profile/settings",
kind: "page",
parent: "M01",
allowedSources: ["M01"],
allowedSources: ["F01", "M01"],
}),
F11: defineRoute({
path: "/pages/family/platform-videos",
kind: "page",
parent: "F01",
parent: "F10",
optionalParams: ["placement", "videoId"],
allowedSources: ["F10", "G01"],
allowedSources: ["F10"],
}),
M11: defineRoute({
path: "/pages/profile/ceremony-invitations",
kind: "page",
parent: "M01",
allowedSources: ["M01", "N02"],
allowedSources: ["F01", "M01", "N02"],
}),
M12: defineRoute({
path: "/pages/profile/earnings",
kind: "page",
parent: "M01",
allowedSources: ["M01"],
allowedSources: ["F01", "M01"],
}),
M13: defineRoute({
path: "/pages/profile/compliance-document",
+2 -2
View File
@@ -1,6 +1,6 @@
const GRID_UNIT = 5;
const NODE_HALF_WIDTH = 80;
const NODE_HALF_HEIGHT = 112;
const NODE_HALF_WIDTH = 77;
const NODE_HALF_HEIGHT = 100;
const FAMILY_LINK_OFFSET = 18;
const MEMBER_GAP = 178;
const GENERATION_GAP = 296;