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

336 lines
7.0 KiB
Vue
Raw 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.

<script setup lang="ts">
import { ref, onMounted, computed } from 'vue'
import { message, Modal } from 'ant-design-vue'
import {
listPendingInvites,
acceptInvite,
rejectInvite,
type AppUser
} from '@/api/app/appUser'
import { useRouter } from 'vue-router'
import { TeamOutlined, ClockCircleOutlined } from '@ant-design/icons-vue'
const router = useRouter()
const invites = ref<AppUser[]>([])
const loading = ref(false)
// 待确认邀请数量
const pendingCount = computed(() => invites.value.length)
// 是否有待确认邀请
const hasPending = computed(() => pendingCount.value > 0)
// 加载邀请列表
async function loadInvites() {
try {
loading.value = true
const data = await listPendingInvites()
console.log('邀请列表数据:', data)
invites.value = data
} catch (error) {
console.error('加载邀请列表失败:', error)
} finally {
loading.value = false
}
}
// 接受邀请
async function handleAccept(invite: AppUser) {
if (!invite.id) return
try {
await acceptInvite(invite.id)
message.success('已接受邀请,加入应用成功')
// 移除已处理的邀请
invites.value = invites.value.filter(i => i.id !== invite.id)
// 刷新页面或跳转到应用
setTimeout(() => {
router.push('/developer/apps')
}, 500)
} catch (error: any) {
message.error(error.message || '接受邀请失败')
}
}
// 拒绝邀请
async function handleReject(invite: AppUser) {
if (!invite.id) return
Modal.confirm({
title: '确认拒绝邀请?',
content: `拒绝后将无法加入应用「${invite.productName || '未知应用'}`,
okText: '确认拒绝',
okType: 'danger',
cancelText: '取消',
async onOk() {
try {
await rejectInvite(invite.id!)
message.success('已拒绝邀请')
invites.value = invites.value.filter(i => i.id !== invite.id)
} catch (error: any) {
message.error(error.message || '拒绝邀请失败')
}
}
})
}
// 查看全部邀请
function viewAllInvites() {
router.push('/console/invites')
}
onMounted(() => {
loadInvites()
})
defineExpose({
loadInvites,
pendingCount,
hasPending
})
</script>
<template>
<div class="invite-notification">
<!-- 悬浮卡片 - 直接展示邀请列表 -->
<div v-if="hasPending" class="invite-float-card">
<div class="invite-card-header">
<div class="invite-title">
<TeamOutlined class="invite-icon" />
应用邀请
</div>
<div class="invite-count">{{ pendingCount }}</div>
</div>
<div class="invite-card-body">
<a-spin :spinning="loading">
<div
v-for="invite in invites.slice(0, 3)"
:key="invite.id"
class="invite-item"
>
<a-avatar
:src="invite.icon || '/logo.png'"
:size="40"
class="app-icon"
/>
<div class="invite-info">
<div class="app-name">{{ invite.productName || '未知应用' }}</div>
<div class="invite-meta">
<span class="role-tag" :class="invite.role">
{{ invite.role === 'owner' ? '所有者' :
invite.role === 'admin' ? '管理员' :
invite.role === 'developer' ? '开发者' : '访客' }}
</span>
<span class="inviter"> {{ invite.username || '未知用户' }} 邀请</span>
</div>
<div v-if="invite.inviteExpireTime" class="expire-time">
<ClockCircleOutlined />
有效期至{{ invite.inviteExpireTime }}
</div>
</div>
<div class="invite-actions">
<a-button
size="small"
@click="handleReject(invite)"
>
拒绝
</a-button>
<a-button
type="primary"
size="small"
@click="handleAccept(invite)"
>
接受
</a-button>
</div>
</div>
<div v-if="invites.length > 3" class="view-more" @click="viewAllInvites">
查看全部 {{ pendingCount }} 个邀请
</div>
</a-spin>
</div>
</div>
</div>
</template>
<style scoped>
.invite-notification {
position: relative;
}
/* 悬浮卡片 */
.invite-float-card {
position: fixed;
right: 24px;
bottom: 100px;
z-index: 100;
width: 420px;
background: #fff;
border-radius: 12px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.12);
border: 1px solid rgba(0, 0, 0, 0.06);
animation: slideIn 0.3s ease;
}
@keyframes slideIn {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* 卡片头部 */
.invite-card-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 20px;
border-bottom: 1px solid #f0f0f0;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border-radius: 12px 12px 0 0;
}
.invite-title {
display: flex;
align-items: center;
gap: 10px;
font-size: 16px;
font-weight: 600;
color: #fff;
}
.invite-icon {
font-size: 20px;
}
.invite-count {
min-width: 24px;
height: 24px;
padding: 0 8px;
background: #ff4d4f;
color: #fff;
font-size: 13px;
font-weight: 600;
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 2px 6px rgba(255, 77, 79, 0.4);
}
/* 卡片内容 */
.invite-card-body {
padding: 8px 0;
max-height: 400px;
overflow-y: auto;
}
.invite-item {
display: flex;
align-items: center;
gap: 12px;
padding: 16px 20px;
border-bottom: 1px solid #f5f5f5;
transition: background-color 0.2s;
}
.invite-item:hover {
background-color: #fafafa;
}
.invite-item:last-child {
border-bottom: none;
}
.app-icon {
flex-shrink: 0;
}
.invite-info {
flex: 1;
min-width: 0;
}
.app-name {
font-size: 15px;
font-weight: 600;
color: #262626;
margin-bottom: 6px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.invite-meta {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 4px;
}
.role-tag {
padding: 2px 8px;
border-radius: 4px;
font-size: 12px;
font-weight: 500;
}
.role-tag.owner {
background: #fff2e8;
color: #fa541c;
}
.role-tag.admin {
background: #e6f7ff;
color: #1890ff;
}
.role-tag.developer {
background: #f6ffed;
color: #52c41a;
}
.role-tag.viewer {
background: #f9f0ff;
color: #722ed1;
}
.inviter {
font-size: 12px;
color: #8c8c8c;
}
.expire-time {
font-size: 12px;
color: #faad14;
display: flex;
align-items: center;
gap: 4px;
}
.invite-actions {
display: flex;
gap: 8px;
flex-shrink: 0;
}
.view-more {
text-align: center;
padding: 12px 20px;
color: #667eea;
font-size: 14px;
cursor: pointer;
border-top: 1px solid #f5f5f5;
transition: background-color 0.2s;
}
.view-more:hover {
background-color: #f5f5f5;
color: #764ba2;
}
</style>