Files
console-dev/app/pages/login.vue
T
cneicy 42dfead9cd
前端验证 / verify (push) Failing after 15m3s
feat(ui): 优化控制台响应式布局与视觉体验
- 统一页面标题、圆角、字体和响应式断点,调整暗色主题及图表配色
- 新增表格列标签插件,在窄屏下将表格转换为卡片布局,优化实例、订单、工单等列表展示
- 支持移动端点击订单行进入详情,并完善订单凭据的打印布局
- 在财务中心展示本月及累计消费,补充充值关闭时的提示
- 优化工单详情布局,将附件上传入口整合至富文本编辑器工具栏
- 调整实例操作按钮、SSH 密钥操作及安全菜单图标
- 为多个页面显式导入 definePageMeta,完善页面元数据声明
2026-11-07 00:09:57 +08:00

575 lines
19 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>
/**
* 会员登录:站点配置决定手机/邮箱密码与短信入口;验证码、安全挑战及第三方登录共用会话核心。attemptVersion 隔离取消、离页和重新提交后的旧响应。
*/
import { definePageMeta } from "#imports";
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 "~/components/PasswordField.vue";
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 = "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,
});
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 (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 : "",
});
}
</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>