Files
FileShare/web/src/utils/format.ts
T
SKYMirror 7f060dd0e4 26.9(安全审计修复版)
Go 1.27.1 (Gin+GORM) + Vue 3 文件快传服务:

- 安全审计全部修复(docs/security-audit-2026-09-05.md):
  bcrypt 密码哈希与自动升级、presign 直传服务端大小/内容校验、
  全局请求体上限、依赖升级(govulncheck 0 命中)、janitor 后台清理、
  管理端审计动作落库、/admin CORS 收紧、通知内容白名单净化、
  会话默认 7 天、限流缓存故障降级、robots.txt 端点等
- 前端:取件链接复制修复(不再重复拼接提取码)、markdown 净化器加固
- Redis 支持库号(FCB_REDIS_DB / redis://…/db URL)
- 文档:docs/api/* 与 openapi.yaml 同步最新行为(robots.txt、
  提码 5 位起、chunk 32MiB 上限、admin 审计动作等)

验证:gofmt/go vet/go test 全绿;二进制端到端冒烟通过
2026-09-05 04:22:41 +08:00

142 lines
4.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
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.
/** 前端工具函数 */
import { i18n } from '@/i18n'
function tt(key: string, params?: Record<string, unknown>): 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<T extends (...args: never[]) => void>(fn: T, wait: number): (...args: Parameters<T>) => void {
let timer: ReturnType<typeof setTimeout> | null = null
return (...args: Parameters<T>) => {
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<boolean> {
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-256hex),用于分片上传 file_hash */
export async function sha256Hex(data: ArrayBuffer): Promise<string> {
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<T = unknown>(obj: Record<string, unknown>, 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
}