Files
console-dev/app/pages/login.vue
T
2026-11-06 00:04:38 +08:00

569 lines
18 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup>
import {
computed,
onBeforeUnmount,
onMounted,
reactive,
ref,
watch,
} from "vue";
import { useHead, useRoute, useRouter } from "#app";
import { NuxtLink } from "#components";
import Button from "primevue/button";
import InputText from "primevue/inputtext";
import Message from "primevue/message";
import Password from "primevue/password";
import AuthProviders from "~/components/AuthProviders.vue";
import CaptchaField from "~/components/CaptchaField.vue";
import GenericOAuthProviders from "~/components/GenericOAuthProviders.vue";
import SecurityVerificationDialog from "~/components/SecurityVerificationDialog.vue";
import { loginErrorMessage, securityChallenge, useAuth } from "~/lib/auth.js";
import { r as safeRedirect, t as withRedirect } from "~/lib/auth-redirect.js";
import { t as useCaptcha } from "~/lib/useCaptcha.js";
import { t as useSiteConfig } from "~/lib/useSiteConfig.js";
import { t as useVerifyCode } from "~/lib/useVerifyCode.js";
import "~/assets/styles/SecurityVerificationDialog.skYFo4v7.css";
import "~/assets/styles/GenericOAuthProviders.Bv8oNwXP.css";
import "~/assets/styles/GeetestField.DiieTibK.css";
import "~/assets/styles/CaptchaField.BSN3ZbsD.css";
import "~/assets/styles/AuthProviders.CgAWouSf.css";
import "~/assets/styles/login.Clyo2_3T.css";
definePageMeta({ layout: "blank" });
useHead({ title: "登录 - 破碎工坊网络" });
const auth = useAuth();
const route = useRoute();
const router = useRouter();
const site = useSiteConfig();
const { config, error: configError } = site;
const form = reactive({ account: "", password: "", code: "" });
const loginMode = ref("password");
const phoneCode = ref("86");
const errorMessage = ref("");
const submitting = ref(false);
const authProviders = ref(null);
const securityDialogVisible = ref(false);
const challenge = ref({});
const pendingCredentials = ref(null);
const pendingVerification = ref(null);
let attemptVersion = 0;
const phonePasswordEnabled = computed(
() =>
config.value?.login_phone_password === undefined ||
site.on(config.value.login_phone_password),
);
const emailPasswordEnabled = computed(
() =>
config.value?.login_email_password === undefined ||
site.on(config.value.login_email_password),
);
const passwordLoginEnabled = computed(
() => phonePasswordEnabled.value || emailPasswordEnabled.value,
);
const codeLoginEnabled = computed(() =>
site.on(config.value?.login_phone_verify),
);
const registrationEnabled = computed(
() =>
config.value?.register_phone === undefined ||
config.value?.register_email === undefined ||
site.on(config.value.register_phone) ||
site.on(config.value.register_email),
);
const isPhoneAccount = computed(
() => loginMode.value === "code" || !form.account.includes("@"),
);
const formLocked = computed(
() =>
submitting.value ||
securityDialogVisible.value ||
!!pendingVerification.value,
);
const securityContext = computed(() => ({
scene: "login",
account: pendingCredentials.value?.account,
phoneCode: pendingCredentials.value?.phoneCode,
}));
const passwordWasReset = computed(() => route.query.reset === "1");
const registrationSucceeded = computed(() => route.query.registered === "1");
const captcha = useCaptcha("login");
const {
needed: captchaNeeded,
provider: captchaProvider,
captchaId,
img: captchaImage,
token: captchaToken,
value: captchaValue,
loading: captchaLoading,
configLoading: captchaConfigLoading,
error: captchaError,
load: loadCaptcha,
complete: completeCaptcha,
invalidate: invalidateCaptcha,
} = captcha;
const {
sending: sendingCode,
countdown: codeCountdown,
send: sendCode,
} = useVerifyCode({
action: "login",
captcha,
error: errorMessage,
phoneCode: () => phoneCode.value,
});
const fieldsDisabled = computed(() => formLocked.value || sendingCode.value);
const captchaBusy = computed(
() => captchaConfigLoading.value || captchaLoading.value,
);
onMounted(() => site.load());
watch(
config,
(settings) => {
if (formLocked.value || sendingCode.value) return;
if (
codeLoginEnabled.value &&
(settings?.first_login_method === "code" || !passwordLoginEnabled.value)
) {
loginMode.value = "code";
} else if (!codeLoginEnabled.value) {
loginMode.value = "password";
}
},
{ immediate: true },
);
watch([() => form.account, loginMode], () => {
form.code = "";
errorMessage.value = "";
});
function setLoginMode(mode) {
if (
fieldsDisabled.value ||
!(mode === "code" ? codeLoginEnabled.value : passwordLoginEnabled.value)
)
return;
loginMode.value = mode;
}
function sendLoginCode() {
if (loginMode.value !== "code" || fieldsDisabled.value) return;
if (!config.value) {
errorMessage.value = "登录设置暂不可用,请重新加载";
return;
}
if (!codeLoginEnabled.value) {
errorMessage.value = "当前登录方式未开放,请选择其他方式";
return;
}
const account = form.account.trim();
if (account.includes("@")) {
errorMessage.value = "请输入手机号";
return;
}
return sendCode("phone", account, "请输入手机号");
}
function cancelLogin() {
attemptVersion += 1;
securityDialogVisible.value = false;
pendingCredentials.value = null;
pendingVerification.value = null;
submitting.value = false;
errorMessage.value = "";
}
onBeforeUnmount(cancelLogin);
async function attemptLogin(credentials, verification) {
const methodEnabled =
credentials.type === "code"
? codeLoginEnabled.value
: credentials.account.includes("@")
? emailPasswordEnabled.value
: phonePasswordEnabled.value;
if (!config.value || !methodEnabled) {
cancelLogin();
errorMessage.value = config.value
? "当前登录方式未开放,请选择其他方式"
: "登录设置暂不可用,请重新加载";
return;
}
const version = attemptVersion;
submitting.value = true;
errorMessage.value = "";
try {
await auth.login(
credentials.account,
credentials.password,
credentials.captcha,
credentials.token,
credentials.type,
credentials.code,
credentials.phoneCode,
verification,
);
if (version !== attemptVersion) return;
authProviders.value?.resetQr();
securityDialogVisible.value = false;
pendingCredentials.value = null;
pendingVerification.value = null;
await router.push(safeRedirect(route.query.redirect));
} catch (error) {
if (version !== attemptVersion) return;
const nextChallenge = securityChallenge(error);
if (nextChallenge) {
pendingCredentials.value = credentials;
pendingVerification.value = null;
challenge.value = nextChallenge;
securityDialogVisible.value = true;
} else {
errorMessage.value = loginErrorMessage(error);
const captchaRequired = await captcha.handleError(error);
if (version !== attemptVersion) return;
if (
captchaRequired &&
verification?.security_verify_method === "certification"
) {
pendingCredentials.value = credentials;
pendingVerification.value = { ...verification };
securityDialogVisible.value = false;
}
}
} finally {
if (version === attemptVersion) submitting.value = false;
}
}
async function confirmSecurityVerification(verification) {
const credentials = pendingCredentials.value;
if (!credentials || submitting.value || pendingVerification.value) return;
if (
verification.security_verify_method === "certification" &&
credentials.type === "password" &&
captchaNeeded.value
) {
// Certification returns to this form for a fresh CAPTCHA before continuing the same login.
pendingVerification.value = { ...verification };
securityDialogVisible.value = false;
errorMessage.value = "";
invalidateCaptcha();
await loadCaptcha();
return;
}
await attemptLogin(credentials, verification);
}
async function submitLogin() {
if (submitting.value || sendingCode.value || securityDialogVisible.value)
return;
errorMessage.value = "";
if (pendingVerification.value && pendingCredentials.value) {
const validationError = captcha.validationError();
if (validationError) {
errorMessage.value = validationError;
return;
}
await attemptLogin(
{
...pendingCredentials.value,
captcha: captchaValue.value.trim(),
token: captchaToken.value,
},
pendingVerification.value,
);
return;
}
if (!form.account.trim()) {
errorMessage.value = "请输入手机号或邮箱";
return;
}
if (!config.value) {
errorMessage.value = "登录设置暂不可用,请重新加载";
return;
}
if (
(loginMode.value === "code" && !codeLoginEnabled.value) ||
(loginMode.value === "password" &&
!(isPhoneAccount.value
? phonePasswordEnabled.value
: emailPasswordEnabled.value))
) {
errorMessage.value = "当前登录方式未开放,请选择其他方式";
return;
}
if (isPhoneAccount.value && !/^[1-9]\d{0,3}$/.test(phoneCode.value)) {
errorMessage.value = "请输入有效的国际电话区号";
return;
}
if (loginMode.value === "code" && form.account.includes("@")) {
errorMessage.value = "请输入手机号";
return;
}
if (loginMode.value === "code" && !form.code.trim()) {
errorMessage.value = "请输入验证码";
return;
}
if (loginMode.value === "password" && !form.password) {
errorMessage.value = "请输入密码";
return;
}
const validationError =
loginMode.value === "password" ? captcha.validationError() : "";
if (validationError) {
errorMessage.value = validationError;
return;
}
attemptVersion += 1;
await attemptLogin({
account: form.account.trim(),
password: loginMode.value === "password" ? form.password : "",
captcha: captchaValue.value.trim(),
token: captchaToken.value,
type: loginMode.value,
code: form.code.trim(),
phoneCode: isPhoneAccount.value ? phoneCode.value : "",
});
}
</script>
<template>
<div class="auth-shell">
<div class="auth-card login-card">
<div class="login-main">
<div class="login-brand"><h1>欢迎回来</h1></div>
<form class="login-form" @submit.prevent="submitLogin">
<div class="seg" role="group" aria-label="登录方式">
<button
type="button"
:class="{ active: loginMode === 'password' }"
:aria-pressed="loginMode === 'password'"
:disabled="!passwordLoginEnabled || fieldsDisabled"
@click="setLoginMode('password')"
>
密码登录
</button>
<button
type="button"
:class="{ active: loginMode === 'code' }"
:aria-pressed="loginMode === 'code'"
:disabled="!codeLoginEnabled || fieldsDisabled"
@click="setLoginMode('code')"
>
验证码登录
</button>
</div>
<Message
v-if="passwordWasReset && !errorMessage"
severity="success"
:closable="false"
icon="pi pi-check-circle"
>密码已重置,请使用新密码登录</Message
>
<Message
v-if="registrationSucceeded && !errorMessage"
severity="success"
:closable="false"
>注册成功,请登录</Message
>
<Message v-if="configError" severity="error" :closable="false">
{{ configError }}
<Button label="重新加载" text @click="site.load" />
</Message>
<Message
v-else-if="!passwordLoginEnabled && !codeLoginEnabled"
severity="info"
:closable="false"
>账号登录暂未开放,请使用已开放的其他登录方式</Message
>
<Message v-if="pendingVerification" severity="info" :closable="false"
>实名认证已通过,请完成新的人机验证后继续登录。</Message
>
<div class="field">
<label for="account">账号</label>
<InputText
id="account"
v-model="form.account"
:placeholder="
loginMode === 'code' ? '输入手机号' : '输入手机号/邮箱'
"
autocomplete="username"
:invalid="!!errorMessage"
:aria-describedby="errorMessage ? 'login-error' : undefined"
:disabled="fieldsDisabled"
fluid
/>
</div>
<div v-if="loginMode === 'code'" class="field">
<label for="login-code">验证码</label>
<!-- Retain the deployed scoped selectors without changing the shared stylesheet. -->
<div class="code-row" data-v-7896f676>
<InputText
id="login-code"
v-model="form.code"
autocomplete="one-time-code"
placeholder="请输入验证码"
:disabled="fieldsDisabled"
fluid
/>
<Button
type="button"
:label="codeCountdown > 0 ? `${codeCountdown}s` : '获取验证码'"
:loading="sendingCode"
:disabled="
codeCountdown > 0 ||
fieldsDisabled ||
!codeLoginEnabled ||
captchaBusy
"
severity="secondary"
outlined
@click="sendLoginCode"
/>
</div>
</div>
<div v-if="loginMode === 'password'" class="field">
<div class="field-label-row">
<label for="password">密码</label>
<NuxtLink
:to="withRedirect('/forgot-password', route.query.redirect)"
class="field-link"
>忘记密码?</NuxtLink
>
</div>
<Password
input-id="password"
v-model="form.password"
placeholder="请输入密码"
:feedback="false"
toggle-mask
autocomplete="current-password"
:invalid="!!errorMessage"
:input-props="{
'aria-describedby': errorMessage ? 'login-error' : undefined,
}"
:disabled="fieldsDisabled"
fluid
/>
</div>
<div class="login-captcha" data-v-7896f676 :aria-busy="captchaBusy">
<button
v-if="captchaBusy || captchaError"
type="button"
class="captcha-placeholder"
data-v-7896f676
:class="{ 'has-error': !!captchaError }"
:disabled="
captchaBusy ||
submitting ||
sendingCode ||
securityDialogVisible ||
!captchaNeeded
"
:aria-label="captchaError || '正在加载验证…'"
@click="loadCaptcha"
>
<i
:class="
captchaError
? 'pi pi-exclamation-circle'
: 'pi pi-spin pi-spinner'
"
aria-hidden="true"
/>
<span
:role="captchaError ? 'alert' : 'status'"
aria-live="polite"
aria-atomic="true"
>{{ captchaError || "正在加载验证…" }}</span
>
</button>
<CaptchaField
v-else-if="captchaNeeded"
v-model="captchaValue"
:img="captchaImage"
:provider="captchaProvider"
:captcha-id="captchaId"
:disabled="submitting || sendingCode || securityDialogVisible"
@verified="completeCaptcha"
@invalid="invalidateCaptcha"
@refresh="loadCaptcha"
/>
</div>
<Message
v-if="errorMessage"
id="login-error"
severity="error"
:closable="false"
icon="pi pi-exclamation-circle"
class="login-error"
>{{ errorMessage }}</Message
>
<Button
type="submit"
:label="
submitting
? '正在登录…'
: pendingVerification
? '继续登录'
: '登录'
"
:loading="submitting"
:disabled="
submitting ||
sendingCode ||
securityDialogVisible ||
captchaBusy ||
!config ||
(!passwordLoginEnabled && !codeLoginEnabled)
"
class="login-btn"
fluid
/>
<Button
v-if="pendingVerification"
label="取消本次登录"
type="button"
severity="secondary"
text
:disabled="submitting"
@click="cancelLogin"
/>
<Button
v-if="registrationEnabled"
v-slot="{ class: buttonClass, a11yAttrs }"
as-child
severity="secondary"
fluid
>
<NuxtLink
:to="withRedirect('/register', route.query.redirect)"
:class="[buttonClass, 'register-btn']"
v-bind="a11yAttrs"
><span class="p-button-label">注册账号</span></NuxtLink
>
</Button>
<SecurityVerificationDialog
v-model:visible="securityDialogVisible"
:challenge="challenge"
:context="securityContext"
:busy="submitting"
:request-error="errorMessage"
@verified="confirmSecurityVerification"
@cancel="cancelLogin"
/>
</form>
</div>
</div>
<AuthProviders ref="authProviders" />
<GenericOAuthProviders />
</div>
</template>