Files
CloudSearch/packages/frontend/src/pages/admin/SaveRecords.vue
admin 1c0c024b9a feat: 转存记录记录文件大小, 详情展示使用账号+文件大小+时间格式
- quark/baidu 驱动 saveFromShare 返回 fileSize 总大小
- cloud.service.ts 写入 file_size 字段(非null时转字符串)
- 详情页新增文件大小展示(formatFileSize 自动格式化)
- 详情页时间改为 formatTime(yyyy-MM-dd HH:mm:ss)
- SaveRecords 时间格式: 05-15 → 2026-05-15
2026-05-15 07:05:03 +08:00

794 lines
26 KiB
Vue
Executable File
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div 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" style="flex:2">
<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.file_size">
<span class="detail-label">文件大小</span>
<code class="detail-code">{{ formatFileSize(row.file_size) }}</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.config_nickname">
<span class="detail-label">使用账号</span>
<el-tag size="small" type="success" effect="plain">{{ row.config_nickname }}</el-tag>
</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.folder_name">
<span class="detail-label">转存文件夹</span>
<code class="detail-code">{{ row.folder_name }}</code>
</div>
</div>
<!-- Row 3: 分享链接 + 分享密码 + 耗时 -->
<div class="detail-row">
<div class="detail-cell" v-if="row.share_url" style="flex:2">
<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">
<span class="detail-label">耗时</span>
<span :class="['detail-duration', durationClass(row.duration_ms)]">{{ formatDuration(row.duration_ms) }}</span>
</div>
</div>
<!-- Row 4: 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 class="detail-cell">
<span class="detail-label">时间</span>
<code class="detail-code">{{ formatTime(row.created_at) }}</code>
</div>
</div>
<!-- Row 5: 错误信息整行 -->
<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 `${d.getFullYear()}-${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>