/** 前端工具函数 */ import { i18n } from '@/i18n' function tt(key: string, params?: Record): string { return i18n.global.t(key, params ?? {}) } export function formatBytes(bytes: number | null | undefined): string { if (bytes === null || bytes === undefined || Number.isNaN(bytes)) return '-' if (bytes < 1024) return `${bytes} B` const units = ['KB', 'MB', 'GB', 'TB'] let v = bytes let i = -1 do { v /= 1024 i++ } while (v >= 1024 && i < units.length - 1) return `${v.toFixed(v >= 100 ? 0 : 1)} ${units[i]}` } export function formatDateTime(iso: string | null | undefined): string { if (!iso) return '-' const d = new Date(iso) if (Number.isNaN(d.getTime())) return String(iso) const p = (n: number) => `${n}`.padStart(2, '0') return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}` } /** 剩余有效期的人类描述 */ export function formatRemaining(expiredAt: string | null | undefined): string { if (!expiredAt) return tt('time.forever') const t = new Date(expiredAt).getTime() if (Number.isNaN(t)) return tt('time.forever') const diff = t - Date.now() if (diff <= 0) return tt('time.expired') const m = Math.floor(diff / 60_000) if (m < 1) return tt('time.lessThanMinute') if (m < 60) return tt('time.minutes', { n: m }) const h = Math.floor(m / 60) if (h < 24) return tt('time.hoursMinutes', { h, m: m % 60 }) const d = Math.floor(h / 24) return tt('time.daysHours', { d, h: h % 24 }) } export function formatDuration(ms: number | null | undefined): string { if (ms === null || ms === undefined) return '-' if (ms < 1000) return `${ms} ms` return `${(ms / 1000).toFixed(2)} s` } /** 过期样式选项(对齐参考实现的 expireStyle 配置;label 用 i18n 键名,展示时经 expireStyleLabel 翻译) */ export const EXPIRE_STYLES: { value: string; label: string }[] = [ { value: 'day', label: 'day' }, { value: 'hour', label: 'hour' }, { value: 'minute', label: 'minute' }, { value: 'count', label: 'count' }, { value: 'forever', label: 'forever' }, ] /** 过期样式翻译(未知样式原样返回) */ export function expireStyleLabel(style: string): string { const known = EXPIRE_STYLES.find((s) => s.value === style) if (known) return tt(`expireStyle.${known.value}`) return style } /** 简单防抖 */ export function debounce void>(fn: T, wait: number): (...args: Parameters) => void { let timer: ReturnType | null = null return (...args: Parameters) => { if (timer) clearTimeout(timer) timer = setTimeout(() => fn(...args), wait) } } /** 从 Content-Disposition 提取文件名(支持 RFC 5987 filename*=UTF-8'') */ export function filenameFromDisposition(header: string | null): string | null { if (!header) return null const star = /filename\*=(?:UTF-8'')?([^;]+)/i.exec(header) if (star) { try { return decodeURIComponent(star[1].replace(/["']/g, '').trim()) } catch { /* fallthrough */ } } const plain = /filename="?([^";]+)"?/i.exec(header) return plain ? plain[1] : null } /** 触发浏览器下载 Blob */ export function downloadBlob(blob: Blob, filename: string): void { const url = URL.createObjectURL(blob) const a = document.createElement('a') a.href = url a.download = filename document.body.appendChild(a) a.click() a.remove() setTimeout(() => URL.revokeObjectURL(url), 5_000) } export async function copyText(text: string): Promise { try { await navigator.clipboard.writeText(text) return true } catch { // 兼容非安全上下文(http 部署) try { const ta = document.createElement('textarea') ta.value = text ta.style.position = 'fixed' ta.style.opacity = '0' document.body.appendChild(ta) ta.select() const ok = document.execCommand('copy') ta.remove() return ok } catch { return false } } } /** 计算 SHA-256(hex),用于分片上传 file_hash */ export async function sha256Hex(data: ArrayBuffer): Promise { const digest = await crypto.subtle.digest('SHA-256', data) return Array.from(new Uint8Array(digest)) .map((b) => b.toString(16).padStart(2, '0')) .join('') } /** 宽松取值:对象上第一个存在的键(用于兼容 camelCase/snake_case 响应) */ export function pick(obj: Record, keys: string[]): T | undefined { for (const k of keys) { if (obj && typeof obj === 'object' && k in obj && obj[k] !== undefined && obj[k] !== null) { return obj[k] as T } } return undefined }