809 lines
22 KiB
Vue
809 lines
22 KiB
Vue
<template>
|
||
<div class="invite-accept-page">
|
||
<div class="invite-card">
|
||
<!-- 加载中 -->
|
||
<div v-if="loading" class="invite-loading">
|
||
<a-spin size="large" />
|
||
<p class="loading-text">正在验证邀请...</p>
|
||
</div>
|
||
|
||
<!-- 邀请信息 -->
|
||
<div v-else-if="inviteInfo && !accepted" class="invite-content">
|
||
<div class="invite-header">
|
||
<div class="app-icon">
|
||
<img v-if="inviteInfo.appIcon" :src="inviteInfo.appIcon" alt="app icon" />
|
||
<span v-else class="app-icon-text">{{ (inviteInfo.appName || 'A').charAt(0).toUpperCase() }}</span>
|
||
</div>
|
||
<h2 class="invite-title">邀请你加入应用</h2>
|
||
<p class="app-name">{{ inviteInfo.appName }}</p>
|
||
</div>
|
||
|
||
<div class="invite-details">
|
||
<div class="detail-item">
|
||
<span class="detail-label">邀请人</span>
|
||
<div class="inviter-info">
|
||
<a-avatar :src="inviteInfo.inviterAvatar" size="small" class="inviter-avatar">
|
||
{{ (inviteInfo.inviterName || '?').charAt(0).toUpperCase() }}
|
||
</a-avatar>
|
||
<span class="inviter-name">{{ inviteInfo.inviterName || '未知' }}</span>
|
||
</div>
|
||
</div>
|
||
<div class="detail-item">
|
||
<span class="detail-label">你的角色</span>
|
||
<a-tag :color="roleColor(inviteInfo.role)">{{ roleText(inviteInfo.role) }}</a-tag>
|
||
</div>
|
||
<div class="detail-item">
|
||
<span class="detail-label">有效期至</span>
|
||
<span class="detail-value">{{ formatExpireTime(inviteInfo.expireTime) }}</span>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 已登录:直接显示接受邀请按钮 -->
|
||
<div v-if="isLoggedIn" class="invite-actions">
|
||
<a-button type="primary" size="large" block :loading="accepting" @click="handleAccept">
|
||
{{ accepting ? '处理中...' : '接受邀请' }}
|
||
</a-button>
|
||
<a-button size="large" block style="margin-top: 12px" @click="goToHome">
|
||
暂不加入
|
||
</a-button>
|
||
</div>
|
||
|
||
<!-- 未登录:显示手机号登录表单 -->
|
||
<div v-else class="invite-login-form">
|
||
<div class="login-divider">
|
||
<span>请先登录以接受邀请</span>
|
||
</div>
|
||
|
||
<a-form ref="formRef" :model="form" :rules="rules" class="login-form">
|
||
<a-form-item name="phone">
|
||
<a-input
|
||
v-model:value="form.phone"
|
||
size="large"
|
||
allow-clear
|
||
:maxlength="11"
|
||
placeholder="请输入手机号"
|
||
class="form-input"
|
||
>
|
||
<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"
|
||
size="large"
|
||
allow-clear
|
||
:maxlength="6"
|
||
placeholder="请输入验证码"
|
||
class="form-input"
|
||
@press-enter="handleLoginAndAccept"
|
||
/>
|
||
<button
|
||
class="sms-btn"
|
||
:disabled="countdown > 0"
|
||
:class="{ disabled: countdown > 0 }"
|
||
@click.prevent="openImgCodeModal"
|
||
>
|
||
<span v-if="countdown <= 0">获取验证码</span>
|
||
<span v-else>{{ countdown }}秒后重发</span>
|
||
</button>
|
||
</div>
|
||
</a-form-item>
|
||
|
||
<!-- 注册协议 -->
|
||
<div class="agreement-row">
|
||
<a-checkbox v-model:checked="form.agreement">
|
||
<span class="agreement-text">
|
||
我已阅读并同意
|
||
<NuxtLink to="/agreement" target="_blank" class="agreement-link" @click.stop>注册协议</NuxtLink>
|
||
和
|
||
<NuxtLink to="/privacy" target="_blank" class="agreement-link" @click.stop>隐私政策</NuxtLink>
|
||
</span>
|
||
</a-checkbox>
|
||
</div>
|
||
|
||
<a-form-item>
|
||
<a-button
|
||
block
|
||
size="large"
|
||
type="primary"
|
||
:loading="loginLoading"
|
||
class="submit-btn"
|
||
@click="handleLoginAndAccept"
|
||
>
|
||
{{ loginLoading ? '登录中...' : '登录并加入' }}
|
||
</a-button>
|
||
</a-form-item>
|
||
</a-form>
|
||
|
||
<div class="other-login-options">
|
||
<span class="option-text">其他登录方式</span>
|
||
<div class="option-btns">
|
||
<a-button type="link" size="small" @click="goToFullLogin">
|
||
账号密码登录
|
||
</a-button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 已接受 -->
|
||
<div v-else-if="accepted" class="invite-success">
|
||
<CheckCircleOutlined class="success-icon" />
|
||
<h2 class="success-title">加入成功!</h2>
|
||
<p class="success-desc">你已成为 {{ inviteInfo?.appName }} 的 {{ roleText(inviteInfo?.role || '') }}</p>
|
||
<a-button type="primary" size="large" @click="goToApp">
|
||
进入应用
|
||
</a-button>
|
||
</div>
|
||
|
||
<!-- 错误状态 -->
|
||
<div v-else-if="error" class="invite-error">
|
||
<CloseCircleOutlined class="error-icon" />
|
||
<h2 class="error-title">邀请无效</h2>
|
||
<p class="error-desc">{{ error }}</p>
|
||
<a-button type="primary" @click="goToHome">
|
||
返回首页
|
||
</a-button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 图形验证码弹窗 -->
|
||
<a-modal v-model:open="imgCodeModalOpen" :width="360" :footer="null" title="安全验证">
|
||
<p class="modal-tip">请先完成图形验证码</p>
|
||
<div class="captcha-row modal-captcha">
|
||
<a-input
|
||
v-model:value="imgCode"
|
||
size="large"
|
||
allow-clear
|
||
:maxlength="5"
|
||
placeholder="请输入验证码"
|
||
@press-enter="sendSmsCode"
|
||
/>
|
||
<button class="captcha-img-btn" @click.prevent="changeCaptcha">
|
||
<img v-if="captcha" alt="captcha" :src="captcha" />
|
||
<span v-else class="captcha-placeholder">点击获取</span>
|
||
</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 { ref, onMounted, onUnmounted, reactive, computed } from 'vue'
|
||
import { useRoute, useRouter } from 'vue-router'
|
||
import { message, type FormInstance } from 'ant-design-vue'
|
||
import { CheckCircleOutlined, CloseCircleOutlined, RightOutlined } from '@ant-design/icons-vue'
|
||
import { verifyInvite, acceptInvite } from '@/api/app/invite'
|
||
import { getCaptcha, loginBySms, sendSmsCaptcha } from '@/api/passport/login'
|
||
import { listAdminsByPhoneAll } from '@/api/system/user'
|
||
import { getUserInfo } from '@/api/layout'
|
||
import { getToken } from '@/utils/token-util'
|
||
import { TEMPLATE_ID } from '@/config/setting'
|
||
import type { User } from '@/api/system/user/model'
|
||
|
||
interface InviteInfo {
|
||
appId: number
|
||
appName: string
|
||
appIcon?: string
|
||
inviterName: string
|
||
inviterAvatar?: string
|
||
role: string
|
||
expireTime: string
|
||
}
|
||
|
||
const route = useRoute()
|
||
const router = useRouter()
|
||
|
||
const loading = ref(true)
|
||
const accepting = ref(false)
|
||
const accepted = ref(false)
|
||
const error = ref('')
|
||
const inviteInfo = ref<InviteInfo | null>(null)
|
||
|
||
const token = computed(() => route.query.token as string)
|
||
const appId = computed(() => Number(route.query.appId))
|
||
const role = computed(() => route.query.role as string)
|
||
|
||
const isLoggedIn = computed(() => {
|
||
if (import.meta.client) {
|
||
return !!getToken()
|
||
}
|
||
return false
|
||
})
|
||
|
||
// 登录表单相关
|
||
const formRef = ref<FormInstance>()
|
||
const form = reactive({
|
||
phone: '',
|
||
smsCode: '',
|
||
agreement: false,
|
||
tenantId: undefined as number | undefined
|
||
})
|
||
|
||
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 loginLoading = ref(false)
|
||
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 autoJoined = ref(false)
|
||
|
||
onMounted(async () => {
|
||
if (!token.value || !appId.value) {
|
||
error.value = '邀请链接不完整,请确认链接是否正确'
|
||
loading.value = false
|
||
return
|
||
}
|
||
|
||
try {
|
||
const res = await verifyInvite({ token: token.value, appId: appId.value })
|
||
if (res.data?.code === 0 && res.data.data) {
|
||
inviteInfo.value = res.data.data
|
||
|
||
// 已登录用户:验证通过后自动加入,不用再点按钮
|
||
if (isLoggedIn.value && !autoJoined.value) {
|
||
autoJoined.value = true
|
||
await handleAccept()
|
||
}
|
||
} else {
|
||
error.value = res.data?.message || '邀请已过期或无效'
|
||
}
|
||
} catch (e: any) {
|
||
localVerifyInvite()
|
||
} finally {
|
||
loading.value = false
|
||
}
|
||
})
|
||
|
||
onUnmounted(() => {
|
||
stopCountdown()
|
||
})
|
||
|
||
function localVerifyInvite() {
|
||
const localInvite = localStorage.getItem(`invite_token_${token.value}`)
|
||
if (localInvite) {
|
||
try {
|
||
const data = JSON.parse(localInvite)
|
||
if (data.appId === appId.value && data.expireTime > Date.now()) {
|
||
inviteInfo.value = {
|
||
appId: data.appId,
|
||
appName: '应用',
|
||
inviterName: '邀请人',
|
||
role: data.role,
|
||
expireTime: new Date(data.expireTime).toISOString(),
|
||
}
|
||
} else {
|
||
error.value = '邀请已过期'
|
||
}
|
||
} catch {
|
||
error.value = '邀请验证失败'
|
||
}
|
||
} else {
|
||
error.value = '邀请验证失败'
|
||
}
|
||
}
|
||
|
||
function roleText(role?: string): string {
|
||
const map: Record<string, string> = { owner: '所有者', admin: '管理员', developer: '开发者', viewer: '只读成员' }
|
||
return role ? (map[role] || role) : ''
|
||
}
|
||
|
||
function roleColor(role?: string): string {
|
||
const map: Record<string, string> = { owner: 'red', admin: 'orange', developer: 'blue', viewer: 'default' }
|
||
return role ? (map[role] || 'default') : 'default'
|
||
}
|
||
|
||
function formatExpireTime(time?: string): string {
|
||
if (!time) return ''
|
||
const date = new Date(time)
|
||
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`
|
||
}
|
||
|
||
function goToHome() {
|
||
router.push('/')
|
||
}
|
||
|
||
async function goToApp() {
|
||
try {
|
||
const userInfo = await getUserInfo()
|
||
// type=2 是开发者用户,跳转开发者中心;其他跳转控制台
|
||
if (userInfo.type === 2) {
|
||
router.push('/developer/apps')
|
||
} else {
|
||
router.push('/console/apps')
|
||
}
|
||
} catch {
|
||
router.push('/console/apps')
|
||
}
|
||
}
|
||
|
||
function goToFullLogin() {
|
||
const returnUrl = encodeURIComponent(window.location.href)
|
||
router.push(`/login?from=${returnUrl}`)
|
||
}
|
||
|
||
async function handleAccept() {
|
||
if (!token.value || !appId.value) return
|
||
accepting.value = true
|
||
try {
|
||
const res = await acceptInvite({ token: token.value, appId: appId.value, role: role.value })
|
||
if (res.data?.code === 0) {
|
||
accepted.value = true
|
||
message.success('已成功加入应用')
|
||
} else {
|
||
message.error(res.data?.message || '接受邀请失败')
|
||
}
|
||
} catch (e: any) {
|
||
localSimulateAccept()
|
||
} finally {
|
||
accepting.value = false
|
||
}
|
||
}
|
||
|
||
function localSimulateAccept() {
|
||
const localInvite = localStorage.getItem(`invite_token_${token.value}`)
|
||
if (localInvite) {
|
||
accepted.value = true
|
||
message.success('已成功加入应用(本地模式)')
|
||
} else {
|
||
message.error('接受邀请失败')
|
||
}
|
||
}
|
||
|
||
// 登录相关方法
|
||
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 = 60
|
||
stopCountdown()
|
||
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 handleLoginAndAccept() {
|
||
if (!formRef.value) return
|
||
if (!form.agreement) {
|
||
return message.error('请先同意注册协议和隐私政策')
|
||
}
|
||
|
||
loginLoading.value = true
|
||
try {
|
||
await formRef.value.validate()
|
||
|
||
// 1. 先登录
|
||
const msg = await loginBySms({
|
||
phone: form.phone,
|
||
code: String(form.smsCode || '').toLowerCase(),
|
||
tenantId: form.tenantId,
|
||
remember: true
|
||
})
|
||
|
||
// 需要选择账号
|
||
if (msg === '请选择登录用户') {
|
||
selectUserOpen.value = true
|
||
admins.value = await listAdminsByPhoneAll({
|
||
phone: form.phone,
|
||
templateId: Number(TEMPLATE_ID)
|
||
})
|
||
loginLoading.value = false
|
||
return
|
||
}
|
||
|
||
message.success('登录成功')
|
||
|
||
// 2. 保存用户信息
|
||
await ensureUserIdPersisted()
|
||
|
||
// 3. 自动接受邀请
|
||
await autoAcceptAfterLogin()
|
||
|
||
} catch (e: unknown) {
|
||
message.error(e instanceof Error ? e.message : '登录失败')
|
||
} finally {
|
||
loginLoading.value = false
|
||
}
|
||
}
|
||
|
||
async function selectUser(user: User) {
|
||
form.tenantId = user.tenantId
|
||
selectUserOpen.value = false
|
||
loginLoading.value = true
|
||
try {
|
||
await loginBySms({
|
||
phone: form.phone,
|
||
code: String(form.smsCode || '').toLowerCase(),
|
||
tenantId: user.tenantId,
|
||
remember: true
|
||
})
|
||
message.success('登录成功')
|
||
await ensureUserIdPersisted()
|
||
await autoAcceptAfterLogin()
|
||
} catch (e: unknown) {
|
||
message.error(e instanceof Error ? e.message : '登录失败')
|
||
} finally {
|
||
loginLoading.value = false
|
||
}
|
||
}
|
||
|
||
async function autoAcceptAfterLogin() {
|
||
if (!token.value || !appId.value) {
|
||
message.error('邀请信息已失效')
|
||
return
|
||
}
|
||
|
||
accepting.value = true
|
||
try {
|
||
const res = await acceptInvite({ token: token.value, appId: appId.value, role: role.value })
|
||
if (res.data?.code === 0) {
|
||
accepted.value = true
|
||
message.success('已成功加入应用')
|
||
} else {
|
||
message.error(res.data?.message || '接受邀请失败')
|
||
}
|
||
} catch (e: any) {
|
||
message.error('接受邀请失败,请手动重试')
|
||
} finally {
|
||
accepting.value = false
|
||
}
|
||
}
|
||
|
||
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
|
||
}
|
||
}
|
||
</script>
|
||
|
||
<style scoped>
|
||
.invite-accept-page {
|
||
min-height: 100vh;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||
padding: 20px;
|
||
}
|
||
.invite-card {
|
||
background: #fff;
|
||
border-radius: 16px;
|
||
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.15);
|
||
width: 100%;
|
||
max-width: 420px;
|
||
padding: 40px 32px;
|
||
text-align: center;
|
||
}
|
||
.invite-loading { padding: 40px 0; }
|
||
.loading-text { margin-top: 16px; color: #666; font-size: 14px; }
|
||
.invite-header { margin-bottom: 32px; }
|
||
.app-icon {
|
||
width: 80px; height: 80px; border-radius: 16px; overflow: hidden;
|
||
margin: 0 auto 16px;
|
||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||
display: flex; align-items: center; justify-content: center;
|
||
}
|
||
.app-icon img { width: 100%; height: 100%; object-fit: cover; }
|
||
.app-icon-text { font-size: 36px; font-weight: 700; color: #fff; }
|
||
.invite-title { font-size: 20px; font-weight: 600; color: #1a1a1a; margin-bottom: 8px; }
|
||
.app-name { font-size: 16px; color: #666; }
|
||
.invite-details {
|
||
background: #f8f9fa; border-radius: 12px; padding: 20px; margin-bottom: 24px;
|
||
}
|
||
.detail-item {
|
||
display: flex; justify-content: space-between; align-items: center;
|
||
padding: 10px 0; border-bottom: 1px solid #eee;
|
||
}
|
||
.detail-item:last-child { border-bottom: none; }
|
||
.detail-label { color: #999; font-size: 14px; }
|
||
.detail-value { color: #333; font-size: 14px; font-weight: 500; }
|
||
.inviter-info {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
}
|
||
.inviter-avatar {
|
||
background: #4e6ef2;
|
||
flex-shrink: 0;
|
||
}
|
||
.inviter-name {
|
||
color: #333;
|
||
font-size: 14px;
|
||
font-weight: 500;
|
||
}
|
||
.invite-actions { margin-top: 24px; }
|
||
|
||
/* 登录表单样式 */
|
||
.invite-login-form { margin-top: 24px; }
|
||
.login-divider {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 12px;
|
||
margin-bottom: 20px;
|
||
color: #999;
|
||
font-size: 13px;
|
||
}
|
||
.login-divider::before,
|
||
.login-divider::after {
|
||
content: '';
|
||
flex: 1;
|
||
height: 1px;
|
||
background: #eee;
|
||
}
|
||
|
||
.login-form :deep(.ant-input-affix-wrapper),
|
||
.login-form :deep(.ant-input) {
|
||
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: #6366f1;
|
||
background: #fff;
|
||
box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.1);
|
||
}
|
||
.login-form :deep(.ant-form-item) {
|
||
margin-bottom: 16px;
|
||
}
|
||
|
||
.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;
|
||
}
|
||
|
||
.captcha-row {
|
||
display: flex;
|
||
gap: 10px;
|
||
align-items: center;
|
||
}
|
||
.captcha-row :deep(.ant-input) {
|
||
flex: 1;
|
||
min-width: 0;
|
||
border-radius: 10px !important;
|
||
}
|
||
|
||
.sms-btn {
|
||
flex-shrink: 0;
|
||
padding: 0 16px;
|
||
height: 44px;
|
||
border: 1px solid #6366f1;
|
||
border-radius: 10px;
|
||
background: transparent;
|
||
color: #6366f1;
|
||
font-size: 13px;
|
||
font-weight: 500;
|
||
cursor: pointer;
|
||
white-space: nowrap;
|
||
transition: all 0.2s;
|
||
outline: none;
|
||
}
|
||
.sms-btn:hover:not(.disabled) {
|
||
background: #6366f1;
|
||
color: #fff;
|
||
}
|
||
.sms-btn.disabled {
|
||
border-color: #e0e0e0;
|
||
color: #bfbfbf;
|
||
cursor: not-allowed;
|
||
}
|
||
|
||
.agreement-row {
|
||
margin-bottom: 16px;
|
||
text-align: left;
|
||
}
|
||
.agreement-row :deep(.ant-checkbox-wrapper) {
|
||
font-size: 12px;
|
||
color: #666;
|
||
align-items: flex-start;
|
||
}
|
||
.agreement-row :deep(.ant-checkbox) {
|
||
margin-top: 2px;
|
||
}
|
||
.agreement-text {
|
||
font-size: 12px;
|
||
color: #666;
|
||
line-height: 1.6;
|
||
}
|
||
.agreement-link {
|
||
color: #6366f1;
|
||
text-decoration: none;
|
||
font-size: 12px;
|
||
}
|
||
.agreement-link:hover {
|
||
text-decoration: underline;
|
||
}
|
||
|
||
.submit-btn.ant-btn-primary {
|
||
background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%) !important;
|
||
border: none !important;
|
||
border-radius: 10px !important;
|
||
height: 44px !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;
|
||
}
|
||
|
||
.other-login-options {
|
||
margin-top: 20px;
|
||
padding-top: 16px;
|
||
border-top: 1px solid #f0f0f0;
|
||
}
|
||
.option-text {
|
||
font-size: 12px;
|
||
color: #999;
|
||
}
|
||
.option-btns {
|
||
margin-top: 8px;
|
||
}
|
||
|
||
/* 弹窗样式 */
|
||
.modal-tip {
|
||
font-size: 13px;
|
||
color: #8c8c8c;
|
||
margin-bottom: 16px;
|
||
}
|
||
.modal-captcha {
|
||
margin-bottom: 16px;
|
||
}
|
||
.captcha-img-btn {
|
||
width: 120px;
|
||
height: 44px;
|
||
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;
|
||
}
|
||
|
||
/* 账号列表 */
|
||
.list-item {
|
||
cursor: pointer;
|
||
transition: background 0.15s;
|
||
padding: 8px 12px;
|
||
border-radius: 8px;
|
||
}
|
||
.list-item:hover {
|
||
background: #f5f5f5;
|
||
}
|
||
|
||
.invite-success, .invite-error { padding: 20px 0; }
|
||
.success-icon { font-size: 64px; color: #52c41a; margin-bottom: 16px; }
|
||
.error-icon { font-size: 64px; color: #ff4d4f; margin-bottom: 16px; }
|
||
.success-title, .error-title { font-size: 22px; font-weight: 600; color: #1a1a1a; margin-bottom: 8px; }
|
||
.success-desc, .error-desc { color: #666; font-size: 14px; margin-bottom: 24px; }
|
||
</style>
|