初始化2

This commit is contained in:
2026-04-08 17:10:58 +08:00
commit 4986d90eb9
532 changed files with 112617 additions and 0 deletions

798
app/pages/invite/accept.vue Normal file
View File

@@ -0,0 +1,798 @@
<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>