Files
tyc-webview-v2/src/ui/CBehaviorRiskScan.vue
2026-01-22 16:03:28 +08:00

748 lines
26 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>
const props = defineProps({
data: {
type: Object,
default: () => ({})
},
apiId: {
type: String,
default: '',
},
index: {
type: Number,
default: 0,
},
notifyRiskStatus: {
type: Function,
default: () => { },
},
})
// 风险等级转换为文字描述
const riskLevelText = (level, type) => {
if (type === 'black_gray_level') {
const levels = {
'': '无风险',
1: '低风险',
2: '中等风险',
3: '高风险',
4: '极高风险',
}
return levels[level] || '未知风险'
} else if (type === 'telefraud_level') {
const levels = {
0: '无风险',
1: '极低风险',
2: '低风险',
3: '中低风险',
4: '中等风险',
5: '高风险',
6: '极高风险',
}
return levels[level] || '未知风险'
} else if (type === 'frg_list_level') {
if (level >= '3' && level <= '5') return '低风险团伙'
if (level >= '6' && level <= '7') return '中风险团伙'
if (level >= '8' && level <= '10') return '高风险团伙'
return '无风险'
} else if (type === 'risk_level') {
const levels = {
A: '无风险',
F: '低风险',
C: '中风险',
D: '中风险',
B: '高风险',
E: '高风险',
}
return levels[level] || '未知风险'
} else if (type === 'gaming') {
const levelNum = parseInt(level)
if (levelNum === 0) return '无风险'
if (levelNum > 0 && levelNum <= 20) return '极低风险'
if (levelNum > 20 && levelNum <= 40) return '低风险'
if (levelNum > 40 && levelNum <= 60) return '中等风险'
if (levelNum > 60 && levelNum <= 80) return '高风险'
if (levelNum > 80) return '极高风险'
return '未知风险'
}
return '未知风险'
}
// 风险等级转换为颜色
const riskLevelColor = (level, type) => {
if (type === 'black_gray_level') {
if (level === '' || level === '1') return 'bg-gradient-to-r from-emerald-400 to-teal-500'
if (level === '2') return 'bg-gradient-to-r from-amber-400 to-yellow-500'
if (level === '3') return 'bg-gradient-to-r from-orange-400 to-amber-600'
if (level === '4') return 'bg-gradient-to-r from-rose-400 to-red-500'
return 'bg-gradient-to-r from-gray-400 to-gray-500'
} else if (type === 'telefraud_level') {
if (level === '0') return 'bg-gradient-to-r from-emerald-400 to-teal-500'
if (level === '1' || level === '2') return 'bg-gradient-to-r from-teal-300 to-green-400'
if (level === '3' || level === '4') return 'bg-gradient-to-r from-amber-400 to-yellow-500'
if (level === '5') return 'bg-gradient-to-r from-orange-400 to-amber-600'
if (level === '6') return 'bg-gradient-to-r from-rose-400 to-red-500'
return 'bg-gradient-to-r from-gray-400 to-gray-500'
} else if (type === 'frg_list_level') {
if (level >= '3' && level <= '5') return 'bg-gradient-to-r from-emerald-400 to-teal-500'
if (level >= '6' && level <= '7') return 'bg-gradient-to-r from-amber-400 to-yellow-500'
if (level >= '8' && level <= '10') return 'bg-gradient-to-r from-rose-400 to-red-500'
return 'bg-gradient-to-r from-gray-400 to-gray-500'
} else if (type === 'risk_level') {
if (level === 'A') return 'bg-gradient-to-r from-emerald-400 to-teal-500'
if (level === 'F') return 'bg-gradient-to-r from-amber-400 to-yellow-500'
if (level === 'C' || level === 'D') return 'bg-gradient-to-r from-orange-400 to-amber-600'
if (level === 'B' || level === 'E') return 'bg-gradient-to-r from-rose-400 to-red-500'
return 'bg-gradient-to-r from-gray-400 to-gray-500'
} else if (type === 'gaming') {
const levelNum = parseInt(level)
if (levelNum === 0) return 'bg-gradient-to-r from-emerald-400 to-teal-500'
if (levelNum > 0 && levelNum <= 20) return 'bg-gradient-to-r from-teal-300 to-green-400'
if (levelNum > 20 && levelNum <= 40) return 'bg-gradient-to-r from-green-400 to-green-500'
if (levelNum > 40 && levelNum <= 60) return 'bg-gradient-to-r from-amber-400 to-yellow-500'
if (levelNum > 60 && levelNum <= 80) return 'bg-gradient-to-r from-orange-400 to-amber-600'
if (levelNum > 80) return 'bg-gradient-to-r from-rose-400 to-red-500'
return 'bg-gradient-to-r from-gray-400 to-gray-500'
}
return 'bg-gradient-to-r from-gray-400 to-gray-500'
}
// 根据风险类型获取名称
const getRiskTypeName = type => {
const types = {
110: '疑似欺诈',
130: '疑似赌博庄家',
150: '疑似赌博玩家',
170: '疑似涉赌跑分',
}
return types[type] || '未知类型'
}
// 获取团伙规模描述
const getGroupSizeDesc = code => {
const sizes = {
a: '小规模(少于50人)',
b: '中等规模(50-100人)',
c: '大规模(100-500人)',
d: '超大规模(500人以上)',
}
return sizes[code] || '未知规模'
}
// 获取风险图标
const getRiskIcon = type => {
switch (type) {
case '110':
return 'fa-exclamation-triangle'
case '130':
return 'fa-dice'
case '150':
return 'fa-gamepad'
case '170':
return 'fa-money-bill-wave'
default:
return 'fa-question-circle'
}
}
// 获取不良记录详情
const getRiskLevelDetail = level => {
switch (level) {
case 'A':
return '无任何不良记录'
case 'F':
return '涉稳、寻衅滋事'
case 'C':
case 'D':
return '吸毒、涉毒、犯罪前科'
case 'B':
case 'E':
return '涉案人员、在逃、犯罪嫌疑人'
default:
return '未知记录'
}
}
// 风险评估总结
const getRiskSummary = () => {
if (!props.data) return { text: '无法评估风险', level: 'low', color: 'text-gray-500' }
let highRiskCount = 0
let mediumRiskCount = 0
// 检查黑灰产等级
if (props.data.black_gray_level && parseInt(props.data.black_gray_level) > 2) {
highRiskCount++
} else if (props.data.black_gray_level && parseInt(props.data.black_gray_level) === 2) {
mediumRiskCount++
}
// 检查电诈风险
if (props.data.telefraud_level && parseInt(props.data.telefraud_level) > 4) {
highRiskCount++
} else if (props.data.telefraud_level && parseInt(props.data.telefraud_level) > 2) {
mediumRiskCount++
}
// 检查团伙欺诈
if (
props.data.fraud_group &&
props.data.fraud_group.frg_list_level &&
parseInt(props.data.fraud_group.frg_list_level) > 7
) {
highRiskCount++
} else if (
props.data.fraud_group &&
props.data.fraud_group.frg_list_level &&
parseInt(props.data.fraud_group.frg_list_level) > 5
) {
mediumRiskCount++
}
// 检查风险等级
if (props.data.risk_level && props.data.risk_level.risk_level) {
if (['B', 'E'].includes(props.data.risk_level.risk_level)) {
highRiskCount++
} else if (['C', 'D'].includes(props.data.risk_level.risk_level)) {
mediumRiskCount++
} else if (props.data.risk_level.risk_level === 'F') {
// 低风险,不增加计数
}
}
// 检查反诈反赌核验
if (props.data.anti_fraud_gaming) {
props.data.anti_fraud_gaming.forEach(item => {
const levelNum = parseInt(item.riskLevel)
if (levelNum > 60) {
highRiskCount++
} else if (levelNum > 40) {
mediumRiskCount++
}
})
}
if (highRiskCount > 0) {
return {
text: '该用户存在较高风险行为,建议进行进一步核实和监控',
level: 'high',
color: 'text-red-500',
}
} else if (mediumRiskCount > 0) {
return {
text: '该用户存在一定风险行为,建议提高警惕',
level: 'medium',
color: 'text-yellow-500',
}
} else {
return {
text: '该用户行为正常,风险较低',
level: 'low',
color: 'text-green-500',
}
}
}
const summary = getRiskSummary()
// 计算风险评分0-100分分数越高越安全
const riskScore = computed(() => {
// 计算总风险项数量
const totalRiskCount = Object.values(summary).reduce((sum, item) => sum + item.count, 0);
// 根据风险项数量计算评分
// 0项100分最安全
// 1-2项80分较安全
// 3-5项60分中等风险
// 6-10项40分较高风险
// 10项以上20分高风险
if (totalRiskCount === 0) return 100;
if (totalRiskCount <= 2) return 80;
if (totalRiskCount <= 5) return 60;
if (totalRiskCount <= 10) return 40;
return 20;
});
// 使用 composable 通知父组件风险评分
useRiskNotifier(props, riskScore);
// 暴露给父组件
defineExpose({
riskScore
});
</script>
<template>
<div class="card main-card">
<div v-if="!data || Object.keys(data).length === 0" class="py-4 text-center text-gray-500">
暂无风险行为扫描数据
</div>
<div v-else class="risk-content">
<!-- 风险总结 -->
<div class="summary-card" :class="{
'border-red-500 glow-red': summary.level === 'high',
'border-yellow-500 glow-yellow': summary.level === 'medium',
'border-green-500 glow-green': summary.level === 'low',
}">
<div class="flex items-center">
<div class="summary-icon" :class="summary.color">
<i class="fas" :class="summary.level === 'high'
? 'fa-exclamation-triangle'
: summary.level === 'medium'
? 'fa-exclamation-circle'
: 'fa-check-circle'
"></i>
</div>
<div class="font-bold text-lg" :class="summary.color">风险评估总结</div>
</div>
<div class="mt-1 text-gray-700">{{ summary.text }}</div>
</div>
<div class="grid-container">
<!-- 左侧列 -->
<div class="grid-left">
<!-- 黑灰产等级 -->
<!-- <div class="risk-section hover-lift">
<div class="section-title flex items-center">
<div class="title-icon bg-indigo-100 text-indigo-600">
<i class="fas fa-user-secret"></i>
</div>
<span>黑灰产等级</span>
</div>
<div class="section-content">
<div class="risk-level-indicator">
<div class="indicator-label">风险等级</div>
<div class="indicator-bar">
<div class="indicator-value" :class="riskLevelColor(data.black_gray_level || '', 'black_gray_level')"
:style="{
width: data.black_gray_level ? `${Math.min(parseInt(data.black_gray_level) * 25, 100)}%` : '0%',
}"></div>
</div>
<div class="indicator-text" :class="{
'text-green-500': (data.black_gray_level || '') === '' || (data.black_gray_level || '') === '1',
'text-yellow-500': (data.black_gray_level || '') === '2',
'text-orange-500': (data.black_gray_level || '') === '3',
'text-red-500': (data.black_gray_level || '') === '4',
}">
{{ riskLevelText(data.black_gray_level || '', 'black_gray_level') }}
</div>
</div>
<div class="description">黑灰产等级评估用户是否参与非法活动等级越高风险越大</div>
</div>
</div> -->
<!-- 电诈风险预警 -->
<!-- <div class="risk-section hover-lift">
<div class="section-title flex items-center">
<div class="title-icon bg-red-100 text-red-600">
<i class="fas fa-phone-slash"></i>
</div>
<span>电诈风险预警</span>
</div>
<div class="section-content">
<div class="risk-level-indicator">
<div class="indicator-label">风险等级</div>
<div class="indicator-bar">
<div class="indicator-value" :class="riskLevelColor(data.telefraud_level || '0', 'telefraud_level')"
:style="{ width: `${Math.min(parseInt(data.telefraud_level || '0') * 16.6, 100)}%` }"></div>
</div>
<div class="indicator-text" :class="{
'text-green-500':
(data.telefraud_level || '0') === '0' ||
(data.telefraud_level || '0') === '1' ||
(data.telefraud_level || '0') === '2',
'text-yellow-500': (data.telefraud_level || '0') === '3' || (data.telefraud_level || '0') === '4',
'text-orange-500': (data.telefraud_level || '0') === '5',
'text-red-500': (data.telefraud_level || '0') === '6',
}">
{{ riskLevelText(data.telefraud_level || '0', 'telefraud_level') }}
</div>
</div>
<div class="description">电诈风险预警评估用户是否涉及电信诈骗活动值越大风险越高</div>
</div>
</div> -->
<!-- 综合风险等级 -->
<!-- <div class="risk-section hover-lift">
<div class="section-title flex items-center">
<div class="title-icon bg-emerald-100 text-emerald-600">
<i class="fas fa-shield-alt"></i>
</div>
<span>不良个人核查</span>
</div>
<div class="section-content">
<div v-if="data.risk_level" class="flex items-center justify-center py-3">
<div
class="risk-level-badge"
:class="{
'bg-green-100 text-green-700 badge-pulse-green': data.risk_level.risk_level === 'A',
'bg-yellow-100 text-yellow-700 badge-pulse-yellow': data.risk_level.risk_level === 'F',
'bg-orange-100 text-orange-700 badge-pulse-orange': ['C', 'D'].includes(data.risk_level.risk_level),
'bg-red-100 text-red-700 badge-pulse-red': ['B', 'E'].includes(data.risk_level.risk_level),
}"
>
<span class="text-xl font-bold">{{
riskLevelText(data.risk_level.risk_level || 'A', 'risk_level')
}}</span>
</div>
<div class="ml-4 text-sm">
<div class="font-medium">详情:</div>
<div
class="mt-1"
:class="{
'text-green-600': data.risk_level.risk_level === 'A',
'text-yellow-600': data.risk_level.risk_level === 'F',
'text-orange-600': ['C', 'D'].includes(data.risk_level.risk_level),
'text-red-600': ['B', 'E'].includes(data.risk_level.risk_level),
}"
>
{{ getRiskLevelDetail(data.risk_level.risk_level || 'A') }}
</div>
</div>
</div>
<div v-else class="text-center py-2 text-gray-500">暂无不良个人核查数据</div>
<div class="description">不良个人核查评估用户的风险状况从无风险到高风险分级</div>
</div>
</div> -->
</div>
<div class="grid-right">
<!-- <div class="risk-section hover-lift">
<div class="section-title flex items-center">
<div class="title-icon bg-amber-100 text-amber-600">
<i class="fas fa-users-slash"></i>
</div>
<span>团伙欺诈排查</span>
</div>
<div class="section-content">
<div v-if="data.fraud_group" class="flex flex-col md:flex-row gap-3">
<div class="risk-level-indicator flex-1">
<div class="indicator-label">团伙风险等级</div>
<div class="indicator-bar">
<div class="indicator-value"
:class="riskLevelColor(data.fraud_group.frg_list_level || '3', 'frg_list_level')" :style="{
width: `${Math.min((parseInt(data.fraud_group.frg_list_level || '3') - 2) * 12.5, 100)}%`,
}"></div>
</div>
<div class="indicator-text" :class="{
'text-green-500': parseInt(data.fraud_group.frg_list_level || '3') <= 5,
'text-yellow-500':
parseInt(data.fraud_group.frg_list_level || '3') >= 6 &&
parseInt(data.fraud_group.frg_list_level || '3') <= 7,
'text-red-500': parseInt(data.fraud_group.frg_list_level || '3') >= 8,
}">
{{ riskLevelText(data.fraud_group.frg_list_level || '3', 'frg_list_level') }}
</div>
</div>
<div class="group-size flex-1">
<div class="font-medium text-gray-700">团伙规模</div>
<div class="mt-2 flex items-center">
<i class="fas fa-users text-blue-500 mr-2 text-xl"></i>
<span>{{ getGroupSizeDesc(data.fraud_group.frg_group_num || 'a') }}</span>
</div>
</div>
</div>
<div v-else class="text-center py-2 text-gray-500">暂无团伙欺诈数据</div>
<div class="description mt-1">团伙欺诈排查评估用户是否属于欺诈团伙及团伙规模大小</div>
</div>
</div> -->
<div class="risk-section hover-lift">
<div class="section-title flex items-center">
<div class="title-icon bg-purple-100 text-purple-600">
<i class="fas fa-dice-slash"></i>
</div>
<span>反诈反赌核验</span>
</div>
<div class="section-content">
<div v-if="data.anti_fraud_gaming && data.anti_fraud_gaming.length > 0" class="grid grid-cols-1 gap-3">
<div v-for="(item, index) in data.anti_fraud_gaming" :key="index" class="gaming-item" :class="parseInt(item.riskLevel) === 0
? 'border-green-500'
: parseInt(item.riskLevel) < 4
? 'border-green-400'
: parseInt(item.riskLevel) < 7
? 'border-yellow-500'
: 'border-red-500'
">
<div class="gaming-icon" :class="parseInt(item.riskLevel) === 0
? 'bg-green-100 text-green-500'
: parseInt(item.riskLevel) < 4
? 'bg-green-100 text-green-500'
: parseInt(item.riskLevel) < 7
? 'bg-yellow-100 text-yellow-600'
: 'bg-red-100 text-red-500'
">
<i class="fas" :class="getRiskIcon(item.riskType)"></i>
</div>
<div class="flex-1">
<div class="font-medium text-sm">{{ getRiskTypeName(item.riskType) }}</div>
<div class="flex items-center mt-2">
<div class="progress-container">
<div class="progress-bar" :class="riskLevelColor(item.riskLevel, 'gaming')"
:style="{ width: `${Math.min(parseInt(item.riskLevel), 100)}%` }"></div>
</div>
<span class="risk-level-text" :class="{
'text-green-500': parseInt(item.riskLevel) <= 20,
'text-green-600': parseInt(item.riskLevel) > 20 && parseInt(item.riskLevel) <= 40,
'text-yellow-500': parseInt(item.riskLevel) > 40 && parseInt(item.riskLevel) <= 60,
'text-orange-500': parseInt(item.riskLevel) > 60 && parseInt(item.riskLevel) <= 80,
'text-red-500': parseInt(item.riskLevel) > 80,
}">
{{ riskLevelText(item.riskLevel, 'gaming') }}
</span>
</div>
</div>
</div>
</div>
<div v-else class="text-center py-2 text-gray-500">暂无反诈反赌核验数据</div>
<div class="description mt-1">反诈反赌核验评估用户是否有涉及诈骗或赌博活动的风险</div>
</div>
</div>
<div class="security-tips hover-lift">
<div class="flex items-center">
<div class="title-icon bg-blue-100 text-blue-600 mr-2">
<i class="fas fa-lightbulb"></i>
</div>
<div class="font-bold text-blue-700">安全建议</div>
</div>
<div class="tip-list">
<div class="tip-item">
<i class="fas fa-check-circle text-green-500 mr-1"></i>
<span>定期更新密码使用复杂且不易猜测的密码</span>
</div>
<div class="tip-item">
<i class="fas fa-check-circle text-green-500 mr-1"></i>
<span>开启双因素认证提高账户安全性</span>
</div>
<div class="tip-item">
<i class="fas fa-check-circle text-green-500 mr-1"></i>
<span>不点击来源不明的链接或下载不明文件</span>
</div>
<div class="tip-item">
<i class="fas fa-check-circle text-green-500 mr-1"></i>
<span>不向陌生人透露个人敏感信息</span>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.main-card {
@apply bg-white shadow-md rounded-xl p-4 mb-3 border border-gray-100;
}
.risk-content {
@apply space-y-4;
}
.grid-container {
@apply grid grid-cols-1 md:grid-cols-2 gap-4;
}
.grid-left,
.grid-right {
@apply flex flex-col gap-4;
}
.summary-card {
@apply p-4 rounded-xl shadow-sm bg-gradient-to-br from-sky-50 to-indigo-100 border-l-4 transition-all duration-300;
}
.glow-red {
box-shadow: 0 4px 12px rgba(239, 68, 68, 0.15);
border-color: rgba(239, 68, 68, 0.6);
}
.glow-yellow {
box-shadow: 0 4px 12px rgba(245, 158, 11, 0.15);
border-color: rgba(245, 158, 11, 0.6);
}
.glow-green {
box-shadow: 0 4px 12px rgba(16, 185, 129, 0.15);
border-color: rgba(16, 185, 129, 0.6);
}
.summary-icon {
@apply mr-2 text-xl;
}
.risk-section {
@apply bg-white rounded-xl shadow-sm border border-gray-100 overflow-hidden transition-all duration-300;
}
.hover-lift:hover {
transform: translateY(-3px);
box-shadow:
0 8px 16px -2px rgba(0, 0, 0, 0.1),
0 4px 8px -2px rgba(0, 0, 0, 0.05);
}
.section-title {
@apply bg-gradient-to-r from-gray-50 to-gray-100 px-4 py-3 font-bold text-gray-700 border-b border-gray-200 flex items-center;
}
.title-icon {
@apply w-7 h-7 rounded-full flex items-center justify-center mr-3 shadow-sm;
}
.section-content {
@apply p-4;
}
.risk-level-indicator {
@apply mb-2;
}
.indicator-label {
@apply text-gray-700 font-medium mb-1 text-sm;
}
.indicator-bar {
@apply w-full bg-gray-200 rounded-full h-3 overflow-hidden shadow-inner;
}
.indicator-value {
@apply h-3 rounded-full transition-all duration-500;
}
.indicator-text {
@apply mt-1 font-medium text-sm;
}
.description {
@apply text-xs text-gray-500 mt-2 italic;
}
.risk-level-badge {
@apply flex flex-col items-center justify-center w-20 h-20 rounded-full shadow-md border transition-transform duration-300 backdrop-blur-sm;
}
.badge-pulse-green {
background: linear-gradient(135deg, rgba(16, 185, 129, 0.15), rgba(5, 150, 105, 0.3));
border-color: rgba(5, 150, 105, 0.4);
animation: pulse-green 3s infinite;
}
.badge-pulse-yellow {
background: linear-gradient(135deg, rgba(245, 158, 11, 0.15), rgba(217, 119, 6, 0.3));
border-color: rgba(217, 119, 6, 0.4);
animation: pulse-yellow 3s infinite;
}
.badge-pulse-orange {
background: linear-gradient(135deg, rgba(249, 115, 22, 0.15), rgba(234, 88, 12, 0.3));
border-color: rgba(234, 88, 12, 0.4);
animation: pulse-orange 3s infinite;
}
.badge-pulse-red {
background: linear-gradient(135deg, rgba(239, 68, 68, 0.15), rgba(220, 38, 38, 0.3));
border-color: rgba(220, 38, 38, 0.4);
animation: pulse-red 3s infinite;
}
@keyframes pulse-green {
0% {
box-shadow: 0 0 0 0 rgba(16, 185, 129, 0.3);
}
70% {
box-shadow: 0 0 0 8px rgba(16, 185, 129, 0);
}
100% {
box-shadow: 0 0 0 0 rgba(16, 185, 129, 0);
}
}
@keyframes pulse-yellow {
0% {
box-shadow: 0 0 0 0 rgba(245, 158, 11, 0.3);
}
70% {
box-shadow: 0 0 0 8px rgba(245, 158, 11, 0);
}
100% {
box-shadow: 0 0 0 0 rgba(245, 158, 11, 0);
}
}
@keyframes pulse-orange {
0% {
box-shadow: 0 0 0 0 rgba(249, 115, 22, 0.3);
}
70% {
box-shadow: 0 0 0 8px rgba(249, 115, 22, 0);
}
100% {
box-shadow: 0 0 0 0 rgba(249, 115, 22, 0);
}
}
@keyframes pulse-red {
0% {
box-shadow: 0 0 0 0 rgba(239, 68, 68, 0.3);
}
70% {
box-shadow: 0 0 0 8px rgba(239, 68, 68, 0);
}
100% {
box-shadow: 0 0 0 0 rgba(239, 68, 68, 0);
}
}
.group-size {
@apply bg-gradient-to-br from-gray-50 to-gray-100 p-3 rounded-lg shadow-sm;
}
.gaming-item {
@apply flex items-center bg-white shadow-sm rounded-lg p-3 border-l-2 transition-all duration-300;
}
.gaming-item:hover {
@apply shadow-md;
transform: scale(1.01);
}
.gaming-icon {
@apply w-9 h-9 flex items-center justify-center rounded-full mr-3 shadow-sm;
}
.progress-container {
@apply w-full bg-gray-200 rounded-full h-3 mr-3 flex-1 shadow-inner;
}
.progress-bar {
@apply h-3 rounded-full transition-all duration-500;
}
.risk-level-text {
@apply text-xs whitespace-nowrap min-w-[3.5rem] text-right font-semibold;
}
.security-tips {
@apply bg-gradient-to-br from-sky-50 to-indigo-100 rounded-xl p-4 shadow-sm border border-blue-200;
}
.tip-list {
@apply mt-3 space-y-2;
}
.tip-item {
@apply flex items-start text-sm text-gray-700 bg-white p-2 rounded-lg shadow-sm border border-gray-100;
}
</style>