Files
FileShare/web/src/views/admin/SettingsView.vue
T
SKYMirror 9686fe887a FileCodeBox Go 重写版 v2.5.6(安全审计修复版)
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

785 lines
34 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 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,
})
// —— 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)
// 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),
})
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
// 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>
</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>