v0.3.7: 恢复前端Vue源码 + 修复AdminDashboard 401根源

This commit is contained in:
2026-05-17 13:26:36 +08:00
parent 09be4c307e
commit 8cd4dabb60
178 changed files with 20570 additions and 5 deletions
@@ -0,0 +1,889 @@
<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'
import { getSaveRecords } from '../../api'
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 data = await getSaveRecords(1, 5)
recentSaves.value = data.records || []
} 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>