1072 lines
29 KiB
Vue
1072 lines
29 KiB
Vue
<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>
|