新版官网模板

This commit is contained in:
2026-04-29 01:33:33 +08:00
commit 0d82386f8f
341 changed files with 64526 additions and 0 deletions

980
app/pages/login.vue Normal file
View File

@@ -0,0 +1,980 @@
<template>
<div class="login-page">
<!-- 左侧品牌展示区 -->
<div :style="bgStyle" class="login-left">
<div class="left-overlay" />
<!-- 装饰网格线 -->
<div class="left-grid" />
<!-- 浮动装饰圆点 -->
<div class="dot dot-1" />
<div class="dot dot-2" />
<div class="dot dot-3" />
<div class="left-content">
<!-- 品牌 logo -->
<div class="brand">
<div class="brand-logo-text">决策咨询网</div>
<div class="brand-site-name">GX Decision Consulting</div>
</div>
<!-- 中央文案 -->
<div class="hero-text">
<div class="hero-tag">广西决策咨询中心</div>
<h1 class="hero-title">汇聚专家智慧<br>服务政府决策</h1>
<p class="hero-desc">广西决策咨询网是自治区党委政府决策咨询服务的重要平台<br>汇聚各领域专家学者提供权威决策咨询服务</p>
<div class="hero-stats">
<div class="stat-item">
<span class="stat-num">200+</span>
<span class="stat-label">认证专家</span>
</div>
<div class="stat-divider" />
<div class="stat-item">
<span class="stat-num">500+</span>
<span class="stat-label">会员单位</span>
</div>
<div class="stat-divider" />
<div class="stat-item">
<span class="stat-num">1000+</span>
<span class="stat-label">建言献策</span>
</div>
</div>
</div>
<!-- 底部 -->
<div class="left-footer">
<span>© {{ new Date().getFullYear() }} 广西决策咨询中心 保留所有权利</span>
</div>
</div>
</div>
<!-- 右侧登录表单区 -->
<div class="login-right">
<div class="form-wrapper">
<!-- 移动端 logo仅小屏显示 -->
<div class="mobile-brand">
<img :src="config?.sysLogo || defaultLogo" alt="logo" class="mobile-logo" />
<span class="mobile-brand-name">{{ config?.siteName || '广西决策咨询网' }}</span>
</div>
<!-- 登录卡片头部 -->
<div class="form-header">
<h2 class="form-title">{{ '欢迎回来' }}</h2>
<p class="form-subtitle">{{ '请登录您的账号以继续' }}</p>
</div>
<!-- 切换标签 -->
<div class="login-tabs">
<button
:class="{ active: loginType === 'scan' }"
class="login-tab"
@click="setLoginType('scan')"
>
<QrcodeOutlined />
{{ '扫码登录' }}
</button>
<button
:class="{ active: loginType === 'sms' }"
class="login-tab"
@click="setLoginType('sms')"
>
<MobileOutlined />
{{ '手机号登录' }}
</button>
</div>
<!-- 表单区域 -->
<a-form ref="formRef" :model="form" :rules="rules" class="login-form">
<!-- 手机号登录 -->
<template v-if="loginType === 'sms'">
<a-form-item name="phone">
<a-input
v-model:value="form.phone"
:maxlength="11"
:placeholder="'请输入手机号码'"
allow-clear
class="form-input"
size="large"
>
<template #prefix>
<span class="phone-prefix">+86</span>
<span class="phone-prefix-divider" />
</template>
</a-input>
</a-form-item>
<a-form-item name="smsCode">
<div class="captcha-row">
<a-input
v-model:value="form.smsCode"
:maxlength="6"
:placeholder="'请输入验证码'"
allow-clear
class="form-input"
size="large"
@press-enter="submitSms"
/>
<button
:class="{ disabled: countdown > 0 }"
:disabled="countdown > 0"
class="sms-btn"
@click.prevent="openImgCodeModal"
>
<span v-if="countdown <= 0">{{ '发送验证码' }}</span>
<span v-else>{{ countdown }}{{ 's 后重发' }}</span>
</button>
</div>
</a-form-item>
<!-- 注册协议和隐私政策 -->
<div class="agreement-row">
<a-checkbox v-model:checked="form.agreement">
<span class="agreement-text">
{{ '我已阅读并同意' }}
<NuxtLink class="agreement-link" target="_blank" to="/agreement" @click.stop>{{ '注册协议' }}</NuxtLink>
{{ '' || '' }}
<NuxtLink class="agreement-link" target="_blank" to="/privacy" @click.stop>{{ '隐私政策' }}</NuxtLink>
</span>
</a-checkbox>
</div>
<a-form-item>
<a-button
:loading="loading"
block
class="submit-btn"
size="large"
type="primary"
@click="submitSms"
>
{{ loading ? '登录中…' : '立即登录' }}
</a-button>
</a-form-item>
</template>
<!-- 扫码登录 -->
<template v-else>
<QrLogin @login-success="onQrLoginSuccess" @login-error="onQrLoginError" />
</template>
</a-form>
<!-- 底部切换扫码 -->
<div class="form-footer">
<button class="switch-scan-btn" @click="toggleScan">
<QrcodeOutlined v-if="loginType !== 'scan'" />
<MobileOutlined v-else />
{{ loginType === 'scan' ? '切换到手机号登录' : '切换到扫码登录' }}
</button>
</div>
</div>
</div>
<!-- 图形验证码弹窗发送短信用 -->
<a-modal v-model:open="imgCodeModalOpen" :footer="null" :title="'安全验证'" :width="360">
<p class="modal-tip">{{ '请先完成图形验证码验证' }}</p>
<div class="captcha-row modal-captcha">
<a-input
v-model:value="imgCode"
:maxlength="5"
:placeholder="'请输入图形验证码'"
allow-clear
size="large"
@press-enter="sendSmsCode"
/>
<button :title="'点击刷新'" class="captcha-img-btn" @click.prevent="changeCaptcha">
<img :src="captcha" alt="captcha" />
</button>
</div>
<a-button :loading="sendingSms" block class="submit-btn" size="large" type="primary" @click="sendSmsCode">
{{ '发送验证码' }}
</a-button>
</a-modal>
<!-- 选择账号弹窗 -->
<a-modal v-model:open="selectUserOpen" :footer="null" :title="'选择账号登录'" :width="520">
<a-list :data-source="admins" item-layout="horizontal">
<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 lang="ts" setup>
import { computed, onMounted, onUnmounted, reactive, ref } from 'vue'
import { message, type FormInstance } from 'ant-design-vue'
import {
MobileOutlined,
QrcodeOutlined,
RightOutlined
} from '@ant-design/icons-vue'
import QrLogin from '@/components/QrLogin.vue'
import { configWebsiteField, type Config } from '@/api/cms/cmsWebsiteField'
import { loginBySms, sendSmsCaptcha, getCaptcha } 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 { getUserInfo } from '@/api/layout'
import { TEMPLATE_ID } from '@/config/setting'
import { setToken } from '@/utils/token-util'
import type { QrCodeStatusResponse } from '@/api/passport/qrLogin'
definePageMeta({ layout: false })
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'>('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; agreement?: boolean }>({
phone: '',
smsCode: '',
remember: true,
agreement: false
})
const phoneReg = /^1[3-9]\d{9}$/
const rules = reactive({
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') {
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 || '/')
}
function persistUserId(userId: unknown) {
if (!import.meta.client) return
const n = typeof userId === 'number' ? userId : Number(userId)
if (Number.isFinite(n) && n > 0) localStorage.setItem('UserId', String(n))
}
async function ensureUserIdPersisted(seed?: unknown) {
if (!import.meta.client) return
persistUserId(seed)
try {
if (localStorage.getItem('UserId')) return
} catch {
// ignore
}
try {
const me = await getUserInfo()
persistUserId(me.userId)
} catch {
// ignore (don't block login redirect)
}
}
async function submitSms() {
if (!formRef.value) return
// 校验协议勾选
if (!form.agreement) {
return message.error('请先阅读并同意《注册协议》和《隐私政策》')
}
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 ensureUserIdPersisted()
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()
}
async 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))
const seedUserId =
typeof payload.userInfo === 'object' && payload.userInfo && 'userId' in payload.userInfo
? (payload.userInfo as { userId?: unknown }).userId
: undefined
await ensureUserIdPersisted(seedUserId)
message.success('扫码登录成功')
await 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 {
display: flex;
min-height: 100vh;
background: #f7f8fa;
}
/* ===== 左侧品牌区 ===== */
.login-left {
flex: 1;
position: relative;
background: linear-gradient(150deg, #0d1b2a 0%, #1e3a5f 30%, #2563eb 60%, #1e3a5f 100%);
background-size: cover;
background-position: center;
display: flex;
flex-direction: column;
overflow: hidden;
min-height: 100vh;
}
.left-overlay {
position: absolute;
inset: 0;
background: linear-gradient(150deg, rgba(13, 27, 42, 0.75) 0%, rgba(30, 58, 95, 0.55) 100%);
}
/* 装饰网格 */
.left-grid {
position: absolute;
inset: 0;
background-image:
linear-gradient(rgba(255,255,255,0.04) 1px, transparent 1px),
linear-gradient(90deg, rgba(255,255,255,0.04) 1px, transparent 1px);
background-size: 48px 48px;
pointer-events: none;
}
/* 浮动装饰圆点 */
.dot {
position: absolute;
border-radius: 50%;
pointer-events: none;
}
.dot-1 {
width: 420px;
height: 420px;
background: radial-gradient(circle, rgba(37, 99, 235, 0.22) 0%, transparent 65%);
top: -120px;
right: -80px;
}
.dot-2 {
width: 320px;
height: 320px;
background: radial-gradient(circle, rgba(99, 179, 237, 0.18) 0%, transparent 65%);
bottom: 60px;
left: -60px;
}
.dot-3 {
width: 180px;
height: 180px;
background: radial-gradient(circle, rgba(249, 115, 22, 0.14) 0%, transparent 65%);
top: 45%;
left: 55%;
}
/* 光晕大圆 */
.login-left::before {
content: '';
position: absolute;
width: 560px;
height: 560px;
border-radius: 50%;
border: 1px solid rgba(255, 255, 255, 0.06);
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
pointer-events: none;
}
.login-left::after {
content: '';
position: absolute;
width: 380px;
height: 380px;
border-radius: 50%;
border: 1px solid rgba(255, 255, 255, 0.08);
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
pointer-events: none;
}
.left-content {
position: relative;
z-index: 1;
display: flex;
flex-direction: column;
height: 100%;
min-height: 100vh;
padding: 48px 56px;
}
/* 品牌区 */
.brand {
display: flex;
align-items: center;
gap: 0;
}
/* 品牌 logo 图片 */
.brand-logo-img {
height: 22px;
width: auto;
object-fit: contain;
}
/* 品牌文字 logo */
.brand-logo-text {
font-size: 22px;
font-weight: 800;
color: #fff;
letter-spacing: 0.04em;
text-shadow: 0 2px 8px rgba(0,0,0,0.3);
}
/* site-name 渐变文字,与导航栏保持一致 */
.brand-site-name {
color: #fff;
font-family: 'Alimama FangYuanTi VF,sans-serif', sans-serif;
font-size: 18px;
font-weight: 700;
letter-spacing: 0.04em;
white-space: nowrap;
line-height: 1;
background: linear-gradient(135deg, #ffffff 0%, #93c5fd 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
margin-left: 8px;
}
/* Hero 文案 */
.hero-text {
flex: 1;
display: flex;
flex-direction: column;
justify-content: center;
padding: 48px 0 36px;
}
.hero-tag {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 5px 14px;
border-radius: 20px;
background: rgba(37, 99, 235, 0.2);
border: 1px solid rgba(37, 99, 235, 0.35);
color: #93c5fd;
font-size: 12px;
font-weight: 500;
letter-spacing: 0.5px;
margin-bottom: 24px;
width: fit-content;
}
.hero-title {
font-size: 46px;
font-weight: 800;
color: #fff;
line-height: 1.18;
margin-bottom: 22px;
letter-spacing: -1px;
white-space: pre-line;
}
.hero-desc {
font-size: 15px;
color: rgba(255, 255, 255, 0.55);
margin-bottom: 44px;
line-height: 1.8;
}
/* 统计数字 */
.hero-stats {
display: flex;
align-items: center;
gap: 0;
background: rgba(255, 255, 255, 0.06);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 14px;
padding: 20px 28px;
backdrop-filter: blur(8px);
width: fit-content;
}
.stat-item {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
padding: 0 28px;
}
.stat-item:first-child {
padding-left: 0;
}
.stat-item:last-child {
padding-right: 0;
}
.stat-num {
font-size: 22px;
font-weight: 800;
color: #fff;
letter-spacing: -0.5px;
line-height: 1;
}
.stat-label {
font-size: 12px;
color: rgba(255, 255, 255, 0.45);
}
.stat-divider {
width: 1px;
height: 36px;
background: rgba(255, 255, 255, 0.12);
}
.left-footer {
color: rgba(255, 255, 255, 0.25);
font-size: 12px;
}
/* ===== 右侧表单区 ===== */
.login-right {
width: 65%;
min-width: 380px;
background: #fff;
display: flex;
align-items: center;
justify-content: center;
padding: 64px 48px;
box-shadow: -1px 0 0 0 #f0f0f0;
}
.form-wrapper {
width: 100%;
max-width: 400px;
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.08);
padding: 40px;
}
/* 移动端品牌 */
.mobile-brand {
display: none;
align-items: center;
gap: 10px;
margin-bottom: 40px;
}
.mobile-logo {
width: 34px;
height: 34px;
border-radius: 8px;
}
.mobile-brand-name {
font-size: 18px;
font-weight: 700;
color: #111;
}
/* 表单头部 */
.form-header {
margin-bottom: 36px;
}
.form-title {
font-size: 28px;
font-weight: 800;
color: #0d0d0d;
margin: 0 0 10px;
letter-spacing: -0.5px;
}
.form-subtitle {
font-size: 14px;
color: #8c8c8c;
margin: 0;
line-height: 1.5;
}
/* 切换 Tab */
.login-tabs {
display: flex;
gap: 0;
margin-bottom: 32px;
border-bottom: 1.5px solid #f0f0f0;
}
.login-tab {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
padding: 10px 0 12px;
border: none;
border-bottom: 2px solid transparent;
background: transparent;
font-size: 14px;
font-weight: 500;
color: #a0a0a0;
cursor: pointer;
transition: all 0.2s ease;
outline: none;
margin-bottom: -1.5px;
}
.login-tab.active {
color: #0d0d0d;
border-bottom-color: #6366f1;
font-weight: 600;
}
.login-tab:hover:not(.active) {
color: #555;
}
/* 表单输入框 */
.login-form :deep(.ant-input-affix-wrapper),
.login-form :deep(.ant-input),
.login-form :deep(.ant-input-password) {
border-radius: 10px;
border-color: #ebebeb;
background: #fafafa;
transition: all 0.2s;
padding-top: 6px;
padding-bottom: 6px;
align-items: center;
}
.login-form :deep(.ant-input-affix-wrapper:focus),
.login-form :deep(.ant-input-affix-wrapper-focused) {
border-color: #2563eb;
background: #fff;
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.1);
}
.login-form :deep(.ant-form-item) {
margin-bottom: 18px;
}
.input-icon {
color: #bfbfbf;
font-size: 15px;
}
/* 验证码行 */
.captcha-row {
display: flex;
gap: 10px;
align-items: center;
}
.captcha-row :deep(.ant-input-affix-wrapper),
.captcha-row :deep(.ant-input) {
flex: 1;
min-width: 0;
border-radius: 10px !important;
}
/* 图形验证码按钮 */
.captcha-img-btn {
width: 148px;
height: 48px;
flex-shrink: 0;
border: 1px solid #ebebeb;
border-radius: 10px;
overflow: hidden;
background: #fafafa;
cursor: pointer;
padding: 0;
display: flex;
align-items: center;
justify-content: center;
transition: border-color 0.2s;
}
.captcha-img-btn:hover {
border-color: #6366f1;
}
.captcha-img-btn img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.captcha-placeholder {
font-size: 12px;
color: #bfbfbf;
}
/* 发送验证码按钮 */
.sms-btn {
flex-shrink: 0;
padding: 0 18px;
height: 48px;
border: 1px solid #2563eb;
border-radius: 10px;
background: transparent;
color: #2563eb;
font-size: 13px;
font-weight: 500;
cursor: pointer;
white-space: nowrap;
transition: all 0.2s;
outline: none;
}
.sms-btn:hover:not(.disabled) {
background: #2563eb;
color: #fff;
}
.sms-btn.disabled {
border-color: #e0e0e0;
color: #bfbfbf;
cursor: not-allowed;
}
/* 手机号前缀 +86prefix 模式) */
.phone-prefix {
font-size: 14px;
color: #555;
font-weight: 500;
margin-right: 2px;
}
.phone-prefix-divider {
display: inline-block;
width: 1px;
height: 14px;
background: #d9d9d9;
margin-left: 8px;
vertical-align: middle;
}
/* 记住登录 */
.form-options {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 24px;
margin-top: -4px;
}
/* 提交按钮 */
.submit-btn.ant-btn-primary {
background: linear-gradient(135deg, #1e3a5f 0%, #2563eb 100%) !important;
border: none !important;
border-radius: 10px !important;
height: 48px !important;
font-size: 15px !important;
font-weight: 600 !important;
letter-spacing: 0.3px !important;
box-shadow: 0 4px 16px rgba(99, 102, 241, 0.28) !important;
transition: all 0.22s !important;
}
.submit-btn.ant-btn-primary:hover:not(:disabled) {
transform: translateY(-1px) !important;
box-shadow: 0 8px 24px rgba(99, 102, 241, 0.38) !important;
}
/* 扫码切换 */
.form-footer {
margin-top: 28px;
text-align: center;
}
.switch-scan-btn {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 8px 18px;
border: 1px solid #ebebeb;
border-radius: 20px;
background: transparent;
color: #8c8c8c;
font-size: 13px;
cursor: pointer;
transition: all 0.2s;
outline: none;
}
.switch-scan-btn:hover {
border-color: #2563eb;
color: #2563eb;
background: rgba(37, 99, 235, 0.04);
}
/* 弹窗 */
.modal-tip {
font-size: 13px;
color: #8c8c8c;
margin-bottom: 16px;
}
.modal-captcha {
margin-bottom: 16px;
}
/* 账号列表 */
.list-item {
cursor: pointer;
transition: background 0.15s;
padding: 8px 12px;
border-radius: 8px;
}
.list-item:hover {
background: #f5f5f5;
}
/* 注册协议和隐私政策勾选 */
.agreement-row {
margin-bottom: 20px;
line-height: 1.6;
}
.agreement-row :deep(.ant-checkbox-wrapper) {
font-size: 13px;
color: #666;
align-items: flex-start;
}
.agreement-row :deep(.ant-checkbox) {
margin-top: 2px;
}
.agreement-text {
font-size: 13px;
color: #666;
line-height: 1.6;
}
.agreement-link {
color: #6366f1;
text-decoration: none;
font-size: 13px;
}
.agreement-link:hover {
text-decoration: underline;
}
/* ===== 响应式 ===== */
@media (max-width: 900px) {
.login-left {
display: none;
}
.login-right {
width: 100%;
min-width: unset;
padding: 48px 32px;
min-height: 100vh;
box-shadow: none;
}
.mobile-brand {
display: flex;
}
.form-wrapper {
max-width: 400px;
margin: 0 auto;
}
}
</style>