26.9:直链下载 + 过期回收 + SHA512 去重 + 防盗链 + 媒体预览 + 文件夹上传提示
CI 测试 / go vet + go test (push) Failing after 5s
CI 测试 / go vet + go test (push) Failing after 5s
- 对象存储直链:S3 引擎 302 到限时预签名 URL(有效期钳位分享剩余时效),失败自动回落代理 - 过期回收:janitor 定时扫描 + 取件惰性回收 + 管理端手动触发(POST /admin/recycle/run), retention_days 最长存储时长;删除走引用计数(去重对象安全) - SHA512 内容去重:三条上传链路落库后计算哈希,命中即复用旧对象并删除本次副本 - 下载防盗链:Referer 白名单(同源/空 Referer/通配域名放行),挂 /share/download - 取件页图片/音频内联预览(下载地址直连,加载失败回退下载按钮) - 文件夹上传:拖拽目录明确提示"建议压缩后上传"(webkitGetAsEntry 探测) - 管理端设置卡「回收与下载安全」8 个新配置键(KVSchema + configKeys + UI + i18n) - 前端产物重建并同步 server/web/dist 与 web-embed - 文档:10-config 配置表、03-file-share 直链/防盗链/文件夹章节、07-admin 回收端点、openapi
This commit is contained in:
@@ -49,6 +49,7 @@ export async function getMetadata(code: string): Promise<ShareMetadata> {
|
||||
remainingDownloads:
|
||||
(pick<number>(raw, ['remaining_downloads', 'remainingDownloads', 'expired_count', 'expiredCount']) as number) ??
|
||||
null,
|
||||
downloadUrl: (pick<string>(raw, ['download_url', 'downloadUrl']) as string) ?? null,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -27,6 +27,8 @@ export interface ShareMetadata {
|
||||
expiredCount: number | null
|
||||
usedCount: number
|
||||
remainingDownloads: number | null
|
||||
/** 26.9:select 接口返回的下载地址(代理路径或 S3 预签名直链) */
|
||||
downloadUrl?: string | null
|
||||
}
|
||||
|
||||
export interface ShareTextResult {
|
||||
|
||||
@@ -40,6 +40,12 @@ const acceptHint = computed(() => {
|
||||
function accept(f: File | null | undefined): void {
|
||||
error.value = ''
|
||||
if (!f) return
|
||||
// 26.9:拒绝文件夹条目(webkitRelativePath 非空 = 来自文件夹选择)
|
||||
if ((f as File & { webkitRelativePath?: string }).webkitRelativePath) {
|
||||
error.value = t('drop.folderHint')
|
||||
emit('update:modelValue', null)
|
||||
return
|
||||
}
|
||||
if (props.maxSize && f.size > props.maxSize) {
|
||||
error.value = t('drop.tooLarge', { size: formatBytes(f.size), limit: formatBytes(props.maxSize) })
|
||||
emit('update:modelValue', null)
|
||||
@@ -48,9 +54,25 @@ function accept(f: File | null | undefined): void {
|
||||
emit('update:modelValue', f)
|
||||
}
|
||||
|
||||
/** 26.9:拖拽条目里是否夹带目录(dataTransfer.files 对目录为空,须用 entries 探测) */
|
||||
function hasDirectory(items: DataTransferItemList | null | undefined): boolean {
|
||||
if (!items) return false
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
const entry = items[i]?.webkitGetAsEntry?.()
|
||||
if (entry?.isDirectory) return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
function onDrop(e: DragEvent): void {
|
||||
dragover.value = false
|
||||
if (props.disabled) return
|
||||
// 26.9:拖入目录 → 明确提示"建议压缩后上传"(替代此前的静默忽略)
|
||||
if (hasDirectory(e.dataTransfer?.items)) {
|
||||
error.value = t('drop.folderHint')
|
||||
emit('update:modelValue', null)
|
||||
return
|
||||
}
|
||||
accept(e.dataTransfer?.files?.[0])
|
||||
}
|
||||
|
||||
|
||||
@@ -47,6 +47,9 @@ export default {
|
||||
close: 'Close',
|
||||
actions: 'Actions',
|
||||
all: 'All',
|
||||
enabled: 'Enabled',
|
||||
disabled: 'Disabled',
|
||||
days: 'days',
|
||||
query: 'Query',
|
||||
reset: 'Reset',
|
||||
previousPage: 'Previous',
|
||||
@@ -144,6 +147,8 @@ export default {
|
||||
loadingText: 'Fetching content…',
|
||||
copyContent: 'Copy content',
|
||||
downloadTxt: 'Download as .txt',
|
||||
previewImage: 'Image preview',
|
||||
previewAudio: 'Audio playback',
|
||||
downloaded: 'Download complete',
|
||||
downloadFailed: 'Download failed, please retry',
|
||||
copied: 'Content copied',
|
||||
@@ -170,6 +175,7 @@ export default {
|
||||
remove: 'Remove',
|
||||
tooLarge: 'File size {size} exceeds the limit {limit}',
|
||||
typeHint: 'Allowed types: {types}',
|
||||
folderHint: 'Folder upload is not supported. Please compress it into an archive first.',
|
||||
},
|
||||
docs: {
|
||||
searchPlaceholder: 'Search documentation…',
|
||||
@@ -362,6 +368,24 @@ export default {
|
||||
uploadMinuteHint: 'Min 1, max {max}',
|
||||
uploadRate: 'Upload bandwidth (optional)',
|
||||
uploadRateHint: '0 = unlimited; MB/s; range 0~1024. Live-effective (admin reads latest KV each request)',
|
||||
sectionRecycle: 'Recycle & Download Safety',
|
||||
recycleHint: 'Expired-share recycling, retention limit, content dedup, direct download and hotlink protection',
|
||||
recycleEnabled: 'Auto-recycle expired',
|
||||
recycleEnabledHint: 'Periodically delete expired/exhausted shares together with their stored objects',
|
||||
recycleInterval: 'Recycle scan interval',
|
||||
recycleIntervalHint: '1~1440 minutes; accessing an expired share also triggers immediate recycling',
|
||||
retentionDays: 'Max retention (days)',
|
||||
retentionDaysHint: 'Shares older than this many days are recycled automatically; 0 = unlimited',
|
||||
dedupEnabled: 'SHA512 dedup',
|
||||
dedupEnabledHint: 'Identical files are stored once (multiple shares reference the same object)',
|
||||
directDownload: 'Direct download',
|
||||
directDownloadHint: '302 redirect to a presigned URL on S3-like engines; bytes bypass this server',
|
||||
directLinkExpire: 'Direct link TTL',
|
||||
directLinkExpireHint: '1~60 minutes (never longer than the share remains valid)',
|
||||
hotlinkEnabled: 'Hotlink protection',
|
||||
hotlinkEnabledHint: 'Validates Referer against a whitelist; empty Referer (direct visit) is allowed',
|
||||
hotlinkWhitelist: 'Hotlink whitelist',
|
||||
hotlinkWhitelistHint: 'Comma-separated domains, supports *.example.com; empty = same-site only',
|
||||
downloadRate: 'Download bandwidth (optional)',
|
||||
downloadRateHint: '0 = unlimited; MB/s; range 0~1024. S3 presigned direct upload cannot be throttled server-side',
|
||||
// —— v3: friendly units + storage engine ——
|
||||
|
||||
@@ -47,6 +47,9 @@ export default {
|
||||
close: '关闭',
|
||||
actions: '操作',
|
||||
all: '全部',
|
||||
enabled: '开启',
|
||||
disabled: '关闭',
|
||||
days: '天',
|
||||
query: '查询',
|
||||
reset: '重置',
|
||||
previousPage: '上一页',
|
||||
@@ -140,6 +143,8 @@ export default {
|
||||
loadingText: '正在获取内容…',
|
||||
copyContent: '复制内容',
|
||||
downloadTxt: '下载为 .txt',
|
||||
previewImage: '图片预览',
|
||||
previewAudio: '音频播放',
|
||||
downloaded: '下载完成',
|
||||
downloadFailed: '下载失败,请重试',
|
||||
copied: '内容已复制',
|
||||
@@ -166,6 +171,7 @@ export default {
|
||||
remove: '移除',
|
||||
tooLarge: '文件大小 {size} 超过限制 {limit}',
|
||||
typeHint: '仅支持 {types}',
|
||||
folderHint: '暂不支持文件夹上传,建议压缩后上传',
|
||||
},
|
||||
docs: {
|
||||
searchPlaceholder: '检索文档内容…',
|
||||
@@ -356,6 +362,24 @@ export default {
|
||||
uploadMinuteHint: '最小 1,最大 {max}',
|
||||
uploadRate: '上行带宽(可选)',
|
||||
uploadRateHint: '0 = 不限速;单位 MB/s;范围 0~1024。修改后立即生效(管理端读最新 KV)',
|
||||
sectionRecycle: '回收与下载安全',
|
||||
recycleHint: '过期分享自动回收、存储时长上限、内容去重、直链下载与防盗链',
|
||||
recycleEnabled: '过期自动回收',
|
||||
recycleEnabledHint: '开启后定时清理已过期/次数耗尽的分享(含其存储对象)',
|
||||
recycleInterval: '回收扫描间隔',
|
||||
recycleIntervalHint: '范围 1~1440 分钟;取件时发现过期也会立即触发回收',
|
||||
retentionDays: '最长存储时长',
|
||||
retentionDaysHint: '上传超过该天数的分享将被自动回收;0 = 不限制',
|
||||
dedupEnabled: 'SHA512 内容去重',
|
||||
dedupEnabledHint: '相同内容的文件仅存储一份(多分享引用同一对象)',
|
||||
directDownload: '直链下载',
|
||||
directDownloadHint: 'S3 等对象存储引擎时 302 跳转到限时直链,文件不经过本站带宽',
|
||||
directLinkExpire: '直链有效期',
|
||||
directLinkExpireHint: '范围 1~60 分钟(不超过分享剩余时效)',
|
||||
hotlinkEnabled: '下载防盗链',
|
||||
hotlinkEnabledHint: '校验 Referer 白名单;空 Referer(直接访问)放行',
|
||||
hotlinkWhitelist: '防盗链白名单',
|
||||
hotlinkWhitelistHint: '逗号分隔域名,支持 *.example.com;空 = 仅本站域名放行',
|
||||
downloadRate: '下行带宽(可选)',
|
||||
downloadRateHint: '0 = 不限速;单位 MB/s;范围 0~1024。S3 预签名直传(客户端→S3)无法限速',
|
||||
// —— v3:单位友好化 + 存储引擎 ——
|
||||
|
||||
@@ -107,6 +107,35 @@ const remaining = computed(() => {
|
||||
if (m.remainingDownloads === null || m.remainingDownloads === undefined) return t('pickup.remainingUnlimited')
|
||||
return m.remainingDownloads < 0 ? t('pickup.remainingUnlimited') : t('pickup.remainingCount', { n: m.remainingDownloads })
|
||||
})
|
||||
|
||||
// ===== 26.9:媒体内联预览(图片/音频)=====
|
||||
const IMAGE_EXT = ['png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp', 'svg', 'avif']
|
||||
const AUDIO_EXT = ['mp3', 'wav', 'ogg', 'oga', 'm4a', 'flac', 'aac', 'opus']
|
||||
const IMAGE_PREVIEW_MAX = 30 * 1024 * 1024
|
||||
const AUDIO_PREVIEW_MAX = 100 * 1024 * 1024
|
||||
|
||||
const mediaKind = computed<'image' | 'audio' | null>(() => {
|
||||
const m = meta.value
|
||||
if (!m || m.isText) return null
|
||||
const name = (m.name || '').toLowerCase()
|
||||
const dot = name.lastIndexOf('.')
|
||||
if (dot < 0) return null
|
||||
const ext = name.slice(dot + 1)
|
||||
if (IMAGE_EXT.includes(ext) && m.size <= IMAGE_PREVIEW_MAX) return 'image'
|
||||
if (AUDIO_EXT.includes(ext) && m.size <= AUDIO_PREVIEW_MAX) return 'audio'
|
||||
return null
|
||||
})
|
||||
|
||||
/** 媒体源:select 返回的 download_url(代理路径或 S3 直链);缺失则不预览 */
|
||||
const mediaSrc = computed(() => {
|
||||
if (!mediaKind.value) return ''
|
||||
const url = meta.value?.downloadUrl
|
||||
if (!url) return ''
|
||||
return url.startsWith('/') ? new URL(url, location.origin).toString() : url
|
||||
})
|
||||
|
||||
const previewFailed = ref(false)
|
||||
watch([mediaSrc, () => meta.value?.code], () => (previewFailed.value = false))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -161,6 +190,27 @@ const remaining = computed(() => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 26.9:图片/音频内联预览(加载失败自动隐藏回退下载) -->
|
||||
<div v-if="mediaSrc && !previewFailed" class="media-preview">
|
||||
<p class="hint" style="margin: 0 0 8px">
|
||||
{{ mediaKind === 'image' ? t('pickup.previewImage') : t('pickup.previewAudio') }}
|
||||
</p>
|
||||
<img
|
||||
v-if="mediaKind === 'image'"
|
||||
:src="mediaSrc"
|
||||
:alt="meta.name"
|
||||
style="max-width: 100%; max-height: 360px; border-radius: 10px; display: block; margin: 0 auto"
|
||||
@error="previewFailed = true"
|
||||
/>
|
||||
<audio
|
||||
v-else
|
||||
controls
|
||||
:src="mediaSrc"
|
||||
style="width: 100%"
|
||||
@error="previewFailed = true"
|
||||
></audio>
|
||||
</div>
|
||||
|
||||
<div v-if="downloadPercent !== null" style="margin: 16px 0 6px">
|
||||
<div class="progress"><i :style="{ width: `${downloadPercent}%` }"></i></div>
|
||||
<p class="hint">{{ t('pickup.downloading', { percent: downloadPercent }) }}</p>
|
||||
|
||||
@@ -43,6 +43,9 @@ const BACKGROUND_URL_MAX = 2048
|
||||
const UPLOAD_COUNT_MAX = 10000
|
||||
const UPLOAD_MINUTE_MAX = 1440
|
||||
const RATE_MAX_MB = 1024 // 1 GiB/s 上限(与后端 KVSchema 1<<30 字节对齐)
|
||||
// 26.9:回收与下载安全边界(与后端 KVSchema 一致)
|
||||
const RETENTION_DAYS_MAX = 3650
|
||||
const HOTLINK_WHITELIST_MAX = 2048
|
||||
|
||||
const form = reactive({
|
||||
site_name: '',
|
||||
@@ -65,6 +68,15 @@ const form = reactive({
|
||||
// 26.9:上下行带宽(前端友好单位 = MB/s;提交时换算为字节/秒)
|
||||
uploadRate: 0,
|
||||
downloadRate: 0,
|
||||
// 26.9 回收与下载安全(interval/expire 前端友好单位 = 分钟;提交换算秒)
|
||||
recycle_enabled: true,
|
||||
recycle_interval_min: 30,
|
||||
retention_days: 0,
|
||||
dedup_enabled: true,
|
||||
direct_download: true,
|
||||
direct_link_expire_min: 15,
|
||||
hotlink_enabled: false,
|
||||
hotlink_whitelist: '',
|
||||
})
|
||||
|
||||
// —— v3:友好单位状态(canonical 单位仍为秒/字节,换算在前端)——
|
||||
@@ -169,6 +181,15 @@ async function load(): Promise<void> {
|
||||
// 26.9:带宽(KV 字节/秒 → UI MB/s,0 保留为不限)
|
||||
form.uploadRate = Math.round(toNum(pick<unknown>(cfg, ['upload_rate', 'uploadRate']), 0) / MB_BYTES)
|
||||
form.downloadRate = Math.round(toNum(pick<unknown>(cfg, ['download_rate', 'downloadRate']), 0) / MB_BYTES)
|
||||
// 26.9:回收与下载安全(秒 → 分钟)
|
||||
form.recycle_enabled = toNum(pick<unknown>(cfg, ['recycle_enabled']), 1) === 1
|
||||
form.recycle_interval_min = Math.max(1, Math.round(toNum(pick<unknown>(cfg, ['recycle_interval']), 1800) / 60))
|
||||
form.retention_days = toNum(pick<unknown>(cfg, ['retention_days']), 0)
|
||||
form.dedup_enabled = toNum(pick<unknown>(cfg, ['dedup_enabled']), 1) === 1
|
||||
form.direct_download = toNum(pick<unknown>(cfg, ['direct_download']), 1) === 1
|
||||
form.direct_link_expire_min = Math.max(1, Math.round(toNum(pick<unknown>(cfg, ['direct_link_expire']), 900) / 60))
|
||||
form.hotlink_enabled = toNum(pick<unknown>(cfg, ['hotlink_enabled']), 0) === 1
|
||||
form.hotlink_whitelist = String(pick<string>(cfg, ['hotlink_whitelist']) ?? '')
|
||||
// v3:当前引擎(get 的 _engine_hint.storage_backend 或公开 config 的 storage_engine)
|
||||
const hint = pick<Record<string, unknown>>(cfg, ['_engine_hint'])
|
||||
const backend = hint ? String(pick<string>(hint, ['storage_backend']) ?? '') : ''
|
||||
@@ -239,6 +260,15 @@ async function save(): Promise<void> {
|
||||
// 26.9:带宽(MB/s → 字节/秒;负数与 NaN 归 0)
|
||||
upload_rate: clamp(Number(form.uploadRate) || 0, 0, RATE_MAX_MB) * MB_BYTES,
|
||||
download_rate: clamp(Number(form.downloadRate) || 0, 0, RATE_MAX_MB) * MB_BYTES,
|
||||
// 26.9:回收与下载安全(分钟 → 秒;开关 0/1)
|
||||
recycle_enabled: form.recycle_enabled ? 1 : 0,
|
||||
recycle_interval: clamp((Number(form.recycle_interval_min) || 30) * 60, 60, 86400),
|
||||
retention_days: clamp(Number(form.retention_days) || 0, 0, RETENTION_DAYS_MAX),
|
||||
dedup_enabled: form.dedup_enabled ? 1 : 0,
|
||||
direct_download: form.direct_download ? 1 : 0,
|
||||
direct_link_expire: clamp((Number(form.direct_link_expire_min) || 15) * 60, 60, 3600),
|
||||
hotlink_enabled: form.hotlink_enabled ? 1 : 0,
|
||||
hotlink_whitelist: form.hotlink_whitelist.trim().slice(0, HOTLINK_WHITELIST_MAX),
|
||||
})
|
||||
toast.success(t('admin.settings.saved'))
|
||||
await config.load() // 立即刷新导航 Logo / favicon / 站点名称 / 背景 / 页脚 / 通知
|
||||
@@ -268,6 +298,14 @@ function restoreDefaults(): void {
|
||||
form.uploadMinute = 1
|
||||
form.uploadRate = 0
|
||||
form.downloadRate = 0
|
||||
form.recycle_enabled = true
|
||||
form.recycle_interval_min = 30
|
||||
form.retention_days = 0
|
||||
form.dedup_enabled = true
|
||||
form.direct_download = true
|
||||
form.direct_link_expire_min = 15
|
||||
form.hotlink_enabled = false
|
||||
form.hotlink_whitelist = ''
|
||||
// v3:单位状态复位
|
||||
saveTimeValue.value = 0
|
||||
saveTimeUnit.value = 'day'
|
||||
@@ -785,6 +823,111 @@ onMounted(load)
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<h3 class="card-title">{{ t('admin.settings.sectionRecycle') }}</h3>
|
||||
<p class="card-sub">{{ t('admin.settings.recycleHint') }}</p>
|
||||
<div class="field-row">
|
||||
<div class="field">
|
||||
<label for="set-recycle-enabled">{{ t('admin.settings.recycleEnabled') }}</label>
|
||||
<select id="set-recycle-enabled" v-model="form.recycle_enabled" class="input">
|
||||
<option :value="true">{{ t('common.enabled') }}</option>
|
||||
<option :value="false">{{ t('common.disabled') }}</option>
|
||||
</select>
|
||||
<p class="hint">{{ t('admin.settings.recycleEnabledHint') }}</p>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="set-recycle-interval">{{ t('admin.settings.recycleInterval') }}</label>
|
||||
<div class="unit-row">
|
||||
<input
|
||||
id="set-recycle-interval"
|
||||
v-model.number="form.recycle_interval_min"
|
||||
class="input"
|
||||
type="number"
|
||||
:min="1"
|
||||
:max="1440"
|
||||
step="1"
|
||||
/>
|
||||
<span class="unit-suffix">min</span>
|
||||
</div>
|
||||
<p class="hint">{{ t('admin.settings.recycleIntervalHint') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field-row" style="margin-top: 14px">
|
||||
<div class="field">
|
||||
<label for="set-retention-days">{{ t('admin.settings.retentionDays') }}</label>
|
||||
<div class="unit-row">
|
||||
<input
|
||||
id="set-retention-days"
|
||||
v-model.number="form.retention_days"
|
||||
class="input"
|
||||
type="number"
|
||||
:min="0"
|
||||
:max="3650"
|
||||
step="1"
|
||||
/>
|
||||
<span class="unit-suffix">{{ t('common.days') }}</span>
|
||||
</div>
|
||||
<p class="hint">{{ t('admin.settings.retentionDaysHint') }}</p>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="set-dedup-enabled">{{ t('admin.settings.dedupEnabled') }}</label>
|
||||
<select id="set-dedup-enabled" v-model="form.dedup_enabled" class="input">
|
||||
<option :value="true">{{ t('common.enabled') }}</option>
|
||||
<option :value="false">{{ t('common.disabled') }}</option>
|
||||
</select>
|
||||
<p class="hint">{{ t('admin.settings.dedupEnabledHint') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field-row" style="margin-top: 14px">
|
||||
<div class="field">
|
||||
<label for="set-direct-download">{{ t('admin.settings.directDownload') }}</label>
|
||||
<select id="set-direct-download" v-model="form.direct_download" class="input">
|
||||
<option :value="true">{{ t('common.enabled') }}</option>
|
||||
<option :value="false">{{ t('common.disabled') }}</option>
|
||||
</select>
|
||||
<p class="hint">{{ t('admin.settings.directDownloadHint') }}</p>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="set-direct-expire">{{ t('admin.settings.directLinkExpire') }}</label>
|
||||
<div class="unit-row">
|
||||
<input
|
||||
id="set-direct-expire"
|
||||
v-model.number="form.direct_link_expire_min"
|
||||
class="input"
|
||||
type="number"
|
||||
:min="1"
|
||||
:max="60"
|
||||
step="1"
|
||||
/>
|
||||
<span class="unit-suffix">min</span>
|
||||
</div>
|
||||
<p class="hint">{{ t('admin.settings.directLinkExpireHint') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field-row" style="margin-top: 14px">
|
||||
<div class="field">
|
||||
<label for="set-hotlink-enabled">{{ t('admin.settings.hotlinkEnabled') }}</label>
|
||||
<select id="set-hotlink-enabled" v-model="form.hotlink_enabled" class="input">
|
||||
<option :value="true">{{ t('common.enabled') }}</option>
|
||||
<option :value="false">{{ t('common.disabled') }}</option>
|
||||
</select>
|
||||
<p class="hint">{{ t('admin.settings.hotlinkEnabledHint') }}</p>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="set-hotlink-whitelist">{{ t('admin.settings.hotlinkWhitelist') }}</label>
|
||||
<textarea
|
||||
id="set-hotlink-whitelist"
|
||||
v-model="form.hotlink_whitelist"
|
||||
class="input"
|
||||
rows="2"
|
||||
maxlength="2048"
|
||||
placeholder="a.com, b.org"
|
||||
></textarea>
|
||||
<p class="hint">{{ t('admin.settings.hotlinkWhitelistHint') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="save-row">
|
||||
<button class="btn" type="submit" :disabled="saving">
|
||||
<span v-if="saving" class="spin" aria-hidden="true"></span>
|
||||
|
||||
Reference in New Issue
Block a user