- 移除经营范围按钮,精简导航栏 - 实现文章标题链接功能,提升用户体验 - 添加商品详情页面包屑导航,支持分类跳转 - 引入配送管理相关页面(区域、接单台、配送员、派单) - 替换控制台布局为站点头部和底部组件 - 重构商品分类页面,集成CMS导航功能 - 新增文章详情页面,支持多种访问方式 - 删除已迁移的创建应用和空应用页面 - 优化样式和组件导入,提升代码质量
540 lines
14 KiB
Vue
540 lines
14 KiB
Vue
<template>
|
|
<div class="login-shell">
|
|
<SiteHeader />
|
|
|
|
<div class="login-page" :style="bgStyle">
|
|
|
|
<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 hidden">
|
|
<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>
|
|
|
|
<SiteFooter />
|
|
</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'
|
|
|
|
// Login page is a public page: keep a lightweight layout and render header/footer locally.
|
|
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-shell {
|
|
min-height: 100vh;
|
|
display: flex;
|
|
flex-direction: column;
|
|
}
|
|
|
|
.login-page {
|
|
position: relative;
|
|
flex: 1;
|
|
min-height: 0;
|
|
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>
|