feat(app): 初始化项目配置和页面结构
- 添加 .dockerignore 和 .env.example 配置文件 - 添加 .gitignore 忽略规则配置 - 创建服务端代理API路由(_file、_modules、_server) - 集成 Ant Design Vue 组件库并配置SSR样式提取 - 定义API响应类型封装 - 创建基础布局组件(blank、console) - 实现应用中心页面和组件(AppsCenter) - 添加文章列表测试页面 - 配置控制台导航菜单结构 - 实现控制台头部组件 - 创建联系页面表单
This commit is contained in:
516
app/pages/login.vue
Normal file
516
app/pages/login.vue
Normal file
@@ -0,0 +1,516 @@
|
||||
<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>
|
||||
Reference in New Issue
Block a user