批量推送

This commit is contained in:
郭庆泰
2026-08-16 21:24:53 +08:00
parent 0c093076a4
commit ccfd03a6eb
55 changed files with 3772 additions and 577 deletions
+335 -45
View File
@@ -30,10 +30,15 @@
<!-- 右侧登录卡片 -->
<section class="login-section">
<div class="login-card">
<h1 class="login-title">账号登录</h1>
<p class="login-subtitle">请输入您的账号信息</p>
<h1 class="login-title">{{ loginMode === 'password' ? '账号登录' : '手机号登录' }}</h1>
<p class="login-subtitle">{{ loginMode === 'password' ? '请输入您的账号信息' : '请输入手机号接收验证码' }}</p>
<form id="loginForm" @submit.prevent="onSubmit">
<div class="login-tabs">
<span class="tab" :class="{ active: loginMode === 'password' }" @click="switchMode('password')">账号密码登录</span>
<span class="tab" :class="{ active: loginMode === 'sms' }" @click="switchMode('sms')">手机验证码登录</span>
</div>
<form v-if="loginMode === 'password'" id="loginForm" @submit.prevent="onSubmit">
<div class="form-group">
<svg class="input-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/>
@@ -65,6 +70,29 @@
<a class="bottom-link" @click="onForgot">忘记密码</a>
</div>
</form>
<form v-else id="smsLoginForm" @submit.prevent="onSmsLoginSubmit">
<div class="form-group">
<svg class="input-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72 12.84 12.84 0 0 0 .7 2.81 2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45 12.84 12.84 0 0 0 2.81.7A2 2 0 0 1 22 16.92z"/>
</svg>
<input v-model="smsForm.phone" type="text" class="form-input" placeholder="请输入手机号" maxlength="11" autocomplete="tel">
</div>
<div class="form-group sms-group">
<input v-model="smsForm.smsCode" type="text" class="form-input sms-input" placeholder="短信验证码" maxlength="6">
<button type="button" class="sms-btn" :disabled="smsSmsLocked || smsSmsCountdown > 0 || !smsForm.phone" @click="sendLoginSmsCode">
{{ smsSmsLocked ? '发送中...' : smsSmsCountdown > 0 ? `${smsSmsCountdown}s 后重试` : '获取验证码' }}
</button>
</div>
<button type="submit" class="login-btn" :disabled="smsLoginLocked">{{ smsLoginLocked ? '登录中...' : '登 录' }}</button>
<div class="form-bottom-links">
<a class="bottom-link" @click="onRegister">新用户注册</a>
<a class="bottom-link" @click="onForgot">忘记密码</a>
</div>
</form>
</div>
</section>
</main>
@@ -94,15 +122,68 @@
</div>
</div>
</div>
<!-- 忘记密码弹窗 -->
<div class="modal-overlay" :class="{ active: showForgotModal }" @click.self="closeForgot">
<div class="modal">
<h2 class="modal-title">忘记密码</h2>
<p class="modal-subtitle">通过注册时填写的手机号验证后重置密码</p>
<form @submit.prevent="onResetSubmit">
<div class="form-group">
<svg class="input-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72 12.84 12.84 0 0 0 .7 2.81 2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45 12.84 12.84 0 0 0 2.81.7A2 2 0 0 1 22 16.92z"/>
</svg>
<input v-model="forgotForm.phone" type="text" class="form-input" placeholder="请输入注册时的手机号" maxlength="11" autocomplete="tel">
</div>
<div class="form-group sms-group">
<input v-model="forgotForm.smsCode" type="text" class="form-input sms-input" placeholder="短信验证码" maxlength="6">
<button type="button" class="sms-btn" :disabled="forgotSmsLocked || forgotSmsCountdown > 0 || !forgotForm.phone" @click="sendForgotSms">
{{ forgotSmsLocked ? '发送中...' : forgotSmsCountdown > 0 ? `${forgotSmsCountdown}s 后重试` : '获取验证码' }}
</button>
</div>
<div class="form-group">
<svg class="input-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="3" y="11" width="18" height="11" rx="2" ry="2"/>
<path d="M7 11V7a5 5 0 0 1 10 0v4"/>
</svg>
<input v-model="forgotForm.password" type="password" class="form-input" placeholder="新密码 (6-20 位)" autocomplete="new-password">
</div>
<div class="form-group">
<svg class="input-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="3" y="11" width="18" height="11" rx="2" ry="2"/>
<path d="M7 11V7a5 5 0 0 1 10 0v4"/>
</svg>
<input v-model="forgotForm.confirmPassword" type="password" class="form-input" placeholder="确认新密码" autocomplete="new-password">
</div>
<div class="modal-actions">
<button class="modal-btn secondary" type="button" @click="closeForgot">取消</button>
<button class="modal-btn primary" type="submit" :disabled="forgotLoading">{{ forgotLoading ? '提交中...' : '重置密码' }}</button>
</div>
</form>
</div>
</div>
</div>
</template>
<script setup>
import { reactive, ref, onMounted } from 'vue'
import { reactive, ref, onMounted, onUnmounted } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { login, getInfo, getCaptcha } from '@/api/auth'
import request from '@/utils/request'
import { login, getInfo, getCaptcha, sendLoginSms, smsLogin } from '@/api/auth'
import { useUserStore } from '@/store/user'
import { useAsyncLock } from '@/utils/useAsyncLock'
// 图形验证码刷新 + 忘记密码短信 + 短信登录 各自独立锁 (避免重复请求)
const { locked: captchaLocked, run: runCaptchaOnce } = useAsyncLock()
const { locked: forgotSmsLocked, run: runForgotSmsOnce } = useAsyncLock()
const { locked: smsSmsLocked, run: runSmsOnce } = useAsyncLock()
const { locked: smsLoginLocked, run: runSmsLoginOnce } = useAsyncLock()
const router = useRouter()
const userStore = useUserStore()
@@ -114,6 +195,12 @@ const loading = ref(false)
const showRoleModal = ref(false)
const registerUserType = ref('')
// 登录模式: 'password' | 'sms'
const loginMode = ref('password')
const smsForm = reactive({ phone: '', smsCode: '', uuid: '' })
const smsSmsCountdown = ref(0)
let smsSmsTimer = null
const registerTypes = [
{ value: 'expert', name: '项目参与专家', desc: '负责项目方案设计与评审' },
{ value: 'executor', name: '项目执行单位(供应商)', desc: '承担项目执行,需走入库流程' },
@@ -130,16 +217,18 @@ const userTypes = [
]
async function loadCaptcha() {
try {
const res = await getCaptcha()
captchaEnabled.value = res?.captchaEnabled !== false
if (res?.uuid) {
form.uuid = res.uuid
captchaImg.value = 'data:image/png;base64,' + (res.img || '')
await runCaptchaOnce(async () => {
try {
const res = await getCaptcha()
captchaEnabled.value = res?.captchaEnabled !== false
if (res?.uuid) {
form.uuid = res.uuid
captchaImg.value = 'data:image/png;base64,' + (res.img || '')
}
} catch (e) {
captchaImg.value = ''
}
} catch (e) {
captchaImg.value = ''
}
})
}
async function onSubmit() {
@@ -154,36 +243,7 @@ async function onSubmit() {
loading.value = true
try {
const res = await login({ username: form.username, password: form.password, code: form.code || '', uuid: form.uuid || '' })
// username 推断仅作兜底, 真实 role 以 /getInfo 返回的 sys_user.role_type 为准
const fallbackRole = autoRole(form.username)
userStore.setToken(res.token)
// /login 接口只返回 token; 调 /getInfo 拿完整 user (含 accountType/parentUserId/roleType, 让 isMain/isSub/role 都从后端真实值取)
let role = fallbackRole
try {
const info = await getInfo()
const u = info.user || {}
role = u.roleType || fallbackRole
userStore.setUser({
userId: u.userId,
userName: u.userName || form.username,
nickName: u.nickName || form.username,
accountType: u.accountType || 'MAIN',
parentUserId: u.parentUserId || null,
role
})
} catch {
// /getInfo 失败时回退: 只存基本信息
userStore.setUser({
userId: res.userId,
userName: form.username,
nickName: form.username,
accountType: 'MAIN',
parentUserId: null,
role: fallbackRole
})
}
ElMessage.success(`欢迎,${form.username}${userTypes.find(u => u.value === role)?.name || role}`)
router.replace(roleHome[role] || '/leader/home')
await afterLogin(res.token, form.username, autoRole(form.username))
} catch (e) {
// 错误提示已由 utils/request.js 拦截器统一弹 (ElMessage.error), 这里只刷新验证码
loadCaptcha()
@@ -192,6 +252,97 @@ async function onSubmit() {
}
}
/**
* 登录后置: 存 token → 调 /getInfo 拿真实 user → 跳角色首页
* 密码登录和短信登录共用
*/
async function afterLogin(token, displayName, fallbackRole) {
userStore.setToken(token)
let role = fallbackRole
try {
const info = await getInfo()
const u = info.user || {}
role = u.roleType || fallbackRole
userStore.setUser({
userId: u.userId,
userName: u.userName || displayName,
nickName: u.nickName || displayName,
accountType: u.accountType || 'MAIN',
parentUserId: u.parentUserId || null,
role
})
} catch {
// /getInfo 失败时回退: 只存基本信息
userStore.setUser({
userId: null,
userName: displayName,
nickName: displayName,
accountType: 'MAIN',
parentUserId: null,
role: fallbackRole
})
}
ElMessage.success(`欢迎,${displayName}${userTypes.find(u => u.value === role)?.name || role}`)
router.replace(roleHome[role] || '/leader/home')
}
function switchMode(mode) {
if (loginMode.value === mode) return
loginMode.value = mode
// 切换时清空两条表单, 避免脏数据
if (mode === 'password') {
smsForm.phone = ''
smsForm.smsCode = ''
smsForm.uuid = ''
if (smsSmsTimer) { clearInterval(smsSmsTimer); smsSmsTimer = null }
smsSmsCountdown.value = 0
} else {
form.username = ''
form.password = ''
form.code = ''
// 切到短信登录时不需要图形验证码, 不再 loadCaptcha
}
}
async function sendLoginSmsCode() {
if (!/^1\d{10}$/.test(smsForm.phone)) {
return ElMessage.warning('请输入正确的手机号')
}
await runSmsOnce(async () => {
try {
const r = await sendLoginSms(smsForm.phone)
smsForm.uuid = r.data || ''
ElMessage.success('验证码已发送')
smsSmsCountdown.value = 60
smsSmsTimer = setInterval(() => {
smsSmsCountdown.value--
if (smsSmsCountdown.value <= 0) {
clearInterval(smsSmsTimer)
smsSmsTimer = null
}
}, 1000)
} catch (e) {
// request.js 已弹错误, 这里只重置 uuid
smsForm.uuid = ''
}
})
}
async function onSmsLoginSubmit() {
if (!/^1\d{10}$/.test(smsForm.phone)) return ElMessage.warning('请输入正确的手机号')
if (!smsForm.smsCode) return ElMessage.warning('请输入短信验证码')
if (!smsForm.uuid) return ElMessage.warning('请先获取验证码')
await runSmsLoginOnce(async () => {
try {
const res = await smsLogin({ phone: smsForm.phone, smsCode: smsForm.smsCode, uuid: smsForm.uuid })
// 短信登录后无 username, 用 phone 作为显示名; role 兜底默认 leader
await afterLogin(res.token, smsForm.phone, 'leader')
} catch (e) {
// request.js 已弹错误
}
})
}
function autoRole(username) {
const u = (username || '').toLowerCase()
if (u === 'admin' || u === 'ry') return 'admin'
@@ -241,10 +392,88 @@ function confirmRegister() {
}
function onForgot() {
ElMessage.info('请联系系统管理员重置密码')
showForgotModal.value = true
}
// ===== 忘记密码弹窗 =====
const showForgotModal = ref(false)
const forgotLoading = ref(false)
const forgotSmsCountdown = ref(0)
let forgotSmsTimer = null
const forgotForm = reactive({ phone: '', smsCode: '', password: '', confirmPassword: '' })
let forgotSmsUuid = ''
function closeForgot() {
showForgotModal.value = false
forgotForm.phone = ''
forgotForm.smsCode = ''
forgotForm.password = ''
forgotForm.confirmPassword = ''
forgotSmsUuid = ''
if (forgotSmsTimer) { clearInterval(forgotSmsTimer); forgotSmsTimer = null }
forgotSmsCountdown.value = 0
}
async function sendForgotSms() {
if (!/^1\d{10}$/.test(forgotForm.phone)) {
return ElMessage.warning('请输入正确的手机号')
}
await runForgotSmsOnce(async () => {
try {
const r = await request({ url: '/business/auth/forgotSendSms', method: 'post', data: { phone: forgotForm.phone } })
forgotSmsUuid = r.data || ''
ElMessage.success('验证码已发送')
forgotSmsCountdown.value = 60
forgotSmsTimer = setInterval(() => {
forgotSmsCountdown.value--
if (forgotSmsCountdown.value <= 0) {
clearInterval(forgotSmsTimer)
forgotSmsTimer = null
}
}, 1000)
} catch (e) {
// request.js 拦截器已弹 ElMessage.error, 这里只重置
forgotSmsUuid = ''
}
})
}
async function onResetSubmit() {
if (!/^1\d{10}$/.test(forgotForm.phone)) return ElMessage.warning('请输入正确的手机号')
if (!forgotForm.smsCode) return ElMessage.warning('请输入短信验证码')
if (!forgotForm.password || forgotForm.password.length < 6 || forgotForm.password.length > 20) {
return ElMessage.warning('密码长度 6-20 位')
}
if (forgotForm.password !== forgotForm.confirmPassword) {
return ElMessage.warning('两次密码输入不一致')
}
forgotLoading.value = true
try {
await request({
url: '/business/auth/resetPassword',
method: 'post',
data: {
phone: forgotForm.phone,
smsCode: forgotForm.smsCode,
uuid: forgotSmsUuid,
password: forgotForm.password,
confirmPassword: forgotForm.confirmPassword
}
})
ElMessage.success('密码重置成功, 请用新密码登录')
closeForgot()
} catch (e) {
// request.js 已弹错误
} finally {
forgotLoading.value = false
}
}
onMounted(() => { loadCaptcha() })
onUnmounted(() => {
if (forgotSmsTimer) clearInterval(forgotSmsTimer)
if (smsSmsTimer) clearInterval(smsSmsTimer)
})
</script>
<style>
@@ -396,6 +625,30 @@ onMounted(() => { loadCaptcha() })
margin-bottom: 28px;
}
/* 登录模式切换 tabs */
.login-page .login-tabs{
display: flex;
gap: 24px;
margin-bottom: 24px;
border-bottom: 1px solid #e5e7eb;
}
.login-page .login-tabs .tab{
padding: 8px 0;
font-size: 14px;
color: #6b7280;
cursor: pointer;
border-bottom: 2px solid transparent;
margin-bottom: -1px;
transition: color 0.2s, border-color 0.2s;
user-select: none;
}
.login-page .login-tabs .tab:hover{ color: #1e3a8a; }
.login-page .login-tabs .tab.active{
color: #1e3a8a;
border-bottom-color: #1e3a8a;
font-weight: 500;
}
.login-page .form-group{
margin-bottom: 18px;
position: relative;
@@ -689,4 +942,41 @@ onMounted(() => { loadCaptcha() })
.modal-btn.secondary:hover { background: #ebeef5; }
.modal-btn.primary { background: var(--brand-primary, #1890ff); color: #fff; }
.modal-btn.primary:hover { background: var(--brand-primary-deep, #096dd9); }
/* ===== 忘记密码弹窗 ===== */
.modal-subtitle {
font-size: 13px;
color: #6b7280;
text-align: center;
margin: -12px 0 20px;
}
.sms-group {
display: flex;
gap: 10px;
margin-bottom: 18px;
}
.sms-group .sms-input {
flex: 1;
padding-left: 14px;
}
.sms-btn {
flex-shrink: 0;
width: 110px;
height: 44px;
border: 1px solid #d1d5db;
background: #f9fafb;
color: #1e3a8a;
font-size: 13px;
cursor: pointer;
transition: all 0.2s;
}
.sms-btn:hover:not(:disabled) {
border-color: #1e3a8a;
background: #f3f4f6;
}
.sms-btn:disabled {
color: #9ca3af;
background: #f3f4f6;
cursor: not-allowed;
}
</style>