Files
template-10586/app/pages/login.vue
赵忠林 5e26fdc7fb feat(app): 初始化项目配置和页面结构
- 添加 .dockerignore 和 .env.example 配置文件
- 添加 .gitignore 忽略规则配置
- 创建服务端代理API路由(_file、_modules、_server)
- 集成 Ant Design Vue 组件库并配置SSR样式提取
- 定义API响应类型封装
- 创建基础布局组件(blank、console)
- 实现应用中心页面和组件(AppsCenter)
- 添加文章列表测试页面
- 配置控制台导航菜单结构
- 实现控制台头部组件
- 创建联系页面表单
2026-01-17 18:23:37 +08:00

517 lines
14 KiB
Vue

<template>
<div class="login-page" :style="bgStyle">
<div class="overlay" />
<div v-if="config?.siteName" class="brand">
<img :src="config.sysLogo || defaultLogo" class="brand-logo" alt="logo" />
<h1 class="brand-name">{{ config.siteName }}</h1>
</div>
<div v-if="config?.loginTitle" class="brand-title">{{ config.loginTitle }}</div>
<a-form ref="formRef" :model="form" :rules="rules" class="card">
<div class="card-header">
<template v-if="loginType === 'scan'">
<h2 class="card-title">扫码登录</h2>
</template>
<template v-else>
<h2 class="tab" :class="{ active: loginType === 'sms' }" @click="setLoginType('sms')">
手机号登录
</h2>
<a-divider type="vertical" style="height: 20px" />
<h2
class="tab"
:class="{ active: loginType === 'account' }"
@click="setLoginType('account')"
>
账号登录
</h2>
</template>
<a-button class="switch" type="text" @click="toggleScan" :title="loginType === 'scan' ? '切换到手机号登录' : '切换到扫码登录'">
<QrcodeOutlined v-if="loginType !== 'scan'" />
<MobileOutlined v-else />
</a-button>
</div>
<template v-if="loginType === 'account'">
<a-form-item name="username">
<a-input v-model:value="form.username" size="large" allow-clear placeholder="账号 / 用户ID">
<template #prefix><UserOutlined /></template>
</a-input>
</a-form-item>
<a-form-item name="password">
<a-input-password
v-model:value="form.password"
size="large"
placeholder="登录密码"
@press-enter="submitAccount"
>
<template #prefix><LockOutlined /></template>
</a-input-password>
</a-form-item>
<a-form-item name="code">
<div class="input-group">
<a-input
v-model:value="form.code"
size="large"
allow-clear
:maxlength="5"
placeholder="验证码"
@press-enter="submitAccount"
>
<template #prefix><SafetyCertificateOutlined /></template>
</a-input>
<a-button class="captcha-btn" @click="changeCaptcha">
<img v-if="captcha" :src="captcha" alt="captcha" />
</a-button>
</div>
</a-form-item>
<a-form-item>
<div class="row">
<a-checkbox v-model:checked="form.remember">记住登录</a-checkbox>
</div>
</a-form-item>
<a-form-item>
<a-button block size="large" type="primary" :loading="loading" @click="submitAccount">
{{ loading ? '登录中' : '登录' }}
</a-button>
</a-form-item>
</template>
<template v-else-if="loginType === 'sms'">
<a-form-item name="phone">
<a-input v-model:value="form.phone" size="large" allow-clear :maxlength="11" placeholder="请输入手机号码">
<template #addonBefore>+86</template>
</a-input>
</a-form-item>
<a-form-item name="smsCode">
<div class="input-group">
<a-input
v-model:value="form.smsCode"
size="large"
allow-clear
:maxlength="6"
placeholder="请输入验证码"
@press-enter="submitSms"
/>
<a-button class="captcha-btn" :disabled="countdown > 0" @click="openImgCodeModal">
<span v-if="countdown <= 0">发送验证码</span>
<span v-else>已发送 {{ countdown }} s</span>
</a-button>
</div>
</a-form-item>
<a-form-item>
<a-button block size="large" type="primary" :loading="loading" @click="submitSms">
{{ loading ? '登录中' : '登录' }}
</a-button>
</a-form-item>
</template>
<template v-else>
<QrLogin @login-success="onQrLoginSuccess" @login-error="onQrLoginError" />
</template>
</a-form>
<div class="copyright">
<span>© {{ new Date().getFullYear() }}</span>
<span class="sep">·</span>
<span>{{ config?.copyright || 'websoft.top Inc.' }}</span>
</div>
<a-modal v-model:open="imgCodeModalOpen" :width="340" :footer="null" title="发送验证码">
<div class="input-group modal-row">
<a-input
v-model:value="imgCode"
size="large"
allow-clear
:maxlength="5"
placeholder="请输入图形验证码"
@press-enter="sendSmsCode"
/>
<a-button class="captcha-btn">
<img alt="captcha" :src="captcha" @click="changeCaptcha" />
</a-button>
</div>
<a-button block size="large" type="primary" :loading="sendingSms" @click="sendSmsCode">
立即发送
</a-button>
</a-modal>
<a-modal v-model:open="selectUserOpen" :width="520" :footer="null" title="选择账号登录">
<a-list item-layout="horizontal" :data-source="admins">
<template #renderItem="{ item }">
<a-list-item class="list-item" @click="selectUser(item)">
<a-list-item-meta :description="`租户ID: ${item.tenantId}`">
<template #title>{{ item.tenantName || item.username }}</template>
<template #avatar>
<a-avatar :src="item.avatar" />
</template>
</a-list-item-meta>
<template #actions><RightOutlined /></template>
</a-list-item>
</template>
</a-list>
</a-modal>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, onUnmounted, reactive, ref } from 'vue'
import { message, type FormInstance } from 'ant-design-vue'
import {
LockOutlined,
MobileOutlined,
QrcodeOutlined,
RightOutlined,
SafetyCertificateOutlined,
UserOutlined
} from '@ant-design/icons-vue'
import QrLogin from '@/components/QrLogin.vue'
import { configWebsiteField, type Config } from '@/api/cms/cmsWebsiteField'
import { getCaptcha, login, loginBySms, sendSmsCaptcha } from '@/api/passport/login'
import type { LoginParam } from '@/api/passport/login/model'
import { listAdminsByPhoneAll } from '@/api/system/user'
import type { User } from '@/api/system/user/model'
import { TEMPLATE_ID } from '@/config/setting'
import { setToken } from '@/utils/token-util'
import type { QrCodeStatusResponse } from '@/api/passport/qrLogin'
definePageMeta({ layout: 'blank' })
const route = useRoute()
const defaultLogo = 'https://oss.wsdns.cn/20240822/0252ad4ed46449cdafe12f8d3d96c2ea.svg'
const config = ref<Config>()
const loading = ref(false)
const loginType = ref<'scan' | 'sms' | 'account'>('scan')
const captcha = ref('')
const captchaText = ref('')
const imgCodeModalOpen = ref(false)
const imgCode = ref('')
const sendingSms = ref(false)
const countdown = ref(0)
let countdownTimer: ReturnType<typeof setInterval> | null = null
const selectUserOpen = ref(false)
const admins = ref<User[]>([])
const formRef = ref<FormInstance>()
const form = reactive<LoginParam & { smsCode?: string }>({
username: '',
password: '',
phone: '',
code: '',
smsCode: '',
remember: true
})
const phoneReg = /^1[3-9]\d{9}$/
const rules = reactive({
username: [{ required: true, message: '请输入账号', type: 'string' }],
password: [{ required: true, message: '请输入密码', type: 'string' }],
code: [{ required: true, message: '请输入验证码', type: 'string' }],
phone: [
{ required: true, message: '请输入手机号码', type: 'string' },
{ pattern: phoneReg, message: '手机号格式不正确', trigger: 'blur' }
],
smsCode: [{ required: true, message: '请输入短信验证码', type: 'string' }]
})
const bgStyle = computed(() => {
const bg = config.value?.loginBgImg
if (!bg) return {}
return { backgroundImage: `url(${bg})` }
})
function setLoginType(type: 'scan' | 'sms' | 'account') {
loginType.value = type
}
function toggleScan() {
loginType.value = loginType.value === 'scan' ? 'sms' : 'scan'
}
function stopCountdown() {
if (countdownTimer) clearInterval(countdownTimer)
countdownTimer = null
countdown.value = 0
}
async function changeCaptcha() {
try {
const data = await getCaptcha()
captcha.value = data.base64
captchaText.value = data.text
} catch (e: unknown) {
message.error(e instanceof Error ? e.message : '获取验证码失败')
}
}
function openImgCodeModal() {
if (!form.phone) return message.error('请输入手机号码')
imgCode.value = ''
changeCaptcha()
imgCodeModalOpen.value = true
}
async function sendSmsCode() {
if (!imgCode.value) return message.error('请输入图形验证码')
if (captchaText.value && imgCode.value.toLowerCase() !== captchaText.value.toLowerCase()) {
return message.error('图形验证码不正确')
}
sendingSms.value = true
try {
await sendSmsCaptcha({ phone: form.phone })
message.success('短信验证码发送成功,请注意查收')
imgCodeModalOpen.value = false
countdown.value = 30
stopCountdown()
countdown.value = 30
countdownTimer = setInterval(() => {
countdown.value -= 1
if (countdown.value <= 0) stopCountdown()
}, 1000)
} catch (e: unknown) {
message.error(e instanceof Error ? e.message : '发送失败')
} finally {
sendingSms.value = false
}
}
async function goAfterLogin() {
const from = typeof route.query.from === 'string' ? route.query.from : ''
await navigateTo(from || '/')
}
async function submitAccount() {
if (!formRef.value) return
loading.value = true
try {
await formRef.value.validate()
const msg = await login({
username: form.username,
password: form.password,
code: String(form.code || '').toLowerCase(),
remember: !!form.remember
})
message.success(msg || '登录成功')
await goAfterLogin()
} catch (e: unknown) {
message.error(e instanceof Error ? e.message : '登录失败')
changeCaptcha()
} finally {
loading.value = false
}
}
async function submitSms() {
if (!formRef.value) return
loading.value = true
try {
await formRef.value.validate()
const msg = await loginBySms({
phone: form.phone,
code: String(form.smsCode || '').toLowerCase(),
tenantId: form.tenantId,
remember: !!form.remember
})
if (msg === '请选择登录用户') {
selectUserOpen.value = true
admins.value = await listAdminsByPhoneAll({
phone: form.phone,
templateId: Number(TEMPLATE_ID)
})
return
}
message.success(msg || '登录成功')
await goAfterLogin()
} catch (e: unknown) {
message.error(e instanceof Error ? e.message : '登录失败')
} finally {
loading.value = false
}
}
async function selectUser(user: User) {
form.tenantId = user.tenantId
selectUserOpen.value = false
await submitSms()
}
function onQrLoginSuccess(payload: QrCodeStatusResponse) {
const accessToken = payload.accessToken || payload.access_token
if (accessToken) setToken(String(accessToken), true)
if (payload.tenantId && import.meta.client) localStorage.setItem('TenantId', String(payload.tenantId))
if (import.meta.client && typeof payload.userInfo === 'object' && payload.userInfo && 'userId' in payload.userInfo) {
const userId = (payload.userInfo as { userId?: unknown }).userId
if (userId !== undefined && userId !== null) localStorage.setItem('UserId', String(userId))
}
message.success('扫码登录成功')
goAfterLogin()
}
function onQrLoginError(error: string) {
message.error(error || '扫码登录失败')
}
onMounted(async () => {
try {
config.value = await configWebsiteField({ lang: 'zh-CN' })
} catch {
// ignore config errors
}
changeCaptcha()
if (typeof route.query.loginPhone === 'string') {
form.phone = route.query.loginPhone
loginType.value = 'sms'
}
})
onUnmounted(() => {
stopCountdown()
})
</script>
<style scoped>
.login-page {
position: relative;
min-height: 100vh;
background: #111827;
background-size: cover;
background-position: center;
padding: 48px 16px;
}
.overlay {
position: absolute;
inset: 0;
background: rgba(0, 0, 0, 0.25);
}
.brand {
position: absolute;
top: 18px;
left: 18px;
display: flex;
align-items: center;
gap: 10px;
z-index: 2;
}
.brand-logo {
width: 28px;
height: 28px;
border-radius: 6px;
}
.brand-name {
margin: 0;
font-size: 16px;
font-weight: 600;
color: #fff;
}
.brand-title {
position: absolute;
top: 16%;
left: 50%;
transform: translateX(-50%);
z-index: 2;
color: #fff;
font-size: 24px;
font-weight: 600;
text-align: center;
padding: 0 12px;
}
.card {
width: 390px;
max-width: 100%;
margin: 0 auto;
background: #fff;
padding: 0 28px 22px;
border-radius: 10px;
box-shadow: 0 10px 35px rgba(0, 0, 0, 0.25);
position: relative;
z-index: 2;
}
.card-header {
display: flex;
align-items: center;
justify-content: center;
position: relative;
gap: 12px;
padding: 18px 0 6px;
}
.card-title {
margin: 0;
font-size: 18px;
font-weight: 600;
}
.tab {
margin: 0;
font-size: 16px;
font-weight: 600;
cursor: pointer;
color: #374151;
}
.tab.active {
color: #1677ff;
}
.switch {
position: absolute;
right: 0;
top: 14px;
}
.input-group {
display: flex;
align-items: center;
}
.captcha-btn {
width: 140px;
height: 40px;
margin-left: 10px;
padding: 0;
display: inline-flex;
align-items: center;
justify-content: center;
white-space: nowrap;
}
.captcha-btn img {
width: 100%;
height: 100%;
object-fit: contain;
}
.row {
display: flex;
justify-content: space-between;
align-items: center;
}
.copyright {
z-index: 2;
position: relative;
margin-top: 28px;
text-align: center;
color: rgba(255, 255, 255, 0.85);
font-size: 12px;
}
.sep {
margin: 0 8px;
opacity: 0.7;
}
.modal-row {
margin-bottom: 16px;
}
.list-item {
cursor: pointer;
}
</style>