Files
IM-web-new/src/components/LoginView.vue
T
2026-10-09 22:49:53 +07:00

503 lines
16 KiB
Vue

<template>
<main class="login-page">
<section class="login-panel">
<div class="brand-lockup">
<img class="brand-mark" :src="platformLogo" :alt="`${platformName} logo`" @error="handleBrandLogoError">
<div>
<h1>{{ platformName }}</h1>
<!-- <p>按照最新 App Server 文档接入的 Web IM 客户端</p> -->
</div>
</div>
<form v-if="view === 'login'" class="login-form" @submit.prevent="submitLogin">
<div class="login-tabs register-tabs" :style="{ gridTemplateColumns: 'repeat(3, minmax(0, 1fr))' }">
<button type="button" :class="{ active: loginMode === 'qr' }" @click="setLoginMode('qr')">扫码登录</button>
<button type="button" :class="{ active: loginMode === 'phone' }" @click="setLoginMode('phone')">手机号</button>
<button type="button" :class="{ active: loginMode === 'email' }" @click="setLoginMode('email')">邮箱</button>
</div>
<template v-if="loginMode === '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>{{ loginMode === 'phone' ? '手机号' : '邮箱' }}</span>
<input
v-model.trim="loginAccount"
:autocomplete="loginMode === 'phone' ? 'username' : 'email'"
:inputmode="loginMode === 'phone' ? 'tel' : 'email'"
:placeholder="loginMode === 'phone' ? '请输入手机号' : '请输入邮箱'"
>
</label>
<label>
<span>密码</span>
<input v-model="loginPassword" autocomplete="current-password" type="password" placeholder="请输入密码">
</label>
<button class="primary-action" :disabled="session.connecting || !canSubmitLogin">
{{ 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 class="login-form" @submit.prevent="submitRegister">
<div class="login-tabs register-tabs" :style="{ gridTemplateColumns: 'repeat(2, minmax(0, 1fr))' }">
<button type="button" :class="{ active: registerType === 1 }" @click="switchRegisterType(1)">手机号注册</button>
<button type="button" :class="{ active: registerType === 2 }" @click="switchRegisterType(2)">邮箱注册</button>
</div>
<label>
<span>{{ registerType === 1 ? '手机号' : '邮箱' }}</span>
<input
v-model.trim="registerForm.number"
:autocomplete="registerType === 1 ? 'tel' : 'email'"
:inputmode="registerType === 1 ? 'tel' : 'email'"
:placeholder="registerType === 1 ? '请输入手机号' : '请输入邮箱'"
>
</label>
<label v-if="registerType === 2">
<span>邮箱验证码</span>
<div class="code-row">
<input v-model.trim="registerForm.code" autocomplete="one-time-code" inputmode="numeric" placeholder="请输入邮箱验证码">
<button type="button" class="code-action" :disabled="sendingCode || !canRequestEmailCode" @click="requestEmailCode">
{{ sendingCode ? '发送中' : '发送验证码' }}
</button>
</div>
</label>
<label>
<span>密码</span>
<input v-model="registerForm.password" autocomplete="new-password" type="password" placeholder="请输入密码">
</label>
<label>
<span>确认密码</span>
<input v-model="registerForm.confirmPassword" autocomplete="new-password" type="password" placeholder="请再次输入密码">
</label>
<label>
<span>邀请码</span>
<input v-model.trim="registerForm.invitationCode" autocomplete="off" placeholder="没有可留空">
</label>
<button class="primary-action" :disabled="session.connecting || !canSubmitRegister">
{{ 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 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 } from 'vue';
import { appConfig } from '@/config';
import { useSessionStore } from '@/stores/session';
import { imClient } from '@/services/imClient';
const session = useSessionStore();
const view = ref('login');
const loginMode = ref('phone');
const loginAccount = ref('');
const loginPassword = ref('');
const registerType = ref(1);
const registerForm = reactive({
number: '',
code: '',
password: '',
confirmPassword: '',
invitationCode: ''
});
const sendingCode = ref(false);
const info = ref('');
const QR_CODE_PREFIX_PC_SESSION = 'wildfirechat://pcsession/';
const qrStatus = ref('idle');
const qrCodeImage = ref('');
const qrSessionToken = ref('');
const qrUserName = ref('');
const qrUserPortrait = ref('');
let qrGeneration = 0;
let qrExpireTimer = null;
const defaultPlatformName = appConfig.appName;
const defaultPlatformLogo = '/favicon.ico';
const platformName = ref(defaultPlatformName);
const platformLogo = ref(defaultPlatformLogo);
const canSubmitLogin = computed(() => loginAccount.value.length >= 3 && loginPassword.value.length >= 1);
const canRequestEmailCode = computed(() => registerType.value === 2 && /\S+@\S+\.\S+/.test(registerForm.number));
const canSubmitRegister = computed(() => {
if (registerForm.password.length < 1 || registerForm.password !== registerForm.confirmPassword) {
return false;
}
if (registerType.value === 2) {
return canRequestEmailCode.value && registerForm.code.length >= 1;
}
return registerForm.number.length >= 5;
});
const qrUserInitial = computed(() => String(qrUserName.value || 'U').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 '刷新二维码';
});
onMounted(() => {
loadPlatformConfig();
});
onBeforeUnmount(() => {
stopQrLogin();
});
function clearFormMessage() {
session.error = '';
info.value = '';
}
function setLoginMode(mode) {
loginMode.value = mode;
loginAccount.value = '';
loginPassword.value = '';
clearFormMessage();
if (mode === 'qr') {
startQrLogin();
return;
}
stopQrLogin();
}
async function submitLogin() {
clearFormMessage();
const type = loginMode.value === 'email' ? 2 : 1;
await session.loginWithPassword(loginAccount.value, loginPassword.value, type);
}
function switchRegisterType(type) {
registerType.value = type;
registerForm.number = '';
registerForm.code = '';
registerForm.password = '';
registerForm.confirmPassword = '';
registerForm.invitationCode = '';
clearFormMessage();
}
async function requestEmailCode() {
clearFormMessage();
sendingCode.value = true;
try {
await imClient.requestAuthCode(registerForm.number);
info.value = '邮箱验证码已发送,请在当前注册表单中填写后继续。';
} catch (error) {
session.error = error.message || '验证码发送失败';
} finally {
sendingCode.value = false;
}
}
async function submitRegister() {
clearFormMessage();
if (registerForm.password !== registerForm.confirmPassword) {
session.error = '两次输入的密码不一致';
return;
}
const result = await session.register({
type: registerType.value,
number: registerForm.number,
code: registerType.value === 2 ? registerForm.code : '',
password: registerForm.password,
invitationCode: registerForm.invitationCode
});
if (result) {
info.value = '注册成功,正在进入系统。';
}
}
function openRegister() {
clearFormMessage();
stopQrLogin();
view.value = 'register';
}
function backToLogin() {
clearFormMessage();
loginMode.value = 'phone';
stopQrLogin();
view.value = 'login';
}
function resetQrState() {
qrStatus.value = 'idle';
qrCodeImage.value = '';
qrSessionToken.value = '';
qrUserName.value = '';
qrUserPortrait.value = '';
}
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 (loginMode.value !== 'qr') {
return;
}
if (!force && qrSessionToken.value && qrCodeImage.value && !['expired', 'error'].includes(qrStatus.value)) {
return;
}
qrGeneration += 1;
const generation = qrGeneration;
clearQrExpireTimer();
clearFormMessage();
qrStatus.value = 'loading';
qrCodeImage.value = '';
qrSessionToken.value = '';
qrUserName.value = '';
qrUserPortrait.value = '';
try {
const pcSession = await imClient.createPcSession();
if (generation !== qrGeneration || loginMode.value !== 'qr') {
return;
}
const token = String(pcSession.token || '');
if (!token) {
throw new Error('服务端没有返回扫码 token');
}
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) {
try {
while (generation === qrGeneration && loginMode.value === 'qr' && !session.isAuthenticated) {
const payload = await imClient.loginWithPcSession(token);
if (generation !== qrGeneration || loginMode.value !== 'qr') {
return;
}
const code = Number(payload.code);
const data = payload.data || payload.result || {};
const status = Number(data.status);
if (code === 0 && data.imToken) {
qrStatus.value = 'confirmed';
clearQrExpireTimer();
await session.completePcSessionLogin(data);
return;
}
if (code === 20042 || status === 1 || status === 3) {
qrStatus.value = 'scanned';
qrUserName.value = data.userName || data.displayName || qrUserName.value;
qrUserPortrait.value = data.portrait || qrUserPortrait.value;
await delay(1000);
continue;
}
if (status === 0 || code === 13) {
qrStatus.value = 'waiting';
await delay(1000);
continue;
}
if (status === 4) {
qrStatus.value = 'expired';
session.error = payload.message || '扫码登录已取消';
return;
}
if (payload.message) {
throw new Error(payload.message);
}
await delay(1000);
}
} catch (error) {
if (generation === qrGeneration && loginMode.value === 'qr' && !session.isAuthenticated) {
qrStatus.value = 'error';
session.error = error.message || '扫码登录失败';
}
}
}
function delay(ms) {
return new Promise(resolve => window.setTimeout(resolve, ms));
}
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;
let icon = document.querySelector('link[rel="icon"]');
if (!icon) {
icon = document.createElement('link');
icon.setAttribute('rel', 'icon');
document.head.appendChild(icon);
}
icon.setAttribute('href', platformLogo.value || defaultPlatformLogo);
}
function handleBrandLogoError() {
if (platformLogo.value !== defaultPlatformLogo) {
platformLogo.value = defaultPlatformLogo;
applyDocumentBrand();
}
}
</script>