完成70%

This commit is contained in:
2026-07-24 07:56:22 +08:00
parent bb6431b319
commit c7f278fe79
92 changed files with 4741 additions and 14440 deletions
+1 -1
View File
@@ -46,7 +46,7 @@
class="auth-input"
type="number"
maxlength="11"
:disabled="sendingCode || submitting || tacVisible || authenticationCommitted"
:disabled="sendingCode || submitting || tacVisible || authenticationCommitted || (cooldownSeconds > 0 && phone.length > 0)"
placeholder="手机号"
aria-label="手机号"
placeholder-class="input-placeholder"
+1 -1
View File
@@ -38,7 +38,7 @@
class="auth-input"
type="number"
maxlength="11"
:disabled="sendingCode || submitting || registrationCommitted"
:disabled="sendingCode || submitting || tacVisible || registrationCommitted || (cooldownSeconds > 0 && phone.length > 0)"
placeholder="请输入手机号"
placeholder-class="placeholder"
:aria-invalid="Boolean(fieldErrors.phone)"
+1 -1
View File
@@ -38,7 +38,7 @@
class="auth-input"
type="number"
maxlength="11"
:disabled="sendingCode || submitting"
:disabled="sendingCode || submitting || (cooldownSeconds > 0 && phone.length > 0)"
placeholder="请输入手机号"
placeholder-class="placeholder"
:aria-invalid="Boolean(fieldErrors.phone)"
+49 -260
View File
@@ -1,114 +1,50 @@
<!-- 页面编号F-01用途家族动态首页列表空态与失败状态 -->
<!-- 页面编号F-01用途家族动态入口列表 DTO 缺失时不展示 fixture 条目 -->
<template>
<view
class="family-page"
:class="{
'feed-state--list': feedState === 'list',
'feed-state--empty': feedState === 'empty',
'feed-state--error': feedState === 'error',
}"
>
<view class="family-page" :class="`feed-state--${feedState}`">
<ModulePageBackground module="family" />
<view class="family-page__header">
<PageHeader root title="家族动态" :action="hasValidContext ? '发布' : ''" @action="toPublish" />
</view>
<view class="family-page__header"><PageHeader root title="家族动态" :action="hasValidContext ? '发布' : ''" @action="toPublish" /></view>
<view class="feed-content">
<AppLoading
v-if="feedState === 'loading'"
text="正在整理家族动态"
description="请稍候,正在读取家宴、通知与共同记忆。"
/>
<view v-if="feedState !== 'loading'" class="feed-heading"
><text>{{ familyTitle }}</text><text>家宴通知与共同记忆</text></view
>
<view v-if="feedState !== 'loading' && hasValidContext" class="feed-shortcuts">
<view
v-for="item in shortcuts"
:key="item.key"
class="feed-shortcut"
@click="openSection(item.key)"
><text>{{ item.label }}</text></view
>
</view>
<template v-if="feedState === 'list'">
<view
v-for="item in feeds"
:key="item.id"
class="feed-card"
@click="openDetail(item)"
>
<view class="feed-card__copy">
<text class="feed-card__meta"
>{{ item.tag }} · {{ item.time }}</text
>
<text class="feed-card__title">{{ item.title }}</text>
<text class="feed-card__summary">{{ item.content }}</text>
<text class="feed-card__author">发布人{{ item.author }}</text>
</view>
<view class="feed-heading"><text>家族圈</text><text>家宴通知与共同记忆</text></view>
<template v-if="hasValidContext">
<view class="feed-source-note"><text>动态列表接口当前没有声明条目 DTO页面不猜测标题正文发布人或时间已停止展示本地动态</text></view>
<view class="feed-shortcuts">
<view v-for="item in shortcuts" :key="item.key" class="feed-shortcut" @click="openSection(item.key)"><text>{{ item.label }}</text></view>
</view>
</template>
<view v-else-if="feedState !== 'loading'" class="feed-state-card">
<view class="feed-state-card__copy"
><text>{{ stateCopy.title }}</text
><text>{{ stateCopy.copy }}</text></view
>
<view class="feed-state-card">
<view class="feed-state-card__copy"><text>{{ stateCopy.title }}</text><text>{{ stateCopy.copy }}</text></view>
</view>
<view
v-if="feedState !== 'loading'"
class="feed-action"
@click="handlePrimaryAction"
><text>{{ stateCopy.action }}</text></view
>
<view class="feed-action" @click="handlePrimaryAction"><text>{{ stateCopy.action }}</text></view>
</view>
<AppTabbar active="family" />
</view>
</template>
<script setup>
import { computed, ref } from "vue";
import { onLoad } from "@dcloudio/uni-app";
import AppLoading from "@/components/AppLoading.vue";
import PageHeader from "@/components/PageHeader.vue";
import AppTabbar from "@/components/AppTabbar.vue";
import ModulePageBackground from "@/components/ModulePageBackground.vue";
import {
findGenealogyFixture,
getGenealogyFixtureAccess,
listFamilyFeedFixtures,
} from "@/data/mock.js";
import PageHeader from "@/components/PageHeader.vue";
import { genealogyContext } from "@/utils/genealogy-context.js";
import { goRoot, openPage } from "@/utils/navigation.js";
const genealogyId = ref("");
const genealogy = ref(null);
const hasValidContext = ref(false);
const feedState = ref("loading");
const feeds = ref([]);
const familyTitle = computed(() =>
genealogy.value ? `${genealogy.value.name}家族圈` : "家族圈",
);
const feedState = ref("unavailable");
const stateCopy = computed(() => {
if (!hasValidContext.value) {
return {
title: "请先选择可访问的家谱",
title: "请先选择有效家谱",
copy: "家族动态必须归属明确家谱,页面不会展示其他家谱的内容。",
action: "返回我的家谱",
};
}
if (feedState.value === "empty") {
return {
title: "还没有家族动态",
copy: "可先查看其他家族内容;发布接口接入后才能新增动态",
action: "填写动态预览",
};
}
if (feedState.value === "error") {
return {
title: "家族动态暂不可用",
copy: "请稍后重新查看,已有内容不会受到影响。",
action: "重新查看",
};
}
return { title: "", copy: "", action: "发布家族动态" };
return {
title: "动态列表待后端字段合同",
copy: "已找到动态列表 operation,但响应只声明通用对象数组;待后端提供动态 ID、标题、正文、发布人和时间字段后再恢复列表与详情入口。",
action: "发布家族动态",
};
});
const shortcuts = [
{ key: "articles", label: "谱文" },
@@ -120,52 +56,25 @@ const shortcuts = [
{ key: "merits", label: "功德录" },
{ key: "videos", label: "家族视频" },
];
onLoad((query) => {
const hasRouteIdentity = Object.prototype.hasOwnProperty.call(query, "genealogyId");
const hasRouteIdentity = Object.prototype.hasOwnProperty.call(query || {}, "genealogyId");
const resolvedGenealogyId = hasRouteIdentity
? String(query.genealogyId || "")
? String(query?.genealogyId || "")
: String(genealogyContext.getCurrentGenealogyId() || "");
genealogyId.value = resolvedGenealogyId;
genealogy.value = findGenealogyFixture(resolvedGenealogyId);
const access = getGenealogyFixtureAccess(resolvedGenealogyId);
const isAccessible = Boolean(
genealogy.value && ["owner", "member"].includes(access.accessRole),
);
if (!isAccessible) {
feeds.value = [];
feedState.value = "error";
return;
}
if (!hasRouteIdentity) {
hasValidContext.value = /^[1-9]\d*$/.test(resolvedGenealogyId);
feedState.value = hasValidContext.value ? "unavailable" : "error";
if (!hasRouteIdentity && hasValidContext.value) {
goRoot("F01", { genealogyId: resolvedGenealogyId }).catch(() => {
hasValidContext.value = false;
feedState.value = "error";
});
return;
}
hasValidContext.value = isAccessible;
feeds.value = listFamilyFeedFixtures(resolvedGenealogyId);
feedState.value =
query.state === "loading"
? "loading"
: query.state === "empty"
? "empty"
: query.state === "error"
? "error"
: feeds.value.length
? "list"
: "empty";
});
const toPublish = () =>
hasValidContext.value
? openPage("F02", { genealogyId: genealogyId.value }, "F01")
: goRoot("G01");
const openDetail = (item) =>
openPage(
"F03",
{ genealogyId: genealogyId.value, feedId: String(item.id) },
"F01",
);
const toPublish = () => hasValidContext.value
? openPage("F02", { genealogyId: genealogyId.value }, "F01")
: goRoot("G01");
const openSection = (key) => {
const routes = {
articles: "F04",
@@ -179,147 +88,27 @@ const openSection = (key) => {
};
return openPage(routes[key], { genealogyId: genealogyId.value }, "F01");
};
const handlePrimaryAction = () => {
if (!hasValidContext.value) return goRoot("G01");
if (feedState.value === "error") {
feeds.value = listFamilyFeedFixtures(genealogyId.value);
feedState.value = feeds.value.length ? "list" : "empty";
return;
}
return toPublish();
};
const handlePrimaryAction = () => hasValidContext.value ? toPublish() : goRoot("G01");
</script>
<style scoped lang="scss">
@use "../../styles/adaptive-frame-profiles.scss" as adaptive;
.family-page {
display: flex;
min-height: 100vh;
flex-direction: column;
background: $paper;
}
.family-page__header,
.feed-content {
z-index: 1;
}
.feed-content {
flex: 1;
padding: 24rpx 24rpx 190rpx;
}
.feed-heading text {
display: block;
}
.feed-heading text:first-child {
color: $ink;
font-family: STKaiti, KaiTi, serif;
font-size: 31rpx;
font-weight: 700;
}
.feed-heading text:last-child {
margin-top: 6rpx;
color: $ink-muted;
font-size: 23rpx;
}
.feed-shortcuts {
@include adaptive.adaptive-scroll-button(secondary);
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
width: 100%;
min-height: 48px;
margin-top: 15rpx;
}
.feed-shortcut {
width: 100%;
height: 48px;
min-height: 44px;
}
.feed-shortcut text {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
color: $ink;
font-size: 22rpx;
font-weight: 700;
}
.feed-card,
.feed-state-card {
@include adaptive.adaptive-family-letter;
display: flex;
width: 100%;
min-height: 230rpx;
min-height: 98px;
flex-direction: column;
justify-content: center;
margin-top: 17rpx;
box-sizing: border-box;
}
.feed-card__copy {
padding: 15% 9%;
}
.feed-card text {
display: block;
}
.feed-card__meta {
color: #806a51;
font-size: 21rpx;
letter-spacing: 1rpx;
}
.feed-card__title {
margin-top: 5rpx;
color: $ink;
font-family: STKaiti, KaiTi, serif;
font-size: 30rpx;
font-weight: 700;
}
.feed-card__summary {
margin-top: 6rpx;
color: $ink-muted;
font-size: 23rpx;
line-height: 1.4;
}
.feed-card__author {
margin-top: 6rpx;
color: $brand-red;
font-size: 21rpx;
}
.feed-state-card {
margin-top: 70rpx;
}
.feed-state-card__copy {
padding: 23% 10%;
box-sizing: border-box;
text-align: center;
}
.feed-state-card text {
display: block;
}
.feed-state-card text:first-child {
color: $ink;
font-family: STKaiti, KaiTi, serif;
font-size: 31rpx;
font-weight: 700;
}
.feed-state-card text:last-child {
margin-top: 13rpx;
color: $ink-muted;
font-size: 23rpx;
line-height: 1.45;
}
.feed-action {
@include adaptive.adaptive-scroll-button(primary);
width: 514rpx;
max-width: 100%;
min-height: 76rpx;
margin: 19rpx auto 0;
}
.feed-action text {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
color: #fff9ed;
font-size: 24rpx;
font-weight: 700;
}
.family-page { display: flex; min-height: 100vh; flex-direction: column; background: $paper; }
.family-page__header, .feed-content { z-index: 1; }
.feed-content { flex: 1; padding: 24rpx 24rpx 190rpx; }
.feed-heading text { display: block; }
.feed-heading text:first-child { color: $ink; font-family: STKaiti, KaiTi, serif; font-size: 31rpx; font-weight: 700; }
.feed-heading text:last-child { margin-top: 6rpx; color: $ink-muted; font-size: 23rpx; }
.feed-source-note { margin-top: 10rpx; padding: 12rpx 16rpx; border: 1rpx solid rgba(128, 106, 81, .22); border-radius: 10rpx; background: rgba(255, 255, 255, .56); }
.feed-source-note text { display: block; color: $ink-muted; font-size: 20rpx; line-height: 1.5; }
.feed-shortcuts { @include adaptive.adaptive-scroll-button(secondary); display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); width: 100%; min-height: 48px; margin-top: 15rpx; }
.feed-shortcut { width: 100%; height: 48px; min-height: 44px; }
.feed-shortcut text { display: flex; align-items: center; justify-content: center; height: 100%; color: $ink; font-size: 22rpx; font-weight: 700; }
.feed-state-card { @include adaptive.adaptive-family-letter; display: flex; width: 100%; min-height: 230rpx; flex-direction: column; justify-content: center; margin-top: 70rpx; box-sizing: border-box; }
.feed-state-card__copy { padding: 23% 10%; box-sizing: border-box; text-align: center; }
.feed-state-card text { display: block; }
.feed-state-card text:first-child { color: $ink; font-family: STKaiti, KaiTi, serif; font-size: 31rpx; font-weight: 700; }
.feed-state-card text:last-child { margin-top: 13rpx; color: $ink-muted; font-size: 23rpx; line-height: 1.45; }
.feed-action { @include adaptive.adaptive-scroll-button(primary); width: 514rpx; max-width: 100%; min-height: 76rpx; margin: 19rpx auto 0; }
.feed-action text { display: flex; align-items: center; justify-content: center; height: 100%; color: #fff9ed; font-size: 24rpx; font-weight: 700; }
</style>
+72 -167
View File
@@ -1,43 +1,23 @@
<!-- 页面编号F-02用途发布家族动态与提交结果 -->
<!-- 页面编号F-02用途 Apifox 已声明的 feedContent 发布家族动态 -->
<template>
<view
class="publish-page"
:class="{
'publish-state--form': publishState === 'form',
'publish-state--preview': publishState === 'preview',
'publish-state--error': publishState === 'error',
'publish-state--invalid': publishState === 'invalid',
}"
><ModulePageBackground module="family" /><view class="publish-page__header"
><PageHeader title="发布动态" custom-back @back="requestBack" /></view
><view class="publish-panel"
><view
v-if="publishState === 'form'"
class="publish-form"
><text>记录此刻</text
><text>分享通知活动家族故事或一段共同记忆</text
><view class="publish-field"
><textarea
v-model="content"
auto-height
maxlength="300"
placeholder="写下想对家人说的话"
placeholder-class="publish-placeholder"
/></view
><AppButton
block
:label="isSubmitting ? '正在校验' : '生成本地预览'"
:disabled="isSubmitting"
@click="submit"
/></view
><view v-else class="publish-result"
><text>{{ resultCopy.title }}</text
><text>{{ resultCopy.copy }}</text
><AppButton
block
:label="resultCopy.action"
@click="handleResultAction" /></view></view
><AppDialog
<view class="publish-page" :class="`publish-state--${publishState}`">
<ModulePageBackground module="family" />
<view class="publish-page__header"><PageHeader title="发布动态" custom-back @back="requestBack" /></view>
<view class="publish-panel">
<view v-if="publishState === 'form'" class="publish-form">
<text>记录此刻</text>
<text>当前接口只明确文本动态的 `feedContent` 请求字段媒体排序和状态均不由本页猜测或提交</text>
<view class="publish-field"><textarea v-model="content" auto-height maxlength="300" placeholder="写下想对家人说的话" placeholder-class="publish-placeholder" @input="submitError = ''" /></view>
<text v-if="submitError" class="publish-error">{{ submitError }}</text>
<AppButton block :label="isSubmitting ? '正在提交' : '提交动态'" :disabled="isSubmitting" @click="submit" />
</view>
<view v-else class="publish-result">
<text>{{ resultCopy.title }}</text>
<text>{{ resultCopy.copy }}</text>
<AppButton block :label="resultCopy.action" @click="handleResultAction" />
</view>
</view>
<AppDialog
:visible="discardVisible"
title="放弃动态草稿?"
message="当前内容尚未提交服务器,确认返回后不会保留。"
@@ -48,46 +28,34 @@
@confirm="confirmDiscard"
@cancel="cancelDiscard"
/>
><AppToast :visible="toastVisible" :message="toastMessage"
/></view>
</view>
</template>
<script setup>
import { computed, onUnmounted, ref } from "vue";
import { onBackPress, onLoad } from "@dcloudio/uni-app";
import AppButton from "@/components/AppButton.vue";
import AppDialog from "@/components/AppDialog.vue";
import AppToast from "@/components/AppToast.vue";
import ModulePageBackground from "@/components/ModulePageBackground.vue";
import PageHeader from "@/components/PageHeader.vue";
import { getGenealogyFixtureAccess } from "@/data/mock.js";
import { appApi, createRequestController, isRequestCancelled } from "@/utils/api.js";
import { createDiscardConfirmation } from "@/utils/discard-confirmation.js";
import {
goBack,
handleBackPress,
returnTo,
runBackGuard,
} from "@/utils/navigation.js";
import { goBack, handleBackPress, returnTo, runBackGuard } from "@/utils/navigation.js";
const genealogyId = ref("");
const content = ref("");
const publishState = ref("form");
const isSubmitting = ref(false);
const submitError = ref("");
const discardVisible = ref(false);
const toastVisible = ref(false);
const toastMessage = ref("");
let toastTimer = null;
let submitTimer = null;
const requestController = createRequestController();
const isDirty = computed(() => Boolean(content.value.trim()));
const hasValidContext = computed(() =>
["owner", "member"].includes(
getGenealogyFixtureAccess(genealogyId.value).accessRole,
),
);
const hasValidContext = computed(() => /^[1-9]\d*$/.test(genealogyId.value));
const resultCopy = computed(() => ({
preview: {
title: "动态内容已完成本地预览",
copy: "当前尚未提交服务器,返回家族圈后不会出现这条动态。",
action: "返回家族圈(不发布)",
success: {
title: "动态已提交服务端",
copy: "服务端已返回成功信封。动态列表仍缺条目 DTO,返回后不会把本地内容伪装成列表项。",
action: "返回家族动态",
},
error: {
title: "动态未提交",
@@ -96,7 +64,7 @@ const resultCopy = computed(() => ({
},
invalid: {
title: "动态入口无效",
copy: "没有找到可发布内容的成员家谱,页面不会创建无归属动态。",
copy: "没有取得有效家谱标识,页面不会创建无归属动态。",
action: "返回上一页",
},
}[publishState.value]));
@@ -108,57 +76,45 @@ const confirmDiscard = discardConfirmation.confirm;
const cancelDiscard = discardConfirmation.cancel;
onLoad((query) => {
genealogyId.value = String(query.genealogyId || "");
if (!genealogyId.value || !hasValidContext.value) {
publishState.value = "invalid";
return;
}
if (["preview", "error"].includes(query.state)) {
content.value = "这是一段尚未提交服务器的家族动态预览。";
publishState.value = query.state;
}
genealogyId.value = String(query?.genealogyId || "");
if (!hasValidContext.value) publishState.value = "invalid";
});
const showToast = (message) => {
toastMessage.value = message;
toastVisible.value = true;
if (toastTimer) clearTimeout(toastTimer);
toastTimer = setTimeout(() => {
toastVisible.value = false;
toastTimer = null;
}, 1800);
};
const submit = () => {
const submit = async () => {
if (isSubmitting.value || !hasValidContext.value) return;
if (!content.value.trim()) {
showToast("请先写下动态内容");
const feedContent = content.value.trim();
if (!feedContent) {
submitError.value = "请先写下动态内容";
return;
}
isSubmitting.value = true;
const submittedContent = content.value.trim();
const timer = setTimeout(() => {
if (submitTimer !== timer) return;
submitTimer = null;
submitError.value = "";
try {
await appApi.createFeed(genealogyId.value, { feedContent }, { requestController });
content.value = "";
publishState.value = "success";
} catch (error) {
if (isRequestCancelled(error)) return;
publishState.value = "error";
submitError.value = error?.message || "动态提交失败,请稍后重试。";
} finally {
isSubmitting.value = false;
publishState.value = submittedContent ? "preview" : "error";
}, 280);
submitTimer = timer;
}
};
const requestBack = () =>
runBackGuard({
transientOpen: discardVisible.value,
dirty: isDirty.value,
submitting: isSubmitting.value,
"close-transient": cancelDiscard,
"block-submitting": () => true,
"confirm-discard": requestDiscardConfirmation,
});
const requestBack = () => runBackGuard({
transientOpen: discardVisible.value,
dirty: isDirty.value,
submitting: isSubmitting.value,
"close-transient": cancelDiscard,
"block-submitting": () => true,
"confirm-discard": requestDiscardConfirmation,
});
const returnToFamily = async () => {
const confirmed = isDirty.value ? await requestDiscardConfirmation() : true;
if (!confirmed) return false;
return returnTo("F01", { genealogyId: genealogyId.value });
};
const handleResultAction = () => {
if (publishState.value === "preview") return returnToFamily();
if (publishState.value === "success") return returnToFamily();
if (publishState.value === "error") {
publishState.value = "form";
return;
@@ -167,75 +123,24 @@ const handleResultAction = () => {
};
onBackPress((event) => handleBackPress(event, requestBack));
onUnmounted(() => {
if (toastTimer) clearTimeout(toastTimer);
if (submitTimer) clearTimeout(submitTimer);
requestController.abort();
discardConfirmation.dispose();
});
</script>
<style scoped lang="scss">
@use "../../styles/adaptive-frame-profiles.scss" as adaptive;
.publish-page {
display: flex;
min-height: 100vh;
flex-direction: column;
background: $paper;
}
.publish-page__header,
.publish-panel {
@include adaptive.adaptive-family-content;
z-index: 1;
}
.publish-panel {
width: calc(100% - 32rpx);
margin: 18rpx auto 0;
padding: 9%;
box-sizing: border-box;
}
.publish-form > text,
.publish-result > text {
display: block;
}
.publish-form > text:first-child,
.publish-result > text:first-child {
color: $ink;
font-family: STKaiti, KaiTi, serif;
font-size: 36rpx;
font-weight: 700;
}
.publish-form > text:nth-child(2),
.publish-result > text:nth-child(2) {
margin-top: 12rpx;
color: $ink-muted;
font-size: 23rpx;
line-height: 1.6;
}
.publish-field {
@include adaptive.adaptive-family-field;
display: flex;
min-height: 250rpx;
margin-top: 24rpx;
padding: 25rpx;
box-sizing: border-box;
}
.publish-field textarea {
width: 100%;
min-height: 200rpx;
color: $ink;
font-size: 24rpx;
line-height: 1.7;
}
.publish-placeholder {
color: #8e806e;
}
.publish-form > .app-button {
margin-top: 24rpx;
}
.publish-result {
margin-top: 20%;
text-align: center;
}
.publish-result > .app-button {
margin: 30rpx auto 0;
}
.publish-page { display: flex; min-height: 100vh; flex-direction: column; background: $paper; }
.publish-page__header, .publish-panel { @include adaptive.adaptive-family-content; z-index: 1; }
.publish-panel { width: calc(100% - 32rpx); margin: 18rpx auto 0; padding: 9%; box-sizing: border-box; }
.publish-form > text, .publish-result > text { display: block; }
.publish-form > text:first-child, .publish-result > text:first-child { color: $ink; font-family: STKaiti, KaiTi, serif; font-size: 36rpx; font-weight: 700; }
.publish-form > text:nth-child(2), .publish-result > text:nth-child(2) { margin-top: 12rpx; color: $ink-muted; font-size: 23rpx; line-height: 1.6; }
.publish-field { @include adaptive.adaptive-family-field; display: flex; min-height: 250rpx; margin-top: 24rpx; padding: 25rpx; box-sizing: border-box; }
.publish-field textarea { width: 100%; min-height: 200rpx; color: $ink; font-size: 24rpx; line-height: 1.7; }
.publish-placeholder { color: #8e806e; }
.publish-error { margin-top: 12rpx; color: $brand-red; font-size: 22rpx; line-height: 1.5; }
.publish-form > .app-button { margin-top: 24rpx; }
.publish-result { margin-top: 20%; text-align: center; }
.publish-result > .app-button { margin: 30rpx auto 0; }
</style>
+83 -58
View File
@@ -1,20 +1,19 @@
<!-- 页面编号F-03用途动态详情评论与内容失效状态 -->
<!-- 页面编号F-03用途动态详情与一级评论动态正文 DTO 未声明时不展示伪字段 -->
<template>
<view class="feed-detail-page" :class="{ 'feed-state--expired': feedState === 'expired', 'feed-state--error': feedState === 'error', 'feed-state--ready': feedState === 'ready' }">
<ModulePageBackground module="family" />
<view class="feed-detail-header"><PageHeader title="动态详情" custom-back @back="requestBack" /></view>
<view v-if="feedState === 'loading'" class="feed-detail-loading">
<AppLoading text="正在读取家族动态" description="请稍候,正在整理正文与家人评论。" />
<AppLoading text="正在读取家人评论" description="动态正文的展示字段尚未由后端合同声明。" />
</view>
<view v-else class="feed-detail-content">
<template v-if="feedState === 'ready'">
<view class="feed-source-note"><text>动态详情接口尚未声明标题正文发布人和发布时间字段本页不猜测这些字段只展示该动态的真实一级评论</text></view>
<view class="feed-article-card">
<text class="feed-article-card__meta">{{ currentFeed.tag }} · {{ currentFeed.time }}</text>
<text class="feed-article-card__title">{{ currentFeed.title }}</text>
<text class="feed-article-card__body">{{ currentFeed.content }}</text>
<text class="feed-article-card__author">发布人{{ currentFeed.author }}</text>
<text class="feed-article-card__title">动态正文待后端字段合同</text>
<text class="feed-article-card__body">已根据当前动态 ID 读取评论正文内容会在详情响应提供可消费 DTO 后再接入</text>
</view>
<view class="feed-comments-panel">
@@ -24,17 +23,19 @@
<view v-for="comment in feedComments" :key="comment.id" class="feed-comment-card">
<view><text>{{ comment.author }}</text><text>{{ comment.time }}</text></view>
<text>{{ comment.content }}</text>
<text v-if="comment.replyCount > 0" class="feed-comment-card__replies">直属回复 {{ comment.replyCount }} </text>
</view>
<view v-if="!feedComments.length" class="feed-comments-empty">
<text>还没有评论</text><text>写下第一句祝福或共同记忆</text>
<text>还没有一级评论</text><text>服务端返回评论后会显示在这里</text>
</view>
</view>
<view class="feed-comment-form" :class="{ 'comment-state--validating': commentState === 'validating', 'comment-state--preview': commentState === 'preview', 'comment-state--error': commentState === 'error' }">
<text>写下评论</text>
<textarea v-model="commentDraft" auto-height maxlength="240" placeholder="对家人说点什么" />
<view class="feed-comment-form" :class="{ 'comment-state--submitting': commentState === 'submitting', 'comment-state--error': commentState === 'error' }">
<text>提交一级评论</text>
<textarea v-model="commentDraft" auto-height maxlength="1000" placeholder="对家人说点什么" />
<text v-if="commentError" class="feed-comment-error">{{ commentError }}</text>
<AppButton block :disabled="commentState === 'validating'" :label="commentState === 'validating' ? '正在校验' : '生成评论预览'" @click="submitComment" />
<text v-else-if="commentNotice" class="feed-comment-notice">{{ commentNotice }}</text>
<AppButton block :disabled="commentState === 'submitting'" :label="commentState === 'submitting' ? '正在提交' : '提交评论'" @click="submitComment" />
</view>
</template>
@@ -47,7 +48,7 @@
<AppDialog
:visible="discardVisible"
title="放弃评论草稿?"
message="当前评论尚未提交服务,确认返回后不会保留。"
message="当前评论尚未提交服务,确认返回后不会保留。"
confirm-text="放弃并返回"
cancel-text="继续填写"
show-cancel
@@ -55,7 +56,6 @@
@confirm="confirmDiscard"
@cancel="cancelDiscard"
/>
<AppToast :visible="toastVisible" message="评论尚未提交服务器,草稿已保留" />
</view>
</template>
@@ -65,11 +65,10 @@ import { onBackPress, onLoad } from "@dcloudio/uni-app";
import AppButton from "@/components/AppButton.vue";
import AppDialog from "@/components/AppDialog.vue";
import AppLoading from "@/components/AppLoading.vue";
import AppToast from "@/components/AppToast.vue";
import ModulePageBackground from "@/components/ModulePageBackground.vue";
import PageHeader from "@/components/PageHeader.vue";
import { findFamilyFeedFixture } from "@/data/mock.js";
import { createDiscardConfirmation } from "@/utils/discard-confirmation.js";
import { appApi, createRequestController, isRequestCancelled } from "@/utils/api.js";
import {
goBack,
handleBackPress,
@@ -79,29 +78,28 @@ import {
const genealogyId = ref("");
const feedId = ref("");
const currentFeed = ref(null);
const feedState = ref("loading");
const commentState = ref("idle");
const commentDraft = ref("");
const commentError = ref("");
const toastVisible = ref(false);
const commentNotice = ref("");
const discardVisible = ref(false);
const feedComments = ref([]);
let submitTimer = null;
let toastTimer = null;
const feedRequestController = createRequestController();
let loadSequence = 0;
const isDirty = computed(() => Boolean(commentDraft.value.trim()));
const stateCopy = computed(() => {
if (feedState.value === "error" && currentFeed.value) {
if (feedState.value === "error") {
return {
title: "动态暂不可用",
copy: "请稍后重新查看,已有家族记录不会受到影响。",
action: "重新查看",
title: "评论暂不可用",
copy: "评论读取没有得到可消费的服务端响应;页面不会用本地数据替代。",
action: "重新读取",
};
}
return {
title: "动态已失效或入口无效",
copy: "没有找到当前家谱中的这条动态,页面不会回退到其他记录。",
action: genealogyId.value ? "返回家族" : "返回上一页",
title: "动态入口无效",
copy: "没有取得当前家谱和动态标识,页面不会回退到其他动态。",
action: genealogyId.value ? "返回家族动态" : "返回上一页",
};
});
const discardConfirmation = createDiscardConfirmation((visible) => {
@@ -111,45 +109,69 @@ const requestDiscardConfirmation = discardConfirmation.request;
const confirmDiscard = discardConfirmation.confirm;
const cancelDiscard = discardConfirmation.cancel;
const loadComments = async () => {
if (!genealogyId.value || !feedId.value) {
feedState.value = "expired";
return false;
}
const sequence = ++loadSequence;
feedState.value = "loading";
try {
const comments = await appApi.getFeedComments(genealogyId.value, feedId.value, {
requestController: feedRequestController,
});
if (sequence !== loadSequence) return false;
feedComments.value = comments;
feedState.value = "ready";
return true;
} catch (error) {
if (isRequestCancelled(error) || sequence !== loadSequence) return false;
feedState.value = "error";
return false;
}
};
onLoad((query) => {
genealogyId.value = String(query.genealogyId || "");
feedId.value = String(query.feedId || "");
const selected = findFamilyFeedFixture(genealogyId.value, feedId.value);
currentFeed.value = selected;
feedComments.value = selected?.comments || [];
feedState.value = ["loading", "error", "expired"].includes(query.state)
? selected
? query.state
: "expired"
: selected
? "ready"
: "expired";
loadComments();
});
const submitComment = () => {
if (commentState.value === "validating" || !currentFeed.value) return;
const submitComment = async () => {
if (commentState.value === "submitting" || feedState.value !== "ready") return;
const content = commentDraft.value.trim();
if (!content) {
commentError.value = "请先写下评论内容";
commentNotice.value = "";
return;
}
commentError.value = "";
commentState.value = "validating";
const timer = setTimeout(() => {
if (submitTimer !== timer) return;
submitTimer = null;
commentState.value = "preview";
toastVisible.value = true;
toastTimer = setTimeout(() => { toastVisible.value = false; }, 1800);
}, 320);
submitTimer = timer;
commentNotice.value = "";
commentState.value = "submitting";
try {
await appApi.createFeedComment(genealogyId.value, feedId.value, { commentContent: content }, {
requestController: feedRequestController,
});
commentDraft.value = "";
const refreshed = await loadComments();
if (!refreshed) {
commentState.value = "error";
commentError.value = "评论已提交,但列表刷新失败;请稍后重新查看。";
return;
}
commentState.value = "idle";
commentNotice.value = "评论已提交,并已从服务端刷新。";
} catch (error) {
if (isRequestCancelled(error)) return;
commentState.value = "error";
commentError.value = error?.message || "评论提交失败,请稍后重试。";
}
};
const restoreFeed = () => { feedState.value = "ready"; };
const requestBack = () =>
runBackGuard({
transientOpen: discardVisible.value,
dirty: isDirty.value,
submitting: commentState.value === "validating",
submitting: commentState.value === "submitting",
"close-transient": cancelDiscard,
"block-submitting": () => true,
"confirm-discard": requestDiscardConfirmation,
@@ -161,14 +183,14 @@ const backToFamily = async () => {
return returnTo("F01", { genealogyId: genealogyId.value });
};
const handleStateAction = () => {
if (feedState.value === "error" && currentFeed.value) return restoreFeed();
if (feedState.value === "error") return loadComments();
return backToFamily();
};
onBackPress((event) => handleBackPress(event, requestBack));
onUnmounted(() => {
if (submitTimer) clearTimeout(submitTimer);
if (toastTimer) clearTimeout(toastTimer);
++loadSequence;
feedRequestController.abort();
discardConfirmation.dispose();
});
</script>
@@ -180,26 +202,29 @@ onUnmounted(() => {
.feed-detail-header, .feed-detail-loading, .feed-detail-content { z-index: 1; }
.feed-detail-loading { min-height: calc(100vh - 100rpx); }
.feed-detail-content { padding: 18rpx 24rpx 72rpx; }
.feed-source-note { margin-top: 12rpx; padding: 14rpx 18rpx; border: 1rpx solid rgba(128, 106, 81, .22); border-radius: 10rpx; background: rgba(255,255,255,.56); }
.feed-source-note text { display: block; color: $ink-muted; font-size: 20rpx; line-height: 1.5; }
.feed-article-card, .feed-comments-panel, .feed-comment-form, .feed-state-card { @include adaptive.adaptive-family-content; width: 100%; }
.feed-article-card { padding: 46rpx 48rpx 42rpx; }
.feed-article-card { padding: 42rpx 48rpx; }
.feed-article-card text, .feed-state-card > text { display: block; }
.feed-article-card__meta, .feed-article-card__author { color: $ink-muted; font-size: 22rpx; }
.feed-article-card__title { margin-top: 12rpx; color: $ink; font-family: STKaiti, KaiTi, serif; font-size: 36rpx; font-weight: 700; }
.feed-article-card__body { margin-top: 18rpx; color: $ink; font-size: 25rpx; line-height: 1.75; }
.feed-article-card__author { margin-top: 22rpx; }
.feed-article-card__title { color: $ink; font-family: STKaiti, KaiTi, serif; font-size: 32rpx; font-weight: 700; }
.feed-article-card__body { margin-top: 18rpx; color: $ink-muted; font-size: 24rpx; line-height: 1.7; }
.feed-comments-panel { margin-top: 18rpx; padding: 38rpx 38rpx 34rpx; }
.feed-comments-heading { display: flex; align-items: center; justify-content: space-between; color: $brand-red; font-size: 24rpx; font-weight: 700; }
.feed-comments-heading text:last-child { color: $ink-muted; font-size: 21rpx; font-weight: 400; }
.feed-comment-card { @include adaptive.adaptive-family-field; margin-top: 16rpx; padding: 20rpx 22rpx; }
.feed-comment-card > view { display: flex; justify-content: space-between; gap: 18rpx; color: $ink-muted; font-size: 20rpx; }
.feed-comment-card > text { display: block; margin-top: 9rpx; color: $ink; font-size: 24rpx; line-height: 1.55; }
.feed-comment-card > .feed-comment-card__replies { color: $ink-muted; font-size: 20rpx; }
.feed-comments-empty { padding: 34rpx 10rpx 20rpx; text-align: center; }
.feed-comments-empty text { display: block; color: $ink-muted; font-size: 23rpx; line-height: 1.6; }
.feed-comments-empty text:first-child { color: $ink; font-size: 28rpx; font-weight: 700; }
.feed-comment-form { margin-top: 18rpx; padding: 38rpx 40rpx 42rpx; }
.feed-comment-form > text:first-child { color: $ink; font-family: STKaiti, KaiTi, serif; font-size: 30rpx; font-weight: 700; }
.feed-comment-form textarea { @include adaptive.adaptive-family-field; width: auto; min-width: 0; min-height: 126rpx; margin-top: 16rpx; padding: 20rpx 22rpx; color: $ink; font-size: 24rpx; line-height: 1.55; }
.feed-comment-error { display: block; margin-top: 10rpx; color: $brand-red; font-size: 22rpx; }
.feed-comment-error, .feed-comment-notice { display: block; margin-top: 10rpx; font-size: 22rpx; }
.feed-comment-error { color: $brand-red; }
.feed-comment-notice { color: $ink-muted; }
.feed-comment-form .app-button { margin-top: 22rpx; }
.feed-state-card { min-height: 340rpx; margin-top: 36rpx; padding: 78rpx 52rpx 50rpx; text-align: center; }
.feed-state-card > text:first-child { color: $ink; font-family: STKaiti, KaiTi, serif; font-size: 35rpx; font-weight: 700; }
+27 -132
View File
@@ -1,45 +1,13 @@
<!-- 页面编号F-04用途谱文分类搜索列表与新建入口 -->
<!-- 页面编号F-04用途谱文入口列表 item DTO 未声明时不展示本地文章 -->
<template>
<view class="article-list-page" :class="{ 'article-list-state--loading': listState === 'loading', 'article-list-state--empty': listState === 'empty', 'article-list-state--error': listState === 'error' }">
<view class="article-list-page" :class="`article-list-state--${listState}`">
<ModulePageBackground module="family" />
<view class="article-list-header"><PageHeader title="谱文" :action="hasValidContext ? '新建' : ''" @action="createArticle" /></view>
<view v-if="listState === 'loading'" class="article-list-loading">
<AppLoading text="正在整理家族谱文" description="请稍候,正在读取家训、往事与序言。" />
</view>
<view v-else class="article-list-content">
<template v-if="listState === 'ready'">
<view class="article-search">
<input v-model="keyword" placeholder="搜索标题、作者或正文" confirm-type="search" />
</view>
<view class="article-categories">
<view class="article-categories__row">
<view v-for="category in articleCategories" :key="category" class="article-category" :class="{ 'article-category--active': activeCategory === category }" @click="activeCategory = category"><text>{{ category }}</text></view>
</view>
</view>
<view v-if="filteredArticles.length" class="article-list">
<view v-for="article in filteredArticles" :key="article.id" class="article-card" role="button" :aria-label="`查看谱文${article.title}`" @click="openArticle(article)">
<text class="article-card__category">{{ article.category }}</text>
<text class="article-card__title">{{ article.title }}</text>
<text class="article-card__summary">{{ article.summary }}</text>
<view class="article-card__meta"><text>{{ article.author }}</text><text>{{ article.updatedAt }}</text></view>
</view>
<AppButton block label="新建谱文" @click="createArticle" />
</view>
<view v-else class="article-list-state-card">
<text>{{ keyword || activeCategory !== '全部' ? '没有找到相关谱文' : '还没有谱文' }}</text>
<text>{{ keyword || activeCategory !== '全部' ? '换一个关键词或分类继续查找。' : '从第一篇家训、序言或家族往事开始记录。' }}</text>
<AppButton block :label="keyword || activeCategory !== '全部' ? '清空筛选' : '新建谱文'" @click="keyword || activeCategory !== '全部' ? resetFilters() : createArticle()" />
</view>
</template>
<view v-else class="article-list-state-card">
<view class="article-list-content">
<view class="article-list-state-card">
<text>{{ stateCopy.title }}</text>
<text>{{ stateCopy.copy }}</text>
<AppButton :type="listState === 'error' ? 'secondary' : 'primary'" block :label="stateCopy.action" @click="handleStateAction" />
<AppButton block :label="stateCopy.action" @click="handleStateAction" />
</view>
</view>
</view>
@@ -49,115 +17,42 @@
import { computed, ref } from "vue";
import { onLoad } from "@dcloudio/uni-app";
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 {
getGenealogyFixtureAccess,
listFamilyArticleFixtures,
} from "@/data/mock.js";
import { goBack, openPage } from "@/utils/navigation.js";
const articleCategories = ["全部", "家风家训", "家族往事", "族谱序言"];
const genealogyId = ref("");
const hasValidContext = ref(false);
const articles = ref([]);
const activeCategory = ref("全部");
const keyword = ref("");
const listState = ref("loading");
const filteredArticles = computed(() => {
const term = keyword.value.trim().toLowerCase();
return articles.value.filter((article) => {
const categoryMatched = activeCategory.value === "全部" || article.category === activeCategory.value;
const keywordMatched = !term || `${article.title} ${article.summary} ${article.author}`.toLowerCase().includes(term);
return categoryMatched && keywordMatched;
});
});
const stateCopy = computed(() => ({
empty: {
title: "还没有谱文",
copy: "当前家谱尚无可读取谱文,真实写接口接入后才能新增。",
action: "填写谱文预览",
},
error: {
title: "谱文列表暂不可用",
copy: "请稍后重新查看,已有谱文不会受到影响。",
action: "重新查看",
},
invalid: {
title: "谱文入口无效",
copy: "没有找到可访问的成员家谱,页面不会展示其他家谱内容。",
action: "返回上一页",
},
}[listState.value]));
const listState = ref("unavailable");
const stateCopy = computed(() => hasValidContext.value
? {
title: "谱文列表待后端字段合同",
copy: "列表接口只声明通用对象数组,没有文章 ID、分类、标题、摘要、作者或更新时间字段;页面已停止展示本地文章和本地筛选。",
action: "新建谱文",
}
: {
title: "谱文入口无效",
copy: "没有取得有效家谱标识,页面不会展示其他家谱内容。",
action: "返回上一页",
},
);
onLoad((query) => {
genealogyId.value = String(query.genealogyId || "");
hasValidContext.value = ["owner", "member"].includes(
getGenealogyFixtureAccess(genealogyId.value).accessRole,
);
if (!genealogyId.value || !hasValidContext.value) {
listState.value = "invalid";
return;
}
articles.value = listFamilyArticleFixtures(genealogyId.value);
listState.value = ["loading", "empty", "error"].includes(query.state)
? query.state
: articles.value.length
? "ready"
: "empty";
genealogyId.value = String(query?.genealogyId || "");
hasValidContext.value = /^[1-9]\d*$/.test(genealogyId.value);
listState.value = hasValidContext.value ? "unavailable" : "invalid";
});
const openArticle = (article) =>
openPage(
"F05",
{ genealogyId: genealogyId.value, articleId: String(article.id) },
"F04",
);
const createArticle = () =>
hasValidContext.value
? openPage(
"F06",
{ genealogyId: genealogyId.value, mode: "create" },
"F04",
)
: Promise.resolve(false);
const restoreArticles = () => {
articles.value = listFamilyArticleFixtures(genealogyId.value);
listState.value = articles.value.length ? "ready" : "empty";
};
const handleStateAction = () => {
if (listState.value === "invalid") return goBack();
if (listState.value === "error") return restoreArticles();
return createArticle();
};
const resetFilters = () => { keyword.value = ""; activeCategory.value = "全部"; };
const createArticle = () => hasValidContext.value
? openPage("F06", { genealogyId: genealogyId.value, mode: "create" }, "F04")
: Promise.resolve(false);
const handleStateAction = () => hasValidContext.value ? createArticle() : goBack();
</script>
<style scoped lang="scss">
@use "../../styles/adaptive-frame-profiles.scss" as adaptive;
.article-list-page { display: flex; min-height: 100vh; flex-direction: column; background: $paper; }
.article-list-header, .article-list-loading, .article-list-content { z-index: 1; }
.article-list-loading { min-height: calc(100vh - 100rpx); }
.article-list-header, .article-list-content { z-index: 1; }
.article-list-content { padding: 18rpx 24rpx 72rpx; }
.article-search { @include adaptive.adaptive-family-field; min-height: 82rpx; padding: 12rpx 26rpx; }
.article-search input { width: 100%; min-height: 58rpx; color: $ink; font-size: 24rpx; }
.article-categories { width: 100%; margin-top: 16rpx; }
.article-categories__row { display: flex; flex-wrap: wrap; gap: 12rpx; padding: 2rpx 4rpx 8rpx; }
.article-category { @include adaptive.adaptive-family-field; min-height: 58rpx; padding: 0 28rpx; color: $ink-muted; font-size: 23rpx; }
.article-category text { display: flex; min-height: 58rpx; align-items: center; }
.article-category--active { color: $brand-red; font-weight: 700; }
.article-list { display: flex; flex-direction: column; gap: 16rpx; margin-top: 10rpx; }
.article-card, .article-list-state-card { @include adaptive.adaptive-family-content; width: 100%; }
.article-card { min-height: 196rpx; padding: 34rpx 46rpx 30rpx; }
.article-card > text { display: block; }
.article-card__category { color: $brand-red; font-size: 21rpx; font-weight: 700; letter-spacing: 2rpx; }
.article-card__title { margin-top: 7rpx; color: $ink; font-family: STKaiti, KaiTi, serif; font-size: 31rpx; font-weight: 700; }
.article-card__summary { margin-top: 10rpx; color: $ink-muted; font-size: 23rpx; line-height: 1.55; }
.article-card__meta { display: flex; flex-wrap: wrap; justify-content: space-between; gap: 6rpx 18rpx; margin-top: 12rpx; color: $ink-muted; font-size: 20rpx; }
.article-list > .app-button { margin-top: 8rpx; }
.article-list-state-card { min-height: 340rpx; margin-top: 30rpx; padding: 78rpx 52rpx 50rpx; text-align: center; }
.article-list-state-card { @include adaptive.adaptive-family-content; width: 100%; min-height: 340rpx; margin-top: 30rpx; padding: 78rpx 52rpx 50rpx; text-align: center; }
.article-list-state-card > text { display: block; }
.article-list-state-card > text:first-child { color: $ink; font-family: STKaiti, KaiTi, serif; font-size: 35rpx; font-weight: 700; }
.article-list-state-card > text:nth-child(2) { margin-top: 18rpx; color: $ink-muted; font-size: 24rpx; line-height: 1.65; }
+28 -92
View File
@@ -1,33 +1,13 @@
<!-- 页面编号F-05用途谱文正文收藏编辑与受控状态 -->
<!-- 页面编号F-05用途谱文详情详情 DTO 未声明时不展示本地正文 -->
<template>
<view class="article-detail-page" :class="articleStateClasses">
<view class="article-detail-page">
<ModulePageBackground module="family" />
<view class="article-detail-header"><PageHeader title="谱文详情" /></view>
<view v-if="articleState === 'loading'" class="article-detail-loading">
<AppLoading text="正在读取谱文" description="请稍候,正在整理正文与收录信息。" />
</view>
<view v-else class="article-detail-content">
<template v-if="articleState === 'ready'">
<view class="article-paper">
<text class="article-paper__category">{{ article.category }}</text>
<text class="article-paper__title">{{ article.title }}</text>
<view class="article-paper__meta"><text>{{ article.author }}</text><text>{{ article.updatedAt }}</text></view>
<view class="article-paper__body">
<text v-for="(paragraph, index) in articleParagraphs" :key="index">{{ paragraph }}</text>
</view>
</view>
<view class="article-actions">
<AppButton block disabled label="收藏暂未开放" />
<AppButton type="secondary" block label="编辑谱文" @click="editArticle" />
</view>
</template>
<view v-else class="article-state-card">
<view class="article-detail-header"><PageHeader title="谱文详情" custom-back @back="backToArticles" /></view>
<view class="article-detail-content">
<view class="article-state-card">
<text>{{ stateCopy.title }}</text>
<text>{{ stateCopy.copy }}</text>
<AppButton :type="articleState === 'error' ? 'secondary' : 'primary'" block :label="stateCopy.action" @click="handleStateAction" />
<AppButton block :label="stateCopy.action" @click="backToArticles" />
</view>
</view>
</view>
@@ -37,86 +17,42 @@
import { computed, ref } from "vue";
import { onLoad } from "@dcloudio/uni-app";
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 { findFamilyArticleFixture } from "@/data/mock.js";
import { goBack, openPage, returnTo } from "@/utils/navigation.js";
import { goBack, returnTo } from "@/utils/navigation.js";
const genealogyId = ref("");
const articleId = ref("");
const article = ref(null);
const articleState = ref("loading");
const articleParagraphs = computed(() => article.value?.paragraphs || []);
const articleStateClasses = computed(() => ({
[`article-state--${articleState.value}`]: true,
"article-state--expired": articleState.value === "expired",
"article-state--privacy": articleState.value === "privacy",
"article-state--error": articleState.value === "error",
}));
const stateCopy = computed(() => ({
expired: { title: "这篇谱文已无法查看", copy: "当前家谱中不存在这篇谱文,页面不会回退到其他文章。", action: genealogyId.value ? "返回谱文列表" : "返回上一页" },
privacy: { title: "这篇谱文暂未公开", copy: "作者仅向有权限的家人开放正文,请返回谱文列表查看其他内容。", action: "返回谱文列表" },
error: { title: "谱文暂不可用", copy: "请稍后重新查看,已有谱文不会受到影响。", action: "重新查看" },
}[articleState.value] || {}));
onLoad((query) => {
genealogyId.value = String(query.genealogyId || "");
articleId.value = String(query.articleId || "");
const selected = findFamilyArticleFixture(genealogyId.value, articleId.value);
article.value = selected;
articleState.value = ["loading", "error", "expired", "privacy"].includes(query.state)
? selected
? query.state
: "expired"
: selected
? "ready"
: "expired";
});
const editArticle = () =>
openPage(
"F06",
{
genealogyId: genealogyId.value,
mode: "edit",
articleId: articleId.value,
const hasValidContext = computed(() => /^[1-9]\d*$/.test(genealogyId.value) && /^[1-9]\d*$/.test(articleId.value));
const stateCopy = computed(() => hasValidContext.value
? {
title: "谱文正文待后端字段合同",
copy: "详情接口没有声明文章标题、分类、作者、更新时间或正文段落字段;页面已停止读取本地文章,不能猜测这些字段。",
action: "返回谱文列表",
}
: {
title: "谱文入口无效",
copy: "没有取得有效家谱或文章标识,页面不会回退到其他文章。",
action: "返回上一页",
},
"F05",
);
const backToArticles = () =>
genealogyId.value
? returnTo("F04", { genealogyId: genealogyId.value })
: goBack();
const handleStateAction = () => {
if (articleState.value === "error" && article.value) {
articleState.value = "ready";
return;
}
return backToArticles();
};
);
onLoad((query) => {
genealogyId.value = String(query?.genealogyId || "");
articleId.value = String(query?.articleId || "");
});
const backToArticles = () => /^[1-9]\d*$/.test(genealogyId.value)
? returnTo("F04", { genealogyId: genealogyId.value })
: goBack();
</script>
<style scoped lang="scss">
@use "../../styles/adaptive-frame-profiles.scss" as adaptive;
.article-detail-page { display: flex; min-height: 100vh; flex-direction: column; background: $paper; }
.article-detail-header, .article-detail-loading, .article-detail-content { z-index: 1; }
.article-detail-loading { min-height: calc(100vh - 100rpx); }
.article-detail-header, .article-detail-content { z-index: 1; }
.article-detail-content { padding: 18rpx 24rpx 72rpx; }
.article-paper, .article-state-card { @include adaptive.adaptive-family-content; width: 100%; }
.article-paper { padding: 50rpx 48rpx 54rpx; }
.article-paper > text { display: block; }
.article-paper__category { color: $brand-red; font-size: 22rpx; font-weight: 700; letter-spacing: 3rpx; }
.article-paper__title { margin-top: 12rpx; color: $ink; font-family: STKaiti, KaiTi, serif; font-size: 39rpx; font-weight: 700; line-height: 1.35; }
.article-paper__meta { display: flex; flex-wrap: wrap; justify-content: space-between; gap: 6rpx 18rpx; margin-top: 16rpx; color: $ink-muted; font-size: 21rpx; }
.article-paper__body { margin-top: 28rpx; }
.article-paper__body text { display: block; margin-top: 18rpx; color: $ink; font-size: 25rpx; line-height: 1.85; text-align: justify; }
.article-actions { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: 14rpx; margin-top: 18rpx; }
.article-state-card { min-height: 350rpx; margin-top: 36rpx; padding: 78rpx 52rpx 50rpx; text-align: center; }
.article-state-card { @include adaptive.adaptive-family-content; width: 100%; min-height: 350rpx; margin-top: 36rpx; padding: 78rpx 52rpx 50rpx; text-align: center; }
.article-state-card > text { display: block; }
.article-state-card > text:first-child { color: $ink; font-family: STKaiti, KaiTi, serif; font-size: 35rpx; font-weight: 700; }
.article-state-card > text:nth-child(2) { margin-top: 18rpx; color: $ink-muted; font-size: 24rpx; line-height: 1.65; }
.article-state-card .app-button { margin-top: 30rpx; }
@media (max-width: 340px) { .article-actions { grid-template-columns: minmax(0, 1fr); } }
</style>
+97 -419
View File
@@ -1,26 +1,27 @@
<!-- 页面编号F-06用途新建与编辑谱文 -->
<!-- 页面编号F-06用途按已声明 articleTitle/articleContent 创建谱文 -->
<template>
<view
class="article-editor-page"
:class="`article-editor-state--${editorState}`"
>
<view class="article-editor-page" :class="`article-editor-state--${editorState}`">
<ModulePageBackground module="family" />
<view class="article-editor-page__header">
<PageHeader title="编辑谱文" custom-back @back="requestBack" />
</view>
<view v-if="editorState === 'loading'" class="article-editor-loading">
<AppLoading
text="正在打开谱文编辑器"
description="请稍候,草稿正在展开。"
/>
</view>
<view
v-else-if="editorState === 'preview' || editorState === 'invalid'"
class="article-editor-content"
>
<view class="editor-result-card">
<view class="article-editor-page__header"><PageHeader title="新建谱文" custom-back @back="requestBack" /></view>
<view class="article-editor-content">
<view v-if="editorState === 'form'" class="editor-panel">
<view class="editor-panel__body">
<text class="editor-eyebrow">服务端创建</text>
<text class="editor-title">把值得传承的故事写下来</text>
<text class="editor-intro">本页只发送 Apifox 已声明且可映射的文章标题与正文分类需要 `categoryId`当前没有可用分类 owner故不展示或猜测分类</text>
<view class="editor-field">
<text class="editor-field__label">文章标题</text>
<view class="editor-control"><input v-model="form.title" maxlength="40" placeholder="请输入文章标题" placeholder-class="editor-placeholder" @input="submitError = ''" /></view>
</view>
<view class="editor-field">
<text class="editor-field__label">正文内容</text>
<view class="editor-control editor-control--textarea"><textarea v-model="form.content" auto-height maxlength="1200" placeholder="记录家训、往事或想留给后人的话" placeholder-class="editor-placeholder" @input="submitError = ''" /></view>
</view>
<text v-if="submitError" class="editor-save-error">{{ submitError }}</text>
<AppButton block :label="isSubmitting ? '正在提交' : '提交谱文'" :disabled="isSubmitting" @click="submit" />
</view>
</view>
<view v-else class="editor-result-card">
<view class="editor-result-card__body">
<text class="editor-eyebrow">{{ resultCopy.eyebrow }}</text>
<text class="editor-result-card__title">{{ resultCopy.title }}</text>
@@ -29,89 +30,6 @@
</view>
</view>
</view>
<view v-else class="article-editor-content">
<view class="editor-panel">
<view class="editor-panel__body">
<text class="editor-eyebrow">草稿 · 未发布</text>
<text class="editor-title">把值得传承的故事写下来</text>
<text class="editor-intro"
>补充标题分类和正文当前只校验并生成本地预览</text
>
<view class="editor-field">
<text class="editor-field__label">文章标题</text>
<view
class="editor-control"
:class="{ 'editor-control--error': fieldErrors.title }"
>
<input
v-model="form.title"
maxlength="40"
placeholder="请输入文章标题"
placeholder-class="editor-placeholder"
@input="clearFieldError('title')"
/>
</view>
<text v-if="fieldErrors.title" class="editor-field__error">{{
fieldErrors.title
}}</text>
</view>
<view class="editor-field">
<text class="editor-field__label">文章分类</text>
<view
class="editor-control"
:class="{ 'editor-control--error': fieldErrors.category }"
>
<input
v-model="form.category"
maxlength="20"
placeholder="如:家风家训"
placeholder-class="editor-placeholder"
@input="clearFieldError('category')"
/>
</view>
<text v-if="fieldErrors.category" class="editor-field__error">{{
fieldErrors.category
}}</text>
</view>
<view class="editor-field">
<text class="editor-field__label">正文内容</text>
<view
class="editor-control editor-control--textarea"
:class="{ 'editor-control--error': fieldErrors.content }"
>
<textarea
v-model="form.content"
auto-height
maxlength="1200"
placeholder="记录家训、往事或想留给后人的话"
placeholder-class="editor-placeholder"
@input="clearFieldError('content')"
/>
</view>
<text v-if="fieldErrors.content" class="editor-field__error">{{
fieldErrors.content
}}</text>
</view>
<text class="editor-draft-note"
>当前为未发布草稿真实草稿保存将在接口阶段接入</text
>
<text v-if="editorState === 'error'" class="editor-save-error"
>保存失败已填写内容仍保留请稍后重试</text
>
<AppButton
block
:label="actionLabel"
:disabled="isSubmitting"
@click="submit"
/>
</view>
</view>
</view>
</view>
<AppDialog
:visible="discardVisible"
@@ -127,87 +45,38 @@
</template>
<script setup>
import { computed, reactive, ref } from "vue";
import { onBackPress, onLoad, onUnload } from "@dcloudio/uni-app";
import { computed, onUnmounted, reactive, ref } from "vue";
import { onBackPress, onLoad } from "@dcloudio/uni-app";
import AppButton from "@/components/AppButton.vue";
import AppDialog from "@/components/AppDialog.vue";
import AppLoading from "@/components/AppLoading.vue";
import ModulePageBackground from "@/components/ModulePageBackground.vue";
import PageHeader from "@/components/PageHeader.vue";
import {
findFamilyArticleFixture,
getGenealogyFixtureAccess,
} from "@/data/mock.js";
import { appApi, createRequestController, isRequestCancelled } from "@/utils/api.js";
import { createDiscardConfirmation } from "@/utils/discard-confirmation.js";
import {
goBack,
handleBackPress,
returnTo,
runBackGuard,
} from "@/utils/navigation.js";
import { goBack, handleBackPress, returnTo, runBackGuard } from "@/utils/navigation.js";
const draftFixture = {
title: "汤氏家训辑录",
category: "家风家训",
content:
"孝友传家,勤俭立业;敬祖睦宗,诚实待人。愿后人常怀感恩,彼此扶持。",
};
const editorState = ref("form");
const genealogyId = ref("");
const articleId = ref("");
const editorMode = ref("create");
const isSubmitting = ref(false);
const discardVisible = ref(false);
const allowedStates = new Set([
"draft",
"loading",
"validation",
"error",
"preview",
]);
const form = reactive({ title: "", category: "", content: "" });
const fieldErrors = reactive({ title: "", category: "", content: "" });
const baseline = ref("");
let saveTimer = null;
const actionLabel = computed(() =>
isSubmitting.value
? "正在校验…"
: editorState.value === "error"
? "重新校验"
: "生成本地预览",
);
const formSnapshot = computed(() => JSON.stringify(form));
const isDirty = computed(() =>
Boolean(baseline.value) && formSnapshot.value !== baseline.value,
);
const hasValidContext = computed(() =>
Boolean(
genealogyId.value &&
["owner", "member"].includes(
getGenealogyFixtureAccess(genealogyId.value).accessRole,
) &&
(editorMode.value === "create" ||
(editorMode.value === "edit" && articleId.value)),
),
);
const resultCopy = computed(() =>
editorState.value === "preview"
? {
eyebrow: "本地流程预览",
title: "谱文内容已通过本地校验",
copy: "当前尚未提交服务器,返回后不会新增或修改谱文。",
action:
editorMode.value === "edit"
? "返回原谱文(不保存)"
: "返回谱文列表(不保存)",
}
: {
eyebrow: "谱文入口无效",
title: "没有找到要编辑的谱文上下文",
copy: "请从当前家谱的谱文列表重新进入,页面不会创建无归属内容。",
action: "返回上一页",
},
const submitError = ref("");
const form = reactive({ title: "", content: "" });
const requestController = createRequestController();
const isDirty = computed(() => Boolean(form.title.trim() || form.content.trim()));
const hasValidContext = computed(() => /^[1-9]\d*$/.test(genealogyId.value));
const resultCopy = computed(() => editorState.value === "success"
? {
eyebrow: "服务端已接受",
title: "谱文已提交",
copy: "服务端已返回成功信封。谱文列表与详情仍缺展示 DTO,返回后不会生成本地文章卡片。",
action: "返回谱文列表",
}
: {
eyebrow: "谱文入口无效",
title: "无法创建谱文",
copy: "当前只有新建操作可映射;编辑需要可靠的文章详情和文章 ID,暂不从 fixture 进入。",
action: "返回上一页",
},
);
const discardConfirmation = createDiscardConfirmation((visible) => {
discardVisible.value = visible;
@@ -216,263 +85,72 @@ const requestDiscardConfirmation = discardConfirmation.request;
const confirmDiscard = discardConfirmation.confirm;
const cancelDiscard = discardConfirmation.cancel;
const fillDraft = () => {
Object.assign(form, draftFixture);
};
const showAllFieldErrors = () => {
fieldErrors.title = "请填写文章标题";
fieldErrors.category = "请填写文章分类";
fieldErrors.content = "请填写正文内容";
};
onLoad((query) => {
genealogyId.value = String(query.genealogyId || "");
articleId.value = String(query.articleId || "");
editorMode.value = String(query.mode || "");
const article =
editorMode.value === "edit"
? findFamilyArticleFixture(genealogyId.value, articleId.value)
: null;
const modeIsValid =
(editorMode.value === "create" && !articleId.value) ||
(editorMode.value === "edit" && Boolean(article));
if (!modeIsValid || !hasValidContext.value) {
editorState.value = "invalid";
return;
}
const requestedState = allowedStates.has(query.state) ? query.state : "form";
if (article) {
Object.assign(form, {
title: article.title,
category: article.category,
content: article.paragraphs.join("\n\n"),
});
} else if (["draft", "error", "preview"].includes(requestedState)) {
fillDraft();
}
baseline.value = formSnapshot.value;
if (editorMode.value === "create" && requestedState === "draft") {
baseline.value = JSON.stringify({ title: "", category: "", content: "" });
}
if (requestedState === "validation") showAllFieldErrors();
editorState.value = requestedState;
genealogyId.value = String(query?.genealogyId || "");
if (!hasValidContext.value || query?.mode !== "create") editorState.value = "invalid";
});
const clearFieldError = (field) => {
fieldErrors[field] = "";
if (
editorState.value === "error" ||
editorState.value === "validation"
) {
editorState.value = "form";
}
};
const validate = () => {
fieldErrors.title = form.title.trim() ? "" : "请填写文章标题";
fieldErrors.category = form.category.trim() ? "" : "请填写文章分类";
fieldErrors.content = form.content.trim() ? "" : "请填写正文内容";
return !fieldErrors.title && !fieldErrors.category && !fieldErrors.content;
};
const submit = () => {
const submit = async () => {
if (isSubmitting.value || !hasValidContext.value) return;
if (!validate()) {
editorState.value = "validation";
const articleTitle = form.title.trim();
const articleContent = form.content.trim();
if (!articleTitle || !articleContent) {
submitError.value = !articleTitle ? "请填写文章标题" : "请填写正文内容";
return;
}
isSubmitting.value = true;
const submitSnapshot = formSnapshot.value;
const timer = setTimeout(() => {
if (saveTimer !== timer) return;
editorState.value = submitSnapshot ? "preview" : "error";
submitError.value = "";
try {
await appApi.createArticle(genealogyId.value, { articleTitle, articleContent }, { requestController });
form.title = "";
form.content = "";
editorState.value = "success";
} catch (error) {
if (!isRequestCancelled(error)) submitError.value = error?.message || "谱文提交失败,请稍后重试。";
} finally {
isSubmitting.value = false;
saveTimer = null;
}, 320);
saveTimer = timer;
}
};
const requestBack = () =>
runBackGuard({
transientOpen: discardVisible.value,
dirty: isDirty.value,
submitting: isSubmitting.value,
"close-transient": cancelDiscard,
"block-submitting": () => true,
"confirm-discard": requestDiscardConfirmation,
});
const returnToTarget = async () => {
const confirmed = isDirty.value ? await requestDiscardConfirmation() : true;
if (!confirmed) return false;
return editorMode.value === "edit"
? returnTo("F05", {
genealogyId: genealogyId.value,
articleId: articleId.value,
})
: returnTo("F04", { genealogyId: genealogyId.value });
};
const handleResultAction = () =>
editorState.value === "preview" ? returnToTarget() : goBack();
const requestBack = () => runBackGuard({
transientOpen: discardVisible.value,
dirty: isDirty.value,
submitting: isSubmitting.value,
"close-transient": cancelDiscard,
"block-submitting": () => true,
"confirm-discard": requestDiscardConfirmation,
});
const handleResultAction = () => editorState.value === "success"
? returnTo("F04", { genealogyId: genealogyId.value })
: goBack();
onBackPress((event) => handleBackPress(event, requestBack));
onUnload(() => {
if (saveTimer) clearTimeout(saveTimer);
onUnmounted(() => {
requestController.abort();
discardConfirmation.dispose();
});
</script>
<style scoped lang="scss">
@use "../../styles/adaptive-frame-profiles.scss" as adaptive;
.article-editor-page {
display: flex;
min-height: 100vh;
flex-direction: column;
background: $paper;
}
.article-editor-page__header,
.article-editor-loading,
.article-editor-content {
z-index: 1;
}
.article-editor-loading {
padding-top: 150rpx;
}
.article-editor-content {
padding: 18rpx 20rpx calc(38rpx + env(safe-area-inset-bottom));
}
.editor-panel,
.editor-result-card {
@include adaptive.adaptive-family-panel;
width: 100%;
}
.editor-panel__body {
padding: 38rpx 34rpx 42rpx;
}
.editor-eyebrow {
display: block;
color: $brand-red;
font-size: 23rpx;
letter-spacing: 3rpx;
text-align: center;
}
.editor-title {
display: block;
margin-top: 10rpx;
color: $ink;
font-family: STKaiti, KaiTi, serif;
font-size: 34rpx;
font-weight: 700;
text-align: center;
}
.editor-intro {
display: block;
margin: 10rpx 8rpx 22rpx;
color: $ink-muted;
font-size: 23rpx;
line-height: 1.55;
text-align: center;
}
.editor-field {
margin-top: 18rpx;
}
.editor-field__label {
display: block;
margin: 0 8rpx 8rpx;
color: $ink;
font-size: 25rpx;
font-weight: 700;
}
.editor-control {
@include adaptive.adaptive-family-field;
display: flex;
min-height: 82rpx;
align-items: center;
}
.editor-control input,
.editor-control textarea {
box-sizing: border-box;
width: 100%;
color: $ink;
font-size: 24rpx;
}
.editor-control input {
width: 100%;
min-height: 82rpx;
padding: 0 28rpx;
}
.editor-control--textarea {
min-height: 220rpx;
padding: 20rpx 24rpx;
box-sizing: border-box;
}
.editor-control textarea {
width: 100%;
min-height: 180rpx;
line-height: 1.65;
}
.editor-control--error {
filter: sepia(0.18) saturate(1.2);
}
.editor-placeholder {
color: #9e8e79;
}
.editor-field__error,
.editor-save-error {
display: block;
color: $brand-red;
font-size: 24rpx;
line-height: 34rpx;
}
.editor-field__error {
margin: 5rpx 8rpx 0;
}
.editor-draft-note {
display: block;
margin: 20rpx 10rpx 0;
color: $ink-muted;
font-size: 22rpx;
line-height: 1.55;
text-align: center;
}
.editor-save-error {
margin: 12rpx 8rpx 0;
text-align: center;
}
.editor-panel__body > .app-button {
margin-top: 22rpx;
}
.editor-result-card {
min-height: 420rpx;
}
.editor-result-card__body {
padding: 112rpx 52rpx 68rpx;
text-align: center;
}
.editor-result-card__title {
display: block;
margin-top: 14rpx;
color: $ink;
font-family: STKaiti, KaiTi, serif;
font-size: 36rpx;
font-weight: 700;
}
.editor-result-card__copy {
display: block;
margin-top: 16rpx;
color: $ink-muted;
font-size: 24rpx;
line-height: 1.65;
}
.editor-result-card__body > .app-button {
margin-top: 30rpx;
}
@media (max-width: 340px) {
.article-editor-content {
padding-right: 14rpx;
padding-left: 14rpx;
}
.editor-panel__body {
padding-right: 26rpx;
padding-left: 26rpx;
}
}
.article-editor-page { display: flex; min-height: 100vh; flex-direction: column; background: $paper; }
.article-editor-page__header, .article-editor-content { z-index: 1; }
.article-editor-content { padding: 18rpx 20rpx calc(38rpx + env(safe-area-inset-bottom)); }
.editor-panel, .editor-result-card { @include adaptive.adaptive-family-panel; width: 100%; }
.editor-panel__body { padding: 38rpx 34rpx 42rpx; }
.editor-eyebrow { display: block; color: $brand-red; font-size: 23rpx; letter-spacing: 3rpx; text-align: center; }
.editor-title { display: block; margin-top: 10rpx; color: $ink; font-family: STKaiti, KaiTi, serif; font-size: 34rpx; font-weight: 700; text-align: center; }
.editor-intro { display: block; margin: 10rpx 8rpx 22rpx; color: $ink-muted; font-size: 23rpx; line-height: 1.55; text-align: center; }
.editor-field { margin-top: 18rpx; }
.editor-field__label { display: block; margin: 0 8rpx 8rpx; color: $ink; font-size: 25rpx; font-weight: 700; }
.editor-control { @include adaptive.adaptive-family-field; display: flex; min-height: 82rpx; align-items: center; }
.editor-control input, .editor-control textarea { box-sizing: border-box; width: 100%; color: $ink; font-size: 24rpx; }
.editor-control input { min-height: 82rpx; padding: 0 28rpx; }
.editor-control--textarea { min-height: 220rpx; padding: 20rpx 24rpx; }
.editor-control textarea { min-height: 180rpx; line-height: 1.65; }
.editor-placeholder { color: #9e8e79; }
.editor-save-error { display: block; margin: 12rpx 8rpx 0; color: $brand-red; font-size: 24rpx; line-height: 34rpx; text-align: center; }
.editor-panel__body > .app-button { margin-top: 22rpx; }
.editor-result-card { min-height: 420rpx; }
.editor-result-card__body { padding: 112rpx 52rpx 68rpx; text-align: center; }
.editor-result-card__title { display: block; margin-top: 14rpx; color: $ink; font-family: STKaiti, KaiTi, serif; font-size: 36rpx; font-weight: 700; }
.editor-result-card__copy { display: block; margin-top: 16rpx; color: $ink-muted; font-size: 24rpx; line-height: 1.65; }
.editor-result-card__body > .app-button { margin-top: 30rpx; }
</style>
+58 -188
View File
@@ -1,63 +1,22 @@
<!-- 页面编号F-07用途家族相册列表创建与受控状态 -->
<!-- 页面编号F-07用途相册入口列表 DTO 缺失时不展示本地相册 -->
<template>
<view class="album-list-page" :class="albumStateClasses">
<view class="album-list-page">
<ModulePageBackground module="family" />
<view class="album-list-header"><PageHeader title="家族相册" :action="hasValidContext ? '新建' : ''" custom-back @back="requestBack" @action="createAlbum" /></view>
<view v-if="albumState === 'loading'" class="album-list-loading">
<AppLoading text="正在整理家族相册" description="请稍候,正在读取照片与更新时间。" />
</view>
<view v-else class="album-list-content">
<template v-if="albumState === 'ready'">
<view class="album-list-lead"><text>让每一张照片都回到家人身边</text><text> {{ albums.length }} 本相册</text></view>
<view v-if="localAlbumPreview" class="album-local-preview">
<text>本地预览 · 尚未提交服务器</text>
<text>{{ localAlbumPreview.name }}</text>
<text>这本相册不会加入正式列表离开页面后不保存</text>
</view>
<view v-if="albums.length" class="album-list">
<view v-for="album in albums" :key="album.id" class="album-card" role="button" :aria-label="`打开相册${album.name}`" @click="openAlbum(album)">
<view class="album-card__media"><image class="album-card__cover" :src="album.cover" mode="aspectFill" :alt="album.name" /></view>
<view class="album-card__copy">
<text>{{ album.name }}</text>
<text>{{ album.photoCount }} 张照片 · {{ album.updatedAt }}</text>
<text>{{ album.description }}</text>
</view>
</view>
<AppButton block label="新建相册" @click="createAlbum" />
</view>
<view v-else class="album-state-card">
<text>还没有相册</text><text>创建一本相册把团圆成长与祖居旧影整理在一起</text>
<AppButton block label="新建相册" @click="createAlbum" />
</view>
</template>
<view v-else class="album-state-card">
<view class="album-list-header"><PageHeader title="家族相册" :action="hasValidContext ? '新建' : ''" custom-back @back="requestBack" @action="openCreateDialog" /></view>
<view class="album-list-content">
<view class="album-state-card">
<text>{{ stateCopy.title }}</text>
<text>{{ stateCopy.copy }}</text>
<AppButton :type="albumState === 'error' ? 'secondary' : 'primary'" block :label="stateCopy.action" @click="handleStateAction" />
<AppButton block :label="stateCopy.action" @click="handleStateAction" />
</view>
</view>
<AppDialog :visible="dialogVisible" eyebrow="新建相册预览" title="为家人整理一段影像" message="当前只生成本地预览,不会创建服务器相册。" confirm-text="生成本地预览" cancel-text="取消" show-cancel :close-on-mask="false" @confirm="confirmCreateAlbum" @cancel="requestCloseCreateDialog">
<AppDialog :visible="dialogVisible" eyebrow="新建相册" title="为家人整理一段影像" message="仅提交已声明的相册名称;封面、排序和状态没有可用 owner,不会猜测提交。" :confirm-text="isSubmitting ? '正在提交' : '提交相册'" cancel-text="取消" show-cancel :close-on-mask="false" @confirm="submitAlbum" @cancel="closeCreateDialog">
<view class="album-dialog-field">
<text>相册名称</text>
<input v-model="albumNameDraft" maxlength="30" placeholder="例如:春节团圆" />
<text v-if="albumNameError">{{ albumNameError }}</text>
<input v-model="albumNameDraft" maxlength="30" placeholder="例如:春节团圆" @input="submitError = ''" />
<text v-if="submitError">{{ submitError }}</text>
</view>
</AppDialog>
<AppDialog
:visible="discardVisible"
title="放弃相册草稿?"
message="当前相册尚未提交服务器,确认后不会保留。"
confirm-text="放弃"
cancel-text="继续整理"
show-cancel
:close-on-mask="false"
@confirm="confirmDiscard"
@cancel="cancelDiscard"
/>
</view>
</template>
@@ -66,162 +25,74 @@ import { computed, onUnmounted, ref } from "vue";
import { onBackPress, onLoad } from "@dcloudio/uni-app";
import AppButton from "@/components/AppButton.vue";
import AppDialog from "@/components/AppDialog.vue";
import AppLoading from "@/components/AppLoading.vue";
import ModulePageBackground from "@/components/ModulePageBackground.vue";
import PageHeader from "@/components/PageHeader.vue";
import {
getGenealogyFixtureAccess,
listFamilyAlbumFixtures,
} from "@/data/mock.js";
import { createDiscardConfirmation } from "@/utils/discard-confirmation.js";
import {
goBack,
handleBackPress,
openPage,
runBackGuard,
} from "@/utils/navigation.js";
import { appApi, createRequestController, isRequestCancelled } from "@/utils/api.js";
import { goBack, handleBackPress, runBackGuard } from "@/utils/navigation.js";
const genealogyId = ref("");
const hasValidContext = ref(false);
const albums = ref([]);
const albumState = ref("loading");
const dialogVisible = ref(false);
const albumNameDraft = ref("");
const albumNameError = ref("");
const localAlbumPreview = ref(null);
const discardVisible = ref(false);
const albumStateClasses = computed(() => ({
[`album-list-state--${albumState.value}`]: true,
"album-state--empty": albumState.value === "empty",
"album-list-state--loading": albumState.value === "loading",
"album-list-state--error": albumState.value === "error",
}));
const isDirty = computed(() =>
Boolean(albumNameDraft.value.trim() || localAlbumPreview.value),
const submitError = ref("");
const isSubmitting = ref(false);
const created = ref(false);
const requestController = createRequestController();
const stateCopy = computed(() => !hasValidContext.value
? { title: "相册入口无效", copy: "没有取得有效家谱标识,页面不会展示其他家谱相册。", action: "返回上一页" }
: created.value
? { title: "相册已提交服务端", copy: "服务端已返回成功信封;相册列表仍没有条目 DTO,页面不会生成本地相册卡片。", action: "新建相册" }
: { title: "相册列表待后端字段合同", copy: "列表响应没有声明相册 ID、封面、名称、照片数、描述或更新时间字段;页面已停止展示本地相册。", action: "新建相册" },
);
const stateCopy = computed(() => ({
empty: {
title: "还没有相册",
copy: "当前家谱尚无可读取相册,可先生成不会保存的本地预览。",
action: "填写相册预览",
},
error: {
title: "相册暂不可用",
copy: "请稍后重新查看,已有照片不会受到影响。",
action: "重新查看",
},
invalid: {
title: "相册入口无效",
copy: "没有找到可访问的成员家谱,页面不会展示其他家谱相册。",
action: "返回上一页",
},
}[albumState.value]));
const discardConfirmation = createDiscardConfirmation((visible) => {
discardVisible.value = visible;
});
const requestDiscardConfirmation = discardConfirmation.request;
const confirmDiscard = discardConfirmation.confirm;
const cancelDiscard = discardConfirmation.cancel;
onLoad((query) => {
genealogyId.value = String(query.genealogyId || "");
hasValidContext.value = ["owner", "member"].includes(
getGenealogyFixtureAccess(genealogyId.value).accessRole,
);
if (!genealogyId.value || !hasValidContext.value) {
albumState.value = "invalid";
genealogyId.value = String(query?.genealogyId || "");
hasValidContext.value = /^[1-9]\d*$/.test(genealogyId.value);
});
const openCreateDialog = () => {
if (!hasValidContext.value || isSubmitting.value) return;
albumNameDraft.value = "";
submitError.value = "";
dialogVisible.value = true;
};
const closeCreateDialog = () => {
if (!isSubmitting.value) dialogVisible.value = false;
};
const submitAlbum = async () => {
if (isSubmitting.value) return;
const albumName = albumNameDraft.value.trim();
if (!albumName) {
submitError.value = "请填写相册名称";
return;
}
albums.value = listFamilyAlbumFixtures(genealogyId.value);
albumState.value = ["loading", "empty", "error"].includes(query.state)
? query.state
: albums.value.length
? "ready"
: "empty";
isSubmitting.value = true;
submitError.value = "";
try {
await appApi.createAlbum(genealogyId.value, { albumName }, { requestController });
dialogVisible.value = false;
created.value = true;
} catch (error) {
if (!isRequestCancelled(error)) submitError.value = error?.message || "相册提交失败,请稍后重试。";
} finally {
isSubmitting.value = false;
}
};
const requestBack = () => runBackGuard({
transientOpen: dialogVisible.value,
submitting: isSubmitting.value,
"close-transient": closeCreateDialog,
"block-submitting": () => true,
});
const openAlbum = (album) =>
openPage(
"F08",
{ genealogyId: genealogyId.value, albumId: String(album.id) },
"F07",
);
const createAlbum = () => { albumNameDraft.value = ""; albumNameError.value = ""; dialogVisible.value = true; };
const closeCreateDialog = () => { dialogVisible.value = false; };
const requestCloseCreateDialog = async () => {
if (!albumNameDraft.value.trim()) {
closeCreateDialog();
return true;
}
const confirmed = await requestDiscardConfirmation();
if (!confirmed) return false;
albumNameDraft.value = "";
albumNameError.value = "";
closeCreateDialog();
return true;
};
const confirmCreateAlbum = () => {
const name = albumNameDraft.value.trim();
if (!name) { albumNameError.value = "请填写相册名称"; return; }
localAlbumPreview.value = Object.freeze({ name });
albumNameDraft.value = "";
dialogVisible.value = false;
};
const restoreAlbums = () => {
albums.value = listFamilyAlbumFixtures(genealogyId.value);
albumState.value = albums.value.length ? "ready" : "empty";
};
const handleStateAction = () => {
if (albumState.value === "invalid") return goBack();
if (albumState.value === "error") return restoreAlbums();
return createAlbum();
};
const requestBack = () => {
if (discardVisible.value) {
return runBackGuard({
transientOpen: true,
"close-transient": cancelDiscard,
});
}
if (dialogVisible.value) {
return runBackGuard({
transientOpen: true,
"close-transient": requestCloseCreateDialog,
});
}
return runBackGuard({
dirty: isDirty.value,
"confirm-discard": requestDiscardConfirmation,
});
};
const handleStateAction = () => hasValidContext.value ? openCreateDialog() : goBack();
onBackPress((event) => handleBackPress(event, requestBack));
onUnmounted(() => { discardConfirmation.dispose(); });
onUnmounted(() => requestController.abort());
</script>
<style scoped lang="scss">
@use "../../styles/adaptive-frame-profiles.scss" as adaptive;
.album-list-page { display: flex; min-height: 100vh; flex-direction: column; background: $paper; }
.album-list-header, .album-list-loading, .album-list-content { z-index: 1; }
.album-list-loading { min-height: calc(100vh - 100rpx); }
.album-list-header, .album-list-content { z-index: 1; }
.album-list-content { padding: 18rpx 24rpx 72rpx; }
.album-list-lead { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 8rpx 18rpx; min-height: 62rpx; padding: 0 20rpx; color: $ink-muted; font-size: 22rpx; background: url("/static/assets/modules/genealogy/transparent/section-divider.png") center / 100% auto no-repeat; }
.album-list { display: flex; flex-direction: column; gap: 16rpx; margin-top: 16rpx; }
.album-local-preview { @include adaptive.adaptive-family-field; margin-top: 16rpx; padding: 22rpx 24rpx; }
.album-local-preview text { display: block; color: $ink-muted; font-size: 22rpx; line-height: 1.5; }
.album-local-preview text:first-child { color: $brand-red; font-weight: 700; }
.album-local-preview text:nth-child(2) { margin-top: 6rpx; color: $ink; font-family: STKaiti, KaiTi, serif; font-size: 29rpx; font-weight: 700; }
.album-card, .album-state-card { @include adaptive.adaptive-family-content; width: 100%; }
.album-card { display: grid; grid-template-columns: minmax(150rpx, 0.7fr) minmax(0, 1.3fr); min-height: 210rpx; gap: 22rpx; padding: 30rpx 38rpx; }
.album-card__media { width: 100%; aspect-ratio: 4 / 3; align-self: center; }
.album-card__cover { display: block; width: 100%; height: 100%; }
.album-card__copy { align-self: center; min-width: 0; }
.album-card__copy text { display: block; overflow-wrap: anywhere; }
.album-card__copy text:first-child { color: $ink; font-family: STKaiti, KaiTi, serif; font-size: 30rpx; font-weight: 700; }
.album-card__copy text:nth-child(2) { margin-top: 9rpx; color: $brand-red; font-size: 21rpx; }
.album-card__copy text:last-child { margin-top: 8rpx; color: $ink-muted; font-size: 22rpx; line-height: 1.5; }
.album-list > .app-button { margin-top: 8rpx; }
.album-state-card { min-height: 340rpx; margin-top: 30rpx; padding: 78rpx 52rpx 50rpx; text-align: center; }
.album-state-card { @include adaptive.adaptive-family-content; width: 100%; min-height: 340rpx; margin-top: 30rpx; padding: 78rpx 52rpx 50rpx; text-align: center; }
.album-state-card > text { display: block; }
.album-state-card > text:first-child { color: $ink; font-family: STKaiti, KaiTi, serif; font-size: 35rpx; font-weight: 700; }
.album-state-card > text:nth-child(2) { margin-top: 18rpx; color: $ink-muted; font-size: 24rpx; line-height: 1.65; }
@@ -230,5 +101,4 @@ onUnmounted(() => { discardConfirmation.dispose(); });
.album-dialog-field > text:first-child { display: block; color: $ink; font-size: 23rpx; font-weight: 700; }
.album-dialog-field input { @include adaptive.adaptive-family-field; width: 100%; min-height: 76rpx; margin-top: 10rpx; padding: 0 22rpx; color: $ink; font-size: 24rpx; }
.album-dialog-field > text:last-child { display: block; margin-top: 8rpx; color: $brand-red; font-size: 21rpx; }
@media (max-width: 340px) { .album-card { grid-template-columns: 130rpx minmax(0, 1fr); gap: 16rpx; padding-right: 30rpx; padding-left: 30rpx; } }
</style>
+28 -320
View File
@@ -1,342 +1,50 @@
<!-- 页面编号F-08用途相册详情与照片墙 -->
<!-- 页面编号F-08用途相册照片墙响应未声明照片展示 DTO 时保持关闭 -->
<template>
<view class="album-detail-page" :class="`album-state--${albumState}`">
<view class="album-detail-page">
<ModulePageBackground module="family" />
<view class="album-detail-header"><PageHeader title="相册详情" custom-back @back="requestBack" /></view>
<view class="album-detail-header"><PageHeader title="相册详情" custom-back @back="returnToAlbums" /></view>
<view class="album-detail-content">
<view v-if="albumState === 'expired'" class="album-expired-state">
<view class="album-state-card__body">
<text class="album-state-card__eyebrow">相册状态</text>
<text class="album-state-card__title">相册已失效或入口无效</text>
<text class="album-state-card__copy">当前家谱中没有找到这本相册页面不会回退到其他相册</text>
<AppButton block :label="genealogyId ? '返回相册列表' : '返回上一页'" @click="returnToAlbums" />
</view>
</view>
<template v-else>
<view class="album-heading">
<text class="album-heading__eyebrow">家族影像</text>
<text class="album-heading__title">{{ album.name }}</text>
<text class="album-heading__copy">{{ album.description }}</text>
<text class="album-heading__count"
>{{ albumState === "empty" ? "0 张照片" : `${photos.length} 张照片` }}</text
>
</view>
<view v-if="albumState === 'empty'" class="album-empty-state">
<view class="album-state-card__body">
<text class="album-state-card__eyebrow">照片墙</text>
<text class="album-state-card__title">相册里还没有照片</text>
<text class="album-state-card__copy"
>添加第一张团圆影像把值得珍藏的时刻留在这里</text
>
</view>
</view>
<view v-else class="album-wall">
<view
class="album-photo-tile album-photo-tile--hero"
@click="openPreview(0)"
>
<image
class="album-hero-photo"
:src="photos[0].src"
:alt="photos[0].alt"
mode="aspectFill"
/>
<text class="album-photo-tile__caption">{{ photos[0].caption }}</text>
</view>
<view class="album-photo-grid">
<view
v-for="(photo, index) in photos.slice(1)"
:key="photo.src"
class="album-photo-tile album-photo-tile--grid"
@click="openPreview(index + 1)"
>
<image
class="album-photo-tile__image"
:src="photo.src"
:alt="photo.alt"
mode="aspectFill"
/>
<text class="album-photo-tile__caption">{{ photo.caption }}</text>
</view>
</view>
</view>
<view class="album-upload-action" @click="toUpload">
<AppButton block label="添加照片" />
</view>
</template>
</view>
<view v-if="previewVisible" class="album-preview">
<view class="album-preview__toolbar">
<text class="album-preview__title">照片预览</text>
<view class="album-preview__close" @click="closePreview"><text>关闭</text></view>
</view>
<image
class="album-preview__image"
:src="photos[previewIndex].src"
:alt="photos[previewIndex].alt"
mode="aspectFit"
/>
<view class="album-preview__footer">
<text class="album-preview__position"
>{{ previewIndex + 1 }} / {{ photos.length }}</text
>
<text class="album-preview__caption">{{ photos[previewIndex].caption }}</text>
<view class="album-state-card">
<text>{{ stateCopy.title }}</text>
<text>{{ stateCopy.copy }}</text>
<AppButton block :label="stateCopy.action" @click="returnToAlbums" />
</view>
</view>
</view>
</template>
<script setup>
import { ref } from "vue";
import { onBackPress, onLoad } from "@dcloudio/uni-app";
import { computed, ref } from "vue";
import { onLoad } from "@dcloudio/uni-app";
import AppButton from "@/components/AppButton.vue";
import ModulePageBackground from "@/components/ModulePageBackground.vue";
import PageHeader from "@/components/PageHeader.vue";
import { findFamilyAlbumFixture } from "@/data/mock.js";
import {
goBack,
handleBackPress,
openPage,
returnTo,
runBackGuard,
} from "@/utils/navigation.js";
import { goBack, returnTo } from "@/utils/navigation.js";
const albumState = ref("normal");
const genealogyId = ref("");
const albumId = ref("");
const album = ref(null);
const photos = ref([]);
const previewVisible = ref(false);
const previewIndex = ref(0);
const openPreview = (index) => {
previewIndex.value = index;
previewVisible.value = true;
albumState.value = "preview";
};
const closePreview = () => {
previewVisible.value = false;
albumState.value = "normal";
};
const toUpload = () =>
openPage(
"F09",
{ genealogyId: genealogyId.value, albumId: albumId.value },
"F08",
);
const returnToAlbums = () =>
genealogyId.value
? returnTo("F07", { genealogyId: genealogyId.value })
: goBack();
const hasValidContext = computed(() => /^[1-9]\d*$/.test(genealogyId.value) && /^[1-9]\d*$/.test(albumId.value));
const stateCopy = computed(() => hasValidContext.value
? { title: "照片墙待后端字段合同", copy: "照片列表没有声明照片 ID、OSS 访问地址、标题、说明或相册展示字段;页面已停止展示本地图片和本地预览。", action: "返回相册列表" }
: { title: "相册入口无效", copy: "没有取得有效家谱或相册标识,页面不会回退到其他相册。", action: "返回上一页" },
);
onLoad((query) => {
genealogyId.value = String(query.genealogyId || "");
albumId.value = String(query.albumId || "");
album.value = findFamilyAlbumFixture(genealogyId.value, albumId.value);
photos.value = album.value?.photos || [];
if (!album.value) {
albumState.value = "expired";
previewVisible.value = false;
return;
}
albumState.value =
query.state === "empty"
? "empty"
: query.state === "preview"
? "preview"
: query.state === "expired"
? "expired"
: "normal";
previewVisible.value = albumState.value === "preview";
genealogyId.value = String(query?.genealogyId || "");
albumId.value = String(query?.albumId || "");
});
const requestBack = () =>
runBackGuard({
transientOpen: previewVisible.value,
"close-transient": closePreview,
});
onBackPress((event) => handleBackPress(event, requestBack));
const returnToAlbums = () => /^[1-9]\d*$/.test(genealogyId.value)
? returnTo("F07", { genealogyId: genealogyId.value })
: goBack();
</script>
<style scoped lang="scss">
@use "../../styles/adaptive-frame-profiles.scss" as adaptive;
.album-detail-page {
display: flex;
min-height: 100vh;
flex-direction: column;
background: $paper;
}
.album-detail-header,
.album-detail-content {
z-index: 1;
}
.album-detail-content {
padding: 22rpx 24rpx calc(48rpx + env(safe-area-inset-bottom));
}
.album-heading {
padding: 18rpx 12rpx 20rpx;
text-align: center;
}
.album-heading text,
.album-state-card__body text,
.album-preview__footer text {
display: block;
}
.album-heading__eyebrow,
.album-state-card__eyebrow {
color: $brand-red;
font-size: 22rpx;
letter-spacing: 2rpx;
}
.album-heading__title,
.album-state-card__title {
margin-top: 8rpx;
color: $ink;
font-family: STKaiti, KaiTi, serif;
font-size: 36rpx;
font-weight: 700;
}
.album-heading__copy,
.album-state-card__copy {
margin-top: 10rpx;
color: $ink-muted;
font-size: 23rpx;
line-height: 1.6;
}
.album-heading__count {
margin-top: 12rpx;
color: #806a51;
font-size: 22rpx;
}
.album-wall {
margin-top: 8rpx;
}
.album-photo-tile {
position: relative;
min-height: 44px;
overflow: hidden;
box-sizing: border-box;
border: 2rpx solid rgba(179, 133, 63, 0.62);
background: rgba(245, 238, 226, 0.86);
box-shadow: 0 8rpx 18rpx rgba(91, 60, 32, 0.12);
}
.album-photo-tile--hero {
width: 100%;
aspect-ratio: 16 / 9;
}
.album-photo-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 14rpx;
margin-top: 14rpx;
}
.album-photo-tile--grid {
aspect-ratio: 4 / 3;
}
.album-hero-photo,
.album-photo-tile__image {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
}
.album-photo-tile__caption {
position: absolute;
z-index: 1;
right: 0;
bottom: 0;
left: 0;
padding: 10rpx 12rpx;
background: rgba(48, 35, 25, 0.72);
color: #fffaf0;
font-size: 21rpx;
line-height: 1.35;
}
.album-upload-action {
margin-top: 24rpx;
}
.album-empty-state,
.album-expired-state {
@include adaptive.adaptive-family-panel;
width: 100%;
min-height: 430rpx;
}
.album-state-card__body {
padding: 104rpx 40rpx 62rpx;
text-align: center;
}
.album-state-card__body > .app-button {
margin-top: 34rpx;
}
.album-preview {
position: fixed;
z-index: 80;
inset: 0;
display: flex;
flex-direction: column;
background: rgba(24, 17, 13, 0.96);
}
.album-preview__toolbar {
display: flex;
min-height: 96rpx;
align-items: center;
justify-content: space-between;
padding: calc(env(safe-area-inset-top) + 12rpx) 24rpx 12rpx;
}
.album-preview__title {
color: #fffaf0;
font-size: 29rpx;
font-weight: 700;
}
.album-preview__close {
display: flex;
min-width: 44px;
min-height: 44px;
align-items: center;
justify-content: center;
color: #fffaf0;
font-size: 25rpx;
}
.album-preview__image {
width: 100%;
min-height: 0;
flex: 1;
}
.album-preview__footer {
padding: 20rpx 28rpx calc(30rpx + env(safe-area-inset-bottom));
color: #fffaf0;
text-align: center;
}
.album-preview__position {
font-size: 23rpx;
letter-spacing: 2rpx;
}
.album-preview__caption {
margin-top: 8rpx;
color: rgba(255, 250, 240, 0.78);
font-size: 22rpx;
}
@media (max-width: 340px) {
.album-detail-content {
padding-right: 16rpx;
padding-left: 16rpx;
}
.album-photo-grid {
gap: 10rpx;
}
}
.album-detail-page { display: flex; min-height: 100vh; flex-direction: column; background: $paper; }
.album-detail-header, .album-detail-content { z-index: 1; }
.album-detail-content { padding: 22rpx 24rpx calc(48rpx + env(safe-area-inset-bottom)); }
.album-state-card { @include adaptive.adaptive-family-panel; width: 100%; min-height: 430rpx; padding: 104rpx 40rpx 62rpx; box-sizing: border-box; text-align: center; }
.album-state-card text { display: block; }
.album-state-card text:first-child { color: $ink; font-family: STKaiti, KaiTi, serif; font-size: 36rpx; font-weight: 700; }
.album-state-card text:nth-child(2) { margin-top: 10rpx; color: $ink-muted; font-size: 23rpx; line-height: 1.6; }
.album-state-card .app-button { margin-top: 34rpx; }
</style>
+25 -604
View File
@@ -1,627 +1,48 @@
<!-- 页面编号F-09用途照片选择说明上传进度与失败重试视觉候选 -->
<!-- 页面编号F-09用途相册照片写入没有文件上传与 OSS 回执 owner 时关闭 -->
<template>
<view
class="media-upload-page"
:class="`media-upload-state--${uploadState}`"
>
<view class="media-upload-page">
<ModulePageBackground module="family" />
<view class="media-upload-header"><PageHeader title="添加照片" custom-back @back="requestBack" /></view>
<view class="media-upload-header"><PageHeader title="添加照片" custom-back @back="returnToAlbum" /></view>
<view class="media-upload-content">
<view v-if="uploadState === 'invalid'" class="media-invalid-card">
<text class="media-state-card__eyebrow">相册入口无效</text>
<text class="media-state-card__title">没有找到当前相册</text>
<text class="media-state-card__copy">页面不会把照片归入其他家谱或其他相册</text>
<view class="media-preview-action" @click="returnFromInvalid">
<AppButton block :label="genealogyId ? '返回相册列表' : '返回上一页'" />
</view>
<view class="media-state-card">
<text class="media-state-card__eyebrow">文件服务待接入</text>
<text class="media-state-card__title">照片上传暂不可用</text>
<text class="media-state-card__copy">照片写入接口要求已有 `ossId`当前没有经 Apifox 核实的文件上传 owner真实 OSS 回执或访问 URL页面不再从本地图片库选择图片也不会生成上传预览</text>
<AppButton block :label="hasValidContext ? '返回相册列表' : '返回上一页'" @click="returnToAlbum" />
</view>
<view v-else class="media-album-card">
<image
class="media-album-card__cover"
:src="album.cover"
:alt="album.name"
mode="aspectFill"
/>
<view class="media-album-card__copy">
<text class="media-album-card__eyebrow">当前相册</text>
<text class="media-album-card__title">{{ album.name }}</text>
<text class="media-album-card__limit">最多可选择 9 </text>
</view>
</view>
<view v-if="uploadState === 'permission' && album" class="media-permission-card">
<text class="media-state-card__eyebrow">照片访问权限</text>
<text class="media-state-card__title">需要照片访问权限</text>
<text class="media-state-card__copy"
>授权后才能选择照片当前只验证选择与说明流程不会上传</text
>
<view class="media-permission-action" @click="selectMockPhotos">
<AppButton block label="重新授权" />
</view>
</view>
<view v-else-if="uploadState === 'preview'" class="media-preview-card">
<text class="media-state-card__eyebrow">本地流程预览</text>
<text class="media-preview-title">{{ selectedPhotos.length }} 张照片已完成本地校验</text>
<text class="media-state-card__copy"
>照片尚未上传也没有加入{{ album.name }}返回后不会保存</text
>
<view class="media-preview-action" @click="returnToAlbum">
<AppButton block label="返回当前相册(不上传)" />
</view>
</view>
<template v-else-if="album">
<view class="media-section-heading">
<text>选择照片</text>
<text>{{ selectedPhotos.length }} / {{ MAX_PHOTOS }}</text>
</view>
<view class="media-photo-grid">
<view
v-for="(photo, index) in selectedPhotos"
:key="photo.id"
class="media-photo-tile"
:class="{
'media-photo-tile--active': index === activePhotoIndex,
}"
@click="selectPhoto(index)"
>
<image
class="media-photo-tile__image"
:src="photo.src"
:alt="photo.alt"
mode="aspectFill"
/>
<text class="media-photo-order">{{ index + 1 }}</text>
<text
v-if="index === activePhotoIndex"
class="media-photo-current"
>当前</text
>
<view
v-if="!isLocked"
class="media-photo-remove"
@click.stop="removePhoto(index)"
>
<text>删除</text>
</view>
</view>
<view
v-if="selectedPhotos.length < MAX_PHOTOS && !isLocked"
class="media-add-tile"
@click="selectedPhotos.length ? addMockPhoto() : selectMockPhotos()"
>
<text class="media-add-tile__title">{{
selectedPhotos.length ? "继续添加" : "选择照片"
}}</text>
<text class="media-add-tile__copy">{{
selectedPhotos.length ? `还可选择 ${MAX_PHOTOS - selectedPhotos.length}` : "最多 9 张"
}}</text>
</view>
</view>
<view v-if="selectedPhotos.length" class="media-description-panel">
<view class="media-batch-field">
<view class="media-field-heading">
<text>整批说明</text><text>必填</text>
</view>
<textarea
v-model="batchDescription"
auto-height
:disabled="isLocked"
maxlength="120"
placeholder="例如:2024 年春节全家团圆留影"
placeholder-class="media-field-placeholder"
/>
</view>
<text v-if="validationMessage" class="media-field-error">{{
validationMessage
}}</text>
<view v-if="activePhoto" class="media-photo-field">
<view class="media-photo-editor">
<image
class="media-photo-editor__thumb"
:src="activePhoto.src"
:alt="activePhoto.alt"
mode="aspectFill"
/>
<view class="media-photo-editor__copy">
<text class="media-photo-editor__position"
> {{ activePhotoIndex + 1 }} /
{{ selectedPhotos.length }} </text
>
<text class="media-photo-editor__hint"
>正在编辑此照片的说明</text
>
</view>
</view>
<view class="media-field-heading">
<text>当前照片说明</text><text>选填</text>
</view>
<textarea
:value="activePhoto.note"
auto-height
:disabled="isLocked"
maxlength="80"
placeholder="补充人物、时间或拍摄地点"
placeholder-class="media-field-placeholder"
@input="updateActiveNote"
/>
</view>
</view>
<view class="media-primary-action" @click="generatePreview">
<AppButton
block
:disabled="isSubmitting"
:label="
isSubmitting
? '正在校验'
: selectedPhotos.length
? `生成 ${selectedPhotos.length} 张照片预览`
: '选择照片'
"
/>
</view>
</template>
</view>
<AppDialog
:visible="discardVisible"
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 { onLoad } from "@dcloudio/uni-app";
import AppButton from "@/components/AppButton.vue";
import AppDialog from "@/components/AppDialog.vue";
import ModulePageBackground from "@/components/ModulePageBackground.vue";
import PageHeader from "@/components/PageHeader.vue";
import { findFamilyAlbumFixture } from "@/data/mock.js";
import { createDiscardConfirmation } from "@/utils/discard-confirmation.js";
import {
goBack,
handleBackPress,
returnTo,
runBackGuard,
} from "@/utils/navigation.js";
import { goBack, returnTo } from "@/utils/navigation.js";
const MAX_PHOTOS = 9;
const genealogyId = ref("");
const albumId = ref("");
const album = ref(null);
const uploadState = ref("initial");
const activePhotoIndex = ref(0);
const batchDescription = ref("");
const validationMessage = ref("");
const selectedPhotos = ref([]);
const isSubmitting = ref(false);
const discardVisible = ref(false);
let previewTimer = null;
const mockLibrary = [
{ id: "reunion", src: "/static/assets/modules/family/f08/f08-reunion-hero.png", alt: "春节团圆时三代家人的合影", note: "" },
{ id: "portrait", src: "/static/assets/modules/family/f08/f08-family-portrait.png", alt: "家人在院落前的春节合影", note: "" },
{ id: "table", src: "/static/assets/modules/family/f08/f08-reunion-table.png", alt: "家人围坐吃年夜饭", note: "" },
{ id: "home", src: "/static/assets/modules/family/f08/f08-ancestral-home.png", alt: "祖居院落的复古旧照", note: "" },
{ id: "ancestor", src: "/static/assets/modules/family/f08/f08-ancestral-portrait.png", alt: "老一辈家人在祖居门前的合影", note: "" },
];
const activePhoto = computed(
() => selectedPhotos.value[activePhotoIndex.value] || null,
);
const isLocked = computed(() => isSubmitting.value);
const isDirty = computed(() =>
Boolean(
selectedPhotos.value.length ||
batchDescription.value.trim() ||
selectedPhotos.value.some((photo) => photo.note.trim()),
),
);
const discardConfirmation = createDiscardConfirmation((visible) => {
discardVisible.value = visible;
});
const requestDiscardConfirmation = discardConfirmation.request;
const confirmDiscard = discardConfirmation.confirm;
const cancelDiscard = discardConfirmation.cancel;
const makeSelectedPhotos = () =>
mockLibrary.slice(0, 4).map((photo) => ({ ...photo, note: "" }));
const selectMockPhotos = () => {
selectedPhotos.value = makeSelectedPhotos();
activePhotoIndex.value = 0;
validationMessage.value = "";
uploadState.value = "selected";
};
const selectPhoto = (index) => {
if (!isLocked.value) activePhotoIndex.value = index;
};
const removePhoto = (index) => {
if (isLocked.value) return;
selectedPhotos.value.splice(index, 1);
activePhotoIndex.value = Math.max(
0,
Math.min(activePhotoIndex.value, selectedPhotos.value.length - 1),
);
};
const addMockPhoto = () => {
if (isLocked.value || selectedPhotos.value.length >= MAX_PHOTOS) return;
const nextIndex = selectedPhotos.value.length;
const next = mockLibrary[nextIndex % mockLibrary.length];
selectedPhotos.value.push({
...next,
id: `${next.id}-${nextIndex + 1}`,
note: "",
});
};
const updateActiveNote = (event) => {
if (!isLocked.value && activePhoto.value) {
activePhoto.value.note = event.detail.value;
}
};
const generatePreview = () => {
if (isSubmitting.value || !album.value) return;
if (!selectedPhotos.value.length) {
selectMockPhotos();
return;
}
if (!batchDescription.value.trim()) {
validationMessage.value = "请填写整批照片说明";
return;
}
validationMessage.value = "";
isSubmitting.value = true;
const selectedCount = selectedPhotos.value.length;
const timer = setTimeout(() => {
if (previewTimer !== timer) return;
previewTimer = null;
isSubmitting.value = false;
uploadState.value = selectedCount > 0 ? "preview" : "selected";
}, 280);
previewTimer = timer;
};
const returnToAlbum = () =>
returnTo("F08", {
genealogyId: genealogyId.value,
albumId: albumId.value,
});
const returnFromInvalid = () =>
genealogyId.value
? returnTo("F07", { genealogyId: genealogyId.value })
: goBack();
const requestBack = () =>
runBackGuard({
transientOpen: discardVisible.value,
dirty: isDirty.value,
submitting: isSubmitting.value,
"close-transient": cancelDiscard,
"block-submitting": () => true,
"confirm-discard": requestDiscardConfirmation,
});
const hasValidContext = computed(() => /^[1-9]\d*$/.test(genealogyId.value) && /^[1-9]\d*$/.test(albumId.value));
onLoad((query) => {
genealogyId.value = String(query.genealogyId || "");
albumId.value = String(query.albumId || "");
album.value = findFamilyAlbumFixture(genealogyId.value, albumId.value);
if (!album.value) {
uploadState.value = "invalid";
return;
}
uploadState.value = ["permission", "selected", "preview"].includes(query.state)
? query.state
: "initial";
if (["selected", "preview"].includes(uploadState.value)) {
selectedPhotos.value = makeSelectedPhotos();
batchDescription.value = "春节团圆照片整理";
}
});
onBackPress((event) => handleBackPress(event, requestBack));
onUnload(() => {
if (previewTimer) clearTimeout(previewTimer);
discardConfirmation.dispose();
genealogyId.value = String(query?.genealogyId || "");
albumId.value = String(query?.albumId || "");
});
const returnToAlbum = () => hasValidContext.value
? returnTo("F07", { genealogyId: genealogyId.value })
: goBack();
</script>
<style scoped lang="scss">
@use "../../styles/adaptive-frame-profiles.scss" as adaptive;
.media-upload-page {
display: flex;
min-height: 100vh;
flex-direction: column;
background: $paper;
}
.media-upload-header,
.media-upload-content {
z-index: 1;
}
.media-upload-content {
padding: 20rpx 24rpx calc(48rpx + env(safe-area-inset-bottom));
}
.media-album-card {
@include adaptive.adaptive-family-panel;
display: grid;
min-height: 154rpx;
box-sizing: border-box;
grid-template-columns: 132rpx minmax(0, 1fr);
gap: 20rpx;
align-items: center;
padding: 14rpx 20rpx;
}
.media-album-card__cover {
width: 132rpx;
height: 132rpx;
border: 2rpx solid rgba(179, 133, 63, 0.62);
box-shadow: 0 6rpx 14rpx rgba(91, 60, 32, 0.12);
}
.media-album-card__copy text,
.media-state-card__eyebrow,
.media-state-card__title,
.media-state-card__copy,
.media-preview-title {
display: block;
}
.media-album-card__eyebrow {
color: $brand-red;
font-size: 21rpx;
letter-spacing: 2rpx;
}
.media-album-card__title {
margin-top: 7rpx;
color: $ink;
font-family: STKaiti, KaiTi, serif;
font-size: 31rpx;
font-weight: 700;
}
.media-album-card__limit {
margin-top: 7rpx;
color: $ink-muted;
font-size: 21rpx;
}
.media-section-heading {
display: flex;
align-items: center;
justify-content: space-between;
margin: 24rpx 8rpx 12rpx;
color: $brand-red;
font-size: 25rpx;
font-weight: 700;
}
.media-section-heading text:last-child {
color: $ink-muted;
font-size: 21rpx;
font-weight: 400;
}
.media-photo-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 12rpx;
}
.media-photo-tile,
.media-add-tile {
min-width: 0;
min-height: 44px;
overflow: hidden;
box-sizing: border-box;
aspect-ratio: 1;
border: 2rpx solid rgba(179, 133, 63, 0.62);
background: rgba(247, 241, 231, 0.88);
}
.media-photo-tile {
position: relative;
}
.media-photo-tile--active {
outline: 4rpx solid rgba(159, 44, 35, 0.78);
outline-offset: -4rpx;
}
.media-photo-tile__image {
width: 100%;
height: 100%;
}
.media-photo-order,
.media-photo-current {
position: absolute;
z-index: 2;
color: #fffaf0;
font-size: 20rpx;
}
.media-photo-order {
top: 7rpx;
left: 7rpx;
min-width: 34rpx;
padding: 3rpx 7rpx;
border-radius: 999px;
background: rgba(43, 31, 23, 0.78);
text-align: center;
}
.media-photo-current {
bottom: 7rpx;
left: 7rpx;
padding: 4rpx 8rpx;
background: rgba(74, 45, 27, 0.84);
}
.media-photo-remove {
position: absolute;
z-index: 3;
top: 0;
right: 0;
display: flex;
min-width: 44px;
min-height: 44px;
align-items: flex-start;
justify-content: flex-end;
color: #fffaf0;
font-size: 20rpx;
}
.media-photo-remove text {
padding: 7rpx 8rpx;
background: rgba(145, 36, 29, 0.88);
}
.media-add-tile {
display: flex;
min-height: 44px;
flex-direction: column;
align-items: center;
justify-content: center;
border-style: dashed;
color: $brand-red;
text-align: center;
}
.media-add-tile__title {
font-size: 24rpx;
font-weight: 700;
}
.media-add-tile__copy {
margin-top: 6rpx;
color: $ink-muted;
font-size: 19rpx;
}
.media-description-panel {
margin-top: 22rpx;
}
.media-batch-field,
.media-photo-field {
padding: 18rpx 20rpx 20rpx;
border: 2rpx solid rgba(179, 133, 63, 0.55);
background: rgba(250, 246, 237, 0.76);
}
.media-photo-field {
margin-top: 16rpx;
}
.media-photo-editor {
display: grid;
grid-template-columns: 78rpx minmax(0, 1fr);
gap: 14rpx;
align-items: center;
margin-bottom: 16rpx;
padding-bottom: 14rpx;
border-bottom: 1px solid rgba(179, 133, 63, 0.32);
}
.media-photo-editor__thumb {
width: 78rpx;
height: 78rpx;
border: 2rpx solid rgba(159, 44, 35, 0.62);
}
.media-photo-editor__copy text {
display: block;
}
.media-photo-editor__position {
color: $ink;
font-size: 23rpx;
font-weight: 700;
}
.media-photo-editor__hint {
margin-top: 6rpx;
color: $ink-muted;
font-size: 20rpx;
}
.media-field-heading {
display: flex;
align-items: center;
justify-content: space-between;
color: $ink;
font-size: 24rpx;
font-weight: 700;
}
.media-field-heading text:last-child {
color: $brand-red;
font-size: 20rpx;
font-weight: 500;
}
.media-batch-field textarea,
.media-photo-field textarea {
width: 100%;
min-height: 92rpx;
margin-top: 12rpx;
box-sizing: border-box;
color: $ink;
font-size: 23rpx;
line-height: 1.55;
}
.media-field-placeholder {
color: #9e8e79;
}
.media-field-error {
display: block;
margin: 9rpx 6rpx 0;
color: $brand-red;
font-size: 21rpx;
line-height: 1.45;
}
.media-primary-action {
margin-top: 24rpx;
}
.media-permission-card,
.media-preview-card,
.media-invalid-card {
@include adaptive.adaptive-family-panel;
min-height: 410rpx;
margin-top: 24rpx;
box-sizing: border-box;
padding: 98rpx 46rpx 58rpx;
text-align: center;
}
.media-state-card__eyebrow {
color: $brand-red;
font-size: 22rpx;
letter-spacing: 2rpx;
}
.media-state-card__title,
.media-preview-title {
margin-top: 12rpx;
color: $ink;
font-family: STKaiti, KaiTi, serif;
font-size: 35rpx;
font-weight: 700;
}
.media-state-card__copy {
margin-top: 16rpx;
color: $ink-muted;
font-size: 23rpx;
line-height: 1.65;
}
.media-permission-action,
.media-preview-action {
margin-top: 30rpx;
}
@media (max-width: 340px) {
.media-upload-content {
padding-right: 16rpx;
padding-left: 16rpx;
}
.media-photo-grid {
gap: 8rpx;
}
.media-album-card {
grid-template-columns: 116rpx minmax(0, 1fr);
gap: 14rpx;
}
.media-album-card__cover {
width: 116rpx;
height: 116rpx;
}
}
.media-upload-page { display: flex; min-height: 100vh; flex-direction: column; background: $paper; }
.media-upload-header, .media-upload-content { z-index: 1; }
.media-upload-content { padding: 20rpx 24rpx calc(48rpx + env(safe-area-inset-bottom)); }
.media-state-card { @include adaptive.adaptive-family-panel; min-height: 410rpx; box-sizing: border-box; padding: 98rpx 46rpx 58rpx; text-align: center; }
.media-state-card text { display: block; }
.media-state-card__eyebrow { color: $brand-red; font-size: 22rpx; letter-spacing: 2rpx; }
.media-state-card__title { margin-top: 12rpx; color: $ink; font-family: STKaiti, KaiTi, serif; font-size: 35rpx; font-weight: 700; }
.media-state-card__copy { margin-top: 16rpx; color: $ink-muted; font-size: 23rpx; line-height: 1.65; }
.media-state-card .app-button { margin-top: 30rpx; }
</style>
+4 -135
View File
@@ -1,142 +1,11 @@
<!-- 页面编号F-10用途家族视频待开放状态 -->
<!-- 页面编号F-10用途家族视频当前缺读取发布和播放业务 owner -->
<template>
<view class="video-status-page" :class="{ 'video-status-state--invalid': !hasValidContext }">
<ModulePageBackground module="family" />
<view class="video-status-header"><PageHeader title="家族视频" /></view>
<view class="video-status-content">
<view class="video-status-lead">
<image
src="/static/assets/modules/genealogy/transparent/section-divider.png"
mode="scaleToFill"
/>
<text>记录会动的家族记忆</text>
</view>
<view class="video-status-card">
<view class="video-status-card__body">
<text class="video-status-card__eyebrow">{{ hasValidContext ? '视频 · 服务说明' : '页面入口' }}</text>
<text class="video-status-card__title">{{ hasValidContext ? '视频服务暂未开放' : '家谱身份无效' }}</text>
<text class="video-status-card__copy">{{ hasValidContext ? '开放后可在这里浏览当前家谱的影像与纪念视频。' : '请从一个可访问的成员家谱重新进入,页面不会展示其他家谱内容。' }}</text>
<view class="video-return-action" @click="returnToFamily">
<AppButton block :label="hasValidContext ? '返回家族首页' : '返回上一页'" />
</view>
</view>
</view>
</view>
</view>
<view class="video-page"><ModulePageBackground module="family" /><view class="page-header"><PageHeader title="家族视频" custom-back @back="returnToFamily" /></view><view class="page-content"><view class="state-card"><text>视频服务暂未开放</text><text>Apifox 当前只有删除视频 operation未声明视频列表详情播放地址发布编辑评论点赞或分享 owner页面不以相册参考项目或本地数据补造视频能力</text><AppButton block :label="hasValidContext ? '返回家族动态' : '返回上一页'" @click="returnToFamily" /></view></view></view>
</template>
<script setup>
import { computed, ref } from "vue";
import { onLoad } from "@dcloudio/uni-app";
import AppButton from "@/components/AppButton.vue";
import ModulePageBackground from "@/components/ModulePageBackground.vue";
import PageHeader from "@/components/PageHeader.vue";
import { getGenealogyFixtureAccess } from "@/data/mock.js";
import { goBack, returnTo } from "@/utils/navigation.js";
const genealogyId = ref("");
const hasValidContext = computed(() =>
Boolean(
genealogyId.value &&
["owner", "member"].includes(
getGenealogyFixtureAccess(genealogyId.value).accessRole,
),
),
);
onLoad((query) => {
genealogyId.value = String(query.genealogyId || "");
});
const returnToFamily = () =>
hasValidContext.value
? returnTo("F01", { genealogyId: genealogyId.value })
: goBack();
import { computed,ref } from "vue"; import { onLoad } from "@dcloudio/uni-app"; import AppButton from "@/components/AppButton.vue"; import ModulePageBackground from "@/components/ModulePageBackground.vue"; import PageHeader from "@/components/PageHeader.vue"; import { goBack,returnTo } from "@/utils/navigation.js"; const genealogyId=ref("");const hasValidContext=computed(()=>/^[1-9]\d*$/.test(genealogyId.value));onLoad((query)=>{genealogyId.value=String(query?.genealogyId||"");});const returnToFamily=()=>hasValidContext.value?returnTo("F01",{genealogyId:genealogyId.value}):goBack();
</script>
<style scoped lang="scss">
@use "../../styles/adaptive-frame-profiles.scss" as adaptive;
.video-status-page {
min-height: 100vh;
background: $paper;
}
.video-status-content {
padding: 16rpx 26rpx 56rpx;
}
.video-status-lead {
display: grid;
min-height: 56rpx;
margin: 0 12rpx 18rpx;
color: $ink-muted;
font-size: 23rpx;
text-align: center;
}
.video-status-lead image {
grid-area: 1 / 1;
width: 100%;
height: 100%;
}
.video-status-lead text {
z-index: 1;
display: flex;
grid-area: 1 / 1;
align-items: center;
justify-content: center;
height: 100%;
}
.video-status-card {
@include adaptive.adaptive-family-panel;
width: 100%;
min-height: 330rpx;
box-sizing: border-box;
text-align: center;
}
.video-status-card__body {
padding: 70rpx 60rpx 48rpx;
}
.video-status-card__eyebrow,
.video-status-card__title,
.video-status-card__copy {
display: block;
}
.video-status-card__eyebrow {
color: $brand-red;
font-size: 23rpx;
letter-spacing: 3rpx;
}
.video-status-card__title {
margin-top: 14rpx;
color: $ink;
font-family: STKaiti, KaiTi, serif;
font-size: 35rpx;
font-weight: 700;
}
.video-status-card__copy {
margin-top: 17rpx;
color: $ink-muted;
font-size: 24rpx;
line-height: 1.6;
}
.video-return-action {
margin: 28rpx auto 0;
}
@media (min-width: 400px) {
.video-status-content {
padding-right: 34rpx;
padding-left: 34rpx;
}
}
@media (max-width: 340px) {
.video-status-content {
padding-right: 20rpx;
padding-left: 20rpx;
}
.video-status-card__body {
padding-right: 44rpx;
padding-left: 44rpx;
}
}
.video-page{display:flex;min-height:100vh;flex-direction:column;background:$paper}.page-header,.page-content{z-index:1}.page-content{padding:18rpx 24rpx 72rpx}.state-card{box-sizing:border-box;min-height:340rpx;padding:78rpx 52rpx 50rpx;text-align:center;@include adaptive.adaptive-family-content}.state-card text{display:block}.state-card text:first-child{color:$ink;font-family:STKaiti,KaiTi,serif;font-size:35rpx;font-weight:700}.state-card text:nth-child(2){margin-top:18rpx;color:$ink-muted;font-size:24rpx;line-height:1.65}.state-card .app-button{margin-top:28rpx}
</style>
+4 -735
View File
@@ -1,735 +1,4 @@
<!-- 页面编号G-03用途创建家谱与录入首代人物的同路由两步流程 -->
<template>
<view class="flow-page">
<GenealogyPageBackground />
<PageHeader
:title="isAncestorStep ? '录入首代人物' : '创建家谱'"
custom-back
@back="requestBack"
/>
<view class="flow-content">
<view class="create-flow-panel">
<view v-if="!isAncestorStep" class="create-flow-panel__content">
<view class="flow-step-label"><text>第一步 · 立谱信息</text></view>
<text class="flow-heading">为家族立一部可传承的谱</text>
<text class="flow-note"
>家谱建立后可继续完善名称与访问规则由创建者维护</text
>
<view class="flow-fields">
<view class="field-row">
<text>姓氏</text>
<input
v-model="createForm.surname"
maxlength="4"
placeholder="请输入姓氏"
placeholder-class="placeholder"
@input="clearFieldError('surname')"
/>
</view>
<text v-if="fieldErrors.surname" class="field-error">{{
fieldErrors.surname
}}</text>
<view class="field-row">
<text>谱名</text>
<input
v-model="createForm.name"
maxlength="24"
placeholder="请输入家谱名称"
placeholder-class="placeholder"
@input="clearFieldError('name')"
/>
</view>
<text v-if="fieldErrors.name" class="field-error">{{
fieldErrors.name
}}</text>
<view class="field-row">
<text>堂号</text>
<input
v-model="createForm.hall"
maxlength="12"
placeholder="选填"
placeholder-class="placeholder"
/>
</view>
<view class="field-row">
<text>所在地</text>
<input
v-model="createForm.location"
maxlength="30"
placeholder="请选择或输入"
placeholder-class="placeholder"
@input="clearFieldError('location')"
/>
</view>
<text v-if="fieldErrors.location" class="field-error">{{
fieldErrors.location
}}</text>
</view>
<view class="flow-rule">
<text class="flow-rule__label">访问规则</text>
<view class="flow-rule__options">
<view
v-for="option in GENEALOGY_ACCESS_PRESET_OPTIONS"
:key="option.value"
class="flow-rule__option"
:class="{
'flow-rule__option--active':
createForm.accessPreset === option.value,
}"
@click="createForm.accessPreset = option.value"
>{{ option.label }}</view
>
</view>
</view>
<text class="flow-rule__note"
>保护族人资料创建后可在家谱设置中开放搜索与申请加入</text
>
<text v-if="createState === 'error'" class="flow-error"
>创建样式模拟失败请检查信息后重试</text
>
<view
class="flow-primary-action"
hover-class="action-hover"
@click="submitCreate"
>
<text class="flow-primary-action__copy">{{
createState === "submitting" ? "正在创建…" : "创建并录入首代"
}}</text>
</view>
</view>
<view v-else class="create-flow-panel__content">
<view class="flow-step-label"><text>第二步 · 首代人物</text></view>
<text class="flow-heading">家谱从这一位开始</text>
<text class="flow-note"
>可先录入姓名世代和简要生平其他资料后续随时补充</text
>
<view class="flow-fields">
<view class="field-row">
<text>姓名</text>
<input
v-model="ancestorForm.personName"
maxlength="20"
placeholder="请输入首代姓名"
placeholder-class="placeholder"
@input="clearFieldError('personName')"
/>
</view>
<text v-if="fieldErrors.personName" class="field-error">{{
fieldErrors.personName
}}</text>
<view class="field-row">
<text>世代</text>
<text class="fixed-value">第一世</text>
</view>
<picker
mode="selector"
:range="sexOptions"
range-key="label"
@change="changeAncestorSex"
>
<view class="field-row">
<text>性别</text>
<text class="fixed-value">{{ sexLabel }}</text>
</view>
</picker>
<view class="field-row">
<text>出生日期</text>
<picker
mode="date"
:value="ancestorForm.birthDate"
start="1800-01-01"
:end="new Date().toISOString().slice(0, 10)"
@change="changeAncestorBirthDate"
>
<text class="fixed-value">{{
ancestorForm.birthDate || "请选择日期"
}}</text>
</picker>
</view>
</view>
<text v-if="ancestorState === 'error'" class="flow-error"
>首代人物保存失败请稍后重试</text
>
<view class="intro-field">
<text>生平简述</text>
<textarea
v-model="ancestorForm.introduction"
auto-height
maxlength="200"
placeholder="可选,记录家训、迁徙或重要经历"
placeholder-class="placeholder"
/>
</view>
<view
class="flow-primary-action"
hover-class="action-hover"
@click="submitAncestor"
>
<text class="flow-primary-action__copy">{{
ancestorState === "submitting" ? "正在保存…" : "保存并进入家谱"
}}</text>
</view>
</view>
</view>
</view>
<view
v-if="duplicateReminderVisible"
class="duplicate-reminder-layer"
@click="closeDuplicateReminder"
>
<view class="duplicate-reminder" @click.stop>
<view class="duplicate-reminder__content">
<text class="duplicate-reminder__title">先确认是否已有家谱</text>
<text class="duplicate-reminder__copy"
>同一家族可能已经建谱建议先搜索谱名地区和堂号避免重复创建</text
>
<view
class="duplicate-reminder__search"
@click="searchExistingGenealogy"
>
<text>先搜索已有家谱</text>
</view>
<view class="duplicate-reminder__confirm" @click="confirmCreate">
<text>确认没有继续创建</text>
</view>
<view
class="duplicate-reminder__cancel"
@click="closeDuplicateReminder"
>返回修改</view
>
</view>
</view>
</view>
<view v-if="ancestorState === 'success'" class="flow-success-layer">
<view class="flow-success-dialog">
<view class="flow-success-dialog__content">
<text class="flow-success-dialog__title">家谱创建完成</text>
<text class="flow-success-dialog__copy"
>当前为本地流程预览资料尚未提交服务器可进入总览继续检查页面</text
>
<view class="flow-success-dialog__action" @click="enterOverview">
<text>进入家谱总览</text>
</view>
</view>
</view>
</view>
<AppDialog
:visible="discardVisible"
title="放弃创建?"
message="当前填写内容尚未保存,确认返回后将清空。"
confirm-text="放弃并返回"
cancel-text="继续填写"
show-cancel
compact-actions
:close-on-mask="false"
@confirm="confirmDiscard"
@cancel="cancelDiscard"
/>
</view>
</template>
<script setup>
import { computed, reactive, ref } from "vue";
import { onBackPress, onUnload } from "@dcloudio/uni-app";
import AppDialog from "@/components/AppDialog.vue";
import GenealogyPageBackground from "@/components/GenealogyPageBackground.vue";
import PageHeader from "@/components/PageHeader.vue";
import {
createLocalGenealogyPreview,
removeLocalGenealogyPreview,
updateLocalGenealogyPreview,
updateLocalGenealogyPreviewAncestor,
} from "@/data/mock.js";
import { createDiscardConfirmation } from "@/utils/discard-confirmation.js";
import {
GENEALOGY_ACCESS_PRESET,
GENEALOGY_ACCESS_PRESET_OPTIONS,
} from "@/utils/genealogy-contracts.js";
import { handleBackPress, openPage, returnTo, runBackGuard } from "@/utils/navigation.js";
const currentStep = ref("create");
const genealogyId = ref("");
const isSubmitting = ref(false);
const createState = ref("form");
const ancestorState = ref("form");
const duplicateReminderVisible = ref(false);
const discardVisible = ref(false);
const fieldErrors = reactive({
surname: "",
name: "",
location: "",
personName: "",
});
let submitTimer = null;
const createForm = reactive({
surname: "",
name: "",
hall: "",
location: "",
accessPreset: GENEALOGY_ACCESS_PRESET.MEMBER_ONLY,
});
const ancestorForm = reactive({
personName: "",
generationNo: 1,
generationName: "一世",
sex: "0",
birthDate: "",
introduction: "",
});
const sexOptions = [
{ value: "0", label: "男" },
{ value: "1", label: "女" },
{ value: "2", label: "暂不填写" },
];
const sexLabel = computed(
() =>
sexOptions.find((option) => option.value === ancestorForm.sex)?.label ||
"请选择",
);
const changeAncestorSex = (event) => {
ancestorForm.sex = sexOptions[Number(event.detail.value)]?.value || "2";
};
const changeAncestorBirthDate = (event) => {
ancestorForm.birthDate = event.detail.value;
};
const isAncestorStep = computed(() => currentStep.value === "ancestor");
const isDirty = computed(() =>
isAncestorStep.value
? Boolean(
ancestorForm.personName ||
ancestorForm.birthDate ||
ancestorForm.introduction ||
ancestorForm.sex !== "0",
)
: Boolean(
createForm.surname ||
createForm.name ||
createForm.hall ||
createForm.location ||
createForm.accessPreset !== GENEALOGY_ACCESS_PRESET.MEMBER_ONLY,
),
);
const discardConfirmation = createDiscardConfirmation((visible) => {
discardVisible.value = visible;
});
const requestRawDiscardConfirmation = discardConfirmation.request;
const requestDiscardConfirmation = async () => {
const confirmed = await requestRawDiscardConfirmation();
if (confirmed && currentStep.value === "create" && genealogyId.value) {
removeLocalGenealogyPreview(genealogyId.value);
genealogyId.value = "";
}
return confirmed;
};
const confirmDiscard = discardConfirmation.confirm;
const cancelDiscard = discardConfirmation.cancel;
onUnload(() => {
const timer = submitTimer;
submitTimer = null;
if (timer) clearTimeout(timer);
discardConfirmation.dispose();
});
const closeActiveTransient = () => {
if (duplicateReminderVisible.value) closeDuplicateReminder();
else cancelDiscard();
};
const popInternalTrail = () => {
currentStep.value = "create";
ancestorState.value = "form";
return true;
};
const requestBack = () => {
if (ancestorState.value === "success") return enterOverview();
return runBackGuard({
transientOpen: duplicateReminderVisible.value || discardVisible.value,
internalTrail: isAncestorStep.value && !isSubmitting.value,
dirty: isDirty.value,
submitting: isSubmitting.value,
"close-transient": closeActiveTransient,
"pop-internal-trail": popInternalTrail,
"block-submitting": () => true,
"confirm-discard": requestDiscardConfirmation,
});
};
onBackPress((event) => handleBackPress(event, requestBack));
const clearFieldError = (field) => {
fieldErrors[field] = "";
};
const validateCreateForm = () => {
fieldErrors.surname = createForm.surname.trim() ? "" : "请填写姓氏";
fieldErrors.name = createForm.name.trim() ? "" : "请填写谱名";
fieldErrors.location = createForm.location.trim() ? "" : "请填写所在地";
return !fieldErrors.surname && !fieldErrors.name && !fieldErrors.location;
};
const submitCreate = () => {
if (isSubmitting.value) return;
createState.value = "form";
if (!validateCreateForm()) return;
duplicateReminderVisible.value = true;
};
const closeDuplicateReminder = () => {
duplicateReminderVisible.value = false;
};
const searchExistingGenealogy = () => {
closeDuplicateReminder();
return openPage("G06", {}, "G03");
};
const confirmCreate = () => {
if (isSubmitting.value) return;
closeDuplicateReminder();
const createSnapshot = Object.freeze({ ...createForm });
isSubmitting.value = true;
createState.value = "submitting";
const timer = setTimeout(() => {
if (submitTimer !== timer) return;
submitTimer = null;
isSubmitting.value = false;
if (createSnapshot.name.trim() === "失败") {
createState.value = "error";
return;
}
genealogyId.value =
updateLocalGenealogyPreview(genealogyId.value, createSnapshot) ||
createLocalGenealogyPreview(createSnapshot);
currentStep.value = "ancestor";
createState.value = "form";
}, 320);
submitTimer = timer;
};
const submitAncestor = () => {
if (isSubmitting.value) return;
if (!genealogyId.value) {
ancestorState.value = "error";
return;
}
if (!ancestorForm.personName.trim()) {
fieldErrors.personName = "请填写首代姓名";
return;
}
const ancestorSnapshot = Object.freeze({ ...ancestorForm });
isSubmitting.value = true;
ancestorState.value = "submitting";
const timer = setTimeout(() => {
if (submitTimer !== timer) return;
submitTimer = null;
isSubmitting.value = false;
if (ancestorSnapshot.personName.trim() === "失败") {
ancestorState.value = "error";
return;
}
ancestorState.value = updateLocalGenealogyPreviewAncestor(
genealogyId.value,
ancestorSnapshot,
)
? "success"
: "error";
}, 320);
submitTimer = timer;
};
const enterOverview = () =>
returnTo("G05", { genealogyId: genealogyId.value });
</script>
<style scoped lang="scss">
@use "../../styles/adaptive-frame-profiles.scss" as adaptive;
.flow-page {
display: flex;
min-height: 100vh;
flex-direction: column;
background: #f9f6ef;
}
.flow-content {
z-index: 2;
padding: 24rpx 32rpx 48rpx;
}
.create-flow-panel {
@include adaptive.adaptive-genealogy-state-panel;
min-height: 1000rpx;
}
.create-flow-panel__content {
z-index: 1;
display: flex;
min-height: 1000rpx;
flex-direction: column;
box-sizing: border-box;
padding: 48rpx 50rpx 42rpx;
}
.flow-step-label {
color: #a33b2b;
font-family: "STKaiti", "KaiTi", serif;
font-size: 25rpx;
font-weight: 700;
letter-spacing: 2rpx;
}
.flow-heading {
display: block;
margin-top: 13rpx;
color: #392719;
font-family: "STKaiti", "KaiTi", serif;
font-size: 39rpx;
font-weight: 700;
letter-spacing: 2rpx;
}
.flow-note,
.flow-rule__note {
display: block;
color: #786654;
font-size: 25rpx;
line-height: 1.65;
}
.flow-note {
min-height: 56rpx;
margin-top: 12rpx;
}
.flow-fields {
margin-top: 10rpx;
}
.field-row {
display: flex;
min-height: 86rpx;
align-items: center;
border-bottom: 1rpx solid rgba(181, 138, 75, 0.36);
}
.field-row > text {
width: 126rpx;
flex: 0 0 auto;
color: #786654;
font-family: "STKaiti", "KaiTi", serif;
font-size: 29rpx;
}
.field-row input {
min-width: 0;
flex: 1;
color: #392719;
font-family: "STKaiti", "KaiTi", serif;
font-size: 28rpx;
}
.placeholder {
color: #b7aa97;
}
.field-error {
display: block;
margin-top: 3rpx;
color: $brand-red;
font-size: 24rpx;
line-height: 34rpx;
text-align: right;
}
.fixed-value {
color: #a33b2b !important;
}
.flow-rule {
display: flex;
align-items: center;
justify-content: space-between;
margin-top: 20rpx;
}
.flow-rule__label {
color: #392719;
font-family: "STKaiti", "KaiTi", serif;
font-size: 29rpx;
font-weight: 700;
}
.flow-rule__options {
display: flex;
gap: 12rpx;
}
.flow-rule__option {
display: flex;
min-height: 54rpx;
align-items: center;
padding: 0 10rpx;
color: #786654;
font-size: 23rpx;
}
.flow-rule__option--active {
color: $brand-red;
font-weight: 700;
}
.flow-rule__note {
margin-top: 8rpx;
}
.flow-error {
display: block;
margin-top: 10rpx;
color: $brand-red;
font-size: 25rpx;
line-height: 36rpx;
text-align: center;
}
.intro-field {
margin-top: 18rpx;
}
.intro-field > text {
display: block;
color: #392719;
font-family: "STKaiti", "KaiTi", serif;
font-size: 29rpx;
font-weight: 700;
}
.intro-field textarea {
width: 100%;
min-height: 142rpx;
margin-top: 8rpx;
color: #392719;
font-family: "STKaiti", "KaiTi", serif;
font-size: 26rpx;
line-height: 1.6;
}
.flow-primary-action {
display: flex;
width: 100%;
min-height: 96rpx;
align-items: center;
justify-content: center;
margin-top: auto;
background: url("/static/assets/foundation/transparent/a01-scroll-primary-v3.png")
center / contain no-repeat;
}
.flow-primary-action__copy {
z-index: 1;
color: #fff9ec;
font-family: "STKaiti", "KaiTi", serif;
font-size: 31rpx;
font-weight: 700;
letter-spacing: 4rpx;
}
.action-hover {
opacity: 0.82;
}
.duplicate-reminder-layer,
.flow-success-layer {
position: fixed;
z-index: 30;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
box-sizing: border-box;
padding: 42rpx;
background: rgba(34, 20, 12, 0.6);
}
.duplicate-reminder,
.flow-success-dialog {
width: 100%;
max-width: 670rpx;
min-height: 650rpx;
background: url("/static/assets/modules/auth/transparent/a01-scroll-dialog-v3.png")
center / contain no-repeat;
}
.duplicate-reminder__content,
.flow-success-dialog__content {
z-index: 1;
display: flex;
min-height: 650rpx;
flex-direction: column;
align-items: center;
box-sizing: border-box;
padding: 92rpx 64rpx 56rpx;
text-align: center;
}
.duplicate-reminder__title,
.flow-success-dialog__title {
color: $ink;
font-family: "STKaiti", "KaiTi", serif;
font-size: 39rpx;
font-weight: 700;
}
.duplicate-reminder__copy,
.flow-success-dialog__copy {
margin-top: 24rpx;
color: $ink-muted;
font-size: 24rpx;
line-height: 40rpx;
}
.duplicate-reminder__search,
.duplicate-reminder__confirm,
.flow-success-dialog__action {
display: flex;
width: 100%;
min-height: 92rpx;
align-items: center;
justify-content: center;
}
.duplicate-reminder__search {
margin-top: 38rpx;
background: url("/static/assets/foundation/transparent/a01-scroll-secondary-v3.png")
center / contain no-repeat;
}
.duplicate-reminder__confirm {
margin-top: 14rpx;
background: url("/static/assets/foundation/transparent/a01-scroll-primary-v3.png")
center / contain no-repeat;
}
.duplicate-reminder__search text,
.duplicate-reminder__confirm text,
.flow-success-dialog__action text {
z-index: 1;
color: #fff9ec;
font-size: 25rpx;
font-weight: 700;
}
.duplicate-reminder__search text {
color: #7b4e24;
}
.duplicate-reminder__cancel {
display: flex;
min-height: 68rpx;
align-items: center;
margin-top: auto;
color: $ink-muted;
font-size: 23rpx;
}
.flow-success-dialog__action {
margin-top: 44rpx;
background: url("/static/assets/foundation/transparent/a01-scroll-primary-v3.png")
center / contain no-repeat;
}
</style>
<!-- 页面编号G-03用途创建家谱两阶段创建的结果恢复链未闭合时保持关闭 -->
<template><view class="create-page"><GenealogyPageBackground /><view class="page-header"><PageHeader title="创建家谱" custom-back @back="backToGenealogies" /></view><view class="page-content"><view class="state-card"><text>创建家谱待结果恢复合同</text><text>创建家谱后还必须用真实响应中的 `genealogyId` 创建首位人物当前没有可靠的创建结果恢复查询 owner且不能从泛型我的家谱列表按名称猜 ID页面不再生成本地家谱或本地首位人物预览</text><AppButton block label="返回我的家谱" @click="backToGenealogies" /></view></view></view></template>
<script setup>import AppButton from "@/components/AppButton.vue";import GenealogyPageBackground from "@/components/GenealogyPageBackground.vue";import PageHeader from "@/components/PageHeader.vue";import {returnTo} from "@/utils/navigation.js";const backToGenealogies=()=>returnTo("G01");</script>
<style scoped lang="scss">@use "../../styles/adaptive-frame-profiles.scss" as adaptive;.create-page{display:flex;min-height:100vh;flex-direction:column;background:$paper}.page-header,.page-content{z-index:1}.page-content{padding:18rpx 24rpx 72rpx}.state-card{box-sizing:border-box;min-height:340rpx;padding:78rpx 52rpx 50rpx;text-align:center;@include adaptive.adaptive-genealogy-state-panel}.state-card text{display:block}.state-card text:first-child{color:$ink;font-family:STKaiti,KaiTi,serif;font-size:35rpx;font-weight:700}.state-card text:nth-child(2){margin-top:18rpx;color:$ink-muted;font-size:24rpx;line-height:1.65}.state-card .app-button{margin-top:28rpx}</style>
+4 -718
View File
@@ -1,718 +1,4 @@
<!-- 页面编号G-06用途搜索家谱邀请码加入双模式定位目标家谱 -->
<template>
<view class="search-page">
<GenealogyPageBackground />
<view class="search-page__header"><PageHeader title="加入家谱" /></view>
<view class="search-page__content">
<view class="mode-tabs">
<view
class="mode-tab"
:class="{ 'mode-tab--active': mode === 'search' }"
@click="switchMode('search')"
>搜索家谱</view
>
<view
class="mode-tab"
:class="{ 'mode-tab--active': mode === 'invite' }"
@click="switchMode('invite')"
>邀请码加入</view
>
</view>
<template v-if="mode === 'search'">
<view class="search-controls">
<view class="search-field">
<input
v-model.trim="keyword"
class="search-input"
placeholder="姓氏、谱名、地区或堂号"
placeholder-class="search-input__placeholder"
@confirm="search"
/>
<view v-if="keyword" class="search-clear" @click="clearSearch"
>清空</view
>
</view>
<view class="search-action" @click="search">
<text class="search-action__label">搜索</text>
</view>
</view>
<view class="filter-row">
<text class="filter-label">地区</text>
<view
v-for="area in areas"
:key="area"
class="filter-chip"
:class="{ 'filter-chip--active': selectedArea === area }"
@click="selectedArea = area"
>{{ area }}</view
>
</view>
<image
class="search-divider"
src="/static/assets/modules/genealogy/transparent/section-divider.png"
mode="scaleToFill"
/>
<AppLoading
v-if="searchState === 'loading'"
variant="section"
text="正在检索公开家谱"
description="请稍候,正在整理匹配结果。"
/>
<view
v-else-if="searchState === 'initial'"
class="search-status search-initial"
>
<text class="search-status__lead">先确认家族是否已有家谱</text>
<text class="search-status__copy"
>可按姓氏谱名地区或堂号查找避免重复创建</text
>
<image
class="search-status__hall"
src="/static/assets/foundation/transparent/root-header-hall.png"
mode="aspectFit"
/>
</view>
<view v-else-if="searchState === 'results'" class="search-results">
<view class="search-results__head"
><text>检索到 {{ results.length }} 部家谱</text
><text>请核对支系与管理信息</text></view
>
<view
v-for="item in results"
:key="item.id"
class="result-card genealogy-card"
@click="openPreview(item)"
>
<view class="result-card__content">
<view class="result-card__head">
<view>
<text class="result-card__name">{{ item.name }}</text>
<text class="result-card__identity"
>{{ item.surname }} · {{ item.hall }}</text
>
</view>
<text
class="result-card__relation"
:class="`result-card__relation--${item.relation}`"
>{{ resultRelationLabel(item) }}</text
>
</view>
<view class="result-card__facts">
<text>地区{{ item.location }}</text>
<text>所属上级谱{{ item.parentName }}</text>
<text>当前支系{{ item.branchName }}</text>
<text
>管理信息{{ item.manager }} · {{ item.certification }}</text
>
<text
>{{ item.memberCount }} 位成员 · 更新于
{{ item.updatedAt }}</text
>
</view>
<view
v-if="resultActionLabel(item)"
class="result-card__action"
@click.stop="handleResultAction(item)"
>{{ resultActionLabel(item) }}</view
>
</view>
</view>
</view>
<view
v-else-if="searchState === 'empty'"
class="search-status search-empty"
>
<image
class="search-status__cloud"
src="/static/assets/modules/genealogy/transparent/create-cloud.png"
mode="aspectFit"
/>
<text class="search-status__lead">暂未找到匹配家谱</text>
<text class="search-status__copy"
>可调整地区或关键词后重试也可使用邀请码加入</text
>
</view>
<view v-else class="search-status search-error">
<text class="search-status__lead">检索暂时失败</text>
<text class="search-status__copy"
>当前仅展示失败样式请稍后重新搜索</text
>
<view class="status-retry" @click="search">
<text>重新搜索</text>
</view>
</view>
</template>
<template v-else>
<view class="invite-block">
<text class="invite-title">输入家谱邀请码</text>
<text class="invite-copy"
>邀请码用于定位指定家谱加入前仍需确认真实姓名和关系</text
>
<view class="invite-controls">
<view class="invite-field">
<input
v-model.trim="inviteCode"
class="invite-input"
maxlength="12"
placeholder="请输入邀请码"
placeholder-class="search-input__placeholder"
@input="resetInvite"
/>
</view>
<view class="search-action" @click="verifyInvite">
<text class="search-action__label">验证</text>
</view>
</view>
<text class="invite-demo">样式验证可输入 JP2026</text>
</view>
<image
class="search-divider"
src="/static/assets/modules/genealogy/transparent/section-divider.png"
mode="scaleToFill"
/>
<view
v-if="inviteState === 'initial'"
class="search-status invite-initial"
>
<text class="search-status__lead">通过邀请码直接定位家谱</text>
<text class="search-status__copy"
>验证有效后仅显示目标家谱仍需填写身份关系本地验证不会变更成员身份</text
>
</view>
<view
v-else-if="inviteState === 'invalid'"
class="search-status invite-invalid"
>
<text class="search-status__lead">邀请码无效或已过期</text>
<text class="search-status__copy"
>请核对邀请码或向家谱管理员重新获取</text
>
</view>
<view v-else class="invite-result">
<text class="invite-result__label">已定位目标家谱</text>
<view class="result-card genealogy-card">
<view class="result-card__content">
<text class="result-card__name">{{ inviteTarget.name }}</text>
<view class="result-card__facts">
<text
>{{ inviteTarget.surname }} · {{ inviteTarget.hall }} ·
{{ inviteTarget.location }}</text
>
<text>所属上级谱{{ inviteTarget.parentName }}</text>
<text>当前支系{{ inviteTarget.branchName }}</text>
<text
>{{ inviteTarget.manager }} ·
{{ inviteTarget.certification }}</text
>
</view>
<view class="result-card__action" @click="confirmInvite"
>填写关系信息</view
>
</view>
</view>
<text class="invite-result__note">当前为样式验证不会变更成员身份</text>
</view>
</template>
</view>
</view>
</template>
<script setup>
import { computed, ref } from "vue";
import { onLoad, onUnload } from "@dcloudio/uni-app";
import AppLoading from "@/components/AppLoading.vue";
import GenealogyPageBackground from "@/components/GenealogyPageBackground.vue";
import PageHeader from "@/components/PageHeader.vue";
import {
getGenealogyFixtureAccess,
isGenealogySearchVisible,
publicGenealogies,
} from "@/data/mock.js";
import { goRoot, openPage } from "@/utils/navigation.js";
const mode = ref("search");
const keyword = ref("");
const selectedArea = ref("全部");
const searchState = ref("initial");
const inviteCode = ref("");
const inviteState = ref("initial");
const results = ref([]);
let searchTimer = null;
const invalidateSearch = () => {
const timer = searchTimer;
searchTimer = null;
if (timer) clearTimeout(timer);
};
const areas = ["全部", "河南", "山东"];
const inviteTarget = computed(() => publicGenealogies[0]);
const syncModeFromRoute = (query = {}) => {
mode.value = query?.mode === "invite" ? "invite" : "search";
if (mode.value === "search" && query?.state === "loading")
searchState.value = "loading";
};
onLoad((query) => syncModeFromRoute(query));
onUnload(invalidateSearch);
const switchMode = (nextMode) => {
invalidateSearch();
mode.value = nextMode;
results.value = [];
searchState.value = "initial";
inviteState.value = "initial";
};
const search = () => {
invalidateSearch();
searchState.value = "loading";
const value = keyword.value.trim();
const area = selectedArea.value;
const timer = setTimeout(() => {
if (searchTimer !== timer || mode.value !== "search") return;
searchTimer = null;
if (value === "失败") {
searchState.value = "error";
return;
}
results.value = publicGenealogies.filter((item) => {
const matchesKeyword =
!value ||
`${item.name}${item.surname}${item.location}${item.hall}`.includes(
value,
);
const matchesArea =
area === "全部" || item.location.includes(area);
return isGenealogySearchVisible(String(item.id)) && matchesKeyword && matchesArea;
});
searchState.value = results.value.length ? "results" : "empty";
}, 260);
searchTimer = timer;
};
const clearSearch = () => {
invalidateSearch();
keyword.value = "";
results.value = [];
searchState.value = "initial";
};
const openPreview = (item) => {
if (
item.relation === "available" &&
getGenealogyFixtureAccess(String(item.id)).canApply
) {
return openPage(
"G05",
{ genealogyId: String(item.id) },
"G06",
);
}
return false;
};
const resultActionLabel = (item) => {
if (item.relation === "pending") return "查看申请进度";
if (item.relation === "joined") return "切换到该家谱";
if (item.relation === "owned") return "进入我的家谱";
if (!getGenealogyFixtureAccess(String(item.id)).canApply) return "";
return {
available: "申请加入",
rejected: "修改后重新申请",
removed: "重新申请",
}[item.relation] || "";
};
const resultRelationLabel = (item) =>
({
available: "可申请",
joined: "已加入",
pending: "审核中",
rejected: "已拒绝",
removed: "已退出",
owned: "我创建的",
})[item.relation] || "关系待确认";
const handleResultAction = (item) => {
if (item.relation === "pending")
return openPage("G09", { status: "pending" }, "G06");
if (
["available", "rejected", "removed"].includes(item.relation) &&
getGenealogyFixtureAccess(String(item.id)).canApply
)
return openPage(
"G08",
{ genealogyId: String(item.id), source: "search" },
"G06",
);
if (item.relation === "joined" || item.relation === "owned")
return goRoot("G01", { genealogyId: String(item.id) });
return false;
};
const verifyInvite = () => {
inviteState.value =
inviteCode.value.toUpperCase() === "JP2026" ? "valid" : "invalid";
};
const resetInvite = () => {
inviteState.value = "initial";
};
const confirmInvite = () => {
if (inviteState.value !== "valid") return false;
if (inviteCode.value.trim().toUpperCase() !== "JP2026") return false;
return openPage(
"G08",
{
genealogyId: String(inviteTarget.value.id),
source: "invite",
},
"G06",
);
};
</script>
<style scoped lang="scss">
@use "../../styles/adaptive-frame-profiles.scss" as adaptive;
.search-page {
display: flex;
min-height: 100vh;
flex-direction: column;
background: $paper;
}
.search-page__header {
z-index: 2;
}
.search-page__content {
z-index: 3;
min-height: calc(100vh - 104rpx);
padding: 30rpx 24rpx calc(58rpx + env(safe-area-inset-bottom));
box-sizing: border-box;
}
.mode-tabs {
display: flex;
width: 500rpx;
margin: 30rpx auto 0;
border-bottom: 1rpx solid rgba(181, 138, 75, 0.45);
}
.mode-tab {
display: flex;
flex: 1;
align-items: center;
justify-content: center;
min-height: 70rpx;
color: $ink-muted;
font-family: "STKaiti", "KaiTi", serif;
font-size: 28rpx;
}
.mode-tab--active {
margin-bottom: -2rpx;
background: linear-gradient($brand-red, $brand-red) center bottom /
calc(100% - 84rpx) 4rpx no-repeat;
color: $brand-red;
font-weight: 700;
}
.search-controls {
display: flex;
align-items: center;
gap: 20rpx;
margin-top: 32rpx;
}
.invite-controls {
display: flex;
align-items: center;
gap: 20rpx;
margin-top: 32rpx;
}
.search-field {
@include adaptive.adaptive-g06-search-field;
display: grid;
width: 430rpx;
min-height: 95rpx;
}
.search-input {
z-index: 1;
grid-area: 1 / 1;
width: 100%;
height: 100%;
padding: 0 82rpx 0 30rpx;
box-sizing: border-box;
color: $ink;
font-size: 25rpx;
}
.search-input__placeholder {
color: #ab9c83;
}
.search-clear {
z-index: 2;
display: flex;
grid-area: 1 / 1;
justify-self: end;
height: 100%;
align-items: center;
margin-right: 20rpx;
color: $brand-red;
font-size: 21rpx;
}
.search-action {
@include adaptive.adaptive-g06-search-action;
display: flex;
width: 200rpx;
min-height: 88rpx;
align-items: center;
justify-content: center;
}
.search-action__label {
z-index: 1;
color: #fff4dc;
font-family: "STKaiti", "KaiTi", serif;
font-size: 31rpx;
font-weight: 700;
letter-spacing: 5rpx;
}
.filter-row {
display: flex;
align-items: center;
gap: 12rpx;
margin: 16rpx 14rpx 0;
}
.filter-label {
color: $ink-muted;
font-size: 22rpx;
}
.filter-chip {
display: flex;
min-width: 82rpx;
min-height: 52rpx;
align-items: center;
justify-content: center;
color: $ink-muted;
font-size: 22rpx;
}
.filter-chip--active {
color: $brand-red;
font-weight: 700;
}
.search-divider {
display: block;
width: 100%;
height: 30rpx;
margin: 24rpx 0 24rpx;
}
.search-status {
display: flex;
min-height: 240rpx;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 16rpx 28rpx 24rpx;
box-sizing: border-box;
text-align: center;
}
.search-status__cloud {
width: 92rpx;
height: 46rpx;
margin-bottom: 4rpx;
opacity: 0.78;
}
.search-status__lead {
color: $ink;
font-family: "STKaiti", "KaiTi", serif;
font-size: 30rpx;
font-weight: 700;
letter-spacing: 2rpx;
}
.search-status__copy {
margin-top: 12rpx;
color: $ink-muted;
font-size: 22rpx;
line-height: 36rpx;
}
.search-status__hall {
width: 330rpx;
height: 132rpx;
margin-top: 210rpx;
opacity: 0.22;
filter: grayscale(1);
}
.status-retry {
display: flex;
width: 300rpx;
min-height: 76rpx;
align-items: center;
justify-content: center;
margin-top: 24rpx;
background: url("/static/assets/foundation/transparent/a01-scroll-primary-v3.png")
center / contain no-repeat;
}
.status-retry text {
z-index: 1;
color: #fff9ed;
font-size: 25rpx;
font-weight: 700;
}
.search-results {
display: flex;
flex-direction: column;
gap: 16rpx;
margin-top: 4rpx;
}
.search-results__head {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 16rpx;
color: $ink-muted;
font-size: 21rpx;
}
.search-results__head text:first-child {
color: $ink;
font-family: "STKaiti", "KaiTi", serif;
font-size: 27rpx;
font-weight: 700;
}
.result-card {
@include adaptive.adaptive-genealogy-list-card;
min-height: 314rpx;
box-sizing: border-box;
padding: 28rpx 30rpx;
}
.result-card__content {
z-index: 1;
}
.result-card__head {
display: flex;
align-items: flex-start;
justify-content: space-between;
}
.result-card__name,
.result-card__identity {
display: block;
}
.result-card__name {
color: $ink;
font-family: "STKaiti", "KaiTi", serif;
font-size: 33rpx;
font-weight: 700;
}
.result-card__identity {
margin-top: 5rpx;
color: $ink-muted;
font-size: 22rpx;
}
.result-card__relation {
flex: 0 0 auto;
max-width: 190rpx;
margin-left: 12rpx;
color: #9a641f;
font-size: 21rpx;
text-align: right;
}
.result-card__relation--rejected,
.result-card__relation--removed {
color: $brand-red;
}
.result-card__facts {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8rpx 18rpx;
margin-top: 16rpx;
color: $ink-muted;
font-size: 23rpx;
line-height: 32rpx;
}
.result-card__facts text:nth-child(4),
.result-card__facts text:nth-child(5) {
grid-column: 1 / -1;
}
.result-card__action {
display: flex;
min-height: 58rpx;
align-items: center;
justify-content: center;
margin-top: 14rpx;
color: $brand-red;
font-family: "STKaiti", "KaiTi", serif;
font-size: 27rpx;
font-weight: 700;
}
.invite-block {
padding: 24rpx 18rpx 0;
}
.invite-title {
display: block;
color: $ink;
font-family: "STKaiti", "KaiTi", serif;
font-size: 31rpx;
font-weight: 700;
}
.invite-copy {
display: block;
margin-top: 10rpx;
color: $ink-muted;
font-size: 22rpx;
line-height: 35rpx;
}
.invite-field {
@include adaptive.adaptive-g06-search-field;
display: grid;
flex: 1;
min-height: 95rpx;
}
.invite-input {
z-index: 1;
grid-area: 1 / 1;
width: 100%;
height: 100%;
padding: 0 30rpx;
box-sizing: border-box;
color: $ink;
font-size: 25rpx;
letter-spacing: 3rpx;
}
.invite-demo {
display: block;
margin-top: 8rpx;
color: #9b876f;
font-size: 19rpx;
}
.invite-result__label {
display: block;
margin: 0 12rpx 12rpx;
color: $ink;
font-family: "STKaiti", "KaiTi", serif;
font-size: 27rpx;
font-weight: 700;
}
.invite-result__note {
display: block;
margin-top: 12rpx;
color: $brand-red;
font-size: 22rpx;
text-align: center;
}
@media screen and (max-width: 340px) {
.search-page__content {
padding-right: 20rpx;
padding-left: 20rpx;
}
.search-field {
width: 430rpx;
}
.result-card {
padding-right: 24rpx;
padding-left: 24rpx;
}
.result-card__facts {
grid-template-columns: 1fr;
}
.result-card__facts text {
grid-column: 1 !important;
}
}
</style>
<!-- 页面编号G-06用途搜索公开家谱公开列表 item DTO 未声明时保持关闭 -->
<template><view class="search-page"><GenealogyPageBackground /><view class="page-header"><PageHeader title="搜索家谱" custom-back @back="backToGenealogies" /></view><view class="page-content"><view class="state-card"><text>公开家谱搜索待后端字段合同</text><text>公开家谱读取没有声明家谱 ID名称地区简介成员数或可申请状态字段页面不再展示 fixture 搜索结果也不从猜测条目进入申请</text><AppButton block label="返回我的家谱" @click="backToGenealogies" /></view></view></view></template>
<script setup>import AppButton from "@/components/AppButton.vue";import GenealogyPageBackground from "@/components/GenealogyPageBackground.vue";import PageHeader from "@/components/PageHeader.vue";import {returnTo} from "@/utils/navigation.js";const backToGenealogies=()=>returnTo("G01");</script>
<style scoped lang="scss">@use "../../styles/adaptive-frame-profiles.scss" as adaptive;.search-page{display:flex;min-height:100vh;flex-direction:column;background:$paper}.page-header,.page-content{z-index:1}.page-content{padding:18rpx 24rpx 72rpx}.state-card{box-sizing:border-box;min-height:340rpx;padding:78rpx 52rpx 50rpx;text-align:center;@include adaptive.adaptive-genealogy-state-panel}.state-card text{display:block}.state-card text:first-child{color:$ink;font-family:STKaiti,KaiTi,serif;font-size:35rpx;font-weight:700}.state-card text:nth-child(2){margin-top:18rpx;color:$ink-muted;font-size:24rpx;line-height:1.65}.state-card .app-button{margin-top:28rpx}</style>
+4 -462
View File
@@ -1,462 +1,4 @@
<!-- 页面编号G-08用途申请加入家谱与提交成功/失败状态 -->
<template>
<view class="join-page">
<GenealogyPageBackground />
<view class="join-page__header">
<PageHeader :title="sourceContract.headerTitle" custom-back @back="requestBack" />
</view>
<view
class="join-panel"
:class="{
'join-state--form': joinState === 'form',
'join-state--success': joinState === 'success',
'join-state--error': joinState === 'error',
'join-state--ineligible': joinState === 'ineligible',
}"
>
<view v-if="joinState === 'form'" class="join-form">
<text class="join-form__eyebrow">{{ sourceContract.eyebrow }}</text>
<text class="join-form__title"
>{{ sourceContract.formTitle }} {{ genealogyName }}</text
>
<text class="join-form__copy">{{ sourceContract.formCopy }}</text>
<view class="join-field">
<text>真实姓名</text
><input
v-model="form.realName"
placeholder="请输入真实姓名"
placeholder-class="join-placeholder"
@input="clearFieldError('realName')"
/>
</view>
<text v-if="fieldErrors.realName" class="join-field-error">{{
fieldErrors.realName
}}</text>
<view class="join-field">
<text>与家谱关系</text
><input
v-model="form.relation"
placeholder="例如:某某某堂侄"
placeholder-class="join-placeholder"
@input="clearFieldError('relation')"
/>
</view>
<text v-if="fieldErrors.relation" class="join-field-error">{{
fieldErrors.relation
}}</text>
<text class="relation-help"
>请以家谱中一位已知长辈为参照例如某某某堂侄</text
>
<view class="join-field join-field--message">
<text>{{ sourceContract.thirdFieldLabel }}</text
><textarea
v-model="form.message"
auto-height
maxlength="80"
:placeholder="sourceContract.thirdFieldPlaceholder"
placeholder-class="join-placeholder"
/>
</view>
<text class="join-form__note">{{ sourceContract.note }}</text>
<view class="join-action" @click="submitJoin">
<text>{{
isSubmitting
? sourceContract.submittingLabel
: sourceContract.submitLabel
}}</text>
</view>
</view>
<view v-else class="join-result">
<text class="join-result__eyebrow">{{
joinState === "success"
? sourceContract.successEyebrow
: joinState === "ineligible"
? "当前不可申请"
: sourceContract.errorEyebrow
}}</text>
<text class="join-result__title">{{
joinState === "success"
? sourceContract.successTitle
: joinState === "ineligible"
? ineligibleTitle
: sourceContract.errorTitle
}}</text>
<text class="join-result__copy">{{ resultCopy }}</text>
<view
class="join-action"
@click="
joinState === 'success'
? completeFlow()
: joinState === 'ineligible'
? handleIneligibleAction()
: retryForm()
"
>
<text>{{
joinState === "success"
? sourceContract.nextLabel
: joinState === "ineligible"
? ineligibleActionLabel
: "重新填写"
}}</text>
</view>
</view>
</view>
<AppDialog
:visible="discardVisible"
title="放弃填写?"
message="当前身份关系尚未保存,确认返回后将清空。"
confirm-text="放弃并返回"
cancel-text="继续填写"
show-cancel
compact-actions
:close-on-mask="false"
@confirm="confirmDiscard"
@cancel="cancelDiscard"
/>
</view>
</template>
<script setup>
import { computed, reactive, ref } from "vue";
import { onBackPress, onLoad, onUnload } from "@dcloudio/uni-app";
import AppDialog from "@/components/AppDialog.vue";
import GenealogyPageBackground from "@/components/GenealogyPageBackground.vue";
import PageHeader from "@/components/PageHeader.vue";
import {
findGenealogyFixture,
getGenealogyFixtureAccess,
} from "@/data/mock.js";
import { createDiscardConfirmation } from "@/utils/discard-confirmation.js";
import {
goBack,
goRoot,
handleBackPress,
openPage,
returnTo,
runBackGuard,
} from "@/utils/navigation.js";
const genealogyId = ref("");
const source = ref("search");
const genealogyName = ref("这部家谱");
const joinState = ref("form");
const isSubmitting = ref(false);
const errorMessage = ref("");
const ineligibleRelation = ref("unknown");
const discardVisible = ref(false);
const form = reactive({ realName: "", relation: "", message: "" });
const fieldErrors = reactive({ realName: "", relation: "" });
let submitTimer = null;
const isDirty = computed(() =>
Boolean(form.realName || form.relation || form.message),
);
const discardConfirmation = createDiscardConfirmation((visible) => {
discardVisible.value = visible;
});
const requestDiscardConfirmation = discardConfirmation.request;
const confirmDiscard = discardConfirmation.confirm;
const cancelDiscard = discardConfirmation.cancel;
const sourceContract = computed(() =>
source.value === "invite"
? {
headerTitle: "确认身份关系",
eyebrow: "邀请码定位预览",
formTitle: "核对家谱",
formCopy:
"请填写真实身份和亲属关系;当前仅验证页面流程,不会变更成员身份。",
thirdFieldLabel: "补充信息",
thirdFieldPlaceholder: "选填:补充祖居地、长辈姓名等信息",
note: "后端尚未提供邀请码验证与直接入谱接口。",
submitLabel: "完成本地校验",
submittingLabel: "正在校验…",
successEyebrow: "本地校验完成",
successTitle: "信息尚未提交服务器",
errorEyebrow: "校验未完成",
errorTitle: "暂时无法完成校验",
nextLabel: "返回我的家谱",
successCopy: `本地流程预览已完成“${genealogyName.value}”的身份填写;返回后不会选中或加入这部家谱。`,
}
: {
headerTitle: "申请加入家谱",
eyebrow: "公开家谱入谱申请",
formTitle: "申请加入",
formCopy: "请填写真实身份和亲属关系,管理员审核后会通过消息告知结果。",
thirdFieldLabel: "申请说明",
thirdFieldPlaceholder: "补充祖居地、长辈姓名等核验信息",
note: "当前仅验证页面流程,后端申请接口接入后才能正式提交。",
submitLabel: "完成本地校验",
submittingLabel: "正在校验…",
successEyebrow: "本地校验完成",
successTitle: "申请尚未提交服务器",
errorEyebrow: "申请未提交",
errorTitle: "暂时无法完成校验",
nextLabel: "查看我的申请",
successCopy: `本地流程预览已完成“${genealogyName.value}”的申请填写,当前不会新增审核记录。`,
},
);
const resultCopy = computed(() =>
joinState.value === "success"
? sourceContract.value.successCopy
: joinState.value === "ineligible"
? ({
owned: "这是你创建的家谱,无需重复提交加入申请。",
joined: "你已经是这部家谱的成员,无需重复申请。",
pending: "这部家谱已有待审核申请,请先查看申请进度。",
})[ineligibleRelation.value] ||
"当前家谱不存在、未公开或不可申请,请返回后重新选择。"
: errorMessage.value ||
"请检查网络后重新填写;未成功提交的内容不会进入审核列表。",
);
const ineligibleTitle = computed(
() =>
({
owned: "你已拥有这部家谱",
joined: "你已加入这部家谱",
pending: "申请正在审核中",
})[ineligibleRelation.value] || "无法打开申请表",
);
const ineligibleActionLabel = computed(
() =>
ineligibleRelation.value === "pending"
? "查看申请进度"
: ["owned", "joined"].includes(ineligibleRelation.value)
? "返回我的家谱"
: "返回上一页",
);
onLoad((query) => {
genealogyId.value = String(query.genealogyId || "");
source.value = query.source === "invite" ? "invite" : "search";
if (!genealogyId.value) {
ineligibleRelation.value = "unknown";
joinState.value = "ineligible";
return;
}
const fixture = findGenealogyFixture(genealogyId.value);
genealogyName.value = fixture?.name || "这部家谱";
const access = getGenealogyFixtureAccess(genealogyId.value);
if (!fixture || !access.canApply) {
ineligibleRelation.value = access.relation;
joinState.value = "ineligible";
return;
}
if (query.state === "success") joinState.value = "success";
});
const requestBack = () => {
if (joinState.value === "success") return completeFlow();
return runBackGuard({
transientOpen: discardVisible.value,
dirty: isDirty.value,
submitting: isSubmitting.value,
"close-transient": cancelDiscard,
"block-submitting": () => true,
"confirm-discard": requestDiscardConfirmation,
});
};
onBackPress((event) => handleBackPress(event, requestBack));
onUnload(() => {
const timer = submitTimer;
submitTimer = null;
if (timer) clearTimeout(timer);
discardConfirmation.dispose();
});
const submitJoin = () => {
if (isSubmitting.value) return;
fieldErrors.realName = form.realName.trim() ? "" : "请填写真实姓名";
fieldErrors.relation = form.relation.trim() ? "" : "请填写与家谱的关系";
if (fieldErrors.realName || fieldErrors.relation) return;
const submitSnapshot = Object.freeze({ ...form });
isSubmitting.value = true;
const timer = setTimeout(() => {
if (submitTimer !== timer) return;
submitTimer = null;
joinState.value =
submitSnapshot.realName.trim() === "失败" ? "error" : "success";
if (joinState.value === "error")
errorMessage.value =
source.value === "invite"
? "邀请码加入暂未完成,请稍后重试。"
: "申请暂未提交,请稍后重试。";
isSubmitting.value = false;
}, 280);
submitTimer = timer;
};
const clearFieldError = (field) => {
fieldErrors[field] = "";
};
const retryForm = () => {
joinState.value = "form";
errorMessage.value = "";
};
const handleIneligibleAction = () => {
if (ineligibleRelation.value === "pending")
return openPage("G09", { status: "pending" }, "G08");
if (["owned", "joined"].includes(ineligibleRelation.value))
return goRoot("G01", { genealogyId: genealogyId.value });
return goBack();
};
const completeFlow = () =>
source.value === "invite" ? returnTo("G01", {}) : returnTo("G09", {});
</script>
<style scoped lang="scss">
@use "../../styles/adaptive-frame-profiles.scss" as adaptive;
.join-page {
display: flex;
min-height: 100vh;
flex-direction: column;
background: $paper;
}
.join-page__header {
z-index: 3;
}
.join-panel {
@include adaptive.adaptive-genealogy-state-panel;
z-index: 2;
width: calc(100% - 32rpx);
min-height: min(590px, calc((100vw - 16px) * 1.337));
margin: 22rpx auto 0;
padding: 70rpx 8%;
box-sizing: border-box;
}
.join-form {
min-width: 0;
}
.join-form__eyebrow,
.join-result__eyebrow {
display: block;
color: $brand-red;
font-size: 23rpx;
letter-spacing: 3rpx;
}
.join-form__title,
.join-result__title {
display: block;
margin-top: 12rpx;
color: $ink;
font-family: "STKaiti", "KaiTi", serif;
font-size: 36rpx;
font-weight: 700;
}
.join-form__copy,
.join-result__copy {
display: block;
margin-top: 12rpx;
color: $ink-muted;
font-size: 22rpx;
line-height: 1.6;
}
.join-field {
@include adaptive.adaptive-genealogy-form-field;
display: grid;
min-height: 92rpx;
margin-top: 18rpx;
}
.join-form__previous {
display: block;
margin-top: 14rpx;
color: $brand-red;
font-size: 22rpx;
line-height: 1.55;
}
.join-field > text {
z-index: 1;
grid-area: 1 / 1;
align-self: center;
margin-left: 24rpx;
color: $ink;
font-size: 23rpx;
font-weight: 700;
}
.join-field input,
.join-field textarea {
z-index: 1;
grid-area: 1 / 1;
width: auto;
min-width: 0;
min-height: 92rpx;
margin-right: 20rpx;
margin-left: 170rpx;
color: $ink;
font-size: 23rpx;
line-height: 92rpx;
}
.join-field textarea {
box-sizing: border-box;
padding-top: 26rpx;
line-height: 1.5;
}
.join-placeholder {
color: #a79884;
}
.join-field-error {
display: block;
margin-top: 3rpx;
color: $brand-red;
font-size: 24rpx;
line-height: 34rpx;
text-align: right;
}
.relation-help {
display: block;
margin-top: 5rpx;
color: #8e7b67;
font-size: 19rpx;
line-height: 29rpx;
}
.join-form__note {
display: block;
margin-top: 18rpx;
color: $ink-muted;
font-size: 21rpx;
text-align: center;
}
.join-action {
display: flex;
width: 100%;
min-height: 82rpx;
align-items: center;
justify-content: center;
margin-top: 20rpx;
background: url("/static/assets/foundation/transparent/a01-scroll-primary-v3.png")
center / contain no-repeat;
}
.join-action text {
z-index: 1;
color: #fff9ed;
font-size: 27rpx;
font-weight: 700;
letter-spacing: 3rpx;
}
.join-result {
text-align: center;
}
.join-state--success,
.join-state--error {
padding: 170rpx 12% 70rpx;
}
.join-result__eyebrow {
text-align: center;
}
.join-result__copy {
margin-top: 22rpx;
}
.join-result .join-action {
width: 420rpx;
max-width: 100%;
margin: 34rpx auto 0;
}
@media (min-width: 400px) {
.join-panel {
width: calc(100% - 48rpx);
}
}
</style>
<!-- 页面编号G-08用途加入申请公开家谱详情/权限投影不完整时保持关闭 -->
<template><view class="join-page"><GenealogyPageBackground /><view class="page-header"><PageHeader title="申请加入家谱" custom-back @back="backToSearch" /></view><view class="page-content"><view class="state-card"><text>加入申请待后端字段合同</text><text>申请提交 operation 不等于可安全发起申请当前没有公开家谱详情可申请权限或稳定家谱条目 ID 的消费投影页面不再使用 fixture 家谱或本地申请成功提示</text><AppButton block label="返回搜索家谱" @click="backToSearch" /></view></view></view></template>
<script setup>import AppButton from "@/components/AppButton.vue";import GenealogyPageBackground from "@/components/GenealogyPageBackground.vue";import PageHeader from "@/components/PageHeader.vue";import {returnTo} from "@/utils/navigation.js";const backToSearch=()=>returnTo("G06");</script>
<style scoped lang="scss">@use "../../styles/adaptive-frame-profiles.scss" as adaptive;.join-page{display:flex;min-height:100vh;flex-direction:column;background:$paper}.page-header,.page-content{z-index:1}.page-content{padding:18rpx 24rpx 72rpx}.state-card{box-sizing:border-box;min-height:340rpx;padding:78rpx 52rpx 50rpx;text-align:center;@include adaptive.adaptive-genealogy-state-panel}.state-card text{display:block}.state-card text:first-child{color:$ink;font-family:STKaiti,KaiTi,serif;font-size:35rpx;font-weight:700}.state-card text:nth-child(2){margin-top:18rpx;color:$ink-muted;font-size:24rpx;line-height:1.65}.state-card .app-button{margin-top:28rpx}</style>
+4 -387
View File
@@ -1,394 +1,11 @@
<!-- 页面编号G-09用途我的家谱申请列表与空/失败状态 -->
<!-- 页面编号G-09用途我的申请列表 DTO 未声明时不展示 fixture 申请 -->
<template>
<view class="application-page">
<GenealogyPageBackground />
<view class="application-page__header">
<PageHeader title="我的申请" custom-back @back="requestBack" />
</view>
<view
class="application-content"
:class="{
'application-state--list': applicationState === 'list',
'application-state--empty': applicationState === 'empty',
'application-state--error': applicationState === 'error',
}"
>
<template v-if="applicationState === 'list'">
<view class="application-intro">
<text>入谱申请进度</text><text>审核结果会同步到消息中心</text>
</view>
<view
v-for="item in applications"
:key="item.id"
class="application-card"
>
<view class="application-card__body">
<text class="application-card__name">{{ item.genealogyName }}</text>
<text class="application-card__time">{{ item.appliedAt }}</text>
<text class="application-card__relation">{{ item.relation }}</text>
<text
class="application-card__status"
:class="`application-card__status--${item.status.toLowerCase()}`"
>{{ statusLabel(item.status) }}</text
>
<text class="application-card__hint">{{
statusHint(item.status)
}}</text>
<view
v-if="actionFor(item)"
class="application-card__action"
@click="handleApplicationAction(item)"
>{{ actionFor(item) }}</view
>
</view>
</view>
</template>
<AppLoading
v-else-if="applicationState === 'loading'"
text="正在整理申请记录"
description="请稍候,正在同步审核状态。"
/>
<view v-else class="application-state-card">
<view class="application-state-card__copy">
<text>{{ stateTitle }}</text>
<text>{{ stateCopy }}</text>
</view>
</view>
<view
v-if="applicationState !== 'list' && applicationState !== 'loading'"
class="application-page__action"
@click="
applicationState === 'error' ? loadApplications({}) : toSearch()
"
>
<text>{{
applicationState === "error" ? "重新查看" : "查找公开家谱"
}}</text>
</view>
</view>
<AppDialog
:visible="!!withdrawTarget"
title="预览撤回效果"
:message="
withdrawTarget
? `当前只更新本页对“${withdrawTarget.genealogyName}”的撤回预览,不会向服务器提交;真实申请仍可能处于审核中。`
: ''
"
confirm-text="查看本地效果"
cancel-text="暂不撤回"
show-cancel
:close-on-mask="false"
@confirm="confirmWithdraw"
@cancel="cancelWithdraw"
/>
</view>
<view class="applications-page"><GenealogyPageBackground /><view class="page-header"><PageHeader title="我的申请" custom-back @back="returnToGenealogies" /></view><view class="page-content"><view class="state-card"><text>申请列表待后端字段合同</text><text>申请读取响应没有声明申请 ID家谱名称申请时间状态或审核备注字段页面已停止展示本地申请和本地撤回结果</text><AppButton block label="返回我的家谱" @click="returnToGenealogies" /></view></view></view>
</template>
<script setup>
import { computed, ref } from "vue";
import { onBackPress, onLoad } from "@dcloudio/uni-app";
import AppDialog from "@/components/AppDialog.vue";
import AppLoading from "@/components/AppLoading.vue";
import GenealogyPageBackground from "@/components/GenealogyPageBackground.vue";
import PageHeader from "@/components/PageHeader.vue";
import { findGenealogyFixture } from "@/data/mock.js";
import { handleBackPress, openPage, runBackGuard } from "@/utils/navigation.js";
const applications = ref([]);
const applicationState = ref("loading");
const errorMessage = ref("");
const withdrawTarget = ref(null);
const genealogyNameFor = (genealogyId) =>
findGenealogyFixture(genealogyId)?.name || "未知家谱";
const applicationSamples = [
{
id: "pending-2003",
genealogyId: "2003",
genealogyName: genealogyNameFor("2003"),
relation: "自述为汤正华堂侄",
appliedAt: "今天 10:24",
status: "PENDING",
},
{
id: "approved-1002",
genealogyId: "1002",
genealogyName: genealogyNameFor("1002"),
relation: "祖居河南汝南",
appliedAt: "昨天 18:02",
status: "APPROVED",
},
{
id: "rejected-2004",
genealogyId: "2004",
genealogyName: genealogyNameFor("2004"),
relation: "补充材料不足",
appliedAt: "7月12日 09:18",
status: "REJECTED",
},
];
const statusLabel = (status) =>
({
PENDING: "审核中",
APPROVED: "已通过",
REJECTED: "未通过",
LOCAL_WITHDRAWN: "本地撤回预览",
})[status] || "状态未知";
const statusHint = (status) =>
({
PENDING: "管理员尚未处理,可在审核前撤回",
APPROVED: "申请已通过,可进入这部家谱",
REJECTED: "请修改关系说明后重新提交",
LOCAL_WITHDRAWN: "尚未提交服务器,真实申请仍可能处于审核中",
})[status] || "";
const actionFor = (item) =>
({ PENDING: "预览撤回效果", APPROVED: "进入家谱", REJECTED: "修改后重新提交" })[
item.status
] || "";
const stateTitle = computed(() =>
applicationState.value === "empty"
? "还没有入谱申请"
: applicationState.value === "loading"
? "正在整理申请记录"
: "申请记录暂时无法读取",
);
const stateCopy = computed(() =>
applicationState.value === "empty"
? "从家谱搜索提交的申请会显示在这里;邀请码本地校验不会生成申请记录。"
: applicationState.value === "loading"
? "请稍候,正在同步审核状态。"
: errorMessage.value || "请检查网络后重新查看。",
);
const loadApplications = (query = {}) => {
applicationState.value = "loading";
errorMessage.value = "";
if (query.state === "empty") {
applicationState.value = "empty";
return;
}
if (query.state === "error") {
applicationState.value = "error";
return;
}
if (query.state === "loading") {
applicationState.value = "loading";
return;
}
applications.value = applicationSamples.map((item) => ({ ...item }));
if (query.status) {
const requestedStatus = String(query.status).toUpperCase();
applications.value = applications.value.filter(
(item) => item.status === requestedStatus,
);
if (!applications.value.length) applicationState.value = "empty";
}
if (applicationState.value !== "empty") applicationState.value = "list";
};
onLoad(loadApplications);
const requestBack = () =>
runBackGuard({
transientOpen: Boolean(withdrawTarget.value),
"close-transient": cancelWithdraw,
});
onBackPress((event) => handleBackPress(event, requestBack));
const handleApplicationAction = (item) => {
if (item.status === "APPROVED")
return openPage(
"G05",
{ genealogyId: String(item.genealogyId) },
"G09",
);
if (item.status === "REJECTED")
return openPage(
"G08",
{ genealogyId: String(item.genealogyId), source: "search" },
"G09",
);
if (item.status === "PENDING") withdrawTarget.value = item;
};
const cancelWithdraw = () => {
withdrawTarget.value = null;
};
const confirmWithdraw = () => {
const target = applications.value.find(
(item) => item.id === withdrawTarget.value?.id,
);
if (target) target.status = "LOCAL_WITHDRAWN";
cancelWithdraw();
};
const toSearch = () => openPage("G06", {}, "G09");
import AppButton from "@/components/AppButton.vue"; import GenealogyPageBackground from "@/components/GenealogyPageBackground.vue"; import PageHeader from "@/components/PageHeader.vue"; import { returnTo } from "@/utils/navigation.js"; const returnToGenealogies=()=>returnTo("G01");
</script>
<style scoped lang="scss">
@use "../../styles/adaptive-frame-profiles.scss" as adaptive;
.application-page {
display: flex;
min-height: 100vh;
flex-direction: column;
background: $paper;
}
.application-page__header {
z-index: 3;
}
.application-content {
z-index: 2;
padding: 28rpx 24rpx 60rpx;
}
.application-intro {
display: flex;
align-items: baseline;
justify-content: space-between;
margin: 0 8rpx 20rpx;
}
.application-intro text:first-child {
color: $ink;
font-family: "STKaiti", "KaiTi", serif;
font-size: 31rpx;
font-weight: 700;
}
.application-intro text:last-child {
color: $ink-muted;
font-size: 24rpx;
}
.application-card {
@include adaptive.adaptive-genealogy-list-card;
display: grid;
width: 100%;
min-height: 210rpx;
margin-bottom: 12rpx;
}
.application-card__body {
display: grid;
min-width: 0;
min-height: 174rpx;
grid-template-columns: minmax(0, 1fr) auto;
gap: 8rpx 14rpx;
padding: 26rpx 28rpx 22rpx 40rpx;
box-sizing: border-box;
}
.application-card__name {
grid-row: 1;
grid-column: 1;
align-self: start;
justify-self: start;
color: $ink;
font-family: "STKaiti", "KaiTi", serif;
font-size: 30rpx;
font-weight: 700;
}
.application-card__time {
grid-row: 1;
grid-column: 2;
align-self: start;
justify-self: end;
margin-top: 3rpx;
color: #998873;
font-size: 23rpx;
}
.application-card__relation {
display: block;
grid-row: 2;
grid-column: 1 / -1;
align-self: start;
justify-self: start;
margin-top: 0;
color: $ink-muted;
font-size: 24rpx;
}
.application-card__status {
grid-row: 2;
grid-column: 2;
align-self: center;
justify-self: end;
margin-right: 3%;
color: $brand-red;
font-size: 25rpx;
font-weight: 700;
}
.application-card__status--approved {
color: #537368;
}
.application-card__status--rejected {
color: #7e6f62;
}
.application-card__hint {
grid-row: 3;
grid-column: 1;
align-self: center;
justify-self: start;
margin-bottom: 4rpx;
color: #766653;
font-size: 23rpx;
}
.application-card__action {
z-index: 2;
display: flex;
grid-row: 3;
grid-column: 2;
align-self: center;
justify-self: end;
min-height: 54rpx;
align-items: center;
margin-right: 2%;
margin-bottom: 0;
color: $brand-red;
font-size: 23rpx;
font-weight: 700;
white-space: nowrap;
}
.application-state-card {
@include adaptive.adaptive-genealogy-list-card;
display: flex;
width: 100%;
min-height: 228rpx;
align-items: center;
justify-content: center;
margin-top: 80rpx;
padding: 48rpx 12%;
box-sizing: border-box;
}
.application-state-card__copy {
display: flex;
flex-direction: column;
justify-content: center;
text-align: center;
}
.application-state-card__copy text:first-child {
color: $ink;
font-family: "STKaiti", "KaiTi", serif;
font-size: 31rpx;
font-weight: 700;
}
.application-state-card__copy text:last-child {
margin-top: 16rpx;
color: $ink-muted;
font-size: 24rpx;
line-height: 1.6;
}
.application-page__action {
display: flex;
width: 420rpx;
min-height: 82rpx;
align-items: center;
justify-content: center;
margin: 34rpx auto 0;
background: url("/static/assets/foundation/transparent/a01-scroll-primary-v3.png")
center / contain no-repeat;
}
.application-page__action text {
z-index: 1;
color: #fff9ed;
font-size: 26rpx;
font-weight: 700;
}
.applications-page{display:flex;min-height:100vh;flex-direction:column;background:$paper}.page-header,.page-content{z-index:1}.page-content{padding:18rpx 24rpx 72rpx}.state-card{box-sizing:border-box;min-height:340rpx;padding:78rpx 52rpx 50rpx;text-align:center;@include adaptive.adaptive-genealogy-state-panel}.state-card text{display:block}.state-card text:first-child{color:$ink;font-family:STKaiti,KaiTi,serif;font-size:35rpx;font-weight:700}.state-card text:nth-child(2){margin-top:18rpx;color:$ink-muted;font-size:24rpx;line-height:1.65}.state-card .app-button{margin-top:28rpx}
</style>
+4 -471
View File
@@ -1,478 +1,11 @@
<!-- 页面编号G-10用途管理员入谱申请审核与空/失败状态 -->
<!-- 页面编号G-10用途加入申请审核缺申请列表 DTO/ID 来源时保持关闭 -->
<template>
<view class="review-page">
<GenealogyPageBackground />
<view class="review-page__header">
<PageHeader
title="入谱审核"
action="说明"
custom-back
@action="showHelp"
@back="requestBack"
/>
</view>
<view
class="review-content"
:class="{
'review-state--list': reviewState === 'list',
'review-state--empty': reviewState === 'empty',
'review-state--error': reviewState === 'error',
'review-state--no-permission': reviewState === 'no-permission',
}"
>
<view v-if="reviewState !== 'loading'" class="review-intro">
<text>核实亲属关系后再决定</text>
<text>当前只预览审核交互不会提交服务器</text>
</view>
<template v-if="reviewState === 'list'">
<view
v-for="item in applications"
:key="item.id"
class="application-card"
>
<view class="application-card__body">
<text class="application-card__name">{{ item.name }}</text>
<text class="application-card__phone">{{ item.phone }}</text>
<text class="application-card__time">{{ item.appliedAt }}</text>
<text class="application-card__relation">{{ item.relation }}</text>
<view v-if="item.status === 'PENDING'" class="review-actions">
<AppButton
compact
type="secondary"
label="预览拒绝"
@click="confirmAudit(item, false)"
/>
<AppButton
compact
label="预览通过"
@click="confirmAudit(item, true)"
/>
</view>
<view
v-else
class="review-result"
:class="
item.status === 'APPROVED'
? 'review-result--approved'
: 'review-result--rejected'
"
>
<text class="application-card__status">{{
item.status === "APPROVED" ? "已通过" : "已拒绝"
}}</text>
</view>
</view>
</view>
</template>
<AppLoading
v-else-if="reviewState === 'loading'"
text="正在整理审核申请"
description="请稍候,正在读取待审核记录。"
/>
<view v-else class="review-state-card">
<view class="review-state-card__copy">
<text>{{ stateTitle }}</text>
<text>{{ stateCopy }}</text>
</view>
</view>
<AppButton
v-if="reviewState === 'error'"
class="review-page__retry"
label="重新查看"
@click="loadApplications({ genealogyId })"
/>
</view>
<AppDialog
:visible="!!confirmation || helpVisible"
:title="
helpVisible
? '审核说明'
: confirmation?.approved
? '预览通过效果?'
: '预览拒绝效果?'
"
:message="
helpVisible
? '请核对申请人的姓名、亲属关系和补充说明,仅确认与本家谱存在真实关系的申请。'
: confirmation?.approved
? '当前只更新本页本地预览,不会让申请人成为成员,也不会提交服务器。'
: '当前只更新本页本地预览,不会通知申请人,也不会提交服务器。'
"
:confirm-text="
helpVisible
? '我知道了'
: confirmation?.approved
? '查看通过效果'
: '查看拒绝效果'
"
:show-cancel="!!confirmation"
compact-actions
:close-on-mask="false"
@confirm="helpVisible ? closeDialog() : applyAudit()"
@cancel="closeDialog"
>
<view v-if="confirmation && !confirmation.approved" class="rejection-field">
<text>拒绝原因</text>
<textarea
id="g10-rejection-reason"
v-model="rejectionReason"
auto-height
maxlength="120"
placeholder="请说明需要补充或核实的信息"
:aria-invalid="!!rejectionError"
aria-describedby="g10-rejection-error"
:focus="rejectionFocused"
@input="clearRejectionError"
/>
<text
v-if="rejectionError"
id="g10-rejection-error"
class="rejection-field__error"
role="alert"
>{{ rejectionError }}</text
>
</view>
</AppDialog>
<view v-if="feedbackVisible" class="review-feedback">
<text>{{ feedbackMessage }}</text>
</view>
</view>
<view class="review-page"><GenealogyPageBackground /><view class="page-header"><PageHeader title="申请审核" custom-back @back="returnToGenealogies" /></view><view class="page-content"><view class="state-card"><text>申请审核待后端字段合同</text><text>审核 operation 存在但没有可消费的申请列表 DTO 或稳定申请 ID 来源页面不再展示 fixture 申请或本地通过/拒绝预览</text><AppButton block label="返回我的家谱" @click="returnToGenealogies" /></view></view></view>
</template>
<script setup>
import { computed, nextTick, ref } from "vue";
import { onBackPress, onLoad, onUnload } from "@dcloudio/uni-app";
import AppDialog from "@/components/AppDialog.vue";
import AppButton from "@/components/AppButton.vue";
import AppLoading from "@/components/AppLoading.vue";
import GenealogyPageBackground from "@/components/GenealogyPageBackground.vue";
import PageHeader from "@/components/PageHeader.vue";
import { getGenealogyFixtureAccess } from "@/data/mock.js";
import { handleBackPress, runBackGuard } from "@/utils/navigation.js";
const applications = ref([]);
const reviewSamples = [
{
id: "review-1",
name: "汤志成",
phone: "139****6421",
relation: "自述为汤正华堂侄 · 祖居洛阳",
appliedAt: "今天 10:24",
status: "PENDING",
},
{
id: "review-2",
name: "汤雨薇",
phone: "136****2798",
relation: "自述为汤正国之女 · 已补充长辈姓名",
appliedAt: "昨天 18:02",
status: "PENDING",
},
];
const genealogyId = ref("");
const reviewState = ref("loading");
const errorMessage = ref("");
const confirmation = ref(null);
const rejectionReason = ref("");
const rejectionError = ref("");
const rejectionFocused = ref(false);
const helpVisible = ref(false);
const feedbackVisible = ref(false);
const feedbackMessage = ref("");
let feedbackTimer = null;
onUnload(() => {
const timer = feedbackTimer;
feedbackTimer = null;
if (timer) clearTimeout(timer);
});
const stateTitle = computed(() =>
reviewState.value === "empty"
? "暂无待审核申请"
: reviewState.value === "no-permission"
? "当前账号无审核权限"
: "审核列表暂时无法读取",
);
const stateCopy = computed(() =>
reviewState.value === "empty"
? "新的入谱申请会在这里出现,并同步发送消息提醒。"
: reviewState.value === "no-permission"
? "只有家谱所有者或具备审核权限的管理员可以处理申请。"
: errorMessage.value || "请检查网络或家谱权限后重试。",
);
const loadApplications = (query = {}) => {
reviewState.value = "loading";
errorMessage.value = "";
genealogyId.value = String(query.genealogyId || "");
if (!genealogyId.value) {
errorMessage.value = "没有找到当前家谱,请从家谱总览进入。";
reviewState.value = "error";
return;
}
if (
getGenealogyFixtureAccess(genealogyId.value).accessRole !== "owner"
) {
reviewState.value = "no-permission";
return;
}
if (query.state === "loading") {
reviewState.value = "loading";
return;
}
if (query.state === "empty") {
reviewState.value = "empty";
return;
}
if (query.state === "error") {
reviewState.value = "error";
return;
}
if (query.state === "no-permission") {
reviewState.value = "no-permission";
return;
}
applications.value = reviewSamples.map((item) => ({ ...item }));
reviewState.value = "list";
};
onLoad(loadApplications);
const confirmAudit = (item, approved) => {
rejectionReason.value = "";
rejectionError.value = "";
rejectionFocused.value = false;
confirmation.value = { item, approved };
};
const closeDialog = () => {
confirmation.value = null;
rejectionReason.value = "";
rejectionError.value = "";
rejectionFocused.value = false;
helpVisible.value = false;
};
const requestBack = () =>
runBackGuard({
transientOpen: Boolean(confirmation.value) || helpVisible.value,
"close-transient": closeDialog,
});
onBackPress((event) => handleBackPress(event, requestBack));
const showFeedback = (message) => {
feedbackMessage.value = message;
feedbackVisible.value = true;
if (feedbackTimer) clearTimeout(feedbackTimer);
const timer = setTimeout(() => {
if (feedbackTimer !== timer) return;
feedbackVisible.value = false;
feedbackTimer = null;
}, 1800);
feedbackTimer = timer;
};
const clearRejectionError = () => {
rejectionError.value = "";
};
const applyAudit = async () => {
const current = confirmation.value;
if (!current) return;
if (!current.approved && !rejectionReason.value.trim()) {
rejectionError.value = "请填写拒绝原因,方便申请人补充资料";
rejectionFocused.value = false;
await nextTick();
rejectionFocused.value = true;
return;
}
current.item.status = current.approved ? "APPROVED" : "REJECTED";
if (!current.approved) current.item.rejectionReason = rejectionReason.value.trim();
closeDialog();
showFeedback(
`本地审核预览已更新,尚未提交服务器 · ${current.approved ? "已通过" : "已拒绝"}`,
);
};
const showHelp = () => {
helpVisible.value = true;
};
import AppButton from "@/components/AppButton.vue"; import GenealogyPageBackground from "@/components/GenealogyPageBackground.vue"; import PageHeader from "@/components/PageHeader.vue"; import { returnTo } from "@/utils/navigation.js"; const returnToGenealogies=()=>returnTo("G01");
</script>
<style scoped lang="scss">
@use "../../styles/adaptive-frame-profiles.scss" as adaptive;
.review-page {
display: flex;
min-height: 100vh;
flex-direction: column;
background: $paper;
}
.review-page__header {
z-index: 1;
}
.rejection-field { width: 100%; margin: 20rpx 0; text-align: left; }
.rejection-field > text:first-child { display: block; color: $ink; font-size: 23rpx; font-weight: 700; }
.rejection-field textarea { @include adaptive.adaptive-genealogy-form-field; width: 100%; min-height: 110rpx; margin-top: 10rpx; padding: 18rpx; color: $ink; font-size: 23rpx; line-height: 1.55; }
.rejection-field__error { display: block; margin-top: 8rpx; color: $brand-red; font-size: 21rpx; }
.review-content {
z-index: 1;
padding: 24rpx 24rpx 60rpx;
}
.review-intro {
margin: 0 8rpx 24rpx;
padding-bottom: 22rpx;
background: url("/static/assets/modules/genealogy/transparent/section-divider.png") bottom center / 100% 14rpx no-repeat;
}
.review-intro text {
display: block;
}
.review-intro text:first-child {
color: $ink;
font-family: "STKaiti", "KaiTi", serif;
font-size: 29rpx;
font-weight: 700;
}
.review-intro text:nth-child(2) {
margin-top: 8rpx;
color: $ink-muted;
font-size: 24rpx;
}
.application-card {
@include adaptive.adaptive-genealogy-list-card;
width: 100%;
min-height: 218rpx;
margin-bottom: 12rpx;
}
.application-card__body {
display: grid;
width: 100%;
min-height: 182rpx;
grid-template-columns: auto minmax(0, 1fr) auto;
grid-template-rows: auto auto auto;
gap: 8rpx 0;
padding: 28rpx 28rpx 22rpx 40rpx;
box-sizing: border-box;
}
.application-card__name {
grid-column: 1;
color: $ink;
font-family: "STKaiti", "KaiTi", serif;
font-size: 30rpx;
font-weight: 700;
}
.application-card__phone {
grid-column: 2;
margin-left: 14rpx;
color: $ink-muted;
font-size: 23rpx;
}
.application-card__time {
grid-column: 3;
justify-self: end;
margin-top: 3rpx;
color: #998873;
font-size: 23rpx;
}
.application-card__relation {
display: block;
grid-column: 1 / span 2;
grid-row: 2;
margin-top: 8rpx;
color: $ink-muted;
font-size: 24rpx;
line-height: 1.35;
}
.review-actions {
display: flex;
grid-column: 2 / 4;
grid-row: 3;
justify-self: end;
gap: 12rpx;
}
.review-actions .app-button {
width: 148rpx;
min-height: 68rpx;
}
.review-result {
@include adaptive.adaptive-scroll-button(primary);
display: grid;
grid-column: 2 / 4;
grid-row: 3;
justify-self: end;
width: 308rpx;
min-height: 68rpx;
}
.application-card__status {
z-index: 1;
display: flex;
grid-area: 1 / 1;
align-items: center;
justify-content: center;
height: 100%;
color: $brand-red;
font-size: 24rpx;
font-weight: 700;
}
.review-result--approved .application-card__status {
color: #fff9ed;
}
.review-result--approved {
border-image-source: url("/static/assets/foundation/transparent/a01-scroll-primary-v3.png");
}
.review-result--rejected .application-card__status {
color: $ink;
}
.review-result--rejected {
border-image-source: url("/static/assets/foundation/transparent/a01-scroll-secondary-v3.png");
}
.review-state-card {
@include adaptive.adaptive-genealogy-list-card;
width: 100%;
min-height: 228rpx;
margin-top: 70rpx;
}
.review-state-card__copy {
display: flex;
min-height: 228rpx;
flex-direction: column;
justify-content: center;
padding: 48rpx 12%;
box-sizing: border-box;
text-align: center;
}
.review-state-card__copy text:first-child {
color: $ink;
font-family: "STKaiti", "KaiTi", serif;
font-size: 31rpx;
font-weight: 700;
}
.review-state-card__copy text:last-child {
margin-top: 15rpx;
color: $ink-muted;
font-size: 24rpx;
line-height: 1.6;
}
.review-page__retry {
width: 420rpx;
min-height: 82rpx;
margin: 32rpx auto 0;
}
.review-feedback {
@include adaptive.adaptive-feedback-toast;
position: fixed;
z-index: 40;
top: 140rpx;
left: 50%;
display: flex;
min-width: 250rpx;
min-height: 76rpx;
align-items: center;
justify-content: center;
padding: 0 36rpx;
transform: translateX(-50%);
}
.review-feedback text {
color: $ink;
font-size: 23rpx;
}
.review-page{display:flex;min-height:100vh;flex-direction:column;background:$paper}.page-header,.page-content{z-index:1}.page-content{padding:18rpx 24rpx 72rpx}.state-card{box-sizing:border-box;min-height:340rpx;padding:78rpx 52rpx 50rpx;text-align:center;@include adaptive.adaptive-genealogy-state-panel}.state-card text{display:block}.state-card text:first-child{color:$ink;font-family:STKaiti,KaiTi,serif;font-size:35rpx;font-weight:700}.state-card text:nth-child(2){margin-top:18rpx;color:$ink-muted;font-size:24rpx;line-height:1.65}.state-card .app-button{margin-top:28rpx}
</style>
+4 -478
View File
@@ -1,485 +1,11 @@
<!-- 页面编号G-11用途家谱设置访问预设与家谱简介页面设计阶段使用本地模拟交互 -->
<!-- 页面编号G-11用途家谱设置缺可靠读取 DTO 与封面上传 owner 时保持关闭 -->
<template>
<view class="settings-page">
<GenealogyPageBackground />
<view class="settings-page__header">
<PageHeader title="家谱设置" custom-back @back="requestBack" />
</view>
<view
class="settings-panel"
:class="{
'settings-state--form': settingsState === 'form',
'settings-state--success': settingsState === 'success',
'settings-state--error': settingsState === 'error',
'settings-state--no-permission': settingsState === 'no-permission',
}"
>
<AppLoading
v-if="settingsState === 'loading'"
text="正在读取家谱设置"
description="请稍候,正在准备名称与访问规则。"
/>
<view v-else-if="settingsState === 'form'" class="settings-form">
<text class="settings-form__eyebrow">谱主可见 · 基础设置</text>
<text class="settings-form__title">完善家谱访问规则</text>
<text class="settings-form__copy"
>这里维护名称访问规则与家谱简介访问规则会同时决定公开范围和加入方式管理权转让在成员场景中单独处理</text
>
<view class="settings-field">
<text>家谱名称</text>
<input
v-model="genealogyDraft.name"
maxlength="20"
placeholder="请输入家谱名称"
placeholder-class="settings-placeholder"
@input="nameError = ''"
/>
</view>
<text v-if="nameError" class="settings-field-error">{{
nameError
}}</text>
<view class="visibility-block">
<text class="visibility-block__label">访问规则</text>
<view class="visibility-options">
<view
v-for="option in accessPresetOptions"
:key="option.value"
class="visibility-option"
@click="genealogyDraft.accessPreset = option.value"
>
<image
:src="
genealogyDraft.accessPreset === option.value
? '/static/assets/foundation/transparent/a01-scroll-primary-v3.png'
: '/static/assets/foundation/transparent/a01-scroll-secondary-v3.png'
"
mode="aspectFit"
/>
<text
:class="{
'visibility-option__text--active':
genealogyDraft.accessPreset === option.value,
}"
>{{ option.label }}</text
>
</view>
</view>
<text class="visibility-block__hint">{{ visibilityHint }}</text>
</view>
<view class="settings-field settings-field--note">
<text>家谱简介</text>
<textarea
v-model="genealogyDraft.intro"
auto-height
maxlength="80"
placeholder="简要介绍家谱来源与支系"
placeholder-class="settings-placeholder"
/>
</view>
<view class="settings-action" @click="saveSettings">
<text>保存设置</text>
</view>
</view>
<view v-else class="settings-result">
<text class="settings-result__eyebrow">{{
settingsState === "success"
? "设置已保存"
: settingsState === "no-permission"
? "权限不足"
: "设置未保存"
}}</text>
<text class="settings-result__title">{{
settingsState === "success"
? "本页设置草稿已更新"
: settingsState === "no-permission"
? "当前账号不能修改家谱"
: "暂时无法打开家谱设置"
}}</text>
<text class="settings-result__copy">{{
settingsState === "success"
? `本地预览已更新,尚未提交服务器;当前只保留在本页,返回总览不会改变原资料 · ${genealogyDraft.name} · ${accessPresetLabel}`
: settingsState === "no-permission"
? "只有家谱所有者可以修改名称、访问规则和家谱简介。"
: "请从家谱总览重新进入,当前修改不会被保留。"
}}</text>
<view class="settings-action" @click="handleResultAction">
<text>{{
settingsState === "success" ? "继续调整" : "重新查看"
}}</text>
</view>
</view>
</view>
<AppDialog
:visible="discardVisible"
title="放弃设置修改?"
message="当前修改尚未保存,确认返回后将清空。"
confirm-text="放弃并返回"
cancel-text="继续修改"
show-cancel
compact-actions
:close-on-mask="false"
@confirm="confirmDiscard"
@cancel="cancelDiscard"
/>
<view v-if="feedbackVisible" class="settings-feedback">
<text>本页草稿已更新</text>
</view>
</view>
<view class="settings-page"><GenealogyPageBackground /><view class="page-header"><PageHeader title="家谱设置" custom-back @back="returnToOverview" /></view><view class="page-content"><view class="state-card"><text>家谱设置待字段合同</text><text>更新 operation 已声明名称姓氏祠堂地区简介可见性等字段但当前没有可消费的概览 DTO 来安全预填也没有封面 `coverOssId` 的上传闭环页面不再以 fixture 编辑或本地预览保存</text><AppButton block :label="hasValidContext ? '返回家谱总览' : '返回上一页'" @click="returnToOverview" /></view></view></view>
</template>
<script setup>
import { computed, reactive, ref } from "vue";
import { onBackPress, onLoad, onUnload } from "@dcloudio/uni-app";
import AppDialog from "@/components/AppDialog.vue";
import AppLoading from "@/components/AppLoading.vue";
import GenealogyPageBackground from "@/components/GenealogyPageBackground.vue";
import PageHeader from "@/components/PageHeader.vue";
import {
findGenealogyFixture,
getGenealogyFixtureAccess,
} from "@/data/mock.js";
import { createDiscardConfirmation } from "@/utils/discard-confirmation.js";
import {
GENEALOGY_ACCESS_PRESET,
GENEALOGY_ACCESS_PRESET_OPTIONS,
isGenealogyAccessPreset,
} from "@/utils/genealogy-contracts.js";
import { goBack, handleBackPress, runBackGuard } from "@/utils/navigation.js";
const genealogyId = ref("");
const settingsState = ref("loading");
const nameError = ref("");
const feedbackVisible = ref(false);
const discardVisible = ref(false);
let feedbackTimer = null;
const genealogyDraft = reactive({
name: "",
accessPreset: GENEALOGY_ACCESS_PRESET.MEMBER_ONLY,
intro: "",
});
const originalDraft = ref("");
const isDirty = computed(
() =>
settingsState.value === "form" &&
JSON.stringify(genealogyDraft) !== originalDraft.value,
);
const discardConfirmation = createDiscardConfirmation((visible) => {
discardVisible.value = visible;
});
const requestDiscardConfirmation = discardConfirmation.request;
const confirmDiscard = discardConfirmation.confirm;
const cancelDiscard = discardConfirmation.cancel;
const accessPresetOptions = GENEALOGY_ACCESS_PRESET_OPTIONS;
const accessPresetLabel = computed(
() =>
accessPresetOptions.find((item) => item.value === genealogyDraft.accessPreset)
?.label || "",
);
const visibilityHint = computed(() =>
genealogyDraft.accessPreset === GENEALOGY_ACCESS_PRESET.MEMBER_ONLY
? "只有已加入本家谱的成员可以查看谱系和家族资料。"
: "访客可检索到家谱并提交入谱申请,资料仍需审核后查看。",
);
const loadSettings = (query = {}) => {
genealogyId.value = String(query.genealogyId || "");
if (!genealogyId.value) {
settingsState.value = "error";
return;
}
if (
getGenealogyFixtureAccess(genealogyId.value).accessRole !== "owner"
) {
settingsState.value = "no-permission";
return;
}
const fixture = findGenealogyFixture(genealogyId.value);
if (!fixture) {
settingsState.value = "error";
return;
}
if (!isGenealogyAccessPreset(fixture.accessPreset)) {
settingsState.value = "error";
return;
}
Object.assign(genealogyDraft, {
name: fixture.name,
accessPreset: fixture.accessPreset,
intro: fixture.publicDescription || "家族资料,请妥善保存",
});
originalDraft.value = JSON.stringify(genealogyDraft);
settingsState.value =
query.state === "loading"
? "loading"
: query.state === "success"
? "success"
: query.state === "no-permission"
? "no-permission"
: query.state === "error" || !genealogyId.value
? "error"
: "form";
};
onLoad(loadSettings);
onUnload(() => {
const timer = feedbackTimer;
feedbackTimer = null;
if (timer) clearTimeout(timer);
discardConfirmation.dispose();
});
const requestBack = () =>
runBackGuard({
transientOpen: discardVisible.value,
dirty: isDirty.value,
"close-transient": cancelDiscard,
"confirm-discard": requestDiscardConfirmation,
});
onBackPress((event) => handleBackPress(event, requestBack));
const continueEditing = () => {
if (
getGenealogyFixtureAccess(genealogyId.value).accessRole === "owner"
) {
settingsState.value = "form";
}
};
const handleResultAction = () => {
if (settingsState.value === "success") return continueEditing();
if (settingsState.value === "error")
return loadSettings({ genealogyId: genealogyId.value });
return goBack();
};
const saveSettings = () => {
if (!genealogyDraft.name.trim()) {
nameError.value = "请填写家谱名称";
return;
}
if (genealogyDraft.name.trim().length > 20) {
nameError.value = "家谱名称不能超过 20 个字";
return;
}
originalDraft.value = JSON.stringify(genealogyDraft);
settingsState.value = "success";
feedbackVisible.value = true;
if (feedbackTimer) clearTimeout(feedbackTimer);
const timer = setTimeout(() => {
if (feedbackTimer !== timer) return;
feedbackVisible.value = false;
feedbackTimer = null;
}, 1800);
feedbackTimer = timer;
};
import { computed,ref } from "vue"; import { onLoad } from "@dcloudio/uni-app"; import AppButton from "@/components/AppButton.vue"; import GenealogyPageBackground from "@/components/GenealogyPageBackground.vue"; import PageHeader from "@/components/PageHeader.vue"; import { goBack,returnTo } from "@/utils/navigation.js"; const genealogyId=ref("");const hasValidContext=computed(()=>/^[1-9]\d*$/.test(genealogyId.value));onLoad((query)=>{genealogyId.value=String(query?.genealogyId||"");});const returnToOverview=()=>hasValidContext.value?returnTo("G05",{genealogyId:genealogyId.value}):goBack();
</script>
<style scoped lang="scss">
@use "../../styles/adaptive-frame-profiles.scss" as adaptive;
.settings-page {
display: flex;
min-height: 100vh;
flex-direction: column;
background: $paper;
}
.settings-page__header {
z-index: 3;
}
.settings-panel {
@include adaptive.adaptive-genealogy-state-panel;
z-index: 2;
width: calc(100% - 32rpx);
margin: 18rpx auto 0;
padding: 70rpx 8%;
box-sizing: border-box;
}
.settings-panel > .app-loading {
grid-area: 1 / 1 / -1 / -1;
}
.settings-form {
min-width: 0;
}
.settings-form__eyebrow,
.settings-result__eyebrow {
display: block;
color: $brand-red;
font-size: 23rpx;
letter-spacing: 3rpx;
}
.settings-form__title,
.settings-result__title {
display: block;
margin-top: 10rpx;
color: $ink;
font-family: "STKaiti", "KaiTi", serif;
font-size: 35rpx;
font-weight: 700;
}
.settings-form__copy,
.settings-result__copy {
display: block;
margin-top: 10rpx;
color: $ink-muted;
font-size: 24rpx;
line-height: 1.55;
}
.settings-field {
@include adaptive.adaptive-genealogy-form-field;
display: grid;
min-height: 92rpx;
margin-top: 17rpx;
}
.settings-field > text {
z-index: 1;
grid-area: 1 / 1;
align-self: center;
margin-left: 24rpx;
color: $ink;
font-size: 24rpx;
font-weight: 700;
}
.settings-field input,
.settings-field textarea {
z-index: 1;
grid-area: 1 / 1;
width: auto;
min-width: 0;
min-height: 92rpx;
margin-right: 20rpx;
margin-left: 164rpx;
color: $ink;
font-size: 24rpx;
line-height: 92rpx;
}
.settings-field textarea {
box-sizing: border-box;
padding-top: 26rpx;
line-height: 1.5;
}
.settings-placeholder {
color: #a79884;
}
.settings-field-error {
display: block;
margin-top: 3rpx;
color: $brand-red;
font-size: 24rpx;
line-height: 34rpx;
text-align: right;
}
.visibility-block {
margin-top: 17rpx;
}
.visibility-block__label {
display: block;
color: $ink;
font-size: 24rpx;
font-weight: 700;
}
.visibility-options {
display: flex;
gap: 14rpx;
margin-top: 10rpx;
}
.visibility-option {
display: grid;
width: calc(50% - 7rpx);
min-height: 64rpx;
}
.visibility-option image {
grid-area: 1 / 1;
width: 100%;
height: 100%;
}
.visibility-option text {
z-index: 1;
display: flex;
grid-area: 1 / 1;
align-items: center;
justify-content: center;
height: 100%;
color: $ink;
font-size: 24rpx;
font-weight: 700;
}
.visibility-option .visibility-option__text--active {
color: #fff9ed;
}
.visibility-block__hint {
display: block;
margin-top: 9rpx;
color: $ink-muted;
font-size: 22rpx;
line-height: 1.45;
}
.settings-field--note {
margin-top: 14rpx;
}
.settings-action {
display: flex;
width: 100%;
min-height: 78rpx;
align-items: center;
justify-content: center;
margin-top: 19rpx;
background: url("/static/assets/foundation/transparent/a01-scroll-primary-v3.png")
center / contain no-repeat;
}
.settings-action text {
z-index: 1;
color: #fff9ed;
font-size: 26rpx;
font-weight: 700;
letter-spacing: 3rpx;
}
.settings-result {
text-align: center;
}
.settings-state--success,
.settings-state--error,
.settings-state--no-permission {
padding: 180rpx 12% 70rpx;
}
.settings-result__eyebrow {
text-align: center;
}
.settings-result__copy {
margin-top: 22rpx;
}
.settings-result .settings-action {
width: 420rpx;
max-width: 100%;
margin: 34rpx auto 0;
}
.settings-feedback {
position: fixed;
z-index: 30;
top: 138rpx;
left: 50%;
display: flex;
min-width: 240rpx;
min-height: 74rpx;
align-items: center;
justify-content: center;
padding: 0 34rpx;
transform: translateX(-50%);
background: url("/static/assets/foundation/transparent/a01-scroll-secondary-v3.png")
center / contain no-repeat;
}
.settings-feedback text {
z-index: 1;
color: $ink;
font-size: 22rpx;
}
@media (min-width: 400px) {
.settings-panel {
width: calc(100% - 48rpx);
}
}
.settings-page{display:flex;min-height:100vh;flex-direction:column;background:$paper}.page-header,.page-content{z-index:1}.page-content{padding:18rpx 24rpx 72rpx}.state-card{box-sizing:border-box;min-height:340rpx;padding:78rpx 52rpx 50rpx;text-align:center;@include adaptive.adaptive-genealogy-state-panel}.state-card text{display:block}.state-card text:first-child{color:$ink;font-family:STKaiti,KaiTi,serif;font-size:35rpx;font-weight:700}.state-card text:nth-child(2){margin-top:18rpx;color:$ink-muted;font-size:24rpx;line-height:1.65}.state-card .app-button{margin-top:28rpx}
</style>
+214 -593
View File
@@ -1,176 +1,76 @@
<!-- 页面编号G-12用途字辈诗列表空状态与批量维护页面设计阶段使用本地模拟交互 -->
<!-- 页面编号G-12用途 Apifox GenerationPoemView 读取字辈并以服务端预览维护 -->
<template>
<view class="poem-page">
<GenealogyPageBackground />
<view class="poem-page__header"
><PageHeader
:title="pageTitle"
:action="canManage && poemState === 'list' ? '维护' : ''"
custom-back
@action="openEditor"
@back="requestBack"
/></view>
<view class="poem-page__header">
<PageHeader :title="pageTitle" :action="poemState === 'list' || poemState === 'empty' ? '维护' : ''" custom-back @action="enterManagement" @back="requestBack" />
</view>
<view class="poem-panel" :class="`poem-state--${poemState}`">
<AppLoading v-if="poemState === 'loading'" text="正在读取字辈诗" description="正在读取后端已声明的世代、字辈和状态字段。" />
<view
class="poem-panel"
:class="{
'poem-state--list': poemState === 'list',
'poem-state--empty': poemState === 'empty',
'poem-state--edit': poemState === 'edit',
'poem-state--error': poemState === 'error',
'poem-state--no-permission': poemState === 'no-permission',
}"
>
<AppLoading
v-if="poemState === 'loading'"
text="正在整理字辈诗"
description="请稍候,正在读取家谱字序。"
/>
<view v-else-if="poemState === 'list'" class="poem-list">
<text class="poem-list__eyebrow">{{ genealogyName }} · 传承字序</text>
<text class="poem-list__title">{{ generationRangeTitle }}</text>
<text class="poem-list__copy">{{ currentGenerationCopy }}</text>
<text class="poem-list__copy">接口未声明当前世代字段页面只按后端返回的世代文字和状态展示</text>
<view class="poem-rows">
<view
v-for="item in visiblePoemRows"
:key="item.generationNo"
class="poem-row"
:class="{
'poem-row--current': item.current,
'poem-row--disabled': item.status === GENERATION_POEM_STATUS.DISABLED,
}"
>
<view v-for="item in visiblePoemRows" :key="item.poemId" class="poem-row" :class="{ 'poem-row--disabled': item.status === GENERATION_POEM_STATUS.DISABLED }">
<text class="poem-row__number"> {{ item.generationNo }} </text>
<text class="poem-row__character">{{ item.generationText }}</text>
<text class="poem-row__status">{{
item.current
? "当前字辈"
: item.status === GENERATION_POEM_STATUS.DISABLED
? "已停用·记录保留"
: "传承字序"
}}</text>
<text class="poem-row__status">{{ item.status === GENERATION_POEM_STATUS.DISABLED ? '已停用·记录保留' : '传承字序' }}</text>
</view>
</view>
<view
v-if="remainingPoemCount > 0"
class="poem-load-more"
@click="loadMorePoems"
>
<view v-if="remainingPoemCount > 0" class="poem-load-more" @click="loadMorePoems">
<text>继续加载后续字辈剩余 {{ remainingPoemCount }} </text>
</view>
<view v-if="canManage" class="poem-action" @click="openEditor">
<image
src="/static/assets/foundation/transparent/a01-scroll-primary-v3.png"
mode="aspectFit"
/><text>维护字辈诗</text>
<view class="poem-action" @click="enterManagement">
<image src="/static/assets/foundation/transparent/a01-scroll-primary-v3.png" mode="aspectFit" /><text>{{ canManage ? '继续维护字辈诗' : '读取维护列表' }}</text>
</view>
</view>
<view v-else-if="poemState === 'edit'" class="poem-editor">
<text class="poem-list__eyebrow">批量维护</text>
<text class="poem-list__eyebrow">服务端批量维护</text>
<text class="poem-list__title">录入完整字辈序列</text>
<text class="poem-list__copy"
>无分隔符时每个字符对应一代也可用空格逗号分号顿号斜杠或竖线分隔多字字辈一次最多
{{ MAX_GENERATION_COUNT }} 每代最多
{{ MAX_GENERATION_TEXT_LENGTH }} 个字符本页只更新本地预览尚未提交服务器</text
>
<text class="poem-list__copy">提交前会先请求服务端批量预览无分隔符时每个字符对应一代也可用空格逗号分号顿号斜杠或竖线分隔多字字辈</text>
<view class="poem-field">
<text>字辈内容</text>
<textarea
v-model="poemDraft"
auto-height
:maxlength="MAX_GENERATION_POEM_INPUT_LENGTH * 2"
placeholder="例如:启宗敦本,或 克勤 克俭 承先 启后"
placeholder-class="poem-placeholder"
@input="poemError = ''"
/>
<textarea v-model="poemDraft" auto-height :maxlength="MAX_GENERATION_POEM_INPUT_LENGTH" placeholder="例如:启宗敦本,或 克勤 克俭 承先 启后" placeholder-class="poem-placeholder" @input="invalidatePreview" />
</view>
<text v-if="poemError" class="poem-field-error">{{ poemError }}</text>
<view class="poem-policy">
<text>未被新文本覆盖的后续世代</text>
<view
class="poem-policy__option"
@click="disableMissing = !disableMissing"
>
<image
:src="
disableMissing
? '/static/assets/foundation/transparent/a01-scroll-primary-v3.png'
: '/static/assets/foundation/transparent/a01-scroll-secondary-v3.png'
"
mode="aspectFit"
/>
<text
:class="{
'poem-policy__option-text--active': disableMissing,
}"
>{{ disableMissing ? "停用并保留记录" : "保持原状态" }}</text
>
<view class="poem-policy__option" @click="toggleDisableMissing">
<image :src="disableMissing ? '/static/assets/foundation/transparent/a01-scroll-primary-v3.png' : '/static/assets/foundation/transparent/a01-scroll-secondary-v3.png'" mode="aspectFit" />
<text :class="{ 'poem-policy__option-text--active': disableMissing }">{{ disableMissing ? '停用并保留记录' : '保持原状态' }}</text>
</view>
</view>
<text class="poem-preview"
>预览{{ previewSummary }}</text
>
<text class="poem-preview">{{ previewSummary }}</text>
<view class="poem-editor__actions">
<view class="poem-action" @click="requestLeaveEditor">
<image
src="/static/assets/foundation/transparent/a01-scroll-secondary-v3.png"
mode="aspectFit"
/><text class="poem-action__secondary">取消</text>
<image src="/static/assets/foundation/transparent/a01-scroll-secondary-v3.png" mode="aspectFit" /><text class="poem-action__secondary">取消</text>
</view>
<view class="poem-action" @click="savePoems">
<image
src="/static/assets/foundation/transparent/a01-scroll-primary-v3.png"
mode="aspectFit"
/><text>保存字辈</text>
<view class="poem-action" :class="{ 'poem-action--disabled': previewing || saving }" @click="previewPoems">
<image src="/static/assets/foundation/transparent/a01-scroll-secondary-v3.png" mode="aspectFit" /><text class="poem-action__secondary">{{ previewing ? '正在预览' : '服务端预览' }}</text>
</view>
<view class="poem-action" :class="{ 'poem-action--disabled': !canSave || saving }" @click="savePoems">
<image src="/static/assets/foundation/transparent/a01-scroll-primary-v3.png" mode="aspectFit" /><text>{{ saving ? '正在保存' : '保存字辈' }}</text>
</view>
</view>
</view>
<view v-else class="poem-state-card">
<text class="poem-list__eyebrow">{{
poemState === "empty"
? "尚未建立字辈"
: poemState === "no-permission"
? "权限不足"
: "字辈暂不可用"
}}</text>
<text class="poem-list__title">{{
poemState === "empty"
? "从第一段传承字序开始"
: poemState === "no-permission"
? "当前账号不能维护字辈"
: "暂时无法读取字辈诗"
}}</text>
<text class="poem-list__copy">{{
poemState === "empty"
? "管理员可以一次录入连续字辈,系统会按世代拆分展示。"
: poemState === "no-permission"
? "普通成员可以查看字辈,但只有具备管理权限的成员可以维护。"
: "请从家谱总览重新进入,或稍后再试。"
}}</text>
<view
v-if="poemState !== 'empty' || canManage"
class="poem-action"
@click="handleStateAction"
>
<image
src="/static/assets/foundation/transparent/a01-scroll-primary-v3.png"
mode="aspectFit"
/><text>{{
poemState === "empty"
? "开始录入"
: poemState === "no-permission"
? "返回家谱总览"
: "重新查看"
}}</text>
<text class="poem-list__eyebrow">{{ poemState === 'empty' ? '尚未建立字辈' : '字辈暂不可用' }}</text>
<text class="poem-list__title">{{ poemState === 'empty' ? '当前读取没有正常状态字辈' : '暂时无法读取字辈诗' }}</text>
<text class="poem-list__copy">{{ stateCopy }}</text>
<view class="poem-action" @click="handleStateAction">
<image src="/static/assets/foundation/transparent/a01-scroll-primary-v3.png" mode="aspectFit" /><text>{{ poemState === 'empty' ? '读取维护列表' : '重新读取' }}</text>
</view>
</view>
</view>
<AppDialog
:visible="discardVisible"
title="放弃字辈修改?"
message="当前字辈草稿尚未保存,确认后将恢复进入编辑器前的内容。"
message="当前草稿尚未保存,确认后不会提交服务端。"
confirm-text="放弃修改"
cancel-text="继续编辑"
show-cancel
@@ -179,9 +79,7 @@
@confirm="confirmDiscard"
@cancel="cancelDiscard"
/>
<view v-if="feedbackVisible" class="poem-feedback">
<text>本地字辈预览已更新</text>
</view>
<view v-if="feedbackMessage" class="poem-feedback"><text>{{ feedbackMessage }}</text></view>
</view>
</template>
@@ -192,85 +90,61 @@ import AppDialog from "@/components/AppDialog.vue";
import AppLoading from "@/components/AppLoading.vue";
import GenealogyPageBackground from "@/components/GenealogyPageBackground.vue";
import PageHeader from "@/components/PageHeader.vue";
import {
findGenealogyFixture,
getGenealogyFixtureAccess,
} from "@/data/mock.js";
import { createDiscardConfirmation } from "@/utils/discard-confirmation.js";
import { appApi, createRequestController, isRequestCancelled } from "@/utils/api.js";
import {
MAX_GENERATION_COUNT,
MAX_GENERATION_POEM_INPUT_LENGTH,
MAX_GENERATION_TEXT_LENGTH,
GENERATION_POEM_STATUS,
findFirstGenerationGap,
mergeGenerationPoemRows,
validateGenerationPoemText,
} from "@/utils/generation-poem.js";
import { handleBackPress, runBackGuard } from "@/utils/navigation.js";
const INITIAL_POEM_TEXT = "启宗敦本";
const POEM_RENDER_BATCH_SIZE = 50;
const PREVIEW_GENERATION_LIMIT = 12;
const LOCAL_PREVIEW_START_GENERATION = 1;
const LOCAL_CURRENT_GENERATION = 3;
const genealogyId = ref("");
const genealogyName = ref("家谱");
const genealogyName = ref("当前家谱");
const poemState = ref("loading");
const poemError = ref("");
const feedbackVisible = ref(false);
const feedbackMessage = ref("");
const discardVisible = ref(false);
const canManage = ref(false);
let feedbackTimer = null;
const poemDraft = ref(INITIAL_POEM_TEXT);
// OpenAPI 的 disableMissing 示例为 false;本地同样采用安全默认 false。停用后续世代是高影响动作,
// 必须由谱主主动选择,不能把缩短一次本地草稿解释为默认停用历史记录。
const previewing = ref(false);
const saving = ref(false);
const poemDraft = ref("");
const disableMissing = ref(false);
const editorOrigin = ref("list");
const editorSnapshot = ref(null);
const poemRows = ref([]);
const visiblePoemCount = ref(POEM_RENDER_BATCH_SIZE);
const visiblePoemRows = computed(() =>
poemRows.value.slice(0, visiblePoemCount.value),
);
const remainingPoemCount = computed(() =>
poemRows.value.length > visiblePoemCount.value
? poemRows.value.length - visiblePoemCount.value
: 0,
);
const pageTitle = computed(() =>
poemState.value === "edit" ? "维护字辈诗" : "字辈诗",
);
const preview = ref(null);
const previewSignature = ref("");
const requestController = createRequestController();
let requestSequence = 0;
const visiblePoemRows = computed(() => poemRows.value.slice(0, visiblePoemCount.value));
const remainingPoemCount = computed(() => Math.max(0, poemRows.value.length - visiblePoemCount.value));
const pageTitle = computed(() => poemState.value === "edit" ? "维护字辈诗" : "字辈诗");
const generationRangeTitle = computed(() => {
if (!poemRows.value.length) return "尚未建立字辈";
const first = poemRows.value[0].generationNo;
const last = poemRows.value[poemRows.value.length - 1].generationNo;
return first === last ? `${first} 世字辈` : `${first}${last} 世字辈`;
});
const currentGenerationCopy = computed(() => {
const current = poemRows.value.find((item) => item.current);
return current
? `当前为第 ${current.generationNo} 世“${current.generationText}”字辈。`
: `当前第 ${LOCAL_CURRENT_GENERATION} 世尚未被有效字辈覆盖。`;
});
const draftSignature = computed(() => `${poemDraft.value}\u0000${disableMissing.value}`);
const canSave = computed(() => Boolean(preview.value) && previewSignature.value === draftSignature.value);
const previewSummary = computed(() => {
const validation = validateGenerationPoemText(poemDraft.value);
if (!validation.valid) {
return poemDraft.value.trim() ? validation.message : "尚未录入字辈";
}
const visible = validation.generations
.slice(0, PREVIEW_GENERATION_LIMIT)
.join(" · ");
const remaining = validation.generations.length - PREVIEW_GENERATION_LIMIT;
return remaining > 0 ? `${visible} · …另 ${remaining}` : visible;
if (previewing.value) return "正在请求服务端预览。";
if (!preview.value) return "尚未请求服务端预览;预览成功后才可保存。";
return `服务端预览:新增 ${preview.value.createCount} 条,更新 ${preview.value.updateCount} 条,保留 ${preview.value.keepCount} 条,停用 ${preview.value.disableCount} 条。`;
});
const isDirty = computed(() =>
Boolean(
poemState.value === "edit" &&
editorSnapshot.value &&
(poemDraft.value !== editorSnapshot.value.poemDraft ||
disableMissing.value !== editorSnapshot.value.disableMissing),
),
const stateCopy = computed(() => poemState.value === "empty"
? "正常字辈读取未返回条目。维护列表由后端单独鉴权,点击后才会判断是否可维护。"
: "未取得可消费的服务端响应,页面不会用本地字辈或本地权限替代。",
);
const isDirty = computed(() => Boolean(
poemState.value === "edit" && editorSnapshot.value && (
poemDraft.value !== editorSnapshot.value.poemDraft ||
disableMissing.value !== editorSnapshot.value.disableMissing
),
));
const discardConfirmation = createDiscardConfirmation((visible) => {
discardVisible.value = visible;
});
@@ -278,439 +152,186 @@ const requestDiscardConfirmation = discardConfirmation.request;
const confirmDiscard = discardConfirmation.confirm;
const cancelDiscard = discardConfirmation.cancel;
const resetPoemRows = () => {
const seed = validateGenerationPoemText(INITIAL_POEM_TEXT).generations;
poemDraft.value = INITIAL_POEM_TEXT;
poemRows.value = mergeGenerationPoemRows({
existingRows: [],
generationTexts: seed,
startGeneration: LOCAL_PREVIEW_START_GENERATION,
currentGeneration: LOCAL_CURRENT_GENERATION,
disableMissing: false,
});
const applyRows = (rows) => {
poemRows.value = rows;
visiblePoemCount.value = POEM_RENDER_BATCH_SIZE;
const named = rows.find((item) => item.genealogyName);
genealogyName.value = named?.genealogyName || "当前家谱";
};
const loadMorePoems = () => {
visiblePoemCount.value = Math.min(
poemRows.value.length,
visiblePoemCount.value + POEM_RENDER_BATCH_SIZE,
);
};
const loadPoems = (query = {}) => {
genealogyId.value = String(query.genealogyId || "");
const fixture = findGenealogyFixture(genealogyId.value);
if (!fixture) {
canManage.value = false;
const loadPoems = async ({ management = false } = {}) => {
if (!genealogyId.value) {
poemState.value = "error";
return;
return false;
}
genealogyName.value = fixture.name || "家谱";
const accessRole = getGenealogyFixtureAccess(genealogyId.value).accessRole;
canManage.value = accessRole === "owner";
if (accessRole === "guest") {
poemState.value = "no-permission";
return;
const sequence = ++requestSequence;
poemState.value = "loading";
poemError.value = "";
try {
const rows = management
? await appApi.getGenerationPoemManagement(genealogyId.value, { requestController })
: await appApi.getGenerationPoems(genealogyId.value, { requestController });
if (sequence !== requestSequence) return false;
applyRows(rows);
canManage.value = management;
poemState.value = rows.length ? "list" : "empty";
return true;
} catch (error) {
if (isRequestCancelled(error) || sequence !== requestSequence) return false;
poemState.value = "error";
return false;
}
resetPoemRows();
const requestedState =
query.state === "loading"
? "loading"
: query.state === "empty"
? "empty"
: query.state === "edit"
? "edit"
: query.state === "no-permission"
? "no-permission"
: query.state === "error"
? "error"
: "list";
if (requestedState === "empty") {
poemDraft.value = "";
poemRows.value = [];
}
if (requestedState === "edit") {
if (!canManage.value) {
poemState.value = "no-permission";
return;
}
poemState.value = "list";
openEditor("list");
return;
}
poemState.value = requestedState;
};
onLoad(loadPoems);
onUnload(() => {
const timer = feedbackTimer;
feedbackTimer = null;
if (timer) clearTimeout(timer);
discardConfirmation.dispose();
});
const openEditor = (origin = poemState.value) => {
const loadMorePoems = () => {
visiblePoemCount.value = Math.min(poemRows.value.length, visiblePoemCount.value + POEM_RENDER_BATCH_SIZE);
};
const invalidatePreview = () => {
poemError.value = "";
preview.value = null;
previewSignature.value = "";
};
const toggleDisableMissing = () => {
disableMissing.value = !disableMissing.value;
invalidatePreview();
};
const openEditor = () => {
if (!canManage.value) return false;
editorOrigin.value = origin === "empty" ? "empty" : "list";
editorSnapshot.value = Object.freeze({
poemDraft: poemDraft.value,
disableMissing: disableMissing.value,
});
poemDraft.value = poemRows.value
.filter((item) => item.status === GENERATION_POEM_STATUS.ACTIVE)
.map((item) => item.generationText)
.join(" ");
disableMissing.value = false;
preview.value = null;
previewSignature.value = "";
editorSnapshot.value = Object.freeze({ poemDraft: poemDraft.value, disableMissing: disableMissing.value });
poemState.value = "edit";
return true;
};
const restoreEditorSnapshot = () => {
if (!editorSnapshot.value) return;
poemDraft.value = editorSnapshot.value.poemDraft;
disableMissing.value = editorSnapshot.value.disableMissing;
const enterManagement = async () => {
if (previewing.value || saving.value) return;
const loaded = await loadPoems({ management: true });
if (loaded) openEditor();
};
const requestLeaveEditor = async () => {
if (isDirty.value) {
const confirmed = await requestDiscardConfirmation();
if (!confirmed) return false;
}
restoreEditorSnapshot();
poemState.value = editorOrigin.value;
poemState.value = poemRows.value.length ? "list" : "empty";
editorSnapshot.value = null;
poemError.value = "";
return true;
};
const requestBack = () =>
runBackGuard({
transientOpen: discardVisible.value,
internalTrail: poemState.value === "edit",
"close-transient": cancelDiscard,
"pop-internal-trail": requestLeaveEditor,
});
onBackPress((event) => handleBackPress(event, requestBack));
const handleStateAction = () => {
if (poemState.value === "empty") return openEditor("empty");
if (poemState.value === "error")
return loadPoems({ genealogyId: genealogyId.value });
return requestBack();
};
const savePoems = () => {
const validateDraft = () => {
const validation = validateGenerationPoemText(poemDraft.value);
if (!validation.valid) {
poemError.value = validation.message;
return;
return false;
}
const lastGeneration =
LOCAL_PREVIEW_START_GENERATION + validation.generations.length - 1;
if (LOCAL_CURRENT_GENERATION > lastGeneration) {
poemError.value = `请至少录入到当前第 ${LOCAL_CURRENT_GENERATION}`;
return;
}
const nextRows = mergeGenerationPoemRows({
existingRows: poemRows.value,
generationTexts: validation.generations,
startGeneration: LOCAL_PREVIEW_START_GENERATION,
currentGeneration: LOCAL_CURRENT_GENERATION,
disableMissing: disableMissing.value,
});
const activeRows = nextRows.filter(
(item) => item.status === GENERATION_POEM_STATUS.ACTIVE,
);
const lastActiveGeneration = activeRows.length
? activeRows[activeRows.length - 1].generationNo
: null;
const firstGap = lastActiveGeneration === null
? null
: findFirstGenerationGap(
nextRows,
lastActiveGeneration + 1,
LOCAL_PREVIEW_START_GENERATION,
);
if (firstGap !== null) {
poemError.value = `${firstGap} 世字辈缺失;请补齐完整序列,或选择停用未覆盖的后续记录`;
return;
}
poemRows.value = nextRows;
visiblePoemCount.value = POEM_RENDER_BATCH_SIZE;
editorSnapshot.value = null;
poemState.value = "list";
feedbackVisible.value = true;
if (feedbackTimer) clearTimeout(feedbackTimer);
const timer = setTimeout(() => {
if (feedbackTimer !== timer) return;
feedbackVisible.value = false;
feedbackTimer = null;
}, 1800);
feedbackTimer = timer;
return true;
};
const previewPoems = async () => {
if (previewing.value || saving.value || !validateDraft()) return;
previewing.value = true;
poemError.value = "";
try {
preview.value = await appApi.previewGenerationPoemBatch(genealogyId.value, {
poemText: poemDraft.value,
disableMissing: disableMissing.value,
}, { requestController });
previewSignature.value = draftSignature.value;
} catch (error) {
if (!isRequestCancelled(error)) poemError.value = error?.message || "服务端预览失败,请稍后重试。";
} finally {
previewing.value = false;
}
};
const savePoems = async () => {
if (!canSave.value || saving.value || !validateDraft()) return;
saving.value = true;
poemError.value = "";
try {
await appApi.saveGenerationPoemBatch(genealogyId.value, {
poemText: poemDraft.value,
disableMissing: disableMissing.value,
}, { requestController });
feedbackMessage.value = "服务端字辈已保存,正在刷新维护列表。";
const loaded = await loadPoems({ management: true });
if (!loaded) {
poemError.value = "字辈已提交,但维护列表刷新失败;请稍后重新查看。";
return;
}
editorSnapshot.value = null;
feedbackMessage.value = "服务端字辈已保存并已刷新。";
} catch (error) {
if (!isRequestCancelled(error)) poemError.value = error?.message || "字辈保存失败,请稍后重试。";
} finally {
saving.value = false;
}
};
const requestBack = () => runBackGuard({
transientOpen: discardVisible.value,
internalTrail: poemState.value === "edit",
submitting: previewing.value || saving.value,
"close-transient": cancelDiscard,
"pop-internal-trail": requestLeaveEditor,
"block-submitting": () => true,
});
const handleStateAction = () => poemState.value === "empty" ? enterManagement() : loadPoems();
onLoad((query) => {
genealogyId.value = String(query?.genealogyId || "");
loadPoems();
});
onBackPress((event) => handleBackPress(event, requestBack));
onUnload(() => {
++requestSequence;
requestController.abort();
discardConfirmation.dispose();
});
</script>
<style scoped lang="scss">
@use "../../styles/adaptive-frame-profiles.scss" as adaptive;
.poem-page {
display: flex;
min-height: 100vh;
flex-direction: column;
background: $paper;
}
.poem-page__header {
z-index: 3;
}
.poem-panel {
@include adaptive.adaptive-genealogy-state-panel;
z-index: 2;
width: calc(100% - 32rpx);
margin: 18rpx auto 0;
padding: 76rpx 8%;
box-sizing: border-box;
}
.poem-panel > .app-loading {
grid-area: 1 / 1 / -1 / -1;
}
.poem-list,
.poem-editor,
.poem-state-card {
min-width: 0;
}
.poem-list__eyebrow {
display: block;
color: $brand-red;
font-size: 23rpx;
letter-spacing: 3rpx;
}
.poem-list__title {
display: block;
margin-top: 11rpx;
color: $ink;
font-family: "STKaiti", "KaiTi", serif;
font-size: 35rpx;
font-weight: 700;
}
.poem-list__copy {
display: block;
margin-top: 10rpx;
color: $ink-muted;
font-size: 24rpx;
line-height: 1.55;
}
.poem-rows {
margin-top: 18rpx;
}
.poem-load-more {
display: flex;
min-height: 64rpx;
align-items: center;
justify-content: center;
margin-top: 14rpx;
background: url("/static/assets/foundation/transparent/a01-scroll-secondary-v3.png")
center / contain no-repeat;
}
.poem-load-more text {
color: $ink;
font-size: 23rpx;
font-weight: 700;
}
.poem-row {
@include adaptive.adaptive-genealogy-form-field;
display: grid;
min-height: 72rpx;
margin-top: 10rpx;
grid-template-columns: minmax(116rpx, 38%) minmax(0, 1fr) auto;
align-items: center;
}
.poem-row__number {
z-index: 1;
grid-column: 1;
margin-left: 24rpx;
color: $ink-muted;
font-size: 23rpx;
}
.poem-row__character {
z-index: 1;
grid-column: 2;
min-width: 0;
padding: 12rpx 16rpx 12rpx 0;
color: $ink;
font-family: "STKaiti", "KaiTi", serif;
font-size: 32rpx;
font-weight: 700;
line-height: 1.4;
overflow-wrap: anywhere;
word-break: break-word;
}
.poem-row__status {
z-index: 1;
grid-column: 3;
margin-right: 22rpx;
color: $ink-muted;
font-size: 22rpx;
}
.poem-row--current .poem-row__character,
.poem-row--current .poem-row__status {
color: $brand-red;
}
.poem-row--disabled .poem-row__character,
.poem-row--disabled .poem-row__status {
color: $ink-muted;
opacity: 0.62;
}
.poem-action {
display: grid;
width: 100%;
min-height: 76rpx;
margin-top: 20rpx;
}
.poem-action image {
grid-area: 1 / 1;
width: 100%;
height: 100%;
}
.poem-action text {
z-index: 1;
display: flex;
grid-area: 1 / 1;
align-items: center;
justify-content: center;
height: 100%;
color: #fff9ed;
font-size: 25rpx;
font-weight: 700;
letter-spacing: 2rpx;
}
.poem-field {
@include adaptive.adaptive-genealogy-form-field;
display: grid;
min-height: 118rpx;
margin-top: 22rpx;
}
.poem-field > text {
z-index: 1;
grid-area: 1 / 1;
align-self: center;
margin-left: 24rpx;
color: $ink;
font-size: 22rpx;
font-weight: 700;
}
.poem-field textarea {
z-index: 1;
grid-area: 1 / 1;
box-sizing: border-box;
width: auto;
min-width: 0;
min-height: 118rpx;
margin-right: 20rpx;
margin-left: 164rpx;
padding-top: 34rpx;
color: $ink;
font-size: 22rpx;
line-height: 1.5;
}
.poem-placeholder {
color: #a79884;
}
.poem-field-error {
display: block;
margin-top: 4rpx;
color: $brand-red;
font-size: 24rpx;
line-height: 34rpx;
text-align: right;
}
.poem-policy {
display: flex;
align-items: center;
justify-content: space-between;
margin-top: 22rpx;
}
.poem-policy > text {
color: $ink;
font-size: 24rpx;
font-weight: 700;
}
.poem-policy__option {
display: grid;
width: 248rpx;
min-height: 62rpx;
}
.poem-policy__option image {
grid-area: 1 / 1;
width: 100%;
height: 100%;
}
.poem-policy__option text {
z-index: 1;
display: flex;
grid-area: 1 / 1;
align-items: center;
justify-content: center;
height: 100%;
color: $ink;
font-size: 23rpx;
font-weight: 700;
}
.poem-policy__option .poem-policy__option-text--active {
color: #fff9ed;
}
.poem-preview {
display: block;
margin-top: 22rpx;
color: $ink-muted;
font-size: 23rpx;
line-height: 1.6;
}
.poem-editor__actions {
display: flex;
gap: 14rpx;
margin-top: 18rpx;
}
.poem-editor__actions .poem-action {
width: calc(50% - 7rpx);
margin-top: 0;
}
.poem-action .poem-action__secondary {
color: $ink;
}
.poem-state-card {
text-align: center;
}
.poem-state--empty,
.poem-state--error,
.poem-state--no-permission {
padding: 180rpx 4% 70rpx;
}
.poem-state-card .poem-list__eyebrow {
text-align: center;
}
.poem-state-card .poem-list__copy {
margin-top: 22rpx;
}
.poem-state-card .poem-action {
width: 420rpx;
max-width: 100%;
margin: 34rpx auto 0;
}
.poem-feedback {
position: fixed;
z-index: 30;
top: 138rpx;
left: 50%;
display: flex;
min-width: 260rpx;
min-height: 74rpx;
align-items: center;
justify-content: center;
padding: 0 34rpx;
transform: translateX(-50%);
background: url("/static/assets/foundation/transparent/a01-scroll-secondary-v3.png")
center / contain no-repeat;
}
.poem-feedback text {
z-index: 1;
color: $ink;
font-size: 22rpx;
}
@media (min-width: 400px) {
.poem-panel {
width: calc(100% - 48rpx);
}
}
.poem-page { display: flex; min-height: 100vh; flex-direction: column; background: $paper; }
.poem-page__header { z-index: 3; }
.poem-panel { @include adaptive.adaptive-genealogy-state-panel; z-index: 2; width: calc(100% - 32rpx); margin: 18rpx auto 0; padding: 76rpx 8%; box-sizing: border-box; }
.poem-panel > .app-loading { grid-area: 1 / 1 / -1 / -1; }
.poem-list, .poem-editor, .poem-state-card { min-width: 0; }
.poem-list__eyebrow { display: block; color: $brand-red; font-size: 23rpx; letter-spacing: 3rpx; }
.poem-list__title { display: block; margin-top: 11rpx; color: $ink; font-family: "STKaiti", "KaiTi", serif; font-size: 35rpx; font-weight: 700; }
.poem-list__copy { display: block; margin-top: 10rpx; color: $ink-muted; font-size: 24rpx; line-height: 1.55; }
.poem-rows { margin-top: 18rpx; }
.poem-load-more { display: flex; min-height: 64rpx; align-items: center; justify-content: center; margin-top: 14rpx; background: url("/static/assets/foundation/transparent/a01-scroll-secondary-v3.png") center / contain no-repeat; }
.poem-load-more text { color: $ink; font-size: 23rpx; font-weight: 700; }
.poem-row { @include adaptive.adaptive-genealogy-form-field; display: grid; min-height: 72rpx; margin-top: 10rpx; grid-template-columns: minmax(116rpx, 38%) minmax(0, 1fr) auto; align-items: center; }
.poem-row__number { z-index: 1; grid-column: 1; margin-left: 24rpx; color: $ink-muted; font-size: 23rpx; }
.poem-row__character { z-index: 1; grid-column: 2; min-width: 0; padding: 12rpx 16rpx 12rpx 0; color: $ink; font-family: "STKaiti", "KaiTi", serif; font-size: 32rpx; font-weight: 700; line-height: 1.4; overflow-wrap: anywhere; word-break: break-word; }
.poem-row__status { z-index: 1; grid-column: 3; margin-right: 22rpx; color: $ink-muted; font-size: 22rpx; }
.poem-row--disabled .poem-row__character, .poem-row--disabled .poem-row__status { color: $ink-muted; opacity: .62; }
.poem-action { display: grid; width: 100%; min-height: 76rpx; margin-top: 20rpx; }
.poem-action--disabled { opacity: .55; }
.poem-action image { grid-area: 1 / 1; width: 100%; height: 100%; }
.poem-action text { z-index: 1; display: flex; grid-area: 1 / 1; align-items: center; justify-content: center; height: 100%; color: #fff9ed; font-size: 25rpx; font-weight: 700; letter-spacing: 2rpx; }
.poem-field { @include adaptive.adaptive-genealogy-form-field; display: grid; min-height: 118rpx; margin-top: 22rpx; }
.poem-field > text { z-index: 1; grid-area: 1 / 1; align-self: center; margin-left: 24rpx; color: $ink; font-size: 22rpx; font-weight: 700; }
.poem-field textarea { z-index: 1; grid-area: 1 / 1; box-sizing: border-box; width: auto; min-width: 0; min-height: 118rpx; margin-right: 20rpx; margin-left: 164rpx; padding-top: 34rpx; color: $ink; font-size: 22rpx; line-height: 1.5; }
.poem-placeholder { color: #a79884; }
.poem-field-error { display: block; margin-top: 4rpx; color: $brand-red; font-size: 24rpx; line-height: 34rpx; text-align: right; }
.poem-policy { display: flex; align-items: center; justify-content: space-between; margin-top: 22rpx; }
.poem-policy > text { color: $ink; font-size: 24rpx; font-weight: 700; }
.poem-policy__option { display: grid; width: 248rpx; min-height: 62rpx; }
.poem-policy__option image { grid-area: 1 / 1; width: 100%; height: 100%; }
.poem-policy__option text { z-index: 1; display: flex; grid-area: 1 / 1; align-items: center; justify-content: center; height: 100%; color: $ink; font-size: 23rpx; font-weight: 700; }
.poem-policy__option .poem-policy__option-text--active { color: #fff9ed; }
.poem-preview { display: block; margin-top: 22rpx; color: $ink-muted; font-size: 23rpx; line-height: 1.6; }
.poem-editor__actions { display: flex; gap: 14rpx; margin-top: 18rpx; }
.poem-editor__actions .poem-action { width: calc((100% - 28rpx) / 3); margin-top: 0; }
.poem-action .poem-action__secondary { color: $ink; }
.poem-state-card { padding: 180rpx 4% 70rpx; text-align: center; }
.poem-state-card .poem-list__eyebrow { text-align: center; }
.poem-state-card .poem-list__copy { margin-top: 22rpx; }
.poem-state-card .poem-action { width: 420rpx; max-width: 100%; margin: 34rpx auto 0; }
.poem-feedback { position: fixed; z-index: 30; top: 138rpx; left: 50%; display: flex; min-width: 260rpx; min-height: 74rpx; align-items: center; justify-content: center; padding: 0 34rpx; transform: translateX(-50%); background: url("/static/assets/foundation/transparent/a01-scroll-secondary-v3.png") center / contain no-repeat; }
.poem-feedback text { z-index: 1; color: $ink; font-size: 22rpx; }
@media (min-width: 400px) { .poem-panel { width: calc(100% - 48rpx); } }
</style>
+5 -253
View File
@@ -1,260 +1,12 @@
<!-- 页面编号N-01用途消息中心已读操作空态与失败状态 -->
<!-- 页面编号N-01用途通知入口列表 item DTO 缺失时不展示 fixture 通知 -->
<template>
<view
class="notice-page"
:class="{
'notice-state--loading': noticeState === 'loading',
'notice-state--list': noticeState === 'list',
'notice-state--empty': noticeState === 'empty',
'notice-state--error': noticeState === 'error',
}"
>
<ModulePageBackground module="notification" />
<view class="notice-page__header">
<PageHeader
title="消息中心"
:action="unreadCount > 0 && noticeState === 'list' ? '全部已读' : ''"
@action="markAllRead"
/>
</view>
<view class="notice-content">
<AppLoading
v-if="noticeState === 'loading'"
text="正在整理消息"
description="请稍候,正在同步家谱申请与家族提醒。"
/>
<template v-else-if="noticeState === 'list'">
<view class="notice-list">
<view
v-for="item in notices"
:key="item.id"
class="notice-card"
role="button"
:aria-label="`${item.unread ? '未读' : '已读'}消息${item.title}`"
@click="openNotice(item)"
>
<view class="notice-card__copy">
<text
class="notice-card__status"
:class="{ 'is-unread': item.unread }"
>{{ item.unread ? "未读提醒" : "已读" }} · {{ item.time }}</text
>
<text class="notice-card__title">{{ item.title }}</text>
<text class="notice-card__summary">{{ item.content }}</text>
</view>
</view>
</view>
<AppButton
class="notice-review-action"
block
type="secondary"
label="前往入谱审核"
@click="toReview"
/>
</template>
<view v-else class="notice-state-card">
<view class="notice-state-card__copy">
<text class="notice-state-card__title">{{
noticeState === "empty" ? "暂时没有新消息" : "消息中心暂不可用"
}}</text>
<text class="notice-state-card__description">{{
noticeState === "empty"
? "家谱申请、审核结果和家族提醒会留在这里。"
: "请稍后重新进入,已读状态不会受到影响。"
}}</text>
</view>
<AppButton
block
:type="noticeState === 'error' ? 'secondary' : 'primary'"
:label="noticeState === 'error' ? '重新查看' : '前往入谱审核'"
@click="noticeState === 'error' ? restoreList() : toReview()"
/>
</view>
</view>
<AppToast :visible="toastVisible" :message="toastMessage" />
</view>
<view class="message-page"><ModulePageBackground module="notification" /><view class="page-header"><PageHeader root title="消息中心" /></view><view class="page-content"><view class="state-card"><text>消息列表待后端字段合同</text><text>通知列表没有声明通知 ID已读状态标题时间正文类型或跳转参数页面已停止展示本地消息也不会以 fixture 修改已读状态</text><AppButton block label="返回我的" @click="returnToProfile" /></view></view><AppTabbar active="profile" /></view>
</template>
<script setup>
import { computed, onUnmounted, ref } from "vue";
import { onLoad } from "@dcloudio/uni-app";
import AppButton from "@/components/AppButton.vue";
import AppLoading from "@/components/AppLoading.vue";
import AppToast from "@/components/AppToast.vue";
import ModulePageBackground from "@/components/ModulePageBackground.vue";
import PageHeader from "@/components/PageHeader.vue";
import { listNotificationFixtures } from "@/data/mock.js";
import { genealogyContext } from "@/utils/genealogy-context.js";
import { openPage } from "@/utils/navigation.js";
const genealogyId = ref("");
const noticeState = ref("loading");
const toastVisible = ref(false);
const toastMessage = ref("");
let toastTimer = null;
const notices = ref(listNotificationFixtures());
onLoad((query) => {
genealogyId.value =
query.genealogyId || genealogyContext.getCurrentGenealogyId() || "";
noticeState.value =
query.state === "loading"
? "loading"
: query.state === "empty"
? "empty"
: query.state === "error"
? "error"
: "list";
});
const unreadCount = computed(
() => notices.value.filter((item) => item.unread).length,
);
const openNotice = async (item) => {
const opened = await openPage(
"N02",
{ id: String(item.id) },
"N01",
);
if (opened) item.unread = false;
};
const restoreList = () => {
noticeState.value = "list";
};
const showToast = (message) => {
toastMessage.value = message;
toastVisible.value = true;
if (toastTimer) clearTimeout(toastTimer);
toastTimer = setTimeout(() => {
toastVisible.value = false;
toastTimer = null;
}, 1800);
};
const markAllRead = () => {
notices.value.forEach((item) => {
item.unread = false;
});
showToast("已全部标记为已读");
};
const toReview = () =>
genealogyId.value
? openPage(
"G10",
{ genealogyId: genealogyId.value },
"N01",
)
: showToast("请先选择可管理的家谱");
onUnmounted(() => {
if (toastTimer) clearTimeout(toastTimer);
});
import AppButton from "@/components/AppButton.vue"; import AppTabbar from "@/components/AppTabbar.vue"; import ModulePageBackground from "@/components/ModulePageBackground.vue"; import PageHeader from "@/components/PageHeader.vue"; import { goRoot } from "@/utils/navigation.js";
const returnToProfile=()=>goRoot("M01");
</script>
<style scoped lang="scss">
@use "../../styles/adaptive-frame-profiles.scss" as adaptive;
.notice-page {
display: flex;
min-height: 100vh;
flex-direction: column;
background: $paper;
}
.notice-page__header,
.notice-content {
z-index: 1;
}
.notice-content {
flex: 1;
padding: 24rpx 28rpx 100rpx;
}
.notice-list {
display: flex;
flex-direction: column;
gap: 18rpx;
}
.notice-card {
@include adaptive.adaptive-notification-content;
width: 100%;
min-height: 220rpx;
}
.notice-card__copy {
display: flex;
min-height: 220rpx;
box-sizing: border-box;
flex-direction: column;
justify-content: center;
padding: 30rpx 44rpx;
}
.notice-card__status {
display: block;
color: $ink-muted;
font-size: 21rpx;
font-weight: 600;
letter-spacing: 2rpx;
}
.notice-card__status.is-unread {
color: $brand-red;
font-weight: 700;
}
.notice-card__title {
display: block;
margin-top: 7rpx;
color: $ink;
font-family: STKaiti, KaiTi, serif;
font-size: 30rpx;
font-weight: 700;
}
.notice-card__summary {
display: block;
margin-top: 7rpx;
color: #62584c;
font-size: 23rpx;
font-weight: 500;
line-height: 1.45;
}
.notice-review-action {
margin: 30rpx auto 0;
}
.notice-state-card {
margin-top: 38rpx;
background: url("/static/assets/modules/notification/transparent/n01-notice-card.png") top center / 100% 220rpx no-repeat;
text-align: center;
}
.notice-state-card__copy {
display: flex;
min-height: 118px;
box-sizing: border-box;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 0 54rpx;
}
.notice-state-card__title {
display: block;
color: $ink;
font-family: STKaiti, KaiTi, serif;
font-size: 31rpx;
font-weight: 700;
}
.notice-state-card__description {
display: block;
margin-top: 13rpx;
color: $ink-muted;
font-size: 23rpx;
line-height: 1.5;
}
.notice-state-card .app-button {
margin: 20rpx auto 0;
}
@media (min-width: 400px) {
.notice-content {
padding-right: 32rpx;
padding-left: 32rpx;
}
}
.message-page{display:flex;min-height:100vh;flex-direction:column;background:$paper}.page-header,.page-content{z-index:1}.page-content{flex:1;padding:18rpx 24rpx 190rpx}.state-card{box-sizing:border-box;min-height:340rpx;padding:78rpx 52rpx 50rpx;text-align:center;@include adaptive.adaptive-family-content}.state-card text{display:block}.state-card text:first-child{color:$ink;font-family:STKaiti,KaiTi,serif;font-size:35rpx;font-weight:700}.state-card text:nth-child(2){margin-top:18rpx;color:$ink-muted;font-size:24rpx;line-height:1.65}.state-card .app-button{margin-top:28rpx}
</style>
+4 -134
View File
@@ -1,141 +1,11 @@
<!-- 页面编号N-02用途消息详情已读状态与上下文操作 -->
<!-- 页面编号N-02用途通知详情没有详情读取 operation 时保持关闭 -->
<template>
<view class="notice-detail-page" :class="{ 'notice-state--ready': noticeState === 'ready', 'notice-state--loading': noticeState === 'loading', 'notice-state--expired': noticeState === 'expired' }">
<ModulePageBackground module="notification" />
<view class="page-layer"><PageHeader title="消息详情" custom-back @back="backToMessages" /></view>
<view class="notice-content page-layer">
<AppLoading v-if="noticeState === 'loading'" text="正在读取消息" description="请稍候,正在整理消息详情。" />
<view v-else-if="noticeState === 'expired'" class="paper-panel state-card">
<text class="state-title">这条消息已失效</text>
<text class="state-copy">消息可能已撤回或超过保留期限请返回消息中心查看其他内容</text>
<AppButton block label="返回消息中心" @click="backToMessages" />
</view>
<template v-else>
<view class="paper-panel notice-card">
<view class="notice-meta">
<text :class="{ 'is-unread': noticeDetail.unread }">{{ noticeDetail.unread ? "未读提醒" : "已读" }}</text>
<text>{{ noticeDetail.time }}</text>
</view>
<text class="notice-title">{{ noticeDetail.title }}</text>
<text class="notice-body">{{ noticeDetail.body }}</text>
<text class="notice-source">来自{{ noticeDetail.source }}</text>
</view>
<view class="action-stack">
<AppButton v-if="noticeDetail.unread" block label="标记已读" @click="markAsRead" />
<AppButton v-if="noticeDetail.targetType" block type="secondary" :label="noticeDetail.targetLabel" @click="openNoticeTarget" />
<text v-if="targetError" class="target-error" role="alert">{{ targetError }}</text>
</view>
</template>
</view>
<AppToast :visible="toastVisible" :message="toastMessage" />
</view>
<view class="message-detail-page"><ModulePageBackground module="notification" /><view class="page-header"><PageHeader title="消息详情" custom-back @back="backToMessages" /></view><view class="page-content"><view class="state-card"><text>消息详情暂未开放</text><text>Apifox 当前只有通知列表单条标已读和全部标已读 operation没有消息详情读取 owner页面不再从 fixture 展示正文来源或业务跳转</text><AppButton block label="返回消息中心" @click="backToMessages" /></view></view></view>
</template>
<script setup>
import { onUnmounted, ref } from "vue";
import { onBackPress, onLoad } from "@dcloudio/uni-app";
import AppButton from "@/components/AppButton.vue";
import AppLoading from "@/components/AppLoading.vue";
import AppToast from "@/components/AppToast.vue";
import ModulePageBackground from "@/components/ModulePageBackground.vue";
import PageHeader from "@/components/PageHeader.vue";
import {
findNotificationFixture,
getGenealogyFixtureAccess,
} from "@/data/mock.js";
import {
handleBackPress,
openNoticeTarget as navigateNoticeTarget,
returnTo,
} from "@/utils/navigation.js";
const noticeState = ref("loading");
const toastVisible = ref(false);
const toastMessage = ref("");
const targetError = ref("");
let toastTimer = null;
const noticeId = ref("");
const noticeDetail = ref(null);
onLoad((query) => {
noticeId.value = String(query.id || "");
if (query.state === "loading") return;
const selectedNotice = findNotificationFixture(noticeId.value);
if (
query.state === "expired" ||
!noticeId.value ||
!selectedNotice
) {
noticeState.value = "expired";
return;
}
noticeDetail.value = selectedNotice;
noticeState.value = "ready";
});
const showToast = (message) => {
toastMessage.value = message;
toastVisible.value = true;
if (toastTimer) clearTimeout(toastTimer);
toastTimer = setTimeout(() => (toastVisible.value = false), 1800);
};
const markAsRead = () => {
if (!noticeDetail.value) return;
noticeDetail.value.unread = false;
showToast("已标记为已读");
};
const getTargetAccessError = (detail) => {
const genealogyId = detail?.targetParams?.genealogyId;
const accessRole = getGenealogyFixtureAccess(genealogyId).accessRole;
if (detail?.targetType === "GENEALOGY_REVIEW" && accessRole !== "owner") {
return "当前账号没有处理这条审核消息的权限。";
}
if (
detail?.targetType === "GENEALOGY_HOME" &&
!["owner", "member"].includes(accessRole)
) {
return "这条消息关联的家谱已不可访问。";
}
return "";
};
const openNoticeTarget = async () => {
targetError.value = getTargetAccessError(noticeDetail.value);
if (targetError.value) return false;
try {
return await navigateNoticeTarget(
noticeDetail.value.targetType,
noticeDetail.value.targetParams,
);
} catch (_error) {
targetError.value = "这条消息的业务入口已失效,请返回消息中心。";
return false;
}
};
const backToMessages = () => returnTo("N01", {});
onBackPress((event) => handleBackPress(event, backToMessages));
onUnmounted(() => toastTimer && clearTimeout(toastTimer));
import AppButton from "@/components/AppButton.vue"; import ModulePageBackground from "@/components/ModulePageBackground.vue"; import PageHeader from "@/components/PageHeader.vue"; import { returnTo } from "@/utils/navigation.js"; const backToMessages=()=>returnTo("N01");
</script>
<style scoped lang="scss">
@use "../../styles/adaptive-frame-profiles.scss" as adaptive;
.notice-detail-page { display: flex; min-height: 100vh; flex-direction: column; background: $paper; }
.page-layer { z-index: 1; }
.notice-content { flex: 1; padding: 28rpx 30rpx 72rpx; }
.paper-panel { @include adaptive.adaptive-notification-content; }
.notice-card { min-height: 430rpx; padding: 54rpx 52rpx; box-sizing: border-box; }
.notice-meta { display: flex; flex-wrap: wrap; justify-content: space-between; gap: 8rpx 20rpx; color: $ink-muted; font-size: 22rpx; }
.notice-meta .is-unread { color: $brand-red; font-weight: 700; }
.notice-title { display: block; margin-top: 24rpx; color: $ink; font-family: STKaiti, KaiTi, serif; font-size: 38rpx; font-weight: 700; overflow-wrap: anywhere; }
.notice-body { display: block; margin-top: 22rpx; color: #5f4a38; font-size: 25rpx; line-height: 1.75; overflow-wrap: anywhere; }
.notice-source { display: block; margin-top: 28rpx; color: $ink-muted; font-size: 22rpx; overflow-wrap: anywhere; }
.action-stack { display: flex; flex-direction: column; gap: 18rpx; margin-top: 28rpx; }
.target-error { display: block; color: #b42318; font-size: 22rpx; line-height: 1.5; text-align: center; }
.state-card { min-height: 340rpx; padding: 72rpx 50rpx 48rpx; box-sizing: border-box; text-align: center; }
.state-title { display: block; color: $ink; font-size: 34rpx; font-weight: 700; }
.state-copy { display: block; margin: 18rpx 0 28rpx; color: $ink-muted; font-size: 24rpx; line-height: 1.65; }
@media (max-width: 340px) { .notice-content { padding-right: 22rpx; padding-left: 22rpx; } .notice-card { padding-right: 40rpx; padding-left: 40rpx; } }
.message-detail-page{display:flex;min-height:100vh;flex-direction:column;background:$paper}.page-header,.page-content{z-index:1}.page-content{padding:18rpx 24rpx 72rpx}.state-card{box-sizing:border-box;min-height:340rpx;padding:78rpx 52rpx 50rpx;text-align:center;@include adaptive.adaptive-family-content}.state-card text{display:block}.state-card text:first-child{color:$ink;font-family:STKaiti,KaiTi,serif;font-size:35rpx;font-weight:700}.state-card text:nth-child(2){margin-top:18rpx;color:$ink-muted;font-size:24rpx;line-height:1.65}.state-card .app-button{margin-top:28rpx}
</style>
+4 -422
View File
@@ -1,429 +1,11 @@
<!-- 页面编号M-01用途个人中心首页提醒与服务导航 -->
<!-- 页面编号M-01用途个人中心资料 DTO 未声明时不展示 mock 用户 -->
<template>
<view
class="profile-page"
:class="{
'profile-state--ready': profileState === 'ready',
'profile-state--error': profileState === 'error',
}"
>
<ModulePageBackground module="profile" />
<view class="profile-page__header">
<PageHeader
root
title="我的"
:action="profileState === 'ready' ? '资料' : ''"
@action="toProfile"
/>
</view>
<view class="profile-content">
<template v-if="profileState === 'ready'">
<view class="profile-hero">
<image
class="profile-hero__hall"
src="/static/assets/foundation/transparent/root-header-hall.png"
mode="aspectFit"
/>
<image
class="profile-hero__cloud"
src="/static/assets/foundation/transparent/auth-title-cloud.png"
mode="aspectFit"
/>
<view class="profile-hero__content">
<view class="profile-hero__label"
><text></text><text></text><text></text><text></text></view
>
<view class="profile-identity">
<image
class="profile-identity__seal"
src="/static/assets/foundation/transparent/brand-seal.png"
mode="aspectFit"
/>
<view class="profile-identity__copy">
<text class="profile-identity__name">{{ currentUser.name }}</text>
<text class="profile-identity__role">{{ currentUser.role }}</text>
<text class="profile-identity__phone">{{ currentUser.phone }}</text>
</view>
</view>
</view>
</view>
<view
class="profile-scroll-notice"
role="button"
aria-label="查看待处理消息"
@click="toNotifications"
>
<view class="profile-scroll-notice__copy">
<text>待你处理</text>
<text>{{ unreadCount }} 条家谱提醒与审核通知</text>
</view>
</view>
<view class="profile-services">
<view class="profile-section-heading">
<image
src="/static/assets/foundation/transparent/auth-divider-knot.png"
mode="aspectFit"
/>
<text>服务与设置</text>
<image
src="/static/assets/foundation/transparent/auth-divider-knot.png"
mode="aspectFit"
/>
</view>
<view class="profile-menu-list">
<view
v-for="item in menuItems"
:key="item.label"
class="profile-menu"
role="button"
:aria-label="`查看${item.label}`"
@click="openItem(item)"
>
<image
class="profile-menu__icon"
:src="item.icon"
mode="aspectFit"
/>
<view class="profile-menu__copy">
<text class="profile-menu__label">{{ item.label }}</text>
<text class="profile-menu__note">{{ item.note }}</text>
</view>
<image
class="profile-menu__chevron"
src="/static/assets/foundation/transparent/chevron-right.png"
mode="aspectFit"
/>
</view>
</view>
</view>
</template>
<view v-else class="profile-error">
<view class="profile-error__copy">
<text class="profile-error__title">个人资料暂不可用</text>
<text class="profile-error__description"
>请稍后重新进入账号和家谱资料不会受到影响</text
>
</view>
<AppButton
block
type="secondary"
label="重新查看"
@click="restoreProfile"
/>
</view>
</view>
<AppTabbar active="profile" />
</view>
<view class="profile-page"><ModulePageBackground module="profile" /><view class="page-header"><PageHeader root title="我的" /></view><view class="page-content"><view class="state-card"><text>个人资料待后端字段合同</text><text>当前资料读取接口只返回通用对象未声明昵称角色手机号或脱敏规则页面已停止展示 mock 用户与 fixture 未读数</text><view class="profile-menu"><AppButton block label="编辑资料" @click="open('M02')" /><AppButton block label="账号与安全" @click="open('M03')" /><AppButton block label="消息中心" @click="open('N01')" /><AppButton block label="帮助中心" @click="open('M06')" /><AppButton block label="意见反馈" @click="open('M07')" /><AppButton block label="应用推广" @click="open('M08')" /><AppButton block label="VIP 服务" @click="open('M09')" /><AppButton block label="关于与设置" @click="open('M10')" /></view></view></view><AppTabbar active="profile" /></view>
</template>
<script setup>
import { computed, ref } from "vue";
import { onLoad } from "@dcloudio/uni-app";
import AppButton from "@/components/AppButton.vue";
import AppTabbar from "@/components/AppTabbar.vue";
import ModulePageBackground from "@/components/ModulePageBackground.vue";
import PageHeader from "@/components/PageHeader.vue";
import {
currentUser,
listNotificationFixtures,
} from "@/data/mock.js";
import { openPage } from "@/utils/navigation.js";
const profileState = ref("ready");
const unreadCount = computed(
() => listNotificationFixtures().filter((notice) => notice.unread).length,
);
const menuItems = [
{
label: "账号与安全",
note: "密码与手机号",
icon: "/static/assets/modules/auth/transparent/a01-icon-lock-v1.png",
routeKey: "M03",
},
{
label: "帮助与反馈",
note: "使用说明与问题反馈",
icon: "/static/assets/foundation/transparent/notice.png",
routeKey: "M06",
},
{
label: "关于家谱",
note: "协议、隐私与版本",
icon: "/static/assets/foundation/transparent/brand-seal.png",
routeKey: "M10",
},
{
label: "邀请家人",
note: "邀请规则与接入状态",
icon: "/static/assets/foundation/transparent/brand-seal.png",
routeKey: "M08",
},
{
label: "服务与订单",
note: "权益说明与订单记录",
icon: "/static/assets/foundation/transparent/notice.png",
routeKey: "M09",
},
];
onLoad((query) => {
profileState.value = query.state === "error" ? "error" : "ready";
});
const restoreProfile = () => {
profileState.value = "ready";
};
const toProfile = () => openPage("M02", {}, "M01");
const toNotifications = () => openPage("N01", {}, "M01");
const openItem = (item) =>
item.routeKey ? openPage(item.routeKey, {}, "M01") : Promise.resolve(false);
import AppButton from "@/components/AppButton.vue"; import AppTabbar from "@/components/AppTabbar.vue"; import ModulePageBackground from "@/components/ModulePageBackground.vue"; import PageHeader from "@/components/PageHeader.vue"; import { openPage } from "@/utils/navigation.js"; const open=(route)=>openPage(route,{},"M01");
</script>
<style scoped lang="scss">
@use "../../styles/adaptive-frame-profiles.scss" as adaptive;
.profile-page {
display: flex;
min-height: 100vh;
flex-direction: column;
background: $paper;
}
.profile-page__header,
.profile-content {
z-index: 1;
}
.profile-content {
flex: 1;
padding: 26rpx 30rpx 190rpx;
}
.profile-hero {
@include adaptive.adaptive-profile-summary;
display: grid;
width: 100%;
min-height: 252rpx;
}
.profile-hero__content {
z-index: 1;
display: grid;
grid-area: 1 / 1;
grid-template-columns: 42rpx minmax(0, 1fr);
align-items: center;
gap: 30rpx;
padding: 34rpx 52rpx 32rpx 40rpx;
}
.profile-hero__hall {
align-self: end;
justify-self: end;
grid-area: 1 / 1;
width: 330rpx;
height: 122rpx;
margin-right: -18rpx;
margin-bottom: -30rpx;
opacity: 0.12;
pointer-events: none;
}
.profile-hero__cloud {
align-self: start;
justify-self: end;
grid-area: 1 / 1;
width: 96rpx;
height: 52rpx;
margin-top: 22rpx;
margin-right: 30rpx;
opacity: 0.32;
pointer-events: none;
}
.profile-hero__label {
z-index: 2;
display: flex;
width: 42rpx;
min-height: 152rpx;
box-sizing: border-box;
flex-direction: column;
align-items: center;
justify-content: center;
border: 2rpx solid #c9964c;
border-radius: 4rpx;
background: linear-gradient(180deg, #9f241f, #bd3427);
box-shadow: inset 0 0 0 3rpx rgba(255, 222, 147, 0.24);
color: #ffe6a9;
font-family: STKaiti, KaiTi, serif;
font-size: 21rpx;
line-height: 1.12;
}
.profile-identity {
z-index: 1;
display: flex;
min-height: 252rpx;
box-sizing: border-box;
align-items: center;
gap: 24rpx;
}
.profile-identity__seal {
width: 92rpx;
height: 106rpx;
flex: 0 0 auto;
}
.profile-identity__name,
.profile-identity__role,
.profile-identity__phone {
display: block;
}
.profile-identity__name {
color: $ink;
font-family: STKaiti, KaiTi, serif;
font-size: 38rpx;
font-weight: 700;
letter-spacing: 3rpx;
}
.profile-identity__role {
margin-top: 10rpx;
color: #6f5942;
font-size: 23rpx;
}
.profile-identity__phone {
margin-top: 7rpx;
color: $ink-muted;
font-size: 21rpx;
letter-spacing: 2rpx;
}
.profile-scroll-notice {
@include adaptive.adaptive-scroll-button(primary);
width: 88%;
min-height: 112rpx;
margin: 22rpx auto 0;
}
.profile-scroll-notice__copy {
display: flex;
min-height: 112rpx;
box-sizing: border-box;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 13rpx 70rpx;
color: #fff4d3;
text-align: center;
}
.profile-scroll-notice__copy text:first-child {
font-family: STKaiti, KaiTi, serif;
font-size: 26rpx;
font-weight: 700;
letter-spacing: 3rpx;
}
.profile-scroll-notice__copy text:last-child {
margin-top: 4rpx;
color: #ffe4a8;
font-size: 18rpx;
}
.profile-services {
margin-top: 26rpx;
padding: 0 12rpx;
}
.profile-section-heading {
display: flex;
align-items: center;
justify-content: center;
gap: 20rpx;
padding-bottom: 12rpx;
}
.profile-section-heading image {
width: 88rpx;
height: 28rpx;
opacity: 0.78;
}
.profile-section-heading image:last-child {
transform: scaleX(-1);
}
.profile-section-heading text {
color: $ink;
font-family: STKaiti, KaiTi, serif;
font-size: 31rpx;
font-weight: 700;
letter-spacing: 4rpx;
}
.profile-menu-list {
border-top: 1px solid rgba(181, 137, 63, 0.55);
}
.profile-menu {
display: flex;
min-height: 116rpx;
align-items: center;
padding: 0 8rpx;
border-bottom: 1px solid rgba(181, 137, 63, 0.4);
}
.profile-menu__icon {
width: 52rpx;
height: 52rpx;
margin-right: 22rpx;
flex: 0 0 auto;
}
.profile-menu__copy {
min-width: 0;
flex: 1;
}
.profile-menu__label,
.profile-menu__note {
display: block;
}
.profile-menu__label {
color: $ink;
font-size: 25rpx;
font-weight: 700;
}
.profile-menu__note {
margin-top: 5rpx;
color: $ink-muted;
font-size: 21rpx;
}
.profile-menu__chevron {
width: 30rpx;
height: 30rpx;
margin-left: 18rpx;
opacity: 0.75;
}
.profile-error {
margin-top: 38rpx;
background: url("/static/assets/modules/profile/transparent/m01-profile-summary-card.png") top center / 100% 220rpx no-repeat;
text-align: center;
}
.profile-error__copy {
display: flex;
min-height: 118px;
box-sizing: border-box;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 0 54rpx;
}
.profile-error__title,
.profile-error__description {
display: block;
}
.profile-error__title {
color: $ink;
font-family: STKaiti, KaiTi, serif;
font-size: 31rpx;
font-weight: 700;
}
.profile-error__description {
margin-top: 13rpx;
color: $ink-muted;
font-size: 23rpx;
line-height: 1.5;
}
.profile-error .app-button {
margin: 20rpx auto 0;
}
@media (min-width: 400px) {
.profile-content {
padding-right: 38rpx;
padding-left: 38rpx;
}
}
/* M01 medium-ornate profile folio styles remain page-scoped and extensible. */
.profile-page{display:flex;min-height:100vh;flex-direction:column;background:$paper}.page-header,.page-content{z-index:1}.page-content{flex:1;padding:18rpx 24rpx 190rpx}.state-card{box-sizing:border-box;min-height:340rpx;padding:52rpx 40rpx 50rpx;text-align:center;@include adaptive.adaptive-family-content}.state-card text{display:block}.state-card text:first-child{color:$ink;font-family:STKaiti,KaiTi,serif;font-size:35rpx;font-weight:700}.state-card text:nth-child(2){margin-top:18rpx;color:$ink-muted;font-size:24rpx;line-height:1.65}.profile-menu{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:14rpx;margin-top:28rpx}.profile-menu .app-button{margin:0}
</style>
+4 -117
View File
@@ -1,124 +1,11 @@
<!-- 页面编号M-02用途编辑个人资料 -->
<!-- 页面编号M-02用途编辑资料读取 DTO 与现有字段不一致时保持关闭 -->
<template>
<view class="profile-edit-page" :class="{ 'profile-state--ready': profileState === 'ready', 'profile-state--saving': profileState === 'saving' }">
<ModulePageBackground module="profile" />
<view class="page-layer"><PageHeader title="个人资料" custom-back @back="requestBack" /></view>
<view class="page-content page-layer">
<view class="profile-avatar-card">
<image class="avatar-seal" src="/static/assets/foundation/transparent/brand-seal.png" mode="aspectFit" />
<view class="avatar-copy"><text>{{ profileForm.nickName || "未填写昵称" }}</text><text>头像将在上传接口批次接入</text></view>
<view class="text-action" role="button" aria-label="选择头像" @click="chooseAvatar">选择头像</view>
</view>
<view class="form-panel">
<view class="form-row"><text>昵称</text><input v-model.trim="profileForm.nickName" maxlength="30" aria-label="昵称" placeholder="请输入昵称" @input="errors.nickName = ''" /></view>
<text v-if="errors.nickName" class="field-error">{{ errors.nickName }}</text>
<view class="form-row"><text>真实姓名</text><input v-model.trim="profileForm.realName" maxlength="30" aria-label="真实姓名" placeholder="请输入真实姓名" /></view>
<view class="form-row"><text>邮箱</text><input v-model.trim="profileForm.email" maxlength="100" aria-label="邮箱" placeholder="选填,用于接收通知" @input="errors.email = ''" /></view>
<text v-if="errors.email" class="field-error">{{ errors.email }}</text>
</view>
<AppButton block :disabled="profileState === 'saving'" :label="profileState === 'saving' ? '正在校验' : '生成本地校验预览'" @click="saveProfile" />
</view>
<AppToast :visible="toastVisible" :message="toastMessage" />
<AppDialog
:visible="discardVisible"
:close-on-mask="false"
eyebrow="放弃确认"
title="放弃当前填写?"
message="尚未提交服务器的资料将从当前页面清除。"
confirm-text="确认放弃"
cancel-text="继续填写"
show-cancel
@confirm="confirmDiscard"
@cancel="cancelDiscard"
/>
</view>
<view class="profile-edit-page"><ModulePageBackground module="profile" /><view class="page-header"><PageHeader title="编辑资料" custom-back @back="backToProfile" /></view><view class="page-content"><view class="state-card"><text>个人资料编辑待字段合同</text><text>读取接口没有资料 DTO更新接口允许昵称头像性别生日地区和地址但当前页面原有真实姓名邮箱等字段不属于该合同页面不再用 mock 预填或提交未知字段</text><AppButton block label="返回个人中心" @click="backToProfile" /></view></view></view>
</template>
<script setup>
import { computed, reactive, ref } from "vue";
import { onBackPress, onUnload } from "@dcloudio/uni-app";
import AppButton from "@/components/AppButton.vue";
import AppDialog from "@/components/AppDialog.vue";
import AppToast from "@/components/AppToast.vue";
import ModulePageBackground from "@/components/ModulePageBackground.vue";
import PageHeader from "@/components/PageHeader.vue";
import { currentUser } from "@/data/mock.js";
import { createDiscardConfirmation } from "@/utils/discard-confirmation.js";
import {
handleBackPress,
runBackGuard,
} from "@/utils/navigation.js";
const profileForm = reactive({ nickName: currentUser.name, realName: currentUser.name, email: "" });
const errors = reactive({ nickName: "", email: "" });
const profileState = ref("ready");
const toastVisible = ref(false);
const toastMessage = ref("");
const discardVisible = ref(false);
let timer = null;
const formSnapshot = computed(() => JSON.stringify(profileForm));
const baseline = ref(formSnapshot.value);
const isDirty = computed(() => formSnapshot.value !== baseline.value);
const discardConfirmation = createDiscardConfirmation((visible) => {
discardVisible.value = visible;
});
const requestDiscardConfirmation = discardConfirmation.request;
const confirmDiscard = discardConfirmation.confirm;
const cancelDiscard = discardConfirmation.cancel;
const showToast = (message) => { toastMessage.value = message; toastVisible.value = true; clearTimeout(timer); timer = setTimeout(() => (toastVisible.value = false), 1800); };
const chooseAvatar = () => showToast("头像选择将在相册权限接入后开放");
const validateProfile = () => {
errors.nickName = profileForm.nickName ? "" : "请填写昵称";
errors.email = !profileForm.email || /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(profileForm.email)
? ""
: "请输入正确的邮箱地址";
return !errors.nickName && !errors.email;
};
const saveProfile = () => {
if (!validateProfile() || profileState.value === "saving") return;
profileState.value = "saving";
clearTimeout(timer);
timer = setTimeout(() => {
profileState.value = "ready";
showToast("本地校验通过,尚未提交服务器");
}, 500);
};
const requestBack = () =>
runBackGuard({
transientOpen: discardVisible.value,
dirty: isDirty.value,
submitting: profileState.value === "saving",
"close-transient": cancelDiscard,
"block-submitting": () => true,
"confirm-discard": requestDiscardConfirmation,
});
onBackPress((event) => handleBackPress(event, requestBack));
onUnload(() => {
clearTimeout(timer);
discardConfirmation.dispose();
});
import AppButton from "@/components/AppButton.vue"; import ModulePageBackground from "@/components/ModulePageBackground.vue"; import PageHeader from "@/components/PageHeader.vue"; import { returnTo } from "@/utils/navigation.js"; const backToProfile=()=>returnTo("M01");
</script>
<style scoped lang="scss">
@use "../../styles/adaptive-frame-profiles.scss" as adaptive;
.profile-edit-page { display: flex; min-height: 100vh; flex-direction: column; background: $paper; }
.page-layer { z-index: 1; }
.page-content { flex: 1; padding: 26rpx 30rpx 70rpx; }
.profile-avatar-card, .form-panel { @include adaptive.adaptive-profile-field; }
.profile-avatar-card { display: grid; grid-template-columns: 92rpx minmax(0,1fr) auto; align-items: center; gap: 20rpx; min-height: 150rpx; padding: 24rpx 34rpx; box-sizing: border-box; }
.avatar-seal { width: 82rpx; height: auto; max-height: 92rpx; aspect-ratio: 82 / 92; }
.avatar-copy { min-width: 0; }
.avatar-copy text { display: block; overflow-wrap: anywhere; }
.avatar-copy text:first-child { color: $ink; font-size: 29rpx; font-weight: 700; }
.avatar-copy text:last-child { margin-top: 6rpx; color: $ink-muted; font-size: 20rpx; line-height: 1.45; }
.text-action { min-height: 72rpx; display: flex; align-items: center; color: $brand-red; font-size: 22rpx; }
.form-panel { margin-top: 22rpx; padding: 20rpx 34rpx 30rpx; box-sizing: border-box; }
.form-row { display: grid; grid-template-columns: 150rpx minmax(0,1fr); min-height: 92rpx; align-items: center; border-bottom: 1px solid rgba(181,137,63,.42); gap: 18rpx; }
.form-row > text { color: $ink; font-size: 24rpx; font-weight: 700; }
.form-row input { width: auto; min-width: 0; min-height: 70rpx; color: $ink; font-size: 24rpx; text-align: right; }
.field-error { display: block; padding-top: 7rpx; color: #b42318; font-size: 21rpx; text-align: right; }
.page-content > .app-button { margin-top: 26rpx; }
@media(max-width:340px){.page-content{padding-right:22rpx;padding-left:22rpx}.profile-avatar-card{grid-template-columns:72rpx minmax(0,1fr);padding-right:26rpx;padding-left:26rpx}.text-action{grid-column:2}.avatar-seal{width:68rpx;max-height:78rpx}.form-row{grid-template-columns:126rpx minmax(0,1fr)}}
.profile-edit-page{display:flex;min-height:100vh;flex-direction:column;background:$paper}.page-header,.page-content{z-index:1}.page-content{padding:18rpx 24rpx 72rpx}.state-card{box-sizing:border-box;min-height:340rpx;padding:78rpx 52rpx 50rpx;text-align:center;@include adaptive.adaptive-family-content}.state-card text{display:block}.state-card text:first-child{color:$ink;font-family:STKaiti,KaiTi,serif;font-size:35rpx;font-weight:700}.state-card text:nth-child(2){margin-top:18rpx;color:$ink-muted;font-size:24rpx;line-height:1.65}.state-card .app-button{margin-top:28rpx}
</style>
+4 -43
View File
@@ -1,50 +1,11 @@
<!-- 页面编号M-03用途账号安全总览与安全功能入口 -->
<!-- 页面编号M-03用途账号与安全入口安全概览 DTO 未声明 -->
<template>
<view class="security-page device-state--limited">
<ModulePageBackground module="profile" />
<view class="page-layer"><PageHeader title="账号与安全" /></view>
<view class="page-content page-layer">
<view class="security-summary">
<text>账号安全操作</text>
<text>修改密码和换绑手机号目前只提供本地校验预览不会变更服务器账号信息</text>
</view>
<view class="security-list">
<view v-for="item in securityItems" :key="item.key" class="security-row" role="button" :aria-label="item.label" @click="openSecurityItem(item)">
<image :src="item.icon" mode="aspectFit" />
<view><text>{{ item.label }}</text><text>{{ item.note }}</text></view>
<image class="chevron" src="/static/assets/foundation/transparent/chevron-right.png" mode="aspectFit" />
</view>
</view>
<AppButton block type="secondary" label="查看接入状态" @click="checkSecurity" />
</view>
<AppToast :visible="toastVisible" :message="toastMessage" />
</view>
<view class="security-page"><ModulePageBackground module="profile" /><view class="page-header"><PageHeader title="账号与安全" custom-back @back="backToProfile" /></view><view class="page-content"><view class="state-card"><text>安全资料待后端字段合同</text><text>没有独立安全概览设备或登录记录读取 owner页面不再显示 mock 账号摘要可进入已独立核对的修改密码页面换绑手机号仍需人工 TAC/短信窗口</text><AppButton block label="修改密码" @click="openPassword" /><AppButton type="secondary" block label="换绑手机号" @click="openPhone" /></view></view></view>
</template>
<script setup>
import { onUnmounted, ref } from "vue";
import AppButton from "@/components/AppButton.vue";
import AppToast from "@/components/AppToast.vue";
import ModulePageBackground from "@/components/ModulePageBackground.vue";
import PageHeader from "@/components/PageHeader.vue";
import { currentUser } from "@/data/mock.js";
import { openPage } from "@/utils/navigation.js";
const securityItems = [
{ key: "password", label: "登录密码", note: "建议定期更新密码", icon: "/static/assets/modules/auth/transparent/a01-icon-lock-v1.png", routeKey: "M04" },
{ key: "phone", label: "绑定手机号", note: currentUser.phone, icon: "/static/assets/modules/auth/transparent/a01-icon-phone-v1.png", routeKey: "M05" },
];
const toastVisible = ref(false);
const toastMessage = ref("");
let timer = null;
const showToast = (message) => { toastMessage.value = message; toastVisible.value = true; clearTimeout(timer); timer = setTimeout(() => (toastVisible.value = false), 1800); };
const openSecurityItem = (item) => openPage(item.routeKey, {}, "M03");
const checkSecurity = () => showToast("账号安全接口将在后续独立批次接入");
onUnmounted(() => clearTimeout(timer));
import AppButton from "@/components/AppButton.vue"; import ModulePageBackground from "@/components/ModulePageBackground.vue"; import PageHeader from "@/components/PageHeader.vue"; import { openPage,returnTo } from "@/utils/navigation.js"; const backToProfile=()=>returnTo("M01");const openPassword=()=>openPage("M04",{},"M03");const openPhone=()=>openPage("M05",{},"M03");
</script>
<style scoped lang="scss">
@use "../../styles/adaptive-frame-profiles.scss" as adaptive;
.security-page{display:flex;min-height:100vh;flex-direction:column;background:$paper}.page-layer{z-index:1}.page-content{flex:1;padding:28rpx 30rpx 72rpx}.security-summary{@include adaptive.adaptive-profile-summary;min-height:190rpx;padding:42rpx 48rpx;text-align:center}.security-summary text{display:block;overflow-wrap:anywhere}.security-summary text:first-child{color:$ink;font-family:STKaiti,KaiTi,serif;font-size:34rpx;font-weight:700}.security-summary text:last-child{margin-top:12rpx;color:$ink-muted;font-size:23rpx;line-height:1.55}.security-list{margin-top:22rpx}.security-row{@include adaptive.adaptive-profile-field;display:grid;grid-template-columns:56rpx minmax(0,1fr) 34rpx;min-height:110rpx;align-items:center;gap:18rpx;padding:16rpx 26rpx}.security-row+ .security-row{margin-top:12rpx}.security-row>image{width:50rpx;height:50rpx}.security-row .chevron{width:30rpx;height:30rpx}.security-row view{min-width:0}.security-row text{display:block;overflow-wrap:anywhere}.security-row text:first-child{color:$ink;font-size:25rpx;font-weight:700}.security-row text:last-child{margin-top:6rpx;color:$ink-muted;font-size:21rpx}.page-content>.app-button{margin-top:28rpx}@media(max-width:340px){.page-content{padding-right:22rpx;padding-left:22rpx}}
.security-page{display:flex;min-height:100vh;flex-direction:column;background:$paper}.page-header,.page-content{z-index:1}.page-content{padding:18rpx 24rpx 72rpx}.state-card{box-sizing:border-box;min-height:340rpx;padding:78rpx 52rpx 50rpx;text-align:center;@include adaptive.adaptive-family-content}.state-card text{display:block}.state-card text:first-child{color:$ink;font-family:STKaiti,KaiTi,serif;font-size:35rpx;font-weight:700}.state-card text:nth-child(2){margin-top:18rpx;color:$ink-muted;font-size:24rpx;line-height:1.65}.state-card .app-button{margin-top:20rpx}
</style>
+26 -6
View File
@@ -15,7 +15,7 @@
<text v-if="errors[field.key]" class="field-error">{{ errors[field.key] }}</text>
</view>
</view>
<AppButton block :disabled="passwordState === 'saving'" :label="passwordState === 'saving' ? '正在校验' : '校验新密码(不提交)'" @click="savePassword" />
<AppButton block :disabled="passwordState === 'saving'" :label="passwordState === 'saving' ? '正在提交' : '确认修改密码'" @click="savePassword" />
</view>
<AppToast :visible="toastVisible" :message="toastMessage" />
<AppDialog
@@ -41,7 +41,9 @@ import AppDialog from "@/components/AppDialog.vue";
import AppToast from "@/components/AppToast.vue";
import ModulePageBackground from "@/components/ModulePageBackground.vue";
import PageHeader from "@/components/PageHeader.vue";
import { appApi, createRequestController, isRequestCancelled } from "@/utils/api.js";
import { createDiscardConfirmation } from "@/utils/discard-confirmation.js";
import { calcMD5 } from "@/utils/md5.js";
import { handleBackPress, runBackGuard } from "@/utils/navigation.js";
import {
PASSWORD_POLICY_MESSAGE,
@@ -59,6 +61,8 @@ const passwordFields = [
{ key: "confirm", label: "确认新密码", placeholder: "请再次输入新密码" },
];
const toastVisible = ref(false); const toastMessage = ref(""); let timer = null;
const passwordRequestController = createRequestController();
let pageActive = true;
const formSnapshot = computed(() => JSON.stringify(passwordForm));
const baseline = ref(formSnapshot.value);
const isDirty = computed(() => formSnapshot.value !== baseline.value);
@@ -79,13 +83,27 @@ const validateForm = () => {
errors.confirm = !passwordForm.confirm ? "请再次输入新密码" : passwordForm.confirm !== passwordForm.next ? "两次输入的新密码不一致" : "";
return !Object.values(errors).some(Boolean);
};
const savePassword = () => {
const savePassword = async () => {
if (!validateForm() || passwordState.value === "saving") return;
passwordState.value = "saving";
timer = setTimeout(() => {
passwordState.value = "ready";
showToast("本地校验通过,尚未提交服务器");
}, 500);
try {
await appApi.changePassword({
oldPasswordHash: calcMD5(passwordForm.current),
newPasswordHash: calcMD5(passwordForm.next),
}, { requestController: passwordRequestController });
if (!pageActive) return;
passwordForm.current = "";
passwordForm.next = "";
passwordForm.confirm = "";
baseline.value = formSnapshot.value;
showToast("密码修改成功");
} catch (error) {
if (pageActive && !isRequestCancelled(error)) {
showToast(error?.message || "密码修改失败,请稍后重试");
}
} finally {
if (pageActive) passwordState.value = "ready";
}
};
const requestBack = () =>
runBackGuard({
@@ -98,6 +116,8 @@ const requestBack = () =>
});
onBackPress((event) => handleBackPress(event, requestBack));
onUnload(() => {
pageActive = false;
passwordRequestController.abort();
clearTimeout(timer);
discardConfirmation.dispose();
});
+4 -91
View File
@@ -1,98 +1,11 @@
<!-- 页面编号M-05用途验证并更换绑手机号 -->
<!-- 页面编号M-05用途换绑手机号缺专用发码/资料 DTO 且需人工 TAC/短信时保持关闭 -->
<template>
<view class="phone-page" :class="{ 'phone-state--ready': phoneState === 'ready', 'phone-state--saving': phoneState === 'saving' }">
<ModulePageBackground module="profile" />
<view class="page-layer"><PageHeader title="修改手机号" custom-back @back="requestBack" /></view>
<view class="page-content page-layer">
<view class="current-phone"><text>当前绑定手机号</text><text>{{ phoneForm.currentPhone }}</text><text>更换后新手机号将用于登录与安全验证</text></view>
<view class="form-panel">
<view class="form-row"><text>新手机号</text><input v-model.trim="phoneForm.newPhone" type="number" maxlength="11" aria-label="新手机号" placeholder="请输入新手机号" @input="errors.newPhone = ''" /></view>
<text v-if="errors.newPhone" class="field-error">{{ errors.newPhone }}</text>
<view class="form-row code-row"><text>验证码</text><input v-model.trim="phoneForm.code" type="number" maxlength="4" aria-label="短信验证码" placeholder="4 位验证码" @input="errors.code = ''" /><view class="code-action" role="button" aria-label="检查验证码发送条件" @click="sendCode">发送条件</view></view>
<text v-if="errors.code" class="field-error">{{ errors.code }}</text>
</view>
<AppButton block :disabled="phoneState === 'saving'" :label="phoneState === 'saving' ? '正在校验' : '校验换绑信息(不提交)'" @click="savePhone" />
</view>
<AppToast :visible="toastVisible" :message="toastMessage" />
<AppDialog
:visible="discardVisible"
:close-on-mask="false"
eyebrow="放弃确认"
title="放弃换绑填写?"
message="新手机号和验证码尚未提交服务器。"
confirm-text="确认放弃"
cancel-text="继续填写"
show-cancel
@confirm="confirmDiscard"
@cancel="cancelDiscard"
/>
</view>
<view class="phone-page"><ModulePageBackground module="profile" /><view class="page-header"><PageHeader title="换绑手机号" custom-back @back="backToSecurity" /></view><view class="page-content"><view class="state-card"><text>换绑手机号待人工验证</text><text>换绑接口要求新手机号四位短信码和 `clientId`但没有可安全消费的当前手机号 DTO也没有已核实的专用受保护发码链页面不显示 mock 手机号不发送验证码不在无人值守时换绑</text><AppButton block label="返回账号与安全" @click="backToSecurity" /></view></view></view>
</template>
<script setup>
import { computed, reactive, ref } from "vue";
import { onBackPress, onUnload } from "@dcloudio/uni-app";
import AppButton from "@/components/AppButton.vue";
import AppDialog from "@/components/AppDialog.vue";
import AppToast from "@/components/AppToast.vue";
import ModulePageBackground from "@/components/ModulePageBackground.vue";
import PageHeader from "@/components/PageHeader.vue";
import { currentUser } from "@/data/mock.js";
import { createDiscardConfirmation } from "@/utils/discard-confirmation.js";
import { handleBackPress, runBackGuard } from "@/utils/navigation.js";
const phoneForm = reactive({ currentPhone: currentUser.phone, newPhone: "", code: "" });
const errors = reactive({ newPhone: "", code: "" });
const phoneState = ref("ready");
const discardVisible = ref(false);
const toastVisible = ref(false); const toastMessage = ref("");
let stateTimer = null; let toastTimer = null;
const formSnapshot = computed(() => JSON.stringify({ newPhone: phoneForm.newPhone, code: phoneForm.code }));
const baseline = ref(formSnapshot.value);
const isDirty = computed(() => formSnapshot.value !== baseline.value);
const discardConfirmation = createDiscardConfirmation((visible) => {
discardVisible.value = visible;
});
const requestDiscardConfirmation = discardConfirmation.request;
const confirmDiscard = discardConfirmation.confirm;
const cancelDiscard = discardConfirmation.cancel;
const showToast = (message) => { toastMessage.value = message; toastVisible.value = true; clearTimeout(toastTimer); toastTimer = setTimeout(() => (toastVisible.value = false), 1800); };
const sendCode = () => {
if (!/^1\d{10}$/.test(phoneForm.newPhone)) { errors.newPhone = "请输入正确的新手机号"; return; }
errors.newPhone = "";
showToast("需先完成滑动行为验证;当前未发送验证码");
};
const validatePhone = () => {
errors.newPhone = /^1\d{10}$/.test(phoneForm.newPhone) ? "" : "请输入正确的新手机号";
errors.code = /^\d{4}$/.test(phoneForm.code) ? "" : "请输入 4 位验证码";
return !errors.newPhone && !errors.code;
};
const savePhone = () => {
if (!validatePhone() || phoneState.value === "saving") return;
phoneState.value = "saving";
stateTimer = setTimeout(() => {
phoneState.value = "ready";
showToast("本地校验通过,尚未提交服务器");
}, 500);
};
const requestBack = () =>
runBackGuard({
transientOpen: discardVisible.value,
dirty: isDirty.value,
submitting: phoneState.value === "saving",
"close-transient": cancelDiscard,
"block-submitting": () => true,
"confirm-discard": requestDiscardConfirmation,
});
onBackPress((event) => handleBackPress(event, requestBack));
onUnload(() => {
clearTimeout(stateTimer);
clearTimeout(toastTimer);
discardConfirmation.dispose();
});
import AppButton from "@/components/AppButton.vue"; import ModulePageBackground from "@/components/ModulePageBackground.vue"; import PageHeader from "@/components/PageHeader.vue"; import { returnTo } from "@/utils/navigation.js"; const backToSecurity=()=>returnTo("M03");
</script>
<style scoped lang="scss">
@use "../../styles/adaptive-frame-profiles.scss" as adaptive;
.phone-page{display:flex;min-height:100vh;flex-direction:column;background:$paper}.page-layer{z-index:1}.page-content{flex:1;padding:28rpx 30rpx 72rpx}.current-phone,.form-panel{@include adaptive.adaptive-profile-content}.current-phone{min-height:210rpx;padding:40rpx 48rpx;text-align:center}.current-phone text{display:block}.current-phone text:first-child{color:$ink-muted;font-size:22rpx}.current-phone text:nth-child(2){margin-top:8rpx;color:$ink;font-size:38rpx;font-weight:700;letter-spacing:3rpx}.current-phone text:last-child{margin-top:12rpx;color:$ink-muted;font-size:21rpx;line-height:1.5}.form-panel{margin-top:22rpx;padding:24rpx 34rpx 32rpx}.form-row{display:grid;grid-template-columns:130rpx minmax(0,1fr);min-height:94rpx;align-items:center;gap:16rpx;border-bottom:1px solid rgba(181,137,63,.42)}.code-row{grid-template-columns:130rpx minmax(0,1fr) auto}.form-row>text{color:$ink;font-size:23rpx;font-weight:700}.form-row input{width:auto;min-width:0;min-height:70rpx;color:$ink;font-size:23rpx}.code-action{display:flex;min-width:126rpx;min-height:70rpx;align-items:center;justify-content:flex-end;color:$brand-red;font-size:21rpx}.field-error{display:block;padding-top:7rpx;color:#b42318;font-size:20rpx;text-align:right}.page-content>.app-button{margin-top:28rpx}@media(max-width:340px){.page-content{padding-right:22rpx;padding-left:22rpx}.form-panel{padding-right:26rpx;padding-left:26rpx}.form-row{grid-template-columns:112rpx minmax(0,1fr)}.code-row{grid-template-columns:112rpx minmax(0,1fr);}.code-action{grid-column:2;justify-content:flex-start}}
.phone-page{display:flex;min-height:100vh;flex-direction:column;background:$paper}.page-header,.page-content{z-index:1}.page-content{padding:18rpx 24rpx 72rpx}.state-card{box-sizing:border-box;min-height:340rpx;padding:78rpx 52rpx 50rpx;text-align:center;@include adaptive.adaptive-family-content}.state-card text{display:block}.state-card text:first-child{color:$ink;font-family:STKaiti,KaiTi,serif;font-size:35rpx;font-weight:700}.state-card text:nth-child(2){margin-top:18rpx;color:$ink-muted;font-size:24rpx;line-height:1.65}.state-card .app-button{margin-top:28rpx}
</style>
+2 -1
View File
@@ -4,6 +4,7 @@
<ModulePageBackground module="profile" />
<view class="page-layer"><PageHeader title="帮助中心" /></view>
<view class="page-content page-layer">
<text class="help-source-note">当前展示的是本地使用说明不代表已读取服务端帮助文章需要最新支持请提交意见反馈</text>
<view class="search-box"><input v-model.trim="keyword" aria-label="搜索帮助" placeholder="搜索问题关键词" /><text>{{ filteredQuestions.length }} </text></view>
<scroll-view scroll-x class="category-scroll" :show-scrollbar="false"><view class="category-row"><view v-for="category in helpCategories" :key="category" class="category-chip" :class="{ active: activeCategory === category }" role="button" @click="activeCategory = category">{{ category }}</view></view></scroll-view>
<view v-if="filteredQuestions.length" class="question-list">
@@ -46,5 +47,5 @@ const contactSupport = () => openPage("M07", {}, "M06");
<style scoped lang="scss">
@use "../../styles/adaptive-frame-profiles.scss" as adaptive;
.help-page{display:flex;min-height:100vh;flex-direction:column;background:$paper}.page-layer{z-index:1}.page-content{flex:1;padding:24rpx 28rpx 72rpx}.search-box{@include adaptive.adaptive-profile-field;display:grid;grid-template-columns:minmax(0,1fr) auto;min-height:86rpx;align-items:center;gap:16rpx;padding:0 30rpx}.search-box input{width:auto;min-width:0;min-height:68rpx;color:$ink;font-size:23rpx}.search-box text{color:$ink-muted;font-size:20rpx}.category-scroll{width:100%;margin-top:18rpx}.category-row{display:flex;width:max-content;gap:12rpx;padding:2rpx}.category-chip{display:flex;min-width:100rpx;min-height:64rpx;align-items:center;justify-content:center;padding:0 22rpx;box-sizing:border-box;color:$ink-muted;font-size:22rpx}.category-chip.active{@include adaptive.adaptive-scroll-button(secondary);color:$brand-red;font-weight:700}.question-list{display:flex;flex-direction:column;gap:14rpx;margin-top:18rpx}.question-card,.empty-card{@include adaptive.adaptive-profile-content}.question-card{min-height:104rpx;padding:24rpx 34rpx}.question-heading{display:grid;grid-template-columns:minmax(0,1fr) auto;min-height:58rpx;align-items:center;gap:18rpx}.question-heading text:first-child{color:$ink;font-size:24rpx;font-weight:700;overflow-wrap:anywhere}.question-heading text:last-child{color:$brand-red;font-size:20rpx}.answer{display:block;padding:14rpx 4rpx 6rpx;border-top:1px solid rgba(181,137,63,.32);color:$ink-muted;font-size:22rpx;line-height:1.65;overflow-wrap:anywhere}.empty-card{min-height:220rpx;padding:58rpx 44rpx;text-align:center}.empty-card text{display:block}.empty-card text:first-child{color:$ink;font-size:29rpx;font-weight:700}.empty-card text:last-child{margin-top:12rpx;color:$ink-muted;font-size:22rpx;line-height:1.5}.page-content>.app-button{margin-top:28rpx}@media(max-width:340px){.page-content{padding-right:20rpx;padding-left:20rpx}}
.help-page{display:flex;min-height:100vh;flex-direction:column;background:$paper}.page-layer{z-index:1}.page-content{flex:1;padding:24rpx 28rpx 72rpx}.help-source-note{display:block;margin:0 4rpx 16rpx;color:$ink-muted;font-size:21rpx;line-height:1.55}.search-box{@include adaptive.adaptive-profile-field;display:grid;grid-template-columns:minmax(0,1fr) auto;min-height:86rpx;align-items:center;gap:16rpx;padding:0 30rpx}.search-box input{width:auto;min-width:0;min-height:68rpx;color:$ink;font-size:23rpx}.search-box text{color:$ink-muted;font-size:20rpx}.category-scroll{width:100%;margin-top:18rpx}.category-row{display:flex;width:max-content;gap:12rpx;padding:2rpx}.category-chip{display:flex;min-width:100rpx;min-height:64rpx;align-items:center;justify-content:center;padding:0 22rpx;box-sizing:border-box;color:$ink-muted;font-size:22rpx}.category-chip.active{@include adaptive.adaptive-scroll-button(secondary);color:$brand-red;font-weight:700}.question-list{display:flex;flex-direction:column;gap:14rpx;margin-top:18rpx}.question-card,.empty-card{@include adaptive.adaptive-profile-content}.question-card{min-height:104rpx;padding:24rpx 34rpx}.question-heading{display:grid;grid-template-columns:minmax(0,1fr) auto;min-height:58rpx;align-items:center;gap:18rpx}.question-heading text:first-child{color:$ink;font-size:24rpx;font-weight:700;overflow-wrap:anywhere}.question-heading text:last-child{color:$brand-red;font-size:20rpx}.answer{display:block;padding:14rpx 4rpx 6rpx;border-top:1px solid rgba(181,137,63,.32);color:$ink-muted;font-size:22rpx;line-height:1.65;overflow-wrap:anywhere}.empty-card{min-height:220rpx;padding:58rpx 44rpx;text-align:center}.empty-card text{display:block}.empty-card text:first-child{color:$ink;font-size:29rpx;font-weight:700}.empty-card text:last-child{margin-top:12rpx;color:$ink-muted;font-size:22rpx;line-height:1.5}.page-content>.app-button{margin-top:28rpx}@media(max-width:340px){.page-content{padding-right:20rpx;padding-left:20rpx}}
</style>
+19 -6
View File
@@ -1,6 +1,6 @@
<!-- 页面编号M-10用途协议隐私版本与退出登录 -->
<template>
<view class="about-page">
<view class="about-page" :class="{ 'about-state--logging-out': logoutSubmitting }">
<ModulePageBackground module="profile" />
<view class="page-layer"><PageHeader title="关于家谱" custom-back @back="requestBack" /></view>
<view class="page-content page-layer">
@@ -11,18 +11,19 @@
<AppButton block type="secondary" label="退出登录" @click="logoutVisible = true" />
</view>
<AppDialog :visible="agreementVisible" :close-on-mask="false" eyebrow="协议与说明" :title="activeAgreement.label" :message="activeAgreement.copy" confirm-text="关闭" @confirm="agreementVisible = false" @close="agreementVisible = false" />
<AppDialog :visible="logoutVisible" eyebrow="账号操作" title="确认退出登录?" message="退出后需要重新验证账号;本机保存的密码不会被保留。" confirm-text="确认退出" cancel-text="取消" show-cancel :close-on-mask="false" @confirm="confirmLogout" @cancel="logoutVisible = false" @close="logoutVisible = false" />
<AppDialog :visible="logoutVisible" eyebrow="账号操作" title="确认退出登录?" message="退出后需要重新验证账号;本机保存的密码不会被保留。" :confirm-text="logoutSubmitting ? '正在退出' : '确认退出'" cancel-text="取消" show-cancel :close-on-mask="false" @confirm="confirmLogout" @cancel="logoutVisible = false" @close="logoutVisible = false" />
</view>
</template>
<script setup>
import { reactive, ref } from "vue";
import { onBackPress } from "@dcloudio/uni-app";
import { onBackPress, onUnload } from "@dcloudio/uni-app";
import AppButton from "@/components/AppButton.vue";
import AppDialog from "@/components/AppDialog.vue";
import ModulePageBackground from "@/components/ModulePageBackground.vue";
import PageHeader from "@/components/PageHeader.vue";
import manifest from "@/manifest.json";
import { appApi, createRequestController } from "@/utils/api.js";
import { goRoot, handleBackPress, runBackGuard } from "@/utils/navigation.js";
import { session } from "@/utils/session.js";
@@ -35,10 +36,21 @@ const agreementItems = [
const activeAgreement = reactive({ label: "", copy: "" });
const agreementVisible = ref(false);
const logoutVisible = ref(false);
const logoutSubmitting = ref(false);
const logoutRequestController = createRequestController();
const openAgreement = (item) => { activeAgreement.label = item.label; activeAgreement.copy = item.copy; agreementVisible.value = true; };
const confirmLogout = () => {
session.clear();
logoutVisible.value = false;
const confirmLogout = async () => {
if (logoutSubmitting.value) return false;
logoutSubmitting.value = true;
try {
await appApi.logout({ requestController: logoutRequestController });
} catch {
// 退出请求的结果未知时仍必须撤销本机会话,不能保留旧授权或自动重试。
} finally {
session.clear();
logoutVisible.value = false;
logoutSubmitting.value = false;
}
return goRoot("A01");
};
const closeActiveDialog = () => {
@@ -51,6 +63,7 @@ const requestBack = () =>
"close-transient": closeActiveDialog,
});
onBackPress((event) => handleBackPress(event, requestBack));
onUnload(() => logoutRequestController.abort());
</script>
<style scoped lang="scss">
+67 -43
View File
@@ -4,6 +4,7 @@
class="people-page"
:class="{
'people-state--ready': peopleState === 'ready',
'people-state--loading': peopleState === 'loading',
'people-state--empty': peopleState === 'empty',
'people-state--error': peopleState === 'error',
}"
@@ -12,7 +13,12 @@
<view class="people-page__header"><PageHeader title="人物录" /></view>
<view class="people-content">
<template v-if="peopleState === 'ready'">
<AppLoading
v-if="peopleState === 'loading'"
text="正在读取人物录"
description="请稍候,正在读取当前家谱的人物资料。"
/>
<template v-else-if="peopleState === 'ready'">
<view class="people-search">
<input
v-model="keywordInput"
@@ -30,9 +36,9 @@
>
</view>
<view v-if="filteredPeople.length" class="people-list">
<view v-if="people.length" class="people-list">
<view
v-for="person in filteredPeople"
v-for="person in people"
:key="person.id"
class="person-card"
@click="openPerson(person)"
@@ -46,10 +52,12 @@
</view>
</view>
<AppButton
v-if="hasMore"
class="people-primary-action"
type="secondary"
block
label="填写人物预览"
@click="createPersonPreview"
:label="loadingMore ? '正在加载…' : '加载更多人物'"
@click="loadMore"
/>
</view>
@@ -85,9 +93,10 @@
}}</text>
</view>
<AppButton
v-if="peopleState !== 'empty'"
:type="peopleState === 'empty' ? 'primary' : 'secondary'"
block
:label="peopleState === 'error' ? '重新查看' : peopleState === 'invalid' ? '返回上一页' : '填写人物预览'"
:label="peopleState === 'error' ? '重新查看' : '返回上一页'"
@click="handleStateAction"
/>
</view>
@@ -96,15 +105,13 @@
</template>
<script setup>
import { onLoad } from "@dcloudio/uni-app";
import { onLoad, onUnload } from "@dcloudio/uni-app";
import { computed, ref } from "vue";
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 {
getGenealogyFixtureAccess,
listTreeMemberPresentationFixtures,
} from "@/data/mock.js";
import { appApi, createRequestController, isRequestCancelled } from "@/utils/api.js";
import { goBack, openPage } from "@/utils/navigation.js";
const genealogyId = ref("");
@@ -112,17 +119,13 @@ const people = ref([]);
const peopleState = ref("ready");
const keywordInput = ref("");
const keyword = ref("");
const hasValidContext = computed(() => peopleState.value !== "invalid");
const filteredPeople = computed(() => {
const value = keyword.value.trim().toLowerCase();
if (!value) return people.value;
return people.value.filter((person) =>
`${person.name} ${person.relation} ${person.branch || ""} ${person.generationName || ""}${person.generation}${person.generation}`
.toLowerCase()
.includes(value),
);
});
const total = ref(0);
const pageNum = ref(1);
const loadingMore = ref(false);
const peopleRequestController = createRequestController();
let loadSequence = 0;
const hasValidContext = computed(() => Boolean(genealogyId.value));
const hasMore = computed(() => people.value.length < total.value);
const applySearch = () => {
if (keyword.value) {
@@ -130,14 +133,42 @@ const applySearch = () => {
return;
}
keyword.value = keywordInput.value.trim();
pageNum.value = 1;
void loadPeople();
};
const clearSearch = () => {
keywordInput.value = "";
keyword.value = "";
pageNum.value = 1;
void loadPeople();
};
const restoreList = () => {
people.value = listTreeMemberPresentationFixtures(genealogyId.value);
peopleState.value = people.value.length ? "ready" : "empty";
const loadPeople = async ({ append = false } = {}) => {
if (!hasValidContext.value) return;
const activeLoad = ++loadSequence;
if (append) loadingMore.value = true;
else peopleState.value = "loading";
try {
const result = await appApi.getPersonPage(
genealogyId.value,
{ pageNum: pageNum.value, pageSize: 10, keyword: keyword.value },
{ requestController: peopleRequestController },
);
if (activeLoad !== loadSequence) return;
people.value = append ? [...people.value, ...result.rows] : result.rows;
total.value = result.total;
peopleState.value = people.value.length ? "ready" : "empty";
} catch (error) {
if (activeLoad !== loadSequence || isRequestCancelled(error)) return;
if (!append) people.value = [];
peopleState.value = "error";
} finally {
if (activeLoad === loadSequence) loadingMore.value = false;
}
};
const loadMore = () => {
if (loadingMore.value || !hasMore.value) return;
pageNum.value += 1;
void loadPeople({ append: true });
};
const openPerson = (person) =>
hasValidContext.value
@@ -151,34 +182,27 @@ const openPerson = (person) =>
"R01",
)
: Promise.resolve(false);
const createPersonPreview = () =>
hasValidContext.value
? openPage(
"R02",
{ genealogyId: genealogyId.value, mode: "create" },
"R01",
)
: Promise.resolve(false);
const handleStateAction = () => {
if (peopleState.value === "invalid") return goBack();
if (peopleState.value === "error") return restoreList();
return createPersonPreview();
if (peopleState.value === "error") {
pageNum.value = 1;
return loadPeople();
}
return goBack();
};
onLoad((query) => {
genealogyId.value = String(query.genealogyId || "");
const access = getGenealogyFixtureAccess(genealogyId.value);
if (!["owner", "member"].includes(access.accessRole)) {
if (!hasValidContext.value || query.state === "error") {
people.value = [];
peopleState.value = "invalid";
return;
}
people.value = listTreeMemberPresentationFixtures(genealogyId.value);
peopleState.value = ["empty", "error"].includes(query.state)
? query.state
: people.value.length
? "ready"
: "empty";
void loadPeople();
});
onUnload(() => {
loadSequence += 1;
peopleRequestController.abort();
});
</script>
+63 -232
View File
@@ -4,7 +4,7 @@
<ModulePageBackground module="records" />
<view class="person-detail-header">
<PageHeader
:title="personState === 'edit' ? (isCreateMode ? '人物预览' : '编辑预览') : '人物详情'"
title="人物详情"
custom-back
@back="requestBack"
/>
@@ -15,12 +15,12 @@
</view>
<view v-else class="person-detail-content">
<template v-if="['detail', 'edit', 'privacy', 'preview'].includes(personState)">
<template v-if="personState === 'detail'">
<view class="person-identity-card">
<view class="person-identity-card__copy">
<text class="person-identity-card__name">{{ person.name || "待填写姓名" }}</text>
<text class="person-identity-card__meta">{{ person.relation || "人物预览" }} · {{ person.generation || "—" }} </text>
<text class="person-identity-card__hint">{{ personState === "preview" ? "本地预览 · 未提交" : "人物录档案" }}</text>
<text class="person-identity-card__hint">人物录档案</text>
</view>
</view>
</template>
@@ -33,35 +33,9 @@
<AppButton type="secondary" block label="成长日志" @click="toGrowthJournal" />
<AppButton type="secondary" block label="人生事(待开放)" @click="toLifeEvents" />
</view>
<view class="person-edit-action" @click="enterEdit"><AppButton block label="制作编辑预览" /></view>
<view class="person-edit-action" @click="toMemberProfile"><AppButton block label="查看成员档案" /></view>
</template>
<template v-else-if="personState === 'edit'">
<view v-for="field in shortFields" :key="field.key" class="person-field">
<text class="person-field__label">{{ field.label }}</text>
<input v-model="draft[field.key]" :type="field.key === 'generation' ? 'number' : 'text'" :placeholder="`请输入${field.label}`" />
<text v-if="errors[field.key]" class="person-field-error">{{ errors[field.key] }}</text>
</view>
<view v-for="field in longFields" :key="field.key" class="person-long-field">
<text class="person-long-field__label">{{ field.label }}</text>
<textarea v-model="draft[field.key]" auto-height :placeholder="`请输入${field.label}`" />
</view>
<view class="person-edit-actions">
<view class="person-save-action" @click="savePerson"><AppButton block label="生成本地预览" /></view>
<view class="person-cancel-action" @click="cancelEdit"><AppButton type="secondary" block label="取消填写" /></view>
</view>
</template>
<view v-else-if="personState === 'preview'" class="person-state-card">
<view><text>本地预览尚未提交服务器</text><text>这份人物资料只存在于当前页面不会新增覆盖或刷新人物录</text></view>
<view class="person-state-action"><AppButton block label="返回人物录" @click="returnToPeople" /></view>
</view>
<view v-else-if="personState === 'privacy'" class="person-state-card">
<view><text>部分资料未公开</text><text>人物小传与档案备注受隐私设置保护当前只展示公开身份</text></view>
<view class="person-state-action"><AppButton block label="返回人物录" @click="returnToPeople" /></view>
</view>
<view v-else class="person-state-card">
<view>
<text>{{ personState === 'expired' ? '人物档案已失效' : '人物档案暂不可用' }}</text>
@@ -71,173 +45,66 @@
</view>
</view>
<AppToast :visible="toastVisible" message="已生成本地预览,尚未保存" />
<AppDialog
:visible="discardVisible"
:close-on-mask="false"
eyebrow="放弃确认"
title="放弃当前填写?"
message="尚未提交的内容将从当前页面清除。"
confirm-text="确认放弃"
cancel-text="继续填写"
show-cancel
@confirm="confirmDiscard"
@cancel="cancelDiscard"
/>
</view>
</template>
<script setup>
import { computed, onUnmounted, reactive, ref } from "vue";
import { onBackPress, onLoad } from "@dcloudio/uni-app";
import { computed, reactive, ref } from "vue";
import { onBackPress, onLoad, onUnload } from "@dcloudio/uni-app";
import AppButton from "@/components/AppButton.vue";
import AppDialog from "@/components/AppDialog.vue";
import AppLoading from "@/components/AppLoading.vue";
import AppToast from "@/components/AppToast.vue";
import ModulePageBackground from "@/components/ModulePageBackground.vue";
import PageHeader from "@/components/PageHeader.vue";
import {
findTreeMemberPresentationFixture,
getGenealogyFixtureAccess,
} from "@/data/mock.js";
import { createDiscardConfirmation } from "@/utils/discard-confirmation.js";
import { appApi, createRequestController, isRequestCancelled } from "@/utils/api.js";
import {
goBack,
handleBackPress,
openPage,
returnTo,
runBackGuard,
} from "@/utils/navigation.js";
const genealogyId = ref("");
const personId = ref("");
const routeMode = ref("");
const personState = ref("loading");
const personRequestController = createRequestController();
let loadSequence = 0;
const person = reactive({
id: "",
name: "",
relation: "",
generationName: "",
generation: "",
biography: "",
remark: "",
status: "",
});
const draft = reactive({
name: "",
generationName: "",
generation: "",
aliasName: "",
sex: "",
personStatus: "",
birthDate: "",
birthLunar: "",
birthplace: "",
deathDate: "",
deathLunar: "",
deathPlace: "",
burialPlace: "",
spouseNames: "",
biography: "",
remark: "",
});
const errors = reactive({ name: "", generation: "" });
const baseline = ref("");
const toastVisible = ref(false);
const discardVisible = ref(false);
let toastTimer = null;
const isCreateMode = computed(() => routeMode.value === "create");
const formSnapshot = computed(() => JSON.stringify({ ...draft }));
const isDirty = computed(() =>
personState.value === "preview" ||
(personState.value === "edit" && formSnapshot.value !== baseline.value),
);
const shortFields = [
{ key: "name", label: "姓名" },
{ key: "generationName", label: "字辈" },
{ key: "generation", label: "世代" },
];
const longFields = [
{ key: "biography", label: "人物小传" },
{ key: "remark", label: "档案备注" },
];
const detailSections = computed(() => [
{ title: "别名", copy: person.aliasName },
{ title: "字辈", copy: person.generationName },
{ title: "性别(字典值)", copy: person.sex },
{ title: "人物状态(字典值)", copy: person.personStatus },
{ title: "出生日期", copy: person.birthDate },
{ title: "出生农历", copy: person.birthLunar },
{ title: "出生地", copy: person.birthplace },
{ title: "逝世日期", copy: person.deathDate },
{ title: "逝世农历", copy: person.deathLunar },
{ title: "逝世地", copy: person.deathPlace },
{ title: "安葬地", copy: person.burialPlace },
{ title: "配偶", copy: person.spouseNames },
{ title: "人物小传", copy: person.biography },
{ title: "档案备注", copy: person.remark },
]);
const copyToDraft = () => {
Object.assign(draft, {
name: person.name,
generationName: person.generationName,
generation: String(person.generation || ""),
biography: person.biography,
remark: person.remark,
});
baseline.value = formSnapshot.value;
};
const clearErrors = () => Object.assign(errors, { name: "", generation: "" });
const enterEdit = () => {
if (personState.value !== "detail" || !person.id) return false;
copyToDraft();
clearErrors();
personState.value = "edit";
return true;
};
const showPreviewToast = () => {
toastVisible.value = true;
if (toastTimer) clearTimeout(toastTimer);
toastTimer = setTimeout(() => {
toastVisible.value = false;
toastTimer = null;
}, 1800);
};
const validatePerson = () => {
errors.name = draft.name.trim() ? "" : "请填写姓名";
const generationInput = draft.generation.trim();
const generation = Number(generationInput);
errors.generation = !generationInput
? ""
: Number.isInteger(generation) && generation > 0
? ""
: "世代必须是正整数";
return !errors.name && !errors.generation;
};
const savePerson = () => {
clearErrors();
if (!validatePerson()) return false;
const localPersonPreview = {
name: draft.name.trim(),
generationName: draft.generationName.trim(),
generation: draft.generation.trim()
? String(Number(draft.generation))
: "",
biography: draft.biography.trim(),
remark: draft.remark.trim(),
};
Object.assign(person, localPersonPreview);
personState.value = "preview";
showPreviewToast();
return true;
};
const discardConfirmation = createDiscardConfirmation(
(visible) => { discardVisible.value = visible; },
);
const confirmDiscard = discardConfirmation.confirm;
const cancelDiscard = discardConfirmation.cancel;
const requestDiscardConfirmation = discardConfirmation.request;
const cancelEdit = async () => {
const confirmed = isDirty.value ? await requestDiscardConfirmation() : true;
if (!confirmed) return false;
if (isCreateMode.value) return goBack();
copyToDraft();
clearErrors();
personState.value = "detail";
return true;
};
const requestBack = () => {
if (discardVisible.value) {
cancelDiscard();
return Promise.resolve(true);
}
if (personState.value === "edit" && !isCreateMode.value) return cancelEdit();
return runBackGuard({
dirty: isDirty.value,
"confirm-discard": requestDiscardConfirmation,
});
};
const requestBack = () => goBack();
const returnToPeople = () =>
genealogyId.value
@@ -259,74 +126,45 @@ const toLifeEvents = () =>
"R02",
)
: Promise.resolve(false);
const toMemberProfile = () =>
person.id
? openPage(
"T03",
{ genealogyId: genealogyId.value, personId: person.id },
"R02",
)
: Promise.resolve(false);
const loadPerson = async () => {
const activeLoad = ++loadSequence;
personState.value = "loading";
try {
const result = await appApi.getPerson(genealogyId.value, personId.value, {
requestController: personRequestController,
});
if (activeLoad !== loadSequence) return;
Object.assign(person, result);
personState.value = "detail";
} catch (error) {
if (activeLoad !== loadSequence || isRequestCancelled(error)) return;
personState.value = "expired";
}
};
onLoad((query) => {
genealogyId.value = String(query.genealogyId || "");
personId.value = String(query.personId || "");
routeMode.value = String(query.mode || "");
if (query.state === "loading") return;
const access = getGenealogyFixtureAccess(genealogyId.value);
const hasValidGenealogy = ["owner", "member"].includes(access.accessRole);
const isCreateContract = routeMode.value === "create" && !personId.value;
const isViewContract = routeMode.value === "view" && Boolean(personId.value);
if (!hasValidGenealogy || (!isCreateContract && !isViewContract)) {
if (query.mode !== "view" || !genealogyId.value || !personId.value || query.state === "error") {
personState.value = "error";
return;
}
if (isCreateContract) {
Object.assign(person, {
id: "",
name: "",
relation: "人物预览",
generationName: "",
generation: "",
biography: "",
remark: "",
status: "",
});
copyToDraft();
personState.value = "edit";
return;
}
const selected = findTreeMemberPresentationFixture(genealogyId.value, personId.value);
if (!selected) {
personState.value = "expired";
return;
}
Object.assign(person, {
id: selected.id,
name: selected.name,
relation: selected.relation,
generation: String(selected.generation),
status: selected.status,
});
if (["privacy", "forbidden"].includes(selected.status)) {
Object.assign(person, {
generationName: "",
biography: "",
remark: "",
});
personState.value = "privacy";
return;
}
Object.assign(person, {
generationName: selected.generationName || "",
biography: selected.summary || "",
remark: selected.note || "",
});
copyToDraft();
personState.value = ["expired", "error"].includes(query.state)
? query.state
: "detail";
void loadPerson();
});
onBackPress((event) => handleBackPress(event, requestBack));
onUnmounted(() => {
if (toastTimer) clearTimeout(toastTimer);
discardConfirmation.dispose();
onUnload(() => {
loadSequence += 1;
personRequestController.abort();
});
</script>
@@ -344,19 +182,12 @@ onUnmounted(() => {
.person-identity-card__meta { margin-top: 8rpx; color: $ink-muted; font-size: 25rpx; font-weight: 600; }
.person-identity-card__hint { margin-top: 8rpx; color: #806a51; font-size: 21rpx; }
.person-archive-card { min-height: 154rpx; margin-top: 14rpx; padding: 32rpx 42rpx; box-sizing: border-box; }
.person-archive-card,.person-long-field,.person-state-card { @include adaptive.adaptive-records-content; }
.person-archive-card,.person-state-card { @include adaptive.adaptive-records-content; }
.person-archive-card text { display: block; }
.person-archive-card text:first-child,.person-long-field__label { color: $brand-red; font-size: 24rpx; font-weight: 700; }
.person-archive-card text:first-child { color: $brand-red; font-size: 24rpx; font-weight: 700; }
.person-archive-card text:last-child { margin-top: 11rpx; color: $ink; font-size: 24rpx; line-height: 1.55; }
.person-edit-action { margin-top: 20rpx; }
.person-related-actions { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 14rpx; margin-top: 18rpx; }
.person-field { @include adaptive.adaptive-records-field; display: grid; grid-template-columns: auto minmax(0, 1fr); align-items: center; gap: 8rpx 24rpx; min-height: 92rpx; margin-top: 12rpx; padding: 18rpx 28rpx; }
.person-field__label { color: $ink; font-size: 23rpx; font-weight: 700; }
.person-field input { width: 100%; min-width: 0; min-height: 56rpx; color: $ink; font-size: 23rpx; text-align: right; }
.person-field-error { grid-column: 1 / -1; display: block; color: $brand-red; font-size: 21rpx; text-align: right; }
.person-long-field { display: flex; flex-direction: column; min-height: 210rpx; margin-top: 14rpx; padding: 28rpx 38rpx; box-sizing: border-box; }
.person-long-field textarea { width: 100%; min-height: 112rpx; margin-top: 14rpx; color: $ink; font-size: 23rpx; line-height: 1.5; }
.person-edit-actions { display: flex; flex-direction: column; gap: 14rpx; margin-top: 20rpx; }
.person-state-card { display: flex; flex-direction: column; min-height: 300rpx; margin-top: 26rpx; padding: 72rpx 54rpx 42rpx; box-sizing: border-box; text-align: center; }
.person-state-card text { display: block; }
.person-state-card text:first-child { color: $ink; font-family: STKaiti,KaiTi,serif; font-size: 34rpx; font-weight: 700; }
+29 -179
View File
@@ -1,200 +1,50 @@
<!-- 页面编号R-03用途贺礼簿列表空态失败与新增入口 -->
<!-- 页面编号R-03用途亲友往来入口列表 DTO 缺失时不展示 fixture 记录 -->
<template>
<view class="gift-page" :class="stateClasses">
<view class="gift-page">
<ModulePageBackground module="records" />
<view class="page-header">
<PageHeader title="贺礼簿" :action="hasValidContext ? '填写预览' : ''" @action="createRelativePreview" />
</view>
<view v-if="giftState === 'loading'" class="page-loading">
<AppLoading
text="正在整理贺礼簿"
description="请稍候,正在读取家人的礼仪往来。"
/>
</view>
<view v-else class="page-content">
<template v-if="giftState === 'ready' && relativeRecords.length">
<view
v-for="record in relativeRecords"
:key="record.relativeId"
class="record-card"
role="button"
:aria-label="`查看${record.eventName}`"
@click="openRelative(record)"
>
<text class="record-card__tag">{{ record.relationName }}</text>
<text class="record-card__title">{{ record.eventName }}</text>
<text class="record-card__copy">
{{ record.relativeName }} · {{ record.eventTime }} · 金额记录{{ record.giftAmount }}
</text>
<text class="record-card__hint">查看往来记录</text>
</view>
<AppButton block label="填写往来预览" @click="createRelativePreview" />
</template>
<view v-else class="state-card">
<text>
{{ giftState === "error" ? "贺礼簿暂不可用" : giftState === "invalid" ? "贺礼簿入口无效" : "还没有往来记录" }}
</text>
<text>
{{
giftState === "error"
? "请稍后重新查看,已有记录不会受到影响。"
: giftState === "invalid"
? "没有找到可访问的成员家谱,页面不会展示其他家谱记录。"
: "从第一份家人之间的心意开始记录。"
}}
</text>
<AppButton
:type="giftState === 'empty' ? 'primary' : 'secondary'"
block
:label="giftState === 'error' ? '重新查看' : giftState === 'invalid' ? '返回上一页' : '填写往来预览'"
@click="handleStateAction"
/>
<view class="page-header"><PageHeader title="贺礼簿" :action="hasValidContext ? '新建' : ''" @action="createRelative" /></view>
<view class="page-content">
<view class="state-card">
<text>{{ stateCopy.title }}</text>
<text>{{ stateCopy.copy }}</text>
<AppButton block :label="stateCopy.action" @click="handleStateAction" />
</view>
</view>
</view>
</template>
<script setup>
import { computed, ref } from "vue";
import { onLoad } from "@dcloudio/uni-app";
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 {
getGenealogyFixtureAccess,
listRelativeRecordFixtures,
} from "@/data/mock.js";
import { goBack, openPage } from "@/utils/navigation.js";
const genealogyId = ref("");
const relativeRecords = ref([]);
const giftState = ref("loading");
const hasValidContext = computed(() => ["ready", "empty"].includes(giftState.value));
const stateClasses = computed(() => ({
"relative-state--loading": giftState.value === "loading",
"relative-state--empty": giftState.value === "empty",
"relative-state--error": giftState.value === "error",
"relative-state--invalid": giftState.value === "invalid",
}));
const hasValidContext = ref(false);
const stateCopy = computed(() => hasValidContext.value
? { title: "贺礼簿待后端字段合同", copy: "往来列表没有声明记录 ID、关系、事项、时间、金额或备注字段;页面已停止展示本地记录。", action: "新建往来记录" }
: { title: "贺礼簿入口无效", copy: "没有取得有效家谱标识,页面不会展示其他家谱记录。", action: "返回上一页" },
);
onLoad((query) => {
genealogyId.value = String(query.genealogyId || "");
const access = getGenealogyFixtureAccess(genealogyId.value);
if (!["owner", "member"].includes(access.accessRole)) {
relativeRecords.value = [];
giftState.value = "invalid";
return;
}
relativeRecords.value = listRelativeRecordFixtures(genealogyId.value);
giftState.value = ["loading", "empty", "error"].includes(query.state)
? query.state
: relativeRecords.value.length
? "ready"
: "empty";
genealogyId.value = String(query?.genealogyId || "");
hasValidContext.value = /^[1-9]\d*$/.test(genealogyId.value);
});
const openRelative = (record) =>
openPage(
"R04",
{
genealogyId: genealogyId.value,
mode: "view",
relativeId: String(record.relativeId),
},
"R03",
);
const createRelativePreview = () =>
hasValidContext.value
? openPage(
"R04",
{ genealogyId: genealogyId.value, mode: "create" },
"R03",
)
: Promise.resolve(false);
const restoreRelatives = () => {
relativeRecords.value = listRelativeRecordFixtures(genealogyId.value);
giftState.value = relativeRecords.value.length ? "ready" : "empty";
};
const handleStateAction = () => {
if (giftState.value === "invalid") return goBack();
if (giftState.value === "error") return restoreRelatives();
return createRelativePreview();
};
const createRelative = () => hasValidContext.value
? openPage("R04", { genealogyId: genealogyId.value, mode: "create" }, "R03")
: Promise.resolve(false);
const handleStateAction = () => hasValidContext.value ? createRelative() : goBack();
</script>
<style scoped lang="scss">
@use "../../styles/adaptive-frame-profiles.scss" as adaptive;
.gift-page {
display: flex;
min-height: 100vh;
flex-direction: column;
background: $paper;
}
.page-header,
.page-loading,
.page-content {
z-index: 1;
}
.page-loading {
min-height: calc(100vh - 100rpx);
}
.page-content {
display: flex;
flex-direction: column;
gap: 16rpx;
padding: 18rpx 24rpx 72rpx;
}
.record-card,
.state-card {
box-sizing: border-box;
@include adaptive.adaptive-records-content;
}
.record-card {
min-height: 190rpx;
padding: 34rpx 46rpx;
}
.record-card > text,
.state-card > text {
display: block;
}
.record-card__tag {
color: $brand-red;
font-size: 21rpx;
font-weight: 700;
}
.record-card__title {
margin-top: 6rpx;
color: $ink;
font-family: STKaiti, KaiTi, serif;
font-size: 31rpx;
font-weight: 700;
}
.record-card__copy {
margin-top: 9rpx;
color: $ink-muted;
font-size: 23rpx;
line-height: 1.5;
}
.record-card__hint {
margin-top: 10rpx;
color: $brand-red;
font-size: 21rpx;
}
.state-card {
min-height: 340rpx;
padding: 78rpx 52rpx 50rpx;
text-align: center;
}
.state-card > text:first-child {
color: $ink;
font-size: 35rpx;
font-weight: 700;
}
.state-card > text:nth-child(2) {
margin-top: 18rpx;
color: $ink-muted;
font-size: 24rpx;
line-height: 1.65;
}
.state-card .app-button {
margin-top: 28rpx;
}
.gift-page { display: flex; min-height: 100vh; flex-direction: column; background: $paper; }
.page-header, .page-content { z-index: 1; }
.page-content { padding: 18rpx 24rpx 72rpx; }
.state-card { box-sizing: border-box; min-height: 340rpx; padding: 78rpx 52rpx 50rpx; text-align: center; @include adaptive.adaptive-records-content; }
.state-card > text { display: block; }
.state-card > text:first-child { color: $ink; font-size: 35rpx; font-weight: 700; }
.state-card > text:nth-child(2) { margin-top: 18rpx; color: $ink-muted; font-size: 24rpx; line-height: 1.65; }
.state-card .app-button { margin-top: 28rpx; }
</style>
+92 -314
View File
@@ -1,130 +1,47 @@
<!-- 页面编号R-04用途人情往来详情与不写库的新增编辑预览 -->
<!-- 页面编号R-04用途 Apifox 已声明字段创建亲友往来记录 -->
<template>
<view class="gift-editor-page" :class="editorClasses">
<view class="gift-editor-page" :class="`relative-editor-state--${editorState}`">
<ModulePageBackground module="records" />
<view class="page-header">
<PageHeader
:title="
mode === 'create'
? '往来预览'
: mode === 'view'
? '往来详情'
: '编辑预览'
"
:action="mode === 'view' && editorState === 'ready' ? '制作预览' : ''"
custom-back
@back="requestBack"
@action="enterEdit"
/>
</view>
<view v-if="editorState === 'loading'" class="page-loading">
<AppLoading
text="正在读取往来记录"
description="请稍候,正在核对当前家谱与记录身份。"
/>
</view>
<view v-else class="page-content">
<view v-if="editorState === 'preview'" class="state-card">
<text>本地预览尚未提交服务器</text>
<text>这份往来内容只存在于当前页面不会插入修改或删除正式记录</text>
<view v-for="item in previewRows" :key="item.label">
<text>{{ item.label }}</text><text>{{ item.value }}</text>
</view>
<AppButton block label="返回贺礼簿" @click="returnToRelatives" />
</view>
<view v-else-if="mode === 'view' && editorState === 'ready'" class="detail-card">
<text>{{ relativeForm.eventName }}</text>
<view v-for="item in detailRows" :key="item.label">
<text>{{ item.label }}</text>
<text>{{ item.value }}</text>
</view>
<AppButton block label="制作编辑预览" @click="enterEdit" />
<AppButton type="secondary" block disabled label="删除暂未开放" />
</view>
<view v-else-if="editorState === 'ready'" class="form-card">
<text>
{{ mode === "create" ? "填写一份人情往来预览" : "调整往来记录预览" }}
</text>
<view class="page-header"><PageHeader title="新建往来记录" custom-back @back="requestBack" /></view>
<view class="page-content">
<view v-if="editorState === 'form'" class="form-card">
<text>记录一份家人往来</text>
<text class="form-copy">页面只发送已声明且可映射的字段媒体需要 `mediaOssIds`没有上传 owner 时不提交</text>
<view v-for="field in fields" :key="field.key" class="field-row">
<text>{{ field.label }}</text>
<input
v-model="relativeForm[field.key]"
:type="field.key === 'giftAmount' ? 'digit' : 'text'"
:placeholder="`请输入${field.label}`"
/>
<text v-if="relativeErrors[field.key]">{{ relativeErrors[field.key] }}</text>
<textarea v-if="field.key === 'recordContent'" v-model="form[field.key]" auto-height :placeholder="`请输入${field.label}`" @input="submitError = ''" />
<input v-else v-model="form[field.key]" :type="field.key === 'giftAmount' ? 'digit' : 'text'" :placeholder="`请输入${field.label}`" @input="submitError = ''" />
</view>
<AppButton
block
:disabled="isSubmitting"
:label="isSubmitting ? '正在生成预览' : '生成本地预览'"
@click="saveRelative"
/>
<AppButton type="secondary" block label="取消填写" @click="requestBack" />
<text v-if="submitError" class="save-error">{{ submitError }}</text>
<AppButton block :disabled="isSubmitting" :label="isSubmitting ? '正在提交' : '提交往来记录'" @click="saveRelative" />
</view>
<view v-else class="state-card">
<text>往来记录不可用</text>
<text>记录不存在缺少身份或不属于当前家谱页面不会回退到其他记录</text>
<AppButton type="secondary" block label="返回贺礼簿" @click="returnToRelatives" />
<text>{{ resultCopy.title }}</text>
<text>{{ resultCopy.copy }}</text>
<AppButton block :label="resultCopy.action" @click="handleResultAction" />
</view>
</view>
<AppDialog
:visible="discardVisible"
:close-on-mask="false"
eyebrow="放弃确认"
title="放弃当前填写?"
message="尚未提交的预览内容将从当前页面清除。"
confirm-text="确认放弃"
cancel-text="继续填写"
show-cancel
@confirm="confirmDiscard"
@cancel="cancelDiscard"
/>
<AppDialog :visible="discardVisible" :close-on-mask="false" eyebrow="放弃确认" title="放弃当前填写?" message="尚未提交的内容将从当前页面清除。" confirm-text="确认放弃" cancel-text="继续填写" show-cancel @confirm="confirmDiscard" @cancel="cancelDiscard" />
</view>
</template>
<script setup>
import { computed, onUnmounted, reactive, ref } from "vue";
import { onBackPress, onLoad } from "@dcloudio/uni-app";
import AppButton from "@/components/AppButton.vue";
import AppDialog from "@/components/AppDialog.vue";
import AppLoading from "@/components/AppLoading.vue";
import ModulePageBackground from "@/components/ModulePageBackground.vue";
import PageHeader from "@/components/PageHeader.vue";
import {
findRelativeRecordFixture,
getGenealogyFixtureAccess,
} from "@/data/mock.js";
import { appApi, createRequestController, isRequestCancelled } from "@/utils/api.js";
import { createDiscardConfirmation } from "@/utils/discard-confirmation.js";
import {
goBack,
handleBackPress,
returnTo,
runBackGuard,
} from "@/utils/navigation.js";
import { goBack, handleBackPress, returnTo, runBackGuard } from "@/utils/navigation.js";
const genealogyId = ref("");
const relativeId = ref("");
const mode = ref("");
const editorState = ref("loading");
const editorState = ref("form");
const isSubmitting = ref(false);
const discardVisible = ref(false);
const localRelativePreview = ref(null);
const relativeForm = reactive({
relativeName: "",
relationName: "",
eventName: "",
eventTime: "",
giftAmount: "",
recordContent: "",
});
const relativeErrors = reactive({
relativeName: "",
relationName: "",
eventName: "",
eventTime: "",
giftAmount: "",
recordContent: "",
});
const submitError = ref("");
const form = reactive({ relativeName: "", relationName: "", eventName: "", eventTime: "", giftAmount: "", recordContent: "" });
const fields = [
{ key: "relativeName", label: "亲友姓名" },
{ key: "relationName", label: "关系称谓" },
@@ -133,221 +50,82 @@ const fields = [
{ key: "giftAmount", label: "礼金金额" },
{ key: "recordContent", label: "往来备注" },
];
const baseline = ref("");
let submitTimer = null;
const editorClasses = computed(() => ({
"relative-editor-state--preview": editorState.value === "preview",
"relative-editor-state--invalid": editorState.value === "invalid",
}));
const formSnapshot = computed(() => JSON.stringify({ ...relativeForm }));
const isDirty = computed(() =>
editorState.value === "preview" ||
(["create", "edit"].includes(mode.value) && formSnapshot.value !== baseline.value),
);
const displayValue = (value) =>
value === "" || value === null || value === undefined ? "未填写" : String(value);
const detailRows = computed(() =>
fields
.slice(1)
.map((field) => ({ label: field.label, value: displayValue(relativeForm[field.key]) })),
);
const previewRows = computed(() =>
fields.map((field) => ({
label: field.label,
value: displayValue(localRelativePreview.value?.[field.key]),
})),
);
const copyRecordToForm = (record) => {
Object.assign(relativeForm, {
relativeName: record.relativeName,
relationName: record.relationName,
eventName: record.eventName,
eventTime: record.eventTime,
giftAmount: String(record.giftAmount ?? ""),
recordContent: record.recordContent,
});
baseline.value = formSnapshot.value;
};
onLoad((query) => {
genealogyId.value = String(query.genealogyId || "");
relativeId.value = String(query.relativeId || "");
mode.value = String(query.mode || "");
if (query.state === "loading") return;
const access = getGenealogyFixtureAccess(genealogyId.value);
const hasValidGenealogy = ["owner", "member"].includes(access.accessRole);
const isCreateContract = mode.value === "create" && !relativeId.value;
const isEntityContract = ["view", "edit"].includes(mode.value) && Boolean(relativeId.value);
if (!hasValidGenealogy || (!isCreateContract && !isEntityContract)) {
editorState.value = "invalid";
return;
}
if (isCreateContract) {
baseline.value = formSnapshot.value;
editorState.value = "ready";
return;
}
const selected = findRelativeRecordFixture(genealogyId.value, relativeId.value);
if (!selected) {
editorState.value = "invalid";
return;
}
copyRecordToForm(selected);
editorState.value = "ready";
});
const enterEdit = () => {
if (mode.value !== "view" || editorState.value !== "ready") return false;
mode.value = "edit";
baseline.value = formSnapshot.value;
return true;
};
const validateRelative = () => {
relativeErrors.relativeName = relativeForm.relativeName.trim() ? "" : "请填写亲友姓名";
relativeErrors.relationName = "";
relativeErrors.eventName = "";
relativeErrors.eventTime = "";
relativeErrors.recordContent = "";
const amountInput = relativeForm.giftAmount.trim();
relativeErrors.giftAmount =
!amountInput || Number.isFinite(Number(amountInput))
? ""
: "礼金金额必须是数字";
return !relativeErrors.relativeName && !relativeErrors.giftAmount;
};
const saveRelative = () => {
if (isSubmitting.value || !validateRelative()) return false;
isSubmitting.value = true;
const snapshot = Object.freeze({
relativeName: relativeForm.relativeName.trim(),
relationName: relativeForm.relationName.trim(),
eventName: relativeForm.eventName.trim(),
eventTime: relativeForm.eventTime.trim(),
giftAmount: relativeForm.giftAmount.trim()
? Number(relativeForm.giftAmount)
: null,
recordContent: relativeForm.recordContent.trim(),
});
const timer = setTimeout(() => {
if (submitTimer !== timer) return;
localRelativePreview.value = snapshot;
editorState.value = "preview";
isSubmitting.value = false;
submitTimer = null;
}, 240);
submitTimer = timer;
return true;
};
const discardConfirmation = createDiscardConfirmation(
(visible) => { discardVisible.value = visible; },
const requestController = createRequestController();
const isDirty = computed(() => Object.values(form).some((value) => String(value).trim()));
const hasValidContext = computed(() => /^[1-9]\d*$/.test(genealogyId.value));
const resultCopy = computed(() => editorState.value === "success"
? { title: "往来记录已提交服务端", copy: "服务端已返回成功信封。列表仍缺条目 DTO,返回后不会生成本地记录。", action: "返回贺礼簿" }
: { title: "往来记录入口无效", copy: "当前只有新建请求可映射;详情和修改缺可靠条目 DTO/记录 ID 来源,页面不从 fixture 进入。", action: "返回上一页" },
);
const discardConfirmation = createDiscardConfirmation((visible) => { discardVisible.value = visible; });
const requestDiscardConfirmation = discardConfirmation.request;
const confirmDiscard = discardConfirmation.confirm;
const cancelDiscard = discardConfirmation.cancel;
const requestBack = () =>
runBackGuard({
transientOpen: discardVisible.value,
dirty: isDirty.value,
submitting: isSubmitting.value,
"close-transient": cancelDiscard,
"block-submitting": () => true,
"confirm-discard": discardConfirmation.request,
});
const returnToRelatives = () =>
genealogyId.value
? returnTo("R03", { genealogyId: genealogyId.value })
: goBack();
onBackPress((event) => handleBackPress(event, requestBack));
onUnmounted(() => {
if (submitTimer) clearTimeout(submitTimer);
discardConfirmation.dispose();
onLoad((query) => {
genealogyId.value = String(query?.genealogyId || "");
if (!hasValidContext.value || query?.mode !== "create") editorState.value = "invalid";
});
const saveRelative = async () => {
if (isSubmitting.value || !hasValidContext.value) return;
const relativeName = form.relativeName.trim();
const giftAmountText = form.giftAmount.trim();
if (!relativeName) {
submitError.value = "请填写亲友姓名";
return;
}
if (giftAmountText && !Number.isFinite(Number(giftAmountText))) {
submitError.value = "礼金金额必须是数字";
return;
}
isSubmitting.value = true;
submitError.value = "";
try {
await appApi.createRelativeRecord(genealogyId.value, {
relativeName,
relationName: form.relationName,
eventName: form.eventName,
eventTime: form.eventTime,
...(giftAmountText ? { giftAmount: Number(giftAmountText) } : {}),
recordContent: form.recordContent,
}, { requestController });
Object.keys(form).forEach((key) => { form[key] = ""; });
editorState.value = "success";
} catch (error) {
if (!isRequestCancelled(error)) submitError.value = error?.message || "往来记录提交失败,请稍后重试。";
} finally {
isSubmitting.value = false;
}
};
const requestBack = () => runBackGuard({
transientOpen: discardVisible.value,
dirty: isDirty.value,
submitting: isSubmitting.value,
"close-transient": cancelDiscard,
"block-submitting": () => true,
"confirm-discard": requestDiscardConfirmation,
});
const handleResultAction = () => editorState.value === "success"
? returnTo("R03", { genealogyId: genealogyId.value })
: goBack();
onBackPress((event) => handleBackPress(event, requestBack));
onUnmounted(() => { requestController.abort(); discardConfirmation.dispose(); });
</script>
<style scoped lang="scss">
@use "../../styles/adaptive-frame-profiles.scss" as adaptive;
.gift-editor-page {
display: flex;
min-height: 100vh;
flex-direction: column;
background: $paper;
}
.page-header,
.page-loading,
.page-content {
z-index: 1;
}
.page-loading {
min-height: calc(100vh - 100rpx);
}
.page-content {
padding: 18rpx 24rpx 72rpx;
}
.form-card,
.detail-card,
.state-card {
box-sizing: border-box;
padding: 46rpx;
@include adaptive.adaptive-records-content;
}
.form-card > text:first-child,
.detail-card > text:first-child,
.state-card > text:first-child {
display: block;
color: $ink;
font-size: 34rpx;
font-weight: 700;
}
.field-row,
.detail-card > view {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
align-items: center;
gap: 12rpx 20rpx;
min-height: 82rpx;
margin-top: 14rpx;
padding: 14rpx 24rpx;
box-sizing: border-box;
@include adaptive.adaptive-records-field;
}
.field-row > text:first-child,
.detail-card > view > text:first-child {
color: $ink;
font-size: 23rpx;
font-weight: 700;
}
.field-row input,
.detail-card > view > text:last-child {
min-width: 0;
color: $ink;
font-size: 23rpx;
text-align: right;
}
.field-row > text:last-child {
grid-column: 1/-1;
color: $brand-red;
font-size: 20rpx;
text-align: right;
}
.form-card .app-button,
.detail-card .app-button {
margin-top: 18rpx;
}
.save-error {
display: block;
margin-top: 14rpx;
color: $brand-red;
font-size: 22rpx;
}
.state-card {
min-height: 340rpx;
padding-top: 80rpx;
text-align: center;
}
.state-card > text:nth-child(2) {
display: block;
margin-top: 18rpx;
color: $ink-muted;
font-size: 24rpx;
}
.state-card .app-button {
margin-top: 28rpx;
}
.gift-editor-page { display: flex; min-height: 100vh; flex-direction: column; background: $paper; }
.page-header, .page-content { z-index: 1; }
.page-content { padding: 18rpx 24rpx 72rpx; }
.form-card, .state-card { box-sizing: border-box; padding: 46rpx; @include adaptive.adaptive-records-content; }
.form-card > text:first-child, .state-card > text:first-child { display: block; color: $ink; font-size: 34rpx; font-weight: 700; }
.form-copy { display: block; margin-top: 12rpx; color: $ink-muted; font-size: 22rpx; line-height: 1.55; }
.field-row { display: grid; grid-template-columns: auto minmax(0, 1fr); align-items: center; gap: 12rpx 20rpx; min-height: 82rpx; margin-top: 14rpx; padding: 14rpx 24rpx; box-sizing: border-box; @include adaptive.adaptive-records-field; }
.field-row > text { color: $ink; font-size: 23rpx; font-weight: 700; }
.field-row input, .field-row textarea { min-width: 0; color: $ink; font-size: 23rpx; text-align: right; }
.field-row textarea { min-height: 72rpx; text-align: left; }
.save-error { display: block; margin-top: 14rpx; color: $brand-red; font-size: 22rpx; }
.form-card .app-button { margin-top: 18rpx; }
.state-card { min-height: 340rpx; padding-top: 80rpx; text-align: center; }
.state-card > text:nth-child(2) { display: block; margin-top: 18rpx; color: $ink-muted; font-size: 24rpx; line-height: 1.65; }
.state-card .app-button { margin-top: 28rpx; }
</style>
+16 -201
View File
@@ -1,216 +1,31 @@
<!-- 页面编号R-05用途当前家谱的礼仪活动列表与本地创建预览入口 -->
<!-- 页面编号R-05用途礼仪活动入口活动列表 operation 未声明 -->
<template>
<view class="ritual-page" :class="stateClasses">
<view class="ritual-list-page">
<ModulePageBackground module="records" />
<view class="page-header">
<PageHeader
title="礼仪活动"
:action="hasValidContext ? '填写预览' : ''"
@action="createCeremonyPreview"
/>
</view>
<view v-if="ceremonyState === 'loading'" class="page-loading">
<AppLoading
text="正在整理礼仪活动"
description="请稍候,正在核对当前家谱的活动记录。"
/>
</view>
<view v-else class="page-content">
<template v-if="ceremonyState === 'ready' && ceremonies.length">
<view
v-for="ceremony in ceremonies"
:key="ceremony.ceremonyId"
class="record-card"
role="button"
:aria-label="`查看${ceremony.ceremonyTitle}`"
@click="openCeremony(ceremony)"
>
<text>{{ ceremony.ceremonyType }}</text>
<text>{{ ceremony.ceremonyTitle }}</text>
<text>
{{ ceremony.ceremonyTime }} ·
{{ ceremony.location || "地点待定" }}
</text>
<text>查看活动与受邀信息</text>
</view>
<AppButton block label="填写礼仪预览" @click="createCeremonyPreview" />
</template>
<view v-else class="state-card">
<text>{{ stateCopy.title }}</text>
<text>{{ stateCopy.copy }}</text>
<AppButton
:type="ceremonyState === 'empty' ? 'primary' : 'secondary'"
block
:label="stateCopy.action"
@click="handleStateAction"
/>
</view>
</view>
<view class="page-header"><PageHeader title="礼仪活动" custom-back @back="returnToFamily" /></view>
<view class="page-content"><view class="state-card"><text>礼仪活动列表暂未开放</text><text>Apifox 只有活动详情修改献礼和删除相关 operation没有活动列表或新建活动 owner页面不再展示本地礼仪活动</text><AppButton block :label="hasValidContext ? '返回家族动态' : '返回上一页'" @click="returnToFamily" /></view></view>
</view>
</template>
<script setup>
import { computed, ref } from "vue";
import { onLoad } from "@dcloudio/uni-app";
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 {
getGenealogyFixtureAccess,
listCeremonyFixtures,
} from "@/data/mock.js";
import { goBack, openPage } from "@/utils/navigation.js";
import { goBack, returnTo } from "@/utils/navigation.js";
const genealogyId = ref("");
const ceremonies = ref([]);
const ceremonyState = ref("loading");
const hasValidContext = computed(() =>
["ready", "empty"].includes(ceremonyState.value),
);
const stateClasses = computed(() => ({
"ceremony-state--loading": ceremonyState.value === "loading",
"ceremony-state--empty": ceremonyState.value === "empty",
"ceremony-state--error": ceremonyState.value === "error",
"ceremony-state--invalid": ceremonyState.value === "invalid",
}));
const stateCopy = computed(() => ({
error: {
title: "礼仪活动暂不可用",
copy: "请稍后重新查看,已有活动不会受到影响。",
action: "重新查看",
},
invalid: {
title: "礼仪活动入口无效",
copy: "没有找到可访问的成员家谱,页面不会展示其他家谱活动。",
action: "返回上一页",
},
empty: {
title: "还没有礼仪活动",
copy: "可以先填写一份本地预览;正式创建仍需等待线上写接口启用。",
action: "填写礼仪预览",
},
})[ceremonyState.value] || {
title: "礼仪活动暂不可用",
copy: "请返回上一页重新进入。",
action: "返回上一页",
});
onLoad((query) => {
genealogyId.value = String(query.genealogyId || "");
const access = getGenealogyFixtureAccess(genealogyId.value);
if (!["owner", "member"].includes(access.accessRole)) {
ceremonies.value = [];
ceremonyState.value = "invalid";
return;
}
ceremonies.value = listCeremonyFixtures(genealogyId.value);
ceremonyState.value = ["loading", "empty", "error"].includes(query.state)
? query.state
: ceremonies.value.length
? "ready"
: "empty";
});
const openCeremony = (ceremony) =>
openPage(
"R06",
{
genealogyId: genealogyId.value,
ceremonyId: String(ceremony.ceremonyId),
},
"R05",
);
const createCeremonyPreview = () =>
hasValidContext.value
? openPage(
"R07",
{ genealogyId: genealogyId.value, mode: "create" },
"R05",
)
: Promise.resolve(false);
const restoreCeremonies = () => {
ceremonies.value = listCeremonyFixtures(genealogyId.value);
ceremonyState.value = ceremonies.value.length ? "ready" : "empty";
};
const handleStateAction = () => {
if (ceremonyState.value === "invalid") return goBack();
if (ceremonyState.value === "error") return restoreCeremonies();
return createCeremonyPreview();
};
const hasValidContext = computed(() => /^[1-9]\d*$/.test(genealogyId.value));
onLoad((query) => { genealogyId.value = String(query?.genealogyId || ""); });
const returnToFamily = () => hasValidContext.value ? returnTo("F01", { genealogyId: genealogyId.value }) : goBack();
</script>
<style scoped lang="scss">
@use "../../styles/adaptive-frame-profiles.scss" as adaptive;
.ritual-page {
display: flex;
min-height: 100vh;
flex-direction: column;
background: $paper;
}
.page-header,
.page-loading,
.page-content {
z-index: 1;
}
.page-loading {
min-height: calc(100vh - 100rpx);
}
.page-content {
display: flex;
flex-direction: column;
gap: 16rpx;
padding: 18rpx 24rpx 72rpx;
}
.record-card,
.state-card {
box-sizing: border-box;
@include adaptive.adaptive-records-content;
}
.record-card {
min-height: 190rpx;
padding: 32rpx 46rpx;
}
.record-card > text,
.state-card > text {
display: block;
}
.record-card > text:first-child {
color: $brand-red;
font-size: 21rpx;
}
.record-card > text:nth-child(2) {
margin-top: 6rpx;
color: $ink;
font-size: 31rpx;
font-weight: 700;
}
.record-card > text:nth-child(3) {
margin-top: 9rpx;
color: $ink-muted;
font-size: 23rpx;
line-height: 1.5;
}
.record-card > text:last-child {
margin-top: 9rpx;
color: $brand-red;
font-size: 21rpx;
}
.state-card {
min-height: 340rpx;
padding: 78rpx 52rpx 50rpx;
text-align: center;
}
.state-card > text:first-child {
color: $ink;
font-size: 35rpx;
font-weight: 700;
}
.state-card > text:nth-child(2) {
margin-top: 18rpx;
color: $ink-muted;
font-size: 24rpx;
line-height: 1.65;
}
.state-card .app-button {
margin-top: 28rpx;
}
.ritual-list-page { display: flex; min-height: 100vh; flex-direction: column; background: $paper; }
.page-header, .page-content { z-index: 1; }
.page-content { padding: 18rpx 24rpx 72rpx; }
.state-card { box-sizing: border-box; min-height: 340rpx; padding: 78rpx 52rpx 50rpx; text-align: center; @include adaptive.adaptive-records-content; }
.state-card text { display: block; }
.state-card text:first-child { color: $ink; font-size: 35rpx; font-weight: 700; }
.state-card text:nth-child(2) { margin-top: 18rpx; color: $ink-muted; font-size: 24rpx; line-height: 1.65; }
.state-card .app-button { margin-top: 28rpx; }
</style>
+9 -240
View File
@@ -1,252 +1,21 @@
<!-- 页面编号R-06用途当前家谱礼仪详情受邀信息与受控状态 -->
<!-- 页面编号R-06用途礼仪详情详情响应无展示 DTO 时保持关闭 -->
<template>
<view class="ritual-detail-page" :class="stateClasses">
<ModulePageBackground module="records" />
<view class="page-header">
<PageHeader
title="礼仪详情"
:action="ceremonyState === 'ready' ? '制作预览' : ''"
custom-back
@back="requestBack"
@action="editCeremony"
/>
</view>
<view v-if="ceremonyState === 'loading'" class="page-loading">
<AppLoading
text="正在读取礼仪详情"
description="请稍候,正在核对活动身份与受邀信息。"
/>
</view>
<view v-else class="page-content">
<template v-if="ceremonyState === 'ready' && ceremonyDetail">
<view class="detail-card">
<text>{{ ceremonyDetail.ceremonyType }}</text>
<text>{{ ceremonyDetail.ceremonyTitle }}</text>
<text>
{{ ceremonyDetail.ceremonyTime }} ·
{{ ceremonyDetail.location || "地点待定" }}
</text>
<text>{{ ceremonyDetail.ceremonyDesc || "暂无活动说明" }}</text>
</view>
<view class="participant-card">
<view>
<text>受邀家人</text>
<text>{{ invitees.length }} </text>
</view>
<view v-for="invitee in invitees" :key="invitee.inviteeUserId">
<text>{{ invitee.displayName }} · {{ invitee.relationName }}</text>
<text>{{ invitee.statusText }}</text>
</view>
<view v-if="!invitees.length"><text>尚无受邀记录</text><text></text></view>
</view>
<AppButton block label="制作编辑预览" @click="editCeremony" />
</template>
<view v-else class="state-card">
<text>{{ stateCopy.title }}</text>
<text>{{ stateCopy.copy }}</text>
<AppButton
type="secondary"
block
:label="ceremonyState === 'error' ? '重新查看' : '返回礼仪列表'"
@click="ceremonyState === 'error' ? restoreCeremony() : returnToCeremonies()"
/>
</view>
</view>
</view>
<view class="ritual-detail-page"><ModulePageBackground module="records" /><view class="page-header"><PageHeader title="礼仪详情" custom-back @back="returnToList" /></view><view class="page-content"><view class="state-card"><text>{{ stateCopy.title }}</text><text>{{ stateCopy.copy }}</text><AppButton block :label="stateCopy.action" @click="returnToList" /></view></view></view>
</template>
<script setup>
import { computed, ref } from "vue";
import { onLoad } from "@dcloudio/uni-app";
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 {
findCeremonyFixture,
getGenealogyFixtureAccess,
listTreeMemberPresentationFixtures,
} from "@/data/mock.js";
import { goBack, openPage, returnTo } from "@/utils/navigation.js";
const genealogyId = ref("");
const ceremonyId = ref("");
const ceremonyDetail = ref(null);
const memberOptions = ref([]);
const ceremonyState = ref("loading");
const invitees = computed(() => {
const memberByAppUserId = new Map(
memberOptions.value.map((member) => [String(member.appUserId), member]),
);
return (ceremonyDetail.value?.invitees || []).map((invitation) => {
const member = memberByAppUserId.get(String(invitation.inviteeUserId));
return {
inviteeUserId: String(invitation.inviteeUserId),
displayName: member?.name || "受邀成员信息不可用",
relationName: member?.relation || "未完成同谱成员联接",
statusText: invitation.inviteStatus
? "受邀状态字典待后端确认"
: "受邀状态未提供",
};
});
});
const stateClasses = computed(() => ({
"ceremony-state--expired": ceremonyState.value === "expired",
"ceremony-state--error": ceremonyState.value === "error",
}));
const stateCopy = computed(
() =>
({
expired: {
title: "活动已失效",
copy: "活动不存在或不属于当前家谱,页面不会回退到其他活动。",
},
error: {
title: "礼仪详情暂不可用",
copy: "请稍后重新查看,已有活动不会受到影响。",
},
})[ceremonyState.value] || {
title: "礼仪详情暂不可用",
copy: "缺少家谱或活动身份,请返回列表重新选择。",
},
);
onLoad((query) => {
genealogyId.value = String(query.genealogyId || "");
ceremonyId.value = String(query.ceremonyId || "");
if (query.state === "loading") return;
const access = getGenealogyFixtureAccess(genealogyId.value);
if (!["owner", "member"].includes(access.accessRole) || !ceremonyId.value) {
ceremonyState.value = "expired";
return;
}
memberOptions.value = listTreeMemberPresentationFixtures(genealogyId.value);
ceremonyDetail.value = findCeremonyFixture(
genealogyId.value,
ceremonyId.value,
);
if (!ceremonyDetail.value) {
ceremonyState.value = "expired";
return;
}
ceremonyState.value = query.state === "error" ? "error" : "ready";
});
const editCeremony = () =>
ceremonyState.value === "ready" && ceremonyDetail.value
? openPage(
"R07",
{
genealogyId: genealogyId.value,
mode: "edit",
ceremonyId: ceremonyId.value,
},
"R06",
)
: Promise.resolve(false);
const restoreCeremony = () => {
ceremonyDetail.value = findCeremonyFixture(
genealogyId.value,
ceremonyId.value,
);
ceremonyState.value = ceremonyDetail.value ? "ready" : "expired";
};
const requestBack = () => goBack();
const returnToCeremonies = () =>
genealogyId.value
? returnTo("R05", { genealogyId: genealogyId.value })
: goBack();
import { goBack, returnTo } from "@/utils/navigation.js";
const genealogyId = ref(""); const ceremonyId = ref("");
const valid = computed(() => /^[1-9]\d*$/.test(genealogyId.value) && /^[1-9]\d*$/.test(ceremonyId.value));
const stateCopy = computed(() => valid.value ? { title: "礼仪详情待后端字段合同", copy: "详情接口没有声明活动类型、标题、时间、地点、说明或受邀人字段;页面不再展示本地礼仪详情。", action: "返回礼仪活动" } : { title: "礼仪入口无效", copy: "没有取得有效家谱或活动标识。", action: "返回上一页" });
onLoad((query) => { genealogyId.value = String(query?.genealogyId || ""); ceremonyId.value = String(query?.ceremonyId || ""); });
const returnToList = () => /^[1-9]\d*$/.test(genealogyId.value) ? returnTo("R05", { genealogyId: genealogyId.value }) : goBack();
</script>
<style scoped lang="scss">
@use "../../styles/adaptive-frame-profiles.scss" as adaptive;
.ritual-detail-page {
display: flex;
min-height: 100vh;
flex-direction: column;
background: $paper;
}
.page-header,
.page-loading,
.page-content {
z-index: 1;
}
.page-loading {
min-height: calc(100vh - 100rpx);
}
.page-content {
display: flex;
flex-direction: column;
gap: 16rpx;
padding: 18rpx 24rpx 72rpx;
}
.detail-card,
.participant-card,
.state-card {
box-sizing: border-box;
padding: 42rpx 46rpx;
@include adaptive.adaptive-records-content;
}
.detail-card > text {
display: block;
}
.detail-card > text:first-child {
color: $brand-red;
font-size: 21rpx;
}
.detail-card > text:nth-child(2) {
margin-top: 8rpx;
color: $ink;
font-size: 36rpx;
font-weight: 700;
}
.detail-card > text:nth-child(3) {
margin-top: 10rpx;
color: $ink-muted;
font-size: 23rpx;
}
.detail-card > text:last-child {
margin-top: 20rpx;
color: $ink;
font-size: 24rpx;
line-height: 1.7;
}
.participant-card > view {
display: flex;
flex-wrap: wrap;
justify-content: space-between;
gap: 8rpx 18rpx;
min-height: 52rpx;
align-items: center;
color: $ink;
font-size: 23rpx;
}
.participant-card > view:first-child {
color: $brand-red;
font-weight: 700;
}
.participant-card > view + view {
margin-top: 9rpx;
padding-top: 9rpx;
border-top: 1px solid rgba(136, 84, 42, 0.18);
}
.state-card {
min-height: 340rpx;
padding-top: 78rpx;
text-align: center;
}
.state-card > text {
display: block;
}
.state-card > text:first-child {
color: $ink;
font-size: 35rpx;
font-weight: 700;
}
.state-card > text:nth-child(2) {
margin-top: 18rpx;
color: $ink-muted;
font-size: 24rpx;
line-height: 1.65;
}
.state-card .app-button {
margin-top: 28rpx;
}
.ritual-detail-page { display: flex; min-height: 100vh; flex-direction: column; background: $paper; }.page-header,.page-content { z-index: 1; }.page-content { padding: 18rpx 24rpx 72rpx; }.state-card { box-sizing: border-box; min-height: 340rpx; padding: 78rpx 52rpx 50rpx; text-align: center; @include adaptive.adaptive-records-content; }.state-card text { display:block; }.state-card text:first-child { color:$ink; font-size:35rpx; font-weight:700; }.state-card text:nth-child(2) { margin-top:18rpx; color:$ink-muted; font-size:24rpx; line-height:1.65; }.state-card .app-button { margin-top:28rpx; }
</style>
+9 -311
View File
@@ -1,321 +1,19 @@
<!-- 页面编号R-07用途礼仪创建编辑校验与不写库的本地预览 -->
<!-- 页面编号R-07用途礼仪活动创建/编辑创建 owner 缺失编辑无可靠详情来源 -->
<template>
<view class="ritual-editor-page" :class="editorClasses">
<ModulePageBackground module="records" />
<view class="page-header">
<PageHeader
:title="mode === 'create' ? '礼仪预览' : '编辑预览'"
custom-back
@back="requestBack"
/>
</view>
<view v-if="editorState === 'loading'" class="page-loading">
<AppLoading
text="正在读取礼仪资料"
description="请稍候,正在核对当前家谱与活动身份。"
/>
</view>
<view v-else class="page-content">
<view v-if="editorState === 'preview'" class="state-card preview-card">
<text>本地预览尚未提交服务器</text>
<text>这份礼仪内容只存在于当前页面不会新增覆盖或删除正式活动</text>
<view v-for="item in previewRows" :key="item.label" class="preview-row">
<text>{{ item.label }}</text>
<text>{{ item.value }}</text>
</view>
<AppButton block :label="returnLabel" @click="returnAfterPreview" />
</view>
<view v-else-if="editorState === 'ready'" class="form-card">
<text>
{{ mode === "create" ? "填写一份家族礼仪预览" : "调整活动预览" }}
</text>
<view v-for="field in fields" :key="field.key" class="field-row">
<text>{{ field.label }}</text>
<textarea
v-if="field.long"
v-model="ritualForm[field.key]"
auto-height
:placeholder="`请输入${field.label}`"
/>
<input
v-else
v-model="ritualForm[field.key]"
:placeholder="`请输入${field.label}`"
/>
<text v-if="ritualErrors[field.key]">
{{ ritualErrors[field.key] }}
</text>
</view>
<AppButton
block
label="生成本地预览"
@click="createCeremonyPreview"
/>
<AppButton
type="secondary"
block
label="取消填写"
@click="requestBack"
/>
<AppButton v-if="mode === 'edit'" type="secondary" block disabled label="删除暂未开放" />
</view>
<view v-else class="state-card">
<text>礼仪活动不可用</text>
<text>活动不存在缺少身份或不属于当前家谱页面不会回退到其他活动</text>
<AppButton type="secondary" block label="返回礼仪列表" @click="returnToCeremonies" />
</view>
</view>
<AppDialog
:visible="discardVisible"
:close-on-mask="false"
eyebrow="放弃确认"
title="放弃当前填写?"
message="尚未提交的预览内容将从当前页面清除。"
confirm-text="确认放弃"
cancel-text="继续填写"
show-cancel
@confirm="confirmDiscard"
@cancel="cancelDiscard"
/>
</view>
<view class="ritual-editor-page"><ModulePageBackground module="records" /><view class="page-header"><PageHeader title="礼仪活动" custom-back @back="returnToList" /></view><view class="page-content"><view class="state-card"><text>礼仪活动维护暂未开放</text><text>当前没有新建活动 operation修改虽有 operation但没有可消费的详情 DTO 或可靠活动 ID 来源本页不再生成本地创建或编辑预览</text><AppButton block :label="hasValidContext ? '返回礼仪活动' : '返回上一页'" @click="returnToList" /></view></view></view>
</template>
<script setup>
import { computed, onUnmounted, reactive, ref } from "vue";
import { onBackPress, onLoad } from "@dcloudio/uni-app";
import { computed, ref } from "vue";
import { onLoad } from "@dcloudio/uni-app";
import AppButton from "@/components/AppButton.vue";
import AppDialog from "@/components/AppDialog.vue";
import AppLoading from "@/components/AppLoading.vue";
import ModulePageBackground from "@/components/ModulePageBackground.vue";
import PageHeader from "@/components/PageHeader.vue";
import {
findCeremonyFixture,
getGenealogyFixtureAccess,
} from "@/data/mock.js";
import { createDiscardConfirmation } from "@/utils/discard-confirmation.js";
import {
goBack,
handleBackPress,
returnTo,
runBackGuard,
} from "@/utils/navigation.js";
const genealogyId = ref("");
const ceremonyId = ref("");
const mode = ref("");
const editorState = ref("loading");
const discardVisible = ref(false);
const localCeremonyPreview = ref(null);
const ceremonyForm = reactive({
ceremonyType: "",
ceremonyTitle: "",
ceremonyTime: "",
location: "",
ceremonyDesc: "",
});
const ritualErrors = reactive({
ceremonyType: "",
ceremonyTitle: "",
ceremonyTime: "",
location: "",
ceremonyDesc: "",
});
const fields = [
{ key: "ceremonyType", label: "礼仪类型" },
{ key: "ceremonyTitle", label: "活动标题" },
{ key: "ceremonyTime", label: "活动时间" },
{ key: "location", label: "举办地点" },
{ key: "ceremonyDesc", label: "活动说明", long: true },
];
const baseline = ref("");
const editorClasses = computed(() => ({
"ceremony-editor-state--preview": editorState.value === "preview",
"ceremony-editor-state--invalid": editorState.value === "invalid",
}));
const formSnapshot = computed(() => JSON.stringify({ ...ceremonyForm }));
const isDirty = computed(() =>
editorState.value === "preview" ||
(["create", "edit"].includes(mode.value) && formSnapshot.value !== baseline.value),
);
const previewRows = computed(() =>
fields.map((field) => ({
label: field.label,
value: localCeremonyPreview.value?.[field.key] || "未填写",
})),
);
const returnLabel = computed(() =>
mode.value === "edit" ? "返回礼仪详情" : "返回礼仪列表",
);
const copyCeremonyToForm = (record) => {
Object.assign(ceremonyForm, {
ceremonyType: record.ceremonyType,
ceremonyTitle: record.ceremonyTitle,
ceremonyTime: record.ceremonyTime,
location: record.location,
ceremonyDesc: record.ceremonyDesc,
});
baseline.value = formSnapshot.value;
};
onLoad((query) => {
genealogyId.value = String(query.genealogyId || "");
ceremonyId.value = String(query.ceremonyId || "");
mode.value = String(query.mode || "");
if (query.state === "loading") return;
const access = getGenealogyFixtureAccess(genealogyId.value);
const hasValidGenealogy = ["owner", "member"].includes(access.accessRole);
const isCreateContract = mode.value === "create" && !ceremonyId.value;
const isEditContract = mode.value === "edit" && Boolean(ceremonyId.value);
if (!hasValidGenealogy || (!isCreateContract && !isEditContract)) {
editorState.value = "invalid";
return;
}
if (isCreateContract) {
baseline.value = formSnapshot.value;
editorState.value = "ready";
return;
}
const selected = findCeremonyFixture(genealogyId.value, ceremonyId.value);
if (!selected) {
editorState.value = "invalid";
return;
}
copyCeremonyToForm(selected);
editorState.value = "ready";
});
const validateCeremony = () => {
ritualErrors.ceremonyType = ceremonyForm.ceremonyType.trim()
? ""
: "请填写礼仪类型";
ritualErrors.ceremonyTitle = ceremonyForm.ceremonyTitle.trim()
? ""
: "请填写活动标题";
return fields.every((field) => !ritualErrors[field.key]);
};
const createCeremonyPreview = () => {
if (!validateCeremony()) return false;
// 线上写接口的枚举、成员权限与错误语义尚未形成完整合同,因此这里只生成
// 与表单分离的不可变快照;后续接入真实写接口时由该入口唯一替换。
localCeremonyPreview.value = Object.freeze({
ceremonyType: ceremonyForm.ceremonyType.trim(),
ceremonyTitle: ceremonyForm.ceremonyTitle.trim(),
ceremonyTime: ceremonyForm.ceremonyTime.trim(),
location: ceremonyForm.location.trim(),
ceremonyDesc: ceremonyForm.ceremonyDesc.trim(),
});
editorState.value = "preview";
return true;
};
const discardConfirmation = createDiscardConfirmation(
(visible) => { discardVisible.value = visible; },
);
const confirmDiscard = discardConfirmation.confirm;
const cancelDiscard = discardConfirmation.cancel;
const requestBack = () =>
runBackGuard({
transientOpen: discardVisible.value,
dirty: isDirty.value,
"close-transient": cancelDiscard,
"confirm-discard": discardConfirmation.request,
});
const returnToCeremonies = () =>
genealogyId.value
? returnTo("R05", { genealogyId: genealogyId.value })
: goBack();
const returnAfterPreview = () =>
mode.value === "edit" && ceremonyId.value
? returnTo("R06", {
genealogyId: genealogyId.value,
ceremonyId: ceremonyId.value,
})
: returnToCeremonies();
onBackPress((event) => handleBackPress(event, requestBack));
onUnmounted(() => {
discardConfirmation.dispose();
});
import { goBack, returnTo } from "@/utils/navigation.js";
const genealogyId = ref(""); const hasValidContext = computed(() => /^[1-9]\d*$/.test(genealogyId.value));
onLoad((query) => { genealogyId.value = String(query?.genealogyId || ""); });
const returnToList = () => hasValidContext.value ? returnTo("R05", { genealogyId: genealogyId.value }) : goBack();
</script>
<style scoped lang="scss">
@use "../../styles/adaptive-frame-profiles.scss" as adaptive;
.ritual-editor-page {
display: flex;
min-height: 100vh;
flex-direction: column;
background: $paper;
}
.page-header,
.page-content {
z-index: 1;
}
.page-content {
padding: 18rpx 24rpx 72rpx;
}
.form-card,
.state-card {
box-sizing: border-box;
padding: 46rpx;
@include adaptive.adaptive-records-content;
}
.form-card > text:first-child,
.state-card > text:first-child {
display: block;
color: $ink;
font-size: 34rpx;
font-weight: 700;
}
.field-row {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
align-items: center;
gap: 10rpx 18rpx;
min-height: 82rpx;
margin-top: 14rpx;
padding: 14rpx 24rpx;
box-sizing: border-box;
@include adaptive.adaptive-records-field;
}
.field-row > text:first-child {
color: $ink;
font-size: 23rpx;
font-weight: 700;
}
.field-row input,
.field-row textarea {
width: auto;
min-width: 0;
color: $ink;
font-size: 23rpx;
text-align: right;
}
.field-row textarea {
min-height: 76rpx;
line-height: 1.5;
}
.field-row > text:last-child {
grid-column: 1/-1;
color: $brand-red;
font-size: 20rpx;
text-align: right;
}
.form-card .app-button {
margin-top: 18rpx;
}
.save-error {
display: block;
margin-top: 14rpx;
color: $brand-red;
font-size: 22rpx;
}
.state-card {
min-height: 340rpx;
padding-top: 80rpx;
text-align: center;
}
.state-card > text:nth-child(2) {
display: block;
margin-top: 18rpx;
color: $ink-muted;
font-size: 24rpx;
}
.state-card .app-button {
margin-top: 28rpx;
}
.ritual-editor-page { display:flex; min-height:100vh; flex-direction:column; background:$paper; }.page-header,.page-content { z-index:1; }.page-content { padding:18rpx 24rpx 72rpx; }.state-card { box-sizing:border-box; min-height:340rpx; padding:78rpx 52rpx 50rpx; text-align:center; @include adaptive.adaptive-records-content; }.state-card text { display:block; }.state-card text:first-child { color:$ink; font-size:35rpx; font-weight:700; }.state-card text:nth-child(2) { margin-top:18rpx; color:$ink-muted; font-size:24rpx; line-height:1.65; }.state-card .app-button { margin-top:28rpx; }
</style>
+13 -398
View File
@@ -1,407 +1,22 @@
<!-- 页面编号R-08用途当前家谱人物的成长日志与不写库的本地预览 -->
<!-- 页面编号R-08用途成长记录创建列表 DTO 未声明创建仅提交可映射字段 -->
<template>
<view class="timeline-page" :class="stateClasses">
<ModulePageBackground module="records" />
<view class="page-header">
<PageHeader
title="成长日志"
:action="hasValidContext ? '记录预览' : ''"
custom-back
@back="requestBack"
@action="recordGrowth"
/>
</view>
<view v-if="timelineState === 'loading'" class="page-loading">
<AppLoading
text="正在读取成长日志"
description="请稍候,正在核对当前家谱与人物身份。"
/>
</view>
<view v-else class="page-content">
<view v-if="memberRecord" class="person-lead">
<text>{{ memberRecord.name }}</text>
<text>成长中的每一个瞬间</text>
</view>
<view v-if="localGrowthPreview" class="preview-card">
<text>本地预览 · 尚未提交</text>
<text>{{ localGrowthPreview.recordTitle }}</text>
<text>{{ localGrowthPreview.recordDate || "日期未填写" }}</text>
<text>{{ localGrowthPreview.recordContent || "内容未填写" }}</text>
</view>
<template v-if="timelineState === 'ready' && growthRecords.length">
<view
v-for="(record, index) in growthRecords"
:key="record.recordId"
class="timeline-card"
>
<text> {{ growthRecords.length - index }} </text>
<text>{{ record.recordTitle }}</text>
<text>{{ record.recordDate || "日期未填写" }}</text>
<text>{{ record.recordContent || "内容未填写" }}</text>
</view>
<AppButton block label="记录成长预览" @click="recordGrowth" />
</template>
<view v-else class="state-card">
<text>{{ stateCopy.title }}</text>
<text>{{ stateCopy.copy }}</text>
<AppButton
:type="timelineState === 'empty' ? 'primary' : 'secondary'"
block
:label="stateCopy.action"
@click="handleStateAction"
/>
</view>
</view>
<AppDialog
:visible="dialogVisible"
:close-on-mask="false"
eyebrow="成长日志"
title="填写一份成长预览"
confirm-text="生成预览"
cancel-text="取消"
show-cancel
@confirm="createGrowthPreview"
@cancel="requestCloseEditor"
>
<view class="dialog-form">
<input v-model="growthForm.recordTitle" placeholder="记录标题" />
<input v-model="growthForm.recordDate" placeholder="日期(选填)" />
<textarea
v-model="growthForm.recordContent"
auto-height
placeholder="写下当时的故事(选填)"
/>
<text v-if="formError">{{ formError }}</text>
</view>
</AppDialog>
<AppDialog
:visible="discardVisible"
:close-on-mask="false"
eyebrow="放弃确认"
title="放弃当前填写?"
message="尚未提交的预览内容将从当前页面清除。"
confirm-text="确认放弃"
cancel-text="继续填写"
show-cancel
@confirm="confirmDiscard"
@cancel="cancelDiscard"
/>
<AppToast :visible="toastVisible" message="已生成本地预览,尚未保存" />
</view>
<view class="record-page"><ModulePageBackground module="records" /><view class="page-header"><PageHeader title="成长日志" custom-back @back="requestBack" /></view><view class="page-content"><view v-if="state === 'form'" class="form-card"><text>新建成长记录</text><text class="form-copy">列表和详情没有展示 DTO;本页只提交标题、日期和内容,不猜测人物绑定、类型、提醒或媒体字段。</text><view class="field"><text>记录标题</text><input v-model="form.title" maxlength="40" placeholder="请输入记录标题" @input="error = ''" /></view><view class="field"><text>记录日期</text><input v-model="form.date" placeholder="例如:2026-07-24" @input="error = ''" /></view><view class="field"><text>记录内容</text><textarea v-model="form.content" auto-height maxlength="1200" placeholder="记录成长片段" @input="error = ''" /></view><text v-if="error" class="error">{{ error }}</text><AppButton block :disabled="submitting" :label="submitting ? '正在提交' : '提交成长记录'" @click="submit" /></view><view v-else class="state-card"><text>{{ result.title }}</text><text>{{ result.copy }}</text><AppButton block :label="result.action" @click="resultAction" /></view></view><AppDialog :visible="discardVisible" title="放弃成长记录?" message="尚未提交的内容将被清除。" confirm-text="放弃并返回" cancel-text="继续填写" show-cancel :close-on-mask="false" @confirm="confirmDiscard" @cancel="cancelDiscard" /></view>
</template>
<script setup>
import { computed, onUnmounted, reactive, ref } from "vue";
import { onBackPress, onLoad } from "@dcloudio/uni-app";
import AppButton from "@/components/AppButton.vue";
import AppDialog from "@/components/AppDialog.vue";
import AppLoading from "@/components/AppLoading.vue";
import AppToast from "@/components/AppToast.vue";
import ModulePageBackground from "@/components/ModulePageBackground.vue";
import PageHeader from "@/components/PageHeader.vue";
import {
findTreeMemberPresentationFixture,
getGenealogyFixtureAccess,
listGrowthRecordFixtures,
} from "@/data/mock.js";
import { createDiscardConfirmation } from "@/utils/discard-confirmation.js";
import { goBack, handleBackPress, runBackGuard } from "@/utils/navigation.js";
const genealogyId = ref("");
const personId = ref("");
const memberRecord = ref(null);
const growthRecords = ref([]);
const timelineState = ref("loading");
const dialogVisible = ref(false);
const discardVisible = ref(false);
const toastVisible = ref(false);
const formError = ref("");
const localGrowthPreview = ref(null);
const growthForm = reactive({
recordTitle: "",
recordDate: "",
recordContent: "",
});
const editorBaseline = ref("");
let timer = null;
const stateClasses = computed(() => ({
"timeline-state--loading": timelineState.value === "loading",
"timeline-state--empty": timelineState.value === "empty",
"timeline-state--error": timelineState.value === "error",
"timeline-state--privacy": timelineState.value === "privacy",
"timeline-state--invalid": timelineState.value === "invalid",
}));
const hasValidContext = computed(() =>
["ready", "empty"].includes(timelineState.value),
);
const formSnapshot = computed(() => JSON.stringify({ ...growthForm }));
const growthDraftDirty = computed(() =>
dialogVisible.value && formSnapshot.value !== editorBaseline.value,
);
const stateCopy = computed(() => ({
error: {
title: "成长日志暂不可用",
copy: "请稍后重新查看,已有记录不会受到影响。",
action: "重新查看",
},
privacy: {
title: "成长日志未公开",
copy: "当前人物资料受隐私设置保护,页面不会展示或填写成长记录。",
action: "返回上一页",
},
invalid: {
title: "成长日志入口无效",
copy: "人物不存在、缺少身份或不属于当前家谱。",
action: "返回上一页",
},
empty: {
title: "还没有成长记录",
copy: "可以先生成一份本地预览;正式创建仍需等待线上写接口启用。",
action: "记录成长预览",
},
})[timelineState.value] || {
title: "成长日志暂不可用",
copy: "请返回上一页重新进入。",
action: "返回上一页",
});
onLoad((query) => {
genealogyId.value = String(query.genealogyId || "");
personId.value = String(query.personId || "");
if (query.state === "loading") return;
const access = getGenealogyFixtureAccess(genealogyId.value);
memberRecord.value = findTreeMemberPresentationFixture(
genealogyId.value,
personId.value,
);
if (
!["owner", "member"].includes(access.accessRole) ||
!memberRecord.value
) {
timelineState.value = "invalid";
return;
}
if (["privacy", "forbidden"].includes(memberRecord.value.status)) {
timelineState.value = "privacy";
return;
}
growthRecords.value = listGrowthRecordFixtures(
genealogyId.value,
personId.value,
);
timelineState.value = ["empty", "error"].includes(query.state)
? query.state
: growthRecords.value.length
? "ready"
: "empty";
});
const recordGrowth = () => {
if (!hasValidContext.value) return false;
Object.assign(growthForm, {
recordTitle: "",
recordDate: "",
recordContent: "",
});
formError.value = "";
editorBaseline.value = formSnapshot.value;
dialogVisible.value = true;
return true;
};
const createGrowthPreview = () => {
formError.value = growthForm.recordTitle.trim() ? "" : "请填写记录标题";
if (formError.value) return false;
// 预览与正式列表分离:这里不生成服务端 ID,也不改写只读夹具。
localGrowthPreview.value = Object.freeze({
recordTitle: growthForm.recordTitle.trim(),
recordDate: growthForm.recordDate.trim(),
recordContent: growthForm.recordContent.trim(),
});
dialogVisible.value = false;
toastVisible.value = true;
if (timer) clearTimeout(timer);
timer = setTimeout(() => {
toastVisible.value = false;
timer = null;
}, 1800);
return true;
};
const closeEditor = () => {
dialogVisible.value = false;
formError.value = "";
};
const discardConfirmation = createDiscardConfirmation(
(visible) => { discardVisible.value = visible; },
);
const confirmDiscard = () => {
discardConfirmation.confirm();
closeEditor();
};
const cancelDiscard = discardConfirmation.cancel;
const requestCloseEditor = async () => {
if (!growthDraftDirty.value) {
closeEditor();
return true;
}
const confirmed = await discardConfirmation.request();
if (confirmed) closeEditor();
return confirmed;
};
const restoreGrowthRecords = () => {
growthRecords.value = listGrowthRecordFixtures(
genealogyId.value,
personId.value,
);
timelineState.value = growthRecords.value.length ? "ready" : "empty";
};
const handleStateAction = () => {
if (["invalid", "privacy"].includes(timelineState.value)) return goBack();
if (timelineState.value === "error") return restoreGrowthRecords();
return recordGrowth();
};
const requestBack = () => {
if (discardVisible.value) {
cancelDiscard();
return Promise.resolve(true);
}
if (dialogVisible.value) return requestCloseEditor();
return runBackGuard({
dirty: Boolean(localGrowthPreview.value),
"confirm-discard": discardConfirmation.request,
});
};
onBackPress((event) => handleBackPress(event, requestBack));
onUnmounted(() => {
if (timer) clearTimeout(timer);
discardConfirmation.dispose();
});
import AppButton from "@/components/AppButton.vue"; import AppDialog from "@/components/AppDialog.vue"; import ModulePageBackground from "@/components/ModulePageBackground.vue"; import PageHeader from "@/components/PageHeader.vue";
import { appApi, createRequestController, isRequestCancelled } from "@/utils/api.js"; import { createDiscardConfirmation } from "@/utils/discard-confirmation.js"; import { goBack, handleBackPress, returnTo, runBackGuard } from "@/utils/navigation.js";
const genealogyId = ref(""); const state = ref("form"); const submitting = ref(false); const error = ref(""); const discardVisible = ref(false); const form = reactive({ title: "", date: "", content: "" }); const controller = createRequestController();
const valid = computed(() => /^[1-9]\d*$/.test(genealogyId.value)); const dirty = computed(() => Object.values(form).some((value) => value.trim()));
const result = computed(() => state.value === "success" ? { title: "成长记录已提交服务端", copy: "服务端已返回成功信封;列表仍缺条目 DTO,不生成本地记录。", action: "返回记录首页" } : { title: "成长日志入口无效", copy: "没有取得有效家谱标识。", action: "返回上一页" });
const confirmation = createDiscardConfirmation((visible) => { discardVisible.value = visible; }); const confirmDiscard = confirmation.confirm; const cancelDiscard = confirmation.cancel;
onLoad((query) => { genealogyId.value = String(query?.genealogyId || ""); if (!valid.value) state.value = "invalid"; });
const submit = async () => { if (submitting.value || !valid.value) return; const recordTitle = form.title.trim(); if (!recordTitle) { error.value = "请填写记录标题"; return; } submitting.value = true; error.value = ""; try { await appApi.createGrowthRecord(genealogyId.value, { recordTitle, recordDate: form.date, recordContent: form.content }, { requestController: controller }); Object.keys(form).forEach((key) => { form[key] = ""; }); state.value = "success"; } catch (cause) { if (!isRequestCancelled(cause)) error.value = cause?.message || "成长记录提交失败,请稍后重试。"; } finally { submitting.value = false; } };
const requestBack = () => runBackGuard({ transientOpen: discardVisible.value, dirty: dirty.value, submitting: submitting.value, "close-transient": cancelDiscard, "block-submitting": () => true, "confirm-discard": confirmation.request }); const resultAction = () => state.value === "success" ? returnTo("R08", { genealogyId: genealogyId.value }) : goBack();
onBackPress((event) => handleBackPress(event, requestBack)); onUnmounted(() => { controller.abort(); confirmation.dispose(); });
</script>
<style scoped lang="scss">
@use "../../styles/adaptive-frame-profiles.scss" as adaptive;
.timeline-page {
display: flex;
min-height: 100vh;
flex-direction: column;
background: $paper;
}
.page-header,
.page-loading,
.page-content {
z-index: 1;
}
.page-loading {
min-height: calc(100vh - 100rpx);
}
.page-content {
display: flex;
flex-direction: column;
gap: 16rpx;
padding: 18rpx 24rpx 72rpx;
}
.person-lead {
display: flex;
flex-wrap: wrap;
justify-content: space-between;
gap: 8rpx 18rpx;
min-height: 62rpx;
align-items: center;
padding: 0 20rpx;
color: $ink-muted;
font-size: 22rpx;
background: url("/static/assets/modules/genealogy/transparent/section-divider.png")
center/100% auto no-repeat;
}
.person-lead text:first-child {
color: $brand-red;
font-weight: 700;
}
.timeline-card,
.preview-card,
.state-card {
box-sizing: border-box;
padding: 34rpx 46rpx;
@include adaptive.adaptive-records-content;
}
.timeline-card > text,
.preview-card > text,
.state-card > text {
display: block;
}
.timeline-card > text:first-child {
color: $brand-red;
font-size: 20rpx;
}
.timeline-card > text:nth-child(2) {
margin-top: 6rpx;
color: $ink;
font-size: 31rpx;
font-weight: 700;
}
.timeline-card > text:nth-child(3) {
margin-top: 8rpx;
color: $ink-muted;
font-size: 21rpx;
}
.timeline-card > text:last-child {
margin-top: 10rpx;
color: $ink;
font-size: 23rpx;
line-height: 1.55;
}
.preview-card > text:first-child {
color: $brand-red;
font-size: 21rpx;
font-weight: 700;
}
.preview-card > text:nth-child(2) {
margin-top: 8rpx;
color: $ink;
font-size: 30rpx;
font-weight: 700;
}
.preview-card > text:nth-child(n + 3) {
margin-top: 8rpx;
color: $ink-muted;
font-size: 22rpx;
line-height: 1.55;
}
.state-card {
min-height: 340rpx;
padding-top: 78rpx;
text-align: center;
}
.state-card > text:first-child {
color: $ink;
font-size: 35rpx;
font-weight: 700;
}
.state-card > text:nth-child(2) {
margin-top: 18rpx;
color: $ink-muted;
font-size: 24rpx;
line-height: 1.65;
}
.state-card .app-button {
margin-top: 28rpx;
}
.dialog-form {
width: 100%;
margin: 18rpx 0;
}
.dialog-form input,
.dialog-form textarea {
width: 100%;
min-height: 70rpx;
margin-top: 10rpx;
padding: 14rpx 20rpx;
box-sizing: border-box;
color: $ink;
font-size: 23rpx;
@include adaptive.adaptive-records-field;
}
.dialog-form text {
display: block;
margin-top: 8rpx;
color: $brand-red;
font-size: 20rpx;
}
.record-page{display:flex;min-height:100vh;flex-direction:column;background:$paper}.page-header,.page-content{z-index:1}.page-content{padding:18rpx 24rpx 72rpx}.form-card,.state-card{box-sizing:border-box;padding:46rpx;@include adaptive.adaptive-records-content}.form-card>text:first-child,.state-card>text:first-child{display:block;color:$ink;font-size:34rpx;font-weight:700}.form-copy{display:block;margin-top:12rpx;color:$ink-muted;font-size:22rpx;line-height:1.55}.field{margin-top:16rpx}.field>text{display:block;margin:0 8rpx 8rpx;color:$ink;font-size:23rpx;font-weight:700}.field input,.field textarea{@include adaptive.adaptive-records-field;box-sizing:border-box;width:100%;min-height:76rpx;padding:16rpx 22rpx;color:$ink;font-size:23rpx}.field textarea{min-height:150rpx}.error{display:block;margin-top:12rpx;color:$brand-red;font-size:22rpx}.form-card .app-button{margin-top:20rpx}.state-card{min-height:340rpx;padding-top:80rpx;text-align:center}.state-card>text:nth-child(2){display:block;margin-top:18rpx;color:$ink-muted;font-size:24rpx;line-height:1.65}.state-card .app-button{margin-top:28rpx}
</style>
+4 -105
View File
@@ -1,112 +1,11 @@
<!-- 页面编号R-09用途校验人物身份并明确关闭缺失的线上服务 -->
<!-- 页面编号R-09用途人生大事当前没有独立业务 operation -->
<template>
<view class="service-page" :class="`service-state--${serviceState}`">
<ModulePageBackground module="records" />
<view class="page-header">
<PageHeader title="人生事" custom-back @back="requestBack" />
</view>
<view v-if="serviceState === 'loading'" class="page-loading">
<AppLoading
text="正在核对人物身份"
description="请稍候,页面正在确认当前家谱与人物。"
/>
</view>
<view v-else class="page-content">
<view class="state-card">
<text>{{ stateCopy.title }}</text>
<text v-if="personRecord && serviceState === 'unavailable'" class="person-name">
当前人物{{ personRecord.name }}
</text>
<text>{{ stateCopy.copy }}</text>
<AppButton type="secondary" block label="返回上一页" @click="requestBack" />
</view>
</view>
</view>
<view class="life-page"><ModulePageBackground module="records" /><view class="page-header"><PageHeader title="人生大事" custom-back @back="returnToRecords" /></view><view class="page-content"><view class="state-card"><text>人生大事暂未开放</text><text>已在 Apifox APP/PC 目录检索人生life没有独立人生事件读取或写入 operation页面不借成长备忘人物资料或参考项目伪造保存</text><AppButton block :label="hasValidContext ? '返回记录首页' : '返回上一页'" @click="returnToRecords" /></view></view></view>
</template>
<script setup>
import { computed, ref } from "vue";
import { onBackPress, onLoad } from "@dcloudio/uni-app";
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 {
findTreeMemberPresentationFixture,
getGenealogyFixtureAccess,
} from "@/data/mock.js";
import { goBack, handleBackPress } from "@/utils/navigation.js";
const serviceState = ref("loading");
const personRecord = ref(null);
const stateCopy = computed(() =>
serviceState.value === "unavailable"
? {
title: "人生事件接口尚未开放",
copy: "线上接口文档没有独立的人生事件资源。为避免把其他记录类型冒充人生事,本页暂不展示或提交数据。",
}
: {
title: "人生事入口无效",
copy: "人物不存在、缺少身份或不属于当前家谱,页面不会回退到其他人物。",
},
);
onLoad((query) => {
const genealogyId = String(query.genealogyId || "");
const personId = String(query.personId || "");
if (query.state === "loading") return;
const access = getGenealogyFixtureAccess(genealogyId);
personRecord.value = findTreeMemberPresentationFixture(genealogyId, personId);
serviceState.value =
["owner", "member"].includes(access.accessRole) && personRecord.value
? "unavailable"
: "invalid";
});
const requestBack = () => goBack();
onBackPress((event) => handleBackPress(event, requestBack));
import { computed,ref } from "vue"; import { onLoad } from "@dcloudio/uni-app"; import AppButton from "@/components/AppButton.vue"; import ModulePageBackground from "@/components/ModulePageBackground.vue"; import PageHeader from "@/components/PageHeader.vue"; import { goBack,returnTo } from "@/utils/navigation.js"; const genealogyId=ref("");const hasValidContext=computed(()=>/^[1-9]\d*$/.test(genealogyId.value));onLoad((query)=>{genealogyId.value=String(query?.genealogyId||"");});const returnToRecords=()=>hasValidContext.value?returnTo("R01",{genealogyId:genealogyId.value}):goBack();
</script>
<style scoped lang="scss">
@use "../../styles/adaptive-frame-profiles.scss" as adaptive;
.service-page {
display: flex;
min-height: 100vh;
flex-direction: column;
background: $paper;
}
.page-header,
.page-loading,
.page-content {
z-index: 1;
}
.page-loading {
min-height: calc(100vh - 100rpx);
}
.page-content {
padding: 18rpx 24rpx 72rpx;
}
.state-card {
box-sizing: border-box;
min-height: 380rpx;
padding: 78rpx 52rpx 50rpx;
@include adaptive.adaptive-records-content;
text-align: center;
}
.state-card > text { display: block; }
.state-card > text:first-child {
color: $ink;
font-size: 35rpx;
font-weight: 700;
}
.state-card > text:last-of-type {
margin-top: 18rpx;
color: $ink-muted;
font-size: 24rpx;
line-height: 1.65;
}
.person-name {
color: $brand-red;
font-weight: 700;
}
.state-card .app-button {
margin-top: 28rpx;
}
.life-page{display:flex;min-height:100vh;flex-direction:column;background:$paper}.page-header,.page-content{z-index:1}.page-content{padding:18rpx 24rpx 72rpx}.state-card{box-sizing:border-box;min-height:340rpx;padding:78rpx 52rpx 50rpx;text-align:center;@include adaptive.adaptive-records-content}.state-card text{display:block}.state-card text:first-child{color:$ink;font-size:35rpx;font-weight:700}.state-card text:nth-child(2){margin-top:18rpx;color:$ink-muted;font-size:24rpx;line-height:1.65}.state-card .app-button{margin-top:28rpx}
</style>
+6 -244
View File
@@ -1,251 +1,13 @@
<!-- 页面编号R-10用途当前家谱备忘列表与不写库的本地预览 -->
<!-- 页面编号R-10用途家族备忘创建列表 DTO 缺失时不展示 fixture -->
<template>
<view class="memo-page" :class="stateClasses">
<ModulePageBackground module="records" />
<view class="page-header">
<PageHeader
title="家族备忘"
:action="hasValidContext ? '填写预览' : ''"
custom-back
@back="requestBack"
@action="startMemoPreview"
/>
</view>
<view v-if="memoState === 'loading'" class="page-loading">
<AppLoading text="正在读取家族备忘" description="请稍候,正在核对当前家谱的备忘记录。" />
</view>
<view v-else class="page-content">
<view v-if="localMemoPreview" class="preview-card">
<text>本地预览 · 尚未提交</text>
<text>{{ localMemoPreview.memoTitle }}</text>
<text>{{ localMemoPreview.remindTime || "提醒时间未填写" }}</text>
<text>{{ localMemoPreview.memoContent || "内容未填写" }}</text>
</view>
<template v-if="memoState === 'ready' && memos.length">
<view v-for="memo in memos" :key="memo.memoId" class="memo-card">
<view>
<text>{{ memo.completedLabel }}</text>
<text>{{ memo.remindTime || "未设置提醒" }}</text>
</view>
<text>{{ memo.memoTitle }}</text>
<text>{{ memo.memoContent || "暂无备忘内容" }}</text>
<text>状态仅展示线上切换接口尚未确认</text>
</view>
<AppButton block label="填写备忘预览" @click="startMemoPreview" />
</template>
<view v-else class="state-card">
<text>{{ stateCopy.title }}</text>
<text>{{ stateCopy.copy }}</text>
<AppButton
:type="memoState === 'empty' ? 'primary' : 'secondary'"
block
:label="stateCopy.action"
@click="handleStateAction"
/>
</view>
</view>
<AppDialog
:visible="dialogVisible"
:close-on-mask="false"
eyebrow="家族备忘"
title="填写一份备忘预览"
confirm-text="生成预览"
cancel-text="取消"
show-cancel
@confirm="createMemoPreview"
@cancel="requestCloseEditor"
>
<view class="dialog-form">
<input v-model="memoForm.memoTitle" placeholder="备忘标题" />
<input v-model="memoForm.remindTime" placeholder="提醒时间(选填)" />
<textarea v-model="memoForm.memoContent" auto-height placeholder="补充具体事项(选填)" />
<text v-if="formError">{{ formError }}</text>
</view>
</AppDialog>
<AppDialog
:visible="discardVisible"
:close-on-mask="false"
eyebrow="放弃确认"
title="放弃当前填写?"
message="尚未提交的预览内容将从当前页面清除。"
confirm-text="确认放弃"
cancel-text="继续填写"
show-cancel
@confirm="confirmDiscard"
@cancel="cancelDiscard"
/>
<AppToast :visible="toastVisible" message="已生成本地预览,尚未保存" />
</view>
<view class="memo-page"><ModulePageBackground module="records" /><view class="page-header"><PageHeader title="家族备忘" custom-back @back="requestBack" /></view><view class="page-content"><view v-if="state === 'form'" class="form-card"><text>新建家族备忘</text><text class="form-copy">列表和详情没有可消费字段;本页仅提交标题、提醒时间和内容,不猜测完成状态或媒体。</text><view class="field"><text>备忘标题</text><input v-model="form.title" maxlength="40" placeholder="请输入备忘标题" @input="error = ''" /></view><view class="field"><text>提醒时间</text><input v-model="form.time" placeholder="例如:2026-07-24 09:00" @input="error = ''" /></view><view class="field"><text>备忘内容</text><textarea v-model="form.content" auto-height maxlength="1200" placeholder="记录需要提醒的事情" @input="error = ''" /></view><text v-if="error" class="error">{{ error }}</text><AppButton block :disabled="submitting" :label="submitting ? '正在提交' : '提交备忘'" @click="submit" /></view><view v-else class="state-card"><text>{{ result.title }}</text><text>{{ result.copy }}</text><AppButton block :label="result.action" @click="resultAction" /></view></view><AppDialog :visible="discardVisible" title="放弃家族备忘?" message="尚未提交的内容将被清除。" confirm-text="放弃并返回" cancel-text="继续填写" show-cancel :close-on-mask="false" @confirm="confirmDiscard" @cancel="cancelDiscard" /></view>
</template>
<script setup>
import { computed, onUnmounted, reactive, ref } from "vue";
import { onBackPress, onLoad } from "@dcloudio/uni-app";
import AppButton from "@/components/AppButton.vue";
import AppDialog from "@/components/AppDialog.vue";
import AppLoading from "@/components/AppLoading.vue";
import AppToast from "@/components/AppToast.vue";
import ModulePageBackground from "@/components/ModulePageBackground.vue";
import PageHeader from "@/components/PageHeader.vue";
import {
getGenealogyFixtureAccess,
listMemoFixtures,
} from "@/data/mock.js";
import { createDiscardConfirmation } from "@/utils/discard-confirmation.js";
import { goBack, handleBackPress, runBackGuard } from "@/utils/navigation.js";
const genealogyId = ref("");
const memos = ref([]);
const memoState = ref("loading");
const dialogVisible = ref(false);
const discardVisible = ref(false);
const toastVisible = ref(false);
const formError = ref("");
const localMemoPreview = ref(null);
const memoForm = reactive({ memoTitle: "", remindTime: "", memoContent: "" });
const editorBaseline = ref("");
let timer = null;
const stateClasses = computed(() => ({
"memo-state--loading": memoState.value === "loading",
"memo-state--empty": memoState.value === "empty",
"memo-state--error": memoState.value === "error",
"memo-state--invalid": memoState.value === "invalid",
}));
const hasValidContext = computed(() => ["ready", "empty"].includes(memoState.value));
const formSnapshot = computed(() => JSON.stringify({ ...memoForm }));
const memoDraftDirty = computed(() =>
dialogVisible.value && formSnapshot.value !== editorBaseline.value,
);
const stateCopy = computed(() => ({
error: {
title: "家族备忘暂不可用",
copy: "请稍后重新查看,已有备忘不会受到影响。",
action: "重新查看",
},
invalid: {
title: "家族备忘入口无效",
copy: "没有找到可访问的成员家谱,页面不会展示其他家谱备忘。",
action: "返回上一页",
},
empty: {
title: "还没有备忘",
copy: "可以先生成一份本地预览;正式创建仍需等待线上写接口启用。",
action: "填写备忘预览",
},
})[memoState.value] || {
title: "家族备忘暂不可用",
copy: "请返回上一页重新进入。",
action: "返回上一页",
});
onLoad((query) => {
genealogyId.value = String(query.genealogyId || "");
const access = getGenealogyFixtureAccess(genealogyId.value);
if (!["owner", "member"].includes(access.accessRole)) {
memoState.value = "invalid";
return;
}
memos.value = listMemoFixtures(genealogyId.value);
memoState.value = ["loading", "empty", "error"].includes(query.state)
? query.state
: memos.value.length
? "ready"
: "empty";
});
const startMemoPreview = () => {
if (!hasValidContext.value) return false;
Object.assign(memoForm, { memoTitle: "", remindTime: "", memoContent: "" });
formError.value = "";
editorBaseline.value = formSnapshot.value;
dialogVisible.value = true;
return true;
};
const createMemoPreview = () => {
formError.value = memoForm.memoTitle.trim() ? "" : "请填写备忘标题";
if (formError.value) return false;
// 正式列表来自只读选择器;预览不生成 ID,也不会改变完成状态或记录数量。
localMemoPreview.value = Object.freeze({
memoTitle: memoForm.memoTitle.trim(),
remindTime: memoForm.remindTime.trim(),
memoContent: memoForm.memoContent.trim(),
});
dialogVisible.value = false;
toastVisible.value = true;
if (timer) clearTimeout(timer);
timer = setTimeout(() => {
toastVisible.value = false;
timer = null;
}, 1800);
return true;
};
const closeEditor = () => {
dialogVisible.value = false;
formError.value = "";
};
const discardConfirmation = createDiscardConfirmation(
(visible) => { discardVisible.value = visible; },
);
const confirmDiscard = () => {
discardConfirmation.confirm();
closeEditor();
};
const cancelDiscard = discardConfirmation.cancel;
const requestCloseEditor = async () => {
if (!memoDraftDirty.value) {
closeEditor();
return true;
}
const confirmed = await discardConfirmation.request();
if (confirmed) closeEditor();
return confirmed;
};
const restoreMemos = () => {
memos.value = listMemoFixtures(genealogyId.value);
memoState.value = memos.value.length ? "ready" : "empty";
};
const handleStateAction = () => {
if (memoState.value === "invalid") return goBack();
if (memoState.value === "error") return restoreMemos();
return startMemoPreview();
};
const requestBack = () => {
if (discardVisible.value) {
cancelDiscard();
return Promise.resolve(true);
}
if (dialogVisible.value) return requestCloseEditor();
return runBackGuard({
dirty: Boolean(localMemoPreview.value),
"confirm-discard": discardConfirmation.request,
});
};
onBackPress((event) => handleBackPress(event, requestBack));
onUnmounted(() => {
if (timer) clearTimeout(timer);
discardConfirmation.dispose();
});
import { computed, onUnmounted, reactive, ref } from "vue"; import { onBackPress, onLoad } from "@dcloudio/uni-app"; import AppButton from "@/components/AppButton.vue"; import AppDialog from "@/components/AppDialog.vue"; import ModulePageBackground from "@/components/ModulePageBackground.vue"; import PageHeader from "@/components/PageHeader.vue"; import { appApi, createRequestController, isRequestCancelled } from "@/utils/api.js"; import { createDiscardConfirmation } from "@/utils/discard-confirmation.js"; import { goBack, handleBackPress, returnTo, runBackGuard } from "@/utils/navigation.js";
const genealogyId=ref("");const state=ref("form");const submitting=ref(false);const error=ref("");const discardVisible=ref(false);const form=reactive({title:"",time:"",content:""});const controller=createRequestController();const valid=computed(()=>/^[1-9]\d*$/.test(genealogyId.value));const dirty=computed(()=>Object.values(form).some((value)=>value.trim()));const result=computed(()=>state.value==="success"?{title:"备忘已提交服务端",copy:"服务端已返回成功信封;列表仍缺条目 DTO,不生成本地备忘。",action:"返回备忘"}:{title:"备忘入口无效",copy:"没有取得有效家谱标识。",action:"返回上一页"});const confirmation=createDiscardConfirmation((visible)=>{discardVisible.value=visible;});const confirmDiscard=confirmation.confirm;const cancelDiscard=confirmation.cancel;
onLoad((query)=>{genealogyId.value=String(query?.genealogyId||"");if(!valid.value)state.value="invalid";});const submit=async()=>{if(submitting.value||!valid.value)return;const memoTitle=form.title.trim();if(!memoTitle){error.value="请填写备忘标题";return;}submitting.value=true;error.value="";try{await appApi.createMemo(genealogyId.value,{memoTitle,remindTime:form.time,memoContent:form.content},{requestController:controller});Object.keys(form).forEach((key)=>{form[key]="";});state.value="success";}catch(cause){if(!isRequestCancelled(cause))error.value=cause?.message||"备忘提交失败,请稍后重试。";}finally{submitting.value=false;}};const requestBack=()=>runBackGuard({transientOpen:discardVisible.value,dirty:dirty.value,submitting:submitting.value,"close-transient":cancelDiscard,"block-submitting":()=>true,"confirm-discard":confirmation.request});const resultAction=()=>state.value==="success"?returnTo("R10",{genealogyId:genealogyId.value}):goBack();onBackPress((event)=>handleBackPress(event,requestBack));onUnmounted(()=>{controller.abort();confirmation.dispose();});
</script>
<style scoped lang="scss">
@use "../../styles/adaptive-frame-profiles.scss" as adaptive;
.memo-page { display: flex; min-height: 100vh; flex-direction: column; background: $paper; }
.page-header,.page-loading,.page-content { z-index: 1; }
.page-loading { min-height: calc(100vh - 100rpx); }
.page-content { display: flex; flex-direction: column; gap: 16rpx; padding: 18rpx 24rpx 72rpx; }
.memo-card,.preview-card,.state-card { box-sizing: border-box; padding: 34rpx 46rpx; @include adaptive.adaptive-records-content; }
.memo-card > view { display: flex; flex-wrap: wrap; justify-content: space-between; gap: 6rpx 18rpx; color: $brand-red; font-size: 20rpx; }
.memo-card > text,.preview-card > text,.state-card > text { display: block; }
.memo-card > text:nth-child(2),.preview-card > text:nth-child(2) { margin-top: 8rpx; color: $ink; font-size: 31rpx; font-weight: 700; }
.memo-card > text:nth-child(3),.preview-card > text:nth-child(n + 3) { margin-top: 9rpx; color: $ink-muted; font-size: 23rpx; line-height: 1.55; }
.memo-card > text:last-child,.preview-card > text:first-child { margin-top: 10rpx; color: $brand-red; font-size: 20rpx; }
.state-card { min-height: 340rpx; padding-top: 78rpx; text-align: center; }
.state-card > text:first-child { color: $ink; font-size: 35rpx; font-weight: 700; }
.state-card > text:nth-child(2) { margin-top: 18rpx; color: $ink-muted; font-size: 24rpx; line-height: 1.65; }
.state-card .app-button { margin-top: 28rpx; }
.dialog-form { width: 100%; margin: 18rpx 0; }
.dialog-form input,.dialog-form textarea { width: 100%; min-height: 68rpx; margin-top: 9rpx; padding: 13rpx 20rpx; box-sizing: border-box; color: $ink; font-size: 23rpx; @include adaptive.adaptive-records-field; }
.dialog-form text { display: block; margin-top: 8rpx; color: $brand-red; font-size: 20rpx; }
.memo-page{display:flex;min-height:100vh;flex-direction:column;background:$paper}.page-header,.page-content{z-index:1}.page-content{padding:18rpx 24rpx 72rpx}.form-card,.state-card{box-sizing:border-box;padding:46rpx;@include adaptive.adaptive-records-content}.form-card>text:first-child,.state-card>text:first-child{display:block;color:$ink;font-size:34rpx;font-weight:700}.form-copy{display:block;margin-top:12rpx;color:$ink-muted;font-size:22rpx;line-height:1.55}.field{margin-top:16rpx}.field>text{display:block;margin:0 8rpx 8rpx;color:$ink;font-size:23rpx;font-weight:700}.field input,.field textarea{@include adaptive.adaptive-records-field;box-sizing:border-box;width:100%;min-height:76rpx;padding:16rpx 22rpx;color:$ink;font-size:23rpx}.field textarea{min-height:150rpx}.error{display:block;margin-top:12rpx;color:$brand-red;font-size:22rpx}.form-card .app-button{margin-top:20rpx}.state-card{min-height:340rpx;padding-top:80rpx;text-align:center}.state-card>text:nth-child(2){display:block;margin-top:18rpx;color:$ink-muted;font-size:24rpx;line-height:1.65}.state-card .app-button{margin-top:28rpx}
</style>
+6 -281
View File
@@ -1,288 +1,13 @@
<!-- 页面编号R-11用途当前家谱功德记录与不写库的本地预览 -->
<!-- 页面编号R-11用途功德记录创建列表 DTO 缺失时不展示 fixture -->
<template>
<view class="merit-page" :class="stateClasses">
<ModulePageBackground module="records" />
<view class="page-header">
<PageHeader
title="功德记录"
:action="hasValidContext ? '填写预览' : ''"
custom-back
@back="requestBack"
@action="startMeritPreview"
/>
</view>
<view v-if="meritState === 'loading'" class="page-loading">
<AppLoading text="正在整理功德记录" description="请稍候,正在核对当前家谱的正式记录。" />
</view>
<view v-else class="page-content">
<view v-if="hasValidContext" class="merit-summary">
<text>正式记录</text>
<text>{{ totalContribution }} </text>
<text>本地预览不计入正式记录数量</text>
</view>
<view v-if="localMeritPreview" class="preview-card">
<text>本地预览 · 尚未提交</text>
<text>{{ localMeritPreview.meritTitle }}</text>
<text>{{ localMeritPreview.donorName }} · {{ localMeritPreview.meritTime || "时间未填写" }}</text>
<text>{{ localMeritPreview.meritType || "类型未填写" }}</text>
<text>金额数值单位待确认{{ formatMeritAmount(localMeritPreview.amount) }}</text>
<text>{{ localMeritPreview.meritContent || "内容未填写" }}</text>
</view>
<template v-if="meritState === 'ready' && meritRecords.length">
<view v-for="merit in meritRecords" :key="merit.meritId" class="merit-card">
<text>{{ merit.meritTypeLabel || "类型未标注" }}</text>
<text>{{ merit.meritTitle }}</text>
<text>{{ merit.donorName }} · {{ merit.meritTime || "时间未填写" }}</text>
<text>{{ merit.meritContent || "暂无记录内容" }}</text>
</view>
<AppButton block label="填写功德预览" @click="startMeritPreview" />
</template>
<view v-else class="state-card">
<text>{{ stateCopy.title }}</text>
<text>{{ stateCopy.copy }}</text>
<AppButton
:type="meritState === 'empty' ? 'primary' : 'secondary'"
block
:label="stateCopy.action"
@click="handleStateAction"
/>
</view>
</view>
<AppDialog
:visible="dialogVisible"
:close-on-mask="false"
eyebrow="功德记录"
title="填写一份功德预览"
confirm-text="生成预览"
cancel-text="取消"
show-cancel
@confirm="createMeritPreview"
@cancel="requestCloseEditor"
>
<view class="dialog-form">
<input v-model="meritForm.meritTitle" placeholder="贡献事项" />
<input v-model="meritForm.donorName" placeholder="贡献人" />
<input v-model="meritForm.meritType" placeholder="贡献类型(选填)" />
<input v-model="meritForm.meritTime" placeholder="时间(选填)" />
<input v-model="meritForm.amount" type="digit" placeholder="金额数值(单位未明确,可不填)" />
<textarea v-model="meritForm.meritContent" auto-height placeholder="说明时间、物资或具体帮助(选填)" />
<text v-if="formError">{{ formError }}</text>
</view>
</AppDialog>
<AppDialog
:visible="discardVisible"
:close-on-mask="false"
eyebrow="放弃确认"
title="放弃当前填写?"
message="尚未提交的预览内容将从当前页面清除。"
confirm-text="确认放弃"
cancel-text="继续填写"
show-cancel
@confirm="confirmDiscard"
@cancel="cancelDiscard"
/>
<AppToast :visible="toastVisible" message="已生成本地预览,尚未保存" />
</view>
<view class="merit-page"><ModulePageBackground module="records" /><view class="page-header"><PageHeader title="功德记录" custom-back @back="requestBack" /></view><view class="page-content"><view v-if="state === 'form'" class="form-card"><text>新建功德记录</text><text class="form-copy">列表没有展示 DTO;本页仅提交已声明的捐赠人、标题、类型、金额、时间和内容,不猜测状态或排序。</text><view v-for="field in fields" :key="field.key" class="field"><text>{{ field.label }}</text><textarea v-if="field.key === 'content'" v-model="form[field.key]" auto-height :placeholder="`请输入${field.label}`" @input="error = ''" /><input v-else v-model="form[field.key]" :type="field.key === 'amount' ? 'digit' : 'text'" :placeholder="`请输入${field.label}`" @input="error = ''" /></view><text v-if="error" class="error">{{ error }}</text><AppButton block :disabled="submitting" :label="submitting ? '正在提交' : '提交功德记录'" @click="submit" /></view><view v-else class="state-card"><text>{{ result.title }}</text><text>{{ result.copy }}</text><AppButton block :label="result.action" @click="resultAction" /></view></view><AppDialog :visible="discardVisible" title="放弃功德记录?" message="尚未提交的内容将被清除。" confirm-text="放弃并返回" cancel-text="继续填写" show-cancel :close-on-mask="false" @confirm="confirmDiscard" @cancel="cancelDiscard" /></view>
</template>
<script setup>
import { computed, onUnmounted, reactive, ref } from "vue";
import { onBackPress, onLoad } from "@dcloudio/uni-app";
import AppButton from "@/components/AppButton.vue";
import AppDialog from "@/components/AppDialog.vue";
import AppLoading from "@/components/AppLoading.vue";
import AppToast from "@/components/AppToast.vue";
import ModulePageBackground from "@/components/ModulePageBackground.vue";
import PageHeader from "@/components/PageHeader.vue";
import {
getGenealogyFixtureAccess,
listMeritRecordFixtures,
} from "@/data/mock.js";
import { createDiscardConfirmation } from "@/utils/discard-confirmation.js";
import { goBack, handleBackPress, runBackGuard } from "@/utils/navigation.js";
const genealogyId = ref("");
const meritRecords = ref([]);
const meritState = ref("loading");
const dialogVisible = ref(false);
const discardVisible = ref(false);
const toastVisible = ref(false);
const formError = ref("");
const localMeritPreview = ref(null);
const meritForm = reactive({
meritTitle: "",
donorName: "",
meritType: "",
meritTime: "",
amount: "",
meritContent: "",
});
const editorBaseline = ref("");
let timer = null;
const totalContribution = computed(() => meritRecords.value.length);
const stateClasses = computed(() => ({
"merit-state--loading": meritState.value === "loading",
"merit-state--empty": meritState.value === "empty",
"merit-state--error": meritState.value === "error",
"merit-state--invalid": meritState.value === "invalid",
}));
const hasValidContext = computed(() => ["ready", "empty"].includes(meritState.value));
const formSnapshot = computed(() => JSON.stringify({ ...meritForm }));
const meritDraftDirty = computed(() =>
dialogVisible.value && formSnapshot.value !== editorBaseline.value,
);
const stateCopy = computed(() => ({
error: {
title: "功德记录暂不可用",
copy: "请稍后重新查看,已有记录不会受到影响。",
action: "重新查看",
},
invalid: {
title: "功德记录入口无效",
copy: "没有找到可访问的成员家谱,页面不会展示其他家谱记录。",
action: "返回上一页",
},
empty: {
title: "还没有功德记录",
copy: "可以先生成一份本地预览;正式创建仍需等待线上写接口启用。",
action: "填写功德预览",
},
})[meritState.value] || {
title: "功德记录暂不可用",
copy: "请返回上一页重新进入。",
action: "返回上一页",
});
onLoad((query) => {
genealogyId.value = String(query.genealogyId || "");
const access = getGenealogyFixtureAccess(genealogyId.value);
if (!["owner", "member"].includes(access.accessRole)) {
meritState.value = "invalid";
return;
}
meritRecords.value = listMeritRecordFixtures(genealogyId.value);
meritState.value = ["loading", "empty", "error"].includes(query.state)
? query.state
: meritRecords.value.length
? "ready"
: "empty";
});
const startMeritPreview = () => {
if (!hasValidContext.value) return false;
Object.assign(meritForm, {
meritTitle: "",
donorName: "",
meritType: "",
meritTime: "",
amount: "",
meritContent: "",
});
formError.value = "";
editorBaseline.value = formSnapshot.value;
dialogVisible.value = true;
return true;
};
const formatMeritAmount = (amount) =>
amount === null || amount === "" ? "未填写" : String(amount);
const createMeritPreview = () => {
const missing = [];
if (!meritForm.meritTitle.trim()) missing.push("贡献事项");
if (!meritForm.donorName.trim()) missing.push("贡献人");
formError.value = missing.length ? `请填写${missing.join("和")}` : "";
const amountInput = meritForm.amount.trim();
if (!formError.value && amountInput && !Number.isFinite(Number(amountInput))) {
formError.value = "金额必须是数字,单位仍待后端确认";
}
if (formError.value) return false;
// 金额的单位、精度和取值规则尚未由后端明确,本地预览只保留原始输入。
localMeritPreview.value = Object.freeze({
meritTitle: meritForm.meritTitle.trim(),
donorName: meritForm.donorName.trim(),
meritType: meritForm.meritType.trim(),
meritTime: meritForm.meritTime.trim(),
amount: amountInput ? Number(amountInput) : null,
meritContent: meritForm.meritContent.trim(),
});
dialogVisible.value = false;
toastVisible.value = true;
if (timer) clearTimeout(timer);
timer = setTimeout(() => {
toastVisible.value = false;
timer = null;
}, 1800);
return true;
};
const closeEditor = () => {
dialogVisible.value = false;
formError.value = "";
};
const discardConfirmation = createDiscardConfirmation(
(visible) => { discardVisible.value = visible; },
);
const confirmDiscard = () => {
discardConfirmation.confirm();
closeEditor();
};
const cancelDiscard = discardConfirmation.cancel;
const requestCloseEditor = async () => {
if (!meritDraftDirty.value) {
closeEditor();
return true;
}
const confirmed = await discardConfirmation.request();
if (confirmed) closeEditor();
return confirmed;
};
const restoreMeritRecords = () => {
meritRecords.value = listMeritRecordFixtures(genealogyId.value);
meritState.value = meritRecords.value.length ? "ready" : "empty";
};
const handleStateAction = () => {
if (meritState.value === "invalid") return goBack();
if (meritState.value === "error") return restoreMeritRecords();
return startMeritPreview();
};
const requestBack = () => {
if (discardVisible.value) {
cancelDiscard();
return Promise.resolve(true);
}
if (dialogVisible.value) return requestCloseEditor();
return runBackGuard({
dirty: Boolean(localMeritPreview.value),
"confirm-discard": discardConfirmation.request,
});
};
onBackPress((event) => handleBackPress(event, requestBack));
onUnmounted(() => {
if (timer) clearTimeout(timer);
discardConfirmation.dispose();
});
import { computed,onUnmounted,reactive,ref } from "vue"; import { onBackPress,onLoad } from "@dcloudio/uni-app"; import AppButton from "@/components/AppButton.vue"; import AppDialog from "@/components/AppDialog.vue"; import ModulePageBackground from "@/components/ModulePageBackground.vue"; import PageHeader from "@/components/PageHeader.vue"; import { appApi,createRequestController,isRequestCancelled } from "@/utils/api.js"; import { createDiscardConfirmation } from "@/utils/discard-confirmation.js"; import { goBack,handleBackPress,returnTo,runBackGuard } from "@/utils/navigation.js";
const genealogyId=ref("");const state=ref("form");const submitting=ref(false);const error=ref("");const discardVisible=ref(false);const form=reactive({donor:"",title:"",type:"",amount:"",time:"",content:""});const fields=[{key:"donor",label:"捐赠人"},{key:"title",label:"功德标题"},{key:"type",label:"功德类型"},{key:"amount",label:"金额"},{key:"time",label:"记录时间"},{key:"content",label:"记录内容"}];const controller=createRequestController();const valid=computed(()=>/^[1-9]\d*$/.test(genealogyId.value));const dirty=computed(()=>Object.values(form).some((value)=>value.trim()));const result=computed(()=>state.value==="success"?{title:"功德记录已提交服务端",copy:"服务端已返回成功信封;列表仍缺条目 DTO,不生成本地功德记录。",action:"返回功德记录"}:{title:"功德记录入口无效",copy:"没有取得有效家谱标识。",action:"返回上一页"});const confirmation=createDiscardConfirmation((visible)=>{discardVisible.value=visible;});const confirmDiscard=confirmation.confirm;const cancelDiscard=confirmation.cancel;
onLoad((query)=>{genealogyId.value=String(query?.genealogyId||"");if(!valid.value)state.value="invalid";});const submit=async()=>{if(submitting.value||!valid.value)return;const donorName=form.donor.trim();const meritTitle=form.title.trim();const amountText=form.amount.trim();if(!donorName||!meritTitle){error.value=!donorName?"请填写捐赠人":"请填写功德标题";return;}if(amountText&&!Number.isFinite(Number(amountText))){error.value="金额必须是数字";return;}submitting.value=true;error.value="";try{await appApi.createMeritRecord(genealogyId.value,{donorName,meritTitle,meritType:form.type,meritContent:form.content,meritTime:form.time,...(amountText?{amount:Number(amountText)}:{})},{requestController:controller});Object.keys(form).forEach((key)=>{form[key]="";});state.value="success";}catch(cause){if(!isRequestCancelled(cause))error.value=cause?.message||"功德记录提交失败,请稍后重试。";}finally{submitting.value=false;}};const requestBack=()=>runBackGuard({transientOpen:discardVisible.value,dirty:dirty.value,submitting:submitting.value,"close-transient":cancelDiscard,"block-submitting":()=>true,"confirm-discard":confirmation.request});const resultAction=()=>state.value==="success"?returnTo("R11",{genealogyId:genealogyId.value}):goBack();onBackPress((event)=>handleBackPress(event,requestBack));onUnmounted(()=>{controller.abort();confirmation.dispose();});
</script>
<style scoped lang="scss">
@use "../../styles/adaptive-frame-profiles.scss" as adaptive;
.merit-page { display: flex; min-height: 100vh; flex-direction: column; background: $paper; }
.page-header,.page-loading,.page-content { z-index: 1; }
.page-loading { min-height: calc(100vh - 100rpx); }
.page-content { display: flex; flex-direction: column; gap: 16rpx; padding: 18rpx 24rpx 72rpx; }
.merit-summary,.merit-card,.preview-card,.state-card { box-sizing: border-box; padding: 34rpx 46rpx; @include adaptive.adaptive-records-content; }
.merit-summary { text-align: center; }
.merit-summary > text,.merit-card > text,.preview-card > text,.state-card > text { display: block; }
.merit-summary > text:first-child,.merit-card > text:first-child,.preview-card > text:first-child { color: $brand-red; font-size: 21rpx; }
.merit-summary > text:nth-child(2) { margin-top: 5rpx; color: $ink; font-size: 38rpx; font-weight: 700; }
.merit-summary > text:last-child { margin-top: 9rpx; color: $ink-muted; font-size: 22rpx; line-height: 1.5; }
.merit-card > text:nth-child(2),.preview-card > text:nth-child(2) { margin-top: 6rpx; color: $ink; font-size: 31rpx; font-weight: 700; }
.merit-card > text:nth-child(3),.preview-card > text:nth-child(3) { margin-top: 8rpx; color: $ink-muted; font-size: 21rpx; }
.merit-card > text:last-child,.preview-card > text:last-child { margin-top: 9rpx; color: $ink; font-size: 23rpx; line-height: 1.55; }
.state-card { min-height: 340rpx; padding-top: 78rpx; text-align: center; }
.state-card > text:first-child { color: $ink; font-size: 35rpx; font-weight: 700; }
.state-card > text:nth-child(2) { margin-top: 18rpx; color: $ink-muted; font-size: 24rpx; line-height: 1.65; }
.state-card .app-button { margin-top: 28rpx; }
.dialog-form { width: 100%; margin: 14rpx 0; }
.dialog-form input,.dialog-form textarea { width: 100%; min-height: 62rpx; margin-top: 7rpx; padding: 11rpx 18rpx; box-sizing: border-box; color: $ink; font-size: 22rpx; @include adaptive.adaptive-records-field; }
.dialog-form text { display: block; margin-top: 7rpx; color: $brand-red; font-size: 20rpx; }
.merit-page{display:flex;min-height:100vh;flex-direction:column;background:$paper}.page-header,.page-content{z-index:1}.page-content{padding:18rpx 24rpx 72rpx}.form-card,.state-card{box-sizing:border-box;padding:46rpx;@include adaptive.adaptive-records-content}.form-card>text:first-child,.state-card>text:first-child{display:block;color:$ink;font-size:34rpx;font-weight:700}.form-copy{display:block;margin-top:12rpx;color:$ink-muted;font-size:22rpx;line-height:1.55}.field{margin-top:16rpx}.field>text{display:block;margin:0 8rpx 8rpx;color:$ink;font-size:23rpx;font-weight:700}.field input,.field textarea{@include adaptive.adaptive-records-field;box-sizing:border-box;width:100%;min-height:76rpx;padding:16rpx 22rpx;color:$ink;font-size:23rpx}.field textarea{min-height:150rpx}.error{display:block;margin-top:12rpx;color:$brand-red;font-size:22rpx}.form-card .app-button{margin-top:20rpx}.state-card{min-height:340rpx;padding-top:80rpx;text-align:center}.state-card>text:nth-child(2){display:block;margin-top:18rpx;color:$ink-muted;font-size:24rpx;line-height:1.65}.state-card .app-button{margin-top:28rpx}
</style>
+149 -71
View File
@@ -30,7 +30,7 @@
>回到当前</text
>
<text @click="treeState = 'landscape'">阅读提示</text>
<text @click="toRelationship">关系维护</text>
<text @click="toRank">调整排行</text>
</view>
</view>
@@ -99,7 +99,7 @@
class="member-node"
:class="{ 'member-node--selected': selected?.id === member.id }"
:style="nodeGridStyle(member)"
@click="selected = member"
@click="openMemberPanel(member)"
>
<image
class="member-node__skin"
@@ -110,6 +110,11 @@
"
mode="scaleToFill"
/>
<image
class="member-node__avatar"
src="/static/assets/foundation/transparent/meta-member.png"
mode="aspectFit"
/>
<view class="member-node__copy">
<text class="node-name">{{ member.name }}</text>
<text class="node-relation"
@@ -145,33 +150,54 @@
</scroll-view>
</view>
<view v-if="treeState === 'tree' && selected" class="member-sheet">
<image
class="member-sheet__skin"
src="/static/assets/modules/tree/transparent/t01-member-drawer.png"
mode="scaleToFill"
/>
<view class="member-sheet__copy">
<text class="sheet-name">{{ selected.name }}</text>
<text class="sheet-meta"
> {{ selected.generation }} · {{ selected.relation }} ·
{{ selected.branch }}</text
>
<AppDialog
:visible="memberActionPanelVisible"
eyebrow="人物操作"
:title="selected ? `管理${selected.name}` : '管理人物'"
message="仅展示当前人物可达的操作。未具备可靠后端合同的写入会明确说明,且不会创建本地假数据。"
confirm-text="关闭"
@confirm="memberActionPanelVisible = false"
@cancel="memberActionPanelVisible = false"
>
<view
v-if="selected"
class="member-action-profile"
role="button"
:aria-label="`查看${selected.name}的资料`"
@click="toMember"
>
<image
class="member-action-profile__avatar"
src="/static/assets/foundation/transparent/meta-member.png"
mode="aspectFit"
/>
<view class="member-action-profile__copy">
<text>{{ selected.name }}</text>
<text> {{ selected.generation }} · {{ selected.relation }} · {{ selected.branch }}</text>
<text>点击头像查看资料</text>
</view>
</view>
<view class="sheet-actions">
<view class="member-action-grid">
<AppButton
class="sheet-action"
v-for="action in memberActions"
:key="action.key"
type="secondary"
label="查看资料"
@click="toMember"
/>
<AppButton
class="sheet-action"
label="添加亲属"
@click="toAddRelative"
compact
:label="action.label"
@click="openMemberAction(action)"
/>
</view>
</view>
</AppDialog>
<AppDialog
:visible="unavailableActionVisible"
eyebrow="服务状态"
:title="unavailableAction?.label || '当前操作'"
:message="unavailableAction?.unavailableCopy || '该操作暂未接入可靠服务合同。'"
confirm-text="我知道了"
@confirm="unavailableActionVisible = false"
@cancel="unavailableActionVisible = false"
/>
</view>
</template>
@@ -179,6 +205,7 @@
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 ModulePageBackground from "@/components/ModulePageBackground.vue";
import PageHeader from "@/components/PageHeader.vue";
@@ -193,6 +220,9 @@ import { openPage } from "@/utils/navigation.js";
const genealogyId = ref("");
const treeState = ref("loading");
const selected = ref(null);
const memberActionPanelVisible = ref(false);
const unavailableActionVisible = ref(false);
const unavailableAction = ref(null);
const treeScrollLeft = ref(90);
const currentTreeScrollLeft = ref(90);
const centeredTreeScrollLeft = ref(90);
@@ -209,6 +239,23 @@ const MEMBER_GAP = 250;
const GENERATION_GAP = 220;
const members = ref([]);
const memberActions = Object.freeze([
{ key: "VIEW_PROFILE", label: "查看资料", routeKey: "T03" },
{ key: "ADD_FATHER", label: "添加父亲", routeKey: "T04", relationType: "FATHER" },
{ key: "ADD_MOTHER", label: "添加母亲", routeKey: "T04", relationType: "MOTHER" },
{ key: "ADD_SPOUSE", label: "添加配偶", routeKey: "T04", relationType: "SPOUSE" },
{ key: "ADD_SIBLING", label: "添加兄弟姐妹", routeKey: "T04", relationType: "SIBLING" },
{ key: "ADJUST_RANK", label: "调整排行", routeKey: "T06", mode: "rank" },
{ key: "ADD_SON", label: "添加儿子", routeKey: "T04", relationType: "SON" },
{ key: "ADD_DAUGHTER", label: "添加女儿", routeKey: "T04", relationType: "DAUGHTER" },
{
key: "BIND_INVITE",
label: "邀请绑定",
unavailableCopy: "邀请签发、目标身份查找、绑定 mutation 和结果查询尚无可靠合同;当前不会借邀请码或普通入谱申请替代。",
},
{ key: "EDIT_PROFILE", label: "编辑信息", routeKey: "T05" },
]);
const snapToGrid = (value) => Math.ceil(value / GRID_UNIT) * GRID_UNIT;
const layoutMembers = computed(() => {
const generations = Array.from(
@@ -482,20 +529,40 @@ const handleStateAction = () => {
selected.value = layoutMembers.value[0];
treeState.value = "tree";
};
const toMember = () =>
selected.value
? openPage("T03", { genealogyId: genealogyId.value, personId: String(selected.value.id) }, "T01")
: Promise.resolve(false);
const toMember = () => {
if (!selected.value) return Promise.resolve(false);
memberActionPanelVisible.value = false;
return openPage("T03", { genealogyId: genealogyId.value, personId: String(selected.value.id) }, "T01");
};
const toDirectory = () =>
openPage("T07", { genealogyId: genealogyId.value }, "T01");
const toRelationship = () =>
const toRank = () =>
selected.value
? openPage("T06", { genealogyId: genealogyId.value, personId: String(selected.value.id) }, "T01")
: Promise.resolve(false);
const toAddRelative = () =>
selected.value
? openPage("T04", { genealogyId: genealogyId.value, personId: String(selected.value.id) }, "T01")
? openPage("T06", { genealogyId: genealogyId.value, personId: String(selected.value.id), mode: "rank" }, "T01")
: Promise.resolve(false);
const openMemberPanel = (member) => {
selected.value = member;
memberActionPanelVisible.value = true;
};
const openMemberAction = (action) => {
if (!selected.value) return Promise.resolve(false);
memberActionPanelVisible.value = false;
if (!action.routeKey) {
unavailableAction.value = action;
unavailableActionVisible.value = true;
return Promise.resolve(false);
}
const params = {
genealogyId: genealogyId.value,
personId: String(selected.value.id),
};
if (action.routeKey === "T04") {
params.relationType = action.relationType;
} else if (action.routeKey === "T06") {
params.mode = action.mode;
}
return openPage(action.routeKey, params, "T01");
};
</script>
<style scoped lang="scss">
@@ -638,8 +705,18 @@ const toAddRelative = () =>
.member-node__copy {
grid-area: 1 / 1;
}
.member-node__avatar {
grid-area: 1 / 1;
z-index: 3;
align-self: start;
justify-self: start;
width: 42rpx;
height: 42rpx;
margin: 13rpx 0 0 18rpx;
}
.member-node__copy {
z-index: 1;
padding-left: 44rpx;
}
.node-name,
.node-relation,
@@ -736,50 +813,53 @@ const toAddRelative = () =>
font-size: 24rpx;
font-weight: 700;
}
.member-sheet {
position: fixed;
right: 18rpx;
bottom: calc(14rpx + env(safe-area-inset-bottom));
left: 18rpx;
.member-action-profile {
display: flex;
min-height: 240rpx;
flex-direction: column;
padding: 38rpx 44rpx 24rpx;
width: 100%;
align-items: center;
gap: 18rpx;
margin-top: 16rpx;
padding: 14rpx 18rpx;
box-sizing: border-box;
}
.member-sheet__skin {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
pointer-events: none;
.member-action-profile__avatar {
width: 82rpx;
aspect-ratio: 1;
flex: 0 0 82rpx;
}
.member-sheet__copy {
z-index: 1;
.member-action-profile__copy {
min-width: 0;
text-align: left;
}
.sheet-name {
.member-action-profile__copy text {
display: block;
}
.member-action-profile__copy text:first-child {
color: $ink;
font-family: "STKaiti", "KaiTi", serif;
font-size: 31rpx;
font-size: 30rpx;
font-weight: 700;
}
.sheet-meta {
display: block;
margin-top: 8rpx;
.member-action-profile__copy text:nth-child(2) {
margin-top: 4rpx;
color: $ink-muted;
font-size: 22rpx;
font-size: 21rpx;
}
.sheet-actions {
z-index: 1;
display: flex;
justify-content: center;
gap: 16rpx;
margin-top: auto;
.member-action-profile__copy text:last-child {
margin-top: 4rpx;
color: $brand-red;
font-size: 21rpx;
}
.sheet-action {
width: 250rpx;
min-height: 88rpx;
.member-action-grid {
display: grid;
width: 100%;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12rpx;
margin-top: 20rpx;
}
.member-action-grid .app-button {
width: 100%;
min-height: 70rpx;
}
@media (max-width: 360px) {
@@ -790,12 +870,10 @@ const toAddRelative = () =>
.tree-toolbar__actions {
gap: 14rpx;
}
.member-sheet {
min-height: 240rpx;
padding-top: 32rpx;
padding-right: 36rpx;
padding-left: 36rpx;
padding-bottom: 20rpx;
.member-action-profile {
gap: 14rpx;
padding-right: 14rpx;
padding-left: 14rpx;
}
}
</style>
+66 -34
View File
@@ -93,11 +93,11 @@
<script setup>
import { computed, reactive, ref } from "vue";
import { onBackPress, onLoad, onShow } from "@dcloudio/uni-app";
import { onBackPress, onLoad, onShow, onUnload } from "@dcloudio/uni-app";
import AppLoading from "@/components/AppLoading.vue";
import ModulePageBackground from "@/components/ModulePageBackground.vue";
import PageHeader from "@/components/PageHeader.vue";
import { findTreeMemberPresentationFixture } from "@/data/mock.js";
import { appApi, createRequestController, isRequestCancelled } from "@/utils/api.js";
import {
consumeNavigationResult,
handleBackPress,
@@ -113,13 +113,25 @@ const errorMessage = ref("");
const genealogyName = ref("汤氏家谱");
const memberTrail = reactive([]);
const trailIndex = ref(-1);
const memberRequestController = createRequestController();
let loadSequence = 0;
const details = computed(() => member.value ? [
{ label: "别名", value: member.value.aliasName },
{ label: "字辈", value: member.value.generationName },
{ label: "性别(字典值)", value: member.value.sex },
{ label: "人物状态(字典值)", value: member.value.personStatus },
{ label: "出生日期", value: member.value.status === "forbidden" ? "按权限隐藏" : member.value.birthDate },
{ label: "出生农历", value: member.value.status === "forbidden" ? "按权限隐藏" : member.value.birthLunar },
{ label: "生卒信息", value: member.value.years },
{ label: "祖居地", value: member.value.status === "forbidden" ? "按权限隐藏" : member.value.birthplace },
{ label: "出生地", value: member.value.status === "forbidden" ? "按权限隐藏" : member.value.birthplace },
{ label: "逝世农历", value: member.value.status === "forbidden" ? "按权限隐藏" : member.value.deathLunar },
{ label: "逝世地", value: member.value.status === "forbidden" ? "按权限隐藏" : member.value.deathPlace },
{ label: "安葬地", value: member.value.status === "forbidden" ? "按权限隐藏" : member.value.burialPlace },
{ label: "配偶", value: member.value.spouseNames },
{ label: "所属支系", value: member.value.branch },
{ label: "生平", value: member.value.status === "forbidden" ? "按权限隐藏" : member.value.biography },
{ label: "备注", value: member.value.remark },
] : []);
const canPreviewEdit = computed(() => memberState.value === "detail");
const restrictedCopy = computed(() =>
@@ -146,40 +158,59 @@ const memberContextDescription = computed(() => ({
error: "请重新选择成员",
}[memberState.value] || "请重新选择成员"));
const loadMember = async (nextPersonId) => {
const loadMember = async (nextPersonId, { preserveCurrent = false } = {}) => {
const normalizedPersonId = String(nextPersonId || "");
const fixture = findTreeMemberPresentationFixture(genealogyId.value, normalizedPersonId);
if (!fixture) {
errorMessage.value = "这位成员不存在或已不属于当前家谱。";
const activeLoad = ++loadSequence;
const previous = {
personId: personId.value,
member: member.value,
state: memberState.value,
errorMessage: errorMessage.value,
genealogyName: genealogyName.value,
};
const restorePrevious = () => {
personId.value = previous.personId;
member.value = previous.member;
memberState.value = previous.state;
errorMessage.value = previous.errorMessage;
genealogyName.value = previous.genealogyName;
};
memberState.value = "loading";
errorMessage.value = "";
try {
const nextMember = await appApi.getPerson(
genealogyId.value,
normalizedPersonId,
{ requestController: memberRequestController },
);
if (activeLoad !== loadSequence) return false;
personId.value = nextMember.id;
member.value = nextMember;
genealogyName.value = nextMember.genealogyName;
memberState.value = "detail";
return true;
} catch (error) {
if (activeLoad !== loadSequence || isRequestCancelled(error)) {
if (preserveCurrent && activeLoad === loadSequence) restorePrevious();
return false;
}
if (preserveCurrent) {
restorePrevious();
return false;
}
member.value = null;
memberState.value = "error";
errorMessage.value = error?.message || "这位成员不存在或已不属于当前家谱。";
return false;
}
const restricted = ["privacy", "forbidden"].includes(fixture.status);
const relatives = restricted ? [] : fixture.relatives
.map((relative) => {
const relativeMember = findTreeMemberPresentationFixture(
genealogyId.value,
relative.personId,
);
return relativeMember
? { id: relativeMember.id, name: relativeMember.name, relation: relative.relation }
: null;
})
.filter(Boolean);
personId.value = normalizedPersonId;
member.value = {
...fixture,
relatives,
};
errorMessage.value = "";
memberState.value = restricted ? "restricted" : "detail";
return true;
};
const initializeMemberTrail = async (initialPersonId) => {
memberTrail.splice(0, memberTrail.length);
trailIndex.value = -1;
const normalizedPersonId = String(initialPersonId || "");
const loaded = await loadMember(normalizedPersonId);
const loaded = await loadMember(normalizedPersonId, { preserveCurrent: true });
if (!loaded) return false;
memberTrail.splice(0, memberTrail.length, normalizedPersonId);
trailIndex.value = 0;
@@ -204,7 +235,7 @@ const popMemberTrail = async () => {
while (trailIndex.value > 0) {
const targetIndex = trailIndex.value - 1;
const historicalPersonId = memberTrail[targetIndex];
const loaded = await loadMember(historicalPersonId);
const loaded = await loadMember(historicalPersonId, { preserveCurrent: true });
if (loaded) {
trailIndex.value = targetIndex;
return true;
@@ -235,11 +266,7 @@ onLoad((query) => {
: "当前家谱上下文无效,请重新进入。";
return;
}
void initializeMemberTrail(initialPersonId).then((loaded) => {
if (loaded) return;
member.value = null;
memberState.value = "error";
});
void initializeMemberTrail(initialPersonId);
});
onShow(() => {
@@ -251,6 +278,11 @@ onShow(() => {
onBackPress((event) => handleBackPress(event, requestBack));
onUnload(() => {
loadSequence += 1;
memberRequestController.abort();
});
const toEdit = () => {
if (!canPreviewEdit.value) return false;
return openPage("T05", { genealogyId: genealogyId.value, personId: personId.value }, "T03");
+121 -68
View File
@@ -4,7 +4,7 @@
class="add-relative-page"
:class="{
'add-state--form': addState === 'form',
'add-state--preview': addState === 'preview',
'add-state--loading': addState === 'loading',
'add-state--error': addState === 'error',
}"
>
@@ -14,7 +14,9 @@
</view>
<view class="add-relative-panel">
<view v-if="addState === 'form'" class="add-relative-form">
<AppLoading v-if="addState === 'loading'" text="正在读取成员资料" description="请稍候,正在确认当前人物。" />
<view v-else-if="addState === 'form'" class="add-relative-form">
<text class="form-eyebrow">{{ isFirstMember ? "建立世系起点" : "补全家族关系" }}</text>
<text class="form-title">{{ formTitle }}</text>
<text class="form-copy">{{ formCopy }}</text>
@@ -48,13 +50,6 @@
</picker>
<text v-if="fieldErrors.relation" class="field-error">{{ fieldErrors.relation }}</text>
<picker :range="genderOptions" :value="genderIndex" @change="selectGender">
<view class="form-field form-field--picker">
<text>性别</text><text>{{ addForm.gender || "请选择" }}</text>
</view>
</picker>
<text v-if="fieldErrors.gender" class="field-error">{{ fieldErrors.gender }}</text>
<picker mode="date" :value="addForm.birthDate" @change="selectBirthDate">
<view class="form-field form-field--picker">
<text>出生日期</text><text>{{ addForm.birthDate || "选填" }}</text>
@@ -75,17 +70,17 @@
<text class="form-note">{{ formNote }}</text>
<view class="form-action" @click="submitAdd">
<image src="/static/assets/foundation/transparent/a01-scroll-primary-v3.png" mode="scaleToFill" />
<text>{{ isSubmitting ? "正在校验…" : "生成本地预览" }}</text>
<text>{{ isSubmitting ? "正在保存…" : "保存成员" }}</text>
</view>
</view>
<view v-else class="add-result">
<text class="form-eyebrow">{{ addState === "preview" ? "本地流程预览" : hasValidContext ? "成员未保存" : "成员入口无效" }}</text>
<text class="form-title">{{ addState === "preview" ? previewTitle : hasValidContext ? "暂时无法校验成员" : "没有找到要关联的成员" }}</text>
<text class="form-copy">{{ addState === "preview" ? previewCopy : hasValidContext ? "当前填写内容仍保留,可返回修改后重试。" : "请从世系树重新进入,页面不会创建临时成员身份。" }}</text>
<view class="form-action" @click="addState === 'preview' ? returnToTree() : retryForm()">
<text class="form-eyebrow">{{ resultCopy.eyebrow }}</text>
<text class="form-title">{{ resultCopy.title }}</text>
<text class="form-copy">{{ resultCopy.copy }}</text>
<view class="form-action" @click="handleResultAction">
<image src="/static/assets/foundation/transparent/a01-scroll-primary-v3.png" mode="scaleToFill" />
<text>{{ addState === "preview" ? "返回世系树(不保存)" : hasValidContext ? "返回修改" : "返回上一页" }}</text>
<text>{{ resultCopy.action }}</text>
</view>
</view>
</view>
@@ -108,46 +103,57 @@
<script setup>
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 ModulePageBackground from "@/components/ModulePageBackground.vue";
import PageHeader from "@/components/PageHeader.vue";
import { findTreeMemberFixture } from "@/data/mock.js";
import { appApi, createRequestController, isRequestCancelled } from "@/utils/api.js";
import { createDiscardConfirmation } from "@/utils/discard-confirmation.js";
import {
goBack,
handleBackPress,
returnTo,
runBackGuard,
} from "@/utils/navigation.js";
const addState = ref("form");
const addState = ref("loading");
const genealogyId = ref("");
const personId = ref("");
const mode = ref("relative");
const relationType = ref("");
const isSubmitting = ref(false);
const discardDialogVisible = ref(false);
let submitTimer = null;
const currentMember = ref(null);
const errorMessage = ref("");
const addRequestController = createRequestController();
let loadSequence = 0;
const relationOptions = ["长子", "次子", "女儿", "配偶", "兄弟", "姐妹"];
const genderOptions = ["男", "女", "未说明"];
const addForm = reactive({ name: "", relation: "", gender: "", birthDate: "", summary: "" });
const fieldErrors = reactive({ name: "", relation: "", gender: "" });
const relationIntents = Object.freeze({
FATHER: { label: "父亲" },
MOTHER: { label: "母亲" },
SPOUSE: { label: "配偶" },
SIBLING: { label: "兄弟姐妹" },
SON: { label: "儿子" },
DAUGHTER: { label: "女儿" },
});
const genericRelationTypes = Object.freeze(["FATHER", "MOTHER", "SPOUSE", "SIBLING", "SON", "DAUGHTER"]);
const addForm = reactive({ name: "", relation: "", birthDate: "", summary: "" });
const fieldErrors = reactive({ name: "", relation: "" });
const isFirstMember = computed(() => mode.value === "first");
const currentMember = computed(() =>
isFirstMember.value
? null
: findTreeMemberFixture(genealogyId.value, personId.value),
);
const activeRelationIntent = computed(() => relationIntents[relationType.value] || null);
const relationOptions = computed(() => {
const types = activeRelationIntent.value ? [relationType.value] : genericRelationTypes;
return types.map((type) => relationIntents[type].label);
});
const relationLabel = computed(() => activeRelationIntent.value?.label || addForm.relation || "亲属关系");
const hasValidContext = computed(
() =>
Boolean(genealogyId.value) &&
(isFirstMember.value ? !personId.value : Boolean(currentMember.value)),
);
const relationIndex = computed(() => Math.max(0, relationOptions.indexOf(addForm.relation)));
const genderIndex = computed(() => Math.max(0, genderOptions.indexOf(addForm.gender)));
const relationIndex = computed(() => Math.max(0, relationOptions.value.indexOf(addForm.relation)));
const formTitle = computed(() =>
isFirstMember.value ? "录入家谱中的第一位成员" : `${currentMember.value.name}添加一位亲属`,
isFirstMember.value ? "录入家谱中的第一位成员" : `${currentMember.value?.name || "当前成员"}添加${relationLabel.value}`,
);
const formCopy = computed(() =>
isFirstMember.value
@@ -155,16 +161,24 @@ const formCopy = computed(() =>
: "先确认新成员与当前成员的关系,再填写可核实的身份信息。",
);
const formNote = computed(() =>
isFirstMember.value
? "当前阶段只校验首位成员资料,不会修改世系树。"
: "当前阶段只校验亲属资料,不会新增成员或改变世系关系。",
);
const previewTitle = computed(() =>
isFirstMember.value ? `${addForm.name}可作为世系起点` : `${addForm.name}的亲属资料已通过本地校验`,
);
const previewCopy = computed(() =>
`本地预览尚未提交服务器,返回后不会保存${isFirstMember.value ? "首位成员" : `${addForm.relation}关系`},世系树也不会变化。`,
"本次只提交姓名、出生日期和人物简介;性别编码、头像和同辈排行不在当前写入范围。",
);
const resultCopy = computed(() => {
if (hasValidContext.value) {
return {
eyebrow: "保存失败",
title: `${relationLabel.value}尚未保存`,
copy: errorMessage.value || "服务未确认本次写入,请检查填写后重试。",
action: "返回修改",
};
}
return {
eyebrow: "成员入口无效",
title: "没有找到要关联的成员",
copy: errorMessage.value || "请从世系树重新进入。",
action: "返回世系树",
};
});
const hasDraft = computed(() =>
Object.values(addForm).some((value) => String(value).trim()),
);
@@ -175,27 +189,58 @@ const requestDiscardConfirmation = discardConfirmation.request;
const confirmDiscard = discardConfirmation.confirm;
const cancelDiscard = discardConfirmation.cancel;
const loadCurrentMember = async () => {
const activeLoad = ++loadSequence;
addState.value = "loading";
try {
const member = await appApi.getPerson(genealogyId.value, personId.value, {
requestController: addRequestController,
});
if (activeLoad !== loadSequence) return;
currentMember.value = member;
addState.value = "form";
} catch (error) {
if (activeLoad !== loadSequence || isRequestCancelled(error)) return;
currentMember.value = null;
addState.value = "error";
errorMessage.value = error?.message || "当前成员暂不可用。";
}
};
onLoad((query) => {
genealogyId.value = String(query.genealogyId || "");
personId.value = String(query.personId || "");
mode.value = query.mode === "first" ? "first" : "relative";
addState.value = !hasValidContext.value || query.state === "error"
? "error"
: query.state === "preview"
? "preview"
: "form";
relationType.value = String(query.relationType || "");
if (!genealogyId.value || query.state === "error" || (!isFirstMember.value && !personId.value)) {
addState.value = "error";
errorMessage.value = "当前家谱或成员上下文无效,请从世系树重新进入。";
return;
}
if (relationType.value && !activeRelationIntent.value) {
addState.value = "error";
errorMessage.value = "未知的亲属关系意图,本页不会推断或替换。";
return;
}
if (activeRelationIntent.value) {
addForm.relation = activeRelationIntent.value.label;
}
if (isFirstMember.value) {
addState.value = "form";
return;
}
void loadCurrentMember();
});
onUnload(() => {
const timer = submitTimer;
submitTimer = null;
if (timer) clearTimeout(timer);
loadSequence += 1;
addRequestController.abort();
discardConfirmation.dispose();
});
const requestBack = () =>
runBackGuard({
transientOpen: discardDialogVisible.value,
dirty: (addState.value === "form" || addState.value === "preview") && hasDraft.value,
dirty: addState.value === "form" && hasDraft.value,
submitting: isSubmitting.value,
"close-transient": cancelDiscard,
"block-submitting": () => true,
@@ -206,36 +251,44 @@ onBackPress((event) => handleBackPress(event, requestBack));
const clearError = (field) => { fieldErrors[field] = ""; };
const selectRelation = (event) => {
addForm.relation = relationOptions[Number(event.detail.value)] || "";
addForm.relation = relationOptions.value[Number(event.detail.value)] || "";
clearError("relation");
};
const selectGender = (event) => {
addForm.gender = genderOptions[Number(event.detail.value)] || "";
clearError("gender");
};
const selectBirthDate = (event) => { addForm.birthDate = event.detail.value || ""; };
const validateAddForm = () => {
fieldErrors.name = addForm.name.trim() ? "" : "请填写成员姓名";
fieldErrors.relation = isFirstMember.value || addForm.relation ? "" : "请选择与当前成员的关系";
fieldErrors.gender = addForm.gender ? "" : "请选择性别或未说明";
return !fieldErrors.name && !fieldErrors.relation && !fieldErrors.gender;
return !fieldErrors.name && !fieldErrors.relation;
};
const submitAdd = () => {
const submitAdd = async () => {
if (isSubmitting.value || !validateAddForm()) return;
const submitSnapshot = Object.freeze({ ...addForm });
isSubmitting.value = true;
const timer = setTimeout(() => {
if (submitTimer !== timer) return;
addState.value = submitSnapshot.name.trim() === "失败" ? "error" : "preview";
try {
const payload = {
name: addForm.name,
birthDate: addForm.birthDate,
biography: addForm.summary,
...(isFirstMember.value ? {} : { relationName: relationLabel.value }),
};
if (isFirstMember.value) {
await appApi.createPerson(genealogyId.value, payload, { requestController: addRequestController });
} else {
const type = activeRelationIntent.value
? relationType.value
: genericRelationTypes[relationOptions.value.indexOf(addForm.relation)];
await appApi.createRelatedPerson(genealogyId.value, personId.value, type, payload, { requestController: addRequestController });
}
Object.assign(addForm, { name: "", relation: "", birthDate: "", summary: "" });
await returnTo("T01", { genealogyId: genealogyId.value });
} catch (error) {
if (isRequestCancelled(error)) return;
errorMessage.value = error?.message || "服务未确认本次写入。";
addState.value = "error";
} finally {
isSubmitting.value = false;
submitTimer = null;
}, 280);
submitTimer = timer;
};
const retryForm = () => {
if (!hasValidContext.value) return goBack();
addState.value = "form";
}
};
const handleResultAction = () => hasValidContext.value ? (addState.value = "form") : returnToTree();
const returnToTree = async () => {
const confirmed = hasDraft.value
? await requestDiscardConfirmation()
+125 -52
View File
@@ -4,15 +4,16 @@
class="edit-member-page"
:class="{
'edit-state--form': editState === 'form',
'edit-state--preview': editState === 'preview',
'edit-state--loading': editState === 'loading',
'edit-state--error': editState === 'error',
'edit-state--no-permission': editState === 'no-permission',
}"
>
<ModulePageBackground module="tree" />
<view class="edit-member-page__header"><PageHeader title="编辑成员" custom-back @back="requestBack" /></view>
<view class="edit-member-panel">
<view v-if="editState === 'form'" class="edit-member-form">
<AppLoading v-if="editState === 'loading'" text="正在读取成员资料" description="请稍候,正在确认可编辑字段。" />
<view v-else-if="editState === 'form'" class="edit-member-form">
<text class="form-eyebrow">成员档案维护</text>
<text class="form-title">完善{{ originalMember.name }}的生命记录</text>
<text class="form-copy">基础身份用于世系展示生平说明会显示在有权限查看的成员档案中</text>
@@ -26,6 +27,10 @@
</view>
<text v-if="fieldErrors.name" class="field-error">{{ fieldErrors.name }}</text>
<view class="form-field">
<text>别名</text>
<input v-model="editForm.aliasName" placeholder="别名或曾用名" placeholder-class="form-placeholder" />
</view>
<view class="form-field">
<text>字辈</text>
<input v-model="editForm.generationName" maxlength="12" placeholder="例如:文字辈" placeholder-class="form-placeholder" />
@@ -33,19 +38,43 @@
<picker mode="date" :value="editForm.birthDate" @change="selectDate('birthDate', $event)">
<view class="form-field form-field--picker"><text>出生日期</text><text>{{ editForm.birthDate || "未填写" }}</text></view>
</picker>
<view class="form-field">
<text>出生农历</text>
<input v-model="editForm.birthLunar" placeholder="按家谱记载填写" placeholder-class="form-placeholder" />
</view>
<view class="form-field">
<text>出生地</text>
<input v-model="editForm.birthPlace" placeholder="按家谱记载填写" placeholder-class="form-placeholder" />
</view>
<picker mode="date" :value="editForm.deathDate" @change="selectDate('deathDate', $event)">
<view class="form-field form-field--picker"><text>离世日期</text><text>{{ editForm.deathDate || "在世或未填写" }}</text></view>
</picker>
<view class="form-field">
<text>逝世农历</text>
<input v-model="editForm.deathLunar" placeholder="按家谱记载填写" placeholder-class="form-placeholder" />
</view>
<view class="form-field">
<text>逝世地</text>
<input v-model="editForm.deathPlace" placeholder="按家谱记载填写" placeholder-class="form-placeholder" />
</view>
<view class="form-field">
<text>安葬地</text>
<input v-model="editForm.burialPlace" placeholder="按家谱记载填写" placeholder-class="form-placeholder" />
</view>
<view class="form-field form-field--summary">
<text>人物简介</text>
<textarea v-model="editForm.summary" auto-height maxlength="500" placeholder="记录生平、迁徙或重要经历" placeholder-class="form-placeholder" />
</view>
<view class="form-field form-field--summary">
<text>备注</text>
<textarea v-model="editForm.remark" auto-height placeholder="记录其他需要保留的信息" placeholder-class="form-placeholder" />
</view>
<text v-if="fieldErrors.dates" class="field-error">{{ fieldErrors.dates }}</text>
<text class="form-note">隐私字段只向本人和具备维护权限的家谱管理员展示</text>
<view class="form-action" @click="saveMember">
<image src="/static/assets/foundation/transparent/a01-scroll-primary-v3.png" mode="scaleToFill" />
<text>{{ isSubmitting ? "正在校验…" : "生成本地预览" }}</text>
<text>{{ isSubmitting ? "正在保存…" : "保存资料" }}</text>
</view>
</view>
@@ -78,10 +107,11 @@
<script setup>
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 ModulePageBackground from "@/components/ModulePageBackground.vue";
import PageHeader from "@/components/PageHeader.vue";
import { findTreeMemberPresentationFixture } from "@/data/mock.js";
import { appApi, createRequestController, isRequestCancelled } from "@/utils/api.js";
import { createDiscardConfirmation } from "@/utils/discard-confirmation.js";
import {
goBack,
@@ -89,16 +119,32 @@ import {
runBackGuard,
} from "@/utils/navigation.js";
const editState = ref("form");
const editState = ref("loading");
const genealogyId = ref("");
const personId = ref("");
const isSubmitting = ref(false);
const discardDialogVisible = ref(false);
let submitTimer = null;
const errorMessage = ref("");
const failedAction = ref("load");
const editRequestController = createRequestController();
let loadSequence = 0;
const originalMember = ref(null);
const baseline = ref("");
const editForm = reactive({ name: "", generationName: "", birthDate: "", deathDate: "", summary: "" });
const editForm = reactive({
name: "",
aliasName: "",
generationName: "",
birthDate: "",
birthLunar: "",
birthPlace: "",
deathDate: "",
deathLunar: "",
deathPlace: "",
burialPlace: "",
summary: "",
remark: "",
});
const fieldErrors = reactive({ name: "", dates: "" });
const formSnapshot = computed(() => JSON.stringify(editForm));
@@ -107,16 +153,16 @@ const hasValidContext = computed(() =>
);
const isDirty = computed(
() =>
(editState.value === "form" || editState.value === "preview") &&
editState.value === "form" &&
Boolean(baseline.value) &&
formSnapshot.value !== baseline.value,
);
const resultCopy = computed(() => ({
preview: { eyebrow: "本地流程预览", title: `${editForm.name}的资料已通过本地校验`, copy: "尚未提交服务器,返回成员档案后不会显示本次修改。", action: "返回成员档案(不保存)" },
error: hasValidContext.value
? { eyebrow: "资料未保存", title: "暂时无法保存成员档案", copy: "当前修改仍保留,可返回表单后重试。", action: "返回修改" }
: { eyebrow: "成员入口无效", title: "没有找到要编辑的成员", copy: "请从成员档案重新进入,页面不会创建临时成员身份。", action: "返回上一页" },
"no-permission": { eyebrow: "权限不足", title: "当前账号不能编辑这位成员", copy: "本人或具备成员维护权限的家谱管理员才能修改档案。", action: "返回成员档案" },
error: failedAction.value === "save"
? { eyebrow: "保存失败", title: "成员档案尚未保存", copy: errorMessage.value || "服务未确认本次修改,请检查填写后重试。", action: "返回修改" }
: hasValidContext.value
? { eyebrow: "资料读取失败", title: "暂时无法读取成员档案", copy: errorMessage.value || "请返回后重试。", action: "重新读取" }
: { eyebrow: "成员入口无效", title: "没有找到要编辑的成员", copy: errorMessage.value || "请从成员档案重新进入。", action: "返回上一页" },
}[editState.value] || {}));
const discardConfirmation = createDiscardConfirmation((visible) => {
discardDialogVisible.value = visible;
@@ -125,49 +171,55 @@ const requestDiscardConfirmation = discardConfirmation.request;
const confirmDiscard = discardConfirmation.confirm;
const cancelDiscard = discardConfirmation.cancel;
const loadMember = (id) => {
const member = findTreeMemberPresentationFixture(genealogyId.value, id);
if (!member) return false;
originalMember.value = { ...member };
if (["privacy", "forbidden"].includes(member.status)) {
Object.assign(editForm, {
name: member.name,
generationName: "",
birthDate: "",
deathDate: "",
summary: "",
const loadMember = async () => {
const activeLoad = ++loadSequence;
editState.value = "loading";
try {
const member = await appApi.getPerson(genealogyId.value, personId.value, {
requestController: editRequestController,
});
} else {
if (activeLoad !== loadSequence) return;
originalMember.value = member;
Object.assign(editForm, {
name: member.name,
aliasName: member.aliasName || "",
generationName: member.generationName || "",
birthDate: member.birthDate || "",
birthLunar: member.birthLunar || "",
birthPlace: member.birthplace || "",
deathDate: member.deathDate || "",
summary: member.summary || "",
deathLunar: member.deathLunar || "",
deathPlace: member.deathPlace || "",
burialPlace: member.burialPlace || "",
summary: member.biography || "",
remark: member.remark || "",
});
baseline.value = formSnapshot.value;
errorMessage.value = "";
failedAction.value = "load";
editState.value = "form";
} catch (error) {
if (activeLoad !== loadSequence || isRequestCancelled(error)) return;
originalMember.value = null;
failedAction.value = "load";
errorMessage.value = error?.message || "成员资料暂不可用。";
editState.value = "error";
}
baseline.value = formSnapshot.value;
return true;
};
onLoad((query) => {
genealogyId.value = String(query.genealogyId || "");
personId.value = String(query.personId || "");
const loaded = Boolean(genealogyId.value && personId.value) && loadMember(personId.value);
editState.value = !loaded
? "error"
: ["privacy", "forbidden"].includes(originalMember.value.status)
? "no-permission"
: query.state === "preview"
? "preview"
: query.state === "error"
? "error"
: "form";
if (!genealogyId.value || !personId.value || query.state === "error") {
editState.value = "error";
errorMessage.value = "当前家谱或成员上下文无效。";
return;
}
void loadMember();
});
onUnload(() => {
const timer = submitTimer;
submitTimer = null;
if (timer) clearTimeout(timer);
loadSequence += 1;
editRequestController.abort();
discardConfirmation.dispose();
});
@@ -193,17 +245,34 @@ const validateEditForm = () => {
fieldErrors.dates = editForm.birthDate && editForm.deathDate && editForm.deathDate < editForm.birthDate ? "离世日期不能早于出生日期" : "";
return !fieldErrors.name && !fieldErrors.dates;
};
const saveMember = () => {
const saveMember = async () => {
if (isSubmitting.value || !validateEditForm()) return;
const submitSnapshot = Object.freeze({ ...editForm });
isSubmitting.value = true;
const timer = setTimeout(() => {
if (submitTimer !== timer) return;
editState.value = submitSnapshot.name.trim() === "失败" ? "error" : "preview";
try {
await appApi.updatePerson(genealogyId.value, personId.value, {
name: editForm.name,
aliasName: editForm.aliasName,
generationName: editForm.generationName,
birthDate: editForm.birthDate,
birthLunar: editForm.birthLunar,
birthPlace: editForm.birthPlace,
deathDate: editForm.deathDate,
deathLunar: editForm.deathLunar,
deathPlace: editForm.deathPlace,
burialPlace: editForm.burialPlace,
biography: editForm.summary,
remark: editForm.remark,
}, { requestController: editRequestController });
baseline.value = formSnapshot.value;
await goBack();
} catch (error) {
if (isRequestCancelled(error)) return;
failedAction.value = "save";
errorMessage.value = error?.message || "服务未确认本次修改。";
editState.value = "error";
} finally {
isSubmitting.value = false;
submitTimer = null;
}, 280);
submitTimer = timer;
}
};
const returnToMember = async () => {
const confirmed = isDirty.value
@@ -213,11 +282,15 @@ const returnToMember = async () => {
return goBack();
};
const handleResultAction = () => {
if (editState.value === "error" && hasValidContext.value) {
if (failedAction.value === "save") {
editState.value = "form";
return;
}
return returnToMember();
if (editState.value === "error") {
if (!genealogyId.value || !personId.value) return returnToMember();
return loadMember();
}
editState.value = "form";
};
</script>
+80 -245
View File
@@ -1,291 +1,126 @@
<!-- 页面编号T-06用途选择两位现有成员并校正其家族关系 -->
<!-- 页面编号T-06用途展示指定成员的同辈排行调整入口 -->
<template>
<view
class="relationship-page"
class="rank-page"
:class="{
'relationship-state--form': relationshipState === 'form',
'relationship-state--preview': relationshipState === 'preview',
'relationship-state--conflict': relationshipState === 'conflict',
'relationship-state--error': relationshipState === 'error',
'rank-state--loading': rankState === 'loading',
'rank-state--form': rankState === 'form',
'rank-state--unavailable': rankState === 'unavailable',
'rank-state--error': rankState === 'error',
}"
>
<ModulePageBackground module="tree" />
<view class="relationship-page__header"><PageHeader title="关系维护" custom-back @back="requestBack" /></view>
<view class="relationship-panel">
<view v-if="relationshipState === 'form'" class="relationship-form">
<text class="form-eyebrow">亲属关系校正</text>
<text class="form-title">确认两位成员的家族关系</text>
<text class="form-copy">选择成员和关系类型后先查看对世系的影响再决定是否保存</text>
<view class="rank-page__header"><PageHeader title="调整排行" custom-back @back="goBack" /></view>
<view class="rank-panel">
<AppLoading v-if="rankState === 'loading'" text="正在读取成员资料" description="请稍候,正在确认待调整人物。" />
<picker :range="memberLabels" :value="sourceIndex" @change="selectMember('sourceId', $event)">
<view class="form-field form-field--picker"><text>当前成员</text><text>{{ memberName(relationshipForm.sourceId) || "请选择成员" }}</text></view>
</picker>
<text v-if="fieldErrors.sourceId" class="field-error">{{ fieldErrors.sourceId }}</text>
<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>
<picker :range="memberLabels" :value="targetIndex" @change="selectMember('targetId', $event)">
<view class="form-field form-field--picker"><text>关联成员</text><text>{{ memberName(relationshipForm.targetId) || "请选择另一位成员" }}</text></view>
</picker>
<text v-if="fieldErrors.targetId" class="field-error">{{ fieldErrors.targetId }}</text>
<picker :range="relationshipOptions" :value="relationshipIndex" @change="selectRelationship">
<view class="form-field form-field--picker"><text>关系类型</text><text>{{ relationshipForm.relationship || "请选择关系" }}</text></view>
</picker>
<text v-if="fieldErrors.relationship" class="field-error">{{ fieldErrors.relationship }}</text>
<view class="relationship-preview">
<text>关系影响预览</text>
<text>{{ relationshipPreview }}</text>
<view class="member-context">
<text>当前成员</text><text> {{ member.generation }} · {{ member.branch }}</text>
</view>
<text class="form-note">父母子女关系会改变世系位置配偶和兄弟姐妹关系不会自动改写现有父母</text>
<view class="form-action" @click="saveRelationship">
<view class="rank-notice">
<text>当前服务状态</text>
<text>尚未提供带版本冲突处理和完整结果的同辈原子重排合同</text>
</view>
<view class="form-action" @click="showRankUnavailable">
<image src="/static/assets/foundation/transparent/a01-scroll-primary-v3.png" mode="scaleToFill" />
<text>{{ isSubmitting ? "正在校验…" : "生成本地预览" }}</text>
<text>查看服务状态</text>
</view>
</view>
<view v-else class="relationship-result">
<text class="form-eyebrow">{{ resultCopy.eyebrow }}</text>
<text class="form-title">{{ resultCopy.title }}</text>
<text class="form-copy">{{ resultCopy.copy }}</text>
<view v-if="relationshipState === 'conflict'" class="result-actions">
<view class="form-action form-action--secondary" @click="relationshipState = 'form'">
<image src="/static/assets/foundation/transparent/a01-scroll-secondary-v3.png" mode="scaleToFill" /><text>返回核对</text>
</view>
<view class="form-action" @click="conflictDialogVisible = true">
<image src="/static/assets/foundation/transparent/a01-scroll-primary-v3.png" mode="scaleToFill" /><text>查看规则</text>
</view>
</view>
<view v-else class="form-action" @click="handleResultAction">
<view v-else class="rank-result">
<text class="form-eyebrow">{{ rankState === "unavailable" ? "服务暂未开放" : "成员入口无效" }}</text>
<text class="form-title">{{ rankState === "unavailable" ? "暂不能提交排行调整" : "暂时无法读取成员资料" }}</text>
<text class="form-copy">{{ rankState === "unavailable" ? "当前人物更新接口只有单人物 sortOrder 候选,不能保证同辈排行原子一致。本页不会逐人写入,也不会显示本地预览成功。" : errorMessage }}</text>
<view class="form-action" @click="rankState === 'unavailable' ? (rankState = 'form') : goBack()">
<image src="/static/assets/foundation/transparent/a01-scroll-primary-v3.png" mode="scaleToFill" />
<text>{{ relationshipState === "preview" ? "返回世系树(不保存)" : hasValidContext ? "重新选择" : "返回上一页" }}</text>
<text>{{ rankState === "unavailable" ? "返回查看" : "返回世系树" }}</text>
</view>
</view>
</view>
<AppDialog
:visible="conflictDialogVisible"
eyebrow="关系校验规则"
title="为什么不能保存这段关系"
:message="conflictReason || '同一成员不能成为自己的亲属,也不能形成上下代循环或重复父母关系。'"
:close-on-mask="false"
@confirm="conflictDialogVisible = false"
/>
<AppDialog
:visible="discardDialogVisible"
eyebrow="关系尚未保存"
title="要放弃本次关系调整吗"
message="当前关系只在本页预览,确认返回后不会修改世系。"
cancel-text="继续核对"
confirm-text="放弃并返回"
show-cancel
:close-on-mask="false"
@cancel="cancelDiscard"
@confirm="confirmDiscard"
/>
</view>
</template>
<script setup>
import { computed, reactive, ref } from "vue";
import { ref } from "vue";
import { onBackPress, onLoad, onUnload } from "@dcloudio/uni-app";
import AppDialog from "@/components/AppDialog.vue";
import AppLoading from "@/components/AppLoading.vue";
import ModulePageBackground from "@/components/ModulePageBackground.vue";
import PageHeader from "@/components/PageHeader.vue";
import { listTreeMemberFixtures } from "@/data/mock.js";
import { createDiscardConfirmation } from "@/utils/discard-confirmation.js";
import {
goBack,
handleBackPress,
returnTo,
runBackGuard,
} from "@/utils/navigation.js";
import { appApi, createRequestController, isRequestCancelled } from "@/utils/api.js";
import { goBack, handleBackPress } from "@/utils/navigation.js";
const relationshipState = ref("form");
const rankState = ref("loading");
const genealogyId = ref("");
const personId = ref("");
const isSubmitting = ref(false);
const conflictDialogVisible = ref(false);
const discardDialogVisible = ref(false);
const conflictReason = ref("");
let submitTimer = null;
const member = ref(null);
const errorMessage = ref("");
const rankRequestController = createRequestController();
let loadSequence = 0;
const memberOptions = computed(() => listTreeMemberFixtures(genealogyId.value));
const relationshipOptions = ["父子(当前成员为父)", "父女(当前成员为父)", "母子(当前成员为母)", "母女(当前成员为母)", "配偶", "兄弟姐妹"];
const relationshipForm = reactive({ sourceId: "", targetId: "", relationship: "" });
const fieldErrors = reactive({ sourceId: "", targetId: "", relationship: "" });
const baseline = ref("");
const memberLabels = computed(() => memberOptions.value.map((item) => `${item.name} · 第 ${item.generation}`));
const memberById = computed(() => new Map(memberOptions.value.map((item) => [item.id, item])));
const hasValidContext = computed(() =>
Boolean(genealogyId.value && memberById.value.has(personId.value)),
);
const sourceIndex = computed(() => Math.max(0, memberOptions.value.findIndex((item) => item.id === relationshipForm.sourceId)));
const targetIndex = computed(() => Math.max(0, memberOptions.value.findIndex((item) => item.id === relationshipForm.targetId)));
const relationshipIndex = computed(() => Math.max(0, relationshipOptions.indexOf(relationshipForm.relationship)));
const memberName = (id) => memberById.value.get(String(id))?.name || "";
const relationshipPreview = computed(
() => relationshipForm.sourceId && relationshipForm.targetId && relationshipForm.relationship
? `${memberName(relationshipForm.sourceId)}将以“${relationshipForm.relationship}”关联${memberName(relationshipForm.targetId)}`
: "完成三项选择后,这里会说明世系位置将如何变化。",
);
const formSnapshot = computed(() => JSON.stringify(relationshipForm));
const isDirty = computed(() =>
Boolean(baseline.value) && formSnapshot.value !== baseline.value,
);
const resultCopy = computed(() => ({
preview: { eyebrow: "本地流程预览", title: "关系校验已经通过", copy: `${relationshipPreview.value} 尚未提交服务器,返回后世系不会变化。` },
conflict: { eyebrow: "发现关系冲突", title: "这段关系会造成世系矛盾", copy: conflictReason.value },
error: hasValidContext.value
? { eyebrow: "关系未保存", title: "暂时无法完成关系调整", copy: "当前选择仍然保留,可返回后重新校验。" }
: { eyebrow: "成员入口无效", title: "没有找到要调整的成员", copy: "请从世系树重新进入,页面不会回退到其他成员。" },
}[relationshipState.value] || {}));
const discardConfirmation = createDiscardConfirmation((visible) => {
discardDialogVisible.value = visible;
});
const requestDiscardConfirmation = discardConfirmation.request;
const confirmDiscard = discardConfirmation.confirm;
const cancelDiscard = discardConfirmation.cancel;
const loadMember = async () => {
const activeLoad = ++loadSequence;
rankState.value = "loading";
try {
const result = await appApi.getPerson(genealogyId.value, personId.value, {
requestController: rankRequestController,
});
if (activeLoad !== loadSequence) return;
member.value = result;
errorMessage.value = "";
rankState.value = "form";
} catch (error) {
if (activeLoad !== loadSequence || isRequestCancelled(error)) return;
member.value = null;
errorMessage.value = error?.message || "当前成员资料暂不可用,请返回世系树后重试。";
rankState.value = "error";
}
};
const showRankUnavailable = () => {
rankState.value = "unavailable";
};
onLoad((query) => {
genealogyId.value = String(query.genealogyId || "");
personId.value = String(query.personId || "");
relationshipForm.sourceId = hasValidContext.value ? personId.value : "";
baseline.value = formSnapshot.value;
relationshipState.value = !hasValidContext.value || query.state === "error"
? "error"
: query.state === "preview"
? "preview"
: query.state === "conflict"
? "conflict"
: "form";
if (relationshipState.value === "conflict") conflictReason.value = "目标成员已经存在父级关系,请先核对原关系。";
if (!genealogyId.value || !personId.value || query.mode !== "rank") {
rankState.value = "error";
errorMessage.value = "当前排行入口无效,请从人物操作面板重新进入。";
return;
}
void loadMember();
});
onUnload(() => {
const timer = submitTimer;
submitTimer = null;
if (timer) clearTimeout(timer);
discardConfirmation.dispose();
loadSequence += 1;
rankRequestController.abort();
});
const closeTransient = () => {
if (conflictDialogVisible.value) {
conflictDialogVisible.value = false;
return;
}
cancelDiscard();
};
const requestBack = () =>
runBackGuard({
transientOpen: conflictDialogVisible.value || discardDialogVisible.value,
dirty: isDirty.value,
submitting: isSubmitting.value,
"close-transient": closeTransient,
"block-submitting": () => true,
"confirm-discard": requestDiscardConfirmation,
});
onBackPress((event) => handleBackPress(event, requestBack));
const clearFieldError = (field) => { fieldErrors[field] = ""; };
const selectMember = (field, event) => {
relationshipForm[field] = memberOptions.value[Number(event.detail.value)]?.id || "";
clearFieldError(field);
};
const selectRelationship = (event) => {
relationshipForm.relationship = relationshipOptions[Number(event.detail.value)] || "";
clearFieldError("relationship");
};
const isAncestor = (possibleAncestorId, memberId) => {
let current = memberById.value.get(String(memberId));
const visited = new Set();
while (current?.parentId && !visited.has(current.id)) {
if (current.parentId === String(possibleAncestorId)) return true;
visited.add(current.id);
current = memberById.value.get(current.parentId);
}
return false;
};
const validateRelationship = () => {
fieldErrors.sourceId = relationshipForm.sourceId ? "" : "请选择当前成员";
fieldErrors.targetId = relationshipForm.targetId ? "" : "请选择关联成员";
fieldErrors.relationship = relationshipForm.relationship ? "" : "请选择关系类型";
if (fieldErrors.sourceId || fieldErrors.targetId || fieldErrors.relationship) return false;
if (relationshipForm.sourceId === relationshipForm.targetId) {
conflictReason.value = "同一成员不能与自己建立亲属关系。";
return false;
}
const isParentRelationship = relationshipForm.relationship.includes("当前成员为");
const target = memberById.value.get(relationshipForm.targetId);
if (isParentRelationship && isAncestor(relationshipForm.targetId, relationshipForm.sourceId)) {
conflictReason.value = "保存后会形成上下代循环,请重新选择成员方向。";
return false;
}
if (isParentRelationship && target?.parentId && target.parentId !== relationshipForm.sourceId) {
conflictReason.value = `${target.name}已经存在父级成员,不能直接重复建立父母关系。`;
return false;
}
conflictReason.value = "";
return true;
};
const saveRelationship = () => {
if (isSubmitting.value) return;
if (!validateRelationship()) {
if (conflictReason.value) relationshipState.value = "conflict";
return;
}
const submitSnapshot = Object.freeze({ ...relationshipForm });
isSubmitting.value = true;
const timer = setTimeout(() => {
if (submitTimer !== timer) return;
relationshipState.value = submitSnapshot.relationship === "失败" ? "error" : "preview";
isSubmitting.value = false;
submitTimer = null;
}, 280);
submitTimer = timer;
};
const returnToTree = async () => {
const confirmed = isDirty.value
? await requestDiscardConfirmation()
: true;
if (!confirmed) return false;
return returnTo("T01", { genealogyId: genealogyId.value });
};
const handleResultAction = () => {
if (relationshipState.value === "error") {
if (!hasValidContext.value) return goBack();
relationshipState.value = "form";
return;
}
return returnToTree();
};
onBackPress((event) => handleBackPress(event, goBack));
</script>
<style scoped lang="scss">
@use "../../styles/adaptive-frame-profiles.scss" as adaptive;
.relationship-page { display: flex; min-height: 100vh; flex-direction: column; background: $paper; }
.relationship-page__header { z-index: 3; }
.relationship-panel { @include adaptive.adaptive-tree-panel; z-index: 2; width: calc(100% - 32rpx); margin: 18rpx auto 28rpx; padding: 7.5% 8%; }
.rank-page { display: flex; min-height: 100vh; flex-direction: column; background: $paper; }
.rank-page__header { z-index: 3; }
.rank-panel { @include adaptive.adaptive-tree-panel; z-index: 2; width: calc(100% - 32rpx); margin: 18rpx auto 28rpx; padding: 7.5% 8%; }
.form-eyebrow { display: block; color: $brand-red; font-size: 22rpx; letter-spacing: 3rpx; }
.form-title { display: block; margin-top: 10rpx; color: $ink; font-family: "STKaiti", "KaiTi", serif; font-size: 34rpx; font-weight: 700; line-height: 1.35; }
.form-copy, .form-note { display: block; margin-top: 10rpx; color: $ink-muted; font-size: 24rpx; line-height: 1.5; }
.form-field { @include adaptive.adaptive-tree-field; display: grid; grid-template-columns: auto minmax(0, 1fr); min-height: 78rpx; align-items: center; gap: 20rpx; margin-top: 14rpx; padding: 12rpx 22rpx; }
.form-field text:first-child { color: $ink; font-size: 24rpx; font-weight: 700; }
.form-field text:last-child { min-width: 0; color: $ink; font-size: 24rpx; line-height: 1.45; text-align: right; }
.field-error { display: block; margin: 5rpx 18rpx 0; color: $brand-red; font-size: 22rpx; line-height: 32rpx; }
.relationship-preview { @include adaptive.adaptive-genealogy-list-card; margin-top: 18rpx; padding: 20rpx 22rpx; }
.relationship-preview text { display: block; color: $ink-muted; font-size: 23rpx; line-height: 1.5; }
.relationship-preview text:first-child { color: $brand-red; font-weight: 700; }
.form-note { text-align: center; }
.form-action { display: grid; width: 100%; min-height: 76rpx; margin-top: 18rpx; }
.form-copy { display: block; margin-top: 10rpx; color: $ink-muted; font-size: 24rpx; line-height: 1.5; }
.member-context, .rank-notice { @include adaptive.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-notice text:first-child { color: $ink; font-size: 24rpx; font-weight: 700; }
.member-context text:last-child, .rank-notice text:last-child { min-width: 0; color: $ink-muted; font-size: 23rpx; line-height: 1.45; text-align: right; }
.form-action { display: grid; width: 100%; min-height: 76rpx; margin-top: 22rpx; }
.form-action image, .form-action text { grid-area: 1 / 1; width: 100%; height: 100%; }
.form-action text { z-index: 1; display: flex; align-items: center; justify-content: center; color: #fff9ed; font-size: 24rpx; font-weight: 700; }
.form-action--secondary text { color: $ink; }
.relationship-result { margin-top: 28%; text-align: center; }
.relationship-result .form-eyebrow, .relationship-result .form-copy { text-align: center; }
.relationship-result > .form-action { width: 420rpx; max-width: 100%; margin-right: auto; margin-left: auto; }
.result-actions { display: flex; gap: 14rpx; margin-top: 24rpx; }
.result-actions .form-action { width: calc(50% - 7rpx); margin-top: 0; }
@media (min-width: 400px) { .relationship-panel { width: calc(100% - 48rpx); } }
.rank-result { margin-top: 30%; text-align: center; }
.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); } }
</style>
+63 -35
View File
@@ -14,7 +14,7 @@
<view v-if="hasValidContext" class="directory-context">
<text class="directory-context__name">汤氏家谱</text>
<text class="directory-context__meta"
>主支 · {{ members.length }} 位成员</text
>已加载 {{ members.length }} / {{ total }} 位成员</text
>
</view>
<view
@@ -46,10 +46,10 @@
<template v-else-if="directoryState === 'list'">
<view class="directory-summary"
><text>家谱成员</text
><text>{{ filteredMembers.length }} · 按世代排列</text></view
><text>{{ members.length }} / {{ total }} · 按世代排列</text></view
>
<view
v-for="item in filteredMembers"
v-for="item in members"
:key="item.id"
class="directory-card"
@click="openMember(item)"
@@ -60,6 +60,13 @@
<text class="directory-card__status">{{ memberStatus(item) }}</text>
</view>
</view>
<AppButton
v-if="hasMore"
block
type="secondary"
:label="loadingMore ? '正在加载…' : '加载更多成员'"
@click="loadMore"
/>
</template>
<view v-else class="directory-state-card">
<view
@@ -85,61 +92,82 @@
</template>
<script setup>
import { computed, ref } from "vue";
import { onLoad } from "@dcloudio/uni-app";
import { onLoad, onUnload } from "@dcloudio/uni-app";
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 { listTreeMemberPresentationFixtures } from "@/data/mock.js";
import { appApi, createRequestController, isRequestCancelled } from "@/utils/api.js";
import { goBack, openPage } from "@/utils/navigation.js";
const genealogyId = ref("");
const keyword = ref("");
const directoryState = ref("loading");
const members = ref([]);
const total = ref(0);
const pageNum = ref(1);
const loadingMore = ref(false);
const directoryRequestController = createRequestController();
let loadSequence = 0;
const hasValidContext = computed(() => Boolean(genealogyId.value));
const members = computed(() => listTreeMemberPresentationFixtures(genealogyId.value));
const isRestrictedMember = (member) =>
["privacy", "forbidden"].includes(member.status);
const memberMeta = (member) =>
isRestrictedMember(member)
? `${member.generation} 世 · ${member.relation}`
: `${member.generation} 世 · ${member.generationName} · ${member.branch}`;
`${member.generation} 世 · ${member.generationName || "字辈待补"} · ${member.branch}`;
const memberStatus = (member) =>
isRestrictedMember(member)
? member.status === "forbidden"
? "访问受限"
: "隐私资料"
: member.note;
const filteredMembers = computed(() => {
const value = keyword.value.trim();
if (!value) return members.value;
return members.value.filter((item) =>
`${item.name}${item.relation}${item.generation}${item.generationName || ""}${item.branch || ""}`.includes(value),
);
});
member.personStatus ? `人物状态:${member.personStatus}` : "人物状态待补";
const hasMore = computed(() => members.value.length < total.value);
const loadMembers = async ({ append = false } = {}) => {
if (!hasValidContext.value) return;
const activeLoad = ++loadSequence;
if (append) loadingMore.value = true;
else directoryState.value = "loading";
try {
const result = await appApi.getPersonPage(
genealogyId.value,
{ pageNum: pageNum.value, pageSize: 10, keyword: keyword.value },
{ requestController: directoryRequestController },
);
if (activeLoad !== loadSequence) return;
members.value = append ? [...members.value, ...result.rows] : result.rows;
total.value = result.total;
directoryState.value = members.value.length ? "list" : "empty";
} catch (error) {
if (activeLoad !== loadSequence || isRequestCancelled(error)) return;
if (!append) members.value = [];
directoryState.value = "error";
} finally {
if (activeLoad === loadSequence) loadingMore.value = false;
}
};
onLoad((query) => {
genealogyId.value = String(query.genealogyId || "");
directoryState.value =
!hasValidContext.value ? "error" : query.state === "loading"
? "loading"
: query.state === "empty"
? "empty"
: query.state === "error"
? "error"
: members.value.length
? "list"
: "empty";
if (!hasValidContext.value || query.state === "error") {
directoryState.value = "error";
return;
}
void loadMembers();
});
const searchMembers = () => {
directoryState.value = filteredMembers.value.length ? "list" : "empty";
pageNum.value = 1;
void loadMembers();
};
const loadMore = () => {
if (loadingMore.value || !hasMore.value) return;
pageNum.value += 1;
void loadMembers({ append: true });
};
const retryDirectory = () => {
if (!hasValidContext.value) return goBack();
directoryState.value = "list";
pageNum.value = 1;
return loadMembers();
};
const openMember = (item) =>
hasValidContext.value
? openPage("T03", { genealogyId: genealogyId.value, personId: String(item.id) }, "T07")
: Promise.resolve(false);
onUnload(() => {
loadSequence += 1;
directoryRequestController.abort();
});
</script>
<style scoped lang="scss">
@use "../../styles/adaptive-frame-profiles.scss" as adaptive;
+60 -49
View File
@@ -3,9 +3,7 @@
<view
class="member-status-page"
:class="{
'member-status--privacy': statusState === 'privacy',
'member-status--deceased': statusState === 'deceased',
'member-status--forbidden': statusState === 'forbidden',
'member-status--available': statusState === 'available',
'member-status--error': statusState === 'error',
}"
>
@@ -19,21 +17,26 @@
</view>
<view class="status-card">
<view class="status-card__copy">
<AppLoading
v-if="statusState === 'loading'"
text="正在读取成员状态"
description="请稍候,正在读取人物详情中的状态字段。"
/>
<view v-else class="status-card__copy">
<text>{{ activeStatus.eyebrow }}</text>
<text>{{ activeStatus.title }}</text>
<text>{{ activeStatus.copy }}</text>
</view>
</view>
<view class="status-guidance">
<view v-if="statusState !== 'loading'" class="status-guidance">
<view>
<text>{{ activeStatus.guideTitle }}</text>
<text v-for="line in activeStatus.guides" :key="line">{{ line }}</text>
</view>
</view>
<view class="status-action" @click="handleAction">
<view v-if="statusState !== 'loading'" class="status-action" @click="handleAction">
<image src="/static/assets/foundation/transparent/a01-scroll-primary-v3.png" mode="scaleToFill" />
<text>{{ activeStatus.action }}</text>
</view>
@@ -42,43 +45,38 @@
<script setup>
import { computed, ref } from "vue";
import { onLoad } from "@dcloudio/uni-app";
import { onLoad, onUnload } from "@dcloudio/uni-app";
import AppLoading from "@/components/AppLoading.vue";
import ModulePageBackground from "@/components/ModulePageBackground.vue";
import PageHeader from "@/components/PageHeader.vue";
import { findTreeMemberPresentationFixture } from "@/data/mock.js";
import { goBack, goRoot } from "@/utils/navigation.js";
import { appApi, createRequestController, isRequestCancelled } from "@/utils/api.js";
import { goBack } from "@/utils/navigation.js";
const genealogyId = ref("");
const personId = ref("");
const statusState = ref("loading");
const genealogyName = ref("汤氏家谱");
const member = ref(null);
const statusRequestController = createRequestController();
let loadSequence = 0;
const states = {
privacy: {
eyebrow: "隐私成员",
title: "敏感资料只向授权成员展示",
copy: "该成员姓名和世系位置仍然保留,出生信息、联系方式和生平内容按权限隐藏。",
guideTitle: "当前可见范围",
guides: ["姓名、世代与家族关系可见", "联系方式和详细生平已隐藏", "本人或谱主可维护授权范围"],
action: "返回成员档案",
loading: {
eyebrow: "正在读取",
title: "正在读取成员状态",
copy: "",
guideTitle: "",
guides: [],
action: "",
},
deceased: {
eyebrow: "离世纪念",
title: "这位家人的生命记录被温和保留",
copy: "离世状态不会删除成员关系;有权限的家人仍可共同维护生卒年月、生平和追思资料。",
guideTitle: "纪念资料范围",
guides: ["生卒年月与世系关系继续保留", "生平内容由有权限家人维护", "敏感资料继续遵守原有隐私设置"],
available: {
eyebrow: "人物状态",
title: "当前状态已由服务端返回",
copy: "页面只展示人物详情中的原始状态值,不把未声明的字典值解释成隐私、纪念或访问限制。",
guideTitle: "当前合同范围",
guides: ["人物状态值来自 personStatus 字段", "状态展示文案需要后端提供字典 owner", "当前页不执行停用或任何状态写入"],
action: "返回成员档案",
},
forbidden: {
eyebrow: "访问受限",
title: "当前账号没有查看该档案的权限",
copy: "页面不会展示被隐藏字段,也不会提供绕过家谱权限的入口。",
guideTitle: "如何申请查看",
guides: ["先确认已经加入对应家谱", "联系谱主说明亲属关系和用途", "权限变更后重新进入成员档案"],
action: "返回我的家谱",
},
error: {
eyebrow: "成员状态不可用",
title: "没有找到要查看的成员",
@@ -89,35 +87,48 @@ const states = {
},
};
const activeStatus = computed(() => states[statusState.value] || states.error);
const pageTitle = computed(() => statusState.value === "deceased" ? "成员纪念" : statusState.value === "privacy" ? "隐私资料" : "成员状态");
const pageTitle = computed(() => "成员状态");
const hasValidContext = computed(() => Boolean(genealogyId.value && personId.value));
const memberIdentityCopy = computed(() => {
if (!member.value) return "";
const identity = `${member.value.name} · 第 ${member.value.generation}`;
return ["privacy", "forbidden"].includes(member.value.status)
? `${identity} · ${member.value.relation}`
: `${identity} · ${member.value.branch}`;
const status = member.value.personStatus || "未填写";
return `${member.value.name} · 第 ${member.value.generation} 世 · 状态值 ${status}`;
});
const loadMember = async () => {
const activeLoad = ++loadSequence;
statusState.value = "loading";
try {
const result = await appApi.getPerson(genealogyId.value, personId.value, {
requestController: statusRequestController,
});
if (activeLoad !== loadSequence) return;
member.value = result;
genealogyName.value = result.genealogyName;
statusState.value = "available";
} catch (error) {
if (activeLoad !== loadSequence || isRequestCancelled(error)) return;
member.value = null;
statusState.value = "error";
}
};
onLoad((query) => {
genealogyId.value = String(query.genealogyId || "");
personId.value = String(query.personId || "");
member.value = hasValidContext.value
? findTreeMemberPresentationFixture(genealogyId.value, personId.value)
: null;
const requestedState = ["privacy", "deceased", "forbidden"].includes(query.state) ? query.state : "";
const derivedState = member.value?.status || "";
statusState.value = member.value && (!requestedState || requestedState === derivedState)
? requestedState || derivedState
: "error";
if (!hasValidContext.value || query.state === "error") {
statusState.value = "error";
return;
}
void loadMember();
});
const handleAction = () => {
if (statusState.value === "forbidden") {
return goRoot("G01");
}
return goBack();
};
onUnload(() => {
loadSequence += 1;
statusRequestController.abort();
});
const handleAction = () => goBack();
</script>
<style scoped lang="scss">