Files
tiantian-system/app/pages/invite/accept.vue
2026-04-08 17:10:58 +08:00

799 lines
22 KiB
Vue
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<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('/')
}
function goToApp() {
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>