销售名片

This commit is contained in:
2026-03-03 21:57:18 +08:00
parent e1c132ead9
commit 89d3487c02
22 changed files with 340 additions and 111 deletions

View File

@@ -4,29 +4,62 @@
<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">
<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 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>
<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 v-if="contacts && contacts.length" class="cards-area">
<div class="cards-header">
<button class="refresh-btn" @click="shuffleContacts">
<span class="material-icons-round" style="font-size:18px;">refresh</span>
换一批
</button>
</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>
<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">
<span class="material-icons-round phone-icon">phone</span>
{{ contact.phone }}
</div>
</div>
</div>
</div>
</div>
</div>
@@ -39,75 +72,74 @@ import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
import { getPermissions } from '@/utils/storage'
const props = defineProps({
/**
* 权限类型: 'bigBrother' | 'crawl' | 'webAi'
*/
permissionKey: {
type: String,
required: true,
validator: (value) => ['bigBrother', 'crawl', 'webAi'].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 contactOffset = ref(0)
// 生成唯一的守卫标识
const guardKey = computed(() => `guard_${props.permissionKey}_${Date.now()}`)
// 响应式权限检查
const permissionsData = ref(getPermissions())
const hasAccess = computed(() => {
return permissionsData.value[props.permissionKey] === 1
})
// 定时刷新权限状态防止localStorage被篡改后状态不同步
// 每次显示3张换一批向后轮转
const visibleContacts = computed(() => {
if (!props.contacts.length) return []
const total = props.contacts.length
return [0, 1, 2].map(i => props.contacts[(contactOffset.value + i) % total])
})
const shuffleContacts = () => {
if (props.contacts.length <= 3) return
contactOffset.value = (contactOffset.value + 3) % props.contacts.length
}
let permissionCheckInterval = null
const refreshPermissions = () => {
permissionsData.value = getPermissions()
}
// MutationObserver 监测DOM篡改
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()
}
}
// 检测遮罩样式是否被修改如display:none, visibility:hidden等
if (mutation.type === 'attributes' && mutation.target.classList?.contains('permission-mask')) {
const mask = mutation.target
const style = window.getComputedStyle(mask)
@@ -118,7 +150,7 @@ const setupDOMProtection = () => {
}
}
})
observer.observe(wrapperRef.value, {
childList: true,
subtree: true,
@@ -128,30 +160,20 @@ const setupDOMProtection = () => {
}
onMounted(() => {
// 定时检查权限每2秒
permissionCheckInterval = setInterval(refreshPermissions, 2000)
console.log("获取名片",props.contacts)
// 延迟设置DOM保护确保元素已渲染
permissionCheckInterval = setInterval(refreshPermissions, 2000)
setTimeout(setupDOMProtection, 100)
})
onUnmounted(() => {
if (permissionCheckInterval) {
clearInterval(permissionCheckInterval)
}
if (observer) {
observer.disconnect()
}
if (permissionCheckInterval) clearInterval(permissionCheckInterval)
if (observer) observer.disconnect()
})
// 权限变化时重新设置保护
watch(hasAccess, (newVal) => {
if (observer) {
observer.disconnect()
}
if (!newVal) {
setTimeout(setupDOMProtection, 100)
}
if (observer) observer.disconnect()
if (!newVal) setTimeout(setupDOMProtection, 100)
})
</script>
@@ -162,7 +184,6 @@ watch(hasAccess, (newVal) => {
height: 100%;
}
/* 占位背景 - 无权限时显示,防止删除遮罩后看到内容 */
.permission-placeholder {
position: absolute;
inset: 0;
@@ -183,7 +204,7 @@ watch(hasAccess, (newVal) => {
.placeholder-pattern {
width: 100%;
height: 100%;
background-image:
background-image:
linear-gradient(45deg, #e2e8f0 25%, transparent 25%),
linear-gradient(-45deg, #e2e8f0 25%, transparent 25%),
linear-gradient(45deg, transparent 75%, #e2e8f0 75%),
@@ -198,11 +219,21 @@ watch(hasAccess, (newVal) => {
inset: 0;
z-index: 100;
display: flex;
flex-direction: column;
align-items: center;
justify-content: 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 {
@@ -219,14 +250,8 @@ watch(hasAccess, (newVal) => {
}
@keyframes slideUp {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
from { opacity: 0; transform: translateY(20px); }
to { opacity: 1; transform: translateY(0); }
}
.lock-icon-wrapper {
@@ -276,4 +301,119 @@ watch(hasAccess, (newVal) => {
.hint-icon {
font-size: 1.125rem;
}
/* 名片区域 */
.cards-area {
width: 100%;
padding: 3vh 4rem 2rem;
animation: slideUp 0.4s ease-out 0.1s both;
}
.cards-header {
display: flex;
justify-content: flex-end;
margin-bottom: 1rem;
}
.refresh-btn {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 0.5rem 1.25rem;
background: #2563eb;
color: #fff;
border: none;
border-radius: 9999px;
font-size: 0.9rem;
font-weight: 500;
cursor: pointer;
transition: background 0.2s;
}
.refresh-btn:hover {
background: #1d4ed8;
}
.cards-row {
display: flex;
justify-content: space-evenly;
}
/* 单张名片 */
.contact-card {
flex: 1;
max-width: 280px;
background: #fff;
border-radius: 1.25rem;
padding-top: 52px;
position: relative;
box-shadow: 0 8px 24px rgba(0,0,0,0.12);
display: flex;
flex-direction: column;
align-items: center;
}
.card-avatar-wrapper {
position: absolute;
top: -44px;
left: 50%;
transform: translateX(-50%);
width: 88px;
height: 88px;
border-radius: 50%;
border: 4px solid #fff;
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
overflow: hidden;
background: #e2e8f0;
}
.card-avatar {
width: 100%;
height: 100%;
object-fit: cover;
}
.card-body {
display: flex;
flex-direction: column;
align-items: center;
padding: 0.5rem 1.5rem 1.5rem;
width: 100%;
}
.card-name {
font-size: 1.25rem;
font-weight: 700;
color: #1e293b;
margin-bottom: 0.5rem;
}
.card-desc {
font-size: 0.8125rem;
color: #64748b;
text-align: center;
margin-bottom: 1rem;
line-height: 1.5;
}
.card-qrcode {
width: 140px;
height: 140px;
object-fit: contain;
margin-bottom: 1rem;
}
.card-phone {
display: flex;
align-items: center;
gap: 6px;
font-size: 1rem;
color: #2563eb;
font-weight: 500;
}
.phone-icon {
font-size: 1.1rem;
color: #2563eb;
}
</style>