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 全绿;二进制端到端冒烟通过
This commit is contained in:
@@ -0,0 +1,784 @@
|
||||
<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,否则 MB(0 归 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>
|
||||
Reference in New Issue
Block a user