Files
FileShare/web/src/views/admin/SettingsView.vue
T
SKYMirror 7a9015aad0
CI 测试 / go vet + go test (push) Failing after 5s
26.9:直链下载 + 过期回收 + SHA512 去重 + 防盗链 + 媒体预览 + 文件夹上传提示
- 对象存储直链: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
2026-09-08 04:54:05 +08:00

975 lines
43 KiB
Vue
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.
<script setup lang="ts">
/**
* 系统设置(需求⑨):
* - v1:站点名称、Logo/favicon 自定义(上传或填写 URL)、管理员密码
* - v2 新增:背景图 URL、页脚文案/备案号、通知开关/标题/内容、
* 保存策略(max_save_seconds/max_save_count)、存储策略(max_file_size/allowed_file_types)、
* 上传频率限制(uploadCount/uploadMinute
* - v3 新增:策略单位友好化(保存时间 小时/天、文件大小 MB/GB,后端 canonical 单位不变);
* 存储引擎卡(local/webdav/s3 三选 + 引擎参数表单 + 热切换 POST /admin/storage/switch
* - 表单 min/max 对齐 server/internal/config/schema.go KVSchema 边界;保存调 PATCH /admin/config/update
* - 策略校验最终由服务端强制(400 中文错误),前端仅做边界约束与提示
*/
import { computed, onMounted, reactive, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { NSwitch } from 'naive-ui'
import { useRouter } from 'vue-router'
import { adminConfigGet, adminConfigUpdate, adminPasswordUpdate, adminStorageSwitch } from '@/api/admin'
import { ApiError } from '@/api/http'
import { useConfigStore } from '@/stores/config'
import { useAuthStore } from '@/stores/auth'
import { useToastStore } from '@/stores/toast'
import { pick } from '@/utils/format'
import { formatBytes } from '@/utils/format'
const { t } = useI18n()
const toast = useToastStore()
const config = useConfigStore()
const auth = useAuthStore()
const router = useRouter()
const loading = ref(false)
const saving = ref(false)
// —— KVSchema 边界(与 server/internal/config/schema.go 对齐)——
const MAX_SAVE_SECONDS_MAX = 365 * 24 * 60 * 60 // 31536000
const MAX_SAVE_COUNT_MAX = 100000
const MAX_FILE_SIZE_MAX = 10 * 1024 * 1024 * 1024 // 10 GiB
const NOTIFY_TITLE_MAX = 128
const NOTIFY_CONTENT_MAX = 2000
const FOOTER_TEXT_MAX = 2000
const FOOTER_BEIAN_MAX = 128
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: '',
site_domain: '',
logo_url: '',
favicon_url: '',
// v2
background_url: '',
footer_text: '',
footer_beian: '',
notify_enabled: true,
notify_title: '',
notify_content: '',
max_save_seconds: 0,
max_save_count: 0,
max_file_size: 0,
allowed_file_types: '',
uploadCount: 0,
uploadMinute: 0,
// 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 单位仍为秒/字节,换算在前端)——
// 保存时间:值 + 单位(hour|day);文件大小:值 + 单位(MB|GB,1024 进制)
const saveTimeValue = ref(0)
const saveTimeUnit = ref<'hour' | 'day'>('day')
const fileSizeValue = ref(0)
const fileSizeUnit = ref<'MB' | 'GB'>('MB')
const HOUR_SECONDS = 3600
const DAY_SECONDS = 86400
const MB_BYTES = 1024 * 1024
const GB_BYTES = 1024 * 1024 * 1024
/** 秒 → {value, unit}:≥1 天显示天,否则小时(0 归 0) */
function secondsToFriendly(sec: number): { value: number; unit: 'hour' | 'day' } {
if (sec <= 0) return { value: 0, unit: 'day' }
if (sec % DAY_SECONDS === 0 || sec >= DAY_SECONDS) {
const days = sec / DAY_SECONDS
if (Number.isInteger(days)) return { value: days, unit: 'day' }
}
return { value: Math.round((sec / HOUR_SECONDS) * 100) / 100, unit: 'hour' }
}
/** 字节 → {value, unit}:≥1 GiB 显示 GB,否则 MB0 归 0 */
function bytesToFriendly(b: number): { value: number; unit: 'MB' | 'GB' } {
if (b <= 0) return { value: 0, unit: 'MB' }
if (b % GB_BYTES === 0 || b >= GB_BYTES) {
const gb = b / GB_BYTES
if (Number.isInteger(gb)) return { value: gb, unit: 'GB' }
}
return { value: Math.round((b / MB_BYTES) * 100) / 100, unit: 'MB' }
}
/** 友好值 → 秒(clamp 到 KVSchema 上限;负数归 0=不限制) */
function friendlyToSeconds(value: number, unit: 'hour' | 'day'): number {
const sec = unit === 'day' ? value * DAY_SECONDS : value * HOUR_SECONDS
return clamp(sec, 0, MAX_SAVE_SECONDS_MAX)
}
/** 友好值 → 字节(clamp 到 KVSchema 上限;负数归 0=回落 uploadSize */
function friendlyToBytes(value: number, unit: 'MB' | 'GB'): number {
const b = unit === 'GB' ? value * GB_BYTES : value * MB_BYTES
return clamp(b, 0, MAX_FILE_SIZE_MAX)
}
const saveTimeUnlimited = computed(() => saveTimeValue.value <= 0)
const fileSizeUnlimited = computed(() => fileSizeValue.value <= 0)
const LOGO_MAX_BYTES = 256 * 1024
function toBool(v: unknown, fallback: boolean): boolean {
if (v === undefined || v === null) return fallback
if (typeof v === 'boolean') return v
if (typeof v === 'number') return v !== 0
return String(v) !== '0' && String(v) !== 'false' && String(v) !== ''
}
function toNum(v: unknown, fallback: number): number {
const n = Number(v)
return Number.isFinite(n) ? n : fallback
}
function toList(v: unknown): string[] {
if (Array.isArray(v)) return v.map((x) => String(x).trim()).filter(Boolean)
if (typeof v === 'string') return v.split(',').map((x) => x.trim()).filter(Boolean)
return []
}
function clamp(v: number, min: number, max: number): number {
return Math.min(Math.max(Math.round(v) || 0, min), max)
}
async function load(): Promise<void> {
loading.value = true
try {
const cfg = await adminConfigGet()
form.site_name = String(pick<string>(cfg, ['site_name', 'name']) ?? '')
form.site_domain = String(pick<string>(cfg, ['site_domain', 'siteDomain']) ?? '')
form.logo_url = String(pick<string>(cfg, ['logo_url']) ?? '')
form.favicon_url = String(pick<string>(cfg, ['favicon_url']) ?? '')
form.background_url = String(pick<string>(cfg, ['background_url', 'backgroundUrl']) ?? '')
form.footer_text = String(pick<string>(cfg, ['footer_text', 'footerText']) ?? '')
form.footer_beian = String(pick<string>(cfg, ['footer_beian', 'footerBeian']) ?? '')
form.notify_enabled = toBool(pick<unknown>(cfg, ['notify_enabled', 'notifyEnabled']), true)
form.notify_title = String(pick<string>(cfg, ['notify_title', 'notifyTitle']) ?? '')
form.notify_content = String(pick<string>(cfg, ['notify_content', 'notifyContent']) ?? '')
form.max_save_seconds = toNum(pick<unknown>(cfg, ['max_save_seconds', 'maxSaveSeconds']), 0)
form.max_save_count = toNum(pick<unknown>(cfg, ['max_save_count', 'maxSaveCount']), 0)
form.max_file_size = toNum(pick<unknown>(cfg, ['max_file_size', 'maxFileSize']), 0)
// v3:秒/字节 → 友好单位
const st = secondsToFriendly(form.max_save_seconds)
saveTimeValue.value = st.value
saveTimeUnit.value = st.unit
const fs = bytesToFriendly(form.max_file_size)
fileSizeValue.value = fs.value
fileSizeUnit.value = fs.unit
const types = toList(pick<unknown>(cfg, ['allowed_file_types', 'allowedFileTypes']))
form.allowed_file_types = types.join(',')
form.uploadCount = toNum(pick<unknown>(cfg, ['uploadCount', 'upload_count']), 0)
form.uploadMinute = toNum(pick<unknown>(cfg, ['uploadMinute', 'upload_minute']), 0)
// 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']) ?? '') : ''
engineCurrent.value = (backend || String(pick<string>(cfg, ['storage_engine']) ?? 'local')) as EngineName
engineSelected.value = engineCurrent.value
loadEngineParams(cfg)
} catch (e) {
toast.error(e instanceof ApiError ? e.msg : t('admin.settings.loadFailed'))
} finally {
loading.value = false
}
}
/** 本地图片 → dataURL(无需专用上传端点,直接作为 URL 值存入 KV,全站生效) */
function pickImage(target: 'logo' | 'favicon'): void {
const input = document.createElement('input')
input.type = 'file'
input.accept = 'image/*'
input.onchange = () => {
const file = input.files?.[0]
if (!file) return
if (file.size > LOGO_MAX_BYTES) {
toast.error(t('admin.settings.imageTooLarge'))
return
}
const reader = new FileReader()
reader.onload = () => {
const dataUrl = String(reader.result ?? '')
if (target === 'logo') form.logo_url = dataUrl
else form.favicon_url = dataUrl
toast.success(t('admin.settings.imageLoaded'))
}
reader.onerror = () => toast.error(t('admin.settings.imageReadFailed'))
reader.readAsDataURL(file)
}
input.click()
}
async function save(): Promise<void> {
saving.value = true
try {
// 需求⑨:数值 clamp 到 KVSchema 边界;allowed_file_types 空列表按「*」提交(服务端要求非空列表)
const types = form.allowed_file_types
.split(',')
.map((x) => x.trim())
.filter(Boolean)
await adminConfigUpdate({
site_name: form.site_name.trim(),
site_domain: form.site_domain.trim(),
logo_url: form.logo_url.trim(),
favicon_url: form.favicon_url.trim(),
// 需求⑤⑥⑦⑨:背景/页脚/通知
background_url: form.background_url.trim(),
footer_text: form.footer_text.trim(),
footer_beian: form.footer_beian.trim(),
notify_enabled: form.notify_enabled ? 1 : 0,
notify_title: form.notify_title.trim(),
notify_content: form.notify_content.trim(),
// 保存策略(v3:友好单位换算回秒)
max_save_seconds: friendlyToSeconds(saveTimeValue.value, saveTimeUnit.value),
max_save_count: clamp(form.max_save_count, 0, MAX_SAVE_COUNT_MAX),
// 存储策略(v3:友好单位换算回字节)
max_file_size: friendlyToBytes(fileSizeValue.value, fileSizeUnit.value),
allowed_file_types: types.length ? types : ['*'],
// 上传频率限制(键名对齐参考既有 camelCase 键)
uploadCount: clamp(form.uploadCount || 0, 1, UPLOAD_COUNT_MAX),
uploadMinute: clamp(form.uploadMinute || 0, 1, UPLOAD_MINUTE_MAX),
// 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 / 站点名称 / 背景 / 页脚 / 通知
} catch (e) {
toast.error(e instanceof ApiError ? e.msg : t('admin.settings.saveFailed'))
} finally {
saving.value = false
}
}
function restoreDefaults(): void {
form.site_name = '文件快传'
form.site_domain = ''
form.logo_url = ''
form.favicon_url = ''
form.background_url = ''
form.footer_text = ''
form.footer_beian = ''
form.notify_enabled = true
form.notify_title = ''
form.notify_content = ''
form.max_save_seconds = 0
form.max_save_count = 0
form.max_file_size = 0
form.allowed_file_types = '*'
form.uploadCount = 10
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'
fileSizeValue.value = 0
fileSizeUnit.value = 'MB'
toast.info(t('admin.settings.restoreDefaultsDone'))
}
// ---- v3:存储引擎 ----
type EngineName = 'local' | 's3' | 'webdav'
const SENSITIVE_MASK = '******'
const engineCurrent = ref<EngineName>('local')
const engineSelected = ref<EngineName>('local')
const engineSwitching = ref(false)
const engineParamsSaving = ref(false)
const engineParams = reactive({
// local
local_storage_path: '',
// webdav
webdav_url: '',
webdav_root_path: 'filebox_storage',
webdav_username: '',
webdav_password: '',
// s3
s3_endpoint_url: '',
s3_bucket_name: '',
s3_region_name: 'auto',
s3_access_key_id: '',
s3_secret_access_key: '',
aws_session_token: '',
s3_addressing_style: 'auto',
})
function loadEngineParams(cfg: Record<string, unknown>): void {
engineParams.local_storage_path = String(pick<string>(cfg, ['local_storage_path', 'localStoragePath']) ?? '')
engineParams.webdav_url = String(pick<string>(cfg, ['webdav_url', 'webdavUrl']) ?? '')
engineParams.webdav_root_path = String(pick<string>(cfg, ['webdav_root_path', 'webdavRootPath']) ?? 'filebox_storage')
engineParams.webdav_username = String(pick<string>(cfg, ['webdav_username', 'webdavUsername']) ?? '')
engineParams.webdav_password = String(pick<string>(cfg, ['webdav_password', 'webdavPassword']) ?? '')
engineParams.s3_endpoint_url = String(pick<string>(cfg, ['s3_endpoint_url', 's3EndpointUrl']) ?? '')
engineParams.s3_bucket_name = String(pick<string>(cfg, ['s3_bucket_name', 's3BucketName']) ?? '')
engineParams.s3_region_name = String(pick<string>(cfg, ['s3_region_name', 's3RegionName']) ?? 'auto')
engineParams.s3_access_key_id = String(pick<string>(cfg, ['s3_access_key_id', 's3AccessKeyId']) ?? '')
engineParams.s3_secret_access_key = String(pick<string>(cfg, ['s3_secret_access_key', 's3SecretAccessKey']) ?? '')
engineParams.aws_session_token = String(pick<string>(cfg, ['aws_session_token', 'awsSessionToken']) ?? '')
engineParams.s3_addressing_style = String(pick<string>(cfg, ['s3_addressing_style', 's3AddressingStyle']) ?? 'auto')
}
/** 敏感键提交规则:空或掩码 → 不提交该键(服务端跳过不覆盖) */
function secretOrSkip(v: string): string | undefined {
const s = v.trim()
if (s === '' || s === SENSITIVE_MASK) return undefined
return s
}
async function switchEngine(): Promise<void> {
engineSwitching.value = true
try {
const engine = await adminStorageSwitch(engineSelected.value)
engineCurrent.value = (String(engine) as EngineName) || engineSelected.value
toast.success(t('admin.settings.engineSwitchOk', { engine }))
await config.load() // 公开 config 的 storage_engine 同步刷新
} catch (e) {
toast.error(e instanceof ApiError ? e.msg : t('admin.settings.engineSwitchFail'))
} finally {
engineSwitching.value = false
}
}
/** 保存当前选中引擎的参数(敏感键空/掩码不提交) */
async function saveEngineParams(): Promise<void> {
engineParamsSaving.value = true
try {
const patch: Record<string, unknown> = {}
if (engineSelected.value === 'local') {
patch.local_storage_path = engineParams.local_storage_path.trim()
} else if (engineSelected.value === 'webdav') {
patch.webdav_url = engineParams.webdav_url.trim()
patch.webdav_root_path = engineParams.webdav_root_path.trim()
patch.webdav_username = engineParams.webdav_username.trim()
const pass = secretOrSkip(engineParams.webdav_password)
if (pass !== undefined) patch.webdav_password = pass
} else {
patch.s3_endpoint_url = engineParams.s3_endpoint_url.trim()
patch.s3_bucket_name = engineParams.s3_bucket_name.trim()
patch.s3_region_name = engineParams.s3_region_name.trim() || 'auto'
patch.s3_access_key_id = engineParams.s3_access_key_id.trim()
const sk = secretOrSkip(engineParams.s3_secret_access_key)
if (sk !== undefined) patch.s3_secret_access_key = sk
const tok = secretOrSkip(engineParams.aws_session_token)
if (tok !== undefined) patch.aws_session_token = tok
patch.s3_addressing_style = engineParams.s3_addressing_style
}
await adminConfigUpdate(patch)
toast.success(t('admin.settings.engineParamsSaved'))
} catch (e) {
toast.error(e instanceof ApiError ? e.msg : t('admin.settings.saveFailed'))
} finally {
engineParamsSaving.value = false
}
}
// ---- 修改管理员密码 ----
const pwdForm = reactive({ old: '', next: '', confirm: '' })
const pwdSaving = ref(false)
async function savePassword(): Promise<void> {
if (!pwdForm.old || !pwdForm.next) {
toast.error(t('admin.settings.pwdRequired'))
return
}
if (pwdForm.next.length < 6) {
toast.error(t('admin.settings.pwdTooShort'))
return
}
if (pwdForm.next !== pwdForm.confirm) {
toast.error(t('admin.settings.pwdMismatch'))
return
}
pwdSaving.value = true
try {
await adminPasswordUpdate(pwdForm.old, pwdForm.next)
toast.success(t('admin.settings.pwdChanged'))
pwdForm.old = pwdForm.next = pwdForm.confirm = ''
await auth.logout() // 服务端轮换 jwt_secret,旧 token 已失效
void router.replace({ name: 'admin-login' })
} catch (e) {
toast.error(e instanceof ApiError ? (e.code === 401 ? t('admin.settings.pwdWrong') : e.msg) : t('admin.settings.pwdChangeFailed'))
} finally {
pwdSaving.value = false
}
}
onMounted(load)
</script>
<template>
<div>
<div class="toolbar">
<div>
<h2 class="page-title">{{ t('admin.settings.title') }}</h2>
<p class="page-sub">{{ t('admin.settings.subtitle') }}</p>
</div>
<button class="btn btn-ghost" type="button" @click="restoreDefaults">{{ t('admin.settings.restoreDefaults') }}</button>
</div>
<div v-if="loading" class="loading-block"><span class="spin" aria-hidden="true"></span> {{ t('admin.settings.loading') }}</div>
<form v-else class="settings-grid" @submit.prevent="save">
<section class="card">
<h3 class="card-title">{{ t('admin.settings.sectionBasic') }}</h3>
<div class="field">
<label for="set-site-name">{{ t('admin.settings.siteName') }}</label>
<input id="set-site-name" v-model="form.site_name" class="input" placeholder="文件快传" maxlength="64" />
<p class="hint">{{ t('admin.settings.siteNameHint') }}</p>
</div>
<div class="field">
<label for="set-site-domain">{{ t('admin.settings.siteDomain') }}</label>
<input id="set-site-domain" v-model="form.site_domain" class="input" placeholder="https://share.example.com" maxlength="256" />
<p class="hint">{{ t('admin.settings.siteDomainHint') }}</p>
</div>
</section>
<section class="card">
<h3 class="card-title">{{ t('admin.settings.sectionLogo') }}</h3>
<div class="field">
<label for="set-logo-url">{{ t('admin.settings.logoUrl') }}</label>
<div class="logo-input-row">
<input id="set-logo-url" v-model="form.logo_url" class="input" placeholder="https://…/logo.svg" />
<button class="btn btn-ghost" type="button" @click="pickImage('logo')">{{ t('admin.settings.uploadImage') }}</button>
</div>
<p class="hint">{{ t('admin.settings.logoHint') }}</p>
</div>
<div class="logo-preview-row">
<div class="logo-box">
<img :src="form.logo_url || config.displayLogoUrl" alt="Logo preview" />
</div>
<div style="font-size: 13px; color: var(--c-text-2)">
{{ t('admin.settings.navPreview') }}
<span style="display: inline-flex; align-items: center; gap: 8px; font-weight: 700; color: var(--c-text)">
<img :src="form.logo_url || config.displayLogoUrl" alt="" style="width: 22px; height: 22px; object-fit: contain" />
{{ form.site_name || config.displayName }}
</span>
</div>
</div>
</section>
<section class="card">
<h3 class="card-title">{{ t('admin.settings.sectionFavicon') }}</h3>
<div class="field">
<label for="set-favicon-url">{{ t('admin.settings.faviconUrl') }}</label>
<div class="logo-input-row">
<input id="set-favicon-url" v-model="form.favicon_url" class="input" placeholder="https://…/favicon.png" />
<button class="btn btn-ghost" type="button" @click="pickImage('favicon')">{{ t('admin.settings.uploadImage') }}</button>
</div>
<p class="hint">{{ t('admin.settings.faviconHint') }}</p>
</div>
<div class="logo-preview-row">
<div class="logo-box" style="width: 44px; height: 44px">
<img :src="form.favicon_url || config.displayFaviconUrl" alt="Favicon preview" />
</div>
<span style="font-size: 13px; color: var(--c-text-2)">{{ t('admin.settings.faviconPreviewHint') }}</span>
</div>
</section>
<!-- 需求⑨背景图 -->
<section class="card">
<h3 class="card-title">{{ t('admin.settings.sectionBackground') }}</h3>
<div class="field">
<label for="set-bg-url">{{ t('admin.settings.backgroundUrl') }}</label>
<input
id="set-bg-url"
v-model="form.background_url"
class="input"
placeholder="https://…/bg.jpg"
:maxlength="BACKGROUND_URL_MAX"
/>
<p class="hint">{{ t('admin.settings.backgroundHint') }}</p>
</div>
<div v-if="form.background_url.trim()" class="logo-box" style="width: 120px">
<img :src="form.background_url" alt="Background preview" style="width: 100%; height: 100%; object-fit: cover" />
</div>
</section>
<!-- 需求⑨页脚 -->
<section class="card">
<h3 class="card-title">{{ t('admin.settings.sectionFooter') }}</h3>
<div class="field">
<label for="set-footer-text">{{ t('admin.settings.footerText') }}</label>
<textarea
id="set-footer-text"
v-model="form.footer_text"
class="textarea"
style="min-height: 72px"
:maxlength="FOOTER_TEXT_MAX"
></textarea>
<p class="hint">{{ t('admin.settings.footerTextHint') }}</p>
</div>
<div class="field">
<label for="set-footer-beian">{{ t('admin.settings.footerBeian') }}</label>
<input id="set-footer-beian" v-model="form.footer_beian" class="input" :maxlength="FOOTER_BEIAN_MAX" />
<p class="hint">{{ t('admin.settings.footerBeianHint') }}</p>
</div>
</section>
<!-- 需求⑨系统通知 -->
<section class="card">
<h3 class="card-title">{{ t('admin.settings.sectionNotify') }}</h3>
<div class="field notify-switch-row">
<label for="set-notify-enabled">{{ t('admin.settings.notifyEnabled') }}</label>
<NSwitch id="set-notify-enabled" v-model:value="form.notify_enabled" size="small" />
</div>
<div class="field">
<label for="set-notify-title">{{ t('admin.settings.notifyTitle') }}</label>
<input id="set-notify-title" v-model="form.notify_title" class="input" :maxlength="NOTIFY_TITLE_MAX" />
<p class="hint">{{ t('admin.settings.notifyTitleHint') }}</p>
</div>
<div class="field">
<label for="set-notify-content">{{ t('admin.settings.notifyContent') }}</label>
<textarea
id="set-notify-content"
v-model="form.notify_content"
class="textarea"
style="min-height: 72px"
:maxlength="NOTIFY_CONTENT_MAX"
></textarea>
<p class="hint">{{ t('admin.settings.notifyContentHint') }}</p>
</div>
</section>
<!-- 需求⑨保存策略v3 友好单位小时/ -->
<section class="card">
<h3 class="card-title">{{ t('admin.settings.sectionSavePolicy') }}</h3>
<div class="field">
<label for="set-max-save-time">{{ t('admin.settings.maxSaveTime') }}</label>
<div class="unit-row">
<input
id="set-max-save-time"
v-model.number="saveTimeValue"
class="input"
type="number"
:min="0"
step="1"
:placeholder="t('admin.settings.saveTimeUnlimited')"
/>
<select v-model="saveTimeUnit" class="input unit-select" aria-label="unit">
<option value="hour">{{ t('admin.settings.unitHour') }}</option>
<option value="day">{{ t('admin.settings.unitDay') }}</option>
</select>
</div>
<p class="hint">
{{ t('admin.settings.maxSaveTimeHint') }}
<template v-if="saveTimeUnlimited">{{ t('admin.settings.saveTimeUnlimited') }}</template>
</p>
</div>
<div class="field">
<label for="set-max-save-count">{{ t('admin.settings.maxSaveCount') }}</label>
<input
id="set-max-save-count"
v-model.number="form.max_save_count"
class="input"
type="number"
:min="0"
:max="MAX_SAVE_COUNT_MAX"
step="1"
/>
<p class="hint">{{ t('admin.settings.maxSaveCountHint', { max: MAX_SAVE_COUNT_MAX }) }}</p>
</div>
</section>
<!-- 需求⑨存储策略v3 友好单位MB/GB+ 引擎卡 -->
<section class="card">
<h3 class="card-title">{{ t('admin.settings.sectionStorage') }}</h3>
<div class="field">
<label for="set-max-file-size">{{ t('admin.settings.maxFileSizeFriendly') }}</label>
<div class="unit-row">
<input
id="set-max-file-size"
v-model.number="fileSizeValue"
class="input"
type="number"
:min="0"
step="1"
:placeholder="t('admin.settings.sizeUnlimited')"
/>
<select v-model="fileSizeUnit" class="input unit-select" aria-label="unit">
<option value="MB">{{ t('admin.settings.unitMB') }}</option>
<option value="GB">{{ t('admin.settings.unitGB') }}</option>
</select>
</div>
<p class="hint">
{{ t('admin.settings.maxFileSizeHintV3', { fallback: formatBytes(config.uploadSize) }) }}
<template v-if="fileSizeUnlimited">{{ t('admin.settings.sizeUnlimited') }}</template>
<template v-else>{{ t('admin.settings.approxSize', { size: formatBytes(friendlyToBytes(fileSizeValue, fileSizeUnit)) }) }}</template>
</p>
</div>
<div class="field">
<label for="set-allowed-types">{{ t('admin.settings.allowedFileTypes') }}</label>
<input id="set-allowed-types" v-model="form.allowed_file_types" class="input" placeholder="jpg, png, pdf / image/*, *" />
<p class="hint">{{ t('admin.settings.allowedFileTypesHint') }}</p>
</div>
</section>
<!-- v3存储引擎类型三选 + 参数 + 热切换 -->
<section class="card">
<h3 class="card-title">{{ t('admin.settings.sectionEngine') }}</h3>
<div class="field">
<label>{{ t('admin.settings.engineCurrent') }}: <strong>{{ engineCurrent }}</strong></label>
</div>
<div class="field">
<label for="set-engine-type">{{ t('admin.settings.sectionEngine') }}</label>
<div class="unit-row">
<select id="set-engine-type" v-model="engineSelected" class="input" style="flex: 1">
<option value="local">{{ t('admin.settings.engineLocal') }}</option>
<option value="webdav">{{ t('admin.settings.engineWebdav') }}</option>
<option value="s3">{{ t('admin.settings.engineS3') }}</option>
</select>
<button class="btn" type="button" :disabled="engineSwitching" @click="switchEngine">
<span v-if="engineSwitching" class="spin" aria-hidden="true"></span>
{{ t('admin.settings.engineSwitch') }}
</button>
</div>
<p class="hint">{{ t('admin.settings.engineSwitchFail') }}</p>
</div>
<h4 class="card-sub" style="margin-top: 8px">{{ t('admin.settings.engineParamsTitle') }}</h4>
<!-- local 参数 -->
<div v-if="engineSelected === 'local'" class="field">
<label for="set-local-root">{{ t('admin.settings.localRoot') }}</label>
<input id="set-local-root" v-model="engineParams.local_storage_path" class="input" placeholder="/app/data/storage" />
<p class="hint">{{ t('admin.settings.localRootHint') }}</p>
</div>
<!-- webdav 参数 -->
<template v-if="engineSelected === 'webdav'">
<div class="field">
<label for="set-webdav-url">{{ t('admin.settings.webdavUrl') }}</label>
<input id="set-webdav-url" v-model="engineParams.webdav_url" class="input" placeholder="https://dav.example.com/dav/" />
<p class="hint">{{ t('admin.settings.webdavUrlHint') }}</p>
</div>
<div class="field">
<label for="set-webdav-root">{{ t('admin.settings.webdavRoot') }}</label>
<input id="set-webdav-root" v-model="engineParams.webdav_root_path" class="input" placeholder="filebox_storage" />
<p class="hint">{{ t('admin.settings.webdavRootHint') }}</p>
</div>
<div class="field-row">
<div class="field">
<label for="set-webdav-user">{{ t('admin.settings.webdavUser') }}</label>
<input id="set-webdav-user" v-model="engineParams.webdav_username" class="input" autocomplete="off" />
</div>
<div class="field">
<label for="set-webdav-pass">{{ t('admin.settings.webdavPass') }}</label>
<input id="set-webdav-pass" v-model="engineParams.webdav_password" class="input" type="password" autocomplete="new-password" />
<p class="hint">{{ t('admin.settings.secretKeepHint') }}</p>
</div>
</div>
</template>
<!-- s3 参数 -->
<template v-if="engineSelected === 's3'">
<div class="field-row">
<div class="field">
<label for="set-s3-endpoint">{{ t('admin.settings.s3Endpoint') }}</label>
<input id="set-s3-endpoint" v-model="engineParams.s3_endpoint_url" class="input" placeholder="https://s3.example.com:9000" />
<p class="hint">{{ t('admin.settings.s3EndpointHint') }}</p>
</div>
<div class="field">
<label for="set-s3-bucket">{{ t('admin.settings.s3Bucket') }}</label>
<input id="set-s3-bucket" v-model="engineParams.s3_bucket_name" class="input" placeholder="filecodebox" />
</div>
</div>
<div class="field-row">
<div class="field">
<label for="set-s3-region">{{ t('admin.settings.s3Region') }}</label>
<input id="set-s3-region" v-model="engineParams.s3_region_name" class="input" placeholder="auto" />
</div>
<div class="field">
<label for="set-s3-style">{{ t('admin.settings.s3Style') }}</label>
<select id="set-s3-style" v-model="engineParams.s3_addressing_style" class="input">
<option value="auto">{{ t('admin.settings.styleAuto') }}</option>
<option value="path">{{ t('admin.settings.stylePath') }}</option>
<option value="virtual">{{ t('admin.settings.styleVirtual') }}</option>
</select>
</div>
</div>
<div class="field-row">
<div class="field">
<label for="set-s3-ak">{{ t('admin.settings.s3Ak') }}</label>
<input id="set-s3-ak" v-model="engineParams.s3_access_key_id" class="input" autocomplete="off" />
</div>
<div class="field">
<label for="set-s3-sk">{{ t('admin.settings.s3Sk') }}</label>
<input id="set-s3-sk" v-model="engineParams.s3_secret_access_key" class="input" type="password" autocomplete="new-password" />
<p class="hint">{{ t('admin.settings.secretKeepHint') }}</p>
</div>
</div>
<div class="field">
<label for="set-s3-token">{{ t('admin.settings.s3Token') }}</label>
<input id="set-s3-token" v-model="engineParams.aws_session_token" class="input" type="password" autocomplete="new-password" />
<p class="hint">{{ t('admin.settings.secretKeepHint') }}</p>
</div>
</template>
<div class="save-row">
<button class="btn" type="button" :disabled="engineParamsSaving" @click="saveEngineParams">
<span v-if="engineParamsSaving" class="spin" aria-hidden="true"></span>
{{ t('admin.settings.engineParamsSave') }}
</button>
</div>
</section>
<!-- 需求⑨:上传频率限制 -->
<section class="card">
<h3 class="card-title">{{ t('admin.settings.sectionUploadRate') }}</h3>
<div class="field-row">
<div class="field">
<label for="set-upload-count">{{ t('admin.settings.uploadCount') }}</label>
<input
id="set-upload-count"
v-model.number="form.uploadCount"
class="input"
type="number"
:min="1"
:max="UPLOAD_COUNT_MAX"
step="1"
/>
<p class="hint">{{ t('admin.settings.uploadCountHint', { max: UPLOAD_COUNT_MAX }) }}</p>
</div>
<div class="field">
<label for="set-upload-minute">{{ t('admin.settings.uploadMinute') }}</label>
<input
id="set-upload-minute"
v-model.number="form.uploadMinute"
class="input"
type="number"
:min="1"
:max="UPLOAD_MINUTE_MAX"
step="1"
/>
<p class="hint">{{ t('admin.settings.uploadMinuteHint', { max: UPLOAD_MINUTE_MAX }) }}</p>
</div>
</div>
<!-- 26.9上下行带宽与上传次数限制并列 -->
<div class="field-row" style="margin-top: 14px">
<div class="field">
<label for="set-upload-rate">{{ t('admin.settings.uploadRate') }}</label>
<div class="unit-row">
<input
id="set-upload-rate"
v-model.number="form.uploadRate"
class="input"
type="number"
:min="0"
:max="RATE_MAX_MB"
step="1"
/>
<span class="unit-suffix">MB/s</span>
</div>
<p class="hint">{{ t('admin.settings.uploadRateHint') }}</p>
</div>
<div class="field">
<label for="set-download-rate">{{ t('admin.settings.downloadRate') }}</label>
<div class="unit-row">
<input
id="set-download-rate"
v-model.number="form.downloadRate"
class="input"
type="number"
:min="0"
:max="RATE_MAX_MB"
step="1"
/>
<span class="unit-suffix">MB/s</span>
</div>
<p class="hint">{{ t('admin.settings.downloadRateHint') }}</p>
</div>
</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>
{{ t('admin.settings.saveAll') }}
</button>
</div>
<section class="card">
<h3 class="card-title">{{ t('admin.settings.sectionPassword') }}</h3>
<p class="card-sub">{{ t('admin.settings.passwordHint') }}</p>
<form @submit.prevent="savePassword">
<div class="field">
<label for="pwd-old">{{ t('admin.settings.oldPassword') }}</label>
<input id="pwd-old" v-model="pwdForm.old" class="input" type="password" autocomplete="current-password" />
</div>
<div class="field">
<label for="pwd-new">{{ t('admin.settings.newPassword') }}</label>
<input id="pwd-new" v-model="pwdForm.next" class="input" type="password" autocomplete="new-password" />
</div>
<div class="field">
<label for="pwd-confirm">{{ t('admin.settings.confirmPassword') }}</label>
<input id="pwd-confirm" v-model="pwdForm.confirm" class="input" type="password" autocomplete="new-password" />
</div>
<div class="save-row">
<button class="btn" type="submit" :disabled="pwdSaving">
<span v-if="pwdSaving" class="spin" aria-hidden="true"></span>
{{ t('common.save') }}
</button>
</div>
</form>
</section>
</form>
</div>
</template>
<style scoped>
.settings-grid { display: grid; gap: 16px; }
.save-row { display: flex; justify-content: flex-end; }
.notify-switch-row { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
.notify-switch-row label { margin-bottom: 0; }
.unit-row { display: flex; gap: 8px; align-items: center; }
.unit-row .input { flex: 1; }
.unit-select { flex: 0 0 110px !important; }
</style>