完成40%

This commit is contained in:
rain
2026-07-23 17:21:27 +08:00
parent f1edc6b533
commit bb6431b319
114 changed files with 10931 additions and 877 deletions
+172 -43
View File
@@ -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
View File
@@ -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 {
+40 -21
View File
@@ -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;
+71 -57
View File
@@ -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) => {
+58 -23
View File
@@ -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 = () =>
+65 -29
View File
@@ -16,8 +16,8 @@
<view v-if="treeState !== 'loading'" class="tree-toolbar">
<view class="tree-toolbar__title">
<text>汤氏家谱</text>
<text>主支 · 1214 </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";
};