919 lines
31 KiB
Vue
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>
|