完成40%
This commit is contained in:
+172
-43
@@ -13,10 +13,11 @@
|
||||
|
||||
<view class="login-tabs" role="tablist" aria-label="登录方式">
|
||||
<button
|
||||
class="auth-plain-button login-tab login-tab--unavailable"
|
||||
class="auth-plain-button login-tab"
|
||||
role="tab"
|
||||
:aria-selected="activeLoginMethod === 'password'"
|
||||
aria-disabled="true"
|
||||
:class="{ active: activeLoginMethod === 'password' }"
|
||||
:disabled="submitting || sendingCode || tacVisible"
|
||||
hover-class="tap-fade"
|
||||
@click="switchLoginMethod('password')"
|
||||
>密码登录</button
|
||||
@@ -26,6 +27,7 @@
|
||||
role="tab"
|
||||
:aria-selected="activeLoginMethod === 'sms'"
|
||||
:class="{ active: activeLoginMethod === 'sms' }"
|
||||
:disabled="submitting || sendingCode || tacVisible"
|
||||
hover-class="tap-fade"
|
||||
@click="switchLoginMethod('sms')"
|
||||
>验证码登录</button
|
||||
@@ -44,11 +46,12 @@
|
||||
class="auth-input"
|
||||
type="number"
|
||||
maxlength="11"
|
||||
:disabled="sendingCode || cooldownSeconds > 0 || submitting"
|
||||
:disabled="sendingCode || submitting || tacVisible || authenticationCommitted"
|
||||
placeholder="手机号"
|
||||
aria-label="手机号"
|
||||
placeholder-class="input-placeholder"
|
||||
confirm-type="next"
|
||||
@input="handlePhoneInput"
|
||||
/>
|
||||
</view>
|
||||
|
||||
@@ -63,7 +66,7 @@
|
||||
class="auth-input"
|
||||
:password="!passwordVisible"
|
||||
maxlength="32"
|
||||
:disabled="submitting"
|
||||
:disabled="submitting || tacVisible"
|
||||
placeholder="密码"
|
||||
aria-label="登录密码"
|
||||
placeholder-class="input-placeholder"
|
||||
@@ -74,6 +77,7 @@
|
||||
class="auth-plain-button password-toggle"
|
||||
:aria-label="passwordVisible ? '隐藏密码' : '显示密码'"
|
||||
:aria-pressed="passwordVisible"
|
||||
:disabled="submitting || tacVisible"
|
||||
hover-class="tap-fade"
|
||||
@click="togglePasswordVisibility"
|
||||
>
|
||||
@@ -140,7 +144,15 @@
|
||||
src="/static/assets/foundation/transparent/a01-scroll-primary-v3.png"
|
||||
mode="aspectFit"
|
||||
/>
|
||||
<text class="login-submit__copy">{{ submitting ? "登录中…" : "登录" }}</text>
|
||||
<text class="login-submit__copy">{{
|
||||
submitting
|
||||
? authenticationCommitted
|
||||
? "正在进入…"
|
||||
: "登录中…"
|
||||
: authenticationCommitted
|
||||
? "进入家谱"
|
||||
: "登录"
|
||||
}}</text>
|
||||
</button>
|
||||
|
||||
<view class="other-login-divider">
|
||||
@@ -249,7 +261,7 @@
|
||||
|
||||
<script setup>
|
||||
import { ref } from "vue";
|
||||
import { onBackPress, onUnload } from "@dcloudio/uni-app";
|
||||
import { onBackPress, onShow, onUnload } from "@dcloudio/uni-app";
|
||||
import AuthPageShell from "@/components/AuthPageShell.vue";
|
||||
import AppToast from "@/components/AppToast.vue";
|
||||
import TacVerification from "@/components/TacVerification.vue";
|
||||
@@ -260,13 +272,15 @@ import {
|
||||
} from "@/utils/api.js";
|
||||
import {
|
||||
AUTH_TAC_SCENE,
|
||||
PASSWORD_TAC_BLOCKED_MESSAGE,
|
||||
createTacRenderContext,
|
||||
isAuthPhone,
|
||||
isSmsDeliveryOutcomeUnknown,
|
||||
normalizeCaptchaRequirement,
|
||||
normalizeTacSuccess,
|
||||
} from "@/utils/auth-verification.js";
|
||||
import { createAuthSmsCooldown } from "@/utils/auth-sms-cooldown.js";
|
||||
import { runtimeConfig } from "@/utils/config.js";
|
||||
import { calcMD5 } from "@/utils/md5.js";
|
||||
import {
|
||||
goRoot,
|
||||
handleBackPress,
|
||||
@@ -274,7 +288,7 @@ import {
|
||||
runBackGuard,
|
||||
} from "@/utils/navigation.js";
|
||||
|
||||
const activeLoginMethod = ref("sms");
|
||||
const activeLoginMethod = ref("password");
|
||||
const passwordVisible = ref(false);
|
||||
const phone = ref("");
|
||||
const password = ref("");
|
||||
@@ -285,21 +299,32 @@ const tacVisible = ref(false);
|
||||
const tacContext = ref(null);
|
||||
const sendingCode = ref(false);
|
||||
const submitting = ref(false);
|
||||
const authenticationCommitted = ref(false);
|
||||
const cooldownSeconds = ref(0);
|
||||
const sentPhone = ref("");
|
||||
const feedbackVisible = ref(false);
|
||||
const feedbackMessage = ref("");
|
||||
let feedbackTimer = null;
|
||||
let cooldownTimer = null;
|
||||
let tacSequence = 0;
|
||||
let pendingTacAction = null;
|
||||
let pageActive = true;
|
||||
const authenticationNavigationFailure =
|
||||
"登录已完成,但暂时无法进入家谱,请再次点击进入";
|
||||
const authRequestController = createRequestController();
|
||||
const smsCooldown = createAuthSmsCooldown({
|
||||
sceneCode: AUTH_TAC_SCENE.SMS_LOGIN,
|
||||
onChange: (seconds) => {
|
||||
cooldownSeconds.value = seconds;
|
||||
},
|
||||
});
|
||||
|
||||
onUnload(() => {
|
||||
pageActive = false;
|
||||
authRequestController.abort();
|
||||
if (feedbackTimer) clearTimeout(feedbackTimer);
|
||||
if (cooldownTimer) clearInterval(cooldownTimer);
|
||||
smsCooldown.dispose();
|
||||
});
|
||||
onShow(() => smsCooldown.sync());
|
||||
|
||||
const showFeedback = (message) => {
|
||||
feedbackMessage.value = message;
|
||||
@@ -311,6 +336,21 @@ const showFeedback = (message) => {
|
||||
}, 2200);
|
||||
};
|
||||
|
||||
const enterAuthenticatedRoot = async () => {
|
||||
if (!pageActive) return false;
|
||||
submitting.value = true;
|
||||
try {
|
||||
const opened = await goRoot("G01");
|
||||
if (opened !== true) throw new Error("NAVIGATION_RETRY_REQUIRED");
|
||||
return true;
|
||||
} catch {
|
||||
if (pageActive) showFeedback(authenticationNavigationFailure);
|
||||
return false;
|
||||
} finally {
|
||||
if (pageActive) submitting.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const blockBusyAction = () => {
|
||||
if (!sendingCode.value && !submitting.value) return false;
|
||||
showFeedback("请求处理中,请稍候");
|
||||
@@ -319,17 +359,19 @@ const blockBusyAction = () => {
|
||||
|
||||
const switchLoginMethod = (method) => {
|
||||
if (blockBusyAction()) return;
|
||||
if (method === "password") {
|
||||
showFeedback(PASSWORD_TAC_BLOCKED_MESSAGE);
|
||||
return;
|
||||
}
|
||||
if (method === "sms") activeLoginMethod.value = method;
|
||||
if (method === "password" || method === "sms") activeLoginMethod.value = method;
|
||||
};
|
||||
|
||||
const togglePasswordVisibility = () => {
|
||||
passwordVisible.value = !passwordVisible.value;
|
||||
};
|
||||
|
||||
const handlePhoneInput = () => {
|
||||
if (!sentPhone.value || phone.value === sentPhone.value) return;
|
||||
verificationCode.value = "";
|
||||
sentPhone.value = "";
|
||||
};
|
||||
|
||||
const toggleAgreement = () => {
|
||||
agreed.value = !agreed.value;
|
||||
if (agreed.value) agreementError.value = false;
|
||||
@@ -350,6 +392,7 @@ const requireAgreement = () => {
|
||||
const closeTac = () => {
|
||||
tacVisible.value = false;
|
||||
tacContext.value = null;
|
||||
pendingTacAction = null;
|
||||
};
|
||||
|
||||
const cancelPendingRequest = () => {
|
||||
@@ -380,18 +423,6 @@ onBackPress((event) => {
|
||||
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 = async () => {
|
||||
if (sendingCode.value || cooldownSeconds.value > 0) return;
|
||||
if (!validatePhone() || !requireAgreement()) return;
|
||||
@@ -416,6 +447,10 @@ const prepareGetCode = async () => {
|
||||
subject: requestedPhone,
|
||||
requirement,
|
||||
});
|
||||
pendingTacAction = {
|
||||
kind: "sms-code",
|
||||
phone: requestedPhone,
|
||||
};
|
||||
tacVisible.value = true;
|
||||
} catch (error) {
|
||||
if (pageActive && !isRequestCancelled(error)) {
|
||||
@@ -426,12 +461,83 @@ const prepareGetCode = async () => {
|
||||
}
|
||||
};
|
||||
|
||||
const preparePasswordLogin = async () => {
|
||||
const requestedPhone = phone.value;
|
||||
const passwordHash = calcMD5(password.value);
|
||||
submitting.value = true;
|
||||
try {
|
||||
// 产品规则:密码登录也必须先完成滑动验证。当前登录接口不消费
|
||||
// validToken,因此票据只作为本次前端验证成功的完成信号,不写入登录体。
|
||||
const sceneCode = AUTH_TAC_SCENE.SMS_LOGIN;
|
||||
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-password-${tacSequence}`,
|
||||
baseUrl: runtimeConfig.baseUrl,
|
||||
clientId: runtimeConfig.clientId,
|
||||
tenantId: runtimeConfig.tenantId,
|
||||
sceneCode,
|
||||
subject: requestedPhone,
|
||||
requirement,
|
||||
});
|
||||
pendingTacAction = {
|
||||
kind: "password-login",
|
||||
phone: requestedPhone,
|
||||
passwordHash,
|
||||
};
|
||||
tacVisible.value = true;
|
||||
} catch (error) {
|
||||
if (pageActive && !isRequestCancelled(error)) {
|
||||
showFeedback(error.message || "登录失败,请稍后重试");
|
||||
}
|
||||
} finally {
|
||||
if (pageActive) submitting.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const completeTac = async (result) => {
|
||||
const expectedContext = tacContext.value;
|
||||
if (!expectedContext) return;
|
||||
const action = pendingTacAction;
|
||||
if (!expectedContext || !action) return;
|
||||
try {
|
||||
const ticket = normalizeTacSuccess(result, expectedContext.requestId);
|
||||
if (phone.value !== expectedContext.subject) throw new Error("手机号已变化,请重新验证");
|
||||
if (action.kind === "password-login") {
|
||||
if (
|
||||
phone.value !== action.phone ||
|
||||
calcMD5(password.value) !== action.passwordHash
|
||||
) {
|
||||
throw new Error("登录信息已变化,请重新验证");
|
||||
}
|
||||
tacVisible.value = false;
|
||||
tacContext.value = null;
|
||||
pendingTacAction = null;
|
||||
submitting.value = true;
|
||||
await appApi.loginWithPassword(
|
||||
{
|
||||
phone: action.phone,
|
||||
passwordHash: action.passwordHash,
|
||||
},
|
||||
{ requestController: authRequestController },
|
||||
);
|
||||
if (!pageActive) return;
|
||||
authenticationCommitted.value = true;
|
||||
await enterAuthenticatedRoot();
|
||||
return;
|
||||
}
|
||||
if (action.kind !== "sms-code") {
|
||||
const error = new Error("安全验证状态无效,请重新操作");
|
||||
error.code = "AUTH_TAC_ACTION_INVALID";
|
||||
throw error;
|
||||
}
|
||||
closeTac();
|
||||
sendingCode.value = true;
|
||||
await appApi.sendSmsCode(
|
||||
@@ -443,15 +549,33 @@ const completeTac = async (result) => {
|
||||
{ requestController: authRequestController },
|
||||
);
|
||||
if (!pageActive) return;
|
||||
startCooldown();
|
||||
sentPhone.value = expectedContext.subject;
|
||||
smsCooldown.start();
|
||||
showFeedback("验证码已发送");
|
||||
} catch (error) {
|
||||
closeTac();
|
||||
if (pageActive && !isRequestCancelled(error)) {
|
||||
showFeedback(error.message || "验证码发送失败");
|
||||
if (pageActive) {
|
||||
if (
|
||||
action?.kind === "sms-code" &&
|
||||
isSmsDeliveryOutcomeUnknown(error)
|
||||
) {
|
||||
sentPhone.value = expectedContext.subject;
|
||||
smsCooldown.start();
|
||||
showFeedback("发送结果未知,如收到短信可直接填写;60 秒后可重试");
|
||||
} else if (!isRequestCancelled(error)) {
|
||||
showFeedback(
|
||||
error.message ||
|
||||
(action?.kind === "password-login"
|
||||
? "登录失败,请稍后重试"
|
||||
: "验证码发送失败"),
|
||||
);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
if (pageActive) sendingCode.value = false;
|
||||
if (pageActive) {
|
||||
sendingCode.value = false;
|
||||
submitting.value = false;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -465,11 +589,19 @@ const handleTacError = ({ message } = {}) => {
|
||||
|
||||
const submitLogin = async () => {
|
||||
if (submitting.value) return;
|
||||
if (authenticationCommitted.value) return enterAuthenticatedRoot();
|
||||
if (!validatePhone()) return;
|
||||
if (activeLoginMethod.value === "password" && !password.value) {
|
||||
showFeedback("请输入密码");
|
||||
return;
|
||||
}
|
||||
if (
|
||||
activeLoginMethod.value === "sms" &&
|
||||
sentPhone.value !== phone.value
|
||||
) {
|
||||
showFeedback("请先获取当前手机号的验证码");
|
||||
return;
|
||||
}
|
||||
if (
|
||||
activeLoginMethod.value === "sms" &&
|
||||
!/^\d{4}$/.test(verificationCode.value)
|
||||
@@ -479,10 +611,7 @@ const submitLogin = async () => {
|
||||
}
|
||||
if (!requireAgreement()) return;
|
||||
if (activeLoginMethod.value === "password") {
|
||||
// 受保护源合同明确禁止密码登录携带 TAC 票据。仅在客户端先滑动仍可被
|
||||
// 绕过,因此此入口必须失败关闭;短信登录已经具备 TAC→短信票据闭环。
|
||||
showFeedback(PASSWORD_TAC_BLOCKED_MESSAGE);
|
||||
return;
|
||||
return preparePasswordLogin();
|
||||
}
|
||||
submitting.value = true;
|
||||
try {
|
||||
@@ -494,7 +623,8 @@ const submitLogin = async () => {
|
||||
{ requestController: authRequestController },
|
||||
);
|
||||
if (!pageActive) return;
|
||||
await goRoot("G01");
|
||||
authenticationCommitted.value = true;
|
||||
await enterAuthenticatedRoot();
|
||||
} catch (error) {
|
||||
if (pageActive && !isRequestCancelled(error)) {
|
||||
showFeedback(error.message || "登录失败,请稍后重试");
|
||||
@@ -647,17 +777,16 @@ const prepareAgreement = () => showFeedback("协议页面准备中");
|
||||
|
||||
.get-code {
|
||||
display: flex;
|
||||
flex: 0 0 auto;
|
||||
flex: 0 0 176rpx;
|
||||
box-sizing: border-box;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
min-width: 142rpx;
|
||||
min-height: var(--app-touch-min);
|
||||
padding: 0 8rpx;
|
||||
background: transparent !important;
|
||||
color: #a9160d;
|
||||
font-size: 28rpx;
|
||||
}
|
||||
|
||||
.login-tab--unavailable {
|
||||
color: #9f968d;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.get-code--disabled {
|
||||
|
||||
+93
-40
@@ -38,12 +38,12 @@
|
||||
class="auth-input"
|
||||
type="number"
|
||||
maxlength="11"
|
||||
:disabled="sendingCode || cooldownSeconds > 0 || submitting"
|
||||
:disabled="sendingCode || submitting || registrationCommitted"
|
||||
placeholder="请输入手机号"
|
||||
placeholder-class="placeholder"
|
||||
:aria-invalid="Boolean(fieldErrors.phone)"
|
||||
:aria-describedby="fieldErrors.phone ? 'a04-phone-error' : undefined"
|
||||
@input="clearFieldError('phone')"
|
||||
@input="handlePhoneInput"
|
||||
/>
|
||||
</view>
|
||||
<text v-if="fieldErrors.phone" id="a04-phone-error" class="field-error" role="alert">{{
|
||||
@@ -63,7 +63,7 @@
|
||||
class="auth-input"
|
||||
type="number"
|
||||
maxlength="4"
|
||||
:disabled="submitting"
|
||||
:disabled="submitting || registrationCommitted"
|
||||
placeholder="请输入验证码"
|
||||
placeholder-class="placeholder"
|
||||
:aria-invalid="Boolean(fieldErrors.verificationCode)"
|
||||
@@ -73,7 +73,7 @@
|
||||
<button
|
||||
class="auth-plain-button get-code"
|
||||
:class="{ 'get-code--disabled': sendingCode || cooldownSeconds > 0 }"
|
||||
:disabled="sendingCode || submitting || cooldownSeconds > 0"
|
||||
:disabled="sendingCode || submitting || cooldownSeconds > 0 || registrationCommitted"
|
||||
hover-class="tap-fade"
|
||||
@click="prepareGetCode"
|
||||
>{{ sendingCode ? "请求中…" : cooldownSeconds > 0 ? `${cooldownSeconds}s 后重发` : "获取验证码" }}</button
|
||||
@@ -96,7 +96,7 @@
|
||||
class="auth-input"
|
||||
password
|
||||
maxlength="32"
|
||||
:disabled="submitting"
|
||||
:disabled="submitting || registrationCommitted"
|
||||
placeholder="请设置登录密码"
|
||||
placeholder-class="placeholder"
|
||||
:aria-invalid="Boolean(fieldErrors.password)"
|
||||
@@ -121,7 +121,7 @@
|
||||
class="auth-input"
|
||||
password
|
||||
maxlength="32"
|
||||
:disabled="submitting"
|
||||
:disabled="submitting || registrationCommitted"
|
||||
placeholder="请再次输入密码"
|
||||
placeholder-class="placeholder"
|
||||
:aria-invalid="Boolean(fieldErrors.confirmPassword)"
|
||||
@@ -147,7 +147,15 @@
|
||||
src="/static/assets/foundation/transparent/a01-scroll-primary-v3.png"
|
||||
mode="aspectFit"
|
||||
/>
|
||||
<text class="register-submit__content">{{ submitting ? "注册中…" : "注册账号" }}</text>
|
||||
<text class="register-submit__content">{{
|
||||
submitting
|
||||
? registrationCommitted
|
||||
? "正在进入…"
|
||||
: "注册中…"
|
||||
: registrationCommitted
|
||||
? "进入家谱"
|
||||
: "注册账号"
|
||||
}}</text>
|
||||
</button>
|
||||
|
||||
<view
|
||||
@@ -160,7 +168,7 @@
|
||||
role="checkbox"
|
||||
:aria-checked="agreed"
|
||||
:aria-label="agreed ? '取消同意用户协议与隐私政策' : '同意用户协议与隐私政策'"
|
||||
:disabled="sendingCode || submitting || tacVisible"
|
||||
:disabled="sendingCode || submitting || tacVisible || registrationCommitted"
|
||||
@click="toggleAgreement"
|
||||
>
|
||||
<image
|
||||
@@ -230,7 +238,7 @@
|
||||
|
||||
<script setup>
|
||||
import { computed, ref } from "vue";
|
||||
import { onBackPress, onUnload } from "@dcloudio/uni-app";
|
||||
import { onBackPress, onShow, onUnload } from "@dcloudio/uni-app";
|
||||
import AppDialog from "@/components/AppDialog.vue";
|
||||
import AppToast from "@/components/AppToast.vue";
|
||||
import AuthPageShell from "@/components/AuthPageShell.vue";
|
||||
@@ -244,9 +252,11 @@ import {
|
||||
AUTH_TAC_SCENE,
|
||||
createTacRenderContext,
|
||||
isAuthPhone,
|
||||
isSmsDeliveryOutcomeUnknown,
|
||||
normalizeCaptchaRequirement,
|
||||
normalizeTacSuccess,
|
||||
} from "@/utils/auth-verification.js";
|
||||
import { createAuthSmsCooldown } from "@/utils/auth-sms-cooldown.js";
|
||||
import { runtimeConfig } from "@/utils/config.js";
|
||||
import { createDiscardConfirmation } from "@/utils/discard-confirmation.js";
|
||||
import { calcMD5 } from "@/utils/md5.js";
|
||||
@@ -275,8 +285,11 @@ const tacVisible = ref(false);
|
||||
const tacContext = ref(null);
|
||||
const sendingCode = ref(false);
|
||||
const submitting = ref(false);
|
||||
const registrationCommitted = ref(false);
|
||||
const cooldownSeconds = ref(0);
|
||||
const sentPhone = ref("");
|
||||
const isDirty = computed(() =>
|
||||
!registrationCommitted.value &&
|
||||
Boolean(
|
||||
phone.value ||
|
||||
verificationCode.value ||
|
||||
@@ -286,10 +299,17 @@ const isDirty = computed(() =>
|
||||
),
|
||||
);
|
||||
let feedbackTimer = null;
|
||||
let cooldownTimer = null;
|
||||
let tacSequence = 0;
|
||||
let pageActive = true;
|
||||
const registrationNavigationFailure =
|
||||
"注册已完成,但暂时无法进入家谱,请再次点击进入";
|
||||
const authRequestController = createRequestController();
|
||||
const smsCooldown = createAuthSmsCooldown({
|
||||
sceneCode: AUTH_TAC_SCENE.REGISTER,
|
||||
onChange: (seconds) => {
|
||||
cooldownSeconds.value = seconds;
|
||||
},
|
||||
});
|
||||
const discardConfirmation = createDiscardConfirmation((visible) => {
|
||||
discardVisible.value = visible;
|
||||
});
|
||||
@@ -309,17 +329,19 @@ const cancelPendingRequest = () => {
|
||||
};
|
||||
|
||||
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,
|
||||
});
|
||||
registrationCommitted.value
|
||||
? enterAuthenticatedRoot()
|
||||
: 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));
|
||||
|
||||
@@ -327,9 +349,10 @@ onUnload(() => {
|
||||
pageActive = false;
|
||||
authRequestController.abort();
|
||||
if (feedbackTimer) clearTimeout(feedbackTimer);
|
||||
if (cooldownTimer) clearInterval(cooldownTimer);
|
||||
smsCooldown.dispose();
|
||||
discardConfirmation.dispose();
|
||||
});
|
||||
onShow(() => smsCooldown.sync());
|
||||
|
||||
const showFeedback = (message) => {
|
||||
feedbackMessage.value = message;
|
||||
@@ -341,10 +364,26 @@ const showFeedback = (message) => {
|
||||
}, 2200);
|
||||
};
|
||||
|
||||
const enterAuthenticatedRoot = async () => {
|
||||
if (!pageActive) return false;
|
||||
submitting.value = true;
|
||||
try {
|
||||
const opened = await goRoot("G01");
|
||||
if (opened !== true) throw new Error("NAVIGATION_RETRY_REQUIRED");
|
||||
return true;
|
||||
} catch {
|
||||
if (pageActive) showFeedback(registrationNavigationFailure);
|
||||
return false;
|
||||
} finally {
|
||||
if (pageActive) submitting.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const prepareAgreement = () => showFeedback("协议页面准备中");
|
||||
|
||||
const toggleAgreement = () => {
|
||||
if (sendingCode.value || submitting.value) return;
|
||||
if (sendingCode.value || submitting.value || registrationCommitted.value)
|
||||
return;
|
||||
agreed.value = !agreed.value;
|
||||
if (agreed.value) agreementError.value = false;
|
||||
};
|
||||
@@ -353,16 +392,11 @@ 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 handlePhoneInput = () => {
|
||||
clearFieldError("phone");
|
||||
if (!sentPhone.value || phone.value === sentPhone.value) return;
|
||||
verificationCode.value = "";
|
||||
sentPhone.value = "";
|
||||
};
|
||||
|
||||
const prepareGetCode = async () => {
|
||||
@@ -424,12 +458,19 @@ const completeTac = async (result) => {
|
||||
{ requestController: authRequestController },
|
||||
);
|
||||
if (!pageActive) return;
|
||||
startCooldown();
|
||||
sentPhone.value = expectedContext.subject;
|
||||
smsCooldown.start();
|
||||
showFeedback("验证码已发送");
|
||||
} catch (error) {
|
||||
closeTac();
|
||||
if (pageActive && !isRequestCancelled(error)) {
|
||||
showFeedback(error.message || "验证码发送失败");
|
||||
if (pageActive) {
|
||||
if (isSmsDeliveryOutcomeUnknown(error)) {
|
||||
sentPhone.value = expectedContext.subject;
|
||||
smsCooldown.start();
|
||||
showFeedback("发送结果未知,如收到短信可直接填写;60 秒后可重试");
|
||||
} else if (!isRequestCancelled(error)) {
|
||||
showFeedback(error.message || "验证码发送失败");
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
if (pageActive) sendingCode.value = false;
|
||||
@@ -452,7 +493,9 @@ const validateForm = () => {
|
||||
confirmPassword: "",
|
||||
};
|
||||
if (!isAuthPhone(phone.value)) nextErrors.phone = "请输入正确手机号";
|
||||
if (!/^\d{4}$/.test(verificationCode.value))
|
||||
if (sentPhone.value !== phone.value)
|
||||
nextErrors.verificationCode = "请先获取当前手机号的验证码";
|
||||
else if (!/^\d{4}$/.test(verificationCode.value))
|
||||
nextErrors.verificationCode = "请输入 4 位验证码";
|
||||
const passwordResult = validatePassword(password.value);
|
||||
if (!passwordResult.valid) nextErrors.password = PASSWORD_POLICY_MESSAGE;
|
||||
@@ -465,6 +508,7 @@ const validateForm = () => {
|
||||
|
||||
const submitRegister = async () => {
|
||||
if (submitting.value || sendingCode.value) return;
|
||||
if (registrationCommitted.value) return enterAuthenticatedRoot();
|
||||
const formValid = validateForm();
|
||||
if (!agreed.value) agreementError.value = true;
|
||||
if (!formValid || !agreed.value) return;
|
||||
@@ -479,14 +523,17 @@ const submitRegister = async () => {
|
||||
{ requestController: authRequestController },
|
||||
);
|
||||
if (!pageActive) return;
|
||||
await goRoot("G01");
|
||||
registrationCommitted.value = true;
|
||||
} catch (error) {
|
||||
if (pageActive && !isRequestCancelled(error)) {
|
||||
showFeedback(error.message || "注册失败,请稍后重试");
|
||||
}
|
||||
return;
|
||||
} finally {
|
||||
if (pageActive) submitting.value = false;
|
||||
}
|
||||
if (!pageActive) return;
|
||||
await enterAuthenticatedRoot();
|
||||
};
|
||||
</script>
|
||||
|
||||
@@ -588,11 +635,17 @@ const submitRegister = async () => {
|
||||
}
|
||||
|
||||
.get-code {
|
||||
flex: 0 0 auto;
|
||||
padding-left: 14rpx;
|
||||
display: flex;
|
||||
flex: 0 0 176rpx;
|
||||
box-sizing: border-box;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
padding: 0 8rpx;
|
||||
border-left: 1rpx solid #d7bd94;
|
||||
background: transparent !important;
|
||||
color: #a7160c;
|
||||
font-size: 26rpx;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.get-code--disabled {
|
||||
|
||||
@@ -38,12 +38,12 @@
|
||||
class="auth-input"
|
||||
type="number"
|
||||
maxlength="11"
|
||||
:disabled="sendingCode || cooldownSeconds > 0 || submitting"
|
||||
:disabled="sendingCode || submitting"
|
||||
placeholder="请输入手机号"
|
||||
placeholder-class="placeholder"
|
||||
:aria-invalid="Boolean(fieldErrors.phone)"
|
||||
:aria-describedby="fieldErrors.phone ? 'a05-phone-error' : undefined"
|
||||
@input="clearFieldError('phone')"
|
||||
@input="handlePhoneInput"
|
||||
/>
|
||||
</view>
|
||||
<text v-if="fieldErrors.phone" id="a05-phone-error" class="field-error" role="alert">{{
|
||||
@@ -208,7 +208,7 @@
|
||||
|
||||
<script setup>
|
||||
import { computed, ref } from "vue";
|
||||
import { onBackPress, onUnload } from "@dcloudio/uni-app";
|
||||
import { onBackPress, onShow, onUnload } from "@dcloudio/uni-app";
|
||||
import AppDialog from "@/components/AppDialog.vue";
|
||||
import AppToast from "@/components/AppToast.vue";
|
||||
import AuthPageShell from "@/components/AuthPageShell.vue";
|
||||
@@ -222,9 +222,11 @@ import {
|
||||
AUTH_TAC_SCENE,
|
||||
createTacRenderContext,
|
||||
isAuthPhone,
|
||||
isSmsDeliveryOutcomeUnknown,
|
||||
normalizeCaptchaRequirement,
|
||||
normalizeTacSuccess,
|
||||
} from "@/utils/auth-verification.js";
|
||||
import { createAuthSmsCooldown } from "@/utils/auth-sms-cooldown.js";
|
||||
import { runtimeConfig } from "@/utils/config.js";
|
||||
import { createDiscardConfirmation } from "@/utils/discard-confirmation.js";
|
||||
import { calcMD5 } from "@/utils/md5.js";
|
||||
@@ -251,6 +253,7 @@ const tacContext = ref(null);
|
||||
const sendingCode = ref(false);
|
||||
const submitting = ref(false);
|
||||
const cooldownSeconds = ref(0);
|
||||
const sentPhone = ref("");
|
||||
const fieldErrors = ref({
|
||||
phone: "",
|
||||
verificationCode: "",
|
||||
@@ -267,10 +270,15 @@ const isDirty = computed(() =>
|
||||
),
|
||||
);
|
||||
let feedbackTimer = null;
|
||||
let cooldownTimer = null;
|
||||
let tacSequence = 0;
|
||||
let pageActive = true;
|
||||
const authRequestController = createRequestController();
|
||||
const smsCooldown = createAuthSmsCooldown({
|
||||
sceneCode: AUTH_TAC_SCENE.FORGOT_PASSWORD,
|
||||
onChange: (seconds) => {
|
||||
cooldownSeconds.value = seconds;
|
||||
},
|
||||
});
|
||||
const discardConfirmation = createDiscardConfirmation((visible) => {
|
||||
discardVisible.value = visible;
|
||||
});
|
||||
@@ -313,9 +321,10 @@ onUnload(() => {
|
||||
pageActive = false;
|
||||
authRequestController.abort();
|
||||
if (feedbackTimer) clearTimeout(feedbackTimer);
|
||||
if (cooldownTimer) clearInterval(cooldownTimer);
|
||||
smsCooldown.dispose();
|
||||
discardConfirmation.dispose();
|
||||
});
|
||||
onShow(() => smsCooldown.sync());
|
||||
|
||||
const showFeedback = (message) => {
|
||||
feedbackMessage.value = message;
|
||||
@@ -331,16 +340,11 @@ 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 handlePhoneInput = () => {
|
||||
clearFieldError("phone");
|
||||
if (!sentPhone.value || phone.value === sentPhone.value) return;
|
||||
verificationCode.value = "";
|
||||
sentPhone.value = "";
|
||||
};
|
||||
|
||||
const prepareGetCode = async () => {
|
||||
@@ -398,12 +402,19 @@ const completeTac = async (result) => {
|
||||
{ requestController: authRequestController },
|
||||
);
|
||||
if (!pageActive) return;
|
||||
startCooldown();
|
||||
sentPhone.value = expectedContext.subject;
|
||||
smsCooldown.start();
|
||||
showFeedback("验证码已发送");
|
||||
} catch (error) {
|
||||
closeTac();
|
||||
if (pageActive && !isRequestCancelled(error)) {
|
||||
showFeedback(error.message || "验证码发送失败");
|
||||
if (pageActive) {
|
||||
if (isSmsDeliveryOutcomeUnknown(error)) {
|
||||
sentPhone.value = expectedContext.subject;
|
||||
smsCooldown.start();
|
||||
showFeedback("发送结果未知,如收到短信可直接填写;60 秒后可重试");
|
||||
} else if (!isRequestCancelled(error)) {
|
||||
showFeedback(error.message || "验证码发送失败");
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
if (pageActive) sendingCode.value = false;
|
||||
@@ -426,7 +437,9 @@ const validateForm = () => {
|
||||
confirmPassword: "",
|
||||
};
|
||||
if (!isAuthPhone(phone.value)) nextErrors.phone = "请输入正确手机号";
|
||||
if (!/^\d{4}$/.test(verificationCode.value))
|
||||
if (sentPhone.value !== phone.value)
|
||||
nextErrors.verificationCode = "请先获取当前手机号的验证码";
|
||||
else if (!/^\d{4}$/.test(verificationCode.value))
|
||||
nextErrors.verificationCode = "请输入 4 位验证码";
|
||||
const passwordResult = validatePassword(password.value);
|
||||
if (!passwordResult.valid) nextErrors.password = PASSWORD_POLICY_MESSAGE;
|
||||
@@ -557,11 +570,17 @@ const submitReset = async () => {
|
||||
color: #9f968d;
|
||||
}
|
||||
.get-code {
|
||||
flex: 0 0 auto;
|
||||
padding-left: 14rpx;
|
||||
display: flex;
|
||||
flex: 0 0 176rpx;
|
||||
box-sizing: border-box;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
padding: 0 8rpx;
|
||||
border-left: 1rpx solid #d7bd94;
|
||||
background: transparent !important;
|
||||
color: #a7160c;
|
||||
font-size: 26rpx;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.get-code--disabled {
|
||||
color: #9f968d;
|
||||
|
||||
@@ -121,7 +121,7 @@
|
||||
>
|
||||
<view class="genealogy-lower">
|
||||
<view v-if="createdGenealogies.length" class="list-section">
|
||||
<view class="section-heading"><text>我创建的</text></view>
|
||||
<view class="section-heading"><text>我管理的</text></view>
|
||||
<GenealogyCard
|
||||
v-for="item in createdGenealogies"
|
||||
:key="item.id"
|
||||
@@ -144,7 +144,10 @@
|
||||
/>
|
||||
</view>
|
||||
|
||||
<view class="list-section application-section">
|
||||
<view
|
||||
v-if="applicationRecords.length"
|
||||
class="list-section application-section"
|
||||
>
|
||||
<view class="section-heading"><text>加入申请</text></view>
|
||||
<view
|
||||
v-for="item in applicationRecords"
|
||||
@@ -350,7 +353,7 @@
|
||||
|
||||
<script setup>
|
||||
import { computed, nextTick, ref } from "vue";
|
||||
import { onBackPress, onLoad } from "@dcloudio/uni-app";
|
||||
import { onBackPress, onLoad, onShow, onUnload } from "@dcloudio/uni-app";
|
||||
import AppLoading from "@/components/AppLoading.vue";
|
||||
import AppTabbar from "@/components/AppTabbar.vue";
|
||||
import AppButton from "@/components/AppButton.vue";
|
||||
@@ -358,17 +361,16 @@ import GenealogyCard from "@/components/GenealogyCard.vue";
|
||||
import GenealogyPageBackground from "@/components/GenealogyPageBackground.vue";
|
||||
import PageHeader from "@/components/PageHeader.vue";
|
||||
import {
|
||||
findGenealogyFixture,
|
||||
genealogies,
|
||||
getGenealogyFixtureAccess,
|
||||
listNotificationFixtures,
|
||||
} from "@/data/mock.js";
|
||||
appApi,
|
||||
createRequestController,
|
||||
isRequestCancelled,
|
||||
} from "@/utils/api.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 list = ref([]);
|
||||
const forceEmptyState = ref(false);
|
||||
const contextInvalidated = ref(false);
|
||||
const contextReconcileFailed = ref(false);
|
||||
@@ -378,9 +380,14 @@ const switcherVisible = ref(false);
|
||||
const selectedGenealogyId = ref(null);
|
||||
const listScrollCommand = ref(0);
|
||||
const currentListScrollTop = ref(0);
|
||||
const listRequestController = createRequestController();
|
||||
let loadGeneration = 0;
|
||||
let pageActive = true;
|
||||
let presentationState = "default";
|
||||
let skipNextShowRefresh = true;
|
||||
|
||||
const syncEmptyStateFromRoute = (query = {}) => {
|
||||
const presentationState = ["empty", "loading", "error"].includes(
|
||||
presentationState = ["empty", "loading", "error"].includes(
|
||||
query?.state,
|
||||
)
|
||||
? query.state
|
||||
@@ -419,15 +426,59 @@ const reconcilePageGenealogyContext = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const loadGenealogies = async () => {
|
||||
const generation = ++loadGeneration;
|
||||
listRequestController.abort();
|
||||
isLoading.value = true;
|
||||
hasError.value = false;
|
||||
try {
|
||||
const result = await appApi.getMyGenealogies({
|
||||
requestController: listRequestController,
|
||||
});
|
||||
if (!pageActive || generation !== loadGeneration) return;
|
||||
list.value = result;
|
||||
reconcilePageGenealogyContext();
|
||||
} catch (error) {
|
||||
if (
|
||||
!pageActive ||
|
||||
generation !== loadGeneration ||
|
||||
isRequestCancelled(error)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
hasError.value = true;
|
||||
} finally {
|
||||
if (pageActive && generation === loadGeneration) {
|
||||
isLoading.value = false;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
onLoad((query) => {
|
||||
syncEmptyStateFromRoute(query);
|
||||
requestedGenealogyId.value = String(query?.genealogyId || "");
|
||||
reconcilePageGenealogyContext();
|
||||
if (presentationState === "default") {
|
||||
loadGenealogies();
|
||||
} else {
|
||||
reconcilePageGenealogyContext();
|
||||
}
|
||||
});
|
||||
|
||||
const unreadCount = computed(
|
||||
() => listNotificationFixtures().filter((item) => item.unread).length,
|
||||
);
|
||||
onShow(() => {
|
||||
if (skipNextShowRefresh) {
|
||||
skipNextShowRefresh = false;
|
||||
return;
|
||||
}
|
||||
if (presentationState === "default") loadGenealogies();
|
||||
});
|
||||
|
||||
onUnload(() => {
|
||||
pageActive = false;
|
||||
loadGeneration += 1;
|
||||
listRequestController.abort();
|
||||
});
|
||||
|
||||
const unreadCount = computed(() => 0);
|
||||
const hasGenealogies = computed(
|
||||
() => !forceEmptyState.value && list.value.length > 0,
|
||||
);
|
||||
@@ -439,14 +490,10 @@ const isListLayout = computed(
|
||||
hasGenealogies.value,
|
||||
);
|
||||
const createdGenealogies = computed(() =>
|
||||
list.value.filter(
|
||||
(item) => getGenealogyFixtureAccess(item.id).accessRole === "owner",
|
||||
),
|
||||
list.value.filter((item) => item.accessRole === "owner"),
|
||||
);
|
||||
const joinedGenealogies = computed(() =>
|
||||
list.value.filter(
|
||||
(item) => getGenealogyFixtureAccess(item.id).accessRole === "member",
|
||||
),
|
||||
list.value.filter((item) => item.accessRole === "member"),
|
||||
);
|
||||
const availableGenealogies = computed(() => list.value);
|
||||
const currentGenealogy = computed(
|
||||
@@ -456,41 +503,14 @@ const currentGenealogy = computed(
|
||||
) || null,
|
||||
);
|
||||
const isCurrentGenealogyOwner = computed(
|
||||
() =>
|
||||
getGenealogyFixtureAccess(currentGenealogy.value?.id).accessRole ===
|
||||
"owner",
|
||||
() => currentGenealogy.value?.accessRole === "owner",
|
||||
);
|
||||
const currentRoleLabel = computed(() =>
|
||||
isCurrentGenealogyOwner.value ? "管理员" : "成员",
|
||||
);
|
||||
|
||||
// 仅用于页面样式阶段覆盖加入申请的关键状态,不作为接口数据。
|
||||
const applicationRecords = [
|
||||
{
|
||||
id: "pending",
|
||||
genealogyId: "2003",
|
||||
statusLabel: "审核中",
|
||||
tone: "pending",
|
||||
description: "申请已提交,等待管理员审核",
|
||||
},
|
||||
{
|
||||
id: "rejected",
|
||||
genealogyId: "2004",
|
||||
statusLabel: "被拒绝",
|
||||
tone: "rejected",
|
||||
description: "可修改关系说明后重新申请",
|
||||
},
|
||||
{
|
||||
id: "removed",
|
||||
genealogyId: "2005",
|
||||
statusLabel: "已退出",
|
||||
tone: "muted",
|
||||
description: "如需恢复成员身份,可重新申请加入",
|
||||
},
|
||||
].map((record) => ({
|
||||
...record,
|
||||
name: findGenealogyFixture(record.genealogyId)?.name || "未知家谱",
|
||||
}));
|
||||
// 普通加入申请批次接通前不展示本地伪记录。
|
||||
const applicationRecords = ref([]);
|
||||
|
||||
const shortcuts = [
|
||||
{
|
||||
@@ -603,13 +623,7 @@ const openApplication = (record) => {
|
||||
);
|
||||
};
|
||||
const retryLoad = () => {
|
||||
if (contextReconcileFailed.value) {
|
||||
hasError.value = false;
|
||||
reconcilePageGenealogyContext();
|
||||
return;
|
||||
}
|
||||
hasError.value = false;
|
||||
isLoading.value = false;
|
||||
loadGenealogies();
|
||||
};
|
||||
|
||||
const openShortcut = (key) => {
|
||||
|
||||
@@ -31,16 +31,19 @@
|
||||
>
|
||||
<text class="overview-hero__title">{{ genealogy.name }}</text>
|
||||
<text class="overview-hero__motto">{{
|
||||
genealogy.motto || "敦亲睦族,敬祖传家。"
|
||||
genealogy.intro || "简介待补充"
|
||||
}}</text>
|
||||
<view class="overview-hero__stats">
|
||||
<text>共 {{ genealogy.memberCount || 0 }} 人</text>
|
||||
<text>已激活 {{ genealogy.activeCount || 0 }} 人</text>
|
||||
<text v-if="genealogy.personCount !== null"
|
||||
>世系 {{ genealogy.personCount }} 人</text
|
||||
>
|
||||
<text>{{ getGenealogyAccessPresetLabel(genealogy.accessPreset) }}</text>
|
||||
</view>
|
||||
<view class="overview-hero__stats">
|
||||
<text>始祖 {{ genealogy.ancestorName }}</text>
|
||||
<text>更新于 {{ genealogy.updatedAt }}</text>
|
||||
<text v-if="genealogy.joinTime"
|
||||
>加入于 {{ formatGenealogyTime(genealogy.joinTime) }}</text
|
||||
>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
@@ -194,11 +197,15 @@
|
||||
|
||||
<script setup>
|
||||
import { computed, ref } from "vue";
|
||||
import { onBackPress, onLoad } from "@dcloudio/uni-app";
|
||||
import { onBackPress, onLoad, onShow, onUnload } 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 {
|
||||
appApi,
|
||||
createRequestController,
|
||||
isRequestCancelled,
|
||||
} from "@/utils/api.js";
|
||||
import { getGenealogyAccessPresetLabel } from "@/utils/genealogy-contracts.js";
|
||||
import {
|
||||
goBack,
|
||||
@@ -216,6 +223,10 @@ const viewMode = ref("member");
|
||||
const accessRole = ref("guest");
|
||||
const publicRelation = ref("unknown");
|
||||
const publicCanApply = ref(false);
|
||||
const overviewRequestController = createRequestController();
|
||||
let loadGeneration = 0;
|
||||
let pageActive = true;
|
||||
let skipNextShowRefresh = true;
|
||||
const publicActionLabel = computed(() => {
|
||||
if (publicRelation.value === "pending") return "查看申请进度";
|
||||
if (!publicCanApply.value) return "";
|
||||
@@ -249,7 +260,12 @@ const stateCopy = computed(
|
||||
"",
|
||||
);
|
||||
|
||||
const loadGenealogy = (query = {}) => {
|
||||
const formatGenealogyTime = (value) =>
|
||||
typeof value === "string" && value.length >= 10 ? value.slice(0, 10) : value;
|
||||
|
||||
const loadGenealogy = async (query = {}) => {
|
||||
const generation = ++loadGeneration;
|
||||
overviewRequestController.abort();
|
||||
overviewState.value = "loading";
|
||||
loadError.value = "";
|
||||
genealogy.value = null;
|
||||
@@ -273,28 +289,47 @@ const loadGenealogy = (query = {}) => {
|
||||
}
|
||||
if (query.state === "loading") return;
|
||||
|
||||
const fixture = findGenealogyFixture(genealogyId.value);
|
||||
if (!fixture) {
|
||||
overviewState.value = "no-permission";
|
||||
return;
|
||||
try {
|
||||
const result = await appApi.getOverview(genealogyId.value, {
|
||||
requestController: overviewRequestController,
|
||||
});
|
||||
if (!pageActive || generation !== loadGeneration) return;
|
||||
genealogy.value = result;
|
||||
viewMode.value = "member";
|
||||
accessRole.value = result.accessRole;
|
||||
overviewState.value = "ready";
|
||||
} catch (error) {
|
||||
if (
|
||||
!pageActive ||
|
||||
generation !== loadGeneration ||
|
||||
isRequestCancelled(error)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if ([401, 403, 404].includes(error?.httpStatus)) {
|
||||
overviewState.value = "no-permission";
|
||||
return;
|
||||
}
|
||||
loadError.value = error?.message || "家谱概览读取失败,请稍后重试。";
|
||||
overviewState.value = "error";
|
||||
}
|
||||
|
||||
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);
|
||||
onShow(() => {
|
||||
if (skipNextShowRefresh) {
|
||||
skipNextShowRefresh = false;
|
||||
return;
|
||||
}
|
||||
if (genealogyId.value) loadGenealogy({ genealogyId: genealogyId.value });
|
||||
});
|
||||
onUnload(() => {
|
||||
pageActive = false;
|
||||
loadGeneration += 1;
|
||||
overviewRequestController.abort();
|
||||
});
|
||||
const reloadOverview = () => loadGenealogy({ genealogyId: genealogyId.value });
|
||||
const toGenealogies = () => returnTo("G01", {});
|
||||
const toTree = () =>
|
||||
|
||||
@@ -16,8 +16,8 @@
|
||||
|
||||
<view v-if="treeState !== 'loading'" class="tree-toolbar">
|
||||
<view class="tree-toolbar__title">
|
||||
<text>汤氏家谱</text>
|
||||
<text>主支 · 第 12—14 世</text>
|
||||
<text>世系成员</text>
|
||||
<text>{{ generationRangeLabel }}</text>
|
||||
</view>
|
||||
<view class="tree-toolbar__actions">
|
||||
<text
|
||||
@@ -177,12 +177,16 @@
|
||||
|
||||
<script setup>
|
||||
import { computed, ref } from "vue";
|
||||
import { onLoad, onUnload } from "@dcloudio/uni-app";
|
||||
import { onLoad, onShow, onUnload } from "@dcloudio/uni-app";
|
||||
import AppButton from "@/components/AppButton.vue";
|
||||
import AppLoading from "@/components/AppLoading.vue";
|
||||
import ModulePageBackground from "@/components/ModulePageBackground.vue";
|
||||
import PageHeader from "@/components/PageHeader.vue";
|
||||
import { getGenealogyFixtureAccess, listTreeMemberFixtures } from "@/data/mock.js";
|
||||
import {
|
||||
appApi,
|
||||
createRequestController,
|
||||
isRequestCancelled,
|
||||
} from "@/utils/api.js";
|
||||
import { genealogyContext } from "@/utils/genealogy-context.js";
|
||||
import { openPage } from "@/utils/navigation.js";
|
||||
|
||||
@@ -195,15 +199,14 @@ const centeredTreeScrollLeft = ref(90);
|
||||
const treeHasDrifted = ref(false);
|
||||
let ignoreTreeScroll = false;
|
||||
let recenterTimer = null;
|
||||
const treeRequestController = createRequestController();
|
||||
let treeLoadGeneration = 0;
|
||||
let pageActive = true;
|
||||
let skipNextShowRefresh = true;
|
||||
const GRID_UNIT = 5;
|
||||
const NODE_HALF_HEIGHT = 47;
|
||||
const MEMBER_GAP = 250;
|
||||
const GENERATION_GAP = 220;
|
||||
const generationMeta = {
|
||||
12: { label: "第十二世", summary: "始祖" },
|
||||
13: { label: "第十三世", summary: "两房" },
|
||||
14: { label: "第十四世", summary: "三支" },
|
||||
};
|
||||
const members = ref([]);
|
||||
|
||||
const snapToGrid = (value) => Math.ceil(value / GRID_UNIT) * GRID_UNIT;
|
||||
@@ -218,8 +221,8 @@ const layoutMembers = computed(() => {
|
||||
.filter((member) => Number(member.generation) === generation)
|
||||
.sort(
|
||||
(left, right) =>
|
||||
Number(left.parentId || 0) - Number(right.parentId || 0) ||
|
||||
Number(left.id) - Number(right.id),
|
||||
String(left.parentId || "").localeCompare(String(right.parentId || "")) ||
|
||||
String(left.id).localeCompare(String(right.id)),
|
||||
),
|
||||
]),
|
||||
);
|
||||
@@ -275,12 +278,19 @@ const generationRows = computed(() => {
|
||||
const y = Math.min(...group.map((member) => member.y));
|
||||
return {
|
||||
generation,
|
||||
label: generationMeta[generation]?.label || `第 ${generation} 世`,
|
||||
summary: generationMeta[generation]?.summary || `${group.length} 位成员`,
|
||||
label: `第 ${generation} 世`,
|
||||
summary: `${group.length} 位成员`,
|
||||
y,
|
||||
};
|
||||
});
|
||||
});
|
||||
const generationRangeLabel = computed(() => {
|
||||
const rows = generationRows.value;
|
||||
if (!rows.length) return "世系关系";
|
||||
const first = rows[0].generation;
|
||||
const last = rows[rows.length - 1].generation;
|
||||
return first === last ? `第 ${first} 世` : `第 ${first}—${last} 世`;
|
||||
});
|
||||
const generationBandStyle = (row) => ({
|
||||
gridRow: `${Math.max(1, Math.round((row.y - 88) / GRID_UNIT) + 1)} / span 12`,
|
||||
});
|
||||
@@ -351,7 +361,9 @@ const stateCopy = computed(
|
||||
})[treeState.value] || {},
|
||||
);
|
||||
|
||||
onLoad((query) => {
|
||||
const loadTree = async (query = {}) => {
|
||||
const generation = ++treeLoadGeneration;
|
||||
treeRequestController.abort();
|
||||
if (genealogyContext.isCurrentGenealogyInvalidated()) {
|
||||
genealogyId.value = "";
|
||||
members.value = [];
|
||||
@@ -366,15 +378,6 @@ onLoad((query) => {
|
||||
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)
|
||||
@@ -382,15 +385,45 @@ onLoad((query) => {
|
||||
treeState.value = query.state;
|
||||
return;
|
||||
}
|
||||
genealogyContext.setCurrentGenealogyId(genealogyId.value);
|
||||
selected.value =
|
||||
layoutMembers.value.find(
|
||||
(item) => String(item.id) === String(query.selectedId),
|
||||
) || layoutMembers.value[0];
|
||||
treeState.value = members.value.length ? "tree" : "empty";
|
||||
try {
|
||||
const result = await appApi.getTree(genealogyId.value, {
|
||||
requestController: treeRequestController,
|
||||
});
|
||||
if (!pageActive || generation !== treeLoadGeneration) return;
|
||||
members.value = result;
|
||||
genealogyContext.setCurrentGenealogyId(genealogyId.value);
|
||||
selected.value =
|
||||
layoutMembers.value.find(
|
||||
(item) => String(item.id) === String(query.selectedId),
|
||||
) || layoutMembers.value[0] || null;
|
||||
treeState.value = members.value.length ? "tree" : "empty";
|
||||
} catch (error) {
|
||||
if (
|
||||
!pageActive ||
|
||||
generation !== treeLoadGeneration ||
|
||||
isRequestCancelled(error)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
members.value = [];
|
||||
selected.value = null;
|
||||
treeState.value = "error";
|
||||
}
|
||||
};
|
||||
|
||||
onLoad(loadTree);
|
||||
onShow(() => {
|
||||
if (skipNextShowRefresh) {
|
||||
skipNextShowRefresh = false;
|
||||
return;
|
||||
}
|
||||
if (genealogyId.value) loadTree({ genealogyId: genealogyId.value });
|
||||
});
|
||||
|
||||
onUnload(() => {
|
||||
pageActive = false;
|
||||
treeLoadGeneration += 1;
|
||||
treeRequestController.abort();
|
||||
if (recenterTimer) clearTimeout(recenterTimer);
|
||||
});
|
||||
|
||||
@@ -443,6 +476,9 @@ const handleStateAction = () => {
|
||||
"T01",
|
||||
);
|
||||
}
|
||||
if (treeState.value === "error") {
|
||||
return loadTree({ genealogyId: genealogyId.value });
|
||||
}
|
||||
selected.value = layoutMembers.value[0];
|
||||
treeState.value = "tree";
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user