- quark/baidu 驱动 saveFromShare 返回 fileSize 总大小 - cloud.service.ts 写入 file_size 字段(非null时转字符串) - 详情页新增文件大小展示(formatFileSize 自动格式化) - 详情页时间改为 formatTime(yyyy-MM-dd HH:mm:ss) - SaveRecords 时间格式: 05-15 → 2026-05-15
794 lines
26 KiB
Vue
Executable File
794 lines
26 KiB
Vue
Executable File
<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>
|