完成50%

This commit is contained in:
2026-07-23 08:23:59 +08:00
parent 9b0ad62df4
commit f1edc6b533
218 changed files with 24318 additions and 5514 deletions
+295 -214
View File
@@ -11,20 +11,24 @@
/>
</view>
<view class="login-tabs">
<view
class="login-tab"
:class="{ active: activeLoginMethod === 'password' }"
<view class="login-tabs" role="tablist" aria-label="登录方式">
<button
class="auth-plain-button login-tab login-tab--unavailable"
role="tab"
:aria-selected="activeLoginMethod === 'password'"
aria-disabled="true"
hover-class="tap-fade"
@click="switchLoginMethod('password')"
>密码登录</view
>密码登录</button
>
<view
class="login-tab"
<button
class="auth-plain-button login-tab"
role="tab"
:aria-selected="activeLoginMethod === 'sms'"
:class="{ active: activeLoginMethod === 'sms' }"
hover-class="tap-fade"
@click="switchLoginMethod('sms')"
>验证码登录</view
>验证码登录</button
>
</view>
@@ -40,7 +44,9 @@
class="auth-input"
type="number"
maxlength="11"
:disabled="sendingCode || cooldownSeconds > 0 || submitting"
placeholder="手机号"
aria-label="手机号"
placeholder-class="input-placeholder"
confirm-type="next"
/>
@@ -57,13 +63,17 @@
class="auth-input"
:password="!passwordVisible"
maxlength="32"
:disabled="submitting"
placeholder="密码"
aria-label="登录密码"
placeholder-class="input-placeholder"
confirm-type="done"
@confirm="submitLogin"
/>
<view
class="password-toggle"
<button
class="auth-plain-button password-toggle"
:aria-label="passwordVisible ? '隐藏密码' : '显示密码'"
:aria-pressed="passwordVisible"
hover-class="tap-fade"
@click="togglePasswordVisibility"
>
@@ -75,8 +85,9 @@
: '/static/assets/modules/auth/transparent/a01-icon-eye-closed-pupil-v2.png'
"
mode="aspectFit"
aria-hidden="true"
/>
</view>
</button>
</view>
<view v-else class="input-row">
@@ -89,33 +100,38 @@
v-model.trim="verificationCode"
class="auth-input"
type="number"
maxlength="6"
maxlength="4"
:disabled="submitting"
placeholder="短信验证码"
aria-label="短信验证码"
placeholder-class="input-placeholder"
confirm-type="done"
@confirm="submitLogin"
/>
<view
class="get-code"
<button
class="auth-plain-button get-code"
:class="{ 'get-code--disabled': sendingCode || cooldownSeconds > 0 }"
:disabled="sendingCode || submitting || cooldownSeconds > 0"
hover-class="tap-fade"
@click="prepareGetCode"
>获取验证码</view
>{{ sendingCode ? "请求中…" : cooldownSeconds > 0 ? `${cooldownSeconds}s 后重发` : "获取验证码" }}</button
>
</view>
<view class="form-secondary-row">
<view
v-if="activeLoginMethod === 'password'"
class="forgot-password"
<button
class="auth-plain-button forgot-password"
hover-class="tap-fade"
@click="prepareForgotPassword"
>忘记密码</view
>忘记密码</button
>
</view>
</view>
<view
class="login-submit"
<button
class="auth-plain-button login-submit"
:disabled="submitting || sendingCode || tacVisible"
:aria-busy="submitting"
hover-class="button-pressed"
@click="submitLogin"
>
@@ -124,8 +140,8 @@
src="/static/assets/foundation/transparent/a01-scroll-primary-v3.png"
mode="aspectFit"
/>
<text class="login-submit__copy">登录</text>
</view>
<text class="login-submit__copy">{{ submitting ? "登录中…" : "登录" }}</text>
</button>
<view class="other-login-divider">
<view class="divider-line" />
@@ -143,8 +159,9 @@
<view class="divider-line" />
</view>
<view
class="wechat-login"
<button
class="auth-plain-button wechat-login"
:disabled="submitting || sendingCode || tacVisible"
hover-class="button-pressed"
@click="prepareWechatLogin"
>
@@ -161,15 +178,16 @@
/>
<text>微信登录</text>
</view>
</view>
</button>
<view class="register-entry">
<text>还没有账号</text>
<view
class="register-link"
<button
class="auth-plain-button register-link"
:disabled="submitting || sendingCode || tacVisible"
hover-class="tap-fade"
@click="prepareRegister"
>注册账号</view
>注册账号</button
>
</view>
@@ -177,103 +195,110 @@
class="agreement-area"
:class="{ 'agreement-area--error': agreementError }"
>
<view class="agreement-row" @click="toggleAgreement">
<image
class="agreement-icon"
:src="
agreed
? '/static/assets/modules/auth/transparent/a02-agreement-checked.png'
: '/static/assets/modules/auth/transparent/a02-agreement-unchecked.png'
"
mode="aspectFit"
/>
<view class="agreement-row">
<button
class="auth-plain-button agreement-toggle"
role="checkbox"
:aria-checked="agreed"
:aria-label="agreed ? '取消同意用户协议与隐私政策' : '同意用户协议与隐私政策'"
:disabled="submitting || sendingCode || tacVisible"
@click="toggleAgreement"
>
<image
class="agreement-icon"
:src="
agreed
? '/static/assets/modules/auth/transparent/a02-agreement-checked.png'
: '/static/assets/modules/auth/transparent/a02-agreement-unchecked.png'
"
mode="aspectFit"
aria-hidden="true"
/>
</button>
<view class="agreement-copy">
<text>我已阅读并同意</text>
<text class="agreement-link" @click.stop="prepareAgreement"
>用户协议</text
<button class="auth-plain-button agreement-link" @click="prepareAgreement"
>用户协议</button
>
<text></text>
<text class="agreement-link" @click.stop="prepareAgreement"
>隐私政策</text
<button class="auth-plain-button agreement-link" @click="prepareAgreement"
>隐私政策</button
>
</view>
</view>
<text v-if="agreementError" class="agreement-error"
<text v-if="agreementError" class="agreement-error" role="alert"
>请先阅读并同意用户协议与隐私政策</text
>
</view>
</view>
<template #overlay>
<!-- 当前只验收自定义容器外观真实行为验证数据留到接口阶段接入 -->
<view
v-if="verificationVisible"
class="verification-layer"
@click="closeVerification"
>
<view class="verification-dialog" @click.stop>
<image
class="verification-dialog__skin"
src="/static/assets/modules/auth/transparent/a01-scroll-dialog-v3.png"
mode="aspectFit"
/>
<view class="verification-dialog__content">
<text class="verification-title">安全验证</text>
<text class="verification-copy"
>{{ verificationPurpose }}前需要完成行为验证</text
>
<text class="verification-note">验证数据将在接口接入后启用</text>
<view class="verification-actions">
<view
class="verification-action"
hover-class="tap-fade"
@click="closeVerification"
>取消</view
>
<view
class="verification-action verification-action--primary"
hover-class="tap-fade"
@click="confirmVerification"
>知道了</view
>
</view>
</view>
</view>
</view>
<TacVerification
:visible="tacVisible"
:context="tacContext"
@success="completeTac"
@failure="handleTacFailure"
@error="handleTacError"
@cancel="closeTac"
/>
<view v-if="feedbackVisible" class="feedback-toast">
<text class="feedback-toast__copy">{{ feedbackMessage }}</text>
</view>
<AppToast :visible="feedbackVisible" :message="feedbackMessage" />
</template>
</AuthPageShell>
</template>
<script setup>
import { ref } from "vue";
import { onLoad, onUnload } from "@dcloudio/uni-app";
import { onBackPress, onUnload } from "@dcloudio/uni-app";
import AuthPageShell from "@/components/AuthPageShell.vue";
import AppToast from "@/components/AppToast.vue";
import TacVerification from "@/components/TacVerification.vue";
import {
appApi,
createRequestController,
isRequestCancelled,
} from "@/utils/api.js";
import {
AUTH_TAC_SCENE,
PASSWORD_TAC_BLOCKED_MESSAGE,
createTacRenderContext,
isAuthPhone,
normalizeCaptchaRequirement,
normalizeTacSuccess,
} from "@/utils/auth-verification.js";
import { runtimeConfig } from "@/utils/config.js";
import {
goRoot,
handleBackPress,
openPage,
runBackGuard,
} from "@/utils/navigation.js";
const LOGIN_METHOD_STORAGE_KEY = "a01:last-login-method";
const activeLoginMethod = ref("password");
const activeLoginMethod = ref("sms");
const passwordVisible = ref(false);
const phone = ref("");
const password = ref("");
const verificationCode = ref("");
const agreed = ref(false);
const agreementError = ref(false);
const verificationVisible = ref(false);
const verificationPurpose = ref("登录");
const tacVisible = ref(false);
const tacContext = ref(null);
const sendingCode = ref(false);
const submitting = ref(false);
const cooldownSeconds = ref(0);
const feedbackVisible = ref(false);
const feedbackMessage = ref("");
let feedbackTimer = null;
onLoad(() => {
const storedMethod = uni.getStorageSync(LOGIN_METHOD_STORAGE_KEY);
if (storedMethod === "sms") activeLoginMethod.value = "sms";
});
let cooldownTimer = null;
let tacSequence = 0;
let pageActive = true;
const authRequestController = createRequestController();
onUnload(() => {
pageActive = false;
authRequestController.abort();
if (feedbackTimer) clearTimeout(feedbackTimer);
if (cooldownTimer) clearInterval(cooldownTimer);
});
const showFeedback = (message) => {
@@ -286,10 +311,19 @@ const showFeedback = (message) => {
}, 2200);
};
const blockBusyAction = () => {
if (!sendingCode.value && !submitting.value) return false;
showFeedback("请求处理中,请稍候");
return true;
};
const switchLoginMethod = (method) => {
if (method !== "password" && method !== "sms") return;
activeLoginMethod.value = method;
uni.setStorageSync(LOGIN_METHOD_STORAGE_KEY, method);
if (blockBusyAction()) return;
if (method === "password") {
showFeedback(PASSWORD_TAC_BLOCKED_MESSAGE);
return;
}
if (method === "sms") activeLoginMethod.value = method;
};
const togglePasswordVisibility = () => {
@@ -302,7 +336,7 @@ const toggleAgreement = () => {
};
const validatePhone = () => {
if (/^1\d{10}$/.test(phone.value)) return true;
if (isAuthPhone(phone.value)) return true;
showFeedback("请输入正确的手机号");
return false;
};
@@ -313,26 +347,124 @@ const requireAgreement = () => {
return false;
};
const openVerification = (purpose) => {
verificationPurpose.value = purpose;
verificationVisible.value = true;
const closeTac = () => {
tacVisible.value = false;
tacContext.value = null;
};
const closeVerification = () => {
verificationVisible.value = false;
const cancelPendingRequest = () => {
authRequestController.abort();
sendingCode.value = false;
submitting.value = false;
};
const confirmVerification = () => {
closeVerification();
showFeedback("行为验证接口待接入");
const requestBack = () =>
runBackGuard({
transientOpen: tacVisible.value,
submitting: submitting.value || sendingCode.value,
"close-transient": closeTac,
"block-submitting": () => {
cancelPendingRequest();
return true;
},
});
// A01 是认证根页:没有浮层时必须把系统返回交还 Android,只在行为验证浮层
// 可见时消费返回键,避免根页被守卫误拦而无法正常退出应用。
onBackPress((event) => {
if (!tacVisible.value && (submitting.value || sendingCode.value)) {
cancelPendingRequest();
return false;
}
if (!tacVisible.value) return false;
return handleBackPress(event, requestBack);
});
const startCooldown = () => {
cooldownSeconds.value = 60;
if (cooldownTimer) clearInterval(cooldownTimer);
cooldownTimer = setInterval(() => {
cooldownSeconds.value -= 1;
if (cooldownSeconds.value <= 0) {
clearInterval(cooldownTimer);
cooldownTimer = null;
}
}, 1000);
};
const prepareGetCode = () => {
const prepareGetCode = async () => {
if (sendingCode.value || cooldownSeconds.value > 0) return;
if (!validatePhone() || !requireAgreement()) return;
openVerification("发送验证码");
sendingCode.value = true;
try {
const sceneCode = AUTH_TAC_SCENE.SMS_LOGIN;
const requestedPhone = phone.value;
const response = await appApi.getCaptchaRequirement(
{ sceneCode, subject: requestedPhone },
{ requestController: authRequestController },
);
if (!pageActive) return;
if (phone.value !== requestedPhone) throw new Error("手机号已变化,请重新获取验证码");
const requirement = normalizeCaptchaRequirement(response, sceneCode);
tacSequence += 1;
tacContext.value = createTacRenderContext({
requestId: `a01-sms-${tacSequence}`,
baseUrl: runtimeConfig.baseUrl,
clientId: runtimeConfig.clientId,
tenantId: runtimeConfig.tenantId,
sceneCode,
subject: requestedPhone,
requirement,
});
tacVisible.value = true;
} catch (error) {
if (pageActive && !isRequestCancelled(error)) {
showFeedback(error.message || "安全验证暂不可用");
}
} finally {
if (pageActive) sendingCode.value = false;
}
};
const submitLogin = () => {
const completeTac = async (result) => {
const expectedContext = tacContext.value;
if (!expectedContext) return;
try {
const ticket = normalizeTacSuccess(result, expectedContext.requestId);
if (phone.value !== expectedContext.subject) throw new Error("手机号已变化,请重新验证");
closeTac();
sendingCode.value = true;
await appApi.sendSmsCode(
{
sceneCode: AUTH_TAC_SCENE.SMS_LOGIN,
phone: expectedContext.subject,
validToken: ticket.validToken,
},
{ requestController: authRequestController },
);
if (!pageActive) return;
startCooldown();
showFeedback("验证码已发送");
} catch (error) {
closeTac();
if (pageActive && !isRequestCancelled(error)) {
showFeedback(error.message || "验证码发送失败");
}
} finally {
if (pageActive) sendingCode.value = false;
}
};
const handleTacFailure = ({ message } = {}) =>
showFeedback(message || "行为验证未通过,请重试");
const handleTacError = ({ message } = {}) => {
closeTac();
showFeedback(message || "安全验证暂不可用");
};
const submitLogin = async () => {
if (submitting.value) return;
if (!validatePhone()) return;
if (activeLoginMethod.value === "password" && !password.value) {
showFeedback("请输入密码");
@@ -340,19 +472,46 @@ const submitLogin = () => {
}
if (
activeLoginMethod.value === "sms" &&
!/^\d{6}$/.test(verificationCode.value)
!/^\d{4}$/.test(verificationCode.value)
) {
showFeedback("请输入 6 位验证码");
showFeedback("请输入 4 位验证码");
return;
}
if (!requireAgreement()) return;
openVerification("登录");
if (activeLoginMethod.value === "password") {
// 受保护源合同明确禁止密码登录携带 TAC 票据。仅在客户端先滑动仍可被
// 绕过,因此此入口必须失败关闭;短信登录已经具备 TAC→短信票据闭环。
showFeedback(PASSWORD_TAC_BLOCKED_MESSAGE);
return;
}
submitting.value = true;
try {
await appApi.loginWithSms(
{
phone: phone.value,
smsCode: verificationCode.value,
},
{ requestController: authRequestController },
);
if (!pageActive) return;
await goRoot("G01");
} catch (error) {
if (pageActive && !isRequestCancelled(error)) {
showFeedback(error.message || "登录失败,请稍后重试");
}
} finally {
if (pageActive) submitting.value = false;
}
};
const prepareForgotPassword = () =>
uni.navigateTo({ url: "/pages/auth/a05-reset-password" });
const prepareRegister = () =>
uni.navigateTo({ url: "/pages/auth/a04-register" });
const prepareForgotPassword = () => {
if (blockBusyAction()) return;
return openPage("A05", {}, "A01");
};
const prepareRegister = () => {
if (blockBusyAction()) return;
return openPage("A04", {}, "A01");
};
const prepareWechatLogin = () => {
if (!requireAgreement()) return;
@@ -363,7 +522,6 @@ const prepareAgreement = () => showFeedback("协议页面准备中");
</script>
<style scoped lang="scss">
@use "../../styles/adaptive-frame-profiles.scss" as adaptive;
.login-content {
display: flex;
@@ -498,6 +656,14 @@ const prepareAgreement = () => showFeedback("协议页面准备中");
font-size: 28rpx;
}
.login-tab--unavailable {
color: #9f968d;
}
.get-code--disabled {
color: #9f968d;
}
.form-secondary-row {
display: flex;
align-items: center;
@@ -623,7 +789,17 @@ const prepareAgreement = () => showFeedback("协议页面准备中");
flex: 0 0 auto;
width: 34rpx;
height: 34rpx;
margin-right: 10rpx;
}
.agreement-toggle {
display: inline-flex;
flex: 0 0 auto;
align-items: center;
justify-content: center;
width: var(--app-touch-min);
min-width: var(--app-touch-min);
min-height: var(--app-touch-min);
margin-right: 2rpx;
}
.agreement-copy {
@@ -634,6 +810,9 @@ const prepareAgreement = () => showFeedback("协议页面准备中");
}
.agreement-link {
display: inline-flex;
align-items: center;
min-height: var(--app-touch-min);
color: #a9160d;
}
@@ -669,102 +848,4 @@ const prepareAgreement = () => showFeedback("协议页面准备中");
opacity: 0.82;
}
.feedback-toast {
position: fixed;
z-index: 30;
top: calc(var(--status-bar-height, 0px) + 24rpx);
left: 50%;
display: flex;
align-items: center;
justify-content: center;
@include adaptive.adaptive-feedback-toast;
width: 590rpx;
min-height: 82rpx;
background: transparent;
transform: translateX(-50%);
}
.feedback-toast__copy {
z-index: 1;
padding: 16rpx 36rpx;
color: #5c4330;
font-size: 25rpx;
text-align: center;
}
.verification-layer {
position: fixed;
z-index: 40;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
padding: 40rpx;
background: rgba(31, 20, 13, 0.56);
}
.verification-dialog {
display: grid;
width: 620rpx;
min-height: 520rpx;
max-height: calc(100vh - 80rpx);
}
.verification-dialog__skin {
grid-area: 1 / 1;
width: 100%;
height: 100%;
}
.verification-dialog__content {
z-index: 1;
display: flex;
grid-area: 1 / 1;
flex-direction: column;
align-items: center;
box-sizing: border-box;
min-height: 100%;
overflow-y: auto;
padding: 90rpx 64rpx 54rpx;
}
.verification-title {
color: #6f140f;
font-size: 42rpx;
font-weight: 700;
}
.verification-copy {
margin-top: 42rpx;
color: #493323;
font-size: 28rpx;
text-align: center;
}
.verification-note {
margin-top: 20rpx;
color: #8d7a68;
font-size: 23rpx;
}
.verification-actions {
display: flex;
width: 100%;
margin-top: auto;
}
.verification-action {
display: flex;
flex: 1;
align-items: center;
justify-content: center;
min-height: 88rpx;
color: #745b46;
font-size: 28rpx;
}
.verification-action--primary {
color: #a9160d;
font-weight: 700;
}
</style>
+342 -72
View File
@@ -3,13 +3,19 @@
<AuthPageShell class="auth-page register-page">
<view class="register-content">
<view class="page-heading">
<view class="back-button" hover-class="tap-fade" @click="goBack">
<button
class="auth-plain-button back-button"
aria-label="返回登录"
hover-class="tap-fade"
@click="requestBack"
>
<image
class="back-button__icon"
src="/static/assets/foundation/transparent/chevron-right.png"
mode="aspectFit"
aria-hidden="true"
/>
</view>
</button>
<text class="page-title">注册账号</text>
<text class="page-subtitle">创建属于你的家谱账号</text>
<image
@@ -25,39 +31,80 @@
:class="{ 'field-block--error': fieldErrors.phone }"
>
<view class="input-row">
<text class="input-label">手机号</text>
<label class="input-label" for="a04-phone">手机号</label>
<input
id="a04-phone"
v-model.trim="phone"
class="auth-input"
type="number"
maxlength="11"
:disabled="sendingCode || cooldownSeconds > 0 || submitting"
placeholder="请输入手机号"
placeholder-class="placeholder"
:aria-invalid="Boolean(fieldErrors.phone)"
:aria-describedby="fieldErrors.phone ? 'a04-phone-error' : undefined"
@input="clearFieldError('phone')"
/>
</view>
<text v-if="fieldErrors.phone" class="field-error">{{
<text v-if="fieldErrors.phone" id="a04-phone-error" class="field-error" role="alert">{{
fieldErrors.phone
}}</text>
</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>
<input
id="a04-verification-code"
v-model.trim="verificationCode"
class="auth-input"
type="number"
maxlength="4"
:disabled="submitting"
placeholder="请输入验证码"
placeholder-class="placeholder"
:aria-invalid="Boolean(fieldErrors.verificationCode)"
:aria-describedby="fieldErrors.verificationCode ? 'a04-verification-code-error' : undefined"
@input="clearFieldError('verificationCode')"
/>
<button
class="auth-plain-button get-code"
:class="{ 'get-code--disabled': sendingCode || cooldownSeconds > 0 }"
:disabled="sendingCode || submitting || cooldownSeconds > 0"
hover-class="tap-fade"
@click="prepareGetCode"
>{{ sendingCode ? "请求中…" : cooldownSeconds > 0 ? `${cooldownSeconds}s 后重发` : "获取验证码" }}</button
>
</view>
<text v-if="fieldErrors.verificationCode" id="a04-verification-code-error" class="field-error" role="alert">{{
fieldErrors.verificationCode
}}</text>
</view>
<view
class="field-block"
:class="{ 'field-block--error': fieldErrors.password }"
>
<view class="input-row">
<text class="input-label">设置密码</text>
<label class="input-label" for="a04-password">设置密码</label>
<input
id="a04-password"
v-model="password"
class="auth-input"
password
maxlength="32"
:disabled="submitting"
placeholder="请设置登录密码"
placeholder-class="placeholder"
:aria-invalid="Boolean(fieldErrors.password)"
:aria-describedby="fieldErrors.password ? 'a04-password-error' : undefined"
@input="clearFieldError('password')"
/>
</view>
<text v-if="fieldErrors.password" class="field-error">{{
<text v-if="fieldErrors.password" id="a04-password-error" class="field-error" role="alert">{{
fieldErrors.password
}}</text>
</view>
@@ -67,25 +114,31 @@
:class="{ 'field-block--error': fieldErrors.confirmPassword }"
>
<view class="input-row">
<text class="input-label">确认密码</text>
<label class="input-label" for="a04-confirm-password">确认密码</label>
<input
id="a04-confirm-password"
v-model="confirmPassword"
class="auth-input"
password
maxlength="32"
:disabled="submitting"
placeholder="请再次输入密码"
placeholder-class="placeholder"
:aria-invalid="Boolean(fieldErrors.confirmPassword)"
:aria-describedby="fieldErrors.confirmPassword ? 'a04-confirm-password-error' : undefined"
@input="clearFieldError('confirmPassword')"
/>
</view>
<text v-if="fieldErrors.confirmPassword" class="field-error">{{
<text v-if="fieldErrors.confirmPassword" id="a04-confirm-password-error" class="field-error" role="alert">{{
fieldErrors.confirmPassword
}}</text>
</view>
</view>
<view
class="register-submit"
<button
class="auth-plain-button register-submit"
:disabled="submitting || sendingCode || tacVisible"
:aria-busy="submitting"
hover-class="button-hover"
@click="submitRegister"
>
@@ -94,91 +147,189 @@
src="/static/assets/foundation/transparent/a01-scroll-primary-v3.png"
mode="aspectFit"
/>
<text class="register-submit__content">注册账号</text>
</view>
<text class="register-submit__content">{{ submitting ? "注册中…" : "注册账号" }}</text>
</button>
<view
class="agreement-area"
:class="{ 'agreement-area--error': agreementError }"
>
<view class="agreement-row" @click="toggleAgreement">
<image
class="agreement-icon"
:src="
agreed
? '/static/assets/modules/auth/transparent/a02-agreement-checked.png'
: '/static/assets/modules/auth/transparent/a02-agreement-unchecked.png'
"
mode="aspectFit"
/>
<view class="agreement-row">
<button
class="auth-plain-button agreement-toggle"
role="checkbox"
:aria-checked="agreed"
:aria-label="agreed ? '取消同意用户协议与隐私政策' : '同意用户协议与隐私政策'"
:disabled="sendingCode || submitting || tacVisible"
@click="toggleAgreement"
>
<image
class="agreement-icon"
:src="
agreed
? '/static/assets/modules/auth/transparent/a02-agreement-checked.png'
: '/static/assets/modules/auth/transparent/a02-agreement-unchecked.png'
"
mode="aspectFit"
aria-hidden="true"
/>
</button>
<view class="agreement-copy">
<text>我已阅读并同意</text>
<text class="agreement-link" @click.stop="prepareAgreement"
>用户协议</text
<button class="auth-plain-button agreement-link" @click="prepareAgreement"
>用户协议</button
>
<text></text>
<text class="agreement-link" @click.stop="prepareAgreement"
>隐私政策</text
<button class="auth-plain-button agreement-link" @click="prepareAgreement"
>隐私政策</button
>
</view>
</view>
<text v-if="agreementError" class="agreement-error"
<text v-if="agreementError" class="agreement-error" role="alert"
>请先阅读并同意用户协议与隐私政策</text
>
</view>
<view class="login-entry">
<text>已有账号</text>
<view
class="login-entry__link"
<button
class="auth-plain-button login-entry__link"
:disabled="submitting || sendingCode || tacVisible"
hover-class="tap-fade"
@click="prepareLogin"
>登录</view
@click="requestBack"
>登录</button
>
</view>
</view>
<template #overlay>
<view v-if="feedbackVisible" class="feedback-toast">
<text class="feedback-toast__copy">{{ feedbackMessage }}</text>
</view>
<TacVerification
:visible="tacVisible"
:context="tacContext"
@success="completeTac"
@failure="handleTacFailure"
@error="handleTacError"
@cancel="closeTac"
/>
<AppDialog
:visible="discardVisible"
title="放弃注册?"
message="当前填写内容尚未保存,确认返回后将清空。"
confirm-text="放弃并返回"
cancel-text="继续填写"
show-cancel
compact-actions
:close-on-mask="false"
@confirm="confirmDiscard"
@cancel="cancelDiscard"
/>
<AppToast :visible="feedbackVisible" :message="feedbackMessage" />
</template>
</AuthPageShell>
</template>
<script setup>
import { ref } from "vue";
import { onUnload } from "@dcloudio/uni-app";
import { computed, ref } from "vue";
import { onBackPress, onUnload } from "@dcloudio/uni-app";
import AppDialog from "@/components/AppDialog.vue";
import AppToast from "@/components/AppToast.vue";
import AuthPageShell from "@/components/AuthPageShell.vue";
import TacVerification from "@/components/TacVerification.vue";
import {
appApi,
createRequestController,
isRequestCancelled,
} from "@/utils/api.js";
import {
AUTH_TAC_SCENE,
createTacRenderContext,
isAuthPhone,
normalizeCaptchaRequirement,
normalizeTacSuccess,
} from "@/utils/auth-verification.js";
import { runtimeConfig } from "@/utils/config.js";
import { createDiscardConfirmation } from "@/utils/discard-confirmation.js";
import { calcMD5 } from "@/utils/md5.js";
import { goRoot, handleBackPress, runBackGuard } from "@/utils/navigation.js";
import {
PASSWORD_POLICY_MESSAGE,
validatePassword,
} from "@/utils/validation.js";
const phone = ref("");
const verificationCode = ref("");
const password = ref("");
const confirmPassword = ref("");
const agreed = ref(false);
const agreementError = ref(false);
const fieldErrors = ref({
phone: "",
verificationCode: "",
password: "",
confirmPassword: "",
});
const feedbackVisible = ref(false);
const feedbackMessage = ref("");
const discardVisible = ref(false);
const tacVisible = ref(false);
const tacContext = ref(null);
const sendingCode = ref(false);
const submitting = ref(false);
const cooldownSeconds = ref(0);
const isDirty = computed(() =>
Boolean(
phone.value ||
verificationCode.value ||
password.value ||
confirmPassword.value ||
agreed.value,
),
);
let feedbackTimer = null;
onUnload(() => {
if (feedbackTimer) clearTimeout(feedbackTimer);
let cooldownTimer = null;
let tacSequence = 0;
let pageActive = true;
const authRequestController = createRequestController();
const discardConfirmation = createDiscardConfirmation((visible) => {
discardVisible.value = visible;
});
const requestDiscardConfirmation = discardConfirmation.request;
const confirmDiscard = discardConfirmation.confirm;
const cancelDiscard = discardConfirmation.cancel;
const goBack = () => {
uni.navigateBack();
const closeTac = () => {
tacVisible.value = false;
tacContext.value = null;
};
// “已有账号”用于退出注册流程,替换当前页可避免登录/注册页面反复叠加。
const prepareLogin = () => uni.redirectTo({ url: "/pages/auth/a01-entry" });
const cancelPendingRequest = () => {
authRequestController.abort();
sendingCode.value = false;
submitting.value = false;
};
const requestBack = () =>
runBackGuard({
transientOpen: tacVisible.value || discardVisible.value,
submitting: submitting.value || sendingCode.value,
dirty: isDirty.value,
"close-transient": tacVisible.value ? closeTac : cancelDiscard,
"block-submitting": () => {
cancelPendingRequest();
return requestBack();
},
"confirm-discard": requestDiscardConfirmation,
});
onBackPress((event) => handleBackPress(event, requestBack));
onUnload(() => {
pageActive = false;
authRequestController.abort();
if (feedbackTimer) clearTimeout(feedbackTimer);
if (cooldownTimer) clearInterval(cooldownTimer);
discardConfirmation.dispose();
});
const showFeedback = (message) => {
feedbackMessage.value = message;
@@ -193,6 +344,7 @@ const showFeedback = (message) => {
const prepareAgreement = () => showFeedback("协议页面准备中");
const toggleAgreement = () => {
if (sendingCode.value || submitting.value) return;
agreed.value = !agreed.value;
if (agreed.value) agreementError.value = false;
};
@@ -201,9 +353,107 @@ const clearFieldError = (field) => {
fieldErrors.value[field] = "";
};
const startCooldown = () => {
cooldownSeconds.value = 60;
if (cooldownTimer) clearInterval(cooldownTimer);
cooldownTimer = setInterval(() => {
cooldownSeconds.value -= 1;
if (cooldownSeconds.value <= 0) {
clearInterval(cooldownTimer);
cooldownTimer = null;
}
}, 1000);
};
const prepareGetCode = async () => {
if (sendingCode.value || submitting.value || cooldownSeconds.value > 0) return;
if (!isAuthPhone(phone.value)) {
fieldErrors.value.phone = "请输入正确手机号";
return;
}
fieldErrors.value.phone = "";
if (!agreed.value) {
agreementError.value = true;
return;
}
sendingCode.value = true;
try {
const sceneCode = AUTH_TAC_SCENE.REGISTER;
const requestedPhone = phone.value;
const response = await appApi.getCaptchaRequirement(
{ sceneCode, subject: requestedPhone },
{ requestController: authRequestController },
);
if (!pageActive) return;
if (phone.value !== requestedPhone) throw new Error("手机号已变化,请重新获取验证码");
const requirement = normalizeCaptchaRequirement(response, sceneCode);
tacSequence += 1;
tacContext.value = createTacRenderContext({
requestId: `a04-register-${tacSequence}`,
baseUrl: runtimeConfig.baseUrl,
clientId: runtimeConfig.clientId,
tenantId: runtimeConfig.tenantId,
sceneCode,
subject: requestedPhone,
requirement,
});
tacVisible.value = true;
} catch (error) {
if (pageActive && !isRequestCancelled(error)) {
showFeedback(error.message || "安全验证暂不可用");
}
} finally {
if (pageActive) sendingCode.value = false;
}
};
const completeTac = async (result) => {
const expectedContext = tacContext.value;
if (!expectedContext) return;
try {
const ticket = normalizeTacSuccess(result, expectedContext.requestId);
if (phone.value !== expectedContext.subject) throw new Error("手机号已变化,请重新验证");
closeTac();
sendingCode.value = true;
await appApi.sendSmsCode(
{
sceneCode: AUTH_TAC_SCENE.REGISTER,
phone: expectedContext.subject,
validToken: ticket.validToken,
},
{ requestController: authRequestController },
);
if (!pageActive) return;
startCooldown();
showFeedback("验证码已发送");
} catch (error) {
closeTac();
if (pageActive && !isRequestCancelled(error)) {
showFeedback(error.message || "验证码发送失败");
}
} finally {
if (pageActive) sendingCode.value = false;
}
};
const handleTacFailure = ({ message } = {}) =>
showFeedback(message || "行为验证未通过,请重试");
const handleTacError = ({ message } = {}) => {
closeTac();
showFeedback(message || "安全验证暂不可用");
};
const validateForm = () => {
const nextErrors = { phone: "", password: "", confirmPassword: "" };
if (!/^1\d{10}$/.test(phone.value)) nextErrors.phone = "请输入正确手机号";
const nextErrors = {
phone: "",
verificationCode: "",
password: "",
confirmPassword: "",
};
if (!isAuthPhone(phone.value)) nextErrors.phone = "请输入正确手机号";
if (!/^\d{4}$/.test(verificationCode.value))
nextErrors.verificationCode = "请输入 4 位验证码";
const passwordResult = validatePassword(password.value);
if (!passwordResult.valid) nextErrors.password = PASSWORD_POLICY_MESSAGE;
if (!confirmPassword.value) nextErrors.confirmPassword = "请再次输入密码";
@@ -213,17 +463,34 @@ const validateForm = () => {
return !Object.values(nextErrors).some(Boolean);
};
const submitRegister = () => {
const submitRegister = async () => {
if (submitting.value || sendingCode.value) return;
const formValid = validateForm();
if (!agreed.value) agreementError.value = true;
if (!formValid || !agreed.value) return;
// 真实滑动验证由接口阶段的组件自带样式接管,本页不保留自定义假面板。
showFeedback("滑动验证待接口接入");
submitting.value = true;
try {
await appApi.registerWithPassword(
{
phone: phone.value,
passwordHash: calcMD5(password.value),
smsCode: verificationCode.value,
},
{ requestController: authRequestController },
);
if (!pageActive) return;
await goRoot("G01");
} catch (error) {
if (pageActive && !isRequestCancelled(error)) {
showFeedback(error.message || "注册失败,请稍后重试");
}
} finally {
if (pageActive) submitting.value = false;
}
};
</script>
<style scoped lang="scss">
@use "../../styles/adaptive-frame-profiles.scss" as adaptive;
.register-content {
display: flex;
@@ -320,6 +587,18 @@ const submitRegister = () => {
font-size: 30rpx;
}
.get-code {
flex: 0 0 auto;
padding-left: 14rpx;
border-left: 1rpx solid #d7bd94;
color: #a7160c;
font-size: 26rpx;
}
.get-code--disabled {
color: #9f968d;
}
.placeholder {
color: #9f968d;
}
@@ -380,7 +659,17 @@ const submitRegister = () => {
flex: 0 0 auto;
width: 34rpx;
height: 34rpx;
margin-right: 10rpx;
}
.agreement-toggle {
display: inline-flex;
flex: 0 0 auto;
align-items: center;
justify-content: center;
width: var(--app-touch-min);
min-width: var(--app-touch-min);
min-height: var(--app-touch-min);
margin-right: 2rpx;
}
.agreement-copy {
@@ -389,6 +678,9 @@ const submitRegister = () => {
}
.agreement-link {
display: inline-flex;
align-items: center;
min-height: var(--app-touch-min);
color: #a7160c;
}
.agreement-error {
@@ -437,26 +729,4 @@ const submitRegister = () => {
opacity: 0.84;
}
.feedback-toast {
position: fixed;
z-index: 30;
top: calc(var(--status-bar-height, 0px) + 24rpx);
left: 50%;
display: flex;
align-items: center;
justify-content: center;
@include adaptive.adaptive-feedback-toast;
width: 590rpx;
min-height: 82rpx;
background: transparent;
transform: translateX(-50%);
}
.feedback-toast__copy {
z-index: 1;
padding: 16rpx 36rpx;
color: #5c4330;
font-size: 25rpx;
text-align: center;
}
</style>
+281 -157
View File
@@ -3,13 +3,19 @@
<AuthPageShell class="auth-page reset-page">
<view class="reset-content">
<view class="page-heading">
<view class="back-button" hover-class="tap-fade" @click="goBack">
<button
class="auth-plain-button back-button"
aria-label="返回登录"
hover-class="tap-fade"
@click="requestBack"
>
<image
class="back-button__icon"
src="/static/assets/foundation/transparent/chevron-right.png"
mode="aspectFit"
aria-hidden="true"
/>
</view>
</button>
<text class="page-title">重设密码</text>
<text class="page-subtitle">验证手机号后设置新的登录密码</text>
<image
@@ -25,18 +31,22 @@
:class="{ 'field-block--error': fieldErrors.phone }"
>
<view class="input-row">
<text class="input-label">手机号</text>
<label class="input-label" for="a05-phone">手机号</label>
<input
id="a05-phone"
v-model.trim="phone"
class="auth-input"
type="number"
maxlength="11"
:disabled="sendingCode || cooldownSeconds > 0 || submitting"
placeholder="请输入手机号"
placeholder-class="placeholder"
:aria-invalid="Boolean(fieldErrors.phone)"
:aria-describedby="fieldErrors.phone ? 'a05-phone-error' : undefined"
@input="clearFieldError('phone')"
/>
</view>
<text v-if="fieldErrors.phone" class="field-error">{{
<text v-if="fieldErrors.phone" id="a05-phone-error" class="field-error" role="alert">{{
fieldErrors.phone
}}</text>
</view>
@@ -46,24 +56,30 @@
:class="{ 'field-block--error': fieldErrors.verificationCode }"
>
<view class="input-row code-row">
<text class="input-label">验证码</text>
<label class="input-label" for="a05-verification-code">验证码</label>
<input
id="a05-verification-code"
v-model.trim="verificationCode"
class="auth-input"
type="number"
maxlength="6"
maxlength="4"
:disabled="submitting"
placeholder="请输入验证码"
placeholder-class="placeholder"
:aria-invalid="Boolean(fieldErrors.verificationCode)"
:aria-describedby="fieldErrors.verificationCode ? 'a05-verification-code-error' : undefined"
@input="clearFieldError('verificationCode')"
/>
<view
class="get-code"
<button
class="auth-plain-button get-code"
:class="{ 'get-code--disabled': sendingCode || cooldownSeconds > 0 }"
:disabled="sendingCode || submitting || cooldownSeconds > 0"
hover-class="tap-fade"
@click="prepareGetCode"
>获取验证码</view
>{{ sendingCode ? "请求中…" : cooldownSeconds > 0 ? `${cooldownSeconds}s 后重发` : "获取验证码" }}</button
>
</view>
<text v-if="fieldErrors.verificationCode" class="field-error">{{
<text v-if="fieldErrors.verificationCode" id="a05-verification-code-error" class="field-error" role="alert">{{
fieldErrors.verificationCode
}}</text>
</view>
@@ -73,18 +89,22 @@
:class="{ 'field-block--error': fieldErrors.password }"
>
<view class="input-row">
<text class="input-label">新密码</text>
<label class="input-label" for="a05-password">新密码</label>
<input
id="a05-password"
v-model="password"
class="auth-input"
password
maxlength="32"
:disabled="submitting"
placeholder="请设置新密码"
placeholder-class="placeholder"
:aria-invalid="Boolean(fieldErrors.password)"
:aria-describedby="fieldErrors.password ? 'a05-password-error' : undefined"
@input="clearFieldError('password')"
/>
</view>
<text v-if="fieldErrors.password" class="field-error">{{
<text v-if="fieldErrors.password" id="a05-password-error" class="field-error" role="alert">{{
fieldErrors.password
}}</text>
</view>
@@ -94,25 +114,31 @@
:class="{ 'field-block--error': fieldErrors.confirmPassword }"
>
<view class="input-row">
<text class="input-label">确认新密码</text>
<label class="input-label" for="a05-confirm-password">确认新密码</label>
<input
id="a05-confirm-password"
v-model="confirmPassword"
class="auth-input"
password
maxlength="32"
:disabled="submitting"
placeholder="请再次输入新密码"
placeholder-class="placeholder"
:aria-invalid="Boolean(fieldErrors.confirmPassword)"
:aria-describedby="fieldErrors.confirmPassword ? 'a05-confirm-password-error' : undefined"
@input="clearFieldError('confirmPassword')"
/>
</view>
<text v-if="fieldErrors.confirmPassword" class="field-error">{{
<text v-if="fieldErrors.confirmPassword" id="a05-confirm-password-error" class="field-error" role="alert">{{
fieldErrors.confirmPassword
}}</text>
</view>
</view>
<view
class="reset-submit"
<button
class="auth-plain-button reset-submit"
:disabled="submitting || sendingCode || tacVisible"
:aria-busy="submitting"
hover-class="button-hover"
@click="submitReset"
>
@@ -121,64 +147,92 @@
src="/static/assets/foundation/transparent/a01-scroll-primary-v3.png"
mode="aspectFit"
/>
<text class="reset-submit__content">确认重设</text>
</view>
<text class="reset-submit__content">{{ submitting ? "提交中…" : "确认重设" }}</text>
</button>
<text class="reset-tip">重设成功后请使用新密码登录</text>
<view class="login-entry">
<text>想起密码了</text>
<view
class="login-entry__link"
<button
class="auth-plain-button login-entry__link"
:disabled="submitting || sendingCode || tacVisible"
hover-class="tap-fade"
@click="prepareLogin"
>返回登录</view
@click="requestBack"
>返回登录</button
>
</view>
</view>
<template #overlay>
<view v-if="successVisible" class="success-layer">
<view class="success-dialog">
<image
class="success-dialog__skin"
src="/static/assets/modules/auth/transparent/a01-scroll-dialog-v3.png"
mode="aspectFit"
/>
<view class="success-dialog__content">
<image
class="success-mark"
src="/static/assets/foundation/transparent/brand-seal.png"
mode="aspectFit"
/>
<text class="success-title">密码已重设</text>
<text class="success-copy">请返回登录页使用新密码登录</text>
<view
class="success-action"
hover-class="button-hover"
@click="prepareLogin"
>
<image
class="success-action__skin"
src="/static/assets/foundation/transparent/a01-scroll-primary-v3.png"
mode="aspectFit"
/>
<text class="success-action__copy">返回登录</text>
</view>
</view>
</view>
</view>
<TacVerification
:visible="tacVisible"
:context="tacContext"
@success="completeTac"
@failure="handleTacFailure"
@error="handleTacError"
@cancel="closeTac"
/>
<AppDialog
:visible="discardVisible"
title="放弃重设密码?"
message="当前填写内容尚未保存,确认返回后将清空。"
confirm-text="放弃并返回"
cancel-text="继续填写"
show-cancel
compact-actions
:close-on-mask="false"
@confirm="confirmDiscard"
@cancel="cancelDiscard"
/>
<view v-if="feedbackVisible" class="feedback-toast">
<text class="feedback-toast__copy">{{ feedbackMessage }}</text>
</view>
<AppDialog
:visible="successVisible"
title="密码已重设"
message="请返回登录页,使用新密码登录"
confirm-text="返回登录"
:close-on-mask="false"
@confirm="leaveResetSuccess"
>
<image
class="success-mark"
src="/static/assets/foundation/transparent/brand-seal.png"
mode="aspectFit"
aria-hidden="true"
/>
</AppDialog>
<AppToast :visible="feedbackVisible" :message="feedbackMessage" />
</template>
</AuthPageShell>
</template>
<script setup>
import { ref } from "vue";
import { onUnload } from "@dcloudio/uni-app";
import { computed, ref } from "vue";
import { onBackPress, onUnload } from "@dcloudio/uni-app";
import AppDialog from "@/components/AppDialog.vue";
import AppToast from "@/components/AppToast.vue";
import AuthPageShell from "@/components/AuthPageShell.vue";
import TacVerification from "@/components/TacVerification.vue";
import {
appApi,
createRequestController,
isRequestCancelled,
} from "@/utils/api.js";
import {
AUTH_TAC_SCENE,
createTacRenderContext,
isAuthPhone,
normalizeCaptchaRequirement,
normalizeTacSuccess,
} from "@/utils/auth-verification.js";
import { runtimeConfig } from "@/utils/config.js";
import { createDiscardConfirmation } from "@/utils/discard-confirmation.js";
import { calcMD5 } from "@/utils/md5.js";
import {
handleBackPress,
returnTo,
runBackGuard,
} from "@/utils/navigation.js";
import {
PASSWORD_POLICY_MESSAGE,
validatePassword,
@@ -191,23 +245,78 @@ const confirmPassword = ref("");
const successVisible = ref(false);
const feedbackVisible = ref(false);
const feedbackMessage = ref("");
const discardVisible = ref(false);
const tacVisible = ref(false);
const tacContext = ref(null);
const sendingCode = ref(false);
const submitting = ref(false);
const cooldownSeconds = ref(0);
const fieldErrors = ref({
phone: "",
verificationCode: "",
password: "",
confirmPassword: "",
});
const isDirty = computed(() =>
!successVisible.value &&
Boolean(
phone.value ||
verificationCode.value ||
password.value ||
confirmPassword.value,
),
);
let feedbackTimer = null;
let cooldownTimer = null;
let tacSequence = 0;
let pageActive = true;
const authRequestController = createRequestController();
const discardConfirmation = createDiscardConfirmation((visible) => {
discardVisible.value = visible;
});
const requestDiscardConfirmation = discardConfirmation.request;
const confirmDiscard = discardConfirmation.confirm;
const cancelDiscard = discardConfirmation.cancel;
// 重设成功不产生可跨页消费的数据;只有远端 PUT 已成功后才显示该终态。
const leaveResetSuccess = () => returnTo("A01", {});
const closeTac = () => {
tacVisible.value = false;
tacContext.value = null;
};
const cancelPendingRequest = () => {
authRequestController.abort();
sendingCode.value = false;
submitting.value = false;
};
const requestBack = () => {
if (successVisible.value) return leaveResetSuccess();
return runBackGuard({
transientOpen: tacVisible.value || discardVisible.value,
submitting: submitting.value || sendingCode.value,
dirty: isDirty.value,
"close-transient": tacVisible.value ? closeTac : cancelDiscard,
"block-submitting": () => {
cancelPendingRequest();
return requestBack();
},
"confirm-discard": requestDiscardConfirmation,
});
};
onBackPress((event) => handleBackPress(event, requestBack));
onUnload(() => {
pageActive = false;
authRequestController.abort();
if (feedbackTimer) clearTimeout(feedbackTimer);
if (cooldownTimer) clearInterval(cooldownTimer);
discardConfirmation.dispose();
});
const goBack = () => uni.navigateBack();
// 返回登录时替换当前认证页,避免登录与重设页反复叠加。
const prepareLogin = () => uni.redirectTo({ url: "/pages/auth/a01-entry" });
const showFeedback = (message) => {
feedbackMessage.value = message;
feedbackVisible.value = true;
@@ -222,13 +331,91 @@ const clearFieldError = (field) => {
fieldErrors.value[field] = "";
};
const prepareGetCode = () => {
if (!/^1\d{10}$/.test(phone.value)) {
const startCooldown = () => {
cooldownSeconds.value = 60;
if (cooldownTimer) clearInterval(cooldownTimer);
cooldownTimer = setInterval(() => {
cooldownSeconds.value -= 1;
if (cooldownSeconds.value <= 0) {
clearInterval(cooldownTimer);
cooldownTimer = null;
}
}, 1000);
};
const prepareGetCode = async () => {
if (sendingCode.value || submitting.value || cooldownSeconds.value > 0) return;
if (!isAuthPhone(phone.value)) {
fieldErrors.value.phone = "请输入正确手机号";
return;
}
fieldErrors.value.phone = "";
showFeedback("滑动验证待接口接入");
sendingCode.value = true;
try {
const sceneCode = AUTH_TAC_SCENE.FORGOT_PASSWORD;
const requestedPhone = phone.value;
const response = await appApi.getCaptchaRequirement(
{ sceneCode, subject: requestedPhone },
{ requestController: authRequestController },
);
if (!pageActive) return;
if (phone.value !== requestedPhone) throw new Error("手机号已变化,请重新获取验证码");
const requirement = normalizeCaptchaRequirement(response, sceneCode);
tacSequence += 1;
tacContext.value = createTacRenderContext({
requestId: `a05-forgot-${tacSequence}`,
baseUrl: runtimeConfig.baseUrl,
clientId: runtimeConfig.clientId,
tenantId: runtimeConfig.tenantId,
sceneCode,
subject: requestedPhone,
requirement,
});
tacVisible.value = true;
} catch (error) {
if (pageActive && !isRequestCancelled(error)) {
showFeedback(error.message || "安全验证暂不可用");
}
} finally {
if (pageActive) sendingCode.value = false;
}
};
const completeTac = async (result) => {
const expectedContext = tacContext.value;
if (!expectedContext) return;
try {
const ticket = normalizeTacSuccess(result, expectedContext.requestId);
if (phone.value !== expectedContext.subject) throw new Error("手机号已变化,请重新验证");
closeTac();
sendingCode.value = true;
await appApi.sendSmsCode(
{
sceneCode: AUTH_TAC_SCENE.FORGOT_PASSWORD,
phone: expectedContext.subject,
validToken: ticket.validToken,
},
{ requestController: authRequestController },
);
if (!pageActive) return;
startCooldown();
showFeedback("验证码已发送");
} catch (error) {
closeTac();
if (pageActive && !isRequestCancelled(error)) {
showFeedback(error.message || "验证码发送失败");
}
} finally {
if (pageActive) sendingCode.value = false;
}
};
const handleTacFailure = ({ message } = {}) =>
showFeedback(message || "行为验证未通过,请重试");
const handleTacError = ({ message } = {}) => {
closeTac();
showFeedback(message || "安全验证暂不可用");
};
const validateForm = () => {
@@ -238,9 +425,9 @@ const validateForm = () => {
password: "",
confirmPassword: "",
};
if (!/^1\d{10}$/.test(phone.value)) nextErrors.phone = "请输入正确手机号";
if (!/^\d{6}$/.test(verificationCode.value))
nextErrors.verificationCode = "请输入 6 位验证码";
if (!isAuthPhone(phone.value)) nextErrors.phone = "请输入正确手机号";
if (!/^\d{4}$/.test(verificationCode.value))
nextErrors.verificationCode = "请输入 4 位验证码";
const passwordResult = validatePassword(password.value);
if (!passwordResult.valid) nextErrors.password = PASSWORD_POLICY_MESSAGE;
if (!confirmPassword.value) nextErrors.confirmPassword = "请再次输入新密码";
@@ -250,14 +437,32 @@ const validateForm = () => {
return !Object.values(nextErrors).some(Boolean);
};
const submitReset = () => {
const submitReset = async () => {
if (submitting.value || sendingCode.value) return;
if (!validateForm()) return;
successVisible.value = true;
submitting.value = true;
try {
await appApi.resetPassword(
{
phone: phone.value,
passwordHash: calcMD5(password.value),
smsCode: verificationCode.value,
},
{ requestController: authRequestController },
);
if (!pageActive) return;
successVisible.value = true;
} catch (error) {
if (pageActive && !isRequestCancelled(error)) {
showFeedback(error.message || "密码重设失败,请稍后重试");
}
} finally {
if (pageActive) submitting.value = false;
}
};
</script>
<style scoped lang="scss">
@use "../../styles/adaptive-frame-profiles.scss" as adaptive;
.reset-content {
display: flex;
@@ -358,6 +563,9 @@ const submitReset = () => {
color: #a7160c;
font-size: 26rpx;
}
.get-code--disabled {
color: #9f968d;
}
.field-error {
display: block;
padding-top: 4rpx;
@@ -377,8 +585,7 @@ const submitReset = () => {
margin-top: 26rpx;
}
.reset-submit__skin,
.success-action__skin {
.reset-submit__skin {
grid-area: 1 / 1;
width: 100%;
height: 100%;
@@ -414,93 +621,10 @@ const submitReset = () => {
color: #a7160c;
}
.success-layer {
position: fixed;
z-index: 20;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
box-sizing: border-box;
padding: 42rpx;
background: rgba(35, 18, 10, 0.62);
}
.success-dialog {
display: grid;
width: 620rpx;
min-height: 520rpx;
max-height: calc(var(--app-viewport-height) - 40px);
}
.success-dialog__skin {
grid-area: 1 / 1;
width: 100%;
height: 100%;
}
.success-dialog__content {
z-index: 1;
display: flex;
grid-area: 1 / 1;
flex-direction: column;
align-items: center;
box-sizing: border-box;
min-height: 100%;
overflow-y: auto;
padding: 64rpx 64rpx 42rpx;
text-align: center;
}
.success-title {
color: #8f160f;
font-size: 43rpx;
font-weight: 700;
letter-spacing: 4rpx;
}
.success-copy {
margin-top: 18rpx;
color: #513a28;
font-size: 26rpx;
line-height: 40rpx;
}
.success-mark {
width: 82rpx;
height: 82rpx;
margin-bottom: 20rpx;
}
.success-action {
display: grid;
place-items: center;
width: 100%;
min-height: 82rpx;
margin-top: 24rpx;
}
.success-action__copy {
z-index: 1;
grid-area: 1 / 1;
color: #fffaf0;
font-size: 30rpx;
letter-spacing: 4rpx;
}
.feedback-toast {
position: fixed;
z-index: 30;
top: calc(var(--status-bar-height, 0px) + 24rpx);
left: 50%;
display: flex;
align-items: center;
justify-content: center;
@include adaptive.adaptive-feedback-toast;
width: 590rpx;
min-height: 82rpx;
background: transparent;
transform: translateX(-50%);
}
.feedback-toast__copy {
z-index: 1;
padding: 16rpx 36rpx;
color: #5c4330;
font-size: 25rpx;
text-align: center;
margin: 8rpx auto 12rpx;
}
.tap-fade,
.button-hover {
+17 -15
View File
@@ -3,7 +3,7 @@
<AuthPageShell class="auth-page status-page">
<view class="status-content">
<view class="page-heading">
<view class="back-button" hover-class="tap-fade" @click="goBack">
<view class="back-button" hover-class="tap-fade" @click="requestBack">
<image
class="back-button__icon"
src="/static/assets/foundation/transparent/chevron-right.png"
@@ -69,7 +69,7 @@
/>
<text class="status-primary__copy">查看恢复方式</text>
</view>
<view class="status-secondary" hover-class="tap-fade" @click="goLogin"
<view class="status-secondary" hover-class="tap-fade" @click="requestBack"
>返回登录</view
>
</view>
@@ -113,8 +113,9 @@
<script setup>
import { computed, ref } from "vue";
import { onLoad, onShow } from "@dcloudio/uni-app";
import { onBackPress, onLoad } from "@dcloudio/uni-app";
import AuthPageShell from "@/components/AuthPageShell.vue";
import { goRoot, handleBackPress, runBackGuard } from "@/utils/navigation.js";
const status = ref("risk");
const recoveryVisible = ref(false);
@@ -165,15 +166,8 @@ const statusConfig = {
const currentState = computed(() => statusConfig[status.value]);
const resolveStatus = () => {
let requestedStatus = "risk";
if (typeof location !== "undefined") {
const query = new URLSearchParams(location.hash.split("?")[1] || "");
requestedStatus = query.get("status") || "risk";
} else {
const pages = getCurrentPages();
requestedStatus = pages[pages.length - 1]?.options?.status || "risk";
}
const resolveStatus = (options = {}) => {
const requestedStatus = options.status || "risk";
status.value = Object.prototype.hasOwnProperty.call(
statusConfig,
requestedStatus,
@@ -184,16 +178,24 @@ const resolveStatus = () => {
};
onLoad(resolveStatus);
onShow(resolveStatus);
const goLogin = () => uni.redirectTo({ url: "/pages/auth/a01-entry" });
const openRecovery = () => {
recoveryVisible.value = true;
};
const closeRecovery = () => {
recoveryVisible.value = false;
};
const goBack = () => uni.navigateBack();
const requestBack = () => {
if (recoveryVisible.value) {
return runBackGuard({
transientOpen: true,
"close-transient": closeRecovery,
});
}
return goRoot("A01");
};
onBackPress((event) => handleBackPress(event, requestBack));
</script>
<style scoped lang="scss">
+100 -54
View File
@@ -9,9 +9,9 @@
}"
>
<ModulePageBackground module="family" />
<view class="family-page__header"
><PageHeader title="家族动态" action="发布" @action="toPublish"
/></view>
<view class="family-page__header">
<PageHeader root title="家族动态" :action="hasValidContext ? '发布' : ''" @action="toPublish" />
</view>
<view class="feed-content">
<AppLoading
v-if="feedState === 'loading'"
@@ -19,9 +19,9 @@
description="请稍候,正在读取家宴、通知与共同记忆。"
/>
<view v-if="feedState !== 'loading'" class="feed-heading"
><text>汤氏家族圈</text><text>家宴通知与共同记忆</text></view
><text>{{ familyTitle }}</text><text>家宴通知与共同记忆</text></view
>
<view v-if="feedState !== 'loading'" class="feed-shortcuts">
<view v-if="feedState !== 'loading' && hasValidContext" class="feed-shortcuts">
<view
v-for="item in shortcuts"
:key="item.key"
@@ -49,56 +49,67 @@
</template>
<view v-else-if="feedState !== 'loading'" class="feed-state-card">
<view class="feed-state-card__copy"
><text>{{
feedState === "empty" ? "还没有家族动态" : "家族动态暂不可用"
}}</text
><text>{{
feedState === "empty"
? "发布第一条通知、家宴记录或家族故事。"
: "请稍后重新进入,已有内容不会受到影响。"
}}</text></view
><text>{{ stateCopy.title }}</text
><text>{{ stateCopy.copy }}</text></view
>
</view>
<view
v-if="feedState !== 'loading'"
class="feed-action"
@click="feedState === 'error' ? (feedState = 'list') : toPublish()"
><text>{{
feedState === "error" ? "重新查看" : "发布家族动态"
}}</text></view
@click="handlePrimaryAction"
><text>{{ stateCopy.action }}</text></view
>
</view>
<AppTabbar active="family" />
</view>
</template>
<script setup>
import { ref } from "vue";
import { computed, ref } from "vue";
import { onLoad } from "@dcloudio/uni-app";
import AppLoading from "@/components/AppLoading.vue";
import PageHeader from "@/components/PageHeader.vue";
import AppTabbar from "@/components/AppTabbar.vue";
import ModulePageBackground from "@/components/ModulePageBackground.vue";
import {
findGenealogyFixture,
getGenealogyFixtureAccess,
listFamilyFeedFixtures,
} from "@/data/mock.js";
import { genealogyContext } from "@/utils/genealogy-context.js";
import { goRoot, openPage } from "@/utils/navigation.js";
const genealogyId = ref("");
const genealogy = ref(null);
const hasValidContext = ref(false);
const feedState = ref("loading");
const feeds = [
{
id: 1,
tag: "团圆记忆",
time: "今天 10:24",
title: "端午家宴",
content: "今年端午全家相聚,留下了许多温暖照片。",
author: "汤正国",
},
{
id: 2,
tag: "家族通知",
time: "昨天 18:02",
title: "修谱资料征集",
content: "请家人补充老照片中的人物姓名与拍摄时间。",
author: "谱主",
},
];
const feeds = ref([]);
const familyTitle = computed(() =>
genealogy.value ? `${genealogy.value.name}家族圈` : "家族圈",
);
const stateCopy = computed(() => {
if (!hasValidContext.value) {
return {
title: "请先选择可访问的家谱",
copy: "家族动态必须归属明确家谱,页面不会展示其他家谱的内容。",
action: "返回我的家谱",
};
}
if (feedState.value === "empty") {
return {
title: "还没有家族动态",
copy: "可先查看其他家族内容;发布接口接入后才能新增动态。",
action: "填写动态预览",
};
}
if (feedState.value === "error") {
return {
title: "家族动态暂不可用",
copy: "请稍后重新查看,已有内容不会受到影响。",
action: "重新查看",
};
}
return { title: "", copy: "", action: "发布家族动态" };
});
const shortcuts = [
{ key: "articles", label: "谱文" },
{ key: "albums", label: "相册" },
@@ -110,8 +121,30 @@ const shortcuts = [
{ key: "videos", label: "家族视频" },
];
onLoad((query) => {
genealogyId.value =
query.genealogyId || genealogyContext.getCurrentGenealogyId() || "";
const hasRouteIdentity = Object.prototype.hasOwnProperty.call(query, "genealogyId");
const resolvedGenealogyId = hasRouteIdentity
? String(query.genealogyId || "")
: String(genealogyContext.getCurrentGenealogyId() || "");
genealogyId.value = resolvedGenealogyId;
genealogy.value = findGenealogyFixture(resolvedGenealogyId);
const access = getGenealogyFixtureAccess(resolvedGenealogyId);
const isAccessible = Boolean(
genealogy.value && ["owner", "member"].includes(access.accessRole),
);
if (!isAccessible) {
feeds.value = [];
feedState.value = "error";
return;
}
if (!hasRouteIdentity) {
goRoot("F01", { genealogyId: resolvedGenealogyId }).catch(() => {
hasValidContext.value = false;
feedState.value = "error";
});
return;
}
hasValidContext.value = isAccessible;
feeds.value = listFamilyFeedFixtures(resolvedGenealogyId);
feedState.value =
query.state === "loading"
? "loading"
@@ -119,28 +152,41 @@ onLoad((query) => {
? "empty"
: query.state === "error"
? "error"
: "list";
: feeds.value.length
? "list"
: "empty";
});
const toPublish = () =>
uni.navigateTo({
url: `/pages/family/f02-publish-feed?genealogyId=${genealogyId.value}`,
});
hasValidContext.value
? openPage("F02", { genealogyId: genealogyId.value }, "F01")
: goRoot("G01");
const openDetail = (item) =>
uni.navigateTo({ url: `/pages/family/f03-feed-detail?feedId=${item.id}` });
openPage(
"F03",
{ genealogyId: genealogyId.value, feedId: String(item.id) },
"F01",
);
const openSection = (key) => {
const routes = {
articles: "/pages/family/f04-article-list",
albums: "/pages/family/f07-album-list",
rituals: "/pages/records/r05-ritual-list",
memos: "/pages/records/r10-memo-list",
people: "/pages/records/r01-people-list",
gifts: "/pages/records/r03-gift-list",
merits: "/pages/records/r11-merit-records",
videos: "/pages/family/f10-video-list",
articles: "F04",
albums: "F07",
rituals: "R05",
memos: "R10",
people: "R01",
gifts: "R03",
merits: "R11",
videos: "F10",
};
uni.navigateTo({
url: `${routes[key]}?genealogyId=${genealogyId.value}`,
});
return openPage(routes[key], { genealogyId: genealogyId.value }, "F01");
};
const handlePrimaryAction = () => {
if (!hasValidContext.value) return goRoot("G01");
if (feedState.value === "error") {
feeds.value = listFamilyFeedFixtures(genealogyId.value);
feedState.value = feeds.value.length ? "list" : "empty";
return;
}
return toPublish();
};
</script>
<style scoped lang="scss">
+115 -23
View File
@@ -4,11 +4,12 @@
class="publish-page"
:class="{
'publish-state--form': publishState === 'form',
'publish-state--success': publishState === 'success',
'publish-state--preview': publishState === 'preview',
'publish-state--error': publishState === 'error',
'publish-state--invalid': publishState === 'invalid',
}"
><ModulePageBackground module="family" /><view class="publish-page__header"
><PageHeader title="发布动态" /></view
><PageHeader title="发布动态" custom-back @back="requestBack" /></view
><view class="publish-panel"
><view
v-if="publishState === 'form'"
@@ -23,42 +24,99 @@
placeholder="写下想对家人说的话"
placeholder-class="publish-placeholder"
/></view
><AppButton block label="发布动态" @click="submit" /></view
><view v-else class="publish-result"
><text>{{
publishState === "success" ? "动态已发布" : "动态未发布"
}}</text
><text>{{
publishState === "success"
? "家人现在可以在家族圈看到这条记录。"
: "请检查内容后重新发布,当前文字仍保留在页面中。"
}}</text
><AppButton
block
:label="publishState === 'success' ? '继续发布' : '重新填写'"
@click="publishState = 'form'" /></view></view
:label="isSubmitting ? '正在校验' : '生成本地预览'"
:disabled="isSubmitting"
@click="submit"
/></view
><view v-else class="publish-result"
><text>{{ resultCopy.title }}</text
><text>{{ resultCopy.copy }}</text
><AppButton
block
:label="resultCopy.action"
@click="handleResultAction" /></view></view
><AppDialog
:visible="discardVisible"
title="放弃动态草稿?"
message="当前内容尚未提交服务器,确认返回后不会保留。"
confirm-text="放弃并返回"
cancel-text="继续填写"
show-cancel
:close-on-mask="false"
@confirm="confirmDiscard"
@cancel="cancelDiscard"
/>
><AppToast :visible="toastVisible" :message="toastMessage"
/></view>
</template>
<script setup>
import { onUnmounted, ref } from "vue";
import { onLoad } from "@dcloudio/uni-app";
import { computed, onUnmounted, ref } from "vue";
import { onBackPress, onLoad } from "@dcloudio/uni-app";
import AppButton from "@/components/AppButton.vue";
import AppDialog from "@/components/AppDialog.vue";
import AppToast from "@/components/AppToast.vue";
import ModulePageBackground from "@/components/ModulePageBackground.vue";
import PageHeader from "@/components/PageHeader.vue";
import { getGenealogyFixtureAccess } from "@/data/mock.js";
import { createDiscardConfirmation } from "@/utils/discard-confirmation.js";
import {
goBack,
handleBackPress,
returnTo,
runBackGuard,
} from "@/utils/navigation.js";
const genealogyId = ref("");
const content = ref("");
const publishState = ref("form");
const isSubmitting = ref(false);
const discardVisible = ref(false);
const toastVisible = ref(false);
const toastMessage = ref("");
let toastTimer = null;
let submitTimer = null;
const isDirty = computed(() => Boolean(content.value.trim()));
const hasValidContext = computed(() =>
["owner", "member"].includes(
getGenealogyFixtureAccess(genealogyId.value).accessRole,
),
);
const resultCopy = computed(() => ({
preview: {
title: "动态内容已完成本地预览",
copy: "当前尚未提交服务器,返回家族圈后不会出现这条动态。",
action: "返回家族圈(不发布)",
},
error: {
title: "动态未提交",
copy: "当前文字仍保留在页面中,可返回表单继续核对。",
action: "返回填写",
},
invalid: {
title: "动态入口无效",
copy: "没有找到可发布内容的成员家谱,页面不会创建无归属动态。",
action: "返回上一页",
},
}[publishState.value]));
const discardConfirmation = createDiscardConfirmation((visible) => {
discardVisible.value = visible;
});
const requestDiscardConfirmation = discardConfirmation.request;
const confirmDiscard = discardConfirmation.confirm;
const cancelDiscard = discardConfirmation.cancel;
onLoad((query) => {
publishState.value =
query.state === "success"
? "success"
: query.state === "error"
? "error"
: "form";
genealogyId.value = String(query.genealogyId || "");
if (!genealogyId.value || !hasValidContext.value) {
publishState.value = "invalid";
return;
}
if (["preview", "error"].includes(query.state)) {
content.value = "这是一段尚未提交服务器的家族动态预览。";
publishState.value = query.state;
}
});
const showToast = (message) => {
toastMessage.value = message;
@@ -70,14 +128,48 @@ const showToast = (message) => {
}, 1800);
};
const submit = () => {
if (isSubmitting.value || !hasValidContext.value) return;
if (!content.value.trim()) {
showToast("请先写下动态内容");
return;
}
publishState.value = "success";
isSubmitting.value = true;
const submittedContent = content.value.trim();
const timer = setTimeout(() => {
if (submitTimer !== timer) return;
submitTimer = null;
isSubmitting.value = false;
publishState.value = submittedContent ? "preview" : "error";
}, 280);
submitTimer = timer;
};
const requestBack = () =>
runBackGuard({
transientOpen: discardVisible.value,
dirty: isDirty.value,
submitting: isSubmitting.value,
"close-transient": cancelDiscard,
"block-submitting": () => true,
"confirm-discard": requestDiscardConfirmation,
});
const returnToFamily = async () => {
const confirmed = isDirty.value ? await requestDiscardConfirmation() : true;
if (!confirmed) return false;
return returnTo("F01", { genealogyId: genealogyId.value });
};
const handleResultAction = () => {
if (publishState.value === "preview") return returnToFamily();
if (publishState.value === "error") {
publishState.value = "form";
return;
}
return goBack();
};
onBackPress((event) => handleBackPress(event, requestBack));
onUnmounted(() => {
if (toastTimer) clearTimeout(toastTimer);
if (submitTimer) clearTimeout(submitTimer);
discardConfirmation.dispose();
});
</script>
<style scoped lang="scss">
+91 -38
View File
@@ -2,7 +2,7 @@
<template>
<view class="feed-detail-page" :class="{ 'feed-state--expired': feedState === 'expired', 'feed-state--error': feedState === 'error', 'feed-state--ready': feedState === 'ready' }">
<ModulePageBackground module="family" />
<view class="feed-detail-header"><PageHeader title="动态详情" /></view>
<view class="feed-detail-header"><PageHeader title="动态详情" custom-back @back="requestBack" /></view>
<view v-if="feedState === 'loading'" class="feed-detail-loading">
<AppLoading text="正在读取家族动态" description="请稍候,正在整理正文与家人评论。" />
@@ -30,93 +30,146 @@
</view>
</view>
<view class="feed-comment-form" :class="{ 'comment-state--saving': commentState === 'saving', 'comment-state--error': commentState === 'error' }">
<view class="feed-comment-form" :class="{ 'comment-state--validating': commentState === 'validating', 'comment-state--preview': commentState === 'preview', 'comment-state--error': commentState === 'error' }">
<text>写下评论</text>
<textarea v-model="commentDraft" auto-height maxlength="240" placeholder="对家人说点什么" />
<text v-if="commentError" class="feed-comment-error">{{ commentError }}</text>
<AppButton block :disabled="commentState === 'saving'" :label="commentState === 'saving' ? '正在发送' : '发送评论'" @click="submitComment" />
<AppButton block :disabled="commentState === 'validating'" :label="commentState === 'validating' ? '正在校验' : '生成评论预览'" @click="submitComment" />
</view>
</template>
<view v-else class="feed-state-card">
<text>{{ feedState === 'expired' ? '动态已失效' : '动态暂不可用' }}</text>
<text>{{ feedState === 'expired' ? '这条动态可能已被发布人删除,请返回家族圈查看其他内容。' : '请稍后重新查看,已有家族记录不会受到影响。' }}</text>
<AppButton :type="feedState === 'error' ? 'secondary' : 'primary'" block :label="feedState === 'error' ? '重新查看' : '返回家族圈'" @click="feedState === 'error' ? restoreFeed() : backToFamily()" />
<text>{{ stateCopy.title }}</text>
<text>{{ stateCopy.copy }}</text>
<AppButton :type="feedState === 'error' ? 'secondary' : 'primary'" block :label="stateCopy.action" @click="handleStateAction" />
</view>
</view>
<AppToast :visible="toastVisible" message="评论已发送" />
<AppDialog
:visible="discardVisible"
title="放弃评论草稿?"
message="当前评论尚未提交服务器,确认返回后不会保留。"
confirm-text="放弃并返回"
cancel-text="继续填写"
show-cancel
:close-on-mask="false"
@confirm="confirmDiscard"
@cancel="cancelDiscard"
/>
<AppToast :visible="toastVisible" message="评论尚未提交服务器,草稿已保留" />
</view>
</template>
<script setup>
import { onUnmounted, ref } from "vue";
import { onLoad } from "@dcloudio/uni-app";
import { computed, onUnmounted, ref } from "vue";
import { onBackPress, onLoad } from "@dcloudio/uni-app";
import AppButton from "@/components/AppButton.vue";
import AppDialog from "@/components/AppDialog.vue";
import AppLoading from "@/components/AppLoading.vue";
import AppToast from "@/components/AppToast.vue";
import ModulePageBackground from "@/components/ModulePageBackground.vue";
import PageHeader from "@/components/PageHeader.vue";
import { findFamilyFeedFixture } from "@/data/mock.js";
import { createDiscardConfirmation } from "@/utils/discard-confirmation.js";
import {
goBack,
handleBackPress,
returnTo,
runBackGuard,
} from "@/utils/navigation.js";
const feedRecords = [
{ id: "1", tag: "团圆记忆", time: "今天 10:24", title: "端午家宴", content: "今年端午全家相聚,长辈讲起祖居旧事,孩子们也为大家拍下了新的全家福。饭后我们把照片和口述片段整理进家族档案,让这份热闹成为往后仍能翻看的共同记忆。", author: "汤正国" },
{ id: "2", tag: "家族通知", time: "昨天 18:02", title: "修谱资料征集", content: "请家人补充老照片中的人物姓名、拍摄时间和地点。无法确认的信息也可以先写下线索,由熟悉往事的长辈共同核对。", author: "谱主" },
];
const feedId = ref("1");
const currentFeed = ref(feedRecords[0]);
const genealogyId = ref("");
const feedId = ref("");
const currentFeed = ref(null);
const feedState = ref("loading");
const commentState = ref("idle");
const commentDraft = ref("");
const commentError = ref("");
const toastVisible = ref(false);
const forceCommentFailure = ref(false);
const feedComments = ref([
{ id: 1, author: "汤淑华", time: "今天 10:42", content: "一家人能常常相聚,就是最珍贵的福气。" },
{ id: 2, author: "汤文清", time: "今天 11:08", content: "照片已经整理好了,晚些时候放进春节团圆相册。" },
]);
const discardVisible = ref(false);
const feedComments = ref([]);
let submitTimer = null;
let toastTimer = null;
const isDirty = computed(() => Boolean(commentDraft.value.trim()));
const stateCopy = computed(() => {
if (feedState.value === "error" && currentFeed.value) {
return {
title: "动态暂不可用",
copy: "请稍后重新查看,已有家族记录不会受到影响。",
action: "重新查看",
};
}
return {
title: "动态已失效或入口无效",
copy: "没有找到当前家谱中的这条动态,页面不会回退到其他记录。",
action: genealogyId.value ? "返回家族圈" : "返回上一页",
};
});
const discardConfirmation = createDiscardConfirmation((visible) => {
discardVisible.value = visible;
});
const requestDiscardConfirmation = discardConfirmation.request;
const confirmDiscard = discardConfirmation.confirm;
const cancelDiscard = discardConfirmation.cancel;
onLoad((query) => {
feedId.value = String(query.feedId || "1");
const selected = feedRecords.find((item) => item.id === feedId.value);
currentFeed.value = selected || feedRecords[0];
forceCommentFailure.value = query.commentResult === "error";
genealogyId.value = String(query.genealogyId || "");
feedId.value = String(query.feedId || "");
const selected = findFamilyFeedFixture(genealogyId.value, feedId.value);
currentFeed.value = selected;
feedComments.value = selected?.comments || [];
feedState.value = ["loading", "error", "expired"].includes(query.state)
? query.state
? selected
? query.state
: "expired"
: selected
? "ready"
: "expired";
});
const submitComment = () => {
if (commentState.value === "saving") return;
if (commentState.value === "validating" || !currentFeed.value) return;
const content = commentDraft.value.trim();
if (!content) {
commentError.value = "请先写下评论内容";
return;
}
commentError.value = "";
commentState.value = "saving";
submitTimer = setTimeout(() => {
if (forceCommentFailure.value) {
commentState.value = "error";
commentError.value = "评论发送失败,请保留文字后重试";
forceCommentFailure.value = false;
return;
}
feedComments.value.push({ id: Date.now(), author: "我", time: "刚刚", content });
commentDraft.value = "";
commentState.value = "idle";
commentState.value = "validating";
const timer = setTimeout(() => {
if (submitTimer !== timer) return;
submitTimer = null;
commentState.value = "preview";
toastVisible.value = true;
toastTimer = setTimeout(() => { toastVisible.value = false; }, 1800);
}, 320);
submitTimer = timer;
};
const restoreFeed = () => { feedState.value = "ready"; };
const backToFamily = () => uni.redirectTo({ url: "/pages/family/f01-family-feed" });
const requestBack = () =>
runBackGuard({
transientOpen: discardVisible.value,
dirty: isDirty.value,
submitting: commentState.value === "validating",
"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" && currentFeed.value) return restoreFeed();
return backToFamily();
};
onBackPress((event) => handleBackPress(event, requestBack));
onUnmounted(() => {
if (submitTimer) clearTimeout(submitTimer);
if (toastTimer) clearTimeout(toastTimer);
discardConfirmation.dispose();
});
</script>
+66 -20
View File
@@ -2,7 +2,7 @@
<template>
<view class="article-list-page" :class="{ 'article-list-state--loading': listState === 'loading', 'article-list-state--empty': listState === 'empty', 'article-list-state--error': listState === 'error' }">
<ModulePageBackground module="family" />
<view class="article-list-header"><PageHeader title="谱文" action="新建" @action="createArticle" /></view>
<view class="article-list-header"><PageHeader title="谱文" :action="hasValidContext ? '新建' : ''" @action="createArticle" /></view>
<view v-if="listState === 'loading'" class="article-list-loading">
<AppLoading text="正在整理家族谱文" description="请稍候,正在读取家训、往事与序言。" />
@@ -37,9 +37,9 @@
</template>
<view v-else class="article-list-state-card">
<text>{{ listState === 'empty' ? '还没有谱文' : '谱文列表暂不可用' }}</text>
<text>{{ listState === 'empty' ? '记录第一篇家风家训或家族往事。' : '请稍后重新查看,已有谱文不会受到影响。' }}</text>
<AppButton :type="listState === 'error' ? 'secondary' : 'primary'" block :label="listState === 'error' ? '重新查看' : '新建谱文'" @click="listState === 'error' ? restoreArticles() : createArticle()" />
<text>{{ stateCopy.title }}</text>
<text>{{ stateCopy.copy }}</text>
<AppButton :type="listState === 'error' ? 'secondary' : 'primary'" block :label="stateCopy.action" @click="handleStateAction" />
</view>
</view>
</view>
@@ -52,14 +52,16 @@ import AppButton from "@/components/AppButton.vue";
import AppLoading from "@/components/AppLoading.vue";
import ModulePageBackground from "@/components/ModulePageBackground.vue";
import PageHeader from "@/components/PageHeader.vue";
import {
getGenealogyFixtureAccess,
listFamilyArticleFixtures,
} from "@/data/mock.js";
import { goBack, openPage } from "@/utils/navigation.js";
const articleCategories = ["全部", "家风家训", "家族往事", "族谱序言"];
const baseArticles = [
{ id: "101", category: "家风家训", title: "孝友传家的日常", summary: "从敬老、睦亲与守信的小事里,看见家风如何代代相传。", author: "汤文正", updatedAt: "今天更新" },
{ id: "102", category: "家族往事", title: "祖居门前的那棵桂花树", summary: "长辈口述的旧居记忆,以及每年中秋一家人相聚的故事。", author: "汤淑华", updatedAt: "昨天更新" },
{ id: "103", category: "族谱序言", title: "续修族谱序", summary: "说明本次续修的缘起、资料来源与共同参与的家人。", author: "谱主", updatedAt: "5 月 12 日" },
];
const articles = ref([...baseArticles]);
const genealogyId = ref("");
const hasValidContext = ref(false);
const articles = ref([]);
const activeCategory = ref("全部");
const keyword = ref("");
const listState = ref("loading");
@@ -71,20 +73,64 @@ const filteredArticles = computed(() => {
return categoryMatched && keywordMatched;
});
});
const stateCopy = computed(() => ({
empty: {
title: "还没有谱文",
copy: "当前家谱尚无可读取谱文,真实写接口接入后才能新增。",
action: "填写谱文预览",
},
error: {
title: "谱文列表暂不可用",
copy: "请稍后重新查看,已有谱文不会受到影响。",
action: "重新查看",
},
invalid: {
title: "谱文入口无效",
copy: "没有找到可访问的成员家谱,页面不会展示其他家谱内容。",
action: "返回上一页",
},
}[listState.value]));
onLoad((query) => {
const count = Math.max(1, Math.min(Number(query.count) || baseArticles.length, 50));
articles.value = Array.from({ length: count }, (_, index) => ({
...baseArticles[index % baseArticles.length],
id: String(101 + index),
title: count > baseArticles.length ? `${baseArticles[index % baseArticles.length].title}(第 ${index + 1} 篇)` : baseArticles[index].title,
}));
listState.value = ["loading", "empty", "error"].includes(query.state) ? query.state : "ready";
genealogyId.value = String(query.genealogyId || "");
hasValidContext.value = ["owner", "member"].includes(
getGenealogyFixtureAccess(genealogyId.value).accessRole,
);
if (!genealogyId.value || !hasValidContext.value) {
listState.value = "invalid";
return;
}
articles.value = listFamilyArticleFixtures(genealogyId.value);
listState.value = ["loading", "empty", "error"].includes(query.state)
? query.state
: articles.value.length
? "ready"
: "empty";
});
const openArticle = (article) => uni.navigateTo({ url: `/pages/family/f05-article-detail?articleId=${article.id}` });
const createArticle = () => uni.navigateTo({ url: "/pages/family/f06-article-editor?mode=create" });
const restoreArticles = () => { listState.value = "ready"; };
const openArticle = (article) =>
openPage(
"F05",
{ genealogyId: genealogyId.value, articleId: String(article.id) },
"F04",
);
const createArticle = () =>
hasValidContext.value
? openPage(
"F06",
{ genealogyId: genealogyId.value, mode: "create" },
"F04",
)
: Promise.resolve(false);
const restoreArticles = () => {
articles.value = listFamilyArticleFixtures(genealogyId.value);
listState.value = articles.value.length ? "ready" : "empty";
};
const handleStateAction = () => {
if (listState.value === "invalid") return goBack();
if (listState.value === "error") return restoreArticles();
return createArticle();
};
const resetFilters = () => { keyword.value = ""; activeCategory.value = "全部"; };
</script>
+36 -29
View File
@@ -19,7 +19,7 @@
</view>
</view>
<view class="article-actions">
<AppButton block :label="favorite ? '已收藏谱文' : '收藏谱文'" @click="toggleFavorite" />
<AppButton block disabled label="收藏暂未开放" />
<AppButton type="secondary" block label="编辑谱文" @click="editArticle" />
</view>
</template>
@@ -30,31 +30,24 @@
<AppButton :type="articleState === 'error' ? 'secondary' : 'primary'" block :label="stateCopy.action" @click="handleStateAction" />
</view>
</view>
<AppToast :visible="toastVisible" :message="favorite ? '已收藏谱文' : '已取消收藏'" />
</view>
</template>
<script setup>
import { computed, onUnmounted, reactive, ref } from "vue";
import { computed, ref } from "vue";
import { onLoad } from "@dcloudio/uni-app";
import AppButton from "@/components/AppButton.vue";
import AppLoading from "@/components/AppLoading.vue";
import AppToast from "@/components/AppToast.vue";
import ModulePageBackground from "@/components/ModulePageBackground.vue";
import PageHeader from "@/components/PageHeader.vue";
import { findFamilyArticleFixture } from "@/data/mock.js";
import { goBack, openPage, returnTo } from "@/utils/navigation.js";
const articleRecords = [
{ id: "101", category: "家风家训", title: "孝友传家的日常", author: "汤文正", updatedAt: "2024 年 5 月 12 日", paragraphs: ["孝友传家,不只在族谱序言里,也在一家人每日的言行中。长辈以宽厚待晚辈,晚辈以耐心照料长辈,亲友之间守信互助,便是最朴素也最长久的家风。", "勤俭并非一味节省,而是珍惜所得、量入为出,也愿意在家人需要时伸出援手。家中每一代人都可以用自己的方式,把这份分寸与担当继续传下去。", "敬祖睦宗,最终是为了让今天的家人彼此认识、彼此关心。记录姓名与世代之外,也应留下真实的生活、共同经历和温暖记忆。"] },
{ id: "102", category: "家族往事", title: "祖居门前的那棵桂花树", author: "汤淑华", updatedAt: "2024 年 5 月 10 日", paragraphs: ["祖居门前曾有一棵桂花树。每到中秋,院里都是清甜的香气,远道回来的家人也总能循着那股味道找到家门。", "后来房屋几经修缮,桂花树仍被大家小心保留下来。它见过孩子长大,也见过长辈把往事一遍遍讲给后来人。"] },
{ id: "103", category: "族谱序言", title: "续修族谱序", author: "谱主", updatedAt: "2024 年 5 月 8 日", paragraphs: ["本次续修以旧谱、碑记、户籍资料和长辈口述为基础,由家人共同核对补充。凡暂不能确认之处,均保留来源和疑问,留待后续查证。", "愿这份记录不仅理清世系,也能保存家风、人物与共同记忆。"] },
];
const articleId = ref("101");
const article = reactive({ ...articleRecords[0] });
const genealogyId = ref("");
const articleId = ref("");
const article = ref(null);
const articleState = ref("loading");
const favorite = ref(false);
const toastVisible = ref(false);
let toastTimer = null;
const articleParagraphs = computed(() => article.paragraphs || []);
const articleParagraphs = computed(() => article.value?.paragraphs || []);
const articleStateClasses = computed(() => ({
[`article-state--${articleState.value}`]: true,
"article-state--expired": articleState.value === "expired",
@@ -62,32 +55,46 @@ const articleStateClasses = computed(() => ({
"article-state--error": articleState.value === "error",
}));
const stateCopy = computed(() => ({
expired: { title: "这篇谱文已无法查看", copy: "内容可能已被作者删除或取消公开,请返回谱文列表查看其他内容。", action: "返回谱文列表" },
expired: { title: "这篇谱文已无法查看", copy: "当前家谱中不存在这篇谱文,页面不会回退到其他文章。", action: genealogyId.value ? "返回谱文列表" : "返回上一页" },
privacy: { title: "这篇谱文暂未公开", copy: "作者仅向有权限的家人开放正文,请返回谱文列表查看其他内容。", action: "返回谱文列表" },
error: { title: "谱文暂不可用", copy: "请稍后重新查看,已有谱文不会受到影响。", action: "重新查看" },
}[articleState.value] || {}));
onLoad((query) => {
articleId.value = String(query.articleId || "101");
const selected = articleRecords.find((item) => item.id === articleId.value);
if (selected) Object.assign(article, selected);
genealogyId.value = String(query.genealogyId || "");
articleId.value = String(query.articleId || "");
const selected = findFamilyArticleFixture(genealogyId.value, articleId.value);
article.value = selected;
articleState.value = ["loading", "error", "expired", "privacy"].includes(query.state)
? query.state
? selected
? query.state
: "expired"
: selected
? "ready"
: "expired";
});
const toggleFavorite = () => {
favorite.value = !favorite.value;
toastVisible.value = true;
if (toastTimer) clearTimeout(toastTimer);
toastTimer = setTimeout(() => { toastVisible.value = false; }, 1800);
const editArticle = () =>
openPage(
"F06",
{
genealogyId: genealogyId.value,
mode: "edit",
articleId: articleId.value,
},
"F05",
);
const backToArticles = () =>
genealogyId.value
? returnTo("F04", { genealogyId: genealogyId.value })
: goBack();
const handleStateAction = () => {
if (articleState.value === "error" && article.value) {
articleState.value = "ready";
return;
}
return backToArticles();
};
const editArticle = () => uni.navigateTo({ url: `/pages/family/f06-article-editor?mode=edit&articleId=${articleId.value}` });
const backToArticles = () => uni.redirectTo({ url: "/pages/family/f04-article-list" });
const handleStateAction = () => { if (articleState.value === "error") articleState.value = "ready"; else backToArticles(); };
onUnmounted(() => { if (toastTimer) clearTimeout(toastTimer); });
</script>
<style scoped lang="scss">
+135 -36
View File
@@ -6,7 +6,7 @@
>
<ModulePageBackground module="family" />
<view class="article-editor-page__header">
<PageHeader title="编辑谱文" />
<PageHeader title="编辑谱文" custom-back @back="requestBack" />
</view>
<view v-if="editorState === 'loading'" class="article-editor-loading">
@@ -17,17 +17,15 @@
</view>
<view
v-else-if="editorState === 'success'"
v-else-if="editorState === 'preview' || editorState === 'invalid'"
class="article-editor-content"
>
<view class="editor-result-card">
<view class="editor-result-card__body">
<text class="editor-eyebrow">保存结果</text>
<text class="editor-result-card__title">谱文已保存</text>
<text class="editor-result-card__copy"
>这篇谱文已收录可返回谱文列表继续查看</text
>
<AppButton block label="返回谱文列表" @click="returnToList" />
<text class="editor-eyebrow">{{ resultCopy.eyebrow }}</text>
<text class="editor-result-card__title">{{ resultCopy.title }}</text>
<text class="editor-result-card__copy">{{ resultCopy.copy }}</text>
<AppButton block :label="resultCopy.action" @click="handleResultAction" />
</view>
</view>
</view>
@@ -38,7 +36,7 @@
<text class="editor-eyebrow">草稿 · 未发布</text>
<text class="editor-title">把值得传承的故事写下来</text>
<text class="editor-intro"
>补充标题分类和正文保存后可返回谱文列表继续查看</text
>补充标题分类和正文当前只校验并生成本地预览</text
>
<view class="editor-field">
@@ -108,22 +106,45 @@
<AppButton
block
:label="actionLabel"
:disabled="editorState === 'saving'"
:disabled="isSubmitting"
@click="submit"
/>
</view>
</view>
</view>
</view>
<AppDialog
:visible="discardVisible"
title="放弃谱文草稿?"
message="当前内容尚未提交服务器,确认返回后不会保留。"
confirm-text="放弃并返回"
cancel-text="继续编辑"
show-cancel
:close-on-mask="false"
@confirm="confirmDiscard"
@cancel="cancelDiscard"
/>
</template>
<script setup>
import { computed, reactive, ref } from "vue";
import { onLoad, onUnload } from "@dcloudio/uni-app";
import { onBackPress, onLoad, onUnload } from "@dcloudio/uni-app";
import AppButton from "@/components/AppButton.vue";
import AppDialog from "@/components/AppDialog.vue";
import AppLoading from "@/components/AppLoading.vue";
import ModulePageBackground from "@/components/ModulePageBackground.vue";
import PageHeader from "@/components/PageHeader.vue";
import {
findFamilyArticleFixture,
getGenealogyFixtureAccess,
} from "@/data/mock.js";
import { createDiscardConfirmation } from "@/utils/discard-confirmation.js";
import {
goBack,
handleBackPress,
returnTo,
runBackGuard,
} from "@/utils/navigation.js";
const draftFixture = {
title: "汤氏家训辑录",
@@ -132,28 +153,68 @@ const draftFixture = {
"孝友传家,勤俭立业;敬祖睦宗,诚实待人。愿后人常怀感恩,彼此扶持。",
};
const editorState = ref("form");
const genealogyId = ref("");
const articleId = ref("");
const editorMode = ref("create");
const simulateSaveFailure = ref(false);
const isSubmitting = ref(false);
const discardVisible = ref(false);
const allowedStates = new Set([
"draft",
"loading",
"validation",
"saving",
"error",
"success",
"preview",
]);
const form = reactive({ title: "", category: "", content: "" });
const fieldErrors = reactive({ title: "", category: "", content: "" });
const baseline = ref("");
let saveTimer = null;
const actionLabel = computed(() =>
editorState.value === "saving"
? "正在保存…"
isSubmitting.value
? "正在校验…"
: editorState.value === "error"
? "重新保存"
: "保存谱文",
? "重新校验"
: "生成本地预览",
);
const formSnapshot = computed(() => JSON.stringify(form));
const isDirty = computed(() =>
Boolean(baseline.value) && formSnapshot.value !== baseline.value,
);
const hasValidContext = computed(() =>
Boolean(
genealogyId.value &&
["owner", "member"].includes(
getGenealogyFixtureAccess(genealogyId.value).accessRole,
) &&
(editorMode.value === "create" ||
(editorMode.value === "edit" && articleId.value)),
),
);
const resultCopy = computed(() =>
editorState.value === "preview"
? {
eyebrow: "本地流程预览",
title: "谱文内容已通过本地校验",
copy: "当前尚未提交服务器,返回后不会新增或修改谱文。",
action:
editorMode.value === "edit"
? "返回原谱文(不保存)"
: "返回谱文列表(不保存)",
}
: {
eyebrow: "谱文入口无效",
title: "没有找到要编辑的谱文上下文",
copy: "请从当前家谱的谱文列表重新进入,页面不会创建无归属内容。",
action: "返回上一页",
},
);
const discardConfirmation = createDiscardConfirmation((visible) => {
discardVisible.value = visible;
});
const requestDiscardConfirmation = discardConfirmation.request;
const confirmDiscard = discardConfirmation.confirm;
const cancelDiscard = discardConfirmation.cancel;
const fillDraft = () => {
Object.assign(form, draftFixture);
@@ -165,16 +226,34 @@ const showAllFieldErrors = () => {
};
onLoad((query) => {
articleId.value = query.articleId || "";
editorMode.value = articleId.value || query.mode === "edit" ? "edit" : "create";
genealogyId.value = String(query.genealogyId || "");
articleId.value = String(query.articleId || "");
editorMode.value = String(query.mode || "");
const article =
editorMode.value === "edit"
? findFamilyArticleFixture(genealogyId.value, articleId.value)
: null;
const modeIsValid =
(editorMode.value === "create" && !articleId.value) ||
(editorMode.value === "edit" && Boolean(article));
if (!modeIsValid || !hasValidContext.value) {
editorState.value = "invalid";
return;
}
const requestedState = allowedStates.has(query.state) ? query.state : "form";
simulateSaveFailure.value = requestedState === "error";
if (
editorMode.value === "edit" ||
["draft", "saving", "error"].includes(requestedState)
) {
if (article) {
Object.assign(form, {
title: article.title,
category: article.category,
content: article.paragraphs.join("\n\n"),
});
} else if (["draft", "error", "preview"].includes(requestedState)) {
fillDraft();
}
baseline.value = formSnapshot.value;
if (editorMode.value === "create" && requestedState === "draft") {
baseline.value = JSON.stringify({ title: "", category: "", content: "" });
}
if (requestedState === "validation") showAllFieldErrors();
editorState.value = requestedState;
});
@@ -195,28 +274,48 @@ const validate = () => {
return !fieldErrors.title && !fieldErrors.category && !fieldErrors.content;
};
const submit = () => {
if (editorState.value === "saving") return;
if (isSubmitting.value || !hasValidContext.value) return;
if (!validate()) {
editorState.value = "validation";
return;
}
editorState.value = "saving";
saveTimer = setTimeout(() => {
editorState.value = simulateSaveFailure.value ? "error" : "success";
simulateSaveFailure.value = false;
isSubmitting.value = true;
const submitSnapshot = formSnapshot.value;
const timer = setTimeout(() => {
if (saveTimer !== timer) return;
editorState.value = submitSnapshot ? "preview" : "error";
isSubmitting.value = false;
saveTimer = null;
}, 320);
saveTimer = timer;
};
const returnToList = () =>
uni.redirectTo({
url:
editorMode.value === "edit" && articleId.value
? `/pages/family/f05-article-detail?articleId=${articleId.value}`
: "/pages/family/f04-article-list",
const requestBack = () =>
runBackGuard({
transientOpen: discardVisible.value,
dirty: isDirty.value,
submitting: isSubmitting.value,
"close-transient": cancelDiscard,
"block-submitting": () => true,
"confirm-discard": requestDiscardConfirmation,
});
const returnToTarget = async () => {
const confirmed = isDirty.value ? await requestDiscardConfirmation() : true;
if (!confirmed) return false;
return editorMode.value === "edit"
? returnTo("F05", {
genealogyId: genealogyId.value,
articleId: articleId.value,
})
: returnTo("F04", { genealogyId: genealogyId.value });
};
const handleResultAction = () =>
editorState.value === "preview" ? returnToTarget() : goBack();
onBackPress((event) => handleBackPress(event, requestBack));
onUnload(() => {
if (saveTimer) clearTimeout(saveTimer);
discardConfirmation.dispose();
});
</script>
+131 -30
View File
@@ -2,7 +2,7 @@
<template>
<view class="album-list-page" :class="albumStateClasses">
<ModulePageBackground module="family" />
<view class="album-list-header"><PageHeader title="家族相册" action="新建" @action="createAlbum" /></view>
<view class="album-list-header"><PageHeader title="家族相册" :action="hasValidContext ? '新建' : ''" custom-back @back="requestBack" @action="createAlbum" /></view>
<view v-if="albumState === 'loading'" class="album-list-loading">
<AppLoading text="正在整理家族相册" description="请稍候,正在读取照片与更新时间。" />
@@ -11,6 +11,11 @@
<view v-else class="album-list-content">
<template v-if="albumState === 'ready'">
<view class="album-list-lead"><text>让每一张照片都回到家人身边</text><text> {{ albums.length }} 本相册</text></view>
<view v-if="localAlbumPreview" class="album-local-preview">
<text>本地预览 · 尚未提交服务器</text>
<text>{{ localAlbumPreview.name }}</text>
<text>这本相册不会加入正式列表离开页面后不保存</text>
</view>
<view v-if="albums.length" class="album-list">
<view v-for="album in albums" :key="album.id" class="album-card" role="button" :aria-label="`打开相册${album.name}`" @click="openAlbum(album)">
<view class="album-card__media"><image class="album-card__cover" :src="album.cover" mode="aspectFill" :alt="album.name" /></view>
@@ -29,76 +34,168 @@
</template>
<view v-else class="album-state-card">
<text>{{ albumState === 'empty' ? '还没有相册' : '相册暂不可用' }}</text>
<text>{{ albumState === 'empty' ? '从第一本团圆相册开始收集家族影像。' : '请稍后重新查看,已有照片不会受到影响。' }}</text>
<AppButton :type="albumState === 'error' ? 'secondary' : 'primary'" block :label="albumState === 'error' ? '重新查看' : '新建相册'" @click="albumState === 'error' ? restoreAlbums() : createAlbum()" />
<text>{{ stateCopy.title }}</text>
<text>{{ stateCopy.copy }}</text>
<AppButton :type="albumState === 'error' ? 'secondary' : 'primary'" block :label="stateCopy.action" @click="handleStateAction" />
</view>
</view>
<AppDialog :visible="dialogVisible" eyebrow="新建相册" title="为家人整理一段影像" message="相册创建后可继续添加照片和说明。" confirm-text="创建相册" cancel-text="取消" show-cancel @confirm="confirmCreateAlbum" @cancel="closeCreateDialog">
<AppDialog :visible="dialogVisible" eyebrow="新建相册预览" title="为家人整理一段影像" message="当前只生成本地预览,不会创建服务器相册。" confirm-text="生成本地预览" cancel-text="取消" show-cancel :close-on-mask="false" @confirm="confirmCreateAlbum" @cancel="requestCloseCreateDialog">
<view class="album-dialog-field">
<text>相册名称</text>
<input v-model="albumNameDraft" maxlength="30" placeholder="例如:春节团圆" />
<text v-if="albumNameError">{{ albumNameError }}</text>
</view>
</AppDialog>
<AppToast :visible="toastVisible" message="相册已创建" />
<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 { onLoad } from "@dcloudio/uni-app";
import { onBackPress, onLoad } from "@dcloudio/uni-app";
import AppButton from "@/components/AppButton.vue";
import AppDialog from "@/components/AppDialog.vue";
import AppLoading from "@/components/AppLoading.vue";
import AppToast from "@/components/AppToast.vue";
import ModulePageBackground from "@/components/ModulePageBackground.vue";
import PageHeader from "@/components/PageHeader.vue";
import {
getGenealogyFixtureAccess,
listFamilyAlbumFixtures,
} from "@/data/mock.js";
import { createDiscardConfirmation } from "@/utils/discard-confirmation.js";
import {
goBack,
handleBackPress,
openPage,
runBackGuard,
} from "@/utils/navigation.js";
const baseAlbums = [
{ id: "201", name: "2024 春节团圆", photoCount: 18, updatedAt: "今天更新", description: "三代家人的团圆饭与院前合影", cover: "/static/assets/modules/family/f08/f08-reunion-hero.png" },
{ id: "202", name: "祖居旧影", photoCount: 32, updatedAt: "5 月 10 日更新", description: "祖居、旧物与长辈珍藏的老照片", cover: "/static/assets/modules/family/f08/f08-ancestral-home.png" },
{ id: "203", name: "儿童成长", photoCount: 46, updatedAt: "持续更新", description: "记录孩子们每一个值得珍藏的瞬间", cover: "/static/assets/modules/family/f08/f08-family-portrait.png" },
];
const albums = ref([...baseAlbums]);
const genealogyId = ref("");
const hasValidContext = ref(false);
const albums = ref([]);
const albumState = ref("loading");
const dialogVisible = ref(false);
const albumNameDraft = ref("");
const albumNameError = ref("");
const toastVisible = ref(false);
let toastTimer = null;
const localAlbumPreview = ref(null);
const discardVisible = ref(false);
const albumStateClasses = computed(() => ({
[`album-list-state--${albumState.value}`]: true,
"album-state--empty": albumState.value === "empty",
"album-list-state--loading": albumState.value === "loading",
"album-list-state--error": albumState.value === "error",
}));
const isDirty = computed(() =>
Boolean(albumNameDraft.value.trim() || localAlbumPreview.value),
);
const stateCopy = computed(() => ({
empty: {
title: "还没有相册",
copy: "当前家谱尚无可读取相册,可先生成不会保存的本地预览。",
action: "填写相册预览",
},
error: {
title: "相册暂不可用",
copy: "请稍后重新查看,已有照片不会受到影响。",
action: "重新查看",
},
invalid: {
title: "相册入口无效",
copy: "没有找到可访问的成员家谱,页面不会展示其他家谱相册。",
action: "返回上一页",
},
}[albumState.value]));
const discardConfirmation = createDiscardConfirmation((visible) => {
discardVisible.value = visible;
});
const requestDiscardConfirmation = discardConfirmation.request;
const confirmDiscard = discardConfirmation.confirm;
const cancelDiscard = discardConfirmation.cancel;
onLoad((query) => {
const count = Math.max(1, Math.min(Number(query.count) || baseAlbums.length, 30));
albums.value = Array.from({ length: count }, (_, index) => ({
...baseAlbums[index % baseAlbums.length],
id: String(201 + index),
name: count > baseAlbums.length ? `${baseAlbums[index % baseAlbums.length].name}${index + 1}` : baseAlbums[index].name,
}));
albumState.value = ["loading", "empty", "error"].includes(query.state) ? query.state : "ready";
genealogyId.value = String(query.genealogyId || "");
hasValidContext.value = ["owner", "member"].includes(
getGenealogyFixtureAccess(genealogyId.value).accessRole,
);
if (!genealogyId.value || !hasValidContext.value) {
albumState.value = "invalid";
return;
}
albums.value = listFamilyAlbumFixtures(genealogyId.value);
albumState.value = ["loading", "empty", "error"].includes(query.state)
? query.state
: albums.value.length
? "ready"
: "empty";
});
const openAlbum = (album) => uni.navigateTo({ url: `/pages/family/f08-album-detail?albumId=${album.id}` });
const openAlbum = (album) =>
openPage(
"F08",
{ genealogyId: genealogyId.value, albumId: String(album.id) },
"F07",
);
const createAlbum = () => { albumNameDraft.value = ""; albumNameError.value = ""; dialogVisible.value = true; };
const closeCreateDialog = () => { dialogVisible.value = false; };
const requestCloseCreateDialog = async () => {
if (!albumNameDraft.value.trim()) {
closeCreateDialog();
return true;
}
const confirmed = await requestDiscardConfirmation();
if (!confirmed) return false;
albumNameDraft.value = "";
albumNameError.value = "";
closeCreateDialog();
return true;
};
const confirmCreateAlbum = () => {
const name = albumNameDraft.value.trim();
if (!name) { albumNameError.value = "请填写相册名称"; return; }
albums.value.unshift({ id: String(Date.now()), name, photoCount: 0, updatedAt: "刚刚创建", description: "等待添加第一张照片", cover: "/static/assets/modules/family/f08/f08-reunion-hero.png" });
albumState.value = "ready";
localAlbumPreview.value = Object.freeze({ name });
albumNameDraft.value = "";
dialogVisible.value = false;
toastVisible.value = true;
toastTimer = setTimeout(() => { toastVisible.value = false; }, 1800);
};
const restoreAlbums = () => { albumState.value = "ready"; };
onUnmounted(() => { if (toastTimer) clearTimeout(toastTimer); });
const restoreAlbums = () => {
albums.value = listFamilyAlbumFixtures(genealogyId.value);
albumState.value = albums.value.length ? "ready" : "empty";
};
const handleStateAction = () => {
if (albumState.value === "invalid") return goBack();
if (albumState.value === "error") return restoreAlbums();
return createAlbum();
};
const requestBack = () => {
if (discardVisible.value) {
return runBackGuard({
transientOpen: true,
"close-transient": cancelDiscard,
});
}
if (dialogVisible.value) {
return runBackGuard({
transientOpen: true,
"close-transient": requestCloseCreateDialog,
});
}
return runBackGuard({
dirty: isDirty.value,
"confirm-discard": requestDiscardConfirmation,
});
};
onBackPress((event) => handleBackPress(event, requestBack));
onUnmounted(() => { discardConfirmation.dispose(); });
</script>
<style scoped lang="scss">
@@ -110,6 +207,10 @@ onUnmounted(() => { if (toastTimer) clearTimeout(toastTimer); });
.album-list-content { padding: 18rpx 24rpx 72rpx; }
.album-list-lead { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 8rpx 18rpx; min-height: 62rpx; padding: 0 20rpx; color: $ink-muted; font-size: 22rpx; background: url("/static/assets/modules/genealogy/transparent/section-divider.png") center / 100% auto no-repeat; }
.album-list { display: flex; flex-direction: column; gap: 16rpx; margin-top: 16rpx; }
.album-local-preview { @include adaptive.adaptive-family-field; margin-top: 16rpx; padding: 22rpx 24rpx; }
.album-local-preview text { display: block; color: $ink-muted; font-size: 22rpx; line-height: 1.5; }
.album-local-preview text:first-child { color: $brand-red; font-weight: 700; }
.album-local-preview text:nth-child(2) { margin-top: 6rpx; color: $ink; font-family: STKaiti, KaiTi, serif; font-size: 29rpx; font-weight: 700; }
.album-card, .album-state-card { @include adaptive.adaptive-family-content; width: 100%; }
.album-card { display: grid; grid-template-columns: minmax(150rpx, 0.7fr) minmax(0, 1.3fr); min-height: 210rpx; gap: 22rpx; padding: 30rpx 38rpx; }
.album-card__media { width: 100%; aspect-ratio: 4 / 3; align-self: center; }
+44 -30
View File
@@ -2,27 +2,25 @@
<template>
<view class="album-detail-page" :class="`album-state--${albumState}`">
<ModulePageBackground module="family" />
<view class="album-detail-header"><PageHeader title="相册详情" /></view>
<view class="album-detail-header"><PageHeader title="相册详情" custom-back @back="requestBack" /></view>
<view class="album-detail-content">
<view v-if="albumState === 'expired'" class="album-expired-state">
<view class="album-state-card__body">
<text class="album-state-card__eyebrow">相册状态</text>
<text class="album-state-card__title">相册已失效</text>
<text class="album-state-card__copy">这个相册已无法查看</text>
<AppButton block label="返回相册列表" @click="returnToAlbums" />
<text class="album-state-card__title">相册已失效或入口无效</text>
<text class="album-state-card__copy">当前家谱中没有找到这本相册页面不会回退到其他相册</text>
<AppButton block :label="genealogyId ? '返回相册列表' : '返回上一页'" @click="returnToAlbums" />
</view>
</view>
<template v-else>
<view class="album-heading">
<text class="album-heading__eyebrow">家族影像 · 2024</text>
<text class="album-heading__title">2024 春节团圆</text>
<text class="album-heading__copy"
>一家人围坐团圆让今朝欢聚与祖居旧影留在同一本相册里</text
>
<text class="album-heading__eyebrow">家族影像</text>
<text class="album-heading__title">{{ album.name }}</text>
<text class="album-heading__copy">{{ album.description }}</text>
<text class="album-heading__count"
>{{ albumState === "empty" ? "0 张照片" : "5 张照片" }}</text
>{{ albumState === "empty" ? "0 张照片" : `${photos.length} 张照片` }}</text
>
</view>
@@ -102,20 +100,23 @@ import { onBackPress, onLoad } from "@dcloudio/uni-app";
import AppButton from "@/components/AppButton.vue";
import ModulePageBackground from "@/components/ModulePageBackground.vue";
import PageHeader from "@/components/PageHeader.vue";
import { findFamilyAlbumFixture } from "@/data/mock.js";
import {
goBack,
handleBackPress,
openPage,
returnTo,
runBackGuard,
} from "@/utils/navigation.js";
const albumState = ref("normal");
const genealogyId = ref("");
const albumId = ref("");
const album = ref(null);
const photos = ref([]);
const previewVisible = ref(false);
const previewIndex = ref(0);
const photos = [
{ src: "/static/assets/modules/family/f08/f08-reunion-hero.png", alt: "春节团圆时三代家人的合影", caption: "除夕团圆 · 2024" },
{ src: "/static/assets/modules/family/f08/f08-family-portrait.png", alt: "家人在院落前的春节合影", caption: "院前合影 · 2024" },
{ src: "/static/assets/modules/family/f08/f08-reunion-table.png", alt: "家人围坐吃年夜饭", caption: "围桌守岁 · 2024" },
{ src: "/static/assets/modules/family/f08/f08-ancestral-home.png", alt: "祖居院落的复古旧照", caption: "祖居旧影 · 1968" },
{ src: "/static/assets/modules/family/f08/f08-ancestral-portrait.png", alt: "老一辈家人在祖居门前的合影", caption: "门前合影 · 1972" },
];
const openPreview = (index) => {
previewIndex.value = index;
previewVisible.value = true;
@@ -128,16 +129,27 @@ const closePreview = () => {
};
const toUpload = () =>
uni.navigateTo({
url: `/pages/family/f09-media-upload?albumId=${albumId.value}`,
});
openPage(
"F09",
{ genealogyId: genealogyId.value, albumId: albumId.value },
"F08",
);
const returnToAlbums = () => {
uni.redirectTo({ url: "/pages/family/f07-album-list" });
};
const returnToAlbums = () =>
genealogyId.value
? returnTo("F07", { genealogyId: genealogyId.value })
: goBack();
onLoad((query) => {
albumId.value = query.albumId || "reunion";
genealogyId.value = String(query.genealogyId || "");
albumId.value = String(query.albumId || "");
album.value = findFamilyAlbumFixture(genealogyId.value, albumId.value);
photos.value = album.value?.photos || [];
if (!album.value) {
albumState.value = "expired";
previewVisible.value = false;
return;
}
albumState.value =
query.state === "empty"
? "empty"
@@ -149,11 +161,13 @@ onLoad((query) => {
previewVisible.value = albumState.value === "preview";
});
onBackPress(() => {
if (!previewVisible.value) return false;
closePreview();
return true;
});
const requestBack = () =>
runBackGuard({
transientOpen: previewVisible.value,
"close-transient": closePreview,
});
onBackPress((event) => handleBackPress(event, requestBack));
</script>
+131 -158
View File
@@ -5,46 +5,55 @@
:class="`media-upload-state--${uploadState}`"
>
<ModulePageBackground module="family" />
<view class="media-upload-header"><PageHeader title="上传照片" /></view>
<view class="media-upload-header"><PageHeader title="添加照片" custom-back @back="requestBack" /></view>
<view class="media-upload-content">
<view class="media-album-card">
<view v-if="uploadState === 'invalid'" class="media-invalid-card">
<text class="media-state-card__eyebrow">相册入口无效</text>
<text class="media-state-card__title">没有找到当前相册</text>
<text class="media-state-card__copy">页面不会把照片归入其他家谱或其他相册</text>
<view class="media-preview-action" @click="returnFromInvalid">
<AppButton block :label="genealogyId ? '返回相册列表' : '返回上一页'" />
</view>
</view>
<view v-else class="media-album-card">
<image
class="media-album-card__cover"
:src="mockLibrary[0].src"
:alt="mockLibrary[0].alt"
:src="album.cover"
:alt="album.name"
mode="aspectFill"
/>
<view class="media-album-card__copy">
<text class="media-album-card__eyebrow">当前相册</text>
<text class="media-album-card__title">2024 春节团圆</text>
<text class="media-album-card__title">{{ album.name }}</text>
<text class="media-album-card__limit">最多可选择 9 </text>
</view>
</view>
<view v-if="uploadState === 'permission'" class="media-permission-card">
<view v-if="uploadState === 'permission' && album" class="media-permission-card">
<text class="media-state-card__eyebrow">照片访问权限</text>
<text class="media-state-card__title">需要照片访问权限</text>
<text class="media-state-card__copy"
>授权后才能选择要加入当前相册的照片当前仅展示 H5 审核状态</text
>授权后才能选择照片当前只验证选择与说明流程不会上传</text
>
<view class="media-permission-action" @click="selectMockPhotos">
<AppButton block label="重新授权" />
</view>
</view>
<view v-else-if="uploadState === 'success'" class="media-success-card">
<text class="media-state-card__eyebrow">上传结果</text>
<text class="media-success-title">4 张照片已上传</text>
<view v-else-if="uploadState === 'preview'" class="media-preview-card">
<text class="media-state-card__eyebrow">本地流程预览</text>
<text class="media-preview-title">{{ selectedPhotos.length }} 张照片已完成本地校验</text>
<text class="media-state-card__copy"
>照片已加入2024 春节团圆可以返回相册继续查看</text
>照片尚未上传也没有加入{{ album.name }}返回后不会保存</text
>
<view class="media-success-action" @click="returnToAlbum">
<AppButton block label="返回当前相册" />
<view class="media-preview-action" @click="returnToAlbum">
<AppButton block label="返回当前相册(不上传)" />
</view>
</view>
<template v-else>
<template v-else-if="album">
<view class="media-section-heading">
<text>选择照片</text>
<text>{{ selectedPhotos.length }} / {{ MAX_PHOTOS }}</text>
@@ -57,7 +66,6 @@
class="media-photo-tile"
:class="{
'media-photo-tile--active': index === activePhotoIndex,
'media-photo-tile--failed': photo.status === 'error',
}"
@click="selectPhoto(index)"
>
@@ -80,21 +88,6 @@
>
<text>删除</text>
</view>
<text
v-if="photo.status === 'error'"
class="media-photo-status media-photo-status--error"
>上传失败</text
>
<text
v-else-if="photo.status === 'uploading'"
class="media-photo-status media-photo-status--uploading"
>{{ photo.progress }}%</text
>
<text
v-else-if="photo.status === 'uploaded'"
class="media-photo-status media-photo-status--uploaded"
>已上传</text
>
</view>
<view
@@ -162,104 +155,95 @@
</view>
</view>
<template v-if="uploadState === 'uploading'">
<text class="media-progress-copy"
>正在上传 2/{{ selectedPhotos.length }}</text
>
<view class="media-progress-action">
<AppButton block label="正在上传" />
</view>
</template>
<view
v-else-if="uploadState === 'error'"
class="media-retry-action"
@click="retryFailed"
>
<AppButton block label="重试 2 张失败照片" />
</view>
<view v-else class="media-primary-action" @click="startUpload">
<view class="media-primary-action" @click="generatePreview">
<AppButton
block
:disabled="isSubmitting"
:label="
selectedPhotos.length
? `上传 ${selectedPhotos.length} 张照片`
isSubmitting
? '正在校验'
: selectedPhotos.length
? `生成 ${selectedPhotos.length} 张照片预览`
: '选择照片'
"
/>
</view>
</template>
</view>
<AppDialog
:visible="discardVisible"
title="放弃照片草稿?"
message="已选择的照片和说明尚未上传,确认返回后不会保留。"
confirm-text="放弃并返回"
cancel-text="继续整理"
show-cancel
:close-on-mask="false"
@confirm="confirmDiscard"
@cancel="cancelDiscard"
/>
</view>
</template>
<script setup>
import { computed, ref } from "vue";
import { onLoad } from "@dcloudio/uni-app";
import { onBackPress, onLoad, onUnload } from "@dcloudio/uni-app";
import AppButton from "@/components/AppButton.vue";
import AppDialog from "@/components/AppDialog.vue";
import ModulePageBackground from "@/components/ModulePageBackground.vue";
import PageHeader from "@/components/PageHeader.vue";
import { findFamilyAlbumFixture } from "@/data/mock.js";
import { createDiscardConfirmation } from "@/utils/discard-confirmation.js";
import {
goBack,
handleBackPress,
returnTo,
runBackGuard,
} from "@/utils/navigation.js";
const MAX_PHOTOS = 9;
const genealogyId = ref("");
const albumId = ref("");
const album = ref(null);
const uploadState = ref("initial");
const activePhotoIndex = ref(0);
const batchDescription = ref("");
const validationMessage = ref("");
const selectedPhotos = ref([]);
const isSubmitting = ref(false);
const discardVisible = ref(false);
let previewTimer = null;
const mockLibrary = [
{
id: "reunion",
src: "/static/assets/modules/family/f08/f08-reunion-hero.png",
alt: "春节团圆时三代家人的合影",
note: "",
},
{
id: "portrait",
src: "/static/assets/modules/family/f08/f08-family-portrait.png",
alt: "家人在院落前的春节合影",
note: "",
},
{
id: "table",
src: "/static/assets/modules/family/f08/f08-reunion-table.png",
alt: "家人围坐吃年夜饭",
note: "",
},
{
id: "home",
src: "/static/assets/modules/family/f08/f08-ancestral-home.png",
alt: "祖居院落的复古旧照",
note: "",
},
{
id: "ancestor",
src: "/static/assets/modules/family/f08/f08-ancestral-portrait.png",
alt: "老一辈家人在祖居门前的合影",
note: "",
},
{ id: "reunion", src: "/static/assets/modules/family/f08/f08-reunion-hero.png", alt: "春节团圆时三代家人的合影", note: "" },
{ id: "portrait", src: "/static/assets/modules/family/f08/f08-family-portrait.png", alt: "家人在院落前的春节合影", note: "" },
{ id: "table", src: "/static/assets/modules/family/f08/f08-reunion-table.png", alt: "家人围坐吃年夜饭", note: "" },
{ id: "home", src: "/static/assets/modules/family/f08/f08-ancestral-home.png", alt: "祖居院落的复古旧照", note: "" },
{ id: "ancestor", src: "/static/assets/modules/family/f08/f08-ancestral-portrait.png", alt: "老一辈家人在祖居门前的合影", note: "" },
];
const selectedPhotos = ref([]);
const albumId = ref("");
const activePhoto = computed(
() => selectedPhotos.value[activePhotoIndex.value] || null,
);
const isLocked = computed(() => uploadState.value === "uploading");
const isLocked = computed(() => isSubmitting.value);
const isDirty = computed(() =>
Boolean(
selectedPhotos.value.length ||
batchDescription.value.trim() ||
selectedPhotos.value.some((photo) => photo.note.trim()),
),
);
const discardConfirmation = createDiscardConfirmation((visible) => {
discardVisible.value = visible;
});
const requestDiscardConfirmation = discardConfirmation.request;
const confirmDiscard = discardConfirmation.confirm;
const cancelDiscard = discardConfirmation.cancel;
const makeSelectedPhotos = (state) =>
mockLibrary.slice(0, 4).map((photo, index) => ({
...photo,
note: "",
progress: state === "uploading" ? [100, 62, 35, 0][index] : 0,
status:
state === "error"
? index < 2
? "error"
: "uploaded"
: state,
}));
const makeSelectedPhotos = () =>
mockLibrary.slice(0, 4).map((photo) => ({ ...photo, note: "" }));
const selectMockPhotos = () => {
selectedPhotos.value = makeSelectedPhotos("selected");
selectedPhotos.value = makeSelectedPhotos();
activePhotoIndex.value = 0;
validationMessage.value = "";
uploadState.value = "selected";
@@ -286,8 +270,6 @@ const addMockPhoto = () => {
...next,
id: `${next.id}-${nextIndex + 1}`,
note: "",
progress: 0,
status: "selected",
});
};
@@ -297,7 +279,8 @@ const updateActiveNote = (event) => {
}
};
const startUpload = () => {
const generatePreview = () => {
if (isSubmitting.value || !album.value) return;
if (!selectedPhotos.value.length) {
selectMockPhotos();
return;
@@ -307,40 +290,59 @@ const startUpload = () => {
return;
}
validationMessage.value = "";
selectedPhotos.value = selectedPhotos.value.map((photo, index) => ({
...photo,
progress: [100, 62, 35, 0][index] || 0,
status: "uploading",
}));
uploadState.value = "uploading";
isSubmitting.value = true;
const selectedCount = selectedPhotos.value.length;
const timer = setTimeout(() => {
if (previewTimer !== timer) return;
previewTimer = null;
isSubmitting.value = false;
uploadState.value = selectedCount > 0 ? "preview" : "selected";
}, 280);
previewTimer = timer;
};
const retryFailed = () => {
selectedPhotos.value = selectedPhotos.value.map((photo, index) => ({
...photo,
progress: [100, 62, 35, 0][index] || 0,
status: "uploading",
}));
uploadState.value = "uploading";
};
const returnToAlbum = () => {
uni.redirectTo({
url: `/pages/family/f08-album-detail?albumId=${albumId.value}`,
const returnToAlbum = () =>
returnTo("F08", {
genealogyId: genealogyId.value,
albumId: albumId.value,
});
const returnFromInvalid = () =>
genealogyId.value
? returnTo("F07", { genealogyId: genealogyId.value })
: goBack();
const requestBack = () =>
runBackGuard({
transientOpen: discardVisible.value,
dirty: isDirty.value,
submitting: isSubmitting.value,
"close-transient": cancelDiscard,
"block-submitting": () => true,
"confirm-discard": requestDiscardConfirmation,
});
};
onLoad((query) => {
albumId.value = query.albumId || "reunion";
uploadState.value = ["permission", "selected", "uploading", "error", "success"].includes(query.state)
genealogyId.value = String(query.genealogyId || "");
albumId.value = String(query.albumId || "");
album.value = findFamilyAlbumFixture(genealogyId.value, albumId.value);
if (!album.value) {
uploadState.value = "invalid";
return;
}
uploadState.value = ["permission", "selected", "preview"].includes(query.state)
? query.state
: "initial";
if (!["initial", "permission"].includes(uploadState.value)) {
selectedPhotos.value = makeSelectedPhotos(uploadState.value);
if (["selected", "preview"].includes(uploadState.value)) {
selectedPhotos.value = makeSelectedPhotos();
batchDescription.value = "春节团圆照片整理";
}
});
onBackPress((event) => handleBackPress(event, requestBack));
onUnload(() => {
if (previewTimer) clearTimeout(previewTimer);
discardConfirmation.dispose();
});
</script>
<style scoped lang="scss">
@@ -379,9 +381,7 @@ onLoad((query) => {
.media-state-card__eyebrow,
.media-state-card__title,
.media-state-card__copy,
.media-success-title,
.media-photo-status,
.media-progress-copy {
.media-preview-title {
display: block;
}
.media-album-card__eyebrow {
@@ -437,17 +437,12 @@ onLoad((query) => {
outline: 4rpx solid rgba(159, 44, 35, 0.78);
outline-offset: -4rpx;
}
.media-photo-tile--failed {
border-style: dashed;
border-color: #9f2c23;
}
.media-photo-tile__image {
width: 100%;
height: 100%;
}
.media-photo-order,
.media-photo-current,
.media-photo-status {
.media-photo-current {
position: absolute;
z-index: 2;
color: #fffaf0;
@@ -485,21 +480,6 @@ onLoad((query) => {
padding: 7rpx 8rpx;
background: rgba(145, 36, 29, 0.88);
}
.media-photo-status {
right: 0;
bottom: 0;
left: 0;
padding: 7rpx 8rpx;
background: rgba(58, 43, 32, 0.82);
text-align: center;
}
.media-photo-status--error {
background: rgba(132, 35, 29, 0.92);
font-weight: 700;
}
.media-photo-status--uploaded {
background: rgba(55, 82, 51, 0.88);
}
.media-add-tile {
display: flex;
min-height: 44px;
@@ -591,19 +571,12 @@ onLoad((query) => {
font-size: 21rpx;
line-height: 1.45;
}
.media-primary-action,
.media-retry-action,
.media-progress-action {
.media-primary-action {
margin-top: 24rpx;
}
.media-progress-copy {
margin-top: 22rpx;
color: $ink;
font-size: 23rpx;
text-align: center;
}
.media-permission-card,
.media-success-card {
.media-preview-card,
.media-invalid-card {
@include adaptive.adaptive-family-panel;
min-height: 410rpx;
margin-top: 24rpx;
@@ -617,7 +590,7 @@ onLoad((query) => {
letter-spacing: 2rpx;
}
.media-state-card__title,
.media-success-title {
.media-preview-title {
margin-top: 12rpx;
color: $ink;
font-family: STKaiti, KaiTi, serif;
@@ -631,7 +604,7 @@ onLoad((query) => {
line-height: 1.65;
}
.media-permission-action,
.media-success-action {
.media-preview-action {
margin-top: 30rpx;
}
@media (max-width: 340px) {
+27 -10
View File
@@ -1,6 +1,6 @@
<!-- 页面编号F-10用途家族视频待开放状态 -->
<template>
<view class="video-status-page">
<view class="video-status-page" :class="{ 'video-status-state--invalid': !hasValidContext }">
<ModulePageBackground module="family" />
<view class="video-status-header"><PageHeader title="家族视频" /></view>
@@ -15,13 +15,11 @@
<view class="video-status-card">
<view class="video-status-card__body">
<text class="video-status-card__eyebrow">视频 · 服务说明</text>
<text class="video-status-card__title">视频服务暂未开放</text>
<text class="video-status-card__copy"
>开放后可在这里浏览家族影像与纪念视频</text
>
<text class="video-status-card__eyebrow">{{ hasValidContext ? '视频 · 服务说明' : '页面入口' }}</text>
<text class="video-status-card__title">{{ hasValidContext ? '视频服务暂未开放' : '家谱身份无效' }}</text>
<text class="video-status-card__copy">{{ hasValidContext ? '开放后可在这里浏览当前家谱的影像与纪念视频。' : '请从一个可访问的成员家谱重新进入,页面不会展示其他家谱内容。' }}</text>
<view class="video-return-action" @click="returnToFamily">
<AppButton block label="返回家族首页" />
<AppButton block :label="hasValidContext ? '返回家族首页' : '返回上一页'" />
</view>
</view>
</view>
@@ -30,13 +28,32 @@
</template>
<script setup>
import { computed, ref } from "vue";
import { onLoad } from "@dcloudio/uni-app";
import AppButton from "@/components/AppButton.vue";
import ModulePageBackground from "@/components/ModulePageBackground.vue";
import PageHeader from "@/components/PageHeader.vue";
import { getGenealogyFixtureAccess } from "@/data/mock.js";
import { goBack, returnTo } from "@/utils/navigation.js";
const returnToFamily = () => {
uni.reLaunch({ url: "/pages/family/f01-family-feed" });
};
const genealogyId = ref("");
const hasValidContext = computed(() =>
Boolean(
genealogyId.value &&
["owner", "member"].includes(
getGenealogyFixtureAccess(genealogyId.value).accessRole,
),
),
);
onLoad((query) => {
genealogyId.value = String(query.genealogyId || "");
});
const returnToFamily = () =>
hasValidContext.value
? returnTo("F01", { genealogyId: genealogyId.value })
: goBack();
</script>
<style scoped lang="scss">
+153 -66
View File
@@ -7,6 +7,7 @@
<GenealogyPageBackground />
<PageHeader
root
notice
title="我的家谱"
:unread-count="unreadCount"
@notice="toNotifications"
@@ -44,6 +45,12 @@
</view>
</view>
<view v-else-if="contextInvalidated" class="state-panel state-panel--context">
<text class="state-title">当前家谱已不可用</text>
<text class="state-copy">权限或家谱列表可能已经变化请明确选择仍可访问的家谱</text>
<AppButton block label="选择可用家谱" @click="openSwitcher" />
</view>
<template v-else-if="hasGenealogies">
<view class="genealogy-fixed-zone">
<view class="current-slip" @click="openSwitcher">
@@ -132,6 +139,7 @@
:key="item.id"
:genealogy="item"
role="成员"
:selected="item.id === currentGenealogy.id"
@select="openGenealogy"
/>
</view>
@@ -310,13 +318,15 @@
/>
</view>
<scroll-view class="genealogy-switcher__list" scroll-y>
<view
<button
v-for="item in availableGenealogies"
:key="item.id"
class="switcher-item"
:class="{
'switcher-item--active': item.id === selectedGenealogyId,
}"
:aria-pressed="item.id === selectedGenealogyId"
:aria-label="`${item.name}${item.location}${item.memberCount} 位成员`"
@click="selectGenealogy(item)"
>
<view>
@@ -328,7 +338,7 @@
<text class="switcher-item__state">{{
item.id === selectedGenealogyId ? "当前" : "选择"
}}</text>
</view>
</button>
</scroll-view>
</view>
</view>
@@ -347,21 +357,25 @@ import AppButton from "@/components/AppButton.vue";
import GenealogyCard from "@/components/GenealogyCard.vue";
import GenealogyPageBackground from "@/components/GenealogyPageBackground.vue";
import PageHeader from "@/components/PageHeader.vue";
import { genealogies, notifications } from "@/data/mock.js";
import {
findGenealogyFixture,
genealogies,
getGenealogyFixtureAccess,
listNotificationFixtures,
} from "@/data/mock.js";
import { genealogyContext } from "@/utils/genealogy-context.js";
import { handleBackPress, openPage, runBackGuard } from "@/utils/navigation.js";
const isLoading = ref(false);
const hasError = ref(false);
const list = ref(genealogies);
const forceEmptyState = ref(false);
const contextInvalidated = ref(false);
const contextReconcileFailed = ref(false);
const requestedGenealogyId = ref("");
const addDialogVisible = ref(false);
const switcherVisible = ref(false);
const storedGenealogyId = Number(genealogyContext.getCurrentGenealogyId());
const selectedGenealogyId = ref(
genealogies.some((item) => item.id === storedGenealogyId)
? storedGenealogyId
: genealogies[0]?.id || null,
);
const selectedGenealogyId = ref(null);
const listScrollCommand = ref(0);
const currentListScrollTop = ref(0);
@@ -376,41 +390,75 @@ const syncEmptyStateFromRoute = (query = {}) => {
hasError.value = presentationState === "error";
};
onLoad((query) => {
const requestedId = Number(query?.genealogyId);
if (genealogies.some((item) => item.id === requestedId)) {
selectedGenealogyId.value = requestedId;
genealogyContext.setCurrentGenealogyId(requestedId);
} else if (selectedGenealogyId.value) {
genealogyContext.setCurrentGenealogyId(selectedGenealogyId.value);
const reconcilePageGenealogyContext = () => {
try {
const availableIds = list.value.map((item) => String(item.id));
const previousId = genealogyContext.getCurrentGenealogyId();
selectedGenealogyId.value =
genealogyContext.reconcileCurrentGenealogyId(
availableIds,
requestedGenealogyId.value,
) ||
null;
contextReconcileFailed.value = false;
contextInvalidated.value = Boolean(
availableIds.length &&
!selectedGenealogyId.value &&
(requestedGenealogyId.value ||
previousId ||
genealogyContext.isCurrentGenealogyInvalidated()),
);
return true;
} catch {
genealogyContext.invalidateCurrentGenealogyId();
selectedGenealogyId.value = null;
contextReconcileFailed.value = true;
contextInvalidated.value = false;
hasError.value = true;
return false;
}
};
onLoad((query) => {
syncEmptyStateFromRoute(query);
requestedGenealogyId.value = String(query?.genealogyId || "");
reconcilePageGenealogyContext();
});
const unreadCount = computed(
() => notifications.filter((item) => item.unread).length,
() => listNotificationFixtures().filter((item) => item.unread).length,
);
const hasGenealogies = computed(
() => !forceEmptyState.value && list.value.length > 0,
);
const isListLayout = computed(
() => !isLoading.value && !hasError.value && hasGenealogies.value,
() =>
!isLoading.value &&
!hasError.value &&
!contextInvalidated.value &&
hasGenealogies.value,
);
const createdGenealogies = computed(() =>
list.value.filter((item) => item.membership === "created"),
list.value.filter(
(item) => getGenealogyFixtureAccess(item.id).accessRole === "owner",
),
);
const joinedGenealogies = computed(() =>
list.value.filter((item) => item.membership === "joined"),
list.value.filter(
(item) => getGenealogyFixtureAccess(item.id).accessRole === "member",
),
);
const availableGenealogies = computed(() => list.value);
const currentGenealogy = computed(
() =>
availableGenealogies.value.find(
(item) => item.id === selectedGenealogyId.value,
) || availableGenealogies.value[0],
) || null,
);
const isCurrentGenealogyOwner = computed(
() => currentGenealogy.value?.membership === "created",
() =>
getGenealogyFixtureAccess(currentGenealogy.value?.id).accessRole ===
"owner",
);
const currentRoleLabel = computed(() =>
isCurrentGenealogyOwner.value ? "管理员" : "成员",
@@ -420,26 +468,29 @@ const currentRoleLabel = computed(() =>
const applicationRecords = [
{
id: "pending",
name: "汤氏南阳宗谱",
genealogyId: "2003",
statusLabel: "审核中",
tone: "pending",
description: "申请已提交,等待管理员审核",
},
{
id: "rejected",
name: "汤氏清河家谱",
genealogyId: "2004",
statusLabel: "被拒绝",
tone: "rejected",
description: "可修改关系说明后重新申请",
},
{
id: "removed",
name: "汤氏汝南支谱",
genealogyId: "2005",
statusLabel: "已退出",
tone: "muted",
description: "如需恢复成员身份,可重新申请加入",
},
];
].map((record) => ({
...record,
name: findGenealogyFixture(record.genealogyId)?.name || "未知家谱",
}));
const shortcuts = [
{
@@ -469,22 +520,34 @@ const visibleShortcuts = computed(() =>
: shortcuts.filter((item) => item.key !== "applications"),
);
const openGenealogy = (genealogy) => {
genealogyContext.setCurrentGenealogyId(genealogy.id);
uni.navigateTo({
url: `/pages/genealogy/g05-genealogy-overview?genealogyId=${genealogy.id}`,
});
const openGenealogy = (genealogy) =>
openPage("G05", { genealogyId: String(genealogy.id) }, "G01").then(
(opened) => {
if (opened) {
selectedGenealogyId.value = String(genealogy.id);
genealogyContext.setCurrentGenealogyId(genealogy.id);
}
return opened;
},
);
const createGenealogy = () => {
closeAddDialog();
return openPage("G03", {}, "G01");
};
const applyToJoin = () => {
closeAddDialog();
return openPage("G06", {}, "G01");
};
const joinByInvite = () => {
closeAddDialog();
return openPage("G06", { mode: "invite" }, "G01");
};
const toNotifications = () => {
const notificationParams = currentGenealogy.value
? { genealogyId: String(currentGenealogy.value.id) }
: {};
return openPage("N01", notificationParams, "G01");
};
const createGenealogy = () =>
uni.navigateTo({ url: "/pages/genealogy/g03-create-genealogy" });
const applyToJoin = () =>
uni.navigateTo({ url: "/pages/genealogy/g06-search-genealogies" });
const joinByInvite = () =>
uni.navigateTo({
url: "/pages/genealogy/g06-search-genealogies?mode=invite",
});
const toNotifications = () =>
uni.navigateTo({ url: "/pages/notification/n01-message-center" });
const openAddDialog = () => {
addDialogVisible.value = true;
@@ -492,23 +555,25 @@ const openAddDialog = () => {
const closeAddDialog = () => {
addDialogVisible.value = false;
};
onBackPress(() => {
if (switcherVisible.value) {
closeSwitcher();
return true;
}
if (addDialogVisible.value) {
closeAddDialog();
return true;
}
return false;
});
const openSwitcher = () => {
switcherVisible.value = true;
};
const closeSwitcher = () => {
switcherVisible.value = false;
};
const closeActiveOverlay = () => {
if (switcherVisible.value) closeSwitcher();
else closeAddDialog();
};
const requestBack = () =>
runBackGuard({
transientOpen: switcherVisible.value || addDialogVisible.value,
"close-transient": closeActiveOverlay,
});
onBackPress((event) => {
if (!switcherVisible.value && !addDialogVisible.value) return false;
return handleBackPress(event, requestBack);
});
const handleListScroll = (event) => {
currentListScrollTop.value = Number(event?.detail?.scrollTop || 0);
};
@@ -519,33 +584,44 @@ const resetListScroll = async () => {
currentListScrollTop.value = 0;
};
const selectGenealogy = async (genealogy) => {
selectedGenealogyId.value = genealogy.id;
genealogyContext.setCurrentGenealogyId(genealogy.id);
selectedGenealogyId.value = String(genealogy.id);
genealogyContext.setCurrentGenealogyId(String(genealogy.id));
contextInvalidated.value = false;
closeSwitcher();
await resetListScroll();
};
const openApplication = (record) => {
const statusQuery = record.id === "pending" ? "pending" : record.id;
uni.navigateTo({
url: `/pages/genealogy/g09-my-applications?status=${statusQuery}`,
});
if (record.id === "pending")
return openPage("G09", { status: "pending" }, "G01");
return openPage(
"G08",
{
genealogyId: String(record.genealogyId),
source: "search",
},
"G01",
);
};
const retryLoad = () => {
if (contextReconcileFailed.value) {
hasError.value = false;
reconcilePageGenealogyContext();
return;
}
hasError.value = false;
isLoading.value = false;
};
const openShortcut = (key) => {
if (!currentGenealogy.value) return;
const genealogyId = currentGenealogy.value.id;
genealogyContext.setCurrentGenealogyId(genealogyId);
const paths = {
tree: `/pages/tree/t01-tree-overview?genealogyId=${currentGenealogy.value.id}`,
members: `/pages/genealogy/g05-genealogy-overview?genealogyId=${currentGenealogy.value.id}`,
poem: `/pages/genealogy/g12-generation-poems?genealogyId=${currentGenealogy.value.id}`,
applications: `/pages/genealogy/g10-application-review?genealogyId=${currentGenealogy.value.id}`,
const genealogyId = String(currentGenealogy.value.id);
const actions = {
tree: () => openPage("T01", { genealogyId }, "G01"),
members: () => openPage("G05", { genealogyId }, "G01"),
poem: () => openPage("G12", { genealogyId }, "G01"),
applications: () => openPage("G10", { genealogyId }, "G01"),
};
uni.navigateTo({ url: paths[key] });
return actions[key]?.();
};
</script>
@@ -869,6 +945,10 @@ const openShortcut = (key) => {
padding: 0;
}
.state-panel--context > .app-button {
margin-top: 30rpx;
}
.error-panel__content {
display: flex;
width: 100%;
@@ -1189,9 +1269,16 @@ const openShortcut = (key) => {
align-items: center;
justify-content: space-between;
box-sizing: border-box;
margin: 0;
padding: 18rpx 16rpx;
border: 1rpx solid transparent;
border-bottom-color: rgba(181, 138, 75, 0.42);
background: transparent;
line-height: normal;
text-align: left;
}
.switcher-item::after {
border: 0;
}
.switcher-item__name,
.switcher-item__meta {
+129 -53
View File
@@ -6,7 +6,7 @@
<PageHeader
:title="isAncestorStep ? '录入首代人物' : '创建家谱'"
custom-back
@back="goBack"
@back="requestBack"
/>
<view class="flow-content">
@@ -73,22 +73,15 @@
<text class="flow-rule__label">访问规则</text>
<view class="flow-rule__options">
<view
v-for="option in GENEALOGY_ACCESS_PRESET_OPTIONS"
:key="option.value"
class="flow-rule__option"
:class="{
'flow-rule__option--active':
createForm.visibility === 'MEMBER_ONLY',
createForm.accessPreset === option.value,
}"
@click="createForm.visibility = 'MEMBER_ONLY'"
>仅成员可见</view
>
<view
class="flow-rule__option"
:class="{
'flow-rule__option--active':
createForm.visibility === 'SEARCHABLE',
}"
@click="createForm.visibility = 'SEARCHABLE'"
>可搜索申请</view
@click="createForm.accessPreset = option.value"
>{{ option.label }}</view
>
</view>
</view>
@@ -223,7 +216,7 @@
<view class="flow-success-dialog__content">
<text class="flow-success-dialog__title">家谱创建完成</text>
<text class="flow-success-dialog__copy"
>首代人物已保存接下来进入家谱总览继续完善资料</text
>当前为本地流程预览资料尚未提交服务器可进入总览继续检查页面</text
>
<view class="flow-success-dialog__action" @click="enterOverview">
<text>进入家谱总览</text>
@@ -231,14 +224,40 @@
</view>
</view>
</view>
<AppDialog
:visible="discardVisible"
title="放弃创建?"
message="当前填写内容尚未保存,确认返回后将清空。"
confirm-text="放弃并返回"
cancel-text="继续填写"
show-cancel
compact-actions
:close-on-mask="false"
@confirm="confirmDiscard"
@cancel="cancelDiscard"
/>
</view>
</template>
<script setup>
import { computed, onUnmounted, reactive, ref } from "vue";
import { onLoad } from "@dcloudio/uni-app";
import { computed, reactive, ref } from "vue";
import { onBackPress, onUnload } from "@dcloudio/uni-app";
import AppDialog from "@/components/AppDialog.vue";
import GenealogyPageBackground from "@/components/GenealogyPageBackground.vue";
import PageHeader from "@/components/PageHeader.vue";
import {
createLocalGenealogyPreview,
removeLocalGenealogyPreview,
updateLocalGenealogyPreview,
updateLocalGenealogyPreviewAncestor,
} from "@/data/mock.js";
import { createDiscardConfirmation } from "@/utils/discard-confirmation.js";
import {
GENEALOGY_ACCESS_PRESET,
GENEALOGY_ACCESS_PRESET_OPTIONS,
} from "@/utils/genealogy-contracts.js";
import { handleBackPress, openPage, returnTo, runBackGuard } from "@/utils/navigation.js";
const currentStep = ref("create");
const genealogyId = ref("");
@@ -246,6 +265,7 @@ const isSubmitting = ref(false);
const createState = ref("form");
const ancestorState = ref("form");
const duplicateReminderVisible = ref(false);
const discardVisible = ref(false);
const fieldErrors = reactive({
surname: "",
name: "",
@@ -258,7 +278,7 @@ const createForm = reactive({
name: "",
hall: "",
location: "",
visibility: "MEMBER_ONLY",
accessPreset: GENEALOGY_ACCESS_PRESET.MEMBER_ONLY,
});
const ancestorForm = reactive({
personName: "",
@@ -286,31 +306,69 @@ const changeAncestorBirthDate = (event) => {
};
const isAncestorStep = computed(() => currentStep.value === "ancestor");
const isDirty = computed(() =>
isAncestorStep.value
? Boolean(
ancestorForm.personName ||
ancestorForm.birthDate ||
ancestorForm.introduction ||
ancestorForm.sex !== "0",
)
: Boolean(
createForm.surname ||
createForm.name ||
createForm.hall ||
createForm.location ||
createForm.accessPreset !== GENEALOGY_ACCESS_PRESET.MEMBER_ONLY,
),
);
const syncFlowFromRoute = (query = {}) => {
const isAncestorRoute = query?.step === "ancestor";
const routeGenealogyId = query?.genealogyId || "";
currentStep.value = isAncestorRoute ? "ancestor" : "create";
genealogyId.value =
routeGenealogyId || (isAncestorStep.value ? "local-created-genealogy" : "");
};
onLoad((query) => {
syncFlowFromRoute(query);
const discardConfirmation = createDiscardConfirmation((visible) => {
discardVisible.value = visible;
});
onUnmounted(() => {
if (submitTimer) clearTimeout(submitTimer);
});
const goBack = () => {
if (isAncestorStep.value) {
uni.redirectTo({ url: "/pages/genealogy/g03-create-genealogy" });
return;
const requestRawDiscardConfirmation = discardConfirmation.request;
const requestDiscardConfirmation = async () => {
const confirmed = await requestRawDiscardConfirmation();
if (confirmed && currentStep.value === "create" && genealogyId.value) {
removeLocalGenealogyPreview(genealogyId.value);
genealogyId.value = "";
}
uni.navigateBack();
return confirmed;
};
const confirmDiscard = discardConfirmation.confirm;
const cancelDiscard = discardConfirmation.cancel;
onUnload(() => {
const timer = submitTimer;
submitTimer = null;
if (timer) clearTimeout(timer);
discardConfirmation.dispose();
});
const closeActiveTransient = () => {
if (duplicateReminderVisible.value) closeDuplicateReminder();
else cancelDiscard();
};
const popInternalTrail = () => {
currentStep.value = "create";
ancestorState.value = "form";
return true;
};
const requestBack = () => {
if (ancestorState.value === "success") return enterOverview();
return runBackGuard({
transientOpen: duplicateReminderVisible.value || discardVisible.value,
internalTrail: isAncestorStep.value && !isSubmitting.value,
dirty: isDirty.value,
submitting: isSubmitting.value,
"close-transient": closeActiveTransient,
"pop-internal-trail": popInternalTrail,
"block-submitting": () => true,
"confirm-discard": requestDiscardConfirmation,
});
};
onBackPress((event) => handleBackPress(event, requestBack));
const clearFieldError = (field) => {
fieldErrors[field] = "";
@@ -333,23 +391,31 @@ const submitCreate = () => {
const closeDuplicateReminder = () => {
duplicateReminderVisible.value = false;
};
const searchExistingGenealogy = () =>
uni.navigateTo({ url: "/pages/genealogy/g06-search-genealogies" });
const confirmCreate = () => {
const searchExistingGenealogy = () => {
closeDuplicateReminder();
return openPage("G06", {}, "G03");
};
const confirmCreate = () => {
if (isSubmitting.value) return;
closeDuplicateReminder();
const createSnapshot = Object.freeze({ ...createForm });
isSubmitting.value = true;
createState.value = "submitting";
submitTimer = setTimeout(() => {
const timer = setTimeout(() => {
if (submitTimer !== timer) return;
submitTimer = null;
isSubmitting.value = false;
if (createForm.name.trim() === "失败") {
if (createSnapshot.name.trim() === "失败") {
createState.value = "error";
return;
}
const createdId = "local-created-genealogy";
uni.redirectTo({
url: `/pages/genealogy/g03-create-genealogy?step=ancestor&genealogyId=${createdId}`,
});
genealogyId.value =
updateLocalGenealogyPreview(genealogyId.value, createSnapshot) ||
createLocalGenealogyPreview(createSnapshot);
currentStep.value = "ancestor";
createState.value = "form";
}, 320);
submitTimer = timer;
};
const submitAncestor = () => {
@@ -363,19 +429,29 @@ const submitAncestor = () => {
return;
}
const ancestorSnapshot = Object.freeze({ ...ancestorForm });
isSubmitting.value = true;
ancestorState.value = "submitting";
submitTimer = setTimeout(() => {
const timer = setTimeout(() => {
if (submitTimer !== timer) return;
submitTimer = null;
isSubmitting.value = false;
ancestorState.value =
ancestorForm.personName.trim() === "失败" ? "error" : "success";
if (ancestorSnapshot.personName.trim() === "失败") {
ancestorState.value = "error";
return;
}
ancestorState.value = updateLocalGenealogyPreviewAncestor(
genealogyId.value,
ancestorSnapshot,
)
? "success"
: "error";
}, 320);
submitTimer = timer;
};
const enterOverview = () =>
uni.redirectTo({
url: `/pages/genealogy/g05-genealogy-overview?genealogyId=${genealogyId.value}`,
});
returnTo("G05", { genealogyId: genealogyId.value });
</script>
<style scoped lang="scss">
+127 -106
View File
@@ -4,15 +4,15 @@
<GenealogyPageBackground />
<view class="overview-page__header">
<PageHeader
:title="viewMode === 'public' ? '家谱公开预览' : '家谱总览'"
:action="
overviewState === 'ready' &&
viewMode === 'member' &&
accessRole === 'owner'
? '管理'
: ''
:title="
viewMode === 'public'
? '家谱公开预览'
: viewMode === 'preview'
? '创建流程预览'
: '家谱总览'
"
@action="toSettings"
custom-back
@back="requestBack"
/>
</view>
@@ -36,7 +36,7 @@
<view class="overview-hero__stats">
<text> {{ genealogy.memberCount || 0 }} </text>
<text>已激活 {{ genealogy.activeCount || 0 }} </text>
<text>{{ genealogy.visibility || "仅成员可见" }}</text>
<text>{{ getGenealogyAccessPresetLabel(genealogy.accessPreset) }}</text>
</view>
<view class="overview-hero__stats">
<text>始祖 {{ genealogy.ancestorName }}</text>
@@ -52,14 +52,6 @@
<text class="overview-action__title">世系树</text
><text>查看家脉关系</text>
</view>
<view
v-if="accessRole === 'owner'"
class="overview-action overview-action--ancestor"
@click="toFirstPerson"
>
<text class="overview-action__title">录入族人</text
><text>从首代开始完善</text>
</view>
<view
class="overview-action overview-action--poem"
@click="toGenerationPoems"
@@ -75,6 +67,24 @@
<text class="overview-action__title">入谱审核</text
><text>处理加入申请</text>
</view>
<view
v-if="accessRole === 'owner'"
class="overview-action overview-action--settings"
@click="toSettings"
>
<text class="overview-action__title">家谱设置</text
><text>维护公开范围与基础资料</text>
</view>
<template v-else>
<view class="overview-summary">
<text class="overview-action__title">成员身份</text
><text>已加入 · 普通成员</text>
</view>
<view class="overview-summary">
<text class="overview-action__title">访问范围</text
><text>{{ getGenealogyAccessPresetLabel(genealogy.accessPreset) }}</text>
</view>
</template>
</view>
<view class="overview-family" @click="toFamily">
@@ -82,8 +92,8 @@
><text>查看</text>
</view>
<view class="overview-note">
<text class="overview-note__title">家谱资料仅向家人开放</text>
<text>公开范围访问说明与管理权由家谱管理员在设置中维护</text>
<text class="overview-note__title">成员资料按家谱访问规则保护</text>
<text>名称公开范围与家谱简介由谱主在设置中维护</text>
</view>
</view>
</template>
@@ -93,34 +103,49 @@
class="overview-public"
>
<view class="overview-public__hero">
<text class="overview-public__eyebrow">公开家谱</text>
<text class="overview-public__eyebrow">{{
viewMode === "preview" ? "本地流程预览" : "公开家谱"
}}</text>
<text class="overview-public__title">{{ genealogy.name }}</text>
<text class="overview-public__source">{{ genealogy.source }}</text>
<text class="overview-public__source">{{ genealogy.source || "来源信息待同步" }}</text>
</view>
<view class="overview-public__details">
<view
><text>姓氏</text><text>{{ genealogy.surname }}</text></view
>
<view
><text>地区</text><text>{{ genealogy.location }}</text></view
><text>地区</text><text>{{ genealogy.location || "待补充" }}</text></view
>
<view
><text>堂号</text><text>{{ genealogy.hall }}</text></view
><text>堂号</text><text>{{ genealogy.hall || "待补充" }}</text></view
>
<view
><text>当前支系</text><text>{{ genealogy.branchName }}</text></view
><text>当前支系</text><text>{{ genealogy.branchName || "待补充" }}</text></view
>
<view
><text>所属上级谱</text
><text>{{ genealogy.parentName }}</text></view
><text>{{ viewMode === "preview" ? "首代人物" : "所属上级谱" }}</text
><text>{{ (viewMode === "preview" ? genealogy.ancestorName : genealogy.parentName) || "待补充" }}</text></view
>
</view>
<view
v-if="viewMode === 'public' && (genealogy.manager || genealogy.certification)"
class="overview-public__trust"
>
<text>{{ genealogy.manager || "管理者待确认" }}</text>
<text>{{ genealogy.certification || "认证信息待确认" }}</text>
<text>{{ genealogy.memberCount || 0 }} 位成员</text>
<text>更新于 {{ genealogy.updatedAt || "待同步" }}</text>
</view>
<view class="overview-public__notice">
<text>公开说明</text>
<text>{{ genealogy.publicDescription }}</text>
<text>{{ viewMode === "preview" ? "预览说明" : "公开说明" }}</text>
<text>{{ genealogy.publicDescription || "公开说明待补充" }}</text>
</view>
<view class="overview-public__action" @click="applyToJoin">
<text>申请加入这部家谱</text>
<view
v-if="viewMode === 'public' && publicActionLabel"
class="overview-public__action"
@click="applyToJoin"
>
<text>{{ publicActionLabel }}</text>
</view>
</view>
@@ -169,55 +194,39 @@
<script setup>
import { computed, ref } from "vue";
import { onLoad } from "@dcloudio/uni-app";
import { onBackPress, onLoad } from "@dcloudio/uni-app";
import AppLoading from "@/components/AppLoading.vue";
import GenealogyPageBackground from "@/components/GenealogyPageBackground.vue";
import PageHeader from "@/components/PageHeader.vue";
import { findGenealogyFixture, getGenealogyFixtureAccess } from "@/data/mock.js";
import { getGenealogyAccessPresetLabel } from "@/utils/genealogy-contracts.js";
import {
goBack,
goRoot,
handleBackPress,
openPage,
returnTo,
} from "@/utils/navigation.js";
const genealogy = ref(null);
const genealogyId = ref("");
const overviewState = ref("loading");
const loadError = ref("");
const viewMode = ref("member");
const accessRole = ref("owner");
const overviewFixture = {
id: "2001",
surname: "汤",
name: "汤氏南阳宗谱",
hall: "敦睦堂",
location: "河南·南阳",
parentName: "汤氏中华总谱",
branchName: "南阳主支",
source: "由南阳汤氏族人整理并维护",
publicDescription:
"公开展示家谱身份、地区、堂号与支系信息;成员资料和世系详情仅向已加入成员开放。",
motto: "敦亲睦族,敬祖传家。",
memberCount: 428,
activeCount: 316,
visibility: "仅成员可见",
ancestorName: "汤文远",
updatedAt: "2026-07-12",
};
const overviewFixtures = {
1001: {
...overviewFixture,
id: "1001",
name: "汤氏家谱",
location: "河南·洛阳",
memberCount: 158,
activeCount: 108,
},
1002: {
...overviewFixture,
id: "1002",
name: "汤氏宗谱",
hall: "承志堂",
location: "山东·济宁",
memberCount: 286,
activeCount: 215,
},
2001: overviewFixture,
};
const accessRole = ref("guest");
const publicRelation = ref("unknown");
const publicCanApply = ref(false);
const publicActionLabel = computed(() => {
if (publicRelation.value === "pending") return "查看申请进度";
if (!publicCanApply.value) return "";
return (
({
available: "申请加入这部家谱",
rejected: "修改后重新申请",
removed: "重新申请加入",
})[publicRelation.value] || ""
);
});
const stateTitle = computed(
() =>
@@ -244,9 +253,11 @@ const loadGenealogy = (query = {}) => {
overviewState.value = "loading";
loadError.value = "";
genealogy.value = null;
genealogyId.value = query.genealogyId || genealogyId.value || "";
viewMode.value = query.mode === "public" ? "public" : "member";
accessRole.value = query.role === "member" ? "member" : "owner";
genealogyId.value = String(query.genealogyId || genealogyId.value || "");
viewMode.value = "member";
accessRole.value = "guest";
publicRelation.value = "unknown";
publicCanApply.value = false;
if (query.state === "empty" || !genealogyId.value) {
overviewState.value = "empty";
@@ -262,45 +273,49 @@ const loadGenealogy = (query = {}) => {
}
if (query.state === "loading") return;
genealogy.value = {
...(overviewFixtures[genealogyId.value] || overviewFixture),
id: genealogyId.value,
};
if (query.genealogyName) {
genealogy.value.name = decodeURIComponent(query.genealogyName);
const fixture = findGenealogyFixture(genealogyId.value);
if (!fixture) {
overviewState.value = "no-permission";
return;
}
const access = getGenealogyFixtureAccess(genealogyId.value);
if (!access.canView) {
overviewState.value = "no-permission";
return;
}
genealogy.value = { ...fixture };
viewMode.value = access.viewMode;
accessRole.value = access.accessRole;
publicRelation.value = access.relation;
publicCanApply.value = access.canApply;
overviewState.value = "ready";
};
const requestBack = () => goBack();
onBackPress((event) => handleBackPress(event, requestBack));
onLoad(loadGenealogy);
const reloadOverview = () => loadGenealogy({ genealogyId: genealogyId.value });
const toGenealogies = () =>
uni.reLaunch({ url: "/pages/genealogy/g01-my-genealogies" });
const toGenealogies = () => returnTo("G01", {});
const toTree = () =>
uni.navigateTo({
url: `/pages/tree/t01-tree-overview?genealogyId=${genealogyId.value}`,
});
const toFirstPerson = () =>
uni.navigateTo({
url: `/pages/genealogy/g03-create-genealogy?step=ancestor&genealogyId=${genealogyId.value}`,
});
const toFamily = () => uni.reLaunch({ url: "/pages/family/f01-family-feed" });
openPage("T01", { genealogyId: genealogyId.value }, "G05");
const toFamily = () => goRoot("F01", { genealogyId: genealogyId.value });
const toApplications = () =>
uni.navigateTo({
url: `/pages/genealogy/g10-application-review?genealogyId=${genealogyId.value}`,
});
openPage("G10", { genealogyId: genealogyId.value }, "G05");
const toSettings = () =>
uni.navigateTo({
url: `/pages/genealogy/g11-genealogy-settings?genealogyId=${genealogyId.value}`,
});
openPage("G11", { genealogyId: genealogyId.value }, "G05");
const toGenerationPoems = () =>
uni.navigateTo({
url: `/pages/genealogy/g12-generation-poems?genealogyId=${genealogyId.value}`,
});
const applyToJoin = () =>
uni.navigateTo({
url: `/pages/genealogy/g08-join-application?source=search&genealogyId=${genealogyId.value}`,
});
openPage("G12", { genealogyId: genealogyId.value }, "G05");
const applyToJoin = () => {
if (publicRelation.value === "pending")
return openPage("G09", { status: "pending" }, "G05");
if (!publicCanApply.value) return false;
return openPage(
"G08",
{ genealogyId: genealogyId.value, source: "search" },
"G05",
);
};
</script>
<style scoped lang="scss">
@@ -380,7 +395,7 @@ const applyToJoin = () =>
min-height: 386rpx;
}
.overview-action,
.overview-action-lock {
.overview-summary {
display: flex;
flex-direction: column;
justify-content: center;
@@ -390,9 +405,6 @@ const applyToJoin = () =>
color: $ink-muted;
font-size: 22rpx;
}
.overview-action-lock {
opacity: 0.58;
}
.overview-action__title {
margin-bottom: 9rpx;
color: $ink;
@@ -562,6 +574,15 @@ const applyToJoin = () =>
font-family: "STKaiti", "KaiTi", serif;
font-size: 27rpx;
}
.overview-public__trust {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
gap: 10rpx 24rpx;
margin: 28rpx 9% 0;
color: $ink-muted;
font-size: 21rpx;
line-height: 1.45;
}
.overview-public__notice {
display: flex;
margin: 54rpx 9% 0;
+92 -132
View File
@@ -97,7 +97,7 @@
<text
class="result-card__relation"
:class="`result-card__relation--${item.relation}`"
>{{ item.actionLabel }}</text
>{{ resultRelationLabel(item) }}</text
>
</view>
<view class="result-card__facts">
@@ -113,9 +113,10 @@
>
</view>
<view
v-if="resultActionLabel(item)"
class="result-card__action"
@click.stop="handleResultAction(item)"
>{{ item.actionLabel }}</view
>{{ resultActionLabel(item) }}</view
>
</view>
</view>
@@ -159,6 +160,7 @@
maxlength="12"
placeholder="请输入邀请码"
placeholder-class="search-input__placeholder"
@input="resetInvite"
/>
</view>
<view class="search-action" @click="verifyInvite">
@@ -179,7 +181,7 @@
>
<text class="search-status__lead">通过邀请码直接定位家谱</text>
<text class="search-status__copy"
>验证有效后显示目标家谱确认关系后可直接加入</text
>验证有效后显示目标家谱仍需填写身份关系本地验证不会变更成员身份</text
>
</view>
<view
@@ -209,11 +211,11 @@
>
</view>
<view class="result-card__action" @click="confirmInvite"
>确认关系并加入</view
>填写关系信息</view
>
</view>
</view>
<text class="invite-result__note">提交后直接加入无需等待审核</text>
<text class="invite-result__note">当前为样式验证不会变更成员身份</text>
</view>
</template>
</view>
@@ -226,6 +228,12 @@ import { onLoad, onUnload } from "@dcloudio/uni-app";
import AppLoading from "@/components/AppLoading.vue";
import GenealogyPageBackground from "@/components/GenealogyPageBackground.vue";
import PageHeader from "@/components/PageHeader.vue";
import {
getGenealogyFixtureAccess,
isGenealogySearchVisible,
publicGenealogies,
} from "@/data/mock.js";
import { goRoot, openPage } from "@/utils/navigation.js";
const mode = ref("search");
const keyword = ref("");
@@ -235,102 +243,14 @@ const inviteCode = ref("");
const inviteState = ref("initial");
const results = ref([]);
let searchTimer = null;
const invalidateSearch = () => {
const timer = searchTimer;
searchTimer = null;
if (timer) clearTimeout(timer);
};
const areas = ["全部", "河南", "山东"];
const resultFixtures = [
{
id: 2001,
surname: "汤",
name: "汤氏南阳宗谱",
hall: "敦睦堂",
location: "河南·南阳",
parentName: "汤氏中华总谱",
branchName: "南阳主支",
manager: "管理员 汤文礼",
certification: "资料已认证",
memberCount: 428,
updatedAt: "2026-07-12",
relation: "available",
actionLabel: "申请加入",
},
{
id: 2002,
surname: "汤",
name: "汤氏洛阳家谱",
hall: "承志堂",
location: "河南·洛阳",
parentName: "汤氏中华总谱",
branchName: "洛阳二支",
manager: "管理员 汤文远",
certification: "资料已认证",
memberCount: 158,
updatedAt: "2026-07-10",
relation: "joined",
actionLabel: "已加入",
},
{
id: 2003,
surname: "汤",
name: "汤氏济宁宗谱",
hall: "敬宗堂",
location: "山东·济宁",
parentName: "汤氏鲁西总谱",
branchName: "济宁主支",
manager: "管理员 汤正明",
certification: "管理员已实名",
memberCount: 286,
updatedAt: "2026-07-08",
relation: "pending",
actionLabel: "审核中",
},
{
id: 2004,
surname: "汤",
name: "汤氏清河家谱",
hall: "思源堂",
location: "山东·临清",
parentName: "汤氏鲁西总谱",
branchName: "清河支系",
manager: "管理员 汤志成",
certification: "资料已认证",
memberCount: 96,
updatedAt: "2026-07-05",
relation: "rejected",
actionLabel: "修改后重新申请",
},
{
id: 2005,
surname: "汤",
name: "汤氏汝南支谱",
hall: "崇本堂",
location: "河南·驻马店",
parentName: "汤氏中原总谱",
branchName: "汝南三支",
manager: "管理员 汤国安",
certification: "管理员已实名",
memberCount: 72,
updatedAt: "2026-07-02",
relation: "removed",
actionLabel: "重新申请",
},
{
id: 2006,
surname: "汤",
name: "汤氏新安家谱",
hall: "继善堂",
location: "河南·三门峡",
parentName: "无上级谱",
branchName: "新安主支",
manager: "创建者 当前用户",
certification: "资料待完善",
memberCount: 34,
updatedAt: "2026-06-28",
relation: "owned",
actionLabel: "我创建的",
},
];
const inviteTarget = computed(() => resultFixtures[0]);
const inviteTarget = computed(() => publicGenealogies[0]);
const syncModeFromRoute = (query = {}) => {
mode.value = query?.mode === "invite" ? "invite" : "search";
@@ -339,81 +259,121 @@ const syncModeFromRoute = (query = {}) => {
};
onLoad((query) => syncModeFromRoute(query));
onUnload(() => {
if (searchTimer) clearTimeout(searchTimer);
});
onUnload(invalidateSearch);
const switchMode = (nextMode) => {
invalidateSearch();
mode.value = nextMode;
results.value = [];
searchState.value = "initial";
inviteState.value = "initial";
};
const search = () => {
if (searchTimer) clearTimeout(searchTimer);
invalidateSearch();
searchState.value = "loading";
searchTimer = setTimeout(() => {
const value = keyword.value.trim();
const value = keyword.value.trim();
const area = selectedArea.value;
const timer = setTimeout(() => {
if (searchTimer !== timer || mode.value !== "search") return;
searchTimer = null;
if (value === "失败") {
searchState.value = "error";
return;
}
results.value = resultFixtures.filter((item) => {
results.value = publicGenealogies.filter((item) => {
const matchesKeyword =
!value ||
`${item.name}${item.surname}${item.location}${item.hall}`.includes(
value,
);
const matchesArea =
selectedArea.value === "全部" ||
item.location.includes(selectedArea.value);
return matchesKeyword && matchesArea;
area === "全部" || item.location.includes(area);
return isGenealogySearchVisible(String(item.id)) && matchesKeyword && matchesArea;
});
searchState.value = results.value.length ? "results" : "empty";
searchTimer = null;
}, 260);
searchTimer = timer;
};
const clearSearch = () => {
invalidateSearch();
keyword.value = "";
results.value = [];
searchState.value = "initial";
};
const openPreview = (item) => {
if (item.relation === "available") {
uni.navigateTo({
url: `/pages/genealogy/g05-genealogy-overview?mode=public&genealogyId=${item.id}&genealogyName=${encodeURIComponent(item.name)}`,
});
if (
item.relation === "available" &&
getGenealogyFixtureAccess(String(item.id)).canApply
) {
return openPage(
"G05",
{ genealogyId: String(item.id) },
"G06",
);
}
return false;
};
const resultActionLabel = (item) => {
if (item.relation === "pending") return "查看申请进度";
if (item.relation === "joined") return "切换到该家谱";
if (item.relation === "owned") return "进入我的家谱";
if (!getGenealogyFixtureAccess(String(item.id)).canApply) return "";
return {
available: "申请加入",
rejected: "修改后重新申请",
removed: "重新申请",
}[item.relation] || "";
};
const resultRelationLabel = (item) =>
({
available: "可申请",
joined: "已加入",
pending: "审核中",
rejected: "已拒绝",
removed: "已退出",
owned: "我创建的",
})[item.relation] || "关系待确认";
const handleResultAction = (item) => {
if (item.relation === "available")
return uni.navigateTo({
url: `/pages/genealogy/g08-join-application?source=search&genealogyId=${item.id}&genealogyName=${encodeURIComponent(item.name)}`,
});
if (item.relation === "pending")
return uni.navigateTo({
url: "/pages/genealogy/g09-my-applications?status=pending",
});
if (item.relation === "rejected" || item.relation === "removed")
return uni.navigateTo({
url: `/pages/genealogy/g08-join-application?source=search&previous=${item.relation}&genealogyId=${item.id}&genealogyName=${encodeURIComponent(item.name)}`,
});
return uni.reLaunch({
url: `/pages/genealogy/g01-my-genealogies?genealogyId=${item.id}`,
});
return openPage("G09", { status: "pending" }, "G06");
if (
["available", "rejected", "removed"].includes(item.relation) &&
getGenealogyFixtureAccess(String(item.id)).canApply
)
return openPage(
"G08",
{ genealogyId: String(item.id), source: "search" },
"G06",
);
if (item.relation === "joined" || item.relation === "owned")
return goRoot("G01", { genealogyId: String(item.id) });
return false;
};
const verifyInvite = () => {
inviteState.value =
inviteCode.value.toUpperCase() === "JP2026" ? "valid" : "invalid";
};
const confirmInvite = () =>
uni.navigateTo({
url: `/pages/genealogy/g08-join-application?source=invite&genealogyId=${inviteTarget.value.id}&genealogyName=${encodeURIComponent(inviteTarget.value.name)}`,
});
const resetInvite = () => {
inviteState.value = "initial";
};
const confirmInvite = () => {
if (inviteState.value !== "valid") return false;
if (inviteCode.value.trim().toUpperCase() !== "JP2026") return false;
return openPage(
"G08",
{
genealogyId: String(inviteTarget.value.id),
source: "invite",
},
"G06",
);
};
</script>
<style scoped lang="scss">
+148 -64
View File
@@ -2,9 +2,9 @@
<template>
<view class="join-page">
<GenealogyPageBackground />
<view class="join-page__header"
><PageHeader :title="sourceContract.headerTitle"
/></view>
<view class="join-page__header">
<PageHeader :title="sourceContract.headerTitle" custom-back @back="requestBack" />
</view>
<view
class="join-panel"
@@ -12,6 +12,7 @@
'join-state--form': joinState === 'form',
'join-state--success': joinState === 'success',
'join-state--error': joinState === 'error',
'join-state--ineligible': joinState === 'ineligible',
}"
>
<view v-if="joinState === 'form'" class="join-form">
@@ -20,10 +21,6 @@
>{{ sourceContract.formTitle }} {{ genealogyName }}</text
>
<text class="join-form__copy">{{ sourceContract.formCopy }}</text>
<text v-if="previousNotice" class="join-form__previous">{{
previousNotice
}}</text>
<view class="join-field">
<text>真实姓名</text
><input
@@ -76,74 +73,113 @@
<text class="join-result__eyebrow">{{
joinState === "success"
? sourceContract.successEyebrow
: joinState === "ineligible"
? "当前不可申请"
: sourceContract.errorEyebrow
}}</text>
<text class="join-result__title">{{
joinState === "success"
? sourceContract.successTitle
: joinState === "ineligible"
? ineligibleTitle
: sourceContract.errorTitle
}}</text>
<text class="join-result__copy">{{ resultCopy }}</text>
<view
class="join-action"
@click="joinState === 'success' ? completeFlow() : retryForm()"
@click="
joinState === 'success'
? completeFlow()
: joinState === 'ineligible'
? handleIneligibleAction()
: retryForm()
"
>
<text>{{
joinState === "success" ? sourceContract.nextLabel : "重新填写"
joinState === "success"
? sourceContract.nextLabel
: joinState === "ineligible"
? ineligibleActionLabel
: "重新填写"
}}</text>
</view>
</view>
</view>
<AppDialog
:visible="discardVisible"
title="放弃填写?"
message="当前身份关系尚未保存,确认返回后将清空。"
confirm-text="放弃并返回"
cancel-text="继续填写"
show-cancel
compact-actions
:close-on-mask="false"
@confirm="confirmDiscard"
@cancel="cancelDiscard"
/>
</view>
</template>
<script setup>
import { computed, reactive, ref } from "vue";
import { onLoad } from "@dcloudio/uni-app";
import { onBackPress, onLoad, onUnload } from "@dcloudio/uni-app";
import AppDialog from "@/components/AppDialog.vue";
import GenealogyPageBackground from "@/components/GenealogyPageBackground.vue";
import PageHeader from "@/components/PageHeader.vue";
import {
findGenealogyFixture,
getGenealogyFixtureAccess,
} from "@/data/mock.js";
import { createDiscardConfirmation } from "@/utils/discard-confirmation.js";
import {
goBack,
goRoot,
handleBackPress,
openPage,
returnTo,
runBackGuard,
} from "@/utils/navigation.js";
const genealogyId = ref("");
const source = ref("search");
const previousRelation = ref("");
const genealogyName = ref("这部家谱");
const genealogyPreview = {
1001: "汤氏家谱",
1002: "汝南汤氏家谱",
};
const joinState = ref("form");
const isSubmitting = ref(false);
const errorMessage = ref("");
const ineligibleRelation = ref("unknown");
const discardVisible = ref(false);
const form = reactive({ realName: "", relation: "", message: "" });
const fieldErrors = reactive({ realName: "", relation: "" });
const previousNotice = computed(() =>
previousRelation.value === "rejected"
? "上次申请未通过,请补充更准确的长辈姓名、祖居地或支系信息。"
: previousRelation.value === "removed"
? "你曾退出或被移出这部家谱,请重新确认身份关系后申请。"
: previousRelation.value === "withdrawn"
? "上次申请已撤回;如仍希望加入,请重新确认关系并提交。"
: "",
let submitTimer = null;
const isDirty = computed(() =>
Boolean(form.realName || form.relation || form.message),
);
const discardConfirmation = createDiscardConfirmation((visible) => {
discardVisible.value = visible;
});
const requestDiscardConfirmation = discardConfirmation.request;
const confirmDiscard = discardConfirmation.confirm;
const cancelDiscard = discardConfirmation.cancel;
const sourceContract = computed(() =>
source.value === "invite"
? {
headerTitle: "确认关系并加入",
eyebrow: "邀请码直接加入",
formTitle: "确认加入",
headerTitle: "确认身份关系",
eyebrow: "邀请码定位预览",
formTitle: "核对家谱",
formCopy:
"请填写真实身份和亲属关系;提交后直接加入,无需等待管理员审核。",
"请填写真实身份和亲属关系;当前仅验证页面流程,不会变更成员身份。",
thirdFieldLabel: "补充信息",
thirdFieldPlaceholder: "选填:补充祖居地、长辈姓名等信息",
note: "邀请码来源提交后直接加入,无需等待审核。",
submitLabel: "确认加入",
submittingLabel: "正在加入…",
successEyebrow: "已加入家谱",
successTitle: "关系信息已提交",
errorEyebrow: "加入未完成",
errorTitle: "暂时无法加入家谱",
note: "后端尚未提供邀请码验证与直接入谱接口。",
submitLabel: "完成本地校验",
submittingLabel: "正在校验…",
successEyebrow: "本地校验完成",
successTitle: "信息尚未提交服务器",
errorEyebrow: "校验未完成",
errorTitle: "暂时无法完成校验",
nextLabel: "返回我的家谱",
successCopy: `已直接加入${genealogyName.value},无需等待审核;返回后将刷新并选中这部家谱。`,
successCopy: `本地流程预览已完成${genealogyName.value}的身份填写;返回后不会选中或加入这部家谱。`,
}
: {
headerTitle: "申请加入家谱",
@@ -152,42 +188,84 @@ const sourceContract = computed(() =>
formCopy: "请填写真实身份和亲属关系,管理员审核后会通过消息告知结果。",
thirdFieldLabel: "申请说明",
thirdFieldPlaceholder: "补充祖居地、长辈姓名等核验信息",
note: "提交后可在“我的申请”中查看审核进度。",
submitLabel: "提交申请",
submittingLabel: "正在提交…",
successEyebrow: "申请已送达",
successTitle: "等待管理员核实亲属关系",
note: "当前仅验证页面流程,后端申请接口接入后才能正式提交。",
submitLabel: "完成本地校验",
submittingLabel: "正在校验…",
successEyebrow: "本地校验完成",
successTitle: "申请尚未提交服务器",
errorEyebrow: "申请未提交",
errorTitle: "暂时无法提交申请",
errorTitle: "暂时无法完成校验",
nextLabel: "查看我的申请",
successCopy: `${genealogyName.value}”的管理员会在核实后给出结果,请留意消息中心`,
successCopy: `本地流程预览已完成${genealogyName.value}”的申请填写,当前不会新增审核记录`,
},
);
const resultCopy = computed(() =>
joinState.value === "success"
? sourceContract.value.successCopy
: joinState.value === "ineligible"
? ({
owned: "这是你创建的家谱,无需重复提交加入申请。",
joined: "你已经是这部家谱的成员,无需重复申请。",
pending: "这部家谱已有待审核申请,请先查看申请进度。",
})[ineligibleRelation.value] ||
"当前家谱不存在、未公开或不可申请,请返回后重新选择。"
: errorMessage.value ||
"请检查网络后重新填写;未成功提交的内容不会进入审核列表。",
);
const ineligibleTitle = computed(
() =>
({
owned: "你已拥有这部家谱",
joined: "你已加入这部家谱",
pending: "申请正在审核中",
})[ineligibleRelation.value] || "无法打开申请表",
);
const ineligibleActionLabel = computed(
() =>
ineligibleRelation.value === "pending"
? "查看申请进度"
: ["owned", "joined"].includes(ineligibleRelation.value)
? "返回我的家谱"
: "返回上一页",
);
onLoad((query) => {
genealogyId.value = query.genealogyId || "";
genealogyId.value = String(query.genealogyId || "");
source.value = query.source === "invite" ? "invite" : "search";
previousRelation.value = ["rejected", "removed", "withdrawn"].includes(query.previous)
? query.previous
: "";
if (query.state === "success") {
joinState.value = "success";
return;
}
if (!genealogyId.value) {
errorMessage.value = "没有找到要申请加入的家谱,请先返回公开家谱检索。";
joinState.value = "error";
ineligibleRelation.value = "unknown";
joinState.value = "ineligible";
return;
}
genealogyName.value = query.genealogyName
? decodeURIComponent(query.genealogyName)
: genealogyPreview[genealogyId.value] || "这部家谱";
const fixture = findGenealogyFixture(genealogyId.value);
genealogyName.value = fixture?.name || "这部家谱";
const access = getGenealogyFixtureAccess(genealogyId.value);
if (!fixture || !access.canApply) {
ineligibleRelation.value = access.relation;
joinState.value = "ineligible";
return;
}
if (query.state === "success") joinState.value = "success";
});
const requestBack = () => {
if (joinState.value === "success") return completeFlow();
return runBackGuard({
transientOpen: discardVisible.value,
dirty: isDirty.value,
submitting: isSubmitting.value,
"close-transient": cancelDiscard,
"block-submitting": () => true,
"confirm-discard": requestDiscardConfirmation,
});
};
onBackPress((event) => handleBackPress(event, requestBack));
onUnload(() => {
const timer = submitTimer;
submitTimer = null;
if (timer) clearTimeout(timer);
discardConfirmation.dispose();
});
const submitJoin = () => {
@@ -195,9 +273,13 @@ const submitJoin = () => {
fieldErrors.realName = form.realName.trim() ? "" : "请填写真实姓名";
fieldErrors.relation = form.relation.trim() ? "" : "请填写与家谱的关系";
if (fieldErrors.realName || fieldErrors.relation) return;
const submitSnapshot = Object.freeze({ ...form });
isSubmitting.value = true;
setTimeout(() => {
joinState.value = form.realName.trim() === "失败" ? "error" : "success";
const timer = setTimeout(() => {
if (submitTimer !== timer) return;
submitTimer = null;
joinState.value =
submitSnapshot.realName.trim() === "失败" ? "error" : "success";
if (joinState.value === "error")
errorMessage.value =
source.value === "invite"
@@ -205,6 +287,7 @@ const submitJoin = () => {
: "申请暂未提交,请稍后重试。";
isSubmitting.value = false;
}, 280);
submitTimer = timer;
};
const clearFieldError = (field) => {
fieldErrors[field] = "";
@@ -213,14 +296,15 @@ const retryForm = () => {
joinState.value = "form";
errorMessage.value = "";
};
const toMyApplications = () =>
uni.redirectTo({ url: "/pages/genealogy/g09-my-applications" });
const toMyGenealogies = () =>
uni.reLaunch({
url: `/pages/genealogy/g01-my-genealogies?genealogyId=${genealogyId.value}`,
});
const handleIneligibleAction = () => {
if (ineligibleRelation.value === "pending")
return openPage("G09", { status: "pending" }, "G08");
if (["owned", "joined"].includes(ineligibleRelation.value))
return goRoot("G01", { genealogyId: genealogyId.value });
return goBack();
};
const completeFlow = () =>
source.value === "invite" ? toMyGenealogies() : toMyApplications();
source.value === "invite" ? returnTo("G01", {}) : returnTo("G09", {});
</script>
<style scoped lang="scss">
+45 -34
View File
@@ -2,9 +2,9 @@
<template>
<view class="application-page">
<GenealogyPageBackground />
<view class="application-page__header"
><PageHeader title="我的申请"
/></view>
<view class="application-page__header">
<PageHeader title="我的申请" custom-back @back="requestBack" />
</view>
<view
class="application-content"
@@ -71,55 +71,59 @@
<AppDialog
:visible="!!withdrawTarget"
title="撤回加入申请"
title="预览撤回效果"
:message="
withdrawTarget
? `确认撤回对“${withdrawTarget.genealogyName}”的申请?撤回后如需加入,可重新提交。`
? `当前只更新本页对“${withdrawTarget.genealogyName}”的撤回预览,不会向服务器提交;真实申请仍可能处于审核中。`
: ''
"
confirm-text="确认撤回"
confirm-text="查看本地效果"
cancel-text="暂不撤回"
show-cancel
:close-on-mask="false"
@confirm="confirmWithdraw"
@cancel="cancelWithdraw"
@close="cancelWithdraw"
/>
</view>
</template>
<script setup>
import { computed, ref } from "vue";
import { onLoad } from "@dcloudio/uni-app";
import { onBackPress, onLoad } from "@dcloudio/uni-app";
import AppDialog from "@/components/AppDialog.vue";
import AppLoading from "@/components/AppLoading.vue";
import GenealogyPageBackground from "@/components/GenealogyPageBackground.vue";
import PageHeader from "@/components/PageHeader.vue";
import { findGenealogyFixture } from "@/data/mock.js";
import { handleBackPress, openPage, runBackGuard } from "@/utils/navigation.js";
const applications = ref([]);
const applicationState = ref("loading");
const errorMessage = ref("");
const withdrawTarget = ref(null);
const genealogyNameFor = (genealogyId) =>
findGenealogyFixture(genealogyId)?.name || "未知家谱";
const applicationSamples = [
{
id: 1,
genealogyId: 1001,
genealogyName: "汤氏家谱",
id: "pending-2003",
genealogyId: "2003",
genealogyName: genealogyNameFor("2003"),
relation: "自述为汤正华堂侄",
appliedAt: "今天 10:24",
status: "PENDING",
},
{
id: 2,
genealogyId: 1002,
genealogyName: "汝南汤氏家谱",
id: "approved-1002",
genealogyId: "1002",
genealogyName: genealogyNameFor("1002"),
relation: "祖居河南汝南",
appliedAt: "昨天 18:02",
status: "APPROVED",
},
{
id: 3,
genealogyId: 1003,
genealogyName: "清河汤氏家谱",
id: "rejected-2004",
genealogyId: "2004",
genealogyName: genealogyNameFor("2004"),
relation: "补充材料不足",
appliedAt: "7月12日 09:18",
status: "REJECTED",
@@ -131,7 +135,7 @@ const statusLabel = (status) =>
PENDING: "审核中",
APPROVED: "已通过",
REJECTED: "未通过",
WITHDRAWN: "已撤回",
LOCAL_WITHDRAWN: "本地撤回预览",
})[status] || "状态未知";
const statusHint = (status) =>
@@ -139,11 +143,11 @@ const statusHint = (status) =>
PENDING: "管理员尚未处理,可在审核前撤回",
APPROVED: "申请已通过,可进入这部家谱",
REJECTED: "请修改关系说明后重新提交",
WITHDRAWN: "申请已撤回,不再进入管理员审核",
LOCAL_WITHDRAWN: "尚未提交服务器,真实申请仍可能处于审核",
})[status] || "";
const actionFor = (item) =>
({ PENDING: "撤回申请", APPROVED: "进入家谱", REJECTED: "修改后重新提交", WITHDRAWN: "重新申请" })[
({ PENDING: "预览撤回效果", APPROVED: "进入家谱", REJECTED: "修改后重新提交" })[
item.status
] || "";
const stateTitle = computed(() =>
@@ -155,7 +159,7 @@ const stateTitle = computed(() =>
);
const stateCopy = computed(() =>
applicationState.value === "empty"
? "从家谱搜索提交的申请会显示在这里;邀请码直接加入不进入本页。"
? "从家谱搜索提交的申请会显示在这里;邀请码本地校验不会生成申请记录。"
: applicationState.value === "loading"
? "请稍候,正在同步审核状态。"
: errorMessage.value || "请检查网络后重新查看。",
@@ -188,19 +192,27 @@ const loadApplications = (query = {}) => {
};
onLoad(loadApplications);
const requestBack = () =>
runBackGuard({
transientOpen: Boolean(withdrawTarget.value),
"close-transient": cancelWithdraw,
});
onBackPress((event) => handleBackPress(event, requestBack));
const handleApplicationAction = (item) => {
if (item.status === "APPROVED")
return uni.navigateTo({
url: `/pages/genealogy/g05-genealogy-overview?genealogyId=${item.genealogyId}`,
});
return openPage(
"G05",
{ genealogyId: String(item.genealogyId) },
"G09",
);
if (item.status === "REJECTED")
return uni.navigateTo({
url: `/pages/genealogy/g08-join-application?source=search&previous=rejected&genealogyId=${item.genealogyId}&genealogyName=${encodeURIComponent(item.genealogyName)}`,
});
if (item.status === "WITHDRAWN")
return uni.navigateTo({
url: `/pages/genealogy/g08-join-application?source=search&previous=withdrawn&genealogyId=${item.genealogyId}&genealogyName=${encodeURIComponent(item.genealogyName)}`,
});
return openPage(
"G08",
{ genealogyId: String(item.genealogyId), source: "search" },
"G09",
);
if (item.status === "PENDING") withdrawTarget.value = item;
};
const cancelWithdraw = () => {
@@ -210,11 +222,10 @@ const confirmWithdraw = () => {
const target = applications.value.find(
(item) => item.id === withdrawTarget.value?.id,
);
if (target) target.status = "WITHDRAWN";
if (target) target.status = "LOCAL_WITHDRAWN";
cancelWithdraw();
};
const toSearch = () =>
uni.navigateTo({ url: "/pages/genealogy/g06-search-genealogies" });
const toSearch = () => openPage("G06", {}, "G09");
</script>
<style scoped lang="scss">
+53 -31
View File
@@ -2,9 +2,15 @@
<template>
<view class="review-page">
<GenealogyPageBackground />
<view class="review-page__header"
><PageHeader title="入谱审核" action="说明" @action="showHelp"
/></view>
<view class="review-page__header">
<PageHeader
title="入谱审核"
action="说明"
custom-back
@action="showHelp"
@back="requestBack"
/>
</view>
<view
class="review-content"
@@ -17,7 +23,7 @@
>
<view v-if="reviewState !== 'loading'" class="review-intro">
<text>核实亲属关系后再决定</text>
<text>审核结果会通过消息告知申请人</text>
<text>当前只预览审核交互不会提交服务器</text>
</view>
<template v-if="reviewState === 'list'">
@@ -35,12 +41,12 @@
<AppButton
compact
type="secondary"
label="拒绝申请"
label="预览拒绝"
@click="confirmAudit(item, false)"
/>
<AppButton
compact
label="通过申请"
label="预览通过"
@click="confirmAudit(item, true)"
/>
</view>
@@ -86,28 +92,28 @@
helpVisible
? '审核说明'
: confirmation?.approved
? '确认通过申请'
: '确认拒绝申请'
? '预览通过效果'
: '预览拒绝效果'
"
:message="
helpVisible
? '请核对申请人的姓名、亲属关系和补充说明,仅确认与本家谱存在真实关系的申请。'
: confirmation?.approved
? '通过后,申请人成为本家谱成员。'
: '拒绝后,申请人会收到审核结果,并可修改后重新申请。'
? '当前只更新本页本地预览,不会让申请人成为成员,也不会提交服务器。'
: '当前只更新本页本地预览,不会通知申请人,也不会提交服务器。'
"
:confirm-text="
helpVisible
? '我知道了'
: confirmation?.approved
? '确认通过'
: '确认拒绝'
? '查看通过效果'
: '查看拒绝效果'
"
:show-cancel="!!confirmation"
compact-actions
:close-on-mask="false"
@confirm="helpVisible ? closeDialog() : applyAudit()"
@cancel="closeDialog"
@close="closeDialog"
>
<view v-if="confirmation && !confirmation.approved" class="rejection-field">
<text>拒绝原因</text>
@@ -140,18 +146,19 @@
<script setup>
import { computed, nextTick, ref } from "vue";
import { onLoad, onUnload } from "@dcloudio/uni-app";
import { onBackPress, onLoad, onUnload } from "@dcloudio/uni-app";
import AppDialog from "@/components/AppDialog.vue";
import AppButton from "@/components/AppButton.vue";
import AppLoading from "@/components/AppLoading.vue";
import GenealogyPageBackground from "@/components/GenealogyPageBackground.vue";
import PageHeader from "@/components/PageHeader.vue";
import { genealogyContext } from "@/utils/genealogy-context.js";
import { getGenealogyFixtureAccess } from "@/data/mock.js";
import { handleBackPress, runBackGuard } from "@/utils/navigation.js";
const applications = ref([]);
const reviewSamples = [
{
id: 1,
id: "review-1",
name: "汤志成",
phone: "139****6421",
relation: "自述为汤正华堂侄 · 祖居洛阳",
@@ -159,7 +166,7 @@ const reviewSamples = [
status: "PENDING",
},
{
id: 2,
id: "review-2",
name: "汤雨薇",
phone: "136****2798",
relation: "自述为汤正国之女 · 已补充长辈姓名",
@@ -179,7 +186,9 @@ const feedbackVisible = ref(false);
const feedbackMessage = ref("");
let feedbackTimer = null;
onUnload(() => {
if (feedbackTimer) clearTimeout(feedbackTimer);
const timer = feedbackTimer;
feedbackTimer = null;
if (timer) clearTimeout(timer);
});
const stateTitle = computed(() =>
reviewState.value === "empty"
@@ -199,11 +208,18 @@ const stateCopy = computed(() =>
const loadApplications = (query = {}) => {
reviewState.value = "loading";
errorMessage.value = "";
genealogyId.value =
query.genealogyId?.value ||
query.genealogyId ||
genealogyId.value ||
genealogyContext.getCurrentGenealogyId();
genealogyId.value = String(query.genealogyId || "");
if (!genealogyId.value) {
errorMessage.value = "没有找到当前家谱,请从家谱总览进入。";
reviewState.value = "error";
return;
}
if (
getGenealogyFixtureAccess(genealogyId.value).accessRole !== "owner"
) {
reviewState.value = "no-permission";
return;
}
if (query.state === "loading") {
reviewState.value = "loading";
return;
@@ -220,12 +236,6 @@ const loadApplications = (query = {}) => {
reviewState.value = "no-permission";
return;
}
if (!genealogyId.value) {
errorMessage.value = "没有找到当前家谱,请从家谱总览进入。";
reviewState.value = "error";
return;
}
genealogyContext.setCurrentGenealogyId(genealogyId.value);
applications.value = reviewSamples.map((item) => ({ ...item }));
reviewState.value = "list";
};
@@ -244,14 +254,24 @@ const closeDialog = () => {
rejectionFocused.value = false;
helpVisible.value = false;
};
const requestBack = () =>
runBackGuard({
transientOpen: Boolean(confirmation.value) || helpVisible.value,
"close-transient": closeDialog,
});
onBackPress((event) => handleBackPress(event, requestBack));
const showFeedback = (message) => {
feedbackMessage.value = message;
feedbackVisible.value = true;
if (feedbackTimer) clearTimeout(feedbackTimer);
feedbackTimer = setTimeout(() => {
const timer = setTimeout(() => {
if (feedbackTimer !== timer) return;
feedbackVisible.value = false;
feedbackTimer = null;
}, 1800);
feedbackTimer = timer;
};
const clearRejectionError = () => {
rejectionError.value = "";
@@ -269,7 +289,9 @@ const applyAudit = async () => {
current.item.status = current.approved ? "APPROVED" : "REJECTED";
if (!current.approved) current.item.rejectionReason = rejectionReason.value.trim();
closeDialog();
showFeedback(current.approved ? "已通过申请" : "已拒绝申请");
showFeedback(
`本地审核预览已更新,尚未提交服务器 · ${current.approved ? "已通过" : "已拒绝"}`,
);
};
const showHelp = () => {
helpVisible.value = true;
+124 -52
View File
@@ -1,8 +1,10 @@
<!-- 页面编号G-11用途家谱设置公开范围与访问说明页面设计阶段使用本地模拟交互 -->
<!-- 页面编号G-11用途家谱设置访问预设与家谱简介页面设计阶段使用本地模拟交互 -->
<template>
<view class="settings-page">
<GenealogyPageBackground />
<view class="settings-page__header"><PageHeader title="家谱设置" /></view>
<view class="settings-page__header">
<PageHeader title="家谱设置" custom-back @back="requestBack" />
</view>
<view
class="settings-panel"
@@ -22,7 +24,7 @@
<text class="settings-form__eyebrow">谱主可见 · 基础设置</text>
<text class="settings-form__title">完善家谱访问规则</text>
<text class="settings-form__copy"
>这里仅安排接口支持的名称公开范围与访问说明管理权转让在成员选择场景中单独处理</text
>这里维护名称访问规则与家谱简介访问规则会同时决定公开范围和加入方式管理权转让在成员场景中单独处理</text
>
<view class="settings-field">
@@ -40,17 +42,17 @@
}}</text>
<view class="visibility-block">
<text class="visibility-block__label">公开范围</text>
<text class="visibility-block__label">访问规则</text>
<view class="visibility-options">
<view
v-for="option in visibilityOptions"
v-for="option in accessPresetOptions"
:key="option.value"
class="visibility-option"
@click="genealogyDraft.visibility = option.value"
@click="genealogyDraft.accessPreset = option.value"
>
<image
:src="
genealogyDraft.visibility === option.value
genealogyDraft.accessPreset === option.value
? '/static/assets/foundation/transparent/a01-scroll-primary-v3.png'
: '/static/assets/foundation/transparent/a01-scroll-secondary-v3.png'
"
@@ -59,7 +61,7 @@
<text
:class="{
'visibility-option__text--active':
genealogyDraft.visibility === option.value,
genealogyDraft.accessPreset === option.value,
}"
>{{ option.label }}</text
>
@@ -69,12 +71,12 @@
</view>
<view class="settings-field settings-field--note">
<text>访问说明</text>
<text>家谱简介</text>
<textarea
v-model="genealogyDraft.accessNote"
v-model="genealogyDraft.intro"
auto-height
maxlength="80"
placeholder="向访问者说明家谱用途"
placeholder="简要介绍家谱来源与支系"
placeholder-class="settings-placeholder"
/>
</view>
@@ -94,85 +96,123 @@
}}</text>
<text class="settings-result__title">{{
settingsState === "success"
? "新的访问规则已经生效"
? "本页设置草稿已更新"
: settingsState === "no-permission"
? "当前账号不能修改家谱"
: "暂时无法打开家谱设置"
}}</text>
<text class="settings-result__copy">{{
settingsState === "success"
? `${genealogyDraft.name} · ${visibilityLabel}`
? `本地预览已更新,尚未提交服务器;当前只保留在本页,返回总览不会改变原资料 · ${genealogyDraft.name} · ${accessPresetLabel}`
: settingsState === "no-permission"
? "只有家谱所有者可以修改名称、公开范围和访问说明。"
? "只有家谱所有者可以修改名称、访问规则和家谱简介。"
: "请从家谱总览重新进入,当前修改不会被保留。"
}}</text>
<view class="settings-action" @click="settingsState = 'form'">
<view class="settings-action" @click="handleResultAction">
<text>{{
settingsState === "success" ? "继续调整" : "重新查看"
}}</text>
</view>
</view>
</view>
<AppDialog
:visible="discardVisible"
title="放弃设置修改?"
message="当前修改尚未保存,确认返回后将清空。"
confirm-text="放弃并返回"
cancel-text="继续修改"
show-cancel
compact-actions
:close-on-mask="false"
@confirm="confirmDiscard"
@cancel="cancelDiscard"
/>
<view v-if="feedbackVisible" class="settings-feedback">
<text>设置已保存</text>
<text>本页草稿已更新</text>
</view>
</view>
</template>
<script setup>
import { computed, reactive, ref } from "vue";
import { onLoad, onUnload } from "@dcloudio/uni-app";
import { onBackPress, onLoad, onUnload } from "@dcloudio/uni-app";
import AppDialog from "@/components/AppDialog.vue";
import AppLoading from "@/components/AppLoading.vue";
import GenealogyPageBackground from "@/components/GenealogyPageBackground.vue";
import PageHeader from "@/components/PageHeader.vue";
import {
findGenealogyFixture,
getGenealogyFixtureAccess,
} from "@/data/mock.js";
import { createDiscardConfirmation } from "@/utils/discard-confirmation.js";
import {
GENEALOGY_ACCESS_PRESET,
GENEALOGY_ACCESS_PRESET_OPTIONS,
isGenealogyAccessPreset,
} from "@/utils/genealogy-contracts.js";
import { goBack, handleBackPress, runBackGuard } from "@/utils/navigation.js";
const genealogyId = ref("");
const settingsState = ref("loading");
const nameError = ref("");
const feedbackVisible = ref(false);
const discardVisible = ref(false);
let feedbackTimer = null;
const settingsFixtures = {
1001: {
name: "汤氏家谱",
visibility: "MEMBER_ONLY",
accessNote: "家族资料,请妥善保存",
},
1002: {
name: "汤氏宗谱",
visibility: "PUBLIC_APPLY",
accessNote: "公开家谱身份,成员资料需审核后查看",
},
};
const genealogyDraft = reactive({
name: "汤氏家谱",
visibility: "MEMBER_ONLY",
accessNote: "家族资料,请妥善保存",
name: "",
accessPreset: GENEALOGY_ACCESS_PRESET.MEMBER_ONLY,
intro: "",
});
const originalDraft = ref("");
const isDirty = computed(
() => JSON.stringify(genealogyDraft) !== originalDraft.value,
);
const visibilityOptions = [
{ value: "MEMBER_ONLY", label: "仅成员可见" },
{ value: "PUBLIC_APPLY", label: "公开可申请" },
];
const visibilityLabel = computed(
() =>
visibilityOptions.find((item) => item.value === genealogyDraft.visibility)
settingsState.value === "form" &&
JSON.stringify(genealogyDraft) !== originalDraft.value,
);
const discardConfirmation = createDiscardConfirmation((visible) => {
discardVisible.value = visible;
});
const requestDiscardConfirmation = discardConfirmation.request;
const confirmDiscard = discardConfirmation.confirm;
const cancelDiscard = discardConfirmation.cancel;
const accessPresetOptions = GENEALOGY_ACCESS_PRESET_OPTIONS;
const accessPresetLabel = computed(
() =>
accessPresetOptions.find((item) => item.value === genealogyDraft.accessPreset)
?.label || "",
);
const visibilityHint = computed(() =>
genealogyDraft.visibility === "MEMBER_ONLY"
genealogyDraft.accessPreset === GENEALOGY_ACCESS_PRESET.MEMBER_ONLY
? "只有已加入本家谱的成员可以查看谱系和家族资料。"
: "访客可检索到家谱并提交入谱申请,资料仍需审核后查看。",
);
onLoad((query) => {
genealogyId.value = query.genealogyId || "";
Object.assign(
genealogyDraft,
settingsFixtures[genealogyId.value] || settingsFixtures[1001],
);
const loadSettings = (query = {}) => {
genealogyId.value = String(query.genealogyId || "");
if (!genealogyId.value) {
settingsState.value = "error";
return;
}
if (
getGenealogyFixtureAccess(genealogyId.value).accessRole !== "owner"
) {
settingsState.value = "no-permission";
return;
}
const fixture = findGenealogyFixture(genealogyId.value);
if (!fixture) {
settingsState.value = "error";
return;
}
if (!isGenealogyAccessPreset(fixture.accessPreset)) {
settingsState.value = "error";
return;
}
Object.assign(genealogyDraft, {
name: fixture.name,
accessPreset: fixture.accessPreset,
intro: fixture.publicDescription || "家族资料,请妥善保存",
});
originalDraft.value = JSON.stringify(genealogyDraft);
settingsState.value =
query.state === "loading"
@@ -184,27 +224,59 @@ onLoad((query) => {
: query.state === "error" || !genealogyId.value
? "error"
: "form";
});
};
onLoad(loadSettings);
onUnload(() => {
if (feedbackTimer) clearTimeout(feedbackTimer);
const timer = feedbackTimer;
feedbackTimer = null;
if (timer) clearTimeout(timer);
discardConfirmation.dispose();
});
const requestBack = () =>
runBackGuard({
transientOpen: discardVisible.value,
dirty: isDirty.value,
"close-transient": cancelDiscard,
"confirm-discard": requestDiscardConfirmation,
});
onBackPress((event) => handleBackPress(event, requestBack));
const continueEditing = () => {
if (
getGenealogyFixtureAccess(genealogyId.value).accessRole === "owner"
) {
settingsState.value = "form";
}
};
const handleResultAction = () => {
if (settingsState.value === "success") return continueEditing();
if (settingsState.value === "error")
return loadSettings({ genealogyId: genealogyId.value });
return goBack();
};
const saveSettings = () => {
if (!genealogyDraft.name.trim()) {
nameError.value = "请填写家谱名称";
return;
}
if (genealogyDraft.name.trim().length > 30) {
nameError.value = "家谱名称不能超过 30 个字";
if (genealogyDraft.name.trim().length > 20) {
nameError.value = "家谱名称不能超过 20 个字";
return;
}
originalDraft.value = JSON.stringify(genealogyDraft);
settingsState.value = "success";
feedbackVisible.value = true;
feedbackTimer = setTimeout(() => {
if (feedbackTimer) clearTimeout(feedbackTimer);
const timer = setTimeout(() => {
if (feedbackTimer !== timer) return;
feedbackVisible.value = false;
feedbackTimer = null;
}, 1800);
feedbackTimer = timer;
};
</script>
+302 -71
View File
@@ -4,9 +4,11 @@
<GenealogyPageBackground />
<view class="poem-page__header"
><PageHeader
title="字辈诗"
:action="poemState === 'list' ? '维护' : ''"
:title="pageTitle"
:action="canManage && poemState === 'list' ? '维护' : ''"
custom-back
@action="openEditor"
@back="requestBack"
/></view>
<view
@@ -25,26 +27,38 @@
description="请稍候,正在读取家谱字序。"
/>
<view v-else-if="poemState === 'list'" class="poem-list">
<text class="poem-list__eyebrow">汤氏家谱 · 传承字序</text>
<text class="poem-list__title">启宗敦本继世传芳</text>
<text class="poem-list__copy"
>按世代查看字辈当前家谱使用到字辈</text
>
<text class="poem-list__eyebrow">{{ genealogyName }} · 传承字序</text>
<text class="poem-list__title">{{ generationRangeTitle }}</text>
<text class="poem-list__copy">{{ currentGenerationCopy }}</text>
<view class="poem-rows">
<view
v-for="item in poemRows"
v-for="item in visiblePoemRows"
:key="item.generationNo"
class="poem-row"
:class="{ 'poem-row--current': item.current }"
:class="{
'poem-row--current': item.current,
'poem-row--disabled': item.status === GENERATION_POEM_STATUS.DISABLED,
}"
>
<text class="poem-row__number"> {{ item.generationNo }} </text>
<text class="poem-row__character">{{ item.character }}</text>
<text class="poem-row__character">{{ item.generationText }}</text>
<text class="poem-row__status">{{
item.current ? "当前字辈" : "传承字序"
item.current
? "当前字辈"
: item.status === GENERATION_POEM_STATUS.DISABLED
? "已停用·记录保留"
: "传承字序"
}}</text>
</view>
</view>
<view class="poem-action" @click="openEditor">
<view
v-if="remainingPoemCount > 0"
class="poem-load-more"
@click="loadMorePoems"
>
<text>继续加载后续字辈剩余 {{ remainingPoemCount }} </text>
</view>
<view v-if="canManage" class="poem-action" @click="openEditor">
<image
src="/static/assets/foundation/transparent/a01-scroll-primary-v3.png"
mode="aspectFit"
@@ -54,31 +68,33 @@
<view v-else-if="poemState === 'edit'" class="poem-editor">
<text class="poem-list__eyebrow">批量维护</text>
<text class="poem-list__title">录入连续字辈</text>
<text class="poem-list__title">录入完整字辈序列</text>
<text class="poem-list__copy"
>按照接口支持的连续文本录入每个汉字对应一代保存前可预览新字序</text
>无分隔符时每个字符对应一代也可用空格逗号分号顿号斜杠或竖线分隔多字字辈一次最多
{{ MAX_GENERATION_COUNT }} 每代最多
{{ MAX_GENERATION_TEXT_LENGTH }} 个字符本页只更新本地预览尚未提交服务器</text
>
<view class="poem-field">
<text>字辈内容</text>
<textarea
v-model="poemDraft"
auto-height
maxlength="50"
placeholder="例如:启宗敦本继世传芳"
:maxlength="MAX_GENERATION_POEM_INPUT_LENGTH * 2"
placeholder="例如:启宗敦本,或 克勤 克俭 承先 启后"
placeholder-class="poem-placeholder"
@input="poemError = ''"
/>
</view>
<text v-if="poemError" class="poem-field-error">{{ poemError }}</text>
<view class="poem-policy">
<text>缺失旧世代</text>
<text>未被新文本覆盖的后续世代</text>
<view
class="poem-policy__option"
@click="stopMissingOldGeneration = !stopMissingOldGeneration"
@click="disableMissing = !disableMissing"
>
<image
:src="
stopMissingOldGeneration
disableMissing
? '/static/assets/foundation/transparent/a01-scroll-primary-v3.png'
: '/static/assets/foundation/transparent/a01-scroll-secondary-v3.png'
"
@@ -86,17 +102,17 @@
/>
<text
:class="{
'poem-policy__option-text--active': stopMissingOldGeneration,
'poem-policy__option-text--active': disableMissing,
}"
>{{ stopMissingOldGeneration ? "停止并提醒" : "继续补录" }}</text
>{{ disableMissing ? "停用并保留记录" : "保持原状态" }}</text
>
</view>
</view>
<text class="poem-preview"
>预览{{ previewCharacters || "尚未录入字辈" }}</text
>预览{{ previewSummary }}</text
>
<view class="poem-editor__actions">
<view class="poem-action" @click="poemState = 'list'">
<view class="poem-action" @click="requestLeaveEditor">
<image
src="/static/assets/foundation/transparent/a01-scroll-secondary-v3.png"
mode="aspectFit"
@@ -134,56 +150,171 @@
: "请从家谱总览重新进入,或稍后再试。"
}}</text>
<view
v-if="poemState !== 'empty' || canManage"
class="poem-action"
@click="poemState === 'empty' ? openEditor() : (poemState = 'list')"
@click="handleStateAction"
>
<image
src="/static/assets/foundation/transparent/a01-scroll-primary-v3.png"
mode="aspectFit"
/><text>{{ poemState === "empty" ? "开始录入" : "重新查看" }}</text>
/><text>{{
poemState === "empty"
? "开始录入"
: poemState === "no-permission"
? "返回家谱总览"
: "重新查看"
}}</text>
</view>
</view>
</view>
<AppDialog
:visible="discardVisible"
title="放弃字辈修改?"
message="当前字辈草稿尚未保存,确认后将恢复进入编辑器前的内容。"
confirm-text="放弃修改"
cancel-text="继续编辑"
show-cancel
compact-actions
:close-on-mask="false"
@confirm="confirmDiscard"
@cancel="cancelDiscard"
/>
<view v-if="feedbackVisible" class="poem-feedback">
<text>字辈预览已更新</text>
<text>本地字辈预览已更新</text>
</view>
</view>
</template>
<script setup>
import { computed, ref } from "vue";
import { onLoad, onUnload } from "@dcloudio/uni-app";
import { onBackPress, onLoad, onUnload } from "@dcloudio/uni-app";
import AppDialog from "@/components/AppDialog.vue";
import AppLoading from "@/components/AppLoading.vue";
import GenealogyPageBackground from "@/components/GenealogyPageBackground.vue";
import PageHeader from "@/components/PageHeader.vue";
import {
findGenealogyFixture,
getGenealogyFixtureAccess,
} from "@/data/mock.js";
import { createDiscardConfirmation } from "@/utils/discard-confirmation.js";
import {
MAX_GENERATION_COUNT,
MAX_GENERATION_POEM_INPUT_LENGTH,
MAX_GENERATION_TEXT_LENGTH,
GENERATION_POEM_STATUS,
findFirstGenerationGap,
mergeGenerationPoemRows,
validateGenerationPoemText,
} from "@/utils/generation-poem.js";
import { handleBackPress, runBackGuard } from "@/utils/navigation.js";
const INITIAL_POEM_TEXT = "启宗敦本";
const POEM_RENDER_BATCH_SIZE = 50;
const PREVIEW_GENERATION_LIMIT = 12;
const LOCAL_PREVIEW_START_GENERATION = 1;
const LOCAL_CURRENT_GENERATION = 3;
const genealogyId = ref("");
const genealogyName = ref("家谱");
const poemState = ref("loading");
const poemError = ref("");
const feedbackVisible = ref(false);
const discardVisible = ref(false);
const canManage = ref(false);
let feedbackTimer = null;
const poemDraft = ref("启宗敦本继世传芳");
const stopMissingOldGeneration = ref(true);
const startGeneration = ref(12);
const currentGeneration = ref(14);
const poemRows = ref([
{ generationNo: 12, character: "启", current: false },
{ generationNo: 13, character: "宗", current: false },
{ generationNo: 14, character: "敦", current: true },
{ generationNo: 15, character: "本", current: false },
]);
const previewCharacters = computed(() =>
poemDraft.value.trim().split("").join(" · "),
const poemDraft = ref(INITIAL_POEM_TEXT);
// OpenAPI 的 disableMissing 示例为 false;本地同样采用安全默认 false。停用后续世代是高影响动作,
// 必须由谱主主动选择,不能把缩短一次本地草稿解释为默认停用历史记录。
const disableMissing = ref(false);
const editorOrigin = ref("list");
const editorSnapshot = ref(null);
const poemRows = ref([]);
const visiblePoemCount = ref(POEM_RENDER_BATCH_SIZE);
const visiblePoemRows = computed(() =>
poemRows.value.slice(0, visiblePoemCount.value),
);
const remainingPoemCount = computed(() =>
poemRows.value.length > visiblePoemCount.value
? poemRows.value.length - visiblePoemCount.value
: 0,
);
const pageTitle = computed(() =>
poemState.value === "edit" ? "维护字辈诗" : "字辈诗",
);
const generationRangeTitle = computed(() => {
if (!poemRows.value.length) return "尚未建立字辈";
const first = poemRows.value[0].generationNo;
const last = poemRows.value[poemRows.value.length - 1].generationNo;
return first === last ? `${first} 世字辈` : `${first}${last} 世字辈`;
});
const currentGenerationCopy = computed(() => {
const current = poemRows.value.find((item) => item.current);
return current
? `当前为第 ${current.generationNo} 世“${current.generationText}”字辈。`
: `当前第 ${LOCAL_CURRENT_GENERATION} 世尚未被有效字辈覆盖。`;
});
const previewSummary = computed(() => {
const validation = validateGenerationPoemText(poemDraft.value);
if (!validation.valid) {
return poemDraft.value.trim() ? validation.message : "尚未录入字辈";
}
const visible = validation.generations
.slice(0, PREVIEW_GENERATION_LIMIT)
.join(" · ");
const remaining = validation.generations.length - PREVIEW_GENERATION_LIMIT;
return remaining > 0 ? `${visible} · …另 ${remaining}` : visible;
});
const isDirty = computed(() =>
Boolean(
poemState.value === "edit" &&
editorSnapshot.value &&
(poemDraft.value !== editorSnapshot.value.poemDraft ||
disableMissing.value !== editorSnapshot.value.disableMissing),
),
);
const discardConfirmation = createDiscardConfirmation((visible) => {
discardVisible.value = visible;
});
const requestDiscardConfirmation = discardConfirmation.request;
const confirmDiscard = discardConfirmation.confirm;
const cancelDiscard = discardConfirmation.cancel;
onLoad((query) => {
genealogyId.value = query.genealogyId || "";
startGeneration.value = Math.max(1, Number(query.startGeneration) || 12);
currentGeneration.value = Math.max(
startGeneration.value,
Number(query.currentGeneration) || 14,
const resetPoemRows = () => {
const seed = validateGenerationPoemText(INITIAL_POEM_TEXT).generations;
poemDraft.value = INITIAL_POEM_TEXT;
poemRows.value = mergeGenerationPoemRows({
existingRows: [],
generationTexts: seed,
startGeneration: LOCAL_PREVIEW_START_GENERATION,
currentGeneration: LOCAL_CURRENT_GENERATION,
disableMissing: false,
});
visiblePoemCount.value = POEM_RENDER_BATCH_SIZE;
};
const loadMorePoems = () => {
visiblePoemCount.value = Math.min(
poemRows.value.length,
visiblePoemCount.value + POEM_RENDER_BATCH_SIZE,
);
poemState.value =
};
const loadPoems = (query = {}) => {
genealogyId.value = String(query.genealogyId || "");
const fixture = findGenealogyFixture(genealogyId.value);
if (!fixture) {
canManage.value = false;
poemState.value = "error";
return;
}
genealogyName.value = fixture.name || "家谱";
const accessRole = getGenealogyFixtureAccess(genealogyId.value).accessRole;
canManage.value = accessRole === "owner";
if (accessRole === "guest") {
poemState.value = "no-permission";
return;
}
resetPoemRows();
const requestedState =
query.state === "loading"
? "loading"
: query.state === "empty"
@@ -192,47 +323,123 @@ onLoad((query) => {
? "edit"
: query.state === "no-permission"
? "no-permission"
: query.state === "error" || !genealogyId.value
: query.state === "error"
? "error"
: "list";
});
if (requestedState === "empty") {
poemDraft.value = "";
poemRows.value = [];
}
if (requestedState === "edit") {
if (!canManage.value) {
poemState.value = "no-permission";
return;
}
poemState.value = "list";
openEditor("list");
return;
}
poemState.value = requestedState;
};
onLoad(loadPoems);
onUnload(() => {
if (feedbackTimer) clearTimeout(feedbackTimer);
const timer = feedbackTimer;
feedbackTimer = null;
if (timer) clearTimeout(timer);
discardConfirmation.dispose();
});
const openEditor = () => {
const openEditor = (origin = poemState.value) => {
if (!canManage.value) return false;
editorOrigin.value = origin === "empty" ? "empty" : "list";
editorSnapshot.value = Object.freeze({
poemDraft: poemDraft.value,
disableMissing: disableMissing.value,
});
poemState.value = "edit";
return true;
};
const restoreEditorSnapshot = () => {
if (!editorSnapshot.value) return;
poemDraft.value = editorSnapshot.value.poemDraft;
disableMissing.value = editorSnapshot.value.disableMissing;
};
const requestLeaveEditor = async () => {
if (isDirty.value) {
const confirmed = await requestDiscardConfirmation();
if (!confirmed) return false;
}
restoreEditorSnapshot();
poemState.value = editorOrigin.value;
editorSnapshot.value = null;
poemError.value = "";
return true;
};
const requestBack = () =>
runBackGuard({
transientOpen: discardVisible.value,
internalTrail: poemState.value === "edit",
"close-transient": cancelDiscard,
"pop-internal-trail": requestLeaveEditor,
});
onBackPress((event) => handleBackPress(event, requestBack));
const handleStateAction = () => {
if (poemState.value === "empty") return openEditor("empty");
if (poemState.value === "error")
return loadPoems({ genealogyId: genealogyId.value });
return requestBack();
};
const savePoems = () => {
const characters = poemDraft.value.trim().split("").filter(Boolean);
if (!characters.length) {
poemError.value = "请录入字辈内容";
const validation = validateGenerationPoemText(poemDraft.value);
if (!validation.valid) {
poemError.value = validation.message;
return;
}
if (poemDraft.value.trim() === "失败") {
poemError.value = "字辈保存失败,请稍后重试";
const lastGeneration =
LOCAL_PREVIEW_START_GENERATION + validation.generations.length - 1;
if (LOCAL_CURRENT_GENERATION > lastGeneration) {
poemError.value = `请至少录入到当前第 ${LOCAL_CURRENT_GENERATION}`;
return;
}
const preservedRows = stopMissingOldGeneration.value
? []
: poemRows.value.filter(
(item) => item.generationNo < startGeneration.value,
);
const nextRows = characters.map((character, index) => {
const generationNo = startGeneration.value + index;
return {
generationNo,
character,
current: generationNo === currentGeneration.value,
};
const nextRows = mergeGenerationPoemRows({
existingRows: poemRows.value,
generationTexts: validation.generations,
startGeneration: LOCAL_PREVIEW_START_GENERATION,
currentGeneration: LOCAL_CURRENT_GENERATION,
disableMissing: disableMissing.value,
});
poemRows.value = [...preservedRows, ...nextRows];
const activeRows = nextRows.filter(
(item) => item.status === GENERATION_POEM_STATUS.ACTIVE,
);
const lastActiveGeneration = activeRows.length
? activeRows[activeRows.length - 1].generationNo
: null;
const firstGap = lastActiveGeneration === null
? null
: findFirstGenerationGap(
nextRows,
lastActiveGeneration + 1,
LOCAL_PREVIEW_START_GENERATION,
);
if (firstGap !== null) {
poemError.value = `${firstGap} 世字辈缺失;请补齐完整序列,或选择停用未覆盖的后续记录`;
return;
}
poemRows.value = nextRows;
visiblePoemCount.value = POEM_RENDER_BATCH_SIZE;
editorSnapshot.value = null;
poemState.value = "list";
feedbackVisible.value = true;
feedbackTimer = setTimeout(() => {
if (feedbackTimer) clearTimeout(feedbackTimer);
const timer = setTimeout(() => {
if (feedbackTimer !== timer) return;
feedbackVisible.value = false;
feedbackTimer = null;
}, 1800);
feedbackTimer = timer;
};
</script>
@@ -287,12 +494,26 @@ const savePoems = () => {
.poem-rows {
margin-top: 18rpx;
}
.poem-load-more {
display: flex;
min-height: 64rpx;
align-items: center;
justify-content: center;
margin-top: 14rpx;
background: url("/static/assets/foundation/transparent/a01-scroll-secondary-v3.png")
center / contain no-repeat;
}
.poem-load-more text {
color: $ink;
font-size: 23rpx;
font-weight: 700;
}
.poem-row {
@include adaptive.adaptive-genealogy-form-field;
display: grid;
min-height: 72rpx;
margin-top: 10rpx;
grid-template-columns: 48% auto 1fr auto;
grid-template-columns: minmax(116rpx, 38%) minmax(0, 1fr) auto;
align-items: center;
}
.poem-row__number {
@@ -305,14 +526,19 @@ const savePoems = () => {
.poem-row__character {
z-index: 1;
grid-column: 2;
min-width: 0;
padding: 12rpx 16rpx 12rpx 0;
color: $ink;
font-family: "STKaiti", "KaiTi", serif;
font-size: 32rpx;
font-weight: 700;
line-height: 1.4;
overflow-wrap: anywhere;
word-break: break-word;
}
.poem-row__status {
z-index: 1;
grid-column: 4;
grid-column: 3;
margin-right: 22rpx;
color: $ink-muted;
font-size: 22rpx;
@@ -321,6 +547,11 @@ const savePoems = () => {
.poem-row--current .poem-row__status {
color: $brand-red;
}
.poem-row--disabled .poem-row__character,
.poem-row--disabled .poem-row__status {
color: $ink-muted;
opacity: 0.62;
}
.poem-action {
display: grid;
width: 100%;
+17 -26
View File
@@ -87,7 +87,9 @@ import AppLoading from "@/components/AppLoading.vue";
import AppToast from "@/components/AppToast.vue";
import ModulePageBackground from "@/components/ModulePageBackground.vue";
import PageHeader from "@/components/PageHeader.vue";
import { listNotificationFixtures } from "@/data/mock.js";
import { genealogyContext } from "@/utils/genealogy-context.js";
import { openPage } from "@/utils/navigation.js";
const genealogyId = ref("");
const noticeState = ref("loading");
@@ -95,24 +97,7 @@ const toastVisible = ref(false);
const toastMessage = ref("");
let toastTimer = null;
const notices = ref([
{
id: 1,
title: "申请待审核",
content: "汤志成申请加入汤氏家谱,请核实亲属关系。",
time: "今天 10:28",
unread: true,
detailId: "review-1",
},
{
id: 2,
title: "入谱申请已通过",
content: "你申请加入汝南汤氏家谱的请求已通过。",
time: "昨天 18:10",
unread: false,
detailId: "approved",
},
]);
const notices = ref(listNotificationFixtures());
onLoad((query) => {
genealogyId.value =
@@ -130,11 +115,13 @@ onLoad((query) => {
const unreadCount = computed(
() => notices.value.filter((item) => item.unread).length,
);
const openNotice = (item) => {
item.unread = false;
uni.navigateTo({
url: `/pages/notification/n02-message-detail?id=${item.detailId}&genealogyId=${genealogyId.value}`,
});
const openNotice = async (item) => {
const opened = await openPage(
"N02",
{ id: String(item.id) },
"N01",
);
if (opened) item.unread = false;
};
const restoreList = () => {
noticeState.value = "list";
@@ -155,9 +142,13 @@ const markAllRead = () => {
showToast("已全部标记为已读");
};
const toReview = () =>
uni.navigateTo({
url: `/pages/genealogy/g10-application-review?genealogyId=${genealogyId.value}`,
});
genealogyId.value
? openPage(
"G10",
{ genealogyId: genealogyId.value },
"N01",
)
: showToast("请先选择可管理的家谱");
onUnmounted(() => {
if (toastTimer) clearTimeout(toastTimer);
+59 -31
View File
@@ -2,7 +2,7 @@
<template>
<view class="notice-detail-page" :class="{ 'notice-state--ready': noticeState === 'ready', 'notice-state--loading': noticeState === 'loading', 'notice-state--expired': noticeState === 'expired' }">
<ModulePageBackground module="notification" />
<view class="page-layer"><PageHeader title="消息详情" /></view>
<view class="page-layer"><PageHeader title="消息详情" custom-back @back="backToMessages" /></view>
<view class="notice-content page-layer">
<AppLoading v-if="noticeState === 'loading'" text="正在读取消息" description="请稍候,正在整理消息详情。" />
@@ -16,7 +16,7 @@
<template v-else>
<view class="paper-panel notice-card">
<view class="notice-meta">
<text :class="{ 'is-unread': !noticeDetail.read }">{{ noticeDetail.read ? "已读" : "未读提醒" }}</text>
<text :class="{ 'is-unread': noticeDetail.unread }">{{ noticeDetail.unread ? "未读提醒" : "已读" }}</text>
<text>{{ noticeDetail.time }}</text>
</view>
<text class="notice-title">{{ noticeDetail.title }}</text>
@@ -24,8 +24,9 @@
<text class="notice-source">来自{{ noticeDetail.source }}</text>
</view>
<view class="action-stack">
<AppButton v-if="!noticeDetail.read" block label="标记已读" @click="markAsRead" />
<AppButton v-if="noticeDetail.target" block type="secondary" :label="noticeDetail.targetLabel" @click="openNoticeTarget" />
<AppButton v-if="noticeDetail.unread" block label="标记已读" @click="markAsRead" />
<AppButton v-if="noticeDetail.targetType" block type="secondary" :label="noticeDetail.targetLabel" @click="openNoticeTarget" />
<text v-if="targetError" class="target-error" role="alert">{{ targetError }}</text>
</view>
</template>
</view>
@@ -35,45 +36,43 @@
<script setup>
import { onUnmounted, ref } from "vue";
import { onLoad } from "@dcloudio/uni-app";
import { onBackPress, onLoad } from "@dcloudio/uni-app";
import AppButton from "@/components/AppButton.vue";
import AppLoading from "@/components/AppLoading.vue";
import AppToast from "@/components/AppToast.vue";
import ModulePageBackground from "@/components/ModulePageBackground.vue";
import PageHeader from "@/components/PageHeader.vue";
import {
findNotificationFixture,
getGenealogyFixtureAccess,
} from "@/data/mock.js";
import {
handleBackPress,
openNoticeTarget as navigateNoticeTarget,
returnTo,
} from "@/utils/navigation.js";
const noticeState = ref("loading");
const toastVisible = ref(false);
const toastMessage = ref("");
const targetError = ref("");
let toastTimer = null;
const noticeDetail = ref({
id: "review-1",
title: "申请待审核",
body: "汤志成申请加入汤氏家谱,请核实申请人的亲属关系与世代信息后完成审核。",
time: "今天 10:28",
source: "汝南汤氏家谱",
read: false,
target: "/pages/genealogy/g10-application-review",
targetLabel: "前往入谱审核",
});
const noticeId = ref("");
const noticeDetail = ref(null);
onLoad((query) => {
if (query.state === "expired") {
noticeId.value = String(query.id || "");
if (query.state === "loading") return;
const selectedNotice = findNotificationFixture(noticeId.value);
if (
query.state === "expired" ||
!noticeId.value ||
!selectedNotice
) {
noticeState.value = "expired";
return;
}
if (query.id === "approved") {
noticeDetail.value = {
id: "approved",
title: "入谱申请已通过",
body: "你申请加入汝南汤氏家谱的请求已通过,现在可以查看家谱与家族动态。",
time: "昨天 18:10",
source: "汝南汤氏家谱",
read: true,
target: "/pages/genealogy/g01-my-genealogies",
targetLabel: "查看我的家谱",
};
}
noticeDetail.value = selectedNotice;
noticeState.value = "ready";
});
@@ -84,11 +83,39 @@ const showToast = (message) => {
toastTimer = setTimeout(() => (toastVisible.value = false), 1800);
};
const markAsRead = () => {
noticeDetail.value.read = true;
if (!noticeDetail.value) return;
noticeDetail.value.unread = false;
showToast("已标记为已读");
};
const openNoticeTarget = () => uni.navigateTo({ url: noticeDetail.value.target });
const backToMessages = () => uni.navigateBack();
const getTargetAccessError = (detail) => {
const genealogyId = detail?.targetParams?.genealogyId;
const accessRole = getGenealogyFixtureAccess(genealogyId).accessRole;
if (detail?.targetType === "GENEALOGY_REVIEW" && accessRole !== "owner") {
return "当前账号没有处理这条审核消息的权限。";
}
if (
detail?.targetType === "GENEALOGY_HOME" &&
!["owner", "member"].includes(accessRole)
) {
return "这条消息关联的家谱已不可访问。";
}
return "";
};
const openNoticeTarget = async () => {
targetError.value = getTargetAccessError(noticeDetail.value);
if (targetError.value) return false;
try {
return await navigateNoticeTarget(
noticeDetail.value.targetType,
noticeDetail.value.targetParams,
);
} catch (_error) {
targetError.value = "这条消息的业务入口已失效,请返回消息中心。";
return false;
}
};
const backToMessages = () => returnTo("N01", {});
onBackPress((event) => handleBackPress(event, backToMessages));
onUnmounted(() => toastTimer && clearTimeout(toastTimer));
</script>
@@ -106,6 +133,7 @@ onUnmounted(() => toastTimer && clearTimeout(toastTimer));
.notice-body { display: block; margin-top: 22rpx; color: #5f4a38; font-size: 25rpx; line-height: 1.75; overflow-wrap: anywhere; }
.notice-source { display: block; margin-top: 28rpx; color: $ink-muted; font-size: 22rpx; overflow-wrap: anywhere; }
.action-stack { display: flex; flex-direction: column; gap: 18rpx; margin-top: 28rpx; }
.target-error { display: block; color: #b42318; font-size: 22rpx; line-height: 1.5; text-align: center; }
.state-card { min-height: 340rpx; padding: 72rpx 50rpx 48rpx; box-sizing: border-box; text-align: center; }
.state-title { display: block; color: $ink; font-size: 34rpx; font-weight: 700; }
.state-copy { display: block; margin: 18rpx 0 28rpx; color: $ink-muted; font-size: 24rpx; line-height: 1.65; }
+25 -17
View File
@@ -10,6 +10,7 @@
<ModulePageBackground module="profile" />
<view class="profile-page__header">
<PageHeader
root
title="我的"
:action="profileState === 'ready' ? '资料' : ''"
@action="toProfile"
@@ -40,9 +41,9 @@
mode="aspectFit"
/>
<view class="profile-identity__copy">
<text class="profile-identity__name">汤文清</text>
<text class="profile-identity__role">汤氏家谱 · 家谱成员</text>
<text class="profile-identity__phone">139****6421</text>
<text class="profile-identity__name">{{ currentUser.name }}</text>
<text class="profile-identity__role">{{ currentUser.role }}</text>
<text class="profile-identity__phone">{{ currentUser.phone }}</text>
</view>
</view>
</view>
@@ -56,7 +57,7 @@
>
<view class="profile-scroll-notice__copy">
<text>待你处理</text>
<text>2 条家谱提醒与审核通知</text>
<text>{{ unreadCount }} 条家谱提醒与审核通知</text>
</view>
</view>
@@ -121,44 +122,52 @@
</template>
<script setup>
import { ref } from "vue";
import { computed, ref } from "vue";
import { onLoad } from "@dcloudio/uni-app";
import AppButton from "@/components/AppButton.vue";
import AppTabbar from "@/components/AppTabbar.vue";
import ModulePageBackground from "@/components/ModulePageBackground.vue";
import PageHeader from "@/components/PageHeader.vue";
import {
currentUser,
listNotificationFixtures,
} from "@/data/mock.js";
import { openPage } from "@/utils/navigation.js";
const profileState = ref("ready");
const unreadCount = computed(
() => listNotificationFixtures().filter((notice) => notice.unread).length,
);
const menuItems = [
{
label: "账号与安全",
note: "密码手机与设备",
note: "密码手机",
icon: "/static/assets/modules/auth/transparent/a01-icon-lock-v1.png",
url: "/pages/profile/m03-security-settings",
routeKey: "M03",
},
{
label: "帮助与反馈",
note: "使用说明与问题反馈",
icon: "/static/assets/foundation/transparent/notice.png",
url: "/pages/profile/m06-help-center",
routeKey: "M06",
},
{
label: "关于家谱",
note: "协议、隐私与版本",
icon: "/static/assets/foundation/transparent/brand-seal.png",
url: "/pages/profile/m10-about-settings",
routeKey: "M10",
},
{
label: "邀请家人",
note: "邀请码与家谱推广",
note: "邀请规则与接入状态",
icon: "/static/assets/foundation/transparent/brand-seal.png",
url: "/pages/profile/m08-promotion",
routeKey: "M08",
},
{
label: "服务与订单",
note: "权益说明与订单记录",
icon: "/static/assets/foundation/transparent/notice.png",
url: "/pages/profile/m09-vip-orders",
routeKey: "M09",
},
];
@@ -169,11 +178,10 @@ onLoad((query) => {
const restoreProfile = () => {
profileState.value = "ready";
};
const toProfile = () =>
uni.navigateTo({ url: "/pages/profile/m02-edit-profile" });
const toNotifications = () =>
uni.navigateTo({ url: "/pages/notification/n01-message-center" });
const openItem = (item) => uni.navigateTo({ url: item.url });
const toProfile = () => openPage("M02", {}, "M01");
const toNotifications = () => openPage("N01", {}, "M01");
const openItem = (item) =>
item.routeKey ? openPage(item.routeKey, {}, "M01") : Promise.resolve(false);
</script>
<style scoped lang="scss">
+65 -22
View File
@@ -2,56 +2,102 @@
<template>
<view class="profile-edit-page" :class="{ 'profile-state--ready': profileState === 'ready', 'profile-state--saving': profileState === 'saving' }">
<ModulePageBackground module="profile" />
<view class="page-layer"><PageHeader title="个人资料" /></view>
<view class="page-layer"><PageHeader title="个人资料" custom-back @back="requestBack" /></view>
<view class="page-content page-layer">
<view class="profile-avatar-card">
<image class="avatar-seal" src="/static/assets/foundation/transparent/brand-seal.png" mode="aspectFit" />
<view class="avatar-copy"><text>{{ profileForm.nickname || "未填写昵称" }}</text><text>头像将在相册权限接入后支持上传</text></view>
<view class="avatar-copy"><text>{{ profileForm.nickName || "未填写昵称" }}</text><text>头像将在上传接口批次接入</text></view>
<view class="text-action" role="button" aria-label="选择头像" @click="chooseAvatar">选择头像</view>
</view>
<view class="form-panel">
<view class="form-row"><text>昵称</text><input v-model.trim="profileForm.nickname" maxlength="20" aria-label="昵称" placeholder="请输入昵称" /></view>
<text v-if="errors.nickname" class="field-error">{{ errors.nickname }}</text>
<view class="form-row"><text>真实姓名</text><input v-model.trim="profileForm.realName" maxlength="20" aria-label="真实姓名" placeholder="请输入真实姓名" /></view>
<view class="form-row"><text>常住地区</text><input v-model.trim="profileForm.region" maxlength="40" aria-label="常住地区" placeholder="省 / 市 / 区县" /></view>
<view class="textarea-row"><text>个人简介</text><textarea v-model.trim="profileForm.bio" auto-height maxlength="300" aria-label="个人简介" placeholder="介绍你的家族身份或经历" /></view>
<text class="counter">{{ profileForm.bio.length }}/300</text>
<text v-if="errors.bio" class="field-error">{{ errors.bio }}</text>
<view class="form-row"><text>昵称</text><input v-model.trim="profileForm.nickName" maxlength="30" aria-label="昵称" placeholder="请输入昵称" @input="errors.nickName = ''" /></view>
<text v-if="errors.nickName" class="field-error">{{ errors.nickName }}</text>
<view class="form-row"><text>真实姓名</text><input v-model.trim="profileForm.realName" maxlength="30" aria-label="真实姓名" placeholder="请输入真实姓名" /></view>
<view class="form-row"><text>邮箱</text><input v-model.trim="profileForm.email" maxlength="100" aria-label="邮箱" placeholder="选填,用于接收通知" @input="errors.email = ''" /></view>
<text v-if="errors.email" class="field-error">{{ errors.email }}</text>
</view>
<AppButton block :disabled="profileState === 'saving'" :label="profileState === 'saving' ? '正在保存' : '保存资料'" @click="saveProfile" />
<AppButton block :disabled="profileState === 'saving'" :label="profileState === 'saving' ? '正在校验' : '生成本地校验预览'" @click="saveProfile" />
</view>
<AppToast :visible="toastVisible" :message="toastMessage" />
<AppDialog
:visible="discardVisible"
:close-on-mask="false"
eyebrow="放弃确认"
title="放弃当前填写?"
message="尚未提交服务器的资料将从当前页面清除。"
confirm-text="确认放弃"
cancel-text="继续填写"
show-cancel
@confirm="confirmDiscard"
@cancel="cancelDiscard"
/>
</view>
</template>
<script setup>
import { onUnmounted, reactive, ref } from "vue";
import { computed, reactive, ref } from "vue";
import { onBackPress, onUnload } from "@dcloudio/uni-app";
import AppButton from "@/components/AppButton.vue";
import AppDialog from "@/components/AppDialog.vue";
import AppToast from "@/components/AppToast.vue";
import ModulePageBackground from "@/components/ModulePageBackground.vue";
import PageHeader from "@/components/PageHeader.vue";
import { currentUser } from "@/data/mock.js";
import { createDiscardConfirmation } from "@/utils/discard-confirmation.js";
import {
handleBackPress,
runBackGuard,
} from "@/utils/navigation.js";
const profileForm = reactive({ nickname: "汤文清", realName: "汤文清", region: "河南省 周口市", bio: "热心参与家谱资料整理。" });
const errors = reactive({ nickname: "", bio: "" });
const profileForm = reactive({ nickName: currentUser.name, realName: currentUser.name, email: "" });
const errors = reactive({ nickName: "", email: "" });
const profileState = ref("ready");
const toastVisible = ref(false);
const toastMessage = ref("");
const discardVisible = ref(false);
let timer = null;
const formSnapshot = computed(() => JSON.stringify(profileForm));
const baseline = ref(formSnapshot.value);
const isDirty = computed(() => formSnapshot.value !== baseline.value);
const discardConfirmation = createDiscardConfirmation((visible) => {
discardVisible.value = visible;
});
const requestDiscardConfirmation = discardConfirmation.request;
const confirmDiscard = discardConfirmation.confirm;
const cancelDiscard = discardConfirmation.cancel;
const showToast = (message) => { toastMessage.value = message; toastVisible.value = true; clearTimeout(timer); timer = setTimeout(() => (toastVisible.value = false), 1800); };
const chooseAvatar = () => showToast("头像选择将在相册权限接入后开放");
const validateProfile = () => {
errors.nickname = profileForm.nickname ? "" : "请填写昵称";
errors.bio = profileForm.bio.length > 300 ? "个人简介不能超过 300 字" : "";
return !errors.nickname && !errors.bio;
errors.nickName = profileForm.nickName ? "" : "请填写昵称";
errors.email = !profileForm.email || /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(profileForm.email)
? ""
: "请输入正确的邮箱地址";
return !errors.nickName && !errors.email;
};
const saveProfile = () => {
if (!validateProfile() || profileState.value === "saving") return;
profileState.value = "saving";
clearTimeout(timer);
timer = setTimeout(() => { profileState.value = "ready"; showToast("个人资料已保存"); }, 500);
timer = setTimeout(() => {
profileState.value = "ready";
showToast("本地校验通过,尚未提交服务器");
}, 500);
};
onUnmounted(() => clearTimeout(timer));
const requestBack = () =>
runBackGuard({
transientOpen: discardVisible.value,
dirty: isDirty.value,
submitting: profileState.value === "saving",
"close-transient": cancelDiscard,
"block-submitting": () => true,
"confirm-discard": requestDiscardConfirmation,
});
onBackPress((event) => handleBackPress(event, requestBack));
onUnload(() => {
clearTimeout(timer);
discardConfirmation.dispose();
});
</script>
<style scoped lang="scss">
@@ -70,11 +116,8 @@ onUnmounted(() => clearTimeout(timer));
.text-action { min-height: 72rpx; display: flex; align-items: center; color: $brand-red; font-size: 22rpx; }
.form-panel { margin-top: 22rpx; padding: 20rpx 34rpx 30rpx; box-sizing: border-box; }
.form-row { display: grid; grid-template-columns: 150rpx minmax(0,1fr); min-height: 92rpx; align-items: center; border-bottom: 1px solid rgba(181,137,63,.42); gap: 18rpx; }
.form-row > text, .textarea-row > text { color: $ink; font-size: 24rpx; font-weight: 700; }
.form-row > text { color: $ink; font-size: 24rpx; font-weight: 700; }
.form-row input { width: auto; min-width: 0; min-height: 70rpx; color: $ink; font-size: 24rpx; text-align: right; }
.textarea-row { padding-top: 24rpx; }
.textarea-row textarea { display: block; width: auto; min-width: 0; min-height: 150rpx; margin-top: 14rpx; color: $ink; font-size: 24rpx; line-height: 1.6; }
.counter { display: block; color: $ink-muted; font-size: 20rpx; text-align: right; }
.field-error { display: block; padding-top: 7rpx; color: #b42318; font-size: 21rpx; text-align: right; }
.page-content > .app-button { margin-top: 26rpx; }
@media(max-width:340px){.page-content{padding-right:22rpx;padding-left:22rpx}.profile-avatar-card{grid-template-columns:72rpx minmax(0,1fr);padding-right:26rpx;padding-left:26rpx}.text-action{grid-column:2}.avatar-seal{width:68rpx;max-height:78rpx}.form-row{grid-template-columns:126rpx minmax(0,1fr)}}
+10 -9
View File
@@ -1,12 +1,12 @@
<!-- 页面编号M-03用途账号安全总览与安全功能入口 -->
<template>
<view class="security-page device-state--safe">
<view class="security-page device-state--limited">
<ModulePageBackground module="profile" />
<view class="page-layer"><PageHeader title="账号与安全" /></view>
<view class="page-content page-layer">
<view class="security-summary">
<text>账号状态安全</text>
<text>手机号已绑定最近登录设备未发现异常</text>
<text>账号安全操作</text>
<text>修改密码和换绑手机号目前只提供本地校验预览不会变更服务器账号信息</text>
</view>
<view class="security-list">
<view v-for="item in securityItems" :key="item.key" class="security-row" role="button" :aria-label="item.label" @click="openSecurityItem(item)">
@@ -15,7 +15,7 @@
<image class="chevron" src="/static/assets/foundation/transparent/chevron-right.png" mode="aspectFit" />
</view>
</view>
<AppButton block type="secondary" label="检查账号安全" @click="checkSecurity" />
<AppButton block type="secondary" label="查看接入状态" @click="checkSecurity" />
</view>
<AppToast :visible="toastVisible" :message="toastMessage" />
</view>
@@ -27,18 +27,19 @@ import AppButton from "@/components/AppButton.vue";
import AppToast from "@/components/AppToast.vue";
import ModulePageBackground from "@/components/ModulePageBackground.vue";
import PageHeader from "@/components/PageHeader.vue";
import { currentUser } from "@/data/mock.js";
import { openPage } from "@/utils/navigation.js";
const securityItems = [
{ key: "password", label: "登录密码", note: "建议定期更新密码", icon: "/static/assets/modules/auth/transparent/a01-icon-lock-v1.png", url: "/pages/profile/m04-change-password" },
{ key: "phone", label: "绑定手机号", note: "139****6421", icon: "/static/assets/modules/auth/transparent/a01-icon-phone-v1.png", url: "/pages/profile/m05-change-phone" },
{ key: "device", label: "登录设备", note: "当前设备 · 今天登录", icon: "/static/assets/foundation/transparent/auth-login-outline.png" },
{ key: "password", label: "登录密码", note: "建议定期更新密码", icon: "/static/assets/modules/auth/transparent/a01-icon-lock-v1.png", routeKey: "M04" },
{ key: "phone", label: "绑定手机号", note: currentUser.phone, icon: "/static/assets/modules/auth/transparent/a01-icon-phone-v1.png", routeKey: "M05" },
];
const toastVisible = ref(false);
const toastMessage = ref("");
let timer = null;
const showToast = (message) => { toastMessage.value = message; toastVisible.value = true; clearTimeout(timer); timer = setTimeout(() => (toastVisible.value = false), 1800); };
const openSecurityItem = (item) => item.url ? uni.navigateTo({ url: item.url }) : showToast("当前设备登录正常,未发现异常记录");
const checkSecurity = () => showToast("安全检查完成,当前账号状态正常");
const openSecurityItem = (item) => openPage(item.routeKey, {}, "M03");
const checkSecurity = () => showToast("账号安全接口将在后续独立批次接入");
onUnmounted(() => clearTimeout(timer));
</script>
+47 -5
View File
@@ -2,7 +2,7 @@
<template>
<view class="password-page" :class="{ 'password-state--ready': passwordState === 'ready', 'password-state--saving': passwordState === 'saving' }">
<ModulePageBackground module="profile" />
<view class="page-layer"><PageHeader title="修改密码" /></view>
<view class="page-layer"><PageHeader title="修改密码" custom-back @back="requestBack" /></view>
<view class="page-content page-layer">
<view class="security-tip"><text>设置安全密码</text><text>建议使用 832 位字母与数字组合不要与其他应用共用</text></view>
<view class="form-panel">
@@ -15,18 +15,34 @@
<text v-if="errors[field.key]" class="field-error">{{ errors[field.key] }}</text>
</view>
</view>
<AppButton block :disabled="passwordState === 'saving'" :label="passwordState === 'saving' ? '正在修改' : '确认修改'" @click="savePassword" />
<AppButton block :disabled="passwordState === 'saving'" :label="passwordState === 'saving' ? '正在校验' : '校验新密码(不提交)'" @click="savePassword" />
</view>
<AppToast :visible="toastVisible" :message="toastMessage" />
<AppDialog
:visible="discardVisible"
:close-on-mask="false"
eyebrow="放弃确认"
title="放弃密码填写?"
message="当前密码和新密码尚未提交服务器。"
confirm-text="确认放弃"
cancel-text="继续填写"
show-cancel
@confirm="confirmDiscard"
@cancel="cancelDiscard"
/>
</view>
</template>
<script setup>
import { onUnmounted, reactive, ref } from "vue";
import { computed, reactive, ref } from "vue";
import { onBackPress, onUnload } from "@dcloudio/uni-app";
import AppButton from "@/components/AppButton.vue";
import AppDialog from "@/components/AppDialog.vue";
import AppToast from "@/components/AppToast.vue";
import ModulePageBackground from "@/components/ModulePageBackground.vue";
import PageHeader from "@/components/PageHeader.vue";
import { createDiscardConfirmation } from "@/utils/discard-confirmation.js";
import { handleBackPress, runBackGuard } from "@/utils/navigation.js";
import {
PASSWORD_POLICY_MESSAGE,
validatePassword,
@@ -36,12 +52,22 @@ const passwordForm = reactive({ current: "", next: "", confirm: "" });
const passwordVisible = reactive({ current: false, next: false, confirm: false });
const errors = reactive({ current: "", next: "", confirm: "" });
const passwordState = ref("ready");
const discardVisible = ref(false);
const passwordFields = [
{ key: "current", label: "当前密码", placeholder: "请输入当前密码" },
{ key: "next", label: "新密码", placeholder: "832 位字母与数字" },
{ key: "confirm", label: "确认新密码", placeholder: "请再次输入新密码" },
];
const toastVisible = ref(false); const toastMessage = ref(""); let timer = null;
const formSnapshot = computed(() => JSON.stringify(passwordForm));
const baseline = ref(formSnapshot.value);
const isDirty = computed(() => formSnapshot.value !== baseline.value);
const discardConfirmation = createDiscardConfirmation((visible) => {
discardVisible.value = visible;
});
const requestDiscardConfirmation = discardConfirmation.request;
const confirmDiscard = discardConfirmation.confirm;
const cancelDiscard = discardConfirmation.cancel;
const showToast = (message) => { toastMessage.value = message; toastVisible.value = true; clearTimeout(timer); timer = setTimeout(() => (toastVisible.value = false), 1800); };
const togglePassword = (key) => { passwordVisible[key] = !passwordVisible[key]; };
const validateForm = () => {
@@ -56,9 +82,25 @@ const validateForm = () => {
const savePassword = () => {
if (!validateForm() || passwordState.value === "saving") return;
passwordState.value = "saving";
timer = setTimeout(() => { passwordState.value = "ready"; passwordForm.current = ""; passwordForm.next = ""; passwordForm.confirm = ""; showToast("密码已修改,请妥善保管新密码"); }, 500);
timer = setTimeout(() => {
passwordState.value = "ready";
showToast("本地校验通过,尚未提交服务器");
}, 500);
};
onUnmounted(() => clearTimeout(timer));
const requestBack = () =>
runBackGuard({
transientOpen: discardVisible.value,
dirty: isDirty.value,
submitting: passwordState.value === "saving",
"close-transient": cancelDiscard,
"block-submitting": () => true,
"confirm-discard": requestDiscardConfirmation,
});
onBackPress((event) => handleBackPress(event, requestBack));
onUnload(() => {
clearTimeout(timer);
discardConfirmation.dispose();
});
</script>
<style scoped lang="scss">
+55 -13
View File
@@ -2,52 +2,94 @@
<template>
<view class="phone-page" :class="{ 'phone-state--ready': phoneState === 'ready', 'phone-state--saving': phoneState === 'saving' }">
<ModulePageBackground module="profile" />
<view class="page-layer"><PageHeader title="修改手机号" /></view>
<view class="page-layer"><PageHeader title="修改手机号" custom-back @back="requestBack" /></view>
<view class="page-content page-layer">
<view class="current-phone"><text>当前绑定手机号</text><text>{{ phoneForm.currentPhone }}</text><text>更换后新手机号将用于登录与安全验证</text></view>
<view class="form-panel">
<view class="form-row"><text>新手机号</text><input v-model.trim="phoneForm.newPhone" type="number" maxlength="11" aria-label="新手机号" placeholder="请输入新手机号" @input="errors.newPhone = ''" /></view>
<text v-if="errors.newPhone" class="field-error">{{ errors.newPhone }}</text>
<view class="form-row code-row"><text>验证码</text><input v-model.trim="phoneForm.code" type="number" maxlength="6" aria-label="短信验证码" placeholder="6 位验证码" @input="errors.code = ''" /><view class="code-action" role="button" :aria-label="codeCountdown ? `${codeCountdown}秒后可重新发送` : '发送验证码'" @click="sendCode">{{ codeCountdown ? `${codeCountdown}s` : "发送验证码" }}</view></view>
<view class="form-row code-row"><text>验证码</text><input v-model.trim="phoneForm.code" type="number" maxlength="4" aria-label="短信验证码" placeholder="4 位验证码" @input="errors.code = ''" /><view class="code-action" role="button" aria-label="检查验证码发送条件" @click="sendCode">发送条件</view></view>
<text v-if="errors.code" class="field-error">{{ errors.code }}</text>
</view>
<AppButton block :disabled="phoneState === 'saving'" :label="phoneState === 'saving' ? '正在更换' : '确认更换'" @click="savePhone" />
<AppButton block :disabled="phoneState === 'saving'" :label="phoneState === 'saving' ? '正在校验' : '校验换绑信息(不提交)'" @click="savePhone" />
</view>
<AppToast :visible="toastVisible" :message="toastMessage" />
<AppDialog
:visible="discardVisible"
:close-on-mask="false"
eyebrow="放弃确认"
title="放弃换绑填写?"
message="新手机号和验证码尚未提交服务器。"
confirm-text="确认放弃"
cancel-text="继续填写"
show-cancel
@confirm="confirmDiscard"
@cancel="cancelDiscard"
/>
</view>
</template>
<script setup>
import { onUnmounted, reactive, ref } from "vue";
import { computed, reactive, ref } from "vue";
import { onBackPress, onUnload } from "@dcloudio/uni-app";
import AppButton from "@/components/AppButton.vue";
import AppDialog from "@/components/AppDialog.vue";
import AppToast from "@/components/AppToast.vue";
import ModulePageBackground from "@/components/ModulePageBackground.vue";
import PageHeader from "@/components/PageHeader.vue";
import { currentUser } from "@/data/mock.js";
import { createDiscardConfirmation } from "@/utils/discard-confirmation.js";
import { handleBackPress, runBackGuard } from "@/utils/navigation.js";
const phoneForm = reactive({ currentPhone: "139****6421", newPhone: "", code: "" });
const phoneForm = reactive({ currentPhone: currentUser.phone, newPhone: "", code: "" });
const errors = reactive({ newPhone: "", code: "" });
const phoneState = ref("ready");
const codeCountdown = ref(0);
const discardVisible = ref(false);
const toastVisible = ref(false); const toastMessage = ref("");
let countdownTimer = null; let stateTimer = null; let toastTimer = null;
let stateTimer = null; let toastTimer = null;
const formSnapshot = computed(() => JSON.stringify({ newPhone: phoneForm.newPhone, code: phoneForm.code }));
const baseline = ref(formSnapshot.value);
const isDirty = computed(() => formSnapshot.value !== baseline.value);
const discardConfirmation = createDiscardConfirmation((visible) => {
discardVisible.value = visible;
});
const requestDiscardConfirmation = discardConfirmation.request;
const confirmDiscard = discardConfirmation.confirm;
const cancelDiscard = discardConfirmation.cancel;
const showToast = (message) => { toastMessage.value = message; toastVisible.value = true; clearTimeout(toastTimer); toastTimer = setTimeout(() => (toastVisible.value = false), 1800); };
const sendCode = () => {
if (codeCountdown.value) return;
if (!/^1\d{10}$/.test(phoneForm.newPhone)) { errors.newPhone = "请输入正确的新手机号"; return; }
errors.newPhone = ""; codeCountdown.value = 60; showToast("演示验证码已发送,请输入任意 6 位数字");
countdownTimer = setInterval(() => { codeCountdown.value -= 1; if (!codeCountdown.value) { clearInterval(countdownTimer); countdownTimer = null; } }, 1000);
errors.newPhone = "";
showToast("需先完成滑动行为验证;当前未发送验证码");
};
const validatePhone = () => {
errors.newPhone = /^1\d{10}$/.test(phoneForm.newPhone) ? "" : "请输入正确的新手机号";
errors.code = /^\d{6}$/.test(phoneForm.code) ? "" : "请输入 6 位验证码";
errors.code = /^\d{4}$/.test(phoneForm.code) ? "" : "请输入 4 位验证码";
return !errors.newPhone && !errors.code;
};
const savePhone = () => {
if (!validatePhone() || phoneState.value === "saving") return;
phoneState.value = "saving";
stateTimer = setTimeout(() => { phoneState.value = "ready"; phoneForm.currentPhone = `${phoneForm.newPhone.slice(0,3)}****${phoneForm.newPhone.slice(-4)}`; phoneForm.newPhone = ""; phoneForm.code = ""; showToast("绑定手机号已更新"); }, 500);
stateTimer = setTimeout(() => {
phoneState.value = "ready";
showToast("本地校验通过,尚未提交服务器");
}, 500);
};
onUnmounted(() => { clearInterval(countdownTimer); clearTimeout(stateTimer); clearTimeout(toastTimer); });
const requestBack = () =>
runBackGuard({
transientOpen: discardVisible.value,
dirty: isDirty.value,
submitting: phoneState.value === "saving",
"close-transient": cancelDiscard,
"block-submitting": () => true,
"confirm-discard": requestDiscardConfirmation,
});
onBackPress((event) => handleBackPress(event, requestBack));
onUnload(() => {
clearTimeout(stateTimer);
clearTimeout(toastTimer);
discardConfirmation.dispose();
});
</script>
<style scoped lang="scss">
+3 -2
View File
@@ -23,6 +23,7 @@ import { computed, ref } from "vue";
import AppButton from "@/components/AppButton.vue";
import ModulePageBackground from "@/components/ModulePageBackground.vue";
import PageHeader from "@/components/PageHeader.vue";
import { openPage } from "@/utils/navigation.js";
const helpCategories = ["全部", "家谱", "成员", "隐私", "账号"];
const activeCategory = ref("全部");
@@ -30,7 +31,7 @@ const keyword = ref("");
const expandedIds = ref([]);
const questions = [
{ id: 1, category: "家谱", question: "如何创建一部新家谱?", answer: "进入“我的家谱”,选择新建家谱,按步骤填写姓氏、堂号和地区等基础资料。" },
{ id: 2, category: "成员", question: "如何邀请家人共同完善家谱?", answer: "家人可搜索家谱后提交加入申请,管理员核实亲属关系后完成审核。" },
{ id: 2, category: "成员", question: "如何邀请家人共同完善家谱?", answer: "家人可搜索家谱后提交加入申请,管理员核实关系;邀请码尚未接入,未来校验成功后将直接加入,不产生审核记录。" },
{ id: 3, category: "隐私", question: "哪些个人资料会展示给其他成员?", answer: "资料按家谱角色与权限展示;敏感联系方式默认脱敏,后续可在家谱设置中管理权限。" },
{ id: 4, category: "账号", question: "忘记密码后怎样恢复账号?", answer: "在登录页选择“忘记密码”,通过绑定手机号验证后设置新密码。" },
{ id: 5, category: "家谱", question: "家谱资料填写错了怎么办?", answer: "有编辑权限的成员可以进入对应资料页修改;关键世系关系建议核对后再保存。" },
@@ -40,7 +41,7 @@ const filteredQuestions = computed(() => {
return questions.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 = () => uni.navigateTo({ url: "/pages/profile/m07-feedback" });
const contactSupport = () => openPage("M07", {}, "M06");
</script>
<style scoped lang="scss">
+234 -24
View File
@@ -1,53 +1,263 @@
<!-- 页面编号M-07用途提交意见反馈 -->
<template>
<view class="feedback-page" :class="{ 'feedback-state--ready': feedbackState === 'ready', 'feedback-state--submitting': feedbackState === 'submitting' }">
<view
class="feedback-page"
:class="{
'feedback-state--ready': feedbackState === 'ready',
'feedback-state--submitting': feedbackState === 'submitting',
'feedback-state--success': feedbackState === 'success',
'feedback-state--error': feedbackState === 'error',
'feedback-state--uncertain': feedbackState === 'uncertain',
}"
>
<ModulePageBackground module="profile" />
<view class="page-layer"><PageHeader title="意见反馈" /></view>
<view class="page-layer"><PageHeader title="意见反馈" custom-back @back="requestBack" /></view>
<view class="page-content page-layer">
<text class="lead">你的建议会帮助我们把家谱做得更好</text>
<view class="type-grid">
<view v-for="type in feedbackTypes" :key="type" class="type-chip" :class="{ active: feedbackForm.type === type }" role="button" @click="feedbackForm.type = type; errors.type = ''">{{ type }}</view>
<view class="type-field" role="group" aria-label="反馈类型选填" aria-describedby="feedback-type-note">
<text id="feedback-type-note" class="field-note">反馈类型为选填可再次点击取消选择</text>
<view class="type-grid">
<button
v-for="type in feedbackTypes"
:key="type"
class="type-chip"
:class="{ active: feedbackForm.feedbackType === type }"
:aria-pressed="feedbackForm.feedbackType === type"
:disabled="feedbackState === 'submitting'"
@click="selectFeedbackType(type)"
>{{ type }}</button>
</view>
</view>
<text v-if="errors.type" class="field-error">{{ errors.type }}</text>
<view class="form-panel">
<textarea v-model.trim="feedbackForm.description" auto-height maxlength="500" aria-label="问题描述" placeholder="请说明遇到的问题、操作步骤或建议" @input="errors.description = ''" />
<text class="counter">{{ feedbackForm.description.length }}/500</text>
<text v-if="errors.description" class="field-error">{{ errors.description }}</text>
<view class="contact-row"><text>联系方式</text><input v-model.trim="feedbackForm.contact" maxlength="50" aria-label="联系方式" placeholder="手机号或邮箱(选填)" /></view>
<textarea
v-model.trim="feedbackForm.feedbackContent"
auto-height
maxlength="500"
aria-label="问题描述"
aria-required="true"
:aria-describedby="errors.feedbackContent ? 'feedback-content-help feedback-content-error' : 'feedback-content-help'"
:aria-invalid="Boolean(errors.feedbackContent)"
:disabled="feedbackState === 'submitting'"
:focus="feedbackContentFocused"
placeholder="请说明遇到的问题、操作步骤或建议"
@input="errors.feedbackContent = ''"
@blur="feedbackContentFocused = false"
/>
<text class="counter">{{ feedbackForm.feedbackContent.length }}/500</text>
<text id="feedback-content-help" class="field-note">请勿填写密码验证码等敏感信息</text>
<text v-if="errors.feedbackContent" id="feedback-content-error" class="field-error">{{ errors.feedbackContent }}</text>
<view class="contact-row">
<text>联系方式</text>
<input
v-model.trim="feedbackForm.contactInfo"
maxlength="50"
aria-label="联系方式选填"
:disabled="feedbackState === 'submitting'"
placeholder="手机号或邮箱(选填)"
/>
</view>
</view>
<text class="privacy-note">仅用于跟进本次反馈不会在家谱中公开</text>
<AppButton block :disabled="feedbackState === 'submitting'" :label="feedbackState === 'submitting' ? '正在提交' : '提交反馈'" @click="submitFeedback" />
<text class="privacy-note">联系方式选填便于需要时核实本次反馈</text>
<view
v-if="feedbackResult"
class="feedback-result"
:class="{
'feedback-result--success': feedbackResultTone === 'success',
'feedback-result--error': feedbackResultTone === 'error',
'feedback-result--uncertain': feedbackResultTone === 'uncertain',
}"
:role="feedbackResultTone === 'error' ? 'alert' : 'status'"
:aria-live="feedbackResultTone === 'error' ? 'assertive' : 'polite'"
>{{ feedbackResult }}</view>
<AppButton block :disabled="submitDisabled" :label="submitLabel" @click="submitFeedback" />
</view>
<AppToast :visible="toastVisible" :message="toastMessage" />
<AppDialog
:visible="discardVisible"
:close-on-mask="false"
eyebrow="放弃确认"
title="放弃当前反馈?"
message="当前修改尚未提交,离开后将清除这些修改。"
confirm-text="确认放弃"
cancel-text="继续填写"
show-cancel
@confirm="confirmDiscard"
@cancel="cancelDiscard"
/>
</view>
</template>
<script setup>
import { onUnmounted, reactive, ref } from "vue";
import { computed, nextTick, reactive, ref, watch } from "vue";
import { onBackPress, onUnload } from "@dcloudio/uni-app";
import AppButton from "@/components/AppButton.vue";
import AppToast from "@/components/AppToast.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 { handleBackPress, runBackGuard } from "@/utils/navigation.js";
const feedbackTypes = ["功能问题", "使用建议", "内容纠错", "其他"];
const feedbackForm = reactive({ type: "", description: "", contact: "" });
const errors = reactive({ type: "", description: "" });
const feedbackForm = reactive({ feedbackType: "", feedbackContent: "", contactInfo: "" });
const errors = reactive({ feedbackContent: "" });
const feedbackState = ref("ready");
const toastVisible = ref(false); const toastMessage = ref(""); let timer = null;
const feedbackResult = ref("");
const feedbackResultTone = ref("");
const feedbackContentFocused = ref(false);
const discardVisible = ref(false);
const lastSubmittedSnapshot = ref("");
const lastUncertainSnapshot = ref("");
let pageActive = true;
const requestController = createRequestController();
const normalizedForm = computed(() => ({
feedbackType: feedbackForm.feedbackType.trim(),
feedbackContent: feedbackForm.feedbackContent.trim(),
contactInfo: feedbackForm.contactInfo.trim(),
}));
const formSnapshot = computed(() => JSON.stringify(normalizedForm.value));
const baseline = ref(formSnapshot.value);
const isDirty = computed(() => formSnapshot.value !== baseline.value);
const submitDisabled = computed(() =>
feedbackState.value === "submitting" ||
(Boolean(lastSubmittedSnapshot.value) && formSnapshot.value === lastSubmittedSnapshot.value) ||
(Boolean(lastUncertainSnapshot.value) && formSnapshot.value === lastUncertainSnapshot.value),
);
const submitLabel = computed(() => {
if (feedbackState.value === "submitting") return "正在提交";
if (feedbackState.value === "success") return "反馈已提交";
if (feedbackState.value === "uncertain") return "提交结果待确认";
if (feedbackState.value === "error") return "重新提交";
return "提交反馈";
});
const discardConfirmation = createDiscardConfirmation((visible) => {
discardVisible.value = visible;
});
const requestDiscardConfirmation = discardConfirmation.request;
const confirmDiscard = discardConfirmation.confirm;
const cancelDiscard = discardConfirmation.cancel;
const validateFeedback = () => {
errors.type = feedbackForm.type ? "" : "请选择反馈类型";
errors.description = feedbackForm.description.length < 5 ? "请至少填写 5 个字的问题描述" : "";
return !errors.type && !errors.description;
errors.feedbackContent = feedbackForm.feedbackContent.trim() ? "" : "请填写问题描述";
if (errors.feedbackContent) {
feedbackContentFocused.value = false;
nextTick(() => {
if (pageActive) feedbackContentFocused.value = true;
});
}
return !errors.feedbackContent;
};
const submitFeedback = () => {
const selectFeedbackType = (type) => {
if (feedbackState.value === "submitting") return;
feedbackForm.feedbackType = feedbackForm.feedbackType === type ? "" : type;
};
const feedbackErrorMessage = (error) => {
if (error?.code === "WRITE_UNAVAILABLE") {
return "当前为本地预览模式,反馈未提交服务器。";
}
if (error?.httpStatus === 401) {
return "登录状态已失效,服务器未确认提交成功。请重新登录后再处理。";
}
return error?.message ? `服务器未确认提交成功:${error.message}` : "服务器未确认提交成功,请稍后再试。";
};
const submittedFeedbackMessage = "反馈已提交。以下内容为本次提交记录;修改任一项后可提交新反馈。";
const submittedWithPendingEditsMessage = "上一份反馈已提交,当前修改尚未提交。";
const uncertainFeedbackMessage = "提交结果可能未知。为避免重复记录,当前内容不能直接重提;请稍后确认或修改后提交新反馈。";
const uncertainWithPendingEditsMessage = "上一份反馈的提交结果仍待确认,当前修改尚未提交。";
const isFeedbackResultUncertain = (error) => {
if (["REQUEST_TIMEOUT", "NETWORK_ERROR", "RESPONSE_INVALID"].includes(error?.code)) {
return true;
}
if (error?.code !== "HTTP_ERROR") return false;
const status = error.httpStatus;
return (status >= 200 && status < 400) || status === 408 || status >= 500;
};
const submitFeedback = async () => {
if (!validateFeedback() || feedbackState.value === "submitting") return;
if (
(lastSubmittedSnapshot.value && formSnapshot.value === lastSubmittedSnapshot.value) ||
(lastUncertainSnapshot.value && formSnapshot.value === lastUncertainSnapshot.value)
) return;
const submittedSnapshot = formSnapshot.value;
const submittedPayload = JSON.parse(submittedSnapshot);
feedbackState.value = "submitting";
timer = setTimeout(() => { feedbackState.value = "ready"; feedbackForm.type = ""; feedbackForm.description = ""; feedbackForm.contact = ""; toastMessage.value = "反馈已保存,服务接入后将提交给家谱助手"; toastVisible.value = true; timer = setTimeout(() => (toastVisible.value = false), 2200); }, 500);
feedbackResult.value = "";
feedbackResultTone.value = "";
try {
await appApi.submitFeedback(submittedPayload, { requestController });
if (!pageActive) return;
baseline.value = submittedSnapshot;
lastSubmittedSnapshot.value = submittedSnapshot;
feedbackResultTone.value = "success";
if (formSnapshot.value === submittedSnapshot) {
feedbackState.value = "success";
feedbackResult.value = submittedFeedbackMessage;
} else {
feedbackState.value = "ready";
feedbackResult.value = submittedWithPendingEditsMessage;
}
} catch (error) {
if (!pageActive || isRequestCancelled(error)) return;
if (isFeedbackResultUncertain(error)) {
lastUncertainSnapshot.value = submittedSnapshot;
feedbackResultTone.value = "uncertain";
if (formSnapshot.value === submittedSnapshot) {
feedbackState.value = "uncertain";
feedbackResult.value = uncertainFeedbackMessage;
} else {
feedbackState.value = "ready";
feedbackResult.value = uncertainWithPendingEditsMessage;
}
return;
}
feedbackState.value = "error";
feedbackResult.value = feedbackErrorMessage(error);
feedbackResultTone.value = "error";
}
};
onUnmounted(() => clearTimeout(timer));
watch(formSnapshot, (snapshot) => {
if (feedbackState.value === "submitting") return;
errors.feedbackContent = "";
if (lastSubmittedSnapshot.value && snapshot === lastSubmittedSnapshot.value) {
feedbackState.value = "success";
feedbackResult.value = submittedFeedbackMessage;
feedbackResultTone.value = "success";
return;
}
if (lastUncertainSnapshot.value && snapshot === lastUncertainSnapshot.value) {
feedbackState.value = "uncertain";
feedbackResult.value = uncertainFeedbackMessage;
feedbackResultTone.value = "uncertain";
return;
}
feedbackState.value = "ready";
if (lastUncertainSnapshot.value) {
feedbackResult.value = uncertainWithPendingEditsMessage;
feedbackResultTone.value = "uncertain";
} else if (lastSubmittedSnapshot.value) {
feedbackResult.value = submittedWithPendingEditsMessage;
feedbackResultTone.value = "success";
} else {
feedbackResult.value = "";
feedbackResultTone.value = "";
}
});
const requestBack = () =>
runBackGuard({
transientOpen: discardVisible.value,
dirty: isDirty.value,
submitting: feedbackState.value === "submitting",
"close-transient": cancelDiscard,
"block-submitting": () => true,
"confirm-discard": requestDiscardConfirmation,
});
onBackPress((event) => handleBackPress(event, requestBack));
onUnload(() => {
pageActive = false;
requestController.abort();
discardConfirmation.dispose();
});
</script>
<style scoped lang="scss">
@use "../../styles/adaptive-frame-profiles.scss" as adaptive;
.feedback-page{display:flex;min-height:100vh;flex-direction:column;background:$paper}.page-layer{z-index:1}.page-content{flex:1;padding:26rpx 30rpx 72rpx}.lead{display:block;color:$ink-muted;font-size:23rpx;line-height:1.5;text-align:center}.type-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:14rpx;margin-top:20rpx}.type-chip{@include adaptive.adaptive-profile-field;display:flex;min-height:76rpx;align-items:center;justify-content:center;padding:10rpx 16rpx;color:$ink-muted;font-size:22rpx;text-align:center}.type-chip.active{color:$brand-red;font-weight:700;filter:saturate(1.2)}.form-panel{@include adaptive.adaptive-profile-content;margin-top:20rpx;padding:30rpx 34rpx}.form-panel textarea{display:block;width:auto;min-width:0;min-height:210rpx;color:$ink;font-size:24rpx;line-height:1.65}.counter{display:block;color:$ink-muted;font-size:20rpx;text-align:right}.contact-row{display:grid;grid-template-columns:130rpx minmax(0,1fr);min-height:88rpx;align-items:center;gap:16rpx;margin-top:16rpx;border-top:1px solid rgba(181,137,63,.38)}.contact-row text{color:$ink;font-size:23rpx;font-weight:700}.contact-row input{width:auto;min-width:0;min-height:68rpx;color:$ink;font-size:22rpx;text-align:right}.field-error{display:block;margin-top:7rpx;color:#b42318;font-size:20rpx;line-height:1.4;text-align:right}.privacy-note{display:block;margin-top:15rpx;color:$ink-muted;font-size:20rpx;line-height:1.5;text-align:center}.page-content>.app-button{margin-top:24rpx}@media(max-width:340px){.page-content{padding-right:22rpx;padding-left:22rpx}.form-panel{padding-right:26rpx;padding-left:26rpx}}
.feedback-page{display:flex;min-height:100vh;flex-direction:column;background:$paper}.page-layer{z-index:1}.page-content{flex:1;padding:26rpx 30rpx 72rpx}.lead{display:block;color:$ink-muted;font-size:23rpx;line-height:1.5;text-align:center}.type-field{margin-top:20rpx}.field-note{display:block;color:$ink-muted;font-size:20rpx;line-height:1.5}.type-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:14rpx;margin-top:10rpx}.type-chip{margin:0;padding:0;border:0;background:transparent;line-height:normal;@include adaptive.adaptive-profile-field;display:flex;min-height:88rpx;align-items:center;justify-content:center;color:$ink-muted;font-size:22rpx;text-align:center}.type-chip::after{border:0}.type-chip.active{color:$brand-red;font-weight:700;filter:saturate(1.2)}.type-chip[disabled]{opacity:.55}.form-panel{@include adaptive.adaptive-profile-content;margin-top:20rpx;padding:30rpx 34rpx}.form-panel textarea{display:block;width:auto;min-width:0;min-height:210rpx;color:$ink;font-size:24rpx;line-height:1.65}.form-panel textarea[disabled],.contact-row input[disabled]{opacity:.65}.counter{display:block;color:$ink-muted;font-size:20rpx;text-align:right}.contact-row{display:grid;grid-template-columns:130rpx minmax(0,1fr);min-height:88rpx;align-items:center;gap:16rpx;margin-top:16rpx;border-top:1px solid rgba(181,137,63,.38)}.contact-row text{color:$ink;font-size:23rpx;font-weight:700}.contact-row input{width:auto;min-width:0;min-height:68rpx;color:$ink;font-size:22rpx;text-align:right}.field-error{display:block;margin-top:7rpx;color:#b42318;font-size:20rpx;line-height:1.4;text-align:right}.privacy-note{display:block;margin-top:15rpx;color:$ink-muted;font-size:20rpx;line-height:1.5;text-align:center}.feedback-result{margin-top:18rpx;padding:18rpx 20rpx;border:1px solid rgba(122,91,46,.24);border-radius:12rpx;font-size:21rpx;line-height:1.55}.feedback-result--success{border-color:rgba(41,112,66,.34);background:rgba(234,246,237,.88);color:#245f39}.feedback-result--error{border-color:rgba(180,35,24,.3);background:rgba(255,241,239,.9);color:#8f241c}.feedback-result--uncertain{border-color:rgba(155,101,22,.34);background:rgba(255,248,229,.92);color:#76500f}.page-content>.app-button{margin-top:24rpx}@media(max-width:340px){.page-content{padding-right:22rpx;padding-left:22rpx}.form-panel{padding-right:26rpx;padding-left:26rpx}}
</style>
+22 -23
View File
@@ -1,47 +1,46 @@
<!-- 页面编号M-08用途邀请家人共建家谱 -->
<template>
<view class="promotion-page share-state--ready">
<view class="promotion-page share-state--unavailable">
<ModulePageBackground module="profile" />
<view class="page-layer"><PageHeader title="邀请家人" /></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>
</view>
<view class="invite-code-card">
<text>汝南汤氏家谱邀请码</text>
<text class="invite-code">{{ inviteCode }}</text>
<view class="copy-action" role="button" aria-label="复制邀请码" @click="copyInviteCode">复制邀请码</view>
<view v-if="inviteState === 'unavailable'" class="unavailable-card">
<text>当前没有可用邀请码</text>
<text>当前尚未接入邀请码校验与签发接口页面不会生成复制或展示虚假邀请码</text>
</view>
<view class="steps-card"><text>邀请步骤</text><text>1. 生成邀请信息</text><text>2. 发送给家人</text><text>3. 家人提交加入申请</text><text>4. 管理员核实并审核</text></view>
<AppButton block label="生成邀请海报" @click="generatePoster" />
<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="posterVisible" eyebrow="家谱邀请" title="汝南汤氏家谱" :message="`邀请码 ${inviteCode}。长按或复制邀请码发送给家人。`" confirm-text="完成" @confirm="posterVisible = false" @close="posterVisible = false">
<image class="poster-seal" src="/static/assets/foundation/transparent/brand-seal.png" mode="aspectFit" />
</AppDialog>
<AppToast :visible="toastVisible" :message="toastMessage" />
<AppDialog :visible="explanationVisible" :close-on-mask="false" eyebrow="邀请说明" title="功能尚未接入" message="当前后端接口文档没有邀请码签发与校验合同。完成接口接入前,本页保持不可用,避免家人拿到无法验证的邀请码。" confirm-text="我知道了" @confirm="explanationVisible = false" @close="explanationVisible = false" />
</view>
</template>
<script setup>
import { onUnmounted, ref } from "vue";
import { ref } from "vue";
import { onBackPress } from "@dcloudio/uni-app";
import AppButton from "@/components/AppButton.vue";
import AppDialog from "@/components/AppDialog.vue";
import AppToast from "@/components/AppToast.vue";
import ModulePageBackground from "@/components/ModulePageBackground.vue";
import PageHeader from "@/components/PageHeader.vue";
import { handleBackPress, runBackGuard } from "@/utils/navigation.js";
const inviteCode = ref("TN20260720");
const posterVisible = ref(false);
const toastVisible = ref(false); const toastMessage = ref(""); let timer = null;
const showToast = (message) => { toastMessage.value = message; toastVisible.value = true; clearTimeout(timer); timer = setTimeout(() => (toastVisible.value = false), 1800); };
const copyInviteCode = () => uni.setClipboardData({ data: inviteCode.value, showToast: false, success: () => showToast("邀请码已复制"), fail: () => showToast("复制失败,请长按邀请码复制") });
const generatePoster = () => { posterVisible.value = true; };
onUnmounted(() => clearTimeout(timer));
const inviteState = ref("unavailable");
const explanationVisible = ref(false);
const openInviteExplanation = () => { explanationVisible.value = true; };
const requestBack = () =>
runBackGuard({
transientOpen: explanationVisible.value,
"close-transient": () => { explanationVisible.value = false; },
});
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,.invite-code-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}.invite-code-card{min-height:220rpx;margin-top:20rpx;padding:38rpx 44rpx;text-align:center}.invite-code-card>text:first-child{display:block;color:$ink-muted;font-size:21rpx}.invite-code{display:block;margin-top:10rpx;color:$brand-red;font-size:42rpx;font-weight:700;letter-spacing:5rpx;overflow-wrap:anywhere}.copy-action{display:flex;min-height:62rpx;align-items:center;justify-content:center;color:$brand-red;font-size:22rpx}.steps-card{display:flex;min-height:260rpx;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}.poster-seal{width:96rpx;height:auto;max-height:110rpx;aspect-ratio:96/110;margin:18rpx auto 24rpx}@media(max-width:340px){.page-content{padding-right:22rpx;padding-left:22rpx}.invite-code{font-size:35rpx;letter-spacing:3rpx}}
.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}}
</style>
+15 -10
View File
@@ -1,40 +1,45 @@
<!-- 页面编号M-09用途服务权益与订单记录 -->
<template>
<view class="orders-page" :class="orders.length ? 'order-state--ready' : 'order-state--empty'">
<view class="orders-page order-state--unavailable">
<ModulePageBackground module="profile" />
<view class="page-layer"><PageHeader title="VIP 与订单" /></view>
<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>{{ orders.length }} </text></view>
<view v-if="orders.length" class="order-list"><view v-for="order in orders" :key="order.id" class="order-card"><view><text>{{ order.name }}</text><text>{{ order.createdAt }}</text></view><view><text>{{ order.amount }}</text><text>{{ order.status }}</text></view></view></view>
<view v-else class="empty-card"><text>暂无订单记录</text><text>服务开放并完成购买后订单状态会在这里展示</text></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>
<AppDialog :visible="serviceNoticeVisible" eyebrow="服务说明" title="付费服务尚未开放" message="当前版本不会产生扣费或订单。未来开放前会明确展示价格、权益、续费与退款规则。" confirm-text="我知道了" @confirm="serviceNoticeVisible = false" @close="serviceNoticeVisible = false" />
<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 { onLoad } from "@dcloudio/uni-app";
import { onBackPress } 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 { handleBackPress, runBackGuard } from "@/utils/navigation.js";
const serviceBenefits = [
{ title: "更大存储", copy: "为家族影像和资料提供更多空间" },
{ title: "资料导出", copy: "按规则整理并导出家谱资料" },
{ title: "专属服务", copy: "获得家谱整理与使用支持" },
];
const orders = ref([]);
const orderState = ref("unavailable");
const serviceNoticeVisible = ref(false);
onLoad((query) => { if (query.state === "ready") orders.value = [{ id: "O20260720001", name: "家谱服务演示订单", createdAt: "2026-07-20 08:30", amount: "¥0.00", status: "演示记录" }]; });
const openServiceNotice = () => { serviceNoticeVisible.value = true; };
const requestBack = () =>
runBackGuard({
transientOpen: serviceNoticeVisible.value,
"close-transient": () => { serviceNoticeVisible.value = false; },
});
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,.order-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}.order-list{display:flex;flex-direction:column;gap:14rpx;margin-top:18rpx}.order-card{display:grid;grid-template-columns:minmax(0,1fr) auto;min-height:130rpx;align-items:center;gap:20rpx;padding:26rpx 34rpx}.order-card text{display:block;overflow-wrap:anywhere}.order-card view:first-child text:first-child{color:$ink;font-size:23rpx;font-weight:700}.order-card text:last-child{margin-top:6rpx;color:$ink-muted;font-size:20rpx}.order-card view:last-child{text-align:right}.order-card view:last-child text:first-child{color:$brand-red;font-size:23rpx;font-weight:700}.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}.order-card{grid-template-columns:1fr}.order-card view:last-child{text-align:left}}
.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}}
</style>
+26 -10
View File
@@ -2,39 +2,55 @@
<template>
<view class="about-page">
<ModulePageBackground module="profile" />
<view class="page-layer"><PageHeader title="关于家谱" /></view>
<view class="page-layer"><PageHeader title="关于家谱" custom-back @back="requestBack" /></view>
<view class="page-content page-layer">
<view class="brand-card"><image src="/static/assets/foundation/transparent/brand-seal.png" mode="aspectFit" /><text>家谱</text><text>传承每一段值得珍藏的家族记忆</text><text>版本 {{ appVersion }}</text></view>
<view class="settings-list">
<view v-for="item in agreementItems" :key="item.key" class="settings-row" role="button" :aria-label="item.label" @click="openAgreement(item)"><view><text>{{ item.label }}</text><text>{{ item.note }}</text></view><image src="/static/assets/foundation/transparent/chevron-right.png" mode="aspectFit" /></view>
</view>
<AppButton block type="secondary" :label="loggedOut ? '已退出登录' : '退出登录'" :disabled="loggedOut" @click="logoutVisible = true" />
<AppButton block type="secondary" label="退出登录" @click="logoutVisible = true" />
</view>
<AppDialog :visible="agreementVisible" eyebrow="协议与说明" :title="activeAgreement.label" :message="activeAgreement.copy" confirm-text="关闭" @confirm="agreementVisible = false" @close="agreementVisible = false" />
<AppDialog :visible="agreementVisible" :close-on-mask="false" eyebrow="协议与说明" :title="activeAgreement.label" :message="activeAgreement.copy" confirm-text="关闭" @confirm="agreementVisible = false" @close="agreementVisible = false" />
<AppDialog :visible="logoutVisible" eyebrow="账号操作" title="确认退出登录?" message="退出后需要重新验证账号;本机保存的密码不会被保留。" confirm-text="确认退出" cancel-text="取消" show-cancel :close-on-mask="false" @confirm="confirmLogout" @cancel="logoutVisible = false" @close="logoutVisible = false" />
<AppToast :visible="toastVisible" message="已退出当前账号" />
</view>
</template>
<script setup>
import { onUnmounted, reactive, ref } from "vue";
import { reactive, ref } from "vue";
import { onBackPress } from "@dcloudio/uni-app";
import AppButton from "@/components/AppButton.vue";
import AppDialog from "@/components/AppDialog.vue";
import AppToast from "@/components/AppToast.vue";
import ModulePageBackground from "@/components/ModulePageBackground.vue";
import PageHeader from "@/components/PageHeader.vue";
import manifest from "@/manifest.json";
import { goRoot, handleBackPress, runBackGuard } from "@/utils/navigation.js";
import { session } from "@/utils/session.js";
const appVersion = "1.0.0";
const appVersion = manifest.versionName;
const agreementItems = [
{ key: "terms", label: "用户协议", note: "了解账号与服务使用规则", copy: "用户协议正文将在正式协议服务接入后展示。当前页面不代表最终法律文本。" },
{ key: "privacy", label: "隐私政策", note: "了解个人资料如何使用与保护", copy: "隐私政策正文将在正式协议服务接入后展示。敏感资料默认按权限与脱敏规则展示。" },
{ key: "version", label: "版本说明", note: `当前版本 ${appVersion}`, copy: `当前安装版本为 ${appVersion}。基础版本采用浅色国风主题。` },
];
const activeAgreement = reactive({ label: "", copy: "" });
const agreementVisible = ref(false); const logoutVisible = ref(false); const toastVisible = ref(false); const loggedOut = ref(false); let timer = null;
const agreementVisible = ref(false);
const logoutVisible = ref(false);
const openAgreement = (item) => { activeAgreement.label = item.label; activeAgreement.copy = item.copy; agreementVisible.value = true; };
const confirmLogout = () => { logoutVisible.value = false; loggedOut.value = true; toastVisible.value = true; timer = setTimeout(() => (toastVisible.value = false), 1800); };
onUnmounted(() => clearTimeout(timer));
const confirmLogout = () => {
session.clear();
logoutVisible.value = false;
return goRoot("A01");
};
const closeActiveDialog = () => {
if (logoutVisible.value) logoutVisible.value = false;
else agreementVisible.value = false;
};
const requestBack = () =>
runBackGuard({
transientOpen: agreementVisible.value || logoutVisible.value,
"close-transient": closeActiveDialog,
});
onBackPress((event) => handleBackPress(event, requestBack));
</script>
<style scoped lang="scss">
+61 -33
View File
@@ -40,7 +40,7 @@
<view class="person-card__copy">
<text class="person-card__name">{{ person.name }}</text>
<text class="person-card__meta"
>{{ person.role }} · {{ person.generation }} </text
>{{ person.relation }} · {{ person.generation }} </text
>
<text class="person-card__hint">查看人物档案</text>
</view>
@@ -48,8 +48,8 @@
<AppButton
class="people-primary-action"
block
label="新建人物"
@click="showCreateNotice"
label="填写人物预览"
@click="createPersonPreview"
/>
</view>
@@ -70,52 +70,55 @@
<view v-else class="people-state-card">
<view class="people-state-card__copy">
<text>{{
peopleState === "empty" ? "还没有人物记录" : "人物录暂不可用"
peopleState === "empty"
? "还没有人物记录"
: peopleState === "invalid"
? "人物录入口无效"
: "人物录暂不可用"
}}</text>
<text>{{
peopleState === "empty"
? "从第一位值得铭记的家人开始建立人物录。"
: peopleState === "invalid"
? "没有找到可访问的成员家谱,页面不会展示其他家谱人物。"
: "请稍后重新进入,已有档案不会受到影响。"
}}</text>
</view>
<AppButton
:type="peopleState === 'error' ? 'secondary' : 'primary'"
:type="peopleState === 'empty' ? 'primary' : 'secondary'"
block
:label="peopleState === 'error' ? '重新查看' : '新建人物'"
@click="peopleState === 'error' ? restoreList() : showCreateNotice()"
:label="peopleState === 'error' ? '重新查看' : peopleState === 'invalid' ? '返回上一页' : '填写人物预览'"
@click="handleStateAction"
/>
</view>
</view>
<AppToast :visible="toastVisible" message="新建人物将在后续功能阶段开放" />
</view>
</template>
<script setup>
import { onLoad } from "@dcloudio/uni-app";
import { computed, onUnmounted, ref } from "vue";
import { computed, ref } from "vue";
import AppButton from "@/components/AppButton.vue";
import AppToast from "@/components/AppToast.vue";
import ModulePageBackground from "@/components/ModulePageBackground.vue";
import PageHeader from "@/components/PageHeader.vue";
import {
getGenealogyFixtureAccess,
listTreeMemberPresentationFixtures,
} from "@/data/mock.js";
import { goBack, openPage } from "@/utils/navigation.js";
const people = [
{ id: 1, name: "汤文正", role: "家谱管理员", generation: 18 },
{ id: 2, name: "汤淑华", role: "家族长辈", generation: 17 },
{ id: 3, name: "汤文清", role: "青年代表", generation: 19 },
];
const genealogyId = ref("");
const people = ref([]);
const peopleState = ref("ready");
const keywordInput = ref("");
const keyword = ref("");
const toastVisible = ref(false);
let toastTimer = null;
const hasValidContext = computed(() => peopleState.value !== "invalid");
const filteredPeople = computed(() => {
const value = keyword.value.trim().toLowerCase();
if (!value) return people;
return people.filter((person) =>
`${person.name} ${person.role}${person.generation}${person.generation}`
if (!value) return people.value;
return people.value.filter((person) =>
`${person.name} ${person.relation} ${person.branch || ""} ${person.generationName || ""}${person.generation}${person.generation}`
.toLowerCase()
.includes(value),
);
@@ -133,24 +136,49 @@ const clearSearch = () => {
keyword.value = "";
};
const restoreList = () => {
peopleState.value = "ready";
people.value = listTreeMemberPresentationFixtures(genealogyId.value);
peopleState.value = people.value.length ? "ready" : "empty";
};
const openPerson = (person) =>
uni.navigateTo({
url: `/pages/records/r02-person-detail?personId=${person.id}`,
});
const showCreateNotice = () => {
uni.navigateTo({ url: "/pages/records/r02-person-detail?mode=create" });
hasValidContext.value
? openPage(
"R02",
{
genealogyId: genealogyId.value,
mode: "view",
personId: String(person.id),
},
"R01",
)
: Promise.resolve(false);
const createPersonPreview = () =>
hasValidContext.value
? openPage(
"R02",
{ genealogyId: genealogyId.value, mode: "create" },
"R01",
)
: Promise.resolve(false);
const handleStateAction = () => {
if (peopleState.value === "invalid") return goBack();
if (peopleState.value === "error") return restoreList();
return createPersonPreview();
};
onLoad((query) => {
genealogyId.value = String(query.genealogyId || "");
const access = getGenealogyFixtureAccess(genealogyId.value);
if (!["owner", "member"].includes(access.accessRole)) {
people.value = [];
peopleState.value = "invalid";
return;
}
people.value = listTreeMemberPresentationFixtures(genealogyId.value);
peopleState.value = ["empty", "error"].includes(query.state)
? query.state
: "ready";
});
onUnmounted(() => {
if (toastTimer) clearTimeout(toastTimer);
: people.value.length
? "ready"
: "empty";
});
</script>
+251 -61
View File
@@ -1,33 +1,39 @@
<!-- 页面编号R-02用途人物录详情同页编辑与受控状态 -->
<!-- 页面编号R-02用途人物录详情与不写库的人物资料预览 -->
<template>
<view class="person-detail-page" :class="`person-detail-state--${personState}`">
<ModulePageBackground module="records" />
<view class="person-detail-header"><PageHeader :title="personState === 'edit' ? (isCreateMode ? '新建人物' : '编辑人物') : '人物详情'" /></view>
<view class="person-detail-header">
<PageHeader
:title="personState === 'edit' ? (isCreateMode ? '人物预览' : '编辑预览') : '人物详情'"
custom-back
@back="requestBack"
/>
</view>
<view v-if="personState === 'loading'" class="person-detail-loading">
<AppLoading text="正在读取人物档案" description="请稍候,正在整理人物资料。" />
</view>
<view v-else class="person-detail-content">
<template v-if="['detail', 'edit', 'privacy'].includes(personState)">
<template v-if="['detail', 'edit', 'privacy', 'preview'].includes(personState)">
<view class="person-identity-card">
<view class="person-identity-card__copy">
<text class="person-identity-card__name">{{ person.name }}</text>
<text class="person-identity-card__meta">{{ person.role }} · {{ person.generation }} </text>
<text class="person-identity-card__hint">人物录档案</text>
<text class="person-identity-card__name">{{ person.name || "待填写姓名" }}</text>
<text class="person-identity-card__meta">{{ person.relation || "人物预览" }} · {{ person.generation || "—" }} </text>
<text class="person-identity-card__hint">{{ personState === "preview" ? "本地预览 · 未提交" : "人物录档案" }}</text>
</view>
</view>
</template>
<template v-if="personState === 'detail'">
<view v-for="item in detailSections" :key="item.title" class="person-archive-card">
<view><text>{{ item.title }}</text><text>{{ item.copy }}</text></view>
<view><text>{{ item.title }}</text><text>{{ item.copy || "未填写" }}</text></view>
</view>
<view class="person-related-actions">
<AppButton type="secondary" block label="成长日志" @click="toGrowthJournal" />
<AppButton type="secondary" block label="人生事" @click="toLifeEvents" />
<AppButton type="secondary" block label="人生事(待开放)" @click="toLifeEvents" />
</view>
<view class="person-edit-action" @click="enterEdit"><AppButton block label="编辑人物" /></view>
<view class="person-edit-action" @click="enterEdit"><AppButton block label="制作编辑预览" /></view>
</template>
<template v-else-if="personState === 'edit'">
@@ -41,103 +47,287 @@
<textarea v-model="draft[field.key]" auto-height :placeholder="`请输入${field.label}`" />
</view>
<view class="person-edit-actions">
<view class="person-save-action" @click="savePerson"><AppButton block label="保存人物" /></view>
<view class="person-cancel-action" @click="cancelEdit"><AppButton type="secondary" block label="取消编辑" /></view>
<view class="person-save-action" @click="savePerson"><AppButton block label="生成本地预览" /></view>
<view class="person-cancel-action" @click="cancelEdit"><AppButton type="secondary" block label="取消填写" /></view>
</view>
</template>
<view v-else-if="personState === 'preview'" class="person-state-card">
<view><text>本地预览尚未提交服务器</text><text>这份人物资料只存在于当前页面不会新增覆盖或刷新人物录</text></view>
<view class="person-state-action"><AppButton block label="返回人物录" @click="returnToPeople" /></view>
</view>
<view v-else-if="personState === 'privacy'" class="person-state-card">
<view><text>部分资料未公开</text><text>人物小传与家族印记受隐私设置保护当前只展示公开身份</text></view>
<view class="person-state-action" @click="returnToPeople"><AppButton block label="返回人物录" /></view>
<view><text>部分资料未公开</text><text>人物小传与档案备注受隐私设置保护当前只展示公开身份</text></view>
<view class="person-state-action"><AppButton block label="返回人物录" @click="returnToPeople" /></view>
</view>
<view v-else class="person-state-card">
<view>
<text>{{ personState === 'expired' ? '人物档案已失效' : '人物档案暂不可用' }}</text>
<text>{{ personState === 'expired' ? '这份人物资料已无法查看,请返回人物录选择其他档案。' : '请稍后重新查看,已有资料不会受到影响。' }}</text>
</view>
<view class="person-state-action" @click="personState === 'expired' ? returnToPeople() : restoreDetail()">
<AppButton :type="personState === 'error' ? 'secondary' : 'primary'" block :label="personState === 'expired' ? '返回人物录' : '重新查看'" />
<text>{{ personState === 'expired' ? '这份人物资料不存在或不属于当前家谱。' : '请返回人物录重新选择,页面不会回退到其他人物。' }}</text>
</view>
<view class="person-state-action"><AppButton type="secondary" block label="返回人物录" @click="returnToPeople" /></view>
</view>
</view>
<AppToast :visible="toastVisible" message="人物资料已保存" />
<AppToast :visible="toastVisible" message="已生成本地预览,尚未保存" />
<AppDialog
:visible="discardVisible"
:close-on-mask="false"
eyebrow="放弃确认"
title="放弃当前填写?"
message="尚未提交的内容将从当前页面清除。"
confirm-text="确认放弃"
cancel-text="继续填写"
show-cancel
@confirm="confirmDiscard"
@cancel="cancelDiscard"
/>
</view>
</template>
<script setup>
import { computed, onUnmounted, reactive, ref } from "vue";
import { onLoad } from "@dcloudio/uni-app";
import { onBackPress, onLoad } from "@dcloudio/uni-app";
import AppButton from "@/components/AppButton.vue";
import AppDialog from "@/components/AppDialog.vue";
import AppLoading from "@/components/AppLoading.vue";
import AppToast from "@/components/AppToast.vue";
import ModulePageBackground from "@/components/ModulePageBackground.vue";
import PageHeader from "@/components/PageHeader.vue";
import {
findTreeMemberPresentationFixture,
getGenealogyFixtureAccess,
} from "@/data/mock.js";
import { createDiscardConfirmation } from "@/utils/discard-confirmation.js";
import {
goBack,
handleBackPress,
openPage,
returnTo,
runBackGuard,
} from "@/utils/navigation.js";
const people = [
{ id: "1", name: "汤文正", role: "家谱管理员", generation: "18", biography: "勤于修谱,常年整理族中旧照片与口述资料。", legacy: "参与维护汤氏家谱与家风家训。" },
{ id: "2", name: "汤淑华", role: "家族长辈", generation: "17", biography: "熟悉家族往事,愿意为后辈讲述旧时记忆。", legacy: "长期参与家族节庆与敬老活动。" },
{ id: "3", name: "汤文清", role: "青年代表", generation: "19", biography: "协助整理电子家谱和家族影像资料。", legacy: "推动年轻成员共同参与家谱维护。" },
];
const person = reactive({ ...people[0] });
const draft = reactive({ name: "", role: "", generation: "", biography: "", legacy: "" });
const errors = reactive({ name: "", role: "", generation: "" });
const genealogyId = ref("");
const personId = ref("");
const routeMode = ref("");
const personState = ref("loading");
const isCreateMode = ref(false);
const person = reactive({
id: "",
name: "",
relation: "",
generationName: "",
generation: "",
biography: "",
remark: "",
status: "",
});
const draft = reactive({
name: "",
generationName: "",
generation: "",
biography: "",
remark: "",
});
const errors = reactive({ name: "", generation: "" });
const baseline = ref("");
const toastVisible = ref(false);
const discardVisible = ref(false);
let toastTimer = null;
const shortFields = [{ key: "name", label: "姓名" }, { key: "role", label: "身份" }, { key: "generation", label: "世代" }];
const longFields = [{ key: "biography", label: "人物小传" }, { key: "legacy", label: "家族印记" }];
const detailSections = computed(() => [{ title: "人物小传", copy: person.biography }, { title: "家族印记", copy: person.legacy }]);
const copyToDraft = () => Object.assign(draft, { name: person.name, role: person.role, generation: person.generation, biography: person.biography, legacy: person.legacy });
const clearErrors = () => Object.assign(errors, { name: "", role: "", generation: "" });
const enterEdit = () => { copyToDraft(); clearErrors(); personState.value = "edit"; };
const cancelEdit = () => { copyToDraft(); clearErrors(); personState.value = "detail"; };
const savePerson = () => {
const isCreateMode = computed(() => routeMode.value === "create");
const formSnapshot = computed(() => JSON.stringify({ ...draft }));
const isDirty = computed(() =>
personState.value === "preview" ||
(personState.value === "edit" && formSnapshot.value !== baseline.value),
);
const shortFields = [
{ key: "name", label: "姓名" },
{ key: "generationName", label: "字辈" },
{ key: "generation", label: "世代" },
];
const longFields = [
{ key: "biography", label: "人物小传" },
{ key: "remark", label: "档案备注" },
];
const detailSections = computed(() => [
{ title: "字辈", copy: person.generationName },
{ title: "人物小传", copy: person.biography },
{ title: "档案备注", copy: person.remark },
]);
const copyToDraft = () => {
Object.assign(draft, {
name: person.name,
generationName: person.generationName,
generation: String(person.generation || ""),
biography: person.biography,
remark: person.remark,
});
baseline.value = formSnapshot.value;
};
const clearErrors = () => Object.assign(errors, { name: "", generation: "" });
const enterEdit = () => {
if (personState.value !== "detail" || !person.id) return false;
copyToDraft();
clearErrors();
if (!String(draft.name).trim()) errors.name = "请填写姓名";
if (!String(draft.role).trim()) errors.role = "请填写身份";
if (!String(draft.generation).trim()) errors.generation = "请填写世代";
if (errors.name || errors.role || errors.generation) return;
Object.assign(person, { id: person.id || "new", ...draft, name: draft.name.trim(), role: draft.role.trim(), generation: String(draft.generation).trim() });
isCreateMode.value = false;
personState.value = "detail";
personState.value = "edit";
return true;
};
const showPreviewToast = () => {
toastVisible.value = true;
if (toastTimer) clearTimeout(toastTimer);
toastTimer = setTimeout(() => { toastVisible.value = false; toastTimer = null; }, 1800);
toastTimer = setTimeout(() => {
toastVisible.value = false;
toastTimer = null;
}, 1800);
};
const returnToPeople = () => uni.reLaunch({ url: "/pages/records/r01-people-list" });
const restoreDetail = () => { personState.value = "detail"; };
const validatePerson = () => {
errors.name = draft.name.trim() ? "" : "请填写姓名";
const generationInput = draft.generation.trim();
const generation = Number(generationInput);
errors.generation = !generationInput
? ""
: Number.isInteger(generation) && generation > 0
? ""
: "世代必须是正整数";
return !errors.name && !errors.generation;
};
const savePerson = () => {
clearErrors();
if (!validatePerson()) return false;
const localPersonPreview = {
name: draft.name.trim(),
generationName: draft.generationName.trim(),
generation: draft.generation.trim()
? String(Number(draft.generation))
: "",
biography: draft.biography.trim(),
remark: draft.remark.trim(),
};
Object.assign(person, localPersonPreview);
personState.value = "preview";
showPreviewToast();
return true;
};
const discardConfirmation = createDiscardConfirmation(
(visible) => { discardVisible.value = visible; },
);
const confirmDiscard = discardConfirmation.confirm;
const cancelDiscard = discardConfirmation.cancel;
const requestDiscardConfirmation = discardConfirmation.request;
const cancelEdit = async () => {
const confirmed = isDirty.value ? await requestDiscardConfirmation() : true;
if (!confirmed) return false;
if (isCreateMode.value) return goBack();
copyToDraft();
clearErrors();
personState.value = "detail";
return true;
};
const requestBack = () => {
if (discardVisible.value) {
cancelDiscard();
return Promise.resolve(true);
}
if (personState.value === "edit" && !isCreateMode.value) return cancelEdit();
return runBackGuard({
dirty: isDirty.value,
"confirm-discard": requestDiscardConfirmation,
});
};
const returnToPeople = () =>
genealogyId.value
? returnTo("R01", { genealogyId: genealogyId.value })
: goBack();
const toGrowthJournal = () =>
uni.navigateTo({
url: `/pages/records/r08-growth-journal?personId=${person.id}`,
});
person.id
? openPage(
"R08",
{ genealogyId: genealogyId.value, personId: personId.value },
"R02",
)
: Promise.resolve(false);
const toLifeEvents = () =>
uni.navigateTo({
url: `/pages/records/r09-life-events?personId=${person.id}`,
});
person.id
? openPage(
"R09",
{ genealogyId: genealogyId.value, personId: personId.value },
"R02",
)
: Promise.resolve(false);
onLoad((query) => {
isCreateMode.value = query.mode === "create";
if (isCreateMode.value) {
genealogyId.value = String(query.genealogyId || "");
personId.value = String(query.personId || "");
routeMode.value = String(query.mode || "");
if (query.state === "loading") return;
const access = getGenealogyFixtureAccess(genealogyId.value);
const hasValidGenealogy = ["owner", "member"].includes(access.accessRole);
const isCreateContract = routeMode.value === "create" && !personId.value;
const isViewContract = routeMode.value === "view" && Boolean(personId.value);
if (!hasValidGenealogy || (!isCreateContract && !isViewContract)) {
personState.value = "error";
return;
}
if (isCreateContract) {
Object.assign(person, {
id: "",
name: "",
role: "",
relation: "人物预览",
generationName: "",
generation: "",
biography: "",
legacy: "",
remark: "",
status: "",
});
copyToDraft();
personState.value = "edit";
return;
}
const selected = people.find((item) => item.id === String(query.personId || ""));
if (selected) Object.assign(person, selected);
const selected = findTreeMemberPresentationFixture(genealogyId.value, personId.value);
if (!selected) {
personState.value = "expired";
return;
}
Object.assign(person, {
id: selected.id,
name: selected.name,
relation: selected.relation,
generation: String(selected.generation),
status: selected.status,
});
if (["privacy", "forbidden"].includes(selected.status)) {
Object.assign(person, {
generationName: "",
biography: "",
remark: "",
});
personState.value = "privacy";
return;
}
Object.assign(person, {
generationName: selected.generationName || "",
biography: selected.summary || "",
remark: selected.note || "",
});
copyToDraft();
const requested = ["loading", "privacy", "expired", "error", "edit"].includes(query.state) ? query.state : "detail";
personState.value = selected ? requested : "error";
personState.value = ["expired", "error"].includes(query.state)
? query.state
: "detail";
});
onBackPress((event) => handleBackPress(event, requestBack));
onUnmounted(() => {
if (toastTimer) clearTimeout(toastTimer);
discardConfirmation.dispose();
});
onUnmounted(() => { if (toastTimer) clearTimeout(toastTimer); });
</script>
<style scoped lang="scss">
+67 -61
View File
@@ -3,7 +3,7 @@
<view class="gift-page" :class="stateClasses">
<ModulePageBackground module="records" />
<view class="page-header">
<PageHeader title="贺礼簿" action="新增" @action="createGift" />
<PageHeader title="贺礼簿" :action="hasValidContext ? '填写预览' : ''" @action="createRelativePreview" />
</view>
<view v-if="giftState === 'loading'" class="page-loading">
<AppLoading
@@ -12,40 +12,42 @@
/>
</view>
<view v-else class="page-content">
<template v-if="giftState === 'ready' && giftBooks.length">
<template v-if="giftState === 'ready' && relativeRecords.length">
<view
v-for="gift in giftBooks"
:key="gift.id"
v-for="record in relativeRecords"
:key="record.relativeId"
class="record-card"
role="button"
:aria-label="`查看${gift.title}`"
@click="openGiftBook(gift)"
:aria-label="`查看${record.eventName}`"
@click="openRelative(record)"
>
<text class="record-card__tag">{{ gift.occasion }}</text>
<text class="record-card__title">{{ gift.title }}</text>
<text class="record-card__tag">{{ record.relationName }}</text>
<text class="record-card__title">{{ record.eventName }}</text>
<text class="record-card__copy">
{{ gift.from }} · {{ gift.date }}
{{ record.relativeName }} · {{ record.eventTime }} · 金额记录{{ record.giftAmount }}
</text>
<text class="record-card__hint">查看并编辑贺礼</text>
<text class="record-card__hint">查看往来记录</text>
</view>
<AppButton block label="新增贺礼" @click="createGift" />
<AppButton block label="填写往来预览" @click="createRelativePreview" />
</template>
<view v-else class="state-card">
<text>
{{ giftState === "error" ? "贺礼簿暂不可用" : "还没有贺礼记录" }}
{{ giftState === "error" ? "贺礼簿暂不可用" : giftState === "invalid" ? "贺礼簿入口无效" : "还没有往来记录" }}
</text>
<text>
{{
giftState === "error"
? "请稍后重新查看,已有记录不会受到影响。"
: giftState === "invalid"
? "没有找到可访问的成员家谱,页面不会展示其他家谱记录。"
: "从第一份家人之间的心意开始记录。"
}}
</text>
<AppButton
:type="giftState === 'error' ? 'secondary' : 'primary'"
:type="giftState === 'empty' ? 'primary' : 'secondary'"
block
:label="giftState === 'error' ? '重新查看' : '新增贺礼'"
@click="giftState === 'error' ? restoreGifts() : createGift()"
:label="giftState === 'error' ? '重新查看' : giftState === 'invalid' ? '返回上一页' : '填写往来预览'"
@click="handleStateAction"
/>
</view>
</view>
@@ -58,59 +60,63 @@ import AppButton from "@/components/AppButton.vue";
import AppLoading from "@/components/AppLoading.vue";
import ModulePageBackground from "@/components/ModulePageBackground.vue";
import PageHeader from "@/components/PageHeader.vue";
const baseGifts = [
{
id: "301",
title: "新春贺礼",
occasion: "春节",
from: "汤文正一家",
date: "2024 年 2 月 10 日",
},
{
id: "302",
title: "寿宴礼单",
occasion: "寿辰",
from: "汤淑华",
date: "2024 年 4 月 18 日",
},
{
id: "303",
title: "添丁祝福",
occasion: "新生",
from: "汤文清一家",
date: "2024 年 6 月 2 日",
},
];
const giftBooks = ref([...baseGifts]);
import {
getGenealogyFixtureAccess,
listRelativeRecordFixtures,
} from "@/data/mock.js";
import { goBack, openPage } from "@/utils/navigation.js";
const genealogyId = ref("");
const relativeRecords = ref([]);
const giftState = ref("loading");
const hasValidContext = computed(() => ["ready", "empty"].includes(giftState.value));
const stateClasses = computed(() => ({
"gift-state--loading": giftState.value === "loading",
"gift-state--empty": giftState.value === "empty",
"gift-state--error": giftState.value === "error",
"relative-state--loading": giftState.value === "loading",
"relative-state--empty": giftState.value === "empty",
"relative-state--error": giftState.value === "error",
"relative-state--invalid": giftState.value === "invalid",
}));
onLoad((query) => {
const count = Math.max(
1,
Math.min(Number(query.count) || baseGifts.length, 50),
);
giftBooks.value = Array.from({ length: count }, (_, i) => ({
...baseGifts[i % baseGifts.length],
id: String(301 + i),
title:
count > 3 ? `${baseGifts[i % 3].title}${i + 1}` : baseGifts[i].title,
}));
genealogyId.value = String(query.genealogyId || "");
const access = getGenealogyFixtureAccess(genealogyId.value);
if (!["owner", "member"].includes(access.accessRole)) {
relativeRecords.value = [];
giftState.value = "invalid";
return;
}
relativeRecords.value = listRelativeRecordFixtures(genealogyId.value);
giftState.value = ["loading", "empty", "error"].includes(query.state)
? query.state
: "ready";
: relativeRecords.value.length
? "ready"
: "empty";
});
const openGiftBook = (gift) =>
uni.navigateTo({
url: `/pages/records/r04-gift-editor?mode=view&giftId=${gift.id}`,
});
const createGift = () =>
uni.navigateTo({ url: "/pages/records/r04-gift-editor?mode=create" });
const restoreGifts = () => {
giftState.value = "ready";
const openRelative = (record) =>
openPage(
"R04",
{
genealogyId: genealogyId.value,
mode: "view",
relativeId: String(record.relativeId),
},
"R03",
);
const createRelativePreview = () =>
hasValidContext.value
? openPage(
"R04",
{ genealogyId: genealogyId.value, mode: "create" },
"R03",
)
: Promise.resolve(false);
const restoreRelatives = () => {
relativeRecords.value = listRelativeRecordFixtures(genealogyId.value);
giftState.value = relativeRecords.value.length ? "ready" : "empty";
};
const handleStateAction = () => {
if (giftState.value === "invalid") return goBack();
if (giftState.value === "error") return restoreRelatives();
return createRelativePreview();
};
</script>
<style scoped lang="scss">
+199 -106
View File
@@ -1,4 +1,4 @@
<!-- 页面编号R-04用途贺礼查看新增编辑保存与删除确认 -->
<!-- 页面编号R-04用途人情往来详情与不写库的新增编辑预览 -->
<template>
<view class="gift-editor-page" :class="editorClasses">
<ModulePageBackground module="records" />
@@ -6,165 +6,258 @@
<PageHeader
:title="
mode === 'create'
? '新增贺礼'
? '往来预览'
: mode === 'view'
? '贺礼详情'
: '编辑贺礼'
? '往来详情'
: '编辑预览'
"
:action="mode === 'view' ? '编辑' : ''"
@action="mode = 'edit'"
:action="mode === 'view' && editorState === 'ready' ? '制作预览' : ''"
custom-back
@back="requestBack"
@action="enterEdit"
/>
</view>
<view v-if="editorState === 'loading'" class="page-loading">
<AppLoading
text="正在读取贺礼"
description="请稍候,正在整理这份礼仪记录。"
text="正在读取往来记录"
description="请稍候,正在核对当前家谱与记录身份。"
/>
</view>
<view v-else class="page-content">
<view v-if="editorState === 'success'" class="state-card">
<text>贺礼已保存</text>
<text>这份心意已加入家族贺礼簿</text>
<AppButton block label="返回贺礼簿" @click="backToGifts" />
<view v-if="editorState === 'preview'" class="state-card">
<text>本地预览尚未提交服务器</text>
<text>这份往来内容只存在于当前页面不会插入修改或删除正式记录</text>
<view v-for="item in previewRows" :key="item.label">
<text>{{ item.label }}</text><text>{{ item.value }}</text>
</view>
<AppButton block label="返回贺礼簿" @click="returnToRelatives" />
</view>
<view v-else-if="mode === 'view'" class="detail-card">
<text>{{ giftForm.title }}</text>
<view v-else-if="mode === 'view' && editorState === 'ready'" class="detail-card">
<text>{{ relativeForm.eventName }}</text>
<view v-for="item in detailRows" :key="item.label">
<text>{{ item.label }}</text>
<text>{{ item.value }}</text>
</view>
<AppButton block label="编辑贺礼" @click="mode = 'edit'" />
<AppButton
type="secondary"
block
label="删除记录"
@click="confirmDelete"
/>
<AppButton block label="制作编辑预览" @click="enterEdit" />
<AppButton type="secondary" block disabled label="删除暂未开放" />
</view>
<view v-else class="form-card">
<view v-else-if="editorState === 'ready'" class="form-card">
<text>
{{ mode === "create" ? "记录一份家人心意" : "修改贺礼信息" }}
{{ mode === "create" ? "填写一份人情往来预览" : "调整往来记录预览" }}
</text>
<view v-for="field in fields" :key="field.key" class="field-row">
<text>{{ field.label }}</text>
<input
v-model="giftForm[field.key]"
v-model="relativeForm[field.key]"
:type="field.key === 'giftAmount' ? 'digit' : 'text'"
:placeholder="`请输入${field.label}`"
/>
<text v-if="giftErrors[field.key]">{{ giftErrors[field.key] }}</text>
<text v-if="relativeErrors[field.key]">{{ relativeErrors[field.key] }}</text>
</view>
<text v-if="editorState === 'error'" class="save-error">
保存失败请检查内容后重试
</text>
<AppButton
block
:disabled="editorState === 'saving'"
:label="editorState === 'saving' ? '正在保存' : '保存贺礼'"
@click="saveGift"
/>
<AppButton
v-if="mode === 'edit'"
type="secondary"
block
label="删除记录"
@click="confirmDelete"
:disabled="isSubmitting"
:label="isSubmitting ? '正在生成预览' : '生成本地预览'"
@click="saveRelative"
/>
<AppButton type="secondary" block label="取消填写" @click="requestBack" />
</view>
<view v-else class="state-card">
<text>往来记录不可用</text>
<text>记录不存在缺少身份或不属于当前家谱页面不会回退到其他记录</text>
<AppButton type="secondary" block label="返回贺礼簿" @click="returnToRelatives" />
</view>
</view>
<AppDialog
:visible="deleteVisible"
eyebrow="删除确认"
title="删除这份贺礼?"
message="删除后将返回贺礼簿,本地演示记录不会继续显示。"
confirm-text="确认删除"
cancel-text="保留记录"
:visible="discardVisible"
:close-on-mask="false"
eyebrow="放弃确认"
title="放弃当前填写?"
message="尚未提交的预览内容将从当前页面清除。"
confirm-text="确认放弃"
cancel-text="继续填写"
show-cancel
@confirm="deleteGift"
@cancel="deleteVisible = false"
@confirm="confirmDiscard"
@cancel="cancelDiscard"
/>
</view>
</template>
<script setup>
import { computed, onUnmounted, reactive, ref } from "vue";
import { onLoad } from "@dcloudio/uni-app";
import { onBackPress, onLoad } from "@dcloudio/uni-app";
import AppButton from "@/components/AppButton.vue";
import AppDialog from "@/components/AppDialog.vue";
import AppLoading from "@/components/AppLoading.vue";
import ModulePageBackground from "@/components/ModulePageBackground.vue";
import PageHeader from "@/components/PageHeader.vue";
const records = [
{
id: "301",
title: "新春贺礼",
from: "汤文正一家",
date: "2024-02-10",
note: "新春团拜时赠予长辈的心意",
},
{
id: "302",
title: "寿宴礼单",
from: "汤淑华",
date: "2024-04-18",
note: "汤老先生八十寿辰",
},
];
const giftId = ref("");
const mode = ref("create");
const editorState = ref("ready");
const deleteVisible = ref(false);
const forceSaveFailure = ref(false);
const giftForm = reactive({ title: "", from: "", date: "", note: "" });
const giftErrors = reactive({ title: "", from: "", date: "" });
import {
findRelativeRecordFixture,
getGenealogyFixtureAccess,
} from "@/data/mock.js";
import { createDiscardConfirmation } from "@/utils/discard-confirmation.js";
import {
goBack,
handleBackPress,
returnTo,
runBackGuard,
} from "@/utils/navigation.js";
const genealogyId = ref("");
const relativeId = ref("");
const mode = ref("");
const editorState = ref("loading");
const isSubmitting = ref(false);
const discardVisible = ref(false);
const localRelativePreview = ref(null);
const relativeForm = reactive({
relativeName: "",
relationName: "",
eventName: "",
eventTime: "",
giftAmount: "",
recordContent: "",
});
const relativeErrors = reactive({
relativeName: "",
relationName: "",
eventName: "",
eventTime: "",
giftAmount: "",
recordContent: "",
});
const fields = [
{ key: "title", label: "贺礼名称" },
{ key: "from", label: "赠送人" },
{ key: "date", label: "日期" },
{ key: "note", label: "备注" },
{ key: "relativeName", label: "亲友姓名" },
{ key: "relationName", label: "关系称谓" },
{ key: "eventName", label: "礼仪事项" },
{ key: "eventTime", label: "事项日期" },
{ key: "giftAmount", label: "礼金金额" },
{ key: "recordContent", label: "往来备注" },
];
let saveTimer = null;
const baseline = ref("");
let submitTimer = null;
const editorClasses = computed(() => ({
"gift-editor-state--saving": editorState.value === "saving",
"gift-editor-state--error": editorState.value === "error",
"relative-editor-state--preview": editorState.value === "preview",
"relative-editor-state--invalid": editorState.value === "invalid",
}));
const formSnapshot = computed(() => JSON.stringify({ ...relativeForm }));
const isDirty = computed(() =>
editorState.value === "preview" ||
(["create", "edit"].includes(mode.value) && formSnapshot.value !== baseline.value),
);
const displayValue = (value) =>
value === "" || value === null || value === undefined ? "未填写" : String(value);
const detailRows = computed(() =>
fields
.slice(1)
.map((f) => ({ label: f.label, value: giftForm[f.key] || "未填写" })),
.map((field) => ({ label: field.label, value: displayValue(relativeForm[field.key]) })),
);
const previewRows = computed(() =>
fields.map((field) => ({
label: field.label,
value: displayValue(localRelativePreview.value?.[field.key]),
})),
);
const copyRecordToForm = (record) => {
Object.assign(relativeForm, {
relativeName: record.relativeName,
relationName: record.relationName,
eventName: record.eventName,
eventTime: record.eventTime,
giftAmount: String(record.giftAmount ?? ""),
recordContent: record.recordContent,
});
baseline.value = formSnapshot.value;
};
onLoad((query) => {
giftId.value = String(query.giftId || "");
mode.value = ["view", "edit"].includes(query.mode) ? query.mode : "create";
forceSaveFailure.value = query.saveResult === "error";
const selected = records.find((x) => x.id === giftId.value);
if (selected) Object.assign(giftForm, selected);
else if (mode.value !== "create") editorState.value = "error";
if (query.state === "loading") editorState.value = "loading";
genealogyId.value = String(query.genealogyId || "");
relativeId.value = String(query.relativeId || "");
mode.value = String(query.mode || "");
if (query.state === "loading") return;
const access = getGenealogyFixtureAccess(genealogyId.value);
const hasValidGenealogy = ["owner", "member"].includes(access.accessRole);
const isCreateContract = mode.value === "create" && !relativeId.value;
const isEntityContract = ["view", "edit"].includes(mode.value) && Boolean(relativeId.value);
if (!hasValidGenealogy || (!isCreateContract && !isEntityContract)) {
editorState.value = "invalid";
return;
}
if (isCreateContract) {
baseline.value = formSnapshot.value;
editorState.value = "ready";
return;
}
const selected = findRelativeRecordFixture(genealogyId.value, relativeId.value);
if (!selected) {
editorState.value = "invalid";
return;
}
copyRecordToForm(selected);
editorState.value = "ready";
});
const validateGift = () => {
giftErrors.title = giftForm.title.trim() ? "" : "请填写贺礼名称";
giftErrors.from = giftForm.from.trim() ? "" : "请填写赠送人";
giftErrors.date = giftForm.date.trim() ? "" : "请填写日期";
return !giftErrors.title && !giftErrors.from && !giftErrors.date;
const enterEdit = () => {
if (mode.value !== "view" || editorState.value !== "ready") return false;
mode.value = "edit";
baseline.value = formSnapshot.value;
return true;
};
const saveGift = () => {
if (editorState.value === "saving" || !validateGift()) return;
editorState.value = "saving";
saveTimer = setTimeout(() => {
editorState.value = forceSaveFailure.value ? "error" : "success";
forceSaveFailure.value = false;
}, 320);
const validateRelative = () => {
relativeErrors.relativeName = relativeForm.relativeName.trim() ? "" : "请填写亲友姓名";
relativeErrors.relationName = "";
relativeErrors.eventName = "";
relativeErrors.eventTime = "";
relativeErrors.recordContent = "";
const amountInput = relativeForm.giftAmount.trim();
relativeErrors.giftAmount =
!amountInput || Number.isFinite(Number(amountInput))
? ""
: "礼金金额必须是数字";
return !relativeErrors.relativeName && !relativeErrors.giftAmount;
};
const confirmDelete = () => {
deleteVisible.value = true;
const saveRelative = () => {
if (isSubmitting.value || !validateRelative()) return false;
isSubmitting.value = true;
const snapshot = Object.freeze({
relativeName: relativeForm.relativeName.trim(),
relationName: relativeForm.relationName.trim(),
eventName: relativeForm.eventName.trim(),
eventTime: relativeForm.eventTime.trim(),
giftAmount: relativeForm.giftAmount.trim()
? Number(relativeForm.giftAmount)
: null,
recordContent: relativeForm.recordContent.trim(),
});
const timer = setTimeout(() => {
if (submitTimer !== timer) return;
localRelativePreview.value = snapshot;
editorState.value = "preview";
isSubmitting.value = false;
submitTimer = null;
}, 240);
submitTimer = timer;
return true;
};
const deleteGift = () => {
deleteVisible.value = false;
backToGifts();
};
const backToGifts = () =>
uni.redirectTo({ url: "/pages/records/r03-gift-list" });
const discardConfirmation = createDiscardConfirmation(
(visible) => { discardVisible.value = visible; },
);
const confirmDiscard = discardConfirmation.confirm;
const cancelDiscard = discardConfirmation.cancel;
const requestBack = () =>
runBackGuard({
transientOpen: discardVisible.value,
dirty: isDirty.value,
submitting: isSubmitting.value,
"close-transient": cancelDiscard,
"block-submitting": () => true,
"confirm-discard": discardConfirmation.request,
});
const returnToRelatives = () =>
genealogyId.value
? returnTo("R03", { genealogyId: genealogyId.value })
: goBack();
onBackPress((event) => handleBackPress(event, requestBack));
onUnmounted(() => {
if (saveTimer) clearTimeout(saveTimer);
if (submitTimer) clearTimeout(submitTimer);
discardConfirmation.dispose();
});
</script>
<style scoped lang="scss">
+104 -70
View File
@@ -1,47 +1,48 @@
<!-- 页面编号R-05用途礼仪活动列表状态与创建入口 -->
<!-- 页面编号R-05用途当前家谱的礼仪活动列表与本地创建预览入口 -->
<template>
<view class="ritual-page" :class="stateClasses">
<ModulePageBackground module="records" />
<view class="page-header">
<PageHeader title="礼仪活动" action="新建" @action="createRitual" />
<PageHeader
title="礼仪活动"
:action="hasValidContext ? '填写预览' : ''"
@action="createCeremonyPreview"
/>
</view>
<view v-if="ritualState === 'loading'" class="page-loading">
<view v-if="ceremonyState === 'loading'" class="page-loading">
<AppLoading
text="正在整理礼仪活动"
description="请稍候,正在读取时间与地点。"
description="请稍候,正在核对当前家谱的活动记录。"
/>
</view>
<view v-else class="page-content">
<template v-if="ritualState === 'ready' && rituals.length">
<template v-if="ceremonyState === 'ready' && ceremonies.length">
<view
v-for="ritual in rituals"
:key="ritual.id"
v-for="ceremony in ceremonies"
:key="ceremony.ceremonyId"
class="record-card"
@click="openRitual(ritual)"
role="button"
:aria-label="`查看${ceremony.ceremonyTitle}`"
@click="openCeremony(ceremony)"
>
<text>{{ ritual.status }}</text>
<text>{{ ritual.name }}</text>
<text>{{ ritual.date }} · {{ ritual.place }}</text>
<text>查看活动详情</text>
<text>{{ ceremony.ceremonyType }}</text>
<text>{{ ceremony.ceremonyTitle }}</text>
<text>
{{ ceremony.ceremonyTime }} ·
{{ ceremony.location || "地点待定" }}
</text>
<text>查看活动与受邀信息</text>
</view>
<AppButton block label="新建礼仪" @click="createRitual" />
<AppButton block label="填写礼仪预览" @click="createCeremonyPreview" />
</template>
<view v-else class="state-card">
<text>
{{ ritualState === "error" ? "礼仪活动暂不可用" : "还没有礼仪活动" }}
</text>
<text>
{{
ritualState === "error"
? "请稍后重新查看,已有活动不会受到影响。"
: "从一次祭祖、家宴或团拜开始安排。"
}}
</text>
<text>{{ stateCopy.title }}</text>
<text>{{ stateCopy.copy }}</text>
<AppButton
:type="ritualState === 'error' ? 'secondary' : 'primary'"
:type="ceremonyState === 'empty' ? 'primary' : 'secondary'"
block
:label="ritualState === 'error' ? '重新查看' : '新建礼仪'"
@click="ritualState === 'error' ? restoreRituals() : createRitual()"
:label="stateCopy.action"
@click="handleStateAction"
/>
</view>
</view>
@@ -54,53 +55,86 @@ import AppButton from "@/components/AppButton.vue";
import AppLoading from "@/components/AppLoading.vue";
import ModulePageBackground from "@/components/ModulePageBackground.vue";
import PageHeader from "@/components/PageHeader.vue";
const base = [
{
id: "501",
name: "清明祭祖",
status: "报名中",
date: "2025 年 4 月 4 日",
place: "汤氏宗祠",
},
{
id: "502",
name: "中秋家宴",
status: "筹备中",
date: "2025 年 9 月 17 日",
place: "祖居院落",
},
{
id: "503",
name: "新春团拜",
status: "已结束",
date: "2025 年 1 月 29 日",
place: "家族礼堂",
},
];
const rituals = ref([...base]);
const ritualState = ref("loading");
import {
getGenealogyFixtureAccess,
listCeremonyFixtures,
} from "@/data/mock.js";
import { goBack, openPage } from "@/utils/navigation.js";
const genealogyId = ref("");
const ceremonies = ref([]);
const ceremonyState = ref("loading");
const hasValidContext = computed(() =>
["ready", "empty"].includes(ceremonyState.value),
);
const stateClasses = computed(() => ({
"ritual-state--loading": ritualState.value === "loading",
"ritual-state--empty": ritualState.value === "empty",
"ritual-state--error": ritualState.value === "error",
"ceremony-state--loading": ceremonyState.value === "loading",
"ceremony-state--empty": ceremonyState.value === "empty",
"ceremony-state--error": ceremonyState.value === "error",
"ceremony-state--invalid": ceremonyState.value === "invalid",
}));
onLoad((q) => {
const n = Math.max(1, Math.min(Number(q.count) || base.length, 50));
rituals.value = Array.from({ length: n }, (_, i) => ({
...base[i % 3],
id: String(501 + i),
name: n > 3 ? `${base[i % 3].name}${i + 1}` : base[i].name,
}));
ritualState.value = ["loading", "empty", "error"].includes(q.state)
? q.state
: "ready";
const stateCopy = computed(() => ({
error: {
title: "礼仪活动暂不可用",
copy: "请稍后重新查看,已有活动不会受到影响。",
action: "重新查看",
},
invalid: {
title: "礼仪活动入口无效",
copy: "没有找到可访问的成员家谱,页面不会展示其他家谱活动。",
action: "返回上一页",
},
empty: {
title: "还没有礼仪活动",
copy: "可以先填写一份本地预览;正式创建仍需等待线上写接口启用。",
action: "填写礼仪预览",
},
})[ceremonyState.value] || {
title: "礼仪活动暂不可用",
copy: "请返回上一页重新进入。",
action: "返回上一页",
});
const openRitual = (r) =>
uni.navigateTo({ url: `/pages/records/r06-ritual-detail?ritualId=${r.id}` });
const createRitual = () =>
uni.navigateTo({ url: "/pages/records/r07-ritual-editor?mode=create" });
const restoreRituals = () => {
ritualState.value = "ready";
onLoad((query) => {
genealogyId.value = String(query.genealogyId || "");
const access = getGenealogyFixtureAccess(genealogyId.value);
if (!["owner", "member"].includes(access.accessRole)) {
ceremonies.value = [];
ceremonyState.value = "invalid";
return;
}
ceremonies.value = listCeremonyFixtures(genealogyId.value);
ceremonyState.value = ["loading", "empty", "error"].includes(query.state)
? query.state
: ceremonies.value.length
? "ready"
: "empty";
});
const openCeremony = (ceremony) =>
openPage(
"R06",
{
genealogyId: genealogyId.value,
ceremonyId: String(ceremony.ceremonyId),
},
"R05",
);
const createCeremonyPreview = () =>
hasValidContext.value
? openPage(
"R07",
{ genealogyId: genealogyId.value, mode: "create" },
"R05",
)
: Promise.resolve(false);
const restoreCeremonies = () => {
ceremonies.value = listCeremonyFixtures(genealogyId.value);
ceremonyState.value = ceremonies.value.length ? "ready" : "empty";
};
const handleStateAction = () => {
if (ceremonyState.value === "invalid") return goBack();
if (ceremonyState.value === "error") return restoreCeremonies();
return createCeremonyPreview();
};
</script>
<style scoped lang="scss">
+103 -74
View File
@@ -1,129 +1,158 @@
<!-- 页面编号R-06用途礼仪详情参与者与受控状态 -->
<!-- 页面编号R-06用途当前家谱礼仪详情受邀信息与受控状态 -->
<template>
<view class="ritual-detail-page" :class="stateClasses">
<ModulePageBackground module="records" />
<view class="page-header">
<PageHeader
title="礼仪详情"
:action="ritualState === 'ready' ? '编辑' : ''"
@action="editRitual"
:action="ceremonyState === 'ready' ? '制作预览' : ''"
custom-back
@back="requestBack"
@action="editCeremony"
/>
</view>
<view v-if="ritualState === 'loading'" class="page-loading">
<view v-if="ceremonyState === 'loading'" class="page-loading">
<AppLoading
text="正在读取礼仪详情"
description="请稍候,正在整理活动与参与信息。"
description="请稍候,正在核对活动身份与受邀信息。"
/>
</view>
<view v-else class="page-content">
<template v-if="ritualState === 'ready'">
<template v-if="ceremonyState === 'ready' && ceremonyDetail">
<view class="detail-card">
<text>{{ ritualDetail.status }}</text>
<text>{{ ritualDetail.name }}</text>
<text>{{ ritualDetail.date }} · {{ ritualDetail.place }}</text>
<text>{{ ritualDetail.description }}</text>
<text>{{ ceremonyDetail.ceremonyType }}</text>
<text>{{ ceremonyDetail.ceremonyTitle }}</text>
<text>
{{ ceremonyDetail.ceremonyTime }} ·
{{ ceremonyDetail.location || "地点待定" }}
</text>
<text>{{ ceremonyDetail.ceremonyDesc || "暂无活动说明" }}</text>
</view>
<view class="participant-card">
<view>
<text>参与家人</text>
<text>{{ participants.length }} </text>
<text>受邀家人</text>
<text>{{ invitees.length }} </text>
</view>
<view v-for="person in participants" :key="person.id">
<text>{{ person.name }}</text>
<text>{{ person.role }}</text>
<view v-for="invitee in invitees" :key="invitee.inviteeUserId">
<text>{{ invitee.displayName }} · {{ invitee.relationName }}</text>
<text>{{ invitee.statusText }}</text>
</view>
<view v-if="!invitees.length"><text>尚无受邀记录</text><text></text></view>
</view>
<AppButton block label="编辑活动" @click="editRitual" />
<AppButton block label="制作编辑预览" @click="editCeremony" />
</template>
<view v-else class="state-card">
<text>{{ stateCopy.title }}</text>
<text>{{ stateCopy.copy }}</text>
<AppButton
:type="ritualState === 'error' ? 'secondary' : 'primary'"
type="secondary"
block
:label="ritualState === 'error' ? '重新查看' : '返回礼仪列表'"
@click="ritualState === 'error' ? restoreRitual() : backToRituals()"
:label="ceremonyState === 'error' ? '重新查看' : '返回礼仪列表'"
@click="ceremonyState === 'error' ? restoreCeremony() : returnToCeremonies()"
/>
</view>
</view>
</view>
</template>
<script setup>
import { computed, reactive, ref } from "vue";
import { computed, ref } from "vue";
import { onLoad } from "@dcloudio/uni-app";
import AppButton from "@/components/AppButton.vue";
import AppLoading from "@/components/AppLoading.vue";
import ModulePageBackground from "@/components/ModulePageBackground.vue";
import PageHeader from "@/components/PageHeader.vue";
const records = [
{
id: "501",
name: "清明祭祖",
status: "报名中",
date: "2025 年 4 月 4 日",
place: "汤氏宗祠",
description: "缅怀先祖,整理祭扫礼序,并由长辈讲述家族往事。",
},
{
id: "502",
name: "中秋家宴",
status: "筹备中",
date: "2025 年 9 月 17 日",
place: "祖居院落",
description: "家人团聚,共叙近况并整理年度家族影像。",
},
];
const ritualDetail = reactive({ ...records[0] });
const ritualId = ref("501");
const ritualState = ref("loading");
const participants = ref([
{ id: 1, name: "汤文正", role: "主理人" },
{ id: 2, name: "汤淑华", role: "家族长辈" },
{ id: 3, name: "汤文清", role: "影像记录" },
]);
import {
findCeremonyFixture,
getGenealogyFixtureAccess,
listTreeMemberPresentationFixtures,
} from "@/data/mock.js";
import { goBack, openPage, returnTo } from "@/utils/navigation.js";
const genealogyId = ref("");
const ceremonyId = ref("");
const ceremonyDetail = ref(null);
const memberOptions = ref([]);
const ceremonyState = ref("loading");
const invitees = computed(() => {
const memberByAppUserId = new Map(
memberOptions.value.map((member) => [String(member.appUserId), member]),
);
return (ceremonyDetail.value?.invitees || []).map((invitation) => {
const member = memberByAppUserId.get(String(invitation.inviteeUserId));
return {
inviteeUserId: String(invitation.inviteeUserId),
displayName: member?.name || "受邀成员信息不可用",
relationName: member?.relation || "未完成同谱成员联接",
statusText: invitation.inviteStatus
? "受邀状态字典待后端确认"
: "受邀状态未提供",
};
});
});
const stateClasses = computed(() => ({
"ritual-state--expired": ritualState.value === "expired",
"ritual-state--privacy": ritualState.value === "privacy",
"ritual-state--error": ritualState.value === "error",
"ceremony-state--expired": ceremonyState.value === "expired",
"ceremony-state--error": ceremonyState.value === "error",
}));
const stateCopy = computed(
() =>
({
expired: {
title: "活动已失效",
copy: "这项礼仪活动已取消或结束归档,请返回列表查看其他活动。",
},
privacy: {
title: "活动信息未公开",
copy: "当前活动只向受邀家人展示,请返回礼仪列表。",
copy: "活动不存在或不属于当前家谱,页面不会回退到其他活动。",
},
error: {
title: "礼仪详情暂不可用",
copy: "请稍后重新查看,已有活动不会受到影响。",
},
})[ritualState.value] || {},
})[ceremonyState.value] || {
title: "礼仪详情暂不可用",
copy: "缺少家谱或活动身份,请返回列表重新选择。",
},
);
onLoad((q) => {
ritualId.value = String(q.ritualId || "501");
const selected = records.find((x) => x.id === ritualId.value);
if (selected) Object.assign(ritualDetail, selected);
ritualState.value = ["loading", "expired", "privacy", "error"].includes(
q.state,
)
? q.state
: selected
? "ready"
: "expired";
onLoad((query) => {
genealogyId.value = String(query.genealogyId || "");
ceremonyId.value = String(query.ceremonyId || "");
if (query.state === "loading") return;
const access = getGenealogyFixtureAccess(genealogyId.value);
if (!["owner", "member"].includes(access.accessRole) || !ceremonyId.value) {
ceremonyState.value = "expired";
return;
}
memberOptions.value = listTreeMemberPresentationFixtures(genealogyId.value);
ceremonyDetail.value = findCeremonyFixture(
genealogyId.value,
ceremonyId.value,
);
if (!ceremonyDetail.value) {
ceremonyState.value = "expired";
return;
}
ceremonyState.value = query.state === "error" ? "error" : "ready";
});
const editRitual = () =>
uni.navigateTo({
url: `/pages/records/r07-ritual-editor?mode=edit&ritualId=${ritualId.value}`,
});
const restoreRitual = () => {
ritualState.value = "ready";
const editCeremony = () =>
ceremonyState.value === "ready" && ceremonyDetail.value
? openPage(
"R07",
{
genealogyId: genealogyId.value,
mode: "edit",
ceremonyId: ceremonyId.value,
},
"R06",
)
: Promise.resolve(false);
const restoreCeremony = () => {
ceremonyDetail.value = findCeremonyFixture(
genealogyId.value,
ceremonyId.value,
);
ceremonyState.value = ceremonyDetail.value ? "ready" : "expired";
};
const backToRituals = () =>
uni.redirectTo({ url: "/pages/records/r05-ritual-list" });
const requestBack = () => goBack();
const returnToCeremonies = () =>
genealogyId.value
? returnTo("R05", { genealogyId: genealogyId.value })
: goBack();
</script>
<style scoped lang="scss">
@use "../../styles/adaptive-frame-profiles.scss" as adaptive;
+176 -82
View File
@@ -1,19 +1,33 @@
<!-- 页面编号R-07用途礼仪创建编辑校验保存与删除确认 -->
<!-- 页面编号R-07用途礼仪创建编辑校验与不写库的本地预览 -->
<template>
<view class="ritual-editor-page" :class="editorClasses">
<ModulePageBackground module="records" />
<view class="page-header">
<PageHeader :title="mode === 'create' ? '新建礼仪' : '编辑礼仪'" />
<PageHeader
:title="mode === 'create' ? '礼仪预览' : '编辑预览'"
custom-back
@back="requestBack"
/>
</view>
<view class="page-content">
<view v-if="editorState === 'success'" class="state-card">
<text>礼仪活动已保存</text>
<text>时间地点与活动说明已整理完成</text>
<AppButton block label="返回礼仪列表" @click="backToRituals" />
<view v-if="editorState === 'loading'" class="page-loading">
<AppLoading
text="正在读取礼仪资料"
description="请稍候,正在核对当前家谱与活动身份。"
/>
</view>
<view v-else class="page-content">
<view v-if="editorState === 'preview'" class="state-card preview-card">
<text>本地预览尚未提交服务器</text>
<text>这份礼仪内容只存在于当前页面不会新增覆盖或删除正式活动</text>
<view v-for="item in previewRows" :key="item.label" class="preview-row">
<text>{{ item.label }}</text>
<text>{{ item.value }}</text>
</view>
<AppButton block :label="returnLabel" @click="returnAfterPreview" />
</view>
<view v-else class="form-card">
<view v-else-if="editorState === 'ready'" class="form-card">
<text>
{{ mode === "create" ? "安排一次家族礼仪" : "修改活动信息" }}
{{ mode === "create" ? "填写一份家族礼仪预览" : "调整活动预览" }}
</text>
<view v-for="field in fields" :key="field.key" class="field-row">
<text>{{ field.label }}</text>
@@ -32,110 +46,190 @@
{{ ritualErrors[field.key] }}
</text>
</view>
<text v-if="editorState === 'error'" class="save-error">
保存失败请保留内容后重试
</text>
<AppButton
block
:disabled="editorState === 'saving'"
:label="editorState === 'saving' ? '正在保存' : '保存活动'"
@click="saveRitual"
label="生成本地预览"
@click="createCeremonyPreview"
/>
<AppButton
v-if="mode === 'edit'"
type="secondary"
block
label="删除活动"
@click="confirmDelete"
label="取消填写"
@click="requestBack"
/>
<AppButton v-if="mode === 'edit'" type="secondary" block disabled label="删除暂未开放" />
</view>
<view v-else class="state-card">
<text>礼仪活动不可用</text>
<text>活动不存在缺少身份或不属于当前家谱页面不会回退到其他活动</text>
<AppButton type="secondary" block label="返回礼仪列表" @click="returnToCeremonies" />
</view>
</view>
<AppDialog
:visible="deleteVisible"
eyebrow="删除确认"
title="删除这项礼仪活动?"
message="删除后将返回礼仪列表。"
confirm-text="确认删除"
cancel-text="保留活动"
:visible="discardVisible"
:close-on-mask="false"
eyebrow="放弃确认"
title="放弃当前填写?"
message="尚未提交的预览内容将从当前页面清除。"
confirm-text="确认放弃"
cancel-text="继续填写"
show-cancel
@confirm="deleteRitual"
@cancel="deleteVisible = false"
@confirm="confirmDiscard"
@cancel="cancelDiscard"
/>
</view>
</template>
<script setup>
import { computed, onUnmounted, reactive, ref } from "vue";
import { onLoad } from "@dcloudio/uni-app";
import { onBackPress, onLoad } from "@dcloudio/uni-app";
import AppButton from "@/components/AppButton.vue";
import AppDialog from "@/components/AppDialog.vue";
import AppLoading from "@/components/AppLoading.vue";
import ModulePageBackground from "@/components/ModulePageBackground.vue";
import PageHeader from "@/components/PageHeader.vue";
const records = [
{
id: "501",
name: "清明祭祖",
date: "2025-04-04",
place: "汤氏宗祠",
description: "缅怀先祖,凝聚家人,共叙家风传承。",
},
];
const ritualId = ref("");
const mode = ref("create");
const editorState = ref("ready");
const deleteVisible = ref(false);
const forceSaveFailure = ref(false);
const ritualForm = reactive({ name: "", date: "", place: "", description: "" });
import {
findCeremonyFixture,
getGenealogyFixtureAccess,
} from "@/data/mock.js";
import { createDiscardConfirmation } from "@/utils/discard-confirmation.js";
import {
goBack,
handleBackPress,
returnTo,
runBackGuard,
} from "@/utils/navigation.js";
const genealogyId = ref("");
const ceremonyId = ref("");
const mode = ref("");
const editorState = ref("loading");
const discardVisible = ref(false);
const localCeremonyPreview = ref(null);
const ceremonyForm = reactive({
ceremonyType: "",
ceremonyTitle: "",
ceremonyTime: "",
location: "",
ceremonyDesc: "",
});
const ritualErrors = reactive({
name: "",
date: "",
place: "",
description: "",
ceremonyType: "",
ceremonyTitle: "",
ceremonyTime: "",
location: "",
ceremonyDesc: "",
});
const fields = [
{ key: "name", label: "活动名称" },
{ key: "date", label: "活动日期" },
{ key: "place", label: "举办地点" },
{ key: "description", label: "活动说明", long: true },
{ key: "ceremonyType", label: "礼仪类型" },
{ key: "ceremonyTitle", label: "活动标题" },
{ key: "ceremonyTime", label: "活动时间" },
{ key: "location", label: "举办地点" },
{ key: "ceremonyDesc", label: "活动说明", long: true },
];
let saveTimer = null;
const baseline = ref("");
const editorClasses = computed(() => ({
"ritual-editor-state--saving": editorState.value === "saving",
"ritual-editor-state--error": editorState.value === "error",
"ceremony-editor-state--preview": editorState.value === "preview",
"ceremony-editor-state--invalid": editorState.value === "invalid",
}));
onLoad((q) => {
ritualId.value = String(q.ritualId || "");
mode.value = q.mode === "edit" ? "edit" : "create";
forceSaveFailure.value = q.saveResult === "error";
const selected = records.find((x) => x.id === ritualId.value);
if (selected) Object.assign(ritualForm, selected);
const formSnapshot = computed(() => JSON.stringify({ ...ceremonyForm }));
const isDirty = computed(() =>
editorState.value === "preview" ||
(["create", "edit"].includes(mode.value) && formSnapshot.value !== baseline.value),
);
const previewRows = computed(() =>
fields.map((field) => ({
label: field.label,
value: localCeremonyPreview.value?.[field.key] || "未填写",
})),
);
const returnLabel = computed(() =>
mode.value === "edit" ? "返回礼仪详情" : "返回礼仪列表",
);
const copyCeremonyToForm = (record) => {
Object.assign(ceremonyForm, {
ceremonyType: record.ceremonyType,
ceremonyTitle: record.ceremonyTitle,
ceremonyTime: record.ceremonyTime,
location: record.location,
ceremonyDesc: record.ceremonyDesc,
});
baseline.value = formSnapshot.value;
};
onLoad((query) => {
genealogyId.value = String(query.genealogyId || "");
ceremonyId.value = String(query.ceremonyId || "");
mode.value = String(query.mode || "");
if (query.state === "loading") return;
const access = getGenealogyFixtureAccess(genealogyId.value);
const hasValidGenealogy = ["owner", "member"].includes(access.accessRole);
const isCreateContract = mode.value === "create" && !ceremonyId.value;
const isEditContract = mode.value === "edit" && Boolean(ceremonyId.value);
if (!hasValidGenealogy || (!isCreateContract && !isEditContract)) {
editorState.value = "invalid";
return;
}
if (isCreateContract) {
baseline.value = formSnapshot.value;
editorState.value = "ready";
return;
}
const selected = findCeremonyFixture(genealogyId.value, ceremonyId.value);
if (!selected) {
editorState.value = "invalid";
return;
}
copyCeremonyToForm(selected);
editorState.value = "ready";
});
const validateRitual = () => {
for (const f of fields)
ritualErrors[f.key] = String(ritualForm[f.key]).trim()
? ""
: `请填写${f.label}`;
return fields.every((f) => !ritualErrors[f.key]);
const validateCeremony = () => {
ritualErrors.ceremonyType = ceremonyForm.ceremonyType.trim()
? ""
: "请填写礼仪类型";
ritualErrors.ceremonyTitle = ceremonyForm.ceremonyTitle.trim()
? ""
: "请填写活动标题";
return fields.every((field) => !ritualErrors[field.key]);
};
const saveRitual = () => {
if (editorState.value === "saving" || !validateRitual()) return;
editorState.value = "saving";
saveTimer = setTimeout(() => {
editorState.value = forceSaveFailure.value ? "error" : "success";
forceSaveFailure.value = false;
}, 320);
const createCeremonyPreview = () => {
if (!validateCeremony()) return false;
// 线上写接口的枚举、成员权限与错误语义尚未形成完整合同,因此这里只生成
// 与表单分离的不可变快照;后续接入真实写接口时由该入口唯一替换。
localCeremonyPreview.value = Object.freeze({
ceremonyType: ceremonyForm.ceremonyType.trim(),
ceremonyTitle: ceremonyForm.ceremonyTitle.trim(),
ceremonyTime: ceremonyForm.ceremonyTime.trim(),
location: ceremonyForm.location.trim(),
ceremonyDesc: ceremonyForm.ceremonyDesc.trim(),
});
editorState.value = "preview";
return true;
};
const confirmDelete = () => {
deleteVisible.value = true;
};
const deleteRitual = () => {
deleteVisible.value = false;
backToRituals();
};
const backToRituals = () =>
uni.redirectTo({ url: "/pages/records/r05-ritual-list" });
const discardConfirmation = createDiscardConfirmation(
(visible) => { discardVisible.value = visible; },
);
const confirmDiscard = discardConfirmation.confirm;
const cancelDiscard = discardConfirmation.cancel;
const requestBack = () =>
runBackGuard({
transientOpen: discardVisible.value,
dirty: isDirty.value,
"close-transient": cancelDiscard,
"confirm-discard": discardConfirmation.request,
});
const returnToCeremonies = () =>
genealogyId.value
? returnTo("R05", { genealogyId: genealogyId.value })
: goBack();
const returnAfterPreview = () =>
mode.value === "edit" && ceremonyId.value
? returnTo("R06", {
genealogyId: genealogyId.value,
ceremonyId: ceremonyId.value,
})
: returnToCeremonies();
onBackPress((event) => handleBackPress(event, requestBack));
onUnmounted(() => {
if (saveTimer) clearTimeout(saveTimer);
discardConfirmation.dispose();
});
</script>
<style scoped lang="scss">
+225 -71
View File
@@ -1,142 +1,277 @@
<!-- 页面编号R-08用途人物成长日志时间轴与同页新增 -->
<!-- 页面编号R-08用途当前家谱人物成长日志与不写库的本地预览 -->
<template>
<view class="timeline-page" :class="stateClasses">
<ModulePageBackground module="records" />
<view class="page-header">
<PageHeader title="成长日志" action="记录" @action="recordGrowth" />
<PageHeader
title="成长日志"
:action="hasValidContext ? '记录预览' : ''"
custom-back
@back="requestBack"
@action="recordGrowth"
/>
</view>
<view v-if="timelineState === 'loading'" class="page-loading">
<AppLoading
text="正在读取成长日志"
:description="`请稍候,正在整理${personName}的成长记录。`"
description="请稍候,正在核对当前家谱与人物身份。"
/>
</view>
<view v-else class="page-content">
<view class="person-lead">
<text>{{ personName }}</text>
<view v-if="memberRecord" class="person-lead">
<text>{{ memberRecord.name }}</text>
<text>成长中的每一个瞬间</text>
</view>
<view v-if="localGrowthPreview" class="preview-card">
<text>本地预览 · 尚未提交</text>
<text>{{ localGrowthPreview.recordTitle }}</text>
<text>{{ localGrowthPreview.recordDate || "日期未填写" }}</text>
<text>{{ localGrowthPreview.recordContent || "内容未填写" }}</text>
</view>
<template v-if="timelineState === 'ready' && growthRecords.length">
<view
v-for="(record, index) in growthRecords"
:key="record.id"
:key="record.recordId"
class="timeline-card"
>
<text> {{ growthRecords.length - index }} </text>
<text>{{ record.title }}</text>
<text>{{ record.date }}</text>
<text>{{ record.description }}</text>
<text>{{ record.recordTitle }}</text>
<text>{{ record.recordDate || "日期未填写" }}</text>
<text>{{ record.recordContent || "内容未填写" }}</text>
</view>
<AppButton block label="记录成长" @click="recordGrowth" />
<AppButton block label="记录成长预览" @click="recordGrowth" />
</template>
<view v-else class="state-card">
<text>
{{
timelineState === "error" ? "成长日志暂不可用" : "还没有成长记录"
}}
</text>
<text>
{{
timelineState === "error"
? "请稍后重新查看,已有记录不会受到影响。"
: "从第一次微笑、入园或毕业开始记录。"
}}
</text>
<text>{{ stateCopy.title }}</text>
<text>{{ stateCopy.copy }}</text>
<AppButton
:type="timelineState === 'error' ? 'secondary' : 'primary'"
:type="timelineState === 'empty' ? 'primary' : 'secondary'"
block
:label="timelineState === 'error' ? '重新查看' : '记录成长'"
@click="
timelineState === 'error'
? (timelineState = 'ready')
: recordGrowth()
"
:label="stateCopy.action"
@click="handleStateAction"
/>
</view>
</view>
<AppDialog
:visible="dialogVisible"
:close-on-mask="false"
eyebrow="成长日志"
title="记录一个成长瞬间"
confirm-text="保存记录"
title="填写一份成长预览"
confirm-text="生成预览"
cancel-text="取消"
show-cancel
@confirm="saveGrowth"
@cancel="dialogVisible = false"
@confirm="createGrowthPreview"
@cancel="requestCloseEditor"
>
<view class="dialog-form">
<input v-model="growthForm.title" placeholder="事件名称" />
<input v-model="growthForm.date" placeholder="日期" />
<input v-model="growthForm.recordTitle" placeholder="记录标题" />
<input v-model="growthForm.recordDate" placeholder="日期(选填)" />
<textarea
v-model="growthForm.description"
v-model="growthForm.recordContent"
auto-height
placeholder="写下当时的故事"
placeholder="写下当时的故事(选填)"
/>
<text v-if="formError">{{ formError }}</text>
</view>
</AppDialog>
<AppToast :visible="toastVisible" message="成长记录已保存" />
<AppDialog
:visible="discardVisible"
:close-on-mask="false"
eyebrow="放弃确认"
title="放弃当前填写?"
message="尚未提交的预览内容将从当前页面清除。"
confirm-text="确认放弃"
cancel-text="继续填写"
show-cancel
@confirm="confirmDiscard"
@cancel="cancelDiscard"
/>
<AppToast :visible="toastVisible" message="已生成本地预览,尚未保存" />
</view>
</template>
<script setup>
import { computed, onUnmounted, reactive, ref } from "vue";
import { onLoad } from "@dcloudio/uni-app";
import { onBackPress, onLoad } from "@dcloudio/uni-app";
import AppButton from "@/components/AppButton.vue";
import AppDialog from "@/components/AppDialog.vue";
import AppLoading from "@/components/AppLoading.vue";
import AppToast from "@/components/AppToast.vue";
import ModulePageBackground from "@/components/ModulePageBackground.vue";
import PageHeader from "@/components/PageHeader.vue";
const personName = ref("汤小满");
const growthRecords = ref([
{
id: 1,
title: "第一次叫爸爸",
date: "2024 年 3 月",
description: "家人共同听见了这声清晰的呼唤。",
},
{
id: 2,
title: "入园第一天",
date: "2024 年 9 月",
description: "背着小书包,勇敢地向家人挥手。",
},
]);
import {
findTreeMemberPresentationFixture,
getGenealogyFixtureAccess,
listGrowthRecordFixtures,
} from "@/data/mock.js";
import { createDiscardConfirmation } from "@/utils/discard-confirmation.js";
import { goBack, handleBackPress, runBackGuard } from "@/utils/navigation.js";
const genealogyId = ref("");
const personId = ref("");
const memberRecord = ref(null);
const growthRecords = ref([]);
const timelineState = ref("loading");
const dialogVisible = ref(false);
const discardVisible = ref(false);
const toastVisible = ref(false);
const formError = ref("");
const growthForm = reactive({ title: "", date: "", description: "" });
const localGrowthPreview = ref(null);
const growthForm = reactive({
recordTitle: "",
recordDate: "",
recordContent: "",
});
const editorBaseline = ref("");
let timer = null;
const stateClasses = computed(() => ({
"timeline-state--loading": timelineState.value === "loading",
"timeline-state--empty": timelineState.value === "empty",
"timeline-state--error": timelineState.value === "error",
"timeline-state--privacy": timelineState.value === "privacy",
"timeline-state--invalid": timelineState.value === "invalid",
}));
onLoad((q) => {
personName.value = String(q.personName || "汤小满");
timelineState.value = ["loading", "empty", "error"].includes(q.state)
? q.state
: "ready";
const hasValidContext = computed(() =>
["ready", "empty"].includes(timelineState.value),
);
const formSnapshot = computed(() => JSON.stringify({ ...growthForm }));
const growthDraftDirty = computed(() =>
dialogVisible.value && formSnapshot.value !== editorBaseline.value,
);
const stateCopy = computed(() => ({
error: {
title: "成长日志暂不可用",
copy: "请稍后重新查看,已有记录不会受到影响。",
action: "重新查看",
},
privacy: {
title: "成长日志未公开",
copy: "当前人物资料受隐私设置保护,页面不会展示或填写成长记录。",
action: "返回上一页",
},
invalid: {
title: "成长日志入口无效",
copy: "人物不存在、缺少身份或不属于当前家谱。",
action: "返回上一页",
},
empty: {
title: "还没有成长记录",
copy: "可以先生成一份本地预览;正式创建仍需等待线上写接口启用。",
action: "记录成长预览",
},
})[timelineState.value] || {
title: "成长日志暂不可用",
copy: "请返回上一页重新进入。",
action: "返回上一页",
});
const recordGrowth = () => {
Object.assign(growthForm, { title: "", date: "", description: "" });
formError.value = "";
dialogVisible.value = true;
};
const saveGrowth = () => {
if (!growthForm.title.trim() || !growthForm.date.trim()) {
formError.value = "请填写事件名称和日期";
onLoad((query) => {
genealogyId.value = String(query.genealogyId || "");
personId.value = String(query.personId || "");
if (query.state === "loading") return;
const access = getGenealogyFixtureAccess(genealogyId.value);
memberRecord.value = findTreeMemberPresentationFixture(
genealogyId.value,
personId.value,
);
if (
!["owner", "member"].includes(access.accessRole) ||
!memberRecord.value
) {
timelineState.value = "invalid";
return;
}
growthRecords.value.unshift({ id: Date.now(), ...growthForm });
timelineState.value = "ready";
if (["privacy", "forbidden"].includes(memberRecord.value.status)) {
timelineState.value = "privacy";
return;
}
growthRecords.value = listGrowthRecordFixtures(
genealogyId.value,
personId.value,
);
timelineState.value = ["empty", "error"].includes(query.state)
? query.state
: growthRecords.value.length
? "ready"
: "empty";
});
const recordGrowth = () => {
if (!hasValidContext.value) return false;
Object.assign(growthForm, {
recordTitle: "",
recordDate: "",
recordContent: "",
});
formError.value = "";
editorBaseline.value = formSnapshot.value;
dialogVisible.value = true;
return true;
};
const createGrowthPreview = () => {
formError.value = growthForm.recordTitle.trim() ? "" : "请填写记录标题";
if (formError.value) return false;
// 预览与正式列表分离:这里不生成服务端 ID,也不改写只读夹具。
localGrowthPreview.value = Object.freeze({
recordTitle: growthForm.recordTitle.trim(),
recordDate: growthForm.recordDate.trim(),
recordContent: growthForm.recordContent.trim(),
});
dialogVisible.value = false;
toastVisible.value = true;
timer = setTimeout(() => (toastVisible.value = false), 1800);
if (timer) clearTimeout(timer);
timer = setTimeout(() => {
toastVisible.value = false;
timer = null;
}, 1800);
return true;
};
const closeEditor = () => {
dialogVisible.value = false;
formError.value = "";
};
const discardConfirmation = createDiscardConfirmation(
(visible) => { discardVisible.value = visible; },
);
const confirmDiscard = () => {
discardConfirmation.confirm();
closeEditor();
};
const cancelDiscard = discardConfirmation.cancel;
const requestCloseEditor = async () => {
if (!growthDraftDirty.value) {
closeEditor();
return true;
}
const confirmed = await discardConfirmation.request();
if (confirmed) closeEditor();
return confirmed;
};
const restoreGrowthRecords = () => {
growthRecords.value = listGrowthRecordFixtures(
genealogyId.value,
personId.value,
);
timelineState.value = growthRecords.value.length ? "ready" : "empty";
};
const handleStateAction = () => {
if (["invalid", "privacy"].includes(timelineState.value)) return goBack();
if (timelineState.value === "error") return restoreGrowthRecords();
return recordGrowth();
};
const requestBack = () => {
if (discardVisible.value) {
cancelDiscard();
return Promise.resolve(true);
}
if (dialogVisible.value) return requestCloseEditor();
return runBackGuard({
dirty: Boolean(localGrowthPreview.value),
"confirm-discard": discardConfirmation.request,
});
};
onBackPress((event) => handleBackPress(event, requestBack));
onUnmounted(() => {
if (timer) clearTimeout(timer);
discardConfirmation.dispose();
});
</script>
<style scoped lang="scss">
@@ -180,12 +315,14 @@ onUnmounted(() => {
font-weight: 700;
}
.timeline-card,
.preview-card,
.state-card {
box-sizing: border-box;
padding: 34rpx 46rpx;
@include adaptive.adaptive-records-content;
}
.timeline-card > text,
.preview-card > text,
.state-card > text {
display: block;
}
@@ -210,6 +347,23 @@ onUnmounted(() => {
font-size: 23rpx;
line-height: 1.55;
}
.preview-card > text:first-child {
color: $brand-red;
font-size: 21rpx;
font-weight: 700;
}
.preview-card > text:nth-child(2) {
margin-top: 8rpx;
color: $ink;
font-size: 30rpx;
font-weight: 700;
}
.preview-card > text:nth-child(n + 3) {
margin-top: 8rpx;
color: $ink-muted;
font-size: 22rpx;
line-height: 1.55;
}
.state-card {
min-height: 340rpx;
padding-top: 78rpx;
+54 -202
View File
@@ -1,155 +1,72 @@
<!-- 页面编号R-09用途人物人生事时间轴与同页新增 -->
<!-- 页面编号R-09用途校验人物身份并明确关闭缺失的线上服务 -->
<template>
<view class="timeline-page" :class="stateClasses">
<view class="service-page" :class="`service-state--${serviceState}`">
<ModulePageBackground module="records" />
<view class="page-header">
<PageHeader title="人生事" action="新增" @action="createLifeEvent" />
<PageHeader title="人生事" custom-back @back="requestBack" />
</view>
<view v-if="timelineState === 'loading'" class="page-loading">
<view v-if="serviceState === 'loading'" class="page-loading">
<AppLoading
text="正在读取人生事"
:description="`请稍候,正在整理${personName}的重要节点。`"
text="正在核对人物身份"
description="请稍候,页面正在确认当前家谱与人物。"
/>
</view>
<view v-else class="page-content">
<view class="person-lead">
<text>{{ personName }}</text>
<text>值得回望的人生节点</text>
</view>
<template v-if="timelineState === 'ready' && lifeEvents.length">
<view v-for="event in lifeEvents" :key="event.id" class="timeline-card">
<text>{{ event.year }}</text>
<text>{{ event.title }}</text>
<text>{{ event.place }}</text>
<text>{{ event.description }}</text>
</view>
<AppButton block label="新增人生事" @click="createLifeEvent" />
</template>
<view v-else class="state-card">
<text>
{{ timelineState === "error" ? "人生事暂不可用" : "还没有人生事" }}
<view class="state-card">
<text>{{ stateCopy.title }}</text>
<text v-if="personRecord && serviceState === 'unavailable'" class="person-name">
当前人物{{ personRecord.name }}
</text>
<text>
{{
timelineState === "error"
? "请稍后重新查看,已有记录不会受到影响。"
: "从毕业、成家或重要迁居开始记录。"
}}
</text>
<AppButton
:type="timelineState === 'error' ? 'secondary' : 'primary'"
block
:label="timelineState === 'error' ? '重新查看' : '新增人生事'"
@click="
timelineState === 'error'
? (timelineState = 'ready')
: createLifeEvent()
"
/>
<text>{{ stateCopy.copy }}</text>
<AppButton type="secondary" block label="返回上一页" @click="requestBack" />
</view>
</view>
<AppDialog
:visible="dialogVisible"
eyebrow="人生事"
title="记录一个人生节点"
confirm-text="保存记录"
cancel-text="取消"
show-cancel
@confirm="saveLifeEvent"
@cancel="dialogVisible = false"
>
<view class="dialog-form">
<input v-model="lifeEventForm.title" placeholder="事件名称" />
<input v-model="lifeEventForm.year" placeholder="年份或日期" />
<input v-model="lifeEventForm.place" placeholder="地点(选填)" />
<textarea
v-model="lifeEventForm.description"
auto-height
placeholder="写下这段经历"
/>
<text v-if="formError">{{ formError }}</text>
</view>
</AppDialog>
<AppToast :visible="toastVisible" message="人生事已保存" />
</view>
</template>
<script setup>
import { computed, onUnmounted, reactive, ref } from "vue";
import { onLoad } from "@dcloudio/uni-app";
import { computed, ref } from "vue";
import { onBackPress, onLoad } from "@dcloudio/uni-app";
import AppButton from "@/components/AppButton.vue";
import AppDialog from "@/components/AppDialog.vue";
import AppLoading from "@/components/AppLoading.vue";
import AppToast from "@/components/AppToast.vue";
import ModulePageBackground from "@/components/ModulePageBackground.vue";
import PageHeader from "@/components/PageHeader.vue";
const personName = ref("汤文清");
const lifeEvents = ref([
{
id: 1,
title: "大学毕业",
year: "2018 年 6 月",
place: "杭州",
description: "完成学业,带着家人的祝福走向新的生活。",
},
{
id: 2,
title: "结为连理",
year: "2022 年 10 月",
place: "汤氏祖居",
description: "在家人见证下组成新的家庭。",
},
]);
const timelineState = ref("loading");
const dialogVisible = ref(false);
const toastVisible = ref(false);
const formError = ref("");
const lifeEventForm = reactive({
title: "",
year: "",
place: "",
description: "",
});
let timer = null;
const stateClasses = computed(() => ({
"timeline-state--loading": timelineState.value === "loading",
"timeline-state--empty": timelineState.value === "empty",
"timeline-state--error": timelineState.value === "error",
}));
onLoad((q) => {
personName.value = String(q.personName || "汤文清");
timelineState.value = ["loading", "empty", "error"].includes(q.state)
? q.state
: "ready";
});
const createLifeEvent = () => {
Object.assign(lifeEventForm, {
title: "",
year: "",
place: "",
description: "",
});
formError.value = "";
dialogVisible.value = true;
};
const saveLifeEvent = () => {
if (!lifeEventForm.title.trim() || !lifeEventForm.year.trim()) {
formError.value = "请填写事件名称和年份";
return;
}
lifeEvents.value.unshift({ id: Date.now(), ...lifeEventForm });
timelineState.value = "ready";
dialogVisible.value = false;
toastVisible.value = true;
timer = setTimeout(() => (toastVisible.value = false), 1800);
};
onUnmounted(() => {
if (timer) clearTimeout(timer);
import {
findTreeMemberPresentationFixture,
getGenealogyFixtureAccess,
} from "@/data/mock.js";
import { goBack, handleBackPress } from "@/utils/navigation.js";
const serviceState = ref("loading");
const personRecord = ref(null);
const stateCopy = computed(() =>
serviceState.value === "unavailable"
? {
title: "人生事件接口尚未开放",
copy: "线上接口文档没有独立的人生事件资源。为避免把其他记录类型冒充人生事,本页暂不展示或提交数据。",
}
: {
title: "人生事入口无效",
copy: "人物不存在、缺少身份或不属于当前家谱,页面不会回退到其他人物。",
},
);
onLoad((query) => {
const genealogyId = String(query.genealogyId || "");
const personId = String(query.personId || "");
if (query.state === "loading") return;
const access = getGenealogyFixtureAccess(genealogyId);
personRecord.value = findTreeMemberPresentationFixture(genealogyId, personId);
serviceState.value =
["owner", "member"].includes(access.accessRole) && personRecord.value
? "unavailable"
: "invalid";
});
const requestBack = () => goBack();
onBackPress((event) => handleBackPress(event, requestBack));
</script>
<style scoped lang="scss">
@use "../../styles/adaptive-frame-profiles.scss" as adaptive;
.timeline-page {
.service-page {
display: flex;
min-height: 100vh;
flex-direction: column;
@@ -164,97 +81,32 @@ onUnmounted(() => {
min-height: calc(100vh - 100rpx);
}
.page-content {
display: flex;
flex-direction: column;
gap: 16rpx;
padding: 18rpx 24rpx 72rpx;
}
.person-lead {
display: flex;
flex-wrap: wrap;
justify-content: space-between;
gap: 8rpx 18rpx;
min-height: 62rpx;
align-items: center;
padding: 0 20rpx;
color: $ink-muted;
font-size: 22rpx;
background: url("/static/assets/modules/genealogy/transparent/section-divider.png")
center/100% auto no-repeat;
}
.person-lead text:first-child {
color: $brand-red;
font-weight: 700;
}
.timeline-card,
.state-card {
box-sizing: border-box;
padding: 34rpx 46rpx;
min-height: 380rpx;
padding: 78rpx 52rpx 50rpx;
@include adaptive.adaptive-records-content;
}
.timeline-card > text,
.state-card > text {
display: block;
}
.timeline-card > text:first-child {
color: $brand-red;
font-size: 20rpx;
}
.timeline-card > text:nth-child(2) {
margin-top: 6rpx;
color: $ink;
font-size: 31rpx;
font-weight: 700;
}
.timeline-card > text:nth-child(3) {
margin-top: 8rpx;
color: $ink-muted;
font-size: 21rpx;
}
.timeline-card > text:last-child {
margin-top: 10rpx;
color: $ink;
font-size: 23rpx;
line-height: 1.55;
}
.state-card {
min-height: 340rpx;
padding-top: 78rpx;
text-align: center;
}
.state-card > text { display: block; }
.state-card > text:first-child {
color: $ink;
font-size: 35rpx;
font-weight: 700;
}
.state-card > text:nth-child(2) {
.state-card > text:last-of-type {
margin-top: 18rpx;
color: $ink-muted;
font-size: 24rpx;
line-height: 1.65;
}
.person-name {
color: $brand-red;
font-weight: 700;
}
.state-card .app-button {
margin-top: 28rpx;
}
.dialog-form {
width: 100%;
margin: 18rpx 0;
}
.dialog-form input,
.dialog-form textarea {
width: 100%;
min-height: 66rpx;
margin-top: 8rpx;
padding: 12rpx 20rpx;
box-sizing: border-box;
color: $ink;
font-size: 22rpx;
@include adaptive.adaptive-records-field;
}
.dialog-form text {
display: block;
margin-top: 8rpx;
color: $brand-red;
font-size: 20rpx;
}
</style>
+191 -186
View File
@@ -1,246 +1,251 @@
<!-- 页面编号R-10用途家族备忘列表完成状态与同页新增 -->
<!-- 页面编号R-10用途当前家谱备忘列表与不写库的本地预览 -->
<template>
<view class="memo-page" :class="stateClasses">
<ModulePageBackground module="records" />
<view class="page-header">
<PageHeader title="家族备忘" action="新增" @action="createMemo" />
</view>
<view v-if="memoState === 'loading'" class="page-loading">
<AppLoading
text="正在读取家族备忘"
description="请稍候,正在整理待办事项。"
<PageHeader
title="家族备忘"
:action="hasValidContext ? '填写预览' : ''"
custom-back
@back="requestBack"
@action="startMemoPreview"
/>
</view>
<view v-if="memoState === 'loading'" class="page-loading">
<AppLoading text="正在读取家族备忘" description="请稍候,正在核对当前家谱的备忘记录。" />
</view>
<view v-else class="page-content">
<view v-if="localMemoPreview" class="preview-card">
<text>本地预览 · 尚未提交</text>
<text>{{ localMemoPreview.memoTitle }}</text>
<text>{{ localMemoPreview.remindTime || "提醒时间未填写" }}</text>
<text>{{ localMemoPreview.memoContent || "内容未填写" }}</text>
</view>
<template v-if="memoState === 'ready' && memos.length">
<view
v-for="memo in memos"
:key="memo.id"
class="memo-card"
:class="{ 'memo-card--done': memo.done }"
@click="toggleMemo(memo)"
>
<view v-for="memo in memos" :key="memo.memoId" class="memo-card">
<view>
<text>{{ memo.done ? "已完成" : "待办理" }}</text>
<text>{{ memo.due }}</text>
<text>{{ memo.completedLabel }}</text>
<text>{{ memo.remindTime || "未设置提醒" }}</text>
</view>
<text>{{ memo.title }}</text>
<text>{{ memo.description }}</text>
<text>{{ memo.done ? "点击恢复待办" : "点击标记完成" }}</text>
<text>{{ memo.memoTitle }}</text>
<text>{{ memo.memoContent || "暂无备忘内容" }}</text>
<text>状态仅展示线上切换接口尚未确认</text>
</view>
<AppButton block label="新增备忘" @click="createMemo" />
<AppButton block label="填写备忘预览" @click="startMemoPreview" />
</template>
<view v-else class="state-card">
<text>
{{ memoState === "error" ? "家族备忘暂不可用" : "还没有备忘" }}
</text>
<text>
{{
memoState === "error"
? "请稍后重新查看,已有备忘不会受到影响。"
: "把需要家人共同记住的事情写在这里。"
}}
</text>
<text>{{ stateCopy.title }}</text>
<text>{{ stateCopy.copy }}</text>
<AppButton
:type="memoState === 'error' ? 'secondary' : 'primary'"
:type="memoState === 'empty' ? 'primary' : 'secondary'"
block
:label="memoState === 'error' ? '重新查看' : '新增备忘'"
@click="memoState === 'error' ? (memoState = 'ready') : createMemo()"
:label="stateCopy.action"
@click="handleStateAction"
/>
</view>
</view>
<AppDialog
:visible="dialogVisible"
:close-on-mask="false"
eyebrow="家族备忘"
title="新增一项备忘"
confirm-text="保存备忘"
title="填写一份备忘预览"
confirm-text="生成预览"
cancel-text="取消"
show-cancel
@confirm="saveMemo"
@cancel="dialogVisible = false"
@confirm="createMemoPreview"
@cancel="requestCloseEditor"
>
<view class="dialog-form">
<input v-model="memoForm.title" placeholder="备忘标题" />
<input v-model="memoForm.due" placeholder="截止日期或时间" />
<textarea
v-model="memoForm.description"
auto-height
placeholder="补充具体事项"
/>
<input v-model="memoForm.memoTitle" placeholder="备忘标题" />
<input v-model="memoForm.remindTime" placeholder="提醒时间(选填)" />
<textarea v-model="memoForm.memoContent" auto-height placeholder="补充具体事项(选填)" />
<text v-if="formError">{{ formError }}</text>
</view>
</AppDialog>
<AppToast :visible="toastVisible" message="备忘已更新" />
<AppDialog
:visible="discardVisible"
:close-on-mask="false"
eyebrow="放弃确认"
title="放弃当前填写?"
message="尚未提交的预览内容将从当前页面清除。"
confirm-text="确认放弃"
cancel-text="继续填写"
show-cancel
@confirm="confirmDiscard"
@cancel="cancelDiscard"
/>
<AppToast :visible="toastVisible" message="已生成本地预览,尚未保存" />
</view>
</template>
<script setup>
import { computed, onUnmounted, reactive, ref } from "vue";
import { onLoad } from "@dcloudio/uni-app";
import { onBackPress, onLoad } from "@dcloudio/uni-app";
import AppButton from "@/components/AppButton.vue";
import AppDialog from "@/components/AppDialog.vue";
import AppLoading from "@/components/AppLoading.vue";
import AppToast from "@/components/AppToast.vue";
import ModulePageBackground from "@/components/ModulePageBackground.vue";
import PageHeader from "@/components/PageHeader.vue";
const memos = ref([
{
id: 1,
title: "修谱资料整理",
due: "本月底前",
description: "补充老照片中的人物姓名和拍摄时间。",
done: false,
},
{
id: 2,
title: "重阳敬老活动",
due: "10 月 11 日上午",
description: "在祠堂集合,并确认接送长辈的车辆。",
done: true,
},
]);
import {
getGenealogyFixtureAccess,
listMemoFixtures,
} from "@/data/mock.js";
import { createDiscardConfirmation } from "@/utils/discard-confirmation.js";
import { goBack, handleBackPress, runBackGuard } from "@/utils/navigation.js";
const genealogyId = ref("");
const memos = ref([]);
const memoState = ref("loading");
const dialogVisible = ref(false);
const discardVisible = ref(false);
const toastVisible = ref(false);
const formError = ref("");
const memoForm = reactive({ title: "", due: "", description: "" });
const localMemoPreview = ref(null);
const memoForm = reactive({ memoTitle: "", remindTime: "", memoContent: "" });
const editorBaseline = ref("");
let timer = null;
const stateClasses = computed(() => ({
"memo-state--loading": memoState.value === "loading",
"memo-state--empty": memoState.value === "empty",
"memo-state--error": memoState.value === "error",
"memo-state--invalid": memoState.value === "invalid",
}));
onLoad((q) => {
memoState.value = ["loading", "empty", "error"].includes(q.state)
? q.state
: "ready";
const hasValidContext = computed(() => ["ready", "empty"].includes(memoState.value));
const formSnapshot = computed(() => JSON.stringify({ ...memoForm }));
const memoDraftDirty = computed(() =>
dialogVisible.value && formSnapshot.value !== editorBaseline.value,
);
const stateCopy = computed(() => ({
error: {
title: "家族备忘暂不可用",
copy: "请稍后重新查看,已有备忘不会受到影响。",
action: "重新查看",
},
invalid: {
title: "家族备忘入口无效",
copy: "没有找到可访问的成员家谱,页面不会展示其他家谱备忘。",
action: "返回上一页",
},
empty: {
title: "还没有备忘",
copy: "可以先生成一份本地预览;正式创建仍需等待线上写接口启用。",
action: "填写备忘预览",
},
})[memoState.value] || {
title: "家族备忘暂不可用",
copy: "请返回上一页重新进入。",
action: "返回上一页",
});
const showToast = () => {
toastVisible.value = true;
if (timer) clearTimeout(timer);
timer = setTimeout(() => (toastVisible.value = false), 1800);
};
const toggleMemo = (memo) => {
memo.done = !memo.done;
showToast();
};
const createMemo = () => {
Object.assign(memoForm, { title: "", due: "", description: "" });
formError.value = "";
dialogVisible.value = true;
};
const saveMemo = () => {
if (!memoForm.title.trim()) {
formError.value = "请填写备忘标题";
onLoad((query) => {
genealogyId.value = String(query.genealogyId || "");
const access = getGenealogyFixtureAccess(genealogyId.value);
if (!["owner", "member"].includes(access.accessRole)) {
memoState.value = "invalid";
return;
}
memos.value.unshift({ id: Date.now(), ...memoForm, done: false });
memoState.value = "ready";
dialogVisible.value = false;
showToast();
memos.value = listMemoFixtures(genealogyId.value);
memoState.value = ["loading", "empty", "error"].includes(query.state)
? query.state
: memos.value.length
? "ready"
: "empty";
});
const startMemoPreview = () => {
if (!hasValidContext.value) return false;
Object.assign(memoForm, { memoTitle: "", remindTime: "", memoContent: "" });
formError.value = "";
editorBaseline.value = formSnapshot.value;
dialogVisible.value = true;
return true;
};
const createMemoPreview = () => {
formError.value = memoForm.memoTitle.trim() ? "" : "请填写备忘标题";
if (formError.value) return false;
// 正式列表来自只读选择器;预览不生成 ID,也不会改变完成状态或记录数量。
localMemoPreview.value = Object.freeze({
memoTitle: memoForm.memoTitle.trim(),
remindTime: memoForm.remindTime.trim(),
memoContent: memoForm.memoContent.trim(),
});
dialogVisible.value = false;
toastVisible.value = true;
if (timer) clearTimeout(timer);
timer = setTimeout(() => {
toastVisible.value = false;
timer = null;
}, 1800);
return true;
};
const closeEditor = () => {
dialogVisible.value = false;
formError.value = "";
};
const discardConfirmation = createDiscardConfirmation(
(visible) => { discardVisible.value = visible; },
);
const confirmDiscard = () => {
discardConfirmation.confirm();
closeEditor();
};
const cancelDiscard = discardConfirmation.cancel;
const requestCloseEditor = async () => {
if (!memoDraftDirty.value) {
closeEditor();
return true;
}
const confirmed = await discardConfirmation.request();
if (confirmed) closeEditor();
return confirmed;
};
const restoreMemos = () => {
memos.value = listMemoFixtures(genealogyId.value);
memoState.value = memos.value.length ? "ready" : "empty";
};
const handleStateAction = () => {
if (memoState.value === "invalid") return goBack();
if (memoState.value === "error") return restoreMemos();
return startMemoPreview();
};
const requestBack = () => {
if (discardVisible.value) {
cancelDiscard();
return Promise.resolve(true);
}
if (dialogVisible.value) return requestCloseEditor();
return runBackGuard({
dirty: Boolean(localMemoPreview.value),
"confirm-discard": discardConfirmation.request,
});
};
onBackPress((event) => handleBackPress(event, requestBack));
onUnmounted(() => {
if (timer) clearTimeout(timer);
discardConfirmation.dispose();
});
</script>
<style scoped lang="scss">
@use "../../styles/adaptive-frame-profiles.scss" as adaptive;
.memo-page {
display: flex;
min-height: 100vh;
flex-direction: column;
background: $paper;
}
.page-header,
.page-loading,
.page-content {
z-index: 1;
}
.page-loading {
min-height: calc(100vh - 100rpx);
}
.page-content {
display: flex;
flex-direction: column;
gap: 16rpx;
padding: 18rpx 24rpx 72rpx;
}
.memo-card,
.state-card {
box-sizing: border-box;
padding: 34rpx 46rpx;
@include adaptive.adaptive-records-content;
}
.memo-card > view {
display: flex;
flex-wrap: wrap;
justify-content: space-between;
gap: 6rpx 18rpx;
color: $brand-red;
font-size: 20rpx;
}
.memo-card > text,
.state-card > text {
display: block;
}
.memo-card > text:nth-child(2) {
margin-top: 8rpx;
color: $ink;
font-size: 31rpx;
font-weight: 700;
}
.memo-card > text:nth-child(3) {
margin-top: 9rpx;
color: $ink-muted;
font-size: 23rpx;
line-height: 1.55;
}
.memo-card > text:last-child {
margin-top: 10rpx;
color: $brand-red;
font-size: 20rpx;
}
.memo-card--done {
opacity: 0.68;
}
.state-card {
min-height: 340rpx;
padding-top: 78rpx;
text-align: center;
}
.state-card > text:first-child {
color: $ink;
font-size: 35rpx;
font-weight: 700;
}
.state-card > text:nth-child(2) {
margin-top: 18rpx;
color: $ink-muted;
font-size: 24rpx;
line-height: 1.65;
}
.state-card .app-button {
margin-top: 28rpx;
}
.dialog-form {
width: 100%;
margin: 18rpx 0;
}
.dialog-form input,
.dialog-form textarea {
width: 100%;
min-height: 68rpx;
margin-top: 9rpx;
padding: 13rpx 20rpx;
box-sizing: border-box;
color: $ink;
font-size: 23rpx;
@include adaptive.adaptive-records-field;
}
.dialog-form text {
display: block;
margin-top: 8rpx;
color: $brand-red;
font-size: 20rpx;
}
.memo-page { display: flex; min-height: 100vh; flex-direction: column; background: $paper; }
.page-header,.page-loading,.page-content { z-index: 1; }
.page-loading { min-height: calc(100vh - 100rpx); }
.page-content { display: flex; flex-direction: column; gap: 16rpx; padding: 18rpx 24rpx 72rpx; }
.memo-card,.preview-card,.state-card { box-sizing: border-box; padding: 34rpx 46rpx; @include adaptive.adaptive-records-content; }
.memo-card > view { display: flex; flex-wrap: wrap; justify-content: space-between; gap: 6rpx 18rpx; color: $brand-red; font-size: 20rpx; }
.memo-card > text,.preview-card > text,.state-card > text { display: block; }
.memo-card > text:nth-child(2),.preview-card > text:nth-child(2) { margin-top: 8rpx; color: $ink; font-size: 31rpx; font-weight: 700; }
.memo-card > text:nth-child(3),.preview-card > text:nth-child(n + 3) { margin-top: 9rpx; color: $ink-muted; font-size: 23rpx; line-height: 1.55; }
.memo-card > text:last-child,.preview-card > text:first-child { margin-top: 10rpx; color: $brand-red; font-size: 20rpx; }
.state-card { min-height: 340rpx; padding-top: 78rpx; text-align: center; }
.state-card > text:first-child { color: $ink; font-size: 35rpx; font-weight: 700; }
.state-card > text:nth-child(2) { margin-top: 18rpx; color: $ink-muted; font-size: 24rpx; line-height: 1.65; }
.state-card .app-button { margin-top: 28rpx; }
.dialog-form { width: 100%; margin: 18rpx 0; }
.dialog-form input,.dialog-form textarea { width: 100%; min-height: 68rpx; margin-top: 9rpx; padding: 13rpx 20rpx; box-sizing: border-box; color: $ink; font-size: 23rpx; @include adaptive.adaptive-records-field; }
.dialog-form text { display: block; margin-top: 8rpx; color: $brand-red; font-size: 20rpx; }
</style>
+225 -203
View File
@@ -1,266 +1,288 @@
<!-- 页面编号R-11用途功德记录贡献汇总与同页新增 -->
<!-- 页面编号R-11用途当前家谱功德记录与不写库的本地预览 -->
<template>
<view class="merit-page" :class="stateClasses">
<ModulePageBackground module="records" />
<view class="page-header">
<PageHeader title="功德记录" action="新增" @action="createMerit" />
</view>
<view v-if="meritState === 'loading'" class="page-loading">
<AppLoading
text="正在整理功德记录"
description="请稍候,正在读取家人对家族事务的支持。"
<PageHeader
title="功德记录"
:action="hasValidContext ? '填写预览' : ''"
custom-back
@back="requestBack"
@action="startMeritPreview"
/>
</view>
<view v-if="meritState === 'loading'" class="page-loading">
<AppLoading text="正在整理功德记录" description="请稍候,正在核对当前家谱的正式记录。" />
</view>
<view v-else class="page-content">
<view v-if="meritState === 'ready'" class="merit-summary">
<text>共同贡献</text>
<text>{{ totalContribution }} </text>
<text>每一次时间物资与心力的付出都值得被记住</text>
<view v-if="hasValidContext" class="merit-summary">
<text>正式记录</text>
<text>{{ totalContribution }} </text>
<text>本地预览不计入正式记录数量</text>
</view>
<view v-if="localMeritPreview" class="preview-card">
<text>本地预览 · 尚未提交</text>
<text>{{ localMeritPreview.meritTitle }}</text>
<text>{{ localMeritPreview.donorName }} · {{ localMeritPreview.meritTime || "时间未填写" }}</text>
<text>{{ localMeritPreview.meritType || "类型未填写" }}</text>
<text>金额数值单位待确认{{ formatMeritAmount(localMeritPreview.amount) }}</text>
<text>{{ localMeritPreview.meritContent || "内容未填写" }}</text>
</view>
<template v-if="meritState === 'ready' && meritRecords.length">
<view v-for="merit in meritRecords" :key="merit.id" class="merit-card">
<text>{{ merit.category }}</text>
<text>{{ merit.title }}</text>
<text>{{ merit.contributor }} · {{ merit.date }}</text>
<text>{{ merit.description }}</text>
<view v-for="merit in meritRecords" :key="merit.meritId" class="merit-card">
<text>{{ merit.meritTypeLabel || "类型未标注" }}</text>
<text>{{ merit.meritTitle }}</text>
<text>{{ merit.donorName }} · {{ merit.meritTime || "时间未填写" }}</text>
<text>{{ merit.meritContent || "暂无记录内容" }}</text>
</view>
<AppButton block label="新增功德记录" @click="createMerit" />
<AppButton block label="填写功德预览" @click="startMeritPreview" />
</template>
<view v-else class="state-card">
<text>
{{ meritState === "error" ? "功德记录暂不可用" : "还没有功德记录" }}
</text>
<text>
{{
meritState === "error"
? "请稍后重新查看,已有记录不会受到影响。"
: "记录第一份对家族事务的时间、物资或心力支持。"
}}
</text>
<text>{{ stateCopy.title }}</text>
<text>{{ stateCopy.copy }}</text>
<AppButton
:type="meritState === 'error' ? 'secondary' : 'primary'"
:type="meritState === 'empty' ? 'primary' : 'secondary'"
block
:label="meritState === 'error' ? '重新查看' : '新增记录'"
@click="
meritState === 'error' ? (meritState = 'ready') : createMerit()
"
:label="stateCopy.action"
@click="handleStateAction"
/>
</view>
</view>
<AppDialog
:visible="dialogVisible"
:close-on-mask="false"
eyebrow="功德记录"
title="记下一份家族贡献"
confirm-text="保存记录"
title="填写一份功德预览"
confirm-text="生成预览"
cancel-text="取消"
show-cancel
@confirm="saveMerit"
@cancel="dialogVisible = false"
@confirm="createMeritPreview"
@cancel="requestCloseEditor"
>
<view class="dialog-form">
<input v-model="meritForm.title" placeholder="贡献事项" />
<input v-model="meritForm.contributor" placeholder="贡献人" />
<input v-model="meritForm.date" placeholder="日期" />
<textarea
v-model="meritForm.description"
auto-height
placeholder="说明时间、物资或具体帮助"
/>
<input v-model="meritForm.meritTitle" placeholder="贡献事项" />
<input v-model="meritForm.donorName" placeholder="贡献人" />
<input v-model="meritForm.meritType" placeholder="贡献类型(选填)" />
<input v-model="meritForm.meritTime" placeholder="时间(选填)" />
<input v-model="meritForm.amount" type="digit" placeholder="金额数值(单位未明确,可不填)" />
<textarea v-model="meritForm.meritContent" auto-height placeholder="说明时间、物资或具体帮助(选填)" />
<text v-if="formError">{{ formError }}</text>
</view>
</AppDialog>
<AppToast :visible="toastVisible" message="功德记录已保存" />
<AppDialog
:visible="discardVisible"
:close-on-mask="false"
eyebrow="放弃确认"
title="放弃当前填写?"
message="尚未提交的预览内容将从当前页面清除。"
confirm-text="确认放弃"
cancel-text="继续填写"
show-cancel
@confirm="confirmDiscard"
@cancel="cancelDiscard"
/>
<AppToast :visible="toastVisible" message="已生成本地预览,尚未保存" />
</view>
</template>
<script setup>
import { computed, onUnmounted, reactive, ref } from "vue";
import { onLoad } from "@dcloudio/uni-app";
import { onBackPress, onLoad } from "@dcloudio/uni-app";
import AppButton from "@/components/AppButton.vue";
import AppDialog from "@/components/AppDialog.vue";
import AppLoading from "@/components/AppLoading.vue";
import AppToast from "@/components/AppToast.vue";
import ModulePageBackground from "@/components/ModulePageBackground.vue";
import PageHeader from "@/components/PageHeader.vue";
const meritRecords = ref([
{
id: 1,
category: "共同修缮",
title: "修缮祠堂",
contributor: "汤氏家人共同参与",
date: "2024 年春",
description: "协助整理院落、修补门窗并登记旧物。",
},
{
id: 2,
category: "奖学助学",
title: "支持后辈勤学",
contributor: "家族教育小组",
date: "2024 年夏",
description: "为家族中努力求学的孩子提供书籍与经验分享。",
},
]);
const totalContribution = computed(() => meritRecords.value.length);
import {
getGenealogyFixtureAccess,
listMeritRecordFixtures,
} from "@/data/mock.js";
import { createDiscardConfirmation } from "@/utils/discard-confirmation.js";
import { goBack, handleBackPress, runBackGuard } from "@/utils/navigation.js";
const genealogyId = ref("");
const meritRecords = ref([]);
const meritState = ref("loading");
const dialogVisible = ref(false);
const discardVisible = ref(false);
const toastVisible = ref(false);
const formError = ref("");
const localMeritPreview = ref(null);
const meritForm = reactive({
title: "",
contributor: "",
date: "",
description: "",
category: "家族贡献",
meritTitle: "",
donorName: "",
meritType: "",
meritTime: "",
amount: "",
meritContent: "",
});
const editorBaseline = ref("");
let timer = null;
const totalContribution = computed(() => meritRecords.value.length);
const stateClasses = computed(() => ({
"merit-state--loading": meritState.value === "loading",
"merit-state--empty": meritState.value === "empty",
"merit-state--error": meritState.value === "error",
"merit-state--invalid": meritState.value === "invalid",
}));
onLoad((q) => {
meritState.value = ["loading", "empty", "error"].includes(q.state)
? q.state
: "ready";
const hasValidContext = computed(() => ["ready", "empty"].includes(meritState.value));
const formSnapshot = computed(() => JSON.stringify({ ...meritForm }));
const meritDraftDirty = computed(() =>
dialogVisible.value && formSnapshot.value !== editorBaseline.value,
);
const stateCopy = computed(() => ({
error: {
title: "功德记录暂不可用",
copy: "请稍后重新查看,已有记录不会受到影响。",
action: "重新查看",
},
invalid: {
title: "功德记录入口无效",
copy: "没有找到可访问的成员家谱,页面不会展示其他家谱记录。",
action: "返回上一页",
},
empty: {
title: "还没有功德记录",
copy: "可以先生成一份本地预览;正式创建仍需等待线上写接口启用。",
action: "填写功德预览",
},
})[meritState.value] || {
title: "功德记录暂不可用",
copy: "请返回上一页重新进入。",
action: "返回上一页",
});
const createMerit = () => {
Object.assign(meritForm, {
title: "",
contributor: "",
date: "",
description: "",
category: "家族贡献",
});
formError.value = "";
dialogVisible.value = true;
};
const saveMerit = () => {
if (!meritForm.title.trim() || !meritForm.contributor.trim()) {
formError.value = "请填写贡献事项和贡献人";
onLoad((query) => {
genealogyId.value = String(query.genealogyId || "");
const access = getGenealogyFixtureAccess(genealogyId.value);
if (!["owner", "member"].includes(access.accessRole)) {
meritState.value = "invalid";
return;
}
meritRecords.value.unshift({ id: Date.now(), ...meritForm });
meritState.value = "ready";
meritRecords.value = listMeritRecordFixtures(genealogyId.value);
meritState.value = ["loading", "empty", "error"].includes(query.state)
? query.state
: meritRecords.value.length
? "ready"
: "empty";
});
const startMeritPreview = () => {
if (!hasValidContext.value) return false;
Object.assign(meritForm, {
meritTitle: "",
donorName: "",
meritType: "",
meritTime: "",
amount: "",
meritContent: "",
});
formError.value = "";
editorBaseline.value = formSnapshot.value;
dialogVisible.value = true;
return true;
};
const formatMeritAmount = (amount) =>
amount === null || amount === "" ? "未填写" : String(amount);
const createMeritPreview = () => {
const missing = [];
if (!meritForm.meritTitle.trim()) missing.push("贡献事项");
if (!meritForm.donorName.trim()) missing.push("贡献人");
formError.value = missing.length ? `请填写${missing.join("和")}` : "";
const amountInput = meritForm.amount.trim();
if (!formError.value && amountInput && !Number.isFinite(Number(amountInput))) {
formError.value = "金额必须是数字,单位仍待后端确认";
}
if (formError.value) return false;
// 金额的单位、精度和取值规则尚未由后端明确,本地预览只保留原始输入。
localMeritPreview.value = Object.freeze({
meritTitle: meritForm.meritTitle.trim(),
donorName: meritForm.donorName.trim(),
meritType: meritForm.meritType.trim(),
meritTime: meritForm.meritTime.trim(),
amount: amountInput ? Number(amountInput) : null,
meritContent: meritForm.meritContent.trim(),
});
dialogVisible.value = false;
toastVisible.value = true;
timer = setTimeout(() => (toastVisible.value = false), 1800);
if (timer) clearTimeout(timer);
timer = setTimeout(() => {
toastVisible.value = false;
timer = null;
}, 1800);
return true;
};
const closeEditor = () => {
dialogVisible.value = false;
formError.value = "";
};
const discardConfirmation = createDiscardConfirmation(
(visible) => { discardVisible.value = visible; },
);
const confirmDiscard = () => {
discardConfirmation.confirm();
closeEditor();
};
const cancelDiscard = discardConfirmation.cancel;
const requestCloseEditor = async () => {
if (!meritDraftDirty.value) {
closeEditor();
return true;
}
const confirmed = await discardConfirmation.request();
if (confirmed) closeEditor();
return confirmed;
};
const restoreMeritRecords = () => {
meritRecords.value = listMeritRecordFixtures(genealogyId.value);
meritState.value = meritRecords.value.length ? "ready" : "empty";
};
const handleStateAction = () => {
if (meritState.value === "invalid") return goBack();
if (meritState.value === "error") return restoreMeritRecords();
return startMeritPreview();
};
const requestBack = () => {
if (discardVisible.value) {
cancelDiscard();
return Promise.resolve(true);
}
if (dialogVisible.value) return requestCloseEditor();
return runBackGuard({
dirty: Boolean(localMeritPreview.value),
"confirm-discard": discardConfirmation.request,
});
};
onBackPress((event) => handleBackPress(event, requestBack));
onUnmounted(() => {
if (timer) clearTimeout(timer);
discardConfirmation.dispose();
});
</script>
<style scoped lang="scss">
@use "../../styles/adaptive-frame-profiles.scss" as adaptive;
.merit-page {
display: flex;
min-height: 100vh;
flex-direction: column;
background: $paper;
}
.page-header,
.page-loading,
.page-content {
z-index: 1;
}
.page-loading {
min-height: calc(100vh - 100rpx);
}
.page-content {
display: flex;
flex-direction: column;
gap: 16rpx;
padding: 18rpx 24rpx 72rpx;
}
.merit-summary,
.merit-card,
.state-card {
box-sizing: border-box;
padding: 34rpx 46rpx;
@include adaptive.adaptive-records-content;
}
.merit-summary {
text-align: center;
}
.merit-summary > text,
.merit-card > text,
.state-card > text {
display: block;
}
.merit-summary > text:first-child {
color: $brand-red;
font-size: 21rpx;
}
.merit-summary > text:nth-child(2) {
margin-top: 5rpx;
color: $ink;
font-size: 38rpx;
font-weight: 700;
}
.merit-summary > text:last-child {
margin-top: 9rpx;
color: $ink-muted;
font-size: 22rpx;
line-height: 1.5;
}
.merit-card > text:first-child {
color: $brand-red;
font-size: 20rpx;
}
.merit-card > text:nth-child(2) {
margin-top: 6rpx;
color: $ink;
font-size: 31rpx;
font-weight: 700;
}
.merit-card > text:nth-child(3) {
margin-top: 8rpx;
color: $ink-muted;
font-size: 21rpx;
}
.merit-card > text:last-child {
margin-top: 9rpx;
color: $ink;
font-size: 23rpx;
line-height: 1.55;
}
.state-card {
min-height: 340rpx;
padding-top: 78rpx;
text-align: center;
}
.state-card > text:first-child {
color: $ink;
font-size: 35rpx;
font-weight: 700;
}
.state-card > text:nth-child(2) {
margin-top: 18rpx;
color: $ink-muted;
font-size: 24rpx;
line-height: 1.65;
}
.state-card .app-button {
margin-top: 28rpx;
}
.dialog-form {
width: 100%;
margin: 14rpx 0;
}
.dialog-form input,
.dialog-form textarea {
width: 100%;
min-height: 62rpx;
margin-top: 7rpx;
padding: 11rpx 18rpx;
box-sizing: border-box;
color: $ink;
font-size: 22rpx;
@include adaptive.adaptive-records-field;
}
.dialog-form text {
display: block;
margin-top: 7rpx;
color: $brand-red;
font-size: 20rpx;
}
.merit-page { display: flex; min-height: 100vh; flex-direction: column; background: $paper; }
.page-header,.page-loading,.page-content { z-index: 1; }
.page-loading { min-height: calc(100vh - 100rpx); }
.page-content { display: flex; flex-direction: column; gap: 16rpx; padding: 18rpx 24rpx 72rpx; }
.merit-summary,.merit-card,.preview-card,.state-card { box-sizing: border-box; padding: 34rpx 46rpx; @include adaptive.adaptive-records-content; }
.merit-summary { text-align: center; }
.merit-summary > text,.merit-card > text,.preview-card > text,.state-card > text { display: block; }
.merit-summary > text:first-child,.merit-card > text:first-child,.preview-card > text:first-child { color: $brand-red; font-size: 21rpx; }
.merit-summary > text:nth-child(2) { margin-top: 5rpx; color: $ink; font-size: 38rpx; font-weight: 700; }
.merit-summary > text:last-child { margin-top: 9rpx; color: $ink-muted; font-size: 22rpx; line-height: 1.5; }
.merit-card > text:nth-child(2),.preview-card > text:nth-child(2) { margin-top: 6rpx; color: $ink; font-size: 31rpx; font-weight: 700; }
.merit-card > text:nth-child(3),.preview-card > text:nth-child(3) { margin-top: 8rpx; color: $ink-muted; font-size: 21rpx; }
.merit-card > text:last-child,.preview-card > text:last-child { margin-top: 9rpx; color: $ink; font-size: 23rpx; line-height: 1.55; }
.state-card { min-height: 340rpx; padding-top: 78rpx; text-align: center; }
.state-card > text:first-child { color: $ink; font-size: 35rpx; font-weight: 700; }
.state-card > text:nth-child(2) { margin-top: 18rpx; color: $ink-muted; font-size: 24rpx; line-height: 1.65; }
.state-card .app-button { margin-top: 28rpx; }
.dialog-form { width: 100%; margin: 14rpx 0; }
.dialog-form input,.dialog-form textarea { width: 100%; min-height: 62rpx; margin-top: 7rpx; padding: 11rpx 18rpx; box-sizing: border-box; color: $ink; font-size: 22rpx; @include adaptive.adaptive-records-field; }
.dialog-form text { display: block; margin-top: 7rpx; color: $brand-red; font-size: 20rpx; }
</style>
+43 -78
View File
@@ -182,7 +182,9 @@ import AppButton from "@/components/AppButton.vue";
import AppLoading from "@/components/AppLoading.vue";
import ModulePageBackground from "@/components/ModulePageBackground.vue";
import PageHeader from "@/components/PageHeader.vue";
import { getGenealogyFixtureAccess, listTreeMemberFixtures } from "@/data/mock.js";
import { genealogyContext } from "@/utils/genealogy-context.js";
import { openPage } from "@/utils/navigation.js";
const genealogyId = ref("");
const treeState = ref("loading");
@@ -202,61 +204,7 @@ const generationMeta = {
13: { label: "第十三世", summary: "两房" },
14: { label: "第十四世", summary: "三支" },
};
const members = ref([
{
id: 101,
name: "汤文远",
relation: "始祖",
years: "1940—2012",
generation: 12,
branch: "主支",
},
{
id: 102,
name: "汤正国",
relation: "长子",
years: "1965—",
generation: 13,
parentId: 101,
branch: "长房",
},
{
id: 103,
name: "汤正华",
relation: "次子",
years: "1968—",
generation: 13,
parentId: 101,
branch: "二房",
},
{
id: 104,
name: "汤凯",
relation: "长孙",
years: "1992—",
generation: 14,
parentId: 102,
branch: "长房",
},
{
id: 105,
name: "汤悦",
relation: "长孙女",
years: "1995—",
generation: 14,
parentId: 102,
branch: "长房",
},
{
id: 106,
name: "汤晨",
relation: "次孙",
years: "1998—",
generation: 14,
parentId: 103,
branch: "二房",
},
]);
const members = ref([]);
const snapToGrid = (value) => Math.ceil(value / GRID_UNIT) * GRID_UNIT;
const layoutMembers = computed(() => {
@@ -404,8 +352,29 @@ const stateCopy = computed(
);
onLoad((query) => {
genealogyId.value =
query.genealogyId || genealogyContext.getCurrentGenealogyId() || "";
if (genealogyContext.isCurrentGenealogyInvalidated()) {
genealogyId.value = "";
members.value = [];
treeState.value = "error";
return;
}
genealogyId.value = String(
query.genealogyId || genealogyContext.getCurrentGenealogyId() || "",
);
if (!genealogyId.value) {
members.value = [];
treeState.value = "empty";
return;
}
const access = getGenealogyFixtureAccess(genealogyId.value);
if (!["owner", "member"].includes(access.accessRole)) {
genealogyContext.invalidateCurrentGenealogyId();
genealogyId.value = "";
members.value = [];
treeState.value = "error";
return;
}
members.value = listTreeMemberFixtures(genealogyId.value);
if (
query.state === "loading" ||
["landscape", "empty", "error"].includes(query.state)
@@ -413,16 +382,12 @@ onLoad((query) => {
treeState.value = query.state;
return;
}
if (!genealogyId.value) {
treeState.value = "empty";
return;
}
genealogyContext.setCurrentGenealogyId(genealogyId.value);
selected.value =
layoutMembers.value.find(
(item) => String(item.id) === String(query.selectedId),
) || layoutMembers.value[0];
treeState.value = "tree";
treeState.value = members.value.length ? "tree" : "empty";
});
onUnload(() => {
@@ -471,30 +436,30 @@ const nodeGridStyle = (member) => ({
});
const handleStateAction = () => {
if (treeState.value === "empty") {
uni.navigateTo({
url: `/pages/tree/t04-add-relative?genealogyId=${genealogyId.value || 1001}&mode=first`,
});
return;
if (!genealogyId.value) return Promise.resolve(false);
return openPage(
"T04",
{ genealogyId: genealogyId.value, mode: "first" },
"T01",
);
}
selected.value = layoutMembers.value[0];
treeState.value = "tree";
};
const toMember = () =>
uni.navigateTo({
url: `/pages/tree/t03-member-profile?genealogyId=${genealogyId.value}&personId=${selected.value.id}`,
});
selected.value
? openPage("T03", { genealogyId: genealogyId.value, personId: String(selected.value.id) }, "T01")
: Promise.resolve(false);
const toDirectory = () =>
uni.navigateTo({
url: `/pages/tree/t07-member-directory?genealogyId=${genealogyId.value}`,
});
openPage("T07", { genealogyId: genealogyId.value }, "T01");
const toRelationship = () =>
uni.navigateTo({
url: `/pages/tree/t06-edit-relationship?genealogyId=${genealogyId.value}`,
});
selected.value
? openPage("T06", { genealogyId: genealogyId.value, personId: String(selected.value.id) }, "T01")
: Promise.resolve(false);
const toAddRelative = () =>
uni.navigateTo({
url: `/pages/tree/t04-add-relative?genealogyId=${genealogyId.value}&personId=${selected.value.id}`,
});
selected.value
? openPage("T04", { genealogyId: genealogyId.value, personId: String(selected.value.id) }, "T01")
: Promise.resolve(false);
</script>
<style scoped lang="scss">
+183 -43
View File
@@ -2,18 +2,27 @@
<template>
<view
class="member-page"
:data-current-person-id="personId"
:data-trail-length="memberTrail.length"
:data-trail-index="trailIndex"
:class="{
'member-state--detail': memberState === 'detail',
'member-state--restricted': memberState === 'restricted',
'member-state--error': memberState === 'error',
}"
>
<ModulePageBackground module="tree" />
<view class="member-page__header">
<PageHeader title="成员档案" :action="memberState === 'detail' && canEdit ? '编辑' : ''" @action="toEdit" />
<PageHeader title="成员档案"
:action="canPreviewEdit ? '编辑预览' : ''"
custom-back
@back="requestBack"
@action="toEdit"
/>
</view>
<view class="member-context">
<text>{{ genealogyName }}</text>
<text>{{ memberState === "detail" ? "成员身份与亲属关系" : "请重新选择成员" }}</text>
<text>{{ memberContextDescription }}</text>
</view>
<view class="member-panel">
@@ -28,7 +37,7 @@
</view>
</view>
<view class="member-status-entry" @click="openMemberState(member.status)">
<view class="member-status-entry" @click="openMemberState">
<text>{{ statusLabel }}</text>
<text>{{ statusDescription }}</text>
<text>查看</text>
@@ -41,13 +50,36 @@
<text class="member-section-title member-section-title--relation">亲属关系</text>
<view class="member-relatives">
<view v-for="relative in member.relatives" :key="relative.id" @click="openRelative(relative)">
<view
v-for="relative in member.relatives"
:key="relative.id"
:data-person-id="relative.id"
@click="openRelative(relative.id)"
>
<text>{{ relative.relation }}</text><text>{{ relative.name }}</text>
</view>
<text v-if="!member.relatives.length">尚未记录可查看的亲属</text>
</view>
<view v-if="canEdit" class="member-profile-action" @click="toEdit"><text>完善成员档案</text></view>
<view class="member-record-actions">
<view role="button" aria-label="查看成长日志" @click="toGrowthJournal"><text>成长日志</text></view>
<view role="button" aria-label="查看人生事服务状态" @click="toLifeEvents"><text>人生事待开放</text></view>
</view>
<view v-if="canPreviewEdit" class="member-profile-action" @click="toEdit"><text>制作成员编辑预览</text></view>
</view>
<view v-else-if="memberState === 'restricted' && member" class="member-restricted">
<view class="member-heading">
<view class="member-heading__seal"><text>{{ member.name.slice(0, 1) }}</text></view>
<view>
<text>{{ member.name }}</text>
<text> {{ member.generation }} · {{ member.relation }}</text>
</view>
</view>
<text>{{ restrictedCopy.title }}</text>
<text>{{ restrictedCopy.copy }}</text>
<view class="member-restricted-action" @click="toTree"><text>返回上一位成员</text></view>
</view>
<view v-else class="member-error">
@@ -60,12 +92,18 @@
</template>
<script setup>
import { computed, ref } from "vue";
import { onLoad } from "@dcloudio/uni-app";
import { computed, reactive, ref } from "vue";
import { onBackPress, onLoad, onShow } from "@dcloudio/uni-app";
import AppLoading from "@/components/AppLoading.vue";
import ModulePageBackground from "@/components/ModulePageBackground.vue";
import PageHeader from "@/components/PageHeader.vue";
import { genealogyContext } from "@/utils/genealogy-context.js";
import { findTreeMemberPresentationFixture } from "@/data/mock.js";
import {
consumeNavigationResult,
handleBackPress,
openPage,
runBackGuard,
} from "@/utils/navigation.js";
const genealogyId = ref("");
const personId = ref("");
@@ -73,24 +111,9 @@ const memberState = ref("loading");
const member = ref(null);
const errorMessage = ref("");
const genealogyName = ref("汤氏家谱");
const memberTrail = reactive([]);
const trailIndex = ref(-1);
const memberFixtures = {
101: {
id: 101, name: "汤文远", generation: 12, relation: "始祖", branch: "主支", generationName: "文字辈",
birthDate: "1940年3月", years: "1940—2012", birthplace: "河南南阳", status: "deceased", canEdit: true,
relatives: [{ id: 102, name: "汤正国", relation: "长子" }, { id: 103, name: "汤正华", relation: "次子" }],
},
102: {
id: 102, name: "汤正国", generation: 13, relation: "长子", branch: "长房", generationName: "正字辈",
birthDate: "1965年5月", years: "1965—", birthplace: "河南洛阳", status: "privacy", canEdit: true,
relatives: [{ id: 101, name: "汤文远", relation: "父亲" }, { id: 104, name: "汤凯", relation: "长子" }],
},
103: {
id: 103, name: "汤正华", generation: 13, relation: "次子", branch: "二房", generationName: "正字辈",
birthDate: "", years: "资料受限", birthplace: "", status: "forbidden", canEdit: false,
relatives: [{ id: 101, name: "汤文远", relation: "父亲" }],
},
};
const details = computed(() => member.value ? [
{ label: "字辈", value: member.value.generationName },
{ label: "出生日期", value: member.value.status === "forbidden" ? "按权限隐藏" : member.value.birthDate },
@@ -98,39 +121,147 @@ const details = computed(() => member.value ? [
{ label: "祖居地", value: member.value.status === "forbidden" ? "按权限隐藏" : member.value.birthplace },
{ label: "所属支系", value: member.value.branch },
] : []);
const canEdit = computed(() => Boolean(member.value?.canEdit));
const canPreviewEdit = computed(() => memberState.value === "detail");
const restrictedCopy = computed(() =>
member.value?.status === "forbidden"
? {
title: "当前成员档案访问受限",
copy: "页面只保留基础身份,不展示生平、居住地、支系、亲属或编辑入口。",
}
: {
title: "部分成员资料未公开",
copy: "页面只保留姓名、关系与世代,不展示其他资料或操作入口。",
},
);
const statusLabel = computed(() => ({ privacy: "隐私资料", deceased: "离世纪念", forbidden: "访问受限" })[member.value?.status] || "成员状态");
const statusDescription = computed(() => ({
privacy: "部分资料仅向授权成员展示",
deceased: "查看生平保留与纪念资料说明",
forbidden: "当前账号只能查看有限身份信息",
})[member.value?.status] || "查看成员状态说明");
const memberContextDescription = computed(() => ({
loading: "正在读取成员资料",
detail: "成员身份与亲属关系",
restricted: "隐私成员仅展示基础身份",
error: "请重新选择成员",
}[memberState.value] || "请重新选择成员"));
const loadMember = async (nextPersonId) => {
const normalizedPersonId = String(nextPersonId || "");
const fixture = findTreeMemberPresentationFixture(genealogyId.value, normalizedPersonId);
if (!fixture) {
errorMessage.value = "这位成员不存在或已不属于当前家谱。";
return false;
}
const restricted = ["privacy", "forbidden"].includes(fixture.status);
const relatives = restricted ? [] : fixture.relatives
.map((relative) => {
const relativeMember = findTreeMemberPresentationFixture(
genealogyId.value,
relative.personId,
);
return relativeMember
? { id: relativeMember.id, name: relativeMember.name, relation: relative.relation }
: null;
})
.filter(Boolean);
personId.value = normalizedPersonId;
member.value = {
...fixture,
relatives,
};
errorMessage.value = "";
memberState.value = restricted ? "restricted" : "detail";
return true;
};
const initializeMemberTrail = async (initialPersonId) => {
memberTrail.splice(0, memberTrail.length);
trailIndex.value = -1;
const normalizedPersonId = String(initialPersonId || "");
const loaded = await loadMember(normalizedPersonId);
if (!loaded) return false;
memberTrail.splice(0, memberTrail.length, normalizedPersonId);
trailIndex.value = 0;
return true;
};
const openRelative = async (nextPersonId) => {
const normalizedPersonId = String(nextPersonId || "");
if (!normalizedPersonId || normalizedPersonId === personId.value) return false;
// 先读取目标成员,只有真实读取成功后才截断前进历史并推进轨迹;失败时
// 当前成员、当前索引和既有历史全部保持不变。
const loaded = await loadMember(normalizedPersonId);
if (!loaded) return false;
memberTrail.splice(trailIndex.value + 1);
memberTrail.push(normalizedPersonId);
trailIndex.value = memberTrail.length - 1;
return true;
};
const popMemberTrail = async () => {
while (trailIndex.value > 0) {
const targetIndex = trailIndex.value - 1;
const historicalPersonId = memberTrail[targetIndex];
const loaded = await loadMember(historicalPersonId);
if (loaded) {
trailIndex.value = targetIndex;
return true;
}
// 历史成员可能已删除或失去权限。移除失效项后,当前成员在数组中的
// 索引同步左移,再继续寻找更早的有效成员,绝不创建新的 T03 页面。
memberTrail.splice(targetIndex, 1);
trailIndex.value -= 1;
}
return false;
};
const requestBack = () =>
runBackGuard({
internalTrail: trailIndex.value > 0,
"pop-internal-trail": popMemberTrail,
});
onLoad((query) => {
genealogyId.value = query.genealogyId || genealogyContext.getCurrentGenealogyId() || "";
personId.value = query.personId || "";
if (genealogyId.value) genealogyContext.setCurrentGenealogyId(genealogyId.value);
if (query.state === "loading") { memberState.value = "loading"; return; }
if (query.state === "error" || !personId.value || !memberFixtures[personId.value]) {
genealogyId.value = String(query.genealogyId || "");
const initialPersonId = String(query.personId || "");
if (query.state === "loading") return;
if (query.state === "error" || !genealogyId.value || !initialPersonId) {
memberState.value = "error";
errorMessage.value = !personId.value ? "没有指定成员,请从世系树重新选择。" : "这位成员不存在或已不属于当前家谱。";
errorMessage.value = !initialPersonId
? "没有指定成员,请从世系树重新选择。"
: "当前家谱上下文无效,请重新进入。";
return;
}
member.value = { ...memberFixtures[personId.value] };
memberState.value = "detail";
void initializeMemberTrail(initialPersonId).then((loaded) => {
if (loaded) return;
member.value = null;
memberState.value = "error";
});
});
onShow(() => {
const result = consumeNavigationResult("T03");
if (result?.operation === "member-open-requested" && result.entityId) {
void openRelative(result.entityId);
}
});
onBackPress((event) => handleBackPress(event, requestBack));
const toEdit = () => {
if (!canEdit.value) { openMemberState("forbidden"); return; }
uni.navigateTo({ url: `/pages/tree/t05-edit-member?genealogyId=${genealogyId.value}&personId=${personId.value}` });
if (!canPreviewEdit.value) return false;
return openPage("T05", { genealogyId: genealogyId.value, personId: personId.value }, "T03");
};
const openMemberState = (state) =>
uni.navigateTo({
url: `/pages/tree/t08-member-states?genealogyId=${genealogyId.value}&personId=${personId.value}&state=${state}`,
});
const openRelative = (relative) =>
uni.navigateTo({ url: `/pages/tree/t03-member-profile?genealogyId=${genealogyId.value}&personId=${relative.id}` });
const toTree = () => uni.navigateBack();
const openMemberState = () =>
openPage("T08", { genealogyId: genealogyId.value, personId: personId.value }, "T03");
const toGrowthJournal = () =>
openPage("R08", { genealogyId: genealogyId.value, personId: personId.value }, "T03");
const toLifeEvents = () =>
openPage("R09", { genealogyId: genealogyId.value, personId: personId.value }, "T03");
const toTree = requestBack;
</script>
<style scoped lang="scss">
@@ -160,8 +291,17 @@ const toTree = () => uni.navigateBack();
.member-relatives > view { display: flex; justify-content: space-between; gap: 20rpx; padding: 12rpx 18rpx; color: $ink; font-size: 23rpx; }
.member-relatives > view text:last-child { color: $brand-red; }
.member-relatives > text { display: block; color: $ink-muted; font-size: 23rpx; line-height: 1.5; }
.member-record-actions { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12rpx; margin-top: 20rpx; }
.member-record-actions > view { @include adaptive.adaptive-scroll-button(secondary); display: flex; min-height: 72rpx; align-items: center; justify-content: center; padding: 8rpx 14rpx; text-align: center; }
.member-record-actions text { color: $brand-red; font-size: 22rpx; font-weight: 700; line-height: 1.35; }
.member-profile-action { @include adaptive.adaptive-scroll-button(primary); display: flex; min-height: 76rpx; align-items: center; justify-content: center; margin-top: 22rpx; }
.member-profile-action text { color: #fff9ed; font-size: 24rpx; font-weight: 700; }
.member-restricted { padding-top: 14rpx; }
.member-restricted > text { display: block; color: $ink-muted; font-size: 23rpx; line-height: 1.6; text-align: center; }
.member-restricted > text:first-of-type { margin-top: 28rpx; color: $ink; font-family: "STKaiti", "KaiTi", serif; font-size: 31rpx; font-weight: 700; }
.member-restricted > text + text { margin-top: 12rpx; }
.member-restricted-action { @include adaptive.adaptive-scroll-button(secondary); display: flex; min-height: 76rpx; align-items: center; justify-content: center; margin-top: 22rpx; }
.member-restricted-action text { color: $brand-red; font-size: 24rpx; font-weight: 700; }
.member-error { margin-top: 30%; text-align: center; }
.member-error > text { display: block; color: $ink-muted; font-size: 24rpx; line-height: 1.55; }
.member-error > text:nth-child(2) { font-size: 24rpx; }
+78 -52
View File
@@ -4,13 +4,13 @@
class="add-relative-page"
:class="{
'add-state--form': addState === 'form',
'add-state--success': addState === 'success',
'add-state--preview': addState === 'preview',
'add-state--error': addState === 'error',
}"
>
<ModulePageBackground module="tree" />
<view class="add-relative-page__header">
<PageHeader :title="isFirstMember ? '录入首位成员' : '新增亲属'" />
<PageHeader :title="isFirstMember ? '录入首位成员' : '新增亲属'" custom-back @back="requestBack" />
</view>
<view class="add-relative-panel">
@@ -75,17 +75,17 @@
<text class="form-note">{{ formNote }}</text>
<view class="form-action" @click="submitAdd">
<image src="/static/assets/foundation/transparent/a01-scroll-primary-v3.png" mode="scaleToFill" />
<text>{{ isSubmitting ? "正在保存…" : isFirstMember ? "保存首位成员" : "保存亲属" }}</text>
<text>{{ isSubmitting ? "正在校验…" : "生成本地预览" }}</text>
</view>
</view>
<view v-else class="add-result">
<text class="form-eyebrow">{{ addState === "success" ? "世系资料已更新" : "成员未保存" }}</text>
<text class="form-title">{{ addState === "success" ? successTitle : "暂时无法保存成员" }}</text>
<text class="form-copy">{{ addState === "success" ? successCopy : "当前填写内容仍保留,可返回修改后重试。" }}</text>
<view class="form-action" @click="addState === 'success' ? returnToTree() : retryForm()">
<text class="form-eyebrow">{{ addState === "preview" ? "本地流程预览" : hasValidContext ? "成员未保存" : "成员入口无效" }}</text>
<text class="form-title">{{ addState === "preview" ? previewTitle : hasValidContext ? "暂时无法校验成员" : "没有找到要关联的成员" }}</text>
<text class="form-copy">{{ addState === "preview" ? previewCopy : hasValidContext ? "当前填写内容仍保留,可返回修改后重试。" : "请从世系树重新进入,页面不会创建临时成员身份。" }}</text>
<view class="form-action" @click="addState === 'preview' ? returnToTree() : retryForm()">
<image src="/static/assets/foundation/transparent/a01-scroll-primary-v3.png" mode="scaleToFill" />
<text>{{ addState === "success" ? "返回世系树" : "返回修改" }}</text>
<text>{{ addState === "preview" ? "返回世系树(不保存)" : hasValidContext ? "返回修改" : "返回上一页" }}</text>
</view>
</view>
</view>
@@ -98,9 +98,9 @@
cancel-text="继续填写"
confirm-text="放弃并返回"
show-cancel
@close="discardDialogVisible = false"
@cancel="discardDialogVisible = false"
@confirm="discardAndBack"
:close-on-mask="false"
@cancel="cancelDiscard"
@confirm="confirmDiscard"
/>
</view>
</template>
@@ -111,7 +111,14 @@ import { onBackPress, onLoad, onUnload } from "@dcloudio/uni-app";
import AppDialog from "@/components/AppDialog.vue";
import ModulePageBackground from "@/components/ModulePageBackground.vue";
import PageHeader from "@/components/PageHeader.vue";
import { genealogyContext } from "@/utils/genealogy-context.js";
import { findTreeMemberFixture } from "@/data/mock.js";
import { createDiscardConfirmation } from "@/utils/discard-confirmation.js";
import {
goBack,
handleBackPress,
returnTo,
runBackGuard,
} from "@/utils/navigation.js";
const addState = ref("form");
const genealogyId = ref("");
@@ -121,21 +128,21 @@ const isSubmitting = ref(false);
const discardDialogVisible = ref(false);
let submitTimer = null;
const memberFixtures = {
101: { id: 101, name: "汤文远", generation: 12 },
102: { id: 102, name: "汤正国", generation: 13 },
103: { id: 103, name: "汤正华", generation: 13 },
};
const relationOptions = ["长子", "次子", "女儿", "配偶", "兄弟", "姐妹"];
const genderOptions = ["男", "女", "未说明"];
const addForm = reactive({ name: "", relation: "", gender: "", birthDate: "", summary: "" });
const fieldErrors = reactive({ name: "", relation: "", gender: "" });
const isFirstMember = computed(
() => mode.value === "first" || (!personId.value && mode.value !== "relative"),
const isFirstMember = computed(() => mode.value === "first");
const currentMember = computed(() =>
isFirstMember.value
? null
: findTreeMemberFixture(genealogyId.value, personId.value),
);
const currentMember = computed(
() => memberFixtures[personId.value] || { id: personId.value, name: "当前成员", generation: "待确认" },
const hasValidContext = computed(
() =>
Boolean(genealogyId.value) &&
(isFirstMember.value ? !personId.value : Boolean(currentMember.value)),
);
const relationIndex = computed(() => Math.max(0, relationOptions.indexOf(addForm.relation)));
const genderIndex = computed(() => Math.max(0, genderOptions.indexOf(addForm.gender)));
@@ -149,43 +156,54 @@ const formCopy = computed(() =>
);
const formNote = computed(() =>
isFirstMember.value
? "保存后进入世系树,并可继续为首位成员添加亲属。"
: "保存后成员会出现在相应世代;详细生平可在成员档案中继续完善。",
? "当前阶段只校验首位成员资料,不会修改世系树。"
: "当前阶段只校验亲属资料,不会新增成员或改变世系关系。",
);
const successTitle = computed(() =>
isFirstMember.value ? `${addForm.name}已成为世系起点` : `${addForm.name}已加入世系`,
const previewTitle = computed(() =>
isFirstMember.value ? `${addForm.name}可作为世系起点` : `${addForm.name}的亲属资料已通过本地校验`,
);
const successCopy = computed(() =>
isFirstMember.value
? "首位成员已保存,世系树现在可以继续向下补充。"
: `${addForm.relation}关系已记录,返回后可查看新的成员节点。`,
const previewCopy = computed(() =>
`本地预览尚未提交服务器,返回后不会保存${isFirstMember.value ? "首位成员" : `${addForm.relation}关系`},世系树也不会变化。`,
);
const hasDraft = computed(() =>
Object.values(addForm).some((value) => String(value).trim()),
);
const discardConfirmation = createDiscardConfirmation((visible) => {
discardDialogVisible.value = visible;
});
const requestDiscardConfirmation = discardConfirmation.request;
const confirmDiscard = discardConfirmation.confirm;
const cancelDiscard = discardConfirmation.cancel;
onLoad((query) => {
genealogyId.value = query.genealogyId || genealogyContext.getCurrentGenealogyId() || "";
personId.value = query.personId || "";
genealogyId.value = String(query.genealogyId || "");
personId.value = String(query.personId || "");
mode.value = query.mode === "first" ? "first" : "relative";
if (genealogyId.value) genealogyContext.setCurrentGenealogyId(genealogyId.value);
addState.value = query.state === "success" ? "success" : query.state === "error" ? "error" : "form";
addState.value = !hasValidContext.value || query.state === "error"
? "error"
: query.state === "preview"
? "preview"
: "form";
});
onUnload(() => {
if (submitTimer) clearTimeout(submitTimer);
});
onBackPress(() => {
if (discardDialogVisible.value) {
discardDialogVisible.value = false;
return true;
}
if (addState.value === "form" && hasDraft.value) {
discardDialogVisible.value = true;
return true;
}
return false;
const timer = submitTimer;
submitTimer = null;
if (timer) clearTimeout(timer);
discardConfirmation.dispose();
});
const requestBack = () =>
runBackGuard({
transientOpen: discardDialogVisible.value,
dirty: (addState.value === "form" || addState.value === "preview") && hasDraft.value,
submitting: isSubmitting.value,
"close-transient": cancelDiscard,
"block-submitting": () => true,
"confirm-discard": requestDiscardConfirmation,
});
onBackPress((event) => handleBackPress(event, requestBack));
const clearError = (field) => { fieldErrors[field] = ""; };
const selectRelation = (event) => {
addForm.relation = relationOptions[Number(event.detail.value)] || "";
@@ -204,18 +222,26 @@ const validateAddForm = () => {
};
const submitAdd = () => {
if (isSubmitting.value || !validateAddForm()) return;
const submitSnapshot = Object.freeze({ ...addForm });
isSubmitting.value = true;
submitTimer = setTimeout(() => {
addState.value = addForm.name.trim() === "失败" ? "error" : "success";
const timer = setTimeout(() => {
if (submitTimer !== timer) return;
addState.value = submitSnapshot.name.trim() === "失败" ? "error" : "preview";
isSubmitting.value = false;
submitTimer = null;
}, 280);
submitTimer = timer;
};
const retryForm = () => { addState.value = "form"; };
const returnToTree = () => uni.navigateBack();
const discardAndBack = () => {
discardDialogVisible.value = false;
uni.navigateBack();
const retryForm = () => {
if (!hasValidContext.value) return goBack();
addState.value = "form";
};
const returnToTree = async () => {
const confirmed = hasDraft.value
? await requestDiscardConfirmation()
: true;
if (!confirmed) return false;
return returnTo("T01", { genealogyId: genealogyId.value });
};
</script>
+97 -44
View File
@@ -4,13 +4,13 @@
class="edit-member-page"
:class="{
'edit-state--form': editState === 'form',
'edit-state--success': editState === 'success',
'edit-state--preview': editState === 'preview',
'edit-state--error': editState === 'error',
'edit-state--no-permission': editState === 'no-permission',
}"
>
<ModulePageBackground module="tree" />
<view class="edit-member-page__header"><PageHeader title="编辑成员" /></view>
<view class="edit-member-page__header"><PageHeader title="编辑成员" custom-back @back="requestBack" /></view>
<view class="edit-member-panel">
<view v-if="editState === 'form'" class="edit-member-form">
<text class="form-eyebrow">成员档案维护</text>
@@ -45,7 +45,7 @@
<text class="form-note">隐私字段只向本人和具备维护权限的家谱管理员展示</text>
<view class="form-action" @click="saveMember">
<image src="/static/assets/foundation/transparent/a01-scroll-primary-v3.png" mode="scaleToFill" />
<text>{{ isSubmitting ? "正在保存…" : "保存资料" }}</text>
<text>{{ isSubmitting ? "正在校验…" : "生成本地预览" }}</text>
</view>
</view>
@@ -68,9 +68,9 @@
cancel-text="继续编辑"
confirm-text="放弃修改"
show-cancel
@close="discardDialogVisible = false"
@cancel="discardDialogVisible = false"
@confirm="discardAndBack"
:close-on-mask="false"
@cancel="cancelDiscard"
@confirm="confirmDiscard"
/>
</view>
</template>
@@ -81,7 +81,13 @@ import { onBackPress, onLoad, onUnload } from "@dcloudio/uni-app";
import AppDialog from "@/components/AppDialog.vue";
import ModulePageBackground from "@/components/ModulePageBackground.vue";
import PageHeader from "@/components/PageHeader.vue";
import { genealogyContext } from "@/utils/genealogy-context.js";
import { findTreeMemberPresentationFixture } from "@/data/mock.js";
import { createDiscardConfirmation } from "@/utils/discard-confirmation.js";
import {
goBack,
handleBackPress,
runBackGuard,
} from "@/utils/navigation.js";
const editState = ref("form");
const genealogyId = ref("");
@@ -90,54 +96,93 @@ const isSubmitting = ref(false);
const discardDialogVisible = ref(false);
let submitTimer = null;
const memberFixtures = {
101: { id: 101, name: "汤文远", generation: 12, generationName: "文字辈", branch: "主支", birthDate: "1940-03-01", deathDate: "2012-08-16", summary: "一生敦亲睦族,参与整理家族旧谱。" },
102: { id: 102, name: "汤正国", generation: 13, generationName: "正字辈", branch: "长房", birthDate: "1965-05-12", deathDate: "", summary: "负责长房资料核对。" },
103: { id: 103, name: "汤正华", generation: 13, generationName: "正字辈", branch: "二房", birthDate: "1968-09-03", deathDate: "", summary: "资料仍在补充。" },
};
const fallbackMember = { id: "", name: "当前成员", generation: "待确认", generationName: "", branch: "待确认", birthDate: "", deathDate: "", summary: "" };
const originalMember = ref({ ...fallbackMember });
const originalMember = ref(null);
const baseline = ref("");
const editForm = reactive({ name: "", generationName: "", birthDate: "", deathDate: "", summary: "" });
const fieldErrors = reactive({ name: "", dates: "" });
const formSnapshot = computed(() => JSON.stringify(editForm));
const hasValidContext = computed(() =>
Boolean(genealogyId.value && personId.value && originalMember.value),
);
const isDirty = computed(
() => editState.value === "form" && baseline.value && formSnapshot.value !== baseline.value,
() =>
(editState.value === "form" || editState.value === "preview") &&
Boolean(baseline.value) &&
formSnapshot.value !== baseline.value,
);
const resultCopy = computed(() => ({
success: { eyebrow: "成员资料已更新", title: `${editForm.name}档案已保存`, copy: "返回成员档案后可以查看本次修改。", action: "返回成员档案" },
error: { eyebrow: "资料未保存", title: "暂时无法保存成员档案", copy: "当前修改仍保留,可返回表单后重试。", action: "返回修改" },
preview: { eyebrow: "本地流程预览", title: `${editForm.name}资料已通过本地校验`, copy: "尚未提交服务器,返回成员档案后不会显示本次修改。", action: "返回成员档案(不保存)" },
error: hasValidContext.value
? { eyebrow: "资料未保存", title: "暂时无法保存成员档案", copy: "当前修改仍保留,可返回表单后重试。", action: "返回修改" }
: { eyebrow: "成员入口无效", title: "没有找到要编辑的成员", copy: "请从成员档案重新进入,页面不会创建临时成员身份。", action: "返回上一页" },
"no-permission": { eyebrow: "权限不足", title: "当前账号不能编辑这位成员", copy: "本人或具备成员维护权限的家谱管理员才能修改档案。", action: "返回成员档案" },
}[editState.value] || {}));
const discardConfirmation = createDiscardConfirmation((visible) => {
discardDialogVisible.value = visible;
});
const requestDiscardConfirmation = discardConfirmation.request;
const confirmDiscard = discardConfirmation.confirm;
const cancelDiscard = discardConfirmation.cancel;
const loadMember = (id) => {
const member = memberFixtures[id] || { ...fallbackMember, id, name: "待核实成员" };
const member = findTreeMemberPresentationFixture(genealogyId.value, id);
if (!member) return false;
originalMember.value = { ...member };
Object.assign(editForm, {
name: member.name,
generationName: member.generationName,
birthDate: member.birthDate,
deathDate: member.deathDate,
summary: member.summary,
});
if (["privacy", "forbidden"].includes(member.status)) {
Object.assign(editForm, {
name: member.name,
generationName: "",
birthDate: "",
deathDate: "",
summary: "",
});
} else {
Object.assign(editForm, {
name: member.name,
generationName: member.generationName || "",
birthDate: member.birthDate || "",
deathDate: member.deathDate || "",
summary: member.summary || "",
});
}
baseline.value = formSnapshot.value;
return true;
};
onLoad((query) => {
genealogyId.value = query.genealogyId || genealogyContext.getCurrentGenealogyId() || "";
personId.value = query.personId || "";
if (genealogyId.value) genealogyContext.setCurrentGenealogyId(genealogyId.value);
loadMember(personId.value);
editState.value = query.state === "success" ? "success" : query.state === "error" ? "error" : query.state === "no-permission" ? "no-permission" : !personId.value ? "error" : "form";
genealogyId.value = String(query.genealogyId || "");
personId.value = String(query.personId || "");
const loaded = Boolean(genealogyId.value && personId.value) && loadMember(personId.value);
editState.value = !loaded
? "error"
: ["privacy", "forbidden"].includes(originalMember.value.status)
? "no-permission"
: query.state === "preview"
? "preview"
: query.state === "error"
? "error"
: "form";
});
onUnload(() => { if (submitTimer) clearTimeout(submitTimer); });
onBackPress(() => {
if (discardDialogVisible.value) { discardDialogVisible.value = false; return true; }
if (isDirty.value) { discardDialogVisible.value = true; return true; }
return false;
onUnload(() => {
const timer = submitTimer;
submitTimer = null;
if (timer) clearTimeout(timer);
discardConfirmation.dispose();
});
const requestBack = () =>
runBackGuard({
transientOpen: discardDialogVisible.value,
dirty: isDirty.value,
submitting: isSubmitting.value,
"close-transient": cancelDiscard,
"block-submitting": () => true,
"confirm-discard": requestDiscardConfirmation,
});
onBackPress((event) => handleBackPress(event, requestBack));
const clearError = (field) => { fieldErrors[field] = ""; };
const selectDate = (field, event) => {
editForm[field] = event.detail.value || "";
@@ -150,21 +195,29 @@ const validateEditForm = () => {
};
const saveMember = () => {
if (isSubmitting.value || !validateEditForm()) return;
const submitSnapshot = Object.freeze({ ...editForm });
isSubmitting.value = true;
submitTimer = setTimeout(() => {
editState.value = editForm.name.trim() === "失败" ? "error" : "success";
if (editState.value === "success") baseline.value = formSnapshot.value;
const timer = setTimeout(() => {
if (submitTimer !== timer) return;
editState.value = submitSnapshot.name.trim() === "失败" ? "error" : "preview";
isSubmitting.value = false;
submitTimer = null;
}, 280);
submitTimer = timer;
};
const returnToMember = async () => {
const confirmed = isDirty.value
? await requestDiscardConfirmation()
: true;
if (!confirmed) return false;
return goBack();
};
const handleResultAction = () => {
if (editState.value === "error") { editState.value = "form"; return; }
uni.navigateBack();
};
const discardAndBack = () => {
discardDialogVisible.value = false;
uni.navigateBack();
if (editState.value === "error" && hasValidContext.value) {
editState.value = "form";
return;
}
return returnToMember();
};
</script>
+105 -31
View File
@@ -4,13 +4,13 @@
class="relationship-page"
:class="{
'relationship-state--form': relationshipState === 'form',
'relationship-state--success': relationshipState === 'success',
'relationship-state--preview': relationshipState === 'preview',
'relationship-state--conflict': relationshipState === 'conflict',
'relationship-state--error': relationshipState === 'error',
}"
>
<ModulePageBackground module="tree" />
<view class="relationship-page__header"><PageHeader title="关系维护" /></view>
<view class="relationship-page__header"><PageHeader title="关系维护" custom-back @back="requestBack" /></view>
<view class="relationship-panel">
<view v-if="relationshipState === 'form'" class="relationship-form">
<text class="form-eyebrow">亲属关系校正</text>
@@ -40,7 +40,7 @@
<text class="form-note">父母子女关系会改变世系位置配偶和兄弟姐妹关系不会自动改写现有父母</text>
<view class="form-action" @click="saveRelationship">
<image src="/static/assets/foundation/transparent/a01-scroll-primary-v3.png" mode="scaleToFill" />
<text>{{ isSubmitting ? "正在校验…" : "校验并保存关系" }}</text>
<text>{{ isSubmitting ? "正在校验…" : "生成本地预览" }}</text>
</view>
</view>
@@ -58,7 +58,7 @@
</view>
<view v-else class="form-action" @click="handleResultAction">
<image src="/static/assets/foundation/transparent/a01-scroll-primary-v3.png" mode="scaleToFill" />
<text>{{ relationshipState === "success" ? "返回世系树" : "重新选择" }}</text>
<text>{{ relationshipState === "preview" ? "返回世系树(不保存)" : hasValidContext ? "重新选择" : "返回上一页" }}</text>
</view>
</view>
</view>
@@ -68,42 +68,60 @@
eyebrow="关系校验规则"
title="为什么不能保存这段关系"
:message="conflictReason || '同一成员不能成为自己的亲属,也不能形成上下代循环或重复父母关系。'"
:close-on-mask="false"
@confirm="conflictDialogVisible = false"
@close="conflictDialogVisible = false"
/>
<AppDialog
:visible="discardDialogVisible"
eyebrow="关系尚未保存"
title="要放弃本次关系调整吗"
message="当前关系只在本页预览,确认返回后不会修改世系。"
cancel-text="继续核对"
confirm-text="放弃并返回"
show-cancel
:close-on-mask="false"
@cancel="cancelDiscard"
@confirm="confirmDiscard"
/>
</view>
</template>
<script setup>
import { computed, reactive, ref } from "vue";
import { onLoad, onUnload } from "@dcloudio/uni-app";
import { onBackPress, onLoad, onUnload } from "@dcloudio/uni-app";
import AppDialog from "@/components/AppDialog.vue";
import ModulePageBackground from "@/components/ModulePageBackground.vue";
import PageHeader from "@/components/PageHeader.vue";
import { genealogyContext } from "@/utils/genealogy-context.js";
import { listTreeMemberFixtures } from "@/data/mock.js";
import { createDiscardConfirmation } from "@/utils/discard-confirmation.js";
import {
goBack,
handleBackPress,
returnTo,
runBackGuard,
} from "@/utils/navigation.js";
const relationshipState = ref("form");
const genealogyId = ref("");
const personId = ref("");
const isSubmitting = ref(false);
const conflictDialogVisible = ref(false);
const discardDialogVisible = ref(false);
const conflictReason = ref("");
let submitTimer = null;
const memberOptions = [
{ id: "101", name: "汤文远", generation: 12, parentId: "" },
{ id: "102", name: "汤正国", generation: 13, parentId: "101" },
{ id: "103", name: "汤正华", generation: 13, parentId: "101" },
{ id: "104", name: "汤凯", generation: 14, parentId: "102" },
{ id: "105", name: "汤悦", generation: 14, parentId: "102" },
];
const memberOptions = computed(() => listTreeMemberFixtures(genealogyId.value));
const relationshipOptions = ["父子(当前成员为父)", "父女(当前成员为父)", "母子(当前成员为母)", "母女(当前成员为母)", "配偶", "兄弟姐妹"];
const relationshipForm = reactive({ sourceId: "", targetId: "", relationship: "" });
const fieldErrors = reactive({ sourceId: "", targetId: "", relationship: "" });
const memberLabels = computed(() => memberOptions.map((item) => `${item.name} · 第 ${item.generation}`));
const memberById = computed(() => new Map(memberOptions.map((item) => [item.id, item])));
const sourceIndex = computed(() => Math.max(0, memberOptions.findIndex((item) => item.id === relationshipForm.sourceId)));
const targetIndex = computed(() => Math.max(0, memberOptions.findIndex((item) => item.id === relationshipForm.targetId)));
const baseline = ref("");
const memberLabels = computed(() => memberOptions.value.map((item) => `${item.name} · 第 ${item.generation}`));
const memberById = computed(() => new Map(memberOptions.value.map((item) => [item.id, item])));
const hasValidContext = computed(() =>
Boolean(genealogyId.value && memberById.value.has(personId.value)),
);
const sourceIndex = computed(() => Math.max(0, memberOptions.value.findIndex((item) => item.id === relationshipForm.sourceId)));
const targetIndex = computed(() => Math.max(0, memberOptions.value.findIndex((item) => item.id === relationshipForm.targetId)));
const relationshipIndex = computed(() => Math.max(0, relationshipOptions.indexOf(relationshipForm.relationship)));
const memberName = (id) => memberById.value.get(String(id))?.name || "";
const relationshipPreview = computed(
@@ -111,25 +129,67 @@ const relationshipPreview = computed(
? `${memberName(relationshipForm.sourceId)}将以“${relationshipForm.relationship}”关联${memberName(relationshipForm.targetId)}`
: "完成三项选择后,这里会说明世系位置将如何变化。",
);
const formSnapshot = computed(() => JSON.stringify(relationshipForm));
const isDirty = computed(() =>
Boolean(baseline.value) && formSnapshot.value !== baseline.value,
);
const resultCopy = computed(() => ({
success: { eyebrow: "关系已保存", title: "世系关系已经更新", copy: relationshipPreview.value },
preview: { eyebrow: "本地流程预览", title: "关系校验已经通过", copy: `${relationshipPreview.value} 尚未提交服务器,返回后世系不会变化。` },
conflict: { eyebrow: "发现关系冲突", title: "这段关系会造成世系矛盾", copy: conflictReason.value },
error: { eyebrow: "关系未保存", title: "暂时无法完成关系调整", copy: "当前选择仍然保留,可返回后重新校验。" },
error: hasValidContext.value
? { eyebrow: "关系未保存", title: "暂时无法完成关系调整", copy: "当前选择仍然保留,可返回后重新校验。" }
: { eyebrow: "成员入口无效", title: "没有找到要调整的成员", copy: "请从世系树重新进入,页面不会回退到其他成员。" },
}[relationshipState.value] || {}));
const discardConfirmation = createDiscardConfirmation((visible) => {
discardDialogVisible.value = visible;
});
const requestDiscardConfirmation = discardConfirmation.request;
const confirmDiscard = discardConfirmation.confirm;
const cancelDiscard = discardConfirmation.cancel;
onLoad((query) => {
genealogyId.value = query.genealogyId || genealogyContext.getCurrentGenealogyId() || "";
personId.value = query.personId || "";
relationshipForm.sourceId = personId.value && memberById.value.has(String(personId.value)) ? String(personId.value) : memberOptions[0].id;
if (genealogyId.value) genealogyContext.setCurrentGenealogyId(genealogyId.value);
relationshipState.value = query.state === "success" ? "success" : query.state === "conflict" ? "conflict" : query.state === "error" ? "error" : "form";
genealogyId.value = String(query.genealogyId || "");
personId.value = String(query.personId || "");
relationshipForm.sourceId = hasValidContext.value ? personId.value : "";
baseline.value = formSnapshot.value;
relationshipState.value = !hasValidContext.value || query.state === "error"
? "error"
: query.state === "preview"
? "preview"
: query.state === "conflict"
? "conflict"
: "form";
if (relationshipState.value === "conflict") conflictReason.value = "目标成员已经存在父级关系,请先核对原关系。";
});
onUnload(() => { if (submitTimer) clearTimeout(submitTimer); });
onUnload(() => {
const timer = submitTimer;
submitTimer = null;
if (timer) clearTimeout(timer);
discardConfirmation.dispose();
});
const closeTransient = () => {
if (conflictDialogVisible.value) {
conflictDialogVisible.value = false;
return;
}
cancelDiscard();
};
const requestBack = () =>
runBackGuard({
transientOpen: conflictDialogVisible.value || discardDialogVisible.value,
dirty: isDirty.value,
submitting: isSubmitting.value,
"close-transient": closeTransient,
"block-submitting": () => true,
"confirm-discard": requestDiscardConfirmation,
});
onBackPress((event) => handleBackPress(event, requestBack));
const clearFieldError = (field) => { fieldErrors[field] = ""; };
const selectMember = (field, event) => {
relationshipForm[field] = memberOptions[Number(event.detail.value)]?.id || "";
relationshipForm[field] = memberOptions.value[Number(event.detail.value)]?.id || "";
clearFieldError(field);
};
const selectRelationship = (event) => {
@@ -174,16 +234,30 @@ const saveRelationship = () => {
if (conflictReason.value) relationshipState.value = "conflict";
return;
}
const submitSnapshot = Object.freeze({ ...relationshipForm });
isSubmitting.value = true;
submitTimer = setTimeout(() => {
relationshipState.value = relationshipForm.relationship === "失败" ? "error" : "success";
const timer = setTimeout(() => {
if (submitTimer !== timer) return;
relationshipState.value = submitSnapshot.relationship === "失败" ? "error" : "preview";
isSubmitting.value = false;
submitTimer = null;
}, 280);
submitTimer = timer;
};
const returnToTree = async () => {
const confirmed = isDirty.value
? await requestDiscardConfirmation()
: true;
if (!confirmed) return false;
return returnTo("T01", { genealogyId: genealogyId.value });
};
const handleResultAction = () => {
if (relationshipState.value === "error") { relationshipState.value = "form"; return; }
uni.navigateBack();
if (relationshipState.value === "error") {
if (!hasValidContext.value) return goBack();
relationshipState.value = "form";
return;
}
return returnToTree();
};
</script>
+34 -44
View File
@@ -11,7 +11,7 @@
>
<ModulePageBackground module="tree" />
<view class="directory-page__header"><PageHeader title="成员目录" /></view>
<view class="directory-context">
<view v-if="hasValidContext" class="directory-context">
<text class="directory-context__name">汤氏家谱</text>
<text class="directory-context__meta"
>主支 · {{ members.length }} 位成员</text
@@ -24,7 +24,7 @@
<input
v-model="keyword"
aria-label="成员搜索关键词"
placeholder="按姓名、字辈或支系查找"
placeholder="按姓名、身份或世代查找"
placeholder-class="directory-placeholder"
@confirm="searchMembers"
/>
@@ -56,11 +56,8 @@
>
<view class="directory-card__copy">
<text class="directory-card__name">{{ item.name }}</text>
<text class="directory-card__meta"
> {{ item.generation }} · {{ item.generationName }} ·
{{ item.branch }}</text
>
<text class="directory-card__status">{{ item.note }}</text>
<text class="directory-card__meta">{{ memberMeta(item) }}</text>
<text class="directory-card__status">{{ memberStatus(item) }}</text>
</view>
</view>
</template>
@@ -71,7 +68,7 @@
}}</text
><text>{{
directoryState === "empty"
? "换一个姓名、字辈或支系关键词再试。"
? "换一个姓名、身份或世代关键词再试。"
: "请从当前家谱重新进入,成员资料不会受到影响。"
}}</text></view
>
@@ -80,7 +77,7 @@
v-if="directoryState === 'error'"
block
type="secondary"
label="重新查看"
:label="hasValidContext ? '重新查看' : '返回上一页'"
@click="retryDirectory"
/>
</view>
@@ -93,63 +90,56 @@ import AppButton from "@/components/AppButton.vue";
import AppLoading from "@/components/AppLoading.vue";
import ModulePageBackground from "@/components/ModulePageBackground.vue";
import PageHeader from "@/components/PageHeader.vue";
import { listTreeMemberPresentationFixtures } from "@/data/mock.js";
import { goBack, openPage } from "@/utils/navigation.js";
const genealogyId = ref("");
const keyword = ref("");
const directoryState = ref("loading");
const members = [
{
id: 101,
name: "汤文远",
generation: 12,
generationName: "文字辈",
branch: "主支",
note: "始祖 · 档案完整",
},
{
id: 102,
name: "汤正国",
generation: 13,
generationName: "正字辈",
branch: "长房",
note: "家谱管理员",
},
{
id: 103,
name: "汤正华",
generation: 13,
generationName: "正字辈",
branch: "二房",
note: "资料待补充",
},
];
const hasValidContext = computed(() => Boolean(genealogyId.value));
const members = computed(() => listTreeMemberPresentationFixtures(genealogyId.value));
const isRestrictedMember = (member) =>
["privacy", "forbidden"].includes(member.status);
const memberMeta = (member) =>
isRestrictedMember(member)
? `${member.generation} 世 · ${member.relation}`
: `${member.generation} 世 · ${member.generationName} · ${member.branch}`;
const memberStatus = (member) =>
isRestrictedMember(member)
? member.status === "forbidden"
? "访问受限"
: "隐私资料"
: member.note;
const filteredMembers = computed(() => {
const value = keyword.value.trim();
if (!value) return members;
return members.filter((item) =>
`${item.name}${item.generationName}${item.branch}`.includes(value),
if (!value) return members.value;
return members.value.filter((item) =>
`${item.name}${item.relation}${item.generation}${item.generationName || ""}${item.branch || ""}`.includes(value),
);
});
onLoad((query) => {
genealogyId.value = query.genealogyId || "";
genealogyId.value = String(query.genealogyId || "");
directoryState.value =
query.state === "loading"
!hasValidContext.value ? "error" : query.state === "loading"
? "loading"
: query.state === "empty"
? "empty"
: query.state === "error"
? "error"
: "list";
: members.value.length
? "list"
: "empty";
});
const searchMembers = () => {
directoryState.value = filteredMembers.value.length ? "list" : "empty";
};
const retryDirectory = () => {
if (!hasValidContext.value) return goBack();
directoryState.value = "list";
};
const openMember = (item) =>
uni.navigateTo({
url: `/pages/tree/t03-member-profile?genealogyId=${genealogyId.value}&personId=${item.id}`,
});
hasValidContext.value
? openPage("T03", { genealogyId: genealogyId.value, personId: String(item.id) }, "T07")
: Promise.resolve(false);
</script>
<style scoped lang="scss">
@use "../../styles/adaptive-frame-profiles.scss" as adaptive;
+24 -17
View File
@@ -12,9 +12,9 @@
<ModulePageBackground module="tree" />
<view class="member-status-page__header"><PageHeader :title="pageTitle" /></view>
<view class="member-status-context">
<view v-if="hasValidContext" class="member-status-context">
<text>{{ genealogyName }}</text>
<text v-if="member">{{ member.name }} · {{ member.generation }} · {{ member.branch }}</text>
<text v-if="member">{{ memberIdentityCopy }}</text>
<text v-else>未找到成员身份</text>
</view>
@@ -45,7 +45,8 @@ import { computed, ref } from "vue";
import { onLoad } from "@dcloudio/uni-app";
import ModulePageBackground from "@/components/ModulePageBackground.vue";
import PageHeader from "@/components/PageHeader.vue";
import { genealogyContext } from "@/utils/genealogy-context.js";
import { findTreeMemberPresentationFixture } from "@/data/mock.js";
import { goBack, goRoot } from "@/utils/navigation.js";
const genealogyId = ref("");
const personId = ref("");
@@ -53,18 +54,13 @@ const statusState = ref("loading");
const genealogyName = ref("汤氏家谱");
const member = ref(null);
const memberFixtures = {
101: { id: 101, name: "汤文远", generation: 12, branch: "主支", allowedStates: ["deceased"] },
102: { id: 102, name: "汤正国", generation: 13, branch: "长房", allowedStates: ["privacy"] },
103: { id: 103, name: "汤正华", generation: 13, branch: "二房", allowedStates: ["forbidden"] },
};
const states = {
privacy: {
eyebrow: "隐私成员",
title: "敏感资料只向授权成员展示",
copy: "该成员姓名和世系位置仍然保留,出生信息、联系方式和生平内容按权限隐藏。",
guideTitle: "当前可见范围",
guides: ["姓名、世代与所属支系可见", "联系方式和详细生平已隐藏", "本人或谱主可维护授权范围"],
guides: ["姓名、世代与家族关系可见", "联系方式和详细生平已隐藏", "本人或谱主可维护授权范围"],
action: "返回成员档案",
},
deceased: {
@@ -94,22 +90,33 @@ const states = {
};
const activeStatus = computed(() => states[statusState.value] || states.error);
const pageTitle = computed(() => statusState.value === "deceased" ? "成员纪念" : statusState.value === "privacy" ? "隐私资料" : "成员状态");
const hasValidContext = computed(() => Boolean(genealogyId.value && personId.value));
const memberIdentityCopy = computed(() => {
if (!member.value) return "";
const identity = `${member.value.name} · 第 ${member.value.generation}`;
return ["privacy", "forbidden"].includes(member.value.status)
? `${identity} · ${member.value.relation}`
: `${identity} · ${member.value.branch}`;
});
onLoad((query) => {
genealogyId.value = query.genealogyId || genealogyContext.getCurrentGenealogyId() || "";
personId.value = query.personId || "";
if (genealogyId.value) genealogyContext.setCurrentGenealogyId(genealogyId.value);
member.value = memberFixtures[personId.value] || null;
genealogyId.value = String(query.genealogyId || "");
personId.value = String(query.personId || "");
member.value = hasValidContext.value
? findTreeMemberPresentationFixture(genealogyId.value, personId.value)
: null;
const requestedState = ["privacy", "deceased", "forbidden"].includes(query.state) ? query.state : "";
statusState.value = member.value?.allowedStates.includes(requestedState) ? requestedState : "error";
const derivedState = member.value?.status || "";
statusState.value = member.value && (!requestedState || requestedState === derivedState)
? requestedState || derivedState
: "error";
});
const handleAction = () => {
if (statusState.value === "forbidden") {
uni.reLaunch({ url: "/pages/genealogy/g01-my-genealogies" });
return;
return goRoot("G01");
}
uni.navigateBack();
return goBack();
};
</script>