Files
CloudSearch/packages/frontend/src/pages/admin/AdminDashboard.vue

1072 lines
29 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>
<!-- 仪表盘 -->
<div v-show="activeMenu === 'dashboard'" class="section-content">
<!-- Row 1: 6 个统计卡 文字在上数字在下 -->
<div class="dash-row dash-row-stats">
<el-card
v-for="item in statItems" :key="item.key"
class="stat-card stt-card"
>
<div class="stt-label">{{ item.label }}</div>
<div class="stt-value">{{ (stats as any)[item.key] ?? 0 }}</div>
</el-card>
</div>
<!-- Row 2: 网盘使用空间 flex-wrap 自适应 -->
<div class="dash-row">
<el-card class="storage-section-card">
<template #header><span>💾 网盘使用空间</span></template>
<div class="storage-grid">
<div
v-for="item in stats.cloudUsage" :key="item.cloudType + '-' + (item.nickname || '')"
class="storage-drive-card"
>
<div class="drive-header">
<img :src="driveIcon(item.cloudType)" class="drive-icon" />
<span class="drive-name">{{ item.nickname || item.cloudType }}</span>
<span :class="['drive-status', item.isActive ? 'active' : 'inactive']">
{{ item.isActive ? '正常' : '停用' }}
</span>
</div>
<div class="drive-space">
<span class="drive-used">{{ item.storageUsed || '--' }}</span>
<span class="drive-sep">/</span>
<span class="drive-total">{{ item.storageTotal || '--' }}</span>
</div>
<el-progress
:percentage="cloudStoragePercent(item)"
:stroke-width="12"
:color="cloudStoragePercent(item) > 80 ? '#f56c6c' : cloudStoragePercent(item) > 60 ? '#e6a23c' : '#67c23a'"
/>
</div>
<el-empty v-if="stats.cloudUsage.length === 0" description="暂无网盘数据" :image-size="80" />
</div>
</el-card>
</div>
<!-- Row 3: 7 天趋势 + 热门搜索关键词 Top 20 + 操作来源 IP Top 10 -->
<div class="dash-row dash-row-cols-3">
<el-card class="insight-card trend-card">
<template #header>
<div class="insight-header">
<span>📈 网站使用趋势图</span>
<div class="trend-day-btns">
<button
v-for="d in trendDayOptions" :key="d"
:class="['trend-day-btn', { active: trendDays === d }]"
@click="switchTrendDays(d)"
>{{ d }}</button>
</div>
</div>
</template>
<div v-if="trendSummary" class="trend-summary-row">
<div class="trend-summary-item">
<span class="trend-summary-num">{{ trendSummary.totalSearches }}</span>
<span class="trend-summary-desc">总搜索</span>
</div>
<div class="trend-summary-item">
<span class="trend-summary-num">{{ trendSummary.totalSaves }}</span>
<span class="trend-summary-desc">总保存</span>
</div>
<div class="trend-summary-item">
<span class="trend-summary-num">{{ trendSummary.avgSearches }}/{{ trendSummary.avgSaves }}</span>
<span class="trend-summary-desc">日均搜索/保存</span>
</div>
<div class="trend-summary-item">
<span class="trend-summary-num">{{ trendSummary.peakDay }}</span>
<span class="trend-summary-desc">峰值日 {{ trendSummary.peakSearches }}+{{ trendSummary.peakSaves }}</span>
</div>
</div>
<div ref="chartRef" class="trend-chart-echarts"></div>
</el-card>
<el-card class="insight-card">
<template #header><span>🔍 热门搜索关键词 Top 20</span></template>
<div class="keyword-list">
<el-tag
v-for="(kw, i) in stats.hotKeywords" :key="kw.keyword"
:type="keywordTagType(i)"
size="small"
class="keyword-tag"
>{{ kw.keyword }}<sup class="kw-count">{{ kw.count }}</sup></el-tag>
<el-empty v-if="stats.hotKeywords.length === 0" description="暂无搜索数据" :image-size="60" />
</div>
</el-card>
<el-card class="insight-card">
<template #header>
<el-tabs v-model="rightTab" class="card-tabs" @tab-click="handleTabClick">
<el-tab-pane label="🌐 IP Top 10" name="ip" />
<el-tab-pane label="🗺️ 地域 Top 10" name="province" />
</el-tabs>
</template>
<div class="ip-list" v-show="rightTab === 'ip'">
<div v-for="(ip, i) in stats.topIps" :key="ip.ip" class="ip-row">
<span class="ip-rank">{{ i + 1 }}</span>
<span class="ip-addr">{{ ip.ip }}</span>
<span class="ip-loc" v-if="ip.ip_location">{{ formatLocation(ip.ip_location) }}</span>
<span class="ip-count">{{ ip.count }} </span>
</div>
<el-empty v-if="stats.topIps.length === 0" description="暂无数据" :image-size="60" />
</div>
<div class="province-list" v-show="rightTab === 'province'">
<div
v-for="(item, i) in stats.provinceRankings.slice(0, 10)"
:key="item.province"
class="province-row"
>
<span class="province-rank">{{ i + 1 }}</span>
<span class="province-bar-wrap">
<span
class="province-bar"
:style="{
width: (item.count / maxProvinceCount) * 100 + '%',
background: provinceBarColor(i),
}"
></span>
</span>
<span class="province-name">{{ item.province }}</span>
<span class="province-count">{{ item.count }} </span>
</div>
<el-empty v-if="!stats.provinceRankings?.length" description="暂无数据" :image-size="60" />
</div>
</el-card>
</div>
</div>
<!-- 网盘配置 - 设置及授权 -->
<div v-if="showCloudToggle" class="section-content">
<el-card class="config-card">
<template #header><span>📂 网盘设置及授权</span></template>
<div class="cloud-toggle-grid">
<div
v-for="ct in cloudTypes"
:key="ct.type"
class="cloud-toggle-chip"
>
<img :src="ct.icon" class="cloud-icon-img" />
<span class="cloud-label">{{ ct.label }}</span>
<el-tag v-if="ct.type === 'others'" size="small" type="info"></el-tag>
<el-switch
:model-value="ct.enabled"
size="small"
@change="(val: boolean) => handleCloudToggle(ct.type, val)"
/>
</div>
</div>
<div class="form-tip" style="margin-top: 12px;">
关闭的网盘类型在搜索结果中不会展示修改后立即生效无需点击保存
</div>
</el-card>
<CloudConfig />
</div>
<!-- 系统配置 -->
<div v-if="showSystemConfig" class="section-content">
<SystemConfig :section="activeSystemSection" />
</div>
<!-- 转存日志 -->
<div v-if="activeMenu === 'save-records'" class="section-content">
<SaveRecords />
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, reactive, watch, nextTick } from 'vue'
import { useRouter } from 'vue-router'
import { useTrendChart, type TrendSummary } from '../../composables/useTrendChart'
import {
DataBoard,
Connection,
Setting,
SwitchButton,
DocumentCopy,
} from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus'
import { getStats, getCloudConfigs, getSiteConfig, getCloudTypes, toggleCloudType } from '../../api'
import type { StatsData } from '../../types'
import CloudConfig from './CloudConfig.vue'
import SystemConfig from './SystemConfig.vue'
import SaveRecords from './SaveRecords.vue'
interface CloudTypeInfo {
type: string
label: string
icon: string
enabled: boolean
}
const router = useRouter()
const appVersion = ref("")
const stats = ref<StatsData>({
todaySearches: 0,
todaySaves: 0,
monthSearches: 0,
monthSaves: 0,
totalSearches: 0,
totalSaves: 0,
hotKeywords: [],
trendTrend: [],
cloudUsage: [],
topIps: [],
provinceRankings: [],
})
const siteName = ref('')
const cloudTypes = ref<CloudTypeInfo[]>([])
// ── ECharts trend chart (via composable) ──
const { chartRef: _chartRef, render: renderTrendChart, initResize: initTrendChart } = useTrendChart(computed(() => stats.value.trendTrend), (s: any) => { trendSummary.value = s })
const chartRef = _chartRef as any
// Force render after mount (data may already be loaded)
watch(() => stats.value.trendTrend, () => {
renderTrendChart()
}, { deep: true })
// Reload chart when switching back to dashboard
watch(() => activeMenu.value, (val, oldVal) => {
if (val === 'dashboard' && oldVal !== 'dashboard') {
nextTick(() => {
// v-show keeps DOM alive; chart instance may need to be re-initialized
// after being hidden (ECharts can lose its canvas when container has display:none)
const el = document.querySelector('.trend-chart-echarts') as HTMLElement | null
if (el && el.childElementCount === 0) {
renderTrendChart()
initTrendChart()
} else if (el) {
// Chart still exists, just resize
initTrendChart()
}
})
}
})
// ── Dashboard stat items: label top, value bottom ──
const statItems = [
{ key: 'todaySearches', label: '今日搜索' },
{ key: 'todaySaves', label: '今日保存' },
{ key: 'monthSearches', label: '本月搜索' },
{ key: 'monthSaves', label: '本月保存' },
{ key: 'totalSearches', label: '总搜索量' },
{ key: 'totalSaves', label: '总保存量' },
]
function keywordTagType(i: number): string {
if (i === 0) return 'danger'
if (i < 3) return 'warning'
if (i < 8) return ''
return 'info'
}
/** Strip "中国/China" prefix from location string for consistent display */
function formatLocation(loc: string): string {
if (!loc) return ''
return loc.replace(/^(中国|China)\s*/i, '').trim()
}
// ── Province ranking helpers ──
const maxProvinceCount = computed(() => {
const items = stats.value.provinceRankings
return items.length > 0 ? Math.max(...items.map(i => i.count)) : 1
})
const PROVINCE_COLORS = ['#409eff', '#67c23a', '#e6a23c', '#f56c6c', '#909399', '#b37feb', '#36cfc9', '#ff85c0']
function provinceBarColor(i: number): string {
return PROVINCE_COLORS[i % PROVINCE_COLORS.length]
}
// ── Right card tabs ──
const rightTab = ref('ip')
function handleTabClick() {
// no-op for now, tabs handle switching via v-show
}
// ── Drive icon lookup from API data ──
const FALLBACK_ICON = 'data:image/svg+xml,' + encodeURIComponent('<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><rect rx="4" width="24" height="24" fill="#909399"/><text x="12" y="16" text-anchor="middle" fill="white" font-size="14" font-weight="bold" font-family="Arial">☁</text></svg>')
function driveIcon(type: string): string {
return cloudTypes.value.find(c => c.type === type)?.icon || FALLBACK_ICON
}
// ── Trend day range ──
const trendDayOptions = [7, 15, 30, 60]
const trendDays = ref(7)
const trendSummary = ref<TrendSummary | null>(null)
async function switchTrendDays(d: number) {
trendDays.value = d
await loadStats()
}
// Menu selection
const activeMenu = ref('dashboard')
const activeSystemSection = ref('')
// ── Recent saves ──
const recentSaves = ref<any[]>([])
const systemInfo = reactive({
uptime: '--',
memory: '--',
version: '--',
dbOk: false,
redisOk: false,
pansouOk: false,
})
async function loadRecentSaves() {
try {
const res = await fetch('/api/admin/save-records?pageSize=5&page=1')
const data = await res.json()
recentSaves.value = data.records || data.data || []
} catch { recentSaves.value = [] }
}
async function loadSystemInfo() {
try {
const res = await fetch('/health')
const h = await res.json()
systemInfo.uptime = formatUptime(h.uptime || 0)
systemInfo.memory = formatBytes(h.memory || 0)
systemInfo.version = h.version || '--'
systemInfo.dbOk = h.components?.db === 'connected'
systemInfo.redisOk = h.components?.redis === 'connected'
systemInfo.pansouOk = h.components?.pansou === 'ok'
} catch {}
}
function formatUptime(s: number): string {
const d = Math.floor(s / 86400)
const h = Math.floor((s % 86400) / 3600)
const m = Math.floor((s % 3600) / 60)
const parts = []
if (d > 0) parts.push(d + '天')
if (h > 0) parts.push(h + '时')
parts.push(m + '分')
return parts.join(' ')
}
function formatBytes(b: number): string {
if (b >= 1073741824) return (b / 1073741824).toFixed(1) + ' GB'
if (b >= 1048576) return (b / 1048576).toFixed(0) + ' MB'
return (b / 1024).toFixed(0) + ' KB'
}
function formatSaveTime(t: string): string {
if (!t) return ''
const d = new Date(t)
if (isNaN(d.getTime())) return t.slice(0, 10)
const diff = Date.now() - d.getTime()
const mins = Math.floor(diff / 60000)
if (mins < 60) return mins <= 1 ? '刚刚' : mins + '分钟前'
const hours = Math.floor(mins / 60)
if (hours < 24) return hours + '小时前'
return t.slice(0, 10)
}
const pageTitles: Record<string, string> = {
dashboard: '仪表盘',
'save-records': '转存日志',
}
const sysSectionTitles: Record<string, string> = {
'sys-site': '网站设置',
'sys-services': '外部服务 & 缓存',
'sys-strategy': '搜索结果展示策略',
'sys-validation': '链接验证配置',
'sys-filter': '搜索标题过滤规则',
'sys-password': '修改管理员密码',
}
const pageTitle = computed(() => {
if (activeMenu.value.startsWith('sys-')) {
return '系统配置 — ' + (sysSectionTitles[activeMenu.value] || '')
}
if (activeMenu.value === 'cloud-configs-toggle' || activeMenu.value === 'cloud-configs-cleanup') {
return '网盘配置'
}
return pageTitles[activeMenu.value] || '管理后台'
})
const showCloudToggle = computed(() => {
return activeMenu.value === 'cloud-configs-toggle'
})
const showSystemConfig = computed(() => {
return activeMenu.value.startsWith('sys-')
})
onMounted(async () => {
try {
const cfg = await getSiteConfig()
if (cfg.site_name) {
siteName.value = cfg.site_name
document.title = cfg.site_name + ' - 管理后台'
}
} catch {}
try {
const [configs, ctResult] = await Promise.all([
getCloudConfigs(),
getCloudTypes(),
])
cloudTypes.value = ctResult.types
await loadStats()
await loadRecentSaves()
await loadSystemInfo()
initTrendChart()
} catch (e) {
console.error('加载数据失败', e)
}
})
async function loadStats() {
try {
stats.value = await getStats(trendDays.value)
try { const h = await fetch("/health"); const hv = await h.json(); appVersion.value = hv.version; } catch {}
} catch (e) {
console.error('加载统计数据失败', e)
}
}
function cloudStoragePercent(item: { storageUsed: string; storageTotal: string }): number {
if (!item.storageUsed || !item.storageTotal) return 0
const used = parseFloat(item.storageUsed)
const total = parseFloat(item.storageTotal)
if (total <= 0) return 0
return Math.round((used / total) * 100)
}
function handleMenuSelect(index: string) {
if (index === 'logout') {
handleLogout()
return
}
activeMenu.value = index
// For system config sub-items, set which section to scroll to
if (index.startsWith('sys-')) {
activeSystemSection.value = index
}
// Refresh cloud types when switching to cloud configs
if (index === 'cloud-configs-toggle') {
getCloudTypes().then(r => { cloudTypes.value = r.types }).catch(() => {})
}
}
function handleLogout() {
localStorage.removeItem('admin_token')
router.push('/admin/login')
}
function goBackHome() {
window.location.href = '/'
}
// ── 网盘类型开关 ──
async function handleCloudToggle(type: string, enabled: boolean) {
const ct = cloudTypes.value.find(c => c.type === type)
if (!ct) return
try {
await toggleCloudType(type, enabled)
ct.enabled = enabled
} catch (e: any) {
ElMessage.error(e.message || '切换失败')
// Revert optimistic update
ct.enabled = !enabled
}
}
</script>
<style scoped>
.admin-layout {
display: flex;
min-height: 100vh;
}
/* === 左侧菜单:深色渐变背景 === */
.admin-menu {
width: 220px;
min-height: 100vh;
background: linear-gradient(180deg, #1a1a2e 0%, #16213e 100%);
border-right: none;
flex-shrink: 0;
transition: box-shadow 0.3s ease;
}
.admin-menu :deep(.el-menu-item) {
color: #a2a3b7;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
position: relative;
overflow: hidden;
}
.admin-menu :deep(.el-menu-item)::after {
content: '';
position: absolute;
left: 0;
top: 0;
bottom: 0;
width: 3px;
background: linear-gradient(180deg, #409eff, #7c3aed);
transform: scaleY(0);
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
border-radius: 0 2px 2px 0;
}
.admin-menu :deep(.el-menu-item.is-active) {
color: #409eff;
background: linear-gradient(90deg, rgba(64, 158, 255, 0.15) 0%, rgba(124, 58, 237, 0.08) 100%);
}
.admin-menu :deep(.el-menu-item.is-active)::after {
transform: scaleY(1);
}
.admin-menu :deep(.el-menu-item:hover) {
background: linear-gradient(90deg, rgba(255, 255, 255, 0.08) 0%, rgba(255, 255, 255, 0.02) 100%);
color: #e0e0e0;
}
/* Sub-menu styles */
.admin-menu :deep(.el-sub-menu__title) {
color: #a2a3b7;
transition: all 0.3s ease;
}
.admin-menu :deep(.el-sub-menu__title:hover) {
background: linear-gradient(90deg, rgba(255, 255, 255, 0.08) 0%, rgba(255, 255, 255, 0.02) 100%);
color: #e0e0e0;
}
.admin-menu :deep(.el-sub-menu.is-active .el-sub-menu__title) {
color: #409eff;
}
.admin-menu :deep(.el-sub-menu .el-menu) {
background: rgba(0, 0, 0, 0.15);
}
.admin-menu :deep(.el-sub-menu .el-menu .el-menu-item) {
padding-left: 56px !important;
font-size: 13px;
}
.menu-header {
padding: 24px 20px 16px;
color: #fff;
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
margin-bottom: 8px;
}
.menu-header h2 {
font-size: 20px;
font-weight: 700;
margin-bottom: 4px;
background: linear-gradient(90deg, #fff, #90caf9);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.menu-header p {
font-size: 12px;
color: rgba(255, 255, 255, 0.5);
}
.version-footer {
position: fixed;
bottom: 12px;
left: 0;
width: 220px;
text-align: center;
font-size: 12px;
color: rgba(255, 255, 255, 0.35);
letter-spacing: 0.5px;
padding: 8px 0;
pointer-events: none;
}
/* === 右侧内容区 === */
.admin-content {
flex: 1;
display: flex;
flex-direction: column;
overflow: auto;
}
.content-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 20px 24px;
border-bottom: none;
background: linear-gradient(135deg, #f5f7fa 0%, #eef1f5 100%);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
position: relative;
z-index: 1;
}
.content-header h2 {
font-size: 20px;
font-weight: 600;
color: #303133;
}
.content-body {
padding: 24px;
flex: 1;
}
/* === 网盘类型开关 — 网格布局 === */
.config-card {
margin-bottom: 24px;
border-radius: 14px;
border: 1px solid rgba(0, 0, 0, 0.06) !important;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.04);
overflow: hidden;
}
.config-card :deep(.el-card__header) {
border-bottom: 1px solid rgba(0, 0, 0, 0.04);
padding: 16px 20px;
font-weight: 600;
background: linear-gradient(135deg, #fafafa, #f5f5f5);
}
.config-card :deep(.el-card__body) {
padding: 20px;
}
.cloud-toggle-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
gap: 8px;
}
.cloud-toggle-chip {
display: flex;
align-items: center;
gap: 6px;
padding: 8px 10px;
border-radius: 10px;
background: #f8f9fa;
border: 1px solid #eee;
transition: background 0.2s, box-shadow 0.15s;
}
.cloud-toggle-chip:hover {
background: #f0f2f5;
box-shadow: 0 2px 6px rgba(0,0,0,0.04);
}
.cloud-icon-img {
width: 20px;
height: 20px;
border-radius: 4px;
flex-shrink: 0;
object-fit: contain;
}
.cloud-label {
font-size: 13px;
font-weight: 500;
flex: 1;
}
.form-tip {
font-size: 12px;
color: #909399;
line-height: 1.5;
}
/* === 仪表盘布局 === */
.dash-row {
margin-bottom: 24px;
}
.dash-row-stats {
display: grid;
grid-template-columns: repeat(6, 1fr);
gap: 16px;
}
@media (max-width: 1200px) { .dash-row-stats { grid-template-columns: repeat(3, 1fr); } }
@media (max-width: 700px) { .dash-row-stats { grid-template-columns: repeat(2, 1fr); } }
/* 统计卡片 — 文字在上,数字在下 */
.stt-card {
text-align: center;
border: none !important;
background: linear-gradient(135deg, #f0f5ff, #e8f0fe);
border-radius: 14px;
transition: transform 0.25s ease, box-shadow 0.25s ease;
}
.stt-card:hover {
transform: translateY(-4px);
box-shadow: 0 8px 24px rgba(64, 158, 255, 0.15);
}
.stt-card :deep(.el-card__body) {
padding: 20px 12px 16px;
}
.stt-label {
font-size: 13px;
color: #909399;
font-weight: 500;
margin-bottom: 6px;
}
.stt-value {
font-size: 32px;
font-weight: 800;
background: linear-gradient(135deg, #409eff, #7c3aed);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
letter-spacing: -1px;
line-height: 1.2;
}
/* === 第二排3 列洞察卡片 === */
.dash-row-cols-3 {
display: grid;
grid-template-columns: 1fr 1fr 1fr;
gap: 16px;
}
@media (max-width: 1100px) { .dash-row-cols-3 { grid-template-columns: 1fr 1fr; } }
@media (max-width: 700px) { .dash-row-cols-3 { grid-template-columns: 1fr; } }
.insight-card {
border-radius: 14px;
border: 1px solid rgba(0, 0, 0, 0.06) !important;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.04);
}
.insight-card :deep(.el-card__header) {
border-bottom: 1px solid rgba(0, 0, 0, 0.04);
padding: 14px 18px;
font-weight: 600;
font-size: 14px;
background: linear-gradient(135deg, #fafafa, #f5f5f5);
}
.insight-header {
display: flex;
align-items: center;
justify-content: space-between;
}
.trend-day-btns {
display: flex;
gap: 4px;
}
.trend-day-btn {
padding: 2px 10px;
border-radius: 6px;
border: 1px solid #dcdfe6;
background: #fff;
font-size: 11px;
color: #909399;
cursor: pointer;
transition: all 0.2s;
font-family: inherit;
}
.trend-day-btn:hover {
border-color: #409eff;
color: #409eff;
}
.trend-day-btn.active {
background: #409eff;
border-color: #409eff;
color: #fff;
}
.insight-card :deep(.el-card__body) {
padding: 0;
}
.insight-card.trend-card :deep(.el-card__body) {
padding: 8px 6px 0;
height: 380px;
overflow: hidden;
}
.insight-card:not(.trend-card) :deep(.el-card__body) {
padding: 14px 18px;
max-height: 380px;
overflow-y: auto;
}
/* ECharts 趋势图容器 — 铺满整个卡片 */
.trend-chart-echarts {
width: 100%;
height: 100%;
min-height: 310px;
}
/* 趋势汇总行 */
.trend-summary-row {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 8px;
padding: 10px 12px 4px;
border-bottom: 1px solid #f0f0f0;
}
.trend-summary-item {
text-align: center;
}
.trend-summary-num {
display: block;
font-size: 18px;
font-weight: 700;
color: #303133;
line-height: 1.3;
}
.trend-summary-desc {
display: block;
font-size: 11px;
color: #909399;
margin-top: 2px;
}
/* 关键词列表 */
.keyword-list {
display: flex;
flex-wrap: wrap;
gap: 6px;
align-content: flex-start;
}
.keyword-tag {
cursor: default;
}
.kw-count {
font-size: 9px;
margin-left: 2px;
opacity: 0.7;
font-weight: 400;
}
/* IP 列表 */
.ip-list {
display: flex;
flex-direction: column;
gap: 6px;
}
.ip-row {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 8px;
border-radius: 8px;
background: #f8f9fa;
transition: background 0.2s;
}
.ip-row:hover {
background: #f0f2f5;
}
.ip-rank {
width: 20px;
height: 20px;
border-radius: 50%;
background: linear-gradient(135deg, #409eff, #7c3aed);
color: #fff;
font-size: 11px;
font-weight: 700;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.ip-addr {
font-family: 'SF Mono', 'Fira Code', monospace;
font-size: 13px;
color: #303133;
font-weight: 500;
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.ip-loc {
font-size: 11px;
padding: 1px 6px;
border-radius: 4px;
background: rgba(64, 158, 255, 0.1);
color: #409eff;
white-space: nowrap;
flex-shrink: 0;
}
.ip-count {
font-size: 12px;
color: #909399;
white-space: nowrap;
flex-shrink: 0;
}
/* 地域使用榜(省份排行) */
.province-list {
display: flex;
flex-direction: column;
gap: 5px;
}
.province-row {
display: flex;
align-items: center;
gap: 8px;
padding: 5px 8px;
border-radius: 6px;
background: #f8f9fa;
}
.province-row:hover {
background: #f0f2f5;
}
.province-rank {
width: 20px;
height: 20px;
border-radius: 50%;
background: linear-gradient(135deg, #409eff, #7c3aed);
color: #fff;
font-size: 11px;
font-weight: 700;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.province-bar-wrap {
flex: 1;
height: 16px;
background: #e8e8e8;
border-radius: 8px;
overflow: hidden;
min-width: 0;
}
.province-bar {
display: block;
height: 100%;
border-radius: 8px;
transition: width 0.4s ease;
min-width: 4px;
}
.province-name {
font-size: 13px;
color: #303133;
font-weight: 500;
white-space: nowrap;
width: 90px;
text-align: right;
flex-shrink: 0;
}
.province-count {
font-size: 12px;
color: #909399;
white-space: nowrap;
width: 50px;
text-align: right;
flex-shrink: 0;
}
/* === 第三排:网盘空间 — flex-wrap 自适应 === */
.storage-section-card {
border-radius: 14px;
border: 1px solid rgba(0, 0, 0, 0.06) !important;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.04);
}
.storage-section-card :deep(.el-card__header) {
border-bottom: 1px solid rgba(0, 0, 0, 0.04);
padding: 14px 20px;
font-weight: 600;
font-size: 14px;
background: linear-gradient(135deg, #fafafa, #f5f5f5);
}
.storage-section-card :deep(.el-card__body) {
padding: 16px 20px;
}
.storage-grid {
display: flex;
flex-wrap: wrap;
gap: 16px;
}
.storage-drive-card {
flex: 1 1 280px;
min-width: 260px;
border: 1px solid #ebeef5;
border-radius: 12px;
padding: 16px;
background: #fafbfc;
transition: box-shadow 0.2s;
}
.storage-drive-card:hover {
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.06);
}
.drive-header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 10px;
}
.drive-icon {
width: 24px;
height: 24px;
border-radius: 5px;
flex-shrink: 0;
object-fit: contain;
}
.drive-name {
font-size: 14px;
font-weight: 600;
color: #303133;
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.drive-status {
font-size: 11px;
padding: 2px 8px;
border-radius: 4px;
flex-shrink: 0;
}
.drive-status.active { background: #f0f9eb; color: #67c23a; }
.drive-status.inactive { background: #fef0f0; color: #f56c6c; }
.drive-space {
display: flex;
align-items: baseline;
gap: 4px;
margin-bottom: 8px;
font-size: 14px;
}
.drive-used {
font-weight: 600;
color: #303133;
}
.drive-sep {
color: #c0c4cc;
}
.drive-total {
color: #909399;
}
/* Section content scroll */
.section-content {
min-height: 400px;
}
/* Tabs switcher inside card header */
.insight-card :deep(.card-tabs) {
margin-top: -10px;
}
.insight-card :deep(.card-tabs .el-tabs__header) {
margin: 0;
}
.insight-card :deep(.card-tabs .el-tabs__nav-wrap::after) {
height: 0;
}
.insight-card :deep(.card-tabs .el-tabs__item) {
height: 36px;
line-height: 36px;
font-size: 13px;
padding: 0 12px;
}
.insight-card :deep(.card-tabs .el-tabs__active-bar) {
height: 2px;
}
/* Province rows in narrow card */
.province-list .province-row {
gap: 4px;
padding: 3px 0;
}
.province-list .province-rank {
width: 20px;
height: 20px;
font-size: 10px;
}
.province-list .province-bar-wrap {
height: 12px;
}
.province-list .province-name {
width: 60px;
font-size: 12px;
}
.province-list .province-count {
width: 42px;
font-size: 11px;
}
/* Remove old tab styles */
.admin-tabs :deep(.el-tabs__header) {
display: none;
}
</style>