完成50%
This commit is contained in:
+295
-214
@@ -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
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
@@ -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; }
|
||||
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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%;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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)}}
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>建议使用 8–32 位字母与数字组合,不要与其他应用共用。</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: "8–32 位字母与数字" },
|
||||
{ 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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user