first commit

This commit is contained in:
cieanyau committed 2026-11-06 00:04:38 +08:00
commit db5762abd4
687 files changed
+171307

No files matched your search

+568
View File
@@ -0,0 +1,568 @@
<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>