chore: initial commit - CloudSearch v0.0.2

This commit is contained in:
2026-05-15 05:50:50 +08:00
commit d83225d736
102 changed files with 37926 additions and 0 deletions

View File

@@ -0,0 +1,774 @@
<template>
<div class="save-records">
<!-- Toolbar -->
<div class="toolbar">
<div class="toolbar-row">
<div class="filter-group">
<el-select v-model="statusFilter" placeholder="状态" clearable style="width: 100px" @change="loadRecords(1)">
<el-option label="全部状态" value="" />
<el-option label="✓ 成功" value="success" />
<el-option label="♻️ 复用" value="reused" />
<el-option label="✗ 失败" value="failed" />
</el-select>
<el-select v-model="cloudFilter" placeholder="网盘" clearable style="width: 100px" @change="loadRecords(1)">
<el-option label="全部网盘" value="" />
<el-option v-for="ct in cloudTypes" :key="ct" :label="cloudLabel(ct)" :value="ct">
<span :style="{ display: 'inline-flex', alignItems: 'center', gap: '6px' }">
<img :src="cloudIcon(ct)" style="width:16px;height:16px" />
{{ cloudLabel(ct) }}
</span>
</el-option>
</el-select>
<div class="time-btns">
<button
v-for="btn in timeButtons" :key="btn.key"
:class="['time-btn', { active: activeTimeBtn === btn.key }]"
@click="setTimeFilter(btn.key)"
>{{ btn.label }}</button>
</div>
<el-date-picker
v-model="dateRange"
type="daterange"
range-separator=""
start-placeholder="开始日期"
end-placeholder="结束日期"
value-format="YYYY-MM-DD"
style="width: 220px"
@change="onDateRangeChange"
/>
<el-input
v-model="searchKeyword"
placeholder="搜索资源名称…"
clearable
style="width: 180px"
@clear="loadRecords(1)"
@keyup.enter="loadRecords(1)"
>
<template #prefix>
<el-icon><Search /></el-icon>
</template>
</el-input>
</div>
<div class="toolbar-actions">
<el-button size="small" @click="resetFilters">重置筛选</el-button>
<span class="record-count"> {{ total }} </span>
</div>
</div>
</div>
<!-- 转存统计汇总 -->
<div v-if="summary" class="save-summary">
<span class="summary-item summary-all">📊 <strong>{{ summary.total }}</strong> </span>
<span class="summary-divider">|</span>
<span class="summary-item summary-success"> 成功 <strong>{{ summary.success }}</strong></span>
<span class="summary-item summary-reused"> 复用 <strong>{{ summary.reused }}</strong></span>
<span class="summary-item summary-failed"> 失败 <strong>{{ summary.failed }}</strong></span>
<span class="summary-item summary-rate" v-if="summary.total > 0">
成功率 <strong>{{ ((summary.success + summary.reused) / summary.total * 100).toFixed(1) }}%</strong>
</span>
</div>
<!-- Table -->
<div class="el-table-wrap">
<el-table
:data="records" stripe style="width: 100%"
v-loading="loading"
empty-text="暂无转存记录"
@expand-change="onExpandChange"
:row-class-name="rowClassName"
>
<el-table-column type="expand" width="36">
<template #default="{ row }">
<div class="expand-detail">
<!-- Row 1: 原始链接 + 文件夹数量 + 文件数量 -->
<div class="detail-row">
<div class="detail-cell">
<span class="detail-label">原始链接</span>
<a :href="row.source_url" target="_blank" class="detail-link">{{ row.source_url }}</a>
</div>
<div class="detail-cell" v-if="row.original_folder_name">
<span class="detail-label">原始文件夹名</span>
<code class="detail-code">{{ row.original_folder_name }}</code>
</div>
<div class="detail-cell" v-if="row.status !== 'reused' && (row.folder_count > 0 || row.file_count > 0)">
<span class="detail-label">文件夹</span>
<span><strong>{{ row.folder_count || 0 }}</strong> </span>
</div>
<div class="detail-cell" v-if="row.status !== 'reused' && (row.folder_count > 0 || row.file_count > 0)">
<span class="detail-label">文件</span>
<span><strong>{{ row.file_count || 0 }}</strong> </span>
</div>
<div class="detail-cell" v-if="row.status === 'reused'">
<span class="detail-label">复用方式</span>
<span class="reuse-msg"> 直接使用已有分享链接无需实际转存</span>
</div>
</div>
<!-- Row 2: 分享链接 + 分享密码 + 转存文件夹 -->
<div class="detail-row">
<div class="detail-cell" v-if="row.share_url">
<span class="detail-label">分享链接</span>
<a :href="row.share_url" target="_blank" class="detail-link">{{ row.share_url }}</a>
</div>
<div class="detail-cell" v-if="row.share_pwd">
<span class="detail-label">分享密码</span>
<el-tag size="small" type="warning">{{ row.share_pwd }}</el-tag>
</div>
<div class="detail-cell" v-if="row.folder_name">
<span class="detail-label">转存文件夹</span>
<code class="detail-code">{{ row.folder_name }}</code>
</div>
</div>
<!-- Row 3: IP地址 + 归属地 -->
<div class="detail-row" v-if="row.ip_address">
<div class="detail-cell">
<span class="detail-label">IP 地址</span>
<code class="detail-code">{{ row.ip_address }}</code>
</div>
<div class="detail-cell" v-if="row.ip_location">
<span class="detail-label">归属地</span>
<code class="detail-code">{{ formatLocation(row.ip_location) }}</code>
</div>
</div>
<!-- Row 4: 错误信息整行 -->
<div class="detail-row" v-if="row.status === 'failed' && row.error_message">
<div class="detail-cell detail-full">
<span class="detail-label">错误信息</span>
<pre class="detail-error">{{ row.error_message }}</pre>
</div>
</div>
</div>
</template>
</el-table-column>
<el-table-column label="序号" width="68" align="center">
<template #default="{ $index }">
{{ (currentPage - 1) * pageSize + $index + 1 }}
</template>
</el-table-column>
<el-table-column label="时间" width="140">
<template #default="{ row }">
<span :title="row.created_at">{{ formatTime(row.created_at) }}</span>
</template>
</el-table-column>
<el-table-column label="网盘" width="70" align="center">
<template #default="{ row }">
<el-tooltip :content="cloudLabel(row.source_type)" placement="top">
<img :src="cloudIcon(row.source_type)" style="width:22px;height:22px;cursor:default" />
</el-tooltip>
</template>
</el-table-column>
<el-table-column label="状态" width="72" align="center">
<template #default="{ row }">
<el-tooltip :content="statusTip(row.status)" placement="top">
<span :class="['status-badge', statusClass(row.status)]">
{{ statusIcon(row.status) }}
</span>
</el-tooltip>
</template>
</el-table-column>
<el-table-column label="资源名称" min-width="160" show-overflow-tooltip>
<template #default="{ row }">
<span :title="row.source_title || ''">{{ row.source_title || '-' }}</span>
</template>
</el-table-column>
<el-table-column label="耗时" width="85" align="center">
<template #default="{ row }">
<span :class="['duration', durationClass(row.duration_ms)]">
{{ formatDuration(row.duration_ms) }}
</span>
</template>
</el-table-column>
<el-table-column label="归属地" min-width="130" show-overflow-tooltip>
<template #default="{ row }">
<span v-if="row.ip_location" class="loc-badge">{{ formatLocation(row.ip_location) }}</span>
<span v-else class="no-data">-</span>
</template>
</el-table-column>
<el-table-column label="备注" min-width="200" show-overflow-tooltip>
<template #default="{ row }">
<span v-if="row.status === 'failed' && row.error_message" class="err-msg" :title="row.error_message">
{{ truncateErr(row.error_message) }}
</span>
<span v-else-if="row.status === 'failed'" class="err-msg">失败</span>
<span v-else-if="row.status === 'reused'" class="reuse-msg"> 复用已有链接</span>
<span v-else class="no-data">-</span>
</template>
</el-table-column>
<el-table-column label="操作" width="80" fixed="right" align="center">
<template #default="{ row }">
<div class="action-cell">
<el-tooltip content="复制分享链接" placement="top">
<el-button size="small" circle text :disabled="!row.share_url" @click="copyText(row.share_url!)">
<el-icon><Link /></el-icon>
</el-button>
</el-tooltip>
<el-tooltip content="打开分享链接" placement="top">
<el-button size="small" circle text :disabled="!row.share_url" @click="windowOpen(row.share_url!)">
<el-icon><TopRight /></el-icon>
</el-button>
</el-tooltip>
</div>
</template>
</el-table-column>
</el-table>
</div>
<!-- Pagination -->
<div class="pagination-wrap" v-if="total > 0">
<div class="pagination-info">
{{ (currentPage - 1) * pageSize + 1 }}-{{ Math.min(currentPage * pageSize, total) }} {{ total }}
</div>
<el-pagination
v-model:current-page="currentPage"
v-model:page-size="pageSize"
:total="total"
:page-sizes="[15, 20, 30, 50, 100]"
layout="sizes, prev, pager, next, jumper"
@current-change="loadRecords"
@size-change="loadRecords(1)"
/>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { getSaveRecords, getCloudTypes } from '../../api'
import type { SaveRecord } from '../../api'
import { ElMessage } from 'element-plus'
import { Search, CopyDocument, Link, TopRight } from '@element-plus/icons-vue'
const records = ref<SaveRecord[]>([])
const total = ref(0)
const currentPage = ref(1)
const pageSize = ref(20)
const loading = ref(false)
const statusFilter = ref('')
const cloudFilter = ref('')
const searchKeyword = ref('')
const activeTimeBtn = ref('today')
const timeStart = ref('')
const timeEnd = ref('')
const dateRange = ref<string[] | null>(null)
const cloudTypes = ref<string[]>([])
const summary = ref<{ total: number; success: number; failed: number; reused: number } | null>(null)
const timeButtons = [
{ key: 'today', label: '今日' },
{ key: 'week', label: '本周' },
{ key: 'month', label: '本月' },
{ key: 'lastMonth', label: '上月' },
]
// ── Cloud type helpers — loaded from backend API ──
const cloudTypeMap = ref<Record<string, { label: string; icon: string }>>({})
const FALLBACK_ICON_SVG = '<svg viewBox="0 0 24 24" width="16" height="16"><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>'
async function loadCloudTypes() {
try {
const res = await getCloudTypes()
const map: Record<string, { label: string; icon: string }> = {}
for (const ct of res.types) {
map[ct.type] = { label: ct.label, icon: ct.icon }
}
cloudTypeMap.value = map
} catch {
// ignore
}
}
function cloudLabel(t: string): string { return cloudTypeMap.value[t]?.label || t }
function cloudIcon(t: string): string { return cloudTypeMap.value[t]?.icon || FALLBACK_ICON_SVG }
function extractCloudTypes(data: SaveRecord[]) {
const set = new Set<string>()
data.forEach(r => { if (r.source_type) set.add(r.source_type) })
const existing = new Set(cloudTypes.value)
set.forEach(t => { if (!existing.has(t)) cloudTypes.value.push(t) })
}
// ── Formatting helpers ──
function fmt(d: Date): string {
const y = d.getFullYear()
const M = String(d.getMonth() + 1).padStart(2, '0')
const D = String(d.getDate()).padStart(2, '0')
return `${y}-${M}-${D}`
}
function formatTime(t: string): string {
if (!t) return '-'
let ts = t
if (/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/.test(ts)) ts = ts.replace(' ', 'T') + '+08:00'
const d = new Date(ts)
if (isNaN(d.getTime())) return t
const pad = (n: number) => String(n).padStart(2, '0')
return `${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`
}
function formatDuration(ms: number): string {
if (!ms) return '-'
if (ms < 1000) return `${ms}ms`
return `${(ms / 1000).toFixed(1)}s`
}
function durationClass(ms: number): string {
if (!ms) return ''
if (ms > 30000) return 'dur-slow'
if (ms > 10000) return 'dur-warn'
return 'dur-fast'
}
function fileCountType(n: number): string {
if (n >= 50) return 'danger'
if (n >= 10) return 'warning'
return 'success'
}
function truncateErr(msg: string): string {
return msg.length > 50 ? msg.slice(0, 50) + '…' : msg
}
// ── Status helpers ──
function statusTip(status: string): string {
if (status === 'success') return '转存成功'
if (status === 'reused') return '♻️ 复用已有分享链接'
return '转存失败'
}
function statusClass(status: string): string {
if (status === 'success') return 'status-ok'
if (status === 'reused') return 'status-reuse'
return 'status-fail'
}
function statusIcon(status: string): string {
if (status === 'success') return '✓'
if (status === 'reused') return '♻️'
return '✗'
}
/** 省份/城市中英文翻译(用于 api.ip.sb 返回的英语地名) */
const CN_PLACES: Record<string, string> = {
// 省份/直辖市
'Anhui':'安徽','Beijing':'北京','Chongqing':'重庆','Fujian':'福建','Gansu':'甘肃','Guangdong':'广东',
'Guangxi':'广西','Guizhou':'贵州','Hainan':'海南','Hebei':'河北','Henan':'河南','Heilongjiang':'黑龙江',
'Hubei':'湖北','Hunan':'湖南','Inner Mongolia':'内蒙古','Jiangsu':'江苏','Jiangxi':'江西','Jilin':'吉林',
'Liaoning':'辽宁','Ningxia':'宁夏','Qinghai':'青海','Shaanxi':'陕西','Shandong':'山东','Shanghai':'上海',
'Shanxi':'山西','Sichuan':'四川','Tianjin':'天津','Tibet':'西藏','Xinjiang':'新疆','Yunnan':'云南','Zhejiang':'浙江',
'Hong Kong':'香港','Macau':'澳门','Taiwan':'台湾',
// 主要城市
'Changsha':'长沙','Hefei':'合肥','Fuzhou':'福州','Lanzhou':'兰州',
'Guangzhou':'广州','Nanning':'南宁','Guiyang':'贵阳','Haikou':'海口',
'Shijiazhuang':'石家庄','Zhengzhou':'郑州','Harbin':'哈尔滨','Wuhan':'武汉',
'Nanjing':'南京','Nanchang':'南昌','Changchun':'长春','Shenyang':'沈阳',
'Yinchuan':'银川','Xining':'西宁',"Xi'an":"西安",'Jinan':'济南',
'Taiyuan':'太原','Chengdu':'成都','Shenzhen':'深圳','Hangzhou':'杭州',
'Suzhou':'苏州','Wuxi':'无锡','Ningbo':'宁波','Dongguan':'东莞',
'Foshan':'佛山','Zhuhai':'珠海','Qingdao':'青岛','Dalian':'大连',
'Xiamen':'厦门','Kunming':'昆明','Lhasa':'拉萨','Urumqi':'乌鲁木齐',
'Linyi':'临沂','Wenzhou':'温州','Quanzhou':'泉州',
};
/** 常见英文ISP → 中文 */
const CN_ISP: Record<string, string> = {
'China Telecom':'中国电信','China Mobile':'中国移动','China Unicom':'中国联通',
'Chinanet':'中国电信','ChinaNet':'中国电信','CMNET':'中国移动',
'CNC Group':'中国联通','unicom':'中国联通','telecom':'中国电信','mobile':'中国移动',
'China Education and Research Network':'教育网','CERNET':'教育网',
'China Networks':'中国网络','China163':'中国电信','CHINANET BACKBONE':'中国电信',
'Tencent Cloud':'腾讯云','Alibaba Cloud':'阿里云','Aliyun':'阿里云','Huawei Cloud':'华为云',
'Baidu':'百度','Beijing Baidu':'百度',
};
function formatLocation(loc: string): string {
// Remove "China" prefix
let s = loc.replace(/^(中国|China)\s*/i, '')
// Split into parts
const parts = s.split(/\s+/).filter(Boolean)
// Translate each part
return parts.map(p => CN_PLACES[p] || CN_ISP[p] || p).join(' ')
}
function rowClassName({ row }: { row: SaveRecord }) {
return row.status === 'failed' ? 'row-failed' : ''
}
// ── Actions ──
async function copyText(text: string) {
try {
await navigator.clipboard.writeText(text)
ElMessage.success('已复制到剪贴板')
} catch {
// fallback
const ta = document.createElement('textarea')
ta.value = text
document.body.appendChild(ta)
ta.select()
document.execCommand('copy')
document.body.removeChild(ta)
ElMessage.success('已复制到剪贴板')
}
}
function windowOpen(url: string) {
window.open(url, '_blank')
}
function onExpandChange(row: SaveRecord, expanded: boolean[]) {
// just placeholder for potential future use
}
// ── Filter helpers ──
function setTimeFilter(key: string) {
activeTimeBtn.value = key
dateRange.value = null
const now = new Date()
const y = now.getFullYear()
const m = now.getMonth()
let start: Date
let end: Date
switch (key) {
case 'today':
start = new Date(y, m, now.getDate())
end = start
break
case 'week': {
const dow = now.getDay()
start = new Date(y, m, now.getDate() + (dow === 0 ? -6 : 1 - dow))
end = now
break
}
case 'month':
start = new Date(y, m, 1)
end = now
break
case 'lastMonth':
start = new Date(y, m - 1, 1)
end = new Date(y, m, 0)
break
default:
start = new Date(y, m, now.getDate())
end = start
}
timeStart.value = fmt(start)
const nextDay = new Date(end.getFullYear(), end.getMonth(), end.getDate() + 1)
timeEnd.value = fmt(nextDay)
loadRecords(1)
}
function onDateRangeChange(val: string[] | null) {
if (val && val.length === 2) {
activeTimeBtn.value = ''
timeStart.value = val[0]
const next = new Date(val[1])
next.setDate(next.getDate() + 1)
timeEnd.value = fmt(next)
loadRecords(1)
} else {
setTimeFilter('today')
}
}
function resetFilters() {
statusFilter.value = ''
cloudFilter.value = ''
searchKeyword.value = ''
dateRange.value = null
setTimeFilter('today')
}
async function loadRecords(page = 1) {
loading.value = true
try {
currentPage.value = page
const s = statusFilter.value || undefined
const c = cloudFilter.value || undefined
const kw = searchKeyword.value || undefined
const res = await getSaveRecords(page, pageSize.value, timeStart.value, timeEnd.value, s, c, kw)
records.value = res.records
total.value = res.total
summary.value = res.summary || null
extractCloudTypes(res.records)
} catch (e) {
console.error('加载转存记录失败', e)
} finally {
loading.value = false
}
}
onMounted(() => {
setTimeFilter('today')
loadCloudTypes()
})
</script>
<style scoped>
.save-records { padding: 0; }
/* ── Toolbar ── */
.toolbar-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 20px;
padding-bottom: 16px;
flex-wrap: wrap;
border-bottom: 1px solid var(--el-border-color-light, #ebeef5);
}
.filter-group {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
}
.toolbar-actions {
display: flex;
align-items: center;
gap: 12px;
}
.record-count {
font-size: 13px;
color: var(--text-secondary, #909399);
white-space: nowrap;
}
/* ── Time buttons ── */
.time-btns {
display: flex;
gap: 2px;
background: var(--el-fill-color-light, #f0f2f5);
border-radius: 8px;
padding: 3px;
}
.time-btn {
border: none;
background: transparent;
padding: 6px 14px;
border-radius: 6px;
font-size: 13px;
color: #606266;
cursor: pointer;
transition: all .25s ease;
}
.time-btn:hover {
color: var(--el-color-primary);
background: rgba(64,158,255,.06);
}
.time-btn.active {
background: linear-gradient(135deg, var(--el-color-primary), var(--el-color-primary-light-3, #79bbff));
color: #fff;
font-weight: 600;
box-shadow: 0 2px 6px rgba(64,158,255,.3);
}
/* ── Table ── */
.save-records :deep(.el-table) {
border: 1px solid var(--el-border-color-light, #ebeef5);
border-radius: 8px;
}
.save-records .el-table-wrap {
overflow-x: auto;
}
.save-records :deep(.el-table th.el-table__cell) {
background-color: var(--el-fill-color-light, #f5f7fa);
font-weight: 600;
color: var(--el-text-color-primary, #303133);
white-space: nowrap;
}
.save-records :deep(.el-table .el-table__cell) {
padding: 8px 8px;
white-space: nowrap !important;
}
.save-records :deep(.el-table .el-table__cell .cell) {
white-space: nowrap !important;
}
/* ── Cell content: one-line with ellipsis ── */
.cell-nowrap {
display: block;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.save-records :deep(.el-table__row:hover > .el-table__cell) {
background-color: var(--el-color-primary-light-9, #ecf5ff);
}
/* Row highlight for failed */
.save-records :deep(.row-failed > .el-table__cell) {
background-color: rgba(245,108,108,.04);
}
/* ── Status badge ── */
.status-badge {
display: inline-flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
border-radius: 50%;
font-size: 13px;
font-weight: bold;
}
.status-ok {
background: rgba(103,194,58,.15);
color: #67c23a;
}
.status-reuse {
background: rgba(64,158,255,.15);
color: #409eff;
}
.status-fail {
background: rgba(245,108,108,.15);
color: #f56c6c;
}
/* ── Cell helpers ── */
.ip-text {
font-family: monospace;
font-size: 12px;
}
.loc-badge {
display: inline-block;
background: linear-gradient(135deg, #e8f4ff, #f0f8ff);
color: #1a6ea0;
font-size: 12px;
padding: 2px 10px;
border-radius: 4px;
border: 1px solid #b8d9f0;
white-space: nowrap;
}
.err-msg {
color: var(--el-color-danger);
font-size: 12px;
}
.reuse-msg {
color: var(--el-color-primary);
font-size: 12px;
}
.no-data {
color: var(--text-secondary, #c0c4cc);
}
/* ── Duration color ── */
.duration { font-size: 12px; font-family: monospace; }
.dur-fast { color: #67c23a; }
.dur-warn { color: #e6a23c; }
.dur-slow { color: #f56c6c; font-weight: 600; }
/* ── Expand detail (flex rows) ── */
.expand-detail {
padding: 16px 24px;
background: var(--el-fill-color-lighter, #fafafa);
border-radius: 6px;
display: flex;
flex-direction: column;
gap: 12px;
}
.detail-row {
display: flex;
align-items: flex-start;
gap: 16px 24px;
flex-wrap: wrap;
}
.detail-cell {
display: flex;
flex-direction: column;
gap: 4px;
flex: none;
}
.detail-cell.detail-full {
flex: 1 1 100%;
}
.detail-label {
font-size: 12px;
color: #909399;
font-weight: 500;
}
.detail-link {
color: var(--el-color-primary);
font-size: 13px;
word-break: break-all;
text-decoration: none;
}
.detail-link:hover { text-decoration: underline; }
.detail-code {
font-size: 12px;
background: #f0f0f0;
padding: 4px 8px;
border-radius: 4px;
word-break: break-all;
}
.detail-error {
margin: 0;
font-size: 12px;
color: #f56c6c;
background: rgba(245,108,108,.08);
padding: 8px 12px;
border-radius: 4px;
white-space: pre-wrap;
word-break: break-word;
max-height: 120px;
overflow-y: auto;
}
/* ── Action cell ── */
.action-cell {
display: flex;
align-items: center;
justify-content: center;
gap: 2px;
}
/* ── Pagination ── */
.pagination-wrap {
display: flex;
align-items: center;
justify-content: space-between;
margin-top: 20px;
padding: 12px 16px;
background: var(--el-fill-color-light, #f5f7fa);
border-radius: 8px;
flex-wrap: wrap;
gap: 12px;
}
.pagination-info {
font-size: 13px;
color: var(--text-secondary, #909399);
white-space: nowrap;
}
/* ── 转存统计汇总 ── */
.save-summary {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 16px;
margin-bottom: 8px;
background: var(--el-fill-color-light, #f5f7fa);
border-radius: 8px;
font-size: 13px;
flex-wrap: wrap;
}
.summary-item {
white-space: nowrap;
}
.summary-success strong { color: #67c23a; }
.summary-failed strong { color: #f56c6c; }
.summary-reused strong { color: #e6a23c; }
.summary-rate { color: #909399; }
.summary-rate strong { color: #409eff; }
.summary-divider { color: #dcdfe6; font-size: 12px; user-select: none; }
</style>