Files
IM-web-new/src/components/LoginView.vue
T

919 lines
31 KiB
Vue

<template>
<main class="login-page">
<section class="login-panel">
<div class="brand-lockup">
<img class="brand-mark" :src="platformLogo" :alt="`${platformName} 图标`" @error="handleBrandLogoError">
<div>
<h1>{{ platformName }}</h1>
<p>连接既有授权 IM 服务的新版 Web 客户端</p>
</div>
</div>
<form v-if="view === 'login'" class="login-form" @submit.prevent="submit">
<div class="login-tabs">
<button type="button" :class="{ active: mode === 'qr' }" @click="setLoginMode('qr')">扫码登录</button>
<button type="button" :class="{ active: mode === 'password' }" @click="setLoginMode('password')">密码登录</button>
<button type="button" :class="{ active: mode === 'code' }" @click="setLoginMode('code')">验证码登录</button>
</div>
<template v-if="mode === 'qr'">
<div class="qr-login-card" :class="{ scanned: qrStatus === 'scanned', expired: qrStatus === 'expired', error: qrStatus === 'error' }">
<button type="button" class="qr-code-button" :disabled="qrStatus === 'loading' || session.connecting" @click="refreshQrLogin">
<img v-if="qrCodeImage" :src="qrCodeImage" alt="手机扫码登录二维码">
<span v-else>{{ qrStatus === 'loading' ? '生成中' : '点击刷新' }}</span>
</button>
<div v-if="qrStatus === 'scanned'" class="qr-scan-user">
<img v-if="qrUserPortrait" :src="qrUserPortrait" alt="">
<span v-else>{{ qrUserInitial }}</span>
</div>
</div>
<div class="qr-login-status">
<strong>{{ qrStatusTitle }}</strong>
<span>{{ qrStatusText }}</span>
</div>
<button type="button" class="primary-action" :disabled="qrStatus === 'loading' || session.connecting" @click="refreshQrLogin">
{{ qrRefreshText }}
</button>
</template>
<template v-else>
<label>
<span>手机号</span>
<input v-model.trim="mobile" autocomplete="username" inputmode="tel" placeholder="请输入手机号">
</label>
<label v-if="mode === 'password'">
<span>密码</span>
<input v-model="password" autocomplete="current-password" type="password" placeholder="请输入密码">
</label>
<label v-else>
<span>验证码</span>
<div class="code-row">
<input v-model.trim="code" autocomplete="one-time-code" inputmode="numeric" placeholder="本地测试请输入 66666">
<button type="button" class="code-action" :disabled="sendingCode || mobile.length < 5" @click="requestCode">
{{ sendingCode ? '发送中' : '发送验证码' }}
</button>
</div>
</label>
<button class="primary-action" :disabled="session.connecting || !canSubmit">
{{ session.connecting ? '登录中...' : '登录' }}
</button>
</template>
<p v-if="session.error" class="form-error">{{ session.error }}</p>
<p v-if="info" class="form-info">{{ info }}</p>
</form>
<form v-else-if="view === 'register'" class="login-form" @submit.prevent="submitRegister">
<div class="login-tabs register-tabs" :style="{ gridTemplateColumns: `repeat(${visibleRegisterMethods.length || 1}, minmax(0, 1fr))` }">
<button
v-for="item in visibleRegisterMethods"
:key="item.key"
type="button"
:class="{ active: registerType === item.key }"
@click="registerType = item.key"
>
{{ item.label }}
</button>
</div>
<label v-if="registerType === 'phone'">
<span class="label-row">
<span>手机号</span>
<span v-if="registerPhoneError" class="field-error-badge">{{ registerPhoneError }}</span>
</span>
<div class="phone-row">
<div class="country-code-field">
<span>+</span>
<input
v-model.trim="registerForm.phoneCountryCode"
aria-label="手机号国码"
autocomplete="tel-country-code"
inputmode="numeric"
maxlength="6"
@input="normalizeRegisterCountryCode"
>
</div>
<input
v-model.trim="registerForm.phone"
:class="{ 'field-input-error': registerPhoneError }"
autocomplete="tel"
inputmode="tel"
placeholder="请输入手机号"
@input="clearRegisterPhoneError"
>
</div>
</label>
<label v-if="registerType === 'phone'">
<span>验证码</span>
<div v-if="mobileRealVerify" class="code-row">
<input v-model.trim="registerForm.code" autocomplete="one-time-code" inputmode="numeric" placeholder="请输入短信验证码">
<button type="button" class="code-action" :disabled="sendingCode || registerForm.phone.length < 5" @click="requestRegisterCode">
{{ sendingCode ? '发送中' : '发送验证码' }}
</button>
</div>
<div v-else class="captcha-row">
<input
v-model.trim="registerForm.code"
:class="phoneCaptchaClass"
autocomplete="off"
inputmode="numeric"
maxlength="6"
placeholder="请输入验证码"
@input="normalizePhoneCaptchaInput"
>
<button v-if="!phoneCaptchaImage" type="button" class="code-action" @click="showPhoneCaptcha">
发送验证码
</button>
<button v-else type="button" class="captcha-button" title="点击更换验证码" @click="showPhoneCaptcha">
<img :src="phoneCaptchaImage" alt="6位数字验证码">
</button>
</div>
</label>
<label v-if="emailRegisterEnabled && registerType === 'email'">
<span>邮箱</span>
<input v-model.trim="registerForm.email" autocomplete="email" inputmode="email" placeholder="请输入邮箱">
</label>
<label v-if="emailRegisterEnabled && registerType === 'email'">
<span>邮箱验证码</span>
<div class="code-row">
<input v-model.trim="registerForm.emailCode" autocomplete="one-time-code" inputmode="numeric" placeholder="请输入验证码">
<button type="button" class="code-action" disabled>发送验证码</button>
</div>
</label>
<label v-if="registerType === 'username'">
<span>用户名</span>
<input v-model.trim="registerForm.username" autocomplete="username" placeholder="请输入用户名">
</label>
<label v-if="registerType === 'username'">
<span>手机号</span>
<div class="phone-row">
<select v-model="registerForm.usernamePhoneCountryCode" aria-label="用户名注册手机号国码">
<option v-for="item in countryCodeOptions" :key="item.value" :value="item.value">{{ item.label }}</option>
</select>
<input v-model.trim="registerForm.usernamePhone" autocomplete="tel" inputmode="tel" placeholder="请输入手机号">
</div>
</label>
<label v-if="registerType === 'username'">
<span>验证码</span>
<div class="captcha-row">
<input
v-model.trim="registerForm.usernameCode"
:class="usernameCaptchaClass"
autocomplete="off"
inputmode="numeric"
maxlength="6"
placeholder="请输入验证码"
@input="normalizeUsernameCaptchaInput"
>
<button v-if="!usernameCaptchaImage" type="button" class="code-action" @click="showUsernameCaptcha">
显示验证码
</button>
<button v-else type="button" class="captcha-button" title="点击更换验证码" @click="showUsernameCaptcha">
<img :src="usernameCaptchaImage" alt="6位数字验证码">
</button>
</div>
</label>
<label v-if="registerType === 'username'">
<span>密码</span>
<input v-model="registerForm.password" autocomplete="new-password" type="password" placeholder="请输入密码">
</label>
<label v-if="registerType === 'username'">
<span>确认密码</span>
<input v-model="registerForm.confirmPassword" autocomplete="new-password" type="password" placeholder="请再次输入密码">
</label>
<button class="primary-action" :disabled="session.connecting || !canRegisterSubmit">
{{ session.connecting ? '注册中...' : '注册' }}
</button>
<p v-if="session.error" class="form-error">{{ session.error }}</p>
<p v-if="info" class="form-info">{{ info }}</p>
</form>
<form v-else class="login-form" @submit.prevent="submitRegisterPassword">
<label>
<span>手机号</span>
<input :value="pendingRegistration.mobile" autocomplete="username" readonly>
</label>
<label>
<span>登录密码</span>
<input v-model="registerPasswordForm.password" autocomplete="new-password" type="password" placeholder="请输入登录密码">
</label>
<label>
<span>确认密码</span>
<input v-model="registerPasswordForm.confirmPassword" autocomplete="new-password" type="password" placeholder="请再次输入密码">
</label>
<button class="primary-action" :disabled="session.connecting || settingRegisterPassword || !canSetRegisterPassword">
{{ settingRegisterPassword || session.connecting ? '保存中...' : '保存并进入' }}
</button>
<p v-if="session.error" class="form-error">{{ session.error }}</p>
<p v-if="info" class="form-info">{{ info }}</p>
</form>
<button v-if="view === 'login'" class="ghost-action" type="button" @click="openRegister">
新用户注册
</button>
<button v-else-if="view === 'register'" class="ghost-action" type="button" @click="backToLogin">
返回登录
</button>
</section>
</main>
</template>
<script setup>
import axios from 'axios';
import jrQRCode from 'jr-qrcode';
import { computed, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue';
import { appConfig } from '@/config';
import { useSessionStore } from '@/stores/session';
import { imClient } from '@/services/imClient';
const session = useSessionStore();
const QR_CODE_PREFIX_PC_SESSION = 'wildfirechat://pcsession/';
const view = ref('login');
const mode = ref('qr');
const mobile = ref('');
const password = ref('');
const code = ref('66666');
const sendingCode = ref(false);
const info = ref('');
const qrStatus = ref('idle');
const qrCodeImage = ref('');
const qrSessionToken = ref('');
const qrUserName = ref('');
const qrUserPortrait = ref('');
const qrPolling = ref(false);
let qrGeneration = 0;
let qrExpireTimer = null;
const pendingRegistrationStorageKey = 'qmt-web-im-pending-registration';
const defaultPlatformName = appConfig.appName;
const defaultPlatformLogo = '/img/5IuOieu9PF8MJtp43kD2kCkdLVjVwDp7.ico';
const platformName = ref(defaultPlatformName);
const platformLogo = ref(defaultPlatformLogo);
const registerType = ref('phone');
const emailRegisterEnabled = false;
const registerMethods = ref({ phone: true, email: false, username: false });
const mobileRealVerify = ref(false);
const registerPhoneError = ref('');
const phoneCaptcha = ref('');
const phoneCaptchaImage = ref('');
const usernameCaptcha = ref('');
const usernameCaptchaImage = ref('');
const settingRegisterPassword = ref(false);
const pendingRegistration = reactive({
mobile: '',
result: null,
resetCode: ''
});
const registerForm = reactive({
phoneCountryCode: '86',
phone: '',
code: '',
email: '',
emailCode: '',
username: '',
usernamePhoneCountryCode: '+86',
usernamePhone: '',
usernameCode: '',
password: '',
confirmPassword: ''
});
const registerPasswordForm = reactive({
password: '',
confirmPassword: ''
});
const countryCodeOptions = [
{ value: '+86', label: '+86' },
{ value: '+852', label: '+852' },
{ value: '+853', label: '+853' },
{ value: '+886', label: '+886' },
{ value: '+1', label: '+1' },
{ value: '+44', label: '+44' },
{ value: '+60', label: '+60' },
{ value: '+63', label: '+63' },
{ value: '+65', label: '+65' },
{ value: '+66', label: '+66' },
{ value: '+81', label: '+81' },
{ value: '+82', label: '+82' }
];
const registerMethodOptions = [
{ key: 'phone', label: '手机号注册' },
{ key: 'email', label: '邮箱注册' },
{ key: 'username', label: '用户名注册' }
];
const canSubmit = computed(() => {
if (mode.value === 'password') {
return mobile.value.length >= 5 && password.value.length >= 1;
}
return mobile.value.length >= 5 && code.value.length >= 1;
});
const visibleRegisterMethods = computed(() => {
const enabled = registerMethodOptions.filter(item => item.key !== 'email' && registerMethods.value[item.key]);
return enabled.length ? enabled : [registerMethodOptions[0]];
});
const canRegisterSubmit = computed(() => {
if (registerType.value === 'phone') {
return registerForm.phone.length >= 5
&& (mobileRealVerify.value ? registerForm.code.length >= 1 : phoneCaptchaMatched.value);
}
if (registerType.value === 'email') {
return false;
}
return registerForm.username.length >= 3
&& registerForm.usernamePhone.length >= 5
&& usernameCaptchaMatched.value
&& registerForm.password.length >= 1
&& registerForm.password === registerForm.confirmPassword;
});
const canSetRegisterPassword = computed(() => {
return pendingRegistration.mobile
&& pendingRegistration.resetCode
&& registerPasswordForm.password.length >= 1
&& registerPasswordForm.password === registerPasswordForm.confirmPassword;
});
const phoneCaptchaStatus = computed(() => getCaptchaStatus(registerForm.code, phoneCaptcha.value));
const phoneCaptchaMatched = computed(() => phoneCaptchaStatus.value === 'valid');
const phoneCaptchaClass = computed(() => getCaptchaClass(phoneCaptchaStatus.value));
const usernameCaptchaStatus = computed(() => {
return getCaptchaStatus(registerForm.usernameCode, usernameCaptcha.value);
});
const usernameCaptchaMatched = computed(() => usernameCaptchaStatus.value === 'valid');
const usernameCaptchaClass = computed(() => getCaptchaClass(usernameCaptchaStatus.value));
const qrUserInitial = computed(() => {
return String(qrUserName.value || '用').slice(0, 1).toUpperCase();
});
const qrStatusTitle = computed(() => {
if (session.connecting || qrStatus.value === 'confirmed') {
return '正在进入';
}
if (qrStatus.value === 'loading') {
return '正在生成二维码';
}
if (qrStatus.value === 'scanned') {
return qrUserName.value ? `${qrUserName.value} 已扫码` : '已扫码';
}
if (qrStatus.value === 'expired') {
return '二维码已过期';
}
if (qrStatus.value === 'error') {
return '二维码加载失败';
}
return '手机扫码登录';
});
const qrStatusText = computed(() => {
if (session.connecting || qrStatus.value === 'confirmed') {
return '登录信息已确认,正在建立连接';
}
if (qrStatus.value === 'loading') {
return '正在向服务器创建登录会话';
}
if (qrStatus.value === 'scanned') {
return '请在手机 App 上确认登录';
}
if (qrStatus.value === 'expired') {
return '请刷新后重新扫码';
}
if (qrStatus.value === 'error') {
return '请刷新二维码后重试';
}
return '使用已登录的手机 App 扫描二维码';
});
const qrRefreshText = computed(() => {
if (qrStatus.value === 'loading') {
return '生成中...';
}
if (qrStatus.value === 'expired' || qrStatus.value === 'error') {
return '重新生成二维码';
}
return '刷新二维码';
});
watch(visibleRegisterMethods, methods => {
if (!methods.some(item => item.key === registerType.value)) {
registerType.value = methods[0]?.key || 'phone';
}
});
onMounted(() => {
loadPlatformConfig();
loadRegisterConfig();
restorePendingRegistration();
if (view.value === 'login' && mode.value === 'qr') {
startQrLogin();
}
});
onBeforeUnmount(() => {
stopQrLogin();
});
async function requestCode() {
info.value = '';
sendingCode.value = true;
try {
await imClient.requestAuthCode(mobile.value);
info.value = '验证码已发送;本地测试也可以直接使用 66666';
} catch (error) {
session.error = error.message || '验证码发送失败';
} finally {
sendingCode.value = false;
}
}
async function requestRegisterCode() {
info.value = '';
sendingCode.value = true;
try {
await imClient.requestAuthCode(formatPhoneForApi(registerForm.phoneCountryCode, registerForm.phone));
info.value = '验证码已发送;本地测试也可以直接使用 66666';
} catch (error) {
session.error = error.message || '验证码发送失败';
} finally {
sendingCode.value = false;
}
}
function submit() {
if (mode.value === 'qr') {
return;
}
if (mode.value === 'password') {
session.loginWithPassword(mobile.value, password.value);
return;
}
session.loginWithCode(mobile.value, code.value);
}
function setLoginMode(nextMode) {
session.error = '';
info.value = '';
mode.value = nextMode;
if (nextMode === 'qr') {
startQrLogin();
return;
}
stopQrLogin();
}
function resetQrState() {
qrStatus.value = 'idle';
qrCodeImage.value = '';
qrSessionToken.value = '';
qrUserName.value = '';
qrUserPortrait.value = '';
qrPolling.value = false;
}
function clearQrExpireTimer() {
if (qrExpireTimer) {
window.clearTimeout(qrExpireTimer);
qrExpireTimer = null;
}
}
function stopQrLogin() {
qrGeneration += 1;
clearQrExpireTimer();
resetQrState();
}
function refreshQrLogin() {
startQrLogin(true);
}
async function startQrLogin(force = false) {
if (mode.value !== 'qr') {
return;
}
if (!force && qrSessionToken.value && qrCodeImage.value && qrStatus.value !== 'expired' && qrStatus.value !== 'error') {
return;
}
qrGeneration += 1;
const generation = qrGeneration;
clearQrExpireTimer();
session.error = '';
info.value = '';
qrStatus.value = 'loading';
qrCodeImage.value = '';
qrSessionToken.value = '';
qrUserName.value = '';
qrUserPortrait.value = '';
qrPolling.value = false;
try {
const pcSession = await imClient.createPcSession();
if (generation !== qrGeneration || mode.value !== 'qr') {
return;
}
const token = String(pcSession.token || '');
if (!token) {
throw new Error('服务器没有返回扫码登录凭证');
}
qrSessionToken.value = token;
qrCodeImage.value = jrQRCode.getQrBase64(QR_CODE_PREFIX_PC_SESSION + token);
qrStatus.value = 'waiting';
const expireMs = Math.max(30000, Math.min(Number(pcSession.expired || 300000), 300000));
qrExpireTimer = window.setTimeout(() => {
if (generation === qrGeneration && qrStatus.value !== 'confirmed' && !session.isAuthenticated) {
qrStatus.value = 'expired';
}
}, expireMs);
pollQrLogin(generation, token);
} catch (error) {
if (generation !== qrGeneration) {
return;
}
qrStatus.value = 'error';
session.error = error.message || '二维码加载失败';
}
}
async function pollQrLogin(generation, token) {
qrPolling.value = true;
try {
while (generation === qrGeneration && mode.value === 'qr' && !session.isAuthenticated) {
const data = await imClient.loginWithPcSession(token);
if (generation !== qrGeneration || mode.value !== 'qr') {
return;
}
const codeValue = Number(data.code);
if (codeValue === 0) {
qrStatus.value = 'confirmed';
clearQrExpireTimer();
await session.completePcSessionLogin(data.result || {});
return;
}
if (codeValue === 9) {
qrStatus.value = 'scanned';
qrUserName.value = data.result?.userName || data.result?.displayName || qrUserName.value;
qrUserPortrait.value = data.result?.portrait || qrUserPortrait.value;
await delay(800);
continue;
}
if (codeValue === 10) {
qrStatus.value = 'waiting';
await delay(1000);
continue;
}
if (codeValue === 18) {
qrStatus.value = 'expired';
session.error = '手机端已取消登录';
return;
}
if (codeValue === 8) {
qrStatus.value = 'expired';
return;
}
throw new Error(data.message || '扫码登录失败');
}
} catch (error) {
if (generation === qrGeneration && mode.value === 'qr' && !session.isAuthenticated) {
qrStatus.value = 'error';
session.error = error.message || '扫码登录失败';
}
} finally {
if (generation === qrGeneration) {
qrPolling.value = false;
}
}
}
function delay(ms) {
return new Promise(resolve => window.setTimeout(resolve, ms));
}
function normalizeUsernameCaptchaInput() {
registerForm.usernameCode = registerForm.usernameCode.replace(/\D/g, '').slice(0, 6);
}
function normalizePhoneCaptchaInput() {
registerForm.code = registerForm.code.replace(/\D/g, '').slice(0, 6);
}
function normalizeRegisterCountryCode() {
registerForm.phoneCountryCode = registerForm.phoneCountryCode.replace(/\D/g, '').slice(0, 6);
}
function showPhoneCaptcha() {
phoneCaptcha.value = randomDigits(6);
phoneCaptchaImage.value = renderCaptchaImage(phoneCaptcha.value);
registerForm.code = '';
}
function showUsernameCaptcha() {
usernameCaptcha.value = randomDigits(6);
usernameCaptchaImage.value = renderCaptchaImage(usernameCaptcha.value);
registerForm.usernameCode = '';
}
function getCaptchaStatus(inputValue, captchaValue) {
if (inputValue.length < 6) {
return '';
}
return captchaValue && inputValue === captchaValue ? 'valid' : 'invalid';
}
function getCaptchaClass(status) {
return {
'captcha-input-valid': status === 'valid',
'captcha-input-invalid': status === 'invalid'
};
}
function randomDigits(length) {
let value = '';
for (let index = 0; index < length; index += 1) {
value += Math.floor(Math.random() * 10);
}
return value;
}
function renderCaptchaImage(text) {
const width = 112;
const height = 42;
const canvas = document.createElement('canvas');
canvas.width = width;
canvas.height = height;
const context = canvas.getContext('2d');
context.fillStyle = '#f7fbfa';
context.fillRect(0, 0, width, height);
for (let index = 0; index < 11; index += 1) {
context.beginPath();
context.moveTo(Math.random() * width, Math.random() * height);
context.lineTo(Math.random() * width, Math.random() * height);
context.strokeStyle = `rgba(${25 + Math.random() * 60}, ${55 + Math.random() * 70}, ${55 + Math.random() * 70}, 0.82)`;
context.lineWidth = 1.4;
context.stroke();
}
context.font = '700 23px Arial, sans-serif';
context.textBaseline = 'middle';
context.textAlign = 'center';
for (let index = 0; index < text.length; index += 1) {
context.fillStyle = index % 2 === 0 ? '#153f3b' : '#1b6760';
context.fillText(text[index], 14 + index * 17, height / 2 + 1);
}
for (let index = 0; index < 70; index += 1) {
context.fillStyle = `rgba(20, 70, 65, ${0.12 + Math.random() * 0.2})`;
context.fillRect(Math.random() * width, Math.random() * height, 1.2, 1.2);
}
return canvas.toDataURL('image/png');
}
async function submitRegister() {
session.error = '';
info.value = '';
if (registerType.value === 'phone') {
clearRegisterPhoneError();
if (!mobileRealVerify.value && !phoneCaptchaMatched.value) {
session.error = '请输入正确的验证码';
return;
}
const formattedPhone = formatPhoneForApi(registerForm.phoneCountryCode, registerForm.phone);
const phoneExists = await isRegisterPhoneExists(formattedPhone);
if (phoneExists === null) {
return;
}
if (phoneExists) {
registerPhoneError.value = '该手机号已经注册过';
return;
}
const loginCode = mobileRealVerify.value ? registerForm.code : '66666';
const result = await session.registerWithPhone(formattedPhone, loginCode);
if (!result) {
return;
}
if (!result.resetCode) {
session.error = '注册成功,但没有收到设置密码凭证';
return;
}
setPendingRegistration(formattedPhone, result);
registerPasswordForm.password = '';
registerPasswordForm.confirmPassword = '';
view.value = 'setPassword';
return;
}
if (registerType.value === 'email') {
session.error = '邮箱注册已暂时关闭,请使用手机号注册';
return;
}
session.error = registerType.value === 'email'
? '邮箱注册接口还未接入,请先使用手机号注册'
: '用户名注册接口还未接入,请先使用手机号注册';
}
async function submitRegisterPassword() {
session.error = '';
info.value = '';
if (!pendingRegistration.mobile || !pendingRegistration.resetCode) {
session.error = '注册状态已失效,请重新注册';
return;
}
if (!registerPasswordForm.password || !registerPasswordForm.confirmPassword) {
session.error = '请填写完整密码';
return;
}
if (registerPasswordForm.password !== registerPasswordForm.confirmPassword) {
session.error = '两次输入的密码不一致';
return;
}
settingRegisterPassword.value = true;
try {
await session.resetPasswordWithCode({
mobile: pendingRegistration.mobile,
resetCode: pendingRegistration.resetCode,
newPassword: registerPasswordForm.password
});
await session.loginWithPassword(pendingRegistration.mobile, registerPasswordForm.password);
if (!session.error) {
clearPendingRegistration();
}
} catch (error) {
session.error = error.message || '密码设置失败';
} finally {
settingRegisterPassword.value = false;
}
}
function formatPhoneForApi(countryCode, phone) {
const digits = String(countryCode || '86').replace(/\D/g, '') || '86';
const normalizedCode = `+${digits}`;
const normalizedPhone = String(phone || '').replace(/\s/g, '');
if (normalizedCode === '+86') {
return normalizedPhone;
}
return `${normalizedCode}${normalizedPhone}`;
}
async function loadRegisterConfig() {
try {
const response = await axios.get(appConfig.registerConfigApi, { withCredentials: false });
const methods = response.data?.data?.methods;
if (methods) {
registerMethods.value = {
phone: Boolean(methods.phone),
email: emailRegisterEnabled && Boolean(methods.email),
username: Boolean(methods.username)
};
}
mobileRealVerify.value = Boolean(response.data?.data?.mobile_real_verify);
} catch (error) {
registerMethods.value = { phone: true, email: false, username: false };
mobileRealVerify.value = false;
}
}
async function loadPlatformConfig() {
try {
const response = await axios.get(appConfig.platformConfigApi, { withCredentials: false });
if (response.data?.code !== 0) {
throw new Error(response.data?.message || '平台配置加载失败');
}
const config = response.data?.result || {};
const name = String(config.platformName || '').trim();
const logo = String(config.logoUrl || config.logo || '').trim();
const version = String(config.version || '').trim();
platformName.value = name || defaultPlatformName;
platformLogo.value = resolveBrandLogo(logo, version);
} catch (error) {
platformName.value = defaultPlatformName;
platformLogo.value = defaultPlatformLogo;
}
applyDocumentBrand();
}
function resolveBrandLogo(value, version = '') {
const logo = String(value || '').trim();
if (!logo) {
return defaultPlatformLogo;
}
if (/^(https?:|data:|blob:)/i.test(logo)) {
return appendBrandVersion(logo, version);
}
if (logo.startsWith('//')) {
return appendBrandVersion(`${window.location.protocol}${logo}`, version);
}
const base = logo.startsWith('/') ? appConfig.adminServer : `${appConfig.adminServer}/`;
return appendBrandVersion(new URL(logo, base).toString(), version);
}
function appendBrandVersion(url, version) {
if (!version || /^(data:|blob:)/i.test(url)) {
return url;
}
const parsed = new URL(url, window.location.href);
parsed.searchParams.set('v', version);
return parsed.toString();
}
function applyDocumentBrand() {
if (typeof document === 'undefined') {
return;
}
document.title = platformName.value || defaultPlatformName;
const iconUrl = platformLogo.value || defaultPlatformLogo;
setDocumentIcon('icon', iconUrl);
setDocumentIcon('shortcut icon', iconUrl);
setDocumentIcon('apple-touch-icon', iconUrl);
}
function setDocumentIcon(rel, href) {
let icon = document.querySelector(`link[rel="${rel}"]`);
if (!icon) {
icon = document.createElement('link');
icon.setAttribute('rel', rel);
document.head.appendChild(icon);
}
icon.setAttribute('href', href);
}
function handleBrandLogoError() {
if (platformLogo.value !== defaultPlatformLogo) {
platformLogo.value = defaultPlatformLogo;
applyDocumentBrand();
}
}
async function isRegisterPhoneExists(phone) {
try {
const response = await axios.post(appConfig.registerPhoneExistsApi, { mobile: phone }, { withCredentials: false });
return Boolean(response.data?.data?.exists);
} catch (error) {
session.error = error.response?.data?.msg || error.message || '手机号检查失败,请稍后重试';
return null;
}
}
function clearRegisterPhoneError() {
registerPhoneError.value = '';
}
function clearPendingRegistration() {
pendingRegistration.mobile = '';
pendingRegistration.result = null;
pendingRegistration.resetCode = '';
registerPasswordForm.password = '';
registerPasswordForm.confirmPassword = '';
sessionStorage.removeItem(pendingRegistrationStorageKey);
}
function setPendingRegistration(phone, result) {
pendingRegistration.mobile = phone;
pendingRegistration.result = result;
pendingRegistration.resetCode = String(result.resetCode || '');
sessionStorage.setItem(pendingRegistrationStorageKey, JSON.stringify({
appServer: appConfig.appServer,
mobile: pendingRegistration.mobile,
resetCode: pendingRegistration.resetCode,
result
}));
}
function restorePendingRegistration() {
try {
const pending = JSON.parse(sessionStorage.getItem(pendingRegistrationStorageKey) || 'null');
if (!pending || pending.appServer !== appConfig.appServer || !pending.mobile || !pending.resetCode) {
return;
}
pendingRegistration.mobile = pending.mobile;
pendingRegistration.result = pending.result || null;
pendingRegistration.resetCode = String(pending.resetCode || '');
registerPasswordForm.password = '';
registerPasswordForm.confirmPassword = '';
view.value = 'setPassword';
} catch (error) {
sessionStorage.removeItem(pendingRegistrationStorageKey);
}
}
function openRegister() {
session.error = '';
info.value = '';
stopQrLogin();
clearRegisterPhoneError();
clearPendingRegistration();
registerForm.phone = registerForm.phone || mobile.value;
registerForm.code = '';
phoneCaptcha.value = '';
phoneCaptchaImage.value = '';
view.value = 'register';
loadRegisterConfig();
}
function backToLogin() {
session.error = '';
info.value = '';
clearRegisterPhoneError();
clearPendingRegistration();
mode.value = 'qr';
view.value = 'login';
startQrLogin();
}
</script>