890 lines
25 KiB
Vue
890 lines
25 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"
|
||
shadow="never"
|
||
>
|
||
<div class="stat-label">{{ item.label }}</div>
|
||
<div class="stat-value">{{ (stats as any)[item.key] ?? 0 }}</div>
|
||
</el-card>
|
||
</div>
|
||
|
||
<!-- Row 2: 网盘使用空间 -->
|
||
<div class="dash-row">
|
||
<el-card class="storage-card" shadow="never">
|
||
<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="10"
|
||
:color="cloudStoragePercent(item) > 80 ? '#f56c6c' : cloudStoragePercent(item) > 60 ? '#e6a23c' : '#67c23a'"
|
||
/>
|
||
</div>
|
||
<el-empty v-if="stats.cloudUsage.length === 0" description="暂无网盘数据" :image-size="72" />
|
||
</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" shadow="never">
|
||
<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" shadow="never">
|
||
<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="56" />
|
||
</div>
|
||
</el-card>
|
||
|
||
<el-card class="insight-card" shadow="never">
|
||
<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="56" />
|
||
</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="56" />
|
||
</div>
|
||
</el-card>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 网盘设置及授权 -->
|
||
<div v-if="showCloudToggle" class="section-content">
|
||
<el-card class="config-card" shadow="never">
|
||
<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) ──
|
||
/** ── Trend summary (MUST be declared before useTrendChart callback) ── */
|
||
|
||
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(() => {
|
||
const el = document.querySelector('.trend-chart-echarts') as HTMLElement | null
|
||
if (el && el.childElementCount === 0) {
|
||
renderTrendChart()
|
||
initTrendChart()
|
||
} else if (el) {
|
||
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>
|
||
.section-content { }
|
||
|
||
/* ── Row 1: 统计卡片 ── */
|
||
.dash-row { margin-bottom: 20px; }
|
||
.dash-row-stats {
|
||
display: grid;
|
||
grid-template-columns: repeat(6, 1fr);
|
||
gap: 14px;
|
||
}
|
||
@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); } }
|
||
|
||
.stat-card {
|
||
text-align: center;
|
||
background: var(--bg-card) !important;
|
||
border: 1px solid var(--border) !important;
|
||
border-radius: var(--radius-lg) !important;
|
||
transition: transform 0.2s ease, box-shadow 0.2s ease;
|
||
cursor: default;
|
||
position: relative;
|
||
overflow: hidden;
|
||
}
|
||
/* 统计卡片左侧装饰条 — 不同颜色区分 */
|
||
.stat-card::before {
|
||
content: '';
|
||
position: absolute;
|
||
top: 0;
|
||
left: 0;
|
||
width: 4px;
|
||
height: 100%;
|
||
border-radius: 14px 0 0 14px;
|
||
}
|
||
.stat-card:nth-child(1)::before { background: linear-gradient(180deg, #409eff, #79bbff); }
|
||
.stat-card:nth-child(2)::before { background: linear-gradient(180deg, #67c23a, #95d475); }
|
||
.stat-card:nth-child(3)::before { background: linear-gradient(180deg, #e6a23c, #f3d19e); }
|
||
.stat-card:nth-child(4)::before { background: linear-gradient(180deg, #7c3aed, #a78bfa); }
|
||
.stat-card:nth-child(5)::before { background: linear-gradient(180deg, #f56c6c, #f89898); }
|
||
.stat-card:nth-child(6)::before { background: linear-gradient(180deg, #36cfc9, #6fe0d9); }
|
||
.stat-card:hover {
|
||
transform: translateY(-4px);
|
||
box-shadow: 0 8px 24px rgba(0,0,0,0.08) !important;
|
||
}
|
||
.stat-card :deep(.el-card__body) {
|
||
padding: 22px 14px 18px !important;
|
||
}
|
||
.stat-label {
|
||
font-size: 13px;
|
||
color: var(--text-tertiary);
|
||
font-weight: 500;
|
||
margin-bottom: 8px;
|
||
letter-spacing: 0.5px;
|
||
}
|
||
.stat-value {
|
||
font-size: 30px;
|
||
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;
|
||
}
|
||
|
||
/* ── Row 2: 三列洞察卡片 ── */
|
||
.dash-row-cols-3 {
|
||
display: grid;
|
||
grid-template-columns: 1fr 1fr 1fr;
|
||
gap: 14px;
|
||
}
|
||
@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: var(--radius-lg);
|
||
border: 1px solid var(--border) !important;
|
||
transition: box-shadow 0.2s;
|
||
}
|
||
.insight-card:hover {
|
||
box-shadow: var(--shadow-md) !important;
|
||
}
|
||
.insight-card :deep(.el-card__body) {
|
||
padding: 0 !important;
|
||
}
|
||
.insight-card.trend-card :deep(.el-card__body) {
|
||
padding: 8px 6px 0 !important;
|
||
height: 400px;
|
||
overflow: hidden;
|
||
}
|
||
.insight-card:not(.trend-card) :deep(.el-card__body) {
|
||
padding: 14px 18px !important;
|
||
max-height: 400px;
|
||
overflow-y: auto;
|
||
}
|
||
.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: var(--radius-sm);
|
||
border: 1px solid var(--border);
|
||
background: var(--bg-card);
|
||
font-size: 11px;
|
||
color: var(--text-tertiary);
|
||
cursor: pointer;
|
||
transition: all 0.2s;
|
||
font-family: inherit;
|
||
}
|
||
.trend-day-btn:hover {
|
||
border-color: var(--primary);
|
||
color: var(--primary);
|
||
}
|
||
.trend-day-btn.active {
|
||
background: var(--primary);
|
||
border-color: var(--primary);
|
||
color: #fff;
|
||
}
|
||
|
||
/* ECharts */
|
||
.trend-chart-echarts {
|
||
width: 100%;
|
||
height: 100%;
|
||
min-height: 320px;
|
||
}
|
||
|
||
/* Trend summary */
|
||
.trend-summary-row {
|
||
display: grid;
|
||
grid-template-columns: repeat(4, 1fr);
|
||
gap: 8px;
|
||
padding: 12px 14px 6px;
|
||
border-bottom: 1px solid var(--border-light);
|
||
}
|
||
.trend-summary-item { text-align: center; }
|
||
.trend-summary-num {
|
||
display: block;
|
||
font-size: 17px;
|
||
font-weight: 700;
|
||
color: var(--text);
|
||
line-height: 1.3;
|
||
}
|
||
.trend-summary-desc {
|
||
display: block;
|
||
font-size: 11px;
|
||
color: var(--text-tertiary);
|
||
margin-top: 2px;
|
||
}
|
||
|
||
/* Keywords */
|
||
.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 list */
|
||
.ip-list {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 6px;
|
||
}
|
||
.ip-row {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
padding: 6px 8px;
|
||
border-radius: var(--radius-sm);
|
||
background: var(--bg);
|
||
transition: background 0.2s;
|
||
}
|
||
.ip-row:hover {
|
||
background: var(--border-light);
|
||
}
|
||
.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', 'Cascadia Code', monospace;
|
||
font-size: 13px;
|
||
color: var(--text);
|
||
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: var(--primary-soft);
|
||
color: var(--primary);
|
||
white-space: nowrap;
|
||
flex-shrink: 0;
|
||
}
|
||
.ip-count {
|
||
font-size: 12px;
|
||
color: var(--text-tertiary);
|
||
white-space: nowrap;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
/* Province */
|
||
.province-list {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 5px;
|
||
}
|
||
.province-row {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
padding: 5px 8px;
|
||
border-radius: var(--radius-sm);
|
||
background: var(--bg);
|
||
}
|
||
.province-row:hover {
|
||
background: var(--border-light);
|
||
}
|
||
.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: var(--border-light);
|
||
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: var(--text);
|
||
font-weight: 500;
|
||
white-space: nowrap;
|
||
width: 90px;
|
||
text-align: right;
|
||
flex-shrink: 0;
|
||
}
|
||
.province-count {
|
||
font-size: 12px;
|
||
color: var(--text-tertiary);
|
||
white-space: nowrap;
|
||
width: 50px;
|
||
text-align: right;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
/* ── Storage section ── */
|
||
.storage-card {
|
||
border-radius: var(--radius-lg);
|
||
border: 1px solid var(--border) !important;
|
||
}
|
||
.storage-grid {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 14px;
|
||
}
|
||
.storage-drive-card {
|
||
flex: 1 1 280px;
|
||
min-width: 260px;
|
||
border: 1px solid var(--border);
|
||
border-radius: var(--radius-md);
|
||
padding: 16px;
|
||
background: var(--bg-card);
|
||
transition: box-shadow 0.2s, transform 0.2s;
|
||
position: relative;
|
||
overflow: hidden;
|
||
}
|
||
.storage-drive-card::before {
|
||
content: '';
|
||
position: absolute;
|
||
top: 0;
|
||
left: 0;
|
||
right: 0;
|
||
height: 3px;
|
||
background: linear-gradient(90deg, #409eff, #7c3aed);
|
||
}
|
||
.storage-drive-card:hover {
|
||
box-shadow: var(--shadow-md);
|
||
transform: translateY(-2px);
|
||
}
|
||
.drive-header {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
margin-bottom: 10px;
|
||
}
|
||
.drive-icon {
|
||
width: 22px;
|
||
height: 22px;
|
||
border-radius: 5px;
|
||
flex-shrink: 0;
|
||
object-fit: contain;
|
||
}
|
||
.drive-name {
|
||
font-size: 14px;
|
||
font-weight: 600;
|
||
color: var(--text);
|
||
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: var(--bg); color: var(--text-tertiary); }
|
||
.drive-space {
|
||
font-size: 12px;
|
||
margin-bottom: 8px;
|
||
display: flex;
|
||
align-items: baseline;
|
||
gap: 3px;
|
||
}
|
||
.drive-used { color: var(--text); font-weight: 600; }
|
||
.drive-sep { color: var(--border); }
|
||
.drive-total { color: var(--text-secondary); font-weight: 500; }
|
||
|
||
/* ── Cloud toggle ── */
|
||
.config-card {
|
||
margin-bottom: 20px;
|
||
}
|
||
.config-card :deep(.el-card__header) {
|
||
background: var(--bg-card-header);
|
||
}
|
||
.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: var(--radius-sm);
|
||
background: var(--bg);
|
||
border: 1px solid var(--border);
|
||
transition: background 0.2s, box-shadow 0.15s;
|
||
}
|
||
.cloud-toggle-chip:hover {
|
||
background: var(--border-light);
|
||
box-shadow: var(--shadow-sm);
|
||
}
|
||
.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;
|
||
}
|
||
</style>
|