This commit is contained in:
rain
2026-07-24 18:03:02 +08:00
parent c7f278fe79
commit ec8486b035
86 changed files with 7943 additions and 1841 deletions
+34 -100
View File
@@ -1,4 +1,4 @@
<!-- 页面编号A-01用途APP 唯一登录入口承载密码验证码与微信登录入口 -->
<!-- 页面编号A-01用途APP 唯一登录入口承载密码与验证码登录 -->
<template>
<AuthPageShell class="auth-page login-page">
<view class="login-content">
@@ -35,7 +35,7 @@
</view>
<view class="form-content">
<view class="input-row">
<view class="input-row input-row--required">
<image
class="input-icon"
src="/static/assets/modules/auth/transparent/a01-icon-phone-v1.png"
@@ -55,7 +55,7 @@
/>
</view>
<view v-if="activeLoginMethod === 'password'" class="input-row">
<view v-if="activeLoginMethod === 'password'" class="input-row input-row--required">
<image
class="input-icon"
src="/static/assets/modules/auth/transparent/a01-icon-lock-v1.png"
@@ -94,7 +94,7 @@
</button>
</view>
<view v-else class="input-row">
<view v-else class="input-row input-row--required">
<image
class="input-icon input-icon--sms"
src="/static/assets/modules/auth/transparent/a01-icon-sms-code-v2.png"
@@ -155,43 +155,6 @@
}}</text>
</button>
<view class="other-login-divider">
<view class="divider-line" />
<image
class="divider-knot"
src="/static/assets/foundation/transparent/auth-divider-knot.png"
mode="aspectFit"
/>
<text>其他登录方式</text>
<image
class="divider-knot divider-knot--right"
src="/static/assets/foundation/transparent/auth-divider-knot.png"
mode="aspectFit"
/>
<view class="divider-line" />
</view>
<button
class="auth-plain-button wechat-login"
:disabled="submitting || sendingCode || tacVisible"
hover-class="button-pressed"
@click="prepareWechatLogin"
>
<image
class="button-skin"
src="/static/assets/foundation/transparent/a01-scroll-secondary-v3.png"
mode="aspectFit"
/>
<view class="wechat-login__content">
<image
class="wechat-icon"
src="/static/assets/foundation/transparent/auth-wechat.png"
mode="aspectFit"
/>
<text>微信登录</text>
</view>
</button>
<view class="register-entry">
<text>还没有账号</text>
<button
@@ -281,6 +244,7 @@ import {
import { createAuthSmsCooldown } from "@/utils/auth-sms-cooldown.js";
import { runtimeConfig } from "@/utils/config.js";
import { calcMD5 } from "@/utils/md5.js";
import { session } from "@/utils/session.js";
import {
goRoot,
handleBackPress,
@@ -324,8 +288,6 @@ onUnload(() => {
if (feedbackTimer) clearTimeout(feedbackTimer);
smsCooldown.dispose();
});
onShow(() => smsCooldown.sync());
const showFeedback = (message) => {
feedbackMessage.value = message;
feedbackVisible.value = true;
@@ -351,6 +313,23 @@ const enterAuthenticatedRoot = async () => {
}
};
const restoreAuthenticatedSession = () => {
smsCooldown.sync();
if (
authenticationCommitted.value ||
submitting.value ||
sendingCode.value ||
tacVisible.value ||
!session.getToken()
) {
return;
}
authenticationCommitted.value = true;
void enterAuthenticatedRoot();
};
onShow(restoreAuthenticatedSession);
const blockBusyAction = () => {
if (!sendingCode.value && !submitting.value) return false;
showFeedback("请求处理中,请稍候");
@@ -643,11 +622,6 @@ const prepareRegister = () => {
return openPage("A04", {}, "A01");
};
const prepareWechatLogin = () => {
if (!requireAgreement()) return;
showFeedback("微信登录功能准备中");
};
const prepareAgreement = () => showFeedback("协议页面准备中");
</script>
@@ -729,6 +703,7 @@ const prepareAgreement = () => showFeedback("协议页面准备中");
}
.input-row {
position: relative;
display: flex;
align-items: center;
box-sizing: border-box;
@@ -737,6 +712,16 @@ const prepareAgreement = () => showFeedback("协议页面准备中");
border-bottom: 1rpx solid rgba(182, 116, 43, 0.68);
}
.input-row--required::before {
position: absolute;
top: 8rpx;
left: 0;
content: "*";
color: #b42318;
font-size: 26rpx;
line-height: 1;
}
.input-icon {
flex: 0 0 auto;
width: 64rpx;
@@ -808,8 +793,7 @@ const prepareAgreement = () => showFeedback("协议页面准备中");
font-size: 28rpx;
}
.login-submit,
.wechat-login {
.login-submit {
display: grid;
place-items: center;
}
@@ -834,56 +818,6 @@ const prepareAgreement = () => showFeedback("协议页面准备中");
letter-spacing: 5rpx;
}
.other-login-divider {
display: flex;
align-items: center;
min-height: 30px;
color: #9f6a27;
font-size: 28rpx;
white-space: nowrap;
}
.divider-line {
flex: 1;
height: 1rpx;
margin: 0 8rpx;
background: rgba(180, 113, 38, 0.64);
}
.divider-knot {
width: 17rpx;
height: 17rpx;
margin-right: 7rpx;
}
.divider-knot--right {
margin: 0 0 0 7rpx;
transform: scaleX(-1);
}
.wechat-login {
min-height: var(--app-touch-min);
height: var(--app-touch-min);
color: #493323;
font-size: 30rpx;
letter-spacing: 2rpx;
}
.wechat-login__content {
z-index: 1;
display: flex;
grid-area: 1 / 1;
align-items: center;
justify-content: center;
}
.wechat-icon {
width: 48rpx;
height: 48rpx;
margin-right: 14rpx;
transform: translateX(-8rpx) scale(1.22);
}
.register-entry {
display: flex;
align-items: center;
+30 -4
View File
@@ -31,7 +31,7 @@
:class="{ 'field-block--error': fieldErrors.phone }"
>
<view class="input-row">
<label class="input-label" for="a04-phone">手机号</label>
<label class="input-label" for="a04-phone"><text class="required-mark">*</text>手机号</label>
<input
id="a04-phone"
v-model.trim="phone"
@@ -51,12 +51,26 @@
}}</text>
</view>
<view class="field-block">
<view class="input-row">
<label class="input-label" for="a04-nickname">昵称</label>
<input
id="a04-nickname"
v-model.trim="nickName"
class="auth-input"
:disabled="submitting || registrationCommitted"
placeholder="请输入昵称"
placeholder-class="placeholder"
/>
</view>
</view>
<view
class="field-block"
:class="{ 'field-block--error': fieldErrors.verificationCode }"
>
<view class="input-row code-row">
<label class="input-label" for="a04-verification-code">验证码</label>
<label class="input-label" for="a04-verification-code"><text class="required-mark">*</text>验证码</label>
<input
id="a04-verification-code"
v-model.trim="verificationCode"
@@ -89,7 +103,7 @@
:class="{ 'field-block--error': fieldErrors.password }"
>
<view class="input-row">
<label class="input-label" for="a04-password">设置密码</label>
<label class="input-label" for="a04-password"><text class="required-mark">*</text>设置密码</label>
<input
id="a04-password"
v-model="password"
@@ -114,7 +128,7 @@
:class="{ 'field-block--error': fieldErrors.confirmPassword }"
>
<view class="input-row">
<label class="input-label" for="a04-confirm-password">确认密码</label>
<label class="input-label" for="a04-confirm-password"><text class="required-mark">*</text>确认密码</label>
<input
id="a04-confirm-password"
v-model="confirmPassword"
@@ -267,6 +281,7 @@ import {
} from "@/utils/validation.js";
const phone = ref("");
const nickName = ref("");
const verificationCode = ref("");
const password = ref("");
const confirmPassword = ref("");
@@ -292,6 +307,7 @@ const isDirty = computed(() =>
!registrationCommitted.value &&
Boolean(
phone.value ||
nickName.value ||
verificationCode.value ||
password.value ||
confirmPassword.value ||
@@ -517,6 +533,7 @@ const submitRegister = async () => {
await appApi.registerWithPassword(
{
phone: phone.value,
nickName: nickName.value,
passwordHash: calcMD5(password.value),
smsCode: verificationCode.value,
},
@@ -626,6 +643,15 @@ const submitRegister = async () => {
font-size: 30rpx;
}
.required-mark {
display: inline-block;
margin-right: 4rpx;
color: #b42318;
font-size: 26rpx;
line-height: 1;
transform: translateY(-3rpx);
}
.auth-input {
flex: 1;
min-width: 0;
+12 -4
View File
@@ -31,7 +31,7 @@
:class="{ 'field-block--error': fieldErrors.phone }"
>
<view class="input-row">
<label class="input-label" for="a05-phone">手机号</label>
<label class="input-label" for="a05-phone"><text class="required-mark">*</text>手机号</label>
<input
id="a05-phone"
v-model.trim="phone"
@@ -56,7 +56,7 @@
:class="{ 'field-block--error': fieldErrors.verificationCode }"
>
<view class="input-row code-row">
<label class="input-label" for="a05-verification-code">验证码</label>
<label class="input-label" for="a05-verification-code"><text class="required-mark">*</text>验证码</label>
<input
id="a05-verification-code"
v-model.trim="verificationCode"
@@ -89,7 +89,7 @@
:class="{ 'field-block--error': fieldErrors.password }"
>
<view class="input-row">
<label class="input-label" for="a05-password">新密码</label>
<label class="input-label" for="a05-password"><text class="required-mark">*</text>新密码</label>
<input
id="a05-password"
v-model="password"
@@ -114,7 +114,7 @@
:class="{ 'field-block--error': fieldErrors.confirmPassword }"
>
<view class="input-row">
<label class="input-label" for="a05-confirm-password">确认新密码</label>
<label class="input-label" for="a05-confirm-password"><text class="required-mark">*</text>确认新密码</label>
<input
id="a05-confirm-password"
v-model="confirmPassword"
@@ -559,6 +559,14 @@ const submitReset = async () => {
color: #674b35;
font-size: 30rpx;
}
.required-mark {
display: inline-block;
margin-right: 4rpx;
color: #b42318;
font-size: 26rpx;
line-height: 1;
transform: translateY(-3rpx);
}
.auth-input {
flex: 1;
min-width: 0;
+19 -89
View File
@@ -1,19 +1,13 @@
<!-- 页面编号F-01用途家族动态入口列表 DTO 缺失时不展示 fixture 条目 -->
<template>
<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="feed-content">
<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 class="feed-state-card">
<view class="feed-state-card__copy"><text>{{ stateCopy.title }}</text><text>{{ stateCopy.copy }}</text></view>
</view>
<view v-if="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>
<AppLoading v-if="feedState === 'loading'" text="正在读取家族动态" description="请稍候,正在整理家族近况。" />
<view v-else-if="feedState === 'list'" class="feed-list"><view v-for="item in feeds" :key="item.id" class="feed-card" @click="openFeed(item)"><text class="feed-card__publisher">{{ item.publisher }}</text><text class="feed-card__content">{{ item.content }}</text><text class="feed-card__meta">{{ feedTypeLabel(item.type) }} · {{ item.time }}</text></view></view>
<view v-else class="feed-state-card"><view class="feed-state-card__copy"><text>{{ stateCopy.title }}</text><text>{{ stateCopy.copy }}</text></view></view>
<view class="feed-action" @click="handlePrimaryAction"><text>{{ stateCopy.action }}</text></view>
</view>
<AppTabbar active="family" />
@@ -22,93 +16,29 @@
<script setup>
import { computed, ref } from "vue";
import { onLoad } from "@dcloudio/uni-app";
import { onLoad, onShow } from "@dcloudio/uni-app";
import AppLoading from "@/components/AppLoading.vue";
import AppTabbar from "@/components/AppTabbar.vue";
import ModulePageBackground from "@/components/ModulePageBackground.vue";
import PageHeader from "@/components/PageHeader.vue";
import { appApi, isRequestCancelled } from "@/utils/api.js";
import { genealogyContext } from "@/utils/genealogy-context.js";
import { goRoot, openPage } from "@/utils/navigation.js";
const genealogyId = ref("");
const hasValidContext = ref(false);
const feedState = ref("unavailable");
const stateCopy = computed(() => {
if (!hasValidContext.value) {
return {
title: "请先选择有效家谱",
copy: "家族动态必须归属明确家谱,页面不会展示其他家谱的内容。",
action: "返回我的家谱",
};
}
return {
title: "动态列表待后端字段合同",
copy: "已找到动态列表 operation,但响应只声明通用对象数组;待后端提供动态 ID、标题、正文、发布人和时间字段后再恢复列表与详情入口。",
action: "发布家族动态",
};
});
const shortcuts = [
{ key: "articles", label: "谱文" },
{ key: "albums", label: "相册" },
{ key: "rituals", label: "礼仪" },
{ key: "memos", label: "备忘" },
{ key: "people", label: "人物录" },
{ key: "gifts", label: "贺礼簿" },
{ key: "merits", label: "功德录" },
{ key: "videos", label: "家族视频" },
];
onLoad((query) => {
const hasRouteIdentity = Object.prototype.hasOwnProperty.call(query || {}, "genealogyId");
const resolvedGenealogyId = hasRouteIdentity
? String(query?.genealogyId || "")
: String(genealogyContext.getCurrentGenealogyId() || "");
genealogyId.value = resolvedGenealogyId;
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";
});
}
});
const toPublish = () => hasValidContext.value
? openPage("F02", { genealogyId: genealogyId.value }, "F01")
: goRoot("G01");
const openSection = (key) => {
const routes = {
articles: "F04",
albums: "F07",
rituals: "R05",
memos: "R10",
people: "R01",
gifts: "R03",
merits: "R11",
videos: "F10",
};
return openPage(routes[key], { genealogyId: genealogyId.value }, "F01");
};
const handlePrimaryAction = () => hasValidContext.value ? toPublish() : goRoot("G01");
const genealogyId = ref(""); const hasValidContext = ref(false); const feedState = ref("loading"); const feeds = ref([]); let active = true;
const feedTypeLabel = (type) => String(type || "").trim().toLowerCase() === "text" ? "文字动态" : "家族动态";
const shortcuts = [{ key: "articles", label: "谱文" }, { key: "albums", label: "相册" }, { key: "rituals", label: "礼仪" }, { key: "memos", label: "备忘" }, { key: "people", label: "人物录" }, { key: "gifts", label: "贺礼簿" }, { key: "merits", label: "功德录" }, { key: "videos", label: "家族视频" }];
const stateCopy = computed(() => !hasValidContext.value ? { title: "请先选择有效家谱", copy: "家族动态必须归属明确家谱。", action: "返回我的家谱" } : feedState.value === "empty" ? { title: "还没有家族动态", copy: "发布第一条动态,记录家族此刻。", action: "发布家族动态" } : feedState.value === "error" ? { title: "动态暂时无法读取", copy: "请稍后重新读取。", action: "重新读取" } : { title: "", copy: "", action: "发布家族动态" });
const loadFeeds = async () => { if (!hasValidContext.value) return; feedState.value = "loading"; try { const rows = await appApi.getFeeds(genealogyId.value); if (!active) return; feeds.value = rows; feedState.value = feeds.value.length ? "list" : "empty"; } catch (error) { if (!active || isRequestCancelled(error)) return; feedState.value = "error"; } };
onLoad((query) => { const supplied = Object.prototype.hasOwnProperty.call(query || {}, "genealogyId"); genealogyId.value = supplied ? String(query.genealogyId || "") : String(genealogyContext.getCurrentGenealogyId() || ""); hasValidContext.value = /^[1-9]\d*$/.test(genealogyId.value); if (hasValidContext.value) void loadFeeds(); else feedState.value = "error"; });
onShow(() => { if (hasValidContext.value) void loadFeeds(); });
const toPublish = () => hasValidContext.value ? openPage("F02", { genealogyId: genealogyId.value }, "F01") : goRoot("G01");
const openFeed = (item) => openPage("F03", { genealogyId: genealogyId.value, feedId: item.id }, "F01");
const openSection = (key) => { const routes = { articles: "F04", albums: "F07", rituals: "R05", memos: "R10", people: "R01", gifts: "R03", merits: "R11", videos: "F10" }; return openPage(routes[key], { genealogyId: genealogyId.value }, "F01"); };
const handlePrimaryAction = () => feedState.value === "error" ? loadFeeds() : toPublish();
</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-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; }
.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{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));margin-top:15rpx}.feed-shortcut{min-height:48px;display:flex;align-items:center;justify-content:center}.feed-shortcut text{color:$ink;font-size:22rpx;font-weight:700}.feed-list{margin-top:22rpx}.feed-card,.feed-state-card{@include adaptive.adaptive-family-letter;box-sizing:border-box;margin-top:16rpx;padding:30rpx}.feed-card text,.feed-state-card text{display:block}.feed-card__publisher{color:$brand-red;font-size:22rpx}.feed-card__content{margin-top:12rpx;color:$ink;font-size:28rpx;line-height:1.55}.feed-card__meta{margin-top:12rpx;color:$ink-muted;font-size:21rpx}.feed-state-card{min-height:230rpx;display:flex;align-items:center;justify-content:center;text-align:center}.feed-state-card text:first-child{color:$ink;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}.feed-action text{display:flex;height:100%;align-items:center;justify-content:center;color:#fff9ed;font-size:24rpx;font-weight:700}
</style>
+76 -18
View File
@@ -1,4 +1,4 @@
<!-- 页面编号F-02用途 Apifox 已声明 feedContent 发布家族动态 -->
<!-- 页面编号F-02用途 Apifox 完整 AppFamilyFeedBody 创建家族动态 -->
<template>
<view class="publish-page" :class="`publish-state--${publishState}`">
<ModulePageBackground module="family" />
@@ -6,10 +6,31 @@
<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>填写需要的内容留空的字段由服务端按接口默认行为处理</text>
<view class="publish-field publish-field--content">
<text class="publish-field__label"><text class="required-mark">*</text>动态内容</text>
<textarea v-model="form.feedContent" auto-height placeholder="写下想对家人说的话" placeholder-class="publish-placeholder" @input="submitError = ''" />
</view>
<view class="publish-field">
<text class="publish-field__label">动态类型</text>
<input v-model="form.feedType" placeholder="留空时服务端默认为 text" placeholder-class="publish-placeholder" @input="submitError = ''" />
</view>
<view class="publish-field publish-field--media">
<view>
<text class="publish-field__label">动态配图</text>
<text class="publish-field__hint">图片选定后会先取得真实上传回执并在提交动态时关联</text>
</view>
<button class="upload-button" :disabled="isUploading || isSubmitting" @click="uploadImage">{{ isUploading ? '上传中' : '添加图片' }}</button>
<text v-for="(receipt, index) in mediaReceipts" :key="`${receipt.ossId}-${index}`" class="upload-receipt">已上传{{ receipt.fileName || '图片' }}</text>
<text v-if="uploadError" class="publish-error">{{ uploadError }}</text>
</view>
<view class="publish-field">
<text class="publish-field__label">排序值</text>
<input v-model="form.sortOrder" type="number" placeholder="留空时服务端默认为 0" placeholder-class="publish-placeholder" @input="submitError = ''" />
</view>
<text v-if="submitError" class="publish-error">{{ submitError }}</text>
<AppButton block :label="isSubmitting ? '正在提交' : '提交动态'" :disabled="isSubmitting" @click="submit" />
<AppButton block :label="isSubmitting ? '正在提交' : '提交动态'" :disabled="isSubmitting || isUploading" @click="submit" />
</view>
<view v-else class="publish-result">
<text>{{ resultCopy.title }}</text>
@@ -32,7 +53,7 @@
</template>
<script setup>
import { computed, onUnmounted, ref } from "vue";
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";
@@ -40,26 +61,33 @@ 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 { isImagePickCancelled, pickAndUploadImage } from "@/utils/resumable-image-upload.js";
import { goBack, handleBackPress, returnTo, runBackGuard } from "@/utils/navigation.js";
const genealogyId = ref("");
const content = ref("");
const form = reactive({ feedContent: "", feedType: "", sortOrder: "" });
const mediaReceipts = ref([]);
const publishState = ref("form");
const isSubmitting = ref(false);
const isUploading = ref(false);
const submitError = ref("");
const uploadError = ref("");
const discardVisible = ref(false);
const requestController = createRequestController();
const isDirty = computed(() => Boolean(content.value.trim()));
const mediaOssIds = computed(() => mediaReceipts.value.map((item) => item.ossId).join(","));
const isDirty = computed(() => Boolean(
form.feedContent.trim() || form.feedType.trim() || form.sortOrder.trim() || mediaReceipts.value.length,
));
const hasValidContext = computed(() => /^[1-9]\d*$/.test(genealogyId.value));
const resultCopy = computed(() => ({
success: {
title: "动态已提交服务端",
copy: "服务端已返回成功信封。动态列表仍缺条目 DTO,返回后不会把本地内容伪装成列表项。",
copy: "服务端已返回成功信封。返回动态列表后将重新读取服务端数据。",
action: "返回家族动态",
},
error: {
title: "动态未提交",
copy: "当前文字仍保留在页面中,可返回表单继续核对。",
copy: "当前内容仍保留在页面中,可返回表单继续核对。",
action: "返回填写",
},
invalid: {
@@ -79,18 +107,40 @@ onLoad((query) => {
genealogyId.value = String(query?.genealogyId || "");
if (!hasValidContext.value) publishState.value = "invalid";
});
const uploadImage = async () => {
if (isUploading.value || isSubmitting.value) return;
isUploading.value = true;
uploadError.value = "";
try {
const receipt = await pickAndUploadImage({ requestController });
mediaReceipts.value = [...mediaReceipts.value, receipt];
} catch (error) {
if (!isImagePickCancelled(error) && !isRequestCancelled(error)) {
uploadError.value = error?.message || "图片上传失败,请稍后重试。";
}
} finally {
isUploading.value = false;
}
};
const submit = async () => {
if (isSubmitting.value || !hasValidContext.value) return;
const feedContent = content.value.trim();
if (!feedContent) {
submitError.value = "请先写下动态内容";
if (isSubmitting.value || isUploading.value || !hasValidContext.value) return;
if (!form.feedContent.trim()) {
submitError.value = "请填写动态内容";
return;
}
isSubmitting.value = true;
submitError.value = "";
try {
await appApi.createFeed(genealogyId.value, { feedContent }, { requestController });
content.value = "";
await appApi.createFeed(genealogyId.value, {
feedContent: form.feedContent,
feedType: form.feedType,
mediaOssIds: mediaOssIds.value,
sortOrder: form.sortOrder,
}, { requestController });
Object.assign(form, { feedContent: "", feedType: "", sortOrder: "" });
mediaReceipts.value = [];
publishState.value = "success";
} catch (error) {
if (isRequestCancelled(error)) return;
@@ -103,7 +153,7 @@ const submit = async () => {
const requestBack = () => runBackGuard({
transientOpen: discardVisible.value,
dirty: isDirty.value,
submitting: isSubmitting.value,
submitting: isSubmitting.value || isUploading.value,
"close-transient": cancelDiscard,
"block-submitting": () => true,
"confirm-discard": requestDiscardConfirmation,
@@ -136,8 +186,16 @@ onUnmounted(() => {
.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-field { @include adaptive.adaptive-family-field; margin-top: 20rpx; padding: 18rpx 22rpx; }
.publish-field__label { display: block; color: $ink; font-size: 25rpx; font-weight: 700; }
.required-mark { display: inline-block; margin-right: 4rpx; color: $brand-red; font-size: 26rpx; line-height: 1; transform: translateY(-3rpx); }
.publish-field input, .publish-field textarea { display: block; box-sizing: border-box; width: 100%; margin-top: 12rpx; color: $ink; font-size: 24rpx; }
.publish-field input { min-height: 64rpx; }
.publish-field--content textarea { min-height: 200rpx; line-height: 1.7; }
.publish-field--media { display: grid; gap: 12rpx; }
.publish-field__hint { display: block; margin-top: 6rpx; color: $ink-muted; font-size: 20rpx; line-height: 1.45; }
.upload-button { justify-self: start; min-height: 60rpx; margin: 0; padding: 0 20rpx; border: 1rpx solid rgba(159, 23, 15, .42); border-radius: 8rpx; background: transparent; color: $brand-red; font-size: 22rpx; }
.upload-receipt { color: $ink-muted; font-size: 21rpx; overflow-wrap: anywhere; }
.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; }
+164 -170
View File
@@ -1,232 +1,226 @@
<!-- 页面编号F-03用途动态详情与一级评论动态正文 DTO 未声明时不展示伪字段 -->
<!-- 页面编号F-03用途家族动态详情与一级评论 -->
<template>
<view class="feed-detail-page" :class="{ 'feed-state--expired': feedState === 'expired', 'feed-state--error': feedState === 'error', 'feed-state--ready': feedState === 'ready' }">
<view class="feed-detail-page" :class="`feed-state--${feedState}`">
<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="动态正文的展示字段尚未由后端合同声明。" />
</view>
<view class="feed-detail-content">
<AppLoading v-if="feedState === 'loading'" text="正在读取动态" description="请稍候,正在同步家族动态与评论。" />
<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__title">动态正文待后端字段合同</text>
<text class="feed-article-card__body">已根据当前动态 ID 读取评论正文内容会在详情响应提供可消费 DTO 后再接入</text>
</view>
<view class="feed-comments-panel">
<view class="feed-comments-heading">
<text>家人评论</text><text>{{ feedComments.length }} </text>
<view v-else-if="feedState === 'ready'" class="feed-detail-body">
<view class="feed-card">
<view class="feed-card__heading">
<text>{{ feedTypeLabel(feed.type) }}</text>
<text>{{ feed.time || '未标注时间' }}</text>
</view>
<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 class="feed-card__content">{{ feed.content }}</text>
<view class="feed-card__meta">
<text>发布{{ feed.publisher }}</text>
<text>{{ feed.likeCount }} 次点赞 · {{ feed.commentCount }} 条评论</text>
</view>
</view>
<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>
<text v-else-if="commentNotice" class="feed-comment-notice">{{ commentNotice }}</text>
<AppButton block :disabled="commentState === 'submitting'" :label="commentState === 'submitting' ? '正在提交' : '提交评论'" @click="submitComment" />
<view class="comment-section">
<text class="section-title">评论</text>
<AppLoading v-if="commentState === 'loading'" text="正在读取评论" />
<view v-else-if="commentState === 'list'" class="comment-list">
<view v-for="item in comments" :key="item.id" class="comment-card">
<view class="comment-card__heading">
<text>{{ item.author }}</text>
<text>{{ item.time || '刚刚' }}</text>
</view>
<text class="comment-card__content">{{ item.content }}</text>
</view>
</view>
<text v-else class="comment-state-copy">{{ commentStateCopy }}</text>
<view class="comment-editor">
<textarea
v-model="commentDraft"
auto-height
maxlength="1000"
placeholder="写下你的评论"
placeholder-class="comment-editor__placeholder"
@input="commentError = ''"
/>
<text v-if="commentError" class="comment-error">{{ commentError }}</text>
<AppButton
block
:disabled="isSubmittingComment"
:label="isSubmittingComment ? '正在提交' : '发表评论'"
@click="submitComment"
/>
</view>
</view>
</template>
</view>
<view v-else class="feed-state-card">
<text>{{ stateCopy.title }}</text>
<text>{{ stateCopy.copy }}</text>
<AppButton :type="feedState === 'error' ? 'secondary' : 'primary'" block :label="stateCopy.action" @click="handleStateAction" />
<AppButton type="secondary" block :label="stateCopy.action" @click="handleStateAction" />
</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, ref } from "vue";
import { onBackPress, onLoad } from "@dcloudio/uni-app";
import { computed, ref } from "vue";
import { onBackPress, onLoad, onShow, onUnload } from "@dcloudio/uni-app";
import AppButton from "@/components/AppButton.vue";
import AppDialog from "@/components/AppDialog.vue";
import AppLoading from "@/components/AppLoading.vue";
import ModulePageBackground from "@/components/ModulePageBackground.vue";
import PageHeader from "@/components/PageHeader.vue";
import { createDiscardConfirmation } from "@/utils/discard-confirmation.js";
import { appApi, createRequestController, isRequestCancelled } from "@/utils/api.js";
import {
goBack,
handleBackPress,
returnTo,
runBackGuard,
} from "@/utils/navigation.js";
import { goBack, handleBackPress, returnTo } from "@/utils/navigation.js";
const genealogyId = ref("");
const feedId = ref("");
const feedState = ref("loading");
const commentState = ref("idle");
const commentState = ref("loading");
const feed = ref(null);
const comments = ref([]);
const commentDraft = ref("");
const commentError = ref("");
const commentNotice = ref("");
const discardVisible = ref(false);
const feedComments = ref([]);
const feedRequestController = createRequestController();
let loadSequence = 0;
const isDirty = computed(() => Boolean(commentDraft.value.trim()));
const isSubmittingComment = ref(false);
const controller = createRequestController();
let pageActive = true;
const feedTypeLabel = (type) => String(type || "").trim().toLowerCase() === "text" ? "文字动态" : "家族动态";
const hasValidContext = computed(() =>
/^[1-9]\d*$/.test(genealogyId.value) && /^[1-9]\d*$/.test(feedId.value),
);
const stateCopy = computed(() => {
if (feedState.value === "error") {
if (!hasValidContext.value) {
return {
title: "评论暂不可用",
copy: "评论读取没有得到可消费的服务端响应;页面不会用本地数据替代。",
action: "重新读取",
title: "动态入口无效",
copy: "没有取得当前家谱和动态标识,页面不会展示其他动态。",
action: "返回上一页",
};
}
if (feedState.value === "missing") {
return {
title: "该动态已不存在",
copy: "它可能已被发布者删除,或当前账号已不再拥有查看权限。",
action: "返回家族动态",
};
}
return {
title: "动态入口无效",
copy: "没有取得当前家谱和动态标识,页面不会回退到其他动态。",
action: genealogyId.value ? "返回家族动态" : "返回上一页",
title: "动态暂时无法读取",
copy: "请检查网络后重新读取;本页不会替换为其他动态。",
action: "重新读取",
};
});
const discardConfirmation = createDiscardConfirmation((visible) => {
discardVisible.value = visible;
const commentStateCopy = computed(() => {
if (commentState.value === "empty") return "还没有评论,欢迎留下第一句话。";
return "评论暂时无法读取,稍后可重新进入本页查看。";
});
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 loadFeed = async () => {
if (!hasValidContext.value) {
feedState.value = "invalid";
return;
}
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;
const current = await appApi.getFeedDetail(genealogyId.value, feedId.value, { requestController: controller });
if (!pageActive) return;
feed.value = current;
feedState.value = "ready";
return true;
} catch (error) {
if (isRequestCancelled(error) || sequence !== loadSequence) return false;
feedState.value = "error";
return false;
if (!pageActive || isRequestCancelled(error)) return;
feedState.value = error?.code === "HTTP_ERROR" && error?.httpStatus === 404 ? "missing" : "error";
}
};
const loadComments = async () => {
if (!hasValidContext.value || feedState.value !== "ready") return;
commentState.value = "loading";
try {
const rows = await appApi.getFeedComments(genealogyId.value, feedId.value, { requestController: controller });
if (!pageActive) return;
comments.value = rows;
commentState.value = rows.length ? "list" : "empty";
} catch (error) {
if (!pageActive || isRequestCancelled(error)) return;
commentState.value = "error";
}
};
const refresh = async () => {
await loadFeed();
if (feedState.value === "ready") await loadComments();
};
const submitComment = async () => {
if (isSubmittingComment.value || !hasValidContext.value) return;
const commentContent = commentDraft.value.trim();
if (!commentContent) {
commentError.value = "请填写评论内容";
return;
}
isSubmittingComment.value = true;
commentError.value = "";
try {
await appApi.createFeedComment(genealogyId.value, feedId.value, { commentContent }, { requestController: controller });
if (!pageActive) return;
commentDraft.value = "";
await refresh();
} catch (error) {
if (!pageActive || isRequestCancelled(error)) return;
commentError.value = error?.message || "评论提交失败,请稍后重试";
} finally {
if (pageActive) isSubmittingComment.value = false;
}
};
onLoad((query) => {
genealogyId.value = String(query.genealogyId || "");
feedId.value = String(query.feedId || "");
loadComments();
genealogyId.value = String(query?.genealogyId || "");
feedId.value = String(query?.feedId || "");
void refresh();
});
onShow(() => {
if (hasValidContext.value) void refresh();
});
onUnload(() => {
pageActive = false;
controller.abort();
});
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 = "";
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 requestBack = () =>
runBackGuard({
transientOpen: discardVisible.value,
dirty: isDirty.value,
submitting: commentState.value === "submitting",
"close-transient": cancelDiscard,
"block-submitting": () => true,
"confirm-discard": requestDiscardConfirmation,
});
const backToFamily = async () => {
if (!genealogyId.value) return goBack();
const confirmed = isDirty.value ? await requestDiscardConfirmation() : true;
if (!confirmed) return false;
return returnTo("F01", { genealogyId: genealogyId.value });
};
const handleStateAction = () => {
if (feedState.value === "error") return loadComments();
return backToFamily();
};
const backToFamily = () => hasValidContext.value
? returnTo("F01", { genealogyId: genealogyId.value })
: goBack();
const requestBack = () => backToFamily();
const handleStateAction = () => feedState.value === "error" ? refresh() : backToFamily();
onBackPress((event) => handleBackPress(event, requestBack));
onUnmounted(() => {
++loadSequence;
feedRequestController.abort();
discardConfirmation.dispose();
});
</script>
<style scoped lang="scss">
@use "../../styles/adaptive-frame-profiles.scss" as adaptive;
.feed-detail-page { display: flex; min-height: 100vh; flex-direction: column; background: $paper; }
.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: 42rpx 48rpx; }
.feed-article-card text, .feed-state-card > text { display: block; }
.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, .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-detail-header, .feed-detail-content { z-index: 1; }
.feed-detail-content { flex: 1; padding: 18rpx 24rpx 72rpx; }
.feed-detail-body { margin-top: 18rpx; }
.feed-card, .comment-section, .feed-state-card { @include adaptive.adaptive-family-content; box-sizing: border-box; }
.feed-card { padding: 30rpx; }
.feed-card__heading, .feed-card__meta, .comment-card__heading { display: flex; justify-content: space-between; gap: 18rpx; }
.feed-card__heading text:first-child, .comment-card__heading text:first-child { color: $brand-red; font-size: 23rpx; font-weight: 700; }
.feed-card__heading text:last-child, .comment-card__heading text:last-child { color: $ink-muted; font-size: 21rpx; text-align: right; }
.feed-card__content { display: block; margin-top: 20rpx; color: $ink; font-size: 29rpx; line-height: 1.7; white-space: pre-wrap; }
.feed-card__meta { margin-top: 22rpx; color: $ink-muted; font-size: 21rpx; }
.comment-section { margin-top: 18rpx; padding: 28rpx; }
.section-title { display: block; color: $ink; font-family: STKaiti, KaiTi, serif; font-size: 32rpx; font-weight: 700; }
.comment-list { margin-top: 18rpx; }
.comment-card { padding: 18rpx 0; border-bottom: 1rpx solid rgba(128, 89, 49, .16); }
.comment-card__content { display: block; margin-top: 10rpx; color: $ink; font-size: 25rpx; line-height: 1.55; white-space: pre-wrap; }
.comment-state-copy { display: block; margin-top: 18rpx; color: $ink-muted; font-size: 24rpx; line-height: 1.5; }
.comment-editor { margin-top: 24rpx; padding-top: 22rpx; border-top: 1rpx solid rgba(128, 89, 49, .18); }
.comment-editor textarea { display: block; box-sizing: border-box; width: 100%; min-height: 130rpx; padding: 18rpx; border: 1rpx solid rgba(128, 89, 49, .3); border-radius: 12rpx; color: $ink; font-size: 25rpx; line-height: 1.55; }
.comment-editor__placeholder { color: #ab9a86; }
.comment-editor .app-button { margin-top: 18rpx; }
.comment-error { display: block; margin-top: 10rpx; color: $brand-red; font-size: 22rpx; }
.feed-state-card { min-height: 340rpx; margin-top: 36rpx; padding: 78rpx 52rpx 50rpx; text-align: center; }
.feed-state-card > text { display: block; }
.feed-state-card > text:first-child { color: $ink; font-family: STKaiti, KaiTi, serif; font-size: 35rpx; font-weight: 700; }
.feed-state-card > text:nth-child(2) { margin-top: 18rpx; color: $ink-muted; font-size: 24rpx; line-height: 1.65; }
.feed-state-card .app-button { margin-top: 30rpx; }
+45 -9
View File
@@ -1,10 +1,17 @@
<!-- 页面编号F-04用途谱文入口列表 item DTO 未声明时不展示本地文章 -->
<!-- 页面编号F-04用途读取并展示当前家谱的谱文列表 -->
<template>
<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 class="article-list-content">
<view class="article-list-state-card">
<view v-if="listState === 'list'" class="article-list-items">
<view v-for="item in articles" :key="item.id" class="article-card" @click="openArticle(item)">
<text class="article-card__title">{{ item.title }}</text>
<text class="article-card__summary">{{ item.summary || item.content }}</text>
<text class="article-card__meta">{{ item.author || '家族成员' }} · {{ item.time || '未标注时间' }}</text>
</view>
</view>
<view v-else class="article-list-state-card">
<text>{{ stateCopy.title }}</text>
<text>{{ stateCopy.copy }}</text>
<AppButton block :label="stateCopy.action" @click="handleStateAction" />
@@ -15,20 +22,30 @@
<script setup>
import { computed, ref } from "vue";
import { onLoad } from "@dcloudio/uni-app";
import { onLoad, onShow } from "@dcloudio/uni-app";
import AppButton from "@/components/AppButton.vue";
import ModulePageBackground from "@/components/ModulePageBackground.vue";
import PageHeader from "@/components/PageHeader.vue";
import { appApi } from "@/utils/api.js";
import { goBack, openPage } from "@/utils/navigation.js";
const genealogyId = ref("");
const hasValidContext = ref(false);
const listState = ref("unavailable");
const listState = ref("loading");
const articles = ref([]);
const stateCopy = computed(() => hasValidContext.value
? {
title: "谱文列表待后端字段合同",
copy: "列表接口只声明通用对象数组,没有文章 ID、分类、标题、摘要、作者或更新时间字段;页面已停止展示本地文章和本地筛选。",
? listState.value === "empty" ? {
title: "还没有谱文",
copy: "新建第一篇谱文,记录值得传承的故事。",
action: "新建谱文",
} : listState.value === "error" ? {
title: "谱文暂时无法读取",
copy: "请检查网络后重新读取。",
action: "重新读取",
} : {
title: "正在读取谱文",
copy: "请稍候。",
action: "重新读取",
}
: {
title: "谱文入口无效",
@@ -39,12 +56,25 @@ const stateCopy = computed(() => hasValidContext.value
onLoad((query) => {
genealogyId.value = String(query?.genealogyId || "");
hasValidContext.value = /^[1-9]\d*$/.test(genealogyId.value);
listState.value = hasValidContext.value ? "unavailable" : "invalid";
if (hasValidContext.value) void loadArticles(); else listState.value = "invalid";
});
onShow(() => { if (hasValidContext.value) void loadArticles(); });
const loadArticles = async () => {
listState.value = "loading";
try {
const rows = await appApi.getArticles(genealogyId.value);
articles.value = rows;
listState.value = articles.value.length ? "list" : "empty";
} catch { listState.value = "error"; }
};
const createArticle = () => hasValidContext.value
? openPage("F06", { genealogyId: genealogyId.value, mode: "create" }, "F04")
: Promise.resolve(false);
const handleStateAction = () => hasValidContext.value ? createArticle() : goBack();
const openArticle = (item) => openPage("F05", { genealogyId: genealogyId.value, articleId: item.id }, "F04");
const handleStateAction = () => {
if (!hasValidContext.value) return goBack();
return listState.value === "error" ? loadArticles() : createArticle();
};
</script>
<style scoped lang="scss">
@@ -52,6 +82,12 @@ const handleStateAction = () => hasValidContext.value ? createArticle() : goBack
.article-list-page { display: flex; min-height: 100vh; flex-direction: column; background: $paper; }
.article-list-header, .article-list-content { z-index: 1; }
.article-list-content { padding: 18rpx 24rpx 72rpx; }
.article-list-items { margin-top: 24rpx; }
.article-card { @include adaptive.adaptive-family-content; display: flex; min-height: 150rpx; flex-direction: column; justify-content: center; box-sizing: border-box; margin-bottom: 16rpx; padding: 28rpx; }
.article-card text { display: block; }
.article-card__title { color: $ink; font-family: STKaiti, KaiTi, serif; font-size: 31rpx; font-weight: 700; }
.article-card__summary { margin-top: 9rpx; color: $ink-muted; font-size: 24rpx; line-height: 1.45; }
.article-card__meta { margin-top: 10rpx; color: $brand-red; font-size: 21rpx; }
.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; }
+89 -21
View File
@@ -1,13 +1,24 @@
<!-- 页面编号F-05用途谱文详情详情 DTO 未声明时不展示本地正文 -->
<!-- 页面编号F-05用途谱文详情 -->
<template>
<view class="article-detail-page">
<view class="article-detail-page" :class="`article-state--${articleState}`">
<ModulePageBackground module="family" />
<view class="article-detail-header"><PageHeader title="谱文详情" custom-back @back="backToArticles" /></view>
<view class="article-detail-content">
<view class="article-state-card">
<AppLoading v-if="articleState === 'loading'" text="正在读取谱文" description="请稍候,正在同步谱文正文。" />
<view v-else-if="articleState === 'ready'" class="article-card">
<text v-if="article.category" class="article-card__category">{{ article.category }}</text>
<text class="article-card__title">{{ article.title }}</text>
<text class="article-card__meta">{{ article.author }} · {{ article.time || '未标注时间' }}</text>
<text v-if="article.summary" class="article-card__summary">{{ article.summary }}</text>
<view class="article-card__divider" />
<text class="article-card__content">{{ article.content || '作者暂未填写正文。' }}</text>
<text class="article-card__views">阅读 {{ article.viewCount }} </text>
</view>
<view v-else class="article-state-card">
<text>{{ stateCopy.title }}</text>
<text>{{ stateCopy.copy }}</text>
<AppButton block :label="stateCopy.action" @click="backToArticles" />
<AppButton block :label="stateCopy.action" @click="handleStateAction" />
</view>
</view>
</view>
@@ -15,44 +26,101 @@
<script setup>
import { computed, ref } from "vue";
import { onLoad } from "@dcloudio/uni-app";
import { onLoad, onShow, 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 { appApi, createRequestController, isRequestCancelled } from "@/utils/api.js";
import { goBack, returnTo } from "@/utils/navigation.js";
const genealogyId = ref("");
const articleId = ref("");
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: "返回上一页",
},
const articleState = ref("loading");
const article = ref(null);
const controller = createRequestController();
let pageActive = true;
const hasValidContext = computed(() =>
/^[1-9]\d*$/.test(genealogyId.value) && /^[1-9]\d*$/.test(articleId.value),
);
const stateCopy = computed(() => {
if (!hasValidContext.value) {
return {
title: "谱文入口无效",
copy: "没有取得有效家谱或文章标识,页面不会展示其他谱文。",
action: "返回上一页",
};
}
if (articleState.value === "missing") {
return {
title: "该谱文已不存在",
copy: "它可能已被作者删除,或当前账号已不再拥有查看权限。",
action: "返回谱文列表",
};
}
return {
title: "谱文暂时无法读取",
copy: "请检查网络后重新读取;本页不会替换为其他谱文。",
action: "重新读取",
};
});
const loadArticle = async () => {
if (!hasValidContext.value) {
articleState.value = "invalid";
return;
}
articleState.value = "loading";
try {
const current = await appApi.getArticleDetail(genealogyId.value, articleId.value, { requestController: controller });
if (!pageActive) return;
article.value = current;
articleState.value = "ready";
} catch (error) {
if (!pageActive || isRequestCancelled(error)) return;
articleState.value = error?.code === "HTTP_ERROR" && error?.httpStatus === 404 ? "missing" : "error";
}
};
onLoad((query) => {
genealogyId.value = String(query?.genealogyId || "");
articleId.value = String(query?.articleId || "");
void loadArticle();
});
const backToArticles = () => /^[1-9]\d*$/.test(genealogyId.value)
onShow(() => {
if (hasValidContext.value) void loadArticle();
});
onUnload(() => {
pageActive = false;
controller.abort();
});
const backToArticles = () => hasValidContext.value
? returnTo("F04", { genealogyId: genealogyId.value })
: goBack();
const handleStateAction = () => articleState.value === "error" ? loadArticle() : backToArticles();
</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-content { z-index: 1; }
.article-detail-content { padding: 18rpx 24rpx 72rpx; }
.article-state-card { @include adaptive.adaptive-family-content; width: 100%; min-height: 350rpx; margin-top: 36rpx; padding: 78rpx 52rpx 50rpx; text-align: center; }
.article-detail-content { flex: 1; padding: 18rpx 24rpx 72rpx; }
.article-card, .article-state-card { @include adaptive.adaptive-family-content; box-sizing: border-box; }
.article-card { margin-top: 18rpx; padding: 34rpx 30rpx; }
.article-card__category, .article-card__title, .article-card__meta, .article-card__summary, .article-card__content, .article-card__views { display: block; }
.article-card__category { color: $brand-red; font-size: 23rpx; font-weight: 700; }
.article-card__title { margin-top: 14rpx; color: $ink; font-family: STKaiti, KaiTi, serif; font-size: 40rpx; font-weight: 700; line-height: 1.32; }
.article-card__meta { margin-top: 16rpx; color: $ink-muted; font-size: 22rpx; }
.article-card__summary { margin-top: 22rpx; color: $ink-muted; font-size: 25rpx; line-height: 1.6; }
.article-card__divider { height: 1rpx; margin: 26rpx 0; background: rgba(128, 89, 49, .22); }
.article-card__content { color: $ink; font-size: 28rpx; line-height: 1.85; white-space: pre-wrap; }
.article-card__views { margin-top: 30rpx; color: $ink-muted; font-size: 21rpx; text-align: right; }
.article-state-card { 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; }
.article-state-card .app-button { margin-top: 28rpx; }
</style>
+87 -22
View File
@@ -1,4 +1,4 @@
<!-- 页面编号F-06用途已声明 articleTitle/articleContent 创建谱文 -->
<!-- 页面编号F-06用途 Apifox 的完整 AppArticleBody 创建谱文 -->
<template>
<view class="article-editor-page" :class="`article-editor-state--${editorState}`">
<ModulePageBackground module="family" />
@@ -8,17 +8,43 @@
<view class="editor-panel__body">
<text class="editor-eyebrow">服务端创建</text>
<text class="editor-title">把值得传承的故事写下来</text>
<text class="editor-intro">本页只发送 Apifox 已声明且可映射的文章标题正文分类需要 `categoryId`当前没有可用分类 owner故不展示或猜测分类</text>
<text class="editor-intro">标题正文其余字段均可按需填写留空时不提交该字段</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>
<text class="editor-field__label">文章分类</text>
<view class="editor-control editor-control--unavailable"><text>服务端暂未提供可选择的分类项</text></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>
<text class="editor-field__label"><text class="required-mark">*</text>文章标题</text>
<view class="editor-control"><input v-model="form.articleTitle" 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.articleSummary" auto-height placeholder="概括文章内容" placeholder-class="editor-placeholder" @input="submitError = ''" /></view>
</view>
<view class="editor-field editor-field--cover">
<view>
<text class="editor-field__label">封面图片</text>
<text class="editor-field__hint">选择图片后会取得真实上传回执并作为封面关联</text>
</view>
<button class="upload-button" :disabled="uploading || isSubmitting" @click="uploadCover">{{ uploading ? '上传中' : '选择图片' }}</button>
<text v-if="coverFileName" class="upload-receipt">已上传{{ coverFileName }}</text>
<text v-if="uploadError" class="editor-save-error">{{ uploadError }}</text>
</view>
<view class="editor-field">
<text class="editor-field__label"><text class="required-mark">*</text>正文内容</text>
<view class="editor-control editor-control--textarea editor-control--article"><textarea v-model="form.articleContent" auto-height placeholder="记录家训、往事或想留给后人的话" placeholder-class="editor-placeholder" @input="submitError = ''" /></view>
</view>
<view class="editor-field">
<text class="editor-field__label">作者名称</text>
<view class="editor-control"><input v-model="form.authorName" placeholder="请输入作者名称" placeholder-class="editor-placeholder" @input="submitError = ''" /></view>
</view>
<view class="editor-field">
<text class="editor-field__label">排序值</text>
<view class="editor-control"><input v-model="form.sortOrder" type="number" 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" />
<AppButton block :label="isSubmitting ? '正在提交' : '提交谱文'" :disabled="isSubmitting || uploading" @click="submit" />
</view>
</view>
<view v-else class="editor-result-card">
@@ -53,28 +79,41 @@ 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 { isImagePickCancelled, pickAndUploadImage, toConsumerOssId } from "@/utils/resumable-image-upload.js";
import { goBack, handleBackPress, returnTo, runBackGuard } from "@/utils/navigation.js";
const editorState = ref("form");
const genealogyId = ref("");
const isSubmitting = ref(false);
const uploading = ref(false);
const discardVisible = ref(false);
const submitError = ref("");
const form = reactive({ title: "", content: "" });
const uploadError = ref("");
const coverOssId = ref(null);
const coverFileName = ref("");
const form = reactive({
articleTitle: "",
articleSummary: "",
articleContent: "",
authorName: "",
sortOrder: "",
});
const requestController = createRequestController();
const isDirty = computed(() => Boolean(form.title.trim() || form.content.trim()));
const isDirty = computed(() => Boolean(
Object.values(form).some((value) => value.trim()) || coverOssId.value,
));
const hasValidContext = computed(() => /^[1-9]\d*$/.test(genealogyId.value));
const resultCopy = computed(() => editorState.value === "success"
? {
eyebrow: "服务端已接受",
title: "谱文已提交",
copy: "服务端已返回成功信封。谱文列表与详情仍缺展示 DTO,返回后不会生成本地文章卡片。",
copy: "服务端已返回成功信封。返回谱文列表后将重新读取服务端数据。",
action: "返回谱文列表",
}
: {
eyebrow: "谱文入口无效",
title: "无法创建谱文",
copy: "当前只有新建操作可映射;编辑需要可靠的文章详情和文章 ID,暂不从 fixture 进入。",
copy: "没有取得有效家谱标识,页面不会创建无归属谱文。",
action: "返回上一页",
},
);
@@ -89,20 +128,37 @@ onLoad((query) => {
genealogyId.value = String(query?.genealogyId || "");
if (!hasValidContext.value || query?.mode !== "create") editorState.value = "invalid";
});
const uploadCover = async () => {
if (uploading.value || isSubmitting.value) return;
uploading.value = true;
uploadError.value = "";
try {
const receipt = await pickAndUploadImage({ requestController });
coverOssId.value = toConsumerOssId(receipt.ossId);
coverFileName.value = receipt.fileName || "封面图片";
} catch (error) {
if (!isImagePickCancelled(error) && !isRequestCancelled(error)) {
uploadError.value = error?.message || "封面图片上传失败,请稍后重试。";
}
} finally {
uploading.value = false;
}
};
const submit = async () => {
if (isSubmitting.value || !hasValidContext.value) return;
const articleTitle = form.title.trim();
const articleContent = form.content.trim();
if (!articleTitle || !articleContent) {
submitError.value = !articleTitle ? "请填写文章标题" : "请填写正文内容";
if (isSubmitting.value || uploading.value || !hasValidContext.value) return;
if (!form.articleTitle.trim() || !form.articleContent.trim()) {
submitError.value = !form.articleTitle.trim() ? "请填写文章标题" : "请填写正文内容";
return;
}
isSubmitting.value = true;
submitError.value = "";
try {
await appApi.createArticle(genealogyId.value, { articleTitle, articleContent }, { requestController });
form.title = "";
form.content = "";
await appApi.createArticle(genealogyId.value, {
...form,
coverOssId: coverOssId.value,
}, { requestController });
editorState.value = "success";
} catch (error) {
if (!isRequestCancelled(error)) submitError.value = error?.message || "谱文提交失败,请稍后重试。";
@@ -113,7 +169,7 @@ const submit = async () => {
const requestBack = () => runBackGuard({
transientOpen: discardVisible.value,
dirty: isDirty.value,
submitting: isSubmitting.value,
submitting: isSubmitting.value || uploading.value,
"close-transient": cancelDiscard,
"block-submitting": () => true,
"confirm-discard": requestDiscardConfirmation,
@@ -140,11 +196,20 @@ onUnmounted(() => {
.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; }
.required-mark { display: inline-block; margin-right: 4rpx; color: $brand-red; font-size: 26rpx; line-height: 1; transform: translateY(-3rpx); }
.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-control--textarea { min-height: 132rpx; padding: 20rpx 24rpx; }
.editor-control--article { min-height: 220rpx; }
.editor-control--unavailable { padding: 0 28rpx; color: $ink-muted; font-size: 23rpx; }
.editor-control textarea { min-height: 92rpx; line-height: 1.65; }
.editor-control--article textarea { min-height: 180rpx; }
.editor-field--cover { display: grid; gap: 12rpx; padding: 18rpx 22rpx; border: 1rpx solid rgba(128, 89, 49, .34); border-radius: 12rpx; background: rgba(255, 252, 245, .7); }
.editor-field--cover .editor-field__label { margin: 0; }
.editor-field__hint { display: block; color: $ink-muted; font-size: 20rpx; line-height: 1.45; }
.upload-button { justify-self: start; min-height: 60rpx; margin: 0; padding: 0 20rpx; border: 1rpx solid rgba(159, 23, 15, .42); border-radius: 8rpx; background: transparent; color: $brand-red; font-size: 22rpx; }
.upload-receipt { color: $ink-muted; font-size: 21rpx; overflow-wrap: anywhere; }
.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; }
+71 -70
View File
@@ -1,104 +1,105 @@
<!-- 页面编号F-07用途相册入口列表 DTO 缺失时不展示本地相册 -->
<!-- 页面编号F-07用途读取并创建当前家谱的相册 -->
<template>
<view class="album-list-page">
<ModulePageBackground module="family" />
<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 block :label="stateCopy.action" @click="handleStateAction" />
<view v-if="!hasValidContext" class="album-state-card"><text>相册入口无效</text><text>没有取得有效家谱标识。</text><AppButton block label="返回上一页" @click="goBack" /></view>
<view v-else-if="listState === 'loading'" class="album-state-card"><AppLoading text="正在读取家族相册" /></view>
<view v-else-if="listState === 'error'" class="album-state-card"><text>暂时无法读取家族相册</text><text>{{ listError }}</text><AppButton block type="secondary" label="重新加载" @click="loadAlbums" /></view>
<view v-else-if="listState === 'empty'" class="album-state-card"><text>还没有相册</text><text>新建第一本相册,整理家族影像。</text><AppButton block label="新建相册" @click="openCreateDialog" /></view>
<view v-else class="album-list">
<view v-for="item in albums" :key="item.id" class="album-card" @click="openAlbum(item)"><text>{{ item.name }}</text><text v-if="item.description">{{ item.description }}</text><text>{{ item.photoCount }} 张照片</text></view>
</view>
</view>
<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="例如:春节团圆" @input="submitError = ''" />
<text v-if="submitError">{{ submitError }}</text>
</view>
<AppDialog :visible="dialogVisible" eyebrow="新建相册" title="为家人整理一段影像" :confirm-text="isSubmitting ? '正在提交' : '提交相册'" cancel-text="取消" show-cancel :close-on-mask="false" @confirm="submitAlbum" @cancel="closeCreateDialog">
<view class="album-dialog-field"><text><text class="required-mark">*</text>相册名称</text><input v-model="form.albumName" placeholder="例如:春节团圆" @input="submitError = ''" /></view>
<view class="album-dialog-field"><text>相册说明</text><textarea v-model="form.albumDesc" auto-height placeholder="介绍这组影像" @input="submitError = ''" /></view>
<view class="album-dialog-field album-dialog-field--cover"><view><text>封面图片</text><text class="album-field-hint">选择图片后会取得真实上传回执并作为封面关联</text></view><button class="upload-button" :disabled="uploading || isSubmitting" @click="uploadCover">{{ uploading ? '上传中' : '选择图片' }}</button><text v-if="coverFileName" class="upload-receipt">已上传:{{ coverFileName }}</text><text v-if="uploadError" class="album-field-error">{{ uploadError }}</text></view>
<view class="album-dialog-field"><text>排序值</text><input v-model="form.sortOrder" type="number" placeholder="数值越小越靠前" @input="submitError = ''" /></view>
<text v-if="submitError" class="album-field-error">{{ submitError }}</text>
</AppDialog>
</view>
</template>
<script setup>
import { computed, onUnmounted, ref } from "vue";
import { onBackPress, onLoad } from "@dcloudio/uni-app";
import { computed, onUnmounted, reactive, ref } from "vue";
import { onBackPress, onLoad, onShow } 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 { appApi, createRequestController, isRequestCancelled } from "@/utils/api.js";
import { goBack, handleBackPress, runBackGuard } from "@/utils/navigation.js";
import { isImagePickCancelled, pickAndUploadImage, toConsumerOssId } from "@/utils/resumable-image-upload.js";
import { goBack, handleBackPress, openPage, runBackGuard } from "@/utils/navigation.js";
const genealogyId = ref("");
const hasValidContext = ref(false);
const albums = ref([]);
const listState = ref("loading");
const listError = ref("");
const dialogVisible = ref(false);
const albumNameDraft = ref("");
const form = reactive({ albumName: "", albumDesc: "", sortOrder: "" });
const coverOssId = ref(null);
const coverFileName = ref("");
const submitError = ref("");
const uploadError = ref("");
const uploading = ref(false);
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: "新建相册" },
);
onLoad((query) => {
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 controller = createRequestController();
let active = true;
const hasValidContext = computed(() => /^[1-9]\d*$/.test(genealogyId.value));
const text = (value) => typeof value === "string" || typeof value === "number" ? String(value).trim() : "";
const toAlbum = (item) => {
const id = text(item?.albumId);
if (!/^[1-9]\d*$/.test(id)) return null;
return { id, name: text(item.albumName) || "未命名相册", description: text(item.albumDesc), photoCount: Number.isSafeInteger(item.photoCount) ? item.photoCount : 0 };
};
const closeCreateDialog = () => {
if (!isSubmitting.value) dialogVisible.value = false;
const loadAlbums = async () => {
if (!hasValidContext.value) return;
controller.abort();
listState.value = "loading";
listError.value = "";
try {
const rows = await appApi.getAlbums(genealogyId.value, { requestController: controller });
if (!active) return;
albums.value = rows.map(toAlbum).filter(Boolean);
listState.value = albums.value.length ? "list" : "empty";
} catch (error) {
if (!active || isRequestCancelled(error)) return;
listState.value = "error";
listError.value = error?.message || "请稍后重试。";
}
};
const resetDraft = () => { Object.assign(form, { albumName: "", albumDesc: "", sortOrder: "" }); coverOssId.value = null; coverFileName.value = ""; submitError.value = ""; uploadError.value = ""; };
const openCreateDialog = () => { if (!hasValidContext.value || isSubmitting.value || uploading.value) return; resetDraft(); dialogVisible.value = true; };
const closeCreateDialog = () => { if (!isSubmitting.value && !uploading.value) dialogVisible.value = false; };
const uploadCover = async () => {
if (uploading.value || isSubmitting.value) return;
uploading.value = true;
uploadError.value = "";
try { const receipt = await pickAndUploadImage({ requestController: controller }); coverOssId.value = toConsumerOssId(receipt.ossId); coverFileName.value = receipt.fileName || "封面图片"; }
catch (error) { if (!isImagePickCancelled(error) && !isRequestCancelled(error)) uploadError.value = error?.message || "封面图片上传失败,请稍后重试。"; }
finally { uploading.value = false; }
};
const submitAlbum = async () => {
if (isSubmitting.value) return;
const albumName = albumNameDraft.value.trim();
if (!albumName) {
submitError.value = "请填写相册名称";
return;
}
if (isSubmitting.value || uploading.value) return;
if (!form.albumName.trim()) { submitError.value = "请填写相册名称"; return; }
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;
}
try { await appApi.createAlbum(genealogyId.value, { ...form, coverOssId: coverOssId.value }, { requestController: controller }); dialogVisible.value = false; await loadAlbums(); }
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 handleStateAction = () => hasValidContext.value ? openCreateDialog() : goBack();
const openAlbum = (item) => openPage("F08", { genealogyId: genealogyId.value, albumId: item.id }, "F07");
const requestBack = () => runBackGuard({ transientOpen: dialogVisible.value, submitting: isSubmitting.value || uploading.value, "close-transient": closeCreateDialog, "block-submitting": () => true });
onLoad((query) => { genealogyId.value = String(query?.genealogyId || ""); if (hasValidContext.value) loadAlbums(); else listState.value = "invalid"; });
onShow(() => { if (hasValidContext.value && !dialogVisible.value && listState.value !== "loading") loadAlbums(); });
onBackPress((event) => handleBackPress(event, requestBack));
onUnmounted(() => requestController.abort());
onUnmounted(() => { active = false; controller.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-content { z-index: 1; }
.album-list-content { padding: 18rpx 24rpx 72rpx; }
.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; }
.album-state-card .app-button { margin-top: 28rpx; }
.album-dialog-field { width: 100%; margin: 24rpx 0; text-align: left; }
.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; }
.album-list-page { display: flex; min-height: 100vh; flex-direction: column; background: $paper; }.album-list-header, .album-list-content { z-index: 1; }.album-list-content { padding: 18rpx 24rpx 72rpx; }.album-state-card, .album-card { @include adaptive.adaptive-family-content; }.album-state-card { min-height: 340rpx; padding: 78rpx 52rpx 50rpx; text-align: center; }.album-state-card text, .album-card text { display: block; }.album-state-card text:first-child, .album-card text:first-child { color: $ink; font-family: STKaiti, KaiTi, serif; font-size: 35rpx; font-weight: 700; }.album-state-card text:nth-child(2), .album-card text:not(:first-child) { margin-top: 12rpx; color: $ink-muted; font-size: 23rpx; line-height: 1.55; }.album-state-card .app-button { margin-top: 28rpx; }.album-list { display: flex; flex-direction: column; gap: 16rpx; }.album-card { padding: 28rpx 30rpx; }.album-dialog-field { width: 100%; margin: 20rpx 0; text-align: left; }.album-dialog-field > text:first-child, .album-dialog-field--cover > view > text:first-child { display: block; color: $ink; font-size: 23rpx; font-weight: 700; }.required-mark { display: inline-block; margin-right: 4rpx; color: $brand-red; font-size: 26rpx; }.album-dialog-field input, .album-dialog-field textarea { @include adaptive.adaptive-family-field; box-sizing: border-box; display: block; width: 100%; margin-top: 10rpx; padding: 16rpx 22rpx; color: $ink; font-size: 24rpx; }.album-dialog-field input { min-height: 76rpx; padding-top: 0; padding-bottom: 0; }.album-dialog-field textarea { min-height: 112rpx; line-height: 1.55; }.album-dialog-field--cover { display: grid; gap: 12rpx; padding: 18rpx; border: 1rpx solid rgba(128, 89, 49, .34); border-radius: 12rpx; background: rgba(255, 252, 245, .7); }.album-field-hint { display: block; margin-top: 6rpx; color: $ink-muted; font-size: 20rpx; line-height: 1.45; }.upload-button { justify-self: start; min-height: 60rpx; margin: 0; padding: 0 20rpx; border: 1rpx solid rgba(159, 23, 15, .42); border-radius: 8rpx; background: transparent; color: $brand-red; font-size: 22rpx; }.upload-receipt { color: $ink-muted; font-size: 21rpx; overflow-wrap: anywhere; }.album-field-error { display: block; margin-top: 8rpx; color: $brand-red; font-size: 21rpx; line-height: 1.45; }
</style>
+44 -28
View File
@@ -1,13 +1,15 @@
<!-- 页面编号F-08用途相册照片墙响应未声明照片展示 DTO 时保持关闭 -->
<!-- 页面编号F-08用途读取当前相册的照片记录 -->
<template>
<view class="album-detail-page">
<ModulePageBackground module="family" />
<view class="album-detail-header"><PageHeader title="相册详情" custom-back @back="returnToAlbums" /></view>
<view class="album-detail-header"><PageHeader title="相册详情" :action="valid ? '添加' : ''" custom-back @back="returnToAlbums" @action="addPhoto" /></view>
<view class="album-detail-content">
<view class="album-state-card">
<text>{{ stateCopy.title }}</text>
<text>{{ stateCopy.copy }}</text>
<AppButton block :label="stateCopy.action" @click="returnToAlbums" />
<view v-if="!valid" class="album-state-card"><text>相册入口无效</text><AppButton block label="返回相册列表" @click="returnToAlbums" /></view>
<view v-else-if="state === 'loading'" class="album-state-card"><AppLoading text="正在读取相册照片" /></view>
<view v-else-if="state === 'error'" class="album-state-card"><text>暂时无法读取相册照片</text><AppButton block type="secondary" label="重新加载" @click="loadPhotos" /></view>
<view v-else-if="state === 'empty'" class="album-state-card"><text>还没有照片</text><text>添加照片后会直接读取服务端相册记录。</text><AppButton block label="添加照片" @click="addPhoto" /></view>
<view v-else class="photo-list">
<view v-for="item in photos" :key="item.id" class="photo-card"><text>{{ item.title }}</text><text v-if="item.description">{{ item.description }}</text><text v-if="item.meta">{{ item.meta }}</text></view>
</view>
</view>
</view>
@@ -15,36 +17,50 @@
<script setup>
import { computed, ref } from "vue";
import { onLoad } from "@dcloudio/uni-app";
import { onLoad, onShow, 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 { goBack, returnTo } from "@/utils/navigation.js";
import { appApi, createRequestController, isRequestCancelled } from "@/utils/api.js";
import { goBack, openPage, returnTo } from "@/utils/navigation.js";
const genealogyId = ref("");
const albumId = ref("");
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 || "");
});
const returnToAlbums = () => /^[1-9]\d*$/.test(genealogyId.value)
? returnTo("F07", { genealogyId: genealogyId.value })
: goBack();
const photos = ref([]);
const state = ref("loading");
const controller = createRequestController();
let active = true;
const valid = computed(() => /^[1-9]\d*$/.test(genealogyId.value) && /^[1-9]\d*$/.test(albumId.value));
const loadPhotos = async () => {
if (!valid.value) return;
controller.abort();
state.value = "loading";
try {
const rows = await appApi.getAlbumPhotos(genealogyId.value, albumId.value, { requestController: controller });
if (!active) return;
photos.value = rows.map((item) => ({
id: String(item.photoId || ""),
title: String(item.photoTitle || "未命名照片"),
description: String(item.photoDesc || ""),
meta: [item.photographer, item.shootTime].filter(Boolean).join(" · "),
})).filter((item) => /^[1-9]\d*$/.test(item.id));
state.value = photos.value.length ? "ready" : "empty";
} catch (error) {
if (!active || isRequestCancelled(error)) return;
state.value = "error";
}
};
const returnToAlbums = () => /^[1-9]\d*$/.test(genealogyId.value) ? returnTo("F07", { genealogyId: genealogyId.value }) : goBack();
const addPhoto = () => valid.value ? openPage("F09", { genealogyId: genealogyId.value, albumId: albumId.value }, "F08") : Promise.resolve(false);
onLoad((query) => { genealogyId.value = String(query?.genealogyId || ""); albumId.value = String(query?.albumId || ""); if (valid.value) loadPhotos(); });
onShow(() => { if (valid.value && state.value !== "loading") loadPhotos(); });
onUnload(() => { active = false; controller.abort(); });
</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-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; }
.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, .photo-card { @include adaptive.adaptive-family-content; }.album-state-card { width: 100%; min-height: 340rpx; padding: 84rpx 44rpx 56rpx; 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; }
.photo-list { display: flex; flex-direction: column; gap: 16rpx; }.photo-card { padding: 28rpx 32rpx; }.photo-card text { display: block; }.photo-card text:first-child { color: $ink; font-family: STKaiti, KaiTi, serif; font-size: 30rpx; font-weight: 700; }.photo-card text:not(:first-child) { margin-top: 8rpx; color: $ink-muted; font-size: 22rpx; line-height: 1.5; }
</style>
+151 -16
View File
@@ -1,48 +1,183 @@
<!-- 页面编号F-09用途相册照片写入没有文件上传与 OSS 回执 owner 时关闭 -->
<!-- 页面编号F-09用途上传真实图片并创建相册照片记录 -->
<template>
<view class="media-upload-page">
<view class="media-upload-page" :class="`media-state--${pageState}`">
<ModulePageBackground module="family" />
<view class="media-upload-header"><PageHeader title="添加照片" custom-back @back="returnToAlbum" /></view>
<view class="media-upload-header"><PageHeader title="添加照片" custom-back @back="requestBack" /></view>
<view class="media-upload-content">
<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 v-if="pageState === 'form'" class="media-panel">
<text class="media-panel__title">添加一张家族照片</text>
<text class="media-panel__note">请先选择图片保存时只会提交真实上传回执中的文件标识</text>
<view class="media-field media-field--upload">
<text class="media-field__label"><text class="required-mark">*</text>照片</text>
<button class="upload-button" :disabled="uploading || submitting" @click="selectPhoto">{{ uploading ? '上传中' : (receipt ? '重新选择图片' : '选择图片') }}</button>
<text v-if="receipt" class="upload-receipt">已上传{{ receipt.fileName || '图片' }}</text>
</view>
<text v-if="uploadError" class="field-error">{{ uploadError }}</text>
<view class="media-field">
<text class="media-field__label">照片标题</text>
<input v-model="form.photoTitle" maxlength="60" placeholder="例如:祖宅合影" placeholder-class="placeholder" @input="submitError = ''" />
</view>
<view class="media-field media-field--textarea">
<text class="media-field__label">照片说明</text>
<textarea v-model="form.photoDesc" maxlength="500" auto-height placeholder="补充照片中的人物、场景或故事" placeholder-class="placeholder" @input="submitError = ''" />
</view>
<view class="media-field">
<text class="media-field__label">拍摄人</text>
<input v-model="form.photographer" maxlength="30" placeholder="请输入拍摄人" placeholder-class="placeholder" @input="submitError = ''" />
</view>
<view class="media-field">
<text class="media-field__label">拍摄时间</text>
<input v-model="form.shootTime" maxlength="30" placeholder="例如:2026-07-24 10:00:00" placeholder-class="placeholder" @input="submitError = ''" />
</view>
<view class="media-field">
<text class="media-field__label">排序值</text>
<input v-model="form.sortOrder" type="number" placeholder="数值越小越靠前" placeholder-class="placeholder" @input="submitError = ''" />
</view>
<text v-if="submitError" class="field-error">{{ submitError }}</text>
<AppButton block :disabled="uploading || submitting" :label="submitting ? '正在保存…' : '保存照片'" @click="submitPhoto" />
</view>
<view v-else class="media-state-card">
<text class="media-state-card__title">{{ stateCopy.title }}</text>
<text class="media-state-card__copy">{{ stateCopy.copy }}</text>
<AppButton block :label="stateCopy.action" @click="handleStateAction" />
</view>
</view>
</view>
<AppDialog
:visible="discardVisible"
title="放弃照片草稿?"
message="当前内容尚未保存到服务端,返回后不会保留。"
confirm-text="放弃并返回"
cancel-text="继续填写"
show-cancel
:close-on-mask="false"
@confirm="confirmation.confirm"
@cancel="confirmation.cancel"
/>
</template>
<script setup>
import { computed, ref } from "vue";
import { onLoad } 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 ModulePageBackground from "@/components/ModulePageBackground.vue";
import PageHeader from "@/components/PageHeader.vue";
import { goBack, returnTo } from "@/utils/navigation.js";
import { appApi, createRequestController, isRequestCancelled } from "@/utils/api.js";
import { isImagePickCancelled, pickAndUploadImage, toConsumerOssId } from "@/utils/resumable-image-upload.js";
import { createDiscardConfirmation } from "@/utils/discard-confirmation.js";
import { goBack, handleBackPress, returnTo, runBackGuard } from "@/utils/navigation.js";
const genealogyId = ref("");
const albumId = ref("");
const pageState = ref("form");
const receipt = ref(null);
const uploadError = ref("");
const submitError = ref("");
const uploading = ref(false);
const submitting = ref(false);
const form = reactive({ photoTitle: "", photoDesc: "", photographer: "", shootTime: "", sortOrder: "" });
const controller = createRequestController();
const discardVisible = ref(false);
const confirmation = createDiscardConfirmation((visible) => { discardVisible.value = visible; });
const hasValidContext = computed(() => /^[1-9]\d*$/.test(genealogyId.value) && /^[1-9]\d*$/.test(albumId.value));
const isDirty = computed(() => receipt.value || Object.values(form).some((value) => value.trim()));
const stateCopy = computed(() => pageState.value === "success"
? { title: "照片已提交服务端", copy: "服务端已返回成功结果。", action: "返回相册" }
: { title: "照片入口无效", copy: "没有取得有效家谱或相册标识。", action: "返回上一页" });
onLoad((query) => {
genealogyId.value = String(query?.genealogyId || "");
albumId.value = String(query?.albumId || "");
if (!hasValidContext.value) pageState.value = "invalid";
});
const selectPhoto = async () => {
if (uploading.value || submitting.value) return;
uploading.value = true;
uploadError.value = "";
try {
receipt.value = await pickAndUploadImage({ requestController: controller });
} catch (error) {
if (!isImagePickCancelled(error) && !isRequestCancelled(error)) {
uploadError.value = error?.message || "图片上传失败,请稍后重试";
}
} finally {
uploading.value = false;
}
};
const submitPhoto = async () => {
if (uploading.value || submitting.value || !hasValidContext.value) return;
if (!receipt.value) {
submitError.value = "请先选择并上传照片";
return;
}
submitting.value = true;
submitError.value = "";
try {
await appApi.createAlbumPhoto(genealogyId.value, albumId.value, {
...form,
ossId: toConsumerOssId(receipt.value.ossId),
}, { requestController: controller });
pageState.value = "success";
} catch (error) {
if (!isRequestCancelled(error)) submitError.value = error?.message || "照片保存失败,请稍后重试";
} finally {
submitting.value = false;
}
};
const returnToAlbum = () => hasValidContext.value
? returnTo("F07", { genealogyId: genealogyId.value })
? returnTo("F08", { genealogyId: genealogyId.value, albumId: albumId.value })
: goBack();
const requestBack = () => runBackGuard({
transientOpen: discardVisible.value,
dirty: isDirty.value,
submitting: uploading.value || submitting.value,
"close-transient": confirmation.cancel,
"block-submitting": () => true,
"confirm-discard": confirmation.request,
});
const handleStateAction = () => pageState.value === "success" ? returnToAlbum() : goBack();
onBackPress((event) => handleBackPress(event, requestBack));
onUnmounted(() => {
controller.abort();
confirmation.dispose();
});
</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-state-card { @include adaptive.adaptive-family-panel; min-height: 410rpx; box-sizing: border-box; padding: 98rpx 46rpx 58rpx; text-align: center; }
.media-upload-content { flex: 1; padding: 20rpx 24rpx calc(48rpx + env(safe-area-inset-bottom)); }
.media-panel, .media-state-card { @include adaptive.adaptive-family-panel; box-sizing: border-box; }
.media-panel { padding: 38rpx 32rpx 42rpx; }
.media-panel__title, .media-panel__note, .media-field__label, .field-error { display: block; }
.media-panel__title { color: $ink; font-family: STKaiti, KaiTi, serif; font-size: 36rpx; font-weight: 700; }
.media-panel__note { margin-top: 10rpx; color: $ink-muted; font-size: 23rpx; line-height: 1.55; }
.media-field { margin-top: 20rpx; }
.media-field__label { margin: 0 6rpx 8rpx; color: $ink; font-size: 25rpx; font-weight: 700; }
.required-mark { display: inline-block; margin-right: 4rpx; color: $brand-red; font-size: 26rpx; line-height: 1; transform: translateY(-3rpx); }
.media-field input, .media-field textarea { @include adaptive.adaptive-family-field; box-sizing: border-box; display: block; width: 100%; color: $ink; font-size: 24rpx; }
.media-field input { min-height: 78rpx; padding: 0 22rpx; }
.media-field textarea { min-height: 116rpx; padding: 18rpx 22rpx; line-height: 1.55; }
.placeholder { color: #9e8e79; }
.media-field--upload { display: grid; gap: 12rpx; padding: 18rpx 20rpx; border: 1rpx solid rgba(128, 89, 49, .34); border-radius: 12rpx; background: rgba(255, 252, 245, .7); }
.media-field--upload .media-field__label { margin: 0; }
.upload-button { justify-self: start; min-height: 60rpx; margin: 0; padding: 0 20rpx; border: 1rpx solid rgba(159, 23, 15, .42); border-radius: 8rpx; background: transparent; color: $brand-red; font-size: 22rpx; }
.upload-receipt { color: $ink-muted; font-size: 21rpx; overflow-wrap: anywhere; }
.field-error { margin-top: 10rpx; color: $brand-red; font-size: 22rpx; line-height: 1.45; }
.media-panel .app-button { margin-top: 28rpx; }
.media-state-card { min-height: 410rpx; 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__title { 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>
+11 -1
View File
@@ -366,7 +366,7 @@ import {
isRequestCancelled,
} from "@/utils/api.js";
import { genealogyContext } from "@/utils/genealogy-context.js";
import { handleBackPress, openPage, runBackGuard } from "@/utils/navigation.js";
import { consumeNavigationResult, handleBackPress, openPage, runBackGuard } from "@/utils/navigation.js";
const isLoading = ref(false);
const hasError = ref(false);
@@ -465,6 +465,16 @@ onLoad((query) => {
});
onShow(() => {
const result = consumeNavigationResult("G01");
if (
result?.operation === "genealogy-created" &&
result.refresh &&
result.entityId
) {
requestedGenealogyId.value = result.entityId;
loadGenealogies();
return;
}
if (skipNextShowRefresh) {
skipNextShowRefresh = false;
return;
+377 -4
View File
@@ -1,4 +1,377 @@
<!-- 页面编号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>
<!-- 页面编号G-03用途创建家谱首代人物需在服务端提供可恢复合同后另行录入 -->
<template>
<view class="create-page">
<GenealogyPageBackground />
<PageHeader title="创建家谱" custom-back @back="backToGenealogies" />
<view class="page-content">
<view class="create-card">
<text class="create-card__eyebrow">立谱信息</text>
<text class="create-card__title">为家族创建一部家谱</text>
<text class="create-card__note">创建成功后会回到我的家谱首代人物可在后续合同开放后再录入</text>
<view class="field-row">
<text class="field-row__label"><text class="required-mark">*</text>姓氏</text>
<input v-model="form.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 class="field-row__label"><text class="required-mark">*</text>谱名</text>
<input v-model="form.genealogyName" maxlength="24" placeholder="请输入家谱名称" placeholder-class="placeholder" @input="clearFieldError('genealogyName')" />
</view>
<text v-if="fieldErrors.genealogyName" class="field-error">{{ fieldErrors.genealogyName }}</text>
<view class="field-row field-row--selector" @click="openRegionPicker">
<text class="field-row__label"><text class="required-mark">*</text>所在地区</text>
<text class="region-selector-value" :class="{ 'region-selector-value--placeholder': !selectedRegion }">{{ selectedRegion ? regionPickerTrail.map((item) => item.label).join(' / ') : (regionLoading ? '正在加载地区…' : (regionPickerError || '请选择所在地区')) }}</text>
</view>
<text v-if="fieldErrors.regionCode" class="field-error">{{ fieldErrors.regionCode }}</text>
<view class="field-row">
<text class="field-row__label">堂号</text>
<input v-model="form.ancestralHall" maxlength="12" placeholder="请输入堂号" placeholder-class="placeholder" />
</view>
<view class="field-row">
<text class="field-row__label">所在地</text>
<input v-model="form.originPlace" maxlength="30" placeholder="请输入所在地" placeholder-class="placeholder" />
</view>
<view class="field-row">
<text class="field-row__label">详细地址</text>
<input v-model="form.addressDetail" maxlength="60" placeholder="请输入详细地址" placeholder-class="placeholder" />
</view>
<view class="intro-field">
<text class="intro-field__label">家谱简介</text>
<textarea v-model="form.intro" maxlength="200" auto-height placeholder="可记录迁徙、家训或建谱缘由" placeholder-class="placeholder" />
</view>
<view class="cover-field">
<view>
<text class="cover-field__label">封面图片</text>
<text class="cover-field__hint">选择图片后会取得真实上传回执并在创建家谱时关联</text>
</view>
<button class="upload-button" :disabled="isUploading || isSubmitting" @click="uploadCover">{{ isUploading ? '上传中' : '选择图片' }}</button>
<text v-if="coverFileName" class="upload-receipt">已上传{{ coverFileName }}</text>
</view>
<text v-if="uploadError" class="field-error">{{ uploadError }}</text>
<view class="access-rule">
<text class="access-rule__label">访问规则</text>
<view class="access-rule__options">
<view
v-for="option in GENEALOGY_ACCESS_PRESET_OPTIONS"
:key="option.value"
class="access-rule__option"
:class="{ 'access-rule__option--active': form.accessPreset === option.value }"
@click="form.accessPreset = option.value"
>{{ option.label }}</view>
</view>
</view>
<text v-if="submitError" class="submit-error">{{ submitError }}</text>
<AppButton block :disabled="isSubmitting || isUploading" :label="isSubmitting ? '正在创建…' : '确认创建家谱'" @click="submitCreate" />
</view>
</view>
<view v-if="regionPickerOpen" class="region-sheet">
<view class="region-sheet__mask" />
<view class="region-sheet__panel">
<view class="region-sheet__intro">
<text class="region-sheet__title">选择地区</text>
</view>
<view class="region-sheet__picker">
<view class="region-sheet__column-headings">
<text v-for="label in ['省份', '城市', '区县']" :key="label" class="region-sheet__column-heading">{{ label }}</text>
</view>
<picker-view
class="region-sheet__picker-view"
:indicator-style="regionPickerIndicatorStyle"
:value="regionPickerIndexes"
@change="handleRegionPickerChange"
>
<picker-view-column
v-for="(column, columnIndex) in regionPickerColumns"
:key="columnIndex"
>
<view
v-for="(option, optionIndex) in column"
:key="option.regionCode"
class="region-sheet__picker-item"
:class="{ 'region-sheet__picker-item--selected': regionPickerIndexes[columnIndex] === optionIndex }"
>{{ option.label }}</view>
</picker-view-column>
</picker-view>
</view>
<view class="region-sheet__footer">
<view class="region-sheet__cancel" @click="closeRegionPicker">取消</view>
<button class="region-sheet__confirm" @click="confirmRegionSelection">确认选择</button>
</view>
</view>
</view>
</view>
</template>
<script setup>
import { onMounted, reactive, ref } from "vue";
import { onUnload } from "@dcloudio/uni-app";
import AppButton from "@/components/AppButton.vue";
import GenealogyPageBackground from "@/components/GenealogyPageBackground.vue";
import PageHeader from "@/components/PageHeader.vue";
import { appApi, createRequestController, isRequestCancelled } from "@/utils/api.js";
import { GENEALOGY_ACCESS_PRESET, GENEALOGY_ACCESS_PRESET_OPTIONS, toApiGenealogyAccess } from "@/utils/genealogy-contracts.js";
import { isImagePickCancelled, pickAndUploadImage, toConsumerOssId } from "@/utils/resumable-image-upload.js";
import { finishPage, returnTo } from "@/utils/navigation.js";
const form = reactive({
surname: "",
genealogyName: "",
ancestralHall: "",
originPlace: "",
addressDetail: "",
intro: "",
accessPreset: GENEALOGY_ACCESS_PRESET.MEMBER_ONLY,
});
const fieldErrors = reactive({ surname: "", genealogyName: "", regionCode: "" });
const submitError = ref("");
const isSubmitting = ref(false);
const isUploading = ref(false);
const uploadError = ref("");
const coverOssId = ref(null);
const coverFileName = ref("");
const createController = createRequestController();
const regionController = createRequestController();
const selectedRegion = ref(null);
const regionPickerTrail = ref([]);
const regionPickerColumns = ref([]);
const regionPickerIndexes = ref([0]);
const regionPickerError = ref("");
const regionLoading = ref(false);
const regionPickerOpen = ref(false);
const regionPickerIndicatorStyle = "height: 104rpx; border-top: 1px solid rgba(159, 23, 15, .46); border-bottom: 1px solid rgba(159, 23, 15, .46); background: rgba(159, 23, 15, .08);";
let pageActive = true;
const clearFieldError = (field) => {
fieldErrors[field] = "";
submitError.value = "";
};
const validate = () => {
fieldErrors.surname = form.surname.trim() ? "" : "请填写姓氏";
fieldErrors.genealogyName = form.genealogyName.trim() ? "" : "请填写谱名";
fieldErrors.regionCode = selectedRegion.value?.regionCode ? "" : "请选择所在地区";
return !fieldErrors.surname && !fieldErrors.genealogyName && !fieldErrors.regionCode;
};
const fetchRegionChildren = async (parentCode) => {
regionLoading.value = true;
regionPickerError.value = "";
try {
return await appApi.getRegionChildren(parentCode, { requestController: regionController });
} catch (error) {
if (!isRequestCancelled(error)) regionPickerError.value = error?.message || "地区列表加载失败,请重试";
return [];
} finally {
regionLoading.value = false;
}
};
const loadRegionRoot = async () => {
if (isSubmitting.value || isUploading.value || regionLoading.value) return;
const roots = await fetchRegionChildren("0");
if (!roots.length || regionPickerError.value) return;
regionPickerColumns.value = [roots];
regionPickerIndexes.value = [0];
regionPickerTrail.value = [];
await loadPickerColumns();
};
const loadPickerColumns = async (provinceIndex = 0, cityIndex = 0) => {
const provinces = regionPickerColumns.value[0] || await fetchRegionChildren("0");
const province = provinces[provinceIndex];
if (!province) return;
const cities = await fetchRegionChildren(province.regionCode);
const city = cities[cityIndex];
if (!city) {
regionPickerColumns.value = [provinces];
regionPickerIndexes.value = [provinceIndex];
return;
}
const districts = await fetchRegionChildren(city.regionCode);
if (!districts.length || regionPickerError.value) return;
regionPickerColumns.value = [provinces, cities, districts];
regionPickerIndexes.value = [provinceIndex, cityIndex, 0];
};
const handleRegionPickerChange = async (event) => {
if (regionLoading.value) return;
const nextIndexes = (event?.detail?.value || []).map((index) => Number(index) || 0);
const indexes = regionPickerIndexes.value;
const provinceIndex = nextIndexes[0] || 0;
const cityIndex = nextIndexes[1] || 0;
const districtIndex = nextIndexes[2] || 0;
if (provinceIndex !== (indexes[0] || 0)) {
await loadPickerColumns(provinceIndex, 0);
return;
}
if (cityIndex !== (indexes[1] || 0)) {
await loadPickerColumns(indexes[0] || 0, cityIndex);
return;
}
regionPickerIndexes.value = [indexes[0] || 0, indexes[1] || 0, districtIndex];
};
const openRegionPicker = async () => {
if (isSubmitting.value || isUploading.value || regionLoading.value) return;
if (!regionPickerColumns.value.length) await loadRegionRoot();
if (regionPickerColumns.value.length) {
regionPickerOpen.value = true;
}
};
const closeRegionPicker = () => {
regionPickerOpen.value = false;
};
const confirmRegionSelection = () => {
const indexes = regionPickerIndexes.value;
const trail = [];
regionPickerColumns.value.forEach((column, index) => {
const option = column[Number(indexes[index])];
if (option) trail.push(option);
});
const region = trail[trail.length - 1];
if (!region) return;
regionPickerIndexes.value = trail.map((_, index) => Number(indexes[index]) || 0);
selectedRegion.value = region;
regionPickerTrail.value = trail;
fieldErrors.regionCode = "";
regionPickerError.value = "";
regionPickerOpen.value = false;
};
onMounted(() => {
loadRegionRoot();
});
const backToGenealogies = () => {
if (!isSubmitting.value && !isUploading.value) return returnTo("G01");
};
const uploadCover = async () => {
if (isUploading.value || isSubmitting.value) return;
isUploading.value = true;
uploadError.value = "";
try {
const receipt = await pickAndUploadImage({ requestController: createController });
if (!pageActive) return;
coverOssId.value = toConsumerOssId(receipt.ossId);
coverFileName.value = receipt.fileName || "封面图片";
} catch (error) {
if (pageActive && !isImagePickCancelled(error) && !isRequestCancelled(error)) {
uploadError.value = error?.message || "封面图片上传失败,请稍后重试";
}
} finally {
if (pageActive) isUploading.value = false;
}
};
const submitCreate = async () => {
if (isSubmitting.value || isUploading.value || !validate()) return;
const access = toApiGenealogyAccess(form.accessPreset);
if (!access) {
submitError.value = "访问规则无效,请重新选择";
return;
}
isSubmitting.value = true;
submitError.value = "";
try {
const created = await appApi.createGenealogy({
surname: form.surname,
genealogyName: form.genealogyName,
regionCode: selectedRegion.value.regionCode,
ancestralHall: form.ancestralHall,
originPlace: form.originPlace,
addressDetail: form.addressDetail,
intro: form.intro,
coverOssId: coverOssId.value,
...access,
}, { requestController: createController });
if (!pageActive) return;
await finishPage("G01", {}, {
operation: "genealogy-created",
entityId: created.id,
refresh: true,
});
} catch (error) {
if (!pageActive || isRequestCancelled(error)) return;
submitError.value = error?.message || "创建失败,请稍后重试";
} finally {
if (pageActive) isSubmitting.value = false;
}
};
onUnload(() => {
pageActive = false;
createController.abort();
regionController.abort();
});
</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-content { z-index: 1; padding: 24rpx 32rpx 72rpx; }
.create-card { @include adaptive.adaptive-genealogy-state-panel; padding: 48rpx 38rpx 42rpx; }
.create-card__eyebrow, .create-card__title, .create-card__note, .field-error, .submit-error { display: block; }
.create-card__eyebrow { color: $brand-red; font-size: 26rpx; font-weight: 700; letter-spacing: 3rpx; }
.create-card__title { margin-top: 12rpx; color: $ink; font-family: STKaiti, KaiTi, serif; font-size: 42rpx; font-weight: 700; }
.create-card__note { margin-top: 16rpx; color: $ink-muted; font-size: 25rpx; line-height: 1.65; }
.field-row { display: flex; min-height: 100rpx; align-items: center; margin-top: 22rpx; padding: 0 20rpx; border: 1rpx solid rgba(128, 89, 49, .34); border-radius: 12rpx; background: rgba(255, 252, 245, .7); }
.field-row__label { width: 142rpx; flex: 0 0 auto; color: $ink; font-family: STKaiti, KaiTi, serif; font-size: 30rpx; font-weight: 700; }
.required-mark { display: inline-block; margin-right: 4rpx; color: $brand-red; font-size: 26rpx; line-height: 1; transform: translateY(-3rpx); }
.field-row input { min-width: 0; flex: 1; color: $ink; font-size: 28rpx; }
.field-row--selector { justify-content: space-between; cursor: pointer; }
.region-selector-value { min-width: 0; flex: 1; color: $ink; font-size: 28rpx; text-align: right; overflow-wrap: anywhere; }
.region-selector-value--placeholder { color: #ab9a86; }
.placeholder { color: #ab9a86; }
.intro-field { margin-top: 22rpx; padding: 20rpx; border: 1rpx solid rgba(128, 89, 49, .34); border-radius: 12rpx; background: rgba(255, 252, 245, .7); }
.intro-field__label { display: block; color: $ink; font-family: STKaiti, KaiTi, serif; font-size: 30rpx; font-weight: 700; }
.intro-field textarea { display: block; width: 100%; min-height: 120rpx; margin-top: 14rpx; color: $ink; font-size: 27rpx; line-height: 1.55; }
.cover-field { display: grid; gap: 12rpx; margin-top: 22rpx; padding: 20rpx; border: 1rpx solid rgba(128, 89, 49, .34); border-radius: 12rpx; background: rgba(255, 252, 245, .7); }
.cover-field__label { display: block; color: $ink; font-family: STKaiti, KaiTi, serif; font-size: 30rpx; font-weight: 700; }
.cover-field__hint { display: block; margin-top: 6rpx; color: $ink-muted; font-size: 21rpx; line-height: 1.45; }
.upload-button { justify-self: start; min-height: 60rpx; margin: 0; padding: 0 20rpx; border: 1rpx solid rgba(159, 23, 15, .42); border-radius: 8rpx; background: transparent; color: $brand-red; font-size: 22rpx; }
.upload-receipt { color: $ink-muted; font-size: 21rpx; overflow-wrap: anywhere; }
.field-error, .submit-error { margin-top: 10rpx; color: $brand-red; font-size: 24rpx; line-height: 1.5; }
.access-rule { margin-top: 28rpx; }
.access-rule__label { color: $ink; font-family: STKaiti, KaiTi, serif; font-size: 31rpx; font-weight: 700; }
.access-rule__options { display: flex; margin-top: 16rpx; gap: 16rpx; }
.access-rule__option { flex: 1; padding: 20rpx 12rpx; border: 1rpx solid rgba(128, 89, 49, .34); border-radius: 12rpx; color: $ink-muted; font-size: 25rpx; text-align: center; }
.access-rule__option--active { border-color: $brand-red; background: rgba(159, 23, 15, .07); color: $brand-red; font-weight: 700; }
.create-card .app-button { margin-top: 32rpx; }
.region-sheet { position: fixed; z-index: 10; inset: 0; display: flex; align-items: flex-end; }
.region-sheet__mask { position: absolute; inset: 0; background: rgba(43, 30, 20, .42); }
.region-sheet__panel { position: relative; width: 100%; padding: 22rpx 28rpx calc(24rpx + env(safe-area-inset-bottom)); border-radius: 30rpx 30rpx 0 0; background: #fdf9ef; box-shadow: 0 -12rpx 36rpx rgba(43, 30, 20, .2); }
.region-sheet__intro { padding: 0 10rpx 16rpx; text-align: center; }
.region-sheet__title { display: block; }
.region-sheet__title { color: $ink; font-family: STKaiti, KaiTi, serif; font-size: 36rpx; font-weight: 700; }
.region-sheet__picker { overflow: hidden; border: 1rpx solid rgba(128, 89, 49, .28); border-radius: 16rpx; background: rgba(255, 252, 245, .8); }
.region-sheet__column-headings { display: flex; height: 84rpx; border-bottom: 1rpx solid rgba(128, 89, 49, .18); }
.region-sheet__column-heading { box-sizing: border-box; width: 33.333%; padding: 24rpx 12rpx; color: $ink-muted; font-size: 26rpx; text-align: center; }
.region-sheet__column-heading + .region-sheet__column-heading { border-left: 1rpx solid rgba(128, 89, 49, .16); }
.region-sheet__picker-view { width: 100%; height: 520rpx; }
.region-sheet__picker-item { box-sizing: border-box; height: 104rpx; overflow: hidden; padding: 0 6rpx; color: $ink-muted; font-size: 26rpx; line-height: 104rpx; text-align: center; text-overflow: ellipsis; white-space: nowrap; }
.region-sheet__picker-item--selected { color: $brand-red; font-weight: 700; }
.region-sheet__footer { display: flex; align-items: center; margin-top: 22rpx; gap: 12rpx; }
.region-sheet__cancel { min-width: 116rpx; padding: 20rpx 10rpx; color: $ink-muted; font-size: 28rpx; text-align: center; }
.region-sheet__confirm { flex: 1; height: 82rpx; margin: 0; padding: 0; border: 0; border-radius: 10rpx; background: $brand-red; color: #fff; font-size: 29rpx; font-weight: 700; line-height: 82rpx; }
.region-sheet__confirm::after { display: none; }
</style>
+67 -4
View File
@@ -1,4 +1,67 @@
<!-- 页面编号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>
<template>
<view class="search-page">
<GenealogyPageBackground />
<view class="page-header"><PageHeader title="搜索家谱" custom-back @back="backToGenealogies" /></view>
<view class="page-content">
<view class="search-note"><text>公开家谱</text><text>以下结果来自服务端公开家谱列表</text></view>
<view v-if="state === 'loading'" class="state-card"><AppLoading text="正在读取公开家谱" /></view>
<view v-else-if="state === 'error'" class="state-card"><text>暂时无法读取公开家谱</text><AppButton block type="secondary" label="重新加载" @click="loadGenealogies" /></view>
<view v-else-if="state === 'empty'" class="state-card"><text>暂未找到公开家谱</text></view>
<view v-else class="result-list">
<view v-for="item in rows" :key="item.id" class="genealogy-card">
<view class="card-heading"><text>{{ item.name }}</text><text v-if="item.surname">{{ item.surname }}</text></view>
<text v-if="item.location" class="card-meta">{{ item.location }}</text>
<text v-if="item.intro" class="card-copy">{{ item.intro }}</text>
<view class="card-footer"><text>{{ item.memberCount }} 位成员</text><AppButton :label="item.canManage ? '已在我的家谱' : '申请加入'" :disabled="item.canManage" @click="applyToJoin(item)" /></view>
</view>
</view>
</view>
</view>
</template>
<script setup>
import { ref } from "vue";
import { onLoad, onShow, onUnload } from "@dcloudio/uni-app";
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 { appApi, createRequestController, isRequestCancelled } from "@/utils/api.js";
import { openPage, returnTo } from "@/utils/navigation.js";
const rows = ref([]);
const state = ref("loading");
const controller = createRequestController();
let active = true;
const loadGenealogies = async () => {
controller.abort();
state.value = "loading";
try {
const result = await appApi.getPublicGenealogies({ requestController: controller });
if (!active) return;
rows.value = result.map((item) => ({
id: String(item.genealogyId),
name: String(item.genealogyName || "未命名家谱"),
surname: String(item.surname || ""),
location: String(item.regionFullName || item.regionName || item.originPlace || item.addressDetail || ""),
intro: String(item.intro || ""),
memberCount: Number.isSafeInteger(item.memberCount) ? item.memberCount : 0,
canManage: item.canManage === true
})).filter((item) => /^[1-9]\d*$/.test(item.id));
state.value = rows.value.length ? "ready" : "empty";
} catch (error) {
if (!active || isRequestCancelled(error)) return;
state.value = "error";
}
};
const applyToJoin = (item) => openPage("G08", { genealogyId: item.id, genealogyName: item.name }, "G06");
const backToGenealogies = () => returnTo("G01");
onLoad(loadGenealogies);
onShow(() => { if (state.value !== "loading") loadGenealogies(); });
onUnload(() => { active = false; controller.abort(); });
</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}.search-note,.state-card,.genealogy-card{@include adaptive.adaptive-genealogy-state-panel}.search-note{display:flex;min-height:112rpx;flex-direction:column;justify-content:center;padding:20rpx 30rpx}.search-note text:first-child{color:$ink;font-family:STKaiti,KaiTi,serif;font-size:30rpx;font-weight:700}.search-note text:last-child{margin-top:6rpx;color:$ink-muted;font-size:21rpx}.state-card{display:flex;min-height:310rpx;flex-direction:column;align-items:center;justify-content:center;margin-top:18rpx;padding:42rpx;text-align:center;color:$ink;font-size:28rpx}.state-card .app-button{width:100%;margin-top:22rpx}.result-list{display:flex;flex-direction:column;gap:16rpx;margin-top:18rpx}.genealogy-card{padding:28rpx 32rpx}.card-heading{display:flex;align-items:baseline;justify-content:space-between;gap:18rpx}.card-heading text:first-child{color:$ink;font-family:STKaiti,KaiTi,serif;font-size:31rpx;font-weight:700;overflow-wrap:anywhere}.card-heading text:last-child{flex:0 0 auto;color:$brand-red;font-size:22rpx}.card-meta,.card-copy{display:block;color:$ink-muted;font-size:22rpx;line-height:1.5}.card-meta{margin-top:10rpx}.card-copy{margin-top:8rpx}.card-footer{display:flex;align-items:center;justify-content:space-between;gap:18rpx;margin-top:20rpx;color:$ink-muted;font-size:21rpx}.card-footer .app-button{min-width:180rpx}@media(max-width:340px){.page-content{padding-right:20rpx;padding-left:20rpx}.card-footer{align-items:flex-start;flex-direction:column}.card-footer .app-button{width:100%}}
</style>
+146 -4
View File
@@ -1,4 +1,146 @@
<!-- 页面编号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>
<!-- 页面编号G-08用途 APP GenealogyJoinApplyBody 提交加入申请 -->
<template>
<view class="join-page">
<GenealogyPageBackground />
<view class="page-header"><PageHeader title="申请加入家谱" custom-back @back="requestBack" /></view>
<view class="page-content">
<view v-if="!hasValidContext" class="state-card">
<text>申请入口无效</text>
<text>没有取得可申请的家谱标识</text>
<AppButton block label="返回搜索家谱" @click="backToSearch" />
</view>
<view v-else-if="state === 'success'" class="state-card">
<text>申请已提交</text>
<text>服务端已确认本次加入申请可返回继续查看公开家谱</text>
<AppButton block label="返回搜索家谱" @click="backToSearch" />
</view>
<view v-else class="join-form">
<view class="form-heading">
<text>申请加入</text>
<text>{{ genealogyName || '当前家谱' }}</text>
</view>
<view v-for="field in fields" :key="field.key" class="form-field">
<text>{{ field.label }}</text>
<textarea
v-if="field.multiline"
v-model.trim="form[field.key]"
auto-height
:maxlength="field.maxlength"
:placeholder="field.placeholder"
:disabled="state === 'submitting'"
@input="error = ''"
/>
<input
v-else
v-model.trim="form[field.key]"
:type="field.inputType || 'text'"
:maxlength="field.maxlength"
:placeholder="field.placeholder"
:disabled="state === 'submitting'"
@input="error = ''"
/>
</view>
<text v-if="error" class="form-error">{{ error }}</text>
<AppButton block :disabled="state === 'submitting'" :label="state === 'submitting' ? '正在提交' : '提交申请'" @click="submit" />
</view>
</view>
<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 GenealogyPageBackground from "@/components/GenealogyPageBackground.vue";
import PageHeader from "@/components/PageHeader.vue";
import { appApi, createRequestController, isRequestCancelled } from "@/utils/api.js";
import { createDiscardConfirmation } from "@/utils/discard-confirmation.js";
import { handleBackPress, returnTo, runBackGuard } from "@/utils/navigation.js";
const genealogyId = ref("");
const genealogyName = ref("");
const state = ref("form");
const error = ref("");
const discardVisible = ref(false);
const form = reactive({ applicantName: "", phone: "", relationDesc: "", applyReason: "", inviterUserId: "" });
const fields = [
{ key: "applicantName", label: "申请人姓名", maxlength: 50, placeholder: "填写您的姓名" },
{ key: "phone", label: "手机号", inputType: "number", maxlength: 11, placeholder: "填写联系电话" },
{ key: "relationDesc", label: "关系说明", maxlength: 100, placeholder: "例如:本族成员" },
{ key: "applyReason", label: "申请理由", multiline: true, maxlength: 500, placeholder: "说明申请加入的原因" },
{ key: "inviterUserId", label: "邀请人编号", inputType: "number", maxlength: 20, placeholder: "如有邀请人可填写编号" },
];
const controller = createRequestController();
const hasValidContext = computed(() => /^[1-9]\d*$/.test(genealogyId.value));
const dirty = computed(() => Object.values(form).some((value) => value.trim()));
const confirmation = createDiscardConfirmation((visible) => { discardVisible.value = visible; });
const confirmDiscard = confirmation.confirm;
const cancelDiscard = confirmation.cancel;
onLoad((query) => {
genealogyId.value = String(query?.genealogyId || "");
genealogyName.value = String(query?.genealogyName || "");
});
const backToSearch = () => returnTo("G06");
const submit = async () => {
if (state.value === "submitting" || !hasValidContext.value) return;
state.value = "submitting";
error.value = "";
try {
await appApi.applyToJoin(genealogyId.value, { ...form }, { requestController: controller });
state.value = "success";
} catch (cause) {
if (!isRequestCancelled(cause)) {
state.value = "form";
error.value = cause?.message || "申请提交失败,请稍后重试。";
}
}
};
const requestBack = () => runBackGuard({
transientOpen: discardVisible.value,
dirty: dirty.value && state.value === "form",
submitting: state.value === "submitting",
"close-transient": cancelDiscard,
"block-submitting": () => true,
"confirm-discard": confirmation.request,
});
onBackPress((event) => handleBackPress(event, requestBack));
onUnmounted(() => { controller.abort(); confirmation.dispose(); });
</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, .join-form { @include adaptive.adaptive-genealogy-state-panel; }
.state-card { min-height: 340rpx; padding: 78rpx 52rpx 50rpx; text-align: center; }
.state-card text { display: block; }
.state-card text:first-child, .form-heading 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, .join-form .app-button { margin-top: 28rpx; }
.join-form { padding: 30rpx; }
.form-heading { display: flex; flex-direction: column; gap: 8rpx; padding-bottom: 22rpx; border-bottom: 1rpx solid rgba(128, 89, 49, .24); }
.form-heading text:last-child { color: $brand-red; font-size: 24rpx; }
.form-field { margin-top: 22rpx; }
.form-field > text { display: block; color: $ink; font-size: 24rpx; font-weight: 700; }
.form-field input, .form-field textarea { @include adaptive.adaptive-genealogy-form-field; box-sizing: border-box; display: block; width: 100%; margin-top: 10rpx; padding: 16rpx 22rpx; color: $ink; font-size: 24rpx; }
.form-field input { min-height: 76rpx; padding-top: 0; padding-bottom: 0; }
.form-field textarea { min-height: 120rpx; line-height: 1.55; }
.form-error { display: block; margin-top: 16rpx; color: $brand-red; font-size: 22rpx; line-height: 1.45; }
</style>
+42 -3
View File
@@ -1,10 +1,49 @@
<!-- 页面编号G-09用途我的申请列表 DTO 未声明时不展示 fixture 申请 -->
<!-- 页面编号G-09用途读取当前账号的加入申请 -->
<template>
<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>
<view class="applications-page">
<GenealogyPageBackground />
<view class="page-header"><PageHeader title="我的申请" custom-back @back="returnToGenealogies" /></view>
<view class="page-content">
<view v-if="state === 'loading'" class="state-card"><AppLoading text="正在读取我的申请" /></view>
<view v-else-if="state === 'error'" class="state-card"><text>暂时无法读取我的申请</text><AppButton block type="secondary" label="重新加载" @click="loadApplications" /></view>
<view v-else-if="state === 'empty'" class="state-card"><text>暂无加入申请</text><text>申请记录会直接从服务端读取。</text><AppButton block label="返回我的家谱" @click="returnToGenealogies" /></view>
<view v-else class="state-card"><text>已读取 {{ applications.length }} 条申请</text><text>申请详情接口尚未开放当前不会用本地数据补造申请内容</text><AppButton block label="返回我的家谱" @click="returnToGenealogies" /></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 returnToGenealogies=()=>returnTo("G01");
import { ref } from "vue";
import { onLoad, onShow, onUnload } from "@dcloudio/uni-app";
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 { appApi, createRequestController, isRequestCancelled } from "@/utils/api.js";
import { returnTo } from "@/utils/navigation.js";
const applications = ref([]);
const state = ref("loading");
const controller = createRequestController();
let active = true;
const loadApplications = async () => {
controller.abort();
state.value = "loading";
try {
applications.value = await appApi.getMyJoinApplications({ requestController: controller });
if (!active) return;
state.value = applications.value.length ? "ready" : "empty";
} catch (error) {
if (!active || isRequestCancelled(error)) return;
state.value = "error";
}
};
const returnToGenealogies = () => returnTo("G01");
onLoad(loadApplications);
onShow(() => { if (state.value !== "loading") loadApplications(); });
onUnload(() => { active = false; controller.abort(); });
</script>
<style scoped lang="scss">
@use "../../styles/adaptive-frame-profiles.scss" as adaptive;
.applications-page{display:flex;min-height:100vh;flex-direction:column;background:$paper}.page-header,.page-content{z-index:1}.page-content{padding:18rpx 24rpx 72rpx}.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}
+46 -3
View File
@@ -1,10 +1,53 @@
<!-- 页面编号G-10用途加入申请审核缺申请列表 DTO/ID 来源时保持关闭 -->
<!-- 页面编号G-10用途读取当前家谱待审核的加入申请 -->
<template>
<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>
<view class="review-page">
<GenealogyPageBackground />
<view class="page-header"><PageHeader title="申请审核" custom-back @back="returnToGenealogies" /></view>
<view class="page-content">
<view v-if="!valid" class="state-card"><text>申请审核入口无效</text><AppButton block label="返回我的家谱" @click="returnToGenealogies" /></view>
<view v-else-if="state === 'loading'" class="state-card"><AppLoading text="正在读取待审核申请" /></view>
<view v-else-if="state === 'error'" class="state-card"><text>暂时无法读取待审核申请</text><AppButton block type="secondary" label="重新加载" @click="loadApplications" /></view>
<view v-else-if="state === 'empty'" class="state-card"><text>暂无待审核申请</text><text>待审核记录会直接从服务端读取。</text><AppButton block label="返回我的家谱" @click="returnToGenealogies" /></view>
<view v-else class="state-card"><text>已读取 {{ applications.length }} 条待审核申请</text><text>审核详情接口尚未开放当前不会用本地数据补造申请内容或审核结果</text><AppButton block label="返回我的家谱" @click="returnToGenealogies" /></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 returnToGenealogies=()=>returnTo("G01");
import { computed, ref } from "vue";
import { onLoad, onShow, onUnload } from "@dcloudio/uni-app";
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 { appApi, createRequestController, isRequestCancelled } from "@/utils/api.js";
import { returnTo } from "@/utils/navigation.js";
const genealogyId = ref("");
const applications = ref([]);
const state = ref("loading");
const controller = createRequestController();
let active = true;
const valid = computed(() => /^[1-9]\d*$/.test(genealogyId.value));
const loadApplications = async () => {
if (!valid.value) return;
controller.abort();
state.value = "loading";
try {
applications.value = await appApi.getPendingApplications(genealogyId.value, { requestController: controller });
if (!active) return;
state.value = applications.value.length ? "ready" : "empty";
} catch (error) {
if (!active || isRequestCancelled(error)) return;
state.value = "error";
}
};
const returnToGenealogies = () => returnTo("G01");
onLoad((query) => { genealogyId.value = String(query?.genealogyId || ""); if (valid.value) loadApplications(); });
onShow(() => { if (valid.value && state.value !== "loading") loadApplications(); });
onUnload(() => { active = false; controller.abort(); });
</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}.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}
+386 -4
View File
@@ -1,11 +1,393 @@
<!-- 页面编号G-11用途家谱设置缺可靠读取 DTO 与封面上传 owner 时保持关闭 -->
<!-- 页面编号G-11用途 GenealogyUpdateBody 更新当前家谱 -->
<template>
<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>
<view class="settings-page" :class="`settings-state--${pageState}`">
<GenealogyPageBackground />
<view class="page-header"><PageHeader title="家谱设置" custom-back @back="returnToOverview" /></view>
<view class="page-content">
<AppLoading v-if="pageState === 'loading'" text="正在读取家谱设置" description="请稍候,正在同步当前家谱信息。" />
<view v-else-if="pageState === 'form'" class="settings-card">
<text class="settings-card__title">编辑家谱信息</text>
<text class="settings-card__note">带红色星号的内容不能为空其余内容可按需要补充</text>
<view class="field-row">
<text class="field-row__label"><text class="required-mark">*</text>姓氏</text>
<input v-model="form.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 class="field-row__label"><text class="required-mark">*</text>谱名</text>
<input v-model="form.genealogyName" maxlength="24" placeholder="请输入家谱名称" placeholder-class="placeholder" @input="clearFieldError('genealogyName')" />
</view>
<text v-if="fieldErrors.genealogyName" class="field-error">{{ fieldErrors.genealogyName }}</text>
<view class="field-row field-row--selector" @click="openRegionPicker">
<text class="field-row__label"><text class="required-mark">*</text>所在地区</text>
<text class="region-selector-value" :class="{ 'region-selector-value--placeholder': !regionDisplay }">{{ regionDisplay || (regionLoading ? '正在加载地区…' : (regionPickerError || '请选择所在地区')) }}</text>
</view>
<text v-if="fieldErrors.regionCode" class="field-error">{{ fieldErrors.regionCode }}</text>
<view class="field-row">
<text class="field-row__label">堂号</text>
<input v-model="form.ancestralHall" maxlength="12" placeholder="请输入堂号" placeholder-class="placeholder" />
</view>
<view class="field-row">
<text class="field-row__label">所在地</text>
<input v-model="form.originPlace" maxlength="30" placeholder="请输入所在地" placeholder-class="placeholder" />
</view>
<view class="field-row">
<text class="field-row__label">详细地址</text>
<input v-model="form.addressDetail" maxlength="60" placeholder="请输入详细地址" placeholder-class="placeholder" />
</view>
<view class="intro-field">
<text class="intro-field__label">家谱简介</text>
<textarea v-model="form.intro" maxlength="200" auto-height placeholder="记录家谱缘由、迁徙或家训" placeholder-class="placeholder" />
</view>
<view class="cover-field">
<view>
<text class="cover-field__label">封面图片</text>
<text class="cover-field__hint">{{ coverOssId ? '当前已关联封面;重新选择后会以新的真实上传回执更新。' : '选择图片后会取得真实上传回执,并在保存时关联。' }}</text>
</view>
<button class="upload-button" :disabled="isUploading || isSubmitting" @click="uploadCover">{{ isUploading ? '上传中' : '选择图片' }}</button>
<text v-if="coverFileName" class="upload-receipt">已上传{{ coverFileName }}</text>
</view>
<text v-if="uploadError" class="field-error">{{ uploadError }}</text>
<view class="access-rule">
<text class="access-rule__label">访问规则</text>
<view class="access-rule__options">
<view
v-for="option in GENEALOGY_ACCESS_PRESET_OPTIONS"
:key="option.value"
class="access-rule__option"
:class="{ 'access-rule__option--active': form.accessPreset === option.value }"
@click="form.accessPreset = option.value"
>{{ option.label }}</view>
</view>
</view>
<text v-if="submitError" class="submit-error">{{ submitError }}</text>
<AppButton block :disabled="isSubmitting || isUploading" :label="isSubmitting ? '正在保存…' : '保存设置'" @click="submitUpdate" />
</view>
<view v-else class="state-card">
<text>{{ stateCopy.title }}</text>
<text>{{ stateCopy.copy }}</text>
<AppButton block :label="stateCopy.action" @click="handleStateAction" />
</view>
</view>
<view v-if="regionPickerOpen" class="region-sheet">
<view class="region-sheet__mask" @click="closeRegionPicker" />
<view class="region-sheet__panel">
<view class="region-sheet__intro"><text class="region-sheet__title">选择地区</text></view>
<view class="region-sheet__picker">
<view class="region-sheet__column-headings">
<text v-for="label in ['省份', '城市', '区县']" :key="label" class="region-sheet__column-heading">{{ label }}</text>
</view>
<picker-view class="region-sheet__picker-view" :indicator-style="regionPickerIndicatorStyle" :value="regionPickerIndexes" @change="handleRegionPickerChange">
<picker-view-column v-for="(column, columnIndex) in regionPickerColumns" :key="columnIndex">
<view v-for="(option, optionIndex) in column" :key="option.regionCode" class="region-sheet__picker-item" :class="{ 'region-sheet__picker-item--selected': regionPickerIndexes[columnIndex] === optionIndex }">{{ option.label }}</view>
</picker-view-column>
</picker-view>
</view>
<view class="region-sheet__footer">
<view class="region-sheet__cancel" @click="closeRegionPicker">取消</view>
<button class="region-sheet__confirm" @click="confirmRegionSelection">确认选择</button>
</view>
</view>
</view>
</view>
</template>
<script setup>
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();
import { computed, onMounted, reactive, ref } from "vue";
import { onLoad, onUnload } from "@dcloudio/uni-app";
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 { appApi, createRequestController, isRequestCancelled } from "@/utils/api.js";
import { GENEALOGY_ACCESS_PRESET, GENEALOGY_ACCESS_PRESET_OPTIONS, toApiGenealogyAccess } from "@/utils/genealogy-contracts.js";
import { isImagePickCancelled, pickAndUploadImage, toConsumerOssId } from "@/utils/resumable-image-upload.js";
import { goBack, returnTo } from "@/utils/navigation.js";
const genealogyId = ref("");
const pageState = ref("loading");
const form = reactive({
surname: "",
genealogyName: "",
ancestralHall: "",
originPlace: "",
addressDetail: "",
intro: "",
accessPreset: GENEALOGY_ACCESS_PRESET.MEMBER_ONLY,
});
const fieldErrors = reactive({ surname: "", genealogyName: "", regionCode: "" });
const submitError = ref("");
const isSubmitting = ref(false);
const isUploading = ref(false);
const uploadError = ref("");
const coverOssId = ref(null);
const coverFileName = ref("");
const currentRegionCode = ref("");
const currentRegionDisplay = ref("");
const selectedRegion = ref(null);
const regionPickerTrail = ref([]);
const regionPickerColumns = ref([]);
const regionPickerIndexes = ref([0]);
const regionPickerError = ref("");
const regionLoading = ref(false);
const regionPickerOpen = ref(false);
const regionPickerIndicatorStyle = "height: 104rpx; border-top: 1px solid rgba(159, 23, 15, .46); border-bottom: 1px solid rgba(159, 23, 15, .46); background: rgba(159, 23, 15, .08);";
const controller = createRequestController();
let pageActive = true;
const hasValidContext = computed(() => /^[1-9]\d*$/.test(genealogyId.value));
const regionDisplay = computed(() =>
regionPickerTrail.value.length
? regionPickerTrail.value.map((item) => item.label).join(" / ")
: currentRegionDisplay.value,
);
const stateCopy = computed(() => {
if (pageState.value === "success") {
return { title: "家谱设置已保存", copy: "服务端已返回成功结果。", action: "返回家谱总览" };
}
if (!hasValidContext.value) {
return { title: "家谱入口无效", copy: "没有取得有效家谱标识。", action: "返回上一页" };
}
return { title: "家谱设置暂时无法读取", copy: "请检查网络后重新读取。", action: "重新读取" };
});
const clearFieldError = (field) => {
fieldErrors[field] = "";
submitError.value = "";
};
const validate = () => {
fieldErrors.surname = form.surname.trim() ? "" : "请填写姓氏";
fieldErrors.genealogyName = form.genealogyName.trim() ? "" : "请填写谱名";
fieldErrors.regionCode = (selectedRegion.value?.regionCode || currentRegionCode.value) ? "" : "请选择所在地区";
return !fieldErrors.surname && !fieldErrors.genealogyName && !fieldErrors.regionCode;
};
const fetchRegionChildren = async (parentCode) => {
regionLoading.value = true;
regionPickerError.value = "";
try {
return await appApi.getRegionChildren(parentCode, { requestController: controller });
} catch (error) {
if (!isRequestCancelled(error)) regionPickerError.value = error?.message || "地区列表加载失败,请重试";
return [];
} finally {
regionLoading.value = false;
}
};
const loadPickerColumns = async (provinceIndex = 0, cityIndex = 0) => {
const provinces = regionPickerColumns.value[0] || await fetchRegionChildren("0");
const province = provinces[provinceIndex];
if (!province) return;
const cities = await fetchRegionChildren(province.regionCode);
const city = cities[cityIndex];
if (!city) {
regionPickerColumns.value = [provinces];
regionPickerIndexes.value = [provinceIndex];
return;
}
const districts = await fetchRegionChildren(city.regionCode);
if (!districts.length || regionPickerError.value) return;
regionPickerColumns.value = [provinces, cities, districts];
regionPickerIndexes.value = [provinceIndex, cityIndex, 0];
};
const loadRegionRoot = async () => {
if (regionLoading.value) return;
const roots = await fetchRegionChildren("0");
if (!roots.length || regionPickerError.value) return;
regionPickerColumns.value = [roots];
regionPickerIndexes.value = [0];
await loadPickerColumns();
};
const handleRegionPickerChange = async (event) => {
if (regionLoading.value) return;
const nextIndexes = (event?.detail?.value || []).map((index) => Number(index) || 0);
const indexes = regionPickerIndexes.value;
const provinceIndex = nextIndexes[0] || 0;
const cityIndex = nextIndexes[1] || 0;
const districtIndex = nextIndexes[2] || 0;
if (provinceIndex !== (indexes[0] || 0)) {
await loadPickerColumns(provinceIndex, 0);
return;
}
if (cityIndex !== (indexes[1] || 0)) {
await loadPickerColumns(indexes[0] || 0, cityIndex);
return;
}
regionPickerIndexes.value = [indexes[0] || 0, indexes[1] || 0, districtIndex];
};
const openRegionPicker = async () => {
if (isSubmitting.value || isUploading.value || regionLoading.value) return;
if (!regionPickerColumns.value.length) await loadRegionRoot();
if (regionPickerColumns.value.length) regionPickerOpen.value = true;
};
const closeRegionPicker = () => { regionPickerOpen.value = false; };
const confirmRegionSelection = () => {
const trail = regionPickerColumns.value.map((column, index) => column[Number(regionPickerIndexes.value[index])]).filter(Boolean);
const region = trail[trail.length - 1];
if (!region) return;
selectedRegion.value = region;
currentRegionCode.value = region.regionCode;
regionPickerTrail.value = trail;
fieldErrors.regionCode = "";
regionPickerError.value = "";
regionPickerOpen.value = false;
};
const loadSettings = async () => {
if (!hasValidContext.value) {
pageState.value = "invalid";
return;
}
pageState.value = "loading";
try {
const settings = await appApi.getGenealogySettings(genealogyId.value, { requestController: controller });
if (!pageActive) return;
Object.assign(form, {
surname: settings.surname,
genealogyName: settings.genealogyName,
ancestralHall: settings.ancestralHall,
originPlace: settings.originPlace,
addressDetail: settings.addressDetail,
intro: settings.intro,
accessPreset: settings.accessPreset || GENEALOGY_ACCESS_PRESET.MEMBER_ONLY,
});
currentRegionCode.value = settings.regionCode;
currentRegionDisplay.value = settings.regionFullName || settings.regionName;
coverOssId.value = settings.coverOssId;
pageState.value = "form";
} catch (error) {
if (!pageActive || isRequestCancelled(error)) return;
pageState.value = "error";
}
};
const uploadCover = async () => {
if (isUploading.value || isSubmitting.value) return;
isUploading.value = true;
uploadError.value = "";
try {
const receipt = await pickAndUploadImage({ requestController: controller });
if (!pageActive) return;
coverOssId.value = toConsumerOssId(receipt.ossId);
coverFileName.value = receipt.fileName || "封面图片";
} catch (error) {
if (pageActive && !isImagePickCancelled(error) && !isRequestCancelled(error)) {
uploadError.value = error?.message || "封面图片上传失败,请稍后重试";
}
} finally {
if (pageActive) isUploading.value = false;
}
};
const submitUpdate = async () => {
if (isSubmitting.value || isUploading.value || !validate()) return;
const access = toApiGenealogyAccess(form.accessPreset);
if (!access) {
submitError.value = "访问规则无效,请重新选择";
return;
}
isSubmitting.value = true;
submitError.value = "";
try {
await appApi.updateGenealogy(genealogyId.value, {
surname: form.surname,
genealogyName: form.genealogyName,
regionCode: selectedRegion.value?.regionCode || currentRegionCode.value,
ancestralHall: form.ancestralHall,
originPlace: form.originPlace,
addressDetail: form.addressDetail,
intro: form.intro,
...(coverOssId.value ? { coverOssId: coverOssId.value } : {}),
...access,
}, { requestController: controller });
if (!pageActive) return;
pageState.value = "success";
} catch (error) {
if (!pageActive || isRequestCancelled(error)) return;
submitError.value = error?.message || "家谱设置保存失败,请稍后重试";
} finally {
if (pageActive) isSubmitting.value = false;
}
};
const returnToOverview = () => hasValidContext.value ? returnTo("G05", { genealogyId: genealogyId.value }) : goBack();
const handleStateAction = () => pageState.value === "success" ? returnToOverview() : loadSettings();
onLoad((query) => { genealogyId.value = String(query?.genealogyId || ""); });
onMounted(() => { void loadSettings(); });
onUnload(() => {
pageActive = false;
controller.abort();
});
</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}.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}
.settings-page { display: flex; min-height: 100vh; flex-direction: column; background: $paper; }
.page-header, .page-content { z-index: 1; }
.page-content { flex: 1; padding: 24rpx 32rpx 72rpx; }
.settings-card, .state-card { @include adaptive.adaptive-genealogy-state-panel; box-sizing: border-box; }
.settings-card { padding: 42rpx 38rpx; }
.settings-card__title, .settings-card__note, .field-error, .submit-error { display: block; }
.settings-card__title { color: $ink; font-family: STKaiti, KaiTi, serif; font-size: 40rpx; font-weight: 700; }
.settings-card__note { margin-top: 12rpx; color: $ink-muted; font-size: 24rpx; line-height: 1.55; }
.field-row { display: flex; min-height: 100rpx; align-items: center; margin-top: 22rpx; padding: 0 20rpx; border: 1rpx solid rgba(128, 89, 49, .34); border-radius: 12rpx; background: rgba(255, 252, 245, .7); }
.field-row__label { width: 142rpx; flex: 0 0 auto; color: $ink; font-family: STKaiti, KaiTi, serif; font-size: 30rpx; font-weight: 700; }
.required-mark { display: inline-block; margin-right: 4rpx; color: $brand-red; font-size: 26rpx; line-height: 1; transform: translateY(-3rpx); }
.field-row input { min-width: 0; flex: 1; color: $ink; font-size: 28rpx; }
.field-row--selector { justify-content: space-between; cursor: pointer; }
.region-selector-value { min-width: 0; flex: 1; color: $ink; font-size: 28rpx; text-align: right; overflow-wrap: anywhere; }
.region-selector-value--placeholder, .placeholder { color: #ab9a86; }
.intro-field, .cover-field { margin-top: 22rpx; padding: 20rpx; border: 1rpx solid rgba(128, 89, 49, .34); border-radius: 12rpx; background: rgba(255, 252, 245, .7); }
.intro-field__label, .cover-field__label { display: block; color: $ink; font-family: STKaiti, KaiTi, serif; font-size: 30rpx; font-weight: 700; }
.intro-field textarea { display: block; width: 100%; min-height: 120rpx; margin-top: 14rpx; color: $ink; font-size: 27rpx; line-height: 1.55; }
.cover-field { display: grid; gap: 12rpx; }
.cover-field__hint { display: block; margin-top: 6rpx; color: $ink-muted; font-size: 21rpx; line-height: 1.45; }
.upload-button { justify-self: start; min-height: 60rpx; margin: 0; padding: 0 20rpx; border: 1rpx solid rgba(159, 23, 15, .42); border-radius: 8rpx; background: transparent; color: $brand-red; font-size: 22rpx; }
.upload-receipt { color: $ink-muted; font-size: 21rpx; overflow-wrap: anywhere; }
.field-error, .submit-error { margin-top: 10rpx; color: $brand-red; font-size: 24rpx; line-height: 1.5; }
.access-rule { margin-top: 28rpx; }
.access-rule__label { color: $ink; font-family: STKaiti, KaiTi, serif; font-size: 31rpx; font-weight: 700; }
.access-rule__options { display: flex; margin-top: 16rpx; gap: 16rpx; }
.access-rule__option { flex: 1; padding: 20rpx 12rpx; border: 1rpx solid rgba(128, 89, 49, .34); border-radius: 12rpx; color: $ink-muted; font-size: 25rpx; text-align: center; }
.access-rule__option--active { border-color: $brand-red; background: rgba(159, 23, 15, .07); color: $brand-red; font-weight: 700; }
.settings-card .app-button { margin-top: 32rpx; }
.state-card { min-height: 340rpx; padding: 78rpx 52rpx 50rpx; text-align: center; }
.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; }
.region-sheet { position: fixed; z-index: 10; inset: 0; display: flex; align-items: flex-end; }
.region-sheet__mask { position: absolute; inset: 0; background: rgba(43, 30, 20, .42); }
.region-sheet__panel { width: 100%; padding: 22rpx 28rpx calc(24rpx + env(safe-area-inset-bottom)); border-radius: 30rpx 30rpx 0 0; background: #fdf9ef; box-shadow: 0 -12rpx 36rpx rgba(43, 30, 20, .2); }
.region-sheet__intro { padding: 0 10rpx 16rpx; text-align: center; }
.region-sheet__title { display: block; color: $ink; font-family: STKaiti, KaiTi, serif; font-size: 36rpx; font-weight: 700; }
.region-sheet__picker { border: 1rpx solid rgba(128, 89, 49, .28); border-radius: 16rpx; background: rgba(255, 252, 245, .8); }
.region-sheet__column-headings { display: flex; height: 84rpx; border-bottom: 1rpx solid rgba(128, 89, 49, .18); }
.region-sheet__column-heading { box-sizing: border-box; width: 33.333%; padding: 24rpx 12rpx; color: $ink-muted; font-size: 26rpx; text-align: center; }
.region-sheet__column-heading + .region-sheet__column-heading { border-left: 1rpx solid rgba(128, 89, 49, .16); }
.region-sheet__picker-view { width: 100%; height: 520rpx; }
.region-sheet__picker-item { box-sizing: border-box; height: 104rpx; padding: 0 6rpx; color: $ink-muted; font-size: 26rpx; line-height: 104rpx; text-align: center; text-overflow: ellipsis; white-space: nowrap; }
.region-sheet__picker-item--selected { color: $brand-red; font-weight: 700; }
.region-sheet__footer { display: flex; align-items: center; margin-top: 22rpx; gap: 12rpx; }
.region-sheet__cancel { min-width: 116rpx; padding: 20rpx 10rpx; color: $ink-muted; font-size: 28rpx; text-align: center; }
.region-sheet__confirm { flex: 1; height: 82rpx; margin: 0; padding: 0; border: 0; border-radius: 10rpx; background: $brand-red; color: #fff; font-size: 29rpx; font-weight: 700; line-height: 82rpx; }
.region-sheet__confirm::after { display: none; }
</style>
+44 -4
View File
@@ -1,11 +1,51 @@
<!-- 页面编号N-01用途通知入口列表 item DTO 缺失时不展示 fixture 通知 -->
<!-- 页面编号N-01用途读取当前账号的消息通知 -->
<template>
<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>
<view class="message-page">
<ModulePageBackground module="notification" />
<view class="page-header"><PageHeader root title="消息中心" /></view>
<view class="page-content">
<view v-if="state === 'loading'" class="state-card"><AppLoading text="正在读取消息" /></view>
<view v-else-if="state === 'error'" class="state-card"><text>暂时无法读取消息</text><AppButton block type="secondary" label="重新加载" @click="loadNotifications" /></view>
<view v-else-if="state === 'empty'" class="state-card"><text>暂无消息</text><text>消息列表会直接从服务端读取。</text><AppButton block label="返回我的" @click="returnToProfile" /></view>
<view v-else class="state-card"><text>已读取 {{ notifications.length }} 条消息</text><text>消息详情接口尚未开放当前不会用本地数据补造正文或已读状态</text><AppButton block label="返回我的" @click="returnToProfile" /></view>
</view>
<AppTabbar active="profile" />
</view>
</template>
<script setup>
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");
import { ref } from "vue";
import { onLoad, onShow, onUnload } from "@dcloudio/uni-app";
import AppButton from "@/components/AppButton.vue";
import AppLoading from "@/components/AppLoading.vue";
import AppTabbar from "@/components/AppTabbar.vue";
import ModulePageBackground from "@/components/ModulePageBackground.vue";
import PageHeader from "@/components/PageHeader.vue";
import { appApi, createRequestController, isRequestCancelled } from "@/utils/api.js";
import { goRoot } from "@/utils/navigation.js";
const notifications = ref([]);
const state = ref("loading");
const controller = createRequestController();
let active = true;
const loadNotifications = async () => {
controller.abort();
state.value = "loading";
try {
notifications.value = await appApi.getNotifications({ requestController: controller });
if (!active) return;
state.value = notifications.value.length ? "ready" : "empty";
} catch (error) {
if (!active || isRequestCancelled(error)) return;
state.value = "error";
}
};
const returnToProfile = () => goRoot("M01");
onLoad(loadNotifications);
onShow(() => { if (state.value !== "loading") loadNotifications(); });
onUnload(() => { active = false; controller.abort(); });
</script>
<style scoped lang="scss">
@use "../../styles/adaptive-frame-profiles.scss" as adaptive;
.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}
+108 -4
View File
@@ -1,11 +1,115 @@
<!-- 页面编号M-01用途个人中心资料 DTO 未声明时不展示 mock 用户 -->
<!-- 页面编号M-01用途个人中心显示已核实的当前用户资料 -->
<template>
<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>
<view class="profile-page">
<ModulePageBackground module="profile" />
<view class="page-header"><PageHeader root title="我的" /></view>
<view class="page-content">
<view v-if="loading" class="state-card"><text>正在读取个人资料</text></view>
<view v-else-if="loadError" class="state-card">
<text>个人资料暂时无法读取</text>
<text>{{ loadError }}</text>
<AppButton block label="重新读取" @click="loadProfile" />
</view>
<template v-else>
<view class="profile-summary">
<view class="profile-summary__avatar"><text>{{ profile.avatar ? "已设置" : "未设置" }}</text></view>
<view class="profile-summary__identity">
<text>{{ profile.nickName || "未设置昵称" }}</text>
<text>{{ profile.realName || "未设置真实姓名" }}</text>
<text>{{ profile.phone || "未提供手机号" }}</text>
</view>
<AppButton class="profile-summary__edit" label="编辑资料" @click="openEdit" />
</view>
<view class="profile-fields">
<view class="profile-field"><text>性别</text><text>{{ profile.sex || "未设置" }}</text></view>
<view class="profile-field"><text>生日</text><text>{{ birthdayText }}</text></view>
<view class="profile-field"><text>邮箱</text><text>{{ profile.email || "未设置" }}</text></view>
</view>
<view class="profile-menu">
<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>
</template>
</view>
<AppTabbar active="profile" />
</view>
</template>
<script setup>
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");
import { computed, reactive, ref } from "vue";
import { onShow, onUnload } 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 { appApi, createRequestController, isRequestCancelled } from "@/utils/api.js";
import { openPage } from "@/utils/navigation.js";
const profile = reactive({ avatar: null, nickName: "", realName: "", phone: "", sex: "", birthday: "", email: "" });
const loading = ref(false);
const loadError = ref("");
const requestController = createRequestController();
let pageActive = true;
const birthdayText = computed(() => /^\d{4}-\d{2}-\d{2}/.test(profile.birthday) ? profile.birthday.slice(0, 10) : "未设置");
const loadProfile = async () => {
if (loading.value) return;
loading.value = true;
loadError.value = "";
try {
const data = await appApi.getProfile({ requestController });
if (!pageActive) return;
Object.assign(profile, data);
} catch (error) {
if (pageActive && !isRequestCancelled(error)) {
loadError.value = error?.message || "请稍后重试";
}
} finally {
if (pageActive) loading.value = false;
}
};
const open = (route) => openPage(route, {}, "M01");
const openEdit = () => open("M02");
onShow(loadProfile);
onUnload(() => {
pageActive = false;
requestController.abort();
});
</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}.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}
.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, .profile-summary, .profile-fields { @include adaptive.adaptive-family-content; }
.state-card { box-sizing: border-box; min-height: 340rpx; padding: 78rpx 52rpx 50rpx; text-align: center; }
.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; }
.profile-summary { display: grid; grid-template-columns: 96rpx minmax(0, 1fr); align-items: center; gap: 18rpx; padding: 30rpx; }
.profile-summary__avatar { display: flex; width: 96rpx; height: 96rpx; align-items: center; justify-content: center; border: 1rpx solid rgba(159, 23, 15, .32); border-radius: 50%; background: rgba(159, 23, 15, .06); color: $brand-red; font-size: 19rpx; text-align: center; }
.profile-summary__identity { min-width: 0; }
.profile-summary__identity text { display: block; overflow-wrap: anywhere; }
.profile-summary__identity text:first-child { color: $ink; font-family: STKaiti, KaiTi, serif; font-size: 32rpx; font-weight: 700; }
.profile-summary__identity text:nth-child(2), .profile-summary__identity text:last-child { margin-top: 5rpx; color: $ink-muted; font-size: 21rpx; }
.profile-summary__edit { grid-column: 1 / -1; margin-top: 6rpx; }
.profile-fields { display: flex; flex-direction: column; margin-top: 18rpx; padding: 8rpx 30rpx; }
.profile-field { display: flex; min-height: 82rpx; align-items: center; justify-content: space-between; gap: 20rpx; border-bottom: 1rpx solid rgba(181, 137, 63, .3); }
.profile-field:last-child { border-bottom: 0; }
.profile-field text:first-child { flex: 0 0 auto; color: $ink; font-size: 24rpx; font-weight: 700; }
.profile-field text:last-child { min-width: 0; color: $ink-muted; font-size: 22rpx; overflow-wrap: anywhere; text-align: right; }
.profile-menu { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 14rpx; margin-top: 24rpx; }
.profile-menu .app-button { margin: 0; }
</style>
+192 -4
View File
@@ -1,11 +1,199 @@
<!-- 页面编号M-02用途编辑资料读取 DTO 与现有字段不一致时保持关闭 -->
<!-- 页面编号M-02用途编辑已由 Apifox 声明的个人资料字段 -->
<template>
<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>
<view class="profile-edit-page">
<ModulePageBackground module="profile" />
<view class="page-header"><PageHeader title="编辑资料" custom-back @back="backToProfile" /></view>
<view class="page-content">
<view v-if="loading" class="state-card"><text>正在读取个人资料</text></view>
<view v-else-if="loadError" class="state-card">
<text>个人资料暂时无法读取</text>
<text>{{ loadError }}</text>
<AppButton block label="重新读取" @click="loadProfile" />
</view>
<template v-else>
<view class="form-panel">
<view class="avatar-row">
<view><text>头像</text><text>{{ avatarMessage }}</text></view>
<button class="upload-button" :disabled="uploading || saving" @click="uploadAvatar">{{ uploading ? "上传中" : "选择图片" }}</button>
</view>
<text v-if="avatarError" class="field-error">{{ avatarError }}</text>
<view class="form-row"><text>昵称</text><input v-model.trim="form.nickName" maxlength="30" placeholder="请输入昵称" placeholder-class="placeholder" :disabled="saving || uploading" /></view>
<view class="form-row"><text>真实姓名</text><input v-model.trim="form.realName" maxlength="30" placeholder="请输入真实姓名" placeholder-class="placeholder" :disabled="saving || uploading" /></view>
<view class="form-row"><text>性别</text><input v-model.trim="form.sex" placeholder="请输入性别" placeholder-class="placeholder" :disabled="saving || uploading" /></view>
<view class="form-row">
<text>生日</text>
<picker mode="date" :value="form.birthday" :disabled="saving || uploading" @change="changeBirthday"><view class="picker-value" :class="{ 'picker-value--placeholder': !form.birthday }">{{ form.birthday || "请选择生日" }}</view></picker>
</view>
<view class="form-row"><text>邮箱</text><input v-model.trim="form.email" maxlength="100" placeholder="请输入邮箱" placeholder-class="placeholder" :disabled="saving || uploading" /></view>
</view>
<text class="form-note">资料字段均可按需填写留空不会提交为修改</text>
<text v-if="saveError" class="save-error">{{ saveError }}</text>
<AppButton block :disabled="saving || uploading" :label="saving ? '保存中…' : '保存资料'" @click="saveProfile" />
</template>
</view>
<AppToast :visible="feedbackVisible" :message="feedbackMessage" />
</view>
</template>
<script setup>
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");
import { computed, reactive, ref } from "vue";
import { onLoad, onUnload } from "@dcloudio/uni-app";
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 { appApi, createRequestController, isRequestCancelled } from "@/utils/api.js";
import { isImagePickCancelled, pickAndUploadImage, toConsumerOssId } from "@/utils/resumable-image-upload.js";
import { finishPage, returnTo } from "@/utils/navigation.js";
const form = reactive({ nickName: "", realName: "", sex: "", birthday: "", email: "" });
const original = reactive({ nickName: "", realName: "", sex: "", birthday: "", email: "", avatar: null });
const avatarId = ref(null);
const avatarFileName = ref("");
const loading = ref(true);
const loadError = ref("");
const uploading = ref(false);
const avatarError = ref("");
const saving = ref(false);
const saveError = ref("");
const feedbackVisible = ref(false);
const feedbackMessage = ref("");
const requestController = createRequestController();
let feedbackTimer = null;
let pageActive = true;
const avatarMessage = computed(() => {
if (avatarFileName.value) return `已选择 ${avatarFileName.value}`;
return avatarId.value ? "当前头像已设置" : "尚未设置头像";
});
const showFeedback = (message) => {
feedbackMessage.value = message;
feedbackVisible.value = true;
if (feedbackTimer) clearTimeout(feedbackTimer);
feedbackTimer = setTimeout(() => {
feedbackVisible.value = false;
feedbackTimer = null;
}, 2200);
};
const toBirthdayDate = (value) => /^\d{4}-\d{2}-\d{2}/.test(value) ? value.slice(0, 10) : "";
const loadProfile = async () => {
if (loading.value === false && (uploading.value || saving.value)) return;
loading.value = true;
loadError.value = "";
try {
const profile = await appApi.getProfile({ requestController });
if (!pageActive) return;
Object.assign(form, {
nickName: profile.nickName,
realName: profile.realName,
sex: profile.sex,
birthday: toBirthdayDate(profile.birthday),
email: profile.email,
});
Object.assign(original, { ...form, avatar: profile.avatar });
avatarId.value = profile.avatar;
avatarFileName.value = "";
} catch (error) {
if (pageActive && !isRequestCancelled(error)) loadError.value = error?.message || "请稍后重试";
} finally {
if (pageActive) loading.value = false;
}
};
const changeBirthday = (event) => {
form.birthday = event?.detail?.value || "";
};
const uploadAvatar = async () => {
if (uploading.value || saving.value) return;
uploading.value = true;
avatarError.value = "";
try {
const receipt = await pickAndUploadImage({ requestController });
if (!pageActive) return;
avatarId.value = toConsumerOssId(receipt.ossId);
avatarFileName.value = receipt.fileName || "新头像";
} catch (error) {
if (pageActive && !isImagePickCancelled(error) && !isRequestCancelled(error)) {
avatarError.value = error?.message || "头像上传失败,请稍后重试";
}
} finally {
if (pageActive) uploading.value = false;
}
};
const buildUpdatePayload = () => {
const data = {};
for (const field of ["nickName", "realName", "sex", "email"]) {
if (form[field] && form[field] !== original[field]) data[field] = form[field];
}
if (form.birthday && form.birthday !== original.birthday) data.birthday = `${form.birthday}T00:00:00`;
if (avatarId.value && avatarId.value !== original.avatar) data.avatar = avatarId.value;
return data;
};
const saveProfile = async () => {
if (saving.value || uploading.value) return;
const payload = buildUpdatePayload();
if (Object.keys(payload).length === 0) {
showFeedback("没有需要保存的修改");
return;
}
saving.value = true;
saveError.value = "";
try {
await appApi.updateProfile(payload, { requestController });
if (!pageActive) return;
await finishPage("M01", {}, { operation: "profile-updated", refresh: true });
} catch (error) {
if (pageActive && !isRequestCancelled(error)) saveError.value = error?.message || "保存失败,请稍后重试";
} finally {
if (pageActive) saving.value = false;
}
};
const backToProfile = () => {
if (saving.value || uploading.value) return;
return returnTo("M01");
};
onLoad(loadProfile);
onUnload(() => {
pageActive = false;
requestController.abort();
if (feedbackTimer) clearTimeout(feedbackTimer);
});
</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-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}
.profile-edit-page { display: flex; min-height: 100vh; flex-direction: column; background: $paper; }
.page-header, .page-content { z-index: 1; }
.page-content { flex: 1; padding: 24rpx 30rpx 72rpx; }
.state-card, .form-panel { @include adaptive.adaptive-profile-content; }
.state-card { box-sizing: border-box; min-height: 340rpx; padding: 78rpx 52rpx 50rpx; text-align: center; }
.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; }
.form-panel { padding: 16rpx 30rpx 22rpx; }
.avatar-row, .form-row { display: grid; grid-template-columns: 142rpx minmax(0, 1fr); min-height: 92rpx; align-items: center; gap: 16rpx; border-bottom: 1rpx solid rgba(181, 137, 63, .42); }
.avatar-row > view text { display: block; }
.avatar-row > view text:first-child, .form-row > text { color: $ink; font-size: 23rpx; font-weight: 700; }
.avatar-row > view text:last-child { margin-top: 4rpx; color: $ink-muted; font-size: 19rpx; overflow-wrap: anywhere; }
.upload-button { display: flex; min-height: 64rpx; align-items: center; justify-content: center; border: 0; background: transparent; color: $brand-red; font-size: 22rpx; text-align: right; }
.form-row input, .picker-value { width: auto; min-width: 0; min-height: 68rpx; color: $ink; font-size: 23rpx; text-align: right; }
.picker-value { display: flex; align-items: center; justify-content: flex-end; }
.picker-value--placeholder, .placeholder { color: #ab9a86; }
.field-error, .save-error, .form-note { display: block; color: $ink-muted; font-size: 20rpx; line-height: 1.5; }
.field-error, .save-error { color: #b42318; }
.field-error { margin-top: 8rpx; text-align: right; }
.form-note { margin: 16rpx 4rpx 0; }
.save-error { margin: 12rpx 4rpx 0; }
.page-content > .app-button { margin-top: 24rpx; }
@media (max-width: 340px) { .page-content { padding-right: 22rpx; padding-left: 22rpx; } .form-panel { padding-right: 24rpx; padding-left: 24rpx; } .avatar-row, .form-row { grid-template-columns: 116rpx minmax(0, 1fr); gap: 12rpx; } }
</style>
+37 -12
View File
@@ -4,9 +4,9 @@
<ModulePageBackground module="profile" />
<view class="page-layer"><PageHeader title="帮助中心" /></view>
<view class="page-content page-layer">
<text class="help-source-note">当前展示的是本地使用说明不代表已读取服务端帮助文章需要最新支持请提交意见反馈</text>
<text class="help-source-note">{{ sourceNote }}</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>
<scroll-view scroll-x class="category-scroll" :show-scrollbar="false"><view class="category-row"><view v-for="category in helpCategories" :key="category.value" class="category-chip" :class="{ active: activeCategory === category.value }" role="button" @click="activeCategory = category.value">{{ category.label }}</view></view></scroll-view>
<view v-if="filteredQuestions.length" class="question-list">
<view v-for="item in filteredQuestions" :key="item.id" class="question-card">
<view class="question-heading" role="button" :aria-label="item.question" @click="toggleQuestion(item.id)"><text>{{ item.question }}</text><text>{{ expandedIds.includes(item.id) ? "收起" : "查看" }}</text></view>
@@ -21,28 +21,53 @@
<script setup>
import { computed, ref } from "vue";
import { onLoad, onShow, onUnload } from "@dcloudio/uni-app";
import AppButton from "@/components/AppButton.vue";
import ModulePageBackground from "@/components/ModulePageBackground.vue";
import PageHeader from "@/components/PageHeader.vue";
import { appApi, createRequestController, isRequestCancelled } from "@/utils/api.js";
import { openPage } from "@/utils/navigation.js";
const helpCategories = ["全部", "家谱", "成员", "隐私", "账号"];
const activeCategory = ref("全部");
const questions = ref([]);
const helpState = ref("loading");
const controller = createRequestController();
let active = true;
const categoryLabels = Object.freeze({ common: "常见问题", member: "成员管理", lineage: "世系关系" });
const helpCategories = computed(() => [
{ value: "", label: "全部" },
...[...new Set(questions.value.map((item) => item.category).filter(Boolean))].map((value) => ({ value, label: categoryLabels[value.toLowerCase()] || value })),
]);
const activeCategory = ref("");
const keyword = ref("");
const expandedIds = ref([]);
const questions = [
{ id: 1, category: "家谱", question: "如何创建一部新家谱?", answer: "进入“我的家谱”,选择新建家谱,按步骤填写姓氏、堂号和地区等基础资料。" },
{ id: 2, category: "成员", question: "如何邀请家人共同完善家谱?", answer: "家人可搜索家谱后提交加入申请,由管理员核实关系;邀请码尚未接入,未来校验成功后将直接加入,不产生审核记录。" },
{ id: 3, category: "隐私", question: "哪些个人资料会展示给其他成员?", answer: "资料按家谱角色与权限展示;敏感联系方式默认脱敏,后续可在家谱设置中管理权限。" },
{ id: 4, category: "账号", question: "忘记密码后怎样恢复账号?", answer: "在登录页选择“忘记密码”,通过绑定手机号验证后设置新密码。" },
{ id: 5, category: "家谱", question: "家谱资料填写错了怎么办?", answer: "有编辑权限的成员可以进入对应资料页修改;关键世系关系建议核对后再保存。" },
];
const sourceNote = computed(() => ({ loading: "正在读取帮助文章…", error: "帮助文章暂时无法读取,请稍后重试。", empty: "暂未收到可展示的帮助文章。", ready: "帮助内容来自服务端,可按分类或关键词查找。" })[helpState.value]);
const filteredQuestions = computed(() => {
const query = keyword.value.toLowerCase();
return questions.filter((item) => (activeCategory.value === "全部" || item.category === activeCategory.value) && (!query || `${item.question}${item.answer}`.toLowerCase().includes(query)));
return questions.value.filter((item) => (!activeCategory.value || item.category === activeCategory.value) && (!query || `${item.question}${item.answer}`.toLowerCase().includes(query)));
});
const toggleQuestion = (id) => { expandedIds.value = expandedIds.value.includes(id) ? expandedIds.value.filter((item) => item !== id) : [...expandedIds.value, id]; };
const contactSupport = () => openPage("M07", {}, "M06");
const loadHelpArticles = async () => {
controller.abort();
helpState.value = "loading";
try {
const rows = await appApi.getHelpArticles({ requestController: controller });
if (!active) return;
questions.value = rows.map((item) => ({
id: String(item.helpId),
category: String(item.helpCategory || "其他"),
question: String(item.helpTitle || "未命名帮助文章"),
answer: String(item.helpContent || "暂无正文")
}));
helpState.value = questions.value.length ? "ready" : "empty";
} catch (error) {
if (!active || isRequestCancelled(error)) return;
helpState.value = "error";
}
};
onLoad(loadHelpArticles);
onShow(() => { if (helpState.value !== "loading") loadHelpArticles(); });
onUnload(() => { active = false; controller.abort(); });
</script>
<style scoped lang="scss">
+47 -22
View File
@@ -1,46 +1,71 @@
<!-- 页面编号M-08用途邀请家人共建家谱 -->
<template>
<view class="promotion-page share-state--unavailable">
<view class="promotion-page">
<ModulePageBackground module="profile" />
<view class="page-layer"><PageHeader title="邀请家人" custom-back @back="requestBack" /></view>
<view class="page-layer"><PageHeader title="推广中心" custom-back @back="requestBack" /></view>
<view class="page-content page-layer">
<view class="invite-hero">
<image src="/static/assets/foundation/transparent/brand-seal.png" mode="aspectFit" />
<text>邀请亲友共建家族记忆</text>
<text>邀请码校验成功后直接加入不会生成入谱审核记录</text>
<text>家谱服务推荐</text>
<text>以下内容由服务端配置不生成邀请码不会伪造推广信息</text>
</view>
<view v-if="inviteState === 'unavailable'" class="unavailable-card">
<text>当前没有可用邀请码</text>
<text>当前尚未接入邀请码校验与签发接口页面不会生成复制或展示虚假邀请码</text>
<view v-if="state === 'loading'" class="state-card"><AppLoading text="正在读取推广内容" /></view>
<view v-else-if="state === 'error'" class="state-card">
<text>暂时无法读取推广内容</text><AppButton block type="secondary" label="重新加载" @click="loadPromotions" />
</view>
<view v-else-if="state === 'empty'" class="state-card"><text>当前没有可展示的推广内容</text></view>
<view v-else class="promotion-list">
<view v-for="item in promotions" :key="item.id" class="promotion-card">
<text class="promotion-title">{{ item.title }}</text>
<text v-if="item.platform" class="promotion-platform">{{ item.platform }}</text>
<text v-if="item.description" class="promotion-copy">{{ item.description }}</text>
</view>
</view>
<view class="steps-card"><text>开放条件</text><text>1. 后端提供邀请码签发接口</text><text>2. 明确有效期使用次数和失效规则</text><text>3. 校验家谱权限与直接加入结果</text></view>
<AppButton block type="secondary" label="查看邀请说明" @click="openInviteExplanation" />
</view>
<AppDialog :visible="explanationVisible" :close-on-mask="false" eyebrow="邀请说明" title="功能尚未接入" message="当前后端接口文档没有邀请码签发与校验合同。完成接口接入前,本页保持不可用,避免家人拿到无法验证的邀请码。" confirm-text="我知道了" @confirm="explanationVisible = false" @close="explanationVisible = false" />
</view>
</template>
<script setup>
import { ref } from "vue";
import { onBackPress } from "@dcloudio/uni-app";
import { onBackPress, onLoad, onShow, onUnload } from "@dcloudio/uni-app";
import AppButton from "@/components/AppButton.vue";
import AppDialog from "@/components/AppDialog.vue";
import AppLoading from "@/components/AppLoading.vue";
import ModulePageBackground from "@/components/ModulePageBackground.vue";
import PageHeader from "@/components/PageHeader.vue";
import { appApi, createRequestController, isRequestCancelled } from "@/utils/api.js";
import { handleBackPress, runBackGuard } from "@/utils/navigation.js";
const inviteState = ref("unavailable");
const explanationVisible = ref(false);
const openInviteExplanation = () => { explanationVisible.value = true; };
const requestBack = () =>
runBackGuard({
transientOpen: explanationVisible.value,
"close-transient": () => { explanationVisible.value = false; },
});
const promotions = ref([]);
const state = ref("loading");
const controller = createRequestController();
let active = true;
const platformLabels = Object.freeze({ app: "APP 应用", pc: "管理后台", wechat: "微信小程序" });
const formatPlatform = (value) => platformLabels[String(value || "").trim().toLowerCase()] || String(value || "").trim();
const loadPromotions = async () => {
controller.abort();
state.value = "loading";
try {
const rows = await appApi.getPromotions({ requestController: controller });
if (!active) return;
promotions.value = rows.map((item) => ({
id: String(item.promotionId),
title: String(item.promotionTitle || "未命名推广"),
description: String(item.promotionDesc || ""),
platform: formatPlatform(item.platform)
}));
state.value = promotions.value.length ? "ready" : "empty";
} catch (error) {
if (!active || isRequestCancelled(error)) return;
state.value = "error";
}
};
const requestBack = () => runBackGuard({});
onLoad(loadPromotions);
onShow(() => { if (state.value !== "loading") loadPromotions(); });
onUnload(() => { active = false; controller.abort(); });
onBackPress((event) => handleBackPress(event, requestBack));
</script>
<style scoped lang="scss">
@use "../../styles/adaptive-frame-profiles.scss" as adaptive;
.promotion-page{display:flex;min-height:100vh;flex-direction:column;background:$paper}.page-layer{z-index:1}.page-content{flex:1;padding:28rpx 30rpx 72rpx}.invite-hero,.unavailable-card,.steps-card{@include adaptive.adaptive-profile-content}.invite-hero{display:flex;min-height:300rpx;flex-direction:column;align-items:center;justify-content:center;padding:38rpx 48rpx;text-align:center}.invite-hero image{width:90rpx;height:auto;max-height:102rpx;aspect-ratio:90/102}.invite-hero text{display:block}.invite-hero text:nth-child(2){margin-top:12rpx;color:$ink;font-family:STKaiti,KaiTi,serif;font-size:34rpx;font-weight:700}.invite-hero text:last-child{margin-top:12rpx;color:$ink-muted;font-size:22rpx;line-height:1.55}.unavailable-card{min-height:190rpx;margin-top:20rpx;padding:40rpx 44rpx;text-align:center}.unavailable-card text{display:block}.unavailable-card text:first-child{color:$brand-red;font-size:28rpx;font-weight:700}.unavailable-card text:last-child{margin-top:12rpx;color:$ink-muted;font-size:21rpx;line-height:1.55}.steps-card{display:flex;min-height:220rpx;flex-direction:column;gap:10rpx;margin-top:20rpx;padding:34rpx 44rpx;color:$ink-muted;font-size:22rpx}.steps-card text:first-child{margin-bottom:4rpx;color:$ink;font-size:27rpx;font-weight:700}.page-content>.app-button{margin-top:26rpx}@media(max-width:340px){.page-content{padding-right:22rpx;padding-left:22rpx}}
.promotion-page{display:flex;min-height:100vh;flex-direction:column;background:$paper}.page-layer{z-index:1}.page-content{flex:1;padding:28rpx 30rpx 72rpx}.invite-hero,.state-card,.promotion-card{@include adaptive.adaptive-profile-content}.invite-hero{display:flex;min-height:250rpx;flex-direction:column;align-items:center;justify-content:center;padding:38rpx 48rpx;text-align:center}.invite-hero image{width:90rpx;height:102rpx}.invite-hero text{display:block}.invite-hero text:nth-child(2){margin-top:12rpx;color:$ink;font-family:STKaiti,KaiTi,serif;font-size:34rpx;font-weight:700}.invite-hero text:last-child{margin-top:12rpx;color:$ink-muted;font-size:22rpx;line-height:1.55}.state-card{display:flex;min-height:210rpx;flex-direction:column;align-items:center;justify-content:center;margin-top:20rpx;padding:38rpx;text-align:center;color:$ink;font-size:26rpx}.state-card .app-button{width:100%;margin-top:22rpx}.promotion-list{display:flex;flex-direction:column;gap:16rpx;margin-top:20rpx}.promotion-card{position:relative;display:flex;min-height:142rpx;flex-direction:column;padding:30rpx 34rpx}.promotion-title{color:$ink;font-size:28rpx;font-weight:700;overflow-wrap:anywhere}.promotion-platform{align-self:flex-start;margin-top:10rpx;padding:3rpx 12rpx;border:1rpx solid rgba(181,46,34,.26);border-radius:999rpx;color:$brand-red;font-size:20rpx;line-height:1.3}.promotion-copy{margin-top:12rpx;color:$ink-muted;font-size:22rpx;line-height:1.55;overflow-wrap:anywhere}@media(max-width:340px){.page-content{padding-right:22rpx;padding-left:22rpx}}
</style>
+52 -24
View File
@@ -1,45 +1,73 @@
<!-- 页面编号M-09用途服务权益与订单记录 -->
<template>
<view class="orders-page order-state--unavailable">
<view class="orders-page">
<ModulePageBackground module="profile" />
<view class="page-layer"><PageHeader title="VIP 与订单" custom-back @back="requestBack" /></view>
<view class="page-content page-layer">
<view class="service-card"><text>家谱基础服务</text><text>当前未开通付费服务</text><text>基础家谱浏览成员资料与家族记录可正常使用</text></view>
<view class="benefit-grid"><view v-for="benefit in serviceBenefits" :key="benefit.title" class="benefit-card"><text>{{ benefit.title }}</text><text>{{ benefit.copy }}</text></view></view>
<view class="section-heading"><text>订单记录</text><text>暂不可用</text></view>
<view v-if="orderState === 'unavailable'" class="empty-card"><text>订单接口尚未接入</text><text>当前页面不读取查询参数也不会虚构订单完成套餐支付和订单状态合同核对后再开放</text></view>
<AppButton block type="secondary" label="查看服务说明" @click="openServiceNotice" />
<view class="service-card"><text>服务套餐</text><text>套餐和订单均从服务端读取本页不发起支付或创建订单</text></view>
<view v-if="state === 'loading'" class="state-card"><AppLoading text="正在读取服务套餐" /></view>
<view v-else-if="state === 'error'" class="state-card"><text>暂时无法读取套餐或订单</text><AppButton block type="secondary" label="重新加载" @click="loadVip" /></view>
<template v-else>
<view v-if="packages.length" class="package-list"><view v-for="item in packages" :key="item.id" class="package-card"><view class="package-heading"><text>{{ item.name }}</text><text v-if="item.price">¥{{ item.price }}</text></view><text v-if="item.description">{{ item.description }}</text><text v-if="item.duration">{{ item.duration }}</text></view></view>
<view v-else class="state-card"><text>当前没有可展示的服务套餐</text></view>
<view class="section-heading"><text>订单记录</text><text>{{ orders.length }} </text></view>
<view v-if="orders.length" class="order-list"><view v-for="item in orders" :key="item.id" class="order-card"><text>{{ item.title }}</text><text>{{ item.status }}</text></view></view>
<view v-else class="order-empty"><text>暂无订单记录</text></view>
</template>
</view>
<AppDialog :visible="serviceNoticeVisible" :close-on-mask="false" eyebrow="服务说明" title="付费服务尚未开放" message="当前版本不会产生扣费或订单。未来开放前会明确展示价格、权益、续费与退款规则。" confirm-text="我知道了" @confirm="serviceNoticeVisible = false" @close="serviceNoticeVisible = false" />
</view>
</template>
<script setup>
import { ref } from "vue";
import { onBackPress } from "@dcloudio/uni-app";
import { onBackPress, onLoad, onShow, onUnload } from "@dcloudio/uni-app";
import AppButton from "@/components/AppButton.vue";
import AppDialog from "@/components/AppDialog.vue";
import AppLoading from "@/components/AppLoading.vue";
import ModulePageBackground from "@/components/ModulePageBackground.vue";
import PageHeader from "@/components/PageHeader.vue";
import { appApi, createRequestController, isRequestCancelled } from "@/utils/api.js";
import { handleBackPress, runBackGuard } from "@/utils/navigation.js";
const serviceBenefits = [
{ title: "更大存储", copy: "为家族影像和资料提供更多空间" },
{ title: "资料导出", copy: "按规则整理并导出家谱资料" },
{ title: "专属服务", copy: "获得家谱整理与使用支持" },
];
const orderState = ref("unavailable");
const serviceNoticeVisible = ref(false);
const openServiceNotice = () => { serviceNoticeVisible.value = true; };
const requestBack = () =>
runBackGuard({
transientOpen: serviceNoticeVisible.value,
"close-transient": () => { serviceNoticeVisible.value = false; },
});
const packages = ref([]);
const orders = ref([]);
const state = ref("loading");
const controller = createRequestController();
let active = true;
const formatPrice = (value) => Number.isFinite(Number(value)) ? Number(value).toFixed(2) : "";
const durationUnits = Object.freeze({ year: "年", month: "个月", day: "天" });
const formatDuration = (value, unit) => {
if (value === undefined || value === null || value === "") return "";
if (Number.isFinite(Number(value)) && Number(value) <= 0) return "永久有效";
const normalizedUnit = String(unit || "").trim().toLowerCase();
return durationUnits[normalizedUnit] ? `${value} ${durationUnits[normalizedUnit]}` : String(value);
};
const loadVip = async () => {
controller.abort();
state.value = "loading";
try {
const packageRows = await appApi.getVipPackages({ requestController: controller });
const orderRows = await appApi.getVipOrders({ requestController: controller });
if (!active) return;
packages.value = packageRows.map((item) => ({
id: String(item.packageId), name: String(item.packageName || "未命名套餐"), description: String(item.packageDesc || ""),
price: formatPrice(item.price), duration: formatDuration(item.durationValue, item.durationUnit)
}));
orders.value = orderRows.map((item) => ({
id: String(item.orderId || item.id || ""), title: String(item.packageName || item.orderNo || "VIP 订单"), status: String(item.orderStatus || item.status || "" )
})).filter((item) => item.id);
state.value = "ready";
} catch (error) {
if (!active || isRequestCancelled(error)) return;
state.value = "error";
}
};
const requestBack = () => runBackGuard({});
onLoad(loadVip);
onShow(() => { if (state.value !== "loading") loadVip(); });
onUnload(() => { active = false; controller.abort(); });
onBackPress((event) => handleBackPress(event, requestBack));
</script>
<style scoped lang="scss">
@use "../../styles/adaptive-frame-profiles.scss" as adaptive;
.orders-page{display:flex;min-height:100vh;flex-direction:column;background:$paper}.page-layer{z-index:1}.page-content{flex:1;padding:28rpx 30rpx 72rpx}.service-card,.benefit-card,.empty-card{@include adaptive.adaptive-profile-content}.service-card{min-height:220rpx;padding:42rpx 48rpx;text-align:center}.service-card text{display:block}.service-card text:first-child{color:$ink;font-family:STKaiti,KaiTi,serif;font-size:34rpx;font-weight:700}.service-card text:nth-child(2){margin-top:10rpx;color:$brand-red;font-size:23rpx;font-weight:700}.service-card text:last-child{margin-top:12rpx;color:$ink-muted;font-size:21rpx;line-height:1.55}.benefit-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:12rpx;margin-top:18rpx}.benefit-card{min-height:150rpx;padding:28rpx 18rpx;text-align:center}.benefit-card text{display:block;overflow-wrap:anywhere}.benefit-card text:first-child{color:$ink;font-size:23rpx;font-weight:700}.benefit-card text:last-child{margin-top:8rpx;color:$ink-muted;font-size:19rpx;line-height:1.45}.section-heading{display:flex;flex-wrap:wrap;justify-content:space-between;gap:8rpx 20rpx;margin:28rpx 6rpx 14rpx}.section-heading text:first-child{color:$ink;font-size:27rpx;font-weight:700}.section-heading text:last-child{color:$ink-muted;font-size:21rpx}.empty-card{min-height:190rpx;padding:48rpx 42rpx;text-align:center}.empty-card text{display:block}.empty-card text:first-child{color:$ink;font-size:28rpx;font-weight:700}.empty-card text:last-child{margin-top:12rpx;color:$ink-muted;font-size:21rpx;line-height:1.55}.page-content>.app-button{margin-top:26rpx}@media(max-width:340px){.page-content{padding-right:22rpx;padding-left:22rpx}.benefit-grid{grid-template-columns:1fr}.benefit-card{min-height:100rpx}}
.orders-page{display:flex;min-height:100vh;flex-direction:column;background:$paper}.page-layer{z-index:1}.page-content{flex:1;padding:28rpx 30rpx 72rpx}.service-card,.state-card,.package-card,.order-card,.order-empty{@include adaptive.adaptive-profile-content}.service-card{min-height:170rpx;padding:38rpx 44rpx;text-align:center}.service-card text{display:block}.service-card text:first-child{color:$ink;font-family:STKaiti,KaiTi,serif;font-size:34rpx;font-weight:700}.service-card text:last-child{margin-top:12rpx;color:$ink-muted;font-size:21rpx;line-height:1.55}.state-card{display:flex;min-height:190rpx;flex-direction:column;align-items:center;justify-content:center;margin-top:18rpx;padding:36rpx;text-align:center;color:$ink;font-size:26rpx}.state-card .app-button{width:100%;margin-top:22rpx}.package-list,.order-list{display:flex;flex-direction:column;gap:14rpx;margin-top:18rpx}.package-card{padding:28rpx 32rpx}.package-heading{display:flex;align-items:center;justify-content:space-between;gap:16rpx;color:$ink;font-size:27rpx;font-weight:700}.package-heading text:last-child{flex:0 0 auto;color:$brand-red}.package-card>text{display:block;margin-top:10rpx;color:$ink-muted;font-size:21rpx;line-height:1.5}.section-heading{display:flex;justify-content:space-between;margin:28rpx 6rpx 12rpx;color:$ink;font-size:27rpx;font-weight:700}.section-heading text:last-child{color:$ink-muted;font-size:21rpx;font-weight:400}.order-card{display:flex;justify-content:space-between;gap:18rpx;padding:26rpx 32rpx;color:$ink;font-size:24rpx}.order-card text:last-child{color:$ink-muted}.order-empty{padding:38rpx;text-align:center;color:$ink-muted;font-size:23rpx}@media(max-width:340px){.page-content{padding-right:22rpx;padding-left:22rpx}}
</style>
+51 -28
View File
@@ -1,13 +1,18 @@
<!-- 页面编号R-03用途亲友往来入口列表 DTO 缺失时不展示 fixture 记录 -->
<!-- 页面编号R-03用途读取并展示当前家谱的亲友往来记录 -->
<template>
<view class="gift-page">
<ModulePageBackground module="records" />
<view class="page-header"><PageHeader title="贺礼簿" :action="hasValidContext ? '新建' : ''" @action="createRelative" /></view>
<view class="page-header"><PageHeader title="贺礼簿" :action="valid ? '新建' : ''" custom-back @back="returnToFamily" @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 v-if="!valid" class="state-card"><text>贺礼簿入口无效</text><AppButton block label="返回上一页" @click="returnToFamily" /></view>
<view v-else-if="state === 'loading'" class="state-card"><AppLoading text="正在读取亲友往来" /></view>
<view v-else-if="state === 'error'" class="state-card"><text>暂时无法读取亲友往来</text><AppButton block type="secondary" label="重新加载" @click="loadRecords" /></view>
<view v-else-if="state === 'empty'" class="state-card"><text>还没有亲友往来记录</text><AppButton block label="新建往来记录" @click="createRelative" /></view>
<view v-else class="record-list">
<view v-for="item in records" :key="item.id" class="record-card">
<view><text>{{ item.name }}</text><text>{{ item.relation }}{{ item.event ? ` · ${item.event}` : '' }}</text><text v-if="item.time || item.content">{{ item.time || item.content }}</text></view>
<text v-if="item.amount">¥{{ item.amount }}</text>
</view>
</view>
</view>
</view>
@@ -15,36 +20,54 @@
<script setup>
import { computed, ref } from "vue";
import { onLoad } from "@dcloudio/uni-app";
import { onLoad, onShow, 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 { goBack, openPage } from "@/utils/navigation.js";
import { appApi, createRequestController, isRequestCancelled } from "@/utils/api.js";
import { goBack, openPage, returnTo } from "@/utils/navigation.js";
const genealogyId = ref("");
const hasValidContext = ref(false);
const stateCopy = computed(() => hasValidContext.value
? { title: "贺礼簿待后端字段合同", copy: "往来列表没有声明记录 ID、关系、事项、时间、金额或备注字段;页面已停止展示本地记录。", action: "新建往来记录" }
: { title: "贺礼簿入口无效", copy: "没有取得有效家谱标识,页面不会展示其他家谱记录。", action: "返回上一页" },
);
onLoad((query) => {
genealogyId.value = String(query?.genealogyId || "");
hasValidContext.value = /^[1-9]\d*$/.test(genealogyId.value);
});
const createRelative = () => hasValidContext.value
? openPage("R04", { genealogyId: genealogyId.value, mode: "create" }, "R03")
: Promise.resolve(false);
const handleStateAction = () => hasValidContext.value ? createRelative() : goBack();
const records = ref([]);
const state = ref("loading");
const controller = createRequestController();
let active = true;
const valid = computed(() => /^[1-9]\d*$/.test(genealogyId.value));
const loadRecords = async () => {
if (!valid.value) return;
controller.abort();
state.value = "loading";
try {
const rows = await appApi.getRelativeRecords(genealogyId.value, { requestController: controller });
if (!active) return;
records.value = rows.map((item) => ({
id: String(item.relativeId || ""),
name: String(item.relativeName || "未命名亲友"),
relation: String(item.relationName || ""),
event: String(item.eventName || ""),
time: String(item.eventTime || ""),
amount: item.giftAmount == null || item.giftAmount === "" ? "" : String(item.giftAmount),
content: String(item.recordContent || ""),
})).filter((item) => /^[1-9]\d*$/.test(item.id));
state.value = records.value.length ? "ready" : "empty";
} catch (error) {
if (!active || isRequestCancelled(error)) return;
state.value = "error";
}
};
const returnToFamily = () => valid.value ? returnTo("F01", { genealogyId: genealogyId.value }) : goBack();
const createRelative = () => valid.value ? openPage("R04", { genealogyId: genealogyId.value, mode: "create" }, "R03") : Promise.resolve(false);
onLoad((query) => { genealogyId.value = String(query?.genealogyId || ""); if (valid.value) loadRecords(); });
onShow(() => { if (valid.value && state.value !== "loading") loadRecords(); });
onUnload(() => { active = false; controller.abort(); });
</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-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; }
.page-header, .page-content { z-index: 1; }.page-content { padding: 18rpx 24rpx 72rpx; }
.state-card, .record-card { @include adaptive.adaptive-records-content; }
.state-card { display: flex; min-height: 320rpx; flex-direction: column; align-items: center; justify-content: center; padding: 42rpx; text-align: center; color: $ink; font-size: 28rpx; }.state-card .app-button { width: 100%; margin-top: 24rpx; }
.record-list { display: flex; flex-direction: column; gap: 16rpx; }.record-card { display: flex; min-height: 138rpx; align-items: center; justify-content: space-between; gap: 18rpx; padding: 28rpx 32rpx; }.record-card > view { min-width: 0; flex: 1; }.record-card text { display: block; }.record-card > view text:first-child { color: $ink; font-family: STKaiti, KaiTi, serif; font-size: 30rpx; font-weight: 700; overflow-wrap: anywhere; }.record-card > view text:not(:first-child) { margin-top: 7rpx; color: $ink-muted; font-size: 21rpx; line-height: 1.45; overflow-wrap: anywhere; }.record-card > text { flex: 0 0 auto; color: $brand-red; font-size: 22rpx; }
</style>
+53 -68
View File
@@ -1,4 +1,4 @@
<!-- 页面编号R-04用途 Apifox 已声明字段创建亲友往来记录 -->
<!-- 页面编号R-04用途 Apifox 的完整 AppRelativeRecordBody 创建亲友往来记录 -->
<template>
<view class="gift-editor-page" :class="`relative-editor-state--${editorState}`">
<ModulePageBackground module="records" />
@@ -6,20 +6,24 @@
<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>
<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 = ''" />
<text class="form-copy">除亲友姓名外其他字段均可按需填写留空时不提交该字段</text>
<view class="field-row"><text><text class="required-mark">*</text>亲友姓名</text><input v-model="form.relativeName" placeholder="请输入亲友姓名" @input="submitError = ''" /></view>
<view class="field-row"><text>关系称谓</text><input v-model="form.relationName" placeholder="请输入关系称谓" @input="submitError = ''" /></view>
<view class="field-row"><text>礼仪事项</text><input v-model="form.eventName" placeholder="请输入礼仪事项" @input="submitError = ''" /></view>
<view class="field-row"><text>事项时间</text><input v-model="form.eventTime" placeholder="请输入事项时间" @input="submitError = ''" /></view>
<view class="field-row"><text>礼金金额</text><input v-model="form.giftAmount" type="digit" placeholder="请输入礼金金额" @input="submitError = ''" /></view>
<view class="field-row field-row--textarea"><text>往来备注</text><textarea v-model="form.recordContent" auto-height placeholder="记录往来内容" @input="submitError = ''" /></view>
<view class="upload-field">
<view><text>相关图片</text><text>图片选定后会先取得真实上传回执并在提交记录时关联</text></view>
<button class="upload-button" :disabled="isUploading || isSubmitting" @click="uploadImage">{{ isUploading ? '上传中' : '添加图片' }}</button>
<text v-for="(receipt, index) in mediaReceipts" :key="`${receipt.ossId}-${index}`">已上传{{ receipt.fileName || '图片' }}</text>
<text v-if="uploadError" class="save-error">{{ uploadError }}</text>
</view>
<view class="field-row"><text>排序值</text><input v-model="form.sortOrder" type="number" placeholder="数值越小越靠前" @input="submitError = ''" /></view>
<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>{{ resultCopy.title }}</text>
<text>{{ resultCopy.copy }}</text>
<AppButton block :label="resultCopy.action" @click="handleResultAction" />
<AppButton block :disabled="isSubmitting || isUploading" :label="isSubmitting ? '正在提交' : '提交往来记录'" @click="saveRelative" />
</view>
<view v-else class="state-card"><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" />
</view>
@@ -34,61 +38,56 @@ 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 { isImagePickCancelled, pickAndUploadImage } from "@/utils/resumable-image-upload.js";
import { goBack, handleBackPress, returnTo, runBackGuard } from "@/utils/navigation.js";
const genealogyId = ref("");
const editorState = ref("form");
const isSubmitting = ref(false);
const isUploading = ref(false);
const discardVisible = ref(false);
const submitError = ref("");
const form = reactive({ relativeName: "", relationName: "", eventName: "", eventTime: "", giftAmount: "", recordContent: "" });
const fields = [
{ key: "relativeName", label: "亲友姓名" },
{ key: "relationName", label: "关系称谓" },
{ key: "eventName", label: "礼仪事项" },
{ key: "eventTime", label: "事项日期" },
{ key: "giftAmount", label: "礼金金额" },
{ key: "recordContent", label: "往来备注" },
];
const uploadError = ref("");
const form = reactive({ relativeName: "", relationName: "", eventName: "", eventTime: "", giftAmount: "", recordContent: "", sortOrder: "" });
const mediaReceipts = ref([]);
const requestController = createRequestController();
const isDirty = computed(() => Object.values(form).some((value) => String(value).trim()));
const mediaOssIds = computed(() => mediaReceipts.value.map((item) => item.ossId).join(","));
const isDirty = computed(() => Object.values(form).some((value) => String(value).trim()) || mediaReceipts.value.length > 0);
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: "返回上一页" },
? { title: "往来记录已提交服务端", copy: "返回贺礼簿后会重新读取服务端记录。", action: "返回贺礼簿" }
: { title: "往来记录入口无效", copy: "没有取得有效家谱标识,页面不会创建无归属记录。", action: "返回上一页" },
);
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 || "");
if (!hasValidContext.value || query?.mode !== "create") editorState.value = "invalid";
});
const uploadImage = async () => {
if (isUploading.value || isSubmitting.value) return;
isUploading.value = true;
uploadError.value = "";
try {
mediaReceipts.value = [...mediaReceipts.value, await pickAndUploadImage({ requestController })];
} catch (error) {
if (!isImagePickCancelled(error) && !isRequestCancelled(error)) uploadError.value = error?.message || "图片上传失败,请稍后重试。";
} finally {
isUploading.value = false;
}
};
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;
}
if (isSubmitting.value || isUploading.value || !hasValidContext.value) return;
if (!form.relativeName.trim()) { 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] = ""; });
await appApi.createRelativeRecord(genealogyId.value, { ...form, mediaOssIds: mediaOssIds.value }, { requestController });
Object.assign(form, { relativeName: "", relationName: "", eventName: "", eventTime: "", giftAmount: "", recordContent: "", sortOrder: "" });
mediaReceipts.value = [];
editorState.value = "success";
} catch (error) {
if (!isRequestCancelled(error)) submitError.value = error?.message || "往来记录提交失败,请稍后重试。";
@@ -96,17 +95,8 @@ const saveRelative = async () => {
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();
const requestBack = () => runBackGuard({ transientOpen: discardVisible.value, dirty: isDirty.value, submitting: isSubmitting.value || isUploading.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>
@@ -114,18 +104,13 @@ onUnmounted(() => { requestController.abort(); discardConfirmation.dispose(); })
<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-content { z-index: 1; }
.page-content { padding: 18rpx 24rpx 72rpx; }
.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; }
.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: 142rpx 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, .upload-field > view > text:first-child { color: $ink; font-size: 23rpx; font-weight: 700; }.required-mark { display: inline-block; margin-right: 4rpx; color: $brand-red; font-size: 26rpx; transform: translateY(-3rpx); }
.field-row input, .field-row textarea { min-width: 0; color: $ink; font-size: 23rpx; text-align: right; }.field-row--textarea { align-items: start; }.field-row textarea { min-height: 72rpx; text-align: left; }
.upload-field { display: grid; gap: 12rpx; margin-top: 14rpx; padding: 18rpx 24rpx; @include adaptive.adaptive-records-field; }.upload-field > view > text { display: block; }.upload-field > view > text:last-child { margin-top: 6rpx; color: $ink-muted; font-size: 20rpx; line-height: 1.45; }
.upload-button { justify-self: start; min-height: 60rpx; margin: 0; padding: 0 20rpx; border: 1rpx solid rgba(159, 23, 15, .42); border-radius: 8rpx; background: transparent; color: $brand-red; font-size: 22rpx; }.upload-field > text { color: $ink-muted; font-size: 21rpx; overflow-wrap: anywhere; }
.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>
+25 -17
View File
@@ -1,31 +1,39 @@
<!-- 页面编号R-05用途礼仪活动入口活动列表 operation 未声明 -->
<template>
<view class="ritual-list-page">
<ModulePageBackground module="records" />
<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 class="page-header"><PageHeader title="礼仪活动" :action="valid ? '新建' : ''" custom-back @back="returnToFamily" @action="createCeremony" /></view>
<view class="page-content">
<view v-if="!valid" class="state-card"><text>礼仪活动入口无效</text><AppButton block label="返回上一页" @click="returnToFamily" /></view>
<view v-else-if="state === 'loading'" class="state-card"><AppLoading text="正在读取礼仪活动" /></view>
<view v-else-if="state === 'error'" class="state-card"><text>暂时无法读取礼仪活动</text><AppButton block type="secondary" label="重新加载" @click="loadCeremonies" /></view>
<view v-else-if="state === 'empty'" class="state-card"><text>还没有礼仪活动</text><AppButton block label="新建礼仪活动" @click="createCeremony" /></view>
<view v-else class="ceremony-list"><view v-for="item in ceremonies" :key="item.id" class="ceremony-card" @click="openCeremony(item)"><view><text>{{ item.title }}</text><text>{{ item.type }}{{ item.time ? ` · ${item.time}` : '' }}</text><text v-if="item.description">{{ item.description }}</text></view><text>{{ item.giftCount }} 笔献礼</text></view></view>
</view>
</view>
</template>
<script setup>
import { computed, ref } from "vue";
import { onLoad } from "@dcloudio/uni-app";
import { onLoad, onShow, 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 { 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();
import { appApi, createRequestController, isRequestCancelled } from "@/utils/api.js";
import { goBack, openPage, returnTo } from "@/utils/navigation.js";
const genealogyId = ref(""); const ceremonies = ref([]); const state = ref("loading"); const controller = createRequestController(); let active = true;
const valid = computed(() => /^[1-9]\d*$/.test(genealogyId.value));
const loadCeremonies = async () => { if (!valid.value) return; controller.abort(); state.value = "loading"; try { const rows = await appApi.getCeremonies(genealogyId.value, { requestController: controller }); if (!active) return; ceremonies.value = rows.map((item) => ({ id: String(item.ceremonyId), title: String(item.ceremonyTitle || "未命名活动"), type: String(item.ceremonyType || ""), time: String(item.ceremonyTime || ""), description: String(item.ceremonyDesc || ""), giftCount: Number.isSafeInteger(item.giftCount) ? item.giftCount : 0 })).filter((item) => /^[1-9]\d*$/.test(item.id)); state.value = ceremonies.value.length ? "ready" : "empty"; } catch (error) { if (!active || isRequestCancelled(error)) return; state.value = "error"; } };
const returnToFamily = () => valid.value ? returnTo("F01", { genealogyId: genealogyId.value }) : goBack();
const createCeremony = () => valid.value ? openPage("R07", { genealogyId: genealogyId.value, mode: "create" }, "R05") : Promise.resolve(false);
const openCeremony = (item) => openPage("R06", { genealogyId: genealogyId.value, ceremonyId: item.id }, "R05");
onLoad((query) => { genealogyId.value = String(query?.genealogyId || ""); if (valid.value) loadCeremonies(); });
onShow(() => { if (valid.value && state.value !== "loading") loadCeremonies(); });
onUnload(() => { active = false; controller.abort(); });
</script>
<style scoped lang="scss">
@use "../../styles/adaptive-frame-profiles.scss" as adaptive;
.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; }
.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,.ceremony-card{@include adaptive.adaptive-records-content}.state-card{display:flex;min-height:320rpx;flex-direction:column;align-items:center;justify-content:center;padding:42rpx;text-align:center;color:$ink;font-size:28rpx}.state-card .app-button{width:100%;margin-top:24rpx}.ceremony-list{display:flex;flex-direction:column;gap:16rpx}.ceremony-card{display:flex;min-height:138rpx;align-items:center;justify-content:space-between;gap:18rpx;padding:28rpx 32rpx}.ceremony-card>view{min-width:0;flex:1}.ceremony-card text{display:block}.ceremony-card>view text:first-child{color:$ink;font-family:STKaiti,KaiTi,serif;font-size:30rpx;font-weight:700;overflow-wrap:anywhere}.ceremony-card>view text:not(:first-child){margin-top:7rpx;color:$ink-muted;font-size:21rpx;line-height:1.45;overflow-wrap:anywhere}.ceremony-card>text{flex:0 0 auto;color:$brand-red;font-size:21rpx}
</style>
+6 -15
View File
@@ -1,21 +1,12 @@
<!-- 页面编号R-06用途礼仪详情详情响应无展示 DTO 时保持关闭 -->
<template>
<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>
<view class="ritual-detail-page"><ModulePageBackground module="records" /><view class="page-header"><PageHeader title="礼仪详情" custom-back @back="returnToList" /></view><view class="page-content"><view v-if="!valid" class="state-card"><text>礼仪入口无效</text><AppButton block label="返回上一页" @click="returnToList" /></view><view v-else-if="state === 'loading'" class="state-card"><AppLoading text="正在读取礼仪详情" /></view><view v-else-if="state === 'error'" class="state-card"><text>暂时无法读取礼仪详情</text><AppButton block type="secondary" label="重新加载" @click="loadDetail" /></view><view v-else class="detail-card"><text>{{ detail.title }}</text><text>{{ detail.type }}{{ detail.time ? ` · ${detail.time}` : '' }}</text><text v-if="detail.location">地点:{{ detail.location }}</text><text v-if="detail.description">{{ detail.description }}</text><text>已有 {{ detail.giftCount }} 笔献礼</text></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 { 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();
import { computed, ref } from "vue"; import { onLoad, onUnload } from "@dcloudio/uni-app"; import AppButton from "@/components/AppButton.vue"; import AppLoading from "@/components/AppLoading.vue"; import ModulePageBackground from "@/components/ModulePageBackground.vue"; import PageHeader from "@/components/PageHeader.vue"; import { appApi, createRequestController, isRequestCancelled } from "@/utils/api.js"; import { goBack, returnTo } from "@/utils/navigation.js";
const genealogyId=ref(""); const ceremonyId=ref(""); const state=ref("loading"); const detail=ref({title:"",type:"",time:"",location:"",description:"",giftCount:0}); const controller=createRequestController(); let active=true; const valid=computed(()=>/^[1-9]\d*$/.test(genealogyId.value)&&/^[1-9]\d*$/.test(ceremonyId.value));
const loadDetail=async()=>{if(!valid.value)return;controller.abort();state.value="loading";try{const item=await appApi.getCeremonyDetail(genealogyId.value,ceremonyId.value,{requestController:controller});if(!active)return;detail.value={title:String(item.ceremonyTitle||"未命名活动"),type:String(item.ceremonyType||""),time:String(item.ceremonyTime||""),location:String(item.location||item.locationAddress||""),description:String(item.ceremonyDesc||""),giftCount:Number.isSafeInteger(item.giftCount)?item.giftCount:0};state.value="ready";}catch(error){if(!active||isRequestCancelled(error))return;state.value="error";}};
onLoad((query)=>{genealogyId.value=String(query?.genealogyId||"");ceremonyId.value=String(query?.ceremonyId||"");if(valid.value)loadDetail();else state.value="invalid";});onUnload(()=>{active=false;controller.abort();});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-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; }
@use "../../styles/adaptive-frame-profiles.scss" as adaptive;.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,.detail-card{@include adaptive.adaptive-records-content}.state-card{display:flex;min-height:320rpx;flex-direction:column;align-items:center;justify-content:center;padding:42rpx;text-align:center;color:$ink;font-size:28rpx}.state-card .app-button{width:100%;margin-top:24rpx}.detail-card{display:flex;min-height:320rpx;flex-direction:column;padding:42rpx}.detail-card text{display:block}.detail-card text:first-child{color:$ink;font-family:STKaiti,KaiTi,serif;font-size:36rpx;font-weight:700}.detail-card text:not(:first-child){margin-top:14rpx;color:$ink-muted;font-size:23rpx;line-height:1.6}
</style>
+131 -8
View File
@@ -1,19 +1,142 @@
<!-- 页面编号R-07用途礼仪活动创建/编辑创建 owner 缺失编辑无可靠详情来源 -->
<!-- 页面编号R-07用途创建礼仪活动 -->
<template>
<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>
<view class="ritual-editor-page" :class="`ritual-state--${pageState}`">
<ModulePageBackground module="records" />
<view class="page-header"><PageHeader title="新建礼仪活动" custom-back @back="requestBack" /></view>
<view class="page-content">
<view v-if="pageState === 'form'" class="editor-card">
<text class="editor-card__title">记录一场家族礼仪</text>
<text class="editor-card__note">带红色星号的内容不能为空其余内容可按需要补充</text>
<view class="field-row">
<text class="field-row__label"><text class="required-mark">*</text>活动类型</text>
<input v-model="form.ceremonyType" maxlength="30" placeholder="例如:祭祖、家宴" placeholder-class="placeholder" @input="clearError" />
</view>
<view class="field-row">
<text class="field-row__label"><text class="required-mark">*</text>活动标题</text>
<input v-model="form.ceremonyTitle" maxlength="60" placeholder="请输入活动标题" placeholder-class="placeholder" @input="clearError" />
</view>
<view class="textarea-field">
<text class="textarea-field__label">活动说明</text>
<textarea v-model="form.ceremonyDesc" maxlength="500" auto-height placeholder="补充活动安排或说明" placeholder-class="placeholder" @input="clearError" />
</view>
<view class="field-row">
<text class="field-row__label">活动时间</text>
<input v-model="form.ceremonyTime" maxlength="30" placeholder="例如:2026-07-24 10:00:00" placeholder-class="placeholder" @input="clearError" />
</view>
<view class="field-row">
<text class="field-row__label">地点</text>
<input v-model="form.location" maxlength="60" placeholder="请输入地点" placeholder-class="placeholder" @input="clearError" />
</view>
<view class="cover-field">
<view>
<text class="cover-field__label">封面图片</text>
<text class="cover-field__hint">选择图片后会取得真实上传回执并在保存时关联</text>
</view>
<button class="upload-button" :disabled="uploading || submitting" @click="uploadCover">{{ uploading ? '上传中' : '选择图片' }}</button>
<text v-if="coverFileName" class="upload-receipt">已上传{{ coverFileName }}</text>
</view>
<text v-if="uploadError" class="form-error">{{ uploadError }}</text>
<view class="field-row">
<text class="field-row__label">排序值</text>
<input v-model="form.sortOrder" type="number" placeholder="数值越小越靠前" placeholder-class="placeholder" @input="clearError" />
</view>
<text v-if="submitError" class="form-error">{{ submitError }}</text>
<AppButton block :disabled="uploading || submitting" :label="submitting ? '正在保存…' : '保存活动'" @click="submit" />
</view>
<view v-else class="state-card">
<text>{{ stateCopy.title }}</text>
<text>{{ stateCopy.copy }}</text>
<AppButton block :label="stateCopy.action" @click="handleStateAction" />
</view>
</view>
</view>
<AppDialog :visible="discardVisible" title="放弃活动草稿?" message="当前内容尚未保存到服务端,返回后不会保留。" confirm-text="放弃并返回" cancel-text="继续填写" show-cancel :close-on-mask="false" @confirm="confirmation.confirm" @cancel="confirmation.cancel" />
</template>
<script setup>
import { computed, ref } from "vue";
import { onLoad } 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 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 || ""); });
import { appApi, createRequestController, isRequestCancelled } from "@/utils/api.js";
import { isImagePickCancelled, pickAndUploadImage, toConsumerOssId } from "@/utils/resumable-image-upload.js";
import { createDiscardConfirmation } from "@/utils/discard-confirmation.js";
import { goBack, handleBackPress, returnTo, runBackGuard } from "@/utils/navigation.js";
const genealogyId = ref("");
const pageState = ref("form");
const form = reactive({ ceremonyType: "", ceremonyTitle: "", ceremonyDesc: "", ceremonyTime: "", location: "", sortOrder: "" });
const coverOssId = ref(null);
const coverFileName = ref("");
const uploadError = ref("");
const submitError = ref("");
const uploading = ref(false);
const submitting = ref(false);
const controller = createRequestController();
const discardVisible = ref(false);
const confirmation = createDiscardConfirmation((visible) => { discardVisible.value = visible; });
const hasValidContext = computed(() => /^[1-9]\d*$/.test(genealogyId.value));
const isDirty = computed(() => coverOssId.value || Object.values(form).some((value) => value.trim()));
const stateCopy = computed(() => pageState.value === "success"
? { title: "礼仪活动已提交服务端", copy: "服务端已返回成功结果。", action: "返回礼仪活动" }
: { title: "礼仪活动入口无效", copy: "没有取得有效家谱标识。", action: "返回上一页" });
onLoad((query) => {
genealogyId.value = String(query?.genealogyId || "");
if (!hasValidContext.value || query?.mode !== "create") pageState.value = "invalid";
});
const clearError = () => { submitError.value = ""; };
const uploadCover = async () => {
if (uploading.value || submitting.value) return;
uploading.value = true;
uploadError.value = "";
try {
const receipt = await pickAndUploadImage({ requestController: controller });
coverOssId.value = toConsumerOssId(receipt.ossId);
coverFileName.value = receipt.fileName || "活动封面";
} catch (error) {
if (!isImagePickCancelled(error) && !isRequestCancelled(error)) uploadError.value = error?.message || "封面图片上传失败,请稍后重试";
} finally {
uploading.value = false;
}
};
const submit = async () => {
if (uploading.value || submitting.value || !hasValidContext.value) return;
if (!form.ceremonyType.trim() || !form.ceremonyTitle.trim()) {
submitError.value = !form.ceremonyType.trim() ? "请填写活动类型" : "请填写活动标题";
return;
}
submitting.value = true;
submitError.value = "";
try {
await appApi.createCeremony(genealogyId.value, { ...form, ...(coverOssId.value ? { coverOssId: coverOssId.value } : {}) }, { requestController: controller });
pageState.value = "success";
} catch (error) {
if (!isRequestCancelled(error)) submitError.value = error?.message || "礼仪活动保存失败,请稍后重试";
} finally {
submitting.value = false;
}
};
const returnToList = () => hasValidContext.value ? returnTo("R05", { genealogyId: genealogyId.value }) : goBack();
const requestBack = () => runBackGuard({ transientOpen: discardVisible.value, dirty: isDirty.value, submitting: uploading.value || submitting.value, "close-transient": confirmation.cancel, "block-submitting": () => true, "confirm-discard": confirmation.request });
const handleStateAction = () => pageState.value === "success" ? returnToList() : goBack();
onBackPress((event) => handleBackPress(event, requestBack));
onUnmounted(() => { controller.abort(); confirmation.dispose(); });
</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; }.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; }
.ritual-editor-page { display:flex; min-height:100vh; flex-direction:column; background:$paper; }
.page-header,.page-content { z-index:1; }.page-content { flex:1; padding:22rpx 28rpx 72rpx; }
.editor-card,.state-card { @include adaptive.adaptive-records-content; box-sizing:border-box; }
.editor-card { padding:38rpx 32rpx 42rpx; }.editor-card__title,.editor-card__note,.field-row__label,.textarea-field__label,.form-error { display:block; }
.editor-card__title { color:$ink; font-family:STKaiti,KaiTi,serif; font-size:38rpx; font-weight:700; }.editor-card__note { margin-top:10rpx; color:$ink-muted; font-size:23rpx; line-height:1.55; }
.field-row { display:flex; min-height:92rpx; align-items:center; margin-top:20rpx; padding:0 18rpx; border:1rpx solid rgba(128,89,49,.34); border-radius:12rpx; background:rgba(255,252,245,.7); }.field-row__label { width:150rpx; flex:0 0 auto; color:$ink; font-size:26rpx; font-weight:700; }.field-row input { min-width:0; flex:1; color:$ink; font-size:25rpx; }.required-mark { display:inline-block; margin-right:4rpx; color:$brand-red; font-size:26rpx; transform:translateY(-3rpx); }.placeholder { color:#9e8e79; }
.textarea-field,.cover-field { margin-top:20rpx; padding:18rpx; border:1rpx solid rgba(128,89,49,.34); border-radius:12rpx; background:rgba(255,252,245,.7); }.textarea-field__label,.cover-field__label { color:$ink; font-size:26rpx; font-weight:700; }.textarea-field textarea { display:block; width:100%; min-height:122rpx; margin-top:12rpx; color:$ink; font-size:25rpx; line-height:1.55; }
.cover-field { display:grid; gap:12rpx; }.cover-field__hint { display:block; margin-top:5rpx; color:$ink-muted; font-size:20rpx; line-height:1.45; }.upload-button { justify-self:start; min-height:60rpx; margin:0; padding:0 20rpx; border:1rpx solid rgba(159,23,15,.42); border-radius:8rpx; background:transparent; color:$brand-red; font-size:22rpx; }.upload-receipt { color:$ink-muted; font-size:21rpx; overflow-wrap:anywhere; }.form-error { margin-top:10rpx; color:$brand-red; font-size:22rpx; line-height:1.45; }.editor-card .app-button { margin-top:28rpx; }
.state-card { min-height:340rpx; padding:78rpx 52rpx 50rpx; 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; }
</style>
+148 -14
View File
@@ -1,22 +1,156 @@
<!-- 页面编号R-08用途成长记录创建列表 DTO 未声明创建仅提交可映射字段 -->
<!-- 页面编号R-08用途读取新建当前家谱的成长记录 -->
<template>
<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>
<view class="record-page">
<ModulePageBackground module="records" />
<view class="page-header">
<PageHeader title="成长日志" :action="valid && view === 'list' ? '新建' : ''" custom-back @back="requestBack" @action="openCreate" />
</view>
<view class="page-content">
<view v-if="view === 'form'" class="form-card">
<text>新建成长记录</text>
<text class="form-copy">红色 * 为必填项其余内容可按需补充</text>
<view class="field field--context"><text>关联人物</text><view><text>{{ personName }}</text><text>{{ personId ? '已自动关联' : '未关联人物' }}</text></view></view>
<view class="field"><text>记录类型</text><input v-model="form.recordType" placeholder="请输入记录类型" @input="error = ''" /></view>
<view class="field"><text><text class="required-mark">*</text>记录标题</text><input v-model="form.recordTitle" placeholder="请输入记录标题" @input="error = ''" /></view>
<view class="field field--textarea"><text>记录内容</text><textarea v-model="form.recordContent" auto-height placeholder="记录成长片段" @input="error = ''" /></view>
<view class="field"><text>记录时间</text><input v-model="form.recordDate" placeholder="请输入记录时间" @input="error = ''" /></view>
<view class="field"><text>提醒时间</text><input v-model="form.remindTime" placeholder="请输入提醒时间" @input="error = ''" /></view>
<view class="upload-field">
<view><text>相关图片</text><text>图片选定后会先取得真实上传回执并在提交记录时关联</text></view>
<button class="upload-button" :disabled="uploading || submitting" @click="uploadImage">{{ uploading ? '上传中' : '添加图片' }}</button>
<text v-for="(receipt, index) in mediaReceipts" :key="`${receipt.ossId}-${index}`">已上传{{ receipt.fileName || '图片' }}</text>
<text v-if="uploadError" class="error">{{ uploadError }}</text>
</view>
<view class="field"><text>排序值</text><input v-model="form.sortOrder" type="number" placeholder="数值越小越靠前" @input="error = ''" /></view>
<text v-if="error" class="error">{{ error }}</text>
<view class="form-actions"><AppButton type="secondary" label="取消" @click="cancelCreate" /><AppButton :disabled="submitting || uploading" :label="submitting ? '正在提交' : '提交成长记录'" @click="submit" /></view>
</view>
<view v-else-if="!valid" class="state-card"><text>成长日志入口无效</text><AppButton block label="返回上一页" @click="requestBack" /></view>
<view v-else-if="listState === 'loading'" class="state-card"><AppLoading text="正在读取成长记录" /></view>
<view v-else-if="listState === 'error'" class="state-card"><text>暂时无法读取成长记录</text><AppButton block type="secondary" label="重新加载" @click="loadRecords" /></view>
<view v-else-if="listState === 'empty'" class="state-card"><text>还没有成长记录</text><AppButton block label="新建成长记录" @click="openCreate" /></view>
<view v-else class="record-list">
<text v-if="saveNotice" class="save-notice">成长记录已提交并已从服务端重新读取</text>
<view v-for="item in records" :key="item.id" class="record-card">
<view><text>{{ item.title }}</text><text>{{ item.type || item.personName }}{{ item.date ? ` · ${item.date}` : '' }}</text><text v-if="item.content">{{ item.content }}</text></view>
</view>
</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 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(); });
import { onBackPress, onLoad, onShow } 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 { appApi, createRequestController, isRequestCancelled } from "@/utils/api.js";
import { createDiscardConfirmation } from "@/utils/discard-confirmation.js";
import { isImagePickCancelled, pickAndUploadImage } from "@/utils/resumable-image-upload.js";
import { goBack, handleBackPress, runBackGuard } from "@/utils/navigation.js";
const genealogyId = ref("");
const personId = ref("");
const personName = ref("当前家谱");
const view = ref("list");
const listState = ref("loading");
const records = ref([]);
const saveNotice = ref(false);
const submitting = ref(false);
const uploading = ref(false);
const error = ref("");
const uploadError = ref("");
const discardVisible = ref(false);
const form = reactive({ lineagePersonId: "", recordType: "", recordTitle: "", recordContent: "", recordDate: "", remindTime: "", sortOrder: "" });
const mediaReceipts = ref([]);
const controller = createRequestController();
let active = true;
const valid = computed(() => /^[1-9]\d*$/.test(genealogyId.value));
const mediaOssIds = computed(() => mediaReceipts.value.map((item) => item.ossId).join(","));
const dirty = computed(() => Object.values(form).some((value) => String(value).trim()) || mediaReceipts.value.length > 0);
const confirmation = createDiscardConfirmation((visible) => { discardVisible.value = visible; });
const confirmDiscard = () => { confirmation.confirm(); resetForm(); view.value = "list"; };
const cancelDiscard = confirmation.cancel;
const resetForm = () => {
Object.assign(form, { lineagePersonId: personId.value, recordType: "", recordTitle: "", recordContent: "", recordDate: "", remindTime: "", sortOrder: "" });
mediaReceipts.value = [];
error.value = "";
uploadError.value = "";
};
const loadRecords = async () => {
if (!valid.value) return;
controller.abort();
listState.value = "loading";
try {
const rows = await appApi.getGrowthRecords(genealogyId.value, { requestController: controller });
if (!active) return;
records.value = rows.map((item) => ({
id: String(item.recordId || ""),
title: String(item.recordTitle || "未命名记录"),
type: String(item.recordType || ""),
personName: String(item.lineagePersonName || "未关联人物"),
date: String(item.recordDate || item.remindTime || ""),
content: String(item.recordContent || ""),
})).filter((item) => /^[1-9]\d*$/.test(item.id));
listState.value = records.value.length ? "ready" : "empty";
} catch (cause) {
if (!active || isRequestCancelled(cause)) return;
listState.value = "error";
}
};
const openCreate = () => { if (!valid.value) return; saveNotice.value = false; resetForm(); view.value = "form"; };
const cancelCreate = () => { resetForm(); view.value = "list"; };
const uploadImage = async () => {
if (uploading.value || submitting.value) return;
uploading.value = true;
uploadError.value = "";
try { mediaReceipts.value = [...mediaReceipts.value, await pickAndUploadImage({ requestController: controller })]; }
catch (cause) { if (!isImagePickCancelled(cause) && !isRequestCancelled(cause)) uploadError.value = cause?.message || "图片上传失败,请稍后重试。"; }
finally { uploading.value = false; }
};
const submit = async () => {
if (submitting.value || uploading.value || !valid.value) return;
if (!form.recordTitle.trim()) { error.value = "请填写记录标题"; return; }
submitting.value = true;
error.value = "";
try {
await appApi.createGrowthRecord(genealogyId.value, { ...form, mediaOssIds: mediaOssIds.value }, { requestController: controller });
resetForm();
view.value = "list";
saveNotice.value = true;
await loadRecords();
} catch (cause) {
if (!isRequestCancelled(cause)) error.value = cause?.message || "成长记录提交失败,请稍后重试。";
} finally { submitting.value = false; }
};
const requestBack = () => view.value !== "form"
? goBack()
: runBackGuard({ transientOpen: discardVisible.value, dirty: dirty.value, submitting: submitting.value || uploading.value, "close-transient": cancelDiscard, "block-submitting": () => true, "confirm-discard": confirmation.request });
onLoad((query) => {
genealogyId.value = String(query?.genealogyId || "");
personId.value = /^[1-9]\d*$/.test(String(query?.personId || "")) ? String(query.personId) : "";
personName.value = String(query?.personName || (personId.value ? "当前查看的人物" : "当前家谱"));
resetForm();
if (valid.value) loadRecords();
else listState.value = "invalid";
});
onShow(() => { if (valid.value && view.value === "list" && listState.value !== "loading") loadRecords(); });
onBackPress((event) => handleBackPress(event, requestBack));
onUnmounted(() => { active = false; controller.abort(); confirmation.dispose(); });
</script>
<style scoped lang="scss">
@use "../../styles/adaptive-frame-profiles.scss" as adaptive;
.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}
.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, .record-card { @include adaptive.adaptive-records-content; }.form-card { box-sizing: border-box; padding: 46rpx; }.form-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; }.required-mark { display: inline-block; margin-right: 4rpx; color: $brand-red; font-size: 26rpx; transform: translateY(-3rpx); }.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; }
.field--context > view { @include adaptive.adaptive-records-field; display: flex; min-height: 76rpx; align-items: center; justify-content: space-between; padding: 16rpx 22rpx; box-sizing: border-box; }.field--context > view > text:first-child { color: $ink; font-size: 23rpx; }.field--context > view > text:last-child { color: $ink-muted; font-size: 21rpx; }
.upload-field { display: grid; gap: 12rpx; margin-top: 16rpx; padding: 18rpx 22rpx; @include adaptive.adaptive-records-field; }.upload-field > view > text { display: block; }.upload-field > view > text:first-child { color: $ink; font-size: 23rpx; font-weight: 700; }.upload-field > view > text:last-child { margin-top: 6rpx; color: $ink-muted; font-size: 20rpx; line-height: 1.45; }.upload-button { justify-self: start; min-height: 60rpx; margin: 0; padding: 0 20rpx; border: 1rpx solid rgba(159, 23, 15, .42); border-radius: 8rpx; background: transparent; color: $brand-red; font-size: 22rpx; }.upload-field > text, .error { color: $ink-muted; font-size: 21rpx; overflow-wrap: anywhere; }.error { display: block; margin-top: 12rpx; color: $brand-red; font-size: 22rpx; }
.form-actions { display: grid; grid-template-columns: 1fr 1.45fr; gap: 16rpx; margin-top: 20rpx; }.state-card { display: flex; min-height: 320rpx; flex-direction: column; align-items: center; justify-content: center; padding: 42rpx; color: $ink; font-size: 28rpx; text-align: center; }.state-card .app-button { width: 100%; margin-top: 24rpx; }.record-list { display: flex; flex-direction: column; gap: 16rpx; }.save-notice { display: block; color: $brand-red; font-size: 22rpx; }.record-card { min-height: 128rpx; padding: 28rpx 32rpx; }.record-card text { display: block; }.record-card text:first-child { color: $ink; font-family: STKaiti, KaiTi, serif; font-size: 30rpx; font-weight: 700; }.record-card text:not(:first-child) { margin-top: 7rpx; color: $ink-muted; font-size: 21rpx; line-height: 1.45; overflow-wrap: anywhere; }
</style>
+124 -6
View File
@@ -1,13 +1,131 @@
<!-- 页面编号R-10用途家族备忘创建列表 DTO 缺失时不展示 fixture -->
<!-- 页面编号R-10用途读取新建当前家谱的家族备忘 -->
<template>
<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>
<view class="memo-page">
<ModulePageBackground module="records" />
<view class="page-header"><PageHeader title="家族备忘" :action="valid && view === 'list' ? '新建' : ''" custom-back @back="requestBack" @action="openCreate" /></view>
<view class="page-content">
<view v-if="view === 'form'" class="form-card">
<text>新建家族备忘</text>
<text class="form-copy">红色 * 为必填项其余内容可按需补充</text>
<view class="field"><text><text class="required-mark">*</text>备忘标题</text><input v-model="form.memoTitle" maxlength="40" placeholder="请输入备忘标题" @input="error = ''" /></view>
<picker mode="date" :value="form.remindDate" @change="selectRemindDate"><view class="field field--picker"><text>提醒日期</text><text>{{ form.remindDate || '请选择' }}</text></view></picker>
<picker mode="time" :value="form.remindClock" @change="selectRemindClock"><view class="field field--picker"><text>提醒时间</text><text>{{ form.remindClock || '请选择' }}</text></view></picker>
<view class="field field--textarea"><text>备忘内容</text><textarea v-model="form.memoContent" auto-height maxlength="1200" placeholder="记录需要提醒的事情" @input="error = ''" /></view>
<view class="upload-field">
<view><text>相关图片</text><text>图片选定后会先取得真实上传回执并在提交备忘时关联</text></view>
<button class="upload-button" :disabled="uploading || submitting" @click="uploadImage">{{ uploading ? '上传中' : '添加图片' }}</button>
<text v-for="(receipt, index) in mediaReceipts" :key="`${receipt.ossId}-${index}`">已上传{{ receipt.fileName || '图片' }}</text>
<text v-if="uploadError" class="error">{{ uploadError }}</text>
</view>
<view class="field"><text>排序值</text><input v-model="form.sortOrder" type="number" placeholder="数值越小越靠前" @input="error = ''" /></view>
<text v-if="error" class="error">{{ error }}</text>
<view class="form-actions"><AppButton type="secondary" label="取消" @click="cancelCreate" /><AppButton :disabled="submitting || uploading" :label="submitting ? '正在提交' : '提交备忘'" @click="submit" /></view>
</view>
<view v-else-if="!valid" class="state-card"><text>家族备忘入口无效</text><AppButton block label="返回上一页" @click="requestBack" /></view>
<view v-else-if="listState === 'loading'" class="state-card"><AppLoading text="正在读取家族备忘" /></view>
<view v-else-if="listState === 'error'" class="state-card"><text>暂时无法读取家族备忘</text><AppButton block type="secondary" label="重新加载" @click="loadMemos" /></view>
<view v-else-if="listState === 'empty'" class="state-card"><text>还没有家族备忘</text><AppButton block label="新建备忘" @click="openCreate" /></view>
<view v-else class="memo-list">
<text v-if="saveNotice" class="save-notice">备忘已提交并已从服务端重新读取</text>
<view v-for="item in memos" :key="item.id" class="memo-card"><view><text>{{ item.title }}</text><text v-if="item.remindTime">{{ item.remindTime }}</text><text v-if="item.content">{{ item.content }}</text></view></view>
</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 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();});
import { computed, onUnmounted, reactive, ref } from "vue";
import { onBackPress, onLoad, onShow } 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 { appApi, createRequestController, isRequestCancelled } from "@/utils/api.js";
import { createDiscardConfirmation } from "@/utils/discard-confirmation.js";
import { isImagePickCancelled, pickAndUploadImage } from "@/utils/resumable-image-upload.js";
import { goBack, handleBackPress, runBackGuard } from "@/utils/navigation.js";
const genealogyId = ref("");
const view = ref("list");
const listState = ref("loading");
const memos = ref([]);
const saveNotice = ref(false);
const submitting = ref(false);
const uploading = ref(false);
const error = ref("");
const uploadError = ref("");
const discardVisible = ref(false);
const form = reactive({ memoTitle: "", remindDate: "", remindClock: "", memoContent: "", sortOrder: "" });
const mediaReceipts = ref([]);
const controller = createRequestController();
let active = true;
const valid = computed(() => /^[1-9]\d*$/.test(genealogyId.value));
const remindTime = computed(() => form.remindDate ? `${form.remindDate} ${form.remindClock || "00:00"}:00` : "");
const mediaOssIds = computed(() => mediaReceipts.value.map((item) => item.ossId).join(","));
const dirty = computed(() => Object.values(form).some((value) => String(value).trim()) || mediaReceipts.value.length > 0);
const confirmation = createDiscardConfirmation((visible) => { discardVisible.value = visible; });
const confirmDiscard = () => { confirmation.confirm(); resetForm(); view.value = "list"; };
const cancelDiscard = confirmation.cancel;
const resetForm = () => { Object.assign(form, { memoTitle: "", remindDate: "", remindClock: "", memoContent: "", sortOrder: "" }); mediaReceipts.value = []; error.value = ""; uploadError.value = ""; };
const loadMemos = async () => {
if (!valid.value) return;
controller.abort();
listState.value = "loading";
try {
const rows = await appApi.getMemos(genealogyId.value, { requestController: controller });
if (!active) return;
memos.value = rows.map((item) => ({ id: String(item.memoId || ""), title: String(item.memoTitle || "未命名备忘"), remindTime: String(item.remindTime || ""), content: String(item.memoContent || "") })).filter((item) => /^[1-9]\d*$/.test(item.id));
listState.value = memos.value.length ? "ready" : "empty";
} catch (cause) {
if (!active || isRequestCancelled(cause)) return;
listState.value = "error";
}
};
const openCreate = () => { if (!valid.value) return; saveNotice.value = false; resetForm(); view.value = "form"; };
const cancelCreate = () => { resetForm(); view.value = "list"; };
const selectRemindDate = (event) => { form.remindDate = event.detail.value || ""; };
const selectRemindClock = (event) => { form.remindClock = event.detail.value || ""; };
const uploadImage = async () => {
if (uploading.value || submitting.value) return;
uploading.value = true;
uploadError.value = "";
try { mediaReceipts.value = [...mediaReceipts.value, await pickAndUploadImage({ requestController: controller })]; }
catch (cause) { if (!isImagePickCancelled(cause) && !isRequestCancelled(cause)) uploadError.value = cause?.message || "图片上传失败,请稍后重试。"; }
finally { uploading.value = false; }
};
const submit = async () => {
if (submitting.value || uploading.value || !valid.value) return;
if (!form.memoTitle.trim()) { error.value = "请填写备忘标题"; return; }
submitting.value = true;
error.value = "";
try {
await appApi.createMemo(genealogyId.value, { memoTitle: form.memoTitle, remindTime: remindTime.value, memoContent: form.memoContent, mediaOssIds: mediaOssIds.value, sortOrder: form.sortOrder }, { requestController: controller });
resetForm();
view.value = "list";
saveNotice.value = true;
await loadMemos();
} catch (cause) {
if (!isRequestCancelled(cause)) error.value = cause?.message || "备忘提交失败,请稍后重试。";
} finally { submitting.value = false; }
};
const requestBack = () => view.value !== "form"
? goBack()
: runBackGuard({ transientOpen: discardVisible.value, dirty: dirty.value, submitting: submitting.value || uploading.value, "close-transient": cancelDiscard, "block-submitting": () => true, "confirm-discard": confirmation.request });
onLoad((query) => { genealogyId.value = String(query?.genealogyId || ""); if (valid.value) loadMemos(); else listState.value = "invalid"; });
onShow(() => { if (valid.value && view.value === "list" && listState.value !== "loading") loadMemos(); });
onBackPress((event) => handleBackPress(event, requestBack));
onUnmounted(() => { active = false; 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-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}
.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, .memo-card { @include adaptive.adaptive-records-content; }.form-card { box-sizing: border-box; padding: 46rpx; }.form-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 { display: grid; grid-template-columns: 142rpx 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 > text, .upload-field > view > text:first-child { color: $ink; font-size: 23rpx; font-weight: 700; }.required-mark { display: inline-block; margin-right: 4rpx; color: $brand-red; font-size: 26rpx; transform: translateY(-3rpx); }.field input, .field > text:last-child { min-width: 0; color: $ink; font-size: 23rpx; text-align: right; }.field--textarea { align-items: start; }.field textarea { min-height: 116rpx; color: $ink; font-size: 23rpx; line-height: 1.55; }
.upload-field { display: grid; gap: 12rpx; margin-top: 14rpx; padding: 18rpx 24rpx; @include adaptive.adaptive-records-field; }.upload-field > view > text { display: block; }.upload-field > view > text:last-child { margin-top: 6rpx; color: $ink-muted; font-size: 20rpx; line-height: 1.45; }.upload-button { justify-self: start; min-height: 60rpx; margin: 0; padding: 0 20rpx; border: 1rpx solid rgba(159, 23, 15, .42); border-radius: 8rpx; background: transparent; color: $brand-red; font-size: 22rpx; }.upload-field > text { color: $ink-muted; font-size: 21rpx; overflow-wrap: anywhere; }.error { display: block; margin-top: 12rpx; color: $brand-red; font-size: 22rpx; }
.form-actions { display: grid; grid-template-columns: 1fr 1.45fr; gap: 16rpx; margin-top: 20rpx; }.state-card { display: flex; min-height: 320rpx; flex-direction: column; align-items: center; justify-content: center; padding: 42rpx; color: $ink; font-size: 28rpx; text-align: center; }.state-card .app-button { width: 100%; margin-top: 24rpx; }.memo-list { display: flex; flex-direction: column; gap: 16rpx; }.save-notice { display: block; color: $brand-red; font-size: 22rpx; }.memo-card { min-height: 128rpx; padding: 28rpx 32rpx; }.memo-card text { display: block; }.memo-card text:first-child { color: $ink; font-family: STKaiti, KaiTi, serif; font-size: 30rpx; font-weight: 700; }.memo-card text:not(:first-child) { margin-top: 7rpx; color: $ink-muted; font-size: 21rpx; line-height: 1.45; overflow-wrap: anywhere; }
</style>
+120 -6
View File
@@ -1,13 +1,127 @@
<!-- 页面编号R-11用途功德记录创建列表 DTO 缺失时不展示 fixture -->
<!-- 页面编号R-11用途读取新建当前家谱的功德记录 -->
<template>
<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>
<view class="merit-page">
<ModulePageBackground module="records" />
<view class="page-header"><PageHeader title="功德记录" :action="valid && view === 'list' ? '新建' : ''" custom-back @back="requestBack" @action="openCreate" /></view>
<view class="page-content">
<view v-if="view === 'form'" class="form-card">
<text>新建功德记录</text>
<text class="form-copy">红色 * 为必填项其余内容可按需补充</text>
<view v-for="field in fields" :key="field.key" class="field"><text><text v-if="field.required" class="required-mark">*</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.inputType || 'text'" :placeholder="field.placeholder || `请输入${field.label}`" @input="error = ''" /></view>
<text v-if="error" class="error">{{ error }}</text>
<view class="form-actions"><AppButton type="secondary" label="取消" @click="cancelCreate" /><AppButton :disabled="submitting" :label="submitting ? '正在提交' : '提交功德记录'" @click="submit" /></view>
</view>
<view v-else-if="!valid" class="state-card"><text>功德记录入口无效</text><AppButton block label="返回上一页" @click="requestBack" /></view>
<view v-else-if="listState === 'loading'" class="state-card"><AppLoading text="正在读取功德记录" /></view>
<view v-else-if="listState === 'error'" class="state-card"><text>暂时无法读取功德记录</text><AppButton block type="secondary" label="重新加载" @click="loadMerits" /></view>
<view v-else-if="listState === 'empty'" class="state-card"><text>还没有功德记录</text><AppButton block label="新建功德记录" @click="openCreate" /></view>
<view v-else class="merit-list">
<text v-if="saveNotice" class="save-notice">功德记录已提交并已从服务端重新读取</text>
<text class="merit-summary"> {{ merits.length }} 金额合计 ¥{{ totalAmount }}</text>
<view v-for="item in merits" :key="item.id" class="merit-card"><view><text>{{ item.title }}</text><text>{{ item.donor }}{{ item.type ? ` · ${item.type}` : '' }}</text><text v-if="item.time || item.content">{{ item.time || item.content }}</text></view><text>¥{{ item.amount }}</text></view>
</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 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();});
import { computed, onUnmounted, reactive, ref } from "vue";
import { onBackPress, onLoad, onShow } 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 { appApi, createRequestController, isRequestCancelled } from "@/utils/api.js";
import { createDiscardConfirmation } from "@/utils/discard-confirmation.js";
import { goBack, handleBackPress, runBackGuard } from "@/utils/navigation.js";
const genealogyId = ref("");
const view = ref("list");
const listState = ref("loading");
const merits = ref([]);
const saveNotice = ref(false);
const submitting = ref(false);
const error = ref("");
const discardVisible = ref(false);
const form = reactive({ donor: "", title: "", type: "", amount: "", time: "", content: "", sortOrder: "" });
const fields = [
{ key: "donor", label: "捐赠人", required: true },
{ key: "title", label: "功德标题", required: true },
{ key: "type", label: "功德类型" },
{ key: "amount", inputType: "digit", label: "金额" },
{ key: "time", label: "记录时间" },
{ key: "content", label: "记录内容" },
{ key: "sortOrder", label: "排序值", inputType: "number", placeholder: "数值越小越靠前" },
];
const controller = createRequestController();
let active = true;
const valid = computed(() => /^[1-9]\d*$/.test(genealogyId.value));
const dirty = computed(() => Object.values(form).some((value) => String(value).trim()));
const totalAmount = computed(() => merits.value.reduce((total, item) => total + item.amount, 0).toFixed(2));
const confirmation = createDiscardConfirmation((visible) => { discardVisible.value = visible; });
const confirmDiscard = () => { confirmation.confirm(); resetForm(); view.value = "list"; };
const cancelDiscard = confirmation.cancel;
const resetForm = () => { Object.assign(form, { donor: "", title: "", type: "", amount: "", time: "", content: "", sortOrder: "" }); error.value = ""; };
const loadMerits = async () => {
if (!valid.value) return;
controller.abort();
listState.value = "loading";
try {
const rows = await appApi.getMeritRecords(genealogyId.value, { requestController: controller });
if (!active) return;
merits.value = rows.map((item) => ({
id: String(item.meritId || ""),
donor: String(item.donorName || "未署名"),
title: String(item.meritTitle || "未命名功德"),
type: String(item.meritType || ""),
amount: Number.isFinite(Number(item.amount)) ? Number(item.amount) : 0,
time: String(item.meritTime || ""),
content: String(item.meritContent || ""),
})).filter((item) => /^[1-9]\d*$/.test(item.id));
listState.value = merits.value.length ? "ready" : "empty";
} catch (cause) {
if (!active || isRequestCancelled(cause)) return;
listState.value = "error";
}
};
const openCreate = () => { if (!valid.value) return; saveNotice.value = false; resetForm(); view.value = "form"; };
const cancelCreate = () => { resetForm(); view.value = "list"; };
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, sortOrder: form.sortOrder, ...(amountText ? { amount: Number(amountText) } : {}) }, { requestController: controller });
resetForm();
view.value = "list";
saveNotice.value = true;
await loadMerits();
} catch (cause) {
if (!isRequestCancelled(cause)) error.value = cause?.message || "功德记录提交失败,请稍后重试。";
} finally { submitting.value = false; }
};
const requestBack = () => view.value !== "form"
? goBack()
: runBackGuard({ transientOpen: discardVisible.value, dirty: dirty.value, submitting: submitting.value, "close-transient": cancelDiscard, "block-submitting": () => true, "confirm-discard": confirmation.request });
onLoad((query) => { genealogyId.value = String(query?.genealogyId || ""); if (valid.value) loadMerits(); else listState.value = "invalid"; });
onShow(() => { if (valid.value && view.value === "list" && listState.value !== "loading") loadMerits(); });
onBackPress((event) => handleBackPress(event, requestBack));
onUnmounted(() => { active = false; 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-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}
.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, .merit-card { @include adaptive.adaptive-records-content; }.form-card { box-sizing: border-box; padding: 46rpx; }.form-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; }.required-mark { display: inline-block; margin-right: 4rpx; color: $brand-red; font-size: 26rpx; transform: translateY(-3rpx); }.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-actions { display: grid; grid-template-columns: 1fr 1.45fr; gap: 16rpx; margin-top: 20rpx; }.state-card { display: flex; min-height: 320rpx; flex-direction: column; align-items: center; justify-content: center; padding: 42rpx; color: $ink; font-size: 28rpx; text-align: center; }.state-card .app-button { width: 100%; margin-top: 24rpx; }.merit-list { display: flex; flex-direction: column; gap: 16rpx; }.save-notice { display: block; color: $brand-red; font-size: 22rpx; }.merit-summary { display: block; color: $ink-muted; font-size: 22rpx; }.merit-card { display: flex; min-height: 138rpx; align-items: center; justify-content: space-between; gap: 18rpx; padding: 28rpx 32rpx; }.merit-card > view { min-width: 0; flex: 1; }.merit-card text { display: block; }.merit-card > view text:first-child { color: $ink; font-family: STKaiti, KaiTi, serif; font-size: 30rpx; font-weight: 700; overflow-wrap: anywhere; }.merit-card > view text:not(:first-child) { margin-top: 7rpx; color: $ink-muted; font-size: 21rpx; line-height: 1.45; overflow-wrap: anywhere; }.merit-card > text { flex: 0 0 auto; color: $brand-red; font-size: 22rpx; }
</style>
+96 -11
View File
@@ -26,7 +26,7 @@
</view>
<view class="form-field">
<text>姓名</text>
<text><text class="required-mark">*</text>姓名</text>
<input
v-model="addForm.name"
maxlength="20"
@@ -44,25 +44,70 @@
@change="selectRelation"
>
<view class="form-field form-field--picker">
<text>与本人关系</text>
<text><text class="required-mark">*</text>与本人关系</text>
<text>{{ addForm.relation || "请选择亲属关系" }}</text>
</view>
</picker>
<text v-if="fieldErrors.relation" class="field-error">{{ fieldErrors.relation }}</text>
<view class="form-field">
<text>别名</text>
<input v-model="addForm.aliasName" maxlength="40" placeholder="按家谱记载填写" placeholder-class="form-placeholder" />
</view>
<view class="form-field">
<text>字辈</text>
<input v-model="addForm.generationName" maxlength="12" placeholder="按家谱记载填写" placeholder-class="form-placeholder" />
</view>
<picker mode="date" :value="addForm.birthDate" @change="selectBirthDate">
<view class="form-field form-field--picker">
<text>出生日期</text><text>{{ addForm.birthDate || "选填" }}</text>
<text>出生日期</text><text>{{ addForm.birthDate || "请选择" }}</text>
</view>
</picker>
<view class="form-field">
<text>出生农历</text>
<input v-model="addForm.birthLunar" maxlength="40" placeholder="按家谱记载填写" placeholder-class="form-placeholder" />
</view>
<view class="form-field">
<text>出生地</text>
<input v-model="addForm.birthPlace" maxlength="60" placeholder="按家谱记载填写" placeholder-class="form-placeholder" />
</view>
<picker mode="date" :value="addForm.deathDate" @change="selectDeathDate">
<view class="form-field form-field--picker">
<text>逝世日期</text><text>{{ addForm.deathDate || "请选择" }}</text>
</view>
</picker>
<view class="form-field">
<text>逝世农历</text>
<input v-model="addForm.deathLunar" maxlength="40" placeholder="按家谱记载填写" placeholder-class="form-placeholder" />
</view>
<view class="form-field">
<text>逝世地</text>
<input v-model="addForm.deathPlace" maxlength="60" placeholder="按家谱记载填写" placeholder-class="form-placeholder" />
</view>
<view class="form-field">
<text>安葬地</text>
<input v-model="addForm.burialPlace" maxlength="60" placeholder="按家谱记载填写" placeholder-class="form-placeholder" />
</view>
<view class="form-field form-field--summary">
<text>简要说明</text>
<text>人物简介</text>
<textarea
v-model="addForm.summary"
v-model="addForm.biography"
auto-height
maxlength="500"
placeholder="按家谱记载填写"
placeholder-class="form-placeholder"
/>
</view>
<view class="form-field form-field--summary">
<text>备注</text>
<textarea
v-model="addForm.remark"
auto-height
maxlength="200"
placeholder="选填:字辈、祖居地或身份说明"
placeholder="补充说明"
placeholder-class="form-placeholder"
/>
</view>
@@ -136,7 +181,21 @@ const relationIntents = Object.freeze({
DAUGHTER: { label: "女儿" },
});
const genericRelationTypes = Object.freeze(["FATHER", "MOTHER", "SPOUSE", "SIBLING", "SON", "DAUGHTER"]);
const addForm = reactive({ name: "", relation: "", birthDate: "", summary: "" });
const addForm = reactive({
name: "",
relation: "",
aliasName: "",
generationName: "",
birthDate: "",
birthLunar: "",
birthPlace: "",
deathDate: "",
deathLunar: "",
deathPlace: "",
burialPlace: "",
biography: "",
remark: "",
});
const fieldErrors = reactive({ name: "", relation: "" });
const isFirstMember = computed(() => mode.value === "first");
@@ -161,13 +220,13 @@ const formCopy = computed(() =>
: "先确认新成员与当前成员的关系,再填写可核实的身份信息。",
);
const formNote = computed(() =>
"本次只提交姓名、出生日期和人物简介;性别编码、头像和同辈排行不在当前写入范围。",
"红色 * 为必填项,其余内容可按家谱记载补充。",
);
const resultCopy = computed(() => {
if (hasValidContext.value) {
return {
eyebrow: "保存失败",
title: `${relationLabel.value}尚未保存`,
title: `${isFirstMember.value ? "首位成员" : relationLabel.value}尚未保存`,
copy: errorMessage.value || "服务未确认本次写入,请检查填写后重试。",
action: "返回修改",
};
@@ -255,6 +314,7 @@ const selectRelation = (event) => {
clearError("relation");
};
const selectBirthDate = (event) => { addForm.birthDate = event.detail.value || ""; };
const selectDeathDate = (event) => { addForm.deathDate = event.detail.value || ""; };
const validateAddForm = () => {
fieldErrors.name = addForm.name.trim() ? "" : "请填写成员姓名";
fieldErrors.relation = isFirstMember.value || addForm.relation ? "" : "请选择与当前成员的关系";
@@ -266,8 +326,18 @@ const submitAdd = async () => {
try {
const payload = {
name: addForm.name,
aliasName: addForm.aliasName,
generationName: addForm.generationName,
birthDate: addForm.birthDate,
biography: addForm.summary,
birthLunar: addForm.birthLunar,
birthPlace: addForm.birthPlace,
deathDate: addForm.deathDate,
deathLunar: addForm.deathLunar,
deathPlace: addForm.deathPlace,
burialPlace: addForm.burialPlace,
biography: addForm.biography,
remark: addForm.remark,
...(isFirstMember.value ? { generation: 1 } : {}),
...(isFirstMember.value ? {} : { relationName: relationLabel.value }),
};
if (isFirstMember.value) {
@@ -278,7 +348,21 @@ const submitAdd = async () => {
: genericRelationTypes[relationOptions.value.indexOf(addForm.relation)];
await appApi.createRelatedPerson(genealogyId.value, personId.value, type, payload, { requestController: addRequestController });
}
Object.assign(addForm, { name: "", relation: "", birthDate: "", summary: "" });
Object.assign(addForm, {
name: "",
relation: "",
aliasName: "",
generationName: "",
birthDate: "",
birthLunar: "",
birthPlace: "",
deathDate: "",
deathLunar: "",
deathPlace: "",
burialPlace: "",
biography: "",
remark: "",
});
await returnTo("T01", { genealogyId: genealogyId.value });
} catch (error) {
if (isRequestCancelled(error)) return;
@@ -310,6 +394,7 @@ const returnToTree = async () => {
.member-context, .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; }
.member-context text:first-child, .form-field > text:first-child { color: $ink; font-size: 24rpx; font-weight: 700; }
.member-context text:last-child, .form-field > text:last-child, .form-field input, .form-field textarea { min-width: 0; color: $ink; font-size: 24rpx; line-height: 1.45; text-align: right; }
.required-mark { display: inline-block; margin-right: 4rpx; color: $brand-red; font-size: 22rpx; line-height: 1; transform: translateY(-2rpx); }
.form-field textarea { width: auto; min-height: 54rpx; text-align: left; }
.form-field--summary { align-items: start; }
.form-placeholder { color: #a79884; }
+1 -1
View File
@@ -12,7 +12,7 @@
<ModulePageBackground module="tree" />
<view class="directory-page__header"><PageHeader title="成员目录" /></view>
<view v-if="hasValidContext" class="directory-context">
<text class="directory-context__name">汤氏家谱</text>
<text class="directory-context__name">当前家谱</text>
<text class="directory-context__meta"
>已加载 {{ members.length }} / {{ total }} 位成员</text
>
+4 -5
View File
@@ -71,10 +71,10 @@ const states = {
},
available: {
eyebrow: "人物状态",
title: "当前状态已由服务端返回",
copy: "页面只展示人物详情中的原始状态值,不把未声明的字典值解释成隐私、纪念或访问限制。",
title: "暂无法展示状态说明",
copy: "服务端返回了状态字段,但未提供可读名称或说明;本页不会向家人展示内部状态值。",
guideTitle: "当前合同范围",
guides: ["人物状态值来自 personStatus 字段", "状态展示文案需要后端提供字典 owner", "当前页不执行停用或任何状态写入"],
guides: ["状态由服务端维护", "状态展示需要后端提供可读字典", "当前页不执行停用或任何状态写入"],
action: "返回成员档案",
},
error: {
@@ -91,8 +91,7 @@ const pageTitle = computed(() => "成员状态");
const hasValidContext = computed(() => Boolean(genealogyId.value && personId.value));
const memberIdentityCopy = computed(() => {
if (!member.value) return "";
const status = member.value.personStatus || "未填写";
return `${member.value.name} · 第 ${member.value.generation} 世 · 状态值 ${status}`;
return `${member.value.name} · 第 ${member.value.generation}`;
});
const loadMember = async () => {