Files
web-fusion/src/components/PermissionMask.vue
2026-04-17 16:32:07 +08:00

514 lines
12 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.
<template>
<div class="permission-mask-wrapper" ref="wrapperRef">
<!-- 有权限时才渲染原始内容 -->
<template v-if="hasAccess">
<slot></slot>
</template>
<!-- 无权限时显示遮罩和占位内容 -->
<template v-else>
<!-- 占位背景 -->
<div class="permission-placeholder">
<img v-if="placeholderImage" :src="placeholderImage" alt="" class="placeholder-image" />
<div v-else class="placeholder-pattern"></div>
</div>
<!-- 权限遮罩层 -->
<div class="permission-mask" ref="maskRef" :data-permission-guard="guardKey">
<!-- 上方锁提示区域 -->
<button @click="refreshPage" style="
position: absolute; top: 20px; left: 20px;
padding: 10px 20px;
background: #fff;
color: #000;
border: none;
border-radius: 8px;
font-weight: 600;
cursor: pointer;
box-shadow: 0 4px 15px rgba(255, 59, 48, 0.3);
transition: all 0.3s ease;
backdrop-filter: blur(5px);
">
刷新权限
</button> <div class="mask-top">
<div class="mask-content">
<div class="lock-icon-wrapper">
<svg class="lock-icon" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M19 11H5C3.89543 11 3 11.8954 3 13V20C3 21.1046 3.89543 22 5 22H19C20.1046 22 21 21.1046 21 20V13C21 11.8954 20.1046 11 19 11Z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M7 11V7C7 5.67392 7.52678 4.40215 8.46447 3.46447C9.40215 2.52678 10.6739 2 12 2C13.3261 2 14.5979 2.52678 15.5355 3.46447C16.4732 4.40215 17 5.67392 17 7V11" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</div>
<h3 class="mask-title">{{ title }}</h3>
<p class="mask-description">{{ description }}</p>
<div class="mask-hint">
<span class="material-icons-round hint-icon">info</span>
<span>请联系下方客服开通权限</span>
</div>
</div>
</div>
<!-- 下方名片区域 -->
<div v-if="contacts && contacts.length" class="cards-area">
<div class="cards-header">
<img :src="exchangeIcon" class="refresh-btn" @click="shuffleContacts" alt="换一批" />
</div>
<div class="cards-row">
<div
v-for="(contact, index) in visibleContacts"
:key="index"
class="contact-card"
>
<div class="card-avatar-wrapper">
<img :src="contact.avatar" class="card-avatar" alt="" />
</div>
<img :src="cardBg" class="card-bg" alt="" />
<div class="card-body">
<div class="card-name">{{ contact.name }}</div>
<div class="card-desc">{{ contact.desc }}</div>
<img :src="contact.qrcode" class="card-qrcode" alt="二维码" />
<div class="card-phone">
<img :src="phoneIcon" class="phone-icon" alt="" />
{{ contact.phone }}
</div>
</div>
</div>
</div>
</div>
</div>
</template>
</div>
</template>
<script setup>
import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
import { getPermissions,setPermissions } from '@/utils/storage'
import cardBg from '@/assets/nav/card.png'
import phoneIcon from '@/assets/nav/phone.png'
import exchangeIcon from '@/assets/nav/exchange.png'
import { getCurrent } from '@/api/account'
import { setUser } from '@/utils/storage'
const props = defineProps({
permissionKey: {
type: String,
required: true,
validator: (value) => ['bigBrother', 'crawl', 'webAi', 'autotk'].includes(value)
},
title: {
type: String,
default: '功能未开通'
},
description: {
type: String,
default: '您当前没有使用此功能的权限'
},
placeholderImage: {
type: String,
default: ''
},
// 名片数据,每项: { avatar, name, desc, qrcode, phone }
contacts: {
type: Array,
default: () => []
}
})
const wrapperRef = ref(null)
const maskRef = ref(null)
const visibleIndices = ref([])
const visibleContacts = ref([])
const guardKey = computed(() => `guard_${props.permissionKey}_${Date.now()}`)
const effectivePermissionKey = computed(() => {
if (props.permissionKey === 'webAi' && props.title.includes('TK')) {
return 'autotk'
}
return props.permissionKey
})
const permissionsData = ref(getPermissions())
const hasAccess = computed(() => {
return permissionsData.value[effectivePermissionKey.value] === 1
})
const pickRandomIndices = (sourceIndices, count) => {
const arr = [...sourceIndices]
for (let i = arr.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1))
;[arr[i], arr[j]] = [arr[j], arr[i]]
}
return arr.slice(0, count)
}
const updateVisibleContacts = (excludeCurrent = false) => {
const total = props.contacts.length
if (total === 0) {
visibleIndices.value = []
visibleContacts.value = []
return
}
if (total <= 3) {
const all = [...Array(total).keys()]
visibleIndices.value = pickRandomIndices(all, total)
visibleContacts.value = visibleIndices.value.map(i => props.contacts[i])
return
}
const all = [...Array(total).keys()]
let candidates = all
if (excludeCurrent && visibleIndices.value.length) {
const current = new Set(visibleIndices.value)
candidates = all.filter(i => !current.has(i))
if (candidates.length < 3) {
candidates = all
}
}
visibleIndices.value = pickRandomIndices(candidates, 3)
visibleContacts.value = visibleIndices.value.map(i => props.contacts[i])
}
const refreshPage = async () => {
const res = await getCurrent()
if (res) {
setUser(res)
setPermissions({
bigBrother: res.bigBrother,
crawl: res.crawl,
webAi: res.webAi,
autotk: res.autotk ?? res.autoTK,
});
}
}
const shuffleContacts = () => {
updateVisibleContacts(true)
}
let permissionCheckInterval = null
const refreshPermissions = () => {
permissionsData.value = getPermissions()
}
let observer = null
const setupDOMProtection = () => {
if (hasAccess.value || !wrapperRef.value) return
observer = new MutationObserver((mutations) => {
for (const mutation of mutations) {
if (mutation.type === 'childList') {
const maskExists = wrapperRef.value?.querySelector('.permission-mask')
if (!maskExists && !hasAccess.value) {
console.warn('[PermissionMask] 检测到权限遮罩被非法移除,正在重载页面...')
window.location.reload()
}
}
if (mutation.type === 'attributes' && mutation.target.classList?.contains('permission-mask')) {
const mask = mutation.target
const style = window.getComputedStyle(mask)
if (style.display === 'none' || style.visibility === 'hidden' || style.opacity === '0') {
console.warn('[PermissionMask] 检测到权限遮罩样式被非法修改,正在重载页面...')
window.location.reload()
}
}
}
})
observer.observe(wrapperRef.value, {
childList: true,
subtree: true,
attributes: true,
attributeFilter: ['style', 'class']
})
}
onMounted(() => {
updateVisibleContacts(false)
permissionCheckInterval = setInterval(refreshPermissions, 2000)
setTimeout(setupDOMProtection, 100)
})
onUnmounted(() => {
if (permissionCheckInterval) clearInterval(permissionCheckInterval)
if (observer) observer.disconnect()
})
watch(
() => props.contacts,
() => {
updateVisibleContacts(false)
},
{ deep: true }
)
watch(hasAccess, (newVal) => {
if (observer) observer.disconnect()
if (!newVal) setTimeout(setupDOMProtection, 100)
})
</script>
<style scoped>
.permission-mask-wrapper {
position: relative;
width: 100%;
height: 100%;
}
.permission-placeholder {
position: absolute;
inset: 0;
background: #f8fafc;
z-index: 99;
overflow: hidden;
}
.placeholder-image {
width: 100%;
height: 100%;
object-fit: cover;
object-position: top left;
pointer-events: none;
user-select: none;
}
.placeholder-pattern {
width: 100%;
height: 100%;
background-image:
linear-gradient(45deg, #e2e8f0 25%, transparent 25%),
linear-gradient(-45deg, #e2e8f0 25%, transparent 25%),
linear-gradient(45deg, transparent 75%, #e2e8f0 75%),
linear-gradient(-45deg, transparent 75%, #e2e8f0 75%);
background-size: 20px 20px;
background-position: 0 0, 0 10px, 10px -10px, -10px 0px;
opacity: 0.5;
}
.permission-mask {
position: absolute;
inset: 0;
z-index: 100;
display: flex;
flex-direction: column;
align-items: center;
justify-content: flex-start;
background: rgba(15, 23, 42, 0.75);
backdrop-filter: blur(1px);
-webkit-backdrop-filter: blur(8px);
overflow-y: auto;
}
/* 上方锁提示区域 */
.mask-top {
display: flex;
justify-content: center;
padding-top: 6vh;
width: 100%;
}
.mask-content {
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
padding: 2.5rem;
background: rgba(255, 255, 255, 0.95);
border-radius: 1.5rem;
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25);
max-width: 360px;
animation: slideUp 0.4s ease-out;
}
@keyframes slideUp {
from { opacity: 0; transform: translateY(20px); }
to { opacity: 1; transform: translateY(0); }
}
.lock-icon-wrapper {
width: 80px;
height: 80px;
border-radius: 50%;
background: linear-gradient(135deg, #f1f5f9 0%, #e2e8f0 100%);
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 1.5rem;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
}
.lock-icon {
width: 40px;
height: 40px;
color: #64748b;
}
.mask-title {
font-size: 1.375rem;
font-weight: 700;
color: #1e293b;
margin: 0 0 0.5rem 0;
}
.mask-description {
font-size: 0.9375rem;
color: #64748b;
margin: 0 0 1.5rem 0;
line-height: 1.5;
}
.mask-hint {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.625rem 1rem;
background: linear-gradient(135deg, #eff6ff 0%, #dbeafe 100%);
border-radius: 9999px;
color: #3b82f6;
font-size: 0.875rem;
font-weight: 500;
}
.hint-icon {
font-size: 1.125rem;
}
/* 名片区域 */
.cards-area {
width: 100%;
padding: 3vh 0 2rem;
animation: slideUp 0.4s ease-out 0.1s both;
}
.cards-header {
display: flex;
justify-content: flex-end;
margin-bottom: 1rem;
padding-right: 10%;
}
.refresh-btn {
height: 2.2vw;
cursor: pointer;
display: block;
transition: opacity 0.2s;
}
.refresh-btn:hover {
opacity: 0.8;
}
.cards-row {
display: flex;
justify-content: space-evenly;
}
/* 单张名片 */
.contact-card {
width: 17%;
flex: none;
max-width: unset;
aspect-ratio: 2 / 3;
background: transparent;
border-radius: 1.25rem;
padding-top: 0;
position: relative;
box-shadow: none;
display: flex;
flex-direction: column;
align-items: center;
}
.contact-card::before {
display: none;
}
.card-bg {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: fill;
border-radius: 1.25rem;
z-index: 0;
pointer-events: none;
}
.card-avatar-wrapper {
position: absolute;
left: 50%;
transform: translateX(-50%);
width: 40%;
aspect-ratio: 1;
border-radius: 50%;
border: 3px solid #fff;
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
overflow: hidden;
background: #e2e8f0;
z-index: 2;
}
.card-avatar {
width: 100%;
height: 100%;
object-fit: cover;
}
.card-body {
display: flex;
flex-direction: column;
align-items: center;
padding: 50% 8% 6%;
width: 100%;
height: 100%;
position: relative;
z-index: 1;
justify-content: flex-start;
box-sizing: border-box;
}
.card-name {
font-size: 1.1vw;
font-weight: 700;
color: #1e293b;
margin-bottom: 0.3vw;
text-align: center;
}
.card-desc {
font-size: 0.8vw;
color: #64748b;
text-align: center;
margin-bottom: 0.5vw;
line-height: 1.4;
}
.card-qrcode {
width: 60%;
aspect-ratio: 1;
object-fit: contain;
margin-bottom: 0.5vw;
}
.card-phone {
display: flex;
align-items: center;
gap: 0.3vw;
font-size: 0.85vw;
color: #2563eb;
font-weight: 500;
}
.phone-icon {
width: 0.9vw;
height: 0.9vw;
object-fit: contain;
}
</style>