前端验证 / verify (push) Failing after 15m3s
- 统一页面标题、圆角、字体和响应式断点,调整暗色主题及图表配色 - 新增表格列标签插件,在窄屏下将表格转换为卡片布局,优化实例、订单、工单等列表展示 - 支持移动端点击订单行进入详情,并完善订单凭据的打印布局 - 在财务中心展示本月及累计消费,补充充值关闭时的提示 - 优化工单详情布局,将附件上传入口整合至富文本编辑器工具栏 - 调整实例操作按钮、SSH 密钥操作及安全菜单图标 - 为多个页面显式导入 definePageMeta,完善页面元数据声明
575 lines
19 KiB
Vue
575 lines
19 KiB
Vue
<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>
|